File size: 10,972 Bytes
824aa03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
"""The actor queue against the entity queue, on generator ground truth: triage.

Both queues come from one dataset, one fusion bundle and one fitted stacker, so
the only difference is the unit: an entity (address cluster) or an actor
(clusters joined through peer identities, fusion/actors.py). Ground truth is
`eval.actors.actors_of`: the illicit operations pre-registered in
docs/detection_unit_protocol.md.

A queue item *finds* an operation when it holds any of the operation's
wallets. A wallet's true controller, for purity, is its operation if it has
one, else its generator actor.
"""

from __future__ import annotations

import json
import tempfile
from collections import Counter
from pathlib import Path

import pandas as pd

from engines.correlation.profile import build_sources
from features import relay
from fusion import actors as A
from fusion.pipeline import build_alerts, collect_signals
from ingest.ip_intel import load_intel
from p2p import demo_capture

from .actors import actors_of
from .datasets import Dataset

KS = (10, 25, 50)


def dataset_actors(dataset: Dataset, stacker, cfg: dict) -> dict:
    """The fusion bundle, the entity alerts and the actors of one dataset.
    Its relay matrix is built the way the served one is (p2p.demo_capture,
    then features.relay), in a scratch directory. `stacker=None` fits one on
    this dataset's own labels, as eval.fusion_eval does: entities holding an
    illicit operation's wallet."""
    cfg = json.loads(json.dumps(cfg))
    cfg["ingest"]["input_dir"] = str(dataset.raw)       # this dataset's node intel
    df = dataset.frame()
    bundle = collect_signals(df, cfg, dataset.raw)
    if stacker is None:
        from fusion.stacker import train

        from .actors import illicit_entities
        bad = illicit_entities(actors_of(dataset), bundle["features"].entity_of)
        stacker = train(bundle["signals"], bundle["signals"]["entity_id"].isin(bad).astype(int), cfg)
    alerts = build_alerts(bundle, stacker, cfg)
    with tempfile.TemporaryDirectory() as tmp:
        capture = Path(tmp) / "demo_capture"
        demo_capture.write(df, capture)
        matrix, _, _ = relay.build(capture, [demo_capture.COLLECTOR], cfg)
    src = build_sources(cfg, transactions=df, matrix=matrix, features=bundle["features"],
                        intel=load_intel(None, dataset.raw, cfg))
    links = A.peer_links(src, cfg)
    actors = A.build(bundle["signals"], stacker, links, cfg, set(alerts["entity_id"]))
    return {"bundle": bundle, "alerts": alerts, "actors": actors, "links": links}


def _wallets(entity: str, clusters: dict) -> set[str]:
    return set(clusters.get(entity, {entity}))


def triage(dataset: Dataset, built: dict) -> dict:
    gt = dataset.ground_truth()
    operations = actors_of(dataset)
    clusters = built["bundle"]["features"].clustering.clusters
    op_of = {w: op.actor_id for op in operations for w in op.wallets}
    controller = lambda w: op_of.get(w) or gt["wallets"].get(w, w)
    present = {op.actor_id for op in operations
               if any(built["bundle"]["features"].entity_of(w) for w in op.wallets)}

    entity_items = [_wallets(e, clusters) for e in built["alerts"]["entity_id"]]
    queue = A.queue(built["actors"])
    actor_items = [set().union(*(_wallets(m, clusters) for m in members))
                   for members in queue["members"]]

    def found(items):
        return [{op_of[w] for w in wallets if w in op_of} for wallets in items]

    def rows(name, items):
        hits = found(items)
        out = {"queue": name, "items": len(items)}
        for k in KS:
            top = hits[:k]
            seen = set().union(*top) if top else set()
            out[f"precision@{k}"] = round(sum(bool(h) for h in top) / len(top), 3) if top else None
            out[f"recall@{k}"] = round(len(seen) / len(present), 3) if present else None
        # "Items reviewed before finding N", for N = 1, 3, 10, 20 and all of the
        # operations present, whichever of those the dataset holds.
        for n in sorted({k for k in (1, 3, 10, 20) if k <= len(present)} | {len(present)} - {0}):
            reviewed = None
            seen = set()
            for i, h in enumerate(hits, 1):
                seen |= h
                if len(seen) >= n:
                    reviewed = i
                    break
            label = "all" if n == len(present) else str(n)
            out[f"reviewed to find {label}"] = reviewed if reviewed is not None else "not reached"
        return out

    table = [rows("entity queue (before)", entity_items), rows("actor queue", actor_items)]

    def purity(wallets):
        c = Counter(controller(w) for w in wallets)
        return c.most_common(1)[0][1] / sum(c.values())

    multi = queue[queue["members"].map(len) > 1]
    majority = lambda m: Counter(controller(w) for w in _wallets(m, clusters)).most_common(1)[0][0]
    wrong = [len({majority(m) for m in members}) > 1 for members in multi["members"]]
    all_multi = built["actors"][built["actors"]["members"].map(len) > 1]
    wrong_all = [len({majority(m) for m in members}) > 1 for members in all_multi["members"]]
    quality = {
        "entity alerts": len(entity_items), "actor alerts": len(actor_items),
        "alert count reduction": round(1 - len(actor_items) / len(entity_items), 3)
        if entity_items else None,
        "illicit operations present": len(present),
        "actor purity (alerted, mean)": round(sum(map(purity, actor_items)) / len(actor_items), 3)
        if actor_items else None,
        "entity purity (alerted, mean)": round(sum(map(purity, entity_items)) / len(entity_items), 3)
        if entity_items else None,
        "alerted actors joining 2+ clusters": len(multi),
        "wrong-merge rate (alerted multi-cluster actors)": round(sum(wrong) / len(wrong), 3)
        if wrong else None,
        "actors joining 2+ clusters (all)": len(all_multi),
        "wrong-merge rate (all multi-cluster actors)": round(sum(wrong_all) / len(wrong_all), 3)
        if wrong_all else None,
        "links": len(built["links"]),
        "joining links": sum(A.may_join(lk) for lk in built["links"]),
    }
    return {"table": pd.DataFrame(table), "quality": quality}


def evaluate(datasets: dict[str, Dataset], stackers: dict, cfg: dict) -> dict:
    out = {}
    for name, dataset in datasets.items():
        built = dataset_actors(dataset, stackers[name], cfg)
        out[name] = triage(dataset, built)
    return out


# --- power: many seeds, larger datasets ----------------------------------------------
#: Sizes chosen so each dataset holds about thirty illicit operations (shifted
#: instances are larger, so it needs more transactions). Actors scale with size.
POWER_SIZES = {"standard": 9600, "shifted": 18000}
POWER_SEEDS = (101, 102, 103, 104, 105)
T_975 = {2: 12.706, 3: 4.303, 4: 3.182, 5: 2.776, 6: 2.571, 7: 2.447, 8: 2.365, 9: 2.306}


def power_datasets(cfg: dict, seeds=POWER_SEEDS, sizes=None) -> dict[tuple[str, int], Dataset]:
    from .datasets import build
    out = {}
    for condition, n in (sizes or POWER_SIZES).items():
        c = json.loads(json.dumps(cfg))
        c["eval"]["n_actors"], c["eval"]["n_transactions"] = n // 6, n
        for seed in seeds:
            out[(condition, seed)] = build(cfg["eval"]["default_rate"], condition == "shifted",
                                           seed, c, name_prefix=f"power-{condition}-n{n}")
    return out


def _interval(values: list[float]) -> str:
    """Mean and a 95% t-interval across seeds."""
    import statistics
    v = [float(x) for x in values if x is not None and not isinstance(x, str)]
    if not v:
        return "—"
    if len(v) == 1:
        return f"{v[0]:.3f} (one seed)"
    half = T_975.get(len(v), 2.0) * statistics.stdev(v) / len(v) ** 0.5
    return f"{statistics.mean(v):.3f} [{statistics.mean(v) - half:.3f}, {statistics.mean(v) + half:.3f}]"


def power(cfg: dict, seeds=POWER_SEEDS, sizes=None) -> dict:
    """The P9 comparison on every (condition, seed) dataset, each with its own
    fitted stacker, and the spread across seeds. The join rule is fusion/actors.py's,
    unchanged."""
    per = []
    for (condition, seed), dataset in power_datasets(cfg, seeds, sizes).items():
        result = triage(dataset, dataset_actors(dataset, None, cfg))
        table = result["table"].set_index("queue")
        per.append({"condition": condition, "seed": seed, "table": table,
                    "quality": result["quality"]})
    return {"per_seed": per, "summary": summarise_power(per)}


def summarise_power(per: list[dict]) -> dict:
    rows, diffs, merges, sizes = [], [], [], []
    for condition in sorted({p["condition"] for p in per}):
        runs = [p for p in per if p["condition"] == condition]
        metrics = [c for c in runs[0]["table"].columns if c != "items"]
        common = [m for m in metrics if all(m in r["table"].columns for r in runs)]
        for queue in runs[0]["table"].index:
            rows.append({"condition": condition, "queue": queue, "seeds": len(runs),
                         "items": _interval([r["table"].loc[queue, "items"] for r in runs]),
                         **{m: _interval([r["table"].loc[queue, m] for r in runs]) for m in common}})
        before, after = runs[0]["table"].index
        for m in ["items", *common]:
            pairs = [(r["table"].loc[before, m], r["table"].loc[after, m]) for r in runs]
            pairs = [(a, b) for a, b in pairs if not isinstance(a, str) and not isinstance(b, str)]
            diffs.append({"condition": condition, "measure": m,
                          "seeds with both": len(pairs),
                          "actor − entity": _interval([b - a for a, b in pairs])})
        q = [r["quality"] for r in runs]
        merges.append({"condition": condition,
                       **{k: _interval([x[k] for x in q]) for k in (
                           "illicit operations present", "alert count reduction",
                           "actor purity (alerted, mean)",
                           "wrong-merge rate (alerted multi-cluster actors)",
                           "wrong-merge rate (all multi-cluster actors)")},
                       "alerted multi-cluster actors (total)":
                           sum(x["alerted actors joining 2+ clusters"] for x in q)})
        sizes += [{"condition": condition, "seed": r["seed"],
                   "operations": r["quality"]["illicit operations present"],
                   "entity alerts": r["quality"]["entity alerts"],
                   "actor alerts": r["quality"]["actor alerts"],
                   **{f"reviewed to find all ({q.split()[0]})": r["table"].loc[q].get(
                       "reviewed to find all", "—") for q in r["table"].index}}
                  for r in runs]
    return {"queues": pd.DataFrame(rows), "paired": pd.DataFrame(diffs),
            "quality": pd.DataFrame(merges), "datasets": pd.DataFrame(sizes)}