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 +818 -0
- bertuner/CustomTrainer.py +109 -0
- bertuner/Predictor.py +90 -0
- bertuner/TensorBoardCallback.py +39 -0
- bertuner/__init__.py +17 -0
- bertuner/constants.py +64 -0
- bertuner/utils.py +99 -0
- bertuner-0.1.0.dist-info/METADATA +200 -0
- bertuner-0.1.0.dist-info/RECORD +12 -0
- bertuner-0.1.0.dist-info/WHEEL +5 -0
- bertuner-0.1.0.dist-info/licenses/LICENSE +21 -0
- bertuner-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|