Source code for pyfli.bayes_utils.param_combinations

from collections.abc import Callable
from typing import ClassVar

import numpy as np
from tqdm.auto import tqdm

from pyfli.reconstruction import DetailedRecon, ParamToDecay


[docs] class ParamSelector: """ Evaluate a stack of posterior-sample parameter combinations against measured decay data, and select the best-fitting combination per pixel by a chosen goodness-of-fit metric. Parameters ---------- freq_acq : float Acquisition frequency (e.g. 80 MHz), passed straight through to compute_detailed_results' freq_acq argument. irf, decay : np.ndarray Passed through to compute_detailed_results unchanged for every sample and for the final best-combination re-fit. bool_mask : np.ndarray | None Optional (H, W) boolean mask -- e.g. the same ROI mask passed to BiPipeline.run_inference. Not used during sample evaluation/selection itself (compute_detailed_results' own pixel_health_map is derived from decay, not this mask, so a pixel with background counts outside the real ROI can otherwise look "healthy" even though its params are meaningless placeholders). Stored as the default for compute_best_model_fit_result's own bool_mask argument, which uses it to NaN-out excluded pixels in the final result's output maps. model_type : str "bi-exponential" or "mono-exponential". backend : str Which implementation reconstructs each sample's fit/residual/ goodness-of-fit maps: ``"compute_detailed_results"`` (default, the rescale-to-measured-totals implementation in :mod:`pyfli.reconstruction.detailed_results`) or ``"reconstructor"`` (:class:`pyfli.reconstruction.ParamToDecay`'s vectorized path). Both return the same ``{"name", "method", "results": {"maps", "error_maps", "TR_maps"}}`` shape, so switching backends doesn't change any downstream code. """ #: Registry of {metric: (stack_key, "min" | "max")} -- whether the metric #: should be minimized (chi2/reduced_chi2/RMSE) or maximized (R2) to find #: the best-fitting sample per pixel. METRICS: ClassVar[dict[str, tuple[str, str]]] = { "chi2": ("chi2_stack", "min"), "reduced_chi2": ("reduced_chi2_stack", "min"), "RMSE": ("rmse_stack", "min"), "R2": ("r2_stack", "max"), } #: Valid values for the ``backend`` constructor argument. BACKENDS: tuple[str, ...] = ("compute_detailed_results", "reconstructor") #: Registry of {reducer: numpy function} for #: compute_aggregate_model_fit_result's non-"best" methods -- collapses #: the NUM_SAMPLES axis of each output_combination array to a single #: per-pixel value. Add an entry here to support another reduction #: (e.g. "mode") without touching the method itself. REDUCERS: ClassVar[dict[str, Callable]] = { "mean": np.mean, "median": np.median, } def __init__( self, freq_acq, irf, decay, model_type="bi-exponential", backend="compute_detailed_results", bool_mask=None, ): if model_type not in ("bi-exponential", "mono-exponential"): raise ValueError(f"Unknown model_type: {model_type!r}") if backend not in self.BACKENDS: raise ValueError( f"Unknown backend: {backend!r}; expected one of {self.BACKENDS}" ) self.freq_acq = freq_acq self.irf = irf self.decay = decay self.model_type = model_type self.backend = backend self.bool_mask = bool_mask # Lazily built on first use of the "reconstructor" backend. self._reconstructor = None # Lazily built on first use of the "compute_detailed_results" backend. self._detailed_reconstructor = None @staticmethod def _apply_bool_mask(result, bool_mask): """ NaN-out every pixel where ``bool_mask`` is False, in every (H, W) or (H, W, ...) array under ``result["results"]["maps"]``, ``["TR_maps"]``, and ``["error_maps"]`` -- so excluded pixels (e.g. outside the real ROI) are unambiguous downstream instead of looking like ordinary (if poor) fits. """ bool_mask = np.asarray(bool_mask, dtype=bool) results = result["results"] for group_key in ("maps", "TR_maps"): group = results.get(group_key) if not group: continue for key, arr in group.items(): if not isinstance(arr, np.ndarray) or arr.shape[:2] != bool_mask.shape: continue arr = arr.astype(np.float32, copy=True) arr[~bool_mask] = np.nan group[key] = arr error_maps = results.get("error_maps") if ( isinstance(error_maps, np.ndarray) and error_maps.shape[:2] == bool_mask.shape ): error_maps = error_maps.astype(np.float32, copy=True) error_maps[~bool_mask] = np.nan results["error_maps"] = error_maps def _run_compute_detailed_results(self, params, data_name, log_summary=True): """Reconstruct one parameter combination's fit/residual/goodness-of-fit maps via the configured ``backend``, using the shared freq_acq/irf/decay/model_type stored on this instance. ``log_summary`` is forwarded to the ``"compute_detailed_results"`` backend to suppress its per-call goodness-of-fit summary log line; set False when calling in a loop (e.g. evaluate_all_samples) so the progress bar isn't drowned out. The ``"reconstructor"`` backend emits no such line and ignores this flag.""" if self.backend == "reconstructor": return self._run_via_reconstructor(params, data_name) return self._run_via_compute_detailed_results( params, data_name, log_summary=log_summary ) def _run_via_compute_detailed_results(self, params, data_name, log_summary=True): if self._detailed_reconstructor is None: self._detailed_reconstructor = DetailedRecon( self.freq_acq, self.irf, binned_decay=self.decay ) if self.model_type == "bi-exponential": cdr_params = { "tau1_map": params["tau1"], "tau2_map": params["tau2"], "alpha1_map": params["alpha1"], } else: cdr_params = {"tau_map": params["tau"]} return self._detailed_reconstructor.reconstruct( cdr_params, self.model_type, data_name=data_name, log_summary=log_summary ) def _run_via_reconstructor(self, params, data_name): if self._reconstructor is None: self._reconstructor = ParamToDecay( self.model_type, self.freq_acq, irf=self.irf ) if self.model_type == "bi-exponential": recon_params = { "tau1_map": params["tau1"], "tau2_map": params["tau2"], "alpha1_map": params["alpha1"], } else: recon_params = {"tau_map": params["tau"]} out = self._reconstructor.reconstruct_vectorized( recon_params, decay=self.decay, verbose=False ) fit_stats = out["fit_stats_maps"] # Wrapped into compute_detailed_results' own return shape so callers # (evaluate_all_samples, compute_best_model_fit_result) don't need to # know which backend actually ran. return { "name": data_name, "method": "ParamToDecay", "results": { "maps": { "R2_map": fit_stats["R2_map"], "chi2_map": fit_stats["chi2_map"], "reduced_chi2_map": fit_stats["reduced_chi2_map"], "rmse_map": fit_stats["rmse_map"], }, "error_maps": None, "TR_maps": out["TR_maps"], }, }
[docs] def evaluate_all_samples( self, output_combination, keep_per_sample_results=False, progress=True ): """ Run compute_detailed_results once per posterior sample. Parameters ---------- output_combination : dict[str, np.ndarray] e.g. ``{'tau1': (H,W,NUM_SAMPLES), 'tau2': (H,W,NUM_SAMPLES), 'alpha1': (H,W,NUM_SAMPLES)}`` for bi-exponential, or ``{'tau': (H,W,NUM_SAMPLES)}`` for mono-exponential. keep_per_sample_results : bool If True, also returns the full compute_detailed_results() dict for every sample (memory-heavy for large images/NUM_SAMPLES). Default False -- only the scalar metric stacks are kept. progress : bool Show a tqdm progress bar over the NUM_SAMPLES loop. Default True; pass False for quiet use (e.g. a 1x1-pixel crop). The per-sample backend log line is suppressed regardless, so it never competes with the bar. Returns ------- dict Dictionary with keys: - ``chi2_stack``, ``reduced_chi2_stack``, ``rmse_stack``, ``r2_stack`` : (H, W, NUM_SAMPLES) - ``per_sample_results`` : list[dict] or None """ for key, arr in output_combination.items(): if not isinstance(arr, np.ndarray): raise TypeError( f"output_combination['{key}'] is {type(arr).__name__} ({arr!r}), " "not a numpy array. Did a placeholder/example assignment " "(e.g. 'output_combination = {...}') accidentally overwrite " "your real output_combination in a later cell?" ) if arr.ndim != 3: raise ValueError( f"output_combination['{key}'] has shape {arr.shape} (ndim={arr.ndim}); " "expected 3D (H, W, NUM_SAMPLES)." ) first_key = next(iter(output_combination)) H, W, num_samples = output_combination[first_key].shape chi2_stack = np.zeros((H, W, num_samples), dtype=np.float32) reduced_chi2_stack = np.zeros((H, W, num_samples), dtype=np.float32) rmse_stack = np.zeros((H, W, num_samples), dtype=np.float32) r2_stack = np.zeros((H, W, num_samples), dtype=np.float32) per_sample_results = [] if keep_per_sample_results else None with tqdm( total=num_samples, desc="Evaluating posterior samples", disable=not progress, leave=False, ) as pbar: for s in range(num_samples): if self.model_type == "bi-exponential": params_s = { "tau1": output_combination["tau1"][..., s], "tau2": output_combination["tau2"][..., s], "alpha1": output_combination["alpha1"][..., s], } else: params_s = {"tau": output_combination["tau"][..., s]} result_s = self._run_compute_detailed_results( params_s, data_name=f"BI_MODEL_{self.model_type}_sample{s}", log_summary=False, ) maps = result_s["results"]["maps"] chi2_stack[..., s] = maps["chi2_map"] reduced_chi2_stack[..., s] = maps["reduced_chi2_map"] r2_stack[..., s] = maps["R2_map"] rmse_stack[..., s] = maps["rmse_map"] if keep_per_sample_results: per_sample_results.append(result_s) pbar.update(1) return { "chi2_stack": chi2_stack, "reduced_chi2_stack": reduced_chi2_stack, "rmse_stack": rmse_stack, "r2_stack": r2_stack, "per_sample_results": per_sample_results, }
[docs] def select_best_combination(self, output_combination, stacks, metric="RMSE"): """ For each pixel independently, pick the sample index that optimizes the chosen metric (minimizes chi2/reduced_chi2/RMSE, maximizes R2), and build the corresponding best-per-pixel parameter maps. Parameters ---------- output_combination : dict[str, np.ndarray] Same dict passed to evaluate_all_samples (each array (H, W, NUM_SAMPLES)). stacks : dict Output of evaluate_all_samples. metric : str One of "chi2", "reduced_chi2", "RMSE", "R2". Returns ------- dict Dictionary with keys: - ``best_params`` : dict[str, np.ndarray] -- best (H, W) map per key in output_combination (e.g. tau1/tau2/alpha1) - ``best_sample_idx`` : (H, W) int array -- which sample index won per pixel - ``best_score`` : (H, W) float array -- the winning metric value per pixel """ if metric not in self.METRICS: raise ValueError( f"Unknown metric: {metric!r}; expected one of {list(self.METRICS)}" ) stack_key, sense = self.METRICS[metric] score_stack = stacks[stack_key] # (H, W, NUM_SAMPLES) best_idx = (np.argmax if sense == "max" else np.argmin)(score_stack, axis=-1) best_idx_expanded = best_idx[..., np.newaxis] # (H, W, 1) best_params = { key: np.take_along_axis(arr, best_idx_expanded, axis=-1).squeeze(-1) for key, arr in output_combination.items() } best_score = np.take_along_axis( score_stack, best_idx_expanded, axis=-1 ).squeeze(-1) return { "best_params": best_params, "best_sample_idx": best_idx, "best_score": best_score, }
[docs] def compute_best_model_fit_result( self, output_combination, metric="RMSE", data_name="BI_MODEL_bi_best", bool_mask=None, stacks=None, ): """ Full pipeline: evaluate every sample, pick the best per-pixel combination by ``metric``, then re-run compute_detailed_results once more on that best combination. Parameters ---------- output_combination : dict[str, np.ndarray] Same dict passed to evaluate_all_samples. metric : str One of "chi2", "reduced_chi2", "RMSE", "R2". data_name : str Dataset name recorded in the returned result dict. stacks : dict | None Precomputed :meth:`evaluate_all_samples` output for this exact ``output_combination``. When given, the per-sample evaluation loop (NUM_SAMPLES reconstructions) is skipped and these stacks are used directly -- so a caller that already ran ``evaluate_all_samples`` (e.g. to inspect the raw stacks or try several metrics) doesn't pay for it twice. When None (default) it is computed internally. The stacks must come from the same ``output_combination``; passing mismatched stacks yields wrong selections. bool_mask : np.ndarray | None Optional (H, W) boolean mask; defaults to ``self.bool_mask`` (set at construction) when not given. Pixels where the mask is False are NaN'd out in every array under result['results']['maps'], ['TR_maps'], and ['error_maps'] -- so excluded pixels (e.g. outside the real ROI) can't be mistaken for ordinary fits downstream, since compute_detailed_results' own pixel_health_map is derived from decay, not this mask. No masking is applied if both this argument and ``self.bool_mask`` are None. Notes ----- The return value is exactly the dict compute_detailed_results itself returns -- ``{"name", "method", "results": {"maps", "error_maps", "TR_maps"}}`` -- so it plugs directly into the rest of the workflow (e.g. ``result['results']['maps'].keys()``, ``result['results']['TR_maps']['fit_map']``, ``saver.save_npy(...)``, DataViewer, etc.) exactly like any other compute_detailed_results output. Two extra maps are folded into ``result['results']['maps']``: - ``best_sample_idx_map`` : (H, W) -- which posterior sample (0..NUM_SAMPLES-1) won at each pixel - ``<metric>_selection_map`` : (H, W) -- the winning metric value at each pixel (e.g. ``reduced_chi2_selection_map``) Per-sample diagnostics (the full metric stacks across all samples, and the chosen metric name) are attached as an additional top-level key, ``sample_selection``, without disturbing the primary ``"name"``/``"method"``/``"results"`` structure. Returns ------- dict Same shape as compute_detailed_results()'s return value, plus a top-level ``sample_selection`` key holding the raw per-sample stacks and best_params used to produce the final maps. """ if stacks is None: stacks = self.evaluate_all_samples(output_combination) selection = self.select_best_combination( output_combination, stacks, metric=metric ) best_params = selection["best_params"] bi_model_best = self._run_compute_detailed_results( best_params, data_name=data_name ) # Fold the selection diagnostics directly into the same 'maps' dict that # every other compute_detailed_results() call produces, so they show up # alongside tau1_map/alpha1_map/chi2_map/etc. and can be visualized or # saved the same way (e.g. via DataViewer, saver.save_npy). bi_model_best["results"]["maps"]["best_sample_idx_map"] = selection[ "best_sample_idx" ].astype(np.float32) bi_model_best["results"]["maps"][f"{metric}_selection_map"] = selection[ "best_score" ].astype(np.float32) effective_mask = self.bool_mask if bool_mask is None else bool_mask if effective_mask is not None: self._apply_bool_mask(bi_model_best, effective_mask) bi_model_best["sample_selection"] = { "metric": metric, "best_params": best_params, "best_sample_idx": selection["best_sample_idx"], "best_score": selection["best_score"], "stacks": stacks, } print( f"Best-combination selection complete (metric={metric!r}). " f"Mean winning {metric}: {np.nanmean(selection['best_score']):.4f}" ) return bi_model_best
[docs] def compute_aggregate_model_fit_result( self, output_combination, method="best", metric="RMSE", data_name="BI_MODEL_bi_aggregate", bool_mask=None, stacks=None, ): """ Collapse the stack of posterior-sample parameter combinations down to a single per-pixel parameter map via ``method``, then run the configured backend once on that combination. Parameters ---------- output_combination : dict[str, np.ndarray] Same dict passed to evaluate_all_samples/compute_best_model_fit_result (each array (H, W, NUM_SAMPLES)). method : str How to reduce the NUM_SAMPLES axis to a single per-pixel value: - "best" : per-pixel sample that optimizes ``metric`` (delegates to compute_best_model_fit_result; see that method for the extra 'best_sample_idx_map'/'sample_selection' diagnostics only this option adds). - any key in :attr:`REDUCERS` (default "mean", "median") : per-pixel reduction across samples, e.g. the per-pixel mean or median tau/alpha1. metric : str Only used when method="best"; one of "chi2", "reduced_chi2", "RMSE", "R2" (see :attr:`METRICS`). data_name : str Dataset name recorded in the returned result dict. bool_mask : np.ndarray | None Optional (H, W) boolean mask; defaults to ``self.bool_mask`` (set at construction) when not given. Forwarded to compute_best_model_fit_result for method="best"; for the reducer methods, applied the same way directly here -- see compute_best_model_fit_result's bool_mask parameter for what it does. stacks : dict | None Only used when method="best"; forwarded to compute_best_model_fit_result to skip the per-sample evaluation loop when the caller already has its output. Ignored by the reducer methods, which never score individual samples. Returns ------- dict : same shape as compute_detailed_results()'s return value (see compute_best_model_fit_result for the exact structure notes). For method="best" this is exactly compute_best_model_fit_result's return value, including its extra 'sample_selection' key; the other methods have no per-sample selection to report, so that key is simply absent. """ if method == "best": return self.compute_best_model_fit_result( output_combination, metric=metric, data_name=data_name, bool_mask=bool_mask, stacks=stacks, ) reducer = self.REDUCERS.get(method) if reducer is None: raise ValueError( f"Unknown method: {method!r}; expected 'best' or one of " f"{list(self.REDUCERS)}" ) agg_params = { key: reducer(arr, axis=-1).astype(np.float32) for key, arr in output_combination.items() } result = self._run_compute_detailed_results(agg_params, data_name=data_name) effective_mask = self.bool_mask if bool_mask is None else bool_mask if effective_mask is not None: self._apply_bool_mask(result, effective_mask) print(f"{method.capitalize()}-combination result computed ({data_name!r}).") return result