购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.7 案例学习

我们的案例学习将会跨越本书的多个章节。我们将会从不同的角度深入研究同一个问题。多次寻求不同的设计和设计模式是非常重要的。我们将会给出不唯一的正确答案:有多个不错的答案。我们的意图是提供一个真实的案例,它具有现实中的深度和复杂度,会给我们带来一些很难抉择的难题。我们的目标是帮助读者应用面向对象编程和设计概念。这意味着选择不同的技术方案来创建有用的软件。

这个案例学习的第一部分是问题的概述和目标。问题的背景会涵盖多个方面,以便我们在后面的章节中设计和构建解决方案。概述部分会用一些UML图来描绘要解决问题的要素。这些UML图会随着我们在后面的章节中细化设计和改变设计而不断演化。

就像很多现实中的问题一样,作者也会带入一些个人的偏见和假设。关于技术可能带来的偏见,可以考虑阅读相关的图书,比如Sara Wachter-Boettecher的 Technically Wrong

我们的用户想要自动化一个通常被称作 分类 classification )的工作。这是支撑产品推荐的观念:上次,一个用户购买了产品X,所以他可能对相似的产品Y有兴趣。我们已经根据他们的喜好进行了分类,因此可以定位同类产品中的其他产品。这个问题可能会涉及复杂的数据组织问题。

从一个更小和更可控的问题开始会比较容易。用户最终希望能够给各种复杂的消费类产品分类,但意识到解决一个困难的问题并不是学习如何构建这类应用的好方法。最好从一些复杂程度可控的东西开始,并优化和扩展,直到可以应对所有需要解决的问题。因此,在这个案例学习中,我们将构建一个鸢尾花的分类器。这是一个经典的分类问题,已经有很多相关文章讨论如何给鸢尾花做分类。

我们需要一套训练集,其分类器可作为正确分类鸢尾花的示例。我们会在下一节讨论训练数据是什么样的。

我们会用 UML 创建一系列的图来描述和总结我们将要创建的软件。

我们将使用一种被称为 4+1 视图的技术来研究这个问题。这些视图包括:

· 数据实体的 逻辑视图 Logic View ),包括它们的属性和它们之间的关系。这是面向对象设计的核心。

· 描述如何处理数据的 过程视图 Process View )。这可能需要多种形式,包括状态图、活动图和序列图等。

· 代码组件的 开发视图 Development View )。该图展示了软件组件之间的关系,描述了类定义是如何被组织到不同的模块和包中的。

· 展示应用程序集成和部署的 物理视图 Physical View )。如果应用程序遵循通用的设计模式,则物理视图不是必需的。否则,有必要使用物理视图展示各个组件是如何集成和部署的。

· 上下文视图 Context View )为其他4个视图提供了统一的上下文。上下文视图通常会描述使用系统的参与者,这可能涉及用户及自动化接口。这些都属于系统的外部,系统必须响应这些外部参与者。

我们通常会先画上下文视图,这样我们就可以知道其他视图的作用。随着我们对用户和问题领域理解的演化,上下文也会演化。

所有这些4+1视图是一起演化的,认识到这一点非常重要。一个视图的变化通常会反映在其他视图中。一个常见的错误是认为其中一种视图是根基,其他视图基于它之上一层一层地构建,最终开发出软件。

在开始分析和设计软件之前,我们先介绍一下问题的背景和基本信息。

1.7.1 简介和问题概述

前面说过,我们从一个简单的问题开始给花分类。我们要实现一个流行的算法,叫作 k 最近邻 k -nearest neighbor ),简称 KNN 。我们需要一套训练集,分类器算法将其作为正确分类鸢尾花的示例。每个训练样本都有多个属性、一个分数和最终的正确分类(也就是鸢尾花的种类)。在这个鸢尾花的示例中,每个训练样本都是鸢尾花,有花瓣形状、大小等属性,这些属性被放在一个数字向量中(向量是Vector,也可被理解为列表)表示这个鸢尾花,在向量中还包括这个鸢尾花的正确分类标签。

假设有一个未知样本,我们想要知道它属于哪个鸢尾花种类。我们可以计算未知样本和已知样本在向量空间中的距离,然后让少量的近邻投票。这个未知样本可以被分类到大部分近邻归属的那个分类中。

如果我们只有两个维度(或属性),那么我们可以用图1.11表示KNN分类器。

图1.11 k 最近邻

我们的未知样本是一个带有双问号(??)的菱形。它的周围有已知样本,用圆形和正方形表示不同的种类。在用一个虚线表示的圆确定3个最近邻后,我们可以投票并决定这个未知样本最可能是圆形种类的(因为在它的邻居中,圆形种类有2个,而正方形种类只有1个)。

使用人工智能算法的一个基础概念是,属性需要用具体的数字来表示。把文字、地址和其他非线性的数据转换成线性的数字具有一定的挑战性。好消息是我们接下来要使用的数据都已被转换成具体的数字表示。

另一个概念是可以参与投票的邻居的个数。这就是KNN中的 k 因素。在我们的概念图中,我们让 k =3,存在3个邻居,其中2个是圆形,第3个是正方形。如果我们改成 k =5,将会改变参与投票的样本池,最终胜出的将会是正方形。哪个是正确的?这需要使用已知答案的测试集来确认分类算法的正确性。在上图中,菱形样本正好落在两个种类(圆形和正方形)的中间,故意制造出一个困难的分类问题。

鸢尾花分类数据集是学习这个分类问题的最常用数据集。通过链接2可以找到数据集的介绍,通过链接3也可以找到,在很多其他网站上也能找到。

在做面向对象分析和设计的过程中,更多的有经验的读者可能会注意到一些差距和矛盾之处。这是故意为之的。对任何问题的初步分析都会涉及学习和改进。这个案例学习也会随着我们的学习而演化。如果你已经发现了一些差距或矛盾,请试着做出自己的设计,看看这些差距是否会随着接下来章节的学习而逐渐缩小。

在研究了问题的某些方面之后,我们可以提供一个更具体的,包含参与者以及描述参与者如何与系统交互的用例(use case)或场景的上下文。我们将从上下文视图开始。

1.7.2 上下文视图

分类鸢尾花应用的上下文时涉及两个参与者:

· 植物学家 (Botanist)提供预先分类好的训据集和测试集。植物学家也要运行测试用例以确定分类器的一些参数。在简单的KNN示例中,它们要决定 k 的值。

· 用户 (User)需要给未知的数据分类。用户先仔细地测量数据,然后用测量好的数据向分类器系统发起请求,获得分类结果。“用户”这个名称有点儿模糊,但是我们暂时想不到更好的名称。我们就暂时先用它,遇到问题时再去修改。

下面用一个UML上下文图说明我们将探索的两个参与者和三个场景,如图1.12所示。

图1.12 UML上下文图

整个系统被描绘为一个矩形。它用椭圆形表示用户故事(User Story) 。在UML中,特定的形状是有意义的,我们预留矩形用来表示对象。椭圆(和圆形)用来表示用户故事,它们是系统的对外接口。

我们需要正确分类的训练数据,才能进行任何有用的处理。每组数据集都有两部分:训练集和测试集。下面我们将整个数据集称为“训练数据”,而不是使用更长(但更精确)的“训练和测试数据”。

植物学家负责调整和设置参数,他们必须检查测试结果以确保分类器正常工作。有两个参数可以调整:

· 计算距离的算法(Distance Computation Algorithm)。

· 参与投票的邻居的个数,也就是 k 值( k factor)。

我们将在本章后面的过程视图部分详细了解这些参数。我们还将在随后的案例学习章节中重新审视这些想法。距离算法是一个有趣的问题。

我们可以将一组实验定义为由不同候选方案组成的网格,并用测试集结果有条理地填充网格。植物学家将推荐使用测试结果与真相最接近的组合(最佳拟合)的参数。在我们的案例中共有两个参数(计算距离的算法和 k 值),因此可以用下面这种二维表。在更复杂的算法中,可能需要使用多维的空间。

测试完成后,用户可以提出请求。他们提供未知数据给分类器,接收分类的结果。从长远来看,这个“用户”也许不是一个人,他们可能是某个购物网站或搜索引擎发给我们的基于分类器智能推荐引擎的网络请求。

我们可以用一个 用例 用户故事 来总结这些场景:

· 作为植物学家,我想为这个系统提供正确分类的训练和测试数据,以便用户正确识别植物。

· 作为植物学家,我想检查分类器的测试结果,以确保新样本被更好地正确分类。

· 作为用户,我想向这个分类器系统提供关键的测量数据,以便获得正确的鸢尾花种类。

有了用户故事中的名词和动词,我们可以使用这些信息来创建应用需要处理的数据的逻辑视图。

1.7.3 逻辑视图

根据上下文图,处理过程从训练数据和测试数据开始。也就是将已经正确分类的数据用于测试我们的分类算法。下面是一个包含训练集和测试集的类图,如图1.13所示。

图1.13 用于训练和测试的类图

图中有代表训练集的TrainingData类,它包含name,以及uploaded、tested等时间属性来表示测试集的上传时间、测试完成时间。现在看起来每个TrainingData对象都有一个用于KNN分类器算法的k调节参数。TrainingData也包括两个Sample类型的列表,分别是训练列表和测试列表。

类图中的每个类都用一个包含多个部分的矩形表示:

· 最上面的部分是类的名称。第二个类的名称使用了类型提示(type hint) List[Sample]。list是一个通用类型,里面可以存放任何对象。通过类型提示,我们确保里面只能放Sample对象。

· 下一个部分是对象的各个属性,这些属性也被称为类的实例变量。

· 最后的部分用于添加对象的“方法”,现在都还是空的。

Sample类的每个对象有几个属性:4个浮点数和1个植物学家给这个样本设定的分类。在这里,我们的属性名使用了class,因为class就是分类的意思,请不要与代码的类名混淆。

在UML的箭头中,空心的菱形和实心的菱形代表两种具体的关系。实心菱形表示 组合 composition ):一个TrainingData对象由两个数据集合组成。空心菱形表示 聚合 aggregation ):List[Sample]对象是由多个Sample对象聚合而成的。回顾一下我们前面学到的内容:

· 组合 是一种共存关系:TrainingData不能没有两个List[Sample]对象。反过来,List[Sample]是为TrainingData而生的,也随着TrainigData的消亡而消亡。没有TrainingData就没有List[Sample]。

· 聚合 中的对象是可以各自独立存在的。在图1.13中,多个Sample对象既可以是List[Sample]的一部分,也可以独立于它而存在。

我们不确定通过空心菱形把Sample对象聚合到List对象中是否与当前的讨论相关。这种设计细节也许帮助不大。如果不确定,则最好先去掉这些细节,直到在实现过程中确定需要它们的时候再将它们加回来。

我们用List[Sample]表示一个独立的类。它其实是Python的通用类List,只不过指定了里面存放的对象类型是Sample。通常我们会避免这种细节,它们的关系可以简化为图1.14。

图1.14 浓缩的类图

这个简化的类图对分析工作有帮助,因为使用什么基础数据结构在分析阶段并不重要。但它对设计工作不够友好,因为在设计阶段需要确定具体用什么Python类。

有了这个草图,我们将会把这个逻辑视图和上一节介绍的上下文图(图1.12)中的3个场景一一对比。我们要确保用户故事中的数据和处理任务可以被分配到本图所示各个类的属性和方法中。

看一遍用户故事,我们发现上面的逻辑视图有两个问题:

· 不清楚测试和参数k的对应关系。在图1.14中有一个参数k,但没有表明不同的参数k和相应测试结果之间的关系。

· 在图1.14中根本没有用户请求,也没有请求的结果。没有一个类包含相关的属性或方法。

第一个问题告诉我们,应该重新看一下用户故事并创建一个更好的逻辑视图。第二个问题则是一个边界问题。虽然图中没有网络请求和响应,但更重要的是先描述问题的根本所在——分类和KNN。处理用户请求的网络服务是一种技术解决方案,在刚开始的阶段,我们应该先把它放一下。

现在,我们把注意力转向数据的处理。我们遵循一个似乎比较有效的描述应用程序的顺序。首先描述数据,因为它是最持久的部分,也是通过每次加工、改进始终保留下来的东西。处理过程相对于数据是第二位的,因为它会随着上下文、用户体验和用户偏好的变化而变化。

1.7.4 过程视图

现在有3个独立的用户故事,但这不意味着必须创建3个过程图。对于复杂的处理,可能会有比用户故事更多的过程图。在某些情况下,用户故事太简单了,可能不需要精心设计的过程图。

在我们的应用中,看起来至少涉及3个独特的过程:

· 上传包含TrainingData的初始样本。

· 设置特定的 k 值,运行分类测试。

· 给一个新的Sample对象做分类。

我们将为这些用例绘制活动图。活动图总结了许多状态变化。处理从起始节点开始,一步步进行,直至到达结束节点。在基于事务的应用(如Web服务)中,通常会省略整个Web服务器引擎。这样我们就不用画出HTTP的Header、Cookie和安全等业务无关细节。相反,我们通常只专注于描绘每种不同类型的业务请求的处理过程。

活动图中的活动以圆角矩形显示。当涉及特定类型的对象和软件组件时,它们可以和相关活动关联起来。

重要的是,当过程视图因想法改变而发生变化的时候,确保更新相应的逻辑视图。很难完全孤立地完成任一视图。随着新的解决方案想法的出现,在每个视图中进行增量更改变得越来越重要。在某些情况下,需要用户提出新的意见,这也会导致这些视图的演化。

我们可以画一个草图,描绘当植物学家提供初始训练数据时,系统应如何响应。这是第一个示例,如图1.15所示。

图1.15 活动图

植物学家上传的已知分类的数据集会被分成两部分:训练集和测试集。在问题描述或用户故事中并没有提到这点,这表明我们原来的用户故事有所欠缺。如果在用户故事中缺少一些细节,逻辑视图可能就会不完整。现在我们假设大部分的数据,比如75%,作为训练数据,余下的25%用作测试。

比较好的做法是给每个用户故事都创建类似的图表,同时确保每个活动图都有相应的类来实现其中的步骤及状态转换。

我们在图中使用了一个动词: 划分 Partition )。这建议我们应该用一个 方法 实现这个动词,可能意味着我们要重新考虑类模型以确保有相应的实现。

接下来,我们将考虑要构建的组件。这只是初步分析,我们的想法将随着我们进行更详细的设计并开始创建类而演化。

1.7.5 开发视图

最终部署和要开发的组件之间通常存在微妙的平衡。在极少数情况下,部署约束很少,设计人员可以自由考虑要开发的组件。物理视图将从开发中演化而来。在更常见的情况下,必须使用特定的目标架构,并且物理视图的元素是固定的。

有几种方法可以将分类器部署为更大应用的一部分。我们可能会构建桌面应用、手机App或网站。由于网络无处不在,所以一种常见的方法是创建一个分类器网站,并通过桌面应用和手机App连接它。

Web服务架构意味着可以向服务器发出请求。响应可以是在浏览器中呈现的HTML页面,也可以是主要服务于手机App的JSON文档。一些接口请求用于提供全新的训练集,其他接口用于对未知样本进行分类。我们将在下面的物理视图中详细介绍架构。我们可能会使用Flask框架来构建Web服务。有关Flask的更多信息,请参阅 Mastering Flask Web Development (链接4)或 Learning Flask Framework (链接5)。

图1.16显示了构建基于Flask的应用所需的一些组件。

图1.16 要构建的组件

图1.16中显示了一个名为Classifier的Python包,它包含多个模块(module)。顶层的3个模块分别是:

· Data Model :中文是数据模型(由于现在还处于分析阶段,所以这里的名称不是那么Pythonic ,我们会在进入实现阶段后再修改)。把处理同一个问题的类放在一个模块中通常是很有帮助的。这使得我们可以单独测试这部分代码,而不受应用其他部分的影响。数据模型是基础,因此我们将专注于这一部分。

· View Functions :中文是界面功能(同样处于分析阶段,名称不是那么Pythonic)。这个模块将会创建一个Flash类的实例来代表我们的应用。它将会定义一些函数,用于处理请求,创建可以展示在手机App或网站上的响应内容。这些函数会提供模型的一些功能,但不会像数据模型本身那么深入和复杂。书中的案例学习不会关注这个组件。

· Tests :我们将会为模型和界面功能创建单元测试。测试是确保软件正常工作所必需的,这是第13章的主题。

图1.16中用虚线和箭头指明了依赖关系。这些可以使用Python特定的imports关键字来阐明各种包和模块之间的关系。

当我们在后面的章节中进行设计时,我们将扩展这个初始视图。在考虑了需要构建什么之后,我们现在可以通过绘制应用的物理视图来考虑如何部署它。如上所述,开发和部署之间存在微妙的关系。这两个视图通常是一起构建的。

1.7.6 物理视图

物理视图展示了如何将软件安装到物理硬件中。对于Web服务,我们经常讨论 持续集成和持续部署 CI/CD )管道。这意味着先将对软件的更改作为一个单元进行测试,然后与现有应用集成,作为一个集成整体再进行测试,最后面向用户发布。

我们前面假设这是一个网站,但也可以将其部署为命令行应用,它可能在本地计算机上运行,也可能在云端运行。除此之此,还可以直接在核心的分类器中创建Web应用。

图1.17显示了Web应用服务器图。

图1.17 应用服务器图

图1.17中显示了客户端(client)和服务器(server)节点,以及安装在它们之上三维盒子形状的组成部分。我们已经确定了3个组成部分:

· 运行客户端程序的 client app :这个客户端程序与分类器Web服务连接并发出RESTful API请求。它可能是一个用JavaScript语言编写的网站,也可能是一个用Kotlin或Swift语言开发的手机App。所有这些前端都通过 HTTPS 协议与我们的Web服务器连接。这种基于HTTPS的安全连接需要配置数字证书和加密密钥对。

· GUnicorn 网站服务器:可以处理Web服务请求和HTTPS协议。更多细节可查看链接1。

· Classifer 分类器应用:在这个视图中,复杂度被隐藏了,整个Classifer包被缩减成Web服务框架中的一个小组件。可以用Python的Flask框架开发Web服务应用。

在这些组件中,客户端的 client app 不是开发分类器工作的一部分。我们把它包含在图1.17中是为了更完整地展示上下文,但在本书中不会开发它。

我们使用了一个虚线依赖箭头来表明Web服务器依赖于我们的Classifier应用。 GUnicorn 将引入我们的Web服务器对象并使用它来响应请求。

现在我们已经勾勒出应用,可以考虑编写一些代码。我们在写代码时,应保持图的更新。有时候,它们会在复杂的代码中为我们指明方向。

1.7.7 结论

这个案例学习中有几个关键概念:

1.软件应用程序可能会相当复杂。可以用5个视图来描述用户、数据、处理过程、要构建的组件和最终的物理实现。

2.我们会犯错。概述部分有一些遗漏。重要的是向前推动,哪怕解决方案并不完善。Python的一大优势是可以快速地开发软件,这意味着就算有错误的决定也可以快速地修正。

3.对扩展保持开放的心态。我们在实现了前面的设计后,会发现手工设置参数k是一个烦琐的工作。重要的下一步是,使用网络搜索算法自动优化参数k。但一开始我们并没有考虑这种自动化算法,而是先开发出一个可用版本,然后在这个版本上再扩展有用的功能。

4.尽量给每个类都赋予清晰的责任。到目前为止,我们做得还可以。有些责任有点儿模糊,或者被忽视了。当我们把初始分析转变成实现细节的时候,再来修正这些问题。

在后面的章节中,我们将更深入地探讨这些不同的主题。因为我们的目的是呈现真实的工作,所以这将涉及返工。随着读者接触到越来越多的可用的Python面向对象编程技术,一些设计决策可能会被修改。此外,解决方案的某些部分将随着我们对设计选择的理解和问题本身的发展而演变。基于经验教训的返工是敏捷开发方法的产物。 wgqEeJbnn19qvP1FVuy1m8fUI8myz75q2LcDfR2QGWT9MsFVivSRRxn7DjvOa+rp

点击中间区域
呼出菜单
上一章
目录
下一章
×