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 (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.
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 + sampler drivers (sample_by_index, samples_per_row, sample_scalar_plugin!)
│ └── distributions/ # one Rust file per distribution
├── polars_stats/
│ ├── __init__.py # public exports
│ ├── _lib.py # plugin path resolution
│ └── distributions/
│ ├── _base.py # ABCs + coercion / null helpers
│ └── _<name>.py # one Python class per distribution
├── tests/
│ ├── distributions/<name>/ # one folder per distribution, one file per method
│ ├── property/ # hypothesis-based invariant tests
│ └── scipy_parity/ # scipy reference-oracle tests
├── benchmarks/ # internal benchmark harness (not part of the docs)
└── 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) |
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,benchmarks,typing,docs) and installed withuv sync --group .... - Tests run under
pytestwithscipy+numpyas the parity oracle (tests/scipy_parity/) andhypothesisfor property tests; thebenchmarks/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; that issue is canonical if it conflicts with this section.
-
Rust. Add
src/distributions/<name>.rs, register it insrc/distributions/mod.rs, and implement a#[polars_expr]only for methods that need it:- Always the samplers, deriving the RNG from
src/rng.rs(let rngs = kwargs.row_rngs();once outside the loop, thenrngs.rng(i)inside; never reseed from chunk position;try_*_elementwiseso anullin any input propagates and an invalid parameter raises). The per-row<name>_sample/<name>_samples(multi-draw, backingsamples) take the parameter columns plus a row index as the last input; the constant-parameter fast paths<name>_sample_scalar/<name>_samples_scalar(parameters validated once inkwargs) come from thesample_scalar_plugin!macro insrc/rng.rs, generated from one sharedbuild/drawso they stay byte-identical to the per-row path. - 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). Each shares one named*_valuebody between the per-rowvalue_keyedhelper (from thevalue_keyed_per_row!macro) and the constant-parameter<name>_<method>_scalartwin (from thevalue_keyed_scalar_plugins!macro), both over the sharedvalue_keyed_scalardriver, so the two paths are byte-identical by construction. All three macros live insrc/distributions/mod.rs. - When a method is an elementary closed form (no special function: a Normal's
0.5*log(2*pi*e*sigma^2), the Uniform / Exponential / Cauchypdf/cdf/ppf,mean = n*p, ...): leave it in Python as a Polars expression, do not bind it in Rust. A Rust binding for arithmetic Polars does natively is dead FFI surface.UniformandBernoulliare the canonical closed-form distributions: onlysampleand the validator touch Rust, everything else is a Polars hook in_<name>.py. - Factor the validating constructor into a
build_dist(...) -> PolarsResult<Dist>helper so an invalid parameter maps through aComputeErrorconsistently. -
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,_samples_columns,_value_plugin,_moment); a subclass declares only what is distribution-specific: -
_plugin_prefix: ClassVar[str](e.g."normal"), from which the base derives every sampler plugin name (<prefix>_sample/_sample_scalar/_samples/_samples_scalar), routing scalar vs column parameters off_scalar_kwargs. _param_exprs, the coerced parameters as a tuple in plugin-input order; the first sets the output's root name.- The private formula hooks (
_pdf/_pmf,_cdf,_ppf, and any_log_*/_sfclosed form). For a statrs-backed method, returnself._value_plugin("<name>_<method>", value): the base routes constant parameters to the_scalarfast path and column parameters to the per-row plugin. - A validating plugin returning a reused quantity that raises on invalid parameters and nulls on a null one (e.g.
uniform_rangereturnsmax - min). The two-parameter validators are generated by theparam_validator!macro (src/distributions/mod.rs): declare each<name>: <dtype> => <accessor>, thebuild, thereturnsexpression, andoutput_name = inputs[i]. One-parameter validators (bernoulli_proba,exponential_rate) use the macro's unary arm (a single<name>: <dtype> => <accessor>). For a statrs-backed distribution whose moment formulas may omit a parameter, expose the validator as_checked_paramsand gate every closed-form moment throughself._moment(<formula>); for a closed-form distribution whose validator is already part of every formula (Uniform,Bernoulli), weave it in directly and do not call_moment.
Never override the public
pdf/cdf/ ... methods, nor the base-ownedsample/samples/_samples_columns/_value_plugin/_moment. Export the class frompolars_stats/__init__.py. 3. Tests. A new distribution touches its own files and several shared registries; missing a registry silently drops it from that suite (the run still passes, so nothing warns you). All of:tests/distributions/<name>/: one file per method, mirroringtests/distributions/bernoulli/, including avalidation_test.pyasserting an invalid parameter raisesComputeError(not a null) for both scalar and column inputs.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).- Shared registries, one entry each:
tests/property/_specs.py(ALL_SPECS: aDistSpecwithmake/make_columns/make_maskedplusdensity,eval_range, andintegration_bounds(continuous) orsupport(discrete), which drives the whole property suite),tests/distributions/output_name_test.py(thesample/samplesoutput-naming contract),tests/distributions/value_arg_str_test.py(astrvalue argument meanspl.col(name)), and, for distributions with the corresponding fast path,tests/distributions/value_keyed_fast_path_test.pyandtests/distributions/moment_fast_path_test.py(scalar-vs-column validation contracts, including invalid-parameter cases). - Update the umbrella issue when the change merges.
- Always the samplers, deriving the RNG from
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. The base-class defaults _cdf().log(),
_sf().log() and _isf(q) = _ppf(1 - q) are a convenience, not an implementation: inheriting one is a decision to
justify. _isf is the sharpest case, because the loss happens 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.
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/property/_specs.py is untested.
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._log_sf (an exact closed form), Exponential._cdf and LogNormal.variance
(the sinh identity standing in for the expm1 Polars does not expose), Exponential._log_cdf and
Uniform._log_cdf (log1p on the near-certain side), normal.rs's ln_erfc (a special function ported to log
space, the pattern for the hard cases), and the isf_value bodies in normal.rs (a symmetry) and lognormal.rs
(composing one).
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.