polars_ros
Regression on Order Statistics (ROS) and bootstrap confidence intervals for Polars, implemented in Rust and exposed as native Polars expressions.
This is a port of the ROS and bootstrapping code in wqio, aimed at Polars users who do not want to leave their dataframe pipeline to pandas for the statistics step.
import polars as pl
import polars_ros as plr
df = pl.read_csv("results.csv")
df = df.with_columns(plr.ros.impute("result", "censored").alias("ros"))
Everything is an ordinary Expr, so it composes with select, with_columns,
over, and group_by(...).agg(...).
Why ROS
Environmental monitoring data usually carries a detection limit rather than a measurement whenever a result is below the instrument's resolution. Those rows are left censored: the reported number is an upper bound, not an observation. ROS models the uncensored values as normally distributed, fits their rank against normal deviates, and uses that line to predict where the censored values would have fallen.
Conventions
censoredis a Boolean column whereTruemeans "left censored"; the reported value is then a detection limit.resultis aFloat64column. Missing results propagate as nulls.- Row order depends on the function.
imputereturns one row per input row, in input order, so it can be used directly inwith_columns.impute_detailsreturns the per-observation breakdown in ROS order — every censored row first, then the uncensored ones — because that is the order the algorithm works in. Useimputewhen you need to stay aligned with the input; usesortif you want wqio's ordering. - Censored results above the maximum uncensored result carry no information.
imputekeeps those rows and sets them to null, so the output stays aligned with the input.impute_detailsdrops them, exactly as wqio does, so its row count can be lower than the input's.
ROS API
| Function | Returns |
|---|---|
plr.ros.cohn_numbers(result, censorship) |
struct of Cohn numbers, one row per detection limit |
plr.ros.detection_limit_index(result, cohn) |
UInt32 detection-limit index per row; see below |
plr.ros.group_rank(det_limit_index, censorship) |
UInt32 rank within each detection-limit group |
plr.ros.plotting_positions(result, censorship, cohn=None) |
Float64 plotting positions |
plr.ros.zprelim(plotting_positions) |
Float64 normal deviates |
plr.ros.estimate(zprelim, result, censorship, transform_in, transform_out) |
Float64 ROS estimates |
plr.ros.is_valid(censorship, min_uncensored, max_fraction_censored) |
Boolean, whether ROS applies |
plr.ros.impute(result, censorship, ...) |
Float64, the whole pipeline |
plr.ros.substitute(result, censorship, ...) |
Float64, substitution only |
plr.ros.impute_details(result, censorship, ...) |
struct of every intermediate step |
cohn_numbers returns one row per distinct detection limit, which is usually
fewer than the number of censored observations. detection_limit_index maps a
full-length result onto that shorter struct, so nest the two calls rather than
materialising cohn as a column:
df.select(
plr.ros.detection_limit_index(
"result", plr.ros.cohn_numbers("result", "censored")
).alias("dl_idx")
)
The index follows wqio.ros._detection_limit_index exactly, which applies no
censoring mask: it is the 0-based position of the last Cohn row whose lower_dl
is at or below the value, or 0 for a value below every detection limit. Censored
observations therefore get a 1-based index, but so does any uncensored value that
happens to sit at or above the lowest lower_dl.
impute is the one you usually want:
df.with_columns(
plr.ros.impute(
"result",
"censored",
min_uncensored=2,
max_fraction_censored=0.8,
substitution_fraction=0.5,
transform_in="log",
transform_out="exp",
floor=None,
).alias("ros")
)
When there are too few uncensored observations, or too many censored ones, it
falls back to simple substitution at substitution_fraction of the minimum
uncensored result. transform_in and transform_out must be one of
identity, log, exp, sqrt, square.
Use impute_details to see why a row came out the way it did:
df.select(plr.ros.impute_details("result", "censored").alias("d")).unnest("d")
impute_details returns its rows in ROS order (censored first, then
uncensored, each block ascending), matching wqio. Rows with a missing result,
and censored results above the maximum uncensored result, are left out, so the
height can be smaller than the input's. It raises if nothing in the input is
censored, since there would be no regression to report — impute is the
function to reach for in that case.
Bootstrap API
plr.bootstrap.ci(
values, statistic="mean", niter=10_000, alpha=0.05, method="percentile", seed=None
)
plr.bootstrap.fit(x, y, niter=10_000, alpha=0.05, xlog=False, ylog=False, seed=None)
ci returns a length-one struct with lower and upper. In a select or
agg it collapses to a single row; for per-group intervals unnest it:
df.group_by("site").agg(
plr.bootstrap.ci("conc", statistic="median", niter=2000).alias("ci")
).unnest("ci")
statisticis one ofmean,median,sum,min,max,std,var,geomean.methodispercentileorbca(bias-corrected and accelerated).bcafalls back to the percentile interval when the accelerated interval does not contain the bootstrap mean.fitreturns a struct withxhat,yhat,lowerandupper, one row per input row and in the input's order. Setylog=Trueto fit a power law.
Resampling uses ChaCha8Rng, not NumPy's generator, so intervals will not agree
with numpy.random to the last digit. Pass seed for reproducible runs.
Building from source
Needs Rust and Python 3.12+. maturin drives the build:
python -m venv .venv
.venv/Scripts/pip install maturin polars
.venv/Scripts/python -m maturin develop # build in place
.venv/Scripts/python -m pytest # run the tests
Point maturin at your virtualenv explicitly. If VIRTUAL_ENV is unset it will
happily install into whatever Python is first on PATH, and a stray CONDA_PREFIX
can send it to the wrong environment entirely:
export VIRTUAL_ENV="$PWD/.venv"
unset CONDA_PREFIX
The Rust side targets the Polars 0.55 crates, which is the line Python polars 2.0
links against. The polars_expr macro checks the FFI version at load time, so the
Rust polars crate has to stay in the same line as the installed Python polars.
A Python release that moves to a new Rust crate needs a rebuild of this one first.
That constraint is why the runtime dependency is polars>=2.0,<3. The upper
bound is deliberate: without it, pip could hand you a polars this wheel refuses
to register against. A scheduled workflow compares the two lines and opens an
issue when they diverge, so the rebuild is prompted rather than discovered.
sysinfo 0.39.6 requires rustc 1.95 or newer. If your default toolchain is
older, pin a newer one with a rust-toolchain.toml rather than downgrading the
dependency — Cargo.lock is committed deliberately, since this crate builds a
native extension and reproducible builds depend on it.
On this machine .cargo/config.toml points at the VS2017-era linker and
Windows SDK 8.1 libraries that ship with Visual Studio 2022 Community, because
no "Desktop development with C++" workload is installed. Delete that file on a
machine with a normal C++ toolchain. tools/msvc-env.ps1 wraps that setup
along with the toolchain pin; it is machine-specific and is the only file here
you will likely need to rewrite.
Layout
src/stats.rs normal CDF/PPF (AS241), numpy-compatible percentiles, reducers
src/ros.rs Cohn numbers through to the final imputed values
src/bootstrap.rs percentile and BCa intervals, bootstrapped linear fits
src/utils.rs Series <-> Vec glue and struct construction
python/polars_ros/ the expression wrappers registered with Polars
tests/ expected values transcribed from wqio's test suite,
plus a live cross-validation against installed wqio
tools/ stdlib-only checkers that diff those transcriptions
against the installed wqio sources, and the live
cross-validator (needs the dev extra)
The Rust core is deliberately free of DataFrame knowledge: each file has plain
Vec-in/Vec-out functions with unit tests, plus a thin layer that adapts them
to Polars Series.
Verifying the transcribed fixtures
The expected values in tests/ were copied out of wqio's test suite. To prove
nothing drifted during the copy, tools/ parses the installed wqio's sources
with the standard library ast module and diffs them against our literals. These
need no pandas or numpy:
python tools/check_fixtures.py # tests/conftest.py vs wqio/tests/test_ros.py
python tools/check_expected_arrays.py # every expected array in tests/*.py
wqio is located on disk with importlib.util.find_spec rather than imported, so
neither checker runs wqio/__init__.py and neither needs the scientific stack.
Two arrays are intentionally not asserted: NoOp_ZeroND.res is generated by
numpy.random.lognormal and cannot be reproduced bit-for-bit, and the Cohn
numbers for the extra-censored cases are derived rather than transcribed.
Cross-validating against live wqio
The fixture checkers above prove we match the numbers wqio publishes. To also
cover inputs wqio never tests, tools/check_against_wqio.py imports the
installed wqio/ros.py and wqio/bootstrap.py and runs them side by side with
this port, comparing every output array:
pip install -e ".[dev]"
python tools/check_against_wqio.py # full report
pytest tests/test_against_wqio.py # same comparison as a test
It compares Cohn numbers, plotting positions, zprelim, detection-limit
indices, ranks, substituted values, the full ROS pipeline, and bootstrap
percentile/BCa intervals and fits, across six datasets: the transcribed fixture,
an uncensored set, an all-censored set, a set with few uncensored values, one
with ties, and a two-level set.
A plain import wqio would run its __init__.py, pulling in matplotlib,
seaborn and statsmodels just to reach two statistical functions. So the tool
loads ros.py and bootstrap.py by path behind a synthetic wqio package and
supplies the one helper (utils.log_or_warn) they actually use.
By default the tool exits 0 with a skip notice when wqio or its dependencies are
missing, which keeps it safe to wire into a gate that runs without the scientific
stack. Pass --require (CI sets POLARS_ROS_REQUIRE_WQIO=1, which does the same
thing) to make a missing wqio a hard failure instead of a silent no-op.
Because the checkers track whatever wqio is installed, a new wqio release may
change its fixtures or outputs. If the checkers fail after a pip install -U,
that is the signal to re-verify and re-transcribe, not a bug in this port.
Three deliberate differences from wqio are asserted or reported rather than matched:
- Cohn numbers have no sentinel row. wqio appends a trailing all-
NaNrow carryingprob_exceedance = 0. Because itslower_dlisNaN, it can never satisfylower_dl <= res, so it cannot affect the detection-limit index, rank, or plotting positions. The tool verifies the extra row really is that sentinel, then compares the real rows. - Degenerate input raises instead of passing through. With no censored
observations ROS is undefined, so this port raises rather than returning an
invented fit. It refuses the same inputs wqio does, but says why:
ROS needs at least one censored observation, but every value is uncensored, where wqio surfacesIndexError: single positional indexer is out-of-bounds. - Bootstrap bands are compared statistically. wqio resamples from an
unseeded global
numpy.random, so interval widths are compared within half a sample standard deviation atniter=20000. Point estimates that do not depend on resampling (fit.yhat,xhat) are compared exactly.
Two shims are applied to wqio at load time and reported at the end of the run:
plotting_positions sorts ND_plotpos.values.sort() in place, which raises on
pandas 3 because the view is read-only, so the tool substitutes numpy.sort
over the same mask.
Metadata
Release files for polars-ros 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| polars_ros-0.1.0.tar.gz | 89.2 kB | With provenance | Details for polars_ros-0.1.0.tar.gz |
Release files / polars_ros-0.1.0.tar.gz
| Download URL | polars_ros-0.1.0.tar.gz |
|---|---|
| Size | 89.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
98438577f3587d5d061450ec2ca1eb6b9305f5f99afaa4a0ff93def878f3c1c5
|
|
BLAKE2b-256 checksum How to use checksums |
11e103772a3aabc5075a7094d34f9ca6a03898ac81108f825aee7f3fa53e7c39
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logSecurity for polars-ros 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
1 file in this release has been uploaded with provenance metadata.
Attestation for polars_ros-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.