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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyedger
3
- Version: 0.2.0
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, vectorised across genes. Returns beta, mu, deviance, iter, failed."""
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 (vectorised)."""
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
- o = _rsort_with_index(xx)
1669
- xs, ys = xx[o], yy[o]
1670
- delta = 0.01 * (xs[k - 1] - xs[0])
1671
- ans = np.sort(_clowess(xs, ys, 0.5, 3, delta))
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.0
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
@@ -6,3 +6,6 @@ pandas>=1.5
6
6
  pytest>=7
7
7
  pytest-cov
8
8
  ruff
9
+
10
+ [fast]
11
+ numba>=0.57
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyedger"
7
- version = "0.2.0"
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
- _check_ql(fit, pyedger.glmQLFTest(fit, coef=D.shape[1] - 1).table, "design_ql_top02_notrend")
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