File size: 12,558 Bytes
6cc8ae1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Unified Trainer class that orchestrates the full training pipeline.
Takes model, dataloaders, config → trains, evaluates, saves artifacts.
"""

import sys
import time
from pathlib import Path
from typing import Optional

import torch
import torch.nn as nn

PROJECT_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(PROJECT_ROOT))

from ml.src.training.engine import train_one_epoch, validate, measure_inference_latency
from ml.src.training.losses import create_loss
from ml.src.training.optimizer_factory import create_optimizer
from ml.src.training.scheduler_factory import create_scheduler
from ml.src.training.callbacks import EarlyStopping, ModelCheckpoint, MetricLogger
from ml.src.evaluation.metrics import compute_metrics
from ml.src.evaluation.plots import (
    plot_training_curves,
    plot_confusion_matrix,
    plot_per_class_f1,
)
from ml.src.utils.io import save_json, ensure_dirs
from ml.src.utils.seed import set_seed


class Trainer:
    """
    Unified training orchestrator.

    Usage:
        trainer = Trainer(model, config, dataloaders, class_names, device)
        trainer.train()
        trainer.evaluate()
        trainer.save_artifacts()
    """

    def __init__(
        self,
        model: nn.Module,
        config: dict,
        dataloaders: dict,
        class_names: list[str],
        device: torch.device,
        model_name: Optional[str] = None,
    ):
        self.model = model.to(device)
        self.config = config
        self.dataloaders = dataloaders
        self.class_names = class_names
        self.device = device
        self.model_name = model_name or config.get('model', {}).get('name', 'model')

        # Training config
        train_cfg = config.get('training', {})
        self.num_epochs = train_cfg.get('num_epochs', 30)
        self.gradient_clip = train_cfg.get('gradient_clip_max_norm', 1.0)

        # Setup artifact directories
        artifacts_cfg = config.get('artifacts', {})
        self.checkpoint_dir = PROJECT_ROOT / artifacts_cfg.get('checkpoints_dir', 'ml/artifacts/checkpoints')
        self.figures_dir = PROJECT_ROOT / artifacts_cfg.get('figures_dir', 'ml/artifacts/figures')
        self.logs_dir = PROJECT_ROOT / artifacts_cfg.get('logs_dir', 'ml/artifacts/logs')
        self.reports_dir = PROJECT_ROOT / artifacts_cfg.get('reports_dir', 'ml/artifacts/reports')
        ensure_dirs(self.checkpoint_dir, self.figures_dir, self.logs_dir, self.reports_dir)

        # Create loss
        label_smoothing = train_cfg.get('label_smoothing', 0.0)
        self.criterion = create_loss(label_smoothing=label_smoothing)

        # Create optimizer
        self.optimizer = create_optimizer(
            self.model,
            optimizer_name=train_cfg.get('optimizer', 'adam'),
            learning_rate=train_cfg.get('learning_rate', 0.001),
            weight_decay=train_cfg.get('weight_decay', 0.0001),
        )

        # Create scheduler
        self.scheduler = create_scheduler(
            self.optimizer,
            scheduler_name=train_cfg.get('scheduler', 'cosine'),
            num_epochs=self.num_epochs,
            step_size=train_cfg.get('scheduler_step_size', 10),
            gamma=train_cfg.get('scheduler_gamma', 0.1),
            warmup_epochs=train_cfg.get('warmup_epochs', 0),
        )

        # Callbacks
        self.early_stopping = EarlyStopping(
            patience=train_cfg.get('early_stopping_patience', 7),
            mode='min',
        )
        self.checkpoint = ModelCheckpoint(
            save_dir=self.checkpoint_dir,
            model_name=self.model_name,
            mode='min',
        )
        self.logger = MetricLogger(
            log_dir=self.logs_dir,
            model_name=self.model_name,
        )

        # Results storage
        self.test_results = None
        self.training_summary = None

    def train(self) -> dict:
        """Run the full training loop."""
        print(f"\n{'='*60}")
        print(f"Training: {self.model_name}")
        print(f"Device: {self.device}")
        print(f"Epochs: {self.num_epochs}")
        print(f"{'='*60}\n")

        set_seed(self.config.get('data', {}).get('seed', 42))

        for epoch in range(1, self.num_epochs + 1):
            # Train
            train_metrics = train_one_epoch(
                self.model, self.dataloaders['train'], self.criterion,
                self.optimizer, self.device, self.gradient_clip,
            )

            # Validate
            val_metrics = validate(
                self.model, self.dataloaders['val'], self.criterion, self.device,
            )

            # Get current LR
            current_lr = self.optimizer.param_groups[0]['lr']

            # Log
            self.logger.log_epoch(epoch, train_metrics, val_metrics, current_lr)

            # Checkpoint
            self.checkpoint(
                val_metrics['loss'], self.model, self.optimizer, epoch,
                extra_info={'config': self.config, 'class_names': self.class_names},
            )

            # Step scheduler
            if self.scheduler is not None:
                if isinstance(self.scheduler, torch.optim.lr_scheduler.ReduceLROnPlateau):
                    self.scheduler.step(val_metrics['loss'])
                else:
                    self.scheduler.step()

            # Early stopping
            if self.early_stopping(val_metrics['loss']):
                print(f"\nEarly stopping at epoch {epoch}")
                break

        # Save training history
        self.logger.save()
        self.training_summary = self.logger.get_summary()

        print(f"\nTraining complete! Best val loss: {self.checkpoint.best_value:.4f}")
        return self.training_summary

    def evaluate(self, split: str = 'test') -> dict:
        """
        Evaluate the best model on test set.
        Loads the best checkpoint and runs full evaluation.
        """
        print(f"\n{'='*60}")
        print(f"Evaluating: {self.model_name} on {split}")
        print(f"{'='*60}\n")

        # Load best checkpoint
        if self.checkpoint.best_path and self.checkpoint.best_path.exists():
            ckpt = torch.load(self.checkpoint.best_path, map_location=self.device, weights_only=False)
            self.model.load_state_dict(ckpt['model_state_dict'])
            print(f"Loaded best checkpoint from epoch {ckpt.get('epoch', '?')}")

        # Run evaluation
        results = validate(
            self.model, self.dataloaders[split], self.criterion, self.device,
        )

        # Compute detailed metrics
        metrics = compute_metrics(
            results['labels'], results['predictions'], self.class_names,
        )

        # Measure inference latency
        latency = measure_inference_latency(
            self.model, self.device,
            img_size=self.config.get('image', {}).get('size', 224),
        )

        # Calculate model size
        model_size_mb = sum(
            p.numel() * p.element_size() for p in self.model.parameters()
        ) / (1024 * 1024)

        self.test_results = {
            'model_name': self.model_name,
            'split': split,
            'loss': results['loss'],
            'metrics': metrics,
            'latency': latency,
            'model_size_mb': round(model_size_mb, 2),
            'num_parameters': sum(p.numel() for p in self.model.parameters()),
        }

        # Print summary
        print(f"\n{split.upper()} Results:")
        print(f"  Loss:          {results['loss']:.4f}")
        print(f"  Accuracy:      {metrics['accuracy']:.4f}")
        print(f"  Macro F1:      {metrics['macro_f1']:.4f}")
        print(f"  Macro Prec:    {metrics['macro_precision']:.4f}")
        print(f"  Macro Recall:  {metrics['macro_recall']:.4f}")
        print(f"  Avg Latency:   {latency['avg_ms']:.2f} ms")
        print(f"  Model Size:    {model_size_mb:.2f} MB")

        return self.test_results

    def save_artifacts(self) -> None:
        """Save all evaluation artifacts: plots, reports."""
        print(f"\nSaving artifacts for {self.model_name}...")

        model_fig_dir = self.figures_dir / self.model_name
        model_fig_dir.mkdir(parents=True, exist_ok=True)

        # Training curves
        plot_training_curves(
            self.logger.history,
            save_path=model_fig_dir / 'training_curves.png',
            title=f'{self.model_name} - Training Curves',
        )

        # Confusion matrix (from test results)
        if self.test_results:
            labels = self.test_results.get('_raw_labels', None)
            preds = self.test_results.get('_raw_predictions', None)

            # Load from last validation run if needed
            if labels is None:
                val_results = validate(
                    self.model, self.dataloaders.get('test', self.dataloaders['val']),
                    self.criterion, self.device,
                )
                labels = val_results['labels']
                preds = val_results['predictions']

            plot_confusion_matrix(
                labels, preds, self.class_names,
                save_path=model_fig_dir / 'confusion_matrix.png',
                title=f'{self.model_name} - Confusion Matrix',
            )

            # Per-class F1
            if self.test_results and 'metrics' in self.test_results:
                per_class = self.test_results['metrics'].get('per_class', {})
                if per_class:
                    plot_per_class_f1(
                        per_class, self.class_names,
                        save_path=model_fig_dir / 'per_class_f1.png',
                        title=f'{self.model_name} - Per-Class F1 Score',
                    )

        # Save detailed report JSON
        if self.test_results:
            report = {**self.test_results}
            report.pop('_raw_labels', None)
            report.pop('_raw_predictions', None)
            save_json(report, self.reports_dir / f'{self.model_name}_report.json')

        # Save training summary
        if self.training_summary:
            save_json(
                self.training_summary,
                self.reports_dir / f'{self.model_name}_training_summary.json',
            )

        print(f"  Artifacts saved to {self.figures_dir / self.model_name}")

    def train_with_phase_switch(
        self,
        phase1_epochs: int = 10,
        phase2_lr: float = 0.0001,
        unfreeze_layers: list[str] = None,
    ) -> dict:
        """
        Two-phase training for transfer learning models.
        Phase 1: Frozen backbone, train head only.
        Phase 2: Unfreeze specified layers, use lower LR.
        """
        print(f"\n{'='*60}")
        print(f"Phase 1: Frozen backbone training ({phase1_epochs} epochs)")
        print(f"{'='*60}\n")

        # Phase 1
        original_epochs = self.num_epochs
        self.num_epochs = phase1_epochs
        self.train()

        # Phase 2: Unfreeze
        remaining_epochs = original_epochs - phase1_epochs
        if remaining_epochs > 0 and hasattr(self.model, 'unfreeze_layers'):
            print(f"\n{'='*60}")
            print(f"Phase 2: Fine-tuning ({remaining_epochs} epochs)")
            print(f"{'='*60}\n")

            self.model.unfreeze_layers(unfreeze_layers)

            # Use param groups if available
            if hasattr(self.model, 'get_param_groups'):
                base_lr = self.config.get('training', {}).get('learning_rate', 0.001)
                param_groups = self.model.get_param_groups(base_lr, phase2_lr)
                self.optimizer = create_optimizer(
                    param_groups,
                    optimizer_name=self.config.get('training', {}).get('optimizer', 'adamw'),
                    learning_rate=base_lr,
                    weight_decay=self.config.get('training', {}).get('weight_decay', 0.0001),
                )
            else:
                for pg in self.optimizer.param_groups:
                    pg['lr'] = phase2_lr

            # Reset scheduler and early stopping
            self.scheduler = create_scheduler(
                self.optimizer,
                scheduler_name=self.config.get('training', {}).get('scheduler', 'cosine'),
                num_epochs=remaining_epochs,
            )
            self.early_stopping = EarlyStopping(
                patience=self.config.get('training', {}).get('early_stopping_patience', 7),
            )

            self.num_epochs = remaining_epochs
            self.train()

        self.num_epochs = original_epochs
        return self.logger.get_summary()