Source code for mllm_shap.estimation.normalizers

"""Post-aggregation attribution normalizers.

@domain estimation
@see [[knowledge/architecture/estimation]]
@tested-by [[tests/estimation/test_normalizers.py]]
"""

from collections.abc import Sequence
from typing import TYPE_CHECKING, ClassVar

import numpy as np
from pydantic import Field

from ..protocols.plugin import PluginOptions

if TYPE_CHECKING:
    from ..plugins.context import PluginContext


[docs] class IdentityNormalizer: """Return attributions unchanged.""" options_model: ClassVar[type[PluginOptions]] = PluginOptions """Accepts no ``explanation.plugin_options``.""" def __init__(self, context: "PluginContext | None" = None) -> None: """Accept the plugin context; this plugin reads no settings from it.""" _ = context @property def name(self) -> str: """Return the registered normalizer identifier.""" return "identity"
[docs] def normalize(self, values: Sequence[float]) -> list[float]: """Return a copy of ``values``.""" return list(values)
[docs] class AbsSumNormalizer: """Scale attributions by the sum of absolute values.""" options_model: ClassVar[type[PluginOptions]] = PluginOptions """Accepts no ``explanation.plugin_options``.""" def __init__(self, context: "PluginContext | None" = None) -> None: """Accept the plugin context; this plugin reads no settings from it.""" _ = context @property def name(self) -> str: """Return the registered normalizer identifier.""" return "abs_sum"
[docs] def normalize(self, values: Sequence[float]) -> list[float]: """Return ``values`` divided by the sum of absolute entries.""" array = np.asarray(values, dtype=np.float64) abs_sum = float(np.abs(array).sum()) if abs_sum == 0.0: return list(values) normalized = array / abs_sum return [float(value) for value in normalized]
[docs] class PowerShiftOptions(PluginOptions): """Options of the ``power_shift`` normalizer.""" power: float = Field(default=1.0, gt=0.0) """Exponent applied to the shifted attributions."""
[docs] class PowerShiftNormalizer: """Shift to non-negative values, apply a power, then scale to sum one.""" options_model: ClassVar[type[PluginOptions]] = PowerShiftOptions """Options accepted from ``explanation.plugin_options``.""" def __init__( self, context: "PluginContext | None" = None, power: float = 1.0, ) -> None: """Set the exponent; a ``context`` may override it with option ``power``.""" if context is not None: power = context.option("power", power) if power <= 0.0: msg = "power must be a positive float" raise ValueError(msg) self.power = power @property def name(self) -> str: """Return the registered normalizer identifier.""" return "power_shift"
[docs] def normalize(self, values: Sequence[float]) -> list[float]: """Return power-shift normalized attributions.""" array = np.asarray(values, dtype=np.float64) shifted = array - array.min() powered = np.power(shifted, self.power) total = float(powered.sum()) if total == 0.0: return list(values) normalized = powered / total return [float(value) for value in normalized]
[docs] class MinMaxNormalizer: """Min-max scale to ``[0, 1]`` then normalize to sum one.""" options_model: ClassVar[type[PluginOptions]] = PluginOptions """Accepts no ``explanation.plugin_options``.""" def __init__(self, context: "PluginContext | None" = None) -> None: """Accept the plugin context; this plugin reads no settings from it.""" _ = context @property def name(self) -> str: """Return the registered normalizer identifier.""" return "min_max"
[docs] def normalize(self, values: Sequence[float]) -> list[float]: """Return min-max scaled attributions that sum to one.""" array = np.asarray(values, dtype=np.float64) min_val = float(array.min()) max_val = float(array.max()) if max_val - min_val == 0.0: uniform = np.ones_like(array) / len(array) return [float(value) for value in uniform] scaled = (array - min_val) / (max_val - min_val) total = float(scaled.sum()) normalized = scaled / total return [float(value) for value in normalized]