pyedger 0.2.0__tar.gz → 0.2.2__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.
- {pyedger-0.2.0 → pyedger-0.2.2}/PKG-INFO +5 -2
- {pyedger-0.2.0 → pyedger-0.2.2}/README.md +2 -1
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger/__init__.py +8 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger/_glm_batch.py +33 -2
- pyedger-0.2.2/pyedger/_numba_kernels.py +810 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger/_ql_adjust.py +30 -4
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger/core.py +72 -14
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger.egg-info/PKG-INFO +5 -2
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger.egg-info/SOURCES.txt +3 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger.egg-info/requires.txt +3 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyproject.toml +2 -1
- pyedger-0.2.2/tests/test_numba_ql.py +66 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/tests/test_r_parity.py +2 -1
- pyedger-0.2.2/tests/test_robust_disp.py +47 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/LICENSE +0 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger/_limma.py +0 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger/_locfit.py +0 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger.egg-info/dependency_links.txt +0 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/pyedger.egg-info/top_level.txt +0 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/setup.cfg +0 -0
- {pyedger-0.2.0 → pyedger-0.2.2}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyedger
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Pure-Python port of Bioconductor edgeR — negative-binomial models for differential expression of count data (estimateDisp, glmFit, glmLRT, glmQLFTest, exactTest).
|
|
5
5
|
Author-email: Zehua Zeng <starlitnightly@163.com>
|
|
6
6
|
License: LGPL-3.0-or-later
|
|
@@ -22,6 +22,8 @@ License-File: LICENSE
|
|
|
22
22
|
Requires-Dist: numpy>=1.23
|
|
23
23
|
Requires-Dist: scipy>=1.10
|
|
24
24
|
Requires-Dist: pandas>=1.5
|
|
25
|
+
Provides-Extra: fast
|
|
26
|
+
Requires-Dist: numba>=0.57; extra == "fast"
|
|
25
27
|
Provides-Extra: dev
|
|
26
28
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
29
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -43,7 +45,8 @@ A **pure-Python port of [Bioconductor edgeR](https://bioconductor.org/packages/r
|
|
|
43
45
|
## Install
|
|
44
46
|
|
|
45
47
|
```bash
|
|
46
|
-
pip install pyedger
|
|
48
|
+
pip install pyedger # NumPy implementation
|
|
49
|
+
pip install "pyedger[fast]" # + numba kernels (several times faster, parallel over genes)
|
|
47
50
|
```
|
|
48
51
|
|
|
49
52
|
## Quick start
|
|
@@ -13,7 +13,8 @@ A **pure-Python port of [Bioconductor edgeR](https://bioconductor.org/packages/r
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
pip install pyedger
|
|
16
|
+
pip install pyedger # NumPy implementation
|
|
17
|
+
pip install "pyedger[fast]" # + numba kernels (several times faster, parallel over genes)
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## Quick start
|
|
@@ -16,12 +16,14 @@ from .core import (
|
|
|
16
16
|
estimate_common_disp,
|
|
17
17
|
estimate_disp,
|
|
18
18
|
estimate_glm_common_disp,
|
|
19
|
+
estimate_glm_robust_disp,
|
|
19
20
|
estimate_glm_tagwise_disp,
|
|
20
21
|
estimate_glm_trended_disp,
|
|
21
22
|
estimate_tagwise_disp,
|
|
22
23
|
exact_test,
|
|
23
24
|
filter_by_expr,
|
|
24
25
|
glm_fit,
|
|
26
|
+
glm_residuals,
|
|
25
27
|
glm_lrt,
|
|
26
28
|
glm_ql_fit,
|
|
27
29
|
glm_qlf_test,
|
|
@@ -38,6 +40,8 @@ estimateCommonDisp = estimate_common_disp
|
|
|
38
40
|
estimateTagwiseDisp = estimate_tagwise_disp
|
|
39
41
|
estimateDisp = estimate_disp
|
|
40
42
|
estimateGLMCommonDisp = estimate_glm_common_disp
|
|
43
|
+
estimateGLMRobustDisp = estimate_glm_robust_disp
|
|
44
|
+
glmResiduals = glm_residuals
|
|
41
45
|
estimateGLMTagwiseDisp = estimate_glm_tagwise_disp
|
|
42
46
|
estimateGLMTrendedDisp = estimate_glm_trended_disp
|
|
43
47
|
exactTest = exact_test
|
|
@@ -55,6 +59,10 @@ topTags = top_tags
|
|
|
55
59
|
aveLogCPM = ave_log_cpm
|
|
56
60
|
|
|
57
61
|
__all__ = [
|
|
62
|
+
"estimate_glm_robust_disp",
|
|
63
|
+
"estimateGLMRobustDisp",
|
|
64
|
+
"glm_residuals",
|
|
65
|
+
"glmResiduals",
|
|
58
66
|
"DGEExact",
|
|
59
67
|
"DGEGLM",
|
|
60
68
|
"DGELRT",
|
|
@@ -113,7 +113,13 @@ def design_as_factor(X):
|
|
|
113
113
|
|
|
114
114
|
def mglm_one_group(Y, Off, Phi, W, start=None, maxit=50, tol=1e-10):
|
|
115
115
|
"""glm_one_group.cpp (vectorised): returns beta (n,), -inf for all-zero rows."""
|
|
116
|
+
from . import _numba_kernels as nk
|
|
116
117
|
n, s = Y.shape
|
|
118
|
+
if nk.HAVE_NUMBA and n:
|
|
119
|
+
Y = np.ascontiguousarray(Y, dtype=float)
|
|
120
|
+
st = np.zeros(n) if start is None else np.ascontiguousarray(np.broadcast_to(np.asarray(start, dtype=float), (n,)))
|
|
121
|
+
return nk.one_group_nb(Y, _full(Off, Y.shape), _full(Phi, Y.shape), _full(W, Y.shape), st,
|
|
122
|
+
start is not None, int(maxit), float(tol))
|
|
117
123
|
nonzero = (Y > LOW_VALUE).any(axis=1)
|
|
118
124
|
num = np.where(Y > LOW_VALUE, Y / np.exp(Off) * W, 0.0).sum(axis=1)
|
|
119
125
|
with np.errstate(divide="ignore"):
|
|
@@ -171,8 +177,23 @@ def levenberg_start(Y, Off, Phi, W, X):
|
|
|
171
177
|
return c[:, None] * coef_of_one[None, :]
|
|
172
178
|
|
|
173
179
|
|
|
180
|
+
def _full(a, shape):
|
|
181
|
+
return np.ascontiguousarray(np.broadcast_to(np.asarray(a, dtype=float), shape))
|
|
182
|
+
|
|
183
|
+
|
|
174
184
|
def fit_levenberg(Y, Off, Phi, W, X, beta, tol=1e-6, maxit=200):
|
|
175
|
-
"""glm_levenberg.cpp
|
|
185
|
+
"""glm_levenberg.cpp per gene. Returns beta, mu, deviance, iter, failed."""
|
|
186
|
+
from . import _numba_kernels as nk
|
|
187
|
+
if nk.HAVE_NUMBA:
|
|
188
|
+
Y = np.ascontiguousarray(Y, dtype=float)
|
|
189
|
+
X = np.ascontiguousarray(X, dtype=float)
|
|
190
|
+
return nk.fit_levenberg_nb(Y, _full(Off, Y.shape), _full(Phi, Y.shape), _full(W, Y.shape), X,
|
|
191
|
+
_full(beta, (Y.shape[0], X.shape[1])), float(tol), int(maxit))
|
|
192
|
+
return _fit_levenberg_np(Y, Off, Phi, W, X, beta, tol=tol, maxit=maxit)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _fit_levenberg_np(Y, Off, Phi, W, X, beta, tol=1e-6, maxit=200):
|
|
196
|
+
"""glm_levenberg.cpp, vectorised across genes (NumPy fallback)."""
|
|
176
197
|
Y = np.asarray(Y, dtype=float)
|
|
177
198
|
n, s = Y.shape
|
|
178
199
|
p = X.shape[1]
|
|
@@ -303,7 +324,17 @@ def resid_df(zero, X):
|
|
|
303
324
|
|
|
304
325
|
# --------------------------------------------------------------------------- adjusted profile likelihood
|
|
305
326
|
def compute_apl(Y, Mu, Phi, W, X, adjust=True):
|
|
306
|
-
"""R_compute_apl.cpp
|
|
327
|
+
"""R_compute_apl.cpp."""
|
|
328
|
+
from . import _numba_kernels as nk
|
|
329
|
+
if nk.HAVE_NUMBA:
|
|
330
|
+
Y = np.ascontiguousarray(Y, dtype=float)
|
|
331
|
+
return nk.compute_apl_nb(Y, _full(Mu, Y.shape), _full(Phi, Y.shape), _full(W, Y.shape),
|
|
332
|
+
np.ascontiguousarray(X, dtype=float), bool(adjust))
|
|
333
|
+
return _compute_apl_np(Y, Mu, Phi, W, X, adjust)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _compute_apl_np(Y, Mu, Phi, W, X, adjust=True):
|
|
337
|
+
"""R_compute_apl.cpp (vectorised NumPy fallback)."""
|
|
307
338
|
Y = np.asarray(Y, dtype=float)
|
|
308
339
|
pos = Mu != 0
|
|
309
340
|
w = W
|
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
"""Compiled per-gene kernels (numba), parallel over genes.
|
|
2
|
+
|
|
3
|
+
These follow edgeR's C code gene by gene, as the vectorised NumPy versions in
|
|
4
|
+
``_glm_batch`` do, but without (genes x samples) temporaries. They are used
|
|
5
|
+
when numba is installed; set ``PYEDGER_NO_NUMBA=1`` to force the NumPy path.
|
|
6
|
+
The number of threads follows numba's ``NUMBA_NUM_THREADS``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
if os.environ.get("PYEDGER_NO_NUMBA"):
|
|
17
|
+
raise ImportError
|
|
18
|
+
from numba import njit, prange
|
|
19
|
+
HAVE_NUMBA = True
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
HAVE_NUMBA = False
|
|
22
|
+
|
|
23
|
+
LOW_VALUE = 1e-10
|
|
24
|
+
LOG_LOW_VALUE = math.log(LOW_VALUE)
|
|
25
|
+
|
|
26
|
+
if HAVE_NUMBA:
|
|
27
|
+
|
|
28
|
+
@njit(cache=True, fastmath=False)
|
|
29
|
+
def _unit_dev(y, mu, phi):
|
|
30
|
+
# compute_nbdev.c compute_unit_nb_deviance (edgeR 4.10)
|
|
31
|
+
y = y + 1e-8
|
|
32
|
+
mu = mu + 1e-8
|
|
33
|
+
if phi < 1e-4:
|
|
34
|
+
r = y - mu
|
|
35
|
+
# C writes 2/3*resid: integer division, == 0
|
|
36
|
+
out = 2 * (y * math.log(y / mu) - r - 0.5 * r * r * phi * (1 + phi * (0.0 * r - y)))
|
|
37
|
+
else:
|
|
38
|
+
prod = mu * phi
|
|
39
|
+
if prod > 1e6:
|
|
40
|
+
out = 2 * ((y - mu) / mu - math.log(y / mu)) * mu / (1 + prod)
|
|
41
|
+
else:
|
|
42
|
+
inv = 1.0 / phi
|
|
43
|
+
out = 2 * (y * math.log(y / mu) + (y + inv) * math.log((mu + inv) / (y + inv)))
|
|
44
|
+
return out if out > 0.0 else 0.0
|
|
45
|
+
|
|
46
|
+
@njit(cache=True)
|
|
47
|
+
def _gene_dev(y, mu, phi, w):
|
|
48
|
+
d = 0.0
|
|
49
|
+
for j in range(y.size):
|
|
50
|
+
d += w[j] * _unit_dev(y[j], mu[j], phi[j])
|
|
51
|
+
return d
|
|
52
|
+
|
|
53
|
+
@njit(cache=True)
|
|
54
|
+
def _chol(A, L):
|
|
55
|
+
# unpivoted Cholesky; False where LAPACK dpotrf would fail (pivot <= 0 / NaN)
|
|
56
|
+
p = A.shape[0]
|
|
57
|
+
for j in range(p):
|
|
58
|
+
s = A[j, j]
|
|
59
|
+
for k in range(j):
|
|
60
|
+
s -= L[j, k] * L[j, k]
|
|
61
|
+
if not (s > 0.0):
|
|
62
|
+
return False
|
|
63
|
+
d = math.sqrt(s)
|
|
64
|
+
L[j, j] = d
|
|
65
|
+
for i in range(j + 1, p):
|
|
66
|
+
t = A[i, j]
|
|
67
|
+
for k in range(j):
|
|
68
|
+
t -= L[i, k] * L[j, k]
|
|
69
|
+
L[i, j] = t / d
|
|
70
|
+
for i in range(j):
|
|
71
|
+
L[i, j] = 0.0
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
@njit(cache=True)
|
|
75
|
+
def _chol_solve1(L, b, x):
|
|
76
|
+
p = b.size
|
|
77
|
+
z = np.empty(p)
|
|
78
|
+
for i in range(p):
|
|
79
|
+
t = b[i]
|
|
80
|
+
for k in range(i):
|
|
81
|
+
t -= L[i, k] * z[k]
|
|
82
|
+
z[i] = t / L[i, i]
|
|
83
|
+
for i in range(p - 1, -1, -1):
|
|
84
|
+
t = z[i]
|
|
85
|
+
for k in range(i + 1, p):
|
|
86
|
+
t -= L[k, i] * x[k]
|
|
87
|
+
x[i] = t / L[i, i]
|
|
88
|
+
|
|
89
|
+
@njit(cache=True)
|
|
90
|
+
def _fit_mu(off, X, beta, mu):
|
|
91
|
+
s, p = X.shape
|
|
92
|
+
for j in range(s):
|
|
93
|
+
eta = off[j]
|
|
94
|
+
for k in range(p):
|
|
95
|
+
eta += X[j, k] * beta[k]
|
|
96
|
+
mu[j] = math.exp(eta)
|
|
97
|
+
|
|
98
|
+
@njit(parallel=True, cache=True)
|
|
99
|
+
def fit_levenberg_nb(Y, Off, Phi, W, X, beta0, tol, maxit):
|
|
100
|
+
n, s = Y.shape
|
|
101
|
+
p = X.shape[1]
|
|
102
|
+
beta = beta0.copy()
|
|
103
|
+
MU = np.zeros((n, s))
|
|
104
|
+
dev = np.zeros(n)
|
|
105
|
+
iters = np.zeros(n, np.int64)
|
|
106
|
+
failed = np.zeros(n, np.bool_)
|
|
107
|
+
for g in prange(n):
|
|
108
|
+
y = Y[g]; off = Off[g]; phi = Phi[g]; w = W[g]
|
|
109
|
+
ymax = 0.0
|
|
110
|
+
for j in range(s):
|
|
111
|
+
if y[j] > ymax:
|
|
112
|
+
ymax = y[j]
|
|
113
|
+
if ymax < LOW_VALUE:
|
|
114
|
+
for k in range(p):
|
|
115
|
+
beta[g, k] = np.nan
|
|
116
|
+
continue
|
|
117
|
+
b = beta[g].copy()
|
|
118
|
+
mu = np.empty(s)
|
|
119
|
+
_fit_mu(off, X, b, mu)
|
|
120
|
+
d = _gene_dev(y, mu, phi, w)
|
|
121
|
+
if p == 0:
|
|
122
|
+
MU[g] = mu; dev[g] = d
|
|
123
|
+
continue
|
|
124
|
+
A = np.empty((p, p)); Ad = np.empty((p, p)); L = np.zeros((p, p))
|
|
125
|
+
dl = np.empty(p); db = np.empty(p); bn = np.empty(p); mun = np.empty(s)
|
|
126
|
+
ww = np.empty(s); der = np.empty(s)
|
|
127
|
+
max_info = -1.0
|
|
128
|
+
lam = 0.0
|
|
129
|
+
fail = False
|
|
130
|
+
for it in range(1, maxit + 1):
|
|
131
|
+
iters[g] = it
|
|
132
|
+
for j in range(s):
|
|
133
|
+
den = 1 + mu[j] * phi[j]
|
|
134
|
+
ww[j] = mu[j] / den * w[j]
|
|
135
|
+
der[j] = (y[j] - mu[j]) / den * w[j]
|
|
136
|
+
for a in range(p):
|
|
137
|
+
t = 0.0
|
|
138
|
+
for j in range(s):
|
|
139
|
+
t += der[j] * X[j, a]
|
|
140
|
+
dl[a] = t
|
|
141
|
+
for c in range(a + 1):
|
|
142
|
+
t = 0.0
|
|
143
|
+
for j in range(s):
|
|
144
|
+
t += ww[j] * X[j, a] * X[j, c]
|
|
145
|
+
A[a, c] = t
|
|
146
|
+
A[c, a] = t
|
|
147
|
+
for a in range(p):
|
|
148
|
+
if A[a, a] > max_info:
|
|
149
|
+
max_info = A[a, a]
|
|
150
|
+
if it == 1:
|
|
151
|
+
lam = max_info * 1e-6
|
|
152
|
+
if lam < 1e-13:
|
|
153
|
+
lam = 1e-13
|
|
154
|
+
lev = 0
|
|
155
|
+
low_dev = False
|
|
156
|
+
dbeta_dot = 0.0
|
|
157
|
+
while True:
|
|
158
|
+
lev += 1
|
|
159
|
+
while True:
|
|
160
|
+
for a in range(p):
|
|
161
|
+
for c in range(p):
|
|
162
|
+
Ad[a, c] = A[a, c]
|
|
163
|
+
Ad[a, a] += lam
|
|
164
|
+
if _chol(Ad, L):
|
|
165
|
+
break
|
|
166
|
+
lam *= 10
|
|
167
|
+
if lam <= 0:
|
|
168
|
+
lam = 1e-100
|
|
169
|
+
_chol_solve1(L, dl, db)
|
|
170
|
+
for k in range(p):
|
|
171
|
+
bn[k] = b[k] + db[k]
|
|
172
|
+
_fit_mu(off, X, bn, mun)
|
|
173
|
+
dn = _gene_dev(y, mun, phi, w)
|
|
174
|
+
ld = dn / ymax < 1e-13
|
|
175
|
+
dbeta_dot = 0.0
|
|
176
|
+
for k in range(p):
|
|
177
|
+
dbeta_dot += dl[k] * db[k]
|
|
178
|
+
if dn <= d or ld:
|
|
179
|
+
for k in range(p):
|
|
180
|
+
b[k] = bn[k]
|
|
181
|
+
for j in range(s):
|
|
182
|
+
mu[j] = mun[j]
|
|
183
|
+
d = dn
|
|
184
|
+
low_dev = ld
|
|
185
|
+
break
|
|
186
|
+
lam *= 2
|
|
187
|
+
if lam <= 0:
|
|
188
|
+
lam = 1e-100
|
|
189
|
+
if lam / max_info > 1e13:
|
|
190
|
+
fail = True
|
|
191
|
+
break
|
|
192
|
+
if fail or low_dev or dbeta_dot < tol:
|
|
193
|
+
break
|
|
194
|
+
if lev == 1:
|
|
195
|
+
lam /= 10
|
|
196
|
+
failed[g] = fail
|
|
197
|
+
beta[g] = b
|
|
198
|
+
MU[g] = mu
|
|
199
|
+
dev[g] = d
|
|
200
|
+
return beta, MU, dev, iters, failed
|
|
201
|
+
|
|
202
|
+
@njit(parallel=True, cache=True)
|
|
203
|
+
def compute_apl_nb(Y, Mu, Phi, W, X, adjust):
|
|
204
|
+
# R_compute_apl / compute_apl.c with edgeR's LDL logdet floor
|
|
205
|
+
n, s = Y.shape
|
|
206
|
+
p = X.shape[1]
|
|
207
|
+
out = np.zeros(n)
|
|
208
|
+
for g in prange(n):
|
|
209
|
+
ll = 0.0
|
|
210
|
+
ww = np.zeros(s)
|
|
211
|
+
for j in range(s):
|
|
212
|
+
m = Mu[g, j]
|
|
213
|
+
if m == 0:
|
|
214
|
+
continue
|
|
215
|
+
w = W[g, j]
|
|
216
|
+
cmu = m * w
|
|
217
|
+
cy = Y[g, j] * w
|
|
218
|
+
cd = Phi[g, j] / w
|
|
219
|
+
if cd > 0:
|
|
220
|
+
rr = 1.0 / cd
|
|
221
|
+
lmr = math.log(cmu + rr)
|
|
222
|
+
v = (cy * math.log(cmu) - cy * lmr + rr * math.log(rr) - rr * lmr
|
|
223
|
+
+ math.lgamma(cy + rr) - math.lgamma(cy + 1) - math.lgamma(rr))
|
|
224
|
+
else:
|
|
225
|
+
v = cy * math.log(cmu) - cmu - math.lgamma(cy + 1)
|
|
226
|
+
ll += v + math.log(w)
|
|
227
|
+
ww[j] = cmu / (1 + cd * cmu)
|
|
228
|
+
if adjust:
|
|
229
|
+
if p == 1:
|
|
230
|
+
t = 0.0
|
|
231
|
+
for j in range(s):
|
|
232
|
+
t += ww[j]
|
|
233
|
+
ll -= math.log(abs(t)) / 2
|
|
234
|
+
else:
|
|
235
|
+
A = np.empty((p, p))
|
|
236
|
+
for a in range(p):
|
|
237
|
+
for c in range(a + 1):
|
|
238
|
+
t = 0.0
|
|
239
|
+
for j in range(s):
|
|
240
|
+
t += ww[j] * X[j, a] * X[j, c]
|
|
241
|
+
A[a, c] = t
|
|
242
|
+
A[c, a] = t
|
|
243
|
+
logdet = 0.0
|
|
244
|
+
for j in range(p):
|
|
245
|
+
dj = A[j, j]
|
|
246
|
+
if not (dj >= LOW_VALUE) or math.isinf(dj):
|
|
247
|
+
logdet += LOG_LOW_VALUE
|
|
248
|
+
else:
|
|
249
|
+
logdet += math.log(dj)
|
|
250
|
+
if j + 1 < p:
|
|
251
|
+
for i in range(j + 1, p):
|
|
252
|
+
col = A[i, j] / dj if dj != 0 else 0.0
|
|
253
|
+
for k in range(j + 1, p):
|
|
254
|
+
A[i, k] -= col * A[j, k]
|
|
255
|
+
ll -= logdet * 0.5
|
|
256
|
+
out[g] = ll
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# --------------------------------------------------------------------------- new QL method (ql_glm.c, ql_weights.c)
|
|
261
|
+
if HAVE_NUMBA:
|
|
262
|
+
from ._ql_adjust import (NB_A_1_1, NB_A_1_2, NB_A_1_3, NB_A_2_1, NB_A_2_2, NB_K_1_1, NB_K_1_2, NB_K_1_3,
|
|
263
|
+
NB_K_2_1, NB_K_2_2, POIS_ALPHA_WEIGHTS, POIS_KAPPA_WEIGHTS)
|
|
264
|
+
|
|
265
|
+
LOW_BOUND = 1e-32
|
|
266
|
+
QL_THRESHOLD_ZERO = 1e-4
|
|
267
|
+
QR_TOL = 1e-7
|
|
268
|
+
|
|
269
|
+
@njit(cache=True)
|
|
270
|
+
def _cheb1(w, it, x, m):
|
|
271
|
+
# out = w[it] + w[it+1]*x + sum_i w[it+i]*T_i(x), as the C loops
|
|
272
|
+
c0 = 1.0
|
|
273
|
+
c1 = x
|
|
274
|
+
out = w[it] + w[it + 1] * x
|
|
275
|
+
for i in range(2, m):
|
|
276
|
+
c2 = 2 * x * c1 - c0
|
|
277
|
+
out += w[it + i] * c2
|
|
278
|
+
c0 = c1
|
|
279
|
+
c1 = c2
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
@njit(cache=True)
|
|
283
|
+
def _cheb_vec(x, m, out):
|
|
284
|
+
out[0] = 1.0
|
|
285
|
+
out[1] = x
|
|
286
|
+
for i in range(2, m):
|
|
287
|
+
out[i] = 2 * x * out[i - 1] - out[i - 2]
|
|
288
|
+
|
|
289
|
+
@njit(cache=True)
|
|
290
|
+
def _cheb2(w, it, xc, yc, m):
|
|
291
|
+
out = 0.0
|
|
292
|
+
for i in range(m):
|
|
293
|
+
for j in range(m):
|
|
294
|
+
out += w[it] * xc[j] * yc[i]
|
|
295
|
+
it += 1
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
@njit(cache=True)
|
|
299
|
+
def _pois_alpha(mu):
|
|
300
|
+
logmu = 0.0
|
|
301
|
+
it = 0
|
|
302
|
+
if mu < LOW_BOUND:
|
|
303
|
+
return 0.0
|
|
304
|
+
elif mu < 0.0200:
|
|
305
|
+
x = 2 * mu / 0.02 - 1
|
|
306
|
+
logmu = math.log(mu)
|
|
307
|
+
elif mu < 0.4249:
|
|
308
|
+
x = (2 * mu - 0.4449) / 0.4049; it = 10
|
|
309
|
+
elif mu < 1.5000:
|
|
310
|
+
x = (2 * mu - 1.9249) / 1.0751; it = 20
|
|
311
|
+
elif mu < 3.5440:
|
|
312
|
+
x = (2 * mu - 5.0440) / 2.0440; it = 30
|
|
313
|
+
elif mu < 20.000:
|
|
314
|
+
x = (2 * mu - 23.544) / 16.456; it = 40
|
|
315
|
+
else:
|
|
316
|
+
return 1 - 1 / (6 * mu) - 1 / (2 * mu * mu)
|
|
317
|
+
out = _cheb1(POIS_ALPHA_WEIGHTS, it, x, 10)
|
|
318
|
+
if mu < 0.0200:
|
|
319
|
+
out = -out * logmu / ((1 + logmu) * (1 + logmu))
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
@njit(cache=True)
|
|
323
|
+
def _pois_kappa(mu):
|
|
324
|
+
logmu = 0.0
|
|
325
|
+
it = 0
|
|
326
|
+
if mu < LOW_BOUND:
|
|
327
|
+
return 0.0
|
|
328
|
+
elif mu < 0.0200:
|
|
329
|
+
x = 2 * mu / 0.02 - 1
|
|
330
|
+
logmu = math.log(mu) / (1 + math.log(mu))
|
|
331
|
+
elif mu < 0.4966:
|
|
332
|
+
x = (2 * mu - 0.5166) / 0.4766; it = 10
|
|
333
|
+
elif mu < 1.5000:
|
|
334
|
+
x = (2 * mu - 1.9966) / 1.0034; it = 20
|
|
335
|
+
elif mu < 4.2714:
|
|
336
|
+
x = (2 * mu - 5.7714) / 2.7714; it = 30
|
|
337
|
+
elif mu < 20.000:
|
|
338
|
+
x = (2 * mu - 24.2714) / 15.7286; it = 40
|
|
339
|
+
else:
|
|
340
|
+
return 1 - 1 / (2.5 * mu * mu)
|
|
341
|
+
out = _cheb1(POIS_KAPPA_WEIGHTS, it, x, 10)
|
|
342
|
+
if mu < 0.0200:
|
|
343
|
+
out = out * mu * logmu * logmu
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
@njit(cache=True)
|
|
347
|
+
def _mix(w, it, off2, off3, x, y, m):
|
|
348
|
+
# w1, w2 in y (offsets 0, off2), w3 in x (offset off3); returns (w1, w2, w3)
|
|
349
|
+
xc = np.empty(m)
|
|
350
|
+
yc = np.empty(m)
|
|
351
|
+
xc[0] = 1.0; xc[1] = x; yc[0] = 1.0; yc[1] = y
|
|
352
|
+
w1 = w[it] + w[it + 1] * y
|
|
353
|
+
w2 = w[it + off2] + w[it + off2 + 1] * y
|
|
354
|
+
w3 = w[it + off3] + w[it + off3 + 1] * x
|
|
355
|
+
for i in range(2, m):
|
|
356
|
+
xc[i] = 2 * x * xc[i - 1] - xc[i - 2]
|
|
357
|
+
yc[i] = 2 * y * yc[i - 1] - yc[i - 2]
|
|
358
|
+
w1 += w[it + i] * yc[i]
|
|
359
|
+
w2 += w[it + off2 + i] * yc[i]
|
|
360
|
+
w3 += w[it + off3 + i] * xc[i]
|
|
361
|
+
return w1, w2, w3
|
|
362
|
+
|
|
363
|
+
@njit(cache=True)
|
|
364
|
+
def _nbdevc_1(mu, phi, kappa):
|
|
365
|
+
y = phi / 0.368 - 1
|
|
366
|
+
if mu < LOW_BOUND:
|
|
367
|
+
return 0.0
|
|
368
|
+
tail = (1 - 1 / (2.5 * mu * mu)) if kappa else (1 - 1 / (6 * mu) - 1 / (2 * mu * mu))
|
|
369
|
+
if mu > 60:
|
|
370
|
+
it = 0
|
|
371
|
+
if kappa and mu > 250:
|
|
372
|
+
it = 18
|
|
373
|
+
elif mu > 120:
|
|
374
|
+
it = 12
|
|
375
|
+
elif mu > 80:
|
|
376
|
+
it = 6
|
|
377
|
+
return _cheb1(NB_K_1_3 if kappa else NB_A_1_3, it, y, 6) * tail
|
|
378
|
+
elif mu > 20:
|
|
379
|
+
it = 0
|
|
380
|
+
if mu < 25:
|
|
381
|
+
x = (2 * mu - 45) / 5
|
|
382
|
+
elif mu < 30:
|
|
383
|
+
x = (2 * mu - 55) / 5; it = 7
|
|
384
|
+
elif mu < 40:
|
|
385
|
+
x = (mu - 35) / 5; it = 14
|
|
386
|
+
else:
|
|
387
|
+
x = (mu - 50) / 10; it = 21
|
|
388
|
+
w1, w2, w3 = _mix(NB_K_1_2 if kappa else NB_A_1_2, it, 7, 35, x, y, 7)
|
|
389
|
+
return (w2 + (w1 - w2) * w3) * tail
|
|
390
|
+
else:
|
|
391
|
+
it = 0
|
|
392
|
+
if mu < 0.01:
|
|
393
|
+
x = 200 * mu - 1
|
|
394
|
+
elif mu < 0.33:
|
|
395
|
+
x = (2 * mu - 0.34) / 0.32; it = 49
|
|
396
|
+
elif kappa and mu < 1.30:
|
|
397
|
+
x = (2 * mu - 1.63) / 0.97; it = 98
|
|
398
|
+
elif (not kappa) and mu < 1.77:
|
|
399
|
+
x = (2 * mu - 2.10) / 1.44; it = 98
|
|
400
|
+
elif mu < 4.00:
|
|
401
|
+
if kappa:
|
|
402
|
+
x = (2 * mu - 5.30) / 2.70
|
|
403
|
+
else:
|
|
404
|
+
x = (2 * mu - 5.77) / 2.23
|
|
405
|
+
it = 147
|
|
406
|
+
elif mu < 10.0:
|
|
407
|
+
x = (mu - 7) / 3; it = 196
|
|
408
|
+
else:
|
|
409
|
+
x = (mu - 15) / 5; it = 245
|
|
410
|
+
xc = np.empty(7)
|
|
411
|
+
yc = np.empty(7)
|
|
412
|
+
_cheb_vec(x, 7, xc)
|
|
413
|
+
_cheb_vec(y, 7, yc)
|
|
414
|
+
out = _cheb2(NB_K_1_1 if kappa else NB_A_1_1, it, xc, yc, 7)
|
|
415
|
+
return out * (_pois_kappa(mu) if kappa else _pois_alpha(mu))
|
|
416
|
+
|
|
417
|
+
@njit(cache=True)
|
|
418
|
+
def _nbdevc_2(mu, phi, kappa):
|
|
419
|
+
y = phi / 2 - 1
|
|
420
|
+
if mu < LOW_BOUND:
|
|
421
|
+
return 0.0
|
|
422
|
+
if mu < 50:
|
|
423
|
+
it = 0
|
|
424
|
+
if mu < 0.01:
|
|
425
|
+
x = mu / 0.01 - 1
|
|
426
|
+
elif kappa and mu < 0.50:
|
|
427
|
+
x = (2 * mu - 0.51) / 0.49; it = 100
|
|
428
|
+
elif (not kappa) and mu < 0.43:
|
|
429
|
+
x = (mu - 0.22) / 0.21; it = 100
|
|
430
|
+
elif kappa and mu < 3.88:
|
|
431
|
+
x = (2 * mu - 4.38) / 3.38; it = 200
|
|
432
|
+
elif (not kappa) and mu < 3.62:
|
|
433
|
+
x = (2 * mu - 4.05) / 3.19; it = 200
|
|
434
|
+
elif mu < 10.0:
|
|
435
|
+
if kappa:
|
|
436
|
+
x = (2 * mu - 13.88) / 6.12
|
|
437
|
+
else:
|
|
438
|
+
x = (2 * mu - 13.62) / 6.38
|
|
439
|
+
it = 300
|
|
440
|
+
elif mu < 30.0:
|
|
441
|
+
x = mu / 10 - 2; it = 400
|
|
442
|
+
else:
|
|
443
|
+
x = mu / 10 - 4; it = 500
|
|
444
|
+
xc = np.empty(10)
|
|
445
|
+
yc = np.empty(10)
|
|
446
|
+
_cheb_vec(x, 10, xc)
|
|
447
|
+
_cheb_vec(y, 10, yc)
|
|
448
|
+
out = _cheb2(NB_K_2_1 if kappa else NB_A_2_1, it, xc, yc, 10)
|
|
449
|
+
if mu < 0.01:
|
|
450
|
+
if kappa:
|
|
451
|
+
logmu = math.log(mu) / (1 + math.log(mu))
|
|
452
|
+
out = out * mu * logmu * logmu
|
|
453
|
+
else:
|
|
454
|
+
logmu = math.log(mu)
|
|
455
|
+
out = out * (logmu / ((1 + logmu) * (1 + logmu)))
|
|
456
|
+
return out
|
|
457
|
+
elif mu < 5000:
|
|
458
|
+
it = 0
|
|
459
|
+
if mu < 100:
|
|
460
|
+
x = 200 / mu - 3
|
|
461
|
+
elif mu < 1000:
|
|
462
|
+
x = (2000 / mu - 11) / 9; it = 10
|
|
463
|
+
else:
|
|
464
|
+
x = 2500 / mu - 1.5; it = 20
|
|
465
|
+
w1, w2, w3 = _mix(NB_K_2_2 if kappa else NB_A_2_2, it, 10, 40, x, y, 10)
|
|
466
|
+
return w1 + (w2 - w1) * w3
|
|
467
|
+
else:
|
|
468
|
+
return _cheb1(NB_K_2_2 if kappa else NB_A_2_2, 30, y, 10)
|
|
469
|
+
|
|
470
|
+
@njit(cache=True)
|
|
471
|
+
def _ql_weight(u, phi, prior):
|
|
472
|
+
# compute_weight for phi < 4.001 (the exact branch is left to the NumPy path)
|
|
473
|
+
mu = u / prior
|
|
474
|
+
if phi < 0.736:
|
|
475
|
+
return _nbdevc_1(mu, phi, False), _nbdevc_1(mu, phi, True)
|
|
476
|
+
return _nbdevc_2(mu, phi, False), _nbdevc_2(mu, phi, True)
|
|
477
|
+
|
|
478
|
+
@njit(cache=True)
|
|
479
|
+
def _nrm2(x, r0, j):
|
|
480
|
+
n = x.shape[0]
|
|
481
|
+
s = 0.0
|
|
482
|
+
for i in range(r0, n):
|
|
483
|
+
s += x[i, j] * x[i, j]
|
|
484
|
+
r = math.sqrt(s)
|
|
485
|
+
if not (r > 1e-150 and r < 1e150):
|
|
486
|
+
scale = 0.0
|
|
487
|
+
for i in range(r0, n):
|
|
488
|
+
a = abs(x[i, j])
|
|
489
|
+
if a > scale:
|
|
490
|
+
scale = a
|
|
491
|
+
if scale > 0:
|
|
492
|
+
s = 0.0
|
|
493
|
+
for i in range(r0, n):
|
|
494
|
+
t = abs(x[i, j]) / scale
|
|
495
|
+
s += t * t
|
|
496
|
+
r = math.sqrt(s) * scale
|
|
497
|
+
else:
|
|
498
|
+
r = 0.0
|
|
499
|
+
return r
|
|
500
|
+
|
|
501
|
+
@njit(cache=True)
|
|
502
|
+
def _qr_hat(x, hat):
|
|
503
|
+
# src/ql_glm.c qr_hat: LINPACK dqrdc2 (tol 1e-7, limited pivoting) + dqrqy on rank columns
|
|
504
|
+
n, p = x.shape
|
|
505
|
+
qraux = np.empty(p)
|
|
506
|
+
for j in range(p):
|
|
507
|
+
qraux[j] = _nrm2(x, 0, j)
|
|
508
|
+
work1 = qraux.copy()
|
|
509
|
+
work2 = qraux.copy()
|
|
510
|
+
for j in range(p):
|
|
511
|
+
if work2[j] == 0.0:
|
|
512
|
+
work2[j] = 1.0
|
|
513
|
+
lup = min(n, p)
|
|
514
|
+
k = p + 1
|
|
515
|
+
col = np.empty(n)
|
|
516
|
+
for l in range(lup):
|
|
517
|
+
while not ((l + 1) >= k or qraux[l] >= work2[l] * QR_TOL):
|
|
518
|
+
for i in range(n):
|
|
519
|
+
col[i] = x[i, l]
|
|
520
|
+
for c in range(l, p - 1):
|
|
521
|
+
for i in range(n):
|
|
522
|
+
x[i, c] = x[i, c + 1]
|
|
523
|
+
for i in range(n):
|
|
524
|
+
x[i, p - 1] = col[i]
|
|
525
|
+
t, tt, ttt = qraux[l], work1[l], work2[l]
|
|
526
|
+
for c in range(l, p - 1):
|
|
527
|
+
qraux[c] = qraux[c + 1]
|
|
528
|
+
work1[c] = work1[c + 1]
|
|
529
|
+
work2[c] = work2[c + 1]
|
|
530
|
+
qraux[p - 1], work1[p - 1], work2[p - 1] = t, tt, ttt
|
|
531
|
+
k -= 1
|
|
532
|
+
if l + 1 == n:
|
|
533
|
+
continue
|
|
534
|
+
nrmxl = _nrm2(x, l, l)
|
|
535
|
+
if nrmxl == 0.0:
|
|
536
|
+
continue
|
|
537
|
+
if x[l, l] != 0.0:
|
|
538
|
+
nrmxl = math.copysign(nrmxl, x[l, l])
|
|
539
|
+
sc = 1.0 / nrmxl
|
|
540
|
+
for i in range(l, n):
|
|
541
|
+
x[i, l] *= sc
|
|
542
|
+
x[l, l] = 1.0 + x[l, l]
|
|
543
|
+
for j in range(l + 1, p):
|
|
544
|
+
d = 0.0
|
|
545
|
+
for i in range(l, n):
|
|
546
|
+
d += x[i, l] * x[i, j]
|
|
547
|
+
t = -d / x[l, l]
|
|
548
|
+
for i in range(l, n):
|
|
549
|
+
x[i, j] += t * x[i, l]
|
|
550
|
+
if qraux[j] == 0.0:
|
|
551
|
+
continue
|
|
552
|
+
tt = abs(x[l, j]) / qraux[j]
|
|
553
|
+
tt = 1.0 - tt * tt
|
|
554
|
+
if tt < 0.0:
|
|
555
|
+
tt = 0.0
|
|
556
|
+
if abs(tt) < 1e-6:
|
|
557
|
+
qraux[j] = _nrm2(x, l + 1, j)
|
|
558
|
+
work1[j] = qraux[j]
|
|
559
|
+
else:
|
|
560
|
+
qraux[j] = qraux[j] * math.sqrt(tt)
|
|
561
|
+
qraux[l] = x[l, l]
|
|
562
|
+
x[l, l] = -nrmxl
|
|
563
|
+
k = min(k - 1, n)
|
|
564
|
+
Y = np.zeros((n, k))
|
|
565
|
+
for i in range(k):
|
|
566
|
+
Y[i, i] = 1.0
|
|
567
|
+
ju = min(k, n - 1)
|
|
568
|
+
for j in range(ju - 1, -1, -1):
|
|
569
|
+
if qraux[j] == 0.0:
|
|
570
|
+
continue
|
|
571
|
+
v0 = qraux[j]
|
|
572
|
+
for c in range(j, k):
|
|
573
|
+
d = v0 * Y[j, c]
|
|
574
|
+
for i in range(j + 1, n):
|
|
575
|
+
d += x[i, j] * Y[i, c]
|
|
576
|
+
t = -d / v0
|
|
577
|
+
Y[j, c] += v0 * t
|
|
578
|
+
for i in range(j + 1, n):
|
|
579
|
+
Y[i, c] += x[i, j] * t
|
|
580
|
+
for i in range(n):
|
|
581
|
+
h = 0.0
|
|
582
|
+
for c in range(k):
|
|
583
|
+
h += Y[i, c] * Y[i, c]
|
|
584
|
+
hat[i] = h
|
|
585
|
+
|
|
586
|
+
@njit(parallel=True, cache=True)
|
|
587
|
+
def ql_adjust_nb(Y, Mu, X, Disp, W, prior, want_mat):
|
|
588
|
+
# compute_adjust_vec / compute_adjust_mat (src/ql_glm.c), per gene
|
|
589
|
+
G, n = Y.shape
|
|
590
|
+
p = X.shape[1]
|
|
591
|
+
df = np.zeros(G)
|
|
592
|
+
dev = np.zeros(G)
|
|
593
|
+
s2 = np.zeros(G)
|
|
594
|
+
gm = G if want_mat else 1
|
|
595
|
+
udm = np.zeros((gm, n))
|
|
596
|
+
ufm = np.zeros((gm, n))
|
|
597
|
+
lvm = np.zeros((gm, n))
|
|
598
|
+
for g in prange(G):
|
|
599
|
+
xw = np.empty((n, p))
|
|
600
|
+
hat = np.zeros(n)
|
|
601
|
+
for i in range(n):
|
|
602
|
+
u = Mu[g, i]
|
|
603
|
+
zw = math.sqrt(u / (1 + (u * Disp[g, i] * W[g, i] / prior)))
|
|
604
|
+
for c in range(p):
|
|
605
|
+
xw[i, c] = X[i, c] * zw
|
|
606
|
+
_qr_hat(xw, hat)
|
|
607
|
+
dv = 0.0
|
|
608
|
+
dfg = 0.0
|
|
609
|
+
for i in range(n):
|
|
610
|
+
wa, wk = _ql_weight(Mu[g, i], Disp[g, i], prior / W[g, i])
|
|
611
|
+
udp = _unit_dev(Y[g, i], Mu[g, i], Disp[g, i] * W[g, i] / prior)
|
|
612
|
+
hdp = 1.0 - hat[i]
|
|
613
|
+
if hdp < QL_THRESHOLD_ZERO:
|
|
614
|
+
udp = 0.0
|
|
615
|
+
hdp = 0.0
|
|
616
|
+
dv += (udp * wa) * W[g, i]
|
|
617
|
+
dfg += hdp * wk
|
|
618
|
+
if want_mat:
|
|
619
|
+
udm[g, i] = udp * wa
|
|
620
|
+
ufm[g, i] = hdp * wk
|
|
621
|
+
lvm[g, i] = hat[i]
|
|
622
|
+
dev[g] = dv
|
|
623
|
+
df[g] = dfg
|
|
624
|
+
s2[g] = 0.0 if dfg < QL_THRESHOLD_ZERO else dv / dfg
|
|
625
|
+
return df, dev, s2, udm, ufm, lvm
|
|
626
|
+
|
|
627
|
+
@njit(cache=True)
|
|
628
|
+
def rsort_with_index_nb(x):
|
|
629
|
+
# R's rsort_with_index (Shell sort); returns the permutation
|
|
630
|
+
n = x.size
|
|
631
|
+
xs = x.copy()
|
|
632
|
+
idx = np.arange(n)
|
|
633
|
+
h = 1
|
|
634
|
+
while h <= n // 9:
|
|
635
|
+
h = 3 * h + 1
|
|
636
|
+
while h > 0:
|
|
637
|
+
for i in range(h, n):
|
|
638
|
+
v = xs[i]
|
|
639
|
+
iv = idx[i]
|
|
640
|
+
j = i
|
|
641
|
+
while j >= h and xs[j - h] > v:
|
|
642
|
+
xs[j] = xs[j - h]
|
|
643
|
+
idx[j] = idx[j - h]
|
|
644
|
+
j -= h
|
|
645
|
+
xs[j] = v
|
|
646
|
+
idx[j] = iv
|
|
647
|
+
h //= 3
|
|
648
|
+
return idx
|
|
649
|
+
|
|
650
|
+
@njit(cache=True)
|
|
651
|
+
def _lowest(x, y, n, xs, nleft, nright, w, it, rw):
|
|
652
|
+
# edgeR src/clowess.c lowest (0-based arrays, 1-based nleft/nright); returns (ys, notok)
|
|
653
|
+
rng = x[n - 1] - x[0]
|
|
654
|
+
h = max(xs - x[nleft - 1], x[nright - 1] - xs)
|
|
655
|
+
h9 = 0.999 * h
|
|
656
|
+
h1 = 0.001 * h
|
|
657
|
+
a = 0.0
|
|
658
|
+
j = nleft
|
|
659
|
+
while j <= n:
|
|
660
|
+
w[j - 1] = 0.0
|
|
661
|
+
r = abs(x[j - 1] - xs)
|
|
662
|
+
if r <= h9:
|
|
663
|
+
if r <= h1:
|
|
664
|
+
w[j - 1] = 1.0
|
|
665
|
+
else:
|
|
666
|
+
q = r / h
|
|
667
|
+
q = 1.0 - q * q * q
|
|
668
|
+
w[j - 1] = q * q * q
|
|
669
|
+
if it > 1:
|
|
670
|
+
w[j - 1] *= rw[j - 1]
|
|
671
|
+
a += w[j - 1]
|
|
672
|
+
elif x[j - 1] > xs:
|
|
673
|
+
break
|
|
674
|
+
j += 1
|
|
675
|
+
nrt = j - 1
|
|
676
|
+
if a <= 0.0:
|
|
677
|
+
return 0.0, True
|
|
678
|
+
for j in range(nleft, nrt + 1):
|
|
679
|
+
w[j - 1] /= a
|
|
680
|
+
if h > 0.0:
|
|
681
|
+
a = 0.0
|
|
682
|
+
for j in range(nleft, nrt + 1):
|
|
683
|
+
a += w[j - 1] * x[j - 1]
|
|
684
|
+
b = xs - a
|
|
685
|
+
c = 0.0
|
|
686
|
+
for j in range(nleft, nrt + 1):
|
|
687
|
+
d = x[j - 1] - a
|
|
688
|
+
c += w[j - 1] * (d * d)
|
|
689
|
+
if math.sqrt(c) > 0.001 * rng:
|
|
690
|
+
b /= c
|
|
691
|
+
for j in range(nleft, nrt + 1):
|
|
692
|
+
w[j - 1] *= (b * (x[j - 1] - a) + 1.0)
|
|
693
|
+
ys = 0.0
|
|
694
|
+
for j in range(nleft, nrt + 1):
|
|
695
|
+
ys += w[j - 1] * y[j - 1]
|
|
696
|
+
return ys, False
|
|
697
|
+
|
|
698
|
+
@njit(cache=True)
|
|
699
|
+
def clowess_nb(x, y, f, nsteps, delta):
|
|
700
|
+
# edgeR src/clowess.c clowess (x sorted)
|
|
701
|
+
n = x.size
|
|
702
|
+
ys = np.zeros(n)
|
|
703
|
+
if n < 2:
|
|
704
|
+
ys[0] = y[0]
|
|
705
|
+
return ys
|
|
706
|
+
rw = np.zeros(n)
|
|
707
|
+
res = np.zeros(n)
|
|
708
|
+
ns = max(2, min(n, int(f * n + 1e-7)))
|
|
709
|
+
it = 1
|
|
710
|
+
while it <= nsteps + 1:
|
|
711
|
+
nleft = 1
|
|
712
|
+
nright = ns
|
|
713
|
+
last = 0
|
|
714
|
+
i = 1
|
|
715
|
+
while True:
|
|
716
|
+
if nright < n:
|
|
717
|
+
d1 = x[i - 1] - x[nleft - 1]
|
|
718
|
+
d2 = x[nright] - x[i - 1]
|
|
719
|
+
if d1 > d2:
|
|
720
|
+
nleft += 1
|
|
721
|
+
nright += 1
|
|
722
|
+
continue
|
|
723
|
+
v, notok = _lowest(x, y, n, x[i - 1], nleft, nright, res, it, rw)
|
|
724
|
+
ys[i - 1] = y[i - 1] if notok else v
|
|
725
|
+
if last < i - 1:
|
|
726
|
+
denom = x[i - 1] - x[last - 1]
|
|
727
|
+
for j in range(last + 1, i):
|
|
728
|
+
alpha = (x[j - 1] - x[last - 1]) / denom
|
|
729
|
+
ys[j - 1] = alpha * ys[i - 1] + (1.0 - alpha) * ys[last - 1]
|
|
730
|
+
last = i
|
|
731
|
+
cut = x[last - 1] + delta
|
|
732
|
+
i = last + 1
|
|
733
|
+
while i <= n:
|
|
734
|
+
if x[i - 1] > cut:
|
|
735
|
+
break
|
|
736
|
+
if x[i - 1] == x[last - 1]:
|
|
737
|
+
ys[i - 1] = ys[last - 1]
|
|
738
|
+
last = i
|
|
739
|
+
i += 1
|
|
740
|
+
i = max(last + 1, i - 1)
|
|
741
|
+
if last >= n:
|
|
742
|
+
break
|
|
743
|
+
for k in range(n):
|
|
744
|
+
res[k] = y[k] - ys[k]
|
|
745
|
+
sc = 0.0
|
|
746
|
+
for k in range(n):
|
|
747
|
+
sc += abs(res[k])
|
|
748
|
+
sc /= n
|
|
749
|
+
if it > nsteps:
|
|
750
|
+
break
|
|
751
|
+
for k in range(n):
|
|
752
|
+
rw[k] = abs(res[k])
|
|
753
|
+
srt = np.sort(rw)
|
|
754
|
+
m1 = n // 2
|
|
755
|
+
if n % 2 == 0:
|
|
756
|
+
m2 = n - m1 - 1
|
|
757
|
+
cmad = 3.0 * (srt[m1] + srt[m2])
|
|
758
|
+
else:
|
|
759
|
+
cmad = 6.0 * srt[m1]
|
|
760
|
+
if cmad < 1e-7 * sc:
|
|
761
|
+
break
|
|
762
|
+
c9 = 0.999 * cmad
|
|
763
|
+
c1 = 0.001 * cmad
|
|
764
|
+
for k in range(n):
|
|
765
|
+
r = abs(res[k])
|
|
766
|
+
if r <= c1:
|
|
767
|
+
rw[k] = 1.0
|
|
768
|
+
elif r <= c9:
|
|
769
|
+
q = r / cmad
|
|
770
|
+
q = 1.0 - q * q
|
|
771
|
+
rw[k] = q * q
|
|
772
|
+
else:
|
|
773
|
+
rw[k] = 0.0
|
|
774
|
+
it += 1
|
|
775
|
+
return ys
|
|
776
|
+
|
|
777
|
+
@njit(parallel=True, cache=True)
|
|
778
|
+
def one_group_nb(Y, Off, Phi, W, start, has_start, maxit, tol):
|
|
779
|
+
# glm.c glm_one_group_vec per gene (non-converged rows keep the last iterate)
|
|
780
|
+
n, s = Y.shape
|
|
781
|
+
beta = np.empty(n)
|
|
782
|
+
for g in prange(n):
|
|
783
|
+
allzero = True
|
|
784
|
+
num = 0.0
|
|
785
|
+
tot = 0.0
|
|
786
|
+
for j in range(s):
|
|
787
|
+
if Y[g, j] > LOW_VALUE:
|
|
788
|
+
num += Y[g, j] / math.exp(Off[g, j]) * W[g, j]
|
|
789
|
+
allzero = False
|
|
790
|
+
tot += W[g, j]
|
|
791
|
+
b = math.log(num / tot) if num > 0 else -math.inf
|
|
792
|
+
if has_start and not math.isnan(start[g]):
|
|
793
|
+
b = start[g]
|
|
794
|
+
if allzero:
|
|
795
|
+
beta[g] = -math.inf
|
|
796
|
+
continue
|
|
797
|
+
for _ in range(maxit):
|
|
798
|
+
dl = 0.0
|
|
799
|
+
info = 0.0
|
|
800
|
+
for j in range(s):
|
|
801
|
+
mu = math.exp(b + Off[g, j])
|
|
802
|
+
den = 1 + mu * Phi[g, j]
|
|
803
|
+
dl += (Y[g, j] - mu) / den * W[g, j]
|
|
804
|
+
info += mu / den * W[g, j]
|
|
805
|
+
step = dl / info
|
|
806
|
+
b += step
|
|
807
|
+
if abs(step) < tol:
|
|
808
|
+
break
|
|
809
|
+
beta[g] = b
|
|
810
|
+
return beta
|
|
@@ -1447,6 +1447,19 @@ def _adjust_units(y, mu, X, disp, w, prior):
|
|
|
1447
1447
|
return udp * wa, hdp * wk, hat
|
|
1448
1448
|
|
|
1449
1449
|
|
|
1450
|
+
def _numba_adjust(y, mu, X, disp, w, prior, want_mat):
|
|
1451
|
+
"""Compiled per-gene path (``_numba_kernels.ql_adjust_nb``); None if unavailable.
|
|
1452
|
+
|
|
1453
|
+
The exact finite-sum weights for phi >= 4.001 are only in the NumPy path;
|
|
1454
|
+
glmQLFit caps the dispersion at 4, so they are not reached from there.
|
|
1455
|
+
"""
|
|
1456
|
+
from . import _numba_kernels as nk
|
|
1457
|
+
if not nk.HAVE_NUMBA or y.size == 0 or not (np.max(disp) < 4.001):
|
|
1458
|
+
return None
|
|
1459
|
+
c = lambda a: np.ascontiguousarray(a, dtype=np.float64)
|
|
1460
|
+
return nk.ql_adjust_nb(c(y), c(mu), c(X), c(disp), c(w), float(prior), bool(want_mat))
|
|
1461
|
+
|
|
1462
|
+
|
|
1450
1463
|
def compute_adj_vec(y, mu, design, dispersion, prior=1.0, weights=None):
|
|
1451
1464
|
"""edgeR 4.10 ``.cxx_compute_adj_vec`` (src/ql_glm.c ``compute_adjust_vec``).
|
|
1452
1465
|
|
|
@@ -1456,6 +1469,9 @@ def compute_adj_vec(y, mu, design, dispersion, prior=1.0, weights=None):
|
|
|
1456
1469
|
"""
|
|
1457
1470
|
y, mu, X, disp, w = _prep(y, mu, design, dispersion, weights)
|
|
1458
1471
|
prior = float(np.asarray(prior, dtype=np.float64).ravel()[0])
|
|
1472
|
+
nb = _numba_adjust(y, mu, X, disp, w, prior, False)
|
|
1473
|
+
if nb is not None:
|
|
1474
|
+
return {"df": nb[0], "deviance": nb[1], "s2": nb[2]}
|
|
1459
1475
|
unit_dev, unit_df, _ = _adjust_units(y, mu, X, disp, w, prior)
|
|
1460
1476
|
G, n = y.shape
|
|
1461
1477
|
dev = np.zeros(G)
|
|
@@ -1473,6 +1489,9 @@ def compute_adj_mat(y, mu, design, dispersion, prior=1.0, weights=None):
|
|
|
1473
1489
|
"""edgeR 4.10 ``.cxx_compute_adj_mat``: unit df, unit deviance and leverage matrices."""
|
|
1474
1490
|
y, mu, X, disp, w = _prep(y, mu, design, dispersion, weights)
|
|
1475
1491
|
prior = float(np.asarray(prior, dtype=np.float64).ravel()[0])
|
|
1492
|
+
nb = _numba_adjust(y, mu, X, disp, w, prior, True)
|
|
1493
|
+
if nb is not None:
|
|
1494
|
+
return {"unit_df": nb[4], "unit_deviance": nb[3], "leverage": nb[5]}
|
|
1476
1495
|
unit_dev, unit_df, hat = _adjust_units(y, mu, X, disp, w, prior)
|
|
1477
1496
|
return {"unit_df": unit_df, "unit_deviance": unit_dev, "leverage": hat}
|
|
1478
1497
|
|
|
@@ -1665,10 +1684,17 @@ def compute_prior(ave_log_cpm, s2, df):
|
|
|
1665
1684
|
xx = ag[keep]
|
|
1666
1685
|
yy = np.sqrt(np.sqrt(s2[keep]))
|
|
1667
1686
|
k = xx.size
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1687
|
+
from . import _numba_kernels as nk
|
|
1688
|
+
if nk.HAVE_NUMBA:
|
|
1689
|
+
o = nk.rsort_with_index_nb(np.ascontiguousarray(xx))
|
|
1690
|
+
xs, ys = xx[o], yy[o]
|
|
1691
|
+
delta = 0.01 * (xs[k - 1] - xs[0])
|
|
1692
|
+
ans = np.sort(nk.clowess_nb(np.ascontiguousarray(xs), np.ascontiguousarray(ys), 0.5, 3, delta))
|
|
1693
|
+
else:
|
|
1694
|
+
o = _rsort_with_index(xx)
|
|
1695
|
+
xs, ys = xx[o], yy[o]
|
|
1696
|
+
delta = 0.01 * (xs[k - 1] - xs[0])
|
|
1697
|
+
ans = np.sort(_clowess(xs, ys, 0.5, 3, delta))
|
|
1672
1698
|
m = (k - 1) * 0.9
|
|
1673
1699
|
lo = int(m)
|
|
1674
1700
|
h = m - lo
|
|
@@ -7,7 +7,6 @@ import numpy as np
|
|
|
7
7
|
import pandas as pd
|
|
8
8
|
from scipy import optimize, special, stats
|
|
9
9
|
from scipy.stats import rankdata
|
|
10
|
-
from statsmodels.nonparametric.smoothers_lowess import lowess
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
def _as_weight_matrix(weights, shape: tuple[int, int], row_index=None, col_index=None) -> pd.DataFrame:
|
|
@@ -997,6 +996,78 @@ def _fit_glm_batch(counts, X, offset_mat, phi_mat, W, start=None):
|
|
|
997
996
|
return beta, mu, dev
|
|
998
997
|
|
|
999
998
|
|
|
999
|
+
def glm_residuals(glmfit: DGEGLM, type: str = "pearson") -> np.ndarray:
|
|
1000
|
+
"""Observation residuals of a fitted NB GLM (``pearson`` / ``deviance`` / ``anscombe``)."""
|
|
1001
|
+
y = glmfit.counts.to_numpy(dtype=float)
|
|
1002
|
+
mu = np.asarray(glmfit.fitted_values, dtype=float)
|
|
1003
|
+
disp = np.asarray(glmfit.dispersion, dtype=float)
|
|
1004
|
+
phi = np.broadcast_to(disp.reshape(-1, 1) if disp.ndim else disp, y.shape)
|
|
1005
|
+
type = type.lower()
|
|
1006
|
+
if type == "pearson":
|
|
1007
|
+
return (y - mu) / np.sqrt(np.maximum(mu + phi * mu * mu, 1e-300))
|
|
1008
|
+
if type == "deviance":
|
|
1009
|
+
unit = _nbinom_unit_deviance(y, mu, phi)
|
|
1010
|
+
return np.sign(y - mu) * np.sqrt(unit)
|
|
1011
|
+
if type == "anscombe":
|
|
1012
|
+
# Anscombe residual for the NB family (Hilbe 2011), integral evaluated numerically.
|
|
1013
|
+
def a(t, ph):
|
|
1014
|
+
grid = np.linspace(0.0, 1.0, 65)[None, :]
|
|
1015
|
+
s_ = np.maximum(t[:, None] * grid, 1e-12)
|
|
1016
|
+
f = (s_ + ph[:, None] * s_ * s_) ** (-1.0 / 3.0)
|
|
1017
|
+
trap = getattr(np, "trapezoid", None) or np.trapz
|
|
1018
|
+
return trap(f, grid[0], axis=1) * t
|
|
1019
|
+
yf, mf, pf = y.ravel(), mu.ravel(), phi.ravel()
|
|
1020
|
+
res = (a(yf, pf) - a(mf, pf)) / ((mf + pf * mf * mf) ** (1.0 / 6.0) + 1e-300)
|
|
1021
|
+
return res.reshape(y.shape)
|
|
1022
|
+
raise ValueError("type must be 'pearson', 'deviance' or 'anscombe'.")
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def estimate_glm_robust_disp(
|
|
1026
|
+
y: DGEList,
|
|
1027
|
+
design=None,
|
|
1028
|
+
prior_df: float = 10.0,
|
|
1029
|
+
update_trend: bool = True,
|
|
1030
|
+
trend_method: str = "bin.loess",
|
|
1031
|
+
maxit: int = 6,
|
|
1032
|
+
k: float = 1.345,
|
|
1033
|
+
residual_type: str = "pearson",
|
|
1034
|
+
record: bool = False,
|
|
1035
|
+
) -> DGEList:
|
|
1036
|
+
"""Observation-weighted robust dispersion, mirroring ``edgeR::estimateGLMRobustDisp``.
|
|
1037
|
+
|
|
1038
|
+
Alternates GLM fits with Huber observation weights ``min(1, k / |r|)`` on
|
|
1039
|
+
the fitted residuals ``r`` and re-estimates the trended and tagwise
|
|
1040
|
+
dispersions with those weights (Zhou, Lindsay & Robinson 2014, NAR). The
|
|
1041
|
+
final weights are kept in ``y.weights`` so a following :func:`glm_fit` /
|
|
1042
|
+
:func:`glm_lrt` down-weights isolated aberrant observations.
|
|
1043
|
+
"""
|
|
1044
|
+
if not isinstance(y, DGEList):
|
|
1045
|
+
y = DGEList(y)
|
|
1046
|
+
if design is None:
|
|
1047
|
+
design = _default_design_from_group(y)
|
|
1048
|
+
y.weights = pd.DataFrame(np.ones(y.counts.shape), index=y.counts.index, columns=y.counts.columns)
|
|
1049
|
+
if y.trended_dispersion is None:
|
|
1050
|
+
estimate_glm_trended_disp(y, design=design, method=trend_method)
|
|
1051
|
+
if y.tagwise_dispersion is None:
|
|
1052
|
+
estimate_glm_tagwise_disp(y, design=design, prior_df=prior_df)
|
|
1053
|
+
history = []
|
|
1054
|
+
for _ in range(int(maxit)):
|
|
1055
|
+
fit = glm_fit(y, design=design, dispersion=y.tagwise_dispersion, prior_count=0)
|
|
1056
|
+
res = glm_residuals(fit, type=residual_type)
|
|
1057
|
+
res = np.where(np.isfinite(res), res, 0.0)
|
|
1058
|
+
ares = np.abs(res)
|
|
1059
|
+
wts = np.where(ares <= k, 1.0, k / np.maximum(ares, 1e-300))
|
|
1060
|
+
y.weights = pd.DataFrame(wts, index=y.counts.index, columns=y.counts.columns)
|
|
1061
|
+
if update_trend:
|
|
1062
|
+
estimate_glm_trended_disp(y, design=design, method=trend_method)
|
|
1063
|
+
estimate_glm_tagwise_disp(y, design=design, prior_df=prior_df)
|
|
1064
|
+
if record:
|
|
1065
|
+
history.append({"weights": wts.copy(), "tagwise_dispersion": np.asarray(y.tagwise_dispersion).copy()})
|
|
1066
|
+
if record:
|
|
1067
|
+
y.record = history
|
|
1068
|
+
return y
|
|
1069
|
+
|
|
1070
|
+
|
|
1000
1071
|
def pred_fc(y, design, prior_count: float = 0.125, offset=None, dispersion=None, weights=None) -> pd.DataFrame:
|
|
1001
1072
|
"""edgeR-style prior-count shrunken GLM coefficients on log2 scale."""
|
|
1002
1073
|
|
|
@@ -2095,19 +2166,6 @@ def _locfit_by_col(
|
|
|
2095
2166
|
return locfit_by_col(y, x=x, weights=weights, span=span, degree=degree)
|
|
2096
2167
|
|
|
2097
2168
|
|
|
2098
|
-
def _average_ql_dispersion(s2: np.ndarray, df: np.ndarray, ave_log_cpm: np.ndarray) -> float:
|
|
2099
|
-
ok = np.isfinite(s2) & (s2 > 0) & np.isfinite(df) & (df > 1e-8)
|
|
2100
|
-
if not np.any(ok):
|
|
2101
|
-
return 1.0
|
|
2102
|
-
y = np.sqrt(np.sqrt(s2[ok]))
|
|
2103
|
-
x = np.asarray(ave_log_cpm, dtype=float)[ok]
|
|
2104
|
-
# edgeR uses lowess f=0.5, iter=3 and the 90% quantile of the trend,
|
|
2105
|
-
# bounded below by 1 on the QL dispersion scale.
|
|
2106
|
-
line = lowess(y, x, frac=0.5, it=3, delta=0.01 * (np.max(x) - np.min(x)), return_sorted=False)
|
|
2107
|
-
q = float(np.quantile(line, 0.9, method="linear"))
|
|
2108
|
-
return max(q**4, 1.0)
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
2169
|
def q2qnbinom(x, input_mean, output_mean, dispersion=0):
|
|
2112
2170
|
"""Approximate edgeR ``q2qnbinom`` mapping between NB distributions."""
|
|
2113
2171
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyedger
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Pure-Python port of Bioconductor edgeR — negative-binomial models for differential expression of count data (estimateDisp, glmFit, glmLRT, glmQLFTest, exactTest).
|
|
5
5
|
Author-email: Zehua Zeng <starlitnightly@163.com>
|
|
6
6
|
License: LGPL-3.0-or-later
|
|
@@ -22,6 +22,8 @@ License-File: LICENSE
|
|
|
22
22
|
Requires-Dist: numpy>=1.23
|
|
23
23
|
Requires-Dist: scipy>=1.10
|
|
24
24
|
Requires-Dist: pandas>=1.5
|
|
25
|
+
Provides-Extra: fast
|
|
26
|
+
Requires-Dist: numba>=0.57; extra == "fast"
|
|
25
27
|
Provides-Extra: dev
|
|
26
28
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
29
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -43,7 +45,8 @@ A **pure-Python port of [Bioconductor edgeR](https://bioconductor.org/packages/r
|
|
|
43
45
|
## Install
|
|
44
46
|
|
|
45
47
|
```bash
|
|
46
|
-
pip install pyedger
|
|
48
|
+
pip install pyedger # NumPy implementation
|
|
49
|
+
pip install "pyedger[fast]" # + numba kernels (several times faster, parallel over genes)
|
|
47
50
|
```
|
|
48
51
|
|
|
49
52
|
## Quick start
|
|
@@ -5,6 +5,7 @@ pyedger/__init__.py
|
|
|
5
5
|
pyedger/_glm_batch.py
|
|
6
6
|
pyedger/_limma.py
|
|
7
7
|
pyedger/_locfit.py
|
|
8
|
+
pyedger/_numba_kernels.py
|
|
8
9
|
pyedger/_ql_adjust.py
|
|
9
10
|
pyedger/core.py
|
|
10
11
|
pyedger.egg-info/PKG-INFO
|
|
@@ -12,5 +13,7 @@ pyedger.egg-info/SOURCES.txt
|
|
|
12
13
|
pyedger.egg-info/dependency_links.txt
|
|
13
14
|
pyedger.egg-info/requires.txt
|
|
14
15
|
pyedger.egg-info/top_level.txt
|
|
16
|
+
tests/test_numba_ql.py
|
|
15
17
|
tests/test_r_parity.py
|
|
18
|
+
tests/test_robust_disp.py
|
|
16
19
|
tests/test_smoke.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyedger"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Pure-Python port of Bioconductor edgeR — negative-binomial models for differential expression of count data (estimateDisp, glmFit, glmLRT, glmQLFTest, exactTest)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[project.optional-dependencies]
|
|
35
|
+
fast = ["numba>=0.57"]
|
|
35
36
|
dev = ["pytest>=7", "pytest-cov", "ruff"]
|
|
36
37
|
|
|
37
38
|
[project.urls]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The numba QL kernels agree with the NumPy path (ulp level)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import pyedger
|
|
11
|
+
from pyedger import _glm_batch as gb
|
|
12
|
+
from pyedger import _numba_kernels as nk
|
|
13
|
+
from pyedger import _ql_adjust as qa
|
|
14
|
+
|
|
15
|
+
pytestmark = pytest.mark.skipif(not nk.HAVE_NUMBA, reason="numba not available")
|
|
16
|
+
DATA = pathlib.Path(__file__).parent / "data"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="module")
|
|
20
|
+
def inputs():
|
|
21
|
+
X = pd.read_csv(DATA / "counts.tsv", sep="\t", index_col=0)
|
|
22
|
+
lib = X.columns.str.replace(r"_(KO|NT)$", "", regex=True)
|
|
23
|
+
cond = pd.Categorical(X.columns.str.split("_").str[-1], ["NT", "KO"])
|
|
24
|
+
D = pd.get_dummies(pd.DataFrame({"lib": pd.Categorical(lib), "cond": cond}), drop_first=True).astype(float)
|
|
25
|
+
D.insert(0, "Intercept", 1.0)
|
|
26
|
+
y = pyedger.normLibSizes(pyedger.DGEList(X, group=cond))
|
|
27
|
+
fit = pyedger.glmFit(y, D.to_numpy(), dispersion=0.1)
|
|
28
|
+
Y = X.to_numpy(float)
|
|
29
|
+
rng = np.random.default_rng(1)
|
|
30
|
+
disp = np.broadcast_to(rng.uniform(0.001, 3.9, Y.shape[0])[:, None], Y.shape)
|
|
31
|
+
W = rng.uniform(0.5, 2.0, Y.shape)
|
|
32
|
+
return Y, fit.fitted_values, D.to_numpy(), disp, W, pyedger.core.ave_log_cpm(y)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _both(monkeypatch, f):
|
|
36
|
+
a = f()
|
|
37
|
+
monkeypatch.setattr(nk, "HAVE_NUMBA", False)
|
|
38
|
+
b = f()
|
|
39
|
+
monkeypatch.setattr(nk, "HAVE_NUMBA", True)
|
|
40
|
+
return a, b
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_adj_vec_mat(inputs, monkeypatch):
|
|
44
|
+
Y, mu, X, disp, W, _ = inputs
|
|
45
|
+
for fn in (qa.compute_adj_vec, qa.compute_adj_mat):
|
|
46
|
+
a, b = _both(monkeypatch, lambda: fn(Y, mu, X, disp, 1.7, W))
|
|
47
|
+
for k in a:
|
|
48
|
+
# hat values of ill-conditioned weighted designs (zero fits) depend on the
|
|
49
|
+
# summation order inside dqrdc2, as R's own do on the BLAS: ~eps * cond
|
|
50
|
+
np.testing.assert_allclose(a[k], b[k], rtol=1e-12, atol=1e-10)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_ave_qd_and_one_group(inputs, monkeypatch):
|
|
54
|
+
Y, mu, X, disp, W, A = inputs
|
|
55
|
+
a, b = _both(monkeypatch, lambda: qa.compute_ave_qd(Y, mu, X, disp, A, W))
|
|
56
|
+
np.testing.assert_allclose(a, b, rtol=1e-12)
|
|
57
|
+
off = np.broadcast_to(np.log(Y.sum(0)), Y.shape)
|
|
58
|
+
a, b = _both(monkeypatch, lambda: gb.mglm_one_group(Y, off, disp, W))
|
|
59
|
+
np.testing.assert_allclose(a, b, rtol=1e-12)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_exact_weight_branch_falls_back(inputs):
|
|
63
|
+
# phi >= 4.001 uses the exact finite sums, which only the NumPy path implements
|
|
64
|
+
Y, mu, X, _, W, _ = inputs
|
|
65
|
+
out = qa.compute_adj_vec(Y[:20], mu[:20], X, np.full(20, 5.0), 1.0, W[:20])
|
|
66
|
+
assert np.all(np.isfinite(out["s2"]))
|
|
@@ -129,7 +129,8 @@ def test_design_ql_options(setup):
|
|
|
129
129
|
X, cond, D = setup
|
|
130
130
|
y = pyedger.estimateDisp(_dge(X, cond), design=D.to_numpy())
|
|
131
131
|
fit = pyedger.glmQLFit(y, D, top_proportion=0.2, abundance_trend=False)
|
|
132
|
-
|
|
132
|
+
# the NB dispersion is estimated by Cox-Reid on the top genes (RTOL_COXREID)
|
|
133
|
+
_check_ql(fit, pyedger.glmQLFTest(fit, coef=D.shape[1] - 1).table, "design_ql_top02_notrend", RTOL_COXREID, 1e-5)
|
|
133
134
|
|
|
134
135
|
|
|
135
136
|
def test_classic_path(setup):
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""estimate_glm_robust_disp / glm_residuals (edgeR estimateGLMRobustDisp)."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
import pyedger as pe
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _data(seed=1, G=400, n=8, n_out=20):
|
|
9
|
+
rs = np.random.RandomState(seed)
|
|
10
|
+
mu = rs.lognormal(4, 1.2, G)[:, None] * np.ones((1, n))
|
|
11
|
+
disp = 0.1
|
|
12
|
+
Y = rs.negative_binomial(1 / disp, 1 / (1 + disp * mu)).astype(float)
|
|
13
|
+
out = rs.choice(G, n_out, replace=False)
|
|
14
|
+
col = rs.randint(0, n, n_out)
|
|
15
|
+
Y[out, col] *= 20
|
|
16
|
+
counts = pd.DataFrame(Y, index=[f"g{i}" for i in range(G)], columns=[f"s{j}" for j in range(n)])
|
|
17
|
+
design = pd.DataFrame({"Intercept": 1.0, "cond": [0] * (n // 2) + [1] * (n - n // 2)},
|
|
18
|
+
index=counts.columns)
|
|
19
|
+
return counts, design, out, col
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_glm_residuals_pearson_and_deviance():
|
|
23
|
+
counts, design, _, _ = _data()
|
|
24
|
+
y = pe.DGEList(counts, group=["a"] * 4 + ["b"] * 4)
|
|
25
|
+
pe.estimate_disp(y, design=design)
|
|
26
|
+
fit = pe.glm_fit(y, design=design)
|
|
27
|
+
r = pe.glm_residuals(fit, type="pearson")
|
|
28
|
+
mu = fit.fitted_values
|
|
29
|
+
phi = np.asarray(fit.dispersion).reshape(-1, 1)
|
|
30
|
+
np.testing.assert_allclose(r, (counts.to_numpy() - mu) / np.sqrt(mu + phi * mu * mu), rtol=1e-12)
|
|
31
|
+
d = pe.glm_residuals(fit, type="deviance")
|
|
32
|
+
assert d.shape == r.shape and np.all(np.sign(d) == np.sign(counts.to_numpy() - mu))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_robust_disp_downweights_outliers():
|
|
36
|
+
counts, design, out, col = _data()
|
|
37
|
+
y = pe.DGEList(counts, group=["a"] * 4 + ["b"] * 4)
|
|
38
|
+
y = pe.estimateGLMRobustDisp(y, design=design)
|
|
39
|
+
w = y.weights.to_numpy()
|
|
40
|
+
assert np.all(w[out, col] < 0.5)
|
|
41
|
+
mask = np.ones_like(w, dtype=bool)
|
|
42
|
+
mask[out, col] = False
|
|
43
|
+
assert np.median(w[mask]) == 1.0
|
|
44
|
+
assert 0.05 < np.median(y.tagwise_dispersion) < 0.15
|
|
45
|
+
# the weights flow into the GLM fit
|
|
46
|
+
fit = pe.glm_fit(y, design=design)
|
|
47
|
+
assert np.all(np.isfinite(fit.coefficients))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|