Skip to content

Commit 4d03656

Browse files
committed
feat: live benchmark ground truth, progress bar, sample size control
- ground_truth.py: from_detections() — stratified sample from detections table using rule verdicts as ground truth (confirmed/rule_match = anomaly) default 150 events, 35% min anomaly ratio - comparator.py: progress_cb parameter threaded through multi_run_benchmark → BenchmarkComparator → quantum event loop, reports run/event/total - app.py: Rule-based ground truth as default label source in live mode, sample size slider (50-500, default 150), progress bar for both single-mode and compare-both benchmark runs
1 parent fdafb50 commit 4d03656

3 files changed

Lines changed: 289 additions & 204 deletions

File tree

src/benchmark/comparator.py

Lines changed: 58 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
5. Store benchmark_run in DuckDB
1111
6. Return comparison report
1212
13-
Classical head uses the same 16-dimensional feature vector as the
13+
Classical head uses the same 24-dimensional feature vector as the
1414
quantum head — a weighted linear threshold on the feature dimensions
1515
most predictive of anomalous activity. This ensures apples-to-apples
1616
comparison: neither head has access to information the other doesn't.
@@ -33,27 +33,7 @@
3333

3434

3535
# ── Classical feature-vector classifier ───────────────────────────
36-
# Weighted linear combination of the 16 feature dimensions.
37-
# Weights reflect empirical separation between benign and anomaly
38-
# classes observed in the demo dataset analysis.
39-
#
40-
# Dimension index → weight mapping:
41-
# 0 source_ip_is_private -0.5 (public IP = more suspicious for scans)
42-
# 1 source_ip_oct3_norm 0.1
43-
# 2 source_ip_oct4_norm 0.2
44-
# 3 source_ip_entropy 0.1
45-
# 4 severity_norm 1.5 (strong signal)
46-
# 5 severity_is_high 2.0 (strongest signal)
47-
# 6 process_risk 1.2
48-
# 7 process_is_auth 0.8
49-
# 8 process_is_network 0.8 (raised: kernel DROP = network process)
50-
# 9 message_has_fail 1.0
51-
# 10 message_has_auth 0.6
52-
# 11 message_has_exploit 1.3
53-
# 12 message_has_scan 1.8 (raised: primary port scan signal)
54-
# 13 repeat_source 0.9
55-
# 14 hour_of_day_norm 0.0
56-
# 15 is_off_hours 0.4
36+
# Weighted linear combination of the 24 feature dimensions (v2).
5737

5838
_FV_WEIGHTS = [
5939
# ── v1 base dimensions (0-15) ──────────────────────────────────
@@ -86,8 +66,6 @@
8666

8767
# Decision threshold — optimised for v2 24-dim vector
8868
# F1=0.989, Acc=99%, FPR=1.8%, 0 FN on 100-event demo dataset
89-
# One persistent FP: legitimate admin SSH session (benign_auth, score=5.71)
90-
# — structurally indistinguishable from brute force at feature level
9169
_FV_THRESHOLD = 4.974
9270

9371

@@ -102,20 +80,22 @@ def _parse_feature_vector(event: dict) -> list[float]:
10280
"""Extract feature vector from event dict, handling JSON string storage."""
10381
fv = event.get("feature_vector")
10482
if fv is None:
105-
return [0.0] * 16
83+
return [0.0] * 24
10684
if isinstance(fv, str):
10785
try:
10886
fv = json.loads(fv)
10987
except Exception:
110-
return [0.0] * 16
88+
try:
89+
import ast
90+
fv = ast.literal_eval(fv)
91+
except Exception:
92+
return [0.0] * 24
11193
if isinstance(fv, list):
11294
return [float(x) for x in fv]
113-
return [0.0] * 16
95+
return [0.0] * 24
11496

11597

116-
# ── Module-level decoder singleton ───────────────────────────────
117-
# Shared across all BenchmarkComparator instances in the same process
118-
# to avoid reloading model weights on every benchmark run.
98+
# ── Module-level decoder singleton ────────────────────────────────
11999
_shared_decoder = None
120100

121101
def _get_shared_decoder():
@@ -138,35 +118,27 @@ def __init__(
138118
scenario_name: str = "",
139119
noise_enabled: Optional[bool] = None,
140120
backend: Optional[str] = None,
121+
progress_cb: object = None,
122+
run_idx: int = 0,
141123
):
142124
self.circuit_type = circuit_type
143125
self.scenario_name = scenario_name or f"benchmark_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
144-
self.q_runner = QuantumRunner(circuit_type=circuit_type,
145-
decoder=_get_shared_decoder(),
146-
noise_enabled=noise_enabled,
147-
backend=backend)
126+
self.progress_cb = progress_cb
127+
self.run_idx = run_idx
128+
self.q_runner = QuantumRunner(
129+
circuit_type=circuit_type,
130+
decoder=_get_shared_decoder(),
131+
noise_enabled=noise_enabled,
132+
backend=backend,
133+
)
148134

149135
def run(
150136
self,
151-
events: list[dict],
152-
ground_truth: list[int],
137+
events: list[dict],
138+
ground_truth: list[int],
153139
classical_results: Optional[list[dict]] = None,
154-
notes: str = "",
140+
notes: str = "",
155141
) -> dict:
156-
"""
157-
Execute a full benchmark run.
158-
159-
Args:
160-
events: List of normalised ECS events
161-
ground_truth: Binary labels (1=anomaly, 0=benign)
162-
Must match length of events
163-
classical_results: Pre-computed classical results.
164-
If None, uses feature-vector classifier.
165-
notes: Optional notes stored with the run
166-
167-
Returns:
168-
Full comparison report dict
169-
"""
170142
if len(events) != len(ground_truth):
171143
raise ValueError(
172144
f"events ({len(events)}) and ground_truth "
@@ -175,7 +147,7 @@ def run(
175147
if not events:
176148
raise ValueError("events list cannot be empty")
177149

178-
# ── Classical head ────────────────────────────────────────
150+
# ── Classical head ────────────────────────────────────────
179151
if classical_results:
180152
c_predictions = [r.get("prediction", 0) for r in classical_results]
181153
c_latencies = [r.get("latency_ms", 0.0) for r in classical_results]
@@ -186,82 +158,58 @@ def run(
186158
c_predictions, ground_truth, c_latencies, label="classical"
187159
)
188160

189-
# ── Quantum head ──────────────────────────────────────────
161+
# ── Quantum head ──────────────────────────────────────────
190162
q_predictions = []
191163
q_latencies = []
192164

193-
for event in events:
165+
for _ev_idx, event in enumerate(events):
194166
result = self.q_runner.run(event)
195167
q_predictions.append(result["prediction"])
196168
q_latencies.append(result["latency_ms"])
169+
if self.progress_cb:
170+
try:
171+
self.progress_cb(self.run_idx, _ev_idx, len(events))
172+
except Exception:
173+
pass
197174

198175
quantum_metrics = compute_metrics(
199176
q_predictions, ground_truth, q_latencies, label="quantum"
200177
)
201178

202-
# ── Compare ───────────────────────────────────────────────
179+
# ── Compare & store ────────────────────────────────────────
203180
comparison = compare_metrics(classical_metrics, quantum_metrics)
181+
run_id = self._store_benchmark(classical_metrics, quantum_metrics, events, notes)
204182

205-
# ── Store ─────────────────────────────────────────────────
206-
run_id = self._store_benchmark(
207-
classical_metrics, quantum_metrics,
208-
events, notes
209-
)
210-
211-
comparison["run_id"] = run_id
212-
comparison["scenario_name"] = self.scenario_name
213-
comparison["n_events"] = len(events)
214-
comparison["circuit_type"] = self.circuit_type
215-
comparison["created_at"] = datetime.now(timezone.utc).isoformat()
183+
comparison["run_id"] = run_id
184+
comparison["scenario_name"] = self.scenario_name
185+
comparison["n_events"] = len(events)
186+
comparison["circuit_type"] = self.circuit_type
187+
comparison["created_at"] = datetime.now(timezone.utc).isoformat()
216188
comparison["classical_predictions"] = c_predictions
217189
comparison["quantum_predictions"] = q_predictions
218190

219191
return comparison
220192

221-
# ── Classifiers ───────────────────────────────────────────────
222-
223-
def _fv_classifier(
224-
self,
225-
events: list[dict],
226-
) -> tuple[list[int], list[float]]:
227-
"""
228-
Feature-vector weighted linear classifier.
229-
Uses the same 16-dimensional feature vector as the quantum head.
230-
Predicts anomaly (1) if weighted score >= _FV_THRESHOLD.
231-
Returns (predictions, latencies_ms).
232-
"""
193+
def _fv_classifier(self, events: list[dict]) -> tuple[list[int], list[float]]:
233194
import time
234-
predictions = []
235-
latencies = []
236-
195+
predictions, latencies = [], []
237196
for event in events:
238197
start = time.perf_counter()
239198
fv = _parse_feature_vector(event)
240199
score = _fv_score(fv)
241200
predictions.append(1 if score >= _FV_THRESHOLD else 0)
242201
latencies.append((time.perf_counter() - start) * 1000)
243-
244202
return predictions, latencies
245203

246-
def _severity_classifier(
247-
self,
248-
events: list[dict],
249-
) -> tuple[list[int], list[float]]:
250-
"""
251-
Legacy severity-threshold classifier. Kept for reference.
252-
Predicts anomaly (1) if event_severity >= 50.
253-
NOT used in benchmark — replaced by _fv_classifier.
254-
"""
204+
def _severity_classifier(self, events: list[dict]) -> tuple[list[int], list[float]]:
205+
"""Legacy severity-threshold classifier — not used in benchmark."""
255206
import time
256-
predictions = []
257-
latencies = []
258-
207+
predictions, latencies = [], []
259208
for event in events:
260209
start = time.perf_counter()
261210
sev = event.get("event_severity") or 0
262211
predictions.append(1 if sev >= 50 else 0)
263212
latencies.append((time.perf_counter() - start) * 1000)
264-
265213
return predictions, latencies
266214

267215
def _store_benchmark(
@@ -271,10 +219,8 @@ def _store_benchmark(
271219
events: list[dict],
272220
notes: str,
273221
) -> str:
274-
"""Store benchmark run in DuckDB. Returns run ID."""
275222
run_id = str(uuid.uuid4())
276223
event_ids = [e.get("id", "") for e in events]
277-
278224
con = get_connection()
279225
con.execute("""
280226
INSERT OR REPLACE INTO benchmark_runs (
@@ -287,16 +233,10 @@ def _store_benchmark(
287233
notes
288234
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
289235
""", [
290-
run_id,
291-
datetime.now(timezone.utc),
292-
self.scenario_name,
293-
event_ids,
294-
classical.accuracy, classical.f1,
295-
classical.fpr, classical.avg_latency_ms,
296-
quantum.accuracy, quantum.f1,
297-
quantum.fpr, quantum.avg_latency_ms,
298-
classical.efficacy, quantum.efficacy,
299-
notes,
236+
run_id, datetime.now(timezone.utc), self.scenario_name, event_ids,
237+
classical.accuracy, classical.f1, classical.fpr, classical.avg_latency_ms,
238+
quantum.accuracy, quantum.f1, quantum.fpr, quantum.avg_latency_ms,
239+
classical.efficacy, quantum.efficacy, notes,
300240
])
301241
con.close()
302242
return run_id
@@ -309,11 +249,10 @@ def quick_benchmark(
309249
scenario: str = "",
310250
) -> dict:
311251
"""Convenience function for a one-line benchmark run."""
312-
comparator = BenchmarkComparator(
252+
return BenchmarkComparator(
313253
circuit_type=circuit_type,
314254
scenario_name=scenario,
315-
)
316-
return comparator.run(events, ground_truth)
255+
).run(events, ground_truth)
317256

318257

319258
def multi_run_benchmark(
@@ -325,11 +264,11 @@ def multi_run_benchmark(
325264
classical_results: list[dict] | None = None,
326265
noise_enabled: bool | None = None,
327266
backend: str | None = None,
267+
progress_cb: object = None,
328268
) -> dict:
329269
"""
330-
Run benchmark N times and return averaged results with
331-
standard deviation — giving statistically meaningful results
332-
at low qubit counts where single-run variance is high.
270+
Run benchmark N times and return averaged results with std dev.
271+
progress_cb(run_idx, event_idx, total_events) called per event.
333272
"""
334273
if n_runs < 1:
335274
raise ValueError("n_runs must be >= 1")
@@ -341,6 +280,8 @@ def multi_run_benchmark(
341280
scenario_name=f"{scenario}_run{i+1}" if scenario else f"run{i+1}",
342281
noise_enabled=noise_enabled,
343282
backend=backend,
283+
progress_cb=progress_cb,
284+
run_idx=i,
344285
)
345286
report = comp.run(events, ground_truth, classical_results=classical_results)
346287
runs.append(report)
@@ -356,7 +297,6 @@ def agg(key: str, subkey: str) -> dict:
356297
"max": round(max(vals), 4),
357298
}
358299

359-
# Aggregate predictions across runs (majority vote per event)
360300
def majority_preds(key: str) -> list[int]:
361301
all_preds = [r.get(key, []) for r in runs]
362302
if not all_preds or not all_preds[0]:
@@ -369,10 +309,10 @@ def majority_preds(key: str) -> list[int]:
369309
return result
370310

371311
return {
372-
"n_runs": n_runs,
373-
"circuit_type": circuit_type,
374-
"scenario": scenario,
375-
"n_events": len(events),
312+
"n_runs": n_runs,
313+
"circuit_type": circuit_type,
314+
"scenario": scenario,
315+
"n_events": len(events),
376316
"classical": {
377317
"efficacy": agg("classical", "efficacy"),
378318
"accuracy": agg("classical", "accuracy"),
@@ -400,7 +340,6 @@ def majority_preds(key: str) -> list[int]:
400340

401341

402342
def _determine_overall_winner(runs: list[dict]) -> str:
403-
"""Determine winner across multiple runs by average efficacy delta."""
404343
import statistics
405344
deltas = [r["delta"]["efficacy"] for r in runs]
406345
avg_delta = statistics.mean(deltas)

0 commit comments

Comments
 (0)