Source code for bayesflow.estimators.ensemble_estimator

from collections.abc import Callable, Mapping, Sequence
from typing import Any

import numpy as np
from scipy.special import logsumexp

import keras

from bayesflow.adapters import Adapter
from bayesflow.simulators import Simulator
from bayesflow.types import Tensor
from bayesflow.utils import logging, filter_kwargs
from bayesflow.utils.keras_utils import resolve_seed, multinomial_allocation
from bayesflow.utils.serialization import serializable, serialize
from bayesflow.datasets import EnsembleDataset

from .estimator import Estimator


[docs] @serializable("bayesflow.estimators") class EnsembleEstimator(Estimator): """Combines multiple estimators into a single ensemble. An ``EnsembleEstimator`` wraps a named collection of :class:`~bayesflow.estimators.Estimator` instances and trains them jointly. At inference time it can produce *per-member* results or *merged* results (weighted mixture for :meth:`sample` and :meth:`log_prob`). The adapter is inherited from the first member estimator and is assumed to be the same across all members (this is **not** enforced). Parameters ---------- estimators : Mapping[str, Estimator] A mapping from member names to estimator instances. Each member is trained on its own slice of the data during :meth:`fit` and is addressed by name in :meth:`sample`, :meth:`log_prob`, and :meth:`estimate`. **kwargs : dict, optional Additional arguments forwarded to the :class:`~bayesflow.estimators.Estimator` base class. """ def __init__(self, estimators: Mapping[str, Estimator], **kwargs): super().__init__(**kwargs) self._warn_if_shared_estimator_components(estimators) self.estimators = estimators self.members = tuple(self.estimators.keys()) self.distribution_members = tuple( k for k, a in self.estimators.items() if getattr(a, "has_distribution", False) ) self.estimate_members = tuple(k for k, a in self.estimators.items() if hasattr(a, "estimate")) self.has_distribution = bool(self.distribution_members) self.seed_generator = keras.random.SeedGenerator() @classmethod def _warn_if_shared_estimator_components(cls, estimators): """Warn if estimators share component instances (not safely serializable yet).""" tracked = ("inference_network", "summary_network") seen = {name: {} for name in tracked} for member_name, estimator in estimators.items(): for attr in tracked: if not hasattr(estimator, attr): continue obj = getattr(estimator, attr) if obj is None: continue obj_id = id(obj) seen[attr].setdefault(obj_id, []).append(member_name) # Emit one warning per shared object instance. for attr, by_id in seen.items(): for members in by_id.values(): if len(members) > 1: logging.warning( "EnsembleEstimator contains shared component '{attr}' across members {members}. " "Deserialization of weights of shared components is not supported yet and may fail. " "Use separate component instances (e.g., clone networks) to be able to serialize " "the whole EnsembleEstimator object or serialize the estimators in the ensemble " "separately.", attr=attr, members=members, ) @classmethod def _warn_ignored_member_weights(cls, member_weights: Mapping[str, float] | None, merge_members: bool): if member_weights is not None and not merge_members: logging.warning( "`member_weights` is ignored when `merge_members=False`. " "Set `merge_members=True` to use a weighted mixture." ) @property def adapter(self) -> Adapter: # Defer to any adapter of the estimators, # assuming all are the same, which is not enforced at the moment. # self.adapter will only be used when super().fit calls build_dataset(..., adapter=self.adapter). return next(iter(self.estimators.values())).adapter
[docs] def build_dataset( self, *, batch_size: int = "auto", num_batches: int, adapter: Adapter = "auto", memory_budget: str | int = "auto", simulator: Simulator, workers: int = "auto", use_multiprocessing: bool = False, max_queue_size: int = 32, **kwargs, ) -> EnsembleDataset: base_dataset = super().build_dataset( batch_size=batch_size, num_batches=num_batches, adapter=adapter, memory_budget=memory_budget, simulator=simulator, workers=workers, use_multiprocessing=use_multiprocessing, max_queue_size=max_queue_size, **kwargs, ) return EnsembleDataset( dataset=base_dataset, member_names=self.members, **filter_kwargs(kwargs, keras.utils.PyDataset.__init__), )
[docs] def build(self, data_shapes: dict) -> None: for estimator_name, estimator in self.estimators.items(): _data_shape = {} for var_name, variable in data_shapes.items(): # variable type # If data_shapes has a nested ensemble level, select shapes with estimator_name. # Note, summary_variables might be dict, if a FusionNetwork is used. # Thus, we further check whether the estimator_name is in the keys. if isinstance(variable, dict) and estimator_name in variable.keys(): _data_shape[var_name] = variable[estimator_name] else: _data_shape[var_name] = variable estimator.build(_data_shape)
[docs] def fit(self, *args, **kwargs) -> keras.callbacks.History: """ Trains the ensemble of estimators on the provided dataset or on-demand data generated from the given simulator. If `dataset` is not provided, a dataset is built from the `simulator`. If the model has not been built, it will be built using a batch from the dataset. If `dataset` is `OnlineDataset`, `OfflineDataset` or `DiskDataset`, it will be wrapped into an `EnsembleDataset`. Parameters ---------- dataset : keras.utils.PyDataset, optional A dataset containing simulations for training. If provided, `simulator` must be None. simulator : Simulator, optional A simulator used to generate a dataset. If provided, `dataset` must be None. **kwargs Additional keyword arguments passed to `keras.Model.fit()` and to the dataset constructor if `dataset` is not provided. Returns ------- keras.callbacks.History A history object containing the training loss and metrics values. Raises ------ ValueError If both `dataset` and `simulator` are provided or neither is provided. """ return super().fit(*args, **kwargs, adapter=self.adapter)
[docs] def compute_metrics( self, inference_variables: dict[str, Tensor] | Tensor, inference_conditions: dict[str, Tensor] | Tensor | None = None, summary_variables: dict[str, Tensor] | Tensor | None = None, sample_weight: dict[str, Tensor] | Tensor | None = None, summary_attention_mask: dict[str, Tensor] | Tensor | None = None, summary_mask: dict[str, Tensor] | Tensor | None = None, inference_attention_mask: dict[str, Tensor] | Tensor | None = None, inference_mask: dict[str, Tensor] | Tensor | None = None, stage: str = "training", **kwargs, ) -> dict[str, dict[str, Tensor]]: metrics = {} for name, estimator in self.estimators.items(): metrics[name] = estimator.compute_metrics( inference_variables=self._select_member(inference_variables, name), inference_conditions=self._select_member(inference_conditions, name), summary_variables=self._select_member(summary_variables, name), sample_weight=self._select_member(sample_weight, name), summary_attention_mask=self._select_member(summary_attention_mask, name), summary_mask=self._select_member(summary_mask, name), inference_attention_mask=self._select_member(inference_attention_mask, name), inference_mask=self._select_member(inference_mask, name), stage=stage, **{key: self._select_member(value, name) for key, value in kwargs.items()}, ) loss = keras.ops.sum([estimator_metrics["loss"] for estimator_metrics in metrics.values()]) metrics = { f"{estimator_name}/{metric_key}": value for estimator_name, estimator_metrics in metrics.items() for metric_key, value in estimator_metrics.items() } metrics["loss"] = loss return metrics
[docs] def sample( self, *, num_samples: int, conditions: Mapping[str, np.ndarray], split: bool = False, member_weights: Mapping[str, float] | None = None, merge_members: bool = True, seed: int | keras.random.SeedGenerator | None = None, **kwargs, ) -> dict[str, np.ndarray]: """ Draw samples from the marginalized distribution over ensemble members. Samples are allocated to estimators via multinomial sampling using member_weights, then concatenated and shuffled to produce the marginal distribution. Parameters ---------- num_samples : int Total number of samples to draw. conditions : Mapping[str, np.ndarray] Conditions for sampling. split : bool, optional Whether to split output arrays, by default False. member_weights : Mapping[str, float] or None, optional Probability weights for each estimator. If None, uses uniform weights. Must be nonnegative, will be normalized to sum to 1. merge_members : bool, optional Whether to merge samples from all estimators into a single (weighted) marginal sample. seed : int, keras.random.SeedGenerator, or None, optional Seed for reproducible sampling. An integer is converted to a ``keras.random.SeedGenerator`` and shared across all stochastic operations in the call. A ``SeedGenerator`` is passed through as-is. If ``None`` (default), this instance's own seed generator is used. **kwargs Additional arguments passed to estimator.sample(). Returns ------- dict[str, np.ndarray] Samples with shape (batch_size, num_samples, ...) for each variable. """ self._warn_ignored_member_weights(member_weights, merge_members) seed = resolve_seed(seed, self.seed_generator) if not merge_members: return self._map_members( None, capability="distribution", fn=lambda name, a: a.sample( num_samples=num_samples, conditions=conditions, split=split, seed=seed, **{key: self._select_member(value, name) for key, value in kwargs.items()}, ), ) weights = self._resolve_member_weights(member_weights) counts = multinomial_allocation(weights, num_samples, seed=seed) alloc = {name: count for name, count in counts.items() if count > 0} per_member = self._map_members( list(alloc.keys()), capability="distribution", fn=lambda name, a: a.sample( num_samples=alloc[name], conditions=conditions, split=split, seed=seed, **{key: self._select_member(value, name) for key, value in kwargs.items()}, ), ) merged = keras.tree.map_structure(lambda *xs: np.concatenate(xs, axis=1), *per_member.values()) shuffle_idx = keras.random.shuffle(keras.ops.arange(num_samples), seed=seed) shuffle_idx = keras.ops.convert_to_numpy(shuffle_idx) return keras.tree.map_structure(lambda a: np.take(a, shuffle_idx, axis=1), merged)
[docs] def log_prob( self, data: Mapping[str, np.ndarray], member_weights: Mapping[str, float] | None = None, merge_members: bool = True, **kwargs, ) -> np.ndarray: """ Compute the marginalized log probability over ensemble members. Uses log-sum-exp trick to compute log p(x) = log(sum_i w_i * p_i(x)). Parameters ---------- data : Mapping[str, np.ndarray] Data containing inference variables and conditions. member_weights : Mapping[str, float] or None, default None Probability weights for each estimator. If None, uses uniform weights. Must be nonnegative, will be normalized to sum to 1. merge_members : bool, optional Whether to merge log probabilities from all estimators into a single marginal log probability. **kwargs Additional arguments passed to estimator.log_prob(). Returns ------- np.ndarray Marginalized log probabilities with shape (batch_size,). """ self._warn_ignored_member_weights(member_weights, merge_members) if not merge_members: return self._map_members( None, capability="distribution", fn=lambda name, a: a.log_prob( data=data, **{key: self._select_member(value, name) for key, value in kwargs.items()}, ), ) weights = self._resolve_member_weights(member_weights) members = list(weights.keys()) log_probs = self._map_members( members, capability="distribution", fn=lambda name, a: a.log_prob( data=data, **{key: self._select_member(value, name) for key, value in kwargs.items()}, ), ) stacked = np.stack([log_probs[m] for m in members], axis=-1) log_w = np.log(np.fromiter((weights[m] for m in members), dtype=float, count=len(members))) return logsumexp(stacked + log_w, axis=-1)
[docs] def estimate( self, conditions: Mapping[str, np.ndarray], *, members: Sequence[str] | None = None, split: bool = False, groupby: str = "member", **kwargs, ) -> dict: """ Compute point estimates and distribution parameters from each estimator separately. Parameters ---------- conditions : Mapping[str, np.ndarray] Conditions for estimation. members : Sequence[str] or None, default None Ensemble members to estimate with. If None, will estimate with all members that have an `estimate` method. split : bool, optional Whether to split output arrays, by default False. groupby : {"member", "variable"}, default "member" Controls the top-level nesting of the returned dictionary. - "member": return estimates as ``member -> variable -> score (-> head) -> array``. - "variable": return estimates as ``variable -> score (-> head) -> member -> array``. See also :py:meth:`~bayesflow.ScoringRuleEstimator.estimate`. **kwargs Additional arguments passed to estimator.estimate(). Returns ------- dict[str, dict[str, dict[str, np.ndarray]]] Estimates keyed by estimator name, then by variable and score names. """ estimates = self._map_members( members, capability="estimate", fn=lambda name, a: a.estimate(conditions=conditions, split=split, **kwargs), ) if groupby == "member": return estimates elif groupby == "variable": out = {} for member_key, member_est in estimates.items(): for var_key, var_est in member_est.items(): out.setdefault(var_key, {}) for score_key, score_val in var_est.items(): # score has heads -> dict[head] = array if isinstance(score_val, dict): node = out[var_key].setdefault(score_key, {}) if not isinstance(node, dict): raise ValueError( f"Inconsistent estimate structure for variable={var_key!r}, score={score_key!r}: " "some members return a dict of heads, others return an array." ) for head_key, arr in score_val.items(): node.setdefault(head_key, {}) node[head_key][member_key] = arr # score is already an array (no head level / squeezed) else: node = out[var_key].setdefault(score_key, {}) if isinstance(node, dict) and node and any(isinstance(v, dict) for v in node.values()): raise ValueError( f"Inconsistent estimate structure for variable={var_key!r}, score={score_key!r}: " "some members return an array, others return a dict of heads." ) # keep head level absent; attach member at score level if not isinstance(node, dict): # should not happen, but keep it safe out[var_key][score_key] = {} node = out[var_key][score_key] node[member_key] = score_val return out else: raise NotImplementedError( f"`groupby={groupby!r}` is not supported for EnsembleEstimator. Use 'member' or 'variable'." )
def _map_members( self, members: Sequence[str] | None, *, capability: str, fn: Callable, ) -> dict[str, Any]: resolved = self._resolve_members(members, capability=capability) return {name: fn(name, self.estimators[name]) for name in resolved} def _select_member(self, value, name: str): if isinstance(value, Mapping) and any(member in value for member in self.members): return value[name] return value def _resolve_members(self, members: Sequence[str] | None, *, capability: str) -> tuple[str, ...]: if capability == "any": base = self.members elif capability == "distribution": base = self.distribution_members elif capability == "estimate": base = self.estimate_members else: raise ValueError(f"Unknown capability {capability!r}") if members is None: return base base_set = set(base) members_t = tuple(members) unknown = [m for m in members_t if m not in base_set] if unknown: raise ValueError(f"Unknown/unsupported members for capability={capability!r}: {unknown}") return members_t def _resolve_member_weights(self, member_weights: Mapping[str, float] | None) -> Mapping[str, float]: if member_weights is None: member_weights = {k: 1.0 for k in self.distribution_members} for key, weight in member_weights.items(): if key not in self.distribution_members: raise ValueError( "Member weights must be subset of self.distribution_members. " f"Unknown keys: {set(member_weights) - set(self.distribution_members)}" ) if weight < 0: raise ValueError(f"All member_weights must be positive. Received {key}: {weight}.") # Normalize weights to 1 summed = np.sum(list(member_weights.values())) member_weights = {k: v / summed for k, v in member_weights.items()} return member_weights def _batch_size_from_data(self, data: Mapping[str, Mapping[str, Any]]) -> int: """ Fetches the current batch size from an input dictionary. Can only be used during training when inference variables as present. """ if isinstance(data["inference_variables"], dict): return keras.ops.shape(data["inference_variables"][list(self.estimators.keys())[0]])[0] return keras.ops.shape(data["inference_variables"])[0]
[docs] def get_config(self): base_config = super().get_config() config = {"estimators": self.estimators} return base_config | serialize(config)
[docs] def build_from_config(self, config): # the estimators are already built pass