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.
@@ -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"]