1.2.2 编码和测试
机器学习中无论是算法的开发、工具包的开发、数据和处理功能开发还是机器学习系统的开发,都离不开代码的编写。首先,我们按照软件工程的方法将编码涉及的活动分为以下6个环节。
1)项目定义与计划:项目一般指较正式或大型的软件系统,比如机器学习系统。需要按照正规的软件项目立项,在预算和进度等条件的约束下通过项目管理(可理解为上文的软件过程管理),确保提交满足需求的软件产品。小功能或函数的开发则相对自由。
2)需求分析:在正式编码前,要将需求即待实现的功能用自然语言描述出来,必要时加入图形来阐述,阐明软件提供的服务或功能、行为描述、相关约束等,并形成可供追溯和方便查阅的文档。对于小功能或函数的开发,笔者建议以笔记的形式记录或在代码中加入详细的注释说明。
3)设计:对于较大型的软件系统开发,一般由架构师和资深工程师进行软件系统设计,常用的方式有系统建模、用UML进行面向对象设计、交互系统的时序图等。对于小功能或函数的开发,笔者建议深入理解待实现功能的逻辑和算法,在笔记本上推演,抓住本质后再开始编码。
4)编码:程序的本质即数据结构和算法。在设计阶段需要考虑使用什么数据结构(List、Dict、DataFrame等)、函数接口、算法逻辑等。笔者所见,很多没有软件开发背景的工程师在前3个环节的实际表现还不错,这体现了个人做事的习惯和风格,而在编码这一环节则可以看出机器学习工程师或建模工程师是否具有软件开发背景。想要编写更好的代码,只能多看、多实践。
5)测试:测试的目的是发现Bug并验证功能是否正确,即针对单一功能或函数的单元测试或调试,而非集成测试或系统测试。简言之,测试是检验指定的输入是否有正确的输出。软件工程领域中可将输入理解成测试用例。测试过程中需要准备尽可能覆盖各种情况的测试用例,以尽量保证功能正常。要将所有的测试用例形成测试报告,记录测试用例的通过情况。
6)运行维护:新需求或功能的开发及Bug修复等工作。
接下来,我们简要描述第4个环节“编码”和第5个环节“测试”中的测试驱动开发理念。
1.SOLID编码原则
编码的指导原则有很多,这里我们参考Robert C.Martin提出的5个经典的面向对象编程原则:SOLID。该系列原则旨在促进编写可维护、易于理解和稳定的代码。SOLID中5个字母的含义如表1-2所示。
表1-2 SOLID含义
1)SRP(单一职责原则):要求每个方法或类有且仅有一个改变的理由,这意味着每个方法或类应当只实现一个具体的功能或服务,即只有一项职责。SRP能促进编写简单的类、对象和函数。例如一个函数只做一件事情,那么它就符合这一原则。符合SRP原则的代码往往代码量非常小,遵循SRP原则也是实现代码高内聚、低耦合的方法。相反,如果代码不符合SRP原则,则由于功能多,极有可能需要经常修改和维护。
2)OCR(开放封闭原则):要求程序实体(类、模块、函数等)对扩展开放,而对修改闭合,表现为当新增功能时应该尽量只添加新代码,而不要修改原代码,以避免向前一版本中引入Bug。在面向对象编程中,OCR表现为基类只能被继承和使用,但不能被修改。这要求程序员遵循SRP的同时具有面向对象的抽象能力,能看到本质。
3)LSP(里氏替换原则):要求子类型必须能够替换它们的父类型而没有其他影响,即所有引用父类型的地方必须能透明地使用其子类型的对象。具体表现为父类型中的属性和行为必须包含于子类型中,例如,父类型的测试用例能在子类型中测试通过。该原则保证了面向对象中类继承的正确方法,避免类继承的混乱。
4)ISP(接口分离原则):要求不能强迫用户依赖那些他们不使用的接口,表现为当A类依赖B类时,接口中B类的成员数量应该被最小化,以减少依赖。直白地说,使用多个专用的接口比使用一个大而全的接口要好,这样能增加程序的健壮性和灵活性,进而提高可复用性。
5)DIP(依赖倒置原则):要求上层模块不依赖下级模块,如果有依赖,应该是上层模块和下级模块依赖抽象,并且不应该是抽象依赖于具体实现,而是具体实现依赖于抽象。该原则保证去除了模块间的耦合或绑定关系,也提高了复用性。
另外,Robert C.Martin的《代码整洁之道》一书中详细讲述了如何编写干净、高质量的代码,感兴趣的读者可阅读参考。
2.测试驱动开发
测试驱动开发(Test-Driven Development,TDD)的软件开发过程为:在非常短的开发循环里重复将需求转化为测试用例,然后对软件进行改造以通过(新)测试,这也是敏捷开发中的一项核心实践。TDD强调测试的重要性,以测试为先,以保证功能为先,功能代码只是一种实现载体而已。在TDD的过程中,测试代码和功能代码的开发交错进行。很明显,这样的测试已涵盖需求分析、设计和质量控制,而不仅仅是测试。图1-6展示了传统开发模式和TDD开发模式的区别。
图1-6 传统开发模式VS TDD开发模式
在传统开发模式中,开发为先,测试随后,测试通过后本轮的开发阶段就已结束。有时会有正式的重构过程,例如专门立项重构项目,但这已经脱离了本轮的开发过程,在当前的开发流中并不形成闭环。在TDD中,测试先行,开发只是满足测试的后续活动,重构活动时常出现,是开发闭环中的一部分。TDD中突出了编写测试和重构这两个额外的步骤,这也导致TDD对开发人员的素质要求更高。除了多实践外,开发人员的思维和习惯也需要转变。在实践中,建议循序渐进或根据项目计划适当调整,从简单的测试用例开始,对于复杂的测试(使用Mock和Stub等技术)可先手动代替测试。
测试代码与功能代码一样重要,测试代码不仅要简洁干净、明确可读,而且必须随功能代码的演进而修改。除此之外,建议在编写测试用例时遵循FIRST的5点原则。
·F:Fast,测试要求能快速运行,支持频繁运行。否则,测试本身将耗费大量的时间而影响功能代码的实现效果,抑或难以忍受测试耗时而逐渐忽视甚至不愿运行测试。
·I:Independent,独立。测试应该相互独立,没有依赖关系,每个测试不作为前置或后置步骤,可独立运行而不影响其他测试。
·R:Repeatable,可重复。测试可重复是测试的基本要求,同时要求测试在测试环境、开发环境和线上环境都能运行。
·S:Self-Validating,自我验证。测试要求有布尔型结果输出,直观显示成功或失败,而无须人工再干预和判断。每个测试用例中尽量只有一个断言或一组同功能的断言。
·T:Timely,及时。要求测试在编写功能代码前及时编码,这有利于有目标和有组织地编写功能代码。
遵循以上原则有以下好处。
·说明已经完全明确了功能代码的功能、考虑到了的边界。
·每个测试用例覆盖功能代码的一项功能,能够提前发现Bug,提升代码测试覆盖率。
·每当增量开发时,如果之前的测试用例通过,则可以说明没有给之前的代码引入新Bug。
·简化了调试工作量,测试失败的地方显而易见。
·测试用例同时也是一种代码文档,它描述了代码的功能点,阅读测试用例有利于理解代码。在软件工程项目中,测试用例还能便于交接传承,作为项目的一部分,它会与功能代码一起提交至代码仓库。
Python中最常见的单元测试套件是unittest,它属于Python标准库,与之类似的还有pytest、nose等。这些软件包是我们践行TDD的工具。
虽然不是每个人都要成为算法工程师,都要编写底层算法、开发机器学习框架或发布工具包,但了解和掌握TDD的思维是很有必要的。感兴趣的读者可参考Lasse Koskela的《测试驱动开发的艺术》和Kent Beck的《测试驱动开发》等书。