caseload 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.
caseload/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Budgeted fraud investigation under selective labels and regime drift."""
2
+
3
+ from .mdp import (
4
+ EXPLORE_GRID,
5
+ OBS_DIM,
6
+ OBS_NAMES,
7
+ PACE_GRID,
8
+ Episode,
9
+ InvestigationMDP,
10
+ RolloutResult,
11
+ Round,
12
+ StepRecord,
13
+ )
14
+ from .policies import (
15
+ EpsilonExplore,
16
+ RandomGrid,
17
+ RandomPolicy,
18
+ TopK,
19
+ YieldTriggered,
20
+ oracle_ceiling,
21
+ rollout,
22
+ )
23
+ from .scorers import GradientBoostScorer, LogisticScorer
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "EXPLORE_GRID",
29
+ "OBS_DIM",
30
+ "OBS_NAMES",
31
+ "PACE_GRID",
32
+ "Episode",
33
+ "EpsilonExplore",
34
+ "GradientBoostScorer",
35
+ "InvestigationMDP",
36
+ "LogisticScorer",
37
+ "RandomGrid",
38
+ "RandomPolicy",
39
+ "RolloutResult",
40
+ "Round",
41
+ "StepRecord",
42
+ "TopK",
43
+ "YieldTriggered",
44
+ "oracle_ceiling",
45
+ "rollout",
46
+ ]
@@ -0,0 +1,5 @@
1
+ """Learned policies. Importing this needs the ``agents`` extra (torch)."""
2
+
3
+ from .ppo import PPO, ActorCritic, PPOConfig, PPOPolicy
4
+
5
+ __all__ = ["PPO", "ActorCritic", "PPOConfig", "PPOPolicy"]
caseload/agents/ppo.py ADDED
@@ -0,0 +1,197 @@
1
+ """A compact PPO for the investigation MDP, in one file.
2
+
3
+ Deliberately small. The action space is two grids of five, the observation is
4
+ fifteen numbers and an episode is a few tens of rounds, so the policy is a
5
+ two-layer MLP and the whole thing trains on a laptop CPU. At these batch sizes
6
+ Apple's MPS backend was slower than CPU when I tried it on an M1 Pro; that
7
+ comparison is not scripted here, so it is a note rather than a result. There is no
8
+ device flag.
9
+
10
+ The only non-obvious part is that the environment is expensive relative to the
11
+ policy: a round refits a gradient boosting detector, so almost all wall-clock is
12
+ environment, not gradients. Updates are therefore large and infrequent.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+ from torch.distributions import Categorical
23
+
24
+ from ..mdp import EXPLORE_GRID, OBS_DIM, PACE_GRID
25
+
26
+
27
+ @dataclass
28
+ class PPOConfig:
29
+ hidden: int = 64
30
+ lr: float = 1e-3
31
+ gamma: float = 0.995
32
+ gae_lambda: float = 0.95
33
+ clip: float = 0.2
34
+ epochs: int = 10
35
+ minibatch: int = 32
36
+ entropy_coef: float = 0.02
37
+ value_coef: float = 0.5
38
+ max_grad_norm: float = 0.5
39
+ episodes_per_update: int = 8
40
+ updates: int = 60
41
+ # the environment refits a detector every round, so it dominates wall-clock and
42
+ # a batch is only a few hundred transitions. small minibatches and more epochs
43
+ # are what turn that into enough gradient steps to actually move the policy:
44
+ # at minibatch 256 a batch is one minibatch, which is ~4 steps per update
45
+
46
+ seed: int = 0
47
+
48
+
49
+ class ActorCritic(nn.Module):
50
+ """Two independent categorical heads, one per action grid, and a value head."""
51
+
52
+ def __init__(self, obs_dim: int = OBS_DIM, hidden: int = 64) -> None:
53
+ super().__init__()
54
+ self.body = nn.Sequential(
55
+ nn.Linear(obs_dim, hidden),
56
+ nn.Tanh(),
57
+ nn.Linear(hidden, hidden),
58
+ nn.Tanh(),
59
+ )
60
+ self.pace_head = nn.Linear(hidden, len(PACE_GRID))
61
+ self.explore_head = nn.Linear(hidden, len(EXPLORE_GRID))
62
+ self.value_head = nn.Linear(hidden, 1)
63
+ # small final-layer init keeps the initial policy close to uniform, which
64
+ # matters here because a confidently wrong opening policy wastes the budget
65
+ for head in (self.pace_head, self.explore_head):
66
+ nn.init.orthogonal_(head.weight, gain=0.01)
67
+ nn.init.zeros_(head.bias)
68
+ nn.init.orthogonal_(self.value_head.weight, gain=1.0)
69
+ nn.init.zeros_(self.value_head.bias)
70
+
71
+ def forward(self, obs: torch.Tensor) -> tuple[Categorical, Categorical, torch.Tensor]:
72
+ h = self.body(obs)
73
+ return (
74
+ Categorical(logits=self.pace_head(h)),
75
+ Categorical(logits=self.explore_head(h)),
76
+ self.value_head(h).squeeze(-1),
77
+ )
78
+
79
+ @torch.no_grad()
80
+ def act(self, obs: np.ndarray, greedy: bool = False) -> tuple[int, int, float, float]:
81
+ o = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0)
82
+ dp, de, v = self(o)
83
+ if greedy:
84
+ a_p = int(dp.probs.argmax())
85
+ a_e = int(de.probs.argmax())
86
+ else:
87
+ a_p = int(dp.sample())
88
+ a_e = int(de.sample())
89
+ lp = float(dp.log_prob(torch.tensor([a_p])) + de.log_prob(torch.tensor([a_e])))
90
+ return a_p, a_e, lp, float(v)
91
+
92
+
93
+ @dataclass
94
+ class Batch:
95
+ obs: list[np.ndarray] = field(default_factory=list)
96
+ pace: list[int] = field(default_factory=list)
97
+ explore: list[int] = field(default_factory=list)
98
+ logp: list[float] = field(default_factory=list)
99
+ value: list[float] = field(default_factory=list)
100
+ reward: list[float] = field(default_factory=list)
101
+ done: list[bool] = field(default_factory=list)
102
+
103
+ def __len__(self) -> int:
104
+ return len(self.obs)
105
+
106
+ def tensors(self) -> dict[str, torch.Tensor]:
107
+ return {
108
+ "obs": torch.as_tensor(np.asarray(self.obs), dtype=torch.float32),
109
+ "pace": torch.as_tensor(self.pace, dtype=torch.long),
110
+ "explore": torch.as_tensor(self.explore, dtype=torch.long),
111
+ "logp": torch.as_tensor(self.logp, dtype=torch.float32),
112
+ "value": torch.as_tensor(self.value, dtype=torch.float32),
113
+ }
114
+
115
+
116
+ def gae(
117
+ rewards: list[float],
118
+ values: list[float],
119
+ dones: list[bool],
120
+ gamma: float,
121
+ lam: float,
122
+ ) -> tuple[np.ndarray, np.ndarray]:
123
+ """Generalised advantage estimation over a batch of concatenated episodes."""
124
+ n = len(rewards)
125
+ adv = np.zeros(n, dtype=np.float32)
126
+ last = 0.0
127
+ for i in reversed(range(n)):
128
+ nonterminal = 0.0 if dones[i] else 1.0
129
+ next_value = values[i + 1] if i + 1 < n else 0.0
130
+ delta = rewards[i] + gamma * next_value * nonterminal - values[i]
131
+ last = delta + gamma * lam * nonterminal * last
132
+ adv[i] = last
133
+ return adv, adv + np.asarray(values, dtype=np.float32)
134
+
135
+
136
+ class PPO:
137
+ def __init__(self, cfg: PPOConfig | None = None) -> None:
138
+ self.cfg = cfg or PPOConfig()
139
+ torch.manual_seed(self.cfg.seed)
140
+ self.net = ActorCritic(hidden=self.cfg.hidden)
141
+ self.opt = torch.optim.Adam(self.net.parameters(), lr=self.cfg.lr, eps=1e-5)
142
+ self.history: list[dict[str, float]] = []
143
+
144
+ def update(self, batch: Batch) -> dict[str, float]:
145
+ c = self.cfg
146
+ adv, ret = gae(batch.reward, batch.value, batch.done, c.gamma, c.gae_lambda)
147
+ t = batch.tensors()
148
+ adv_t = torch.as_tensor(adv)
149
+ adv_t = (adv_t - adv_t.mean()) / (adv_t.std() + 1e-8)
150
+ ret_t = torch.as_tensor(ret)
151
+
152
+ n = len(batch)
153
+ idx = np.arange(n)
154
+ stats: dict[str, float] = {}
155
+ for _ in range(c.epochs):
156
+ np.random.shuffle(idx)
157
+ for start in range(0, n, c.minibatch):
158
+ mb = idx[start : start + c.minibatch]
159
+ dp, de, v = self.net(t["obs"][mb])
160
+ logp = dp.log_prob(t["pace"][mb]) + de.log_prob(t["explore"][mb])
161
+ ratio = torch.exp(logp - t["logp"][mb])
162
+ a = adv_t[mb]
163
+ unclipped = ratio * a
164
+ clipped = torch.clamp(ratio, 1 - c.clip, 1 + c.clip) * a
165
+ pg_loss = -torch.min(unclipped, clipped).mean()
166
+ v_loss = ((v - ret_t[mb]) ** 2).mean()
167
+ ent = (dp.entropy() + de.entropy()).mean()
168
+ loss = pg_loss + c.value_coef * v_loss - c.entropy_coef * ent
169
+ self.opt.zero_grad()
170
+ loss.backward()
171
+ nn.utils.clip_grad_norm_(self.net.parameters(), c.max_grad_norm)
172
+ self.opt.step()
173
+ with torch.no_grad():
174
+ stats = {
175
+ "pg_loss": pg_loss.item(),
176
+ "value_loss": v_loss.item(),
177
+ "entropy": ent.item(),
178
+ "approx_kl": (t["logp"][mb] - logp).mean().abs().item(),
179
+ }
180
+ self.history.append(stats)
181
+ return stats
182
+
183
+
184
+ class PPOPolicy:
185
+ """Wraps a trained network so it plugs into ``caseload.policies.rollout``."""
186
+
187
+ def __init__(self, net: ActorCritic, greedy: bool = True, name: str = "ppo") -> None:
188
+ self.net = net
189
+ self.greedy = greedy
190
+ self.name = name
191
+
192
+ def act(self, obs: np.ndarray) -> tuple[int, int]:
193
+ p, e, _, _ = self.net.act(obs, greedy=self.greedy)
194
+ return p, e
195
+
196
+ def reset(self) -> None:
197
+ return None
@@ -0,0 +1,5 @@
1
+ """Episode sources: a procedural drift simulator for training, Elliptic for evaluation."""
2
+
3
+ from .drift import DriftConfig, DriftScenario, make_episode
4
+
5
+ __all__ = ["DriftConfig", "DriftScenario", "make_episode"]
caseload/envs/drift.py ADDED
@@ -0,0 +1,177 @@
1
+ """A procedural fraud-drift simulator, used for training.
2
+
3
+ The real dataset in this package, the Elliptic transaction graph, contains exactly
4
+ one regime break. One break is one trajectory, and you cannot fit a policy to one
5
+ trajectory without simply memorising it. So policies are trained here, on randomly
6
+ generated drift scenarios, and evaluated on the real break they have never seen.
7
+
8
+ Fraud is a mixture of modes in feature space. At a break, some modes stop emitting
9
+ and new ones start. Nothing about the feature vectors announces this: the only
10
+ visible symptom is that the detector's yield falls, and the only way to recover is
11
+ to spend budget on cases the detector does not rank highly, which costs recall now
12
+ in exchange for a detector that still works later.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from ..mdp import Episode, Round
22
+
23
+
24
+ @dataclass
25
+ class DriftConfig:
26
+ n_rounds: int = 20
27
+ warm_rounds: int = 8
28
+ pool_lo: int = 400
29
+ pool_hi: int = 1200
30
+ dim: int = 24
31
+ base_rate_lo: float = 0.02
32
+ base_rate_hi: float = 0.10
33
+ n_modes: int = 3
34
+ mode_scale: float = 1.15
35
+ mode_spread: float = 0.55
36
+ break_lo_frac: float = 0.45
37
+ break_hi_frac: float = 0.75
38
+ n_breaks: int = 1
39
+ mode_turnover: float = 1.0
40
+ label_noise: float = 0.0
41
+ adversarial_break: bool = True
42
+ adversarial_candidates: int = 40
43
+
44
+
45
+ class DriftScenario:
46
+ """One randomly drawn drift world. Call :meth:`episode` to materialise it."""
47
+
48
+ def __init__(self, cfg: DriftConfig, seed: int) -> None:
49
+ self.cfg = cfg
50
+ self.rng = np.random.default_rng(seed)
51
+ self.seed = seed
52
+ c = cfg
53
+ self.base_rate = float(self.rng.uniform(c.base_rate_lo, c.base_rate_hi))
54
+ total = c.warm_rounds + c.n_rounds
55
+ lo = c.warm_rounds + int(c.break_lo_frac * c.n_rounds)
56
+ hi = c.warm_rounds + int(c.break_hi_frac * c.n_rounds)
57
+ self.breaks = sorted(
58
+ int(self.rng.integers(lo, max(lo + 1, hi + 1))) for _ in range(max(c.n_breaks, 0))
59
+ )
60
+ # a bank of fraud modes; regimes pick disjoint-ish subsets of them
61
+ n_bank = c.n_modes * (len(self.breaks) + 1)
62
+ self.modes = self.rng.normal(0.0, c.mode_spread, size=(n_bank, c.dim)) * c.mode_scale
63
+ if c.adversarial_break and len(self.breaks) > 0:
64
+ self._hide_later_modes(n_bank)
65
+ self.regimes: list[np.ndarray] = []
66
+ for r in range(len(self.breaks) + 1):
67
+ if c.mode_turnover >= 1.0:
68
+ idx = np.arange(r * c.n_modes, (r + 1) * c.n_modes) % n_bank
69
+ else:
70
+ keep = int(round((1 - c.mode_turnover) * c.n_modes))
71
+ prev = self.regimes[-1] if self.regimes else np.arange(c.n_modes)
72
+ fresh = (np.arange(c.n_modes - keep) + (r + 1) * c.n_modes) % n_bank
73
+ idx = np.concatenate([prev[:keep], fresh])
74
+ self.regimes.append(np.asarray(idx, dtype=int))
75
+ self.total_rounds = total
76
+
77
+ def _hide_later_modes(self, n_bank: int) -> None:
78
+ """Move post-break modes to where the incumbent detector scores lowest.
79
+
80
+ Without this the new fraud mode lands somewhere the old detector still gives
81
+ a middling score, so a pure top-k policy stumbles into a few cases every
82
+ round, feeds them to the refit, and quietly heals itself. That recovers a
83
+ large share of post-break fraud with no exploration at all, which leaves
84
+ exploration nothing to contribute and makes the environment a poor model of
85
+ the real thing: on Elliptic the frozen detector finds 2 of the 169 illicit
86
+ transactions in steps 43-49 (``results/collapse.log``).
87
+
88
+ Screening candidate centres against a detector fitted on pre-break traffic
89
+ reproduces that. It is also the more realistic story, since an adversary who
90
+ adapts moves to where the current model is not looking.
91
+ """
92
+ from sklearn.ensemble import HistGradientBoostingClassifier
93
+
94
+ c = self.cfg
95
+ pre = self.modes[: c.n_modes]
96
+ # a small pre-break sample, enough to fit a screening detector
97
+ xs, ys = [], []
98
+ for _ in range(6):
99
+ k = int(self.rng.binomial(600, self.base_rate))
100
+ x = self.rng.normal(0.0, 1.0, size=(600, c.dim))
101
+ y = np.zeros(600, dtype=int)
102
+ if k:
103
+ pick = self.rng.choice(len(pre), size=k, replace=True)
104
+ x[:k] = pre[pick] + self.rng.normal(0.0, 0.42, size=(k, c.dim))
105
+ y[:k] = 1
106
+ xs.append(x)
107
+ ys.append(y)
108
+ x_all, y_all = np.concatenate(xs), np.concatenate(ys)
109
+ if int((y_all == 1).sum()) < 2:
110
+ return
111
+ screen = HistGradientBoostingClassifier(
112
+ max_iter=20, max_leaf_nodes=8, random_state=0, early_stopping=False
113
+ ).fit(x_all, y_all)
114
+
115
+ n_new = n_bank - c.n_modes
116
+ if n_new <= 0:
117
+ return
118
+ cand = (
119
+ self.rng.normal(0.0, c.mode_spread, size=(c.adversarial_candidates, c.dim))
120
+ * c.mode_scale
121
+ )
122
+ # score each candidate centre by how suspicious the old detector finds it
123
+ s = screen.predict_proba(cand)[:, 1]
124
+ # also require distance from the pre-break modes, so "hidden" does not mean
125
+ # "sitting on top of an old mode the detector happens to score low"
126
+ far = np.sqrt(((cand[:, None, :] - pre[None]) ** 2).sum(-1)).min(1)
127
+ rank = np.argsort(s - 0.05 * far)
128
+ self.modes[c.n_modes :] = cand[rank[:n_new]]
129
+
130
+ def _regime_at(self, t: int) -> int:
131
+ return int(np.searchsorted(self.breaks, t, side="right"))
132
+
133
+ def _draw(self, t: int, n: int) -> tuple[np.ndarray, np.ndarray]:
134
+ c = self.cfg
135
+ k = int(self.rng.binomial(n, self.base_rate))
136
+ x = self.rng.normal(0.0, 1.0, size=(n, c.dim))
137
+ y = np.zeros(n, dtype=int)
138
+ if k > 0:
139
+ modes = self.regimes[self._regime_at(t)]
140
+ pick = self.rng.choice(modes, size=k, replace=True)
141
+ x[:k] = self.modes[pick] + self.rng.normal(0.0, 0.42, size=(k, c.dim))
142
+ y[:k] = 1
143
+ if c.label_noise > 0:
144
+ flip = self.rng.random(n) < c.label_noise
145
+ y[flip] = 1 - y[flip]
146
+ order = self.rng.permutation(n)
147
+ return x[order], y[order]
148
+
149
+ def episode(self) -> Episode:
150
+ c = self.cfg
151
+ sizes = self.rng.integers(c.pool_lo, c.pool_hi + 1, size=self.total_rounds)
152
+ warm_x, warm_y = [], []
153
+ for t in range(c.warm_rounds):
154
+ x, y = self._draw(t, int(sizes[t]))
155
+ warm_x.append(x)
156
+ warm_y.append(y)
157
+ rounds = []
158
+ for t in range(c.warm_rounds, self.total_rounds):
159
+ x, y = self._draw(t, int(sizes[t]))
160
+ rounds.append(Round(x=x, y=y))
161
+ return Episode(
162
+ rounds=rounds,
163
+ warm_x=np.concatenate(warm_x),
164
+ warm_y=np.concatenate(warm_y),
165
+ name=f"drift-{self.seed}",
166
+ )
167
+
168
+ @property
169
+ def break_rounds(self) -> list[int]:
170
+ """Break positions expressed as indices into the episode's rounds."""
171
+ return [b - self.cfg.warm_rounds for b in self.breaks]
172
+
173
+
174
+ def make_episode(seed: int, cfg: DriftConfig | None = None) -> tuple[Episode, list[int]]:
175
+ """Draw one drift episode and report where its breaks fall."""
176
+ sc = DriftScenario(cfg or DriftConfig(), seed)
177
+ return sc.episode(), sc.break_rounds
@@ -0,0 +1,131 @@
1
+ """The Elliptic Bitcoin transaction graph, as a held-out investigation episode.
2
+
3
+ The data set is from Weber et al., "Anti-Money Laundering in Bitcoin: Experimenting
4
+ with Graph Convolutional Networks for Financial Forensics", KDD '19 Workshop on
5
+ Anomaly Detection in Finance (arXiv:1908.02591): 203,769 transactions over 49 time
6
+ steps, each with 166 features, the first of which is the time step itself. The
7
+ archive keeps the other 165 as ``x``.
8
+
9
+ Weber et al. also document the regime break this environment is built around. A dark
10
+ market closed at time step 43, and every model they tried, including one retrained
11
+ after each step, performed poorly on the illicit transactions that followed.
12
+ ``scripts/measure_collapse.py`` reproduces that failure at an investigation budget
13
+ and writes the per-step counts to ``results/collapse.log``: a detector frozen on
14
+ steps 1 to 34 finds a large share of the illicit cases per step up to step 42 and
15
+ almost none from 43 on, while the same detector refitted on labels up to step 44
16
+ finds many times more of the fraud in steps 45 to 49, so the information needed to
17
+ recover exists. It is only obtainable by spending budget on cases the broken detector
18
+ ranks low.
19
+
20
+ Most transactions carry no label (the exact share is in ``results/collapse.log``).
21
+ Unlabelled cases are real cases whose status nobody established; investigating one
22
+ costs budget and reveals nothing. That is left in deliberately, because it is what
23
+ the operational problem actually looks like.
24
+
25
+ The data is CC BY-NC-ND 4.0 and is not shipped here. ``scripts/fetch_elliptic.py``
26
+ says where to get it and converts the Kaggle CSVs into ``elliptic_parsed.npz`` with
27
+ arrays ``x``, ``node_time``, ``labels``, ``src``, ``dst`` and ``txid``. The archive
28
+ is looked for in ``~/.cache/caseload``. Two older locations are still read if that
29
+ is empty: ``~/.cache/auditgym`` (this package's former name) and ``~/.cache/graphspot``
30
+ (the graphspot library, which writes the same arrays in the same row order). The
31
+ graphspot parser rounds some features differently in the last bit, and the post-break
32
+ counts are small enough for that to move them, so convert with
33
+ ``scripts/fetch_elliptic.py`` to reproduce the committed results exactly.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import hashlib
39
+ import pathlib
40
+
41
+ import numpy as np
42
+
43
+ from ..mdp import Episode, Round
44
+
45
+ CANONICAL_DIR = pathlib.Path.home() / ".cache" / "caseload"
46
+ LEGACY_DIRS = (
47
+ pathlib.Path.home() / ".cache" / "auditgym",
48
+ pathlib.Path.home() / ".cache" / "graphspot",
49
+ )
50
+ ARCHIVE = "elliptic_parsed.npz"
51
+ # the dark-market shutdown reported by Weber et al., in the dataset's own numbering
52
+ BREAK_TIME = 43
53
+ DEFAULT_WARM_UNTIL = 34
54
+
55
+
56
+ def find_archive(path: str | pathlib.Path | None = None) -> pathlib.Path:
57
+ if path is not None:
58
+ p = pathlib.Path(path).expanduser()
59
+ if p.is_dir():
60
+ p = p / ARCHIVE
61
+ if p.exists():
62
+ return p
63
+ raise FileNotFoundError(p)
64
+ for base in (CANONICAL_DIR, *LEGACY_DIRS):
65
+ p = base / ARCHIVE
66
+ if p.exists():
67
+ return p
68
+ raise FileNotFoundError(
69
+ f"{ARCHIVE} not found in {CANONICAL_DIR}. "
70
+ "Run scripts/fetch_elliptic.py, which prints where to get the data."
71
+ )
72
+
73
+
74
+ def load_raw(path: str | pathlib.Path | None = None) -> dict[str, np.ndarray]:
75
+ d = np.load(find_archive(path), allow_pickle=True)
76
+ return {k: d[k] for k in d.files}
77
+
78
+
79
+ def fingerprint(x: np.ndarray, t: np.ndarray, y: np.ndarray) -> str:
80
+ """sha256 over the feature, time-step and label arrays, to tell archives apart."""
81
+ h = hashlib.sha256()
82
+ for a in (x, t, y):
83
+ h.update(np.ascontiguousarray(a).tobytes())
84
+ return h.hexdigest()
85
+
86
+
87
+ def archive_fingerprint(path: str | pathlib.Path | None = None) -> str:
88
+ raw = load_raw(path)
89
+ return fingerprint(
90
+ raw["x"], raw["node_time"].astype(np.int64), raw["labels"].astype(np.int64)
91
+ )
92
+
93
+
94
+ def load_episode(
95
+ path: str | pathlib.Path | None = None,
96
+ warm_until: int = DEFAULT_WARM_UNTIL,
97
+ drop_unlabelled: bool = False,
98
+ labelled_only_rewards: bool = True,
99
+ ) -> tuple[Episode, int]:
100
+ """Build the Elliptic episode.
101
+
102
+ Returns the episode and the index of the break within its rounds.
103
+
104
+ ``drop_unlabelled`` removes cases nobody adjudicated. That makes the problem
105
+ easier and less faithful; it exists so the cost of the unlabelled majority can
106
+ be measured rather than assumed. ``labelled_only_rewards`` maps the unlabelled
107
+ class to 0, meaning investigating one wastes budget, which is the honest
108
+ reading: you looked and learned nothing.
109
+ """
110
+ raw = load_raw(path)
111
+ x, t, y = raw["x"], raw["node_time"].astype(int), raw["labels"].astype(int)
112
+ if drop_unlabelled:
113
+ keep = y >= 0
114
+ x, t, y = x[keep], t[keep], y[keep]
115
+ y_eff = np.where(y == 1, 1, 0)
116
+ if not labelled_only_rewards:
117
+ y_eff = np.where(y < 0, 0, y_eff)
118
+
119
+ warm = t <= warm_until
120
+ # the detector warm-starts only from cases that were actually adjudicated
121
+ warm_labelled = warm & (y >= 0)
122
+ steps = sorted(int(s) for s in np.unique(t[t > warm_until]))
123
+ rounds = [Round(x=x[t == s], y=y_eff[t == s]) for s in steps]
124
+ break_index = steps.index(BREAK_TIME) if BREAK_TIME in steps else -1
125
+ ep = Episode(
126
+ rounds=rounds,
127
+ warm_x=x[warm_labelled],
128
+ warm_y=y_eff[warm_labelled],
129
+ name=f"elliptic(t>{warm_until})",
130
+ )
131
+ return ep, break_index