项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:49:20 +08:00

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)