File size: 23,942 Bytes
1e05592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
#!/usr/bin/env python3
"""
Temporal Belief Aggregation Trainer for LKAlert Policy Head.

Key insight: single-frame beliefs cannot distinguish OBSERVE from ALERT
(AP locked at 0.24). By processing K consecutive observation windows
through a GRU, the model captures danger escalation dynamics.

Architecture:
  belief_seq [B, T, 2048] -> proj(256) -> GRU(258, 256) -> MLP -> 3-class logits

Usage:
  python -m training.Policy.temporal_trainer \
      --sft_checkpoint checkpoints/SFT/sft_v2/best \
      --label_dir data/policy_labels \
      --belief_cache_dir data/belief_cache \
      --output_dir checkpoints/Policy \
      --experiment_name temporal_base \
      --seq_len 8
"""

from __future__ import annotations

import argparse
import json
import logging
import math
import time
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.metrics import average_precision_score
from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
from tqdm import tqdm

import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from lkalert.models.components import TemporalPolicyHead
from training.Policy.policy_dataset import PolicyDataset, policy_collate_fn

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("Policy.temporal")

ACTION_NAMES = {0: "SILENT", 1: "OBSERVE", 2: "ALERT"}


# ═══════════════════════════════════════════════════════════════════════════════
# Dataset: extends PolicyDataset with temporal context
# ═══════════════════════════════════════════════════════════════════════════════

class TemporalPolicyDataset(PolicyDataset):
    """
    Extends PolicyDataset with temporal context: for each sample, returns
    the K most recent belief vectors from the same video (sorted by frame index).
    """

    def __init__(self, manifests, split, belief_cache_path, seq_len=8, **kwargs):
        super().__init__(manifests, split, belief_cache_path, **kwargs)
        self.seq_len = seq_len
        self._build_temporal_index()

    def _build_temporal_index(self):
        """Build per-video sorted index for temporal context lookup."""
        video_samples: dict[str, list] = defaultdict(list)
        for i, s in enumerate(self.samples):
            # Use first frame index as temporal sort key
            frame_key = s["frame_indices"][0] if s.get("frame_indices") else i
            video_samples[s["video_id"]].append((i, frame_key))

        self._temporal_ctx: list[list[int]] = [[] for _ in range(len(self.samples))]
        for vid, pairs in video_samples.items():
            pairs.sort(key=lambda x: x[1])
            for j, (idx, _) in enumerate(pairs):
                start = max(0, j - self.seq_len + 1)
                ctx = [pairs[k][0] for k in range(start, j + 1)]
                # Left-pad with earliest if shorter than seq_len
                while len(ctx) < self.seq_len:
                    ctx.insert(0, ctx[0])
                self._temporal_ctx[idx] = ctx

        # Stats
        n_unique = sum(1 for ctx in self._temporal_ctx if len(set(ctx)) > 1)
        logger.info(
            f"Temporal index built: seq_len={self.seq_len}, "
            f"{n_unique}/{len(self.samples)} samples have >1 unique context frame"
        )

    def __getitem__(self, idx: int) -> Dict[str, Any]:
        item = super().__getitem__(idx)
        if self._cache is not None:
            ctx = self._temporal_ctx[idx]
            item["belief_seq"] = self._cache["beliefs"][ctx]       # [K, H]
            item["tta_mean_seq"] = self._cache["tta_means"][ctx]   # [K]
            item["tta_var_seq"] = self._cache["tta_vars"][ctx]     # [K]
        return item


def temporal_collate_fn(batch: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Collate for temporal dataset β€” adds sequence tensors."""
    out = policy_collate_fn(batch)
    if "belief_seq" in batch[0]:
        out["belief_seqs"] = torch.stack([b["belief_seq"] for b in batch])      # [B, K, H]
        out["tta_mean_seqs"] = torch.stack([b["tta_mean_seq"] for b in batch])  # [B, K]
        out["tta_var_seqs"] = torch.stack([b["tta_var_seq"] for b in batch])    # [B, K]
    return out


# ═══════════════════════════════════════════════════════════════════════════════
# Model: frozen SFT + trainable TemporalPolicyHead
# ═══════════════════════════════════════════════════════════════════════════════

class TemporalPolicyModel(nn.Module):
    """Lightweight wrapper: only the TemporalPolicyHead is trainable."""

    def __init__(self, hidden_dim: int, seq_len: int, device: str = "cuda"):
        super().__init__()
        self.policy_head = TemporalPolicyHead(hidden_dim=hidden_dim).to(device)
        self._device = torch.device(device)
        trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
        logger.info(f"TemporalPolicyModel: {trainable:,} trainable params, seq_len={seq_len}")

    @property
    def device(self):
        return self._device

    def forward(self, belief_seqs, tta_mean_seqs, tta_var_seqs):
        """
        Args: belief_seqs [B,T,H], tta_mean_seqs [B,T], tta_var_seqs [B,T]
        Returns: logits [B, 3]
        """
        return self.policy_head(
            belief_seqs.to(self._device),
            tta_mean_seqs.to(self._device),
            tta_var_seqs.to(self._device),
        )

    def save_checkpoint(self, save_dir: str, meta: Optional[dict] = None):
        d = Path(save_dir)
        d.mkdir(parents=True, exist_ok=True)
        torch.save(self.policy_head.state_dict(), d / "policy_head.pt")
        if meta is not None:
            meta["version"] = "v6_temporal"
            with open(d / "policy_meta.json", "w") as f:
                json.dump(meta, f, indent=2)
        logger.info(f"  Checkpoint saved -> {d}")

    def load_policy_checkpoint(self, ckpt_dir: str):
        path = Path(ckpt_dir) / "policy_head.pt"
        self.policy_head.load_state_dict(torch.load(path, map_location=self._device))
        logger.info(f"  Loaded checkpoint from {path}")


# ═══════════════════════════════════════════════════════════════════════════════
# Loss functions
# ═══════════════════════════════════════════════════════════════════════════════

def focal_cross_entropy(
    logits: torch.Tensor,    # [B, C]
    targets: torch.Tensor,   # [B] long
    alpha: float = 0.75,
    gamma: float = 2.0,
    label_smoothing: float = 0.0,
) -> torch.Tensor:
    """Focal loss for multi-class classification."""
    C = logits.shape[1]
    probs = F.softmax(logits, dim=-1)
    idx = torch.arange(len(targets), device=logits.device)
    pt = probs[idx, targets]

    # Label smoothing
    if label_smoothing > 0:
        with torch.no_grad():
            smooth_target = torch.full_like(probs, label_smoothing / (C - 1))
            smooth_target.scatter_(1, targets.unsqueeze(1), 1.0 - label_smoothing)
        ce = -(smooth_target * probs.clamp(1e-8).log()).sum(dim=-1)
    else:
        ce = F.cross_entropy(logits, targets, reduction="none")

    focal_weight = alpha * (1.0 - pt) ** gamma
    return (focal_weight * ce).mean()


def monotonic_loss(
    logits: torch.Tensor,       # [B, 3]
    tta_raws: torch.Tensor,     # [B]
    video_ids: List[str],       # [B]
    margin: float = 0.05,
) -> torch.Tensor:
    """
    Temporal monotonic constraint: P(ALERT) should be non-decreasing
    as TTA decreases (closer to collision).
    """
    probs = F.softmax(logits, dim=-1)
    p_alert = probs[:, 2]

    # Group by video
    vid_to_idx: dict[str, list] = defaultdict(list)
    for i, vid in enumerate(video_ids):
        if tta_raws[i].item() > 0:  # skip non_ego / safe_neg with tta=-1
            vid_to_idx[vid].append(i)

    violations = []
    n_pairs = 0
    for vid, indices in vid_to_idx.items():
        if len(indices) < 2:
            continue
        ttas = tta_raws[indices]
        palerts = p_alert[indices]
        order = ttas.argsort(descending=True)
        sorted_p = palerts[order]
        for i in range(len(sorted_p) - 1):
            diff = sorted_p[i] - sorted_p[i + 1] + margin
            if diff > 0:
                violations.append(diff)
            n_pairs += 1

    if not violations:
        return logits.new_tensor(0.0), 0, n_pairs

    loss = torch.stack(violations).mean()
    return loss, len(violations), n_pairs


# ═══════════════════════════════════════════════════════════════════════════════
# Evaluation
# ═══════════════════════════════════════════════════════════════════════════════

@torch.no_grad()
def evaluate(model, loader, tau_grid=True) -> dict:
    """Evaluate model on val set with optional threshold grid search."""
    model.eval()
    all_logits, all_labels, all_cats, all_ttas, all_vids = [], [], [], [], []

    for batch in tqdm(loader, desc="Eval", ncols=80, leave=False):
        logits = model(batch["belief_seqs"], batch["tta_mean_seqs"], batch["tta_var_seqs"])
        all_logits.append(logits.cpu())
        all_labels.extend(batch["action_labels"].tolist())
        all_cats.extend(batch["categories"])
        all_ttas.extend(batch["tta_raws"].tolist())
        all_vids.extend(batch["video_ids"])

    logits = torch.cat(all_logits, dim=0)   # [N, 3]
    probs = F.softmax(logits, dim=-1).numpy()
    labels = np.array(all_labels)
    cats = np.array(all_cats)

    # Binary AP: P(ALERT) ranking
    binary_true = (labels == 2).astype(int)
    p_alert = probs[:, 2]
    binary_ap = float(average_precision_score(binary_true, p_alert)) if binary_true.sum() > 0 else 0.0

    # Danger AP
    danger_true = (labels >= 1).astype(int)
    p_danger = 1.0 - probs[:, 0]
    danger_ap = float(average_precision_score(danger_true, p_danger)) if danger_true.sum() > 0 else 0.0

    # Monotonic violation rate
    mono_viol, mono_pairs = _mono_stats(p_alert, np.array(all_ttas), all_vids)

    def _metrics_at_threshold(alert_bias=0.0):
        """Compute PolicyScore at given alert_bias (added to P(ALERT) before argmax)."""
        adj = probs.copy()
        adj[:, 2] += alert_bias
        preds = adj.argmax(axis=1)
        return _policy_metrics(preds, labels, cats)

    # Default (no bias)
    base = _metrics_at_threshold(0.0)
    result = {
        **base,
        "binary_ap": binary_ap,
        "danger_ap": danger_ap,
        "mono_violation_rate": mono_viol,
        "mono_n_pairs": mono_pairs,
    }

    # Threshold grid search: adjust alert_bias to maximize PolicyScore
    if tau_grid:
        best_score = base["policy_score"]
        best_bias = 0.0
        for bias in np.arange(-0.3, 0.31, 0.02):
            m = _metrics_at_threshold(bias)
            if m["policy_score"] > best_score:
                best_score = m["policy_score"]
                best_bias = bias
        if best_bias != 0.0:
            best_m = _metrics_at_threshold(best_bias)
            result["grid_best_policy_score"] = best_m["policy_score"]
            result["grid_best_alert_bias"] = best_bias
            result["grid_best_ego_alert_recall"] = best_m["ego_alert_recall"]
            result["grid_best_safe_neg_silent"] = best_m["safe_neg_silent_rate"]
        else:
            result["grid_best_policy_score"] = best_score
            result["grid_best_alert_bias"] = 0.0

    model.train()
    return result


def _policy_metrics(preds, labels, cats):
    """Compute PolicyScore and sub-metrics."""
    ego_mask = cats == "ego_positive"
    ne_mask = cats == "non_ego"
    sn_mask = cats == "safe_neg"

    # Ego alert recall: fraction of ego_positive with label=ALERT that are predicted ALERT
    ego_alert_mask = ego_mask & (labels == 2)
    ego_alert_recall = float((preds[ego_alert_mask] == 2).mean()) if ego_alert_mask.sum() > 0 else 0.0

    # Non-ego no-alert: fraction of non_ego NOT predicted ALERT
    ne_noalert = float((preds[ne_mask] != 2).mean()) if ne_mask.sum() > 0 else 0.0

    # Safe-neg silent: fraction of safe_neg predicted SILENT
    sn_silent = float((preds[sn_mask] == 0).mean()) if sn_mask.sum() > 0 else 0.0

    # Safe-neg alert leak
    sn_alert = float((preds[sn_mask] == 2).mean()) if sn_mask.sum() > 0 else 0.0

    # PolicyScore v3 (safety-first): 0.65 * ego_recall + 0.25 * safe_silent - 0.15 * safe_alert
    policy_score = 0.65 * ego_alert_recall + 0.25 * sn_silent - 0.15 * sn_alert
    acc = float((preds == labels).mean())

    return {
        "policy_score": policy_score,
        "ego_alert_recall": ego_alert_recall,
        "non_ego_noalert_rate": ne_noalert,
        "safe_neg_silent_rate": sn_silent,
        "safe_neg_alert_rate": sn_alert,
        "overall_acc": acc,
    }


def _mono_stats(p_alert, ttas, video_ids):
    """Compute monotonic violation statistics."""
    vid_to_data: dict[str, list] = defaultdict(list)
    for i, vid in enumerate(video_ids):
        if ttas[i] > 0:
            vid_to_data[vid].append((ttas[i], p_alert[i]))

    violations = 0
    n_pairs = 0
    for vid, data in vid_to_data.items():
        if len(data) < 2:
            continue
        data.sort(key=lambda x: -x[0])  # descending TTA
        for i in range(len(data) - 1):
            n_pairs += 1
            if data[i][1] > data[i + 1][1]:  # earlier frame has higher P(ALERT)
                violations += 1

    return violations / max(n_pairs, 1), n_pairs


# ═══════════════════════════════════════════════════════════════════════════════
# Training loop
# ═══════════════════════════════════════════════════════════════════════════════

def train(args):
    label_dir = Path(args.label_dir)
    cache_dir = Path(args.belief_cache_dir)
    train_cache_path = Path(args.train_cache_path) if args.train_cache_path else cache_dir / "train.pt"
    val_cache_path   = Path(args.val_cache_path)   if args.val_cache_path   else cache_dir / "val.pt"

    # ── datasets ──
    train_ds = TemporalPolicyDataset(
        manifests=[label_dir / "train.json"],
        split="train",
        belief_cache_path=train_cache_path,
        seq_len=args.seq_len,
        debug=args.debug,
        debug_samples=args.debug_samples,
    )
    val_ds = TemporalPolicyDataset(
        manifests=[label_dir / "val.json"],
        split="val",
        belief_cache_path=val_cache_path,
        seq_len=args.seq_len,
        debug=args.debug,
        debug_samples=args.debug_samples,
    )

    # ── balanced sampler ──
    if args.use_balanced_sampler:
        labels = [s["action_label"] for s in train_ds.samples]
        counts = Counter(labels)
        weights = [1.0 / counts[l] for l in labels]
        sampler = WeightedRandomSampler(weights, len(weights), replacement=True)
    else:
        sampler = None

    bs = min(args.batch_size, len(train_ds))
    train_loader = DataLoader(
        train_ds, batch_size=bs,
        sampler=sampler, shuffle=(sampler is None),
        collate_fn=temporal_collate_fn,
        num_workers=4, pin_memory=True,
        drop_last=(not args.debug),
    )
    val_loader = DataLoader(
        val_ds, batch_size=args.batch_size, shuffle=False,
        collate_fn=temporal_collate_fn,
        num_workers=4, pin_memory=True,
    )

    # ── model ──
    if args.hidden_dim and args.hidden_dim > 0:
        hidden_dim = args.hidden_dim
    else:
        # Auto-detect from the belief cache tensor (no loader consumption).
        cache = getattr(train_ds, "_cache", None)
        if cache is None or "beliefs" not in cache:
            raise RuntimeError("Cannot auto-detect hidden_dim: belief cache missing. "
                               "Pass --hidden_dim explicitly.")
        hidden_dim = int(cache["beliefs"].shape[-1])
        logger.info(f"  auto-detected hidden_dim={hidden_dim} from belief cache")
    model = TemporalPolicyModel(hidden_dim, args.seq_len)
    optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate, weight_decay=1e-4)

    n_epochs = 2 if args.debug else args.num_epochs
    total_steps = n_epochs * len(train_loader)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=total_steps, eta_min=1e-6)

    # ── training ──
    exp_dir = Path(args.output_dir) / args.experiment_name
    best_dir = exp_dir / "best"
    best_score = -1.0
    patience_counter = 0
    global_step = 0

    logger.info(f"Training {args.experiment_name}: {n_epochs} epochs, "
                f"{len(train_loader)} steps/epoch, seq_len={args.seq_len}")
    logger.info(f"  focal: alpha={args.focal_alpha}, gamma={args.focal_gamma}")
    logger.info(f"  mono_lambda={args.mono_lambda}, label_smoothing={args.label_smoothing}")

    t0 = time.time()

    for epoch in range(n_epochs):
        model.train()
        epoch_loss = 0.0
        epoch_mono = 0.0
        n_batches = 0

        pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{n_epochs}", ncols=100)
        for batch in pbar:
            logits = model(batch["belief_seqs"], batch["tta_mean_seqs"], batch["tta_var_seqs"])
            labels = batch["action_labels"].to(model.device)

            # Focal CE
            loss = focal_cross_entropy(
                logits, labels,
                alpha=args.focal_alpha, gamma=args.focal_gamma,
                label_smoothing=args.label_smoothing,
            )

            # Monotonic constraint
            mono_l = torch.tensor(0.0)
            if args.mono_lambda > 0:
                mono_l, _, _ = monotonic_loss(
                    logits, batch["tta_raws"], batch["video_ids"]
                )
                loss = loss + args.mono_lambda * mono_l

            optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()
            scheduler.step()

            epoch_loss += loss.item()
            epoch_mono += mono_l.item()
            n_batches += 1
            global_step += 1

            pbar.set_postfix(loss=f"{loss.item():.4f}", mono=f"{mono_l.item():.4f}",
                             lr=f"{scheduler.get_last_lr()[0]:.2e}")

            # Mid-epoch validation (log + save best, but do NOT count patience)
            if global_step % args.val_every_n_steps == 0:
                val_result = evaluate(model, val_loader, tau_grid=True)
                score = val_result.get("grid_best_policy_score", val_result["policy_score"])
                logger.info(
                    f"  [step {global_step}] PolicyScore={score:.4f} "
                    f"AP={val_result['binary_ap']:.4f} "
                    f"ego_recall={val_result['ego_alert_recall']:.3f} "
                    f"sn_silent={val_result['safe_neg_silent_rate']:.3f}"
                )
                if score > best_score:
                    best_score = score
                    patience_counter = 0
                    model.save_checkpoint(str(best_dir), meta={
                        **val_result, "global_step": global_step, "epoch": epoch + 1,
                        "seq_len": args.seq_len,
                    })

        avg_loss = epoch_loss / max(n_batches, 1)
        avg_mono = epoch_mono / max(n_batches, 1)
        logger.info(f"Epoch {epoch+1} avg_loss={avg_loss:.4f} avg_mono={avg_mono:.4f}")

        # End-of-epoch validation
        val_result = evaluate(model, val_loader, tau_grid=True)
        score = val_result.get("grid_best_policy_score", val_result["policy_score"])
        logger.info(
            f"  Val: PolicyScore={score:.4f} AP={val_result['binary_ap']:.4f} "
            f"danger_ap={val_result['danger_ap']:.4f} "
            f"mono_viol={val_result['mono_violation_rate']:.3f}"
        )

        if score > best_score:
            best_score = score
            patience_counter = 0
            model.save_checkpoint(str(best_dir), meta={
                **val_result, "global_step": global_step, "epoch": epoch + 1,
                "seq_len": args.seq_len,
            })
        else:
            patience_counter += 1

        if patience_counter >= args.early_stop_patience:
            logger.info(f"Early stopping at epoch {epoch+1} (patience={args.early_stop_patience})")
            break

    elapsed = time.time() - t0
    logger.info(f"Training complete in {elapsed/60:.1f} min. Best PolicyScore={best_score:.4f}")
    logger.info(f"Best checkpoint: {best_dir}")
    return best_dir


def main():
    parser = argparse.ArgumentParser("temporal_trainer")
    parser.add_argument("--sft_checkpoint", required=True, help="(unused, kept for CLI compat)")
    parser.add_argument("--label_dir", default="data/policy_labels")
    parser.add_argument("--belief_cache_dir", default="data/belief_cache")
    parser.add_argument("--output_dir", default="checkpoints/Policy")
    parser.add_argument("--experiment_name", default="temporal_base")
    parser.add_argument("--seq_len", type=int, default=8)
    parser.add_argument("--num_epochs", type=int, default=15)
    parser.add_argument("--batch_size", type=int, default=256)
    parser.add_argument("--learning_rate", type=float, default=2e-4)
    parser.add_argument("--focal_alpha", type=float, default=0.75)
    parser.add_argument("--focal_gamma", type=float, default=2.0)
    parser.add_argument("--mono_lambda", type=float, default=0.0)
    parser.add_argument("--label_smoothing", type=float, default=0.0)
    parser.add_argument("--val_every_n_steps", type=int, default=200)
    parser.add_argument("--early_stop_patience", type=int, default=7)
    parser.add_argument("--use_balanced_sampler", action="store_true")
    parser.add_argument("--debug", action="store_true")
    parser.add_argument("--debug_samples", type=int, default=128)
    parser.add_argument("--hidden_dim", type=int, default=0,
                        help="Belief hidden dim. 0 = auto-detect from cache (recommended).")
    parser.add_argument("--train_cache_path", type=str, default=None,
                        help="Override: explicit path to train belief cache (.pt). "
                             "Falls back to belief_cache_dir/train.pt.")
    parser.add_argument("--val_cache_path", type=str, default=None,
                        help="Override: explicit path to val belief cache (.pt). "
                             "Falls back to belief_cache_dir/val.pt.")
    args = parser.parse_args()

    train(args)


if __name__ == "__main__":
    main()