cytocommunity2 1.0.7__tar.gz → 1.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {cytocommunity2-1.0.7/src/cytocommunity2.egg-info → cytocommunity2-1.0.8}/PKG-INFO +1 -1
  2. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/pyproject.toml +1 -1
  3. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/__init__.py +1 -1
  4. cytocommunity2-1.0.8/src/cytocommunity2/_display.py +23 -0
  5. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/colors.py +9 -0
  6. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/config.py +3 -2
  7. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/communication/between.py +23 -10
  8. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/communication/cca.py +92 -95
  9. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/communication/utils.py +1 -12
  10. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/communication/within.py +4 -10
  11. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/differential_composition.py +21 -5
  12. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/enrichment.py +18 -0
  13. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/moran.py +2 -2
  14. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/__init__.py +2 -0
  15. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/cn_selection.py +2 -2
  16. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/coherence.py +1 -1
  17. cytocommunity2-1.0.8/src/cytocommunity2/downstream/plotting/communication.py +123 -0
  18. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/communication_cca.py +44 -51
  19. cytocommunity2-1.0.8/src/cytocommunity2/downstream/plotting/communication_dumbbell.py +278 -0
  20. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/dominant.py +14 -4
  21. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/dotplots.py +84 -45
  22. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/recurrence.py +18 -1
  23. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/ensemble.py +6 -0
  24. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/learning/api.py +10 -1
  25. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/learning/dataset.py +8 -1
  26. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/learning/selection.py +1 -1
  27. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/learning/training.py +88 -65
  28. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/paths.py +18 -2
  29. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/visualization/spatial.py +7 -6
  30. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8/src/cytocommunity2.egg-info}/PKG-INFO +1 -1
  31. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2.egg-info/SOURCES.txt +2 -1
  32. cytocommunity2-1.0.7/src/cytocommunity2/downstream/plotting/communication.py +0 -121
  33. cytocommunity2-1.0.7/src/cytocommunity2/downstream/plotting/communication_stack.py +0 -217
  34. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/LICENSE +0 -0
  35. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/README.md +0 -0
  36. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/setup.cfg +0 -0
  37. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/_io.py +0 -0
  38. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/__init__.py +0 -0
  39. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/coherence.py +0 -0
  40. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  41. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/dominant_celltype.py +0 -0
  42. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/plotting/common.py +0 -0
  43. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/recurrence.py +0 -0
  44. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/downstream/wilcoxon.py +0 -0
  45. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/learning/__init__.py +0 -0
  46. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/learning/model.py +0 -0
  47. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2/visualization/__init__.py +0 -0
  48. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  49. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2.egg-info/requires.txt +0 -0
  50. {cytocommunity2-1.0.7 → cytocommunity2-1.0.8}/src/cytocommunity2.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cytocommunity2
3
- Version: 1.0.7
3
+ Version: 1.0.8
4
4
  Summary: Weakly supervised cellular-neighborhood learning for spatial omics
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cytocommunity2"
7
- version = "1.0.7"
7
+ version = "1.0.8"
8
8
  description = "Weakly supervised cellular-neighborhood learning for spatial omics"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -10,7 +10,7 @@ from .learning import (
10
10
  )
11
11
  from .visualization import visualize_results
12
12
 
13
- __version__ = "1.0.7"
13
+ __version__ = "1.0.8"
14
14
 
15
15
  __all__ = [
16
16
  "CytoCommunityConfig",
@@ -0,0 +1,23 @@
1
+ """Dataset-specific display labels, separate from statistical identifiers."""
2
+
3
+
4
+ def display_label_map(config, name, overrides=None):
5
+ visualization = getattr(config, "visualization", None)
6
+ labels = dict(getattr(visualization, name, {}) or {})
7
+ labels.update(overrides or {})
8
+ return {str(key): str(value) for key, value in labels.items()}
9
+
10
+
11
+ def celltype_label(value, config=None, *, labels=None, mathtext=True):
12
+ text = str(value)
13
+ text = display_label_map(config, "celltype_labels", labels).get(text, text)
14
+ # Arial lacks the superscript-plus glyph; mathtext keeps it readable.
15
+ return text.replace("⁺", r"$^{+}$") if mathtext else text
16
+
17
+
18
+ def condition_label(value, config=None, *, labels=None):
19
+ text = str(value)
20
+ key = text.removeprefix("label")
21
+ mapping = display_label_map(config, "condition_labels", labels)
22
+ fallback = text if text == "rest" or text.startswith("label") else f"label{text}"
23
+ return mapping.get(text, mapping.get(key, fallback))
@@ -2,6 +2,15 @@
2
2
 
3
3
 
4
4
  CN_PALETTE = "d3_category20"
5
+ TARGET_COLOR = "#CB3027"
6
+ REST_COLOR = "#466988"
7
+
8
+
9
+ def condition_comparison_colors(condition_a, condition_b):
10
+ """Color the target red and rest/reference blue, independent of rho order."""
11
+ if str(condition_a).lower() == "rest":
12
+ return REST_COLOR, TARGET_COLOR
13
+ return TARGET_COLOR, REST_COLOR
5
14
 
6
15
 
7
16
  def cn_color_map(cns):
@@ -2,7 +2,7 @@
2
2
 
3
3
  import json
4
4
  import math
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
  from pathlib import Path
7
7
  from typing import Any, Mapping
8
8
 
@@ -21,7 +21,6 @@ DOWNSTREAM_PARAMETERS = {
21
21
  "communication": {
22
22
  "minimum_samples": 3,
23
23
  "permutation_max_attempt_multiplier": 20,
24
- "spearman_p_threshold": 0.05,
25
24
  "permutation_p_threshold": 0.05,
26
25
  "cca_p_threshold": 0.05,
27
26
  "cca_minimum_common_samples": 3,
@@ -62,6 +61,8 @@ class RecurrenceConfig:
62
61
  @dataclass(frozen=True)
63
62
  class VisualizationConfig:
64
63
  point_size: float = 2.0
64
+ celltype_labels: Mapping[str, str] = field(default_factory=dict)
65
+ condition_labels: Mapping[str, str] = field(default_factory=dict)
65
66
 
66
67
 
67
68
  @dataclass(frozen=True)
@@ -3,6 +3,7 @@
3
3
  from pathlib import Path
4
4
 
5
5
  import pandas as pd
6
+ import numpy as np
6
7
 
7
8
  from ...config import CytoCommunityConfig
8
9
  from ...paths import build_paths
@@ -24,6 +25,7 @@ def select_cca_pairs(cca_pairs, top_n):
24
25
  pairs = cca_pairs.copy()
25
26
  pairs["CN_A"] = pairs["CN_A"].astype(str).str.strip()
26
27
  pairs["CN_B"] = pairs["CN_B"].astype(str).str.strip()
28
+ pairs = pairs.loc[np.isfinite(pairs["rho1"])].copy()
27
29
  return pairs.reindex(
28
30
  pairs["rho1"].abs().sort_values(ascending=False).index
29
31
  ).head(top_n)
@@ -42,8 +44,10 @@ def analyze_between_cn(
42
44
  cn_a, cn_b = str(pair["CN_A"]), str(pair["CN_B"])
43
45
  loadings = loading_tables[(cn_a, cn_b)].copy()
44
46
  loadings["CellType"] = loadings["CellType"].astype(str).str.strip()
45
- loadings_a = loadings.dropna(subset=["CN_A_loading1"])
46
- loadings_b = loadings.dropna(subset=["CN_B_loading1"])
47
+ loadings_a = loadings.loc[np.isfinite(loadings["CN_A_loading1"])]
48
+ loadings_b = loadings.loc[np.isfinite(loadings["CN_B_loading1"])]
49
+ if loadings_a.empty or loadings_b.empty:
50
+ raise ValueError(f"No finite first canonical loadings for CN{cn_a} vs CN{cn_b}")
47
51
  top_a_index = loadings_a["CN_A_loading1"].abs().idxmax()
48
52
  top_b_index = loadings_b["CN_B_loading1"].abs().idxmax()
49
53
  top_a = loadings_a.loc[top_a_index, "CellType"]
@@ -87,19 +91,24 @@ def analyze_between_cn(
87
91
  return result.drop(columns="rho1_CCA")
88
92
 
89
93
 
90
- def run_between_cn_statistics(config_dir, minimum_samples, top_n_cca_pairs):
94
+ def run_between_cn_statistics(
95
+ cca_dir, minimum_samples, top_n_cca_pairs, *, input_file=None, output_dir=None
96
+ ):
91
97
  """Calculate and save Spearman statistics for top CCA pairs."""
92
- config_dir = Path(config_dir)
98
+ cca_dir = Path(cca_dir)
99
+ output_dir = Path(output_dir or cca_dir.parent)
100
+ output_dir.mkdir(parents=True, exist_ok=True)
93
101
  scores = read_scores_long(
94
- config_dir / "EnrichScoreMatrix_long.csv", require_condition=False
102
+ input_file or cca_dir.parent.parent / "EnrichScoreMatrix_long.csv",
103
+ require_condition=False,
95
104
  )
96
105
  pairs = pd.read_csv(
97
- config_dir / "CCA_results.csv", dtype={"CN_A": str, "CN_B": str}
106
+ cca_dir / "CCA_results.csv", dtype={"CN_A": str, "CN_B": str}
98
107
  )
99
108
  selected = select_cca_pairs(pairs, top_n_cca_pairs)
100
109
  tables = {
101
110
  (str(row.CN_A), str(row.CN_B)): pd.read_csv(
102
- config_dir / f"CCA_loadings_CN{row.CN_A}_vs_CN{row.CN_B}.csv",
111
+ cca_dir / f"CCA_loadings_CN{row.CN_A}_vs_CN{row.CN_B}.csv",
103
112
  converters={"CellType": str.strip},
104
113
  )
105
114
  for row in selected.itertuples(index=False)
@@ -111,17 +120,21 @@ def run_between_cn_statistics(config_dir, minimum_samples, top_n_cca_pairs):
111
120
  minimum_samples=minimum_samples,
112
121
  top_n_cca_pairs=top_n_cca_pairs,
113
122
  )
114
- result.to_csv(config_dir / "Spearman_TopCCA_Pairs.csv", index=False)
123
+ result.to_csv(output_dir / "Spearman_TopCCA_Pairs.csv", index=False)
115
124
  return result
116
125
 
117
126
 
118
- def run_between_cn_analysis(config: CytoCommunityConfig, *, config_dir=None):
127
+ def run_between_cn_analysis(
128
+ config: CytoCommunityConfig, *, input_file=None, output_dir=None, cca_dir=None
129
+ ):
119
130
  paths = build_paths(config)
120
131
  params = config.downstream["communication"]
121
132
  return run_between_cn_statistics(
122
- config_dir or paths.communication_config,
133
+ cca_dir or paths.communication_cca,
123
134
  params["minimum_samples"],
124
135
  params["top_n_cca_pairs"],
136
+ input_file=input_file or paths.communication_scores,
137
+ output_dir=output_dir or paths.communication_between,
125
138
  )
126
139
 
127
140
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from itertools import combinations
4
4
  from pathlib import Path
5
+ import warnings
5
6
 
6
7
  import numpy as np
7
8
  import pandas as pd
@@ -12,8 +13,6 @@ from ...paths import build_paths
12
13
  from .utils import (
13
14
  build_score_tables,
14
15
  condition_sort_key,
15
- normalize_scores_long,
16
- prune_csv_outputs,
17
16
  read_scores_long,
18
17
  )
19
18
 
@@ -22,8 +21,8 @@ def _result_columns(n_components, *, by_condition=False):
22
21
  columns = ["CN_A", "CN_B", "n_samples"]
23
22
  if by_condition:
24
23
  columns.append("Condition")
25
- for component in range(1, n_components + 1):
26
- columns.extend([f"rho{component}", f"pval{component}", f"invalid_perm{component}"])
24
+ columns.extend(["rho1", "pval1", "invalid_perm1"])
25
+ columns.extend(f"rho{component}" for component in range(2, n_components + 1))
27
26
  return columns
28
27
 
29
28
 
@@ -42,8 +41,8 @@ def clean_cca_data(x, y, minimum_common_samples, min_std):
42
41
  common = x.index.intersection(y.index)
43
42
  if len(common) < minimum_common_samples:
44
43
  return None
45
- x = x.loc[common]
46
- y = y.loc[common]
44
+ x = x.loc[common].replace([np.inf, -np.inf], np.nan)
45
+ y = y.loc[common].replace([np.inf, -np.inf], np.nan)
47
46
  x = x.loc[:, x.std(axis=0) > min_std]
48
47
  y = y.loc[:, y.std(axis=0) > min_std]
49
48
  if x.shape[1] == 0 or y.shape[1] == 0:
@@ -52,6 +51,11 @@ def clean_cca_data(x, y, minimum_common_samples, min_std):
52
51
  x, y = x.loc[complete], y.loc[complete]
53
52
  if x.shape[0] < minimum_common_samples:
54
53
  return None
54
+ # Removing incomplete samples can make a previously variable column constant.
55
+ x = x.loc[:, x.std(axis=0) > min_std]
56
+ y = y.loc[:, y.std(axis=0) > min_std]
57
+ if x.shape[1] == 0 or y.shape[1] == 0:
58
+ return None
55
59
  return {
56
60
  "X": x.values,
57
61
  "Y": y.values,
@@ -66,20 +70,41 @@ def build_score_list(frame, cn_list):
66
70
 
67
71
 
68
72
  def select_top_features(matrix, top_n=5):
69
- """Select cell types by decreasing mean enrichment before variance filtering."""
70
- if matrix is None or matrix.shape[1] == 0:
73
+ """Select the five highest-mean cell types by default, before filtering."""
74
+ if top_n is None or matrix is None or matrix.shape[1] == 0:
71
75
  return matrix
76
+ if isinstance(top_n, bool) or not isinstance(top_n, (int, np.integer)) or top_n < 1:
77
+ raise ValueError("cca_top_celltypes must be a positive integer or null")
72
78
  means = matrix.mean(axis=0).fillna(-np.inf)
73
79
  selected = means.sort_values(ascending=False, kind="stable").head(top_n).index
74
80
  return matrix.loc[:, selected]
75
81
 
76
82
 
83
+ def _fit_cca_scores(x_matrix, y_matrix, n_components, max_iterations):
84
+ """Fit finite canonical scores, retrying a degenerate second component."""
85
+ for count in range(n_components, 0, -1):
86
+ try:
87
+ model = CCA(n_components=count, scale=True, max_iter=max_iterations)
88
+ with np.errstate(invalid="raise", divide="raise", over="raise"):
89
+ model.fit(x_matrix, y_matrix)
90
+ x_scores, y_scores = model.transform(x_matrix, y_matrix)
91
+ if not (np.isfinite(x_scores).all() and np.isfinite(y_scores).all()):
92
+ continue
93
+ if any(np.ptp(scores[:, index]) == 0 for scores in (x_scores, y_scores) for index in range(count)):
94
+ continue
95
+ return x_scores, y_scores
96
+ except (ValueError, np.linalg.LinAlgError, FloatingPointError):
97
+ continue
98
+ return None
99
+
100
+
77
101
  def run_cca_for_scores(
78
102
  score_list, cn_list, communication_params, random_seed, condition=None
79
103
  ):
80
- """Run CCA and permutation tests on in-memory enrichment matrices."""
104
+ """Fit up to two pooled components, or just one within a condition."""
105
+ n_components = 1 if condition is not None else communication_params["cca_n_components"]
81
106
  score_list = {
82
- cn: select_top_features(matrix, communication_params["cca_top_celltypes"])
107
+ cn: select_top_features(matrix, communication_params.get("cca_top_celltypes", 5))
83
108
  for cn, matrix in score_list.items()
84
109
  }
85
110
  result_rows = []
@@ -97,78 +122,69 @@ def run_cca_for_scores(
97
122
  continue
98
123
  x_matrix, y_matrix = cleaned["X"], cleaned["Y"]
99
124
  n_samples = x_matrix.shape[0]
100
- max_components = min(x_matrix.shape[1], y_matrix.shape[1], n_samples - 2)
125
+ # Column count alone does not detect proportional cell-type profiles.
126
+ max_components = min(
127
+ n_components,
128
+ np.linalg.matrix_rank(x_matrix - x_matrix.mean(axis=0)),
129
+ np.linalg.matrix_rank(y_matrix - y_matrix.mean(axis=0)),
130
+ n_samples - 2,
131
+ )
101
132
  if max_components < 1:
102
133
  continue
103
134
 
104
- model = CCA(
105
- n_components=max_components,
106
- scale=True,
107
- max_iter=communication_params["cca_max_iterations"],
108
- )
109
- model.fit(x_matrix, y_matrix)
110
- x_scores, y_scores = model.transform(x_matrix, y_matrix)
111
- actual_components = min(
112
- communication_params["cca_n_components"], x_scores.shape[1]
135
+ scores = _fit_cca_scores(
136
+ x_matrix, y_matrix, max_components,
137
+ communication_params["cca_max_iterations"],
113
138
  )
139
+ if scores is None:
140
+ warnings.warn(
141
+ f"CCA could not estimate finite scores for CN{cn_a} vs CN{cn_b} "
142
+ f"(condition={condition}); pair omitted.", RuntimeWarning,
143
+ )
144
+ continue
145
+ x_scores, y_scores = scores
146
+ actual_components = min(n_components, x_scores.shape[1])
114
147
  correlations = [
115
148
  np.corrcoef(x_scores[:, index], y_scores[:, index])[0, 1]
116
149
  for index in range(actual_components)
117
150
  ]
151
+ if not np.isfinite(correlations[0]):
152
+ continue
118
153
 
119
- p_values = []
120
- invalid_counts = []
154
+ # Only the first canonical correlation is used for inference. rho2 and
155
+ # loading2 are descriptive coordinates for the pooled loading diagram.
156
+ observed = correlations[0]
157
+ extreme = 0
158
+ valid_count = 0
159
+ invalid_count = 0
121
160
  rng = pair_rng(random_seed, condition, cn_a, cn_b)
122
- for component, observed in enumerate(correlations):
123
- extreme = 0
124
- valid_count = 0
125
- invalid_count = 0
126
- for _ in range(communication_params["cca_n_permutations"]):
127
- shuffled_y = rng.permutation(y_matrix)
128
- permutation_model = CCA(
129
- n_components=min(
130
- communication_params["cca_n_components"],
131
- max_components,
132
- ),
133
- scale=True,
134
- max_iter=communication_params["cca_max_iterations"],
135
- )
136
- try:
137
- permutation_model.fit(x_matrix, shuffled_y)
138
- permuted_x, permuted_y = permutation_model.transform(
139
- x_matrix, shuffled_y
140
- )
141
- except (ValueError, np.linalg.LinAlgError, FloatingPointError):
142
- invalid_count += 1
143
- continue
144
- if permuted_x.shape[1] <= component:
145
- continue
146
- permuted_rho = abs(
147
- np.corrcoef(
148
- permuted_x[:, component], permuted_y[:, component]
149
- )[0, 1]
150
- )
151
- valid_count += 1
152
- if permuted_rho >= abs(observed) - 1e-12:
153
- extreme += 1
154
- p_values.append(
155
- (extreme + 1) / (valid_count + 1) if valid_count else np.nan
161
+ for _ in range(communication_params["cca_n_permutations"]):
162
+ shuffled_y = rng.permutation(y_matrix)
163
+ permuted = _fit_cca_scores(
164
+ x_matrix, shuffled_y, x_scores.shape[1],
165
+ communication_params["cca_max_iterations"],
156
166
  )
157
- invalid_counts.append(invalid_count)
167
+ if permuted is None:
168
+ invalid_count += 1
169
+ continue
170
+ permuted_x, permuted_y = permuted
171
+ permuted_rho = abs(np.corrcoef(permuted_x[:, 0], permuted_y[:, 0])[0, 1])
172
+ if not np.isfinite(permuted_rho):
173
+ invalid_count += 1
174
+ continue
175
+ valid_count += 1
176
+ if permuted_rho >= abs(observed) - 1e-12:
177
+ extreme += 1
158
178
 
159
179
  row = {"CN_A": cn_a, "CN_B": cn_b, "n_samples": n_samples}
160
180
  if condition is not None:
161
181
  row["Condition"] = str(condition)
162
- for component in range(communication_params["cca_n_components"]):
182
+ row["pval1"] = (extreme + 1) / (valid_count + 1) if valid_count else np.nan
183
+ row["invalid_perm1"] = invalid_count
184
+ for component in range(n_components):
163
185
  row[f"rho{component + 1}"] = (
164
186
  correlations[component] if component < len(correlations) else None
165
187
  )
166
- row[f"pval{component + 1}"] = (
167
- p_values[component] if component < len(p_values) else None
168
- )
169
- row[f"invalid_perm{component + 1}"] = (
170
- invalid_counts[component] if component < len(invalid_counts) else None
171
- )
172
188
  result_rows.append(row)
173
189
  key = "__".join(
174
190
  filter(
@@ -187,9 +203,7 @@ def run_cca_for_scores(
187
203
 
188
204
  results = pd.DataFrame(
189
205
  result_rows,
190
- columns=_result_columns(
191
- communication_params["cca_n_components"], by_condition=condition is not None
192
- ),
206
+ columns=_result_columns(n_components, by_condition=condition is not None),
193
207
  )
194
208
  if not results.empty:
195
209
  results = results.sort_values(
@@ -263,19 +277,15 @@ def write_cca_loadings(
263
277
  return tables
264
278
 
265
279
 
266
- def _normalize_scores(frame):
267
- return normalize_scores_long(frame, require_condition=True)
268
-
269
-
270
280
  def run_cca_analysis(
271
281
  config: CytoCommunityConfig, *, input_file=None, output_dir=None
272
282
  ):
273
283
  """Run pooled and condition-specific CCA using configured paths."""
274
284
  paths = build_paths(config)
275
285
  params = config.downstream["communication"]
276
- output_dir = Path(output_dir or paths.communication_config)
286
+ output_dir = Path(output_dir or paths.communication_cca)
277
287
  output_dir.mkdir(parents=True, exist_ok=True)
278
- frame = read_scores_long(input_file or output_dir / "EnrichScoreMatrix_long.csv")
288
+ frame = read_scores_long(input_file or paths.communication_scores)
279
289
  celltype_order = frame["CellType"].astype(str).drop_duplicates().tolist()
280
290
  conditions = sorted(frame["Condition"].unique(), key=condition_sort_key)
281
291
  cn_list = sorted(frame["CN"].unique(), key=condition_sort_key)
@@ -283,7 +293,6 @@ def run_cca_analysis(
283
293
  build_score_list(frame, cn_list), cn_list, params, config.random_seed
284
294
  )
285
295
  condition_results = []
286
- fitted_conditions = []
287
296
  for condition in conditions:
288
297
  subset = frame[frame["Condition"] == condition]
289
298
  result = run_cca_for_scores(
@@ -293,14 +302,12 @@ def run_cca_analysis(
293
302
  config.random_seed,
294
303
  condition,
295
304
  )
296
- condition_results.append(result["results"])
297
- fitted_conditions.append((condition, result))
305
+ if not result["results"].empty:
306
+ condition_results.append(result["results"])
298
307
  by_condition = (
299
308
  pd.concat(condition_results, ignore_index=True)
300
309
  if condition_results
301
- else pd.DataFrame(
302
- columns=_result_columns(params["cca_n_components"], by_condition=True)
303
- )
310
+ else pd.DataFrame(columns=_result_columns(1, by_condition=True))
304
311
  )
305
312
  if not by_condition.empty:
306
313
  by_condition["Condition"] = pd.Categorical(
@@ -311,24 +318,14 @@ def run_cca_analysis(
311
318
  key=lambda values: abs(values) if values.name == "rho1" else values,
312
319
  ascending=[True, False],
313
320
  )
314
- # Retain only loadings from the completed pooled/condition analyses. A CN pair
315
- # that disappeared on this run must not survive as an apparently current file.
316
- current_loadings = []
317
- for condition, result in [(None, pooled), *fitted_conditions]:
318
- tables = write_cca_loadings(
319
- result["results"], result["cache"], output_dir, celltype_order,
320
- params["cca_n_components"], condition,
321
- )
322
- prefix = f"{condition}_" if condition is not None else ""
323
- current_loadings.extend(
324
- f"CCA_loadings_{prefix}CN{cn_a}_vs_CN{cn_b}.csv"
325
- for cn_a, cn_b in tables
326
- )
321
+ # The circle plot consumes only the condition summary. Loading tables are
322
+ # needed for pooled CCA plots and selection of between-CN cell-type pairs.
323
+ write_cca_loadings(
324
+ pooled["results"], pooled["cache"], output_dir, celltype_order,
325
+ params["cca_n_components"],
326
+ )
327
327
  pooled["results"].to_csv(output_dir / "CCA_results.csv", index=False)
328
328
  by_condition.to_csv(output_dir / "CCA_results_by_condition.csv", index=False)
329
- prune_csv_outputs(
330
- output_dir, "CCA_loadings_*CN*_vs_CN*.csv", current_loadings
331
- )
332
329
  return {"pooled": pooled["results"], "by_condition": by_condition}
333
330
 
334
331
 
@@ -1,8 +1,6 @@
1
1
  """Shared communication table and statistical utilities."""
2
2
 
3
3
  import itertools
4
- from pathlib import Path
5
-
6
4
  import numpy as np
7
5
  import pandas as pd
8
6
  from scipy.stats import spearmanr
@@ -72,14 +70,6 @@ def build_score_tables(
72
70
  return tables
73
71
 
74
72
 
75
- def prune_csv_outputs(output_dir, pattern, current_names):
76
- """Remove only obsolete files from one analysis-owned CSV filename pattern."""
77
- current_names = set(current_names)
78
- for path in Path(output_dir).glob(pattern):
79
- if path.is_file() and path.name not in current_names:
80
- path.unlink()
81
-
82
-
83
73
  def condition_label(condition):
84
74
  condition = str(condition)
85
75
  return condition if condition == "rest" else f"label{condition}"
@@ -114,7 +104,7 @@ def stable_seed(base_seed, *parts):
114
104
  def safe_spearman(x, y, min_samples):
115
105
  x = np.asarray(x, dtype=float)
116
106
  y = np.asarray(y, dtype=float)
117
- valid = (~np.isnan(x)) & (~np.isnan(y))
107
+ valid = np.isfinite(x) & np.isfinite(y)
118
108
  x = x[valid]
119
109
  y = y[valid]
120
110
  if len(x) < min_samples or len(np.unique(x)) < 2 or len(np.unique(y)) < 2:
@@ -133,7 +123,6 @@ __all__ = [
133
123
  "condition_pairs",
134
124
  "condition_sort_key",
135
125
  "normalize_scores_long",
136
- "prune_csv_outputs",
137
126
  "read_scores_long",
138
127
  "safe_spearman",
139
128
  "stable_seed",
@@ -12,7 +12,6 @@ from .utils import (
12
12
  available_conditions,
13
13
  build_score_tables,
14
14
  condition_pair_label,
15
- prune_csv_outputs,
16
15
  read_scores_long,
17
16
  safe_spearman,
18
17
  stable_seed,
@@ -88,7 +87,7 @@ def permutation_pvalue(
88
87
  x = np.asarray(x, dtype=float)
89
88
  y = np.asarray(y, dtype=float)
90
89
  labels = np.asarray(labels)
91
- valid = (~np.isnan(x)) & (~np.isnan(y)) & np.isin(
90
+ valid = np.isfinite(x) & np.isfinite(y) & np.isin(
92
91
  labels, [condition_a, condition_b]
93
92
  )
94
93
  x, y, labels = x[valid], y[valid], labels[valid]
@@ -222,9 +221,9 @@ def run_within_cn_analysis(
222
221
  """Run and save pooled plus condition-specific within-CN analyses."""
223
222
  paths = build_paths(config)
224
223
  params = config.downstream["communication"]
225
- output_dir = Path(output_dir or paths.communication_config)
224
+ output_dir = Path(output_dir or paths.communication_within)
226
225
  output_dir.mkdir(parents=True, exist_ok=True)
227
- input_file = Path(input_file or output_dir / "EnrichScoreMatrix_long.csv")
226
+ input_file = Path(input_file or paths.communication_scores)
228
227
  scores = read_scores_long(input_file)
229
228
  celltype_order = scores["CellType"].astype(str).drop_duplicates().tolist()
230
229
  num_cn = int(paths.selected_cn_num.read_text(encoding="utf-8"))
@@ -251,17 +250,12 @@ def run_within_cn_analysis(
251
250
  )
252
251
  label = condition_pair_label(comparison["cond_a"], comparison["cond_b"])
253
252
  outputs[label] = result
254
- # Publish only after all comparisons compute; retired comparison modes and
255
- # conditions must not leave tables that a later run can mistake for current.
256
253
  outputs["pooled"].to_csv(output_dir / "SpearmanWithinCNs.csv", index=False)
257
- current_names = []
258
254
  for label, result in outputs.items():
259
- if label == "pooled":
255
+ if label == "pooled" or result.empty:
260
256
  continue
261
257
  name = f"CommunicationPair_{label}.csv"
262
258
  result.to_csv(output_dir / name, index=False)
263
- current_names.append(name)
264
- prune_csv_outputs(output_dir, "CommunicationPair_label*_vs_*.csv", current_names)
265
259
  return outputs
266
260
 
267
261
 
@@ -92,7 +92,8 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode, minimum
92
92
 
93
93
  The result maps comparison names to maps of CN names and result tables.
94
94
  Comparisons with fewer than ``minimum_samples`` samples on either side
95
- have empty maps.
95
+ have empty maps. Pairwise tests are computed once per unordered pair;
96
+ the reverse comparison swaps alternatives, sample counts, and rank sums.
96
97
  """
97
98
  normalized_records = [
98
99
  {**record, "matrix": np.asarray(record["matrix"], dtype=float)}
@@ -116,8 +117,12 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode, minimum
116
117
  ) >= minimum_samples
117
118
  ]
118
119
  for cn_index in range(expected_shape[1]):
120
+ cn_name = f"CN{cn_index + 1}"
119
121
  for comparison in eligible:
122
+ if mode == "pairwise" and cn_name in outputs[comparison["name"]]:
123
+ continue
120
124
  rows = []
125
+ reverse_rows = []
121
126
  for cell_index, cell_type in enumerate(celltypes):
122
127
  target = [
123
128
  record["matrix"][cell_index, cn_index]
@@ -134,8 +139,22 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode, minimum
134
139
  )
135
140
  if result is not None:
136
141
  rows.extend(result)
142
+ if mode == "pairwise":
143
+ total_n = len(target) + len(reference)
144
+ # Reversing the two rows keeps greater before less.
145
+ for row in reversed(result):
146
+ reverse_rows.append({
147
+ **row,
148
+ "Alternative": "greater" if row["Alternative"] == "less" else "less",
149
+ "W_statistic": total_n * (total_n + 1) / 2 - row["W_statistic"],
150
+ "N_target": row["N_reference"],
151
+ "N_reference": row["N_target"],
152
+ })
137
153
  if rows:
138
- outputs[comparison["name"]][f"CN{cn_index + 1}"] = pd.DataFrame(rows)
154
+ outputs[comparison["name"]][cn_name] = pd.DataFrame(rows)
155
+ if mode == "pairwise":
156
+ reverse_name = f"{comparison['reference_name']}_gt_{comparison['target_name']}"
157
+ outputs[reverse_name][cn_name] = pd.DataFrame(reverse_rows)
139
158
  return outputs
140
159
 
141
160
 
@@ -154,9 +173,6 @@ def run_differential_composition_analysis(
154
173
  output_dir = Path(output_dir or paths.downstream_data / "DifferentialComposed")
155
174
  for comparison, cn_tables in outputs.items():
156
175
  comparison_dir = output_dir / comparison
157
- if comparison_dir.exists():
158
- for csv_file in comparison_dir.glob("CN*.csv"):
159
- csv_file.unlink()
160
176
  if cn_tables:
161
177
  comparison_dir.mkdir(parents=True, exist_ok=True)
162
178
  for cn, table in cn_tables.items():
@@ -19,13 +19,31 @@ def load_enrichment_by_sample(enrichment_dir, input_dir):
19
19
  .str.strip()
20
20
  .tolist()
21
21
  )
22
+ if not celltypes or any(not celltype for celltype in celltypes):
23
+ raise ValueError(f"{enrichment_dir}: cell-type names must be nonempty")
24
+ if len(set(celltypes)) != len(celltypes):
25
+ raise ValueError(f"{enrichment_dir}: cell-type names must be unique")
22
26
  samples = read_nonempty_lines(Path(input_dir) / "ImageNameList.txt")
27
+ if not samples or len(set(samples)) != len(samples):
28
+ raise ValueError(f"{input_dir}: sample names must be nonempty and unique")
23
29
  matrices = {
24
30
  sample: pd.read_csv(
25
31
  enrichment_dir / f"{sample}_EnrichScoreMatrix.csv", header=None
26
32
  ).to_numpy(dtype=float)
27
33
  for sample in samples
28
34
  }
35
+ expected_shape = next(iter(matrices.values())).shape
36
+ if expected_shape[0] != len(celltypes) or expected_shape[1] == 0:
37
+ raise ValueError(
38
+ f"{enrichment_dir}: enrichment rows must match cell types and include at least one CN"
39
+ )
40
+ for sample, matrix in matrices.items():
41
+ if matrix.shape != expected_shape:
42
+ raise ValueError(
43
+ f"{sample}: enrichment shape={matrix.shape}, expected {expected_shape}"
44
+ )
45
+ if not np.isfinite(matrix).all():
46
+ raise ValueError(f"{sample}: enrichment scores must be finite")
29
47
  return celltypes, matrices
30
48
 
31
49