Source code for nlp_shap.estimation.complementary
"""Complementary pair sampling estimator."""
from collections.abc import Iterator, Sequence
import numpy as np
from ..domain.coalition import CoalitionMask
from ..domain.conversation import ConversationSnapshot
from ..domain.players import PlayerSet
from ._shared import (
aggregate_complementary_shapley,
build_c_matrix_from_pairs,
complementary_base_present,
compute_complementary_num_splits,
increment_m_counts,
is_empty_or_grand,
present_to_mask_int,
random_present,
)
[docs]
class ComplementaryEstimator:
"""Sample complementary coalition pairs and aggregate CC attributions."""
def __init__(self) -> None:
self._snapshot: ConversationSnapshot | None = None
self._m_counts: np.ndarray | None = None
@property
def name(self) -> str:
"""Return the registered estimator identifier."""
return "complementary"
@property
def m_counts(self) -> np.ndarray | None:
"""Return the latest complementary M-matrix counts from sampling."""
return None if self._m_counts is None else self._m_counts.copy()
[docs]
def bind_snapshot(self, snapshot: ConversationSnapshot) -> None:
"""Attach the conversation snapshot under explanation."""
self._snapshot = snapshot
[docs]
def reset_sampling_state(self, num_players: int) -> None:
"""Reset complementary M-matrix counts before a new sampling run."""
self._m_counts = np.zeros((num_players, num_players + 1), dtype=np.int64)
[docs]
def sample_masks(
self,
player_set: PlayerSet,
budget_fraction: float,
include_minimal_masks: bool,
seed: int,
) -> Iterator[CoalitionMask]:
"""Yield complementary coalition pairs up to the configured budget."""
num_players = player_set.num_players
num_splits = compute_complementary_num_splits(
num_players,
budget_fraction,
include_minimal_masks,
)
self.reset_sampling_state(num_players)
m_counts = self._m_counts
if m_counts is None:
msg = "M matrix must be initialized before sampling."
raise RuntimeError(msg)
rng = np.random.default_rng(seed)
seen: set[int] = set()
generated = 0
base_index = 0
while generated < num_splits:
if include_minimal_masks and base_index < num_players:
present = complementary_base_present(rng, num_players, base_index)
base_index += 1
else:
present = random_present(rng, num_players)
if is_empty_or_grand(present):
continue
mask_int = present_to_mask_int(present)
complement_int = present_to_mask_int(tuple(not value for value in present))
if mask_int in seen or complement_int in seen:
continue
if generated + 2 > num_splits:
return
complement = tuple(not value for value in present)
seen.add(mask_int)
seen.add(complement_int)
increment_m_counts(m_counts, present)
increment_m_counts(m_counts, complement)
generated += 2
yield CoalitionMask.from_sequence(present)
yield CoalitionMask.from_sequence(complement)
[docs]
def estimate_attributions(
self,
masks: Sequence[CoalitionMask],
payoffs: Sequence[float],
) -> list[float]:
"""Aggregate complementary pair payoffs into CC Shapley attributions."""
if self._m_counts is None:
msg = "M matrix must be initialized before calculating attributions."
raise RuntimeError(msg)
c_matrix = build_c_matrix_from_pairs(self._m_counts, masks, payoffs)
return aggregate_complementary_shapley(self._m_counts, c_matrix)