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.
- cds_interpret/__init__.py +106 -0
- cds_interpret/__main__.py +5 -0
- cds_interpret/_rlbot.py +186 -0
- cds_interpret/_version.py +1 -0
- cds_interpret/analysis.py +244 -0
- cds_interpret/attribution.py +525 -0
- cds_interpret/behavior.py +181 -0
- cds_interpret/cli.py +130 -0
- cds_interpret/crosscoder.py +192 -0
- cds_interpret/curriculum.py +190 -0
- cds_interpret/demo/__init__.py +260 -0
- cds_interpret/demo/demo_policy.pt +0 -0
- cds_interpret/dict_eval.py +160 -0
- cds_interpret/effects.py +194 -0
- cds_interpret/emergence.py +534 -0
- cds_interpret/erasure.py +105 -0
- cds_interpret/errors.py +53 -0
- cds_interpret/frames.py +289 -0
- cds_interpret/hooks.py +276 -0
- cds_interpret/intervene.py +232 -0
- cds_interpret/labels.py +556 -0
- cds_interpret/moments.py +276 -0
- cds_interpret/names.py +297 -0
- cds_interpret/overlay/__init__.py +49 -0
- cds_interpret/overlay/bot.py +166 -0
- cds_interpret/overlay/pack.py +244 -0
- cds_interpret/overlay/panel.py +600 -0
- cds_interpret/overlay/panel_example.toml +97 -0
- cds_interpret/overlay/worker.py +204 -0
- cds_interpret/policy.py +383 -0
- cds_interpret/probes.py +575 -0
- cds_interpret/py.typed +0 -0
- cds_interpret/readout.py +217 -0
- cds_interpret/record/__init__.py +20 -0
- cds_interpret/record/_builder.py +111 -0
- cds_interpret/record/rlbot.py +160 -0
- cds_interpret/record/rlgym_env.py +105 -0
- cds_interpret/record/scripted.py +52 -0
- cds_interpret/record/sim.py +278 -0
- cds_interpret/report.py +336 -0
- cds_interpret/rlgym.py +133 -0
- cds_interpret/sae.py +373 -0
- cds_interpret/store.py +171 -0
- cds_interpret/study.py +781 -0
- cds_interpret/tracking.py +61 -0
- cds_interpret/viz/__init__.py +6 -0
- cds_interpret/viz/field.py +217 -0
- cds_interpret/viz/gallery.py +55 -0
- cds_interpret/viz/plots.py +217 -0
- cds_interpret-0.1.0.dist-info/METADATA +194 -0
- cds_interpret-0.1.0.dist-info/RECORD +55 -0
- cds_interpret-0.1.0.dist-info/WHEEL +4 -0
- cds_interpret-0.1.0.dist-info/entry_points.txt +2 -0
- cds_interpret-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|
cds_interpret/_rlbot.py
ADDED
|
@@ -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}
|