scCS.pairwise

pairwise.py — PairScorer: pairwise condition comparison for scCS.

Extends the single-condition SingleScorer to handle exactly 2 experimental conditions (e.g., treatment vs. control, mutant vs. wild-type).

Key design principle: shared embedding

All conditions are embedded in a SINGLE shared star layout built on the pooled data. This is critical — if each condition had its own embedding, the arm angles would differ and CS values would not be comparable across conditions.

Architecture

PairScorer

Wraps SingleScorer. Pools both conditions for embedding, then scores each condition separately using cell masks on the shared embedding.

Tier 1 — Core pairwise API

score_all_conditions() : dict[condition -> CommitmentScoreResult]

Tier 2 — Statistical comparison

compare_conditions() : permutation test on per-cell fate affinity compute_delta_CS() : ΔCS = nCS_A − nCS_B with bootstrap CI plot_affinity_distributions() : violin/box plots of per-cell affinities

Tier 3 — Advanced
fit_mixed_model()linear mixed-effects model on per-cell

fate affinity scores via statsmodels MixedLM

trajectory_shift()KS test + Wasserstein distance on

pseudotime distributions per fate arm

plot_trajectory_shift() : visualization of pseudotime distributions

Usage

>>> pscorer = scCS.PairScorer(
...     adata,
...     root='17',
...     branches=['homeostatic', 'activated'],
...     condition_obs_key='treatment',
...     obs_key='leiden',
... )
>>> pscorer.build_embedding(ordering_metric='pseudotime')
>>> pscorer.fit()
>>> results = pscorer.score_all_conditions()
>>> delta = pscorer.compute_delta_CS('control', 'treated')
>>> stats = pscorer.compare_conditions(results)
>>> pscorer.plot_affinity_distributions(results)
>>> shift = pscorer.trajectory_shift(results)
>>> pscorer.plot_trajectory_shift(shift)

Classes

PairScorer

RNA velocity commitment scorer for pairwise (2-condition) experiments.

Module Contents

class scCS.pairwise.PairScorer(adata, root: str, branches: List[str], condition_obs_key: str, obs_key: str = 'leiden', n_angle_bins: int = 36, sector_method: Literal['centroid', 'equal'] = 'centroid', copy: bool = False)[source]

RNA velocity commitment scorer for pairwise (2-condition) experiments.

Builds a SHARED star embedding on the pooled data from both conditions, then scores each condition separately. This ensures arm geometry is identical across conditions, making CS values directly comparable.

Parameters:
  • adata (AnnData) – Full single-cell dataset containing both conditions.

  • root (str) – Label of the progenitor/root cluster in adata.obs[obs_key].

  • branches (list of str) – Labels of the k terminal fate clusters.

  • condition_obs_key (str) – Column in adata.obs with condition labels (e.g., ‘treatment’). Must contain exactly 2 unique values.

  • obs_key (str) – Column in adata.obs with cluster labels. Default: ‘leiden’.

  • n_angle_bins (int) – Number of angular bins. Default: 36.

  • sector_method ({'centroid', 'equal'}) – Sector definition strategy.

  • copy (bool) – Work on a copy of adata.

Raises:

ValueError – If condition_obs_key has fewer or more than 2 unique values. For 3+ conditions, use MultiScorer instead.

Examples

>>> pscorer = PairScorer(
...     adata,
...     root='17',
...     branches=['homeostatic', 'activated'],
...     condition_obs_key='treatment',
...     obs_key='leiden',
... )
>>> pscorer.build_embedding(ordering_metric='pseudotime')
>>> pscorer.fit()
>>> results = pscorer.score_all_conditions()
>>> delta = pscorer.compute_delta_CS('control', 'treated')
>>> stats = pscorer.compare_conditions(results)
adata[source]
root = ''[source]
branches[source]
condition_obs_key[source]
obs_key = 'leiden'[source]
n_angle_bins = 36[source]
sector_method = 'centroid'[source]
conditions[source]
build_embedding(ordering_metric: str | numpy.ndarray = 'pseudotime', invert_ordering: bool = False, scale_ordering: bool = False, arm_scale: float = 10.0, jitter: float = 0.3, seed: int = 42, arm_norm: str = 'global', verbose: bool = True) PairScorer[source]

Build the shared star embedding on pooled data from both conditions.

The embedding is built on ALL cells (both conditions pooled), ensuring that arm geometry is identical across conditions.

Parameters:
  • ordering_metric (str or np.ndarray) – See SingleScorer.build_embedding().

  • invert_ordering (bool)

  • scale_ordering (bool)

  • arm_scale (float)

  • jitter (float)

  • seed (int)

  • verbose (bool)

Return type:

self

refit_pseudotime(scale_01: bool = True, arm_scale: float = 10.0, jitter: float = 0.3, seed: int = 42, arm_norm: str = 'global', verbose: bool = True) PairScorer[source]

Rebuild the shared embedding using subset-local pseudotime.

See SingleScorer.refit_pseudotime().

fit(verbose: bool = True) PairScorer[source]

Fit the shared FateMap and project velocity.

Must be called after build_embedding().

Return type:

self

score_all_conditions(cell_level: bool = True, k_nn: int | None = None, n_bootstrap: int = 0, bootstrap_ci: float = 0.95, verbose: bool = True) Dict[str, scCS.scores.CommitmentScoreResult][source]

Compute commitment scores separately for each condition.

Uses the shared embedding and FateMap. Each condition’s cells are masked from the shared adata_sub, so arm geometry is identical.

Parameters:
  • cell_level (bool) – Compute per-cell fate affinity scores.

  • k_nn (int, optional) – NN-smoothed entropy neighbors.

  • n_bootstrap (int) – Bootstrap replicates for CI. 0 = disabled.

  • bootstrap_ci (float) – CI level for bootstrap.

  • verbose (bool)

Returns:

dict

Return type:

condition_label -> CommitmentScoreResult

compute_delta_CS(condition_a: str, condition_b: str, n_bootstrap: int = 500, ci: float = 0.95, seed: int = 42, verbose: bool = True) Dict[source]

Compute ΔCS = nCS_A − nCS_B with bootstrap confidence intervals.

For each pair of fates (i, j), computes the difference in normalized commitment score between condition A and condition B, with a bootstrap CI obtained by resampling cells within each condition.

Parameters:
  • condition_a (str) – Condition labels (must be in self.conditions).

  • condition_b (str) – Condition labels (must be in self.conditions).

  • n_bootstrap (int) – Number of bootstrap replicates. Default 500.

  • ci (float) – Confidence interval level. Default 0.95.

  • seed (int)

  • verbose (bool)

Returns:

‘delta_nCS’ : np.ndarray (k, k) — nCS_A − nCS_B ‘ci_low’ : np.ndarray (k, k) — lower CI bound on delta ‘ci_high’ : np.ndarray (k, k) — upper CI bound on delta ‘nCS_A’ : np.ndarray (k, k) — nCS for condition A ‘nCS_B’ : np.ndarray (k, k) — nCS for condition B ‘fate_names’ : list of str ‘condition_a’: str ‘condition_b’: str ‘n_bootstrap’: int ‘ci_level’ : float

Return type:

dict with keys

compare_conditions(results: Dict[str, scCS.scores.CommitmentScoreResult], test: Literal['permutation', 'kruskal'] = 'permutation', n_permutations: int = 1000, pval_threshold: float = 0.05, seed: int = 42, verbose: bool = True) pandas.DataFrame[source]

Statistical comparison of per-cell fate affinity scores across conditions.

For PairScorer (k=2 conditions), the default test is a permutation test: shuffle condition labels, recompute mean per-cell affinity difference, and get an empirical null distribution.

Parameters:
  • results (dict) – Output of score_all_conditions() with cell_level=True.

  • test ({'permutation', 'kruskal'}) – Statistical test to use. Default: ‘permutation’ (recommended for k=2).

  • n_permutations (int) – Number of permutations for the permutation test. Default 1000.

  • pval_threshold (float) – Significance threshold. Default 0.05.

  • seed (int)

  • verbose (bool)

Returns:

fate, test, statistic, pval, pval_adj, significant [+ comparison column for pairwise tests]

Return type:

pd.DataFrame with columns

plot_affinity_distributions(results: Dict[str, scCS.scores.CommitmentScoreResult], plot_type: Literal['violin', 'box', 'strip'] = 'violin', color_map: Dict[str, str] | None = None, figsize: Tuple[float, float] | None = None, title: str | None = None, save_path: str | None = None) matplotlib.figure.Figure[source]

Violin/box plots of per-cell fate affinity scores by condition.

One panel per fate, showing the distribution of per-cell affinity scores split by condition.

Parameters:
  • results (dict) – Output of score_all_conditions(cell_level=True).

  • plot_type ({'violin', 'box', 'strip'})

  • color_map (dict, optional) – condition_label -> hex color.

  • figsize (tuple, optional)

  • title (str, optional)

  • save_path (str, optional)

Returns:

fig

Return type:

matplotlib Figure

fit_mixed_model(results: Dict[str, scCS.scores.CommitmentScoreResult], replicate_key: str | None = None, ref_condition: str | None = None, verbose: bool = True) pandas.DataFrame[source]

Linear mixed-effects model on per-cell fate affinity scores.

Models per-cell fate affinity as a function of condition (fixed effect) with optional sample/replicate as a random effect.

Model (per fate j):

affinity_ij ~ condition_i + (1 | sample_id_i)

Uses statsmodels MixedLM.

Parameters:
  • results (dict) – Output of score_all_conditions(cell_level=True).

  • replicate_key (str, optional) – Column in adata_sub.obs with sample/replicate IDs.

  • ref_condition (str, optional) – Reference condition for the fixed effect.

  • verbose (bool)

Returns:

fate, condition, coef, std_err, z_score, pval, pval_adj, ci_low, ci_high, significant

Return type:

pd.DataFrame with columns

trajectory_shift(results: Dict[str, scCS.scores.CommitmentScoreResult], pseudotime_key: str = 'sccs_pseudotime', n_bootstrap: int = 500, seed: int = 42, verbose: bool = True) pandas.DataFrame[source]

Test whether pseudotime distributions differ across conditions per fate arm.

For each fate arm, computes: - Kolmogorov-Smirnov (KS) statistic and p-value - Wasserstein distance (Earth Mover’s Distance) - Bootstrap CI on the Wasserstein distance

Parameters:
  • results (dict) – Output of score_all_conditions().

  • pseudotime_key (str) – Column in adata_sub.obs with pseudotime values.

  • n_bootstrap (int) – Bootstrap replicates for Wasserstein CI. Default 500.

  • seed (int)

  • verbose (bool)

Returns:

fate, comparison, ks_stat, ks_pval, wasserstein, wasserstein_ci_low, wasserstein_ci_high, mean_pt_A, mean_pt_B, delta_mean_pt, significant

Return type:

pd.DataFrame with columns

plot_trajectory_shift(shift_df: pandas.DataFrame, pseudotime_key: str = 'sccs_pseudotime', color_map: Dict[str, str] | None = None, figsize: Tuple[float, float] | None = None, title: str | None = None, save_path: str | None = None) matplotlib.figure.Figure[source]

Visualize pseudotime distributions per condition per fate arm.

Produces a grid of KDE plots: one row per fate arm. Overlaid KDEs show how pseudotime distributions shift between conditions.

Parameters:
  • shift_df (pd.DataFrame) – Output of trajectory_shift().

  • pseudotime_key (str)

  • color_map (dict, optional)

  • figsize (tuple, optional)

  • title (str, optional)

  • save_path (str, optional)

Returns:

fig

Return type:

matplotlib Figure

transfer_labels(results: Dict[str, scCS.scores.CommitmentScoreResult], prefix: str = 'cs_') None[source]

Transfer per-cell commitment scores to the full adata for all conditions.

Calls SingleScorer.transfer_labels() for each condition’s result, writing condition-specific columns to adata.obs.

Parameters:
  • results (dict) – Output of score_all_conditions(cell_level=True).

  • prefix (str) – Column prefix. Default: ‘cs_’.

plot_star(result: scCS.scores.CommitmentScoreResult, **kwargs)[source]

Radial star embedding plot.

plot_star_grid(results: Dict[str, scCS.scores.CommitmentScoreResult], color_map: Dict[str, str] | None = None, figsize_per_panel: Tuple[float, float] = (6, 6), save_path: str | None = None) matplotlib.figure.Figure[source]

Side-by-side star embedding plots, one per condition.

All panels share the same arm geometry and color scale.

Parameters:
  • results (dict)

  • color_map (dict, optional)

  • figsize_per_panel (tuple)

  • save_path (str, optional)

Returns:

fig

Return type:

matplotlib Figure

plot_rose_grid(results: Dict[str, scCS.scores.CommitmentScoreResult], color_map: Dict[str, str] | None = None, figsize_per_panel: Tuple[float, float] = (5, 5), title: str | None = None, save_path: str | None = None) matplotlib.figure.Figure[source]

Grid of polar rose plots — one per condition.

All panels share the same radial scale, making magnitudes directly comparable across conditions.

Parameters:
  • results (dict)

  • color_map (dict, optional)

  • figsize_per_panel (tuple)

  • title (str, optional)

  • save_path (str, optional)

Returns:

fig

Return type:

matplotlib Figure

plot_delta_cs_heatmap(delta_result: dict, **kwargs) matplotlib.figure.Figure[source]

Heatmap of ΔCS = nCS_A − nCS_B with CI annotation.

Parameters:
Returns:

fig

Return type:

matplotlib Figure

plot_compare_conditions_bar(results: Dict[str, scCS.scores.CommitmentScoreResult], **kwargs) matplotlib.figure.Figure[source]

Grouped bar chart of nCS per condition.

Parameters:
Returns:

fig

Return type:

matplotlib Figure

plot_commitment_vector_radar(results: Dict[str, scCS.scores.CommitmentScoreResult], **kwargs) matplotlib.figure.Figure[source]

Radar / spider chart of commitment vectors per condition.

Parameters:
Returns:

fig

Return type:

matplotlib Figure

property scorer: scCS.single.SingleScorer | None[source]

The internal SingleScorer used for embedding and scoring.

property adata_sub[source]

The embedding subset (from the internal SingleScorer).

property is_fitted: bool[source]