ludwig-ai--ludwig
593b94c120
pytest / Unit Tests (push) Has been cancelled
pytest / Integration (integration_tests_a) (push) Has been cancelled
pytest / Integration (integration_tests_b) (push) Has been cancelled
pytest / Integration (integration_tests_c) (push) Has been cancelled
pytest / Integration (integration_tests_d) (push) Has been cancelled
pytest / Integration (integration_tests_e) (push) Has been cancelled
pytest / Integration (integration_tests_f) (push) Has been cancelled
pytest / Integration (integration_tests_g) (push) Has been cancelled
pytest / Integration (integration_tests_h) (push) Has been cancelled
pytest / Integration (integration_tests_i) (push) Has been cancelled
pytest / Integration (integration_tests_j) (push) Has been cancelled
pytest / Distributed (distributed_a) (push) Has been cancelled
pytest / Distributed (distributed_b) (push) Has been cancelled
pytest / Distributed (distributed_c) (push) Has been cancelled
pytest / Distributed (distributed_d) (push) Has been cancelled
pytest / Distributed (distributed_e) (push) Has been cancelled
pytest / Distributed (distributed_f) (push) Has been cancelled
pytest / Minimal Install (push) Has been cancelled
pytest / Event File (push) Has been cancelled
pytest (slow) / py-slow (push) Has been cancelled
Publish JSON Schema / publish-schema (push) Has been cancelled
322 行
14 KiB
Python
322 行
14 KiB
Python
#! /usr/bin/env python
|
|
# Copyright (c) 2022 Predibase, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ==============================================================================
|
|
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn as nn
|
|
|
|
from ludwig.api_annotations import DeveloperAPI
|
|
from ludwig.constants import BINARY, CATEGORY
|
|
from ludwig.utils.registry import DEFAULT_KEYS, Registry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
calibration_registry = Registry()
|
|
|
|
|
|
@DeveloperAPI
|
|
def register_calibration(name: str, features: str | list[str], default=False):
|
|
"""Registers a calibration implementation for a list of features."""
|
|
if isinstance(features, str):
|
|
features = [features]
|
|
|
|
def wrap(cls):
|
|
for feature in features:
|
|
feature_registry = calibration_registry.get(feature, {})
|
|
feature_registry[name] = cls
|
|
if default:
|
|
for key in DEFAULT_KEYS:
|
|
feature_registry[key] = cls
|
|
calibration_registry[feature] = feature_registry
|
|
return cls
|
|
|
|
return wrap
|
|
|
|
|
|
@DeveloperAPI
|
|
def get_calibration_cls(feature: str, calibration_method: str) -> type["CalibrationModule"]:
|
|
"""Get calibration class for specified feature type and calibration method."""
|
|
if not calibration_method:
|
|
return None
|
|
if feature in calibration_registry:
|
|
if calibration_method in calibration_registry[feature]:
|
|
return calibration_registry[feature][calibration_method]
|
|
else:
|
|
raise ValueError(f"Calibration method {calibration_method} not supported for {feature} output features")
|
|
else:
|
|
raise ValueError(f"Calibration not yet supported for {feature} output features")
|
|
return None
|
|
|
|
|
|
@DeveloperAPI
|
|
class ECELoss(nn.Module):
|
|
"""Calculates the Expected Calibration Error of a model.
|
|
|
|
The input to this loss is the logits of a model, NOT the softmax scores.
|
|
This divides the confidence outputs into equally-sized interval bins.
|
|
In each bin, we compute the confidence gap:
|
|
|
|
bin_gap = | avg_confidence_in_bin - accuracy_in_bin |
|
|
|
|
We then return an average of the gaps, weighted by the number of samples in each bin.
|
|
|
|
References:
|
|
Naeini, Mahdi Pakdaman, Gregory F. Cooper, and Milos Hauskrecht
|
|
"Obtaining Well Calibrated Probabilities Using Bayesian Binning." AAAI. 2015.
|
|
|
|
Chuan Guo, Geoff Pleiss, Yu Sun, Kilian Q. Weinberger
|
|
"On Calibration of Modern Neural Networks." PMLR 2017.
|
|
"""
|
|
|
|
def __init__(self, n_bins: int = 15):
|
|
"""n_bins (int): number of confidence interval bins."""
|
|
super().__init__()
|
|
bin_boundaries = torch.linspace(0, 1, n_bins + 1)
|
|
self.bin_lowers = bin_boundaries[:-1]
|
|
self.bin_uppers = bin_boundaries[1:]
|
|
|
|
def forward(self, logits: torch.Tensor, one_hot_labels: torch.Tensor) -> torch.Tensor:
|
|
softmaxes = nn.functional.softmax(logits, dim=1)
|
|
confidences, predictions = torch.max(softmaxes, 1)
|
|
labels = torch.argmax(one_hot_labels, 1)
|
|
accuracies = predictions.eq(labels)
|
|
ece = torch.zeros(1, device=logits.device)
|
|
for bin_lower, bin_upper in zip(self.bin_lowers, self.bin_uppers):
|
|
# Calculates |confidence - accuracy| in each bin
|
|
in_bin = confidences.gt(bin_lower.item()) * confidences.le(bin_upper.item())
|
|
prop_in_bin = in_bin.float().mean()
|
|
if prop_in_bin.item() > 0:
|
|
accuracy_in_bin = accuracies[in_bin].float().mean()
|
|
avg_confidence_in_bin = confidences[in_bin].mean()
|
|
ece += torch.abs(avg_confidence_in_bin - accuracy_in_bin) * prop_in_bin
|
|
return ece
|
|
|
|
|
|
@DeveloperAPI
|
|
@dataclass
|
|
class CalibrationResult:
|
|
"""Tracks results of probability calibration."""
|
|
|
|
before_calibration_nll: float
|
|
before_calibration_ece: float
|
|
after_calibration_nll: float
|
|
after_calibration_ece: float
|
|
|
|
|
|
@DeveloperAPI
|
|
class CalibrationModule(nn.Module, ABC):
|
|
@abstractmethod
|
|
def train_calibration(
|
|
self, logits: torch.Tensor | np.ndarray, labels: torch.Tensor | np.ndarray
|
|
) -> CalibrationResult:
|
|
"""Calibrate output probabilities using logits and labels from validation set."""
|
|
return NotImplementedError()
|
|
|
|
|
|
@DeveloperAPI
|
|
@register_calibration("temperature_scaling", [BINARY, CATEGORY], default=True)
|
|
class TemperatureScaling(CalibrationModule):
|
|
"""Implements temperature scaling of logits. Based on results from "On Calibration of Modern Neural Networks":
|
|
https://arxiv.org/abs/1706.04599. Temperature scaling scales all logits by the same constant factor. Though it
|
|
may modify output probabilities it will never change argmax or categorical top-n predictions. In the case of
|
|
binary classification with a threshold, however, calibration may change predictions.
|
|
|
|
Implementation inspired by https://github.com/gpleiss/temperature_scaling
|
|
|
|
Args:
|
|
num_classes: The number of classes. Must be 2 if binary is True.
|
|
binary: If binary is true, logits is expected to be a 1-dimensional array. If false, logits is a 2-dimensional
|
|
array of shape (num_examples, num_classes).
|
|
"""
|
|
|
|
def __init__(self, num_classes: int = 2, binary: bool = False):
|
|
super().__init__()
|
|
self.num_classes = 2 if binary else num_classes
|
|
self.binary = binary
|
|
self.device = "cuda" if torch.cuda.is_available() and torch.cuda.device_count() > 0 else "cpu"
|
|
self.temperature = nn.Parameter(torch.ones(1), requires_grad=False).to(self.device)
|
|
|
|
def train_calibration(
|
|
self, logits: torch.Tensor | np.ndarray, labels: torch.Tensor | np.ndarray
|
|
) -> CalibrationResult:
|
|
logits = torch.as_tensor(logits, dtype=torch.float32, device=self.device)
|
|
labels = torch.as_tensor(labels, dtype=torch.int64, device=self.device)
|
|
one_hot_labels = nn.functional.one_hot(labels, self.num_classes).float()
|
|
if self.binary:
|
|
# Treat binary classification as multi-class with 2 classes to re-use code.
|
|
# The math works out the same: softmax([0, a])[1] == sigmoid(a)
|
|
logits = torch.stack([torch.zeros_like(logits), logits], axis=-1)
|
|
nll_criterion = nn.CrossEntropyLoss().to(self.device)
|
|
ece_criterion = ECELoss().to(self.device)
|
|
# Saves the original temperature parameter, in case something goes wrong in optimization.
|
|
original_temperature = self.temperature.clone().detach()
|
|
self.temperature.requires_grad = True
|
|
# Calculate NLL and ECE before temperature scaling
|
|
before_calibration_nll = nll_criterion(logits, one_hot_labels).item()
|
|
before_calibration_ece = ece_criterion(logits, one_hot_labels).item()
|
|
logger.info(
|
|
"Before temperature scaling:\n"
|
|
f" Negative log-likelihood: {before_calibration_nll:.3f}\n"
|
|
f" Expected Calibration Error: {before_calibration_ece:.3f}"
|
|
)
|
|
|
|
# Optimizes the temperature to minimize NLL
|
|
optimizer = torch.optim.LBFGS([self.temperature], lr=0.01, max_iter=50, line_search_fn="strong_wolfe")
|
|
|
|
def eval():
|
|
optimizer.zero_grad()
|
|
loss = nll_criterion(self.scale_logits(logits), one_hot_labels)
|
|
loss.backward()
|
|
return loss
|
|
|
|
optimizer.step(eval)
|
|
|
|
# Calculate NLL and ECE after temperature scaling
|
|
after_calibration_nll = nll_criterion(self.scale_logits(logits), one_hot_labels).item()
|
|
after_calibration_ece = ece_criterion(self.scale_logits(logits), one_hot_labels).item()
|
|
logger.info(f"Optimal temperature: {self.temperature.item():.3f}")
|
|
logger.info(
|
|
"After temperature scaling:\n"
|
|
f" Negative log-likelihood: {after_calibration_nll:.3f}\n"
|
|
f" Expected Calibration Error: {after_calibration_ece:.3f}"
|
|
)
|
|
self.temperature.requires_grad = False
|
|
# This should never happen, but if expected calibration error is higher after optimizing temperature, revert.
|
|
if after_calibration_ece > before_calibration_ece:
|
|
logger.warning(
|
|
"Expected calibration error higher after scaling, "
|
|
f"reverting to temperature={original_temperature.item():.3f}."
|
|
)
|
|
with torch.no_grad():
|
|
self.temperature.data = original_temperature.data
|
|
return CalibrationResult(
|
|
before_calibration_nll, before_calibration_ece, after_calibration_nll, after_calibration_ece
|
|
)
|
|
|
|
def scale_logits(self, logits: torch.Tensor) -> torch.Tensor:
|
|
return torch.div(logits, self.temperature)
|
|
|
|
def forward(self, logits: torch.Tensor) -> torch.Tensor:
|
|
"""Converts logits to probabilities."""
|
|
scaled_logits = self.scale_logits(logits)
|
|
if self.binary:
|
|
return torch.sigmoid(scaled_logits)
|
|
else:
|
|
return torch.softmax(scaled_logits, -1)
|
|
|
|
|
|
@DeveloperAPI
|
|
@register_calibration("matrix_scaling", CATEGORY, default=False)
|
|
class MatrixScaling(CalibrationModule):
|
|
"""Implements matrix scaling of logits, as described in Beyond temperature scaling: Obtaining well-calibrated
|
|
multiclass probabilities with Dirichlet calibration https://arxiv.org/abs/1910.12656.
|
|
|
|
Unlike temperature scaling which has only one free parameter, matrix scaling has n_classes x (n_classes + 1)
|
|
parameters. Use this only with a large validation set, as matrix scaling has a tendency to overfit small datasets.
|
|
Also, unlike temperature scaling, matrix scaling can change the argmax or top-n predictions.
|
|
|
|
NOTE: Matrix Scaling is not exposed in the UI or config yet, though it may be in a future release after testing.
|
|
|
|
Args:
|
|
num_classes: The number of classes.
|
|
off_diagonal_l2: The regularization weight for off-diagonal matrix entries.
|
|
mu: The regularization weight for bias vector. Defaults to off_diagonal_l2 if not specified.
|
|
"""
|
|
|
|
def __init__(self, num_classes: int = 2, off_diagonal_l2: float = 0.01, mu: float | None = None):
|
|
super().__init__()
|
|
self.num_classes = num_classes
|
|
self.device = "cuda" if torch.cuda.is_available() and torch.cuda.device_count() > 0 else "cpu"
|
|
self.w = nn.Parameter(torch.eye(self.num_classes), requires_grad=False).to(self.device)
|
|
self.b = nn.Parameter(torch.zeros(self.num_classes), requires_grad=False).to(self.device)
|
|
self.off_diagonal_l2 = off_diagonal_l2
|
|
self.mu = off_diagonal_l2 if mu is None else mu
|
|
|
|
def train_calibration(
|
|
self, logits: torch.Tensor | np.ndarray, labels: torch.Tensor | np.ndarray
|
|
) -> CalibrationResult:
|
|
logits = torch.as_tensor(logits, dtype=torch.float32, device=self.device)
|
|
labels = torch.as_tensor(labels, dtype=torch.int64, device=self.device)
|
|
one_hot_labels = nn.functional.one_hot(labels, self.num_classes).float()
|
|
nll_criterion = nn.CrossEntropyLoss().to(self.device)
|
|
ece_criterion = ECELoss().to(self.device)
|
|
self.w.requires_grad = True
|
|
self.b.requires_grad = True
|
|
# Calculate NLL and ECE before temperature scaling
|
|
before_calibration_nll = nll_criterion(logits, one_hot_labels).item()
|
|
before_calibration_ece = ece_criterion(logits, one_hot_labels).item()
|
|
logger.info(
|
|
"Before matrix scaling:\n"
|
|
f" Negative log-likelihood: {before_calibration_nll:.3f}\n"
|
|
f" Expected Calibration Error: {before_calibration_ece:.3f}"
|
|
)
|
|
|
|
# Optimizes the linear transform to minimize NLL
|
|
optimizer = torch.optim.LBFGS([self.w, self.b], lr=0.001, max_iter=200, line_search_fn="strong_wolfe")
|
|
|
|
def eval():
|
|
optimizer.zero_grad()
|
|
loss = nll_criterion(self.scale_logits(logits), one_hot_labels) + self.regularization_terms()
|
|
loss.backward()
|
|
return loss
|
|
|
|
optimizer.step(eval)
|
|
|
|
# Calculate NLL and ECE after matrix scaling
|
|
after_calibration_nll = nll_criterion(self.scale_logits(logits), one_hot_labels).item()
|
|
after_calibration_ece = ece_criterion(self.scale_logits(logits), one_hot_labels).item()
|
|
logger.info(
|
|
"After matrix scaling:\n"
|
|
f" Negative log-likelihood: {after_calibration_nll:.3f}\n"
|
|
f" Expected Calibration Error: {after_calibration_ece:.3f}"
|
|
)
|
|
self.w.requires_grad = False
|
|
self.b.requires_grad = False
|
|
# This should never happen, but if expected calibration error is higher after optimizing matrix, revert.
|
|
if after_calibration_ece > before_calibration_ece:
|
|
logger.warning("Expected calibration error higher after matrix scaling, reverting to identity.")
|
|
with torch.no_grad():
|
|
self.w.data = torch.eye(self.num_classes)
|
|
self.b.data = torch.zeros(self.num_classes)
|
|
return CalibrationResult(
|
|
before_calibration_nll, before_calibration_ece, after_calibration_nll, after_calibration_ece
|
|
)
|
|
|
|
def regularization_terms(self) -> torch.Tensor:
|
|
"""Off-Diagonal and Intercept Regularisation (ODIR).
|
|
|
|
Described in "Beyond temperature scaling: Obtaining well-calibrated multiclass probabilities with Dirichlet
|
|
calibration"
|
|
https://proceedings.neurips.cc/paper/2019/file/8ca01ea920679a0fe3728441494041b9-Paper.pdf
|
|
"""
|
|
off_diagonal_entries = torch.masked_select(
|
|
self.w, ~torch.eye(self.num_classes, dtype=bool, device=self.w.device)
|
|
)
|
|
weight_matrix_loss = self.off_diagonal_l2 * torch.linalg.vector_norm(off_diagonal_entries)
|
|
bias_vector_loss = self.mu * torch.linalg.vector_norm(self.b, 2)
|
|
return bias_vector_loss + weight_matrix_loss
|
|
|
|
def scale_logits(self, logits: torch.Tensor) -> torch.Tensor:
|
|
return torch.matmul(self.w, logits.T).T + self.b
|
|
|
|
def forward(self, logits: torch.Tensor) -> torch.Tensor:
|
|
"""Converts logits to probabilities."""
|
|
return torch.softmax(self.scale_logits(logits), -1)
|