MNIST分类CNN严重过拟合求助:常规抑过拟合手段无效
看起来你遇到了挺头疼的过拟合问题,而且数据增强的表现还和预期相反——别担心,我们一步步拆解问题,找到根源:
一、为什么你的数据增强起反作用?
你用的随机90度旋转其实对MNIST任务来说是有害的噪声:MNIST里的手写数字都是正向的,旋转90/180/270度后,数字会变成模型从未见过的“异常样本”(比如旋转90度的2根本不像2),模型花了大量精力去学习这些无意义的特征,自然会影响正常任务的性能。
你应该换成适合MNIST的轻微数据增强,比如:
- 小角度旋转(±15度以内)
- 轻微平移(上下左右各1-2像素)
- 随机缩放(0.9-1.1倍)
- 轻微剪切变形
这样的增强才是在模拟真实手写数字的自然变化,而不是制造完全陌生的样本。
二、模型结构的核心问题
你的ConvBlock设计有几个明显的不合理之处,这是导致过拟合的关键:
1. 每个Conv层后直接加MaxPool,特征丢失过快
输入是28x28的图像,你的第一个ConvBlock设置了conv_deep=2,意味着每个Conv后都要做一次Pool:
- 第一次Conv+Pool:28x28 → 14x14
- 第二次Conv+Pool:14x14 →7x7
再加上第二个ConvBlock的Pool,最终特征图缩小到3x3,这会丢失大量细粒度的手写特征,模型只能靠记住训练样本拟合,自然过拟合。
正确的做法是:多个Conv层共享一个Pool层,比如2个Conv后再做一次Pool,让模型充分提取特征后再缩小尺寸。
2. BatchNormalization和Dropout的顺序错误
你当前的层顺序是:Conv → Pool → BN → Dropout,这不符合最佳实践。正确的顺序应该是:Conv2D → BatchNormalization → Activation → MaxPooling → Dropout
(现在主流是把BN放在激活函数之前,能避免激活函数导致的梯度消失)
另外,你手动在call里判断training来应用Dropout是多余的——Keras的Dropout层会自动根据训练/推理模式切换,不需要手动处理。
3. 优化器学习率过高
你用了Adadelta(7.0),Adadelta的默认学习率是1.0,7.0的学习率太大了,会导致模型训练不稳定,参数震荡,无法收敛到泛化性好的点,反而容易记住训练数据的噪声。
三、具体修改方案
1. 重构ConvBlock结构
class ConvBlock(tf.keras.layers.Layer): """修正后的卷积块:多Conv共享一个Pool,调整BN/Dropout顺序""" def __init__(self, conv_deep=1, kernels=32, kernel_size=3, pool_size=2, dropout_rate=0.4): super(ConvBlock, self).__init__() self.conv_layers = [] self.bnorm_layers = [] self.pooling_layer = tf.keras.layers.MaxPool2D(pool_size=pool_size) self.dropout_layer = tf.keras.layers.Dropout(dropout_rate) for _ in range(conv_deep): self.conv_layers.append(tf.keras.layers.Conv2D(filters=kernels, kernel_size=kernel_size, padding="same")) self.bnorm_layers.append(tf.keras.layers.BatchNormalization()) def call(self, inputs, training=False): output = inputs for conv, bnorm in zip(self.conv_layers, self.bnorm_layers): output = conv(output) output = bnorm(output, training=training) output = tf.keras.activations.relu(output) output = self.pooling_layer(output) output = self.dropout_layer(output, training=training) return output
2. 修正数据增强函数
def transform_sample(digit, label): # 小角度旋转(±15度) angle = tf.random.uniform([], -15, 15, dtype=tf.float32) t_digit = tf.keras.layers.experimental.preprocessing.rotation(digit, angle) # 轻微平移(上下左右最多2像素) t_digit = tf.keras.layers.experimental.preprocessing.random_translation(t_digit, 0.07, 0.07) # 随机缩放(0.9-1.1倍) t_digit = tf.keras.layers.experimental.preprocessing.random_zoom(t_digit, (0.9, 1.1)) return t_digit, label
3. 调整模型结构与优化器
class DigitsClassifier(tf.keras.Model): """优化后的分类器""" def __init__(self): super(DigitsClassifier, self).__init__() # 2个Conv层后再Pool,避免特征过早丢失 self.conv_input = ConvBlock(conv_deep=2, kernels=32) self.conv_hiden = ConvBlock(conv_deep=2, kernels=64) # 增加Conv深度,减少全连接参数 # 用GlobalAveragePooling代替Flatten,大幅减少参数,避免过拟合 self.global_pool = tf.keras.layers.GlobalAveragePooling2D() self.softmax = tf.keras.layers.Dense(10, "softmax") def call(self, inputs, training=False): output = self.conv_input(inputs, training=training) output = self.conv_hiden(output, training=training) output = self.global_pool(output) output = self.softmax(output) return output # 优化器换成Adam,学习率用默认的0.001,比Adadelta更稳定 model = DigitsClassifier() model.compile(tf.keras.optimizers.Adam(), tf.keras.losses.SparseCategoricalCrossentropy(), metrics=["accuracy"]) # 加上准确率指标,方便监控 model.fit(train_ds, epochs=30, verbose=2, validation_data=eval_ds)
四、额外的抑过拟合建议
- 减少全连接层的参数:用
GlobalAveragePooling2D代替Flatten是非常有效的方式,能把参数从几万降到几十 - 调整Dropout率:可以尝试把Dropout率调到0.2-0.3,过高的Dropout会让模型学习不到足够的特征
- 早停法:加入
EarlyStopping回调,当验证集损失不再下降时自动停止训练,避免过度训练early_stop = tf.keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True) model.fit(..., callbacks=[early_stop]) - 权重衰减:在优化器中加入L2正则化,限制参数的大小
model.compile(tf.keras.optimizers.Adam(weight_decay=1e-4), ...)
内容的提问来源于stack exchange,提问作者ElPapi42

