哈喽,大家好~
最近,有同学问到:模型准确率都到95%了,为什么一上线还是抓不到异常用户?
大概不是调参的事情,你的数据,很可能根本不平衡。
在风控、故障检测、疾病预测这些任务里,正样本往往非常少。模型只要把大多数样本预测正确,就能拿到一个漂亮的准确率,但真正重要的少数类却一个都识别不出来。
下面,我们就来聊聊数据平衡:它为什么影响模型、常见方法怎么选~
数据不平衡
分类模型做的事情,可以简单理解为:根据训练数据,学习一条区分不同类别的决策边界。
如果训练集中有1000个样本,其中950个正常、50个异常,那么模型即使把所有样本都预测成正常,准确率也有:
看上去很高,但50个异常样本一个都没找到。
所以,不平衡任务不能只看准确率,我们更应该关注少数类的精确率、召回率、F1值和PR曲线。
其中,召回率为:
表示正确识别出的正样本, 表示被漏掉的正样本。翻译成大白话就是:所有真正的异常中,模型到底找回来了多少。
常见的数据平衡方法
数据平衡的思路并不复杂,核心就是让模型“多关注少数类”。
欠采样会减少多数类样本。它训练速度快,但可能删掉有价值的信息,比较适合数据量很大的场景。
随机过采样会复制少数类样本。它不会丢失多数类信息,但重复数据容易让模型过拟合。
SMOTE不是简单复制,而是在少数类样本之间合成新样本。其基本形式为:
其中, 是一个少数类样本, 是它的近邻, 是0到1之间的随机数。你可以这样理解:在两个少数类样本之间连一条线,再从线上取一个新点。
还有一种常用方法是类别权重。以二分类为例,常见权重计算方式为:
是总样本数, 是类别数, 是类别 的样本数。样本越少,权重越大,模型预测错它时受到的惩罚也越大。
Python实现
我们的数据,构造一个比例约为95:5的二分类任务,对比原始模型、类别权重、欠采样和SMOTE。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
precision_score, recall_score, f1_score,
average_precision_score, precision_recall_curve
)
from imblearn.pipeline import Pipeline
from imblearn.under_sampling import RandomUnderSampler
from imblearn.over_sampling import SMOTE
# 1. 构造二维不平衡数据
X, y = make_classification(
n_samples=3000, n_features=2, n_redundant=0,
n_informative=2, n_clusters_per_class=2,
weights=[0.95, 0.05], class_sep=1.1,
flip_y=0.02, random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.35, stratify=y, random_state=42
)
# 2. 四种处理方案
models = {
"Baseline": Pipeline([
("scale", StandardScaler()),
("model", LogisticRegression(max_iter=1000))
]),
"Class Weight": Pipeline([
("scale", StandardScaler()),
("model", LogisticRegression(
class_weight="balanced", max_iter=1000
))
]),
"Under Sampling": Pipeline([
("under", RandomUnderSampler(random_state=42)),
("scale", StandardScaler()),
("model", LogisticRegression(max_iter=1000))
]),
"SMOTE": Pipeline([
("smote", SMOTE(k_neighbors=5, random_state=42)),
("scale", StandardScaler()),
("model", LogisticRegression(max_iter=1000))
])
}
colors = {
"Baseline": "#00D4FF",
"Class Weight": "#FF3D8D",
"Under Sampling": "#8A5CFF",
"SMOTE": "#FF9F1C"
}
# 3. 训练并评估
results = []
probabilities = {}
for name, model in models.items():
model.fit(X_train, y_train)
prob = model.predict_proba(X_test)[:, 1]
pred = (prob >= 0.5).astype(int)
probabilities[name] = prob
results.append({
"Method": name,
"Precision": precision_score(y_test, pred),
"Recall": recall_score(y_test, pred),
"F1": f1_score(y_test, pred),
"AP": average_precision_score(y_test, prob)
})
result_df = pd.DataFrame(results).round(3)
print(result_df)
# 4. 图一:不同平衡方法的决策区域
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
xx, yy = np.meshgrid(
np.linspace(x_min, x_max, 350),
np.linspace(y_min, y_max, 350)
)
grid = np.c_[xx.ravel(), yy.ravel()]
fig, axes = plt.subplots(2, 2, figsize=(13, 10))
for ax, (name, model) in zip(axes.ravel(), models.items()):
score = model.predict_proba(grid)[:, 1].reshape(xx.shape)
contour = ax.contourf(
xx, yy, score, levels=np.linspace(0, 1, 15),
cmap="turbo", alpha=0.75
)
ax.contour(
xx, yy, score, levels=[0.5],
colors="white", linewidths=2.5
)
ax.scatter(
X_test[y_test == 0, 0], X_test[y_test == 0, 1],
c="#00E5FF", s=18, alpha=0.45, label="Majority"
)
ax.scatter(
X_test[y_test == 1, 0], X_test[y_test == 1, 1],
c="#FF1744", s=45, edgecolors="white",
linewidths=0.6, label="Minority"
)
ap = result_df.loc[result_df.Method == name, "AP"].iloc[0]
ax.set_title(f"{name} | AP={ap:.3f}", fontweight="bold")
ax.legend(loc="upper right")
fig.colorbar(contour, ax=axes, label="Positive probability")
plt.suptitle("Decision Regions under Different Balancing Methods",
fontsize=16, fontweight="bold")
plt.show()
# 5. 图二:PR曲线与阈值-F1联合分析
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for name, prob in probabilities.items():
precision, recall, thresholds = precision_recall_curve(y_test, prob)
ap = average_precision_score(y_test, prob)
axes[0].plot(
recall, precision, lw=2.5,
color=colors[name], label=f"{name} AP={ap:.3f}"
)
f1_values = (
2 * precision[:-1] * recall[:-1] /
(precision[:-1] + recall[:-1] + 1e-10)
)
axes[1].plot(
thresholds, f1_values, lw=2.5,
color=colors[name], label=name
)
axes[0].set(
xlabel="Recall", ylabel="Precision",
title="Precision-Recall Curves"
)
axes[1].set(
xlabel="Classification Threshold", ylabel="F1",
title="F1 under Different Thresholds"
)
for ax in axes:
ax.grid(alpha=0.25)
ax.legend()
plt.tight_layout()
plt.show()
第一张图展示了四种方案学习到的决策区域。背景颜色代表模型预测为正类的概率,白线是默认阈值0.5对应的决策边界。
Baseline通常会把更多区域判为多数类。类别权重、欠采样和SMOTE则会主动扩大少数类区域,提高召回率。但边界扩张过多,也会带来更多误报。
第二张图左侧是PR曲线。曲线越靠近右上方,AP越高,说明模型在不同阈值下对少数类的综合识别能力越强。右侧展示阈值变化时的F1值,它能帮助我们找到比0.5更合适的分类阈值。
这里还有一个很重要的细节:只能对训练集做采样,不能先对完整数据做SMOTE再划分测试集。否则合成样本的信息会进入测试集,最终指标会虚高。
事实上,数据量很大、重复样本较多时,可以先尝试欠采样;少数类样本有限时,可以尝试SMOTE;如果不希望改变训练数据,类别权重通常是成本最低的起点。
不过,平衡比例并不是越接近1:1越好。我们真正要优化的是业务目标。例如疾病筛查更重视召回率,营销推荐则要控制误报成本。
最后
数据平衡不是为了让类别数量看起来整齐,而是为了让模型真正学会识别少数类。实践中不要只看准确率,要结合PR曲线、召回率、F1和业务成本一起判断。
接下来大家可以尝试调整SMOTE的k_neighbors,或者通过验证集搜索最佳分类阈值。数据关系更复杂时,也可以换成树模型,再对比类别权重与采样方法的效果~

