bertuner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bertuner/BERTuner.py ADDED
@@ -0,0 +1,818 @@
1
+ import os
2
+ import random
3
+ import json
4
+ import shutil
5
+ import numpy as np
6
+ import pandas as pd
7
+ import torch
8
+ import torch.nn.functional as F
9
+ import mlflow
10
+ import optuna
11
+ from datasets import Dataset
12
+ from optuna.samplers import TPESampler
13
+ from transformers import (
14
+ AutoTokenizer,
15
+ AutoConfig,
16
+ AutoModelForSequenceClassification,
17
+ TrainingArguments,
18
+ DataCollatorWithPadding,
19
+ EarlyStoppingCallback,
20
+ set_seed,
21
+ )
22
+ from sklearn.metrics import (
23
+ precision_score,
24
+ recall_score,
25
+ f1_score,
26
+ average_precision_score,
27
+ roc_auc_score,
28
+ accuracy_score,
29
+ )
30
+ from sklearn.preprocessing import label_binarize
31
+ from mlflow.tracking import MlflowClient
32
+
33
+ from bertuner.CustomTrainer import CustomTrainer
34
+ from bertuner.TensorBoardCallback import (
35
+ TensorBoardSyncCallback,
36
+ CleanupCheckpointsCallback,
37
+ )
38
+ from bertuner.utils import (
39
+ split_group_stratified,
40
+ check_group_leakage,
41
+ train_val_test_split,
42
+ )
43
+ from bertuner.constants import (
44
+ DEFAULT_MODEL_CHOICES,
45
+ DEFAULT_SEARCH_SPACE,
46
+ DEFAULT_SEARCH_SPACE_SINGLELABEL,
47
+ DEFAULT_SEARCH_SPACE_MULTILABEL,
48
+ MODEL_DROPOUT_ATTRS,
49
+ SEED,
50
+ )
51
+
52
+
53
+ class BERTuneClassifier:
54
+ """
55
+ Handles hyperparameter optimization and final training for BERT-based classification models.
56
+
57
+ Supports both single-label (binary/multiclass) and multi-label classification.
58
+
59
+ Single-label: target_cols = ['label'] → CrossEntropyLoss, argmax predictions
60
+ Multi-label: target_cols = ['l1', 'l2', ...] → BCEWithLogitsLoss, sigmoid + threshold
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ models_dir: str,
66
+ text_feature: str,
67
+ target_cols: list[str],
68
+ data_path: str = None,
69
+ dataframe: pd.DataFrame = None,
70
+ num_labels: int = None,
71
+ group_key: str = None,
72
+ seed: int = SEED,
73
+ mlflow_port: int = 9090,
74
+ mlflow_tracking_uri: str = None,
75
+ log_level: str = "best",
76
+ max_length: int = 512,
77
+ gradient_checkpointing: bool = None,
78
+ ):
79
+ if data_path is None and dataframe is None:
80
+ raise ValueError("Provide either data_path (CSV) or dataframe, not neither.")
81
+ if data_path is not None and dataframe is not None:
82
+ raise ValueError("Provide either data_path (CSV) or dataframe, not both.")
83
+
84
+ self.data_path = data_path
85
+ self.models_dir = models_dir
86
+ self.text_feature = text_feature
87
+ self.target_cols = target_cols
88
+ self.seed = seed
89
+ self.df = pd.read_csv(data_path) if data_path is not None else dataframe.copy()
90
+ if num_labels is not None:
91
+ self.num_labels = num_labels
92
+ elif self.is_multilabel:
93
+ self.num_labels = len(target_cols)
94
+ else:
95
+ # Single-label: model head needs one logit per class, not per column
96
+ self.num_labels = int(self.df[target_cols[0]].nunique())
97
+ self.group_key = group_key
98
+ if mlflow_tracking_uri is not None:
99
+ # File-based tracking: logs to a local directory, no server required.
100
+ # Accepts a plain path (converted to file: URI) or any mlflow URI.
101
+ if "://" not in mlflow_tracking_uri:
102
+ mlflow_tracking_uri = f"file:{os.path.abspath(mlflow_tracking_uri)}"
103
+ self.mlflow_uri = mlflow_tracking_uri
104
+ else:
105
+ self.mlflow_uri = f"http://127.0.0.1:{mlflow_port}"
106
+ self.log_level = log_level # 'best' or 'verbose'
107
+ self.best_params = {}
108
+ # Single-label: scalar float. Multi-label: array of per-label floats.
109
+ self.best_threshold = 0.5
110
+ self.max_length = max_length
111
+ # None → auto: enabled when the effective sequence length is long enough
112
+ # that activation memory dominates (see _use_gradient_checkpointing).
113
+ self.gradient_checkpointing = gradient_checkpointing
114
+ self._setup_seed()
115
+ if self.mlflow_uri.startswith("http"):
116
+ print(f"Please make sure an mlflow instance is running on: {self.mlflow_uri}")
117
+ else:
118
+ print(f"Logging mlflow runs locally to: {self.mlflow_uri} (no server needed)")
119
+
120
+ if self.is_multilabel and self.group_key:
121
+ print(
122
+ "[WARNING] group_key is set but multi-label mode is active. "
123
+ "StratifiedGroupKFold does not support multi-label targets — "
124
+ "falling back to standard stratify-free train/val/test split."
125
+ )
126
+
127
+ # ------------------------------------------------------------------
128
+ # Properties
129
+ # ------------------------------------------------------------------
130
+
131
+ @property
132
+ def is_multilabel(self) -> bool:
133
+ """True when there are multiple target columns (multi-label classification)."""
134
+ return len(self.target_cols) > 1
135
+
136
+ @property
137
+ def is_binary(self) -> bool:
138
+ """True for single-label two-class classification (thresholded predictions)."""
139
+ return not self.is_multilabel and self.num_labels == 2
140
+
141
+ # ------------------------------------------------------------------
142
+ # Setup helpers
143
+ # ------------------------------------------------------------------
144
+
145
+ def initialize_model_choices(self, model_choices: dict = DEFAULT_MODEL_CHOICES):
146
+ """Initializes the different model choices."""
147
+ self.MODEL_CHOICES = model_choices
148
+ print("Model choices set")
149
+
150
+ def initialize_search_space(self, search_space: dict = None):
151
+ """Initializes the search space, auto-selecting based on classification mode if not provided."""
152
+ if search_space is not None:
153
+ self.search_space = search_space
154
+ elif self.is_multilabel:
155
+ self.search_space = DEFAULT_SEARCH_SPACE_MULTILABEL
156
+ else:
157
+ self.search_space = DEFAULT_SEARCH_SPACE_SINGLELABEL
158
+ print(f"Search space set ({'multi-label' if self.is_multilabel else 'single-label'} mode)")
159
+
160
+ def _setup_seed(self):
161
+ """Sets reproducible seeds."""
162
+ random.seed(self.seed)
163
+ np.random.seed(self.seed)
164
+ torch.manual_seed(self.seed)
165
+ if torch.cuda.is_available():
166
+ torch.cuda.manual_seed_all(self.seed)
167
+ set_seed(self.seed)
168
+ os.environ["PYTHONHASHSEED"] = str(self.seed)
169
+ torch.backends.cudnn.deterministic = True
170
+ torch.backends.cudnn.benchmark = False
171
+
172
+ def _get_tokenizer(self, model_path: str) -> AutoTokenizer:
173
+ """Get tokenizer. DeBERTa fast tokenizer has compatibility issues."""
174
+ use_fast = "deberta" not in model_path.lower()
175
+ return AutoTokenizer.from_pretrained(model_path, use_fast=use_fast)
176
+
177
+ def _effective_max_length(self, tokenizer, model_path: str) -> int:
178
+ """Clamps the requested max_length to what the model can actually encode."""
179
+ capacity = getattr(tokenizer, "model_max_length", None)
180
+ # Some tokenizers report a huge sentinel instead of the real limit
181
+ if capacity is None or capacity > 100_000:
182
+ capacity = getattr(
183
+ AutoConfig.from_pretrained(model_path),
184
+ "max_position_embeddings",
185
+ self.max_length,
186
+ )
187
+ if self.max_length > capacity:
188
+ print(
189
+ f"[WARNING] max_length={self.max_length} exceeds the context window "
190
+ f"of {model_path} ({capacity}); using {capacity} for this model."
191
+ )
192
+ return min(self.max_length, capacity)
193
+
194
+ def _precision_flags(self) -> dict:
195
+ """bf16 where the GPU supports it (required for ModernBERT), else fp16."""
196
+ bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
197
+ return {"bf16": bf16, "fp16": torch.cuda.is_available() and not bf16}
198
+
199
+ def _use_gradient_checkpointing(self, max_length: int) -> bool:
200
+ """Auto-enables checkpointing for long sequences unless explicitly overridden."""
201
+ if self.gradient_checkpointing is not None:
202
+ return self.gradient_checkpointing
203
+ return max_length > 1024
204
+
205
+ # ------------------------------------------------------------------
206
+ # Metrics
207
+ # ------------------------------------------------------------------
208
+
209
+ def _compute_metrics(self, eval_pred):
210
+ """
211
+ Computes metrics for both single-label and multi-label modes.
212
+
213
+ Single-label: accuracy, f1, precision, recall, specificity, AP, AUC-ROC
214
+ Multi-label: micro/macro/sample-averaged variants of the above
215
+ """
216
+ predictions, labels = eval_pred
217
+
218
+ if self.is_multilabel:
219
+ # predictions shape: (N, num_labels) — raw logits
220
+ probs = torch.sigmoid(torch.from_numpy(predictions)).numpy()
221
+ # Use a fixed 0.5 threshold during training eval (optimised later on val set)
222
+ preds = (probs >= 0.5).astype(int)
223
+
224
+ metrics = {
225
+ "accuracy": accuracy_score(labels, preds),
226
+ "precision_micro": precision_score(
227
+ labels, preds, average="micro", zero_division=0
228
+ ),
229
+ "recall_micro": recall_score(labels, preds, average="micro", zero_division=0),
230
+ "f1_micro": f1_score(labels, preds, average="micro", zero_division=0),
231
+ "f1_macro": f1_score(labels, preds, average="macro", zero_division=0),
232
+ "f1_samples": f1_score(labels, preds, average="samples", zero_division=0),
233
+ }
234
+ # Average-precision and AUC per label, then macro-average
235
+
236
+ aps = []
237
+ aucs = []
238
+
239
+ for i in range(labels.shape[1]):
240
+ y_i = labels[:, i]
241
+ p_i = probs[:, i]
242
+
243
+ if len(np.unique(y_i)) > 1:
244
+ aps.append(average_precision_score(y_i, p_i))
245
+ aucs.append(roc_auc_score(y_i, p_i))
246
+
247
+ metrics["avg_precision"] = float(np.mean(aps)) if aps else 0.5
248
+ metrics["auc_roc"] = float(np.mean(aucs)) if aucs else 0.5
249
+ else:
250
+ # Single-label: predictions shape (N, num_classes) or (N,)
251
+ if len(predictions.shape) > 1:
252
+ probs = F.softmax(torch.from_numpy(predictions), dim=-1).numpy()
253
+ preds = np.argmax(predictions, axis=1)
254
+ else:
255
+ preds = predictions
256
+ probs = None
257
+
258
+ if self.is_binary:
259
+ metrics = {
260
+ "accuracy": accuracy_score(labels, preds),
261
+ "precision": precision_score(labels, preds, zero_division=0),
262
+ "recall": recall_score(labels, preds, zero_division=0),
263
+ "f1": f1_score(labels, preds, zero_division=0),
264
+ "specificity": recall_score(labels, preds, pos_label=0, zero_division=0),
265
+ }
266
+ if probs is not None:
267
+ metrics["avg_precision"] = average_precision_score(labels, probs[:, 1])
268
+ metrics["auc_roc"] = (
269
+ roc_auc_score(labels, probs[:, 1]) if len(np.unique(labels)) > 1 else 0.5
270
+ )
271
+ else:
272
+ # Multiclass: macro-averaged metrics, one-vs-rest ranking metrics
273
+ metrics = {
274
+ "accuracy": accuracy_score(labels, preds),
275
+ "precision": precision_score(labels, preds, average="macro", zero_division=0),
276
+ "recall": recall_score(labels, preds, average="macro", zero_division=0),
277
+ "f1": f1_score(labels, preds, average="macro", zero_division=0),
278
+ }
279
+ # Ranking metrics need every class present in the eval split
280
+ if probs is not None and len(np.unique(labels)) == probs.shape[1]:
281
+ y_bin = label_binarize(labels, classes=np.arange(probs.shape[1]))
282
+ metrics["avg_precision"] = average_precision_score(
283
+ y_bin, probs, average="macro"
284
+ )
285
+ metrics["auc_roc"] = roc_auc_score(
286
+ labels, probs, multi_class="ovr", average="macro"
287
+ )
288
+ elif probs is not None:
289
+ metrics["avg_precision"] = 0.5
290
+ metrics["auc_roc"] = 0.5
291
+
292
+ return metrics
293
+
294
+ # ------------------------------------------------------------------
295
+ # Data preparation
296
+ # ------------------------------------------------------------------
297
+
298
+ def _prepare_datasets(self, tokenizer, group_key, max_length=512):
299
+ """
300
+ Splits, balances, and tokenizes data.
301
+
302
+ Splitting strategy
303
+ ------------------
304
+ Multi-label + group_key → standard split (group stratification not supported
305
+ for multi-label targets; warning shown in __init__)
306
+ Single-label + group_key → StratifiedGroupKFold split
307
+ No group_key → standard stratified split (single-label) or plain
308
+ random split (multi-label)
309
+ """
310
+ use_group_split = (
311
+ not self.is_multilabel and group_key is not None and group_key in self.df.columns
312
+ )
313
+
314
+ if use_group_split:
315
+ self.df[group_key] = self.df[group_key].astype(str).str.strip().str.casefold()
316
+ train, val, test = split_group_stratified(
317
+ self.df,
318
+ group_key,
319
+ self.target_cols[0], # single-label: one column
320
+ seed=self.seed,
321
+ test_size=0.15,
322
+ val_size=0.15,
323
+ )
324
+ check_group_leakage(train, val, test, group_key)
325
+ else:
326
+ # For multi-label we skip stratification on labels; for single-label
327
+ # train_val_test_split handles it internally when stratify_y=True.
328
+ stratify = not self.is_multilabel
329
+ X_train, X_val, X_test, y_train, y_val, y_test = train_val_test_split(
330
+ self.df[[self.text_feature]],
331
+ self.df[self.target_cols],
332
+ random_state=self.seed,
333
+ stratify_y=stratify,
334
+ test_size=0.15,
335
+ validation_size=0.15,
336
+ train_size=0.7,
337
+ )
338
+ train = pd.concat([X_train, y_train], axis=1)
339
+ val = pd.concat([X_val, y_val], axis=1)
340
+ test = pd.concat([X_test, y_test], axis=1)
341
+
342
+ def _tokenize(data):
343
+ data = data.copy()
344
+ ds = Dataset.from_pandas(data)
345
+ # No padding here — DataCollatorWithPadding pads each batch dynamically
346
+ ds = ds.map(
347
+ lambda x: tokenizer(
348
+ x[self.text_feature],
349
+ truncation=True,
350
+ max_length=max_length,
351
+ ),
352
+ batched=True,
353
+ )
354
+
355
+ if self.is_multilabel:
356
+ # Labels: float vector of length num_labels for BCEWithLogitsLoss
357
+ ds = ds.map(
358
+ lambda x: {
359
+ "labels": [
360
+ [float(x[col][i]) for col in self.target_cols]
361
+ for i in range(len(x[self.target_cols[0]]))
362
+ ]
363
+ },
364
+ batched=True,
365
+ )
366
+ else:
367
+ # Labels: single integer for CrossEntropyLoss
368
+ col = self.target_cols[0]
369
+ ds = ds.map(
370
+ lambda x: {"labels": [int(v) for v in x[col]]},
371
+ batched=True,
372
+ )
373
+
374
+ remove_cols = [self.text_feature] + self.target_cols
375
+ ds = ds.remove_columns([c for c in remove_cols if c in ds.column_names])
376
+ ds.set_format("torch")
377
+ return ds
378
+
379
+ return _tokenize(train), _tokenize(val), _tokenize(test)
380
+
381
+ # ------------------------------------------------------------------
382
+ # Class-weight helpers
383
+ # ------------------------------------------------------------------
384
+
385
+ def _compute_class_weights(self, train_ds) -> torch.Tensor:
386
+ """
387
+ Binary : returns shape (2,) — weight for [neg, pos] class.
388
+ Multiclass : returns shape (num_labels,) — balanced inverse-frequency weights.
389
+ Multi-label : returns shape (num_labels,) — pos_weight per label,
390
+ suitable for BCEWithLogitsLoss(pos_weight=...).
391
+ """
392
+ if self.is_multilabel:
393
+ # Stack all label vectors: shape (N, num_labels)
394
+ all_labels = torch.stack([item["labels"] for item in train_ds]).numpy()
395
+ pos_counts = all_labels.sum(axis=0).clip(min=1)
396
+ neg_counts = (len(all_labels) - all_labels.sum(axis=0)).clip(min=1)
397
+ pos_weight = neg_counts / pos_counts # shape (num_labels,)
398
+ return torch.tensor(pos_weight, dtype=torch.float)
399
+ elif self.is_binary:
400
+ labels = [item["labels"].item() for item in train_ds]
401
+ neg = labels.count(0)
402
+ pos = labels.count(1)
403
+ pos = max(pos, 1)
404
+ return torch.tensor([1.0, neg / pos], dtype=torch.float)
405
+ else:
406
+ labels = [item["labels"].item() for item in train_ds]
407
+ counts = np.bincount(labels, minlength=self.num_labels).clip(min=1)
408
+ weights = len(labels) / (self.num_labels * counts)
409
+ return torch.tensor(weights, dtype=torch.float)
410
+
411
+ # ------------------------------------------------------------------
412
+ # Hyperparameter suggestion
413
+ # ------------------------------------------------------------------
414
+
415
+ def _suggest_hyperparams(self, trial: optuna.Trial):
416
+ """Infers Optuna method based on search space value types."""
417
+ params = {}
418
+ for name, spec in self.search_space.items():
419
+ if isinstance(spec, list):
420
+ params[name] = trial.suggest_categorical(name, spec)
421
+ elif isinstance(spec, dict):
422
+ low, high = spec["low"], spec["high"]
423
+ if isinstance(low, int) and isinstance(high, int):
424
+ params[name] = trial.suggest_int(name, low, high, step=spec.get("step", 1))
425
+ else:
426
+ params[name] = trial.suggest_float(name, low, high, log=spec.get("log", False))
427
+ return params
428
+
429
+ # ------------------------------------------------------------------
430
+ # Model loading helper
431
+ # ------------------------------------------------------------------
432
+
433
+ def _load_model(self, model_path: str, dropout: float):
434
+ """
435
+ Loads a model with the correct config for single-label vs multi-label,
436
+ applying dropout robustly across architectures.
437
+ """
438
+ problem_type = (
439
+ "multi_label_classification" if self.is_multilabel else "single_label_classification"
440
+ )
441
+ config = AutoConfig.from_pretrained(
442
+ model_path,
443
+ num_labels=self.num_labels,
444
+ problem_type=problem_type,
445
+ )
446
+
447
+ drop = float(dropout)
448
+ attrs = MODEL_DROPOUT_ATTRS.get(config.model_type, MODEL_DROPOUT_ATTRS["default"])
449
+ for attr in attrs:
450
+ if hasattr(config, attr):
451
+ setattr(config, attr, drop)
452
+
453
+ # ModernBERT's compiled-MLP fast path crashes under gradient checkpointing
454
+ if hasattr(config, "reference_compile"):
455
+ config.reference_compile = False
456
+
457
+ return AutoModelForSequenceClassification.from_pretrained(model_path, config=config)
458
+
459
+ # ------------------------------------------------------------------
460
+ # Optuna objective
461
+ # ------------------------------------------------------------------
462
+
463
+ def _objective(self, trial):
464
+ """Optuna objective function with conditional logging."""
465
+ params = self._suggest_hyperparams(trial)
466
+ model_path = self.MODEL_CHOICES[params["model"]]
467
+ self._setup_seed()
468
+
469
+ tokenizer = self._get_tokenizer(model_path)
470
+ max_length = self._effective_max_length(tokenizer, model_path)
471
+ self.train_ds, self.val_ds, self.test_ds = self._prepare_datasets(
472
+ tokenizer, self.group_key, max_length
473
+ )
474
+
475
+ class_weights = self._compute_class_weights(self.train_ds)
476
+ model = self._load_model(model_path, params["dropout"])
477
+
478
+ output_dir = f"{self.models_dir}/optuna_trial_{trial.number}"
479
+ optimize_metric_key = f"eval_{self.optimize_metric}"
480
+
481
+ args = TrainingArguments(
482
+ output_dir=output_dir,
483
+ learning_rate=params["learning_rate"],
484
+ per_device_train_batch_size=params["batch_size"],
485
+ per_device_eval_batch_size=params["batch_size"],
486
+ gradient_accumulation_steps=params.get("gradient_accumulation_steps", 1),
487
+ gradient_checkpointing=self._use_gradient_checkpointing(max_length),
488
+ gradient_checkpointing_kwargs={"use_reentrant": False},
489
+ weight_decay=params["weight_decay"],
490
+ warmup_ratio=params["warmup_ratio"],
491
+ metric_for_best_model=optimize_metric_key,
492
+ greater_is_better=self.greater_is_better,
493
+ lr_scheduler_type=params["scheduler"],
494
+ eval_strategy="epoch",
495
+ save_strategy="epoch",
496
+ num_train_epochs=6,
497
+ save_total_limit=2,
498
+ load_best_model_at_end=True,
499
+ seed=self.seed,
500
+ remove_unused_columns=True,
501
+ report_to=["none"],
502
+ **self._precision_flags(),
503
+ )
504
+
505
+ trainer = CustomTrainer(
506
+ model=model,
507
+ args=args,
508
+ train_dataset=self.train_ds,
509
+ eval_dataset=self.val_ds,
510
+ data_collator=DataCollatorWithPadding(tokenizer),
511
+ compute_metrics=self._compute_metrics,
512
+ callbacks=[
513
+ EarlyStoppingCallback(early_stopping_patience=params["early_stopping_patience"])
514
+ ],
515
+ loss_type=params.get("loss_type", "weighted"),
516
+ class_weights=class_weights,
517
+ )
518
+
519
+ if self.log_level == "verbose":
520
+ with mlflow.start_run(run_name=f"trial_{trial.number}"):
521
+ mlflow.log_params(params)
522
+ trainer.train()
523
+ metrics = trainer.evaluate()
524
+ mlflow.log_metrics(metrics)
525
+ else:
526
+ trainer.train()
527
+ metrics = trainer.evaluate()
528
+
529
+ shutil.rmtree(output_dir, ignore_errors=True)
530
+ return metrics.get(optimize_metric_key, 0.0)
531
+
532
+ # ------------------------------------------------------------------
533
+ # Public API: optimize
534
+ # ------------------------------------------------------------------
535
+
536
+ def optimize(
537
+ self,
538
+ n_trials: int = 10,
539
+ optimize_metric: str = "avg_precision",
540
+ study_name: str = "bert_optimization",
541
+ greater_is_better: bool = True,
542
+ ):
543
+
544
+ self.greater_is_better = greater_is_better
545
+
546
+ """Runs Optuna optimization."""
547
+ mlflow.set_tracking_uri(self.mlflow_uri)
548
+ client = MlflowClient()
549
+ exp_name = f"enhanced_expert_model_optimization_{study_name}"
550
+ exp = client.get_experiment_by_name(exp_name)
551
+ if not exp:
552
+ client.create_experiment(exp_name)
553
+ elif exp.lifecycle_stage == "deleted":
554
+ client.restore_experiment(exp.experiment_id)
555
+
556
+ mlflow.set_experiment(experiment_name=exp_name)
557
+
558
+ sampler = TPESampler(seed=self.seed)
559
+ study = optuna.create_study(direction="maximize", study_name=study_name, sampler=sampler)
560
+ self.optimize_metric = optimize_metric
561
+ study.optimize(self._objective, n_trials=n_trials)
562
+
563
+ self.best_params = study.best_params
564
+ self._cleanup_trials(study.best_trial.number)
565
+ return study.best_value
566
+
567
+ # ------------------------------------------------------------------
568
+ # Public API: train_final_model
569
+ # ------------------------------------------------------------------
570
+
571
+ def train_final_model(self, run_name: str = "final_model_run"):
572
+ """Trains the model with best parameters and evaluates on test set."""
573
+ if not self.best_params:
574
+ raise ValueError("Run optimize() before training final model.")
575
+
576
+ self._setup_seed()
577
+ model_path = self.MODEL_CHOICES[self.best_params["model"]]
578
+
579
+ tokenizer = self._get_tokenizer(model_path)
580
+ max_length = self._effective_max_length(tokenizer, model_path)
581
+ train_ds, val_ds, test_ds = self._prepare_datasets(
582
+ tokenizer, self.group_key, max_length
583
+ )
584
+
585
+ class_weights = self._compute_class_weights(train_ds)
586
+ model = self._load_model(model_path, self.best_params["dropout"])
587
+
588
+ final_dir = f"{self.models_dir}/final_model"
589
+ args = TrainingArguments(
590
+ output_dir=final_dir,
591
+ eval_strategy="epoch",
592
+ save_strategy="epoch",
593
+ num_train_epochs=6,
594
+ learning_rate=self.best_params["learning_rate"],
595
+ per_device_train_batch_size=self.best_params["batch_size"],
596
+ per_device_eval_batch_size=self.best_params["batch_size"],
597
+ gradient_accumulation_steps=self.best_params.get("gradient_accumulation_steps", 1),
598
+ gradient_checkpointing=self._use_gradient_checkpointing(max_length),
599
+ gradient_checkpointing_kwargs={"use_reentrant": False},
600
+ weight_decay=self.best_params["weight_decay"],
601
+ warmup_ratio=self.best_params["warmup_ratio"],
602
+ metric_for_best_model=f"eval_{self.optimize_metric}",
603
+ greater_is_better=self.greater_is_better,
604
+ save_total_limit=2,
605
+ load_best_model_at_end=True,
606
+ report_to=["tensorboard", "mlflow"],
607
+ logging_dir=f"{final_dir}/logs",
608
+ seed=self.seed,
609
+ **self._precision_flags(),
610
+ )
611
+
612
+ with mlflow.start_run(run_name=run_name):
613
+ trainer = CustomTrainer(
614
+ model=model,
615
+ args=args,
616
+ train_dataset=train_ds,
617
+ eval_dataset=val_ds,
618
+ data_collator=DataCollatorWithPadding(tokenizer),
619
+ compute_metrics=self._compute_metrics,
620
+ callbacks=[
621
+ EarlyStoppingCallback(
622
+ early_stopping_patience=self.best_params["early_stopping_patience"]
623
+ ),
624
+ TensorBoardSyncCallback(f"{final_dir}/logs"),
625
+ CleanupCheckpointsCallback,
626
+ ],
627
+ loss_type=self.best_params.get("loss_type", "weighted"),
628
+ class_weights=class_weights,
629
+ )
630
+ trainer.train()
631
+
632
+ val_res = trainer.predict(val_ds)
633
+ self.best_threshold = self._optimize_threshold(val_res)
634
+
635
+ test_res = trainer.predict(test_ds)
636
+ metrics_df = self._build_metrics_df(
637
+ val_res.label_ids,
638
+ self._get_probs(val_res.predictions),
639
+ test_res.label_ids,
640
+ self._get_probs(test_res.predictions),
641
+ self.best_threshold,
642
+ )
643
+
644
+ mlflow.log_params(self.best_params)
645
+ for _, row in metrics_df.iterrows():
646
+ for m in ["Accuracy", "F1", "AUC"]:
647
+ mlflow.log_metric(f"{row['Split']}_{m}", row[m])
648
+
649
+ self._save_model(final_dir, trainer, tokenizer, model_path, max_length)
650
+
651
+ return metrics_df, model, test_ds
652
+
653
+ # ------------------------------------------------------------------
654
+ # Threshold optimisation
655
+ # ------------------------------------------------------------------
656
+
657
+ def _get_probs(self, predictions: np.ndarray) -> np.ndarray:
658
+ """
659
+ Converts raw logits to probabilities.
660
+ Binary → softmax → positive-class column shape (N,)
661
+ Multiclass → softmax over classes shape (N, num_classes)
662
+ Multi-label → sigmoid shape (N, num_labels)
663
+ """
664
+ if self.is_multilabel:
665
+ return torch.sigmoid(torch.from_numpy(predictions)).numpy()
666
+ probs = F.softmax(torch.from_numpy(predictions), dim=-1).numpy()
667
+ return probs[:, 1] if self.is_binary else probs
668
+
669
+ def _optimize_threshold(self, val_res):
670
+ """
671
+ Finds the best classification threshold(s) on the validation set.
672
+
673
+ Binary → one scalar threshold (maximises F1).
674
+ Multiclass → None (predictions are argmax; thresholds don't apply).
675
+ Multi-label → one threshold per label (maximises macro-F1);
676
+ returns np.ndarray of shape (num_labels,).
677
+ """
678
+ if not self.is_multilabel and not self.is_binary:
679
+ return None
680
+
681
+ probs = self._get_probs(val_res.predictions)
682
+ labels = val_res.label_ids
683
+
684
+ if self.is_multilabel:
685
+ # Optimise each label independently
686
+ best_thresholds = np.full(self.num_labels, 0.5)
687
+ for i in range(self.num_labels):
688
+ best_f1, best_t = 0.0, 0.5
689
+ for thresh in np.linspace(0.1, 0.9, 81):
690
+ f1 = f1_score(
691
+ labels[:, i],
692
+ (probs[:, i] >= thresh).astype(int),
693
+ zero_division=0,
694
+ )
695
+ if f1 > best_f1:
696
+ best_f1, best_t = f1, thresh
697
+ best_thresholds[i] = best_t
698
+ return best_thresholds
699
+ else:
700
+ best_f1, best_t = 0.0, 0.5
701
+ for thresh in np.linspace(0.1, 0.9, 81):
702
+ f1 = f1_score(
703
+ labels,
704
+ (probs >= thresh).astype(int),
705
+ zero_division=0,
706
+ )
707
+ if f1 > best_f1:
708
+ best_f1, best_t = f1, thresh
709
+ return best_t
710
+
711
+ # ------------------------------------------------------------------
712
+ # Metrics DataFrame
713
+ # ------------------------------------------------------------------
714
+
715
+ def _build_metrics_df(self, val_y, val_p, test_y, test_p, thresh):
716
+ """Helper to construct the results DataFrame."""
717
+
718
+ def get_row(split, y, p):
719
+ if self.is_multilabel:
720
+ # thresh is shape (num_labels,); p is (N, num_labels)
721
+ preds = (p >= thresh).astype(int)
722
+ return {
723
+ "Split": split,
724
+ "Accuracy": accuracy_score(y, preds),
725
+ "Precision_micro": precision_score(y, preds, average="micro", zero_division=0),
726
+ "Recall_micro": recall_score(y, preds, average="micro", zero_division=0),
727
+ "F1": f1_score(y, preds, average="macro", zero_division=0),
728
+ "F1_micro": f1_score(y, preds, average="micro", zero_division=0),
729
+ "F1_samples": f1_score(y, preds, average="samples", zero_division=0),
730
+ "AP": average_precision_score(y, p, average="macro"),
731
+ "AUC": (
732
+ roc_auc_score(y, p, average="macro") if len(np.unique(y)) > 1 else 0.5
733
+ ),
734
+ "Threshold": str(np.round(thresh, 3).tolist()),
735
+ }
736
+ elif self.is_binary:
737
+ # thresh is a scalar; p is (N,)
738
+ preds = (p >= thresh).astype(int)
739
+ return {
740
+ "Split": split,
741
+ "Accuracy": accuracy_score(y, preds),
742
+ "Precision": precision_score(y, preds, zero_division=0),
743
+ "Recall": recall_score(y, preds, zero_division=0),
744
+ "F1": f1_score(y, preds, zero_division=0),
745
+ "Specificity": recall_score(y, preds, pos_label=0, zero_division=0),
746
+ "AP": average_precision_score(y, p),
747
+ "AUC": roc_auc_score(y, p) if len(np.unique(y)) > 1 else 0.5,
748
+ "Threshold": thresh,
749
+ }
750
+ else:
751
+ # Multiclass: thresh is None; p is (N, num_classes), argmax predictions
752
+ preds = p.argmax(axis=1)
753
+ all_classes_present = len(np.unique(y)) == p.shape[1]
754
+ return {
755
+ "Split": split,
756
+ "Accuracy": accuracy_score(y, preds),
757
+ "Precision": precision_score(y, preds, average="macro", zero_division=0),
758
+ "Recall": recall_score(y, preds, average="macro", zero_division=0),
759
+ "F1": f1_score(y, preds, average="macro", zero_division=0),
760
+ "AP": (
761
+ average_precision_score(
762
+ label_binarize(y, classes=np.arange(p.shape[1])),
763
+ p,
764
+ average="macro",
765
+ )
766
+ if all_classes_present
767
+ else 0.5
768
+ ),
769
+ "AUC": (
770
+ roc_auc_score(y, p, multi_class="ovr", average="macro")
771
+ if all_classes_present
772
+ else 0.5
773
+ ),
774
+ "Threshold": None,
775
+ }
776
+
777
+ return pd.DataFrame(
778
+ [get_row("Validation", val_y, val_p), get_row("Test", test_y, test_p)],
779
+ )
780
+
781
+ # ------------------------------------------------------------------
782
+ # Persistence
783
+ # ------------------------------------------------------------------
784
+
785
+ def _save_model(self, path, trainer, tokenizer, model_path, max_length=None):
786
+ """Saves model + tokenizer via save_pretrained and a JSON config for reloading."""
787
+ save_dir = f"{path}/model"
788
+ os.makedirs(save_dir, exist_ok=True)
789
+ trainer.save_model(save_dir)
790
+ tokenizer.save_pretrained(save_dir)
791
+
792
+ threshold = self.best_threshold
793
+ # Convert numpy array to list for JSON serialisation
794
+ if isinstance(threshold, np.ndarray):
795
+ threshold = threshold.tolist()
796
+
797
+ config = {
798
+ "model_metadata": {
799
+ "model": self.best_params["model"],
800
+ "model_path": model_path,
801
+ "optimal_threshold": threshold,
802
+ "is_multilabel": self.is_multilabel,
803
+ "target_cols": self.target_cols,
804
+ "max_length": max_length if max_length is not None else self.max_length,
805
+ },
806
+ "parameters": self.best_params,
807
+ }
808
+ with open(f"{save_dir}/bertuner_config.json", "w") as f:
809
+ json.dump(config, f, indent=2)
810
+
811
+ def _cleanup_trials(self, keep_trial_id):
812
+ """Removes non-best trial directories."""
813
+ for entry in os.listdir(self.models_dir):
814
+ if entry.startswith("optuna_trial_") and f"_{keep_trial_id}" not in entry:
815
+ shutil.rmtree(
816
+ os.path.join(self.models_dir, entry),
817
+ ignore_errors=True,
818
+ )