milopy 0.3.0__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.
- milopy-0.3.0/.gitignore +6 -0
- milopy-0.3.0/LICENSE +21 -0
- milopy-0.3.0/PKG-INFO +32 -0
- milopy-0.3.0/README.md +9 -0
- milopy-0.3.0/pyproject.toml +34 -0
- milopy-0.3.0/scripts/compare_milo.py +114 -0
- milopy-0.3.0/scripts/correlate_pipelines.py +122 -0
- milopy-0.3.0/scripts/diagnose_divergence.py +208 -0
- milopy-0.3.0/src/milopy/__init__.py +21 -0
- milopy-0.3.0/src/milopy/core.py +190 -0
- milopy-0.3.0/src/milopy/meta.py +147 -0
- milopy-0.3.0/tests/__init__.py +0 -0
- milopy-0.3.0/tests/conftest.py +35 -0
- milopy-0.3.0/tests/test_core.py +99 -0
- milopy-0.3.0/uv.lock +3867 -0
milopy-0.3.0/.gitignore
ADDED
milopy-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Lukas J. Haeuser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
milopy-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: milopy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A native Python implementation of miloR for differential abundance testing
|
|
5
|
+
Project-URL: Homepage, https://github.com/LuJoHae/milopy
|
|
6
|
+
Project-URL: Repository, https://github.com/LuJoHae/milopy.git
|
|
7
|
+
Author-email: LuJoHae <lukas.j.haeuser@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: anndata
|
|
12
|
+
Requires-Dist: edgepython
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pandas
|
|
15
|
+
Requires-Dist: patsy
|
|
16
|
+
Requires-Dist: scanpy
|
|
17
|
+
Requires-Dist: scipy
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: anndata2ri; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest; extra == 'test'
|
|
21
|
+
Requires-Dist: rpy2; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# milopy
|
|
25
|
+
|
|
26
|
+
A native Python implementation of miloR for differential abundance testing of single-cell datasets.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e .
|
|
32
|
+
```
|
milopy-0.3.0/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "milopy"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A native Python implementation of miloR for differential abundance testing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "LuJoHae", email = "lukas.j.haeuser@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy",
|
|
17
|
+
"pandas",
|
|
18
|
+
"scipy",
|
|
19
|
+
"scanpy",
|
|
20
|
+
"anndata",
|
|
21
|
+
"edgepython",
|
|
22
|
+
"patsy"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
test = [
|
|
27
|
+
"pytest",
|
|
28
|
+
"rpy2",
|
|
29
|
+
"anndata2ri"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/LuJoHae/milopy"
|
|
34
|
+
Repository = "https://github.com/LuJoHae/milopy.git"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import matplotlib.pyplot as plt
|
|
2
|
+
import scanpy as sc
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy.sparse import csr_matrix
|
|
6
|
+
import time
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
# Generate synthetic dataset
|
|
10
|
+
np.random.seed(42)
|
|
11
|
+
n_cells = 3000
|
|
12
|
+
n_genes = 200
|
|
13
|
+
n_samples = 6
|
|
14
|
+
|
|
15
|
+
print("Generating synthetic data...")
|
|
16
|
+
# Base counts
|
|
17
|
+
counts = np.random.poisson(lam=1.5, size=(n_cells, n_genes))
|
|
18
|
+
X = csr_matrix(counts)
|
|
19
|
+
|
|
20
|
+
samples = np.random.choice([f"Sample_{i}" for i in range(n_samples)], size=n_cells)
|
|
21
|
+
conditions = np.array(["ConditionA" if int(s.split("_")[1]) < 3 else "ConditionB" for s in samples])
|
|
22
|
+
|
|
23
|
+
obs = pd.DataFrame({
|
|
24
|
+
"sample": samples,
|
|
25
|
+
"condition": conditions
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
adata = sc.AnnData(X=X, obs=obs)
|
|
29
|
+
|
|
30
|
+
# Let's add some artificial DA effect by modifying PCA coords for ConditionB
|
|
31
|
+
sc.pp.pca(adata, n_comps=30)
|
|
32
|
+
|
|
33
|
+
# Cells in ConditionB have a slight shift in PCA space to create DA
|
|
34
|
+
is_condB = adata.obs['condition'] == 'ConditionB'
|
|
35
|
+
adata.obsm['X_pca'][is_condB, 0] += 2.0
|
|
36
|
+
|
|
37
|
+
sc.pp.neighbors(adata, n_neighbors=30)
|
|
38
|
+
sc.tl.umap(adata)
|
|
39
|
+
|
|
40
|
+
print("Running milopy pipeline...")
|
|
41
|
+
import milopy
|
|
42
|
+
t0 = time.time()
|
|
43
|
+
milopy.build_graph(adata, k=30, d=30)
|
|
44
|
+
milopy.make_nhoods(adata, prop=0.1, k=30, d=30, random_state=42)
|
|
45
|
+
milopy.count_cells(adata, sample_col='sample')
|
|
46
|
+
milopy.calc_nhood_distance(adata, d=30)
|
|
47
|
+
design_df = pd.DataFrame({'condition': ['ConditionA', 'ConditionA', 'ConditionA', 'ConditionB', 'ConditionB', 'ConditionB']},
|
|
48
|
+
index=[f"Sample_{i}" for i in range(6)])
|
|
49
|
+
milopy.test_nhoods(adata, design='~ condition', design_df=design_df)
|
|
50
|
+
py_time = time.time() - t0
|
|
51
|
+
print(f"milopy took {py_time:.2f}s")
|
|
52
|
+
|
|
53
|
+
py_res = adata.uns['nhood_test_results']
|
|
54
|
+
|
|
55
|
+
print("Running miloR pipeline (via rpy2)...")
|
|
56
|
+
import rpy2.robjects as ro
|
|
57
|
+
from rpy2.robjects import pandas2ri
|
|
58
|
+
from rpy2.robjects import default_converter
|
|
59
|
+
from rpy2.robjects.conversion import localconverter
|
|
60
|
+
import anndata2ri
|
|
61
|
+
from rpy2.robjects.packages import importr
|
|
62
|
+
|
|
63
|
+
milor = importr('miloR')
|
|
64
|
+
scater = importr('scater')
|
|
65
|
+
base = importr('base')
|
|
66
|
+
|
|
67
|
+
adata_r = adata.copy()
|
|
68
|
+
adata_r.uns = {} # Clear uns to avoid anndata2ri conversion errors on nested dicts from scanpy PCA
|
|
69
|
+
t0 = time.time()
|
|
70
|
+
with localconverter(default_converter + pandas2ri.converter + anndata2ri.converter):
|
|
71
|
+
r_sce = ro.conversion.py2rpy(adata_r)
|
|
72
|
+
r_design_df = ro.conversion.py2rpy(design_df)
|
|
73
|
+
|
|
74
|
+
ro.globalenv['r_sce'] = r_sce
|
|
75
|
+
ro.globalenv['design_df'] = r_design_df
|
|
76
|
+
|
|
77
|
+
ro.r('''
|
|
78
|
+
library(miloR)
|
|
79
|
+
library(SingleCellExperiment)
|
|
80
|
+
|
|
81
|
+
milo <- Milo(r_sce)
|
|
82
|
+
milo <- buildGraph(milo, k=30, d=30, reduced.dim="PCA")
|
|
83
|
+
set.seed(42)
|
|
84
|
+
milo <- makeNhoods(milo, prop=0.1, k=30, d=30, refined=TRUE)
|
|
85
|
+
milo <- countCells(milo, meta.data=as.data.frame(colData(milo)), sample="sample")
|
|
86
|
+
milo <- calcNhoodDistance(milo, d=30, reduced.dim="PCA")
|
|
87
|
+
|
|
88
|
+
design_mat <- model.matrix(~ condition, data=design_df)
|
|
89
|
+
res <- testNhoods(milo, design=design_mat, design.df=design_df)
|
|
90
|
+
''')
|
|
91
|
+
r_time = time.time() - t0
|
|
92
|
+
print(f"miloR took {r_time:.2f}s")
|
|
93
|
+
|
|
94
|
+
with localconverter(default_converter + pandas2ri.converter):
|
|
95
|
+
r_res = ro.conversion.rpy2py(ro.globalenv['res'])
|
|
96
|
+
|
|
97
|
+
print("Plotting comparison...")
|
|
98
|
+
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
|
99
|
+
|
|
100
|
+
# Plot Py Results
|
|
101
|
+
axes[0].hist(py_res['logFC'], bins=30, alpha=0.7, color='blue', label='milopy')
|
|
102
|
+
axes[0].set_title('milopy logFC Distribution')
|
|
103
|
+
axes[0].set_xlabel('logFC')
|
|
104
|
+
axes[0].set_ylabel('Frequency')
|
|
105
|
+
|
|
106
|
+
# Plot R Results
|
|
107
|
+
axes[1].hist(r_res['logFC'], bins=30, alpha=0.7, color='red', label='miloR')
|
|
108
|
+
axes[1].set_title('miloR logFC Distribution')
|
|
109
|
+
axes[1].set_xlabel('logFC')
|
|
110
|
+
axes[1].set_ylabel('Frequency')
|
|
111
|
+
|
|
112
|
+
plt.tight_layout()
|
|
113
|
+
plt.savefig('/Users/halu/Code/milo/milo_comparison.png')
|
|
114
|
+
print("Saved comparison plot to /Users/halu/Code/milo/milo_comparison.png")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Correlation pipeline: Force R neighborhoods into milopy (now using edgepython)
|
|
3
|
+
and compare all 5 outputs.
|
|
4
|
+
"""
|
|
5
|
+
import scanpy as sc
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from scipy.sparse import csr_matrix, csc_matrix
|
|
9
|
+
from scipy.stats import pearsonr
|
|
10
|
+
|
|
11
|
+
# Generate synthetic dataset
|
|
12
|
+
np.random.seed(42)
|
|
13
|
+
n_cells = 3000
|
|
14
|
+
n_genes = 200
|
|
15
|
+
n_samples = 6
|
|
16
|
+
|
|
17
|
+
print("=== Correlation Pipeline: milopy (edgepython) vs miloR (R) ===\n")
|
|
18
|
+
|
|
19
|
+
counts = np.random.poisson(lam=1.5, size=(n_cells, n_genes))
|
|
20
|
+
X = csr_matrix(counts)
|
|
21
|
+
samples = np.random.choice([f"Sample_{i}" for i in range(n_samples)], size=n_cells)
|
|
22
|
+
conditions = np.array(["ConditionA" if int(s.split("_")[1]) < 3 else "ConditionB" for s in samples])
|
|
23
|
+
obs = pd.DataFrame({"sample": samples, "condition": conditions})
|
|
24
|
+
adata = sc.AnnData(X=X, obs=obs)
|
|
25
|
+
|
|
26
|
+
sc.pp.pca(adata, n_comps=30)
|
|
27
|
+
is_condB = adata.obs['condition'] == 'ConditionB'
|
|
28
|
+
adata.obsm['X_pca'][is_condB, 0] += 2.0
|
|
29
|
+
sc.pp.neighbors(adata, n_neighbors=30)
|
|
30
|
+
|
|
31
|
+
# ---- Run miloR in R ----
|
|
32
|
+
print("Running miloR (R)...")
|
|
33
|
+
import rpy2.robjects as ro
|
|
34
|
+
from rpy2.robjects import pandas2ri, numpy2ri
|
|
35
|
+
from rpy2.robjects import default_converter
|
|
36
|
+
from rpy2.robjects.conversion import localconverter
|
|
37
|
+
import anndata2ri
|
|
38
|
+
from rpy2.robjects.packages import importr
|
|
39
|
+
|
|
40
|
+
milor = importr('miloR')
|
|
41
|
+
|
|
42
|
+
adata_r = adata.copy()
|
|
43
|
+
adata_r.uns = {}
|
|
44
|
+
with localconverter(default_converter + pandas2ri.converter + anndata2ri.converter):
|
|
45
|
+
r_sce = ro.conversion.py2rpy(adata_r)
|
|
46
|
+
|
|
47
|
+
design_df = pd.DataFrame(
|
|
48
|
+
{'condition': ['ConditionA']*3 + ['ConditionB']*3},
|
|
49
|
+
index=[f"Sample_{i}" for i in range(6)]
|
|
50
|
+
)
|
|
51
|
+
with localconverter(default_converter + pandas2ri.converter):
|
|
52
|
+
r_design_df = ro.conversion.py2rpy(design_df)
|
|
53
|
+
|
|
54
|
+
ro.globalenv['r_sce'] = r_sce
|
|
55
|
+
ro.globalenv['design_df'] = r_design_df
|
|
56
|
+
|
|
57
|
+
ro.r('''
|
|
58
|
+
library(miloR)
|
|
59
|
+
library(SingleCellExperiment)
|
|
60
|
+
|
|
61
|
+
milo <- Milo(r_sce)
|
|
62
|
+
milo <- buildGraph(milo, k=30, d=30, reduced.dim="PCA")
|
|
63
|
+
set.seed(42)
|
|
64
|
+
milo <- makeNhoods(milo, prop=0.1, k=30, d=30, refined=TRUE)
|
|
65
|
+
milo <- countCells(milo, meta.data=as.data.frame(colData(milo)), sample="sample")
|
|
66
|
+
milo <- calcNhoodDistance(milo, d=30, reduced.dim="PCA")
|
|
67
|
+
|
|
68
|
+
design_mat <- model.matrix(~ condition, data=design_df)
|
|
69
|
+
r_test_res <- testNhoods(milo, design=design_mat, design.df=design_df)
|
|
70
|
+
|
|
71
|
+
# Extract R artifacts
|
|
72
|
+
r_nhood_mat <- as.matrix(nhoods(milo))
|
|
73
|
+
r_nhood_counts_dense <- as.matrix(nhoodCounts(milo))
|
|
74
|
+
''')
|
|
75
|
+
|
|
76
|
+
with localconverter(default_converter + pandas2ri.converter):
|
|
77
|
+
r_test_res = ro.conversion.rpy2py(ro.globalenv['r_test_res'])
|
|
78
|
+
with localconverter(default_converter + numpy2ri.converter):
|
|
79
|
+
r_nhood_mat = ro.conversion.rpy2py(ro.globalenv['r_nhood_mat'])
|
|
80
|
+
r_nhood_counts_dense = ro.conversion.rpy2py(ro.globalenv['r_nhood_counts_dense'])
|
|
81
|
+
|
|
82
|
+
print(f" R: {r_nhood_mat.shape[1]} neighborhoods")
|
|
83
|
+
print(f" R test columns: {list(r_test_res.columns)}")
|
|
84
|
+
|
|
85
|
+
# ---- Force R neighborhoods into milopy ----
|
|
86
|
+
print("\nForcing R neighborhoods into milopy pipeline...")
|
|
87
|
+
import milopy
|
|
88
|
+
|
|
89
|
+
adata_forced = adata.copy()
|
|
90
|
+
adata_forced.obsm['nhoods'] = csc_matrix(r_nhood_mat)
|
|
91
|
+
adata_forced.uns['nhood_indices'] = np.array([
|
|
92
|
+
np.where(r_nhood_mat[:, j] > 0)[0][0] for j in range(r_nhood_mat.shape[1])
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
milopy.count_cells(adata_forced, sample_col='sample')
|
|
96
|
+
milopy.calc_nhood_distance(adata_forced, d=30)
|
|
97
|
+
milopy.test_nhoods(adata_forced, design='~ condition', design_df=design_df)
|
|
98
|
+
|
|
99
|
+
py_res = adata_forced.uns['nhood_test_results']
|
|
100
|
+
print(f" Py: {len(py_res)} neighborhoods")
|
|
101
|
+
print(f" Py test columns: {list(py_res.columns)}")
|
|
102
|
+
|
|
103
|
+
# ---- Compare ----
|
|
104
|
+
print("\n=== Correlations (forced same neighborhoods) ===")
|
|
105
|
+
metrics = ['logFC', 'logCPM', 'F', 'PValue', 'FDR']
|
|
106
|
+
for metric in metrics:
|
|
107
|
+
if metric in py_res.columns and metric in r_test_res.columns:
|
|
108
|
+
py_vals = py_res[metric].values
|
|
109
|
+
r_vals = r_test_res[metric].values
|
|
110
|
+
if len(py_vals) == len(r_vals):
|
|
111
|
+
corr, pval = pearsonr(py_vals, r_vals)
|
|
112
|
+
exact = np.allclose(py_vals, r_vals, atol=1e-10)
|
|
113
|
+
max_diff = np.max(np.abs(py_vals - r_vals))
|
|
114
|
+
print(f" {metric:8s}: r={corr:.6f}, exact={exact}, max_diff={max_diff:.2e}")
|
|
115
|
+
else:
|
|
116
|
+
print(f" {metric}: size mismatch {len(py_vals)} vs {len(r_vals)}")
|
|
117
|
+
|
|
118
|
+
# Also show first few rows side-by-side
|
|
119
|
+
print("\n--- R results (first 5) ---")
|
|
120
|
+
print(r_test_res[['logFC','logCPM','F','PValue','FDR']].head())
|
|
121
|
+
print("\n--- Py results (first 5) ---")
|
|
122
|
+
print(py_res[['logFC','logCPM','F','PValue','FDR']].head())
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diagnostic script: Force the SAME neighborhoods into both pipelines
|
|
3
|
+
to isolate whether divergence comes from:
|
|
4
|
+
(A) neighborhood sampling differences, or
|
|
5
|
+
(B) downstream logic (count_cells, calc_nhood_distance, test_nhoods)
|
|
6
|
+
"""
|
|
7
|
+
import scanpy as sc
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from scipy.sparse import csr_matrix, csc_matrix
|
|
11
|
+
from scipy.stats import pearsonr
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
# ---- Generate synthetic dataset (identical to before) ----
|
|
15
|
+
np.random.seed(42)
|
|
16
|
+
n_cells = 3000
|
|
17
|
+
n_genes = 200
|
|
18
|
+
n_samples = 6
|
|
19
|
+
|
|
20
|
+
print("=== DIAGNOSTIC: Isolating sources of divergence ===\n")
|
|
21
|
+
|
|
22
|
+
counts = np.random.poisson(lam=1.5, size=(n_cells, n_genes))
|
|
23
|
+
X = csr_matrix(counts)
|
|
24
|
+
samples = np.random.choice([f"Sample_{i}" for i in range(n_samples)], size=n_cells)
|
|
25
|
+
conditions = np.array(["ConditionA" if int(s.split("_")[1]) < 3 else "ConditionB" for s in samples])
|
|
26
|
+
obs = pd.DataFrame({"sample": samples, "condition": conditions})
|
|
27
|
+
adata = sc.AnnData(X=X, obs=obs)
|
|
28
|
+
|
|
29
|
+
sc.pp.pca(adata, n_comps=30)
|
|
30
|
+
is_condB = adata.obs['condition'] == 'ConditionB'
|
|
31
|
+
adata.obsm['X_pca'][is_condB, 0] += 2.0
|
|
32
|
+
sc.pp.neighbors(adata, n_neighbors=30)
|
|
33
|
+
|
|
34
|
+
# ---- Step 1: Run miloR to get its neighborhoods ----
|
|
35
|
+
print("Step 1: Running miloR to extract its neighborhoods...")
|
|
36
|
+
import rpy2.robjects as ro
|
|
37
|
+
from rpy2.robjects import pandas2ri, numpy2ri
|
|
38
|
+
from rpy2.robjects import default_converter
|
|
39
|
+
from rpy2.robjects.conversion import localconverter
|
|
40
|
+
import anndata2ri
|
|
41
|
+
from rpy2.robjects.packages import importr
|
|
42
|
+
|
|
43
|
+
milor = importr('miloR')
|
|
44
|
+
base = importr('base')
|
|
45
|
+
matrix_pkg = importr('Matrix')
|
|
46
|
+
|
|
47
|
+
adata_r = adata.copy()
|
|
48
|
+
adata_r.uns = {}
|
|
49
|
+
with localconverter(default_converter + pandas2ri.converter + anndata2ri.converter):
|
|
50
|
+
r_sce = ro.conversion.py2rpy(adata_r)
|
|
51
|
+
|
|
52
|
+
design_df = pd.DataFrame(
|
|
53
|
+
{'condition': ['ConditionA']*3 + ['ConditionB']*3},
|
|
54
|
+
index=[f"Sample_{i}" for i in range(6)]
|
|
55
|
+
)
|
|
56
|
+
with localconverter(default_converter + pandas2ri.converter):
|
|
57
|
+
r_design_df = ro.conversion.py2rpy(design_df)
|
|
58
|
+
|
|
59
|
+
ro.globalenv['r_sce'] = r_sce
|
|
60
|
+
ro.globalenv['design_df'] = r_design_df
|
|
61
|
+
|
|
62
|
+
ro.r('''
|
|
63
|
+
library(miloR)
|
|
64
|
+
library(SingleCellExperiment)
|
|
65
|
+
|
|
66
|
+
milo <- Milo(r_sce)
|
|
67
|
+
milo <- buildGraph(milo, k=30, d=30, reduced.dim="PCA")
|
|
68
|
+
set.seed(42)
|
|
69
|
+
milo <- makeNhoods(milo, prop=0.1, k=30, d=30, refined=TRUE)
|
|
70
|
+
milo <- countCells(milo, meta.data=as.data.frame(colData(milo)), sample="sample")
|
|
71
|
+
milo <- calcNhoodDistance(milo, d=30, reduced.dim="PCA")
|
|
72
|
+
|
|
73
|
+
design_mat <- model.matrix(~ condition, data=design_df)
|
|
74
|
+
r_test_res <- testNhoods(milo, design=design_mat, design.df=design_df)
|
|
75
|
+
|
|
76
|
+
# Extract R nhood matrix as dense
|
|
77
|
+
r_nhood_mat <- as.matrix(nhoods(milo))
|
|
78
|
+
r_nhood_counts <- nhoodCounts(milo)
|
|
79
|
+
r_nhood_counts_dense <- as.matrix(r_nhood_counts)
|
|
80
|
+
|
|
81
|
+
# Extract nhood distances
|
|
82
|
+
r_nhood_dists <- as.matrix(nhoodDistances(milo))
|
|
83
|
+
''')
|
|
84
|
+
|
|
85
|
+
# Pull R results into Python
|
|
86
|
+
with localconverter(default_converter + pandas2ri.converter):
|
|
87
|
+
r_test_res = ro.conversion.rpy2py(ro.globalenv['r_test_res'])
|
|
88
|
+
|
|
89
|
+
with localconverter(default_converter + numpy2ri.converter):
|
|
90
|
+
r_nhood_mat = ro.conversion.rpy2py(ro.globalenv['r_nhood_mat'])
|
|
91
|
+
r_nhood_counts_dense = ro.conversion.rpy2py(ro.globalenv['r_nhood_counts_dense'])
|
|
92
|
+
|
|
93
|
+
r_n_nhoods = r_nhood_mat.shape[1]
|
|
94
|
+
print(f" R produced {r_n_nhoods} neighborhoods")
|
|
95
|
+
print(f" R nhood_mat shape: {r_nhood_mat.shape}")
|
|
96
|
+
print(f" R nhood_counts shape: {r_nhood_counts_dense.shape}")
|
|
97
|
+
print(f" R test_res shape: {r_test_res.shape}")
|
|
98
|
+
print(f" R test_res columns: {list(r_test_res.columns)}")
|
|
99
|
+
print(f" R test_res head:\n{r_test_res.head()}\n")
|
|
100
|
+
|
|
101
|
+
# ---- Step 2: Run milopy on same data ----
|
|
102
|
+
print("Step 2: Running milopy pipeline...")
|
|
103
|
+
import milopy
|
|
104
|
+
milopy.build_graph(adata, k=30, d=30)
|
|
105
|
+
milopy.make_nhoods(adata, prop=0.1, k=30, d=30, random_state=42)
|
|
106
|
+
milopy.count_cells(adata, sample_col='sample')
|
|
107
|
+
milopy.calc_nhood_distance(adata, d=30)
|
|
108
|
+
milopy.test_nhoods(adata, design='~ condition', design_df=design_df)
|
|
109
|
+
|
|
110
|
+
py_res = adata.uns['nhood_test_results']
|
|
111
|
+
py_nhood_mat = adata.obsm['nhoods']
|
|
112
|
+
if hasattr(py_nhood_mat, 'toarray'):
|
|
113
|
+
py_nhood_mat_dense = py_nhood_mat.toarray()
|
|
114
|
+
else:
|
|
115
|
+
py_nhood_mat_dense = py_nhood_mat
|
|
116
|
+
py_nhood_counts = adata.uns['nhood_counts']
|
|
117
|
+
|
|
118
|
+
py_n_nhoods = py_nhood_mat_dense.shape[1]
|
|
119
|
+
print(f" Py produced {py_n_nhoods} neighborhoods")
|
|
120
|
+
print(f" Py test_res columns: {list(py_res.columns)}")
|
|
121
|
+
print(f" Py test_res head:\n{py_res.head()}\n")
|
|
122
|
+
|
|
123
|
+
# ---- Step 3: Diagnose - Force R neighborhoods into Python pipeline ----
|
|
124
|
+
print("Step 3: Force R neighborhoods into Python pipeline and rerun downstream...")
|
|
125
|
+
|
|
126
|
+
adata_forced = adata.copy()
|
|
127
|
+
# Set the nhoods matrix to be the R nhoods
|
|
128
|
+
adata_forced.obsm['nhoods'] = csc_matrix(r_nhood_mat)
|
|
129
|
+
# Derive nhood indices from the R nhood matrix (index cells)
|
|
130
|
+
r_nhood_indices = []
|
|
131
|
+
for j in range(r_nhood_mat.shape[1]):
|
|
132
|
+
col = r_nhood_mat[:, j]
|
|
133
|
+
members = np.where(col > 0)[0]
|
|
134
|
+
r_nhood_indices.append(members[0]) # placeholder
|
|
135
|
+
r_nhood_indices = np.array(r_nhood_indices)
|
|
136
|
+
adata_forced.uns['nhood_indices'] = r_nhood_indices
|
|
137
|
+
|
|
138
|
+
# Rerun count_cells
|
|
139
|
+
milopy.count_cells(adata_forced, sample_col='sample')
|
|
140
|
+
py_forced_counts = adata_forced.uns['nhood_counts']
|
|
141
|
+
|
|
142
|
+
print(f" Forced Py nhood_counts shape: {py_forced_counts.shape}")
|
|
143
|
+
print(f" R nhood_counts shape: {r_nhood_counts_dense.shape}")
|
|
144
|
+
|
|
145
|
+
# Compare count matrices
|
|
146
|
+
# The R count matrix has samples as rows, nhoods as cols (or vice versa)
|
|
147
|
+
# Let's check
|
|
148
|
+
print(f"\n R counts sample (first 5x5):\n{r_nhood_counts_dense[:5, :5]}")
|
|
149
|
+
print(f" Py forced counts sample (first 5 rows):\n{py_forced_counts.head()}")
|
|
150
|
+
|
|
151
|
+
# Try to align: R nhood_counts might be samples x nhoods or nhoods x samples
|
|
152
|
+
# Our count_cells produces nhoods x samples
|
|
153
|
+
r_counts_df = pd.DataFrame(r_nhood_counts_dense)
|
|
154
|
+
print(f"\n R counts shape: {r_counts_df.shape}")
|
|
155
|
+
print(f" Py counts shape: {py_forced_counts.shape}")
|
|
156
|
+
|
|
157
|
+
# If R is samples x nhoods, transpose it
|
|
158
|
+
if r_counts_df.shape[0] == n_samples:
|
|
159
|
+
print(" R counts appears to be samples x nhoods, transposing...")
|
|
160
|
+
r_counts_df = r_counts_df.T
|
|
161
|
+
|
|
162
|
+
# Now both should be nhoods x samples
|
|
163
|
+
print(f" Aligned R counts shape: {r_counts_df.shape}")
|
|
164
|
+
print(f" Aligned Py counts shape: {py_forced_counts.shape}")
|
|
165
|
+
|
|
166
|
+
# Compare element-by-element
|
|
167
|
+
r_counts_vals = r_counts_df.values.flatten().astype(float)
|
|
168
|
+
py_counts_vals = py_forced_counts.values.flatten().astype(float)
|
|
169
|
+
|
|
170
|
+
if len(r_counts_vals) == len(py_counts_vals):
|
|
171
|
+
count_corr, count_p = pearsonr(r_counts_vals, py_counts_vals)
|
|
172
|
+
count_exact = np.allclose(r_counts_vals, py_counts_vals)
|
|
173
|
+
print(f"\n count_cells comparison (forced same nhoods):")
|
|
174
|
+
print(f" Pearson r = {count_corr:.6f}")
|
|
175
|
+
print(f" Exact match: {count_exact}")
|
|
176
|
+
print(f" Max abs diff: {np.max(np.abs(r_counts_vals - py_counts_vals))}")
|
|
177
|
+
else:
|
|
178
|
+
print(f" Cannot compare: different sizes {len(r_counts_vals)} vs {len(py_counts_vals)}")
|
|
179
|
+
|
|
180
|
+
# ---- Step 4: Run test_nhoods on the forced counts ----
|
|
181
|
+
print("\nStep 4: Running test_nhoods with forced R neighborhoods...")
|
|
182
|
+
milopy.calc_nhood_distance(adata_forced, d=30)
|
|
183
|
+
milopy.test_nhoods(adata_forced, design='~ condition', design_df=design_df)
|
|
184
|
+
py_forced_res = adata_forced.uns['nhood_test_results']
|
|
185
|
+
|
|
186
|
+
print(f" Py forced test_res shape: {py_forced_res.shape}")
|
|
187
|
+
print(f" R test_res shape: {r_test_res.shape}")
|
|
188
|
+
|
|
189
|
+
# Compare all 5 metrics
|
|
190
|
+
metrics = ['logFC', 'logCPM', 'F', 'PValue', 'FDR']
|
|
191
|
+
print(f"\n=== FINAL: Correlations with FORCED same neighborhoods ===")
|
|
192
|
+
for metric in metrics:
|
|
193
|
+
if metric in py_forced_res.columns and metric in r_test_res.columns:
|
|
194
|
+
py_vals = py_forced_res[metric].values
|
|
195
|
+
r_vals = r_test_res[metric].values
|
|
196
|
+
if len(py_vals) == len(r_vals):
|
|
197
|
+
corr, pval = pearsonr(py_vals, r_vals)
|
|
198
|
+
exact = np.allclose(py_vals, r_vals, atol=1e-10)
|
|
199
|
+
max_diff = np.max(np.abs(py_vals - r_vals))
|
|
200
|
+
print(f" {metric}: r={corr:.6f}, exact={exact}, max_diff={max_diff:.2e}")
|
|
201
|
+
else:
|
|
202
|
+
print(f" {metric}: size mismatch {len(py_vals)} vs {len(r_vals)}")
|
|
203
|
+
else:
|
|
204
|
+
print(f" {metric}: missing from one or both results")
|
|
205
|
+
|
|
206
|
+
# Also check if the issue is the SpatialFDR
|
|
207
|
+
if 'SpatialFDR' in r_test_res.columns:
|
|
208
|
+
print(f"\n NOTE: R has 'SpatialFDR' column. Columns in R result: {list(r_test_res.columns)}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
build_graph,
|
|
3
|
+
make_nhoods,
|
|
4
|
+
count_cells,
|
|
5
|
+
calc_nhood_distance,
|
|
6
|
+
test_nhoods,
|
|
7
|
+
)
|
|
8
|
+
from .meta import (
|
|
9
|
+
test_nhoods_meta,
|
|
10
|
+
test_nhoods_mixed,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"build_graph",
|
|
15
|
+
"make_nhoods",
|
|
16
|
+
"count_cells",
|
|
17
|
+
"calc_nhood_distance",
|
|
18
|
+
"test_nhoods",
|
|
19
|
+
"test_nhoods_meta",
|
|
20
|
+
"test_nhoods_mixed",
|
|
21
|
+
]
|