原文:towardsdatascience.com/making-the-case-for-test-driven-development-in-machine-learning-1aa84bc2a0aa

我打赌你有过这样的经历——坐在那张桌子旁或参加那个机器学习(ML)项目会议,一个机器学习工程师或数据科学家报告了为可能已经处于生产阶段的代码编写单元测试所花费的时间。但让我们暂停一下,思考一下单元测试实际上是为了什么。这里有一个定义:单元测试是一段代码,用于验证应用程序代码中较小、孤立部分的准确性,通常是函数或方法。它的设计是为了确保这段代码按照开发者的初始设计、需求和逻辑正常工作。

核心思想:确保“代码块按预期运行”,并不是什么新鲜事。软件开发者长期以来一直拥抱单元测试,通常在编写实际代码之前就实施这些测试。然而,机器学习工程师和数据科学家通常有不同的背景和方法。他们的主要目标是利用统计方法、数学和一系列预构建的库(如 SciKit Learn)将输入 X 映射到目标 Y。但是考虑一下:在他们能够调用model.fit()方法之前,必须处理许多编码任务,如读取、验证和转换数据,生成特征,并确保数据一致性。

所有这些任务都需要仔细的编码。你看到了这里的大问题吗?

在我们深入探讨之前,让我们非正式地定义测试驱动开发(TDD)为在实际代码开发之前或期间编写测试的实践。这种方法不仅确保每个函数从一开始就得到彻底测试,而且使开发过程与即期和长期项目目标保持一致。

重大事件

回到会议,当你听到那个人解释他们花了两天时间为 10%的代码编写单元测试时,你不禁要问:“这个人怎么知道剩下的代码真的做了它应该做的事情?”想想看——如果没有一些测试,我们只是在寄希望于最好的结果吗?依赖运气,代码现在不仅工作正常,而且随着规模的扩大、库的更新或新功能的集成,将继续正常工作?这不仅仅是为了捕捉错误。这是确保我们建立的基础是坚固、可靠和健壮的。毕竟,在快速发展的机器学习世界中,谁有时间为那些本可以在早期通过适当的测试捕捉到的可预防错误回溯和修复?

当你编写单元测试时,正如引言中的数据科学家所说,你与编写代码时心中的想法脱节了。 在那一刻,你只是在寻找测试通过。这是你能做到的最好的事情,因为那一刻的上下文细节已经完全丢失,这种情况可能发生在编写代码数月之后。

然而,这一切是如何发生的呢?为什么有人会在编写服务于生产中某些应用的代码之后编写测试?

这个问题的根源通常可以追溯到机器学习开发中典型的环境和实践。ML 项目具有独特的快速节奏和迭代性,通常在像 Jupyter 笔记本这样的交互式环境中进行(魔鬼的伪装)。这些工具对于快速原型设计和实验是无价的——它们允许数据科学家和 ML 工程师快速测试假设,实时调整参数,并实时可视化结果。这种方法在导航机器学习的复杂领域至关重要,及时的反馈可以显著引导项目的方向。

然而,这种非常强大的特性也带来了它的弱点。在 Jupyter 笔记本中修改代码的灵活性和便捷性可能导致对更结构化的软件实践,如编写单元测试,采取随意态度。 当代码在笔记本中演变时,很容易失去对更改及其原因的跟踪——每个微调或添加在当时似乎都很小且可控。随着代码库的增长和变得更加复杂,缺乏测试成为了一个沉重的负担。

没有单元测试,系统的每一部分对任何其他开发者——甚至是对原始作者来说,几个月后(或者说是几天)——都是一个黑盒。正如之前所述,代码创建的初始条件已经被遗忘,这使得几乎不可能保证代码在所有情况下都能按预期行为。这种疏忽不仅仅是一个小失误;它引入了重大的风险,尤其是在项目规模扩大或从研发阶段过渡到生产阶段时。通过系统测试可能早期就能识别的错误,可能只有在部署后才会显现,这可能导致在实时环境中进行昂贵且耗时的纠正。

在项目已经投入生产后,事后添加单元测试的倾向是一种低效且往往无效的做法。在这个阶段,开发者不再与每一行代码的细节紧密相连,相反,他们实际上是从现有的代码库中逆向工程测试。主要目标从确保代码满足其设计规范转变为通过任何必要手段简单地使测试通过。 这种方法可能验证基本功能,但它几乎无法捕捉到数据驱动行为和边缘情况,这些在机器学习应用中至关重要。想想看:这就像在阅读答案后写考试题目。

为了理解 TDD 可能有多有用,我将与你分享两个关于两位数据科学家马克和玛丽亚的故事。

说了太多,给我看看一些丑陋的代码

在第一个故事中,观察马克编写一个函数来对 Pandas DataFrame 列进行一些预处理。他的目的是简单的:“我需要将列表中的每个元素乘以一个系数,因为可能生成该列数据的传感器没有正确校准。”

“嗯…好的。传感器没有校准,但我恰好知道可以纠正问题的系数。我只需要一个函数,它接收一个列表和一个系数,然后返回乘以系数的相同列表。这将很优雅,因为我将使用列表推导。”

def scale_list(numbers, factor):
    return [x * factor for x in numbers]

“这很简单,这个应该可以。我怎么知道它是否工作?我可以只是用一些列表调用函数。然后去吃午饭!”

scale_list([1, 2, 3], 2)

# Output:
# [2, 4, 6]

“好的。看起来不错。”

scale_list([0, 0, 10, 10000], 5)

# Output:
# [0, 0, 50, 50000]

“太好了!它支持零。再来一个。”

scale_list([0.5, 1., 10, 10000], 1.65)

# Output:
# [0.825, 1.65, 16.5, 16500.0]

“我需要计算器来做这个。嗯,它支持浮点数。对我来说足够好了。”

马克接着使用他的函数,将其放入一个模块中,并像这样将其导入到预处理代码中。

# ---------------------
# tools.py 
# ---------------------

def scale_list(numbers, factor):
    return [x * factor for x in numbers]

# ---------------------
# preprocessing_pipeline.ipynb (yes a Jupyter notebook)
# ---------------------

from tools import scale_list

# read stuff and create a dataframe

# then use scale_list
factor = 1.5
df['scaled_values'] = df['original_values'].apply(lambda x: scale_list(x, factor))

这就是全部了。 停下来一会儿,意识到发生了什么,失去了什么,以及风险是什么。

已经回到这里了吗?

不,回去再想想。

在这种情况下,关键的缺陷是,一旦函数被认为令人满意,马克的整个推理和测试过程,他对短列表的整数、零和浮点数的非正式试验,就被丢弃了。尽管这些探索步骤对于他对函数的初始信心至关重要,但这些步骤在最终代码中留下了痕迹。这些测试的记录不存在;一旦他继续前进,它们就消失了。因此,如果他或其他人几天后重新访问这段代码,函数可能表面上看起来很健壮且精心制作:"看起来不错,"他们会说。

现在,让我们考虑一下涉及的风险。想象一下,由于任何可以想象的原因(沟通失误、上游数据处理的变化,或者可能是无数其他情况之一),DataFrame 中的“original_values”列突然看起来像这样:['0', 1, 2., 0.5, 1],甚至包括像 [0, 1.5, NaN] 这样的问题条目。

那么,接下来会怎样呢?

结果是立即的:系统陷入停滞,数据处理管道崩溃,模型无法再进行训练。整个操作停滞不前,最糟糕的部分是?没有简单的方法可以判断这种崩溃是由于初始设计中的疏忽还是新的、未预见的异常。

现在给我看看一些更好的代码

看看这个第二个备选故事。让我们介绍一下玛丽亚,她是一位数据科学家,她理解在编写代码之前(甚至同时)编写测试的重要性。

“嗯…好吧。传感器没有校准,但我碰巧知道可以纠正问题的系数。我只需要一个函数,它接收一个列表、一个系数,并返回乘以该系数的相同列表。这将很优雅,因为我将使用列表推导。”

def scale_list(numbers: list, factor: float):
    """
    Receives a list of numbers and returns a new list multiplied by a factor.
    """
    return [x * factor for x in numbers]

“看起来不错。它会工作吗?让我们看看。首先的事情是,这应该对整数和浮点数有效。”

然后她开始编写一些样板测试。她甚至编写了一些类型提示和文档!

import unittest

class TestScaleList(unittest.TestCase):

    def test_scaling_by_positive_integer_factor(self):
        factor = 2
        self.assertEqual(scale_list([1, 2, 3], factor), [2, 4, 6])

    def test_scaling_by_positive_floating_factor(self):
        factor = 2
        self.assertEqual(scale_list([1., 2., 3.], factor), [2., 4., 6.])

if __name__ == '__main__':
    unittest.main() 

认识到这项工作的努力是微不足道的。**观察在先前的故事中,马克编写了测试函数的代码,然后丢弃了它!**马克和玛丽亚之间的唯一区别是后者明确地在代码中表述了她的思考。

“对整数和浮点数有效。我遗漏了什么?它应该对负因子也有效。我应该添加另一个测试,然后我就去周末了。”

import unittest

class TestScaleList(unittest.TestCase):

    def test_scaling_by_positive_integer_factor(self):
        factor = 2
        self.assertEqual(scale_list([1, 2, 3], factor), [2, 4, 6])

    def test_scaling_by_positive_floating_factor(self):
        factor = 2
        self.assertEqual(scale_list([1., 2., 3.], factor), [2., 4., 6.])

    def test_scaling_by_negative_factor(self):
        self.assertEqual(scale_list([1, 2, 3], -1), [-1, -2, -3])

if __name__ == '__main__':
    unittest.main()

她然后继续推送更改。

在享受了一个轻松的周末后,玛丽亚回到工作岗位,回顾了她离开前写的测试。当她浏览它们时,她被它们的清晰度所打动:它们不仅仅是测试,它们是一种文档形式。它们迅速将她带回到她编写代码时的状态,使她几乎立即就能回忆起为什么以及如何设计这个函数。很明显,该函数旨在处理整数、浮点数和负因子。没有歧义;功能被明确定义并受到保护,不仅在她记忆中,而且在代码库中,提交并推送到所有人可以看到。

当包含字符串和 NaN 值的臭名昭著的列表再次导致系统崩溃时,情况令人沮丧,但也很有启发性。与之前不同,对于失败的原因没有疑问:代码明确没有设计来处理这样的输入。即使这些测试没有涵盖所有可能的场景,它们也提供了显著的优势。它们让玛丽亚,以及任何可能参与这个项目的其他人,了解到现有的代码安全措施是有意识地针对特定数据类型配置的。

这种认识将叙事从意外的失败转变为已知的限制。 现在,玛丽亚不再像马克那样慌乱地试图理解出了什么问题,她可以专注于增强功能以处理这些边缘情况,如果需要的话。即使测试套件无法预见每个可能出现的错误场景,也是无价的。它为代码应该如何运行设定了明确的界限和期望,使得任何偏离这些期望的情况都容易诊断和解决。

最小努力,最大影响:从两个故事中得到的 TDD 力量教训

反思这两种情况,重要的是要认识到编写正式测试所需的额外努力是微不足道的。在实践中,测试的行为,无论是通过单元测试还是通过临时方法进行,都需要相同的初步步骤:你提供输入并验证输出。关键的区别不在于工作量的大小,而在于心态和过程的一小调整。

转向测试框架涉及思维上的小转变:从临时检查到永久性测试用例。 这种变化伴随着一些初始设置或样板代码,这可能会显得有些额外负担,但很快就会带来回报。编写这些测试不必花费太多时间;它关乎将你在开发周期中已经执行的部分检查以结构化格式捕捉下来。

基本单元测试既快速又声明式。它不需要大量的时间;相反,它无缝地集成到开发过程中。通过投资几分钟时间编写全面或甚至基本的单元测试,你嵌入了一个强大的安全网,可以保护代码不受意外行为的影响,并简化未来的代码维护。这种做法不仅确保了你的应用程序,还在你的团队中推广了质量和可靠性的文化。

那么,如果第一个故事中的马克在编写代码后回来编写测试会怎样呢?

即使他在开发代码后回来添加测试,这种努力的效果也会降低。到这个时候,他心中原有的细微差别和探索性思维已经不再新鲜。因此,他可能会简单地编写基本的测试来确保代码运行无误,而不是创建能够捕捉到全部功能范围和最初考虑到的任何边缘情况的测试。正如之前提到的,这种事后测试通常只会进行表面验证,表明代码在正常条件下可以工作,但无法保护更复杂的场景。这些测试可能会让代码通过,但它们不能保证稳健性或深入理解,突显了将测试作为开发过程基本部分的迫切需要。意识到故事场景很简单,现实世界的情况要复杂得多,需要更多的关注来理解编写相应代码后可能出现的错误。

最后的想法

总结来说,测试驱动开发(TDD)和基本的单元测试不仅是有益的做法;它们对于构建健壮、可靠的软件开发至关重要,尤其是在像机器学习(ML)这样的领域,其中数据和条件可能高度可变。这些做法快速高效,通过确保每一块代码不仅满足其规格,而且能够随着时间的推移应对未预见的挑战,从而产生实质性的价值。

对于马克和玛丽亚来说,接受 TDD 可以将他们的工作流程从不确定性和频繁的中断转变为自信和高效。而不是被动地——在项目脱轨后匆忙修复错误——两位数据科学家可以主动地,利用单元测试从代码开发的最初阶段就开始引导。这种转变不仅会确保其应用程序的安全,还会增强其作为专业人士的信誉和能力。

通过采用 TDD,你,马克,玛丽亚,以及任何机器学习工程师或开发者,都可以确保代码不仅能在当前工作,而且为未来做好准备,使软件在面对变化时具有弹性和适应性。

更多推荐