contextattr 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contextattr/__init__.py +22 -0
- contextattr/__main__.py +7 -0
- contextattr/cache.py +64 -0
- contextattr/cli.py +181 -0
- contextattr/evaluate.py +137 -0
- contextattr/methods/__init__.py +27 -0
- contextattr/methods/base.py +86 -0
- contextattr/methods/indices.py +59 -0
- contextattr/methods/interactions.py +215 -0
- contextattr/methods/kernelshap.py +114 -0
- contextattr/methods/linear.py +104 -0
- contextattr/methods/loo.py +31 -0
- contextattr/prompt/__init__.py +9 -0
- contextattr/prompt/io.py +222 -0
- contextattr/prompt/render.py +70 -0
- contextattr/prompt/split.py +153 -0
- contextattr/prompt/units.py +271 -0
- contextattr/report.py +120 -0
- contextattr/responses/__init__.py +6 -0
- contextattr/responses/checks.py +156 -0
- contextattr/responses/sample.py +36 -0
- contextattr/result.py +120 -0
- contextattr/run.py +306 -0
- contextattr/targets/__init__.py +7 -0
- contextattr/targets/sampled.py +58 -0
- contextattr/targets/scored.py +177 -0
- contextattr/value.py +201 -0
- contextattr/wrapper/__init__.py +9 -0
- contextattr/wrapper/check.py +163 -0
- contextattr/wrapper/contract.py +166 -0
- contextattr/wrapper/template.py +56 -0
- contextattr/wrapper/toy.py +129 -0
- contextattr-0.1.0.dist-info/METADATA +129 -0
- contextattr-0.1.0.dist-info/RECORD +38 -0
- contextattr-0.1.0.dist-info/WHEEL +5 -0
- contextattr-0.1.0.dist-info/entry_points.txt +2 -0
- contextattr-0.1.0.dist-info/licenses/LICENSE +21 -0
- contextattr-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The interaction methods (docs/methods.md), through shapiq, the extra [interactions]: Faith-Banzhaf and Faith-Shap
|
|
2
|
+
(every interaction up to an order; small n), SPEX (sparse interactions up to a degree, 5 unless set; large n) and
|
|
3
|
+
ProxySPEX (sparse, about a tenth of SPEX's masks). shapiq chooses its own masks and asks the value of each through
|
|
4
|
+
the game it is given.
|
|
5
|
+
|
|
6
|
+
Each gives its answer as Faith-Banzhaf (FBII) coefficients: at an order at least the surrogate's degree they are its
|
|
7
|
+
Möbius coefficients, the product basis Surrogate uses, so predictions, R², removal and the indices of App. C apply as
|
|
8
|
+
for the per-unit methods (Faith-Shap keeps its own FSII coefficients, a product-basis polynomial too). shapiq is
|
|
9
|
+
imported only when one of these runs: its import takes 25–45 s and galois's first codes about 15 s more on a shared
|
|
10
|
+
two-CPU machine (measured 2026-11-06), which `import contextattr` should not wait for."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import itertools
|
|
15
|
+
import random
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .base import Method, Surrogate, check_budget
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def _global_seed(seed: int):
|
|
25
|
+
"""Seed numpy's and Python's global generators for one approximation, and give the caller's states back after.
|
|
26
|
+
SPEX's codes and delays are drawn by sparse_transform from numpy's global generator, which shapiq's random_state
|
|
27
|
+
does not reach: unseeded, the same seed gave other masks each run (found 2026-11-07, when a re-run found none of
|
|
28
|
+
them in the cache). The game is called inside, so a wrapper drawing from these generators draws seeded values."""
|
|
29
|
+
np_state, py_state = np.random.get_state(), random.getstate()
|
|
30
|
+
np.random.seed(seed)
|
|
31
|
+
random.seed(seed)
|
|
32
|
+
try:
|
|
33
|
+
yield
|
|
34
|
+
finally:
|
|
35
|
+
np.random.set_state(np_state)
|
|
36
|
+
random.setstate(py_state)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ShapiqMethod(Method):
|
|
40
|
+
"""A shapiq approximator run against the game; `make(shapiq, n, seed)` builds it, `finish` reads its answer."""
|
|
41
|
+
|
|
42
|
+
index = "FBII"
|
|
43
|
+
|
|
44
|
+
def __init__(self, max_order: int = 2):
|
|
45
|
+
if isinstance(max_order, bool) or not isinstance(max_order, int) or max_order < 1:
|
|
46
|
+
raise ValueError(f"{type(self).__name__}: max_order must be a positive integer")
|
|
47
|
+
self.max_order = max_order
|
|
48
|
+
|
|
49
|
+
def make(self, shapiq, n: int, seed: int):
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
def check(self, approximator, n: int, budget: int) -> None:
|
|
53
|
+
"""Called before the first value is asked: refuse what shapiq changed on its own."""
|
|
54
|
+
|
|
55
|
+
def run(self, game, n: int, budget: int, rng: np.random.Generator) -> tuple[Surrogate, int]:
|
|
56
|
+
check_budget(self.name, budget, 2)
|
|
57
|
+
import shapiq # noqa: PLC0415 - imported only here, see the module's note
|
|
58
|
+
|
|
59
|
+
asked = []
|
|
60
|
+
seed = int(rng.integers(2 ** 31))
|
|
61
|
+
approximator = self.make(shapiq, n, seed)
|
|
62
|
+
|
|
63
|
+
def g(masks):
|
|
64
|
+
if not asked:
|
|
65
|
+
self.check(approximator, n, budget)
|
|
66
|
+
masks = np.array(masks, dtype=bool)
|
|
67
|
+
values = np.asarray(game(masks), dtype=float)
|
|
68
|
+
asked.append((masks, values))
|
|
69
|
+
return values
|
|
70
|
+
|
|
71
|
+
with _global_seed(seed):
|
|
72
|
+
values = approximator.approximate(budget, g)
|
|
73
|
+
return self.finish(values, n, asked), int(sum(len(m) for m, _ in asked))
|
|
74
|
+
|
|
75
|
+
def finish(self, values, n: int, asked: list) -> Surrogate:
|
|
76
|
+
"""The surrogate from shapiq's answer; `asked` holds every (masks, values) batch shapiq asked for."""
|
|
77
|
+
return from_interaction_values(values, n)
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"{type(self).__name__}(max_order={self.max_order})"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FaithBanzhaf(ShapiqMethod):
|
|
84
|
+
"""Faith-Banzhaf (Tsai 2023): the best polynomial of order max_order under uniform weights over masks. shapiq's
|
|
85
|
+
RegressionFBII draws the masks. With `ridge` (the default since 2026-11-09), they are fitted as SPEX does (B.3:
|
|
86
|
+
cross-validated ridge regression; B.1: scikit-learn's RidgeCV), by `ridge_fit`; but when every one of the 2ⁿ masks
|
|
87
|
+
was asked, by shapiq's least squares, which then gives the index exactly (ridge could only shrink it). Least squares
|
|
88
|
+
on sampled masks (`ridge=False`) overfits as the terms near the masks. With noise of 0.3 of the function's spread, on
|
|
89
|
+
the same masks (measured 2026-11-08): R² −0.08 against ridge's 0.79 at n = 31, order 3 (4,992 terms on 5,376 masks),
|
|
90
|
+
where ridge took 9 min on one CPU against 5 s; within 0.005 of ridge at 2,017 terms or fewer on 5,376–6,912 masks."""
|
|
91
|
+
|
|
92
|
+
name = "faithbanzhaf"
|
|
93
|
+
|
|
94
|
+
def __init__(self, max_order: int = 2, ridge: bool = True):
|
|
95
|
+
super().__init__(max_order=max_order)
|
|
96
|
+
self.ridge = bool(ridge)
|
|
97
|
+
|
|
98
|
+
def make(self, shapiq, n, seed):
|
|
99
|
+
return shapiq.RegressionFBII(n, max_order=self.max_order, random_state=seed)
|
|
100
|
+
|
|
101
|
+
def finish(self, values, n, asked):
|
|
102
|
+
masks = np.concatenate([m for m, _ in asked])
|
|
103
|
+
if not self.ridge or len(np.unique(masks, axis=0)) == 2 ** n:
|
|
104
|
+
return super().finish(values, n, asked)
|
|
105
|
+
return ridge_fit(masks, np.concatenate([v for _, v in asked]), self.max_order)
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str: # always names the fit: before 2026-11-09, FaithBanzhaf(max_order=2) meant least squares
|
|
108
|
+
return f"FaithBanzhaf(max_order={self.max_order}, ridge={self.ridge})"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FaithShap(ShapiqMethod):
|
|
112
|
+
"""Faith-Shap (Tsai 2023): the best polynomial of order max_order under the Shapley kernel's weights."""
|
|
113
|
+
|
|
114
|
+
name = "faithshap"
|
|
115
|
+
index = "FSII"
|
|
116
|
+
|
|
117
|
+
def make(self, shapiq, n, seed):
|
|
118
|
+
return shapiq.RegressionFSII(n, max_order=self.max_order, random_state=seed)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SPEX(ShapiqMethod):
|
|
122
|
+
"""SPEX (Kang 2025, §5): sparse Fourier recovery with masks from channel codes; interactions up to `degree`
|
|
123
|
+
(SPEX's t), reported in full as FBII of that order, so the surrogate is SPEX's own. `chase_depth` is the soft
|
|
124
|
+
decoder's Chase depth (App. A, Algorithm 3, which does not give its value): by default (since 2026-11-09) 2t, as
|
|
125
|
+
the SPEX authors' spectralexplain, whose predictions this SPEX then gives on the same masks (measured 2026-11-08 in
|
|
126
|
+
8 of 8 noisy cases; with t, shapiq's own, R² was 0.003–0.070 lower there, and each fit 1–11 s faster).
|
|
127
|
+
`chase_depth=degree` is shapiq's SPEX exactly (sparse_transform takes no depth as t)."""
|
|
128
|
+
|
|
129
|
+
name = "spex"
|
|
130
|
+
|
|
131
|
+
def __init__(self, degree: int = 5, chase_depth: int | None = None):
|
|
132
|
+
super().__init__(max_order=degree)
|
|
133
|
+
if chase_depth is not None and (isinstance(chase_depth, bool) or not isinstance(chase_depth, int)
|
|
134
|
+
or chase_depth < 1):
|
|
135
|
+
raise ValueError("SPEX: chase_depth must be a positive integer, or None for 2·degree")
|
|
136
|
+
self.chase_depth = 2 * degree if chase_depth is None else chase_depth
|
|
137
|
+
|
|
138
|
+
def make(self, shapiq, n, seed):
|
|
139
|
+
approximator = shapiq.SPEX(n=n, max_order=self.max_order, index="FBII", degree_parameter=self.max_order,
|
|
140
|
+
random_state=seed)
|
|
141
|
+
from sparse_transform.qsft.utils.query import get_bch_decoder # noqa: PLC0415 - a shapiq dependency
|
|
142
|
+
approximator.decoder_args["source_decoder"] = get_bch_decoder(n, self.max_order, "soft",
|
|
143
|
+
chase_depth=self.chase_depth)
|
|
144
|
+
return approximator
|
|
145
|
+
|
|
146
|
+
def check(self, approximator, n, budget):
|
|
147
|
+
"""shapiq lowers the code's degree t when the budget is too small for it (at n = 20, measured 2026-11-06: to 2
|
|
148
|
+
at 300 masks, to 4 at 600, none from 1,200), which would explain at a degree other than the one named."""
|
|
149
|
+
t = getattr(approximator, "degree_parameter", self.max_order)
|
|
150
|
+
if t < self.max_order:
|
|
151
|
+
raise ValueError(f"SPEX: a budget of {budget} masks is too small for degree {self.max_order} at n = {n} "
|
|
152
|
+
f"(shapiq would lower it to {t}); give a larger budget (SPEX Table 2) or "
|
|
153
|
+
f"SPEX(degree={t})")
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str: # always names the depth: before 2026-11-09, SPEX(degree=5) meant depth t
|
|
156
|
+
return f"SPEX(degree={self.max_order}, chase_depth={self.chase_depth})"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ProxySPEX(ShapiqMethod):
|
|
160
|
+
"""ProxySPEX (Butler 2025): gradient-boosted trees fitted to uniform masks, their Fourier transform read off the
|
|
161
|
+
trees. With `hpo` (shapiq's default) LightGBM is chosen by 5-fold cross-validation over depth 3 or 5, 500 or 1,000
|
|
162
|
+
trees and learning rate 0.01 or 0.1, shapiq's grid for Butler 2025's proxy (scikit-learn's GridSearchCV); without
|
|
163
|
+
it, LightGBM's own defaults (100 trees of up to 31 leaves, any depth). LightGBM takes as many threads as the
|
|
164
|
+
process may use CPUs, whatever OMP_NUM_THREADS says (LOKY_MAX_CPU_COUNT limits it)."""
|
|
165
|
+
|
|
166
|
+
name = "proxyspex"
|
|
167
|
+
|
|
168
|
+
def __init__(self, max_order: int = 5, hpo: bool = True):
|
|
169
|
+
super().__init__(max_order=max_order)
|
|
170
|
+
self.hpo = bool(hpo)
|
|
171
|
+
|
|
172
|
+
def make(self, shapiq, n, seed):
|
|
173
|
+
return shapiq.ProxySPEX(n=n, max_order=self.max_order, index="FBII", hpo=self.hpo, random_state=seed)
|
|
174
|
+
|
|
175
|
+
def __repr__(self) -> str:
|
|
176
|
+
return f"ProxySPEX(max_order={self.max_order}, hpo={self.hpo})"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def ridge_fit(masks, values, order: int) -> Surrogate:
|
|
180
|
+
"""Every term up to `order` fitted by scikit-learn's RidgeCV, the penalty chosen by leave-one-out among 100 from
|
|
181
|
+
1e-6 to 1e6, the intercept unpenalised. The fit is in the ±1 basis z = 1 − 2m (orthonormal under uniform masks, so
|
|
182
|
+
the penalty shrinks every term alike); its terms are then written in the product basis Surrogate uses:
|
|
183
|
+
Π_{i∈S} (1 − 2mᵢ) = Σ_{T⊆S} (−2)^|T| Π_{i∈T} mᵢ."""
|
|
184
|
+
from sklearn.linear_model import RidgeCV # noqa: PLC0415 - as LIME and ContextCite import theirs
|
|
185
|
+
|
|
186
|
+
masks = np.asarray(masks, dtype=bool)
|
|
187
|
+
n = masks.shape[1]
|
|
188
|
+
sets = [list(s) for k in range(1, order + 1) for s in itertools.combinations(range(n), k)]
|
|
189
|
+
z = 1.0 - 2.0 * masks
|
|
190
|
+
x = np.empty((len(masks), len(sets)))
|
|
191
|
+
for j, s in enumerate(sets):
|
|
192
|
+
x[:, j] = z[:, s].prod(axis=1)
|
|
193
|
+
model = RidgeCV(alphas=np.logspace(-6, 6, 100)).fit(x, np.asarray(values, dtype=float))
|
|
194
|
+
terms = {(): float(model.intercept_)}
|
|
195
|
+
for s, c in zip(sets, model.coef_):
|
|
196
|
+
for k in range(len(s) + 1):
|
|
197
|
+
for t in itertools.combinations(s, k):
|
|
198
|
+
terms[t] = terms.get(t, 0.0) + (-2.0) ** k * float(c)
|
|
199
|
+
intercept = terms.pop(())
|
|
200
|
+
scores = np.zeros(n)
|
|
201
|
+
for t in [t for t in terms if len(t) == 1]:
|
|
202
|
+
scores[t[0]] = terms.pop(t)
|
|
203
|
+
return Surrogate(intercept=intercept, scores=scores, interactions={t: w for t, w in terms.items() if w != 0.0})
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def from_interaction_values(values, n: int) -> Surrogate:
|
|
207
|
+
"""A Surrogate from shapiq's InteractionValues of a product-basis index (FBII, FSII): () is the intercept, single
|
|
208
|
+
units the scores, larger sets the interactions."""
|
|
209
|
+
terms = {tuple(int(i) for i in s): float(v) for s, v in values.dict_values.items()}
|
|
210
|
+
intercept = terms.pop((), float(getattr(values, "baseline_value", 0.0)))
|
|
211
|
+
scores = np.zeros(n)
|
|
212
|
+
for s in [s for s in terms if len(s) == 1]:
|
|
213
|
+
scores[s[0]] = terms.pop(s)
|
|
214
|
+
return Surrogate(intercept=intercept, scores=scores,
|
|
215
|
+
interactions={s: w for s, w in terms.items() if s and w != 0.0})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""KernelSHAP (Lundberg & Lee 2017; SPEX B.3): Shapley values as the weighted least-squares fit of the Shapley kernel,
|
|
2
|
+
with the empty and the full prompt fitted exactly (efficiency: the scores add up to f(1) − f(0)).
|
|
3
|
+
|
|
4
|
+
The masks are drawn as the shap library's KernelExplainer draws them. The kernel puts most weight on the smallest and
|
|
5
|
+
the largest coalitions, so whole size groups are enumerated first, sizes 1 and n − 1, then 2 and n − 2, …, while the
|
|
6
|
+
budget holds them; each enumerated mask gets its kernel weight π(s) = (n − 1) / (C(n, s)·s·(n − s)). The rest of the
|
|
7
|
+
budget is drawn from the sizes left, size s with probability ∝ C(n, s)·π(s), each mask beside its complement (paired
|
|
8
|
+
sampling), and these masks share the kernel weight of the sizes left equally. A budget of 2^n enumerates every mask
|
|
9
|
+
and gives the exact Shapley values; one of 2n + 2 already completes sizes 1 and n − 1, so the fit is always determined.
|
|
10
|
+
|
|
11
|
+
The kernel's denominator C(n, s)·s·(n − s) outgrows numpy's 64-bit integers from n = 57, and C(n, s) a float from
|
|
12
|
+
n ≈ 1,030, so the weights come from Python's exact integers: a size group's mass C(n, s)·π(s) = (n − 1) / (s·(n − s))
|
|
13
|
+
needs no C(n, s) at all, and the weights of masks from elsewhere are taken in logarithms, relative to the largest
|
|
14
|
+
(the fit does not change with their scale).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import itertools
|
|
20
|
+
from math import comb, exp, log
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from .base import Method, Surrogate, check_budget, least_squares
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KernelSHAP(Method):
|
|
28
|
+
name = "kernelshap"
|
|
29
|
+
|
|
30
|
+
def __init__(self, paired: bool = True):
|
|
31
|
+
self.paired = bool(paired)
|
|
32
|
+
self._design: tuple[np.ndarray, np.ndarray] | None = None
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def least_budget(n: int) -> int:
|
|
36
|
+
return 2 if n == 1 else min(2 ** n, 2 * n + 2)
|
|
37
|
+
|
|
38
|
+
def masks(self, n: int, budget: int, rng: np.random.Generator) -> np.ndarray:
|
|
39
|
+
check_budget(self.name, budget, self.least_budget(n))
|
|
40
|
+
rows, weights = [np.zeros(n, dtype=bool), np.ones(n, dtype=bool)], [0.0, 0.0]
|
|
41
|
+
left = budget - 2
|
|
42
|
+
done: set[int] = set()
|
|
43
|
+
for s in range(1, n // 2 + 1): # the kernel's mass of a size group falls as s nears n/2
|
|
44
|
+
group = [s] if 2 * s == n else [s, n - s]
|
|
45
|
+
count = sum(comb(n, k) for k in group)
|
|
46
|
+
if count > left:
|
|
47
|
+
break
|
|
48
|
+
for k in group:
|
|
49
|
+
for subset in itertools.combinations(range(n), k):
|
|
50
|
+
row = np.zeros(n, dtype=bool)
|
|
51
|
+
row[list(subset)] = True
|
|
52
|
+
rows.append(row)
|
|
53
|
+
weights.append(_kernel(n, k))
|
|
54
|
+
left -= count
|
|
55
|
+
done.update(group)
|
|
56
|
+
sizes = [k for k in range(1, n) if k not in done]
|
|
57
|
+
draws = left - left % 2 if self.paired else left # every drawn mask beside its complement: an even count
|
|
58
|
+
if sizes and draws > 0:
|
|
59
|
+
mass = np.array([(n - 1) / (k * (n - k)) for k in sizes]) # C(n, k)·π(k), Python's integers
|
|
60
|
+
drawn = []
|
|
61
|
+
while len(drawn) < draws:
|
|
62
|
+
row = np.zeros(n, dtype=bool)
|
|
63
|
+
row[rng.permutation(n)[: rng.choice(sizes, p=mass / mass.sum())]] = True
|
|
64
|
+
drawn.append(row)
|
|
65
|
+
if self.paired:
|
|
66
|
+
drawn.append(~row)
|
|
67
|
+
rows += drawn
|
|
68
|
+
weights += [mass.sum() / len(drawn)] * len(drawn)
|
|
69
|
+
masks = np.array(rows)
|
|
70
|
+
self._design = (masks.copy(), np.array(weights))
|
|
71
|
+
return masks
|
|
72
|
+
|
|
73
|
+
def fit(self, masks: np.ndarray, values: np.ndarray) -> Surrogate:
|
|
74
|
+
masks, values = np.asarray(masks, dtype=bool), np.asarray(values, dtype=float)
|
|
75
|
+
n = masks.shape[1]
|
|
76
|
+
size = masks.sum(axis=1)
|
|
77
|
+
empty, full = np.flatnonzero(size == 0), np.flatnonzero(size == n)
|
|
78
|
+
if not len(empty) or not len(full):
|
|
79
|
+
raise ValueError("KernelSHAP needs the empty and the full prompt among its masks")
|
|
80
|
+
f0, f1 = values[empty[0]], values[full[0]]
|
|
81
|
+
if n == 1:
|
|
82
|
+
return Surrogate(intercept=f0, scores=np.array([f1 - f0]))
|
|
83
|
+
if self._design is not None and np.array_equal(self._design[0], masks):
|
|
84
|
+
weights = self._design[1]
|
|
85
|
+
else: # masks from elsewhere: each with its kernel weight (right for every mask, or for uniform draws)
|
|
86
|
+
weights = kernel_weights(n, size)
|
|
87
|
+
inner = (size > 0) & (size < n) & (weights > 0)
|
|
88
|
+
x, y, w = masks[inner].astype(float), values[inner] - f0, weights[inner]
|
|
89
|
+
# efficiency, Σφ = f(1) − f(0), eliminates the last score: y − x_n Δ = Σ_{i<n} φ_i (x_i − x_n)
|
|
90
|
+
delta = f1 - f0
|
|
91
|
+
design = x[:, :-1] - x[:, -1:]
|
|
92
|
+
phi = least_squares(design, y - x[:, -1] * delta, w, refuse=(
|
|
93
|
+
f"KernelSHAP: these masks leave the {n} scores undetermined; give a budget of at least "
|
|
94
|
+
f"{self.least_budget(n)}"))
|
|
95
|
+
return Surrogate(intercept=f0, scores=np.append(phi, delta - phi.sum()))
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"KernelSHAP(paired={self.paired})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _kernel(n: int, s: int) -> float:
|
|
102
|
+
"""The Shapley kernel's weight π(s) of one coalition of size s, 0 < s < n, for the sizes enumerated whole (C(n, s)
|
|
103
|
+
at most the budget)."""
|
|
104
|
+
n, s = int(n), int(s)
|
|
105
|
+
return (n - 1) / (comb(n, s) * s * (n - s))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def kernel_weights(n: int, sizes) -> np.ndarray:
|
|
109
|
+
"""π(s) of masks of these sizes relative to the largest among them, from log π(s) (exact integers inside the
|
|
110
|
+
logarithm); 0 for the empty and the full mask."""
|
|
111
|
+
n = int(n)
|
|
112
|
+
logs = {k: log(n - 1) - log(comb(n, k)) - log(k) - log(n - k) for k in {int(s) for s in sizes} if 0 < k < n}
|
|
113
|
+
top = max(logs.values(), default=0.0)
|
|
114
|
+
return np.array([exp(logs[int(s)] - top) if 0 < s < n else 0.0 for s in sizes])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Linear fits on random masks (docs/methods.md): Banzhaf, LIME and ContextCite. Each fits f(m) ≈ b + Σ_i w_i·m_i;
|
|
2
|
+
they differ in which masks they draw and how they fit. scikit-learn is imported only when ContextCite or LIME fits
|
|
3
|
+
(its import takes long on a busy machine, and `import contextattr` should not wait for it)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .base import Method, Surrogate, all_masks, check_budget, least_squares
|
|
10
|
+
|
|
11
|
+
EXACT_UP_TO = 16 # Banzhaf enumerates every mask when the budget covers all 2^n of them and n is at most this
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Banzhaf(Method):
|
|
15
|
+
"""Banzhaf values: least squares on masks drawn uniformly (each unit kept with probability ½), which is
|
|
16
|
+
Faith-Banzhaf at order 1 (SPEX B.3, eq. 32). With a budget of at least 2^n masks every mask is used once and the
|
|
17
|
+
values are exact. Masks that leave the fit undetermined (too few, or units always kept or removed together) are
|
|
18
|
+
refused rather than fitted."""
|
|
19
|
+
|
|
20
|
+
name = "banzhaf"
|
|
21
|
+
|
|
22
|
+
def masks(self, n: int, budget: int, rng: np.random.Generator) -> np.ndarray:
|
|
23
|
+
check_budget(self.name, budget, n + 1)
|
|
24
|
+
if n <= EXACT_UP_TO and budget >= 2 ** n:
|
|
25
|
+
return all_masks(n)
|
|
26
|
+
return rng.random((budget, n)) < 0.5
|
|
27
|
+
|
|
28
|
+
def fit(self, masks: np.ndarray, values: np.ndarray) -> Surrogate:
|
|
29
|
+
x = np.hstack([np.ones((len(masks), 1)), np.asarray(masks, dtype=float)])
|
|
30
|
+
coef = least_squares(x, np.asarray(values, dtype=float), refuse=(
|
|
31
|
+
f"Banzhaf: these {len(x)} masks leave the {x.shape[1] - 1} scores undetermined (too few masks, or units "
|
|
32
|
+
f"always kept or removed together); give a larger budget"))
|
|
33
|
+
return Surrogate(intercept=coef[0], scores=coef[1:])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContextCite(Method):
|
|
37
|
+
"""ContextCite (Cohen-Wang 2024 §3): a Lasso on masks that keep each unit with probability ½, so most scores are
|
|
38
|
+
exactly 0. alpha = 0.01 as in ContextCite. With standardize (the default) the Lasso fits the values divided by
|
|
39
|
+
their spread, so alpha means the same whatever the target's units; standardize=False fits the raw values, as
|
|
40
|
+
ContextCite does with its log-odds target."""
|
|
41
|
+
|
|
42
|
+
name = "contextcite"
|
|
43
|
+
|
|
44
|
+
def __init__(self, alpha: float = 0.01, standardize: bool = True):
|
|
45
|
+
if not alpha > 0:
|
|
46
|
+
raise ValueError("ContextCite: alpha must be positive")
|
|
47
|
+
self.alpha, self.standardize = float(alpha), bool(standardize)
|
|
48
|
+
|
|
49
|
+
def masks(self, n: int, budget: int, rng: np.random.Generator) -> np.ndarray:
|
|
50
|
+
check_budget(self.name, budget, 2)
|
|
51
|
+
return rng.random((budget, n)) < 0.5
|
|
52
|
+
|
|
53
|
+
def fit(self, masks: np.ndarray, values: np.ndarray) -> Surrogate:
|
|
54
|
+
x, y = np.asarray(masks, dtype=float), np.asarray(values, dtype=float)
|
|
55
|
+
mean, spread = y.mean(), y.std()
|
|
56
|
+
if spread == 0:
|
|
57
|
+
return Surrogate(intercept=mean, scores=np.zeros(x.shape[1]))
|
|
58
|
+
from sklearn.linear_model import Lasso # noqa: PLC0415
|
|
59
|
+
|
|
60
|
+
scale = spread if self.standardize else 1.0
|
|
61
|
+
model = Lasso(alpha=self.alpha, fit_intercept=True, max_iter=100_000, tol=1e-10)
|
|
62
|
+
model.fit(x, (y - mean) / scale)
|
|
63
|
+
return Surrogate(intercept=mean + scale * model.intercept_, scores=scale * model.coef_)
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"ContextCite(alpha={self.alpha}, standardize={self.standardize})"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LIME(Method):
|
|
70
|
+
"""LIME (Ribeiro 2016) as its text explainer does it: the full prompt, then masks that each remove a number of
|
|
71
|
+
units drawn uniformly from 1 to n; each mask weighted by sqrt(exp(−d²/width²)) of its cosine distance d (×100) to
|
|
72
|
+
the full prompt, width 25; a weighted ridge fit (alpha 1) on every unit (no feature selection)."""
|
|
73
|
+
|
|
74
|
+
name = "lime"
|
|
75
|
+
|
|
76
|
+
def __init__(self, kernel_width: float = 25.0, alpha: float = 1.0):
|
|
77
|
+
if not kernel_width > 0 or not alpha >= 0:
|
|
78
|
+
raise ValueError("LIME: kernel_width must be positive and alpha at least 0")
|
|
79
|
+
self.kernel_width, self.alpha = float(kernel_width), float(alpha)
|
|
80
|
+
|
|
81
|
+
def masks(self, n: int, budget: int, rng: np.random.Generator) -> np.ndarray:
|
|
82
|
+
check_budget(self.name, budget, 2)
|
|
83
|
+
out = np.ones((budget, n), dtype=bool)
|
|
84
|
+
for row in out[1:]:
|
|
85
|
+
row[rng.choice(n, size=rng.integers(1, n + 1), replace=False)] = False
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
def weights(self, masks: np.ndarray) -> np.ndarray:
|
|
89
|
+
kept = np.asarray(masks, dtype=float).sum(axis=1)
|
|
90
|
+
n = masks.shape[1]
|
|
91
|
+
cosine = np.sqrt(kept / n) # the cosine of a 0/1 mask with the all-ones vector
|
|
92
|
+
distance = (1 - cosine) * 100
|
|
93
|
+
return np.sqrt(np.exp(-(distance ** 2) / self.kernel_width ** 2))
|
|
94
|
+
|
|
95
|
+
def fit(self, masks: np.ndarray, values: np.ndarray) -> Surrogate:
|
|
96
|
+
from sklearn.linear_model import Ridge # noqa: PLC0415
|
|
97
|
+
|
|
98
|
+
x, y = np.asarray(masks, dtype=float), np.asarray(values, dtype=float)
|
|
99
|
+
model = Ridge(alpha=self.alpha, fit_intercept=True)
|
|
100
|
+
model.fit(x, y, sample_weight=self.weights(masks))
|
|
101
|
+
return Surrogate(intercept=model.intercept_, scores=model.coef_)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
return f"LIME(kernel_width={self.kernel_width}, alpha={self.alpha})"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Leave-one-out (ContextCite's baseline, SPEX §2): each unit's score is how much the value falls when that unit
|
|
2
|
+
alone is removed from the full prompt. n + 1 masks; the budget does not apply."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .base import Method, Surrogate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LeaveOneOut(Method):
|
|
12
|
+
name = "loo"
|
|
13
|
+
|
|
14
|
+
def masks(self, n: int, budget: int | None = None, rng=None) -> np.ndarray:
|
|
15
|
+
return np.vstack([np.ones((1, n), dtype=bool), ~np.eye(n, dtype=bool)])
|
|
16
|
+
|
|
17
|
+
def fit(self, masks: np.ndarray, values: np.ndarray) -> Surrogate:
|
|
18
|
+
masks, values = np.asarray(masks, dtype=bool), np.asarray(values, dtype=float)
|
|
19
|
+
n = masks.shape[1]
|
|
20
|
+
full = np.flatnonzero(masks.all(axis=1))
|
|
21
|
+
if not len(full):
|
|
22
|
+
raise ValueError("leave-one-out needs the full prompt among its masks")
|
|
23
|
+
f_full = values[full[0]]
|
|
24
|
+
scores = np.empty(n)
|
|
25
|
+
for i in range(n):
|
|
26
|
+
row = np.flatnonzero((masks.sum(axis=1) == n - 1) & ~masks[:, i])
|
|
27
|
+
if not len(row):
|
|
28
|
+
raise ValueError(f"leave-one-out needs the mask without unit {i}")
|
|
29
|
+
scores[i] = f_full - values[row[0]]
|
|
30
|
+
# the additive model anchored at the full prompt: f̂(1) = f(1), f̂(1 − e_i) = f(1 − e_i)
|
|
31
|
+
return Surrogate(intercept=f_full - scores.sum(), scores=scores)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The input: prompts, units, splitting, masking and prompts.jsonl (docs/interface.md §2)."""
|
|
2
|
+
|
|
3
|
+
from .io import prompt_from_dict, prompt_to_dict, read_prompts, write_prompts
|
|
4
|
+
from .render import as_mask, render
|
|
5
|
+
from .split import KINDS, split
|
|
6
|
+
from .units import LABELS, Group, Message, Piece, Prompt, Response, Unit
|
|
7
|
+
|
|
8
|
+
__all__ = ["KINDS", "LABELS", "Group", "Message", "Piece", "Prompt", "Response", "Unit", "as_mask",
|
|
9
|
+
"prompt_from_dict", "prompt_to_dict", "read_prompts", "render", "split", "write_prompts"]
|