踩过坑才知道哪些路是不可行的,有时候犯错误也能帮助我们变得更加专业。
数据科学家 Archy de Berker 在本文中详述了他和周围同伴们在机器学习探索中踩过的坑,这也都是大家经常性遇到的问题。他希望通过这一篇文章,带大家了解机器学习中一些有趣的错误——一些只有你深入了这个领域才能接触到的错误。
这不是一篇入门级的文章,想要读懂本文,最好先在 Pytorch 或 Tensorow 上多练习下毁坏模型。
本文主要集中在绿色分布的错误,但紫色分布和黄色分布也会部分涉及
Berker 将机器学习中出现的各种错误归为三大类,严重程度由低到高。
计算科学中最难的两件事是命名和缓存失效,图中这条推文高度概括了此类错误。shape error 是最可怕又最常见的错误,通常是由于大小不一致的矩阵相乘导致。
本文不会花太多时间来讨论这类错误,因为错得非常明显。大家很容易找到错误,然后进行修复,然后再犯错,然后再修复。这是个不断重复的过程。
这类错误会让你付出很大代价,因为它会造成模型结果不准确。
2015年,有一架从澳大利亚悉尼飞往马来西亚吉隆坡的亚航航班因出现技术故障,在墨尔本机场紧急降落。如果模型的结果不准确,就如同这架飞机的技术故障,最终会飞到错误的目的地。
举个例子,假如你在模型中新增了一个特征并同时增加了许多参数,在未进行超参数调优的情况下对比之前的性能,发现增加特征后模型性能变差了,于是得出结论,增加的特征会让模型性能变差。这是不对的,实际上你需要更加规范化的操作,以寻求更具表现力的模型。
错误对于模型的影响会随着时间而加重,导致更加不准确的实验结果。因此,尽早发现错误是非常有价值的。
这是很严重的错误,会让你高估模型的性能。这种错误通常很难发现,因为我们从心底里不愿承认看似”完美“的模型可能是假象。
当模型表现得出奇差时,我们倾向于不相信然后再测试一遍,但当模型表现得出奇好时,我们通常会相信并开始沾沾自喜。这就是所谓的确认偏差(Confirmation Bias),即个人无论合乎事实与否,都倾向于偏好支持自己的见解、猜想。
模型性能看似「完美」,通常是因为过拟合的原因,导致训练数据已经不再有代表性,或者是因为选错了评价指标,这两点后文都会详细解释。
如果你只能从本文带走一点,希望你记住:没有什么比你发现模型的真正结果实际上很糟糕这件事更令人尴尬和沮丧的了。
机器学习就如同上图香肠机的三个阶段一样:获取数据,将数据输入到模型中,然后通过一些指标来量化输出。
接下来我们会讨论到每个阶段中一些看似愚蠢的错误。
机器学习可以归结为不断减少损失函数值的过程。
但是,损失函数绝不是最终的优化目标,它只是一个近似值。例如训练分类任务时,通过交叉熵损失函数来优化训练集或者验证集,但实际上我们更加信任在测试集上的结果或者说 F1、AUC 评价指标。在实际优化目标的数据差异非常小的情况下,在模型评价上采用低置信度来加速评价过程会导致问题更加严重。
无论在哪种环境下,如果损失函数已经不能代表模型的真实表现,那么麻烦就大了。
此外还有一些让模型更糟糕的做法:
1)混合训练集和测试集
混合训练集和测试集是很容易的,而且通常会训练出来很不错的性能,但这样的模型在复杂的真实环境中会表现非常糟糕。
所以,训练集、验证集、测试集数据是不能相交的,各自需要包含不同的样本数据。我们要思考模型需要怎样的泛化能力,这最终会通过测试集的性能来量化。
以商店收据的数据为例,使用商店的收据进行分析预测,那么测试集显然需要包含以前没见过的新数据,但是测试集是否也需包含以前没见过的新商品以保证模型不会对特定商店过度测试呢 (过拟合)?
最好的方式是一次性将数据分为训练集、验证集和测试集,然后放在不同的文件夹下,且命名都应该非常明确,例如 TrainDataLoader 和 TestDataLoader。
2)错误使用损失函数
错误使用损失函数其实是很少出现的,因为已经有无数的材料教会大家如何使用损失函数。最常见的两种错误使用损失函数的情况,一个是搞不清楚损失函数要使用概率分布还是对数(即是否需要添加 softmax),另一个就是混淆了回归函数和分类函数。
即使在学术界,混淆回归函数和分类函数也是很普遍的。例如亚马逊用户的评价和评星数据 Amazon Reviews 数据集,经常被顶级实验室用于分类任务,但这其实是不太对的,因为与 1 星评价相比,5 星评价显然更类似于 4 星评价,应该采用有序回归。
不同的任务使用的损失函数不一样,而在模型性能的验证上,我们也经常组合多个侧重点不一样的评价指标来评估模型性能。例如,机器翻译首选 BLEU 作为评价指标,自动文摘采用 ROUGE 来验证性能,而对于其他任务,可以选用准确性,精确度或召回率作为评价指标。
通常,评价指标比损失函数容易让人理解。一个好的思路是尽可能多地记录日志。
认真思考如何划分不相交的训练集、测试集和验证集,让模型具有优异而不过度的泛化能力。在训练过程中可以使用评价指标来测试模型性能,而不必等到最后才开始使用测试集来测试。这样有助于更好地理解模型当前的训练结果,防止问题到最后才暴露。
评价指标的选择上要多注意。举个例子,你不能用简单使用准确性来评估序列模型的性能,因为序列间的非对齐情况会导致准确率为 0。因此对于序列数据要采用距离来评估。选错评价指标是非常痛苦的事情。
还是以序列模型为例,请确保排除了所有特殊字符,特殊字符通常是序列的开头、结尾和填充。如果忘记了排除特殊字符,可能会得到看起来不错的模型性能,但这样的模型实际上只能预测充满填充字符的长序列。
有一个让作者印象非常深刻的错误,其曾经做过一些语义解析工作,目的是将自然语言语句转换为数据库查询,回答诸如「明天从蒙特利尔到亚特兰大有多少趟航班?」这样的典型 SQL 问题。为了评价模型的准确性,他们将模型转义的 SQL 查询发送到数据库,检查返回的内容是否与真实查询的内容匹配。他设置了一种情况,如果向数据库发送毫无意义的查询,数据库返回「error」。然后,他发送了已经被损坏的预测 SQL 和真实 SQL 到数据库查询,两者都返回「error」,模型将这种情况计算为: 100%准确。
这就引出了指导原则,你犯的任何错误只会使性能变差。要坚持检查模型实际做出的预测,而不仅仅是关注评价指标的结果。
1) 首先跑一遍所有评价指标
在没有任何训练的情况下如果模型表现很好,那一定是有问题的。
2) 所有过程都记录日志
机器学习是一门定量学科,但数字有时候也可能会骗人,所有可以想到的数字都记录日志,但要以容易理解的方式记录。
在 NLP 中,这通常意味着你需要颠倒标记,这过程很复杂,但百分百是值得的,日志提供了模型训练过程中的定性解释。例如,语言模型通常从学习输出类似 eeeeeeeeee <PAD> <PAD> <PAD>字符串开始,因为这些都是数据中最常见的字符。
如果是处理图像任务,那么日志就更加麻烦了,因为你不能将图片以文本的形式存为日志。可以通过使用 ASCII 解决这一问题,即在 OCR 的训练过程中使用 ASCII 保存日志,从而能可视化输入的图像数据:
3)研究验证集
使用测试评价指标来确定集合中性能最佳和最差的样本。了解样本情况,使用一些量化置信度的方法(如 softmax),了解模型可能在哪些分布上表现良好、哪些分布上会表现糟糕,在回归任务中残差分析是很有用的。
但是请记住,正如 Anscombe Quartet 指出的那样,平均值可能会误导你。
Anscombe Quartet:所有 4 个模型的均值和方差均相同,而且它们都拟合了同一条回归线 t。因此,不用过分依赖统计结果,要理解数据本身。
如果遇到多维问题,尝试绘制错误与单个特征的关系图来找出原因。是否存在模型表现非常差的输入空间区域?如果是这样,你可能需要在该数据区域补充更多数据或进行数据增强。
考虑消融和干扰在模型性能中的影响。诸如 LIME 和 Eli5 之类的工具可以让模型变简单。下面这篇文章很好地描述了扰动分析,揭示了用于 X 射线分类的 CNN 模型使用 X 射线机本身引入的标签来确定患者是否患有肺炎,而不是 X 射线机的使用本身可能和患病率之间存在的相关性:
https://medium.com/@jrzech/what-are-radiological-deep-learning-models-actually-learning-f97a546c5b98
现在很多课程和文章都将重点放在建模方面。但实际上,作为机器学习从业者,大部分时间都是在处理数据和指标,而不是研究创新的算法。
深度学习错误中的绝大多数都是形状错误( shape error),从而导致很多浅显的错误发生。
模型错误类型很多,如下:
1) 包含不可微分运算操作的模型
在深度学习模型中,一切都必须是端到端可微分的,以支持反向计算。因此,你可能希望不可微分操作能够在 TensorFlow 等深度学习框架中被明确标识出来。这是不对的,正如 Berker 曾经对 Keras Lambda 层感到特别困惑,因为它可以破坏反向计算。一个解决办法是使用 model.summary() 进行检查,以验证大多数参数是可训练的,如果发现有不可训练参数的 layer,则可能是破坏了自动微分能力。
2)在测试时没有成功关闭 dropout
我们都知道,在测试数据时需要关闭 dropout,否则可能获得的是随机结果。这可能非常令人困惑,尤其是对于正在部署模型并开始跑测试集的人而言。
这一问题可以通过 eval() 来解决。另外需要注意的是,在训练模型时 dropout 可能会导致一个奇怪现象——模型在验证集上的准确性高过比训练集上的准确性。这是因为在验证集上用到了 dropout,这看起来可能是欠拟合了,而且可以会造成一些让你头疼的问题。
3)维度参数错误
不同框架在样本数 (batch size),序列长度 (sequence length) 和通道数 (channels) 上有不一样的约定,有些框架提供了在这三者上的修改空间,但其他的框架是不允许任意修改的,修改就会出错。
维度参数错误可能会产生奇怪现象。例如,如果你弄错了样本数和序列长度,那么最终可能会忽略部分样本的信息,并且无法随着时间保存信息。
1)模块化,可测试
如果发现有不可训练参数的层,则可能是破坏了自动微分能力。
编写结构合理的代码并进行单元测试是有助于避免模型错误的。
将模型分为几个离散的代码块,每个代码块有明确的功能定义,就可以对其进行有效的测试。测试的重点,在于验证变化样本数和输入数据量的情况下,模型是否与预期一致?Berker 推荐了 Chase Roberts 的一篇帖子,详细介绍了 ML 代码的单元测试:
https://medium.com/@keeper6928/how-to-unit-test-machine-learning-code-57cf6fd81765
2)维度论断
Berker 倾向于将维度论断加入到 ML 代码中,让读者可以清楚地知道哪些维度应该更改,哪些不应该更改。当然,如果发生意外,它会引发错误。
富有表达力的 Tensorflow 代码,由 Keith Ito 提供。注意模块化和注释。
至少要养成在代码中添加维度注释的习惯,让读者可以直接阅读而不需要记忆大量信息。请前往以下地址查看 Keith Ito 实现 beautifulTacotron 的代码,这是一个注释的优秀范例:
https://github.com/keithito/tacotron/blob/master/models/tacotron.py
3)小数据简单模型的过拟合问题
技巧:先确保模型在非常小的一部分数据集上进行过拟合训练,短时间内排除明显的错误。
尽量让模型能轻松通过配置文件进行配置,并指定参数最少的测试配置。然后在 CI/CD 中添加一个步骤,检查非常小的数据集的过拟合,并自动运行它。这将有助于捕获破坏模型和训练 管道的代码改动。
在开始建模之前,你应该就已经厌倦了数据探查吧。
大多数机器学习模型都在尝试复制人脑的某些模式识别能力。在开始编写代码之前需要熟悉数据,锻炼模式识别能力,让你的代码写的更轻松!了解数据集有助于整体架构的考虑和指标的选择,而且能够迅速识别可能会出现性能问题的地方。
一般来说,数据本身就可以识别一些问题:数据不平衡,文件类型问题或者数据偏见。数据偏见很难通过算法进行评估,除非你有一个非常「聪明」的模型能识别这些问题。例如,这个「聪明」的模型能自己意识到偏见,「所有猫的照片都是在室内拍摄的,所有狗的图片都是在室外拍摄的,所以也许我正在训练室内/室外分类器,而不是识别猫和狗的分类器?」。
Karpathy 为 ImageNet 建立了一个标注平台,以评估他自己的表现并加深他对数据集的理解。
正如 Karpathy 所说的那样,数据探查的系统能够完成数据查看、数据切块和切片。2018 年在伦敦举办的 KDD 上,他在演讲中强调,Uber 的许多 ML 工程师并不是在编写代码来优化模型,而是编写代码优化数据标签。
要了解数据,首先需要明白以下三种数据分布:
输入数据的分布情况,例如平均序列长度,平均像素值,音频时长
输出数据的分布情况,分类失衡是一个大问题
输出/输入的分布情况,这通常就是你要建模的内容
有效地加载和预处理数据是机器学习工程中比较痛苦的环节之一,往往要在效率和透明度之间权衡取舍。
像 Tensorow Records 这样的专用数据结构可以将数据序列转为大数据包,减少对磁盘的频繁读取/写入,但是这样的作法却有损透明度:这些结构很难再进一步研究或者分解数据,如果你想要添加或者删除一些数据,则必须重新序列化。
目前 Pytorch Dataset 和 DatasetLoader 是平衡透明度和效率比较好的办法,专用的程序包 torchtext 处理文本数据集,torchvision 处理图像数据集,这些程序包提供了相对有效的加载方式,填充并批处理每个域中的数据。
以下是 Berker 在加快数据加载的尝试过程中所得到的经验教训:
1)不要加载目前正在加载的数据
这是因为你最终会发现,这样做可能会丢失数据或者加载了重复数据。Berker 曾踩过的坑:
编写正则表达式从文件夹中加载某些文件,但是在添加新文件时没有更新正则文件,这意味着新文件无法成功加载
错误计算一个Epoch中的步数导致跳过了一些数据集
文件夹中有递归符号,导致多次加载相同的数据(在 Python 中,递归限制为 1000)
无法完全遍历文件层次结构,因而无法将数据加载到子文件夹中
2) 错误存放数据
不要把所有数据放在一个目录中。
如果你有上百万个文本文件全部放在一个文件夹中,那么任何操作都会非常非常慢。有时候哪怕仅仅查看或计算的动作,都需要等待大量的文件夹加载,从而大大降低了工作效率。如果数据不在本地,而是远程存储在数据中心,使用 sshfs 挂载目录,情况会更加糟糕。
第二个错误陷阱就是在预处理时没有备份数据。正确的做法是将耗时的预处理结果保存到磁盘中,这样就不必在每次运行模型时都要重来一遍,不过要确保不覆盖原数据,并需要一直跟踪在哪些数据上运行了哪些预处理代码。
下图是很好的一个示例:
3)不恰当的预处理
在预处理中出现数据滥用的情况是常见的,尤其是在 NLP 任务中。
非 ASCII 字符的错误处理是一个很大的痛点,这种情况不常出现,因此很难发现。
分词也会导致很多错误发生。如果使用的是基于词的分词,很容易基于一个数据集形成词汇表,结果在另一个数据集上使用的时候发现,大量的词汇在词汇表上找不到。这种情况模型并不报错,它只是在别的数据集上表现不好。
训练集和测试集之间的词汇差异同样是问题,因为那些只出现在测试集的词汇是没有被训练的。
因此,了解数据并尽早发现这些问题是非常有价值的。
1)尽可能多记录日志
确保每次数据处理时都有样本数据的日志,不应该只记录模型结果日志,还应该记录过程日志。
2) 熟记模型超参数
你需要非常熟悉模型超参数:
有多少样本数?
一次训练所选取的样本数有多大?
一个Epoch有多少批处理?
这些同样要记录日志,或者可以添加一些论断来确保所有内容都没有拉下。
3)预处理过程中记录所有状态
某些预处理步骤需要使用或创建工件 ,因此需要记得将其保存下来。例如,使用训练集的平均数和变量正则化数值数据,并保存平均数和变量,以便可以在测试时应用相同的变换。
同样,在NLP中,如果不保存训练集的词汇表,就无法在测试时以相同的方式进行分词。如果在测试中形成新的词汇表并重新分词就会产生无意义的结果,因为每个单词都将得到一个完全不同的标记。
4) 降采样
当数据集非常大(例如图像和音频)时,将数据输入到神经网络中,期望模型能够学习到最有效的预处理方法。如果有无限的时间和计算能力,那么这可能是个好方法,但是在实际情况中,降采样是比较合适的选择。
我们不需要全高清图像来训练狗/猫分类器,可以使用扩张卷积 来学习降采样器,或者传统的梯度下降完成降采样。
降采样可以更快地完成模型拟合和评估,是较好的节约时间的做法。
总结一下在机器学习应遵循的 5 条指导原则:
从小处着手,实验会进行的很快。减少循环时间能够及早发现问题并更快地验证假设。
了解数据。不了解数据就无法做好建模的工作。不要浪费时间在花哨的模型上,要沉心静气地完成数据探查工作。
尽量多地记录日志。训练过程的信息越多,就容易识别异常并进行改进。
注重简单性和透明性而不仅仅是效率。不要为了节省少量时间而牺牲了代码的透明性。理解不透明代码所浪费的时间要比低效算法的运行时间多得多。
如果模型表现优异令人难以置信,那可能就是有问题。机器学习中存在很多错误可能会「愚弄」你,成为一名优秀的科学家意味着要理性的发现并消除这些错误。
推荐阅读:
Andrej Karpathy 也写了一篇非常出色的博客《A Recipe for Training Neural Networks》,同样是讲机器学习的常见错误,但 Karpathy 更加专注于技术细节和深度学习,阅读地址如下:
http://karpathy.github.io/2019/04/25/recipe/
via https://towardsdatascience.com/rookie-errors-in-machine-learning-bc1c627f2789 雷锋网雷锋网雷锋网