expdpy.distributions

Full source of expdpy.distributionssrc/expdpy/distributions.py on GitHub. Each top-level definition is anchored; reach it from the source link on a reference page.

"""Distribution graphs: histogram and simple count bar chart."""

from __future__ import annotations

import warnings

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from pandas.api import types as pdt
from scipy.stats import gaussian_kde, norm

from expdpy._labels import resolve_label
from expdpy._roles import resolve_roles
from expdpy._theme import apply_default_layout, color_for
from expdpy._types import BarChartResult, HistogramResult
from expdpy._validation import ExpdpyWarning, ensure_dataframe

__all__ = ["explore_bar_plot", "explore_histogram"]

_kde_on_grid

def _kde_on_grid(values: np.ndarray, grid: np.ndarray) -> np.ndarray:
    """Gaussian-KDE density of ``values`` on ``grid`` (zeros if KDE is ill-posed)."""
    if values.size >= 5 and np.ptp(values) > 0:
        try:
            return gaussian_kde(values)(grid)
        except (np.linalg.LinAlgError, ValueError):  # pragma: no cover - singular cov
            return np.zeros_like(grid)
    return np.zeros_like(grid)

explore_histogram

↩︎ docs

def explore_histogram(
    df: pd.DataFrame,
    var: str | None = None,
    *,
    bins: int = 30,
    kde: bool = False,
    normal: bool = False,
    title: str | None = None,
    subtitle: str | None = None,
) -> HistogramResult:
    """Histogram of a numeric variable, optionally with density overlays.

    Parameters
    ----------
    df
        Data frame containing ``var``.
    var
        Numeric column to bin. Defaults to the declared main outcome
        (:func:`expdpy.set_roles`) when omitted.
    bins
        Number of equal-width bins.
    kde
        Overlay a Gaussian kernel-density estimate of the distribution.
    normal
        Overlay a normal curve with the sample mean and standard deviation.

    Returns
    -------
    HistogramResult
        ``df`` (columns ``bin_left``, ``bin_right``, ``count``) and the Plotly ``fig``.

    Notes
    -----
    The density overlays are drawn on the **Density** scale, so requesting either one opens
    the figure in Density view; the built-in Count/Density toggle hides the overlays in
    Count view and shows them in Density view.

    Examples
    --------
    Basic — a 30-bin histogram of a numeric variable, with readable labels from the data
    dictionary:

    ```python
    import expdpy as ex
    from expdpy.data import load_kuznets, load_kuznets_data_def

    df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
    ex.explore_histogram(df, "gini_regional").fig
    ```

    Advanced — overlay a kernel-density estimate and a normal curve:

    ```python
    import expdpy as ex
    from expdpy.data import load_kuznets, load_kuznets_data_def

    df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
    ex.explore_histogram(df, "log_gdp_pc", bins=40, kde=True, normal=True).fig
    ```
    """
    df = ensure_dataframe(df)
    if var is None:
        var = resolve_roles(df)[0]
    if var is None:
        raise ValueError(
            "explore_histogram: pass var=, or declare a main outcome via set_roles"
        )
    if var not in df.columns:
        raise ValueError("var needs to be in df")
    if not pdt.is_numeric_dtype(df[var]):
        raise ValueError(f"var ({var!r}) needs to be numeric")
    var_label = resolve_label(df, var)
    values = df[var].to_numpy(dtype=float)
    n_before = values.size
    values = values[np.isfinite(values)]
    n_dropped = n_before - values.size
    if n_dropped:
        warnings.warn(
            f"explore_histogram: dropped {n_dropped} of {n_before} row(s) "
            f"({n_dropped / n_before:.0%}) with missing values in {[var]}",
            ExpdpyWarning,
            stacklevel=2,
        )
    if values.size == 0:
        raise ValueError(f"var ({var!r}) has no finite observations to bin")
    counts, edges = np.histogram(values, bins=bins)
    widths = np.diff(edges)
    out = pd.DataFrame(
        {"bin_left": edges[:-1], "bin_right": edges[1:], "count": counts}
    )
    centers = (edges[:-1] + edges[1:]) / 2.0
    density = counts / (counts.sum() * widths)

    # Overlays are density-scaled, so requesting one opens the figure in Density view.
    start_density = kde or normal
    bar_hover_count = (
        "[%{customdata[0]:.4g}, %{customdata[1]:.4g})<br>count=%{y}<extra></extra>"
    )
    bar_hover_density = "[%{customdata[0]:.4g}, %{customdata[1]:.4g})<br>density=%{y:.4g}<extra></extra>"
    fig = go.Figure(
        go.Bar(
            x=centers,
            y=density if start_density else counts,
            width=widths,
            marker={"color": color_for(0), "line": {"color": "white", "width": 0.5}},
            customdata=np.stack([edges[:-1], edges[1:]], axis=1),
            hovertemplate=bar_hover_density if start_density else bar_hover_count,
            showlegend=False,
        )
    )

    # Density-scaled overlay curves, comparable to the Density-view bars.
    grid = np.linspace(float(values.min()), float(values.max()), 200)
    overlay_idx: list[int] = []
    overlay_y: list[np.ndarray] = []
    overlay_hover: list[str] = []
    curve_hover = f"{var_label}=%{{x:.4g}}<br>density=%{{y:.4g}}<extra></extra>"
    if kde:
        kde_y = _kde_on_grid(values, grid)
        fig.add_trace(
            go.Scatter(
                x=grid,
                y=kde_y,
                mode="lines",
                name="KDE",
                line={"color": color_for(1), "width": 2.5},
                hovertemplate=curve_hover,
            )
        )
        overlay_idx.append(len(fig.data) - 1)
        overlay_y.append(kde_y)
        overlay_hover.append(curve_hover)
    if normal:
        sigma = float(np.std(values, ddof=1))
        norm_y = (
            norm.pdf(grid, float(np.mean(values)), sigma)
            if sigma > 0
            else np.zeros_like(grid)
        )
        fig.add_trace(
            go.Scatter(
                x=grid,
                y=norm_y,
                mode="lines",
                name="Normal",
                line={"color": color_for(3), "width": 2.5, "dash": "dash"},
                hovertemplate=curve_hover,
            )
        )
        overlay_idx.append(len(fig.data) - 1)
        overlay_y.append(norm_y)
        overlay_hover.append(curve_hover)

    # Toggle: Count hides the (density-scaled) overlays; Density shows them.
    idx = [0, *overlay_idx]
    n = len(overlay_idx)
    count_args = {
        "y": [counts, *overlay_y],
        "visible": [True, *([False] * n)],
        "hovertemplate": [bar_hover_count, *overlay_hover],
    }
    density_args = {
        "y": [density, *overlay_y],
        "visible": [True, *([True] * n)],
        "hovertemplate": [bar_hover_density, *overlay_hover],
    }
    apply_default_layout(
        fig,
        xaxis={"title": var_label},
        yaxis={"title": "Density" if start_density else "Count"},
        bargap=0,
        updatemenus=[
            {
                "type": "buttons",
                "direction": "right",
                "x": 0,
                "y": 1.14,
                "xanchor": "left",
                "showactive": True,
                "active": 1 if start_density else 0,
                "buttons": [
                    {
                        "label": "Count",
                        "method": "update",
                        "args": [count_args, {"yaxis.title.text": "Count"}, idx],
                    },
                    {
                        "label": "Density",
                        "method": "update",
                        "args": [density_args, {"yaxis.title.text": "Density"}, idx],
                    },
                ],
            }
        ],
    )
    if title is not None or subtitle is not None:
        apply_default_layout(fig, title=title, subtitle=subtitle)
    return HistogramResult(df=out, fig=fig)

explore_bar_plot

↩︎ docs

def explore_bar_plot(
    df: pd.DataFrame,
    var: str,
    *,
    order_by_count: bool = False,
    color: str | None = None,
    title: str | None = None,
    subtitle: str | None = None,
) -> BarChartResult:
    """Bar chart of category counts for a (typically categorical) variable.

    Parameters
    ----------
    df
        Data frame containing ``var``.
    var
        Column whose value counts are charted.
    order_by_count
        If ``True``, bars are ordered by descending count; otherwise by category.
    color
        Bar fill color. Defaults to the primary theme color.

    Returns
    -------
    BarChartResult
        ``df`` (columns ``var`` and ``count``) and the Plotly ``fig``.

    Examples
    --------
    Basic — category counts of a (typically categorical) variable, with readable
    labels from the data dictionary:

    ```python
    import expdpy as ex
    from expdpy.data import load_kuznets, load_kuznets_data_def

    df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
    ex.explore_bar_plot(df, "continent").fig
    ```

    Advanced — order bars by descending count and set a custom color:

    ```python
    import expdpy as ex
    from expdpy.data import load_kuznets, load_kuznets_data_def

    df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
    ex.explore_bar_plot(df, "continent", order_by_count=True, color="red").fig
    ```
    """
    df = ensure_dataframe(df)
    if var not in df.columns:
        raise ValueError("var needs to be in df")
    var_label = resolve_label(df, var)
    counts = df[var].value_counts(dropna=False)
    out = counts.rename_axis(var).reset_index(name="count")
    if not order_by_count:
        # Sort by category as text so a missing/NaN level cannot raise on a mixed-type index.
        out = out.sort_values(
            var, key=lambda s: s.astype(str), kind="stable"
        ).reset_index(drop=True)
    bar_color = color if color is not None else color_for(0)
    fig = go.Figure(
        go.Bar(
            x=out[var].astype(str),
            y=out["count"],
            marker={"color": bar_color, "line": {"color": "white", "width": 0.5}},
            hovertemplate=f"{var_label}=%{{x}}<br>count=%{{y}}<extra></extra>",
        )
    )
    xaxis: dict = {"title": var_label}
    if len(out) > 8:
        xaxis["tickangle"] = -40
    apply_default_layout(fig, xaxis=xaxis, yaxis={"title": "Count"})
    if title is not None or subtitle is not None:
        apply_default_layout(fig, title=title, subtitle=subtitle)
    return BarChartResult(df=out, fig=fig)