cytocommunity2 0.1.3__tar.gz → 0.1.4__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 (42) hide show
  1. {cytocommunity2-0.1.3/src/cytocommunity2.egg-info → cytocommunity2-0.1.4}/PKG-INFO +2 -2
  2. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/pyproject.toml +2 -2
  3. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/__init__.py +9 -9
  4. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/config.py +7 -6
  5. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/recurrence.py +1 -1
  6. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/learning/api.py +60 -40
  7. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/learning/selection.py +145 -232
  8. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/learning/training.py +15 -10
  9. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4/src/cytocommunity2.egg-info}/PKG-INFO +2 -2
  10. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2.egg-info/requires.txt +1 -1
  11. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/LICENSE +0 -0
  12. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/README.md +0 -0
  13. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/setup.cfg +0 -0
  14. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/colors.py +0 -0
  15. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/__init__.py +0 -0
  16. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/coherence.py +0 -0
  17. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  18. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/communication/between.py +0 -0
  19. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/communication/cca.py +0 -0
  20. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/communication/utils.py +0 -0
  21. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/communication/within.py +0 -0
  22. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/composition.py +0 -0
  23. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/moran.py +0 -0
  24. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
  25. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/cn_selection.py +0 -0
  26. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/coherence.py +0 -0
  27. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/common.py +0 -0
  28. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/communication.py +0 -0
  29. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/composition.py +0 -0
  30. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/dominant.py +0 -0
  31. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/plotting/dotplots.py +0 -0
  32. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/downstream/runtime.py +0 -0
  33. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/ensemble.py +0 -0
  34. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/learning/__init__.py +0 -0
  35. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/learning/dataset.py +0 -0
  36. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/learning/model.py +0 -0
  37. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/paths.py +0 -0
  38. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/visualization/__init__.py +0 -0
  39. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2/visualization/spatial.py +0 -0
  40. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2.egg-info/SOURCES.txt +0 -0
  41. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  42. {cytocommunity2-0.1.3 → cytocommunity2-0.1.4}/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: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Weakly supervised cellular-neighborhood learning for spatial omics
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ Requires-Dist: numpy<2,>=1.23
10
10
  Requires-Dist: pandas<3,>=1.5
11
11
  Requires-Dist: sci-palettes
12
12
  Requires-Dist: scikit-learn>=1.1
13
- Requires-Dist: scipy>=1.9
13
+ Requires-Dist: scipy>=1.11
14
14
  Requires-Dist: seaborn>=0.12
15
15
  Requires-Dist: torch-geometric>=2.0
16
16
  Provides-Extra: downstream
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cytocommunity2"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Weakly supervised cellular-neighborhood learning for spatial omics"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "pandas>=1.5,<3",
15
15
  "sci-palettes",
16
16
  "scikit-learn>=1.1",
17
- "scipy>=1.9",
17
+ "scipy>=1.11",
18
18
  "seaborn>=0.12",
19
19
  "torch-geometric>=2.0",
20
20
  ]
@@ -2,22 +2,22 @@
2
2
 
3
3
  from .config import CytoCommunityConfig
4
4
  from .learning import (
5
- LearningResult,
6
- SpatialGraphDataset,
7
- build_spatial_graphs,
8
- load_learning_result,
9
- run_learning,
5
+ LearningResult,
6
+ SpatialGraphDataset,
7
+ build_spatial_graphs,
8
+ load_learning_result,
9
+ run_learning,
10
10
  )
11
11
  from .visualization import visualize_results
12
12
 
13
- __version__ = "0.1.1"
13
+ __version__ = "0.1.4"
14
14
 
15
15
  __all__ = [
16
16
  "CytoCommunityConfig",
17
17
  "LearningResult",
18
18
  "SpatialGraphDataset",
19
- "build_spatial_graphs",
20
- "load_learning_result",
21
- "run_learning",
19
+ "build_spatial_graphs",
20
+ "load_learning_result",
21
+ "run_learning",
22
22
  "visualize_results",
23
23
  ]
@@ -8,7 +8,6 @@ from typing import Any, Mapping
8
8
 
9
9
 
10
10
  DEFAULT_CONFIG_FILE = "hyperparameters.json"
11
- INPUT_DIR_NAME = "TNBC_Input_pCR"
12
11
 
13
12
  # Established analysis rules stay stable across runs; only experimental choices
14
13
  # belong in hyperparameters.json.
@@ -94,13 +93,15 @@ def _require_keys(values, required, section):
94
93
 
95
94
 
96
95
  def _load_config(config_file=None, config_class=CytoCommunityConfig):
97
- """Load one configuration file and use the adjacent TNBC input folder."""
98
- config_file = Path(config_file or DEFAULT_CONFIG_FILE).expanduser().resolve()
99
- raw = json.loads(config_file.read_text(encoding="utf-8"))
100
- _require_keys(raw, ["random_seed", "main", "downstream"], "root")
96
+ """Load one configuration file and resolve its configured input folder."""
97
+ config_file = Path(config_file or DEFAULT_CONFIG_FILE).expanduser().resolve()
98
+ raw = json.loads(config_file.read_text(encoding="utf-8"))
99
+ _require_keys(raw, ["input_dir", "random_seed", "main", "downstream"], "root")
101
100
 
102
101
  project_root = config_file.parent
103
- input_dir = project_root / INPUT_DIR_NAME
102
+ input_dir = Path(raw["input_dir"]).expanduser()
103
+ if not input_dir.is_absolute():
104
+ input_dir = project_root / input_dir
104
105
 
105
106
  main = MainConfig(**raw["main"])
106
107
  enrichment = EnrichmentConfig()
@@ -62,7 +62,7 @@ def plot_recurrence_results(config):
62
62
  output_dir.mkdir(parents=True, exist_ok=True)
63
63
  real = pd.read_csv(overlap_dir / "RealDistribution_sameCN.csv")
64
64
  background = pd.read_csv(overlap_dir / "BackgroundDistribution_randomCN.csv")
65
- tests = pd.read_csv(overlap_dir / "TTest_Results.csv")
65
+ tests = pd.read_csv(overlap_dir / "KSTest_Results.csv")
66
66
  real["CN"] = real["CN"].astype(str).str.strip()
67
67
  real["OverlapCoefficient"] = pd.to_numeric(real["OverlapCoefficient"], errors="coerce")
68
68
  background["OverlapCoefficient"] = pd.to_numeric(
@@ -3,9 +3,10 @@
3
3
  import datetime
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
- import shutil
7
-
8
- import pandas as pd
6
+ import shutil
7
+
8
+ import numpy as np
9
+ import pandas as pd
9
10
 
10
11
  from ..config import CytoCommunityConfig
11
12
  from ..ensemble import ensemble_cn
@@ -133,7 +134,11 @@ def run_learning(
133
134
  collect_hard_assignments=True,
134
135
  )
135
136
 
136
- result, cn_pvalues = evaluate_cn_num(
137
+ cn_labels_by_sample = {
138
+ sample: hard_assignments[sample_index]
139
+ for sample_index, sample in enumerate(dataset.sample_names)
140
+ }
141
+ result, cn_pvalues = evaluate_cn_num(
137
142
  num_cn=num_cn,
138
143
  dataset=input_dir,
139
144
  celltype_file=unique_celltypes_file,
@@ -143,30 +148,35 @@ def run_learning(
143
148
  recurrence_p_threshold=config.recurrence.recurrence_p_threshold,
144
149
  n_random=config.recurrence.null_draws,
145
150
  seed=config.random_seed,
146
- hard_assignments=hard_assignments,
147
- )
148
- del hard_assignments
151
+ cn_labels_by_sample=cn_labels_by_sample,
152
+ )
153
+ del hard_assignments, cn_labels_by_sample
149
154
  selection_results.append(result)
150
- cn_pvalues = cn_pvalues[
151
- [
152
- "CN",
153
- "p_value_one_sided",
154
- "p_adjusted_bh",
155
- "RecurrenceScore",
155
+ cn_pvalues = cn_pvalues[
156
+ [
157
+ "CN",
158
+ "n_real",
159
+ "n_background",
160
+ "real_mean",
161
+ "background_mean",
162
+ "delta_mean",
163
+ "KSStatistic",
164
+ "KSLocation",
165
+ "p_value_one_sided",
166
+ "p_adjusted_bh",
167
+ "RecurrenceScore",
156
168
  "SignificantRecurrence",
157
169
  ]
158
170
  ].copy()
159
171
  cn_pvalues.insert(0, "NumCN", num_cn)
160
- cn_pvalue_tables.append(cn_pvalues)
161
- print(
162
- f"K={num_cn}: significantCN={result['SignificantCN']}, "
163
- f"significantFraction={result['SignificantFraction']:.6f}, "
164
- f"meanRS={result['MeanRS']:.6f}, score={result['Score']:.6f}"
172
+ cn_pvalue_tables.append(cn_pvalues)
173
+ print(
174
+ f"K={num_cn}: Ksig={result['Ksig']}, "
175
+ f"significantFraction={result['SignificantFraction']:.6f}, "
176
+ f"MeanRS={result['MeanRS']:.6f}, CRI={result['CRI']:.6f}"
165
177
  )
166
178
 
167
- pd.DataFrame(selection_results).sort_values("NumCN").to_csv(
168
- selection_summary_file, index=False
169
- )
179
+ pd.DataFrame(selection_results).to_csv(selection_summary_file, index=False)
170
180
  pd.concat(cn_pvalue_tables, ignore_index=True).to_csv(
171
181
  cn_pvalues_file, index=False
172
182
  )
@@ -175,9 +185,9 @@ def run_learning(
175
185
  (output_dir / "SelectedCNNum.txt").write_text(
176
186
  f"{selected_cn}\n", encoding="utf-8"
177
187
  )
178
- print(
179
- f"\nSelected K={selected_cn} with score={selected['Score']:.6f}. "
180
- f"Training a fresh set of Runs 1-{config.main.final_num_runs}."
188
+ print(
189
+ f"\nSelected K={selected_cn} with CRI={selected['CRI']:.6f}. "
190
+ f"Training a fresh set of Runs 1-{config.main.final_num_runs}."
181
191
  )
182
192
 
183
193
  selected_config = _training_config(
@@ -185,22 +195,32 @@ def run_learning(
185
195
  )
186
196
  for run_index in range(1, config.main.final_num_runs + 1):
187
197
  print(f"\n=== Selected K={selected_cn}: Run {run_index} ===")
188
- train_one_run(
189
- dataset,
190
- selected_config,
191
- run_index=run_index,
192
- output_folder=selected_model_dir / f"Run{run_index}",
193
- dynamic_pseudo=dataset.dynamic_pseudo,
194
- base_seed=config.random_seed,
195
- save_soft_assignments=True,
196
- collect_hard_assignments=False,
197
- )
198
-
199
- ensemble_cn(
200
- input_dir=input_dir,
201
- selected_model_dir=selected_model_dir,
202
- hard_assignment_dir=hard_assignment_dir,
203
- )
198
+ _, hard_assignments = train_one_run(
199
+ dataset,
200
+ selected_config,
201
+ run_index=run_index,
202
+ output_folder=selected_model_dir / f"Run{run_index}",
203
+ dynamic_pseudo=dataset.dynamic_pseudo,
204
+ base_seed=config.random_seed,
205
+ save_soft_assignments=True,
206
+ collect_hard_assignments=config.main.final_num_runs == 1,
207
+ )
208
+
209
+ if config.main.final_num_runs == 1:
210
+ hard_assignment_dir.mkdir()
211
+ for sample_index, sample in enumerate(dataset.sample_names):
212
+ np.savetxt(
213
+ hard_assignment_dir / f"{sample}_CNLabel_MajorityVoting.csv",
214
+ hard_assignments[sample_index],
215
+ delimiter=",",
216
+ fmt="%d",
217
+ )
218
+ else:
219
+ ensemble_cn(
220
+ input_dir=input_dir,
221
+ selected_model_dir=selected_model_dir,
222
+ hard_assignment_dir=hard_assignment_dir,
223
+ )
204
224
  return LearningResult(
205
225
  selected_cn=selected_cn,
206
226
  dataset=dataset,
@@ -5,15 +5,12 @@ from pathlib import Path
5
5
 
6
6
  import numpy as np
7
7
  import pandas as pd
8
- from scipy.stats import hypergeom, ttest_ind
8
+ from scipy.stats import false_discovery_control, hypergeom, ks_2samp
9
9
 
10
10
  from ..paths import build_paths
11
11
 
12
12
 
13
- MIN_FLOAT_P = np.finfo(np.float64).tiny
14
-
15
-
16
- def read_nonempty_lines(path):
13
+ def read_nonempty_lines(path):
17
14
  values = [
18
15
  line.strip()
19
16
  for line in Path(path).read_text(encoding="utf-8-sig").splitlines()
@@ -24,42 +21,14 @@ def read_nonempty_lines(path):
24
21
  return values
25
22
 
26
23
 
27
- def _bh_adjust_matlab(pvalues):
28
- pvalues = np.asarray(pvalues, dtype=np.float64)
29
- flat = pvalues.ravel(order="F")
30
- order = np.argsort(flat, kind="stable")
31
- sorted_p = flat[order]
32
- weighted = sorted_p.size * sorted_p / np.arange(1, sorted_p.size + 1)
33
- adjusted_sorted = np.minimum.accumulate(weighted[::-1])[::-1]
34
- adjusted = np.empty_like(flat)
35
- adjusted[order] = adjusted_sorted
36
- return adjusted.reshape(pvalues.shape, order="F")
37
-
38
-
39
- def bh_adjust(pvalues):
40
- """Benjamini-Hochberg adjustment for one family of CN hypotheses."""
41
- pvalues = np.asarray(pvalues, dtype=np.float64)
42
- clean_pvalues = np.where(np.isfinite(pvalues), pvalues, 1.0)
43
- clean_pvalues = np.clip(clean_pvalues, 0.0, 1.0)
44
- order = np.argsort(clean_pvalues, kind="stable")
45
- sorted_pvalues = clean_pvalues[order]
46
- ranks = np.arange(1, sorted_pvalues.size + 1, dtype=np.float64)
47
- adjusted_sorted = np.minimum.accumulate(
48
- (sorted_pvalues.size * sorted_pvalues / ranks)[::-1]
49
- )[::-1]
50
- adjusted = np.empty_like(clean_pvalues)
51
- adjusted[order] = np.clip(adjusted_sorted, 0.0, 1.0)
52
- return adjusted
53
-
54
-
55
- def calculate_enrichment_matrix(
24
+ def calculate_enrichment_matrix(
56
25
  cell_labels,
57
26
  cn_labels,
58
27
  cell_types,
59
28
  num_cn,
60
29
  minimum_adjusted_p,
61
30
  ):
62
- """Reproduce CellTypeEnrichmentMatrix.m, including its tail precision."""
31
+ """Calculate BH-adjusted cell-type enrichment scores for one sample."""
63
32
  cell_labels = np.asarray(cell_labels, dtype=str)
64
33
  cn_labels = np.asarray(cn_labels, dtype=np.int64)
65
34
  if cell_labels.ndim != 1 or cn_labels.ndim != 1:
@@ -98,70 +67,47 @@ def calculate_enrichment_matrix(
98
67
  int(cell_type_totals[cell_type_index]),
99
68
  int(cn_totals[cn_index]),
100
69
  )
101
- raw_pvalues[cell_type_index, cn_index] = 1.0 - (1.0 - upper_tail)
70
+ raw_pvalues[cell_type_index, cn_index] = upper_tail
102
71
 
103
- adjusted = _bh_adjust_matlab(raw_pvalues)
72
+ adjusted = false_discovery_control(
73
+ raw_pvalues.ravel(order="F"), method="bh"
74
+ ).reshape(raw_pvalues.shape, order="F")
104
75
  adjusted[adjusted < minimum_adjusted_p] = minimum_adjusted_p
105
76
  return -np.log10(adjusted)
106
77
 
107
78
 
108
- def calculate_enrichment(
109
- dataset,
110
- celltype_file,
111
- output_dir,
112
- num_cn,
113
- minimum_adjusted_p,
114
- cn_label_dir=None,
115
- hard_assignments=None,
116
- ):
117
- dataset = Path(dataset)
118
- if (cn_label_dir is None) == (hard_assignments is None):
119
- raise ValueError(
120
- "Provide exactly one of cn_label_dir or hard_assignments"
121
- )
122
- if cn_label_dir is not None:
123
- cn_label_dir = Path(cn_label_dir)
124
- output_dir = Path(output_dir)
125
- samples = read_nonempty_lines(dataset / "ImageNameList.txt")
126
- cell_types = read_nonempty_lines(celltype_file)
127
- output_dir.mkdir(parents=True, exist_ok=True)
128
- pd.DataFrame({"Var1": cell_types}).to_csv(
129
- output_dir / "CellTypeVec_List.csv", index=False
130
- )
131
-
132
- rows = []
133
- for sample_index, sample in enumerate(samples):
134
- cell_labels = (
135
- pd.read_csv(
136
- dataset / f"{sample}_CellTypeLabel.txt",
79
+ def calculate_enrichment(
80
+ dataset,
81
+ cell_types,
82
+ cn_labels_by_sample,
83
+ output_dir,
84
+ num_cn,
85
+ minimum_adjusted_p,
86
+ ):
87
+ dataset = Path(dataset)
88
+ output_dir = Path(output_dir)
89
+ samples = read_nonempty_lines(dataset / "ImageNameList.txt")
90
+ output_dir.mkdir(parents=True, exist_ok=True)
91
+ pd.DataFrame({"Var1": cell_types}).to_csv(
92
+ output_dir / "CellTypeVec_List.csv", index=False
93
+ )
94
+
95
+ matrices = {}
96
+ for sample in samples:
97
+ cell_labels = (
98
+ pd.read_csv(
99
+ dataset / f"{sample}_CellTypeLabel.txt",
137
100
  sep="\t",
138
101
  header=None,
139
102
  dtype=str,
140
103
  )
141
- .iloc[:, 0]
142
- .to_numpy()
143
- )
144
- if hard_assignments is not None:
145
- if sample_index not in hard_assignments:
146
- raise KeyError(
147
- f"Missing in-memory CN labels for sample index {sample_index}"
148
- )
149
- cn_labels = np.asarray(
150
- hard_assignments[sample_index], dtype=np.int64
151
- )
152
- else:
153
- cn_labels = (
154
- pd.read_csv(
155
- cn_label_dir / f"{sample}_CNLabel_MajorityVoting.csv",
156
- header=None,
157
- dtype=np.int64,
158
- )
159
- .iloc[:, 0]
160
- .to_numpy()
161
- )
162
- matrix = calculate_enrichment_matrix(
163
- cell_labels,
164
- cn_labels,
104
+ .iloc[:, 0]
105
+ .to_numpy()
106
+ )
107
+ cn_labels = np.asarray(cn_labels_by_sample[sample], dtype=np.int64)
108
+ matrix = calculate_enrichment_matrix(
109
+ cell_labels,
110
+ cn_labels,
165
111
  cell_types,
166
112
  num_cn,
167
113
  minimum_adjusted_p,
@@ -169,11 +115,25 @@ def calculate_enrichment(
169
115
  np.savetxt(
170
116
  output_dir / f"{sample}_EnrichScoreMatrix.csv",
171
117
  matrix,
172
- delimiter=",",
173
- fmt="%.15g",
174
- )
175
- rows.append({"Sample": sample, "Cells": len(cell_labels), "NumCN": num_cn})
176
- return pd.DataFrame(rows)
118
+ delimiter=",",
119
+ fmt="%.15g",
120
+ )
121
+ matrices[sample] = matrix
122
+ return matrices
123
+
124
+
125
+ def _load_cn_labels(samples, cn_label_dir):
126
+ cn_label_dir = Path(cn_label_dir)
127
+ return {
128
+ sample: pd.read_csv(
129
+ cn_label_dir / f"{sample}_CNLabel_MajorityVoting.csv",
130
+ header=None,
131
+ dtype=np.int64,
132
+ )
133
+ .iloc[:, 0]
134
+ .to_numpy()
135
+ for sample in samples
136
+ }
177
137
 
178
138
 
179
139
  def export_enrichment_long(dataset, enrichment_dir, output_file):
@@ -223,82 +183,51 @@ def overlap_coefficient(first, second):
223
183
  return len(first & second) / smaller_size if smaller_size else 0.0
224
184
 
225
185
 
226
- def _load_dominant_sets(samples, score_dir, score_threshold):
227
- celltypes = (
228
- pd.read_csv(Path(score_dir) / "CellTypeVec_List.csv")
229
- .iloc[:, 0]
230
- .astype(str)
231
- .to_numpy()
232
- )
233
- dominant_sets = {}
234
- num_cn = None
235
- for sample in samples:
236
- matrix = pd.read_csv(
237
- Path(score_dir) / f"{sample}_EnrichScoreMatrix.csv", header=None
238
- ).to_numpy(dtype=float)
239
- if matrix.shape[0] != len(celltypes):
240
- raise ValueError(f"{sample}: enrichment matrix has the wrong row count")
241
- if num_cn is None:
242
- num_cn = matrix.shape[1]
243
- elif matrix.shape[1] != num_cn:
244
- raise ValueError(f"{sample}: inconsistent number of CN columns")
245
- dominant_sets[sample] = [
246
- set(celltypes[matrix[:, cn] > score_threshold])
247
- for cn in range(matrix.shape[1])
248
- ]
249
- return dominant_sets, num_cn
250
-
251
-
252
- def _summarize_ttest(cn, real_values, background_values):
253
- real_values = np.asarray(real_values, dtype=float)
254
- background_values = np.asarray(background_values, dtype=float)
255
- if len(real_values) >= 2 and len(background_values) >= 2:
256
- result = ttest_ind(
257
- real_values,
258
- background_values,
259
- equal_var=False,
260
- alternative="greater",
261
- )
262
- t_statistic, p_value = float(result.statistic), float(result.pvalue)
263
- else:
264
- t_statistic, p_value = np.nan, np.nan
265
- real_mean = np.mean(real_values) if len(real_values) else np.nan
266
- background_mean = (
267
- np.mean(background_values) if len(background_values) else np.nan
268
- )
269
- return {
270
- "CN": cn,
271
- "n_real": len(real_values),
272
- "n_background": len(background_values),
273
- "real_mean": real_mean,
274
- "real_std": np.std(real_values, ddof=1) if len(real_values) > 1 else np.nan,
275
- "background_mean": background_mean,
276
- "background_std": (
277
- np.std(background_values, ddof=1)
278
- if len(background_values) > 1
279
- else np.nan
280
- ),
281
- "delta_mean": real_mean - background_mean,
282
- "t_statistic": t_statistic,
283
- "p_value_one_sided": p_value,
186
+ def _summarize_ks(cn, real_values, background_values):
187
+ real_values = np.asarray(real_values, dtype=float)
188
+ background_values = np.asarray(background_values, dtype=float)
189
+ result = ks_2samp(
190
+ real_values,
191
+ background_values,
192
+ alternative="less",
193
+ method="asymp",
194
+ )
195
+ real_mean = float(np.mean(real_values))
196
+ background_mean = float(np.mean(background_values))
197
+ return {
198
+ "CN": cn,
199
+ "n_real": len(real_values),
200
+ "n_background": len(background_values),
201
+ "real_mean": real_mean,
202
+ "background_mean": background_mean,
203
+ "delta_mean": real_mean - background_mean,
204
+ "KSStatistic": float(result.statistic),
205
+ "KSLocation": float(result.statistic_location),
206
+ "p_value_one_sided": float(result.pvalue),
284
207
  }
285
208
 
286
209
 
287
- def calculate_recurrence(
288
- score_dir,
289
- image_list_file,
290
- output_dir,
210
+ def calculate_recurrence(
211
+ enrichment_matrices,
212
+ cell_types,
213
+ output_dir,
291
214
  score_threshold,
292
215
  recurrence_p_threshold,
293
- n_random,
294
- seed,
295
- ):
296
- samples = read_nonempty_lines(image_list_file)
297
- if len(samples) < 2:
298
- raise ValueError("At least two samples are required for recurrence analysis")
299
- dominant_sets, num_cn = _load_dominant_sets(
300
- samples, score_dir, score_threshold
301
- )
216
+ n_random,
217
+ seed,
218
+ ):
219
+ samples = list(enrichment_matrices)
220
+ if len(samples) < 2:
221
+ raise ValueError("At least two samples are required for recurrence analysis")
222
+ cell_types = np.asarray(cell_types, dtype=str)
223
+ num_cn = enrichment_matrices[samples[0]].shape[1]
224
+ dominant_sets = {
225
+ sample: [
226
+ set(cell_types[matrix[:, cn] > score_threshold])
227
+ for cn in range(num_cn)
228
+ ]
229
+ for sample, matrix in enrichment_matrices.items()
230
+ }
302
231
  sample_pairs = list(combinations(samples, 2))
303
232
  real_df = pd.DataFrame(
304
233
  [
@@ -324,35 +253,20 @@ def calculate_recurrence(
324
253
  )
325
254
  background_df = pd.DataFrame({"OverlapCoefficient": background_values})
326
255
 
327
- ttest_rows = []
256
+ ks_rows = []
328
257
  for cn in [f"CN{index + 1}" for index in range(num_cn)]:
329
258
  values = real_df.loc[
330
259
  real_df["CN"] == cn, "OverlapCoefficient"
331
260
  ].to_numpy()
332
- ttest_rows.append(_summarize_ttest(cn, values, background_values))
333
- ttest_rows.append(
334
- _summarize_ttest(
335
- "Overall", real_df["OverlapCoefficient"].to_numpy(), background_values
336
- )
337
- )
338
- ttest_df = pd.DataFrame(ttest_rows)
339
- cn_mask = ttest_df["CN"] != "Overall"
340
- adjusted_pvalues = bh_adjust(
341
- ttest_df.loc[cn_mask, "p_value_one_sided"].to_numpy()
342
- )
343
- ttest_df["p_adjusted_bh"] = np.nan
344
- ttest_df["RecurrenceScore"] = np.nan
345
- ttest_df["SignificantRecurrence"] = False
346
- ttest_df.loc[cn_mask, "p_adjusted_bh"] = adjusted_pvalues
347
- ttest_df.loc[cn_mask, "RecurrenceScore"] = -np.log10(
348
- np.maximum(adjusted_pvalues, MIN_FLOAT_P)
349
- )
350
- ttest_df.loc[cn_mask, "SignificantRecurrence"] = (
351
- (
352
- ttest_df.loc[cn_mask, "p_adjusted_bh"]
353
- < recurrence_p_threshold
354
- )
355
- & (ttest_df.loc[cn_mask, "delta_mean"] > 0)
261
+ ks_rows.append(_summarize_ks(cn, values, background_values))
262
+ ks_df = pd.DataFrame(ks_rows)
263
+ ks_df["p_adjusted_bh"] = false_discovery_control(
264
+ ks_df["p_value_one_sided"].to_numpy(), method="bh"
265
+ )
266
+ ks_df["RecurrenceScore"] = ks_df["KSStatistic"] * 100.0
267
+ ks_df["SignificantRecurrence"] = (
268
+ (ks_df["p_adjusted_bh"] < recurrence_p_threshold)
269
+ & (ks_df["delta_mean"] > 0)
356
270
  )
357
271
 
358
272
  output_dir = Path(output_dir)
@@ -361,73 +275,72 @@ def calculate_recurrence(
361
275
  background_df.to_csv(
362
276
  output_dir / "BackgroundDistribution_randomCN.csv", index=False
363
277
  )
364
- ttest_df.to_csv(output_dir / "TTest_Results.csv", index=False)
365
- return real_df, background_df, ttest_df
278
+ ks_df.to_csv(output_dir / "KSTest_Results.csv", index=False)
279
+ return real_df, background_df, ks_df
366
280
 
367
281
 
368
- def evaluate_cn_num(
282
+ def evaluate_cn_num(
369
283
  num_cn,
370
284
  dataset,
371
285
  celltype_file,
372
286
  output_dir,
373
287
  enrichment_p_threshold,
374
288
  minimum_adjusted_p,
375
- recurrence_p_threshold,
376
- n_random,
377
- seed,
378
- cn_label_dir=None,
379
- hard_assignments=None,
380
- ):
289
+ recurrence_p_threshold,
290
+ n_random,
291
+ seed,
292
+ cn_labels_by_sample,
293
+ ):
381
294
  """Calculate the recurrence-weighted CN resolution index and per-CN tests."""
382
295
  output_dir = Path(output_dir)
383
296
  enrichment_dir = output_dir / "EnrichScoreMatrix"
384
297
  overlap_dir = output_dir / "OverlapCoefficient"
385
- calculate_enrichment(
386
- dataset=dataset,
387
- celltype_file=celltype_file,
388
- output_dir=enrichment_dir,
389
- num_cn=num_cn,
390
- minimum_adjusted_p=minimum_adjusted_p,
391
- cn_label_dir=cn_label_dir,
392
- hard_assignments=hard_assignments,
393
- )
394
- real_df, _, ttest_df = calculate_recurrence(
395
- enrichment_dir,
396
- Path(dataset) / "ImageNameList.txt",
397
- overlap_dir,
398
- score_threshold=-np.log10(enrichment_p_threshold),
298
+ cell_types = read_nonempty_lines(celltype_file)
299
+ enrichment_matrices = calculate_enrichment(
300
+ dataset=dataset,
301
+ cell_types=cell_types,
302
+ cn_labels_by_sample=cn_labels_by_sample,
303
+ output_dir=enrichment_dir,
304
+ num_cn=num_cn,
305
+ minimum_adjusted_p=minimum_adjusted_p,
306
+ )
307
+ real_df, _, per_cn = calculate_recurrence(
308
+ enrichment_matrices=enrichment_matrices,
309
+ cell_types=cell_types,
310
+ output_dir=overlap_dir,
311
+ score_threshold=-np.log10(enrichment_p_threshold),
399
312
  recurrence_p_threshold=recurrence_p_threshold,
400
313
  n_random=n_random,
401
314
  seed=seed,
402
315
  )
403
- per_cn = ttest_df[ttest_df["CN"] != "Overall"]
404
- significant_mask = per_cn["SignificantRecurrence"].astype(bool)
316
+ significant_mask = per_cn["SignificantRecurrence"].astype(bool)
405
317
  significant_cn = int(significant_mask.sum())
406
318
  significant_fraction = significant_cn / num_cn
407
319
  mean_rs = float(per_cn["RecurrenceScore"].mean())
408
320
  mean_oc = float(real_df["OverlapCoefficient"].mean())
409
- score = float(
410
- significant_fraction**2 * significant_cn * mean_rs
411
- )
412
- result = {
413
- "NumCN": int(num_cn),
414
- "SignificantCN": significant_cn,
415
- "SignificantFraction": significant_fraction,
416
- "MeanRS": mean_rs,
417
- "MeanOC": mean_oc,
418
- "Score": score,
321
+ cri = float(significant_fraction**2 * mean_rs)
322
+ result = {
323
+ "NumCN": int(num_cn),
324
+ "Ksig": significant_cn,
325
+ "SignificantFraction": significant_fraction,
326
+ "MeanOC": mean_oc,
327
+ "MeanRS": mean_rs,
328
+ "CRI": cri,
419
329
  }
420
330
  pd.DataFrame([result]).to_csv(output_dir / "CNNumScore.csv", index=False)
421
- return result, per_cn.copy()
331
+ return result, per_cn
422
332
 
423
333
 
424
334
  def evaluate_selected_cn(result, config):
425
335
  """Evaluate the selected model using the configured project layout."""
426
336
  paths = build_paths(config)
337
+ cn_labels_by_sample = _load_cn_labels(
338
+ result.dataset.sample_names, result.hard_assignment_dir
339
+ )
427
340
  metrics, recurrence = evaluate_cn_num(
428
341
  num_cn=result.selected_cn,
429
342
  dataset=result.input_dir,
430
- cn_label_dir=result.hard_assignment_dir,
343
+ cn_labels_by_sample=cn_labels_by_sample,
431
344
  celltype_file=result.unique_celltypes_file,
432
345
  output_dir=paths.downstream_data,
433
346
  enrichment_p_threshold=config.enrichment.p_value_threshold,
@@ -445,7 +358,7 @@ def evaluate_selected_cn(result, config):
445
358
 
446
359
 
447
360
  def select_cn_num(results):
448
- """Select maximum score, using smaller K as the deterministic tie-break."""
449
- if not results:
450
- raise ValueError("No CN-number results were supplied")
451
- return min(results, key=lambda row: (-row["Score"], row["NumCN"]))
361
+ """Select maximum CRI, using smaller K as the deterministic tie-break."""
362
+ if not results:
363
+ raise ValueError("No CN-number results were supplied")
364
+ return min(results, key=lambda row: (-row["CRI"], row["NumCN"]))
@@ -212,14 +212,19 @@ def train_one_run(
212
212
  _, _, _, assignment_logits, _ = model(
213
213
  data.x, data.edge_index, data.batch
214
214
  )
215
- if collect_hard_assignments:
216
- hard_assignments[index] = (
217
- torch.argmax(assignment_logits, dim=-1).cpu().numpy() + 1
218
- )
219
- if save_soft_assignments:
220
- np.savetxt(
221
- output_folder / f"ClusterAssignMatrix1_{index}.csv",
222
- torch.softmax(assignment_logits, dim=-1).cpu().numpy(),
223
- delimiter=",",
224
- )
215
+ if save_soft_assignments:
216
+ soft_assignments = torch.softmax(
217
+ assignment_logits, dim=-1
218
+ ).cpu().numpy()
219
+ np.savetxt(
220
+ output_folder / f"ClusterAssignMatrix1_{index}.csv",
221
+ soft_assignments,
222
+ delimiter=",",
223
+ )
224
+ if collect_hard_assignments:
225
+ hard_assignments[index] = (
226
+ np.argmax(soft_assignments, axis=-1) + 1
227
+ if save_soft_assignments
228
+ else torch.argmax(assignment_logits, dim=-1).cpu().numpy() + 1
229
+ )
225
230
  return summary, hard_assignments
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cytocommunity2
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Weakly supervised cellular-neighborhood learning for spatial omics
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -10,7 +10,7 @@ Requires-Dist: numpy<2,>=1.23
10
10
  Requires-Dist: pandas<3,>=1.5
11
11
  Requires-Dist: sci-palettes
12
12
  Requires-Dist: scikit-learn>=1.1
13
- Requires-Dist: scipy>=1.9
13
+ Requires-Dist: scipy>=1.11
14
14
  Requires-Dist: seaborn>=0.12
15
15
  Requires-Dist: torch-geometric>=2.0
16
16
  Provides-Extra: downstream
@@ -3,7 +3,7 @@ numpy<2,>=1.23
3
3
  pandas<3,>=1.5
4
4
  sci-palettes
5
5
  scikit-learn>=1.1
6
- scipy>=1.9
6
+ scipy>=1.11
7
7
  seaborn>=0.12
8
8
  torch-geometric>=2.0
9
9
 
File without changes
File without changes
File without changes