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.
Files changed (51) hide show
  1. {cytocommunity2-1.0.4/src/cytocommunity2.egg-info → cytocommunity2-1.0.6}/PKG-INFO +1 -1
  2. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/pyproject.toml +1 -1
  3. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/__init__.py +1 -1
  4. cytocommunity2-1.0.6/src/cytocommunity2/_io.py +94 -0
  5. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/between.py +34 -22
  6. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/cca.py +53 -39
  7. cytocommunity2-1.0.6/src/cytocommunity2/downstream/communication/utils.py +140 -0
  8. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/within.py +42 -34
  9. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/differential_composition.py +3 -4
  10. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/enrichment.py +7 -19
  11. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/moran.py +10 -11
  12. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/cn_selection.py +2 -1
  13. cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/coherence.py +91 -0
  14. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/common.py +44 -7
  15. cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/communication.py +121 -0
  16. cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/communication_cca.py +148 -0
  17. cytocommunity2-1.0.6/src/cytocommunity2/downstream/plotting/communication_stack.py +217 -0
  18. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/dominant.py +5 -3
  19. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/dotplots.py +142 -40
  20. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/recurrence.py +31 -32
  21. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/recurrence.py +11 -3
  22. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/ensemble.py +10 -11
  23. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/api.py +41 -51
  24. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/dataset.py +11 -21
  25. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/paths.py +16 -0
  26. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/visualization/spatial.py +49 -36
  27. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6/src/cytocommunity2.egg-info}/PKG-INFO +1 -1
  28. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/SOURCES.txt +2 -0
  29. cytocommunity2-1.0.4/src/cytocommunity2/_io.py +0 -11
  30. cytocommunity2-1.0.4/src/cytocommunity2/downstream/communication/utils.py +0 -63
  31. cytocommunity2-1.0.4/src/cytocommunity2/downstream/plotting/coherence.py +0 -50
  32. cytocommunity2-1.0.4/src/cytocommunity2/downstream/plotting/communication.py +0 -350
  33. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/LICENSE +0 -0
  34. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/README.md +0 -0
  35. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/setup.cfg +0 -0
  36. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/colors.py +0 -0
  37. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/config.py +0 -0
  38. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/__init__.py +0 -0
  39. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/coherence.py +0 -0
  40. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  41. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/dominant_celltype.py +0 -0
  42. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
  43. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/downstream/wilcoxon.py +0 -0
  44. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/__init__.py +0 -0
  45. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/model.py +0 -0
  46. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/selection.py +0 -0
  47. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/learning/training.py +0 -0
  48. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2/visualization/__init__.py +0 -0
  49. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  50. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/src/cytocommunity2.egg-info/requires.txt +0 -0
  51. {cytocommunity2-1.0.4 → cytocommunity2-1.0.6}/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.4
3
+ Version: 1.0.6
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.4"
7
+ version = "1.0.6"
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.4"
13
+ __version__ = "1.0.6"
14
14
 
15
15
  __all__ = [
16
16
  "CytoCommunityConfig",
@@ -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
@@ -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
- def normalize_scores_long(scores_long):
13
- frame = scores_long.copy()
14
- for column in ["Sample", "CN", "CellType"]:
15
- frame[column] = frame[column].astype(str).str.strip()
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
- scores_a = scores.loc[
46
- (scores["CN"] == cn_a) & (scores["CellType"] == top_a),
47
- ["Sample", "Score"],
48
- ].rename(columns={"Score": "Score_A"})
49
- scores_b = scores.loc[
50
- (scores["CN"] == cn_b) & (scores["CellType"] == top_b),
51
- ["Sample", "Score"],
52
- ].rename(columns={"Score": "Score_B"})
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 = spearmanr(merged["Score_A"], merged["Score_B"], nan_policy="omit")
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
- ).drop(columns="rho1_CCA")
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 = pd.read_csv(config_dir / "EnrichScoreMatrix_long.csv")
87
- pairs = pd.read_csv(config_dir / "CCA_results.csv")
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
  }
@@ -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 condition_sort_key
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 one sample-by-cell-type score matrix per CN."""
50
- matrices = {}
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(result_rows)
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(maximum):
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 = frame.copy()
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 = _normalize_scores(
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
+ ]
@@ -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
- subset = scores_long[scores_long["CN"].astype(str) == str(cn)]
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": correlation[left, right],
48
- "p": p_value[left, right],
49
- "n": len(matrix[[cell_type_a, cell_type_b]].dropna()),
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
- 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
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
- if not rows:
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 = pd.read_csv(input_file, dtype={"Condition": str, "CN": str})
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