共计 2542 个字符,预计需要花费 7 分钟才能阅读完成。
引言
最近在用 BERT 做文本二分类任务时,遇到了典型的过拟合问题:训练集准确率一路飙升到 98%,验证集却卡在 70% 左右不动。排查发现是正负样本比例严重失衡(1:9)导致的。经过两周的调参和方案对比,总结出一套适合新手的解决方案,在这里分享关键思路和代码实现。
过拟合的典型表现
- 指标异常
- 训练 loss 持续下降,验证 loss 在 5 个 epoch 后开始上升
- 验证集准确率比训练集低 20% 以上
-
F1-score 的波动幅度超过 0.15
-
数据维度的红灯信号
- 样本比例失衡(比如垃圾邮件检测中负样本占 90%)
- 文本长度差异大(正样本平均 200 词,负样本仅 50 词)
-
验证集分布与训练集显著不同
-
模型行为异常
- 对某些特定词(如『免费』『促销』)过度敏感
- 对长文本的预测准确率明显低于短文本
三大解决方案对比
方案一:数据层的魔法
- SMOTE 过采样 + 欠采样组合
- 对少数类用 SMOTE 生成合成样本(注意保持文本语义)
- 对多数类随机删除部分样本
-
最终比例建议控制在 1:2 到 1:3 之间
-
分层抽样技巧
from torch.utils.data import WeightedRandomSampler sample_weights = [1.0 if label == 0 else 5.0 for label in labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)
方案二:模型层的改造
-
Custom Head 设计
class BalancedBertClassifier(nn.Module): def __init__(self, bert_model, dropout_rate=0.3): super().__init__() self.bert = bert_model self.dropout = nn.Dropout(dropout_rate) self.classifier = nn.Linear(768, 2) self.layer_norm = nn.LayerNorm(768) def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids, attention_mask=attention_mask) pooled = outputs.pooler_output pooled = self.layer_norm(self.dropout(pooled)) return self.classifier(pooled) -
关键参数建议
- Dropout 率:0.2-0.5(BERT 层保持 0.1)
- LayerNorm 位置:紧接在 Dropout 之后
方案三:训练策略优化
-
Focal Loss 实现
class FocalLoss(nn.Module): def __init__(self, alpha=0.75, gamma=2.0): super().__init__() self.alpha = alpha # 控制类别权重 self.gamma = gamma # 控制难易样本权重 def forward(self, inputs, targets): BCE_loss = F.cross_entropy(inputs, targets, reduction='none') pt = torch.exp(-BCE_loss) loss = self.alpha * (1-pt)**self.gamma * BCE_loss return loss.mean() -
动态学习率示例
optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=100, num_training_steps=1000 )
核心代码实现
训练循环关键部分
# Early Stopping 实现
class EarlyStopping:
def __init__(self, patience=3, delta=0.001):
self.patience = patience
self.delta = delta
self.counter = 0
self.best_score = None
def __call__(self, val_loss):
if self.best_score is None:
self.best_score = val_loss
elif val_loss > self.best_score + self.delta:
self.counter += 1
if self.counter >= self.patience:
return True
else:
self.best_score = val_loss
self.counter = 0
return False
完整训练流程
- 数据准备阶段
- 用
TextDataset封装数据 - 创建
WeightedRandomSampler -
注意不要对测试集做任何采样
-
模型初始化
- 加载预训练 BERT
- 替换默认的分类头
-
冻结前 8 层参数
-
训练循环
- 每个 epoch 后验证
- 只在验证集上触发 EarlyStopping
- 保存最佳模型
新手避坑指南
- Dropout 的误区
- 不要超过 0.5(会破坏 BERT 的注意力模式)
-
不同层应该用不同比率(底层 < 顶层)
-
早停的陷阱
- 绝对不要在测试集上做早停决策
-
建议保留 10% 训练集作为验证集
-
Focal Loss 的注意事项
- 当标签噪声多时(如众包标注),调小 γ 值
- α 参数需要与样本比例反向设置
效果验证
在 IMDB 数据集上的对比结果:
| 方案 | 验证集准确率 | F1-score |
|---|---|---|
| 原始 BERT | 72.3% | 0.68 |
| + 数据均衡 | 85.1% | 0.83 |
| + 模型改造 | 86.7% | 0.85 |
| 完整方案 | 89.2% | 0.88 |

(左图为过拟合时的特征分布,右图为优化后的分布)
总结与建议
对于小样本二分类任务,推荐采取以下策略组合:
1. 先用 SMOTE 调整样本比例
2. 添加带 LayerNorm 的自定义分类头
3. 采用 Focal Loss+ 动态学习率
4. 严格在独立验证集上早停
最终我的模型过拟合问题得到明显改善,验证集 F1 从 0.68 提升到 0.88。关键是要理解:数据质量比模型复杂度更重要,适当的约束反而能提升泛化能力。
正文完
