Repository navigation
Conversation
Add a pytest suite for the numerical core: size factors and normalization, the variance-stabilizing transform, dispersion estimation, GLM fitting, Cook's distances and refitting, Wald tests, p-value adjustment and apeGLM shrinkage, through DeseqDataSet, DeseqStats, DefaultInference and the module-level functions behind them. The 300 tests sit in nine modules under tests/, with shared helpers in tests/helpers.py (a negative binomial simulator that records its true parameters, hand-worked inputs and reference implementations), fixtures in tests/conftest.py, DESeq2 1.50.2 reference outputs and the R scripts that wrote them under tests/data/unit_a/ and tests/data/unit_b/, tests/README_NEW_TESTS.md on running and extending the suite, and tests/TARGETS.md mapping every public class, method and function to its tests. No source, build or workflow file changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fit genes in blocks rather than one at a time, so that DeseqDataSet.deseq2(), DeseqStats.summary() and DeseqStats.lfc_shrink() take less time and return the existing results: - DefaultInference sends one task per block of batch_size genes to the loky workers instead of one per gene. fit_genewise_dispersions() fits mu_hat and the gene-wise dispersions in one map, deseq2() fits the MAP dispersions and LFCs in one map, and the Cook's refit fits its genes in one map. - The gene-wise and MAP dispersion fits and the apeGLM shrinkage step scipy's L-BFGS-B routine (setulb) for every gene of a block in lockstep and evaluate the objective of all waiting genes at once. The driver adapts the loop of scipy's _minimize_lbfgsb and carries SciPy's BSD-3 and the wrapper's MIT notices. It runs when the installed setulb has the expected signature (scipy 1.15 to 1.18); otherwise each gene goes through scipy.optimize.minimize as before. - IRLS, the linear-regression starting means and the Wald test run on stacked arrays; fit_lin_mu makes the one lstsq call LinearRegression makes, without the estimator. - The size factors, the method-of-moments dispersions and the Cook's distances are computed on column chunks across up to n_cpus threads, and independent filtering sorts the p-values once. Every fast path checks its inputs and falls back to the existing code, which raises the existing errors. Public signatures, defaults and the Inference interface are unchanged; an Inference subclass that overrides a method gets the existing call sequence. tests/test_threaded_passes.py checks every threaded pass against n_cpus=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This optimization was found with auto-optimize, a tool developed by the Programmable Genomics Laboratory at UMass Chan Medical School: it fits the genes of a block together, with scipy's L-BFGS-B stepped for every gene of a block in lockstep, batched IRLS and Wald tests, gene blocks as joblib tasks and threaded whole-matrix passes, while every output stays bitwise identical to main's. Across 38 timed configurations of 20 inputs, this branch runs 0.07x to 17.55x as fast as
main. The exception is PyDESeq2's bundled 10-gene example at 8 threads: a loky worker imports PyDESeq2 on its first task, and with 10 genes this branch gives each map a single block, so the timed call still meets cold workers (Behaviour changes). The output was checked againstmainin 421 comparisons, all of them identical; see How the output was checked.Most of the gain comes from three changes: stepping L-BFGS-B for every gene of a block together in the gene-wise and MAP dispersion fits, sending the loky workers one task per block of genes instead of one per gene, and the same lockstep L-BFGS-B in the apeGLM shrinkage. Batched IRLS and the
lstsqform offit_lin_mucome next. No OS-specific call is added: the threads useconcurrent.futures, and no file is written. Two constants set the threaded passes inmisc.py:_CHUNK_COLS = 1024genes per chunk, chosen by timing balanced chunk widths of 512 to 2,048 genes on the benchmark input on one AMD EPYC machine, and_MIN_THREADED_SIZE = 1 << 18count entries, below which the serial code runs. Neither is derived from the machine at run time. The batched Wald test takes 1,024 genes at a time, and gene blocks holdbatch_sizegenes, 128 by default as in main. The fork's GitHub Actions ran the suite and an output comparison with main on Linux (AMD EPYC 7763 and 9V74), Windows (AMD family 25 and Intel family 6 model 207) and macOS (Apple M1 and M2 Pro) runners.It builds on the tests pull request #492, which changes no source. It is one commit changing nine files, +2,957 / -173. It is opened against
mainwith the tests commit beneath it, so until #492 is merged GitHub shows both commits here; the change under review is the last one. The lockstep L-BFGS-B drivers adapt code from SciPy 1.18.1 (scipy/optimize/_lbfgsb_py.py,_minimize_lbfgsb; BSD-3-Clause, with that file's MIT notice by David M. Cooke): the per-gene L-BFGS-B state allocation andsetulbloop in_fit_alpha_mle_lockstepand_lbfgsb_stepindispersions.py, and in_nbinom_glm_lockstepindistributions.py. Both files carry the two notices verbatim in a comment block above that code. Nothing else is vendored or translated from another project.Usage benchmarks
main)All 38 configurations
main(s)mainpeak (MB)How it was timed
Machine: AMD EPYC 9575F 64-Core Processor, 256 cores; Linux 7.0.0-38-generic; Python 3.12.13; numpy 2.5.4, scipy 1.18.1 with its bundled OpenBLAS, pandas 3.0.6, joblib 1.6.0, scikit-learn 1.9.1, anndata 0.13.4; the four rows marked lowest supported dependencies use numpy 2.2.0, scipy 1.15.0, pandas 2.3.0, scikit-learn 1.6.0 and anndata 0.11.0. Two sockets of 64 cores, two NUMA nodes, 1 TB of memory. Both builds are pure Python: main and this commit were each installed with
uv pip installfrom agit archiveexport into a fresh Python 3.12.13 environment with the same dependency versions; the lowest-dependency rows install the same two sources with the lower versions.Inputs: Real inputs are recount3 GTEx v8 gene counts for lung, thyroid, skeletal muscle and tibial nerve (gene_sums divided by each sample's STAR average mapped length and rounded), obtained from the GTEx Portal through recount3 on 10/28/2026; synthetic inputs are negative binomial counts with log-normal gene means, a dispersion of 0.05 + 1/mean with log-normal noise and log-normal size factors, drawn with numpy's default_rng from fixed seeds 1 to 8, plus PyDESeq2's bundled synthetic data and an 8 vs 8 GTEx cut with all-zero, zero-inflated, outlier and 1e8-scaled genes (seed 20261028). Every row calls
DeseqDataSet(counts=..., metadata=..., design=..., n_cpus=t, quiet=True),deseq2(),DeseqStats(dds, contrast=..., n_cpus=t, quiet=True),summary()andlfc_shrink(coeff=...)with default options, on counts in a pandas DataFrame:~tissue, thyroid vs lung.~tissue, and the same withvst()alone; muscle (881) vs tibial nerve (659),~sex + tissue; every lung and thyroid sample with a RIN (1,361),~sex + rin + tissuewith RIN centred; lung alone, male vs female,~sex.~sex + rin + tissue; 250 muscle vs 250 nerve on every gene,~sex + tissue.~agewith age continuous and a numeric contrast; 1,000 vs 1,000 on 20,000 genes; 2 vs 2 on 200 and on 60,000 genes; 10 vs 10 on 5,000 genes with one outlier count in 20% of genes; 3 batches of 4 vs 4 on 10,000 genes,~batch + condition.How it was timed:
n_cpusphysical cores, one logical CPU each, within whole L3 domains (8 cores each) of NUMA node 0, holding every core of those domains so that no other run shared their L3 cache; the timed rows ran one at a time. The output-only runs of How the output was checked used up to 16 cores of NUMA node 1, the other socket, during most of the timing. Timed again at the end with nothing else of this testing running, all five rows agreed with the table within 2% on both builds. Other users' jobs left the machine 91 to 99% idle.DeseqDataSet(...)throughlfc_shrink()(for thevst()row,DeseqDataSet(...)andvst()), measured withtime.perf_counter.memory.peakover the timed calls (reset just before them): the process and its loky workers, each page once, and any page cache charged during the calls.obsandvar, eachvarm,obsm,layersandunsentry, the results table and the shrunk table) was compared with main's byte for byte.Outside the table, the 347 output-only configurations that completed on both builds ran two at a time, so their times are not controlled: they add up to 3,399 s on main and 806 s on this branch with current dependencies (263 configurations), 313 s and 51 s at the lowest supported versions (46), and 386 s and 75 s under the Haswell kernel (38).
Changes
In the order they were made:
src/pydeseq2/default_inference.py): The per-gene maps ofDefaultInference(lin_reg_mu,irls,alpha_mle,wald_test,lfc_shrink_nbinom_glm) send one joblib task per block ofbatch_sizegenes (128 by default) instead of one task per gene. Each task receives its block's per-gene inputs as one C-contiguous (genes, samples) array, runs a batched fit or the unchanged per-gene function on each row, and returns stacked results that the method concatenates in gene order.batch_size="auto"the blocks hold 128 genes and joblib batches the blocks itself; a value joblib rejects is still passed to joblib, which raises as on main.Parallelis built withmax_nbytes=None, so joblib pickles the blocks instead of memory-mapping large arguments.src/pydeseq2/default_inference.py,src/pydeseq2/dispersions.py): The gene-wise, MAP and refit dispersions of a block (dispersions.py:_fit_alpha_mle_lockstep,_lbfgsb_step,_alpha_mle_loss_grad;default_inference.py:_alpha_mle_block) are fitted by a driver that keeps one L-BFGS-B state per gene and calls scipy'ssetulbfor each gene asscipy.optimize.minimizewould, while the loss and gradient of all genes waiting for an evaluation are computed in one vectorized pass. The per-gene state allocation andsetulbloop are adapted from scipy's_minimize_lbfgsb.scipy.optimize._lbfgsb.setulb, the signature ofscipy.optimize._lbfgsb_py._minimize_lbfgsb(read withinspectfor the defaults ofmaxcor,ftol,gtol,maxfun,maxiterandmaxls, so a scipy that changes them is followed) andscipy.optimize._lbfgsb_py.HAS_ILP64(int32 when it is absent).setulb's documented signature is not the one the driver calls, when an import fails, or when_minimize_lbfgsblacks one of the six parameters,_alpha_mle_blockruns the original per-genefit_alpha_mle. The check runs once per process.min_disp < max_disp,prior_regwithoutprior_disp_var, the BFGS optimizer) run the per-gene path. Genes that do not converge go togrid_fit_alphawith the same arguments as before.src/pydeseq2/default_inference.py,src/pydeseq2/distributions.py): The apeGLM fits oflfc_shrink_nbinom_glm(distributions.py:_nbinom_glm_lockstep,_nbinom_glm_rows,_is_real_scalar;default_inference.py:_nbinom_glm_block) use the same kind of driver: one unboundedsetulbstate per gene withftol = gtol = 1e-8asnbinomGLMsets them, and the objective and gradient of all waiting genes in one vectorized pass.setulbcheck. When that check fails, and when an input is not a 2-D float64 C- or F-contiguous design, integer or float64 counts, float64 size and offset, real scalar prior scales or a valid shrink index,_nbinom_glm_blockrunsnbinomGLMper gene.grid_fit_shrink_betaone at a time, and the inverse Hessians come from one stackednp.linalg.inv.src/pydeseq2/dds.py,src/pydeseq2/default_inference.py,src/pydeseq2/glm.py): IRLS (glm.py:_irls_rows,_irls_rows_steps,_solve_rows,_solve_takes_stacks;default_inference.py:_irls_block) runs for all genes of a block together with an active set, as stacked products and solves laid out as the per-gene computation lays out its operands. A gene whose step leaves[-max_beta, max_beta]or reachesmaxiteris refitted from the start byirls_solver. Inputs the batched code does not handle, or any exception, send the whole block toirls_solverper gene, which raises or warns as on main.scipy.linalg.solveis called once on the stack when it accepts stacks (checked once at run time; scipy before 1.16 does not) and the design has more than one column, else per gene.With unique gene names,
DeseqDataSet._fit_parametric_dispersion_trendkeeps its targets and covariates as arrays plus an array of kept positions, and drops the genes with non-finite covariates with onenp.isfinitemask.drop.src/pydeseq2/default_inference.py,src/pydeseq2/glm.py):fit_lin_mu(glm.py:fit_lin_mu,_fit_lin_mu_rows,_lstsq_takes_stacks,_linear_regression_cond) makes the one callLinearRegression(fit_intercept=False).fitmakes,scipy.linalg.lstsq(X, y, cond=..., check_finite=False), thenX @ coef + 0.0, without the estimator's input validation.condis the value the installed scikit-learn passes: itstolfrom 1.9 on,max(X.shape) * epsfor 1.6 to 1.8. A design or quotient that is not a finite, non-empty float64 ndarray, or a scikit-learn version that cannot be read, usesLinearRegressionas before._lin_reg_mu_blockfits a whole block with one stackedlstsqcall where scipy accepts stacks (scipy before 1.16 does not), keeping one matrix-vector product per gene. A non-finite value, annp.errstatewhoseunderis not"ignore",LinAlgErrororValueErrorsends the block tofit_lin_muper gene.src/pydeseq2/default_inference.py,src/pydeseq2/glm.py):DefaultInference.wald_test(glm.py:_wald_test_rows,_wald_test_block) computes the test in the calling process, 1,024 genes at a time, with stacked products, a stackednp.linalg.inv, p-values fromscipy.special.ndtr(-x)(the functionnorm.sfevaluates) and element-wise forms of the fouralt_hypothesisbranches, all evaluated aswald_testevaluates them. Inputs it does not handle (arrays that are not float64 of the expected shapes; a contrast Series is accepted), or any exception, run the per-gene joblib path, which raises main's exception.src/pydeseq2/ds.py):DeseqStats._independent_filtering(ds.py:_sorted_tested_p_values,_bh_sorted) sorts the tested p-values once together with their base means and gene positions. For each of the 50 cutoffs, the sorted p-values of the genes at or above it are adjusted with the Benjamini-Hochberg arithmetic of scipy'sfalse_discovery_control(p * (m / rank), a reverse running minimum, a clip to [0, 1]) and the values belowalphaare counted. Only the chosen cutoff's adjusted p-values are scattered back to gene order; no genes x 50 frame is built.var_names, or a p-value outside [0, 1], keep the per-cutofffalse_discovery_controlloop.src/pydeseq2/dds.py,src/pydeseq2/default_inference.py,src/pydeseq2/dispersions.py): The terms of the dispersion loss that do not depend on the dispersion are computed once per block, and the shared terms once per evaluation. Each block computes its mu_hat (lin_reg_mu, orirlson designs with more distinct rows than coefficients) and then its gene-wise dispersions in the same task (default_inference.py:DefaultInference._genewise_dispersions,_genewise_block,_fits_genewise_in_one_map;dds.py:DeseqDataSet.fit_genewise_dispersions), sofit_genewise_dispersionsmakes one map instead of two. The_mu_hatlayer is filled from the returned blocks as before.DefaultInferencethat overrides none oflin_reg_mu,irls,alpha_mleand_genewise_dispersions. Any exception, or no gene, runs the two public calls, which raise as on main.src/pydeseq2/dds.py,src/pydeseq2/dispersions.py,src/pydeseq2/misc.py,src/pydeseq2/preprocessing.py): The median-of-ratios size factors (preprocessing.py:_threadable_counts,_column_bounds,_deseq2_norm_fit_threads,_deseq2_norm_transform_threads) and the Cook's distances (dds.py:DeseqDataSet.calculate_cooks,_cooks_threads;dispersions.py:_robust_moments_plan,_robust_moments_disp_columns) run on chunks of about 1,024 genes, or of samples for the medians, on threads, since numpy releases the GIL in these loops. The thread count isn_cpusread as joblib'sn_jobsand capped atjoblib.cpu_count()(misc.py:_thread_count,_chunk_bounds,_balanced_width,_thread_pool,_map_threads,_FPFlag); it is 1 for an inference without an integern_cpus._robust_moments_plancomputes the design-only part of the robust dispersions once; the publicrobust_method_of_moments_dispandtrimmed_cell_varianceare unchanged.src/pydeseq2/dds.py,src/pydeseq2/default_inference.py,src/pydeseq2/dispersions.py,src/pydeseq2/distributions.py,src/pydeseq2/ds.py): Several independent changes, each falling back to main's code for inputs andDefaultInferencemethods it does not replace:dds.py:DeseqDataSet.deseq2,_fit_MAP_and_LFC,_MAP_and_LFC_map,_stashed_LFC,fit_MAP_dispersions,fit_LFC,_same_bytes;default_inference.py:DefaultInference._MAP_dispersions_and_LFC,_map_lfc_block,_fits_MAP_and_LFC_in_one_map): indeseq2(), and in the Cook's refit when the one-map refit does not apply, each block fits its MAP dispersions and then runs IRLS on the dispersionsfit_MAP_dispersionswill store (the clipped MAP value, or the gene-wise value for outlier genes), so the LFCs need no second map. Both public methods still run and write every field with their own code.fit_LFCuses the stored fits only when the dispersions, size factors and design it reads equal byte for byte what IRLS was given and the inference,X,non_zero_idx,min_muandbeta_tolare the same objects; otherwise it callsirlsas before. This runs only for aDefaultInferencethat overrides none ofalpha_mle,irlsand_MAP_dispersions_and_LFC; any exception runs the two public maps.dds.py:DeseqDataSet._refit_in_one_map,_refit_trend_and_prior;default_inference.py:DefaultInference._refit_dispersions_and_LFC,_refit_block,_fits_refit_in_one_map): the refit's gene-wise dispersions, MAP dispersions and IRLS run per block in one map, since the refit takes its trend and prior from the main fit and every later step is per gene. The fields copied back are built with the same pandas statements asfit_genewise_dispersions,fit_MAP_dispersionsandfit_LFC, andfit_LFCruns again if the dispersions IRLS was given are not byte-equal to what it would read. ADefaultInferencethat overrides any oflin_reg_mu,irls,alpha_mle,_genewise_dispersionsand_refit_dispersions_and_LFC, any exception, or no gene to fit runs the three steps as before.dds.py:DeseqDataSet._refit_without_outliers):replace_cooksis zeroed for all replaceable samples and refitted genes in onenp.ix_assignment instead of one gene at a time. A layer that is not a 2-D ndarray, or anobs["replaceable"]that is not a bool mask of the samples, keeps the per-gene loop.dds.py:DeseqDataSet._fit_MoM_dispersions,_mom_dispersions_threads): when the inference'sfit_rough_dispersionsandfit_moments_dispersionsare PyDESeq2's own and more than one thread is allowed, the regression over all non-zero genes is onescipy.linalg.lstsqcall in the calling thread, the callLinearRegression.fitmakes (withcheck_finite=False); the gathering of the non-zero genes, the moments and rough estimates, their minimum and the clip run on column chunks on threads. Fewer than 2^18 normed-count entries, 1,024 or fewer non-zero genes, a design that is not float64 with more samples than columns, an unreadable scikit-learn version, a non-finite or all-zero column, a floating-point flag, a warning fromlstsq, or an exception run the original code.Series.drop; the Series given todispersion_trend_gamma_glmare rebuilt from the kept positions with the same values, dtype, name and index labels. Predictions that are not an ndarray of the kept genes' shape (from a custom inference) switch to the label code from that pass on.ds.py:DeseqStats.lfc_shrink): the shrunk SEs are onenp.sqrt(np.abs(inv_hessians[:, i, i]))when the inverse Hessians are a 3-D float64 array, and LFC, SE and the convergence flags are written by position whencoeff_idxis an int, the gene names are unique and the LFC and SE indexes equalvar_names. Other forms, such as a custom inference returning a list of Hessians, keep the original code.counts + sizeonce per block, and both lockstep L-BFGS-B drivers readsetulb's task word with scalar reads.src/pydeseq2/dds.py,src/pydeseq2/default_inference.py,src/pydeseq2/dispersions.py,src/pydeseq2/distributions.py,src/pydeseq2/ds.py,src/pydeseq2/glm.py,src/pydeseq2/misc.py,src/pydeseq2/preprocessing.py,tests/test_threaded_passes.py): - Notices:dispersions.py(above_SETULB_SIGNATURE) anddistributions.py(above_nbinom_glm_lockstep) carry SciPy's BSD-3-Clause license and the David M. Cooke MIT notice ofscipy/optimize/_lbfgsb_py.pyverbatim.setulb.__doc__, with its whitespace removed, starts with the signature the driver calls, which holds on scipy 1.15 to 1.18 (1.15 to 1.17 print it without spaces).float(), asminimizeconverts bounds to float64, so annp.longdoublemin_dispormax_dispgives main's result.except Exceptionblocks, each guarding a fast path whose fallback reproduces main's error, carry# noqa: BLE001, and the remaining mypy findings carry# type: ignore[<code>]as elsewhere in the package.tests/test_threaded_passes.pybuilds inputs just above 2^18 count entries with more than 1,024 genes (24 samples x 11,200 genes, and 260 samples x 1,100 genes) and checks that every output of the public calls withn_cpus=2equals then_cpus=1call's, NaN positions included: two groups throughdeseq2()and step by step, a continuous covariate, 260 samples,vst()with and without the design, duplicated gene names, and a custom inference returning float32 dispersions. No other test in the suite has an input of this size, so none reaches the threaded passes.src/pydeseq2/dds.py): - Thecounts_to_refitAnnData that_refit_without_outliersreplaces has itsobsmcleared, so its copies of_mu_LFCand_hat_diagonalsare freed at once instead of by the cyclic garbage collector, which can run after the next allocation peak.X @ coefonce over the whole matrix in the calling thread, asLinearRegression.predictmakes it, instead of per chunk, where BLAS rounded the chunk edges differently from the whole-matrix product and the results moved with the thread count.Behaviour changes
n_cpus1 and 2. On the one Apple M2 Pro runner, 27 of 44 synthetic outputs were bitwise identical and the rest differed by at most 1.96e-12 relative (Cook's distances; LFCs and p-values by at most 1.8e-13); PyDESeq2's example data was bitwise identical there, and this branch gave the same bits atn_cpus1 and 2. The cause of the M2 Pro difference was not investigated.quiet=False: the same lines in the same order, but some are printed after the single map they now describe. "Fitting dispersions..." is printed after the one map offit_genewise_dispersions, and its time covers mu_hat. Withindeseq2()andrefit(), "Fitting MAP dispersions..." includes the LFC fit and "Fitting LFCs..." only collects it, and the Cook's refit prints its three pairs after its one map. Withjoblib_verbosity > 0, joblib counts blocks ofbatch_sizegenes,deseq2()makes fewer maps, and the Wald test prints no joblib progress.n_cpusalso sizes short-livedThreadPoolExecutorpools in the calling process (read as joblib'sn_jobs, capped atjoblib.cpu_count()) for the size factors, the method-of-moments dispersions and the Cook's distances, used only with at least 2^18 count entries and more than 1,024 genes. The Wald test runs in the calling process with its BLAS threads, so its floating-point warnings, if any, are issued there (main: on loky workers with one BLAS thread each whenn_cpus> 1). Withn_cpus=1no pool is used.lstsqchoose the per-gene code from their inputs (and, forlstsq, onLinAlgErrororValueError); any other exception raised inside them propagates from the batched code.n_cpus=8, main's joblib sends each map as ten one-gene tasks, so its first call reaches most workers; this branch sends each map as one block on one worker, so workers that have not run a task yet are still cold in later calls, and those calls can take longer than main's (Usage benchmarks). Inputs with at leastbatch_size * n_cpusnon-zero genes give each map at least one task per worker.Series.drop(inplace=True)on every pass, which warns under pandas 3.1.0. On this branch that call remains only on the label-based path (duplicated gene names, or a custom inference whose predictions are not an ndarray of the kept genes' shape), so with unique names andDefaultInferencethe warning no longer appears.np.longdoublemin_dispandmax_dispgive main's results; a longdoublemin_muorprior_LFC_varraises main'sTypeError.Inferencesubclasses: the one-map fits run only on aDefaultInferencethat overrides none of the methods they replace, and the threaded method-of-moments dispersions only whenfit_rough_dispersionsandfit_moments_dispersionsare PyDESeq2's own; a subclass that overrides one, and any otherInference, get main's call sequence. A subclass that only callssuper()gets the block implementations. The threaded size-factor and Cook's passes call no inference method and run for anyInferencewith an integern_cpus.setulbchanges its signature would send every fit back toscipy.optimize.minimizeper gene. Stackedsolveandlstsqneed scipy 1.16 or later; on 1.15 IRLS and_lin_reg_mu_blocksolve gene by gene. Thecondgiven tolstsqfollows the installed scikit-learn. Independent filtering reproduces the arithmetic of scipy'sfalse_discovery_controland would not follow a change to it.dds.py,default_inference.pyandmisc.py, and feat: add likelihood ratio test (LRT) to DeseqStats #452 already conflicts with main. Fix prior curvature in shrinkage Hessian #470 (the prior's curvature innbinomGLM's Hessian), Preserve dispersion regularization in optimizer fallback #471 (the dispersion prior passed to the grid fallback offit_alpha_mle) and Honor the requested coefficient in shrinkage grid fallback #472 (shrink_indexpassed togrid_fit_shrink_beta) fix per-gene functions that the batched code does not call, so their fixes would not take effect here; whichever of them lands second needs the same change in_nbinom_glm_rows,_fit_alpha_mle_lockstepor_nbinom_glm_lockstep.Tests and CI
The
testssuite: 373 passed, 0 skipped and 0 failed on this branch. The repository's two workflows and its pre-commit hooks were run locally on this commit's tree, on Linux x86-64, with this commit's parent, the tests pull request's head, run the same way as the control. Eachhatch-testenvironment was created fresh withuvx hatch env createand run with the workflow's command,uvx hatch run <env>:run-cov -v --color=yes -n 4, on 8 dedicated cores; the workflow's-n autogives 4 workers on GitHub's 4-core runners.get-environments(uvx hatch env show --json)hatch-test.py3.12-stablehatch-test.py3.14-stablehatch-test.py3.14-pre (PRE-RELEASE DEPENDENCIES)continue-on-error)generate coverage reportUpload coverageTests pass in all hatch environmentsCheck Build:uv buildCheck Build:uvx twine check --strict dist/*.whlThe 51 failures under pandas 3.1.0 come from main's test setup, not from this change: 50 are
Pandas4Warnings about thefuture.infer_stringoption, which anndata 0.13.4 reads and the suite'sfilterwarnings = errorturns into errors, and 1 is the deprecatedinplacekeyword ofDataFrame.renameintests/test_pydeseq2.py. The list of failing tests is identical on the parent and on this commit in all three environments. Outside the workflows, the suite also passes in full on both builds (373 tests here, 365 on the parent) at the lowest supported versions (numpy 2.2.0, pandas 2.3.0, scipy 1.15.0, scikit-learn 1.6.0, anndata 0.11.0, formulaic 1.0.2, with pyparsing below 3.3, as matplotlib 3.10.0 warns on import under 3.3 and the suite makes warnings errors) and with pandas 2.3.3. On the fork's GitHub Actions, with the workflow's matrix run onubuntu-latest,windows-latestandmacos-latest, the 373 tests failed exactly main's 51 under pandas 3.1.0 in every environment and none under pandas below 3.1, the build passed, and nopydeseq2-*temporary folder was left in any of 18 test and 24 comparison jobs. The Codecov upload needs the repository's token. Line coverage is 91.6% on this commit (194 of 2,309 lines not reached) against 94.2% on the parent.How the output was checked
The output was checked against
mainin 421 comparisons, all of them identical. They covered 26 inputs from 2 assays (10 GTEx count matrices and 16 synthetic count matrices), among them 3 malformed count matrices and 4 edge-case designs and count matrices. Each comparison is a pair of fresh processes of main and this branch on one input, at one thread count, with one set of options: 1, 2, 4, 8 and 16 threads, the default calls and nine option variants, under two sets of dependency versions and two of OpenBLAS's CPU kernels. Each run saved every array a user can read afterdeseq2(),summary()andlfc_shrink(), or aftervst(): each column ofobsandvar, eachvarm,obsm,layersandunsentry, the results table of every contrast and the shrunk table, together with the warnings raised, the progress text printed underquiet=False(numbers masked) and the exception, if one was raised. The 17,497 arrays were compared byte for byte.Bugs in
mainfound along the way. The tests pull request's suite found bugs in main, filed as #491. Each bug's reproduction was run again against this branch's install, and each prints the same output as on main except the pandas 3.1.0 deprecation warning fromSeries.drop(inplace=True)in_fit_parametric_dispersion_trend, which does not occur on this branch when the gene names are unique and the trend's columns are float64, because the trend fit drops genes by position there.Builds, commands, results and limits
main)uv pip installfrom agit archiveexport into a fresh Python 3.12.13 environment: numpy 2.5.4, scipy 1.18.1, pandas 3.0.6, joblib 1.6.0, scikit-learn 1.9.1, anndata 0.13.4OPENBLAS_CORETYPE=Haswellsolveandlstsq, scikit-learn 1.6'scondWhat ran
vst()on one full experimentrefit_cooks=False,fit_type="mean",poscountssize factors,low_memory=True,greaterAbswithlfc_null=0.5andadapt=False, no independent or Cook's filtering,quiet=False, andvst()without and with the design;iterativesize factors on 4 inputs of at most 5,000 genes;refit_cooks=Falseandvst()on two full experiments"Identical" means every array, warning, printed line and exception is byte for byte the same. "Identical failure" means both builds raised the same exception with the same message: the three malformed count matrices (
ValueErrornaming the NaN, the fraction or the negative value), the all-zero counts (scikit-learn'sValueErrorfromLinearRegression, reached on both builds), and the two designs naming a level the data lacks. No configuration differs. On the~sex + rin + tissueexperiment (1,361 samples), main's own results depend on the thread count (between 4 and 8 threads its trend coefficients move by about 1e-8 relative and 0.18% of the genes move by more than 1e-2), and this branch reproduces main's results at each of 1, 2, 4, 8 and 16 threads.Sanitizers. PyDESeq2 is pure Python, so no sanitizer build applies, and no race detector was run on the threaded passes. Those passes write disjoint slices of preallocated arrays; their outputs were compared with main's at 2, 4, 8 and 16 threads.
Threads. At
n_cpus=1neither build starts a thread pool or a worker process. In the 20 one-thread configurations with the default options that ran to completion, the process had a single thread on both builds and each used one CPU-second per wall-second (median 1.00 on both), and this branch used a median 3.6 times less CPU time than main (from 0.96 to 19 times), so the gain at one thread comes from doing less work. Atn_cpus=8both builds run 8 loky workers; at its peak main's process had 17 threads and this branch's 17 or 25, the extra ones being its thread pools, and the median CPU time per wall-second over the 20 eight-thread configurations was 6.1 on main and 6.3 on this branch.Memory. Over the 38 timed configurations, this branch's peak memory is a median 0.99 of main's, from 0.84 to 1.33. It is higher by more than 5% in two: 250 vs 250 samples with 5,000 genes at 8 threads (+33%) and 1,000 vs 1,000 synthetic samples at 16 threads (+17%). On all 1,361 lung and thyroid samples it is 0.89 of main's at 1 thread and 1.04 at 8 threads.
Not covered. The comparisons here ran on Linux x86-64 with scipy's bundled OpenBLAS; the fork's GitHub Actions compared outputs on Windows and macOS runners (Behaviour changes), and other CPUs and BLAS builds (MKL, Accelerate) remain untested. Dependency versions between the two sets were tested only on the benchmark inputs (outputs under scipy 1.15.0, 1.15.3, 1.16.3 and 1.17.1 were bitwise identical to main's under the same version); numpy 2.3 and 2.4, joblib other than 1.6.0, and Python 3.13 and 3.14 were not compared (the unit tests ran on 3.12 and 3.14). Options not exercised:
size_factors_fit_type="iterative"above 5,000 genes, designs with interaction terms,ref_level, a design given as a DataFrame,control_genes,min_replicatesother than 7, joblib backends other than loky,joblib_verbosity > 0, abatch_sizeother than the default, and customInferencesubclasses beyond the suite's test of one. Inputs were human bulk RNA-seq from four GTEx tissues only, at most 1,540 samples and 63,856 genes, and synthetic counts up to 2,000 x 20,000. The merges with open pull requests were not run on this branch.🤖 Generated with Claude Code