cytocommunity2 1.0.0__tar.gz → 1.0.2__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 (43) hide show
  1. {cytocommunity2-1.0.0/src/cytocommunity2.egg-info → cytocommunity2-1.0.2}/PKG-INFO +19 -3
  2. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/README.md +18 -2
  3. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/pyproject.toml +1 -1
  4. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/__init__.py +1 -1
  5. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/config.py +1 -1
  6. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/differential_composition.py +30 -11
  7. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/dominant_celltype.py +19 -2
  8. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/moran.py +16 -19
  9. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/dominant.py +31 -5
  10. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/dotplots.py +83 -15
  11. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/selection.py +39 -12
  12. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2/src/cytocommunity2.egg-info}/PKG-INFO +19 -3
  13. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/LICENSE +0 -0
  14. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/setup.cfg +0 -0
  15. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/colors.py +0 -0
  16. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/__init__.py +0 -0
  17. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/coherence.py +0 -0
  18. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  19. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/between.py +0 -0
  20. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/cca.py +0 -0
  21. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/utils.py +0 -0
  22. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/within.py +0 -0
  23. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
  24. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/cn_selection.py +0 -0
  25. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/coherence.py +0 -0
  26. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/common.py +0 -0
  27. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/communication.py +0 -0
  28. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/composition.py +0 -0
  29. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/recurrence.py +0 -0
  30. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/runtime.py +0 -0
  31. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/ensemble.py +0 -0
  32. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/__init__.py +0 -0
  33. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/api.py +0 -0
  34. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/dataset.py +0 -0
  35. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/model.py +0 -0
  36. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/training.py +0 -0
  37. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/paths.py +0 -0
  38. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/visualization/__init__.py +0 -0
  39. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2/visualization/spatial.py +0 -0
  40. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2.egg-info/SOURCES.txt +0 -0
  41. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  42. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/src/cytocommunity2.egg-info/requires.txt +0 -0
  43. {cytocommunity2-1.0.0 → cytocommunity2-1.0.2}/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.0
3
+ Version: 1.0.2
4
4
  Summary: Weakly supervised cellular-neighborhood learning for spatial omics
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -120,10 +120,26 @@ pseudo-samples for weakly-supervised learning.
120
120
 
121
121
  Before running CytoCommunity2, set `input_dir` and review all
122
122
  model and downstream parameters in
123
- [`hyperparameters.json`](https://github.com/LiukangWu/CytoCommunity2/blob/main/hyperparameters.json). The Python workflow will use this configuration file.
123
+ [`hyperparameters.json`](https://github.com/LiukangWu/CytoCommunity2/blob/main/tutorial/hyperparameters.json). The Python workflow will use this configuration file.
124
124
 
125
125
  ### 3. Run CytoCommunity2
126
126
 
127
+ With `hyperparameters.json` in the working directory, run the core workflow:
128
+
129
+ ```python
130
+ from pathlib import Path
131
+ import cytocommunity2 as cc2
132
+
133
+ CONFIG_FILE = Path("hyperparameters.json").resolve()
134
+
135
+ config = cc2.CytoCommunityConfig.from_json(CONFIG_FILE)
136
+ dataset = cc2.build_spatial_graphs(config)
137
+ learning_result = cc2.run_learning(dataset, config)
138
+ cc2.visualize_results(learning_result, config)
139
+ ```
140
+
141
+ ### 4.Tutorial
142
+
127
143
  The tutorial uses a TNBC dataset as a worked example and demonstrates the
128
144
  entire CytoCommunity2 workflow, from model training and CN identification to
129
145
  downstream analysis:
@@ -132,7 +148,7 @@ downstream analysis:
132
148
  jupyter notebook tutorial/TNBC_workflow.ipynb
133
149
  ```
134
150
 
135
- ### 4. Output files
151
+ ### 5. Output files
136
152
 
137
153
  The main output directories are:
138
154
 
@@ -100,10 +100,26 @@ pseudo-samples for weakly-supervised learning.
100
100
 
101
101
  Before running CytoCommunity2, set `input_dir` and review all
102
102
  model and downstream parameters in
103
- [`hyperparameters.json`](https://github.com/LiukangWu/CytoCommunity2/blob/main/hyperparameters.json). The Python workflow will use this configuration file.
103
+ [`hyperparameters.json`](https://github.com/LiukangWu/CytoCommunity2/blob/main/tutorial/hyperparameters.json). The Python workflow will use this configuration file.
104
104
 
105
105
  ### 3. Run CytoCommunity2
106
106
 
107
+ With `hyperparameters.json` in the working directory, run the core workflow:
108
+
109
+ ```python
110
+ from pathlib import Path
111
+ import cytocommunity2 as cc2
112
+
113
+ CONFIG_FILE = Path("hyperparameters.json").resolve()
114
+
115
+ config = cc2.CytoCommunityConfig.from_json(CONFIG_FILE)
116
+ dataset = cc2.build_spatial_graphs(config)
117
+ learning_result = cc2.run_learning(dataset, config)
118
+ cc2.visualize_results(learning_result, config)
119
+ ```
120
+
121
+ ### 4.Tutorial
122
+
107
123
  The tutorial uses a TNBC dataset as a worked example and demonstrates the
108
124
  entire CytoCommunity2 workflow, from model training and CN identification to
109
125
  downstream analysis:
@@ -112,7 +128,7 @@ downstream analysis:
112
128
  jupyter notebook tutorial/TNBC_workflow.ipynb
113
129
  ```
114
130
 
115
- ### 4. Output files
131
+ ### 5. Output files
116
132
 
117
133
  The main output directories are:
118
134
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cytocommunity2"
7
- version = "1.0.0"
7
+ version = "1.0.2"
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.0"
13
+ __version__ = "1.0.2"
14
14
 
15
15
  __all__ = [
16
16
  "CytoCommunityConfig",
@@ -13,7 +13,7 @@ DEFAULT_CONFIG_FILE = "hyperparameters.json"
13
13
  # belong in hyperparameters.json.
14
14
  FIXED_DOWNSTREAM = {
15
15
  "dominant_cell_type": {"frequency_threshold": 0.5},
16
- "differential_composed": {"p_value_threshold": 0.05},
16
+ "differential_composed": {"p_value_threshold": 0.05, "minimum_samples": 3},
17
17
  "moran": {
18
18
  "minimum_positive_count": 2,
19
19
  "minimum_cn_cells": 10,
@@ -66,11 +66,13 @@ 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):
69
+ def composition_wilcoxon_tests(
70
+ cell_type, target_values, reference_values, *, minimum_samples=3
71
+ ):
70
72
  """Run one-sided asymptotic Wilcoxon rank-sum tests."""
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
@@ -100,10 +102,12 @@ def composition_wilcoxon_tests(cell_type, target_values, reference_values):
100
102
  return rows
101
103
 
102
104
 
103
- def analyze_differential_composition(matrix_records, celltypes, *, mode):
105
+ def analyze_differential_composition(matrix_records, celltypes, *, mode, minimum_samples=3):
104
106
  """Calculate all directional composition comparisons in memory.
105
107
 
106
108
  The result maps comparison names to maps of CN names and result tables.
109
+ Comparisons with fewer than ``minimum_samples`` samples on either side
110
+ have empty maps.
107
111
  """
108
112
  normalized_records = [
109
113
  {**record, "matrix": np.asarray(record["matrix"], dtype=float)}
@@ -114,8 +118,20 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode):
114
118
  labels = sorted({record["condition"] for record in normalized_records})
115
119
  comparisons = build_label_comparisons(labels, mode)
116
120
  outputs = {comparison["name"]: {} for comparison in comparisons}
121
+ eligible = [
122
+ comparison
123
+ for comparison in comparisons
124
+ if sum(
125
+ record["condition"] in comparison["target_labels"]
126
+ for record in normalized_records
127
+ ) >= minimum_samples
128
+ and sum(
129
+ record["condition"] in comparison["reference_labels"]
130
+ for record in normalized_records
131
+ ) >= minimum_samples
132
+ ]
117
133
  for cn_index in range(expected_shape[1]):
118
- for comparison in comparisons:
134
+ for comparison in eligible:
119
135
  rows = []
120
136
  for cell_index, cell_type in enumerate(celltypes):
121
137
  target = [
@@ -129,7 +145,7 @@ def analyze_differential_composition(matrix_records, celltypes, *, mode):
129
145
  if record["condition"] in comparison["reference_labels"]
130
146
  ]
131
147
  result = composition_wilcoxon_tests(
132
- cell_type, target, reference
148
+ cell_type, target, reference, minimum_samples=minimum_samples
133
149
  )
134
150
  if result is not None:
135
151
  rows.extend(result)
@@ -147,16 +163,19 @@ def run_differential_composition_analysis(
147
163
  paths.enrichment, paths.input_dir
148
164
  )
149
165
  outputs = analyze_differential_composition(
150
- records, celltypes, mode=config.downstream["ttest_mode"]
166
+ records, celltypes, mode=config.downstream["ttest_mode"],
167
+ minimum_samples=config.downstream["differential_composed"]["minimum_samples"],
151
168
  )
152
169
  output_dir = Path(output_dir or paths.downstream_data / "DifferentialComposed")
153
170
  for comparison, cn_tables in outputs.items():
154
171
  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)
172
+ if comparison_dir.exists():
173
+ for old_file in comparison_dir.glob("CN*.csv"):
174
+ old_file.unlink()
175
+ if cn_tables:
176
+ comparison_dir.mkdir(parents=True, exist_ok=True)
177
+ for cn, table in cn_tables.items():
178
+ table.to_csv(comparison_dir / f"{cn}_{comparison}.csv", index=False)
160
179
  return outputs
161
180
 
162
181
 
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
 
6
6
  import numpy as np
7
7
  import pandas as pd
8
+ from scipy.cluster.hierarchy import leaves_list, linkage
8
9
 
9
10
  from ..config import CytoCommunityConfig
10
11
  from ..paths import build_paths
@@ -16,6 +17,8 @@ class DominantCellTypeResult:
16
17
 
17
18
  frequency: pd.DataFrame
18
19
  dominant_cell_types: pd.DataFrame
20
+ cn_order: tuple[str, ...] = ()
21
+ cn_linkage: np.ndarray | None = None
19
22
 
20
23
 
21
24
  def load_enrichment_matrices(enrichment_dir, input_dir):
@@ -46,12 +49,26 @@ def analyze_dominant_celltypes(
46
49
  score_threshold,
47
50
  frequency_threshold,
48
51
  ):
49
- """Compute dominant cell types from aligned enrichment matrices."""
52
+ """Compute dominant cell types and cluster CNs by enrichment frequency."""
50
53
  matrices = np.asarray(enrichment_matrices, dtype=float)
51
54
 
52
55
  frequencies = (matrices > score_threshold).mean(axis=0)
53
56
  cn_names = [f"CN{index}" for index in range(1, matrices.shape[2] + 1)]
54
57
  frequency = pd.DataFrame(frequencies, index=celltypes, columns=cn_names)
58
+ # Each CN is represented by its across-sample enrichment frequency for
59
+ # every cell type, the same values displayed in the heatmap.
60
+ cn_linkage = None
61
+ if len(cn_names) > 1:
62
+ cn_linkage = linkage(
63
+ frequency.to_numpy().T,
64
+ method="ward",
65
+ metric="euclidean",
66
+ optimal_ordering=True,
67
+ )
68
+ cn_order = (
69
+ tuple(cn_names[index] for index in leaves_list(cn_linkage))
70
+ if cn_linkage is not None else tuple(cn_names)
71
+ )
55
72
  dominant = pd.DataFrame(
56
73
  {
57
74
  "CN": cn_names,
@@ -63,7 +80,7 @@ def analyze_dominant_celltypes(
63
80
  ],
64
81
  }
65
82
  )
66
- return DominantCellTypeResult(frequency, dominant)
83
+ return DominantCellTypeResult(frequency, dominant, cn_order, cn_linkage)
67
84
 
68
85
 
69
86
  def run_dominant_celltype_analysis(config: CytoCommunityConfig, *, output_dir=None):
@@ -1,7 +1,6 @@
1
1
  """Moran's I calculation and between-condition statistical comparisons."""
2
2
 
3
3
  from pathlib import Path
4
- import warnings
5
4
 
6
5
  from esda.moran import Moran
7
6
  from libpysal.weights import DistanceBand
@@ -161,8 +160,8 @@ def build_comparisons(graph_labels, mode):
161
160
  raise ValueError(f"Unknown comparison mode: {mode}")
162
161
 
163
162
 
164
- def welch_one_sided_tests(target_values, reference_values):
165
- """Return both one-sided Welch tests plus descriptive statistics."""
163
+ def wilcoxon_one_sided_tests(target_values, reference_values):
164
+ """Return both one-sided Wilcoxon rank-sum tests and descriptive statistics."""
166
165
  target = np.asarray(target_values, dtype=float)
167
166
  reference = np.asarray(reference_values, dtype=float)
168
167
  target = target[np.isfinite(target)]
@@ -171,19 +170,16 @@ def welch_one_sided_tests(target_values, reference_values):
171
170
  reference_mean = float(np.mean(reference)) if len(reference) else np.nan
172
171
  p_values = {"greater": np.nan, "less": np.nan}
173
172
  if len(target) >= 2 and len(reference) >= 2:
174
- both_constant = np.var(target, ddof=1) == 0 and np.var(reference, ddof=1) == 0
175
- if not both_constant:
176
- with warnings.catch_warnings():
177
- warnings.simplefilter("ignore", RuntimeWarning)
178
- for alternative in p_values:
179
- p_values[alternative] = float(
180
- stats.ttest_ind(
181
- target,
182
- reference,
183
- equal_var=False,
184
- alternative=alternative,
185
- ).pvalue
186
- )
173
+ for alternative in p_values:
174
+ p_values[alternative] = float(
175
+ stats.mannwhitneyu(
176
+ target,
177
+ reference,
178
+ alternative=alternative,
179
+ method="asymptotic",
180
+ use_continuity=True,
181
+ ).pvalue
182
+ )
187
183
  return {
188
184
  "target_n": len(target),
189
185
  "reference_n": len(reference),
@@ -209,7 +205,7 @@ def test_comparison(frame, comparison):
209
205
  reference = group.loc[
210
206
  group["GraphLabel"] == comparison["reference"], "Global_Moran_I"
211
207
  ].to_numpy(dtype=float)
212
- result = welch_one_sided_tests(target, reference)
208
+ result = wilcoxon_one_sided_tests(target, reference)
213
209
  for alternative, p_value in result["p_values"].items():
214
210
  rows.append(
215
211
  {
@@ -224,6 +220,7 @@ def test_comparison(frame, comparison):
224
220
  "ReferenceMeanMoran": result["reference_mean"],
225
221
  "DeltaMeanMoran": result["delta"],
226
222
  "p_value": p_value,
223
+ "Test": "Wilcoxon rank-sum asymptotic",
227
224
  }
228
225
  )
229
226
  return pd.DataFrame(rows)
@@ -232,7 +229,7 @@ def test_comparison(frame, comparison):
232
229
  def run_moran_comparisons(
233
230
  config: CytoCommunityConfig, *, input_dir=None, output_dir=None
234
231
  ):
235
- """Run configured Welch comparisons over saved Moran tables."""
232
+ """Run configured Wilcoxon rank-sum comparisons over saved Moran tables."""
236
233
  paths = build_paths(config)
237
234
  input_dir = Path(input_dir or paths.downstream_data / "globalMoran")
238
235
  output_dir = Path(output_dir or input_dir / "t_test")
@@ -261,5 +258,5 @@ __all__ = [
261
258
  "run_moran_analysis",
262
259
  "run_moran_comparisons",
263
260
  "test_comparison",
264
- "welch_one_sided_tests",
261
+ "wilcoxon_one_sided_tests",
265
262
  ]
@@ -9,13 +9,25 @@ def _prepare(result):
9
9
  frequency = result.frequency.copy()
10
10
  frequency.columns = frequency.columns.astype(str).str.strip()
11
11
  frequency.index = frequency.index.astype(str).str.strip()
12
- cn_order = sorted(
12
+ cn_order = result.cn_order or sorted(
13
13
  frequency.columns,
14
14
  key=lambda column: int(column.removeprefix("CN")),
15
15
  )
16
16
  return frequency.loc[:, cn_order]
17
17
 
18
18
 
19
+ def _equal_level_linkage(linkage):
20
+ """Give each dendrogram merge level equal display height."""
21
+ display_linkage = linkage.copy()
22
+ n_leaves = len(linkage) + 1
23
+ levels = np.zeros(2 * n_leaves - 1, dtype=int)
24
+ for index, (left, right, _, _) in enumerate(linkage):
25
+ level = max(levels[int(left)], levels[int(right)]) + 1
26
+ levels[n_leaves + index] = level
27
+ display_linkage[index, 2] = level
28
+ return display_linkage
29
+
30
+
19
31
  def plot_dominant_celltype_results(result, config):
20
32
  """Save the frequency heatmap and its colorbar."""
21
33
  import matplotlib as mpl
@@ -35,12 +47,26 @@ def plot_dominant_celltype_results(result, config):
35
47
  frequency = _prepare(result)
36
48
 
37
49
  rows, columns = frequency.shape
38
- heat_height = 0.68 * 10 * rows / (8 * columns)
39
- figure = plt.figure(figsize=(10, 8))
40
- heat_axis = figure.add_axes([0.10, 0.10, 0.68, heat_height])
50
+ cell_fraction = min(0.68 / columns, 0.68 / rows)
51
+ heat_width = columns * cell_fraction
52
+ heat_height = rows * cell_fraction
53
+ figure = plt.figure(figsize=(8, 8))
54
+ heat_axis = figure.add_axes([0.10, 0.10, heat_width, heat_height])
55
+ if result.cn_linkage is not None:
56
+ from scipy.cluster.hierarchy import dendrogram
57
+
58
+ tree_axis = figure.add_axes([0.10, 0.108 + heat_height, heat_width, 0.075])
59
+ tree = dendrogram(_equal_level_linkage(result.cn_linkage), no_plot=True)
60
+ for x, y in zip(tree["icoord"], tree["dcoord"]):
61
+ tree_axis.plot(
62
+ [(value - 5) / 10 for value in x], y,
63
+ color="#4c5966", linewidth=0.8,
64
+ )
65
+ tree_axis.set_xlim(-0.5, columns - 0.5)
66
+ tree_axis.axis("off")
41
67
  heat_axis.imshow(
42
68
  frequency.to_numpy(), cmap="RdYlBu_r", vmin=0, vmax=1,
43
- aspect="auto", interpolation="nearest", origin="upper",
69
+ aspect="equal", interpolation="nearest", origin="upper",
44
70
  )
45
71
  heat_axis.set_xticks(np.arange(columns))
46
72
  heat_axis.set_xticklabels(frequency.columns, rotation=90, fontsize=10)
@@ -8,6 +8,9 @@ import pandas as pd
8
8
  from .common import dot_size, legend_size
9
9
 
10
10
 
11
+ DCF_STAR_THRESHOLDS = (0.1, 0.05)
12
+
13
+
11
14
  def _configure_style():
12
15
  import matplotlib as mpl
13
16
 
@@ -22,7 +25,10 @@ def _configure_style():
22
25
  })
23
26
 
24
27
 
25
- def _draw_panel(axis, record, celltypes, cn_order, norm, threshold, rotation, title):
28
+ def _draw_panel(
29
+ axis, record, celltypes, cn_order, norm, threshold, rotation, title,
30
+ star_thresholds=None,
31
+ ):
26
32
  effect, pvalues = record["effect"].T, record["pvalues"].T
27
33
  x_grid, y_grid = np.meshgrid(np.arange(len(celltypes)), np.arange(len(cn_order)))
28
34
  tested = np.isfinite(effect) & np.isfinite(pvalues)
@@ -37,14 +43,31 @@ def _draw_panel(axis, record, celltypes, cn_order, norm, threshold, rotation, ti
37
43
  c=effect[mask], cmap="coolwarm", norm=norm, alpha=alpha,
38
44
  edgecolors=edgecolor, linewidths=linewidth, zorder=zorder,
39
45
  )
40
- if np.any(significant):
41
- axis.scatter(
42
- x_grid[significant], y_grid[significant], s=60.0, marker="*",
43
- c="#1d2630", edgecolors="none", zorder=4,
44
- )
46
+ if star_thresholds is None:
47
+ if np.any(significant):
48
+ axis.scatter(
49
+ x_grid[significant], y_grid[significant], s=60.0, marker="*",
50
+ c="#1d2630", edgecolors="none", zorder=4,
51
+ )
52
+ else:
53
+ weak_threshold, strong_threshold = star_thresholds
54
+ one_star = tested & (pvalues < weak_threshold) & (pvalues >= strong_threshold)
55
+ two_stars = tested & (pvalues < strong_threshold)
56
+ if np.any(one_star):
57
+ axis.scatter(
58
+ x_grid[one_star], y_grid[one_star], s=60.0, marker="*",
59
+ c="#1d2630", edgecolors="none", zorder=4,
60
+ )
61
+ if np.any(two_stars):
62
+ for x_offset in (-0.075, 0.075):
63
+ axis.scatter(
64
+ x_grid[two_stars] + x_offset, y_grid[two_stars],
65
+ s=45.0, marker="*", c="#1d2630", edgecolors="none",
66
+ zorder=4,
67
+ )
45
68
  axis.set_title(title, fontsize=13, fontweight="bold", pad=10)
46
69
  if not np.any(tested):
47
- axis.text(0.5, 0.5, "Not tested\n(n < 2)", transform=axis.transAxes, ha="center", va="center", color="#6d7781")
70
+ axis.text(0.5, 0.5, "Not tested", transform=axis.transAxes, ha="center", va="center", color="#6d7781")
48
71
  axis.set_xticks(np.arange(len(celltypes)))
49
72
  axis.set_xticklabels(celltypes, rotation=rotation, ha="right" if rotation != 90 else "center", fontsize=8.5)
50
73
  axis.set_yticks(np.arange(len(cn_order)))
@@ -58,9 +81,13 @@ def _draw_panel(axis, record, celltypes, cn_order, norm, threshold, rotation, ti
58
81
  spine.set_visible(False)
59
82
 
60
83
 
61
- def _add_legends(figure, axis, norm, threshold, colorbar_title):
84
+ def _add_legends(
85
+ figure, axis, norm, threshold, colorbar_title, star_thresholds=None,
86
+ significance_title="-log10(p-value)",
87
+ ):
62
88
  import matplotlib as mpl
63
89
  import matplotlib.pyplot as plt
90
+ from matplotlib.legend_handler import HandlerTuple
64
91
 
65
92
  axis.axis("off")
66
93
  scalar_map = mpl.cm.ScalarMappable(norm=norm, cmap=plt.get_cmap("coolwarm"))
@@ -70,12 +97,31 @@ def _add_legends(figure, axis, norm, threshold, colorbar_title):
70
97
  colorbar.ax.tick_params(labelsize=9)
71
98
  values = (1.3, 2.0, 3.0)
72
99
  handles = [axis.scatter([], [], s=legend_size(value), color="#b9414b") for value in values]
73
- handles.append(axis.scatter([], [], s=60.0, marker="*", color="#1d2630"))
100
+ labels = [f"{value:g}" for value in values]
101
+ handler_map = None
102
+ if star_thresholds is None:
103
+ handles.append(axis.scatter([], [], s=60.0, marker="*", color="#1d2630"))
104
+ labels.append(f"p-value < {threshold:g}")
105
+ else:
106
+ weak_threshold, strong_threshold = star_thresholds
107
+ handles.extend([
108
+ axis.scatter([], [], s=60.0, marker="*", color="#1d2630"),
109
+ (
110
+ axis.scatter([], [], s=45.0, marker="*", color="#1d2630"),
111
+ axis.scatter([], [], s=45.0, marker="*", color="#1d2630"),
112
+ ),
113
+ ])
114
+ labels.extend([
115
+ f"{strong_threshold:g} <= p-value < {weak_threshold:g}",
116
+ f"p-value < {strong_threshold:g}",
117
+ ])
118
+ handler_map = {tuple: HandlerTuple(ndivide=None, pad=0.0)}
74
119
  axis.legend(
75
120
  handles,
76
- [f"{value:g}" for value in values] + [f"p-value < {threshold:g}"],
77
- title="-log10(p-value)", frameon=False, loc="center left",
121
+ labels,
122
+ title=significance_title, frameon=False, loc="center left",
78
123
  bbox_to_anchor=(0.36, 0.50), labelspacing=1.0, handletextpad=0.75,
124
+ handler_map=handler_map,
79
125
  )
80
126
 
81
127
 
@@ -97,7 +143,8 @@ def _normalization(records, *, cap=None):
97
143
 
98
144
  def _save_dotplots(
99
145
  records, celltypes, cn_order, norm, threshold, output_dir, prefix,
100
- colorbar_title, *, grid_name=None, grid_title=None,
146
+ colorbar_title, *, grid_name=None, grid_title=None, star_thresholds=None,
147
+ significance_title="-log10(p-value)",
101
148
  ):
102
149
  import matplotlib.pyplot as plt
103
150
 
@@ -109,6 +156,7 @@ def _save_dotplots(
109
156
  _draw_panel(
110
157
  axis, record, celltypes, cn_order, norm, threshold, 90,
111
158
  f"{record['target']} vs {record['reference']}",
159
+ star_thresholds,
112
160
  )
113
161
  output = output_dir / f"{prefix}{record['name']}.pdf"
114
162
  figure.savefig(output, bbox_inches="tight", dpi=1200)
@@ -116,7 +164,10 @@ def _save_dotplots(
116
164
  plt.close(figure)
117
165
  outputs.append(output)
118
166
  figure, axis = plt.subplots(figsize=(4.6, 3.6))
119
- _add_legends(figure, axis, norm, threshold, colorbar_title)
167
+ _add_legends(
168
+ figure, axis, norm, threshold, colorbar_title, star_thresholds,
169
+ significance_title,
170
+ )
120
171
  legend = output_dir / "LEGEND.pdf"
121
172
  figure.savefig(legend, bbox_inches="tight", dpi=1200)
122
173
  plt.close(figure)
@@ -130,8 +181,12 @@ def _save_dotplots(
130
181
  _draw_panel(
131
182
  axis, record, celltypes, cn_order, norm, threshold, 55,
132
183
  grid_title(record) if grid_title else record["target"],
184
+ star_thresholds,
133
185
  )
134
- _add_legends(figure, axes[len(records)], norm, threshold, colorbar_title)
186
+ _add_legends(
187
+ figure, axes[len(records)], norm, threshold, colorbar_title,
188
+ star_thresholds, significance_title,
189
+ )
135
190
  for axis in axes[len(records) + 1:]:
136
191
  axis.set_visible(False)
137
192
  figure.subplots_adjust(left=0.07, right=0.98, bottom=0.10, top=0.96, hspace=0.60, wspace=0.25)
@@ -170,6 +225,8 @@ def _differential_records(config, results):
170
225
  celltype_index = {celltype: index for index, celltype in enumerate(celltypes)}
171
226
  records = []
172
227
  for name in names:
228
+ if not results.get(name):
229
+ continue
173
230
  target, reference = name.split("_gt_", 1)
174
231
  reference_mean = means[reference] if reference != "rest" else np.nanmean(np.stack([
175
232
  matrix for condition, matrices in condition_matrices.items()
@@ -208,14 +265,25 @@ def plot_differential_composition_results(config, results, output_dir=None):
208
265
  """Save the original differential-composition dot plots."""
209
266
  _configure_style()
210
267
  celltypes, records = _differential_records(config, results)
268
+ output_dir = Path(output_dir or config.project_root / "plot" / "DifferentialComposed")
269
+ plotted_names = {record["name"] for record in records}
270
+ for name in results:
271
+ if name not in plotted_names:
272
+ for old_plot in (output_dir / f"{name}.pdf", output_dir / f"{name}.png"):
273
+ old_plot.unlink(missing_ok=True)
274
+ if not records:
275
+ for old_plot in (output_dir / "LEGEND.pdf", output_dir / "DotPlot_all.pdf"):
276
+ old_plot.unlink(missing_ok=True)
277
+ return {"plots": [], "legend": None, "grid": None}
211
278
  threshold = config.downstream["differential_composed"]["p_value_threshold"]
212
279
  return _save_dotplots(
213
280
  records, celltypes, range(1, records[0]["effect"].shape[1] + 1),
214
281
  _normalization(records, cap=4.0), threshold,
215
- output_dir or config.project_root / "plot" / "DifferentialComposed",
282
+ output_dir,
216
283
  "", "log2FC",
217
284
  grid_name="DotPlot_all.pdf" if config.downstream["ttest_mode"] == "one_vs_rest" else None,
218
285
  grid_title=lambda record: f"{record['target']} (n={record['sample_count']})",
286
+ star_thresholds=DCF_STAR_THRESHOLDS,
219
287
  )
220
288
 
221
289
 
@@ -28,16 +28,42 @@ def calculate_enrichment_matrix(
28
28
  num_cn,
29
29
  minimum_adjusted_p,
30
30
  ):
31
- """Calculate BH-adjusted cell-type enrichment scores for one sample."""
31
+ """Calculate enrichment while retaining cells with an unassigned CN."""
32
32
  cell_labels = np.asarray(cell_labels, dtype=str)
33
- cn_labels = np.asarray(cn_labels, dtype=np.int64)
33
+ cn_labels = pd.to_numeric(
34
+ pd.Series(np.asarray(cn_labels)).replace("NA", np.nan),
35
+ errors="raise",
36
+ ).to_numpy(dtype=float)
37
+ if cell_labels.size != cn_labels.size:
38
+ raise ValueError("Cell-type and CN-label vectors must have equal lengths")
34
39
 
35
40
  categories = pd.Categorical(cell_labels, categories=cell_types)
36
41
  cell_type_codes = categories.codes
42
+ if np.any(cell_type_codes < 0):
43
+ unknown = sorted(np.unique(cell_labels[cell_type_codes < 0]).tolist())
44
+ raise ValueError(f"Unknown cell types: {unknown}")
45
+
46
+ assigned = np.isfinite(cn_labels)
47
+ assigned_labels = cn_labels[assigned]
48
+ if np.any(assigned_labels != np.floor(assigned_labels)) or np.any(
49
+ (assigned_labels < 1) | (assigned_labels > num_cn)
50
+ ):
51
+ raise ValueError(f"CN labels must be integers from 1 to {num_cn}, or NA")
37
52
 
38
53
  counts = np.zeros((len(cell_types), num_cn), dtype=np.int64)
39
- np.add.at(counts, (cell_type_codes, cn_labels - 1), 1)
40
- cell_type_totals = counts.sum(axis=1)
54
+ np.add.at(
55
+ counts,
56
+ (
57
+ cell_type_codes[assigned],
58
+ assigned_labels.astype(np.int64) - 1,
59
+ ),
60
+ 1,
61
+ )
62
+ # Cells without a CN assignment remain part of the sample and their cell
63
+ # type totals, but they do not contribute to any CN-specific overlap.
64
+ cell_type_totals = np.bincount(
65
+ cell_type_codes, minlength=len(cell_types)
66
+ )
41
67
  cn_totals = counts.sum(axis=0)
42
68
  raw_pvalues = np.ones(counts.shape, dtype=np.float64)
43
69
 
@@ -88,7 +114,7 @@ def calculate_enrichment(
88
114
  .iloc[:, 0]
89
115
  .to_numpy()
90
116
  )
91
- cn_labels = np.asarray(cn_labels_by_sample[sample], dtype=np.int64)
117
+ cn_labels = np.asarray(cn_labels_by_sample[sample])
92
118
  matrix = calculate_enrichment_matrix(
93
119
  cell_labels,
94
120
  cn_labels,
@@ -109,13 +135,14 @@ def calculate_enrichment(
109
135
  def _load_cn_labels(samples, result_table_dir):
110
136
  result_table_dir = Path(result_table_dir)
111
137
  return {
112
- sample: pd.read_csv(
113
- result_table_dir / f"ResultTable_{sample}.csv",
114
- usecols=["CN_Label"],
115
- dtype={"CN_Label": np.int64},
116
- )
117
- ["CN_Label"]
118
- .to_numpy()
138
+ sample: pd.to_numeric(
139
+ pd.read_csv(
140
+ result_table_dir / f"ResultTable_{sample}.csv",
141
+ usecols=["CN_Label"],
142
+ na_values=["NA"],
143
+ )["CN_Label"],
144
+ errors="raise",
145
+ ).to_numpy(dtype=float)
119
146
  for sample in samples
120
147
  }
121
148
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cytocommunity2
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Weakly supervised cellular-neighborhood learning for spatial omics
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -120,10 +120,26 @@ pseudo-samples for weakly-supervised learning.
120
120
 
121
121
  Before running CytoCommunity2, set `input_dir` and review all
122
122
  model and downstream parameters in
123
- [`hyperparameters.json`](https://github.com/LiukangWu/CytoCommunity2/blob/main/hyperparameters.json). The Python workflow will use this configuration file.
123
+ [`hyperparameters.json`](https://github.com/LiukangWu/CytoCommunity2/blob/main/tutorial/hyperparameters.json). The Python workflow will use this configuration file.
124
124
 
125
125
  ### 3. Run CytoCommunity2
126
126
 
127
+ With `hyperparameters.json` in the working directory, run the core workflow:
128
+
129
+ ```python
130
+ from pathlib import Path
131
+ import cytocommunity2 as cc2
132
+
133
+ CONFIG_FILE = Path("hyperparameters.json").resolve()
134
+
135
+ config = cc2.CytoCommunityConfig.from_json(CONFIG_FILE)
136
+ dataset = cc2.build_spatial_graphs(config)
137
+ learning_result = cc2.run_learning(dataset, config)
138
+ cc2.visualize_results(learning_result, config)
139
+ ```
140
+
141
+ ### 4.Tutorial
142
+
127
143
  The tutorial uses a TNBC dataset as a worked example and demonstrates the
128
144
  entire CytoCommunity2 workflow, from model training and CN identification to
129
145
  downstream analysis:
@@ -132,7 +148,7 @@ downstream analysis:
132
148
  jupyter notebook tutorial/TNBC_workflow.ipynb
133
149
  ```
134
150
 
135
- ### 4. Output files
151
+ ### 5. Output files
136
152
 
137
153
  The main output directories are:
138
154
 
File without changes
File without changes