Source code for figures.roughness

"""The trajectory-roughness figure: step size against noise, and directional movement.

Built from the ``trajectory`` runs of both axes, the figure reads each class's path in two
ways. Panel A compares the mean step between adjacent strata with the step that independent
sampling of a class of that size would produce, so a jagged path can be read as sampling noise
rather than movement. Panel B compares each class's net young-to-old displacement with an
ordering-shuffle null, so a large displacement tied to the axis reads as directional drift
rather than scatter. The directional test is a pilot on the observed centroids; the
confirmatory test is the continuous-trend regression against the refit permutation null.
"""

from __future__ import annotations

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.figure import Figure

from figures import style


def _class_names(frame: pd.DataFrame) -> list[str]:
    """Return the class names in reference-class order."""
    ordered = frame.sort_values("ref_class")
    return [str(name) for name in ordered["class_name"]]


[docs] def roughness_figure( roughness_by_axis: dict[str, pd.DataFrame], directional_by_axis: dict[str, pd.DataFrame] ) -> Figure: """Build the trajectory-roughness figure from both axes' ``trajectory`` runs. Parameters ---------- roughness_by_axis : dict of str to pandas.DataFrame Per axis label, the ``roughness_<axis>`` table (``ref_class``, ``class_name``, ``step``, ``sampling_noise``). directional_by_axis : dict of str to pandas.DataFrame Per axis label, the ``directional_<axis>`` table (``ref_class``, ``class_name``, ``net``, ``null95``, ``significant``). Returns ------- matplotlib.figure.Figure A two-panel figure: step magnitude versus sampling noise, and net displacement against the ordering-shuffle null. Raises ------ ValueError When the two mappings do not share their axis labels, or a table is empty. """ axes_labels = list(roughness_by_axis) if list(directional_by_axis) != axes_labels or not axes_labels: raise ValueError("roughness and directional mappings must share the same axis labels") first = next(iter(roughness_by_axis.values())) names = _class_names(first) n_classes = len(names) positions = np.arange(n_classes) width = 0.8 / max(len(axes_labels), 1) def ordered(frame: pd.DataFrame, column: str) -> np.ndarray: return frame.sort_values("ref_class")[column].to_numpy(dtype=float) with style.house_style(): fig, (ax_step, ax_net) = plt.subplots(1, 2, figsize=(9.6, 4.0)) for k, axis in enumerate(axes_labels): offset = (k - (len(axes_labels) - 1) / 2) * width rough = roughness_by_axis[axis] ax_step.bar( positions + offset, ordered(rough, "step"), width, color=[style.PALETTE[4 + k]] * n_classes, label=f"observed, {axis}", ) ax_step.scatter( positions + offset, ordered(rough, "sampling_noise"), marker="D", s=28, color="black", zorder=5, label="sampling-noise expectation" if k == 0 else None, ) ax_step.set_xticks(positions) ax_step.set_xticklabels(names, rotation=30, ha="right", fontsize=7.5) ax_step.set_ylabel("Mean centroid step between\nadjacent strata (SD units)") ax_step.legend(fontsize=6.8, loc="upper left") style.panel_title(ax_step, "A", "Step magnitude versus sampling noise") top = max(ordered(directional_by_axis[axis], "net").max() for axis in axes_labels) for k, axis in enumerate(axes_labels): offset = (k - (len(axes_labels) - 1) / 2) * width direction = directional_by_axis[axis].sort_values("ref_class") nets = direction["net"].to_numpy(dtype=float) ax_net.bar( positions + offset, nets, width, color=[style.PALETTE[4 + k]] * n_classes, label=f"observed, {axis}", ) ax_net.scatter( positions + offset, direction["null95"].to_numpy(dtype=float), marker="_", s=190, color="black", linewidth=1.6, zorder=5, label="ordering-shuffle 95th pct" if k == 0 else None, ) for pos, net, significant in zip( positions + offset, nets, direction["significant"].to_numpy(dtype=bool), strict=False, ): if significant: ax_net.text(pos, net + 0.06 * top, "*", ha="center", va="bottom", fontsize=11) ax_net.set_ylim(0, top * 1.16) ax_net.set_xticks(positions) ax_net.set_xticklabels(names, rotation=30, ha="right", fontsize=7.5) ax_net.set_ylabel("Net young-to-old displacement\n(SD units)") ax_net.legend(fontsize=6.8, loc="upper center") ax_net.text( 0.02, 0.02, "* p < 0.05, ordering-shuffle on observed centroids (pilot);\n" "confirmatory: refit permutation null and continuous trend", transform=ax_net.transAxes, ha="left", va="bottom", fontsize=6.0, color="#777", style="italic", ) style.panel_title(ax_net, "B", "Net displacement against the ordering-shuffle null") fig.tight_layout() return fig