cytocommunity2 1.0.4__tar.gz → 1.0.6__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.
- {cytocommunity2-1.0.4/src/cytocommunity2.egg-info → cytocommunity2-1.0.6}/PKG-INFO +1 -1
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/pyproject.toml +1 -1
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/__init__.py +1 -1
- cytocommunity2-1.0.6/src/cytocommunity2/_io.py +94 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/between.py +34 -22
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/cca.py +53 -39
- cytocommunity2-1.0.6/src/cytocommunity2/downstream/communication/utils.py +140 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/within.py +42 -34
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/differential_composition.py +3 -4
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/enrichment.py +7 -19
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/moran.py +10 -11
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/cn_selection.py +2 -1
- cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/coherence.py +91 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/common.py +44 -7
- cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/communication.py +121 -0
- cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/communication_cca.py +148 -0
- cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/communication_stack.py +217 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/dominant.py +5 -3
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/dotplots.py +142 -40
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/recurrence.py +31 -32
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/recurrence.py +11 -3
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/ensemble.py +10 -11
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/api.py +41 -51
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/dataset.py +11 -21
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/paths.py +16 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/visualization/spatial.py +49 -36
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6/src/cytocommunity2.egg-info}/PKG-INFO +1 -1
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/SOURCES.txt +2 -0
- cytocommunity2-1.0.4/src/cytocommunity2/_io.py +0 -11
- cytocommunity2-1.0.4/src/cytocommunity2/downstream/communication/utils.py +0 -63
- cytocommunity2-1.0.4/src/cytocommunity2/downstream/plotting/coherence.py +0 -50
- cytocommunity2-1.0.4/src/cytocommunity2/downstream/plotting/communication.py +0 -350
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/LICENSE +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/README.md +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/setup.cfg +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/colors.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/config.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/__init__.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/coherence.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/dominant_celltype.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/wilcoxon.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/__init__.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/model.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/selection.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/training.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/visualization/__init__.py +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/requires.txt +0 -0
- {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Shared input readers with explicit cell-row and condition-label contracts."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_nonempty_lines(path):
|
|
9
|
+
"""Read a list such as ImageNameList, allowing blank separator lines."""
|
|
10
|
+
return [
|
|
11
|
+
line.strip()
|
|
12
|
+
for line in Path(path).read_text(encoding="utf-8-sig").splitlines()
|
|
13
|
+
if line.strip()
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_cell_type_labels(path, *, expected_count=None):
|
|
18
|
+
"""Read one label per cell, preserving literal labels such as ``NA``.
|
|
19
|
+
|
|
20
|
+
Cell rows cannot be removed: an empty label would shift every subsequent
|
|
21
|
+
label relative to the coordinates and CN assignments, so it is an error.
|
|
22
|
+
"""
|
|
23
|
+
path = Path(path)
|
|
24
|
+
labels = [line.strip() for line in path.read_text(encoding="utf-8-sig").splitlines()]
|
|
25
|
+
if not labels:
|
|
26
|
+
raise ValueError(f"{path}: no cell-type labels found")
|
|
27
|
+
for row, label in enumerate(labels, start=1):
|
|
28
|
+
if not label:
|
|
29
|
+
raise ValueError(f"{path}: empty cell-type label at row {row}")
|
|
30
|
+
if expected_count is not None and len(labels) != expected_count:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"{path}: cell-type labels={len(labels)}, expected cell rows={expected_count}"
|
|
33
|
+
)
|
|
34
|
+
return labels
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_coordinates(coordinates, *, source="Coordinates", expected_count=None):
|
|
38
|
+
"""Require a nonempty, finite x/y coordinate matrix with one row per cell."""
|
|
39
|
+
coordinates = np.asarray(coordinates, dtype=np.float64)
|
|
40
|
+
if coordinates.ndim != 2 or coordinates.shape[1] != 2 or not len(coordinates):
|
|
41
|
+
raise ValueError(f"{source}: coordinates must have at least one row and exactly 2 columns")
|
|
42
|
+
if not np.isfinite(coordinates).all():
|
|
43
|
+
raise ValueError(f"{source}: coordinates must contain only finite values")
|
|
44
|
+
if expected_count is not None and len(coordinates) != expected_count:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"{source}: coordinates={len(coordinates)}, expected cell rows={expected_count}"
|
|
47
|
+
)
|
|
48
|
+
return coordinates
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_coordinates(path, *, expected_count=None):
|
|
52
|
+
"""Read tab-delimited coordinates and validate their shape and cell count."""
|
|
53
|
+
path = Path(path)
|
|
54
|
+
coordinates = np.loadtxt(
|
|
55
|
+
path, dtype=np.float64, delimiter="\t", ndmin=2, encoding="utf-8-sig"
|
|
56
|
+
)
|
|
57
|
+
return validate_coordinates(coordinates, source=path, expected_count=expected_count)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_graph_labels(input_dir, samples=None, *, allow_missing=False):
|
|
61
|
+
"""Read original integer condition labels, without training-label remapping.
|
|
62
|
+
|
|
63
|
+
Every sample must have a GraphLabel file, or all files must be absent.
|
|
64
|
+
An all-missing dataset is assigned condition 0 only when the caller permits
|
|
65
|
+
single-condition data; a partially labelled dataset is always rejected.
|
|
66
|
+
"""
|
|
67
|
+
input_dir = Path(input_dir)
|
|
68
|
+
samples = list(samples) if samples is not None else read_nonempty_lines(
|
|
69
|
+
input_dir / "ImageNameList.txt"
|
|
70
|
+
)
|
|
71
|
+
if not samples:
|
|
72
|
+
raise ValueError(f"{input_dir}: no samples found")
|
|
73
|
+
files = {sample: input_dir / f"{sample}_GraphLabel.txt" for sample in samples}
|
|
74
|
+
present = {sample: path.is_file() for sample, path in files.items()}
|
|
75
|
+
if not any(present.values()):
|
|
76
|
+
if allow_missing:
|
|
77
|
+
return dict.fromkeys(samples, 0)
|
|
78
|
+
raise FileNotFoundError(f"{input_dir}: GraphLabel files are required for this analysis")
|
|
79
|
+
if not all(present.values()):
|
|
80
|
+
missing = ", ".join(sample for sample in samples if not present[sample])
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"GraphLabel files must be provided for either all or no samples; "
|
|
83
|
+
f"missing: {missing}"
|
|
84
|
+
)
|
|
85
|
+
labels = {}
|
|
86
|
+
for sample, path in files.items():
|
|
87
|
+
values = read_nonempty_lines(path)
|
|
88
|
+
try:
|
|
89
|
+
if len(values) != 1:
|
|
90
|
+
raise ValueError
|
|
91
|
+
labels[sample] = int(values[0])
|
|
92
|
+
except ValueError as error:
|
|
93
|
+
raise ValueError(f"{path}: expected exactly one integer GraphLabel") from error
|
|
94
|
+
return labels
|
{cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/between.py
RENAMED
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
import pandas as pd
|
|
6
|
-
from scipy.stats import spearmanr
|
|
7
6
|
|
|
8
7
|
from ...config import CytoCommunityConfig
|
|
9
8
|
from ...paths import build_paths
|
|
9
|
+
from .utils import (
|
|
10
|
+
build_score_tables,
|
|
11
|
+
normalize_scores_long,
|
|
12
|
+
read_scores_long,
|
|
13
|
+
safe_spearman,
|
|
14
|
+
)
|
|
10
15
|
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return frame
|
|
17
|
+
_RESULT_COLUMNS = [
|
|
18
|
+
"CN_A", "CN_B", "Top_CellType_A", "Top_CellType_B",
|
|
19
|
+
"Spearman_rho", "Spearman_p", "N_overlap"
|
|
20
|
+
]
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
def select_cca_pairs(cca_pairs, top_n):
|
|
@@ -31,6 +35,8 @@ def analyze_between_cn(
|
|
|
31
35
|
"""Calculate between-CN correlations without filesystem access."""
|
|
32
36
|
scores = normalize_scores_long(scores_long)
|
|
33
37
|
pairs = select_cca_pairs(cca_pairs, top_n_cca_pairs)
|
|
38
|
+
selected_cns = pd.concat([pairs["CN_A"], pairs["CN_B"]]).drop_duplicates()
|
|
39
|
+
matrices = build_score_tables(scores, selected_cns, duplicate_policy="raise")
|
|
34
40
|
rows = []
|
|
35
41
|
for _, pair in pairs.iterrows():
|
|
36
42
|
cn_a, cn_b = str(pair["CN_A"]), str(pair["CN_B"])
|
|
@@ -42,15 +48,14 @@ def analyze_between_cn(
|
|
|
42
48
|
top_b_index = loadings_b["CN_B_loading1"].abs().idxmax()
|
|
43
49
|
top_a = loadings_a.loc[top_a_index, "CellType"]
|
|
44
50
|
top_b = loadings_b.loc[top_b_index, "CellType"]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
merged = pd.merge(scores_a, scores_b, on="Sample", how="inner")
|
|
51
|
+
merged = pd.concat(
|
|
52
|
+
[
|
|
53
|
+
matrices[cn_a][top_a].rename("Score_A"),
|
|
54
|
+
matrices[cn_b][top_b].rename("Score_B"),
|
|
55
|
+
],
|
|
56
|
+
axis=1,
|
|
57
|
+
join="inner",
|
|
58
|
+
).dropna()
|
|
54
59
|
if len(merged) < minimum_samples:
|
|
55
60
|
raise ValueError(
|
|
56
61
|
f"Too few overlapping samples for CN{cn_a}({top_a}) vs CN{cn_b}({top_b})"
|
|
@@ -59,7 +64,9 @@ def analyze_between_cn(
|
|
|
59
64
|
raise ValueError(
|
|
60
65
|
f"Constant enrichment scores for CN{cn_a}({top_a}) vs CN{cn_b}({top_b})"
|
|
61
66
|
)
|
|
62
|
-
rho, p_value =
|
|
67
|
+
rho, p_value = safe_spearman(
|
|
68
|
+
merged["Score_A"], merged["Score_B"], minimum_samples
|
|
69
|
+
)
|
|
63
70
|
rows.append(
|
|
64
71
|
{
|
|
65
72
|
"CN_A": cn_a,
|
|
@@ -72,23 +79,28 @@ def analyze_between_cn(
|
|
|
72
79
|
"N_overlap": len(merged),
|
|
73
80
|
}
|
|
74
81
|
)
|
|
75
|
-
result = pd.DataFrame(rows)
|
|
82
|
+
result = pd.DataFrame(rows, columns=_RESULT_COLUMNS + ["rho1_CCA"])
|
|
76
83
|
if not result.empty:
|
|
77
84
|
result = result.sort_values(
|
|
78
85
|
"rho1_CCA", key=lambda values: values.abs(), ascending=False
|
|
79
|
-
)
|
|
80
|
-
return result
|
|
86
|
+
)
|
|
87
|
+
return result.drop(columns="rho1_CCA")
|
|
81
88
|
|
|
82
89
|
|
|
83
90
|
def run_between_cn_statistics(config_dir, minimum_samples, top_n_cca_pairs):
|
|
84
91
|
"""Calculate and save Spearman statistics for top CCA pairs."""
|
|
85
92
|
config_dir = Path(config_dir)
|
|
86
|
-
scores =
|
|
87
|
-
|
|
93
|
+
scores = read_scores_long(
|
|
94
|
+
config_dir / "EnrichScoreMatrix_long.csv", require_condition=False
|
|
95
|
+
)
|
|
96
|
+
pairs = pd.read_csv(
|
|
97
|
+
config_dir / "CCA_results.csv", dtype={"CN_A": str, "CN_B": str}
|
|
98
|
+
)
|
|
88
99
|
selected = select_cca_pairs(pairs, top_n_cca_pairs)
|
|
89
100
|
tables = {
|
|
90
101
|
(str(row.CN_A), str(row.CN_B)): pd.read_csv(
|
|
91
|
-
config_dir / f"CCA_loadings_CN{row.CN_A}_vs_CN{row.CN_B}.csv"
|
|
102
|
+
config_dir / f"CCA_loadings_CN{row.CN_A}_vs_CN{row.CN_B}.csv",
|
|
103
|
+
converters={"CellType": str.strip},
|
|
92
104
|
)
|
|
93
105
|
for row in selected.itertuples(index=False)
|
|
94
106
|
}
|
{cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/cca.py
RENAMED
|
@@ -9,7 +9,22 @@ from sklearn.cross_decomposition import CCA
|
|
|
9
9
|
|
|
10
10
|
from ...config import CytoCommunityConfig
|
|
11
11
|
from ...paths import build_paths
|
|
12
|
-
from .utils import
|
|
12
|
+
from .utils import (
|
|
13
|
+
build_score_tables,
|
|
14
|
+
condition_sort_key,
|
|
15
|
+
normalize_scores_long,
|
|
16
|
+
prune_csv_outputs,
|
|
17
|
+
read_scores_long,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _result_columns(n_components, *, by_condition=False):
|
|
22
|
+
columns = ["CN_A", "CN_B", "n_samples"]
|
|
23
|
+
if by_condition:
|
|
24
|
+
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}"])
|
|
27
|
+
return columns
|
|
13
28
|
|
|
14
29
|
|
|
15
30
|
def pair_rng(base_seed, condition, cn_a, cn_b):
|
|
@@ -46,18 +61,8 @@ def clean_cca_data(x, y, minimum_common_samples, min_std):
|
|
|
46
61
|
|
|
47
62
|
|
|
48
63
|
def build_score_list(frame, cn_list):
|
|
49
|
-
"""Build
|
|
50
|
-
|
|
51
|
-
for cn in cn_list:
|
|
52
|
-
subset = frame[frame["CN"].astype(str) == str(cn)]
|
|
53
|
-
if subset.empty:
|
|
54
|
-
continue
|
|
55
|
-
matrix = subset.pivot_table(
|
|
56
|
-
index="Sample", columns="CellType", values="Score", aggfunc="mean"
|
|
57
|
-
)
|
|
58
|
-
if not matrix.empty:
|
|
59
|
-
matrices[str(cn)] = matrix.astype(float)
|
|
60
|
-
return matrices
|
|
64
|
+
"""Build score matrices with the established duplicate-mean aggregation."""
|
|
65
|
+
return build_score_tables(frame, cn_list, duplicate_policy="mean")
|
|
61
66
|
|
|
62
67
|
|
|
63
68
|
def select_top_features(matrix, top_n=5):
|
|
@@ -180,7 +185,12 @@ def run_cca_for_scores(
|
|
|
180
185
|
"Y_mat": y_matrix,
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
results = pd.DataFrame(
|
|
188
|
+
results = pd.DataFrame(
|
|
189
|
+
result_rows,
|
|
190
|
+
columns=_result_columns(
|
|
191
|
+
communication_params["cca_n_components"], by_condition=condition is not None
|
|
192
|
+
),
|
|
193
|
+
)
|
|
184
194
|
if not results.empty:
|
|
185
195
|
results = results.sort_values(
|
|
186
196
|
"rho1", key=lambda values: values.abs(), ascending=False, na_position="last"
|
|
@@ -210,7 +220,11 @@ def cca_loading_tables(
|
|
|
210
220
|
]
|
|
211
221
|
data = {"CellType": all_celltypes}
|
|
212
222
|
maximum = min(n_components, entry["X_scores"].shape[1])
|
|
213
|
-
for component in range(
|
|
223
|
+
for component in range(n_components):
|
|
224
|
+
if component >= maximum:
|
|
225
|
+
data[f"CN_A_loading{component + 1}"] = [np.nan] * len(all_celltypes)
|
|
226
|
+
data[f"CN_B_loading{component + 1}"] = [np.nan] * len(all_celltypes)
|
|
227
|
+
continue
|
|
214
228
|
x_score = entry["X_scores"][:, component]
|
|
215
229
|
y_score = entry["Y_scores"][:, component]
|
|
216
230
|
x_loadings = {
|
|
@@ -250,10 +264,7 @@ def write_cca_loadings(
|
|
|
250
264
|
|
|
251
265
|
|
|
252
266
|
def _normalize_scores(frame):
|
|
253
|
-
frame =
|
|
254
|
-
for column in ["Sample", "Condition", "CN", "CellType"]:
|
|
255
|
-
frame[column] = frame[column].astype(str)
|
|
256
|
-
return frame
|
|
267
|
+
return normalize_scores_long(frame, require_condition=True)
|
|
257
268
|
|
|
258
269
|
|
|
259
270
|
def run_cca_analysis(
|
|
@@ -264,24 +275,15 @@ def run_cca_analysis(
|
|
|
264
275
|
params = config.downstream["communication"]
|
|
265
276
|
output_dir = Path(output_dir or paths.communication_config)
|
|
266
277
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
267
|
-
frame =
|
|
268
|
-
pd.read_csv(input_file or output_dir / "EnrichScoreMatrix_long.csv")
|
|
269
|
-
)
|
|
278
|
+
frame = read_scores_long(input_file or output_dir / "EnrichScoreMatrix_long.csv")
|
|
270
279
|
celltype_order = frame["CellType"].astype(str).drop_duplicates().tolist()
|
|
271
280
|
conditions = sorted(frame["Condition"].unique(), key=condition_sort_key)
|
|
272
281
|
cn_list = sorted(frame["CN"].unique(), key=condition_sort_key)
|
|
273
282
|
pooled = run_cca_for_scores(
|
|
274
283
|
build_score_list(frame, cn_list), cn_list, params, config.random_seed
|
|
275
284
|
)
|
|
276
|
-
pooled["results"].to_csv(output_dir / "CCA_results.csv", index=False)
|
|
277
|
-
write_cca_loadings(
|
|
278
|
-
pooled["results"],
|
|
279
|
-
pooled["cache"],
|
|
280
|
-
output_dir,
|
|
281
|
-
celltype_order,
|
|
282
|
-
params["cca_n_components"],
|
|
283
|
-
)
|
|
284
285
|
condition_results = []
|
|
286
|
+
fitted_conditions = []
|
|
285
287
|
for condition in conditions:
|
|
286
288
|
subset = frame[frame["Condition"] == condition]
|
|
287
289
|
result = run_cca_for_scores(
|
|
@@ -291,19 +293,14 @@ def run_cca_analysis(
|
|
|
291
293
|
config.random_seed,
|
|
292
294
|
condition,
|
|
293
295
|
)
|
|
294
|
-
write_cca_loadings(
|
|
295
|
-
result["results"],
|
|
296
|
-
result["cache"],
|
|
297
|
-
output_dir,
|
|
298
|
-
celltype_order,
|
|
299
|
-
params["cca_n_components"],
|
|
300
|
-
condition,
|
|
301
|
-
)
|
|
302
296
|
condition_results.append(result["results"])
|
|
297
|
+
fitted_conditions.append((condition, result))
|
|
303
298
|
by_condition = (
|
|
304
299
|
pd.concat(condition_results, ignore_index=True)
|
|
305
300
|
if condition_results
|
|
306
|
-
else pd.DataFrame(
|
|
301
|
+
else pd.DataFrame(
|
|
302
|
+
columns=_result_columns(params["cca_n_components"], by_condition=True)
|
|
303
|
+
)
|
|
307
304
|
)
|
|
308
305
|
if not by_condition.empty:
|
|
309
306
|
by_condition["Condition"] = pd.Categorical(
|
|
@@ -314,7 +311,24 @@ def run_cca_analysis(
|
|
|
314
311
|
key=lambda values: abs(values) if values.name == "rho1" else values,
|
|
315
312
|
ascending=[True, False],
|
|
316
313
|
)
|
|
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
|
+
)
|
|
327
|
+
pooled["results"].to_csv(output_dir / "CCA_results.csv", index=False)
|
|
317
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
|
+
)
|
|
318
332
|
return {"pooled": pooled["results"], "by_condition": by_condition}
|
|
319
333
|
|
|
320
334
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Shared communication table and statistical utilities."""
|
|
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
|
+
|
|
11
|
+
def normalize_scores_long(scores_long, *, require_condition=False):
|
|
12
|
+
"""Validate score columns and normalize identifiers without losing NA labels."""
|
|
13
|
+
identifiers = ["Sample", "CN", "CellType"]
|
|
14
|
+
if require_condition or "Condition" in scores_long:
|
|
15
|
+
identifiers.insert(1, "Condition")
|
|
16
|
+
required = identifiers + ["Score"]
|
|
17
|
+
missing = [column for column in required if column not in scores_long]
|
|
18
|
+
if missing:
|
|
19
|
+
raise ValueError(f"Enrichment scores are missing columns: {', '.join(missing)}")
|
|
20
|
+
frame = scores_long.copy()
|
|
21
|
+
for column in identifiers:
|
|
22
|
+
if frame[column].isna().any():
|
|
23
|
+
raise ValueError(f"Enrichment score identifiers cannot be missing: {column}")
|
|
24
|
+
frame[column] = frame[column].astype(str).str.strip()
|
|
25
|
+
if frame[column].eq("").any():
|
|
26
|
+
raise ValueError(f"Enrichment score identifiers cannot be blank: {column}")
|
|
27
|
+
frame["Score"] = pd.to_numeric(frame["Score"].replace("", np.nan), errors="raise")
|
|
28
|
+
return frame
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_scores_long(path, *, require_condition=True):
|
|
32
|
+
"""Read score tables while preserving literal identifiers such as ``NA``."""
|
|
33
|
+
frame = pd.read_csv(
|
|
34
|
+
path,
|
|
35
|
+
dtype={column: str for column in ["Sample", "Condition", "CN", "CellType"]},
|
|
36
|
+
keep_default_na=False,
|
|
37
|
+
na_values={"Score": ["", "NaN", "nan", "NA"]},
|
|
38
|
+
)
|
|
39
|
+
return normalize_scores_long(frame, require_condition=require_condition)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_score_tables(
|
|
43
|
+
scores_long, cn_order, *, index_columns=("Sample",), duplicate_policy="raise"
|
|
44
|
+
):
|
|
45
|
+
"""Build CN score matrices with an explicit reject-or-mean duplicate policy."""
|
|
46
|
+
if duplicate_policy not in {"raise", "mean"}:
|
|
47
|
+
raise ValueError(f"Unknown score duplicate policy: {duplicate_policy}")
|
|
48
|
+
frame = normalize_scores_long(
|
|
49
|
+
scores_long, require_condition="Condition" in index_columns
|
|
50
|
+
)
|
|
51
|
+
index_columns = list(index_columns)
|
|
52
|
+
tables = {}
|
|
53
|
+
for cn in cn_order:
|
|
54
|
+
subset = frame[frame["CN"] == str(cn)]
|
|
55
|
+
if subset.empty:
|
|
56
|
+
continue
|
|
57
|
+
if duplicate_policy == "raise":
|
|
58
|
+
if subset.duplicated(index_columns + ["CellType"]).any():
|
|
59
|
+
raise ValueError(f"Duplicate enrichment score rows for CN{cn}")
|
|
60
|
+
matrix = subset.pivot(
|
|
61
|
+
index=index_columns, columns="CellType", values="Score"
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
matrix = subset.pivot_table(
|
|
65
|
+
index=index_columns,
|
|
66
|
+
columns="CellType",
|
|
67
|
+
values="Score",
|
|
68
|
+
aggfunc="mean",
|
|
69
|
+
)
|
|
70
|
+
if not matrix.empty:
|
|
71
|
+
tables[str(cn)] = matrix.astype(float)
|
|
72
|
+
return tables
|
|
73
|
+
|
|
74
|
+
|
|
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
|
+
def condition_label(condition):
|
|
84
|
+
condition = str(condition)
|
|
85
|
+
return condition if condition == "rest" else f"label{condition}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def condition_pair_label(condition_a, condition_b):
|
|
89
|
+
return f"{condition_label(condition_a)}_vs_{condition_label(condition_b)}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def condition_sort_key(value):
|
|
93
|
+
value = str(value)
|
|
94
|
+
try:
|
|
95
|
+
return (0, int(value), value)
|
|
96
|
+
except ValueError:
|
|
97
|
+
return (1, value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def available_conditions(values):
|
|
101
|
+
present = pd.Series(values).dropna().astype(str).str.strip()
|
|
102
|
+
present = present[present.ne("")].unique().tolist()
|
|
103
|
+
return sorted(present, key=condition_sort_key)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def condition_pairs(values):
|
|
107
|
+
return list(itertools.combinations(available_conditions(values), 2))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def stable_seed(base_seed, *parts):
|
|
111
|
+
return int(base_seed) + sum(ord(character) for character in "_".join(map(str, parts)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def safe_spearman(x, y, min_samples):
|
|
115
|
+
x = np.asarray(x, dtype=float)
|
|
116
|
+
y = np.asarray(y, dtype=float)
|
|
117
|
+
valid = (~np.isnan(x)) & (~np.isnan(y))
|
|
118
|
+
x = x[valid]
|
|
119
|
+
y = y[valid]
|
|
120
|
+
if len(x) < min_samples or len(np.unique(x)) < 2 or len(np.unique(y)) < 2:
|
|
121
|
+
return np.nan, np.nan
|
|
122
|
+
rho, p_value = spearmanr(x, y)
|
|
123
|
+
if np.isnan(rho):
|
|
124
|
+
return np.nan, np.nan
|
|
125
|
+
return float(rho), float(p_value) if np.isfinite(p_value) else np.nan
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = [
|
|
129
|
+
"available_conditions",
|
|
130
|
+
"build_score_tables",
|
|
131
|
+
"condition_label",
|
|
132
|
+
"condition_pair_label",
|
|
133
|
+
"condition_pairs",
|
|
134
|
+
"condition_sort_key",
|
|
135
|
+
"normalize_scores_long",
|
|
136
|
+
"prune_csv_outputs",
|
|
137
|
+
"read_scores_long",
|
|
138
|
+
"safe_spearman",
|
|
139
|
+
"stable_seed",
|
|
140
|
+
]
|
{cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/within.py
RENAMED
|
@@ -5,24 +5,31 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
import pandas as pd
|
|
8
|
-
from scipy.stats import spearmanr
|
|
9
8
|
|
|
10
9
|
from ...config import CytoCommunityConfig
|
|
11
10
|
from ...paths import build_paths
|
|
12
11
|
from .utils import (
|
|
13
12
|
available_conditions,
|
|
13
|
+
build_score_tables,
|
|
14
14
|
condition_pair_label,
|
|
15
|
+
prune_csv_outputs,
|
|
16
|
+
read_scores_long,
|
|
15
17
|
safe_spearman,
|
|
16
18
|
stable_seed,
|
|
17
19
|
)
|
|
18
20
|
|
|
19
21
|
|
|
22
|
+
_POOLED_COLUMNS = ["CN", "CellType1", "CellType2", "rho", "p", "n"]
|
|
23
|
+
_COMPARISON_COLUMNS = [
|
|
24
|
+
"CN", "CellType1", "CellType2", "rho_A", "p_A", "rho_B", "p_B", "perm_p"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
20
28
|
def compute_pooled_within_cn(scores_long, num_cn, min_samples):
|
|
21
29
|
"""Compute pooled within-CN Spearman correlations."""
|
|
22
30
|
rows = []
|
|
23
|
-
for cn in range(1, num_cn + 1)
|
|
24
|
-
|
|
25
|
-
matrix = subset.pivot(index="Sample", columns="CellType", values="Score")
|
|
31
|
+
cn_order = [str(cn) for cn in range(1, num_cn + 1)]
|
|
32
|
+
for cn, matrix in build_score_tables(scores_long, cn_order).items():
|
|
26
33
|
valid_columns = [
|
|
27
34
|
cell_type
|
|
28
35
|
for cell_type in matrix.columns
|
|
@@ -32,41 +39,37 @@ def compute_pooled_within_cn(scores_long, num_cn, min_samples):
|
|
|
32
39
|
matrix = matrix[valid_columns]
|
|
33
40
|
if matrix.shape[1] < 2:
|
|
34
41
|
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
42
|
for left, right in itertools.combinations(range(matrix.shape[1]), 2):
|
|
40
43
|
cell_type_a = matrix.columns[left]
|
|
41
44
|
cell_type_b = matrix.columns[right]
|
|
45
|
+
paired = matrix[[cell_type_a, cell_type_b]].dropna()
|
|
46
|
+
rho, p_value = safe_spearman(
|
|
47
|
+
paired[cell_type_a], paired[cell_type_b], min_samples
|
|
48
|
+
)
|
|
42
49
|
rows.append(
|
|
43
50
|
{
|
|
44
51
|
"CN": str(cn),
|
|
45
52
|
"CellType1": cell_type_a,
|
|
46
53
|
"CellType2": cell_type_b,
|
|
47
|
-
"rho":
|
|
48
|
-
"p": p_value
|
|
49
|
-
"n": len(
|
|
54
|
+
"rho": rho,
|
|
55
|
+
"p": p_value,
|
|
56
|
+
"n": len(paired),
|
|
50
57
|
}
|
|
51
58
|
)
|
|
52
|
-
return pd.DataFrame(rows)
|
|
59
|
+
return pd.DataFrame(rows, columns=_POOLED_COLUMNS)
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
def prepare_wide_tables(scores_long, cn_order):
|
|
56
|
-
tables
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
aggfunc="mean",
|
|
67
|
-
).reset_index()
|
|
68
|
-
)
|
|
69
|
-
return tables
|
|
63
|
+
"""Build condition tables, retaining the established duplicate-mean policy."""
|
|
64
|
+
return {
|
|
65
|
+
cn: matrix.reset_index()
|
|
66
|
+
for cn, matrix in build_score_tables(
|
|
67
|
+
scores_long,
|
|
68
|
+
cn_order,
|
|
69
|
+
index_columns=("Sample", "Condition"),
|
|
70
|
+
duplicate_policy="mean",
|
|
71
|
+
).items()
|
|
72
|
+
}
|
|
70
73
|
|
|
71
74
|
|
|
72
75
|
def permutation_pvalue(
|
|
@@ -208,9 +211,7 @@ def compute_condition_comparison_stats(
|
|
|
208
211
|
"perm_p": permutation_p,
|
|
209
212
|
}
|
|
210
213
|
)
|
|
211
|
-
|
|
212
|
-
return pd.DataFrame()
|
|
213
|
-
return pd.DataFrame(rows).sort_values(
|
|
214
|
+
return pd.DataFrame(rows, columns=_COMPARISON_COLUMNS).sort_values(
|
|
214
215
|
["CN", "CellType1", "CellType2"]
|
|
215
216
|
).reset_index(drop=True)
|
|
216
217
|
|
|
@@ -224,7 +225,7 @@ def run_within_cn_analysis(
|
|
|
224
225
|
output_dir = Path(output_dir or paths.communication_config)
|
|
225
226
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
226
227
|
input_file = Path(input_file or output_dir / "EnrichScoreMatrix_long.csv")
|
|
227
|
-
scores =
|
|
228
|
+
scores = read_scores_long(input_file)
|
|
228
229
|
celltype_order = scores["CellType"].astype(str).drop_duplicates().tolist()
|
|
229
230
|
num_cn = int(paths.selected_cn_num.read_text(encoding="utf-8"))
|
|
230
231
|
cn_order = [str(index) for index in range(1, num_cn + 1)]
|
|
@@ -232,7 +233,6 @@ def run_within_cn_analysis(
|
|
|
232
233
|
outputs = {
|
|
233
234
|
"pooled": compute_pooled_within_cn(scores, num_cn, params["minimum_samples"])
|
|
234
235
|
}
|
|
235
|
-
outputs["pooled"].to_csv(output_dir / "SpearmanWithinCNs.csv", index=False)
|
|
236
236
|
for comparison in build_condition_comparisons(
|
|
237
237
|
scores["Condition"], config.downstream["comparison_mode"]
|
|
238
238
|
):
|
|
@@ -249,11 +249,19 @@ def run_within_cn_analysis(
|
|
|
249
249
|
max_attempt_multiplier=params["permutation_max_attempt_multiplier"],
|
|
250
250
|
base_seed=config.random_seed,
|
|
251
251
|
)
|
|
252
|
-
if result.empty:
|
|
253
|
-
continue
|
|
254
252
|
label = condition_pair_label(comparison["cond_a"], comparison["cond_b"])
|
|
255
|
-
result.to_csv(output_dir / f"CommunicationPair_{label}.csv", index=False)
|
|
256
253
|
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
|
+
outputs["pooled"].to_csv(output_dir / "SpearmanWithinCNs.csv", index=False)
|
|
257
|
+
current_names = []
|
|
258
|
+
for label, result in outputs.items():
|
|
259
|
+
if label == "pooled":
|
|
260
|
+
continue
|
|
261
|
+
name = f"CommunicationPair_{label}.csv"
|
|
262
|
+
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)
|
|
257
265
|
return outputs
|
|
258
266
|
|
|
259
267
|
|