pyedger 0.2.1__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.1
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
@@ -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",
@@ -996,6 +996,78 @@ def _fit_glm_batch(counts, X, offset_mat, phi_mat, W, start=None):
996
996
  return beta, mu, dev
997
997
 
998
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
+
999
1071
  def pred_fc(y, design, prior_count: float = 0.125, offset=None, dispersion=None, weights=None) -> pd.DataFrame:
1000
1072
  """edgeR-style prior-count shrunken GLM coefficients on log2 scale."""
1001
1073
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyedger
3
- Version: 0.2.1
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
@@ -15,4 +15,5 @@ pyedger.egg-info/requires.txt
15
15
  pyedger.egg-info/top_level.txt
16
16
  tests/test_numba_ql.py
17
17
  tests/test_r_parity.py
18
+ tests/test_robust_disp.py
18
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.1"
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"
@@ -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
File without changes
File without changes
File without changes