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 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
@@ -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))}