案例 6:不平衡营销响应的阈值、ROC、PR 与校准#
学习目标与记号#
银行营销数据中,认购定期存款的客户所占比例较低。若只查看准确率,“把所有客户都预测为不认购”也可能显得表现很好。本案例训练逻辑回归概率模型,重点讨论分类阈值、正类比例、排序能力、概率预测质量和两类误判的代价。
本案例把认购定期存款记为正类 \(y=1\),不认购记为负类 \(y=0\);\(\widehat p_i\) 表示第 \(i\) 个样本属于正类的预测概率,\(t\) 表示分类阈值。主要学习目标如下:
根据混淆矩阵计算准确率、精确率、召回率、特异性和 F1 分数;
区分固定阈值指标、ROC 曲线下面积、Precision-Recall 曲线、平均精确率和概率校准;
只用验证集选择使 F1 分数最大或使误判代价最小的阈值,确定规则后再评价一次测试集;
计算并解释 Brier 分数与校准曲线,比较校准前后的概率预测;
说明测试集中正类与负类的样本数量比例、数据随时间变化以及商业代价会怎样限制结论。
对应正文: “评价二分类模型”。
数据来源、许可与任务含义#
数据:UCI Bank Marketing
说明页:https://archive.ics.uci.edu/dataset/222/bank+market
匿名 HTTPS 直链:https://archive.ics.uci.edu/static/public/222/bank+marketing.zip
许可:CC BY 4.0
目标 y:客户是否认购定期存款。
本案例读取 bank-full.csv。duration 是最后一次联系持续时间,只有联系完成后才知道;若目标是在拨号前选择客户,它属于未来信息。为保持部署边界,本案例主动删除 duration。数据下载到 AI_COURSE_DATA_DIR 或用户缓存,不写入源码目录。
from pathlib import Path
import hashlib
import io
import os
import shutil
import tempfile
import urllib.request
import zipfile
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
SEED = 42
np.random.seed(SEED)
CACHE_ROOT = Path(os.environ.get('AI_COURSE_DATA_DIR', Path.home() / '.cache' / 'ai-course-cases')).expanduser()
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
def download(url, filename):
destination = CACHE_ROOT / filename
if not destination.exists():
request = urllib.request.Request(url, headers={'User-Agent': 'ai-course-case/1.0'})
with urllib.request.urlopen(request, timeout=180) as response:
with tempfile.NamedTemporaryFile(dir=CACHE_ROOT, delete=False) as tmp:
shutil.copyfileobj(response, tmp)
temporary = Path(tmp.name)
temporary.replace(destination)
digest = hashlib.sha256(destination.read_bytes()).hexdigest()
print(f'{destination.name}:{destination.stat().st_size / 1024**2:.2f} MiB;SHA-256:{digest}')
return destination
plt.rcParams['figure.dpi'] = 120
print('缓存目录:', CACHE_ROOT)
缓存目录: /private/tmp/ai-course-case-data
第 1 步:读取完整表并删除部署时不可用字段#
CSV 使用分号分隔。标签 yes 映射为 1,no 映射为 0。duration 虽然可能很有预测力,但在拨号前不可知;使用它会让离线测试与真实决策时点不一致。删除它不是一般的数据清洗技巧,而是由任务定义决定的因果时间边界。
还要核对标签、缺失值、重复行和每列类型。字符串 unknown 是数据中的一个合法类别,不应被 pandas 当作 NaN。
URL = 'https://archive.ics.uci.edu/static/public/222/bank+marketing.zip'
archive = download(URL, 'uci_bank_marketing.zip')
with zipfile.ZipFile(archive) as outer_zip:
direct_candidates = [name for name in outer_zip.namelist() if name.lower().endswith('bank-full.csv')]
nested_candidates = [name for name in outer_zip.namelist() if name.lower().endswith('bank.zip')]
if len(direct_candidates) == 1:
csv_bytes = outer_zip.read(direct_candidates[0])
elif len(nested_candidates) == 1:
with zipfile.ZipFile(io.BytesIO(outer_zip.read(nested_candidates[0]))) as inner_zip:
csv_candidates = [name for name in inner_zip.namelist() if name.lower().endswith('bank-full.csv')]
if len(csv_candidates) != 1:
raise RuntimeError(f'内层 ZIP 中未唯一找到 bank-full.csv:{csv_candidates}')
csv_bytes = inner_zip.read(csv_candidates[0])
else:
raise RuntimeError(f'无法确定 Bank Marketing 数据文件:{outer_zip.namelist()}')
raw = pd.read_csv(io.BytesIO(csv_bytes), sep=';')
assert raw.shape[0] == 45211
assert set(raw['y']) == {'yes', 'no'}
y = (raw.pop('y') == 'yes').astype(np.int64)
raw = raw.drop(columns=['duration'])
assert not raw.isna().any().any()
print('特征表维度:', raw.shape, '正类比例:', float(y.mean()), '重复行:', int(raw.duplicated().sum()))
field_display_names = {'age': '年龄', 'job': '职业', 'marital': '婚姻状况', 'education': '教育程度', 'default': '信用违约', 'balance': '账户余额', 'housing': '住房贷款', 'loan': '个人贷款', 'contact': '联系方式', 'day': '联系日', 'month': '联系月', 'campaign': '本次活动联系次数', 'pdays': '上次活动后天数', 'previous': '此前联系次数', 'poutcome': '上次活动结果'}
display(raw.head(3).rename(columns=field_display_names))
uci_bank_marketing.zip:0.98 MiB;SHA-256:e0bf5f5de5b846e2f18e9d90606637267d46dfa260e0f17bb12e605db5efbeb4
特征表维度: (45211, 15) 正类比例: 0.11698480458295547 重复行: 16
| 年龄 | 职业 | 婚姻状况 | 教育程度 | 信用违约 | 账户余额 | 住房贷款 | 个人贷款 | 联系方式 | 联系日 | 联系月 | 本次活动联系次数 | 上次活动后天数 | 此前联系次数 | 上次活动结果 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 58 | management | married | tertiary | no | 2143 | yes | no | unknown | 5 | may | 1 | -1 | 0 | unknown |
| 1 | 44 | technician | single | secondary | no | 29 | yes | no | unknown | 5 | may | 1 | -1 | 0 | unknown |
| 2 | 33 | entrepreneur | married | secondary | no | 2 | yes | yes | unknown | 5 | may | 1 | -1 | 0 | unknown |
第 2 步:检查类别比例与变量类型#
测试集中真实正类所占的比例决定随机排序时 Precision-Recall 曲线的基准,也会影响 Precision、F1 分数、Brier 分数和误判总数。ROC-AUC 对正负样本数量比例相对不敏感,但不能代替固定阈值指标和概率评价。下面先绘制标签数量,再列出数值变量与类别变量。
重复的联系记录不一定是数据错误;没有稳定的客户标识时,不能直接认定这些记录应被删除。该数据在一段时间内收集,但当前文件没有提供足以构造严格时间划分的完整日期,因此后面的随机分层划分只评价来自相近数据分布的随机留出样本。
positive_rate = float(y.mean())
pd.Series({0: int((y == 0).sum()), 1: int((y == 1).sum())}).plot.bar(title=f'标签数量(正类比例 {positive_rate:.3f})')
plt.xlabel('真实标签'); plt.ylabel('样本数'); plt.show()
numeric_columns = raw.select_dtypes(include=np.number).columns.tolist()
categorical_columns = [column for column in raw.columns if column not in numeric_columns]
print('数值列:', [field_display_names[column] for column in numeric_columns])
print('类别列:', [field_display_names[column] for column in categorical_columns])
display(raw[categorical_columns].nunique().sort_values().rename(index=field_display_names).rename('类别数').to_frame())
数值列: ['年龄', '账户余额', '联系日', '本次活动联系次数', '上次活动后天数', '此前联系次数']
类别列: ['职业', '婚姻状况', '教育程度', '信用违约', '住房贷款', '个人贷款', '联系方式', '联系月', '上次活动结果']
| 类别数 | |
|---|---|
| 信用违约 | 2 |
| 住房贷款 | 2 |
| 个人贷款 | 2 |
| 婚姻状况 | 3 |
| 联系方式 | 3 |
| 教育程度 | 4 |
| 上次活动结果 | 4 |
| 职业 | 12 |
| 联系月 | 12 |
第 3 步:划分训练集、验证集与测试集#
分层划分使三个数据集中的正类比例大致相同。ColumnTransformer 只根据训练集计算数值变量的均值与标准差,也只根据训练集确定类别变量的独热编码。设置 handle_unknown='ignore' 后,若验证集或测试集中出现训练时未见的类别,程序会把对应编码位置设为 0,而不会重新学习编码规则。
验证集用于选择阈值和拟合校准方法,测试集只用于最终评价。若在搜索阈值时查看测试标签,最终报告的测试结果通常会过于乐观。
from sklearn.compose import ColumnTransformer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder, StandardScaler
X_train, X_hold, y_train, y_hold = train_test_split(raw, y, test_size=0.40, stratify=y, random_state=SEED)
X_validation, X_test, y_validation, y_test = train_test_split(X_hold, y_hold, test_size=0.50, stratify=y_hold, random_state=SEED)
preprocessor = ColumnTransformer([
('numeric', StandardScaler(), numeric_columns),
('categorical', OneHotEncoder(handle_unknown='ignore'), categorical_columns),
])
X_train_encoded = preprocessor.fit_transform(X_train)
X_validation_encoded = preprocessor.transform(X_validation)
X_test_encoded = preprocessor.transform(X_test)
assert X_train_encoded.shape[0] == len(y_train)
assert X_validation_encoded.shape[1] == X_train_encoded.shape[1]
assert X_test_encoded.shape[1] == X_train_encoded.shape[1]
print('编码后训练/验证/测试:', X_train_encoded.shape, X_validation_encoded.shape, X_test_encoded.shape)
print('正类比例:', y_train.mean(), y_validation.mean(), y_test.mean())
编码后训练/验证/测试: (27126, 50) (9042, 50) (9043, 50)
正类比例: 0.11697264616972647 0.11700951117009512 0.11699657193409267
第 4 步:两个最简单的基线#
全预测负类在不平衡数据上可能取得很高准确率,但召回率为 0,说明没有识别任何正响应。常数概率基线则把所有样本预测为训练集正类比例;它没有排序能力,却为对数损失和 Brier 分数提供参照。
基线帮助避免把“多数类很大”误认为模型学到了有效模式。
from sklearn.metrics import accuracy_score, brier_score_loss, f1_score, log_loss, precision_score, recall_score
all_negative = np.zeros(len(y_validation), dtype=int)
constant_probability = np.full(len(y_validation), y_train.mean(), dtype=float)
print({
'全负类准确率': accuracy_score(y_validation, all_negative),
'全负类召回率': recall_score(y_validation, all_negative, zero_division=0),
'常数概率 Brier 分数': brier_score_loss(y_validation, constant_probability),
'常数概率对数损失': log_loss(y_validation, constant_probability),
})
{'全负类准确率': 0.8829904888299049, '全负类召回率': 0.0, '常数概率 Brier 分数': 0.10331828682485873, '常数概率对数损失': 0.3609240061312741}
第 5 步:训练能够输出正类概率的模型#
逻辑回归能够给出每个样本属于正类的预测概率,便于讨论分类阈值和概率校准。损失函数用于指导模型训练,本例使用二元交叉熵;准确率等评价指标用于衡量模型在实际任务中的表现。模型先学习概率与样本排序,再由事先确定的规则把概率转换为类别。
这里不设置 class_weight='balanced',因为类别加权会改变训练目标,得到的数值通常需要重新检查和校准后才能解释为概率。若使用重采样或类别权重,应按照模型实际使用时的正负样本数量比例检查概率预测质量。
from sklearn.linear_model import LogisticRegression
model = LogisticRegression(max_iter=2000, solver='liblinear', random_state=SEED)
model.fit(X_train_encoded, y_train)
validation_probability = model.predict_proba(X_validation_encoded)[:, 1]
assert validation_probability.shape == (len(y_validation),)
assert np.all((validation_probability >= 0) & (validation_probability <= 1))
print({'验证对数损失': log_loss(y_validation, validation_probability), '验证 Brier 分数': brier_score_loss(y_validation, validation_probability)})
{'验证对数损失': 0.30161513893075903, '验证 Brier 分数': 0.08588653725654391}
第 6 步:混淆矩阵与固定阈值指标#
给定分类阈值 \(t\),预测标签为 \(\widehat y_i=\mathbb{1}(\widehat p_i\ge t)\)。混淆矩阵包含真正例(TP)、真负例(TN)、假正例(FP)和假负例(FN)。各项指标使用不同的分母:
精确率为 \(\operatorname{TP}/(\operatorname{TP}+\operatorname{FP})\),表示预测为正类的样本中有多少确实属于正类;
召回率为 \(\operatorname{TP}/(\operatorname{TP}+\operatorname{FN})\),表示全部真实正类中有多少被识别出来;
特异性为 \(\operatorname{TN}/(\operatorname{TN}+\operatorname{FP})\),表示全部真实负类中有多少被正确识别;
F1 分数是精确率与召回率的调和平均。
当分母为 0 时,不能在没有说明的情况下加入一个很小的数。下面的函数明确返回 NaN,并核对混淆矩阵四项数量之和是否等于样本量。
from sklearn.metrics import confusion_matrix
def safe_ratio(numerator, denominator):
return np.nan if denominator == 0 else numerator / denominator
def fixed_threshold_metrics(target, probability, threshold):
prediction = (np.asarray(probability) >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(target, prediction, labels=[0, 1]).ravel()
assert tn + fp + fn + tp == len(target)
precision = safe_ratio(tp, tp + fp)
recall = safe_ratio(tp, tp + fn)
specificity = safe_ratio(tn, tn + fp)
return {'threshold': threshold, 'tn': tn, 'fp': fp, 'fn': fn, 'tp': tp, 'accuracy': (tp + tn) / len(target), 'precision': precision, 'recall': recall, 'specificity': specificity, 'f1': safe_ratio(2 * precision * recall, precision + recall)}
metrics_at_half = fixed_threshold_metrics(y_validation, validation_probability, 0.5)
metric_display_names = {'threshold': '阈值', 'tn': '真负例', 'fp': '假正例', 'fn': '假负例', 'tp': '真正例', 'accuracy': '准确率', 'precision': '精确率', 'recall': '召回率', 'specificity': '特异度', 'f1': 'F1 分数', 'cost': '总代价', 'roc_auc': 'ROC 曲线下面积', 'average_precision': '平均精确率', 'brier': 'Brier 分数', 'log_loss': '对数损失'}
display(pd.Series(metrics_at_half).rename(index=metric_display_names).to_frame('数值'))
| 数值 | |
|---|---|
| 阈值 | 0.500000 |
| 真负例 | 7900.000000 |
| 假正例 | 84.000000 |
| 假负例 | 876.000000 |
| 真正例 | 182.000000 |
| 准确率 | 0.893829 |
| 精确率 | 0.684211 |
| 召回率 | 0.172023 |
| 特异度 | 0.989479 |
| F1 分数 | 0.274924 |
第 7 步:只在验证集选择阈值#
比较三种规则:固定 0.5、验证 F1 最大、验证误判代价最小。教学假设一次 FN 的单位代价为 5、一次 FP 的单位代价为 1;真实部署必须由业务、伦理与合规讨论确定代价,不能从数据中“自动发现”。
候选包括高于最大概率的全负类端点和每个不同概率。若多个阈值并列,选择较高阈值以减少预测正类数量;规则在查看测试结果前确定。
FN_COST, FP_COST = 5.0, 1.0
candidates = np.r_[np.nextafter(validation_probability.max(), np.inf), np.unique(validation_probability)]
rows = []
for threshold in candidates:
row = fixed_threshold_metrics(y_validation, validation_probability, float(threshold))
row['cost'] = FN_COST * row['fn'] + FP_COST * row['fp']
rows.append(row)
threshold_table = pd.DataFrame(rows)
best_f1_value = threshold_table['f1'].max()
f1_threshold = float(threshold_table.loc[threshold_table['f1'] == best_f1_value, 'threshold'].max())
best_cost_value = threshold_table['cost'].min()
cost_threshold = float(threshold_table.loc[threshold_table['cost'] == best_cost_value, 'threshold'].max())
selected_thresholds = {'fixed_0.5': 0.5, 'validation_max_f1': f1_threshold, 'validation_min_cost': cost_threshold}
rule_display_names = {'fixed_0.5': '固定阈值 0.5', 'validation_max_f1': '验证集 F1 最大', 'validation_min_cost': '验证集代价最小'}
print({rule_display_names[key]: value for key, value in selected_thresholds.items()})
threshold_comparison_display = pd.DataFrame([fixed_threshold_metrics(y_validation, validation_probability, value) for value in selected_thresholds.values()], index=[rule_display_names[key] for key in selected_thresholds]).rename(columns=metric_display_names)
threshold_comparison_display.index.name = '阈值规则'
display(threshold_comparison_display)
{'固定阈值 0.5': 0.5, '验证集 F1 最大': 0.2043358423065644, '验证集代价最小': 0.16715007774568488}
| 阈值 | 真负例 | 假正例 | 假负例 | 真正例 | 准确率 | 精确率 | 召回率 | 特异度 | F1 分数 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 阈值规则 | ||||||||||
| 固定阈值 0.5 | 0.500000 | 7900 | 84 | 876 | 182 | 0.893829 | 0.684211 | 0.172023 | 0.989479 | 0.274924 |
| 验证集 F1 最大 | 0.204336 | 7463 | 521 | 603 | 455 | 0.875691 | 0.466189 | 0.430057 | 0.934744 | 0.447394 |
| 验证集代价最小 | 0.167150 | 7187 | 797 | 539 | 519 | 0.852245 | 0.394377 | 0.490548 | 0.900175 | 0.437237 |
第 8 步:用 ROC 与 Precision-Recall 曲线描述排序能力#
ROC 曲线以假正例率(FPR)为横轴,以真正例率(TPR,即 Recall)为纵轴。ROC-AUC 可以解释为:随机选取一个正类样本和一个负类样本时,正类样本得分高于负类样本得分的概率;当两个得分相同时,通常按一半计入。Precision-Recall 曲线直接展示 Precision 与 Recall 的关系。当正类较少时,随机排序对应的 Precision 基准大致等于测试集中的正类比例,因此平均精确率(average precision,AP)更容易反映大量假正例造成的影响。
两条曲线都综合考察多个阈值下的排序能力,但不能告诉我们实际应用时应选择哪个阈值,也不能说明预测概率是否校准良好。
from sklearn.metrics import RocCurveDisplay, PrecisionRecallDisplay, average_precision_score, roc_auc_score
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
RocCurveDisplay.from_predictions(y_validation, validation_probability, ax=axes[0], name='逻辑回归')
PrecisionRecallDisplay.from_predictions(y_validation, validation_probability, ax=axes[1], name='逻辑回归')
axes[0].set_title('ROC 曲线')
axes[0].set_xlabel('假正例率')
axes[0].set_ylabel('真正例率')
axes[1].set_title('精确率—召回率曲线')
axes[1].set_xlabel('召回率')
axes[1].set_ylabel('精确率')
axes[1].axhline(y_validation.mean(), color='gray', linestyle='--', label='正类比例')
axes[1].legend()
plt.tight_layout(); plt.show()
print({'验证 ROC 曲线下面积': roc_auc_score(y_validation, validation_probability), '验证平均精确率': average_precision_score(y_validation, validation_probability), '正类比例': y_validation.mean()})
{'验证 ROC 曲线下面积': 0.7643942184936982, '验证平均精确率': 0.40561442464782327, '正类比例': np.float64(0.11700951117009512)}
第 9 步:Brier 分数与校准曲线#
Brier 分数定义为 \(n^{-1}\sum_i(\widehat p_i-y_i)^2\),它同时受到模型区分正负样本的能力和概率校准程度的影响。校准曲线把相近的预测概率分为一组,再比较每组的平均预测概率与实际正类比例。曲线越接近对角线,说明对应概率区间中的预测概率与实际比例越接近;若某组样本很少,曲线也可能出现较大随机波动。
下面使用独立验证集完成 sigmoid 校准,再在测试集上评价;不能根据测试标签拟合校准方法。scikit-learn 不同版本对已训练模型的接口有所不同,因此代码优先使用 FrozenEstimator,在旧版本中改用 cv='prefit'。
from sklearn.calibration import CalibratedClassifierCV, CalibrationDisplay
try:
from sklearn.frozen import FrozenEstimator
calibrated_model = CalibratedClassifierCV(FrozenEstimator(model), method='sigmoid')
except ImportError:
calibrated_model = CalibratedClassifierCV(model, method='sigmoid', cv='prefit')
calibrated_model.fit(X_validation_encoded, y_validation)
calibrated_validation_probability = calibrated_model.predict_proba(X_validation_encoded)[:, 1]
fig, ax = plt.subplots(figsize=(5, 5))
CalibrationDisplay.from_predictions(y_validation, validation_probability, n_bins=10, strategy='quantile', name='未校准', ax=ax)
CalibrationDisplay.from_predictions(y_validation, calibrated_validation_probability, n_bins=10, strategy='quantile', name='Sigmoid 校准', ax=ax)
ax.set_title('校准曲线')
ax.set_xlabel('平均预测概率')
ax.set_ylabel('实际正类比例')
legend_handles, legend_labels = ax.get_legend_handles_labels()
ax.legend(legend_handles, [label.replace('Perfectly calibrated', '理想校准') for label in legend_labels])
plt.show()
calibration_display = pd.DataFrame([
{'模型': '未校准', 'Brier 分数': brier_score_loss(y_validation, validation_probability), '对数损失': log_loss(y_validation, validation_probability)},
{'模型': '已校准', 'Brier 分数': brier_score_loss(y_validation, calibrated_validation_probability), '对数损失': log_loss(y_validation, calibrated_validation_probability)},
])
display(calibration_display)
| 模型 | Brier 分数 | 对数损失 | |
|---|---|---|---|
| 0 | 未校准 | 0.085887 | 0.301615 |
| 1 | 已校准 | 0.085862 | 0.301608 |
第 10 步:确定规则后只评价一次测试集#
最终报告保留事先为未经校准模型确定的三种阈值,并另外报告根据验证集完成校准后的概率预测质量。阈值是在未经校准的验证集概率上选出的,因此必须用于未经校准的测试集概率。校准会改变概率的数值;如果还要为校准后的模型选择阈值,需要使用另一个独立验证部分。
同时报告固定阈值指标、ROC-AUC、AP、Brier 分数、对数损失和误判总代价,从而分别说明给定阈值下的分类结果、跨阈值的排序能力和概率预测质量。
from sklearn.metrics import average_precision_score
test_probability = model.predict_proba(X_test_encoded)[:, 1]
calibrated_test_probability = calibrated_model.predict_proba(X_test_encoded)[:, 1]
final_rows = []
for rule, threshold in selected_thresholds.items():
row = fixed_threshold_metrics(y_test, test_probability, threshold)
row['rule'] = rule
row['cost'] = FN_COST * row['fn'] + FP_COST * row['fp']
row['roc_auc'] = roc_auc_score(y_test, test_probability)
row['average_precision'] = average_precision_score(y_test, test_probability)
row['brier'] = brier_score_loss(y_test, test_probability)
row['log_loss'] = log_loss(y_test, test_probability)
final_rows.append(row)
final_results = pd.DataFrame(final_rows).set_index('rule')
final_results_display = final_results.rename(index=rule_display_names, columns=metric_display_names)
final_results_display.index.name = '阈值规则'
display(final_results_display)
probability_display = pd.DataFrame([
{'概率版本': '未校准', 'Brier 分数': brier_score_loss(y_test, test_probability), '对数损失': log_loss(y_test, test_probability)},
{'概率版本': '使用验证集校准', 'Brier 分数': brier_score_loss(y_test, calibrated_test_probability), '对数损失': log_loss(y_test, calibrated_test_probability)},
])
display(probability_display)
| 阈值 | 真负例 | 假正例 | 假负例 | 真正例 | 准确率 | 精确率 | 召回率 | 特异度 | F1 分数 | 总代价 | ROC 曲线下面积 | 平均精确率 | Brier 分数 | 对数损失 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 阈值规则 | |||||||||||||||
| 固定阈值 0.5 | 0.500000 | 7905 | 80 | 862 | 196 | 0.895831 | 0.710145 | 0.185255 | 0.989981 | 0.293853 | 4390.0 | 0.776757 | 0.443338 | 0.083414 | 0.293658 |
| 验证集 F1 最大 | 0.204336 | 7473 | 512 | 592 | 466 | 0.877917 | 0.476483 | 0.440454 | 0.935880 | 0.457760 | 3472.0 | 0.776757 | 0.443338 | 0.083414 | 0.293658 |
| 验证集代价最小 | 0.167150 | 7227 | 758 | 524 | 534 | 0.858233 | 0.413313 | 0.504726 | 0.905072 | 0.454468 | 3378.0 | 0.776757 | 0.443338 | 0.083414 | 0.293658 |
| 概率版本 | Brier 分数 | 对数损失 | |
|---|---|---|---|
| 0 | 未校准 | 0.083414 | 0.293658 |
| 1 | 使用验证集校准 | 0.083366 | 0.293569 |
第 11 步:分析错误与阈值附近的样本#
当假负例的代价设得较高时,使误判代价最小的阈值通常会允许更多假正例,以减少假负例。下面比较三种阈值规则得到的混淆矩阵四项数量,并查看预测概率接近阈值的测试样本。阈值附近很小的概率变化就可能改变预测类别,因此在需要人工复核或允许暂不决定的任务中,不应把单个阈值当作全部决策依据。
测试后的观察只用于描述模型表现,不再用于重新选择阈值。
display(final_results_display[['真负例', '假正例', '假负例', '真正例', '精确率', '召回率', '特异度', 'F1 分数', '总代价']])
review = X_test.copy()
review['true_response'] = y_test.to_numpy()
review['predicted_probability'] = test_probability
review['distance_to_cost_threshold'] = np.abs(test_probability - cost_threshold)
review_display_names = {**field_display_names, 'true_response': '真实响应', 'predicted_probability': '预测概率', 'distance_to_cost_threshold': '距代价阈值'}
display(review.sort_values('distance_to_cost_threshold').head(10)[['age', 'job', 'campaign', 'pdays', 'true_response', 'predicted_probability', 'distance_to_cost_threshold']].rename(columns=review_display_names))
| 真负例 | 假正例 | 假负例 | 真正例 | 精确率 | 召回率 | 特异度 | F1 分数 | 总代价 | |
|---|---|---|---|---|---|---|---|---|---|
| 阈值规则 | |||||||||
| 固定阈值 0.5 | 7905 | 80 | 862 | 196 | 0.710145 | 0.185255 | 0.989981 | 0.293853 | 4390.0 |
| 验证集 F1 最大 | 7473 | 512 | 592 | 466 | 0.476483 | 0.440454 | 0.935880 | 0.457760 | 3472.0 |
| 验证集代价最小 | 7227 | 758 | 524 | 534 | 0.413313 | 0.504726 | 0.905072 | 0.454468 | 3378.0 |
| 年龄 | 职业 | 本次活动联系次数 | 上次活动后天数 | 真实响应 | 预测概率 | 距代价阈值 | |
|---|---|---|---|---|---|---|---|
| 26494 | 30 | management | 2 | -1 | 0 | 0.167108 | 0.000042 |
| 34272 | 26 | self-employed | 3 | -1 | 0 | 0.167220 | 0.000070 |
| 39591 | 26 | admin. | 1 | -1 | 0 | 0.167256 | 0.000106 |
| 33361 | 44 | technician | 1 | -1 | 0 | 0.167028 | 0.000122 |
| 26246 | 32 | services | 1 | -1 | 1 | 0.166948 | 0.000202 |
| 32357 | 43 | management | 1 | -1 | 1 | 0.166945 | 0.000205 |
| 21498 | 60 | retired | 2 | -1 | 0 | 0.166909 | 0.000241 |
| 16856 | 42 | management | 2 | -1 | 0 | 0.167433 | 0.000283 |
| 39742 | 31 | services | 3 | -1 | 0 | 0.167472 | 0.000322 |
| 34134 | 25 | technician | 1 | 13 | 0 | 0.166775 | 0.000375 |
结论、局限与常见错误#
准确率可能主要反映多数类的表现;精确率、召回率、F1 分数、ROC-AUC、AP、Brier 分数和校准分别回答不同问题,不存在脱离具体任务的单一最佳指标。
阈值和校准方法都属于模型方案,只能根据训练集与验证集确定。反复查看测试集并选择表现最好的阈值,会使测试结果过于乐观。
较高的 ROC-AUC 不保证正类较少时仍具有较高精确率,也不保证预测概率经过良好校准。
假负例与假正例的代价比 5:1 只是教学假设;真实营销任务还要考虑联系成本、客户体验、公平性和合规要求。
随机分层划分没有检验模型能否适用于不同时间、地区或营销策略;数据中的相关关系也不能直接解释为客户行为的因果关系。
删除
duration是根据“拨号前选择客户”这一任务作出的决定。如果任务改为联系结束后预测响应,就必须重新说明哪些信息在预测时可以使用。
综合练习#
已知 \(\operatorname{TP}=50\)、\(\operatorname{TN}=30\)、\(\operatorname{FP}=10\)、\(\operatorname{FN}=10\),手工计算准确率、精确率、召回率、假正例率、特异性和 F1 分数,并解释每个分母表示哪一组样本。
分别把假负例与假正例的单位代价之比设为 1:1、5:1 和 20:1。只在验证集上选择阈值,然后比较测试集上的召回率、精确率和误判总代价。
实现 ROC 曲线的扫描算法:先把得分相同的样本分为一组,再按照得分从高到低依次更新混淆矩阵。将得到的完整 ROC 点与 AUC 同 scikit-learn 的结果进行核对。
对验证集概率进行严格单调递增变换,验证 ROC-AUC 保持不变,但 Brier 分数和阈值 0.5 下的评价指标可能发生变化。
分别使用等宽分组和每组样本数大致相同的分组方式绘制校准曲线,报告每组样本数,并解释样本较少的组为什么容易波动。
假设模型实际使用时的正类比例从 11% 降到 3%,讨论精确率、Precision-Recall 曲线基准、Brier 分数和误判成本报告应作哪些调整。