Source code for figures.invariance
r"""The score-based invariance figure: the empirical fluctuation process against its null band.
Built from an `invariance` run's stored process, the figure plots the squared norm of the
standardised fluctuation process $\lVert B(t) \rVert^2$ for the strongest-drifting focal block,
against the axis (age at diagnosis or diagnosis year), with the pointwise envelope of the
simulated Brownian-bridge null shaded beneath. Under stability the observed curve would sit
inside the null band; a curve that climbs far above it is a class profile drifting along the
axis, and the peak marks the estimated break. The y-axis is logarithmic because the observed
excursion dwarfs the null band by orders of magnitude at this sample size.
"""
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
_NICE_AXIS = {"age_at_diagnosis": "age at diagnosis (years)", "era": "diagnosis year"}
[docs]
def invariance_process_figure(process: pd.DataFrame, meta: dict) -> Figure:
"""Build the fluctuation-process figure from an `invariance` run's process table.
Parameters
----------
process : pandas.DataFrame
The stored process, with ``position``, ``observed``, ``null_q50`` and ``null_q95``
columns (one row per grid point).
meta : dict
The run's manifest metrics, carrying ``axis`` and ``top_block`` for the labels.
Returns
-------
matplotlib.figure.Figure
The one-panel figure.
"""
axis = str(meta.get("axis", ""))
block = str(meta.get("top_block", "focal block"))
positions = process["position"].to_numpy()
observed = process["observed"].to_numpy()
q95 = process["null_q95"].to_numpy()
q50 = process["null_q50"].to_numpy()
# A log axis cannot show the endpoints, where the bridge is pinned to zero; floor the curves
# at a small positive value so the shape reads without dropping to minus infinity.
floor = max(1e-3, float(q50[q50 > 0].min()) / 10.0) if np.any(q50 > 0) else 1e-3
obs_plot = np.clip(observed, floor, None)
break_position = float(positions[int(np.argmax(observed))])
with style.house_style():
fig, ax = plt.subplots(figsize=(7.2, 4.4))
ax.fill_between(
positions,
floor,
np.clip(q95, floor, None),
color=style.REFERENCE_COLOUR,
alpha=0.18,
linewidth=0,
label="bridge null, 95th percentile",
)
ax.plot(positions, np.clip(q50, floor, None), color=style.REFERENCE_COLOUR, lw=0.8, ls=":")
ax.plot(positions, obs_plot, color=style.PALETTE[3], lw=1.8, zorder=3, label="observed")
ax.axvline(break_position, color=style.PALETTE[0], ls="--", lw=1.0, zorder=2)
ax.text(
break_position,
0.02,
f" break {break_position:.1f}",
transform=ax.get_xaxis_transform(),
ha="left",
va="bottom",
fontsize=7,
color=style.PALETTE[0],
)
ax.set_yscale("log")
ax.set_ylim(bottom=floor)
ax.set_xlabel(_NICE_AXIS.get(axis, axis))
ax.set_ylabel(r"$\Vert B(t) \Vert^2$")
ax.legend(frameon=False, fontsize=8, loc="upper left")
style.panel_title(ax, "A", f"{block}: fluctuation process vs bridge null")
ax.margins(x=0.02)
return fig