Source code for pyfli.data_vnp.model_selector

"""Per-pixel mono- vs bi-exponential model selection (nested-model F-test + AICc)."""

import os
from typing import Any, ClassVar

import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.colors import ListedColormap, TwoSlopeNorm
from scipy import stats

from pyfli import logging


[docs] class ModelSelector: """ Per-pixel mono- versus bi-exponential model selection using a nested-model F-test (BH-FDR corrected) combined with an AICc criterion. Pixels are assigned to one of four classes: mono sufficient, bi justified, bi significant but with a parameter at a fit limit, and bi fit worse than mono (optimiser failure). Class ``0`` marks pixels outside the ROI or invalid. Parameters ---------- b_bool_mask : np.ndarray ROI mask; only pixels where it is truthy are analysed. fdr_q : float False-discovery rate for the F-test. daicc_min : float Minimum ΔAICc additionally required for a pixel to be classified as bi. tau_bound : float Upper τ fit bound in ns; τ2 near it (or τ1 ≈ 0) flags a parameter at a limit. k_mono : int Number of fitted parameters in the mono-exponential model (A, τ, v, h). k_bi : int Number of fitted parameters in the bi-exponential model (A, α, τ1, τ2, v, h). """ DEFAULT_FITS: ClassVar[dict[str, str]] = { "MLE": "CPU_MLE_poisson", "NLSF": "CPU_NLSF_least_squares", } CLASS_NAMES: ClassVar[list[str]] = [ "Mono sufficient", "Bi justified", "Bi, parameter at limit", "Bi fit worse than mono", ] CLASS_COLORS: ClassVar[list[str]] = ["#56B4E9", "#D55E00", "#E69F00", "#7f7f7f"] RC: ClassVar[dict[str, Any]] = { "font.size": 7, "axes.titlesize": 8, "axes.labelsize": 7, "xtick.labelsize": 6, "ytick.labelsize": 6, "axes.linewidth": 0.6, "axes.grid": False, "pdf.fonttype": 42, "svg.fonttype": "none", "mathtext.default": "regular", } def __init__( self, b_bool_mask: np.ndarray, fdr_q: float = 0.05, daicc_min: float = 4.0, tau_bound: float = 12.5, k_mono: int = 4, k_bi: int = 6, ) -> None: self.roi = np.asarray(b_bool_mask).astype(bool) self.fdr_q = fdr_q self.daicc_min = daicc_min self.tau_bound = tau_bound self.k_mono = k_mono self.k_bi = k_bi self.results: dict[str, dict[str, Any]] = {} self._rows: list[dict[str, Any]] = []
[docs] @staticmethod def bh_fdr(p: np.ndarray) -> np.ndarray: """ Benjamini–Hochberg adjusted p-values (q-values) for a 1-D array. Parameters ---------- p : np.ndarray Raw p-values. Returns ------- np.ndarray q-values in the original order, clipped to ``[0, 1]``. """ p = np.asarray(p, float) order = np.argsort(p) ranked = p[order] * p.size / np.arange(1, p.size + 1) q = np.minimum.accumulate(ranked[::-1])[::-1] out = np.empty_like(q) out[order] = np.clip(q, 0, 1) return out
@staticmethod def _load_maps(path: str) -> dict[str, Any]: return np.load(path, allow_pickle=True).item()["results"]["maps"] def _aicc(self, D: np.ndarray, k: int, n_pts: int) -> np.ndarray: return D + 2 * k + 2 * k * (k + 1) / (n_pts - k - 1)
[docs] def compare( self, label: str, maps_mono: dict[str, Any], maps_bi: dict[str, Any] ) -> dict[str, Any]: """ Run model selection for one pair of mono/bi fit result maps. Parameters ---------- label : str Name of the fit (e.g. ``"MLE"``); used as the key in :attr:`results`. maps_mono : dict Mono-exponential result maps; must contain ``chi2_map``. maps_bi : dict Bi-exponential result maps; must contain ``chi2_map``, ``reduced_chi2_map``, ``tau1_map`` and ``tau2_map``. Returns ------- dict ``class_map``, ``bi_justified``, ``F_map``, ``p_map``, ``q_map``, ``dAICc_map``, ``dDev_map`` and ``n_points``. """ roi = self.roi D_mono = np.asarray(maps_mono["chi2_map"], float) D_bi = np.asarray(maps_bi["chi2_map"], float) red_bi = np.asarray(maps_bi["reduced_chi2_map"], float) valid = ( roi & np.isfinite(D_mono) & np.isfinite(D_bi) & (D_bi > 0) & (red_bi > 0) ) n_pts = int(np.round(np.median(D_bi[valid] / red_bi[valid]))) + self.k_bi dof_bi = n_pts - self.k_bi dk = self.k_bi - self.k_mono dD = D_mono - D_bi F = np.full(roi.shape, np.nan) F[valid] = (dD[valid] / dk) / (D_bi[valid] / dof_bi) p = np.full(roi.shape, np.nan) p[valid] = stats.f.sf(F[valid], dk, dof_bi) q = np.full(roi.shape, np.nan) q[valid] = self.bh_fdr(p[valid]) dAICc = np.where( valid, self._aicc(D_mono, self.k_mono, n_pts) - self._aicc(D_bi, self.k_bi, n_pts), np.nan, ) tau1 = np.asarray(maps_bi["tau1_map"], float) tau2 = np.asarray(maps_bi["tau2_map"], float) at_limit = (tau2 >= 0.99 * self.tau_bound) | (tau1 <= 0.01) with np.errstate(invalid="ignore"): bi_sig = valid & (dD > 0) & (q < self.fdr_q) & (dAICc > self.daicc_min) cls = np.zeros(roi.shape, int) cls[valid] = 1 cls[bi_sig & ~at_limit] = 2 cls[bi_sig & at_limit] = 3 cls[valid & (dD < 0)] = 4 result = dict( class_map=cls, bi_justified=(cls == 2), F_map=F, p_map=p, q_map=q, dAICc_map=dAICc, dDev_map=np.where(valid, dD, np.nan), n_points=n_pts, ) self.results[label] = result n_valid = valid.sum() self._rows = [r for r in self._rows if r["fit"] != label] with np.errstate(invalid="ignore"): self._rows.append( { "fit": label, "pixels": int(n_valid), "gates": n_pts, **{ name: f"{np.sum(cls == i + 1) / n_valid:.1%}" for i, name in enumerate(self.CLASS_NAMES) }, "mean ΔDev (null = Δk)": round(float(np.nanmean(dD[valid])), 2), "F-test only (q<FDR)": f"{np.sum(valid & (dD > 0) & (q < self.fdr_q)) / n_valid:.1%}", "AICc only (ΔAICc>min)": f"{np.sum(valid & (dAICc > self.daicc_min)) / n_valid:.1%}", } ) return result
[docs] def compare_from_dir( self, save_dir: str, fits: dict[str, str] | None = None ) -> dict[str, dict[str, Any]]: """ Load saved mono/bi fit results from ``save_dir`` and run :meth:`compare` on each. Files are expected as ``{prefix}_mono-exponential.npy`` and ``{prefix}_bi-exponential.npy``. Fits missing either file are skipped. Parameters ---------- save_dir : str Directory containing the saved fit results (e.g. ``saver.save_dir``). fits : dict[str, str] | None Mapping of label to file prefix; defaults to :attr:`DEFAULT_FITS`. Returns ------- dict :attr:`results`, keyed by fit label. """ for label, prefix in (fits or self.DEFAULT_FITS).items(): f_mono = os.path.join(save_dir, f"{prefix}_mono-exponential.npy") f_bi = os.path.join(save_dir, f"{prefix}_bi-exponential.npy") if not (os.path.exists(f_mono) and os.path.exists(f_bi)): logging.info( f"[{label}] skipped — needs both {os.path.basename(f_mono)} " f"and {os.path.basename(f_bi)}" ) continue self.compare(label, self._load_maps(f_mono), self._load_maps(f_bi)) return self.results
[docs] def summary(self) -> pd.DataFrame: """ Per-fit summary of class fractions and test statistics. Returns ------- pd.DataFrame One row per compared fit, indexed by fit label. """ if not self._rows: return pd.DataFrame() return pd.DataFrame(self._rows).set_index("fit")
[docs] def agreement(self) -> float | None: """ Jaccard index of the 'Bi justified' masks when exactly two fits were compared. Returns ------- float | None Jaccard index, or ``None`` if the number of compared fits is not two. """ if len(self.results) != 2: return None a, b = (r["bi_justified"] for r in self.results.values()) return float(np.sum(a & b) / max(np.sum(a | b), 1))
[docs] def plot( self, saver: Any | None = None, name: str = "model_selection_mono_vs_bi" ) -> Any: """ Plot one row per fit: ΔAICc map, −log10 q map, class map and class fractions. Parameters ---------- saver : Any | None Optional object with a ``save_plot(name, fig=..., close=...)`` method (e.g. :class:`pyfli.io.data_saving.DataSaver`). name : str File name passed to ``saver.save_plot``. Returns ------- matplotlib.figure.Figure | None The figure, or ``None`` if nothing has been compared. """ if not self.results: logging.info("No model-selection results to plot; run compare first.") return None roi = self.roi _r, _c = np.where(roi.any(1))[0], np.where(roi.any(0))[0] crop = (slice(_r[0], _r[-1] + 1), slice(_c[0], _c[-1] + 1)) cls_cmap = ListedColormap(["white", *self.CLASS_COLORS]) with mpl.rc_context(self.RC): nR = len(self.results) fig, axes = plt.subplots( nR, 4, figsize=(11, 3.6 * nR), squeeze=False, gridspec_kw=dict(width_ratios=[1, 1, 1, 0.8], wspace=0.75, hspace=0.3), ) for r_i, (label, ms) in enumerate(self.results.items()): ax0, ax1, ax2, ax3 = axes[r_i] d = np.where(roi, ms["dAICc_map"], np.nan)[crop] lim = np.nanpercentile(np.abs(d), 98) cm = mpl.colormaps["RdBu_r"].copy() cm.set_bad("white") im0 = ax0.imshow( d, cmap=cm, norm=TwoSlopeNorm(0, -lim, lim), interpolation="none" ) fig.colorbar(im0, ax=ax0, fraction=0.046, pad=0.03).set_label( "ΔAICc (mono − bi)" ) ax0.set_title(f"{label}: ΔAICc (>0 favours bi)") lq = np.where(roi, -np.log10(np.clip(ms["q_map"], 1e-300, 1)), np.nan)[ crop ] cm = mpl.colormaps["magma"].copy() cm.set_bad("white") im1 = ax1.imshow( lq, cmap=cm, vmin=0, vmax=max(np.nanpercentile(lq, 99.5), 2 * -np.log10(self.fdr_q)), interpolation="none", ) cb1 = fig.colorbar(im1, ax=ax1, fraction=0.046, pad=0.03) cb1.set_label(r"$-\log_{10}$ q (F-test, BH-FDR)") cb1.ax.axhline(-np.log10(self.fdr_q), color="cyan", lw=1) ax1.set_title(f"{label}: F-test significance") ax2.imshow( ms["class_map"][crop], cmap=cls_cmap, vmin=-0.5, vmax=4.5, interpolation="none", ) ax2.set_title(f"{label}: model class") for ax in (ax0, ax1, ax2): ax.set_axis_off() n_valid = np.sum(ms["class_map"] > 0) fr = [ 100 * np.sum(ms["class_map"] == i + 1) / n_valid for i in range(len(self.CLASS_NAMES)) ] ypos = np.arange(len(self.CLASS_NAMES))[::-1] ax3.barh(ypos, fr, color=self.CLASS_COLORS, height=0.65) for y, v in zip(ypos, fr): ax3.text(v + 1, y, f"{v:.1f}%", va="center", fontsize=6.5) ax3.set_yticks(ypos, self.CLASS_NAMES) ax3.set_xlim(0, 100 * 1.15 if max(fr) > 85 else max(fr) * 1.25) ax3.set_xlabel("ROI pixels (%)") ax3.spines[["top", "right"]].set_visible(False) ax3.set_title(f"q < {self.fdr_q}, ΔAICc > {self.daicc_min:g}") if saver is not None: saver.save_plot(name, fig=fig, close=False) return fig