Source code for deephyper.ensemble.aggregator._mixed_normal
from collections import defaultdict
from typing import Dict, List, Optional
import numpy as np
from deephyper.ensemble.aggregator._aggregator import Aggregator
[docs]
class MixedNormalAggregator(Aggregator):
"""Aggregate a collection of predictions, each representing a normal distribution.
This aggregator combines the mean (`loc`) and standard deviation (`scale`)
of multiple normal distributions into a single mixture distribution.
Eventhough the mixture of normal distributions is not a normal distribution, this aggregator
approximates it as a normal and only returns the mean and standard deviation of the mixture.
.. list-table::
:widths: 25 25
:header-rows: 1
* - Array (Fixed Set)
- MaskedArray
* - ✅
- ❌
Args:
decomposed_scale (bool, optional): If ``True``, the scale of the mixture distribution
is decomposed into aleatoric and epistemic components. Default is ``False``.
"""
def __init__(self, decomposed_scale: bool = False):
self.decomposed_scale = decomposed_scale
[docs]
def aggregate(
self,
y: List[Dict[str, np.ndarray]],
weights: Optional[List[float]] = None,
) -> Dict[str, np.ndarray]:
"""Aggregate the predictions.
Args:
y (List[Dict[str, np.ndarray]]): Predictions with keys:
- ``loc``: Mean of each normal distribution, shape ``(n_predictors, n_samples, ...,
n_outputs)``.
- ``scale``: Standard deviation of each normal distribution.
weights (Optional[List[float]]): Predictor weights. Defaults to uniform weights.
Returns:
Dict[str, np.ndarray]: Aggregated predictions with:
- `loc`: Mean of the mixture distribution.
- `scale`: Standard deviation (or decomposed components if `decomposed_scale` is
`True`).
"""
if not y:
raise ValueError("Input list 'y' must not be empty.")
# Validate all dictionaries in y have the same keys
keys = y[0].keys()
if "loc" not in keys:
raise ValueError("All elements of 'y' must have a 'loc' key.")
if "scale" not in keys:
raise ValueError("All elements of 'y' must have a 'scale' key.")
if any(set(yi.keys()) != set(keys) for yi in y):
raise ValueError("All elements of 'y' must have the 'loc' and 'scale' keys.")
if weights is not None and len(weights) != len(y):
raise ValueError("The length of `weights` must match the number of predictors in `y`.")
# Stack the loc and scale arrays
y_dict = defaultdict(list)
for yi in y:
for k, v in yi.items():
y_dict[k].append(v)
y = {k: v for k, v in y_dict.items()}
self._np = np
if all(isinstance(yi, np.ma.MaskedArray) for yi in y["loc"]) and all(
isinstance(yi, np.ma.MaskedArray) for yi in y["scale"]
):
self._np = np.ma
y["loc"] = self._np.stack(y["loc"], axis=0)
y["scale"] = self._np.stack(y["scale"], axis=0)
loc = y["loc"]
scale = y["scale"]
mean_loc = self._np.average(loc, weights=weights, axis=0)
agg = {"loc": mean_loc}
if not self.decomposed_scale:
sum_loc_scale = loc**2 + scale**2
mean_scale = self._np.sqrt(
self._np.average(sum_loc_scale, weights=weights, axis=0) - mean_loc**2
)
agg["scale"] = mean_scale
else:
# Here we assume that the mixture distribution is a normal distribution with a scale
# that is the sum of the aleatoric and epistemic scales. This is a significant
# approximation that could be improved by returning the true GMM.
scale_aleatoric = self._np.sqrt(
self._np.average(scale**2, weights=weights, axis=0),
)
scale_epistemic = self._np.sqrt(self._np.std(loc, axis=0) ** 2)
agg["scale_aleatoric"] = scale_aleatoric
agg["scale_epistemic"] = scale_epistemic
return agg