summaryrefslogtreecommitdiff
path: root/train.py
blob: 4a04a300c08355e8856117bcfc553059cf4949d0 (plain)
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
import copy
import time

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from sklearn.metrics import f1_score


def train_model(
    model: nn.Module,
    train_loader: DataLoader,
    val_loader: DataLoader,
    criterion: nn.Module,
    optimizer: torch.optim.Optimizer,
    scheduler,
    device: torch.device,
    num_epochs: int = 30,
    patience: int = 5,
    model_name: str = "",
) -> dict:
    """Train model with mixed precision, early stopping on val macro F1.

    Returns a dict with:
      best_model_state, train_losses, val_losses, val_f1s (macro), val_weighted_f1s,
      train_time (seconds), epochs_trained, best_val_f1 (macro)
    """
    scaler = torch.amp.GradScaler('cuda') if device.type == 'cuda' else None

    best_val_f1 = -1.0
    best_state = None
    patience_counter = 0

    train_losses, val_losses, val_f1s, val_weighted_f1s = [], [], [], []
    start_time = time.time()

    for epoch in range(1, num_epochs + 1):
        # ── Training phase ──────────────────────────────────────────────────
        model.train()
        running_loss = 0.0
        n_train = 0

        for images, labels in train_loader:
            images = images.to(device, non_blocking=True)
            labels = labels.to(device, non_blocking=True)

            optimizer.zero_grad()

            if scaler is not None:
                with torch.amp.autocast('cuda'):
                    outputs = model(images)
                    loss = criterion(outputs, labels)
                scaler.scale(loss).backward()
                scaler.unscale_(optimizer)
                torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
                scaler.step(optimizer)
                scaler.update()
            else:
                outputs = model(images)
                loss = criterion(outputs, labels)
                loss.backward()
                torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
                optimizer.step()

            running_loss += loss.item() * images.size(0)
            n_train += images.size(0)

        train_loss = running_loss / n_train
        train_losses.append(train_loss)

        # ── Validation phase ─────────────────────────────────────────────────
        model.eval()
        val_running_loss = 0.0
        n_val = 0
        all_preds, all_labels = [], []

        with torch.no_grad():
            for images, labels in val_loader:
                images = images.to(device, non_blocking=True)
                labels = labels.to(device, non_blocking=True)

                if scaler is not None:
                    with torch.amp.autocast('cuda'):
                        outputs = model(images)
                        loss = criterion(outputs, labels)
                else:
                    outputs = model(images)
                    loss = criterion(outputs, labels)

                val_running_loss += loss.item() * images.size(0)
                n_val += images.size(0)

                preds = outputs.argmax(dim=1)
                all_preds.extend(preds.cpu().numpy())
                all_labels.extend(labels.cpu().numpy())

        val_loss = val_running_loss / n_val
        val_losses.append(val_loss)

        val_f1_macro = f1_score(all_labels, all_preds, average='macro', zero_division=0)
        val_f1_weighted = f1_score(all_labels, all_preds, average='weighted', zero_division=0)
        val_f1s.append(val_f1_macro)
        val_weighted_f1s.append(val_f1_weighted)

        current_lr = scheduler.get_last_lr()[0] if hasattr(scheduler, 'get_last_lr') else optimizer.param_groups[0]['lr']
        print(
            f"[{model_name}] Epoch {epoch:3d}/{num_epochs} | "
            f"train loss: {train_loss:.4f} | val loss: {val_loss:.4f} | "
            f"macro F1: {val_f1_macro:.4f} | weighted F1: {val_f1_weighted:.4f} | lr: {current_lr:.2e}"
        )

        scheduler.step()

        # ── Early stopping (monitored on macro F1) ───────────────────────────
        if val_f1_macro > best_val_f1:
            best_val_f1 = val_f1_macro
            best_state = copy.deepcopy(model.state_dict())
            patience_counter = 0
        else:
            patience_counter += 1
            if patience_counter >= patience:
                print(f"  Early stopping at epoch {epoch} (no improvement for {patience} epochs).")
                break

    train_time = time.time() - start_time
    epochs_trained = len(train_losses)

    print(f"  Training complete: {epochs_trained} epochs, {train_time:.1f}s, best val macro F1: {best_val_f1:.4f}")

    return {
        'best_model_state': best_state,
        'train_losses': train_losses,
        'val_losses': val_losses,
        'val_f1s': val_f1s,           # macro F1 per epoch (used for early stopping)
        'val_weighted_f1s': val_weighted_f1s,
        'train_time': train_time,
        'epochs_trained': epochs_trained,
        'best_val_f1': best_val_f1,   # best macro F1
    }