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)}
|