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 +46 -0
- caseload/agents/__init__.py +5 -0
- caseload/agents/ppo.py +197 -0
- caseload/envs/__init__.py +5 -0
- caseload/envs/drift.py +177 -0
- caseload/envs/elliptic.py +131 -0
- caseload/envs/fifar.py +245 -0
- caseload/envs/gym_env.py +88 -0
- caseload/evaluation.py +140 -0
- caseload/mdp.py +357 -0
- caseload/policies.py +182 -0
- caseload/scorers.py +113 -0
- caseload/triage.py +357 -0
- caseload-0.1.0.dist-info/METADATA +355 -0
- caseload-0.1.0.dist-info/RECORD +17 -0
- caseload-0.1.0.dist-info/WHEEL +4 -0
- caseload-0.1.0.dist-info/licenses/LICENSE +28 -0
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
|
+
]
|
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
|
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
|