from collections.abc import Callable, Mapping, Sequence
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import keras
from bayesflow.networks import ScoringRuleNetwork, SummaryNetwork
from bayesflow.simulators import ModelComparisonSimulator, Simulator
from bayesflow.adapters import Adapter
from bayesflow.approximators import ModelComparisonApproximator
from bayesflow.scoring_rules import CategoricalScoringRule
from bayesflow.utils import (
find_scoring_rule,
find_summary_network,
filter_kwargs,
)
from bayesflow.diagnostics import plots as bf_plots
from bayesflow.diagnostics import metrics as bf_metrics
from .basic_workflow import BasicWorkflow
[docs]
class ModelComparisonWorkflow(BasicWorkflow):
"""
This class extends :class:`~bayesflow.workflows.BasicWorkflow` to support
amortized Bayesian model comparison with scoring rules.
Parameters
----------
simulator : Sequence[Simulator] or ModelComparisonSimulator, optional
Either a list of per-model :class:`~bayesflow.simulators.Simulator` instances,
which will be automatically wrapped in a
:class:`~bayesflow.simulators.ModelComparisonSimulator` with uniform model
priors, or a pre-built :class:`~bayesflow.simulators.ModelComparisonSimulator`.
adapter : Adapter, optional
Adapter for data processing. If not provided, a default adapter is built
that maps ``model_indices`` to ``inference_variables`` and handles any
``inference_conditions`` / ``summary_variables``.
subnet : keras.Layer or str, optional
The shared body of the internal :class:`~bayesflow.networks.ScoringRuleNetwork`,
on top of which one head per scoring rule is built (default: ``"mlp"``).
Accepts a Keras layer instance or any name recognised by
:func:`~bayesflow.utils.find_network` (e.g. ``"mlp"``).
scoring_rules : CategoricalScoringRule or Sequence or dict[str, CategoricalScoringRule] or str, optional
Scoring rule(s) used to train the classifier. Accepts, in increasing generality:
- a single :class:`~bayesflow.scoring_rules.CategoricalScoringRule` instance;
- a string name recognised by :func:`~bayesflow.utils.find_scoring_rule`;
- a list/tuple of scores (or names) to co-learn several scores simultaneously,
which are auto-named after their class (e.g. ``"cross_entropy_score"``);
- a mapping of explicit names to scores.
Multiple scores share one classifier body but get separate heads and are trained
jointly. Their outputs can then be kept per-rule or aggregated at estimation time
via the ``merge_scores`` argument of :meth:`estimate`. Defaults to ``"cross_entropy"``.
The chosen rule(s) determine the training dynamics. All scores produce estimates
for posterior model probabilities, log odds, and potentially log Bayes factors (BFs).
summary_network : SummaryNetwork or str, optional
Optional summary network for data compression (default: None).
initial_learning_rate : float, optional
Initial learning rate for the optimizer (default: 5e-4).
optimizer : keras.optimizers.Optimizer or type, optional
Optimizer instance or class. If None, a default schedule is built at
fit time (default: None).
checkpoint_filepath : str, optional
Directory for saving model checkpoints (default: None).
checkpoint_name : str, optional
Base name for checkpoint files (default: ``"model"``).
save_weights_only : bool, optional
Save only weights rather than the full model (default: False).
save_best_only : bool, optional
Save only the best checkpoint instead of the last (default: False).
inference_conditions : Sequence[str] or str, optional
Keys in the simulator output to use as direct classifier conditions.
When ``shared_simulator`` is provided, these keys are automatically
broadcast to the batch dimension before being passed to the network.
summary_variables : Sequence[str] or str, optional
Keys in the simulator output to compress via the summary network.
shared_simulator : Simulator or Callable, optional
A shared simulator that provides context variables to every per-model
simulator (e.g. sample size ``n``). When ``simulator`` is a list this
is forwarded to :class:`~bayesflow.simulators.ModelComparisonSimulator`.
use_mixed_batches : bool, optional
Whether each training batch mixes samples from different models
(default: ``True``). Forwarded to
:class:`~bayesflow.simulators.ModelComparisonSimulator` when ``simulator``
is a list.
model_names : Sequence[str], optional
Human-readable names for each model, used in diagnostic plots.
standardize : Sequence[str] or str or None, optional
Variables to standardize. Defaults to all available input variables.
``inference_variables`` (one-hot model indices) are never standardized.
**kwargs : dict, optional
Additional keyword arguments organised by context:
- ``subnet_kwargs`` : dict — used in :class:`~bayesflow.networks.ScoringRuleNetwork`
to construct ``subnet`` when it is given as a name.
- ``summary_kwargs`` : dict — passed to :func:`~bayesflow.utils.find_summary_network`.
- ``optimizer_kwargs`` : dict — passed to ``_init_optimizer``.
- ``simulator_kwargs`` : dict — passed to :class:`~bayesflow.simulators.ModelComparisonSimulator`.
- Other keys forwarded to :class:`~bayesflow.approximators.ModelComparisonApproximator`.
"""
def __init__(
self,
simulator: Sequence[Simulator] | ModelComparisonSimulator | None = None,
adapter: Adapter | None = None,
subnet: keras.Layer | str = "mlp",
summary_network: SummaryNetwork | str | None = None,
scoring_rules: CategoricalScoringRule
| Sequence[CategoricalScoringRule | str]
| dict[str, CategoricalScoringRule]
| str = "cross_entropy",
initial_learning_rate: float = 5e-4,
optimizer: keras.optimizers.Optimizer | type | None = None,
checkpoint_filepath: str | None = None,
checkpoint_name: str = "model",
save_weights_only: bool = False,
save_best_only: bool = False,
inference_conditions: Sequence[str] | str | None = None,
summary_variables: Sequence[str] | str | None = None,
shared_simulator: Simulator | Callable | None = None,
use_mixed_batches: bool = True,
model_names: Sequence[str] | None = None,
standardize: Sequence[str] | str | None = "all",
**kwargs,
):
if isinstance(simulator, Sequence):
simulator = ModelComparisonSimulator(
simulators=simulator,
shared_simulator=shared_simulator,
use_mixed_batches=use_mixed_batches,
**kwargs.get("simulator_kwargs", {}),
)
self.simulator = simulator
self.model_names = model_names
adapter = adapter or ModelComparisonWorkflow.default_adapter(
inference_conditions=inference_conditions, summary_variables=summary_variables
)
resolved_scoring_rules = ModelComparisonWorkflow._resolve_scoring_rules(scoring_rules)
self.approximator = ModelComparisonApproximator(
inference_network=ScoringRuleNetwork(
scoring_rules=resolved_scoring_rules,
subnet=subnet,
subnet_kwargs=kwargs.get("subnet_kwargs", {}),
),
summary_network=find_summary_network(summary_network, **kwargs.get("summary_kwargs", {})),
adapter=adapter,
standardize=standardize,
**filter_kwargs(kwargs, ModelComparisonApproximator.__init__),
)
self._init_optimizer(initial_learning_rate, optimizer, **kwargs.get("optimizer_kwargs", {}))
self._init_checkpointing(checkpoint_filepath, checkpoint_name, save_weights_only, save_best_only)
self.history = None
self._needs_compile = True
@staticmethod
def _resolve_scoring_rules(
scoring_rules: CategoricalScoringRule
| Sequence[CategoricalScoringRule | str]
| Mapping[str, CategoricalScoringRule | str]
| str
| None,
) -> dict[str, CategoricalScoringRule]:
"""Normalize the ``scoring_rules`` argument to a ``dict[str, CategoricalScoringRule]``.
Accepts a single rule (or its string name), a list/tuple of scores (or names), or a
mapping of names to scores. ``None`` defaults to a single ``CrossEntropyScore``.
Scores given without an explicit name (i.e. in a list) are keyed by a snake-cased
version of their class name, de-duplicated with a numeric suffix.
Raises
------
TypeError
If ``scoring_rules`` is of an unsupported type.
"""
if isinstance(scoring_rules, (str, CategoricalScoringRule)):
return {"scoring_rule": find_scoring_rule(scoring_rules)}
if isinstance(scoring_rules, Mapping):
resolved = {key: find_scoring_rule(rule) for key, rule in scoring_rules.items()}
elif isinstance(scoring_rules, Sequence):
resolved: dict[str, CategoricalScoringRule] = {}
counts: dict[str, int] = {}
for item in scoring_rules:
rule = find_scoring_rule(item)
base = re.sub(r"(?<!^)(?=[A-Z])", "_", type(rule).__name__).lower()
counts[base] = counts.get(base, 0) + 1
key = base if counts[base] == 1 else f"{base}_{counts[base]}"
resolved[key] = rule
else:
raise TypeError(
"`scoring_rules` must be a CategoricalScoringRule, a string name, a list of "
f"scores/names, or a dict of name -> rule, got {type(scoring_rules).__name__}."
)
return resolved
[docs]
@staticmethod
def default_adapter(
inference_conditions: Sequence[str] | str,
summary_variables: Sequence[str] | str,
) -> Adapter:
"""
Build a default adapter for model comparison data.
Maps the ``model_indices`` key produced by
:class:`~bayesflow.simulators.ModelComparisonSimulator` to
``inference_variables``, and optionally handles condition and summary keys.
Parameters
----------
inference_conditions : Sequence[str] or str or None
Keys to concatenate into ``inference_conditions``.
summary_variables : Sequence[str] or str or None
Keys to concatenate into ``summary_variables``.
Returns
-------
Adapter
A configured Adapter instance that applies dtype conversion and concatenation.
"""
return BasicWorkflow.default_adapter(
inference_variables="model_indices",
summary_variables=summary_variables,
inference_conditions=inference_conditions,
)
[docs]
def plot_default_diagnostics(
self,
test_data: Mapping[str, np.ndarray] | int,
estimates: Mapping[str, np.ndarray] | None = None,
true_log_bfs_fn: Callable | None = None,
**kwargs,
) -> dict[str, plt.Figure]:
r"""
Generate default diagnostic plots for model comparison.
Produces a loss curve (when training history is available) followed by
a set of plots that depend on the scoring rule families present. With
multiple scoring rules the diagnostics run on the pooled estimates (see
:meth:`estimate`), so all plots are produced for a single rule, multiple
scores, and mixed families alike.
Always produced:
- ``"confusion_and_bayes_factors"`` — the posterior model probability
confusion matrix (PMP) and the pairwise Bayes factor (BF) heatmap,
drawn side by side in a single figure.
- ``"calibration"`` — per-model calibration curves with ECE annotations.
- ``"bayes_factor_recovery"`` — scatter of predicted vs. true log Bayes
factors, one panel per competing model. Only produced when
``true_log_bfs_fn`` is supplied.
Parameters
----------
test_data : Mapping[str, np.ndarray] or int
Either a pre-simulated data dictionary (as returned by
:meth:`simulate`) or an integer specifying how many datasets to
generate using the attached simulator.
estimates: Mapping[str, np.ndarray] or None
Optional pre-computed dictionary of estimates corresponding to the quantities
in ``test_data``. Default is ``None`` (i.e., esimtates are obtained from the
test data by calling the workflow's ``.estimate()`` method).
true_log_bfs_fn : callable or None, optional
A function ``(test_data: dict) -> np.ndarray`` that receives the
simulated data dictionary and returns ground-truth log Bayes factors
of shape ``(num_datasets, num_models - 1)``. When provided, a
``"bayes_factor_recovery"`` plot is added for Bayes factor scoring
rules. Ignored for PMP scoring rules.
**kwargs : dict, optional
Fine-grained control over individual plots via nested dicts:
- ``test_data_kwargs`` — forwarded to :meth:`simulate` when
``test_data`` is an integer.
- ``predict_kwargs`` — forwarded to :meth:`predict`.
- ``loss_kwargs`` — forwarded to :func:`~bayesflow.diagnostics.plots.loss`.
- ``confusion_matrix_kwargs`` — forwarded to
:func:`~bayesflow.diagnostics.plots.mc_confusion_matrix` (PMP only).
- ``calibration_kwargs`` — forwarded to
:func:`~bayesflow.diagnostics.plots.mc_calibration` (both PMP and BF rules).
- ``bayes_factor_recovery_kwargs`` — forwarded to
:func:`~bayesflow.diagnostics.plots.bayes_factor_recovery` (BF scoring
rules only, requires ``true_log_bfs_fn``).
- ``pairwise_bayes_factors_kwargs`` — forwarded to
:func:`~bayesflow.diagnostics.plots.pairwise_bayes_factors` (BF scoring
rules only).
- ``matrix_row_figsize`` — figure size ``(width, height)`` for the combined
side-by-side confusion matrix / pairwise Bayes factor figure, used only
when both scoring rule families are present. Inferred from the number of
models if omitted.
Returns
-------
dict[str, plt.Figure]
Keys are plot names; values are the corresponding
:class:`~matplotlib.figure.Figure` objects.
Raises
------
ValueError
If ``test_data`` is an integer but no simulator is attached.
"""
if isinstance(test_data, int):
if self.simulator is None:
raise ValueError(f"No simulator attached. Cannot generate {test_data} test datasets.")
test_data = self.simulate(test_data, **kwargs.get("test_data_kwargs", {}))
figures = {}
if self.history is not None:
figures["loss"] = bf_plots.loss(self.history, **kwargs.get("loss_kwargs", {}))
if "model_indices" in test_data:
true_models = test_data["model_indices"]
elif "inference_variables" in test_data:
true_models = test_data["inference_variables"]
else:
raise KeyError(
"test_data must contain 'model_indices' (raw simulator output) or "
"'inference_variables' (adapted output). Neither key was found."
)
if estimates is None:
# Diagnostics operate on merged 'model_probs'; force it regardless of any user estimate_kwargs.
estimate_kwargs = kwargs.get("estimate_kwargs", {})
estimate_kwargs["merge_scores"] = True
estimates = self.estimate(conditions=test_data, **estimate_kwargs)
# Always draw the confusion matrix (PMP view) and pairwise Bayes factor heatmap
model_probs = estimates["model_probs"]
pred_log_bfs = estimates.get("log_bayes_factors", estimates["log_odds"])[..., 1:]
num_models = model_probs.shape[-1]
size = max(4.0, num_models * 1.2)
matrix_fig, matrix_axes = plt.subplots(1, 2, figsize=kwargs.get("matrix_row_figsize", (2 * size + 1.0, size)))
# Smaller fonts so two titled panels read as one row without a grand title;
# user-supplied kwargs still win.
panel_defaults = dict(title_fontsize=14, label_fontsize=13, tick_fontsize=11, value_fontsize=9)
bf_plots.mc_confusion_matrix(
pred_models=model_probs,
true_models=true_models,
model_names=self.model_names,
ax=matrix_axes[0],
**{**panel_defaults, **kwargs.get("confusion_matrix_kwargs", {})},
)
bf_plots.pairwise_bayes_factors(
pred_log_bayes_factors=pred_log_bfs,
true_models=true_models,
model_names=self.model_names,
ax=matrix_axes[1],
**{**panel_defaults, **kwargs.get("pairwise_bayes_factors_kwargs", {})},
)
matrix_fig.tight_layout()
figures["confusion_and_bayes_factors"] = matrix_fig
figures["calibration"] = bf_plots.mc_calibration(
pred_models=estimates["model_probs"],
true_models=true_models,
model_names=self.model_names,
**kwargs.get("calibration_kwargs", {}),
)
if true_log_bfs_fn is not None:
true_log_bfs = true_log_bfs_fn(test_data)
figures["bayes_factor_recovery"] = bf_plots.bayes_factor_recovery(
pred_log_bayes_factors=estimates.get("log_bayes_factors", estimates["log_odds"])[..., 1:],
true_log_bayes_factors=true_log_bfs,
true_models=true_models,
model_names=self.model_names,
**kwargs.get("bayes_factor_recovery_kwargs", {}),
)
return figures
[docs]
def compute_default_diagnostics(
self,
test_data: Mapping[str, np.ndarray] | int,
estimates: Mapping[str, np.ndarray] | None = None,
as_data_frame: bool = True,
**kwargs,
) -> Sequence[dict] | pd.DataFrame:
"""
Compute default scalar diagnostic metrics for model comparison.
All metrics are computed from the pooled log odds, see :meth:`estimate`):
- ``"accuracy"`` — per-model classification accuracy, i.e., the diagonal
entries of the row-normalized confusion matrix (fraction of datasets
simulated from model ``m`` for which ``argmax(PMPs)`` also predicts
model ``m``), as returned by
:func:`~bayesflow.diagnostics.metrics.accuracy`. Controlled
via the ``per_model`` keyword (default ``True``); set to ``False`` to
instead obtain the overall (aggregate) accuracy as a single float.
- ``"ece"`` — per-model expected calibration errors, as returned by
:func:`~bayesflow.diagnostics.metrics.expected_calibration_error`.
- ``"brier_score"`` — per-model (one-vs-rest) and aggregate multi-class
Brier scores, as returned by
:func:`~bayesflow.diagnostics.metrics.brier_score`.
Parameters
----------
test_data : Mapping[str, np.ndarray] or int
Either a pre-simulated data dictionary or an integer specifying how many
datasets to generate using the attached simulator.
estimates: Mapping[str, np.ndarray] or None
Optional pre-computed dictionary of estimates corresponding to the quantities
in ``test_data``. Default is ``None`` (i.e., esimtates are obtained from the
test data by calling the workflow's ``.estimate()`` method).
as_data_frame : bool, optional
Whether to return the results as a pandas DataFrame (default: True),
with one column per model and one row per metric. If False, a
dictionary of the raw (unparsed) metric outputs is returned instead.
**kwargs : dict, optional
Fine-grained control via nested dicts:
- ``test_data_kwargs`` — forwarded to :meth:`simulate` when ``test_data``
is an integer.
- ``estimate_kwargs`` — forwarded to :meth:`estimate`.
- ``accuracy_kwargs`` — forwarded to
:func:`~bayesflow.diagnostics.metrics.accuracy`
(e.g. ``{"per_model": False}`` for the aggregate accuracy).
- ``ece_kwargs`` — forwarded to
:func:`~bayesflow.diagnostics.metrics.expected_calibration_error`.
Returns
-------
dict[str, Any] or pd.DataFrame
If ``as_data_frame`` is True, a pandas DataFrame with one row per
metric and one column per model. Otherwise, a dictionary of the raw
metric outputs.
Raises
------
ValueError
If ``test_data`` is an integer but no simulator is attached.
"""
if isinstance(test_data, int):
if self.simulator is None:
raise ValueError(f"No simulator attached. Cannot generate {test_data} test datasets.")
test_data = self.simulate(test_data, **kwargs.get("test_data_kwargs", {}))
if estimates is None:
# Diagnostics operate on merged 'model_probs'; force it regardless of any user estimate_kwargs.
estimate_kwargs = kwargs.get("estimate_kwargs", {})
estimate_kwargs["merge_scores"] = True
estimates = self.estimate(conditions=test_data, **estimate_kwargs)
if "model_indices" in test_data:
true_models = test_data["model_indices"]
elif "inference_variables" in test_data:
true_models = test_data["inference_variables"]
else:
raise KeyError(
"test_data must contain 'model_indices' (raw simulator output) or "
"'inference_variables' (adapted output). Neither key was found."
)
accuracy_kwargs = dict(kwargs.get("accuracy_kwargs", {}))
accuracy_kwargs.setdefault("per_model", True)
# Merged estimates always carry 'model_probs' (derived from the pooled log Bayes
# factors for BF rules), so all metrics apply to both rule families.
accuracy_result = bf_metrics.accuracy(
estimates["model_probs"],
true_models,
model_names=self.model_names,
**accuracy_kwargs,
)
ece = bf_metrics.expected_calibration_error(
estimates=estimates["model_probs"],
targets=true_models,
model_names=self.model_names,
**kwargs.get("ece_kwargs", {}),
)
brier_score_result = bf_metrics.brier_score(
estimates=estimates["model_probs"],
targets=true_models,
model_names=self.model_names,
)
if as_data_frame:
model_names = ece["model_names"]
if isinstance(accuracy_result, dict):
accuracy_values = accuracy_result["values"]
else:
# Aggregate accuracy (per_model=False): broadcast the single value to all models.
accuracy_values = [accuracy_result] * len(model_names)
metrics = pd.DataFrame(
{
"Accuracy": accuracy_values,
ece["metric_name"]: ece["values"],
brier_score_result["metric_name"]: brier_score_result["values"],
},
index=model_names,
).T
else:
metrics = {"accuracy": accuracy_result, "ece": ece, "brier_score": brier_score_result}
return metrics
[docs]
def sample(self, *args, **kwargs):
raise NotImplementedError("ModelComparisonWorkflow does not support sampling. Use estimate() instead.")
[docs]
def log_prob(self, *args, **kwargs):
raise NotImplementedError("ModelComparisonWorkflow does not support log_prob().")
[docs]
def ancestral_sample(self, *args, **kwargs):
raise NotImplementedError(
"ModelComparisonWorkflow does not support ancestral_sample(). Use estimate() instead."
)