bertuner 0.2.2__tar.gz → 0.2.4__tar.gz
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-0.2.2 → bertuner-0.2.4}/PKG-INFO +19 -1
- {bertuner-0.2.2 → bertuner-0.2.4}/README.md +18 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/BERTuner.py +96 -39
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/CustomTrainer.py +3 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/__init__.py +1 -1
- bertuner-0.2.4/bertuner/compat.py +15 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/PKG-INFO +19 -1
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/SOURCES.txt +2 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/pyproject.toml +1 -1
- {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_bertuner.py +84 -2
- bertuner-0.2.4/tests/test_transformers_compat.py +88 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/LICENSE +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/Predictor.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/TensorBoardCallback.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/constants.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/exceptions.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/utils.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/dependency_links.txt +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/requires.txt +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/top_level.txt +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/setup.cfg +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_numerical_stability.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_predictor.py +0 -0
- {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bertuner
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Hyperparameter optimization and fine-tuning for BERT-style text classifiers (Optuna + MLflow), with long-context ModernBERT support
|
|
5
5
|
Author-email: elemets <alafunnell@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -58,6 +58,20 @@ git clone https://github.com/elemets/bertuner && cd bertuner
|
|
|
58
58
|
pip install -r requirements.txt
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
BERTuner supports Transformers 4.48+ and 5.x. Compatibility tests cover the
|
|
62
|
+
4.48 baseline and the latest available 4.x and 5.x releases, including final
|
|
63
|
+
training, TensorBoard logging, warmup scheduling, and gradient accumulation.
|
|
64
|
+
Future releases are checked by a weekly CI run rather than assumed compatible.
|
|
65
|
+
|
|
66
|
+
When initializing a classifier from a base pretrained model, a Transformers 5
|
|
67
|
+
load report may list `lm_head.*` weights as `UNEXPECTED` and classifier weights
|
|
68
|
+
as `MISSING`. This is expected when replacing the language-model head with a
|
|
69
|
+
classification head; the new head is learned during fine-tuning. Unexpected
|
|
70
|
+
encoder weights or shape mismatches should still be investigated.
|
|
71
|
+
|
|
72
|
+
Run the test suite with `python -m pytest tests -q`. Training regression tests
|
|
73
|
+
use a tiny local checkpoint; cached-model predictor tests skip if unavailable.
|
|
74
|
+
|
|
61
75
|
MLflow tracking works in two modes:
|
|
62
76
|
|
|
63
77
|
```bash
|
|
@@ -107,6 +121,10 @@ print(metrics)
|
|
|
107
121
|
|
|
108
122
|
Multi-label classification: pass several target columns — `target_cols=["l1", "l2", "l3"]`. The loss switches to BCE-with-logits and one decision threshold is optimised per label.
|
|
109
123
|
|
|
124
|
+
Pass `threshold_metric="balanced_accuracy"` to `BERTuneClassifier` to choose thresholds by balanced accuracy. The default, `threshold_metric="f1"`, optimizes F-beta with `threshold_beta=1.0` (F1). Set `threshold_beta=2.0` for F2, which gives recall more weight, or `threshold_beta=0.5` for F0.5, which gives precision more weight. Beta must be positive and finite; it is ignored when using balanced accuracy.
|
|
125
|
+
|
|
126
|
+
Threshold tuning runs during `train_final_model()` using validation predictions only and is independent of `optimize_metric`, which selects the model during Optuna optimization. It checks every distinct predicted probability, plus 0.5 and a boundary above the maximum for all-negative predictions. Ties prefer 0.5, then the lowest optimal threshold. Multi-label thresholds are chosen independently for each label; multiclass predictions use argmax. The saved `bertuner_config.json` includes `threshold_metric` and `threshold_beta` alongside `optimal_threshold`, and the predictor automatically uses the saved threshold. Reported F1 metrics remain F1 even when the threshold is tuned for another beta.
|
|
127
|
+
|
|
110
128
|
Grouped data (e.g. multiple notes per patient): pass `group_key="patient_id"` and the train/val/test split guarantees no group leaks across splits.
|
|
111
129
|
|
|
112
130
|
### Using existing splits
|
|
@@ -17,6 +17,20 @@ git clone https://github.com/elemets/bertuner && cd bertuner
|
|
|
17
17
|
pip install -r requirements.txt
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
BERTuner supports Transformers 4.48+ and 5.x. Compatibility tests cover the
|
|
21
|
+
4.48 baseline and the latest available 4.x and 5.x releases, including final
|
|
22
|
+
training, TensorBoard logging, warmup scheduling, and gradient accumulation.
|
|
23
|
+
Future releases are checked by a weekly CI run rather than assumed compatible.
|
|
24
|
+
|
|
25
|
+
When initializing a classifier from a base pretrained model, a Transformers 5
|
|
26
|
+
load report may list `lm_head.*` weights as `UNEXPECTED` and classifier weights
|
|
27
|
+
as `MISSING`. This is expected when replacing the language-model head with a
|
|
28
|
+
classification head; the new head is learned during fine-tuning. Unexpected
|
|
29
|
+
encoder weights or shape mismatches should still be investigated.
|
|
30
|
+
|
|
31
|
+
Run the test suite with `python -m pytest tests -q`. Training regression tests
|
|
32
|
+
use a tiny local checkpoint; cached-model predictor tests skip if unavailable.
|
|
33
|
+
|
|
20
34
|
MLflow tracking works in two modes:
|
|
21
35
|
|
|
22
36
|
```bash
|
|
@@ -66,6 +80,10 @@ print(metrics)
|
|
|
66
80
|
|
|
67
81
|
Multi-label classification: pass several target columns — `target_cols=["l1", "l2", "l3"]`. The loss switches to BCE-with-logits and one decision threshold is optimised per label.
|
|
68
82
|
|
|
83
|
+
Pass `threshold_metric="balanced_accuracy"` to `BERTuneClassifier` to choose thresholds by balanced accuracy. The default, `threshold_metric="f1"`, optimizes F-beta with `threshold_beta=1.0` (F1). Set `threshold_beta=2.0` for F2, which gives recall more weight, or `threshold_beta=0.5` for F0.5, which gives precision more weight. Beta must be positive and finite; it is ignored when using balanced accuracy.
|
|
84
|
+
|
|
85
|
+
Threshold tuning runs during `train_final_model()` using validation predictions only and is independent of `optimize_metric`, which selects the model during Optuna optimization. It checks every distinct predicted probability, plus 0.5 and a boundary above the maximum for all-negative predictions. Ties prefer 0.5, then the lowest optimal threshold. Multi-label thresholds are chosen independently for each label; multiclass predictions use argmax. The saved `bertuner_config.json` includes `threshold_metric` and `threshold_beta` alongside `optimal_threshold`, and the predictor automatically uses the saved threshold. Reported F1 metrics remain F1 even when the threshold is tuned for another beta.
|
|
86
|
+
|
|
69
87
|
Grouped data (e.g. multiple notes per patient): pass `group_key="patient_id"` and the train/val/test split guarantees no group leaks across splits.
|
|
70
88
|
|
|
71
89
|
### Using existing splits
|
|
@@ -3,6 +3,7 @@ import random
|
|
|
3
3
|
import json
|
|
4
4
|
import shutil
|
|
5
5
|
import warnings
|
|
6
|
+
from numbers import Real
|
|
6
7
|
import numpy as np
|
|
7
8
|
import pandas as pd
|
|
8
9
|
import torch
|
|
@@ -15,7 +16,6 @@ from transformers import (
|
|
|
15
16
|
AutoTokenizer,
|
|
16
17
|
AutoConfig,
|
|
17
18
|
AutoModelForSequenceClassification,
|
|
18
|
-
TrainingArguments,
|
|
19
19
|
DataCollatorWithPadding,
|
|
20
20
|
EarlyStoppingCallback,
|
|
21
21
|
set_seed,
|
|
@@ -38,6 +38,7 @@ from sklearn.preprocessing import label_binarize
|
|
|
38
38
|
from mlflow.tracking import MlflowClient
|
|
39
39
|
|
|
40
40
|
from bertuner.CustomTrainer import CustomTrainer
|
|
41
|
+
from bertuner.compat import TrainingArguments
|
|
41
42
|
from bertuner.exceptions import NonFiniteTrainingError, NoStableTrialError
|
|
42
43
|
from bertuner.TensorBoardCallback import (
|
|
43
44
|
TensorBoardSyncCallback,
|
|
@@ -56,6 +57,7 @@ from bertuner.constants import (
|
|
|
56
57
|
MODEL_DROPOUT_ATTRS,
|
|
57
58
|
SEED,
|
|
58
59
|
)
|
|
60
|
+
import inspect
|
|
59
61
|
|
|
60
62
|
|
|
61
63
|
class BERTuneClassifier:
|
|
@@ -88,11 +90,21 @@ class BERTuneClassifier:
|
|
|
88
90
|
max_grad_norm: float | None = 1.0,
|
|
89
91
|
class_weight_warning_threshold: float | None = 100.0,
|
|
90
92
|
data_splits: dict[str, pd.DataFrame | str | os.PathLike] = None,
|
|
93
|
+
threshold_metric: str = "f1",
|
|
94
|
+
threshold_beta: float = 1.0,
|
|
91
95
|
):
|
|
92
96
|
if sum(source is not None for source in (data_path, dataframe, data_splits)) != 1:
|
|
93
97
|
raise ValueError("Provide exactly one of data_path (CSV), dataframe, or data_splits.")
|
|
94
98
|
if precision not in {"auto", "fp32", "bf16", "fp16"}:
|
|
95
99
|
raise ValueError("precision must be one of: 'auto', 'fp32', 'bf16', 'fp16'.")
|
|
100
|
+
if threshold_metric not in ("f1", "balanced_accuracy"):
|
|
101
|
+
raise ValueError("threshold_metric must be 'f1' or 'balanced_accuracy'.")
|
|
102
|
+
if (
|
|
103
|
+
not isinstance(threshold_beta, Real)
|
|
104
|
+
or not np.isfinite(threshold_beta)
|
|
105
|
+
or threshold_beta <= 0
|
|
106
|
+
):
|
|
107
|
+
raise ValueError("threshold_beta must be a positive finite number.")
|
|
96
108
|
if max_grad_norm is not None and (
|
|
97
109
|
not np.isfinite(max_grad_norm) or max_grad_norm <= 0
|
|
98
110
|
):
|
|
@@ -155,6 +167,8 @@ class BERTuneClassifier:
|
|
|
155
167
|
self.best_precision_fallback = False
|
|
156
168
|
# Single-label: scalar float. Multi-label: array of per-label floats.
|
|
157
169
|
self.best_threshold = 0.5
|
|
170
|
+
self.threshold_metric = threshold_metric
|
|
171
|
+
self.threshold_beta = float(threshold_beta)
|
|
158
172
|
self.max_length = max_length
|
|
159
173
|
# None → auto: enabled when the effective sequence length is long enough
|
|
160
174
|
# that activation memory dominates (see _use_gradient_checkpointing).
|
|
@@ -746,7 +760,6 @@ class BERTuneClassifier:
|
|
|
746
760
|
"gradient_checkpointing": self._use_gradient_checkpointing(max_length),
|
|
747
761
|
"gradient_checkpointing_kwargs": {"use_reentrant": False},
|
|
748
762
|
"weight_decay": params["weight_decay"],
|
|
749
|
-
"warmup_ratio": params["warmup_ratio"],
|
|
750
763
|
"metric_for_best_model": f"eval_{self.optimize_metric}",
|
|
751
764
|
"greater_is_better": self.greater_is_better,
|
|
752
765
|
"eval_strategy": "epoch",
|
|
@@ -760,17 +773,20 @@ class BERTuneClassifier:
|
|
|
760
773
|
"seed": self.seed,
|
|
761
774
|
**self._precision_flags(precision),
|
|
762
775
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
else
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
776
|
+
|
|
777
|
+
_TA_PARAMS = inspect.signature(TrainingArguments.__init__).parameters
|
|
778
|
+
|
|
779
|
+
# v5 removed warmup_ratio and accepts fractions in warmup_steps.
|
|
780
|
+
warmup_key = "warmup_ratio" if "warmup_ratio" in _TA_PARAMS else "warmup_steps"
|
|
781
|
+
kwargs[warmup_key] = params["warmup_ratio"]
|
|
782
|
+
kwargs["lr_scheduler_type"] = params["scheduler"]
|
|
783
|
+
# Final training uses an explicit TensorBoard writer in _build_trainer;
|
|
784
|
+
# newer Transformers versions removed TrainingArguments.logging_dir.
|
|
785
|
+
kwargs.update(remove_unused_columns=True, report_to=["none"])
|
|
786
|
+
args = TrainingArguments(**kwargs)
|
|
787
|
+
if warmup_key == "warmup_steps":
|
|
788
|
+
args._bertuner_warmup_ratio = params["warmup_ratio"]
|
|
789
|
+
return args
|
|
774
790
|
|
|
775
791
|
def _build_trainer(
|
|
776
792
|
self,
|
|
@@ -792,8 +808,12 @@ class BERTuneClassifier:
|
|
|
792
808
|
)
|
|
793
809
|
]
|
|
794
810
|
if final:
|
|
811
|
+
from transformers.integrations import TensorBoardCallback
|
|
812
|
+
from torch.utils.tensorboard import SummaryWriter
|
|
813
|
+
|
|
795
814
|
callbacks.extend(
|
|
796
815
|
[
|
|
816
|
+
TensorBoardCallback(tb_writer=SummaryWriter(logging_dir)),
|
|
797
817
|
TensorBoardSyncCallback(logging_dir),
|
|
798
818
|
CleanupCheckpointsCallback,
|
|
799
819
|
]
|
|
@@ -857,11 +877,15 @@ class BERTuneClassifier:
|
|
|
857
877
|
try:
|
|
858
878
|
trainer.train()
|
|
859
879
|
except Exception:
|
|
880
|
+
from transformers.integrations import TensorBoardCallback
|
|
881
|
+
|
|
860
882
|
# Trainer does not emit on_train_end after an exception. Close only
|
|
861
883
|
# BERTuner-owned writers before the caller decides whether to retry.
|
|
862
884
|
for callback in trainer.callback_handler.callbacks:
|
|
863
885
|
if isinstance(callback, TensorBoardSyncCallback):
|
|
864
886
|
callback.writer.close()
|
|
887
|
+
elif isinstance(callback, TensorBoardCallback) and callback.tb_writer is not None:
|
|
888
|
+
callback.tb_writer.close()
|
|
865
889
|
raise
|
|
866
890
|
return trainer, model
|
|
867
891
|
|
|
@@ -1208,10 +1232,14 @@ class BERTuneClassifier:
|
|
|
1208
1232
|
"""
|
|
1209
1233
|
Finds the best classification threshold(s) on the validation set.
|
|
1210
1234
|
|
|
1211
|
-
Binary → one scalar threshold
|
|
1235
|
+
Binary → one scalar threshold.
|
|
1212
1236
|
Multiclass → None (predictions are argmax; thresholds don't apply).
|
|
1213
|
-
Multi-label → one threshold per label
|
|
1237
|
+
Multi-label → one independently optimised threshold per label;
|
|
1214
1238
|
returns np.ndarray of shape (num_labels,).
|
|
1239
|
+
|
|
1240
|
+
Maximises F-beta (threshold_metric='f1') or balanced accuracy.
|
|
1241
|
+
Evaluates every distinct prediction boundary, including all-negative
|
|
1242
|
+
predictions. Ties prefer 0.5, then the lowest optimal threshold.
|
|
1215
1243
|
"""
|
|
1216
1244
|
if not self.is_multilabel and not self.is_binary:
|
|
1217
1245
|
return None
|
|
@@ -1220,31 +1248,58 @@ class BERTuneClassifier:
|
|
|
1220
1248
|
labels = val_res.label_ids
|
|
1221
1249
|
|
|
1222
1250
|
if self.is_multilabel:
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1251
|
+
return np.array([
|
|
1252
|
+
self._best_binary_threshold(labels[:, i], probs[:, i])
|
|
1253
|
+
for i in range(self.num_labels)
|
|
1254
|
+
])
|
|
1255
|
+
return self._best_binary_threshold(labels, probs)
|
|
1256
|
+
|
|
1257
|
+
def _best_binary_threshold(self, labels, probs):
|
|
1258
|
+
"""Score distinct boundaries in O(n log n), with tied scores kept together."""
|
|
1259
|
+
order = np.argsort(probs)
|
|
1260
|
+
sorted_probs = probs[order]
|
|
1261
|
+
positives = np.concatenate(([0], np.cumsum(labels[order] == 1)))
|
|
1262
|
+
# Use the probability dtype so the all-negative boundary remains above
|
|
1263
|
+
# the maximum when inference compares against float32 probabilities.
|
|
1264
|
+
above_max = float(np.nextafter(probs.max(), np.array(np.inf, dtype=probs.dtype)))
|
|
1265
|
+
thresholds = np.unique(np.append(sorted_probs, [0.5, above_max]))
|
|
1266
|
+
boundaries = np.searchsorted(sorted_probs, thresholds, side="left")
|
|
1267
|
+
tp = positives[-1] - positives[boundaries]
|
|
1268
|
+
fn = positives[boundaries]
|
|
1269
|
+
fp = len(labels) - boundaries - tp
|
|
1270
|
+
tn = boundaries - fn
|
|
1271
|
+
|
|
1272
|
+
if self.threshold_metric == "balanced_accuracy":
|
|
1273
|
+
scores = np.zeros(len(thresholds))
|
|
1274
|
+
present_classes = 0
|
|
1275
|
+
if positives[-1] > 0:
|
|
1276
|
+
scores += tp / positives[-1]
|
|
1277
|
+
present_classes += 1
|
|
1278
|
+
negatives = len(labels) - positives[-1]
|
|
1279
|
+
if negatives > 0:
|
|
1280
|
+
scores += tn / negatives
|
|
1281
|
+
present_classes += 1
|
|
1282
|
+
scores /= present_classes
|
|
1237
1283
|
else:
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
)
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1284
|
+
# Normalised beta weights avoid overflow for large finite beta.
|
|
1285
|
+
beta = self.threshold_beta
|
|
1286
|
+
if beta <= 1:
|
|
1287
|
+
recall_weight = beta * beta / (1 + beta * beta)
|
|
1288
|
+
precision_weight = 1 / (1 + beta * beta)
|
|
1289
|
+
else:
|
|
1290
|
+
inverse_square = (1 / beta) ** 2
|
|
1291
|
+
recall_weight = 1 / (1 + inverse_square)
|
|
1292
|
+
precision_weight = inverse_square / (1 + inverse_square)
|
|
1293
|
+
denominator = tp + recall_weight * fn + precision_weight * fp
|
|
1294
|
+
scores = np.divide(
|
|
1295
|
+
tp, denominator, out=np.zeros(len(thresholds)), where=denominator > 0
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
best_score = scores.max()
|
|
1299
|
+
default_index = np.searchsorted(thresholds, 0.5)
|
|
1300
|
+
if scores[default_index] == best_score:
|
|
1301
|
+
return 0.5
|
|
1302
|
+
return float(thresholds[np.argmax(scores)])
|
|
1248
1303
|
|
|
1249
1304
|
# ------------------------------------------------------------------
|
|
1250
1305
|
# Metrics DataFrame
|
|
@@ -1393,6 +1448,8 @@ class BERTuneClassifier:
|
|
|
1393
1448
|
"model": self.best_params["model"],
|
|
1394
1449
|
"model_path": model_path,
|
|
1395
1450
|
"optimal_threshold": threshold,
|
|
1451
|
+
"threshold_metric": self.threshold_metric,
|
|
1452
|
+
"threshold_beta": self.threshold_beta,
|
|
1396
1453
|
"is_multilabel": self.is_multilabel,
|
|
1397
1454
|
"target_cols": self.target_cols,
|
|
1398
1455
|
"max_length": max_length if max_length is not None else self.max_length,
|
|
@@ -69,6 +69,9 @@ class CustomTrainer(Trainer):
|
|
|
69
69
|
callbacks = list(kwargs.pop("callbacks", None) or [])
|
|
70
70
|
callbacks.append(NonFiniteGradientCallback(training_precision))
|
|
71
71
|
super().__init__(callbacks=callbacks, **kwargs)
|
|
72
|
+
# compute_loss returns a microbatch mean and does not consume
|
|
73
|
+
# num_items_in_batch, even when the model's forward accepts **kwargs.
|
|
74
|
+
self.model_accepts_loss_kwargs = False
|
|
72
75
|
self.loss_type = loss_type
|
|
73
76
|
self.class_weights = class_weights
|
|
74
77
|
self.training_precision = training_precision
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Small adapters for Transformers APIs shared by supported 4.x and 5.x."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from transformers import TrainingArguments as HFTrainingArguments
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TrainingArguments(HFTrainingArguments):
|
|
9
|
+
def get_warmup_steps(self, num_training_steps):
|
|
10
|
+
ratio = getattr(self, "_bertuner_warmup_ratio", None)
|
|
11
|
+
if ratio is not None:
|
|
12
|
+
# New warmup_steps treats 1.0 as one step, whereas the old
|
|
13
|
+
# warmup_ratio treats it as the entire training run.
|
|
14
|
+
return math.ceil(num_training_steps * ratio)
|
|
15
|
+
return super().get_warmup_steps(num_training_steps)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bertuner
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Hyperparameter optimization and fine-tuning for BERT-style text classifiers (Optuna + MLflow), with long-context ModernBERT support
|
|
5
5
|
Author-email: elemets <alafunnell@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -58,6 +58,20 @@ git clone https://github.com/elemets/bertuner && cd bertuner
|
|
|
58
58
|
pip install -r requirements.txt
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
BERTuner supports Transformers 4.48+ and 5.x. Compatibility tests cover the
|
|
62
|
+
4.48 baseline and the latest available 4.x and 5.x releases, including final
|
|
63
|
+
training, TensorBoard logging, warmup scheduling, and gradient accumulation.
|
|
64
|
+
Future releases are checked by a weekly CI run rather than assumed compatible.
|
|
65
|
+
|
|
66
|
+
When initializing a classifier from a base pretrained model, a Transformers 5
|
|
67
|
+
load report may list `lm_head.*` weights as `UNEXPECTED` and classifier weights
|
|
68
|
+
as `MISSING`. This is expected when replacing the language-model head with a
|
|
69
|
+
classification head; the new head is learned during fine-tuning. Unexpected
|
|
70
|
+
encoder weights or shape mismatches should still be investigated.
|
|
71
|
+
|
|
72
|
+
Run the test suite with `python -m pytest tests -q`. Training regression tests
|
|
73
|
+
use a tiny local checkpoint; cached-model predictor tests skip if unavailable.
|
|
74
|
+
|
|
61
75
|
MLflow tracking works in two modes:
|
|
62
76
|
|
|
63
77
|
```bash
|
|
@@ -107,6 +121,10 @@ print(metrics)
|
|
|
107
121
|
|
|
108
122
|
Multi-label classification: pass several target columns — `target_cols=["l1", "l2", "l3"]`. The loss switches to BCE-with-logits and one decision threshold is optimised per label.
|
|
109
123
|
|
|
124
|
+
Pass `threshold_metric="balanced_accuracy"` to `BERTuneClassifier` to choose thresholds by balanced accuracy. The default, `threshold_metric="f1"`, optimizes F-beta with `threshold_beta=1.0` (F1). Set `threshold_beta=2.0` for F2, which gives recall more weight, or `threshold_beta=0.5` for F0.5, which gives precision more weight. Beta must be positive and finite; it is ignored when using balanced accuracy.
|
|
125
|
+
|
|
126
|
+
Threshold tuning runs during `train_final_model()` using validation predictions only and is independent of `optimize_metric`, which selects the model during Optuna optimization. It checks every distinct predicted probability, plus 0.5 and a boundary above the maximum for all-negative predictions. Ties prefer 0.5, then the lowest optimal threshold. Multi-label thresholds are chosen independently for each label; multiclass predictions use argmax. The saved `bertuner_config.json` includes `threshold_metric` and `threshold_beta` alongside `optimal_threshold`, and the predictor automatically uses the saved threshold. Reported F1 metrics remain F1 even when the threshold is tuned for another beta.
|
|
127
|
+
|
|
110
128
|
Grouped data (e.g. multiple notes per patient): pass `group_key="patient_id"` and the train/val/test split guarantees no group leaks across splits.
|
|
111
129
|
|
|
112
130
|
### Using existing splits
|
|
@@ -6,6 +6,7 @@ bertuner/CustomTrainer.py
|
|
|
6
6
|
bertuner/Predictor.py
|
|
7
7
|
bertuner/TensorBoardCallback.py
|
|
8
8
|
bertuner/__init__.py
|
|
9
|
+
bertuner/compat.py
|
|
9
10
|
bertuner/constants.py
|
|
10
11
|
bertuner/exceptions.py
|
|
11
12
|
bertuner/utils.py
|
|
@@ -17,4 +18,5 @@ bertuner.egg-info/top_level.txt
|
|
|
17
18
|
tests/test_bertuner.py
|
|
18
19
|
tests/test_numerical_stability.py
|
|
19
20
|
tests/test_predictor.py
|
|
21
|
+
tests/test_transformers_compat.py
|
|
20
22
|
tests/test_utils.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bertuner"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Hyperparameter optimization and fine-tuning for BERT-style text classifiers (Optuna + MLflow), with long-context ModernBERT support"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -9,6 +9,7 @@ import pandas as pd
|
|
|
9
9
|
import pytest
|
|
10
10
|
import optuna
|
|
11
11
|
import mlflow
|
|
12
|
+
from sklearn.metrics import balanced_accuracy_score, fbeta_score
|
|
12
13
|
from mlflow.tracking import MlflowClient
|
|
13
14
|
|
|
14
15
|
from bertuner.BERTuner import BERTuneClassifier
|
|
@@ -244,6 +245,75 @@ class TestGetProbs:
|
|
|
244
245
|
|
|
245
246
|
|
|
246
247
|
class TestOptimizeThreshold:
|
|
248
|
+
@pytest.mark.parametrize("metric", ["auc_roc", "fbeta", None])
|
|
249
|
+
def test_invalid_metric(self, tmp_path, metric):
|
|
250
|
+
with pytest.raises(ValueError, match="threshold_metric"):
|
|
251
|
+
make_classifier(tmp_path, threshold_metric=metric)
|
|
252
|
+
|
|
253
|
+
@pytest.mark.parametrize("beta", [0, -1, np.nan, np.inf, "2", None])
|
|
254
|
+
def test_invalid_beta(self, tmp_path, beta):
|
|
255
|
+
with pytest.raises(ValueError, match="threshold_beta"):
|
|
256
|
+
make_classifier(tmp_path, threshold_beta=beta)
|
|
257
|
+
|
|
258
|
+
@pytest.mark.parametrize("metric", ["f1", "balanced_accuracy"])
|
|
259
|
+
def test_clustered_probabilities(self, tmp_path, monkeypatch, metric):
|
|
260
|
+
clf = make_classifier(tmp_path, threshold_metric=metric)
|
|
261
|
+
probs = np.array([0.531, 0.534, 0.536, 0.539])
|
|
262
|
+
monkeypatch.setattr(clf, "_get_probs", lambda _: probs)
|
|
263
|
+
threshold = clf._optimize_threshold(SimpleNamespace(
|
|
264
|
+
predictions=None, label_ids=np.array([0, 0, 1, 1])
|
|
265
|
+
))
|
|
266
|
+
assert threshold == 0.536
|
|
267
|
+
|
|
268
|
+
@pytest.mark.parametrize("metric, beta, expected", [
|
|
269
|
+
("f1", 0.5, 0.9), ("f1", 1, 0.5), ("f1", 2, 0.5),
|
|
270
|
+
("balanced_accuracy", 1, 0.9),
|
|
271
|
+
])
|
|
272
|
+
def test_metric_changes_optimal_threshold(self, tmp_path, monkeypatch, metric, beta, expected):
|
|
273
|
+
clf = make_classifier(tmp_path, threshold_metric=metric, threshold_beta=beta)
|
|
274
|
+
probs = np.array([0.9, 0.8, 0.7, 0.6, 0.55, 0.54])
|
|
275
|
+
monkeypatch.setattr(clf, "_get_probs", lambda _: probs)
|
|
276
|
+
threshold = clf._optimize_threshold(SimpleNamespace(
|
|
277
|
+
predictions=None, label_ids=np.array([1, 0, 0, 1, 0, 1])
|
|
278
|
+
))
|
|
279
|
+
assert threshold == expected
|
|
280
|
+
|
|
281
|
+
@pytest.mark.parametrize("metric, beta", [
|
|
282
|
+
("f1", 1), ("f1", 2), ("f1", 0.5), ("balanced_accuracy", 1)
|
|
283
|
+
])
|
|
284
|
+
def test_matches_exhaustive_search(self, tmp_path, metric, beta):
|
|
285
|
+
clf = make_classifier(tmp_path, threshold_metric=metric, threshold_beta=beta)
|
|
286
|
+
rng = np.random.default_rng(17)
|
|
287
|
+
for _ in range(10):
|
|
288
|
+
probs = rng.choice([0.02, 0.531, 0.534, 0.539, 0.98], 30)
|
|
289
|
+
labels = rng.integers(0, 2, len(probs))
|
|
290
|
+
candidates = np.unique(np.append(probs, [0.5, 1.0]))
|
|
291
|
+
def score(t):
|
|
292
|
+
preds = probs >= t
|
|
293
|
+
if metric == "balanced_accuracy":
|
|
294
|
+
return balanced_accuracy_score(labels, preds)
|
|
295
|
+
return fbeta_score(labels, preds, beta=beta, zero_division=0)
|
|
296
|
+
threshold = clf._best_binary_threshold(labels, probs)
|
|
297
|
+
assert score(threshold) == pytest.approx(max(map(score, candidates)))
|
|
298
|
+
|
|
299
|
+
@pytest.mark.parametrize("metric", ["f1", "balanced_accuracy"])
|
|
300
|
+
def test_multilabel_single_class_and_saturated_scores(self, tmp_path, monkeypatch, metric):
|
|
301
|
+
cols = ["l1", "l2"]
|
|
302
|
+
clf = make_classifier(
|
|
303
|
+
tmp_path, target_cols=cols, dataframe=make_df(multilabel_cols=cols),
|
|
304
|
+
threshold_metric=metric,
|
|
305
|
+
)
|
|
306
|
+
probs = np.array([[1, 0], [1, 0]], dtype=np.float32)
|
|
307
|
+
labels = np.array([[0, 1], [0, 1]])
|
|
308
|
+
monkeypatch.setattr(clf, "_get_probs", lambda _: probs)
|
|
309
|
+
thresholds = clf._optimize_threshold(SimpleNamespace(predictions=None, label_ids=labels))
|
|
310
|
+
assert thresholds[1] == 0
|
|
311
|
+
if metric == "balanced_accuracy":
|
|
312
|
+
assert thresholds[0] > 1
|
|
313
|
+
assert not np.any(probs[:, 0] >= thresholds[0])
|
|
314
|
+
else:
|
|
315
|
+
assert thresholds[0] == 0.5 # All F-beta scores are zero.
|
|
316
|
+
|
|
247
317
|
def test_single_label_finds_separating_threshold(self, tmp_path):
|
|
248
318
|
clf = make_classifier(tmp_path)
|
|
249
319
|
# Positives cluster at high prob, negatives at low → best threshold between
|
|
@@ -483,6 +553,7 @@ class TestLossCurveLogging:
|
|
|
483
553
|
]
|
|
484
554
|
)
|
|
485
555
|
|
|
556
|
+
(tmp_path / "downloaded-artifacts").mkdir()
|
|
486
557
|
artifact_path = MlflowClient().download_artifacts(
|
|
487
558
|
run.info.run_id,
|
|
488
559
|
"plots/training_vs_evaluation_loss.png",
|
|
@@ -528,11 +599,22 @@ class TestSaveModel:
|
|
|
528
599
|
meta = config["model_metadata"]
|
|
529
600
|
assert meta["model_path"] == "bert-base-uncased"
|
|
530
601
|
assert meta["optimal_threshold"] == 0.42
|
|
602
|
+
assert meta["threshold_metric"] == "f1"
|
|
603
|
+
assert meta["threshold_beta"] == 1.0
|
|
531
604
|
assert meta["is_multilabel"] is False
|
|
532
605
|
assert meta["target_cols"] == ["target"]
|
|
533
606
|
assert meta["max_length"] == 512
|
|
534
607
|
assert config["parameters"] == clf.best_params
|
|
535
608
|
|
|
609
|
+
@pytest.mark.parametrize("metric, beta", [("balanced_accuracy", 1), ("f1", 2)])
|
|
610
|
+
def test_saves_threshold_settings(self, tmp_path, metric, beta):
|
|
611
|
+
clf = make_classifier(tmp_path, threshold_metric=metric, threshold_beta=beta)
|
|
612
|
+
clf.best_params = {"model": "bert-base"}
|
|
613
|
+
clf._save_model(str(tmp_path), MagicMock(), MagicMock(), "bert-base-uncased")
|
|
614
|
+
config = json.loads((tmp_path / "model" / "bertuner_config.json").read_text())
|
|
615
|
+
assert config["model_metadata"]["threshold_metric"] == metric
|
|
616
|
+
assert config["model_metadata"]["threshold_beta"] == beta
|
|
617
|
+
|
|
536
618
|
def test_multilabel_threshold_serialised_as_list(self, tmp_path):
|
|
537
619
|
cols = ["l1", "l2"]
|
|
538
620
|
clf = make_classifier(
|
|
@@ -785,7 +867,7 @@ class TestMulticlassWeightedLoss:
|
|
|
785
867
|
loss = trainer._singlelabel_loss(logits, labels, torch.device("cpu"))
|
|
786
868
|
assert torch.isfinite(loss)
|
|
787
869
|
|
|
788
|
-
def test_end_to_end_training_three_classes(self, tmp_path):
|
|
870
|
+
def test_end_to_end_training_three_classes(self, tmp_path, tiny_model_path):
|
|
789
871
|
import torch
|
|
790
872
|
from transformers import (
|
|
791
873
|
AutoModelForSequenceClassification,
|
|
@@ -795,7 +877,7 @@ class TestMulticlassWeightedLoss:
|
|
|
795
877
|
)
|
|
796
878
|
from bertuner.CustomTrainer import CustomTrainer
|
|
797
879
|
|
|
798
|
-
model_path =
|
|
880
|
+
model_path = tiny_model_path
|
|
799
881
|
clf = make_classifier(tmp_path, dataframe=make_df(n=60, num_classes=3))
|
|
800
882
|
assert clf.num_labels == 3
|
|
801
883
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Run unchanged against the oldest supported Transformers and current releases."""
|
|
2
|
+
import copy
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
import torch
|
|
8
|
+
from datasets import Dataset
|
|
9
|
+
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
|
|
10
|
+
from transformers import AutoTokenizer, TrainingArguments
|
|
11
|
+
|
|
12
|
+
from bertuner.CustomTrainer import CustomTrainer
|
|
13
|
+
from test_bertuner import make_classifier, make_df
|
|
14
|
+
from test_numerical_stability import sampled_params
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize("ratio", [0.0, 0.1, 1.0])
|
|
18
|
+
@pytest.mark.parametrize("final", [False, True])
|
|
19
|
+
def test_warmup_and_scheduler(tmp_path, ratio, final):
|
|
20
|
+
clf = make_classifier(tmp_path)
|
|
21
|
+
clf.optimize_metric = "avg_precision"
|
|
22
|
+
clf.greater_is_better = True
|
|
23
|
+
params = dict(sampled_params(), warmup_ratio=ratio, scheduler="cosine")
|
|
24
|
+
args = clf._build_training_arguments(
|
|
25
|
+
params, str(tmp_path / "output"), 32, "fp32", final=final,
|
|
26
|
+
logging_dir=str(tmp_path / "logs"),
|
|
27
|
+
)
|
|
28
|
+
assert args.get_warmup_steps(100) == int(100 * ratio)
|
|
29
|
+
assert args.lr_scheduler_type == "cosine"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_final_training_logs_and_reloads(tmp_path, tiny_model_path):
|
|
33
|
+
clf = make_classifier(tmp_path, dataframe=make_df(n=30, num_classes=3))
|
|
34
|
+
clf.optimize_metric = "f1"
|
|
35
|
+
clf.greater_is_better = True
|
|
36
|
+
tokenizer = AutoTokenizer.from_pretrained(tiny_model_path)
|
|
37
|
+
train, val, _ = clf._prepare_datasets(tokenizer, None, max_length=32)
|
|
38
|
+
params = sampled_params()
|
|
39
|
+
log_dir = str(tmp_path / "logs")
|
|
40
|
+
args = clf._build_training_arguments(
|
|
41
|
+
params, str(tmp_path / "output"), 32, "fp32", final=True, logging_dir=log_dir,
|
|
42
|
+
)
|
|
43
|
+
args.num_train_epochs = 1
|
|
44
|
+
args.use_cpu = True
|
|
45
|
+
trainer = clf._build_trainer(
|
|
46
|
+
clf._load_model(tiny_model_path, 0.0), args, train, val, tokenizer,
|
|
47
|
+
params, clf._compute_class_weights(train), "fp32", final=True,
|
|
48
|
+
logging_dir=log_dir,
|
|
49
|
+
)
|
|
50
|
+
assert np.isfinite(trainer.train().training_loss)
|
|
51
|
+
assert trainer.state.best_model_checkpoint is not None
|
|
52
|
+
assert np.isfinite(trainer.evaluate()["eval_loss"])
|
|
53
|
+
events = EventAccumulator(log_dir).Reload()
|
|
54
|
+
assert "train/loss" in events.Tags()["scalars"]
|
|
55
|
+
assert "eval/loss" in events.Tags()["scalars"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class KwargsClassifier(torch.nn.Module):
|
|
59
|
+
def __init__(self):
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.linear = torch.nn.Linear(2, 2)
|
|
62
|
+
self.accepts_loss_kwargs = True
|
|
63
|
+
|
|
64
|
+
def forward(self, features, labels=None, **kwargs):
|
|
65
|
+
return SimpleNamespace(logits=self.linear(features))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_accumulation_matches_full_batch_update(tmp_path):
|
|
69
|
+
torch.manual_seed(42)
|
|
70
|
+
initial = KwargsClassifier()
|
|
71
|
+
dataset = Dataset.from_dict({"features": [[1., 2.]] * 4, "labels": [1] * 4})
|
|
72
|
+
models = []
|
|
73
|
+
for batch, accumulation in [(4, 1), (2, 2)]:
|
|
74
|
+
model = copy.deepcopy(initial)
|
|
75
|
+
args = TrainingArguments(
|
|
76
|
+
output_dir=str(tmp_path / str(batch)), use_cpu=True,
|
|
77
|
+
per_device_train_batch_size=batch, gradient_accumulation_steps=accumulation,
|
|
78
|
+
max_steps=1, max_grad_norm=0., report_to="none", save_strategy="no",
|
|
79
|
+
lr_scheduler_type="constant", disable_tqdm=True,
|
|
80
|
+
)
|
|
81
|
+
trainer = CustomTrainer(
|
|
82
|
+
model=model, args=args, train_dataset=dataset, loss_type="plain",
|
|
83
|
+
optimizers=(torch.optim.SGD(model.parameters(), lr=0.1), None),
|
|
84
|
+
)
|
|
85
|
+
trainer.train()
|
|
86
|
+
models.append(model)
|
|
87
|
+
for full, accumulated in zip(models[0].parameters(), models[1].parameters()):
|
|
88
|
+
torch.testing.assert_close(full, accumulated)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|