"""Sentiment probability calibration. Applies isotonic or Platt calibration to raw FinBERT probabilities to produce better-calibrated confidence estimates. The calibrator preserves probability ordering (monotonicity for isotonic) while improving expected calibration error (ECE). """ from __future__ import annotations import logging from typing import Literal import numpy as np logger = logging.getLogger(__name__) # Default calibration version when no artifact is loaded DEFAULT_CALIBRATION_VERSION = "uncalibrated" class SentimentCalibrator: """Calibrates raw sentiment probabilities using isotonic or Platt scaling. The calibrator fits on a held-out calibration set from the Gold_Corpus and transforms raw model probabilities to better-calibrated values. Parameters ---------- method Calibration method: "isotonic" or "platt". """ def __init__(self, method: Literal["isotonic", "platt"] = "isotonic") -> None: self._method = method self._calibration_version = DEFAULT_CALIBRATION_VERSION self._fitted = False self._calibrators: list | None = None # One per class @property def calibration_version(self) -> str: """Return the current calibration artifact version.""" return self._calibration_version @property def is_fitted(self) -> bool: """Return whether the calibrator has been fitted.""" return self._fitted @property def method(self) -> str: """Return the calibration method.""" return self._method def fit( self, raw_probs: list[list[float]], true_labels: list[int], version: str = "v1.0", ) -> None: """Fit the calibrator on a calibration dataset. Parameters ---------- raw_probs List of [positive, negative, neutral] probability vectors. true_labels True class labels: 0=positive, 1=negative, 2=neutral. version Version string for this calibration artifact. """ if not raw_probs or not true_labels: raise ValueError("raw_probs and true_labels must not be empty") if len(raw_probs) != len(true_labels): raise ValueError("raw_probs and true_labels must have the same length") raw_array = np.array(raw_probs, dtype=np.float64) labels_array = np.array(true_labels, dtype=np.int32) n_classes = raw_array.shape[1] if raw_array.ndim > 1 else 3 if self._method == "isotonic": self._fit_isotonic(raw_array, labels_array, n_classes) else: self._fit_platt(raw_array, labels_array, n_classes) self._calibration_version = version self._fitted = True logger.info( "Calibrator fitted: method=%s, samples=%d, version=%s", self._method, len(true_labels), version, ) def calibrate(self, raw_probs: list[float]) -> list[float]: """Calibrate a single probability vector. Parameters ---------- raw_probs Raw [positive, negative, neutral] probabilities. Returns ------- list[float] Calibrated probabilities that sum to 1.0 and preserve relative ordering within each class. """ if not self._fitted: # Pass through uncalibrated return list(raw_probs) calibrated = [] for i, prob in enumerate(raw_probs): if self._calibrators and i < len(self._calibrators): cal = self._calibrators[i] cal_prob = float(cal.predict(np.array([[prob]]))[0]) # Clamp to [0, 1] cal_prob = max(0.0, min(1.0, cal_prob)) calibrated.append(cal_prob) else: calibrated.append(prob) # Normalize to sum to 1.0 total = sum(calibrated) if total > 0: calibrated = [p / total for p in calibrated] else: calibrated = [1.0 / len(calibrated)] * len(calibrated) return calibrated def calibrate_batch(self, raw_probs_batch: list[list[float]]) -> list[list[float]]: """Calibrate a batch of probability vectors. Parameters ---------- raw_probs_batch List of raw [positive, negative, neutral] probability vectors. Returns ------- list[list[float]] Calibrated probability vectors. """ return [self.calibrate(probs) for probs in raw_probs_batch] def _fit_isotonic( self, raw_array: np.ndarray, labels_array: np.ndarray, n_classes: int, ) -> None: """Fit isotonic regression calibrators per class.""" from sklearn.isotonic import IsotonicRegression self._calibrators = [] for cls_idx in range(n_classes): # Binary indicator: is this the true class? binary_labels = (labels_array == cls_idx).astype(np.float64) class_probs = raw_array[:, cls_idx] iso = IsotonicRegression(y_min=0.0, y_max=1.0, out_of_bounds="clip") iso.fit(class_probs, binary_labels) self._calibrators.append(iso) def _fit_platt( self, raw_array: np.ndarray, labels_array: np.ndarray, n_classes: int, ) -> None: """Fit Platt (logistic) scaling calibrators per class.""" from sklearn.linear_model import LogisticRegression self._calibrators = [] for cls_idx in range(n_classes): binary_labels = (labels_array == cls_idx).astype(np.int32) class_probs = raw_array[:, cls_idx].reshape(-1, 1) lr = LogisticRegression(solver="lbfgs", max_iter=1000) # Need at least 2 classes in binary labels if len(np.unique(binary_labels)) < 2: # If only one class present, use identity self._calibrators.append(_IdentityCalibrator()) else: lr.fit(class_probs, binary_labels) self._calibrators.append(_PlattWrapper(lr)) class _IdentityCalibrator: """Pass-through calibrator when insufficient data for fitting.""" def predict(self, x: np.ndarray) -> np.ndarray: return x.ravel() class _PlattWrapper: """Wrapper that extracts probability of the positive class.""" def __init__(self, lr) -> None: # noqa: ANN001 self._lr = lr def predict(self, x: np.ndarray) -> np.ndarray: return self._lr.predict_proba(x)[:, 1]