feat(trainer): replace eager _augment with streaming TrajectoryDataset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 12:51:52 +08:00
parent 0b1d18d15c
commit bba8fc567b
2 changed files with 113 additions and 8 deletions

View File

@@ -21,7 +21,7 @@ from pathlib import Path
import numpy as np import numpy as np
import torch import torch
from torch.utils.data import DataLoader, TensorDataset from torch.utils.data import DataLoader
from ai_mouse.config import TrainConfig from ai_mouse.config import TrainConfig
from ai_mouse.coord import encode_trajectory from ai_mouse.coord import encode_trajectory
@@ -226,6 +226,62 @@ def _augment(
return np.concatenate(seqs, axis=0), np.concatenate(conds, axis=0) return np.concatenate(seqs, axis=0), np.concatenate(conds, axis=0)
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Duration distribution (per distance bin) # Duration distribution (per distance bin)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -328,12 +384,11 @@ def train(
seq_np, cond_np, click_durs = load_and_prepare_data(data_path, seq_len=seq_len) seq_np, cond_np, click_durs = load_and_prepare_data(data_path, seq_len=seq_len)
logger.info("Loaded %d traces", len(seq_np)) logger.info("Loaded %d traces", len(seq_np))
# ---- Augment ---- # ---- Build streaming dataset (on-the-fly 6× augmentation) ----
seq_np, cond_np = _augment(seq_np, cond_np) if config.augment:
logger.info("After augmentation: %d samples", len(seq_np)) logger.info("Using on-the-fly 6× augmentation, base samples: %d", len(seq_np))
ds = TrajectoryDataset(seq_np, cond_np, augment=config.augment)
seq_t = torch.from_numpy(seq_np) # (N, seq_len, 3) logger.info("Effective dataset size: %d", len(ds))
cond_t = torch.from_numpy(cond_np) # (N, 3)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
@@ -350,7 +405,6 @@ def train(
optimiser = torch.optim.AdamW(model.parameters(), lr=lr) optimiser = torch.optim.AdamW(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimiser, T_max=epochs) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimiser, T_max=epochs)
ds = TensorDataset(seq_t, cond_t)
loader = DataLoader(ds, batch_size=batch_size, shuffle=True, drop_last=False) loader = DataLoader(ds, batch_size=batch_size, shuffle=True, drop_last=False)
# ---- Training loop: OT-Conditional Flow Matching ---- # ---- Training loop: OT-Conditional Flow Matching ----

View File

@@ -119,3 +119,54 @@ class TestTrain:
first_half = np.mean(losses[:10]) first_half = np.mean(losses[:10])
second_half = np.mean(losses[10:]) second_half = np.mean(losses[10:])
assert second_half < first_half assert second_half < first_half
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()