根据前面我的文章看来,咱们只能控制可以观察到的东西。因为您的目标是开发出能够成功泛化到新数据的模型,所以能够可靠地衡量模型泛化能力是至关重要的,咱们这篇文章将正式介绍评估机器学习模型的各种方法。
政安晨的个人主页:政安晨
欢迎 👍点赞✍评论⭐收藏
收录专栏: 政安晨的机器学习笔记
希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!
本系列的前一篇文章为:
政安晨:【机器学习基础】(一)—— 泛化:机器学习的目标https://blog.csdn.net/snowdenkeke/article/details/136275013
训练集、验证集和测试集
评估模型的重点是将可用数据划分为三部分:训练集、验证集和测试集。
在训练数据上训练模型,在验证数据上评估模型。模型准备上线之前,在测试数据上最后测试一次,测试数据应与生产数据尽可能相似。做完这些工作之后,就可以在生产环境中部署该模型。
你可能会问,为什么不将数据划分为两部分,即训练集和测试集?在训练数据上进行训练,在测试数据上进行评估。这样做简单多了。
原因在于开发模型时总是需要调节模型配置,比如确定层数或每层大小[这些叫作模型的超参数(hyperparameter),以便与参数(权重)区分开]。这个调节过程需要使用模型在验证数据上的表现作为反馈信号。
该过程本质上是一种学习过程:在某个参数空间中寻找良好的模型配置。因此,基于模型在验证集上的表现来调节模型配置,很快会导致模型在验证集上过拟合,即使你并没有在验证集上直接训练模型。造成这一现象的核心原因是信息泄露(information leak)。
每次基于模型在验证集上的表现来调节模型超参数,都会将验证数据的一些信息泄露到模型中。如果对每个参数只调节一次,那么泄露的信息很少,验证集仍然可以可靠地评估模型。但如果多次重复这一过程(运行一次实验,在验证集上评估,然后据此修改模型),那么会有越来越多的验证集信息泄露到模型中。
最后得到的模型在验证数据上的表现非常好——这是人为造成的,因为这正是你优化模型的目的。你关心的是模型在全新数据上的表现,而不是在验证数据上的表现,因此你需要一个完全不同、前所未见的数据集来评估模型,这就是测试集。
你的模型一定不能读取与测试集有关的任何信息,间接读取也不行,如果基于测试集表现对模型做了任何调节,那么对泛化能力的衡量将是不准确的。
将数据划分为训练集、验证集和测试集,看起来可能很简单,但如果可用数据很少,那么有几种高级方法可以派上用场。
我们将介绍三种经典的评估方法:简单的留出验证、K折交叉验证,以及带有打乱数据的重复K折交叉验证。我们还会介绍使用基于常识的基准,以判断模型训练是否有效。
简单的留出验证
留出一定比例的数据作为测试集。在剩余的数据上训练模型,然后在测试集上评估模型。如前所述,为防止信息泄露,你不能基于测试集来调节模型,所以还应该保留一个验证集。
下图展示了留出验证的原理,并且往下代码给出了其简单实现:
留出验证(下段代码省略了标签,不可执行)
num_validation_samples = 10000# 通常需要打乱数据
np.random.shuffle(data)# 定义验证集
validation_data = data[:num_validation_samples]# 定义训练集
training_data = data[num_validation_samples:]# (本行及以下2行)在训练数据上训练模型,然后在验证数据上评估模型
model = get_model()
model.fit(training_data, ...)
validation_score = model.evaluate(validation_data, ...)# 现在可以对模型进行调节、重新训练、评估,然后再次调节
...# (本行及以下3行)调节好模型的超参数之后,通常的做法是在所有非测试数据上从头开始训练最终模型
model = get_model()model.fit(np.concatenate([training_data,validation_data]), ...)test_score = model.evaluate(test_data, ...)
这是最简单的评估方法,但它有一个缺点:如果可用的数据很少,那么可能验证集包含的样本就很少,无法在统计学上代表数据。
这个问题很容易发现:在划分数据前进行不同的随机打乱,如果最终得到的模型性能差别很大,那么就存在这个问题。接下来会介绍解决这一问题的两种方法:K折交叉验证和重复K折交叉验证。
K折交叉验证
K折交叉验证是指将数据划分为K个大小相等的分区。
对于每个分区i,在剩余的K-1个分区上训练模型,然后在分区i上评估模型。最终分数等于K个分数的平均值。对于不同的训练集−测试集划分,如果模型的性能变化很大,那么这种方法很有用。与留出验证一样,这种方法也需要独立的验证集来校准模型。
下图展示了K折交叉验证的原理,下面代码给出了其简单实现:
K折交叉验证(省略了标签)
k = 3
num_validation_samples = len(data) // k
np.random.shuffle(data)
validation_scores = []
for fold in range(k):# (本行及以下1行)选择验证数据分区validation_data = data[num_validation_samples * fold:num_validation_samples * (fold + 1)]# (本行及以下2行)使用剩余数据作为训练数据。注意,+运算符表示列表拼接,不是加法training_data = np.concatenate(data[:num_validation_samples * fold],data[num_validation_samples * (fold + 1):])# 创建一个全新的模型实例(未训练)model = get_model()model.fit(training_data, ...)validation_score = model.evaluate(validation_data, ...)validation_scores.append(validation_score)# 最终验证分数:K折交叉验证分数的平均值
validation_score = np.average(validation_scores)# (本行及以下2行)在所有非测试数据上训练最终模型
model = get_model()
model.fit(data, ...)
test_score = model.evaluate(test_data, ...)
带有打乱数据的重复K折交叉验证
如果可用的数据相对较少,而你又需要尽可能精确地评估模型,那么可以使用带有打乱数据的重复K折交叉验证。
您会发现这种方法在Kaggle竞赛中特别有用,具体做法是多次使用K折交叉验证,每次将数据划分为K个分区之前都将数据打乱。最终分数是每次K折交叉验证分数的平均值。注意,这种方法一共要训练和评估P * K个模型(P是重复次数),计算代价很大。
超越基于常识的基准
除了不同的评估方法,你还应该了解的是利用基于常识的基准。
训练深度学习模型就好比在平行世界里按下发射火箭的按钮,你听不到也看不到。你无法观察流形学习过程,它发生在数千维空间中,即使投影到三维空间中,你也无法解释它。唯一的反馈信号就是验证指标,就像隐形火箭的高度计。
特别重要的是,我们需要知道火箭是否离开了地面。发射地点的海拔高度是多少?模型似乎有15%的精度——这算是很好吗?在开始处理一个数据集之前,你总是应该选择一个简单的基准,并努力去超越它。如果跨过了这道门槛,你就知道你的方向对了——模型正在使用输入数据中的信息做出具有泛化能力的预测,你可以继续做下去。这个基准既可以是随机分类器的性能,也可以是你能想到的最简单的非机器学习方法的性能。
比如对于MNIST数字分类示例,一个简单的基准是验证精度大于0.1(随机分类器);对于IMDB示例,基准可以是验证精度大于0.5。对于路透社示例,由于类别不均衡,因此基准约为0.18~0.19。对于一个二分类问题,如果90%的样本属于类别A,10%的样本属于类别B,那么一个总是预测类别A的分类器就已经达到了0.9的验证精度,你需要做得比这更好。
在面对一个全新的问题时,你需要设定一个可以参考的基于常识的基准,这很重要。如果无法超越简单的解决方案,那么你的模型毫无价值——也许你用错了模型,也许你的问题根本不能用机器学习方法来解决。这时应该重新思考解决问题的思路。
模型评估的注意事项
选择模型评估方法时,需要注意以下几点:
数据代表性(data representativeness)。训练集和测试集应该都能够代表当前数据。假设你要对数字图像进行分类,而初始样本是按类别排序的,如果你将前80%作为训练集,剩余20%作为测试集,那么会导致训练集中只包含类别0~7,而测试集中只包含类别8和9。这个错误看起来很可笑,但非常常见。因此,将数据划分为训练集和测试集之前,通常应该随机打乱数据。
时间箭头(the arrow of time)。如果想根据过去预测未来(比如明日天气、股票走势等),那么在划分数据前不应该随机打乱数据,因为这么做会造成时间泄露(temporal leak):模型将在未来数据上得到有效训练。对于这种情况,应该始终确保测试集中所有数据的时间都晚于训练数据。
数据冗余(redundancy in your data)。如果某些数据点出现了两次(这对于现实世界的数据来说十分常见),那么打乱数据并划分成训练集和验证集,将导致训练集和验证集之间出现冗余。从效果上看,你将在部分训练数据上评估模型,这是极其糟糕的。一定要确保训练集和验证集之间没有交集。
有了评估模型性能的可靠方法,你就可以监控机器学习的核心矛盾——优化与泛化之间的矛盾,以及欠拟合与过拟合之间的矛盾。
改进模型拟合
为了实现完美的拟合,你必须首先实现过拟合。由于事先并不知道界线在哪里,因此你必须穿过界线才能找到它。在开始处理一个问题时,你的初始目标是构建一个具有一定泛化能力并且能够过拟合的模型。得到这样一个模型之后,你的重点将是通过降低过拟合来提高泛化能力。
在这一阶段,你会遇到以下3种常见问题:
训练不开始:训练损失不随着时间的推移而减小。
训练开始得很好,但模型没有真正泛化:模型无法超越基于常识的基准。
训练损失和验证损失都随着时间的推移而减小,模型可以超越基准,但似乎无法过拟合,这表示模型仍然处于欠拟合状态。
我们来看一下如何解决这些问题,从而抵达机器学习项目的第一个重要里程碑:
得到一个具有一定泛化能力(可以超越简单的基准)并且能够过拟合的模型。
调节关键的梯度下降参数
有时训练不开始,或者过早停止。损失保持不变。这个问题总是可以解决的——请记住,对随机数据也可以拟合一个模型。即使你的问题毫无意义,也应该可以训练出一个模型,不过模型可能只是记住了训练数据。
出现这种情况时,问题总是出在梯度下降过程的配置:优化器、模型权重初始值的分布、学习率或批量大小。所有这些参数都是相互依赖的,因此,保持其他参数不变,调节学习率和批量大小通常就足够了。
咱们来看一个具体的例子:
训练MNIST模型,但选取一个过大的学习率(取值为1),代码如下所示:
(使用过大的学习率训练MNIST模型)
(train_images, train_labels), _ = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255model = keras.Sequential([layers.Dense(512, activation="relu"),layers.Dense(10, activation="softmax")
])
model.compile(optimizer=keras.optimizers.RMSprop(1.),loss="sparse_categorical_crossentropy",metrics=["accuracy"])
model.fit(train_images, train_labels,epochs=10,batch_size=128,validation_split=0.2)
这个模型的训练精度和验证精度很快就达到了30%~40%,但无法超出这个范围。下面我们试着把学习率降低到一个更合理的值1e-2,代码如下所示:
(使用更合理的学习率训练同一个模型)
model = keras.Sequential([layers.Dense(512, activation="relu"),layers.Dense(10, activation="softmax")
])
model.compile(optimizer=keras.optimizers.RMSprop(1e-2),loss="sparse_categorical_crossentropy",metrics=["accuracy"])
model.fit(train_images, train_labels,epochs=10,batch_size=128,validation_split=0.2)
注:要使用上述代码进行训练,您还需先准备如下工作,再执行上述代码:
import tensorflow from tensorflow import keras from tensorflow.keras import layersfrom tensorflow.keras.datasets import mnist(train_images, train_labels), _ = mnist.load_data() train_images = train_images.reshape((60000, 28 * 28)) train_images = train_images.astype("float32") / 255
演绎如下:
现在模型可以正常训练了。
如果你自己的模型出现类似的问题,那么可以尝试以下做法:
降低或提高学习率。学习率过大,可能会导致权重更新大大超出正常拟合的范围,就像前面的例子一样。学习率过小,则可能导致训练过于缓慢,以至于几乎停止。增加批量大小。如果批量包含更多样本,那么梯度将包含更多信息且噪声更少(方差更小)。最终,你会找到一个能够开始训练的配置。
利用更好的架构预设
你有了一个能够拟合的模型,但由于某些原因,验证指标根本没有提高。这些指标一直与随机分类器相同,也就是说,模型虽然能够训练,但并没有泛化能力。这是怎么回事?
这也许是你在机器学习中可能遇到的最糟糕的情况。这表示你的方法从根本上就是错误的,而且可能很难判断问题出在哪里。下面给出一些提示:
首先,你使用的输入数据可能没有包含足够的信息来预测目标。也就是说,这个问题是无法解决的。前面我们试图拟合一个标签被打乱的MNIST模型,它就属于这种情况:模型可以训练得很好,但验证精度停留在10%,因为这样的数据集显然是不可能泛化的。
其次,你使用的模型类型可能不适合解决当前问题。对于一个时间序列预测问题的示例,密集连接架构的性能无法超越简单的基准,而更加合适的循环架构则能够很好地泛化。模型能够对问题做出正确的假设,这是实现泛化的关键,你应该利用正确的架构预设。
未来年还会学到针对各种数据模式的最佳架构,这些数据模式包括图像、文本、时间序列等。总体来说,你应该充分调研待解决任务的架构最佳实践,因为你可能不是第一个尝试解决这个任务的人。
提高模型容量
如果你成功得到了一个能够拟合的模型,验证指标正在下降,而且模型似乎具有一定的泛化能力,那么您就快要成功了。接下来,你需要让模型过拟合。
考虑下面这个小模型,如下代码所示:它是在MNIST上训练的一个简单的logistic回归模型。
model = keras.Sequential([layers.Dense(10, activation="softmax")])
model.compile(optimizer="rmsprop",loss="sparse_categorical_crossentropy",metrics=["accuracy"])
history_small_model = model.fit(train_images, train_labels,epochs=20,batch_size=128,validation_split=0.2)
演绎如下:
模型得到的损失曲线如下所示:
import matplotlib.pyplot as plt
val_loss = history_small_model.history["val_loss"]
epochs = range(1, 21)
plt.plot(epochs, val_loss, "b--",label="Validation loss")
plt.title("Effect of insufficient model capacity on validation loss")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend()
演绎如下:
上图为模型容量不足对损失曲线的影响。
验证指标似乎保持不变,或者改进得非常缓慢,而不是达到峰值后扭转方向。
验证损失达到了0.26,然后就保持不变。
你可以拟合模型,但无法实现过拟合,即使在训练数据上多次迭代之后也无法实现。
在你的职业生涯中,你可能会经常遇到类似的曲线。
请记住,任何情况下应该都可以实现过拟合。与训练损失不下降的问题一样,这个问题也总是可以解决的。如果无法实现过拟合,可能是因为模型的表示能力(representational power)存在问题:你需要一个容量(capacity)更大的模型,也就是一个能够存储更多信息的模型。若要提高模型的表示能力,你可以添加更多的层、使用更大的层(拥有更多参数的层),或者使用更适合当前问题的层类型(也就是更好的架构预设)。
我们尝试训练一个更大的模型,它有两个中间层,每层有96个单元:
model = keras.Sequential([layers.Dense(96, activation="relu"),layers.Dense(96, activation="relu"),layers.Dense(10, activation="softmax"),
])
model.compile(optimizer="rmsprop",loss="sparse_categorical_crossentropy",metrics=["accuracy"])
history_large_model = model.fit(train_images, train_labels,epochs=20,batch_size=128,validation_split=0.2)
演绎如下:
现在验证曲线看起来正是它应有的样子:模型很快拟合,并在8轮之后开始过拟合,如下图所示:
(代码如下)
import matplotlib.pyplot as plt
val_loss = history_large_model.history["val_loss"]
epochs = range(1, 21)
plt.plot(epochs, val_loss, "b--",label="Validation loss")
plt.title("Effect of insufficient model capacity on validation loss")
plt.xlabel("Epochs")
plt.ylabel("Loss")
plt.legend()
(演绎和图如下)
至此,咱们这篇文章的讲解目标达到。