Skip to content

Commit eb7ec66

Browse files
committed
feat: head-to-head learned vs rolling anomaly-detector evaluation + results
1 parent 38b278a commit eb7ec66

9 files changed

Lines changed: 478 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/).
55

66
## [Unreleased]
77
### Added
8+
- Head-to-head detector evaluation (`alerts/eval_detector.py`): learned vs
9+
rolling on the same labeled stream, showing the frozen model holds recall
10+
(~1.0) where the rolling detector's recall collapses as faults poison its
11+
moving baseline.
12+
- Offline-trained anomaly detector (`alerts/detector.py`): a Gaussian/Mahalanobis
13+
model fit on clean data with a threshold calibrated to a target false-positive
14+
rate, versioned to `alerts/model.json`. Trained + evaluated by
15+
`alerts/train_detector.py` (precision 0.98 / recall 1.00 on injected faults).
16+
The engine uses it when `anomaly.method: learned`, falling back to the online
17+
rolling detector if the model is missing.
818
- Occupancy map + laser scan path: new `map` and `scan` sample kinds, a synthetic
919
bordered-room map and ray-cast scans in the publisher, a `maps` table + upsert
1020
in ingest, `GET /api/map`, and 3D rendering of the map (floor) and live scans

README.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ The design decision worth calling out: **ingestion is separated from serving.**
7272
- **Fleet monitoring** — real-time status across multiple robots, with online/offline detection and fleet-wide KPIs.
7373
- **3D pose visualization** — live robot positions with historical trajectory trails in a shared scene.
7474
- **Telemetry analytics** — battery, CPU temperature, and IMU signals with history backed by TimescaleDB and 1-second rollups.
75-
- **Alert engine** — threshold rules, topic staleness/missing-data detection, and **multivariate anomaly detection** (rolling Mahalanobis distance) that flags unusual *combinations* of signals the thresholds miss.
75+
- **Alert engine** — threshold rules, topic staleness/missing-data detection, and **multivariate anomaly detection** that flags unusual *combinations* of signals the thresholds miss. Ships with an offline-**trained** model (precision 0.98 / recall 1.00 on injected faults) and an online rolling fallback.
7676
- **Occupancy map + live laser scans** — the 3D viewer renders a `nav_msgs/OccupancyGrid` as the scene floor and `sensor_msgs/LaserScan` returns as a live point cloud around each robot. Demoable on the synthetic fleet today; the bridge consumes real Nav2/Gazebo `/map` and `/scan` unchanged.
7777
- **Session record & replay** — bookmark a time range, then scrub through it on a timeline (play/pause/seek/speed) with the whole dashboard replaying from stored data.
7878
- **Self-observable** — a Prometheus `/metrics` endpoint so Ros Scope can be scraped and graphed in Grafana like any production service.
@@ -167,6 +167,33 @@ docker compose --profile bench run --rm bench --count 100000 --latency-samples 5
167167

168168
It prints a JSON summary (throughput and p50/p95/p99 latency) suitable for dropping into a results table here. The metric math (`bench/stats.py`) is pure and unit-tested.
169169

170+
### Detection quality
171+
172+
The trained anomaly detector is evaluated against injected faults (CPU-temperature spikes and out-of-envelope yaw). On a held-out synthetic set of 4,000 normal + 2,000 fault vectors, calibrated to a 1% target false-positive rate:
173+
174+
| Metric | Value |
175+
|--------|-------|
176+
| Precision | 0.98 |
177+
| Recall | 1.00 |
178+
| F1 | 0.99 |
179+
| False-positive rate | 0.009 |
180+
181+
The trained model is also evaluated head-to-head against the online rolling detector on the *same* labeled stream (3,000 samples with interspersed faults). The rolling detector adapts, but faults leak into its moving baseline and crater its recall — which is exactly why the frozen, calibrated model is the default:
182+
183+
| Detector | Precision | Recall | F1 |
184+
|----------|-----------|--------|-----|
185+
| **Learned (frozen)** | 0.96 | **1.00** | **0.98** |
186+
| Rolling (online) | 1.00 | 0.07 | 0.13 |
187+
188+
Reproduce (and retrain on your own clean data) with:
189+
190+
```bash
191+
python3 -m alerts.train_detector # writes alerts/model.json + prints metrics
192+
python3 -m alerts.eval_detector # learned vs rolling, same labeled stream
193+
```
194+
195+
The model is unsupervised — it never sees faults during training; the labels exist only to measure detection quality afterwards. If the model file is missing, the engine falls back to the online rolling detector automatically.
196+
170197
## 🛠 Engineering Decisions
171198

172199
A few choices that make this more than a toy, and what they buy:
@@ -175,7 +202,7 @@ A few choices that make this more than a toy, and what they buy:
175202
- **Batched `COPY` ingestion.** The ingest worker accumulates samples and writes them with `copy_records_to_table`, dramatically cheaper than row-by-row inserts at sensor rates.
176203
- **Continuous aggregate for history.** Charts over long windows read a 1-second rollup instead of raw rows, keeping payloads small and queries fast; raw data has a 7-day retention policy.
177204
- **Staleness as a first-class signal.** "No data" is often the most important alert in robotics — the engine tracks last-seen time per topic and fires when a stream goes quiet, not just on bad values.
178-
- **Anomalies beyond thresholds.** A rolling Mahalanobis-distance detector catches unusual multivariate patterns (e.g. a CPU-temperature blip that never crosses the hard limit).
205+
- **Anomalies beyond thresholds.** An offline-trained Gaussian model (Mahalanobis distance) with a threshold calibrated to a 1% false-positive rate catches unusual multivariate patterns a CPU-temp blip that never crosses the hard limit, say measured at 0.98 precision / 1.00 recall on injected faults. The model is versioned to disk and retrainable; an online rolling detector is the fallback.
179206
- **Interchangeable producers.** A shared envelope means the synthetic publisher and the ROS 2 bridge are drop-in replacements — which is what lets the project demo with zero hardware.
180207
- **Self-observable.** A Prometheus `/metrics` endpoint exposes ingest rate, active alerts, anomalies, and fleet KPIs, so the observability platform is itself observable.
181208

@@ -186,13 +213,13 @@ common/ shared telemetry envelope + logging helper (used by every service)
186213
sim/ synthetic fleet publisher (default data source)
187214
bridge/ ROS 2 rclpy bridge + demo bot (profile: ros)
188215
ingest/ Redis stream -> TimescaleDB worker
189-
alerts/ threshold, staleness + anomaly rule engine
216+
alerts/ threshold, staleness + anomaly rule engine (+ trained detector, model.json)
190217
api/ FastAPI: REST, /ws/live, /metrics, static dashboard
191218
api/static/ the dashboard (Three.js + µPlot)
192219
db/ TimescaleDB schema + continuous aggregate (telemetry, poses, maps)
193220
monitoring/ Prometheus scrape config + provisioned Grafana dashboard
194221
bench/ pipeline benchmark harness (throughput + latency)
195-
tests/ unit tests: rules, schema, simulator, anomaly, metrics, bench
222+
tests/ unit tests: rules, schema, simulator, anomaly, detector, metrics, bench
196223
```
197224

198225
## 🎯 Outcome

alerts/detector.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Offline-trained anomaly detector.
2+
3+
The rolling detector in anomaly.py recomputes mean/covariance from a moving
4+
window at runtime: it adapts, but it has no calibrated threshold and a sustained
5+
fault can slowly poison its own baseline. This module is the trained alternative:
6+
7+
1. fit a Gaussian model (mean + covariance) on a curated *clean* dataset,
8+
2. calibrate the decision threshold to a target false-positive rate,
9+
3. freeze and version the model to JSON,
10+
4. score live vectors by Mahalanobis distance against the frozen model.
11+
12+
Training, evaluation (precision/recall against injected faults), and the model
13+
file are produced by train_detector.py. Everything here is pure numpy, so it is
14+
unit-tested directly in tests/test_detector.py.
15+
"""
16+
from __future__ import annotations
17+
18+
import json
19+
20+
import numpy as np
21+
22+
MODEL_VERSION = 1
23+
24+
25+
def fit(x: np.ndarray, features: list[str], target_fpr: float = 0.01, reg: float = 1e-6) -> dict:
26+
"""Fit mean + covariance on clean data and calibrate the threshold so that
27+
only `target_fpr` of the training points are flagged."""
28+
x = np.asarray(x, dtype=float)
29+
mean = x.mean(axis=0)
30+
cov = np.atleast_2d(np.cov(x, rowvar=False)) + np.eye(x.shape[1]) * reg
31+
inv = np.linalg.inv(cov)
32+
dists = _batch_mahalanobis(x, mean, inv)
33+
threshold = float(np.quantile(dists, 1.0 - target_fpr))
34+
return {
35+
"version": MODEL_VERSION,
36+
"features": list(features),
37+
"mean": mean.tolist(),
38+
"inv_cov": inv.tolist(),
39+
"threshold": threshold,
40+
"target_fpr": target_fpr,
41+
"trained_on": int(x.shape[0]),
42+
}
43+
44+
45+
def score(model: dict, vec: np.ndarray) -> float:
46+
"""Mahalanobis distance of one vector from the trained model."""
47+
mean = np.asarray(model["mean"], dtype=float)
48+
inv = np.asarray(model["inv_cov"], dtype=float)
49+
d = np.asarray(vec, dtype=float) - mean
50+
return float(np.sqrt(max(0.0, d @ inv @ d)))
51+
52+
53+
def predict(model: dict, vec: np.ndarray) -> bool:
54+
return score(model, vec) > model["threshold"]
55+
56+
57+
def _batch_mahalanobis(x: np.ndarray, mean: np.ndarray, inv: np.ndarray) -> np.ndarray:
58+
d = x - mean
59+
return np.sqrt(np.maximum(0.0, np.einsum("ij,jk,ik->i", d, inv, d)))
60+
61+
62+
def save_model(model: dict, path: str) -> None:
63+
with open(path, "w") as f:
64+
json.dump(model, f, indent=2)
65+
66+
67+
def load_model(path: str) -> dict:
68+
with open(path) as f:
69+
return json.load(f)
70+
71+
72+
class LearnedDetector:
73+
"""Drop-in replacement for AnomalyDetector that scores against a frozen,
74+
pre-trained model instead of a rolling window. Same update() interface."""
75+
76+
def __init__(self, model: dict, cooldown_s: float = 20.0) -> None:
77+
self.model = model
78+
self.features = list(model["features"])
79+
self.cooldown_s = cooldown_s
80+
self._latest: dict[str, dict[str, float]] = {}
81+
self._last_alert: dict[str, float] = {}
82+
83+
def update(self, robot_id: str, metric: str, value: float, now: float):
84+
if metric not in self.features:
85+
return None
86+
latest = self._latest.setdefault(robot_id, {})
87+
latest[metric] = value
88+
if len(latest) < len(self.features):
89+
return None
90+
vec = np.array([latest[f] for f in self.features], dtype=float)
91+
sc = score(self.model, vec)
92+
if sc > self.model["threshold"]:
93+
if now - self._last_alert.get(robot_id, -1e9) >= self.cooldown_s:
94+
self._last_alert[robot_id] = now
95+
return {"robot_id": robot_id, "score": round(sc, 2),
96+
"features": {f: round(float(v), 3)
97+
for f, v in zip(self.features, vec, strict=False)}}
98+
return None

alerts/engine.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dataclasses import dataclass
2020

2121
from alerts.anomaly import AnomalyDetector
22+
from alerts.detector import LearnedDetector, load_model
2223
from common.log import get_logger
2324

2425
# Note: asyncpg / redis / yaml are imported lazily inside the functions that
@@ -115,6 +116,24 @@ async def staleness_loop(pool, r, staleness, last_seen, cd) -> None:
115116
await persist_and_publish(pool, r, alert)
116117

117118

119+
def _build_detector(acfg: dict):
120+
"""Pick the anomaly detector: a frozen trained model when method=learned and
121+
the model file loads, otherwise the online rolling detector."""
122+
cooldown = acfg.get("cooldown_s", 20)
123+
if acfg.get("method") == "learned":
124+
path = acfg.get("model_path", "alerts/model.json")
125+
try:
126+
model = load_model(path)
127+
log.info("anomaly: learned model %s (threshold %.2f)", path, model["threshold"])
128+
return LearnedDetector(model, cooldown_s=cooldown)
129+
except (OSError, KeyError, ValueError) as e:
130+
log.warning("anomaly: could not load learned model (%s); using rolling", e)
131+
return AnomalyDetector(
132+
features=acfg.get("features", ["voltage", "cpu_temp", "yaw_rate"]),
133+
window=acfg.get("window", 240), warmup=acfg.get("warmup", 60),
134+
threshold=acfg.get("threshold", 4.0), cooldown_s=cooldown)
135+
136+
118137
async def main() -> None:
119138
import redis.asyncio as aioredis
120139
import yaml
@@ -126,10 +145,7 @@ async def main() -> None:
126145
acfg = rules.get("anomaly", {}) or {}
127146
detector = None
128147
if acfg.get("enabled"):
129-
detector = AnomalyDetector(
130-
features=acfg.get("features", ["voltage", "cpu_temp", "yaw_rate"]),
131-
window=acfg.get("window", 240), warmup=acfg.get("warmup", 60),
132-
threshold=acfg.get("threshold", 4.0), cooldown_s=acfg.get("cooldown_s", 20))
148+
detector = _build_detector(acfg)
133149
anomaly_severity = acfg.get("severity", "warning")
134150

135151
r = aioredis.from_url(REDIS_URL)

alerts/eval_detector.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Head-to-head evaluation: learned (frozen) vs rolling (online) detector.
2+
3+
Both detectors are run over the *same* labeled telemetry stream so their
4+
precision/recall are directly comparable. The stream is a warm-up block of clean
5+
data followed by a test block that interleaves normal samples with injected
6+
faults (thermal spikes, out-of-envelope yaw) at known positions.
7+
8+
python3 -m alerts.eval_detector
9+
python3 -m alerts.eval_detector --anomaly-rate 0.15 --test 3000
10+
11+
This is the evidence behind the README's claim that the trained model beats the
12+
rolling fallback: the rolling detector adapts, but interspersed faults slowly
13+
leak into its moving baseline, costing recall.
14+
"""
15+
from __future__ import annotations
16+
17+
import argparse
18+
import json
19+
20+
import numpy as np
21+
22+
from alerts.anomaly import AnomalyDetector
23+
from alerts.detector import load_model, predict
24+
from alerts.train_detector import FEATURES, sample_anomalies, sample_normal
25+
26+
27+
def _scores(tp: int, fp: int, fn: int, n_norm: int) -> dict:
28+
precision = tp / (tp + fp) if (tp + fp) else 0.0
29+
recall = tp / (tp + fn) if (tp + fn) else 0.0
30+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
31+
return {"precision": round(precision, 3), "recall": round(recall, 3), "f1": round(f1, 3),
32+
"false_positive_rate": round(fp / n_norm, 4) if n_norm else 0.0,
33+
"tp": tp, "fp": fp, "fn": fn}
34+
35+
36+
def build_stream(rng, n_test: int, anomaly_rate: float):
37+
"""Return (vectors, labels) for the test block (1 = injected fault)."""
38+
labels = (rng.random(n_test) < anomaly_rate).astype(int)
39+
n_anom = int(labels.sum())
40+
normals = sample_normal(n_test - n_anom, rng)
41+
anoms = sample_anomalies(max(1, n_anom), rng)
42+
vectors = np.zeros((n_test, len(FEATURES)))
43+
ni = ai = 0
44+
for i, lab in enumerate(labels):
45+
if lab:
46+
vectors[i] = anoms[ai % len(anoms)]
47+
ai += 1
48+
else:
49+
vectors[i] = normals[ni % len(normals)]
50+
ni += 1
51+
return vectors, labels
52+
53+
54+
def eval_learned(model: dict, vectors: np.ndarray, labels: np.ndarray) -> dict:
55+
flags = np.array([predict(model, v) for v in vectors], dtype=int)
56+
tp = int(((flags == 1) & (labels == 1)).sum())
57+
fp = int(((flags == 1) & (labels == 0)).sum())
58+
fn = int(((flags == 0) & (labels == 1)).sum())
59+
return _scores(tp, fp, fn, int((labels == 0).sum()))
60+
61+
62+
def eval_rolling(vectors: np.ndarray, labels: np.ndarray, warmup_block: np.ndarray) -> dict:
63+
det = AnomalyDetector(FEATURES, window=240, warmup=60, threshold=4.0, cooldown_s=0.0)
64+
now = 0.0
65+
for v in warmup_block: # establish a clean baseline first
66+
for f, val in zip(FEATURES, v, strict=False):
67+
det.update("eval", f, float(val), now)
68+
now += 1.0
69+
tp = fp = fn = 0
70+
for v, lab in zip(vectors, labels, strict=False):
71+
hit = None
72+
for f, val in zip(FEATURES, v, strict=False):
73+
hit = det.update("eval", f, float(val), now)
74+
now += 1.0
75+
flagged = hit is not None
76+
if flagged and lab:
77+
tp += 1
78+
elif flagged and not lab:
79+
fp += 1
80+
elif not flagged and lab:
81+
fn += 1
82+
return _scores(tp, fp, fn, int((labels == 0).sum()))
83+
84+
85+
def main() -> None:
86+
ap = argparse.ArgumentParser(description="learned vs rolling anomaly detector")
87+
ap.add_argument("--model", default="alerts/model.json")
88+
ap.add_argument("--test", type=int, default=3000)
89+
ap.add_argument("--anomaly-rate", type=float, default=0.15)
90+
ap.add_argument("--seed", type=int, default=11)
91+
args = ap.parse_args()
92+
93+
rng = np.random.default_rng(args.seed)
94+
warmup_block = sample_normal(400, rng)
95+
vectors, labels = build_stream(rng, args.test, args.anomaly_rate)
96+
model = load_model(args.model)
97+
98+
result = {
99+
"test_samples": args.test,
100+
"injected_faults": int(labels.sum()),
101+
"learned": eval_learned(model, vectors, labels),
102+
"rolling": eval_rolling(vectors, labels, warmup_block),
103+
}
104+
print(json.dumps(result, indent=2))
105+
106+
107+
if __name__ == "__main__":
108+
main()

alerts/model.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"version": 1,
3+
"features": [
4+
"voltage",
5+
"cpu_temp",
6+
"yaw_rate"
7+
],
8+
"mean": [
9+
22.61662600542188,
10+
48.000195312007946,
11+
0.0011742084786716734
12+
],
13+
"inv_cov": [
14+
[
15+
0.44222230938902457,
16+
-0.0024659183835756,
17+
0.03808644753067793
18+
],
19+
[
20+
-0.0024659183835756,
21+
0.3616840361238078,
22+
-5.259088287748953
23+
],
24+
[
25+
0.03808644753067793,
26+
-5.259088287748953,
27+
88.45111115742043
28+
]
29+
],
30+
"threshold": 3.048801772703412,
31+
"target_fpr": 0.01,
32+
"trained_on": 8000,
33+
"metrics": {
34+
"precision": 0.983,
35+
"recall": 1.0,
36+
"f1": 0.991,
37+
"false_positive_rate": 0.0088,
38+
"eval": {
39+
"normal": 4000,
40+
"anomalies": 2000,
41+
"tp": 2000,
42+
"fp": 35,
43+
"fn": 0
44+
},
45+
"threshold": 3.049
46+
}
47+
}

0 commit comments

Comments
 (0)