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,22 @@
1
+ """ContextAttr: which parts of a prompt make a model give an output (docs/interface.md, docs/methods.md)."""
2
+
3
+ from .cache import Cache
4
+ from . import responses
5
+ from .evaluate import faithfulness, intervene
6
+ from .methods import LIME, Banzhaf, ContextCite, KernelSHAP, LeaveOneOut
7
+ from .prompt import Group, Message, Prompt, Response, Unit, read_prompts, split, write_prompts
8
+ from .responses import Verdict, sample
9
+ from .result import Explanation
10
+ from .run import Results, Run, attribute, explain
11
+ from .targets import Choice, Contrast, LogProb, Pooled, Sampled
12
+ from .value import Value
13
+ from .wrapper import ModelReport, Sampling, Scored, ToyModel, WrapperError, check_model
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ checks = responses # ca.checks.regex(...), ca.checks.reference(...)
18
+
19
+ __all__ = ["LIME", "Banzhaf", "Cache", "Choice", "ContextCite", "Contrast", "Explanation", "Group", "KernelSHAP",
20
+ "LeaveOneOut", "LogProb", "Message", "ModelReport", "Pooled", "Prompt", "Response", "Results", "Run", "Sampled", "Sampling",
21
+ "Scored", "ToyModel", "Unit", "Value", "Verdict", "WrapperError", "attribute", "check_model", "checks",
22
+ "explain", "faithfulness", "intervene", "read_prompts", "responses", "sample", "split", "write_prompts"]
@@ -0,0 +1,7 @@
1
+ """python -m contextattr: the command line (cli.py)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
contextattr/cache.py ADDED
@@ -0,0 +1,64 @@
1
+ """The cache: every model answer stored once, under a key made from its request (docs/interface.md §7).
2
+
3
+ One SQLite file per run, or memory when no path is given. A run that stops resumes from it, another method on the
4
+ same masks reuses its answers, and nothing is asked of the model twice. The first answer stored for a key stays.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import sqlite3
13
+ from collections.abc import Iterable, Mapping
14
+
15
+ _CHUNK = 500 # keys per SELECT, under SQLite's limit on query parameters
16
+
17
+
18
+ class Cache:
19
+ """Answers by key. `key(request)` makes the key from a JSON-able request; answers are JSON-able values."""
20
+
21
+ def __init__(self, path: str | os.PathLike | None = None):
22
+ self.path = None if path is None else os.fspath(path)
23
+ if self.path is not None and os.path.dirname(self.path):
24
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
25
+ self._db = sqlite3.connect(":memory:" if self.path is None else self.path)
26
+ self._db.execute("CREATE TABLE IF NOT EXISTS answers "
27
+ "(key TEXT PRIMARY KEY, kind TEXT NOT NULL, answer TEXT NOT NULL)")
28
+ self._db.commit()
29
+
30
+ @staticmethod
31
+ def key(request: Mapping) -> str:
32
+ """sha256 of the request as canonical JSON: the same request always gives the same key."""
33
+ text = json.dumps(request, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
34
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
35
+
36
+ def get_many(self, keys: Iterable[str]) -> dict[str, object]:
37
+ """The stored answers among `keys`."""
38
+ keys = list(dict.fromkeys(keys))
39
+ found = {}
40
+ for i in range(0, len(keys), _CHUNK):
41
+ chunk = keys[i:i + _CHUNK]
42
+ rows = self._db.execute(f"SELECT key, answer FROM answers WHERE key IN ({','.join('?' * len(chunk))})",
43
+ chunk)
44
+ found.update((k, json.loads(a)) for k, a in rows)
45
+ return found
46
+
47
+ def put_many(self, kind: str, answers: Mapping[str, object]) -> None:
48
+ """Store answers and commit at once; a key already stored keeps its first answer."""
49
+ self._db.executemany("INSERT OR IGNORE INTO answers (key, kind, answer) VALUES (?, ?, ?)",
50
+ [(k, kind, json.dumps(a, ensure_ascii=False, allow_nan=False))
51
+ for k, a in answers.items()])
52
+ self._db.commit()
53
+
54
+ def __len__(self) -> int:
55
+ return self._db.execute("SELECT COUNT(*) FROM answers").fetchone()[0]
56
+
57
+ def close(self) -> None:
58
+ self._db.close()
59
+
60
+ def __enter__(self) -> Cache:
61
+ return self
62
+
63
+ def __exit__(self, *exc) -> None:
64
+ self.close()
contextattr/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """The command line (docs/interface.md §8):
2
+
3
+ contextattr plan run.toml [--measure K] masks, requests and tokens before any call (K real requests timed)
4
+ contextattr run run.toml explain every prompt into the run directory; resumes a stopped run
5
+ contextattr report <run directory> the HTML report again, and the summary by tag
6
+ contextattr template [my_wrapper.py] a wrapper to fill in (wrapper/template.py), written or printed
7
+
8
+ run.toml names the wrapper by import path, so one run file works wherever its wrapper does:
9
+
10
+ prompts = "prompts.jsonl" # paths are relative to this file
11
+ out = "runs/rio"
12
+ [model]
13
+ use = "my_wrappers.py:MyModel" # or "package.module:Class", or "contextattr:ToyModel.demo"
14
+ args = { model_id = "…" } # given to it as keyword arguments
15
+ [run] # optional: method, budget, test_masks, seed, replace, batch_size,
16
+ method = "contextcite" # noise_repeats, removal
17
+ [target] # optional: kind = logprob | pooled | contrast | choice | sampled, with
18
+ kind = "contrast" # response, reduce, label, a, b, samples, temperature, top_p,
19
+ # max_tokens, seed
20
+ [check] # optional: regex = "…" (negative = true) or reference = "contains"
21
+ [sample] # optional: n, temperature, top_p, max_tokens, seed
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import importlib
28
+ import importlib.util
29
+ import json
30
+ import sys
31
+ import tomllib
32
+ from pathlib import Path
33
+
34
+ from .responses import reference, regex
35
+ from .run import Results, Run
36
+ from .targets import Choice, Contrast, LogProb, Pooled, Sampled
37
+ from .wrapper.contract import Sampling
38
+
39
+ TOP = {"prompts", "out", "model", "run", "target", "check", "sample"}
40
+ RUN = {"method", "budget", "test_masks", "seed", "replace", "batch_size", "noise_repeats", "removal"}
41
+ SAMPLING = {"n", "temperature", "top_p", "max_tokens", "seed", "stop"}
42
+
43
+
44
+ def load(path) -> tuple[Run, Path]:
45
+ """The Run a run.toml describes, and its run directory."""
46
+ path = Path(path)
47
+ with open(path, "rb") as f:
48
+ cfg = tomllib.load(f)
49
+ _keys(cfg, TOP, {"prompts", "out", "model"}, path.name)
50
+ base = path.parent
51
+ model = build(cfg["model"], base)
52
+ check = None
53
+ if "check" in cfg:
54
+ c = dict(cfg["check"])
55
+ if "regex" in c:
56
+ _keys(c, {"regex", "negative"}, {"regex"}, "[check]")
57
+ negative = c.get("negative", True)
58
+ if not isinstance(c["regex"], str) or not isinstance(negative, bool):
59
+ raise ValueError("[check]: regex must be a string, and negative true or false")
60
+ check = regex(c["regex"], negative=negative)
61
+ elif "reference" in c:
62
+ _keys(c, {"reference"}, {"reference"}, "[check]")
63
+ if c["reference"] not in ("contains", "exact") or not isinstance(c["reference"], str):
64
+ raise ValueError(f"[check]: reference must be \"contains\" or \"exact\", not {c['reference']!r}")
65
+ check = reference(contains=c["reference"] == "contains")
66
+ else:
67
+ raise ValueError("[check] needs regex = \"…\" or reference = \"contains\" | \"exact\"")
68
+ sample = None
69
+ if "sample" in cfg:
70
+ _keys(cfg["sample"], SAMPLING, set(), "[sample]")
71
+ sample = Sampling(**{**{"n": 8, "temperature": 0.7, "top_p": 0.95}, **cfg["sample"]})
72
+ target = _target(cfg.get("target"), check)
73
+ run_args = dict(cfg.get("run", {}))
74
+ _keys(run_args, RUN, set(), "[run]")
75
+ if "removal" in run_args:
76
+ run_args["removal"] = tuple(run_args["removal"])
77
+ run = Run(model, base / cfg["prompts"], target=target, sample=sample, check=check, **run_args)
78
+ return run, base / cfg["out"]
79
+
80
+
81
+ def build(spec: dict, base: Path = Path(".")):
82
+ """The wrapper `use` names ("module:attr" or "file.py:attr", attr may be dotted), called with `args`."""
83
+ _keys(spec, {"use", "args"}, {"use"}, "[model]")
84
+ where, _, attr = spec["use"].partition(":")
85
+ if not attr:
86
+ raise ValueError(f"[model] use must be \"module:attr\" or \"file.py:attr\", not {spec['use']!r}")
87
+ if where.endswith(".py"):
88
+ file = (base / where).resolve()
89
+ loader = importlib.util.spec_from_file_location(file.stem, file)
90
+ if loader is None:
91
+ raise ValueError(f"cannot load {file}")
92
+ module = importlib.util.module_from_spec(loader)
93
+ sys.modules.setdefault(file.stem, module)
94
+ loader.loader.exec_module(module)
95
+ else:
96
+ module = importlib.import_module(where)
97
+ obj = module
98
+ for part in attr.split("."):
99
+ obj = getattr(obj, part)
100
+ return obj(**spec.get("args", {}))
101
+
102
+
103
+ def _target(spec, check):
104
+ if spec is None:
105
+ return None
106
+ spec = dict(spec)
107
+ kind = spec.pop("kind", None)
108
+ if kind == "logprob":
109
+ _keys(spec, {"response", "reduce"}, set(), "[target]")
110
+ return LogProb(spec.get("response"), reduce=spec.get("reduce", "mean"))
111
+ if kind == "pooled":
112
+ _keys(spec, {"label", "reduce"}, set(), "[target]")
113
+ return Pooled(spec.get("label", "negative"), reduce=spec.get("reduce", "mean"))
114
+ if kind == "contrast":
115
+ _keys(spec, {"reduce"}, set(), "[target]")
116
+ return Contrast(reduce=spec.get("reduce", "mean"))
117
+ if kind == "choice":
118
+ _keys(spec, {"a", "b"}, {"a", "b"}, "[target]")
119
+ return Choice(spec["a"], spec["b"])
120
+ if kind == "sampled":
121
+ if check is None:
122
+ raise ValueError("a sampled target needs a [check]")
123
+ _keys(spec, {"samples"} | SAMPLING - {"n"}, set(), "[target]")
124
+ samples = spec.pop("samples", 4)
125
+ sampling = Sampling(**{**{"temperature": 0.7, "top_p": 0.95}, **spec, "n": samples}) if spec else None
126
+ return Sampled(check, samples=samples, sampling=sampling)
127
+ raise ValueError(f"[target] kind must be logprob, pooled, contrast, choice or sampled, not {kind!r}")
128
+
129
+
130
+ def _keys(d: dict, allowed: set, required: set, where: str) -> None:
131
+ unknown, missing = sorted(set(d) - allowed), sorted(required - set(d))
132
+ if unknown:
133
+ raise ValueError(f"{where}: unknown key {', '.join(unknown)} (allowed: {', '.join(sorted(allowed))})")
134
+ if missing:
135
+ raise ValueError(f"{where}: missing {', '.join(missing)}")
136
+
137
+
138
+ def main(argv=None) -> int:
139
+ ap = argparse.ArgumentParser(prog="contextattr", description=__doc__.split("\n\n")[0])
140
+ sub = ap.add_subparsers(dest="command", required=True)
141
+ plan = sub.add_parser("plan", help="masks, requests and tokens before any call")
142
+ plan.add_argument("config")
143
+ plan.add_argument("--measure", type=int, default=0, help="time this many real requests of the first prompt")
144
+ run = sub.add_parser("run", help="explain every prompt into the run directory")
145
+ run.add_argument("config")
146
+ report = sub.add_parser("report", help="the HTML report and the summary of a run directory")
147
+ report.add_argument("directory")
148
+ template = sub.add_parser("template", help="a wrapper to fill in around your model: written to FILE, or printed")
149
+ template.add_argument("file", nargs="?")
150
+ args = ap.parse_args(argv)
151
+ if args.command == "template":
152
+ from importlib.resources import files # noqa: PLC0415
153
+
154
+ text = files("contextattr.wrapper").joinpath("template.py").read_text(encoding="utf-8")
155
+ if args.file is None:
156
+ print(text, end="")
157
+ return 0
158
+ try:
159
+ with open(args.file, "x", encoding="utf-8") as f: # "x": an existing file is never overwritten
160
+ f.write(text)
161
+ except FileExistsError:
162
+ print(f"contextattr template: {args.file} exists; give another name", file=sys.stderr)
163
+ return 1
164
+ print(f"wrote {args.file}: fill in score and/or generate, then run it (python {args.file}) to check it")
165
+ return 0
166
+ if args.command == "plan":
167
+ r, _ = load(args.config)
168
+ print(json.dumps(r.plan(measure=args.measure), indent=2))
169
+ elif args.command == "run":
170
+ r, out = load(args.config)
171
+ results = r.execute(out)
172
+ print(f"{len(results)} prompts explained in {out}; report: {out / 'report.html'}")
173
+ else:
174
+ results = Results.load(args.directory)
175
+ results.to_html(Path(args.directory) / "report.html")
176
+ print(json.dumps(results.summary(), indent=2))
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ sys.exit(main())
@@ -0,0 +1,137 @@
1
+ """Checks of an explanation (docs/methods.md; SPEX §6.1): how well its surrogate predicts the value on held-out
2
+ masks (faithfulness R², eq. 7), how much the value changes when the units the surrogate ranks highest are removed
3
+ (top-r removal, eq. 8), how well its largest terms fall on human-labelled units (Recovery@r, eq. 9), and what removing
4
+ units does to the behaviour itself, sampled afresh (intervention, as SPEX B.5.2 did on the trolley prompt)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import itertools
9
+ from math import comb
10
+
11
+ import numpy as np
12
+
13
+ from .methods.base import Surrogate
14
+ from .methods.indices import terms as surrogate_terms
15
+
16
+ TEST_STREAM = 7919 # the test masks' own random stream, apart from every method's
17
+ EXACT_REMOVAL = 20_000
18
+
19
+
20
+ def held_out_masks(n: int, count: int, seed: int = 0) -> np.ndarray:
21
+ """`count` masks keeping each unit with probability ½, from a stream of their own: the same for every method run
22
+ on a prompt with this seed, so their values are asked of the model once and then come from the cache."""
23
+ return np.random.default_rng([seed, TEST_STREAM]).random((count, n)) < 0.5
24
+
25
+
26
+ def faithfulness(predicted, values) -> float:
27
+ """R² = 1 − Σ(f − f̂)² / Σ(f − f̄)² over held-out masks (SPEX eq. 7): 1 when the surrogate predicts every value,
28
+ 0 for the mean alone, below 0 for worse; nan when every value is the same."""
29
+ predicted, values = np.asarray(predicted, dtype=float), np.asarray(values, dtype=float)
30
+ spread = ((values - values.mean()) ** 2).sum()
31
+ if spread == 0:
32
+ return float("nan")
33
+ return float(1 - ((values - predicted) ** 2).sum() / spread)
34
+
35
+
36
+ def removal_mask(surrogate: Surrogate, r: int) -> np.ndarray:
37
+ """m* = argmax over masks with r units removed of |f̂(1) − f̂(m)| (SPEX eq. 8): exact over all C(n, r) masks up
38
+ to 20,000 of them, else greedy, removing one unit at a time."""
39
+ n = surrogate.n
40
+ if not 0 <= r <= n:
41
+ raise ValueError(f"r must lie in 0..{n}")
42
+ full = np.ones(n, dtype=bool)
43
+ top = surrogate.predict(full[None])[0]
44
+ if comb(n, r) <= EXACT_REMOVAL:
45
+ best, best_change = full, -1.0
46
+ for chunk in _chunks(itertools.combinations(range(n), r), 4096):
47
+ masks = np.ones((len(chunk), n), dtype=bool)
48
+ for row, removed in zip(masks, chunk):
49
+ row[list(removed)] = False
50
+ change = np.abs(top - surrogate.predict(masks))
51
+ k = int(change.argmax())
52
+ if change[k] > best_change:
53
+ best, best_change = masks[k], float(change[k])
54
+ return best
55
+ mask = full.copy()
56
+ for _ in range(r):
57
+ options = [i for i in range(n) if mask[i]]
58
+ trial = np.repeat(mask[None], len(options), axis=0)
59
+ trial[np.arange(len(options)), options] = False
60
+ mask = trial[int(np.abs(top - surrogate.predict(trial)).argmax())]
61
+ return mask
62
+
63
+
64
+ def removal(surrogate: Surrogate, value, rs=(1, 2, 3)) -> list[dict]:
65
+ """Top-r removal (SPEX eq. 8): Rem(r) = |f(1) − f(m*)| / |f(1)|, with the value function asked for f(1) and each
66
+ f(m*). `change` is f(m*) − f(1), how the value moves when the units go, and `predicted_change` the surrogate's
67
+ f̂(m*) − f̂(1). Rem divides by |f(1)|, as SPEX's log-probabilities allow; for a value near 0 (a contrast) read
68
+ the change instead."""
69
+ n = surrogate.n
70
+ masks = [removal_mask(surrogate, r) for r in rs]
71
+ values = value(np.vstack([np.ones((1, n), dtype=bool), *[m[None] for m in masks]]))
72
+ f1 = values[0]
73
+ out = []
74
+ for r, m, f in zip(rs, masks, values[1:]):
75
+ out.append({"r": r, "removed": np.flatnonzero(~m).tolist(), "value": float(f), "change": float(f - f1),
76
+ "predicted_change": float(surrogate.predict(m[None])[0] - surrogate.predict(np.ones((1, n)))[0]),
77
+ "rem": float(abs(f1 - f) / abs(f1)) if f1 != 0 else float("nan")})
78
+ return out
79
+
80
+
81
+ def recovery(surrogate: Surrogate, relevant, r: int) -> float:
82
+ """Recovery@r (SPEX eq. 9): (1/r) Σ_{i ≤ r} |S* ∩ S_i| / |S_i|, the S_i being the surrogate's r most important
83
+ terms, by absolute value (single units and interactions alike), and S* the human-labelled units. Only a non-zero
84
+ term is one the method found: with fewer than r of them the slots left add 0 (a sparse fit is not credited with
85
+ terms it gave no weight, in the order of the units). Terms tied in size at the r-th place share the slots left to
86
+ them, each adding its mean, so the order of the units never decides the result."""
87
+ if isinstance(r, bool) or not isinstance(r, (int, np.integer)) or r < 1:
88
+ raise ValueError(f"r must be a positive integer, not {r!r}")
89
+ relevant = {int(i) for i in relevant}
90
+ terms = surrogate_terms(surrogate)
91
+ if not all(np.isfinite(w) for w in terms.values()):
92
+ raise ValueError("recovery needs finite terms: the surrogate has a NaN or infinite one")
93
+ found = sorted(((abs(w), len(relevant & set(s)) / len(s)) for s, w in terms.items()
94
+ if s and w != 0), key=lambda t: -t[0])
95
+ if len(found) <= r:
96
+ return float(sum(h for _, h in found) / r)
97
+ edge = found[r - 1][0]
98
+ above = [h for size, h in found if size > edge]
99
+ tied = [h for size, h in found if size == edge]
100
+ return float((sum(above) + (r - len(above)) * sum(tied) / len(tied)) / r)
101
+
102
+
103
+ def _chunks(iterable, size):
104
+ chunk = []
105
+ for item in iterable:
106
+ chunk.append(item)
107
+ if len(chunk) == size:
108
+ yield chunk
109
+ chunk = []
110
+ if chunk:
111
+ yield chunk
112
+
113
+
114
+ def intervene(prompt, model, check, drop, *, samples: int = 64, sampling=None, replace: str | None = None,
115
+ cache=None) -> dict:
116
+ """Remove units and sample again: the mean of the check's values (the share flagged negative, for a labelling
117
+ check) over `samples` fresh replies to the full prompt and to the prompt without `drop` (unit or group ids), the
118
+ change, and its standard error. Needs only the wrapper's generate."""
119
+ import math # noqa: PLC0415
120
+
121
+ from .responses.checks import verdict # noqa: PLC0415
122
+ from .targets.sampled import Sampled # noqa: PLC0415
123
+ from .value import Value # noqa: PLC0415
124
+
125
+ v = Value(prompt, model, Sampled(check, samples=samples, sampling=sampling), replace=replace, cache=cache)
126
+ replies = v.replies(np.vstack([np.ones(prompt.n, dtype=bool), prompt.mask(drop=drop)]))
127
+ rates = []
128
+ for rs in replies:
129
+ values = [x for x in (verdict(check(prompt, r)).value for r in rs) if x is not None]
130
+ if not values:
131
+ raise ValueError("the check labelled none of the replies")
132
+ mean = math.fsum(values) / len(values)
133
+ spread = float(np.std(values, ddof=1)) if len(values) > 1 else 0.0
134
+ rates.append((mean, spread / math.sqrt(len(values)), len(values)))
135
+ (before, se0, k0), (after, se1, k1) = rates
136
+ return {"dropped": [drop] if isinstance(drop, str) else list(drop), "before": before, "after": after,
137
+ "change": after - before, "se": math.hypot(se0, se1), "replies": [k0, k1], "requests": dict(v.stats)}
@@ -0,0 +1,27 @@
1
+ """Which masks to evaluate and which fit (docs/methods.md). A method is named or given as an object."""
2
+
3
+ from .base import Method, Surrogate, all_masks
4
+ from .indices import banzhaf, shapley, shapley_taylor, terms
5
+ from .interactions import SPEX, FaithBanzhaf, FaithShap, ProxySPEX, ShapiqMethod
6
+ from .kernelshap import KernelSHAP
7
+ from .linear import LIME, Banzhaf, ContextCite
8
+ from .loo import LeaveOneOut
9
+
10
+ METHODS = {"loo": LeaveOneOut, "banzhaf": Banzhaf, "lime": LIME, "contextcite": ContextCite,
11
+ "kernelshap": KernelSHAP, "faithbanzhaf": FaithBanzhaf, "faithshap": FaithShap, "spex": SPEX,
12
+ "proxyspex": ProxySPEX}
13
+
14
+
15
+ def get_method(method) -> Method:
16
+ """A Method from its name (loo, banzhaf, lime, contextcite, kernelshap, faithbanzhaf, faithshap, spex,
17
+ proxyspex) or the object itself."""
18
+ if isinstance(method, Method):
19
+ return method
20
+ if isinstance(method, str) and method in METHODS:
21
+ return METHODS[method]()
22
+ raise ValueError(f"method must be one of {', '.join(METHODS)} or a Method, not {method!r}")
23
+
24
+
25
+ __all__ = ["LIME", "METHODS", "SPEX", "Banzhaf", "ContextCite", "FaithBanzhaf", "FaithShap", "KernelSHAP",
26
+ "LeaveOneOut", "Method", "ProxySPEX", "ShapiqMethod", "Surrogate", "all_masks", "banzhaf", "get_method",
27
+ "shapley", "shapley_taylor", "terms"]
@@ -0,0 +1,86 @@
1
+ """What every method is (docs/methods.md): a design of masks to evaluate, and a fit of the surrogate whose terms
2
+ are the scores. The surrogate predicts the value of any mask, which is how its faithfulness is measured (eq. 7)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import itertools
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from types import MappingProxyType
10
+
11
+ import numpy as np
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Surrogate:
16
+ """f̂(m) = intercept + Σ_i scores[i]·m_i + Σ_S interactions[S]·Π_{i∈S} m_i, for masks of 0/1. A unit's score is
17
+ its effect alone; an interaction S, a tuple of two or more unit positions, is what the units do together."""
18
+
19
+ intercept: float
20
+ scores: np.ndarray
21
+ interactions: Mapping[tuple[int, ...], float] = field(default_factory=dict)
22
+
23
+ def __post_init__(self) -> None:
24
+ scores = np.asarray(self.scores, dtype=float).copy()
25
+ scores.flags.writeable = False
26
+ object.__setattr__(self, "scores", scores)
27
+ object.__setattr__(self, "intercept", float(self.intercept))
28
+ object.__setattr__(self, "interactions", MappingProxyType(
29
+ {tuple(int(i) for i in s): float(w) for s, w in dict(self.interactions).items()}))
30
+
31
+ @property
32
+ def n(self) -> int:
33
+ return len(self.scores)
34
+
35
+ def predict(self, masks) -> np.ndarray:
36
+ m = np.asarray(masks, dtype=float)
37
+ out = self.intercept + m @ self.scores
38
+ for s, w in self.interactions.items():
39
+ out = out + w * m[:, list(s)].prod(axis=1)
40
+ return out
41
+
42
+
43
+ class Method:
44
+ """A method: `masks(n, budget, rng)` designs the masks to evaluate, `fit(masks, values)` gives the surrogate, and
45
+ `run(game, n, budget, rng)` does both against a game (masks → values). A method that chooses its masks as it
46
+ goes (the shapiq ones) overrides `run` instead."""
47
+
48
+ name = "method"
49
+
50
+ def masks(self, n: int, budget: int, rng: np.random.Generator) -> np.ndarray:
51
+ raise NotImplementedError
52
+
53
+ def fit(self, masks: np.ndarray, values: np.ndarray) -> Surrogate:
54
+ raise NotImplementedError
55
+
56
+ def run(self, game, n: int, budget: int, rng: np.random.Generator) -> tuple[Surrogate, int]:
57
+ """The surrogate, and how many masks it took."""
58
+ masks = self.masks(n, budget, rng)
59
+ return self.fit(masks, game(masks)), len(masks)
60
+
61
+ def __repr__(self) -> str:
62
+ return f"{type(self).__name__}()"
63
+
64
+
65
+ def all_masks(n: int) -> np.ndarray:
66
+ """Every mask of n units (2^n rows), the empty one first and the full one last."""
67
+ return np.array(list(itertools.product([False, True], repeat=n)), dtype=bool).reshape(2 ** n, n)
68
+
69
+
70
+ def check_budget(name: str, budget: int, least: int) -> None:
71
+ if isinstance(budget, bool) or not isinstance(budget, (int, np.integer)) or budget < least:
72
+ raise ValueError(f"{name} needs a budget of at least {least} masks, not {budget!r}")
73
+
74
+
75
+ def least_squares(x: np.ndarray, y: np.ndarray, w: np.ndarray | None = None, *, refuse: str | None = None
76
+ ) -> np.ndarray:
77
+ """The (weighted) least-squares coefficients of y on the columns of x. With `refuse`, a ValueError of that message
78
+ when the rows leave the coefficients undetermined (numerical rank below the number of columns), where lstsq would
79
+ silently give one fit of many."""
80
+ if w is not None:
81
+ root = np.sqrt(w)
82
+ x, y = x * root[:, None], y * root
83
+ coef, _, rank, _ = np.linalg.lstsq(x, y, rcond=None)
84
+ if refuse is not None and rank < x.shape[1]:
85
+ raise ValueError(refuse)
86
+ return coef
@@ -0,0 +1,59 @@
1
+ """One surrogate read as the classic indices (SPEX App. C, Table 1). A surrogate's terms are Möbius coefficients a(S):
2
+ f̂(m) = Σ_S a(S)·Π_{i∈S} m_i, the intercept being a(∅). From them, exactly:
3
+
4
+ Shapley value φ_i = Σ_{S∋i} a(S) / |S|
5
+ Banzhaf value β_i = Σ_{S∋i} a(S) / 2^(|S|−1)
6
+ Shapley-Taylor of order k: a(S) for |S| < k, and Σ_{T⊇S} a(T) / C(|T|, k) for |S| = k (App. C, eq. 58)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from math import comb
12
+
13
+ import numpy as np
14
+
15
+ from .base import Surrogate
16
+
17
+
18
+ def terms(surrogate: Surrogate) -> dict[tuple[int, ...], float]:
19
+ """Every Möbius term of the surrogate: () → intercept, (i,) → score, S → interaction."""
20
+ out = {(): surrogate.intercept}
21
+ out.update({(i,): float(w) for i, w in enumerate(surrogate.scores)})
22
+ out.update(surrogate.interactions)
23
+ return out
24
+
25
+
26
+ def shapley(surrogate: Surrogate) -> np.ndarray:
27
+ phi = np.zeros(surrogate.n)
28
+ for s, a in terms(surrogate).items():
29
+ for i in s:
30
+ phi[i] += a / len(s)
31
+ return phi
32
+
33
+
34
+ def banzhaf(surrogate: Surrogate) -> np.ndarray:
35
+ beta = np.zeros(surrogate.n)
36
+ for s, a in terms(surrogate).items():
37
+ for i in s:
38
+ beta[i] += a / 2 ** (len(s) - 1)
39
+ return beta
40
+
41
+
42
+ def shapley_taylor(surrogate: Surrogate, order: int = 2) -> dict[tuple[int, ...], float]:
43
+ """The Shapley-Taylor interaction index of the given order, every set of at most `order` units that has a value."""
44
+ if order < 1:
45
+ raise ValueError("order must be at least 1")
46
+ out: dict[tuple[int, ...], float] = {}
47
+ for t, a in terms(surrogate).items():
48
+ if len(t) < order:
49
+ out[t] = out.get(t, 0.0) + a
50
+ else:
51
+ share = a / comb(len(t), order)
52
+ for s in _subsets(t, order):
53
+ out[s] = out.get(s, 0.0) + share
54
+ return out
55
+
56
+
57
+ def _subsets(t: tuple[int, ...], k: int):
58
+ from itertools import combinations
59
+ return combinations(t, k)