Source code for modelly.calibrate.plots

"""Matplotlib figures of a Posterior: marginals, pairs, and the fit to the data.

The figure is built with ``matplotlib.figure.Figure`` (no pyplot state), so it
works the same in scripts, notebooks and without a display; save it with
``figure.savefig(path)``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import numpy as np

from modelly._optional import require
from modelly.calibrate.posterior import weighted_quantile

if TYPE_CHECKING:
    from modelly.calibrate.posterior import Posterior

BINS = 20
POINT_SIZE = 8
ALPHA = 0.5
PANEL_INCHES = 2.4
OBSERVED_STYLE = {"color": "black", "marker": "o", "markersize": 3, "linestyle": "none"}


def _marginal(axis: Any, values: np.ndarray, weights: np.ndarray, name: str) -> None:
    """A weighted histogram of one parameter."""
    axis.hist(values, bins=BINS, weights=weights, color="tab:blue", alpha=ALPHA)
    axis.set_xlabel(name)
    axis.set_yticks([])


def _pairs(figure: Any, posterior: Posterior) -> None:
    """Histograms on the diagonal, weighted scatter plots below it."""
    numeric = posterior.numeric()
    names = list(numeric)
    axes = figure.subplots(len(names), len(names), squeeze=False)
    sizes = POINT_SIZE * posterior.weights / posterior.weights.max()
    for row, y_name in enumerate(names):
        for col, x_name in enumerate(names):
            axis = axes[row][col]
            if col > row:
                axis.set_axis_off()
            elif col == row:
                _marginal(axis, numeric[x_name], posterior.weights, x_name)
            else:
                axis.scatter(numeric[x_name], numeric[y_name], s=sizes, alpha=ALPHA)
                axis.set_xlabel(x_name)
                axis.set_ylabel(y_name)


def _fit(axis: Any, posterior: Posterior, target: Any, probability: float) -> None:
    """The observed values against the weighted median and band of the samples' fits."""
    simulated = posterior.summaries[target.name]
    tail = (1 - probability) / 2
    bands = [
        [weighted_quantile(column, posterior.weights, q) for column in simulated.T]
        for q in (tail, 0.5, 1 - tail)
    ]
    x = np.arange(len(target.observed))
    if target.summary is None:  # the observed series ends at the last step
        x = x + posterior.calibration.steps + 1 - len(target.observed)
        axis.set_xlabel("step")
    axis.fill_between(
        x, bands[0], bands[2], alpha=ALPHA, label=f"{probability:.0%} band"
    )
    axis.plot(x, bands[1], label="posterior median")
    axis.plot(x, target.observed, label="observed", **OBSERVED_STYLE)
    axis.set_title(target.name)
    axis.legend(fontsize="small")


[docs] def posterior_figure(posterior: Posterior, probability: float) -> Any: """The figure of :meth:`Posterior.plot`: pairs on the left, one fit per target on the right.""" matplotlib_figure = require("matplotlib.figure", "viz") targets = posterior.calibration.targets count = max(len(posterior.numeric()), 1) width = PANEL_INCHES * (count + 1) height = PANEL_INCHES * max(count, len(targets)) figure = matplotlib_figure.Figure(figsize=(width, height), layout="constrained") left, right = figure.subfigures(1, 2, width_ratios=[count, 1]) if posterior.numeric(): _pairs(left, posterior) for axis, target in zip( right.subplots(len(targets), 1, squeeze=False)[:, 0], targets ): _fit(axis, posterior, target, probability) return figure