Shapley vs Banzhaf on toy cooperative games

Business context: Attribution reports shown to legal, risk, and product teams must name the estimand being reported. Shapley and Banzhaf answer different fairness questions; mislabelling one as the other breaks audit trails and can invalidate compliance reviews.

Goal: Walk through every public estimand API — aggregators, coalition weights, labelled results, manifest wire format, and plugin resolution.

Prerequisites: mllm-shap, Python 3.12.

Theory: See the estimands theory page.

Expected output: Divergence on a majority game; agreement on an additive game; ExplainResult and RunManifest carry the estimand label end to end.

from itertools import product

from mllm_shap import (
    BanzhafAggregator,
    Estimand,
    ExplainConfig,
    ExplainResult,
    RunManifest,
    ShapleyAggregator,
    parse_manifest,
)
from mllm_shap.domain.estimands import estimand_to_wire
from mllm_shap.plugins import PluginContext, PluginGroup, PluginRegistry

Why the estimand label matters

Regulators and enterprise buyers increasingly ask which cooperative-game value was computed, not only the attribution vector. mllm-shap treats the estimand as first-class metadata so archived runs remain interpretable months later.

Majority game — non-additive divergence

Three players; payoff is 1 when at least two are present. Interaction between players means Shapley and Banzhaf generally disagree — a realistic sanity check before trusting LLM attributions.

NUM_PLAYERS = 3

masks = [tuple(bits) for bits in product([False, True], repeat=NUM_PLAYERS)]


def majority_payoff(mask: tuple[bool, ...]) -> float:
    """One when at least two players are present."""
    return 1.0 if sum(mask) >= 2 else 0.0


payoffs = [majority_payoff(mask) for mask in masks]
print("coalitions:", len(masks))
print("first payoffs:", payoffs[:4])
coalitions: 8
first payoffs: [0.0, 0.0, 0.0, 1.0]

Aggregator labels and coalition weights

Each aggregator exposes its estimand and the coalition weight formula it applies. Weights explain why identical samples can yield different player scores.

shapley_agg = ShapleyAggregator()
banzhaf_agg = BanzhafAggregator()

print("labels:", shapley_agg.estimand, banzhaf_agg.estimand)

for coalition_size in range(NUM_PLAYERS):
    print(
        f"size {coalition_size}:",
        "shapley",
        round(shapley_agg.coalition_weight(coalition_size, NUM_PLAYERS), 4),
        "banzhaf",
        round(banzhaf_agg.coalition_weight(coalition_size, NUM_PLAYERS), 4),
    )
labels: shapley banzhaf
size 0: shapley 0.3333 banzhaf 0.25
size 1: shapley 0.1667 banzhaf 0.25
size 2: shapley 0.3333 banzhaf 0.25

Aggregate identical samples

Product teams comparing vendors should demand this check: same coalitions, same payoffs, different estimands → different attributions unless the game is additive.

shapley = shapley_agg.aggregate(masks, payoffs)
banzhaf = banzhaf_agg.aggregate(masks, payoffs)

print("Shapley:", shapley)
print("Banzhaf:", banzhaf)
assert shapley != banzhaf
Shapley: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333]
Banzhaf: [0.5, 0.5, 0.5]

Additive game — when estimands agree

For linear value functions, both estimands recover the underlying coefficients. This is the case where estimand choice is less consequential — useful when explaining the feature to non-technical stakeholders.

ADDITIVE_PLAYERS = 2
coefficients = (1.0, 2.0)
additive_masks = [
    tuple(bits) for bits in product([False, True], repeat=ADDITIVE_PLAYERS)
]
additive_payoffs = [
    sum(coef for coef, present in zip(coefficients, mask, strict=True) if present)
    for mask in additive_masks
]

additive_shapley = shapley_agg.aggregate(additive_masks, additive_payoffs)
additive_banzhaf = banzhaf_agg.aggregate(additive_masks, additive_payoffs)

print("additive Shapley:", additive_shapley)
print("additive Banzhaf:", additive_banzhaf)
print("match:", additive_shapley == additive_banzhaf == list(coefficients))
additive Shapley: [1.0, 2.0]
additive Banzhaf: [1.0, 2.0]
match: True

Label explain results

ExplainResult stores the estimand beside the attribution vector so dashboards and exports cannot silently swap definitions.

shapley_result = ExplainResult(
    estimand=Estimand.SHAPLEY,
    values=tuple(shapley),
)
banzhaf_result = ExplainResult(
    estimand=Estimand.BANZHAF,
    values=tuple(banzhaf),
)

print(shapley_result.estimand, shapley_result.values)
print(banzhaf_result.estimand, banzhaf_result.values)
assert shapley_result.estimand != banzhaf_result.estimand
shapley (0.3333333333333333, 0.3333333333333333, 0.3333333333333333)
banzhaf (0.5, 0.5, 0.5)

Persist estimand metadata in run manifests

Run archives serialize the estimand as a stable wire value for JSON manifests — the hook compliance teams use to verify what was shipped to production.

manifest = RunManifest(estimand=Estimand.SHAPLEY, run_id="toy-majority")
payload = manifest.to_dict()
restored = parse_manifest(payload)

print("wire:", payload)
print("round-trip estimand:", restored.estimand)
print("estimand_to_wire:", estimand_to_wire(Estimand.BANZHAF))
assert restored.estimand is Estimand.SHAPLEY
assert restored.run_id == "toy-majority"
wire: {'schema_version': 4, 'mllm_shap_version': '0.6.0', 'run_id': 'toy-majority', 'snapshot_id': '', 'estimand': 'shapley', 'estimator': '', 'value_fn': '', 'normalizer': '', 'absence_policy': '', 'players': '', 'backend_kind': '', 'model_id': '', 'player_ids': [], 'embedding_source': 'none', 'embedding_model': '', 'config': {}}
round-trip estimand: shapley
estimand_to_wire: banzhaf

Resolve estimand plugins from packaging entry points

Downstream apps select shapley or banzhaf by name through the plugin registry — the same lookup the explanation.estimand key of a run configuration goes through.

registry = PluginRegistry()
registry.load_entry_points(PluginGroup.ESTIMANDS)

print("registered:", registry.names(PluginGroup.ESTIMANDS))
# Every plugin is built with a PluginContext that carries the run configuration.
context = PluginContext(
    config=ExplainConfig.model_validate({
        "backend": {"kind": "mock", "model_id": "stub"}
    }),
    registry=registry,
)
resolved_shapley = registry.resolve(PluginGroup.ESTIMANDS, "shapley", context)
resolved_banzhaf = registry.resolve(PluginGroup.ESTIMANDS, "banzhaf", context)

type(resolved_shapley).__name__, type(resolved_banzhaf).__name__
registered: ('banzhaf', 'shapley')
('ShapleyAggregator', 'BanzhafAggregator')