本章的案例学习主要解决数据或代码可能引起的异常。数据问题和程序问题是造成异常的两种来源,但它们并不等价。我们可以这样对比它们:
· 数据异常是最常见的问题。数据可能没有遵循语法原则,格式不对。另外,更微小的错误可能源于没有由可识别的逻辑组织的数据,如列名拼写错误等。当用户试图进行越权操作时也会引发异常。我们需要提醒用户和管理员无效的数据和无效的操作。
· 代码异常通常就是指 Bug 。应用程序不应尝试从这些问题中自动恢复。我们应该在单元测试或集成测试(第13章)中发现它们并修复它们。也有可能,某个问题逃过了测试,然后被发布到线上,并被用户碰到。我们应该用优雅的方式告诉用户,系统出问题了,然后停止执行,甚至让程序崩溃。程序出了问题还让用户继续使用,可能会带来更严重的后果。
在我们的案例学习中,有三个可能出错的地方:
· 植物学家提供的已知Sample实例,反映了专家判断这些数据应该是质量很好的,但是没有人可以保证不会有人不小心重命名了文件,用一些无效或无法处理的数据替代了好的数据。
· 研究员们提供的未知Sample实例,可能存在各种数据质量问题。我们稍后会讨论。
· 研究员或者植物学家所做的操作。我们再看看用例,确定一下什么角色允许做什么操作。有时候,我们可以让用户只看见他们可以操作的菜单来避免这些问题。
我们先从回顾用例开始,这样可以确定应用所需的异常类型。
在第1章的上下文视图中,我们使用User来代表所有用户。这在一开始还可以,但经过深入分析,我们可能需要把用户细分为植物学家(Botanist)和研究员(Reseacher)。植物学家提供分好类的样本,而研究员使用我们的AI算法做分类。
下面是扩展后的上下文视图,里面包含了两种用户和他们各自被允许的操作,如图4.2所示。
图4.2 应用上下文视图
植物学家提供已知样本数据,他有两个有效操作。研究员提供未知样本数据,只有一个有效操作。
数据和处理用例是紧密联系在一起的。当一个植物学家提供新的训练样本或者设置参数并测试分类器时,应用程序必须验证他提供的数据的有效性。
类似地,当研究员尝试给一个未知样本分类时,应用程序必须确认数据是否有效且可用。如果是无效数据,必须把问题反馈给研究员,这样研究员才能修改数据并重新尝试。
我们可以把坏数据的处理分成两部分:
· 发现异常数据。正如我们在本章中看到的,遇到无效数据时,可以通过抛出异常来使其被发现。
· 处理异常数据。这可以通过try:/except:代码块来实现,给用户提供问题的原因以及可能的解决方案。
我们先从发现异常数据开始。抛出正确的异常是处理坏数据的基础。
应用程序中有很多数据对象,但我们现在聚焦在KnownSample类和UnknownSample类上,它们都继承自Sample类,它们是由另两个类创建的。图4.3展示了Sample对象是由谁创建的。
图4.3 对象创建
图4.3中包含了两个会创建样本实例的类。TrainingData类会加载已知样本。一个代表分类器的ClassifierApp类会验证未知样本并试图对它分类。
已知样本有5个属性,每个属性的有效值也是明确的:
· 4个衡量指标sepal_length、sepal_width、petal_length、petal_width都是浮点数,它们都大于0。
· 专家提供的species属性是一个字符串,它只有3个有效值。
未知样本只有4个指标属性。已知样本和未知样本继承自同一个父类确保了我们的验证逻辑可以重用。
上面属性的有效值都是各自独立的。在有些程序中,属性之间可能有复杂的关系,我们需要综合考虑多个属性的值以及它们的关系来判断数据是否有效。在我们的案例学习中,只需要关注4个属性各自的验证规则。
我们要考虑一下在加载样本的时候什么可能出错,如果出错了,用户可以做什么。我们最好根据样本的验证规则,抛出一个特殊的ValueError的子类来描述数据问题,比如衡量指标不是小数、物种名是未知字符等。
我们可以使用下面的类来代表这种特殊异常:
如果数据无法处理,我们就抛出InvalidSampleError异常,这个异常会携带一条消息,告诉用户具体的数据问题。
这可以帮助我们区分代码Bug引起的ValueError异常和真实的数据问题。当InvalidSampleError被抛出,说明不是代码有Bug,而是数据有问题。这意味着我们需要在except:代码块中捕获InvalidSampleError异常。
如果我们直接使用except ValueError:,这可能会把数据错误和代码Bug混在一起。我们可能会把代码Bug误认为是数据问题。所以要谨慎处理通用异常,因为我们可能不小心捕获了代码Bug。代码Bug应该被修复,而不是被捕获。
之前我们提到过,用户可能会做出一些非法的操作。比如,一个研究员可能试图提供一个已知样本。但加载新的训练数据只有植物学家可以做。这意味着,如果研究员尝试去做这件事情,程序应该抛出某种异常。
我们的应用程序运行在操作系统环境中。对于命令行程序,我们可以把用户分成两组,并且使用操作系统的文件权限系统来限制哪组的人可以运行哪些文件。这是一个有效且全面的解决方案,不需要任何Python代码。
但是,对于基于Web的应用程序,我们需要对每个用户进行Web应用程序的身份验证。所有的Python Web框架都提供了用户身份验证机制。有些框架提供了一些很方便的插件,如Open Authentication、OAuth等。更多信息请查看链接12。
对于Web应用,我们一般有两步验证:
· 身份验证 :这一步是为了验证用户的身份,是为了知道用户是谁。可以使用密码等单一的验证方式,也可以结合其他方式做多因素验证,比如物理密钥、手机验证码等。
· 权限验证 :这一步是为了判定用户是否有权限执行某个操作。我们通常会给用户定义角色,并根据用户的角色限定用户可以访问的资源。当用户没有权限访问某个资源时,应该抛出异常。
很多Web框架将异常作为一种内部信号,用异常表示某种操作是不被允许的。这种内部的异常一定要映射到外部的HTTP状态码,比如401表示没有权限访问当前资源。
这是一个很深的主题,超出了本书的范围。例如,请参阅“使用Flask构建Web应用程序”(链接13)以了解Web应用程序的介绍。
关于如何读取不同格式文件的细节,我们留到第9章进行介绍。现在我们只关注如何读取CSV文件。
CSV 是 Comma-Separated Value (逗号隔开的值)的缩写,可以用于定义表格中的一行数据。在一行中,每一列是用逗号隔开的字符串。当CSV数据被Python的csv模块解析时,每一行数据被转换成一个字典。字典的key是列名,字典的value是当前行中这一列的值。
例如,一行数据看起来是这样的:
csv模块的DictReader类会把多行数据转换成一个由dict[str,str]行实例组成的序列。如果原始数据是合法的,我们需要把一行的字典实例转换成某个Sample子类的实例。如果原始数据不合法,我们要抛出异常。
假设使用上面示例中的字典,下面的方法会把字典转换成一个更有用的对象。这个方法在KnownSample类中:
from_dict()方法会检查species的值,如果它无效就抛出异常。它尝试用float()函数把各字符串数值转换成浮点数,然后创建一个cls类型的实例。如果所有数值都被成功转换为浮点数,就能成功创建对象,也就是参数cls指定类型的实例。
如果任何一个float()调用报错(某个数值不是浮点数),就会抛出一个ValueError。基于这个ValueError异常,我们创建了一个InvalidSampleError并抛出去。
这个函数的验证混合使用了 请求宽恕比请求许可更容易 ( EAFP )和 三思而后行 ( LBYL )两种风格。EAFP在Python中的应用更广泛,但是在验证species值的时候,没有类似于float()的转换函数可以帮忙抛出异常或坏数据,所以我们使用了LBYL。我们后面会学习一种替代方案。
from_dict()方法使用了@classmethod装饰器。这意味着真实的类对象会成为方法的第一个参数cls。这样做意味着任何它的子类都会把子类自身作为cls参数传给这个方法。我们可以创建一个新的子类,比如TrainingKnownSample:
TrainingKnownSample.from_dict()方法会把TrainingKnownSample作为cls参数值。没有写任何其他代码,from_dict()方法就会为TrainingKnownSample类创建实例。
这运行起来没有问题,但 mypy 可能不这么认为。我们可以使用下面的方法提供明确的类型映射:
另一种做法是继续使用前面的更简单的类定义,但在实际调用from_dict()的地方使用cast()操作,比如cast(TrainingKnownSample,TrainingKnownSample.from_dict(data))。既然这个方法用的地方不多,也很难说哪个方法更简单。
下面是第3章中定义的KnownSample类的其余部分:
让我们看看以上代码在实践中是如何工作的。以下是加载一些有效数据的示例:
我们创建了一个名为valid的字典,csv.DictReader将会读取这个字典中的一行数据。接着,我们创建了一个名为rks的TrainingKnownSample的实例。这个实例拥有正确的浮点数属性值,这说明字符串到浮点数的转换都成功了。
下面是一个坏数据引发异常的示例,它展示了验证的过程:
当我们试图创建TestingKnownSample实例时,无效的species值引发了异常。
我们已经发现了所有可能的问题吗?csv模块会处理物理格式问题,如果我们提供一个PDF文件,它会抛出异常。无效的物种名和浮点数会在from_dict()方法中被检查。
有一些我们还没有检查的东西。下面是一些额外的验证:
· 缺少属性(key)。如果某个属性名(字典的key)拼写错误,代码会抛出KeyError异常,其不会被重新封装成InvalidSampleError异常。这个改动我们留给读者自行完成。
· 多余属性(key)。如果有多余的属性,数据应该算是无效的,还是应该忽略多余的属性?数据可能来自一个有多余列的数据表,我们应该忽略多余的列。这样会比较灵活,但是暴露出输入数据的潜在问题也很重要。
· 超出范围的浮点数。数值的合理范围可能是有上限和下限的。很明显,下限是0,花瓣长度等属性值不可能为负数,但是上限就没那么清楚了。存在一些用于定位异常值的统计技术,包括 中值绝对偏差 ( MAD )技术。有关如何发现似乎不符合正态分布的数据的更多信息,请参阅链接14。
这些额外检查的第一个可以被添加到from_dict()方法中。第二个必须与用户商讨决定,是否算是异常,如果算异常,可以将其添加到from_dict()方法中。
异常值检测更加复杂。我们需要在加载所有测试和训练样本后执行此检查。因为异常值检查不适用于单行,所以它需要一个不同的异常。我们可以像如下这样定义另一个异常:
对于这个异常,可以用简单的范围检查或更复杂的MAD方法做异常值检测。
有效的species列表被放在from_dict()方法中,这看起来不太明显,也会带来维护问题。如果源数据改变,我们需要修改方法,这很难发现,也容易忘记。如果有效物种变多,相关代码会变得很长而难以阅读。
使用包含有效值列表的显式 枚举类 ( enum )是一种将其转换为纯EAFP风格的方法。考虑使用以下方法来验证物种,这样做意味着重新定义多个类:
当我们使用species枚举类创建物种实例时,如果传入的字符串无效,会抛出ValueError异常。这与float()和int()函数会对非法数字字符串抛出ValueError异常是同样的做法。
使用可枚举的值也需要修改KnownSample类。它需要使用Species类,而不是使用str类来代表物种。在这个案例学习中,可枚举的物种数量有限,使用枚举类是可行的。但是,在某些问题领域中,可枚举的值可能很多,这会让枚举类变得很长,或者无法提供所有的值。
我们可以继续使用字符串对象,而不是枚举类。我们可以将该领域每个可枚举的字符串值定义为Set[str]类的子类:
我们可以像使用float()函数一样使用species.validate()函数。这将验证字符串,不会将其转换为其他类型,而是返回字符串。如果是无效的值,则会抛出ValueError异常。
我们可以用这种方法来重写from_dict()方法:
使用这种方法,我们要先定义一组全局的有效物种集合。令人愉快的是,这种方法使用了一致的EAFP风格来构建对象或引发异常。
我们前面说过,这个设计包含两部分。我们已经讨论了如何抛出合理的异常。现在,我们可以查看使用from_dict()方法的上下文,以及如何把错误汇报给用户。
我们将创建一个从CSV源数据创建对象的通用模板。这样就可以利用各个类的from_dict()方法创建相应的对象:
load()方法把样本分成测试子集和训练子集。csv.DictReader对象会产生可枚举的dict[str,str]对象数据源,作为参数传给load()方法。
我们可以这样做,一旦遇到错误就马上汇报错误信息并返回。错误信息看起来是如下这样的:
这个错误信息包含了所有信息,但这种方法并不是那么好。比如,我们最好一次性返回所有错误,而不是遇到一个错误就返回。我们可以如下这样重构load()方法:
这种方法会捕获每一个InvalidSampleError异常,并打印一条消息,显示错误的数量。这种做法可能会更有用,因为用户可以一次性修正所有错误。
在数据集非常大的情况下,这可能会导致太多的无用细节。例如,如果我们不小心使用了包含数十万行手写数字图像的CSV文件,而不是鸢尾花数据,我们会收到数十万条消息,告诉我们每一行都是错误的。
围绕数据加载操作,我们可以添加一些额外的用户体验设计,以使其在各种情况下都可用。这些方案都是围绕着出现问题时引发的Python异常进行设计的。在本章案例学习中,我们利用了float()函数的ValueError并将其封装成我们应用程序定义的InvalidSampleError异常。我们还为无效字符串专门创建了ValueError异常。
TrainingData的load()方法会创建KnownSample的两个子类的实例。我们把大部分逻辑放在父类KnownSample中,这样可以避免在每个子类中重复验证逻辑。
但是,对于UnknownSample,我们有点儿问题:UnknownSample中没有物种数据。理想情况下,应该把对4个测量值的验证和对物种的验证分开。如果我们这样做,我们就不能简单地将构建样本和数据验证放在同一个EAFP风格的方法中,要么创建所需的对象,要么引发异常。
当一个子类有新属性时,我们有两个选择:
· 放弃简单的EAFP验证。这种方法需要把验证和构造对象分开。这会导致两次使用float()做类型转换:一次用于验证数据,另一次用于创建目标对象。多次float()转换意味着我们没有遵守 不要重复你自己 ( Don ’ t Repeat Yourself , DRY )原则。
· 创建一个可以被子类使用的中间产物,意味着Sample的两个KnownSample子类涉及3个步骤。首先,创建一个Sample对象并验证4个测量值;然后,验证species。最后,用Sample对象的有效属性和有效species值构建KnownSample对象。这会创建一个临时对象,但避免了重复代码。
我们将把实现细节留给读者作为练习。
一旦定义了异常,我们还需要以某种形式向用户显示结果,引导他们采取正确的修正措施。这是建立在底层异常基础上的额外的用户体验设计考虑。