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.
Files changed (24) hide show
  1. {bertuner-0.2.2 → bertuner-0.2.4}/PKG-INFO +19 -1
  2. {bertuner-0.2.2 → bertuner-0.2.4}/README.md +18 -0
  3. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/BERTuner.py +96 -39
  4. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/CustomTrainer.py +3 -0
  5. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/__init__.py +1 -1
  6. bertuner-0.2.4/bertuner/compat.py +15 -0
  7. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/PKG-INFO +19 -1
  8. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/SOURCES.txt +2 -0
  9. {bertuner-0.2.2 → bertuner-0.2.4}/pyproject.toml +1 -1
  10. {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_bertuner.py +84 -2
  11. bertuner-0.2.4/tests/test_transformers_compat.py +88 -0
  12. {bertuner-0.2.2 → bertuner-0.2.4}/LICENSE +0 -0
  13. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/Predictor.py +0 -0
  14. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/TensorBoardCallback.py +0 -0
  15. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/constants.py +0 -0
  16. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/exceptions.py +0 -0
  17. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner/utils.py +0 -0
  18. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/dependency_links.txt +0 -0
  19. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/requires.txt +0 -0
  20. {bertuner-0.2.2 → bertuner-0.2.4}/bertuner.egg-info/top_level.txt +0 -0
  21. {bertuner-0.2.2 → bertuner-0.2.4}/setup.cfg +0 -0
  22. {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_numerical_stability.py +0 -0
  23. {bertuner-0.2.2 → bertuner-0.2.4}/tests/test_predictor.py +0 -0
  24. {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.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
- if final:
764
- # Canonical final metrics are logged explicitly after restoring the
765
- # best checkpoint; Trainer only sends loss curves to TensorBoard.
766
- kwargs.update(report_to=["tensorboard"], logging_dir=logging_dir)
767
- else:
768
- kwargs.update(
769
- lr_scheduler_type=params["scheduler"],
770
- remove_unused_columns=True,
771
- report_to=["none"],
772
- )
773
- return TrainingArguments(**kwargs)
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 (maximises F1).
1235
+ Binary → one scalar threshold.
1212
1236
  Multiclass → None (predictions are argmax; thresholds don't apply).
1213
- Multi-label → one threshold per label (maximises macro-F1);
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
- # Optimise each label independently
1224
- best_thresholds = np.full(self.num_labels, 0.5)
1225
- for i in range(self.num_labels):
1226
- best_f1, best_t = 0.0, 0.5
1227
- for thresh in np.linspace(0.1, 0.9, 81):
1228
- f1 = f1_score(
1229
- labels[:, i],
1230
- (probs[:, i] >= thresh).astype(int),
1231
- zero_division=0,
1232
- )
1233
- if f1 > best_f1:
1234
- best_f1, best_t = f1, thresh
1235
- best_thresholds[i] = best_t
1236
- return best_thresholds
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
- best_f1, best_t = 0.0, 0.5
1239
- for thresh in np.linspace(0.1, 0.9, 81):
1240
- f1 = f1_score(
1241
- labels,
1242
- (probs >= thresh).astype(int),
1243
- zero_division=0,
1244
- )
1245
- if f1 > best_f1:
1246
- best_f1, best_t = f1, thresh
1247
- return best_t
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
@@ -1,6 +1,6 @@
1
1
  """bertuner: hyperparameter optimization and fine-tuning for BERT-style text classifiers."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
4
4
 
5
5
  from bertuner.BERTuner import BERTuneClassifier
6
6
  from bertuner.Predictor import BERTunePredictor
@@ -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.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.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 = "prajjwal1/bert-tiny"
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