refactor: move eval/ and data_adapters/ to tools/
This commit is contained in:
1
tools/data_adapters/__init__.py
Normal file
1
tools/data_adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Data adapters: convert external datasets to the project's traces.jsonl format."""
|
||||
13
tools/data_adapters/__main__.py
Normal file
13
tools/data_adapters/__main__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""CLI dispatch: `python -m ai_mouse.data_adapters.balabit ...`
|
||||
|
||||
Note: This file makes `python -m ai_mouse.data_adapters` invokable but for
|
||||
clarity prefer the explicit form `python -m ai_mouse.data_adapters.balabit`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from tools.data_adapters.balabit import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
326
tools/data_adapters/balabit.py
Normal file
326
tools/data_adapters/balabit.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Adapter for the Balabit Mouse Dynamics Challenge dataset.
|
||||
|
||||
Source: https://github.com/balabit/Mouse-Dynamics-Challenge
|
||||
|
||||
Each session is a CSV file with columns:
|
||||
record timestamp, client timestamp, button, state, x, y
|
||||
|
||||
Where:
|
||||
state ∈ {Move, Pressed, Released, Drag, Scroll}
|
||||
button ∈ {NoButton, Left, Right, Wheel}
|
||||
|
||||
We extract "click-anchored" trajectory segments: each Pressed event
|
||||
defines a target, and the W ms of Move events preceding it form one
|
||||
training trace.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MouseEvent:
|
||||
"""A single mouse event from a Balabit CSV row."""
|
||||
|
||||
t_ms: int # client timestamp in milliseconds (relative to session start)
|
||||
button: str # "NoButton", "Left", "Right", "Wheel"
|
||||
state: str # "Move", "Pressed", "Released", "Drag", "Scroll"
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
"""A click-anchored trajectory segment ready to be written to JSONL."""
|
||||
|
||||
events: list[MouseEvent] # only Move events, sorted by t_ms ascending
|
||||
click_x: int # the Pressed event's x coordinate
|
||||
click_y: int # the Pressed event's y coordinate
|
||||
click_t_ms: int # the Pressed event's timestamp
|
||||
session_id: str # e.g. "user7_session_42"
|
||||
|
||||
|
||||
def parse_session_csv(path: Path) -> list[MouseEvent]:
|
||||
"""Parse a Balabit session CSV file into MouseEvent objects.
|
||||
|
||||
Malformed rows are logged and skipped (not raised).
|
||||
Client timestamps (seconds, float) are converted to int milliseconds.
|
||||
|
||||
Args:
|
||||
path: Path to a Balabit session CSV file.
|
||||
|
||||
Returns:
|
||||
List of MouseEvent in original order. Empty list if file is empty.
|
||||
"""
|
||||
import csv as csv_module
|
||||
|
||||
events: list[MouseEvent] = []
|
||||
with path.open("r", encoding="utf-8", newline="") as f:
|
||||
reader = csv_module.DictReader(f)
|
||||
for row_idx, row in enumerate(reader, 2): # 1-based, header is line 1
|
||||
try:
|
||||
client_ts = float(row["client timestamp"])
|
||||
t_ms = int(round(client_ts * 1000))
|
||||
button = row["button"].strip()
|
||||
state = row["state"].strip()
|
||||
x = int(row["x"])
|
||||
y = int(row["y"])
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
logger.debug("Skipping malformed row %d in %s: %s", row_idx, path.name, exc)
|
||||
continue
|
||||
events.append(MouseEvent(t_ms=t_ms, button=button, state=state, x=x, y=y))
|
||||
return events
|
||||
|
||||
|
||||
def segment_by_clicks(
|
||||
events: list[MouseEvent],
|
||||
window_ms: int,
|
||||
session_id: str,
|
||||
) -> list[Segment]:
|
||||
"""Extract click-anchored segments from a session.
|
||||
|
||||
For each Left-button Pressed event, collect all Move events within
|
||||
[click_t - window_ms, click_t) into one segment.
|
||||
|
||||
Args:
|
||||
events: Full session events (any state, any order is OK but typically sorted).
|
||||
window_ms: How far back to look before each click.
|
||||
session_id: String tag attached to every segment for debugging.
|
||||
|
||||
Returns:
|
||||
List of Segment, one per Left Pressed event that has at least one preceding Move.
|
||||
"""
|
||||
segments: list[Segment] = []
|
||||
for ev in events:
|
||||
if ev.state != "Pressed" or ev.button != "Left":
|
||||
continue
|
||||
click_t = ev.t_ms
|
||||
window_start = click_t - window_ms
|
||||
moves = [
|
||||
m for m in events
|
||||
if m.state == "Move" and window_start <= m.t_ms < click_t
|
||||
]
|
||||
if not moves:
|
||||
continue
|
||||
moves.sort(key=lambda m: m.t_ms)
|
||||
segments.append(Segment(
|
||||
events=moves,
|
||||
click_x=ev.x,
|
||||
click_y=ev.y,
|
||||
click_t_ms=click_t,
|
||||
session_id=session_id,
|
||||
))
|
||||
return segments
|
||||
|
||||
|
||||
def filter_segments(
|
||||
segments: list[Segment],
|
||||
min_events: int,
|
||||
min_dist: int,
|
||||
max_span_ms: int,
|
||||
max_gap_ms: int,
|
||||
coord_max: int = 5000,
|
||||
) -> list[Segment]:
|
||||
"""Drop segments that fail any quality check.
|
||||
|
||||
A segment is dropped if any of these are true:
|
||||
- len(events) < min_events
|
||||
- Euclidean dist(events[0], (click_x, click_y)) < min_dist
|
||||
- events[-1].t_ms - events[0].t_ms > max_span_ms
|
||||
- any adjacent Move pair has dt > max_gap_ms (sampling drop-out)
|
||||
- any coord (start/end/click) outside [0, coord_max]
|
||||
- total arc length < min_dist (high-frequency jitter only)
|
||||
|
||||
Args:
|
||||
segments: Candidate segments.
|
||||
min_events: Minimum number of Move events.
|
||||
min_dist: Minimum start→click pixel distance AND minimum total arc length.
|
||||
max_span_ms: Maximum time span of the segment (events[-1] - events[0]).
|
||||
max_gap_ms: Maximum allowed gap between adjacent Move events.
|
||||
coord_max: Maximum allowed pixel coordinate value (5000 catches multi-monitor anomalies).
|
||||
|
||||
Returns:
|
||||
Filtered list, original order preserved.
|
||||
"""
|
||||
import math
|
||||
|
||||
keep: list[Segment] = []
|
||||
for seg in segments:
|
||||
if len(seg.events) < min_events:
|
||||
continue
|
||||
|
||||
sx, sy = seg.events[0].x, seg.events[0].y
|
||||
ex, ey = seg.click_x, seg.click_y
|
||||
|
||||
# Coord range check
|
||||
if any(c < 0 or c > coord_max for c in (sx, sy, ex, ey)):
|
||||
continue
|
||||
|
||||
# Endpoint distance
|
||||
dist = math.hypot(ex - sx, ey - sy)
|
||||
if dist < min_dist:
|
||||
continue
|
||||
|
||||
# Time span
|
||||
span = seg.events[-1].t_ms - seg.events[0].t_ms
|
||||
if span > max_span_ms:
|
||||
continue
|
||||
|
||||
# Gap check + total arc length
|
||||
total_arc = 0.0
|
||||
bad_gap = False
|
||||
for i in range(1, len(seg.events)):
|
||||
dt = seg.events[i].t_ms - seg.events[i - 1].t_ms
|
||||
if dt > max_gap_ms:
|
||||
bad_gap = True
|
||||
break
|
||||
dx = seg.events[i].x - seg.events[i - 1].x
|
||||
dy = seg.events[i].y - seg.events[i - 1].y
|
||||
total_arc += math.hypot(dx, dy)
|
||||
if bad_gap:
|
||||
continue
|
||||
if total_arc < min_dist:
|
||||
continue
|
||||
|
||||
keep.append(seg)
|
||||
|
||||
return keep
|
||||
|
||||
|
||||
def process_session(
|
||||
csv_path: Path,
|
||||
output_jsonl: Path,
|
||||
config,
|
||||
) -> int:
|
||||
"""Convert one Balabit session CSV to JSONL traces, append to output.
|
||||
|
||||
Args:
|
||||
csv_path: Path to a Balabit session CSV.
|
||||
output_jsonl: Output JSONL file (will be appended to).
|
||||
config: BalabitAdapterConfig with window_ms / min_dist / etc.
|
||||
|
||||
Returns:
|
||||
Number of valid segments written.
|
||||
"""
|
||||
import math
|
||||
|
||||
session_id = csv_path.stem # e.g. "session_42"
|
||||
events = parse_session_csv(csv_path)
|
||||
if not events:
|
||||
return 0
|
||||
|
||||
raw_segments = segment_by_clicks(
|
||||
events, window_ms=config.window_ms, session_id=session_id
|
||||
)
|
||||
valid_segments = filter_segments(
|
||||
raw_segments,
|
||||
min_events=config.min_events,
|
||||
min_dist=config.min_dist,
|
||||
max_span_ms=config.max_span_ms,
|
||||
max_gap_ms=config.max_gap_ms,
|
||||
)
|
||||
|
||||
if not valid_segments:
|
||||
return 0
|
||||
|
||||
output_jsonl.parent.mkdir(parents=True, exist_ok=True)
|
||||
n_written = 0
|
||||
with output_jsonl.open("a", encoding="utf-8") as f:
|
||||
for seg in valid_segments:
|
||||
sx, sy = seg.events[0].x, seg.events[0].y
|
||||
ex, ey = seg.click_x, seg.click_y
|
||||
dist = math.hypot(ex - sx, ey - sy)
|
||||
angle = math.degrees(math.atan2(ey - sy, ex - sx))
|
||||
t0 = seg.events[0].t_ms
|
||||
record = {
|
||||
"meta": {
|
||||
"start": [sx, sy],
|
||||
"end": [ex, ey],
|
||||
"dist": int(round(dist)),
|
||||
"angle": round(angle, 1),
|
||||
"source": "balabit",
|
||||
"session_id": seg.session_id,
|
||||
},
|
||||
"events": [
|
||||
{"type": "move", "x": e.x, "y": e.y, "t": e.t_ms - t0}
|
||||
for e in seg.events
|
||||
],
|
||||
}
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
n_written += 1
|
||||
return n_written
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point: convert a directory of Balabit sessions to one JSONL file."""
|
||||
import argparse
|
||||
|
||||
from tools.config import BalabitAdapterConfig
|
||||
|
||||
parser = argparse.ArgumentParser(description="Convert Balabit dataset to traces.jsonl format")
|
||||
parser.add_argument(
|
||||
"--input", type=Path, required=True,
|
||||
help="Directory containing Balabit session CSV files (recursive)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=Path("data/pretrain_traces.jsonl"),
|
||||
help="Output JSONL path (default: data/pretrain_traces.jsonl)",
|
||||
)
|
||||
parser.add_argument("--window-ms", type=int, default=1200)
|
||||
parser.add_argument("--min-dist", type=int, default=50)
|
||||
parser.add_argument("--min-events", type=int, default=5)
|
||||
parser.add_argument("--max-span-ms", type=int, default=5000)
|
||||
parser.add_argument("--max-gap-ms", type=int, default=200)
|
||||
parser.add_argument(
|
||||
"--overwrite", action="store_true",
|
||||
help="Truncate output file before writing (default: append)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
if not args.input.is_dir():
|
||||
logger.error("Input is not a directory: %s", args.input)
|
||||
return 2
|
||||
|
||||
config = BalabitAdapterConfig(
|
||||
window_ms=args.window_ms,
|
||||
min_dist=args.min_dist,
|
||||
min_events=args.min_events,
|
||||
max_span_ms=args.max_span_ms,
|
||||
max_gap_ms=args.max_gap_ms,
|
||||
)
|
||||
|
||||
if args.overwrite and args.output.exists():
|
||||
args.output.unlink()
|
||||
|
||||
# Recursively walk the input directory looking for session files.
|
||||
# Balabit files have no extension; we accept any regular file.
|
||||
csv_files = sorted([p for p in args.input.rglob("*") if p.is_file() and not p.name.startswith(".")])
|
||||
if not csv_files:
|
||||
logger.error("No session files found under %s", args.input)
|
||||
return 2
|
||||
|
||||
total = 0
|
||||
for i, csv_path in enumerate(csv_files, 1):
|
||||
try:
|
||||
n = process_session(csv_path, args.output, config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Skipping %s due to error: %s", csv_path.name, exc)
|
||||
continue
|
||||
total += n
|
||||
if i % 10 == 0 or i == len(csv_files):
|
||||
logger.info("Processed %d/%d sessions, %d segments so far", i, len(csv_files), total)
|
||||
|
||||
logger.info("Done. Wrote %d segments to %s", total, args.output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(main())
|
||||
1
tools/eval/__init__.py
Normal file
1
tools/eval/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Evaluation module: kinematic metrics and Markdown report generation."""
|
||||
127
tools/eval/__main__.py
Normal file
127
tools/eval/__main__.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""CLI: `python -m ai_mouse.eval --model-dir ... --reference ... --output ...`
|
||||
|
||||
Loads N synthetic start/end pairs, calls the generator, loads M reference
|
||||
traces from a Balabit-format jsonl, and writes a Markdown report.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_reference_jsonl(path: Path, n_samples: int) -> list[dict]:
|
||||
"""Load up to n_samples reference traces from a JSONL file.
|
||||
|
||||
Returns list of {"xs","ys","ts"} 1-D ndarrays.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
moves = [e for e in rec.get("events", []) if e.get("type") == "move"]
|
||||
if len(moves) < 4:
|
||||
continue
|
||||
xs = np.array([e["x"] for e in moves], dtype=float)
|
||||
ys = np.array([e["y"] for e in moves], dtype=float)
|
||||
ts = np.array([e["t"] for e in moves], dtype=float)
|
||||
out.append({"xs": xs, "ys": ys, "ts": ts})
|
||||
if len(out) >= n_samples:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _generate_n_samples(
|
||||
model_dir: str, n_samples: int, seed: int = 0
|
||||
) -> list[dict]:
|
||||
"""Call the project's generator N times with random start/end pairs."""
|
||||
from ai_mouse.generator import generate
|
||||
|
||||
rng = random.Random(seed)
|
||||
out: list[dict] = []
|
||||
for i in range(n_samples):
|
||||
sx = rng.randint(50, 750)
|
||||
sy = rng.randint(50, 550)
|
||||
angle = rng.uniform(0, 2 * math.pi)
|
||||
dist = rng.randint(100, 600)
|
||||
ex = int(sx + dist * math.cos(angle))
|
||||
ey = int(sy + dist * math.sin(angle))
|
||||
ex = max(0, min(800, ex))
|
||||
ey = max(0, min(600, ey))
|
||||
try:
|
||||
pts = generate(start=(sx, sy), end=(ex, ey), model_dir=model_dir)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("generate() failed at i=%d: %s", i, exc)
|
||||
continue
|
||||
# Drop click events (last 2)
|
||||
moves = pts[:-2]
|
||||
if len(moves) < 4:
|
||||
continue
|
||||
xs = np.array([p[0] for p in moves], dtype=float)
|
||||
ys = np.array([p[1] for p in moves], dtype=float)
|
||||
ts = np.array([p[2] for p in moves], dtype=float)
|
||||
out.append({"xs": xs, "ys": ys, "ts": ts})
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Generate eval report comparing model output to reference traces.")
|
||||
parser.add_argument("--model-dir", required=True, help="Path to trained model dir (with flow_model.pt)")
|
||||
parser.add_argument("--reference", type=Path, required=True, help="JSONL reference traces (Balabit holdout)")
|
||||
parser.add_argument("--n-samples", type=int, default=200, help="Number of generated samples")
|
||||
parser.add_argument("--n-reference", type=int, default=1000, help="Number of reference samples to load")
|
||||
parser.add_argument("--output", type=Path, required=True, help="Output Markdown file")
|
||||
parser.add_argument("--tag", default="eval", help="Tag string used in plot filenames")
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
if not Path(args.model_dir).exists():
|
||||
logger.error("Model dir not found: %s", args.model_dir)
|
||||
return 2
|
||||
if not args.reference.exists():
|
||||
logger.error("Reference jsonl not found: %s", args.reference)
|
||||
return 2
|
||||
|
||||
logger.info("Loading reference from %s ...", args.reference)
|
||||
ref_traces = _load_reference_jsonl(args.reference, args.n_reference)
|
||||
logger.info("Loaded %d reference traces", len(ref_traces))
|
||||
|
||||
logger.info("Generating %d samples from %s ...", args.n_samples, args.model_dir)
|
||||
gen_traces = _generate_n_samples(args.model_dir, args.n_samples, seed=args.seed)
|
||||
logger.info("Generated %d valid traces", len(gen_traces))
|
||||
|
||||
if not gen_traces or not ref_traces:
|
||||
logger.error("Empty trace sets — aborting")
|
||||
return 1
|
||||
|
||||
from tools.eval.report import build_report
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
build_report(
|
||||
generated_traces=gen_traces,
|
||||
reference_traces=ref_traces,
|
||||
output_md=args.output,
|
||||
tag=args.tag,
|
||||
model_dir=args.model_dir,
|
||||
)
|
||||
logger.info("Done. Report at %s", args.output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
157
tools/eval/metrics.py
Normal file
157
tools/eval/metrics.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Kinematic metrics for mouse trajectory evaluation.
|
||||
|
||||
All inputs are 1-D NumPy arrays. Time is in milliseconds, position in pixels.
|
||||
Velocities are px/ms, accelerations px/ms², jerks px/ms³.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def compute_speed(
|
||||
xs: np.ndarray, ys: np.ndarray, ts: np.ndarray, eps: float = 1e-6
|
||||
) -> np.ndarray:
|
||||
"""Compute scalar speed at each step.
|
||||
|
||||
Args:
|
||||
xs: (N,) x coordinates.
|
||||
ys: (N,) y coordinates.
|
||||
ts: (N,) timestamps in ms.
|
||||
eps: minimum dt (ms) to avoid div-by-zero.
|
||||
|
||||
Returns:
|
||||
(N-1,) array of speeds (px/ms).
|
||||
"""
|
||||
dx = np.diff(xs)
|
||||
dy = np.diff(ys)
|
||||
dt = np.maximum(np.diff(ts), eps)
|
||||
return np.hypot(dx, dy) / dt
|
||||
|
||||
|
||||
def compute_acceleration(speeds: np.ndarray, ts: np.ndarray, eps: float = 1e-6) -> np.ndarray:
|
||||
"""Compute scalar acceleration from speeds.
|
||||
|
||||
Args:
|
||||
speeds: (M,) speeds (px/ms). Typically M = N-1 from compute_speed.
|
||||
ts: (M,) or (M+1,) timestamps in ms.
|
||||
If len(ts) == len(speeds): timestamps are treated as the time
|
||||
points associated with each speed value directly.
|
||||
If len(ts) == len(speeds)+1: timestamps are the original position
|
||||
timestamps; midpoints are computed for speed intervals.
|
||||
eps: minimum dt (ms) to avoid div-by-zero.
|
||||
|
||||
Returns:
|
||||
(M-1,) array of accelerations (px/ms²).
|
||||
"""
|
||||
if len(speeds) < 2:
|
||||
return np.array([], dtype=float)
|
||||
if len(ts) == len(speeds):
|
||||
# ts[i] is already the time associated with speed[i]
|
||||
dt = np.maximum(np.diff(ts), eps)
|
||||
else:
|
||||
# ts has length M+1; speed[i] is between ts[i] and ts[i+1]
|
||||
midpoints = (ts[:-1] + ts[1:]) / 2.0
|
||||
dt = np.maximum(np.diff(midpoints), eps)
|
||||
return np.diff(speeds) / dt
|
||||
|
||||
|
||||
def compute_jerk(accels: np.ndarray, ts: np.ndarray, eps: float = 1e-6) -> np.ndarray:
|
||||
"""Compute jerk from accelerations.
|
||||
|
||||
Args:
|
||||
accels: (K,) accelerations.
|
||||
ts: (K+2,) timestamps that produced those accelerations.
|
||||
Used to derive midpoint-of-midpoint dts.
|
||||
eps: minimum dt to avoid div-by-zero.
|
||||
|
||||
Returns:
|
||||
(K-1,) array of jerks (px/ms³).
|
||||
"""
|
||||
if len(accels) < 2:
|
||||
return np.array([], dtype=float)
|
||||
# Approximate dt for jerks as average dt of original ts (good enough for stats)
|
||||
dt_avg = np.maximum(np.diff(ts).mean(), eps)
|
||||
return np.diff(accels) / dt_avg
|
||||
|
||||
|
||||
def compute_stats(x: np.ndarray) -> dict[str, float]:
|
||||
"""Summary statistics for a 1-D distribution.
|
||||
|
||||
Returns:
|
||||
dict with keys: mean, std, cv (coef of variation), p25, p50, p75, p95.
|
||||
"""
|
||||
if len(x) == 0:
|
||||
return {k: 0.0 for k in ("mean", "std", "cv", "p25", "p50", "p75", "p95")}
|
||||
x = np.asarray(x, dtype=float)
|
||||
mean = float(x.mean())
|
||||
std = float(x.std(ddof=1)) if len(x) > 1 else 0.0
|
||||
cv = std / mean if mean != 0 else 0.0
|
||||
return {
|
||||
"mean": mean,
|
||||
"std": std,
|
||||
"cv": cv,
|
||||
"p25": float(np.percentile(x, 25)),
|
||||
"p50": float(np.percentile(x, 50)),
|
||||
"p75": float(np.percentile(x, 75)),
|
||||
"p95": float(np.percentile(x, 95)),
|
||||
}
|
||||
|
||||
|
||||
def fft_spectrum(
|
||||
signal: np.ndarray, sample_rate_hz: float
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Compute one-sided FFT magnitude spectrum.
|
||||
|
||||
Args:
|
||||
signal: 1-D real-valued signal.
|
||||
sample_rate_hz: Sampling rate in Hz.
|
||||
|
||||
Returns:
|
||||
(freqs, magnitudes) — positive frequencies only.
|
||||
Magnitudes are absolute values of complex FFT coefficients.
|
||||
"""
|
||||
n = len(signal)
|
||||
if n == 0:
|
||||
return np.array([]), np.array([])
|
||||
# Zero-mean to remove DC component which dominates the spectrum
|
||||
s = signal - signal.mean()
|
||||
fft = np.fft.rfft(s)
|
||||
freqs = np.fft.rfftfreq(n, d=1.0 / sample_rate_hz)
|
||||
return freqs, np.abs(fft)
|
||||
|
||||
|
||||
def kl_divergence_histograms(
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
bins: int = 50,
|
||||
eps: float = 1e-10,
|
||||
) -> float:
|
||||
"""KL divergence KL(P_x || P_y) estimated via shared-bin histograms.
|
||||
|
||||
Both arrays are histogrammed over their joint range. Empty bins get
|
||||
`eps` mass to avoid log(0) — keeps result finite even for disjoint
|
||||
supports.
|
||||
|
||||
Args:
|
||||
x: samples from distribution P.
|
||||
y: samples from distribution Q (the "reference").
|
||||
bins: number of histogram bins.
|
||||
eps: smoothing constant for empty bins.
|
||||
|
||||
Returns:
|
||||
scalar KL divergence (nats). Always finite, ≥ 0.
|
||||
"""
|
||||
if len(x) == 0 or len(y) == 0:
|
||||
return 0.0
|
||||
lo = float(min(x.min(), y.min()))
|
||||
hi = float(max(x.max(), y.max()))
|
||||
if hi <= lo:
|
||||
return 0.0
|
||||
edges = np.linspace(lo, hi, bins + 1)
|
||||
px, _ = np.histogram(x, bins=edges, density=False)
|
||||
qy, _ = np.histogram(y, bins=edges, density=False)
|
||||
px = px.astype(float) + eps
|
||||
qy = qy.astype(float) + eps
|
||||
px /= px.sum()
|
||||
qy /= qy.sum()
|
||||
return float(np.sum(px * np.log(px / qy)))
|
||||
231
tools/eval/report.py
Normal file
231
tools/eval/report.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""Markdown report generation for eval results.
|
||||
|
||||
Outputs a self-contained .md file with embedded PNG plots in a sibling
|
||||
'plots/' directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg") # headless
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
|
||||
from tools.eval.metrics import (
|
||||
compute_acceleration,
|
||||
compute_jerk,
|
||||
compute_speed,
|
||||
compute_stats,
|
||||
fft_spectrum,
|
||||
kl_divergence_histograms,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _aggregate_kinematics(traces: list[dict]) -> dict[str, np.ndarray]:
|
||||
"""Concatenate per-trace speed/accel/jerk arrays from a list of traces.
|
||||
|
||||
Args:
|
||||
traces: list of {"xs", "ys", "ts"} dicts (1-D ndarrays).
|
||||
|
||||
Returns:
|
||||
dict with keys "speed", "accel", "jerk", "dt" — each a flat ndarray.
|
||||
"""
|
||||
speeds, accels, jerks, dts = [], [], [], []
|
||||
for tr in traces:
|
||||
xs, ys, ts = tr["xs"], tr["ys"], tr["ts"]
|
||||
if len(xs) < 4:
|
||||
continue
|
||||
v = compute_speed(xs, ys, ts)
|
||||
a = compute_acceleration(v, ts)
|
||||
j = compute_jerk(a, ts)
|
||||
speeds.append(v)
|
||||
accels.append(a)
|
||||
jerks.append(j)
|
||||
dts.append(np.diff(ts))
|
||||
return {
|
||||
"speed": np.concatenate(speeds) if speeds else np.array([]),
|
||||
"accel": np.concatenate(accels) if accels else np.array([]),
|
||||
"jerk": np.concatenate(jerks) if jerks else np.array([]),
|
||||
"dt": np.concatenate(dts) if dts else np.array([]),
|
||||
}
|
||||
|
||||
|
||||
def _plot_distribution(
|
||||
gen: np.ndarray,
|
||||
ref: np.ndarray,
|
||||
title: str,
|
||||
output: Path,
|
||||
xlabel: str,
|
||||
bins: int = 50,
|
||||
) -> None:
|
||||
"""Side-by-side histogram of gen vs ref."""
|
||||
fig, ax = plt.subplots(figsize=(8, 4), dpi=100)
|
||||
if len(gen) > 0:
|
||||
ax.hist(gen, bins=bins, alpha=0.5, label="生成", density=True)
|
||||
if len(ref) > 0:
|
||||
ax.hist(ref, bins=bins, alpha=0.5, label="参考 (Balabit)", density=True)
|
||||
ax.set_title(title)
|
||||
ax.set_xlabel(xlabel)
|
||||
ax.set_ylabel("密度")
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _plot_fft_overlay(
|
||||
gen_traces: list[dict],
|
||||
ref_traces: list[dict],
|
||||
output: Path,
|
||||
sample_rate_hz: float = 100.0,
|
||||
) -> None:
|
||||
"""Average FFT magnitude over lateral component for gen vs ref."""
|
||||
def _avg_spectrum(traces: list[dict]) -> tuple[np.ndarray, np.ndarray]:
|
||||
all_freqs = None
|
||||
all_mags = []
|
||||
for tr in traces:
|
||||
xs, ys = tr["xs"], tr["ys"]
|
||||
if len(xs) < 8:
|
||||
continue
|
||||
sig = ys - np.linspace(ys[0], ys[-1], len(ys))
|
||||
f, m = fft_spectrum(sig, sample_rate_hz)
|
||||
if all_freqs is None:
|
||||
all_freqs = f
|
||||
all_mags.append(m)
|
||||
elif len(m) == len(all_freqs):
|
||||
all_mags.append(m)
|
||||
if not all_mags:
|
||||
return np.array([]), np.array([])
|
||||
return all_freqs, np.mean(all_mags, axis=0)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4), dpi=100)
|
||||
f_gen, m_gen = _avg_spectrum(gen_traces)
|
||||
f_ref, m_ref = _avg_spectrum(ref_traces)
|
||||
if len(f_gen) > 0:
|
||||
ax.plot(f_gen, m_gen, label="生成", alpha=0.7)
|
||||
if len(f_ref) > 0:
|
||||
ax.plot(f_ref, m_ref, label="参考 (Balabit)", alpha=0.7)
|
||||
ax.axvspan(4, 12, alpha=0.1, color="green", label="生理震颤区间 4–12 Hz")
|
||||
ax.set_title("FFT 频谱(横向偏移信号)")
|
||||
ax.set_xlabel("Hz")
|
||||
ax.set_ylabel("|FFT|")
|
||||
ax.set_xlim(0, sample_rate_hz / 2)
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _plot_paths_overlay(traces: list[dict], output: Path, max_traces: int = 5) -> None:
|
||||
"""Plot up to N generated trajectories on the same axes."""
|
||||
fig, ax = plt.subplots(figsize=(6, 5), dpi=100)
|
||||
for i, tr in enumerate(traces[:max_traces]):
|
||||
ax.plot(tr["xs"], tr["ys"], alpha=0.6, label=f"路径 {i+1}")
|
||||
ax.invert_yaxis() # screen coords
|
||||
ax.set_title(f"前 {min(max_traces, len(traces))} 条生成轨迹")
|
||||
ax.set_xlabel("x (px)")
|
||||
ax.set_ylabel("y (px)")
|
||||
ax.set_aspect("equal", adjustable="datalim")
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(output)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def build_report(
|
||||
generated_traces: list[dict],
|
||||
reference_traces: list[dict],
|
||||
output_md: Path,
|
||||
tag: str,
|
||||
model_dir: str,
|
||||
sample_rate_hz: float = 100.0,
|
||||
) -> None:
|
||||
"""Build a Markdown eval report with embedded plots.
|
||||
|
||||
Args:
|
||||
generated_traces: list of {"xs","ys","ts"} from the generator under test.
|
||||
reference_traces: list of {"xs","ys","ts"} from Balabit (ground truth).
|
||||
output_md: destination .md path. plots/ created in same dir.
|
||||
tag: short identifier (e.g. "baseline", "post-finetune").
|
||||
model_dir: model directory path string (for provenance).
|
||||
sample_rate_hz: nominal sample rate for FFT.
|
||||
"""
|
||||
plot_dir = output_md.parent / "plots"
|
||||
plot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
gen_kin = _aggregate_kinematics(generated_traces)
|
||||
ref_kin = _aggregate_kinematics(reference_traces)
|
||||
|
||||
# --- KL divergences ---
|
||||
kl_speed = kl_divergence_histograms(gen_kin["speed"], ref_kin["speed"])
|
||||
kl_accel = kl_divergence_histograms(gen_kin["accel"], ref_kin["accel"])
|
||||
kl_jerk = kl_divergence_histograms(gen_kin["jerk"], ref_kin["jerk"])
|
||||
kl_dt = kl_divergence_histograms(gen_kin["dt"], ref_kin["dt"])
|
||||
|
||||
# --- Stats ---
|
||||
stats_gen = {k: compute_stats(v) for k, v in gen_kin.items()}
|
||||
stats_ref = {k: compute_stats(v) for k, v in ref_kin.items()}
|
||||
|
||||
# --- Plots ---
|
||||
_plot_distribution(gen_kin["speed"], ref_kin["speed"],
|
||||
"速度分布", plot_dir / f"{tag}-speed.png", "px/ms")
|
||||
_plot_distribution(gen_kin["accel"], ref_kin["accel"],
|
||||
"加速度分布", plot_dir / f"{tag}-accel.png", "px/ms²")
|
||||
_plot_distribution(gen_kin["jerk"], ref_kin["jerk"],
|
||||
"Jerk 分布", plot_dir / f"{tag}-jerk.png", "px/ms³")
|
||||
_plot_distribution(gen_kin["dt"], ref_kin["dt"],
|
||||
"Δt 分布", plot_dir / f"{tag}-dt.png", "ms")
|
||||
_plot_fft_overlay(generated_traces, reference_traces,
|
||||
plot_dir / f"{tag}-fft.png", sample_rate_hz)
|
||||
_plot_paths_overlay(generated_traces, plot_dir / f"{tag}-paths.png")
|
||||
|
||||
# --- Markdown ---
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
lines = [
|
||||
f"# Eval Report: {tag} ({now})",
|
||||
"",
|
||||
"## 模型信息",
|
||||
f"- Checkpoint dir: `{model_dir}`",
|
||||
f"- 生成样本数: {len(generated_traces)}",
|
||||
f"- 参考样本数: {len(reference_traces)}",
|
||||
"",
|
||||
"## KL 散度(生成 vs 参考,越小越好)",
|
||||
"| 指标 | KL |",
|
||||
"|---|---|",
|
||||
f"| 速度分布 | {kl_speed:.4f} |",
|
||||
f"| 加速度分布 | {kl_accel:.4f} |",
|
||||
f"| Jerk 分布 | {kl_jerk:.4f} |",
|
||||
f"| Δt 分布 | {kl_dt:.4f} |",
|
||||
"",
|
||||
"## 摘要统计",
|
||||
"| 指标 | 生成 mean | 参考 mean | 生成 CV | 参考 CV |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for key, label in [("speed", "速度"), ("accel", "加速度"), ("jerk", "jerk"), ("dt", "Δt")]:
|
||||
lines.append(
|
||||
f"| {label} | {stats_gen[key]['mean']:.4g} | {stats_ref[key]['mean']:.4g} | "
|
||||
f"{stats_gen[key]['cv']:.3f} | {stats_ref[key]['cv']:.3f} |"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
"## 直方图",
|
||||
f"",
|
||||
f"",
|
||||
f"",
|
||||
f"",
|
||||
"",
|
||||
"## FFT 频谱(横向偏移)",
|
||||
f"",
|
||||
"",
|
||||
"## 生成轨迹示例",
|
||||
f"",
|
||||
"",
|
||||
]
|
||||
output_md.write_text("\n".join(lines), encoding="utf-8")
|
||||
logger.info("Report written to %s", output_md)
|
||||
Reference in New Issue
Block a user