spacr.power_model¶
Bayesian sparse-regression inference for pooled-screen power analysis.
This is the inference half of a Python port of spaCRPower (https://github.com/maomlab/spaCRPower), by Matthew O’Meara (maom@umich.edu, ORCID 0000-0002-3128-5331), released under the MIT licence. The MIT licence requires the copyright notice be retained, so:
spaCRPower — Copyright (c) Matthew O’Meara. Released under the MIT licence. Ported to Python for spaCR; the statistical model (Poisson regression on log10 read-fraction with a horseshoe prior on the per-gene coefficients, scored by AUROC / average precision against ground-truth hit status) is his. Errors in the translation are ours.
The simulator half lives in spacr.power_simulate; this module only
consumes the tidy (gene, well) table it produces and never imports it
at module scope.
The question this module answers¶
How many wells, how many cells per well, and how good a classifier do I
need before a pooled CRISPR screen can actually find its hits? You
answer it by simulating screens you know the truth for, fitting the model
you would really use, and measuring how well the fit recovers the hits
you planted. scan_parameters() sweeps that loop over a design grid.
The model¶
One row per well. Npositive positive cells out of Ntotal imaged,
with a per-gene covariate log10expression — the log10 fraction of the
well’s sequencing reads assigned to that gene:
Npositive_w ~ Poisson(exp(alpha + sum_g beta_g * x_wg + log(Ntotal_w)))
beta_g is the evidence that gene g is a hit: higher means the wells
carrying more of gene g had more positive cells than their cell count
alone explains. There are far more genes than wells (p >> n), so
beta is given a regularized horseshoe prior (Piironen & Vehtari,
Electron. J. Statist. 11(2), 2017) — heavy tails so genuine hits escape
shrinkage, a sharp spike at zero so the hundreds of non-hits collapse
onto it, and a Student-t slab so the tails stay proper.
Backends¶
The R original fits this with brms + cmdstanr (full NUTS). There
is no Python drop-in: cmdstanpy needs a C++ toolchain at install
time, which is not acceptable for a pip-installed package. So the
backend is pluggable:
"torch"(default, always available)Mean-field ADVI — automatic-differentiation variational inference — in a non-centred parameterisation, using the torch spaCR already depends on. Zero new dependencies, seconds not minutes, and it will run on the GPU if asked.
"numpyro"/"pymc"(optional, lazily imported)Exact NUTS, if the user has installed either. Never added to
install_requires."auto"numpyro, else pymc, else torch — and it says which, both in the log and in
PowerFit.backend, so a result can never be mistaken for one produced by a different method. Pinbackend=explicitly if you need two machines to agree bit for bit.
What ADVI costs you versus NUTS¶
ADVI is an optimisation, not a sampler, and mean-field means the approximating family is a product of independent Gaussians in the unconstrained space. Concretely:
Intervals are too narrow. Mean-field VI systematically underestimates posterior variance, because it cannot represent posterior correlations and pays no penalty for ignoring them. Treat
q5/q95from the torch backend as a spread indicator, not as a calibrated 90 % interval, and never quote them as one in a paper.No convergence diagnostics worth the name. There is no R-hat, no effective sample size. All you get is “did the ELBO stop moving”, which is reported as
PowerFit.convergedand is a much weaker claim than NUTS convergence.It can land in a local optimum, and the horseshoe’s funnel geometry is exactly the sort of thing that causes it.
The posterior mean of beta — the ranking statistic — is the part that survives. AUROC and average precision depend only on the order of the
betaestimates, and that ordering is what mean-field VI gets right even when it gets the spread wrong. This is why ADVI is a defensible default for power analysis specifically, and why it is not a defensible default for reporting a per-gene credible interval.
Use backend="numpyro" or backend="pymc" when the interval itself
is the deliverable.
Things this module refuses to get wrong¶
A gene with no across-well variance is not “not a hit”. If a gene’s
log10expression column is constant, its coefficient is perfectly
confounded with the intercept and carries no information whatsoever.
The horseshoe will happily shrink it to ~0, which reads exactly like
“tested, and not a hit”. That is a believable wrong answer, so those
genes get NaN and are counted in
n_unidentified_dropped instead.
A degenerate evaluation is NaN, never 0.5. If every gene is a hit,
or none is, AUROC is undefined. Returning 0.5 there would be
indistinguishable from “the method has no signal”, which is a completely
different finding. Degenerate cases return NaN plus a reason
string.
A failed fit is reported as failed. scan_parameters() records
status="failed" or status="not_converged" with the exception
text, and NaN metrics. A sweep that silently backfills 0.5 for the
points that blew up looks, on a plot, exactly like a design that is at
chance — which is the single most expensive mistake this module could
make.
The sign convention is pinned down and tested. See
evaluate_model_fit().
Attributes¶
Concrete backends, in the order |
Exceptions¶
The power model could not be fit, so it has no estimate to give. |
Classes¶
Functions¶
|
Report which backends this interpreter can actually run. |
|
Score the fit against ground-truth hit status: AUROC and average precision. |
|
Run the whole inference chain on one simulated screen. |
|
Fit the horseshoe Poisson model and return posterior draws of |
|
Summarise the per-gene coefficients, one row per gene. |
|
Collapse a tidy |
|
Turn a requested backend into the one that will actually be used. |
|
Sweep a design grid: simulate, fit, score, at every combination. |
Module Contents¶
- exception spacr.power_model.PowerFitError[source]¶
Bases:
spacr.errors.SpacrErrorThe power model could not be fit, so it has no estimate to give.
Raised rather than returning a fit object with plausible-looking numbers in it.
scan_parameters()catches this per grid point and records the point as failed; everywhere else it propagates.Initialize self. See help(type(self)) for accurate signature.
- class spacr.power_model.ModelData[source]¶
Well-level design for the Poisson model, one row per well.
- Parameters:
wells – retained well identifiers in design-row order.
genes – gene identifiers in the column order of
log10expressionand fitted gene effects.Npositive – positive-cell counts per retained well, summed over genes.
Ntotal – imaged-cell counts per retained well; every value is positive because the model uses
log(Ntotal)as its offset.log10expression –
(n_wells, n_genes)matrix oflog10(reads_gene_well / reads_well + 1e-4).dropped_wells – wells removed because their imaged-cell count was not positive.
zero_read_wells – retained wells with no sequencing reads; their expression fractions use the pseudocount rather than
nan.unidentified_genes – genes whose expression column is constant across retained wells and whose fitted effect must remain unidentified.
- to_frame() pandas.DataFrame[source]¶
Return the design as a wide DataFrame, for eyeballing.
Columns are
well,Npositive,Ntotaland onelog10expressioncolumn per gene, named after the gene.- Returns:
pandas.DataFramewithn_wellsrows.
- class spacr.power_model.PowerFit[source]¶
Posterior draws of the per-gene coefficients, plus how they were got.
- Variables:
backend – the backend that actually ran. Recorded so a result can never be mistaken for one produced by a different method.
requested_backend – what the caller asked for (
"auto", or a name).method –
"advi"or"nuts".draws –
(n_draws, n_genes)posterior draws ofbeta. Columns whose gene is unidentified areNaNthroughout.intercept_draws –
(n_draws,)posterior draws of the intercept, on the centred covariate scale.genes – gene labels, aligned with the columns of
draws.converged – whether the fit met its own convergence criterion. For ADVI that means the ELBO stopped improving; for NUTS it means no divergences and R-hat within tolerance.
diagnostics – backend-specific detail (ELBO trace, R-hat, timings, prior scales). Always includes
beta_scale, naming the unitsbetais in.data – the
ModelDatathat was fit.seed – the seed the fit was run with.
- spacr.power_model.available_backends() Dict[str, bool][source]¶
Report which backends this interpreter can actually run.
- Returns:
mapping from backend name to availability.
torchis always True in a working spaCR install, because spaCR depends on it.
>>> available_backends()["torch"] True
- spacr.power_model.evaluate_model_fit(data: pandas.DataFrame, model_estimate: pandas.DataFrame) pandas.DataFrame[source]¶
Score the fit against ground-truth hit status: AUROC and average precision.
Port of
evaluate_model_fit.The sign convention, spelled out, because it is the easiest thing in this port to get backwards and the easiest to not notice. The R computes
mean_inv = -mean, recodeshitas a factor with levelsc("no", "yes"), and hands both toyardstick.yardstick’s defaultevent_levelis"first", so the event it scores is"no"— the non-hits — and the score it scores them with is-mean. Scoring-mfor the event “not a hit” is identical to scoring+mfor the event “is a hit”: both ROC curves are the same curve. So here we do the equivalent and much more legible thing,roc_auc_score(y_true=hit, y_score=mean), andspacr.power_model.evaluate_model_fit()’s tests pin the orientation on a case whose answer is known — strong planted hits must score near 1, not near 0. A flipped convention returns1 - AUROC, which for a mediocre design looks entirely plausible.Average precision uses scikit-learn’s step-wise definition (
sum_n (R_n - R_{n-1}) P_n), the same estimatoryardstickuses, so the two are comparable.- Parameters:
data – any frame carrying ground truth, with columns
geneandhit(0/1 or boolean). The tidy screen table works directly;hitmust be constant within a gene.model_estimate – output of
gather_model_estimate(), or any frame withgeneandmean.
- Returns:
one-row
pandas.DataFramewithmodel_ap,model_auroc,ap_baseline(the prevalence, which is what average precision would be at chance),n_genes_scored,n_hits,n_non_hits,n_unidentified_dropped,n_missing_truthandreason. Degenerate cases giveNaNmetrics and a non-emptyreason; they never give 0.5.- Raises:
PowerFitError – on missing columns, or a gene whose
hitstatus is inconsistent between rows.
>>> import pandas as pd >>> truth = pd.DataFrame({"gene": ["A", "B", "C", "D"], ... "hit": [1, 0, 0, 0]}) >>> est = pd.DataFrame({"gene": ["A", "B", "C", "D"], ... "mean": [2.0, 0.1, 0.0, -0.1]}) >>> float(evaluate_model_fit(truth, est)["model_auroc"].iloc[0]) 1.0
- spacr.power_model.fit_and_evaluate(data: pandas.DataFrame, *, fill_missing: bool = False, **fit_kwargs: Any) Tuple[PowerFit, pandas.DataFrame, pandas.DataFrame][source]¶
Run the whole inference chain on one simulated screen.
prepare_model_data->fit_model->gather_model_estimate->evaluate_model_fit, which is the sequence every vignette in the R package runs and the sequencescan_parameters()runs at each grid point.- Parameters:
data – tidy screen table (see
prepare_model_data()).fill_missing – passed to
prepare_model_data().fit_kwargs – passed to
fit_model().
- Returns:
(fit, model_estimate, model_evaluation).
- spacr.power_model.fit_model(model_data: ModelData, *, backend: str = 'auto', seed: int = 0, standardize: bool = False, expected_hits: float | None = None, scale_global: float | None = None, df_local: float = 10.0, df_global: float = 1.0, df_slab: float = 4.0, scale_slab: float = 2.0, n_draws: int = 1000, n_steps: int = 3000, n_mc_samples: int = 8, learning_rate: float = 0.05, convergence_tol: float = 0.001, device: str = 'cpu', n_warmup: int = 1000, n_samples: int = 1000, n_chains: int = 4, max_tree_depth: int = 12, target_accept: float = 0.9) PowerFit[source]¶
Fit the horseshoe Poisson model and return posterior draws of
beta.Port of
compile_model+fit_modelfrom spaCRPower’sR/fit_model.R, which runbrms::brm(Npositive ~ 1 + log10expression + offset(log(Ntotal)), family = poisson, prior = horseshoe(df = 10)).df_local=10here is thatdf = 10.- Parameters:
model_data – output of
prepare_model_data().backend –
"auto","torch","numpyro"or"pymc". Seeresolve_backend(); the choice is recorded on the result.seed – RNG seed. The same seed and backend give the same estimates.
standardize – scale each covariate column to unit SD before fitting, so the horseshoe shrinks every gene on a common scale. Changes the units of
beta(recorded indiagnostics["beta_scale"]). DefaultFalse, matching the R.expected_hits – prior guess at the number of true hits, used for the horseshoe’s global scale. Default: 5 % of the library.
scale_global – set the horseshoe global scale
tau0outright, ignoringexpected_hits.df_local – degrees of freedom of the local shrinkage half-Student-t.
brms’shorseshoe(df=...).df_global – degrees of freedom of the global half-Student-t; 1 is a half-Cauchy, the standard choice.
df_slab – degrees of freedom of the regularising slab.
scale_slab – scale of the regularising slab; caps how large a coefficient the prior will entertain.
n_draws – posterior draws to keep (torch backend; the NUTS backends keep
n_samples * n_chains).n_steps – ADVI optimisation steps (torch backend).
n_mc_samples – Monte-Carlo samples per ELBO gradient (torch).
learning_rate – Adam learning rate (torch).
convergence_tol – relative ELBO drift below which the ADVI fit is called converged (torch).
device – torch device;
"cpu"by default because the problem is small and CPU float64 is bit-reproducible across machines.n_warmup – NUTS warmup iterations (numpyro/pymc).
n_samples – NUTS post-warmup draws per chain (numpyro/pymc).
n_chains – NUTS chains (numpyro/pymc).
max_tree_depth – NUTS max tree depth (numpyro); the R uses 12.
target_accept – NUTS target acceptance (pymc).
- Returns:
- Raises:
PowerFitError – on an unusable design, an unavailable named backend, or a diverged optimisation.
>>> import numpy as np >>> md = ModelData( ... wells=np.array(["w1", "w2"]), ... genes=np.array(["A", "B"]), ... Npositive=np.array([1, 9]), ... Ntotal=np.array([100, 100]), ... log10expression=np.array([[-0.3, -0.3], [-1.0, -0.05]]), ... ) >>> fit = fit_model(md, backend="torch", n_steps=50, n_draws=16, seed=0) >>> fit.backend, fit.draws.shape ('torch', (16, 2))
- spacr.power_model.gather_model_estimate(fit: PowerFit) pandas.DataFrame[source]¶
Summarise the per-gene coefficients, one row per gene.
Port of
gather_model_estimate, which runsposterior::summarize_draws()and keeps theb_log10expression*variables. Thevariablecolumn reproduces that R naming so the two implementations’ outputs can be joined and compared directly.Higher
meanmeans stronger evidence the gene is a hit. Wells carrying more of a hit gene have more positive cells than their imaged cell count alone accounts for, which is a positive coefficient on log read fraction.- Parameters:
fit – result of
fit_model().- Returns:
pandas.DataFramewith columnsgene,variable,mean,sd,q5,q95,prob_positive(posterior probability thatbeta > 0) andidentified. Unidentified genes carryNaNin every numeric column andidentified=False.- Raises:
PowerFitError – if
fitis not aPowerFit.
>>> import numpy as np >>> fit = PowerFit( ... backend="torch", requested_backend="torch", method="advi", ... draws=np.array([[1.0, 0.0], [3.0, 0.0]]), ... intercept_draws=np.array([-4.0, -4.0]), ... genes=np.array(["A", "B"]), converged=True) >>> gather_model_estimate(fit)[["gene", "mean"]].to_dict("records") [{'gene': 'A', 'mean': 2.0}, {'gene': 'B', 'mean': 0.0}]
- spacr.power_model.prepare_model_data(data: pandas.DataFrame, *, fill_missing: bool = False) ModelData[source]¶
Collapse a tidy
(gene, well)screen table to the well-level design.Port of
prepare_model_datafrom spaCRPower’sR/fit_model.R. Per well:Npositive = sum over genes of `positive` Ntotal = cells imaged in the well log10expression = log10(n_reads_per_gene_per_well / total_reads + 1e-4)
Ntotalis read from animaging_n_cells_per_wellcolumn if the simulator provides one (it must then be constant within a well, or the table is malformed and we say so); otherwise it is summed fromimaging_n_cells_per_gene_per_well. The two agree by construction, since the per-gene counts are a multinomial split of the per-well total.Two deliberate departures from the R:
The R writes
Npositive = sum(well_data$positive[1])—sumof a single element, i.e. the positive count of whichever gene happens to sort first in the well, not the well’s total. That is a bug in the R (the surrounding documentation says “number of positive”), and reproducing it would throw away most of the response. We sum over all genes in the well.Genes whose covariate column ends up constant across wells are flagged in
ModelData.unidentified_genes. Their coefficients are not estimable and are reported asNaNrather than as a shrunk-to-zero “not a hit”.
- Parameters:
data – tidy screen table with one row per
(gene, well)pair. Required columns:gene,well,positive,n_reads_per_gene_per_well, and one ofimaging_n_cells_per_well/imaging_n_cells_per_gene_per_well.fill_missing – if the
gene x wellgrid is incomplete,False(the default) raises, because a missing pair is ambiguous — zero reads, or not measured?Truefills the missing pairs with zeros for every count column.
- Returns:
- Raises:
PowerFitError – on a missing required column, a duplicated
(gene, well)pair, an incomplete grid withfill_missing=False, a within-well inconsistentimaging_n_cells_per_well,Npositive > Ntotal, negative counts, or an empty design.
Worked example — two wells, two genes, gene
Bdominating wellw2:>>> import pandas as pd >>> tidy = pd.DataFrame({ ... "well": ["w1", "w1", "w2", "w2"], ... "gene": ["A", "B", "A", "B"], ... "positive": [1, 0, 0, 9], ... "imaging_n_cells_per_gene_per_well": [50, 50, 10, 90], ... "n_reads_per_gene_per_well": [500, 500, 100, 900], ... }) >>> md = prepare_model_data(tidy) >>> list(md.wells), list(md.genes) (['w1', 'w2'], ['A', 'B']) >>> list(md.Npositive), list(md.Ntotal) ([1, 9], [100, 100]) >>> md.log10expression.round(3).tolist() [[-0.301, -0.301], [-1.0, -0.046]]
- spacr.power_model.resolve_backend(backend: str = 'auto') str[source]¶
Turn a requested backend into the one that will actually be used.
"auto"prefers exact NUTS where it is installed — numpyro, then pymc — and otherwise uses torch, which always is. A named backend is never silently substituted: asking for pymc without pymc installed raises, because a power analysis quietly computed by a different inference method than the one in your methods section is a reproducibility failure, not a convenience.- Parameters:
backend –
"auto","torch","numpyro"or"pymc".- Returns:
the concrete backend name.
- Raises:
PowerFitError – for an unknown name, or a known one that is not installed.
>>> resolve_backend("torch") 'torch'
- spacr.power_model.scan_parameters(*, progress_file: str | None = None, resume: bool = True, on_error: str = 'record', backend: str = 'auto', seed: int = 0, n_replicates: int = 1, verbose: bool = False, simulate_fn: Callable[..., pandas.DataFrame] | None = None, fit_kwargs: Mapping[str, Any] | None = None, on_point: Callable[[Mapping[str, Any]], Any] | None = None, **parameters: Any) pandas.DataFrame[source]¶
Sweep a design grid: simulate, fit, score, at every combination.
Port of
scan_parametersfrom spaCRPower’sR/scan_parameters.R. Every keyword argument that is not one of the named options above is a simulator parameter; pass a scalar to hold it fixed and a sequence to sweep it. The grid is the Cartesian product, in the order the sweeps were given (matching R’stidyr::expand_grid), withparam_indexnumbered from 1 as in the R.A point that fails is reported as failed. A fit that raises lands as
status="failed"with the exception text inerror; a fit that runs but does not meet its convergence criterion lands asstatus="not_converged". Both carryNaNmetrics. This is the whole point: a sweep that backfilled 0.5 for its broken points would plot as a design sitting at chance, and “this design cannot find its hits” and “this fit crashed” are conclusions with opposite consequences.Resuming. With
progress_file, each completed row is appended to a TSV immediately, so a killed sweep loses at most the point it was on. Re-running withresume=True(the default) reads the file and skips points whoserun_keyis already there. The key is a digest of the parameter values, the replicate, the seed and the backend – not the row number – so extending or reordering the grid does not invalidate finished work.- Parameters:
progress_file – TSV to append completed rows to; created, with its parent directory, if absent.
resume – skip points already present in
progress_file. Withresume=Falseand a non-empty progress file, this raises rather than appending a second copy of every point.on_error –
"record"(default) logs the failure, writes the row and continues;"raise"re-raises, for debugging one point.backend – inference backend, see
resolve_backend().seed – master seed. Each point’s seed is derived from
(seed, param_index, replicate), so a point’s data does not depend on how many points ran before it.n_replicates – independent simulated screens per grid point. One screen at one setting is a single draw from a noisy process; the sweep is much easier to read with three or five.
verbose – log each point’s parameters and score as it runs.
simulate_fn – screen simulator; defaults to
spacr.power_simulate.simulate_screen().fit_kwargs – extra keyword arguments for
fit_model().on_point –
called once per completed (point, replicate) with a mapping
{"index", "total", "point_index", "n_points", "replicate", "resumed", "row"}—indexandtotalcount fits, one-based, soindex / totalis a progress fraction, androwis that fit’s result record.Returning exactly
Falsestops the sweep and returns the rows completed so far; anything else (includingNone) continues. This exists for one reason: a sweep is minutes long and a GUI has to be able to show where it is and to stop it without killing the thread. Between points is the only safe granularity — one fit is atomic — so a cancel is honoured after the fit in flight, not during it. The callback runs on the calling thread, inline; it must not block, and a callback that raises aborts the sweep rather than being swallowed, because a broken progress reporter is a bug in the caller and hiding it would leave the sweep running with nothing watching it.parameters – simulator parameters, scalar or sequence.
- Returns:
pandas.DataFrame, one row per (grid point, replicate), with every parameter column plus the columns in_SCAN_RESULT_COLUMNS. Rows loaded from a resumed progress file are included.DataFrame.attrscarriesn_planned(fits the full grid would have run) andcancelled(whetheron_pointstopped it early), so a short frame can be told from a small grid.- Raises:
PowerFitError – if no parameters were given,
on_erroris not recognised,n_replicates < 1, the progress file exists with a different column layout (appending misaligned rows to a TSV is a silent data-corruption bug, so we stop instead), or the progress file is non-empty andresume=False.
Example – sweep the number of wells, three replicates each, resumable. Every keyword here is forwarded verbatim to the simulator, so the full set is whatever
spacr.power_simulate.simulate_screen()accepts; the ones below are the design knobs a power analysis usually turns:scores = scan_parameters( n_genes_in_library=200, gene_hit_rate=0.05, n_wells_per_screen=[24, 48, 96], # the sweep class_pos_mu=0.99, class_neg_mu=0.01, ..., # remaining simulator arguments n_replicates=3, progress_file="scan/wells.tsv", ) solvable = scores.query("status == 'ok' and model_auroc >= 0.9")
Nested helpers¶
- _fit_numpyro_nuts.model()¶
Define the regularized-horseshoe Poisson model for NumPyro NUTS.
spacr/power_model.py:1011
- _fit_torch_advi._log_joint(theta: 'torch.Tensor') 'torch.Tensor'¶
Evaluate the unnormalized horseshoe-Poisson log posterior.
The parameter-independent Poisson factorial is omitted, so each leading draw receives the same optimizer-equivalent objective.
spacr/power_model.py:829
- _fit_torch_advi._unpack(theta: 'torch.Tensor')¶
Split the final parameter axis while preserving leading draws.
spacr/power_model.py:820
- prepare_model_data._matrix(column: str) np.ndarray¶
Pivot
columnto captured well-by-gene order as float64.spacr/power_model.py:414
- scan_parameters._report(row: Dict[str, Any], point_index: int, replicate: int, resumed: bool) bool¶
Hand one finished row to
on_point; True means “keep going”.Kept as a closure over the loop counters rather than inlined twice so the resumed and freshly-computed paths cannot drift apart — a progress bar that skips the rows a resume restored is a progress bar that runs backwards on the second attempt.
spacr/power_model.py:1949