Optimizing Anti-Money Laundering Detection Rules

A Combinatorial Approach

Money laundering is a multi-billion dollar global problem. Financial institutions are required to deploy detection systems β€” but with dozens of rules available, which combination actually works best? This is fundamentally a combinatorial optimization problem, and today we’ll solve it with Python.


πŸ” Problem Setup

Imagine a compliance team at a bank. They have 10 detection rules (e.g., β€œlarge cash transaction > $10,000”, β€œrapid fund movement between accounts”, etc.). Each rule has:

  • A cost (computational/operational cost to run it)
  • A detection rate (how many true money laundering cases it catches)
  • A false positive rate (how many legitimate transactions it wrongly flags)

The goal: Select a subset of rules that maximizes detection rate, minimizes false positives, and stays within a budget constraint.

This is a variant of the Multi-Objective Knapsack Problem:

$$\max \sum_{i \in S} d_i \quad \text{subject to} \quad \sum_{i \in S} c_i \leq B, \quad \sum_{i \in S} f_i \leq F$$

Where:

  • $d_i$ = detection rate of rule $i$
  • $c_i$ = cost of rule $i$
  • $f_i$ = false positive rate of rule $i$
  • $B$ = budget limit
  • $F$ = false positive tolerance
  • $S \subseteq {1, \dots, n}$ = selected rule set

We define a composite score to balance both objectives:

$$\text{Score}(S) = \alpha \sum_{i \in S} d_i - (1 - \alpha) \sum_{i \in S} f_i$$

where $\alpha \in [0,1]$ controls the trade-off between detection power and false positive suppression.


πŸ› οΈ Solution Strategy

We solve this with three approaches and compare them:

  1. Brute Force (exact, for small $n$)
  2. Greedy Heuristic (fast approximation)
  3. Genetic Algorithm (scalable metaheuristic)

We then visualize the Pareto frontier of detection vs. false-positive trade-offs in both 2D and 3D.


πŸ’» Full Source Code

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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# ============================================================
# AML Detection Rule Optimization
# Multi-Objective Combinatorial Optimization
# ============================================================

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from mpl_toolkits.mplot3d import Axes3D
from itertools import combinations
import random
import warnings
warnings.filterwarnings('ignore')

# ── Reproducibility ──────────────────────────────────────────
np.random.seed(42)
random.seed(42)

# ============================================================
# 1. PROBLEM DEFINITION
# ============================================================

rules = {
'names': [
'Large Cash (>$10K)',
'Rapid Fund Movement',
'Structuring Pattern',
'Shell Company Link',
'High-Risk Country',
'Unusual Velocity',
'Dormant Activation',
'Round Number Txn',
'PEP Involvement',
'Crypto Conversion'
],
'cost': np.array([3, 5, 4, 6, 2, 3, 4, 2, 7, 8]),
'detection_rate': np.array([0.72, 0.85, 0.78, 0.91, 0.65,
0.70, 0.68, 0.55, 0.88, 0.93]),
'false_pos_rate': np.array([0.30, 0.20, 0.25, 0.15, 0.40,
0.35, 0.38, 0.50, 0.12, 0.18]),
}

n_rules = len(rules['names'])
BUDGET = 20 # max total cost
FP_LIMIT = 1.50 # max sum of false positive rates
ALPHA = 0.6 # weight: detection vs false-positive

cost = rules['cost']
det = rules['detection_rate']
fp = rules['false_pos_rate']

def score(subset):
"""Composite score for a given subset (list of indices)."""
if len(subset) == 0:
return 0.0
return ALPHA * det[list(subset)].sum() - (1 - ALPHA) * fp[list(subset)].sum()

def is_feasible(subset):
"""Check budget and false-positive constraints."""
if len(subset) == 0:
return False
idx = list(subset)
return cost[idx].sum() <= BUDGET and fp[idx].sum() <= FP_LIMIT

# ============================================================
# 2. BRUTE FORCE (Exact Solution)
# ============================================================

def brute_force():
best_score = -np.inf
best_subset = []
all_results = []

for r in range(1, n_rules + 1):
for subset in combinations(range(n_rules), r):
if is_feasible(subset):
s = score(subset)
all_results.append({
'subset': subset,
'score': s,
'total_det': det[list(subset)].sum(),
'total_fp': fp[list(subset)].sum(),
'total_cost': cost[list(subset)].sum(),
'n_rules': len(subset)
})
if s > best_score:
best_score = s
best_subset = subset

return best_subset, best_score, pd.DataFrame(all_results)

print("Running Brute Force...")
bf_subset, bf_score, bf_results = brute_force()
print(f" Best subset : {[rules['names'][i] for i in bf_subset]}")
print(f" Score : {bf_score:.4f}")
print(f" Total cost : {cost[list(bf_subset)].sum()}")
print(f" Detection Ξ£ : {det[list(bf_subset)].sum():.3f}")
print(f" False Pos Ξ£ : {fp[list(bf_subset)].sum():.3f}")

# ============================================================
# 3. GREEDY HEURISTIC
# ============================================================

def greedy():
selected = []
remaining = list(range(n_rules))
used_cost = 0
used_fp = 0

while remaining:
best_idx = None
best_ratio = -np.inf
for i in remaining:
if used_cost + cost[i] <= BUDGET and used_fp + fp[i] <= FP_LIMIT:
ratio = (ALPHA * det[i] - (1 - ALPHA) * fp[i]) / cost[i]
if ratio > best_ratio:
best_ratio = ratio
best_idx = i
if best_idx is None:
break
selected.append(best_idx)
used_cost += cost[best_idx]
used_fp += fp[best_idx]
remaining.remove(best_idx)

return tuple(selected), score(selected)

print("\nRunning Greedy...")
gr_subset, gr_score = greedy()
print(f" Best subset : {[rules['names'][i] for i in gr_subset]}")
print(f" Score : {gr_score:.4f}")

# ============================================================
# 4. GENETIC ALGORITHM
# ============================================================

POP_SIZE = 200
N_GEN = 300
MUT_RATE = 0.05
ELITE_RATIO = 0.1

def ga_score(chrom):
subset = [i for i, g in enumerate(chrom) if g == 1]
if not is_feasible(subset):
penalty = (max(0, cost[subset].sum() - BUDGET) +
max(0, fp[subset].sum() - FP_LIMIT)) * 2
return score(subset) - penalty if subset else -999
return score(subset) if subset else 0

def init_population():
pop = []
for _ in range(POP_SIZE):
chrom = np.zeros(n_rules, dtype=int)
idx = random.sample(range(n_rules), random.randint(1, n_rules))
chrom[idx] = 1
pop.append(chrom)
return pop

def tournament(pop, fits, k=5):
contestants = random.sample(range(len(pop)), k)
return pop[max(contestants, key=lambda x: fits[x])].copy()

def crossover(p1, p2):
pt = random.randint(1, n_rules - 1)
c1 = np.concatenate([p1[:pt], p2[pt:]])
c2 = np.concatenate([p2[:pt], p1[pt:]])
return c1, c2

def mutate(chrom):
for i in range(n_rules):
if random.random() < MUT_RATE:
chrom[i] ^= 1
return chrom

def genetic_algorithm():
pop = init_population()
best_chrom = None
best_fit = -np.inf
history = []

n_elite = max(1, int(POP_SIZE * ELITE_RATIO))

for gen in range(N_GEN):
fits = [ga_score(c) for c in pop]
order = np.argsort(fits)[::-1]

if fits[order[0]] > best_fit:
best_fit = fits[order[0]]
best_chrom = pop[order[0]].copy()

history.append(best_fit)
elites = [pop[i].copy() for i in order[:n_elite]]
new_pop = elites[:]

while len(new_pop) < POP_SIZE:
p1 = tournament(pop, fits)
p2 = tournament(pop, fits)
c1, c2 = crossover(p1, p2)
new_pop.extend([mutate(c1), mutate(c2)])

pop = new_pop[:POP_SIZE]

best_subset = tuple(i for i, g in enumerate(best_chrom) if g == 1)
return best_subset, score(best_subset), history

print("\nRunning Genetic Algorithm...")
ga_subset, ga_score_val, ga_history = genetic_algorithm()
print(f" Best subset : {[rules['names'][i] for i in ga_subset]}")
print(f" Score : {ga_score_val:.4f}")

# ============================================================
# 5. VISUALIZATION
# ============================================================

fig = plt.figure(figsize=(22, 18))
fig.patch.set_facecolor('#0f0f1a')
plt.rcParams.update({
'text.color': 'white',
'axes.labelcolor': 'white',
'xtick.color': 'white',
'ytick.color': 'white',
})

CYAN = '#00e5ff'
GREEN = '#00ff88'
ORANGE = '#ff6b35'
PURPLE = '#b388ff'
YELLOW = '#ffd740'
PINK = '#ff4081'

# ── Plot 1: Rule Properties Bar Chart ────────────────────────
ax1 = fig.add_subplot(3, 3, 1)
ax1.set_facecolor('#1a1a2e')
x = np.arange(n_rules)
w = 0.3
bars1 = ax1.bar(x - w, det, w, label='Detection Rate', color=GREEN, alpha=0.85)
bars2 = ax1.bar(x, fp, w, label='False Pos Rate', color=ORANGE, alpha=0.85)
bars3 = ax1.bar(x + w, cost / cost.max(), w, label='Cost (norm)', color=CYAN, alpha=0.85)
ax1.set_xticks(x)
ax1.set_xticklabels([r.split()[0] for r in rules['names']], rotation=45, ha='right', fontsize=7)
ax1.set_title('Rule Properties Overview', color='white', fontweight='bold')
ax1.legend(fontsize=7, facecolor='#1a1a2e', labelcolor='white')
ax1.spines[:].set_color('#333355')
ax1.set_ylim(0, 1.1)

# ── Plot 2: Score Comparison ──────────────────────────────────
ax2 = fig.add_subplot(3, 3, 2)
ax2.set_facecolor('#1a1a2e')
methods = ['Brute Force\n(Exact)', 'Greedy\n(Heuristic)', 'Genetic Alg.\n(Metaheuristic)']
scores = [bf_score, gr_score, ga_score_val]
colors = [GREEN, ORANGE, PURPLE]
bars = ax2.bar(methods, scores, color=colors, alpha=0.85, width=0.5)
for bar, s in zip(bars, scores):
ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.005,
f'{s:.4f}', ha='center', va='bottom', color='white', fontsize=9, fontweight='bold')
ax2.set_title('Score Comparison by Method', color='white', fontweight='bold')
ax2.set_ylabel('Composite Score', color='white')
ax2.spines[:].set_color('#333355')
ax2.set_ylim(0, max(scores) * 1.15)

# ── Plot 3: GA Convergence ────────────────────────────────────
ax3 = fig.add_subplot(3, 3, 3)
ax3.set_facecolor('#1a1a2e')
ax3.plot(ga_history, color=PURPLE, linewidth=1.5, alpha=0.9)
ax3.fill_between(range(len(ga_history)), ga_history, alpha=0.2, color=PURPLE)
ax3.set_title('Genetic Algorithm Convergence', color='white', fontweight='bold')
ax3.set_xlabel('Generation', color='white')
ax3.set_ylabel('Best Score', color='white')
ax3.spines[:].set_color('#333355')
ax3.axhline(bf_score, color=GREEN, linestyle='--', linewidth=1, label=f'Optimal={bf_score:.3f}')
ax3.legend(fontsize=8, facecolor='#1a1a2e', labelcolor='white')

# ── Plot 4: Pareto Frontier (2D) ──────────────────────────────
ax4 = fig.add_subplot(3, 3, 4)
ax4.set_facecolor('#1a1a2e')

feasible = bf_results[bf_results.apply(
lambda r: cost[list(r['subset'])].sum() <= BUDGET and fp[list(r['subset'])].sum() <= FP_LIMIT,
axis=1
)].copy()

sc = ax4.scatter(
feasible['total_fp'], feasible['total_det'],
c=feasible['score'], cmap='plasma',
alpha=0.6, s=20, zorder=2
)
plt.colorbar(sc, ax=ax4, label='Score').ax.yaxis.label.set_color('white')

# Mark best solutions
for subset, label, color, marker in [
(bf_subset, 'Brute Force', GREEN, '*'),
(gr_subset, 'Greedy', ORANGE, 'D'),
(ga_subset, 'GA', PURPLE, '^'),
]:
ax4.scatter(fp[list(subset)].sum(), det[list(subset)].sum(),
color=color, s=180, marker=marker, zorder=5,
label=label, edgecolors='white', linewidth=0.8)

ax4.set_xlabel('Total False Positive Rate', color='white')
ax4.set_ylabel('Total Detection Rate', color='white')
ax4.set_title('Pareto Space: Detection vs False Positives', color='white', fontweight='bold')
ax4.legend(fontsize=8, facecolor='#1a1a2e', labelcolor='white')
ax4.spines[:].set_color('#333355')

# ── Plot 5: Rule Selection Heatmap ───────────────────────────
ax5 = fig.add_subplot(3, 3, 5)
ax5.set_facecolor('#1a1a2e')
selection_matrix = np.zeros((3, n_rules))
for j, meth_subset in enumerate([bf_subset, gr_subset, ga_subset]):
for i in meth_subset:
selection_matrix[j, i] = 1

im = ax5.imshow(selection_matrix, cmap='YlOrRd', aspect='auto', vmin=0, vmax=1)
ax5.set_yticks([0, 1, 2])
ax5.set_yticklabels(['Brute\nForce', 'Greedy', 'GA'], fontsize=9)
ax5.set_xticks(range(n_rules))
ax5.set_xticklabels([r.split()[0] for r in rules['names']], rotation=45, ha='right', fontsize=7)
ax5.set_title('Rule Selection by Method', color='white', fontweight='bold')
for i in range(3):
for j in range(n_rules):
val = int(selection_matrix[i, j])
ax5.text(j, i, 'βœ“' if val else '', ha='center', va='center',
color='white', fontsize=12, fontweight='bold')
ax5.spines[:].set_color('#333355')

# ── Plot 6: Cost-Detection Efficiency Bubble ─────────────────
ax6 = fig.add_subplot(3, 3, 6)
ax6.set_facecolor('#1a1a2e')
efficiency = (ALPHA * det - (1 - ALPHA) * fp) / cost
bubble_colors = plt.cm.cool(efficiency / efficiency.max())
bubbles = ax6.scatter(cost, det, s=fp * 1000, c=efficiency, cmap='cool',
alpha=0.8, edgecolors='white', linewidth=0.5)
for i, name in enumerate(rules['names']):
ax6.annotate(name.split()[0], (cost[i], det[i]),
textcoords='offset points', xytext=(5, 3),
fontsize=7, color='white', alpha=0.9)
plt.colorbar(bubbles, ax=ax6, label='Efficiency').ax.yaxis.label.set_color('white')
ax6.set_xlabel('Cost', color='white')
ax6.set_ylabel('Detection Rate', color='white')
ax6.set_title('Efficiency Bubble Chart\n(bubble size = false positive rate)', color='white', fontweight='bold')
ax6.spines[:].set_color('#333355')

# ── Plot 7: 3D Pareto Surface ─────────────────────────────────
ax7 = fig.add_subplot(3, 3, 7, projection='3d')
ax7.set_facecolor('#0f0f1a')

sampled = feasible.sample(min(300, len(feasible)), random_state=42)
sc3d = ax7.scatter(
sampled['total_det'],
sampled['total_fp'],
sampled['total_cost'],
c=sampled['score'], cmap='plasma',
alpha=0.6, s=20
)
for subset, label, color in [
(bf_subset, 'Brute Force', GREEN),
(gr_subset, 'Greedy', ORANGE),
(ga_subset, 'GA', PURPLE),
]:
ax7.scatter(det[list(subset)].sum(), fp[list(subset)].sum(), cost[list(subset)].sum(),
color=color, s=200, marker='*', zorder=10, label=label)

ax7.set_xlabel('Detection Rate', color='white', fontsize=8)
ax7.set_ylabel('False Positive', color='white', fontsize=8)
ax7.set_zlabel('Total Cost', color='white', fontsize=8)
ax7.set_title('3D Feasible Solution Space', color='white', fontweight='bold')
ax7.tick_params(colors='white', labelsize=7)
ax7.legend(fontsize=7, facecolor='#0f0f1a', labelcolor='white')
ax7.xaxis.pane.fill = False
ax7.yaxis.pane.fill = False
ax7.zaxis.pane.fill = False

# ── Plot 8: Alpha Sensitivity ─────────────────────────────────
ax8 = fig.add_subplot(3, 3, 8)
ax8.set_facecolor('#1a1a2e')
alphas = np.linspace(0, 1, 21)
bf_scores_a = []
n_rules_sel_a = []

for a in alphas:
best_s = -np.inf
best_n = 0
for _, row in feasible.iterrows():
sub = list(row['subset'])
s = a * det[sub].sum() - (1 - a) * fp[sub].sum()
if s > best_s:
best_s = s
best_n = len(sub)
bf_scores_a.append(best_s)
n_rules_sel_a.append(best_n)

ax8_twin = ax8.twinx()
ax8.plot(alphas, bf_scores_a, color=CYAN, linewidth=2, label='Score')
ax8_twin.plot(alphas, n_rules_sel_a, color=YELLOW, linewidth=2, linestyle='--', label='# Rules')
ax8.axvline(ALPHA, color=PINK, linestyle=':', linewidth=1.5, label=f'Current Ξ±={ALPHA}')
ax8.set_xlabel('Alpha (detection weight)', color='white')
ax8.set_ylabel('Best Score', color='white')
ax8_twin.set_ylabel('# Rules Selected', color=YELLOW)
ax8_twin.tick_params(colors='white')
ax8.set_title('Sensitivity to Alpha (Ξ±)', color='white', fontweight='bold')
ax8.spines[:].set_color('#333355')
ax8_twin.spines[:].set_color('#333355')
lines1, labels1 = ax8.get_legend_handles_labels()
lines2, labels2 = ax8_twin.get_legend_handles_labels()
ax8.legend(lines1 + lines2, labels1 + labels2, fontsize=7, facecolor='#1a1a2e', labelcolor='white')

# ── Plot 9: Score Distribution ────────────────────────────────
ax9 = fig.add_subplot(3, 3, 9)
ax9.set_facecolor('#1a1a2e')
ax9.hist(feasible['score'], bins=30, color=CYAN, alpha=0.7, edgecolor='white', linewidth=0.3)
ax9.axvline(bf_score, color=GREEN, linestyle='--', linewidth=2, label=f'Optimal {bf_score:.3f}')
ax9.axvline(gr_score, color=ORANGE, linestyle='--', linewidth=2, label=f'Greedy {gr_score:.3f}')
ax9.axvline(ga_score_val, color=PURPLE, linestyle='--', linewidth=2, label=f'GA {ga_score_val:.3f}')
ax9.set_xlabel('Score', color='white')
ax9.set_ylabel('Frequency', color='white')
ax9.set_title('Score Distribution of Feasible Solutions', color='white', fontweight='bold')
ax9.legend(fontsize=8, facecolor='#1a1a2e', labelcolor='white')
ax9.spines[:].set_color('#333355')

plt.suptitle('AML Detection Rule Optimization Dashboard',
fontsize=16, color='white', fontweight='bold', y=1.01)
plt.tight_layout()
plt.savefig('aml_optimization.png', dpi=150, bbox_inches='tight',
facecolor='#0f0f1a')
plt.show()
print("\n[Dashboard saved as aml_optimization.png]")

# ============================================================
# 6. SUMMARY TABLE
# ============================================================

print("\n" + "="*65)
print(" FINAL COMPARISON SUMMARY")
print("="*65)
header = f"{'Method':<20} {'Score':>8} {'Det Ξ£':>8} {'FP Ξ£':>8} {'Cost':>6} {'#Rules':>7}"
print(header)
print("-"*65)
for method, subset in [('Brute Force', bf_subset), ('Greedy', gr_subset), ('GA', ga_subset)]:
idx = list(subset)
print(f"{method:<20} {score(subset):>8.4f} {det[idx].sum():>8.3f} "
f"{fp[idx].sum():>8.3f} {cost[idx].sum():>6} {len(idx):>7}")
print("="*65)
print(f"\nBudget limit : {BUDGET} | FP limit : {FP_LIMIT} | Alpha : {ALPHA}")

πŸ”¬ Code Walkthrough

Section 1 β€” Problem Definition

We define 10 AML detection rules, each with three properties stored as NumPy arrays:

1
2
3
cost            = operational expense to deploy each rule
detection_rate = fraction of real laundering events caught
false_pos_rate = fraction of legitimate transactions wrongly flagged

The composite score function implements the weighted objective:

$$\text{Score}(S) = \alpha \sum_{i \in S} d_i - (1-\alpha) \sum_{i \in S} f_i$$

The feasibility check enforces two hard constraints: total cost $\leq B$ and total false positive sum $\leq F$.


Section 2 β€” Brute Force (Exact)

We enumerate all $2^{10} - 1 = 1023$ non-empty subsets using itertools.combinations. For each feasible subset, we compute the score and track the global maximum. This is $O(2^n)$ β€” only viable for small $n$, but guarantees the globally optimal answer. We also save all feasible results for visualization.


Section 3 β€” Greedy Heuristic

At each step, we pick the rule with the highest score-per-unit-cost ratio from remaining candidates:

$$\text{select} \arg\max_{i \notin S} \frac{\alpha \cdot d_i - (1-\alpha) \cdot f_i}{c_i}$$

as long as adding it doesn’t violate either constraint. This runs in $O(n^2)$ and is extremely fast. It doesn’t guarantee optimality, but often gets within a few percent.


Section 4 β€” Genetic Algorithm

The GA evolves a population of binary chromosomes (length 10, gene $g_i = 1$ means rule $i$ is selected):

Component Detail
Population 200 chromosomes
Selection Tournament (k=5)
Crossover Single-point
Mutation Bit-flip, rate = 0.05
Elitism Top 10% preserved
Generations 300

Infeasible solutions are penalized (not excluded), which allows the algorithm to explore the boundary of the feasible region. This is critical β€” the optimal solution often lives near constraint boundaries.


πŸ“Š Graph Explanations

Plot 1 β€” Rule Properties Overview: Grouped bars show each rule’s detection rate (green), false positive rate (orange), and normalized cost (cyan). Rules like Crypto Conversion and PEP Involvement dominate on detection but cost more.

Plot 2 β€” Score Comparison: Vertical bars compare the final score achieved by each method. If all three reach the same score, it confirms the heuristics found the global optimum.

Plot 3 β€” GA Convergence: Shows how the genetic algorithm’s best score improves generation by generation. A rapid early rise followed by a plateau is the healthy signature of convergence. The dashed line marks the exact optimal found by brute force.

Plot 4 β€” Pareto Space (2D): Every feasible solution is plotted as detection rate (y) vs. false positive rate (x), colored by score. The three method solutions are overlaid with distinct markers. The ideal solution is top-left (high detection, low false positives).

Plot 5 β€” Rule Selection Heatmap: A binary matrix showing which rules each method selected. Checkmarks indicate selected rules. This directly reveals agreement and divergence between methods.

Plot 6 β€” Efficiency Bubble Chart: Each rule is plotted by cost (x) vs. detection rate (y). Bubble size encodes false positive rate β€” bigger bubbles are riskier. Color shows efficiency $= \frac{\alpha d_i - (1-\alpha) f_i}{c_i}$. High-efficiency rules are cooler in color, appearing in the upper-left with small bubbles.

Plot 7 β€” 3D Feasible Solution Space: The three axes are detection rate, false positive rate, and total cost. Each point is a feasible rule combination. The three optimal solutions are marked with stars. This reveals whether trade-offs exist along the cost dimension that 2D plots miss.

Plot 8 β€” Alpha Sensitivity: We sweep $\alpha$ from 0 (minimize false positives only) to 1 (maximize detection only) and re-solve. The score and number of rules selected both respond. Near $\alpha = 0$, the system picks fewer, more precise rules. Near $\alpha = 1$, it casts a wider net. This plot guides compliance teams in calibrating the system to their risk appetite.

Plot 9 β€” Score Distribution: Histogram of all feasible solution scores. The vertical lines show where each method’s solution falls in this distribution. A good solution sits in the right tail β€” and the optimal sits at the very edge.


πŸ“‹ Execution Results

Running Brute Force...
  Best subset : ['Large Cash (>$10K)', 'Rapid Fund Movement', 'Structuring Pattern', 'Shell Company Link', 'High-Risk Country']
  Score       : 1.8260
  Total cost  : 20
  Detection Ξ£ : 3.910
  False Pos Ξ£ : 1.300

Running Greedy...
  Best subset : ['High-Risk Country', 'Large Cash (>$10K)', 'Unusual Velocity', 'Structuring Pattern', 'Rapid Fund Movement']
  Score       : 1.6200

Running Genetic Algorithm...
  Best subset : ['Large Cash (>$10K)', 'Rapid Fund Movement', 'Structuring Pattern', 'Shell Company Link', 'High-Risk Country']
  Score       : 1.8260

[Dashboard saved as aml_optimization.png]

=================================================================
  FINAL COMPARISON SUMMARY
=================================================================
Method                  Score    Det Ξ£     FP Ξ£   Cost  #Rules
-----------------------------------------------------------------
Brute Force            1.8260    3.910    1.300     20       5
Greedy                 1.6200    3.700    1.500     17       5
GA                     1.8260    3.910    1.300     20       5
=================================================================

Budget limit : 20   |   FP limit : 1.5   |   Alpha : 0.6

🧠 Key Takeaways

The brute force solution is provably optimal β€” for 10 rules it’s entirely tractable ($2^{10} = 1024$ subsets). As rule sets grow to 30–50 rules, only the GA (or similar metaheuristics like Simulated Annealing or Particle Swarm) remain practical.

The alpha parameter is your policy knob. A conservative compliance team minimizing customer friction should use low $\alpha$. An aggressive anti-fraud team should push $\alpha$ higher. The sensitivity plot quantifies exactly how this choice affects outcome.

Greedy performs remarkably well in this problem class. The ratio-based selection naturally aligns with the structure of the knapsack constraint, often finding near-optimal solutions in microseconds versus the GA’s seconds.

In production, this framework would be extended with correlated detection (rules that fire together on the same cases), time-varying false positive costs (certain flags are more expensive during peak seasons), and regulatory minimum coverage constraints (e.g., FATF requirements mandating certain rule categories must always be active).