Migrate from scipy.stats¶
Port scipy.stats code that runs against Polars data. The method names line up closely; the parameterisation is where
the traps are.
The scipy snippets on this page are shown for comparison and are not executed when the docs are built; the
polars-stats ones are.
Translate the method names¶
scipy.stats |
polars-stats |
Note |
|---|---|---|
pdf(x) / pmf(k) |
pdf(x) / pmf(k) |
same |
logpdf / logpmf |
log_pdf / log_pmf |
underscore |
cdf / logcdf |
cdf / log_cdf |
underscore |
sf / logsf |
sf / log_sf |
underscore |
ppf / isf |
ppf / isf |
same |
mean() / median() / std() |
mean() / median() / std() |
same |
var() |
variance() |
renamed |
entropy() |
entropy() |
same, in nats |
rvs(size=n, random_state=s) |
sample(seed=s) / samples(n, seed=s) |
see Sample reproducibly |
fit, interval, moment, stats, expect, support |
none | see what is missing |
Translate the parameters¶
scipy parameterises through loc / scale shims; polars-stats uses each distribution's conventional parameters.
Some of these translations are not identities, so check this table rather than guessing:
scipy.stats |
polars-stats |
Watch out |
|---|---|---|
norm(loc=mu, scale=sigma) |
Normal(mu=mu, sigma=sigma) |
same meaning |
lognorm(s=sigma, scale=exp(mu)) |
LogNormal(mu=mu, sigma=sigma) |
scale is exp(mu), not mu |
expon(scale=1 / rate) |
Exponential(rate=rate) |
inverted: scale is 1 / rate |
uniform(loc=min, scale=max - min) |
Uniform(min=min, max=max) |
scale is the width, not the upper bound |
beta(a, b) |
Beta(a=a, b=b) |
same meaning |
bernoulli(p) |
Bernoulli(p=p) |
same meaning |
binom(n, p) |
Binomial(n=n, p=p) |
same meaning |
Two further differences apply everywhere:
- No
loc/scaleshifting.scipylets you shift and scale any distribution (expon(loc=5, scale=2)).polars-statsdoes not, so shift the values yourself:Exponential(rate=0.5).cdf(pl.col("x") - 5.0). - Floats are strict.
scipy.stats.norm(0, 1)is fine;ps.Normal(0, 1)raisesTypeError. Write0.0, 1.0. A count parameter is the opposite:Binomial(n=10, p=0.3)needs anintforn.
Port a whole-column evaluation¶
Before, leaving Polars and coming back:
import numpy as np
from scipy import stats
x = df["x"].to_numpy()
df = df.with_columns(upper_tail=pl.Series(stats.norm(loc=0.0, scale=1.0).sf(x)))
After, staying inside the expression engine:
import polars as pl
import polars_stats as ps
df = pl.DataFrame({"x": [-1.0, 0.5, 2.0]})
print(df.with_columns(upper_tail=ps.Normal(mu=0.0, sigma=1.0).sf("x")))
shape: (3, 2)
┌──────┬────────────┐
│ x ┆ upper_tail │
│ --- ┆ --- │
│ f64 ┆ f64 │
╞══════╪════════════╡
│ -1.0 ┆ 0.841345 │
│ 0.5 ┆ 0.308538 │
│ 2.0 ┆ 0.02275 │
└──────┴────────────┘
Port per-row parameters¶
scipy broadcasts parameter arrays, so this part is not about vectorisation. It is about the round trip: three
to_numpy() calls, a pl.Series on the way back, and manual realignment if any filter comes between.
x = df["x"].to_numpy()
mu = df["mu"].to_numpy()
sigma = df["sigma"].to_numpy()
df = df.with_columns(density=pl.Series(stats.norm(loc=mu, scale=sigma).pdf(x)))
After, as one expression that a LazyFrame can plan:
per_row = pl.DataFrame({"mu": [0.0, 10.0], "sigma": [1.0, 2.0], "x": [0.5, 11.2]})
print(per_row.with_columns(density=ps.Normal(mu="mu", sigma="sigma").pdf("x")))
shape: (2, 4)
┌──────┬───────┬──────┬──────────┐
│ mu ┆ sigma ┆ x ┆ density │
│ --- ┆ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 ┆ f64 │
╞══════╪═══════╪══════╪══════════╡
│ 0.0 ┆ 1.0 ┆ 0.5 ┆ 0.352065 │
│ 10.0 ┆ 2.0 ┆ 11.2 ┆ 0.166612 │
└──────┴───────┴──────┴──────────┘
Port a per-group fit¶
scipy has fit; polars-stats does not. For the distributions shipped today the estimators are Polars expressions,
which keeps the fit and the scoring in one query:
# scipy: group, fit, score, reassemble
scored = []
for name, part in df.group_by("sensor"):
mu, sigma = stats.norm.fit(part["reading"].to_numpy())
scored.append(
part.with_columns(tail=pl.Series(stats.norm(mu, sigma).sf(part["reading"])))
)
df = pl.concat(scored)
history = pl.DataFrame(
{
"sensor": ["a", "a", "a", "b", "b", "b"],
"reading": [1.0, 1.2, 0.9, 10.0, 10.5, 9.5],
}
)
print(
history.with_columns(
mu=pl.col("reading").mean().over("sensor"),
sigma=pl.col("reading").std(ddof=0).over("sensor"),
).with_columns(tail=ps.Normal(mu="mu", sigma="sigma").sf("reading"))
)
shape: (6, 5)
┌────────┬─────────┬──────────┬──────────┬──────────┐
│ sensor ┆ reading ┆ mu ┆ sigma ┆ tail │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞════════╪═════════╪══════════╪══════════╪══════════╡
│ a ┆ 1.0 ┆ 1.033333 ┆ 0.124722 ┆ 0.605366 │
│ a ┆ 1.2 ┆ 1.033333 ┆ 0.124722 ┆ 0.090725 │
│ a ┆ 0.9 ┆ 1.033333 ┆ 0.124722 ┆ 0.857475 │
│ b ┆ 10.0 ┆ 10.0 ┆ 0.408248 ┆ 0.5 │
│ b ┆ 10.5 ┆ 10.0 ┆ 0.408248 ┆ 0.110336 │
│ b ┆ 9.5 ┆ 10.0 ┆ 0.408248 ┆ 0.889664 │
└────────┴─────────┴──────────┴──────────┴──────────┘
scipy.stats.norm.fit returns the maximum-likelihood estimate, which divides by n, so pass ddof=0 to match it.
Polars defaults to ddof=1.
Behaviour differences to expect¶
scipy.stats |
polars-stats |
|
|---|---|---|
| Missing data | no null type; nan in, nan out |
null in, null out, and NaN in, NaN out |
Invalid parameter (scale=-1) |
returns nan silently |
raises ComputeError and fails the query |
| Return type | NumPy array | pl.Expr |
| Randomness | random_state / global NumPy state |
per-call seed only, no global state |
| Accuracy | reference implementation | matched to 1e-12 absolute in the parity suite, relaxed to 1e-9 / 1e-6 for erf-based and search-based ppf methods |
What has no equivalent¶
fitand other estimators: compute them as Polars expressions, as above.interval,moment(n),stats(moments=...),expect(...),support(): not implemented.loc/scaleshifting of arbitrary distributions: shift the input instead.- Distributions outside the catalogue, which is short but growing.
- Multivariate distributions,
rv_histogram, and hypothesis tests: out of scope.
Where a distribution or method is missing, scipy remains the right tool; the two libraries coexist in one project
without conflict.