Source code for knowledgespaces.viz.reports

"""Static plots of clauses, coefficients and retained bootstrap outcomes."""

from __future__ import annotations

from collections.abc import Mapping
from textwrap import fill
from typing import TYPE_CHECKING, Literal, cast

import numpy as np
from numpy.typing import ArrayLike

from knowledgespaces.structures.surmise_function import SurmiseFunction
from knowledgespaces.viz.hasse import _MATPLOTLIB_IMPORT_ERROR, _state_label, plot_hasse

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure


def _axes(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
    try:
        import matplotlib.pyplot as plt
    except ImportError as error:
        raise ImportError(_MATPLOTLIB_IMPORT_ERROR) from error
    if ax is None:
        return plt.subplots(figsize=figsize)
    return cast("Figure", ax.get_figure()), ax


def _replicate_note(
    values: np.ndarray, converged: ArrayLike | None, *, displayed: bool = True
) -> str:
    note = f"Replicates: {len(values)}; nonfinite values: {int((~np.isfinite(values)).sum())}"
    if converged is not None:
        status = np.asarray(converged)
        if status.dtype.kind != "b" or status.shape != (len(values),):
            raise ValueError("converged must be a boolean vector with one entry per replicate.")
        note += f"; nonconverged: {int((~status).sum())}"
    return note + (
        ". All finite values retained." if displayed else ". Replicate distribution not drawn."
    )


def _title_with_note(axes: Axes, title: str, note: str) -> None:
    """Keep status and heading in one measured title box for tight_layout."""
    if note:
        axes.set_title(
            "\n".join(part for part in (title, fill(note.strip(), width=70)) if part), fontsize=10
        )
    else:
        axes.set_title(title)


[docs] def plot_surmise_function( function: SurmiseFunction, *, space: bool = False, max_items: int | None = None, ax: Axes | None = None, figsize: tuple[float, float] = (10, 8), orientation: Literal["vertical", "horizontal"] = "vertical", node_color: str = "#4A90D9", font_size: int = 8, title: str | None = None, ) -> Figure: """Draw clauses as base nodes, labelled with the items at which they are atoms. The same clause can be atomic at several items, all shown in its label. ``space=True`` explicitly expands the union span and labels atomic states there; ``max_items`` is forwarded to the guarded expansion. The default plots the compact base. Display text does not alter state identities. """ if not isinstance(space, bool): raise ValueError("space must be a boolean.") structure = ( function.to_knowledge_space(max_items=max_items) if space else function.to_knowledge_base().as_family() ) labels = {} for state in structure: atomic_at = sorted(q for q in function.domain if state in function.clauses_for(q)) suffix = "\natom at " + ", ".join(atomic_at) if atomic_at else "" labels[state] = _state_label(state) + suffix return plot_hasse( structure, ax=ax, figsize=figsize, orientation=orientation, node_color=node_color, font_size=font_size, node_labels=labels, title="Surmise function: clauses and atomic items" if title is None else title, )
[docs] def plot_coefficients( coefficients: Mapping[str, float], *, standard_deviations: Mapping[str, float] | None = None, replicates: ArrayLike | None = None, converged: ArrayLike | None = None, kind: Literal["point", "box"] = "point", ax: Axes | None = None, figsize: tuple[float, float] = (8, 5), color: str = "#4A90D9", title: str = "Coefficients", ylabel: str = "Parameter value", ) -> Figure: """Plot named estimates with SD bars or bootstrap parameter boxplots. Column order of ``replicates`` must match insertion order of the mapping. SD bars are descriptive ±1 SD, not confidence intervals, and are never clipped to [0,1]. NaN SDs are annotated as unavailable. ``kind='box'`` requires a replicate matrix and displays every finite outlier. Nonfinite cells cannot be drawn and their count is annotated; convergence flags annotate, never filter, replicates. The original inputs are not modified. Use the estimate/bootstrap arrays directly for numerical export. """ labels = tuple(coefficients) values = np.asarray(list(coefficients.values()), dtype=float) if not labels or not all(isinstance(q, str) for q in labels) or not np.isfinite(values).all(): raise ValueError("coefficients must be a nonempty mapping of labels to finite values.") if kind not in ("point", "box"): raise ValueError("kind must be 'point' or 'box'.") sd = None if standard_deviations is not None: if set(standard_deviations) != set(labels): raise ValueError("standard_deviations must use exactly the coefficient labels.") sd = np.asarray([standard_deviations[q] for q in labels], dtype=float) if np.isinf(sd).any() or (sd < 0).any(): raise ValueError("Standard deviations must be nonnegative, finite or NaN.") samples = None note = "" if replicates is not None: samples = np.asarray(replicates, dtype=float) if samples.ndim != 2 or samples.shape[1] != len(labels): raise ValueError("replicates must be a matrix with one column per coefficient.") note = _replicate_note(samples, converged, displayed=kind == "box") elif converged is not None: raise ValueError("converged requires replicates.") if kind == "box" and samples is None: raise ValueError("Boxplots require replicates.") if kind == "box" and sd is not None: raise ValueError( "Use SD bars with kind='point'; boxplots describe the replicates directly." ) figure, axes = _axes(ax, figsize) positions = np.arange(len(labels)) if kind == "box": assert samples is not None for index in positions: finite = samples[:, index][np.isfinite(samples[:, index])] if len(finite): axes.boxplot( [finite], positions=[index], widths=0.5, showfliers=True, manage_ticks=False, flierprops={"marker": ".", "markersize": 5}, ) else: axes.text( float(index), 0.05, "no finite replicates", rotation=90, ha="center", transform=axes.get_xaxis_transform(), fontsize=8, ) axes.scatter(positions, values, color=color, zorder=3, label="Estimate") if sd is not None: available = np.isfinite(sd) axes.errorbar( positions[available], values[available], yerr=sd[available], fmt="none", ecolor=color, capsize=3, label="±1 SD", ) if not available.all(): note += f" SD unavailable for {int((~available).sum())} coefficients." axes.set_xticks(positions, labels, rotation=45, ha="right") axes.set(ylabel=ylabel) _title_with_note(axes, title, note) axes.legend() figure.tight_layout() return figure
[docs] def plot_bootstrap_distribution( replicates: ArrayLike, *, observed: float | None = None, converged: ArrayLike | None = None, bins: int | str = "auto", kind: Literal["histogram", "survival"] = "histogram", ax: Axes | None = None, figsize: tuple[float, float] = (8, 5), color: str = "#4A90D9", title: str = "Bootstrap distribution", xlabel: str = "Statistic", ) -> Figure: """Plot finite bootstrap statistics with an optional observed reference line. No outlier trimming or convergence selection is performed. Nonfinite values and nonconverged fits are counted visibly. An empty or wholly nonfinite array produces a labelled empty plot. This plot does not compute a p-value or repair failed refits; inspect the underlying bootstrap result. ``kind='survival'`` draws the empirical fraction of finite values >= each unique value, preserving ties. It is descriptive, conditional on finite values when failures occur, and is not an add-one Monte Carlo p-value. """ values = np.asarray(replicates, dtype=float) if values.ndim != 1: raise ValueError("replicates must be a one-dimensional array.") if kind not in ("histogram", "survival"): raise ValueError("kind must be 'histogram' or 'survival'.") if observed is not None and not np.isfinite(observed): raise ValueError("observed must be finite when supplied.") if isinstance(bins, bool) or (isinstance(bins, int) and bins < 1): raise ValueError("bins must be positive or a matplotlib histogram strategy.") note = _replicate_note(values, converged) figure, axes = _axes(ax, figsize) finite = values[np.isfinite(values)] if len(finite): if kind == "histogram": axes.hist(finite, bins=bins, color=color, alpha=0.8) else: x, counts = np.unique(finite, return_counts=True) tail = (len(finite) - np.cumsum(counts) + counts) / len(finite) axes.plot(x, tail, drawstyle="steps-pre", marker="o", color=color) else: axes.text(0.5, 0.5, "No finite replicates", ha="center", transform=axes.transAxes) if observed is not None: axes.axvline(observed, color="#E74C3C", linestyle="--", label="Observed") axes.legend() ylabel = "Replicate count" if kind == "histogram" else "Fraction of finite replicates ≥ x" axes.set(xlabel=xlabel, ylabel=ylabel) _title_with_note(axes, title, note) figure.tight_layout() return figure