cytocommunity2 1.0.1__tar.gz → 1.0.3__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 (48) hide show
  1. {cytocommunity2-1.0.1/src/cytocommunity2.egg-info → cytocommunity2-1.0.3}/PKG-INFO +1 -1
  2. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/pyproject.toml +1 -1
  3. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/__init__.py +1 -1
  4. cytocommunity2-1.0.3/src/cytocommunity2/_io.py +11 -0
  5. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/colors.py +1 -6
  6. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/config.py +9 -17
  7. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/__init__.py +1 -5
  8. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/coherence.py +5 -9
  9. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/between.py +14 -24
  10. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/cca.py +27 -36
  11. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/within.py +1 -1
  12. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/differential_composition.py +37 -110
  13. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/dominant_celltype.py +22 -3
  14. cytocommunity2-1.0.3/src/cytocommunity2/downstream/enrichment.py +166 -0
  15. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/moran.py +9 -16
  16. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/cn_selection.py +1 -6
  17. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/coherence.py +2 -2
  18. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/communication.py +22 -22
  19. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/dominant.py +31 -5
  20. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/dotplots.py +94 -26
  21. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/recurrence.py +2 -2
  22. cytocommunity2-1.0.3/src/cytocommunity2/downstream/recurrence.py +112 -0
  23. cytocommunity2-1.0.3/src/cytocommunity2/downstream/wilcoxon.py +51 -0
  24. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/ensemble.py +9 -25
  25. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/api.py +2 -3
  26. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/dataset.py +3 -14
  27. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/model.py +2 -2
  28. cytocommunity2-1.0.3/src/cytocommunity2/learning/selection.py +128 -0
  29. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/training.py +2 -2
  30. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/paths.py +2 -2
  31. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/visualization/spatial.py +1 -16
  32. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3/src/cytocommunity2.egg-info}/PKG-INFO +1 -1
  33. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2.egg-info/SOURCES.txt +4 -2
  34. cytocommunity2-1.0.1/src/cytocommunity2/downstream/plotting/composition.py +0 -80
  35. cytocommunity2-1.0.1/src/cytocommunity2/downstream/runtime.py +0 -13
  36. cytocommunity2-1.0.1/src/cytocommunity2/learning/selection.py +0 -348
  37. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/LICENSE +0 -0
  38. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/README.md +0 -0
  39. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/setup.cfg +0 -0
  40. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  41. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/utils.py +0 -0
  42. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
  43. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/common.py +0 -0
  44. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/__init__.py +0 -0
  45. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2/visualization/__init__.py +0 -0
  46. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  47. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/src/cytocommunity2.egg-info/requires.txt +0 -0
  48. {cytocommunity2-1.0.1 → cytocommunity2-1.0.3}/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.1
3
+ Version: 1.0.3
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.1"
7
+ version = "1.0.3"
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.1"
13
+ __version__ = "1.0.3"
14
14
 
15
15
  __all__ = [
16
16
  "CytoCommunityConfig",
@@ -0,0 +1,11 @@
1
+ """Shared reader for the workflow's one-value-per-line text files."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def read_nonempty_lines(path):
7
+ return [
8
+ line.strip()
9
+ for line in Path(path).read_text(encoding="utf-8-sig").splitlines()
10
+ if line.strip()
11
+ ]
@@ -7,12 +7,7 @@ def celltype_color_map(celltypes):
7
7
 
8
8
  palette = []
9
9
  for name in ("tab20", "tab20b", "tab20c", "Set1", "Dark2", "Accent", "Paired"):
10
- cmap = plt.get_cmap(name)
11
- palette.extend(
12
- list(cmap.colors)
13
- if hasattr(cmap, "colors")
14
- else [cmap(index / 255) for index in range(256)]
15
- )
10
+ palette.extend(plt.get_cmap(name).colors)
16
11
  return {
17
12
  celltype: palette[index]
18
13
  for index, celltype in enumerate(celltypes)
@@ -9,11 +9,10 @@ from typing import Any, Mapping
9
9
 
10
10
  DEFAULT_CONFIG_FILE = "hyperparameters.json"
11
11
 
12
- # Established analysis rules stay stable across runs; only experimental choices
13
- # belong in hyperparameters.json.
14
- FIXED_DOWNSTREAM = {
12
+ # Statistical thresholds and numerical settings for downstream analyses.
13
+ DOWNSTREAM_PARAMETERS = {
15
14
  "dominant_cell_type": {"frequency_threshold": 0.5},
16
- "differential_composed": {"p_value_threshold": 0.05},
15
+ "differential_composition": {"p_value_threshold": 0.05, "minimum_samples": 3},
17
16
  "moran": {
18
17
  "minimum_positive_count": 2,
19
18
  "minimum_cn_cells": 10,
@@ -27,9 +26,9 @@ FIXED_DOWNSTREAM = {
27
26
  "cca_p_threshold": 0.05,
28
27
  "cca_minimum_common_samples": 3,
29
28
  "cca_minimum_abs_rho": 0.0,
30
- "cca_sd_epsilon": 1e-8,
29
+ "cca_min_std": 1e-8,
31
30
  "cca_max_iterations": 1000,
32
- "cca_canonical_components": 2,
31
+ "cca_n_components": 2,
33
32
  },
34
33
  }
35
34
 
@@ -40,7 +39,7 @@ class MainConfig:
40
39
  cn_num_min: int
41
40
  cn_num_max: int
42
41
  final_num_runs: int
43
- num_epoch: int
42
+ num_epochs: int
44
43
  embedding_dimension: int
45
44
  learning_rate: float
46
45
  mini_batch_size: int
@@ -82,17 +81,10 @@ class CytoCommunityConfig:
82
81
  return _load_config(config_file, config_class=cls)
83
82
 
84
83
 
85
- def _require_keys(values, required, section):
86
- missing = [key for key in required if key not in values]
87
- if missing:
88
- raise ValueError(f"Missing configuration keys in {section}: {missing}")
89
-
90
-
91
84
  def _load_config(config_file=None, config_class=CytoCommunityConfig):
92
85
  """Load one configuration file and resolve its configured input folder."""
93
86
  config_file = Path(config_file or DEFAULT_CONFIG_FILE).expanduser().resolve()
94
87
  raw = json.loads(config_file.read_text(encoding="utf-8"))
95
- _require_keys(raw, ["input_dir", "random_seed", "main", "downstream"], "root")
96
88
 
97
89
  project_root = config_file.parent
98
90
  input_dir = Path(raw["input_dir"]).expanduser()
@@ -104,14 +96,14 @@ def _load_config(config_file=None, config_class=CytoCommunityConfig):
104
96
  recurrence = RecurrenceConfig()
105
97
  visualization = VisualizationConfig(**raw.get("visualization", {}))
106
98
  downstream = dict(raw["downstream"])
107
- for section, fixed_values in FIXED_DOWNSTREAM.items():
108
- downstream[section] = {**downstream.get(section, {}), **fixed_values}
99
+ for section, parameters in DOWNSTREAM_PARAMETERS.items():
100
+ downstream[section] = {**downstream.get(section, {}), **parameters}
109
101
 
110
102
  if main.knn_k < 1:
111
103
  raise ValueError("main.knn_k must be positive")
112
104
  if main.cn_num_min < 1 or main.cn_num_min > main.cn_num_max:
113
105
  raise ValueError("Require 1 <= main.cn_num_min <= main.cn_num_max")
114
- if main.final_num_runs < 1 or main.num_epoch < 1:
106
+ if main.final_num_runs < 1 or main.num_epochs < 1:
115
107
  raise ValueError("Run and epoch counts must be positive")
116
108
  if main.embedding_dimension < 1 or main.mini_batch_size < 1:
117
109
  raise ValueError("Embedding dimension and mini-batch size must be positive")
@@ -1,8 +1,4 @@
1
- """Reusable downstream analyses for CytoCommunity2 results.
2
-
3
- The top-level :mod:`cytocommunity2` API intentionally remains small. These
4
- functions live in a dedicated namespace because downstream analyses are
5
- optional and evolve independently from model training.
1
+ """Downstream analyses of learned cellular neighborhoods.
6
2
  """
7
3
 
8
4
  from .coherence import run_coherence_analysis
@@ -20,15 +20,10 @@ class CoherenceMetrics:
20
20
  pas: float
21
21
 
22
22
 
23
- def _coherence_inputs(cluster_labels, coordinates):
24
- labels = np.asarray(cluster_labels)
25
- locations = np.asarray(coordinates, dtype=float)
26
- return labels, locations
27
-
28
-
29
23
  def compute_chaos(cluster_labels, coordinates):
30
24
  """Compute the normalized sum of within-CN nearest-neighbor distances."""
31
- labels, locations = _coherence_inputs(cluster_labels, coordinates)
25
+ labels = np.asarray(cluster_labels)
26
+ locations = np.asarray(coordinates, dtype=float)
32
27
  locations = StandardScaler().fit_transform(locations)
33
28
  distances = []
34
29
  for label in np.unique(labels):
@@ -43,7 +38,8 @@ def compute_chaos(cluster_labels, coordinates):
43
38
 
44
39
  def compute_pas(cluster_labels, coordinates, *, k=10):
45
40
  """Compute the percentage of abnormal spots (PAS) metric."""
46
- labels, locations = _coherence_inputs(cluster_labels, coordinates)
41
+ labels = np.asarray(cluster_labels)
42
+ locations = np.asarray(coordinates, dtype=float)
47
43
  if not 1 <= k < len(labels):
48
44
  raise ValueError("k must satisfy 1 <= k < number of cells")
49
45
  neighbors = NearestNeighbors(n_neighbors=k + 1, algorithm="kd_tree").fit(
@@ -93,5 +89,5 @@ def run_coherence_analysis(config: CytoCommunityConfig, *, output_dir=None, pas_
93
89
  output_dir = Path(output_dir or paths.downstream_data / "Coherence")
94
90
  output_dir.mkdir(parents=True, exist_ok=True)
95
91
  result = analyze_result_tables(paths.result_tables, pas_k=pas_k)
96
- result.to_csv(output_dir / "config.csv", index=False)
92
+ result.to_csv(output_dir / "CoherenceMetrics.csv", index=False)
97
93
  return result
@@ -2,7 +2,6 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
- import numpy as np
6
5
  import pandas as pd
7
6
  from scipy.stats import spearmanr
8
7
 
@@ -27,26 +26,22 @@ def select_cca_pairs(cca_pairs, top_n):
27
26
 
28
27
 
29
28
  def analyze_between_cn(
30
- scores_long, cca_pairs, coordinate_tables, *, minimum_samples, top_n_cca_pairs
29
+ scores_long, cca_pairs, loading_tables, *, minimum_samples, top_n_cca_pairs
31
30
  ):
32
- """Calculate between-CN correlations without filesystem access.
33
-
34
- ``coordinate_tables`` maps ``(CN_A, CN_B)`` string tuples to coordinate
35
- DataFrames returned by :func:`cca_coordinate_tables`.
36
- """
31
+ """Calculate between-CN correlations without filesystem access."""
37
32
  scores = normalize_scores_long(scores_long)
38
33
  pairs = select_cca_pairs(cca_pairs, top_n_cca_pairs)
39
34
  rows = []
40
35
  for _, pair in pairs.iterrows():
41
36
  cn_a, cn_b = str(pair["CN_A"]), str(pair["CN_B"])
42
- coordinates = coordinate_tables[(cn_a, cn_b)].copy()
43
- coordinates["CellType"] = coordinates["CellType"].astype(str).str.strip()
44
- coordinates_a = coordinates.dropna(subset=["CN_A_coord_can1"])
45
- coordinates_b = coordinates.dropna(subset=["CN_B_coord_can1"])
46
- top_a_index = coordinates_a["CN_A_coord_can1"].abs().idxmax()
47
- top_b_index = coordinates_b["CN_B_coord_can1"].abs().idxmax()
48
- top_a = coordinates_a.loc[top_a_index, "CellType"]
49
- top_b = coordinates_b.loc[top_b_index, "CellType"]
37
+ loadings = loading_tables[(cn_a, cn_b)].copy()
38
+ loadings["CellType"] = loadings["CellType"].astype(str).str.strip()
39
+ loadings_a = loadings.dropna(subset=["CN_A_loading1"])
40
+ loadings_b = loadings.dropna(subset=["CN_B_loading1"])
41
+ top_a_index = loadings_a["CN_A_loading1"].abs().idxmax()
42
+ top_b_index = loadings_b["CN_B_loading1"].abs().idxmax()
43
+ top_a = loadings_a.loc[top_a_index, "CellType"]
44
+ top_b = loadings_b.loc[top_b_index, "CellType"]
50
45
  scores_a = scores.loc[
51
46
  (scores["CN"] == cn_a) & (scores["CellType"] == top_a),
52
47
  ["Sample", "Score"],
@@ -70,13 +65,8 @@ def analyze_between_cn(
70
65
  "CN_A": cn_a,
71
66
  "CN_B": cn_b,
72
67
  "rho1_CCA": pair["rho1"],
73
- "pval1_CCA": pair["pval1"],
74
- "rho2_CCA": pair["rho2"] if "rho2" in pairs else np.nan,
75
- "pval2_CCA": pair["pval2"] if "pval2" in pairs else np.nan,
76
68
  "Top_CellType_A": top_a,
77
69
  "Top_CellType_B": top_b,
78
- "TopA_coord_can1": coordinates_a.loc[top_a_index, "CN_A_coord_can1"],
79
- "TopB_coord_can1": coordinates_b.loc[top_b_index, "CN_B_coord_can1"],
80
70
  "Spearman_rho": rho,
81
71
  "Spearman_p": p_value,
82
72
  "N_overlap": len(merged),
@@ -86,19 +76,19 @@ def analyze_between_cn(
86
76
  if not result.empty:
87
77
  result = result.sort_values(
88
78
  "rho1_CCA", key=lambda values: values.abs(), ascending=False
89
- )
79
+ ).drop(columns="rho1_CCA")
90
80
  return result
91
81
 
92
82
 
93
83
  def run_between_cn_statistics(config_dir, minimum_samples, top_n_cca_pairs):
94
- """Compatibility workflow operating on the established communication folder."""
84
+ """Calculate and save Spearman statistics for top CCA pairs."""
95
85
  config_dir = Path(config_dir)
96
86
  scores = pd.read_csv(config_dir / "EnrichScoreMatrix_long.csv")
97
- pairs = pd.read_csv(config_dir / "CCA_config.csv")
87
+ pairs = pd.read_csv(config_dir / "CCA_results.csv")
98
88
  selected = select_cca_pairs(pairs, top_n_cca_pairs)
99
89
  tables = {
100
90
  (str(row.CN_A), str(row.CN_B)): pd.read_csv(
101
- config_dir / f"CCA_coordinates_CN{row.CN_A}_vs_CN{row.CN_B}.csv"
91
+ config_dir / f"CCA_loadings_CN{row.CN_A}_vs_CN{row.CN_B}.csv"
102
92
  )
103
93
  for row in selected.itertuples(index=False)
104
94
  }
@@ -20,7 +20,7 @@ def pair_rng(base_seed, condition, cn_a, cn_b):
20
20
  return np.random.default_rng(seed)
21
21
 
22
22
 
23
- def clean_cca_data(x, y, minimum_common_samples, sd_epsilon):
23
+ def clean_cca_data(x, y, minimum_common_samples, min_std):
24
24
  """Align two matrices and remove incomplete or near-constant variables."""
25
25
  if x is None or y is None or x.shape[1] == 0 or y.shape[1] == 0:
26
26
  return None
@@ -29,15 +29,13 @@ def clean_cca_data(x, y, minimum_common_samples, sd_epsilon):
29
29
  return None
30
30
  x = x.loc[common]
31
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]
32
+ x = x.loc[:, x.std(axis=0) > min_std]
33
+ y = y.loc[:, y.std(axis=0) > min_std]
36
34
  if x.shape[1] == 0 or y.shape[1] == 0:
37
35
  return None
38
36
  complete = x.notna().all(axis=1) & y.notna().all(axis=1)
39
37
  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:
38
+ if x.shape[0] < minimum_common_samples:
41
39
  return None
42
40
  return {
43
41
  "X": x.values,
@@ -75,7 +73,7 @@ def run_cca_for_scores(
75
73
  score_list[cn_a],
76
74
  score_list[cn_b],
77
75
  communication_params["cca_minimum_common_samples"],
78
- communication_params["cca_sd_epsilon"],
76
+ communication_params["cca_min_std"],
79
77
  )
80
78
  if cleaned is None:
81
79
  continue
@@ -93,7 +91,7 @@ def run_cca_for_scores(
93
91
  model.fit(x_matrix, y_matrix)
94
92
  x_scores, y_scores = model.transform(x_matrix, y_matrix)
95
93
  actual_components = min(
96
- communication_params["cca_canonical_components"], x_scores.shape[1]
94
+ communication_params["cca_n_components"], x_scores.shape[1]
97
95
  )
98
96
  correlations = [
99
97
  np.corrcoef(x_scores[:, index], y_scores[:, index])[0, 1]
@@ -111,7 +109,7 @@ def run_cca_for_scores(
111
109
  shuffled_y = rng.permutation(y_matrix)
112
110
  permutation_model = CCA(
113
111
  n_components=min(
114
- communication_params["cca_canonical_components"],
112
+ communication_params["cca_n_components"],
115
113
  max_components,
116
114
  ),
117
115
  scale=True,
@@ -143,7 +141,7 @@ def run_cca_for_scores(
143
141
  row = {"CN_A": cn_a, "CN_B": cn_b, "n_samples": n_samples}
144
142
  if condition is not None:
145
143
  row["Condition"] = str(condition)
146
- for component in range(communication_params["cca_canonical_components"]):
144
+ for component in range(communication_params["cca_n_components"]):
147
145
  row[f"rho{component + 1}"] = (
148
146
  correlations[component] if component < len(correlations) else None
149
147
  )
@@ -177,8 +175,8 @@ def run_cca_for_scores(
177
175
  return {"results": results, "cache": cache}
178
176
 
179
177
 
180
- def cca_coordinate_tables(
181
- results, cache, celltype_order, canonical_components, condition=None
178
+ def cca_loading_tables(
179
+ results, cache, celltype_order, n_components, condition=None
182
180
  ):
183
181
  """Convert fitted CCA scores into per-pair cell-type loading tables."""
184
182
  tables = {}
@@ -198,47 +196,41 @@ def cca_coordinate_tables(
198
196
  if cell_type in set(entry["X_ct"]) | set(entry["Y_ct"])
199
197
  ]
200
198
  data = {"CellType": all_celltypes}
201
- maximum = min(canonical_components, entry["X_scores"].shape[1])
199
+ maximum = min(n_components, entry["X_scores"].shape[1])
202
200
  for component in range(maximum):
203
201
  x_score = entry["X_scores"][:, component]
204
202
  y_score = entry["Y_scores"][:, component]
205
203
  x_loadings = {
206
204
  cell_type: np.corrcoef(entry["X_mat"][:, index], x_score)[0, 1]
207
- if len(x_score) > 1
208
- else np.nan
209
205
  for index, cell_type in enumerate(entry["X_ct"])
210
206
  }
211
207
  y_loadings = {
212
208
  cell_type: np.corrcoef(entry["Y_mat"][:, index], y_score)[0, 1]
213
- if len(y_score) > 1
214
- else np.nan
215
209
  for index, cell_type in enumerate(entry["Y_ct"])
216
210
  }
217
- data[f"CN_A_coord_can{component + 1}"] = [
211
+ data[f"CN_A_loading{component + 1}"] = [
218
212
  x_loadings.get(cell_type, np.nan) for cell_type in all_celltypes
219
213
  ]
220
- data[f"CN_B_coord_can{component + 1}"] = [
214
+ data[f"CN_B_loading{component + 1}"] = [
221
215
  y_loadings.get(cell_type, np.nan) for cell_type in all_celltypes
222
216
  ]
223
- data[f"rho{component + 1}"] = [row.get(f"rho{component + 1}", np.nan)] * len(all_celltypes)
224
- data[f"pval{component + 1}"] = [row.get(f"pval{component + 1}", np.nan)] * len(all_celltypes)
225
217
  tables[(cn_a, cn_b)] = pd.DataFrame(data)
226
218
  return tables
227
219
 
228
220
 
229
- def write_cca_coordinates(
230
- results, cache, output_dir, celltype_order, canonical_components, condition=None
221
+ def write_cca_loadings(
222
+ results, cache, output_dir, celltype_order, n_components, condition=None
231
223
  ):
232
- """Write coordinate tables using the original filenames."""
224
+ """Write per-pair cell-type CCA loading tables."""
233
225
  output_dir = Path(output_dir)
234
226
  output_dir.mkdir(parents=True, exist_ok=True)
235
- tables = cca_coordinate_tables(
236
- results, cache, celltype_order, canonical_components, condition
227
+ tables = cca_loading_tables(
228
+ results, cache, celltype_order, n_components, condition
237
229
  )
238
230
  prefix = f"{condition}_" if condition is not None else ""
239
231
  for (cn_a, cn_b), table in tables.items():
240
232
  table.to_csv(
241
- output_dir / f"CCA_coordinates_{prefix}CN{cn_a}_vs_CN{cn_b}.csv",
233
+ output_dir / f"CCA_loadings_{prefix}CN{cn_a}_vs_CN{cn_b}.csv",
242
234
  index=False,
243
235
  )
244
236
  return tables
@@ -268,13 +260,13 @@ def run_cca_analysis(
268
260
  pooled = run_cca_for_scores(
269
261
  build_score_list(frame, cn_list), cn_list, params, config.random_seed
270
262
  )
271
- pooled["results"].to_csv(output_dir / "CCA_config.csv", index=False)
272
- write_cca_coordinates(
263
+ pooled["results"].to_csv(output_dir / "CCA_results.csv", index=False)
264
+ write_cca_loadings(
273
265
  pooled["results"],
274
266
  pooled["cache"],
275
267
  output_dir,
276
268
  celltype_order,
277
- params["cca_canonical_components"],
269
+ params["cca_n_components"],
278
270
  )
279
271
  condition_results = []
280
272
  for condition in conditions:
@@ -286,13 +278,12 @@ def run_cca_analysis(
286
278
  config.random_seed,
287
279
  condition,
288
280
  )
289
- result["results"].to_csv(output_dir / f"CCA_config_{condition}.csv", index=False)
290
- write_cca_coordinates(
281
+ write_cca_loadings(
291
282
  result["results"],
292
283
  result["cache"],
293
284
  output_dir,
294
285
  celltype_order,
295
- params["cca_canonical_components"],
286
+ params["cca_n_components"],
296
287
  condition,
297
288
  )
298
289
  condition_results.append(result["results"])
@@ -310,16 +301,16 @@ def run_cca_analysis(
310
301
  key=lambda values: abs(values) if values.name == "rho1" else values,
311
302
  ascending=[True, False],
312
303
  )
313
- by_condition.to_csv(output_dir / "CCA_config_by_condition.csv", index=False)
304
+ by_condition.to_csv(output_dir / "CCA_results_by_condition.csv", index=False)
314
305
  return {"pooled": pooled["results"], "by_condition": by_condition}
315
306
 
316
307
 
317
308
  __all__ = [
318
309
  "build_score_list",
319
- "cca_coordinate_tables",
310
+ "cca_loading_tables",
320
311
  "clean_cca_data",
321
312
  "pair_rng",
322
313
  "run_cca_analysis",
323
314
  "run_cca_for_scores",
324
- "write_cca_coordinates",
315
+ "write_cca_loadings",
325
316
  ]
@@ -234,7 +234,7 @@ def run_within_cn_analysis(
234
234
  }
235
235
  outputs["pooled"].to_csv(output_dir / "SpearmanWithinCNs.csv", index=False)
236
236
  for comparison in build_condition_comparisons(
237
- scores["Condition"], config.downstream["ttest_mode"]
237
+ scores["Condition"], config.downstream["comparison_mode"]
238
238
  ):
239
239
  result = compute_condition_comparison_stats(
240
240
  wide_dict=wide,
@@ -4,11 +4,11 @@ from pathlib import Path
4
4
 
5
5
  import numpy as np
6
6
  import pandas as pd
7
- from scipy import stats
8
7
 
9
8
  from ..config import CytoCommunityConfig
10
9
  from ..paths import build_paths
11
10
  from .dominant_celltype import load_enrichment_matrices
11
+ from .wilcoxon import rank_sum_test
12
12
 
13
13
 
14
14
  def load_condition_enrichment(enrichment_dir, input_dir):
@@ -66,32 +66,26 @@ def build_label_comparisons(labels, mode):
66
66
  raise ValueError(f"Unknown comparison mode: {mode}")
67
67
 
68
68
 
69
- def composition_wilcoxon_tests(cell_type, target_values, reference_values):
70
- """Run one-sided asymptotic Wilcoxon rank-sum tests."""
69
+ def composition_wilcoxon_tests(
70
+ cell_type, target_values, reference_values, *, minimum_samples=3
71
+ ):
72
+ """Run one-sided Wilcoxon rank-sum tests, exact for combined N <= 30."""
71
73
  target = np.asarray(target_values, dtype=float)
72
74
  reference = np.asarray(reference_values, dtype=float)
73
- if len(target) < 2 or len(reference) < 2:
75
+ if len(target) < minimum_samples or len(reference) < minimum_samples:
74
76
  return None
75
77
  if not np.isfinite(target).all() or not np.isfinite(reference).all():
76
78
  return None
77
- pooled_ranks = stats.rankdata(np.concatenate([target, reference]), method="average")
78
- target_rank_sum = float(pooled_ranks[: len(target)].sum())
79
+ p_values, target_rank_sum, test_name = rank_sum_test(target, reference)
79
80
 
80
81
  rows = []
81
- for alternative in ("greater", "less"):
82
- test = stats.mannwhitneyu(
83
- target,
84
- reference,
85
- alternative=alternative,
86
- method="asymptotic",
87
- use_continuity=True,
88
- )
82
+ for alternative, p_value in p_values.items():
89
83
  rows.append(
90
84
  {
91
85
  "CellType": cell_type,
92
86
  "Alternative": alternative,
93
- "p_value": float(test.pvalue),
94
- "Test": "Wilcoxon rank-sum asymptotic",
87
+ "p_value": p_value,
88
+ "Test": test_name,
95
89
  "W_statistic": target_rank_sum,
96
90
  "N_target": len(target),
97
91
  "N_reference": len(reference),
@@ -100,10 +94,12 @@ def composition_wilcoxon_tests(cell_type, target_values, reference_values):
100
94
  return rows
101
95
 
102
96
 
103
- def analyze_differential_composition(matrix_records, celltypes, *, mode):
97
+ def analyze_differential_composition(matrix_records, celltypes, *, mode, minimum_samples=3):
104
98
  """Calculate all directional composition comparisons in memory.
105
99
 
106
100
  The result maps comparison names to maps of CN names and result tables.
101
+ Comparisons with fewer than ``minimum_samples`` samples on either side
102
+ have empty maps.
107
103
  """
108
104
  normalized_records = [
109
105
  {**record, "matrix": np.asarray(record["matrix"], dtype=float)}
@@ -114,8 +110,20 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode):
114
110
  labels = sorted({record["condition"] for record in normalized_records})
115
111
  comparisons = build_label_comparisons(labels, mode)
116
112
  outputs = {comparison["name"]: {} for comparison in comparisons}
113
+ eligible = [
114
+ comparison
115
+ for comparison in comparisons
116
+ if sum(
117
+ record["condition"] in comparison["target_labels"]
118
+ for record in normalized_records
119
+ ) >= minimum_samples
120
+ and sum(
121
+ record["condition"] in comparison["reference_labels"]
122
+ for record in normalized_records
123
+ ) >= minimum_samples
124
+ ]
117
125
  for cn_index in range(expected_shape[1]):
118
- for comparison in comparisons:
126
+ for comparison in eligible:
119
127
  rows = []
120
128
  for cell_index, cell_type in enumerate(celltypes):
121
129
  target = [
@@ -129,7 +137,7 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode):
129
137
  if record["condition"] in comparison["reference_labels"]
130
138
  ]
131
139
  result = composition_wilcoxon_tests(
132
- cell_type, target, reference
140
+ cell_type, target, reference, minimum_samples=minimum_samples
133
141
  )
134
142
  if result is not None:
135
143
  rows.extend(result)
@@ -141,104 +149,23 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode):
141
149
  def run_differential_composition_analysis(
142
150
  config: CytoCommunityConfig, *, output_dir=None
143
151
  ):
144
- """Run differential composition tests and preserve legacy filenames."""
152
+ """Run and save condition-specific differential composition tests."""
145
153
  paths = build_paths(config)
146
154
  celltypes, records = load_condition_enrichment(
147
155
  paths.enrichment, paths.input_dir
148
156
  )
149
157
  outputs = analyze_differential_composition(
150
- records, celltypes, mode=config.downstream["ttest_mode"]
158
+ records, celltypes, mode=config.downstream["comparison_mode"],
159
+ minimum_samples=config.downstream["differential_composition"]["minimum_samples"],
151
160
  )
152
161
  output_dir = Path(output_dir or paths.downstream_data / "DifferentialComposed")
153
162
  for comparison, cn_tables in outputs.items():
154
163
  comparison_dir = output_dir / comparison
155
- comparison_dir.mkdir(parents=True, exist_ok=True)
156
- for old_file in comparison_dir.glob("CN*.csv"):
157
- old_file.unlink()
158
- for cn, table in cn_tables.items():
159
- table.to_csv(comparison_dir / f"{cn}_{comparison}.csv", index=False)
164
+ if comparison_dir.exists():
165
+ for csv_file in comparison_dir.glob("CN*.csv"):
166
+ csv_file.unlink()
167
+ if cn_tables:
168
+ comparison_dir.mkdir(parents=True, exist_ok=True)
169
+ for cn, table in cn_tables.items():
170
+ table.to_csv(comparison_dir / f"{cn}_{comparison}.csv", index=False)
160
171
  return outputs
161
-
162
-
163
- def comparison_label(record, condition_names):
164
- target = condition_names.get(record["target"], record["target"])
165
- reference = record["reference"]
166
- if reference == "rest":
167
- return f"{target} vs. other cancers"
168
- return f"{target} vs. {condition_names.get(reference, reference)}"
169
-
170
-
171
- def calculate_cn_variation_specificity(
172
- records, celltypes, condition_names, *, p_value_threshold
173
- ):
174
- """Summarize significant differential-composition features (DCFs)."""
175
- summary_rows = []
176
- detail_rows = []
177
- for record in records:
178
- effect = np.asarray(record["effect"], dtype=float)
179
- pvalues = np.asarray(record["pvalues"], dtype=float)
180
- significant = np.isfinite(effect) & np.isfinite(pvalues) & (
181
- pvalues < p_value_threshold
182
- )
183
- dcf_rows = [
184
- {
185
- "CellType": celltypes[celltype_index],
186
- "Direction": "Up" if effect[celltype_index, cn_index] > 0 else "Down",
187
- "CN": f"CN{cn_index + 1}",
188
- "Significance": -np.log10(
189
- np.clip(pvalues[celltype_index, cn_index], 1e-300, 1.0)
190
- ),
191
- }
192
- for celltype_index, cn_index in np.argwhere(significant)
193
- ]
194
- dcf_table = pd.DataFrame(
195
- dcf_rows, columns=["CellType", "Direction", "CN", "Significance"]
196
- )
197
- number_dcf = len(dcf_table)
198
- number_patterns = (
199
- dcf_table[["CellType", "Direction"]].drop_duplicates().shape[0]
200
- if number_dcf
201
- else 0
202
- )
203
- label = comparison_label(record, condition_names)
204
- summary_rows.append(
205
- {
206
- "Comparison": record["name"],
207
- "CancerComparison": label,
208
- "NumberDCF": number_dcf,
209
- "NumberCellTypeDirectionPatterns": number_patterns,
210
- "SpecificityOfCNVariation": (
211
- number_patterns / number_dcf if number_dcf else np.nan
212
- ),
213
- "MeanDCFSignificance": (
214
- dcf_table["Significance"].mean() if number_dcf else np.nan
215
- ),
216
- }
217
- )
218
- if number_dcf:
219
- patterns = (
220
- dcf_table.groupby(["CellType", "Direction"], sort=False)
221
- .agg(
222
- NumberDCF=("CN", "size"),
223
- MeanDCFSignificance=("Significance", "mean"),
224
- )
225
- .reset_index()
226
- )
227
- patterns.insert(0, "CancerComparison", label)
228
- patterns.insert(0, "Comparison", record["name"])
229
- detail_rows.extend(patterns.to_dict("records"))
230
- return (
231
- pd.DataFrame(summary_rows),
232
- pd.DataFrame(
233
- detail_rows,
234
- columns=[
235
- "Comparison",
236
- "CancerComparison",
237
- "CellType",
238
- "Direction",
239
- "NumberDCF",
240
- "MeanDCFSignificance",
241
- ],
242
- ),
243
- )
244
-