milopy 0.3.0__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.
milopy/__init__.py ADDED
@@ -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
+ ]
milopy/core.py ADDED
@@ -0,0 +1,190 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import scipy.sparse as sp
4
+ import scanpy as sc
5
+ import anndata
6
+
7
+ def build_graph(adata, k=30, d=30, **kwargs):
8
+ """
9
+ Builds a kNN graph. Wraps scanpy.pp.neighbors.
10
+ """
11
+ if 'X_pca' not in adata.obsm:
12
+ raise ValueError("PCA must be computed first. Run sc.pp.pca(adata)")
13
+
14
+ sc.pp.neighbors(adata, n_neighbors=k, n_pcs=d, **kwargs)
15
+ return adata
16
+
17
+ def make_nhoods(adata, prop=0.1, k=30, d=30, refined=True, random_state=None):
18
+ """
19
+ Defines neighbourhoods on the kNN graph.
20
+ """
21
+ if 'distances' not in adata.obsp:
22
+ raise ValueError("kNN graph not found. Run build_graph(adata)")
23
+
24
+ n_cells = adata.n_obs
25
+ n_nhoods = int(np.round(n_cells * prop))
26
+
27
+ if random_state is not None:
28
+ np.random.seed(random_state)
29
+
30
+ # Initial random sampling
31
+ vertex_indices = np.random.choice(n_cells, size=n_nhoods, replace=False)
32
+
33
+ knn_graph = adata.obsp['connectivities']
34
+
35
+ if refined:
36
+ # Refinement step: for each random vertex, find its neighbourhood,
37
+ # compute the median profile in PCA space, and pick the closest vertex.
38
+ pca_coords = adata.obsm['X_pca'][:, :d]
39
+ refined_vertices = []
40
+ for v in vertex_indices:
41
+ # Find neighbors of v
42
+ neighbors = knn_graph[v].nonzero()[1]
43
+ # include self
44
+ neighborhood = np.append(neighbors, v)
45
+
46
+ # Compute median profile
47
+ median_profile = np.median(pca_coords[neighborhood], axis=0)
48
+
49
+ # Find vertex in neighborhood closest to median
50
+ distances = np.linalg.norm(pca_coords[neighborhood] - median_profile, axis=1)
51
+ closest_idx = neighborhood[np.argmin(distances)]
52
+ refined_vertices.append(closest_idx)
53
+
54
+ # Remove duplicates
55
+ vertex_indices = np.unique(refined_vertices)
56
+
57
+ # Store neighborhoods as a sparse matrix: cells x nhoods
58
+ rows = []
59
+ cols = []
60
+
61
+ for i, v in enumerate(vertex_indices):
62
+ neighbors = knn_graph[v].nonzero()[1]
63
+ neighborhood = np.append(neighbors, v)
64
+ rows.extend(neighborhood)
65
+ cols.extend([i] * len(neighborhood))
66
+
67
+ nhoods_mat = sp.coo_matrix((np.ones(len(rows)), (rows, cols)), shape=(n_cells, len(vertex_indices))).tocsc()
68
+
69
+ adata.obsm['nhoods'] = nhoods_mat
70
+ adata.uns['nhood_indices'] = vertex_indices
71
+ return adata
72
+
73
+ def count_cells(adata, sample_col):
74
+ """
75
+ Counts cells in each neighbourhood across samples.
76
+ """
77
+ if 'nhoods' not in adata.obsm:
78
+ raise ValueError("Neighborhoods not found. Run make_nhoods(adata)")
79
+
80
+ if sample_col not in adata.obs.columns:
81
+ raise ValueError(f"Sample column '{sample_col}' not found in adata.obs")
82
+
83
+ samples = adata.obs[sample_col].astype('category')
84
+ sample_categories = samples.cat.categories
85
+ sample_codes = samples.cat.codes.values
86
+
87
+ n_nhoods = adata.obsm['nhoods'].shape[1]
88
+ n_samples = len(sample_categories)
89
+
90
+ # Initialize count matrix
91
+ counts = np.zeros((n_nhoods, n_samples))
92
+
93
+ nhoods_mat = adata.obsm['nhoods'].tocsc()
94
+
95
+ for i in range(n_nhoods):
96
+ cells_in_nhood = nhoods_mat[:, i].nonzero()[0]
97
+ # Count cells per sample in this neighborhood
98
+ nhood_sample_codes = sample_codes[cells_in_nhood]
99
+ counts[i, :] = np.bincount(nhood_sample_codes, minlength=n_samples)
100
+
101
+ count_df = pd.DataFrame(counts, columns=sample_categories)
102
+
103
+ # store in uns
104
+ adata.uns['nhood_counts'] = count_df
105
+ return adata
106
+
107
+ def calc_nhood_distance(adata, d=30):
108
+ """
109
+ Calculates distances between neighbourhoods based on overlap or PCA distance.
110
+ """
111
+ if 'nhoods' not in adata.obsm:
112
+ raise ValueError("Neighborhoods not found. Run make_nhoods(adata)")
113
+
114
+ nhoods_mat = adata.obsm['nhoods'].tocsc()
115
+ pca_coords = adata.obsm['X_pca'][:, :d]
116
+
117
+ n_nhoods = nhoods_mat.shape[1]
118
+
119
+ # compute median of each neighborhood
120
+ nhood_medians = np.zeros((n_nhoods, d))
121
+ for i in range(n_nhoods):
122
+ cells = nhoods_mat[:, i].nonzero()[0]
123
+ nhood_medians[i, :] = np.median(pca_coords[cells, :], axis=0)
124
+
125
+ # compute euclidean distance between medians
126
+ from scipy.spatial.distance import pdist, squareform
127
+ dists = pdist(nhood_medians, metric='euclidean')
128
+ dist_mat = squareform(dists)
129
+
130
+ adata.uns['nhood_distances'] = dist_mat
131
+ return adata
132
+
133
+ def test_nhoods(adata, design, design_df, model_contrasts=None):
134
+ """
135
+ Tests for differential abundance using edgepython (pure Python edgeR port).
136
+ `design` is a formula string like '~ condition'
137
+ `design_df` is a pandas DataFrame with row names matching sample columns in `nhood_counts`
138
+ """
139
+ if 'nhood_counts' not in adata.uns:
140
+ raise ValueError("Neighborhood counts not found. Run count_cells(adata, sample_col)")
141
+
142
+ import edgepython as ep
143
+ import patsy
144
+
145
+ # Counts are nhoods x samples
146
+ counts_df = adata.uns['nhood_counts']
147
+
148
+ # Ensure design_df is aligned with counts_df columns
149
+ design_df = design_df.loc[counts_df.columns]
150
+
151
+ # edgepython expects genes x samples (rows=features, cols=samples)
152
+ # Our counts_df is nhoods x samples, which is already the right orientation
153
+ counts_matrix = counts_df.values.astype(float)
154
+
155
+ # Create DGEList
156
+ y = ep.make_dgelist(counts=counts_matrix)
157
+ y = ep.calc_norm_factors(y, method="TMM")
158
+
159
+ # Create design matrix using patsy
160
+ design_mat = patsy.dmatrix(design, design_df, return_type='dataframe')
161
+ design_array = np.asarray(design_mat, dtype=float)
162
+
163
+ # Estimate dispersion
164
+ y = ep.estimate_disp(y, design=design_array)
165
+
166
+ # Fit QL GLM
167
+ fit = ep.glm_ql_fit(y, design=design_array, robust=True)
168
+
169
+ # Test
170
+ if model_contrasts is not None:
171
+ # Build contrast vector from string like "conditionB - conditionA"
172
+ # For now, support simple column index contrasts
173
+ res = ep.glm_ql_ftest(fit, contrast=model_contrasts)
174
+ else:
175
+ # Default to testing the last coefficient
176
+ n_coefs = design_array.shape[1]
177
+ res = ep.glm_ql_ftest(fit, coef=n_coefs - 1)
178
+
179
+ # Get results table
180
+ top = ep.top_tags(res, n=counts_matrix.shape[0], sort_by="none")
181
+ res_df = top['table']
182
+
183
+ # Ensure it's a DataFrame with the right index
184
+ if not isinstance(res_df, pd.DataFrame):
185
+ res_df = pd.DataFrame(res_df)
186
+
187
+ res_df.index = counts_df.index
188
+ adata.uns['nhood_test_results'] = res_df
189
+ return adata
190
+
milopy/meta.py ADDED
@@ -0,0 +1,147 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import scipy.stats
4
+ from statsmodels.stats.multitest import multipletests
5
+
6
+ def test_nhoods_meta(adata, design, design_df, dataset_col, model_contrasts=None):
7
+ """
8
+ Random-Effects Meta-Analysis for multi-dataset Differential Abundance.
9
+ Runs edgepython GLM independently for each dataset and combines effects using
10
+ Inverse-Variance DerSimonian-Laird meta-analysis.
11
+ """
12
+ if 'nhood_counts' not in adata.uns:
13
+ raise ValueError("Neighborhood counts not found. Run count_cells(adata, sample_col)")
14
+
15
+ import edgepython as ep
16
+ import patsy
17
+
18
+ counts_df = adata.uns['nhood_counts']
19
+ design_df = design_df.loc[counts_df.columns]
20
+
21
+ if dataset_col not in design_df.columns:
22
+ raise ValueError(f"Dataset column '{dataset_col}' not found in design_df")
23
+
24
+ datasets = design_df[dataset_col].unique()
25
+
26
+ n_nhoods = counts_df.shape[0]
27
+
28
+ # Store effects and standard errors
29
+ all_logfc = np.zeros((n_nhoods, len(datasets)))
30
+ all_se = np.zeros((n_nhoods, len(datasets)))
31
+ all_logcpm = np.zeros((n_nhoods, len(datasets)))
32
+
33
+ for k, ds in enumerate(datasets):
34
+ ds_samples = design_df[design_df[dataset_col] == ds].index
35
+ ds_counts = counts_df[ds_samples].values.astype(float)
36
+ ds_design = design_df.loc[ds_samples]
37
+
38
+ y = ep.make_dgelist(counts=ds_counts)
39
+ y = ep.calc_norm_factors(y, method="TMM")
40
+
41
+ design_mat = patsy.dmatrix(design, ds_design, return_type='dataframe')
42
+ design_array = np.asarray(design_mat, dtype=float)
43
+
44
+ y = ep.estimate_disp(y, design=design_array)
45
+ fit = ep.glm_ql_fit(y, design=design_array, robust=True)
46
+
47
+ if model_contrasts is not None:
48
+ res = ep.glm_ql_ftest(fit, contrast=model_contrasts)
49
+ else:
50
+ n_coefs = design_array.shape[1]
51
+ res = ep.glm_ql_ftest(fit, coef=n_coefs - 1)
52
+
53
+ top = ep.top_tags(res, n=n_nhoods, sort_by="none")
54
+ res_df = pd.DataFrame(top['table'])
55
+
56
+ # logFC and F-statistic from edgeR/edgepython
57
+ logfc = res_df['logFC'].values
58
+ f_stat = res_df['F'].values
59
+ # standard error from F-stat (assuming 1 DF: F = (beta/se)^2 => se = |beta| / sqrt(F))
60
+ se = np.abs(logfc) / np.sqrt(f_stat + 1e-12) # add small epsilon to avoid div by zero
61
+
62
+ all_logfc[:, k] = logfc
63
+ all_se[:, k] = se
64
+ all_logcpm[:, k] = res_df['logCPM'].values
65
+
66
+ # DerSimonian-Laird Random Effects Meta-Analysis
67
+ variances = all_se**2
68
+ w_fixed = 1.0 / variances
69
+ w_sum = w_fixed.sum(axis=1)
70
+ beta_fixed = (w_fixed * all_logfc).sum(axis=1) / w_sum
71
+
72
+ K = len(datasets)
73
+ Q = (w_fixed * (all_logfc - beta_fixed[:, None])**2).sum(axis=1)
74
+ c = w_sum - (w_fixed**2).sum(axis=1) / w_sum
75
+ tau2 = np.maximum(0, (Q - (K - 1)) / (c + 1e-12))
76
+
77
+ w_random = 1.0 / (variances + tau2[:, None])
78
+ w_random_sum = w_random.sum(axis=1)
79
+
80
+ meta_logfc = (w_random * all_logfc).sum(axis=1) / w_random_sum
81
+ meta_se = np.sqrt(1.0 / w_random_sum)
82
+
83
+ z_stat = meta_logfc / meta_se
84
+ pvals = 2 * scipy.stats.norm.sf(np.abs(z_stat))
85
+ _, fdr, _, _ = multipletests(pvals, method='fdr_bh')
86
+
87
+ meta_res = pd.DataFrame({
88
+ 'logFC': meta_logfc,
89
+ 'SE': meta_se,
90
+ 'logCPM': np.mean(all_logcpm, axis=1),
91
+ 'PValue': pvals,
92
+ 'FDR': fdr,
93
+ 'Tau2': tau2
94
+ }, index=counts_df.index)
95
+
96
+ adata.uns['nhood_test_results'] = meta_res
97
+ return adata
98
+
99
+ def test_nhoods_mixed(adata, design, design_df, dataset_col, model_contrasts=None):
100
+ """
101
+ Negative Binomial Mixed-Effects model for multi-dataset DA.
102
+ Uses edgepython's NEBULA-LN extension (ep.glm_sc_fit) modeling
103
+ 'dataset_col' as the random effect grouping variable.
104
+ """
105
+ if 'nhood_counts' not in adata.uns:
106
+ raise ValueError("Neighborhood counts not found. Run count_cells(adata, sample_col)")
107
+
108
+ import edgepython as ep
109
+ import patsy
110
+
111
+ counts_df = adata.uns['nhood_counts']
112
+ design_df = design_df.loc[counts_df.columns]
113
+
114
+ if dataset_col not in design_df.columns:
115
+ raise ValueError(f"Dataset column '{dataset_col}' not found in design_df")
116
+
117
+ counts_matrix = counts_df.values.astype(float)
118
+
119
+ design_mat = patsy.dmatrix(design, design_df, return_type='dataframe')
120
+ design_array = np.asarray(design_mat, dtype=float)
121
+
122
+ try:
123
+ # Fit the NEBULA-LN model with the random effect (dataset_col)
124
+ fit = ep.glm_sc_fit(
125
+ y=counts_matrix,
126
+ cell_meta=design_df,
127
+ design=design_array,
128
+ sample=dataset_col,
129
+ norm_method='TMM'
130
+ )
131
+ except AttributeError:
132
+ raise NotImplementedError("ep.glm_sc_fit (NEBULA-LN) is missing or not compiled in this edgepython version.")
133
+
134
+ n_nhoods = counts_matrix.shape[0]
135
+
136
+ if model_contrasts is not None:
137
+ res = ep.glm_ql_ftest(fit, contrast=model_contrasts)
138
+ else:
139
+ n_coefs = design_array.shape[1]
140
+ res = ep.glm_ql_ftest(fit, coef=n_coefs - 1)
141
+
142
+ top = ep.top_tags(res, n=n_nhoods, sort_by="none")
143
+ res_df = pd.DataFrame(top['table'])
144
+ res_df.index = counts_df.index
145
+
146
+ adata.uns['nhood_test_results'] = res_df
147
+ return adata
@@ -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
+ ```
@@ -0,0 +1,7 @@
1
+ milopy/__init__.py,sha256=BILZtDYnfYxayIqwS0v0qGphuQsDbH6pA2jcpB8u-BU,349
2
+ milopy/core.py,sha256=wcog_bwowOOY_13Y95mpVvjgfXk0SPtVLCYKWExL9dk,6641
3
+ milopy/meta.py,sha256=QWvwNMUAGL_Mauk_5mbaMvqcnWBjmex6Tzv0PIZzavI,5359
4
+ milopy-0.3.0.dist-info/METADATA,sha256=ujuOrRdnuVdtayjLtg9PcE8XZXgRNNJ3t6LYPH8LCzE,856
5
+ milopy-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
6
+ milopy-0.3.0.dist-info/licenses/LICENSE,sha256=_EeNUEP026NojJHSowCSC-yS5ZnUuGyN8Ym175Sbj_g,1073
7
+ milopy-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.