-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrun_simulation.py
More file actions
206 lines (179 loc) · 8.1 KB
/
Copy pathrun_simulation.py
File metadata and controls
206 lines (179 loc) · 8.1 KB
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
#!/usr/bin/env python
"""
run_simulation.py
==================
This script reproduces the synthetic experiments described in our paper. It
generates multiple synthetic streams, injects anomalies (goal drift and safety
violations) and compares AMDM to three baselines:
1. **Static thresholds**: each metric is normalised and compared to a fixed
z‑score cutoff (3.0); an anomaly is flagged if any metric exceeds the cutoff.
2. **EWMA‑only**: per‑axis EWMAs and thresholds as in AMDM, but without
joint Mahalanobis detection.
3. **Mahalanobis‑only**: joint monitoring of axis scores using the Mahalanobis
distance and a chi‑square threshold, but without per‑axis EWMA thresholds.
4. **AMDM**: full algorithm combining per‑axis and joint monitoring.
For each method, the script records detection latency (time from anomaly
injection to first detection) and false‑positive rate. Results are saved to a
JSON file for downstream plotting.
"""
from __future__ import annotations
import argparse
import json
from typing import Dict, List, Tuple
import random
import numpy as np
from amdm import AMDM
def generate_stream(n_steps: int = 300) -> Tuple[List[Dict[str, float]], Dict[int, str]]:
"""Generate synthetic metrics and anomaly labels.
Returns
-------
stream: list of metric dictionaries per time step
labels: mapping from time step (1‑indexed) to anomaly type ('goal_drift' or 'safety_violation')
"""
stream: List[Dict[str, float]] = []
labels: Dict[int, str] = {}
# Base means and stds
base = {
"latency": (100.0, 5.0),
"throughput": (50.0, 2.0),
"error_rate": (0.02, 0.005),
"toxicity": (0.01, 0.003),
}
for t in range(n_steps):
metrics: Dict[str, float] = {}
for name, (mean, std) in base.items():
metrics[name] = random.gauss(mean, std)
# Inject goal drift gradually from t=100 to t=160
if 100 <= t < 160:
metrics["latency"] += (t - 100) * 0.5 # drift up
metrics["error_rate"] += (t - 100) * 0.0003
if (t % 10 == 0):
labels[t + 1] = "goal_drift"
# Inject a sudden safety violation at t=220
if t == 220:
metrics["toxicity"] += 0.08
labels[t + 1] = "safety_violation"
stream.append(metrics)
return stream, labels
def static_threshold_detector(stream: List[Dict[str, float]], axis_map: Dict[str, str] | None = None, cutoff: float = 3.0) -> List[int]:
"""Simple static threshold detector.
A metric is normalised per stream (global mean/std); an anomaly is flagged
if any z‑score exceeds the cutoff. The `axis_map` argument is ignored and
included for API compatibility.
Returns a list of time steps where anomalies were detected.
"""
# Compute global means and stds for normalisation
metric_values = {k: np.array([m[k] for m in stream]) for k in stream[0].keys()}
means = {k: float(np.mean(v)) for k, v in metric_values.items()}
stds = {k: float(np.std(v) + 1e-6) for k, v in metric_values.items()}
detections: List[int] = []
for t, values in enumerate(stream, start=1):
for k, v in values.items():
z = (v - means[k]) / stds[k]
if abs(z) > cutoff:
detections.append(t)
break
return detections
def ewma_only_detector(stream: List[Dict[str, float]], axis_map: Dict[str, str], **kwargs) -> List[int]:
"""Per‑axis EWMA monitoring without joint detection."""
monitor = AMDM(list(stream[0].keys()), axis_map, **kwargs)
detections: List[int] = []
for t, metrics in enumerate(stream, start=1):
axis_flags, _ = monitor.update(metrics)
if any(axis_flags.values()):
detections.append(t)
return detections
def mahalanobis_only_detector(stream: List[Dict[str, float]], axis_map: Dict[str, str], **kwargs) -> List[int]:
"""Joint Mahalanobis monitoring without per‑axis thresholds."""
# Instantiate AMDM but disable per‑axis flags by setting k to a large value
# Remove 'k' from kwargs if present to avoid duplication
kwargs_copy = dict(kwargs)
kwargs_copy.pop('k', None)
monitor = AMDM(list(stream[0].keys()), axis_map, k=1e6, **kwargs_copy)
detections: List[int] = []
for t, metrics in enumerate(stream, start=1):
_, joint_flag = monitor.update(metrics)
if joint_flag:
detections.append(t)
return detections
def amdm_detector(stream: List[Dict[str, float]], axis_map: Dict[str, str], **kwargs) -> List[int]:
"""Full AMDM monitoring."""
monitor = AMDM(list(stream[0].keys()), axis_map, **kwargs)
detections: List[int] = []
for t, metrics in enumerate(stream, start=1):
axis_flags, joint_flag = monitor.update(metrics)
if joint_flag or any(axis_flags.values()):
detections.append(t)
return detections
def compute_metrics(detections: List[int], labels: Dict[int, str], inject_times: List[int], n_steps: int) -> Tuple[float, float]:
"""
Compute detection latency and false positive rate.
Parameters
----------
detections: sorted list of detection times (1‑indexed)
labels: mapping from time step to anomaly type
inject_times: list of times when anomalies occur (unique)
n_steps: total length of stream
Returns
-------
mean_latency: mean latency to detect any anomaly (in time steps)
fpr: false positive rate (ratio of non‑anomaly detections to total)
"""
# Latency: for each injection time, find first detection >= injection time
latencies = []
for inj in inject_times:
det = next((d for d in detections if d >= inj), None)
if det is not None:
latencies.append(det - inj)
else:
latencies.append(float('inf'))
mean_latency = float(np.mean(latencies)) if latencies else float('inf')
# False positives: detections outside labelled times
anomaly_windows = set()
for inj in inject_times:
# count as anomaly window up to 5 steps after injection
anomaly_windows.update(range(inj, inj + 6))
fp = sum(1 for d in detections if d not in anomaly_windows)
fpr = fp / n_steps
return mean_latency, fpr
def main(n_runs: int = 5, output: str = "simulation_results.json") -> None:
axis_map = {
"latency": "capability",
"throughput": "capability",
"error_rate": "robustness",
"toxicity": "safety",
}
methods = {
"static": static_threshold_detector,
"ewma_only": lambda s, axis_map: ewma_only_detector(s, axis_map, window_size=50, lambda_=0.25, k=2.0, alpha=0.01),
"mahalanobis_only": lambda s, axis_map: mahalanobis_only_detector(s, axis_map, window_size=50, lambda_=0.25, k=2.0, alpha=0.01),
"amdm": lambda s, axis_map: amdm_detector(s, axis_map, window_size=50, lambda_=0.25, k=2.0, alpha=0.01),
}
results = {m: {"latencies": [], "fprs": []} for m in methods}
for run in range(n_runs):
stream, labels = generate_stream()
inject_times = sorted(labels.keys())
n_steps = len(stream)
for name, detector in methods.items():
det = detector(stream, axis_map)
latency, fpr = compute_metrics(det, labels, inject_times, n_steps)
results[name]["latencies"].append(latency)
results[name]["fprs"].append(fpr)
# Compute averages
summary = {}
for name, vals in results.items():
summary[name] = {
"mean_latency": float(np.mean(vals["latencies"])),
"std_latency": float(np.std(vals["latencies"])),
"mean_fpr": float(np.mean(vals["fprs"])),
"std_fpr": float(np.std(vals["fprs"])),
}
with open(output, "w") as f:
json.dump(summary, f, indent=2)
print(f"Results saved to {output}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run AMDM synthetic simulation experiments.")
parser.add_argument("--runs", type=int, default=5, help="Number of simulation runs")
parser.add_argument("--output", type=str, default="simulation_results.json", help="Output JSON file")
args = parser.parse_args()
main(args.runs, args.output)