Skip to content

Commit d65eb2c

Browse files
authored
Merge pull request #47 from bitstarkbridge/Ledgerlens-data43
feature:Implement Causal Inference Engine for Wash-Trade Attribution ……and Root-Cause Forensics
2 parents 0eb51fe + 2027bf9 commit d65eb2c

10 files changed

Lines changed: 1094 additions & 0 deletions

data/synthetic_dataset.parquet

38.1 KB
Binary file not shown.

detection/causal_attribution.py

Lines changed: 472 additions & 0 deletions
Large diffs are not rendered by default.

detection/feature_engineering.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def compute_trade_pattern_features(
7474
return {
7575
"counterparty_concentration_ratio": 0.0,
7676
"round_trip_frequency": 0.0,
77+
"net_roundtrip_ratio": 0.0,
7778
"self_matching_rate": 0.0,
7879
"order_cancellation_rate": order_cancellation_rate,
7980
}
@@ -95,6 +96,7 @@ def compute_trade_pattern_features(
9596
return {
9697
"counterparty_concentration_ratio": float(concentration),
9798
"round_trip_frequency": float(round_trip_frequency),
99+
"net_roundtrip_ratio": float(round_trip_frequency),
98100
"self_matching_rate": float(self_matching_rate),
99101
"order_cancellation_rate": order_cancellation_rate,
100102
}

detection/forensic_report.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Forensic report structures for risk scoring and causal attribution."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
7+
import networkx as nx
8+
import pandas as pd
9+
10+
from detection.causal_attribution import CounterfactualAttributor
11+
from detection.model_inference import RiskScorer
12+
from detection.shap_explainer import ShapExplainer
13+
14+
15+
@dataclass(slots=True)
16+
class CausalAttribution:
17+
minimal_exonerating_trades: list[str]
18+
counterfactual_score: int
19+
root_cause_wallet: str | None
20+
causal_chain: list[dict]
21+
interventional_score_if_no_wash: int
22+
23+
24+
@dataclass(slots=True)
25+
class ForensicReport:
26+
wallet: str
27+
asset_pair: str
28+
risk_score: dict
29+
shap_explanations: list[dict] = field(default_factory=list)
30+
causal_attribution: CausalAttribution | None = None
31+
32+
33+
class ForensicReportGenerator:
34+
"""Build a structured report for a scored wallet."""
35+
36+
def __init__(self, scorer: RiskScorer | None = None, explainer: ShapExplainer | None = None):
37+
self._scorer = scorer or RiskScorer()
38+
self._explainer = explainer or ShapExplainer()
39+
40+
def generate(
41+
self,
42+
wallet: str,
43+
asset_pair: str,
44+
feature_row: pd.Series,
45+
wallet_trades: pd.DataFrame,
46+
activity=None,
47+
orderbook_events: pd.DataFrame | None = None,
48+
funding_graph: nx.DiGraph | None = None,
49+
all_pairs_df: pd.DataFrame | None = None,
50+
causal: bool = False,
51+
top_n: int = 5,
52+
) -> ForensicReport:
53+
risk_score = self._scorer.score(feature_row)
54+
shap_explanations = []
55+
try:
56+
shap_explanations = self._explainer.explain_ensemble(
57+
feature_row, self._scorer.models, top_n=top_n
58+
)
59+
except Exception: # noqa: BLE001
60+
shap_explanations = []
61+
62+
causal_attribution = None
63+
if causal:
64+
attributor = CounterfactualAttributor(self._scorer)
65+
minimal_set = (
66+
attributor.minimal_exonerating_set(
67+
wallet,
68+
wallet_trades,
69+
activity=activity,
70+
orderbook_events=orderbook_events,
71+
funding_graph=funding_graph,
72+
all_pairs_df=all_pairs_df,
73+
)
74+
or []
75+
)
76+
counterfactual = attributor.counterfactual_score(
77+
wallet,
78+
wallet_trades,
79+
minimal_set,
80+
activity=activity,
81+
orderbook_events=orderbook_events,
82+
funding_graph=funding_graph,
83+
all_pairs_df=all_pairs_df,
84+
)
85+
scm = attributor.build_scm(
86+
wallet,
87+
wallet_trades,
88+
activities=[activity] if activity is not None else None,
89+
orderbook_events=orderbook_events,
90+
funding_graph=funding_graph,
91+
all_pairs_df=all_pairs_df,
92+
)
93+
intervention_score = counterfactual["counterfactual_score"]
94+
intervention_key = next(
95+
(name for name in feature_row.index if name == "benford_chi_square_24h"),
96+
next(
97+
(name for name in feature_row.index if name.startswith("benford_chi_square_")),
98+
None,
99+
),
100+
)
101+
if intervention_key is not None:
102+
intervention_result = attributor.interventional_score(
103+
wallet, scm, {intervention_key: 0.0}
104+
)
105+
intervention_score = intervention_result["score"]
106+
107+
causal_attribution = CausalAttribution(
108+
minimal_exonerating_trades=minimal_set,
109+
counterfactual_score=counterfactual["counterfactual_score"],
110+
root_cause_wallet=attributor.root_cause_wallet(
111+
wallet,
112+
wallet_trades,
113+
funding_graph,
114+
activity=activity,
115+
orderbook_events=orderbook_events,
116+
all_pairs_df=all_pairs_df,
117+
),
118+
causal_chain=attributor.causal_chain(wallet, funding_graph),
119+
interventional_score_if_no_wash=intervention_score,
120+
)
121+
122+
return ForensicReport(
123+
wallet=wallet,
124+
asset_pair=asset_pair,
125+
risk_score=risk_score,
126+
shap_explanations=shap_explanations,
127+
causal_attribution=causal_attribution,
128+
)

docs/causal_inference.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Causal Inference for LedgerLens
2+
3+
This document describes the causal attribution layer added to LedgerLens on top of the existing feature pipeline and ensemble scorer.
4+
5+
## Why Causal Attribution
6+
7+
SHAP explains which features contributed to a score. Causal attribution asks a more operational question: which trades, counterparties, or funding paths would need to change for the wallet to fall below the risk threshold?
8+
9+
That distinction matters in investigations. A wallet can be high-risk because a feature is large, but the analyst still needs to know which observable trades or upstream wallets are driving that feature.
10+
11+
## Structural Causal Model
12+
13+
LedgerLens builds a lightweight SCM from the existing feature vector:
14+
15+
- Nodes are features.
16+
- Edges represent simple structural dependencies between features computed from the same trade set.
17+
- Interventions propagate through the graph so downstream features are recomputed rather than blindly overwritten.
18+
19+
The SCM is intentionally small and deterministic. It is not a symbolic causal discovery engine; it is a forensic explanation layer built around known feature relationships.
20+
21+
## Counterfactual Scoring
22+
23+
`CounterfactualAttributor.counterfactual_score()` removes selected trades, rebuilds the wallet features, and rescales the wallet with the same trained ensemble used in production.
24+
25+
This is different from feature substitution. Removing trades changes the trade-derived features, the Benford metrics, and the graph-derived signals together.
26+
27+
## Greedy Exoneration Search
28+
29+
`minimal_exonerating_set()` uses greedy backward elimination:
30+
31+
1. Score the wallet with the current trade set.
32+
2. Remove the trade that lowers the score the most.
33+
3. Repeat until the score falls below the threshold or the search limit is reached.
34+
35+
If no subset of up to 20 trades can move the wallet below threshold, the result is `None`. That indicates the signal is structural or graph-driven rather than explained by a small trade subset.
36+
37+
## Root Cause Wallets
38+
39+
`root_cause_wallet()` evaluates each counterparty wallet and measures the score reduction if its shared trades are removed. Ties prefer counterparties with stronger funding-source similarity and larger shared trade sets.
40+
41+
## Interventions
42+
43+
`interventional_score()` applies a `do(feature = value)` style intervention to the SCM and propagates the effect to downstream features. This is useful for questions like:
44+
45+
- What happens if the Benford anomaly is neutralized?
46+
- Does the round-trip signal remain high after upstream changes?
47+
- Which downstream indicators move together with the manipulated feature?
48+
49+
## Counterfactual vs SHAP
50+
51+
SHAP is correlational. It tells you which features are most associated with the model output.
52+
53+
The causal layer is operational. It tells you which trades and wallets change the score when removed or intervened on.
54+
55+
Use SHAP for attribution. Use causal scoring for investigation and evidence triage.
56+
57+
## Investigative Use Cases
58+
59+
- Identify the smallest trade subset that keeps a wallet below threshold.
60+
- Rank counterparties by how much they contribute to the score.
61+
- Trace the funding chain behind a flagged wallet.
62+
- Test whether an apparent wash-trading signal propagates into downstream trade-pattern features.

scripts/generate_synthetic_dataset.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def generate_synthetic_dataset(n_wallets: int = 500, seed: int = 42) -> pd.DataF
4646
if is_wash:
4747
row["counterparty_concentration_ratio"] = rng.uniform(0.7, 1.0)
4848
row["round_trip_frequency"] = rng.uniform(0.3, 1.0)
49+
row["net_roundtrip_ratio"] = rng.uniform(0.3, 1.0)
4950
row["self_matching_rate"] = rng.uniform(0.3, 1.0)
5051
row["order_cancellation_rate"] = rng.uniform(0.4, 0.9)
5152
row["volume_per_counterparty_ratio"] = rng.uniform(1000, 10000)
@@ -65,6 +66,7 @@ def generate_synthetic_dataset(n_wallets: int = 500, seed: int = 42) -> pd.DataF
6566
else:
6667
row["counterparty_concentration_ratio"] = rng.uniform(0.0, 0.5)
6768
row["round_trip_frequency"] = rng.uniform(0.0, 0.1)
69+
row["net_roundtrip_ratio"] = rng.uniform(0.0, 0.1)
6870
row["self_matching_rate"] = rng.uniform(0.0, 0.1)
6971
row["order_cancellation_rate"] = rng.uniform(0.0, 0.3)
7072
row["volume_per_counterparty_ratio"] = rng.uniform(10, 1000)

scripts/score_wallet.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from stellar_sdk import Asset as SdkAsset
2121

2222
from config import config
23+
from detection.causal_attribution import CounterfactualAttributor
2324
from detection.feature_engineering import build_feature_vector
2425
from detection.model_inference import RiskScorer
2526
from detection.shap_explainer import ShapExplainer
@@ -83,9 +84,40 @@ def parse_args() -> argparse.Namespace:
8384
help="Skip loading order-book events",
8485
)
8586
parser.add_argument("--json", action="store_true", help="Output result as JSON")
87+
parser.add_argument(
88+
"--causal",
89+
action="store_true",
90+
help="Include causal attribution in the output",
91+
)
92+
parser.add_argument(
93+
"--what-if-remove",
94+
default=None,
95+
help="Comma-separated trade IDs to remove for a counterfactual score",
96+
)
8697
return parser.parse_args()
8798

8899

100+
def _parse_remove_trade_ids(
101+
remove_trade_ids: str | None, trades_df: pd.DataFrame, wallet: str
102+
) -> list[str]:
103+
if not remove_trade_ids:
104+
return []
105+
106+
requested = [trade_id.strip() for trade_id in remove_trade_ids.split(",") if trade_id.strip()]
107+
if not requested:
108+
return []
109+
110+
if trades_df.empty or "trade_id" not in trades_df.columns:
111+
raise ValueError("Cannot remove trades: wallet trade history is empty")
112+
113+
wallet_trade_ids = set(trades_df["trade_id"].astype(str))
114+
invalid = [trade_id for trade_id in requested if trade_id not in wallet_trade_ids]
115+
if invalid:
116+
raise ValueError(f"Trade IDs not found in wallet history: {', '.join(sorted(invalid))}")
117+
118+
return requested
119+
120+
89121
def main() -> None:
90122
args = parse_args()
91123

@@ -139,6 +171,31 @@ def main() -> None:
139171
print(f"Error during scoring: {e}", file=sys.stderr)
140172
sys.exit(1)
141173

174+
remove_trade_ids = []
175+
causal_result = None
176+
if args.what_if_remove or args.causal:
177+
try:
178+
remove_trade_ids = _parse_remove_trade_ids(args.what_if_remove, trades_df, args.wallet)
179+
except ValueError as exc:
180+
print(f"Error: {exc}", file=sys.stderr)
181+
raise
182+
183+
attributor = CounterfactualAttributor(scorer)
184+
if remove_trade_ids:
185+
causal_result = attributor.counterfactual_score(
186+
args.wallet,
187+
trades_df,
188+
remove_trade_ids,
189+
orderbook_events=orderbook_events_df,
190+
)
191+
elif args.causal:
192+
causal_result = attributor.counterfactual_score(
193+
args.wallet,
194+
trades_df,
195+
[],
196+
orderbook_events=orderbook_events_df,
197+
)
198+
142199
# 5. Explain
143200
try:
144201
explainer = ShapExplainer()
@@ -159,6 +216,8 @@ def main() -> None:
159216
"confidence": result["confidence"],
160217
"shap_explanations": shap_explanations,
161218
}
219+
if causal_result is not None:
220+
output["causal_attribution"] = causal_result
162221
print(json.dumps(output, indent=2))
163222
else:
164223
status = "FLAGGED" if result["score"] >= config.RISK_SCORE_FLAG_THRESHOLD else "OK"
@@ -172,6 +231,16 @@ def main() -> None:
172231
contrib = f"{exp['contribution']:+.2f}"
173232
print(f" {i}. {exp['feature']:<25} {contrib:>6} (value: {exp['value']:.4g})")
174233

234+
if causal_result is not None:
235+
print("\nCausal attribution:")
236+
print(f" Original score: {causal_result['original_score']}")
237+
print(f" Counterfactual score: {causal_result['counterfactual_score']}")
238+
print(f" Score delta: {causal_result['score_delta']}")
239+
if causal_result["features_changed"]:
240+
print(" Features changed:")
241+
for name, details in causal_result["features_changed"].items():
242+
print(f" - {name}: {details['original']} -> {details['counterfactual']}")
243+
175244

176245
if __name__ == "__main__":
177246
main()

templates/forensic_report.md.j2

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Forensic Report
2+
3+
Wallet: {{ report.wallet }}
4+
Asset pair: {{ report.asset_pair }}
5+
Risk score: {{ report.risk_score.score }}
6+
7+
## Summary
8+
9+
- Benford flag: {{ report.risk_score.benford_flag }}
10+
- ML flag: {{ report.risk_score.ml_flag }}
11+
- Confidence: {{ report.risk_score.confidence }}
12+
13+
## SHAP
14+
15+
{% for item in report.shap_explanations %}
16+
- {{ item.feature }}: {{ item.contribution }} (value: {{ item.value }})
17+
{% endfor %}
18+
19+
## Causal Attribution
20+
21+
{% if report.causal_attribution %}
22+
- Minimal exonerating trades: {{ report.causal_attribution.minimal_exonerating_trades | join(', ') }}
23+
- Counterfactual score: {{ report.causal_attribution.counterfactual_score }}
24+
- Root cause wallet: {{ report.causal_attribution.root_cause_wallet }}
25+
- Interventional score if no wash: {{ report.causal_attribution.interventional_score_if_no_wash }}
26+
27+
### Causal chain
28+
29+
{% for hop in report.causal_attribution.causal_chain %}
30+
- Hop {{ hop.hop }}: {{ hop.wallet }} ({{ hop.role }})
31+
{% endfor %}
32+
33+
> The presence of a minimal exonerating set does not indicate the wallet is innocent; it indicates which specific trades are most anomalous.
34+
{% else %}
35+
- Causal attribution disabled.
36+
{% endif %}

0 commit comments

Comments
 (0)