diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5c862e6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Tests + +on: + push: + branches: [master, main, new_version] + pull_request: + # Runs the unpinned job below, so a new release of a dependency is found on a + # schedule instead of on an unrelated push. + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install dependencies + # Constrained so that a dependency release cannot turn this red without a commit + # here. The pins only apply to CI; setup.py stays open for anyone installing the + # package. See ci/constraints-py*.txt for how to move them forward. + run: | + python -m pip install --upgrade pip + pip install -e .[test] -c ci/constraints-py${{ matrix.python-version }}.txt + - name: Run tests + run: pytest -v --cov=cell2cell --cov-report=term-missing + + latest-dependencies: + # The same suite with nothing pinned, to find out when the ecosystem moves ahead of + # the constraints. A failure here means a dependency needs attention, not that the + # commit is broken, which is why it does not run on pushes or pull requests. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install the newest dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + pip list + - name: Run tests + run: pytest -v diff --git a/README.md b/README.md index 36b4873..15be95a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ pip install cell2cell | cell2cell Examples | Tensor-cell2cell Examples | | --- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ![cell2cell Logo](https://github.com/earmingol/cell2cell/blob/master/Logo.png?raw=true) | ![Tensor-cell2cell Logo](https://github.com/earmingol/cell2cell/blob/master/LogoTensor.png?raw=true) | -| - [Step-by-step Pipeline](https://github.com/earmingol/cell2cell/blob/master/examples/cell2cell/Toy-Example.ipynb)
- [Interaction Pipeline for Bulk Data](https://earmingol.github.io/cell2cell/tutorials/Toy-Example-BulkPipeline)
- [Interaction Pipeline for Single-Cell Data](https://earmingol.github.io/cell2cell/tutorials/Toy-Example-SingleCellPipeline)
- [Whole Body of *C. elegans*](https://github.com/LewisLabUCSD/Celegans-cell2cell) | - [Obtaining patterns of cell-cell communication](https://earmingol.github.io/cell2cell/tutorials/ASD/01-Tensor-Factorization-ASD/)
- [Downstream 1: Factor-specific analyses](https://earmingol.github.io/cell2cell/tutorials/ASD/02-Factor-Specific-ASD/)
- [Downstream 2: Patterns to functions (GSEA)](https://earmingol.github.io/cell2cell/tutorials/ASD/03-GSEA-ASD/)
- [Tensor-cell2cell in Google Colab (**GPU**)](https://colab.research.google.com/drive/1T6MUoxafTHYhjvenDbEtQoveIlHT2U6_?usp=sharing)
- [Communication patterns in **Spatial Transcriptomics**](https://earmingol.github.io/cell2cell/tutorials/Tensor-cell2cell-Spatial/)
- [Multi-modal communication patterns with **Coupled Tensor Component Analysis**](https://earmingol.github.io/cell2cell/tutorials/Version2/Tensor-cell2cell-CTCA/) | +| - [Step-by-step Pipeline](https://github.com/earmingol/cell2cell/blob/master/examples/cell2cell/Toy-Example.ipynb)
- [Interaction Pipeline for Bulk Data](https://earmingol.github.io/cell2cell/tutorials/Toy-Example-BulkPipeline)
- [Interaction Pipeline for Single-Cell Data](https://earmingol.github.io/cell2cell/tutorials/Toy-Example-SingleCellPipeline)
- [Whole Body of *C. elegans*](https://github.com/LewisLabUCSD/Celegans-cell2cell)
- [Identifying a **spatial code** of ligand-receptor pairs (Genetic Algorithm)](https://earmingol.github.io/cell2cell/tutorials/Genetic-Algorithm-LR-Selection) | - [Obtaining patterns of cell-cell communication](https://earmingol.github.io/cell2cell/tutorials/ASD/01-Tensor-Factorization-ASD/)
- [Downstream 1: Factor-specific analyses](https://earmingol.github.io/cell2cell/tutorials/ASD/02-Factor-Specific-ASD/)
- [Downstream 2: Patterns to functions (GSEA)](https://earmingol.github.io/cell2cell/tutorials/ASD/03-GSEA-ASD/)
- [Tensor-cell2cell in Google Colab (**GPU**)](https://colab.research.google.com/drive/1T6MUoxafTHYhjvenDbEtQoveIlHT2U6_?usp=sharing)
- [Communication patterns in **Spatial Transcriptomics**](https://earmingol.github.io/cell2cell/tutorials/Tensor-cell2cell-Spatial/)
- [Multi-modal communication patterns with **Coupled Tensor Component Analysis**](https://earmingol.github.io/cell2cell/tutorials/Version2/Tensor-cell2cell-CTCA/) | Reproducible runs of the analyses in the [Tensor-cell2cell paper](https://doi.org/10.1038/s41467-022-31369-2) are available at [CodeOcean.com](https://doi.org/10.24433/CO.0051950.v2) diff --git a/cell2cell/__init__.py b/cell2cell/__init__.py index 3cab73b..e534ceb 100644 --- a/cell2cell/__init__.py +++ b/cell2cell/__init__.py @@ -13,4 +13,4 @@ from cell2cell import tensor from cell2cell import utils -__version__ = "0.8.4" +__version__ = "0.9.0" diff --git a/cell2cell/analysis/__init__.py b/cell2cell/analysis/__init__.py index d46a775..1b77e62 100644 --- a/cell2cell/analysis/__init__.py +++ b/cell2cell/analysis/__init__.py @@ -1,4 +1,8 @@ from cell2cell.analysis.cell2cell_pipelines import (initialize_interaction_space, BulkInteractions, SingleCellInteractions) +from cell2cell.analysis.genetic_algorithm import (optimize_lr_pairs, PreparedCCIScorer, + lr_selection_frequency, lr_cooccurrence, + consensus_from_cooccurrence, + consensus_from_frequency) from cell2cell.analysis.tensor_pipelines import (run_tensor_cell2cell_pipeline) import cell2cell.analysis.tensor_downstream as tensor_downstream diff --git a/cell2cell/analysis/cell2cell_pipelines.py b/cell2cell/analysis/cell2cell_pipelines.py index ac36fed..1c54600 100644 --- a/cell2cell/analysis/cell2cell_pipelines.py +++ b/cell2cell/analysis/cell2cell_pipelines.py @@ -5,6 +5,7 @@ import pandas as pd import scanpy import numpy as np +from natsort import natsorted from tqdm import tqdm from cell2cell.core import interaction_space as ispace @@ -462,6 +463,11 @@ class SingleCellInteractions: of a given gene. - 'average' : Computes the average gene expression among the single cells composing a cell type for a given gene. + - 'trimean' : Computes the Tukey's trimean of the gene expression among the + single cells composing a cell type for a given gene. It is a weighted + average of the median and the first and third quartiles + (0.5 * Q2 + 0.25 * (Q1 + Q3)), so it is more robust to outliers than + the average while still accounting for the spread of the distribution. barcode_col : str, default='barcodes' Column-name for the single cells in the metadata. @@ -609,6 +615,11 @@ class SingleCellInteractions: of a given gene. - 'average' : Computes the average gene expression among the single cells composing a cell type for a given gene. + - 'trimean' : Computes the Tukey's trimean of the gene expression among the + single cells composing a cell type for a given gene. It is a weighted + average of the median and the first and third quartiles + (0.5 * Q2 + 0.25 * (Q1 + Q3)), so it is more robust to outliers than + the average while still accounting for the spread of the distribution. ccc_permutation_pvalues : pandas.DataFrame Contains the P-values of the permutation analysis on the @@ -935,7 +946,7 @@ def initialize_interaction_space(rnaseq_data, ppi_data, cutoff_setup, analysis_s if excluded_cells is None: excluded_cells = [] - included_cells = sorted(list((set(rnaseq_data.columns) - set(excluded_cells)))) + included_cells = natsorted(set(rnaseq_data.columns) - set(excluded_cells)) interaction_space = ispace.InteractionSpace(rnaseq_data=rnaseq_data[included_cells], ppi_data=ppi_data, diff --git a/cell2cell/analysis/genetic_algorithm.py b/cell2cell/analysis/genetic_algorithm.py new file mode 100644 index 0000000..c7cfbae --- /dev/null +++ b/cell2cell/analysis/genetic_algorithm.py @@ -0,0 +1,1091 @@ +# -*- coding: utf-8 -*- + +'''Selection of ligand-receptor pairs with a genetic algorithm. + +This module reimplements, as part of the package, the analysis in +https://github.com/LewisLabUCSD/Celegans-cell2cell (`code/genetic_algorithm.py`), +which searches for the subset of ligand-receptor pairs whose cell-cell interaction +scores best reproduce a reference distance between cells, for example the physical +distances measured in a 3D map. + +Reference +--------- +Armingol E, Ghaddar A, Joshi CJ, Baghdassarian H, Shamie I, Chan J, et al. (2022) +Inferring a spatial code of cell-cell interactions across a whole animal body. +PLOS Computational Biology 18(11): e1010715. +https://doi.org/10.1371/journal.pcbi.1010715 + +The objective function is the one used there: the absolute Spearman correlation +between the CCI distance matrix and the reference distance matrix. The original +implementation used `pyevolve`, which only supports Python 2; this one uses +`pygad`, and evaluates the objective in a vectorized way that is orders of +magnitude faster (see `optimize_lr_pairs` for the details). +''' + +from __future__ import absolute_import + +import warnings + +import numpy as np +import pandas as pd +import scipy.spatial +import scipy.stats + +from types import ModuleType + +from cell2cell.clustering.cluster_interactions import get_clusters_from_linkage +from cell2cell.core.interaction_space import InteractionSpace +from cell2cell.preprocessing.manipulate_dataframes import check_symmetry +from cell2cell.preprocessing.ppi import bidirectional_ppi_for_cci, remove_ppi_bidirectionality + + +# CCI scores whose value is a function of three quantities that are linear in the +# PPI weights, which is what makes the vectorized objective possible. See +# `PreparedCCIScorer` for the derivation. +LINEAR_CCI_SCORES = ('bray_curtis', 'jaccard', 'count', 'icellnet') + +# Scores that are not bounded between 0 and 1, and whose distance matrix is +# therefore computed with the regularized formula in `InteractionSpace`. +UNBOUNDED_CCI_SCORES = ('count', 'icellnet') + + +def _check_if_pygad() -> ModuleType: + try: + import pygad + + except Exception: + raise ImportError('pygad is not installed. Please install it with: ' + 'pip install pygad' + ) + return pygad + + +class PreparedCCIScorer: + ''' + Precomputes the expression-dependent part of the CCI scores, so that scoring + the same cells again under a different set of PPI weights becomes a matrix + multiplication instead of a rebuild of the interaction space. + + Every CCI score in `cell2cell.core.cci_scores` is a function of three + quantities, each of which is a weighted sum over the PPIs and therefore + **linear in the PPI weights** `w`: + + .. code-block:: text + + N(i, j) = sum_k w_k * A_ki * B_kj (the ligand-receptor product) + SA(i) = sum_k w_k * A_ki^2 + SB(j) = sum_k w_k * B_kj^2 + + bray_curtis = 2 * N / (SA + SB) + jaccard = N / (SA + SB - N) + icellnet = N + count = sum_k [w_k * A_ki * B_kj != 0] + + Since `A` and `B` depend only on the expression data, they can be computed + once and reused for any number of weight vectors. Scoring a whole population + of weight vectors is then a single matrix product. + + Parameters + ---------- + interaction_space : cell2cell.core.interaction_space.InteractionSpace + A built interaction space. Only its expression-derived matrices are used; + it is not modified. + + cci_score : str, default=None + CCI score to compute. If None, the one the interaction space was built + with is used. Must be one of 'bray_curtis', 'jaccard', 'count' or + 'icellnet'. + + max_memory_mb : float, default=512 + Budget for the precomputed ligand-receptor outer products, which are what + make the batched path possible. If the array would exceed this, only the + per-vector path is prepared, which is still much faster than rebuilding + the interaction space but does not gain from batching. + + Attributes + ---------- + A, B : numpy.ndarray + Weighted ligand and receptor expression, of shape (PPIs, cells). + + cell_names : list + Cell names, in the order they have in the interaction space. Rows and + columns of every returned matrix follow this order. + + batched : boolean + Whether the precomputed outer products fitted in `max_memory_mb`. + ''' + + def __init__(self, interaction_space, cci_score=None, max_memory_mb=512): + if cci_score is None: + cci_score = interaction_space.cci_score + if cci_score not in LINEAR_CCI_SCORES: + raise NotImplementedError( + "'{}' is not supported by the vectorized scorer. Use one of {}, or " + "pass fast=False to fall back to the reference implementation." + .format(cci_score, list(LINEAR_CCI_SCORES))) + + self.cci_score = cci_score + self.cci_type = interaction_space.cci_type + self.cell_names = list(interaction_space.interaction_elements['cell_names']) + + cells = interaction_space.interaction_elements['cells'] + self.A = np.column_stack([cells[c].weighted_ppi['A'].values for c in self.cell_names]) + self.B = np.column_stack([cells[c].weighted_ppi['B'].values for c in self.cell_names]) + + # `nansum` in the scalar scores treats missing values as zero. Doing the + # substitution once here reproduces that without a NaN-aware reduction in + # the inner loop. + self._has_nans = bool(np.isnan(self.A).any() or np.isnan(self.B).any()) + self.A = np.nan_to_num(self.A) + self.B = np.nan_to_num(self.B) + + self.n_ppi, self.n_cells = self.A.shape + self._A2 = self.A * self.A + self._B2 = self.B * self.B + + # Outer product of every ligand-receptor pair, flattened over the two cell + # axes so the contraction over PPIs is a plain matrix product. + outer_mb = self.n_ppi * self.n_cells * self.n_cells * 8 / 1e6 + self.batched = outer_mb <= max_memory_mb + if self.batched: + outer = self.A[:, :, None] * self.B[:, None, :] + if self.cci_score == 'count': + # 'count' counts the non-zero products rather than adding them up, + # which is linear in a binary weight vector but not in a general one. + self._P = (outer != 0).astype(float).reshape(self.n_ppi, -1) + else: + self._P = outer.reshape(self.n_ppi, -1) + else: + self._P = None + + def _terms(self, W): + '''Computes N, SA and SB for a stack of weight vectors W of shape (n, PPIs).''' + SA = W @ self._A2 + SB = W @ self._B2 + if self.batched: + N = (W @ self._P).reshape(-1, self.n_cells, self.n_cells) + else: + N = np.stack([(self.A * w[:, None]).T @ self.B for w in W]) + return N, SA, SB + + def _combine(self, N, SA, SB): + '''Applies the score-specific formula. Shapes: N (n, C, C), SA/SB (n, C).''' + if self.cci_score == 'icellnet' or self.cci_score == 'count': + return N + + denominator = SA[:, :, None] + SB[:, None, :] + if self.cci_score == 'jaccard': + denominator = denominator - N + + with np.errstate(divide='ignore', invalid='ignore'): + if self.cci_score == 'bray_curtis': + scores = np.divide(2.0 * N, denominator) + else: + scores = np.divide(N, denominator) + # The scalar implementations return 0.0 when the denominator is zero + scores[denominator == 0.0] = 0.0 + return scores + + def score_batch(self, W): + ''' + Computes the CCI matrix for each of several PPI weight vectors. + + Parameters + ---------- + W : numpy.ndarray + Weights, of shape (n, PPIs). One row per weight vector. + + Returns + ------- + scores : numpy.ndarray + CCI matrices, of shape (n, cells, cells). Rows and columns follow + `self.cell_names`. For an undirected interaction space the matrices + are symmetrized the same way `compute_pairwise_cci_scores` does, by + mirroring the upper triangle. + ''' + W = np.atleast_2d(np.asarray(W, dtype=float)) + if W.shape[1] != self.n_ppi: + raise ValueError('Expected weight vectors of length {}, got {}' + .format(self.n_ppi, W.shape[1])) + + if self.batched and self.cci_score == 'count' and not np.isin(W, (0.0, 1.0)).all(): + raise ValueError("The 'count' score is only vectorized for binary weights, " + "because it counts non-zero products rather than adding them.") + + scores = self._combine(*self._terms(W)) + + if self.cci_type == 'undirected': + # `generate_pairs` yields the upper triangle plus the diagonal, and the + # scoring loop mirrors each value. The lower triangle of the directed + # result is therefore never used. + upper = np.triu(scores) + scores = upper + np.triu(scores, k=1).transpose(0, 2, 1) + return scores + + def score(self, ppi_score): + ''' + Computes the CCI matrix for a single PPI weight vector. + + Parameters + ---------- + ppi_score : array-like + Weights, one per PPI. + + Returns + ------- + cci_matrix : pandas.DataFrame + CCI scores, with cells as rows and columns. + ''' + scores = self.score_batch(np.asarray(ppi_score, dtype=float)[None, :])[0] + return pd.DataFrame(scores, index=self.cell_names, columns=self.cell_names) + + def distance_batch(self, W): + ''' + Computes the distance matrix for each of several PPI weight vectors, + reproducing what `InteractionSpace.compute_pairwise_cci_scores` derives. + + Bounded scores use `1 - score`; the unbounded ones ('count', 'icellnet') + use the regularized `1 - score / (score + mean)`, where the mean is taken + over the whole CCI matrix of that weight vector. The diagonal is zeroed. + + Parameters + ---------- + W : numpy.ndarray + Weights, of shape (n, PPIs). + + Returns + ------- + distances : numpy.ndarray + Distance matrices, of shape (n, cells, cells). + ''' + scores = self.score_batch(W) + if self.cci_score in UNBOUNDED_CCI_SCORES: + means = np.nanmean(scores, axis=(1, 2))[:, None, None] + with np.errstate(divide='ignore', invalid='ignore'): + distances = 1.0 - np.divide(scores, scores + means) + else: + distances = 1.0 - scores + + idx = np.arange(self.n_cells) + distances[:, idx, idx] = 0.0 + return distances + + +def _reference_distance_matrix(interaction_space, ppi_score, cells): + '''Distance matrix through the unmodified `InteractionSpace` code path.''' + interaction_space.ppi_data['score'] = np.asarray(ppi_score, dtype=float) + interaction_space.interaction_elements['ppi_score'] = interaction_space.ppi_data['score'].values + interaction_space.compute_pairwise_cci_scores(use_ppi_score=True, verbose=False) + return interaction_space.distance_matrix.loc[cells, cells] + + +def _bidirectional_index(ppi_data, interaction_columns=('A', 'B'), verbose=False): + ''' + Maps every row of the bidirectional PPI table back to the row of `ppi_data` + it came from. + + `bidirectional_ppi_for_cci` duplicates every interaction with its partners + swapped and then drops duplicates, which collapses self-interactions back to a + single copy. Rather than reimplementing that, this runs it once on a table + whose score column holds each row's position, and reads the positions back. + + Returns + ------- + source : numpy.ndarray + For each row of the bidirectional table, the index of the row of + `ppi_data` it originates from. + ''' + probe = ppi_data.copy() + probe['score'] = np.arange(len(probe), dtype=float) + bi_probe = bidirectional_ppi_for_cci(ppi_data=probe, + interaction_columns=interaction_columns, + verbose=verbose) + source = bi_probe['score'].values.astype(int) + + # `drop_duplicates` acts on (A, B, score), so if the table still contains a pair + # and its reciprocal, two rows that differ only by score stop being duplicates and + # the bidirectional table changes length with the weights. The mapping is then not + # well defined -- and neither is assigning that column to a fixed interaction space. + # The interaction space is built from the all-ones table, so that one has to + # match too, not just an arbitrary binary vector. + rng = np.random.default_rng(0) + probes = [np.ones(len(ppi_data)), + (np.arange(len(ppi_data)) % 2).astype(float), + rng.integers(0, 2, size=len(ppi_data)).astype(float)] + lengths = {len(bidirectional_ppi_for_cci(ppi_data=ppi_data.assign(score=p), + interaction_columns=interaction_columns, + verbose=verbose)) + for p in probes} + if lengths != {len(source)}: + raise ValueError( + 'The number of bidirectional interactions depends on the weights, so a ' + 'ligand-receptor pair cannot be mapped onto a fixed set of rows. This ' + 'happens when `ppi_data` holds a pair and its reciprocal as separate rows, ' + 'or the exact same pair more than once. Deduplicate it first -- ' + 'cell2cell.preprocessing.remove_ppi_bidirectionality() followed by ' + 'drop_duplicates() on the interaction columns.') + return source + + +def _correlation(distance_vector, reference_vector, method='spearman'): + if method == 'spearman': + corr = scipy.stats.spearmanr(distance_vector, reference_vector)[0] + elif method == 'pearson': + corr = scipy.stats.pearsonr(distance_vector, reference_vector)[0] + else: + raise ValueError("`method` must be either 'spearman' or 'pearson'") + return abs(np.nan_to_num(corr)) + + +def lr_selection_frequency(selection_masks): + ''' + Fraction of independent genetic-algorithm executions that selected each pair. + + Parameters + ---------- + selection_masks : array-like + Binary matrix of shape (executions, LR pairs). One row per independent run + of the genetic algorithm, holding the 0/1 mask it converged to. + + Returns + ------- + frequency : numpy.ndarray + Value in [0, 1] per ligand-receptor pair. + ''' + masks = np.atleast_2d(np.asarray(selection_masks, dtype=float)) + return np.nansum(masks, axis=0) / masks.shape[0] + + +def lr_cooccurrence(selection_masks, labels=None): + ''' + Co-occurrence of ligand-receptor pairs across independent genetic-algorithm runs. + + Two pairs co-occur when the same run selected both. The value reported is the + Jaccard index of their selection patterns -- the number of runs that selected + both, divided by the number that selected either -- so a pair that is chosen + rarely can still co-occur strongly with another it is always chosen alongside. + + Parameters + ---------- + selection_masks : array-like + Binary matrix of shape (executions, LR pairs), one row per independent run. + + labels : list, default=None + Names for the ligand-receptor pairs, used as the index and columns of the + result. If None, positional integers are used. + + Returns + ------- + cooccurrence : pandas.DataFrame + Symmetric matrix of Jaccard indexes, with ones on the diagonal for pairs + selected at least once and zeros for pairs never selected. + ''' + masks = np.atleast_2d(np.asarray(selection_masks)).astype(bool) + intersection = (masks.astype(float).T @ masks.astype(float)) + counts = masks.sum(axis=0) + union = counts[:, None] + counts[None, :] - intersection + + with np.errstate(divide='ignore', invalid='ignore'): + cooccurrence = np.divide(intersection, union) + # A pair of LRs that no run ever selected has an empty union + cooccurrence[union == 0] = 0.0 + + if labels is None: + labels = list(range(masks.shape[1])) + return pd.DataFrame(cooccurrence, index=labels, columns=labels) + + +def consensus_from_cooccurrence(cooccurrence, n_clusters=2, method='ward', + select='cooccurrence', frequency=None, min_frequency=0.0): + ''' + Picks the group of ligand-receptor pairs that keep being selected together. + + Clusters the co-occurrence matrix and returns one cluster: the tight group of + pairs that are chosen alongside each other across independent runs of the + genetic algorithm. This is how the published selection for *C. elegans* was + produced. + + Parameters + ---------- + cooccurrence : pandas.DataFrame + Square co-occurrence matrix, as returned by `lr_cooccurrence`. Pairs that no + run ever selected (an all-zero row and column) are dropped first. + + n_clusters : int, default=2 + Number of clusters to cut the dendrogram into. + + method : str, default='ward' + Linkage method. + + frequency : array-like, default=None + Fraction of runs that selected each pair, aligned with `cooccurrence`. Only + needed for `min_frequency`. + + min_frequency : float, default=0.0 + Drop pairs selected in fewer than this fraction of runs before clustering. + Two pairs selected once, in the same run, have a Jaccard index of 1.0 even + though a single run is no evidence that they belong together, and with few + runs a cluster of such pairs can outscore the genuinely reproducible one. + This removes them, but it is not a substitute for running the search enough + times: **the clustering needs on the order of 30 or more runs to be stable**, + and below that the frequency route is the more reliable of the two. Requires + `frequency`. + + select : str, default='cooccurrence' + Which cluster to return. + + - 'cooccurrence' : the one with the highest mean co-occurrence among its own + members, i.e. the most consistently co-selected group. This is the + intent of the analysis and does not depend on cluster sizes. + - 'smallest' : the one with fewest members. This is literally what the + reference notebook did, and on that data it is also the highest- + co-occurrence one; it is kept for exact reproducibility. + + Returns + ------- + selected : list + Labels of the pairs in the chosen cluster. + + clusters : dict + Every cluster, keyed by its scipy cluster id, so the others can be inspected. + + scores : dict + Mean intra-cluster co-occurrence per cluster id, which is what `select` + ranks on. + ''' + import scipy.cluster.hierarchy as hc + from sklearn.metrics import pairwise_distances + + keep = (cooccurrence != 0).any(axis=0) + if min_frequency > 0.0: + if frequency is None: + raise ValueError('`frequency` is required when `min_frequency` is set') + frequency = np.asarray(frequency, dtype=float) + if len(frequency) != cooccurrence.shape[0]: + raise ValueError('`frequency` must have one value per pair in `cooccurrence`') + keep = keep & (frequency >= min_frequency) + data = cooccurrence.loc[keep, keep] + if data.shape[0] < n_clusters: + raise ValueError('Only {} pairs passed the filters, which is fewer than the {} ' + 'clusters requested. Lower `min_frequency`, or run more ' + 'executions.'.format(data.shape[0], n_clusters)) + + # Distances between co-occurrence profiles, then Ward on those. `hc.linkage` + # reads a square array as observations x features, so it clusters the rows of + # the distance matrix -- which is what the reference analysis did. + distances = pairwise_distances(data.values) + with warnings.catch_warnings(): + # scipy notices that a square hollow matrix was passed where it usually takes + # a condensed one, and warns. That is what is meant here: the rows of the + # distance matrix are the observations, matching the reference analysis. + warnings.simplefilter('ignore', hc.ClusterWarning) + linkage = hc.linkage(distances, method=method, optimal_ordering=True) + + clusters = get_clusters_from_linkage(linkage, n_clusters, criterion='maxclust', + labels=list(data.index)) + + # Mean co-occurrence between distinct members of each cluster + scores = {} + for key, members in clusters.items(): + if len(members) < 2: + scores[key] = 0.0 + continue + block = data.loc[members, members].values + off_diagonal = block[~np.eye(len(members), dtype=bool)] + scores[key] = float(np.nanmean(off_diagonal)) + + if select == 'cooccurrence': + chosen = max(scores, key=lambda k: scores[k]) + elif select == 'smallest': + chosen = min(clusters, key=lambda k: len(clusters[k])) + else: + raise ValueError("`select` must be either 'cooccurrence' or 'smallest'") + return clusters[chosen], clusters, scores + + +def consensus_from_frequency(frequency, percentile=90): + ''' + Keeps the ligand-receptor pairs selected most often across independent runs. + + The simpler alternative to `consensus_from_cooccurrence`: rather than asking + which pairs are chosen *together*, it asks which are chosen *often*. Cheaper to + reason about, but it cannot separate two groups of pairs that are each + self-consistent yet rarely co-selected. + + Parameters + ---------- + frequency : array-like + Fraction of runs that selected each pair, from `lr_selection_frequency`. + + percentile : float, default=90 + Percentile of the frequency distribution used as the cutoff. Pairs strictly + above it are kept. The reference analysis used the 90th percentile. + + Returns + ------- + mask : numpy.ndarray + Boolean, True for the pairs that are kept. + + threshold : float + The cutoff value. + ''' + values = np.asarray(frequency, dtype=float) + threshold = float(np.percentile(values, percentile)) + return values > threshold, threshold + + + +def _optimize_once(rnaseq_data, ppi_data, reference_distances, cutoff_setup, analysis_setup, + included_cells=None, population_size=200, generations=200, runs=None, + inc_percentage=0.025, max_runs=100, correlation='spearman', + mutation_probability=0.05, keep_elitism=1, random_state=None, + interaction_columns=('A', 'B'), complex_sep=None, complex_agg_method='min', + fast=True, validate_fast=True, max_memory_mb=512, deduplicate=True, + verbose=False): + ''' + Selects the subset of ligand-receptor pairs whose cell-cell interaction scores + best reproduce a reference distance between cells, using a genetic algorithm. + + Each individual is a binary vector with one entry per ligand-receptor pair, + indicating whether it is included. The objective function is the absolute + Spearman correlation between the resulting CCI distance matrix and + `reference_distances`, as in Armingol et al. (2022) on the whole body of *C. elegans*. + + The search is repeated in successive runs: each run keeps only the pairs that + the previous one selected, so the set shrinks until the objective stops + improving by at least `inc_percentage`. + + Parameters + ---------- + rnaseq_data : pandas.DataFrame + Gene expression matrix, with genes as rows and cells as columns. + + ppi_data : pandas.DataFrame + List of ligand-receptor pairs. A 'score' column is added if missing. + + reference_distances : pandas.DataFrame + Square, symmetric matrix of reference distances between cells, for example + physical distances. Rows and columns are cell names. + + cutoff_setup : dict + Cutoff setup, as in `cell2cell.analysis.initialize_interaction_space`. + + analysis_setup : dict + Analysis setup with the keys 'communication_score', 'cci_score' and + 'cci_type', as in `cell2cell.analysis.initialize_interaction_space`. + `cci_type` must be 'undirected', since the objective compares the + condensed form of a symmetric distance matrix. + + included_cells : list, default=None + Cells to consider. If None, the cells present in both `rnaseq_data` and + `reference_distances` are used. + + population_size : int, default=200 + Number of individuals per generation. + + generations : int, default=200 + Number of generations per run. + + runs : int, default=None + Number of runs. If None, runs continue until the objective improves by + less than `inc_percentage` with respect to the previous run, or until + `max_runs` is reached. + + inc_percentage : float, default=0.025 + Minimum relative improvement of the objective for another run to start. + Only used when `runs` is None. + + max_runs : int, default=100 + Upper bound on the number of runs when `runs` is None. + + correlation : str, default='spearman' + Correlation between the CCI distances and the reference distances, either + 'spearman' or 'pearson'. + + mutation_probability : float, default=0.05 + Probability of flipping each gene, equivalent to the flip mutator of the + original implementation. + + keep_elitism : int, default=1 + Number of best individuals carried over to the next generation. + + random_state : int, default=None + Seed for reproducibility. + + interaction_columns : tuple, default=('A', 'B') + Columns of `ppi_data` holding the ligands and the receptors. + + complex_sep : str, default=None + Separator of the subunits of a protein complex, if any. + + complex_agg_method : str, default='min' + Method to aggregate the expression of the subunits of a complex. + + fast : boolean, default=True + Whether to evaluate the objective with `PreparedCCIScorer`, which is + equivalent but vectorized. If False, every individual is evaluated by + rebuilding the CCI scores through `InteractionSpace`, exactly as the + original implementation did. + + validate_fast : boolean, default=True + Whether to check the vectorized objective against the reference one on a + few random individuals before starting. Cheap, and it catches the case + where the two paths would disagree. + + max_memory_mb : float, default=512 + Memory budget for the precomputed ligand-receptor outer products. + + deduplicate : boolean, default=True + Whether to collapse each interaction and its reciprocal into a single row + with `remove_ppi_bidirectionality` before the search. This is required for + a pair to map onto a fixed set of bidirectional rows: `bidirectional_ppi_for_cci` + drops duplicates on (A, B, score), so if both directions are present as + separate rows, the number of bidirectional rows depends on the candidate + solution. Pairs loaded with `cell2cell.io.load_ppi` are already deduplicated + by `preprocess_ppi_data`, so this is a no-op for them; it matters when the + table comes from somewhere else. Turn it off only if the input is already + deduplicated -- the ambiguous case is rejected either way. + Note that the returned masks are then indexed against the deduplicated + table, which is also what 'best_ppi_data' contains. + + verbose : boolean, default=False + Whether to print the progress of each run. + + Returns + ------- + results : dict + Dictionary with one entry per run, keyed 'run1', 'run2', ..., each with: + + - 'obj_fn' : the objective function of the best individual. + - 'ppi_data' : list of 0/1, one per row of the original `ppi_data`, + indicating the pairs selected in that run. + - 'drop_fraction' : fraction of the pairs available to that run that were + dropped. + - 'n_selected' : number of pairs selected. + + The dictionary also holds 'best_run', 'best_obj_fn' and 'best_ppi_data', + the last being a copy of `ppi_data` restricted to the selected pairs. + + Examples + -------- + >>> import cell2cell as c2c + >>> results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, + ... ppi_data=lr_pairs, + ... reference_distances=physical_distances, + ... cutoff_setup={'type': 'constant_value', + ... 'parameter': 10}, + ... analysis_setup={'communication_score': 'expression_thresholding', + ... 'cci_score': 'bray_curtis', + ... 'cci_type': 'undirected'}, + ... random_state=888) + >>> selected = results['best_ppi_data'] + ''' + pygad = _check_if_pygad() + + if analysis_setup['cci_type'] != 'undirected': + raise NotImplementedError("Only 'undirected' interactions are supported, because the " + "objective compares condensed symmetric distance matrices.") + + reference_distances = _as_symmetric(reference_distances) + + # Cells shared by the expression data and the reference distances + if included_cells is None: + included_cells = sorted(set(rnaseq_data.columns) & set(reference_distances.columns)) + included_cells = list(included_cells) + if len(included_cells) < 3: + raise ValueError('At least three cells are needed to correlate distances') + + reference_vector = scipy.spatial.distance.squareform( + np.asarray(reference_distances.loc[included_cells, included_cells].values, dtype=float), + checks=False) + + if deduplicate: + # Required for the pair-to-bidirectional-row mapping to be well defined; see + # `_bidirectional_index`. Also what the reference analysis effectively had, + # since its LR list held each interaction once. + ppi_data = remove_ppi_bidirectionality(ppi_data=ppi_data, + interaction_columns=interaction_columns, + verbose=verbose) + ppi_data = ppi_data.drop_duplicates(subset=list(interaction_columns)) + ppi_data = ppi_data.reset_index(drop=True) + + theta_ppi_data = ppi_data.copy() + if 'score' not in theta_ppi_data.columns: + theta_ppi_data = theta_ppi_data.assign(score=1.0) + + prot_a, prot_b = interaction_columns + results = dict() + run = 1 + previous_obj = None + + while True: + if runs is None: + if run > max_runs: + break + elif run > runs: + break + + # Each run searches only among the pairs the previous run kept + theta_ppi_data = theta_ppi_data.loc[theta_ppi_data['score'] == 1].reset_index(drop=True) + n_ppi = len(theta_ppi_data) + if n_ppi == 0: + break + + bi_ppi_data = bidirectional_ppi_for_cci(ppi_data=theta_ppi_data, + interaction_columns=interaction_columns, + verbose=verbose) + interaction_space = InteractionSpace(rnaseq_data=rnaseq_data[included_cells], + ppi_data=bi_ppi_data, + gene_cutoffs=cutoff_setup, + communication_score=analysis_setup['communication_score'], + cci_score=analysis_setup['cci_score'], + cci_type=analysis_setup['cci_type'], + complex_sep=complex_sep, + complex_agg_method=complex_agg_method, + interaction_columns=interaction_columns, + verbose=verbose) + + # Position in `theta_ppi_data` that each bidirectional row comes from, so a + # candidate solution can be expanded to the weights the scorer expects + source = _bidirectional_index(theta_ppi_data, + interaction_columns=interaction_columns, + verbose=verbose) + + space_cells = list(interaction_space.interaction_elements['cell_names']) + take = [space_cells.index(c) for c in included_cells] + + def reference_objective(theta): + weights = np.asarray(theta, dtype=float)[source] + distances = _reference_distance_matrix(interaction_space, weights, included_cells) + vector = scipy.spatial.distance.squareform(np.asarray(distances.values, dtype=float), + checks=False) + return _correlation(vector, reference_vector, method=correlation) + + use_fast = fast + if use_fast: + try: + scorer = PreparedCCIScorer(interaction_space, + cci_score=analysis_setup['cci_score'], + max_memory_mb=max_memory_mb) + except NotImplementedError: + if verbose: + print('Falling back to the reference objective for this CCI score') + use_fast = False + + if use_fast and scorer._has_nans and analysis_setup['cci_score'] == 'count': + # `count` treats a NaN product as active, which the substitution above + # does not reproduce. Only this score is affected. + use_fast = False + + if use_fast: + def batch_objective(THETA): + W = np.asarray(THETA, dtype=float)[:, source] + distances = scorer.distance_batch(W)[:, take][:, :, take] + out = np.empty(len(W)) + for n, d in enumerate(distances): + vector = scipy.spatial.distance.squareform(d, checks=False) + out[n] = _correlation(vector, reference_vector, method=correlation) + return out + + if validate_fast: + rng = np.random.default_rng(random_state) + probes = rng.integers(0, 2, size=(2, n_ppi)).astype(float) + fast_values = batch_objective(probes) + ref_values = np.array([reference_objective(p) for p in probes]) + if not np.allclose(fast_values, ref_values, rtol=1e-9, atol=1e-9): + raise RuntimeError( + 'The vectorized objective disagrees with the reference one ' + '({} vs {}). Please report this, and use fast=False meanwhile.' + .format(fast_values, ref_values)) + + def fitness_func(ga_instance, solution, solution_idx): + return float(batch_objective(np.atleast_2d(solution))[0]) + else: + def fitness_func(ga_instance, solution, solution_idx): + return float(reference_objective(solution)) + + ga = pygad.GA(num_generations=generations, + num_parents_mating=max(2, population_size // 2), + fitness_func=fitness_func, + sol_per_pop=population_size, + num_genes=n_ppi, + gene_type=int, + init_range_low=0, + init_range_high=2, + gene_space=[0, 1], + parent_selection_type='tournament', + keep_elitism=keep_elitism, + mutation_type='random', + mutation_probability=mutation_probability, + random_seed=random_state if random_state is None else random_state + run, + suppress_warnings=True, + ) + if use_fast: + # Evaluate the whole generation with one matrix product + ga.fitness_batch_size = population_size + + def batch_fitness(ga_instance, solutions, solutions_indices): + return list(batch_objective(np.atleast_2d(solutions))) + + ga.fitness_func = batch_fitness + + ga.run() + + best_solution, best_fitness, _ = ga.best_solution() + best = np.asarray(best_solution, dtype=int) + + theta_ppi_data['score'] = best.astype(float) + drop_fraction = 1.0 - best.sum() / len(best) + + # Map the selection back onto the rows of the original ppi_data + selected = theta_ppi_data.loc[theta_ppi_data['score'] == 1, [prot_a, prot_b]] + selected_pairs = set(map(tuple, selected.values)) + mask = [1 if tuple(row) in selected_pairs else 0 + for row in ppi_data[[prot_a, prot_b]].values] + + results['run{}'.format(run)] = {'obj_fn': float(best_fitness), + 'ppi_data': mask, + 'drop_fraction': float(drop_fraction), + 'n_selected': int(best.sum()), + } + if verbose: + print('Run {}: objective {:.4f}, {} of {} pairs kept' + .format(run, best_fitness, int(best.sum()), len(best))) + + if runs is None and previous_obj is not None: + if (best_fitness - previous_obj) / previous_obj < inc_percentage: + run += 1 + break + previous_obj = best_fitness + run += 1 + + if not results: + raise RuntimeError('The genetic algorithm produced no results') + + best_key = max(results, key=lambda k: results[k]['obj_fn']) + best_mask = np.asarray(results[best_key]['ppi_data'], dtype=bool) + results['best_run'] = best_key + results['best_obj_fn'] = results[best_key]['obj_fn'] + results['best_ppi_data'] = ppi_data.loc[best_mask].reset_index(drop=True) + return results + +def optimize_lr_pairs(rnaseq_data, ppi_data, reference_distances, cutoff_setup, analysis_setup, + executions=1, random_state=None, consensus_method='cooccurrence', + n_clusters=2, cluster_selection='cooccurrence', min_frequency=0.0, + frequency_percentile=90, verbose=False, **kwargs): + ''' + Selects ligand-receptor pairs whose cell-cell interaction scores best reproduce a + reference distance between cells, using a genetic algorithm. + + A genetic algorithm converges to a *local* optimum, so a single execution is not + conclusive: different seeds settle on different, largely overlapping sets of + pairs. With `executions > 1` the search is repeated independently and the results + are integrated the way the reference analysis did -- by how often each pair is + selected, and by which pairs are selected *together* -- which separates the + reproducible core from the noise of any one execution. + + Parameters + ---------- + rnaseq_data : pandas.DataFrame + Gene expression matrix, with genes as rows and cells as columns. + + ppi_data : pandas.DataFrame + List of ligand-receptor pairs. A 'score' column is added if missing. + + reference_distances : pandas.DataFrame + Square, symmetric matrix of reference distances between cells, for example + physical distances. Rows and columns are cell names. + + cutoff_setup : dict + Cutoff setup, as in `cell2cell.analysis.initialize_interaction_space`. + + analysis_setup : dict + Analysis setup with the keys 'communication_score', 'cci_score' and + 'cci_type'. `cci_type` must be 'undirected'. + + executions : int, default=1 + Number of independent runs of the genetic algorithm. Each uses a different + seed derived from `random_state`. With more than one, the consensus outputs + described below are added to the result. **Around 30 or more is needed for + the co-occurrence clustering to be stable**; the reference analysis used + about a hundred. The frequency route tolerates fewer. + + random_state : int, default=None + Seed. Execution *i* uses `random_state + i`, so the whole set is reproducible. + + consensus_method : str, default='cooccurrence' + How to integrate the executions when there is more than one. + + - 'cooccurrence' : cluster the pairs by how often they are selected + *together* and keep one cluster. This is what the reference analysis + did, and it is able to tell apart groups of pairs that are each + self-consistent. + - 'frequency' : keep the pairs selected most often, above + `frequency_percentile`. Simpler, and blind to which pairs go together. + + n_clusters : int, default=2 + Number of clusters to cut the co-occurrence dendrogram into. + + cluster_selection : str, default='cooccurrence' + Which cluster to keep: 'cooccurrence' for the one whose members co-occur + most with each other, or 'smallest' for the fewest members, which is + literally what the reference notebook did. See `consensus_from_cooccurrence`. + + min_frequency : float, default=0.0 + Drop pairs selected in fewer than this fraction of executions before building + the co-occurrence clusters, removing pairs that co-occur perfectly only + because they were each chosen once, in the same execution. Note the + co-occurrence route needs roughly 30 or more executions to be stable; with + fewer, prefer `consensus_method='frequency'`. See + `consensus_from_cooccurrence`. + + frequency_percentile : float, default=90 + Cutoff percentile when `consensus_method='frequency'`. + + verbose : boolean, default=False + Whether to print the progress of each execution. + + **kwargs + Passed to each individual search: `population_size`, `generations`, `runs`, + `inc_percentage`, `max_runs`, `correlation`, `mutation_probability`, + `keep_elitism`, `included_cells`, `interaction_columns`, `complex_sep`, + `complex_agg_method`, `fast`, `validate_fast`, `max_memory_mb` and + `deduplicate`. See `_optimize_once` for their meaning. + + Returns + ------- + results : dict + With a single execution, the result of that search: one entry per run + ('run1', 'run2', ...) with 'obj_fn', 'ppi_data' (a 0/1 mask over the rows of + `ppi_data`), 'drop_fraction' and 'n_selected', plus 'best_run', + 'best_obj_fn' and 'best_ppi_data'. + + With several executions, the same keys report the single best execution, and + these are added: + + - 'executions' : the full result of each execution, keyed 'execution1', ... + - 'selection_masks' : binary array of shape (executions, LR pairs), the mask + each execution converged to. + - 'selection_frequency' : dataframe of the pairs with the fraction of + executions that selected each one. + - 'cooccurrence' : Jaccard co-occurrence between pairs across executions. + - 'consensus_ppi_data' : the consensus selection -- with the default method, + the pairs of the co-occurrence cluster whose members are most consistently + chosen together. **This is the recommended output.** + - 'consensus_clusters' : every cluster, so the others can be inspected. + - 'consensus_cluster_scores' : mean intra-cluster co-occurrence per cluster. + + With `consensus_method='frequency'`, 'consensus_threshold' holds the + frequency cutoff instead of the cluster keys. + + Examples + -------- + >>> import cell2cell as c2c + >>> results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, + ... ppi_data=lr_pairs, + ... reference_distances=physical_distances, + ... cutoff_setup={'type': 'constant_value', + ... 'parameter': 10}, + ... analysis_setup={'communication_score': 'expression_thresholding', + ... 'cci_score': 'bray_curtis', + ... 'cci_type': 'undirected'}, + ... executions=20, random_state=888) + >>> results['consensus_ppi_data'] + ''' + if executions < 1: + raise ValueError('`executions` must be at least 1') + + common = dict(rnaseq_data=rnaseq_data, ppi_data=ppi_data, + reference_distances=reference_distances, cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, verbose=verbose, **kwargs) + + if executions == 1: + return _optimize_once(random_state=random_state, **common) + + interaction_columns = kwargs.get('interaction_columns', ('A', 'B')) + prot_a, prot_b = interaction_columns + + # The pool the masks are indexed against, matching what each execution searches + pool = ppi_data + if kwargs.get('deduplicate', True): + pool = remove_ppi_bidirectionality(ppi_data=ppi_data, + interaction_columns=interaction_columns, + verbose=False) + pool = pool.drop_duplicates(subset=list(interaction_columns)).reset_index(drop=True) + + all_executions, masks = {}, [] + for i in range(executions): + seed = None if random_state is None else random_state + i + result = _optimize_once(random_state=seed, **common) + all_executions['execution{}'.format(i + 1)] = result + # The pairs that execution converged to, i.e. its last run + last = max((k for k in result if k.startswith('run')), + key=lambda k: int(k[3:])) + masks.append(result[last]['ppi_data']) + if verbose: + print('Execution {}: best objective {:.4f}, {} pairs' + .format(i + 1, result['best_obj_fn'], sum(result[last]['ppi_data']))) + + masks = np.asarray(masks, dtype=int) + labels = ['{}^{}'.format(a, b) for a, b in pool[[prot_a, prot_b]].values] + + frequency = pd.DataFrame({prot_a: pool[prot_a].values, prot_b: pool[prot_b].values, + 'frequency': lr_selection_frequency(masks)}) + cooccurrence = lr_cooccurrence(masks, labels=labels) + + best_key = max(all_executions, key=lambda k: all_executions[k]['best_obj_fn']) + results = dict(all_executions[best_key]) + results['executions'] = all_executions + results['best_execution'] = best_key + results['selection_masks'] = masks + results['selection_frequency'] = frequency.sort_values('frequency', ascending=False) + results['cooccurrence'] = cooccurrence + + if consensus_method == 'frequency': + mask, threshold = consensus_from_frequency(frequency['frequency'].values, + percentile=frequency_percentile) + results['consensus_ppi_data'] = pool.loc[mask].reset_index(drop=True) + results['consensus_threshold'] = threshold + if verbose: + print('Frequency consensus: {} pairs above {:.3f}' + .format(int(mask.sum()), threshold)) + elif consensus_method == 'cooccurrence': + try: + selected, clusters, scores = consensus_from_cooccurrence( + cooccurrence, n_clusters=n_clusters, select=cluster_selection, + frequency=frequency['frequency'].values, min_frequency=min_frequency) + chosen = set(selected) + results['consensus_ppi_data'] = pool.loc[[l in chosen for l in labels]].reset_index(drop=True) + results['consensus_clusters'] = clusters + results['consensus_cluster_scores'] = scores + if verbose: + print('Co-occurrence consensus: {} pairs, cluster sizes {}, mean co-occurrence {}' + .format(len(chosen), {k: len(v) for k, v in clusters.items()}, + {k: round(v, 3) for k, v in scores.items()})) + except ValueError as error: + if verbose: + print('No consensus could be built: {}'.format(error)) + results['consensus_ppi_data'] = None + results['consensus_clusters'] = None + results['consensus_cluster_scores'] = None + else: + raise ValueError("`consensus_method` must be either 'cooccurrence' or 'frequency'") + return results + + +def _as_symmetric(matrix): + ''' + Validates a reference distance matrix and makes it exactly symmetric. + + `check_symmetry` compares with exact equality, which a matrix produced by a + distance function often fails by a few ULP. Rather than rejecting those, they + are averaged with their transpose; genuinely asymmetric input still raises. + ''' + values = np.asarray(matrix.values, dtype=float) + if values.shape[0] != values.shape[1]: + raise ValueError('`reference_distances` must be a square matrix') + if list(matrix.index) != list(matrix.columns): + raise ValueError('`reference_distances` must have the same cells as rows and columns') + if not np.allclose(values, values.T, rtol=1e-8, atol=1e-8, equal_nan=True): + raise ValueError('`reference_distances` must be a symmetric matrix') + return pd.DataFrame((values + values.T) / 2.0, index=matrix.index, columns=matrix.columns) diff --git a/cell2cell/analysis/tensor_downstream.py b/cell2cell/analysis/tensor_downstream.py index 34abd66..22dcac3 100644 --- a/cell2cell/analysis/tensor_downstream.py +++ b/cell2cell/analysis/tensor_downstream.py @@ -3,6 +3,8 @@ import numpy as np import pandas as pd +from natsort import natsorted + from cell2cell.stats import gini_coefficient @@ -97,7 +99,7 @@ def get_factor_specific_ccc_networks(result, sender_label='Sender Cells', receiv else: raise ValueError('result is not of a valid type. It must be an InteractionTensor or a dict.') - factors = sorted(list(set(result[sender_label].columns) & set(result[receiver_label].columns))) + factors = natsorted(set(result[sender_label].columns) & set(result[receiver_label].columns)) networks = dict() for f in factors: @@ -113,7 +115,7 @@ def flatten_factor_ccc_networks(networks, orderby='senders'): ''' Flattens all adjacency matrices in the factor-specific cell-cell communication networks. It generates a matrix - where rows are factors and columns are cell-cell pairs. + where rows are cell-cell pairs and columns are factors. Parameters ---------- @@ -131,11 +133,19 @@ def flatten_factor_ccc_networks(networks, orderby='senders'): Returns ------- flatten_networks : pandas.DataFrame - A dataframe wherein rows contains a factor-specific network. Columns are - the directed cell-cell pairs. + A dataframe wherein each column contains a factor-specific network. Rows are + the directed cell-cell pairs, named as ' --> '. Cells keep + the order they have in the tensor dimensions. ''' - senders = sorted(set.intersection(*[set(v.index) for v in networks.values()])) - receivers = sorted(set.intersection(*[set(v.columns) for v in networks.values()])) + net_list = list(networks.values()) + common_senders = set.intersection(*[set(v.index) for v in net_list]) + common_receivers = set.intersection(*[set(v.columns) for v in net_list]) + + # Keep the order of the elements in the tensor dimensions instead of sorting them. + # Sorting the names here without reordering the data assigns loadings to the wrong + # cell-cell pair whenever the tensor elements are not alphabetically sorted. + senders = [s for s in net_list[0].index if s in common_senders] + receivers = [r for r in net_list[0].columns if r in common_receivers] if orderby == 'senders': cell_pairs = [s + ' --> ' + r for s in senders for r in receivers] @@ -146,7 +156,10 @@ def flatten_factor_ccc_networks(networks, orderby='senders'): else: raise ValueError("`orderby` must be either 'senders' or 'receivers'.") - data = np.asarray([v.values.flatten(flatten_order) for v in networks.values()]).T + # Reindexing guarantees that every network is flattened in the same order the + # cell-cell pair names were built from. + data = np.asarray([v.reindex(index=senders, columns=receivers).values.flatten(flatten_order) + for v in net_list]).T flatten_networks = pd.DataFrame(data=data, index=cell_pairs, columns=list(networks.keys()) @@ -191,7 +204,7 @@ def compute_gini_coefficients(result, sender_label='Sender Cells', receiver_labe else: raise ValueError('result is not of a valid type. It must be an InteractionTensor or a dict.') - factors = sorted(list(set(result[sender_label].columns) & set(result[receiver_label].columns))) + factors = natsorted(set(result[sender_label].columns) & set(result[receiver_label].columns)) ginis = [] for f in factors: @@ -276,7 +289,7 @@ def get_lr_by_cell_pairs(result, lr_label, sender_label, receiver_label, order_c assert receiver_label in result.keys(), 'The specified dimension ' + receiver_label + ' is not present in the `result` input' # Sort factors - sorted_factors = sorted(result[lr_label].columns, key=lambda x: int(x.split(' ')[1])) + sorted_factors = natsorted(result[lr_label].columns) # Get CCI network per factor networks = get_factor_specific_ccc_networks(result=result, diff --git a/cell2cell/clustering/cluster_interactions.py b/cell2cell/clustering/cluster_interactions.py index 0c14a13..62a216a 100644 --- a/cell2cell/clustering/cluster_interactions.py +++ b/cell2cell/clustering/cluster_interactions.py @@ -86,10 +86,10 @@ def compute_linkage(distance_matrix, method='ward', optimal_ordering=True): Z : numpy.ndarray The hierarchical clustering encoded as a linkage matrix. ''' - if (type(distance_matrix) is pd.core.frame.DataFrame): - data = distance_matrix.values - else: - data = distance_matrix.copy() + # `np.array` accepts both dataframes and arrays, and always copies, so the diagonal + # can be zeroed below. The array behind `DataFrame.values` is read-only under the + # copy-on-write of pandas >= 3.0. + data = np.array(distance_matrix, dtype=float) if ~(data.transpose() == data).all(): raise ValueError('The matrix is not symmetric') diff --git a/cell2cell/core/interaction_space.py b/cell2cell/core/interaction_space.py index d9c946b..ec377df 100644 --- a/cell2cell/core/interaction_space.py +++ b/cell2cell/core/interaction_space.py @@ -2,7 +2,8 @@ from __future__ import absolute_import -from cell2cell.preprocessing import integrate_data, cutoffs, get_genes_from_complexes, add_complexes_to_expression +from cell2cell.preprocessing import (integrate_data, cutoffs, get_genes_from_complexes, add_complexes_to_expression, + zero_diagonal) from cell2cell.core import cell, cci_scores, communication_scores import itertools @@ -64,7 +65,9 @@ def generate_pairs(cells, cci_type, self_interaction=True, remove_duplicates=Tru else: raise NotImplementedError("CCI type has to be directed or undirected") if remove_duplicates: - pairs = list(set(pairs)) # Remove duplicates + # `dict.fromkeys` removes duplicates while keeping the order given by the + # list of cells, so the resulting pairs are reproducible across runs. + pairs = list(dict.fromkeys(pairs)) return pairs @@ -510,14 +513,15 @@ def compute_pairwise_cci_scores(self, cci_score=None, use_ppi_score=False, verbo # ) # Generate distance matrix - if ~(cci_score in ['count', 'icellnet']): + if cci_score not in ['count', 'icellnet']: self.distance_matrix = self.interaction_elements['cci_matrix'].apply(lambda x: 1 - x) else: #self.distance_matrix = self.interaction_elements['cci_matrix'].div(self.interaction_elements['cci_matrix'].max().max()).apply(lambda x: 1 - x) # Regularized distance mean = np.nanmean(self.interaction_elements['cci_matrix']) self.distance_matrix = self.interaction_elements['cci_matrix'].div(self.interaction_elements['cci_matrix'] + mean).apply(lambda x: 1 - x) - np.fill_diagonal(self.distance_matrix.values, 0.0) # Make diagonal zero (delete autocrine-interactions) + # Make diagonal zero (delete autocrine-interactions) + self.distance_matrix = zero_diagonal(self.distance_matrix) def pair_communication_score(self, cell1, cell2, communication_score='expression_thresholding', use_ppi_score=False, verbose=True): diff --git a/cell2cell/datasets/__init__.py b/cell2cell/datasets/__init__.py index bc7ec62..cb377c1 100644 --- a/cell2cell/datasets/__init__.py +++ b/cell2cell/datasets/__init__.py @@ -3,4 +3,6 @@ from cell2cell.datasets.heuristic_data import (HeuristicGOTerms) from cell2cell.datasets.random_data import (generate_random_rnaseq, generate_random_ppi, generate_random_cci_scores, generate_random_metadata) -from cell2cell.datasets.toy_data import (generate_toy_distance, generate_toy_rnaseq, generate_toy_ppi, generate_toy_metadata) \ No newline at end of file +from cell2cell.datasets.toy_data import (generate_toy_distance, generate_toy_rnaseq, generate_toy_ppi, generate_toy_metadata, + generate_toy_contexts, generate_toy_single_cells, generate_toy_coordinates, + generate_toy_spatial_adata, generate_toy_liana_output) \ No newline at end of file diff --git a/cell2cell/datasets/toy_data.py b/cell2cell/datasets/toy_data.py index ca2b803..dc916e1 100644 --- a/cell2cell/datasets/toy_data.py +++ b/cell2cell/datasets/toy_data.py @@ -111,4 +111,235 @@ def generate_toy_distance(): index=['C1', 'C2', 'C3', 'C4', 'C5'], columns=['C1', 'C2', 'C3', 'C4', 'C5'] ) - return distance \ No newline at end of file + return distance + + +def generate_toy_contexts(n_contexts=4, context_names=None): + '''Generates a toy RNA-seq dataset for multiple contexts. + + Each context contains the same genes and cells as the dataset generated by + `generate_toy_rnaseq()`, but with different expression values, so the contexts + can be used to build a 4D communication tensor. + + Parameters + ---------- + n_contexts : int, default=4 + Number of contexts to generate. Contexts are named 'Context-1' to + 'Context-N'. Using a number equal to or greater than 10 results in + context names whose alphabetical order differs from their natural + order (e.g. 'Context-2' and 'Context-10'). + + context_names : list, default=None + Names to use for the contexts. If None, contexts are named 'Context-1' + to 'Context-N'. Its length must be equal to `n_contexts`. + + Returns + ------- + contexts : dict + Dictionary where keys are the context names and values are + pandas.DataFrame objects containing the gene expression of that context. + Columns are cells and rows are genes. + ''' + if context_names is None: + context_names = ['Context-{}'.format(i) for i in range(1, n_contexts + 1)] + else: + assert len(context_names) == n_contexts, \ + "The length of `context_names` must be equal to `n_contexts`" + + base = generate_toy_rnaseq() + + contexts = dict() + for i, name in enumerate(context_names): + # Shifting the genes and scaling the values generates a different + # communication pattern per context, while keeping it deterministic. + data = np.roll(base.values, i, axis=0) * (1.0 + 0.25 * i) + df = pd.DataFrame(data, index=base.index, columns=base.columns) + df.index.name = base.index.name + contexts[name] = df + return contexts + + +def generate_toy_single_cells(n_cell_types=3, n_cells_per_type=4): + '''Generates a toy single-cell RNA-seq dataset with its metadata. + + Parameters + ---------- + n_cell_types : int, default=3 + Number of cell types to generate. Cell types are named 'CT-1' to 'CT-N'. + Using a number equal to or greater than 10 results in cell-type names + whose alphabetical order differs from their natural order (e.g. 'CT-2' + and 'CT-10'). + + n_cells_per_type : int, default=4 + Number of single cells to generate for each cell type. + + Returns + ------- + rnaseq : pandas.DataFrame + Gene expression of the single cells. Columns are single cells and rows + are genes, as in `generate_toy_rnaseq()`. To aggregate it with + `cell2cell.preprocessing.aggregate_single_cells`, either pass + `rnaseq.T` with `transposed=True` or `rnaseq` with `transposed=False`. + + metadata : pandas.DataFrame + Metadata of the single cells. Contains the columns 'barcodes' and + 'cell_types', matching the default parameters of + `cell2cell.preprocessing.aggregate_single_cells`. + ''' + base = generate_toy_rnaseq() + + barcodes = [] + cell_types = [] + columns = [] + for t in range(1, n_cell_types + 1): + cell_type = 'CT-{}'.format(t) + # Each cell type is based on one of the cells in the toy RNA-seq dataset + profile = base.iloc[:, (t - 1) % base.shape[1]].values.astype(float) + for c in range(1, n_cells_per_type + 1): + barcodes.append('{}-cell-{}'.format(cell_type, c)) + cell_types.append(cell_type) + # Deterministic variation across the single cells of a cell type + columns.append(profile * (1.0 + 0.1 * (c - 1)) + (c - 1)) + + rnaseq = pd.DataFrame(np.asarray(columns).T, index=base.index, columns=barcodes) + rnaseq.index.name = base.index.name + + metadata = pd.DataFrame({'barcodes': barcodes, 'cell_types': cell_types}) + return rnaseq, metadata + + +def generate_toy_coordinates(n_cell_types=3, n_cells_per_type=5): + '''Generates toy spatial coordinates for cells of different cell types. + + Cells of a same cell type are placed close to each other, so the distances + between cell types are meaningful. + + Parameters + ---------- + n_cell_types : int, default=3 + Number of cell types to generate. Cell types are named 'CT-1' to 'CT-N'. + + n_cells_per_type : int, default=5 + Number of cells to generate for each cell type. + + Returns + ------- + coordinates : pandas.DataFrame + DataFrame containing the columns 'X' and 'Y' with the coordinates of + each cell, and the column 'celltype' with its cell type. Rows are + indexed by the cell barcodes. + ''' + records = [] + for t in range(1, n_cell_types + 1): + cell_type = 'CT-{}'.format(t) + # Cell types are centered along a diagonal, separated by 50 units + center_x = 50.0 * ((t - 1) % 3) + center_y = 50.0 * ((t - 1) // 3) + for c in range(1, n_cells_per_type + 1): + records.append({'barcode': '{}-cell-{}'.format(cell_type, c), + 'X': center_x + (c - 1) * 2.0, + 'Y': center_y + ((c - 1) % 3) * 2.0, + 'celltype': cell_type}) + + coordinates = pd.DataFrame.from_records(records).set_index('barcode') + coordinates.index.name = 'barcode' + return coordinates + + +def generate_toy_spatial_adata(num_cells=225, n_cell_types=3): + '''Generates a toy AnnData object containing spatial coordinates. + + The cells are placed on a regular square lattice covering coordinates from + 0 to 100 in both dimensions, which makes the object usable with the + functions in `cell2cell.spatial`. + + Parameters + ---------- + num_cells : int, default=225 + Number of cells/spots to generate. + + n_cell_types : int, default=3 + Number of cell types to assign to the cells. Cell types are named + 'CT-1' to 'CT-N'. + + Returns + ------- + adata : AnnData + Annotated data matrix with the toy genes as variables and the cells as + observations. Spatial coordinates are stored in `adata.obsm['spatial']` + and cell types in the 'celltype' column of `adata.obs`. + ''' + import anndata + + base = generate_toy_rnaseq() + + # Regular lattice of coordinates, so the windows and grids are reproducible + side = int(np.ceil(np.sqrt(num_cells))) + x_coords, y_coords = np.meshgrid(np.linspace(0., 100., side), + np.linspace(0., 100., side)) + coordinates = np.column_stack([x_coords.ravel(), y_coords.ravel()])[:num_cells] + + n_genes = base.shape[0] + n_profiles = base.shape[1] + expression = np.zeros((num_cells, n_genes)) + barcodes = [] + cell_types = [] + for i in range(num_cells): + profile = base.iloc[:, i % n_profiles].values.astype(float) + # Expression depends on the position, generating a spatial pattern + expression[i, :] = profile * (1.0 + coordinates[i, 0] / 100.) + barcodes.append('spot-{}'.format(i + 1)) + cell_types.append('CT-{}'.format(i % n_cell_types + 1)) + + obs = pd.DataFrame({'celltype': cell_types}, index=barcodes) + obs.index.name = 'barcode' + var = pd.DataFrame(index=list(base.index)) + var.index.name = 'gene_id' + + adata = anndata.AnnData(X=expression, obs=obs, var=var) + adata.obsm['spatial'] = coordinates + return adata + + +def generate_toy_liana_output(n_contexts=3, n_cell_types=3): + '''Generates a toy output resembling the one obtained from LIANA. + + It contains the communication scores of each ligand-receptor pair for each + pair of sender-receiver cells, across multiple contexts, in a long format. + + Parameters + ---------- + n_contexts : int, default=3 + Number of contexts to generate. Contexts are named 'Context-1' to + 'Context-N'. + + n_cell_types : int, default=3 + Number of cell types to generate. Cell types are named 'CT-1' to 'CT-N'. + + Returns + ------- + liana_outputs : pandas.DataFrame + Dataframe in a long format, containing the columns 'context', 'source', + 'target', 'ligand', 'receptor' and 'score'. Grouping it by the 'context' + column generates the dictionary that + `cell2cell.tensor.dataframes_to_tensor` expects. + ''' + ppi = generate_toy_ppi(prot_complex=False) + lr_pairs = list(zip(ppi['A'], ppi['B'])) + + records = [] + for i in range(1, n_contexts + 1): + context = 'Context-{}'.format(i) + for s in range(1, n_cell_types + 1): + for t in range(1, n_cell_types + 1): + for k, (ligand, receptor) in enumerate(lr_pairs): + # Deterministic score, different for each combination + score = ((i * 7 + s * 13 + t * 17 + k * 3) % 100) / 100. + records.append({'context': context, + 'source': 'CT-{}'.format(s), + 'target': 'CT-{}'.format(t), + 'ligand': ligand, + 'receptor': receptor, + 'score': score}) + liana_outputs = pd.DataFrame.from_records(records) + return liana_outputs \ No newline at end of file diff --git a/cell2cell/external/pcoa.py b/cell2cell/external/pcoa.py index fb0ac83..18a3c09 100644 --- a/cell2cell/external/pcoa.py +++ b/cell2cell/external/pcoa.py @@ -87,8 +87,10 @@ def pcoa(distance_matrix, method="eigh", number_of_dimensions=0, """ distance_matrix = convert_to_distance_matrix(distance_matrix) - # Center distance matrix, a requirement for PCoA here - matrix_data = center_distance_matrix(distance_matrix.values, inplace=inplace) + # Center distance matrix, a requirement for PCoA here. `np.array` always copies, so + # the `inplace` option gets a writable array, unlike `DataFrame.values` under the + # copy-on-write of pandas >= 3.0. + matrix_data = center_distance_matrix(np.array(distance_matrix, dtype=float), inplace=inplace) # If no dimension specified, by default will compute all eigenvectors # and eigenvalues @@ -357,7 +359,9 @@ def pcoa_biplot(ordination, y): raise ValueError('The eigenvectors and the descriptors must describe ' 'the same samples.') - eigvals = ordination['eigvals'] + # Converted to a numpy array because using np.power() with the `where` argument + # on a pandas Series recurses through pandas' ufunc handling. + eigvals = np.asarray(ordination['eigvals']) coordinates = ordination['samples'] N = coordinates.shape[0] @@ -373,8 +377,11 @@ def pcoa_biplot(ordination, y): # # Only get the power of non-zero values, otherwise this will raise a # divide by zero warning. There shouldn't be negative eigenvalues(?) - Uproj = np.sqrt(N - 1) * spc.dot(np.diag(np.power(eigvals, -0.5, - where=eigvals > 0))) + # `out` is needed so the entries excluded by `where` are zero instead of + # whatever was left in the uninitialized output array. + inverse_sqrt = np.power(eigvals, -0.5, where=eigvals > 0, + out=np.zeros_like(eigvals, dtype=float)) + Uproj = np.sqrt(N - 1) * spc.dot(np.diag(inverse_sqrt)) ordination['features'] = pd.DataFrame(data=Uproj, index=y.columns.copy(), diff --git a/cell2cell/external/pcoa_utils.py b/cell2cell/external/pcoa_utils.py index 39d5023..9703f3a 100644 --- a/cell2cell/external/pcoa_utils.py +++ b/cell2cell/external/pcoa_utils.py @@ -107,9 +107,12 @@ def scale(a, weights=None, with_mean=True, with_std=True, ddof=0, copy=True): Wherever std equals 0, it is replaced by 1 in order to avoid division by zero. """ - if copy: - a = a.copy() a = np.asarray(a, dtype=np.float64) + # `a` is standardized in place below. Copying it beforehand is not only what `copy` + # asks for, but also a requirement when the array is not writable, which is the case + # for the one behind `DataFrame.values` under the copy-on-write of pandas >= 3.0. + if copy or not a.flags.writeable: + a = a.copy() avg, std = mean_and_std(a, axis=0, weights=weights, with_mean=with_mean, with_std=with_std, ddof=ddof) if with_mean: @@ -218,7 +221,7 @@ def _e_matrix_inplace(distance_matrix): distance_matrix : 2D array_like Distance matrix. """ - distance_matrix = distance_matrix.astype(np.float) + distance_matrix = distance_matrix.astype(float) for i in np.arange(len(distance_matrix)): distance_matrix[i] = (distance_matrix[i] * distance_matrix[i]) / -2 @@ -238,7 +241,7 @@ def _f_matrix_inplace(e_matrix): e_matrix : 2D array_like A matrix representing the "E matrix" as described above. """ - e_matrix = e_matrix.astype(np.float) + e_matrix = e_matrix.astype(float) row_means = np.zeros(len(e_matrix), dtype=float) col_means = np.zeros(len(e_matrix), dtype=float) diff --git a/cell2cell/io/directories.py b/cell2cell/io/directories.py index 0231b87..ffbecb1 100644 --- a/cell2cell/io/directories.py +++ b/cell2cell/io/directories.py @@ -2,6 +2,8 @@ import os +from natsort import natsorted + def create_directory(pathname): '''Creates a directory. @@ -37,8 +39,10 @@ def get_files_from_directory(pathname, dir_in_filepath=False): ------- filenames : list A list containing the names (strings) of the files - in the folder. + in the folder, naturally sorted by filename. ''' directory = os.fsencode(pathname) - filenames = [pathname + '/' + os.fsdecode(file) if dir_in_filepath else os.fsdecode(file) for file in os.listdir(directory)] + # Naturally sorted to avoid a filesystem-dependent order of the files + files = natsorted([os.fsdecode(file) for file in os.listdir(directory)]) + filenames = [pathname + '/' + file if dir_in_filepath else file for file in files] return filenames diff --git a/cell2cell/plotting/aesthetics.py b/cell2cell/plotting/aesthetics.py index 5c352f4..fe1bd1b 100644 --- a/cell2cell/plotting/aesthetics.py +++ b/cell2cell/plotting/aesthetics.py @@ -6,6 +6,8 @@ import matplotlib.patches as patches import numpy as np +from natsort import natsorted + def get_colors_from_labels(labels, cmap='gist_rainbow', factor=1): '''Generates colors for each label in a list given a colormap @@ -158,7 +160,7 @@ def generate_legend(color_dict, loc='center left', bbox_to_anchor=(1.01, 0.5), n ''' color_patches = [] if sorted_labels: - iteritems = sorted(color_dict.items()) + iteritems = natsorted(color_dict.items(), key=lambda x: x[0]) else: iteritems = color_dict.items() for k, v in iteritems: diff --git a/cell2cell/plotting/circular_plot.py b/cell2cell/plotting/circular_plot.py index 5cdbbab..978b270 100644 --- a/cell2cell/plotting/circular_plot.py +++ b/cell2cell/plotting/circular_plot.py @@ -9,6 +9,8 @@ import numpy as np import pandas as pd +from natsort import natsorted + from cell2cell.plotting.aesthetics import get_colors_from_labels, generate_legend @@ -165,7 +167,7 @@ def circos_plot(interaction_space, sender_cells, receiver_cells, ligands, recept small_R = determine_small_radius(edges_dict) # Colors - cells = list(set(sender_cells+receiver_cells)) + cells = natsorted(set(sender_cells + receiver_cells)) if metadata is not None: meta = metadata.set_index(sample_col).reindex(cells) meta = meta[[group_col]].fillna('NA') @@ -303,7 +305,7 @@ def get_arc_angles(G, sorting_feature=None): values are tuples with angles for the start and end of the arc that represents a node. ''' - elements = list(set(G.nodes())) + elements = natsorted(G.nodes()) n_elements = len(elements) if sorting_feature is not None: diff --git a/cell2cell/plotting/factor_plot.py b/cell2cell/plotting/factor_plot.py index e0742b2..c3c1829 100644 --- a/cell2cell/plotting/factor_plot.py +++ b/cell2cell/plotting/factor_plot.py @@ -6,6 +6,7 @@ from matplotlib import pyplot as plt from statannotations.Annotator import Annotator from scipy.stats import zscore +from natsort import natsorted from cell2cell.clustering.cluster_interactions import compute_distance, compute_linkage from cell2cell.analysis.tensor_downstream import get_factor_specific_ccc_networks @@ -116,7 +117,7 @@ def context_boxplot(context_loadings, metadict, included_factors=None, group_ord if group_order is not None: assert len(set(group_order) & set(metadict.values())) == len(set(metadict.values())), "All groups in `metadict` must be contained in `group_order`" else: - group_order = list(set(metadict.values())) + group_order = natsorted(set(metadict.values())) df = context_loadings.copy() if included_factors is None: @@ -143,16 +144,25 @@ def context_boxplot(context_loadings, metadict, included_factors=None, group_ord order = group_order # Plot the boxes + # `hue` repeats `x` because seaborn 0.14 removes the option of passing a palette + # without one. `dodge=False` keeps one box per group instead of splitting them, + # which is what makes this identical to passing the palette on its own. It is + # preferred over the `legend` argument, which only exists from seaborn 0.13. ax = sns.boxplot(x=x, y=y, data=df, order=order, whis=[0, 100], width=.6, + hue=x, palette=cmap, + dodge=False, boxprops=dict(alpha=.5), ax=ax ) + # The x axis already labels the groups, so the legend the hue brings is redundant + if ax.get_legend() is not None: + ax.get_legend().remove() # Plot the dots sns.stripplot(x=x, diff --git a/cell2cell/plotting/pcoa_plot.py b/cell2cell/plotting/pcoa_plot.py index fb14ca4..c31f23a 100644 --- a/cell2cell/plotting/pcoa_plot.py +++ b/cell2cell/plotting/pcoa_plot.py @@ -5,6 +5,8 @@ from matplotlib import pyplot as plt from mpl_toolkits.mplot3d import Axes3D +from natsort import natsorted + from cell2cell.external import pcoa, _check_ordination from cell2cell.plotting.aesthetics import get_colors_from_labels @@ -138,7 +140,7 @@ def pcoa_3dplot(interaction_space, metadata=None, sample_col='#SampleID', group_ assert all(elem in colors.keys() for elem in set(labels)) # Plot each data point with respective color - for i, cell_type in enumerate(sorted(meta_[group_col].unique())): + for i, cell_type in enumerate(natsorted(meta_[group_col].unique())): cells = list(meta_.loc[meta_[group_col] == cell_type].index) if colors is not None: ax.scatter(ordination['samples'].loc[cells, 'PC1'], diff --git a/cell2cell/plotting/pval_plot.py b/cell2cell/plotting/pval_plot.py index c204930..cf92869 100644 --- a/cell2cell/plotting/pval_plot.py +++ b/cell2cell/plotting/pval_plot.py @@ -189,7 +189,8 @@ def generate_dot_plot(pval_df, score_df, significance=0.05, xlabel='', ylabel='' # Drop all zeros df = df.loc[(df != 0).any(axis=1)] df = df.T.loc[(df != 0).any(axis=0)].T - pval_df = pval_df[df.columns].loc[df.index].applymap(lambda x: -1. * np.log10(x + 1e-9)) + # Vectorized instead of elementwise, since `DataFrame.applymap` was removed in pandas 3.0 + pval_df = -1. * np.log10(pval_df[df.columns].loc[df.index] + 1e-9) n_rows = len(pval_df.index) n_cols = len(pval_df.columns) @@ -211,8 +212,9 @@ def generate_dot_plot(pval_df, score_df, significance=0.05, xlabel='', ylabel='' norm = mpl.colors.Normalize(vmin=-1. * max_abs, vmax=max_abs) max_size = mpl.colors.Normalize(vmin=0., vmax=3) - # Colormap - cmap = mpl.cm.get_cmap(cmap) + # Colormap. `matplotlib.cm.get_cmap` was removed in matplotlib 3.11, and the pyplot + # function is the one used elsewhere in this subpackage. It also takes a Colormap. + cmap = plt.get_cmap(cmap) # Create figure with proper height ratios # Use height_ratios based on actual inches rather than arbitrary numbers diff --git a/cell2cell/plotting/tensor_plot.py b/cell2cell/plotting/tensor_plot.py index fe6cf96..66f1be1 100644 --- a/cell2cell/plotting/tensor_plot.py +++ b/cell2cell/plotting/tensor_plot.py @@ -469,7 +469,8 @@ def reorder_dimension_elements(factors, reorder_elements, metadata=None): assert all((len(set(factors[key].index).difference(set(reorder_elements[key]))) == 0) for key in reorder_elements.keys()), "All elements of each dimension included should be present" reordered_factors = factors.copy() - new_metadata = metadata.copy() + # `metadata` is optional, so it is only copied when it was actually provided + new_metadata = metadata.copy() if metadata is not None else None i = 0 for k, df in reordered_factors.items(): @@ -596,7 +597,7 @@ def plot_multiple_run_elbow(all_loss, elbow=None, ci='95%', figsize=(4, 2.25), y raise ValueError("Specify a correct ci. Either '95%' or 'std'") plt.fill_between(x, mean - coeff * std, mean + coeff * std, color='steelblue', alpha=.2, - label='$\pm$ 1 std') + label=r'$\pm$ 1 std') plt.tick_params(axis='both', labelsize=fontsize) plt.xlabel('Rank', fontsize=int(1.2 * fontsize)) diff --git a/cell2cell/plotting/umap_plot.py b/cell2cell/plotting/umap_plot.py index adf0bfb..45c1365 100644 --- a/cell2cell/plotting/umap_plot.py +++ b/cell2cell/plotting/umap_plot.py @@ -54,11 +54,15 @@ def umap_biplot(umap_df, figsize=(8 ,8), ax=None, show_axes=True, show_legend=Tr if ax is None: fig = plt.figure(figsize=figsize) + # A palette is only meaningful together with a hue. Passing it without one was + # ignored with a warning by seaborn, and is removed in seaborn 0.14. + palette = cmap if hue is not None else None + ax = sns.scatterplot(x='umap1', y='umap2', data=umap_df, hue=hue, - palette=cmap, + palette=palette, ax=ax ) diff --git a/cell2cell/preprocessing/__init__.py b/cell2cell/preprocessing/__init__.py index 0e1d7e6..3067e65 100644 --- a/cell2cell/preprocessing/__init__.py +++ b/cell2cell/preprocessing/__init__.py @@ -6,7 +6,8 @@ from cell2cell.preprocessing.integrate_data import (get_thresholded_rnaseq, get_modified_rnaseq, get_ppi_dict_from_go_terms, get_ppi_dict_from_proteins, get_weighted_ppi) from cell2cell.preprocessing.manipulate_dataframes import (check_presence_in_dataframe, shuffle_cols_in_df, shuffle_rows_in_df, - shuffle_dataframe, subsample_dataframe) + shuffle_dataframe, subsample_dataframe, check_symmetry, + convert_to_distance_matrix, zero_diagonal) from cell2cell.preprocessing.ppi import (bidirectional_ppi_for_cci, filter_ppi_by_proteins, filter_ppi_network, get_all_to_all_ppi, get_filtered_ppi_network, get_one_group_to_other_ppi, remove_ppi_bidirectionality, simplify_ppi, filter_complex_ppi_by_proteins, diff --git a/cell2cell/preprocessing/find_elements.py b/cell2cell/preprocessing/find_elements.py index d28d246..2e6f7a2 100644 --- a/cell2cell/preprocessing/find_elements.py +++ b/cell2cell/preprocessing/find_elements.py @@ -44,9 +44,12 @@ def get_element_abundances(element_lists): abundance_dict : dict Dictionary containing the number of times that an element was present, divided by the total number of - lists in `element_lists`. + lists in `element_lists`. Keys keep the order in which + the elements were first found across `element_lists`. ''' - abundance_dict = Counter(itertools.chain(*map(set, element_lists))) + # `dict.fromkeys` removes duplicates within each list while keeping their order, + # so that the resulting keys are reproducible across runs (unlike using sets). + abundance_dict = Counter(itertools.chain(*[dict.fromkeys(l) for l in element_lists])) total = len(element_lists) abundance_dict = {k : v/total for k, v in abundance_dict.items()} return abundance_dict diff --git a/cell2cell/preprocessing/gene_ontology.py b/cell2cell/preprocessing/gene_ontology.py index f0c693f..a2eff5e 100644 --- a/cell2cell/preprocessing/gene_ontology.py +++ b/cell2cell/preprocessing/gene_ontology.py @@ -5,6 +5,8 @@ import numpy as np import networkx +from natsort import natsorted + def get_genes_from_go_terms(go_annotations, go_filter, go_header='GO', gene_header='Gene', verbose=True): ''' @@ -80,7 +82,7 @@ def get_genes_from_go_hierarchy(go_annotations, go_terms, go_filter, go_header=' iter = len(go_hierarchy) for i in range(iter): find_all_children_of_go_term(go_terms, go_hierarchy[i], go_hierarchy, verbose=verbose) - go_hierarchy = list(set(go_hierarchy)) + go_hierarchy = natsorted(set(go_hierarchy)) genes = get_genes_from_go_terms(go_annotations=go_annotations, go_filter=go_hierarchy, go_header=go_header, diff --git a/cell2cell/preprocessing/manipulate_dataframes.py b/cell2cell/preprocessing/manipulate_dataframes.py index d7d5f81..b6d16a3 100644 --- a/cell2cell/preprocessing/manipulate_dataframes.py +++ b/cell2cell/preprocessing/manipulate_dataframes.py @@ -3,6 +3,8 @@ from __future__ import absolute_import import random +import warnings + import numpy as np import pandas as pd @@ -33,9 +35,14 @@ def check_presence_in_dataframe(df, elements, columns=None): ''' if columns is None: columns = list(df.columns) - df_elements = pd.Series(np.unique(df[columns].values.flatten())) - df_elements = df_elements.loc[df_elements.isin(elements)].values - found_elements = list(df_elements) + elif isinstance(columns, str): + # A string would be read by pandas as a single column name, returning a Series + # instead of the dataframe the code below expects, as in `shuffle_cols_in_df`. + columns = [columns] + # `pd.unique` does not sort the values, so it also works when the considered + # columns contain a mix of data types (e.g. gene names and scores). + df_elements = pd.Series(pd.unique(df[columns].to_numpy(dtype=object).ravel())) + found_elements = df_elements.loc[df_elements.isin(elements)].tolist() return found_elements @@ -136,7 +143,9 @@ def shuffle_dataframe(df, shuffling_number=1, axis=0, random_state=None): ''' df_ = df.copy() axis = int(not axis) # pandas.DataFrame is always 2D - to_shuffle = np.rollaxis(df_.values, axis) + # `to_numpy(copy=True)` is shuffled in place below, so it must be writable. The array + # behind `DataFrame.values` is read-only under the copy-on-write of pandas >= 3.0. + to_shuffle = np.rollaxis(df_.to_numpy(copy=True), axis) for _ in range(shuffling_number): for i, view in enumerate(to_shuffle): if random_state is not None: @@ -202,6 +211,29 @@ def check_symmetry(df): return symmetric +def zero_diagonal(df): + ''' + Sets all diagonal elements of a square dataframe to zero. + + Parameters + ---------- + df : pandas.DataFrame + A square dataframe. + + Returns + ------- + df_ : pandas.DataFrame + A copy of df, but with all diagonal elements with a + value of zero. + ''' + # `np.array` always copies, so the result is writable. The array behind + # `DataFrame.values` is read-only under the copy-on-write of pandas >= 3.0, + # which makes it unusable with in-place functions such as `np.fill_diagonal`. + values = np.array(df, dtype=float) + np.fill_diagonal(values, 0.0) + return pd.DataFrame(values, index=df.index, columns=df.columns) + + def convert_to_distance_matrix(df): ''' Converts a symmetric dataframe into a distance dataframe. @@ -219,10 +251,10 @@ def convert_to_distance_matrix(df): value of zero. ''' if check_symmetry(df): - df_ = df.copy() - if np.trace(df_.values,) != 0.0: - raise Warning("Diagonal elements are not zero. Automatically replaced by zeros") - np.fill_diagonal(df_.values, 0.0) + if np.trace(df.values,) != 0.0: + # Warned instead of raised, so the diagonal is actually replaced below + warnings.warn("Diagonal elements are not zero. Automatically replaced by zeros") + df_ = zero_diagonal(df) else: raise ValueError('The DataFrame is not symmetric') return df_ diff --git a/cell2cell/preprocessing/ppi.py b/cell2cell/preprocessing/ppi.py index beae77b..3158b36 100644 --- a/cell2cell/preprocessing/ppi.py +++ b/cell2cell/preprocessing/ppi.py @@ -7,6 +7,8 @@ from itertools import combinations +from natsort import natsorted + ### Preprocess a PPI table from a known list def preprocess_ppi_data(ppi_data, interaction_columns, sort_values=None, score=None, rnaseq_genes=None, complex_sep=None, @@ -596,7 +598,7 @@ def get_filtered_ppi_network(ppi_data, contact_proteins, mediator_proteins=None, interaction_columns=interaction_columns) elif interaction_type == 'complete': - total_proteins = list(set(contact_proteins + mediator_proteins)) + total_proteins = natsorted(set(contact_proteins + mediator_proteins)) new_ppi_data = get_all_to_all_ppi(ppi_data=ppi_data, proteins=total_proteins, diff --git a/cell2cell/preprocessing/rnaseq.py b/cell2cell/preprocessing/rnaseq.py index 4daef0d..763e47d 100644 --- a/cell2cell/preprocessing/rnaseq.py +++ b/cell2cell/preprocessing/rnaseq.py @@ -5,6 +5,8 @@ import numpy as np import pandas as pd +from natsort import natsorted + ### Pre-process RNAseq datasets def drop_empty_genes(rnaseq_data): @@ -82,7 +84,9 @@ def scale_expression_by_sum(rnaseq_data, axis=0, sum_value=1e6): cell-types/tissues/samples and rows are genes. ''' data = rnaseq_data.values - data = sum_value * np.divide(data, np.nansum(data, axis=axis)) + # `keepdims` is needed so the sums broadcast back along the specified axis. + # Without it, normalizing across columns (axis=1) raises a broadcasting error. + data = sum_value * np.divide(data, np.nansum(data, axis=axis, keepdims=True)) scaled_data = pd.DataFrame(data, index=rnaseq_data.index, columns=rnaseq_data.columns) return scaled_data @@ -196,9 +200,33 @@ def add_complexes_to_expression(rnaseq_data, complexes, agg_method='min'): return tmp_rna +def _trimean(x, axis): + ''' + Computes the trimean of the data along the specified axis. + + Parameters + ---------- + x : numpy.ndarray + The input data for which the trimean is to be computed. + + axis : int + The axis along which to compute the trimean. Use 0 for columns, 1 for rows. + + Returns + ------- + trimean : numpy.ndarray + An array containing the trimean values for each row or column, depending on + the specified axis. + ''' + q1, q2, q3 = np.nanpercentile(x, [25, 50, 75], axis=axis) + trimean = 0.5 * q2 + 0.25 * (q1 + q3) + return trimean + + def aggregate_single_cells(rnaseq_data, metadata, barcode_col='barcodes', celltype_col='cell_types', method='average', transposed=True): - '''Aggregates gene expression of single cells into cell types for each gene. + ''' + Aggregates gene expression of single cells into cell types for each gene. Parameters ---------- @@ -229,6 +257,11 @@ def aggregate_single_cells(rnaseq_data, metadata, barcode_col='barcodes', cellty of a given gene. - 'average' : Computes the average gene expression among the single cells composing a cell type for a given gene. + - 'trimean' : Computes the Tukey's trimean of the gene expression among the + single cells composing a cell type for a given gene. It is a weighted + average of the median and the first and third quartiles + (0.5 * Q2 + 0.25 * (Q1 + Q3)), so it is more robust to outliers than + the average while still accounting for the spread of the distribution. transposed : boolean, default=True Whether the rnaseq_data is organized with columns as @@ -241,7 +274,7 @@ def aggregate_single_cells(rnaseq_data, metadata, barcode_col='barcodes', cellty by cell types. Columns are cell types and rows are genes. ''' assert metadata is not None, "Please provide metadata containing the barcodes and cell-type annotation." - assert method in ['average', 'nn_cell_fraction'], "{} is not a valid option for method".format(method) + assert method in ['average', 'nn_cell_fraction', 'trimean'], "{} is not a valid option for method".format(method) meta = metadata.reset_index() meta = meta[[barcode_col, celltype_col]].set_index(barcode_col) @@ -251,18 +284,24 @@ def aggregate_single_cells(rnaseq_data, metadata, barcode_col='barcodes', cellty df = rnaseq_data else: df = rnaseq_data.T - df.index = [mapper[c] for c in df.index] - df.index.name = 'celltype' - df.reset_index(inplace=True) - agg_df = pd.DataFrame(index=df.columns).drop('celltype') + # Grouping by an external list of cell types, instead of replacing the index of + # `df` and adding a column to it, avoids modifying the dataframe passed by the user. + celltypes = [mapper[c] for c in df.index] + + agg_df = pd.DataFrame(index=df.columns) - for celltype, ct_df in df.groupby('celltype'): - ct_df = ct_df.drop('celltype', axis=1) + for celltype, ct_df in df.groupby(celltypes): if method == 'average': agg = ct_df.mean() elif method == 'nn_cell_fraction': agg = ((ct_df > 0).sum() / ct_df.shape[0]) + elif method == 'trimean': + agg = pd.Series(_trimean(ct_df.values, axis=0), index=ct_df.columns) agg_df[celltype] = agg + + # Naturally sorted to avoid a lexicographic order of the cell types (e.g. to obtain + # 'CT-1', 'CT-2', 'CT-10' instead of 'CT-1', 'CT-10', 'CT-2') + agg_df = agg_df[natsorted(agg_df.columns)] return agg_df diff --git a/cell2cell/spatial/__init__.py b/cell2cell/spatial/__init__.py index 45f6da4..74b9088 100644 --- a/cell2cell/spatial/__init__.py +++ b/cell2cell/spatial/__init__.py @@ -1,3 +1,5 @@ -from cell2cell.spatial.distances import (celltype_pair_distance, pairwise_celltype_distances) +from cell2cell.spatial.distances import (celltype_pair_distance, pairwise_celltype_distances, + get_spatial_coordinates, celltype_centroids, + celltype_centroid_distances, celltype_distances) from cell2cell.spatial.filtering import (dist_filter_liana, dist_filter_tensor) -from cell2cell.spatial.neighborhoods import (create_spatial_grid, create_sliding_windows, calculate_window_size, add_sliding_window_info_to_adata) \ No newline at end of file +from cell2cell.spatial.neighborhoods import (create_spatial_grid, create_sliding_windows, calculate_window_size, add_sliding_window_info_to_adata) diff --git a/cell2cell/spatial/distances.py b/cell2cell/spatial/distances.py index 5e20043..0681cb9 100644 --- a/cell2cell/spatial/distances.py +++ b/cell2cell/spatial/distances.py @@ -2,6 +2,7 @@ import itertools import numpy as np import pandas as pd +from natsort import natsorted from sklearn.metrics.pairwise import euclidean_distances, manhattan_distances @@ -22,7 +23,7 @@ def celltype_pair_distance(df1, df2, method='min', distance='euclidean'): method : str, default='min' The aggregation method for the calculated distances. It can be one of 'min', - 'max', or 'mean'. + 'max', 'mean', or 'median'. distance : str, default='euclidean' The distance metric to use. It can be 'euclidean' or 'manhattan'. @@ -46,6 +47,8 @@ def celltype_pair_distance(df1, df2, method='min', distance='euclidean'): agg_dist = np.nanmax(distances) elif method == 'mean': agg_dist = np.nanmean(distances) + elif method == 'median': + agg_dist = np.nanmedian(distances) else: raise NotImplementedError('Method {} is not implemented.'.format(method)) return agg_dist @@ -98,4 +101,283 @@ def pairwise_celltype_distances(df, group_col, coord_cols=['X', 'Y'], ) distances.loc[pair[0], pair[1]] = dist distances.loc[pair[1], pair[0]] = dist - return distances \ No newline at end of file + return distances + +def get_spatial_coordinates(adata, spatial_key='spatial', coord_names=None): + ''' + Extracts the spatial coordinates of an AnnData object as a dataframe. + + Parameters + ---------- + adata : anndata.AnnData + Object containing the spatial coordinates of each single cell. + + spatial_key : str, default='spatial' + Key in `adata.obsm` where the coordinates are stored. Objects written by + different tools use different keys (e.g. 'spatial', 'X_spatial', + 'X_umap'), so it can be changed here. + + coord_names : list, default=None + Names to give to the coordinate columns. If None, they are named 'X', 'Y' + and 'Z' for the first three dimensions, and 'Dim4', 'Dim5', ... beyond + that, so the result works with the `coord_cols` parameter of the other + functions in this module. + + Returns + ------- + coordinates : pandas.DataFrame + Coordinates of each single cell. Rows are the observation names of + `adata`, in the same order, and columns are the dimensions. + ''' + if spatial_key not in adata.obsm.keys(): + raise KeyError("'{}' is not in adata.obsm. Available keys are: {}" + .format(spatial_key, list(adata.obsm.keys()))) + + coords = np.asarray(adata.obsm[spatial_key]) + if coords.ndim != 2: + raise ValueError('The coordinates in adata.obsm[\'{}\'] must be two-dimensional' + .format(spatial_key)) + + if coord_names is None: + default = ['X', 'Y', 'Z'] + coord_names = [default[i] if i < len(default) else 'Dim{}'.format(i + 1) + for i in range(coords.shape[1])] + elif len(coord_names) != coords.shape[1]: + raise ValueError('`coord_names` must have one name per dimension ({})' + .format(coords.shape[1])) + + return pd.DataFrame(coords, index=adata.obs_names, columns=coord_names) + + +def celltype_centroids(adata, group_col, spatial_key='spatial', coord_names=None, + method='mean'): + ''' + Computes the centroid of each cell type from the coordinates of its single cells. + + Parameters + ---------- + adata : anndata.AnnData or pandas.DataFrame + Either an AnnData object with coordinates in `adata.obsm[spatial_key]` and + the cell-type annotation in `adata.obs[group_col]`, or a dataframe with one + row per single cell containing both the coordinates and the annotation. + + group_col : str + Column with the cell-type annotation. Taken from `adata.obs` for an AnnData + object, and from the dataframe itself otherwise. + + spatial_key : str, default='spatial' + Key in `adata.obsm` where the coordinates are stored. Ignored when a + dataframe is passed. + + coord_names : list, default=None + Names of the coordinate columns. For an AnnData object they name the + extracted dimensions; for a dataframe they select which columns to use. If + None, an AnnData is named 'X', 'Y', 'Z', ... and for a dataframe every + column other than `group_col` is used. + + method : str, default='mean' + How to summarize the coordinates of the single cells of a cell type. It can + be 'mean' (the centroid proper) or 'median' (the component-wise median, + which is robust to cells scattered far from the rest of their type). + + Returns + ------- + centroids : pandas.DataFrame + One row per cell type and one column per dimension. Cell types are + naturally sorted, so 'CT-2' comes before 'CT-10'. + + Examples + -------- + >>> import cell2cell as c2c + >>> adata = c2c.datasets.generate_toy_spatial_adata() + >>> centroids = c2c.spatial.celltype_centroids(adata, group_col='cell_type') + ''' + coords, groups = _coordinates_and_groups(adata, group_col, spatial_key, coord_names) + + if method == 'mean': + centroids = coords.groupby(groups, observed=True).mean() + elif method == 'median': + centroids = coords.groupby(groups, observed=True).median() + else: + raise NotImplementedError("Method {} is not implemented. Use 'mean' or 'median'." + .format(method)) + centroids.index.name = group_col + return centroids.loc[natsorted(centroids.index)] + + +def celltype_centroid_distances(adata, group_col, spatial_key='spatial', coord_names=None, + centroid_method='mean', distance='euclidean'): + ''' + Computes the distances between the centroids of every pair of cell types. + + This summarizes each cell type by one point before measuring distances, so its + cost does not depend on how many single cells each type contains. That makes it + the option to use on large datasets, where the all-versus-all single-cell + distances of `pairwise_celltype_distances` become prohibitive. + + Parameters + ---------- + adata : anndata.AnnData or pandas.DataFrame + Object or dataframe containing the coordinates and the cell-type annotation. + + group_col : str + Column with the cell-type annotation. + + spatial_key : str, default='spatial' + Key in `adata.obsm` where the coordinates are stored. + + coord_names : list, default=None + Names of the coordinate columns. + + centroid_method : str, default='mean' + How to summarize the coordinates of each cell type, 'mean' or 'median'. + + distance : str, default='euclidean' + The distance metric to use. It can be 'euclidean' or 'manhattan'. + + Returns + ------- + distances : pandas.DataFrame + Symmetric matrix with a zero diagonal, where rows and columns are the cell + types, naturally sorted. + + Examples + -------- + >>> import cell2cell as c2c + >>> adata = c2c.datasets.generate_toy_spatial_adata() + >>> distances = c2c.spatial.celltype_centroid_distances(adata, group_col='cell_type') + ''' + centroids = celltype_centroids(adata, group_col, spatial_key=spatial_key, + coord_names=coord_names, method=centroid_method) + + if distance == 'euclidean': + matrix = euclidean_distances(centroids.values, centroids.values) + elif distance == 'manhattan': + matrix = manhattan_distances(centroids.values, centroids.values) + else: + raise NotImplementedError("{} distance is not implemented.".format(distance.capitalize())) + + # Forced rather than assumed, so the result always satisfies `check_symmetry` + # and `squareform`, whatever rounding the metric introduced + matrix = (matrix + matrix.T) / 2.0 + np.fill_diagonal(matrix, 0.0) + return pd.DataFrame(matrix, index=centroids.index, columns=centroids.index) + + +def celltype_distances(adata, group_col, spatial_key='spatial', coord_names=None, + method='centroid', distance='euclidean', centroid_method='mean', + pairs=None, verbose=False): + ''' + Computes a distance between every pair of cell types from the coordinates of + their single cells. + + Single entry point for the two ways of summarizing the distance between two + cell types: aggregating the distances between all of their single cells + ('min', 'max', 'mean', 'median'), or measuring between their centroids + ('centroid'). + + Parameters + ---------- + adata : anndata.AnnData or pandas.DataFrame + Object or dataframe containing the coordinates and the cell-type annotation. + + group_col : str + Column with the cell-type annotation. + + spatial_key : str, default='spatial' + Key in `adata.obsm` where the coordinates are stored. Ignored when a + dataframe is passed. + + coord_names : list, default=None + Names of the coordinate columns. + + method : str, default='centroid' + How to summarize the distance between two cell types: + + - 'centroid' : distance between the centroids of the two cell types. Cost + is independent of the number of single cells, so this is the one to use + on large datasets. + - 'min' : smallest distance between any two of their single cells, i.e. + how close the two types get to each other. + - 'max' : largest distance between any two of their single cells. + - 'mean' : average over all pairs of their single cells. + - 'median' : median over all pairs of their single cells, less sensitive + to a few distant cells than 'mean'. + + Every option other than 'centroid' evaluates all pairs of single cells of + the two types, so its cost grows with the product of their sizes. + + distance : str, default='euclidean' + The distance metric to use. It can be 'euclidean' or 'manhattan'. + + centroid_method : str, default='mean' + How to summarize the coordinates of each cell type when + `method='centroid'`, either 'mean' or 'median'. + + pairs : list, default=None + Specific pairs of cell types to compute. If None, all combinations are + used. Ignored when `method='centroid'`, which computes all of them at once. + + verbose : boolean, default=False + Whether to warn when the all-versus-all computation is going to be large. + + Returns + ------- + distances : pandas.DataFrame + Symmetric matrix with a zero diagonal, where rows and columns are the cell + types, naturally sorted. + + Examples + -------- + >>> import cell2cell as c2c + >>> adata = c2c.datasets.generate_toy_spatial_adata() + >>> # Fast, and the sensible default on large data + >>> distances = c2c.spatial.celltype_distances(adata, group_col='cell_type') + >>> # How close the two cell types get to each other + >>> distances = c2c.spatial.celltype_distances(adata, group_col='cell_type', + ... method='min') + ''' + if method == 'centroid': + return celltype_centroid_distances(adata, group_col, spatial_key=spatial_key, + coord_names=coord_names, + centroid_method=centroid_method, + distance=distance) + + coords, groups = _coordinates_and_groups(adata, group_col, spatial_key, coord_names) + + counts = pd.Series(groups).value_counts() + if verbose: + worst = int(counts.max()) ** 2 + if worst > 1e8: + print('Computing all-versus-all distances for up to {:.1e} pairs of single ' + "cells per cell-type pair. Consider method='centroid'.".format(worst)) + + df = coords.copy() + df[group_col] = groups + return pairwise_celltype_distances(df, group_col=group_col, + coord_cols=list(coords.columns), + method=method, distance=distance, pairs=pairs) + + +def _coordinates_and_groups(adata, group_col, spatial_key, coord_names): + ''' + Normalizes the two accepted inputs into a coordinates dataframe and a list of + cell-type labels aligned with it. + ''' + if hasattr(adata, 'obsm'): + if group_col not in adata.obs.columns: + raise KeyError("'{}' is not a column of adata.obs".format(group_col)) + coords = get_spatial_coordinates(adata, spatial_key=spatial_key, + coord_names=coord_names) + groups = np.asarray(adata.obs[group_col].values) + elif isinstance(adata, pd.DataFrame): + if group_col not in adata.columns: + raise KeyError("'{}' is not a column of the dataframe".format(group_col)) + if coord_names is None: + coord_names = [c for c in adata.columns if c != group_col] + coords = adata[list(coord_names)] + groups = np.asarray(adata[group_col].values) + else: + raise TypeError('`adata` must be an AnnData object or a pandas DataFrame, got {}' + .format(type(adata).__name__)) + return coords, groups diff --git a/cell2cell/spatial/neighborhoods.py b/cell2cell/spatial/neighborhoods.py index 26d3a85..d2cc62d 100644 --- a/cell2cell/spatial/neighborhoods.py +++ b/cell2cell/spatial/neighborhoods.py @@ -2,6 +2,8 @@ import numpy as np import pandas as pd +from natsort import natsorted + def create_spatial_grid(adata, num_bins, copy=False): """ @@ -162,9 +164,10 @@ def add_sliding_window_info_to_adata(adata, window_mapping): """ # Initialize all window columns to 0.0 - for window in sorted(window_mapping.keys()): + for window in natsorted(window_mapping.keys()): adata.obs[window] = 0.0 # Mark cells that belong to each window for window, barcode_indeces in window_mapping.items(): - adata.obs.loc[barcode_indeces, window] = 1.0 \ No newline at end of file + # Converted to a list because pandas does not accept a set as an indexer + adata.obs.loc[list(barcode_indeces), window] = 1.0 \ No newline at end of file diff --git a/cell2cell/stats/permutation.py b/cell2cell/stats/permutation.py index 656eb40..0488162 100644 --- a/cell2cell/stats/permutation.py +++ b/cell2cell/stats/permutation.py @@ -10,8 +10,9 @@ import seaborn as sns import cell2cell.core.interaction_space as ispace -from cell2cell.preprocessing import shuffle_rows_in_df +from cell2cell.preprocessing import shuffle_rows_in_df, zero_diagonal +from natsort import natsorted from sklearn.utils import shuffle from tqdm import tqdm @@ -142,7 +143,10 @@ def pvalue_from_dist(obs_value, dist, label='', consider_size=False, comparison= label_ = label + ' - p-val: <{:g}'.format(float('{:.1g}'.format(1. / len(dist)))) else: label_ = label + ' - p-val: {0:.2E}'.format(pval) - fig = sns.distplot(dist, hist=True, kde=True, norm_hist=False, rug=False, label=label_) + # `sns.distplot` is deprecated and removed in seaborn 0.14. It normalized the + # histogram to a density whenever a KDE was drawn, regardless of `norm_hist`, + # so `stat='density'` reproduces what it did here. + fig = sns.histplot(dist, kde=True, stat='density', label=label_) fig.axvline(x=obs_value, color=fig.get_lines()[-1].get_c(), ls='--') fig.tick_params(axis='both', which='major', labelsize=16) @@ -196,24 +200,31 @@ def random_switching_ppi_labels(ppi_data, genes=None, random_state=None, interac prot_b = interaction_columns[1] if permuted_column == 'both': if genes is None: - genes = list(np.unique(ppi_data_[interaction_columns].values.flatten())) + # `interaction_columns` is a tuple, which pandas would treat as a single + # column name, so it is converted into a list before selecting them. + # An object dtype is requested because the values are protein names, which + # pandas >= 3.0 returns as an extension array that has no `.ravel()`. + genes = list(np.unique(ppi_data_[list(interaction_columns)].to_numpy(dtype=object).ravel())) else: - genes = list(set(genes)) + # Sorted to make the permutation reproducible for a given random_state + genes = natsorted(set(genes)) mapper = dict(zip(genes, shuffle(genes, random_state=random_state))) ppi_data_[prot_a] = ppi_data_[prot_a].apply(lambda x: mapper[x]) ppi_data_[prot_b] = ppi_data_[prot_b].apply(lambda x: mapper[x]) elif permuted_column == 'first': if genes is None: - genes = list(np.unique(ppi_data_[prot_a].values.flatten())) + genes = list(np.unique(ppi_data_[prot_a].to_numpy(dtype=object))) else: - genes = list(set(genes)) + # Sorted to make the permutation reproducible for a given random_state + genes = natsorted(set(genes)) mapper = dict(zip(genes, shuffle(genes, random_state=random_state))) ppi_data_[prot_a] = ppi_data_[prot_a].apply(lambda x: mapper[x]) elif permuted_column == 'second': if genes is None: - genes = list(np.unique(ppi_data_[prot_b].values.flatten())) + genes = list(np.unique(ppi_data_[prot_b].to_numpy(dtype=object))) else: - genes = list(set(genes)) + # Sorted to make the permutation reproducible for a given random_state + genes = natsorted(set(genes)) mapper = dict(zip(genes, shuffle(genes, random_state=random_state))) ppi_data_[prot_b] = ppi_data_[prot_b].apply(lambda x: mapper[x]) else: raise ValueError('Not valid option') @@ -331,9 +342,9 @@ def run_label_permutation(rnaseq_data, ppi_data, genes, analysis_setup, cutoff_s genes = list(rnaseq_data.index) if excluded_cells is not None: - included_cells = sorted(list(set(rnaseq_data.columns) - set(excluded_cells))) + included_cells = natsorted(set(rnaseq_data.columns) - set(excluded_cells)) else: - included_cells = sorted(list(set(rnaseq_data.columns))) + included_cells = natsorted(set(rnaseq_data.columns)) rnaseq_data_ = rnaseq_data.loc[genes, included_cells] @@ -367,12 +378,15 @@ def run_label_permutation(rnaseq_data, ppi_data, genes, analysis_setup, cutoff_s cci_type=analysis_setup['cci_type'], verbose=verbose) + # The CCI matrix is only filled by this method. Without it, the scores below are the + # zeros the interaction space is initialized with. + interaction_space.compute_pairwise_cci_scores(verbose=verbose) + # Keep scores cci = interaction_space.interaction_elements['cci_matrix'].loc[included_cells, included_cells] cci_diag = np.diag(cci).copy() - np.fill_diagonal(cci.values, 0.0) - iter_scores = scipy.spatial.distance.squareform(cci) + iter_scores = scipy.spatial.distance.squareform(zero_diagonal(cci)) iter_scores = np.reshape(iter_scores, (len(iter_scores), 1)).T iter_diag = np.reshape(cci_diag, (len(cci_diag), 1)).T @@ -396,12 +410,13 @@ def run_label_permutation(rnaseq_data, ppi_data, genes, analysis_setup, cutoff_s cci_type=analysis_setup['cci_type'], verbose=verbose) + base_interaction_space.compute_pairwise_cci_scores(verbose=verbose) + # Keep scores base_cci = base_interaction_space.interaction_elements['cci_matrix'].loc[included_cells, included_cells] base_cci_diag = np.diag(base_cci).copy() - np.fill_diagonal(base_cci.values, 0.0) - base_scores = scipy.spatial.distance.squareform(base_cci) + base_scores = scipy.spatial.distance.squareform(zero_diagonal(base_cci)) # P-values pvals = np.zeros((scores.shape[1], 1)) diff --git a/cell2cell/tensor/external_scores.py b/cell2cell/tensor/external_scores.py index 18e7995..c075430 100644 --- a/cell2cell/tensor/external_scores.py +++ b/cell2cell/tensor/external_scores.py @@ -4,11 +4,30 @@ import pandas as pd from collections import defaultdict +from natsort import natsorted from tqdm import tqdm from cell2cell.preprocessing.find_elements import get_element_abundances, get_elements_over_fraction from cell2cell.tensor.tensor import PreBuiltTensor +def _ordered_intersection(element_lists): + '''Intersects multiple lists of elements, keeping the order in which the + elements appear in the first list. + + Parameters + ---------- + element_lists : list + A list containing lists of elements, one per context. + + Returns + ------- + elements : list + Elements present in all lists, ordered as in `element_lists[0]`. + ''' + common = set.intersection(*map(set, element_lists)) + return [e for e in element_lists[0] if e in common] + + def dataframes_to_tensor(context_df_dict, sender_col, receiver_col, ligand_col, receptor_col, score_col, how='inner', outer_fraction=0.0, lr_fill=np.nan, cell_fill=np.nan, lr_sep='^', dup_aggregation='max', context_order=None, order_labels=None, sort_elements=True, device=None): @@ -123,10 +142,12 @@ def dataframes_to_tensor(context_df_dict, sender_col, receiver_col, ligand_col, if order_labels is None: order_labels = ['Contexts', 'Ligand-Receptor Pairs', 'Sender Cells', 'Receiver Cells'] - # Find all existing LR pairs, sender and receiver cells across contexts - lr_dict = defaultdict(set) - sender_dict = defaultdict(set) - receiver_dict = defaultdict(set) + # Find all existing LR pairs, sender and receiver cells across contexts. + # Lists (instead of sets) are used to keep the order in which elements are found, + # making the tensor reproducible across runs when `sort_elements=False`. + lr_dict = defaultdict(list) + sender_dict = defaultdict(list) + receiver_dict = defaultdict(list) for k, df in cont_dict.items(): df['LRs'] = df.apply(lambda row: row[ligand_col] + lr_sep + row[receptor_col], axis=1) @@ -136,9 +157,9 @@ def dataframes_to_tensor(context_df_dict, sender_col, receiver_col, ligand_col, # ccc_df = ccc_df.dropna(how='any') # lr_dict[k].update(list(ccc_df.index)) # else: - lr_dict[k].update(df['LRs'].unique().tolist()) - sender_dict[k].update(df[sender_col].unique().tolist()) - receiver_dict[k].update(df[receiver_col].unique().tolist()) + lr_dict[k] = list(dict.fromkeys(lr_dict[k] + df['LRs'].unique().tolist())) + sender_dict[k] = list(dict.fromkeys(sender_dict[k] + df[sender_col].unique().tolist())) + receiver_dict[k] = list(dict.fromkeys(receiver_dict[k] + df[receiver_col].unique().tolist())) # Subset LR pairs, sender and receiver cells given parameter 'how' df_lrs = [list(lr_dict[k]) for k in context_order] @@ -146,9 +167,9 @@ def dataframes_to_tensor(context_df_dict, sender_col, receiver_col, ligand_col, df_receivers = [list(receiver_dict[k]) for k in context_order] if how == 'inner': - lr_pairs = list(set.intersection(*map(set, df_lrs))) - sender_cells = list(set.intersection(*map(set, df_senders))) - receiver_cells = list(set.intersection(*map(set, df_receivers))) + lr_pairs = _ordered_intersection(df_lrs) + sender_cells = _ordered_intersection(df_senders) + receiver_cells = _ordered_intersection(df_receivers) elif how == 'outer': lr_pairs = get_elements_over_fraction(abundance_dict=get_element_abundances(element_lists=df_lrs), fraction=outer_fraction) @@ -159,10 +180,10 @@ def dataframes_to_tensor(context_df_dict, sender_col, receiver_col, ligand_col, elif how == 'outer_lrs': lr_pairs = get_elements_over_fraction(abundance_dict=get_element_abundances(element_lists=df_lrs), fraction=outer_fraction) - sender_cells = list(set.intersection(*map(set, df_senders))) - receiver_cells = list(set.intersection(*map(set, df_receivers))) + sender_cells = _ordered_intersection(df_senders) + receiver_cells = _ordered_intersection(df_receivers) elif how == 'outer_cells': - lr_pairs = list(set.intersection(*map(set, df_lrs))) + lr_pairs = _ordered_intersection(df_lrs) sender_cells = get_elements_over_fraction(abundance_dict=get_element_abundances(element_lists=df_senders), fraction=outer_fraction) receiver_cells = get_elements_over_fraction(abundance_dict=get_element_abundances(element_lists=df_receivers), @@ -172,10 +193,10 @@ def dataframes_to_tensor(context_df_dict, sender_col, receiver_col, ligand_col, if sort_elements: if sort_context: - context_order = sorted(context_order) - lr_pairs = sorted(lr_pairs) - sender_cells = sorted(sender_cells) - receiver_cells = sorted(receiver_cells) + context_order = natsorted(context_order) + lr_pairs = natsorted(lr_pairs) + sender_cells = natsorted(sender_cells) + receiver_cells = natsorted(receiver_cells) # Build temporal tensor to pass to PreBuiltTensor tmp_tensor = [] diff --git a/cell2cell/tensor/tensor.py b/cell2cell/tensor/tensor.py index f4b2890..52d4bac 100644 --- a/cell2cell/tensor/tensor.py +++ b/cell2cell/tensor/tensor.py @@ -5,6 +5,7 @@ import tensorly as tl from collections import OrderedDict +from natsort import natsorted from tqdm import tqdm from cell2cell.core.communication_scores import compute_ccc_matrix, aggregate_ccc_matrices @@ -1142,12 +1143,12 @@ def build_context_ccc_tensor(rnaseq_matrices, ppi_data, how='inner', outer_fract if set(df_idxs[0]) == genes: genes = df_idxs[0] else: - genes = sorted(list(genes)) + genes = natsorted(genes) if set(df_cols[0]) == cells: cells = df_cols[0] else: - cells = sorted(list(cells)) + cells = natsorted(cells) # Filter PPI data for ppi_data_ = filter_ppi_by_proteins(ppi_data=ppi_data, diff --git a/cell2cell/tensor/tensor_manipulation.py b/cell2cell/tensor/tensor_manipulation.py index e62ff77..2d960af 100644 --- a/cell2cell/tensor/tensor_manipulation.py +++ b/cell2cell/tensor/tensor_manipulation.py @@ -65,19 +65,24 @@ def concatenate_interaction_tensors(interaction_tensors, axis, order_labels, rem except: context = {'dtype': interaction_tensors[0].tensor.dtype, 'device' : None} - # Concatenate tensors - concat_tensor = tl.concatenate([tensor.tensor.to('cpu') for tensor in interaction_tensors], axis=axis) + # Concatenate tensors. `.to('cpu')` only exists in backends such as pytorch, so + # it is skipped for backends whose tensors are numpy arrays (the default one). + def to_cpu(data): + return data.to('cpu') if hasattr(data, 'to') else data + + concat_tensor = tl.concatenate([to_cpu(tensor.tensor) for tensor in interaction_tensors], axis=axis) if mask is not None: assert mask.shape == concat_tensor.shape, "Mask must have the same shape of the concatenated tensor. Here: {}".format(concat_tensor.shape) else: # Generate a new mask from all previous masks if all are not None if all([tensor.mask is not None for tensor in interaction_tensors]): - mask = tl.concatenate([tensor.mask.to('cpu') for tensor in interaction_tensors], axis=axis) + mask = tl.concatenate([to_cpu(tensor.mask) for tensor in interaction_tensors], axis=axis) else: mask = None - concat_tensor = tl.tensor(concat_tensor, device=context['device']) + # The context of a numpy-backed tensor does not include a 'device' key + concat_tensor = tl.tensor(concat_tensor, device=context.get('device', None)) if mask is not None: - mask = tl.tensor(mask, device=context['device']) + mask = tl.tensor(mask, device=context.get('device', None)) # Concatenate names of elements for the given axis but keep the others as in one tensor order_names = [] diff --git a/ci/constraints-py3.10.txt b/ci/constraints-py3.10.txt new file mode 100644 index 0000000..c8d058f --- /dev/null +++ b/ci/constraints-py3.10.txt @@ -0,0 +1,65 @@ +# Versions the CI job resolved on Python 3.10, pinned so a new release of a +# dependency cannot turn a build red without a commit to this repository. +# +# This constrains CI only. setup.py stays open, so nothing here limits what a +# user installing cell2cell gets. +# +# To move to newer dependencies, regenerate on Python 3.10 and commit the diff: +# python -m venv /tmp/c && /tmp/c/bin/pip install -e '.[test]' +# /tmp/c/bin/pip freeze --exclude-editable > ci/constraints-py3.10.txt +# then re-add this header. Review the diff and run the suite before pushing. +anndata==0.11.4 +array-api-compat==1.15.0 +certifi==2026.7.22 +charset-normalizer==3.4.9 +contourpy==1.3.2 +coverage==7.15.3 +cycler==0.12.1 +et_xmlfile==2.0.0 +exceptiongroup==1.3.1 +fonttools==4.63.0 +gseapy==1.3.1 +h5py==3.16.0 +idna==3.18 +iniconfig==2.3.0 +joblib==1.5.3 +kiwisolver==1.5.0 +kneed==0.8.6 +legacy-api-wrap==1.5 +llvmlite==0.48.0 +matplotlib==3.10.9 +natsort==8.4.0 +networkx==3.4.2 +numba==0.66.0 +numpy==2.2.6 +openpyxl==3.1.5 +packaging==26.3 +pandas==2.3.3 +patsy==1.0.2 +pillow==12.3.0 +pluggy==1.6.0 +Pygments==2.20.0 +pynndescent==0.6.0 +pyparsing==3.3.2 +pytest-cov==7.1.0 +pytest==9.1.1 +python-dateutil==2.9.0.post0 +pytz==2026.3.post1 +requests==2.34.2 +scanpy==1.11.5 +scikit-learn==1.7.2 +scipy==1.15.3 +seaborn==0.13.2 +session-info2==0.4.1 +six==1.17.0 +statannotations==0.7.2 +statsmodels==0.14.6 +tensorly==0.9.0 +threadpoolctl==3.6.0 +tomli==2.4.1 +tqdm==4.70.0 +typing_extensions==4.16.0 +tzdata==2026.3 +umap-learn==0.5.12 +urllib3==2.7.0 +xlrd==2.0.2 diff --git a/ci/constraints-py3.12.txt b/ci/constraints-py3.12.txt new file mode 100644 index 0000000..6e9fc63 --- /dev/null +++ b/ci/constraints-py3.12.txt @@ -0,0 +1,75 @@ +# Versions the CI job resolved on Python 3.12, pinned so a new release of a +# dependency cannot turn a build red without a commit to this repository. +# +# This constrains CI only. setup.py stays open, so nothing here limits what a +# user installing cell2cell gets. +# +# To move to newer dependencies, regenerate on Python 3.12 and commit the diff: +# python -m venv /tmp/c && /tmp/c/bin/pip install -e '.[test]' +# /tmp/c/bin/pip freeze --exclude-editable > ci/constraints-py3.12.txt +# then re-add this header. Review the diff and run the suite before pushing. +anndata==0.13.2 +annotated-types==0.8.0 +array-api-compat==1.15.0 +certifi==2026.7.22 +charset-normalizer==3.4.9 +contourpy==1.3.3 +coverage==7.15.3 +cycler==0.12.1 +donfig==0.8.1.post1 +et_xmlfile==2.0.0 +fast-array-utils==1.5 +fonttools==4.63.0 +google-crc32c==1.8.0 +gseapy==1.3.1 +h5py==3.16.0 +idna==3.18 +iniconfig==2.3.0 +joblib==1.5.3 +kiwisolver==1.5.0 +kneed==0.8.6 +legacy-api-wrap==1.5 +llvmlite==0.48.0 +matplotlib==3.11.1 +narwhals==2.24.0 +natsort==8.4.0 +networkx==3.6.1 +numba==0.66.0 +numcodecs==0.16.5 +numpy==2.4.6 +openpyxl==3.1.5 +packaging==26.3 +pandas==3.0.5 +patsy==1.0.2 +pillow==12.3.0 +pluggy==1.6.0 +pydantic_core==2.46.4 +pydantic-settings==2.14.2 +pydantic==2.13.4 +Pygments==2.20.0 +pynndescent==0.6.0 +pyparsing==3.3.2 +pytest-cov==7.1.0 +pytest==9.1.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +PyYAML==6.0.3 +requests==2.34.2 +scanpy==1.12.3 +scikit-learn==1.9.0 +scipy==1.18.0 +scverse-misc==0.1.3 +seaborn==0.13.2 +session-info2==0.4.2 +six==1.17.0 +statannotations==0.7.2 +statsmodels==0.14.6 +tensorly==0.9.0 +threadpoolctl==3.6.0 +tqdm==4.70.0 +typing_extensions==4.16.0 +typing-inspection==0.4.2 +umap-learn==0.5.12 +urllib3==2.7.0 +xlrd==2.0.2 +zarr==3.3.0 diff --git a/docs/index.md b/docs/index.md index 61aa7bd..8e370a6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,7 +42,7 @@ pip install cell2cell | cell2cell Examples | Tensor-cell2cell Examples | |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | ![cell2cell Logo](https://github.com/earmingol/cell2cell/blob/master/Logo.png?raw=true) | ![Tensor-cell2cell Logo](https://github.com/earmingol/cell2cell/blob/master/LogoTensor.png?raw=true) | -| - [Step-by-step Pipeline](https://github.com/earmingol/cell2cell/blob/master/examples/cell2cell/Toy-Example.ipynb)
- [Interaction Pipeline for Bulk Data](./tutorials/Toy-Example-BulkPipeline)
- [Interaction Pipeline for Single-Cell Data](./tutorials/Toy-Example-SingleCellPipeline)
- [Whole Body of *C. elegans*](https://github.com/LewisLabUCSD/Celegans-cell2cell) | - [Obtaining patterns of cell-cell communication](./tutorials/ASD/01-Tensor-Factorization-ASD/)
- [Downstream 1: Factor-specific analyses](./tutorials/ASD/02-Factor-Specific-ASD/)
- [Downstream 2: Patterns to functions (GSEA)](./tutorials/ASD/03-GSEA-ASD/)
- [Tensor-cell2cell in Google Colab (**GPU**)](https://colab.research.google.com/drive/1T6MUoxafTHYhjvenDbEtQoveIlHT2U6_?usp=sharing)
- [Communication patterns in **Spatial Transcriptomics**](./tutorials/Tensor-cell2cell-Spatial/)
- [Multi-modal communication patterns with **Coupled Tensor Component Analysis**](./tutorials/Version2/Tensor-cell2cell-CTCA/) | +| - [Step-by-step Pipeline](https://github.com/earmingol/cell2cell/blob/master/examples/cell2cell/Toy-Example.ipynb)
- [Interaction Pipeline for Bulk Data](./tutorials/Toy-Example-BulkPipeline)
- [Interaction Pipeline for Single-Cell Data](./tutorials/Toy-Example-SingleCellPipeline)
- [Whole Body of *C. elegans*](https://github.com/LewisLabUCSD/Celegans-cell2cell)
- [Identifying a **spatial code** of ligand-receptor pairs (Genetic Algorithm)](./tutorials/Genetic-Algorithm-LR-Selection/) | - [Obtaining patterns of cell-cell communication](./tutorials/ASD/01-Tensor-Factorization-ASD/)
- [Downstream 1: Factor-specific analyses](./tutorials/ASD/02-Factor-Specific-ASD/)
- [Downstream 2: Patterns to functions (GSEA)](./tutorials/ASD/03-GSEA-ASD/)
- [Tensor-cell2cell in Google Colab (**GPU**)](https://colab.research.google.com/drive/1T6MUoxafTHYhjvenDbEtQoveIlHT2U6_?usp=sharing)
- [Communication patterns in **Spatial Transcriptomics**](./tutorials/Tensor-cell2cell-Spatial/)
- [Multi-modal communication patterns with **Coupled Tensor Component Analysis**](./tutorials/Version2/Tensor-cell2cell-CTCA/) | Reproducible runs of the analyses in the [Tensor-cell2cell paper](https://doi.org/10.1038/s41467-022-31369-2) are available at [CodeOcean.com](https://doi.org/10.24433/CO.0051950.v2) diff --git a/docs/tutorials/Genetic-Algorithm-LR-Selection.ipynb b/docs/tutorials/Genetic-Algorithm-LR-Selection.ipynb new file mode 100644 index 0000000..3372e65 --- /dev/null +++ b/docs/tutorials/Genetic-Algorithm-LR-Selection.ipynb @@ -0,0 +1,1714 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3984da0a", + "metadata": {}, + "source": [ + "# Identifying a spatial code of ligand-receptor pairs with a genetic algorithm\n", + "\n", + "Cells that sit close together in a tissue should be talking to each other — but only some of the\n", + "ligand-receptor pairs in a resource carry that spatial signal, and the rest dilute it. This tutorial\n", + "searches for the subset whose cell-cell interaction scores best reproduce **how far apart the cells\n", + "actually are**, using a genetic algorithm.\n", + "\n", + "The pairs it recovers are a *spatial code*: the interactions whose activity tracks physical\n", + "proximity, and which can therefore be read as a signature of where cells are relative to one another.\n", + "\n", + "This is the analysis of\n", + "[Armingol et al. (2022)](https://doi.org/10.1371/journal.pcbi.1010715) on the whole body of\n", + "*C. elegans*, where the reference was a digital 3D map of the animal\n", + "([Celegans-cell2cell](https://github.com/LewisLabUCSD/Celegans-cell2cell)). It is now part of the\n", + "package as `cell2cell.analysis.optimize_lr_pairs`, and §6 reproduces it end to end.\n", + "\n", + "The reference does not have to be a physical distance — any square matrix of distances between the\n", + "same cells works, so the same search applies to developmental, functional or phenotypic similarity.\n", + "Here we start from spatial transcriptomics, using the helpers that turn single-cell coordinates into\n", + "cell-type distances, and finish on the *C. elegans* 3D map.\n", + "\n", + "**Requirements.** The genetic algorithm needs the optional dependency `pygad`:\n", + "\n", + "```\n", + "pip install cell2cell[ga]\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "44fd2f26", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:19.971590Z", + "iopub.status.busy": "2026-08-23T22:44:19.971388Z", + "iopub.status.idle": "2026-08-23T22:44:26.339568Z", + "shell.execute_reply": "2026-08-23T22:44:26.339079Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/private/tmp/claude-503/-Users-eg22-Repos-cell2cell/fa1914c8-fe56-40d2-b837-7db6feb472f8/scratchpad/venv-ci312/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "import cell2cell as c2c\n", + "\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "id": "21a766fe", + "metadata": {}, + "source": [ + "## 1. Data\n", + "\n", + "We build a small synthetic system where we know the answer in advance, so the search can be judged.\n", + "\n", + "Twelve cell types sit at different positions along an axis. Around each one we scatter single cells,\n", + "which is what a spatial transcriptomics experiment would give us." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "927a32e7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.341694Z", + "iopub.status.busy": "2026-08-23T22:44:26.341470Z", + "iopub.status.idle": "2026-08-23T22:44:26.346324Z", + "shell.execute_reply": "2026-08-23T22:44:26.345922Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "AnnData object with n_obs × n_vars = 480 × 1\n", + " obs: 'celltype'\n", + " obsm: 'spatial'\n", + " layers: None (.X)" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import anndata\n", + "\n", + "rng = np.random.default_rng(0)\n", + "\n", + "n_celltypes = 12\n", + "cells_per_type = 40\n", + "positions = np.linspace(0, 100, n_celltypes)\n", + "celltypes = ['CT-{}'.format(i + 1) for i in range(n_celltypes)]\n", + "\n", + "coords, labels = [], []\n", + "for name, x in zip(celltypes, positions):\n", + " coords.append(np.column_stack([rng.normal(x, 2.5, cells_per_type),\n", + " rng.normal(0, 2.5, cells_per_type)]))\n", + " labels += [name] * cells_per_type\n", + "\n", + "coords = np.vstack(coords)\n", + "adata = anndata.AnnData(X=np.zeros((len(coords), 1), dtype=float),\n", + " obs=pd.DataFrame({'celltype': labels},\n", + " index=['cell-{}'.format(i) for i in range(len(coords))]))\n", + "adata.obsm['spatial'] = coords\n", + "adata" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "bf559184", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.347622Z", + "iopub.status.busy": "2026-08-23T22:44:26.347532Z", + "iopub.status.idle": "2026-08-23T22:44:26.444169Z", + "shell.execute_reply": "2026-08-23T22:44:26.443809Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAwgAAAFICAYAAAD0wtlSAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAjvFJREFUeJztnQeYFFXWhs/kGWbISBJEARURMIGCCIqKrJjTmtc1J1RQjL+65oiKCRUjZnSNa9gVFQQUVIwgZhBBkQGGNDnV/3y3u5rq6qruqurqON/7PENxb1dXV1enc+453zk5mqZpQgghhBBCCCEiksurQAghhBBCCNGhg0AIIYQQQggJQQeBEEIIIYQQEoIOAiGEEEIIISQEHQRCCCGEEEJICDoIhBBCCCGEkBB0EAghhBBCCCEh6CAQQgghhBBCQtBBIISkDUOHDpWDDjoo6x7Lj3NL1/NN1/NySlVVleTn58stt9wSdb9PP/1U7ffmm29KpvDjjz+qc37++eejzhFCiBk6CISQhNLQ0CCPPvqoDBs2TDp37iydOnVS/7/99ttl9erVYfs2NjZKU1NTUl6RZD6WH+eWruebruflFE3T1Pk3Nzf7sl86YXXOTp/HN998oxyJl156KQlnSghJN/JTfQKEkOzm+OOPV6uukyZNkqOOOkpKS0vlf//7n1x66aUyf/58ee2118JWaXNyclJ6voSQzHSICCH+wQgCISRhfP/99/LKK6/IueeeKxdeeKFsueWW0q5dOzn22GPl66+/lt133z1s/7y8PMnN5dcSIYQQkkr4S0wISRhr165V2x49ekTc1qFDB7nyyitj5rP369dPORTInT7ggAOkTZs2svXWW8sdd9wRcUyseN5www2yzTbbqP323XdfWbRokYpcDBgwwNE5v/322+p+7du3V8fYZ599ZNasWY7vO3r0aOnYsaNKpRo7dqx89tlnvh3fDO6Hx+vSpYt6zJEjRyqHzK9zffjhh2WXXXaRsrIylR6G6/jdd985Or6T++K2Sy65JOK+hx12mOy8886W7wMcA++Dtm3bykUXXaRuq62tleuvv1769++vIlTdu3eXM844Q8rLy8OOsXz5cvn73/+urj2u2QUXXCB1dXXilkceeUT69u2rzn+vvfaSuXPnhm5D2hzOwep5rV+/Xr3m48aNi3p8PJ8bb7xRBg4cqB6jT58+Mn78eKmoqAjbx8lz9gIifoMHD1b/P/HEE1WqEf7w2XLz/Iy6jWjXLBnPiRDiDjoIhJCEAaO8devW8uSTT8qSJUs8597DQLjiiivktttuk6VLl8rZZ58tl19+ubz66qth+yJKAbHp//3f/6nHQ1rTZZddpowaHCcWDzzwgBxyyCEyfPhwFeH46aefZMSIEbL//vvLjBkzot538uTJ6r4w6ubNm6fui/OB1sKP45v5+eef5cADD1TG1JdffqmeL67Piy++KMuWLYv7XJECBkPv5JNPVsf+8MMPlYEKJw5OVzSc3tdOv4A58+uF8apVq2TixInqNV68eLG6jvX19TJmzBiZOnWq3HzzzcoJ+O9//6tuh8NUWVmp7r9hwwbZe++91eO/88476vYhQ4Yow9sNzzzzjPz666/y0UcfqdcQjgZev88//1zdvsUWWygHB+/5mpqasPtibtOmTfLPf/7T9vhwWHC8+++/XznQuH7vv/++9O7dWx5//HG1j9Pn7BW8N2Dc688Xhjv+rr76alfPT09TmjZtWtRrloznRAhxiUYIIQnkzTff1LbYYgstNzdXGzx4sHbWWWdpzzzzjLZ69eqIfXfbbTdtzJgxYXN9+vTRioqKtBUrVoTN9+vXTzvggANC46VLl6rHuPzyy8P2w3x+fr62/fbbR32s8vJyrbi4WDv11FMjzguPs8suu9g+x5UrV6pzPPHEE233cXN8q+tgnnvqqac0fIX//vvvto/p9Vx/++03dS3POeecsPl169Zpbdq00Q455BDb83JzX5zHRRddFPH4Bx10kLbjjjtGvA/y8vLU62lkypQp6jp88MEHYfPLly9Xx7/rrrvU+JZbblH7ff3112H73XDDDWr+xhtv1KIxb948td++++4bNl9bW6t1795d23///UNzc+fOVfs+/vjjobnm5mZtu+220wYNGhT1ce677z5133fffdd2H6fP+fvvv1f74fOmYzVnxVdffaX2e+GFFyJuc/r83Fwzp8+JEJIcGEEghCQUrEZiNfA///mPSh9auXKligAgDeiJJ55wdIxdd91V6ReMYPXbGJVAug0ElYceemjYfkhHwr6xeO+999QqKVJQzCAV56uvvlKr0FZg9R8rv1gxT8Tx7aIzEHSfeeaZ8sEHHzhOlXFyrjNnzlTXEmlBRqAfwbni8RJx31gg7QivpxGkryBN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ksyDr2d2wdczu+DrmV3w9cw+NmaRTYS4ACp8du/eXXJzc7MjxSgZ4GJF86haGvggZPqHgWyGr2d2wdczu+DrmV3w9cw+2mSJTdS2bduWVcWIEEIIIYQQEh90EAghhBBCCCEh6CAQS1AB6l//+hebyGUJfD2zC76e2QVfz+yCr2f2UdQCbSKKlAkhhBBCCCEhGEEghBBCCCGEhKCDQAghhBBCCAlBB4EQQgghhBASgn0QSBi1tbXy7LPPyvfff6/6QZx88snSqVMnXqUMoKmpSd5++2357LPPpFWrVrLvvvvK0KFDI/b77bff5IUXXpCKigoZPHiwHHPMMVGbpZDU88MPP8gDDzwgu+yyi5x++ulht61bt06eeeYZWbZsmWy77bbyj3/8Q73+JD358ccf5bXXXlONiv72t7/JiBEjwm6vr69X38GLFy+Wbt26qe/gzp07p+x8iT2VlZUyffp0+emnn6S0tFRGjhwp++yzT8R+eL0/+eQTVXv+2GOPVZ9Tkh72zksvvaR+M0888UQZNmyYZYO0p59+WpYuXSp9+vRR369lZWWu98lEaBWQsA8LvuDuvfdeadeunfznP/+RnXbaSXUOJOkNvqAGDBggTzzxhBQXF8uaNWtk9OjRctVVV4Xt98UXX8jAgQPVFkbkpZdeKkcffXTKzps4+1zCqMAP2bvvvht226pVq2TXXXeVl19+WX1mp06dqpxCGC4k/XjkkUeUk7dkyRLp0KGD3HDDDeo1MzoHo0aNkkmTJqlmTP/73//U5xWGB0kvVq9erb5z8ZritYTDd+ihh8oll1wStt+pp54q5513nvpehqM/aNAg+eijj1J23iTAO++8o4z5GTNmqNfwu+++i7g0a9euld12202ee+459f365JNPypAhQ2T9+vWu9slYNEKCTJ48WWvXrp22du1aNa6vr9cGDRqknXrqqbxGaU51dbX2008/hc298MILGj7iK1asCM3ttdde2hFHHBEaL1q0SO3z9ttvJ/V8iXPOPfdc9XfQQQdpRx11VNht48aN0/r166fV1taq8fr167UttthCu/nmm3mJ04xvv/1Wy8/P155++umw+aVLl4b+//DDD2ulpaXaqlWr1LixsVEbMmSIdvzxxyf9fEl0pk6dqhUVFWmVlZWhufvvv1/NNTQ0qPHcuXPV9+unn34a2ufkk0/WBg4cyMubYvB7qds6eM0effTRiH0mTpyo9e7dW/2+gk2bNmndu3fXrrnmGlf7ZCqMIJAQb7zxhhx44IFqNQQUFBTIcccdp+ZJelNSUhIRtt5xxx3V9q+//gqtdHz88cdy0kknhe2DFWi+xunJq6++KrNmzZK77rrL8na8bkgR02tzI4XhkEMO4euZhiBSoKdtGtl6663DXs8DDjgglFKUl5cnxx9/vLz55ptYzEv6ORN7unbtqtI6q6qqQnNYNcZrl5+fH3o9+/btK7vvvntoH7z+CxcuZFQoxeD3Urd17HjjjTfkqKOOUr+vAGlDhx9+eNj3q5N9MhU6CCTEzz//LNtss03YFendu7fKVccfySyQbtSxY8eQo/DLL78oI8PqNcZrT9KL33//XaUmIHSt//gYqaurU+l/fD0zA6T1DR8+XOU7X3HFFXLTTTfJvHnzHH0HwwhduXJlks+YRAOO+I033qi0XqeddpocdthhSgP2+uuvh72eeP2M6GN+56Y3mqbJr7/+GvX71ck+mQwdBBKipqZGWrduHXZFkAcLqqureaUyCOSk33ffffLQQw+p3Ff99QVWrzFf3/SisbFRrRxffPHFKmfdCr6emacT+vzzz+Xss89WnznksO+3335y6623hvbhd3BmvZ6zZ88ORW8RKUChALzGOnw9M5e6ujppbm62/L3E6wrnwMk+mQyrGJEQCI2ZhTWokALMHwCSvrz11lsqjQgOAtJPdPSqClavse4IkvQA4tQFCxaoIgHjxo1TcxDRIeUEY4hb8ZnMycnh65kh4PXCawgjsn379moOhuWECRPkwgsvVFVwon0H8zOaXtx5553y7bffqpViPcK3xx57qGo4Y8eOlZ49e/L1zGCKiopUqpjV51H/7nWyTybDCAIJgVQUlDc1gvGWW26pcptJZlRmQFUiVEE5//zzw27bfvvtlYFp9Rr3798/yWdKooHXA7oDbPv166f+YEDiRwf/hz4IfzAw+XpmBqh4A72B7hwARIcQLVqxYkXU72CkCrLUaXqB0qb4fBrT/1DNBq8n0k701xOVi4wryfrru8MOO6TgrIlTcnJy1Osb7fvVyT4ZTapV0iR9mDZtmlZSUqL9/PPPoYooW2+9tXbxxRen+tSIA959912tuLhYu++++2z3OeSQQ7QRI0aEqmx88MEHqsrGJ598wmuc5lhVMUKljC233DJUjeO3337TysrKtClTpqToLIkdH374ofp8LlmyJDR37bXXam3btlUV48D06dO1goIC7fvvvw9VRNluu+208847jxc2zbjxxhu1Dh06aOXl5aG5Bx54QFWqWrlypRovXLhQy8nJ0V5//XU1bmpq0saMGaONHDkyZedNIrGrYnTzzTdrXbp0CVUVQ0VAVHq8++67Xe2TqeTgn1Q7KSQ9QC4d6q0jr3L//feXTz/9VEUOPvjgA1Xfl6Qvf/75p6rp3KVLFzn44IPDbjvrrLNU7W29SRoa+WAVExEFRBzOOeccueOOO1J05sQpeF2hJ/n3v/8dmoN4Ff0uIGDdc889ZebMmaoqFRozIcJA0gv0HXnqqadUCgp6WKB51rRp0+SII45Qt+PnGFVukGKGakZIM0MaA15XRBFI+oDPHn4n8Z06ZswY1Xvm/fffl9tuu03Gjx8f2u/mm29WOhO85tgXhQXwm5oVK8wZDHqR3H333er/Dz/8sOoBhdcEFafQ6AxAR4DKjuhDstdee6n+FdgHPaL0ynFO9slU6CCQCObMmaPCokgtgvFBQyP9QQ4kuq9acdBBB4VVWcAPGwwQvZPyzjvvnMQzJV5BhRSkiKH7rhGUWoRhgqpHSDnae++9Mz73NZtB3jqErHDSYVBYpQ7BcYBeAZ2U4SgUFham5FxJdODQoXQ0dAjQj0CDsNVWW0Xsh9dy/vz5SkcCZ4J6ktSDRZVXXnklYh4LZ7B7jAuncOj0LsmoWmX+fnWyTyZCB4EQQgghhBASgiJlQgghhBBCSAg6CIQQQgghhJAQdBAIIYQQQgghIeggEEIIIYQQQkLQQSCEEEIIIYSEoINACCGEEEIICUEHgRBCCCGEEBKCDgIhhBBCCCEkBB0EQgghhBBCSAg6CIQQQpJKZWWl/Pbbb9LU1BQ2v3z5cqmoqOCrQQghKYYOAiGEkKRSW1sre+65p9xwww2huVdffVX69u0rv//+O18NQghJMTmapmmpPglCCCEtixkzZsjYsWNl1qxZsvXWW8ugQYPkyiuvlIkTJ6b61AghpMVDB4EQQkhKuOSSS+SVV16RXr16SUFBgXIacnJy+GoQQkiKoYNACCEkJdTX18u2224r5eXl8ssvv8iWW27JV4IQQtIAahAIIYSkhA8//FBWrlyp/j9z5ky+CoQQkiYwgkAIISTprF69WukOzjvvPGnXrp1cffXV8s033yg9AiGEkNRCB4EQQkjSOeyww2TNmjUye/ZsycvLU4LlTZs2KdEyxoQQQlIHU4wIIYQklddee01++OEHefbZZ0POwJNPPinr16+XadOm8dUghJAUwwgCIYQQQgghJAQjCIQQQgghhJAQdBAIIYQQQgghIeggEEIIIYQQQkLQQSCEEEIIIYSEoINACCGEEEIICUEHgRBCCCGEEBKCDgIhhBBCCCEkBB0EQgghhBBCSAg6CIQQQgghhJAQdBAIIYQQQgghIeggEEIIIYQQQkLQQSCEEEIIIYSEoINACCGEEEIICUEHgRBCCCGEEBKCDgIhhBBCCCEkBB0EQgghhBBCSAg6CIQQQgghhJAQdBAIIYQQQgghIeggEEIIIYQQQkLQQSCEEEIIIYSEoINACCGEEEIICUEHgRBCCCGEEBKCDgIhhBBCCCEkBB0EQgghhBBCSAg6CIQQQgghhJAQdBAIIYQQQgghIeggEEIIIYQQQkLQQSCEEEIIIYSEyN/8X0JIvDQ1NUlDQwMvJCGEkIRRUFAgeXl5vMIkYdBBIMQnKisrZcWKFaJpGq8pIYSQhJGTkyM9evSQsrIyXmWSEHI0WjOE+BI5+Pnnn6VVq1ayxRZbqC9vQgghxG9gtq1evVqqq6tl2223ZSSBJARGEAjxAaQV4UsbzkFJSQmvKSGEkISB35rffvtN/fYw1YgkAoqUCfERRg4IIYQkGv7WkETDCAIhWQSiGJ988olKd9pmm21k5MiR8vnnn8tnn30WsW/fvn3lb3/7W2gM/cTrr7+u/r/nnnvKrrvuKtlATU2NzJ07V/7880/ZZZddZNCgQfKf//xHli1bFrHvXnvtJTvvvHPY3I8//ijz5s2TAQMGyODBgyUbKC8vV8+pqqpKRowYIT179pSpU6dKfX19xL7HHHOMdOnSJTR+4403ZPny5aHxySefLG3btpVM55dffpEFCxZIUVGRjBo1Spqbm+X555+33HfcuHGh/yPVY/r06WG3Dx06NCveK1988YV899130q1bN9lnn33U98qHH34YsV/Xrl3l6KOPDptbsmSJzJkzR902evRoyc3N3vXIxsZG9VzxndK/f3/Zfffd5YMPPpDvv/8+Yl98Bw0fPjw0Xrx4ceiajhkzRqUMRZsnJFnQQSAkS9i0aZMcdNBBUlFRIUOGDJFp06YpY+fCCy+UH374Qe3z5JNPyvHHHy/FxcURqVAwFrEfnAkIrrPBQYBxc+ihh8qWW24pffr0kcmTJ8shhxyijBY8V1wzGLwnnXSS2n+HHXYIu/9TTz2l7rPbbrvJtddeKxdccIFceumlksm8+uqrcvbZZysnsHXr1nLDDTfI7bffrhyhuro6+emnn5QDsN9++4UcLCP333+/dO7cWTp06KDG2VC16/rrr5cpU6YoxwDG3tVXXy0PP/xw6HMze/Zs6dixo+y4444R98X10fcDL7/8sjzzzDOSyeAawOBfuHChcpr/+usv9f6/9dZbQ8/1pZdekn333Vc6deqk3jdGZs2apRxLfB/hvvgcvfDCC5KN4LMyduxY9X2K98cjjzwiAwcOVIszuFbQpz3++ONy1llnqf0hLDayYcMGtR8cClxL3RGwmyckaUCkTAiJj5qaGm3x4sVq6+p+9Y3asjVVahsvF198sXbSSSdpTU1Nobk5c+aE7dOlSxdt9erVUY/zr3/9S7v11lu1VFHbWKv9vvF3tY2XPffcU7vvvvvC5ozXZOnSpVqfPn1s7//FF19ozc3N6v8fffSRNnz4cC0VNNQ3auvLq9U2HioqKrROnTppn332WWgO79nPP/88NH7mmWe0U045xfYY++23n3b77bdrzz//vFZeXq6lgub6Jq1hTbXaxsvHH3+sbbnllmGfi5UrV2o///xzaHz++edrDz30UMxj/fHHH9p2220Xes8kk8bGWq26epnaxsvdd9+t7b///lp9fX1o7ssvv9QqKytD491220376quvLO8/btw4dQydQYMGaYsWLdLSBT+/d4888kjtqquusv2OweertLQ05nHwmXvhhRccz3v9zSHEKYwgEJIiPv5ljZz9zBdSWdcoZUX58sjJu8nwvp08Hw8rTVgFNYbysfqXScxfOV/GzxwvVQ1VUlZQJveMukeGdhvq6VhY2UV61cyZM8Pm3VwTRFGQnoTjvPvuu6FVwGSy/IcK+e/DC6W+tkkKi/Pkb+cMlJ79Aqv3XlJGsBKJCJMOoklu0mEOP/xwtbL58ccfq4gKrk+/fv0kWdT+sk7WPvO9aHVNklOUJx1P3kGK+7b3fDykcfz9739Xq7Q6iDB5AavHp556atLzwysqPpZvF54nTU2VkpdXJoMGTpEOHTansXj5LjnzzDNVrX1jaoxThg0bpqIwSFlbuXKlSsNatGiRZQQm07938f5BVM1Ipn3vEmIFHQRCUkBtQ5P6kaqqb1RjbDFecPX+UlzgrfkNfowLCwsd7//000/Lxo0bpXv37nLkkUdKqqlrqlPOQXVDtRrDSZgwc4LMOnaWFOUVuT4eUl/gLDmt8IGSgU888UREnvCqVauUQYyQP65XMmlsaAo4B3VNaowtxqfeuZfke3ifuH2P/Prrr8oxAkgXga7FmH+P9KSHHnpI7r33XkkGWkNzwDmoD1wPbDHufvVQySnwluPu9prAMfrqq6/U/0877TRV2lh/vyGVxkrvk0iamuqCzkFVcFylxiP2+kzyPHxuvFwTpK1B49OmTRv5xz/+ISeccIK0a9dOPvroI+V8In0tG793oflye60eeOABtd1+++2VNoOQdCV7VUOEpDHlG+vUCpbeUw1bjDHvlT322ENeeeWVsDmUwYtm/MHwtRLrpoLy6nLlFGgSuCjYVjZUqnkvwFiBQfvWW285uibIFcb1wB9WPMEff/whRx11lFoZfvvtt1UedjKpWl+vIgfBS6K2GGPeC3B8vv32W/n999/D5u3eA9Bo6NcE/zfTvn37iPzzRNK0sU5FDozXA2PMx/O5gWjdKNCura1VufNW4L2hXxO8Z4xGMsTJRkF3MqivX6UiB8aLgjHm/fwuwfWwe63x/sH1wHeKDvLyoW1BIQRoOMzi/2z43kWkCIJkN9+7+nsHkRVC0hlGEAhJAZ3bFKnwNlaw8COFjITSwnw175Wbb75ZCU+xWocfePxooyqL3YomhJlGYABC2Iz9kVqAla4zzjhDpaAkg86tOqu0It1JyJEcKS0oVfNeweo2xJLvvPOO9O7dW77++mspLS2Vxx57LGJfCHb11T2diRMnKkEuqvy89957SsSaTErbFaq0IhVBgFGTI1JYlKfmvYBqNNdcc40yZFF9CE7UjBkz1P9PP/30iP1h1BmvCVbJ4Szp4kxUPoJxnSzy2hSptCIVQQhej5zCPDXvFURGnn32WSVEhzAXzxHvl0cffdQy1QgpVvgzg/S+f/3rX5JsCgu7qLSiQAQhcFHy8krVvFcuu+wyFUE74IAD1HseUTSk6iF6gsIHZiZMmBA2Xr9+vbqmKHyASCWiClgxz8bvXRQxQJUhVI9DChUqF6FQBIofWGH+jsEixGuvvaacBkQ8EaVEKqPdPCHJghEEQlIAwtnIfS0tDPjo2GLsNb0IIFUIVXtg8Kxbt04Zd+b8e6RE2DVyg2GEHyMY0jCIzSukiQZpRNAcwCkA2GLsJb1IB6UZkQ6CH25cE6RSwfDTgYEM49gOOEwoW4hVY+xnV/YyUSCNCJoDOAUAW4y9pBcZjTlEVfTV/5tuuinMOYAht//++9umVOB9AScUjhZWhpOZb400ImgO4BSocWFAg+A1vUgHFXbuuOMO9fzwvF588UXlMOjsvffeqsytHaj6Bac82Q4kQBoRNAdwCgLj0uDY++cGzjL0KqeccopaOEAFMHyXlJWVhfY59thjVbMuK/C+wvtk7dq1oTS0bP3e3WmnnZS+Aq8/HANUdjJGFPLz81XVsGipjbhWSMVCupqejmU3T0iyyIFSOWmPRkiWgpSEpUuXqpQWNyvuyIlFeBsrWPH8SGUT0CIgrQiRg3icg2wCWgSkFSFyEI9zkC1Ai4C0IhVRiNM5yBagRUBaUSCiwM9Ntn/vev3NIcQpTDEiJIXgx2mrjgGRIwkAp6Bn6568HAbgFLTdwjry0xKBU5DfkdfDCJyCkpKtUvaaZBL83iUkNlx6IYQQQgghhISgg0AIIYQQQggJQQeBEEIIIYQQEoIOAiGEEEIIISQEHQRCCCGEEEJICFYxIiTLQP3sJUuWSK9evVQ98xUrVqg/Mx07dpRtt902bK6xsVHV20ZPBdTJzxbQA+HPP/9Uz7ewsFDVF0czJzO4ZmgmZlXXHf0UBg0apGqSZwNoxIRGVrgm6Aj7+eefW/a9QP1/Y/17gOrYeI+hP4TdNcs08NxRNhKNwNAHpKamRr755hvLfdFozgzui8/edtttpxoNZgN436M7MhrGdejQQb3exm7JOvieQa8RYwlONCUEuO/WW28d0WkY1zkb3jc6aGSGDuXoGYFeM7hOekd2I/hu3WqrzdWmjNcUn0V8Lxuv/88//xz6LickqaAPAiEkPmpqarTFixerbapobGzUxo8fr5WVlWkDBgzQ2rdvr1122WXao48+qu2xxx7qr6CgQNttt93U/6+88sqw+8+ePVvbZptttB133FFr06aNNnnyZC3TKS8v1w455BCtXbt26pp06tRJe/bZZ9V1wjXYeeedtaKiotD1ee655yyPc+GFF2odOnTQvvrqKy3TwXPAtejevbu2/fbba3369NE+//xzbdSoUeoaYIzrpF+TRYsWhd1/yZIl2qBBg7SePXtGvWaZxCuvvKJ17dpVPfdevXppI0aM0ObPnx+6Bl26dFGfDX1spLm5WTviiCO0bt26af369VP7/f7771qmc9NNN6nvgf79+6v3w6mnnqq9+uqroWtQWlqqDRw4UP3/9NNPD7vvb7/9puZ79+6tnX/++aH5pqYm7fDDD9d69OihbbHFFtrEiRO1TGfTpk3aSSedpLVu3Vp9rvA9ce+992o33HCDuga77767lpubG7pu9913X9j933jjDTWPa/zCCy+E5nGt8Z7CMdu2bau9/PLLafebQ7IbOgiE+IDnL+v6Gk1buySwjZNJkyZpw4cP19asWRP6Mb7//vvD9oGhs3r1asv7T58+XVu+fLn6/4IFC9S+qaCptlar+/13tY2Xo48+WjvrrLO0urq60I/5Y489Frp96dKlyiiMZTzefffdyrFKlYPQUFenrftrpdrGA96fMGDhNBqvweuvvx4aP/PMM9opp5xie4wDDzxQO/fcc7VUUl9fr61du1Zt4+XHH39URh0cAp25c+eqz4AOjNyHHnrI8v6//PKLcpb0cznttNOUgZhsahqbtN+qa9U2Xl588UVthx12CHN0nnrqKW39+vWhsZPPA95nRgfhzTffVMZwQ0ODtmHDBm2rrbbSvv/+ey3p+Pi9O27cOO2oo47SKisr1RjfNcb3Cj5zcKZigc+c0UF44okntIqKCvX/f//73xGOKR0EkmiYYkRIqlgyS2T6SSJ1m0SKWosc+6xI7308H+7ll1+Wm266KRSizs3NlXHjxjm+/9///vfQ/5F6stNOO0myqZo3T1ZccKE0V1ZKblmZ9Lj/PikdNszTsRoaGuTNN99UIXykFQGkypx++umOj4GUgbfeekueeOIJee655yQVLFv4tbx51y1SX1MthSWt5NBLrpJeA3f2dKwFCxZImzZt5IwzzgjNIf3DnAJiB1LQZsyYodKLvv32W5WKk+xUNDz29OnTVfoF0lSOPfZY6d27t+fj4T1yzDHHyB577BGaGz58uOP7b7nlluoavPTSS7LFFlvIokWL5Nxzz5VkMqdik5y2aKlsamqW1nm58sSAbWREh9ZxfZdcdtll6vXVOeWUU+I+z88++0wOPfRQyc/PV+/DAw44QM3169dPMvl7d+7cuVJaWqrG+K4555xz4j7NU089NeXfx6RlQ5Ey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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(9, 2.4))\n", + "for name in celltypes:\n", + " mask = adata.obs['celltype'] == name\n", + " ax.scatter(adata.obsm['spatial'][mask, 0], adata.obsm['spatial'][mask, 1], s=8, label=name)\n", + "ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_title('Single cells coloured by cell type')\n", + "ax.legend(ncol=6, fontsize=7, loc='upper center', bbox_to_anchor=(0.5, -0.35))\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "e99e39e0", + "metadata": {}, + "source": [ + "### Gene expression\n", + "\n", + "Half of the ligand-receptor pairs are **informative**: the expression of their ligand and receptor\n", + "follows a bump centred somewhere along the axis, so cell types close to each other co-express them.\n", + "The other pairs are **noise**, with expression unrelated to position.\n", + "\n", + "A perfect search would keep the informative pairs and drop the rest." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a67ee92f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.445368Z", + "iopub.status.busy": "2026-08-23T22:44:26.445283Z", + "iopub.status.idle": "2026-08-23T22:44:26.453451Z", + "shell.execute_reply": "2026-08-23T22:44:26.453028Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "expression : (120, 12)\n", + "LR pairs : 60 (12 informative, 48 noise)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
CT-1CT-2CT-3CT-4CT-5CT-6
Linfo0162.9222.6171.7124.8108.664.2
Rinfo0147.8190.2163.4143.381.564.9
Linfo1176.8162.4136.3122.063.030.5
Rinfo1199.9162.7189.7115.669.131.4
\n", + "
" + ], + "text/plain": [ + " CT-1 CT-2 CT-3 CT-4 CT-5 CT-6\n", + "Linfo0 162.9 222.6 171.7 124.8 108.6 64.2\n", + "Rinfo0 147.8 190.2 163.4 143.3 81.5 64.9\n", + "Linfo1 176.8 162.4 136.3 122.0 63.0 30.5\n", + "Rinfo1 199.9 162.7 189.7 115.6 69.1 31.4" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "n_informative, n_noise = 12, 48\n", + "\n", + "genes, profiles, pairs = [], [], []\n", + "\n", + "for k in range(n_informative):\n", + " centre = rng.uniform(0, 100)\n", + " bump = 200 * np.exp(-((positions - centre) ** 2) / (2 * 22.0 ** 2))\n", + " for tag in ('L', 'R'):\n", + " genes.append('{}info{}'.format(tag, k))\n", + " profiles.append(bump * rng.uniform(0.8, 1.2, n_celltypes))\n", + " pairs.append(('Linfo{}'.format(k), 'Rinfo{}'.format(k)))\n", + "\n", + "for k in range(n_noise):\n", + " for tag in ('L', 'R'):\n", + " genes.append('{}noise{}'.format(tag, k))\n", + " profiles.append(rng.uniform(0, 200, n_celltypes))\n", + " pairs.append(('Lnoise{}'.format(k), 'Rnoise{}'.format(k)))\n", + "\n", + "rnaseq = pd.DataFrame(np.vstack(profiles), index=genes, columns=celltypes)\n", + "lr_pairs = pd.DataFrame(pairs, columns=['A', 'B'])\n", + "\n", + "print('expression :', rnaseq.shape)\n", + "print('LR pairs :', lr_pairs.shape[0], '({} informative, {} noise)'.format(n_informative, n_noise))\n", + "rnaseq.iloc[:4, :6].round(1)" + ] + }, + { + "cell_type": "markdown", + "id": "0b7126df", + "metadata": {}, + "source": [ + "## 2. Reference distances from the single-cell coordinates\n", + "\n", + "`cell2cell.spatial.celltype_distances` summarizes the distance between two cell types from the\n", + "coordinates of their single cells. It takes an `AnnData` (reading `obsm`) or a plain dataframe, and\n", + "offers several ways to summarize:\n", + "\n", + "| `method` | what it measures |\n", + "|---|---|\n", + "| `'centroid'` | distance between the centroids of the two cell types |\n", + "| `'min'` | how close the two types get to each other |\n", + "| `'max'` | how far apart their most distant cells are |\n", + "| `'mean'` / `'median'` | average / median over all pairs of their single cells |\n", + "\n", + "Only `'centroid'` has a cost independent of how many single cells there are, so it is the one to\n", + "reach for on large datasets — the others evaluate all pairs of cells.\n", + "\n", + "The key holding the coordinates is configurable with `spatial_key`, since objects written by\n", + "different tools use different ones (`'spatial'`, `'X_spatial'`, ...)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7a438459", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.454725Z", + "iopub.status.busy": "2026-08-23T22:44:26.454633Z", + "iopub.status.idle": "2026-08-23T22:44:26.459430Z", + "shell.execute_reply": "2026-08-23T22:44:26.458991Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
XY
celltype
CT-1-0.150.75
CT-29.10-0.42
CT-318.19-0.53
CT-427.41-0.61
CT-536.45-0.19
\n", + "
" + ], + "text/plain": [ + " X Y\n", + "celltype \n", + "CT-1 -0.15 0.75\n", + "CT-2 9.10 -0.42\n", + "CT-3 18.19 -0.53\n", + "CT-4 27.41 -0.61\n", + "CT-5 36.45 -0.19" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "centroids = c2c.spatial.celltype_centroids(adata, group_col='celltype', spatial_key='spatial')\n", + "centroids.round(2).head()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "90944c9d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.460515Z", + "iopub.status.busy": "2026-08-23T22:44:26.460440Z", + "iopub.status.idle": "2026-08-23T22:44:26.587393Z", + "shell.execute_reply": "2026-08-23T22:44:26.586939Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "centroid CT-1 to CT-12 = 100.01\n", + "min CT-1 to CT-12 = 91.64\n", + "mean CT-1 to CT-12 = 100.08\n", + "median CT-1 to CT-12 = 100.16\n", + "max CT-1 to CT-12 = 109.19\n" + ] + } + ], + "source": [ + "for method in ['centroid', 'min', 'mean', 'median', 'max']:\n", + " d = c2c.spatial.celltype_distances(adata, group_col='celltype', method=method)\n", + " print('{:<9} CT-1 to CT-12 = {:6.2f}'.format(method, d.loc['CT-1', 'CT-12']))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a8d41785", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.588670Z", + "iopub.status.busy": "2026-08-23T22:44:26.588590Z", + "iopub.status.idle": "2026-08-23T22:44:26.594081Z", + "shell.execute_reply": "2026-08-23T22:44:26.593663Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
celltypeCT-1CT-2CT-3CT-4CT-5
celltype
CT-10.09.318.427.636.6
CT-29.30.09.118.327.3
CT-318.49.10.09.218.3
CT-427.618.39.20.09.0
CT-536.627.318.39.00.0
\n", + "
" + ], + "text/plain": [ + "celltype CT-1 CT-2 CT-3 CT-4 CT-5\n", + "celltype \n", + "CT-1 0.0 9.3 18.4 27.6 36.6\n", + "CT-2 9.3 0.0 9.1 18.3 27.3\n", + "CT-3 18.4 9.1 0.0 9.2 18.3\n", + "CT-4 27.6 18.3 9.2 0.0 9.0\n", + "CT-5 36.6 27.3 18.3 9.0 0.0" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reference_distances = c2c.spatial.celltype_distances(adata, group_col='celltype',\n", + " method='centroid')\n", + "reference_distances.iloc[:5, :5].round(1)" + ] + }, + { + "cell_type": "markdown", + "id": "54757bcc", + "metadata": {}, + "source": [ + "## 3. The baseline: using every ligand-receptor pair\n", + "\n", + "Before searching, we measure how well the CCI distances reproduce the reference when *all* pairs are\n", + "used. The genetic algorithm has to beat this.\n", + "\n", + "The objective is the absolute **Spearman** correlation between the two distance matrices, in their\n", + "condensed (upper-triangle) form." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "4e4aa9b7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.595227Z", + "iopub.status.busy": "2026-08-23T22:44:26.595139Z", + "iopub.status.idle": "2026-08-23T22:44:26.626068Z", + "shell.execute_reply": "2026-08-23T22:44:26.625674Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Spearman correlation using all 60 pairs: 0.7459\n" + ] + } + ], + "source": [ + "import scipy.spatial\n", + "from cell2cell.analysis.genetic_algorithm import PreparedCCIScorer, _bidirectional_index, _correlation\n", + "from cell2cell.core.interaction_space import InteractionSpace\n", + "\n", + "analysis_setup = {'communication_score': 'expression_thresholding',\n", + " 'cci_score': 'bray_curtis',\n", + " 'cci_type': 'undirected'}\n", + "cutoff_setup = {'type': 'constant_value', 'parameter': 50}\n", + "\n", + "bi_ppi = c2c.preprocessing.bidirectional_ppi_for_cci(lr_pairs, verbose=False)\n", + "interaction_space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi,\n", + " gene_cutoffs=cutoff_setup,\n", + " communication_score=analysis_setup['communication_score'],\n", + " cci_score=analysis_setup['cci_score'],\n", + " cci_type=analysis_setup['cci_type'], verbose=False)\n", + "\n", + "scorer = PreparedCCIScorer(interaction_space)\n", + "source = _bidirectional_index(lr_pairs, verbose=False)\n", + "\n", + "reference_vector = scipy.spatial.distance.squareform(reference_distances.values, checks=False)\n", + "all_pairs = np.ones(len(lr_pairs))\n", + "baseline_distance = scorer.distance_batch(all_pairs[source][None, :])[0]\n", + "baseline = _correlation(scipy.spatial.distance.squareform(baseline_distance, checks=False),\n", + " reference_vector)\n", + "\n", + "print('Spearman correlation using all {} pairs: {:.4f}'.format(len(lr_pairs), baseline))" + ] + }, + { + "cell_type": "markdown", + "id": "1d43daa2", + "metadata": {}, + "source": [ + "## 4. Running the genetic algorithm\n", + "\n", + "Each individual is a binary vector with one entry per ligand-receptor pair, saying whether it is\n", + "included. `optimize_lr_pairs` evolves a population of these and keeps the best.\n", + "\n", + "The search happens in successive **runs**: each run keeps only the pairs the previous one selected,\n", + "so the set shrinks. With `runs=None` it stops once the objective improves by less than\n", + "`inc_percentage`." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "37bdcc6a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:26.627381Z", + "iopub.status.busy": "2026-08-23T22:44:26.627301Z", + "iopub.status.idle": "2026-08-23T22:44:27.630502Z", + "shell.execute_reply": "2026-08-23T22:44:27.630023Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
runobjectivepairs keptdropped
0run10.9462732165%
1run20.9611531529%
2run30.961968940%
\n", + "
" + ], + "text/plain": [ + " run objective pairs kept dropped\n", + "0 run1 0.946273 21 65%\n", + "1 run2 0.961153 15 29%\n", + "2 run3 0.961968 9 40%" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq,\n", + " ppi_data=lr_pairs,\n", + " reference_distances=reference_distances,\n", + " cutoff_setup=cutoff_setup,\n", + " analysis_setup=analysis_setup,\n", + " population_size=60,\n", + " generations=40,\n", + " runs=3,\n", + " random_state=888)\n", + "\n", + "summary = pd.DataFrame([{'run': k,\n", + " 'objective': results[k]['obj_fn'],\n", + " 'pairs kept': results[k]['n_selected'],\n", + " 'dropped': '{:.0%}'.format(results[k]['drop_fraction'])}\n", + " for k in sorted(results) if k.startswith('run')])\n", + "summary" + ] + }, + { + "cell_type": "markdown", + "id": "0374f8be", + "metadata": {}, + "source": [ + "Every run improves on the baseline, and the set of pairs shrinks as it goes.\n", + "\n", + "Because we know which pairs were informative, we can check *what* the search recovered." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "3ecff99a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:27.631717Z", + "iopub.status.busy": "2026-08-23T22:44:27.631632Z", + "iopub.status.idle": "2026-08-23T22:44:27.635781Z", + "shell.execute_reply": "2026-08-23T22:44:27.635368Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "run1: objective 0.9463 | 21 pairs kept | 48% of them informative\n", + "run2: objective 0.9612 | 15 pairs kept | 53% of them informative\n", + "run3: objective 0.9620 | 9 pairs kept | 78% of them informative\n", + "\n", + "Best run: run3 -> objective 0.9620\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
AB
0Linfo0Rinfo0
1Linfo1Rinfo1
2Linfo10Rinfo10
3Linfo11Rinfo11
4Linfo2Rinfo2
5Linfo6Rinfo6
6Linfo8Rinfo8
7Lnoise0Rnoise0
8Lnoise29Rnoise29
\n", + "
" + ], + "text/plain": [ + " A B\n", + "0 Linfo0 Rinfo0\n", + "1 Linfo1 Rinfo1\n", + "2 Linfo10 Rinfo10\n", + "3 Linfo11 Rinfo11\n", + "4 Linfo2 Rinfo2\n", + "5 Linfo6 Rinfo6\n", + "6 Linfo8 Rinfo8\n", + "7 Lnoise0 Rnoise0\n", + "8 Lnoise29 Rnoise29" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "for key in sorted(k for k in results if k.startswith('run')):\n", + " kept = lr_pairs.loc[np.asarray(results[key]['ppi_data'], dtype=bool)]\n", + " informative = kept['A'].str.startswith('Linfo').mean()\n", + " print('{}: objective {:.4f} | {:>2} pairs kept | {:.0%} of them informative'\n", + " .format(key, results[key]['obj_fn'], len(kept), informative))\n", + "\n", + "best = results['best_ppi_data']\n", + "print('\\nBest run:', results['best_run'], '-> objective {:.4f}'.format(results['best_obj_fn']))\n", + "best" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "1f46600e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:27.636962Z", + "iopub.status.busy": "2026-08-23T22:44:27.636890Z", + "iopub.status.idle": "2026-08-23T22:44:27.673439Z", + "shell.execute_reply": "2026-08-23T22:44:27.672943Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAeoAAAEiCAYAAAA21pHjAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAMcpJREFUeJzt3Ql0VOX5x/GHfaessiNYioRFwK1QUDYFUVxArYArRRFZlLqg8BergqCIthSVKqi4YAGxWkAEQcCigKKgoqCAoKyyowHCksD//N54x8kkk0wmM8lM8v2cM2fm3rn3zs1Ncp95t+ctdOrUqVMGAABiUuG8PgEAABAcgRoAgBhGoAYAIIYRqAEAiGEEagAAYhiBGgCAGEagBgAghhGoAQCIYUXz+gRi1cmTJ23Hjh1Wrlw5K1SoUF6fDgAgH1GuscTERKtZs6YVLpx5mZlAHYSCdJ06daLx+wEAwNm6davVrl3bMkOgDkIlae8ili9fPtOLCABAdvzyyy+uMOjFmswQqIPwqrsVpAnUAIBoCKVplc5kAADEMAI1AAAxjKpvAACykHLylH29Zb/tP3TUKpUtaU3rVrIihXNnRBCBGkDc3sCA3PDRup02cf5a25t41LeuSrmSdkeXxtY2oUbUP59ADSBub2BAbvyNj5y5Kt16/c1r/Yhrzo763zpt1ABydAPzD9L+NzC9D8R7bdHE+Wsz3eZf769120UTJWoAUbuBtT6zetBqcGVm0u3tlLvHnXLPqcu/vTa/bdwr3zaBy6k3Sj2d9HutbX5d9H2e/z6/LZ8yd69Nc07BzzHwvd9e+392aOcf/HzT/4z+5+/2C/Iz+paDvBf050pzvunPP3Xf9L+vUH7m387Xb58gv5fA63EqyO/F/+cPXLZfPzPL32XQn/mU/XzkeLovooH2/HLUNf00r1fZooVADSAd3aSOHE+2xCMn7Oek4/bLkdTHz0knLPHIcdu0+5eQbmDdRs9140QzCnRAfrH/UOb/CzlFoAYKQNA9fCw5Ndi6oHvClRQSk46751+STqR5L/X5uCVHoDrPK6XmJpXfU3NIFHLPqcuppXpvWS/0XPjXFVnvk7re0u2TehwLtk/Ae4HH8vYPXE7dp5C5yohs7uM+w28b/583zevA9woFv16BP5f/NfvtszLfJ835Bl6/INcy/bUolOHPHvi7C+laZPL7dr9jM9u275DNWLYpy785daKMJgI1EEdUlXfo6Im0JV0vwPpep5Z8veXEpBNht6EVL1rYypcubuVLFbfypYu559+VLm5Jx5Jt4ZrtWe4/vEdLa1ynYsCNOuPAEyw4pb+5pr9Bax8mz0Gk6f9m0ZodmdYeVS2fOtIhmgjUQB7eBBR0MyrNppZ4U0u+3jqVfFUKDregW6JYERdky5cqlib4/q5UcStXuvivz6nL7v3Sxa1ksSJBz/2LH/ZleQNTb1iGaiFeFSlcyI1gyKjXt6d/58ZR/xsnUAMRkHLypAus/u24PwcEXy/Y+pd0w60ULlW8iF+wTQ2+CsLlXIk3teQbWBJWoM5vNzAg2vRlU0OwAoch6ouo/sZzYxhioVNeFzukm9nkd7/7nf38889MylHAJKekBt2M2nEVfL1qZ/9grJJxuEqXKPprkE0Ntv7Bt3wGJV1tV7xo5IJupMdR5+YNDIjXxD7ZiTGUqJGvnUg5mWFpNrNOVOp4Fa6yJYumK+l6yxlVO6sEXKxI/KYzUDDWECwykyG/K1K4UFSHYMV0oJ4wYYL94x//sF27dlmzZs3s6aefttatWwfdfs+ePTZ8+HCbP3++HTx40Fq1amXjx4+3hIQE3zYDBw60559/Ps1+TZs2tS+++CKqPwui63hyShY9ltNWO6vkqyFG4dD35LIq4fqVZl07brpgqxJvMV9Jt0jh+A268XgDAwqCPA3UL7/8st1///32xhtvuOA8duxY69y5s61du9ZNqB1ItfRXXnmle16wYIFVqVLFnnrqKevQoYOtW7fOKlas6LZLSUlx202fPt23Lz1CY8vREykZlnS95YyCsfYJh2qnVHINLM36Aq1ftXNqG29xK1tSQZf2VQB5L0/bqBs3bmwdO3a0Z555xi3rVGrXrm0333yzjR49Ot3233//vTVo0MCWLFli7dq18+1Ts2ZNu/fee+2ee+5x6/r372979+61mTNnhn1utFGHRtf/2ImUtO24viDr15EqIBgfSz4Z1u9F4xvTBNlSxdL2WM6g2llB1xsXCQCxIC7aqA8cOOBKwSNHjkxT6lXpeNmyZRnuc/Jk6s29sF/1ovbR8kcffeQL1KKqcf3wuhAXXHCBPf7441a3bl3LTfE2q5CCbtLx1JKufzaqtG27qUOE/IcPqR04HLoWvwVW/57KAcOH/Eq66nhF0AVQkORZoN65MzVh/2mnnZZmfdWqVe2zzz7LcJ/f//73rh17xIgR9uqrr7qqb7Vp61je8aR+/frufZW6t2/fbn/9619dsF6zZk3Qby7Hjh1zD/9vO/E8q5BLAXksOUhJN/LZqNQhKm2wDRyj+2vw9XuvdPGiNEkAQKx3Jguk0nGw2ni9N2vWLLv77rutRYsWlpSUZNdee61dc801tnXrVt92avf2VKpUyd58802rUaOGTZs2zfr165fhsceMGWOPPPJITE6LpmxUh4/+lgIyTTKMDNt2UwNvpLJR+RJgZNK2q8QY9AMAgHwUqKtXr+7rxe1v9+7dVq1ataD71atXz/7zn/+kWffHP/7RtV0Ho05mqvbesGFD0G2GDRvmvgD4l6gz6tAWiVmFnpv/jdWpXNYOHVOpNn07bqxmowIAFKBArZJuw4YN7cMPP7QePXq4dSpJq6PY9ddfH/Jx1MFMVeX+7dOBFHRV4lapOpgSJUq4R06pTTqrWYX2JR6zfs//L2LZqNK38UYnGxUAoIBVfSu4qhR76aWXuvHQGp61f/9+12vbc/vtt9snn3ziGwOtNukmTZpY+/btbf369a6HuHqOq/pb1M58ww03uBKyttuyZYvdddddVqZMmWx9AYj2dGeqXq5crmRcZqMCAMRBoE5MTLT333/fNm3aZPfdd59bp17cjRo1CrmtUu3FSlrSp08fV+WtpCRz58511dsejYlOTv4tacXVV19tgwcPtu7du7se3b169bJRo0b5eoKrVKxAPWjQIBfcK1So4DqSKdhnVqUeKaFOdzaq1/kkiQAARGcc9bfffmsXX3yxGy61Y8cOX+cvlW67dOlivXv3tkjRZ+j4RYrkbmky3HHUaqO+6Z+LspxV6JXBHWN6qBYAIDZiTFj5DjXc6cYbb7Rt27alWX/nnXfauHHjLJJUUs7tIB2JWYUyw6xCAIColqhVnfzDDz+4Z1Vze4c4fPiw6yTmPx45XuU0MxmzCgEA8iwzmUsb+Wsw9m+PVnu1PhjMKgQAiIywqr41cYZ6aPsHanUGUweurl27RujU8s+sQh2a1nLPtEkDAHKl6ltjkjU8Su3HGzdutLZt29rq1atdEpOlS5dmOl45XjApBwAgbqu+lbHrq6++ctNTKtmIemarp7c6mJUtWzbc8wYAALE0zWUso0QNAIjb4Vk68Ouvv55uvdbpPQAAEBlhBWql/Tx+/Hi69Vp37733RuK8AABAuFXfGiutyTA0K5W/AwcOuIk2AmfEikdUfQMA4rbqW/bu3ZtunQK0f15uAACQM2EFauX5VvW3vhF49K1AqUX1HgAAiIywhmc9+eST1q5dOzv99NOtefPmLlPZl19+6arCNb80AADIw0Bdt25dN45avbxXrVrlspP17NnTTS9Zrly5CJ0aAABgHHUQdCYDAMRtZjI5dOiQrV271vbv35/uvUsuuSTcwwIAgJwG6nnz5rmUoRqOldFc0fT8BgAgD3t9q3f3gAEDXNFdQTnwAQAA8rBErdmzHnjgASbgAAAgFkvUCQkJ9sMPP0T+bAAAQM5L1P369XNTWo4dO9YaNGjghmf5q1evXjiHBQAAkRieFRiYA+WHmTMZngUAiNvhWRs2bAj33AAAQDaEFahV3Q0AAKIv7NmzEhMT7a233nJ5vz3r1q3LF9XeAADEdRv1t99+62bJOnnypO3YscMXnG+++Wbr0qWLS4YS72ijBgDE7XzUSniiXt/btm1Ls/7OO++0cePGhXNIAAAQqRJ1hQoV3DhqPasHuHeIw4cPW6VKlezYsWMW7yhRAwDitkStwOwFY/+hWps2bXIfDAAAIiOsQN25c2eX7MQ/UO/evdsGDRpkXbt2jdCpAQCAsKq+leu7ffv2VrhwYdu4caO1bdvWVq9ebdWrV7elS5dajRo14v7KUvUNAIjbhCd16tSxr776yt544w377LPPXO9v9fRWB7OyZcuGe94AACASJeqePXvatGnTLD+jRA0AiNvOZLNnz7akpKRwzw8AAIQorECt9uk5c+aEsysAAMiGsNqoGzVq5Nqj3333XWvcuLEVL148zftDhgwJ57AAACASbdRNmzbN9P2vv/46W8fbvHmz7dq1y84880yrWLFiltunpKTY999/bwcPHrQmTZpYmTJlInJcf7RRAwDittd3dgNxMGrnVse0Dz74wOrXr++Geo0ePdqlKA1m+fLldtNNN9nx48ddFjQF4+eeey5NfvFwjgsAQL6aPSsSHn74YTf+WqXjNWvW2IwZM+zuu++2FStWZLj9iRMn7Oqrr7YOHTq4AK19tU+fPn3su+++C/u4AADku0C9ePFi107dpk0b37pnn33WFeNDNWXKFLv11lutWrVqbvnyyy+3s846y15++eWgs3bt3LnT+vXr55KteFnSateu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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "runs = sorted(k for k in results if k.startswith('run'))\n", + "objectives = [results[k]['obj_fn'] for k in runs]\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 3))\n", + "ax.axhline(baseline, color='grey', ls='--', label='all pairs')\n", + "ax.plot(range(1, len(runs) + 1), objectives, 'o-', color='steelblue', label='selected pairs')\n", + "ax.set_xlabel('run'); ax.set_ylabel('|Spearman| vs reference')\n", + "ax.set_xticks(range(1, len(runs) + 1)); ax.legend()\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "714e8f01", + "metadata": {}, + "source": [ + "### How the distances line up\n", + "\n", + "Plotting the CCI distances against the reference shows what the search bought." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "3c25ee02", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:27.674742Z", + "iopub.status.busy": "2026-08-23T22:44:27.674638Z", + "iopub.status.idle": "2026-08-23T22:44:27.839085Z", + "shell.execute_reply": "2026-08-23T22:44:27.838617Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA3oAAAFeCAYAAADT8W1xAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAbE1JREFUeJzt3Qd4FNX6x/E3jYTeAii9ilIEUUQQu6jX7hUVe7nX3su1+/eq94q9936tqKio194VsaEoIgoi0qWXEEjP/J/fwdm7CZuwCSEzu/v9PE9YdnY2Mzs7mTPvOe85J83zPM8AAAAAAEkjPegdAAAAAADULQI9AAAAAEgyBHoAAAAAkGQI9AAAAAAgyRDoAQAAAECSIdADAAAAgCRDoAcAAAAASYZADwAAAACSDIEektqkSZPsm2++iTwvKiqy999/3+bPn1/v+/LHH3+4bRcUFNTL9qZMmWKTJ0+usGzChAn266+/btTv/Pbbb+tg71LH7Nmz7fPPPw96NwBsBF1Lv/zyy4Q8ht9//7199dVX9b7dtWvXujJv4cKF9bK9efPm2aefflph2dSpUyvcA9Tmd3744Yd1sHepY+XKle57Ly4uDnpXYGZpnud5HAkkqo8//thKS0tt1113tczMzPVe33PPPS0/Pz9SQOui3alTJ7v//vvttNNOq9d9feKJJ+zEE090gVbPnj036baWLl1qvXr1snvuuceOPvroyHJt9+CDD7ZbbrmlVr931KhRrtDWcQ/CsmXL7JdffrEmTZpY//79LT19w3VVixcvXi/gjbb55ptb3759Y76Wl5dnX3/9tdvO7rvvXuXvWLRokf3222/ud3Xr1q3Ca9OmTbN+/fq5Y7bjjjtucH8B1I3CwkL7/fffbcWKFda1a1dr3759rX+XrpszZsxwlV2bgn7vqlWrNsk1Yp999nHXbQV89UnX6q222sqeeuopO+aYYzb59oYNG2YDBgxw5bvvhBNOcPtR2yBdZeh5553n7jOCoIrhH3/80XSrvvXWW1vDhg1r9H6Vf/obaNCggXXv3t2aN29e5braxs8//+zOwz59+lS5rsrFmTNnuorzLl262GabbVbhdS3XvYaO24UXXlij/UXdW//OGEig1rrddtvN/f+VV15xBXGY6SZjjz32sEaNGm3ybV177bXu4nvkkUdaMigrK3OFxsMPP2wDBw50Ny0qlHQDsfPOO1f7XgVaN9xww3rL586da9OnT7ezzz7b7rrrrpjvPfXUU23MmDGWkZERs6CfNWuWqzBQS6kCz9WrV1vjxo3tkUceiQSPvXv3dgHy+eef74JGAJuWbjSvvvpqu/fee61169bWsWNHd7PfsmVLO/300+2CCy4I3Vfwr3/9ywUjuqYkC10LVeapAmxTe+GFF2zixIn24osvWrJ48MEH7aKLLnKVFGlpae7cuPXWW+3kk0/e4Hu1rs71jz76yAWIqvRQWXjsscfaHXfc4SpLoz3//PNuW6owV2W4gsO//vWvdtttt7nyT9asWePKsf/85z+2xRZbuHuZH374wXbZZRcXXCuQlOzsbLv00kvtiiuucJXbrVq12kRHCHFRix6QiE4//XSvTZs2Xt++fb1999035jp77LGHN2TIkMjzuXPnqgXbu//++71ktXLlSq9x48bezTffvN5rPXr08C688MJa/+4jjjjC22WXXbz6dsUVV3gNGjTwJkyY4J6XlZV5xx9/vNe0aVNvzpw5tfqdhx56qDsXvvrqq5ivP/fcc17Dhg29/v37exkZGeu9vnDhQq9Dhw7eqFGjvNWrV0eWf/PNN957771XYd2PP/7YbWv8+PG12lcANSsb0tPTvTFjxkSWFRYWeqNHj/aaNGlSq0N50EEHubJmU9G1tUuXLpvkd++9997egAEDvGSmcl7fUWUqJ6LvAWrq7rvvjnn939Ref/11V2bcc889kWX33nuvW/bmm29u8P2DBw/2WrZs6U2fPj2yTO9LS0vzzjjjjArrPv744658ff755yPLSkpK3GcvKCiILDv77LPd9l977bXIshkzZni5ubneoEGD1rsPycnJ8W655ZZafHrUJQI9JKS1a9d6zZs39y655BLvvvvuc4W6gri6CvR0kdLN+pIlS9xzXSx1k758+fL11v3tt9/cuvr54IMP3I3+mjVr1ltvwYIFbh3tu+/LL7/0Jk2a5P6vYOGLL77wfvrpp8jr2p5+37fffuvl5+fHdWz02fQZ582bV+NA7/3333eBy8477+yC5+gbpaACPX3uRo0aeccdd1yF5YsXL/YyMzO98847r8a/U9+rCraqbn7mz5/vCskbb7zR3TzEKuh1A9G+ffsKBWFVysvL3bp6D4BNS2XDsGHDYr721ltvxVy+dOlSd41XxU/0NTqeQE/X9s8++8ybOHGiV1xcXO217Ouvv3bX9OhtaNmuu+7qtWvXLlKW6EcVWtF0Tf/000+97777zt2IV+Xnn3/2Pv/8c1eOxRvo6RqlberGPfozxapI07Hy91FlhralZZWpHNQ6f/zxR2TZL7/84n300Ufu/6Wlpe6z6P0+HZfJkye7snDZsmVePH788UdX5lUur+IJ9KZNm+adfPLJ3m677ebtvvvu7poffWyDCvS23357r3fv3ust17Iddtih2veqUkPH4/DDD1/vtV69enn9+vWLPF+xYoWr/Lj44os3uE8DBw70OnfuvN7yY4891m2v8j3KwQcfHPMzoH6RuomEpDQNpckpbS43N9cuueQSe+yxx+z//u//6uT3Kyd+xIgRLp3hueeec/2vlLagPn7//Oc/7bLLLousO378eHvyyScjKYYafEN9tpS2cPnll0fWe+edd9bro6fUQKVYHn/88XbVVVe5VIhtt93WfZZzzjnHpQBuueWWLr9eOfHHHXec3XzzzdX2TXvvvfdcqkeHDh3i/ryq9FHaxp133unSNbbbbjt77bXXXMqhHHHEETU6fuqEXblTfFWU46/+hFX57LPPXKd+pQBFa9OmjeuPoeNaU0r51D6ecsopMV8/6aSTXF879S9QWmasfhNK6dT79d0ofUXno77Xyv0VRGk36j/y7rvv1nhfAdRMVlZWlQNBqL9aNPXfU4qb0v/Vn0xpnwsWLHDp3lpeHZUHf//73+2TTz5xqdrLly93fcKVMnrYYYdF1lPa3MUXX2wPPfSQS+FXmaVyQtcXLdf1yO8bFZ1mrpQ4XeuVRqdrkgZU0XaWLFni9lO/74ADDoisr/3W9Vt98ZRKrj7NSuOPh8oulXkqt5Sm/tJLL7ny6KeffnLlwKOPPupS8kT74++nyg71A9P+q5xQX3Qdf5kzZ477ndF99O6++263jgao0jJdP3U91aApSlVUyp+u7W3btnVl5U477eTSArWsujJPhg8fbjWhclvXcF2bVeapLNe9hLoGKGWxpvSZ4hlsrUWLFm57VdE5qTT/WOefxh3Q+aVzpao+dPqeVH7pe4qmexh9V3vttVeFeymds+rLr0FU9H3r/Tp//O/bp7+PV1991fXRa9asWWS57k2U7qlU3Wj6PsaNG+fOg86dO2/wuGATqefAEqgTO+64o7fffvtFnisVQTVNlWtAa9uip5pMrad0hA8//DCy/Pbbb3fLn3766Wrf/+yzz7r13n333QrpEVr266+/RpaplrVnz57e3//+90gtomozX3zxxfVS/VRTrO1XV2MsSic88MADY75WVYveHXfc4Woto/dXLWbNmjXzhg4dWuMWPdXgav/j+dlQKumdd97p1lNNdmUjR450+63a6JpQjaZaCf0a72hKj1FLod/SGqtFTy2x2qejjjrKpVvpuOq71HqqRc3Ly1vv9/773/9275k1a1aN9hVAzag80N/aueee61qQqqLyQte3Tp06eVOmTIksf+qpp9z7x40bV2WLnlqrtthiC5faPXv27MjyG264wV0HolPCdU1Qq8nbb79d4f133XXXBlM3dY3ScqXiKdPAd+WVV7qsBJUXomugWoG6du3qzZw50y1TmXLiiSe6MmZDLXpaV5+5T58+3m233RZZrta37Oxs76yzzqr2/WqZU0vqtddeW6FlUb9Tx9N35plnupS+ww47zJUxos+gY6jMnMqpfi+//HKFLJdYdOxatWoV87WqWvTUDUDbu/766yscAx1n7Z9axWraoqfvKZ4yT/cv1dG5o/Vuuumm9V7T8dFragWuju4dlEVy5JFHei+99JK7J9F21SoXfb7q3kO/74ILLnBdIbbbbjtv8803dxktjz76aIXfqfsnva6/mWeeecZ9N0cffbS32WabeZ988sl6+6AMp6paWlF/aNFDwlGnetWcvfHGG5FlZ5xxht13332uxaRyje3GUOuaP+CLaEAQ1UaqVS16NEtRDa0G+FDtrWof1fH/rbfecjWa1dFUDzfeeGNk1FDVpOl9agVSh2efakm1/eqodlWtidXVflam2jm1Jp555pkV9lW/Y8iQIbUaqU01gZVb4KoS/Rmr2j+pXFsoTZs2dTXRavGL9XosqinVCHdqXa1cI6oa5H/84x+ull2DvlQ3qqk8++yzbgRTf2QxDcO99957u+9OLX7RVIvvT7OhVkwAm4YGrFCLgzIilKWgwSAGDx5s++67r2vBycnJceupDPniiy9c1kb0yLtqaVILln7PQQcdFHMbGpBCgzkp4yC6tULXDr1XA17o+qDsELWaKBNE1wafWss0ENSGqNVOZYtaRqJHDdXvU1mkFjKto9YoXdu0bX/kX5Upuj5pMJoNXWd9GnhDA274NKK1Mk60jX//+98VWnJ0HdQ1U9dflT06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/e/IuZeTk+Mde+yxXklJiZdKlixZ4l1wwQXe5ptv7jVs2NC78MILY653++23u2O01VZbufVGjhzpFRYW1ngd1NyyZcu8U045xZVFuhbk5ua68/rnn39er8zSea7Xu3bt6rVr18776KOPOORR7r33XlcWqZyuzTmequ644w53D6RrZc+ePd21sqioKPL6okWLvB122MGdo3q9ZcuW3quvvuqluuLiYnesVI5vs8027jrbvXt374svvqiw3uOPP+41btzY6927t3vce++9vdWrV3upZM2aNd7VV1/tde7c2WvatKl36KGHxlxvzJgx7lzs1auXW0/3lcuXL6/xOrVBoJdkVEA+//zz7oZbVqxY4QrXAw44oMIfsf5ojz/+ePdcAcphhx3mbr7996W6999/3/2x6Y+2cqCnP0bdaH/99dfuuQJkXeQefPBBL9VdeeWVLkCZPHly5NzSTUo0FRw6H0tLS93zU0891Wvfvr1XUFDgpbLLL7/cVbosXrzYPdfx0E3IfvvtF1nn7bff9jIyMiI3wr/99pvXqlUr78Ybb/RSycSJE71bbrnFW7p0qQsiYgV648eP99LS0rw33njDPZ87d647vldddVWN1kHt6Bqga6J/Y63zWedy//79K6w3fPhwVxmpckn0XeoaoooPeN4PP/zgdezY0TvppJPWC/Q4f6v2yCOPuHL6gw8+iCx79tlnK9w4H3zwwd52223nbtZl9OjRLripXDGeah566CFXeTB9+nT3XGW1KhD69u0bWeenn35yZdGTTz7pnutarPPz9NNP91LJnDlzXKCnsuOggw6KGejNnDnTa9CggXffffe557q2qfJBwXRN1qktAr0UoBsi1VT53n33XU2p4c2YMSOy7Pvvv3fLPvvsMy/VqZZPBatqrxQMVw709tprL1dARDvmmGO8IUOGeKls5cqVrkb5tttuq3IdtSTrPPvyyy8jy3SB1M32K6+84qWyk08+2d30RjvzzDO97bffPvL88MMP93bdddcK65x11lmuRjVVVRXo6cZYN3HRLr30UteCXJN1UHfGjh3r/v79IE43knquijWfbhgzMzO9p556KuUPvQIQtdTp2njFFVesF+hx/samCmv9DZ9zzjnVZgWolVQVtz5VRqglRa2kqUxZI7oHinbzzTe7LBzfxRdf7FrgK99rKiPFr7RJNQdVEehde+217thFN6Q88MADriIiPz8/7nVqiz56KUB9eXr27Bl5rnz15s2bW48ePSLLBgwYYA0aNHCvpTJVfhx//PF28skn2w477BBzHR2jbbfdtsKy7bff3r7//nv3/lSlvlLqj3PAAQe4vmM6Tnl5eRXW8c+vQYMGRZZ17NjRNt9885Q/984991z7/fff7dprr7UPPvjA7rvvPhs7dqz985//3OC5N336dJfbD9vgsZo/f74tWbIk7nVQt2VRmzZtrFmzZpHjL9HfQevWra179+4pfz2Qs88+24YPH24HH3xwzOPJ+RvbtGnT3N+wyiL1pY/VX1zjE2hsguhzLycnx/r375/y596JJ55ojRs3duffe++9Z//5z3/srrvustGjR2/w3FOfPfXpg1U4Vttss42lp6dXOFZFRUU2derUuNeprcyNejdC75VXXrEXXnjBXn311ciy5cuXu8K0Mi3Ta6nslltuccHJFVdcUeU6sY6fnusPUjfbukCmIgV3aWlpdu+999rzzz/vbugUgJxzzjl24403Ro6dbvKysrIqvJdzz6x37952wgkn2G233Wbjxo2z2bNn27777mvDhg3b4LmnCgYN0NKoUaN6+rbDr6pj5b+m8zOedVA3NFDBHXfcYTfffHOF7ygjI8NVPFb+DlK9LBozZoyNHz/eBSlV4fyNzR+k6vXXX7djjjnGVSRqkIzDDz/cHnnkEVf++OdXrL//VD/3dLzOOOMMV8moc1ADhA0cOND22muvyDo6Rt26davy2on/0fHo0KFDtccqnnVqixa9JPbRRx/Z0Ucfbddff72r2fLpIhdrdCm1xqhVL1VplKOrrrrKTjvtNDfCmS5wixcvdiOa6f+qqarq+OnYSSofPx0XBRwa3WzOnDlupD21TClwUeDnr8O5F9s//vEPe/bZZ11wrJs7jQCpAvbQQw+tcIw59+I/Hzd0rDie9UMj7O6///6uIkOtBNHfkUbzqzzSZqqXRStXrrRTTz3VTj/9dFfTr/JHo5bqfNb//Rs/zt/Y/IpEHbuZM2e6R7XgqcJb5VH0OrGuEal87smdd95pV199tauc0bHTude2bVvbc889I6Nkc+4lTllEoJekPvnkExfcXX755XbppZdWeK1Lly4unaG4uDiybM2aNS6g6dy5s6UqtcZtt9129uCDD7pjph8Nc6uCQv9XKoh//Pz/+/R8s802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1C3XNe7111931zSlLGo9VaLpGlrd+0TXW6Xf77///pH37rvvvta4cWP3un9N1vVzwIABlp7+v54/SpVUuafKPa1XWFjogqVmzZqt99kmT57sKka32mqrmFkv1W0nloKCAvvkk0/cdT9WWaTPqYybaNq+yiO/TM7KyopsV9vzyyIdfx0HrSsq71R2bb755hV+X5CfH9gQAj3gT3PnzrUjjzzS9ttvP5s1a5b16dPHXWwVSJxyyin22muvuYu3CtO8vDx79dVXXeH7+++/u4JGVMiqRnCvvfayBg0aVPs+efnll+3RRx91BYICGRU8CvR0oT/wwANd7WyvXr1cILPtttu6Phf6vXL55Ze7glqtiCo0VBipsFH6iQpy0edQ4LRkyRIX5Pzyyy9uny655BL3ejzbqWzixIm2zz77WPv27V0wp+OmzxzdgheduqlC9thjj7V33nnHhg0b5gpO7beOi24odCOj/Ro3bpx7r1KN1L9kwoQJ7rnWV+GolJxDDz00so2gPj8AbEoKFFQWKdBSpaOuXUrHVKB35ZVX2r333usqtVavXu0qz1TRuMsuu7jKKlVW+inxug4qkFGgV937RBV3Z555pgsuFGh27tzZdt11V3dt/Otf/+rKOb2moFOp9rp++5Vvt99+u7sOr1y50lWW6f8rVqxwFXndunVz6yxfvtxGjhzpruXbb7+9+3177LGHa0UTrb+h7VSmdXfbbTcXqHXv3t1d3/Ue7buvcurmZZddZvfdd5+rXNU2i4qK7JVXXnF9ylWWqXLRL4u22247tx8qs0WVkqqQveGGG+zss8+ObCOozw/ExQPg/Pjjj6r280444QSvvLw8clRuuukmr3///t6qVasiyy666CJvyJAhkedffPGFe+/q1atr9L7rrrvOve+VV16p8C0MGzbMO++88yL7sWbNGm/rrbf2Ro8eHVln22239bp06eItW7bMPV+7dq3XqVMn77bbbouss/3223t77bWXe03Kysq8119/vUbbqUzbjT5GL774ovsMF154YWSdXXbZxbviiivc/6dMmeKlpaV58+bNi7w+fvx4b/Hixe7/O+64o3f11VdXexY+88wzXm5urldYWBj45weATUnliK6pI0aM8IqLiyPLX3jhBa99+/be/PnzI8vuuecer2PHjpH1/vjjD/delWc1ed9TTz3l3nf77bdX2JejjjrKGzlypFdSUuKe63GfffbxTj311Mg6hx56qNesWTNv5syZkeusyrlzzjknss5hhx3mDRgwIHK9lnHjxtVoO5Udcsgh3p577hn5DF999ZWXnp7u9sd3/PHHe0cffXTkuKosmjBhQuR1lU/Tpk1z/9d6Wr86Kruys7MrHMugPj8QD1r0gEpUU6daPd/jjz/uWnmUKqjWKf20atXKpfutWbMmktpSWbzvU/qhWp1806dPd61Zo0aNspdeeiny3h49ethHH31kl156aWRdtf7pd4pSRdVy6I/4+fPPP7ttqYXLTyNVGohSc2q6HZ9qgZX685///CdyjFRLqdrUqviplKox9dNg/Y7y1VHttFI6VYuqWmU9+ikvQX1+AKgvp512mmutii5TNGiVrlv+9UpZDBoETNfm6GtjtHjfp/T3M844I/I+lVMaRVrXQrVy+e9Vi5mukdE0GJnfeqXrrFrM/JGX8/PzXfbKM888E7lei7o41HQ7PpUJamlTi5d/jNRSplbIqujzKVND+6UWPpVh8aTqq6VOaa3qWqG0WG3vhx9+cFktQX1+IF4EekAllfPvlf6nFBj1mYimIEP9A6oK9OJ9X6ztyaeffuoKJp8KKL+Phi+60BAV3kr7EaWPSOX31GY7Pv93du3atcJyv4CLRYGTUluOP/54F/RpoJYTTjghkjJU1SieSv9U2pHSV5QaKipoo29m6vvzA0B9iVU26FpYuUw54ogjqv098b5PKYLRKetKm1faoyr31Bc9mlLzo1V3LdbvKSsrq/K6WpPt+BSkKuiKVRYpMItFFX6qpLz44ovt//7v/1wZdPTRR7v0/ao8/fTTLvhV14SOHTu646PPorIoyM8PxItAD6gkujVP1H/ugAMOcH3CaiLe98XanlxzzTWun2BttWjRwj2qD5w6kcfav5pux+8roP4E0QGunlfn3HPPdS2l6p+g/hDqm/DGG2+4WtDKtL/qR6E+Jup/IRouWzWelTvV1/fnB4D6Eqts0GAgDz30UI1+T7zvq6osOuuss1x/wdqKvhZXtX813U50WRRNzyt/jsrBrX7Un09l0FFHHWW33Xab67tdmcob9Vu888477cQTT4wsU9lX27Iolro6zkAsDOcDbIAGHlHqizptV66F2xTv0yhhGpTlgQceWO81DegSL7WGabCUJ598ssJyDUxS2+1olDB17Pc7q/ujiiqNpSoq3HQMlM6iQQUUWKmzuQZikSZNmkRqPv2UTRWi0SOSVa6JDurzA0BQVKboWqg09pqWRbV5nwYi07U61jVyQ++NpqwMBZpVXYtrsx1lyygdNbos0mjQqiCsilIoNSCaqIXuwgsvdJWN/oTqlcsiZd5o/eiySMGhltfEpvj8QLxo0QM2YPTo0S7fXvn/qtVTfr6GU1YB8N///rfO36fUkEceecQOO+wwF/RoeGwV0OqLcNxxx9npp58e13em36OCQ6O3qbBQnwT1GVDh8txzz9VqO0q9vO666+y8885zqSsKlO66666Yw0j71AdEaZvajtI4FRSqNvXhhx+OjGym4axVmCq1Rn3olJ6p/db71NeucgEZ1OcHgKBcdNFFblTNwYMHu+uTgh2NgqxRitWHrK7fJ2oFVDCkn0MOOcQFS+p3rv7nKuPipdEv9Ts0crIeNbqkAiy/D1pttqPRL9W/XWmPCtx0PffT/GPRyJfqOqDfrywOpbTquGiUZb8sUjbJgw8+6I6RplfQj46ZWttUAaiRSytP4RPU5wfiQaAHRKVXKKXDH7gjup+ECkMFGyocVeunwkWDkPjUyqX3Rnecj+d96gheucVPlPKpgUieeuopN0SzBmy5++67XUHkU4FQOeVw6NChFebd0XDNyvvX79Gw0Hr/LbfcUqPtxBogQJ3Q1RF+7dq1btoDdfL3B1qpPGG6BkhRgaVtaBoKrad98l9Xaqv6N6ivnGpKNb2C1tOw05r0Vx3Ste9aT4Fl0J8fADYllSMqT/z573wqQzQNgiqqVGmovsW65in48Kn80nv9dMF436e+btGDgvlUUampazQJu66hap3SoCG6xkf3I4uek0/UghXd309T66iiTVkuKi9U9qlSrSbbqUxTIWnuWvWjU4WgskVUAamWvVgTpqssUSaJtqHjoeOrskYBsKjvuD+vqgItlQMaREXHSeWDyh9tT0FbdL/0oD4/EI80Db0Z15oAAAAAgIRAHz0AAAAASDIEegAAAACQZAj0AAAAACDJEOgBAAAAQJIh0AMAAACAJEOgBwAAAABJhkAPAAAAAJIMgR4AAAAAJBkCPQAAAABIMgR6AAAAAJBkCPQAAAAAIMkQ6AEAAACAJZf/B6cyISqb/xolAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "best_mask = np.zeros(len(lr_pairs))\n", + "best_mask[np.asarray(results[results['best_run']]['ppi_data'], dtype=bool)] = 1.0\n", + "best_distance = scorer.distance_batch(best_mask[source][None, :])[0]\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(9, 3.6), sharey=True)\n", + "for ax, matrix, title in zip(axes, [baseline_distance, best_distance],\n", + " ['All pairs', 'Selected pairs']):\n", + " vector = scipy.spatial.distance.squareform(matrix, checks=False)\n", + " rho = _correlation(vector, reference_vector)\n", + " ax.scatter(reference_vector, vector, s=12, alpha=0.6, color='steelblue')\n", + " ax.set_title('{} ($|\\\\rho|$ = {:.3f})'.format(title, rho))\n", + " ax.set_xlabel('reference distance')\n", + "axes[0].set_ylabel('CCI distance')\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5022e31f", + "metadata": {}, + "source": [ + "## 5. Why this is fast\n", + "\n", + "Rebuilding the CCI scores for every individual would be far too slow: the published analysis used a\n", + "population of 200 over 200 generations, i.e. **40,000 evaluations per run**.\n", + "\n", + "`PreparedCCIScorer` avoids that. Every CCI score is a function of three quantities that are each a\n", + "weighted sum over the ligand-receptor pairs, and therefore **linear in the pair weights** `w`:\n", + "\n", + "```\n", + "N(i, j) = sum_k w_k * A_ki * B_kj SA(i) = sum_k w_k * A_ki^2\n", + " SB(j) = sum_k w_k * B_kj^2\n", + "\n", + "bray_curtis = 2N / (SA + SB) jaccard = N / (SA + SB - N) icellnet = N\n", + "```\n", + "\n", + "`A` and `B` depend only on the expression data, which does not change during the search. So they are\n", + "computed once, and scoring a whole population becomes a single matrix multiplication.\n", + "\n", + "This is used automatically. `fast=False` falls back to rebuilding the scores through\n", + "`InteractionSpace`, exactly as the original implementation did — the two give the same answer." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "c675ff5e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:27.840603Z", + "iopub.status.busy": "2026-08-23T22:44:27.840515Z", + "iopub.status.idle": "2026-08-23T22:44:27.848741Z", + "shell.execute_reply": "2026-08-23T22:44:27.848240Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "whole population at once : 0.0004 s\n", + "one individual at a time : 0.0045 s\n", + "identical : True\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "rng_check = np.random.default_rng(0)\n", + "population = rng_check.integers(0, 2, size=(200, len(lr_pairs))).astype(float)\n", + "\n", + "start = time.perf_counter()\n", + "batched = scorer.score_batch(population[:, source])\n", + "t_batched = time.perf_counter() - start\n", + "\n", + "start = time.perf_counter()\n", + "one_at_a_time = np.stack([scorer.score_batch(w[source][None, :])[0] for w in population])\n", + "t_single = time.perf_counter() - start\n", + "\n", + "print('whole population at once : {:.4f} s'.format(t_batched))\n", + "print('one individual at a time : {:.4f} s'.format(t_single))\n", + "print('identical :', np.allclose(batched, one_at_a_time, rtol=1e-12, atol=1e-12))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "8aa49dc4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:27.850018Z", + "iopub.status.busy": "2026-08-23T22:44:27.849927Z", + "iopub.status.idle": "2026-08-23T22:44:27.857196Z", + "shell.execute_reply": "2026-08-23T22:44:27.856743Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "largest difference vs InteractionSpace: 0.00e+00\n" + ] + } + ], + "source": [ + "# ...and the vectorized scorer agrees with the reference implementation\n", + "theta = rng_check.integers(0, 2, size=len(lr_pairs)).astype(float)\n", + "weights = theta[source]\n", + "\n", + "interaction_space.ppi_data['score'] = weights\n", + "interaction_space.compute_pairwise_cci_scores(use_ppi_score=True, verbose=False)\n", + "expected = interaction_space.interaction_elements['cci_matrix'].values\n", + "\n", + "print('largest difference vs InteractionSpace: {:.2e}'\n", + " .format(np.abs(scorer.score_batch(weights[None, :])[0] - expected).max()))" + ] + }, + { + "cell_type": "markdown", + "id": "b8ed91df", + "metadata": {}, + "source": [ + "## 6. The *C. elegans* analysis\n", + "\n", + "The published study searched for the ligand-receptor pairs that best explain the physical distances\n", + "between the cells of the whole animal. The data is in\n", + "[LewisLabUCSD/Celegans-cell2cell](https://github.com/LewisLabUCSD/Celegans-cell2cell), and pandas\n", + "reads it straight from the URL, so everything below runs as it is." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "50d74ad6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:27.858365Z", + "iopub.status.busy": "2026-08-23T22:44:27.858282Z", + "iopub.status.idle": "2026-08-23T22:44:34.747415Z", + "shell.execute_reply": "2026-08-23T22:44:34.745020Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "expression : (19391, 27) (genes x cells)\n", + "LR pairs : 245\n", + "cells in the map: 22\n" + ] + } + ], + "source": [ + "BASE = ('https://raw.githubusercontent.com/LewisLabUCSD/'\n", + " 'Celegans-cell2cell/master/data/')\n", + "\n", + "celegans_rnaseq = c2c.io.load_rnaseq(\n", + " rnaseq_file=BASE + 'RNA-Seq/Celegans_RNASeqData_Cell.xlsx',\n", + " gene_column='gene_id', drop_nangenes=True, log_transformation=False,\n", + " format='auto', verbose=False)\n", + "\n", + "celegans_lr = c2c.io.load_ppi(\n", + " ppi_file=BASE + 'PPI-Networks/Celegans-Curated-LR-pairs.xlsx',\n", + " interaction_columns=('Ligand_WB', 'Receptor_WB'),\n", + " rnaseq_genes=list(celegans_rnaseq.index), format='auto', verbose=False)\n", + "\n", + "physical_distances = pd.read_csv(\n", + " BASE + 'Digital-3D-Map/Celegans_Physical_Distances_Min.csv', index_col=0)\n", + "\n", + "print('expression :', celegans_rnaseq.shape, '(genes x cells)')\n", + "print('LR pairs :', len(celegans_lr))\n", + "print('cells in the map:', physical_distances.shape[0])" + ] + }, + { + "cell_type": "markdown", + "id": "6b590cbd", + "metadata": {}, + "source": [ + "The study reported three variants, differing only in the score:\n", + "\n", + "| Score | `communication_score` | `cci_score` | Notes |\n", + "|---|---|---|---|\n", + "| Bray-Curtis | `expression_thresholding` | `bray_curtis` | the main analysis |\n", + "| LR count | `expression_thresholding` | `count` | counts active pairs |\n", + "| ICELLNET | `expression_product` | `icellnet` | expression is `log2(x + 1)` transformed first |\n", + "\n", + "We run the Bray-Curtis one, at the published settings: 200 individuals, 200 generations, a 10 TPM\n", + "threshold, and runs continuing until the objective improves by less than 2.5%." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "bd0537ad", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:44:34.751911Z", + "iopub.status.busy": "2026-08-23T22:44:34.751605Z", + "iopub.status.idle": "2026-08-23T22:45:02.507101Z", + "shell.execute_reply": "2026-08-23T22:45:02.506657Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "finished in 28 s\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
runobjectivepairs kept
0run10.49871391
1run20.62300545
2run30.64209030
3run40.64780623
\n", + "
" + ], + "text/plain": [ + " run objective pairs kept\n", + "0 run1 0.498713 91\n", + "1 run2 0.623005 45\n", + "2 run3 0.642090 30\n", + "3 run4 0.647806 23" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "celegans_setup = {'communication_score': 'expression_thresholding',\n", + " 'cci_score': 'bray_curtis',\n", + " 'cci_type': 'undirected'}\n", + "\n", + "start = time.perf_counter()\n", + "celegans_results = c2c.analysis.optimize_lr_pairs(\n", + " rnaseq_data=celegans_rnaseq,\n", + " ppi_data=celegans_lr,\n", + " reference_distances=physical_distances,\n", + " cutoff_setup={'type': 'constant_value', 'parameter': 10}, # 10 TPM\n", + " analysis_setup=celegans_setup,\n", + " population_size=200,\n", + " generations=200,\n", + " runs=None, # until the objective stops improving\n", + " inc_percentage=0.025,\n", + " random_state=888,\n", + ")\n", + "print('finished in {:.0f} s'.format(time.perf_counter() - start))\n", + "\n", + "pd.DataFrame([{'run': k, 'objective': celegans_results[k]['obj_fn'],\n", + " 'pairs kept': celegans_results[k]['n_selected']}\n", + " for k in sorted(celegans_results) if k.startswith('run')])" + ] + }, + { + "cell_type": "markdown", + "id": "8f68b4cf", + "metadata": {}, + "source": [ + "Each of the ~40,000 objective evaluations per run would otherwise have meant rebuilding the CCI\n", + "scores; the vectorized scorer is what brings the whole search down to seconds.\n", + "\n", + "### Comparing with the pairs published in the paper\n", + "\n", + "The 37 pairs the paper selected for Bray-Curtis are in the same repository." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d9b47708", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:45:02.508358Z", + "iopub.status.busy": "2026-08-23T22:45:02.508274Z", + "iopub.status.idle": "2026-08-23T22:45:02.964679Z", + "shell.execute_reply": "2026-08-23T22:45:02.963819Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "paper selection : 37 pairs\n", + "our selection : 23 pairs\n", + " also in the paper : 21\n", + " not in the paper : 2\n", + " of the paper's, we recovered 21 (57%)\n" + ] + } + ], + "source": [ + "paper_pairs = pd.read_csv(BASE + 'PPI-Networks/Celegans-GA-BrayCurtis-Selected-LR-pairs.csv')\n", + "\n", + "# `remove_ppi_bidirectionality` can flip a pair, so compare them as unordered\n", + "unordered = lambda a, b: frozenset((a, b))\n", + "paper_keys = {unordered(a, b) for a, b in\n", + " paper_pairs[['Ligand_WB', 'Receptor_WB']].values}\n", + "our_keys = {unordered(a, b) for a, b in\n", + " celegans_results['best_ppi_data'][['A', 'B']].values}\n", + "\n", + "shared = our_keys & paper_keys\n", + "print('paper selection : {} pairs'.format(len(paper_keys)))\n", + "print('our selection : {} pairs'.format(len(our_keys)))\n", + "print(' also in the paper : {}'.format(len(shared)))\n", + "print(' not in the paper : {}'.format(len(our_keys - paper_keys)))\n", + "print(' of the paper\\'s, we recovered {} ({:.0%})'\n", + " .format(len(shared), len(shared) / len(paper_keys)))" + ] + }, + { + "cell_type": "markdown", + "id": "1ef54492", + "metadata": {}, + "source": [ + "The overlap is far beyond chance, but a single execution understates the agreement: a genetic\n", + "algorithm returns a *local* optimum, so different seeds land on different, largely overlapping sets.\n", + "\n", + "The published analysis handled this by running the search many times independently and asking which\n", + "pairs are selected **together**. `executions=N` does that here — it repeats the search and adds three\n", + "things to the result:\n", + "\n", + "- `selection_frequency` — how often each pair was chosen;\n", + "- `cooccurrence` — the Jaccard index between the selection patterns of every two pairs, i.e. how\n", + " often they are chosen alongside each other;\n", + "- `consensus_ppi_data` — the pairs of the co-occurrence cluster whose members are most consistently\n", + " co-selected. **This is the output to use.**" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "6969f138", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:45:02.966872Z", + "iopub.status.busy": "2026-08-23T22:45:02.966715Z", + "iopub.status.idle": "2026-08-23T22:57:47.986690Z", + "shell.execute_reply": "2026-08-23T22:57:47.986218Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "30 executions in 765 s\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ABfrequency
23WBGene00000857WBGene000032381.000000
189WBGene00004890WBGene000040470.966667
173WBGene00003774WBGene000113450.933333
235WBGene00019407WBGene000113450.933333
16WBGene00000674WBGene000161040.800000
143WBGene00002881WBGene000068940.766667
11WBGene00000527WBGene000016870.733333
224WBGene00006951WBGene000042080.700000
156WBGene00003111WBGene000040480.700000
46WBGene00000936WBGene000206490.666667
\n", + "
" + ], + "text/plain": [ + " A B frequency\n", + "23 WBGene00000857 WBGene00003238 1.000000\n", + "189 WBGene00004890 WBGene00004047 0.966667\n", + "173 WBGene00003774 WBGene00011345 0.933333\n", + "235 WBGene00019407 WBGene00011345 0.933333\n", + "16 WBGene00000674 WBGene00016104 0.800000\n", + "143 WBGene00002881 WBGene00006894 0.766667\n", + "11 WBGene00000527 WBGene00001687 0.733333\n", + "224 WBGene00006951 WBGene00004208 0.700000\n", + "156 WBGene00003111 WBGene00004048 0.700000\n", + "46 WBGene00000936 WBGene00020649 0.666667" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "start = time.perf_counter()\n", + "consensus = c2c.analysis.optimize_lr_pairs(\n", + " rnaseq_data=celegans_rnaseq,\n", + " ppi_data=celegans_lr,\n", + " reference_distances=physical_distances,\n", + " cutoff_setup={'type': 'constant_value', 'parameter': 10},\n", + " analysis_setup=celegans_setup,\n", + " population_size=200, generations=200, runs=None, inc_percentage=0.025,\n", + " executions=30, # thirty independent searches\n", + " random_state=888,\n", + ")\n", + "print('30 executions in {:.0f} s'.format(time.perf_counter() - start))\n", + "\n", + "consensus['selection_frequency'].head(10)" + ] + }, + { + "cell_type": "markdown", + "id": "51dbf001", + "metadata": {}, + "source": [ + "The co-occurrence matrix is what the consensus is built from. Clustering it separates a tight group\n", + "of pairs that keep being chosen together from a diffuse remainder." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "c6d69772", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:57:47.989070Z", + "iopub.status.busy": "2026-08-23T22:57:47.988983Z", + "iopub.status.idle": "2026-08-23T22:57:47.991196Z", + "shell.execute_reply": "2026-08-23T22:57:47.990813Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cluster sizes : {np.int32(1): 75, np.int32(2): 31}\n", + "mean co-occurrence within : {np.int32(1): 0.076, np.int32(2): 0.359}\n", + "\n", + "consensus selection: 31 pairs\n" + ] + } + ], + "source": [ + "sizes = {k: len(v) for k, v in consensus['consensus_clusters'].items()}\n", + "scores = {k: round(v, 3) for k, v in consensus['consensus_cluster_scores'].items()}\n", + "print('cluster sizes :', sizes)\n", + "print('mean co-occurrence within :', scores)\n", + "print('\\nconsensus selection: {} pairs'.format(len(consensus['consensus_ppi_data'])))" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "92f46fcb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:57:47.992356Z", + "iopub.status.busy": "2026-08-23T22:57:47.992286Z", + "iopub.status.idle": "2026-08-23T22:57:47.994640Z", + "shell.execute_reply": "2026-08-23T22:57:47.994249Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "consensus selection : 31 pairs\n", + " also in the paper : 29\n", + " not in the paper : 2\n" + ] + } + ], + "source": [ + "consensus_keys = {unordered(a, b) for a, b in consensus['consensus_ppi_data'][['A', 'B']].values}\n", + "shared = consensus_keys & paper_keys\n", + "print('consensus selection : {} pairs'.format(len(consensus_keys)))\n", + "print(' also in the paper : {}'.format(len(shared)))\n", + "print(' not in the paper : {}'.format(len(consensus_keys - paper_keys)))" + ] + }, + { + "cell_type": "markdown", + "id": "2bc2d1d1", + "metadata": {}, + "source": [ + "The two criteria for *which* cluster to keep coincide on this data: the tight cluster is also the\n", + "smaller one. `cluster_selection='cooccurrence'` (the default) ranks by mean co-occurrence among a\n", + "cluster's own members, which is the property the analysis is actually after;\n", + "`cluster_selection='smallest'` is literally what the reference notebook did and is kept so the\n", + "published result can be reproduced exactly.\n", + "\n", + "> **How many executions?** The clustering needs enough of them. Two pairs selected once, in the same\n", + "> execution, have a co-occurrence of 1.0 without that meaning anything, and with too few executions a\n", + "> cluster of such pairs outscores the reproducible one — with 10 executions this analysis returns 17\n", + "> pairs of which only 6 are the paper's, against 29 of 31 with 30. Use around 30 or more (the\n", + "> reference analysis used about a hundred), or set `min_frequency` to drop the rarely-chosen pairs.\n", + "> The frequency route below is the more forgiving of the two.\n", + "\n", + "### The simpler alternative: selection frequency\n", + "\n", + "Rather than asking which pairs are chosen *together*, one can just keep those chosen *often*. The\n", + "reference notebook computed this too, using the 90th percentile of the frequency distribution. It is\n", + "easier to reason about, but it cannot separate two groups of pairs that are each self-consistent yet\n", + "rarely co-selected." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "44a825b3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:57:47.995811Z", + "iopub.status.busy": "2026-08-23T22:57:47.995740Z", + "iopub.status.idle": "2026-08-23T22:57:47.999474Z", + "shell.execute_reply": "2026-08-23T22:57:47.999090Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "80th percentile (frequency > 0.17): 41 pairs | 34 in the paper, 7 not\n", + "85th percentile (frequency > 0.20): 35 pairs | 33 in the paper, 2 not\n", + "90th percentile (frequency > 0.33): 24 pairs | 24 in the paper, 0 not\n", + "95th percentile (frequency > 0.56): 13 pairs | 13 in the paper, 0 not\n" + ] + } + ], + "source": [ + "frequency = consensus['selection_frequency']['frequency'].values\n", + "\n", + "for percentile in (80, 85, 90, 95):\n", + " mask, threshold = c2c.analysis.consensus_from_frequency(frequency, percentile=percentile)\n", + " kept = consensus['selection_frequency'].loc[mask]\n", + " keys = {unordered(a, b) for a, b in kept[['A', 'B']].values}\n", + " print('{}th percentile (frequency > {:.2f}): {:>2} pairs | {:>2} in the paper, {:>2} not'\n", + " .format(percentile, threshold, len(keys), len(keys & paper_keys),\n", + " len(keys - paper_keys)))" + ] + }, + { + "cell_type": "markdown", + "id": "a5cf532d", + "metadata": {}, + "source": [ + "Both routes converge on the same set of pairs — the ones the paper reports. Which to prefer depends\n", + "on the question: the co-occurrence clustering keeps groups of pairs that act together, while the\n", + "frequency threshold is a plain ranking of individual pairs.\n", + "\n", + "The same steps are available on their own, for integrating results computed elsewhere:\n", + "`c2c.analysis.lr_selection_frequency`, `lr_cooccurrence`, `consensus_from_cooccurrence` and\n", + "`consensus_from_frequency`." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "7651f751", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T22:57:48.000596Z", + "iopub.status.busy": "2026-08-23T22:57:48.000514Z", + "iopub.status.idle": "2026-08-23T22:57:48.053968Z", + "shell.execute_reply": "2026-08-23T22:57:48.053675Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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iIiIKMBLJGDs2LEqaEB1xX333afGkChQoID06dPH30kjIiKyvIAukYBGjRrJihUr5OjRo/Luu+9KlSpVZPXq1ZI9e3avt43qj1GjRt1SDULeY976DvOWeRuMeNyGbt6GaZ707SAiIiIKxhIJIiIiClwMJIiIiMgwBhJERERkWMgHEpcuXZJNmzap4bZ9+RkrunDhgsqn06dPe/wZNJrduXOnXL9+3adpC3bnz59XeYsh4rMiNTVV1qxZo/KYnDt79qzKW/zNin379snvv//u0ZDBVoXjFXmL49dTGERp8+bNt4wXRI6Qp+hokJW8xbkZ+wPnap/SQtjrr7+uxcTEaJUrV9ZiY2O17t27aykpKaZ/xopGjhxpyyf87dmzp5aWluby/d99951WoUIFrUSJElqVKlW07NmzaxMmTLitaQ4WQ4cOdcjbZ599VktPT/fos8OGDdPCw8O1Ro0a+TydweiFF15wyFs8z8zatWvVsVukSBHtnnvuUY+9e/felvQGCxyfOE7t8xbHsTvHjx/XateureXJk0erUaOGljNnTnXcnjt37ralOxjs2bNHXYcKFy6MCFabMWNGpp9JTU1V52Rcw/T9gXO2r4RsILF48WItIiJCW7FihXp+8OBB7Y477tDGjRtn6mesaM6cOVpUVJS2Zs0a9fyPP/7QcuXKpb377rsuP4OgYd++fbbnCxcu1MLCwrQlS5bcljQHi2+//Vb902/cuFE937lzp5YtWzbt448/zvSzy5Yt08qVK6d17NiRgYQT06ZN0+Lj47Xt27er51u3btXi4uK0L774wmWeHj58WMuRI4e6KOqB8u7du7Xly5cb3cUhafLkySqfdu3apZ5v2LBBi46OVjcQrnTp0kWrVq2adu3aNfX80qVLWunSpbXnnnvutqU7GMycOVMdo1euXPE4kJg0aZKWO3duW8C7atUqLTIyUps7d65P0hiygcRjjz2mNW7c2GFd//791Z2FmZ+xorZt22oPPvigw7pevXppd911V5a2U6pUKW3EiBEmpy64PfDAA1r79u0d1nXr1k2rU6eO28+dOXNGK1asmLZu3TrtySefZCDhRMOGDbXHH3/cYd2jjz7qNq/69eunLm64wyPXULLQo0cPh3Vt2rTRWrZs6fIzLVq0UMe2vdatW6vzMN0KJeOeBhLVq1fX+vbt67CuefPmWrt27TRf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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(5.4, 3.4))\n", + "ax.hist(frequency[frequency > 0], bins=25, color='steelblue', alpha=0.7,\n", + " edgecolor='w', label='pairs selected at least once')\n", + "_, threshold = c2c.analysis.consensus_from_frequency(frequency, percentile=90)\n", + "ax.axvline(threshold, color='firebrick', ls='--', label='90th percentile')\n", + "ax.set_xlabel('fraction of executions that selected the pair')\n", + "ax.set_ylabel('number of LR pairs'); ax.legend(fontsize=8)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "a813f6ec", + "metadata": {}, + "source": [ + "### Notes\n", + "\n", + "- **`pygad` is not `pyevolve`**, the seeds differ, and the published set is one run of many, so the\n", + " numbers will not match exactly. The reproducible core is what should agree, and does.\n", + "- **The pair list is deduplicated first.** `optimize_lr_pairs` applies `remove_ppi_bidirectionality`\n", + " before searching (`deduplicate=True`), because `bidirectional_ppi_for_cci` drops duplicates on\n", + " (A, B, score): if a table holds a pair *and* its reciprocal, the number of bidirectional rows\n", + " depends on the candidate solution and a pair no longer maps onto a fixed set of them. Pairs loaded\n", + " with `load_ppi` are already deduplicated, so this is a no-op here.\n", + "- **No multiprocessing.** The original distributed independent runs across cores; the vectorized\n", + " objective makes one run fast enough that it is not needed." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index 73de842..e149b81 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,7 @@ nav: - "cell2cell Tutorials": - tutorials/Toy-Example-BulkPipeline.ipynb - tutorials/Toy-Example-SingleCellPipeline.ipynb + - tutorials/Genetic-Algorithm-LR-Selection.ipynb - "Tensor-cell2cell Tutorials": - tutorials/ASD/01-Tensor-Factorization-ASD.ipynb - tutorials/ASD/02-Factor-Specific-ASD.ipynb diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7ea5e35 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +testpaths = tests + +# Network tests are deselected by default so the suite is hermetic and fast. +# Run them explicitly with: pytest -m network +addopts = -m "not network" + +markers = + network: tests that download data from the internet (deselected by default) + slow: long-running tests, such as tensor factorizations and permutations diff --git a/release/0.9.0-notes.md b/release/0.9.0-notes.md new file mode 100644 index 0000000..f5b23fe --- /dev/null +++ b/release/0.9.0-notes.md @@ -0,0 +1,229 @@ +# Release Notes - cell2cell v0.9.0 + +## New features + +- **Test suite**: The package now includes a test suite under `tests/`, run with `pytest`. It covers +the public functions of every subpackage, and includes regression tests for each of the bugs fixed in +this release. Tests that download data are marked as `network` and deselected by default; long-running +ones are marked as `slow`. `pytest.ini` holds the configuration, `pip install -e .[test]` installs the +test dependencies, and the tests run on every push and pull request through +`.github/workflows/test.yml`. + +- **Selection of ligand-receptor pairs with a genetic algorithm**: The new module +`cell2cell.analysis.genetic_algorithm` searches for the subset of ligand-receptor pairs whose +cell-cell interaction scores best reproduce a reference distance between cells, for example the +physical distances of a 3D map. It brings into the package the analysis of the whole body of +*C. elegans* ([Armingol et al., 2022](https://doi.org/10.1371/journal.pcbi.1010715)), whose code lived in +[LewisLabUCSD/Celegans-cell2cell](https://github.com/LewisLabUCSD/Celegans-cell2cell) and depended on +`pyevolve`, which only supports Python 2. The objective function is the same, the absolute Spearman +correlation between the CCI distance matrix and the reference one, and the search is repeated in +successive runs that keep only the pairs the previous one selected. + + - `optimize_lr_pairs()` runs the search, using [`pygad`](https://pygad.readthedocs.io) as the + genetic algorithm. It is an optional dependency, installed with `pip install cell2cell[ga]`. + - `PreparedCCIScorer` is what makes it usable: every CCI score is a function of three quantities + that are weighted sums over the ligand-receptor pairs, and therefore linear in the pair weights. + The expression-dependent part is computed once, so scoring a whole population of candidate + solutions becomes a single matrix multiplication instead of one rebuild of the interaction space + per individual. It is exact — the tests check it against + `InteractionSpace.compute_pairwise_cci_scores` for the 'bray_curtis', 'jaccard', 'count' and + 'icellnet' scores — and `fast=False` falls back to that reference path. + - The list of pairs is deduplicated with `remove_ppi_bidirectionality` before the search, and + input where a pair and its reciprocal are separate rows is rejected. `bidirectional_ppi_for_cci` + drops duplicates on (A, B, score), so on such a table the number of bidirectional rows depends on + the candidate solution and a pair no longer maps onto a fixed set of rows. Tables loaded with + `load_ppi` are already deduplicated by `preprocess_ppi_data`, so this only matters when the pairs + come from somewhere else. + - Unlike the original script there is no multiprocessing, which the vectorized objective makes + unnecessary. + - **Integrating independent executions.** A genetic algorithm converges to a *local* optimum, so + one execution is not conclusive. `optimize_lr_pairs(executions=N)` repeats the search from + different seeds and integrates the results the way the reference analysis did, adding + `selection_frequency` (how often each pair is chosen), `cooccurrence` (the Jaccard index between + the selection patterns of every two pairs, i.e. how often they are chosen *together*) and + `consensus_ppi_data`, the recommended output. The same steps are available on their own as + `lr_selection_frequency`, `lr_cooccurrence`, `consensus_from_cooccurrence` and + `consensus_from_frequency`, so results obtained elsewhere can be integrated too. + - Two ways to reach the consensus. `consensus_method='cooccurrence'` (the default) clusters the + co-occurrence matrix and keeps one cluster — by default the one whose members co-occur most with + each other, or with `cluster_selection='smallest'` the one with fewest members, which is + literally what the reference notebook did. `consensus_method='frequency'` instead keeps the pairs + selected more often than a percentile of the frequency distribution, which is simpler but cannot + tell apart two groups that are each self-consistent yet rarely chosen together. + - A tutorial is available at + [Identifying a spatial code of ligand-receptor pairs with a genetic algorithm](./tutorials/Genetic-Algorithm-LR-Selection/). + It reruns the *C. elegans* analysis end to end, reading the data straight from the repository + URLs, and compares the outcome with the pairs published with the paper. + +- **Distances between cell types from single-cell spatial coordinates**: `cell2cell.spatial` gained +functions to summarize how far apart two cell types are, starting from the coordinates of their +single cells. They accept an AnnData object, reading the coordinates from `adata.obsm` under a +configurable `spatial_key` (objects written by different tools use different keys), or a plain +dataframe. + + - `celltype_distances()` is the single entry point. Its `method` can be 'centroid' (distance + between the centroids of the two cell types), or 'min', 'max', 'mean' and 'median' to aggregate + the distances between all of their single cells. Only 'centroid' has a cost independent of the + number of single cells, which makes it the option for large datasets. + - `celltype_centroids()` returns the centroid of each cell type, as a mean or a median of the + coordinates of its single cells; `celltype_centroid_distances()` the distances between them; and + `get_spatial_coordinates()` extracts `adata.obsm[spatial_key]` as a dataframe. + - The result is always symmetric with a zero diagonal, so it can be used directly wherever a + distance matrix is expected, including as the reference of the genetic algorithm above and as the + `distances` argument of `dist_filter_tensor` and `dist_filter_liana`. + - `celltype_pair_distance()` and `pairwise_celltype_distances()` also accept `method='median'` now. + +- **New toy datasets in `cell2cell.datasets`**: Five deterministic generators were added to cover +scenarios that the existing ones could not, so tests and users have realistic inputs without any +download: + - `generate_toy_contexts()`: a dictionary with a toy RNA-seq dataset per context, to build + 4D communication tensors. + - `generate_toy_single_cells()`: a toy single-cell dataset with its metadata, using the + 'barcodes' and 'cell_types' columns expected by `aggregate_single_cells`. + - `generate_toy_coordinates()`: spatial coordinates ('X', 'Y') with a cell-type column. + - `generate_toy_spatial_adata()`: an AnnData object with coordinates in `obsm['spatial']`. + - `generate_toy_liana_output()`: a LIANA-like output in long format, which can be grouped by + its 'context' column to feed `dataframes_to_tensor`. + + Elements are numbered so that their alphabetical and natural orders differ when enough of them are + generated (e.g. 'Context-2' and 'Context-10'), which makes the natural sorting of this release + directly testable. + +- **New `'trimean'` aggregation method for single cells**: `aggregate_single_cells` in +`cell2cell.preprocessing.rnaseq` accepts `method='trimean'` to aggregate the gene expression of the +single cells composing a cell type using Tukey's trimean (`0.5 * Q2 + 0.25 * (Q1 + Q3)`), computed +with a new `cell2cell.preprocessing.rnaseq._trimean` helper. It is a weighted average of the median +and the first and third quartiles, so it is more robust to outliers than the average while still +accounting for the spread of the distribution, and it ignores NaNs. It is also available through the +`aggregation_method` parameter of `SingleCellInteractions` in `cell2cell.analysis`. + +- **New dependency `natsort`**: Element names are now sorted using natural sorting (`natsorted`) +instead of lexicographic sorting (`sorted`) across the package. Names containing numbers are ordered +as expected (e.g. `Factor 1, Factor 2, ..., Factor 10` instead of `Factor 1, Factor 10, Factor 2, ...`, +and `C-1, C-2, C-10` instead of `C-1, C-10, C-2`). + +## Feature updates + +- **Natural sorting of element names**: `sorted()` was replaced with `natsorted()` in the functions +that define the order of element names, including `build_context_ccc_tensor` and `dataframes_to_tensor` +in `cell2cell.tensor` (tensor gene, cell, LR-pair and context orders), `add_sliding_window_info_to_adata` +in `cell2cell.spatial` (window column names), `run_label_permutation` in `cell2cell.stats`, +`initialize_interaction_space` in `cell2cell.analysis`, `get_filtered_ppi_network` and +`get_genes_from_go_hierarchy` in `cell2cell.preprocessing`, `get_files_from_directory` in `cell2cell.io`, +`aggregate_single_cells` in `cell2cell.preprocessing` (order of the aggregated cell types), +and `generate_legend`, `pcoa_3dplot`, `context_boxplot` and `circos_plot` in `cell2cell.plotting`. + +- **Reproducible order of elements**: Multiple functions obtained the order of their elements from a +Python `set`, which is not reproducible across runs. These now return a deterministic order: + - `generate_pairs` in `cell2cell.core.interaction_space` keeps the order given by the list of cells + when removing duplicated pairs, which also defines the column order of the communication matrix. + - `get_element_abundances` in `cell2cell.preprocessing.find_elements` keeps the order in which + elements are first found, making the `how='outer'` options of the tensor builders reproducible. + - `dataframes_to_tensor` in `cell2cell.tensor` keeps the order in which elements appear in the + input dataframes when `sort_elements=False`. + - `random_switching_ppi_labels` in `cell2cell.stats.permutation` sorts the genes before permuting + them, so results are now reproducible for a given `random_state` when a list of `genes` is passed. + - `circos_plot` and `context_boxplot` in `cell2cell.plotting` produce a reproducible order of + cells, nodes and groups. + +- **Note on the order of results**: Because of the changes above, the order of the elements in some +outputs may differ from previous versions (tensor dimensions, columns of the communication matrix, +order of groups and legends in plots, and permutations obtained with a fixed `random_state`). The +values themselves are unchanged. Lexicographic sorting was intentionally kept in +`remove_ppi_bidirectionality` in `cell2cell.preprocessing.ppi` because it determines which direction +of a bidirectional interaction is kept, so its results remain identical to previous versions. + +## Fixed Bugs + +- **`run_label_permutation` compared scores it never computed**: This function in +`cell2cell.stats.permutation` built an `InteractionSpace` for every permutation and one for the +observed data, but never called `compute_pairwise_cci_scores` on any of them. That method is the only +one that fills `interaction_elements['cci_matrix']`, so the function read the matrix of zeros the +interaction space is initialized with, for both the null distribution and the observed value. As a +consequence **every returned p-value was the same constant, and the expression data had no effect on +the result at all** (on the toy dataset, comparing an all-zeros observed matrix against an all-zeros +null gave 0.9 for every cell pair, whether the expression values were real or completely randomized). +The scores are now computed before being read. **Any p-values obtained from this function should be +recomputed.** `permute_cell_labels` in `cell2cell.analysis` was not affected: it computes the scores +of each permutation explicitly and guards the observed ones, so its results are unchanged. + +- **Wrong cell-pair labels in `flatten_factor_ccc_networks`**: This function in +`cell2cell.analysis.tensor_downstream` built the names of the sender-receiver pairs from the +*sorted* names of the cells, but flattened the loadings of the factor-specific networks in the order +the cells have in the tensor. When the elements of the sender and receiver dimensions of the tensor +were not alphabetically sorted, every loading was assigned to the wrong sender-receiver pair. The +names of the cell pairs are now built directly from the adjacency matrices, keeping the order of the +tensor dimensions, and the matrices are reindexed before flattening so that values and labels always +match. **`get_lr_by_cell_pairs` used this function internally, so its results were affected too. +Results and figures generated from tensors whose sender/receiver elements were not alphabetically +sorted should be regenerated.** Results obtained from tensors with alphabetically sorted elements +were not affected and are identical to previous versions. + +- **Factor order in tensor downstream analyses**: `get_factor_specific_ccc_networks` and +`compute_gini_coefficients` in `cell2cell.analysis.tensor_downstream` sorted the factor names +lexicographically, so decompositions with 10 or more factors were returned in the order +`Factor 1, Factor 10, Factor 11, Factor 2, ...`. They are now naturally sorted. + +- **Factor names in `get_lr_by_cell_pairs`**: This function assumed that every factor was named +`' '` and raised an error with any other naming. It now uses natural sorting instead +of parsing the factor names. + +- **`aggregate_single_cells` modified the dataframe passed by the user**: This function in +`cell2cell.preprocessing.rnaseq` replaced the index of the input dataframe with the cell types and +added a `'celltype'` column to it, modifying it in place. As a consequence, calling the function +twice on the same dataframe raised a `KeyError`. It now groups the single cells by an external list +of cell types, leaving the input untouched (without copying the expression matrix). The aggregated +values are unchanged; only the column order may differ, as the cell types are now naturally sorted. + +- **Negative distances for the 'count' and 'icellnet' CCI scores**: In +`cell2cell.core.interaction_space`, the branch that computes the distance matrix was guarded by +`if ~(cci_score in ['count', 'icellnet'])`. Bitwise inversion of a boolean returns -2 or -1, and both +are truthy, so the regularized-distance branch was never reached. Since those two scores are not +bounded between 0 and 1, the distances were computed as `1 - score` and became **negative** (down to +-7 on the toy dataset), which invalidated any distance-based analysis such as PCoA or clustering. +These two scores now use the intended regularized distance. The 'bray_curtis' and 'jaccard' scores +were not affected and their results are unchanged. + +- **`scale_expression_by_sum` could not normalize across columns**: The function in +`cell2cell.preprocessing.rnaseq` did not keep the sums two-dimensional, so the documented `axis=1` +option raised a broadcasting error. Results for `axis=0` (the default) are unchanged. + +- **`convert_to_distance_matrix` raised instead of warning**: This function in +`cell2cell.preprocessing.manipulate_dataframes` used `raise Warning(...)` when the diagonal was not +zero, which aborted before replacing it, despite the message saying it was replaced automatically. +Because `pcoa` calls this function on every input, **`cell2cell.external.pcoa` failed for any +similarity or correlation matrix whose diagonal was not already zero**. It now warns and replaces the +diagonal as documented. + +- **`pcoa_biplot` crashed and could return uninitialized values**: In `cell2cell.external.pcoa`, the +inverse square root of the eigenvalues was computed with `np.power(..., where=...)` on a pandas +Series, which recursed through pandas' ufunc handling and raised a `RecursionError`. The missing +`out` argument also meant the entries excluded by `where` read uninitialized memory, so results were +not reproducible. Both are fixed. + +- **`random_switching_ppi_labels` crashed with its default arguments**: The function in +`cell2cell.stats.permutation` selected the interaction columns with a tuple, which pandas interprets +as a single column name. Calling it with the default `genes=None` and `permuted_column='both'` +therefore always raised `KeyError: ('A', 'B')`. + +- **`concatenate_interaction_tensors` did not work on the default backend**: The function in +`cell2cell.tensor.tensor_manipulation` called `.to('cpu')`, which only exists in backends such as +pytorch, and then read a `'device'` key that is absent from the context of a numpy-backed tensor. It +therefore always failed with the default numpy backend. + +- **`reorder_dimension_elements` crashed on its own default**: This function in +`cell2cell.plotting.tensor_plot` copied `metadata` unconditionally, so the documented `metadata=None` +default raised an `AttributeError`, even though the code that follows already checks for None. + +- **`check_presence_in_dataframe` failed on mixed data types**: The function in +`cell2cell.preprocessing.manipulate_dataframes` sorted the values, which cannot compare strings with +numbers, so its documented `columns=None` default failed on any dataframe holding both (for example +a PPI table with a numeric score column). + +- **`add_sliding_window_info_to_adata` with pandas >= 2.0**: This function in `cell2cell.spatial` +passed a `set` of barcodes to `DataFrame.loc`, which newer versions of pandas do not accept. The +barcodes are now passed as a list. + +- **Docstring of `flatten_factor_ccc_networks`**: It stated that rows were factors and columns were +cell-cell pairs, while the returned dataframe contains the cell-cell pairs as rows and the factors +as columns. diff --git a/setup.py b/setup.py index 7185026..edcbade 100644 --- a/setup.py +++ b/setup.py @@ -95,6 +95,7 @@ def run(self): 'seaborn >= 0.11.0', 'scikit-learn', 'umap-learn', + 'natsort', 'tqdm', 'statsmodels', 'statannotations', @@ -103,6 +104,10 @@ def run(self): 'scanpy', 'gseapy >= 1.0.3' ], + extras_require={'test': ['pytest', 'pytest-cov', 'pygad'], + # Only needed by cell2cell.analysis.optimize_lr_pairs + 'ga': ['pygad'], + }, classifiers=classifiers, entry_points={}, package_data={}, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dd23e46 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- + +'''Shared fixtures for the cell2cell test suite. + +Fixtures are built on the toy datasets in `cell2cell.datasets` whenever possible, so +the tests are deterministic and do not need any download. Test-only scaffolding that +is not useful enough to be part of the public API (a small gene-ontology graph, a tiny +GMT file) is defined here instead. +''' + +import matplotlib + +# Must be set before anything imports pyplot, otherwise the tests try to open windows. +matplotlib.use('Agg') + +import networkx as nx +import numpy as np +import pandas as pd +import pytest +from matplotlib import pyplot as plt + +import cell2cell as c2c + + +# --------------------------------------------------------------------------------- +# Global hygiene +# --------------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def close_figures(): + '''Closes every figure after each test, so plotting tests do not leak state.''' + yield + plt.close('all') + + +# --------------------------------------------------------------------------------- +# Existing toy datasets +# --------------------------------------------------------------------------------- + +@pytest.fixture +def toy_rnaseq(): + '''Toy bulk RNA-seq dataset. Genes (Protein-A..F) x cells (C1..C5).''' + return c2c.datasets.generate_toy_rnaseq() + + +@pytest.fixture +def toy_ppi(): + '''Toy list of protein-protein interactions. Columns A, B, score.''' + return c2c.datasets.generate_toy_ppi() + + +@pytest.fixture +def toy_ppi_complex(): + '''Toy list of PPIs including multimeric complexes, separated by "&".''' + return c2c.datasets.generate_toy_ppi(prot_complex=True) + + +@pytest.fixture +def toy_metadata(): + '''Toy metadata for cells C1..C5. Columns #SampleID, Groups.''' + return c2c.datasets.generate_toy_metadata() + + +@pytest.fixture +def toy_distance(): + '''Toy square cell-cell distance matrix for cells C1..C5.''' + return c2c.datasets.generate_toy_distance() + + +# --------------------------------------------------------------------------------- +# Toy datasets added in v0.9.0 +# --------------------------------------------------------------------------------- + +@pytest.fixture +def toy_contexts(): + '''Dictionary with a toy RNA-seq dataset for each of 4 contexts.''' + return c2c.datasets.generate_toy_contexts() + + +@pytest.fixture +def toy_single_cells(): + '''Tuple (rnaseq, metadata) for a toy single-cell dataset. 3 cell types.''' + return c2c.datasets.generate_toy_single_cells() + + +@pytest.fixture +def toy_coordinates(): + '''Toy spatial coordinates. Columns X, Y, celltype.''' + return c2c.datasets.generate_toy_coordinates() + + +@pytest.fixture +def toy_spatial_adata(): + '''Toy AnnData with coordinates in obsm["spatial"] on a 15x15 lattice.''' + return c2c.datasets.generate_toy_spatial_adata() + + +@pytest.fixture +def toy_liana(): + '''Toy LIANA-like output in long format. 3 contexts x 3 cell types.''' + return c2c.datasets.generate_toy_liana_output() + + +# --------------------------------------------------------------------------------- +# Derived objects used across several test modules +# --------------------------------------------------------------------------------- + +@pytest.fixture +def bulk_interactions(toy_rnaseq, toy_ppi, toy_metadata): + '''A BulkInteractions object with CCI and communication scores computed.''' + interactions = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, + ppi_data=toy_ppi, + metadata=toy_metadata, + interaction_columns=('A', 'B'), + communication_score='expression_product', + cci_score='bray_curtis', + cci_type='undirected', + complex_sep=None, + verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + interactions.compute_pairwise_communication_scores(verbose=False) + return interactions + + +@pytest.fixture +def analysis_setup(): + '''Analysis parameters expected by `initialize_interaction_space`.''' + return {'communication_score': 'expression_product', + 'cci_score': 'bray_curtis', + 'cci_type': 'undirected', + 'ccc_type': 'undirected'} + + +@pytest.fixture +def cutoff_setup(): + '''Cutoff parameters expected by `initialize_interaction_space`.''' + return {'type': 'constant_value', 'parameter': 10} + + +@pytest.fixture +def interaction_space(toy_rnaseq, toy_ppi, analysis_setup, cutoff_setup): + '''An InteractionSpace with the CCI and communication matrices computed.''' + space = c2c.analysis.initialize_interaction_space(rnaseq_data=toy_rnaseq, + ppi_data=toy_ppi, + cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, + complex_sep=None, + verbose=False) + space.compute_pairwise_cci_scores(verbose=False) + space.compute_pairwise_communication_scores(verbose=False) + return space + + +@pytest.fixture +def toy_cells(toy_rnaseq): + '''Dictionary of Cell objects, one per cell in the toy RNA-seq dataset.''' + return c2c.core.get_cells_from_rnaseq(toy_rnaseq, verbose=False) + + +@pytest.fixture +def interaction_tensor(toy_contexts, toy_ppi): + '''A 4D InteractionTensor built from the toy contexts. Not yet factorized.''' + return c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi, + context_names=list(toy_contexts.keys()), + how='inner', + complex_sep=None, + communication_score='expression_product', + verbose=False) + + +@pytest.fixture +def factorized_tensor(interaction_tensor): + '''An InteractionTensor already decomposed with rank=3 and a fixed seed.''' + interaction_tensor.compute_tensor_factorization(rank=3, random_state=0) + return interaction_tensor + + +@pytest.fixture +def prebuilt_tensor(): + '''A small deterministic PreBuiltTensor with 4 dimensions. + + Sender and receiver cells are deliberately NOT in alphabetical order, so the + tests can detect functions that assume a sorted tensor. + ''' + cells = ['C3', 'C1', 'C2'] + lr_pairs = ['Protein-A^Protein-B', 'Protein-B^Protein-C', 'Protein-C^Protein-A'] + contexts = ['Context-1', 'Context-2'] + shape = (len(contexts), len(lr_pairs), len(cells), len(cells)) + # Deterministic, strictly positive values + tensor = (np.arange(np.prod(shape), dtype=float).reshape(shape) + 1.) / np.prod(shape) + return c2c.tensor.PreBuiltTensor(tensor=tensor, + order_names=[contexts, lr_pairs, cells, cells], + order_labels=['Contexts', 'Ligand-Receptor Pairs', + 'Sender Cells', 'Receiver Cells']) + + +@pytest.fixture +def factorized_prebuilt_tensor(prebuilt_tensor): + '''The unsorted PreBuiltTensor, decomposed with rank=2 and a fixed seed.''' + prebuilt_tensor.compute_tensor_factorization(rank=2, random_state=0) + return prebuilt_tensor + + +# --------------------------------------------------------------------------------- +# Test-only scaffolding (not part of the public API) +# --------------------------------------------------------------------------------- + +@pytest.fixture +def read_only_frame(): + '''Builds a dataframe whose `.values` array cannot be written to. + + pandas >= 3.0 enforces copy-on-write, so `DataFrame.values` always returns a + read-only array and any function mutating it in place raises "underlying array is + read-only". Wrapping an array that is already read-only reproduces that on older + pandas too, so the tests using this fixture guard the behaviour on every version. + ''' + def build(values, labels=None): + array = np.array(values, dtype=float) + array.setflags(write=False) + if labels is None: + return pd.DataFrame(array) + return pd.DataFrame(array, index=labels, columns=labels) + return build + +@pytest.fixture +def go_terms_graph(): + '''A small gene-ontology hierarchy as a networkx.DiGraph. + + Edges point from the children to their parents, as in the ontologies parsed by + `cell2cell.external.goenrich.ontology`. 'GO:0000001' is the root, with two + children and one grandchild. + ''' + graph = nx.DiGraph() + edges = [('GO:0000002', 'GO:0000001'), + ('GO:0000003', 'GO:0000001'), + ('GO:0000004', 'GO:0000002')] + graph.add_edges_from(edges) + names = {'GO:0000001': 'toy root term', + 'GO:0000002': 'toy cell adhesion', + 'GO:0000003': 'toy extracellular space', + 'GO:0000004': 'toy cell junction'} + for node, name in names.items(): + graph.add_node(node, name=name) + return graph + + +@pytest.fixture +def go_annotations(): + '''GO annotations for the toy genes, as returned by `goenrich.goa`.''' + records = [('Protein-A', 'GO:0000002'), + ('Protein-B', 'GO:0000003'), + ('Protein-C', 'GO:0000004'), + ('Protein-D', 'GO:0000001'), + ('Protein-E', 'GO:0000003'), + ('Protein-F', 'GO:0000004')] + return pd.DataFrame(records, columns=['db_object_symbol', 'go_id']) + + +@pytest.fixture +def tiny_gmt(tmp_path): + '''Path to a minimal GMT file, to test the offline path of `load_gmt`. + + The second field of a GMT line is a description. `load_gmt` discards it only when + it looks like a URL, which is the case for the MSigDB files it is written for, so + the fixture uses URLs to mirror those files. + ''' + path = tmp_path / 'toy-pathways.gmt' + lines = ['TOYDB_PATHWAY_ONE\thttp://toydb.org/one\tProtein-A\tProtein-B\tProtein-C', + 'TOYDB_PATHWAY_TWO\thttp://toydb.org/two\tProtein-B\tProtein-E', + 'TOYDB_PATHWAY_THREE\thttp://toydb.org/three\tProtein-F'] + path.write_text('\n'.join(lines) + '\n') + return str(path) diff --git a/tests/test_analysis_genetic_algorithm.py b/tests/test_analysis_genetic_algorithm.py new file mode 100644 index 0000000..37f2ce0 --- /dev/null +++ b/tests/test_analysis_genetic_algorithm.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.analysis.genetic_algorithm''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.analysis import genetic_algorithm as ga +from cell2cell.core.interaction_space import InteractionSpace +from cell2cell.preprocessing.ppi import bidirectional_ppi_for_cci, remove_ppi_bidirectionality + +pygad = pytest.importorskip('pygad') + + +@pytest.fixture +def ga_inputs(): + '''Expression, LR pairs and a reference distance matrix over the same cells.''' + n_cells = 10 + genes = ['G{}'.format(i) for i in range(150)] + rnaseq = c2c.datasets.generate_random_rnaseq(size=n_cells, row_names=genes, + random_state=0, verbose=False) + # Distinct ligand and receptor pools, so the pairs are not all self-interactions + ppi = c2c.datasets.generate_random_ppi(max_size=40, interactors_A=genes[:75], + interactors_B=genes[75:], + random_state=0, verbose=False) + rng = np.random.default_rng(0) + coords = rng.random((n_cells, 2)) * 100 + from sklearn.metrics.pairwise import euclidean_distances + reference = pd.DataFrame(euclidean_distances(coords, coords), + index=rnaseq.columns, columns=rnaseq.columns) + return rnaseq, ppi, reference + + +@pytest.fixture +def setups(): + analysis_setup = {'communication_score': 'expression_thresholding', + 'cci_score': 'bray_curtis', + 'cci_type': 'undirected'} + cutoff_setup = {'type': 'constant_value', 'parameter': 10} + return analysis_setup, cutoff_setup + + +# --------------------------------------------------------------------------------- +# PreparedCCIScorer -- must reproduce InteractionSpace exactly +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('communication_score,cci_score', [ + ('expression_thresholding', 'bray_curtis'), + ('expression_thresholding', 'jaccard'), + ('expression_thresholding', 'count'), + ('expression_product', 'icellnet'), + ('expression_product', 'bray_curtis'), +]) +def test_prepared_scorer_matches_interaction_space(ga_inputs, communication_score, cci_score): + '''The whole point of the vectorized scorer is that it changes nothing.''' + rnaseq, ppi, _ = ga_inputs + bi_ppi = bidirectional_ppi_for_cci(ppi, verbose=False) + space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi, + gene_cutoffs={'type': 'constant_value', 'parameter': 10}, + communication_score=communication_score, + cci_score=cci_score, cci_type='undirected', verbose=False) + scorer = ga.PreparedCCIScorer(space, cci_score=cci_score) + source = ga._bidirectional_index(ppi, verbose=False) + + rng = np.random.default_rng(1) + for _ in range(3): + weights = rng.integers(0, 2, size=len(ppi)).astype(float)[source] + + space.ppi_data['score'] = weights + space.compute_pairwise_cci_scores(use_ppi_score=True, verbose=False) + expected_cci = space.interaction_elements['cci_matrix'].values.astype(float) + expected_distance = space.distance_matrix.values.astype(float) + + got_cci = scorer.score_batch(weights[None, :])[0] + got_distance = scorer.distance_batch(weights[None, :])[0] + + # Relative, because 'icellnet' is unbounded and BLAS sums in a different + # order than the reference loop + np.testing.assert_allclose(got_cci, expected_cci, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(got_distance, expected_distance, rtol=1e-12, atol=1e-12) + + +def test_prepared_scorer_batch_matches_individual(ga_inputs): + '''Scoring a population at once equals scoring its members one by one.''' + rnaseq, ppi, _ = ga_inputs + bi_ppi = bidirectional_ppi_for_cci(ppi, verbose=False) + space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi, + gene_cutoffs={'type': 'constant_value', 'parameter': 10}, + communication_score='expression_thresholding', + cci_score='bray_curtis', cci_type='undirected', verbose=False) + scorer = ga.PreparedCCIScorer(space) + + rng = np.random.default_rng(2) + W = rng.integers(0, 2, size=(6, scorer.n_ppi)).astype(float) + batched = scorer.score_batch(W) + one_by_one = np.stack([scorer.score_batch(w[None, :])[0] for w in W]) + np.testing.assert_allclose(batched, one_by_one, rtol=1e-12, atol=1e-12) + + +def test_prepared_scorer_unbatched_path_matches(ga_inputs): + '''With no memory for the outer products, the fallback must agree.''' + rnaseq, ppi, _ = ga_inputs + bi_ppi = bidirectional_ppi_for_cci(ppi, verbose=False) + space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi, + gene_cutoffs={'type': 'constant_value', 'parameter': 10}, + communication_score='expression_thresholding', + cci_score='bray_curtis', cci_type='undirected', verbose=False) + batched = ga.PreparedCCIScorer(space, max_memory_mb=1e9) + unbatched = ga.PreparedCCIScorer(space, max_memory_mb=0) + assert batched.batched and not unbatched.batched + + W = np.random.default_rng(3).integers(0, 2, size=(4, batched.n_ppi)).astype(float) + np.testing.assert_allclose(batched.score_batch(W), unbatched.score_batch(W), + rtol=1e-12, atol=1e-12) + + +def test_prepared_scorer_symmetric_for_undirected(ga_inputs): + rnaseq, ppi, _ = ga_inputs + bi_ppi = bidirectional_ppi_for_cci(ppi, verbose=False) + space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi, + gene_cutoffs={'type': 'constant_value', 'parameter': 10}, + communication_score='expression_thresholding', + cci_score='bray_curtis', cci_type='undirected', verbose=False) + scorer = ga.PreparedCCIScorer(space) + matrix = scorer.score(np.ones(scorer.n_ppi)) + assert list(matrix.index) == list(matrix.columns) == scorer.cell_names + np.testing.assert_allclose(matrix.values, matrix.values.T, rtol=1e-12, atol=1e-12) + + +def test_prepared_scorer_rejects_unsupported_score(ga_inputs): + rnaseq, ppi, _ = ga_inputs + bi_ppi = bidirectional_ppi_for_cci(ppi, verbose=False) + space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi, + gene_cutoffs={'type': 'constant_value', 'parameter': 10}, + communication_score='expression_thresholding', + cci_score='bray_curtis', cci_type='undirected', verbose=False) + with pytest.raises(NotImplementedError): + ga.PreparedCCIScorer(space, cci_score='cosine') + + +def test_prepared_scorer_count_rejects_non_binary_weights(ga_inputs): + '''`count` counts non-zero products, so it is only linear in binary weights.''' + rnaseq, ppi, _ = ga_inputs + bi_ppi = bidirectional_ppi_for_cci(ppi, verbose=False) + space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi, + gene_cutoffs={'type': 'constant_value', 'parameter': 10}, + communication_score='expression_thresholding', + cci_score='count', cci_type='undirected', verbose=False) + scorer = ga.PreparedCCIScorer(space) + with pytest.raises(ValueError): + scorer.score_batch(np.full((1, scorer.n_ppi), 0.5)) + + +# --------------------------------------------------------------------------------- +# _bidirectional_index +# --------------------------------------------------------------------------------- + +def test_bidirectional_index_recovers_the_weights(toy_ppi): + '''Expanding a per-pair vector must equal bidirectionalizing the table itself.''' + pool = remove_ppi_bidirectionality(toy_ppi, interaction_columns=('A', 'B'), + verbose=False).reset_index(drop=True) + source = ga._bidirectional_index(pool, verbose=False) + theta = np.arange(len(pool), dtype=float) + 1.0 + + expected = bidirectional_ppi_for_cci(pool.assign(score=theta), verbose=False) + np.testing.assert_array_equal(theta[source], expected['score'].values) + + +def test_bidirectional_index_length_is_independent_of_the_weights(toy_ppi): + '''On a deduplicated pool the bidirectional table has a fixed number of rows.''' + pool = remove_ppi_bidirectionality(toy_ppi, interaction_columns=('A', 'B'), + verbose=False).reset_index(drop=True) + source = ga._bidirectional_index(pool, verbose=False) + n_self = int((pool['A'] == pool['B']).sum()) + assert len(source) == 2 * len(pool) - n_self + + rng = np.random.default_rng(0) + for _ in range(5): + theta = rng.integers(0, 2, size=len(pool)).astype(float) + bi = bidirectional_ppi_for_cci(pool.assign(score=theta), verbose=False) + assert len(bi) == len(source) + + +def test_bidirectional_index_rejects_reciprocal_pairs(toy_ppi): + '''`toy_ppi` holds A-B and B-A, which makes the mapping weight-dependent.''' + assert len(toy_ppi) > len(remove_ppi_bidirectionality( + toy_ppi, interaction_columns=('A', 'B'), verbose=False)) + with pytest.raises(ValueError, match='depends on the weights'): + ga._bidirectional_index(toy_ppi, verbose=False) + + +def test_optimize_lr_pairs_deduplicates_by_default(ga_inputs, setups): + '''With deduplicate=True a pool holding both directions is accepted.''' + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + reciprocal = pd.concat([ppi, ppi.rename(columns={'A': 'B', 'B': 'A'})[['A', 'B']]], + ignore_index=True) + results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, ppi_data=reciprocal, + reference_distances=reference, + cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, + population_size=8, generations=3, runs=1, + random_state=5) + assert 0.0 <= results['best_obj_fn'] <= 1.0 + + +# --------------------------------------------------------------------------------- +# optimize_lr_pairs +# --------------------------------------------------------------------------------- + +@pytest.mark.slow +def test_optimize_lr_pairs_runs_and_selects(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, ppi_data=ppi, + reference_distances=reference, + cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, + population_size=20, generations=8, runs=2, + random_state=888) + assert set(['run1', 'run2']).issubset(results.keys()) + for key in ('run1', 'run2'): + assert 0.0 <= results[key]['obj_fn'] <= 1.0 + assert len(results[key]['ppi_data']) == len(ppi) + assert set(results[key]['ppi_data']).issubset({0, 1}) + assert 0.0 <= results[key]['drop_fraction'] <= 1.0 + assert results['best_obj_fn'] == max(results[k]['obj_fn'] for k in ('run1', 'run2')) + assert len(results['best_ppi_data']) == results[results['best_run']]['n_selected'] + + +@pytest.mark.slow +def test_optimize_lr_pairs_fast_equals_reference(ga_inputs, setups): + '''The vectorized objective must not change which pairs get selected.''' + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + kwargs = dict(rnaseq_data=rnaseq, ppi_data=ppi, reference_distances=reference, + cutoff_setup=cutoff_setup, analysis_setup=analysis_setup, + population_size=16, generations=6, runs=2, random_state=42) + + fast = c2c.analysis.optimize_lr_pairs(fast=True, **kwargs) + slow = c2c.analysis.optimize_lr_pairs(fast=False, **kwargs) + + for key in ('run1', 'run2'): + assert np.isclose(fast[key]['obj_fn'], slow[key]['obj_fn'], rtol=1e-9, atol=1e-9) + assert fast[key]['ppi_data'] == slow[key]['ppi_data'] + + +@pytest.mark.slow +def test_optimize_lr_pairs_is_reproducible(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + kwargs = dict(rnaseq_data=rnaseq, ppi_data=ppi, reference_distances=reference, + cutoff_setup=cutoff_setup, analysis_setup=analysis_setup, + population_size=16, generations=6, runs=1, random_state=7) + first = c2c.analysis.optimize_lr_pairs(**kwargs) + second = c2c.analysis.optimize_lr_pairs(**kwargs) + assert first['run1']['ppi_data'] == second['run1']['ppi_data'] + assert np.isclose(first['run1']['obj_fn'], second['run1']['obj_fn']) + + +@pytest.mark.slow +def test_optimize_lr_pairs_shrinks_the_pair_set(ga_inputs, setups): + '''Each run searches only among the pairs the previous one kept.''' + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, ppi_data=ppi, + reference_distances=reference, + cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, + population_size=16, generations=6, runs=3, + random_state=13) + counts = [results['run{}'.format(i)]['n_selected'] for i in (1, 2, 3)] + assert counts[0] >= counts[1] >= counts[2] + + +def test_optimize_lr_pairs_rejects_directed(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + analysis_setup = dict(analysis_setup, cci_type='directed') + with pytest.raises(NotImplementedError): + c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, ppi_data=ppi, + reference_distances=reference, + cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, + population_size=8, generations=2, runs=1) + + +def test_optimize_lr_pairs_rejects_asymmetric_reference(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + asymmetric = reference.copy() + asymmetric.iloc[0, 1] = asymmetric.iloc[1, 0] + 1.0 + with pytest.raises(ValueError): + c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq, ppi_data=ppi, + reference_distances=asymmetric, + cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, + population_size=8, generations=2, runs=1) + + +# --------------------------------------------------------------------------------- +# Integrating independent executions +# --------------------------------------------------------------------------------- + +def test_lr_selection_frequency(): + masks = np.array([[1, 0, 1], [1, 0, 0], [1, 1, 0], [0, 0, 0]]) + np.testing.assert_allclose(ga.lr_selection_frequency(masks), [0.75, 0.25, 0.25]) + + +def test_lr_cooccurrence_is_the_jaccard_of_selection_patterns(): + # pair 0 and 1 are always chosen together; pair 2 never with either + masks = np.array([[1, 1, 0], [1, 1, 0], [0, 0, 1]]) + co = ga.lr_cooccurrence(masks, labels=['a', 'b', 'c']) + assert np.isclose(co.loc['a', 'b'], 1.0) + assert np.isclose(co.loc['a', 'c'], 0.0) + assert np.isclose(co.loc['a', 'a'], 1.0) + np.testing.assert_allclose(co.values, co.values.T) + + +def test_lr_cooccurrence_matches_the_reference_loop(): + '''Pinned against the double loop of the published notebook.''' + rng = np.random.default_rng(0) + masks = rng.integers(0, 2, size=(12, 20)) + masks[:, 3] = 0 # a pair no run ever selected + labels = ['lr{}'.format(i) for i in range(20)] + df = pd.DataFrame(masks, columns=labels).astype(int) + + expected = pd.DataFrame(np.zeros((len(labels), len(labels))), + columns=labels, index=labels) + for i, lr in enumerate(labels): + v1 = df[lr] + for lr2 in labels[i:]: + v2 = df[lr2] + union = sum(v1.values | v2.values) + val = 0.0 if union == 0 else sum(v1.values & v2.values) / union + expected.at[lr, lr2] = val + expected.at[lr2, lr] = val + + got = ga.lr_cooccurrence(masks, labels=labels) + np.testing.assert_allclose(got.values, expected.values, rtol=1e-12, atol=1e-12) + + +def test_lr_cooccurrence_zero_for_never_selected_pairs(): + masks = np.array([[1, 0], [1, 0]]) + co = ga.lr_cooccurrence(masks, labels=['chosen', 'never']) + assert (co.loc['never'] == 0).all() + + +def test_consensus_from_cooccurrence_picks_the_co_selected_group(): + '''Two blocks: a tight one of 3 pairs and a loose one of 5.''' + tight, loose = 3, 5 + n = tight + loose + rng = np.random.default_rng(0) + masks = np.zeros((20, n), dtype=int) + masks[:, :tight] = 1 # always together + masks[:, tight:] = rng.integers(0, 2, size=(20, loose)) # independently + labels = ['t{}'.format(i) for i in range(tight)] + ['l{}'.format(i) for i in range(loose)] + + co = ga.lr_cooccurrence(masks, labels=labels) + selected, clusters, scores = ga.consensus_from_cooccurrence(co, n_clusters=2) + assert set(selected) == {'t0', 't1', 't2'} + assert max(scores, key=lambda k: scores[k]) in clusters + # the chosen cluster is the one whose members co-occur most + chosen_score = max(scores.values()) + assert chosen_score == scores[[k for k, v in clusters.items() if set(v) == set(selected)][0]] + + +def test_consensus_from_cooccurrence_smallest_reproduces_the_notebook(): + masks = np.zeros((10, 8), dtype=int) + masks[:, :2] = 1 + masks[:, 2:] = np.random.default_rng(1).integers(0, 2, size=(10, 6)) + labels = ['lr{}'.format(i) for i in range(8)] + co = ga.lr_cooccurrence(masks, labels=labels) + smallest, clusters, _ = ga.consensus_from_cooccurrence(co, select='smallest') + assert len(smallest) == min(len(v) for v in clusters.values()) + + +def test_consensus_from_cooccurrence_rejects_bad_select(): + co = ga.lr_cooccurrence(np.ones((3, 4), dtype=int)) + with pytest.raises(ValueError): + ga.consensus_from_cooccurrence(co, select='not_an_option') + + +def test_consensus_from_cooccurrence_needs_enough_selected_pairs(): + masks = np.zeros((5, 6), dtype=int) + masks[:, 0] = 1 # only one pair ever selected + with pytest.raises(ValueError, match='fewer than'): + ga.consensus_from_cooccurrence(ga.lr_cooccurrence(masks), n_clusters=2) + + +def test_consensus_from_frequency(): + frequency = np.array([0.1, 0.2, 0.9, 1.0, 0.05]) + mask, threshold = ga.consensus_from_frequency(frequency, percentile=60) + assert mask.sum() == 2 and mask[2] and mask[3] + assert np.isclose(threshold, np.percentile(frequency, 60)) + + +@pytest.mark.slow +def test_optimize_lr_pairs_multiple_executions(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + results = c2c.analysis.optimize_lr_pairs( + rnaseq_data=rnaseq, ppi_data=ppi, reference_distances=reference, + cutoff_setup=cutoff_setup, analysis_setup=analysis_setup, + executions=4, population_size=16, generations=5, runs=1, random_state=11) + + assert len(results['executions']) == 4 + assert results['selection_masks'].shape == (4, len(ppi)) + assert set(np.unique(results['selection_masks'])).issubset({0, 1}) + assert len(results['selection_frequency']) == len(ppi) + assert results['cooccurrence'].shape == (len(ppi), len(ppi)) + assert results['consensus_ppi_data'] is not None + assert len(results['consensus_ppi_data']) <= len(ppi) + # the reported best is the best of the executions + assert np.isclose(results['best_obj_fn'], + max(e['best_obj_fn'] for e in results['executions'].values())) + + +@pytest.mark.slow +def test_optimize_lr_pairs_frequency_consensus(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + results = c2c.analysis.optimize_lr_pairs( + rnaseq_data=rnaseq, ppi_data=ppi, reference_distances=reference, + cutoff_setup=cutoff_setup, analysis_setup=analysis_setup, + executions=4, consensus_method='frequency', frequency_percentile=75, + population_size=16, generations=5, runs=1, random_state=11) + assert 'consensus_threshold' in results + frequency = results['selection_frequency']['frequency'].values + assert len(results['consensus_ppi_data']) == int((frequency > results['consensus_threshold']).sum()) + + +@pytest.mark.slow +def test_optimize_lr_pairs_executions_are_reproducible(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + kwargs = dict(rnaseq_data=rnaseq, ppi_data=ppi, reference_distances=reference, + cutoff_setup=cutoff_setup, analysis_setup=analysis_setup, + executions=3, population_size=16, generations=5, runs=1, random_state=5) + first = c2c.analysis.optimize_lr_pairs(**kwargs) + second = c2c.analysis.optimize_lr_pairs(**kwargs) + np.testing.assert_array_equal(first['selection_masks'], second['selection_masks']) + + +def test_optimize_lr_pairs_rejects_bad_consensus_method(ga_inputs, setups): + rnaseq, ppi, reference = ga_inputs + analysis_setup, cutoff_setup = setups + with pytest.raises(ValueError): + c2c.analysis.optimize_lr_pairs( + rnaseq_data=rnaseq, ppi_data=ppi, reference_distances=reference, + cutoff_setup=cutoff_setup, analysis_setup=analysis_setup, + executions=2, consensus_method='nope', population_size=8, + generations=2, runs=1, random_state=1) diff --git a/tests/test_analysis_pipelines.py b/tests/test_analysis_pipelines.py new file mode 100644 index 0000000..c65eebd --- /dev/null +++ b/tests/test_analysis_pipelines.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.analysis.cell2cell_pipelines and tensor_pipelines''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c + + +# --------------------------------------------------------------------------------- +# BulkInteractions +# --------------------------------------------------------------------------------- + +def test_bulk_interactions_cci_matrix(bulk_interactions, toy_rnaseq): + cci = bulk_interactions.interaction_space.interaction_elements['cci_matrix'] + assert set(cci.index) == set(toy_rnaseq.columns) + assert np.allclose(cci.values, cci.values.T) + + +def test_bulk_interactions_cells_are_naturally_ordered(toy_ppi): + renamed = c2c.datasets.generate_toy_rnaseq().rename( + columns={'C3': 'C10', 'C4': 'C20', 'C5': 'C3'}) + interactions = c2c.analysis.BulkInteractions(rnaseq_data=renamed, ppi_data=toy_ppi, + complex_sep=None, verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + assert list(cci.columns) == ['C1', 'C2', 'C3', 'C10', 'C20'] + + +def test_bulk_interactions_communication_matrix(bulk_interactions): + communication = bulk_interactions.interaction_space.interaction_elements['communication_matrix'] + assert communication.shape[1] > 0 + assert np.isfinite(communication.values.astype(float)).all() + + +@pytest.mark.parametrize('cci_score', ['bray_curtis', 'jaccard', 'count', 'icellnet']) +def test_bulk_interactions_cci_scores(toy_rnaseq, toy_ppi, cci_score): + interactions = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + cci_score=cci_score, complex_sep=None, + verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + assert cci.shape == (toy_rnaseq.shape[1], toy_rnaseq.shape[1]) + distance = interactions.interaction_space.distance_matrix + assert (distance.values >= 0).all() + + +@pytest.mark.parametrize('communication_score', ['expression_thresholding', + 'expression_product', + 'expression_mean', + 'expression_gmean']) +def test_bulk_interactions_communication_scores(toy_rnaseq, toy_ppi, + communication_score): + interactions = c2c.analysis.BulkInteractions( + rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + communication_score=communication_score, complex_sep=None, verbose=False) + interactions.compute_pairwise_communication_scores(verbose=False) + matrix = interactions.interaction_space.interaction_elements['communication_matrix'] + assert matrix.shape[1] > 0 + + +@pytest.mark.parametrize('cci_type', ['directed', 'undirected']) +def test_bulk_interactions_cci_types(toy_rnaseq, toy_ppi, cci_type): + interactions = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + cci_type=cci_type, complex_sep=None, + verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + if cci_type == 'undirected': + assert np.allclose(cci.values, cci.values.T) + + +def test_bulk_interactions_with_complexes(toy_rnaseq, toy_ppi_complex): + interactions = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, + ppi_data=toy_ppi_complex, + complex_sep='&', verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + assert cci.shape[0] == toy_rnaseq.shape[1] + + +def test_bulk_interactions_is_reproducible(toy_rnaseq, toy_ppi, toy_metadata): + def build(): + interactions = c2c.analysis.BulkInteractions( + rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, metadata=toy_metadata, + complex_sep=None, verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + return interactions.interaction_space.interaction_elements['cci_matrix'] + + pd.testing.assert_frame_equal(build(), build()) + + +def test_bulk_interactions_subset_of_cells(toy_rnaseq, toy_ppi): + '''BulkInteractions has no `excluded_cells`; cells are dropped upstream.''' + interactions = c2c.analysis.BulkInteractions( + rnaseq_data=toy_rnaseq.drop(columns=['C1']), ppi_data=toy_ppi, + complex_sep=None, verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + assert 'C1' not in cci.columns + assert cci.shape[0] == toy_rnaseq.shape[1] - 1 + + +# --------------------------------------------------------------------------------- +# SingleCellInteractions +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('aggregation_method', ['average', 'nn_cell_fraction', 'trimean']) +def test_single_cell_interactions_aggregation(toy_single_cells, toy_ppi, + aggregation_method): + rnaseq, metadata = toy_single_cells + interactions = c2c.analysis.SingleCellInteractions( + rnaseq_data=rnaseq, ppi_data=toy_ppi, metadata=metadata, + barcode_col='barcodes', celltype_col='cell_types', + aggregation_method=aggregation_method, complex_sep=None, verbose=False) + assert list(interactions.aggregated_expression.columns) == ['CT-1', 'CT-2', 'CT-3'] + assert list(interactions.aggregated_expression.index) == list(rnaseq.index) + + +def test_single_cell_interactions_computes_scores(toy_single_cells, toy_ppi): + rnaseq, metadata = toy_single_cells + interactions = c2c.analysis.SingleCellInteractions( + rnaseq_data=rnaseq, ppi_data=toy_ppi, metadata=metadata, + barcode_col='barcodes', celltype_col='cell_types', complex_sep=None, + verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + assert set(cci.index) == {'CT-1', 'CT-2', 'CT-3'} + + +def test_single_cell_interactions_does_not_modify_the_input(toy_single_cells, toy_ppi): + rnaseq, metadata = toy_single_cells + before = rnaseq.copy() + c2c.analysis.SingleCellInteractions( + rnaseq_data=rnaseq, ppi_data=toy_ppi, metadata=metadata, + barcode_col='barcodes', celltype_col='cell_types', complex_sep=None, + verbose=False) + pd.testing.assert_frame_equal(rnaseq, before) + + +@pytest.mark.slow +def test_single_cell_permutation(toy_single_cells, toy_ppi): + rnaseq, metadata = toy_single_cells + interactions = c2c.analysis.SingleCellInteractions( + rnaseq_data=rnaseq, ppi_data=toy_ppi, metadata=metadata, + barcode_col='barcodes', celltype_col='cell_types', complex_sep=None, + verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + interactions.permute_cell_labels(evaluation='interactions', permutations=3, + random_state=0, verbose=False) + pvalues = interactions.cci_permutation_pvalues + assert ((pvalues.values >= 0) & (pvalues.values <= 1)).all() + + +# --------------------------------------------------------------------------------- +# initialize_interaction_space +# --------------------------------------------------------------------------------- + +def test_initialize_interaction_space_returns_a_space(interaction_space): + assert hasattr(interaction_space, 'interaction_elements') + assert hasattr(interaction_space, 'distance_matrix') + + +# --------------------------------------------------------------------------------- +# run_tensor_cell2cell_pipeline +# --------------------------------------------------------------------------------- + +@pytest.mark.slow +def test_run_tensor_cell2cell_pipeline(interaction_tensor): + metadata = c2c.tensor.generate_tensor_metadata( + interaction_tensor=interaction_tensor, + metadata_dicts=[None, None, None, None], + fill_with_order_elements=True) + result = c2c.analysis.run_tensor_cell2cell_pipeline( + interaction_tensor, tensor_metadata=metadata, rank=2, + tf_optimization='regular', random_state=0, output_folder=None, + output_fig=False, elbow_metric='error', smooth_elbow=False, upper_rank=3) + assert result.rank == 2 + assert len(result.factors) == 4 + + +@pytest.mark.slow +def test_run_tensor_cell2cell_pipeline_copies_when_asked(interaction_tensor): + metadata = c2c.tensor.generate_tensor_metadata( + interaction_tensor=interaction_tensor, + metadata_dicts=[None, None, None, None], + fill_with_order_elements=True) + result = c2c.analysis.run_tensor_cell2cell_pipeline( + interaction_tensor, tensor_metadata=metadata, rank=2, copy_tensor=True, + random_state=0, output_folder=None, output_fig=False) + assert result is not interaction_tensor + assert interaction_tensor.rank is None diff --git a/tests/test_analysis_tensor_downstream.py b/tests/test_analysis_tensor_downstream.py new file mode 100644 index 0000000..5923a06 --- /dev/null +++ b/tests/test_analysis_tensor_downstream.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.analysis.tensor_downstream''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.analysis import tensor_downstream +from cell2cell.analysis.tensor_downstream import (flatten_factor_ccc_networks, + get_factor_specific_ccc_networks, + get_lr_by_cell_pairs) + + +LABELS = dict(sender_label='Sender Cells', receiver_label='Receiver Cells') + + +# --------------------------------------------------------------------------------- +# get_joint_loadings +# --------------------------------------------------------------------------------- + +def test_get_joint_loadings_is_the_outer_product(factorized_tensor): + factors = factorized_tensor.factors + joint = tensor_downstream.get_joint_loadings(factors, dim1='Sender Cells', + dim2='Receiver Cells', factor='Factor 1') + expected = np.outer(factors['Sender Cells']['Factor 1'].values, + factors['Receiver Cells']['Factor 1'].values) + assert np.allclose(joint.values, expected) + assert list(joint.index) == list(factors['Sender Cells'].index) + assert list(joint.columns) == list(factors['Receiver Cells'].index) + + +def test_get_joint_loadings_sets_axis_names(factorized_tensor): + joint = tensor_downstream.get_joint_loadings(factorized_tensor, dim1='Sender Cells', + dim2='Ligand-Receptor Pairs', + factor='Factor 1') + assert joint.index.name == 'Sender Cells' + assert joint.columns.name == 'Ligand-Receptor Pairs' + + +def test_get_joint_loadings_accepts_a_tensor_or_a_dict(factorized_tensor): + from_tensor = tensor_downstream.get_joint_loadings(factorized_tensor, + dim1='Sender Cells', + dim2='Receiver Cells', + factor='Factor 1') + from_dict = tensor_downstream.get_joint_loadings(factorized_tensor.factors, + dim1='Sender Cells', + dim2='Receiver Cells', + factor='Factor 1') + pd.testing.assert_frame_equal(from_tensor, from_dict) + + +def test_get_joint_loadings_rejects_unknown_dimension(factorized_tensor): + with pytest.raises(AssertionError): + tensor_downstream.get_joint_loadings(factorized_tensor, dim1='Nope', + dim2='Receiver Cells', factor='Factor 1') + + +def test_get_joint_loadings_rejects_an_unfactorized_tensor(interaction_tensor): + with pytest.raises(ValueError): + tensor_downstream.get_joint_loadings(interaction_tensor, dim1='Sender Cells', + dim2='Receiver Cells', factor='Factor 1') + + +def test_get_joint_loadings_rejects_a_bad_type(): + with pytest.raises(ValueError): + tensor_downstream.get_joint_loadings('not-a-tensor', dim1='a', dim2='b', + factor='Factor 1') + + +# --------------------------------------------------------------------------------- +# get_factor_specific_ccc_networks +# --------------------------------------------------------------------------------- + +def test_get_factor_specific_ccc_networks_one_per_factor(factorized_tensor): + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + assert list(networks.keys()) == ['Factor 1', 'Factor 2', 'Factor 3'] + cells = list(factorized_tensor.order_names[2]) + for network in networks.values(): + assert list(network.index) == cells + assert list(network.columns) == cells + + +def test_networks_match_get_joint_loadings(factorized_tensor): + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + for factor, network in networks.items(): + expected = tensor_downstream.get_joint_loadings(factorized_tensor, + dim1='Sender Cells', + dim2='Receiver Cells', + factor=factor) + pd.testing.assert_frame_equal(network, expected) + + +def test_get_factor_specific_ccc_networks_rejects_unfactorized(interaction_tensor): + with pytest.raises(ValueError): + tensor_downstream.get_factor_specific_ccc_networks(interaction_tensor, **LABELS) + + +# --------------------------------------------------------------------------------- +# flatten_factor_ccc_networks +# --------------------------------------------------------------------------------- + +def test_flatten_shape_and_labels(factorized_tensor): + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + flat = tensor_downstream.flatten_factor_ccc_networks(networks) + n_cells = len(factorized_tensor.order_names[2]) + assert flat.shape == (n_cells ** 2, 3) + assert list(flat.columns) == list(networks.keys()) + assert all(' --> ' in label for label in flat.index) + + +def test_flatten_orderings_hold_the_same_values(factorized_tensor): + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + by_senders = tensor_downstream.flatten_factor_ccc_networks(networks, orderby='senders') + by_receivers = tensor_downstream.flatten_factor_ccc_networks(networks, + orderby='receivers') + assert set(by_senders.index) == set(by_receivers.index) + assert list(by_senders.index) != list(by_receivers.index) + for label in by_senders.index: + assert np.allclose(by_senders.loc[label].values, by_receivers.loc[label].values) + + +# --------------------------------------------------------------------------------- +# compute_gini_coefficients +# --------------------------------------------------------------------------------- + +def test_compute_gini_coefficients(factorized_tensor): + ginis = tensor_downstream.compute_gini_coefficients(factorized_tensor, **LABELS) + assert list(ginis.columns) == ['Factor', 'Gini'] + assert list(ginis['Factor']) == ['Factor 1', 'Factor 2', 'Factor 3'] + assert ginis['Gini'].between(0, 1).all() + + +def test_gini_matches_a_direct_computation(factorized_tensor): + from cell2cell.stats import gini_coefficient + ginis = tensor_downstream.compute_gini_coefficients(factorized_tensor, **LABELS) + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + for _, row in ginis.iterrows(): + expected = gini_coefficient(networks[row['Factor']].values.flatten()) + assert np.isclose(row['Gini'], expected) + + +# --------------------------------------------------------------------------------- +# get_lr_by_cell_pairs +# --------------------------------------------------------------------------------- + +@pytest.fixture +def downstream_kwargs(): + return dict(lr_label='Ligand-Receptor Pairs', sender_label='Sender Cells', + receiver_label='Receiver Cells') + + +def test_get_lr_by_cell_pairs_shape_and_axis_names(factorized_tensor, downstream_kwargs): + result = tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, **downstream_kwargs) + n_cells = len(factorized_tensor.order_names[2]) + assert result.shape == (len(factorized_tensor.order_names[1]), n_cells ** 2) + assert result.columns.name == 'Sender-Receiver Pair' + assert result.index.name == 'Ligand-Receptor Pair' + + +def test_get_lr_by_cell_pairs_single_factor(factorized_tensor, downstream_kwargs): + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + lr_loadings = factorized_tensor.factors['Ligand-Receptor Pairs'] + result = tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, + factor='Factor 2', + **downstream_kwargs) + for cell_pair in result.columns: + sender, receiver = cell_pair.split(' --> ') + for lr_pair in result.index: + expected = (networks['Factor 2'].loc[sender, receiver] * + lr_loadings.loc[lr_pair, 'Factor 2']) + assert np.isclose(result.loc[lr_pair, cell_pair], expected) + + +def test_get_lr_by_cell_pairs_thresholds_reduce_the_output(factorized_tensor, + downstream_kwargs): + full = tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, **downstream_kwargs) + filtered = tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, + cci_threshold=0.05, + lr_threshold=0.05, + **downstream_kwargs) + assert filtered.shape[0] <= full.shape[0] + assert filtered.shape[1] <= full.shape[1] + assert set(filtered.columns).issubset(set(full.columns)) + assert set(filtered.index).issubset(set(full.index)) + + +def test_get_lr_by_cell_pairs_thresholds_keep_values_aligned(factorized_tensor, + downstream_kwargs): + networks = tensor_downstream.get_factor_specific_ccc_networks(factorized_tensor, + **LABELS) + lr_loadings = factorized_tensor.factors['Ligand-Receptor Pairs'] + filtered = tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, + cci_threshold=0.02, + **downstream_kwargs) + for cell_pair in filtered.columns: + sender, receiver = cell_pair.split(' --> ') + for lr_pair in filtered.index: + expected = sum(networks[f].loc[sender, receiver] * lr_loadings.loc[lr_pair, f] + for f in networks) + assert np.isclose(filtered.loc[lr_pair, cell_pair], expected) + + +@pytest.mark.parametrize('order_cells_by', ['senders', 'receivers']) +def test_get_lr_by_cell_pairs_order_cells_by(factorized_tensor, downstream_kwargs, + order_cells_by): + result = tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, + order_cells_by=order_cells_by, + **downstream_kwargs) + n_cells = len(factorized_tensor.order_names[2]) + assert result.shape[1] == n_cells ** 2 + + +def test_get_lr_by_cell_pairs_rejects_unknown_labels(factorized_tensor): + with pytest.raises(AssertionError): + tensor_downstream.get_lr_by_cell_pairs(factorized_tensor, lr_label='Nope', + sender_label='Sender Cells', + receiver_label='Receiver Cells') + + +# --------------------------------------------------------------------------------- +# Cell-pair labels in flattened factor-specific networks +# +# `flatten_factor_ccc_networks` built the 'sender --> receiver' labels from the +# SORTED cell names while flattening the values in the tensor's own order. Every +# loading was assigned to the wrong cell pair when the tensor was not alphabetically +# sorted, and `get_lr_by_cell_pairs` inherited the mislabeling. +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('orderby', ['senders', 'receivers']) +def test_flatten_labels_match_values_on_unsorted_tensor(factorized_prebuilt_tensor, orderby): + '''The decisive regression test: 27/27 entries were mislabeled before the fix.''' + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + flat = flatten_factor_ccc_networks(networks, orderby=orderby) + + # The fixture is deliberately unsorted, otherwise this test cannot fail + cells = list(factorized_prebuilt_tensor.order_names[2]) + assert cells != sorted(cells) + + for factor, network in networks.items(): + for sender in network.index: + for receiver in network.columns: + label = '{} --> {}'.format(sender, receiver) + assert np.isclose(flat.loc[label, factor], network.loc[sender, receiver]) + + +def test_flatten_preserves_the_tensor_order(factorized_prebuilt_tensor): + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + flat = flatten_factor_ccc_networks(networks, orderby='senders') + cells = list(factorized_prebuilt_tensor.order_names[2]) + expected = ['{} --> {}'.format(s, r) for s in cells for r in cells] + assert list(flat.index) == expected + + +def test_flatten_groups_by_receiver_when_requested(factorized_prebuilt_tensor): + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + flat = flatten_factor_ccc_networks(networks, orderby='receivers') + cells = list(factorized_prebuilt_tensor.order_names[2]) + expected = ['{} --> {}'.format(s, r) for r in cells for s in cells] + assert list(flat.index) == expected + + +def test_flatten_realigns_networks_with_different_element_order(factorized_prebuilt_tensor): + '''Hand-assembled networks may not share an element order; values must still align.''' + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + factors = list(networks.keys()) + shuffled = {factors[0]: networks[factors[0]], + factors[1]: networks[factors[1]].reindex(index=['C1', 'C2', 'C3'], + columns=['C2', 'C3', 'C1'])} + flat = flatten_factor_ccc_networks(shuffled) + for factor in factors: + for sender in ['C1', 'C2', 'C3']: + for receiver in ['C1', 'C2', 'C3']: + label = '{} --> {}'.format(sender, receiver) + assert np.isclose(flat.loc[label, factor], + networks[factor].loc[sender, receiver]) + + +def test_flatten_keeps_only_common_elements(factorized_prebuilt_tensor): + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + factors = list(networks.keys()) + subset = {factors[0]: networks[factors[0]], + factors[1]: networks[factors[1]].drop(index='C1', columns='C1')} + flat = flatten_factor_ccc_networks(subset) + assert len(flat) == 4 # 2 senders x 2 receivers + assert not any('C1' in label for label in flat.index) + + +def test_flatten_accepts_a_single_factor(factorized_prebuilt_tensor): + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + factor = list(networks.keys())[0] + flat = flatten_factor_ccc_networks({factor: networks[factor]}) + assert list(flat.columns) == [factor] + for sender in networks[factor].index: + for receiver in networks[factor].columns: + label = '{} --> {}'.format(sender, receiver) + assert np.isclose(flat.loc[label, factor], + networks[factor].loc[sender, receiver]) + + +def test_flatten_rejects_an_invalid_orderby(factorized_prebuilt_tensor): + networks = get_factor_specific_ccc_networks(factorized_prebuilt_tensor) + with pytest.raises(ValueError): + flatten_factor_ccc_networks(networks, orderby='not-an-option') + + +def test_get_lr_by_cell_pairs_labels_match_values(factorized_prebuilt_tensor): + '''get_lr_by_cell_pairs inherited the mislabeling from the flattening.''' + tensor = factorized_prebuilt_tensor + networks = get_factor_specific_ccc_networks(tensor) + lr_loadings = tensor.factors['Ligand-Receptor Pairs'] + + result = get_lr_by_cell_pairs(tensor, + lr_label='Ligand-Receptor Pairs', + sender_label='Sender Cells', + receiver_label='Receiver Cells') + + for cell_pair in result.columns: + sender, receiver = cell_pair.split(' --> ') + for lr_pair in result.index: + expected = sum(networks[f].loc[sender, receiver] * lr_loadings.loc[lr_pair, f] + for f in networks) + assert np.isclose(result.loc[lr_pair, cell_pair], expected) + + +# --------------------------------------------------------------------------------- +# Natural ordering of factor names +# +# The factor names were sorted lexicographically, so decompositions with 10 or more +# factors were returned as Factor 1, Factor 10, Factor 11, Factor 2, ... +# --------------------------------------------------------------------------------- + +def test_get_lr_by_cell_pairs_accepts_other_factor_names(factorized_prebuilt_tensor): + '''Previously raised IndexError: the code did int(name.split(' ')[1]).''' + tensor = factorized_prebuilt_tensor + for key in tensor.factors: + tensor.factors[key].columns = ['component_1', 'component_10'] + + result = get_lr_by_cell_pairs(tensor, + lr_label='Ligand-Receptor Pairs', + sender_label='Sender Cells', + receiver_label='Receiver Cells') + assert result.shape[0] == len(tensor.order_names[1]) + assert result.shape[1] == len(tensor.order_names[2]) ** 2 diff --git a/tests/test_clustering.py b/tests/test_clustering.py new file mode 100644 index 0000000..54d0464 --- /dev/null +++ b/tests/test_clustering.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.clustering''' + +import numpy as np +import pandas as pd +import pytest +from scipy.spatial.distance import squareform + +from cell2cell import clustering + + +def test_compute_distance_shape_and_symmetry(toy_rnaseq): + result = clustering.compute_distance(toy_rnaseq, axis=0, metric='euclidean') + n = toy_rnaseq.shape[0] + assert result.shape == (n, n) + assert np.allclose(result, result.T) + assert np.allclose(np.diag(result), 0.0) + + +def test_compute_distance_along_the_other_axis(toy_rnaseq): + result = clustering.compute_distance(toy_rnaseq, axis=1, metric='euclidean') + assert result.shape == (toy_rnaseq.shape[1], toy_rnaseq.shape[1]) + + +@pytest.mark.parametrize('metric', ['euclidean', 'cityblock', 'cosine']) +def test_compute_distance_metrics(toy_rnaseq, metric): + result = clustering.compute_distance(toy_rnaseq, axis=0, metric=metric) + assert (result >= -1e-12).all() + + +def test_compute_distance_matches_scipy(toy_rnaseq): + from scipy.spatial.distance import pdist + result = clustering.compute_distance(toy_rnaseq, axis=0, metric='euclidean') + expected = squareform(pdist(toy_rnaseq.values, metric='euclidean')) + assert np.allclose(result, expected) + + +def test_compute_linkage_shape(toy_distance): + linkage = clustering.compute_linkage(toy_distance, method='ward') + assert linkage.shape == (toy_distance.shape[0] - 1, 4) + + +@pytest.mark.parametrize('method', ['ward', 'average', 'complete', 'single']) +def test_compute_linkage_methods(toy_distance, method): + linkage = clustering.compute_linkage(toy_distance, method=method) + assert linkage.shape[0] == toy_distance.shape[0] - 1 + # Merge distances must be non-decreasing for these methods + assert np.all(np.diff(linkage[:, 2]) >= -1e-9) + + +def test_compute_linkage_is_deterministic(toy_distance): + first = clustering.compute_linkage(toy_distance, method='ward') + second = clustering.compute_linkage(toy_distance, method='ward') + assert np.allclose(first, second) + + +def test_compute_linkage_accepts_a_read_only_frame(read_only_frame, toy_distance): + '''compute_linkage zeroes the diagonal in place, which used to be done on the array + behind `DataFrame.values`. That array is read-only under the copy-on-write of + pandas >= 3.0. Only the ndarray branch of the function used to copy it.''' + frame = read_only_frame(toy_distance, labels=list(toy_distance.index)) + linkage = clustering.compute_linkage(frame, method='ward') + assert np.allclose(linkage, clustering.compute_linkage(toy_distance, method='ward')) + + +def test_compute_linkage_accepts_a_read_only_array(toy_distance): + array = np.array(toy_distance, dtype=float) + array.setflags(write=False) + linkage = clustering.compute_linkage(array, method='ward') + assert np.allclose(linkage, clustering.compute_linkage(toy_distance, method='ward')) + + +def test_compute_linkage_does_not_modify_its_input(toy_distance): + similarity = 1 - toy_distance / toy_distance.values.max() + before = similarity.copy() + clustering.compute_linkage(similarity, method='average') + pd.testing.assert_frame_equal(similarity, before) + + +def test_get_clusters_from_linkage_maxclust(toy_distance): + linkage = clustering.compute_linkage(toy_distance, method='ward') + clusters = clustering.get_clusters_from_linkage(linkage, threshold=2, + criterion='maxclust', + labels=list(toy_distance.index)) + assert len(clusters) == 2 + members = [m for group in clusters.values() for m in group] + assert sorted(members) == sorted(list(toy_distance.index)) + + +def test_get_clusters_from_linkage_without_labels(toy_distance): + linkage = clustering.compute_linkage(toy_distance, method='ward') + clusters = clustering.get_clusters_from_linkage(linkage, threshold=3, + criterion='maxclust') + assert len(clusters) == 3 + total = sum(len(group) for group in clusters.values()) + assert total == toy_distance.shape[0] + + +def test_get_clusters_from_linkage_every_element_appears_once(toy_distance): + linkage = clustering.compute_linkage(toy_distance, method='average') + clusters = clustering.get_clusters_from_linkage(linkage, threshold=2, + criterion='maxclust', + labels=list(toy_distance.index)) + members = [m for group in clusters.values() for m in group] + assert len(members) == len(set(members)) + + +def test_clustering_pipeline_end_to_end(toy_rnaseq): + distance = clustering.compute_distance(toy_rnaseq, axis=1) + frame = pd.DataFrame(distance, index=toy_rnaseq.columns, columns=toy_rnaseq.columns) + linkage = clustering.compute_linkage(frame, method='ward') + clusters = clustering.get_clusters_from_linkage(linkage, threshold=2, + criterion='maxclust', + labels=list(toy_rnaseq.columns)) + assert len(clusters) == 2 diff --git a/tests/test_core_cci_scores.py b/tests/test_core_cci_scores.py new file mode 100644 index 0000000..0849cc1 --- /dev/null +++ b/tests/test_core_cci_scores.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.core.cci_scores''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.core import cci_scores +from cell2cell.core.cci_scores import matmul_cosine + + +class FakeCell: + '''Minimal stand-in exposing only what the scoring functions read.''' + + def __init__(self, a_values, b_values): + self.weighted_ppi = pd.DataFrame({'A': a_values, 'B': b_values}) + + +PAIRWISE = [cci_scores.compute_jaccard_like_cci_score, + cci_scores.compute_braycurtis_like_cci_score, + cci_scores.compute_count_score, + cci_scores.compute_icellnet_score] + + +@pytest.mark.parametrize('score_function', PAIRWISE) +def test_scores_are_zero_for_empty_cells(score_function): + empty = FakeCell([], []) + assert score_function(empty, empty) == 0.0 + + +@pytest.mark.parametrize('score_function', PAIRWISE) +def test_scores_are_zero_when_nothing_is_expressed(score_function): + silent = FakeCell([0.0, 0.0], [0.0, 0.0]) + assert score_function(silent, silent) == 0.0 + + +@pytest.mark.parametrize('score_function', [cci_scores.compute_jaccard_like_cci_score, + cci_scores.compute_braycurtis_like_cci_score]) +def test_bounded_scores_stay_within_zero_and_one(score_function): + cell1 = FakeCell([1.0, 0.5, 0.0], [0.2, 1.0, 0.7]) + cell2 = FakeCell([0.3, 0.9, 1.0], [1.0, 0.1, 0.4]) + score = score_function(cell1, cell2) + assert 0.0 <= score <= 1.0 + + +def test_jaccard_like_of_identical_binary_vectors_is_one(): + cell = FakeCell([1.0, 1.0, 1.0], [1.0, 1.0, 1.0]) + assert np.isclose(cci_scores.compute_jaccard_like_cci_score(cell, cell), 1.0) + + +def test_jaccard_like_formula(): + cell1 = FakeCell([1.0, 2.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [3.0, 4.0]) + c1 = np.array([1.0, 2.0]) + c2 = np.array([3.0, 4.0]) + numerator = np.sum(c1 * c2) + denominator = np.sum(c1 * c1) + np.sum(c2 * c2) - numerator + expected = numerator / denominator + assert np.isclose(cci_scores.compute_jaccard_like_cci_score(cell1, cell2), expected) + + +def test_braycurtis_like_formula(): + cell1 = FakeCell([1.0, 2.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [3.0, 4.0]) + c1 = np.array([1.0, 2.0]) + c2 = np.array([3.0, 4.0]) + expected = 2 * np.sum(c1 * c2) / (np.sum(c1 * c1) + np.sum(c2 * c2)) + assert np.isclose(cci_scores.compute_braycurtis_like_cci_score(cell1, cell2), expected) + + +def test_count_score_counts_active_interactions(): + cell1 = FakeCell([1.0, 0.0, 1.0], [0.0, 0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) + # Only positions 0 and 2 are active in both + assert cci_scores.compute_count_score(cell1, cell2) == 2.0 + + +def test_count_score_is_zero_without_overlap(): + cell1 = FakeCell([1.0, 0.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [0.0, 1.0]) + assert cci_scores.compute_count_score(cell1, cell2) == 0.0 + + +def test_icellnet_score_formula(): + cell1 = FakeCell([2.0, 3.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [4.0, 5.0]) + expected = np.nansum(np.array([2.0, 3.0]) * np.array([4.0, 5.0])) + assert np.isclose(cci_scores.compute_icellnet_score(cell1, cell2), expected) + + +@pytest.mark.parametrize('score_function', PAIRWISE) +def test_ppi_score_weighting_changes_the_result(score_function): + cell1 = FakeCell([1.0, 2.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [3.0, 4.0]) + unweighted = score_function(cell1, cell2) + weighted = score_function(cell1, cell2, ppi_score=np.array([1.0, 0.0])) + assert not np.isclose(unweighted, weighted) + + +def test_ppi_score_of_ones_matches_the_default(): + cell1 = FakeCell([1.0, 2.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [3.0, 4.0]) + default = cci_scores.compute_jaccard_like_cci_score(cell1, cell2) + explicit = cci_scores.compute_jaccard_like_cci_score(cell1, cell2, + ppi_score=np.array([1.0, 1.0])) + assert np.isclose(default, explicit) + + +# --------------------------------------------------------------------------------- +# Matrix implementations must agree with the pairwise ones +# --------------------------------------------------------------------------------- + +@pytest.fixture +def score_matrices(): + '''A_scores and B_scores: rows are PPIs, columns are cells.''' + a_scores = np.array([[1.0, 0.5, 0.0], + [0.2, 1.0, 0.7], + [0.9, 0.0, 0.3]]) + b_scores = np.array([[0.3, 0.9, 1.0], + [1.0, 0.1, 0.4], + [0.5, 0.6, 0.2]]) + return a_scores, b_scores + + +def test_matmul_jaccard_like_matches_pairwise(score_matrices): + a_scores, b_scores = score_matrices + matrix = cci_scores.matmul_jaccard_like(a_scores, b_scores) + for i in range(a_scores.shape[1]): + for j in range(b_scores.shape[1]): + cell1 = FakeCell(a_scores[:, i], b_scores[:, i]) + cell2 = FakeCell(a_scores[:, j], b_scores[:, j]) + expected = cci_scores.compute_jaccard_like_cci_score(cell1, cell2) + assert np.isclose(matrix[i, j], expected) + + +def test_matmul_bray_curtis_like_matches_pairwise(score_matrices): + a_scores, b_scores = score_matrices + matrix = cci_scores.matmul_bray_curtis_like(a_scores, b_scores) + for i in range(a_scores.shape[1]): + for j in range(b_scores.shape[1]): + cell1 = FakeCell(a_scores[:, i], b_scores[:, i]) + cell2 = FakeCell(a_scores[:, j], b_scores[:, j]) + expected = cci_scores.compute_braycurtis_like_cci_score(cell1, cell2) + assert np.isclose(matrix[i, j], expected) + + +def test_matmul_count_active_matches_pairwise(): + a_scores = np.array([[1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + b_scores = np.array([[1.0, 1.0], [0.0, 1.0], [1.0, 0.0]]) + matrix = cci_scores.matmul_count_active(a_scores, b_scores) + for i in range(2): + for j in range(2): + cell1 = FakeCell(a_scores[:, i], b_scores[:, i]) + cell2 = FakeCell(a_scores[:, j], b_scores[:, j]) + expected = cci_scores.compute_count_score(cell1, cell2) + assert np.isclose(matrix[i, j], expected) + + +def test_matmul_shapes(score_matrices): + a_scores, b_scores = score_matrices + n_cells = a_scores.shape[1] + for function in [cci_scores.matmul_jaccard_like, cci_scores.matmul_bray_curtis_like, + cci_scores.matmul_count_active, matmul_cosine]: + assert function(a_scores, b_scores).shape == (n_cells, n_cells) + + +def test_matmul_cosine_is_bounded(score_matrices): + a_scores, b_scores = score_matrices + result = matmul_cosine(a_scores, b_scores) + assert (result >= -1.0).all() and (result <= 1.0).all() + + +def test_matmul_accepts_a_ppi_score(score_matrices): + a_scores, b_scores = score_matrices + ppi_score = np.array([1.0, 0.5, 0.0]) + matrix = cci_scores.matmul_jaccard_like(a_scores, b_scores, ppi_score=ppi_score) + for i in range(a_scores.shape[1]): + for j in range(b_scores.shape[1]): + cell1 = FakeCell(a_scores[:, i], b_scores[:, i]) + cell2 = FakeCell(a_scores[:, j], b_scores[:, j]) + expected = cci_scores.compute_jaccard_like_cci_score(cell1, cell2, + ppi_score=ppi_score) + assert np.isclose(matrix[i, j], expected) diff --git a/tests/test_core_cell.py b/tests/test_core_cell.py new file mode 100644 index 0000000..6b1f7b6 --- /dev/null +++ b/tests/test_core_cell.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.core.cell + +NOTE: `Cell._id` is a class attribute that only ever increases (`__del__` decrements +`_id_counter`, not `_id`), so no test here asserts an absolute `cell.id` value -- that +would make the results depend on the execution order of the whole suite. +''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.core import cell as cell_module + + +def test_cell_takes_its_type_from_the_column_name(toy_rnaseq): + instance = cell_module.Cell(toy_rnaseq[['C3']], verbose=False) + assert instance.type == 'C3' + + +def test_cell_renames_the_expression_column(toy_rnaseq): + instance = cell_module.Cell(toy_rnaseq[['C1']], verbose=False) + assert list(instance.rnaseq_data.columns) == ['value'] + assert np.allclose(instance.rnaseq_data['value'].values, toy_rnaseq['C1'].values) + + +def test_cell_copies_the_expression_data(toy_rnaseq): + data = toy_rnaseq[['C1']].copy() + instance = cell_module.Cell(data, verbose=False) + data.iloc[0, 0] = 99999 + assert not np.isclose(instance.rnaseq_data['value'].iloc[0], 99999) + + +def test_cell_ids_are_unique(toy_rnaseq): + first = cell_module.Cell(toy_rnaseq[['C1']], verbose=False) + second = cell_module.Cell(toy_rnaseq[['C2']], verbose=False) + assert first.id != second.id + + +def test_cell_str_contains_the_type(toy_rnaseq): + instance = cell_module.Cell(toy_rnaseq[['C4']], verbose=False) + assert 'C4' in str(instance) + + +def test_get_cells_from_rnaseq_builds_one_cell_per_column(toy_rnaseq): + cells = cell_module.get_cells_from_rnaseq(toy_rnaseq, verbose=False) + assert set(cells.keys()) == set(toy_rnaseq.columns) + for name, instance in cells.items(): + assert instance.type == name + + +def test_get_cells_from_rnaseq_with_a_subset(toy_rnaseq): + cells = cell_module.get_cells_from_rnaseq(toy_rnaseq, cell_columns=['C1', 'C3'], + verbose=False) + assert set(cells.keys()) == {'C1', 'C3'} + + +def test_get_cells_from_rnaseq_expression_matches_the_source(toy_rnaseq, toy_cells): + for name, instance in toy_cells.items(): + assert np.allclose(instance.rnaseq_data['value'].values, + toy_rnaseq[name].values) + + +def test_get_cells_from_rnaseq_preserves_gene_order(toy_rnaseq, toy_cells): + for instance in toy_cells.values(): + assert list(instance.rnaseq_data.index) == list(toy_rnaseq.index) diff --git a/tests/test_core_communication_scores.py b/tests/test_core_communication_scores.py new file mode 100644 index 0000000..6641843 --- /dev/null +++ b/tests/test_core_communication_scores.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.core.communication_scores''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.core import communication_scores + + +class FakeCell: + def __init__(self, a_values, b_values): + self.weighted_ppi = pd.DataFrame({'A': a_values, 'B': b_values}) + + +def test_get_binary_scores_is_the_product_of_indicators(): + cell1 = FakeCell([1.0, 1.0, 0.0], [0.0, 0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0, 0.0], [1.0, 0.0, 1.0]) + result = communication_scores.get_binary_scores(cell1, cell2) + assert np.allclose(result, [1.0, 0.0, 0.0]) + + +def test_get_binary_scores_with_ppi_score(): + cell1 = FakeCell([1.0, 1.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [1.0, 1.0]) + result = communication_scores.get_binary_scores(cell1, cell2, + ppi_score=np.array([2.0, 0.5])) + assert np.allclose(result, [2.0, 0.5]) + + +def test_get_continuous_scores_expression_product(): + cell1 = FakeCell([2.0, 3.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [4.0, 5.0]) + result = communication_scores.get_continuous_scores(cell1, cell2, + method='expression_product') + assert np.allclose(result, [8.0, 15.0]) + + +def test_get_continuous_scores_expression_mean(): + cell1 = FakeCell([2.0, 3.0], [0.0, 0.0]) + cell2 = FakeCell([0.0, 0.0], [4.0, 5.0]) + result = communication_scores.get_continuous_scores(cell1, cell2, + method='expression_mean') + assert np.allclose(result, [3.0, 4.0]) + + +def test_get_continuous_scores_rejects_unknown_method(): + cell = FakeCell([1.0], [1.0]) + with pytest.raises(ValueError): + communication_scores.get_continuous_scores(cell, cell, method='nonsense') + + +def test_score_expression_product_and_mean_are_elementwise(): + c1 = np.array([1.0, 2.0, 3.0]) + c2 = np.array([4.0, 5.0, 6.0]) + assert np.allclose(communication_scores.score_expression_product(c1, c2), c1 * c2) + assert np.allclose(communication_scores.score_expression_mean(c1, c2), (c1 + c2) / 2.) + + +# --------------------------------------------------------------------------------- +# compute_ccc_matrix +# --------------------------------------------------------------------------------- + +@pytest.fixture +def expression_vectors(): + prot_a = np.array([1.0, 2.0, 3.0]) # senders + prot_b = np.array([4.0, 5.0]) # receivers + return prot_a, prot_b + + +def test_compute_ccc_matrix_expression_product(expression_vectors): + prot_a, prot_b = expression_vectors + result = communication_scores.compute_ccc_matrix(prot_a, prot_b, + communication_score='expression_product') + assert result.shape == (3, 2) + assert np.allclose(result, np.outer(prot_a, prot_b)) + + +def test_compute_ccc_matrix_expression_mean(expression_vectors): + prot_a, prot_b = expression_vectors + result = communication_scores.compute_ccc_matrix(prot_a, prot_b, + communication_score='expression_mean') + expected = (np.outer(prot_a, np.ones(2)) + np.outer(np.ones(3), prot_b)) / 2. + assert np.allclose(result, expected) + + +def test_compute_ccc_matrix_expression_gmean(expression_vectors): + prot_a, prot_b = expression_vectors + result = communication_scores.compute_ccc_matrix(prot_a, prot_b, + communication_score='expression_gmean') + assert np.allclose(result, np.sqrt(np.outer(prot_a, prot_b))) + + +def test_compute_ccc_matrix_rejects_unknown_score(expression_vectors): + prot_a, prot_b = expression_vectors + with pytest.raises(ValueError): + communication_scores.compute_ccc_matrix(prot_a, prot_b, + communication_score='nonsense') + + +def test_compute_ccc_matrix_orientation_is_senders_by_receivers(): + '''Rows must correspond to the first vector and columns to the second.''' + prot_a = np.array([0.0, 1.0]) + prot_b = np.array([1.0, 1.0, 1.0]) + result = communication_scores.compute_ccc_matrix(prot_a, prot_b, + communication_score='expression_product') + assert result.shape == (2, 3) + assert np.allclose(result[0, :], 0.0) + assert np.allclose(result[1, :], 1.0) + + +# --------------------------------------------------------------------------------- +# aggregate_ccc_matrices +# --------------------------------------------------------------------------------- + +@pytest.fixture +def ccc_matrices(): + return [np.array([[1.0, 4.0], [9.0, 16.0]]), + np.array([[1.0, 1.0], [1.0, 1.0]])] + + +def test_aggregate_ccc_matrices_gmean(ccc_matrices): + result = communication_scores.aggregate_ccc_matrices(ccc_matrices, method='gmean') + expected = np.sqrt(ccc_matrices[0] * ccc_matrices[1]) + assert np.allclose(result, expected) + + +def test_aggregate_ccc_matrices_sum(ccc_matrices): + result = communication_scores.aggregate_ccc_matrices(ccc_matrices, method='sum') + assert np.allclose(result, ccc_matrices[0] + ccc_matrices[1]) + + +def test_aggregate_ccc_matrices_mean(ccc_matrices): + result = communication_scores.aggregate_ccc_matrices(ccc_matrices, method='mean') + assert np.allclose(result, (ccc_matrices[0] + ccc_matrices[1]) / 2.) + + +def test_aggregate_ccc_matrices_rejects_unknown_method(ccc_matrices): + with pytest.raises(ValueError): + communication_scores.aggregate_ccc_matrices(ccc_matrices, method='nonsense') + + +def test_aggregate_ccc_matrices_preserves_shape(ccc_matrices): + for method in ['gmean', 'sum', 'mean']: + result = communication_scores.aggregate_ccc_matrices(ccc_matrices, method=method) + assert result.shape == ccc_matrices[0].shape + + +def test_aggregate_a_single_matrix_is_a_no_op(ccc_matrices): + result = communication_scores.aggregate_ccc_matrices([ccc_matrices[0]], method='mean') + assert np.allclose(result, ccc_matrices[0]) diff --git a/tests/test_core_interaction_space.py b/tests/test_core_interaction_space.py new file mode 100644 index 0000000..cdabd36 --- /dev/null +++ b/tests/test_core_interaction_space.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.core.interaction_space''' + +import itertools + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.core import interaction_space as ispace + + +# --------------------------------------------------------------------------------- +# generate_pairs +# --------------------------------------------------------------------------------- + +def test_generate_pairs_directed_with_self_interaction(): + pairs = ispace.generate_pairs(['A', 'B'], 'directed', self_interaction=True) + assert set(pairs) == {('A', 'A'), ('A', 'B'), ('B', 'A'), ('B', 'B')} + + +def test_generate_pairs_directed_without_self_interaction(): + pairs = ispace.generate_pairs(['A', 'B'], 'directed', self_interaction=False) + assert set(pairs) == {('A', 'B'), ('B', 'A')} + + +def test_generate_pairs_undirected_with_self_interaction(): + pairs = ispace.generate_pairs(['A', 'B', 'C'], 'undirected', self_interaction=True) + assert len(pairs) == 6 + for a, b in pairs: + assert (b, a) not in pairs or a == b + + +def test_generate_pairs_undirected_without_self_interaction(): + pairs = ispace.generate_pairs(['A', 'B', 'C'], 'undirected', self_interaction=False) + assert set(pairs) == {('A', 'B'), ('A', 'C'), ('B', 'C')} + + +def test_generate_pairs_rejects_unknown_cci_type(): + with pytest.raises(NotImplementedError): + ispace.generate_pairs(['A', 'B'], 'nonsense') + + +def test_generate_pairs_counts_for_directed(toy_rnaseq): + cells = list(toy_rnaseq.columns) + pairs = ispace.generate_pairs(cells, 'directed') + assert len(pairs) == len(cells) ** 2 + + +# --------------------------------------------------------------------------------- +# InteractionSpace +# --------------------------------------------------------------------------------- + +def test_interaction_space_has_the_expected_elements(interaction_space): + elements = interaction_space.interaction_elements + for key in ['cell_names', 'pairs', 'cci_matrix', 'communication_matrix']: + assert key in elements + + +def test_cci_matrix_is_square_and_labelled(interaction_space, toy_rnaseq): + cci = interaction_space.interaction_elements['cci_matrix'] + assert list(cci.index) == list(cci.columns) + assert set(cci.index) == set(toy_rnaseq.columns) + + +def test_cci_matrix_is_symmetric_for_undirected(interaction_space): + cci = interaction_space.interaction_elements['cci_matrix'] + assert np.allclose(cci.values, cci.values.T) + + +def test_cci_matrix_diagonal_holds_autocrine_scores(interaction_space): + '''The diagonal is not 1: it compares a cell's ligand profile against its own + receptor profile (an autocrine interaction), not a vector with itself. + ''' + cci = interaction_space.interaction_elements['cci_matrix'] + diagonal = np.diag(cci.values) + assert ((diagonal >= 0) & (diagonal <= 1)).all() + assert (diagonal > 0).any() + + +def test_cci_matrix_values_are_bounded_for_bray_curtis(interaction_space): + cci = interaction_space.interaction_elements['cci_matrix'] + assert ((cci.values >= 0) & (cci.values <= 1)).all() + + +def test_distance_matrix_is_a_valid_distance(interaction_space): + distance = interaction_space.distance_matrix + assert np.allclose(np.diag(distance.values), 0.0) + assert (distance.values >= 0).all() + assert np.allclose(distance.values, distance.values.T) + + +def test_distance_matrix_keeps_the_cell_labels(interaction_space): + '''The diagonal is zeroed by rebuilding the frame, so the labels must be carried over + and stay aligned with the CCI matrix they are derived from.''' + distance = interaction_space.distance_matrix + cci = interaction_space.interaction_elements['cci_matrix'] + assert list(distance.index) == list(cci.index) + assert list(distance.columns) == list(cci.columns) + # Off-diagonal entries are the plain complement of the bray_curtis scores + off_diagonal = ~np.eye(cci.shape[0], dtype=bool) + assert np.allclose(distance.values[off_diagonal], 1 - cci.values[off_diagonal]) + + +def test_communication_matrix_columns_use_the_semicolon_separator(interaction_space): + communication = interaction_space.interaction_elements['communication_matrix'] + for column in communication.columns: + assert ';' in column + + +def test_communication_matrix_columns_match_the_pairs(interaction_space): + elements = interaction_space.interaction_elements + expected = ['{};{}'.format(a, b) for a, b in elements['pairs']] + assert list(elements['communication_matrix'].columns) == expected + + +def test_communication_matrix_values_are_finite(interaction_space): + communication = interaction_space.interaction_elements['communication_matrix'] + assert np.isfinite(communication.values.astype(float)).all() + + +def test_pairwise_cci_scores_are_reproducible(toy_rnaseq, toy_ppi, analysis_setup, + cutoff_setup): + import cell2cell as c2c + + def build(): + space = c2c.analysis.initialize_interaction_space( + rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, complex_sep=None, verbose=False) + space.compute_pairwise_cci_scores(verbose=False) + return space.interaction_elements['cci_matrix'] + + pd.testing.assert_frame_equal(build(), build()) + + +def test_generate_interaction_elements_directed_pair_count(toy_rnaseq, toy_ppi): + from cell2cell.preprocessing import integrate_data + modified = integrate_data.get_modified_rnaseq( + toy_rnaseq, communication_score='expression_product') + elements = ispace.generate_interaction_elements(modified, toy_ppi, + cci_type='directed', verbose=False) + n_cells = toy_rnaseq.shape[1] + assert len(elements['pairs']) == n_cells ** 2 + assert len(elements['cell_names']) == n_cells + + +def test_interaction_space_excluded_cells(toy_rnaseq, toy_ppi, analysis_setup, + cutoff_setup): + import cell2cell as c2c + space = c2c.analysis.initialize_interaction_space( + rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, cutoff_setup=cutoff_setup, + analysis_setup=analysis_setup, excluded_cells=['C1'], complex_sep=None, + verbose=False) + assert 'C1' not in space.interaction_elements['cell_names'] + + +# --------------------------------------------------------------------------------- +# Distance matrix for the 'count' and 'icellnet' CCI scores +# +# The branch was guarded by `if ~(cci_score in [...])`. Bitwise inversion of a bool +# gives -2/-1, which are both truthy, so the regularized-distance branch was dead +# code and those scores produced NEGATIVE distances (down to -7 on the toy data). +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('cci_score', ['count', 'icellnet']) +def test_unbounded_cci_scores_give_non_negative_distances(toy_rnaseq, toy_ppi, cci_score): + interactions = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + cci_score=cci_score, cci_type='undirected', + complex_sep=None, verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + distance = interactions.interaction_space.distance_matrix + + assert (distance.values >= 0).all(), 'a distance can never be negative' + assert np.allclose(np.diag(distance.values), 0.0) + # The raw scores are unbounded, so 1 - score would have gone negative + assert interactions.interaction_space.interaction_elements['cci_matrix'].values.max() > 1 + + +@pytest.mark.parametrize('cci_score', ['bray_curtis', 'jaccard']) +def test_bounded_cci_scores_use_the_plain_complement(toy_rnaseq, toy_ppi, cci_score): + '''Scores already in [0, 1] must keep using 1 - score, unchanged by the fix.''' + interactions = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + cci_score=cci_score, cci_type='undirected', + complex_sep=None, verbose=False) + interactions.compute_pairwise_cci_scores(verbose=False) + cci = interactions.interaction_space.interaction_elements['cci_matrix'] + distance = interactions.interaction_space.distance_matrix + + expected = 1 - cci.values + np.fill_diagonal(expected, 0.0) + assert np.allclose(distance.values, expected) + + +# --------------------------------------------------------------------------------- +# Reproducibility -- these orders came from an unsorted set() and varied per run +# --------------------------------------------------------------------------------- + +def test_generate_pairs_follows_the_order_of_the_cells(): + pairs = ispace.generate_pairs(['C3', 'C1', 'C2'], 'directed') + assert pairs == [('C3', 'C3'), ('C3', 'C1'), ('C3', 'C2'), + ('C1', 'C3'), ('C1', 'C1'), ('C1', 'C2'), + ('C2', 'C3'), ('C2', 'C1'), ('C2', 'C2')] + + +def test_generate_pairs_deduplicates_without_losing_order(): + pairs = ispace.generate_pairs(['A', 'A', 'B'], 'directed') + assert pairs == [('A', 'A'), ('A', 'B'), ('B', 'A'), ('B', 'B')] + + +def test_generate_pairs_is_reproducible(): + first = ispace.generate_pairs(['C3', 'C1', 'C2'], 'undirected') + second = ispace.generate_pairs(['C3', 'C1', 'C2'], 'undirected') + assert first == second + assert len(first) == len(set(first)) diff --git a/tests/test_datasets.py b/tests/test_datasets.py new file mode 100644 index 0000000..20bafa9 --- /dev/null +++ b/tests/test_datasets.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.datasets''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.datasets import toy_data + + +TOY_GENES = ['Protein-A', 'Protein-B', 'Protein-C', 'Protein-D', 'Protein-E', 'Protein-F'] +TOY_CELLS = ['C1', 'C2', 'C3', 'C4', 'C5'] + +DETERMINISTIC_GENERATORS = ['generate_toy_rnaseq', 'generate_toy_ppi', 'generate_toy_metadata', + 'generate_toy_distance', 'generate_toy_contexts', + 'generate_toy_single_cells', 'generate_toy_coordinates', + 'generate_toy_liana_output'] + + +# --------------------------------------------------------------------------------- +# Pre-existing toy datasets +# --------------------------------------------------------------------------------- + +def test_toy_rnaseq_shape_and_labels(toy_rnaseq): + assert toy_rnaseq.shape == (6, 5) + assert list(toy_rnaseq.index) == TOY_GENES + assert list(toy_rnaseq.columns) == TOY_CELLS + assert toy_rnaseq.index.name == 'gene_id' + assert (toy_rnaseq.values > 0).all() + + +def test_toy_ppi_columns(toy_ppi): + assert list(toy_ppi.columns) == ['A', 'B', 'score'] + assert toy_ppi.shape == (10, 3) + assert (toy_ppi['score'] == 1.0).all() + # Every interactor must exist in the toy RNA-seq dataset + assert set(toy_ppi['A']).union(toy_ppi['B']).issubset(set(TOY_GENES)) + + +def test_toy_ppi_complex_uses_ampersand(toy_ppi_complex): + interactors = set(toy_ppi_complex['A']).union(toy_ppi_complex['B']) + assert any('&' in i for i in interactors) + # Each subunit of a complex must be a known gene + for interactor in interactors: + for subunit in interactor.split('&'): + assert subunit in TOY_GENES + + +def test_toy_metadata(toy_metadata): + assert list(toy_metadata.columns) == ['#SampleID', 'Groups'] + assert list(toy_metadata['#SampleID']) == TOY_CELLS + + +def test_toy_distance_is_a_valid_distance_matrix(toy_distance): + assert list(toy_distance.index) == TOY_CELLS + assert list(toy_distance.columns) == TOY_CELLS + assert np.allclose(np.diag(toy_distance.values), 0.0) + assert np.allclose(toy_distance.values, toy_distance.values.T) + assert (toy_distance.values >= 0).all() + + +# --------------------------------------------------------------------------------- +# Toy datasets added in v0.9.0 +# --------------------------------------------------------------------------------- + +def test_toy_contexts_default(toy_contexts): + assert list(toy_contexts.keys()) == ['Context-1', 'Context-2', 'Context-3', 'Context-4'] + for df in toy_contexts.values(): + assert list(df.index) == TOY_GENES + assert list(df.columns) == TOY_CELLS + + +def test_toy_contexts_differ_between_contexts(toy_contexts): + values = [df.values for df in toy_contexts.values()] + for other in values[1:]: + assert not np.allclose(values[0], other) + + +def test_toy_contexts_custom_names(): + names = ['early', 'late'] + contexts = c2c.datasets.generate_toy_contexts(n_contexts=2, context_names=names) + assert list(contexts.keys()) == names + + +def test_toy_contexts_rejects_mismatched_names(): + with pytest.raises(AssertionError): + c2c.datasets.generate_toy_contexts(n_contexts=3, context_names=['only-one']) + + +def test_toy_contexts_with_ten_or_more_exposes_natural_order(): + '''With >= 10 contexts the alphabetical and natural orders differ.''' + contexts = c2c.datasets.generate_toy_contexts(n_contexts=11) + names = list(contexts.keys()) + assert names[-2:] == ['Context-10', 'Context-11'] + assert sorted(names) != names # alphabetical order is NOT the natural one + assert names[1] == 'Context-2' + + +def test_toy_single_cells(toy_single_cells): + rnaseq, metadata = toy_single_cells + assert list(rnaseq.index) == TOY_GENES + assert rnaseq.shape == (6, 12) # 3 cell types x 4 cells + assert list(metadata.columns) == ['barcodes', 'cell_types'] + assert list(metadata['barcodes']) == list(rnaseq.columns) + assert list(metadata['cell_types'].unique()) == ['CT-1', 'CT-2', 'CT-3'] + assert metadata['cell_types'].value_counts().unique().tolist() == [4] + + +def test_toy_single_cells_sizes(): + rnaseq, metadata = c2c.datasets.generate_toy_single_cells(n_cell_types=11, + n_cells_per_type=2) + assert rnaseq.shape == (6, 22) + types = list(metadata['cell_types'].unique()) + assert types[-1] == 'CT-11' + assert sorted(types) != types # exposes natural vs alphabetical ordering + + +def test_toy_coordinates(toy_coordinates): + assert list(toy_coordinates.columns) == ['X', 'Y', 'celltype'] + assert toy_coordinates.shape == (15, 3) + assert list(toy_coordinates['celltype'].unique()) == ['CT-1', 'CT-2', 'CT-3'] + assert toy_coordinates.index.is_unique + + +def test_toy_coordinates_celltypes_are_spatially_separated(toy_coordinates): + '''Cells of one type must be closer to each other than to another type.''' + groups = toy_coordinates.groupby('celltype')[['X', 'Y']].mean() + assert not np.allclose(groups.loc['CT-1'].values, groups.loc['CT-2'].values) + + +def test_toy_spatial_adata(toy_spatial_adata): + adata = toy_spatial_adata + assert adata.shape == (225, 6) + assert 'spatial' in adata.obsm + assert adata.obsm['spatial'].shape == (225, 2) + assert list(adata.var_names) == TOY_GENES + assert 'celltype' in adata.obs.columns + assert adata.obs_names[0] == 'spot-1' + + +def test_toy_spatial_adata_coordinate_range(toy_spatial_adata): + coords = toy_spatial_adata.obsm['spatial'] + assert np.isclose(coords.min(), 0.0) + assert np.isclose(coords.max(), 100.0) + # A lattice wide enough that >= 11 bins per axis each receive cells + assert len(np.unique(coords[:, 0])) >= 11 + + +def test_toy_spatial_adata_num_cells(): + adata = c2c.datasets.generate_toy_spatial_adata(num_cells=50, n_cell_types=2) + assert adata.shape[0] == 50 + assert set(adata.obs['celltype']) == {'CT-1', 'CT-2'} + + +def test_toy_liana_output(toy_liana): + assert list(toy_liana.columns) == ['context', 'source', 'target', 'ligand', + 'receptor', 'score'] + # 3 contexts x 3 senders x 3 receivers x 10 LR pairs + assert toy_liana.shape == (270, 6) + assert list(toy_liana['context'].unique()) == ['Context-1', 'Context-2', 'Context-3'] + assert toy_liana['score'].between(0, 1).all() + + +def test_toy_liana_output_has_no_duplicated_entries(toy_liana): + '''dataframes_to_tensor warns/aggregates on duplicates, so the toy data must be unique.''' + keys = ['context', 'source', 'target', 'ligand', 'receptor'] + assert not toy_liana.duplicated(subset=keys).any() + + +def test_toy_liana_output_groups_into_a_context_dict(toy_liana): + context_dict = {k: v for k, v in toy_liana.groupby('context')} + assert len(context_dict) == 3 + for df in context_dict.values(): + assert df.shape[0] == 90 + + +# --------------------------------------------------------------------------------- +# Determinism -- every toy generator must be reproducible +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('name', DETERMINISTIC_GENERATORS) +def test_toy_generators_are_deterministic(name): + generator = getattr(toy_data, name) + first, second = generator(), generator() + if isinstance(first, tuple): + for a, b in zip(first, second): + pd.testing.assert_frame_equal(a, b) + elif isinstance(first, dict): + assert list(first.keys()) == list(second.keys()) + for key in first: + pd.testing.assert_frame_equal(first[key], second[key]) + else: + pd.testing.assert_frame_equal(first, second) + + +def test_toy_spatial_adata_is_deterministic(): + first = c2c.datasets.generate_toy_spatial_adata(num_cells=30) + second = c2c.datasets.generate_toy_spatial_adata(num_cells=30) + assert np.allclose(first.X, second.X) + assert np.allclose(first.obsm['spatial'], second.obsm['spatial']) + assert list(first.obs['celltype']) == list(second.obs['celltype']) + + +def test_toy_data_module_does_not_import_anndata_eagerly(): + '''anndata must be imported inside generate_toy_spatial_adata, not at module level.''' + import inspect + source = inspect.getsource(toy_data) + header = source.split('def ')[0] + assert 'anndata' not in header + + +# --------------------------------------------------------------------------------- +# Random data generators +# --------------------------------------------------------------------------------- + +def test_generate_random_rnaseq_is_seeded(): + first = c2c.datasets.generate_random_rnaseq(size=4, row_names=TOY_GENES, + random_state=0, verbose=False) + second = c2c.datasets.generate_random_rnaseq(size=4, row_names=TOY_GENES, + random_state=0, verbose=False) + pd.testing.assert_frame_equal(first, second) + assert list(first.index) == TOY_GENES + assert list(first.columns) == ['Cell-1', 'Cell-2', 'Cell-3', 'Cell-4'] + + +def test_generate_random_rnaseq_is_scaled_to_a_million(): + df = c2c.datasets.generate_random_rnaseq(size=3, row_names=TOY_GENES, + random_state=1, verbose=False) + assert np.allclose(df.sum(axis=0).values, 1e6) + + +def test_generate_random_ppi_is_seeded(): + first = c2c.datasets.generate_random_ppi(max_size=5, interactors_A=TOY_GENES, + random_state=0, verbose=False) + second = c2c.datasets.generate_random_ppi(max_size=5, interactors_A=TOY_GENES, + random_state=0, verbose=False) + pd.testing.assert_frame_equal(first, second) + assert list(first.columns) == ['A', 'B'] + # max_size is an upper bound: PPIs are de-duplicated after resampling + assert first.shape[0] <= 5 + + +def test_generate_random_ppi_rejects_impossible_size(): + with pytest.raises(AssertionError): + c2c.datasets.generate_random_ppi(max_size=1000, interactors_A=['A', 'B'], + verbose=False) + + +def test_generate_random_cci_scores_symmetric(): + matrix = c2c.datasets.generate_random_cci_scores(cell_number=4, symmetric=True, + random_state=0) + assert matrix.shape == (4, 4) + assert np.allclose(matrix.values, matrix.values.T) + assert matrix.values.min() >= 0 and matrix.values.max() <= 1 + + +def test_generate_random_cci_scores_asymmetric_and_labelled(): + labels = ['a', 'b', 'c'] + matrix = c2c.datasets.generate_random_cci_scores(cell_number=3, labels=labels, + symmetric=False, random_state=0) + assert list(matrix.index) == labels + assert not np.allclose(matrix.values, matrix.values.T) + + +def test_generate_random_cci_scores_rejects_label_mismatch(): + with pytest.raises(AssertionError): + c2c.datasets.generate_random_cci_scores(cell_number=3, labels=['a']) + + +def test_generate_random_metadata(): + metadata = c2c.datasets.generate_random_metadata(cell_labels=TOY_CELLS, group_number=2) + assert list(metadata.columns) == ['Cell', 'Group'] + assert list(metadata['Cell']) == TOY_CELLS + assert set(metadata['Group']).issubset({1, 2}) + + +def test_heuristic_go_terms(): + terms = c2c.datasets.HeuristicGOTerms() + assert len(terms.contact_go_terms) == 13 + assert len(terms.mediator_go_terms) == 6 + assert all(t.startswith('GO:') for t in terms.contact_go_terms) + assert all(t.startswith('GO:') for t in terms.mediator_go_terms) + + +# --------------------------------------------------------------------------------- +# Downloads -- deselected by default (see pytest.ini) +# --------------------------------------------------------------------------------- + +@pytest.mark.network +def test_balf_covid_downloads(tmp_path): + adata = c2c.datasets.balf_covid(filename=str(tmp_path / 'balf.h5ad')) + assert adata.shape[0] > 0 + + +@pytest.mark.network +def test_gsea_msig_downloads(): + pathway_per_gene = c2c.datasets.gsea_msig(organism='human', pathwaydb='KEGG') + assert len(pathway_per_gene) > 0 diff --git a/tests/test_external_goenrich.py b/tests/test_external_goenrich.py new file mode 100644 index 0000000..5249b2a --- /dev/null +++ b/tests/test_external_goenrich.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.external.goenrich (vendored ontology parser)''' + +import cell2cell as c2c + + +def test_ontology_parses_a_minimal_obo(tmp_path): + obo = tmp_path / 'toy.obo' + obo.write_text('\n'.join([ + 'format-version: 1.2', + '', + '[Term]', + 'id: GO:0000001', + 'name: toy root', + 'namespace: biological_process', + '', + '[Term]', + 'id: GO:0000002', + 'name: toy child', + 'namespace: biological_process', + 'is_a: GO:0000001', + '', + ''])) + graph = c2c.external.ontology(str(obo)) + assert 'GO:0000001' in graph.nodes() + assert 'GO:0000002' in graph.nodes() + assert graph.has_edge('GO:0000001', 'GO:0000002') or \ + graph.has_edge('GO:0000002', 'GO:0000001') diff --git a/tests/test_external_gseapy.py b/tests/test_external_gseapy.py new file mode 100644 index 0000000..de80e5a --- /dev/null +++ b/tests/test_external_gseapy.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.external.gseapy -- offline paths only''' + +from cell2cell.external import gseapy as gseapy_module + + +def test_load_gmt_from_a_local_file(tiny_gmt): + pathway_per_gene = gseapy_module.load_gmt(tiny_gmt, backup_url=None) + assert pathway_per_gene['Protein-A'] == {'TOYDB_PATHWAY_ONE'} + assert pathway_per_gene['Protein-B'] == {'TOYDB_PATHWAY_ONE', 'TOYDB_PATHWAY_TWO'} + assert pathway_per_gene['Protein-F'] == {'TOYDB_PATHWAY_THREE'} + + +def test_load_gmt_covers_every_listed_gene(tiny_gmt): + pathway_per_gene = gseapy_module.load_gmt(tiny_gmt, backup_url=None) + assert set(pathway_per_gene.keys()) == {'Protein-A', 'Protein-B', 'Protein-C', + 'Protein-E', 'Protein-F'} + + +def test_load_gmt_readable_names(tiny_gmt): + pathway_per_gene = gseapy_module.load_gmt(tiny_gmt, backup_url=None, + readable_name=True) + names = set().union(*pathway_per_gene.values()) + # The DB prefix is dropped and underscores become spaces + assert all('_' not in name for name in names) + + +def test_generate_lr_geneset_with_an_injected_annotation(tiny_gmt): + pathway_per_gene = gseapy_module.load_gmt(tiny_gmt, backup_url=None) + lr_list = ['Protein-A^Protein-B', 'Protein-B^Protein-C', 'Protein-E^Protein-F'] + geneset = gseapy_module.generate_lr_geneset(lr_list, lr_sep='^', + pathway_per_gene=pathway_per_gene, + min_pathways=0, max_pathways=10000) + assert isinstance(geneset, dict) + assert len(geneset) > 0 + for pathway, pairs in geneset.items(): + for pair in pairs: + assert pair in lr_list + + +def test_generate_lr_geneset_respects_min_pathways(tiny_gmt): + pathway_per_gene = gseapy_module.load_gmt(tiny_gmt, backup_url=None) + lr_list = ['Protein-A^Protein-B', 'Protein-B^Protein-C'] + geneset = gseapy_module.generate_lr_geneset(lr_list, lr_sep='^', + pathway_per_gene=pathway_per_gene, + min_pathways=100, max_pathways=10000) + assert geneset == {} + + +def test_generate_lr_geneset_with_complexes(tiny_gmt): + pathway_per_gene = gseapy_module.load_gmt(tiny_gmt, backup_url=None) + lr_list = ['Protein-A&Protein-B^Protein-C'] + geneset = gseapy_module.generate_lr_geneset(lr_list, complex_sep='&', lr_sep='^', + pathway_per_gene=pathway_per_gene, + min_pathways=0, max_pathways=10000) + assert isinstance(geneset, dict) diff --git a/tests/test_external_pcoa.py b/tests/test_external_pcoa.py new file mode 100644 index 0000000..c4171f8 --- /dev/null +++ b/tests/test_external_pcoa.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.external.pcoa and cell2cell.external.pcoa_utils''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.external.pcoa_utils import scale + + +# --------------------------------------------------------------------------------- +# pcoa +# --------------------------------------------------------------------------------- + +def test_pcoa_returns_the_expected_keys(toy_distance): + result = c2c.external.pcoa(toy_distance) + for key in ['samples', 'eigvals', 'proportion_explained']: + assert key in result + + +def test_pcoa_sample_shape_and_labels(toy_distance): + result = c2c.external.pcoa(toy_distance) + assert result['samples'].shape[0] == toy_distance.shape[0] + assert list(result['samples'].index) == list(toy_distance.index) + + +def test_pcoa_proportion_explained_is_a_distribution(toy_distance): + result = c2c.external.pcoa(toy_distance) + proportions = np.asarray(result['proportion_explained']) + assert (proportions >= -1e-9).all() + assert np.isclose(proportions.sum(), 1.0, atol=1e-6) + + +def test_pcoa_eigenvalues_are_sorted_descending(toy_distance): + result = c2c.external.pcoa(toy_distance) + eigvals = np.asarray(result['eigvals']) + assert np.all(np.diff(eigvals) <= 1e-9) + + +def test_pcoa_with_a_limited_number_of_dimensions(toy_distance): + result = c2c.external.pcoa(toy_distance, number_of_dimensions=2) + assert result['samples'].shape[1] == 2 + + +def test_pcoa_rejects_asymmetric_input(toy_distance): + asymmetric = toy_distance.copy() + asymmetric.iloc[0, 1] = 42.0 + with pytest.raises(ValueError): + c2c.external.pcoa(asymmetric) + + +def test_pcoa_is_deterministic(toy_distance): + first = c2c.external.pcoa(toy_distance) + second = c2c.external.pcoa(toy_distance) + assert np.allclose(first['samples'].values, second['samples'].values) + + +def test_pcoa_biplot_runs(toy_distance, toy_rnaseq): + ordination = c2c.external.pcoa(toy_distance) + # pcoa_biplot expects the samples frame, not the whole result dictionary + features = toy_rnaseq.T + result = c2c.external.pcoa_biplot(ordination, features) + assert 'features' in result + assert list(result['features'].index) == list(features.columns) + + +def test_check_ordination_accepts_a_pcoa_result(toy_distance): + ordination = c2c.external.pcoa(toy_distance) + checked = c2c.external._check_ordination(ordination) + assert checked is not None + + +# --------------------------------------------------------------------------------- +# convert_to_distance_matrix raised instead of warning +# +# `raise Warning(...)` aborts, so the diagonal was never "automatically replaced by +# zeros" as the message claimed. This broke the public `pcoa()` for any similarity +# or correlation matrix, since pcoa calls it unconditionally. +# --------------------------------------------------------------------------------- + +def test_pcoa_accepts_a_similarity_matrix(toy_distance): + '''pcoa() raised Warning for any matrix whose diagonal was not already zero.''' + similarity = 1 - toy_distance / toy_distance.values.max() + with pytest.warns(UserWarning): + result = c2c.external.pcoa(similarity) + assert result['samples'].shape[0] == toy_distance.shape[0] + + +# --------------------------------------------------------------------------------- +# pcoa_biplot recursed through pandas +# +# `np.power(eigvals, -0.5, where=...)` was called on a pandas Series, which recurses +# in pandas' __array_ufunc__ handling. The missing `out=` also left the masked +# entries reading uninitialized memory. +# --------------------------------------------------------------------------------- + +def test_pcoa_biplot_projects_the_features(toy_distance, toy_rnaseq): + ordination = c2c.external.pcoa(toy_distance) + features = toy_rnaseq.T + result = c2c.external.pcoa_biplot(ordination, features) + assert 'features' in result + assert list(result['features'].index) == list(features.columns) + assert np.isfinite(result['features'].values).all() + + +def test_pcoa_biplot_is_deterministic(toy_distance, toy_rnaseq): + '''Without `out=`, the entries excluded by `where` were uninitialized memory.''' + features = toy_rnaseq.T + first = c2c.external.pcoa_biplot(c2c.external.pcoa(toy_distance), features) + second = c2c.external.pcoa_biplot(c2c.external.pcoa(toy_distance), features) + assert np.allclose(first['features'].values, second['features'].values) + + +# --------------------------------------------------------------------------------- +# Arrays that cannot be written to +# +# pandas >= 3.0 enforces copy-on-write, so `DataFrame.values` returns a read-only +# array. `pcoa(inplace=True)` was dead code on top of that, since it went through +# `np.float`, an alias numpy removed in 1.24. +# --------------------------------------------------------------------------------- + +def test_pcoa_centers_the_matrix_in_place_on_request(toy_distance): + inplace = c2c.external.pcoa(toy_distance, inplace=True) + default = c2c.external.pcoa(toy_distance, inplace=False) + assert np.allclose(inplace['eigvals'].values, default['eigvals'].values) + assert np.allclose(np.abs(inplace['samples'].values), + np.abs(default['samples'].values)) + + +def test_pcoa_does_not_modify_its_input(toy_distance): + before = toy_distance.copy() + c2c.external.pcoa(toy_distance, inplace=True) + pd.testing.assert_frame_equal(toy_distance, before) + + +def test_pcoa_biplot_accepts_a_read_only_frame(read_only_frame, toy_distance, toy_rnaseq): + distance = read_only_frame(toy_distance, labels=list(toy_distance.index)) + ordination = c2c.external.pcoa(distance) + features = toy_rnaseq.T + result = c2c.external.pcoa_biplot(ordination, features) + assert list(result['features'].index) == list(features.columns) + + +# --------------------------------------------------------------------------------- +# pcoa_utils +# --------------------------------------------------------------------------------- + +def test_scale_accepts_a_dataframe(): + '''`scale` standardizes its argument in place. It used to copy before converting to an + array, so a dataframe was copied as a dataframe and the conversion then handed back + the read-only buffer that pandas >= 3.0 exposes. Arrays were never affected.''' + frame = pd.DataFrame([[1., 2.], [3., 4.], [5., 7.]], columns=['x', 'y']) + result = scale(frame) + assert np.allclose(result.mean(axis=0), 0.0) + assert np.allclose(result.std(axis=0), 1.0) + + +def test_scale_does_not_modify_its_input(): + array = np.array([[1., 2.], [3., 4.], [5., 7.]]) + before = array.copy() + scale(array) + assert np.allclose(array, before) diff --git a/tests/test_external_umap.py b/tests/test_external_umap.py new file mode 100644 index 0000000..42ebd8e --- /dev/null +++ b/tests/test_external_umap.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.external.umap''' + +import numpy as np + +import cell2cell as c2c + + +def test_run_umap_shape(toy_rnaseq): + result = c2c.external.run_umap(toy_rnaseq, axis=1, n_neighbors=3, random_state=0) + assert result.shape[0] == toy_rnaseq.shape[1] + assert result.shape[1] == 2 + + +def test_run_umap_is_reproducible_with_a_seed(toy_rnaseq): + first = c2c.external.run_umap(toy_rnaseq, axis=1, n_neighbors=3, random_state=7) + second = c2c.external.run_umap(toy_rnaseq, axis=1, n_neighbors=3, random_state=7) + assert np.allclose(first.values, second.values) + + +def test_run_umap_on_the_other_axis(toy_rnaseq): + result = c2c.external.run_umap(toy_rnaseq, axis=0, n_neighbors=3, random_state=0) + assert result.shape[0] == toy_rnaseq.shape[0] diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..f696e35 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.io''' + +import os + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.io import directories, read_data, save_data + + +# --------------------------------------------------------------------------------- +# directories +# --------------------------------------------------------------------------------- + +def test_create_directory(tmp_path): + target = tmp_path / 'new-folder' + directories.create_directory(str(target)) + assert target.is_dir() + + +def test_create_directory_is_idempotent(tmp_path): + target = tmp_path / 'folder' + directories.create_directory(str(target)) + directories.create_directory(str(target)) + assert target.is_dir() + + +def test_get_files_from_directory_is_naturally_sorted(tmp_path): + for name in ['f10.csv', 'f2.csv', 'f1.csv']: + (tmp_path / name).write_text('') + files = directories.get_files_from_directory(str(tmp_path)) + assert files == ['f1.csv', 'f2.csv', 'f10.csv'] + + +def test_get_files_from_directory_with_full_paths(tmp_path): + (tmp_path / 'a.csv').write_text('') + files = directories.get_files_from_directory(str(tmp_path), dir_in_filepath=True) + assert files == [str(tmp_path) + '/a.csv'] + assert os.path.isfile(files[0]) + + +def test_get_files_from_directory_is_reproducible(tmp_path): + for name in ['z.csv', 'a.csv', 'm.csv']: + (tmp_path / name).write_text('') + assert (directories.get_files_from_directory(str(tmp_path)) == + directories.get_files_from_directory(str(tmp_path))) + + +# --------------------------------------------------------------------------------- +# Pickle round-trip +# --------------------------------------------------------------------------------- + +def test_pickle_roundtrip_dataframe(tmp_path, toy_rnaseq): + filename = str(tmp_path / 'data.pkl') + save_data.export_variable_with_pickle(toy_rnaseq, filename) + loaded = read_data.load_variable_with_pickle(filename) + pd.testing.assert_frame_equal(loaded, toy_rnaseq) + + +def test_pickle_roundtrip_dictionary(tmp_path): + variable = {'a': [1, 2, 3], 'b': 'text'} + filename = str(tmp_path / 'var.pkl') + save_data.export_variable_with_pickle(variable, filename) + assert read_data.load_variable_with_pickle(filename) == variable + + +# --------------------------------------------------------------------------------- +# load_table and friends +# --------------------------------------------------------------------------------- + +def test_load_table_auto_infers_the_separator_from_the_extension(tmp_path, toy_rnaseq): + '''format="auto" sets sep="," for .csv and sep="\\t" for .tsv/.txt.''' + filename = tmp_path / 'table.csv' + toy_rnaseq.to_csv(filename) + loaded = read_data.load_table(str(filename), format='auto', index_col=0, verbose=False) + assert list(loaded.columns) == list(toy_rnaseq.columns) + assert np.allclose(loaded.values, toy_rnaseq.values) + + +def test_load_table_explicit_format_keeps_the_given_separator(tmp_path, toy_rnaseq): + '''With an explicit format, `sep` is NOT inferred and defaults to a tab.''' + filename = tmp_path / 'table.csv' + toy_rnaseq.to_csv(filename) + loaded = read_data.load_table(str(filename), format='csv', sep=',', index_col=0, + verbose=False) + assert list(loaded.columns) == list(toy_rnaseq.columns) + + +def test_load_table_returns_none_without_a_filename(): + assert read_data.load_table(None) is None + + +def test_load_table_tsv(tmp_path, toy_rnaseq): + filename = tmp_path / 'table.tsv' + toy_rnaseq.to_csv(filename, sep='\t') + loaded = read_data.load_table(str(filename), format='auto', sep='\t', index_col=0, + verbose=False) + assert loaded.shape == toy_rnaseq.shape + + +def test_load_table_excel(tmp_path, toy_rnaseq): + filename = tmp_path / 'table.xlsx' + toy_rnaseq.to_excel(filename) + loaded = read_data.load_table(str(filename), format='excel', index_col=0, + verbose=False) + assert loaded.shape == toy_rnaseq.shape + + +def test_load_table_returns_none_for_an_unknown_format(tmp_path): + '''An unrecognized format returns None rather than raising.''' + filename = tmp_path / 'table.weird' + filename.write_text('a,b\n1,2\n') + assert read_data.load_table(str(filename), format='nonsense', verbose=False) is None + + +def test_load_tables_from_directory(tmp_path, toy_rnaseq): + for name in ['s1', 's2']: + toy_rnaseq.to_csv(tmp_path / '{}.csv'.format(name)) + tables = read_data.load_tables_from_directory(str(tmp_path), extension='csv', + sep=',', index_col=0, verbose=False) + assert set(tables.keys()) == {'s1', 's2'} + for frame in tables.values(): + assert frame.shape == toy_rnaseq.shape + + +def test_load_rnaseq(tmp_path, toy_rnaseq): + frame = toy_rnaseq.reset_index() + filename = tmp_path / 'rnaseq.csv' + frame.to_csv(filename, index=False) + loaded = read_data.load_rnaseq(str(filename), gene_column='gene_id', format='auto', + verbose=False) + assert list(loaded.index) == list(toy_rnaseq.index) + + +def test_load_metadata(tmp_path, toy_metadata): + filename = tmp_path / 'metadata.csv' + toy_metadata.to_csv(filename, index=False) + loaded = read_data.load_metadata(str(filename), format='auto') + assert 'Groups' in loaded.columns + + +def test_load_ppi(tmp_path, toy_ppi): + filename = tmp_path / 'ppi.csv' + toy_ppi.to_csv(filename, index=False) + loaded = read_data.load_ppi(str(filename), interaction_columns=('A', 'B'), + format='auto', verbose=False) + assert list(loaded.columns) == ['A', 'B', 'score'] + + +def test_load_cutoffs(tmp_path, toy_rnaseq): + cutoffs = c2c.preprocessing.get_constant_cutoff(toy_rnaseq, constant_cutoff=5) + frame = cutoffs.reset_index() + filename = tmp_path / 'cutoffs.csv' + frame.to_csv(filename, index=False) + loaded = read_data.load_cutoffs(str(filename), gene_column='gene_id', format='auto', + verbose=False) + assert 'value' in loaded.columns + + +# --------------------------------------------------------------------------------- +# Tensors +# --------------------------------------------------------------------------------- + +def test_load_tensor_roundtrip(tmp_path, factorized_tensor): + filename = str(tmp_path / 'tensor.pkl') + save_data.export_variable_with_pickle(factorized_tensor, filename) + loaded = read_data.load_tensor(filename) + assert np.allclose(np.asarray(loaded.tensor), np.asarray(factorized_tensor.tensor)) + assert [list(o) for o in loaded.order_names] == \ + [list(o) for o in factorized_tensor.order_names] + + +def test_load_tensor_factors_roundtrip(tmp_path, factorized_tensor): + filename = str(tmp_path / 'factors.xlsx') + factorized_tensor.export_factor_loadings(filename) + loaded = read_data.load_tensor_factors(filename) + assert list(loaded.keys()) == list(factorized_tensor.factors.keys()) + for key, frame in loaded.items(): + expected = factorized_tensor.factors[key] + assert list(frame.index) == list(expected.index) + assert np.allclose(frame.values, expected.values) diff --git a/tests/test_plotting.py b/tests/test_plotting.py new file mode 100644 index 0000000..00f96db --- /dev/null +++ b/tests/test_plotting.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- + +'''Smoke tests for cell2cell.plotting. + +Plots are only checked for running without error and returning a figure/axes; no +image comparison is done. The Agg backend and the figure teardown come from conftest. +''' + +import matplotlib +import numpy as np +import pandas as pd +import pytest +from matplotlib import pyplot as plt + +import cell2cell as c2c +from cell2cell.analysis.tensor_downstream import get_factor_specific_ccc_networks + + +# --------------------------------------------------------------------------------- +# aesthetics +# --------------------------------------------------------------------------------- + +def test_get_colors_from_labels(): + labels = ['a', 'b', 'c'] + colors = c2c.plotting.get_colors_from_labels(labels) + assert set(colors.keys()) == set(labels) + for value in colors.values(): + assert len(value) == 4 # RGBA + + +def test_get_colors_from_labels_are_distinct(): + colors = c2c.plotting.get_colors_from_labels(['a', 'b', 'c', 'd']) + assert len({tuple(v) for v in colors.values()}) == 4 + + +def test_get_colors_from_labels_single_label(): + colors = c2c.plotting.get_colors_from_labels(['only']) + assert len(colors) == 1 + + +def test_map_colors_to_metadata(toy_metadata, toy_rnaseq): + colors = c2c.plotting.map_colors_to_metadata(metadata=toy_metadata, + ref_df=toy_rnaseq, + sample_col='#SampleID', + group_col='Groups') + assert len(colors) == toy_rnaseq.shape[1] + + +def test_generate_legend_returns_a_legend(): + colors = c2c.plotting.get_colors_from_labels(['a', 'b']) + fig, ax = plt.subplots() + legend = c2c.plotting.generate_legend(colors, ax=ax) + assert legend is not None + + +def test_generate_legend_sorted_labels_are_natural(): + colors = c2c.plotting.get_colors_from_labels(['CT-10', 'CT-2', 'CT-1']) + fig, ax = plt.subplots() + legend = c2c.plotting.generate_legend(colors, ax=ax, sorted_labels=True) + texts = [t.get_text() for t in legend.get_texts()] + assert texts == ['CT-1', 'CT-2', 'CT-10'] + + +# --------------------------------------------------------------------------------- +# clustermaps +# --------------------------------------------------------------------------------- + +def test_clustermap_cci(bulk_interactions, toy_metadata): + grid = c2c.plotting.clustermap_cci(bulk_interactions, metadata=toy_metadata, + sample_col='#SampleID', group_col='Groups') + assert grid is not None + + +def test_clustermap_cci_without_metadata(bulk_interactions): + grid = c2c.plotting.clustermap_cci(bulk_interactions) + assert grid is not None + + +def test_clustermap_ccc(bulk_interactions, toy_metadata): + grid = c2c.plotting.clustermap_ccc(bulk_interactions, metadata=toy_metadata, + sample_col='#SampleID', group_col='Groups') + assert grid is not None + + +# --------------------------------------------------------------------------------- +# circos +# --------------------------------------------------------------------------------- + +def test_circos_plot(bulk_interactions, toy_metadata): + result = c2c.plotting.circos_plot(interaction_space=bulk_interactions, + sender_cells=['C1', 'C2'], + receiver_cells=['C3', 'C4'], + ligands=['Protein-A'], + receptors=['Protein-B'], + metadata=toy_metadata, + sample_col='#SampleID', group_col='Groups', + excluded_score=-1) + assert result is not None + + +# --------------------------------------------------------------------------------- +# factor plots +# --------------------------------------------------------------------------------- + +def test_ccc_networks_plot(factorized_tensor): + fig, axes = c2c.plotting.ccc_networks_plot(factorized_tensor.factors, + included_factors=['Factor 1', 'Factor 2'], + ccc_threshold=0.01, nrows=1, + panel_size=(3, 3)) + assert fig is not None + assert len(np.ravel(axes)) >= 2 + + +def test_ccc_networks_plot_all_factors(factorized_tensor): + fig, axes = c2c.plotting.ccc_networks_plot(factorized_tensor.factors, + panel_size=(3, 3)) + assert fig is not None + + +def test_context_boxplot(factorized_tensor): + contexts = list(factorized_tensor.order_names[0]) + metadict = {name: ('early' if i % 2 else 'late') + for i, name in enumerate(contexts)} + fig, axes = c2c.plotting.context_boxplot(factorized_tensor.factors['Contexts'], + metadict=metadict, nrows=1, + statistical_test=None) + assert fig is not None + + +def test_context_boxplot_group_order_is_natural(factorized_tensor): + contexts = list(factorized_tensor.order_names[0]) + metadict = {name: 'G-{}'.format((i % 3) + 1) for i, name in enumerate(contexts)} + fig, axes = c2c.plotting.context_boxplot(factorized_tensor.factors['Contexts'], + metadict=metadict, nrows=1, + statistical_test=None) + assert fig is not None + + +def test_loading_clustermap(factorized_tensor): + grid = c2c.plotting.loading_clustermap(factorized_tensor.factors['Sender Cells'], + use_zscore=False) + assert grid is not None + + +def test_loading_clustermap_with_zscore(factorized_tensor): + grid = c2c.plotting.loading_clustermap(factorized_tensor.factors['Ligand-Receptor Pairs'], + use_zscore=True) + assert grid is not None + + +# --------------------------------------------------------------------------------- +# tensor plots +# --------------------------------------------------------------------------------- + +def test_tensor_factors_plot(factorized_tensor): + fig, axes = c2c.plotting.tensor_factors_plot(factorized_tensor, + order_labels=list(factorized_tensor.factors.keys())) + assert fig is not None + + +def test_tensor_factors_plot_from_loadings(factorized_tensor): + fig, axes = c2c.plotting.tensor_factors_plot_from_loadings( + factorized_tensor.factors, + order_labels=list(factorized_tensor.factors.keys())) + assert fig is not None + + +def test_plot_elbow(): + from cell2cell.plotting.tensor_plot import plot_elbow + loss = [(1, 0.9), (2, 0.5), (3, 0.4), (4, 0.38)] + fig = plot_elbow(loss, elbow=2) + assert fig is not None + + +def test_reorder_dimension_elements(factorized_tensor): + '''Returns a (reordered_factors, new_metadata) tuple; metadata may be None. + + The `metadata=None` default used to crash, because `metadata.copy()` ran + unconditionally even though the lines after it already guard with + `if new_metadata is not None`. + ''' + from cell2cell.plotting.tensor_plot import reorder_dimension_elements + cells = list(factorized_tensor.order_names[2]) + reordered, metadata = reorder_dimension_elements(factorized_tensor.factors, + {'Sender Cells': cells[::-1]}) + assert metadata is None + assert list(reordered['Sender Cells'].index) == cells[::-1] + # The other dimensions keep their original order + assert list(reordered['Receiver Cells'].index) == cells + + +def test_reorder_dimension_elements_with_metadata(factorized_tensor): + from cell2cell.plotting.tensor_plot import reorder_dimension_elements + cells = list(factorized_tensor.order_names[2]) + metadata = c2c.tensor.generate_tensor_metadata( + interaction_tensor=factorized_tensor, + metadata_dicts=[None, None, None, None], + fill_with_order_elements=True) + reordered, new_metadata = reorder_dimension_elements( + factorized_tensor.factors, {'Sender Cells': cells[::-1]}, metadata=metadata) + assert list(reordered['Sender Cells'].index) == cells[::-1] + assert list(new_metadata[2]['Element']) == cells[::-1] + + +def test_reorder_dimension_elements_rejects_unknown_dimension(factorized_tensor): + from cell2cell.plotting.tensor_plot import reorder_dimension_elements + with pytest.raises(AssertionError): + reorder_dimension_elements(factorized_tensor.factors, {'Not-A-Dimension': []}) + + +# --------------------------------------------------------------------------------- +# pcoa and umap +# --------------------------------------------------------------------------------- + +def test_pcoa_3dplot(bulk_interactions, toy_metadata): + fig = c2c.plotting.pcoa_3dplot(interaction_space=bulk_interactions, + metadata=toy_metadata, sample_col='#SampleID', + group_col='Groups') + assert fig is not None + + +def test_umap_biplot(toy_rnaseq): + umap_df = c2c.external.run_umap(toy_rnaseq, axis=1, n_neighbors=3, random_state=0) + fig = c2c.plotting.umap_biplot(umap_df) + assert fig is not None + + +# --------------------------------------------------------------------------------- +# dot plots +# --------------------------------------------------------------------------------- + +def test_generate_dot_plot(): + index = ['LR-1', 'LR-2'] + columns = ['C1 --> C2', 'C1 --> C3'] + pvals = pd.DataFrame([[0.01, 0.2], [0.3, 0.001]], index=index, columns=columns) + scores = pd.DataFrame([[1.0, 0.5], [0.2, 0.9]], index=index, columns=columns) + fig = c2c.plotting.generate_dot_plot(pval_df=pvals, score_df=scores) + assert fig is not None + + +def test_generate_dot_plot_sizes_the_dots_by_significance(): + '''Dot sizes come from a -log10 transform of the p-values, which used to be computed + with the `applymap` method that pandas 3.0 removed. The other dot plot tests only + assert that a figure comes back, so this one checks the dots that were drawn.''' + index = ['LR-1', 'LR-2'] + columns = ['C1 --> C2', 'C1 --> C3'] + pvals = pd.DataFrame([[0.01, 0.2], [0.3, 0.001]], index=index, columns=columns) + scores = pd.DataFrame([[1.0, 0.5], [0.2, 0.9]], index=index, columns=columns) + fig = c2c.plotting.generate_dot_plot(pval_df=pvals, score_df=scores, significance=1.0) + + # Every dot is scattered individually, row by row, on the main (second) subplot + main_ax = fig.axes[1] + sizes = np.array([collection.get_sizes()[0] for collection in main_ax.collections]) + assert len(sizes) == pvals.size + + # A more significant interaction must get a strictly bigger dot + flat_pvals = pvals.values.ravel() + assert sizes.argmax() == flat_pvals.argmin() + by_significance = np.argsort(flat_pvals) + assert list(sizes[by_significance]) == sorted(sizes, reverse=True) + + +@pytest.mark.slow +def test_dot_plot(toy_single_cells, toy_ppi): + rnaseq, metadata = toy_single_cells + interactions = c2c.analysis.SingleCellInteractions( + rnaseq_data=rnaseq, ppi_data=toy_ppi, metadata=metadata, + barcode_col='barcodes', celltype_col='cell_types', + communication_score='expression_product', complex_sep=None, verbose=False) + interactions.compute_pairwise_communication_scores(verbose=False) + interactions.permute_cell_labels(evaluation='communication', permutations=10, + random_state=0, verbose=False) + # significance=1.0 keeps every interaction; with a stricter cutoff the toy data + # can leave nothing to plot, and the underlying plotting code cannot handle an + # entirely empty frame. + fig = c2c.plotting.dot_plot(interactions, evaluation='communication', + significance=1.0) + assert fig is not None diff --git a/tests/test_preprocessing_cutoffs.py b/tests/test_preprocessing_cutoffs.py new file mode 100644 index 0000000..86c6eb2 --- /dev/null +++ b/tests/test_preprocessing_cutoffs.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.cutoffs''' + +import numpy as np +import pytest + +from cell2cell.preprocessing import cutoffs + + +def test_get_local_percentile_cutoffs_values(toy_rnaseq): + result = cutoffs.get_local_percentile_cutoffs(toy_rnaseq, percentile=0.75) + assert list(result.columns) == ['value'] + assert list(result.index) == list(toy_rnaseq.index) + expected = toy_rnaseq.quantile(0.75, axis=1).values + assert np.allclose(result['value'].values, expected) + + +def test_get_local_percentile_cutoffs_is_per_gene(toy_rnaseq): + '''Each gene gets its own cutoff, so the values must not be all identical.''' + result = cutoffs.get_local_percentile_cutoffs(toy_rnaseq, percentile=0.5) + assert result['value'].nunique() > 1 + + +def test_get_global_percentile_cutoffs_is_a_single_value(toy_rnaseq): + result = cutoffs.get_global_percentile_cutoffs(toy_rnaseq, percentile=0.75) + assert list(result.columns) == ['value'] + assert result['value'].nunique() == 1 + expected = np.percentile(toy_rnaseq.values, 75) + assert np.allclose(result['value'].values, expected) + + +def test_get_constant_cutoff(toy_rnaseq): + result = cutoffs.get_constant_cutoff(toy_rnaseq, constant_cutoff=7) + assert (result['value'] == 7).all() + assert list(result.index) == list(toy_rnaseq.index) + + +@pytest.mark.parametrize('cutoff_type,parameter', [('local_percentile', 0.75), + ('global_percentile', 0.75), + ('constant_value', 12)]) +def test_get_cutoffs_dispatches(toy_rnaseq, cutoff_type, parameter): + parameters = {'type': cutoff_type, 'parameter': parameter} + result = cutoffs.get_cutoffs(toy_rnaseq, parameters, verbose=False) + assert list(result.columns) == ['value'] + assert result.shape[0] == toy_rnaseq.shape[0] + + +def test_get_cutoffs_matches_the_direct_functions(toy_rnaseq): + direct = cutoffs.get_local_percentile_cutoffs(toy_rnaseq, percentile=0.6) + through = cutoffs.get_cutoffs(toy_rnaseq, + {'type': 'local_percentile', 'parameter': 0.6}, + verbose=False) + assert np.allclose(direct['value'].values, through['value'].values) + + +def test_get_cutoffs_rejects_unknown_type(toy_rnaseq): + with pytest.raises(ValueError): + cutoffs.get_cutoffs(toy_rnaseq, {'type': 'nonsense', 'parameter': 1}, + verbose=False) + + +def test_cutoffs_do_not_modify_input(toy_rnaseq): + before = toy_rnaseq.copy() + cutoffs.get_local_percentile_cutoffs(toy_rnaseq) + cutoffs.get_global_percentile_cutoffs(toy_rnaseq) + cutoffs.get_constant_cutoff(toy_rnaseq) + assert toy_rnaseq.equals(before) diff --git a/tests/test_preprocessing_find_elements.py b/tests/test_preprocessing_find_elements.py new file mode 100644 index 0000000..1c1a098 --- /dev/null +++ b/tests/test_preprocessing_find_elements.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.find_elements''' + +import numpy as np + +from cell2cell.preprocessing import find_elements + + +def test_find_duplicates(): + result = find_elements.find_duplicates(['a', 'b', 'a', 'c', 'b', 'a']) + assert result == {'a': [0, 2, 5], 'b': [1, 4]} + + +def test_find_duplicates_without_duplicates(): + assert find_elements.find_duplicates(['a', 'b', 'c']) == {} + + +def test_find_duplicates_on_empty_list(): + assert find_elements.find_duplicates([]) == {} + + +def test_get_element_abundances_fractions(): + result = find_elements.get_element_abundances([['a', 'b'], ['b', 'c'], ['b']]) + assert np.isclose(result['b'], 1.0) + assert np.isclose(result['a'], 1 / 3) + assert np.isclose(result['c'], 1 / 3) + + +def test_get_element_abundances_ignores_repeats_within_a_list(): + result = find_elements.get_element_abundances([['a', 'a', 'a'], ['b']]) + assert np.isclose(result['a'], 0.5) + + +def test_get_element_abundances_key_order_is_first_appearance(): + result = find_elements.get_element_abundances([['z', 'a'], ['m', 'z']]) + assert list(result.keys()) == ['z', 'a', 'm'] + + +def test_get_element_abundances_single_list(): + result = find_elements.get_element_abundances([['a', 'b']]) + assert np.isclose(result['a'], 1.0) + assert np.isclose(result['b'], 1.0) + + +def test_get_elements_over_fraction(): + abundances = {'a': 1.0, 'b': 0.5, 'c': 0.2} + assert find_elements.get_elements_over_fraction(abundances, 0.5) == ['a', 'b'] + assert find_elements.get_elements_over_fraction(abundances, 1.0) == ['a'] + assert find_elements.get_elements_over_fraction(abundances, 0.0) == ['a', 'b', 'c'] + + +def test_get_elements_over_fraction_keeps_the_dict_order(): + abundances = {'z': 1.0, 'a': 1.0} + assert find_elements.get_elements_over_fraction(abundances, 0.5) == ['z', 'a'] + + +def test_get_elements_over_fraction_can_return_nothing(): + assert find_elements.get_elements_over_fraction({'a': 0.1}, 0.9) == [] + + +def test_element_abundance_pipeline_is_reproducible(): + lists = [['b', 'a', 'c'], ['c', 'b', 'z'], ['b', 'c', 'q']] + first = find_elements.get_elements_over_fraction( + find_elements.get_element_abundances(lists), 0.5) + second = find_elements.get_elements_over_fraction( + find_elements.get_element_abundances(lists), 0.5) + assert first == second + + +# --------------------------------------------------------------------------------- +# Reproducibility -- these orders came from an unsorted set() and varied per run +# --------------------------------------------------------------------------------- + +def test_get_element_abundances_keeps_first_appearance_order(): + '''Used to iterate over sets, making the tensor axes vary between runs.''' + element_lists = [['b', 'a', 'c'], ['c', 'b', 'z'], ['b', 'c', 'q']] + abundances = find_elements.get_element_abundances(element_lists) + assert list(abundances.keys()) == ['b', 'a', 'c', 'z', 'q'] + assert np.isclose(abundances['b'], 1.0) + assert np.isclose(abundances['a'], 1 / 3) + + +def test_get_element_abundances_counts_each_list_once(): + abundances = find_elements.get_element_abundances([['a', 'a', 'a'], ['a']]) + assert np.isclose(abundances['a'], 1.0) diff --git a/tests/test_preprocessing_gene_ontology.py b/tests/test_preprocessing_gene_ontology.py new file mode 100644 index 0000000..885b84c --- /dev/null +++ b/tests/test_preprocessing_gene_ontology.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.gene_ontology''' + +import networkx as nx +import pandas as pd +import pytest + +from cell2cell.preprocessing import gene_ontology + + +HEADERS = dict(go_header='go_id', gene_header='db_object_symbol') + + +def test_get_genes_from_go_terms_single_term(go_annotations): + genes = gene_ontology.get_genes_from_go_terms(go_annotations, + go_filter=['GO:0000003'], + verbose=False, **HEADERS) + assert set(genes) == {'Protein-B', 'Protein-E'} + + +def test_get_genes_from_go_terms_multiple_terms(go_annotations): + genes = gene_ontology.get_genes_from_go_terms( + go_annotations, go_filter=['GO:0000002', 'GO:0000004'], verbose=False, **HEADERS) + assert set(genes) == {'Protein-A', 'Protein-C', 'Protein-F'} + + +def test_get_genes_from_go_terms_unknown_term(go_annotations): + genes = gene_ontology.get_genes_from_go_terms(go_annotations, + go_filter=['GO:9999999'], + verbose=False, **HEADERS) + assert genes == [] + + +def test_find_all_children_of_go_term(go_terms_graph): + children = [] + gene_ontology.find_all_children_of_go_term(go_terms_graph, 'GO:0000001', children, + verbose=False) + # GO:0000002 and GO:0000003 are children, GO:0000004 is a grandchild + assert set(children) == {'GO:0000002', 'GO:0000003', 'GO:0000004'} + + +def test_find_all_children_of_a_leaf(go_terms_graph): + children = [] + gene_ontology.find_all_children_of_go_term(go_terms_graph, 'GO:0000004', children, + verbose=False) + assert children == [] + + +def test_get_genes_from_go_hierarchy_includes_descendants(go_annotations, + go_terms_graph): + genes = gene_ontology.get_genes_from_go_hierarchy( + go_annotations=go_annotations, go_terms=go_terms_graph, + go_filter=['GO:0000001'], verbose=False, **HEADERS) + # The root plus all of its descendants covers every annotated gene + assert set(genes) == {'Protein-A', 'Protein-B', 'Protein-C', 'Protein-D', + 'Protein-E', 'Protein-F'} + + +def test_get_genes_from_go_hierarchy_of_a_subtree(go_annotations, go_terms_graph): + genes = gene_ontology.get_genes_from_go_hierarchy( + go_annotations=go_annotations, go_terms=go_terms_graph, + go_filter=['GO:0000002'], verbose=False, **HEADERS) + # GO:0000002 annotates Protein-A, its child GO:0000004 annotates C and F + assert set(genes) == {'Protein-A', 'Protein-C', 'Protein-F'} + + +def test_get_genes_from_go_hierarchy_is_reproducible(go_annotations, go_terms_graph): + kwargs = dict(go_annotations=go_annotations, go_terms=go_terms_graph, + go_filter=['GO:0000001'], verbose=False, **HEADERS) + assert (gene_ontology.get_genes_from_go_hierarchy(**kwargs) == + gene_ontology.get_genes_from_go_hierarchy(**kwargs)) + + +def test_find_go_terms_from_keyword(go_terms_graph): + terms = gene_ontology.find_go_terms_from_keyword(go_terms_graph, 'adhesion', + verbose=False) + assert terms == ['GO:0000002'] + + +def test_find_go_terms_from_keyword_without_matches(go_terms_graph): + assert gene_ontology.find_go_terms_from_keyword(go_terms_graph, 'nonsense', + verbose=False) == [] + + +def test_find_go_terms_from_keyword_matches_several(go_terms_graph): + terms = gene_ontology.find_go_terms_from_keyword(go_terms_graph, 'toy', + verbose=False) + assert len(terms) == 4 diff --git a/tests/test_preprocessing_integrate_data.py b/tests/test_preprocessing_integrate_data.py new file mode 100644 index 0000000..9746365 --- /dev/null +++ b/tests/test_preprocessing_integrate_data.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.integrate_data''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.preprocessing import cutoffs as cutoffs_module +from cell2cell.preprocessing import integrate_data + + +@pytest.fixture +def constant_cutoffs(toy_rnaseq): + return cutoffs_module.get_constant_cutoff(toy_rnaseq, constant_cutoff=10) + + +# --------------------------------------------------------------------------------- +# get_thresholded_rnaseq +# --------------------------------------------------------------------------------- + +def test_get_thresholded_rnaseq_is_binary(toy_rnaseq, constant_cutoffs): + result = integrate_data.get_thresholded_rnaseq(toy_rnaseq, constant_cutoffs) + assert set(np.unique(result.values)).issubset({0.0, 1.0}) + assert result.shape == toy_rnaseq.shape + + +def test_get_thresholded_rnaseq_uses_strictly_greater_than(toy_rnaseq, constant_cutoffs): + result = integrate_data.get_thresholded_rnaseq(toy_rnaseq, constant_cutoffs) + expected = (toy_rnaseq > 10).astype(float) + pd.testing.assert_frame_equal(result, expected) + + +def test_get_thresholded_rnaseq_accepts_per_cell_cutoffs(toy_rnaseq): + per_cell = pd.DataFrame(10.0, index=toy_rnaseq.index, columns=toy_rnaseq.columns) + per_cell['C1'] = 0.0 # everything in C1 passes + result = integrate_data.get_thresholded_rnaseq(toy_rnaseq, per_cell) + assert (result['C1'] == 1.0).all() + + +def test_get_thresholded_rnaseq_rejects_mismatched_cutoffs(toy_rnaseq): + bad = pd.DataFrame({'unexpected': [1.0] * toy_rnaseq.shape[0]}, index=toy_rnaseq.index) + with pytest.raises(KeyError): + integrate_data.get_thresholded_rnaseq(toy_rnaseq, bad) + + +def test_get_thresholded_rnaseq_does_not_modify_input(toy_rnaseq, constant_cutoffs): + before = toy_rnaseq.copy() + integrate_data.get_thresholded_rnaseq(toy_rnaseq, constant_cutoffs) + pd.testing.assert_frame_equal(toy_rnaseq, before) + + +# --------------------------------------------------------------------------------- +# get_modified_rnaseq +# --------------------------------------------------------------------------------- + +def test_get_modified_rnaseq_thresholding(toy_rnaseq, constant_cutoffs): + result = integrate_data.get_modified_rnaseq(toy_rnaseq, cutoffs=constant_cutoffs, + communication_score='expression_thresholding') + expected = integrate_data.get_thresholded_rnaseq(toy_rnaseq, constant_cutoffs) + pd.testing.assert_frame_equal(result, expected) + + +@pytest.mark.parametrize('score', ['expression_product', 'expression_mean', + 'expression_gmean']) +def test_get_modified_rnaseq_continuous_scores_are_a_copy(toy_rnaseq, score): + result = integrate_data.get_modified_rnaseq(toy_rnaseq, communication_score=score) + pd.testing.assert_frame_equal(result, toy_rnaseq) + assert result is not toy_rnaseq + + +def test_get_modified_rnaseq_rejects_unknown_score(toy_rnaseq): + with pytest.raises(NotImplementedError): + integrate_data.get_modified_rnaseq(toy_rnaseq, communication_score='nonsense') + + +# --------------------------------------------------------------------------------- +# get_weighted_ppi +# --------------------------------------------------------------------------------- + +def test_get_weighted_ppi_replaces_genes_with_expression(toy_rnaseq, toy_ppi): + modified = toy_rnaseq[['C1']].rename(columns={'C1': 'value'}) + result = integrate_data.get_weighted_ppi(toy_ppi, modified, column='value', + interaction_columns=('A', 'B')) + assert list(result.columns) == ['A', 'B', 'score'] + assert result.shape[0] == toy_ppi.shape[0] + for i, row in toy_ppi.iterrows(): + assert np.isclose(result.loc[i, 'A'], modified.at[row['A'], 'value']) + assert np.isclose(result.loc[i, 'B'], modified.at[row['B'], 'value']) + + +def test_get_weighted_ppi_does_not_modify_input(toy_rnaseq, toy_ppi): + modified = toy_rnaseq[['C1']].rename(columns={'C1': 'value'}) + before = toy_ppi.copy() + integrate_data.get_weighted_ppi(toy_ppi, modified) + pd.testing.assert_frame_equal(toy_ppi, before) + + +# --------------------------------------------------------------------------------- +# get_ppi_dict_from_proteins +# --------------------------------------------------------------------------------- + +def test_get_ppi_dict_from_proteins_contacts_only(toy_ppi): + result = integrate_data.get_ppi_dict_from_proteins( + toy_ppi, contact_proteins=['Protein-A', 'Protein-B'], + interaction_columns=('A', 'B'), verbose=False) + assert 'contacts' in result + for df in result.values(): + assert list(df.columns)[:2] == ['A', 'B'] + + +def test_get_ppi_dict_from_proteins_with_mediators(toy_ppi): + result = integrate_data.get_ppi_dict_from_proteins( + toy_ppi, contact_proteins=['Protein-A', 'Protein-B'], + mediator_proteins=['Protein-E', 'Protein-F'], + interaction_columns=('A', 'B'), verbose=False) + assert 'contacts' in result and 'mediated' in result + assert 'combined' in result + + +def test_get_ppi_dict_from_proteins_is_reproducible(toy_ppi): + kwargs = dict(contact_proteins=['Protein-B', 'Protein-A'], + mediator_proteins=['Protein-F', 'Protein-E'], + interaction_columns=('A', 'B'), verbose=False) + first = integrate_data.get_ppi_dict_from_proteins(toy_ppi, **kwargs) + second = integrate_data.get_ppi_dict_from_proteins(toy_ppi, **kwargs) + assert list(first.keys()) == list(second.keys()) + for key in first: + pd.testing.assert_frame_equal(first[key], second[key]) + + +# --------------------------------------------------------------------------------- +# get_ppi_dict_from_go_terms +# --------------------------------------------------------------------------------- + +def test_get_ppi_dict_from_go_terms(toy_ppi, go_terms_graph, go_annotations): + result = integrate_data.get_ppi_dict_from_go_terms( + ppi_data=toy_ppi, + go_annotations=go_annotations, + go_terms=go_terms_graph, + contact_go_terms=['GO:0000002'], + mediator_go_terms=['GO:0000003'], + go_header='go_id', + gene_header='db_object_symbol', + interaction_columns=('A', 'B'), + verbose=False) + assert 'contacts' in result and 'mediated' in result diff --git a/tests/test_preprocessing_manipulate_dataframes.py b/tests/test_preprocessing_manipulate_dataframes.py new file mode 100644 index 0000000..2611612 --- /dev/null +++ b/tests/test_preprocessing_manipulate_dataframes.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.manipulate_dataframes''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.preprocessing import manipulate_dataframes as manipulate + + +# --------------------------------------------------------------------------------- +# check_presence_in_dataframe +# --------------------------------------------------------------------------------- + +def test_check_presence_in_dataframe_finds_elements(toy_ppi): + found = manipulate.check_presence_in_dataframe(toy_ppi, ['Protein-A', 'Not-A-Gene'], + columns=['A', 'B']) + assert found == ['Protein-A'] + + +def test_check_presence_in_dataframe_defaults_to_all_columns(toy_ppi): + found = manipulate.check_presence_in_dataframe(toy_ppi, ['Protein-F']) + assert 'Protein-F' in found + + +def test_check_presence_in_dataframe_returns_nothing_when_absent(toy_ppi): + assert manipulate.check_presence_in_dataframe(toy_ppi, ['Nope'], columns=['A']) == [] + + +@pytest.mark.parametrize('dtype', [object, 'string']) +def test_check_presence_in_dataframe_accepts_a_single_column_name(toy_ppi, dtype): + '''The documented argument is a list, but a bare column name is normalized instead of + being handed to pandas as-is, which would return a Series. Both the object and the + "string" dtype are covered, since pandas >= 3.0 makes the latter the default.''' + ppi_data = toy_ppi.copy() + ppi_data[['A', 'B']] = ppi_data[['A', 'B']].astype(dtype) + found = manipulate.check_presence_in_dataframe(ppi_data, ['Protein-A'], columns='A') + assert found == ['Protein-A'] + + +# --------------------------------------------------------------------------------- +# Shuffling +# --------------------------------------------------------------------------------- + +def test_shuffle_cols_in_df_is_seeded(toy_rnaseq): + first = manipulate.shuffle_cols_in_df(toy_rnaseq, columns=['C1'], random_state=0) + second = manipulate.shuffle_cols_in_df(toy_rnaseq, columns=['C1'], random_state=0) + pd.testing.assert_frame_equal(first, second) + + +def test_shuffle_cols_in_df_preserves_the_multiset(toy_rnaseq): + result = manipulate.shuffle_cols_in_df(toy_rnaseq, columns=['C1'], random_state=0) + assert sorted(result['C1'].values) == sorted(toy_rnaseq['C1'].values) + # Untouched columns stay identical + assert np.allclose(result['C2'].values, toy_rnaseq['C2'].values) + + +def test_shuffle_rows_in_df_preserves_the_multiset(toy_rnaseq): + result = manipulate.shuffle_rows_in_df(toy_rnaseq, rows=['Protein-A'], random_state=0) + assert sorted(result.loc['Protein-A'].values) == sorted(toy_rnaseq.loc['Protein-A'].values) + assert np.allclose(result.loc['Protein-B'].values, toy_rnaseq.loc['Protein-B'].values) + + +def test_shuffle_rows_in_df_is_seeded(toy_rnaseq): + first = manipulate.shuffle_rows_in_df(toy_rnaseq, rows=['Protein-A'], random_state=3) + second = manipulate.shuffle_rows_in_df(toy_rnaseq, rows=['Protein-A'], random_state=3) + pd.testing.assert_frame_equal(first, second) + + +@pytest.mark.parametrize('axis', [0, 1]) +def test_shuffle_dataframe_preserves_shape_and_values(toy_rnaseq, axis): + result = manipulate.shuffle_dataframe(toy_rnaseq, axis=axis, random_state=0) + assert result.shape == toy_rnaseq.shape + assert sorted(result.values.flatten()) == sorted(toy_rnaseq.values.flatten()) + assert list(result.index) == list(toy_rnaseq.index) + assert list(result.columns) == list(toy_rnaseq.columns) + + +def test_shuffle_dataframe_is_seeded(toy_rnaseq): + first = manipulate.shuffle_dataframe(toy_rnaseq, random_state=1) + second = manipulate.shuffle_dataframe(toy_rnaseq, random_state=1) + pd.testing.assert_frame_equal(first, second) + + +def test_shuffle_dataframe_actually_shuffles(toy_rnaseq): + result = manipulate.shuffle_dataframe(toy_rnaseq, shuffling_number=5, random_state=0) + assert not np.allclose(result.values, toy_rnaseq.values) + + +def test_shuffling_does_not_modify_input(toy_rnaseq): + before = toy_rnaseq.copy() + manipulate.shuffle_dataframe(toy_rnaseq, random_state=0) + manipulate.shuffle_cols_in_df(toy_rnaseq, columns=['C1'], random_state=0) + manipulate.shuffle_rows_in_df(toy_rnaseq, rows=['Protein-A'], random_state=0) + pd.testing.assert_frame_equal(toy_rnaseq, before) + + +# --------------------------------------------------------------------------------- +# subsample_dataframe +# --------------------------------------------------------------------------------- + +def test_subsample_dataframe_size_and_membership(toy_rnaseq): + result = manipulate.subsample_dataframe(toy_rnaseq, n_samples=3, random_state=0) + assert result.shape[0] == 3 + assert set(result.index).issubset(set(toy_rnaseq.index)) + + +def test_subsample_dataframe_is_seeded(toy_rnaseq): + first = manipulate.subsample_dataframe(toy_rnaseq, n_samples=3, random_state=7) + second = manipulate.subsample_dataframe(toy_rnaseq, n_samples=3, random_state=7) + pd.testing.assert_frame_equal(first, second) + + +def test_subsample_dataframe_with_full_size(toy_rnaseq): + result = manipulate.subsample_dataframe(toy_rnaseq, n_samples=toy_rnaseq.shape[0], + random_state=0) + assert set(result.index) == set(toy_rnaseq.index) + + +# --------------------------------------------------------------------------------- +# check_symmetry / convert_to_distance_matrix +# --------------------------------------------------------------------------------- + +def test_check_symmetry_true(toy_distance): + assert manipulate.check_symmetry(toy_distance) + + +def test_check_symmetry_false(toy_distance): + asymmetric = toy_distance.copy() + asymmetric.iloc[0, 1] = 999.0 + assert not manipulate.check_symmetry(asymmetric) + + +def test_convert_to_distance_matrix_zeroes_the_diagonal(toy_distance): + similarity = 1 - toy_distance / toy_distance.values.max() + result = manipulate.convert_to_distance_matrix(similarity) + assert np.allclose(np.diag(result.values), 0.0) + assert result.shape == toy_distance.shape + + +# --------------------------------------------------------------------------------- +# zero_diagonal +# --------------------------------------------------------------------------------- + +def test_zero_diagonal_zeroes_the_diagonal_and_keeps_the_rest(toy_distance): + similarity = 1 - toy_distance / toy_distance.values.max() + result = manipulate.zero_diagonal(similarity) + assert np.allclose(np.diag(result.values), 0.0) + # Off-diagonal values are untouched + off_diagonal = ~np.eye(similarity.shape[0], dtype=bool) + assert np.allclose(result.values[off_diagonal], similarity.values[off_diagonal]) + + +def test_zero_diagonal_keeps_the_labels(toy_distance): + result = manipulate.zero_diagonal(toy_distance) + assert list(result.index) == list(toy_distance.index) + assert list(result.columns) == list(toy_distance.columns) + + +def test_zero_diagonal_does_not_modify_its_input(toy_distance): + similarity = 1 - toy_distance / toy_distance.values.max() + before = similarity.copy() + manipulate.zero_diagonal(similarity) + pd.testing.assert_frame_equal(similarity, before) + + +# --------------------------------------------------------------------------------- +# Dataframes whose `.values` array is read-only +# +# pandas >= 3.0 enforces copy-on-write, so `DataFrame.values` returns a read-only array +# and `np.fill_diagonal`/`np.random.shuffle` on it raise "underlying array is read-only". +# A `df.copy()` does not help, since the copy's `.values` is read-only as well. +# --------------------------------------------------------------------------------- + +def test_zero_diagonal_accepts_a_read_only_frame(read_only_frame, toy_distance): + similarity = read_only_frame(1 - toy_distance / toy_distance.values.max(), + labels=list(toy_distance.index)) + result = manipulate.zero_diagonal(similarity) + assert np.allclose(np.diag(result.values), 0.0) + + +def test_convert_to_distance_matrix_accepts_a_read_only_frame(read_only_frame, toy_distance): + similarity = read_only_frame(1 - toy_distance / toy_distance.values.max(), + labels=list(toy_distance.index)) + with pytest.warns(UserWarning): + result = manipulate.convert_to_distance_matrix(similarity) + assert np.allclose(np.diag(result.values), 0.0) + + +def test_shuffle_dataframe_accepts_a_read_only_frame(read_only_frame, toy_rnaseq): + frame = read_only_frame(toy_rnaseq) + result = manipulate.shuffle_dataframe(frame, random_state=0) + assert result.shape == frame.shape + assert sorted(result.values.flatten()) == sorted(frame.values.flatten()) + + +# --------------------------------------------------------------------------------- +# convert_to_distance_matrix raised instead of warning +# +# `raise Warning(...)` aborts, so the diagonal was never "automatically replaced by +# zeros" as the message claimed. This broke the public `pcoa()` for any similarity +# or correlation matrix, since pcoa calls it unconditionally. +# --------------------------------------------------------------------------------- + +def test_convert_to_distance_matrix_replaces_a_non_zero_diagonal(toy_distance): + similarity = 1 - toy_distance / toy_distance.values.max() + assert not np.allclose(np.diag(similarity.values), 0.0) + + with pytest.warns(UserWarning): + result = manipulate.convert_to_distance_matrix(similarity) + assert np.allclose(np.diag(result.values), 0.0) + + +def test_convert_to_distance_matrix_still_rejects_asymmetric_input(toy_distance): + asymmetric = toy_distance.copy() + asymmetric.iloc[0, 1] = 999.0 + with pytest.raises(ValueError): + manipulate.convert_to_distance_matrix(asymmetric) + + +# --------------------------------------------------------------------------------- +# check_presence_in_dataframe crashed on mixed data types +# +# It sorted the values with np.unique, which cannot compare strings to floats, so +# the documented `columns=None` default failed on any dataframe holding both. +# --------------------------------------------------------------------------------- + +def test_check_presence_in_dataframe_handles_mixed_dtypes(toy_ppi): + # toy_ppi mixes gene names with a float 'score' column + assert toy_ppi.dtypes.nunique() > 1 + found = manipulate.check_presence_in_dataframe(toy_ppi, ['Protein-F']) + assert found == ['Protein-F'] diff --git a/tests/test_preprocessing_ppi.py b/tests/test_preprocessing_ppi.py new file mode 100644 index 0000000..62a1c94 --- /dev/null +++ b/tests/test_preprocessing_ppi.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.ppi''' + +import numpy as np +import pandas as pd +import pytest + +from cell2cell.preprocessing import ppi as ppi_module + + +COLUMNS = ('A', 'B') + + +# --------------------------------------------------------------------------------- +# remove_ppi_bidirectionality +# --------------------------------------------------------------------------------- + +def test_remove_ppi_bidirectionality_keeps_one_direction(): + ppi = pd.DataFrame({'A': ['G1', 'G2', 'G3'], 'B': ['G2', 'G1', 'G4']}) + result = ppi_module.remove_ppi_bidirectionality(ppi, COLUMNS, verbose=False) + pairs = set(zip(result['A'], result['B'])) + assert len(pairs & {('G1', 'G2'), ('G2', 'G1')}) == 1 + assert ('G3', 'G4') in pairs + + +def test_remove_ppi_bidirectionality_keeps_unidirectional_interactions(toy_ppi): + result = ppi_module.remove_ppi_bidirectionality(toy_ppi, COLUMNS, verbose=False) + assert result.shape[0] <= toy_ppi.shape[0] + assert result.shape[0] > 0 + + +def test_remove_ppi_bidirectionality_is_idempotent(toy_ppi): + once = ppi_module.remove_ppi_bidirectionality(toy_ppi, COLUMNS, verbose=False) + twice = ppi_module.remove_ppi_bidirectionality(once, COLUMNS, verbose=False) + assert once.shape[0] == twice.shape[0] + + +def test_remove_ppi_bidirectionality_preserves_self_interactions(): + ppi = pd.DataFrame({'A': ['G1'], 'B': ['G1']}) + result = ppi_module.remove_ppi_bidirectionality(ppi, COLUMNS, verbose=False) + assert result.shape[0] == 1 + + +# --------------------------------------------------------------------------------- +# simplify_ppi / preprocess_ppi_data +# --------------------------------------------------------------------------------- + +def test_simplify_ppi_renames_to_abscore(toy_ppi): + result = ppi_module.simplify_ppi(toy_ppi, COLUMNS, verbose=False) + assert list(result.columns) == ['A', 'B', 'score'] + assert result.shape[0] == toy_ppi.shape[0] + + +def test_simplify_ppi_default_score_is_one(toy_ppi): + result = ppi_module.simplify_ppi(toy_ppi, COLUMNS, verbose=False) + assert (result['score'] == 1.0).all() + + +def test_simplify_ppi_uses_an_existing_column_as_the_score(toy_ppi): + '''`score` names a column in ppi_data, it is not a constant value.''' + weighted = toy_ppi.copy() + weighted['weight'] = np.linspace(0.1, 1.0, weighted.shape[0]) + result = ppi_module.simplify_ppi(weighted, COLUMNS, score='weight', verbose=False) + assert np.allclose(result['score'].values, weighted['weight'].values) + + +def test_simplify_ppi_fills_missing_scores_with_the_minimum(toy_ppi): + weighted = toy_ppi.copy() + weighted['weight'] = [0.4] * weighted.shape[0] + weighted.loc[0, 'weight'] = np.nan + result = ppi_module.simplify_ppi(weighted, COLUMNS, score='weight', verbose=False) + assert np.isclose(result.loc[0, 'score'], 0.4) + assert not result['score'].isna().any() + + +def test_preprocess_ppi_data_sorts_when_requested(toy_ppi): + result = ppi_module.preprocess_ppi_data(toy_ppi, COLUMNS, sort_values='A', + verbose=False) + assert list(result['A']) == sorted(result['A']) + + +def test_preprocess_ppi_data_filters_by_genes(toy_ppi): + genes = ['Protein-A', 'Protein-B'] + result = ppi_module.preprocess_ppi_data(toy_ppi, COLUMNS, rnaseq_genes=genes, + verbose=False) + # Names are upper-cased, since upper_letter_comparison defaults to True + found = set(result['A']).union(result['B']) + assert found.issubset({gene.upper() for gene in genes}) + + +def test_preprocess_ppi_data_removes_duplicates(): + ppi = pd.DataFrame({'A': ['G1', 'G1'], 'B': ['G2', 'G2'], 'score': [1.0, 1.0]}) + result = ppi_module.preprocess_ppi_data(ppi, COLUMNS, verbose=False) + assert result.shape[0] == 1 + + +# --------------------------------------------------------------------------------- +# filter_ppi_by_proteins +# --------------------------------------------------------------------------------- + +def test_filter_ppi_by_proteins_keeps_only_listed_proteins(toy_ppi): + proteins = ['Protein-A', 'Protein-B'] + result = ppi_module.filter_ppi_by_proteins(toy_ppi, proteins, + interaction_columns=COLUMNS) + # upper_letter_comparison=True also upper-cases the names in the output + found = set(result['A']).union(result['B']) + assert found.issubset({p.upper() for p in proteins}) + + +def test_filter_ppi_by_proteins_preserves_case_when_not_comparing_upper(toy_ppi): + proteins = ['Protein-A', 'Protein-B'] + result = ppi_module.filter_ppi_by_proteins(toy_ppi, proteins, + upper_letter_comparison=False, + interaction_columns=COLUMNS) + assert set(result['A']).union(result['B']).issubset(set(proteins)) + + +def test_filter_ppi_by_proteins_is_case_insensitive_when_asked(toy_ppi): + result = ppi_module.filter_ppi_by_proteins(toy_ppi, ['protein-a', 'protein-b'], + upper_letter_comparison=True, + interaction_columns=COLUMNS) + assert result.shape[0] > 0 + + +def test_filter_ppi_by_proteins_case_sensitive(toy_ppi): + result = ppi_module.filter_ppi_by_proteins(toy_ppi, ['protein-a'], + upper_letter_comparison=False, + interaction_columns=COLUMNS) + assert result.shape[0] == 0 + + +def test_filter_ppi_by_proteins_with_complexes(toy_ppi_complex): + result = ppi_module.filter_ppi_by_proteins(toy_ppi_complex, + ['Protein-C', 'Protein-E', 'Protein-F'], + complex_sep='&', + interaction_columns=COLUMNS) + assert result.shape[0] > 0 + + +def test_filter_ppi_by_proteins_with_nothing_matching(toy_ppi): + result = ppi_module.filter_ppi_by_proteins(toy_ppi, ['Not-A-Gene'], + interaction_columns=COLUMNS) + assert result.shape[0] == 0 + + +# --------------------------------------------------------------------------------- +# Complexes +# --------------------------------------------------------------------------------- + +def test_get_genes_from_complexes_returns_five_collections(toy_ppi_complex): + '''Returns (col_a_genes, complex_a, col_b_genes, complex_b, complexes).''' + result = ppi_module.get_genes_from_complexes(toy_ppi_complex, complex_sep='&', + interaction_columns=COLUMNS) + assert len(result) == 5 + col_a_genes, complex_a, col_b_genes, complex_b, complexes = result + assert isinstance(complexes, dict) + for name, subunits in complexes.items(): + assert '&' in name + assert len(subunits) > 1 + assert set(name.split('&')) == set(subunits) + for subunit in subunits: + assert '&' not in subunit + + +def test_get_genes_from_complexes_separates_single_genes(toy_ppi_complex): + col_a_genes, complex_a, col_b_genes, complex_b, _ = \ + ppi_module.get_genes_from_complexes(toy_ppi_complex, complex_sep='&', + interaction_columns=COLUMNS) + # Single-gene entries never contain the separator + for gene in set(col_a_genes).union(col_b_genes): + assert '&' not in gene + # Subunits collected from the complexes are single genes too + for gene in set(complex_a).union(complex_b): + assert '&' not in gene + + +def test_get_genes_from_complexes_without_complexes(toy_ppi): + _, complex_a, _, complex_b, complexes = \ + ppi_module.get_genes_from_complexes(toy_ppi, complex_sep='&', + interaction_columns=COLUMNS) + assert complexes == {} + assert complex_a == set() and complex_b == set() + + +def test_filter_complex_ppi_by_proteins_requires_all_subunits(toy_ppi_complex): + # 'Protein-C&Protein-E' needs both subunits present + with_both = ppi_module.filter_complex_ppi_by_proteins( + toy_ppi_complex, ['Protein-C', 'Protein-E', 'Protein-F'], complex_sep='&', + interaction_columns=COLUMNS) + assert any('&' in value for value in with_both['A']) + + +# --------------------------------------------------------------------------------- +# bidirectional_ppi_for_cci +# --------------------------------------------------------------------------------- + +def test_bidirectional_ppi_for_cci_doubles_the_interactions(): + ppi = pd.DataFrame({'A': ['G1', 'G3'], 'B': ['G2', 'G4'], 'score': [1.0, 1.0]}) + result = ppi_module.bidirectional_ppi_for_cci(ppi, COLUMNS, verbose=False) + pairs = set(zip(result['A'], result['B'])) + assert ('G1', 'G2') in pairs and ('G2', 'G1') in pairs + assert ('G3', 'G4') in pairs and ('G4', 'G3') in pairs + + +def test_bidirectional_ppi_for_cci_does_not_duplicate_self_interactions(): + ppi = pd.DataFrame({'A': ['G1'], 'B': ['G1'], 'score': [1.0]}) + result = ppi_module.bidirectional_ppi_for_cci(ppi, COLUMNS, verbose=False) + assert result.shape[0] == 1 + + +def test_bidirectional_ppi_for_cci_on_empty_input(): + ppi = pd.DataFrame({'A': [], 'B': [], 'score': []}) + result = ppi_module.bidirectional_ppi_for_cci(ppi, COLUMNS, verbose=False) + assert result.shape[0] == 0 + + +# --------------------------------------------------------------------------------- +# get_all_to_all_ppi / get_one_group_to_other_ppi +# --------------------------------------------------------------------------------- + +def test_get_all_to_all_ppi_needs_both_sides_listed(toy_ppi): + result = ppi_module.get_all_to_all_ppi(toy_ppi, ['Protein-A', 'Protein-B'], + interaction_columns=COLUMNS) + for _, row in result.iterrows(): + assert row['A'] in ['Protein-A', 'Protein-B'] + assert row['B'] in ['Protein-A', 'Protein-B'] + + +def test_get_one_group_to_other_ppi_is_directional(toy_ppi): + result = ppi_module.get_one_group_to_other_ppi(toy_ppi, proteins_a=['Protein-A'], + proteins_b=['Protein-B'], + interaction_columns=COLUMNS) + for _, row in result.iterrows(): + assert row['A'] == 'Protein-A' + assert row['B'] == 'Protein-B' + + +# --------------------------------------------------------------------------------- +# filter_ppi_network / get_filtered_ppi_network +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('interaction_type', ['contacts', 'complete']) +def test_get_filtered_ppi_network_types(toy_ppi, interaction_type): + result = ppi_module.get_filtered_ppi_network( + ppi_data=toy_ppi, + contact_proteins=['Protein-A', 'Protein-B'], + mediator_proteins=['Protein-E', 'Protein-F'], + interaction_type=interaction_type, + interaction_columns=COLUMNS, + verbose=False) + assert list(result.columns) == ['A', 'B', 'score'] + + +def test_get_filtered_ppi_network_is_reproducible(toy_ppi): + kwargs = dict(ppi_data=toy_ppi, contact_proteins=['Protein-B', 'Protein-A'], + mediator_proteins=['Protein-F', 'Protein-E'], + interaction_type='complete', interaction_columns=COLUMNS, + verbose=False) + first = ppi_module.get_filtered_ppi_network(**kwargs) + second = ppi_module.get_filtered_ppi_network(**kwargs) + pd.testing.assert_frame_equal(first, second) + + +def test_filter_ppi_network_returns_a_dataframe(toy_ppi): + result = ppi_module.filter_ppi_network( + ppi_data=toy_ppi, + contact_proteins=['Protein-A', 'Protein-B'], + mediator_proteins=['Protein-E', 'Protein-F'], + interaction_type='combined', + interaction_columns=COLUMNS, + verbose=False) + assert isinstance(result, pd.DataFrame) + + +def test_ppi_functions_do_not_modify_input(toy_ppi): + before = toy_ppi.copy() + ppi_module.remove_ppi_bidirectionality(toy_ppi, COLUMNS, verbose=False) + ppi_module.simplify_ppi(toy_ppi, COLUMNS, verbose=False) + ppi_module.filter_ppi_by_proteins(toy_ppi, ['Protein-A'], interaction_columns=COLUMNS) + ppi_module.bidirectional_ppi_for_cci(toy_ppi, COLUMNS, verbose=False) + pd.testing.assert_frame_equal(toy_ppi, before) + + +# --------------------------------------------------------------------------------- +# Deliberate behaviour -- guards against a future "fix" that would break it +# --------------------------------------------------------------------------------- + +def test_remove_ppi_bidirectionality_keeps_using_lexicographic_order(): + '''This lexicographic sort decides WHICH direction of a bidirectional PPI is + dropped. Replacing it with a natural sort would silently change which rows + survive, so the output is pinned here. + ''' + ppi = pd.DataFrame({'A': ['G1', 'G2', 'G3', 'G2', 'G10', 'G2'], + 'B': ['G2', 'G1', 'G4', 'G3', 'G2', 'G10']}) + result = ppi_module.remove_ppi_bidirectionality(ppi, ('A', 'B'), verbose=False) + + pairs = set(zip(result['A'], result['B'])) + # Of each bidirectional pair only one direction is kept + assert ('G1', 'G2') in pairs and ('G2', 'G1') not in pairs + assert ('G10', 'G2') in pairs and ('G2', 'G10') not in pairs + # Unidirectional interactions are untouched + assert ('G3', 'G4') in pairs + assert result.shape[0] == 4 diff --git a/tests/test_preprocessing_rnaseq.py b/tests/test_preprocessing_rnaseq.py new file mode 100644 index 0000000..276fc43 --- /dev/null +++ b/tests/test_preprocessing_rnaseq.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.rnaseq''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.preprocessing import rnaseq +from cell2cell.preprocessing.rnaseq import _trimean + + +# --------------------------------------------------------------------------------- +# drop_empty_genes +# --------------------------------------------------------------------------------- + +def test_drop_empty_genes_removes_all_zero_rows(toy_rnaseq): + data = toy_rnaseq.copy().astype(float) + data.loc['Protein-A'] = 0.0 + result = rnaseq.drop_empty_genes(data) + assert 'Protein-A' not in result.index + assert result.shape[0] == toy_rnaseq.shape[0] - 1 + + +def test_drop_empty_genes_removes_all_nan_rows(toy_rnaseq): + data = toy_rnaseq.copy().astype(float) + data.loc['Protein-B'] = np.nan + result = rnaseq.drop_empty_genes(data) + assert 'Protein-B' not in result.index + + +def test_drop_empty_genes_fills_remaining_nans(toy_rnaseq): + data = toy_rnaseq.copy().astype(float) + data.loc['Protein-C', 'C1'] = np.nan + result = rnaseq.drop_empty_genes(data) + assert 'Protein-C' in result.index + assert result.loc['Protein-C', 'C1'] == 0.0 + assert not result.isna().any().any() + + +def test_drop_empty_genes_keeps_a_full_dataset(toy_rnaseq): + result = rnaseq.drop_empty_genes(toy_rnaseq) + assert result.shape == toy_rnaseq.shape + + +def test_drop_empty_genes_does_not_modify_input(toy_rnaseq): + data = toy_rnaseq.copy().astype(float) + data.loc['Protein-A'] = 0.0 + before = data.values.copy() + rnaseq.drop_empty_genes(data) + assert np.array_equal(data.values, before) + + +# --------------------------------------------------------------------------------- +# log10_transformation +# --------------------------------------------------------------------------------- + +def test_log10_transformation_values(toy_rnaseq): + result = rnaseq.log10_transformation(toy_rnaseq, addition=1e-6) + expected = np.log10(toy_rnaseq.values + 1e-6) + assert np.allclose(result.values, expected) + assert list(result.index) == list(toy_rnaseq.index) + + +def test_log10_transformation_turns_infinities_into_nan(): + data = pd.DataFrame({'C1': [0.0, 1.0]}, index=['g1', 'g2']) + result = rnaseq.log10_transformation(data, addition=0.0) + assert np.isnan(result.loc['g1', 'C1']) + assert np.isclose(result.loc['g2', 'C1'], 0.0) + + +# --------------------------------------------------------------------------------- +# scale_expression_by_sum +# --------------------------------------------------------------------------------- + +def test_scale_expression_by_sum_columns(toy_rnaseq): + result = rnaseq.scale_expression_by_sum(toy_rnaseq, axis=0, sum_value=1e6) + assert np.allclose(result.sum(axis=0).values, 1e6) + assert list(result.columns) == list(toy_rnaseq.columns) + + +def test_scale_expression_by_sum_rows(toy_rnaseq): + result = rnaseq.scale_expression_by_sum(toy_rnaseq, axis=1, sum_value=1.0) + assert np.allclose(result.sum(axis=1).values, 1.0) + + +def test_scale_expression_by_sum_preserves_proportions(toy_rnaseq): + result = rnaseq.scale_expression_by_sum(toy_rnaseq, axis=0, sum_value=1.0) + ratio_before = toy_rnaseq.iloc[0, 0] / toy_rnaseq.iloc[1, 0] + ratio_after = result.iloc[0, 0] / result.iloc[1, 0] + assert np.isclose(ratio_before, ratio_after) + + +# --------------------------------------------------------------------------------- +# divide_expression_by_max / by_mean +# --------------------------------------------------------------------------------- + +def test_divide_expression_by_max_rowwise(toy_rnaseq): + result = rnaseq.divide_expression_by_max(toy_rnaseq, axis=1) + assert np.allclose(result.max(axis=1).values, 1.0) + assert (result.values <= 1.0).all() + + +def test_divide_expression_by_max_columnwise(toy_rnaseq): + result = rnaseq.divide_expression_by_max(toy_rnaseq, axis=0) + assert np.allclose(result.max(axis=0).values, 1.0) + + +def test_divide_expression_by_mean_rowwise(toy_rnaseq): + result = rnaseq.divide_expression_by_mean(toy_rnaseq, axis=1) + expected = toy_rnaseq.values / toy_rnaseq.mean(axis=1).values[:, None] + assert np.allclose(result.values, expected) + + +def test_divide_expression_handles_zero_denominator(): + data = pd.DataFrame({'C1': [0.0, 2.0], 'C2': [0.0, 4.0]}, index=['g1', 'g2']) + by_max = rnaseq.divide_expression_by_max(data, axis=1) + by_mean = rnaseq.divide_expression_by_mean(data, axis=1) + # An all-zero gene must not produce NaN or inf + assert np.isfinite(by_max.values).all() + assert np.isfinite(by_mean.values).all() + assert (by_max.loc['g1'] == 0.0).all() + + +# --------------------------------------------------------------------------------- +# add_complexes_to_expression +# --------------------------------------------------------------------------------- + +def test_add_complexes_to_expression_min(toy_rnaseq): + complexes = {'Protein-A&Protein-B': ['Protein-A', 'Protein-B']} + result = rnaseq.add_complexes_to_expression(toy_rnaseq, complexes, agg_method='min') + assert 'Protein-A&Protein-B' in result.index + expected = toy_rnaseq.loc[['Protein-A', 'Protein-B']].min().values + assert np.allclose(result.loc['Protein-A&Protein-B'].values, expected) + + +def test_add_complexes_to_expression_mean(toy_rnaseq): + complexes = {'complex': ['Protein-A', 'Protein-B']} + result = rnaseq.add_complexes_to_expression(toy_rnaseq, complexes, agg_method='mean') + expected = toy_rnaseq.loc[['Protein-A', 'Protein-B']].mean().values + assert np.allclose(result.loc['complex'].values, expected) + + +def test_add_complexes_to_expression_gmean(toy_rnaseq): + complexes = {'complex': ['Protein-A', 'Protein-B']} + result = rnaseq.add_complexes_to_expression(toy_rnaseq, complexes, agg_method='gmean') + subset = toy_rnaseq.loc[['Protein-A', 'Protein-B']].values.astype(float) + expected = np.exp(np.mean(np.log(subset), axis=0)) + assert np.allclose(result.loc['complex'].values, expected) + + +def test_add_complexes_to_expression_missing_subunit_gives_zeros(toy_rnaseq): + complexes = {'complex': ['Protein-A', 'Not-A-Gene']} + result = rnaseq.add_complexes_to_expression(toy_rnaseq, complexes) + assert (result.loc['complex'] == 0).all() + + +def test_add_complexes_to_expression_accepts_sets(toy_rnaseq): + complexes = {'complex': {'Protein-A', 'Protein-B'}} + result = rnaseq.add_complexes_to_expression(toy_rnaseq, complexes, agg_method='min') + assert 'complex' in result.index + + +def test_add_complexes_to_expression_rejects_bad_values(toy_rnaseq): + with pytest.raises(ValueError): + rnaseq.add_complexes_to_expression(toy_rnaseq, {'complex': 'Protein-A'}) + + +def test_add_complexes_to_expression_does_not_modify_input(toy_rnaseq): + before = toy_rnaseq.copy() + rnaseq.add_complexes_to_expression(toy_rnaseq, {'c': ['Protein-A', 'Protein-B']}) + pd.testing.assert_frame_equal(toy_rnaseq, before) + + +# --------------------------------------------------------------------------------- +# _trimean +# --------------------------------------------------------------------------------- + +def test_trimean_axis_semantics(): + x = np.array([[1., 2., 3.], [4., 5., 6.], [7., 8., 9.], [10., 11., 12.]]) + by_column = _trimean(x, axis=0) + by_row = _trimean(x, axis=1) + assert by_column.shape == (3,) + assert by_row.shape == (4,) + assert np.allclose(by_column, [5.5, 6.5, 7.5]) + assert np.allclose(by_row, [2., 5., 8., 11.]) + + +def test_trimean_formula(): + values = np.array([[1.], [2.], [3.], [10.], [20.]]) + q1, q2, q3 = np.nanpercentile(values, [25, 50, 75], axis=0) + assert np.allclose(_trimean(values, axis=0), 0.5 * q2 + 0.25 * (q1 + q3)) + + +def test_trimean_ignores_nan(): + values = np.array([[1.], [np.nan], [3.]]) + result = _trimean(values, axis=0) + assert np.isfinite(result).all() + assert np.isclose(result[0], 2.0) + + +def test_trimean_of_a_single_value(): + assert np.isclose(_trimean(np.array([[7.]]), axis=0)[0], 7.0) + + +def test_trimean_is_more_robust_than_the_mean(): + clean = np.array([[1.], [2.], [3.], [4.], [5.]]) + with_outlier = np.array([[1.], [2.], [3.], [4.], [1000.]]) + mean_shift = abs(with_outlier.mean() - clean.mean()) + trimean_shift = abs(_trimean(with_outlier, axis=0)[0] - _trimean(clean, axis=0)[0]) + assert trimean_shift < mean_shift + + +# --------------------------------------------------------------------------------- +# aggregate_single_cells +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('method', ['average', 'nn_cell_fraction', 'trimean']) +def test_aggregate_single_cells_shape_and_labels(toy_single_cells, method): + data, metadata = toy_single_cells + result = rnaseq.aggregate_single_cells(data.T, metadata, barcode_col='barcodes', + celltype_col='cell_types', method=method) + assert list(result.index) == list(data.index) + assert list(result.columns) == ['CT-1', 'CT-2', 'CT-3'] + + +def test_aggregate_single_cells_average_values(toy_single_cells): + data, metadata = toy_single_cells + result = rnaseq.aggregate_single_cells(data.T, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='average') + for cell_type in ['CT-1', 'CT-2', 'CT-3']: + barcodes = metadata.loc[metadata['cell_types'] == cell_type, 'barcodes'] + expected = data[list(barcodes)].mean(axis=1) + assert np.allclose(result[cell_type].values, expected.values) + + +def test_aggregate_single_cells_nn_cell_fraction_values(toy_single_cells): + data, metadata = toy_single_cells + result = rnaseq.aggregate_single_cells(data.T, metadata, barcode_col='barcodes', + celltype_col='cell_types', + method='nn_cell_fraction') + assert result.values.min() >= 0.0 and result.values.max() <= 1.0 + for cell_type in ['CT-1', 'CT-2']: + barcodes = list(metadata.loc[metadata['cell_types'] == cell_type, 'barcodes']) + expected = (data[barcodes] > 0).sum(axis=1) / len(barcodes) + assert np.allclose(result[cell_type].values, expected.values) + + +def test_aggregate_single_cells_trimean_values(toy_single_cells): + data, metadata = toy_single_cells + result = rnaseq.aggregate_single_cells(data.T, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='trimean') + for cell_type in ['CT-1', 'CT-2', 'CT-3']: + barcodes = list(metadata.loc[metadata['cell_types'] == cell_type, 'barcodes']) + for gene in data.index: + q1, q2, q3 = np.nanpercentile(data.loc[gene, barcodes].values, [25, 50, 75]) + assert np.isclose(result.loc[gene, cell_type], 0.5 * q2 + 0.25 * (q1 + q3)) + + +def test_aggregate_single_cells_transposed_false_matches(toy_single_cells): + data, metadata = toy_single_cells + transposed = rnaseq.aggregate_single_cells(data.T, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='average') + straight = rnaseq.aggregate_single_cells(data, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='average', + transposed=False) + pd.testing.assert_frame_equal(transposed, straight) + + +def test_aggregate_single_cells_gene_labels_survive_column_permutation(toy_single_cells): + '''Guards against positional (rather than labelled) assignment of the results.''' + data, metadata = toy_single_cells + reference = rnaseq.aggregate_single_cells(data.T, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='trimean') + permuted_genes = list(data.index)[::-1] + permuted = rnaseq.aggregate_single_cells(data.loc[permuted_genes].T, metadata, + barcode_col='barcodes', + celltype_col='cell_types', method='trimean') + for gene in data.index: + for cell_type in reference.columns: + assert np.isclose(reference.loc[gene, cell_type], permuted.loc[gene, cell_type]) + + +def test_aggregate_single_cells_rejects_invalid_method(toy_single_cells): + data, metadata = toy_single_cells + with pytest.raises(AssertionError): + rnaseq.aggregate_single_cells(data.T, metadata, method='not-a-method') + + +def test_aggregate_single_cells_requires_metadata(toy_single_cells): + data, _ = toy_single_cells + with pytest.raises(AssertionError): + rnaseq.aggregate_single_cells(data.T, None) + + +def test_aggregate_single_cells_single_cell_per_type(): + data = pd.DataFrame({'b1': [5.0, 7.0], 'b2': [1.0, 2.0]}, index=['g1', 'g2']) + metadata = pd.DataFrame({'barcodes': ['b1', 'b2'], 'cell_types': ['x', 'y']}) + result = rnaseq.aggregate_single_cells(data.T, metadata, method='trimean') + assert np.allclose(result['x'].values, [5.0, 7.0]) + assert np.allclose(result['y'].values, [1.0, 2.0]) + + +# --------------------------------------------------------------------------------- +# aggregate_single_cells modified the dataframe passed by the user +# --------------------------------------------------------------------------------- + +@pytest.mark.parametrize('method', ['average', 'nn_cell_fraction', 'trimean']) +@pytest.mark.parametrize('transposed', [True, False]) +def test_aggregate_single_cells_does_not_modify_its_input(toy_single_cells, method, transposed): + single_cells, metadata = toy_single_cells + data = single_cells.T if transposed else single_cells + + index_before = list(data.index) + columns_before = list(data.columns) + values_before = data.values.copy() + + rnaseq.aggregate_single_cells(data, metadata, barcode_col='barcodes', + celltype_col='cell_types', method=method, + transposed=transposed) + + assert list(data.index) == index_before + assert list(data.columns) == columns_before + assert np.array_equal(data.values, values_before) + + +def test_aggregate_single_cells_can_be_called_twice(toy_single_cells): + '''Previously raised KeyError, because the first call replaced the index.''' + single_cells, metadata = toy_single_cells + data = single_cells.T + + first = rnaseq.aggregate_single_cells(data, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='average') + second = rnaseq.aggregate_single_cells(data, metadata, barcode_col='barcodes', + celltype_col='cell_types', method='average') + pd.testing.assert_frame_equal(first, second) + + +def test_aggregate_single_cells_orders_cell_types_naturally(): + single_cells, metadata = c2c.datasets.generate_toy_single_cells(n_cell_types=11, + n_cells_per_type=2) + aggregated = rnaseq.aggregate_single_cells(single_cells.T, metadata, + barcode_col='barcodes', + celltype_col='cell_types', method='average') + columns = list(aggregated.columns) + assert columns == ['CT-{}'.format(i) for i in range(1, 12)] + assert columns != sorted(columns) + + +# --------------------------------------------------------------------------------- +# scale_expression_by_sum could not normalize across columns +# +# The sums were not kept 2-dimensional, so the documented `axis=1` option raised +# "operands could not be broadcast together with shapes (6,5) (6,)". +# --------------------------------------------------------------------------------- + +def test_scale_expression_by_sum_supports_both_axes(toy_rnaseq): + by_column = rnaseq.scale_expression_by_sum(toy_rnaseq, axis=0, sum_value=1e6) + assert np.allclose(by_column.sum(axis=0).values, 1e6) + + by_row = rnaseq.scale_expression_by_sum(toy_rnaseq, axis=1, sum_value=1e6) + assert np.allclose(by_row.sum(axis=1).values, 1e6) diff --git a/tests/test_preprocessing_signal.py b/tests/test_preprocessing_signal.py new file mode 100644 index 0000000..0108d6f --- /dev/null +++ b/tests/test_preprocessing_signal.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.preprocessing.signal''' + +import numpy as np +import pytest + +from cell2cell.preprocessing import signal + + +def test_smooth_curve_preserves_length(): + values = [5.0, 3.0, 4.0, 2.0, 3.5, 1.0, 2.0, 0.5, 1.5, 0.2] + smoothed = signal.smooth_curve(values) + assert len(smoothed) == len(values) + + +def test_smooth_curve_reduces_the_variation(): + noisy = [0.0, 10.0, 0.0, 10.0, 0.0, 10.0, 0.0, 10.0, 0.0, 10.0, 0.0] + smoothed = signal.smooth_curve(noisy) + assert np.std(smoothed) < np.std(noisy) + + +def test_smooth_curve_with_an_explicit_window(): + values = list(np.linspace(1.0, 0.1, 20)) + smoothed = signal.smooth_curve(values, window_length=5, polyorder=2) + assert len(smoothed) == len(values) + assert np.isfinite(smoothed).all() + + +def test_smooth_curve_on_a_straight_line_is_almost_unchanged(): + values = list(np.linspace(0.0, 1.0, 15)) + smoothed = signal.smooth_curve(values, window_length=5, polyorder=2) + assert np.allclose(smoothed, values, atol=1e-8) + + +def test_smooth_curve_accepts_a_numpy_array(): + values = np.array([3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0, 5.0, 3.0]) + smoothed = signal.smooth_curve(values) + assert np.isfinite(smoothed).all() + + +def test_smooth_curve_is_deterministic(): + values = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0, 5.0, 3.0] + assert np.allclose(signal.smooth_curve(values), signal.smooth_curve(values)) diff --git a/tests/test_spatial.py b/tests/test_spatial.py new file mode 100644 index 0000000..5ee35d9 --- /dev/null +++ b/tests/test_spatial.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.spatial''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.spatial import distances, filtering, neighborhoods + + +# --------------------------------------------------------------------------------- +# distances +# --------------------------------------------------------------------------------- + +def test_celltype_pair_distance_min(): + df1 = pd.DataFrame({'X': [0.0, 1.0], 'Y': [0.0, 0.0]}) + df2 = pd.DataFrame({'X': [3.0, 10.0], 'Y': [0.0, 0.0]}) + assert np.isclose(distances.celltype_pair_distance(df1, df2, method='min'), 2.0) + + +def test_celltype_pair_distance_max(): + df1 = pd.DataFrame({'X': [0.0], 'Y': [0.0]}) + df2 = pd.DataFrame({'X': [3.0, 4.0], 'Y': [0.0, 0.0]}) + assert np.isclose(distances.celltype_pair_distance(df1, df2, method='max'), 4.0) + + +def test_celltype_pair_distance_mean(): + df1 = pd.DataFrame({'X': [0.0], 'Y': [0.0]}) + df2 = pd.DataFrame({'X': [2.0, 4.0], 'Y': [0.0, 0.0]}) + assert np.isclose(distances.celltype_pair_distance(df1, df2, method='mean'), 3.0) + + +def test_celltype_pair_distance_manhattan(): + df1 = pd.DataFrame({'X': [0.0], 'Y': [0.0]}) + df2 = pd.DataFrame({'X': [3.0], 'Y': [4.0]}) + euclidean = distances.celltype_pair_distance(df1, df2, distance='euclidean') + manhattan = distances.celltype_pair_distance(df1, df2, distance='manhattan') + assert np.isclose(euclidean, 5.0) + assert np.isclose(manhattan, 7.0) + + +def test_celltype_pair_distance_of_a_cluster_with_itself_is_zero(): + df = pd.DataFrame({'X': [0.0, 1.0], 'Y': [0.0, 1.0]}) + assert np.isclose(distances.celltype_pair_distance(df, df, method='min'), 0.0) + + +def test_celltype_pair_distance_rejects_bad_options(): + df = pd.DataFrame({'X': [0.0], 'Y': [0.0]}) + with pytest.raises(NotImplementedError): + distances.celltype_pair_distance(df, df, method='nonsense') + with pytest.raises(NotImplementedError): + distances.celltype_pair_distance(df, df, distance='nonsense') + + +def test_pairwise_celltype_distances_is_a_distance_matrix(toy_coordinates): + result = distances.pairwise_celltype_distances(toy_coordinates, group_col='celltype', + coord_cols=['X', 'Y']) + assert list(result.index) == list(result.columns) + assert set(result.index) == {'CT-1', 'CT-2', 'CT-3'} + assert np.allclose(np.diag(result.values), 0.0) + assert np.allclose(result.values, result.values.T) + assert (result.values >= 0).all() + + +def test_pairwise_celltype_distances_matches_the_pairwise_function(toy_coordinates): + result = distances.pairwise_celltype_distances(toy_coordinates, group_col='celltype') + group_a = toy_coordinates[toy_coordinates['celltype'] == 'CT-1'][['X', 'Y']] + group_b = toy_coordinates[toy_coordinates['celltype'] == 'CT-2'][['X', 'Y']] + expected = distances.celltype_pair_distance(group_a, group_b, method='min') + assert np.isclose(result.loc['CT-1', 'CT-2'], expected) + + +def test_pairwise_celltype_distances_with_explicit_pairs(toy_coordinates): + result = distances.pairwise_celltype_distances(toy_coordinates, group_col='celltype', + pairs=[('CT-1', 'CT-2')]) + assert result.loc['CT-1', 'CT-2'] > 0 + + +# --------------------------------------------------------------------------------- +# neighborhoods +# --------------------------------------------------------------------------------- + +def test_create_spatial_grid_adds_columns(toy_spatial_adata): + c2c.spatial.create_spatial_grid(toy_spatial_adata, num_bins=5) + for column in ['grid_x', 'grid_y', 'grid_cell']: + assert column in toy_spatial_adata.obs.columns + assert toy_spatial_adata.obs['grid_x'].min() >= 0 + assert toy_spatial_adata.obs['grid_x'].max() <= 4 + + +def test_create_spatial_grid_copy_does_not_modify_the_original(toy_spatial_adata): + result = c2c.spatial.create_spatial_grid(toy_spatial_adata, num_bins=4, copy=True) + assert 'grid_cell' in result.obs.columns + assert 'grid_cell' not in toy_spatial_adata.obs.columns + + +def test_create_spatial_grid_cell_ids_combine_both_axes(toy_spatial_adata): + c2c.spatial.create_spatial_grid(toy_spatial_adata, num_bins=4) + for value in toy_spatial_adata.obs['grid_cell']: + assert '_' in value + grid = toy_spatial_adata.obs + rebuilt = grid['grid_x'].astype(str) + '_' + grid['grid_y'].astype(str) + assert (rebuilt == grid['grid_cell']).all() + + +def test_calculate_window_size(toy_spatial_adata): + size = c2c.spatial.calculate_window_size(toy_spatial_adata, num_windows=4) + coords = toy_spatial_adata.obsm['spatial'][:, 0] + assert np.isclose(size, (coords.max() - coords.min()) / 4) + + +def test_create_sliding_windows_maps_barcodes(toy_spatial_adata): + mapping = c2c.spatial.create_sliding_windows(toy_spatial_adata, window_size=25., + stride=25.) + assert len(mapping) > 0 + barcodes = set(toy_spatial_adata.obs_names) + for window, members in mapping.items(): + assert window.startswith('window_') + assert isinstance(members, set) + assert members.issubset(barcodes) + + +def test_create_sliding_windows_covers_the_interior(toy_spatial_adata): + '''Documents a boundary behaviour: windows are half-open intervals + [edge, edge + window_size), and the last edge stops before the maximum + coordinate, so cells sitting exactly on the maximum X or Y are not assigned + to any window. + ''' + mapping = c2c.spatial.create_sliding_windows(toy_spatial_adata, window_size=30., + stride=10.) + covered = set().union(*mapping.values()) + coordinates = toy_spatial_adata.obsm['spatial'] + on_the_edge = {barcode for barcode, (x, y) + in zip(toy_spatial_adata.obs_names, coordinates) + if x == coordinates[:, 0].max() or y == coordinates[:, 1].max()} + + assert covered == set(toy_spatial_adata.obs_names) - on_the_edge + assert len(covered) > 0 and len(on_the_edge) > 0 + + +def test_overlapping_windows_share_cells(toy_spatial_adata): + '''With a stride smaller than the window, a cell belongs to several windows.''' + mapping = c2c.spatial.create_sliding_windows(toy_spatial_adata, window_size=40., + stride=10.) + counts = {} + for members in mapping.values(): + for barcode in members: + counts[barcode] = counts.get(barcode, 0) + 1 + assert max(counts.values()) > 1 + + +def test_add_sliding_window_info_marks_only_members(toy_spatial_adata): + mapping = c2c.spatial.create_sliding_windows(toy_spatial_adata, window_size=25., + stride=25.) + c2c.spatial.add_sliding_window_info_to_adata(toy_spatial_adata, mapping) + for window, members in mapping.items(): + column = toy_spatial_adata.obs[window] + assert set(column.unique()).issubset({0.0, 1.0}) + assert (column.loc[list(members)] == 1.0).all() + outsiders = set(toy_spatial_adata.obs_names) - members + if outsiders: + assert (column.loc[list(outsiders)] == 0.0).all() + + +# --------------------------------------------------------------------------------- +# filtering +# --------------------------------------------------------------------------------- + +@pytest.fixture +def celltype_distances(): + cells = ['CT-1', 'CT-2', 'CT-3'] + values = np.array([[0., 10., 100.], + [10., 0., 50.], + [100., 50., 0.]]) + return pd.DataFrame(values, index=cells, columns=cells) + + +@pytest.fixture +def liana_tensor(toy_liana): + context_dict = {name: frame for name, frame in toy_liana.groupby('context')} + return c2c.tensor.dataframes_to_tensor(context_dict, sender_col='source', + receiver_col='target', ligand_col='ligand', + receptor_col='receptor', score_col='score', + how='inner') + + +def test_dist_filter_tensor_masks_distant_pairs(liana_tensor, celltype_distances): + filtered = filtering.dist_filter_tensor(liana_tensor, celltype_distances, + max_dist=20., source_axis=2, target_axis=3) + senders = list(filtered.order_names[2]) + receivers = list(filtered.order_names[3]) + data = np.asarray(filtered.tensor) + for s, sender in enumerate(senders): + for r, receiver in enumerate(receivers): + if celltype_distances.loc[sender, receiver] > 20.: + assert np.allclose(np.nan_to_num(data[:, :, s, r]), 0.0) + + +def test_dist_filter_tensor_keeps_close_pairs(liana_tensor, celltype_distances): + original = np.asarray(liana_tensor.tensor).copy() + filtered = filtering.dist_filter_tensor(liana_tensor, celltype_distances, max_dist=20.) + senders = list(filtered.order_names[2]) + data = np.asarray(filtered.tensor) + close = senders.index('CT-1'), senders.index('CT-2') + assert np.allclose(data[:, :, close[0], close[1]], + original[:, :, close[0], close[1]]) + + +def test_dist_filter_tensor_returns_a_copy(liana_tensor, celltype_distances): + original = np.asarray(liana_tensor.tensor).copy() + filtering.dist_filter_tensor(liana_tensor, celltype_distances, max_dist=20.) + assert np.allclose(np.asarray(liana_tensor.tensor), original) + + +def test_dist_filter_tensor_min_dist(liana_tensor, celltype_distances): + filtered = filtering.dist_filter_tensor(liana_tensor, celltype_distances, + max_dist=200., min_dist=20.) + senders = list(filtered.order_names[2]) + data = np.asarray(filtered.tensor) + # CT-1 <-> CT-2 are 10 apart, below min_dist, so they must be filtered out + i, j = senders.index('CT-1'), senders.index('CT-2') + assert np.allclose(np.nan_to_num(data[:, :, i, j]), 0.0) + + +def test_dist_filter_liana_removes_distant_rows(toy_liana, celltype_distances): + filtered = filtering.dist_filter_liana(toy_liana, celltype_distances, max_dist=20.) + assert filtered.shape[0] < toy_liana.shape[0] + for _, row in filtered.iterrows(): + assert celltype_distances.loc[row['source'], row['target']] <= 20. + + +def test_dist_filter_liana_can_keep_the_distance_column(toy_liana, celltype_distances): + filtered = filtering.dist_filter_liana(toy_liana, celltype_distances, max_dist=20., + keep_dist=True) + assert 'distance' in filtered.columns + for _, row in filtered.iterrows(): + assert np.isclose(row['distance'], + celltype_distances.loc[row['source'], row['target']]) + + +def test_dist_filter_liana_does_not_modify_input(toy_liana, celltype_distances): + before = toy_liana.copy() + filtering.dist_filter_liana(toy_liana, celltype_distances, max_dist=20.) + pd.testing.assert_frame_equal(toy_liana, before) + + +# --------------------------------------------------------------------------------- +# add_sliding_window_info_to_adata crashed on pandas >= 2 +# +# The barcodes of each window were passed to .loc as a set, which newer versions of +# pandas reject with "Passing a set as an indexer is not supported". +# --------------------------------------------------------------------------------- + +def test_add_sliding_window_info_to_adata_accepts_window_mapping(toy_spatial_adata): + window_mapping = c2c.spatial.create_sliding_windows(toy_spatial_adata, + window_size=20., stride=7.) + assert isinstance(next(iter(window_mapping.values())), set) + + c2c.spatial.add_sliding_window_info_to_adata(toy_spatial_adata, window_mapping) + + window_columns = [c for c in toy_spatial_adata.obs.columns if c.startswith('window_')] + assert len(window_columns) == len(window_mapping) + # Every cell assigned to a window must be flagged with 1.0 + for window, barcodes in window_mapping.items(): + flagged = toy_spatial_adata.obs.loc[list(barcodes), window] + assert (flagged == 1.0).all() + + +def test_sliding_window_columns_are_naturally_ordered(toy_spatial_adata): + '''With more than 10 windows per axis, window_10_* must not precede window_2_*.''' + window_mapping = c2c.spatial.create_sliding_windows(toy_spatial_adata, + window_size=10., stride=5.) + c2c.spatial.add_sliding_window_info_to_adata(toy_spatial_adata, window_mapping) + + columns = [c for c in toy_spatial_adata.obs.columns if c.startswith('window_')] + assert any(c.startswith('window_10_') for c in columns) + assert columns != sorted(columns) # natural order differs from alphabetical + + +# --------------------------------------------------------------------------------- +# distances from single-cell coordinates in an AnnData object +# --------------------------------------------------------------------------------- + +def test_celltype_pair_distance_median(): + df1 = pd.DataFrame({'X': [0.0, 0.0], 'Y': [0.0, 0.0]}) + df2 = pd.DataFrame({'X': [1.0, 3.0], 'Y': [0.0, 0.0]}) + # distances are 1, 3, 1, 3 + assert np.isclose(distances.celltype_pair_distance(df1, df2, method='median'), 2.0) + + +def test_get_spatial_coordinates(toy_spatial_adata): + coords = distances.get_spatial_coordinates(toy_spatial_adata) + assert coords.shape == (toy_spatial_adata.n_obs, 2) + assert list(coords.columns) == ['X', 'Y'] + assert list(coords.index) == list(toy_spatial_adata.obs_names) + np.testing.assert_allclose(coords.values, toy_spatial_adata.obsm['spatial']) + + +def test_get_spatial_coordinates_custom_key(toy_spatial_adata): + toy_spatial_adata.obsm['X_spatial'] = toy_spatial_adata.obsm['spatial'] + coords = distances.get_spatial_coordinates(toy_spatial_adata, spatial_key='X_spatial') + np.testing.assert_allclose(coords.values, toy_spatial_adata.obsm['spatial']) + + +def test_get_spatial_coordinates_missing_key(toy_spatial_adata): + with pytest.raises(KeyError): + distances.get_spatial_coordinates(toy_spatial_adata, spatial_key='not_there') + + +def test_get_spatial_coordinates_custom_names(toy_spatial_adata): + coords = distances.get_spatial_coordinates(toy_spatial_adata, coord_names=['row', 'col']) + assert list(coords.columns) == ['row', 'col'] + + +def test_celltype_centroids(toy_spatial_adata): + centroids = distances.celltype_centroids(toy_spatial_adata, group_col='celltype') + coords = distances.get_spatial_coordinates(toy_spatial_adata) + expected = coords.groupby(np.asarray(toy_spatial_adata.obs['celltype'].values)).mean() + np.testing.assert_allclose(centroids.values, expected.loc[centroids.index].values) + + +def test_celltype_centroids_are_naturally_sorted(): + rng = np.random.default_rng(0) + n = 60 + labels = ['CT-{}'.format(i % 12 + 1) for i in range(n)] + df = pd.DataFrame({'X': rng.random(n), 'Y': rng.random(n), 'celltype': labels}) + centroids = distances.celltype_centroids(df, group_col='celltype') + assert list(centroids.index) == ['CT-{}'.format(i) for i in range(1, 13)] + + +def test_celltype_centroids_median_differs_from_mean(): + df = pd.DataFrame({'X': [0.0, 0.0, 100.0], 'Y': [0.0, 0.0, 0.0], + 'celltype': ['A', 'A', 'A']}) + mean = distances.celltype_centroids(df, group_col='celltype', method='mean') + median = distances.celltype_centroids(df, group_col='celltype', method='median') + assert np.isclose(mean.loc['A', 'X'], 100.0 / 3) + assert np.isclose(median.loc['A', 'X'], 0.0) + + +def test_celltype_centroid_distances_is_a_distance_matrix(toy_spatial_adata): + result = distances.celltype_centroid_distances(toy_spatial_adata, group_col='celltype') + assert list(result.index) == list(result.columns) + assert np.allclose(np.diag(result.values), 0.0) + np.testing.assert_allclose(result.values, result.values.T) + + +def test_celltype_centroid_distances_known_geometry(): + df = pd.DataFrame({'X': [0.0, 0.0, 3.0, 3.0], 'Y': [0.0, 0.0, 4.0, 4.0], + 'celltype': ['A', 'A', 'B', 'B']}) + result = distances.celltype_centroid_distances(df, group_col='celltype') + assert np.isclose(result.loc['A', 'B'], 5.0) + + +@pytest.mark.parametrize('method', ['centroid', 'min', 'max', 'mean', 'median']) +def test_celltype_distances_methods(toy_spatial_adata, method): + result = distances.celltype_distances(toy_spatial_adata, group_col='celltype', + method=method) + assert np.allclose(np.diag(result.values), 0.0) + np.testing.assert_allclose(result.values, result.values.T) + assert (result.values >= 0).all() + + +def test_celltype_distances_min_is_at_most_centroid(): + '''The closest two cells of two types cannot be further apart than their centroids.''' + rng = np.random.default_rng(0) + n = 40 + df = pd.DataFrame({'X': rng.random(n) * 10, 'Y': rng.random(n) * 10, + 'celltype': ['A'] * (n // 2) + ['B'] * (n // 2)}) + closest = distances.celltype_distances(df, group_col='celltype', method='min') + centroid = distances.celltype_distances(df, group_col='celltype', method='centroid') + assert closest.loc['A', 'B'] <= centroid.loc['A', 'B'] + 1e-9 + + +def test_celltype_distances_accepts_a_dataframe(toy_spatial_adata): + coords = distances.get_spatial_coordinates(toy_spatial_adata) + coords['celltype'] = np.asarray(toy_spatial_adata.obs['celltype'].values) + from_df = distances.celltype_distances(coords, group_col='celltype') + from_adata = distances.celltype_distances(toy_spatial_adata, group_col='celltype') + np.testing.assert_allclose(from_df.values, from_adata.values) + + +def test_celltype_distances_custom_spatial_key(toy_spatial_adata): + toy_spatial_adata.obsm['my_coords'] = toy_spatial_adata.obsm['spatial'] + custom = distances.celltype_distances(toy_spatial_adata, group_col='celltype', + spatial_key='my_coords') + default = distances.celltype_distances(toy_spatial_adata, group_col='celltype') + np.testing.assert_allclose(custom.values, default.values) + + +def test_celltype_distances_manhattan_differs_from_euclidean(): + df = pd.DataFrame({'X': [0.0, 3.0], 'Y': [0.0, 4.0], 'celltype': ['A', 'B']}) + euclidean = distances.celltype_distances(df, group_col='celltype', distance='euclidean') + manhattan = distances.celltype_distances(df, group_col='celltype', distance='manhattan') + assert np.isclose(euclidean.loc['A', 'B'], 5.0) + assert np.isclose(manhattan.loc['A', 'B'], 7.0) + + +def test_celltype_distances_rejects_unknown_group_col(toy_spatial_adata): + with pytest.raises(KeyError): + distances.celltype_distances(toy_spatial_adata, group_col='not_a_column') + + +def test_celltype_distances_rejects_bad_input(): + with pytest.raises(TypeError): + distances.celltype_distances([1, 2, 3], group_col='celltype') diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..5ccd2b0 --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.stats''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.stats import enrichment, gini, multitest, permutation + + +# --------------------------------------------------------------------------------- +# gini +# --------------------------------------------------------------------------------- + +def test_gini_of_a_uniform_distribution_is_zero(): + assert np.isclose(gini.gini_coefficient(np.ones(10)), 0.0) + + +def test_gini_of_a_concentrated_distribution_is_high(): + concentrated = np.array([0.0] * 99 + [1.0]) + assert gini.gini_coefficient(concentrated) > 0.95 + + +def test_gini_is_between_zero_and_one(): + for values in [np.arange(1, 11.), np.array([1., 1., 5.]), np.random.default_rng(0).random(50)]: + result = gini.gini_coefficient(values) + assert 0.0 <= result <= 1.0 + + +def test_gini_is_scale_invariant(): + values = np.array([1., 2., 3., 4.]) + assert np.isclose(gini.gini_coefficient(values), gini.gini_coefficient(values * 10)) + + +def test_gini_increases_with_inequality(): + equal = np.array([5., 5., 5., 5.]) + unequal = np.array([1., 2., 7., 10.]) + assert gini.gini_coefficient(unequal) > gini.gini_coefficient(equal) + + +# --------------------------------------------------------------------------------- +# enrichment +# --------------------------------------------------------------------------------- + +def test_hypergeom_representation_returns_depletion_then_enrichment(): + result = enrichment.hypergeom_representation(sample_size=10, class_in_sample=8, + population_size=100, class_in_population=20) + assert len(result) == 2 + for pvalue in result: + assert 0.0 <= pvalue <= 1.0 + + +def test_hypergeom_detects_over_representation(): + '''The whole class ends up in the sample, so enrichment is significant.''' + depletion, enrichment_pval = enrichment.hypergeom_representation( + sample_size=20, class_in_sample=20, population_size=100, class_in_population=20) + assert enrichment_pval < depletion + assert enrichment_pval < 0.05 + + +def test_hypergeom_detects_under_representation(): + depletion, enrichment_pval = enrichment.hypergeom_representation( + sample_size=20, class_in_sample=0, population_size=100, class_in_population=50) + assert depletion < enrichment_pval + assert depletion < 0.05 + + +def test_fisher_representation_returns_odds_and_pvalues(): + result = enrichment.fisher_representation(sample_size=10, class_in_sample=8, + population_size=100, class_in_population=20) + assert set(result.keys()) == {'pval', 'odds'} + assert len(result['pval']) == 2 and len(result['odds']) == 2 + for pvalue in result['pval']: + assert 0.0 <= pvalue <= 1.0 + + +def test_fisher_and_hypergeom_agree_on_direction(): + kwargs = dict(sample_size=20, class_in_sample=18, population_size=200, + class_in_population=30) + _, hyper_enrichment = enrichment.hypergeom_representation(**kwargs) + fisher = enrichment.fisher_representation(**kwargs) + assert hyper_enrichment < 0.05 + assert fisher['pval'][1] < 0.05 # index 1 is the enrichment p-value + + +# --------------------------------------------------------------------------------- +# multitest +# --------------------------------------------------------------------------------- + +@pytest.fixture +def symmetric_pvalues(): + values = np.array([[1.0, 0.001, 0.5], + [0.001, 1.0, 0.04], + [0.5, 0.04, 1.0]]) + return pd.DataFrame(values, index=['a', 'b', 'c'], columns=['a', 'b', 'c']) + + +def test_fdrcorrection_symmetric_keeps_symmetry(symmetric_pvalues): + result = multitest.compute_fdrcorrection_symmetric_matrix(symmetric_pvalues, alpha=0.1) + assert np.allclose(result.values, result.values.T) + assert list(result.index) == list(symmetric_pvalues.index) + + +def test_fdrcorrection_symmetric_only_increases_pvalues(symmetric_pvalues): + result = multitest.compute_fdrcorrection_symmetric_matrix(symmetric_pvalues, alpha=0.1) + lower = np.tril_indices_from(result.values, k=-1) + assert (result.values[lower] >= symmetric_pvalues.values[lower] - 1e-12).all() + + +def test_fdrcorrection_asymmetric_shape_and_labels(): + values = np.array([[0.001, 0.2], [0.03, 0.9]]) + frame = pd.DataFrame(values, index=['a', 'b'], columns=['x', 'y']) + result = multitest.compute_fdrcorrection_asymmetric_matrix(frame, alpha=0.1) + assert result.shape == frame.shape + assert list(result.index) == ['a', 'b'] + assert list(result.columns) == ['x', 'y'] + assert (result.values >= frame.values - 1e-12).all() + + +def test_fdrcorrection_does_not_modify_input(symmetric_pvalues): + before = symmetric_pvalues.copy() + multitest.compute_fdrcorrection_symmetric_matrix(symmetric_pvalues) + pd.testing.assert_frame_equal(symmetric_pvalues, before) + + +# --------------------------------------------------------------------------------- +# permutation helpers +# --------------------------------------------------------------------------------- + +def test_compute_pvalue_from_dist_upper(): + distribution = np.arange(100.) + assert np.isclose(permutation.compute_pvalue_from_dist(200., distribution, + comparison='upper'), 0.0) + assert np.isclose(permutation.compute_pvalue_from_dist(-1., distribution, + comparison='upper'), 1.0) + + +def test_compute_pvalue_from_dist_lower(): + distribution = np.arange(100.) + assert np.isclose(permutation.compute_pvalue_from_dist(-1., distribution, + comparison='lower'), 0.0) + + +def test_compute_pvalue_from_dist_different_returns_two_sided(): + distribution = np.arange(-50., 50.) + pvalue = permutation.compute_pvalue_from_dist(0., distribution, + comparison='different') + assert 0.0 <= pvalue <= 1.0 + + +def test_compute_pvalue_from_dist_is_bounded(): + distribution = np.random.default_rng(0).normal(size=200) + for value in [-5., 0., 5.]: + pvalue = permutation.compute_pvalue_from_dist(value, distribution) + assert 0.0 <= pvalue <= 1.0 + + +def test_compute_pvalue_from_dist_with_an_empty_distribution(): + assert np.isclose(permutation.compute_pvalue_from_dist(1.0, []), 1.0) + + +def test_pvalue_from_dist_returns_a_labelled_result(): + distribution = np.arange(100.) + result = permutation.pvalue_from_dist(150., distribution, label='my-label') + assert result is not None + + +# --------------------------------------------------------------------------------- +# random_switching_ppi_labels +# --------------------------------------------------------------------------------- + +def test_random_switching_ppi_labels_is_reproducible(toy_ppi): + genes = sorted(set(toy_ppi['A']).union(toy_ppi['B'])) + first = permutation.random_switching_ppi_labels(toy_ppi, genes=genes, random_state=0) + second = permutation.random_switching_ppi_labels(toy_ppi, genes=genes, random_state=0) + pd.testing.assert_frame_equal(first, second) + + +def test_random_switching_ppi_labels_without_genes_is_reproducible(toy_ppi): + first = permutation.random_switching_ppi_labels(toy_ppi, random_state=1) + second = permutation.random_switching_ppi_labels(toy_ppi, random_state=1) + pd.testing.assert_frame_equal(first, second) + + +@pytest.mark.parametrize('permuted_column', ['both', 'first', 'second']) +def test_random_switching_ppi_labels_columns(toy_ppi, permuted_column): + result = permutation.random_switching_ppi_labels(toy_ppi, random_state=0, + permuted_column=permuted_column) + assert result.shape == toy_ppi.shape + assert list(result.columns) == list(toy_ppi.columns) + + +def test_random_switching_ppi_labels_only_permutes_the_chosen_column(toy_ppi): + result = permutation.random_switching_ppi_labels(toy_ppi, random_state=0, + permuted_column='first') + assert list(result['B']) == list(toy_ppi['B']) + + +def test_random_switching_ppi_labels_rejects_bad_column(toy_ppi): + with pytest.raises(ValueError): + permutation.random_switching_ppi_labels(toy_ppi, permuted_column='nonsense') + + +def test_random_switching_ppi_labels_does_not_modify_input(toy_ppi): + before = toy_ppi.copy() + permutation.random_switching_ppi_labels(toy_ppi, random_state=0) + pd.testing.assert_frame_equal(toy_ppi, before) + + +@pytest.mark.parametrize('permuted_column', ['both', 'first', 'second']) +def test_random_switching_ppi_labels_with_the_string_dtype(toy_ppi, permuted_column): + '''The gene names used to be collected with `.values.flatten()`. pandas >= 3.0 makes + `str` the default dtype, so the values of a single column are an extension array, + which has no `.flatten()`. Casting to "string" reproduces that on older pandas.''' + ppi_data = toy_ppi.copy() + ppi_data[['A', 'B']] = ppi_data[['A', 'B']].astype('string') + result = permutation.random_switching_ppi_labels(ppi_data, random_state=0, + permuted_column=permuted_column) + assert result.shape == ppi_data.shape + assert list(result.columns) == list(ppi_data.columns) + # Labels are swapped among themselves, so no new name can appear + known_genes = set(toy_ppi['A']).union(toy_ppi['B']) + for column in ('A', 'B'): + assert set(result[column]).issubset(known_genes) + + +# --------------------------------------------------------------------------------- +# run_label_permutation +# --------------------------------------------------------------------------------- + +@pytest.mark.slow +def test_run_label_permutation_shape(toy_rnaseq, toy_ppi, analysis_setup, cutoff_setup): + genes = list(toy_rnaseq.index) + result = permutation.run_label_permutation(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + genes=genes, + analysis_setup=analysis_setup, + cutoff_setup=cutoff_setup, + permutations=5, verbose=False) + assert result.shape == (toy_rnaseq.shape[1], toy_rnaseq.shape[1]) + assert list(result.columns) == list(toy_rnaseq.columns) + assert ((result.values >= 0) & (result.values <= 1)).all() + + +@pytest.mark.slow +def test_run_label_permutation_excludes_cells(toy_rnaseq, toy_ppi, analysis_setup, + cutoff_setup): + result = permutation.run_label_permutation(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + genes=list(toy_rnaseq.index), + analysis_setup=analysis_setup, + cutoff_setup=cutoff_setup, + permutations=3, excluded_cells=['C1'], + verbose=False) + assert 'C1' not in result.columns + + +# --------------------------------------------------------------------------------- +# random_switching_ppi_labels crashed with its own default arguments +# +# `ppi_data[interaction_columns]` passed a TUPLE to pandas, which reads it as a +# single column name. So the simplest possible call -- default genes=None and +# default permuted_column='both' -- always raised KeyError: ('A', 'B'). +# --------------------------------------------------------------------------------- + +def test_random_switching_ppi_labels_works_with_default_arguments(toy_ppi): + result = permutation.random_switching_ppi_labels(toy_ppi, random_state=0) + assert result.shape == toy_ppi.shape + assert list(result.columns) == list(toy_ppi.columns) + # The permutation relabels genes, so the multiset of genes is preserved + original = sorted(list(toy_ppi['A']) + list(toy_ppi['B'])) + permuted = sorted(list(result['A']) + list(result['B'])) + assert len(original) == len(permuted) + + +def test_random_switching_ppi_labels_default_is_reproducible(toy_ppi): + first = permutation.random_switching_ppi_labels(toy_ppi, random_state=3) + second = permutation.random_switching_ppi_labels(toy_ppi, random_state=3) + pd.testing.assert_frame_equal(first, second) + + +# --------------------------------------------------------------------------------- +# run_label_permutation compared scores it never computed +# +# `interaction_elements['cci_matrix']` is only filled by +# `InteractionSpace.compute_pairwise_cci_scores`, which this function never called. +# It read the zeros the interaction space is initialized with, for both the +# permuted and the observed spaces, so every p-value was the same constant and the +# expression data had no effect on the result at all. +# --------------------------------------------------------------------------------- + +@pytest.mark.slow +def test_run_label_permutation_pvalues_are_not_all_equal(toy_rnaseq, toy_ppi, analysis_setup, + cutoff_setup): + result = permutation.run_label_permutation(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + genes=list(toy_rnaseq.index), + analysis_setup=analysis_setup, + cutoff_setup=cutoff_setup, + permutations=10, verbose=False) + # Comparing an all-zeros observed matrix against an all-zeros null gives one + # constant for every cell pair + assert np.unique(result.values).size > 1 + + +@pytest.mark.slow +def test_run_label_permutation_depends_on_the_expression_values(toy_rnaseq, toy_ppi, + analysis_setup, cutoff_setup): + '''The p-values must change when the expression matrix does. They did not, because + the compared scores came from a matrix that was never filled.''' + scrambled = pd.DataFrame(np.random.default_rng(0).random(toy_rnaseq.shape) * toy_rnaseq.values.max(), + index=toy_rnaseq.index, columns=toy_rnaseq.columns) + + kwargs = dict(ppi_data=toy_ppi, genes=list(toy_rnaseq.index), + analysis_setup=analysis_setup, cutoff_setup=cutoff_setup, + permutations=10, verbose=False) + observed = permutation.run_label_permutation(rnaseq_data=toy_rnaseq, **kwargs) + randomized = permutation.run_label_permutation(rnaseq_data=scrambled, **kwargs) + + assert not np.allclose(observed.values, randomized.values) diff --git a/tests/test_tensor_coupled.py b/tests/test_tensor_coupled.py new file mode 100644 index 0000000..adfe1a1 --- /dev/null +++ b/tests/test_tensor_coupled.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.tensor.coupled_tensor and coupled_factorization''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.tensor import coupled_factorization + + +SHARED_CONTEXTS = {'shared': [(0, 0)]} + + +@pytest.fixture +def two_tensors(toy_contexts, toy_ppi): + '''Two tensors that share their context dimension.''' + matrices = list(toy_contexts.values()) + names = list(toy_contexts.keys()) + + def build(scale): + return c2c.tensor.InteractionTensor( + rnaseq_matrices=[matrix * scale for matrix in matrices], + ppi_data=toy_ppi, context_names=names, how='inner', + complex_sep=None, communication_score='expression_product', + verbose=False) + + return build(1.0), build(1.7) + + +@pytest.fixture +def coupled(two_tensors): + first, second = two_tensors + return c2c.tensor.CoupledInteractionTensor(tensor1=first, tensor2=second, + mode_mapping=SHARED_CONTEXTS) + + +@pytest.fixture +def factorized_coupled(coupled): + coupled.compute_tensor_factorization(rank=2, random_state=0) + return coupled + + +# --------------------------------------------------------------------------------- +# Construction and validation +# --------------------------------------------------------------------------------- + +def test_coupled_tensor_keeps_both_tensors(coupled, two_tensors): + first, second = two_tensors + assert coupled.shape is not None + assert np.asarray(coupled.tensor1).shape == np.asarray(first.tensor).shape + assert np.asarray(coupled.tensor2).shape == np.asarray(second.tensor).shape + + +def test_coupled_tensor_shared_mode_elements_agree(coupled): + assert list(coupled.order_names1[0]) == list(coupled.order_names2[0]) + + +def test_coupled_tensor_rejects_an_empty_mode_mapping(two_tensors): + first, second = two_tensors + with pytest.raises(ValueError): + c2c.tensor.CoupledInteractionTensor(tensor1=first, tensor2=second, + mode_mapping={'shared': []}) + + +def test_coupled_tensor_rejects_out_of_range_modes(two_tensors): + '''A mode outside the tensor's dimensions is rejected, though the error type is + an IndexError rather than the validated ValueError.''' + first, second = two_tensors + with pytest.raises((ValueError, IndexError)): + c2c.tensor.CoupledInteractionTensor(tensor1=first, tensor2=second, + mode_mapping={'shared': [(0, 99)]}) + + +def test_coupled_tensor_accepts_several_shared_modes(two_tensors): + first, second = two_tensors + instance = c2c.tensor.CoupledInteractionTensor( + tensor1=first, tensor2=second, mode_mapping={'shared': [(0, 0), (2, 2)]}) + assert instance is not None + + +def test_coupled_tensor_reorders_a_misaligned_shared_mode(toy_contexts, toy_ppi): + matrices = list(toy_contexts.values()) + names = list(toy_contexts.keys()) + first = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices, ppi_data=toy_ppi, + context_names=names, how='inner', + complex_sep=None, verbose=False) + # The same contexts, in a different order + order = [3, 1, 0, 2] + second = c2c.tensor.InteractionTensor( + rnaseq_matrices=[matrices[i] for i in order], ppi_data=toy_ppi, + context_names=[names[i] for i in order], how='inner', complex_sep=None, + verbose=False) + instance = c2c.tensor.CoupledInteractionTensor(tensor1=first, tensor2=second, + mode_mapping=SHARED_CONTEXTS, + auto_sort_shared=True) + assert list(instance.order_names1[0]) == list(instance.order_names2[0]) + + +# --------------------------------------------------------------------------------- +# Factorization +# --------------------------------------------------------------------------------- + +def test_coupled_factorization_produces_factors(factorized_coupled): + assert factorized_coupled.rank == 2 + assert len(factorized_coupled.factors1) == 4 + assert len(factorized_coupled.factors2) == 4 + for frame in factorized_coupled.factors1.values(): + assert list(frame.columns) == ['Factor 1', 'Factor 2'] + + +def test_coupled_factorization_shares_the_coupled_mode(factorized_coupled): + '''The shared dimension must have identical loadings in both tensors.''' + label1 = factorized_coupled.order_labels1[0] + label2 = factorized_coupled.order_labels2[0] + shared1 = factorized_coupled.factors1[label1] + shared2 = factorized_coupled.factors2[label2] + assert np.allclose(shared1.values, shared2.values) + + +def test_coupled_factorization_is_reproducible(coupled): + coupled.compute_tensor_factorization(rank=2, random_state=13) + first = {k: v.copy() for k, v in coupled.factors1.items()} + coupled.compute_tensor_factorization(rank=2, random_state=13) + for key, value in first.items(): + pd.testing.assert_frame_equal(value, coupled.factors1[key]) + + +def test_coupled_factorization_loadings_are_non_negative(factorized_coupled): + for factors in [factorized_coupled.factors1, factorized_coupled.factors2]: + for frame in factors.values(): + assert (frame.values >= -1e-9).all() + + +def test_coupled_factorization_indexes_match_the_element_names(factorized_coupled): + for factors, names in [(factorized_coupled.factors1, + factorized_coupled.order_names1), + (factorized_coupled.factors2, + factorized_coupled.order_names2)]: + for frame, elements in zip(factors.values(), names): + assert list(frame.index) == list(elements) + + +def test_coupled_get_factorization_errors(factorized_coupled): + errors = factorized_coupled.get_factorization_errors() + assert errors is not None + + +def test_coupled_explained_variance(factorized_coupled): + variance = factorized_coupled.explained_variance() + assert variance is not None + + +def test_coupled_get_top_factor_elements(factorized_coupled): + label = factorized_coupled.order_labels1[2] + top = factorized_coupled.get_top_factor_elements(order_name=label, + factor_name='Factor 1', + top_number=2, tensor='tensor1') + assert len(top) == 2 + assert top.is_monotonic_decreasing + + +def test_coupled_export_factor_loadings(factorized_coupled, tmp_path): + filename = tmp_path / 'coupled.xlsx' + factorized_coupled.export_factor_loadings(str(filename)) + assert filename.exists() and filename.stat().st_size > 0 + + +@pytest.mark.parametrize('which', ['both', 'tensor1', 'tensor2']) +def test_coupled_fraction_helpers(factorized_coupled, which): + '''Depending on the method and `tensor`, the result is a scalar or a dict.''' + for method in ['excluded_value_fraction', 'sparsity_fraction', 'missing_fraction']: + result = getattr(factorized_coupled, method)(tensor=which) + if isinstance(result, dict): + values = list(result.values()) + elif isinstance(result, (tuple, list)): + values = list(result) + else: + values = [result] + for value in values: + assert 0.0 <= float(value) <= 1.0 + + +def test_coupled_copy_is_independent(coupled): + duplicate = coupled.copy() + original = np.asarray(coupled.tensor1).copy() + np.asarray(duplicate.tensor1)[0, 0, 0, 0] = 4242.0 + assert np.allclose(np.asarray(coupled.tensor1), original) + + +def test_coupled_write_file_roundtrip(factorized_coupled, tmp_path): + filename = str(tmp_path / 'coupled.pkl') + factorized_coupled.write_file(filename) + loaded = c2c.io.load_variable_with_pickle(filename) + assert np.allclose(np.asarray(loaded.tensor1), + np.asarray(factorized_coupled.tensor1)) + + +def test_coupled_reorder_metadata(factorized_coupled): + metadata1 = [pd.DataFrame({'Element': list(names), 'Category': list(names)}) + for names in factorized_coupled.order_names1] + metadata2 = [pd.DataFrame({'Element': list(names), 'Category': list(names)}) + for names in factorized_coupled.order_names2] + reordered = factorized_coupled.reorder_metadata(metadata1, metadata2) + # Returns a single list covering the shared mode plus each tensor's own modes + assert isinstance(reordered, list) + assert len(reordered) > 0 + for frame in reordered: + assert 'Element' in frame.columns + + +@pytest.mark.slow +def test_coupled_elbow_rank_selection(coupled): + result = coupled.elbow_rank_selection(upper_rank=3, runs=1, automatic_elbow=False, + manual_elbow=2, random_state=0, verbose=False) + assert result is not None + + +# --------------------------------------------------------------------------------- +# coupled_non_negative_parafac +# --------------------------------------------------------------------------------- + +def test_coupled_non_negative_parafac_shapes(two_tensors): + first, second = two_tensors + result = coupled_factorization.coupled_non_negative_parafac( + np.asarray(first.tensor), np.asarray(second.tensor), rank=2, + mode_mapping=SHARED_CONTEXTS, n_iter_max=10, init='random', random_state=0) + assert result is not None + + +def test_coupled_non_negative_parafac_is_reproducible(two_tensors): + first, second = two_tensors + kwargs = dict(rank=2, mode_mapping=SHARED_CONTEXTS, n_iter_max=10, init='random', + random_state=4) + one = coupled_factorization.coupled_non_negative_parafac( + np.asarray(first.tensor), np.asarray(second.tensor), **kwargs) + two = coupled_factorization.coupled_non_negative_parafac( + np.asarray(first.tensor), np.asarray(second.tensor), **kwargs) + assert str(one) == str(two) + + +def test_process_mode_mapping_accepts_a_dict(two_tensors): + first, second = two_tensors + mapping = coupled_factorization._process_mode_mapping( + np.asarray(first.tensor), np.asarray(second.tensor), SHARED_CONTEXTS) + assert isinstance(mapping, dict) + + +def test_process_mode_mapping_rejects_a_bad_type(two_tensors): + first, second = two_tensors + with pytest.raises(ValueError): + coupled_factorization._process_mode_mapping( + np.asarray(first.tensor), np.asarray(second.tensor), 'nonsense') diff --git a/tests/test_tensor_elbow.py b/tests/test_tensor_elbow.py new file mode 100644 index 0000000..2e5bd9d --- /dev/null +++ b/tests/test_tensor_elbow.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- + +'''Tests for the elbow analysis: cell2cell.tensor.factorization and its plots. + +These exercise the rank-selection workflow, which is how users choose the number of +factors, and the plotting helpers that report it. +''' + +import numpy as np +import pytest +from matplotlib import pyplot as plt + +import cell2cell as c2c +from cell2cell.plotting import tensor_plot +from cell2cell.tensor import factorization + + +# --------------------------------------------------------------------------------- +# elbow_rank_selection +# --------------------------------------------------------------------------------- + +@pytest.mark.slow +def test_elbow_rank_selection_single_run(interaction_tensor): + fig, errors = interaction_tensor.elbow_rank_selection( + upper_rank=4, runs=1, automatic_elbow=False, manual_elbow=2, + random_state=0, verbose=False) + assert len(errors) == 4 + assert interaction_tensor.rank == 2 + # Errors are (rank, error) pairs with decreasing rank error overall + ranks = [rank for rank, _ in errors] + assert ranks == [1, 2, 3, 4] + + +@pytest.mark.slow +def test_elbow_rank_selection_multiple_runs(interaction_tensor): + fig, errors = interaction_tensor.elbow_rank_selection( + upper_rank=3, runs=2, automatic_elbow=False, manual_elbow=2, + random_state=0, verbose=False) + assert len(errors) == 3 + assert interaction_tensor.rank == 2 + for entry in errors: + assert len(entry) == 2 + + +@pytest.mark.slow +def test_elbow_rank_selection_automatic_needs_a_detectable_elbow(interaction_tensor): + '''Documents a limitation: when the elbow detector finds no elbow it returns None, + and the code then calls int(None), raising a TypeError instead of a clear message. + The toy tensor is small enough to hit that path. + ''' + with pytest.raises(TypeError): + interaction_tensor.elbow_rank_selection(upper_rank=5, runs=1, + automatic_elbow=True, + random_state=0, verbose=False) + + +@pytest.mark.slow +def test_elbow_rank_selection_is_reproducible(interaction_tensor): + _, first = interaction_tensor.elbow_rank_selection( + upper_rank=3, runs=1, automatic_elbow=False, manual_elbow=2, + random_state=5, verbose=False) + _, second = interaction_tensor.elbow_rank_selection( + upper_rank=3, runs=1, automatic_elbow=False, manual_elbow=2, + random_state=5, verbose=False) + assert np.allclose([e for _, e in first], [e for _, e in second]) + + +@pytest.mark.slow +def test_elbow_rank_selection_without_a_figure(interaction_tensor): + result = interaction_tensor.elbow_rank_selection( + upper_rank=3, runs=1, automatic_elbow=False, manual_elbow=2, + random_state=0, output_fig=False, verbose=False) + fig, errors = result + assert fig is None + assert len(errors) == 3 + + +@pytest.mark.slow +def test_elbow_rank_selection_smoothing_needs_enough_ranks(interaction_tensor): + '''smooth=True runs a Savitzky-Golay filter, whose polynomial order must be + smaller than the window, so it needs a reasonable number of ranks.''' + with pytest.raises(Exception): + interaction_tensor.elbow_rank_selection(upper_rank=4, runs=1, + automatic_elbow=True, smooth=True, + random_state=0, verbose=False) + + +# --------------------------------------------------------------------------------- +# Factorization internals +# --------------------------------------------------------------------------------- + +def test_compute_tensor_factorization_returns_errors(interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=2, random_state=0) + errors = interaction_tensor.get_factorization_errors() + assert errors is not None + assert len(errors) > 0 + + +def test_factorization_errors_decrease(interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=2, random_state=0) + errors = np.asarray(interaction_tensor.get_factorization_errors()) + # The optimization must not make the reconstruction worse overall + assert errors[-1] <= errors[0] + 1e-9 + + +@pytest.mark.parametrize('init', ['random', 'svd']) +def test_factorization_initializations(interaction_tensor, init): + interaction_tensor.compute_tensor_factorization(rank=2, init=init, random_state=0) + assert len(interaction_tensor.factors) == 4 + + +def test_factorization_without_normalizing_loadings(interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=2, random_state=0, + normalize_loadings=False) + assert interaction_tensor.explained_variance_ratio_ is None + assert len(interaction_tensor.factors) == 4 + + +def test_compute_elbow_picks_a_rank(): + loss = [(1, 0.9), (2, 0.5), (3, 0.45), (4, 0.44), (5, 0.43)] + elbow = factorization._compute_elbow(loss) + assert 1 <= elbow <= 5 + + +def test_compute_norm_error_is_between_zero_and_one(factorized_tensor): + '''Signature is (tensor, tl_object), in that order.''' + error = factorization._compute_norm_error(factorized_tensor.tensor, + factorized_tensor.tl_object) + assert 0.0 <= error <= 1.0 + + +# --------------------------------------------------------------------------------- +# Elbow plots +# --------------------------------------------------------------------------------- + +def test_plot_elbow_returns_a_figure(): + loss = [(1, 0.9), (2, 0.5), (3, 0.4), (4, 0.38)] + fig = tensor_plot.plot_elbow(loss, elbow=2) + assert fig is not None + + +def test_plot_elbow_without_an_elbow(): + loss = [(1, 0.9), (2, 0.5), (3, 0.4)] + assert tensor_plot.plot_elbow(loss) is not None + + +def test_plot_multiple_run_elbow(): + all_loss = np.array([[0.9, 0.85, 0.88], + [0.5, 0.52, 0.49], + [0.4, 0.41, 0.39]]).T + fig = tensor_plot.plot_multiple_run_elbow(all_loss, elbow=2) + assert fig is not None + + +def test_plot_multiple_run_elbow_with_std(): + all_loss = np.array([[0.9, 0.85, 0.88], + [0.5, 0.52, 0.49], + [0.4, 0.41, 0.39]]).T + fig = tensor_plot.plot_multiple_run_elbow(all_loss, elbow=2, ci='std') + assert fig is not None + + +def test_plot_factorization_errors(): + errors = [0.9, 0.6, 0.5, 0.45, 0.44] + fig = tensor_plot.plot_factorization_errors(errors) + assert fig is not None + + +def test_generate_plot_df(factorized_tensor): + frame = tensor_plot.generate_plot_df(factorized_tensor) + assert frame is not None + assert frame.shape[0] > 0 diff --git a/tests/test_tensor_external_scores.py b/tests/test_tensor_external_scores.py new file mode 100644 index 0000000..d61fdb6 --- /dev/null +++ b/tests/test_tensor_external_scores.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.tensor.external_scores''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.tensor.external_scores import _ordered_intersection + + +TENSOR_KWARGS = dict(sender_col='source', receiver_col='target', ligand_col='ligand', + receptor_col='receptor', score_col='score') + + +@pytest.fixture +def context_dict(toy_liana): + return {name: frame for name, frame in toy_liana.groupby('context')} + + +def test_ordered_intersection_keeps_first_list_order(): + lists = [['c', 'a', 'b'], ['b', 'c'], ['c', 'b', 'z']] + assert _ordered_intersection(lists) == ['c', 'b'] + + +def test_ordered_intersection_of_identical_lists(): + assert _ordered_intersection([['x', 'y'], ['x', 'y']]) == ['x', 'y'] + + +def test_dataframes_to_tensor_shape_and_labels(context_dict): + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', **TENSOR_KWARGS) + assert tensor.tensor.ndim == 4 + contexts, lr_pairs, senders, receivers = [list(o) for o in tensor.order_names] + assert len(contexts) == 3 + assert len(lr_pairs) == 10 + assert senders == receivers == ['CT-1', 'CT-2', 'CT-3'] + + +def test_dataframes_to_tensor_values_match_labels(context_dict): + '''Independently recompute every labelled position from the long dataframe.''' + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', **TENSOR_KWARGS) + contexts, lr_pairs, senders, receivers = [list(o) for o in tensor.order_names] + + for c, context in enumerate(contexts): + frame = context_dict[context] + for l, lr_pair in enumerate(lr_pairs): + ligand, receptor = lr_pair.split('^') + for s, sender in enumerate(senders): + for r, receiver in enumerate(receivers): + match = frame[(frame['source'] == sender) & + (frame['target'] == receiver) & + (frame['ligand'] == ligand) & + (frame['receptor'] == receptor)] + assert np.isclose(tensor.tensor[c, l, s, r], match['score'].iloc[0]) + + +def test_dataframes_to_tensor_sorts_elements_naturally(): + liana = c2c.datasets.generate_toy_liana_output(n_contexts=11, n_cell_types=11) + context_dict = {name: frame for name, frame in liana.groupby('context')} + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', + sort_elements=True, **TENSOR_KWARGS) + contexts = list(tensor.order_names[0]) + senders = list(tensor.order_names[2]) + assert contexts[-2:] == ['Context-10', 'Context-11'] + assert contexts != sorted(contexts) + assert senders[-2:] == ['CT-10', 'CT-11'] + + +def test_dataframes_to_tensor_without_sorting_is_reproducible(context_dict): + first = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', + sort_elements=False, **TENSOR_KWARGS) + second = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', + sort_elements=False, **TENSOR_KWARGS) + assert [list(o) for o in first.order_names] == [list(o) for o in second.order_names] + assert np.allclose(np.asarray(first.tensor), np.asarray(second.tensor)) + + +def test_dataframes_to_tensor_unsorted_still_aligns_values(context_dict): + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', + sort_elements=False, **TENSOR_KWARGS) + contexts, lr_pairs, senders, receivers = [list(o) for o in tensor.order_names] + frame = context_dict[contexts[0]] + ligand, receptor = lr_pairs[0].split('^') + match = frame[(frame['source'] == senders[0]) & (frame['target'] == receivers[0]) & + (frame['ligand'] == ligand) & (frame['receptor'] == receptor)] + assert np.isclose(tensor.tensor[0, 0, 0, 0], match['score'].iloc[0]) + + +def test_dataframes_to_tensor_respects_a_given_context_order(context_dict): + order = ['Context-3', 'Context-1', 'Context-2'] + tensor = c2c.tensor.dataframes_to_tensor(context_dict, context_order=order, + how='inner', **TENSOR_KWARGS) + assert list(tensor.order_names[0]) == order + + +def test_dataframes_to_tensor_rejects_bad_context_order(context_dict): + with pytest.raises(AssertionError): + c2c.tensor.dataframes_to_tensor(context_dict, context_order=['nope'], + how='inner', **TENSOR_KWARGS) + + +def test_dataframes_to_tensor_rejects_missing_columns(context_dict): + broken = {k: v.drop(columns=['score']) for k, v in context_dict.items()} + with pytest.raises(AssertionError): + c2c.tensor.dataframes_to_tensor(broken, how='inner', **TENSOR_KWARGS) + + +def test_dataframes_to_tensor_rejects_invalid_how(context_dict): + with pytest.raises(ValueError): + c2c.tensor.dataframes_to_tensor(context_dict, how='nonsense', **TENSOR_KWARGS) + + +@pytest.mark.parametrize('how', ['inner', 'outer', 'outer_lrs', 'outer_cells']) +def test_dataframes_to_tensor_how_options(context_dict, how): + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how=how, outer_fraction=0.0, + **TENSOR_KWARGS) + assert tensor.tensor.ndim == 4 + + +def test_dataframes_to_tensor_outer_flags_absent_combinations(toy_liana): + '''Missing combinations become 0.0 and are recorded in loc_nans.''' + liana = toy_liana[~((toy_liana['context'] == 'Context-1') & + (toy_liana['source'] == 'CT-3'))] + context_dict = {name: frame for name, frame in liana.groupby('context')} + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='outer', outer_fraction=0.0, + **TENSOR_KWARGS) + loc_nans = np.asarray(tensor.loc_nans) + assert loc_nans.sum() > 0 + # Where flagged as missing, the stored value must be zero + assert np.allclose(np.asarray(tensor.tensor)[loc_nans == 1], 0.0) + + +def test_dataframes_to_tensor_aggregates_duplicates(context_dict): + duplicated = {k: pd.concat([v, v.head(1)]) for k, v in context_dict.items()} + tensor = c2c.tensor.dataframes_to_tensor(duplicated, how='inner', + dup_aggregation='max', **TENSOR_KWARGS) + assert tensor.tensor.ndim == 4 + + +def test_dataframes_to_tensor_custom_lr_separator(context_dict): + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', lr_sep='::', + **TENSOR_KWARGS) + assert all('::' in name for name in tensor.order_names[1]) + + +def test_dataframes_to_tensor_custom_order_labels(context_dict): + labels = ['Samples', 'LRs', 'From', 'To'] + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', + order_labels=labels, **TENSOR_KWARGS) + assert tensor.order_labels == labels + + +def test_dataframes_to_tensor_can_be_factorized(context_dict): + tensor = c2c.tensor.dataframes_to_tensor(context_dict, how='inner', **TENSOR_KWARGS) + tensor.compute_tensor_factorization(rank=2, random_state=0) + assert len(tensor.factors) == 4 diff --git a/tests/test_tensor_helpers.py b/tests/test_tensor_helpers.py new file mode 100644 index 0000000..be186f9 --- /dev/null +++ b/tests/test_tensor_helpers.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.tensor subset, metrics, factor_manipulation and manipulation''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.tensor import factor_manipulation, metrics, subset, tensor_manipulation + + +# --------------------------------------------------------------------------------- +# subset +# --------------------------------------------------------------------------------- + +def test_subset_tensor_reduces_a_dimension(interaction_tensor): + cells = list(interaction_tensor.order_names[2])[:2] + subsetted = subset.subset_tensor(interaction_tensor, {2: cells}) + assert list(subsetted.order_names[2]) == cells + assert subsetted.tensor.shape[2] == len(cells) + # Other dimensions are untouched + assert subsetted.tensor.shape[0] == interaction_tensor.tensor.shape[0] + + +def test_subset_tensor_keeps_the_values_of_the_kept_elements(interaction_tensor): + cells = list(interaction_tensor.order_names[2]) + keep = [cells[2], cells[0]] + subsetted = subset.subset_tensor(interaction_tensor, {2: keep}) + original = np.asarray(interaction_tensor.tensor) + reduced = np.asarray(subsetted.tensor) + for new_index, cell in enumerate(subsetted.order_names[2]): + old_index = cells.index(cell) + assert np.allclose(reduced[:, :, new_index, :], original[:, :, old_index, :]) + + +def test_subset_tensor_multiple_dimensions(interaction_tensor): + contexts = list(interaction_tensor.order_names[0])[:2] + cells = list(interaction_tensor.order_names[2])[:2] + subsetted = subset.subset_tensor(interaction_tensor, {0: contexts, 2: cells}) + assert subsetted.tensor.shape[0] == 2 + assert subsetted.tensor.shape[2] == 2 + + +def test_subset_tensor_original_order(interaction_tensor): + cells = list(interaction_tensor.order_names[2]) + reversed_cells = cells[::-1] + keep_given = subset.subset_tensor(interaction_tensor, {2: reversed_cells}, + original_order=False) + keep_original = subset.subset_tensor(interaction_tensor, {2: reversed_cells}, + original_order=True) + assert list(keep_given.order_names[2]) == reversed_cells + assert list(keep_original.order_names[2]) == cells + + +def test_subset_tensor_also_subsets_the_masks(interaction_tensor): + cells = list(interaction_tensor.order_names[2])[:2] + subsetted = subset.subset_tensor(interaction_tensor, {2: cells}) + assert np.asarray(subsetted.loc_nans).shape == np.asarray(subsetted.tensor).shape + assert np.asarray(subsetted.loc_zeros).shape == np.asarray(subsetted.tensor).shape + + +def test_subset_tensor_does_not_modify_the_original(interaction_tensor): + before = np.asarray(interaction_tensor.tensor).copy() + subset.subset_tensor(interaction_tensor, {2: list(interaction_tensor.order_names[2])[:2]}) + assert np.allclose(np.asarray(interaction_tensor.tensor), before) + + +def test_subset_metadata(factorized_tensor): + metadata = c2c.tensor.generate_tensor_metadata( + interaction_tensor=factorized_tensor, + metadata_dicts=[None, None, None, None], + fill_with_order_elements=True) + cells = list(factorized_tensor.order_names[2])[:2] + subsetted = subset.subset_tensor(factorized_tensor, {2: cells}) + new_metadata = subset.subset_metadata(metadata, subsetted) + assert list(new_metadata[2]['Element']) == cells + + +# --------------------------------------------------------------------------------- +# metrics +# --------------------------------------------------------------------------------- + +def test_correlation_index_of_identical_factors_is_zero(factorized_tensor): + factors = factorized_tensor.factors + result = metrics.correlation_index(factors, factors) + assert np.isclose(result, 0.0, atol=1e-6) + + +def test_correlation_index_is_symmetric(factorized_tensor, interaction_tensor): + first = factorized_tensor.factors + interaction_tensor.compute_tensor_factorization(rank=3, random_state=99) + second = interaction_tensor.factors + forward = metrics.correlation_index(first, second) + backward = metrics.correlation_index(second, first) + assert np.isclose(forward, backward) + + +def test_correlation_index_is_bounded(factorized_tensor, interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=3, random_state=5) + result = metrics.correlation_index(factorized_tensor.factors, + interaction_tensor.factors) + assert 0.0 <= result <= 1.0 + + +def test_pairwise_correlation_index(factorized_tensor, interaction_tensor): + first = factorized_tensor.factors + interaction_tensor.compute_tensor_factorization(rank=3, random_state=11) + second = interaction_tensor.factors + result = metrics.pairwise_correlation_index([first, second]) + assert result.shape == (2, 2) + assert np.allclose(np.diag(result.values), 0.0, atol=1e-6) + assert np.allclose(result.values, result.values.T) + + +# --------------------------------------------------------------------------------- +# factor_manipulation +# --------------------------------------------------------------------------------- + +def test_normalize_factors_gives_unit_norm_columns(factorized_tensor): + normalized = factor_manipulation.normalize_factors(factorized_tensor.factors) + for frame in normalized.values(): + norms = np.linalg.norm(frame.values, axis=0) + assert np.allclose(norms, 1.0) + + +def test_normalize_factors_preserves_labels(factorized_tensor): + normalized = factor_manipulation.normalize_factors(factorized_tensor.factors) + assert list(normalized.keys()) == list(factorized_tensor.factors.keys()) + for key, frame in normalized.items(): + assert list(frame.index) == list(factorized_tensor.factors[key].index) + assert list(frame.columns) == list(factorized_tensor.factors[key].columns) + + +def test_normalize_factors_keeps_directions(factorized_tensor): + normalized = factor_manipulation.normalize_factors(factorized_tensor.factors) + original = factorized_tensor.factors['Sender Cells']['Factor 1'].values + scaled = normalized['Sender Cells']['Factor 1'].values + # Same direction, so the correlation must be 1 + assert np.isclose(np.corrcoef(original, scaled)[0, 1], 1.0) + + +# --------------------------------------------------------------------------------- +# tensor_manipulation +# --------------------------------------------------------------------------------- + +def test_concatenate_interaction_tensors(toy_contexts, toy_ppi): + matrices = list(toy_contexts.values()) + names = list(toy_contexts.keys()) + first = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices[:2], ppi_data=toy_ppi, + context_names=names[:2], how='inner', + complex_sep=None, verbose=False) + second = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices[2:], ppi_data=toy_ppi, + context_names=names[2:], how='inner', + complex_sep=None, verbose=False) + labels = ['Contexts', 'Ligand-Receptor Pairs', 'Sender Cells', 'Receiver Cells'] + combined = tensor_manipulation.concatenate_interaction_tensors( + [first, second], axis=0, order_labels=labels) + assert combined.tensor.shape[0] == 4 + assert list(combined.order_names[0]) == names + + +def test_concatenated_tensor_keeps_the_original_values(toy_contexts, toy_ppi): + matrices = list(toy_contexts.values()) + names = list(toy_contexts.keys()) + first = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices[:2], ppi_data=toy_ppi, + context_names=names[:2], how='inner', + complex_sep=None, verbose=False) + second = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices[2:], ppi_data=toy_ppi, + context_names=names[2:], how='inner', + complex_sep=None, verbose=False) + labels = ['Contexts', 'Ligand-Receptor Pairs', 'Sender Cells', 'Receiver Cells'] + combined = tensor_manipulation.concatenate_interaction_tensors( + [first, second], axis=0, order_labels=labels) + assert np.allclose(np.asarray(combined.tensor)[:2], + np.asarray(first.tensor)) + + +def test_concatenated_tensor_can_be_factorized(toy_contexts, toy_ppi): + matrices = list(toy_contexts.values()) + names = list(toy_contexts.keys()) + tensors = [c2c.tensor.InteractionTensor(rnaseq_matrices=matrices[i:i + 2], + ppi_data=toy_ppi, + context_names=names[i:i + 2], how='inner', + complex_sep=None, verbose=False) + for i in (0, 2)] + labels = ['Contexts', 'Ligand-Receptor Pairs', 'Sender Cells', 'Receiver Cells'] + combined = tensor_manipulation.concatenate_interaction_tensors(tensors, axis=0, + order_labels=labels) + combined.compute_tensor_factorization(rank=2, random_state=0) + assert len(combined.factors) == 4 + + +# --------------------------------------------------------------------------------- +# Functions that were broken on the default (numpy) tensorly backend +# +# `concatenate_interaction_tensors` called `.to('cpu')`, a pytorch-only method, and +# then read `context['device']`, a key that a numpy context does not have. So it +# always failed unless a pytorch backend was configured. +# --------------------------------------------------------------------------------- + +def test_concatenate_interaction_tensors_works_on_the_numpy_backend(toy_contexts, toy_ppi): + matrices = list(toy_contexts.values()) + names = list(toy_contexts.keys()) + labels = ['Contexts', 'Ligand-Receptor Pairs', 'Sender Cells', 'Receiver Cells'] + + def build(start, stop): + return c2c.tensor.InteractionTensor(rnaseq_matrices=matrices[start:stop], + ppi_data=toy_ppi, + context_names=names[start:stop], + how='inner', complex_sep=None, verbose=False) + + first, second = build(0, 2), build(2, 4) + combined = c2c.tensor.concatenate_interaction_tensors([first, second], axis=0, + order_labels=labels) + assert combined.tensor.shape[0] == 4 + assert list(combined.order_names[0]) == names + assert np.allclose(np.asarray(combined.tensor)[:2], np.asarray(first.tensor)) diff --git a/tests/test_tensor_tensor.py b/tests/test_tensor_tensor.py new file mode 100644 index 0000000..ab530ae --- /dev/null +++ b/tests/test_tensor_tensor.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.tensor.tensor''' + +import numpy as np +import pandas as pd +import pytest + +import cell2cell as c2c +from cell2cell.analysis.tensor_downstream import (compute_gini_coefficients, + flatten_factor_ccc_networks, + get_factor_specific_ccc_networks) + + +EXPECTED_LABELS = ['Contexts', 'Ligand-Receptor Pairs', 'Sender Cells', 'Receiver Cells'] + + +# --------------------------------------------------------------------------------- +# InteractionTensor construction +# --------------------------------------------------------------------------------- + +def test_interaction_tensor_shape_and_names(interaction_tensor, toy_contexts): + tensor = interaction_tensor + assert tensor.tensor.ndim == 4 + assert tensor.tensor.shape[0] == len(toy_contexts) + assert len(tensor.order_names) == 4 + for names, size in zip(tensor.order_names, tensor.tensor.shape): + assert len(names) == size + + +def test_default_dimension_labels_are_exposed_through_factors(interaction_tensor): + '''Documents a known wart: `order_labels` stays None when it is not supplied. + + `compute_tensor_factorization` builds the default labels in a local variable and + uses them as the keys of `factors`, but never assigns them back to + `self.order_labels`. Several docstrings say the labels are "usually found in + InteractionTensor.order_labels", so the reliable source is `factors.keys()`. + ''' + assert interaction_tensor.order_labels is None + interaction_tensor.compute_tensor_factorization(rank=2, random_state=0) + assert interaction_tensor.order_labels is None + assert list(interaction_tensor.factors.keys()) == EXPECTED_LABELS + + +def test_order_labels_can_be_given_upfront(toy_contexts, toy_ppi): + labels = ['Ctx', 'LR', 'Sender', 'Receiver'] + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi, + context_names=list(toy_contexts.keys()), + order_labels=labels, how='inner', + complex_sep=None, verbose=False) + assert tensor.order_labels == labels + + +def test_interaction_tensor_uses_the_same_cells_for_both_axes(interaction_tensor): + assert list(interaction_tensor.order_names[2]) == list(interaction_tensor.order_names[3]) + + +def test_interaction_tensor_lr_names_use_the_caret_separator(interaction_tensor): + for name in interaction_tensor.order_names[1]: + assert '^' in name + + +def test_interaction_tensor_values_match_their_labels(toy_contexts, toy_ppi): + '''Recomputes expression_product independently for every labelled position.''' + matrices = list(toy_contexts.values()) + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices, ppi_data=toy_ppi, + context_names=list(toy_contexts.keys()), + how='inner', complex_sep=None, + communication_score='expression_product', + verbose=False) + # Gene names are upper-cased when building the tensor + upper_to_original = {g.upper(): g for g in matrices[0].index} + + for c, matrix in enumerate(matrices): + for l, lr_pair in enumerate(tensor.order_names[1]): + ligand, receptor = lr_pair.split('^') + for s, sender in enumerate(tensor.order_names[2]): + for r, receiver in enumerate(tensor.order_names[3]): + expected = (matrix.loc[upper_to_original[ligand], sender] * + matrix.loc[upper_to_original[receptor], receiver]) + assert np.isclose(tensor.tensor[c, l, s, r], expected) + + +@pytest.mark.parametrize('how', ['inner', 'outer', 'outer_genes', 'outer_cells']) +def test_interaction_tensor_how_options(toy_contexts, toy_ppi, how): + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi, + context_names=list(toy_contexts.keys()), + how=how, outer_fraction=0.0, + complex_sep=None, verbose=False) + assert tensor.tensor.ndim == 4 + + +def test_interaction_tensor_rejects_invalid_how(toy_contexts, toy_ppi): + with pytest.raises(ValueError): + c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi, + context_names=list(toy_contexts.keys()), + how='nonsense', complex_sep=None, verbose=False) + + +def test_interaction_tensor_with_complexes(toy_contexts, toy_ppi_complex): + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi_complex, + context_names=list(toy_contexts.keys()), + how='inner', complex_sep='&', verbose=False) + assert tensor.tensor.ndim == 4 + assert len(tensor.order_names[1]) > 0 + + +def test_interaction_tensor_outer_records_missing_values(toy_ppi): + base = c2c.datasets.generate_toy_rnaseq() + matrices = [base.drop(columns=['C5']), base.drop(columns=['C1'])] + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices, ppi_data=toy_ppi, + context_names=['a', 'b'], how='outer', + outer_fraction=0.0, complex_sep=None, + verbose=False) + assert tensor.mask is not None + # Missing cells are stored as zeros and flagged in loc_nans + assert np.asarray(tensor.loc_nans).sum() > 0 + + +def test_interaction_tensor_excluded_value(toy_contexts, toy_ppi): + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi, + context_names=list(toy_contexts.keys()), + how='inner', complex_sep=None, verbose=False) + assert np.isfinite(np.asarray(tensor.tensor)).all() + + +# --------------------------------------------------------------------------------- +# PreBuiltTensor +# --------------------------------------------------------------------------------- + +def test_prebuilt_tensor_labels(prebuilt_tensor): + assert prebuilt_tensor.order_labels == EXPECTED_LABELS + assert list(prebuilt_tensor.order_names[2]) == ['C3', 'C1', 'C2'] + + +def test_prebuilt_tensor_default_labels(): + data = np.ones((2, 3, 4)) + tensor = c2c.tensor.PreBuiltTensor(tensor=data, + order_names=[['a', 'b'], ['x', 'y', 'z'], + ['1', '2', '3', '4']]) + assert tensor.order_labels == ['Dimension-1', 'Dimension-2', 'Dimension-3'] + + +def test_prebuilt_tensor_rejects_label_length_mismatch(): + data = np.ones((2, 2)) + with pytest.raises(AssertionError): + c2c.tensor.PreBuiltTensor(tensor=data, order_names=[['a', 'b'], ['c', 'd']], + order_labels=['only-one']) + + +def test_prebuilt_tensor_converts_nan_to_zero_and_records_it(): + data = np.array([[[1.0, np.nan], [2.0, 3.0]]]) + tensor = c2c.tensor.PreBuiltTensor(tensor=data, + order_names=[['ctx'], ['a', 'b'], ['x', 'y']]) + assert np.asarray(tensor.tensor)[0, 0, 1] == 0.0 + assert np.asarray(tensor.loc_nans)[0, 0, 1] == 1 + + +# --------------------------------------------------------------------------------- +# Factorization +# --------------------------------------------------------------------------------- + +def test_factorization_produces_one_dataframe_per_dimension(factorized_tensor): + factors = factorized_tensor.factors + assert list(factors.keys()) == EXPECTED_LABELS + for label, names in zip(EXPECTED_LABELS, factorized_tensor.order_names): + assert list(factors[label].index) == list(names) + assert list(factors[label].columns) == ['Factor 1', 'Factor 2', 'Factor 3'] + + +def test_factorization_is_reproducible(interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=2, random_state=42) + first = {k: v.copy() for k, v in interaction_tensor.factors.items()} + interaction_tensor.compute_tensor_factorization(rank=2, random_state=42) + for key, value in first.items(): + pd.testing.assert_frame_equal(value, interaction_tensor.factors[key]) + + +def test_factorization_records_the_rank_and_variance(factorized_tensor): + assert factorized_tensor.rank == 3 + assert factorized_tensor.explained_variance_ratio_ is not None + assert len(factorized_tensor.explained_variance_ratio_) == 3 + assert factorized_tensor.explained_variance_ is not None + + +def test_factorization_loadings_are_non_negative(factorized_tensor): + for frame in factorized_tensor.factors.values(): + assert (frame.values >= -1e-9).all() + + +def test_factorization_variance_ordering(interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=3, random_state=0, + var_ordered_factors=True) + ratios = interaction_tensor.explained_variance_ratio_ + assert np.all(np.diff(ratios) <= 1e-9) + + +def test_get_top_factor_elements(factorized_tensor): + top = factorized_tensor.get_top_factor_elements(order_name='Sender Cells', + factor_name='Factor 1', + top_number=2) + assert len(top) == 2 + assert top.is_monotonic_decreasing + + +def test_export_factor_loadings_roundtrip(factorized_tensor, tmp_path): + filename = tmp_path / 'loadings.xlsx' + factorized_tensor.export_factor_loadings(str(filename)) + assert filename.exists() + loaded = c2c.io.load_tensor_factors(str(filename)) + assert list(loaded.keys()) == EXPECTED_LABELS + + +@pytest.mark.slow +def test_elbow_rank_selection_runs(interaction_tensor): + fig, errors = interaction_tensor.elbow_rank_selection(upper_rank=4, runs=1, + automatic_elbow=False, + manual_elbow=2, + random_state=0, verbose=False) + assert len(errors) == 4 + assert interaction_tensor.rank == 2 + + +def test_copy_is_independent(interaction_tensor): + duplicate = interaction_tensor.copy() + original = np.asarray(interaction_tensor.tensor).copy() + np.asarray(duplicate.tensor)[0, 0, 0, 0] = 12345.0 + assert np.allclose(np.asarray(interaction_tensor.tensor), original) + + +def test_excluded_value_and_sparsity_fraction(interaction_tensor): + fraction = interaction_tensor.sparsity_fraction() + assert 0.0 <= fraction <= 1.0 + missing = interaction_tensor.missing_fraction() + assert 0.0 <= missing <= 1.0 + + +# --------------------------------------------------------------------------------- +# generate_tensor_metadata and interactions_to_tensor +# --------------------------------------------------------------------------------- + +def test_generate_tensor_metadata(factorized_tensor, toy_metadata): + cell_metadata = toy_metadata.rename(columns={'#SampleID': 'Element', + 'Groups': 'Category'}) + metadata = c2c.tensor.generate_tensor_metadata( + interaction_tensor=factorized_tensor, + metadata_dicts=[None, None, None, None], + fill_with_order_elements=True) + assert len(metadata) == 4 + for frame in metadata: + assert list(frame.columns) == ['Element', 'Category'] + + +def test_generate_tensor_metadata_with_a_dict(factorized_tensor): + mapping = {cell: 'group-A' for cell in factorized_tensor.order_names[2]} + metadata = c2c.tensor.generate_tensor_metadata( + interaction_tensor=factorized_tensor, + metadata_dicts=[None, None, mapping, None], + fill_with_order_elements=True) + senders = metadata[2] + assert set(senders['Category']) == {'group-A'} + + +def test_interactions_to_tensor(toy_contexts, toy_ppi): + spaces = [] + for name, matrix in toy_contexts.items(): + space = c2c.analysis.BulkInteractions(rnaseq_data=matrix, ppi_data=toy_ppi, + complex_sep=None, verbose=False) + space.compute_pairwise_communication_scores(verbose=False) + spaces.append(space) + tensor = c2c.tensor.interactions_to_tensor(interactions=spaces, + experiment='bulk', + context_names=list(toy_contexts.keys()), + how='inner', verbose=False) + assert tensor.tensor.ndim == 4 + assert tensor.tensor.shape[0] == len(toy_contexts) + + +def test_interactions_to_tensor_rejects_unknown_experiment(toy_rnaseq, toy_ppi): + space = c2c.analysis.BulkInteractions(rnaseq_data=toy_rnaseq, ppi_data=toy_ppi, + complex_sep=None, verbose=False) + space.compute_pairwise_communication_scores(verbose=False) + with pytest.raises(ValueError): + c2c.tensor.interactions_to_tensor(interactions=[space], experiment='nonsense', + context_names=['a'], verbose=False) + + +# --------------------------------------------------------------------------------- +# Natural ordering of factor names +# +# The factor names were sorted lexicographically, so decompositions with 10 or more +# factors were returned as Factor 1, Factor 10, Factor 11, Factor 2, ... +# --------------------------------------------------------------------------------- + +@pytest.mark.slow +def test_factor_order_is_natural_beyond_nine_factors(interaction_tensor): + interaction_tensor.compute_tensor_factorization(rank=12, random_state=0) + expected = ['Factor {}'.format(i) for i in range(1, 13)] + + networks = get_factor_specific_ccc_networks(interaction_tensor) + assert list(networks.keys()) == expected + + ginis = compute_gini_coefficients(interaction_tensor) + assert list(ginis['Factor']) == expected + + flat = flatten_factor_ccc_networks(networks) + assert list(flat.columns) == expected + + +# --------------------------------------------------------------------------------- +# Element order of the built tensor -- deliberate behaviours +# +# Guards against a future "fix" that would break them. +# --------------------------------------------------------------------------------- + +def test_build_context_ccc_tensor_preserves_order_when_cells_match(toy_ppi): + '''When every context has the same cells, the first matrix's order is preserved + -- natural sorting is only applied when the sets differ. + ''' + base = c2c.datasets.generate_toy_rnaseq() + unsorted_cells = ['C5', 'C1', 'C3', 'C2', 'C4'] + matrices = [base[unsorted_cells], base[unsorted_cells] * 2.] + + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices, ppi_data=toy_ppi, + context_names=['a', 'b'], how='inner', + complex_sep=None, verbose=False) + assert list(tensor.order_names[2]) == unsorted_cells + + +def test_build_context_ccc_tensor_sorts_naturally_when_cells_differ(toy_ppi): + base = c2c.datasets.generate_toy_rnaseq() + renamed = base.rename(columns={'C3': 'C10', 'C4': 'C20', 'C5': 'C3'}) + renamed = renamed[['C20', 'C1', 'C10', 'C3', 'C2']] + matrices = [renamed.drop(columns=['C20']), renamed.drop(columns=['C1'])] + + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=matrices, ppi_data=toy_ppi, + context_names=['a', 'b'], how='outer', + outer_fraction=0.0, complex_sep=None, + verbose=False) + cells = list(tensor.order_names[2]) + assert cells == ['C1', 'C2', 'C3', 'C10', 'C20'] + assert cells != sorted(cells) + + +def test_context_names_are_never_sorted(toy_contexts, toy_ppi): + '''context_names is supplied by the user and must be preserved verbatim.''' + names = ['Context-10', 'Context-2', 'Context-1', 'Context-3'] + tensor = c2c.tensor.InteractionTensor(rnaseq_matrices=list(toy_contexts.values()), + ppi_data=toy_ppi, context_names=names, + how='inner', complex_sep=None, verbose=False) + assert list(tensor.order_names[0]) == names diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9d46450 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- + +'''Tests for cell2cell.utils''' + +import networkx as nx +import numpy as np +import pandas as pd +import pytest + +from cell2cell.preprocessing import zero_diagonal +from cell2cell.utils import networks, parallel_computing + + +# --------------------------------------------------------------------------------- +# networks +# --------------------------------------------------------------------------------- + +@pytest.fixture +def adjacency(toy_distance): + '''A weighted adjacency matrix derived from the toy distances.''' + return zero_diagonal(1 - toy_distance / toy_distance.values.max()) + + +def test_generate_network_from_adjacency_networkx(adjacency): + graph = networks.generate_network_from_adjacency(adjacency, package='networkx') + assert isinstance(graph, nx.Graph) + assert set(graph.nodes()) == set(adjacency.index) + + +def test_generated_network_keeps_the_edge_weights(adjacency): + graph = networks.generate_network_from_adjacency(adjacency, package='networkx') + for node_a, node_b, data in graph.edges(data=True): + assert np.isclose(data['weight'], adjacency.loc[node_a, node_b]) + + +def test_generate_network_from_adjacency_rejects_other_packages(adjacency): + with pytest.raises((ValueError, NotImplementedError)): + networks.generate_network_from_adjacency(adjacency, package='igraph') + + +def test_export_network_to_gephi_excel(adjacency, tmp_path): + filename = tmp_path / 'network.xlsx' + networks.export_network_to_gephi(adjacency, str(filename), format='excel') + assert filename.exists() and filename.stat().st_size > 0 + + +def test_export_network_to_gephi_csv(adjacency, tmp_path): + filename = tmp_path / 'network.csv' + networks.export_network_to_gephi(adjacency, str(filename), format='csv') + assert filename.exists() + written = pd.read_csv(filename) + assert written.shape[0] > 0 + + +def test_export_network_to_gephi_accepts_a_graph(adjacency, tmp_path): + graph = networks.generate_network_from_adjacency(adjacency, package='networkx') + filename = tmp_path / 'from-graph.csv' + networks.export_network_to_gephi(graph, str(filename), format='csv') + assert filename.exists() + + +def test_export_network_to_cytoscape(adjacency, tmp_path): + filename = tmp_path / 'network.cyjs' + networks.export_network_to_cytoscape(adjacency, str(filename)) + assert filename.exists() + import json + with open(filename) as handle: + content = json.load(handle) + assert 'elements' in content + + +# --------------------------------------------------------------------------------- +# parallel_computing +# --------------------------------------------------------------------------------- + +def test_agents_number_positive(): + assert parallel_computing.agents_number(1) == 1 + assert parallel_computing.agents_number(2) >= 1 + + +def test_agents_number_uses_all_cores_for_minus_one(): + import multiprocessing + assert parallel_computing.agents_number(-1) == multiprocessing.cpu_count() + + +def test_agents_number_never_exceeds_the_cpu_count(): + import multiprocessing + total = multiprocessing.cpu_count() + assert parallel_computing.agents_number(total * 10) <= total + + +def test_agents_number_handles_negative_values(): + import multiprocessing + total = multiprocessing.cpu_count() + result = parallel_computing.agents_number(-2) + assert 1 <= result <= total