Metadata-Version: 2.4
Name: distparams
Version: 0.2.0
Summary: Single source of truth for statistical distribution parameterizations
Keywords: statistics,distribution,parameterization,bayesian,pymc,scipy
Author: Will Dean
Author-email: Will Dean <wd60622@gmail.com>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Mathematics
Requires-Python: >=3.12, <3.15
Project-URL: Homepage, https://github.com/williambdean/distparams
Project-URL: Repository, https://github.com/williambdean/distparams
Description-Content-Type: text/markdown

# distparams

Single source of truth for statistical distribution parameterizations.

## Installation

```bash
pip install distparams
```

## Quick Start

```python
from distparams import get_distribution, Support

# Get distribution info
normal = get_distribution("normal")
sigma = normal.canonical.parameters["sigma"]

# Check parameter support
sigma.support.contains(0.5)  # True
sigma.support.contains(-1)  # False
str(sigma.support)  # "(0, ∞)"

# Convert between ecosystems
from distparams import convert

convert("gamma", "scipy", "pymc", a=2, scale=3)
# → {"alpha": 2, "beta": 0.333}
```

## Core concepts

| Term | What it means |
|---|---|
| **Parameter** | The canonical name for one semantic parameter — normal's `sigma` is PyMC's `sigma`, scipy's `scale`, jStat's `std`: one parameter, many names. Parameters own their `Support` (admissible values) and their bijections. A parameter is a mutable builder while authoring; once registered, its bijections change only via `register_parameter_alias`. |
| **Bijection** | A name's value relation to a canonical parameter: a one-to-one map with its inverse. The identity bijection is a pure rename — a true *alias* (`loc` ↔ `mu`); a transformed one reparameterizes the value (`tau` → `sigma = tau**-0.5`, via `Bijection.power`/`Bijection.reciprocal`, or an explicit pair when the inverse isn't mechanical, like logit ↔ probability). |
| **ParameterSet** | A complete set of parameters that determines the distribution — what PPL docs call a "parameterization" (`{mu, sigma}`, `{mu, tau}`). Derived parameterizations live here too: their members are functions of *several* canonical parameters, so they are not bijections at all (gamma's `mu = shape/rate`; beta's `(mu, sigma)` combines the mean and sd relations). Derived sets carry `name`/`description` with the defining formulas, so the registry can explain them. |
| **NamingConvention** | One ecosystem's parameter names for one distribution. Purely a naming relation — value transforms never live here; a value-transforming name pairs the row with a parameter bijection. |
| **Vocabulary** | One ecosystem's names for a *parametrization*, written once and applying to every distribution that uses it (keyed `(ecosystem, parametrization)`). Per-distribution conventions override it. |
| **Distribution** | Canonical parameters + parameter sets + naming overrides. The canonical parametrization name is derived from the parameter declaration order. |

Conversion flows *source names → canonical parameters → target names*:
names resolve through conventions, vocabularies, and bijections; values
transform through parameter bijections and parameter-set transforms.
Derived parameterizations are self-describing:
`get_distribution("beta").canonical.parameter_sets` includes the `(mu, sigma)`
set with its name and defining formulas.

## Coming from scipy?

Keep your scipy distributions — lose the parameter-name bookkeeping.
`distparams.stats` is a drop-in-shaped stand-in for `scipy.stats`: the same
attribute names (`norm`, `t`, `chi2`, `binom`, ...), but every call accepts
any parametrization the registry knows and returns a frozen scipy
distribution:

```python
from distparams.stats import norm

norm(mu=0, sigma=1)  # ✓ canonical
norm(loc=0, scale=1)  # ✓ plain scipy
norm(mu=0, scale=1)  # ✓ mix freely — names resolve through the registry
norm(tau=4)  # ✓ precision → sigma = 0.5
# → a frozen scipy distribution: .pdf, .rvs, .mean, ...
```

```python
from distparams import stats

stats.gamma(mu=4, sigma=2)  # ✓ mean/sd parameterization
stats.binom(n=10, p=0.3)  # ✓ discrete, scipy names
stats.expon(scale=2)  # ✓ registry names, scipy spelling
```

`dir(distparams.stats)` lists every scipy-style name the registry covers;
anything else (`stats.normal`, distributions scipy doesn't have) raises an
`AttributeError` listing what is available. Attributes resolve lazily, so
importing `distparams` never imports scipy — scipy is only needed when a
distribution is actually used.

Prefer the explicit form? `from_scipy` wraps any `scipy.stats` distribution
the same way:

```python
from scipy import stats
from distparams.integrations import from_scipy

new_normal = from_scipy(stats.norm)

new_normal(mu=0, sigma=1)  # ✓ canonical
new_normal(loc=0, scale=1)  # ✓ plain scipy
new_normal(mean=0, std=1)  # ✓ jStat-style names
new_normal(tau=4)  # ✓ precision → sigma = 0.5
new_normal(0, 1)  # ✓ positional
# → a frozen scipy distribution: .pdf, .rvs, .mean, ...
```

Aliases, alternative parameterizations, and even whole derived
parametrizations come straight from the registry — no mapping code per
distribution, and anything registered later works too:

```python
new_gamma = from_scipy(stats.gamma)

new_gamma(alpha=2, beta=3)  # ✓ shape/rate → a=2, scale=1/3
new_gamma(2)  # ✓ partial — scipy defaults fill the rest
new_gamma(mu=4, sigma=2)  # ✓ mean/sd parameterization

new_uniform = from_scipy(stats.uniform)
new_uniform(lower=1, upper=3)  # ✓ even loc/scale via registry transforms
```

Unknown names are caught at call time instead of silently misbehaving, and a
scipy distribution that has no registry entry raises with instructions rather
than guessing.

## Coming from the stdlib random module?

`distparams.random` mirrors the `random` module with zero dependencies: the
variate generators accept every registered parametrization, and everything
else (`seed`, `choice`, `shuffle`, …) delegates to the RNG untouched:

```python
import distparams.random

distparams.random.gamma(shape=2, scale=3)  # canonical names
distparams.random.gamma(alpha=2, beta=1 / 3)  # pymc names (beta is the rate)
distparams.random.weibull(alpha=2, beta=1.5)  # pymc order — alpha is the shape, beta the scale

from distparams.random import Random

Random(42).gauss(mu=2, variance=2)  # variance → sigma
Random(42).gammavariate(2, 3)  # stdlib spelling stays drop-in: beta is the scale
```

The stdlib method names (`gammavariate`, `gauss`, `weibullvariate`, …) keep
stdlib semantics exactly — the same call on `random.Random` draws the same
number for the same seed, and the `Random` class adds nothing to the
`random.Random` surface (`dir()` matches stdlib exactly). The registry names
(`gamma`, `normal`, `weibull`, …) arbitrate the cross-ecosystem traps
instead: `weibullvariate` swaps alpha/beta relative to PyMC and Stan, and
`gammavariate`'s `beta` is a scale where pymc's `beta` is a rate.

## Interactive Example

The registry types are *executable metadata*. In this
[marimo](https://marimo.io/) notebook, `Support` decides which control each
parameter gets — sliders for bounded probabilities, floored numbers for
scales, step-1 counters for trial counts — and `convert()` feeds the values
straight into scipy:

```python
from distparams import get_distribution

params = get_distribution("binomial").params  # {"n": Parameter, "p": Parameter}
params["p"].support  # [0, 1]
params["p"].support.is_bounded  # True  → render a slider

get_distribution("normal").params["sigma"].support
# (0, ∞)                  → render a number floored at 0
```

Every `Parameter` in the dict also carries its `.description` and
`.bijections` — the alternative names and forms the registry knows (normal's
`tau` is the precision: `sigma = tau**-0.5`).

[![Open with marimo](https://marimo.io/shield.svg)](https://marimo.app/https://github.com/williambdean/distparams/blob/main/examples/scipy_distributions.py)

Run it locally (the notebook needs scipy and marimo, which live in the dev
group — the library itself stays zero-dependency):

```bash
uv run --group dev marimo edit examples/scipy_distributions.py
```

## Features

- **`@parametrized` decorator** — all canonical parameterizations come for free
- **`distparams.stats`** — scipy.stats-shaped namespace where every call accepts any registered parametrization
- **`distparams.random`** — the stdlib `random` module accepting every registered parametrization (zero-dependency, seed-exact drop-in)
- **`from_scipy`** — wrap any scipy distribution into a callable that accepts every registered parametrization
- **Support** dataclass for parameter domains (positive, unit interval, real, etc.)
  — every parameter's description, support, and aliases are reachable via
  `get_distribution(name).params`
- **Cross-ecosystem conversion** between PyMC, scipy, Stan, jStat, TFP, NumPyro, R (base `stats`), and the stdlib `random` module
- **Ecosystem vocabularies** — an ecosystem's parameter names are written once per *parametrization*, not once per distribution
- **Plugin system** for external packages — add an ecosystem, alias, or whole parameterization without forking
- **Zero dependencies** — pure Python, ~160 KB of source, ~50 ms import, µs-scale conversions

## Why not PreliZ?

PreliZ is for prior elicitation — it computes with distributions and brings
`numba`, `pytensor`, `scipy`, and `matplotlib` with it. distparams is the
zero-dependency metadata layer underneath: it knows the parameterizations
(and every ecosystem's names for them) so your library doesn't have to.

## Use it in your own package

Wrap your constructor once and every ecosystem's parameter names — plus
alternative parameterizations from the registry — just work:

```python
from distparams import parametrized


@parametrized("beta")
def beta(alpha, beta):
    return stats.beta(alpha, beta)


beta(1, 1)  # canonical, positional
beta(successes=2, failures=3)  # → alpha=3, beta=4
```

Your function's parameter names are honored exactly — declare them in
whatever convention your library uses:

```python
@parametrized("beta")
def beta(a, b):  # scipy-style names
    return stats.beta(a, b)


beta(alpha=1, beta=2)  # → a=1, b=2
beta(successes=2, failures=3)  # → a=3, b=4
```

Prefer to keep your dataclasses? The decorator works on them directly — and
preserves the class (isinstance, classmethods, `dataclasses.fields` all keep
working):

```python
from dataclasses import dataclass
from distparams import parametrized


@parametrized("beta")
@dataclass
class Beta:
    alpha: float
    beta: float


Beta(a=1, b=2)  # → Beta(alpha=1, beta=2)
Beta(successes=2, failures=3)  # → Beta(alpha=3, beta=4)
```

PyMC-style `.dist()` classmethods work too — decorate the raw function below
`@classmethod`, and the bound `cls` is passed through untouched. Context
parameters ride along: anything the callable declares that the registry
doesn't know — plus everything in `**kwargs` — is forwarded untouched.
Unknown kwargs without `**kwargs` stay strict errors.

```python
class Normal:
    @classmethod
    @parametrized("normal")
    def dist(cls, mu=0, sigma=1, **kwargs): ...


Normal.dist(loc=0, scale=1, size=(2, 3))  # ✓ size/dtype-style extras forward
```

Note that PyMC's `.dist()` only accepts extras like `size`, `shape`, and
`dtype` — `observed`, `dims`, and `initval` belong to the constructor in
model context (PyMC itself raises on them in `.dist()`). Registry-known
names always resolve: for gamma, `shape` is the shape parameter, not array
shape.

Logp-style signatures work too. A value argument like `x` isn't a registry
parameter, so it's a context parameter: it passes through untouched and
takes positionals in declaration order, like plain Python:

```python
@parametrized("normal")
def normal_logp(x, mu, sigma):
    return -0.5 * ((x - mu) / sigma) ** 2 - np.log(sigma) - 0.5 * np.log(2 * np.pi)


normal_logp(data, 0, 1)  # ✓ x=data, mu=0, sigma=1
normal_logp(data, mu=0, sigma=1)  # ✓ positional and keyword mix freely
normal_logp(data, loc=0, scale=1)  # ✓ the parameters resolve across ecosystems
```

Trailing value arguments (`def beta_logp(alpha, beta, x)`) and defaults
(`def normal_logp(x, mu=0, sigma=1)` called as `normal_logp(data)`) bind the
same way. Surplus positionals forward into a declared `*args`, and
keyword-only context parameters must be passed by keyword.

And downstream packages can extend the registry — names, aliases,
parameterizations — without forking, via `distparams.plugins`. To teach every
distribution your package's parameter names for a parametrization, register a
vocabulary once:

```python
from distparams import register_ecosystem_vocabulary

register_ecosystem_vocabulary("my_pkg", "mu_sigma", {"center": "mu", "spread": "sigma"})
# every registered mu_sigma distribution now speaks my_pkg

convert("normal", "scipy", "my_pkg", loc=0, scale=1)  # {"center": 0, "spread": 1}
convert("cauchy", "my_pkg", "scipy", center=1, spread=2)  # {"loc": 1, "scale": 2}
```

A single distribution that names parameters differently keeps a
per-distribution override via `register_ecosystem_mapping` (it takes
precedence over the vocabulary row):

```python
from distparams import NamingConvention, register_ecosystem_mapping

register_ecosystem_mapping("uniform", "my_pkg", NamingConvention("my_pkg", {"min": "lower", "max": "upper"}))

convert("uniform", "my_pkg", "scipy", min=0, max=5)  # {"loc": 0, "scale": 5}
```

Rows are **pure renames** — they change what a parameter is called, never its
value. In the example above `spread` is an sd, so the rename alone is correct.
If `spread` carried a different quantity — a variance — the row alone would
silently pass values through (`spread=4` would convert to `scale=4`). Value
transforms live on the parameter, declared once and inherited by every
ecosystem that maps a name onto that role:

```python
from distparams import Bijection, register_parameter_alias

register_parameter_alias("normal", "sigma", "spread", Bijection.power(0.5))

convert("normal", "my_pkg", "scipy", center=0, spread=4)  # {"loc": 0, "scale": 2.0}
```

Decorate your constructor classes and every registered naming resolves at
construction — declare the parameters in *your* names (after registering
them, as above), and callers may use any of them:

```python
from distparams import parametrized


@parametrized("uniform")
class Uniform:
    def __init__(self, min=0.0, max=1.0): ...


Uniform(min=1, max=3)  # direct
Uniform(a=1, b=3)  # jstat names
Uniform(loc=2, scale=3)  # scipy spelling → min=2, max=5
```

Resolution happens before your body runs, so the body keeps its own typing
contract — and value transforms compose before it too: a `tau=4` precision
becomes `sigma=0.5` (an int your body never sees), and lazily-computed
values such as Polars expressions transform into new expressions, which pass
through untouched. Two things to know: unknown names raise
`UnknownParameterError` (a `ValueError` with grouped suggestions) rather
than `TypeError`, and value-carrying names should be registered with
`register_parameter_alias` first, as above.

The transcendental transforms (`log`/`exp`/`sqrt`) can target your
expression engine as well, so an expression transforms into another
expression instead of hitting the `math` module. Register an ops backend and
pass `ops=` to the decorator — parameter resolution, and only that, then
runs through your engine's methods:

```python
import math
from distparams import parametrized, register_distribution_ops

# Backend ops serve scalars and expressions through one path — guard the
# method call so plain floats keep working:
register_distribution_ops(
    "polars",
    log=lambda x: x.log() if hasattr(x, "log") else math.log(x),
    exp=lambda x: x.exp() if hasattr(x, "exp") else math.exp(x),
)


@parametrized("log_normal", ops="polars")
class LogNormal:
    def __init__(self, mu=0.0, sigma=1.0): ...


LogNormal(s=0.5, scale=expr)  # mu resolves to expr.log(), still lazy
```

A call-site context overrides the class's baked-in backend — `ops=` is a
default, not a lock:

```python
with use_ops("verbose"):
    LogNormal(s=0.5, scale=expr)  # resolution routes through the override
```

`use_ops` remains for wider scoping — plain functions, `with` blocks, or
classes whose bodies also need the backend. It sets a process-global backend
for the block — not thread-local — and arithmetic transforms (powers,
reciprocals) never consult it: they compose through operators alone.

## The `@parametrized` Decorator

The flagship feature. Wrap your distribution constructor once and every
ecosystem's parameter names just work — aliases and alternative
parameterizations resolve automatically from the registry. (Previously
`@distribution`, which is deprecated since 0.2.0 and will be removed in
0.4.0.)

All of these are the *same* Normal:

```python
from distparams import parametrized

# Register the distribution first (see registration below),
# then define your constructor against the canonical parameters.


@parametrized("normal", canonical=["mu", "sigma"])
def normal(mu, sigma):
    return stats.norm(loc=mu, scale=sigma)


normal(mu=0, sigma=1)  # ✓ canonical (Gaussian notation)
normal(mu=0, tau=4)  # ✓ tau -> sigma  (precision parameterization)
normal(mu=0, precision=4)  # ✓ precision -> sigma  (same as tau)
normal(mu=0, variance=4)  # ✓ variance -> sigma
normal(mu=0, var=4)  # ✓ var -> sigma  (same as variance)
normal(loc=0, scale=1)  # ✓ scipy notation
normal(mu=0, std=1)  # ✓ your own aliases
```

`Poisson(mu=...)` and `Poisson(lam=...)` are the same parameter — the
decorator resolves them transparently:

```python
@parametrized("poisson", canonical=["rate"])
def poisson(rate):
    return stats.poisson(mu=rate)


poisson(mu=5)  # ✓ mean parameterization
poisson(lam=5)  # ✓ rate/lambda parameterization
poisson(rate=5)  # ✓ canonical
```

And entire *parameterizations* (not just names) come for free too — a Gamma
written against `(shape, rate)` happily accepts `(shape, scale)`, `(a, scale)`,
`(mu, sigma)`, or `(mu, variance)`:

```python
@parametrized("gamma", canonical=["shape", "rate"])
def gamma(shape, rate):
    return stats.gamma(a=shape, scale=1 / rate)


gamma(shape=2, rate=3)  # ✓ canonical
gamma(shape=2, scale=3)  # ✓ scale -> rate = 1/3
gamma(a=2, scale=3)  # ✓ scipy notation
gamma(mu=2, sigma=2)  # ✓ mean/std parameterization
gamma(mu=2, variance=4)  # ✓ mean/variance (same as sigma=2; var= works too)
```

Conflicting or unknown parameters are caught at call time instead of
silently misbehaving:

```python
normal(mu=0, sigma=1, tau=4)  # ✗ ValueError: both map to 'sigma'
normal(mu=0, foo=1)  # ✗ ValueError: unknown parameter 'foo'
```

To wire this up, define the distribution once (canonical parameters and
every ecosystem's naming). The fluent authoring API makes this read like
the math:

```python
from distparams import Bijection, Distribution, NamingConvention, Parameter, Support, register_distribution

# your own distribution's name — "normal" here for familiarity (the built-in
# is already registered; a fresh name avoids the duplicate-registration error)
mu = Parameter("mu", "Location parameter", Support.real())
mu.alias("mean")

sigma = Parameter("sigma", "Scale parameter", Support.positive())
sigma.alias("scale").alias("std")
sigma.alternative("tau", Bijection.power(-0.5))  # precision → sigma

register_distribution(
    Distribution(
        "normal",
        params={"mu": mu, "sigma": sigma},  # canonical name derived: "mu_sigma"
        naming_conventions=[
            NamingConvention("pymc", {"mu": "mu", "sigma": "sigma"}),
            NamingConvention("scipy", {"loc": "mu", "scale": "sigma"}),
            NamingConvention("jstat", {"mean": "mu", "std": "sigma"}),
        ],
    )
)
```

Ecosystems that share a parametrization are covered once by the built-in
vocabularies (`src/distparams/_vocabularies.py`) — per-distribution
conventions are only needed for genuine exceptions, and the parametrization
name is derived from the parameter declaration order.

## Typing

The decorator preserves your function's signature, so canonical calls are
checked by mypy, pyright, and ty — and IDE autocomplete works on parameter
names and types:

```python
@parametrized("normal")
def normal(mu: float, sigma: float) -> float: ...


normal(mu=0, sigma=1)  # ✓ fully type-checked, like a plain function
normal(0, 1)  # ✓ positional works too
```

Alias calls resolve at runtime from the registry, which is beyond what any
static analyzer can see — a strict checker will flag them even though they
run correctly:

```python
normal(loc=0, scale=1)  # ✓ runs fine, but checkers see unknown names
```

If you want alias resolution to be statically visible, resolve first and
unpack the result — `resolve_parameters` returns canonical parameters:

```python
params = resolve_parameters("normal", loc=0, scale=1)  # {"mu": 0, "sigma": 1}
normal(**params)
```

Rule of thumb: **canonical names are the typed calling convention; aliases
are a runtime convenience.** Typos in alias names are caught at call time with
a `ValueError` listing every valid name, so nothing fails silently.

## Defaults and alternative parameterizations

Your function's own defaults do the heavy lifting — supply what you know,
let the rest default:

```python
@parametrized("normal")
def normal(mu=0, sigma=1):
    return stats.norm(mu, sigma)


normal()  # mu=0, sigma=1      (pure defaults)
normal(sigma=2)  # mu=0, sigma=2      (mu by default)
normal(tau=4)  # mu=0, sigma=0.5    (precision → sigma)
normal(precision=4)  # mu=0, sigma=0.5    (same as tau)
normal(variance=4)  # mu=0, sigma=2      (variance → sigma)
normal(var=4)  # mu=0, sigma=2      (same as variance)
normal(mean=0, std=1)  # jStat-style names work too
```

One name per parameter: `normal(sigma=2, tau=4)` raises a conflict — both
map to sigma — rather than guessing which wins. Values are transformed,
not guessed: `tau=4` means `sigma=0.5`, `tau=2` means `sigma≈0.707`.

An unknown parameter names the distribution, lists every valid name grouped
by role, and suggests the closest match for typos:

```python
normal(precission=4)
# Unknown parameter 'precission' for distribution 'normal'.
# Known parameters: (mu or loc or mean) and
#                   (sigma or scale or std or tau or precision or variance or var or sd).
# Did you mean 'precision'?
```
