cytocommunity2 0.1.1__py3-none-any.whl
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.
- cytocommunity2/__init__.py +23 -0
- cytocommunity2/colors.py +19 -0
- cytocommunity2/config.py +136 -0
- cytocommunity2/downstream/__init__.py +18 -0
- cytocommunity2/downstream/coherence.py +115 -0
- cytocommunity2/downstream/communication/__init__.py +11 -0
- cytocommunity2/downstream/communication/between.py +142 -0
- cytocommunity2/downstream/communication/cca.py +332 -0
- cytocommunity2/downstream/communication/utils.py +63 -0
- cytocommunity2/downstream/communication/within.py +266 -0
- cytocommunity2/downstream/composition.py +387 -0
- cytocommunity2/downstream/moran.py +309 -0
- cytocommunity2/downstream/plotting/__init__.py +21 -0
- cytocommunity2/downstream/plotting/cn_selection.py +72 -0
- cytocommunity2/downstream/plotting/coherence.py +64 -0
- cytocommunity2/downstream/plotting/common.py +79 -0
- cytocommunity2/downstream/plotting/communication.py +365 -0
- cytocommunity2/downstream/plotting/composition.py +80 -0
- cytocommunity2/downstream/plotting/dominant.py +142 -0
- cytocommunity2/downstream/plotting/dotplots.py +260 -0
- cytocommunity2/downstream/plotting/recurrence.py +203 -0
- cytocommunity2/downstream/runtime.py +13 -0
- cytocommunity2/ensemble.py +255 -0
- cytocommunity2/learning/__init__.py +12 -0
- cytocommunity2/learning/api.py +220 -0
- cytocommunity2/learning/dataset.py +137 -0
- cytocommunity2/learning/model.py +122 -0
- cytocommunity2/learning/selection.py +427 -0
- cytocommunity2/learning/training.py +216 -0
- cytocommunity2/paths.py +50 -0
- cytocommunity2/visualization/__init__.py +5 -0
- cytocommunity2/visualization/spatial.py +155 -0
- cytocommunity2-0.1.1.dist-info/METADATA +27 -0
- cytocommunity2-0.1.1.dist-info/RECORD +36 -0
- cytocommunity2-0.1.1.dist-info/WHEEL +5 -0
- cytocommunity2-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Public Python API for CytoCommunity2."""
|
|
2
|
+
|
|
3
|
+
from .config import CytoCommunityConfig
|
|
4
|
+
from .learning import (
|
|
5
|
+
LearningResult,
|
|
6
|
+
SpatialGraphDataset,
|
|
7
|
+
build_spatial_graphs,
|
|
8
|
+
load_learning_result,
|
|
9
|
+
run_learning,
|
|
10
|
+
)
|
|
11
|
+
from .visualization import visualize_results
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.1"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CytoCommunityConfig",
|
|
17
|
+
"LearningResult",
|
|
18
|
+
"SpatialGraphDataset",
|
|
19
|
+
"build_spatial_graphs",
|
|
20
|
+
"load_learning_result",
|
|
21
|
+
"run_learning",
|
|
22
|
+
"visualize_results",
|
|
23
|
+
]
|
cytocommunity2/colors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared categorical colors used across CytoCommunity2 figures."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def celltype_color_map(celltypes):
|
|
5
|
+
"""Return the canonical cell-type colors in the supplied display order."""
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
|
|
8
|
+
palette = []
|
|
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
|
+
)
|
|
16
|
+
return {
|
|
17
|
+
celltype: palette[index]
|
|
18
|
+
for index, celltype in enumerate(celltypes)
|
|
19
|
+
}
|
cytocommunity2/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Central configuration loading and validation for CytoCommunity2."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_FILE = "hyperparameters.json"
|
|
11
|
+
INPUT_DIR_NAME = "TNBC_Input_pCR"
|
|
12
|
+
|
|
13
|
+
# Established analysis rules stay stable across runs; only experimental choices
|
|
14
|
+
# belong in hyperparameters.json.
|
|
15
|
+
FIXED_DOWNSTREAM = {
|
|
16
|
+
"dominant_cell_type": {"frequency_threshold": 0.5},
|
|
17
|
+
"differential_composed": {"p_value_threshold": 0.05},
|
|
18
|
+
"moran": {
|
|
19
|
+
"minimum_positive_count": 2,
|
|
20
|
+
"minimum_cn_cells": 10,
|
|
21
|
+
"p_value_threshold": 0.05,
|
|
22
|
+
},
|
|
23
|
+
"communication": {
|
|
24
|
+
"minimum_samples": 3,
|
|
25
|
+
"permutation_max_attempt_multiplier": 20,
|
|
26
|
+
"spearman_p_threshold": 0.05,
|
|
27
|
+
"permutation_p_threshold": 0.05,
|
|
28
|
+
"cca_p_threshold": 0.05,
|
|
29
|
+
"cca_minimum_common_samples": 3,
|
|
30
|
+
"cca_minimum_abs_rho": 0.0,
|
|
31
|
+
"cca_sd_epsilon": 1e-8,
|
|
32
|
+
"cca_max_iterations": 1000,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class MainConfig:
|
|
39
|
+
knn_k: int
|
|
40
|
+
cn_num_min: int
|
|
41
|
+
cn_num_max: int
|
|
42
|
+
final_num_runs: int
|
|
43
|
+
num_epoch: int
|
|
44
|
+
embedding_dimension: int
|
|
45
|
+
learning_rate: float
|
|
46
|
+
mini_batch_size: int
|
|
47
|
+
beta: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class EnrichmentConfig:
|
|
52
|
+
p_value_threshold: float = 0.05
|
|
53
|
+
minimum_adjusted_p: float = 1e-20
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RecurrenceConfig:
|
|
58
|
+
recurrence_p_threshold: float = 0.05
|
|
59
|
+
null_draws: int = 10000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class VisualizationConfig:
|
|
64
|
+
point_size: float = 2.0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class CytoCommunityConfig:
|
|
69
|
+
config_file: Path
|
|
70
|
+
project_root: Path
|
|
71
|
+
input_dir: Path
|
|
72
|
+
random_seed: int
|
|
73
|
+
main: MainConfig
|
|
74
|
+
enrichment: EnrichmentConfig
|
|
75
|
+
recurrence: RecurrenceConfig
|
|
76
|
+
visualization: VisualizationConfig
|
|
77
|
+
downstream: Mapping[str, Any]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def celltype_order(self):
|
|
81
|
+
groups = self.downstream["dominant_cell_type"]["cell_type_groups"]
|
|
82
|
+
return [cell_type for group in groups.values() for cell_type in group]
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_json(cls, config_file=None):
|
|
86
|
+
"""Load and validate a JSON configuration file."""
|
|
87
|
+
return _load_config(config_file, config_class=cls)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _require_keys(values, required, section):
|
|
91
|
+
missing = [key for key in required if key not in values]
|
|
92
|
+
if missing:
|
|
93
|
+
raise ValueError(f"Missing configuration keys in {section}: {missing}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
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")
|
|
101
|
+
|
|
102
|
+
project_root = config_file.parent
|
|
103
|
+
input_dir = project_root / INPUT_DIR_NAME
|
|
104
|
+
|
|
105
|
+
main = MainConfig(**raw["main"])
|
|
106
|
+
enrichment = EnrichmentConfig()
|
|
107
|
+
recurrence = RecurrenceConfig()
|
|
108
|
+
visualization = VisualizationConfig(**raw.get("visualization", {}))
|
|
109
|
+
downstream = dict(raw["downstream"])
|
|
110
|
+
for section, fixed_values in FIXED_DOWNSTREAM.items():
|
|
111
|
+
downstream[section] = {**downstream.get(section, {}), **fixed_values}
|
|
112
|
+
|
|
113
|
+
if main.knn_k < 1:
|
|
114
|
+
raise ValueError("main.knn_k must be positive")
|
|
115
|
+
if main.cn_num_min < 1 or main.cn_num_min > main.cn_num_max:
|
|
116
|
+
raise ValueError("Require 1 <= main.cn_num_min <= main.cn_num_max")
|
|
117
|
+
if main.final_num_runs < 1 or main.num_epoch < 1:
|
|
118
|
+
raise ValueError("Run and epoch counts must be positive")
|
|
119
|
+
if main.embedding_dimension < 1 or main.mini_batch_size < 1:
|
|
120
|
+
raise ValueError("Embedding dimension and mini-batch size must be positive")
|
|
121
|
+
if main.learning_rate <= 0 or not 0 <= main.beta <= 1:
|
|
122
|
+
raise ValueError("Learning rate must be positive and beta must be in [0, 1]")
|
|
123
|
+
if not math.isfinite(visualization.point_size) or visualization.point_size <= 0:
|
|
124
|
+
raise ValueError("visualization.point_size must be finite and positive")
|
|
125
|
+
|
|
126
|
+
return config_class(
|
|
127
|
+
config_file=config_file,
|
|
128
|
+
project_root=project_root,
|
|
129
|
+
input_dir=input_dir.resolve(),
|
|
130
|
+
random_seed=int(raw["random_seed"]),
|
|
131
|
+
main=main,
|
|
132
|
+
enrichment=enrichment,
|
|
133
|
+
recurrence=recurrence,
|
|
134
|
+
visualization=visualization,
|
|
135
|
+
downstream=downstream,
|
|
136
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
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.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .coherence import run_coherence_analysis
|
|
9
|
+
from .composition import (
|
|
10
|
+
run_differential_composition_analysis,
|
|
11
|
+
run_dominant_celltype_analysis,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"run_coherence_analysis",
|
|
16
|
+
"run_differential_composition_analysis",
|
|
17
|
+
"run_dominant_celltype_analysis",
|
|
18
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Spatial coherence metrics and their file-based workflow."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from sklearn.neighbors import NearestNeighbors
|
|
9
|
+
from sklearn.preprocessing import StandardScaler
|
|
10
|
+
|
|
11
|
+
from ..config import CytoCommunityConfig
|
|
12
|
+
from ..paths import build_paths
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CoherenceMetrics:
|
|
17
|
+
"""CHAOS and PAS values for one cellular-neighborhood assignment."""
|
|
18
|
+
|
|
19
|
+
chaos: float
|
|
20
|
+
pas: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validated_inputs(cluster_labels, coordinates):
|
|
24
|
+
labels = np.asarray(cluster_labels)
|
|
25
|
+
locations = np.asarray(coordinates, dtype=float)
|
|
26
|
+
if labels.ndim != 1:
|
|
27
|
+
raise ValueError("cluster_labels must be one-dimensional")
|
|
28
|
+
if locations.ndim != 2 or locations.shape[1] != 2:
|
|
29
|
+
raise ValueError("coordinates must have shape (n_cells, 2)")
|
|
30
|
+
if len(labels) != len(locations):
|
|
31
|
+
raise ValueError("cluster_labels and coordinates must have equal length")
|
|
32
|
+
if len(labels) == 0:
|
|
33
|
+
raise ValueError("at least one cell is required")
|
|
34
|
+
if not np.isfinite(locations).all():
|
|
35
|
+
raise ValueError("coordinates must contain only finite values")
|
|
36
|
+
return labels, locations
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def compute_chaos(cluster_labels, coordinates):
|
|
40
|
+
"""Compute the normalized sum of within-CN nearest-neighbor distances."""
|
|
41
|
+
labels, locations = _validated_inputs(cluster_labels, coordinates)
|
|
42
|
+
locations = StandardScaler().fit_transform(locations)
|
|
43
|
+
distances = []
|
|
44
|
+
for label in np.unique(labels):
|
|
45
|
+
points = locations[labels == label]
|
|
46
|
+
if len(points) <= 1:
|
|
47
|
+
continue
|
|
48
|
+
neighbors = NearestNeighbors(n_neighbors=2, algorithm="kd_tree").fit(points)
|
|
49
|
+
nearest_distances, _ = neighbors.kneighbors(points)
|
|
50
|
+
distances.append(np.sum(nearest_distances[:, 1]))
|
|
51
|
+
return float(np.sum(distances) / len(labels))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_pas(cluster_labels, coordinates, *, k=10):
|
|
55
|
+
"""Compute the percentage of abnormal spots (PAS) metric."""
|
|
56
|
+
labels, locations = _validated_inputs(cluster_labels, coordinates)
|
|
57
|
+
if not 1 <= k < len(labels):
|
|
58
|
+
raise ValueError("k must satisfy 1 <= k < number of cells")
|
|
59
|
+
neighbors = NearestNeighbors(n_neighbors=k + 1, algorithm="kd_tree").fit(
|
|
60
|
+
locations
|
|
61
|
+
)
|
|
62
|
+
_, indices = neighbors.kneighbors(locations)
|
|
63
|
+
neighbor_indices = indices[:, 1:]
|
|
64
|
+
different = labels[neighbor_indices] != labels[:, None]
|
|
65
|
+
return float((different.sum(axis=1) > (k / 2)).mean())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def compute_coherence(cluster_labels, coordinates, *, pas_k=10):
|
|
69
|
+
"""Return both spatial coherence metrics without reading or writing files."""
|
|
70
|
+
return CoherenceMetrics(
|
|
71
|
+
chaos=compute_chaos(cluster_labels, coordinates),
|
|
72
|
+
pas=compute_pas(cluster_labels, coordinates, k=pas_k),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def analyze_result_tables(result_table_dir, *, pas_k=10):
|
|
77
|
+
"""Calculate coherence metrics for all result-table CSV files."""
|
|
78
|
+
rows = []
|
|
79
|
+
for csv_path in sorted(Path(result_table_dir).glob("*.csv")):
|
|
80
|
+
sample = csv_path.stem.removeprefix("ResultTable_")
|
|
81
|
+
frame = pd.read_csv(csv_path)
|
|
82
|
+
required = ["x_coordinate", "y_coordinate", "CN_Label"]
|
|
83
|
+
missing = [column for column in required if column not in frame]
|
|
84
|
+
if missing:
|
|
85
|
+
raise ValueError(f"Missing columns in {csv_path}: {missing}")
|
|
86
|
+
frame = frame[required].copy()
|
|
87
|
+
for column in required:
|
|
88
|
+
frame[column] = pd.to_numeric(frame[column], errors="coerce")
|
|
89
|
+
frame = frame.dropna(subset=required)
|
|
90
|
+
metrics = compute_coherence(
|
|
91
|
+
frame["CN_Label"].to_numpy(dtype=np.int64),
|
|
92
|
+
frame[["x_coordinate", "y_coordinate"]].to_numpy(dtype=float),
|
|
93
|
+
pas_k=pas_k,
|
|
94
|
+
)
|
|
95
|
+
rows.append({"Sample": sample, "CHAOS": metrics.chaos, "PAS": metrics.pas})
|
|
96
|
+
|
|
97
|
+
result = pd.DataFrame(rows, columns=["Sample", "CHAOS", "PAS"]).round(4)
|
|
98
|
+
if result.empty:
|
|
99
|
+
raise FileNotFoundError(f"No result-table CSV files found in {result_table_dir}")
|
|
100
|
+
average = pd.DataFrame(
|
|
101
|
+
[{"Sample": "Average", "CHAOS": result["CHAOS"].mean(), "PAS": result["PAS"].mean()}]
|
|
102
|
+
).round(4)
|
|
103
|
+
return pd.concat([result, average], ignore_index=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_coherence_analysis(config, *, output_dir=None, pas_k=10):
|
|
107
|
+
"""Run the file-oriented workflow and return the written table."""
|
|
108
|
+
if not isinstance(config, CytoCommunityConfig):
|
|
109
|
+
config = CytoCommunityConfig.from_json(config)
|
|
110
|
+
paths = build_paths(config)
|
|
111
|
+
output_dir = Path(output_dir or paths.downstream_data / "Coherence")
|
|
112
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
result = analyze_result_tables(paths.result_tables, pas_k=pas_k)
|
|
114
|
+
result.to_csv(output_dir / "config.csv", index=False)
|
|
115
|
+
return result
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Within- and between-CN communication analyses."""
|
|
2
|
+
|
|
3
|
+
from .between import run_between_cn_analysis
|
|
4
|
+
from .cca import run_cca_analysis
|
|
5
|
+
from .within import run_within_cn_analysis
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"run_between_cn_analysis",
|
|
9
|
+
"run_cca_analysis",
|
|
10
|
+
"run_within_cn_analysis",
|
|
11
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Spearman analysis for top CCA-derived between-CN pairs."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from scipy.stats import spearmanr
|
|
8
|
+
|
|
9
|
+
from ...config import CytoCommunityConfig
|
|
10
|
+
from ...paths import build_paths
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_scores_long(scores_long):
|
|
14
|
+
frame = scores_long.copy().dropna(subset=["Sample", "CN", "CellType", "Score"])
|
|
15
|
+
for column in ["Sample", "CN", "CellType"]:
|
|
16
|
+
frame[column] = frame[column].astype(str).str.strip()
|
|
17
|
+
frame["Score"] = pd.to_numeric(frame["Score"], errors="coerce")
|
|
18
|
+
return frame.dropna(subset=["Score"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def select_cca_pairs(cca_pairs, top_n):
|
|
22
|
+
pairs = cca_pairs.copy()
|
|
23
|
+
pairs["CN_A"] = pairs["CN_A"].astype(str).str.strip()
|
|
24
|
+
pairs["CN_B"] = pairs["CN_B"].astype(str).str.strip()
|
|
25
|
+
pairs["rho1"] = pd.to_numeric(pairs["rho1"], errors="coerce")
|
|
26
|
+
pairs = pairs.dropna(subset=["rho1"])
|
|
27
|
+
return pairs.reindex(
|
|
28
|
+
pairs["rho1"].abs().sort_values(ascending=False).index
|
|
29
|
+
).head(top_n)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def analyze_between_cn(
|
|
33
|
+
scores_long, cca_pairs, coordinate_tables, *, minimum_samples, top_n_cca_pairs
|
|
34
|
+
):
|
|
35
|
+
"""Calculate between-CN correlations without filesystem access.
|
|
36
|
+
|
|
37
|
+
``coordinate_tables`` maps ``(CN_A, CN_B)`` string tuples to coordinate
|
|
38
|
+
DataFrames returned by :func:`cca_coordinate_tables`.
|
|
39
|
+
"""
|
|
40
|
+
scores = normalize_scores_long(scores_long)
|
|
41
|
+
pairs = select_cca_pairs(cca_pairs, top_n_cca_pairs)
|
|
42
|
+
rows = []
|
|
43
|
+
for _, pair in pairs.iterrows():
|
|
44
|
+
cn_a, cn_b = str(pair["CN_A"]), str(pair["CN_B"])
|
|
45
|
+
try:
|
|
46
|
+
coordinates = coordinate_tables[(cn_a, cn_b)].copy()
|
|
47
|
+
except KeyError as error:
|
|
48
|
+
raise KeyError(f"Missing CCA coordinate table for CN{cn_a} vs CN{cn_b}") from error
|
|
49
|
+
coordinates["CellType"] = coordinates["CellType"].astype(str).str.strip()
|
|
50
|
+
coordinates_a = coordinates.dropna(subset=["CN_A_coord_can1"])
|
|
51
|
+
coordinates_b = coordinates.dropna(subset=["CN_B_coord_can1"])
|
|
52
|
+
if coordinates_a.empty or coordinates_b.empty:
|
|
53
|
+
raise ValueError(f"No valid CCA coordinate for CN{cn_a} vs CN{cn_b}")
|
|
54
|
+
top_a_index = coordinates_a["CN_A_coord_can1"].abs().idxmax()
|
|
55
|
+
top_b_index = coordinates_b["CN_B_coord_can1"].abs().idxmax()
|
|
56
|
+
top_a = coordinates_a.loc[top_a_index, "CellType"]
|
|
57
|
+
top_b = coordinates_b.loc[top_b_index, "CellType"]
|
|
58
|
+
scores_a = scores.loc[
|
|
59
|
+
(scores["CN"] == cn_a) & (scores["CellType"] == top_a),
|
|
60
|
+
["Sample", "Score"],
|
|
61
|
+
].rename(columns={"Score": "Score_A"})
|
|
62
|
+
scores_b = scores.loc[
|
|
63
|
+
(scores["CN"] == cn_b) & (scores["CellType"] == top_b),
|
|
64
|
+
["Sample", "Score"],
|
|
65
|
+
].rename(columns={"Score": "Score_B"})
|
|
66
|
+
merged = pd.merge(scores_a, scores_b, on="Sample", how="inner")
|
|
67
|
+
if len(merged) < minimum_samples:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Too few overlapping samples for CN{cn_a}({top_a}) vs CN{cn_b}({top_b})"
|
|
70
|
+
)
|
|
71
|
+
if merged["Score_A"].nunique() < 2 or merged["Score_B"].nunique() < 2:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Constant enrichment scores for CN{cn_a}({top_a}) vs CN{cn_b}({top_b})"
|
|
74
|
+
)
|
|
75
|
+
rho, p_value = spearmanr(merged["Score_A"], merged["Score_B"], nan_policy="omit")
|
|
76
|
+
rows.append(
|
|
77
|
+
{
|
|
78
|
+
"CN_A": cn_a,
|
|
79
|
+
"CN_B": cn_b,
|
|
80
|
+
"rho1_CCA": pair["rho1"],
|
|
81
|
+
"pval1_CCA": pair["pval1"],
|
|
82
|
+
"rho2_CCA": pair["rho2"] if "rho2" in pairs else np.nan,
|
|
83
|
+
"pval2_CCA": pair["pval2"] if "pval2" in pairs else np.nan,
|
|
84
|
+
"Top_CellType_A": top_a,
|
|
85
|
+
"Top_CellType_B": top_b,
|
|
86
|
+
"TopA_coord_can1": coordinates_a.loc[top_a_index, "CN_A_coord_can1"],
|
|
87
|
+
"TopB_coord_can1": coordinates_b.loc[top_b_index, "CN_B_coord_can1"],
|
|
88
|
+
"Spearman_rho": rho,
|
|
89
|
+
"Spearman_p": p_value,
|
|
90
|
+
"N_overlap": len(merged),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
result = pd.DataFrame(rows)
|
|
94
|
+
if not result.empty:
|
|
95
|
+
result = result.sort_values(
|
|
96
|
+
"rho1_CCA", key=lambda values: values.abs(), ascending=False
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_between_cn_statistics(config_dir, minimum_samples, top_n_cca_pairs):
|
|
102
|
+
"""Compatibility workflow operating on the established communication folder."""
|
|
103
|
+
config_dir = Path(config_dir)
|
|
104
|
+
scores = pd.read_csv(config_dir / "EnrichScoreMatrix_long.csv")
|
|
105
|
+
pairs = pd.read_csv(config_dir / "CCA_config.csv")
|
|
106
|
+
selected = select_cca_pairs(pairs, top_n_cca_pairs)
|
|
107
|
+
tables = {
|
|
108
|
+
(str(row.CN_A), str(row.CN_B)): pd.read_csv(
|
|
109
|
+
config_dir / f"CCA_coordinates_CN{row.CN_A}_vs_CN{row.CN_B}.csv"
|
|
110
|
+
)
|
|
111
|
+
for row in selected.itertuples(index=False)
|
|
112
|
+
}
|
|
113
|
+
result = analyze_between_cn(
|
|
114
|
+
scores,
|
|
115
|
+
pairs,
|
|
116
|
+
tables,
|
|
117
|
+
minimum_samples=minimum_samples,
|
|
118
|
+
top_n_cca_pairs=top_n_cca_pairs,
|
|
119
|
+
)
|
|
120
|
+
result.to_csv(config_dir / "Spearman_TopCCA_Pairs.csv", index=False)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_between_cn_analysis(config, *, config_dir=None):
|
|
125
|
+
if not isinstance(config, CytoCommunityConfig):
|
|
126
|
+
config = CytoCommunityConfig.from_json(config)
|
|
127
|
+
paths = build_paths(config)
|
|
128
|
+
params = config.downstream["communication"]
|
|
129
|
+
return run_between_cn_statistics(
|
|
130
|
+
config_dir or paths.communication_config,
|
|
131
|
+
params["minimum_samples"],
|
|
132
|
+
params["top_n_cca_pairs"],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
"analyze_between_cn",
|
|
138
|
+
"normalize_scores_long",
|
|
139
|
+
"run_between_cn_analysis",
|
|
140
|
+
"run_between_cn_statistics",
|
|
141
|
+
"select_cca_pairs",
|
|
142
|
+
]
|