Zorrojurro commited on
Commit
156f8c8
·
verified ·
1 Parent(s): 0587d11

Upload src/training/train.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/training/train.py +307 -0
src/training/train.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training loop for the Thermal Pattern Analysis pipeline.
3
+
4
+ Supports:
5
+ - AdamW optimiser with cosine annealing scheduler
6
+ - Early stopping
7
+ - TensorBoard logging
8
+ - Checkpoint saving / resuming
9
+ - Mixed-precision training (if GPU available)
10
+ """
11
+
12
+ import os
13
+ import time
14
+ import torch
15
+ import torch.nn as nn
16
+ from torch.utils.data import DataLoader
17
+ from torch.optim import AdamW
18
+ from torch.optim.lr_scheduler import CosineAnnealingLR
19
+ try:
20
+ from torch.utils.tensorboard import SummaryWriter
21
+ HAS_TENSORBOARD = True
22
+ except ImportError:
23
+ HAS_TENSORBOARD = False
24
+ SummaryWriter = None
25
+
26
+ from tqdm import tqdm
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from src.models.anomaly_detector import ThermalPatternPipeline
31
+ from src.training.losses import CombinedLoss
32
+ from src.evaluation.metrics import MetricsCalculator
33
+
34
+
35
+ class EarlyStopping:
36
+ """Stop training when validation loss stops improving."""
37
+
38
+ def __init__(self, patience: int = 10, min_delta: float = 0.001):
39
+ self.patience = patience
40
+ self.min_delta = min_delta
41
+ self.counter = 0
42
+ self.best_loss = float("inf")
43
+ self.should_stop = False
44
+
45
+ def __call__(self, val_loss: float) -> bool:
46
+ if val_loss < self.best_loss - self.min_delta:
47
+ self.best_loss = val_loss
48
+ self.counter = 0
49
+ else:
50
+ self.counter += 1
51
+ if self.counter >= self.patience:
52
+ self.should_stop = True
53
+ return self.should_stop
54
+
55
+
56
+ class Trainer:
57
+ """
58
+ Full training manager for the ThermalPatternPipeline.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ model: ThermalPatternPipeline,
64
+ train_loader: DataLoader,
65
+ val_loader: DataLoader,
66
+ config,
67
+ device: torch.device,
68
+ ):
69
+ self.model = model.to(device)
70
+ self.train_loader = train_loader
71
+ self.val_loader = val_loader
72
+ self.config = config
73
+ self.device = device
74
+
75
+ # Loss
76
+ self.criterion = CombinedLoss.from_config(config)
77
+
78
+ # Classification head (simple linear head for binary)
79
+ self.classifier = nn.Linear(
80
+ config.model.feature_extractor.embedding_dim, 2
81
+ ).to(device)
82
+
83
+ # Optimiser: model params + classifier
84
+ all_params = list(model.parameters()) + list(self.classifier.parameters())
85
+ self.optimizer = AdamW(
86
+ all_params,
87
+ lr=config.training.learning_rate,
88
+ weight_decay=config.training.weight_decay,
89
+ )
90
+
91
+ # Scheduler
92
+ self.scheduler = CosineAnnealingLR(
93
+ self.optimizer,
94
+ T_max=config.training.epochs,
95
+ )
96
+
97
+ # Early stopping
98
+ es_cfg = config.training.early_stopping
99
+ self.early_stopping = EarlyStopping(
100
+ patience=es_cfg.patience,
101
+ min_delta=es_cfg.min_delta,
102
+ )
103
+
104
+ # Logging
105
+ log_dir = config.paths.get("logs", "logs")
106
+ if HAS_TENSORBOARD:
107
+ self.writer = SummaryWriter(log_dir=log_dir)
108
+ else:
109
+ self.writer = None
110
+ print(" ⚠ TensorBoard not available — logging to console only")
111
+ self.metrics = MetricsCalculator()
112
+
113
+ # Checkpoint dir
114
+ self.ckpt_dir = Path(config.paths.get("checkpoints", "checkpoints"))
115
+ self.ckpt_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ # Mixed-precision scaler
118
+ self.scaler = torch.amp.GradScaler("cuda") if device.type == "cuda" else None
119
+
120
+ def train_epoch(self, epoch: int) -> dict:
121
+ """Run one training epoch."""
122
+ self.model.train()
123
+ self.classifier.train()
124
+
125
+ epoch_loss = 0.0
126
+ all_preds, all_labels = [], []
127
+
128
+ pbar = tqdm(self.train_loader, desc=f"Epoch {epoch+1} [Train]")
129
+ for sequences, labels in pbar:
130
+ sequences = sequences.to(self.device)
131
+ labels = labels.to(self.device)
132
+
133
+ self.optimizer.zero_grad()
134
+
135
+ # Forward
136
+ if self.scaler is not None:
137
+ with torch.amp.autocast("cuda"):
138
+ results = self.model(sequences)
139
+ logits = self.classifier(results["encoding"])
140
+ loss_dict = self.criterion(
141
+ results["encoding"], labels, logits
142
+ )
143
+ loss = loss_dict["total_loss"]
144
+
145
+ self.scaler.scale(loss).backward()
146
+ self.scaler.unscale_(self.optimizer)
147
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
148
+ self.scaler.step(self.optimizer)
149
+ self.scaler.update()
150
+ else:
151
+ results = self.model(sequences)
152
+ logits = self.classifier(results["encoding"])
153
+ loss_dict = self.criterion(
154
+ results["encoding"], labels, logits
155
+ )
156
+ loss = loss_dict["total_loss"]
157
+
158
+ loss.backward()
159
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
160
+ self.optimizer.step()
161
+
162
+ # Update baseline with normal samples
163
+ normal_mask = labels == 0
164
+ if normal_mask.any():
165
+ self.model.anomaly_detector.update_baseline(
166
+ results["encoding"][normal_mask].detach()
167
+ )
168
+
169
+ # Track metrics
170
+ epoch_loss += loss.item()
171
+ preds = logits.argmax(dim=1)
172
+ all_preds.extend(preds.cpu().numpy())
173
+ all_labels.extend(labels.cpu().numpy())
174
+
175
+ pbar.set_postfix(loss=f"{loss.item():.4f}")
176
+
177
+ avg_loss = epoch_loss / max(len(self.train_loader), 1)
178
+ metrics = self.metrics.compute_all(all_labels, all_preds)
179
+ metrics["loss"] = avg_loss
180
+ return metrics
181
+
182
+ @torch.no_grad()
183
+ def validate_epoch(self, epoch: int) -> dict:
184
+ """Run one validation epoch."""
185
+ self.model.eval()
186
+ self.classifier.eval()
187
+
188
+ epoch_loss = 0.0
189
+ all_preds, all_labels, all_scores = [], [], []
190
+
191
+ for sequences, labels in tqdm(
192
+ self.val_loader, desc=f"Epoch {epoch+1} [Val]"
193
+ ):
194
+ sequences = sequences.to(self.device)
195
+ labels = labels.to(self.device)
196
+
197
+ results = self.model(sequences)
198
+ logits = self.classifier(results["encoding"])
199
+ loss_dict = self.criterion(results["encoding"], labels, logits)
200
+
201
+ epoch_loss += loss_dict["total_loss"].item()
202
+ preds = logits.argmax(dim=1)
203
+ all_preds.extend(preds.cpu().numpy())
204
+ all_labels.extend(labels.cpu().numpy())
205
+ all_scores.extend(
206
+ torch.softmax(logits, dim=1)[:, 1].cpu().numpy()
207
+ )
208
+
209
+ avg_loss = epoch_loss / max(len(self.val_loader), 1)
210
+ metrics = self.metrics.compute_all(all_labels, all_preds, all_scores)
211
+ metrics["loss"] = avg_loss
212
+ return metrics
213
+
214
+ def train(self) -> dict:
215
+ """
216
+ Full training loop with early stopping, checkpointing,
217
+ and TensorBoard logging.
218
+
219
+ Returns:
220
+ Best validation metrics dict.
221
+ """
222
+ epochs = self.config.training.epochs
223
+ best_val_loss = float("inf")
224
+ best_metrics = {}
225
+
226
+ print(f"\n{'='*60}")
227
+ print(f" Training — {epochs} epochs on {self.device}")
228
+ print(f"{'='*60}\n")
229
+
230
+ for epoch in range(epochs):
231
+ t0 = time.time()
232
+
233
+ # Train
234
+ train_metrics = self.train_epoch(epoch)
235
+ # Validate
236
+ val_metrics = self.validate_epoch(epoch)
237
+ # Step scheduler
238
+ self.scheduler.step()
239
+
240
+ elapsed = time.time() - t0
241
+
242
+ # TensorBoard
243
+ if self.writer is not None:
244
+ for key, val in train_metrics.items():
245
+ self.writer.add_scalar(f"train/{key}", val, epoch)
246
+ for key, val in val_metrics.items():
247
+ self.writer.add_scalar(f"val/{key}", val, epoch)
248
+ self.writer.add_scalar(
249
+ "lr", self.optimizer.param_groups[0]["lr"], epoch
250
+ )
251
+
252
+ # Console summary
253
+ print(
254
+ f"Epoch {epoch+1:3d}/{epochs} | "
255
+ f"Train loss: {train_metrics['loss']:.4f} | "
256
+ f"Val loss: {val_metrics['loss']:.4f} | "
257
+ f"Val acc: {val_metrics.get('accuracy', 0):.4f} | "
258
+ f"Time: {elapsed:.1f}s"
259
+ )
260
+
261
+ # Checkpoint best model
262
+ if val_metrics["loss"] < best_val_loss:
263
+ best_val_loss = val_metrics["loss"]
264
+ best_metrics = val_metrics
265
+ self._save_checkpoint(epoch, val_metrics, is_best=True)
266
+
267
+ # Early stopping
268
+ if self.early_stopping(val_metrics["loss"]):
269
+ print(f"\n⏹ Early stopping at epoch {epoch+1}")
270
+ break
271
+
272
+ if self.writer is not None:
273
+ self.writer.close()
274
+ print(f"\n{'='*60}")
275
+ print(f" Training complete — Best val loss: {best_val_loss:.4f}")
276
+ print(f"{'='*60}\n")
277
+
278
+ return best_metrics
279
+
280
+ def _save_checkpoint(
281
+ self, epoch: int, metrics: dict, is_best: bool = False
282
+ ):
283
+ """Save model checkpoint."""
284
+ state = {
285
+ "epoch": epoch,
286
+ "model_state_dict": self.model.state_dict(),
287
+ "classifier_state_dict": self.classifier.state_dict(),
288
+ "optimizer_state_dict": self.optimizer.state_dict(),
289
+ "scheduler_state_dict": self.scheduler.state_dict(),
290
+ "metrics": metrics,
291
+ }
292
+ path = self.ckpt_dir / f"checkpoint_epoch_{epoch+1}.pt"
293
+ torch.save(state, path)
294
+
295
+ if is_best:
296
+ best_path = self.ckpt_dir / "best_model.pt"
297
+ torch.save(state, best_path)
298
+
299
+ def load_checkpoint(self, checkpoint_path: str):
300
+ """Resume training from a saved checkpoint."""
301
+ ckpt = torch.load(checkpoint_path, map_location=self.device)
302
+ self.model.load_state_dict(ckpt["model_state_dict"])
303
+ self.classifier.load_state_dict(ckpt["classifier_state_dict"])
304
+ self.optimizer.load_state_dict(ckpt["optimizer_state_dict"])
305
+ self.scheduler.load_state_dict(ckpt["scheduler_state_dict"])
306
+ print(f"✓ Resumed from epoch {ckpt['epoch'] + 1}")
307
+ return ckpt["epoch"] + 1