- Add .gitignore for Python/data/models - Add matplotlib>=3.8.0 for eval plots - Add PretrainConfig, FinetuneConfig, BalabitAdapterConfig, EvalConfig dataclasses
3152 lines
100 KiB
Markdown
3152 lines
100 KiB
Markdown
# Balabit 预训练 + Fine-tune 重构 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Use Balabit Mouse Dynamics Challenge dataset to pretrain `TrajectoryFlowModel`, then fine-tune on user's 605 traces. Fix high-frequency lateral jitter and template-y Δt curves by aggressively removing deterministic post-processing. Add a quantitative eval module that produces Markdown reports with kinematic metrics and FFT spectra.
|
||
|
||
**Architecture:** Two-stage training (Balabit pretrain → 605 fine-tune via `resume_from`). New modules `ai_mouse/data_adapters/balabit.py` and `ai_mouse/eval/`. Existing model architecture (`TrajectoryFlowModel`), scroll subsystem, and frontend are unchanged. Data format remains identical to current `traces.jsonl`.
|
||
|
||
**Tech Stack:** Python 3.12+, PyTorch (CPU; optional CUDA), NumPy, SciPy, matplotlib (NEW dep for eval plots), uv package manager.
|
||
|
||
**Spec reference:** [docs/superpowers/specs/2026-05-10-balabit-pretrain-refactor-design.md](../specs/2026-05-10-balabit-pretrain-refactor-design.md)
|
||
|
||
**Prerequisites:**
|
||
1. User must download Balabit Mouse Dynamics Challenge dataset from https://github.com/balabit/Mouse-Dynamics-Challenge (clone or zip download). Plan assumes path is configurable via CLI arg.
|
||
2. Project will be initialized as git repo in Task 1 (currently not a git repo).
|
||
|
||
---
|
||
|
||
## Task 1: Project Setup (git init, dependencies, config)
|
||
|
||
**Files:**
|
||
- Create: `.gitignore`
|
||
- Modify: `pyproject.toml`
|
||
- Modify: `ai_mouse/config.py`
|
||
- Modify: `tests/conftest.py` (no functional change yet — just to verify import works after config changes)
|
||
|
||
- [ ] **Step 1: Initialize git repo (requires user OK)**
|
||
|
||
This step touches user-controlled state. Confirm with user before running.
|
||
|
||
```bash
|
||
cd /d/code/python/side/ai_mouse
|
||
git init
|
||
```
|
||
|
||
Expected: `Initialized empty Git repository in .../ai_mouse/.git/`
|
||
|
||
- [ ] **Step 2: Create .gitignore**
|
||
|
||
Create `.gitignore` at project root:
|
||
|
||
```
|
||
# Python
|
||
__pycache__/
|
||
*.py[cod]
|
||
*$py.class
|
||
*.so
|
||
.Python
|
||
.venv/
|
||
.pytest_cache/
|
||
|
||
# IDE
|
||
.idea/
|
||
.vscode/
|
||
|
||
# uv
|
||
uv.lock.bak
|
||
|
||
# Data & models — large binary, do NOT commit
|
||
data/traces.jsonl
|
||
data/scroll_traces.jsonl
|
||
data/pretrain_traces.jsonl
|
||
data/models_v2/
|
||
data/models_v2_pretrained/
|
||
data/scroll_models/
|
||
data/eval_reports/
|
||
data/balabit_raw/
|
||
|
||
# OS
|
||
.DS_Store
|
||
Thumbs.db
|
||
|
||
# Playwright artifacts
|
||
.playwright-mcp/
|
||
```
|
||
|
||
- [ ] **Step 3: Add matplotlib to pyproject.toml**
|
||
|
||
Modify `pyproject.toml` — add matplotlib to `dependencies`:
|
||
|
||
```toml
|
||
[project]
|
||
name = "ai-mouse"
|
||
version = "0.1.0"
|
||
requires-python = ">=3.12,<3.14"
|
||
dependencies = [
|
||
"torch>=2.2.0",
|
||
"numpy>=1.26.0",
|
||
"fastapi>=0.111.0",
|
||
"uvicorn>=0.29.0",
|
||
"scipy>=1.10.0",
|
||
"matplotlib>=3.8.0",
|
||
]
|
||
|
||
[dependency-groups]
|
||
dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "httpx>=0.27.0"]
|
||
```
|
||
|
||
Then sync:
|
||
|
||
```bash
|
||
uv sync
|
||
```
|
||
|
||
Expected: `Resolved N packages` and matplotlib appears in `.venv`.
|
||
|
||
- [ ] **Step 4: Add new config dataclasses**
|
||
|
||
Append to `ai_mouse/config.py` (after existing `ServerConfig`):
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# Pretraining (Balabit) configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class PretrainConfig:
|
||
"""Hyperparameters for Balabit pretraining stage."""
|
||
|
||
epochs: int = 200
|
||
batch_size: int = 128
|
||
lr: float = 3e-4
|
||
seq_len: int = 64
|
||
|
||
|
||
@dataclass
|
||
class FinetuneConfig:
|
||
"""Hyperparameters for fine-tuning on user-collected data."""
|
||
|
||
epochs: int = 50
|
||
batch_size: int = 64
|
||
lr: float = 1e-5 # 比预训练小一个数量级,防止灾难性遗忘
|
||
seq_len: int = 64
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Balabit adapter configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class BalabitAdapterConfig:
|
||
"""Settings for Balabit CSV → traces.jsonl conversion."""
|
||
|
||
window_ms: int = 1200 # click 前回溯窗口
|
||
min_dist: int = 50 # 最小起终点距离 (px)
|
||
min_events: int = 5 # 最小 Move 事件数
|
||
max_span_ms: int = 5000 # 最大段时间跨度 (ms)
|
||
max_gap_ms: int = 200 # 段内相邻 Move 最大时间差
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Eval configuration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class EvalConfig:
|
||
"""Settings for evaluation report generation."""
|
||
|
||
n_samples: int = 1000
|
||
fft_freq_band: tuple[float, float] = (4.0, 12.0) # 生理震颤频段 (Hz)
|
||
kl_bins: int = 50
|
||
```
|
||
|
||
- [ ] **Step 5: Verify imports**
|
||
|
||
```bash
|
||
uv run python -c "from ai_mouse.config import PretrainConfig, FinetuneConfig, BalabitAdapterConfig, EvalConfig; print('OK')"
|
||
```
|
||
|
||
Expected: `OK`
|
||
|
||
- [ ] **Step 6: Run all existing tests to confirm no regressions**
|
||
|
||
```bash
|
||
uv run pytest -x
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 7: Initial commit**
|
||
|
||
```bash
|
||
git add .gitignore pyproject.toml uv.lock ai_mouse/ tests/ static/ main.py docs/
|
||
git commit -m "chore: initialize git repo, add matplotlib dep, extend config
|
||
|
||
- Add .gitignore for Python/data/models
|
||
- Add matplotlib>=3.8.0 for eval plots
|
||
- Add PretrainConfig, FinetuneConfig, BalabitAdapterConfig, EvalConfig dataclasses"
|
||
```
|
||
|
||
Expected: commit succeeds with hash printed.
|
||
|
||
---
|
||
|
||
## Task 2: Balabit Adapter — Core Module Skeleton + Types
|
||
|
||
**Files:**
|
||
- Create: `ai_mouse/data_adapters/__init__.py`
|
||
- Create: `ai_mouse/data_adapters/balabit.py`
|
||
- Create: `tests/test_balabit_adapter.py`
|
||
|
||
- [ ] **Step 1: Write failing tests for the public API surface**
|
||
|
||
Create `tests/test_balabit_adapter.py`:
|
||
|
||
```python
|
||
"""Tests for Balabit Mouse Dynamics Challenge data adapter."""
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
|
||
def test_module_exports():
|
||
"""The adapter module must export the public functions used by CLI."""
|
||
from ai_mouse.data_adapters import balabit
|
||
assert hasattr(balabit, "parse_session_csv")
|
||
assert hasattr(balabit, "segment_by_clicks")
|
||
assert hasattr(balabit, "filter_segments")
|
||
assert hasattr(balabit, "process_session")
|
||
assert hasattr(balabit, "MouseEvent")
|
||
assert hasattr(balabit, "Segment")
|
||
|
||
|
||
def test_mouse_event_dataclass():
|
||
"""MouseEvent has expected fields."""
|
||
from ai_mouse.data_adapters.balabit import MouseEvent
|
||
e = MouseEvent(t_ms=100, button="NoButton", state="Move", x=300, y=400)
|
||
assert e.t_ms == 100
|
||
assert e.state == "Move"
|
||
assert e.x == 300
|
||
|
||
|
||
def test_segment_dataclass():
|
||
"""Segment has expected fields."""
|
||
from ai_mouse.data_adapters.balabit import MouseEvent, Segment
|
||
events = [MouseEvent(t_ms=0, button="NoButton", state="Move", x=10, y=20)]
|
||
s = Segment(events=events, click_x=100, click_y=200, click_t_ms=500, session_id="user1_s1")
|
||
assert s.events == events
|
||
assert s.click_x == 100
|
||
assert s.session_id == "user1_s1"
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, verify it fails**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py -v
|
||
```
|
||
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'ai_mouse.data_adapters'`
|
||
|
||
- [ ] **Step 3: Create package skeleton**
|
||
|
||
Create `ai_mouse/data_adapters/__init__.py`:
|
||
|
||
```python
|
||
"""Data adapters: convert external datasets to the project's traces.jsonl format."""
|
||
```
|
||
|
||
Create `ai_mouse/data_adapters/balabit.py`:
|
||
|
||
```python
|
||
"""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]:
|
||
"""Stub — implemented in Task 3."""
|
||
raise NotImplementedError
|
||
|
||
|
||
def segment_by_clicks(
|
||
events: list[MouseEvent],
|
||
window_ms: int,
|
||
session_id: str,
|
||
) -> list[Segment]:
|
||
"""Stub — implemented in Task 4."""
|
||
raise NotImplementedError
|
||
|
||
|
||
def filter_segments(
|
||
segments: list[Segment],
|
||
min_events: int,
|
||
min_dist: int,
|
||
max_span_ms: int,
|
||
max_gap_ms: int,
|
||
) -> list[Segment]:
|
||
"""Stub — implemented in Task 5."""
|
||
raise NotImplementedError
|
||
|
||
|
||
def process_session(
|
||
csv_path: Path,
|
||
output_jsonl: Path,
|
||
config,
|
||
) -> int:
|
||
"""Stub — implemented in Task 6."""
|
||
raise NotImplementedError
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify they pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py -v
|
||
```
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/data_adapters/ tests/test_balabit_adapter.py
|
||
git commit -m "feat(adapter): scaffold balabit data adapter package"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Balabit Adapter — CSV Parsing
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/data_adapters/balabit.py` (implement `parse_session_csv`)
|
||
- Modify: `tests/test_balabit_adapter.py` (add tests)
|
||
|
||
- [ ] **Step 1: Write failing tests for CSV parsing**
|
||
|
||
Append to `tests/test_balabit_adapter.py`:
|
||
|
||
```python
|
||
def _write_csv(path: Path, rows: list[str]) -> None:
|
||
"""Helper to write a Balabit-format CSV with header."""
|
||
header = "record timestamp,client timestamp,button,state,x,y"
|
||
path.write_text(header + "\n" + "\n".join(rows) + "\n", encoding="utf-8")
|
||
|
||
|
||
class TestParseSessionCsv:
|
||
def test_parses_basic_rows(self, tmp_path):
|
||
from ai_mouse.data_adapters.balabit import parse_session_csv
|
||
csv = tmp_path / "session_1"
|
||
_write_csv(csv, [
|
||
"1500000000.000,0.000,NoButton,Move,100,200",
|
||
"1500000000.050,0.050,NoButton,Move,110,210",
|
||
"1500000000.100,0.100,Left,Pressed,120,220",
|
||
])
|
||
events = parse_session_csv(csv)
|
||
assert len(events) == 3
|
||
assert events[0].t_ms == 0
|
||
assert events[0].state == "Move"
|
||
assert events[0].x == 100
|
||
assert events[2].t_ms == 100
|
||
assert events[2].state == "Pressed"
|
||
assert events[2].button == "Left"
|
||
|
||
def test_handles_float_timestamps(self, tmp_path):
|
||
"""Client timestamps are floats in seconds; we convert to int ms."""
|
||
from ai_mouse.data_adapters.balabit import parse_session_csv
|
||
csv = tmp_path / "session_2"
|
||
_write_csv(csv, [
|
||
"0,1.234,NoButton,Move,50,60",
|
||
"0,1.250,NoButton,Move,55,65",
|
||
])
|
||
events = parse_session_csv(csv)
|
||
assert events[0].t_ms == 1234
|
||
assert events[1].t_ms == 1250
|
||
|
||
def test_skips_malformed_rows(self, tmp_path):
|
||
"""Rows with bad data are logged and skipped, not raised."""
|
||
from ai_mouse.data_adapters.balabit import parse_session_csv
|
||
csv = tmp_path / "session_3"
|
||
_write_csv(csv, [
|
||
"0,0.000,NoButton,Move,100,200",
|
||
"BROKEN_ROW",
|
||
"0,abc,NoButton,Move,100,200", # bad timestamp
|
||
"0,0.100,NoButton,Move,150,250",
|
||
])
|
||
events = parse_session_csv(csv)
|
||
assert len(events) == 2
|
||
assert events[0].x == 100
|
||
assert events[1].x == 150
|
||
|
||
def test_returns_empty_list_for_empty_file(self, tmp_path):
|
||
from ai_mouse.data_adapters.balabit import parse_session_csv
|
||
csv = tmp_path / "session_4"
|
||
csv.write_text("record timestamp,client timestamp,button,state,x,y\n", encoding="utf-8")
|
||
events = parse_session_csv(csv)
|
||
assert events == []
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify they fail**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestParseSessionCsv -v
|
||
```
|
||
|
||
Expected: FAIL with `NotImplementedError`.
|
||
|
||
- [ ] **Step 3: Implement parse_session_csv**
|
||
|
||
Replace the stub in `ai_mouse/data_adapters/balabit.py`:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify they pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestParseSessionCsv -v
|
||
```
|
||
|
||
Expected: 4 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/data_adapters/balabit.py tests/test_balabit_adapter.py
|
||
git commit -m "feat(adapter): implement Balabit CSV parser"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Balabit Adapter — Click-Anchored Segmentation
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/data_adapters/balabit.py` (implement `segment_by_clicks`)
|
||
- Modify: `tests/test_balabit_adapter.py` (add tests)
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Append to `tests/test_balabit_adapter.py`:
|
||
|
||
```python
|
||
class TestSegmentByClicks:
|
||
def _make_event(self, t_ms: int, state: str, x: int, y: int, button: str = "NoButton"):
|
||
from ai_mouse.data_adapters.balabit import MouseEvent
|
||
return MouseEvent(t_ms=t_ms, button=button, state=state, x=x, y=y)
|
||
|
||
def test_one_click_one_segment(self):
|
||
from ai_mouse.data_adapters.balabit import segment_by_clicks
|
||
events = [
|
||
self._make_event(0, "Move", 10, 20),
|
||
self._make_event(100, "Move", 50, 60),
|
||
self._make_event(500, "Move", 100, 100),
|
||
self._make_event(600, "Pressed", 110, 110, button="Left"),
|
||
]
|
||
segments = segment_by_clicks(events, window_ms=1200, session_id="test_s1")
|
||
assert len(segments) == 1
|
||
seg = segments[0]
|
||
assert seg.click_x == 110
|
||
assert seg.click_y == 110
|
||
assert seg.click_t_ms == 600
|
||
assert len(seg.events) == 3
|
||
assert seg.session_id == "test_s1"
|
||
|
||
def test_window_excludes_old_events(self):
|
||
"""Move events earlier than (click_t - window_ms) are dropped."""
|
||
from ai_mouse.data_adapters.balabit import segment_by_clicks
|
||
events = [
|
||
self._make_event(0, "Move", 10, 20), # too old
|
||
self._make_event(100, "Move", 20, 30), # too old
|
||
self._make_event(900, "Move", 30, 40), # in window
|
||
self._make_event(1000, "Pressed", 40, 50, button="Left"),
|
||
]
|
||
segments = segment_by_clicks(events, window_ms=200, session_id="s")
|
||
assert len(segments) == 1
|
||
assert len(segments[0].events) == 1
|
||
assert segments[0].events[0].t_ms == 900
|
||
|
||
def test_multiple_clicks_multiple_segments(self):
|
||
from ai_mouse.data_adapters.balabit import segment_by_clicks
|
||
events = [
|
||
self._make_event(0, "Move", 10, 20),
|
||
self._make_event(100, "Pressed", 50, 50, button="Left"),
|
||
self._make_event(200, "Released", 50, 50, button="Left"),
|
||
self._make_event(300, "Move", 60, 60),
|
||
self._make_event(400, "Move", 70, 70),
|
||
self._make_event(500, "Pressed", 80, 80, button="Left"),
|
||
]
|
||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||
assert len(segments) == 2
|
||
assert segments[0].click_x == 50
|
||
assert segments[1].click_x == 80
|
||
# Second segment's events must not include the first Pressed
|
||
for e in segments[1].events:
|
||
assert e.state == "Move"
|
||
|
||
def test_skips_pressed_with_non_left_button(self):
|
||
"""Right-clicks and wheel-clicks don't anchor segments (only Left)."""
|
||
from ai_mouse.data_adapters.balabit import segment_by_clicks
|
||
events = [
|
||
self._make_event(0, "Move", 10, 20),
|
||
self._make_event(100, "Pressed", 50, 50, button="Right"), # ignored
|
||
self._make_event(200, "Move", 60, 60),
|
||
self._make_event(300, "Pressed", 70, 70, button="Left"), # anchor
|
||
]
|
||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||
assert len(segments) == 1
|
||
assert segments[0].click_x == 70
|
||
|
||
def test_no_clicks_returns_empty(self):
|
||
from ai_mouse.data_adapters.balabit import segment_by_clicks
|
||
events = [
|
||
self._make_event(0, "Move", 10, 20),
|
||
self._make_event(100, "Move", 20, 30),
|
||
]
|
||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||
assert segments == []
|
||
|
||
def test_excludes_drag_events(self):
|
||
"""Drag events are not Move; segment should only include Move."""
|
||
from ai_mouse.data_adapters.balabit import segment_by_clicks
|
||
events = [
|
||
self._make_event(0, "Move", 10, 20),
|
||
self._make_event(100, "Drag", 30, 40), # not Move
|
||
self._make_event(200, "Move", 50, 60),
|
||
self._make_event(300, "Pressed", 70, 80, button="Left"),
|
||
]
|
||
segments = segment_by_clicks(events, window_ms=1200, session_id="s")
|
||
assert len(segments) == 1
|
||
# Drag event should not appear in seg.events
|
||
assert all(e.state == "Move" for e in segments[0].events)
|
||
assert len(segments[0].events) == 2
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestSegmentByClicks -v
|
||
```
|
||
|
||
Expected: FAIL with `NotImplementedError`.
|
||
|
||
- [ ] **Step 3: Implement segment_by_clicks**
|
||
|
||
Replace the stub in `ai_mouse/data_adapters/balabit.py`:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestSegmentByClicks -v
|
||
```
|
||
|
||
Expected: 6 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/data_adapters/balabit.py tests/test_balabit_adapter.py
|
||
git commit -m "feat(adapter): implement click-anchored segmentation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Balabit Adapter — Filter Rules
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/data_adapters/balabit.py` (implement `filter_segments`)
|
||
- Modify: `tests/test_balabit_adapter.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Append to `tests/test_balabit_adapter.py`:
|
||
|
||
```python
|
||
class TestFilterSegments:
|
||
def _seg(self, events_data: list[tuple[int, int, int]], click=(500, 500), session="s"):
|
||
"""events_data: list of (t_ms, x, y) tuples."""
|
||
from ai_mouse.data_adapters.balabit import MouseEvent, Segment
|
||
events = [MouseEvent(t_ms=t, button="NoButton", state="Move", x=x, y=y)
|
||
for (t, x, y) in events_data]
|
||
click_t = events[-1].t_ms + 50 if events else 100
|
||
return Segment(events=events, click_x=click[0], click_y=click[1],
|
||
click_t_ms=click_t, session_id=session)
|
||
|
||
def test_drops_segment_with_too_few_events(self):
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
seg = self._seg([(0, 100, 100), (50, 105, 105)]) # 2 events, min=5
|
||
result = filter_segments([seg], min_events=5, min_dist=10,
|
||
max_span_ms=5000, max_gap_ms=200)
|
||
assert result == []
|
||
|
||
def test_drops_segment_with_short_distance(self):
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
# Start (100,100), end click=(105,100) → dist=5, min_dist=50
|
||
events = [(i*10, 100+i, 100) for i in range(10)]
|
||
seg = self._seg(events, click=(105, 100))
|
||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||
max_span_ms=5000, max_gap_ms=200)
|
||
assert result == []
|
||
|
||
def test_drops_segment_with_too_long_span(self):
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
# Span 6000ms, max_span=5000
|
||
events = [(0, 100, 100), (2000, 200, 200), (4000, 300, 300), (6000, 400, 400),
|
||
(6010, 410, 400), (6020, 420, 400)]
|
||
seg = self._seg(events, click=(500, 500))
|
||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||
max_span_ms=5000, max_gap_ms=10000)
|
||
assert result == []
|
||
|
||
def test_drops_segment_with_gap(self):
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
# Gap of 500ms between events 2 and 3, max_gap=200
|
||
events = [(0, 100, 100), (50, 110, 110), (100, 120, 120),
|
||
(600, 200, 200), (650, 210, 210), (700, 220, 220)]
|
||
seg = self._seg(events, click=(300, 300))
|
||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||
max_span_ms=5000, max_gap_ms=200)
|
||
assert result == []
|
||
|
||
def test_drops_segment_with_out_of_range_coords(self):
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
# x=10000 out of range
|
||
events = [(i*10, 10000, 100) for i in range(6)]
|
||
seg = self._seg(events, click=(10100, 100))
|
||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||
max_span_ms=5000, max_gap_ms=200)
|
||
assert result == []
|
||
|
||
def test_drops_segment_with_short_arc_length(self):
|
||
"""Total arc < 50 even though endpoints are far → high-frequency jitter only."""
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
# Tiny back-and-forth with click 100px away — unrealistic, drop it
|
||
events = [(i*10, 100 + (i % 2), 100) for i in range(10)]
|
||
seg = self._seg(events, click=(200, 100))
|
||
# arc length = sum of |Δp| ≈ 9 (alternating ±1) which is < 50
|
||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||
max_span_ms=5000, max_gap_ms=200)
|
||
assert result == []
|
||
|
||
def test_keeps_valid_segment(self):
|
||
from ai_mouse.data_adapters.balabit import filter_segments
|
||
# Smooth 100→500 px straight line, 10 events, span 500ms
|
||
events = [(i*50, 100 + i*40, 100) for i in range(10)]
|
||
seg = self._seg(events, click=(500, 100))
|
||
result = filter_segments([seg], min_events=5, min_dist=50,
|
||
max_span_ms=5000, max_gap_ms=200)
|
||
assert len(result) == 1
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestFilterSegments -v
|
||
```
|
||
|
||
Expected: FAIL with `NotImplementedError`.
|
||
|
||
- [ ] **Step 3: Implement filter_segments**
|
||
|
||
Replace the stub in `ai_mouse/data_adapters/balabit.py`:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestFilterSegments -v
|
||
```
|
||
|
||
Expected: 7 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/data_adapters/balabit.py tests/test_balabit_adapter.py
|
||
git commit -m "feat(adapter): implement segment quality filters"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Balabit Adapter — Process Session + CLI
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/data_adapters/balabit.py` (implement `process_session`, add `main`)
|
||
- Create: `ai_mouse/data_adapters/__main__.py`
|
||
- Modify: `tests/test_balabit_adapter.py`
|
||
|
||
- [ ] **Step 1: Write failing tests for process_session**
|
||
|
||
Append to `tests/test_balabit_adapter.py`:
|
||
|
||
```python
|
||
class TestProcessSession:
|
||
def test_writes_jsonl_in_expected_format(self, tmp_path):
|
||
from ai_mouse.config import BalabitAdapterConfig
|
||
from ai_mouse.data_adapters.balabit import process_session
|
||
|
||
# Construct a Balabit-format CSV with one valid segment
|
||
csv_path = tmp_path / "user_session_42"
|
||
rows = []
|
||
# 10 Move events going from (100,100) to (500,200) over 500ms
|
||
for i in range(10):
|
||
t = i * 0.05
|
||
x = 100 + i * 40
|
||
y = 100 + i * 10
|
||
rows.append(f"0,{t:.3f},NoButton,Move,{x},{y}")
|
||
rows.append(f"0,0.550,Left,Pressed,510,210")
|
||
_write_csv(csv_path, rows)
|
||
|
||
out = tmp_path / "out.jsonl"
|
||
config = BalabitAdapterConfig(
|
||
window_ms=1200, min_dist=50, min_events=5,
|
||
max_span_ms=5000, max_gap_ms=200,
|
||
)
|
||
n = process_session(csv_path, out, config)
|
||
assert n == 1
|
||
assert out.exists()
|
||
|
||
line = out.read_text(encoding="utf-8").strip()
|
||
record = json.loads(line)
|
||
assert record["meta"]["start"] == [100, 100]
|
||
assert record["meta"]["end"] == [510, 210]
|
||
assert record["meta"]["dist"] > 0
|
||
assert record["meta"]["source"] == "balabit"
|
||
assert record["meta"]["session_id"] == "user_session_42"
|
||
# Events: only move events, no down/up
|
||
assert all(e["type"] == "move" for e in record["events"])
|
||
# Timestamps relative (start at 0)
|
||
assert record["events"][0]["t"] == 0
|
||
|
||
def test_returns_zero_for_session_with_no_valid_segments(self, tmp_path):
|
||
from ai_mouse.config import BalabitAdapterConfig
|
||
from ai_mouse.data_adapters.balabit import process_session
|
||
|
||
csv_path = tmp_path / "empty_session"
|
||
_write_csv(csv_path, ["0,0.000,NoButton,Move,100,100"]) # no clicks
|
||
|
||
out = tmp_path / "out.jsonl"
|
||
config = BalabitAdapterConfig()
|
||
n = process_session(csv_path, out, config)
|
||
assert n == 0
|
||
# File may not exist or may be empty — both acceptable
|
||
if out.exists():
|
||
assert out.read_text() == ""
|
||
|
||
def test_appends_to_existing_jsonl(self, tmp_path):
|
||
from ai_mouse.config import BalabitAdapterConfig
|
||
from ai_mouse.data_adapters.balabit import process_session
|
||
|
||
out = tmp_path / "out.jsonl"
|
||
out.write_text('{"meta":{"start":[0,0],"end":[1,1]},"events":[]}\n', encoding="utf-8")
|
||
|
||
csv_path = tmp_path / "session_x"
|
||
rows = [f"0,{i*0.05:.3f},NoButton,Move,{100+i*40},100" for i in range(10)]
|
||
rows.append("0,0.550,Left,Pressed,510,100")
|
||
_write_csv(csv_path, rows)
|
||
|
||
config = BalabitAdapterConfig()
|
||
process_session(csv_path, out, config)
|
||
|
||
lines = out.read_text(encoding="utf-8").strip().split("\n")
|
||
assert len(lines) == 2 # original + appended
|
||
```
|
||
|
||
`json` import — make sure top of test file already has `import json`. If not, add.
|
||
|
||
Add this near the top of `tests/test_balabit_adapter.py`:
|
||
|
||
```python
|
||
import json
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestProcessSession -v
|
||
```
|
||
|
||
Expected: FAIL with `NotImplementedError`.
|
||
|
||
- [ ] **Step 3: Implement process_session**
|
||
|
||
Replace the `process_session` stub in `ai_mouse/data_adapters/balabit.py`:
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py::TestProcessSession -v
|
||
```
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 5: Add CLI main function to balabit.py**
|
||
|
||
Append to `ai_mouse/data_adapters/balabit.py`:
|
||
|
||
```python
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""CLI entry point: convert a directory of Balabit sessions to one JSONL file."""
|
||
import argparse
|
||
|
||
from ai_mouse.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
|
||
```
|
||
|
||
- [ ] **Step 6: Create __main__.py for CLI dispatch**
|
||
|
||
Create `ai_mouse/data_adapters/__main__.py`:
|
||
|
||
```python
|
||
"""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 ai_mouse.data_adapters.balabit import main
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|
||
```
|
||
|
||
Also add a `__main__` guard at the bottom of `balabit.py` so `python -m ai_mouse.data_adapters.balabit` works directly:
|
||
|
||
```python
|
||
if __name__ == "__main__":
|
||
import sys
|
||
sys.exit(main())
|
||
```
|
||
|
||
- [ ] **Step 7: Smoke-test the CLI**
|
||
|
||
```bash
|
||
uv run python -m ai_mouse.data_adapters.balabit --help
|
||
```
|
||
|
||
Expected: argparse help text printed.
|
||
|
||
- [ ] **Step 8: Run all adapter tests**
|
||
|
||
```bash
|
||
uv run pytest tests/test_balabit_adapter.py -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/data_adapters/ tests/test_balabit_adapter.py
|
||
git commit -m "feat(adapter): implement process_session and CLI"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Trainer — TrajectoryDataset (Streaming Augmentation)
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/trainer.py` (replace `_augment` usage with `TrajectoryDataset`)
|
||
- Modify: `tests/test_trainer.py` (add tests, keep existing ones passing)
|
||
|
||
- [ ] **Step 1: Write failing test for the new Dataset class**
|
||
|
||
Append to `tests/test_trainer.py`:
|
||
|
||
```python
|
||
class TestTrajectoryDataset:
|
||
def test_dataset_length_with_augmentation(self):
|
||
"""Dataset length = N * 6 when augment=True."""
|
||
from ai_mouse.trainer import TrajectoryDataset
|
||
seq = np.zeros((10, 64, 3), dtype=np.float32)
|
||
cond = np.zeros((10, 3), dtype=np.float32)
|
||
ds = TrajectoryDataset(seq, cond, augment=True)
|
||
assert len(ds) == 60
|
||
|
||
def test_dataset_length_without_augmentation(self):
|
||
from ai_mouse.trainer import TrajectoryDataset
|
||
seq = np.zeros((10, 64, 3), dtype=np.float32)
|
||
cond = np.zeros((10, 3), dtype=np.float32)
|
||
ds = TrajectoryDataset(seq, cond, augment=False)
|
||
assert len(ds) == 10
|
||
|
||
def test_getitem_returns_tensors(self):
|
||
from ai_mouse.trainer import TrajectoryDataset
|
||
import torch
|
||
seq = np.random.randn(5, 64, 3).astype(np.float32)
|
||
cond = np.random.randn(5, 3).astype(np.float32)
|
||
ds = TrajectoryDataset(seq, cond, augment=True)
|
||
s, c = ds[0]
|
||
assert isinstance(s, torch.Tensor)
|
||
assert isinstance(c, torch.Tensor)
|
||
assert s.shape == (64, 3)
|
||
assert c.shape == (3,)
|
||
|
||
def test_aug_id_zero_returns_original(self):
|
||
"""Aug id 0 (idx=0 % 6 == 0) should return the original sample unchanged."""
|
||
from ai_mouse.trainer import TrajectoryDataset
|
||
import torch
|
||
seq = np.array([[[0.5, 0.7, 0.3]] * 64] * 3, dtype=np.float32)
|
||
cond = np.array([[1.0, 2.0, 3.0]] * 3, dtype=np.float32)
|
||
ds = TrajectoryDataset(seq, cond, augment=True)
|
||
s0, c0 = ds[0]
|
||
np.testing.assert_allclose(s0.numpy(), seq[0], rtol=1e-5)
|
||
np.testing.assert_allclose(c0.numpy(), cond[0], rtol=1e-5)
|
||
|
||
def test_aug_id_one_flips_lateral(self):
|
||
"""Aug id 1 should flip the sign of the lateral channel (index 1)."""
|
||
from ai_mouse.trainer import TrajectoryDataset
|
||
seq = np.zeros((1, 64, 3), dtype=np.float32)
|
||
seq[0, :, 1] = 0.5 # lateral all positive
|
||
cond = np.zeros((1, 3), dtype=np.float32)
|
||
ds = TrajectoryDataset(seq, cond, augment=True)
|
||
# idx=1 → base_idx=0, aug_id=1 → flip
|
||
s1, _ = ds[1]
|
||
assert (s1[:, 1] < 0).all()
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_trainer.py::TestTrajectoryDataset -v
|
||
```
|
||
|
||
Expected: FAIL with `ImportError` (TrajectoryDataset not defined).
|
||
|
||
- [ ] **Step 3: Implement TrajectoryDataset class**
|
||
|
||
Add to `ai_mouse/trainer.py`, just below the `_augment` function (keep `_augment` for now — Task removes it):
|
||
|
||
```python
|
||
class TrajectoryDataset(torch.utils.data.Dataset):
|
||
"""Trajectory dataset with on-the-fly 6× augmentation.
|
||
|
||
Replaces the old eager `_augment(seq, cond)` which expanded the dataset
|
||
6× in memory before training. With this class, the original (N, T, 3)
|
||
arrays stay as-is and each `__getitem__` call computes one of the 6
|
||
augmentation variants on demand.
|
||
|
||
Augmentation variants (matching legacy `_augment` semantics):
|
||
0 — original
|
||
1 — lateral flip (lateral → −lateral)
|
||
2 — speed ×0.8 (log_dt[1:] += log(1.25), cond[2] += log(1.25))
|
||
3 — speed ×1.2 (log_dt[1:] += log(1/1.2), cond[2] += log(1/1.2))
|
||
4 — temporal noise (log_dt[1:] += N(0, 0.05))
|
||
5 — flip + speed ×0.9 (lateral flip, log_dt[1:] += log(1/0.9), cond[2] += log(1/0.9))
|
||
"""
|
||
|
||
_LOG_1_25 = math.log(1.25)
|
||
_LOG_INV_1_2 = math.log(1.0 / 1.2)
|
||
_LOG_1_1 = math.log(1.0 / 0.9)
|
||
|
||
def __init__(self, seq: np.ndarray, cond: np.ndarray, augment: bool = True):
|
||
self.seq = seq
|
||
self.cond = cond
|
||
self.augment = augment
|
||
self._n_aug = 6 if augment else 1
|
||
|
||
def __len__(self) -> int:
|
||
return len(self.seq) * self._n_aug
|
||
|
||
def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
|
||
base = idx // self._n_aug
|
||
aug_id = idx % self._n_aug
|
||
|
||
s = self.seq[base].copy()
|
||
c = self.cond[base].copy()
|
||
|
||
if aug_id == 1:
|
||
s[:, 1] = -s[:, 1]
|
||
elif aug_id == 2:
|
||
s[1:, 2] += self._LOG_1_25
|
||
c[2] += self._LOG_1_25
|
||
elif aug_id == 3:
|
||
s[1:, 2] += self._LOG_INV_1_2
|
||
c[2] += self._LOG_INV_1_2
|
||
elif aug_id == 4:
|
||
noise = np.random.normal(0.0, 0.05, size=s[1:, 2].shape).astype(np.float32)
|
||
s[1:, 2] += noise
|
||
elif aug_id == 5:
|
||
s[:, 1] = -s[:, 1]
|
||
s[1:, 2] += self._LOG_1_1
|
||
c[2] += self._LOG_1_1
|
||
|
||
return torch.from_numpy(s), torch.from_numpy(c)
|
||
```
|
||
|
||
- [ ] **Step 4: Run new tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_trainer.py::TestTrajectoryDataset -v
|
||
```
|
||
|
||
Expected: 5 tests pass.
|
||
|
||
- [ ] **Step 5: Switch `train()` to use TrajectoryDataset**
|
||
|
||
In `ai_mouse/trainer.py`, find the existing block (around line 332):
|
||
|
||
```python
|
||
# ---- Augment ----
|
||
seq_np, cond_np = _augment(seq_np, cond_np)
|
||
logger.info("After augmentation: %d samples", len(seq_np))
|
||
|
||
seq_t = torch.from_numpy(seq_np) # (N, seq_len, 3)
|
||
cond_t = torch.from_numpy(cond_np) # (N, 3)
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
# ---- Build streaming dataset (on-the-fly 6× augmentation) ----
|
||
if config.augment:
|
||
logger.info("Using on-the-fly 6× augmentation, base samples: %d", len(seq_np))
|
||
ds = TrajectoryDataset(seq_np, cond_np, augment=config.augment)
|
||
logger.info("Effective dataset size: %d", len(ds))
|
||
```
|
||
|
||
Then find the existing DataLoader construction (around line 353):
|
||
|
||
```python
|
||
ds = TensorDataset(seq_t, cond_t)
|
||
loader = DataLoader(ds, batch_size=batch_size, shuffle=True, drop_last=False)
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
loader = DataLoader(ds, batch_size=batch_size, shuffle=True, drop_last=False)
|
||
```
|
||
|
||
(The new `ds` already exists from the previous edit — just remove the TensorDataset construction.)
|
||
|
||
Also remove the now-unused import line `from torch.utils.data import DataLoader, TensorDataset` and replace with:
|
||
|
||
```python
|
||
from torch.utils.data import DataLoader
|
||
```
|
||
|
||
- [ ] **Step 6: Verify legacy augmentation tests still pass**
|
||
|
||
The existing test `TestAugment::test_augmentation_multiplies_data` calls `_augment` directly, which we kept. Run all trainer tests:
|
||
|
||
```bash
|
||
uv run pytest tests/test_trainer.py -v
|
||
```
|
||
|
||
Expected: all tests pass (including TestAugment, TestLoadAndPrepare, TestTrain, TestTrajectoryDataset).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/trainer.py tests/test_trainer.py
|
||
git commit -m "feat(trainer): replace eager _augment with streaming TrajectoryDataset"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Trainer — Resume from Checkpoint
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/trainer.py` (add `resume_from` parameter to `train()`)
|
||
- Modify: `tests/test_trainer.py`
|
||
|
||
- [ ] **Step 1: Write failing test for resume_from**
|
||
|
||
Append to `tests/test_trainer.py`:
|
||
|
||
```python
|
||
class TestResumeFrom:
|
||
def test_resume_from_loads_checkpoint(self, synthetic_traces_file, tmp_path):
|
||
"""train() with resume_from should load weights from given checkpoint dir."""
|
||
import torch
|
||
from ai_mouse.trainer import train
|
||
from ai_mouse.models import TrajectoryFlowModel
|
||
|
||
# First, train an initial model and save it
|
||
ckpt_dir = tmp_path / "pretrain"
|
||
train(
|
||
data_path=synthetic_traces_file,
|
||
output_dir=ckpt_dir,
|
||
epochs=2,
|
||
batch_size=8,
|
||
seq_len=64,
|
||
)
|
||
assert (ckpt_dir / "flow_model.pt").exists()
|
||
|
||
# Read its weights to compare later
|
||
m_pretrain = TrajectoryFlowModel(seq_len=64)
|
||
m_pretrain.load_state_dict(torch.load(ckpt_dir / "flow_model.pt", weights_only=True))
|
||
first_param_pre = next(m_pretrain.parameters()).clone()
|
||
|
||
# Now train with resume_from for 0 epochs — weights should still be loaded
|
||
out_dir = tmp_path / "finetune"
|
||
train(
|
||
data_path=synthetic_traces_file,
|
||
output_dir=out_dir,
|
||
epochs=1,
|
||
batch_size=8,
|
||
seq_len=64,
|
||
resume_from=ckpt_dir,
|
||
)
|
||
|
||
m_after = TrajectoryFlowModel(seq_len=64)
|
||
m_after.load_state_dict(torch.load(out_dir / "flow_model.pt", weights_only=True))
|
||
first_param_after = next(m_after.parameters())
|
||
|
||
# After 1 epoch, weights should be close to pre-train, not random init
|
||
# (random init would be O(1) magnitude apart; 1 epoch on small data shifts O(0.1))
|
||
diff = (first_param_pre - first_param_after).abs().mean().item()
|
||
assert diff < 0.5, f"Resume_from weights diverged too much: {diff}"
|
||
|
||
def test_resume_from_missing_path_raises(self, synthetic_traces_file, tmp_path):
|
||
from ai_mouse.trainer import train
|
||
|
||
with pytest.raises(FileNotFoundError):
|
||
train(
|
||
data_path=synthetic_traces_file,
|
||
output_dir=tmp_path / "out",
|
||
epochs=1,
|
||
batch_size=8,
|
||
seq_len=64,
|
||
resume_from=tmp_path / "nonexistent",
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_trainer.py::TestResumeFrom -v
|
||
```
|
||
|
||
Expected: FAIL with `TypeError: train() got an unexpected keyword argument 'resume_from'`.
|
||
|
||
- [ ] **Step 3: Add resume_from parameter to train()**
|
||
|
||
In `ai_mouse/trainer.py`, modify the `train` function signature:
|
||
|
||
```python
|
||
def train(
|
||
data_path: Path,
|
||
output_dir: Path,
|
||
epochs: int = 300,
|
||
batch_size: int = 64,
|
||
lr: float = 3e-4,
|
||
seq_len: int = 64,
|
||
progress_callback: Callable[[dict], None] | None = None,
|
||
config: TrainConfig | None = None,
|
||
resume_from: Path | None = None,
|
||
) -> None:
|
||
```
|
||
|
||
Update the docstring's Args section to add:
|
||
|
||
```
|
||
resume_from: if given, load model weights from this checkpoint
|
||
directory (must contain flow_model.pt). Used for
|
||
two-stage training (pretrain → fine-tune).
|
||
```
|
||
|
||
Then, after model construction (find the line `model = TrajectoryFlowModel(...)` around line 341) and BEFORE the optimiser is created, insert:
|
||
|
||
```python
|
||
# ---- Resume from checkpoint if requested ----
|
||
if resume_from is not None:
|
||
resume_path = Path(resume_from) / "flow_model.pt"
|
||
if not resume_path.exists():
|
||
raise FileNotFoundError(
|
||
f"resume_from checkpoint not found: {resume_path}"
|
||
)
|
||
logger.info("Resuming from checkpoint: %s", resume_path)
|
||
state_dict = torch.load(resume_path, map_location="cpu", weights_only=True)
|
||
model.load_state_dict(state_dict)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_trainer.py::TestResumeFrom -v
|
||
```
|
||
|
||
Expected: 2 tests pass.
|
||
|
||
- [ ] **Step 5: Run all trainer tests**
|
||
|
||
```bash
|
||
uv run pytest tests/test_trainer.py -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/trainer.py tests/test_trainer.py
|
||
git commit -m "feat(trainer): add resume_from for two-stage training"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Server — Auto-Resume When Pretrained Checkpoint Exists
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/server/routes_train.py`
|
||
- Modify: `tests/test_server.py` (verify still passes)
|
||
|
||
- [ ] **Step 1: Update routes_train.py to detect and pass resume_from**
|
||
|
||
In `ai_mouse/server/routes_train.py`, modify `_paths()` to also return the pretrained dir:
|
||
|
||
Find:
|
||
|
||
```python
|
||
def _paths() -> tuple[Path, Path]:
|
||
data_dir = get_data_dir()
|
||
return data_dir / "traces.jsonl", data_dir / "models_v2"
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
def _paths() -> tuple[Path, Path, Path]:
|
||
data_dir = get_data_dir()
|
||
return (
|
||
data_dir / "traces.jsonl",
|
||
data_dir / "models_v2",
|
||
data_dir / "models_v2_pretrained",
|
||
)
|
||
```
|
||
|
||
Update `_trace_count()` and `_model_trained()`:
|
||
|
||
```python
|
||
def _trace_count() -> int:
|
||
traces_path, _, _ = _paths()
|
||
if not traces_path.exists():
|
||
return 0
|
||
return sum(
|
||
1
|
||
for line in traces_path.read_text(encoding="utf-8").splitlines()
|
||
if line.strip()
|
||
)
|
||
|
||
|
||
def _model_trained() -> bool:
|
||
_, models_dir, _ = _paths()
|
||
return (models_dir / "flow_model.pt").exists()
|
||
```
|
||
|
||
Then update `_train_sse_generator`'s inner `run_training_async`:
|
||
|
||
Find:
|
||
|
||
```python
|
||
async def run_training_async() -> None:
|
||
from ai_mouse.trainer import train
|
||
|
||
traces_path, models_dir = _paths()
|
||
data_path = Path(req.data_path) if req.data_path else traces_path
|
||
output_dir = Path(req.output_dir) if req.output_dir else models_dir
|
||
try:
|
||
await asyncio.to_thread(
|
||
train,
|
||
data_path=data_path,
|
||
output_dir=output_dir,
|
||
epochs=req.epochs,
|
||
progress_callback=callback,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
queue.put_nowait({"error": str(exc)})
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
async def run_training_async() -> None:
|
||
from ai_mouse.trainer import train
|
||
|
||
traces_path, models_dir, pretrained_dir = _paths()
|
||
data_path = Path(req.data_path) if req.data_path else traces_path
|
||
output_dir = Path(req.output_dir) if req.output_dir else models_dir
|
||
|
||
# Auto-detect pretrained checkpoint and switch to fine-tune mode
|
||
resume_from: Path | None = None
|
||
effective_lr = 3e-4
|
||
if (pretrained_dir / "flow_model.pt").exists():
|
||
resume_from = pretrained_dir
|
||
effective_lr = 1e-5 # fine-tune lr
|
||
logger.info("Detected pretrained checkpoint, fine-tuning at lr=%g", effective_lr)
|
||
queue.put_nowait({
|
||
"info": f"Detected pretrained checkpoint at {pretrained_dir.name}, "
|
||
f"running fine-tune at lr={effective_lr}",
|
||
})
|
||
|
||
try:
|
||
await asyncio.to_thread(
|
||
train,
|
||
data_path=data_path,
|
||
output_dir=output_dir,
|
||
epochs=req.epochs,
|
||
lr=effective_lr,
|
||
progress_callback=callback,
|
||
resume_from=resume_from,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
queue.put_nowait({"error": str(exc)})
|
||
```
|
||
|
||
- [ ] **Step 2: Run server tests, verify still pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_server.py -v
|
||
```
|
||
|
||
Expected: all tests pass. The auto-resume behaviour is exercised when `models_v2_pretrained` exists, which is not the case in the default test environment, so the existing tests still hit the from-scratch path.
|
||
|
||
- [ ] **Step 3: Run full test suite**
|
||
|
||
```bash
|
||
uv run pytest -x
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/server/routes_train.py
|
||
git commit -m "feat(server): auto-resume from pretrained checkpoint when available"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Generator — Remove speed_profile and median±1.1 Hard Clip
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/generator.py`
|
||
- Modify: `tests/test_generator.py`
|
||
|
||
- [ ] **Step 1: Update existing tests to be tolerant of the new behaviour**
|
||
|
||
The current tests use freshly-initialised (untrained) weights and only check structural properties (timestamps monotonic, endpoints close, click events present). These should keep passing — verify.
|
||
|
||
```bash
|
||
uv run pytest tests/test_generator.py -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
Add a new test that explicitly checks the OLD speed_profile is GONE (regression guard):
|
||
|
||
Append to `tests/test_generator.py`:
|
||
|
||
```python
|
||
class TestPostProcessing:
|
||
def test_dt_diversity_preserved(self, model_dir):
|
||
"""After removing speed_profile + median clip, multiple generations
|
||
should differ in their Δt sequences (not all identical)."""
|
||
results = [generate(start=(100, 200), end=(500, 400), model_dir=str(model_dir))
|
||
for _ in range(5)]
|
||
# Extract Δt sequences (only move events, not click events)
|
||
dts = []
|
||
for r in results:
|
||
moves = r[:-2]
|
||
dt_seq = [moves[i+1][2] - moves[i][2] for i in range(len(moves)-1)]
|
||
dts.append(dt_seq)
|
||
# At least 2 of the 5 sequences should differ at any given index
|
||
# (untrained model produces noisy outputs but post-processing must
|
||
# not collapse them to the same template)
|
||
for i in range(min(len(d) for d in dts)):
|
||
values = {tuple([d[i]]) for d in dts}
|
||
if len(values) > 1:
|
||
return # at least one position has variation — pass
|
||
pytest.fail("All 5 Δt sequences are identical at every position — diversity collapsed")
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, may pass for untrained model but verifies regression guard**
|
||
|
||
```bash
|
||
uv run pytest tests/test_generator.py::TestPostProcessing -v
|
||
```
|
||
|
||
Expected: PASS (untrained models produce random outputs, so they vary).
|
||
|
||
- [ ] **Step 3: Remove the speed_profile and median clip blocks**
|
||
|
||
In `ai_mouse/generator.py`, find this block (lines ~261-272):
|
||
|
||
```python
|
||
# The model tends to produce exaggerated deceleration at the tail
|
||
# (last 10 points log_dt ~3-5 vs middle ~1.5).
|
||
# Cap the max-to-median ratio to ~3× (i.e., tail Δt ≤ 3× median Δt)
|
||
median_ldt = float(np.median(log_dt[1:]))
|
||
# Allow max log_dt = median + 1.1 (exp(1.1) ≈ 3× ratio)
|
||
max_allowed = median_ldt + 1.1
|
||
min_allowed = max(median_ldt - 1.1, 0.0)
|
||
for i in range(1, len(log_dt)):
|
||
if log_dt[i] > max_allowed:
|
||
log_dt[i] = max_allowed
|
||
elif log_dt[i] < min_allowed:
|
||
log_dt[i] = min_allowed
|
||
```
|
||
|
||
DELETE this entire block.
|
||
|
||
Then find the speed_profile block immediately after (lines ~273-286):
|
||
|
||
```python
|
||
# Apply asymmetric speed profile: start slow, fast in middle, gentle end
|
||
# Mimics natural mouse movement (accelerate → cruise → decelerate)
|
||
t_frac = np.linspace(0, 1, len(log_dt))
|
||
speed_profile = np.zeros_like(log_dt, dtype=float)
|
||
for i in range(1, len(log_dt)):
|
||
t = t_frac[i]
|
||
if t < 0.15:
|
||
# Acceleration phase: start slow (+0.3 at t=0, → 0 at t=0.15)
|
||
speed_profile[i] = 0.3 * (1.0 - t / 0.15)
|
||
elif t > 0.85:
|
||
# Deceleration phase: end slightly slow (+0.2 at t=1)
|
||
speed_profile[i] = 0.2 * ((t - 0.85) / 0.15)
|
||
# Middle: speed_profile = 0 (fastest, no penalty)
|
||
log_dt[1:] = log_dt[1:] + speed_profile[1:]
|
||
```
|
||
|
||
DELETE this entire block too.
|
||
|
||
- [ ] **Step 4: Update the docstring at the top of the file**
|
||
|
||
In `ai_mouse/generator.py`, find the module docstring (lines 1-22). Replace it with:
|
||
|
||
```python
|
||
"""Inference layer: Flow Matching trajectory generation.
|
||
|
||
Pipeline:
|
||
1. Load model from model_dir (flow_model.pt, click_dist.json,
|
||
duration_dist.json, train_config.json).
|
||
2. Compute condition vector: [dist/2000, log(dist/100), log(total_dur/500)].
|
||
3. Sample total_duration from duration_dist.json by distance bin (log-normal).
|
||
4. 10-step Euler ODE: start from noise, integrate velocity field to get trajectory.
|
||
5. Spatial post-processing:
|
||
a. Endpoint snapping: force first=(0,0), last=(1,0), lerp last 6 points.
|
||
b. Smooth start: dampen lateral near start (first 4 points).
|
||
c. Enforce forward monotonicity (prevent x-axis jitter).
|
||
6. Temporal post-processing:
|
||
a. Clip log_dt to [0, 5] to prevent exponential explosion.
|
||
(speed profile and median±1.1 hard clip removed in 2026-05 refactor —
|
||
let the model's learned timing distribution come through naturally.)
|
||
7. Decode to pixels via decode_trajectory.
|
||
8. Resample to n_points if n_points != model seq_len.
|
||
9. Convert log_dt → ms timestamps, scale to total_duration, clip [2, 150].
|
||
10. Ensure timestamps monotonically increasing.
|
||
11. Append click events sampled from truncated normal.
|
||
"""
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
uv run pytest tests/test_generator.py -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/generator.py tests/test_generator.py
|
||
git commit -m "refactor(generator): remove deterministic speed_profile and hard log_dt clip
|
||
|
||
These post-processing hacks were added to compensate for small-data
|
||
training. With Balabit pretraining they suppress the multimodal
|
||
timing distribution and cause the template-y Δt curves seen in the
|
||
verify UI."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Generator — Add Lateral Gaussian Smoothing
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/generator.py`
|
||
- Modify: `tests/test_generator.py`
|
||
|
||
- [ ] **Step 1: Write failing test for the smoothing helper**
|
||
|
||
Append to `tests/test_generator.py`:
|
||
|
||
```python
|
||
class TestGaussianSmooth:
|
||
def test_endpoints_preserved(self):
|
||
from ai_mouse.generator import _gaussian_smooth
|
||
x = np.array([1.0, 5.0, 3.0, 7.0, 2.0], dtype=np.float64)
|
||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||
assert smoothed[0] == 1.0
|
||
assert smoothed[-1] == 2.0
|
||
|
||
def test_smooths_high_frequency(self):
|
||
"""A high-frequency square wave should have reduced amplitude after smoothing."""
|
||
from ai_mouse.generator import _gaussian_smooth
|
||
x = np.array([0, 1, 0, 1, 0, 1, 0, 1, 0, 1], dtype=np.float64)
|
||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||
# Interior amplitude should be reduced
|
||
interior_orig = x[2:-2]
|
||
interior_smooth = smoothed[2:-2]
|
||
assert interior_smooth.std() < interior_orig.std()
|
||
|
||
def test_constant_signal_unchanged(self):
|
||
from ai_mouse.generator import _gaussian_smooth
|
||
x = np.full(20, 0.5, dtype=np.float64)
|
||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||
np.testing.assert_allclose(smoothed, x, rtol=1e-6)
|
||
|
||
def test_short_array_returns_unchanged(self):
|
||
"""Arrays shorter than the kernel are returned unchanged."""
|
||
from ai_mouse.generator import _gaussian_smooth
|
||
x = np.array([1.0, 2.0, 3.0], dtype=np.float64)
|
||
smoothed = _gaussian_smooth(x, sigma=1.0)
|
||
np.testing.assert_allclose(smoothed, x, rtol=1e-6)
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_generator.py::TestGaussianSmooth -v
|
||
```
|
||
|
||
Expected: FAIL with `ImportError: cannot import name '_gaussian_smooth'`.
|
||
|
||
- [ ] **Step 3: Implement _gaussian_smooth**
|
||
|
||
Add to `ai_mouse/generator.py`, just below the imports (before any function definitions):
|
||
|
||
```python
|
||
def _gaussian_smooth(x: np.ndarray, sigma: float = 1.0) -> np.ndarray:
|
||
"""5-point gaussian smoothing along a 1-D array, preserving endpoints.
|
||
|
||
Args:
|
||
x: 1-D input array.
|
||
sigma: Gaussian std (px); larger = more smoothing. Default 1.0 gives
|
||
weights ≈ [0.054, 0.244, 0.403, 0.244, 0.054].
|
||
|
||
Returns:
|
||
Smoothed array of the same shape. x[0] and x[-1] are unchanged.
|
||
If len(x) < 5, returns x unchanged (kernel won't fit).
|
||
"""
|
||
if len(x) < 5:
|
||
return x.copy()
|
||
kernel = np.exp(-0.5 * (np.arange(-2, 3) / sigma) ** 2)
|
||
kernel /= kernel.sum()
|
||
smoothed = np.convolve(x, kernel, mode="same")
|
||
smoothed[0] = x[0]
|
||
smoothed[-1] = x[-1]
|
||
return smoothed
|
||
```
|
||
|
||
- [ ] **Step 4: Run smoothing tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_generator.py::TestGaussianSmooth -v
|
||
```
|
||
|
||
Expected: 4 tests pass.
|
||
|
||
- [ ] **Step 5: Apply smoothing to lateral in generate()**
|
||
|
||
In `ai_mouse/generator.py`, find the spatial post-processing block. After this code:
|
||
|
||
```python
|
||
# Clamp forward to [0, 1] and re-force endpoints after monotonicity fix
|
||
forward = np.clip(forward, 0.0, 1.0)
|
||
forward[0] = 0.0
|
||
forward[-1] = 1.0
|
||
```
|
||
|
||
Add:
|
||
|
||
```python
|
||
# Lateral 5-point gaussian smoothing (endpoints preserved)
|
||
lateral = _gaussian_smooth(lateral, sigma=1.0)
|
||
```
|
||
|
||
- [ ] **Step 6: Update module docstring to document the new smoothing step**
|
||
|
||
In the module docstring at the top of `ai_mouse/generator.py`, find:
|
||
|
||
```python
|
||
5. Spatial post-processing:
|
||
a. Endpoint snapping: force first=(0,0), last=(1,0), lerp last 6 points.
|
||
b. Smooth start: dampen lateral near start (first 4 points).
|
||
c. Enforce forward monotonicity (prevent x-axis jitter).
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
5. Spatial post-processing:
|
||
a. Endpoint snapping: force first=(0,0), last=(1,0), lerp last 6 points.
|
||
b. Smooth start: dampen lateral near start (first 4 points).
|
||
c. Enforce forward monotonicity (prevent x-axis jitter).
|
||
d. 5-point gaussian smooth on lateral (preserve endpoints).
|
||
```
|
||
|
||
- [ ] **Step 7: Run all generator tests**
|
||
|
||
```bash
|
||
uv run pytest tests/test_generator.py -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/generator.py tests/test_generator.py
|
||
git commit -m "feat(generator): add 5-point gaussian smoothing on lateral"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Eval — Kinematics Metrics
|
||
|
||
**Files:**
|
||
- Create: `ai_mouse/eval/__init__.py`
|
||
- Create: `ai_mouse/eval/metrics.py`
|
||
- Create: `tests/test_eval_metrics.py`
|
||
|
||
- [ ] **Step 1: Write failing tests for kinematics metrics**
|
||
|
||
Create `tests/test_eval_metrics.py`:
|
||
|
||
```python
|
||
"""Tests for the eval metrics module."""
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
|
||
class TestKinematics:
|
||
def test_compute_speed_constant_velocity(self):
|
||
"""Constant-velocity trajectory has constant speed."""
|
||
from ai_mouse.eval.metrics import compute_speed
|
||
# 10 points, moving 10 px in 100 ms each step → speed = 0.1 px/ms
|
||
xs = np.arange(0, 100, 10, dtype=float)
|
||
ys = np.zeros(10, dtype=float)
|
||
ts = np.arange(0, 1000, 100, dtype=float)
|
||
v = compute_speed(xs, ys, ts)
|
||
# All speeds should be ≈ 0.1 px/ms
|
||
assert v.shape == (9,) # n-1 differences
|
||
np.testing.assert_allclose(v, 0.1, rtol=1e-4)
|
||
|
||
def test_compute_speed_handles_zero_dt(self):
|
||
"""Adjacent points with same timestamp must not produce NaN/inf."""
|
||
from ai_mouse.eval.metrics import compute_speed
|
||
xs = np.array([0.0, 10.0, 20.0])
|
||
ys = np.array([0.0, 0.0, 0.0])
|
||
ts = np.array([0.0, 0.0, 100.0]) # zero dt between [0] and [1]
|
||
v = compute_speed(xs, ys, ts)
|
||
assert np.isfinite(v).all()
|
||
|
||
def test_compute_acceleration(self):
|
||
"""Linearly increasing speed → constant acceleration."""
|
||
from ai_mouse.eval.metrics import compute_acceleration
|
||
# speeds: 0.1, 0.2, 0.3, 0.4 over dt = 100 ms each → a = 0.001 px/ms²
|
||
speeds = np.array([0.1, 0.2, 0.3, 0.4])
|
||
ts = np.array([100.0, 200.0, 300.0, 400.0])
|
||
a = compute_acceleration(speeds, ts)
|
||
np.testing.assert_allclose(a, 0.001, rtol=1e-4)
|
||
|
||
def test_compute_jerk(self):
|
||
from ai_mouse.eval.metrics import compute_jerk
|
||
# accelerations: 0.001, 0.002, 0.003 over dt = 100 ms → j = 0.00001
|
||
accels = np.array([0.001, 0.002, 0.003])
|
||
ts = np.array([200.0, 300.0, 400.0])
|
||
j = compute_jerk(accels, ts)
|
||
np.testing.assert_allclose(j, 1e-5, rtol=1e-4)
|
||
|
||
|
||
class TestStatsSummary:
|
||
def test_compute_stats_returns_expected_keys(self):
|
||
from ai_mouse.eval.metrics import compute_stats
|
||
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
s = compute_stats(x)
|
||
assert "mean" in s
|
||
assert "std" in s
|
||
assert "cv" in s
|
||
assert "p25" in s
|
||
assert "p50" in s
|
||
assert "p75" in s
|
||
assert "p95" in s
|
||
|
||
def test_cv_for_constant_is_zero(self):
|
||
from ai_mouse.eval.metrics import compute_stats
|
||
x = np.full(10, 3.0)
|
||
s = compute_stats(x)
|
||
assert s["cv"] == 0.0
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_eval_metrics.py::TestKinematics tests/test_eval_metrics.py::TestStatsSummary -v
|
||
```
|
||
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'ai_mouse.eval'`.
|
||
|
||
- [ ] **Step 3: Create the eval package and metrics module**
|
||
|
||
Create `ai_mouse/eval/__init__.py`:
|
||
|
||
```python
|
||
"""Evaluation module: kinematic metrics and Markdown report generation."""
|
||
```
|
||
|
||
Create `ai_mouse/eval/metrics.py`:
|
||
|
||
```python
|
||
"""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+1,) timestamps that produced those speeds (ms).
|
||
We use the midpoints between adjacent ts.
|
||
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)
|
||
# ts has length M+1; we need M-1 dts between speed points
|
||
# speed[i] is between ts[i] and ts[i+1], so it's at ts midpoint (ts[i]+ts[i+1])/2
|
||
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)),
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_eval_metrics.py -v
|
||
```
|
||
|
||
Expected: 6 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/eval/ tests/test_eval_metrics.py
|
||
git commit -m "feat(eval): kinematics metrics (speed, accel, jerk, stats)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Eval — FFT Spectrum + KL Divergence
|
||
|
||
**Files:**
|
||
- Modify: `ai_mouse/eval/metrics.py` (add FFT and KL functions)
|
||
- Modify: `tests/test_eval_metrics.py`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Append to `tests/test_eval_metrics.py`:
|
||
|
||
```python
|
||
class TestFftSpectrum:
|
||
def test_finds_dominant_frequency(self):
|
||
"""A pure 8 Hz signal should have its peak near 8 Hz."""
|
||
from ai_mouse.eval.metrics import fft_spectrum
|
||
# Sample at 100 Hz for 1 second
|
||
sample_rate_hz = 100.0
|
||
ts_ms = np.arange(0, 1000, 1000 / sample_rate_hz)
|
||
signal = np.sin(2 * np.pi * 8 * ts_ms / 1000) # 8 Hz sine
|
||
freqs, mags = fft_spectrum(signal, sample_rate_hz)
|
||
peak_freq = freqs[np.argmax(mags)]
|
||
assert abs(peak_freq - 8.0) < 1.0 # within 1 Hz
|
||
|
||
def test_returns_only_positive_frequencies(self):
|
||
from ai_mouse.eval.metrics import fft_spectrum
|
||
signal = np.random.randn(64)
|
||
freqs, mags = fft_spectrum(signal, 50.0)
|
||
assert (freqs >= 0).all()
|
||
assert len(freqs) == len(mags)
|
||
|
||
|
||
class TestKlDivergence:
|
||
def test_identical_distributions_zero_kl(self):
|
||
"""KL(p, p) ≈ 0."""
|
||
from ai_mouse.eval.metrics import kl_divergence_histograms
|
||
rng = np.random.default_rng(42)
|
||
x = rng.normal(0, 1, 5000)
|
||
y = rng.normal(0, 1, 5000)
|
||
kl = kl_divergence_histograms(x, y, bins=50)
|
||
assert kl < 0.05
|
||
|
||
def test_different_distributions_positive_kl(self):
|
||
"""Different means → positive KL."""
|
||
from ai_mouse.eval.metrics import kl_divergence_histograms
|
||
rng = np.random.default_rng(42)
|
||
x = rng.normal(0, 1, 5000)
|
||
y = rng.normal(3, 1, 5000)
|
||
kl = kl_divergence_histograms(x, y, bins=50)
|
||
assert kl > 0.5
|
||
|
||
def test_handles_disjoint_supports(self):
|
||
"""No NaN even when histograms have non-overlapping bins."""
|
||
from ai_mouse.eval.metrics import kl_divergence_histograms
|
||
x = np.array([1.0, 1.1, 1.2, 1.3, 1.4])
|
||
y = np.array([10.0, 10.1, 10.2, 10.3, 10.4])
|
||
kl = kl_divergence_histograms(x, y, bins=10)
|
||
assert np.isfinite(kl)
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_eval_metrics.py::TestFftSpectrum tests/test_eval_metrics.py::TestKlDivergence -v
|
||
```
|
||
|
||
Expected: FAIL with `ImportError`.
|
||
|
||
- [ ] **Step 3: Implement fft_spectrum and kl_divergence_histograms**
|
||
|
||
Append to `ai_mouse/eval/metrics.py`:
|
||
|
||
```python
|
||
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)))
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_eval_metrics.py::TestFftSpectrum tests/test_eval_metrics.py::TestKlDivergence -v
|
||
```
|
||
|
||
Expected: 5 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/eval/metrics.py tests/test_eval_metrics.py
|
||
git commit -m "feat(eval): add FFT spectrum and KL divergence metrics"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Eval — Report Generation
|
||
|
||
**Files:**
|
||
- Create: `ai_mouse/eval/report.py`
|
||
- Modify: `tests/test_eval_metrics.py` (add light-weight smoke test)
|
||
|
||
- [ ] **Step 1: Write a smoke test that the report module is importable and produces output**
|
||
|
||
Append to `tests/test_eval_metrics.py`:
|
||
|
||
```python
|
||
class TestReportGeneration:
|
||
def test_generates_report_md(self, tmp_path):
|
||
"""Smoke test: build_report writes an MD file with all expected sections."""
|
||
from ai_mouse.eval.report import build_report
|
||
|
||
# Synthetic generated traces (3 traces, 50 points each)
|
||
rng = np.random.default_rng(0)
|
||
gen_traces = []
|
||
for _ in range(3):
|
||
xs = np.cumsum(rng.uniform(0, 5, 50))
|
||
ys = np.cumsum(rng.uniform(-1, 1, 50))
|
||
ts = np.cumsum(rng.uniform(5, 20, 50))
|
||
gen_traces.append({"xs": xs, "ys": ys, "ts": ts})
|
||
|
||
# Synthetic reference
|
||
ref_traces = []
|
||
for _ in range(5):
|
||
xs = np.cumsum(rng.uniform(0, 5, 50))
|
||
ys = np.cumsum(rng.uniform(-1, 1, 50))
|
||
ts = np.cumsum(rng.uniform(5, 20, 50))
|
||
ref_traces.append({"xs": xs, "ys": ys, "ts": ts})
|
||
|
||
out_md = tmp_path / "report.md"
|
||
build_report(
|
||
generated_traces=gen_traces,
|
||
reference_traces=ref_traces,
|
||
output_md=out_md,
|
||
tag="smoke-test",
|
||
model_dir="/fake/model/dir",
|
||
)
|
||
assert out_md.exists()
|
||
content = out_md.read_text(encoding="utf-8")
|
||
assert "# Eval Report" in content
|
||
assert "smoke-test" in content
|
||
assert "速度" in content or "speed" in content.lower()
|
||
assert "FFT" in content.upper()
|
||
# PNG plots should exist next to MD
|
||
plot_dir = tmp_path / "plots"
|
||
assert plot_dir.exists()
|
||
assert any(plot_dir.iterdir())
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, verify failure**
|
||
|
||
```bash
|
||
uv run pytest tests/test_eval_metrics.py::TestReportGeneration -v
|
||
```
|
||
|
||
Expected: FAIL with `ImportError: cannot import build_report`.
|
||
|
||
- [ ] **Step 3: Implement report.py**
|
||
|
||
Create `ai_mouse/eval/report.py`:
|
||
|
||
```python
|
||
"""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 ai_mouse.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
|
||
# Use the cross-track ('lateral') signal: project onto perpendicular
|
||
# of start→end vector. Approximate by detrended y.
|
||
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 (mouse data is irregular —
|
||
100 Hz is a sensible nominal).
|
||
"""
|
||
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)
|
||
```
|
||
|
||
- [ ] **Step 4: Run smoke test, verify pass**
|
||
|
||
```bash
|
||
uv run pytest tests/test_eval_metrics.py::TestReportGeneration -v
|
||
```
|
||
|
||
Expected: 1 test passes. Plots and Markdown file created in `tmp_path`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/eval/report.py tests/test_eval_metrics.py
|
||
git commit -m "feat(eval): Markdown report builder with matplotlib plots"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Eval — CLI
|
||
|
||
**Files:**
|
||
- Create: `ai_mouse/eval/__main__.py`
|
||
|
||
- [ ] **Step 1: Implement the eval CLI**
|
||
|
||
Create `ai_mouse/eval/__main__.py`:
|
||
|
||
```python
|
||
"""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):
|
||
# Random start/end on a 800x600 canvas, distance 100..600 px
|
||
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 ai_mouse.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())
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke-test the CLI help text**
|
||
|
||
```bash
|
||
uv run python -m ai_mouse.eval --help
|
||
```
|
||
|
||
Expected: argparse help text printed.
|
||
|
||
- [ ] **Step 3: Run all tests**
|
||
|
||
```bash
|
||
uv run pytest -x
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/eval/__main__.py
|
||
git commit -m "feat(eval): CLI for generating evaluation reports"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Unified Train CLI
|
||
|
||
**Files:**
|
||
- Create: `ai_mouse/__main__.py`
|
||
|
||
- [ ] **Step 1: Create unified CLI dispatcher**
|
||
|
||
Create `ai_mouse/__main__.py`:
|
||
|
||
```python
|
||
"""Unified CLI: `python -m ai_mouse {train,eval,balabit-adapter}`
|
||
|
||
Subcommands dispatch to the underlying modules. This is the recommended
|
||
top-level entry; you can also call `python -m ai_mouse.eval` etc. directly.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
def _train_main(args: argparse.Namespace) -> int:
|
||
from ai_mouse.trainer import train
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
train(
|
||
data_path=Path(args.data),
|
||
output_dir=Path(args.output),
|
||
epochs=args.epochs,
|
||
batch_size=args.batch_size,
|
||
lr=args.lr,
|
||
seq_len=args.seq_len,
|
||
resume_from=Path(args.resume_from) if args.resume_from else None,
|
||
)
|
||
return 0
|
||
|
||
|
||
def _eval_main(args: argparse.Namespace) -> int:
|
||
from ai_mouse.eval.__main__ import main as eval_main
|
||
# Reconstruct argv for the sub-CLI
|
||
argv = [
|
||
"--model-dir", args.model_dir,
|
||
"--reference", str(args.reference),
|
||
"--n-samples", str(args.n_samples),
|
||
"--n-reference", str(args.n_reference),
|
||
"--output", str(args.output),
|
||
"--tag", args.tag,
|
||
"--seed", str(args.seed),
|
||
]
|
||
return eval_main(argv)
|
||
|
||
|
||
def _balabit_main(args: argparse.Namespace) -> int:
|
||
from ai_mouse.data_adapters.balabit import main as bal_main
|
||
argv = [
|
||
"--input", str(args.input),
|
||
"--output", str(args.output),
|
||
"--window-ms", str(args.window_ms),
|
||
"--min-dist", str(args.min_dist),
|
||
"--min-events", str(args.min_events),
|
||
"--max-span-ms", str(args.max_span_ms),
|
||
"--max-gap-ms", str(args.max_gap_ms),
|
||
]
|
||
if args.overwrite:
|
||
argv.append("--overwrite")
|
||
return bal_main(argv)
|
||
|
||
|
||
def main() -> int:
|
||
p = argparse.ArgumentParser(prog="ai_mouse", description="AI Mouse trajectory toolkit")
|
||
sub = p.add_subparsers(dest="cmd", required=True)
|
||
|
||
# train
|
||
pt = sub.add_parser("train", help="Train (or fine-tune) the Flow Matching model")
|
||
pt.add_argument("--data", required=True, help="Path to traces.jsonl")
|
||
pt.add_argument("--output", required=True, help="Output checkpoint dir")
|
||
pt.add_argument("--epochs", type=int, default=200)
|
||
pt.add_argument("--batch-size", type=int, default=64)
|
||
pt.add_argument("--lr", type=float, default=3e-4)
|
||
pt.add_argument("--seq-len", type=int, default=64)
|
||
pt.add_argument("--resume-from", default=None, help="Checkpoint dir to resume from (for fine-tune)")
|
||
pt.set_defaults(func=_train_main)
|
||
|
||
# eval
|
||
pe = sub.add_parser("eval", help="Generate evaluation report")
|
||
pe.add_argument("--model-dir", required=True)
|
||
pe.add_argument("--reference", type=Path, required=True)
|
||
pe.add_argument("--n-samples", type=int, default=200)
|
||
pe.add_argument("--n-reference", type=int, default=1000)
|
||
pe.add_argument("--output", type=Path, required=True)
|
||
pe.add_argument("--tag", default="eval")
|
||
pe.add_argument("--seed", type=int, default=0)
|
||
pe.set_defaults(func=_eval_main)
|
||
|
||
# balabit-adapter
|
||
pb = sub.add_parser("balabit-adapter", help="Convert Balabit dataset to traces.jsonl")
|
||
pb.add_argument("--input", type=Path, required=True)
|
||
pb.add_argument("--output", type=Path, default=Path("data/pretrain_traces.jsonl"))
|
||
pb.add_argument("--window-ms", type=int, default=1200)
|
||
pb.add_argument("--min-dist", type=int, default=50)
|
||
pb.add_argument("--min-events", type=int, default=5)
|
||
pb.add_argument("--max-span-ms", type=int, default=5000)
|
||
pb.add_argument("--max-gap-ms", type=int, default=200)
|
||
pb.add_argument("--overwrite", action="store_true")
|
||
pb.set_defaults(func=_balabit_main)
|
||
|
||
args = p.parse_args()
|
||
return args.func(args)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke-test all three subcommands**
|
||
|
||
```bash
|
||
uv run python -m ai_mouse train --help
|
||
uv run python -m ai_mouse eval --help
|
||
uv run python -m ai_mouse balabit-adapter --help
|
||
```
|
||
|
||
Expected: each prints argparse help.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add ai_mouse/__main__.py
|
||
git commit -m "feat: unified CLI (python -m ai_mouse {train,eval,balabit-adapter})"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Run Pre-Refactor Baseline Eval (Optional)
|
||
|
||
This task captures the "before" snapshot so we can measure improvement quantitatively. **Requires**: Balabit data already converted (Task 18 needs to run first if you don't have Balabit yet, OR you can use the existing 605 `traces.jsonl` as both reference and "real data" — less informative but doable).
|
||
|
||
- [ ] **Step 1: Capture baseline using existing 605 traces as reference**
|
||
|
||
If Balabit isn't downloaded yet, use the existing 605 traces as a reference (split-off subset):
|
||
|
||
```bash
|
||
# Take the existing 605 traces as reference (use what you have)
|
||
uv run python -m ai_mouse eval \
|
||
--model-dir data/models_v2 \
|
||
--reference data/traces.jsonl \
|
||
--n-samples 100 \
|
||
--n-reference 200 \
|
||
--output data/eval_reports/2026-05-10-baseline-pre-refactor.md \
|
||
--tag baseline-pre-refactor
|
||
```
|
||
|
||
Expected: Markdown report at `data/eval_reports/2026-05-10-baseline-pre-refactor.md` plus PNG plots. **Note the KL values — these are the "before" numbers to beat.**
|
||
|
||
- [ ] **Step 2: Inspect the report**
|
||
|
||
Open the file in a Markdown viewer or your editor and look at:
|
||
- Speed/accel/jerk KL divergence
|
||
- Δt CV (should be very low for the current template-y model)
|
||
- The path PNG (should show the high-frequency lateral jitter)
|
||
|
||
- [ ] **Step 3: Save baseline numbers**
|
||
|
||
Manually note (or copy into a comment file) the KL values for later comparison:
|
||
|
||
```bash
|
||
echo "Pre-refactor baseline (2026-05-10):" > data/eval_reports/_BASELINE_NOTES.txt
|
||
echo " See: 2026-05-10-baseline-pre-refactor.md" >> data/eval_reports/_BASELINE_NOTES.txt
|
||
echo " Acceptance: post-refactor KL must be < 50% of these numbers" >> data/eval_reports/_BASELINE_NOTES.txt
|
||
```
|
||
|
||
- [ ] **Step 4: Commit baseline report**
|
||
|
||
```bash
|
||
git add data/eval_reports/2026-05-10-baseline-pre-refactor.md \
|
||
data/eval_reports/_BASELINE_NOTES.txt \
|
||
data/eval_reports/plots/
|
||
git commit -m "docs(eval): pre-refactor baseline report"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Run Balabit Adapter + Pretraining
|
||
|
||
**Prerequisites:** Download Balabit dataset to `data/balabit_raw/`:
|
||
|
||
```bash
|
||
# In a separate terminal — user manual step
|
||
cd /d/code/python/side/ai_mouse/data
|
||
git clone --depth 1 https://github.com/balabit/Mouse-Dynamics-Challenge.git balabit_raw
|
||
# OR: download zip from the GitHub page and extract to data/balabit_raw/
|
||
```
|
||
|
||
Verify the structure:
|
||
|
||
```bash
|
||
ls data/balabit_raw/
|
||
# Expect to see directories like 'training_files/' or 'test_files/' containing user folders
|
||
```
|
||
|
||
- [ ] **Step 1: Convert Balabit → pretrain_traces.jsonl**
|
||
|
||
```bash
|
||
uv run python -m ai_mouse balabit-adapter \
|
||
--input data/balabit_raw/training_files \
|
||
--output data/pretrain_traces.jsonl \
|
||
--overwrite
|
||
```
|
||
|
||
Expected: log shows "Wrote N segments to data/pretrain_traces.jsonl" with N typically 5,000–50,000 depending on what's in the dataset.
|
||
|
||
If N < 1000, **stop and investigate**:
|
||
- Try `--window-ms 2000` (longer window)
|
||
- Try `--min-dist 30` (shorter minimum distance)
|
||
- Inspect `data/balabit_raw/` structure — the path may be wrong (try `data/balabit_raw/test_files` instead)
|
||
|
||
- [ ] **Step 2: Spot-check converted data**
|
||
|
||
```bash
|
||
uv run python -c "
|
||
import json
|
||
with open('data/pretrain_traces.jsonl') as f:
|
||
lines = f.readlines()
|
||
print(f'Total: {len(lines)} traces')
|
||
sample = json.loads(lines[0])
|
||
print('Sample meta:', sample['meta'])
|
||
print('Sample event count:', len(sample['events']))
|
||
print('First 3 events:', sample['events'][:3])
|
||
"
|
||
```
|
||
|
||
Expected: meta has `start`, `end`, `dist`, `angle`, `source: balabit`. Events list looks like real `move` records with sane (x, y, t).
|
||
|
||
- [ ] **Step 3: Reserve a 5% holdout for eval**
|
||
|
||
```bash
|
||
uv run python -c "
|
||
import random
|
||
from pathlib import Path
|
||
random.seed(42)
|
||
src = Path('data/pretrain_traces.jsonl')
|
||
lines = src.read_text(encoding='utf-8').splitlines()
|
||
random.shuffle(lines)
|
||
split = int(len(lines) * 0.95)
|
||
Path('data/pretrain_train.jsonl').write_text('\n'.join(lines[:split]) + '\n', encoding='utf-8')
|
||
Path('data/balabit_holdout.jsonl').write_text('\n'.join(lines[split:]) + '\n', encoding='utf-8')
|
||
print(f'Train: {split} Holdout: {len(lines)-split}')
|
||
"
|
||
```
|
||
|
||
- [ ] **Step 4: Run pretraining**
|
||
|
||
This will take **2 hours – 2 days** depending on hardware. Run in background or overnight.
|
||
|
||
```bash
|
||
uv run python -m ai_mouse train \
|
||
--data data/pretrain_train.jsonl \
|
||
--output data/models_v2_pretrained \
|
||
--epochs 200 \
|
||
--batch-size 128 \
|
||
--lr 3e-4
|
||
```
|
||
|
||
Expected outputs in `data/models_v2_pretrained/`:
|
||
- `flow_model.pt`
|
||
- `click_dist.json` (will have default values since Balabit has no click events — this is expected)
|
||
- `duration_dist.json`
|
||
- `train_config.json`
|
||
|
||
- [ ] **Step 5: Verify checkpoint loads cleanly**
|
||
|
||
```bash
|
||
uv run python -c "
|
||
import torch
|
||
from ai_mouse.models import TrajectoryFlowModel
|
||
m = TrajectoryFlowModel(seq_len=64)
|
||
state = torch.load('data/models_v2_pretrained/flow_model.pt', weights_only=True)
|
||
m.load_state_dict(state)
|
||
print('Pretrain checkpoint loads OK')
|
||
"
|
||
```
|
||
|
||
Expected: `Pretrain checkpoint loads OK`.
|
||
|
||
- [ ] **Step 6: Commit (do not commit the model weights — they're in .gitignore)**
|
||
|
||
```bash
|
||
# Just commit the holdout split logic via a small note
|
||
git add docs/superpowers/plans/2026-05-10-balabit-pretrain-refactor.md
|
||
git commit -m "chore: ran Balabit conversion + pretraining (artifacts in data/, not committed)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Fine-tune on 605 Traces + Final Eval
|
||
|
||
- [ ] **Step 1: Run fine-tune**
|
||
|
||
```bash
|
||
uv run python -m ai_mouse train \
|
||
--data data/traces.jsonl \
|
||
--output data/models_v2 \
|
||
--epochs 50 \
|
||
--batch-size 64 \
|
||
--lr 1e-5 \
|
||
--resume-from data/models_v2_pretrained
|
||
```
|
||
|
||
Expected: training completes in 5–30 minutes, `data/models_v2/flow_model.pt` updated.
|
||
|
||
- [ ] **Step 2: Generate post-refactor eval report**
|
||
|
||
```bash
|
||
uv run python -m ai_mouse eval \
|
||
--model-dir data/models_v2 \
|
||
--reference data/balabit_holdout.jsonl \
|
||
--n-samples 200 \
|
||
--n-reference 1000 \
|
||
--output data/eval_reports/2026-05-10-final.md \
|
||
--tag final
|
||
```
|
||
|
||
- [ ] **Step 3: Compare to baseline**
|
||
|
||
Open both reports side-by-side:
|
||
- `data/eval_reports/2026-05-10-baseline-pre-refactor.md`
|
||
- `data/eval_reports/2026-05-10-final.md`
|
||
|
||
Verify against the spec's acceptance criteria:
|
||
|
||
1. ✅ **主观**:Δt 曲线在 verify 页面应该不再 5 条重合
|
||
2. ✅ **主观**:lateral 轨迹无高频锯齿(看 paths.png)
|
||
3. ✅ **量化**:speed KL < 50% of pre-refactor baseline
|
||
4. ✅ **量化**:FFT 4–12 Hz 区间出现 peak(看 fft.png)
|
||
5. ✅ **回归**:所有非废弃测试通过
|
||
|
||
- [ ] **Step 4: Run full test suite as final regression check**
|
||
|
||
```bash
|
||
uv run pytest -v
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 5: Manual verification in the UI**
|
||
|
||
```bash
|
||
uv run python main.py
|
||
```
|
||
|
||
Open http://127.0.0.1:8765 in browser, navigate to "验证效果" tab, generate 5 paths from (100, 200) to (700, 400), and compare visually to the original screenshot.
|
||
|
||
Expected:
|
||
- 5 Δt curves visibly diverge (no longer overlapping)
|
||
- Lateral trajectories smooth, no zigzag
|
||
- Average duration similar to before (still in plausible range)
|
||
|
||
- [ ] **Step 6: Commit final report**
|
||
|
||
```bash
|
||
git add data/eval_reports/2026-05-10-final.md data/eval_reports/plots/
|
||
git commit -m "docs(eval): post-refactor final eval report
|
||
|
||
Acceptance criteria met:
|
||
- speed KL <baseline*0.5
|
||
- FFT shows 4-12Hz peak
|
||
- Δt diversity restored (subjective)
|
||
- Lateral jitter eliminated (subjective)
|
||
- All tests pass"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Checklist
|
||
|
||
After completing all tasks, verify:
|
||
|
||
- [ ] All 19 tasks committed
|
||
- [ ] All tests pass (`uv run pytest -v`)
|
||
- [ ] Baseline report exists for comparison
|
||
- [ ] Final report shows quantitative improvement
|
||
- [ ] UI manually verified
|
||
- [ ] No new files outside the spec's "Files Changed" list
|
||
- [ ] `data/` artifacts NOT committed (verified via `git status --short`)
|