cytocommunity2 0.1.1__py3-none-any.whl

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 (36) hide show
  1. cytocommunity2/__init__.py +23 -0
  2. cytocommunity2/colors.py +19 -0
  3. cytocommunity2/config.py +136 -0
  4. cytocommunity2/downstream/__init__.py +18 -0
  5. cytocommunity2/downstream/coherence.py +115 -0
  6. cytocommunity2/downstream/communication/__init__.py +11 -0
  7. cytocommunity2/downstream/communication/between.py +142 -0
  8. cytocommunity2/downstream/communication/cca.py +332 -0
  9. cytocommunity2/downstream/communication/utils.py +63 -0
  10. cytocommunity2/downstream/communication/within.py +266 -0
  11. cytocommunity2/downstream/composition.py +387 -0
  12. cytocommunity2/downstream/moran.py +309 -0
  13. cytocommunity2/downstream/plotting/__init__.py +21 -0
  14. cytocommunity2/downstream/plotting/cn_selection.py +72 -0
  15. cytocommunity2/downstream/plotting/coherence.py +64 -0
  16. cytocommunity2/downstream/plotting/common.py +79 -0
  17. cytocommunity2/downstream/plotting/communication.py +365 -0
  18. cytocommunity2/downstream/plotting/composition.py +80 -0
  19. cytocommunity2/downstream/plotting/dominant.py +142 -0
  20. cytocommunity2/downstream/plotting/dotplots.py +260 -0
  21. cytocommunity2/downstream/plotting/recurrence.py +203 -0
  22. cytocommunity2/downstream/runtime.py +13 -0
  23. cytocommunity2/ensemble.py +255 -0
  24. cytocommunity2/learning/__init__.py +12 -0
  25. cytocommunity2/learning/api.py +220 -0
  26. cytocommunity2/learning/dataset.py +137 -0
  27. cytocommunity2/learning/model.py +122 -0
  28. cytocommunity2/learning/selection.py +427 -0
  29. cytocommunity2/learning/training.py +216 -0
  30. cytocommunity2/paths.py +50 -0
  31. cytocommunity2/visualization/__init__.py +5 -0
  32. cytocommunity2/visualization/spatial.py +155 -0
  33. cytocommunity2-0.1.1.dist-info/METADATA +27 -0
  34. cytocommunity2-0.1.1.dist-info/RECORD +36 -0
  35. cytocommunity2-0.1.1.dist-info/WHEEL +5 -0
  36. cytocommunity2-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,332 @@
1
+ """Canonical correlation analysis between cellular neighborhoods."""
2
+
3
+ from itertools import combinations
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from sklearn.cross_decomposition import CCA
9
+
10
+ from ...config import CytoCommunityConfig
11
+ from ...paths import build_paths
12
+ from .utils import condition_sort_key
13
+
14
+
15
+ def pair_rng(base_seed, condition, cn_a, cn_b):
16
+ """Derive a reproducible independent random stream for a CN pair."""
17
+ seed = int(base_seed) % (2**32)
18
+ for character in f"{condition}|{cn_a}|{cn_b}":
19
+ seed = (seed * 31 + ord(character)) % (2**32)
20
+ return np.random.default_rng(seed)
21
+
22
+
23
+ def clean_cca_data(x, y, minimum_common_samples, sd_epsilon):
24
+ """Align two matrices and remove incomplete or near-constant variables."""
25
+ if x is None or y is None or x.shape[1] == 0 or y.shape[1] == 0:
26
+ return None
27
+ common = x.index.intersection(y.index)
28
+ if len(common) < minimum_common_samples:
29
+ return None
30
+ x = x.loc[common]
31
+ y = y.loc[common]
32
+ x = x.loc[:, x.notna().sum(axis=0) > 0]
33
+ y = y.loc[:, y.notna().sum(axis=0) > 0]
34
+ x = x.loc[:, x.std(axis=0) > sd_epsilon]
35
+ y = y.loc[:, y.std(axis=0) > sd_epsilon]
36
+ if x.shape[1] == 0 or y.shape[1] == 0:
37
+ return None
38
+ complete = x.notna().all(axis=1) & y.notna().all(axis=1)
39
+ x, y = x.loc[complete], y.loc[complete]
40
+ if x.shape[0] < minimum_common_samples or x.shape[1] == 0 or y.shape[1] == 0:
41
+ return None
42
+ return {
43
+ "X": x.values,
44
+ "Y": y.values,
45
+ "X_ct": x.columns.tolist(),
46
+ "Y_ct": y.columns.tolist(),
47
+ }
48
+
49
+
50
+ def build_score_list(frame, cn_list):
51
+ """Build one sample-by-cell-type score matrix per CN."""
52
+ matrices = {}
53
+ for cn in cn_list:
54
+ subset = frame[frame["CN"].astype(str) == str(cn)]
55
+ if subset.empty:
56
+ continue
57
+ matrix = subset.pivot_table(
58
+ index="Sample", columns="CellType", values="Score", aggfunc="mean"
59
+ )
60
+ if not matrix.empty:
61
+ matrices[str(cn)] = matrix.astype(float)
62
+ return matrices
63
+
64
+
65
+ def run_cca_for_scores(
66
+ score_list, cn_list, communication_params, random_seed, condition=None
67
+ ):
68
+ """Run CCA and permutation tests on in-memory enrichment matrices."""
69
+ result_rows = []
70
+ cache = {}
71
+ for cn_a, cn_b in combinations(map(str, cn_list), 2):
72
+ if cn_a not in score_list or cn_b not in score_list:
73
+ continue
74
+ cleaned = clean_cca_data(
75
+ score_list[cn_a],
76
+ score_list[cn_b],
77
+ communication_params["cca_minimum_common_samples"],
78
+ communication_params["cca_sd_epsilon"],
79
+ )
80
+ if cleaned is None:
81
+ continue
82
+ x_matrix, y_matrix = cleaned["X"], cleaned["Y"]
83
+ n_samples = x_matrix.shape[0]
84
+ max_components = min(x_matrix.shape[1], y_matrix.shape[1], n_samples - 2)
85
+ if max_components < 1:
86
+ continue
87
+
88
+ model = CCA(
89
+ n_components=max_components,
90
+ scale=True,
91
+ max_iter=communication_params["cca_max_iterations"],
92
+ )
93
+ model.fit(x_matrix, y_matrix)
94
+ x_scores, y_scores = model.transform(x_matrix, y_matrix)
95
+ actual_components = min(
96
+ communication_params["cca_canonical_components"], x_scores.shape[1]
97
+ )
98
+ correlations = [
99
+ np.corrcoef(x_scores[:, index], y_scores[:, index])[0, 1]
100
+ for index in range(actual_components)
101
+ ]
102
+
103
+ p_values = []
104
+ invalid_counts = []
105
+ rng = pair_rng(random_seed, condition, cn_a, cn_b)
106
+ for component, observed in enumerate(correlations):
107
+ extreme = 0
108
+ valid_count = 0
109
+ invalid_count = 0
110
+ for _ in range(communication_params["cca_n_bootstrap"]):
111
+ shuffled_y = rng.permutation(y_matrix)
112
+ permutation_model = CCA(
113
+ n_components=min(
114
+ communication_params["cca_canonical_components"],
115
+ max_components,
116
+ ),
117
+ scale=True,
118
+ max_iter=communication_params["cca_max_iterations"],
119
+ )
120
+ try:
121
+ permutation_model.fit(x_matrix, shuffled_y)
122
+ permuted_x, permuted_y = permutation_model.transform(
123
+ x_matrix, shuffled_y
124
+ )
125
+ except (ValueError, np.linalg.LinAlgError, FloatingPointError):
126
+ invalid_count += 1
127
+ continue
128
+ if permuted_x.shape[1] <= component:
129
+ continue
130
+ permuted_rho = abs(
131
+ np.corrcoef(
132
+ permuted_x[:, component], permuted_y[:, component]
133
+ )[0, 1]
134
+ )
135
+ valid_count += 1
136
+ if permuted_rho >= abs(observed) - 1e-12:
137
+ extreme += 1
138
+ p_values.append(
139
+ (extreme + 1) / (valid_count + 1) if valid_count else np.nan
140
+ )
141
+ invalid_counts.append(invalid_count)
142
+
143
+ row = {"CN_A": cn_a, "CN_B": cn_b, "n_samples": n_samples}
144
+ if condition is not None:
145
+ row["Condition"] = str(condition)
146
+ for component in range(communication_params["cca_canonical_components"]):
147
+ row[f"rho{component + 1}"] = (
148
+ correlations[component] if component < len(correlations) else None
149
+ )
150
+ row[f"pval{component + 1}"] = (
151
+ p_values[component] if component < len(p_values) else None
152
+ )
153
+ row[f"invalid_perm{component + 1}"] = (
154
+ invalid_counts[component] if component < len(invalid_counts) else None
155
+ )
156
+ result_rows.append(row)
157
+ key = "__".join(
158
+ filter(
159
+ None,
160
+ [str(condition) if condition is not None else "", cn_a, cn_b],
161
+ )
162
+ )
163
+ cache[key] = {
164
+ "X_scores": x_scores,
165
+ "Y_scores": y_scores,
166
+ "X_ct": cleaned["X_ct"],
167
+ "Y_ct": cleaned["Y_ct"],
168
+ "X_mat": x_matrix,
169
+ "Y_mat": y_matrix,
170
+ }
171
+
172
+ columns = ["CN_A", "CN_B", "n_samples"]
173
+ if condition is not None:
174
+ columns.append("Condition")
175
+ for component in range(communication_params["cca_canonical_components"]):
176
+ columns.extend(
177
+ [f"rho{component + 1}", f"pval{component + 1}", f"invalid_perm{component + 1}"]
178
+ )
179
+ results = pd.DataFrame(result_rows, columns=columns)
180
+ if not results.empty:
181
+ results = results.sort_values(
182
+ "rho1", key=lambda values: values.abs(), ascending=False, na_position="last"
183
+ )
184
+ return {"results": results, "cache": cache}
185
+
186
+
187
+ def cca_coordinate_tables(
188
+ results, cache, celltype_order, canonical_components, condition=None
189
+ ):
190
+ """Convert fitted CCA scores into per-pair cell-type loading tables."""
191
+ tables = {}
192
+ if results is None or results.empty:
193
+ return tables
194
+ for _, row in results.iterrows():
195
+ cn_a, cn_b = str(row["CN_A"]), str(row["CN_B"])
196
+ key = "__".join(
197
+ filter(None, [str(condition) if condition is not None else "", cn_a, cn_b])
198
+ )
199
+ if key not in cache:
200
+ continue
201
+ entry = cache[key]
202
+ all_celltypes = [
203
+ cell_type
204
+ for cell_type in celltype_order
205
+ if cell_type in set(entry["X_ct"]) | set(entry["Y_ct"])
206
+ ]
207
+ data = {"CellType": all_celltypes}
208
+ maximum = min(canonical_components, entry["X_scores"].shape[1])
209
+ for component in range(maximum):
210
+ x_score = entry["X_scores"][:, component]
211
+ y_score = entry["Y_scores"][:, component]
212
+ x_loadings = {
213
+ cell_type: np.corrcoef(entry["X_mat"][:, index], x_score)[0, 1]
214
+ if len(x_score) > 1
215
+ else np.nan
216
+ for index, cell_type in enumerate(entry["X_ct"])
217
+ }
218
+ y_loadings = {
219
+ cell_type: np.corrcoef(entry["Y_mat"][:, index], y_score)[0, 1]
220
+ if len(y_score) > 1
221
+ else np.nan
222
+ for index, cell_type in enumerate(entry["Y_ct"])
223
+ }
224
+ data[f"CN_A_coord_can{component + 1}"] = [
225
+ x_loadings.get(cell_type, np.nan) for cell_type in all_celltypes
226
+ ]
227
+ data[f"CN_B_coord_can{component + 1}"] = [
228
+ y_loadings.get(cell_type, np.nan) for cell_type in all_celltypes
229
+ ]
230
+ data[f"rho{component + 1}"] = [row.get(f"rho{component + 1}", np.nan)] * len(all_celltypes)
231
+ data[f"pval{component + 1}"] = [row.get(f"pval{component + 1}", np.nan)] * len(all_celltypes)
232
+ tables[(cn_a, cn_b)] = pd.DataFrame(data)
233
+ return tables
234
+
235
+
236
+ def write_cca_coordinates(
237
+ results, cache, output_dir, celltype_order, canonical_components, condition=None
238
+ ):
239
+ """Write coordinate tables using the original filenames."""
240
+ output_dir = Path(output_dir)
241
+ output_dir.mkdir(parents=True, exist_ok=True)
242
+ tables = cca_coordinate_tables(
243
+ results, cache, celltype_order, canonical_components, condition
244
+ )
245
+ prefix = f"{condition}_" if condition is not None else ""
246
+ for (cn_a, cn_b), table in tables.items():
247
+ table.to_csv(
248
+ output_dir / f"CCA_coordinates_{prefix}CN{cn_a}_vs_CN{cn_b}.csv",
249
+ index=False,
250
+ )
251
+ return tables
252
+
253
+
254
+ def _normalize_scores(frame):
255
+ frame = frame.copy()
256
+ for column in ["Sample", "Condition", "CN", "CellType"]:
257
+ frame[column] = frame[column].astype(str)
258
+ frame["Score"] = pd.to_numeric(frame["Score"], errors="coerce")
259
+ return frame
260
+
261
+
262
+ def run_cca_analysis(config, *, input_file=None, output_dir=None):
263
+ """Run pooled and condition-specific CCA using configured paths."""
264
+ if not isinstance(config, CytoCommunityConfig):
265
+ config = CytoCommunityConfig.from_json(config)
266
+ paths = build_paths(config)
267
+ params = config.downstream["communication"]
268
+ output_dir = Path(output_dir or paths.communication_config)
269
+ output_dir.mkdir(parents=True, exist_ok=True)
270
+ frame = _normalize_scores(
271
+ pd.read_csv(input_file or output_dir / "EnrichScoreMatrix_long.csv")
272
+ )
273
+ conditions = sorted(frame["Condition"].unique(), key=condition_sort_key)
274
+ cn_list = sorted(frame["CN"].unique(), key=condition_sort_key)
275
+ pooled = run_cca_for_scores(
276
+ build_score_list(frame, cn_list), cn_list, params, config.random_seed
277
+ )
278
+ pooled["results"].to_csv(output_dir / "CCA_config.csv", index=False)
279
+ write_cca_coordinates(
280
+ pooled["results"],
281
+ pooled["cache"],
282
+ output_dir,
283
+ config.celltype_order,
284
+ params["cca_canonical_components"],
285
+ )
286
+ condition_results = []
287
+ for condition in conditions:
288
+ subset = frame[frame["Condition"] == condition]
289
+ result = run_cca_for_scores(
290
+ build_score_list(subset, cn_list),
291
+ cn_list,
292
+ params,
293
+ config.random_seed,
294
+ condition,
295
+ )
296
+ result["results"].to_csv(output_dir / f"CCA_config_{condition}.csv", index=False)
297
+ write_cca_coordinates(
298
+ result["results"],
299
+ result["cache"],
300
+ output_dir,
301
+ config.celltype_order,
302
+ params["cca_canonical_components"],
303
+ condition,
304
+ )
305
+ condition_results.append(result["results"])
306
+ by_condition = (
307
+ pd.concat(condition_results, ignore_index=True)
308
+ if condition_results
309
+ else pd.DataFrame(columns=["Condition", *pooled["results"].columns])
310
+ )
311
+ if not by_condition.empty:
312
+ by_condition["Condition"] = pd.Categorical(
313
+ by_condition["Condition"], categories=conditions, ordered=True
314
+ )
315
+ by_condition = by_condition.sort_values(
316
+ ["Condition", "rho1"],
317
+ key=lambda values: abs(values) if values.name == "rho1" else values,
318
+ ascending=[True, False],
319
+ )
320
+ by_condition.to_csv(output_dir / "CCA_config_by_condition.csv", index=False)
321
+ return {"pooled": pooled["results"], "by_condition": by_condition}
322
+
323
+
324
+ __all__ = [
325
+ "build_score_list",
326
+ "cca_coordinate_tables",
327
+ "clean_cca_data",
328
+ "pair_rng",
329
+ "run_cca_analysis",
330
+ "run_cca_for_scores",
331
+ "write_cca_coordinates",
332
+ ]
@@ -0,0 +1,63 @@
1
+ """Shared, side-effect-free communication-analysis utilities."""
2
+
3
+ import itertools
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy.stats import spearmanr
8
+
9
+
10
+ def condition_label(condition):
11
+ condition = str(condition)
12
+ return condition if condition == "rest" else f"label{condition}"
13
+
14
+
15
+ def condition_pair_label(condition_a, condition_b):
16
+ return f"{condition_label(condition_a)}_vs_{condition_label(condition_b)}"
17
+
18
+
19
+ def condition_sort_key(value):
20
+ value = str(value)
21
+ try:
22
+ return (0, int(value), value)
23
+ except ValueError:
24
+ return (1, value)
25
+
26
+
27
+ def available_conditions(values):
28
+ present = pd.Series(values).dropna().astype(str).str.strip()
29
+ present = present[present.ne("")].unique().tolist()
30
+ return sorted(present, key=condition_sort_key)
31
+
32
+
33
+ def condition_pairs(values):
34
+ return list(itertools.combinations(available_conditions(values), 2))
35
+
36
+
37
+ def stable_seed(base_seed, *parts):
38
+ return int(base_seed) + sum(ord(character) for character in "_".join(map(str, parts)))
39
+
40
+
41
+ def safe_spearman(x, y, min_samples):
42
+ x = np.asarray(x, dtype=float)
43
+ y = np.asarray(y, dtype=float)
44
+ valid = (~np.isnan(x)) & (~np.isnan(y))
45
+ x = x[valid]
46
+ y = y[valid]
47
+ if len(x) < min_samples or len(np.unique(x)) < 2 or len(np.unique(y)) < 2:
48
+ return np.nan, np.nan
49
+ rho, p_value = spearmanr(x, y)
50
+ if np.isnan(rho):
51
+ return np.nan, np.nan
52
+ return float(rho), float(p_value) if np.isfinite(p_value) else np.nan
53
+
54
+
55
+ __all__ = [
56
+ "available_conditions",
57
+ "condition_label",
58
+ "condition_pair_label",
59
+ "condition_pairs",
60
+ "condition_sort_key",
61
+ "safe_spearman",
62
+ "stable_seed",
63
+ ]
@@ -0,0 +1,266 @@
1
+ """Within-CN cell-type communication statistics."""
2
+
3
+ import itertools
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from scipy.stats import spearmanr
9
+
10
+ from ...config import CytoCommunityConfig
11
+ from ...paths import build_paths
12
+ from .utils import (
13
+ available_conditions,
14
+ condition_pair_label,
15
+ safe_spearman,
16
+ stable_seed,
17
+ )
18
+
19
+
20
+ def compute_pooled_within_cn(scores_long, num_cn, min_samples):
21
+ """Compute pooled within-CN Spearman correlations."""
22
+ rows = []
23
+ for cn in range(1, num_cn + 1):
24
+ subset = scores_long[scores_long["CN"].astype(str) == str(cn)]
25
+ matrix = subset.pivot(index="Sample", columns="CellType", values="Score")
26
+ valid_columns = [
27
+ cell_type
28
+ for cell_type in matrix.columns
29
+ if matrix[cell_type].notna().sum() >= min_samples
30
+ and matrix[cell_type].dropna().std() > 0
31
+ ]
32
+ matrix = matrix[valid_columns]
33
+ if matrix.shape[1] < 2:
34
+ continue
35
+ correlation, p_value = spearmanr(matrix.values, axis=0, nan_policy="omit")
36
+ if np.isscalar(correlation):
37
+ correlation = np.array([[1.0, correlation], [correlation, 1.0]])
38
+ p_value = np.array([[0.0, p_value], [p_value, 0.0]])
39
+ for left, right in itertools.combinations(range(matrix.shape[1]), 2):
40
+ cell_type_a = matrix.columns[left]
41
+ cell_type_b = matrix.columns[right]
42
+ rows.append(
43
+ {
44
+ "CN": str(cn),
45
+ "CellType1": cell_type_a,
46
+ "CellType2": cell_type_b,
47
+ "rho": correlation[left, right],
48
+ "p": p_value[left, right],
49
+ "n": len(matrix[[cell_type_a, cell_type_b]].dropna()),
50
+ }
51
+ )
52
+ return pd.DataFrame(rows)
53
+
54
+
55
+ def prepare_wide_tables(scores_long, cn_order):
56
+ tables = {}
57
+ for cn in cn_order:
58
+ subset = scores_long[scores_long["CN"].astype(str) == str(cn)]
59
+ if subset.empty:
60
+ continue
61
+ tables[str(cn)] = (
62
+ subset.pivot_table(
63
+ index=["Sample", "Condition"],
64
+ columns="CellType",
65
+ values="Score",
66
+ aggfunc="mean",
67
+ ).reset_index()
68
+ )
69
+ return tables
70
+
71
+
72
+ def permutation_pvalue(
73
+ x,
74
+ y,
75
+ labels,
76
+ condition_a,
77
+ condition_b,
78
+ observed_delta,
79
+ n_permutations,
80
+ min_samples,
81
+ max_attempt_multiplier,
82
+ seed,
83
+ ):
84
+ """Two-sided label permutation test for a difference in Spearman rho."""
85
+ x = np.asarray(x, dtype=float)
86
+ y = np.asarray(y, dtype=float)
87
+ labels = np.asarray(labels)
88
+ valid = (~np.isnan(x)) & (~np.isnan(y)) & np.isin(
89
+ labels, [condition_a, condition_b]
90
+ )
91
+ x, y, labels = x[valid], y[valid], labels[valid]
92
+ indices_a = np.where(labels == condition_a)[0]
93
+ indices_b = np.where(labels == condition_b)[0]
94
+ if len(indices_a) < min_samples or len(indices_b) < min_samples:
95
+ return np.nan, 0
96
+
97
+ rng = np.random.default_rng(seed)
98
+ all_indices = np.arange(len(labels))
99
+ extreme = 0
100
+ valid_count = 0
101
+ for _ in range(n_permutations * max_attempt_multiplier):
102
+ permutation = rng.permutation(all_indices)
103
+ permuted_a = permutation[: len(indices_a)]
104
+ permuted_b = permutation[len(indices_a) :]
105
+ rho_a, _ = safe_spearman(x[permuted_a], y[permuted_a], min_samples)
106
+ rho_b, _ = safe_spearman(x[permuted_b], y[permuted_b], min_samples)
107
+ if np.isnan(rho_a) or np.isnan(rho_b):
108
+ continue
109
+ valid_count += 1
110
+ if abs(rho_b - rho_a) >= abs(observed_delta) - 1e-12:
111
+ extreme += 1
112
+ if valid_count >= n_permutations:
113
+ break
114
+ if valid_count == 0:
115
+ return np.nan, 0
116
+ return (extreme + 1.0) / (valid_count + 1.0), valid_count
117
+
118
+
119
+ def build_condition_comparisons(values, comparison_mode):
120
+ labels = available_conditions(values)
121
+ if len(labels) < 2:
122
+ return []
123
+ if comparison_mode == "pairwise":
124
+ return [
125
+ {
126
+ "cond_a": condition_a,
127
+ "cond_b": condition_b,
128
+ "labels_a": [condition_a],
129
+ "labels_b": [condition_b],
130
+ }
131
+ for condition_a, condition_b in itertools.combinations(labels, 2)
132
+ ]
133
+ if comparison_mode == "one_vs_rest":
134
+ return [
135
+ {
136
+ "cond_a": target,
137
+ "cond_b": "rest",
138
+ "labels_a": [target],
139
+ "labels_b": [label for label in labels if label != target],
140
+ }
141
+ for target in labels
142
+ ]
143
+ raise ValueError(f"Unknown comparison mode: {comparison_mode}")
144
+
145
+
146
+ def compute_condition_comparison_stats(
147
+ wide_dict,
148
+ cond_a,
149
+ cond_b,
150
+ labels_a,
151
+ labels_b,
152
+ cn_order,
153
+ celltype_order,
154
+ min_samples,
155
+ n_permutations,
156
+ max_attempt_multiplier,
157
+ base_seed,
158
+ ):
159
+ """Compute one pairwise or one-vs-rest within-CN comparison."""
160
+ rows = []
161
+ for cn in cn_order:
162
+ wide = wide_dict.get(str(cn))
163
+ if wide is None:
164
+ continue
165
+ wide = wide[wide["Condition"].astype(str).isin(labels_a + labels_b)].copy()
166
+ if wide.empty:
167
+ continue
168
+ wide["ComparisonGroup"] = np.where(
169
+ wide["Condition"].astype(str).isin(labels_a), cond_a, cond_b
170
+ )
171
+ celltypes = [cell_type for cell_type in celltype_order if cell_type in wide]
172
+ for cell_type_a, cell_type_b in itertools.combinations(celltypes, 2):
173
+ paired = wide[["Sample", "ComparisonGroup", cell_type_a, cell_type_b]].dropna()
174
+ group_a = paired[paired["ComparisonGroup"] == cond_a]
175
+ group_b = paired[paired["ComparisonGroup"] == cond_b]
176
+ if len(group_a) < min_samples or len(group_b) < min_samples:
177
+ continue
178
+ rho_a, p_a = safe_spearman(
179
+ group_a[cell_type_a], group_a[cell_type_b], min_samples
180
+ )
181
+ rho_b, p_b = safe_spearman(
182
+ group_b[cell_type_a], group_b[cell_type_b], min_samples
183
+ )
184
+ if np.isnan(rho_a) or np.isnan(rho_b):
185
+ continue
186
+ delta = rho_b - rho_a
187
+ permutation_p, _ = permutation_pvalue(
188
+ paired[cell_type_a],
189
+ paired[cell_type_b],
190
+ paired["ComparisonGroup"],
191
+ cond_a,
192
+ cond_b,
193
+ delta,
194
+ n_permutations,
195
+ min_samples,
196
+ max_attempt_multiplier,
197
+ stable_seed(base_seed, cond_a, cond_b, cn, cell_type_a, cell_type_b),
198
+ )
199
+ rows.append(
200
+ {
201
+ "CN": str(cn),
202
+ "CellType1": cell_type_a,
203
+ "CellType2": cell_type_b,
204
+ "rho_A": rho_a,
205
+ "p_A": p_a,
206
+ "rho_B": rho_b,
207
+ "p_B": p_b,
208
+ "perm_p": permutation_p,
209
+ }
210
+ )
211
+ if not rows:
212
+ return pd.DataFrame()
213
+ return pd.DataFrame(rows).sort_values(
214
+ ["CN", "CellType1", "CellType2"]
215
+ ).reset_index(drop=True)
216
+
217
+
218
+ def run_within_cn_analysis(config, *, input_file=None, output_dir=None):
219
+ """Run and save pooled plus condition-specific within-CN analyses."""
220
+ if not isinstance(config, CytoCommunityConfig):
221
+ config = CytoCommunityConfig.from_json(config)
222
+ paths = build_paths(config)
223
+ params = config.downstream["communication"]
224
+ output_dir = Path(output_dir or paths.communication_config)
225
+ output_dir.mkdir(parents=True, exist_ok=True)
226
+ input_file = Path(input_file or output_dir / "EnrichScoreMatrix_long.csv")
227
+ scores = pd.read_csv(input_file, dtype={"Condition": str, "CN": str})
228
+ num_cn = int(paths.selected_cn_num.read_text(encoding="utf-8"))
229
+ cn_order = [str(index) for index in range(1, num_cn + 1)]
230
+ wide = prepare_wide_tables(scores, cn_order)
231
+ outputs = {
232
+ "pooled": compute_pooled_within_cn(scores, num_cn, params["minimum_samples"])
233
+ }
234
+ outputs["pooled"].to_csv(output_dir / "SpearmanWithinCNs.csv", index=False)
235
+ for comparison in build_condition_comparisons(
236
+ scores["Condition"], config.downstream["ttest_mode"]
237
+ ):
238
+ result = compute_condition_comparison_stats(
239
+ wide_dict=wide,
240
+ cond_a=comparison["cond_a"],
241
+ cond_b=comparison["cond_b"],
242
+ labels_a=comparison["labels_a"],
243
+ labels_b=comparison["labels_b"],
244
+ cn_order=cn_order,
245
+ celltype_order=config.celltype_order,
246
+ min_samples=params["minimum_samples"],
247
+ n_permutations=params["n_permutations"],
248
+ max_attempt_multiplier=params["permutation_max_attempt_multiplier"],
249
+ base_seed=config.random_seed,
250
+ )
251
+ if result.empty:
252
+ continue
253
+ label = condition_pair_label(comparison["cond_a"], comparison["cond_b"])
254
+ result.to_csv(output_dir / f"CommunicationPair_{label}.csv", index=False)
255
+ outputs[label] = result
256
+ return outputs
257
+
258
+
259
+ __all__ = [
260
+ "build_condition_comparisons",
261
+ "compute_condition_comparison_stats",
262
+ "compute_pooled_within_cn",
263
+ "permutation_pvalue",
264
+ "prepare_wide_tables",
265
+ "run_within_cn_analysis",
266
+ ]