cytocommunity2 1.0.2__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.2/src/cytocommunity2.egg-info → cytocommunity2-1.0.3}/PKG-INFO +1 -1
  2. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/pyproject.toml +1 -1
  3. {cytocommunity2-1.0.2 → 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.2 → cytocommunity2-1.0.3}/src/cytocommunity2/colors.py +1 -6
  6. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/config.py +9 -17
  7. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/__init__.py +1 -5
  8. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/coherence.py +5 -9
  9. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/between.py +14 -24
  10. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/cca.py +27 -36
  11. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/within.py +1 -1
  12. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/differential_composition.py +11 -103
  13. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/dominant_celltype.py +3 -1
  14. cytocommunity2-1.0.3/src/cytocommunity2/downstream/enrichment.py +166 -0
  15. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/moran.py +9 -16
  16. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/cn_selection.py +1 -6
  17. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/coherence.py +2 -2
  18. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/communication.py +22 -22
  19. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/dotplots.py +15 -15
  20. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/recurrence.py +2 -2
  21. cytocommunity2-1.0.3/src/cytocommunity2/downstream/recurrence.py +112 -0
  22. cytocommunity2-1.0.3/src/cytocommunity2/downstream/wilcoxon.py +51 -0
  23. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/ensemble.py +9 -25
  24. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/api.py +2 -3
  25. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/dataset.py +3 -14
  26. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/model.py +2 -2
  27. cytocommunity2-1.0.3/src/cytocommunity2/learning/selection.py +128 -0
  28. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/training.py +2 -2
  29. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/paths.py +2 -2
  30. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/visualization/spatial.py +1 -16
  31. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3/src/cytocommunity2.egg-info}/PKG-INFO +1 -1
  32. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2.egg-info/SOURCES.txt +4 -2
  33. cytocommunity2-1.0.2/src/cytocommunity2/downstream/plotting/composition.py +0 -80
  34. cytocommunity2-1.0.2/src/cytocommunity2/downstream/runtime.py +0 -13
  35. cytocommunity2-1.0.2/src/cytocommunity2/learning/selection.py +0 -375
  36. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/LICENSE +0 -0
  37. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/README.md +0 -0
  38. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/setup.cfg +0 -0
  39. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  40. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/communication/utils.py +0 -0
  41. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
  42. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/common.py +0 -0
  43. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/downstream/plotting/dominant.py +0 -0
  44. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/learning/__init__.py +0 -0
  45. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2/visualization/__init__.py +0 -0
  46. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  47. {cytocommunity2-1.0.2 → cytocommunity2-1.0.3}/src/cytocommunity2.egg-info/requires.txt +0 -0
  48. {cytocommunity2-1.0.2 → 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.2
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.2"
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.2"
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, "minimum_samples": 3},
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):
@@ -69,31 +69,23 @@ def build_label_comparisons(labels, mode):
69
69
  def composition_wilcoxon_tests(
70
70
  cell_type, target_values, reference_values, *, minimum_samples=3
71
71
  ):
72
- """Run one-sided asymptotic Wilcoxon rank-sum tests."""
72
+ """Run one-sided Wilcoxon rank-sum tests, exact for combined N <= 30."""
73
73
  target = np.asarray(target_values, dtype=float)
74
74
  reference = np.asarray(reference_values, dtype=float)
75
75
  if len(target) < minimum_samples or len(reference) < minimum_samples:
76
76
  return None
77
77
  if not np.isfinite(target).all() or not np.isfinite(reference).all():
78
78
  return None
79
- pooled_ranks = stats.rankdata(np.concatenate([target, reference]), method="average")
80
- target_rank_sum = float(pooled_ranks[: len(target)].sum())
79
+ p_values, target_rank_sum, test_name = rank_sum_test(target, reference)
81
80
 
82
81
  rows = []
83
- for alternative in ("greater", "less"):
84
- test = stats.mannwhitneyu(
85
- target,
86
- reference,
87
- alternative=alternative,
88
- method="asymptotic",
89
- use_continuity=True,
90
- )
82
+ for alternative, p_value in p_values.items():
91
83
  rows.append(
92
84
  {
93
85
  "CellType": cell_type,
94
86
  "Alternative": alternative,
95
- "p_value": float(test.pvalue),
96
- "Test": "Wilcoxon rank-sum asymptotic",
87
+ "p_value": p_value,
88
+ "Test": test_name,
97
89
  "W_statistic": target_rank_sum,
98
90
  "N_target": len(target),
99
91
  "N_reference": len(reference),
@@ -157,107 +149,23 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode, minimum
157
149
  def run_differential_composition_analysis(
158
150
  config: CytoCommunityConfig, *, output_dir=None
159
151
  ):
160
- """Run differential composition tests and preserve legacy filenames."""
152
+ """Run and save condition-specific differential composition tests."""
161
153
  paths = build_paths(config)
162
154
  celltypes, records = load_condition_enrichment(
163
155
  paths.enrichment, paths.input_dir
164
156
  )
165
157
  outputs = analyze_differential_composition(
166
- records, celltypes, mode=config.downstream["ttest_mode"],
167
- minimum_samples=config.downstream["differential_composed"]["minimum_samples"],
158
+ records, celltypes, mode=config.downstream["comparison_mode"],
159
+ minimum_samples=config.downstream["differential_composition"]["minimum_samples"],
168
160
  )
169
161
  output_dir = Path(output_dir or paths.downstream_data / "DifferentialComposed")
170
162
  for comparison, cn_tables in outputs.items():
171
163
  comparison_dir = output_dir / comparison
172
164
  if comparison_dir.exists():
173
- for old_file in comparison_dir.glob("CN*.csv"):
174
- old_file.unlink()
165
+ for csv_file in comparison_dir.glob("CN*.csv"):
166
+ csv_file.unlink()
175
167
  if cn_tables:
176
168
  comparison_dir.mkdir(parents=True, exist_ok=True)
177
169
  for cn, table in cn_tables.items():
178
170
  table.to_csv(comparison_dir / f"{cn}_{comparison}.csv", index=False)
179
171
  return outputs
180
-
181
-
182
- def comparison_label(record, condition_names):
183
- target = condition_names.get(record["target"], record["target"])
184
- reference = record["reference"]
185
- if reference == "rest":
186
- return f"{target} vs. other cancers"
187
- return f"{target} vs. {condition_names.get(reference, reference)}"
188
-
189
-
190
- def calculate_cn_variation_specificity(
191
- records, celltypes, condition_names, *, p_value_threshold
192
- ):
193
- """Summarize significant differential-composition features (DCFs)."""
194
- summary_rows = []
195
- detail_rows = []
196
- for record in records:
197
- effect = np.asarray(record["effect"], dtype=float)
198
- pvalues = np.asarray(record["pvalues"], dtype=float)
199
- significant = np.isfinite(effect) & np.isfinite(pvalues) & (
200
- pvalues < p_value_threshold
201
- )
202
- dcf_rows = [
203
- {
204
- "CellType": celltypes[celltype_index],
205
- "Direction": "Up" if effect[celltype_index, cn_index] > 0 else "Down",
206
- "CN": f"CN{cn_index + 1}",
207
- "Significance": -np.log10(
208
- np.clip(pvalues[celltype_index, cn_index], 1e-300, 1.0)
209
- ),
210
- }
211
- for celltype_index, cn_index in np.argwhere(significant)
212
- ]
213
- dcf_table = pd.DataFrame(
214
- dcf_rows, columns=["CellType", "Direction", "CN", "Significance"]
215
- )
216
- number_dcf = len(dcf_table)
217
- number_patterns = (
218
- dcf_table[["CellType", "Direction"]].drop_duplicates().shape[0]
219
- if number_dcf
220
- else 0
221
- )
222
- label = comparison_label(record, condition_names)
223
- summary_rows.append(
224
- {
225
- "Comparison": record["name"],
226
- "CancerComparison": label,
227
- "NumberDCF": number_dcf,
228
- "NumberCellTypeDirectionPatterns": number_patterns,
229
- "SpecificityOfCNVariation": (
230
- number_patterns / number_dcf if number_dcf else np.nan
231
- ),
232
- "MeanDCFSignificance": (
233
- dcf_table["Significance"].mean() if number_dcf else np.nan
234
- ),
235
- }
236
- )
237
- if number_dcf:
238
- patterns = (
239
- dcf_table.groupby(["CellType", "Direction"], sort=False)
240
- .agg(
241
- NumberDCF=("CN", "size"),
242
- MeanDCFSignificance=("Significance", "mean"),
243
- )
244
- .reset_index()
245
- )
246
- patterns.insert(0, "CancerComparison", label)
247
- patterns.insert(0, "Comparison", record["name"])
248
- detail_rows.extend(patterns.to_dict("records"))
249
- return (
250
- pd.DataFrame(summary_rows),
251
- pd.DataFrame(
252
- detail_rows,
253
- columns=[
254
- "Comparison",
255
- "CancerComparison",
256
- "CellType",
257
- "Direction",
258
- "NumberDCF",
259
- "MeanDCFSignificance",
260
- ],
261
- ),
262
- )
263
-
@@ -98,7 +98,9 @@ def run_dominant_celltype_analysis(config: CytoCommunityConfig, *, output_dir=No
98
98
  )
99
99
  output_dir = Path(output_dir or paths.downstream_data / "DominateCT")
100
100
  output_dir.mkdir(parents=True, exist_ok=True)
101
- result.frequency.to_csv(output_dir / "CN_CT_Frequency.csv", float_format="%.4f")
101
+ result.frequency.to_csv(
102
+ output_dir / "CN_CT_Frequency.csv", index_label="CellType", float_format="%.4f"
103
+ )
102
104
  result.dominant_cell_types.to_csv(output_dir / "DominantCT.csv", index=False)
103
105
  return result
104
106