cds-interpret 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.
Files changed (55) hide show
  1. cds_interpret/__init__.py +106 -0
  2. cds_interpret/__main__.py +5 -0
  3. cds_interpret/_rlbot.py +186 -0
  4. cds_interpret/_version.py +1 -0
  5. cds_interpret/analysis.py +244 -0
  6. cds_interpret/attribution.py +525 -0
  7. cds_interpret/behavior.py +181 -0
  8. cds_interpret/cli.py +130 -0
  9. cds_interpret/crosscoder.py +192 -0
  10. cds_interpret/curriculum.py +190 -0
  11. cds_interpret/demo/__init__.py +260 -0
  12. cds_interpret/demo/demo_policy.pt +0 -0
  13. cds_interpret/dict_eval.py +160 -0
  14. cds_interpret/effects.py +194 -0
  15. cds_interpret/emergence.py +534 -0
  16. cds_interpret/erasure.py +105 -0
  17. cds_interpret/errors.py +53 -0
  18. cds_interpret/frames.py +289 -0
  19. cds_interpret/hooks.py +276 -0
  20. cds_interpret/intervene.py +232 -0
  21. cds_interpret/labels.py +556 -0
  22. cds_interpret/moments.py +276 -0
  23. cds_interpret/names.py +297 -0
  24. cds_interpret/overlay/__init__.py +49 -0
  25. cds_interpret/overlay/bot.py +166 -0
  26. cds_interpret/overlay/pack.py +244 -0
  27. cds_interpret/overlay/panel.py +600 -0
  28. cds_interpret/overlay/panel_example.toml +97 -0
  29. cds_interpret/overlay/worker.py +204 -0
  30. cds_interpret/policy.py +383 -0
  31. cds_interpret/probes.py +575 -0
  32. cds_interpret/py.typed +0 -0
  33. cds_interpret/readout.py +217 -0
  34. cds_interpret/record/__init__.py +20 -0
  35. cds_interpret/record/_builder.py +111 -0
  36. cds_interpret/record/rlbot.py +160 -0
  37. cds_interpret/record/rlgym_env.py +105 -0
  38. cds_interpret/record/scripted.py +52 -0
  39. cds_interpret/record/sim.py +278 -0
  40. cds_interpret/report.py +336 -0
  41. cds_interpret/rlgym.py +133 -0
  42. cds_interpret/sae.py +373 -0
  43. cds_interpret/store.py +171 -0
  44. cds_interpret/study.py +781 -0
  45. cds_interpret/tracking.py +61 -0
  46. cds_interpret/viz/__init__.py +6 -0
  47. cds_interpret/viz/field.py +217 -0
  48. cds_interpret/viz/gallery.py +55 -0
  49. cds_interpret/viz/plots.py +217 -0
  50. cds_interpret-0.1.0.dist-info/METADATA +194 -0
  51. cds_interpret-0.1.0.dist-info/RECORD +55 -0
  52. cds_interpret-0.1.0.dist-info/WHEEL +4 -0
  53. cds_interpret-0.1.0.dist-info/entry_points.txt +2 -0
  54. cds_interpret-0.1.0.dist-info/licenses/LICENSE +21 -0
  55. cds_interpret-0.1.0.dist-info/licenses/THIRD_PARTY.md +33 -0
@@ -0,0 +1,106 @@
1
+ """cds-interpret: look inside any PyTorch Rocket League bot.
2
+
3
+ The interpretability toolkit from the Cat Distribution System, on its own. It answers three questions about a bot:
4
+ what does it *know* (can "the ball is about to go in my goal" be read from its network?), what *features* has its
5
+ network learned, and what *happens* when you change one of them - and it can show the answers live, in the real
6
+ game, while the bot plays.
7
+
8
+ import cds_interpret as ci
9
+
10
+ policy = ci.Policy(net, obs=my_obs, actions=my_table) # bring your own network (or ci.demo.policy())
11
+ moments = ci.record.rocketsim(policy, games=20) # or record the real game, or your RLGym env
12
+ study = ci.Study(policy, moments)
13
+ print(study.probes().summary()) # what each layer knows, against baselines
14
+ study.features("mlp.3") # a sparse autoencoder's features of a layer
15
+ study.report("report.html")
16
+
17
+ Three tiers, by what you have of the bot - every tier works for any bot:
18
+
19
+ 1. **Watch** (game state only): concept labels, habits, drawings - for any bot you can watch.
20
+ 2. **Open** (its PyTorch network): probes, sparse-autoencoder features, edits, reports, the live overlay.
21
+ 3. **Run** (it plays in RocketSim or your RLGym env): record its own play at will, causal tests in whole games,
22
+ concepts followed across training snapshots.
23
+
24
+ Start with docs/quickstart.md.
25
+ """
26
+
27
+ from . import (
28
+ analysis,
29
+ behavior,
30
+ curriculum,
31
+ demo,
32
+ emergence,
33
+ frames,
34
+ hooks,
35
+ intervene,
36
+ labels,
37
+ names,
38
+ overlay,
39
+ probes,
40
+ record,
41
+ report,
42
+ rlgym,
43
+ sae,
44
+ store,
45
+ tracking,
46
+ viz,
47
+ )
48
+ from ._version import __version__
49
+ from .errors import CDSInterpretError, LayerNotFound, MeshesNotFound, MissingDependency, ShapeError
50
+ from .frames import Frames, from_arrays, from_rlbot_packet, from_rlgym
51
+ from .hooks import LayerTap, list_layers, run_layers, sequential_layers
52
+ from .labels import LABEL_KIND, concept_labels, describe_label, future_labels, register_label, short_label
53
+ from .moments import Moments, Tracks
54
+ from .policy import Policy, describe_action, eager_copy, lookup_table
55
+ from .study import FeatureSet, ProbeResults, Study
56
+
57
+ __all__ = [
58
+ "LABEL_KIND",
59
+ "CDSInterpretError",
60
+ "FeatureSet",
61
+ "Frames",
62
+ "LayerNotFound",
63
+ "LayerTap",
64
+ "MeshesNotFound",
65
+ "MissingDependency",
66
+ "Moments",
67
+ "Policy",
68
+ "ProbeResults",
69
+ "ShapeError",
70
+ "Study",
71
+ "Tracks",
72
+ "__version__",
73
+ "analysis",
74
+ "behavior",
75
+ "concept_labels",
76
+ "curriculum",
77
+ "demo",
78
+ "describe_action",
79
+ "describe_label",
80
+ "eager_copy",
81
+ "emergence",
82
+ "frames",
83
+ "from_arrays",
84
+ "from_rlbot_packet",
85
+ "from_rlgym",
86
+ "future_labels",
87
+ "hooks",
88
+ "intervene",
89
+ "labels",
90
+ "list_layers",
91
+ "lookup_table",
92
+ "names",
93
+ "overlay",
94
+ "probes",
95
+ "record",
96
+ "register_label",
97
+ "report",
98
+ "rlgym",
99
+ "run_layers",
100
+ "sae",
101
+ "sequential_layers",
102
+ "short_label",
103
+ "store",
104
+ "tracking",
105
+ "viz",
106
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,186 @@
1
+ """What the RLBot recorder and the thoughts overlay share: finding your bot's network, read-only taps on it, the
2
+ events between two packets, the ball's path from RLBot's prediction, and running code after the controls are sent.
3
+
4
+ Nothing here imports rlbot at import time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import torch
14
+
15
+ from .errors import CDSInterpretError
16
+ from .frames import from_rlbot_packet
17
+ from .labels import PATH_T
18
+
19
+
20
+ def find_network(bot: Any, network: str | Callable[[Any], torch.nn.Module] | torch.nn.Module | None) -> torch.nn.Module:
21
+ """Your bot's torch network: ``network`` as a dotted attribute path of the bot (``"agent.policy"``), a function
22
+ of the bot, a module, or None to search the bot's attributes (two levels deep) for the largest nn.Module."""
23
+ if isinstance(network, torch.nn.Module):
24
+ return network
25
+ if callable(network) and not isinstance(network, str):
26
+ net = network(bot)
27
+ elif isinstance(network, str):
28
+ net = bot
29
+ for part in network.split("."):
30
+ if not hasattr(net, part):
31
+ raise CDSInterpretError(f"the bot has no attribute {network!r} (stopped at {part!r}): pass network= the "
32
+ "dotted name of the attribute that holds your torch network, as set in "
33
+ "initialize()")
34
+ net = getattr(net, part)
35
+ else:
36
+ found: list[tuple[int, str, torch.nn.Module]] = []
37
+
38
+ def look(obj: Any, prefix: str, depth: int) -> None:
39
+ for k, v in list(getattr(obj, "__dict__", {}).items()):
40
+ if isinstance(v, torch.nn.Module):
41
+ found.append((sum(p.numel() for p in v.parameters()), prefix + k, v))
42
+ elif depth > 0 and not k.startswith("_") and hasattr(v, "__dict__") and not isinstance(v, type):
43
+ look(v, f"{prefix}{k}.", depth - 1)
44
+ look(bot, "", 1)
45
+ if not found:
46
+ raise CDSInterpretError("no torch network found on the bot: pass network= (the attribute that holds it, "
47
+ "e.g. network=\"policy\", or a function bot -> module)")
48
+ found.sort(key=lambda t: -t[0])
49
+ net = found[0][2]
50
+ if isinstance(net, torch.jit.ScriptModule):
51
+ raise CDSInterpretError("the bot's network is a TorchScript module, whose layers PyTorch cannot hook. Load it "
52
+ "as an eager module in your bot (cds_interpret.eager_copy(net) for MLP blocks).")
53
+ if not isinstance(net, torch.nn.Module):
54
+ raise CDSInterpretError(f"network {network!r} is a {type(net).__name__}, not a torch.nn.Module")
55
+ return net
56
+
57
+
58
+ class PacketEvents:
59
+ """The events of the step between two packets: touches (a car's latest touch changed), demolitions (a car's
60
+ demolition count went up; a car became demolished) and goals (a team's score went up)."""
61
+
62
+ def __init__(self) -> None:
63
+ self.prev: Any = None
64
+
65
+ def update(self, packet: Any) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]:
66
+ c = max(1, len(packet.players))
67
+ touch, made, demoed = np.zeros(c, np.float32), np.zeros(c, np.float32), np.zeros(c, np.float32)
68
+ goal = -1
69
+ prev = self.prev
70
+ if prev is not None and len(prev.players) == len(packet.players):
71
+ for j, (a, b) in enumerate(zip(prev.players, packet.players)):
72
+ ta, tb = getattr(a, "latest_touch", None), getattr(b, "latest_touch", None)
73
+ if tb is not None and (ta is None or tb.game_seconds != ta.game_seconds):
74
+ touch[j] = 1.0
75
+ da = getattr(getattr(a, "score_info", None), "demolitions", 0)
76
+ db = getattr(getattr(b, "score_info", None), "demolitions", 0)
77
+ made[j] = float(db > da)
78
+ demoed[j] = float(b.demolished_timeout > 0 and not a.demolished_timeout > 0)
79
+ for t, (ta2, tb2) in enumerate(zip(prev.teams, packet.teams)):
80
+ if tb2.score > ta2.score:
81
+ goal = t
82
+ self.prev = packet
83
+ return touch, made, demoed, goal
84
+
85
+
86
+ def packet_row(packet: Any) -> dict[str, np.ndarray]:
87
+ """One packet as a row of Frames fields (with prev_controls from each car's last input)."""
88
+ fr = from_rlbot_packet(packet)
89
+ row = {k: v[0] for k, v in fr.data.items()}
90
+ ctl = np.zeros((fr.n_cars, 8), np.float32)
91
+ for j, p in enumerate(packet.players):
92
+ li = getattr(p, "last_input", None)
93
+ if li is not None:
94
+ ctl[j] = [li.throttle, li.steer, li.pitch, li.yaw, li.roll, float(li.jump), float(li.boost),
95
+ float(li.handbrake)]
96
+ row["prev_controls"] = ctl
97
+ return row
98
+
99
+
100
+ def prediction_path(prediction: Any, now: float) -> np.ndarray | None:
101
+ """RLBot's ball prediction at labels.PATH_T seconds after ``now`` (nearest slices), [K, 3]; None without one."""
102
+ slices = getattr(prediction, "slices", None)
103
+ if not slices:
104
+ return None
105
+ ts = np.array([s.game_seconds for s in slices], np.float64) - float(now)
106
+ pos = np.array([[s.physics.location.x, s.physics.location.y, s.physics.location.z] for s in slices], np.float32)
107
+ if ts[-1] < PATH_T[-1] - 0.05:
108
+ return None
109
+ idx = np.clip(np.searchsorted(ts, PATH_T), 0, len(ts) - 1)
110
+ return pos[idx]
111
+
112
+
113
+ def landing_point(prediction: Any, step: int = 4, below: float = 120.0) -> tuple[float, float, float] | None:
114
+ """Where RLBot's prediction says the ball next comes down (first slice under ``below`` uu, every ``step``-th)."""
115
+ slices = getattr(prediction, "slices", None)
116
+ if not slices:
117
+ return None
118
+ for s in slices[::step]:
119
+ loc = s.physics.location
120
+ if loc.z < below:
121
+ return (loc.x, loc.y, loc.z)
122
+ return None
123
+
124
+
125
+ def wrap_after_decision(base: Any, name: str, after: Callable[[Any, Any], None],
126
+ before: Callable[[Any, Any], None] | None = None,
127
+ init: Callable[[Any], None] | None = None, retire: Callable[[Any], None] | None = None) -> type:
128
+ """A subclass of an RLBot v5 Bot / Hivemind class whose decisions are the parent's own, with ``before(bot,
129
+ packet)`` run just before the decision and ``after(bot, packet)`` run once its controls have been sent. Errors in
130
+ the added code are logged and switch it off; they never reach the bot."""
131
+ if not hasattr(base, "_packet_processor"):
132
+ raise CDSInterpretError(f"{base.__name__} does not look like an RLBot v5 Bot or Hivemind class (no "
133
+ "_packet_processor). cds-interpret works with the rlbot package 2.x (RLBot v5).")
134
+
135
+ def initialize(self: Any) -> None:
136
+ base.initialize(self)
137
+ self._cds_on = True
138
+ if init is not None:
139
+ try:
140
+ init(self)
141
+ except Exception as e: # the added code must never stop the bot
142
+ _log(self, f"cds-interpret: {name} off ({e})")
143
+ self._cds_on = False
144
+
145
+ def _packet_processor(self: Any, packet: Any) -> None:
146
+ on = getattr(self, "_cds_on", False)
147
+ if on and before is not None:
148
+ try:
149
+ before(self, packet)
150
+ except Exception as e: # (as above)
151
+ _log(self, f"cds-interpret: {name} stopped ({e})")
152
+ self._cds_on = on = False
153
+ base._packet_processor(self, packet) # the decision and its controls go out first
154
+ if on:
155
+ try:
156
+ after(self, packet)
157
+ except Exception as e: # (as above)
158
+ _log(self, f"cds-interpret: {name} stopped ({e})")
159
+ self._cds_on = False
160
+
161
+ def retire_(self: Any) -> None:
162
+ if retire is not None and getattr(self, "_cds_on", False):
163
+ try:
164
+ retire(self)
165
+ except Exception as e:
166
+ _log(self, f"cds-interpret: {name} could not finish ({e})")
167
+ base.retire(self)
168
+
169
+ return type(f"{base.__name__}With{name.title().replace(' ', '')}", (base,),
170
+ {"initialize": initialize, "_packet_processor": _packet_processor, "retire": retire_,
171
+ "__doc__": f"{base.__name__}, playing exactly as it does, with cds-interpret's {name}."})
172
+
173
+
174
+ def _log(bot: Any, msg: str) -> None:
175
+ logger = getattr(bot, "logger", None) or getattr(bot, "_logger", None)
176
+ if logger is not None:
177
+ logger.warning(msg)
178
+ else:
179
+ print(msg)
180
+
181
+
182
+ def our_indices(bot: Any) -> list[int]:
183
+ """The car indices the bot controls (a Bot: [index]; a Hivemind: its indices)."""
184
+ if hasattr(bot, "indices") and getattr(bot, "indices", None):
185
+ return [int(i) for i in bot.indices]
186
+ return [int(bot.index)]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,244 @@
1
+ """Reading a feature: how it relates to the labels, its top moments, and a plain description of a moment.
2
+
3
+ rel = label_relations(act, labels, kinds) # per label: AUROC (binary) or Pearson r (regression)
4
+ top = top_moments(act, k=12, groups=game_ids, min_gap=8, steps=step_ids) # the strongest, at most one a stretch
5
+ describe(frame, me) # "ours at (x, y) 1840 uu/s on the ground, 34 boost; ball ..."
6
+ guess_name(rel) # the best-matching label, as a GUESS with its score
7
+ causal_targets(features) # a causal test's target, group and controls, by a fixed rule
8
+
9
+ All game-independent except describe(), which reads a Frames moment (any bot's).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ from .frames import Frames, canon, team_sign
16
+ from .probes import auroc
17
+
18
+
19
+ def label_relations(act: np.ndarray, labels: dict[str, np.ndarray], kinds: dict[str, str],
20
+ min_pos: int = 30) -> dict[str, dict]:
21
+ """For a feature's activation act [N] (0 where it is off): per label, AUROC of act as a detector (binary labels)
22
+ or Pearson r (regression labels), on the rows where the label is defined; also, for binary labels, P(label |
23
+ feature on) against the base rate (the lift)."""
24
+ act = np.asarray(act, np.float64)
25
+ on = act > 0
26
+ out = {}
27
+ for k, y in labels.items():
28
+ y = np.asarray(y, np.float64)
29
+ v = ~np.isnan(y)
30
+ if v.sum() < 50:
31
+ continue
32
+ a, yy = act[v], y[v]
33
+ if kinds.get(k) == "bin":
34
+ pos = int((yy > 0.5).sum())
35
+ if pos < min_pos or pos == len(yy):
36
+ continue
37
+ base = pos / len(yy)
38
+ p_on = float((yy[on[v]] > 0.5).mean()) if on[v].any() else float("nan")
39
+ out[k] = {"auroc": auroc(a, yy), "base": base, "p_given_on": p_on,
40
+ "lift": p_on / base if base > 0 and np.isfinite(p_on) else float("nan"),
41
+ "recall": float((on[v] & (yy > 0.5)).sum() / pos), "coverage": float(v.mean())}
42
+ else:
43
+ if np.std(a) == 0 or np.std(yy) == 0:
44
+ continue
45
+ out[k] = {"r": float(np.corrcoef(a, yy)[0, 1]), "coverage": float(v.mean())}
46
+ return out
47
+
48
+
49
+ def relation_strength(rel: dict) -> float:
50
+ """One number per label relation, 0 = none, 1 = perfect: |2 AUROC - 1| or |r|."""
51
+ return abs(2.0 * rel["auroc"] - 1.0) if "auroc" in rel else abs(rel["r"])
52
+
53
+
54
+ def guess_name(rel: dict[str, dict], top: int = 3, min_coverage: float = 0.3) -> list[tuple[str, float, str]]:
55
+ """The labels a feature relates to most: [(label, strength, sign "+" / "-")] - a starting point for a name, not
56
+ a finding: check the top moments. Labels defined on fewer than ``min_coverage`` of the moments (``threat_t`` has
57
+ a value only when there is a threat) are left out: a relation measured on a small slice of moments makes a
58
+ misleading name."""
59
+ scored = []
60
+ for k, r in rel.items():
61
+ if r.get("coverage", 1.0) < min_coverage:
62
+ continue
63
+ s = relation_strength(r)
64
+ sign = "+" if (r.get("auroc", 0.5) >= 0.5 if "auroc" in r else r["r"] >= 0) else "-"
65
+ scored.append((k, s, sign))
66
+ return sorted(scored, key=lambda t: -t[1])[:top]
67
+
68
+
69
+ def top_moments(act: np.ndarray, k: int = 12, groups: np.ndarray | None = None, steps: np.ndarray | None = None,
70
+ min_gap: int = 8) -> np.ndarray:
71
+ """Indices of the k strongest activations, at most one per stretch of min_gap steps of the same game (so the top
72
+ list is not one moment repeated)."""
73
+ order = np.argsort(-np.asarray(act), kind="mergesort")
74
+ picked: list[int] = []
75
+ taken: dict = {}
76
+ for i in order:
77
+ if act[i] <= 0 or len(picked) >= k:
78
+ break
79
+ if groups is not None and steps is not None:
80
+ g, s = groups[i], steps[i]
81
+ if any(abs(s - t) < min_gap for t in taken.get(g, [])):
82
+ continue
83
+ taken.setdefault(g, []).append(s)
84
+ picked.append(int(i))
85
+ return np.asarray(picked, np.int64)
86
+
87
+
88
+ def _where(xy) -> str:
89
+ x, y = xy
90
+ side = "left" if x < -1200 else "right" if x > 1200 else "middle"
91
+ third = "own third" if y < -1700 else "attacking third" if y > 1700 else "midfield"
92
+ return f"{third}, {side}"
93
+
94
+
95
+ def describe(fr: Frames, me: int, t: int = 0) -> str:
96
+ """One moment (row t of fr) in plain words, from car slot me's side (its team attacks +y)."""
97
+ team = float(fr.car_team[t, me])
98
+ s = float(team_sign(np.array([team]))[0])
99
+ pos = canon(fr.car_pos[t], s)
100
+ vel = canon(fr.car_vel[t], s)
101
+ b = canon(fr.ball_pos[t], s) + 0.0 # (+ 0.0: no "-0" in the text)
102
+ bv = canon(fr.ball_vel[t], s) + 0.0
103
+ pos = pos + 0.0
104
+ mine = pos[me]
105
+
106
+ def car_txt(j):
107
+ sp = float(np.linalg.norm(vel[j]))
108
+ where = "demolished" if fr.car_demoed[t, j] > 0.5 else (
109
+ "in the air" if fr.car_on_ground[t, j] < 0.5 else ("on a wall" if pos[j, 2] > 150 else "on the ground"))
110
+ return f"({pos[j, 0]:.0f}, {pos[j, 1]:.0f}, {pos[j, 2]:.0f}) {sp:.0f} uu/s {where}, {fr.car_boost[t, j]:.0f} boost"
111
+ parts = [f"us {car_txt(me)} [{_where(mine[:2])}]"]
112
+ for j in range(fr.n_cars):
113
+ if j == me or fr.car_present[t, j] < 0.5:
114
+ continue
115
+ who = "mate" if fr.car_team[t, j] == team else "opp"
116
+ d = float(np.linalg.norm(pos[j] - mine))
117
+ parts.append(f"{who} {car_txt(j)}, {d:.0f} uu from us")
118
+ speed = float(np.linalg.norm(bv))
119
+ heading = ("toward their goal" if bv[1] > 300 else "toward our goal" if bv[1] < -300 else "across the field")
120
+ moving = "still" if speed < 50 else f"moving {speed:.0f} uu/s {heading}"
121
+ parts.append(f"ball ({b[0]:.0f}, {b[1]:.0f}, {b[2]:.0f}) {moving}, {np.linalg.norm(b - mine):.0f} uu from us")
122
+ return "; ".join(parts)
123
+
124
+
125
+ # ------------------------------------------------------------------------- many sparse features at once
126
+ def sparse_columns(idx: np.ndarray, val: np.ndarray, m: int):
127
+ """Top-k codes (idx, val [n, k]) as columns: (rows, values, starts) - feature j fires on rows[starts[j]:starts[j+1]]
128
+ with values[...] (positive activations only)."""
129
+ flat_f = idx.reshape(-1).astype(np.int64)
130
+ flat_v = val.reshape(-1).astype(np.float32)
131
+ flat_r = np.repeat(np.arange(len(idx), dtype=np.int64), idx.shape[1])
132
+ keep = flat_v > 0
133
+ f, r, v = flat_f[keep], flat_r[keep], flat_v[keep]
134
+ order = np.argsort(f, kind="stable")
135
+ f, r, v = f[order], r[order], v[order]
136
+ return r, v, np.searchsorted(f, np.arange(m + 1))
137
+
138
+
139
+ def _avg_ranks(x: np.ndarray) -> np.ndarray:
140
+ order = np.argsort(x, kind="mergesort")
141
+ _, start, counts = np.unique(x[order], return_index=True, return_counts=True)
142
+ r = np.empty(len(x))
143
+ r[order] = np.repeat(start + (counts - 1) / 2.0 + 1.0, counts)
144
+ return r
145
+
146
+
147
+ def sparse_relations(rows_nz: np.ndarray, vals_nz: np.ndarray, labels: dict[str, np.ndarray], kinds: dict[str, str],
148
+ n: int, min_pos: int = 30) -> dict[str, dict]:
149
+ """label_relations for a feature that is 0 except on rows_nz (values vals_nz) of n rows - in time proportional to
150
+ how often it fires. Same outputs: AUROC / base / P(label | on) / lift / recall, or Pearson r."""
151
+ out = {}
152
+ for k, y in labels.items():
153
+ kind = kinds.get(k)
154
+ valid = ~np.isnan(y)
155
+ nv = int(valid.sum())
156
+ if nv < 50:
157
+ continue
158
+ yn = y[rows_nz]
159
+ okn = ~np.isnan(yn)
160
+ vv, yy = vals_nz[okn], yn[okn]
161
+ if kind == "bin":
162
+ n1 = int((y[valid] > 0.5).sum())
163
+ n0 = nv - n1
164
+ if n1 < min_pos or n0 == 0:
165
+ continue
166
+ pos = yy > 0.5
167
+ nz1, nz0 = int(pos.sum()), int((~pos).sum())
168
+ z1, z0 = n1 - nz1, n0 - nz0
169
+ u_nz = (_avg_ranks(vv)[pos].sum() - nz1 * (nz1 + 1) / 2.0) if len(vv) else 0.0
170
+ u = u_nz + nz1 * z0 + 0.5 * z1 * z0
171
+ base = n1 / nv
172
+ p_on = nz1 / len(yy) if len(yy) else float("nan")
173
+ out[k] = {"auroc": float(u / (n1 * n0)), "base": base, "p_given_on": float(p_on),
174
+ "lift": float(p_on / base) if len(yy) else float("nan"), "recall": nz1 / n1,
175
+ "coverage": nv / max(n, 1)}
176
+ else:
177
+ yv = y[valid].astype(np.float64)
178
+ my, sy = yv.mean(), yv.std()
179
+ v64 = vv.astype(np.float64)
180
+ ma = v64.sum() / nv
181
+ sa = np.sqrt(max((v64 ** 2).sum() / nv - ma * ma, 0.0))
182
+ if sa == 0 or sy == 0:
183
+ continue
184
+ cov = (v64 * yy).sum() / nv - ma * my
185
+ out[k] = {"r": float(cov / (sa * sy)), "coverage": nv / max(n, 1)}
186
+ return out
187
+
188
+
189
+ # ------------------------------------------------------------------------- choosing a causal test's targets
190
+ def causal_targets(features: dict, label: str = "demo_chance", also: str = "demo_soon", min_freq: float = 5e-4,
191
+ min_lift: float = 5.0, min_auroc: float = 0.75, n_group: int = 3, n_controls: int = 3,
192
+ seed: int = 0) -> dict:
193
+ """A causal test's targets, chosen by a rule fixed BEFORE any game is played with an edit (so the result cannot
194
+ steer the choice).
195
+
196
+ features: a dictionary report's {id: {"freq", "relations": {label: {"auroc", "lift", ...}}}} (label_relations per
197
+ feature, plus how often it fires). Target = the feature that best detects `label` (held-out AUROC) among those
198
+ firing on >= min_freq of moments with lift >= min_lift and AUROC >= min_auroc; group = the best n_group such.
199
+ Without any: the cleanest features (best |2 AUROC - 1| on any yes / no label, lift >= min_lift, firing on >= 0.1%
200
+ of moments), each feature counted once, at its best label. Controls = n_controls features firing about as often
201
+ as the target (within 30%), outside the group, that detect neither `label` nor `also` (AUROC < 0.6), drawn with
202
+ `seed`. Returns {"rule", "target", "group", "controls", "candidates" (the top 10), "arms"}: the arms to play -
203
+ base, the target ablated, the group ablated, the target scaled 3x, the controls ablated."""
204
+ feats = {int(j): f for j, f in features.items()}
205
+
206
+ def score(f, lab):
207
+ r = f["relations"].get(lab, {})
208
+ return r.get("auroc", 0.5), r.get("lift", 0.0)
209
+
210
+ cands = sorted(((score(f, label)[0], j, label) for j, f in feats.items()
211
+ if f["freq"] >= min_freq and score(f, label)[1] >= min_lift and score(f, label)[0] >= min_auroc),
212
+ reverse=True)
213
+ kind = label
214
+ if not cands:
215
+ kind = "cleanest"
216
+ best: dict[int, tuple[float, str]] = {}
217
+ for j, f in feats.items():
218
+ if f["freq"] < 1e-3:
219
+ continue
220
+ for lab, r in f["relations"].items():
221
+ if "auroc" in r and r.get("lift", 0) >= min_lift:
222
+ s = abs(2 * r["auroc"] - 1)
223
+ if s > best.get(j, (-1.0, ""))[0]:
224
+ best[j] = (s, lab)
225
+ cands = sorted(((s, j, lab) for j, (s, lab) in best.items()), reverse=True)
226
+ target = cands[0][1] if cands else None
227
+ group = [c[1] for c in cands[:n_group]]
228
+ controls: list[int] = []
229
+ if target is not None:
230
+ ft = feats[target]["freq"]
231
+ pool = sorted(j for j, f in feats.items() if 0.7 * ft <= f["freq"] <= 1.3 * ft and j not in group
232
+ and score(f, label)[0] < 0.6 and score(f, also)[0] < 0.6)
233
+ if pool:
234
+ controls = sorted(int(c) for c in np.random.default_rng(seed).choice(
235
+ pool, size=min(n_controls, len(pool)), replace=False))
236
+ arms = ["base"]
237
+ if target is not None:
238
+ arms += [f"ablate:{target}", "ablate:" + ",".join(map(str, group)), f"scale3:{target}"]
239
+ if controls:
240
+ arms.append("ablate:" + ",".join(map(str, controls)))
241
+ return {"rule": kind, "target": target, "group": group, "controls": controls,
242
+ "candidates": [{"feature": c[1], "label": c[2], "score": round(float(c[0]), 4),
243
+ "score_is": "|2 AUROC - 1|" if kind == "cleanest" else "AUROC"} for c in cands[:10]],
244
+ "arms": arms}