核心结论 :当正样本几万、负样本只有个位数时,直接训练等于白练 ------模型会「聪明地」全猜正类,accuracy 高达 99.99% 却一个负样本都抓不到。正确的姿势是四步走:① 先把评估指标从 accuracy 换成 PR-curve / F1 / recall;② 用 focal loss 或加权交叉熵重设损失函数;③ 对少数类做 SMOTE 过采样或数据增强;④ 最后调低预测阈值(threshold),在 precision 和 recall 之间找回平衡。极端到个位数时,别再想「把它当分类问题」,直接转成异常检测 / one-class 问题更现实。
目录
-
- 一句话结论
-
- 为什么会翻车:accuracy 的陷阱
-
- 第一步:先换评估指标
-
- 第二步:换损失函数(focal loss / 加权交叉熵)
-
- 第三步:数据层过采样
-
- 第四步:调预测阈值
-
- 完整可运行代码(PyTorch 端到端)
-
- 极端情形:个位数样本怎么处理
-
- 对照表:症状 → 原因 → 解法
-
- 判断收尾
1. 一句话结论
先建立一个直觉 🎭:极端类别不平衡就像一场只有 5 个坏人、几万个好人的面试 ------如果你只回答「全录好人」,正确率高达 99.99%,面试官还夸你「准确率真高」,可那 5 个坏人一个都没抓出来。我们要的不是「猜对大多数」,而是「把少数类揪出来」。
所以这套方法的核心不是「怎么训得更准」,而是「怎么让模型在意那几个少数样本」:
- 损失函数:让少数类的错误代价更高(focal loss、加权 CE)。
- 采样:把少数类的样本变多(SMOTE、过采样、增强)。
- 阈值:模型默认在 0.5 处切一刀,对极端不平衡要往下调。
- 评估 :别再用 accuracy,用 PR 曲线(Precision-Recall curve) 与 F1。
一句话:极端不平衡下,训练目标不是「整体准确率」,而是「少数类的召回率(recall)」。
2. 为什么会翻车:accuracy 的陷阱
绝大多数模型默认用交叉熵(cross-entropy) 损失 + accuracy 评估。在平衡数据集上这没问题,但一旦负样本只有个位数,就会立刻崩坏。
陷阱一:模型「摆烂」能拿高分。
假设训练集 30000 个正样本、10 个负样本。一个模型啥都不学,见到什么都预测「正类」,那么:
ext{accuracy} = rac{TP + TN}{TP + TN + FP + FN} = rac{30000}{30010} approx 99.97%
它一个负样本都没抓到(recall = 0),但 accuracy 接近满分。这就是为什么「训练 loss 一直在降、accuracy 99%,上线却是个废物」的根源。
陷阱二:梯度被正样本淹没。
交叉熵的梯度按样本加权,30000 个正样本贡献的梯度会把 10 个负样本的梯度彻底稀释。模型几乎没有动力去「纠正」对负样本的判断,因为纠正带来的 loss 下降微乎其微。
打个比方 🎭:**你在大海上喊一声「这里有个落水的」(负样本),周围几千个人都在喊「我没事」(正样本),你的声音根本传不到救生员耳朵里。**第一步:先换评估指标
训练之前,先想清楚「什么才算好」。在极端不平衡下,accuracy 是无效指标,必须换成下面这些:
| 指标 | 关注什么 | 极端不平衡下是否可用 |
|---|---|---|
| Accuracy | 全部预测对的比例 | ❌ 严重失真,几乎无意义 |
| Precision(精确率) | 预测为正的里面,真正的比例 | ✅ 可用,但单独看会骗人 |
| Recall(召回率) | 真正的正样本里,被抓住的比例 | ✅ 最该盯的指标 |
| F1-Score | Precision 与 Recall 的调和平均 | ✅ 推荐,均衡两个方向 |
| PR 曲线 / PR-AUC | Precision 随 Recall 变化的曲线下面积 | ✅ 强烈推荐,最能反映少数类表现 |
| ROC-AUC | 对类别比例不敏感 | ⚠️ 极端不平衡下会「虚高」,不如 PR-AUC 真实 |
为什么极端不平衡下 PR-AUC 比 ROC-AUC 更可信? ROC-AUC 的横轴是 FP(假正例)占比,在负样本极少的场景,FP 占比本身就很小,曲线容易「看起来很漂亮」;而 PR-AUC 直接以少数类的 precision / recall 为轴,更多表现在少数类上的真实能力。
实操建议 :训练时用 validation F1 或 macro-F1 选模型,不要看 loss 或 accuracy。很多研究(如 2025 年某随机森林 vs 深度模型研究)也表明,用 F1 优化的模型在少数类上的表现远优于只看 accuracy 的模型。
4. 第二步:换损失函数(focal loss / 加权交叉熵)
评估指标换了,损失函数也要换。这是最重要的一步。
4.1 加权交叉熵(Weighted Cross-Entropy)
最简单有效。给不同类别赋一个权重,少数类的权重设大、多数类的权重设小。公式:
mathcalLwce=−wtcdotlog(pt)
其中 wt 就是类别 t 的权重。常用权重设为类别的逆频率:
w_{neg} = rac{N_{total}}{2 cdot N_{neg}}, quad w_{pos} = rac{N_{total}}{2 cdot N_{pos}}
- Npos=30000, Nneg=10 时, wneg 会是 wpos 的几千倍,保证每个负样本的梯度都被放大。
4.2 Focal Loss(焦点损失)
Focal Loss 是加权 CE 的进阶版,由 Lin 等人为解决类别不平衡 + 难易样本不平衡而提出,公式:
mathcalLfocal=−alphat(1−pt)gammalog(pt)
- alphat:类别权重,作用同加权 CE;
- gamma(聚焦参数,通常取 2):降低「易分类样本」的贡献,让模型把注意力放到「难分类」的样本上。
打个比方 🎭:Focal Loss 像老师在考试时给「大家都做对的基础题」降权,把分数重心压到「只有少数人做对的难题」上------那些已经被正确分类的大量正样本(易样本)权重被削弱,模型被迫去学那些让它犯错的少数负样本。
何时用加权 CE、何时用 Focal Loss? 数据规模小、类别极少时,加权 CE 更稳 (Focal 的 gamma 多一个超参,样本少时容易过拟合);数据量大、且希望模型聚焦「难样本」时,Focal Loss 更优。
5. 第三步:数据层过采样
如果损失函数还不够,就要在数据层面做文章,核心思路是「把少数类变多」。
5.1 朴素过采样(Random Oversampling)
直接把 10 个负样本复制几万次。简单,但有一个大坑 :模型会把少数样本「背下来」------因为重复的样本完全一样,模型记住这几个样本的特征就「过拟合」了,泛化能力反而变差。
5.2 SMOTE(合成少数类过采样)
SMOTE(Synthetic Minority Over-sampling Technique)在少数类样本之间做插值,生成「新的」样本而不是复制旧的:
- 对每个少数类样本,找它的 k 个最近邻;
- 在样本与某个邻居的连线上随机取一个点作为新样本。
mathbfxnew=mathbfxi+lambdacdot(mathbfxnn−mathbfxi),quadlambdasimU(0,1)
- 生成的样本不是原样本的复制,而是「邻居间的中间点」,能有效缓解过拟合。
5.3 数据增强(Data Augmentation)
针对图像、文本等,用旋转、裁剪、加噪、同义词替换等方式,从一个少数样本「造出」多个变体。这比纯复制更鲁棒,因为它引入了「语义不变但形态不同」的多样性。
极端到个位数时的取舍 :当负样本只有 5--10 个,SMOTE 也容易在「极小的簇」里打转,生成的样本高度相似。此时更推荐「数据增强 + 加权损失」的组合,而不是疯狂过采样。
6. 第四步:调预测阈值
这一步最容易被忽略,却往往是救命的。
模型预测的是「正类的概率 p」,默认在 pge0.5 时判为正。但在极端不平衡下,模型的概率分布整体都被正样本拉低------即使是最「坏」的样本,模型可能也只敢给 0.2 的概率。此时 0.5 的阈值等于「永远不报警」。
解法 :把阈值(threshold)从 0.5 往下调 ,比如降到 0.1 甚至 0.05,在 precision 和 recall 之间找一个平衡点。这个点通常看 PR 曲线上的拐点(precision≈recall 处)。
实测案例:某压力预测研究把预测阈值从默认调到 0.3 后,模型 recall 从不到 0.5 一举提升到 0.8857,代价是 precision 下降,但换来了「少数类更多被抓住」。
7. 完整可运行代码(PyTorch 端到端)
下面是一段复制粘贴就能跑的完整代码:构造一个 30000 正样本、10 负样本的极端不平衡二分类任务,用加权交叉熵 + SMOTE + 阈值调优,并输出 PR 曲线与 F1。
运行前:pip install torch scikit-learn imbalanced-learn matplotlib numpy。
python
import numpy as np
import torch
import torch.nn as nn
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_recall_fscore_support, precision_recall_curve, auc
from imblearn.over_sampling import SMOTE
import matplotlib.pyplot as plt
# ① 构造极端不平衡数据:30000 正样本、10 负样本,8 维特征
np.random.seed(0)
n_pos, n_neg = 30000, 10
X_pos = np.random.randn(n_pos, 8) * 0.5 + 1.0 # 正样本:均值+1
X_neg = np.random.randn(n_neg, 8) * 0.5 - 1.0 # 负样本:均值-1
X = np.vstack([X_pos, X_neg]) # 合并特征
y = np.concatenate([np.ones(n_pos), np.zeros(n_neg)]) # 1=正,0=负
# ② 划分训练/测试集(分层,保住那 10 个负样本)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
stratify=y, random_state=42)
# ③ 用 SMOTE 过采样少数类(只在训练集做,防止数据泄露)
smote = SMOTE(random_state=42)
X_train_res, y_train_res = smote.fit_resample(X_train, y_train)
print(f"SMOTE 后训练集:正 {np.sum(y_train_res==1)} / 负 {np.sum(y_train_res==0)}")
# ④ 构建一个简单的二分类网络,用加权交叉熵
class Net(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Sequential(nn.Linear(8, 16), nn.ReLU(), nn.Linear(16, 1))
def forward(self, x):
return self.fc(x)
model = Net()
# 负样本权重放大:正/负样本数量比决定权重(负类权重更高)
w = torch.tensor([n_pos / n_neg]) # 负样本权重 ≈ 3000
criterion = nn.BCEWithLogitsLoss(pos_weight=w) # 加权二分类交叉熵
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# ⑤ 训练
X_tr = torch.tensor(X_train_res, dtype=torch.float32)
y_tr = torch.tensor(y_train_res, dtype=torch.float32).unsqueeze(1)
for epoch in range(100):
optimizer.zero_grad()
logits = model(X_tr)
loss = criterion(logits, y_tr)
loss.backward()
optimizer.step()
if epoch % 20 == 0:
print(f"epoch {epoch:3d} loss={loss.item():.4f}")
# ⑥ 在测试集(原始 20% 数据)上评估,并扫描多个阈值
X_te = torch.tensor(X_test, dtype=torch.float32)
with torch.no_grad():
probs = torch.sigmoid(model(X_te)).numpy() # 正类的预测概率
thresholds = np.linspace(0.01, 0.99, 50) # 从 0.01 到 0.99 扫阈值
f1s, precs, recs = [], [], []
for t in thresholds:
y_pred = (probs >= t).astype(int)
p, r, f1, _ = precision_recall_fscore_support(y_test, y_pred,
average="binary", zero_division=0)
precs.append(p); recs.append(r); f1s.append(f1)
best_i = int(np.argmax(f1s))
print(f"
最优阈值 = {thresholds[best_i]:.2f} "
f"F1 = {f1s[best_i]:.3f} "
f"Precision = {precs[best_i]:.3f} Recall = {recs[best_i]:.3f}")
# ⑦ 画 PR 曲线
precision, recall, _ = precision_recall_curve(y_test, probs)
pr_auc = auc(recall, precision)
plt.plot(recall, precision, label=f"PR-AUC = {pr_auc:.3f}")
plt.axvline(recs[best_i], ls="--", color="red", label=f"best threshold={thresholds[best_i]:.2f}")
plt.xlabel("Recall"); plt.ylabel("Precision"); plt.legend()
plt.title("Precision-Recall Curve"); plt.show()
怎么读这段代码 :第 ③ 步只在训练集做 SMOTE(测试集绝不能过采样 ,否则数据泄露、评估失真);第 ④ 步用 pos_weight 实现加权交叉熵,负类权重提高 3000 倍,让模型在意那几个负样本;第 ⑥ 步扫 50 个阈值,从中挑 F1 最高的那个,这正是第 6 节「调阈值」的落地。
8. 极端情形:个位数样本怎么处理
当负样本少到根本撑不起一个分类器 (比如只有 3--5 个),前面几招可能都失效。此时要转换问题定义:
8.1 转成异常检测(Anomaly Detection)
把「正样本(多数)当正常」,「负样本(少数)当异常」。异常检测只学「正常」的分布,任何偏离正常分布的都算异常。常用方法:One-Class SVM 、Isolation Forest 、Autoencoder 重构误差。
- 优点:不再需要大量负样本,因为异常检测默认「只知道正常是什么」。
- 适用:负样本 ≤ 10 个,且「负样本」本身没有统一模式(杂乱的异常)。
8.2 转成排序 / 检索问题
把「判断是不是异常」改成「给每个样本打一个异常分数,取分数最高的 top-N」。这是一个学习排序(learning to rank)问题,对不平衡没那么敏感。
8.3 数据能搞到就别省
除非实在拿不到,否则尽可能多收集几个负样本。哪怕是人工标注 20 个,也比 5 个强太多------因为 5 个样本既撑不起交叉验证,也容易让过采样「记忆」。
核心判断 :如果负样本数量少于所需验证集的最小粒度 (比如连 3--5 个验证样本都凑不出),就别硬当二分类做,转异常检测更靠谱。
9. 对照表:症状 → 原因 → 解法
| 症状 | 原因 | 解法 |
|---|---|---|
| 训练 loss 一直降,accuracy 99%,上线却啥都检不出 | 模型「摆烂」全猜正类 | 换评估指标:用 F1 / recall / PR-AUC,别再看 accuracy |
| 负样本几乎学不到,权重不更新 | 正样本梯度淹没少数类 | 用加权交叉熵 / focal loss 放大少数类梯度 |
| 过采样后模型记住少数样本,泛化差 | 直接复制同一样本导致记忆 | 改用 SMOTE 插值生成新样本,或做数据增强 |
| 负样本永远判不出来,概率都低于 0.5 | 默认阈值 0.5 对不平衡过高 | 扫阈值下调(如 0.3 / 0.1),看 PR 曲线拐点 |
| 测试集效果虚高、上线崩 | 测试集也做了过采样(数据泄露) | 只对训练集过采样,测试集用原始分布 |
| 负样本只有 3--5 个,啥方法都没用 | 样本量撑不起二分类 | 转异常检测(One-Class SVM / Isolation Forest) |
10. 判断收尾
什么时候该用这套方法 :少数组虽少但有一定量(≥ 十几二十个) 、且你希望「尽量多抓少数类、宁肯误报多一些」时,用「加权损失 + SMOTE + 阈值下调 + PR 评估」这套组合拳。什么时候不该用:负样本只有 3--5 个且毫无规律,此时再怎么调损失也是空谈,直接转异常检测 / one-class 更现实。判断标准只有一条------少数样本的数量还撑不撑得起分类器。
本专栏《机器学习》收录从数据预处理、模型评估到不平衡分类、特征工程等实战方法与面试考点,每篇都配一段可直接运行的代码,持续更新。想在实战里少踩坑,可在专栏页订阅。