本节我们将扩展之前的实际案例的面向对象设计。我们从 UML 图开始,这可以帮助我们方便地描述和总结将要创建的软件。
我们将会讨论Python类设计中要考虑的各种情况。下面先温习一下之前设计的类图。
这是我们需要创建的类的概览。这基本就是我们前一章的类图,只是添加了一个方法,如图2.2所示。
图2.2 逻辑视图
有3个类用于定义我们的核心数据模型,还有一些使用List[类名]定义的泛型列表类。下面是4个核心类:
· TrainingData类包含两个数据样本列表,其中一个列表是训练集,另外一个是测试集。两个列表中都是KnownSample实例。另外,它还有一个Hyperparameter(超参数)值的可选列表。不同的超参数会产生不同的模型效果。基本思想就是通过尝试使用不同的超参数来找到最高质量的模型。TrainingData类还包含一些元数据属性:数据集的名称name,第一次上传数据集的时间uploaded,运行测试的时间tested。
· Sample的每个实例包含了数据的核心信息。在我们的示例中,它包含萼片的长度和宽度,花瓣的长度和宽度。沉着冷静的植物学研究生仔细测量了大量的花朵来收集这些数据。我们希望他们在工作时有时间停下来闻一闻玫瑰花香。
· KnownSample对象扩展了Sample类。这部分设计展示了第3章的重点。一个KnownSample就是一个Sample,但它包含了一个额外的属性:species(物种)。这些信息来自成熟的植物学家,他们对这些用于训练和测试的数据进行了预先分类。
· Hyperparameter类包含变量 k ,它代表KNN算法中需要考虑的邻居数量。变量quality记录当前超参数的分类结果的质量(用小数表示,比如0.8代表80%正确)。我们可以预料到,非常小的 k 值(比如1或3)的分类结果不会很好,中间的 k 值可能会好一些,而非常大的 k 值的分类结果也不会太好。
类图中的KnownSample类也许没必要被定义成单独的类。随着不断深入细节,我们会讨论这些类的其他设计方案。
我们先从Sample(和KnownSample)类开始。Python提供了3个定义新类的基本方法:
· 使用class语句定义类。我们将从这种方法开始。
· 使用@dataclass定义类。它提供了许多内置功能。虽然它很方便,但对于刚接触Python的程序员来说并不理想,因为它会隐藏一些实现细节。我们将把它放在第7章中介绍。
· 扩展typing.NamedTuple类。这种方法定义的类最显著的特点是对象的状态是不可变的,属性值不能更改。不变的属性有时候会很有用,它可以确保应用程序中的错误不会干扰训练数据。我们将把它留到第7章介绍。
我们的第一个设计决策是使用Python的class语句为Sample及其子类KnownSample编写类定义。这可能会在未来(如第7章)被dataclass和NamedTuple等方案所取代。
图2.2显示了Sample类和它的子类KnownSample,但其似乎没能分解出所有不同特征的样本。我们回顾一下用户故事和流程视图,可以看到这个设计的缺失之处:具体来说,用户做出分类请求时,需要传入一个未知样本(UnknownSample)。它与Sample具有相同的属性,但没有KnownSample的species属性。此外,这个未知样本的属性值不会发生变化。未知样本永远不会被植物学家正式分类;它将被我们的算法分类,但我们的算法只是人工智能,而不是植物学家。
我们可以给Sample创建两个不同的子类:
· UnknownSample:这个类包含Sample类的4个属性。用户传入它的实例做分类。
· KnownSample:这个类包含Sample的属性及分类结果,也就是一个物种名。我们使用它训练和测试模型。
通常,我们将类定义视为封装状态和行为的一种方法。用户提供的UnknownSample实例一开始没有species属性。然后,在分类器算法计算出一个species属性之后,Sample会改变状态以拥有一个由算法分配的物种。
关于类定义,我们必须经常问的一个问题是:
对象的行为是否会随着状态的变化而变化?
在我们的示例中,它们似乎没有什么新的或不同的行为。也许,我们可以用一个包含可选属性的类来实现,而没必要创建两个不同的类。
有一个其他的状态变化需要我们关注。现在没有一个类负责把样本分成训练集和测试集。这也是一种状态变化。
这就引出了第二个重要问题:
哪个类应该负责这个状态变化?
在这个示例中,似乎TrainingData类应该负责分割训据集和测试集。
审查我们的类设计的一种方法是,枚举单个样本的各种状态。这种技术有助于发现类所需的属性。它还有助于识别类中用于改变对象状态的方法。
我们来看看Sample对象的生命周期。一个对象的生命周期开始于对象的创建,然后是各种状态变化,最后当没有其他引用指向对象时,有时候还需要一个方法负责销毁对象。我们有3个场景:
1. 加载数据 :我们需要一个load()方法把原始数据加载到TrainingData对象中。这里我们提前学一点儿第9章中的知识,读取一个CSV文件通常会产生一个字典序列。我们可以想象load()方法使用CSV阅读器创建具有物种信息的Sample对象,把它们转换成KnownSample对象。load()方法也会把KnowSample对象分割成训练集和测试集。这个分割过程是TrainingData对象的一个重要状态变化。
2. 超参数测试 :Hyperparameter类需要一个test()方法。它使用TrainingData对象的测试样本。对于每一个样本,它使用分类算法,对比AI算法的分类结果和植物学家预先提供的结果,记录正确分类的次数。这说明我们还需要一个给单个样本做AI分类的方法classify()。test()方法会更新Hyperparameter对象中的质量分数(quality)属性。
3. 用户分类请求 :一个RESTful Web应用程序通常被分解为单独的视图函数来处理请求。在处理对未知样本进行分类的请求时,视图函数需要一个用于分类的Hyperparameter对象,这是植物学家选择的最优超参数。用户输入将是一个UnknownSample实例。视图函数应用Hyperparameter.classify()方法来创建对用户的响应,其中包含鸢尾花的分类结果。AI对UnknownSample进行分类时发生的状态变化真的很重要吗?这里有两种保存分类结果的方法:
· 每个UnknownSample有一个代表“AI分类结果”的属性classified。设置这个属性就会改变Sample的状态。这个状态的改变似乎不会引起行为的改变。
· 分类结果不保存在Sample类中。它是视图函数的一个局部变量。这属于函数的状态变化,用于创建用户响应,但对Sample对象没有任何影响。
这些不同方案的详细分解背后有一个关键概念:
没有绝对“正确”的答案。
一些设计决策基于非功能性和非技术性的考虑。这些可能包括应用程序的寿命、未来的用例、其他潜在用户、项目的时间安排和预算、教学价值、技术风险、知识产权的申请,以及在会议上演示效果如何酷炫等。
在第1章中,我们曾提到这个程序也许是消费类产品推荐器程序的一部分。我们是这样说的:用户最终希望能够给各种复杂的消费类产品分类,但意识到解决一个困难的问题并不是学习构建这类应用的好方法。最好从一些容易管理复杂度的东西开始,并优化和扩展直到可以应对所有需要解决的问题。
因此,我们认为从UnknownSample到ClassifiedSample的状态变化非常重要。Sample对象将存在于数据库中,用于额外的营销活动,或者当有新的产品和训练集发生变化时重新分类。
我们决定将AI分类结果属性(classification)和正确物种属性(species)保留在UnknownSample类中。基于这些分析,我们包含多个Sample子类的类图就变成了如图2.3所示的样子。
图2.3中使用空心箭头显示Sample的多个子类。我们不会直接为它们创建子类。我们用箭头是为了表明这些对象有不同的用例。具体来说,KnownSample的独特之处在于,它 包含物种信息 (species is not None)。类似地,UnknownSample的独特之处在于它 没有物种信息 (species is None)。
图2.3 更新后的UML图
在这些UML图中,我们一般会避免显示Python的特殊方法,比如几乎所有类都有的__init__()方法,这是为了让图看起来更清爽。有些时候,某个特殊方法可能对表达设计是很有必要的,也可以被添加到图中。
有一个非常有用的特殊方法__repr__() ,该方法用于创建一个对象的字符串表示。它的返回值通常是一个包含了Python类名、属性名和属性值的字符串。我们可以基于这个字符串重新构建出对象。如果是数字,它就返回数字。如果是字符串,它会包含引号。如果是更复杂的对象,它会包含所有必要的Python要素,包括对象的类和状态的所有细节。我们通常使用带有类名和属性值的f-string来创建它的返回值。
下面是Sample类的第一个版本,它似乎包含了单个样本的所有特征:
__repr__()方法反映了Sample对象复杂的内部状态。物种和分类的存在与否所代表的对象状态会导致一定的行为变化。到目前为止,对象行为的变化仅仅体现在用于展示对象当前状态的__repr__()方法上。
重要的是,要认识到状态的变化的确会导致行为的变化,虽然很微小。
Sample类有两个针对应用的方法,它们是:
classify()方法会把状态由未分类变成已分类。matches()方法会对比分类的结果和植物学家指定的分类,用来测试分类的结果。
下面是状态变化的示例:
我们现在有一个可以运行的Sample类。__repr__()方法挺复杂的,这里可能有一定的改进空间。
它可以帮助定义每个类的职责,可以对属性和方法的重点做总结,解释属性和方法之间的关联性。
哪个类负责执行测试?在测试集中,Training类负责对每个已知样本进行分类吗?或者,它把测试集提供给Hyperparameter类,让Hyperparameter执行测试吗?既然Hyperparameter类拥有KNN中的超参数 k ,那么把一组已知样本实例提供给它,让它用它的 k 值执行测试似乎是合理的。
很明显,TrainingData类可以负责记录各种超参数(Hyperparameter实例)。这意味着TrainingData类可以识别哪些Hyperparameter实例的 k 值具有最高的分类准确度。
这里有多个相关的状态变化。在这种情况下,Hyperparameter和TrainingData类各自完成部分工作。系统作为一个整体,将随着单个元素改变状态而改变状态。这有时被描述为 涌现行为 。我们没有编写一个做很多事情的巨大的类,而是编写了更小的类来相互协作以实现预期目标。
TrainingData的test()方法没有被画在UML图中。我们只给Hyperparameter类中添加了test()方法。在画UML图的时候,似乎没有必要给TrainingData添加test()方法。
下面是Hyperparameter类的起始定义:
注意我们如何给还没有定义的类添加类型提示。当一个类在文件的后面才定义时,在文件的前面对尚未定义的类的任何引用都是前向引用( forward reference )。在上面的代码中,对尚未定义的TrainingData类的前向引用使用字符串,而不是简单的类名。当 mypy 分析代码的时候,它会把字符串解析成类名。
下面是test()方法的定义:
我们首先把训练数据赋值给可选变量training_data。如果training_data不存在,这里会抛出一个异常。然后给每一个样本做分类,并为样本的classification属性赋值。使用matches()方法判断分类是否正确。最后,用正确样本数除以总样本数计算出分类正确率,将其赋值给质量属性quality。我们这里使用的正确率,是一个浮点数。我们也可以用整数记录正确的数量来代表质量。
我们不会在本章实现具体的分类方法,我们把它留在第10章。我们再来看看TrainingData类,它组合了到目前为止我们讨论过的所有类。
TrainingData类包含由Sample对象的两个子类的实例组成的列表。KnownSample和UnknownSample都是Sample类的子类。
我们会在第7章中从多个角度来看这个问题。TrainingData类也有一个由Hyperparameter实例组成的列表。TrainingData类可以简单、直接地引用前面定义好的类名。
它有两个方法用于启动流程:
· load()方法用于读取原始数据,把它们分成训练数据和测试数据。这两类数据的本质都是KnownSample实例,但用于不同的目的。训练集用于训练KNN,测试集用于测试超参数 k 的分类效果。
· test()方法用于使用Hyperparameter对象执行测试,并保存结果。
回顾一下第1章中的上下文视图,我们有3个用户故事:提供训练数据,设定参数;测试分类器;处理分类请求。我们似乎应该添加一个方法基于给定的Hyperparameter实例执行分类操作。这会给TrainingData类添加一个classify()方法。同样地,在我们最开始设计的时候,没必要添加这个方法,但现在是时候了。
下面是TrainingData类的起始定义:
我们已经定义了几个属性来跟踪这个类的历史变化,比如上传(uploaded)时间和测试(tested)时间。Training、testing和tuning属性用于保存Sample对象和Hyperparameter对象。
我们不会添加设置属性的setter()方法。在Python中,我们直接访问属性。这可以最大程度地简化代码。类的责任是封装数据,但我们通常不会写很多getter()或setter()方法 。
在第5章中,我们会看到一些更聪明的方法,比如Python的属性定义,以及其他处理属性的方法。
在我们的设计中,load()方法用于处理参数传入的数据。我们也可以把load()设计成自己打开和读取一个文件,但这样会把TrainingData类和具体的文件格式绑定在一起。更好的做法是把文件格式的细节和模型训练的细节隔离在不同的类中。在第5章中,我们会仔细学习如何读取和验证外部数据。在第9章中,我们将重新讨论文件格式的问题。
现在,load()的方法骨架(伪代码)是这样的,我们来获取训练数据:
我们需要一个数据源。我们使用类型提示Iterable[dict[str,str]]来描述这个数据源。Iterable是可迭代的意思,它表明这个变量可以被for循环遍历或者可以被转换成list。集合中的list、file等都是可以被遍历的。生成器(generator)也是可迭代的,我们将在第10章中进一步学习。
这个迭代器的返回值是一个键和值都是字符串的字典。这是一种很通用的结构,它看起来像这样:
这种结构看起来很灵活,我们可以方便地通过对象生成它。我们将在第9章中详细学习相关内容。
这个类的其余方法将大部分工作委托给Hyperparameter类。它不做分类工作,而是依赖另一个类来做这个工作:
这两个方法都有一个特定的Hyperparameter对象作为参数。对于测试来说,需要传入不同的超参数对象,分别测试它们的效果。而对于分类来说,应该传入测试效果最好的那个超参数对象。
本章案例学习创建了Sample、KnownSample、TrainingData和Hyperparameter类的定义。这些类是整个程序的一部分。当然,还不完善,我们漏掉了一些很重要的算法。我们从明确的事情开始,识别类的行为和状态变化,并定义类的责任。然后,下一轮设计可以围绕这个现有框架填充细节。