DOROTHY home

Source code for dorothy.visualization.evaluation_plots

"""Test-set evaluation plots.

All functions take arrays in physical units, shaped (n_samples, n_params):
predictions, true labels, predicted model scatter, label errors and a validity
mask (label valid where > 0). Grids are sized to the number of parameters,
and axis ranges default to percentiles of the data. A plot function writes a
PNG into a directory and returns its path, or None when matplotlib is not
installed.

Adapted from evaluation plots by Diego (d5martin).
"""

from __future__ import annotations

import math
from pathlib import Path

import numpy as np
from numpy.typing import NDArray

from dorothy.config.schema import parameter_display_name
from dorothy.visualization.training_plots import _try_import_matplotlib

# Sequential colour maps used by the scatter and density panels
_SCATTER_COLOURS = [
    (0.8, 1.0, 0.8),
    (0.2, 0.85, 0.5),
    (0.0, 0.6, 0.8),
    (0.0314, 0.4078, 0.6745),
    (0.0, 0.1490, 0.3176),
    (0.4, 0.0, 0.4),
]
_DENSITY_COLOURS = [
    (1.0, 1.0, 1.0),
    (0.2, 0.85, 0.5),
    (0.0, 0.6, 0.8),
    (0.0314, 0.4078, 0.6745),
    (0.0, 0.1490, 0.3176),
]

Range = tuple[float, float]


def _grid(plt, n_panels: int, panel_size: tuple[float, float] = (4.0, 3.5)):
    """Create a near-square grid of axes; unused axes are removed later."""
    n_cols = min(5, n_panels)
    n_rows = math.ceil(n_panels / n_cols)
    fig, axes = plt.subplots(
        n_rows,
        n_cols,
        figsize=(panel_size[0] * n_cols, panel_size[1] * n_rows),
        squeeze=False,
    )
    axes = axes.flatten()
    for ax in axes[n_panels:]:
        fig.delaxes(ax)
    return fig, axes[:n_panels]


def _robust_range(*arrays: NDArray, lo: float = 0.5, hi: float = 99.5) -> Range:
    """Percentile range covering all finite values in the given arrays."""
    values = np.concatenate([np.ravel(a) for a in arrays])
    values = values[np.isfinite(values)]
    if len(values) == 0:
        return (0.0, 1.0)
    vmin, vmax = np.percentile(values, [lo, hi])
    if vmin == vmax:
        vmin, vmax = vmin - 0.5, vmax + 0.5
    pad = 0.02 * (vmax - vmin)
    return (float(vmin - pad), float(vmax + pad))


def _symmetric_range(values: NDArray, pct: float = 99.0) -> Range:
    """Symmetric range about zero covering the given percentile of |values|."""
    values = values[np.isfinite(values)]
    if len(values) == 0:
        return (-1.0, 1.0)
    half = float(np.percentile(np.abs(values), pct)) or 1.0
    return (-half, half)


def _valid(mask: NDArray, i: int, *arrays: NDArray) -> NDArray:
    """Boolean selector for samples with a valid, finite label for parameter i."""
    keep = mask[:, i] > 0
    for a in arrays:
        keep &= np.isfinite(a[:, i])
    return keep


def _label_text(label_source: str | None) -> str:
    return label_source.upper() if label_source else "Label"


def _check_shapes(*arrays: NDArray) -> None:
    shapes = {np.shape(a) for a in arrays}
    if len(shapes) != 1:
        raise ValueError(f"All arrays must have the same shape, got {shapes}")


[docs] def plot_pred_vs_truth( output_path: str | Path, preds: NDArray, true: NDArray, pred_unc: NDArray, mask: NDArray, parameter_names: list[str], label_source: str | None = None, ranges: dict[str, Range] | None = None, filename: str = "pred_vs_truth.png", ) -> Path | None: """Predicted vs. true value per parameter, coloured by predicted uncertainty. Args: output_path: Directory to save the plot. preds: Predicted values, shape (n_samples, n_params). true: True label values, same shape. pred_unc: Predicted uncertainties, same shape. mask: Label validity mask (valid where > 0), same shape. parameter_names: Parameter names (length n_params). label_source: Label source name, used in axis labels and titles. ranges: Optional per-parameter (min, max) axis ranges, keyed by name. filename: Output file name. Returns: Path of the plot, or None if matplotlib is missing. Raises: ValueError: If the arrays differ in shape. """ plt = _try_import_matplotlib() if plt is None: return None from matplotlib.colors import LinearSegmentedColormap _check_shapes(preds, true, pred_unc, mask) cmap = LinearSegmentedColormap.from_list("dorothy_scatter", _SCATTER_COLOURS) ranges = ranges or {} fig, axes = _grid(plt, len(parameter_names)) for i, (ax, name) in enumerate(zip(axes, parameter_names, strict=True)): keep = _valid(mask, i, preds, true) x, y, c = true[keep, i], preds[keep, i], pred_unc[keep, i] lim = ranges.get(name) or _robust_range(x, y) if len(x) > 0: vmin, vmax = ( np.nanpercentile(c, [1, 99]) if np.isfinite(c).any() else (0, 1) ) sc = ax.scatter(x, y, s=4, alpha=0.6, c=c, cmap=cmap, vmin=vmin, vmax=vmax) fig.colorbar(sc, ax=ax, fraction=0.045, pad=0.02, label="Pred. uncertainty") ax.plot(lim, lim, color="black", lw=1, ls="--") ax.set_xlim(lim) ax.set_ylim(lim) ax.set_xlabel(f"{_label_text(label_source)} {parameter_display_name(name)}") ax.set_ylabel(f"Predicted {parameter_display_name(name)}") ax.set_title(f"{parameter_display_name(name)} (N={len(x):,})", fontsize=10) ax.grid(True, ls=":") fig.suptitle(f"Predicted vs. true ({_label_text(label_source)})") fig.tight_layout() save_path = Path(output_path) / filename fig.savefig(save_path, dpi=150, bbox_inches="tight") plt.close(fig) return save_path
[docs] def plot_residuals( output_path: str | Path, preds: NDArray, true: NDArray, mask: NDArray, parameter_names: list[str], label_source: str | None = None, ranges: dict[str, Range] | None = None, n_bins: int = 50, filename: str = "residuals.png", ) -> Path | None: """Residual (true - predicted) density vs. true value with running quartiles. Args: output_path: Directory to save the plot. preds: Predicted values, shape (n_samples, n_params). true: True label values, same shape. mask: Label validity mask (valid where > 0), same shape. parameter_names: Parameter names (length n_params). label_source: Label source name, used in axis labels and titles. ranges: Optional per-parameter (min, max) ranges for the true value. n_bins: Number of bins along each axis. filename: Output file name. Returns: Path of the plot, or None if matplotlib is missing. Raises: ValueError: If the arrays differ in shape. """ plt = _try_import_matplotlib() if plt is None: return None from matplotlib.colors import LinearSegmentedColormap _check_shapes(preds, true, mask) cmap = LinearSegmentedColormap.from_list("dorothy_density", _DENSITY_COLOURS) ranges = ranges or {} fig, axes = _grid(plt, len(parameter_names)) for i, (ax, name) in enumerate(zip(axes, parameter_names, strict=True)): keep = _valid(mask, i, preds, true) x = true[keep, i] res = x - preds[keep, i] if len(x) == 0: ax.set_title(f"{parameter_display_name(name)} (no data)", fontsize=10) continue x_lim = ranges.get(name) or _robust_range(x) y_lim = _symmetric_range(res) x_edges = np.linspace(*x_lim, n_bins + 1) y_edges = np.linspace(*y_lim, n_bins + 1) counts, _, _ = np.histogram2d(x, res, bins=[x_edges, y_edges]) pcm = ax.pcolormesh(x_edges, y_edges, counts.T, cmap=cmap, shading="auto") fig.colorbar(pcm, ax=ax, label="Number of stars") # Running quartiles in bins with data centres, quartiles = [], [] bin_idx = np.digitize(x, x_edges) - 1 for b in range(n_bins): in_bin = res[bin_idx == b] if len(in_bin) > 0: centres.append(0.5 * (x_edges[b] + x_edges[b + 1])) quartiles.append(np.percentile(in_bin, [25, 50, 75])) if centres: quartiles = np.array(quartiles) ax.plot(centres, quartiles[:, 0], "k--", lw=1, label="25th pct") ax.plot(centres, quartiles[:, 1], "k-", lw=1.2, label="Median") ax.plot(centres, quartiles[:, 2], "k:", lw=1, label="75th pct") ax.legend(fontsize=7) ax.axhline(0, color="grey", lw=0.8) ax.set_xlabel(f"{_label_text(label_source)} {parameter_display_name(name)}") ax.set_ylabel("True - predicted") ax.set_title(parameter_display_name(name), fontsize=10) ax.grid(True, ls="--", alpha=0.5) fig.suptitle(f"Residuals ({_label_text(label_source)})") fig.tight_layout() save_path = Path(output_path) / filename fig.savefig(save_path, dpi=150, bbox_inches="tight") plt.close(fig) return save_path
[docs] def plot_histograms( output_path: str | Path, preds: NDArray, true: NDArray, mask: NDArray, parameter_names: list[str], label_source: str | None = None, ranges: dict[str, Range] | None = None, n_bins: int = 60, filename: str = "histograms.png", ) -> Path | None: """Overlaid histograms of true and predicted values per parameter. Args: output_path: Directory to save the plot. preds: Predicted values, shape (n_samples, n_params). true: True label values, same shape. mask: Label validity mask (valid where > 0), same shape. parameter_names: Parameter names (length n_params). label_source: Label source name, used in the legend. ranges: Optional per-parameter (min, max) histogram ranges. n_bins: Number of histogram bins. filename: Output file name. Returns: Path of the plot, or None if matplotlib is missing. Raises: ValueError: If the arrays differ in shape. """ plt = _try_import_matplotlib() if plt is None: return None _check_shapes(preds, true, mask) ranges = ranges or {} fig, axes = _grid(plt, len(parameter_names)) for i, (ax, name) in enumerate(zip(axes, parameter_names, strict=True)): keep = _valid(mask, i, preds, true) x, y = true[keep, i], preds[keep, i] lim = ranges.get(name) or _robust_range(x, y) bins = np.linspace(*lim, n_bins + 1) ax.hist(x, bins=bins, alpha=0.6, label=_label_text(label_source)) ax.hist(y, bins=bins, alpha=0.6, label="DOROTHY") ax.set_xlabel(parameter_display_name(name)) ax.set_ylabel("Number of stars") ax.legend(fontsize=7) fig.tight_layout() save_path = Path(output_path) / filename fig.savefig(save_path, dpi=150, bbox_inches="tight") plt.close(fig) return save_path
[docs] def plot_zscore_histograms( output_path: str | Path, preds: NDArray, true: NDArray, pred_unc: NDArray, label_err: NDArray | None, mask: NDArray, parameter_names: list[str], label_source: str | None = None, z_max: float = 5.0, filename: str = "zscore_histograms.png", ) -> Path | None: """Per-parameter z-score histograms against a unit Gaussian. z = (predicted - true) / sqrt(pred_unc^2 + label_err^2), as in ``Evaluator``. Each panel reports the median and the robust scatter (P84 - P16) / 2. A calibrated parameter has median 0 and scatter 1. Scatter above 1 means overconfident, below 1 underconfident. Values outside [-z_max, z_max] fall in the end bins. Args: output_path: Directory to save the plot. preds: Predicted values, shape (n_samples, n_params). true: True label values, same shape. pred_unc: Predicted model scatter, same shape. label_err: Label errors, same shape, or None to use pred_unc only. mask: Label validity mask (valid where > 0), same shape. parameter_names: Parameter names (length n_params). label_source: Label source name, used in the title. z_max: Histogram range is [-z_max, z_max]. filename: Output file name. Returns: Path of the plot, or None if matplotlib is missing. Raises: ValueError: If the arrays differ in shape. """ plt = _try_import_matplotlib() if plt is None: return None if label_err is None: label_err = np.zeros_like(pred_unc) _check_shapes(preds, true, pred_unc, label_err, mask) fig, axes = _grid(plt, len(parameter_names), panel_size=(3.6, 3.0)) grid = np.linspace(-z_max, z_max, 200) gaussian = np.exp(-0.5 * grid**2) / np.sqrt(2 * np.pi) for i, (ax, name) in enumerate(zip(axes, parameter_names, strict=True)): keep = _valid(mask, i, preds, true, pred_unc) total = np.sqrt(pred_unc[keep, i] ** 2 + label_err[keep, i] ** 2) with np.errstate(divide="ignore", invalid="ignore"): z = (preds[keep, i] - true[keep, i]) / total z = z[np.isfinite(z)] if len(z) > 0: p16, p50, p84 = np.percentile(z, [16, 50, 84]) ax.hist( np.clip(z, -z_max, z_max), bins=np.linspace(-z_max, z_max, 41), density=True, alpha=0.7, ) ax.text( 0.03, 0.95, f"med={p50:+.2f}\nscatter={(p84 - p16) / 2:.2f}", transform=ax.transAxes, va="top", fontsize=8, ) ax.plot(grid, gaussian, "k--", lw=1) ax.set_xlim(-z_max, z_max) ax.set_title(f"{parameter_display_name(name)} (N={len(z):,})", fontsize=10) ax.set_xlabel("z-score") fig.suptitle(f"Uncertainty calibration ({_label_text(label_source)})") fig.tight_layout() save_path = Path(output_path) / filename fig.savefig(save_path, dpi=150, bbox_inches="tight") plt.close(fig) return save_path
[docs] def generate_evaluation_plots( output_dir: str | Path, preds: NDArray, true: NDArray, pred_unc: NDArray, label_err: NDArray | None, mask: NDArray, parameter_names: list[str], label_source: str | None = None, ranges: dict[str, Range] | None = None, ) -> list[Path]: """Write the four evaluation plots for one label source. The files are ``pred_vs_truth.png``, ``residuals.png``, ``histograms.png`` and ``zscore_histograms.png``, each prefixed with ``<label_source>_`` when a label source is given. Args: output_dir: Directory of the plots, created if missing. preds: Predicted values, shape (n_samples, n_params). true: True label values, same shape. pred_unc: Predicted model scatter, same shape. label_err: Label errors, same shape, or None to use pred_unc only. mask: Label validity mask (valid where > 0), same shape. parameter_names: Parameter names (length n_params). label_source: Label source name, used in titles and file names. ranges: Optional per-parameter (min, max) ranges for the first three plots, keyed by name. Returns: Paths of the plots written; empty if matplotlib is missing. Raises: ValueError: If the arrays differ in shape. """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) prefix = f"{label_source}_" if label_source else "" common = {"parameter_names": parameter_names, "label_source": label_source} paths = [ plot_pred_vs_truth( output_dir, preds, true, pred_unc, mask, ranges=ranges, filename=f"{prefix}pred_vs_truth.png", **common, ), plot_residuals( output_dir, preds, true, mask, ranges=ranges, filename=f"{prefix}residuals.png", **common, ), plot_histograms( output_dir, preds, true, mask, ranges=ranges, filename=f"{prefix}histograms.png", **common, ), plot_zscore_histograms( output_dir, preds, true, pred_unc, label_err, mask, filename=f"{prefix}zscore_histograms.png", **common, ), ] return [p for p in paths if p is not None]