Contributing¶
Build from source¶
The package is a compiled Rust extension built with maturin, with uv managing the environment and lockfile.
make install # uvx maturin develop
make install-release # uvx maturin develop --release (use for benchmarking)
Day-to-day commands¶
Prefer the Makefile targets so flags stay consistent with CI:
make test # POLARS_MAX_THREADS=4 uv run --group testing pytest tests
make typing # pyrefly + pyright + mypy (all three, as in CI)
make lint # prek hooks (ruff, rumdl, ryl) + cargo fmt (nightly) + clippy
make benchmark # polars_stats vs scipy comparison report (tools/benchmarks/)
make test caps POLARS_MAX_THREADS=4 on purpose: it forces multi-thread, multi-chunk execution so the chunk- and
thread-invariance of sample actually gets exercised.
It does not pin a query engine, so it runs whichever one your environment resolves (POLARS_ENGINE_AFFINITY, else the
polars default). CI runs both, because the two chunk a plugin's inputs differently: in-memory calls it once over the
whole column, streaming once per morsel. A chunk-boundary or input-length bug can therefore pass under one and fail
under the other. Pin an engine with:
POLARS_ENGINE_AFFINITY=in-memory uv run --group testing pytest tests
POLARS_ENGINE_AFFINITY=streaming uv run --group testing pytest tests
To preview these docs locally:
Repository layout¶
polars-stats/
├── Cargo.toml
├── pyproject.toml
├── rust-toolchain.toml
├── src/
│ ├── lib.rs # pymodule entry + global allocator
│ ├── rng.rs # per-row RNG + the sampler drivers every plugin shells over
│ └── distributions/ # one Rust file per distribution
├── polars_stats/
│ ├── __init__.py # public exports
│ └── distributions/
│ ├── _base.py # ABCs + coercion helpers
│ └── _<name>.py # one Python class per distribution
├── tests/
│ ├── _registry.py # one DistSpec row per distribution, plus the shared contract frame
│ ├── conftest.py # the fixtures that deliver it
│ ├── distributions/ # flat, one file per behavioural contract, parametrised over the rows
│ ├── property/ # hypothesis-based invariant tests
│ └── scipy_parity/ # scipy reference-oracle tests
├── tools/ # hand-run tooling, outside the package and the test suite
│ ├── accuracy/audit.py # the mpmath tail-accuracy audit (`make audit`)
│ └── benchmarks/ # the polars_stats vs scipy harness (`make benchmark`)
└── docs/ # this documentation, one directory per Diataxis quadrant
Stack¶
Rust runtime¶
| Crate | Purpose |
|---|---|
polars / polars-arrow |
Series and expression types in Rust (pinned transitively by pyo3-polars; dtype-full adds ~3.4 MiB to the installed extension, ~0.9 MiB to the wheel, and stops an unknown dtype aborting the interpreter at the plugin boundary) |
polars-core |
POOL and its rayon re-export, so the multi-draw fill runs on the thread pool Polars itself uses |
pyo3-polars |
the #[polars_expr] macro and FFI glue (source of ABI churn) |
pyo3 |
Python FFI, abi3 for forward compatibility |
statrs 0.19 |
distribution math, and sampling except the binomial and uniform draws |
rand_distr 0.6 |
O(1)-amortised binomial draw (statrs' is O(n) per row); exact build version pinned by Cargo.lock, see Design notes |
rand 0.10 |
TryRng / SysRng for the unseeded root seed |
rand_pcg 0.10 |
Pcg64Mcg per-row RNG for deterministic seeded sampling |
serde |
deserialise the static seed kwarg |
Deliberately excluded: rand_chacha (replaced by rand_pcg; per-row ChaCha20
construction made sampling markedly slower, see Design notes),
ndarray (Polars is Arrow-native), scirs2-stats (pre-1.0). There is no direct rayon dependency either: the
multi-draw fill in rng.rs parallelises through the re-export in polars-core, on the same POOL Polars uses.
Python runtime¶
Only polars>=1.15. No other runtime dependencies.
Dev and CI tooling¶
- Dev dependencies are grouped in
pyproject.toml(testing,tools,typing,docs) and installed withuv sync --group .... - Tests run under
pytestwithscipy+numpyas the parity oracle (tests/scipy_parity/) andhypothesisfor property tests; thetools/benchmarks/comparison report measures wall-clock time and peak memory againstscipy.stats. - Python is checked by
ruff(lint + format) and three type checkers in CI (mypy,pyright,pyrefly); Rust bycargo fmt(nightly) andcargo clippy --all-features --all-targets -- -D warnings. Prose and config are linted byrumdl(Markdown),ryl(YAML),codespell,typos, andblacken-docs, wired through.pre-commit-config.yamland run byprekboth locally and in CI, so the hook versions pinned there are the single source of truth. Do not bypass the hooks with--no-verifyunless asked.
Adding a distribution¶
Copy an existing pair, modify, repeat. Use Uniform (_uniform.py / uniform.rs) as the canonical continuous example
and Bernoulli as the discrete one.
Each distribution opened up for contribution gets a GitHub issue carrying its exact spec and checklist: the parameterisation, the scipy equivalent, every closed form, the contract it settles, and the traps found the last time someone tried. That issue is canonical if it conflicts with this section, so please say so on the issue when it does. Ones labelled good first issue need no new Rust math; the rest need a special-function port. Resolve the choices the issue flags (a validator arity, a scipy reparameterisation, an out-of-regime convention) before writing code rather than halfway through, write the scipy-parity test first, and keep a pull request to one distribution.
-
Rust. Add
src/distributions/<name>.rs, register it insrc/distributions/mod.rs, and implement a#[polars_expr]only for methods that need it. Which ones those are, method by method, is tabulated in Design notes. Whatever you add, every plugin function owes three things:align_inputs(inputs)?first, before any cast, for any plugin with more than one input. Polars broadcasts nothing into a plugin andtry_*_elementwisetruncates to its shortest input, so skipping it silently drops every row past the first.tests/distributions/broadcast_test.pycatches a missed one. The value-keyed drivers inmod.rsskip it when every parameter is length 1: they check the constant once and never expand it. Route through them rather than writing that branch yourself.is_elementwise=True, which the sharedregister_pluginshim fixes for you. An aggregating plugin would breakoverandgroup_by, so it is a guard rather than a default.- Null in, null out. The
try_*_elementwisedrivers give you that; a raw.into_iter()over chunks does not, which is the one way to lose the contract while writing ordinary-looking Rust. - Always the samplers. Every one is a one-line shell over a driver in
src/rng.rs; none resolves a seed, coerces an input or writes a row loop itself, which is what keeps seeding,nullpropagation (anullin any input nulls the row) and the invalid-parameter error contract in one place. The per-row<name>_sample/<name>_samples(multi-draw, backingsamples) take the parameter columns plus a row index as the last input and callsample_per_row_binary/samples_per_row_binary(one parameter, passing itsParamDomain) orsample_per_row_ternary/samples_per_row_ternary(two, passing each parameter's coercer and thecheck_paramspass), plus abuildthat constructs the row's draw state. The constant-parameter fast paths<name>_sample_scalar/<name>_samples_scalarbuild once fromSampleScalarKwargs<<Name>Params>/SamplesScalarKwargs<<Name>Params>and handsample_by_index/samples_by_indexthe row index and a draw closure. Every driver takes its output dtype from whatdrawreturns, so no call site names a polars type. Give the distribution one namedfn drawand call it from all four shells; that shared call, not a test, is what keeps them byte-identical. - When a method needs a special function (
erf, log-gamma, regularized incomplete beta/gamma, ...) or has no elementary closed form: bind it instatrs(pdf/pmf,cdf,ppf,ln_pdf/ln_pmf, nativesf, nativemedian). Write one named*_valuebody per method,fn cdf_value(dist: &Dist, v: f64) -> Option<f64>, and call it from two one-line shells: the per-row<name>_<method>over the distribution's ownvalue_keyedhelper (itself one line overvalue_keyed_ternaryinsrc/distributions/mod.rs, orvalue_keyed_binaryfor one parameter, passing the coercers,check_paramsandbuild_dist), and the constant-parameter<name>_<method>_scalaroverkwargs.value_keyed(&inputs[0], <method>_value), an inherent method on the distribution's<Name>Paramsstruct that builds once throughSelf::buildand maps the body throughvalue_keyed_scalar. The driver's constant branch (every parameter length 1) calls the samevalue_keyed_scalarwith the same body, so a Python scalar and a length-1 literal or aggregate run one instantiation and agree bit for bit. Putting the builder on the kwargs struct is what makes the hoist structural: a shell cannot build the distribution itself, so it cannot rebuild per row. -
When a method is an elementary closed form (no special function: a Normal's
0.5*log(2*pi*e*sigma^2), a Bernoulli's1 - pat 0,mean = n*p, ...), the test is whether every row reaches the validator, not whether Polars can express the arithmetic:- The validated parameter is read unconditionally (
Bernoulli.meanis the validator's own output), or it is named in awhen(...)condition (_moment'swhen(validator.is_not_null())gate): leave it in Python as a Polars expression. A Rust binding for arithmetic Polars does natively is dead FFI surface. In practice this is the moments:mean,variance,std,median,entropy. - The validated parameter is read only inside a
when/then/otherwisearm, which is what every method that branches on the evaluation value looks like: compute it in Rust. From polars 1.44 an arm is masked to null on the rows it does not select, so the validator never sees an invalid parameter on those rows and the method silently returns a value instead of raising (pola-rs/polars#29005), and no Polars expression can spell a guard that survives the optimiser. In practice this is the value-keyed set:pdf/pmf,log_pdf/log_pmf,cdf,log_cdf,sf,log_sf,ppf,isf.DiscreteUniform,Bernoulli,Exponential,GeometricandUniformare the worked examples: the base routes every value-keyed method to its<name>_<method>Rust body, and only the moments stay in_<name>.py. - Split such a body into a
derivethat turns the parameters into their branch answers and aselectthat picks one by the evaluation value. Where the branch answers are fixed once the parameters are, the table is non-generic andderiveis an associated constructor (bernoulli.rs'sMass::pmf/Mass::at,uniform.rs'sDensity::pdf); where a branch still depends on the evaluation point, the table carries anArmtype parameter andderiveis a free function (exponential.rs's,geometric.rs's,pareto.rs's andweibull.rs'sderive_cdfover the sharedSides<Arm>inmod.rs, whosefloorthe four set to0,1, thescaleand0;uniform.rs'sderive_cdf/Regions::at). An inverse needs no table at all:derivereturns the arm andselectis the sharedon_unit_interval.deriveruns once per call on the constant path and once per row when a parameter is a column; a body that recomputed the parameter-only terms per row regressed the constant-parameter path by up to 195% at 10M rows. - The per-row shell is one line over
value_keyed_derived_binary(inputs, &DOMAIN, derive, select)(one parameter) orvalue_keyed_derived_ternary(inputs, check_params, derive, select)(two); these wrapvalue_keyed_binary/value_keyed_ternarywithderiveas an infalliblebuild, so there is one row loop per arity in the crate. The_scalartwin iskwargs.value_keyed(&inputs[0], derive, select), an inherent method that checks the constant and callsvalue_keyed_scalar(value, &derive(p), select), the same call the driver's constant branch makes.- State each parameter's domain once as a
ParamDomainconstant (ParamDomain::finite("mu"),ParamDomain::positive("sigma"),ParamDomain::probability("p"), or a literal for any other rule) and a joint constraint as aPairDomain. Every column-parameter plugin runscheck_column/check_columnsover its parameter columns before its row loop, and every<Name>Params::buildrunscheckonce, so an invalid parameter raises the sameComputeErrorin both regimes whatever else its row holds. Keep abuild_dist(...) -> PolarsResult<Dist>around thestatrsconstructor for the row loops; behind the pass it cannot fail.
- State each parameter's domain once as a
- Python. Add
polars_stats/distributions/_<name>.py, subclassingContinuousDistributionorDiscreteDistribution. In__init__, coerce each parameter withcoerce_param/coerce_n(types only, never validate values at construction, let invalid values raise in Rust) and store the fast-path bundleself._scalar_kwargs = scalar_kwargs(...). The base owns all routing (sample,samples,_value_plugin,_param_plugin,_validated,_moment); a subclass declares only what is distribution-specific:
- The validated parameter is read unconditionally (
-
_distribution_name: ClassVar[DistributionName](e.g."normal"), the prefix of every plugin the base calls: the samplers (<name>_sample/_sample_scalar/_samples/_samples_scalar), the value-keyed hooks (<name>_pdf,<name>_ln_cdf, ...) and the validator. Constant parameters route to the_scalartwins off_scalar_kwargs. Add the name toDistributionNameinpolars_stats/_typing.py. _param_exprs, the coerced parameters as a tuple in plugin-input order; the first sets the output's root name.- Nothing for the value-keyed methods. The base hooks (
_pdf/_pmf,_log_*,_cdf,_sf,_ppf,_isf) each callself._value_plugin("<method>", value), which resolves<name>_<method>(ln_for the log forms). Override a hook only where the Rust body does not exist (BetaandBinomialcomposelog_cdf/log_sfaslog(cdf)/log(sf)). _validated_params, the validating plugin call the moments gate on. In Rust, a plugin returning a reused quantity that raises on invalid parameters and nulls on a null one (uniform_rangereturnsmax - min,normal_sigmathe validatedsigma); its suffix must be inParamFunction. Cast each input, run the domain pass, then return the quantity:validated_param(inputs, &DOMAIN)returns the checked column itself for one parameter (bernoulli_proba);validated_pair(inputs, coerce_a, check_params)returns the second parameter where both are present (normal_sigma,beta_params);param_keyed(inputs, coerce_a, coerce_b, check_params, |a, b| ...)returns any other per-row quantity, null where either parameter is null (uniform_range,beta_entropy). It takes no output name: polars resolves an expression's output name from its first input. In Python,_validated_paramsisself._validated("<suffix>", <the same quantity as a polars expr>), which runs the plugin per row for column parameters and once, on length-1 literals, for constants; orself._param_plugin("<suffix>")when the plugin's own output is the answer on both routings (DiscreteUniform.support_size, whoseFloat64count must not be recomputed in polars).- The closed-form moments, as
self._moment(<formula over the raw parameters>): one validator mention however many times the formula names a parameter. A moment whose formula is the validated quantity reads_validated_paramsdirectly (Bernoulli.mean,Exponential's five,Uniform.range). A moment with no closed form is its own parameter-keyed plugin,self._param_plugin("entropy"), validating inside.
Never override the public
pdf/cdf/ ... methods, nor the base-ownedsample/samples/_value_plugin/_param_plugin/_validated/_moment. Export the class frompolars_stats/__init__.py. 3. Tests. A new distribution touches its own files and the shared registry.The registry row is the one thing that cannot be forgotten quietly
Missing it used to be the only failure mode here with no signal at all: the suite did not know your distribution existed, so it could not report that it had skipped it.
tests/distributions/registry_test.pynow cross-checkspolars_stats.__all__against the rows and fails when they disagree.All of:
tests/_registry.py: oneDistSpecrow, and it is the only registry there is. The row carriesname,cls,continuous,parameters(each with its constructor keyword, its column in the shared contract frame, and its type), aparam_strategy, anexample,eval_range,bounds,on_support_point,sample_dtype, the finite out-of-domaininvalidtable, andintegration_bounds(continuous) orsupport(discrete). Add a column per parameter toCONTRACT_FRAMEin the same file; no two parameters of one distribution may share one. Add the name toDistributionNameinpolars_stats/_typing.py, which is what types the row and the four sparse tables below. Every shared contract reads the row, andtests/distributions/registry_test.pyfails if a distribution is exported without one, so this is the one place the old silent-skip cannot happen.- Possibly a row in one of the four sparse tables, which a
DistSpecfield cannot hold because most distributions have no entry.support_test.py::_DENSITY_AT_ENDPOINT(the density at a finite support endpoint, whose value is the distribution's own rather than a saturated constant) is required when the support has a finite endpoint, and fails red when missing._registry.py::DEGENERATE(parameterisations where the mass collapses onto one point),_registry.py::ULP_TOLERANT_MOMENTS(moments that are not bit-exact across the two parameter routings) and_registry.py::UNDEFINED_MOMENTS(moments the distribution does not have, pinned to null on every valid row) are all opt-in; add toULP_TOLERANT_MOMENTSonly from a failing bit-exactness assertion, never to quiet one. - Any bespoke facts, appended to the file that owns their subject in
tests/distributions/: an algebraic reduction to another distribution goes inidentities_test.py, a numerical-regime fact with no scipy oracle inprecision_test.py. Everything a shared contract already states needs no new test at all; seetests/README.mdfor the file-by-file map. tests/scipy_parity/<name>_test.py: oneCaseper method againstscipy.stats.<name>through the shared_harness.py(default absolute tolerance1e-12, relaxed perCaseto1e-9/1e-6for erf-based or binary-search-ppfmethods).- Update the umbrella issue when the change merges.
Numerical stability¶
Every method must be accurate in the regime it exists to serve. log_sf exists for the deep tail, so a log_sf
that returns -inf there does not work, even though every test passes. Composing log_cdf as cdf().log() (as
Beta and Binomial still do) is a stopgap, not an implementation: shipping one is a decision to justify. Never
write isf as ppf(1 - q), because the loss would happen before your code runs: 1 - q resolves to 1.1e-16
absolute, so the tail mass is already quantised to 1.1e-16 / q relative and no inverse can recover it. Solve
against q itself, via a symmetry, a closed form, or entering a two-sided solve from the other end.
A composed method inherits the weakest part's range, and the composition is often wider than the part. std()
defaults to variance().sqrt(), and a variance that legitimately overflows can hide a standard deviation that does
not. Ask what the domain of the composed quantity is, not what the domain of its parts is.
Reassociate before reaching for log space or a branch. Computing a density as exp(log_pdf) fixes the subnormal
range but costs one to two orders of magnitude everywhere else, because exp(log(rate)) does not round-trip to
rate, so it needs a threshold and when/then/otherwise evaluates both sides. (rate * exp(t / 2)) * exp(t / 2) is
the same product with no branch and no threshold constant. Rearrange so the single unavoidable rounding happens last.
The test suite cannot catch this for you. Three structural blind spots, each of which has produced a real defect:
- parity grids are finite and curated, so they never probe the extreme regime;
scipyis not a valid oracle in the tails: its ownlogcdf/logsfare naive for the incomplete-beta and incomplete-gamma families, so parity passes while both libraries return-inf;- property tests assert shape, not accuracy: monotonicity and mass-integrates-to-one are satisfied by a value that is
relatively wrong by
1e-3.
Choose the oracle deliberately, and say which one you used and why. scipy where it is finite and known accurate;
mpmath at high precision, or an exact closed-form identity, beyond that. Never assert against a scipy value that is
itself saturated. An oracle for a discrete inverse needs exact rational arithmetic, since a rounding there becomes a
jump between support points rather than a small error.
Two acceptable outcomes, and no third. Fix the algorithm, or document the caveat in
Numerical accuracy, quantified with a regime and a magnitude ("relative error
~1.1e-16 / q", not "may be inaccurate in the tails"). That page is the single home for accuracy caveats: no
per-class docstring blocks, no restating the same limit in several places. A runtime warning is never the fix: it
cannot fire per-row from inside the engine, and a Python-side scalar-only warning would break the scalar/column
symmetry the architecture guarantees.
Claim a tolerance and justify it. 1e-12 for elementary closed forms, 1e-10 for special-function methods
(2e-10 through erfc, which is what statrs holds), 1e-9 for log-scale, 1e-8 for discrete log-mass and
support-sum entropy, 1e-6 or integer-valued for a discrete binary-search ppf. A relaxed tolerance needs a one-line
reason in the Case, not a shrug.
statrs' inverse_cdf is not a safe default. For the discrete families it is a binary search, so its parity
tolerance is the search's convergence rather than a formula's: state the 1e-6 loosening in the Case. Where it is
not a binary search it has been wrong in several ways, including relatively wrong by 6e-3 for Gamma in the
low-quantile tail, and panicking, hanging and saturating for Beta. A bounded solve with every Newton proposal
clamped into a bisection bracket is the pattern to copy.
Run make audit for a new distribution, and add its oracle to the registry in tools/accuracy/audit.py. A
distribution absent from the audit is unaudited, exactly as one absent from tests/_registry.py is untested;
make audit now fails outright on a distribution with no oracle.
Never bound the sweep by what the implementation is known to be bad at: that is the defect's own shape used as a bound
on the instrument. Sweep extreme parameters too, not only extreme inputs.
Ask what class a finding belongs to before closing it, and treat "this method is exempt" as a hypothesis to probe
rather than an argument to accept. Both rules were bought the same way: a pdf-in-log-space defect fixed once and
found again elsewhere, and three distributions cleared from the isf fix by reasoning that each fell to the first
probe aimed at it.
Where the recipes live. exponential.rs's derive_ln_sf (an exact closed form), its derive_cdf and
LogNormal.variance (the sinh identity standing in for the expm1 Polars does not expose), its derive_ln_cdf and
uniform.rs's derive_ln_cdf (log1p on the near-certain side), geometric.rs's smallest_support_point (the
one-ulp tie rule of a discrete inverse, decided in the log domain both sides entered through), normal.rs's ln_erfc
(a special function ported to log space, the pattern for the hard cases), the isf_value bodies in normal.rs (a
symmetry) and lognormal.rs (composing one), Sides::read_at in mod.rs with pareto.rs's and weibull.rs's
log_ratio (one distribution read as another on a transformed variate, the transform formed from an exact difference
so the support edge keeps its digits), and weibull.rs's second_moment_terms (a difference of two special-function
values replaced by its own series where the two cancel, with the crossover chosen where the direct form still has the
digits the series keeps).
Conventions¶
Prose, in PRs and docs: no em dashes or double hyphens (use commas, colons, or parentheses); lead with the answer; state uncertainty explicitly; end non-trivial answers with a short "Blind spots" section.
Code: KISS and YAGNI; production-grade type hints and explicit error handling; default to no comments, add one only when
the why is non-obvious. Do not refactor speculatively, do not introduce backwards-compatibility shims unless asked,
and do not add the DistKind dispatch macro (rejected for v1, see
Design notes).
Tests assert on pl.Series / pl.DataFrame via polars.testing, not Python lists. For random output, assert the null
mask, not values. Genuinely scalar results are fine to read with .item(...) and compare via pytest.approx. scipy
parity stays on numpy assert_allclose.
Git: one change per distribution; commit messages mirror the existing history (feat:, fix:, refactor:); add a
co-authored-by trailer for AI-generated commits. Do not use destructive git operations without explicit approval.