Download features/engineer.py from Anish-g/btc-intel: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/Anish-g/btc-intel/resolve/main/features/engineer.py
- Command line
-
hf download hf://spaces/Anish-g/btc-intel/features/engineer.py
-
curl -L -o engineer.py https://huggingface.co/spaces/Anish-g/btc-intel/resolve/main/features/engineer.py
10.7 kB
| """Per-entity and per-transaction features. | |
| python -m features.engineer --input data/processed/transactions.parquet \ | |
| --output data/processed/features.parquet | |
| Two grains, two tables: entities (what the rules, anomaly and fusion stages | |
| score) and transactions (what the CoinJoin/peel-shaped signals live on). | |
| Thresholds come from config.yaml — none of them are baked in here. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| from collections.abc import Iterable | |
| from pathlib import Path | |
| import networkx as nx | |
| import pandas as pd | |
| import config | |
| from graph.builder import IP, Tx, from_parquet, graph_transactions | |
| from graph.clustering import Clustering, cluster_wallets, equal_value_group | |
| DAY = 86400.0 | |
| ENTITY_COLUMNS = [ | |
| "cluster_id", "wallets", "txs", "txs_in", "txs_out", | |
| "counterparties_in", "counterparties_out", "fan_in_ratio", "fan_out_ratio", | |
| "value_in", "value_out", "round_amount_ratio", "velocity", "lifetime_days", | |
| "dormant_then_active", "max_dormant_gap_days", "max_burst_transactions", | |
| "unique_broadcast_ips", "unique_asns", "avg_hop_distance_from_known_bad", | |
| "suspicious_merge", | |
| ] | |
| TX_COLUMNS = ["txid", "timestamp", "input_count", "output_count", "equal_output_count", | |
| "equal_output_value", "peel_ratio", "value_in", "value_out", "fee", | |
| "time_since_prev_tx_same_wallet"] | |
| def _epoch(ts) -> float | None: | |
| if ts is None or (isinstance(ts, float) and pd.isna(ts)): | |
| return None | |
| t = pd.Timestamp(ts) | |
| return None if pd.isna(t) else t.timestamp() | |
| def is_round(amount: float, cfg: dict) -> bool: | |
| """A multiple of one of the configured units — the value a human typed, | |
| not one a coin-selection algorithm produced.""" | |
| f = cfg["features"] | |
| tol = f["round_tolerance"] | |
| for unit in f["round_units"]: | |
| if amount >= unit and abs(amount / unit - round(amount / unit)) * unit <= tol: | |
| return True | |
| return False | |
| def dormancy(times: list[float], cfg: dict) -> tuple[bool, float, int]: | |
| """Long quiet, then a burst. Returns (flag, largest gap in days, burst size).""" | |
| d = cfg["features"]["dormancy"] | |
| gap_s, window_s = d["gap_days"] * DAY, d["burst_window_days"] * DAY | |
| times = sorted(times) | |
| flagged, max_gap, max_burst = False, 0.0, 0 | |
| for i in range(1, len(times)): | |
| gap = times[i] - times[i - 1] | |
| max_gap = max(max_gap, gap) | |
| if gap < gap_s: | |
| continue | |
| burst = sum(1 for t in times[i:] if t - times[i] <= window_s) | |
| max_burst = max(max_burst, burst) | |
| if burst >= d["burst_transactions"]: | |
| flagged = True | |
| return flagged, max_gap / DAY, max_burst | |
| def _as_transactions(source) -> list[Tx]: | |
| if isinstance(source, nx.MultiDiGraph): | |
| return list(graph_transactions(source)) | |
| return list(source) | |
| def compute_features(graph, clustering: Clustering | None = None, | |
| cfg: dict | None = None) -> pd.DataFrame: | |
| """Per-entity features, one row per cluster.""" | |
| cfg = cfg or config.load() | |
| txs = _as_transactions(graph) | |
| clustering = clustering or cluster_wallets(txs, cfg) | |
| asn_of = _asn_lookup(graph) | |
| def entity(addr: str) -> str: | |
| return clustering.cluster_of(addr) or addr | |
| rows: dict[str, dict] = {} | |
| def row(eid: str) -> dict: | |
| if eid not in rows: | |
| rows[eid] = {"cluster_id": eid, | |
| "wallets": len(clustering.clusters.get(eid, {eid})), | |
| "txs_in": 0, "txs_out": 0, "value_in": 0.0, "value_out": 0.0, | |
| "counterparties_in": set(), "counterparties_out": set(), | |
| "amounts": [], "times": [], "ips": set(), | |
| "suspicious_merge": bool(clustering.flags.get(eid))} | |
| return rows[eid] | |
| for tx in txs: | |
| ts = _epoch(tx.timestamp) | |
| senders: dict[str, float] = {} | |
| receivers: dict[str, float] = {} | |
| for addr, value in tx.inputs: | |
| senders[entity(addr)] = senders.get(entity(addr), 0.0) + value | |
| for addr, value in tx.outputs: | |
| receivers[entity(addr)] = receivers.get(entity(addr), 0.0) + value | |
| for eid, value in senders.items(): | |
| r = row(eid) | |
| r["txs_out"] += 1 | |
| r["value_out"] += value | |
| r["counterparties_out"].update(set(receivers) - {eid}) | |
| r["amounts"].append(value) | |
| r["ips"].update(tx.ips) # the spender is who broadcast it | |
| if ts is not None: | |
| r["times"].append(ts) | |
| for eid, value in receivers.items(): | |
| r = row(eid) | |
| r["txs_in"] += 1 | |
| r["value_in"] += value | |
| r["counterparties_in"].update(set(senders) - {eid}) | |
| r["amounts"].append(value) | |
| if ts is not None and eid not in senders: | |
| r["times"].append(ts) | |
| out = [_finish_entity(r, asn_of, cfg) for r in rows.values()] | |
| df = pd.DataFrame(out, columns=ENTITY_COLUMNS) | |
| return df.sort_values("cluster_id", ignore_index=True) | |
| def _finish_entity(r: dict, asn_of: dict, cfg: dict) -> dict: | |
| times = r["times"] | |
| lifetime_days = (max(times) - min(times)) / DAY if len(times) > 1 else 0.0 | |
| window = max(lifetime_days, cfg["features"]["velocity_min_window_days"]) | |
| txs = r["txs_in"] + r["txs_out"] | |
| amounts = r["amounts"] | |
| flagged, max_gap_days, max_burst = dormancy(times, cfg) | |
| asns = {asn_of[ip] for ip in r["ips"] if asn_of.get(ip) is not None} | |
| sides = len(r["counterparties_in"]) + len(r["counterparties_out"]) | |
| return { | |
| "cluster_id": r["cluster_id"], "wallets": r["wallets"], "txs": txs, | |
| "txs_in": r["txs_in"], "txs_out": r["txs_out"], | |
| "counterparties_in": len(r["counterparties_in"]), | |
| "counterparties_out": len(r["counterparties_out"]), | |
| # how one-sided the entity is: 1.0 = pure collector, 0.0 = pure distributor. | |
| # Raw counterparty and tx counts are kept above, so concentration | |
| # (counterparties per transaction) is still derivable downstream. | |
| "fan_in_ratio": len(r["counterparties_in"]) / sides if sides else 0.0, | |
| "fan_out_ratio": len(r["counterparties_out"]) / sides if sides else 0.0, | |
| "value_in": r["value_in"], "value_out": r["value_out"], | |
| "round_amount_ratio": (sum(is_round(a, cfg) for a in amounts) / len(amounts) | |
| if amounts else 0.0), | |
| "velocity": txs / window, | |
| "lifetime_days": lifetime_days, | |
| "dormant_then_active": flagged, | |
| "max_dormant_gap_days": max_gap_days, | |
| "max_burst_transactions": max_burst, | |
| "unique_broadcast_ips": len(r["ips"]), | |
| "unique_asns": len(asns), | |
| # filled in later by fusion/taint.py | |
| "avg_hop_distance_from_known_bad": 0.0, | |
| "suspicious_merge": r["suspicious_merge"], | |
| } | |
| def _asn_lookup(graph) -> dict[str, int]: | |
| if not isinstance(graph, nx.MultiDiGraph): | |
| return {} | |
| return {n: d.get("asn") for n, d in graph.nodes(data=True) if d.get("node_type") == IP} | |
| def transaction_features(graph, cfg: dict | None = None) -> pd.DataFrame: | |
| """Per-transaction features, one row per txid.""" | |
| cfg = cfg or config.load() | |
| tolerance = cfg["graph"]["coinjoin"]["equal_value_tolerance"] | |
| txs = _as_transactions(graph) | |
| # Every appearance of a wallet, in time order, for the "since last seen" gap. | |
| seen: dict[str, list[float]] = {} | |
| for tx in txs: | |
| ts = _epoch(tx.timestamp) | |
| if ts is None: | |
| continue | |
| for addr in tx.input_addresses + tx.output_addresses: | |
| seen.setdefault(addr, []).append(ts) | |
| for values in seen.values(): | |
| values.sort() | |
| rows = [] | |
| for tx in txs: | |
| ts = _epoch(tx.timestamp) | |
| outs = tx.output_values | |
| value, group = equal_value_group(outs, tolerance) if outs else (0.0, []) | |
| rows.append({ | |
| "txid": tx.txid, "timestamp": tx.timestamp, | |
| "input_count": len(tx.inputs), "output_count": len(tx.outputs), | |
| "equal_output_count": len(group), "equal_output_value": value, | |
| "peel_ratio": min(outs) / max(outs) if len(outs) == 2 and max(outs) else float("nan"), | |
| "value_in": sum(tx.input_values), "value_out": sum(outs), "fee": tx.fee, | |
| "time_since_prev_tx_same_wallet": _previous_gap(tx, seen, ts), | |
| }) | |
| return pd.DataFrame(rows, columns=TX_COLUMNS).sort_values("txid", ignore_index=True) | |
| def _previous_gap(tx: Tx, seen: dict[str, list[float]], ts: float | None) -> float: | |
| """Seconds since any input wallet was last involved in a transaction.""" | |
| if ts is None: | |
| return float("nan") | |
| gaps = [] | |
| for addr in tx.input_addresses: | |
| earlier = [t for t in seen.get(addr, []) if t < ts] | |
| if earlier: | |
| gaps.append(ts - earlier[-1]) | |
| return min(gaps) if gaps else float("nan") | |
| def compute_all(graph, clustering: Clustering | None = None, | |
| cfg: dict | None = None) -> tuple[pd.DataFrame, pd.DataFrame]: | |
| cfg = cfg or config.load() | |
| txs = _as_transactions(graph) | |
| clustering = clustering or cluster_wallets(txs, cfg) | |
| return (compute_features(graph, clustering, cfg), transaction_features(graph, cfg)) | |
| def run(input_path=None, output=None, tx_output=None, cfg: dict | None = None) -> dict: | |
| cfg = cfg or config.load() | |
| graph = from_parquet(input_path, cfg) | |
| entities, transactions = compute_all(graph, cfg=cfg) | |
| output = Path(output or cfg["features"]["entity_path"]) | |
| tx_output = Path(tx_output or cfg["features"]["transaction_path"]) | |
| for path, frame in ((output, entities), (tx_output, transactions)): | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| frame.to_parquet(path, index=False) | |
| return {"entities": len(entities), "transactions": len(transactions), | |
| "flagged_entities": int(entities["suspicious_merge"].sum()), | |
| "dormant_then_active": int(entities["dormant_then_active"].sum()), | |
| "output": str(output), "transaction_output": str(tx_output)} | |
| def main(argv=None) -> None: | |
| cfg = config.load() | |
| ap = argparse.ArgumentParser(prog="features.engineer", description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument("--input", default=cfg["ingest"]["output_path"]) | |
| ap.add_argument("--output", default=cfg["features"]["entity_path"]) | |
| ap.add_argument("--tx-output", default=cfg["features"]["transaction_path"]) | |
| args = ap.parse_args(argv) | |
| print(json.dumps(run(args.input, args.output, args.tx_output), indent=2)) | |
| if __name__ == "__main__": | |
| main() | |