pydecontx 0.1.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.
- pydecontx/__init__.py +45 -0
- pydecontx/_core.py +223 -0
- pydecontx/_dirichlet.py +96 -0
- pydecontx/decontx.py +362 -0
- pydecontx/simulate.py +130 -0
- pydecontx-0.1.0.dist-info/METADATA +356 -0
- pydecontx-0.1.0.dist-info/RECORD +10 -0
- pydecontx-0.1.0.dist-info/WHEEL +5 -0
- pydecontx-0.1.0.dist-info/licenses/LICENSE +202 -0
- pydecontx-0.1.0.dist-info/top_level.txt +1 -0
pydecontx/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""pydecontx -- pure-Python port of the Bioconductor ``decontX`` package.
|
|
2
|
+
|
|
3
|
+
DecontX (Yang et al., *Genome Biology* 2020) identifies and removes
|
|
4
|
+
ambient-RNA / cross-contamination from droplet single-cell RNA-seq.
|
|
5
|
+
Each cell's observed UMI counts are modelled as a Bayesian
|
|
6
|
+
two-component multinomial mixture of a *native* gene distribution
|
|
7
|
+
``phi`` and a *contamination* distribution ``eta`` (a weighted blend of
|
|
8
|
+
every other population). Inference is by variational EM and yields a
|
|
9
|
+
per-cell contamination fraction and a decontaminated count matrix.
|
|
10
|
+
|
|
11
|
+
Main entry points
|
|
12
|
+
-----------------
|
|
13
|
+
- :func:`decontx` -- run DecontX on a count matrix / AnnData.
|
|
14
|
+
- :class:`DecontXResult` -- the result object.
|
|
15
|
+
- :func:`simulate_contamination` -- simulate a contaminated dataset.
|
|
16
|
+
|
|
17
|
+
Lower-level routines (variational-EM building blocks) are available in
|
|
18
|
+
:mod:`pydecontx._core` and the Dirichlet MLE in
|
|
19
|
+
:mod:`pydecontx._dirichlet`.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ._core import (
|
|
24
|
+
calculate_native_matrix,
|
|
25
|
+
decontx_em,
|
|
26
|
+
decontx_initialize,
|
|
27
|
+
decontx_loglik,
|
|
28
|
+
)
|
|
29
|
+
from ._dirichlet import fit_dirichlet
|
|
30
|
+
from .decontx import DecontXResult, decontx
|
|
31
|
+
from .simulate import simulate_contamination
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"decontx",
|
|
37
|
+
"DecontXResult",
|
|
38
|
+
"simulate_contamination",
|
|
39
|
+
"decontx_initialize",
|
|
40
|
+
"decontx_em",
|
|
41
|
+
"decontx_loglik",
|
|
42
|
+
"calculate_native_matrix",
|
|
43
|
+
"fit_dirichlet",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
pydecontx/_core.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Core variational-EM routines for DecontX.
|
|
2
|
+
|
|
3
|
+
Pure-Python / numpy / scipy port of the C++ inner loops of the
|
|
4
|
+
Bioconductor ``decontX`` package (``src/DecontX.cpp``):
|
|
5
|
+
|
|
6
|
+
* :func:`decontx_initialize` -- initial native (``phi``) and contamination
|
|
7
|
+
(``eta``) gene distributions from a random ``theta``.
|
|
8
|
+
* :func:`decontx_em` -- one variational-EM step updating ``phi``, ``eta``,
|
|
9
|
+
``theta`` and (optionally) the Dirichlet hyper-parameter ``delta``.
|
|
10
|
+
* :func:`decontx_loglik` -- the two-component multinomial log-likelihood.
|
|
11
|
+
* :func:`calculate_native_matrix` -- the decontaminated count matrix.
|
|
12
|
+
|
|
13
|
+
All matrices follow the R convention: ``counts`` is genes-by-cells
|
|
14
|
+
(rows = genes, columns = cells); ``phi`` / ``eta`` are genes-by-clusters.
|
|
15
|
+
``z`` holds 1-based integer cluster labels (one per cell).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import scipy.sparse as sp
|
|
21
|
+
|
|
22
|
+
from ._dirichlet import fit_dirichlet
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"decontx_initialize",
|
|
26
|
+
"decontx_em",
|
|
27
|
+
"decontx_loglik",
|
|
28
|
+
"calculate_native_matrix",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_csc(counts) -> sp.csc_matrix:
|
|
33
|
+
"""Return ``counts`` as a float CSC sparse matrix (genes x cells)."""
|
|
34
|
+
if sp.issparse(counts):
|
|
35
|
+
return counts.tocsc().astype(float)
|
|
36
|
+
return sp.csc_matrix(np.asarray(counts, dtype=float))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decontx_initialize(counts, theta, z, pseudocount: float = 1e-20):
|
|
40
|
+
"""Initialise the native (``phi``) and contamination (``eta``) matrices.
|
|
41
|
+
|
|
42
|
+
Port of the C++ ``decontXInitialize``. ``phi[:, k]`` accumulates
|
|
43
|
+
``theta_j * counts`` over all cells ``j`` assigned to cluster ``k``;
|
|
44
|
+
``eta`` is the row-sum complement (every *other* cluster's signal),
|
|
45
|
+
and both are column-normalised to proportions.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
counts : (G, C) array or sparse matrix
|
|
50
|
+
Gene-by-cell UMI counts.
|
|
51
|
+
theta : (C,) array
|
|
52
|
+
Initial native proportion for each cell.
|
|
53
|
+
z : (C,) int array
|
|
54
|
+
1-based cluster label for each cell.
|
|
55
|
+
pseudocount : float
|
|
56
|
+
Added to every cell of ``phi``/``eta`` before normalising.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
dict with keys ``phi`` and ``eta`` -- (G, K) numpy arrays.
|
|
61
|
+
"""
|
|
62
|
+
counts = _as_csc(counts)
|
|
63
|
+
theta = np.asarray(theta, dtype=float)
|
|
64
|
+
z = np.asarray(z, dtype=int)
|
|
65
|
+
G, C = counts.shape
|
|
66
|
+
K = int(z.max())
|
|
67
|
+
|
|
68
|
+
phi = np.full((G, K), pseudocount, dtype=float)
|
|
69
|
+
indptr, indices, data = counts.indptr, counts.indices, counts.data
|
|
70
|
+
for j in range(C):
|
|
71
|
+
k = z[j] - 1
|
|
72
|
+
start, end = indptr[j], indptr[j + 1]
|
|
73
|
+
rows = indices[start:end]
|
|
74
|
+
vals = data[start:end] * theta[j]
|
|
75
|
+
np.add.at(phi[:, k], rows, vals)
|
|
76
|
+
|
|
77
|
+
phi_rowsum = phi.sum(axis=1)
|
|
78
|
+
eta = phi_rowsum[:, None] - phi
|
|
79
|
+
|
|
80
|
+
phi = phi / phi.sum(axis=0, keepdims=True)
|
|
81
|
+
eta = eta / eta.sum(axis=0, keepdims=True)
|
|
82
|
+
return {"phi": phi, "eta": eta}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decontx_em(counts, counts_colsums, theta, eta, phi, z,
|
|
86
|
+
estimate_eta: bool = True, estimate_delta: bool = True,
|
|
87
|
+
delta=(10.0, 10.0), pseudocount: float = 1e-20):
|
|
88
|
+
"""One variational-EM update of the DecontX model.
|
|
89
|
+
|
|
90
|
+
Port of the C++ ``decontXEM``. For every observed transcript the
|
|
91
|
+
variational native/contaminant responsibility is
|
|
92
|
+
|
|
93
|
+
``p_native = (phi[i,k] + pc) * (theta[j] + pc)``
|
|
94
|
+
``p_contam = (eta[i,k] + pc) * (1 - theta[j] + pc)``
|
|
95
|
+
``normp = p_native / (p_native + p_contam)``
|
|
96
|
+
|
|
97
|
+
(the non-log form -- exact for a two-component mixture and what the
|
|
98
|
+
C++ code uses). The native mass ``normp * x`` is accumulated into the
|
|
99
|
+
new ``phi`` by cluster; ``eta`` is the row-sum complement. ``theta``
|
|
100
|
+
is then the posterior mean of a Beta/Dirichlet with concentration
|
|
101
|
+
``delta``, which is itself re-estimated by :func:`fit_dirichlet`.
|
|
102
|
+
|
|
103
|
+
Returns a dict with the updated ``phi``, ``eta``, ``theta``,
|
|
104
|
+
``delta`` and the per-cell ``contamination`` fraction.
|
|
105
|
+
"""
|
|
106
|
+
counts = _as_csc(counts)
|
|
107
|
+
theta = np.asarray(theta, dtype=float)
|
|
108
|
+
counts_colsums = np.asarray(counts_colsums, dtype=float)
|
|
109
|
+
phi = np.asarray(phi, dtype=float)
|
|
110
|
+
eta = np.asarray(eta, dtype=float)
|
|
111
|
+
z = np.asarray(z, dtype=int)
|
|
112
|
+
delta = np.asarray(delta, dtype=float)
|
|
113
|
+
|
|
114
|
+
G, C = counts.shape
|
|
115
|
+
K = phi.shape[1]
|
|
116
|
+
|
|
117
|
+
new_phi = np.zeros((G, K), dtype=float)
|
|
118
|
+
native_total = np.zeros(C, dtype=float)
|
|
119
|
+
|
|
120
|
+
indptr, indices, data = counts.indptr, counts.indices, counts.data
|
|
121
|
+
for j in range(C):
|
|
122
|
+
k = z[j] - 1
|
|
123
|
+
start, end = indptr[j], indptr[j + 1]
|
|
124
|
+
if start == end:
|
|
125
|
+
continue
|
|
126
|
+
rows = indices[start:end]
|
|
127
|
+
x = data[start:end]
|
|
128
|
+
p_native = (phi[rows, k] + pseudocount) * (theta[j] + pseudocount)
|
|
129
|
+
p_contam = (eta[rows, k] + pseudocount) * (1.0 - theta[j] + pseudocount)
|
|
130
|
+
normp = p_native / (p_native + p_contam)
|
|
131
|
+
px = normp * x
|
|
132
|
+
np.add.at(new_phi[:, k], rows, px)
|
|
133
|
+
native_total[j] = px.sum()
|
|
134
|
+
|
|
135
|
+
if estimate_eta:
|
|
136
|
+
phi_rowsum = new_phi.sum(axis=1)
|
|
137
|
+
new_eta = phi_rowsum[:, None] - new_phi
|
|
138
|
+
else:
|
|
139
|
+
new_eta = eta
|
|
140
|
+
|
|
141
|
+
new_phi = new_phi / new_phi.sum(axis=0, keepdims=True)
|
|
142
|
+
if estimate_eta:
|
|
143
|
+
new_eta = new_eta / new_eta.sum(axis=0, keepdims=True)
|
|
144
|
+
|
|
145
|
+
# Update theta (and optionally its Dirichlet hyper-parameter delta).
|
|
146
|
+
contamination_prop = (counts_colsums - native_total) / counts_colsums
|
|
147
|
+
native_prop = 1.0 - contamination_prop
|
|
148
|
+
new_delta = delta
|
|
149
|
+
if estimate_delta:
|
|
150
|
+
theta_raw = np.column_stack([native_prop, contamination_prop])
|
|
151
|
+
new_delta = fit_dirichlet(theta_raw)["alpha"]
|
|
152
|
+
|
|
153
|
+
new_theta = (native_total + new_delta[0]) / (counts_colsums
|
|
154
|
+
+ np.sum(new_delta))
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"phi": new_phi,
|
|
158
|
+
"eta": new_eta,
|
|
159
|
+
"theta": new_theta,
|
|
160
|
+
"delta": new_delta,
|
|
161
|
+
"contamination": contamination_prop,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def decontx_loglik(counts, theta, eta, phi, z, pseudocount: float = 1e-20):
|
|
166
|
+
"""Two-component multinomial log-likelihood of the DecontX model.
|
|
167
|
+
|
|
168
|
+
Port of the C++ ``decontXLogLik``:
|
|
169
|
+
``ll = sum_{i,j} x_{ij} * log(phi*theta + eta*(1-theta) + pc)``.
|
|
170
|
+
"""
|
|
171
|
+
counts = _as_csc(counts)
|
|
172
|
+
theta = np.asarray(theta, dtype=float)
|
|
173
|
+
phi = np.asarray(phi, dtype=float)
|
|
174
|
+
eta = np.asarray(eta, dtype=float)
|
|
175
|
+
z = np.asarray(z, dtype=int)
|
|
176
|
+
|
|
177
|
+
loglik = 0.0
|
|
178
|
+
indptr, indices, data = counts.indptr, counts.indices, counts.data
|
|
179
|
+
for j in range(counts.shape[1]):
|
|
180
|
+
k = z[j] - 1
|
|
181
|
+
start, end = indptr[j], indptr[j + 1]
|
|
182
|
+
if start == end:
|
|
183
|
+
continue
|
|
184
|
+
rows = indices[start:end]
|
|
185
|
+
x = data[start:end]
|
|
186
|
+
mix = (phi[rows, k] * theta[j]
|
|
187
|
+
+ eta[rows, k] * (1.0 - theta[j]) + pseudocount)
|
|
188
|
+
loglik += float(np.sum(x * np.log(mix)))
|
|
189
|
+
return loglik
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def calculate_native_matrix(counts, theta, eta, phi, z,
|
|
193
|
+
pseudocount: float = 1e-20) -> sp.csc_matrix:
|
|
194
|
+
"""Return the decontaminated (native) count matrix.
|
|
195
|
+
|
|
196
|
+
Port of the C++ ``calculateNativeMatrix``: each observed entry is
|
|
197
|
+
scaled by its variational native responsibility ``normp``. Values
|
|
198
|
+
may be non-integer; round for integer counts.
|
|
199
|
+
"""
|
|
200
|
+
counts = _as_csc(counts).copy()
|
|
201
|
+
theta = np.asarray(theta, dtype=float)
|
|
202
|
+
phi = np.asarray(phi, dtype=float)
|
|
203
|
+
eta = np.asarray(eta, dtype=float)
|
|
204
|
+
z = np.asarray(z, dtype=int)
|
|
205
|
+
|
|
206
|
+
indptr, indices, data = counts.indptr, counts.indices, counts.data
|
|
207
|
+
out = data.copy()
|
|
208
|
+
for j in range(counts.shape[1]):
|
|
209
|
+
k = z[j] - 1
|
|
210
|
+
start, end = indptr[j], indptr[j + 1]
|
|
211
|
+
if start == end:
|
|
212
|
+
continue
|
|
213
|
+
rows = indices[start:end]
|
|
214
|
+
p_native = np.log(phi[rows, k] + pseudocount) + np.log(theta[j]
|
|
215
|
+
+ pseudocount)
|
|
216
|
+
p_contam = np.log(eta[rows, k] + pseudocount) + np.log(
|
|
217
|
+
1.0 - theta[j] + pseudocount)
|
|
218
|
+
normp = np.exp(p_native) / (np.exp(p_contam) + np.exp(p_native))
|
|
219
|
+
out[start:end] = data[start:end] * normp
|
|
220
|
+
|
|
221
|
+
native = sp.csc_matrix((out, indices.copy(), indptr.copy()),
|
|
222
|
+
shape=counts.shape)
|
|
223
|
+
return native
|
pydecontx/_dirichlet.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Dirichlet maximum-likelihood estimation.
|
|
2
|
+
|
|
3
|
+
A pure-Python port of ``MCMCprecision::fit_dirichlet`` -- the fixed-point
|
|
4
|
+
iteration of Minka (2000) used by R ``decontX`` to update the per-cell
|
|
5
|
+
``delta`` (theta) concentration parameters at every EM iteration.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy.special import digamma, polygamma
|
|
11
|
+
|
|
12
|
+
# Euler-Mascheroni constant ( -psi(1) ).
|
|
13
|
+
_EULER_GAMMA = -digamma(1.0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def inv_digamma(y: np.ndarray, iter: int = 5) -> np.ndarray:
|
|
17
|
+
"""Inverse of the digamma function (Minka, 2000).
|
|
18
|
+
|
|
19
|
+
Mirrors ``MCMCprecision::inv_digamma``: starts from ``exp(y) + 0.5``
|
|
20
|
+
(or ``-1/(y + gamma)`` when ``y < -2.22``) and applies a few Newton
|
|
21
|
+
steps using the trigamma function.
|
|
22
|
+
"""
|
|
23
|
+
y = np.asarray(y, dtype=float)
|
|
24
|
+
x = np.exp(y) + 0.5
|
|
25
|
+
mask = y < -2.22
|
|
26
|
+
x[mask] = -1.0 / (y[mask] + _EULER_GAMMA)
|
|
27
|
+
for _ in range(iter):
|
|
28
|
+
x = x - (digamma(x) - y) / polygamma(1, x)
|
|
29
|
+
return x
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dirichlet_fp(alpha: np.ndarray, logx_mean: np.ndarray,
|
|
33
|
+
maxit: int = 100000, abstol: float = 1e-5) -> np.ndarray:
|
|
34
|
+
"""Fixed-point iteration for Dirichlet MLE (Minka, 2000).
|
|
35
|
+
|
|
36
|
+
Port of ``MCMCprecision::dirichlet_fp``.
|
|
37
|
+
"""
|
|
38
|
+
alpha = np.asarray(alpha, dtype=float).copy()
|
|
39
|
+
logx_mean = np.asarray(logx_mean, dtype=float)
|
|
40
|
+
cnt = 0
|
|
41
|
+
diff = 1.0
|
|
42
|
+
while (diff > abstol) and (cnt < maxit):
|
|
43
|
+
alpha0 = alpha
|
|
44
|
+
alpha = inv_digamma(digamma(np.sum(alpha0)) + logx_mean)
|
|
45
|
+
cnt += 1
|
|
46
|
+
diff = np.max(np.abs(alpha0 - alpha))
|
|
47
|
+
return alpha
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fit_dirichlet(x: np.ndarray, const: float | None = None,
|
|
51
|
+
maxit: int = 100000, abstol: float = 0.1) -> dict:
|
|
52
|
+
"""Estimate the parameters of a Dirichlet distribution.
|
|
53
|
+
|
|
54
|
+
Pure-Python port of ``MCMCprecision::fit_dirichlet`` (Minka 2000
|
|
55
|
+
fixed-point algorithm). ``x`` is a 2-D array of Dirichlet samples,
|
|
56
|
+
one observation per row.
|
|
57
|
+
|
|
58
|
+
Returns a dict with keys ``alpha`` (the estimated concentration
|
|
59
|
+
vector) and ``sum`` (its sum). The default ``abstol`` of 0.1 matches
|
|
60
|
+
the R default used inside ``decontX``.
|
|
61
|
+
"""
|
|
62
|
+
x = np.asarray(x, dtype=float)
|
|
63
|
+
if x.ndim != 2:
|
|
64
|
+
raise ValueError("'x' must be a 2-D array of Dirichlet samples.")
|
|
65
|
+
|
|
66
|
+
if x.min() == 0:
|
|
67
|
+
if const is None:
|
|
68
|
+
const = x[x > 0].min() * 0.01
|
|
69
|
+
x = (x + const) / (1.0 + 2.0 * const)
|
|
70
|
+
|
|
71
|
+
x = x / x.sum(axis=1, keepdims=True)
|
|
72
|
+
logx_mean = np.mean(np.log(x), axis=0)
|
|
73
|
+
|
|
74
|
+
# Heuristic starting values (method of moments).
|
|
75
|
+
x_mean = np.mean(x, axis=0)
|
|
76
|
+
x_squares = np.mean(x ** 2, axis=0)
|
|
77
|
+
denom = x_squares - x_mean ** 2
|
|
78
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
79
|
+
xi = (x_mean - x_squares) / denom
|
|
80
|
+
alpha0 = xi * x_mean
|
|
81
|
+
|
|
82
|
+
alpha = None
|
|
83
|
+
try:
|
|
84
|
+
cand = dirichlet_fp(np.maximum(0.01, alpha0), logx_mean,
|
|
85
|
+
maxit=maxit, abstol=abstol)
|
|
86
|
+
if not np.any(np.isnan(cand)):
|
|
87
|
+
alpha = cand
|
|
88
|
+
except Exception: # pragma: no cover - fall through to random init
|
|
89
|
+
alpha = None
|
|
90
|
+
|
|
91
|
+
if alpha is None or np.any(np.isnan(alpha)):
|
|
92
|
+
rng = np.random.default_rng()
|
|
93
|
+
alpha = dirichlet_fp(rng.uniform(0.5, 1.0, size=len(alpha0)),
|
|
94
|
+
logx_mean, maxit=maxit, abstol=abstol)
|
|
95
|
+
|
|
96
|
+
return {"alpha": alpha, "sum": float(np.sum(alpha))}
|