Skip to content
Feeding a clanker? Grab this page as raw .md →

TangleDistribution API#

Sometimes a single number is too confident. TangleDistribution puts a whole distribution inside your prose, rendered as 50.0 ± 10.0 by default. Drag left/right to change one parameter and up/down to change the other; while you drag, a small chart floats above the page with the starting shape dashed and the current one filled, so you can see exactly what you are changing.

Fourteen families are supported (TangleDistribution.distributions), named after their scipy.stats counterparts and using the same parameters. The maths is built in, so cdf, ppf, pdf/pmf and sample work without scipy, and only for the families listed here.

family kind ↔ drag ↕ drag
norm continuous loc scale
lognorm continuous scale (the median) s
gamma continuous scale a (the shape)
expon continuous scale (the mean) (none)
weibull_min continuous scale c (the shape)
uniform continuous loc (left edge) scale (width)
laplace continuous loc scale
logistic continuous loc scale
beta continuous a b
poisson discrete mu (none)
binom discrete p n (whole number)
nbinom discrete p n
geom discrete p (none)
randint discrete low high (excluded, as in scipy)

params only changes when you let go of a drag. While dragging, live_params follows the pointer and dragging is True, so the rest of a notebook can show the old and the new distribution side by side. Every method takes live=True to use the in-progress values:

from wigglystuff import TangleDistribution

delivery = TangleDistribution("lognorm", params={"scale": 25, "s": 0.3}, template="{mean:.0f} ± {sd:.0f} minutes")

delivery.ppf(0.9)            # 90% of deliveries arrive within this many minutes
delivery.cdf(30)             # chance it arrives within half an hour
delivery.sample(1000)        # numpy array of draws
delivery.ppf(0.9, live=True) # the same, for the distribution being dragged right now

The inline text comes from template. Its placeholders are the keys of params plus the computed mean and sd (widget.template_fields lists them), each with an optional :.Nf precision, e.g. "median {scale:.0f} days (s={s:.2f})".

See also: Tangle widgets for a single draggable number, and TangleFunction for turning a whole function call into draggable arguments.

Bases: AnyWidget

Inline tangle number that carries a whole distribution.

Drag left/right to change the first parameter and up/down to change the second. While dragging, a small chart of the distribution floats above the page so you can see the shape change. Family and parameter names follow scipy.stats, but only the families below are supported (see TangleDistribution.distributions), and the maths is built in, so scipy is not needed.

family kind ↔ drag ↕ drag
"norm" continuous loc scale
"lognorm" continuous scale (the median) s
"gamma" continuous scale a (the shape)
"expon" continuous scale (the mean) (none)
"weibull_min" continuous scale c (the shape)
"uniform" continuous loc (left edge) scale (width)
"laplace" continuous loc scale
"logistic" continuous loc scale
"beta" continuous a b
"poisson" discrete mu (none)
"binom" discrete p n (whole number)
"nbinom" discrete p n
"geom" discrete p (none)
"randint" discrete low high (excluded, as in scipy)

Template fields are the parameters in the table plus mean and sd.

Reading the distribution back in Python:

  • params holds the committed values and only changes when a drag is released. While dragging, live_params follows the pointer (throttled by sync_throttle_ms) and dragging is True, so the rest of the notebook can compare the old distribution against the new one live.
  • mean and sd are computed from params.
  • pdf(x) (continuous) or pmf(k) (discrete), cdf(x), ppf(q) and sample(n) all accept live=True to use live_params instead. sample needs numpy, as do the others when given an array.

The inline text comes from template, by default "{mean} ± {sd}". Placeholders are the keys of params plus the computed mean and sd (widget.template_fields lists them), each with an optional :.Nf precision, e.g. "median {scale:.0f} days (s={s:.2f})".

Examples:

import marimo as mo
from wigglystuff import TangleDistribution

duration = mo.ui.anywidget(
    TangleDistribution("lognorm", params={"scale": 10, "s": 0.4}, template="{mean} ± {sd} days")
)
duration
duration.ppf(0.9)  # 90% of runs finish within this many days
duration.cdf(14)   # chance of finishing within two weeks

Create an inline distribution control.

Parameters:

Name Type Description Default
distribution str

One of TangleDistribution.distributions, see the table above.

'norm'
params Optional[Dict[str, float]]

Starting parameters by scipy name; missing ones use the family default.

None
bounds Optional[Dict[str, Tuple[Optional[float], Optional[float]]]]

Optional (low, high) per parameter; either end may be None. Defaults: loc and randint's low/high are unbounded, a probability like p stays inside (step, 1 - step), binom's n is at least 1, and everything else stays positive with (step, None).

None
steps Optional[Dict[str, float]]

Change per drag step, per parameter. Defaults to 1 for whole numbers and otherwise a power of ten that suits the starting value.

None
pixels_per_step int

Drag distance per step (both directions).

2
template str

Inline text. {name} placeholders take any parameter of the family plus mean and sd (see template_fields); add :.Nf (e.g. {mean:.0f}) to override digits for that placeholder.

'{mean} ± {sd}'
digits Optional[int]

Default number of decimals in the inline text; defaults to what the smallest step and the starting sd need. Whole number parameters always show without decimals.

None
sync_throttle_ms int

Cap on how often live_params reaches Python while dragging, in milliseconds. 0 syncs every move.

100
**kwargs Any

Forwarded to anywidget.AnyWidget.

{}
Source code in wigglystuff/tangle.py
def __init__(
    self,
    distribution: str = "norm",
    params: Optional[Dict[str, float]] = None,
    bounds: Optional[Dict[str, Tuple[Optional[float], Optional[float]]]] = None,
    steps: Optional[Dict[str, float]] = None,
    pixels_per_step: int = 2,
    template: str = "{mean} ± {sd}",
    digits: Optional[int] = None,
    sync_throttle_ms: int = 100,
    **kwargs: Any,
) -> None:
    """Create an inline distribution control.

    Args:
        distribution: One of ``TangleDistribution.distributions``, see
            the table above.
        params: Starting parameters by scipy name; missing ones use the
            family default.
        bounds: Optional ``(low, high)`` per parameter; either end may be
            ``None``. Defaults: ``loc`` and randint's ``low``/``high`` are
            unbounded, a probability like ``p`` stays inside
            ``(step, 1 - step)``, binom's ``n`` is at least 1, and
            everything else stays positive with ``(step, None)``.
        steps: Change per drag step, per parameter. Defaults to 1 for whole
            numbers and otherwise a power of ten that suits the starting
            value.
        pixels_per_step: Drag distance per step (both directions).
        template: Inline text. ``{name}`` placeholders take any parameter
            of the family plus ``mean`` and ``sd`` (see ``template_fields``);
            add ``:.Nf`` (e.g. ``{mean:.0f}``) to override ``digits`` for
            that placeholder.
        digits: Default number of decimals in the inline text; defaults
            to what the smallest step and the starting sd need. Whole
            number parameters always show without decimals.
        sync_throttle_ms: Cap on how often ``live_params`` reaches Python
            while dragging, in milliseconds. ``0`` syncs every move.
        **kwargs: Forwarded to ``anywidget.AnyWidget``.
    """
    if distribution not in FAMILIES:
        raise ValueError(
            f"distribution={distribution!r} is not supported; pick one of {list(FAMILIES)}."
        )
    spec = FAMILIES[distribution]
    names = list(spec["params"])
    params, bounds, steps = dict(params or {}), dict(bounds or {}), dict(steps or {})
    for given in (params, bounds, steps):
        unknown = set(given) - set(names)
        if unknown:
            raise ValueError(
                f"{distribution!r} has parameters {names}; got unknown {sorted(unknown)}."
            )

    full_params, full_bounds, full_steps = {}, {}, {}
    for name in names:
        value = params.get(name, spec["params"][name])
        unbounded = name in spec.get("unbounded", [])
        if name in spec.get("integer", []):
            if value != int(value):
                raise ValueError(f"{name!r} must be a whole number, got {value}.")
            value = int(value)
            step = steps.get(name, 1)
            if step != int(step) or step < 1:
                raise ValueError(f"The step for {name!r} must be a whole number of at least 1.")
            step = int(step)
            low, high = bounds.get(name, (None, None) if unbounded else (1, None))
            if not unbounded and (low is None or low < 1):
                raise ValueError(f"The lower bound for {name!r} must be at least 1.")
        elif name in spec.get("probability", []):
            value = float(value)
            step = float(steps.get(name, _default_step(value)))
            low, high = bounds.get(name, (step, 1 - step))
            if low is None or high is None or low <= 0 or high >= 1:
                raise ValueError(f"The bounds for {name!r} must lie strictly between 0 and 1.")
        else:
            value = float(value)
            step = float(steps.get(name, _default_step(value)))
            low, high = bounds.get(name, (None, None) if unbounded else (step, None))
            if not unbounded and (low is None or low <= 0):
                raise ValueError(f"The lower bound for {name!r} must be positive.")
        if (low is not None and value < low) or (high is not None and value > high):
            raise ValueError(f"{name}={value} is outside the bounds ({low}, {high}).")
        full_params[name], full_bounds[name], full_steps[name] = value, [low, high], step

    if distribution == "randint" and full_params["low"] >= full_params["high"]:
        raise ValueError("randint needs low < high (high itself is excluded, as in scipy).")

    allowed = names + ["mean", "sd"]
    for _, field, fmt_spec, _ in string.Formatter().parse(template):
        if field is None:
            continue
        if field not in allowed:
            raise ValueError(
                f"Unknown placeholder {{{field}}} in template; {distribution!r} supports {allowed}."
            )
        if fmt_spec and not re.fullmatch(r"\.\d+f", fmt_spec):
            raise ValueError(f"Only '.Nf' precision is supported in the template, got {fmt_spec!r}.")

    if digits is None:
        # Enough decimals for the finest drag step, and for two significant
        # figures of the starting sd so "{mean} ± {sd}" never reads "0.3 ± 0.2".
        step_digits = max(-math.floor(math.log10(s)) for s in full_steps.values())
        sd = moments(distribution, full_params)[1]
        sd_digits = 1 - math.floor(math.log10(sd)) if sd > 0 else 0  # randint can have one value
        digits = max(0, step_digits, sd_digits)

    super().__init__(
        distribution=distribution,
        params=full_params,
        live_params=dict(full_params),
        bounds=full_bounds,
        steps=full_steps,
        pixels_per_step=pixels_per_step,
        template=template,
        digits=digits,
        sync_throttle_ms=sync_throttle_ms,
        **kwargs,
    )

discrete property #

discrete: bool

True for families over whole numbers (poisson, binom, nbinom, geom, randint).

mean property #

mean: float

Mean of the committed distribution.

sd property #

sd: float

Standard deviation of the committed distribution.

template_fields property #

template_fields: List[str]

Placeholders template accepts: the family's parameters plus mean and sd.

cdf #

cdf(x, live: bool = False)

Probability of a value at or below x (scalar or array-like).

Source code in wigglystuff/tangle.py
def cdf(self, x, live: bool = False):
    """Probability of a value at or below ``x`` (scalar or array-like)."""
    p = self._p(live)
    return _elementwise(lambda v: cdf(self.distribution, p, v), x)

pdf #

pdf(x, live: bool = False)

Probability density at x (scalar or array-like); continuous families only.

Source code in wigglystuff/tangle.py
def pdf(self, x, live: bool = False):
    """Probability density at ``x`` (scalar or array-like); continuous families only."""
    if self.discrete:
        raise TypeError(f"{self.distribution!r} is discrete; use pmf(k) instead of pdf(x).")
    p = self._p(live)
    return _elementwise(lambda v: density(self.distribution, p, v), x)

pmf #

pmf(k, live: bool = False)

Probability of exactly k (scalar or array-like); discrete families only.

Source code in wigglystuff/tangle.py
def pmf(self, k, live: bool = False):
    """Probability of exactly ``k`` (scalar or array-like); discrete families only."""
    if not self.discrete:
        raise TypeError(f"{self.distribution!r} is continuous; use pdf(x) instead of pmf(k).")
    p = self._p(live)
    return _elementwise(lambda v: density(self.distribution, p, v), k)

ppf #

ppf(q, live: bool = False)

Value below which a fraction q of the distribution lies (inverse of cdf).

Source code in wigglystuff/tangle.py
def ppf(self, q, live: bool = False):
    """Value below which a fraction ``q`` of the distribution lies (inverse of ``cdf``)."""
    p = self._p(live)
    return _elementwise(lambda v: ppf(self.distribution, p, v), q)

sample #

sample(n: int, seed: Optional[int] = None, live: bool = False)

Draw n samples as a numpy array.

Source code in wigglystuff/tangle.py
def sample(self, n: int, seed: Optional[int] = None, live: bool = False):
    """Draw ``n`` samples as a numpy array."""
    import numpy as np

    p = self._p(live)
    rng = np.random.default_rng(seed)
    if self.distribution == "norm":
        return rng.normal(p["loc"], p["scale"], size=n)
    if self.distribution == "lognorm":
        return rng.lognormal(math.log(p["scale"]), p["s"], size=n)
    if self.distribution == "gamma":
        return rng.gamma(p["a"], p["scale"], size=n)
    if self.distribution == "expon":
        return rng.exponential(p["scale"], size=n)
    if self.distribution == "weibull_min":
        return p["scale"] * rng.weibull(p["c"], size=n)
    if self.distribution == "laplace":
        return rng.laplace(p["loc"], p["scale"], size=n)
    if self.distribution == "logistic":
        return rng.logistic(p["loc"], p["scale"], size=n)
    if self.distribution == "uniform":
        return rng.uniform(p["loc"], p["loc"] + p["scale"], size=n)
    if self.distribution == "beta":
        return rng.beta(p["a"], p["b"], size=n)
    if self.distribution == "poisson":
        return rng.poisson(p["mu"], size=n)
    if self.distribution == "binom":
        return rng.binomial(int(p["n"]), p["p"], size=n)
    if self.distribution == "nbinom":
        return rng.negative_binomial(p["n"], p["p"], size=n)
    if self.distribution == "geom":
        return rng.geometric(p["p"], size=n)
    return rng.integers(int(p["low"]), int(p["high"]), size=n)

Synced traitlets#

Traitlet Type Notes
distribution str Family name, one of TangleDistribution.distributions.
params dict Committed parameters by scipy name; changes when a drag is released.
live_params dict In-progress parameters while dragging; equals params otherwise.
dragging bool True while the pointer is held down on the widget.
bounds dict [low, high] per parameter; either end may be None.
steps dict Change per drag step, per parameter.
pixels_per_step int Drag distance per step.
template str Inline text with {param}, {mean} and {sd} placeholders.
digits int Default number of decimals in the inline text.
sync_throttle_ms int Cap on how often live_params reaches Python while dragging.