cytocommunity2 1.0.1__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.1/src/cytocommunity2.egg-info → cytocommunity2-1.0.2}/PKG-INFO +1 -1
  2. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/pyproject.toml +1 -1
  3. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/__init__.py +1 -1
  4. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/config.py +1 -1
  5. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/differential_composition.py +30 -11
  6. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/dominant_celltype.py +19 -2
  7. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/dominant.py +31 -5
  8. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/dotplots.py +83 -15
  9. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/selection.py +39 -12
  10. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2/src/cytocommunity2.egg-info}/PKG-INFO +1 -1
  11. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/LICENSE +0 -0
  12. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/README.md +0 -0
  13. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/setup.cfg +0 -0
  14. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/colors.py +0 -0
  15. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/__init__.py +0 -0
  16. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/coherence.py +0 -0
  17. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/__init__.py +0 -0
  18. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/between.py +0 -0
  19. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/cca.py +0 -0
  20. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/utils.py +0 -0
  21. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/communication/within.py +0 -0
  22. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/moran.py +0 -0
  23. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/__init__.py +0 -0
  24. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/cn_selection.py +0 -0
  25. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/coherence.py +0 -0
  26. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/common.py +0 -0
  27. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/communication.py +0 -0
  28. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/composition.py +0 -0
  29. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/plotting/recurrence.py +0 -0
  30. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/downstream/runtime.py +0 -0
  31. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/ensemble.py +0 -0
  32. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/__init__.py +0 -0
  33. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/api.py +0 -0
  34. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/dataset.py +0 -0
  35. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/model.py +0 -0
  36. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/learning/training.py +0 -0
  37. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/paths.py +0 -0
  38. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/visualization/__init__.py +0 -0
  39. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2/visualization/spatial.py +0 -0
  40. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2.egg-info/SOURCES.txt +0 -0
  41. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2.egg-info/dependency_links.txt +0 -0
  42. {cytocommunity2-1.0.1 → cytocommunity2-1.0.2}/src/cytocommunity2.egg-info/requires.txt +0 -0
  43. {cytocommunity2-1.0.1 → 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.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cytocommunity2"
7
- version = "1.0.1"
7
+ version = "1.0.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.1"
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):
@@ -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.1
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
File without changes
File without changes
File without changes