from collections.abc import Mapping, Sequence
from typing import Literal
import numpy as np
import keras
from bayesflow.adapters import Adapter
from bayesflow.networks import ScoringRuleNetwork, SummaryNetwork
from bayesflow.scoring_rules import CategoricalScoringRule
from bayesflow.simulators import ModelComparisonSimulator, Simulator
from bayesflow.types import Tensor
from bayesflow.utils import filter_kwargs, logging
from bayesflow.utils.serialization import serializable
from .approximator import Approximator
from .scoring_rule_approximator import ScoringRuleApproximator
[docs]
@serializable("bayesflow.approximators")
class ModelComparisonApproximator(ScoringRuleApproximator):
"""
Defines an approximator for model (simulator) comparison, where the (discrete)
posterior model probabilities are learned with a classifier implementing multi-
class, stabilized versions of all losses described in [1].
Uses a :class:`~bayesflow.networks.ScoringRuleNetwork` with a categorical scoring
rule (e.g. :class:`~bayesflow.scoring_rules.CrossEntropyScore`) to map
summary/condition inputs to class logits and train via the chosen scoring rule.
Multiple scoring rules can be co-learned simultaneously by passing a dict of rules
to :class:`~bayesflow.networks.ScoringRuleNetwork`. The combined loss is the mean
of all individual scoring rule losses.
[1] Jeffrey, N., & Wandelt, B. D. (2024). Evidence Networks: simple losses for fast,
amortized, neural Bayesian model comparison. Machine Learning: Science and Technology,
5(1), 015008.
Parameters
----------
inference_network : ScoringRuleNetwork
A :class:`~bayesflow.networks.ScoringRuleNetwork` configured with one or more
categorical scoring rules (e.g. :class:`~bayesflow.scoring_rules.CrossEntropyScore`,
:class:`~bayesflow.scoring_rules.ExponentialScore`).
adapter : bf.adapters.Adapter, optional
Adapter for data pre-processing. If ``None`` (default), an identity
adapter is used that makes a shallow copy and passes data through unchanged.
summary_network : bf.networks.SummaryNetwork, optional
The summary network used for data summarization (default is None).
The input of the summary network is ``summary_variables``.
standardize : str | Sequence[str] | None
The variables to standardize before passing to the networks. Can be any subset
of ["inference_conditions", "summary_variables"].
Note: ``inference_variables`` (one-hot model indices) are never standardized,
even when ``standardize="all"`` is passed.
(default is None).
"""
def __init__(
self,
*,
inference_network: ScoringRuleNetwork,
adapter: Adapter | None = None,
summary_network: SummaryNetwork | None = None,
standardize: str | Sequence[str] | None = "all",
**kwargs,
):
# Model indices (one-hot encoded) must never be standardized.
standardize = self._filter_standardize(standardize)
super().__init__(
inference_network=inference_network,
adapter=adapter,
summary_network=summary_network,
standardize=standardize,
**kwargs,
)
@staticmethod
def _filter_standardize(standardize):
"""Exclude model indices from standardization."""
match standardize:
case None | "inference_variables":
return None
case "all":
return ["summary_variables", "inference_conditions"]
case str():
return standardize
case _:
return [item for item in standardize if item != "inference_variables"] or None
@staticmethod
def _rule_output_to_model_probs(
rule: CategoricalScoringRule, rule_output: dict[str, Tensor]
) -> dict[str, np.ndarray]:
"""Convert a single scoring rule's raw network output to model probabilities and log odds."""
log_odds = rule.to_log_odds(rule_output)
model_probs = keras.ops.softmax(log_odds)
log_odds = log_odds - log_odds[..., :1]
return {
"model_probs": keras.ops.convert_to_numpy(model_probs),
"log_odds": keras.ops.convert_to_numpy(log_odds),
}
@staticmethod
def _merge_rule_estimates(per_rule: dict[str, dict[str, np.ndarray]]) -> dict[str, np.ndarray]:
"""Pool the per-rule estimates of several scoring rules into the single-rule structure.
Each rule is a consistent estimator of the same posterior over models, so their
``log_odds`` are combined by a *logarithmic opinion pool* (the mean in log-odds
space), and ``model_probs`` is derived from the pooled ``log_odds`` so the two stay
mutually consistent.
"""
merged_log_odds = np.mean([r["log_odds"] for r in per_rule.values()], axis=0)
exp = np.exp(merged_log_odds - merged_log_odds.max(axis=-1, keepdims=True))
model_probs = exp / exp.sum(axis=-1, keepdims=True)
return {"model_probs": model_probs, "log_odds": merged_log_odds}
def _add_log_bayes_factors(self, estimates: dict, model_prior: np.ndarray | None) -> dict:
"""Add prior-adjusted log Bayes factors to each leaf estimate dict, in place."""
if model_prior is None:
return estimates
if "log_odds" in estimates:
log_prior = np.log(model_prior)
log_prior_odds = log_prior - log_prior[0]
estimates["log_bayes_factors"] = estimates["log_odds"] - log_prior_odds
else:
for value in estimates.values():
if isinstance(value, dict):
self._add_log_bayes_factors(value, model_prior)
return estimates
[docs]
def build_dataset(
self,
*,
dataset: keras.utils.PyDataset | None = None,
simulator: ModelComparisonSimulator | None = None,
simulators: Sequence[Simulator] | None = None,
**kwargs,
) -> keras.utils.PyDataset:
if sum(arg is not None for arg in (dataset, simulator, simulators)) != 1:
raise ValueError("Exactly one of dataset, simulator, or simulators must be provided.")
if simulators is not None:
simulator = ModelComparisonSimulator(simulators)
return super().build_dataset(dataset=dataset, simulator=simulator, **kwargs)
[docs]
def fit(
self,
*,
adapter: Adapter | Literal["auto"] = "auto",
dataset: keras.utils.PyDataset | None = None,
simulator: ModelComparisonSimulator | None = None,
simulators: Sequence[Simulator] | None = None,
**kwargs,
):
"""
Trains the approximator on the provided dataset or on-demand generated from the given (multi-model) simulator.
If `dataset` is not provided, a dataset is built from the `simulator`.
If `simulator` is not provided, it will be built from a list of `simulators`.
If the model has not been built, it will be built using a batch from the dataset.
Parameters
----------
adapter : Adapter or 'auto', optional
The data adapter that will make the simulated / real outputs neural-network friendly.
dataset : keras.utils.PyDataset, optional
A dataset containing simulations for training. If provided, `simulator` must be None.
simulator : ModelComparisonSimulator, optional
A simulator used to generate a dataset. If provided, `dataset` must be None.
simulators: Sequence[Simulator], optional
A list of simulators (one simulator per model). If provided, `dataset` must be None.
**kwargs
Additional keyword arguments passed to ``keras.Model.fit()``.
Returns
-------
keras.callbacks.History
A history object containing the training loss and metrics values.
Raises
------
ValueError
If both `dataset` and `simulator` or `simulators` are provided or neither is provided.
"""
if dataset is not None:
if simulator is not None or simulators is not None:
raise ValueError(
"Received conflicting arguments. Please provide either a dataset or a simulator, but not both."
)
# Let ContinuousApproximator.fit inject self.adapter
return super().fit(dataset=dataset, **kwargs)
if adapter == "auto":
logging.info("Building automatic data adapter.")
adapter = self.build_adapter(
inference_variables="model_indices", **filter_kwargs(kwargs, self.build_adapter)
)
if simulator is not None:
# Bypass ContinuousApproximator.fit to avoid duplicate adapter kwarg
return Approximator.fit(self, simulator=simulator, adapter=adapter, **kwargs)
logging.info(f"Building model comparison simulator from {len(simulators)} simulators.")
simulator = ModelComparisonSimulator(simulators=simulators)
return Approximator.fit(self, simulator=simulator, adapter=adapter, **kwargs)
[docs]
def estimate(
self,
*,
conditions: Mapping[str, np.ndarray],
return_summaries: bool = False,
merge_scores: bool = True,
model_prior: np.ndarray | None = None,
**kwargs,
) -> dict[str, np.ndarray]:
"""
Returns estimates given input conditions, adapting output to the active scoring rule(s).
Every scoring rule yields ``"model_probs"`` (the posterior over ``num_models`` models)
and length-``M`` ``"log_odds"`` relative to the reference model :math:`\\mathcal{M}_0`
(so ``log_odds[..., 0] = 0``). When ``model_prior`` is given, ``"log_bayes_factors"``
(same shape) is also returned, removing the training model prior:
:math:`\\log K_{k,0} = \\text{log\\_odds}_k - (\\log \\pi_k - \\log \\pi_0)`. Without a
prior the Bayes factors are undefined and omitted.
Parameters
----------
conditions : Mapping[str, np.ndarray]
Dictionary of conditioning variables as NumPy arrays.
return_summaries: bool, optional
If set to True and a summary network is present, will return the learned summary statistics for
the provided conditions.
merge_scores : bool, optional
Only relevant when more than one scoring rule is configured (default: True).
If True, the per-rule estimates are pooled into the flat single-rule structure:
all rules are pooled in log-odds space (logarithmic opinion pool) and a consistent
``"model_probs"`` is derived from the pooled ``"log_odds"``. If False, results are
returned as a nested dict keyed by rule name. Has no effect for a single rule.
model_prior : np.ndarray, optional
Prior model probabilities (length ``num_models``) used during training. If given,
the output includes ``"log_bayes_factors"``.
**kwargs
Additional keyword arguments forwarded to the adapter and classifier.
Returns
-------
dict[str, np.ndarray]
Single scoring rule: ``"model_probs"`` and ``"log_odds"`` of shape
``(num_datasets, num_models)`` (``"log_odds"`` relative to model 0, so
``[..., 0] = 0``), and ``"log_bayes_factors"`` of the same shape when
``model_prior`` is given. If a summary network is present, also contains
``"_summaries"``.
Multiple scoring rules with ``merge_scores=True`` (default): the same flat
structure, with ``"log_odds"`` pooled across rules (logarithmic opinion pool) and
``"model_probs"`` (and ``"log_bayes_factors"``, if a prior was given) derived from it.
Multiple scoring rules with ``merge_scores=False``: nested dict keyed by rule
name, each value having the same structure as the single-rule case.
``"_summaries"`` (if present) is at the top level in both cases.
"""
resolved_conditions, adapted, summary_outputs = self._prepare_conditions(conditions, **kwargs)
inference_kwargs = self._collect_mask_kwargs(self._INFERENCE_MASK_KEYS, adapted)
raw = self.inference_network(xz=None, conditions=resolved_conditions, **inference_kwargs)
per_rule = {
rule_key: self._rule_output_to_model_probs(self.inference_network.scoring_rules[rule_key], rule_output)
for rule_key, rule_output in raw.items()
}
if len(per_rule) == 1:
result = next(iter(per_rule.values()))
elif merge_scores:
result = self._merge_rule_estimates(per_rule)
else:
result = per_rule
self._add_log_bayes_factors(result, model_prior)
if return_summaries and summary_outputs is not None:
result["_summaries"] = keras.ops.convert_to_numpy(summary_outputs)
return result
[docs]
def log_prob(self, *args, **kwargs):
raise NotImplementedError("ModelComparisonApproximator does not support log_prob(). Use estimate() instead.")