| import torch |
| import torch.optim as optim |
| from torch.utils.data import DataLoader |
| import os |
| import time |
| import numpy as np |
| from tqdm import tqdm |
| import sys |
| from pathlib import Path |
|
|
| |
| sys.path.append(str(Path(__file__).parent.parent)) |
|
|
| from src.models.student import LIPEV2Student |
| from src.models.loss import GazeDistillationLoss |
| from src.data.dataset import GazeDataset |
|
|
| def get_loss_weights(epoch): |
| """ |
| Dynamic Weighting Schedule from PHASE3_PLAN.md |
| """ |
| if epoch <= 5: |
| w_aw, w_kd = 1.0, 0.5 |
| elif epoch <= 30: |
| w_aw, w_kd = 0.2, 1.0 |
| elif epoch <= 40: |
| |
| |
| t = (epoch - 30) / 10.0 |
| w_aw = 0.2 + t * (1.0 - 0.2) |
| w_kd = 1.0 - t * (1.0 - 0.1) |
| else: |
| w_aw, w_kd = 1.0, 0.1 |
| |
| return w_aw, w_kd |
|
|
| def train_epoch(model, loader, criterion, optimizer, device, epoch, w_aw, w_kd): |
| model.train() |
| running_loss = 0.0 |
| running_aw = 0.0 |
| running_kd = 0.0 |
| |
| pbar = tqdm(loader, desc=f"Epoch {epoch} [w_aw={w_aw:.2f}, w_kd={w_kd:.2f}]") |
| for batch in pbar: |
| |
| batch = [b.to(device) for b in batch] |
| |
| |
| |
| |
| |
| if len(batch) >= 5: |
| patches, landmarks, gaze, t_p_logits, t_yaw_logits = batch[:5] |
| teacher_logits = (t_p_logits, t_yaw_logits) |
| else: |
| patches, landmarks, gaze = batch[:3] |
| teacher_logits = None |
| |
| optimizer.zero_grad() |
| |
| |
| student_outputs = model(patches, landmarks, state='A') |
| |
| |
| criterion.w_aw = w_aw |
| criterion.w_kd = w_kd |
| |
| loss, l_aw, l_kd = criterion(student_outputs, gaze, teacher_logits) |
| |
| loss.backward() |
| optimizer.step() |
| |
| running_loss += loss.item() |
| running_aw += l_aw.item() if isinstance(l_aw, torch.Tensor) else l_aw |
| running_kd += l_kd.item() if isinstance(l_kd, torch.Tensor) else l_kd |
| |
| pbar.set_postfix({'loss': f"{loss.item():.4f}", 'aw': f"{l_aw:.4f}", 'kd': f"{l_kd:.4f}"}) |
| |
| return running_loss / len(loader), running_aw / len(loader), running_kd / len(loader) |
|
|
| def validate(model, loader, device): |
| model.eval() |
| total_error = 0.0 |
| count = 0 |
| |
| with torch.no_grad(): |
| for batch in loader: |
| batch = [b.to(device) for b in batch] |
| patches, landmarks, gaze = batch[0], batch[1], batch[2] |
| |
| outputs = model(patches, landmarks, state='A') |
| if len(outputs) == 3: |
| p_logits, y_logits, _ = outputs |
| else: |
| p_logits, y_logits = outputs |
| |
| |
| idx = torch.arange(90).float().to(device) |
| p_prob = torch.softmax(p_logits, dim=1) |
| y_prob = torch.softmax(y_logits, dim=1) |
| |
| p_deg = (torch.sum(p_prob * idx, dim=1) * 2 - 90) |
| y_deg = (torch.sum(y_prob * idx, dim=1) * 2 - 90) |
| |
| |
| gt_deg = gaze * (180.0 / np.pi) |
| |
| |
| error = torch.abs(p_deg - gt_deg[:, 0]) + torch.abs(y_deg - gt_deg[:, 1]) |
| total_error += error.sum().item() |
| count += gaze.shape[0] |
| |
| if count == 0: |
| return float('inf') |
| |
| return total_error / (count * 2) |
|
|
| class EarlyStopping: |
| def __init__(self, patience=10, min_delta=0.001, verbose=True): |
| self.patience = patience |
| self.min_delta = min_delta |
| self.verbose = verbose |
| self.counter = 0 |
| self.best_loss = None |
| self.early_stop = False |
|
|
| def __call__(self, val_loss): |
| if self.best_loss is None: |
| self.best_loss = val_loss |
| elif val_loss > self.best_loss - self.min_delta: |
| self.counter += 1 |
| if self.verbose: |
| print(f"EarlyStopping counter: {self.counter} out of {self.patience}") |
| if self.counter >= self.patience: |
| self.early_stop = True |
| else: |
| self.best_loss = val_loss |
| self.counter = 0 |
|
|
| def train(h5_dir, test_participant='p00', num_epochs=100, batch_size=64, lr=1e-4, no_kd=False, patience=15, version_suffix="", num_workers=4): |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| print(f"Training on {device} {'(No KD)' if no_kd else ''} | Version: {version_suffix if version_suffix else 'baseline'}") |
| |
| |
| all_files = os.listdir(h5_dir) |
| if version_suffix: |
| relevant_files = [f for f in all_files if f.endswith(f"{version_suffix}.h5")] |
| else: |
| relevant_files = [f for f in all_files if f.endswith('.h5') and '_v' not in f] |
| |
| train_files = [os.path.join(h5_dir, f) for f in relevant_files if not f.startswith(test_participant)] |
| val_files = [os.path.join(h5_dir, f) for f in relevant_files if f.startswith(test_participant)] |
| |
| if not train_files: |
| print(f"No training files found for {test_participant} with suffix '{version_suffix}'.") |
| return |
| |
| train_ds = GazeDataset(train_files, transform=True) |
| val_ds = GazeDataset(val_files, transform=False) |
| |
| |
| |
| sample_weights = [] |
| |
| import h5py |
| counts = {} |
| for i, f_path in enumerate(train_files): |
| with h5py.File(f_path, 'r') as f: |
| counts[f_path] = f['left_patches'].shape[0] |
| |
| for i, j in train_ds.indices: |
| f_path = train_ds.h5_files[i] |
| sample_weights.append(1.0 / counts[f_path]) |
| |
| sampler = torch.utils.data.WeightedRandomSampler(sample_weights, len(sample_weights)) |
| |
| train_loader = DataLoader(train_ds, batch_size=batch_size, sampler=sampler, num_workers=num_workers, pin_memory=True) |
| val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True) |
|
|
| |
| model = LIPEV2Student().to(device) |
| criterion = GazeDistillationLoss() |
| optimizer = optim.Adam(model.parameters(), lr=lr) |
| |
| scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=15) |
| |
| early_stopping = EarlyStopping(patience=patience, verbose=True) |
| |
| best_mae = float('inf') |
| os.makedirs('checkpoints', exist_ok=True) |
| os.makedirs('logs', exist_ok=True) |
| |
| for epoch in range(1, num_epochs + 1): |
| if no_kd: |
| w_aw, w_kd = 1.0, 0.0 |
| else: |
| w_aw, w_kd = get_loss_weights(epoch) |
| |
| train_loss, train_aw, train_kd = train_epoch(model, train_loader, criterion, optimizer, device, epoch, w_aw, w_kd) |
| val_mae = validate(model, val_loader, device) |
| |
| |
| current_lr = optimizer.param_groups[0]['lr'] |
| print(f"Epoch {epoch}: Train Loss {train_loss:.4f}, Val MAE {val_mae:.4f}, LR {current_lr:.6f}") |
| |
| if val_mae < best_mae: |
| best_mae = val_mae |
| torch.save(model.state_dict(), f'checkpoints/best_student_{test_participant}.pt') |
| log_msg = f"New best model saved at epoch {epoch}! (MAE: {best_mae:.4f})" |
| print(log_msg) |
| |
| |
| with open('logs/best_epochs.log', 'a', encoding='utf-8') as f: |
| f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Participant: {test_participant}, Epoch: {epoch}, MAE: {best_mae:.4f}, Version: {version_suffix}\n") |
| |
| early_stopping(val_mae) |
| if early_stopping.early_stop: |
| print(f"Early stopping triggered at epoch {epoch}") |
| break |
| |
| scheduler.step(val_mae) |
|
|
| if __name__ == '__main__': |
| import argparse |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--h5_dir', type=str, default='data/processed') |
| parser.add_argument('--test_participant', type=str, default='p00') |
| parser.add_argument('--num_epochs', type=int, default=100) |
| parser.add_argument('--batch_size', type=int, default=32) |
| parser.add_argument('--lr', type=float, default=1e-4) |
| parser.add_argument('--patience', type=int, default=15) |
| parser.add_argument('--no_kd', action='store_true', help='Disable Knowledge Distillation') |
| parser.add_argument('--version_suffix', type=str, default='', help='Suffix for h5 files (e.g. _v16)') |
| parser.add_argument('--num_workers', type=int, default=4) |
| args = parser.parse_args() |
| |
| train(args.h5_dir, |
| test_participant=args.test_participant, |
| num_epochs=args.num_epochs, |
| batch_size=args.batch_size, |
| lr=args.lr, |
| no_kd=args.no_kd, |
| patience=args.patience, |
| version_suffix=args.version_suffix, |
| num_workers=args.num_workers) |
|
|