# 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})"`.



```
from wigglystuff import TangleDistribution

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.


  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. |
