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.
- cytocommunity2/__init__.py +23 -0
- cytocommunity2/colors.py +19 -0
- cytocommunity2/config.py +136 -0
- cytocommunity2/downstream/__init__.py +18 -0
- cytocommunity2/downstream/coherence.py +115 -0
- cytocommunity2/downstream/communication/__init__.py +11 -0
- cytocommunity2/downstream/communication/between.py +142 -0
- cytocommunity2/downstream/communication/cca.py +332 -0
- cytocommunity2/downstream/communication/utils.py +63 -0
- cytocommunity2/downstream/communication/within.py +266 -0
- cytocommunity2/downstream/composition.py +387 -0
- cytocommunity2/downstream/moran.py +309 -0
- cytocommunity2/downstream/plotting/__init__.py +21 -0
- cytocommunity2/downstream/plotting/cn_selection.py +72 -0
- cytocommunity2/downstream/plotting/coherence.py +64 -0
- cytocommunity2/downstream/plotting/common.py +79 -0
- cytocommunity2/downstream/plotting/communication.py +365 -0
- cytocommunity2/downstream/plotting/composition.py +80 -0
- cytocommunity2/downstream/plotting/dominant.py +142 -0
- cytocommunity2/downstream/plotting/dotplots.py +260 -0
- cytocommunity2/downstream/plotting/recurrence.py +203 -0
- cytocommunity2/downstream/runtime.py +13 -0
- cytocommunity2/ensemble.py +255 -0
- cytocommunity2/learning/__init__.py +12 -0
- cytocommunity2/learning/api.py +220 -0
- cytocommunity2/learning/dataset.py +137 -0
- cytocommunity2/learning/model.py +122 -0
- cytocommunity2/learning/selection.py +427 -0
- cytocommunity2/learning/training.py +216 -0
- cytocommunity2/paths.py +50 -0
- cytocommunity2/visualization/__init__.py +5 -0
- cytocommunity2/visualization/spatial.py +155 -0
- cytocommunity2-0.1.1.dist-info/METADATA +27 -0
- cytocommunity2-0.1.1.dist-info/RECORD +36 -0
- cytocommunity2-0.1.1.dist-info/WHEEL +5 -0
- 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
|
+
]
|