File size: 9,517 Bytes
a10ba7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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

# Add project root to 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: # Stage 0
        w_aw, w_kd = 1.0, 0.5
    elif epoch <= 30: # Stage 1
        w_aw, w_kd = 0.2, 1.0
    elif epoch <= 40: # Transition Phase (Linear Annealing)
        # Epoch 31: start transition
        # Epoch 40: end transition
        t = (epoch - 30) / 10.0
        w_aw = 0.2 + t * (1.0 - 0.2)
        w_kd = 1.0 - t * (1.0 - 0.1)
    else: # Stage 2
        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:
        # Move all tensors to device
        batch = [b.to(device) for b in batch]
        
        # GazeDataset returns:
        # 3 items: (patch, landmarks, gaze)
        # 5 items: (patch, landmarks, gaze, t_p, t_y)
        # 6 items: (patch, landmarks, gaze, t_p, t_y, domain_id)
        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()
        
        # We always train in State A (Full mode)
        student_outputs = model(patches, landmarks, state='A')
        
        # Set weights dynamically
        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
            
            # Convert logits to angles (Degrees)
            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 Gaze is in radians, convert to degrees
            gt_deg = gaze * (180.0 / np.pi)
            
            # Simple L1 error for now (Pitch/Yaw MAE)
            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) # Average MAE per axis

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'}")
    
    # LOPO: Split files
    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) # Enable augmentations for training
    val_ds = GazeDataset(val_files, transform=False) # Disable for validation
        
    # Subject-Level Weighted Sampling (PHASE3_PLAN.md)
    # Ensure each participant contributes equally
    sample_weights = []
    # Count samples per file
    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)
    # Refinement: Phase 5 Slow and Steady Scheduler
    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)
        
        # Get current learning rate for logging
        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)
            
            # Log to file
            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)