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 in nlp-shap 0.1.1 — aggregators, coalition weights, labelled results, manifest wire format, and plugin resolution.
Prerequisites: nlp-shap 0.1.1+, 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 nlp_shap import (
BanzhafAggregator,
Estimand,
ExplainResult,
RunManifest,
ShapleyAggregator,
parse_manifest,
)
from nlp_shap.domain.estimands import estimand_to_wire
from nlp_shap.plugins import PluginGroup, PluginRegistry
Why the estimand label matters¶
Regulators and enterprise buyers increasingly ask which cooperative-game value was computed, not only the attribution vector. nlp-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:
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: {'estimand': 'shapley', 'run_id': 'toy-majority'}
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 mechanism production configs will use.
registry = PluginRegistry()
registry.load_entry_points(PluginGroup.ESTIMANDS)
print("registered:", registry.names(PluginGroup.ESTIMANDS))
resolved_shapley = registry.resolve(PluginGroup.ESTIMANDS, "shapley")
resolved_banzhaf = registry.resolve(PluginGroup.ESTIMANDS, "banzhaf")
type(resolved_shapley).__name__, type(resolved_banzhaf).__name__
registered: ('banzhaf', 'shapley')
('ShapleyAggregator', 'BanzhafAggregator')