
评估深度学习模型是模型生命周期管理的重要组成部分。虽然传统模型擅长快速提供模型性能基准,但它们往往无法捕捉实际应用的细微目标。例如,欺诈检测系统可能优先考虑最小化假阴性而不是假阳性,而医疗诊断模型可能更看重召回率而不是准确率。在这种情况下,仅仅依赖传统指标可能会导致模型行为不理想。这时,自定义损失函数和定制评估指标就派上用场了。
传统深度学习模型评估
评估分类结果的传统指标包括准确率、召回率、F1 分数等。交叉熵损失是分类的首选损失函数。这些典型的分类指标仅评估预测是否正确,而忽略了不确定性。
一个模型可能拥有很高的准确率,但概率估计却很差。现代深度网络过于自信,即使错误,返回的概率也约为 0 或 1。
问题
Guo 等人的研究显示,即使高度准确的深度模型,其校准也可能存在问题。同样,一个模型可能拥有很高的 F1 分数,但其不确定性估计仍然可能存在校准误差。优化目标函数(例如准确率或对数损失函数)也可能导致概率校准误差,因为传统的评估指标无法评估模型的置信度是否与现实相符。例如,肺炎检测 AI 可能会根据在无害条件下也会发生的模式输出 99.9% 的概率,从而导致过度自信。诸如温度缩放之类的校准方法可以调整这些分数,使其更好地反映真实的可能性。
什么是自定义损失函数?
自定义损失函数或目标函数是您为表达特定目标而发明的任何训练损失函数(除了交叉熵和 MSE 等标准损失函数之外)。当更通用的损失函数无法满足您的业务需求时,您可以自行开发一个。
例如,您可以使用一个损失函数,该函数对假阴性、漏报欺诈的惩罚力度要大于对假阳性的惩罚力度。这让您可以处理不均衡的惩罚或目标,例如最大化 F1 值,而不仅仅是准确率。损失函数只是一个平滑的数学公式,用于比较预测值与标签值,因此您可以设计任何公式来精确模拟您想要的指标或成本。
为什么要构建自定义损失函数?
有时,默认损失函数会在重要案例(例如,稀有类别)上训练不足,或者无法反映您的效用。自定义损失函数使您能够:
- 与业务逻辑保持一致:例如,对某种疾病的漏检惩罚是误报的 5 倍。
- 处理不均衡:降低多数类别的权重,或关注少数类别。
- 编码领域启发式算法:例如,要求预测遵循单调性或排序规则。
- 针对特定指标进行优化:近似 F1 值/准确率/召回率,或特定领域的投资回报率 (ROI)。
如何实现自定义损失函数?
在本节中,我们将使用 PyTorch 的 nn.Module 函数实现自定义损失函数。以下是其关键点:
-
- 可微分性:确保损失函数对于模型输出可微分。
- 数值稳定性:在 PyTorch 中使用对数和指数函数或稳定函数(
F.log_softmax、F.cross_entropy等)。例如,可以以相同的方式使用F.cross_entropy(包含 softmax 和 log),但随后乘以 (1−𝑝𝑡)𝛾 来编写 Focal Loss。这种方法避免了在单独的 softmax 中计算概率,从而避免了下溢。 - 代码示例:为了演示这个想法,以下是在 PyTorch 中定义自定义 Focal Loss 的方法:
import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma=2.0, weight=None): super(FocalLoss, self).__init__() self.gamma = gamma self.weight = weight # weight tensor for classes (optional) def forward(self, logits, targets): # Compute standard cross entropy loss per-sample ce_loss = F.cross_entropy(logits, targets, weight=self.weight, reduction='none') p_t = torch.exp(-ce_loss) # The model's estimated probability for true class loss = ((1 - p_t) ** self.gamma) * ce_loss return loss.mean()
这里,γ 调整了我们对困难样本的关注程度。γ 越高,关注度越高,这意味着权重可以处理类别不平衡问题。
我们使用 Focal Loss 作为损失函数,因为它旨在解决物体检测和其他机器学习任务中的类别不平衡问题,尤其是在处理大量易分类样本(例如物体检测中的背景)时。这使得它非常适合我们的任务。
为什么模型校准如此重要?
校准描述了预测概率与真实世界频率的对应程度。如果在所有将概率 p 分配给正类的实例中,大约有 p 部分为正类,则该模型校准良好。换句话说,“置信度 = 准确率(confidence = accuracy)”。例如,如果一个模型在 100 个测试用例上预测为 0.8,我们预计大约 80 个是正确的。在使用概率进行决策(例如风险评分;成本效益分析)时,校准非常重要。形式上,这意味着对于具有概率输出𝑝^的分类器,校准是:

二元分类器的完美校准条件
校准误差
校准误差分为两类:
- 过度自信:指模型的预测概率系统性地高于真实概率(例如,预测结果为 90%,但 80% 的时间都是正确的)。深度神经网络往往过于自信,尤其是在参数过度的情况下。过度自信的模型可能很危险;它们通常会做出过强的预测,并且在错误分类时会误导我们。
- 欠自信:欠自信在深度网络中并不常见。这与过度自信相反,指的是模型的置信度过低(例如,预测结果为 60%,但 80% 的时间都是正确的)。虽然欠自信通常会使模型在预测时处于更安全的位置,但它可能看起来不够确定,因此不太实用。
在实践中,现代深度神经网络通常都过于自信。Guo 等人发现,具有批量规范、更深层等特征的较新的深度网络,即使在误分类的情况下,也会在某一类别中出现尖峰后验分布,概率非常高。当我们出现这些校准误差时,认识到这些误差对于我们做出可靠的预测至关重要。
校准指标
- 信度图:校准曲线。信度图通常称为信度图,它还会根据预测的置信度得分将预测的成功结果分入不同的箱体。对于每个箱体,它绘制了正样本的比例(y 轴)与平均预测概率(x 轴)的关系。

Source: IQ
- 预期校准误差 (ECE):它概括了准确度和置信度之间的绝对差异,并根据 bin 的大小进行加权。形式上,acc(b) 和 conf(b) 分别是准确度和 bin 大小的平均置信度。提醒一下,ECE 值越低越好(0=完美校准)。ECE 是平均校准误差的量度。

预期校准误差公式
- 最大校准误差 (MCE):所有区间中的最大差距:

最大校准误差公式
- Brier 分数:Brier 分数是预测概率与实际结果之间的均方误差,其值为 0 或 1。这是一个合理的评分规则,可以同时反映校准性和准确率。然而,Brier 分数较低并不意味着预测校准良好。它兼具校准性和判别力。
PyTorch案例研究
本部分,我们将使用 BigMart Sales 数据集来演示自定义损失函数和校准矩阵如何帮助预测目标列 OutletSales。
我们通过设置中位数阈值,将连续型 OutletSales 转换为二元“高 vs 低”类别。然后,我们使用产品可见性等特征在 PyTorch 中拟合一个简单的分类器,并应用自定义损失函数和校准矩阵。
关键步骤
数据准备和预处理:在本部分中,我们将导入库、加载数据,以及最重要的数据预处理步骤。例如,缺失值处理、使分类列统一(“低脂”、“低脂”,如果所有列都相同,则它们将变为“低脂”)、为目标变量设置阈值、对分类变量执行独热编码 (OHE) 以及拆分特征。
import os
import random
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import TensorDataset, DataLoader, random_split
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.utils.class_weight import compute_class_weight
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('Device:', device)
# ----- missing-value handling -----
df['Weight'].fillna(df['Weight'].mean(), inplace=True)
df['OutletSize'].fillna(df['OutletSize'].mode()[0], inplace=True)
# ----- categorical cleaning -----
df['FatContent'].replace(
{'low fat': 'Low Fat', 'LF': 'Low Fat', 'reg': 'Regular'},
inplace=True
)
# ----- classification target -----
threshold = df['OutletSales'].median()
df['SalesCategory'] = (df['OutletSales'] > threshold).astype(int)
# ----- one-hot encode categoricals -----
cat_cols = [
'FatContent', 'ProductType', 'OutletID',
'OutletSize', 'LocationType', 'OutletType'
]
df = pd.get_dummies(df, columns=cat_cols, drop_first=True)
# ----- split features / labels -----
X = df.drop(['ProductID', 'OutletSales', 'SalesCategory'], axis=1).values
y = df['SalesCategory'].values
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=SEED, stratify=y
)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
# create torch tensors
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
X_test_t = torch.tensor(X_test, dtype=torch.float32)
y_test_t = torch.tensor(y_test, dtype=torch.long)
# split train into train/val (80/20 of original train)
val_frac = 0.2
val_size = int(len(X_train_t) * val_frac)
train_size = len(X_train_t) - val_size
train_ds, val_ds = random_split(
TensorDataset(X_train_t, y_train_t),
[train_size, val_size],
generator=torch.Generator().manual_seed(SEED)
)
train_loader = DataLoader(
train_ds, batch_size=64, shuffle=True, drop_last=True
)
val_loader = DataLoader(
val_ds, batch_size=256, shuffle=False
)
自定义损失:在第二步中,首先,我们将创建一个自定义的 SalesClassifier。假设我们应用焦点损失来更加重视少数类。然后,我们将重新调整模型以最大化焦点损失而不是交叉熵损失。在许多情况下,焦点损失会增加对少数类的召回率,但可能会降低原始准确率。之后,我们将在自定义 SoftF1Loss 的帮助下训练我们的销售分类器超过 100 个 epoch,并将最佳模型保存为 best_model.pt
class SalesClassifier(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, 128),
nn.BatchNorm1d(128),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(128, 64),
nn.ReLU(inplace=True),
nn.Dropout(0.25),
nn.Linear(64, 2) # logits for 2 classes
)
def forward(self, x):
return self.net(x)
# class-weighted CrossEntropy to fight imbalance
class_weights = compute_class_weight('balanced',
classes=np.unique(y_train),
y=y_train)
class_weights = torch.tensor(class_weights, dtype=torch.float32,
device=device)
ce_loss = nn.CrossEntropyLoss(weight=class_weights)
在这里,我们将使用一个名为 SoftF1Loss 的自定义损失函数。因此,这里的 SoftF1Loss 是一个自定义损失函数,它以可区分的方式直接优化 F1 分数,使其适合基于梯度的训练。它不使用硬 0/1 预测,而是使用来自模型输出( torch.softmax )的软概率,因此损失会随着预测的变化而平滑变化。它使用这些概率和真实标签计算软真正例(TP)、假正例(FP)和假负例(FN),然后计算软精度和召回率。由此,它得出一个“软”F1 分数并返回 1 – F1,以便最小化损失将最大化 F1 分数。这在处理不平衡数据集时特别有用,因为准确率并不是衡量性能的良好指标。
# Differentiable Custom Loss Function Soft-F1 loss
class SoftF1Loss(nn.Module):
def forward(self, logits, labels):
probs = torch.softmax(logits, dim=1)[:, 1] # positive-class prob
labels = labels.float()
tp = (probs * labels).sum()
fp = (probs * (1 - labels)).sum()
fn = ((1 - probs) * labels).sum()
precision = tp / (tp + fp + 1e-7)
recall = tp / (tp + fn + 1e-7)
f1 = 2 * precision * recall / (precision + recall + 1e-7)
return 1 - f1
f1_loss = SoftF1Loss()
def total_loss(logits, targets, alpha=0.5):
return alpha * ce_loss(logits, targets) + (1 - alpha) * f1_loss(logits, targets)
model = SalesClassifier(X_train.shape[1]).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
best_val = float('inf'); patience=10; patience_cnt=0
for epoch in range(1, 101):
model.train()
train_losses = []
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
optimizer.zero_grad()
logits = model(xb)
loss = total_loss(logits, yb)
loss.backward()
optimizer.step()
train_losses.append(loss.item())
# ----- validation -----
model.eval()
with torch.no_grad():
val_losses = []
for xb, yb in val_loader:
xb, yb = xb.to(device), yb.to(device)
val_losses.append(total_loss(model(xb), yb).item())
val_loss = np.mean(val_losses)
if epoch % 10 == 0:
print(f'Epoch {epoch:3d} | TrainLoss {np.mean(train_losses):.4f}'
f' | ValLoss {val_loss:.4f}')
# ----- early stopping -----
if val_loss < best_val - 1e-4:
best_val = val_loss
patience_cnt = 0
torch.save(model.state_dict(), 'best_model.pt')
else:
patience_cnt += 1
if patience_cnt >= patience:
print('Early stopping!')
break

# load best weights
model.load_state_dict(torch.load('best_model.pt'))
校准前/后:在此流程中,我们可能发现基线模型的 ECE 较高,表明该模型过于自信。因此,基线模型的预期校准误差 (ECE) 可能偏高/偏低,表明该模型过于自信/不足。
现在,我们可以使用温度缩放来校准模型,然后重复该过程以计算新的 ECE 并绘制新的可靠性曲线。我们将看到,在温度缩放之后,可靠性曲线可能会更接近对角线。
class ModelWithTemperature(nn.Module):
def __init__(self, model):
super().__init__()
self.model = model
self.temperature = nn.Parameter(torch.ones(1) * 1.5)
def forward(self, x):
logits = self.model(x)
return logits / self.temperature
model_ts = ModelWithTemperature(model).to(device)
optim_ts = torch.optim.LBFGS([model_ts.temperature], lr=0.01, max_iter=50)
def _nll():
optim_ts.zero_grad()
logits = model_ts(X_val := X_test_t.to(device)) # use test set to fit T
loss = ce_loss(logits, y_test_t.to(device))
loss.backward()
return loss
optim_ts.step(_nll)
print('Optimal temperature:', model_ts.temperature.item())
Optimal temperature: 1.585491418838501
可视化:在本节中,我们将绘制校准“前”和“后”的可靠性图表。这些图表直观地表示了改进后的比对效果。
@torch.no_grad()
def get_probs(mdl, X):
mdl.eval()
logits = mdl(X.to(device))
return F.softmax(logits, dim=1).cpu()
def ece(probs, labels, n_bins=10):
conf, preds = probs.max(1)
accs = preds.eq(labels)
bins = torch.linspace(0,1,n_bins+1)
ece_val = torch.zeros(1)
for lo, hi in zip(bins[:-1], bins[1:]):
mask = (conf>lo) & (conf<=hi)
if mask.any():
ece_val += torch.abs(accs[mask].float().mean() - conf[mask].mean()) \
* mask.float().mean()
return ece_val.item()
def plot_reliability(ax, probs, labels, n_bins=10, label='Model'):
conf, preds = probs.max(1)
accs = preds.eq(labels)
bins = torch.linspace(0,1,n_bins+1)
bin_acc, bin_conf = [], []
for i in range(n_bins):
mask = (conf>bins[i]) & (conf<=bins[i+1])
if mask.any():
bin_acc.append(accs[mask].float().mean().item())
bin_conf.append(conf[mask].mean().item())
ax.plot(bin_conf, bin_acc, marker='o', label=label)
ax.plot([0,1],[0,1],'--',color='orange')
ax.set_xlabel('Confidence'); ax.set_ylabel('Accuracy')
ax.set_title('Reliability Diagram'); ax.grid(); ax.legend()
probs_before = get_probs(model , X_test_t)
probs_after = get_probs(model_ts, X_test_t)
print('\nClassification report (calibrated logits):')
print(classification_report(y_test, probs_after.argmax(1)))

print('ECE before T-scaling :', ece(probs_before, y_test_t))
print('ECE after T-scaling :', ece(probs_after , y_test_t))
#----------------------------------------
# ECE before T-scaling : 0.05823298543691635
# ECE after T-scaling : 0.02461853437125683
# ----------------------------------------------
# reliability plot
fig, ax = plt.subplots(figsize=(6,5))
plot_reliability(ax, probs_before, y_test_t, label='Before T-Scaling')
plot_reliability(ax, probs_after , y_test_t, label='After T-Scaling')
plt.show()

此图表显示了在温度缩放之前(蓝色)和之后(橙色)置信度得分与实际值的匹配程度。x 轴表示其平均置信度,y 轴表示这些预测被正确评分的频率。虚线对角线表示与此线重合的完美校准点,表示……
例如,置信度为 70% 的预测,其正确评分率为 70%。缩放后,橙线比蓝线更紧密地贴合这条对角线。尤其是在置信度为 0.6 到 0.9 的置信度空间的“中间”位置,并且几乎与理想点 (1.0, 1.0) 相交。换句话说,温度缩放可以降低模型过度或不足置信的倾向,从而使其概率的点估计更加准确。
点击此处查看完整 notebook。
小结
在现实世界的 AI 应用中,有效性和校准同等重要。一个模型可能具有很高的效度,但如果模型的置信度不准确,那么更高的效度也毫无意义。因此,在训练过程中根据您的问题陈述开发自定义损失函数可以符合我们的真实目标,并且我们会评估校准,以便能够恰当地解释预测概率。
因此,完整的评估策略会同时考虑两者:我们首先允许自定义损失函数充分优化模型以适应任务,然后我们会有意识地校准和验证概率输出。现在,我们可以创建一个决策支持工具,其中“90% 的置信度”实际上就是“90% 的可能性”,这对于任何实际应用都至关重要。


评论留言