医疗保健专业机器学习实践指南-全-

医疗保健专业机器学习实践指南(全)

原文:Practical AI for Healthcare Professionals

协议:CC BY-NC-SA 4.0

一、人工智能及其用例介绍

在医疗保健领域,关于人工智能(AI)的讨论在过去几年中一直在增加。因此,医疗保健专业人员正在积极考虑使用这一卓越的工具来创建新的解决方案,使临床医生和患者都能受益。然而,在开发这些应用程序的过程中,个人经常会面临几个问题,其中最重要的可能是“我真的需要人工智能吗?”要回答这个问题,我们必须了解什么是高层次之外的人工智能。不幸的是,绝大多数面向初学者的人工智能资源都停留在对人工智能的概括概述上,而没有谈到如何实现它。更先进的材料是不透明的,数学上是密集的,并且经常是面向经验丰富的程序员和计算机科学家。这本书的目的是让读者了解人工智能实际上是什么,它是如何工作的,以及如何以初学者(具有医疗保健/医学背景)可以接受的方式编写基于人工智能的算法。但是,在我们能够进入人工智能及其机制的细节之前,我们需要建立人工智能实际上是什么的基本事实定义,并建立什么样的问题将受益于人工智能方法背后的逻辑。记住这一点,让我们从谈论医疗保健界面临的一个悖论开始:信息太多,却不知道如何处理。

医疗保健信息悖论

医疗保健领域正变得越来越技术化。使用电子健康记录、患者管理、图片和图像存档系统等意味着以前用笔和纸保存患者记录的方法已经过时了。然而,这些跟踪患者数据的新方法意味着医疗保健机构现在有了一种以更轻松的方式处理所有这些数字化信息的方法。就在 20 年前,甚至在一个机构进行分析大量患者数据的研究都令人望而生畏,因为个人患者报告必须被转录、整理、分组(如果有多次就诊)、以某种方式标准化、过滤无关条件等。现在,如果研究人员或医护人员想要收集一组患者的信息,只需点击几下鼠标就可以开始分析。

这是个好消息,但是我们该如何利用这些信息呢?如果我们有一个给定病人的病史的每一个方面的数据,一个研究人员如何确定哪些因素与分析相关?例如,如果有人想预测患者是否可能患糖尿病,首先要考虑的几个因素是他们的血糖含量、年龄和体重。但是这些都是因素吗?我们能收集更多关于他们家族史的信息吗?他们的身体质量指数如何,而不仅仅是他们的体重?糖化血红蛋白呢?关于他们以前的状况、饮食、急诊室就诊频率、杂货店附近等信息呢?所有这些因素都可能导致某人患糖尿病。然而,当我们试图提出一组“是/否”的陈述来判断某人是否可能患上某种疾病时,人们很快就无法确定这些因素中的哪些因素起了重要作用,以及一个因素如何与另一个因素相互作用。

这就是人工智能(AI)领域的技术可能派上用场的地方。具体来说,这些算法可以“学习”如何对风险状态做出决定,对我们对患者的每个特征进行加权,以最大限度地提高预测的整体准确性。这种方法说明了 AI 如何帮助解决医学相关的问题(这里是预测风险的问题)。但 AI 不一定是所有情况下的最佳解决方案。我们真的需要了解什么是人工智能,以及什么时候实际使用它最合适,然后我们才能考虑使用编码来解决可能需要使用人工智能的医疗问题。

人工智能、人工智能、深度学习、大数据:这些流行语是什么意思?

到目前为止,我们已经谈论了一些“人工智能”这个术语,但是一个立即浮现在脑海中的问题是,“人工智能是什么?”如果不深入研究非人工智能程序是如何工作的,这个问题就有点难以回答。

想象一下你要计算一个病人的身体质量指数。根据定义,这只是 weight(kg)/(height(m))².这是一个非常简单的计算,任何人都可以用手算出来。如果以磅和英寸给出重量,我们可以应用 CDC 推荐的 703 * weight(lbs)/(height(in))².公式由于我们知道计算这一数量的精确公式,报告患者的身体质量指数就像在计算器中输入患者的体重和身高并报告数字一样简单。这是我们不用人工智能就能轻松完成的事情,因为这是一个已经设定好参数的简单计算。如果我们需要确定某人是否体重不足、正常、超重或肥胖,我们根据 CDC 指南报告他们的状况(如果身体质量指数< 18.5,则体重不足;如果身体质量指数≥ 18.5 且< 25,则正常;如果≥ 25 且< 30,则超重;如果≥ 30,则肥胖)。为了简单起见,让我们写下到目前为止我们已经讨论过的内容:

Given Weight_in_kg, height_in_m:
    BMI = Weight_in_kg / ((height_in_m)²)
    if BMI < 18.5:
        then Patient is underweight
          otherwise if 18.5 ≤ BMI < 25:
        then Patient is normal
           otherwise if 25 ≤ BMI < 30:
        then Patient is overweight
    otherwise if BMI > 30:
        then Patient is obese

信不信由你,这是我们的第一个“程序”(用引号括起来,因为你还不能在电脑上运行它)。无论如何,这种算法(也称为一系列步骤)有许多特征可以将它与人工智能区分开来。首先,关于定义患者体重状况的某些界限的参数没有变化。这些都是疾控中心明确规定的。另外,我们知道无论我们执行这个算法多少次,结果都不会改变。

用人工智能领域的技术制作的程序通常不会带有固定的参数。相反,人工智能程序将试图“学习”必要的参数,以优化实现某些最终目标。当我说“学习”时,我真的是指学习。人工智能的典型方法包括给一个人工智能程序一组训练数据,“告诉”程序优化一些指标,然后在测试数据上评估程序的性能,这些数据不是用来训练它的(基本上就像学生在学校接受的测试一样)。在我们之前的身体质量指数问题的背景下,解决这个问题的人工智能方法将涉及给人工智能程序一个数百(可能数千)名患者的列表,这些患者的体重、身高和最终身体质量指数状态(体重不足、正常、超重、肥胖)。通过训练过程,人工智能程序将尝试学习相关参数,以输出正确的重量类别。然后,我们可以在一组未用于训练程序的测试数据上评估程序的准确性。我们为什么要做这个测试步骤?以确保程序确实学会了在其看到的训练数据之外归纳其计算。图 1-1 突出显示了普通程序和人工智能程序之间的差异。

img/502243_1_En_1_Fig1_HTML.jpg

图 1-1

展示了普通程序和人工智能程序之间的主要区别。请注意人工智能程序中涉及的训练和测试数据

人工智能程序如何执行前面的例子的细节将在本书后面阐述,但是一些突出的概念可以从前面的场景中得到。首先,在一个人工智能程序中,程序本身试图根据我们指定的一些标准来提高它的性能。第二,在一个人工智能程序中,由于我们没有指定参数,我们应该使用某种形式的训练和测试数据来确保程序学习如何优化主要指标,并在学习过程中推广它已经看到的数据。如果我们窥视人工智能程序学习的东西,我们不一定能保证它已经形成了与我们类似的决策过程。我们所知道的是,相对于我们的指标,它的表现相当准确。

让我们想一个不那么做作的例子来说明人工智能可能被用在什么地方。如何通过脑部扫描来检测病人是否有肿瘤?对于放射科医生来说,这是一项简单的任务。从 PACS 系统调出核磁共振图像,在扫描中寻找异常。好的,现在我如何检测 1000 个病人的扫描结果中是否有肿瘤?嗯,把这项工作交给一个放射科医生,甚至一个放射科医生团队,仍然意味着这项任务需要一段时间才能完成。为了加快速度,我们可以考虑用一个程序来做这件事。但是我们如何向程序指定如何寻找肿瘤呢?我们不一定能从几年的医学院和住院医师培训中获得经验。我们也不能采用早期的非人工智能方法,因为每个患者在 MRI 系列中可能不会在完全相同的位置上有脑肿瘤,也不会所有肿瘤看起来都一样(事实上,MRI 可能是用不同的成像参数拍摄的,等等。).因此,我们剩下的唯一真正的解决方案是用人工智能来完成这项任务。

有一些人工智能算法(称为神经网络)可以学习如何在扫描中检测对象,甚至完成更复杂的任务,如分割(即,准确标记图像中的哪些像素/体素对应于某个对象,在我们的情况下,是脑瘤)。然而,过程将是相同的。我们将使用一些带注释的数据 MRI 扫描(即,如果存在肿瘤,扫描会准确显示 MRI 切片的哪些部分包含肿瘤),对算法之前没有见过的一些数据进行测试,以评估其准确性,然后在我们的 1000 张图像上运行经过训练的 AI 程序(通常可互换地称为“模型”)。同样,我们不知道人工智能是如何完成这个过程的,也不知道它到底会寻找什么(尽管我们可以用一些技巧来可视化程序的一些部分),但我们知道它在一定程度上完成了自己的工作(由它在测试数据和 1000 次扫描中的表现决定)。

现在我们已经对什么是人工智能和什么不是人工智能有了基本的了解,让我们澄清一下人工智能的正式定义。大致来说,人工智能只是由机器展示的智能。它包含了大部分的过程,包括学习/训练阶段的一些模式,然后测试/评估结果程序。我们最终会看到,目前被称为人工智能的东西并不是你我所认为的真正的智能。相反,在大多数适用于医学成像和研究的情况下,人工智能可以被视为高级模式识别。在前面的例子中,我们的人工智能程序的任务是识别身体质量指数和体重分类的模式,并在核磁共振成像中找到指示肿瘤存在的模式。然而,现有的人工智能形式可以执行更复杂的任务,如处理人类语音,创建类似人类的对话,玩人类游戏,等等。迄今为止,这一领域的进展集中在非常好地执行特定任务上;然而,它在开发一种可推广到特定任务之外的机器智能方面没有取得很大进展(然而,谁知道未来会发生什么)。

好的,这就是 AI。现在什么是机器学习?机器学习可以定义为一组随着经验而变得更好的算法。当我说“经验”时,我的意思是当暴露于足够的训练数据时,程序本身的结果将根据一些最终的度量标准(在大多数情况下,这是某种形式的准确性)而改变和优化。机器学习是人工智能的一个子集。常见的机器学习算法包括线性回归(是的,这种类型的线性回归,您可以在 excel 中使用,以对一些数据进行最佳拟合)。在线性回归的情况下,我们不一定要区分训练数据和测试数据;然而,无论给我们什么样的数据,我们都试图得到最佳的猜测。通过特定的算法,程序可以在报告“足够好”的结果(可能是也可能不是最适合数据的最佳线)之前,通过多次迭代反复完善该猜测。其他机器学习算法包括决策树(在给定输入的一些属性的情况下,它将有效地制作流程图,以确定给定输入属于哪个类别)、聚类算法(试图将点分组为预先指定数量的组)和基于实例的算法(试图根据未知点与先前训练数据的接近程度来预测标签)。

深度学习,另一个你可能听说过的术语,完成和机器学习一样的任务;然而,它使用了一套特定的算法,其中包括使用人工神经元,这些人工神经元的工作方式与我们自己的神经元类似。正如我们身体中的单个神经元具有动作电位的行为一样,这些人工神经元也是如此。这些人工神经元相互连接,因为一个神经元的输出输入到另一个神经元的输入,导致多组神经元(称为层)的信号积累。网络的层数越多,它就变得越“深入”,就越有能力从训练数据中学习更多重要信息,从而使它能够在训练数据集之外进行归纳。还有另一种称为自然语言处理(NLP)的深度学习子集,可以帮助计算机处理、解释和生成文本数据。深度学习算法包含了当今人工智能的大部分主要进展(或者至少是最常被谈论的进展)。由于神经网络的多功能性和我们现在可以获得的巨大计算能力(与二十年前相比),深度学习算法对工业和研究变得非常有用,有助于从图像中检测物体、为类似人类的对话生成文本等任务。这些算法如图 1-2 所示。

img/502243_1_En_1_Fig2_HTML.jpg

图 1-2

人工智能概述以及机器学习和深度学习领域的算法示例

深度学习和机器学习的共同点是问题的构造方式。学习算法可以分为有监督的和无监督的(还有一些额外的类别,但与本书无关)。

监督算法要求人类提供训练数据本身的标签。在我们的身体质量指数示例中,我们必须为给定的个人提供一个体重等级标签来训练网络并评估其准确性。监督算法通常涉及任何分类任务(例如,要求程序确定一个人属于哪个体重等级)或回归任务(例如,要求对某组数据的最佳拟合线是什么)。

无监督算法是不需要对数据点进行任何单独标注的算法。相反,我们要求程序本身为我们的训练数据中的单个数据点进行分组。例如,如果我要重新构建身体质量指数问题,而是要求程序将每个人分为四个一般组,而不给出任何关于这些组可能是什么的实际输入,这将是一个无监督的学习任务。另一种类型的无监督学习任务被称为降维,它实际上是要求程序确定给定数据中最重要的特征是什么,这将允许我们解释数据中的所有差异。

然而,所有这些任务都需要数据来实际让算法很好地执行(回想一下,机器学习算法基于它可用的训练数据迭代地改进)。然而,对于其中的一些算法(特别是神经网络),训练好的模型需要的数据量可能很大(有些人甚至会说“大”),这就给我们带来了下一个问题:什么是“大数据”?

很难确定“大数据”本身的确切定义,甚至很难为该术语找到一个统一的定义。然而,有一点是清楚的,那就是如今它被更普遍地使用。随着互联网上信息财富的增加,以及各个组织收集越来越多的关于其用户和访问者的数据点,研究人员、程序员和分析师应该开始弄清楚可以从数百万个人数据点中收集到什么样的见解。实际上,“大数据”及其相关科学“数据科学”围绕着试图通过对大型数据集执行操作来寻找噪音中的信号,以提取有用的见解。数据科学运营根本不需要在分析中采用人工智能方法,而是可以从均值、中值、范围、百分位数等统计指标中提取有意义的结果。然而,数据科学和人工智能方法学配合得很好,因为数据科学专注于从看似不可理解的数据中提取信息,而人工智能的子集专注于做同样的事情,但使用了一些更好的迭代学习方法。结合起来,这两个世界产生了高级模式识别程序,在各种场景中表现良好。

所以现在你知道 AI,机器学习(ML),深度学习(DL),大数据/数据科学实际上是什么意思了。我们现在将继续看看从人工智能的角度处理问题实际上是什么样子,以及当我们计划培训、评估和部署我们开发的最终程序时,我们需要考虑哪些因素。

人工智能考虑因素

嗯,你需要的一件事是对你试图回答的确切问题有一个概念。讽刺的是,这需要问更多的问题。第一个是问你自己你通常如何(即,没有人工智能的帮助)解决这个问题。在此基础上,根据不同的案例参数确定该过程的变化程度,并查看该解决方案是否仍然有效。如果你能找到一个通用的方法,也许值得将你的人工智能问题简化为试图模拟这个过程。例如,在发现椎骨骨折的情况下,需要测量每个椎体的三个椎骨高度。这些高度之间的相对差异足以对骨折类型和严重程度进行分类。因此,显而易见的是,我们的 AI 可能也应该尝试做同样的事情,即,在成像研究中找到所有的椎体,并测量骨折检测所需的三个椎体高度。在这种情况下,我们用来训练 AI 的数据将是椎体的位置和每个椎体的高度。但是仅仅单独提供这些输入(即,对应于椎体的边界框位置的坐标和三个高度测量值的列表)仍然会产生关于这些高度应该来自哪里的不确定性。然而,如果我们训练神经网络来找到与形成测量高度的三条线相关联的六个关键点,我们就可以计算关键点之间的距离。总的来说,将一个问题从模糊的东西变成更具体和范围有限的东西可以帮助你决定一个更好的解决人工智能问题的方法。

当你对一个特定的问题应该是什么有了一个大致的概念后,你的下一步就是尝试找到可以帮助神经网络训练的数据。对于您需要的数据量,没有一个正确的答案;然而,看看以前在其他领域解决你正在尝试的问题的尝试是有帮助的。例如,我在最后一段中提到的脊椎骨折问题可以通过查看一组人工智能模型来解决,这些模型专注于确定照片中人类的位置,以及每个人的头、手、脚、臀部、躯干和膝盖的位置。事实证明,这类研究项目能够利用一种被称为“迁移学习”的概念(有效地使用另一个人工智能模型的训练部分),将所需的训练样本数量从数千个大幅减少到数百个。这些数据也可以被扩充(即,以某种方式复制和操纵)以人工地产生更多的训练数据,这些训练数据模仿在对你可用的训练数据集中可能未被充分表示的其他条件(例如,在 MR 图像的情况下,你可能想要用不同亮度和对比度值的图像来扩充训练数据,以考虑成像参数的变化)。

保护数据集后,下一个任务是量化网络的性能。我将在本书的最后一章讨论“人工智能蛇油”的含义,但要预先警告的是,由于你的训练和测试数据集或你如何构建网络中存在的选择偏差,当准确率缺乏外部有效性时,很容易将一个真正高的准确率作为一项成就。此外,如果你处于考虑使用现有人工智能解决方案的位置,你应该预先警告一些供应商倾向于将术语“人工智能”作为一个流行词来使用,而不是真正使用机器学习或神经网络来产生他们的输出。

训练和部署你的网络也需要计算能力,这取决于你正在处理的任务。一些人工智能可以单独使用你的笔记本电脑进行训练。其他的将要求你以某种方式获得更高能力的计算资源。值得庆幸的是,对于大多数实验目的来说,有一些解决方案是免费的(在部署或需要更多资源时,需要花钱来托管)。一旦你的人工智能被训练,你还需要考虑它将如何被部署(例如,人工智能的最终用户将如何与它交互)。在医疗环境中,部署是一个很大的问题,因为您可能需要与许多系统进行交互,以便为您的最终用户创建简化的体验。此外,作为一种可能用于管理患者和医疗保健数据的工具,您需要探索 FDA 法规和法令在您潜在的基于人工智能的解决方案中的作用(并且应该在这方面咨询适当的专业人员)。

最后,也是最重要的,在开始训练或分发您的人工智能解决方案之前,应该解决对患者数据隐私和数据集中偏见的担忧。在某些情况下,可以从模型本身的输出中收集有关用于训练 AI 模型的底层数据集的信息。例如,如果你要创建一个人工智能驱动的聊天机器人,它通过与医疗保健人员的文本/电子邮件进行训练,神经网络可能会意外输出敏感的患者信息,因为人工智能有非零的可能性“看到”这些信息在执行其最终任务时是有价值的。此外,根据数据集中的偏差,应该注意的是,人工智能的输出反映了用于训练它的数据。如果您要创建一个检测皮肤黑色素瘤的应用程序,并且您的数据集主要由浅色皮肤的个体组成,那么您的网络输出很有可能会偏向于为浅色皮肤的人输出更准确的结果,而为深色皮肤的人输出不同的结果。因此,应注意尽可能平衡跨多个类别使用的数据集,这也将有助于实现较高的外部效度。

摘要

到目前为止,我们已经涵盖了 AI 实际上是什么(机器学习、深度学习算法等的通用描述符)以及它与普通程序的不同之处。我们还谈到了在提出基于人工智能的解决方案来解决你在临床环境中可能发现的问题/研究问题时,你应该记住的考虑因素。所有这些信息都可以在任何其他文章或书籍中找到;然而,在本书的其余部分,我们实际上将开始应用在这一章中学到的原则。

本书的其余部分…

如前所述,这本书不会让你对人工智能有一个笼统的、模糊的或“时髦的”理解。你实际上是在编码它,这将需要你苦读一些介绍性的和高水平的材料,这些材料通常可能与人工智能没有直接关系;然而,当考虑如何编程、实现或改编人工智能技术来解决你想要解决的问题时,这些材料会很有用。

因此,本书的下一章将关注这条道路的第一步:计算思维。具体来说,那一章将集中于算法,算法的分析,以及目前算法研究中一般主题的概述。那一章包括几个例子,这些例子将说明看似困难的问题实际上是如何通过对手头问题的独特见解来快速解决的。我们将讨论两个需要使用算法的主要问题:稳定匹配和活动选择。这些算法本身与人工智能的世界没有特别的关联;然而,它们确实有助于说明计算复杂性、运行时分析和正确性证明等主题,这些主题将在本书的后面出现。此外,他们将开始暗示通过计算思考问题的一般结构,表明有必要设计具体的、离散的和详细的程序来以可证明正确的方式解决问题。

在接下来的一章中,我们将深入到编程的世界中,向你展示对构建、编写和运行程序至关重要的概念。在那一章中,我们将实现一个二次公式求根器。虽然这个例子看起来有点不自然,但是变量、函数和类等概念将会被涵盖(这些也会在后面的章节中出现)。此外,我们将实现一种输入方法。包含二次方程列表的 csv 文件,让我们有机会弄清楚如何处理文件输入和输出。所有早期的讨论将主要在 Python 编程语言中进行,这是初学者开始编程和人工智能的首选语言。

之后的一章将从与建立计算机科学和编程的基本概念相关的弯路中抽身,转而关注特定的人工智能技术和学习算法。这一章,虽然并不意味着是对人工智能所有主题的详尽覆盖,但它旨在给出初学者应该能够在高水平上理解的突出和重要算法的概述。这些算法为什么实际工作背后的数学证明将被忽略(关于这个主题有几本书和在线课程)。相反,我们将专注于这些数学证明对于我们涵盖的人工智能算法的训练、测试和验证过程的实际意义。

之后的两章将涵盖从零开始构建的项目,包括人工智能在医学中的应用。具体来说,我们将编写一个机器学习管道,从篮球受伤的数据集预测急诊室入院情况,然后我们将制作深度神经网络,可以从胸部 x 光片预测肺炎。我们将遍历每个例子的代码,并一点一点地构建它,让您直观地感受到实现和调整这些人工智能技术以解决手头问题所需的工作量。

最后一章将集中讨论人工智能在医学上的意义。主要是讲 AI 在医疗领域的潜在使用案例和误区。在这里,我们将讨论患者数据隐私和 HIPAA 以及人工智能蛇油在医学中的故事(以及如何识别假冒人工智能产品的迹象)。我们还将讨论如何继续自学,以及如何解决未来程序员面临的一个常见问题:修复错误。

说了这么多,让我们继续第二章。

二、计算思维

到目前为止,我们一直在拟人化地谈论计算机,说它们学习,执行算法等。然而,计算机实际上唯一能做的事情是遵循一系列指令。在这一章中,我们将更多地讨论指令列表应该是什么样子。我们现在还不会进入编程,但是我们将会讨论“算法思维”,也就是说,如何为程序的执行制定解决方案的步骤。在此期间,我们还将触及一些计算方面的理论问题(包括强调一些问题是如何非常低效地解决的),如何确定我们算法的复杂性(因此我们可以尝试更简单的解决方案),以及一些算法/算法类别,它们可以提供替代方案,让人工智能解决您的潜在问题。这一章的很多内容会感觉高度理论化(因为它意味着理论化)并与人工智能分离,但确实为开始像计算机“思考”一样思考提供了基础(这对学习如何编写一般程序和编写人工智能算法至关重要)。抛开免责声明不谈,让我们来谈谈计算机实际上是如何工作的。

计算机如何“思考”

在最基本的层面上,计算机按照二进制数(即 0 和 1)运行。从一个日常程序员的角度来看,真的没有必要去想这些数字;然而,它确实说明了计算机是根据数值运算来思考的。CPU(中央处理器)支持的计算机操作的最小集合是加法、乘法、除法以及从/在存储器中加载/存储值的变体。然而,考虑到计算机运行的惊人速度,我们可以使用这些操作来获得更复杂的行为,如比较两个数字的能力,多次执行同一组指令的能力,甚至将信息发送到输出设备(如根据存储在计算机内存中一组特定位置(称为地址)的值来改变像素颜色的屏幕)。

现在,所有这些对初级程序员来说意味着什么?这意味着计算机是“哑的”你让他们做什么,他们就会做什么,而且做得很快;然而,如果你不指定具体的步骤,计算机就不能做你想让它做的任何事情。例如,在我们之前的身体质量指数计算器例子中,从概念的角度来看,我们的程序将做的事情(即,接受两个数字,计算一个身体质量指数,并说出某人属于哪个体重类别)的概要是很棒的;但是,它没有指定一些额外的行为。首先,我们是如何接受单个输入值的?此外,我们如何报告实际的体重类别?我们是否从文件中读取/写入这些值?从更高的层面来看,用户将如何利用这一功能?我们会希望他们通过网站与工具互动吗?如果是这样,我们是否希望存储以前的计算结果以便于参考?

所有前面的问题,以及更多的问题,都是你在定义要解决的问题时应该考虑的。在最基本的层面上,一个程序有某种形式的输入,一组处理输入的步骤,以及一个输出。具体描述每一步会发生什么取决于你。如果我们在计算思维中重新定义我们的身体质量指数问题,我会说程序的输入是可变的;然而,为了简单起见,我们将在网站上提供一个工具。从那里开始,该网站将在页面上有两个输入框:一个用于输入体重,另一个用于输入患者的身高。当用户按下网页上的一个单独的按钮时,将执行一个计算来计算患者的身体质量指数并报告体重类别。计算结果将显示在网站上,供用户查看。我漏掉的一个关键部分正是计算发生的地方。网站其实有两种选择。计算可以在向您发送您正在查看的网站页面的代码的 web 服务器上进行(然后,您的 web 浏览器会为您解释并显示该代码),或者计算可以在网页本身上进行(即,在浏览器内)而无需联系服务器。不管这里提到的细节如何,很明显,为一个程序定义一个问题和解决方案是相当复杂的。

在更高的层面上,当我们谈论可以用 AI 解决的问题时,我们不得不考虑额外的问题。第一,我们如何为他们正在选择的 AI 算法获取足够的训练数据?那个 AI 算法的目标是什么?什么算法最适合这项任务?我们如何量化一个人工智能有多“错误”?我们如何在算法被训练后测试它的功效?

在旨在潜在地检测 MRI 扫描中的肿瘤的 AI 的情况下,我们将需要来自放射科医生的带注释的 MRI 图像,这些放射科医生手动标记图像并描绘肿瘤的精确体素位置(即,分割肿瘤本身)。这项任务产生的其他问题是:放射科医生将使用什么来注释成像序列,该程序如何输出分割,以及它是否容易被我们正在构建的任何程序读取?在只有几个放射科医生为几个 MRI 系列创建单独分割的情况下,我们如何增加训练集(即,对数据应用图像变换,如扭曲、缩放、裁剪、增亮等)。)为我们正在使用的算法提供更多数据?

一旦我们确定了如何为算法提供训练数据,我们必须问的另一个问题是如何评估算法?我们是否只是根据其检测图像中是否存在肿瘤的能力来评估人工智能,或者是否需要更具体的结果?是否有必要对图像中的肿瘤类型进行分类,或者在成像研究中仅报告可能是肿瘤并对其进行标记以供进一步的人体分析是否足够?前面的问题将告知我们使用什么样的方法来评估人工智能,以及它在整个训练过程中有多“错误”(最终在评估程序本身时)。最后,我们需要确定人工智能程序的输入和输出将如何完成,以及它将在哪里可用(即,它将位于个人的计算机上还是服务器上?).

一旦你实际上把你的问题或提议的过程格式化为从计算的角度可以想到的东西,下一个要问的问题是某个东西是否可以通过计算来解决。

什么“能”和“不能”被解决

虽然计算机在执行单个操作时速度惊人,但有些任务用传统的算法方法是不可行的。能够准确发现哪些问题是不可行的,这对于学习如何为医学领域的问题起草研究提案和潜在解决方案至关重要。

从形式的角度来看,计算不可行性被定义为一个确实是可计算的问题,但是难以置信的资源密集程度,以至于由于需要大量的资源,计算机执行一项任务是不实际的。被认为在计算上不可行的问题的一个例子是旅行推销员问题(TSP)。问题如下:给定一个城市列表和每对城市之间的距离,恰好访问每个城市一次并返回原城市的最短可能路线是什么?

解决这个问题的第一个尝试是选择一个城市。从那里,选择一个剩余的城市,并将其添加到累计行驶距离中。一旦你去过所有的城市至少一次,然后回到原来的城市。问题是我们需要找到最短的路线。我们的解决方案只提供了一种潜在的方法来做到这一点:尝试城市路径的每一种可能的组合,以找到一个尽可能最小的距离。然而,这个操作可能很快变得非常复杂。如果我们有四个彼此等距的城市(即,四个点排列在一个正方形中),我们将有四个可能的选择作为我们的起始城市,三个选择作为下一个旅行的城市,两个在那之后,一个在那之后。这意味着我们需要检查 24 个(432*1)不同的路径组合,并找出哪一个是最短的。

检查这些相对较少的路径是非常小的。然而,如果我们改变我们需要访问的城市数量,会发生什么呢?作为一般规则,我们可以看到我们当前的算法要求我们检查 n!不同的路径,其中 n 是城市的数量。如果我们把城市的数量改为 100 个呢?嗯,100!是 9.310¹⁵⁷.这是一个不可思议的大数字,肯定超过了单个处理器的处理能力(很可能一秒钟执行一次 210⁹ 运算)。一些信封背面的计算意味着我们将需要(至少)10¹³⁸ 年来检查所有可能的解决方案。在某些情况下,宇宙的热寂预计将发生在 10¹⁰⁰ 年,到那时,我们的程序仍将运行。我们概述的方法通常被称为“强力”方法,因为我们正在检查每一个可能的解决方案。尽管算法本身相对容易理解,但我们需要做的运算量太大,不可行。

当然,我们可以放松围绕这个问题的一些约束,使我们能够解决一些稍微简单的问题。我们可以通过反复选择我们还没有去过的最近的城市来找到一条通常被认为“足够好”的路径,而不是找到最短的路径。因为我们没有检查所有可能的解决方案,所以我们无法真正保证这将是最短的路径,但是如果您想要在宇宙热寂之前找到 100 个城市的 TSP 问题的解决方案,您可能会愿意考虑使用该算法(也称为“贪婪算法”,因为它在每一步都会立即选择成本最低(即距离最短)的路径)。

让我们尝试另一个稍微简单一点的问题:尝试在电话簿中查找企业。最初的解决方案是通过手动翻页来费力地检查电话簿中的每一页。嗯,没人真的这么做。当我们考虑做这项任务时,我们通常会有某种启发性的想法(捷径):翻到电话簿中该名字所在位置附近的一页。如果它不在那一页上,确定它是在你翻到的那一页之前还是之后。从那里,重复该过程,重新设置您选择的页面的边界。具体来说,假设一本电话簿有 500 页,你需要找到一个以“j”开头的企业。假设字母“O”的企业在那一页上,那么我们知道“J”的企业会在那之前。然后让我们从 0 到 250 的范围内选择中间的一页,而不是 0 到 500,比如说 125 页。第 125 页包含名为“h”的企业,所以现在我们知道“J”企业将出现在第 125 页之后,肯定在第 250 页之前。因此,让我们选择该范围内的中间一页(约 313 页),我们可能会在这一页上找到我们的业务!

实际上,这种方法(称为“递归”)专注于将一个较大的问题分解成较小的问题(称为“子问题”),然后对每个子问题重复相同的操作,直到我们认为完成了某个点。我们的过程基本如下:给定一组我们知道已排序的要查找的东西(我们的例子中的电话簿是按字母顺序排序的),选择中间的元素。如果该元素在我们要查找的项目之前,则在集合的后半部分重复第一步。如果该元素在我们要查找的项目之后,则在集合的前半部分重复前面的过程。一旦我们找到了想要的元素,就停下来报告结果。如果我们的电话簿只有八页,实际上可以证明,在最坏的情况下,我们只需要三次迭代就可以找到我们想要的业务所在的页面。在这里,最坏的情况是我们的业务在电话簿的第一页(或最后一页)(所以我们会打开到第 4 页,然后转到第 2 页,然后转到第 1 页,最后结束)。概括这个规则,如果我们有 n 个元素要搜索,我们将在算法的日志2【n】次迭代中找到想要的元素。事实上,这是一个对数规模的操作,因为如果我们的电话簿有 2048 页,我们只需要执行翻转(最多)11 次!

前面的算法说明了一个人在解决问题时可能面临的潜在问题的两个极端。一方面,您可能会遇到一个需要很长时间才能解决的解决方案,这导致您要么限制问题的规模(在我们的例子中,将城市的数量限制在比 100 小得多的数量),要么放松约束,以便您可以获得一个在计算约束下工作的解决方案(即,追求“足够好”的贪婪方法)。另一方面,通过一点点直觉思维,你可能会得到一个几乎在所有规模下都能很好工作的问题(例如,我们的“递归”解决方案)。

现在,所有这些和人工智能有什么关系呢?回想一下我们的旅行推销员问题(TSP)。我们知道,如果我们有 n 个城市,我们将需要检查 n!不同的潜在解决方案,找出产生最短潜在路径的方案。我们将该算法指定为非多项式算法,其中“多项式”将表示该函数小于表达式 n k (其中 n 是输入的大小,k 是某个不是变量的常数;注意,我们也忽略任何常数,并假设我们只对“小于”表达式求值,只超过一些通用常数)。 n!只能由一个函数 n n 上界,由于指数 n 不是常数且随输入大小变化,所以不是多项式。这类问题属于被称为“NP-Hard”的一般类别,因为(非常,非常,非常粗略地)人们认为它们不能在多项式时间内解决(但陪审团仍然不知道)。深度学习算法属于 NP-Hard 问题的领域,并且共享在旅行推销员问题中看到的类似的求解时间(即,不是多项式时间)。

More on complexity classes

还存在其他复杂性类别。电话簿搜索问题(正式名称为二分搜索法)是一个可以在多项式时间内解决的问题(上限为 n ),属于复杂性类“p”。“NP”问题是指可以在多项式时间内验证其解决方案的问题。就 TSP 而言,如果我们将约束从“查找最短路径”更改为“查找长度小于 X 的路径”,那么就很容易检查解决方案是否正确(只需将城市之间的跳跃长度相加,而不是尝试所有可能的路径,以及所建议的路径是否与所有可能性中的最短路径相同)。“NP-困难”问题更准确地说是与 NP 中的问题一样困难的至少的问题(即,NP 中的问题可以被“重新表述”为 NP-困难问题),而“NP-完全”问题是既“NP”又“NP-困难”的问题计算机科学中讨论的一个问题是 P 是否等于 NP。本质上是问一个问题,它有一个可以在多项式时间内验证的解,是否有办法在多项式时间内找到那个解。

当然,ML、DL 和 AI 算法有各种更宽松的约束和捷径,它们试图得出对大多数情况“足够好”的解决方案。例如,一些人工智能算法倾向于将“足够好”的判断留给用户,并要求预先指定训练网络所需时间的限制,而不是试图找到产生正确输出的最佳方式。因此,很难找到一种算法可以一直 100%准确地运行(如果有人声称找到一种 100%准确的算法,你应该怀疑)。但总的来说,人工智能问题具有更高的计算复杂性,因此需要更长的时间来解决。这意味着人工智能算法有时需要大量的计算来训练(例如,谷歌花费数千美元来训练其自然语言处理模型),这些程序的创造者需要认识到人工智能的 NP-Hard 性质所导致的潜在资源限制。

既然我们已经讨论了什么可以解决,什么不能解决的计算观点,我就不能不提为什么不可能解决人工智能世界中的某些问题的其他原因。这些主要处理您可用工具的实际限制。例如,如果你想找到一种方法来跟踪患者使用手机的步数,你必须确保患者使用的手机有一个加速度计(测量手机的倾斜度),你可以访问它的读数,或者手机本身有某种方法来访问一个人当天的步数(例如,苹果设备允许应用程序开发人员访问拥有 iPhone 或 Apple Watch 的用户的步数数据和心率信息)。如果你没有合适的工具,问题很容易变成不可行的,所以一定要确保你正在解决的问题是正确的。

算法选择

正如我们在上一节中所述,非人工智能算法可能会产生有效的解决方案,并且有几个已经在医疗保健领域这样做了。在这里,我们将涵盖一些类型的非人工智能为基础的算法的替代品存在。本节的标题有点用词不当,因为不一定有一个算法列表或一类算法可以解决您可能面临的问题。然而,我将尝试举例说明算法在医疗保健和生物学中的应用。这些例子并不意味着是全面的;相反,他们旨在表明,在一个特定的问题上投入人工智能可能不是医疗保健领域可能面临的所有既定问题的最佳解决方案。抛开这个免责声明,让我们来看看一个算法,一些读者可能很熟悉。

稳定匹配

如果你是美国的一名医生,你在培训期间可能接触过的一种算法是 Gale-Shapley 稳定匹配算法。这种算法(稍作修改)决定了申请人在未来几年追求住院医师资格时将选择的医院和专科。然而,该算法背后的想法源于一个更简单的问题:寻找最佳婚姻。

匹配算法的基础始于理论情况。假设有一些男人和一些女人(每组人数相等),每个人都有他们最终想和谁结婚的偏好(男人起草一份排序的求婚清单,女人起草一份排序的接受清单)。这个算法的目标是创建一个稳定的匹配,它是所有男人和女人的配对,这样就不存在一个男人 M 和一个女人 W 的配对,其中 MW 更喜欢彼此而不是他们最终匹配的人。实际上,这意味着这个算法的目标是防止私奔(即,如果一切顺利,我们将知道有人没有获得他们的第一偏好,因为第一偏好拒绝了那个人)。实现这种提供稳定匹配的保证是很重要的,因为(在现实世界中)我们希望确保所有的住院医生和医院都得到他们真正想要的住院医生(而不是出现更好的住院医生-医院对是可能的但没有发生的情况)。

那么我们如何解决这个问题呢?一种尝试是列出所有可能的男女配对,然后检查哪些是稳定的。这样做会给我们一个所有可能的稳定匹配列表;然而,这也将是非常计算昂贵的。为了探究它在计算上有多昂贵,假设我们有三个男人和三个女人。列举所有的可能性会产生六个不同的可能匹配(如果你愿意,你可以自己证明),这还不算太坏。但更普遍的是,这种策略会要求你列出 n !不同的匹配(如果我们有 n 个男人和 n 个女人)然后检查看哪一个实际上是稳定的。生成阶乘数量的匹配在计算上是非常昂贵的,即使在 n(例如,20!is 2.43e18)并且处理每年申请住院医师资格的数千名医学学生肯定是不可行的。

1962 年,大卫·盖尔和劳埃德·沙贝利提出了一种解决方案,可以解决稳定匹配问题,并且可以在大约 n 2 的运算中解决。他们的解决方案如下。

如果有人尚未订婚,请执行以下操作:

  • 每个没有订婚的男人都应该向他最喜欢的女人求婚,只要他以前没有向那个女人求婚。

  • 每个不匹配的女人都会暂时“订婚”给她收到的求婚中排名最高的男人。如果她被匹配,但收到一个比她当前“订婚”级别高的男人的订婚邀请,她将离开那个男人,与新的男人订婚(即,她升级)。

这个过程一直重复,直到每个人都和某个人订婚(如果男女数量匹配的话)。该算法保证每个人都必须订婚,并且所有订婚(即将结婚)都是稳定的。为了证明后一点,想象一个男人迈克和一个女人温迪互相喜欢,但最终没有订婚(迈克最终和爱丽丝订婚,温迪和鲍勃订婚)。他们对这种可能性感到困惑。然而,根据算法,我们知道 Wendy 一定在某个时候拒绝了 Mike(当她有空的时候或者当她临时订婚的时候),而选择了 Bob。最后,我们知道匹配不可能有不稳定性,因为一方肯定在某个时候拒绝了另一方,而倾向于更高的偏好。

该算法明显比初始置换方法(即列出所有可能的匹配并检查稳定性)更快。但是这并不能保证我们得到最佳的匹配,因为“最佳”这个词取决于你从谁的角度出发。Gale-Shapley 算法可以被构造成有利于男人而不是女人,或者女人而不是男人(前面提到的算法为男人提供了第一选择,从而确保他们得到最好的可能结果)。无论如何,稳定匹配问题背后的想法在国家住院医师匹配计划中发挥了作用,并考虑到以下事实:申请的医学学生比医院多,医院的多个住院医师职位不同(一方可以有多个约定),以及更愿意匹配到同一家医院或城市但具有不同专业的夫妇。在匹配算法中考虑夫妇实际上使问题 NP-完全。稳定匹配算法具有超越住院医师匹配的相关性,并且最显著地用于确定器官交换的动态过程,当近亲与需要移植的患者不相容时,为关键的肾脏手术分配供体-受体对。

值得注意的是,使用这种传统算法,我们不需要以任何方式使用人工智能技术。不需要根据稳定匹配的先前例子来训练神经网络,也没有办法训练算法来产生正确的匹配。我们只是列出了一套规定的指令,并证明这些指令会导致我们想要的结果。如果我们要对此做出基于人工智能的解决方案,我们将比 Gale-Shapeley 算法具有更高的时间复杂度,并且我们将无法保证产生最佳匹配(因为人工智能算法很少达到 100%的准确性)。在这里,非人工智能算法在所有指标上都击败了人工智能方法。

活动选择

另一个可以在医疗保健中派上用场的算法是活动选择算法。假设您负责一家诊所,并且必须安排医生,以便他们以最佳方式查看您已经在特定时间段预约的一组患者。这些时隙中的一些重叠。此外,就这个问题而言,这些患者对他们要看的医生没有偏好,但是他们的预约时间不同。因此,实际上,你有一个开始和结束时间的列表,并试图找出在给定的时间段内,你可以分配一个医生去看最大数量的病人的最佳方法。

在形式上,我们可以将患者的预约时间视为一组单独的项目。每个项目包含两个描述它的属性。在我们的例子中,这些属性是约会的开始时间和结束时间。实际上,我们的目标是找到最大的集合,使得集合中的所有单个项目在其开始和结束时间方面不重叠(因为违反该约束将意味着我们正在安排医生同时出现在两个地方,这是不可能的)。

那么我们如何解决这个问题呢?一个特别强力的解决方案是列出所有可能的集合,过滤掉不包含彼此兼容的预约的集合(即,它们重叠并导致医生被安排在一次两个地方),然后在这些集合中找到最大的集合。

但是让我们想想构建这样一个集合需要多长时间。嗯,这实际上相当于 2 个 n 个 ,其中 n 个表示约会集的大小。例如,如果我们的集合中只有三个约会(名为 a、b 和 c ),我们可以创建以下集合(其中{...}表示不同的集合):{}、{a}、{b}、{c}、{a,b}、{b,c}、{c,a}、{a,b,c} = 8 = 2 3 集合(注意,我们包括没有元素的集合,因为可以安排当天没有预约的医生)。这在形式上被称为动力集。

Side Note Proving a power set contains

2 n 元素。我们可以通过一个被称为归纳法的过程正式证明一组 n 元素构成一个 2n元素的幂集。归纳法背后的直觉是建立逻辑,即如果关于问题子集的一些假设被认为是正确的,那么,如果我们对问题的稍大的子集继续这种逻辑,并表明我们得到了预期的结果,假设通常会成立。形式上,我们可以对我们的情况做出归纳假设如下:“假设如果我们在一个集合中有 k 个元素(其中 k ≥ 1),那么在其幂集中将有 2 k 个元素。那么如果集合中有 k + 1 个元素,我们需要表明幂集合中会有 2 k + 1 个元素。我们可以这样来说明:在 k+1 的情况下,幂集的每个元素都有两个副本,一个包含k+1?? 第个元素,另一个是原始副本。这给了我们 2k+2k= 2∫2k= 2k+1个元素,表明当我们把问题做得稍微大一点时,我们的归纳假设成立(这正式称为归纳步骤)。当k=n-1 时,我们可以用我们的归纳假设说,包含 k + 1 = n 元素的集合将有 2k+1= 2(n-1)+1= 2n我还遗漏了一点归纳证明,称为“基本情况”,但这只是说明当 k = 0 时会发生什么,因为这些是特殊情况(我们必须确认定义适用于所谓的“空集”)。

因此,如果我们每次想要解决这个问题时都在构建一个幂集,那么我们实际上是在创建一个需要指数数量的运算(即,我们需要构建的集合的数量)才能开始解决的问题。正如我们之前所介绍的,对于少量的操作,这是可行的(例如,对于三个约会,我们只需要进行八组检查)。然而,对于一天大约 20 个病人,我们需要制作超过 100 万套(1,048,576)。最重要的是,我们需要检查每个集合,看看哪些元素是重叠的,哪些是不重叠的。

然而,我们可以对这个问题采取一种“贪婪”的心态,我们专注于做出许多局部最优的选择,以获得最终的全局最优解。我这么说是什么意思?嗯,在这种情况下,这意味着我们不必担心选择最佳的预约集,最大限度地增加医生看病人的数量。相反,我们只是在问题的各个阶段反复做出选择,而不考虑它们未来的后果。在我们的例子中,这个贪婪的解决方案会是什么样的呢?嗯,如果我们从一天的一组预约开始,一种方法可能是添加一天中预约最早结束的患者,并将其添加到我们要查看的不断增长的患者组中。要选择下一位患者,我们将只选择其预约时间段与第一位患者的预约时间段不重叠,但在其余患者中完成最早的下一位患者。如果我们继续这样做,我们可能最终构建我们的最优集合。

好吧,那么做这个需要多长时间?嗯,我们需要在所有的集合中搜索最早结束的约会。在最坏的情况下,该约会将位于列表的末尾,我们需要浏览 n 项来找到它。将该约会添加到我们的最终设置中,并检查它是否与其他约会冲突,只需要很少的时间。当我们选择新的潜在约会添加到我们的集合中时,我们然后搜索在我们刚刚安排的约会之后开始的下一个约会。这将需要我们查看不同的约会等等。总的来说,我们需要搜索大约$$ \frac{n\left(n-1\right)}{2} $$的约会,这很好,但可以使用一些改进。然而,如果我们最初按照完成时间升序对这些约会进行排序,我们可以加快在主集合中找到最早兼容约会的过程。与对集合进行排序相关的初始操作量有些高(实际上这大约是nlog(n)),但是每当 n 很大时,实际上需要的操作比以前少。这给了我们问题中的一点优化,但是我们如何证明我们的解决方案实际上是最优的,并得到我们真正想要的。毕竟,当我们想到“贪婪”的事情时,我们往往会想到许多最终导致糟糕结果的短视决策。在我们的案例中,我们可以证明贪婪有时候是好的。

我们可以通过证明我们的方法的两个性质来做到这一点:(1)贪婪选择性质,定义为通过进行局部最优贪婪选择可以找到全局最优解的事实,以及(2)最优子结构性质,定义为最优解由最优子部分组成的事实。让我们开始证明我们的方法满足这两个属性。

为了证明第一个性质,只要表明如果这个问题有一个最优解,它总是包含我们排序的预约集中的第一个病人(即,它从预约最早结束的病人开始),称为 p 就足够了。为了解释为什么,假设存在一些最优的约会集合,它们不是以约会 p 开始的(称这个集合为 B )和另一个以约会开始的集合( A )。我们可以证明,任何最优集合都可以被构造为从第一个约会开始,如下所示:删除非 p 约会,并用 p 替换它。我们可以执行这个操作,因为我们知道 p 不会与现在存在的 B 中的任何内容重叠。 AB 中的约会数是相同的,但是我们已经表明任何最优解都可以从贪婪选择开始。

很好,但是如果我们从贪婪的选择开始,那如何证明连续做出贪婪的选择会导致最优解呢?我们通过证明最优子结构来做到这一点。先来一个大概的猜想。如果集合 A 是整个约会集合的约会问题的解,那么从 A 中省略约会 p 的解(称这个集合A’)将是不包含 p 约会的约会问题的最优解。这个猜想基本上是说,如果我们有一个最优解,并且取一个问题的子集,这个子集的最优解将包含在全局最优解中。

我们如何证明这一点?我们可以使用一种叫做矛盾证明的技术。我们的目标将是证明一个陈述 S 是真的,通过某种方式显示 S 的对立面是不可能的,通过显示某个原理的内部矛盾(从而显示 S 为真是唯一的可能性)。在我们的例子中,我们的语句 S 是当我们的约会集合省略了 p 时的最优解。因此,与我们的陈述相反的是,有一些其他集合(称之为B’)包含比A’更多的元素。因此,如果我们将 p 添加回约会集合中进行选择,最佳解决方案应该包含该约会。如果我们将 p 加到B’上,并将这一组新的约会称为 B ,我们就构建了一组实际上比 A 更大的问题的最优解,我们之前假设这是最优解。我们得出了一个矛盾,它表明集合B’不可能存在,并且所构造的集合 A 确实是最优的,并且A’是不包含约定 p 的子问题的最优解。我们可以扩展这个逻辑,继续做出贪婪的选择来解决越来越小的子问题(这些子问题的解也是最优的),最终表明我们可以构建全局最优解。

嗯,这是很难理解的,但它确实表明了一些重要的想法。算法被证明是正确的,它们以确定性的方式运行,并且它们可以比暴力方法产生巨大的好处。当我们从人工智能的角度来考虑这个问题时,我们必须再次找到一些方法来训练人工智能模型提出这种逻辑(这已经很难做到了),即使这样,我们也不能特别保证它在所有情况下都有效(因为人工智能在很大程度上是一个黑箱:即,很难确定人工智能是如何提出它的解决方案的)。在我们的活动选择问题中,贪婪方法是最好的。

算法和其他算法的分析

早先的一些解释包括提到操作的次数,发生某事需要的时间等等。然而,计算机科学家很少关心与特定算法相关的单个运算的计数。相反,他们更感兴趣的是所执行操作的一般数量级(即 10 秒、100 秒、1000 秒、1000000 秒等。).但是并不是所有的操作都被认为是平等的。例如,赋值和跟踪 a 值的行为被认为几乎可以忽略不计。也没有考虑从系统中访问文件、下载信息、等待用户输入等需要多长时间。一个算法中唯一重要的部分(当进行算法分析时)是那些实际上会产生某种程度的成本的部分,也就是说,重复的操作,比如在一个数字列表中搜索值。此外,在算法分析中,我们不考虑一台计算机相对于另一台计算机的速度,而只是假设有一个“时间步长”的基本单位,它没有实际意义(但意味着允许算法之间的比较)。

因此,这个模型,正式称为 RAM(随机存取存储器)计算模型,基本上是假设你有一些理论上的计算机,并不真正关心现实生活中的约束。这种假设的优点是我们不必关心硬件的性能等。,在确定我们的算法有多好的时候。缺点是,我们分析我们的算法需要运行的时间步骤的数量并不等于现实世界的时间。

例如,我们正在查看用于确定某人是否肥胖的程序,该算法将使用一个时间步长来执行除法运算并将其设置为等于一个变量,一个时间步长用于评估每个比较运算,另一个时间步长用于输出结果。在最坏的情况下,这将导致我们进行五次比较(即,我们在进行最后一次比较之前评估所有先前的比较)。计算这个问题的所有时间步骤很容易,但是我们的活动选择问题呢?这就有点复杂了。但是我们可以做的是分析算法的伪代码(即,不是实际的代码,而是为了传达语义而编写的代码),并找到每一步所需的时间。

Activity Selection (set of appointments):
      S = Sort (set of appointments) by finish time

      Optimal Set = {First Element in S}

      For each element e in S after the 1st element
      do the following:
            If the start time of the eth appointment
            is after the most recently added element
            in Optimal Set:
                  Add the eth appointment to the
                  Optimal Set.
      Output the Optimal Set

有一点需要注意:伪代码的第一行是一个函数头。它指定了我们正在运行的函数的名称(在本例中为“activity selection”)和函数运行所需的一系列参数(在本例中为我们将要执行操作的约会集)。此外,这里的“=”符号并不意味着相等,而是意味着赋值(例如,如果我说“x = 5”,我将值“5”赋给变量“x”)。

让我们先来看看以“for”开头的部分。每个操作如下:比较检查和向集合中添加内容。这两个操作都需要一个时间步来完成。如果我们正在处理的活动的数量是 n ,那么最多需要 2 个 n 操作来遍历整个集合并构建最优集合(并且这是假设所有的约会都是间隔开的,使得没有一个约会彼此重叠,并且最优集合等同于原始的约会集合)。我们可以用“大 O”符号的形式来表达。我们将去掉这一项前面的常数,只是说程序的这一部分将花费 O ( n )时间来完成。形式上说 2n=O(n)是指存在一些常数 ck 使得对于所有nk0≤2ncn。在这种情况下,c 将是 2,k 将是 0。大 O 符号的另一个例子是说n2+n+1 =O(n2)。本质上,我们只关心算法运算量表达式中的最大值项。在n2+n+1 的例子中,对于一个足够大的 n ,n + 1 部分会有多大并不重要。算法的 n 2 项将始终是算法的运行时间(即,根据时间步长,算法运行大约需要多长时间)的最大贡献者。

所以我们的算法可以被认为是那部分的 O ( n )。但是另一个我们已经合并成一行的主要操作是什么呢?“排序”操作。它本身实际上包含了许多我已经折叠的其他不同的操作,但是这些操作在 O ( n log n )时间内运行。所以算法的总时间是O(n log n)+O(n)。但是该时间可以进一步简化为仅 O ( n log n );在 n 的大值下,与线性算法( O ( n log n ))部分相比,算法的线性部分的贡献不会花费很多时间。

可能对您有用的主题和算法类型如下:

  • 排序算法:顾名思义,这些算法负责帮助你找到以特定顺序对信息进行排序的最快方法。对于排序算法,我们可以获得一个理论上的“最佳”时间,那就是 O ( n log n ),但是这个时间限制只适用于基于比较的排序方法(这意味着我们只能基于一次比较两个对象来排序)。然而,非基于比较的排序算法(在数字数据的情况下,考虑诸如数字之类的事物的属性)可以线性时间运行(即,比基于比较的排序算法更快)。

  • 图算法:图算法关注的是试图找到在节点(把它们想象成项目)和边(把它们想象成项目之间的连接)上进行操作的方法。我们的稳定婚姻问题在某个方面是图算法问题的变体(更一般地,这被称为稳定匹配问题;节点=男女,婚姻=边连接)。在脸书上寻找共同的朋友是一个可以用图算法解决的问题。把自己想象成一个通过边与当前好友(即其他节点)相连的节点。共同的朋友也可以是通过边缘与你的朋友联系在一起的人。我们可以使用算法来尽可能高效地计算与寻找所有共同朋友相关的成本。

  • 动态编程(Dynamic Programming):这是一个更高级的话题,但实际上可以归结为通过重用你以前做过的计算来帮助你找到最优解,从而减少计算时间。一个例子是寻找第 n 个斐波那契数。一个解决方案是计算直到 n 的所有斐波纳契数;然而,我们会浪费大量的计算时间,因为斐波那契数的定义依赖于你知道前面的两个斐波那契数(并且计算它们需要更多的时间等等)。相反,我们可以将斐波那契数列的结果存储在第 n 个数列的下面,这样我们就不必重复计算了。对这个特殊的问题采用动态编程方法会产生一个 O ( n )运行时(比简单解决方案的O(2n)运行时好得多)。

  • 近似算法(Approximation Algorithms):虽然我们以前的算法一直致力于寻找我们遇到的所有问题的最佳解决方案,但这类算法试图找到“足够好”的解决方案放松对我们的解决方案的约束是有用的,特别是对于计算上难以处理的问题。重要的是,这类算法仍然试图给出解决方案在最坏情况下如何“偏离”的保证,这是有用的。

  • 字符串算法:主要关注对字母序列(有时是数字)的字符串执行操作。比如“word”是字符串,“ACTGA”也是字符串。生物信息学领域尤其涉及字符串算法,该算法可以根据物种 DNA 的相似程度来帮助确定物种的系统发育。这类算法中的一些算法与其他类有很多关联(例如,一种称为“最长公共子序列”算法的算法可以帮助找到两个遗传样本之间相似的 DNA 子序列)。

  • 数据结构:这个主题涉及在上述算法中构造和存储信息/数据,以优化搜索、插入、更新、编辑和删除时间等操作。例如,如果我们跟踪 1000 名患者,并希望能够搜索他们的各种特征,我们如何组织数据以实现快速搜索?我们可能希望按照特定的参数对我们的信息进行排序,但是排序后的数据结构如何工作,当我们添加新的患者时会发生什么(添加新信息后需要多长时间才能返回到排序后的状态)?通常提到的一些数据结构是树(其构造数据,使得树中的每个元素可以连接到某个“父”节点,并且具有一些“子”节点,类似于系统发育树)、散列表(其有助于将数据索引成查找时间是瞬时的形式,消除了在整个数据集中搜索特定值的需要), 堆栈(以特定方式添加信息,以根据构造优化最近添加的最多或最少元素的访问时间)和队列(元素以类似于线的有序方式相互“连接”)。

当你考虑潜在的人工智能解决方案来解决你面临的任何医疗保健问题时,前面所有的信息可能对你有用,也可能没用。然而,这本书的目的是给你一个领域和足够的术语知识,让你走上自己的学习之路。如果你对现有的算法有足够的了解,算法和数据结构的知识可以帮助你把看似困难或计算复杂的问题变得简单和快速。在考虑一个潜在的解决方案是否真的需要人工智能之前,想想你的问题陈述中的关键信息。然后确定该操作是否可以归结为一个算法问题(可以用确定性的方式解决),或者你是否需要一个人工智能来“学习”如何解决难以完全描述参数的特定问题。

结论

本章的主要目的是从计算的角度给你一些关于思考的角色的想法。提到的主题有些稀疏(可能很容易需要一整本书来完全覆盖),但遗憾的是,我们必须继续讨论一些概念,这些概念可以让你开始对前面提到的算法进行编程。在下一章中,我们将超越编写理论上的伪代码,实际上用一种叫做 Python 的计算机编码语言编写你的第一个程序。这个练习会给你学习编程的机会,这是你创建 AI 程序所需要的。

三、编程概述

本章的所有支持代码可在 https://github.com/Apress/Practical-AI-for-Healthcare-Professionals/tree/main/ch3 找到

既然我们已经介绍了计算机科学和算法的一些基础知识,是时候进入应用这些概念的本质了。就像天文学家使用望远镜来执行他们的任务一样,计算机科学家也使用编程来实现他们的算法和想法。关键是编程是一种工具,用于将过程步骤转换成可以使用的实际代码。因此,我们需要理解如何编写这些程序,以便我们可以从一个算法或(在接下来的章节中)一个人工智能程序中获得实际有用的输出。在这一章中,我们将编写一个程序来完成一个简单的任务,寻找一个二次方程的根。这项任务虽然与临床无关,但给了我们探索编程中几个概念的机会。首先,我们将回顾一下什么是程序。然后,我们将概述手头的任务。最后,我们将通过一些尝试来解决手头的任务,同时学习基本的 Python 语法和概念。

但首先,什么是程序?

程序本身只是文本文件。文本文件本身不做任何事情。相反,我们必须将这些文本文件输入到另一个程序中,该程序的工作是逐行解释这些文本文件,并根据该文本文件中的语法(命令的排列)产生有用的输出。

  • 边注:有时候,解释文本文件的程序实际上可能会做一些叫做编译的事情。一个编译程序(一个编译器)会把那个程序翻译成机器代码(也叫汇编语言)。程序的这种表示几乎相当于计算机处理器(CPU)用来执行程序指令(即步骤)的 1 和 0。编译的最大好处是它可以在几乎任何机器上运行,而且速度很快(因为程序已经是你的 CPU 可以理解的格式)。必须解释的程序要求程序的最终用户在他们的机器上安装解释程序。规则也有例外(例如,程序解释代码段,但编译经常使用的其他代码段以节省时间),但这是执行其他程序的程序的一般二分法。

但是如果程序是文本文件,我们如何告诉计算机具体做什么呢?很明显,你不能只输入“根据 Y 特征诊断这个病人患有 X”相反,程序必须以特定的格式编写(使用一些特殊的单词)才能产生预期的输出。有许多不同的方式来编写这些格式的指令,每种不同的方式被称为一种语言。有几种编程语言,比如 C、Python、R、Java 等等。它们中的每一个都是为了一个特定的目标而优化的:C 通常被认为是用来制作非常高效的程序。Python 用于科学计算。r 倾向于用于统计。Java 倾向于用来创建可以在任何操作系统上使用的应用程序(只要他们安装了 Java)。对于这本书,我们将学习如何使用 Python ,因为它被广泛用于涉及机器学习和人工智能的研究和科学计算任务。

Python 入门

为了编写 Python,除了文本编辑器(在 Mac 上,这是 TextEdit 在 Windows 上,这是记事本)。然而,为了执行(也称为“运行”)Python 程序,您需要在您的计算机上安装 Python 解释器。为了使事情变得简单(并帮助确保本书的所有读者都有类似的体验),我建议您使用 Google 的 Colab。要让它运行起来,进入 https://colab.research.google.com/ ,用你的谷歌账户登录。

然后进入“文件”菜单(在左上角),点击“新建笔记本”你应该会看到一个空白的屏幕,只有一个灰色的单元格,旁边有一个播放按钮(参见图 3-1 )。

img/502243_1_En_3_Fig1_HTML.jpg

图 3-1

这是一个空白的 Colab 笔记本应该有的样子

  • 旁注:在您阅读本书时,如果 Colab 不可用,您应该执行以下操作(注意:说明不能太具体,因为如何执行以下操作的标准会随着时间的推移而变化):1)为您的系统下载一个 Python 安装。2)在系统中创建一个文件夹,作为编写程序的地方。3)您需要学习如何在您的系统中使用命令行。查一下这方面的教程。对于 Windows,还建议您为 Linux 启用 Windows 子系统。4)完成后,打开终端/命令提示符,输入“cd ”,然后输入要编写程序的文件夹的路径。例如,如果你的桌面上有一个名为“MyPrograms”的文件夹,我会写cd ~/Desktop/MyPrograms。这里,~表示主目录的路径。5)在 Python 安装中,你应该安装了一个叫做“pip”的东西(这是一个包管理器,允许你下载其他人制作的程序)。为了验证您已经安装了这个,在您的命令提示符下,键入python -m pip --version,您应该得到类似于pip X.Y.Z的一些输出。6)在命令行中运行pip install notebook。注:最好按照 https://jupyter.org/install 中的说明来获取如何使用 pip 在您的计算机上安装 Jupyter notebook 的最新说明。7)运行jupyter notebook并打开网络浏览器。

这不是我之前提到的文本文件。更确切地说,它是一种被称为笔记本的快速原型制作工具。在这种设置下,您可以运行单行程序,而无需担心保存文件和从命令行运行。这对于创建有助于数据探索性分析的程序或创建一次性代码行来说是最理想的。

在第一个单元格(带有灰色块的部分)中,键入以下内容:

print("Hello world")

然后单击左边的播放按钮,或者按键盘上的Shift+Enter

您应该会看到文本“Hello world”被打印出来。恭喜你,你已经写出了你的第一个程序!

刚刚发生了什么?

print("Hello world")将执行 Python 标准库中存在的一个名为print的函数。什么是函数?函数是一组代码,它接受一个输入,通常产生一些输出(基于输入值)。安装 Python 语言时,会将几个简单的函数打包在一起(即标准库)。其他职能你将不得不自己定义。函数print接受一个输入(称为参数)。该输入正是您希望从print功能输出的文本。然后,Print 会将该文本(也称为“字符串”)复制到一个叫做“标准输出”的东西中标准输出将向执行程序的人显示该文本。现在理解标准输出并不是非常重要,但是要记住的概念是程序可以将结果直接输出到屏幕上。

再加一点

让我们尝试做一些稍微复杂一点的事情,比如求一个二次公式的根。作为学校的复习,我们知道如果一个方程的一般形式为ax2+bx+c,它将有两个解:

$$ \frac{-b\pm \sqrt{b²-4 ac}}{2a} $$

让我们先尝试一下如何在 Python 中为等式x28x+12 做这件事。也许我们可以像在计算器里一样输入东西。

在下一个单元格中键入以下内容(如果没有其他可用的单元格,请单击“+ Code”按钮):

(-(-8) + ((-8)² - (4*1*12))^(1/2))/(2*1)

然后单击运行。

您应该会看到以下内容

TypeError Traceback (most recent call last)
<ipython-input-4-f7fc0a4be28c> in <module>()
----> 1 (-(-8) + ((-8)² - (4*1*12))^(1/2))/(2*1)

TypeError: unsupported operand type(s) for ^: 'int' and 'float'

由于我们没有看到预期的输出,并且我们在输出中看到单词“Error ”,我们可以安全地假设我们做错了什么。转到输出的最后一行,我们看到有一个称为“类型错误”的错误,并且有一个不支持的“^".”操作数类型这是什么意思?

事实证明,在 Python 中,“^”并没有将某物提升到另一物的幂。相反,它实际上对克拉左边和右边的内容进行按位异或运算(不要担心这到底是什么)。

经过一番搜索,发现在 Python 中取某物的力量时,必须使用**。让我们再试一次:

输入

(-(-8) + ((-8)**2 - (4*1*12))**(1/2))/(2*1)

输出

6.0

太好了!为了得到另一个输出,让我们在单元格中键入等式的另一种形式:

输入

(-(-8) + ((-8)**2 - (4*1*12))**(1/2))/(2*1)
(-(-8) - ((-8)**2 - (4*1*12))**(1/2))/(2*1)

输出

2.0

嗯……那么为什么这次我们只看到一个输出呢?这只是 Python 笔记本的一个奇怪之处。它只会打印出一组命令的最后一行,除非您在那之前显式地打印出一个值。让我们把两个方程都放在一个“打印”函数中,就像这样:

输入

print((-(-8) + ((-8)**2 - (4*1*12))**(1/2))/(2*1))
print((-(-8) - ((-8)**2 - (4*1*12))**(1/2))/(2*1))

输出

6.0
2.0

太好了!我们得到了预期的输出,但是如果有人要求我们得到一个不同的二次方程的根呢?如果他们让我们求 100 个不同二次方程的根呢?好吧,我们可能运气不好,因为我们需要为每一个输入改变我们的程序,除非有其他的解决方案。

变量、方法/函数、字符串操作、应用的打印字符串插值

原来有这样的解决方法。我们可以将 a、b 和 c 的值存储到一个叫做变量的东西中。变量只是可以赋值的字母或单词。然后我们可以用这些变量来进行计算。

重新用变量来表达,它看起来会像下面这样:

输入

a = 1 # setting a equal to 1
b = -8 # setting b equal to -8
c = 12 # setting c equal to 12
print((-(b) + ((b)**2 - (4*a*c))**(1/2))/(2*a))
print((-(b) - ((b)**2 - (4*a*c))**(1/2))/(2*a))

输出

6.0
2.0

关于前面的例子,有几点需要注意。首先,具有# some text的行将不会被解释为超过#标记。它们被用来“注释”代码(例如,为以后阅读代码的人留下注释)。第二,当我们写a = 1时,我们设置变量a等于1的值。我们可以设置变量等于任何东西,甚至其他变量!

当我们重写表达式时,我们只需要重写表达式中的显式数字,并且可以通过它们的变量来引用它们。

此外,看起来我们的二次方程求解器的+/-部分可以稍微清理一下,以便不重复我们的代码(这是一个称为“重构”的过程)。我们可以将$$ \sqrt{b-4 ac} $$部分设置为等于另一个名为sqrt_part的变量。

输入

a = 1 # setting a equal to 1
b = -8 # setting b equal to -8
c = 12 # setting c equal to 12
sqrt_part = ((b)**2 - (4*a*c))**(1/2)
print((-(b) + sqrt_part)/(2*a))
print((-(b) - sqrt_part)/(2*a))

输出

6.0
2.0

很好,我们仍然得到相同的输出,并且我们已经对代码进行了一点清理(它看起来有点不像一堆变量和数字)。

  • 边注:变量(如asqrt_part)可以任意命名,但是如何命名有一些规则。通常,它们不能以数字开头,不能包含?#+-<spaces>'or ",并且它们通常不能是已经引用 Python 生态系统中的函数/语法的一组“保留字”(例如,“print”)的一部分。安全的做法是只使用字母和下划线_来命名变量,以使变量名对人类来说是可读的。

但是问题仍然存在,我们必须手动指定 a、b 和 c 是什么。我们可以通过将这个代码单元变成一个函数来帮助解决这个问题。该函数将接受包含我们要求解的二次公式的文本(也称为字符串),并输出两个解。

要定义一个函数(在 Python 中称为“方法”),我们将把代码包装在该函数中,并为该函数指定一个参数(即包含二次公式的字符串):

def root_finder(quadratic):
  a = 1 # setting a equal to 1
  b = -8 # setting b equal to -8
  c = 12 # setting c equal to 12
  sqrt_part = ((b)**2 - (4*a*c))**(1/2)
  print((-(b) + sqrt_part)/(2*a))
  print((-(b) - sqrt_part)/(2*a))

Note

在 Python 中,我们必须通过按“tab”字符来“缩进”函数的内部体。

我们可以通过在另一个单元格中写入root_finder("some text")来执行这个函数(即“调用”)。现在,它实际上不会解释我们输入的文本,但它会输出我们目前正在处理的上一个二次方程的结果。

实际上,从二次方程的文本到获得 a、b 和 c 的值,我们需要做一些假设,关于某人如何将值输入到我们的函数中。我们假设有人将二次项指定为"ax² + bx + c = 0"。我们可以做以下事情来获得我们想要的值:

  1. 将输入字符串“拆分”成三部分:一部分包含 ax²,另一部分包含 bx,最后一部分包含 c。如果我们可以删除等式中的“+”部分,这三部分大致可以拆分。

  2. 对于 ax² 部分,删除字符串中的“x²”部分,并将“a”部分转换为数字。对于 bx 部分,删除字符串中的“x”部分,并将其余部分转换为数字。对于字符串中的“c = 0”部分,去掉字符串中的“= 0”部分并转换其余部分。

下面是它在代码中的样子:

输入

def root_finder(quadratic):
  split_result = quadratic.split(" + ")
  print(split_result)
  a = int(split_result[0].replace('x²', ''))
  b = int(split_result[1].replace('x', ''))
  c = int(split_result[2].replace(' = 0', ''))
  print(f"a = {a}, b = {b}, c = {c}")
  sqrt_part = ((b)**2 - (4*a*c))**(1/2)
  pos_root = (-(b) + sqrt_part)/(2*a)
  neg_root = (-(b) - sqrt_part)/(2*a)
  print(f"Positive root = {pos_root}. Negative root = {neg_root}")

输出

怎么回事?为什么没有输出?我们在这里定义的是一个函数。我们实际上并没有调用这个函数。要解决这个问题,在单元格底部插入行root_finder("1x² + -8x + 12 = 0"),您应该会看到下面的输出:

输入

def root_finder(quadratic):
  split_result = quadratic.split(" + ")
  print(split_result)
  a = int(split_result[0].replace('x²', ''))
  b = int(split_result[1].replace('x', ''))
  c = int(split_result[2].replace(' = 0', ''))
  print(f"a = {a}, b = {b}, c = {c}")
  sqrt_part = ((b)**2 - (4*a*c))**(1/2)
  pos_root = (-(b) + sqrt_part)/(2*a)
  neg_root = (-(b) - sqrt_part)/(2*a)
  print(f"Positive root = {pos_root}. Negative root = {neg_root}")

root_finder("1x² + -8x + 12 = 0")

输出

['1x²', '-8x', '12 = 0']
a = 1, b = -8, c = 12
Positive root = 6.0\. Negative root = 2.0

好的,我们在最后看到我们的最终输出。现在让我们一行一行地深入我们的函数定义,这样我们可以看到发生了什么:

def root_finder(quadratic):

这一行表明我们正在命名一个名为root_finder的函数。它接受一个值(也称为参数),我们将把这个值赋给名为quadratic的变量。

split_result = quadratic.split(" + ")
print(split_result)

这两行将根据子串+的位置把我们的输入字符串分割成一个叫做list的东西。一个list仅仅是多个其他值的容器。然后,我们可以单独访问这些值来读取它们,甚至覆盖它们。.split(" + ")的语法有点奇怪。但是基本上,Python 中的所有字符串(quadratic即将成为)都有一个与之关联的方法,叫做split。它基本上就像一把剪刀,根据你输入的字符串(分隔符)把字符串剪成几部分。为了调用split方法,我们使用了.操作符,因为它是与一般类型变量相关的方法的一部分。还有其他方法,比如与字符串操作符相关联的replace(我们稍后会看到)。

之后,我们将调用split得到的值赋给变量split_result,然后打印split_result。第一个 print 调用输出为我们提供了第一行输出['1x²', '-8x', '12 = 0']。这意味着我们的split调用给了我们一个包含三个元素的列表'1x²''-8x''12 = 0'。注意,'表示该值是一个字符串(即文本、数字和其他字符的混合)。

接下来,我们有以下内容:

a = int(split_result[0].replace('x²', ''))
b = int(split_result[1].replace('x', ''))
c = int(split_result[2].replace(' = 0', ''))

让我们从第一行开始。我们将获取列表的第一个元素split_result(嗯,在 Python 中,列表的第一个元素实际上是列表的“第零”个元素),我们将用空字符串替换字符串。然后我们将调用int函数,无论这个函数调用产生了什么。那么所有这些让我们能做什么呢?让我们对 split_result 数组中的第一个值运行它。split_result[0]得到我们'1x²'。做'1x²'.replace('x²', '')让我们得到'1'。我们不能对一段文字进行数学运算。相反,我们需要从文本中获取号码。我们特别试图从文本中获取一个整数,所以我们调用int(split_result[0].replace('x²', ''))或者在我们的例子中等价地调用int('1'),来获取最后的a = 1

对于其他两个,我们遵循类似的模式:* int(split_result[1].replace('x', ''))意味着我们从int('-8x'.replace('x',''))int('-8')再到-8。* int(split_result[2].replace(' = 0', ''))意味着我们从int('12 = 0'.replace(' = 0',''))int('12')再到12

现在,a,b,c 都是我们想要的数字。但是为了确保万无一失,我们调用下面的函数:

print(f"a = {a}, b = {b}, c = {c}")

查看相关的输出,a = 1, b = -8, c = 12,我们可以看到它并不完全符合我们的预期(为什么我们不打印大括号呢?).原来,在您输入到print函数中的字符串前面添加f赋予了打印函数一些特殊的属性。实际上,会在作为参数传递给print的字符串中插入(即包含)变量(用花括号括起来)的值。结果,我们在输出中得到 a、b 和 c 的值。

我们函数的其余部分相对来说是相同的,直到如下:

sqrt_part = ((b)**2 - (4*a*c))**(1/2)
pos_root = (-(b) + sqrt_part)/(2*a)
neg_root = (-(b) - sqrt_part)/(2*a)
print(f"Positive root = {pos_root}. Negative root = {neg_root}")

这里我把正根函数和负根函数的值赋给变量pos_rootneg_root。我还在最后打印出这些值(使用我之前提到的字符串插值概念)。

最后一行是用参数"1x² + -8x + 12 = 0"调用我们的函数,其中"表示参数是一个字符串:

  • 边注:由于我们的函数是自带的,所以不需要在同一个笔记本单元格内调用。我们实际上可以从一个新的笔记本单元调用这个函数。唯一需要记住的是,如果我们改变了函数本身,我们需要重新运行包含函数定义的笔记本单元格。这将覆盖存储在笔记本存储器中的先前功能。如果您不重新运行单元格,您将运行旧版本的函数(这在过去导致了编程混乱)。为了安全起见,只需重新运行任何包含函数定义的单元格。
root_finder("1x² + -8x + 12 = 0")

小改进:If 语句

恭喜你,你已经写出了自己的二次规划求解器。但是有几件事我们应该注意。首先,我们需要确保我们的输入格式正确。对于“正确的格式”,我的意思是它应该包含至少两个“+”子字符串,并以“= 0”结尾。它还应该包含“x²”和“x”。信不信由你,我们可以在函数中测试这些东西。

输入

def root_finder(quadratic):
  if (quadratic.find("x² ") > -1 and quadratic.find("x ") > -1 and
      quadratic.find(" = 0") > -1):
    split_result = quadratic.split(" + ")
    if (len(split_result) == 3):
      print(split_result)
      a = int(split_result[0].replace('x²', ''))
      b = int(split_result[1].replace('x', ''))
      c = int(split_result[2].replace(' = 0', ''))
      print(f"a = {a}, b = {b}, c = {c}")
      sqrt_part = ((b)**2 - (4*a*c))**(1/2)
      pos_root = (-(b) + sqrt_part)/(2*a)
      neg_root = (-(b) - sqrt_part)/(2*a)
      print(f"Positive root = {pos_root}. Negative root = {neg_root}")
    else:
      print("Malformed input. Expected two ' + ' in string.")
  else:
    print("Malformed input. Expected x², x, and = 0 in string.")

root_finder("1x² + -8x + 12 = 0") # Expect to get out 6.0 and 2.0
print("SEPARATOR")
root_finder("1x² -8x + 12 = 0") # Expect Malformed input.

输出

['1x²', '-8x', '12 = 0']
a = 1, b = -8, c = 12
Positive root = 6.0\. Negative root = 2.0
SEPARATOR
Malformed input. Expected two ' + ' in string.

我们函数的主要变化是增加了if语句。If语句允许我们一般做以下事情:

if (this statement is true):
    execute this code
else:
    do something else

在我们的例子中,我们的第一个 if 语句如下:

  if (quadratic.find("x² ") > -1 and quadratic.find("x ") > -1 and
      quadratic.find(" = 0") > -1):

这里有几件事情需要讨论。首先,.find是 Python 中另一个与字符串相关联的方法。它将尝试查找作为参数传入的字符串,并返回该字符串的索引(即,您在调用它的字符串中查找的字符串的起始位置,从 0 开始编号)。如果它在你调用的字符串中没有找到你要找的字符串。找到 on,它将返回-1。实际上,这意味着我们将在输入(二次)中寻找字符串 x²,x 和= 0。

  • 旁注:如果我在第二次 find 调用中没有包含“x”后面的空格,那么这个函数在技术上是可以执行的,但是不会产生预期的行为。为什么?让我们看看输入:“1x² + -8x + 12 = 0”。如果我让 Python 查找“x”,它将返回 1,因为 x 第一次出现在第一个索引位置(人类术语中的第二个字母,回想一下 Python 从 0 开始编号)。显然,我们希望它是第九个索引(第十个字符)。我们可以通过在参数中包含额外的空格来解决这个问题,因为我们唯一一次看到“x”后面跟一个“,”是在-8 之后。

现在我们需要处理这条线上的and s。and是一个逻辑运算符,主要是询问左边和右边的语句是否为真。对于一个格式良好的输入,我们期望 find 语句quadratic.find("x² ") > -1quadratic.find("x ") > -1quadratic.find(" = 0") > -1都大于-1(即存在于我们的字符串中)并满足不等式(例如,x² 存在于索引 1 处,索引 1 为> -1,因此quadratic.find("x² ") > -1True)。如果前面的例子都为真,那么执行if下的代码块(比if多缩进一级)。如果不是,Python 将在if语句的同一层寻找一个关键字elseelif(也称为 else if:仅用于在进入 else 之前检查另一个条件)。要检查某个内容是否与另一个语句在同一级别,只需直观地查看它们是否彼此垂直对齐。如果是的话,他们在同一水平。

如果输入没有 x²、x 或= 0,那么我们执行 else,打印出"Malformed input. Expected x², x, and = 0 in string."

我们还看到另一个 if 语句:

if (len(split_result) == 3):

这实际上有助于检查我们在输入格式中是否看到两个“+”子字符串。为什么会这样?回想一下,split_result会产生一个列表,当它看到“+”时,这个列表会剪切掉一个字符串。在前面的例子中,我们展示了 split_result 将生成一个包含三个元素的列表。这意味着,如果我们有一个格式良好的输入,我们将会看到一个包含三个元素的列表。我们可以通过向我们的if语句传递len(split_result) == 3来检查这是否是真的。len是一个函数,它将查找传递给它的任何东西的长度(通常是一个列表或一个字符串)。==是等式逻辑运算符。它确定左侧是否等于右侧。

  • 边注:你会看到的其他常见的等式运算符有<(意思是左小于右)、<=(左小于等于右)、>(左大于右)、>=(左大于等于右)、==(左等于右)、!=(左不等于右)、in(左包含在右之内,只有在右是所谓的“字典”或列表时才使用)。

因为我们的正常输入将产生一个长度为 3 的split_result列表,所以我们期望等式检查在这种情况下通过。如果没有,我们转到与这个if同级的else,发现它会打印出“畸形输入”。字符串中应有两个“+”。

上次更改:

root_finder("1x² + -8x + 12 = 0") # Expect to get out 6.0 and 2.0
print("SEPARATOR")
root_finder("1x² -8x + 12 = 0") # Expect Malformed input.

在这里,我们调用root_finder两次。第一次我们期望得到输出 6 和 2。然后我们打印单词“SEPARATOR”(只是为了帮助直观地分隔输出),然后我们对没有正确格式的畸形输入调用root_finder(我们需要在 x²).后面看到一个“+”在我们的输出中,我们看到 if 语句失败了,并看到以下总体情况:

Positive root = 6.0\. Negative root = 2.0
SEPARATOR
Malformed input. Expected two ' + ' in string.

更多改进:文件输入和 For 循环/迭代

假设我们希望用户也能够提供一个. csv 文件(。csv 或 CSV =逗号分隔值文件,一种类似于 excel 表的格式),包含一个标题为“Formula”的列,然后阅读。csv,然后调用我们的函数。

首先,让我们用一些示例公式制作一个 csv:

| 公式 | | 1x² + -8x + 12 = 0 | | 2x² + -9x + 12 = 0 | | 3x² + -8x + 8 = 0 | | 4x² + -7x + 12 = 0 | | 5x² + -10x + 12 = 0 |

将此 csv 文件保存为“input.csv”文件(可以在 Excel 中完成。注意:确保您选择的类型是 CSV 文件)。

在 Colab 中,转到边栏,然后单击文件夹图标。单击上传图标,然后上传“input.csv”文件。您应该在文件菜单中看到以下内容(参见图 3-2 )。

img/502243_1_En_3_Fig2_HTML.jpg

图 3-2

这是 Colab 侧窗格中文件上传菜单的位置。在此上传您的 input.csv 文件

现在,我们需要以某种方式处理输入。csv 文件。

我们可以通过编辑我们的代码来做到这一点:

import csv

def root_finder(quadratic):
  # ...same as before

def read_file(filename):
  with open(filename) as csv_file:
    csv_data = csv.reader(csv_file)
    for idx, row in enumerate(csv_file):
      if (idx > 0):
        root_finder(row)

read_file("input.csv")

如果我们执行该命令,应该会看到以下输出:

['1x²', '-8x', '12 = 0\n']
a = 1, b = -8, c = 12
Positive root = 6.0\. Negative root = 2.0
['1x²', '-9x', '12 = 0\n']
a = 1, b = -9, c = 12
Positive root = 7.372281323269014\. Negative root = 1.6277186767309857
['1x²', '-8x', '8 = 0\n']
a = 1, b = -8, c = 8
Positive root = 6.82842712474619\. Negative root = 1.1715728752538097
['1x²', '-7x', '12 = 0\n']
a = 1, b = -7, c = 12
Positive root = 4.0\. Negative root = 3.0
['1x²', '-10x', '12 = 0']
a = 1, b = -10, c = 12
Positive root = 8.60555127546399\. Negative root = 1.3944487245360109

这似乎是预期的结果。但是,让我们更深入地看看我们刚刚做了什么。

在代码块的第一行,我们有一行写着import csv。这一行允许我们使用一些默认情况下 Python 不会加载的功能,但是这些功能包含在 Python 标准库中(不需要安装任何其他东西就可以使用的工具集合)。csv库允许我们读写 csv 文件,而不用担心与验证其格式和进行系统调用相关的复杂性。

接下来,我们继续学习新功能read_file。Read file 接受一个参数filename,它(正如我们在最后一行看到的)将是一个 csv 文件名的字符串。

Note

如果我们将这个文件放在一个子文件夹中,我们需要将这个参数指定为"SUBFOLDERNAME/CSVNAME.csv"

接下来,我们有这条线

with open(filename) as csv_file:
    csv_data = csv.reader(csv_file)

这个语句实际上打开了我们的 csv 文件,关键字with将确保 Python 在我们使用完它后删除它在内存中的位置(否则,它将永远存在,或者至少直到我们关闭这个笔记本)。然后我们请求csv库读取 csv 文件。它产生一个 CSV Reader对象(可以把它想象成一组打包成一个单词的函数和变量),这个对象被分配给变量csv_data

  • 旁注:对象在编程中无处不在。它们是一种构造,允许程序员轻松地调用和执行函数,并获得彼此相关的属性。为了了解物体是什么,我们必须了解它们是如何制造的。对象是通过其他叫做“类”的东西来制造的这些类指定组成对象的属性和方法。下面是一个示例类,它保存了患者的姓名、年龄、身高和体重,还计算了该患者的身体质量指数:

  • 我们定义了一个类(在本例中称为Patient),它具有属性 name、age、height 和 weight。名为__init__的方法负责处理我们用来使用类实例化(即创建)一个对象的值。在这里,我们所做的就是告诉 Python 跟踪我们提供的参数。我们通过给self分配属性来做到这一点。进一步分解这个语句,当我们写self.name = name时,我们告诉 Python“创建一个名为‘name’的属性,并将其设置为等于我传递给这个函数的参数名”。我们对所有其他属性也这样做(但是我们可以做一些有趣的事情,比如验证我们的输入值)。我们还在对象上创建了一个名为get_bmi的方法。默认情况下,所有打算访问与类相关联的值的方法都必须有一个名为self的参数。然后,我们可以在方法体本身中使用该对象的属性。这里,我们制作了一个get_bmi方法,它将返回一个病人的身体质量指数(基于他们的体重,可以通过 self.weight 访问,然后除以身高的平方)。当我们运行bob = Patient("bob", 24, 1.76, 63.5)时,我们创建了一个Patient类的实例(也称为,我们已经创建了一个 Patient 对象)并将其赋给了变量 bob。我们可以在我们创建的 bob 实例上调用get_bmi方法,只需键入一个.后跟方法名。我们还可以通过运行variable_of_the_instance.name_of_the_property来获得self对象的任何其他属性。在这种情况下,如果我们想要访问bob的高度,我们就像在这个代码示例的字符串插值语句中一样编写bob.height。我们可以对对象做许多其他的事情,但这仅仅是开始。

# Define the class Patient which has properties age, height, and weight
class Patient:
  def __init__(self, name, age, height, weight):
      self.name = name
      self.age = age
      self.height = height
      self.weight = weight
  def get_bmi(self):
      return self.weight / ((self.height)**2)

# Instantiate a patient object with specific age height and weight
bob = Patient("bob", 24, 1.76, 63.5)
# print out bob's BMI
print(bob.get_bmi()) # outputs: 20.499741735537192
print(f"Bob's height is {bob.height}m. His weight is {bob.weight}kg.")
# The above outputs: "Bob's height is 1.76m. His weight is 63.5kg."

接下来,我们有以下内容:

for idx, row in enumerate(csv_file):
  if (idx > 0):
    root_finder(row)

这个for语句是做什么的?考虑一下我们的 csv 文件的结构。有一个标题将出现在第一行(或者 Python 计数系统中的第零行)。那么接下来的每一行将包含我们想要计算的每一个二次型。如果我们知道如何让我们的 csv 文件等同于一个列表(就像我们前面看到的那样),我们就可以单独枚举我们想要运行 root_finder 的列表的索引。例如,假设我们的 csv 中的行都在一个名为quads的列表中。quads[0]会给我们我们的 csv 头(“公式”在这种情况下)。quads[1]会给我们1x² + -8x + 12 = 0quads[2]会给我们2x² + -9x + 12 = 0,以此类推。我们可以调用我们的 root finder 函数,只需将它们分别传递给如下方法:root_finder(quads[1])。然而,这是低效的,因为我们事先不知道 csv 文件中有多少行。相反,我们可以使用一个for循环。这使得我们可以说“对于列表中的每一项(或其他一些可以迭代的对象集合),执行以下操作。”在刚才提到的例子中,我们可以这样写

for quad in quads:
    root_finder(quad)

这个语句允许我们将列表中的每一项赋给临时变量quad。当我们依次遍历quads中的元素时(即遍历列表中的,我们将每个变量临时赋给quad,然后在 for 循环体中传递使用它(这里我们将quad作为参数传递给root_finder)。我们还可以通过在一个enumerate调用中包装我们的项目列表并重写我们的 for 循环来访问我们在列表中的元素号,如下所示:

for index, quad in enumerate(quads):
    if index > 0:
        root_finder(quad)

回想一下,quads的第一个元素只是单词“Formula ”,它不是root_finder的有效输入。因此,只有当索引为> 0 时,我们才运行root_finder(也就是说,我们不在第一个等于“公式”的第零个元素上运行它)。

我们基本上在原始代码中做了与枚举完全相同的事情。除了在这种情况下,我们实际上可以在包含 reader 对象的csv_file变量上调用enumerate。我们可以这样做,因为 reader 对象具有特定的实现属性,使其成为“可迭代的”(即,可以在其上使用 for 循环)。通过扩展,我们可以将其包装在一个enumerate调用中,并将循环序列中的索引值临时赋给idx,并将该行的值赋给row。当idx> 0时,我们只调用我们的root_finder函数。

最后一行只包含用input.csv文件名调用我们的read_file函数的read_file("input.csv")

文件输出、字典、列表操作

目前,我们将root_finder调用的结果输出到标准输出(即控制台)。如果我们可以将它输出到一个 csv 文件中,该文件有一个名为“方程”的列,包含原始方程,“正根”,另一个名为“负根”的列包含结果,那就太好了。每一行都对应于原始方程。

让我们看看这是如何写出来的:

import csv

def root_finder(quadratic):
  if (quadratic.find("x² ") > -1 and quadratic.find("x ") > -1 and
      quadratic.find(" = 0") > -1):
    split_result = quadratic.split(" + ")
    if (len(split_result) == 3):
      a = int(split_result[0].replace('x²', ''))
      b = int(split_result[1].replace('x', ''))
      c = int(split_result[2].replace(' = 0', ''))
      sqrt_part = ((b)**2 - (4*a*c))**(1/2)
      pos_root = (-(b) + sqrt_part)/(2*a)
      neg_root = (-(b) - sqrt_part)/(2*a)
      return (pos_root, neg_root)
    else:
      print("Malformed input. Expected two ' + ' in string.")
      return None
  else:
    print("Malformed input. Expected x², x, and = 0 in string.")
    return None

def read_write_file(input_filename, output_filename):
  answers = []
  with open(input_filename) as csv_file:
    csv_data = csv.reader(csv_file)
    for idx, row in enumerate(csv_file):
      if (idx > 0):
        answer = root_finder(row)
        if answer != None:
          positive_root, negative_root = answer
          answer_dict = {
              "equation": row,
              "positive root": positive_root,
              "negative root": negative_root,
          }
          answers.append(answers_dict)
  if len(answers) > 0:
    with open(output_filename, 'w') as csv_output_file:
      fieldnames = ["equation", "positive root", "negative root"]
      csv_writer = csv.DictWriter(csv_output_file, fieldnames=fieldnames)
      csv_writer.writeheader()
      for a in answers:
        csv_writer.writerow(a)

read_write_file("input.csv", "output.csv")

这很复杂,但让我们来分解一下变化是什么:

  1. root_finder中,我们现在已经删除了一些打印语句(保留了在输入错误时打印的语句)。我们添加了return语句。Return 语句允许我们在函数之间传递值,并将函数的输出赋给变量。print到目前为止,我们一直使用的语句不能让我们捕获输出并将其赋给一个变量。这里,我们以元组(包含两个值的数据结构)的形式返回pos_rootneg_root,或者返回值None,这是 Python 中的保留字,除了等于None的另一个值/变量之外,它不等于任何东西。我们这样做是为了检查输出是否有效(如果无效,输出将等于None)。

  2. 我们将read_file重命名为read_write_file,因为它现在包含了另一个功能(写文件)。论据已经改变;我们现在接受两个参数,输入文件名和输出文件名。

让我们更深入地研究一下read_write_file函数的主体。为了将结果写入 csv 文件,我们必须跟踪到目前为止我们已经积累的结果。我们将使用一个列表来做到这一点。

  • 旁注:列表是 Python 中用来保存数据的一种常见结构(也称为数据结构)。顾名思义,它们通常只是单个对象、变量或其他值的列表。列表可以赋给其他变量(就像 Python 中的其他东西一样),我们可以通过写list_variable[index]来访问列表的单个元素,其中list_variable是等于列表的变量,index是想要访问的元素的编号。注意列表总是从 0 开始编号,所以如果你想得到列表的第一个元素,你应该写list_variable[0]。如果你想得到一个列表的最后一个元素呢?你可能需要知道列表本身的长度。这可以通过将你的list_variable封装在一个len()调用中来访问,就像这样len(list_variable)。为了得到列表的最后一个元素,我们将做list_variable[len(list_variable)-1](我们必须在末尾使用-1,因为我们从 0 开始编号)。前面例子的一个简写就是做list_variable[-1]。如果您试图访问一个不存在的列表元素(例如list_variable[len(list_variable)]),您可能会得到一个类似于IndexError的错误。这通常意味着你试图访问一个不存在的元素,你应该回到你的代码,并确保你从 0 开始计数。我们只需输入list_variable = ['element 1', 'element 2']就可以创建一个新的列表,但是如果我们想在最初创建列表后添加更多的元素呢?嗯,我们需要做的就是调用list_variable.append(something)。这将在我们的列表末尾添加一个新元素(相当于something)。我们可以通过做list_variable.find(value_of_element_you_want_to_find)在列表中找到元素。最后,您可以通过执行list_variable.remove(value of element to remove)从列表中删除一个元素。

列表中的每个元素都必须以某种方式包含原始方程、正根和负根。这些值可以打包在一个名为Dictionary的结构中。字典允许我们在一个包含的语句中指定一组“键”和“值”。“键”是我们用来查找相关“值”的引用名例如,我们可以做出如下判断:

bob = {
    "name": "Bob Jones",
    "height": 1.76,
    "weight": 67.0
}

然后通过写variable name['key name we want']来访问属性。注意:在下面的代码示例中,我将输出的内容写成注释(即跟在#符号后面的单词):

print(bob['name']) # Bob Jones
print(bob['height']) # 1.76
print(bob['weight']) # 67.0

我们也可以编辑字典如下:

bob['gender'] = 'Male' # adds a key "gender" and set it equal to "Male"
bob['height'] = 1.75 # edits the current value of height from 1.76 to 1.75
print(bob) # {'name': 'Bob Jones', 'height': 1.75, 'weight': 67.0, 'gender': 'Male'}

在这种情况下,我们希望以某种方式将来自root_finder函数的每个结果写入一个输出 csv 文件。我们将输出的列,一个正根、一个负根和原始方程,对应于我们将在中使用的键,用从root_finder函数的结果(对于正/负根)或原始输入本身(方程)获得的相应值来创建一个字典。因此,在我们的read_write_file方法中,我们有如下内容(阅读每一行代码上面的注释来理解程序的流程):

def read_write_file(input_filename, output_filename):
  # Initialize an empty list called "answers" which we will put results into
  answers = []
  # ...then open the csv file
  with open(input_filename) as csv_file:
    # ...then create a CSV reader object to read the CSV row by row
    csv_data = csv.reader(csv_file)
    # ...then iterate through the csv file by row
    for idx, row in enumerate(csv_file):
      # ...after the header row (in the header row idx = 0, we don't want
      # to input that in the root_finder function, so we only look for
      # rows after the header where idx > 0).
      if (idx > 0):
        # ...get the result of `root_finder` called on that equation
        answer = root_finder(row)
        # ...if the input was valid (and we have an answer)
        if answer != None:
          # ...then get the positive and negative root of that answer
          positive_root, negative_root = answer
          # ...then create a dictionary with keys equal to the columns we
          # will report
          answer_dict = {
              "equation": row,
              "positive root": positive_root,
              "negative root": negative_root,
          }
          # ...and lastly append that dictionary to the answers list
          answers.append(answers_dict)
  print(answers) # this is new, but allows us to see what's in the answers list

请注意,前面代码片段的最后一行是新的,但是如果您使用输入的新行运行函数,您应该会得到类似如下的输出:

[
{'equation': '1x² + -8x + 12 = 0\n', 'positive root': 6.0, 'negative root': 2.0},
{'equation': '1x² + -9x + 12 = 0\n', 'positive root': 7.372281323269014, 'negative root': 1.6277186767309857},
{'equation': '1x² + -8x + 8 = 0\n', 'positive root': 6.82842712474619, 'negative root': 1.1715728752538097},
{'equation': '1x² + -7x + 12 = 0\n', 'positive root': 4.0, 'negative root': 3.0},
{'equation': '1x² + -10x + 12 = 0', 'positive root': 8.60555127546399, 'negative root': 1.3944487245360109}
]

它可能会出现在一行中,但是不管怎样,您应该会看到五对左右括号({}),表明我们有一个包含五个元素的列表。还有一点需要注意:在这个输出中,我们看到了字符\n。这是一个特殊的字符集,用于记录换行符(例如,某人按下键盘上的“enter”键,下一个内容应该在单独的一行打印出来)。因为我们正在打印一个数组,Python 忽略了为这些\n字符创建一个新行,但是在任何正常情况下(例如,如果你正在打印一个常规字符串,比如print("Hello\nWorld"),你会在一行上看到\n之前的字母,在另一行上看到\n之后的字母。还有以\开头的其他字符可以表示其他特殊的打印行为(例如,\t表示制表符)。

接下来,我们继续将列表写入文件。事实证明,Python 有一种简便的方法将字典列表写入 csv 文件,只要所有的字典都有相同的键集。我们确实满足这个条件,因为我们所有的字典都有一个equationpositive rootnegative root键。

  # if the answers list is not empty (i.e. we have at least one result)
  if len(answers) > 0:
    # write to the output file we specify
    with open(output_filename, 'w') as csv_output_file:
      # set fieldnames (these will be the columns) equal to the keys of
      # our dictionary.
      fieldnames = ["equation", "positive root", "negative root"]
      # initialize a CSV writer object
      csv_writer = csv.DictWriter(csv_output_file, fieldnames=fieldnames)
      # write the column headers
      csv_writer.writeheader()
      # for each answer (temporarily referred to as 'a') in the answers list
      for a in answers:
        # write a new csv row
        csv_writer.writerow(a)

这段代码看起来对阅读 csv 文件比较熟悉。唯一不同的是,在我们的open语句中,我们必须通过传入第二个参数'w'来指定我们正在写入一个 CSV 文件。此外,由于我们正在编写一个 csv 文件,我们还需要指定我们将在 CSV 中编写的字段名称(也称为列),并且首先编写列名称(我们用csv_writer.writeheader()来做)。

用熊猫来砍伐

有点坏消息。我们刚刚花了一节时间做的事情可以用大约 6 行代码来完成:

输入

import pandas as pd

def read_write_file_with_pandas(input_filename, output_filename):
  df = pd.read_csv(input_filename)
  results = df['Formula'].apply(root_finder)
  results = results.dropna()
  if (len(results) > 0):
    df[['positive root', 'negative root']] = results.tolist()
    df.to_csv(output_filename)
    display(df)
  else:
    print("No valid results")

read_write_file_with_pandas('input.csv', 'output.csv')

输出

Formula               positive root  negative root
1x² + -8x + 12 = 0   6.000000       2.000000
1x² + -9x + 12 = 0   7.372281       1.627719
1x² + -8x + 8 = 0    6.828427       1.171573
1x² + -7x + 12 = 0   4.000000       3.000000
1x² + -10x + 12 = 0  8.605551       1.394449

通过导入 Python 标准库系统之外的库,我们可以大幅减少必须编写的代码。其中一个库叫做“pandas”,它非常擅长操作信息数据集(尤其是 csv 数据)和输出结果。

在第一行中,我们将导入pandas库,并将其所有功能分配给变量pd,如下所示:

import pandas as pd

接下来,我们将定义另一个读和写函数,它接受与前面的read_and_write_file函数相同的参数。

然后我们将使用 pandas read_csv函数读入包含输入公式的 CSV 文件。我们可以使用pd.read_csv来访问read_csv函数(注意,我们在特定于库的方法前添加了库的名称或我们分配给该库的变量,在本例中为pd)。我们将想要读取的文件名传递给pd.read_csv,并将结果存储在一个名为df的变量中。

pd.read_csv产生一种称为数据帧的数据结构。它基本上是 Python 内存中的一个 excel 表,如果你在函数体中写print(df)display(df),你会看到变量包含一个名为Formula的单个列的表,就像我们的 CSV 输入表包含的一样。

接下来,我们将通过调用df['Formula'].apply(root_finder)在每行上运行我们的root_finder函数。df['Formula']获取数据框中名为“公式”的列(这是我们唯一的列)。然后,df['Formula']列上的.apply方法将调用传递给它在df['Formula']列的每一行上的单个参数的函数。在本例中,我们调用了.apply(root_finder),,这意味着我们对公式列中的每一行运行root_finder。然后,我们将这些值存储到results变量中。

回想一下,我们的root_finder函数输出一个包含正负根或值None的元组(包含两个值的数据结构)。我们将首先通过调用results.dropna()删除任何None值。这将删除(也称为“丢弃”)任何无效的数字。我们将那个调用的结果赋回给变量results,这样我们就可以继续操作那个变量(但是我们也可以创建一个新的变量名)。

如果results变量的长度大于 0(即,我们有有效的结果),那么我们将实际上把每个元组中的值解包到相应的列positive rootnegative root。默认情况下,Pandas 没有简单的方法将一个元组分成不同的列。相反,我们必须将我们拥有的results变量转换成一个list(使用.tolist()),然后将结果列表分配给列名。Pandas 会自动理解包含多个元素的列表应该被分成多个列。我们需要做的就是像这样指定这些列的名称。

# We create two columns 'positive root' and 'negative root' that
# are equal to the result from `results.tolist()`
df[['positive root', 'negative root']] = results.tolist()

最后,我们使用df.to_csv(output_filename)将 csv 文件写入我们指定为输出 CSV 文件名的文件名。然后我们使用display(df)向用户显示我们到目前为止处理过的数据帧。

展示前面的例子的目的是让您了解库可能有助于减少您需要编写的代码。pandas 库的作者已经对如何从 CSV 文件中读取和操作数据投入了大量的思考,并且可能投入了比我们在本章中所能做的更多的错误检查。因此,只要您理解库在幕后大致做些什么,尽可能使用库是明智的。

  • 附注:当我们将熊猫导入 Python 脚本时,我们使用了import pandas as pd。我们不需要在我们的系统上安装熊猫。然而,这只是在我们工作的 Colab 环境中的情况。通常,如果你直接在电脑上用 Python 开发(而不是像我们现在这样通过云接口),你将不得不自己安装一个库。在这种情况下,您将需要使用一个包管理器比如pip来帮助您安装您需要使用的库。如果我们要在我们的本地系统上安装 pandas,我们将在我们的终端中写:pip install pandas,就是这样!

摘要

在这一章中,我们通过一个例子,我们创建了一个程序来解决一个二次公式,甚至处理文件输入。我们看到了如何使用变量、for 循环、if 语句、文件输入和输出、字典、对象和列表来帮助简化这项任务。Python 中的这些编程语言构造让我们从硬编码单个值以用于二次方程解算器,到允许 Python 处理所有实际操作数字的繁重工作。

随着我们在本书中的深入,这些单独的概念中的每一个都将对您理解和使用机器学习算法变得至关重要。文件输入/输出将帮助您将大型数据集加载到 Python 中。字典、对象和列表将帮助您以逻辑排列的方式存储和操作所有信息。For 循环和变量将一直用于存储信息和迭代处理数据集。

在下一章,我们将从“鸟瞰”的角度介绍机器学习的概念后面一章编程不会太多,但后面肯定会更多。

四、机器学习算法简介

既然我们已经讲述了如何编程的基础知识和 Python 中的一些数据结构,让我们把注意力转回到第一章中提到的机器学习(ML)的理论和概念上。这一次,我们将开始讨论这些算法的细节,即,关注它们的输入和输出是什么以及它们是如何工作的(在高层次上;比这更低的数字涉及到大量的数学知识,不值得在入门书籍中讨论。

ML 算法基础

ML 算法通常经历一个“训练”的过程,之后是一个被称为“测试”的评估期。大多数算法的训练过程大致遵循这种粗略的模式:(1)使算法看到数据的子集。(2)对我们希望从该数据中预测的事情进行初步猜测(例如,预测图像中存在何种类型的癌症,预测个体是否有患糖尿病的风险)。(3)看看这个猜测有多错误(如果我们有“基本事实”数据)。(4)调整预测的内部结构,希望朝着减少误差的方向。(5)重复(通常直到在我们暴露算法的数据集上结果没有显著改善)。

训练过程本身可以运行多次,因为 ML 算法将在您提供给它的称为“训练数据”的数据子集上运行一旦算法被适当地训练,它就在训练过程中从未暴露给 ML 算法的一组数据(称为“测试数据”)上被评估。为什么我们关心确保算法以前从未看到过数据?嗯,我们希望确保算法实际上正在学习一些关于数据结构的知识,而不仅仅是记忆单个数据点(即“过拟合”)。通过对未用于训练数据的维持集进行测试,我们可以评估该算法,并了解它在现实世界中的表现。

  • 边注:在 ML 算法的训练步骤中,我们可能还想测试我们选择的 ML 算法的不同配置,甚至比较多个 ML 算法。一种方法是根据我们的数据子集进行训练,然后使用剩余的数据进行评估。然而,这会导致测试数据的意外过度拟合,因为您将调整 ML 算法参数以在测试集上执行良好。最终,我们应该对测试集完全视而不见,直到我们准备好在开发的最后一步评估它的性能。

  • 因此,如果我们在尝试不同的参数/ML 模型时不应该在测试集上进行评估,我们如何比较性能呢?我们可以把训练集分成训练集和验证集。在这种情况下,我们将只使用验证集来评估我们跨多个 ML 配置/模型的训练网络,挑选出最佳的一个,然后在测试集上测试它,作为最后一步。通常,您会看到 60%的训练、20%的验证和 20%的底层数据测试分割。这意味着 60%的数据用于训练 ML 算法,20%用于验证/比较配置/多个 ML 模型,最后 20%的数据用于测试验证后选择的 ML 算法。您也可以用其他方式分割您的数据(例如,80%训练,10%验证,10%测试),但是您应该确保您有足够的测试数据来准确了解您的算法在“真实世界”条件下的表现(例如,只测试两三个数据点没有多大用处,因为完全靠运气解决问题的可能性相对较高)。

ML 算法通常分为两大类(技术上还有第三类,但我们将在本书中跳过这一类):“监督学习”算法和“非监督学习”算法。

监督学习算法要求我们在数据上有“标签”。我所说的“标签”是指数据具有某种结果,这种结果可以是分类或连续的度量(例如,心脏病状态、预测的生存概率)。我们的数据通常具有与最终标签本身相关的多个特征(预测因素)。因此,当我们评估这些监督算法的表现时,我们将 ML 算法作为给定基准点的标签输出的内容与该点的实际标签进行比较。然后我们可以报告准确性、敏感性、特异性、ROC 等。这些算法一旦在测试集上评估。

无监督学习算法在没有标签的数据上运行。相反,他们试图优化另一个指标。例如,无监督聚类算法可能专注于尝试找到彼此密切相关的数据聚类。在这种情况下,优化的结果是数据点在一个聚类内相对于在聚类之间彼此有多紧密相关(例如,数据点之间的距离)(我们希望数据在一个聚类内彼此紧密相关,而与其他聚类中的数据不同)。在这种情况下,算法将试图优化如何将数据点分配给聚类。这里,除了描述数据点的特征之外,我们不需要任何关于数据本身的信息(即,我们不需要任何与最终诊断有关的信息,等等)。).无监督学习任务的输出在探索性数据分析中非常有用。例如,在遗传学研究中,无监督学习算法可用于根据基因表达水平区分样本。样品之间的最终分离可以产生对样品之间差异的洞察,从而产生进一步研究的区域。

以下是对机器学习领域中各个算法的一组总结。注意:这个列表并不意味着详尽无遗。更确切地说,它旨在给出现有的不同类型的 ML 算法以及它们如何工作的粗略理解。除非绝对必要,否则在这些解释中将使用非常少的数学(所以这不会非常严格)。

回归

这类最大似然算法处理从数据点到连续(即数值)值的尝试。人们学习的第一个回归算法可能是线性回归,它专注于寻找一条“最佳拟合线”,该线穿过具有 X 和 Y 方向的二维散点图上的数据点。但是,请注意,这些回归技术中的任何一种都可以处理多维数据。就 ML 而言,我们不试图可视化这些多维数据,因为我们的数据可能经常是“宽的”(即,每个基准点有多个预测值)。相反,我们将专注于尝试找到最佳的线(对于二维数据)、平面(对于三维数据)或超平面(对于 N 维数据,其中 N 是任何数字)来拟合我们的数据点。我们将从线性回归开始探索回归技术。从那里,我们将继续进行逻辑回归,它可以帮助预测结果的概率(在 0 和 1 之间)。最后,我们将讨论用于回归的套索和弹性网(这些算法有助于确保我们不会在模型中包含太多变量)。注意,我多次提到“模型”这个词,它的定义如下:模型只是一个可以被训练或评估的 ML 算法的实例。

线性回归(用于分类任务)

线性回归有许多不同的风格。也许最有用的是普通最小二乘法(或最小二乘法线性回归)。该算法的工作原理是试图最小化“残差平方和”(SSR)。那是什么?嗯,这是一个衡量我们提出的线性回归方程有多“差”的指标。我们举个简单的例子。

想象一下,我们有一些服用了减肥药的病人,我们想知道他们在某段时间内服用该药后减了多少磅。在这种情况下,我们希望预测的结果是体重减轻,输入是患者的特征,例如他们的起始体重、年龄、糖尿病状况、身体质量指数,以及他们在任何给定的一周内锻炼了多少分钟。

在线性回归算法运行后,该算法为我们提供了患者体重下降量的预测值。这应该试图代表趋势,同时也尽量减少个别数据点的“错误”程度。为了做到这一点,该算法被设置为最小化“残差”,即,该算法对体重减轻的预测与该患者的实际体重减轻相比有多远。然后对残差求平方,因为一些预测值将低于实际值,一些将高于实际值(我们对这些残差求平方以确保值不会相互抵消)。形式上,对于每个病人 p ,我们找到

$$ \mathrm{residual}={\left(\mathrm{predicted}\ \mathrm{weight}\ \mathrm{lost}\ \mathrm{for}\ \mathrm{p}-\mathrm{actual}\ \mathrm{weight}\ \mathrm{lost}\ \mathrm{for}\ \mathrm{p}\right)}² $$

然后,它将所有这些残差值相加,得出一个称为残差平方和的值。这是普通最小二乘回归试图最小化的量。一些微积分实际上显示了使用这种方法的线性回归有一个“封闭形式”的解决方案(即,它可以在一个步骤中运行)。相应地,该算法找到适当的斜率和截距。在这一点上,模型完成了训练,我们有了一个通用方程,可以预测一个人在服用减肥药物后体重减轻了多少。然后,我们需要确定该算法在真实世界数据上的效果如何(在我们的测试集中)。

该算法的输出非常容易理解。在大多数 Python 库中(甚至在 Excel 中),您可以获得对回归有贡献的每个变量的斜率列表以及截距,从而得到一个看起来像

$$ \mathrm{weight}\ \mathrm{lost}=\mathrm{intercept}+{\beta}_1\left(\mathrm{starting}\ \mathrm{weight}\right)+{\beta}_2\left(\mathrm{diabetes}\right)+{\beta}_3(BMI)+{\beta}_4\left(\mathrm{exercise}\right) $$

的方程

其中βn代表一个斜率。如果我们试图将这种线性回归的结果绘制成图形,我们可能会运气不好,因为有五个维度(四个预测维度+一个输出维度),并且很难在计算机上描绘三维图形以上的任何东西。相反,我们可以看看βn(β)的绝对值,看看哪些是最大的。由此,我们可以做出合理的假设,即最大的贝塔系数对结果的贡献最大。在各种流行病学研究中,线性回归被广泛使用,尤其是在给定一些个体数据的情况下试图量化结果的有效性(例如,健康运动对减肥的影响)时。

逻辑回归

逻辑回归类似于线性回归,因为它可以接受多个可能的预测值,并找到斜率/截距,使直线、斜率或超平面最佳拟合。但是,线性回归和逻辑回归之间的主要区别在于来自这些函数的输出值的范围。线性回归输出范围从负无穷大到正无穷大的值。但是,逻辑回归只能输出从 0 到 1 的值。考虑到逻辑回归的有限输出范围,它非常适合涉及可能性/概率预测的应用,并且可以帮助预测二元结果(例如,疾病对非疾病),因为疾病状态可以编码为 1,而非疾病状态可以编码为 0。在医学应用中,逻辑回归模型通常应用于病例对照研究,因为β一旦指数化,就可以解释为优势比(见边注),从而产生很大程度的可解释性。

我们试图拟合的逻辑回归方程如下:

$$ y=\frac{e^{\beta_0+{\beta}_1\ast X+{\beta}_2\ast X\dots }}{1+{e}^{\beta_0+{\beta}_1\ast X+{\beta}_2\ast X\dots }} $$

进一步简化

$$ \mathsf{\ln}\left(y/\left(1-y\right)\right)={\beta}_0+{\beta}_1\ast X+{\beta}_2\ast X\dots $$

该等式的左侧相当于我们通常认为的某件事“可能性”的自然对数(即某件事发生的概率 y 除以某件事不发生的概率 1-y)。因此,我们可以插入 X (我们对个体的预测)的值,并找到他们发生事件 y 的几率。然而,在大多数情况下(如病例对照研究),我们不能单独报告几率(因为病例对照研究有预设的疾病规模,即“病例”人群和正常人群,即“对照”人群)。相反,我们可以取两个独立个体的预测比值,并确定比值比。

例如,如果我们在给定某人先前吸烟史的情况下预测患肺癌的概率,我们可以在病例对照研究中对个体拟合逻辑回归方程。在这种情况下,我们只有两个β,截距(β0 和β1(用于指示先前的吸烟史)。假设 β 0 等于 1β1= 3.27。如果我们想比较吸烟者和不吸烟者患肺癌的几率,我们会计算$$ \frac{e^{1+3.27\ast 1}}{e^{1+3.27\ast 0}}=26.31 $$。我们可以说,有吸烟史的人患肺癌的几率是没有吸烟史的人的 26.31 倍。注意,我在某人有吸烟史的情况下代入 X = 1,如果没有,则代入 X = 0(这里 X 作为指示变量)。

img/502243_1_En_4_Fig1_HTML.jpg

图 4-1

接收操作特性(ROC)曲线示例

  • 附注:逻辑回归方程的输出值也可以被认为是预测概率(即,一个从 0 到 1 的值,表示某件事情发生的可能性,其中 1 =会发生,0 =不会发生)。当逻辑回归连续输出这些值时,我们可以建立一个“阈值”值,将连续预测转化为二元结果(小于阈值或大于阈值)。这在诸如预测疾病结果的任务中是有用的。然而,阈值的值是由程序员决定的。一个容易选择的阈值可以是 0.5;然而,另一个阈值如 0.7 可能会更好(可能有助于我们消除任何“假阳性”预测,代价是做出一些假阴性预测)。假阳性(FP)预测表明,输出预测患者是阳性病例,而实际上不是(这导致更高的医疗保健支出和不必要的治疗)。另一方面,假阴性是指预测患者没有感兴趣的结果,但实际上有(这导致误诊,如果病情危急,这可能是有害的)。我们还关心当机器预测与患者的真实状态匹配时出现的真阳性和真阴性(即,它们分别实际上是病例或实际上不是病例)。我们可以使用真阳性、真阴性、假阳性和假阴性来生成假阳性率/敏感性和特异性。

  • 为了查看我们的逻辑回归模型在多种情况下的表现,我们可以生成一条“ROC 曲线”ROC(受试者-操作者特征)曲线来自于在多个阈值下找到真阳性率和假阳性率。然后,我们将这些数据点(x =假阳性率或灵敏度,y =真阳性率或 1-特异性)绘制在图表上,并将这些点连接起来,生成如下所示的曲线(ROC 曲线参见图 4-1 ,灵敏度和特异性的定义参见下文)

  • 其中点 A、B 和 C 是从不同阈值产生的敏感/特异性对。然后,我们可以计算曲线下面积(AUC ),这可以让我们更好地了解如何比较不同的分类器(AUC 值范围从 0 到 1,其中 1 =完美的预测值,0 =比随机差,AUC 通常越高越好)。

  • 灵敏度也称为“召回”,可通过以下公式计算:

    $$ \mathrm{Sensitivity}=\frac{#\mathrm{of}\ \mathrm{true}\ \mathrm{positives}}{#\mathrm{of}\ \mathrm{positives}+\mathrm{false}\ \mathrm{negatives}} $$

  • 特异性可以如下计算:

    $$ \mathrm{Specificity}=\frac{#\mathrm{of}\ \mathrm{true}\ \mathrm{negatives}}{#\mathrm{of}\ \mathrm{true}\ \mathrm{negatives}+#\mathrm{of}\ \mathrm{false}\ \mathrm{positives}} $$

  • )医学研究者通常在 ROC 曲线中产生最高灵敏度和特异性的阈值处分别报告灵敏度和特异性(除了 ROC 曲线之外)。

为了实际拟合逻辑回归方程,我们可以使用一种称为最大似然估计(MLE)的方法。MLE 以与普通最小二乘回归相似的方式运行(即,它试图最小化依赖于最小化它的“错误”程度的某个函数);然而,它没有封闭形式的解决方案。相反,它必须通过尝试多个贝塔来试图找到适合该等式的最佳贝塔集,查看哪些贝塔最小化其“错误”程度,然后相应地调整这些贝塔以进一步最小化误差。

逻辑回归可能是医疗保健领域中最有用和最易解释的回归形式。虽然它不被认为是机器学习领域的时髦词汇,但它是一种经过尝试和测试的方法,用于处理涉及特定结果概率的预测。

套索、脊和弹性网回归,偏差-方差权衡

有时,我们会遇到这样的情况,我们的数据集中有太多的预测因素,尝试减少或最小化预测因素的数量可能会对我们有益。这样做的主要好处是通过将 100 多个独立变量提取为更易于管理的变量,如几十个,来帮助最终模型本身的可解释性。我们可以通过两种方法做到这一点,套索或岭回归(弹性网是两者的结合)。

在我们讨论这些算法之前,我们需要讨论机器学习中的偏差-方差权衡。当我们训练模型时,我们可以优先尝试确保我们的最佳拟合线接触我们训练数据中的所有数据点。虽然这将最小化我们的训练数据集中的所有错误,但是我们可以在我们的测试数据集上看到巨大的性能差异,因为测试数据不一定等同于我们的训练数据。在这种情况下,我们说一个模型具有很高的方差,因为它的结果根据用来评估其性能的数据而变化很大。我们还可以说,该模型具有较低的“偏差”,这意味着最佳拟合线不会对数据的底层结构做出任何假设,因为它只是试图在给定所有可用参数的情况下拟合所有数据。或者,我们可以制作另一条最佳拟合线,试图直接穿过这些点(但不触及所有这些点)。这种模型被认为有很大的偏差,因为它对数据的基本结构作出了假设(即它是线性的);然而,它可能具有较低的方差,因为最佳拟合的线性线在拟合测试集和训练集方面做得相当不错。

在这两种极端情况下,我们的数据集中都有很高的误差。当方差很高而偏差很低时,我们会有很大程度的误差,因为我们的测试集不适合我们的模型(即,我们过度拟合了训练数据)。当偏差较高且方差较低时,我们也会有较高程度的误差,因为模型可能过于简单/做出了过多的假设(即,我们对训练数据进行了欠拟合)。我们的目标是试图找到一个“最佳点”,帮助我们在这些情况下最小化整体错误。一种方法是通过正则化方法,有选择地从我们的模型中删除变量(即,帮助最小化方差,同时略微增加偏差),并在权衡中找到一个令人满意的中间点。套索、岭回归和弹性网都是正则化算法。

LASSO 的工作原理是在我们之前讨论过的残差平方和方程中加入一项。除了计算给定数据点的预测值和实际值之间的误差,LASSO 还添加了一个项,该项等于我们的β值的绝对值乘以我们自己设置的一个名为“lambda”的参数(该参数被称为超参数,因为它是我们设置的,而不是让计算机设置的)。这个额外的术语被称为“L1 规范”思考这意味着什么,我们可以看到,如果我们有大量的贝塔项,我们正在增加新的残差平方和公式。由于目标始终是最小化残差平方和(SSR),因此该算法有选择地将β设置为等于 0。这样做可以最小化额外添加的套索术语。我们还可以通过将 lambda 设置为高值(即,去除 beta 更重要)或低值(即,这样做不太重要),来调整去除 beta 在 LASSO 中有多重要。我们还可以在训练集中的验证集上尝试一些不同的 lambdas。

岭回归的操作类似于套索回归,只是它将β的平方和乘以λ添加到常规 SSR 公式中。这个额外的术语被称为“L2 规范”然而,岭回归不同于 LASSO,因为岭回归不会将某些β完全设置为 0(即,它不会完全消除它们)。相反,它保留了所有的功能,只是减少了不重要的测试版。

弹性回归是两者的折中。它将 L1 范数和 L2 范数添加到 SSR 方程中,并使用称为“α”的独立超参数来确定哪个范数的权重较大(随着α的增加,L1 范数/拉索更重要;随着α减小,L2 范数/岭更重要)。因此,我们在套索和岭回归之间找到了一个合适的中间点,有助于防止我们过度拟合数据。

在之前的研究中,弹性网络已被证明在帮助从队列研究中移除变量方面非常有用,队列研究使用每个数据点包含超过 1000 个特征的数据(即,非常“宽”的数据,因为有许多列/特征与单行数据相关联)。因此,我们可以在最终的模型中获得变量重要性的度量(因为不重要的变量要么被消除,要么被最小化到接近零值)。为了找到这些研究的 alpha 和 lambda 的最佳值,这些研究通常采用一种称为“网格搜索”的程序,这意味着他们在训练数据上尝试 alpha 和 lambda 值的每一种可能的组合(每个组合都限制在某个范围内),然后查看哪个在验证集上产生最佳结果。用给出最佳结果的α-λ参数组合训练的模型随后将在测试集上被评估。

  • 在现实世界的使用中:2015 年,Eichstaedt 等人发表了他们关于 Twitter 如何预测县级心脏病死亡率的模型。在这篇论文中,他们基本上是从 Twitter 上下载数据,清洗数据,提取常用的单词和短语。他们在回归模型中使用这些单词和短语作为自变量,因变量是该推文所在县的动脉粥样硬化心脏病发病率。由于这个问题归结为一个简单的回归,他们能够利用我们谈到的正则化算法,特别是岭回归。应用该算法,他们还可以提取“可变重要性”,在这种情况下,这些词是回归公式中对疾病发病率影响最大的词。他们最终发现,在推特上包含愤怒/沮丧词汇/语调的县,心脏病发病率更高。重要的是,这个预测因子,当与县人口统计数据相结合时,是心脏病发病率的一个非常准确的预测因子。

虽然我们一直在谈论回归,但变量和结果之间的简单关系可能不是可以用简单的方程来建模的。相反,我们可能需要知道一些关于已经用于训练模型的实际数据点的信息,以找出新点的分类。这就是为什么我们要看看实例学习算法,它允许我们直接基于先前的数据点来捕捉关系。

实例学习

实例学习算法尝试通过将未知数据点的输出直接与用于训练网络的值进行比较来执行分类或回归。在回归分析中,我们看到了如何首先尝试拟合一个方程(线性方程或逻辑方程),然后根据这些方程预测值。除了帮助找到最佳方程之外,数据的潜在点实际上并不用于回归技术。实例学习算法使用单独的训练数据点来确定测试点的类别或值。我们将探索完成这项任务的两种方法:k-最近邻和支持向量机(SVMs)。

k-最近邻(以及以 ML 为单位的缩放)

k-最近邻是一种非参数算法(即,它不假设输出函数的形式)。与回归技术(对方程的最终形式做出假设)相比,非参数方法更擅长处理没有明确 x-y 关系的数据。这些方法的问题是,您无法找到减少找到有效预测所需的参数数量的方法。

k-最近邻算法的工作方式如下:(1)对于给定的测试点,找出与该测试点最近的 k 个点(其中 k =指定的点数)。这些是最近的 k 个邻居。(2)在分类任务的情况下,测试点的预测类将是 k 个最近邻居的大多数类(例如,如果四个最近邻居是“糖尿病”、“糖尿病”、“糖尿病”和“非糖尿病”,则测试点的类将是糖尿病)。在回归任务的情况下(这里,“回归”只是指输出一个连续的值,而不是不同的类),我们简单地取 k 个最近邻的平均值(例如,如果测试点的四个最近邻的值分别为 50、60、70 和 80 kg,则测试点的输出值将是(50+60+70+80)/4 = 65 kg)。

还有一些额外的注意事项需要考虑:我们使用什么样的“k”值,以及我们使用什么类型的距离度量。

最佳“k”可以通过在训练验证集中尝试 k 的所有可能值来确定。一旦你找到一个使你的目标误差最小化的“k”(例如,分类的准确度),你就可以评估你的函数。对于距离,我们可以使用欧几里得距离(类似于找到三角形的斜边)、曼哈顿距离(类似于获得城市网格上各点之间的“真实世界”距离)等等。其中一些距离可能更适合某些任务(例如,在处理高维数据时,曼哈顿距离是首选),但您应该尝试几种距离,看看哪种距离能产生最佳结果。

另一个重要注意事项是,K-最近邻算法极易受缩放比例变化的影响。例如,如果数据的一个维度测量某人的身高(通常限制在 1 到 2 米之间),而另一个维度测量他们的体重(10 到 100 公斤),我们可能很难找到每个维度中最接近的点,因为他们的体重相差很大。我们也容易受到数据中异常值的影响。为了帮助解决这个问题,我们可以集中和扩展我们的数据。这样做基本上意味着将我们维度的值重新分配给一个 z 分数(即原始值-该维度中值的平均值/该维度中值的标准偏差)。在这种情况下,无论小数位数如何,大多数值都将介于-2 和 2 之间(如果正态分布)。

为了有助于可解释性,我们还可以尝试并输出在不同“k”级别的 k-最近邻分类的决策边界(如图 4-2 所示)。

img/502243_1_En_4_Fig2_HTML.jpg

图 4-2

k-最近邻示例。这里,我们可以看到修改 k 如何导致白色和灰色类点之间的不同决策边界

这里,图表中的每个像素都被着色为给定特定 k 的 k-最近邻输出的类。当 k 较小时,我们可以看到图表的白色和灰色区域之间的边界非常不规则,这是有意义的,因为使用较少的点来分类对象。当 k 较大时,我们看到决策边界的形状更加规则,因为一个类需要更多的 k-最近邻才能成为多数。较大的 k 值可以更好地理解点实际上是如何相互分离的,但是也可能会对一些点进行错误分类。然而,当 k 很小时,我们可能会学习到不那么有用的决策边界,并且可能只对训练数据起作用。

支持向量机

我将只简单地提到这个算法,因为当深入细节时,它往往会变得非常数学化。支持向量机算法基于这样的假设运行,即可能存在将两类数据分开的线、平面或超平面。目标是找到这种分离,使得最终平面和实际数据点之间的界限尽可能地高(即,找到可以完美分离两类数据的线,也称为“硬界限”)。然而,在某些情况下,我们可以通过允许一些数据点被错误分类来获得更大的余量(这种余量被称为“软余量”)。这样做,我们可以得到一条线,除了一些异常值之外,它仍然有很大的边距来分隔数据。我们可以在图 4-3 中看到一个硬边界和软边界分类器的比较示例。

img/502243_1_En_4_Fig3_HTML.jpg

图 4-3

硬边界和软边界分类器的 SVM 边界。“支持向量”表示为空心圆或正方形。违反硬边界假设的圆形或正方形用虚线边框标记。决策边界是带有虚线边界的黑色实线

这里,左边的图像代表一个硬边界分类器,因为没有一个圆或正方形点跨越线周围的“边界”边界。右边的图像表示一个软边距分类器,因为一些圆/正方形被允许出现在线周围的边距区域中,即使它们违反了先前的边距。

有时,我们必须对数据进行变换(比如求平方),以找到将数据点相互分离的最佳直线、平面或超平面。我们可以对数据应用多种可能的变换,支持向量机可以帮助我们找到要应用的最佳变换,从而为我们提供一个能够最好地分离手头数据的平面。例如,在下面的情况下(图 4-4 ,我们可以使用 SVM 算法来找到最好地分离这些数据的平面。

img/502243_1_En_4_Fig4_HTML.jpg

图 4-4

SVM 用了一个很难在二维空间中线性分离的例子

在左侧,很难找到一条线或多项式来适当地分隔这些数据的类别(其中点的颜色代表其类别)。但是,如果我们对数据应用一个变换(在这种情况下,一个称为径向基函数核的变换),我们可以找到一个平面来为我们分隔这些数据。SVM 算法让我们有能力找到这个平面。SVM 也可以被实现用于回归任务。

  • 在现实世界中的使用:Son 等人在 2010 年发表了支持向量机的使用,用于预测心力衰竭患者是否会坚持药物治疗。他们的输入数据点预测了性别、每日用药频率、用药知识、纽约心脏协会功能分类、射血分数、简易精神状态检查分数以及他们是否有配偶。他们的输出是心力衰竭患者是否在服药。他们能够实现接近 80%的检测准确率,这是令人印象深刻的,因为他们只有 76 个人的小数据集。自那以后,支持向量机已被用于许多医学预测应用,包括预测痴呆症、患者是否需要住院等等。类似地,k-最近邻已被用于基于先前的患者数据来确定个体是否有患心脏病的风险。

决策树和基于树的集成算法

决策树有助于产生 ML 世界中一些最易解释的结果。决策树不太像真正的树。相反,它们的结构就像一棵倒置的树,顶部有根,叶子和树枝的数量随着你的深入而增加,如图 4-5 所示。

img/502243_1_En_4_Fig5_HTML.jpg

图 4-5

预测乙型和丁型肝炎感染/恢复/未知状态的决策树结构

在顶部,有一个根(正式称为节点),它有左右两个分支。每个分支也有一个节点(它也有自己的左右分支等等)。这些分支中的一些不会进一步分裂成其他左/右分支(这些被称为叶)。在决策树生成算法中,将为树的每个节点学习决策规则。该节点可以是类似于“如果患者乙肝表面抗原滴度测试阳性,则转到左分支;否则,去正确的分支。”这些分支也有自己的决策节点,直到它们到达一个叶节点,该叶节点通常给出患者的分类(例如,他们有疾病或他们没有疾病)或给出一些数字,该数字代表到达决策树该部分的训练数据的其他实例的平均标记值。

需要学习的决策树的关键部分是“分割”什么特征(即,在每个节点测试)以及是否值得分割该特征。

分类和回归树

分类和回归树(CART)是一种机器学习算法,允许我们学习决策树。该算法通过反复尝试要分割的特征来工作。无论哪种分割产生最佳结果,都被选择应用于数据(根据该规则在树中创建一个带有分支的新节点)。然后,该算法试图为每个分支找到新的分裂。然而,这种算法不一定是最好的,因为它只依赖于在那个时间点选择最佳分割,而不是尝试多种不同的树来查看一旦整个算法运行时什么是最好的。这种类型的算法被称为“贪婪”算法(在这种情况下,该算法是贪婪的,因为它决定了在训练过程中的单个点上看到的最佳分割)。

但是我们如何评价哪种拆分是“最好的”呢?对于分类任务,我们可以使用一个称为“基尼杂质”的术语可以通过将一个类中该节点的训练点比例乘以其补数(1-该比例)来计算每个节点的基尼系数。我们对所有类别的这些值求和,并对每个节点的值进行加权,以确定哪个分裂特征将导致最低的可能基尼不纯系数(0 =分配给每个分支的所有实例都属于同一类别,这意味着我们有一个完美的分类器;任何更高的值意味着在每个节点都有被错误分类的实例)。在一个等式中,基尼系数可以表示为:

$$ G=\sum \limits_{i=1}^Cp(i)\ast \left(1-p(i)\right) $$

其中 G 为基尼不纯系数, C 表示要分割的等级, p ( i )表示给定等级 i 中的点数比例。

然而,如果我们只是找到可能的最佳树,如果我们不惩罚它的增长,我们可能会得到一个非常复杂的树,有数百个节点和分支。毕竟,我们试图找到一棵可以解释的树。我们可以建立一个“停止标准”,如果在一个特定的分支上没有足够的元素通过分裂生成,它就对树的增长进行限制(例如,如果一个分裂向左推动一个数据点,向右推动三个数据点,但是我们的停止条件规定我们必须在一个节点上至少有五个元素,我们不会根据该标准进行分裂)。我们还可以通过设置一个名为“Cp”(复杂性的缩写)的超参数来“修剪”这棵树。这在基尼系数中增加了一项,惩罚在特定节点下创建的较小的树(子树)的数量(子树的数量越多,对树的生长的惩罚越大)。这两种方法都有助于确保我们不会创建过于复杂且无法在实际决策中使用的树。

您还应该知道决策树算法有多种版本。CART 依靠基尼杂质分数寻找最佳树;ID3、C4.5 和 C5.0 等其他标准依赖于另一种称为“信息增益”的衡量标准理解它如何工作的确切细节并不重要,但知道有其他决策树算法可以在您的数据集上试用是很有用的。

基于树的集成方法:Bagging、Random Forest 和 XGBoost

决策树世界中的集成方法通过创建多棵树并允许每棵树对特定结果进行“投票”来帮助优化预测。这些算法中最简单的一种被称为自举聚合(也称为“bagging”)。装袋包括创建多个类似于 CART 算法的树;但是,它会引导训练数据集,这意味着它会随机选择训练集的子集来创建树。它通过替换进行采样,这意味着一个训练样本可以出现多次。然而,由于所有的树都是在训练数据的子集上训练的,所以它对于一般的数据来说更健壮,因为它不容易过度拟合。在评估阶段,所有这些树都根据每个树的分支和节点,对正确的分类进行“投票”。多数预测获胜。

随机森林是另一种建立在 bagging 基础上的机器学习算法。random forest 并不只是选择带有替换的训练集样本并构建多个树,它还会选择随机的特征子集来对每个树中的每个节点进行分割。例如,如果您的数据集中的每个患者有 100 个您跟踪的特征,随机森林将创建许多树,这些树将只使用 100 个特征的某个子集在每个节点上进行分割(例如,20、30、42、…)。随机选择特征的数量可以通过一个称为“k”或“mtry”的超参数来改变通常,mtry 被设置为特征总数的 1/3(在我们的示例中是 33),但是您应该为 mtry 尝试一些值。你也可以设置随机森林中生成的树的数量(通常使用更多的树更好;然而,在你需要微调的树的高值之后,回报减少了)。一些机器学习库还让您有机会指定这些树可以有多“深”(这基本上限制了一棵树可以分支的次数),因为太深(并且有许多分支)的树会使训练数据过拟合。

XGBoost 是另一种集成算法,它产生与随机森林相似的输出;然而,它通过一种称为梯度推进的方法来构建这些树。“随机森林”独立地构建自己的“森林”,而“梯度提升”构建一棵树接一棵树,调整每棵树对最终决策的影响。它通过基于“学习率”超参数(基本上决定了权重在一次训练迭代中可以改变多少)来调整分配给每棵树的权重。如果学习率太高,我们可能永远也找不到最优解,或者可能偶然发现一个只对我们的训练数据有效的稍微最优的解。如果学习率太低,我们可能要花很长时间才能找到最优解。大多数库会建议 XGBoost 算法使用的值,或者自动提供给你。XGBoost 生成的树可以比随机森林好得多;然而,与随机森林相比,它们通常需要一段时间来训练。

  • 在现实世界中的使用:Chang 等人在 2019 年表明,C4.5 决策树和 XGBoost 用于预测高血压患者的临床结果。他们使用的预测指标是体检指标(性别、年龄、身体质量指数、脉率、左臂收缩压、甲状腺功能[FT3]、呼吸睡眠测试 O2、收缩压[检查时和夜间]以及高血压药物的数量),输出是患者是否患有心肌梗死、中风或其他危及生命的事件。他们最终发现,与正常的基于决策树的算法(C4.5 树实现了 86.30%的准确度)相比,XGBoost 实现了最佳的准确度(94.36%)和 AUC (0.927)。这篇论文强调了集成算法如何提供比树算法更大的优势。

聚类/降维

这类算法通常被认为是无监督学习算法。因此,使用这些算法,我们没有真正的准确性或误差的衡量标准;然而,我们仍然可以大致了解他们的表现。无监督算法对于聚类和降维非常有用。

聚类算法通常用于查找哪些数据点彼此密切相关,从而形成“聚类”这有助于确定疾病爆发的位置,也有助于确定在大型数据集中有多少组患者具有共同的特征。

降维正如其名字所暗示的那样。这些算法帮助我们减少了我们在绘制和解释高维数据时所考虑的不同因素的数量。这些算法通常用于群体遗传学研究,其中个体具有 1000 个“维度”(即感兴趣的遗传位置),并且需要以某种方式分离以解释亚组中的差异。

k 均值聚类

k-Means 聚类的工作原理是试图找到彼此密切相关的数据点的聚类。在高层次上,该算法首先随机选择“k”个数据点作为每个聚类的中心。然后,根据最接近的聚类,将其他数据点分配给“k”个聚类之一。一旦将点分配给某个聚类,该聚类的中心就必须进行更新,以考虑所有已添加的新点(该中心可能不会与现有的点重叠)。将点分配给聚类并更新中心位置的过程持续进行,直到算法达到“收敛”(即,中心不会继续显著变化,并且分配给特定聚类的点不会继续变化)。该过程如图 4-6 所示。

img/502243_1_En_4_Fig6_HTML.jpg

图 4-6

k-Means 算法步骤。黑色十字表示该步骤的中心。浅灰色十字表示前面步骤的中心(注意十字是如何穿过步骤的)。请注意,灰色矩形内的步骤会重复进行,直到中心达到收敛

显然,k-Means 有一个您必须调整的主要超参数:您想要在数据集中找到的聚类数(即中心数)k。有时,如果您事先了解数据集,您可能知道您想要什么“k”(例如,如果您知道数据集中有糖尿病患者和非糖尿病患者,一个好的 k 是 2)。其他时候,您不知道最佳的“k ”,只想为您的数据找到可能的最佳聚类(此时,您可以探索每个聚类中的点的特征,以了解聚类之间的区别)。

使用肘方法可以在没有先验知识的情况下找到最佳 k。它的工作原理是,我们应该尝试找到紧密聚集在一起的集群,也就是集群内误差平方和(WSS)。我们可以通过确定每个点到它所属的星团中心的距离来找到 WSS;我们想找到得到最低 WSS 的 k。然而,在一定数量的 k 之后,WSS 通常不会显著降低。在一个极端,我们可以设置 k 等于我们数据集中的点数;然而,这可能是没有价值的,因为小 k 可以给我们机会得到一个多样化的聚类进行分析。我们将收益递减的点定义为“肘部”,可以通过绘制 WSS 与产生 WSS 的 k 值来识别。在图 4-7 中可以看到一个弯管图的例子。

img/502243_1_En_4_Fig7_HTML.jpg

图 4-7

弯头图示例,弯头在 k = 3 左右

在这里,我们可以看到,在 k = 3 个集群附近,图中有一个“肘形”(即,在增加一个额外的集群时,WSS 损失急剧减少,也称为收益递减)。还有其他方法,例如轮廓法,该方法考虑了一个点与其自己的聚类有多相似以及它与其他聚类有多相似(在该方法中,为每个尝试的 k 产生一个轮廓分数,并且最高的轮廓分数被认为是聚类的最佳数量)。另一种称为间隙统计的测量方法也有助于确定最佳聚类数,方法是使用从零参考分布(通过自举生成)确定的期望值计算聚类内变化。产生最高值间隙统计的 k 是具有最佳聚类数的 k。

k-means 聚类算法的变体也可以产生分层聚类,其中我们产生一个树状图(一个紧密相关的点在树上彼此更靠近的树;参见图 4-8 。

img/502243_1_En_4_Fig8_HTML.jpg

图 4-8

描述物种间关联性的树状图示例

这些树通常用于测量物种之间的进化关系,甚至可以模拟我们肠道中微生物群之间的关系(基于微生物群样本中发现的物种之间的 DNA 相似性)。

需要注意的是,k-means 聚类对规模很敏感,这意味着我们在使用这些方法时需要重新调整和集中我们的数据(类似于 k-nearest neighbors)。

主成分分析

主成分分析(PCA)可以帮助我们可视化高维数据。这是通过识别可以产生新轴的预测因子组合来实现的。然后,我们可以使用这些新的轴来重新绘制数据,以帮助显示数据本身的变化程度。PCA 的最终产品是多维组合的一组轴(称为主成分)。我们可以使用这些轴中的一些(通常是前两个)来绘制我们现有的数据,但是是在一个新的坐标系中。在某些情况下,与仅在一对轴上可视化数据相比,这可以帮助看起来没有差异的数据看起来更加不同/可分。在图 4-9 中可以看到一个 PCA 转换的例子。

img/502243_1_En_4_Fig9_HTML.jpg

图 4-9

PCA 转换步骤。首先,绘制数据,然后找到 PCA 轴,然后将数据转换到 PCA 空间,在新的轴上重新绘制数据

粗略地说,PCA 旨在找到主成分轴,当投影到该轴上时,使数据中的方差最大化。要了解这意味着什么,请参考图 4-10 。

img/502243_1_En_4_Fig10_HTML.jpg

图 4-10

此处显示了 PC1 和 PC2 的差异。请注意 PC1 的方差比 PC2 高

在这张图片中,我们可以看到黑线将成为很好的轴,因为它们可以让我们清楚地看到点之间的差异。这些轴是 x 轴描述的任何特征和 y 轴描述的一点特征的组合。然后,我们继续寻找其他轴,一旦先前的轴应用于数据,这些轴可以帮助将点彼此分开。最终,我们得到了主成分(有很多)的排序,然后是它们可以解释的变异百分比。我们选择使用尽可能多的主成分轴来解释很大程度的变化,直到出现收益递减点(这可以通过“scree plot”来评估,该图看起来类似于 k-means 聚类中的肘方法生成的图)。

人工神经网络和深度学习

人工神经网络(ann)和深度学习(DL)被认为是目前 ML 世界的宠儿。这些算法有助于从输入数据中提取信息,以生成输入数据的“中间”表示。该中间表示通常是输入的某种变换,可以用来容易地“学习”该变换输入的哪些方面是输出的最佳预测。我们看到了转换输入如何在让支持向量机如此好地分类数据方面发挥作用,但这些转换将 SVM 带到了另一个完全不同的水平,定期学习人类通常无法理解的转换。

当我们谈论神经网络时,最明显的词是“neural ”,表示与神经元的某种关系。几乎每一篇介绍性的文章都展示了神经网络是如何部分受到人类神经元及其行为的启发;然而,这种比较开始与今天的神经网络大相径庭。无论如何,我们将涵盖这种比较,因为它有助于激发一个简单的神经网络的基本结构。

基础(感知器,多层感知器)

神经网络由称为神经元的单个单元组成。每个神经元可以具有多个输入,并且具有可以作为输入馈入其他神经元的输出(正如生物神经元具有分别充当多个输入和多个输出的树突和轴突末梢;参见图 4-11 。

img/502243_1_En_4_Fig11_HTML.jpg

图 4-11

生物神经元的结构

为了让生物神经元激发动作电位,膜电位需要超过特定的阈值。类似地,人工神经网络可以被设计来模仿这种行为(尽管这并不一定适用于所有的神经网络)。

抛开人工神经网络背后的生物学思维,人工神经网络的实际实现包括两个主要的构造:权重和偏差。对于人工神经网络的每个输入,权重将输入乘以某个数字。偏差是一个加到所有加权输入总和上的数字(正数或负数)。通常将某个函数(也称为“激活函数”)应用于加权输入和偏差的最终和:有时该函数只是恒等式(即和本身),有时它是 sigmoid 函数(将最终值限制在 0 和 1 之间),有时它可能是另一个函数,仅当和为正时,该函数才等于权重和偏差的最终和;否则,它为零(这称为 ReLU 函数)。一个人工神经元的图片可以在图 4-12 中找到。

img/502243_1_En_4_Fig12_HTML.jpg

图 4-12

具有输入 x1 和 x2 以及偏置项的人工神经元(又名“感知器”)。注意,w1 和 w2 可以采用不同的值,如线条粗细的差异所示

在这个图像中,我们可以看到这个人工神经网络单元(正式称为“感知器”)有两个输入,X1 和 X2,以及一个输出。它还有一个偏差,记为“b”,每个输入的权重称为“w1”和“w2”我们还看到,函数“f”(激活函数)应用于权重乘以输入加上偏差之和,以产生最终输出。

许多感知器可以排列在一起形成一层神经网络。这些层中的许多层可以链接在一起,形成一种称为多层感知器的人工神经网络,也称为 MLP(如图 4-13 )。

img/502243_1_En_4_Fig13_HTML.jpg

图 4-13

具有三个输入、三个全连接层和一个输出的全多层感知器

MLP 通常具有被视为输入图层的第一个图层。这一层的每个感知器采用表格数据(例如,电子表格中的行)中的数据点的任一特征的值,或者甚至可以表示图像中像素的值(每个像素一个感知器)。然后,这个输入层被密集地连接到下一层感知器(即,每个感知器连接到下一层中的所有其他感知器一次)。然后,这些感知器可以被输入到另一层,以此类推。最后,倒数第二层通常被送入单个或多个输出感知器。如果我们只是试图在给定一些输入值的情况下预测一个数字,那么单个感知器输出将是有用的。如果我们想要预测输入数据的类别(例如,无糖尿病、糖尿病前期、糖尿病),多个感知器输出将是有用的。在分类任务中,我们将找到产生最高值的输出感知器,或者甚至可以得到类似于属于由每个输出感知器指定的类别的输入的概率的东西(通过应用称为“softmax”的函数)。

你可能已经注意到我们在这个例子中包含了相当多的感知器,但是在我们的网络中包含这么多感知器有什么好处呢?考虑每个神经元可以接受多个输入,并将这些输入转换成完全不同的数字。这些数字中的每一个都可以输入到其他神经元中,这些神经元输出不同的数字,以此类推。在 MLP 的每一层中,我们将网络的输入完全转换成别的东西。这些表示中的一些可以使这些网络更容易找到最适合分类或回归任务的规则。

到目前为止,我们还没有触及的一个话题是,网络实际上是如何为这些连接和感知机中的每一个学习正确的权重和偏差的。人工神经网络通过一个叫做“反向传播”的过程做到这一点本质上,一个训练数据点(或多个训练数据点,也称为“批”)通过网络发送,并记录一组输出。对于每个输出,网络根据我们自己指定的称为“损失函数”的函数来计算它的“错误”程度(例如,对于回归,该错误可能只是输出数与地面真实数的平方之差)。网络在这一点上的目标是尝试并找到调整网络中存在的每个权重和偏差的最佳方式,以最小化损失。反向传播如何工作的证据可以在网上很多地方找到,但它确实涉及到相当多的多变量微积分。一旦网络调整了它的权重和偏差,它就通过新的训练数据(或者甚至可能再次通过训练数据)来继续更新权重和偏差。

重要的是,你可能会看到提到一种叫做“学习率”的东西,它对于确定神经网络能够多快收敛到使其误差最小化的最优解是很重要的。这是通过在看到一些训练数据后乘以每个权重和偏差的调整量来实现的。在大多数情况下,我们希望学习率相对较低,因为我们不想超过最优解,永远达不到它。但是,如果设置得太低,可能会导致网络需要很长时间才能收敛到最终解决方案。我们自己设定学习率,所以它是一个超参数。其他技术(如“批量标准化”,集中/缩放网络中每个感知器的输出)也可以帮助稳定神经网络的学习过程。

您可能需要调整的另一个主要超参数是“时期”的数量一个时期是一个超参数,它定义了人工神经网络/MLP 模型遍历整个数据集的次数。训练一个网络所需的历元数量没有规则,它完全取决于网络架构。一些简单的网络可能只需要十几个纪元来训练。更复杂的网络可能需要数十万个。为了帮助确定要设置的最佳时期,最好对神经网络的训练设置“停止条件”(例如,如果在十个时期后网络的总误差(也称为“损失”)没有减少 10%,则停止网络的训练)。由于神经网络可能会过度拟合训练数据,因此设置此停止条件非常重要。为了防止过度拟合,可以使用“正则化”技术(通常涉及类似于“放弃”的东西,也就是从网络中随机删除感知机,或者惩罚大权重的 L1/L2 范数正则化,类似于我们在套索/岭回归中看到的)。

您还可以设置其他超参数,如批量大小(网络在更新权重之前看到的训练数据点的数量)、优化器选择(除梯度下降之外还有其他算法确定网络如何学习)、权重初始化(确定网络在任何训练之前的第一个权重和偏差;通常这是随机的)、损失函数(你试图优化的)、层大小(网络的每层有多少个感知器)等等。

一般来说,至少在某些方面,MLP 是许多流行的神经网络架构的组件。大多数用于图像分类的复杂网络都有最后的两到三层,它们只是密集连接的感知机,有助于分类或回归任务。MLP 本身也可以用于预测表格数据。然而,MLPs 的规模变得难以处理,尤其是当使用图像作为输入时。考虑到如果一幅图像的大小为 400px x 400px,并用作网络的输入,这意味着有 160,000 个输入感知器进入该网络,因为每个感知器代表一个像素。此外,这些感知器中的每一个都需要连接到 MLP 中间层中的更多感知器,这表示要学习更多的权重和偏差。之后,使用这种架构处理图像在计算上变得非常困难。这就是我们转向卷积神经网络的地方。

卷积神经网络

为了帮助解决训练神经网络处理相当大的图像输入的问题,研究人员提出了对输入图像本身应用“卷积”的想法。卷积是一种线性运算,它将网络中的部分输入图像相乘并生成输出值。决定乘法如何发生的结构是过滤器(也称为内核)。将输入图像乘以过滤器将产生一个较小的图像。在多个卷积(即,多次运行滤波器乘以图像输入并产生输出图像)上这样做可以极大地减小输入的大小,甚至可以产生网络随后可以学习的图像的中间表示(称为“特征图”)。

但是到底什么是过滤器呢?过滤器是排列成矩阵(即网格)的一组数字。通常这些过滤器相当小(3x3,5x5)。包含在这些过滤器本身中的数字也由神经网络学习。为了产生特征图,过滤器以滑动方式(从左到右,然后从上到下)将输入图像的每个 3x3、5x5(或过滤器的任何大小)部分中的像素值相乘。该乘法运算然后产生另一个图像,该图像可能比原始输入图像小,但是看起来肯定与输入图像大不相同。在图 4-14 中可以找到卷积的示例。

img/502243_1_En_4_Fig14_HTML.jpg

图 4-14

图像上的卷积运算。这里,滤波器值在图像中的 3×3 补片上相乘。通过对该乘法的值求和来计算单个值。该值输出到输出图像,滤镜继续在原始图像上滑动

在卷积神经网络中,每一层都由一组应用于图像输入的过滤器(您可以指定数量)组成。假定输入到神经网络的大多数图像都是彩色图像(有一个红色通道、一个绿色通道和一个蓝色通道),则滤镜的大小为 WxHx3 元素(其中 W =滤镜的宽度,H =滤镜的高度)。还有描述如何将这些内核应用于图像的操作的附加参数。其中一个参数称为填充,它有助于我们处理图像边缘发生的问题(如果我们一直向右移动,在图像的右侧大小,过滤器将继续移动不存在的像素,并超出图像边界)。我们可以通过在图像中添加填充(通常是值为 0 的像素)来解决这个问题。我们还可以设置过滤器的步幅,它决定了过滤器在每个方向上移动时“跳过”多少像素。如果我们保持步长为 1,输入和输出图像将具有相同的大小。如果我们增加步幅,我们告诉过滤器在每一步移动两个像素,而不是一个像素,从而减少输入的大小。

当输入图像通过网络时,还有其他方法可以帮助减小输入图像的大小。一种方法叫做“最大池化”这是一个通过传递滤镜(通常是大小为 2x2 的东西)和 stride(通常是 1 或 2)来操作的滤镜,并且只输出一个值(滤镜所在的图像块中的最大值像素)。在图 4-15 中可以找到 max pooling 对图像所做的示例。

img/502243_1_En_4_Fig15_HTML.jpg

图 4-15

最大池 2x2 操作。请注意,在每个 2x2 区域中,只有最大值保留在输出区域中

我们可以通过与处理在每个步骤设置和管理所有滤波器的所有工作的库一起工作,来指定我们希望我们的网络具有的卷积层的排列和集合。通常,这些库只需要指定我们在每一步需要的过滤器数量、内核大小、输入图像形状、批量大小(在更新权重之前在一次迭代中使用的图像数量)以及填充/步幅。在这些神经网络的末端,我们通常会通过创建一个与最后一个卷积层输出中的像素数量相对应的感知器层来“展平”最后一个卷积层,并创建与该展平层紧密连接的附加层。最后,我们将有几个输出神经元,对应于我们要在网络中预测的每个类别。

卷积神经网络被认为引领了对人工智能技术的新兴趣。一个名为 AlexNet 的特定卷积网络能够在一个名为 ImageNet 的标准化图像分类基准测试中实现高度的准确性。谷歌跟进了他们的初始网络架构,并向公众发布了这些网络的训练模型(考虑到这些网络需要大量计算能力来实现其准确性,这是特别慷慨的)。对于医学成像应用来说重要的是,这些卷积网络可以被重新训练以进行其他形式的图像分类。这个过程(称为迁移学习)通常在网络的初始部分冻结学习的滤波器值,并且只重新训练最后几层(密集连接的层)。这种转移学习范式允许个人非常快速地(甚至在他们的笔记本电脑上)重新训练网络,以完成新的图像分类或回归任务,而这些任务不是最初训练的。重要的是,迁移学习突出了应用于图像的卷积如何生成对分类任务的变化具有弹性的中间表示(即,网络真正学习从图像中提取可用于学习任何东西的显著特征)。

多年来,卷积神经网络增加了越来越多的层(我应该提到“深度学习”是指用于学习任务的网络有许多层/卷积运算)。这种趋势导致了网络的发展,这些网络非常大,但在标准化基准的准确性方面只有微小的改进。还有其他被称为“变压器”的网络架构,它们处于最先进的图像分类技术的前沿,性能更好(但通常深度卷积网络足以完成大多数医学成像任务)。

其他网络(RCNNs、LSTMs/RNNs、GANs)和任务(图像分割、关键点检测、图像生成)

对于不同的任务,还有许多其他的网络体系结构。其中一些可能对医学研究有用;然而,其中相当一部分被限制在人工智能的技术领域,还没有在医学界得到广泛采用。

循环神经网络(RNNs)和长短期记忆网络是可用于处理数据流或基于时间的数据的网络。例如,在给定一组病历的情况下,试图预测急诊室入院可能性的任务可以通过使用 RNN 或 LSTM 来解决。LSTMs 被认为比 RNNs 更先进,因为它们解决了探索和消失梯度的问题(这些问题导致训练过程变得非常长或不可能)。这些网络已经开始进入医学研究应用的自然语言处理(即解释文本)领域。

RCNNs(或基于区域的卷积神经网络)用于医学成像中的对象检测任务。这些网络可用于在图像中实际寻找对象实例的过程(例如肺结节、骨折等。).它们还可以用于自动化医学分割任务(即,描绘各种结构/病理),甚至用于界标检测(即,为每个检测到的对象找到相关的解剖关键点,例如用于脊柱侧凸检测的椎体的角点)。

gan 用于根据它之前看到的图像对生成图像(或其他数据)。这个网络由两部分组成:一个生成器,负责在给定一些输入的情况下生成图像建议;一个鉴别器,负责尝试找出这些新图像生成方式中的错误。发生器和鉴别器试图击败对方,因为发生器试图“愚弄”鉴别器,使其相信生成的图像等同于地面真相。GANs 可用于各种医疗应用,例如 CT 成像的去噪、CT 到 MRI 的转换(反之亦然),甚至分割。

还有其他几个网络用于医疗任务(如 UNet 和 Siamese 网络);然而,它们都基于相同的基本概念运行,或者旨在完成与前面章节所述相同的任务(UNet 最适合于分割,而 siame 网络适合于图像分类)。

至此,我们已经完成了 ML 算法和神经网络之旅。在我们实际编写这些网络代码之前,让我们花点时间讨论一下如何评估这些网络的基础知识,以及确保我们报告的结果有效的关键步骤。

其他主题

评估指标

为了提供可解释的网络结果,我们必须报告某些评估指标,以确保我们研究的读者知道网络实际上按预期工作。

就回归度量而言,报告评估度量的最常见方式是报告平均绝对误差(它只是预测数和实际数之间的差值的绝对值,是所有测试实例的平均值)。但是,一些医学杂志希望您提供一个均方误差(MSE ),它会惩罚离实际值较远的预测,而不会惩罚非常接近的预测,因为我们对预测值和实际值之间的差进行平方(这是“均方根误差”的一个变体,它只取均方误差的平方根)。

在分类度量上,最简单的就是准确度;然而,这并不代表全部情况。通常,ML 算法在每次预测时都会输出某事物在特定类别中的概率。如果你有一个概率,你可以生成一个 ROC 曲线(如前所述)并报告曲线下的面积(AUC)。您还可以报告精确度(真阳性除以真阳性+假阳性)或 F1 分数(

$$ F1=2\ast \frac{\mathrm{precision}\ast \mathrm{recall}}{\ \mathrm{precision}+\mathrm{recall}} $$

).

您可能还需要提供一条校准曲线,以显示算法在何处高估或低估了某个特定类别的可能性。这些曲线是通过将 x 轴分割成固定数量的条块来制作的,这些条块表示某个事物属于某个特定类别的可能预测概率(这来自于您的预测算法)。y 轴上的值对应于该类被正确预测的次数。例如,如果我们有 100 个测试数据点,我们可以将它们在 x 轴上的预测概率分成 5 段(预测概率< 0.20, 0.20–0.39, 0.40–0.59, 0.60–0.79, 0.80–1). For each of the test points that produce a probability that falls into one of the bins, we also count the frequency of the true class of that test point being present. Specifically, in the first bin, we would expect a 20% of points to have the true class (since the algorithm predicted these points would have a probability of 0.20). For the next bin, it would be 40% of points, etc. We plot the proportion of true instances of a class in a given bucket on the y axis. In the case that the model is predicting probabilities that are too large, we would see a value to the far right on the x axis but with a low y value (since, in reality, there weren’t that many classes). Conversely, if the model is predicting probabilities that are too small, we would see a value to the left-hand side of the x axis, but with a high y value. An example of calibration curves can be found in Figure 4-16 )。

img/502243_1_En_4_Fig16_HTML.jpg

图 4-16

校准曲线。浅虚线是一个完美校准的预测器。粗实线曲线没有被很好地校准,粗虚线是相对较好地校准的曲线

在此图中,实线表示低值概率箱的预测概率过低,而高值概率箱的预测概率过高(我们根据 y=x 线进行判断)。您可以使用不同的方法(如“普拉特法”对未校准的概率进行逻辑回归拟合)来校准这些未校准的值(最终产生“拥抱”对角线的粗虚线;这里,浅虚线是完美校准的预测器)。

总的来说,你报告的统计数据高度依赖于你寻求完成的最终任务。在分类任务中,准确性可能足以进行报告,但医学期刊也希望看到 ROC/AUC 来确认您的算法的弹性。有时,您肯定希望看到敏感度和特异性,以了解算法如何处理真阳性、假阳性、真阴性和假阴性情况。最好是退后一步,弄清楚使用你的模型的人在使用它之前需要知道什么。幸运的是,机器学习库产生了大量可供使用的统计数据,甚至可以为您生成一些图表。

k 倍交叉验证

在前一章中,我们已经讨论了将训练集划分为训练集和验证集。然而,在这种情况下,我们仍然可能在算法的性能方面欺骗自己,特别是如果我们在同一个训练集上不断尝试多种算法。据我们所知,与现实生活中的数据相比,我们最初划分的训练集和验证集可能相对“更容易”学习。

为了解决这个潜在的问题,我们可以训练我们的算法并进行多次验证,但是是在不同的数据子集上。我们可以这样做:(1)将数据分割成“k”个段(称为折叠),其中 k 是你输入的一个数字(通常 5 或 10 就足够了)。(2)挑选 k-1 个折叠的数据用作训练数据。(3)训练你的算法。(4)使用剩余的折叠作为您的验证数据。(5)跟踪评估指标。(6)继续执行步骤 2 至 5,在步骤 2 中选择一组不同的褶皱用于训练。

图 4-17 说明了在 k 折交叉验证的每次迭代中,通常如何选取训练和验证折叠。

img/502243_1_En_4_Fig17_HTML.jpg

图 4-17

此图描述了 5 重交叉验证。数据集的 20%用于测试。剩余的 80%训练数据被分成五个折叠,并且每个折叠用作至少一次验证折叠

最后,您可以使用您感兴趣的任何评估指标的平均值来比较各种算法或超参数选择。一些库甚至允许你用交叉验证进行网格搜索(例如,尝试许多可能的超参数组合)来提供一组严格的可能性。

后续步骤

在本节中,我们讨论了许多不同的机器学习算法,并了解了哪些算法适用于哪些类型的数据/任务。有监督的算法可能是医学文献中使用最多的,因为我们经常试图确定算法是否与医生的诊断结果相匹配,但无监督的算法在医学界也有一席之地,特别是在遗传学研究中。然后,我们探索了基于感知器的算法(神经网络),了解了什么是真正的深度神经网络(只是一个由许多层感知器相互连接的网络),并涵盖了一些流行的神经网络架构类别(重点是卷积神经网络)。

在接下来的章节中,我们将看到如何在数据集上实际使用这些算法。

五、项目 1:预测入院的机器学习

欢迎首次深入了解机器学习及其背后的代码。在本章中,我们将使用来自国家电子伤害监测系统(NEISS)的数据集。该数据集包括 2011 年至 2019 年因篮球相关伤害而进行的急诊室访问,将用于预测一个人在给定其年龄、种族、性别、伤害发生地点、受影响的身体部位、初步诊断(来自分诊)和护理中心规模的情况下是否入院。在尝试预测录取状态的过程中,我们会遇到机器学习中的一些问题,包括如何处理不平衡数据,如何调整超参数,以及如何进行特征工程。

在本章的第一部分,我们将使用流行的机器学习库 scikit-learn 来创建一个决策树分类器。然后,为了让我们的生活变得简单一点,我们将切换到使用另一个名为 PyCaret 的库,它将帮助我们尝试一系列不同的算法,看看哪个效果最好。这两个库都可以在我们在第三章使用的 Google Colab 笔记本环境中使用。

记住这个路线图,让我们开始吧!

数据处理和清理

首先,从 https://github.com/Apress/Practical-AI-for-Healthcare-Professionals (NEISS.txt)下载 NEISS 数据集。如果你想看一眼,可以用 excel 打开这些数据。还有一个相关的代码本 https://github.com/Apress/Practical-AI-for-Healthcare-Professionals (NEISS_FMT.txt),概述了特定变量类型的值的含义。

我们的总体任务是确定什么使某人有可能入院(这是文件中的“处置”栏)。考虑到我们可以使用的潜在预测因素,年龄、性别和种族可能会影响一个人的入学状况。也许受伤的身体部位、受伤发生的位置以及最初的诊断对结果的影响更大。此外,诸如是否涉及火灾以及医院的层级(小型、中型、大型、超大型)等因素可能会影响结果。

考虑到这些因素,让我们开始将我们的数据处理成可以在我们在第三章末尾使用的pandas库中使用的东西。

在一个新的 Colab 笔记本中,执行以下操作:(1)创建一个新的 Colab 笔记本。(2)在“运行时”菜单下,选择“更改运行时类型”,然后在弹出的“硬件加速器”选项下,选择 GPU 或 TPU(如果 TPU 选项可用)。在训练集上运行任务时,这两个处理器通常优于 CPU,从而更快地获得结果。(3)将之前链接的 NEISS.txt 文件上传到文件夹中(就像我们在第三章上传 input.csv 回来一样)。

安装+导入库

在 Colab 笔记本的第一个单元中,您需要安装以下内容:

输入【单元格= 1】

!pip install --upgrade scikit-learn catboost xgboost pycaret

Note

对于本章的其余部分,任何实际进入代码库的内容都将被加上“INPUT[CELL = Numbered CELL]”;否则,它将被加上“输入”或者什么都不加。如果您看到输入符号,这也是实际运行单元的提示!

输出看起来像一串文本;然而,如果它在最后给你一个“重启运行时”的选项,那就去做。这一行基本安装了 scikit-learn 和 PyCaret 的升级版(都是免费提供的 Python 库);然而,Colab 已经有了构建它的一些版本,这意味着它需要重新加载来注册它们的安装。(注意,在重新启动 Colab 笔记本的过程中,您将会丢失您所创建的任何变量。这类似于重新开始,只是有了一些新的依赖关系)。

接下来,我们需要导入一些库(就像我们在第三章中为熊猫做的那样)。Python 库通过以下粗略语法导入:

import libraryX
import libraryX as foo
from libraryX import subFunctionY, Z
from libraryX import blah as bar
from libraryX import *

让我们来看一下这组 import 语句的例子。第一行将导入名为“libraryX”的库,并让您能够通过使用点运算符(例如,libraryX.someFunction)来访问任何子模块(将它视为库的一部分)。第二行只是让您能够将libraryX重命名为foo,以防键入库的全名变得令人厌烦。第三行让您能够只获得感兴趣的库的特定部分(在本例中是subFunctionYZ)。第四行的作用与第二行和第三行的组合相同。它使您能够导入主库的一些子组件,并对其进行重命名。第四个函数使您能够将库的所有部分导入名称空间。在这种情况下,您将能够访问该库的所有功能,而不需要在它前面加上libraryX.(当您只使用一个库,但不太清楚您想要使用的特定部件/将使用库中的许多部件时,这很有用)。

首先,我们将导入 scikit-learn、pandas、numpy 和 matplotlib:

输入【单元格= 2】

import sklearn
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

这些库处理以下内容。是 scikit-learn,它拥有许多与 Python 中的机器学习相关的函数。pandas(在我们的代码中我们称之为pd,因为我们在导入它时使用了as关键字)允许我们操作和导入数据。numpy允许我们处理基于列或基于数组的数据(并赋予我们做一些基本统计的能力)。matplotlib是 Python 中的一个绘图库。我们将导入名为pltpyplot子模块来创建一些图形。

读入数据并隔离列

接下来,我们将实际读入我们的数据:

输入【单元格= 3】

df = pd.read_csv('NEISS.TXT', delimiter='\t')

如果你要查看 NEISS.txt 文件(参见图 5-1 ,你会看到它实际上有一堆列,都是由制表符分隔的。通常 pandas(这里是pd ) read_csv 函数假设列之间的默认分隔符(也称为分隔符)是一个,。然而,在这种情况下,我们需要指定它是制表符分隔的,这可以通过指定一个名为“delimiter”的命名参数来实现,该参数的值为\t(这是一个制表符)。

img/502243_1_En_5_Fig1_HTML.jpg

图 5-1

NEISS 数据截图。请注意数据列是如何被制表符分隔的

  • 附注:命名参数(也称为“关键字参数”)允许你在调用一个方法时指定你想要给它赋值的参数的名称。当您调用一个包含许多参数的方法时,这尤其有用。例如,如果我们有一个如下定义的函数:

    def calculate_bmi(height, weight):
    # insert logic for calculating bmi here
    
    
  • 我们可以用calculate_bmi(1.76,64)来称呼它(代表得到一个身高 1.76 米,体重 64 公斤的人的身体质量指数),或者我们可以说calculate_bmi(height=1.76, weight=64)或者calculate_bmi(1.76, weight=64)。注意,我们不能说calculate_bmi(weight=64, 1.75),因为 Python 只允许命名关键字参数跟在非命名参数后面。在 pandas read_csv 方法中,有许多参数采用默认值(这些参数的值自动假定等于某个值,除非您另外指定),因此使用命名参数可以节省时间,因为您不必手动指定调用该函数所需的所有参数,并且可以有选择地更改您为想要修改的参数传递的值。

接下来要担心的是如何实际隔离我们想要查看的列。我们可以使用下面的语句来实现:

输入【单元格= 4】

df_smaller = df[['Age', 'Sex', 'Race',
'Body_Part', 'Diagnosis', 'Disposition',
'Fire_Involvement', 'Stratum', 'Location']]

# display the table so we can be sure it is what we want
print(df_smaller)

运行该单元格时,您应该会看到一个表,其中的列名与指定的列名相同。

这段代码的第一行涉及到我们希望在预测任务中使用的列的选择。假设我们已经将 NEISS 数据存储在一个名为df的变量中,我们可以通过执行df[list of columns]来选择我们想要的列。在这种情况下,列的列表是“年龄”、“性别”、“种族”等。然后,我们将这个结果存储到变量df_smaller中,并调用display(这是一个 Google Colab 特有的函数,帮助我们查看熊猫数据)。

数据可视化

接下来,我们应该尝试将一些数据可视化,以确定其中实际包含的内容。我们可以使用库matplotlib来帮助我们做到这一点。在我们的数据集中,年龄是一个连续变量。其余的数据是分类的(即使它们在这个数据集中使用的编码方案中可能被表示为数字)。

让我们为每个变量创建一个图表,以帮助直观显示数据的分布:

输入【单元格= 5】

fig = plt.figure(figsize=(15,10))
fig.subplots_adjust(hspace=0.4, wspace=0.4)
for idx, d in enumerate(df_smaller.columns):
  fig.add_subplot(3,3,idx+1)
  if d  == 'Age':
    df[d].plot(kind='hist', bins=100, title=d)
  else:
    df[d].value_counts().plot(kind='bar', title=d)

输出

参见图 5-2 。

img/502243_1_En_5_Fig2_HTML.jpg

图 5-2

原始数据集中各种特征的数据可视化

我们可以看到我们的年龄分布主要偏向 20 多岁的年轻人。性别分布仅为男性(1 = NEISS 码本中的男性)。种族由类别 2(黑人/非裔美国人)领导。Body_Part(表示受伤的Body_Part)以“37”为首,即脚踝。诊断以“64”为首,这是一种劳损/扭伤(这是意料之中的)。处置(这是我们想要预测的变量)由类别“1”引导,该类别表示患者已接受治疗、检查和出院。我们试图预测患者是否入院/是否发生了代码为 4、5 和 8 的不良事件。fire _ incidence(编码是否有任何消防部门参与事故)对于大多数患者为 0(即没有消防部门参与),但是对于极少数患者为 3(可能有消防部门参与)。病人就诊的医院阶层大多是非常大的医院(“V”)。年龄分布主要向较低端倾斜。

深入研究这里的代码,第一行告诉 matplotlib 开始创建一个图形(matplotlib 图形包含多个子图形)。注意,我们在导入代码块和解释中提到 matplotlib 的绘图部分,简称为plt。figsize 参数以英寸为单位指定宽度和高度(因为这些图形可以输出给出版物,所以 matplotlib 使用英寸而不是像素来表示图形的宽度和高度)。

在第二行,我们调整了支线剧情之间的间距。此方法更改沿图形宽度(wspace)和图形高度(hspace)的填充量。这基本上说明了子情节之间将有填充(值 0.4 意味着填充将相当于平均情节宽度或高度的 40%)。

接下来,我们进入一个 for 循环。我们正在遍历刚刚创建的包含年龄、性别等的数据框中的每一列。,列。回想一下,当我们在 for 循环语法中使用enumerate时,我们将获得存储在变量(这里,该变量是d)中的循环当前所在的值(在本例中是列名),我们还将获得 for 循环在循环过程中的位置(存储在索引变量idx中,记住这只是一个数字)。

对于循环的每次迭代,我们将使用fig.add_subplot方法添加一个子情节。这将接受我们想要创建的子情节网格的大小(在本例中是一个 3×3 的网格,因为我们有 9 列)和我们想要放置一个情节的编号位置(它将是idx变量加 1,因为 matplotlib 从 1 而不是 0 开始编号子情节)。现在创建了一个支线剧情,并将由下一个剧情填充。

if else语句中,我们希望处理不同的绘图,因为我们的数据对于所有列都不相同。年龄是一个连续的变量,最好用柱状图来表示。分类数据(我们所有的其他列)最好用条形图表示。在if语句中,我们检查列名(存储在d中)是否等于“年龄”。如果是,那么我们将使用数据框附带的绘图方法绘制直方图。我们首先将使用df[d]获取当前列(获取名为d的列),然后调用df[d].plot并传入我们想要的绘图类型('hist'因为它是一个直方图),一个bins参数(指定我们希望直方图有多细粒度;在这里,我们将它设置为 100)和一个绘图标题(这只是我们可以分配给标题命名参数的列名d)。

当我们使if语句失败并进入else分支时,我们做同样的事情。这里,我们需要调用df[d].value_counts()来获取每个类别在数据集中出现的次数。然后,我们可以对结果调用.plot,并将种类指定为条形图(使用kind='bar'),还可以显示图形的标题(与前面类似)。

最后,我们应该得到九个好看的图形。回过头来看,我们完全有可能消除“性别”类别,因为在我们的数据集中每个人都有相同的性别,它不可能为我们提供任何方式来区分该类别中的人。

清理数据

接下来,我们需要做一些数据清理,将我们的 Disposition 列更改为一个二元变量(即,允许与不允许)。首先,我们需要删除所有分配了数字 9 作为处置的条目,因为这些条目是未知的/没有数据值。接下来,我们必须将所有处置值 4、5 或 8 设置为“允许”,将其他任何值设置为“不允许”。让我们看看如何在这段代码中做到这一点:

输入【单元格= 6】

df_smaller.loc[df_smaller.Disposition == 9, 'Disposition'] = np.nan
df_smaller['Disposition'] = df_smaller['Disposition'].dropna()

# recode individuals admitted as "admit" and those not admitted as "notadmit"
df_smaller.loc[~df_smaller.Disposition.isin([4,5,8]), 'Disposition'] = 'notadmit'
df_smaller.loc[df_smaller.Disposition.isin([4,5,8]), 'Disposition'] = 'admit'
df_smaller['Disposition'].value_counts()

输出

将会出现一串消息,说明“正在试图在数据帧的一个片的副本上设置一个值”,但是最后,您应该会看到以下内容:

notadmit    115065
admit         2045
Name: Disposition, dtype: int64

看起来我们有了一堆新语法。让我们一行一行地深入研究代码。

我们的第一个任务是删除任何包含处置值 9(未知数据)的行。我们可以这样做,首先定位该列中任何具有 9 的行,将这些值设置为等于NaN(不是一个数字),然后删除(即删除)任何具有NaN值的行。让我们看看这是如何通过代码实现的:

df_smaller.loc[df_smaller.Disposition == 9, 'Disposition'] = np.nan
df_smaller['Disposition'] = df_smaller['Disposition'].dropna()

在第一行中,我们在数据框上调用.loc方法来“定位”符合我们指定的标准的任何行。df_smaller.loc方法接受由逗号分隔的两个输入:第一个输入是查找行时必须满足的条件,第二个值是满足条件时要编辑的列名。在等号的另一边,我们指定我们想要的行(满足loc条件)。这里,我们将我们的条件设置为df_smaller.Disposition == 9,这意味着我们想要数据帧的 Disposition 列中的值为 9 的任何行。我们还将把 Disposition 列(第二个参数)编辑成等号右边的值(np.nan,它不是一个数字,可以很容易地用来删除任何行)。

在第二行中,我们所做的就是在删除任何非数字值(即np.nan)后,将 Disposition 列设置为 Disposition 列。我们通过在列上调用.dropna()来实现。

接下来,如果处置值为 4、5 或 8,我们需要将处置值设置为‘admit ’,否则设置为‘not admint’。我们将首先处理“notadmit”的情况(考虑一下为什么必须先处理这个问题)。

df_smaller.loc[~df_smaller.Disposition.isin([4,5,8]), 'Disposition'] = 'notadmit'

这个对loc的调用看起来与前面的代码片段相对相似,除了我们的条件中有一个~字符和新增的isin语句。~表示一个逻辑“非”(这意味着我们想要的是后面任何事情的反面)。isin函数测试指定列中的值是否在作为参数传递给isin的数组中(在本例中,我们寻找 4,5,8)。如果某行满足此条件,我们将在“Disposition”列中为该行设置值,使其等于“notadmit”。在下面的代码行中,我们做了完全相反的事情(只是省略了~),并将其值设置为‘admit’。

代码块的最后一行只给出 Disposition 中每个值的计数。最后,你应该有 2045 个承认和 115065 个不承认。

处理分类数据/一次性编码

在我们的数据集中需要注意的一个问题是,有些数据看起来是数字,但实际上不是(因为列中的所有编码值都表示为数字)。相应地,我们的一些机器学习算法会“认为”数据是数值型的,除非我们另外指定。由于这些编码值之间没有数字关系,因此这一规定非常重要(如果我们知道编码值越高,表示成绩越高,即数据是有序的,那么将这些编码值保持为数字可能更合适)。

为了确保我们使用的机器学习库理解我们的列的分类性质,我们需要为我们的数据生成一个“一次性编码”。为了说明这个过程,考虑我们的“种族”专栏。该列的值为 0,1,2,3,4,5,6,每个值代表一个不同的种族。为了生成数据的一次性编码,我们将创建 7 个新列(标题为“Race_0、Race_1、Race_2、Race_3、…、Race_6”),并在对应于原始数据的列中将每一行的值设置为 1,否则为 0。例如,如果我们有一个 Race 为“2”的行,我们会将该行中的 Race_0、Race_1、Race_3、Race_4、Race_5 和 Race_6 都设置为 0。我们将只设置 Race_2 等于 1。

为了使这个过程更容易,我们可以调用一个名为get_dummies的 pandas 方法来为我们生成所有分类列,如下所示:

输入【单元格= 7】

categorical_cols = [
 'Sex', 'Race',
 'Body_Part', 'Diagnosis',
 'Fire_Involvement', 'Stratum']
# make dummy variables for them
df_dummy = pd.get_dummies(df_smaller, columns = categorical_cols)

# and display at the end. Should have 117110 rows and 71 columns
display(df_dummy)

输出

应该是类似图 5-3 的东西。

img/502243_1_En_5_Fig3_HTML.jpg

图 5-3

这是我们数据集的虚拟变量版本。请注意我们现在有了多少列

在这种情况下,我们需要做的就是调用pd.get_dummies并传入我们想要为其生成虚拟变量的数据帧(这里是df_smaller)和我们想要为其生成虚拟变量的列(这些列是存储在变量categorical_cols下的列表中的所有分类列)。然后,我们将 df_dummy 的值重新分配给这个新的数据帧,并在最后显示它。正如您在屏幕截图中看到的,我们从 9 列增加到 71 列,因为我们有许多列包含大量不同的分类值。

现在,我们准备开始对这个数据框架进行一些机器学习。

启动 ML 管道

首先,我们需要指定哪些列和值是我们的“X”(即预测变量),哪些是我们的“Y”(即结果)变量。我们可以通过这两行代码做到这一点:

输入【单元格= 8】

X = df_dummy.loc[:, df_dummy.columns != 'Disposition']
Y = df_dummy['Disposition']

第一行代码选择除 Disposition 列之外的所有列,并将其赋给变量X;第二行只是将 Disposition 列中的值分配给变量Y。很简单。

接下来,我们需要安装并导入一些特定于 ML 的库。我们需要安装的一个库是不平衡学习包,它可以帮助我们使用算法来增加训练过程中代表性不足的结果。我们可以用下面一行代码来实现:

输入【单元格= 9】

!pip install imblearn

接下来,我们导入一些特定于 ML 的库和函数:

输入【单元格= 10】

from sklearn.model_selection import train_test_split, GridSearchCV, cross_validate
from sklearn.utils import resample
from imblearn.over_sampling import SMOTE
from collections import Counter

从 sklearn.model_selection 模块中的train_test_splitGridSearchCVcross_validate逐行开始,负责(1)将我们的数据集分成训练集和测试集,(2)进行带交叉验证的网格搜索,以及(3)进行不带网格搜索的交叉验证(分别)。sklearn.utils中的重采样功能有助于我们对数据进行重采样(当我们试图过多地代表我们未被充分代表的阶层时,这将派上用场)。接下来,我们将从imblearn.over_sampling库中导入SMOTE。SMOTE 是一种有选择地对数据集中的少数类进行过采样的算法。SMOTE 代表“合成少数民族过采样技术”,它基本上会在数据集中的少数民族类中选择两个数据点,在它们之间以更高维度绘制一条“线”,并沿着这条线选取一个随机点来创建一个属于少数民族类的新数据点。这导致在少数类中生成新数据,从而产生更平衡的数据集。“计数器”功能允许我们快速计算列中唯一数据点的频率。

  • 旁注:为什么要在我们的训练集中进行过采样?在我们的数据集中,我们只有< 2%的病例构成入院。我们完全有可能最终得到一个只学会猜测每个人都不应该被接纳的分类器。这将导致高准确度(98%)但非常低的灵敏度(即,高#假阴性)。如果我们在训练集中进行过采样,我们可以在某种程度上保证 ML 算法必须处理过采样数量的训练数据点,而不是完全忽略它们。

现在,让我们拆分我们的训练和测试数据,并对我们的训练数据的少数类进行过采样:

输入【单元格= 11】

X_train_imbalanced, X_test, y_train_imbalanced, y_test = train_test_split(X, Y, test_size=0.30, random_state=42)

oversample = SMOTE()
X_train, y_train = oversample.fit_resample(X_train_imbalanced, y_train_imbalanced)

print(Counter(y_train))
print(Counter(y_test))

输出

Counter({'notadmit': 80548, 'admit': 80548})
Counter({'notadmit': 34517, 'admit': 616})

该函数的第一行调用了train_test_split方法,该方法接收我们之前创建的 X 和 Y 变量,并允许您使用test_size(一个比例)和random_state指定测试集的大小,这允许任何人重现您得到的相同结果(无论您将其设置为什么值都没有关系)。

这个方法调用产生了四个值,依次是训练和测试 X 数据以及训练和测试 Y 数据(我将其存储在变量X_train_imbalancedX_testy_train_imbalancedy_test中)。

接下来,我们需要使用 SMOTE 过采样方法将我们的训练不平衡数据集转换为实际平衡的数据集。

在这段代码的第二行,我们使用SMOTE()创建了 SMOTE 采样器的一个新实例,并将其赋给变量oversample。然后我们调用oversample变量上的fit_resample方法(传入不平衡的 X 和 y 训练数据)来生成平衡的 X 和 y 训练数据(存储在X_trainy_train)。

最后,我们在训练 y 和测试 y 数据上打印出对Counter的调用,这给出了训练数据(第一行)和测试数据(第二行)中 notadmit 和 admit 值的数量。在我们的第一行中,我们看到 notadmit 和 admit 类具有相同数量的数据点,这正是我们想要的(即类平衡)。在测试集中,我们保留数据的原始分布(因为我们希望在评估它时保留真实世界的条件)。

训练决策树分类器

现在我们有了平衡的数据,我们终于可以训练一个分类器了。让我们使用决策树分类器(这是 scikit-learn 版本的分类和回归树):

输入【单元格= 12】

from sklearn import tree

scoring = ['accuracy', 'balanced_accuracy', 'precision_macro', 'recall_macro', 'roc_auc']
clf = tree.DecisionTreeClassifier(random_state=42)
scores = cross_validate(clf, X_train, y_train, scoring=scoring, return_estimator=True)
clf = scores['estimator'][np.argmax(scores['test_recall_macro'])]

在第一行中,我们从 scikit-learn 导入树模块,它包含创建决策树的逻辑。在第二行中,我们将变量scoring设置为我们希望在交叉验证结果中看到的指标名称的列表(这里,我们获得了准确性、平衡准确性、精确度、召回和 AUC)。在第三行中,我们实例化了一个决策树分类器(用tree.DecisionTreeClassifier)并传入一个等于数字的random_state命名的参数以确保可再现性。我们将这个未经训练的决策树分类器分配给变量clf

接下来,我们用以下参数调用cross_validate函数:

  • 本例中的分类器将是clf

  • 训练数据集预测值:X_train

  • 训练数据集标签:y_train

  • 我们想要得到的每个交叉验证的分数:我们的评分列表

  • 我们是否想要为每个交叉验证折叠获得训练好的决策树(我们确实想要,所以我们将return_estimator设置为True)

调用这条线需要一两分钟的时间,因为它将根据我们的数据训练一个决策树。交叉验证的结果(和训练模型)将作为字典存储在变量scores中。

这个代码块的最后一行将把性能最好的分类器(定义为具有最高召回率的分类器)保存在一个变量clf中,以便我们稍后使用。为了访问性能最好的分类器,我们将从存储在scores变量(这是一个字典)的“estimator”键下的列表中选择一个元素。所选择的元素将取决于对应于最高召回分数的索引号(注意召回分数存储在scores变量的'test_recall_macro'键下的列表中)。我们使用 numpy(通过关键字np访问)argmax 方法获得最大元素的索引。例如,如果我们发现在召回分数列表的索引 3 处最大召回分数是 0.97,np.argmax将返回 3,这将设置clf等于scores['estimator']数组的第四个元素(召回我们从 0 开始计数)。

接下来,查看准确性等的平均分数。,从交叉验证中,我们可以打印出培训统计数据:

输入【单元格= 13】

for k in scores.keys():
  if k != 'estimator':
    print(f"Train {k}: {np.mean(scores[k])}")

输出

Train fit_time: 2.1358460426330566
Train score_time: 0.9156385898590088
Train test_accuracy: 0.9815949541399911
Train test_balanced_accuracy: 0.9815949560564651
Train test_precision_macro: 0.9821185121689133
Train test_recall_macro: 0.9815949560564651
Train test_roc_auc: 0.9838134464904644

前面的代码所做的就是遍历scores字典中的所有键,如果键不等于estimator(包含一个训练好的决策树分类器的列表),就打印出“Train ”,后面是我们报告的统计数据,后面是我们在 for 循环迭代中当前使用的键下的scores数组中的值的平均值。

总的来说,我们可以看到该算法在训练数据集上做得相当好,但有一个主要的警告,我们将在稍后看到。

网格搜索

我们还可以尝试不同超参数的多个值,并进行交叉验证,以确定使用函数GridSearchCV的最佳值,如下所示:

输入【单元格= 14】

tree_para = {'criterion':['gini','entropy'],
             'max_depth': [1,2,4]}

clf = GridSearchCV(tree.DecisionTreeClassifier(), tree_para, cv=5, verbose=1, n_jobs=-1)
clf.fit(X_train, y_train)
clf = clf.best_estimator_

我们只是创建了一个字典,它的列表键等于一个值列表,用于测试各种超参数。在这里,我们尝试使用基尼纯度函数或熵纯度函数,还尝试了“最大深度”的多个值,这些值告诉我们在沿着树向下时可以遇到的最大节点数。

然后我们创建一个GridSearchCV实例,它将接受以下参数:

  • 要匹配的分类器(在这种情况下是决策树分类器)

  • 要试验的参数(将试验在tree_para中指定的参数的所有组合)

  • cv=:要使用的交叉验证表单的数量

  • verbose=:是否输出正在发生的培训状态

  • n_jobs=:要运行的处理任务的数量(-1 表示我们应该使用所有可用的处理能力)

运行 clf.fit 将实际训练分类器,我们可以通过将clf设置为等于clf.best_estimator在最后存储性能最好的分类器,因为性能最好的分类器存储在那里。

注意,运行前一个块需要一段时间(当然你可以跳过这一步)。

接下来,让我们看看我们的决策树分类器实际表现如何。

估价

首先,让我们看看是否可以看一看一个基本的混淆矩阵。一个混淆矩阵描绘出由它们的真实标签组织的预测,这有助于我们计算灵敏度和特异性。

输入【单元格= 15】

from sklearn.metrics import plot_confusion_matrix

plot_confusion_matrix(clf, X_test, y_test, values_format = '')

输出

参见图 5-4 。

img/502243_1_En_5_Fig4_HTML.jpg

图 5-4

这显示了决策树分类器的混淆矩阵

代码本身非常简单。我们首先从 scikit-learn 矩阵模块导入plot_confusion_matrix函数。然后我们调用这个函数,传入经过训练的分类器(存储在 clf 中)、测试预测器(X_test)、测试标签(y_test)和一个命名参数,该参数指示我们应该打印出完整的数字,而不是默认的科学记数法(只是使用空字符串''指定)。

我们可以在这里看到,我们的分类器没有我们想象的那么好。灵敏度为 133/(133+483) = 0.215。特异性是 33753/(33753 + 764) = 0.977(回头看看灵敏度和特异性公式,看看我们是如何从混淆矩阵中得到这些数字的)。由于灵敏度如此之低,我们不能相信这个分类器能够捕捉到我们数据集中发生的所有“阳性”事件(其中“阳性”表示入院状态),因为如果患者确实需要入院,它只能获得大约 1/5 的入院决策正确。

但是,这里的问题是什么?我们之前不是看到,我们的训练交叉验证分数对于回忆来说非常接近 1(这与敏感度相同)吗?嗯,看起来我们的评分函数可能认为“notadmit”类是“阳性”类(如果我们使用该标准重新计算,我们会得到接近 0.98 的敏感度)。

让我们更深入地了解这些评估指标:

输入【单元格= 16】

from sklearn.metrics import classification_report, plot_roc_curve

y_pred = clf.predict(X_test)

print(classification_report(y_test, y_pred))

plot_roc_curve(clf, X_test, y_test)

输出

              precision    recall  f1-score   support

       admit       0.15      0.22      0.18       616
    notadmit       0.99      0.98      0.98     34517

    accuracy                           0.96     35133
   macro avg       0.57      0.60      0.58     35133
weighted avg       0.97      0.96      0.97     35133

ROC 曲线见图 5-5 。

img/502243_1_En_5_Fig5_HTML.jpg

图 5-5

决策树算法输出的 ROC 曲线

在第一行代码中,我们导入了一些方法,这些方法将帮助我们评估这个决策树分类器的分类准确性:classification_report将为我们的分类器提供一个重要度量的摘要。roc_curve将使我们能够对我们的预测进行 ROC 分析。auc将根据 ROC 结果计算 AUC。plot_roc_curve将绘制 ROC 曲线。

为了调用classification_report函数,我们需要传入真实分类和预测分类。我们通过对训练好的分类器调用.predict方法来获得预测的分类。在这种情况下,我们已经将最终训练好的分类器存储在clf中,所以我们可以调用clf.predict并传入我们的测试预测器(存储在X_test)。

接下来,我们打印出分类报告。生成该报告的方法(classification_report)采用真实分类和预测分类。从打印出来的结果可以看出,如果我们选择‘admit’作为正类,召回率是 0.22(我们之前计算过)。但是,如果真实的类是‘not admint’,那么召回率是 0.98(也和我们之前计算的一样)。

最后,我们可以通过调用plot_roc_curve来绘制 ROC 曲线。该方法接受训练好的分类器(clf)、X_testy_test变量。在生成的图的右下角,我们得到 AUC 为 0.61。

可视化树

如果您运行了带有交叉验证的网格搜索,您还可以可视化该树(如果您没有,由于该树非常复杂,您不太可能可行地运行以下内容):

输入【单元格= 17】

import graphviz
if (clf.tree_.node_count < 100):
  dot_data = tree.export_graphviz(clf, out_file=None,
                                  feature_names=X.columns,
                                  class_names=['admit','notadmit'])
  graph = graphviz.Source(dot_data)
  graph.render("neiss")

输出

最终采油树的输出如图 5-6 所示。

img/502243_1_En_5_Fig6_HTML.jpg

图 5-6

我们训练的决策树模型的最终树结构输出

我们导入了 graphviz 库,它允许我们可视化树。然后,我们检查树中是否有< 100 个节点(这在您运行网格搜索的情况下是正确的,因为我们将树的深度限制为最多 4)。下面几行代码是特定于 graphviz 的代码,除了为导出准备树之外,它们没有什么特别的意义。运行该块后,您将在 Colab 的文件夹菜单中看到一个名为“neiss.pdf”的文件(如果没有看到,请单击左侧的文件夹图标,然后单击带有刷新符号的文件夹图标)。

注意,在采油树底部,我们看到有几个接线盒(称为树叶)。每一片叶子都有一个值,叫做基尼系数。这个值越高,叶子的纯度越低(意味着决策树的这个分支不能对数据的类别做出好的决定)。

这似乎有很多事情要做

在整个过程中,我们已经编写了几十行代码,这还只是一个分类器的代码。如果我们想尝试一堆不同的分类器呢?我们必须重写这些行并自己处理调优/网格搜索吗?嗯,也许吧。然而,有几个库可以帮助这个过程。我们将探索 py Caret(R 的 Caret 机器学习包的 Python 端口)的用法。

移动到 PyCaret

PyCaret 通过自动尝试一系列不同的模型来处理模型选择过程,并使我们能够根据我们想要优化的任何统计数据(例如,准确性、AUC、精度、召回)轻松选择我们想要进一步优化的模型。

首先,让我们将 PyCaret 中的所有分类方法导入到*下,这样我们就可以调用它们,而无需在每个方法调用前添加 PyCaret 分类子模块名称。

我们还需要更改列的类型,并调整输出标签以使用 PyCaret:

输入【单元格= 18】

from pycaret.classification import *

df_smaller['Body_Part'] = df_smaller['Body_Part'].astype('category')
df_smaller['Diagnosis'] = df_smaller['Diagnosis'].astype('category')
df_smaller['Sex'] = df_smaller['Sex'].astype('category')
df_smaller['Race'] = df_smaller['Race'].astype('category')
df_smaller['Fire_Involvement'] = df_smaller['Fire_Involvement'].astype('category')
df_smaller['Stratum'] = df_smaller['Stratum'].astype('category')

df_smaller.loc[df_smaller.Disposition == 'admit', 'Disposition'] = 1
df_smaller.loc[df_smaller.Disposition == 'notadmit', 'Disposition'] = 0

print(Counter(df_smaller['Disposition']))

输出

Counter({'1': 2045, '0': 115065})

第一行将 PyCaret 中所有与分类相关的方法导入到主名称空间中(允许我们直接访问它们)。下面两行设置分类列的类型(Body_PartDiagnosis等)。)对数据帧使用.astype修改器进行“分类”。我们将得到的转换赋回原始的列类型。

我们还需要将‘Disposition’列中的‘admit’和‘not admint’变量改为 1 或 0(其中 1 是正类)。我们以前使用过这种语法,但是如果不熟悉,就回到我们最初处理数据集的时候。

接下来,我们需要建立一个 PyCaret 实验:

输入【单元格= 19】

grid=setup(data=df_smaller, target='Disposition', verbose=True, fix_imbalance=True,
           bin_numeric_features=['Age'], log_experiment=True,
           experiment_name='adv1', fold=5)

输出

将会有一个交互式组件要求您验证变量的类型。它应该如图 5-7 所示。

img/502243_1_En_5_Fig7_HTML.jpg

图 5-7

这是 PyCaret 在设置培训流程时的屏幕截图

按回车键,然后看看输出。将会有一个包含“描述”和“值”两列的表格确保以下情况属实:

  • 目标=倾向

  • 目标类型=二进制

  • 标签编码= 0: 0,1: 1

  • 转换后的训练集= (81976,101)

  • 转换后的测试集= (35134,101)

  • 折叠数= 5

  • 修复不平衡方法= SMOTE

浏览代码,我们调用 PyCaret 的设置函数。我们传入数据df_smaller并指定目标变量来预测Disposition。我们还说,我们希望看到所有输出(使用verbose=True),我们希望修复任何类别不平衡(fix_imbalance=True),我们还希望“存储”数字特征年龄(即,将数据分配到单独的编号存储箱中,并在编号中的 b 上学习原始数字,这对于一些算法来说更好)。我们还想记录(即记录训练过程)我们命名为“adv 1”(experiment_name='adv1')的实验。最后,我们指定我们只想做五次交叉验证(默认为 10)以节省一些时间(fold=5)。

现在我们已经完成了 PyCaret 实验的设置,让我们实际运行一些模型:

输入【单元格= 20】

topmodels = compare_models(n_select = len(models()))

输出

参见图 5-8 。

img/502243_1_En_5_Fig8_HTML.jpg

图 5-8

PyCaret 培训流程的输出模型

许多最大似然算法的训练统计。注意:这一步需要很长时间(大概 30 分钟)。在你等待的时候,喝杯茶/咖啡。

compare_models方法将实际运行 PyCaret 库中所有可用的模型进行分类。所有可用的模型都可以通过调用models()方法来访问(总共有 18 个)。我们还传递一个n_select参数来选择前“N”个模型,其中“N”是我们指定的数字。因为最好保留所有训练过的模型,我们指定“N”为模型的数量(可以通过len(models())调用来访问)。我们把这个赋值给topmodels变量。

在完成运行之后,我们将得到一个所有已经被训练的模型的列表。请注意,这个列表会很长,但是由您来决定哪一个最适合这个任务。您应该尝试找到一个平衡准确性、AUC 和召回率的模型(召回率是最重要的评估项目)。

对我来说,最好的模型是梯度提升器(gbm)、AdaBoost (ada)和逻辑回归(lr)。它们被列为第六、第七和第八高精度模型。我可以通过索引 top models 变量来访问它们(这只是一个在每个索引处存储一个训练模型的列表)。

  • 边注:我们之前已经介绍过其中一些算法,但不包括梯度提升机器和 AdaBoost。

    AdaBoost 是一种算法,它创建许多具有单个分裂的决策树,也就是说,它们只有两个叶节点和一个决策节点。这些被称为“决策难题”在确定预测的过程中,这些决策树桩中的每一个都会获得一次投票。这些树投票接收与其准确性成比例的投票。随着算法看到更多的训练样本,它将在算法遇到困难的训练数据点(即,不会导致强多数的数据)时添加新的决策树桩。AdaBoost 不断添加决策树桩,直到它能够处理这些困难的训练数据点,这些数据点在训练过程中获得更高的权重(即,正确学习的“更高优先级”)。梯度提升的操作类似于 AdaBoost 但是,它不会给难以学习的数据点分配较高的权重。相反,它将尝试并优化一些损失函数,并基于个体树投票是否最小化任何损失(通过梯度下降过程)来迭代地添加/加权个体树投票。

输入【单元格= 21】

gbm = topmodels[5]
ada = topmodels[6]
lr = topmodels[7]

我们还可以使用plot_model函数从每个模型中绘制出不同的结果(它将训练好的模型作为第一个参数,将绘图类型作为第二个参数)。这是梯度提升器器的两个图表:

输入【单元格= 22】

plot_model(gbm, 'auc')

输出

参见图 5-9 。

img/502243_1_En_5_Fig9_HTML.jpg

图 5-9

从 PyCaret 的定型 GBM 模型输出 AUC

我们可以看到 AUC 接近 0.89,这很好。让我们来看看混淆矩阵:

输入【单元格= 23】

plot_model(gbm, 'confusion_matrix')

输出

参见图 5-10 。

img/502243_1_En_5_Fig10_HTML.jpg

图 5-10

从 PyCaret 训练的 GBM 模型输出混淆矩阵

这些结果似乎相当不错。敏感度(也称为召回率)是(476/(476+146) = 0.765),这是对我们的决策树分类器产生的结果的巨大改进。

我们甚至可以将多种模型结合在一起。这种方法(称为混合)允许我们训练另一个模型,该模型从组件子模型获取输出并生成输出。这有效地将混合模型必须学习的总特征空间从九列减少到您正在混合的模型的数量,使得学习更有效。这也有助于将此形象化为允许每个模型对结果进行“投票”,并训练另一个模型来了解哪些投票更重要/更不重要。让我们来看看如何制作一个混合模型:

输入【单元格= 24】

blender = blend_models(estimator_list=[gbm, lr, ada], method='auto')

这里,我们将梯度提升机器、逻辑回归和 AdaBoost 模型混合在一起,以制作一个混合模型,并将其存储在blender变量中。

然后,我们可以使用predict_model函数评估预测:

输入【单元格= 25】

predict_model(blender, probability_threshold=0.43)

输出

Model           Accuracy   AUC Recall  Prec.    F1 Kappa   MCC
Voting Classifier 0.8333 0.8916 0.8135 0.081 0.1474 0.119 0.2232

注意,我们可以改变概率阈值,在这个阈值上,我们将某个事物定义为一个案例。在这种情况下,将概率阈值从默认值(0.5)降低到 0.43 会导致更高的召回率(0.765 对 0.81),但会稍微牺牲准确性(83.3%而不是 85%)。

额外:导出/加载模型

如果您想要保存最终模型,只需执行以下操作即可:

save_model(insert_model_variable_here, 'modelfilename')

然后,您可以在 Colab 文件菜单中找到该模型(如果没有看到,请刷新它)。

此外,您可以通过调用以下命令将模型从文件加载回内存:

model_variable = load_model('modelfilename')

(注意:如果您在单独的会话中运行此行,您必须将模型文件重新上传到 Colab 环境,因为 Colab 文件不会持久化)。

总结和下一步

在上一章中,我们已经介绍了如何在 scikit-learn 中从头开始训练一个模型。我们专门探讨了如何配置决策树分类器。在这个过程中,我们还做了一些初步的数据探索,清理了我们的数据并将其格式化以用于机器学习应用程序,并经历了为我们的最终树调整超参数并评估其功效的过程(并找出我们应该比其他人更重视哪些指标)。

然后,我们继续使用 PyCaret 来帮助自动化模型选择过程,并了解如何将多个模型组合在一起以创建混合模型。对于绝大多数表格数据(即通常只是电子表格的数据),从探索机器学习算法开始要比试图从头开始编写这些算法容易得多。这一事实使得开始探索这些 ML 算法在医学研究甚至临床结果预测中的用途变得相当简单(就像我们对这个数据集所做的那样)。

然而,机器学习算法通常不适用于基于图像的数据,这主要是因为很难在图像本身中捕捉基于位置的信息。为了完成这项任务,我们需要研究一下卷积神经网络,我们将在下一章探讨它。

本章的所有支持代码可在 https://github.com/Apress/Practical-AI-for-Healthcare-Professionals/tree/main/ch5 找到

六、项目 2:中枢神经系统和胸部 x 光肺炎检测

本章的所有支持代码可在 https://github.com/Apress/Practical-AI-for-Healthcare-Professionals/tree/main/ch6 找到

现在,我们已经了解了基本的机器学习算法及其工作原理,让我们转向神经网络。在这一章中,我们将应对图像分类的挑战:尝试使用胸部 x 射线来检测患者的肺炎(即,为每张图像指定“肺炎”或“正常”状态)。我们还将看到,当评估图像时,我们如何可视化神经网络“关注”什么(通过使用一种称为“Grad-CAM”的技术)。

项目设置

首先,我们需要为这项任务找到一个合适的数据集。幸运的是,一个名为 Kaggle 的网站定期举办机器学习比赛(其中一些与医学图像分类任务有关)。作为这些竞赛的一部分,组织(通常是公司,但也有政府机构,如 NIH)提供公共数据集供使用。本章我们将使用以下数据集: www.kaggle.com/paultimothymooney/chest-xray-pneumonia

本项目的相关任务如下:

  1. 使用 Kaggle API 将数据集下载到 Colab 笔记本中。

  2. 将数据分为训练集、验证集和测试集,并可视化正常病例和肺炎病例的分布。

  3. 为我们的每个数据子集创建数据生成器(您将在本章后面了解这是什么),并扩充我们的训练/验证图像。

  4. 为我们的图像分类任务创建一个名为“SmallNet”的小型神经网络。

  5. 设置网络“回调”以在训练过程中调整神经网络参数,并记录进度统计。

  6. 训练小网。

  7. 使用现有的神经网络(VGG16)并为我们的数据集定制它(通过一个称为“迁移学习”的过程)。

  8. VGG16 列车。

  9. 在测试集上使用 Grad-CAM 可视化两个模型的激活图,并评估两个模型。

记住所有这些,让我们开始吧!

Colab 设定

对于本章,请确保将运行时类型更改为“GPU”。在一个新的 Colab 笔记本中,转到运行时➤更改运行时类型,并在下拉菜单中将 CPU 或 TPU 更改为 GPU。

下载数据

首先,我们需要下载将要使用的图像。

我们将使用这个 Kaggle 数据集: www.kaggle.com/paultimothymooney/chest-xray-pneumonia

要快速下载数据集,您需要执行以下操作:

  1. 创建一个 Kaggle.com 帐户。

  2. 单击您的个人资料图片(在右上角)。

  3. 点击“账户”

  4. 向下滚动到“API”部分。

  5. 首先,单击“过期 API 令牌”确保弹出一个通知,说明 API 令牌已经过期或者不存在 API 令牌。

  6. 然后点击“创建新的 API 令牌”;这样做应该会生成一个 kaggle.json 文件,它会自动下载到您的计算机上。

  7. 将 kaggle.json 文件上传到 Google Colab。

  8. 运行以下代码行:

输入【单元格= 1】

!pip install kaggle
!mkdir /root/.kaggle
!cp kaggle.json /root/.kaggle/kaggle.json
!chmod 600 /root/.kaggle/kaggle.json
!kaggle datasets download -d paultimothymooney/chest-xray-pneumonia
!unzip chest-xray-pneumonia.zip
!rm -rf chest_xray/__MACOSX
!rm -rf chest_xray/chest_xray

输出有很多很多行文本不太相关,无法详细描述。然而,让我们来看看上面所有这些实际上都做了什么。

在前面的章节中,我们有时会在代码中使用!pip install somelibrary行。但是在这种情况下,!实际上在做什么呢?嗯,像pip ...这样的命令实际上并不是 Python 代码。它们是你通常会输入到命令提示符或终端的命令(这是你电脑上的一个独立程序,允许你只需输入几个关键字就可以访问文件和运行脚本)。pip install命令实际上会调用一个单独的程序从网上找到一个 Python 库,下载到你的电脑上,安装好就可以用了。在这里,我们正在安装kaggle Python 库,它允许我们以编程方式与 kaggle.com 网站进行交互。但是,当胸部 x 射线挑战页面有一个大按钮允许我们下载整个数据集时,为什么要做所有这些努力呢?这个数据集非常大(几千兆字节),下载到我们的电脑需要一段时间。此外,我们需要将数据集上传回 Colab 笔记本,这可能非常耗时。相反,我们可以使用 Kaggle Python 库将数据集自动下载到 Colab(或任何运行 Python 笔记本的地方)。

让我们浏览一下前面代码片段的所有其他行。

第 2 行将在位置/root/创建一个名为.kaggle的文件夹(注意,mkdir是“制作目录”的缩写,这是一种方便的方式来记住它是用来制作文件夹的,因为目录只是文件夹的另一个名字)。但是为什么要在这样的位置创建文件夹呢?Kaggle 的库实际上要求我们将 kaggle.json 文件(我们从他们的网站下载的)存储在.kaggle子文件夹下的/root文件中。另外,一个次要的注意事项:/root文件夹是一个存在于系统级的文件夹。与您交互的大多数文件夹都存在于用户级别(例如,桌面、下载、照片等。).

第 3 行将我们在 Colab 主目录中的 kaggle.json 文件复制到。/root 文件夹的 kaggle 子文件夹。它还确保它的名字仍然是那个位置的kaggle.json。请注意我们如何在这些命令中将位置指定为 folder/sub foldername/sub foldername/。这些被称为文件路径,是引用特定位置的便捷方式,而不是不断提到某个东西是另一个文件夹的子文件夹。还要注意,cp是 copy 的缩写(也是一种更容易记忆的方法)。

第 4 行修改 kaggle.json 文件的“权限”。文件权限是系统级的限制,允许我们确切地指定用户对典型文件可以做什么和不可以做什么。这里,我们将 kaggle.json 文件的文件权限设置为600,这意味着它可以被读取和写入。我们不会对文件本身进行任何写入,但最好将它保存在那里,以防您需要直接编辑文件。

第 5 行将调用 Kaggle 库命令行工具(Kaggle 库的一部分,可以从命令行而不是 Python 程序进行交互),并将下载与名称paultimothymooney/chest-xray-pneumonia匹配的数据集(注意完整命令中的-d用于指定应该下载与数据集相关联的文件)。

第 6 行用于解压上一步下载的 zip 文件。这将导致在主 Colab 目录中创建一个chest_xray文件夹。

第 7 行和第 8 行删除了chest_xray文件夹中的两个子文件夹__MACOSXchest_xray。无论如何,这些子文件夹的内容包含主数据集的副本,因此没有必要将它们保存在我们的 Colab 会话中。

拆分数据

在运行完这些命令后,您应该有一个名为“chest_xray”的文件夹,其中有三个子文件夹,分别名为“test”、“train”和“val”。如果您尝试查看各个文件夹,您会看到“train”文件夹有两个子文件夹,分别名为“NORMAL”和“PNEUMONIA”(与“val”和“test”相同)。“正常训练”和“肺炎”文件夹有许多图像(。jpg 文件),测试文件夹也是如此。然而,“val”文件夹在正常和肺炎文件夹中都只有一些图像。这些文件夹包含用于训练我们的神经网络的训练、验证和测试数据;然而,由于验证数据很少(只有 16 幅图像),我们最好自己尝试重新分割数据,以确保更好地分割训练、验证和测试数据。

为此,我们基本上要做到以下几点:

  1. 获取 chest _ xrays 文件夹中所有图像的路径(也称为文件位置)。

  2. 重新分割数据,以便 20%的数据用于测试。

  3. 通过在训练数据集和测试数据集中绘制正常与肺炎图像的频率,验证我们做的事情是正确的,并且没有扰乱数据的分布。

因此,应该注意确保我们的训练/测试部分是足够的。我们稍后将处理如何进行验证分割,但是让我们看看代码:

输入【单元格= 2】

import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

from imutils import paths
from sklearn.model_selection import train_test_split

def generate_dataframe(directory):
  img_paths = list(paths.list_images(directory))
  labels = ['normal' if x.find('NORMAL') > -1 else 'pn' for x in img_paths]
  return pd.DataFrame({ 'paths': img_paths, 'labels': labels })

all_df = generate_dataframe('chest_xray')

train, test = train_test_split(all_df, test_size=0.2, random_state=42)

print(train['labels'].value_counts())
print(test['labels'].value_counts())

输出

pn        3419
normal    1265
Name: labels, dtype: int32
pn        854
normal    318
Name: labels, dtype: int32

前五行代码只是用来导入一些将帮助我们完成这项任务的库(以及其他库)。有一个我们以前没见过的新图书馆叫做imutils。它用于处理图像,并提供了一个名为paths的便捷模块,可以找到一个文件夹(及其所有子文件夹)中的所有图像。这对我们获取所有图像的列表非常有用。

一般来说,代码的结构是这样的,我们首先调用一个我们定义的叫做generate_dataframe的方法。这使得 pandas 数据框包含两列:一列包含所有图像的路径,另一列显示该图像的标签(正常或肺炎)。

然后,我们将使用 scikit-learn 的train_test_split方法分割数据,并指定我们希望将 20%的数据分配给测试集。最后,我们将打印出我们刚刚生成的训练和测试集中每个类的计数。让我们进一步看看generate_dataframe方法,因为我们以前没有遇到过这个方法。

generate_dataframe方法接受一个名为directory的参数。这被传递给paths.list_images方法,该方法以 Python 生成器的形式返回给我们(除了它可以生成一个行为类似 for 循环的函数之外,您实际上不需要知道这是什么)。然而,我们并不真的想要一个发电机;我们只是想要一个文件路径列表。为此,我们只需将paths.list_images调用包装在list()调用中,这将返回我们的图像路径,其中包含我们调用generate_dataframe的目录的所有子文件夹中的所有图像。

接下来,我们需要找到与这些图像相关联的实际标签。如前所述,每个图像都位于一个名为 NORMAL 或 PNEUMONIA 的文件夹中。所以,我们需要做的就是查看我们的每一个图像路径,看看“正常”这个词是否出现:如果出现,那就是一个正常的图像;否则就是肺炎形象。我们可以通过列表理解来做到这一点:

['normal' if x.find('NORMAL') > -1 else 'pn' for x in img_paths]

这基本上相当于

new_list = []
for x in img_paths:
    if x.find('NORMAL') > -1
        new_list.append('normal')
    else:
        new_list.append('pn')

小提醒:如果没有找到传递给.find的短语,字符串文件路径上的.find函数(暂存在变量x中)将返回-1;否则,它返回大于-1 的值。

现在,我们有了一个图像路径列表,还有一个标签列表。在一个有两列(一列为路径,一列为标签)的数据框中有这样的方法会很好,因为它与库中的其他方法配合得很好,我们将使用这些方法来构建我们的神经网络。要做到这一点,我们需要做的就是调用pd.DataFrame方法并传入一个字典,其中的键相当于列名,各自的值相当于组成这些列的列表。

然后我们可以调用generate_dataframe ('chest_xray')来获得所有图像的数据帧。train_test_split方法也将分割我们的数据框架(并试图确保两个类具有相同的标签分布)。最后,我们将通过调用'labels'列上的.value_counts()来打印出新生成的traintest数据帧中标签的频率。

我们还可以继续绘制计数:

输入【单元格= 3】

fig = plt.figure(figsize=(10,3))
for idx, x in enumerate([train, test]):
  fig.add_subplot(1,2,idx+1)
  x['labels'].value_counts().plot(kind='bar')

输出

参见图 6-1 。

img/502243_1_En_6_Fig1_HTML.jpg

图 6-1

培训和测试案例的分布

我们可以看到,正常与肺炎(这里称为“pn”)图像在训练和测试数据集中的分布基本相同。重要的是,我们看到肺炎数据相对于正常数据被过度表示,如果我们不考虑这一点,这可能会使我们的网络产生偏差。

现在我们已经创建了训练和测试数据集,我们可以开始实际使用神经网络构建库tensorflowkeras(包含在 TensorFlow 中)。我们将首先创建一个 ImageDataGenerator,它允许网络轻松地获取训练、验证和测试图像。

创建数据生成器和增强图像

数据生成器是我们的网络实际使用存储在数据框中的训练和测试数据的方式。由于我们的数据框架只包含图像和标签的路径,我们理想的情况是能够自动将这些图像读入计算机的内存,这样我们即将制作的神经网络程序就可以在其上进行学习(或对图像进行评估)。

我们正在使用的神经网络库使我们能够创建一个ImageDataGenerator,这将允许我们指定数据扩充(对我们的图像进行随机转换,使我们能够不断生成所有都是唯一的图像,而不是在完全相同的图像上进行训练),并使我们能够指定从中加载图像的数据框。

  • 旁注:为什么我们需要增强我们的图像?神经网络训练的方式是一次遍历所有的训练图像(通常是称为“批次”的图像组)。在整个成像集上运行的每个训练被称为一个“时期”一些神经网络将需要多个时期来实际训练到现实世界使用可行的点,但是一次又一次地在相同的图像上训练有过度适应训练数据的风险。我们可以使用ImageDataGenerator来随机改变每个时期的源训练图像。我们可以指定对图像的随机变换(称为“增强”),例如将图像旋转一定的最大度数,水平翻转图像,左右移动图像,或者改变图像的亮度。我们将在我们的脚本中使用这些增强。

让我们建立一个方法,允许我们创建我们需要的所有生成器(例如,训练、验证和测试生成器)。

输入【单元格= 4】

from tensorflow.keras.preprocessing.image import ImageDataGenerator

def create_generators(train, test, size=224, b=32):
  train_generator = ImageDataGenerator(
      rescale=1./255, rotation_range=5, width_shift_range=0.1,
      height_shift_range=0.1, validation_split=0.2
  )
  test_generator = ImageDataGenerator(rescale=1./255)

  baseargs = {
      "x_col": 'paths',
      "y_col": 'labels',
      "class_labels": ['normal', 'pn'],
      "class_mode": 'binary',
      "target_size": (size,size),
      "batch_size": b,
      "seed": 42
  }
  train_generator_flow = train_generator.flow_from_dataframe(
    **baseargs,
    dataframe=train,
    subset='training')
  validation_generator_flow = train_generator.flow_from_dataframe(
    **baseargs,
    dataframe=train,
    subset='validation')
  test_generator_flow = test_generator.flow_from_dataframe(
      **baseargs,
      dataframe=test,
      shuffle=False)

  return train_generator_flow, validation_generator_flow, test_generator_flow

输出

不会有任何输出,因为我们只是在这里定义一个方法。我们将在完成它的功能后调用它。

这里,我们定义了一个名为create_generators的方法,它接受我们的训练和测试集。我们还指定了另外两个参数size=224, b=32。这些是“默认命名参数”(它们的功能与普通参数一样,但只有在方法调用指定的情况下才具有指定的值)。sizeb将用于指示图像应该如何调整大小,以及每个生成器的“批量大小”(即,神经网络在每个时期的每个训练步骤中看到的图像数量)。

接下来,我们创建ImageDataGenerator的两个实例,一个训练图像数据生成器和一个测试图像数据生成器。在训练图像数据生成器中,我们指定了如下一些参数:

  • rescale是一个数字乘以图像中的每个像素。我们将它设为等于1./255,这意味着我们将所有的东西都乘以 1/255。选择这个数字是因为大多数神经网络架构对比例敏感,这意味着网络很难在整个像素值范围(0–255)内学习。相反,我们可以将图像像素重新调整为 0 到 1 之间的值(将整个图像乘以 1/255 即可)。

  • rotation_range是我们拥有的第一个增强参数。因为我们将它设置为 5,我们将随机地顺时针或逆时针旋转每张图片 0 到 5 度。

  • width_shift_rangeheight_shift_range是第二和第三增强参数。它们分别将图像宽度和高度移动图像宽度和高度的 0–10%(0.1)。

  • validation_split是将我们的训练集分成训练集和测试集的参数。因为我们将它的值指定为 0.2,所以我们最初定义为训练集的 20%将被分配给一个验证子集。

对于测试图像数据生成器,我们只需要重新缩放我们的图像(因为我们想在没有任何额外变换的实际图像上进行测试)。

实例化一个ImageDataGenerator对象不足以让数据生成器从我们的数据框中读取图像。为此,我们必须在我们当前拥有的每个数据生成器(一个训练和测试数据生成器)上调用.flow_from_dataframe。现在,让我们深入到前面代码的第二部分:

...
  baseargs = {
      "x_col": 'paths',
      "y_col": 'labels',
      "class_labels": ['normal', 'pn'],
      "class_mode": 'binary',
      "target_size": (size,size),
      "batch_size": b,
      "seed": 42
  }
  train_generator_flow = train_generator.flow_from_dataframe(
    **baseargs,
    dataframe=train,
    subset='training')
...

这里,我们定义了一个有多个键和值的字典。然后这个字典被传递给我们的函数,但是被表示为**baseargs**是做什么的?它实际上将我们的字典扩展为命名参数,这样字典的每个键和值都是一个命名参数的名称和值。所以前面的例子相当于说

train_generator_flow = train_generator.flow_from_dataframe(
    x_col='paths',
    y_col='labels',
    class_labels=['normal', 'pn'],
    class_mode='binary',
    target_size=(size,size),
    batch_size=b,
    dataframe=train,
    subset='training')

使用**语法更方便一点,因为我们将在每个生成器中重复这些参数。

每个参数都执行以下操作:

  • dataframe指定包含图像位置及其相关标签的数据的源数据框。

  • 我们还需要指定哪一列包含图像路径(x_col参数),哪一列包含标签(y_col参数)。

  • 此外,我们需要将类标签(参数class_labels)指定为一个列表(注意:类名在内部被转换为 0 或 1,列表的第一个元素将被视为 0,第二个元素将被视为 1)。

  • 由于我们只处理两个类别来预测,我们可以指定结果(通过class_mode参数)应该被视为一个二元变量(如果我们预测多个事物,我们可以使用categorical作为class_mode)。

  • 我们还可以将图像的大小调整到一个元组中指定的宽度和高度,作为target_size的参数。这里,我们将size设置为 224,这意味着我们将把 224px x 224px 的图像输入到我们的神经网络中(这在后面会变得很重要)。

  • 我们还通过batch_size变量设置批量大小(即,我们一次呈现给神经网络的图像数量)。我们将批处理大小设置为 32,但这可以根据您的需要而定(较大的批处理大小会占用更多内存,较小的批处理大小会占用较少内存,但可能会导致网络学习速度变慢,因为在更新其权重之前,它一次只能看到一个图像)。

  • 此外,对于我们制作的训练和验证生成器,我们可以指定从哪个数据子集进行选择。回想一下,当我们将ImageDataGenerator存储在train_generator变量中时,我们指定了一个 0.2 的验证分割。这将 80%的数据框行分配给定型子集,20%分配给验证子集。因为我们正在制作一个完全“流动”的训练生成器,所以我们想要选择这个训练生成器的训练子集,这可以用subset参数来完成。

  • 最后,我们可以通过指定一个“种子”参数来确保数据分割的可再现性(这只是一个随机数,但是任何从具有该数字的数据帧运行 flow 的人都应该得到与我们相同的图像分割)。

我们将调用了.flow_from_dataframetrain_generator存储在train_generator_flow变量中。

我们还设置了类似于“流动”序列生成器的“流动”验证生成器,除了我们指定subset为原始序列生成器的'validation'子集。

最后,我们设置了与其他两个类似的“流动”测试生成器,除了我们不包括subset参数,并将源数据帧设置为test数据帧。此外,我们禁用数据集中图像的混排。通常,所有的图像在每个时期被混洗,以确保网络不会以相同的顺序暴露于图像;然而,这种行为使得评估图像更加困难(将导致用于评估图像的标签不正确)。

现在,让我们通过调用create_generators方法并显示来自我们的训练生成器的一些图像来看看是否一切正常:

输入【单元格= 5】

train_generator, validation_generator, test_generator = create_generators(train, test, 224, 32)

imgs = train_generator.next()
fig = plt.figure(figsize=(10,10))
for i in range(16):
  fig.add_subplot(4,4,i+1)
  image = imgs[0][i]
  label = 'PNEUMONIA' if imgs[1][i] == 1 else 'NORMAL'
  plt.axis('off')
  plt.imshow(image)
  plt.title(label)

输出

参见图 6-2 。

img/502243_1_En_6_Fig2_HTML.jpg

图 6-2

用于查看的肺炎和正常病例的输出网格

我们首先使用traintest数据集以及 224 和 32 分别调用我们的create_generators方法,用于图像大小和批量大小(尽管我们可以省略这两个参数,因为它们在方法定义中有默认值)。

代码的下一部分对来自训练生成器的一批图像进行采样(使用train_generator.next())并创建一个图形。我们将这些图像存储在imgs变量中。train_generator.next()返回两个列表:第一个列表包含所有图像,可以使用imgs[0]访问;第二个列表包含相应的标签,可以使用imgs[1]访问。

接下来,我们将显示该训练批次中的前 16 个图像(注意,该批次中总共有 32 个图像)。

for i in range(16)进行 16 次的 for 循环(而i从 0 开始加 1)。对于 For 循环的每次迭代,我们通过指定三个数字向图像添加一个子情节:前两个数字定义子情节的网格(4x4 以容纳所有 16 个图像),最后一个数字指定图像将绘制在哪个数字子情节中(从 1 开始)。

为了绘制图像,我们将访问存储在imgs[0][i]中的第i个索引训练图像。我们还将从imgs[1][i]获取图像的标签,如果标签等于 1,则将它的值存储为“肺炎”;否则,它会将其存储为“正常”

最后,我们使用plt.imshow方法显示图像(将图像绘制到子情节中),并使用plt.title设置情节标题,传入我们之前设置的标签。

正如您所看到的,一些图像被轻微旋转和移动,所有图像看起来都像我们在测试集中可能看到的可信图像(这是数据扩充的目的)。

现在,我们已经设置了用于神经网络的数据生成器,让我们实际指定神经网络的结构。

你的第一个卷积神经网络:SmallNet

神经网络通常使用以下一般步骤来指定:

  1. 指定网络的结构(即进入网络的所有单个层)。

  2. 编译网络,并指定它如何学习(通过优化器),如何惩罚糟糕的学习(通过损失函数),以及如何衡量其进展(通过指标)。

  3. 指定回调函数,以确定在每个时期结束时是否应该记录任何内容,何时应该保存模型,以及一些参数应该如何更改。

  4. 训练模型。

我们程序的整体结构将如下(注意,这只是一个粗略的草图,而不是实际的代码):

def make_modelname():
    # specify model layers and compile model
    # return compiled model
def get_callbacks():
    # return list of callbacks
def run_model(train_generator):
    model = make_modelname()
    callbacks = get_callbacks()
    # fit the model
    model.fit(train_generator, callbacks)

在这一部分,我们将处理第 1 步和第 2 步。为此,让我们指定一个我们称之为“SmallNet”的神经网络架构,因为它具有相对较少的训练参数。在此之前,我们需要导入几个方法:

输入【单元格= 6】

  • 第一行从 Keras 机器学习库中导入一个称为“顺序”模型的模型类型。它还导入了一个“load_model”方法,我们将在稍后尝试重新训练一个已经保存到磁盘的模型时使用这个方法。

  • 第二行导入了一个名为“Adam”的优化器。Adam 将自适应地改变神经网络的学习速率,以便它调整调整其权重的速度。

  • 第三行导入各种层(将这些层视为神经网络的部分)。我们将在创建“SmallNet”时使用所有这些工具

  • 第四行导入了一些回调方法。如果网络性能停滞,ReduceLROnPlateau将降低学习率,ModelCheckpoint允许我们在每个时期完成后保存网络,TensorBoard允许我们可视化我们网络的训练进度,EarlyStopping允许我们在没有任何改进的情况下提前停止网络的训练(帮助我们避免过度拟合)。

  • 第五行导入了一些指标,以便在网络训练期间进行监控。

  • 第六行导入一个叫做VGG16的神经网络架构。我们将调整这个网络的预训练版本来完成我们的任务。

  • 第七行引入了一个名为plot_model的方法,它让我们能够可视化我们的神经网络结构。

  • 最后一行只是让我们能够获得当前的日期和时间(我们在为培训进度创建日志时将需要它)。

from tensorflow.keras.models import Sequential, load_model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.layers import Dense, GlobalAveragePooling2D, Conv2D, MaxPooling2D, Flatten, Dropout
from tensorflow.keras.callbacks import ReduceLROnPlateau, ModelCheckpoint, TensorBoard, EarlyStopping
from tensorflow.keras.metrics import Recall, Precision, AUC
from tensorflow.keras.applications import EfficientNetB4
from tensorflow.keras.utils import plot_model
from datetime import datetime

现在我们已经定义了我们需要的导入,让我们开始指定我们的“smallnet”架构:

输入【单元格= 7】

def make_smallnet():
  SIZE = 224
  model = Sequential()
  model.add(Conv2D(32, (3, 3), activation="relu", input_shape=(SIZE, SIZE, 3)))
  model.add(MaxPooling2D((2, 2)))
  model.add(Conv2D(32, (3, 3), activation="relu"))
  model.add(MaxPooling2D((2, 2)))
  model.add(Conv2D(32, (3, 3), activation="relu"))

  model.add(Flatten())
  model.add(Dense(32, activation="relu"))
  model.add(Dense(1, activation="sigmoid"))

  model.compile(optimizer=Adam(learning_rate=1e-2),
                loss='binary_crossentropy',
                metrics=['accuracy', Recall(name='recall'),
                        Precision(name='precision'), AUC(name='auc')])

  return model

这个方法将被用于在我们的最终训练方法中返回一个“编译”的模型(注意:一个“编译”的模型只是一个准备好被训练的模型)。这种网络体系结构包含按顺序排列的多个层(即每层直接连接到下一层);我们称之为顺序卷积神经网络。这个卷积神经网络(CNN)被分成两个主要部分:卷积部分和密集部分。

在卷积部分(在“Flatten()”行之前),我们创建了三个卷积层和两个最大池层。第一个卷积层由 Keras 神经网络库中的Conv2D方法指定。

让我们进一步分析这一陈述:

Conv2D(32, (3, 3), activation="relu", input_shape=(SIZE, SIZE, 3))

第一个参数32指定了我们想要训练的卷积滤波器的数量。回想一下,这些滤镜从左到右滑过图像,并将滤镜所在的像素乘以滤镜中学习到的任何值。这个过程我们做了 32 次。

过滤器的大小由第二个参数(3,3)指定,这意味着一个 3px x 3px 的正方形将围绕我们的图像移动并进行乘法运算。

activation="relu"参数指定卷积层的激活函数。这基本上是一个应用于来自图像卷积的结果值的函数。在这种情况下,我们使用“relu”激活函数,它将保持正值,并将任何负值设置为 0。我们需要激活函数,因为它允许网络进行梯度下降(即,找出调整权重以最小化损失的最佳方式)。

最后一个参数指定图像的输入形状。在这里,我们将其设置为(SIZE, SIZE, 3)。在方法体中,SIZE是一个我们设置为等于 224 的变量,它对应于我们从训练生成器获得的输入图像的大小。“3”表示我们的图像是 RGB 彩色图像。尽管 x 射线照片只有强度值而没有颜色,但 Kaggle 竞赛的组织者将 x 射线 DICOM 图像保存为. jpg 格式,这将它们转换为 RGB 彩色图像(即使它们在我们眼中看起来是灰色的)。“3”表示每个图像实际上有三个相互堆叠的图像:一个图像包含该图像的“红色”值,另一个包含“绿色”值,最后一个包含“蓝色”值。如果我们有原始强度值,我们会将“3”改为“1”我们将这个Conv2D方法调用包装在一个model.add中,它实际上将这个卷积层(由Conv2D创建)添加到我们正在制作的顺序 CNN 模型中。

  • 边注:当我们实际训练这一层网络时,神经网络需要学习几百个参数,仅仅是这一层。参数的具体数量是 896(可以把这些看作是感知器的权重和偏差,尽管卷积滤波器中没有任何参数)。我们如何计算这个数字?嗯,每个滤镜都是 3x3x3(前两个“3”表示滤镜的宽度和高度;最后 3 个代表我们正在对三通道彩色图像进行操作的事实),其中过滤器的每个单元都是要学习的参数。此外,每个滤波器还必须学习一个通用的“偏差”项。我们有 32 个这样的滤波器要学习,这意味着有 32∫((3∫3∫3)+1)= 896 个参数要学习。如果我们有前一层的输出,我们还需要学习参数来确定它们的权重。卷积模块中要学习的参数的一般公式为

$$ #\mathrm{parameters}=#\mathrm{filters}\ast \left(\left(\mathrm{filter}\ \mathrm{height}\ast \mathrm{filter}\ \mathrm{width}\ast #\mathrm{of}\ \mathrm{filter}\mathrm{s}\ \mathrm{in}\ \mathrm{previous}\ \mathrm{layer}\right)+1\right) $$

我们添加到网络中的第二层是“最大池层”(也称为 maxpool)。所有这一层将做的是缩小我们的图像,在我们的图片中寻找特定的块,只保留最高值的像素。在这种情况下,因为我们将数字(2,2)传递到MaxPool2D层,所以我们将把来自前一卷积层的图像输出分解成小的 2x2 部分(不重叠)。然后,该层将挑选出每个 2x2 部分中最高值的像素。不需要学习任何参数,因为这个操作只是减少输入的算法步骤。

我们将再次按顺序重复卷积➤最大池操作,并跟进最后一个卷积层。在我们的网络中增加更多的卷积可能会产生更好的结果;但是,我们需要确保不要添加太多的最大池层,因为每次应用它们时,图像都会缩小两个。说到这里,我们的图像在这些操作结束时的大小是多少?

在第一次 Conv2D 操作之后,我们的输入 224 x 244 图像将被转换为 222 x 222 x 32 图像。为什么我们两边都丢了两个像素,还加了个“x32”?那么,通过图像的卷积滤波器必须总是通过图像的有效部分(即,我们不能出现滤波器的某些部分在图像上而某些部分不在图像上的情况)。此外,由于我们有 32 个卷积滤波器,我们实际上是为单个输入图像创建 32 个新图像。这就引出了以下关于如何计算卷积中维数变化的通用公式:

$$ dnew=\left[\left(d-k+2p\right)/s\right]+1 $$

这里, d 是我们想要为其寻找新维度大小的原始维度的大小(例如,宽度), k 是沿着相同维度的内核的大小, p 是填充参数, s 是步幅参数。公式的输出是 dnew 尺寸大小。让我们试着计算一下我们的新宽度。由于我们的宽度输入尺寸 size 是 224, d 是 224。 k 是我们滤镜的宽度,为 3。Padding ( p ,我们添加到图像边缘的像素数)是 0(这是默认值,除非另有说明)。Stride ( s ,过滤器在每一步上移动的量)仅为 1。插上,[(224-3+2*0)/1)]+1 = 222。因为我们的输入高度与输入宽度相同,并且过滤器高度与过滤器宽度相同,所以我们的新高度是 222。x32 来自于这样一个事实,即有 32 个过滤器,每个创建一个新的图像。

在最大池操作之后,我们的维度将减少一半。在我们的原始维度是奇数的情况下,我们向下舍入。因此,这意味着我们在最大池层之后的新维度的宽度和高度将是 111 (222/2 ),通道是 32(也就是从卷积中产生的图像数量)。

下一个卷积层与第一个相同,但这次我们将通道数量从 32 个增加到 64 个。通过数学运算,新的宽度和高度尺寸将为[(111-3+2*0)/1]+1 = 109。新的通道数将为 64,最终尺寸为 111 x 111 x 64。然后,我们接着使用一个最大池,这将维度降低到 111/2 = 54.5(向下舍入)= 54(最终大小= 54 x 54 x 64)。接下来,我们进行另一个卷积运算,保持通道数不变,因此最终尺寸为 52 x 52 x 64。正如您所看到的,每个 maxpool 操作都可以减小我们的图像大小,我们不能低于 1x1 图像(还要注意,如果步幅> 1,卷积可以缩小图像)。

第二次卷积需要学习的参数总数是 18496(64∫((3∫3∫32)+1),第三次卷积需要学习的参数总数是 36928(64∫((3∫3∫64)+1)。如您所见,我们已经有超过 56,000 个参数需要学习,而且我们甚至还没有完成网络的指定。

下一个代码块将获取我们拥有的卷积图像(一个尺寸为 52 x 52 的 64 通道图像),并将其“展平”,以便它们现在可以表示单个的感知器。事实上,我们总共创造了 173,056 个($ = 52 52 64$)感知机。这是通过调用model.add(Flatten())完成的。

接下来,我们将 173,056 个感知器密集地连接到 64 个感知器(通过指定model.add(Dense(64...)))。这将 173,056 个感知器中的每一个连接到 64 个感知器中的每一个,形成总共 173065*64 个连接(这些是我们需要学习的参数)。此外,我们需要学习 64 个感知器中每个感知器的偏差,这样这一层要学习的参数总数就达到了 11075648。我们还在这一层设置了一个 relu 激活函数(将负输出设置为 0,不改变正输出)。这让我们看到了这个密集层的最后一行代码model.add(Dense(64, activation="relu"))。注意,前面提到的所有层,Conv2DMaxPool2DFlattenDense,都来自于tensorflow.keras.layers子模块(它包含许多许多其他层供您检查)。

最后,我们有了网络的最后一层。这是唯一一个实际上被约束为特定值的图层,因为该图层的输出将用于评估预测。我们将建立一个密集层,但只有一个输出感知器。这意味着来自前一层的所有 64 个感知器将被连接到这一个输出层(并且我们需要为这一层学习 64 个权重+ 1 个偏置参数= 65 个参数)。我们还将在这一层设置一个“sigmoid”激活函数(沿着“S”曲线从 0 到 1 约束输出;参见图 6-3 比较 sigmoid 和 relu 功能)。这些激活函数的图形可以在图 6-3 中找到。

img/502243_1_En_6_Fig3_HTML.jpg

图 6-3

Sigmoid 和 ReLU 激活函数图

  • 边注:把一个神经元的预激活功能输出看作是馈入这些功能的“x”值。在 sigmoid 函数中,无论我们放入什么样的 x 值,y 值都会一直在 0 到 1 的范围内。在 relu 函数中,如果我们输入一个正的 x 值,我们会得到一个正的 y 值。如果 x 为负,我们得到 0。还有许多其他的激活函数,如身份,二进制步骤,逻辑,tanh,arctan,leaky relu,softplus 等等。

但是,为什么我们选择我们的输出只是一个单一的感知器,它有一个 sigmoid 函数呢?我们的分类任务是二元分类,这意味着只有两种可能的输出。真的,这可以用 0 到 1 范围内的单个值来表示;因此,我们只需要一个输出来表示它。如果输出值低于某个阈值(比如 0.5),我们将认为输出是正常的;否则,就是肺炎了。完美的预测将产生 1 或 0 的输出值(肺炎或正常);然而,这种情况很少发生。但是来自感知器的原始输出值也可以被认为是某样东西被认为是肺炎病例的概率(因为输出被限制在 0 和 1 之间),这对于给出图像是肺炎的“置信度”是有用的。如果您将此网络用于其他任务,如果类别数大于 2,您应该将密集输出的数量设置为您拥有的不同类别数。

最后一段代码由模型编译语句组成。该语句包含关于什么是模型的损失函数(loss='binary_crossentropy')、什么是用于规定其学习速率的优化器(optimizer='adam')以及在每个训练步骤上应该报告什么度量的信息(列表从metrics=['accuracy', ...]开始)。

损失函数被设置为binary_crossentropy。回想一下,损失函数用于量化神经网络的错误程度,更重要的是,它是确定神经网络如何在反向传播过程中调整其权重和偏差的关键组成部分。二元交叉熵公式如下:

$$ -\frac{1}{N}\sum \limits_{i=1}^N{y}_i\log \left(p\left({y}_i\right)\right)+\left(1-{y}_i\right)\log \left(1-p\left({y}_i\right)\right) $$

这看起来很复杂,但是它实际上是做什么的呢?假设神经网络最后一个感知器对某个样本输出 0.73。这个样本实际上是一个肺炎样本,所以如果我们的网络是最好的,它应该输出 1。显然,网络需要改进才能达到这一点,因此,应该告诉它有多“错误”。我们可以用二元交叉熵公式来计算。暂且忽略求和项,设p(yI)等于 0.73,设 y i 等于 1。另外,我们将使用基数为 2 的对数,而不是基数为 10 的对数。损失将会是$$ 1\ast \log (0.73)+\left(1-1\right)\log \left(1-0.73\right)=-0.31 $$。请注意,我们需要翻转符号,因为损耗通常是指应该最小化的正数,所以最终损耗是 0.31。让我们试试另一个例子。假设最后一个感知器输出的值为 0.73,但这是一个真正正常的图像。这真的很糟糕,因为在这种情况下,网络应该输出一个“0”。让我们看看损失会是什么:$$ 0\ast \log (0.73)+\left(1-0\right)\ast \log \left(1-0.73\right)=-1.309 $$。翻转符号,我们得到 1.309 的最终损失。

我们刚刚看到,与距离非常远的预测相比,距离非常近的预测产生的损失更低。这正是我们想要的行为,因为我们的神经网络旨在始终降低每批损失函数的输出。对于一批中的所有图像(由$$ \sum \limits_{i=1}^N $$项处理),我们简单地将它们的损失相加,取其平均值,并乘以-1(由$$ -\frac{1}{N} $$处理)。如果你要预测多个类别,你可以使用一个“类别交叉熵”损失函数,它与前面描述的损失函数非常相似。对于回归任务(例如,从 x 光片预测脊柱弯曲度),您可以使用均方误差损失(预测值和实际值的平方之差)。

现在让我们继续讨论优化器。我们使用的优化器叫做“Adam ”,是自适应矩估计的缩写。对于我们模型中的每个参数,它将基于参数是否与频繁特征(在这种情况下,它将保持低学习率)或不频繁特征(在这种情况下,它将保持高学习率)相关联来改变学习率(即,与参数相关联的权重或偏差将改变多少)。“Adam”优化器的工作原理以及它与其他优化器(如随机梯度下降、adagrad、rmsprop 等)的不同之处还有很多细微差别。;然而,这不值得深究(但如果你想看的话,这里的就是那些背后的所有数学)。一般来说,它被广泛使用,更重要的是,它在 Keras 库中很容易获得,所以我们只需要在编译模型时指定一行代码optimizer='adam'

最后,我们指定我们希望在每个训练步骤中跟踪的度量,然后从方法中返回模型。这些度量在 compile 方法的 list 参数中指定,如下所示:“准确性”(Keras 仅通过字符串本身即可识别)、召回率、精确度和 AUC。这些名称是不言而喻的,将只打印出模型中每个训练步骤的准确度、召回率、精确度和曲线下面积(对于 ROC 曲线)。对于最后三个指标,我们可以指定一个“name”参数,这个参数决定了它们在训练过程中如何显示出来。还要注意,最后三个指标都来自一个叫做tensorflow.keras.metrics的 Keras 子模块。

现在我们已经建立了 smallnet,离训练网络只有一步之遥了。在我们这样做之前,我们需要设置一些回调,这将允许我们监控网络的进展,保存其最佳训练周期,如果它没有改善就停止它,并将其踢出任何学习率停滞期。

回调:张量板、提前停止、模型检查点和降低学习率

我们将定义一个名为get_callbacks的方法,该方法返回一个回调列表,供以后在训练过程中使用。让我们看看这个方法是什么样子的:

输入【单元格= 8】

def get_callbacks(model_name):

  logdir = (
      f'logs/scalars/{model_name}_{datetime.now().strftime("%m%d%Y-%H%M%S")}'
  )
  tb = TensorBoard(log_dir=logdir)
  es = EarlyStopping(
        monitor="val_loss",
        min_delta=1,
        patience=20,
        verbose=2,
        mode="min",
        restore_best_weights=True,
    )
  mc = ModelCheckpoint(f'model_{model_name}.hdf5',
                      save_best_only=True,
                      verbose=0,
                      monitor='val_loss',
                      mode='min')

  rlr = ReduceLROnPlateau(monitor='val_loss',
                          factor=0.3,
                          patience=3,
                          min_lr=0.000001,
                          verbose=1)
  return [tb, es, mc, rlr]

get_callbacks方法只接受一个名为model_name的参数,这只是模型的名称,它将帮助我们保存一些与模型相关的文件(以确保它不会覆盖我们文件系统中存在的任何其他内容)。

我们要用的第一个回调是 TensorBoard 回调。TensorBoard 是一个可视化工具,我们稍后会看到。但它基本上允许我们通过给我们监控我们定义的各种度量标准的能力来查看训练过程进行得如何。图 6-4 展示了 TensorBoard 的样子。

img/502243_1_En_6_Fig4_HTML.jpg

图 6-4

张量板可视化

它可以在您开始训练过程之前或之后加载,方法是运行下面的单元(但在我们完成所有训练之前,我们不会这样做):

%load_ext tensorboard
%tensorboard --logdir logs

如果你注意到,在右上角,我们正在查看标量面板,它是我们拥有的所有不同指标的集合。TensorBoard 将自动读取目录(由--logdir FOLDERNAME指定,在本例中为logs)并尝试查找 TensorBoard 日志文件。只有在设置神经网络进行训练时将 TensorBoard 回调传递给神经网络,才会生成那些 TensorBoard 日志文件;否则,它就没有什么可情节的了。这些日志文件将包含训练和验证过程中每个时间点的所有指标(也称为标量)。TensorBoard 将绘制这些曲线供您查看(这有助于确定您的网络是否仍在改进)。

我们需要为 TensorBoard 回调指定的只是保存日志的目录的文件路径。由于我们可能会多次运行我们的网络,我们希望确保以前的运行不会被覆盖。我们可以通过在文件夹路径中包含网络运行的日期和时间来做到这一点。datetime库提供了一个名为datetime的子模块,上面有一个.now()方法,允许我们以 Python 对象的形式获取当前时间。我们可以通过调用datetime.now()上的.strftime()并传入下面的字符串"%m%d%Y-%H%M%S"来将其转换为一个字符串,该字符串是打印月(%m)、日(%d)和年(%Y)的简写,后跟一个“-”符号,然后是当前小时(%H,24 小时制)、分钟(%M)和秒钟(%S)。如果我在 7 月 26 日下午 12:24 又 36 秒运行这个程序,得到的字符串将是 07272021-122436。我们将这个值插入到通用字符串"logs/scalars/{model_name}_{the date + time}"中。

第二次回调是 EarlyStopping 回调。顾名思义,它将提前停止网络的训练(这意味着如果我们将网络设置为训练 100 个历元,那么如果满足某些条件,这个回调可能会在某个历元数结束时停止其训练)。我们为什么要这么做?对于我们的模型来说,防止过度拟合是非常有用的。如果我们监控验证损失,并且在 20 个时期内没有看到它提高 1%,这可能意味着网络已经完成了它所能学习的一切。monitor="val_loss"告诉回调密切关注验证损失。min_delta=1告诉回调,看看在patience=20时期之后,它是否提高了 1%(按照mode='min'指示的向下方向)。verbose=2用于在停止网络时提前打印出来。最后,我们将模型设置回最佳点(由restore_best_weights=True指定)。

第三个回调是ModelCheckpoint回调。这个回调将在每个时期将模型的一个版本保存到 Colab 的文件系统中(有一些警告)。第一个参数是模型文件名应该是什么(在这种情况下应该是'model_smallnet.hdf5',因为model_name将是“smallnet”)(请继续关注我们在哪里传递这个参数)。然而,我们还指定了一个save_best_only=True参数,这意味着我们将只在每个时期结束时保存模型,如果它击败了先前保存的模型。我们如何确定哪个模型胜出?我们查看验证损失(在参数monitor='val_loss'中指定),并将选择较低的那个(mode='min')。完成模型训练后,我们可以下载 model_smallnet.hdf5 文件,并将其重新上传到 Colab,或者在我们的本地机器上运行它。

最后一个回调是ReduceLROnPlateau回调。一般来说,学习率太高会导致网络无法收敛。在大多数情况下,Adam 优化器应该能够自己调整学习速率,但有时,它需要强制降低基线学习速率,以继续进一步优化并让网络继续学习。如果验证损失在 3 个时期(monitor='val_loss'patience=3)内没有变化,它将学习率降低 0.3 倍(factor=0.3),并且将继续这样做,直到学习率为 0.000001 ( min_lr=0.000001)。当它降低学习率时也会打印(verbose=1)。

现在我们已经定义了所有的回调,让我们开始定义培训是如何进行的!

定义拟合方法并拟合 Smallnet

我们将创建一个调用 make smallnet 方法和 get callbacks 方法的方法,同时训练网络。我们还将看到如何获得模型的摘要(即,它所拥有的所有层)以及所有模型层如何相互反馈的图表。这是该方法的定义。注意:我们稍后将回到这个方法,所以您需要在几个部分中编辑它(我通过在单元格编号旁边标注一个 WIP 标签来表示它是一个正在进行的工作)。

输入[CELL=9][WIP v1]

def fit_model(train_generator, validation_generator, model_name,
              batch_size=32, epochs=15):

  if model_name == 'smallnet':
    model = make_smallnet()

  model.summary()

  plot_model(model, to_file=model_name + '.jpg', show_shapes=True)

  model_history = model.fit(train_generator,
                            validation_data=validation_generator,
                            steps_per_epoch=train_generator.n/batch_size,
                            validation_steps=validation_generator.n/batch_size,
                            epochs=epochs,
                            verbose=1,
                            callbacks=get_callbacks(model_name))
  return model, model_history

这个方法所做的事情如下:

img/502243_1_En_6_Fig5_HTML.jpg

图 6-5

SmallNet 架构输出

  1. 它接受我们之前制作的训练和验证生成器。这些将在模型拟合过程中使用(数据将在训练生成器图像上进行训练;它将在验证生成器图像上的每个时期被评估)。

  2. 它检查我们传入的model_name是否等于'smallnet'。如果是,我们将调用make_smallnet()方法,该方法将编译后的 Keras 模型返回给我们(我们将把它存储在model中)。

  3. 然后,我们将打印出一个模型摘要(使用model.summary()),其中包含关于层数、每层大小以及要训练的参数数量的信息。

  4. 然后我们将调用一个方法plot_model(在tensorflow.keras.utils子模块中定义)并传入我们的model、我们想要保存plot_model结果的文件名(只是在模型名后面加上一个.jpg来表示它是一个图像文件)和show_shapes=True参数(这使得图表更有趣)。一旦我们调用它,这将产生如图 6-5 所示的模型(这将在 Colab 文件目录中;没看到就刷新一下)。

请注意它是如何包含我们指定的所有层的:三个卷积层、两个 max pooling 层、flatten 层和两个密集层。它还有一个由 Keras 添加的输入层,用于表示图像输入。

最后,我们调用model.fit,它实际上训练了我们的模型。它接受训练生成器和验证生成器(validation_data=validation_generator),还需要关于训练和验证所需步骤数量的信息。一个步骤被认为是通过一批图像的一次运行,因此所有的步骤将是批的数量。我们可以通过调用generator_name.n(获取图像总数)并用它除以batch_size(方法声明中的默认命名参数)来得到。steps_per_epoch等于训练生成器中的批次数量,validations_steps等于验证生成器中的批次数量。我们将verbose=1设置为获取训练的进度记录,并将回调设置为get_callbacks()方法调用的结果(它只返回我们想要跟踪的回调列表)。

model.fit调用返回模型的历史(这就是在每个时期训练和验证集的度量)。在方法的最后,我们返回训练好的模型(在model中)和它的历史model_history

现在我们已经指定了训练 SmallNet 网络的方法,让我们实际训练它。注意:运行下一个单元大约需要 30 分钟。

输入【单元格= 10】

small_model, small_model_hist = fit_model(train_generator,
                                          validation_generator,
                                          'smallnet', epochs=15)

输出

参见图 6-6 。

img/502243_1_En_6_Fig6_HTML.jpg

图 6-6

模型训练期间的输出

在这个输出中,我们可以看到一些有趣的事情。首先,在第一部分(在“纪元”之前),我们得到模型摘要(从model.summary()生成)。看起来我们之前对每层的参数数量和维度的计算是正确的!这个模型总结有助于确定您是否在正确的轨道上,并且正确地指定了您的模型。

看到这个之后,你会看到训练过程开始了。我们指定我们应该为 15 个纪元训练我们的网络。在每个时期内,有 117 个步骤,代表我们拥有的 117 个训练批次。在每个步骤之后,您将看到“丢失”和其他度量更新:这些是网络在看到每个步骤中的所有图像(即,一批)之后报告的训练度量。在每个时期(即,运行所有 117 个训练批次),您会看到打印出来的loss将开始随时间减少。在这里,我们看到它从 0.56 到 0.25,再到 0.1659。我们还看到训练集的准确率(打印文本中的accuracy)从 77.69%上升到 93.28%。您还可以看到我们指定的所有其他指标:召回率、精确度和 AUC。如果在 epoch 完成训练后向右滚动输出,您还会在验证集上看到那些相同的指标(这些指标前面有val_字符串)。如果您看到这些验证指标与训练指标相差甚远,这表明您的网络负荷过重。

您还会在前面的输出中看到,在 epoch 5,由于触发了 ReduceLRonPlateau 回调条件,我们将学习率降低到了 0.0003。这是因为验证损失val_loss处于平稳状态(从 0.43 到 0.20 到 0.24 到 0.21 到 0.25),而不是持续下降。这在第 14 纪元对我来说又发生了,但对你来说可能不会发生。学习率下降后,val_loss又开始下降,我还看到val_accuracy快速上升。

我们甚至可以通过以下方式启动 TensorBoard 来查看培训课程:

输入【单元格=可选但在 10 之后】

%load_ext tensorboard
%tensorboard --logdir logs

在培训课程结束时,指标如下:

  • 验证精度 : 0.9402

  • 验证召回 : 0.9608

  • 验证精度 : 0.9580

  • 验证 AUC : 0.9794

这些是我们自己定义的网络上的优秀统计数据!在我们开始评估这个模型的性能之前,让我们继续定义另一个可能击败我们 94%准确率的模型。

你的第二个卷积神经网络:用 VGG16 进行迁移学习

SmallNet 非常适合这项任务;然而,除了我们刚刚制作的相对简单的玩具之外,还有其他经过尝试和测试的神经网络架构。其中一种架构称为 VGG16。诚然,以深度学习的标准来看,它有点老了(它是在 2014 年制作的);然而,它在 ImageNet 分类挑战中击败了竞争对手(了解如何将 1400 万张图像分类到 1000 个类别)。VGG16 包含 13 个卷积层和 3 个密集层,总计超过 1400 万个参数用于训练。由于它在 ImageNet 竞赛中表现如此出色,很可能权重和偏见“学会”了如何提取突出的图像特征,这些特征可归纳到最初训练的 1000 个不同的类别中。因此,对我们来说,以某种方式利用现有的权重和偏差来使我们的分类任务更好可能是有用的,因为 VGG16 似乎在识别图像方面做得非常好。

然而,问题是我们不能直接使用 VGG16。不幸的是,ImageNet challenge 不包含任何使用 x 射线的训练图像,而且肯定没有肺炎与正常的课程。但是,也许有某种方法可以保留网络的主要卷积部分,只删除网络的最后一层,这一层(正如我们在 smallnet 讨论中提到的)是我们获取分类值的地方。正如 SmallNet 对每个类都有一个最后层的感知器(在本例中只有 1 个),VGG16 也有一组最后层的感知器(正好 1000 个)用于对 ImageNet 图像进行分类。如果我们只是从在 ImageNet challenge 上训练的 VGG16 模型中取出最后一个具有 1000 个感知器的密集层,我们可以利用网络内部的卷积权重(即,真正擅长提取图像特征进行学习的权重)。将先前训练的网络中的权重用于新目的的过程被称为“迁移学习”

  • 边注:另一种可能是采用 VGG16 架构,从头开始训练。不幸的是,它需要很长时间才能收敛到准确预测数据的点,并且更适合在更大的数据集上训练。正如我们将很快演示的那样,实际上根据预先存在的权重训练网络就足以获得非常好的准确度。

让我们看看如何做到这一点。在定义了make_smallnet()方法的单元格(单元格 7)后,插入以下make_VGGnet方法:

输入【单元格=单元格#7 后的新单元格】

def make_VGGnet():
  SIZE = 224
  m = VGG16(weights = 'imagenet',
                include_top = False,
                input_shape = (SIZE, SIZE, 3))
  for layer in m.layers:
    layer.trainable = False

  x = Flatten()(m.output)
  x = Dense(4096, activation="relu")(x)
  x = Dense(1072, activation="relu")(x)
  x = Dropout(0.2)(x)
  predictions = Dense(1, activation="sigmoid")(x)

  model = Model(inputs=m.input, outputs=predictions)
  ## Compile and run

  adam = Adam(learning_rate=0.001)
  model.compile(optimizer=adam,
                loss='binary_crossentropy',
                metrics=['accuracy', Recall(name='recall'),
                        Precision(name='precision'), AUC(name='auc')])
return model

一行一行地走

  • 我们在变量SIZE中定义将要接受的输入图像的大小,该变量将被传递给其他方法。我们将其保持在 224,因为这是 VGG16 模型的默认输入大小。

  • 然后我们通过调用VGG16实例化一个新的 VGG16 模型对象。我们指定我们想要使用来自 ImageNet 数据集的权重(weights='imagenet'),我们不想包含用于进行 1000 个分类的网络的最后一层(include_top=False),并且我们想要输入 224 x 224 x 3 个图像(input_shape = (SIZE, SIZE, 3),注意 3 代表图像具有 3 个颜色通道的事实)。我们将这个网络实例存储在变量m中。

  • 然后我们遍历网络中的所有层(for layer in m.layers),并将每层上的trainable属性设置为False。这将阻止在 ImageNet 竞赛中学习的权重被改变(也称为“冻结”权重),这正是我们想要的,因为我们需要保留 VGG16 提取图像特征的能力,就像它在 ImageNet 数据集所做的那样。

  • 然后,我们定义一些附加层添加到网络中。注意,我们做 smallnet 的时候是用model.add做的。在这种情况下,我想向您展示一种不同的方式,您可能会在网上更经常看到:

  • 首先,我们将通过调用Flatten() (m.output)来展平存储在m中的修改后的 VGG16 模型的输出层。这种语法可能看起来很奇怪,但 flatten 所做的实际上是返回一个接受单个输入的方法,即前一层的输出,以将该 Flatten 层附加到该层。我们将更新后的模型分配给变量x

  • 然后,我们将创建一个具有 4096 个感知器的密集连接层,该层附有一个 relu 激活函数,传入x以继续将该层添加到模型中(Dense(4096, activation="relu")(x))。我们将把这个值赋回给x

  • 同样,我们将制作另一个有 1072 个感知器的密集连接层。

  • 我们将添加一个“Dropout”层,它将在训练过程的每一步随机地从上一层到下一层随机地清除 20% ( Dropout(0.2))的输入权重。这有助于防止过拟合,因为每次训练步骤完成时,训练过程的某些部分会忽略一些权重(因为它们的权重被丢弃层设置为 0)。

  • 然后,我们将添加一个单一的感知器密集层,类似于我们对 SmallNet 的最后一层。我们把这个赋值给变量predictions

  • 最后(在编译模型之前),我们需要通过用输入大小(inputs=m.input)和输出(包含所有先前的层)实例化一个普通的Model对象来告诉 Keras 这是一个正常的模型。目前,我们将层存储在predictions中,所以我们将它传递给outputs=命名参数。

为了编译我们的网络,我们需要使用 Adam 优化器的非默认版本(只是向您展示如何在优化器上指定不一定是默认的参数)。我们使用来自tensorflow.keras.optimizersAdam优化器,并将其初始学习率设置为 0.001,得到最终的线adam = Adam(learning_rate=0.001)。我们向 model.compile 方法传递与 SmallNet 相同的损失和度量,并从该方法返回模型。

为了运行这个方法,我们需要稍微改变一下我们的fit_model方法,并且,当我们这样做的时候,让我们也给自己一个能力来获取我们生成的模型的训练版本,并且继续训练它:

输入【单元格=9】【最终】

def fit_model(train_generator, validation_generator, model_name,
              batch_size=32, epochs=15, model_fname=None):
  if model_fname == None:
    if model_name == 'smallnet':
      model = make_smallnet()
    if model_name == 'vgg':
      model = make_VGGnet()
    model.summary()
    plot_model(model, to_file=model_name + '.jpg', show_shapes=True)
  else:
    model = load_model(model_fname)

  ...# REST IS THE SAME!
  model_history = model.fit(train_generator,
                            validation_data=validation_generator,
                            steps_per_epoch=train_generator.n/batch_size,
                            validation_steps=validation_generator.n/batch_size,
                            epochs=epochs,
                            verbose=1,
                            callbacks=get_callbacks(model_name))
  return model, model_history

首先,我们指定了一个名为model_fname的新方法参数。此参数将接受到定型模型的字符串文件路径。如果没有指定,它的缺省值是 none,我们在方法的开始进入if else分支的第一部分。在这里,我们添加了另一个if语句来检查model_name是否是‘vgg’(如果是,我们调用刚刚创建的 make VGG 方法)。

如果我们碰巧有一个我们已经训练过的模型,我们想进一步训练它,我们可以通过给model_fname指定一个参数来实现。回想一下,每当我们的ModelCheckpoint回调触发时(即,每当我们有一个“最佳”时期时),我们生成一个文件名为model_INSERTMODELNAME.hdf5的预训练模型。如果您检查您的 Colab 笔记本文件窗格,您应该看到您有一个model_smallnet.hdf5

现在我们实际上已经设置了我们的 vgg 网络,从我们刚刚编辑的fit_model方法调用,让我们调用它

输入【单元格= 11】

vgg_model, vgg_model_hist = fit_model(train_generator,
                                      validation_generator,
                                      'vgg', epochs=15)

输出

Model: "model_2"
_______________________________________________________________
Layer (type)                 Output Shape              Param #
===============================================================
input_6 (InputLayer)         [(None, 224, 224, 3)]     0
_______________________________________________________________
block1_conv1 (Conv2D)        (None, 224, 224, 64)      1792
_______________________________________________________________
block1_conv2 (Conv2D)        (None, 224, 224, 64)      36928
_______________________________________________________________
block1_pool (MaxPooling2D)   (None, 112, 112, 64)      0

...LOTS OF OTHER LAYERS THAT ARE OMITTED....
===============================================================
Total params: 121,872,289
Trainable params: 107,157,601
Non-trainable params: 14,714,688
_______________________________________________________________

...EPOCHS ALSO OMITTED

哇,我们有超过 1.07 亿个参数要训练。不可训练参数是我们在每个原始 VGG16 层上调用layer.trainable = False时冻结的权重。剩下的 107,157,601 个参数是我们在密集层中定义的(几个密集层很快就增加了几百万个参数!).

如果我们观察训练进度,我们会发现它往往进行得更顺利一些(只触发 ReduceLRonPlateau 回调一次),而且我们通常会看到更高的精度和 AUC,这很好!我得到了 96.58%的最终验证准确率,比 93.28%有所提高。

在深入研究评估指标之前,让我们比较一下这两个网络的培训过程。

如果您不能使用 TensorBoard(它有时会出错),您可以使用以下方法绘制出两个网络的训练与验证准确性和损失的历史记录:

输入【单元格= 12】

def plot_history(history):

    fig = plt.figure(figsize = (18 , 6))

    fig.add_subplot(1,2,1)
    plt.plot(history.history['loss'])
    plt.plot(history.history['val_loss'])
    plt.title('model loss')
    plt.xlabel('epoch')
    plt.ylabel('loss')
    plt.legend(['train loss', 'valid loss'])
    plt.grid(True)
    plt.plot()

    fig.add_subplot(1,2,2)
    plt.plot(history.history['accuracy'])
    plt.plot(history.history['val_accuracy'])
    plt.title('model accuracy')
    plt.xlabel('epoch')
    plt.ylabel('accuracy')
    plt.legend(['train acc', 'valid acc'])
    plt.grid(True)
    plt.plot()

我们在这里所做的就是定义一个名为 plot history 的方法,该方法接受一个模型训练历史,它是我们从model.fitfit_model()返回的值之一。我们创建一个图形,并添加一个支线剧情(将有两个图像,安排在一行两列)。第一幅图像将绘制history.history['loss']history.history['val_loss']阵列,它们是每个时期结束时的训练和验证损失。我们将该图命名为“模型损失”,并将 x 轴标签设置为“时期”,将 y 轴标签设置为“损失”我们还给出了每条线的图例:第一条线将被称为“列车损失”,第二条线将被称为“有效损失”(matplotlib 根据用plt.plot()绘制线的顺序知道哪个图例项与哪条线匹配)。最后,我们选择显示一个网格并绘制数据。我们对另一个图重复相同的过程,但这样做是为了训练准确性和验证准确性。我们来看看调用后的结果是什么:

输入【单元格= 13】

plot_history(small_model_hist)

输出

参见图 6-7 了解 SmallNet 的模型历史图。

img/502243_1_En_6_Fig7_HTML.jpg

图 6-7

SmallNet 模型历史

输入【单元格= 14】

plot_history(vgg_model_hist)

输出

参见图 6-8 获取 VGG16 的模型历史图。

img/502243_1_En_6_Fig8_HTML.jpg

图 6-8

VGG16 车型培训历史

我们可以看到,VGG 网络实际上似乎在纪元 11 前后经历了一段困难时期。这可能与学习率的下降相一致。然而,我们可以看到验证和准确性曲线彼此相对紧密地跟随,这表明到目前为止我们还没有过度拟合。

现在我们已经查看了训练曲线,让我们看看网络在使用 Grad-CAM 技术评估图像时实际关注的是什么。

使用 Grad-CAM 可视化输出

Grad-CAM 是一种算法,允许我们生成热图,显示图像的哪些部分对网络的最终分类决策贡献最大。基于 Grad-CAM 图像并观察它们如何工作,可以获得大量的直觉。事不宜迟,我们开始吧。

我们将使用一个名为 VizGradCam 的库。不幸的是,它在 pip 包管理系统上不可用,所以我们需要直接从作者的 GitHub 页面下载(这是一个托管任何人都可以查看的代码的网站,也称为开放源代码)。我们将使用下面的块来实现这一点:

输入【单元格= 13】

!git clone https://github.com/gkeechin/vizgradcam.git
!cp vizgradcam/gradcam.py gradcam.py

输出

Cloning into 'vizgradcam'...
remote: Enumerating objects: 64, done.
remote: Counting objects: 100% (64/64), done.
remote: Compressing objects: 100% (57/57), done.
remote: Total 64 (delta 30), reused 24 (delta 7), pack-reused 0
Unpacking objects: 100% (64/64), done.

如果你看到了,你就万事俱备了。我们已经下载了 VizGradCam 代码,取出了包含我们需要的功能的文件,并将其复制到我们的主目录中。

接下来,我们将定义一个方法,允许我们获得 Grad-CAM 输出,并打印出我们的预测和它们的置信度。

输入【单元格= 14】

from gradcam import VizGradCAM

def display_map_and_conf(model, test_generator):
  imgs = test_generator.next()
  fig = plt.figure(figsize=(15,5))

  for i in range(3):
    fig.add_subplot(1,3,i+1)
    image = imgs[0][i]
    label = 'PNEUMONIA' if imgs[1][i] == 1 else 'NORMAL'
    VizGradCAM(model, image, plot_results=True, interpolant=0.5)
    out_prob = model.predict(image.reshape(1,224,224,3))[0][0]
    title = f"Prediction: {'PNEUMONIA' if out_prob > 0.5 else 'NORMAL'}\n"
    title += f"Prob(Pneumonia): {out_prob}\n"
    title += f"True Label: {label}\n"
    plt.title(title)

这个方法将接受一个经过训练的模型和一个测试生成器(我们在前面已经定义了)。它将从测试生成器(test_generator.next())中抓取一批图像,然后创建一个图形供我们绘制 Grad-CAM 结果。

对于我们的图像批次(存储在imgs中)中的前三个图像(for i in range(3)),我们将为我们的图形添加一个子图(从 1 开始计数),然后从我们的批次中抓取图像(imgs[0][i]包含该批次的第 i 个图像)和相关联的标签(包含在imgs[1][i]中);如果标签== 1 或“正常”,我们也将标签重新编码为“肺炎”,否则有助于解释)。

接下来,我们从库中调用 VizGradCam 方法(我们使用from gradcam import VizGradCAM将其导入到文件的顶部),传入我们训练的模型、我们想要可视化的图像以及两个命名的参数plot_results(将结果绘制到 matplotlib)和interpolant(确定覆盖的相对亮度)。

接下来,我们得到图像是肺炎的概率的预测。我们通过使用名为model.predict的方法并传入图像来实现这一点。然而,我们的模型通常期望图像是批处理的形式,所以我们需要将图像整形(通过调用image上的.reshape)成 1 x 224 x 224 x 3 的图像(“1”指的是批处理大小)。预测调用的结果是嵌套在一个数组中的一个数组的概率,我们可以通过调用model.predict(...)[0][0]得到它(其中第一个[0]将我们带到第一个数组,第二个[0]将我们带到嵌套的数组)。

接下来,我们将通过对三个单独的字符串进行字符串插值来制作图表的标题。我们通过给变量 title 加上一个title +=来将每个字符串相互追加,这相当于做title = title + ...(还有一个小的旁注:\n是一个特殊的字符,用来换行)。对于我们的预测类,如果某个事物的概率为> 0.5,我们将其定义为肺炎;否则就不是肺炎了。

我们将该方法称为:

输入【单元格= 15】

display_map_and_conf(vgg_model, test_generator)

输出

VGG16 的 Grad-CAM 结果参见图 6-9 。

img/502243_1_En_6_Fig9_HTML.jpg

图 6-9

Grad-CAM 结果 VGG16

输入【单元格= 16】

display_map_and_conf(small_model, test_generator)

输出

SmallNet 的 Grad-CAM 结果参见图 6-10 。

img/502243_1_En_6_Fig10_HTML.jpg

图 6-10

Grad-CAM 结果小型网络

红色区域是网络关注较多的区域,蓝色区域是网络关注较少的区域。总的来说,我们可以看到 vgg 网络倾向于聚焦于解剖结构,例如第一幅图像中的胸膜。然而,它也集中在第二个图像中的心脏。在最后一张图中,它聚焦于肺部的不同肺叶,但也有边缘的随机部分。smallnet 似乎将注意力集中在第一幅图像的边缘区域,第二幅图像左上角的标签上写着“A-P ”,第三幅图像的肋骨。

总的来说,VGG 似乎在关注更符合解剖学的结构;然而,在这些热图能够被用来实际帮助放射科医师建议他们应该关注的领域之前,似乎需要更多的培训。然而,特别值得关注的是,SmallNet 聚焦于图像中的 A-P 标签,这可能表明 x 射线图像中出现的字母标签的某些内容可能在提示网络确定分类,而不是解剖结构本身。看到这些差异就是为什么运行 Grad-CAM 很重要!现在开始网络评估。

评估 SmallNet 与 VGG16 的性能

当我们在上一章中制作基本决策树分类器时,我们能够使用 scikit-learn 获得一些关于模型性能的统计数据,如精度、召回率、准确度、AUC 和 ROC 曲线。

由于我们需要获得两个网络的这些统计数据,我们将只创建一个方法来自动打印这些统计数据并显示相关的图表:

输入【单元格= 17】

from sklearn.metrics import (roc_curve, auc, classification_report,
RocCurveDisplay, confusion_matrix, ConfusionMatrixDisplay)

def get_stats(model, generator, model_name):
  preds = model.predict(generator)
  pred_classes = [1 if x >= 0.5 else 0 for x in preds]
  true_vals = generator.classes
  print("CLASSIFICATION REPORT")
  print(classification_report(true_vals, pred_classes, target_names=['normal','pn']))
  cm = confusion_matrix(true_vals, pred_classes)
  disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['normal','pn'])
  disp.plot(values_format='')
  fpr, tpr, thresholds = roc_curve(true_vals, preds)
  auc_val = auc(fpr, tpr)
  print("AUC is ", auc_val)
  display = RocCurveDisplay(fpr, tpr, auc_val, model_name)
  display.plot()
  plt.show()

这个名为get_stats的方法接收经过训练的模型、一个运行这些统计数据的生成器(测试生成器),以及将用于绘制 ROC 曲线的模型名称。

该方法的第一行在传入的生成器(这将是我们的测试生成器)上获得模型的预测。这些预测值的范围是 0–1。对于我们的一些统计数据,我们需要将这些连续的十进制值强制归入某一类,因此(在第二行中),我们使用一个 list comprehension 语句来循环所有的预测概率。如果预测概率> = 0.5,那么我们就说是 1 类(也就是我们的肺炎类);否则就是 0 类。我们将这些预测类存储在pred_classes变量中。对于我们的度量,我们还需要生成器中每个值的真实类,可以使用generator.classes来访问。

接下来,我们将显示一个分类报告。首先,我们向用户打印出一份分类报告。接下来,我们打印出 scikit-learn 度量子模块中的classification_report方法的结果。这个方法调用接收真实的类标签、预测的类标签和类名(一个列表)。

我们还将使用confusion_matrix方法生成混淆矩阵(也来自 scikit-learn 的度量子模块)。混淆矩阵方法接受真实的类标签(存储在true_values中)和我们预测的类(存储在pred_classes)。然而,如果我们想真正看到混淆矩阵图(而不是真阳性、真阴性、假阳性和假阴性的数量),我们需要实例化一个ConfusionMatrixDisplay对象(也来自 scikit-learn metrics),传入混淆矩阵调用的结果和一个参数display_labels以及我们的类名。我们将这个 ConfusionMatrixDisplay 调用的结果存储在变量disp中,然后调用disp.plot传入参数values_format=''以确保我们打印出原始数字(默认情况下以科学记数法打印数字)。

接下来,我们要打印出 AUC 和 ROC 图。为此,我们将首先从 scikit 调用roc_curve方法,传入真实的类别标签(true_vals)和存储在preds中的预测概率(因为 ROC 曲线生成过程需要访问原始类别概率)。roc_curve方法返回三个值,一个假阳性率列表,一个假阴性率列表,以及一个与这些率中的每一个相关的概率截止阈值的列表。我们可以使用auc方法(从roc_curve调用传入fprtpr变量)获得 AUC,然后可以打印出结果 AUC 值。最后,我们调用RocCurveDisplay方法来生成 ROC 图。该方法只需要假阳性率和真阳性率列表、AUC 值和模型名称(将显示在 AUC 旁边的图例项中)。然后我们对这个调用的结果调用.plot()并显示最终的图。

现在我们已经设置了打印评估指标的方法,让我们实际调用方法。为了给我们自己最好的评估指标,让我们使用验证损失最低的模型。在大多数情况下,那应该是你拥有的当前模型变量(vgg_modelsmall_model);然而,在某些情况下,最新的模型状态可能不是最好的。然而,回想一下,我们设置了一个ModelCheckpoint回调,它只在每个时期保存一个模型,如果它击败了前一个时期。这意味着我们已经将最好的模型保存到了 Colab 文件系统中,只需要将它加载到内存中!让我们在下面的代码块中实现这一点:

输入【单元格= 18】

small_model_ld = load_model('model_smallnet.hdf5')
vgg_model_ld = load_model('model_vgg.hdf5')

load_model方法来自 tensorflow.keras.models 子模块,并接受一个指向您想要加载的模型的文件路径。如果你回头看看我们的 ModelCheckpoint 定义,它将模型保存在名称model_{model_name}.hdf5下,所以我们只需要传入model_smallnet.hdf5来加载最好的 SmallNet 模型,传入model_vgg.hdf5来加载最好的 VGG16 模型。

我们可以得到如下的模型统计数据:

输入【单元格= 19】

get_stats(vgg_model_ld, test_generator, "vgg")

输出

VGG16 的分类报告参见图 6-11 。

img/502243_1_En_6_Fig11_HTML.jpg

图 6-11

VGG16 分类报告

输入【单元格= 20】

get_stats(small_model, test_generator, "smallnet")

输出

SmallNet 的分类报告参见图 6-12 。

img/502243_1_En_6_Fig12_HTML.jpg

图 6-12

小型网络分类报告

从该报告中我们可以看出,VGG 网络具有更高的准确度(0.96 对 0.93)、“pn”(肺炎)类别的精确度(0.98 对 0.97)、“pn”类别的召回率(也称为敏感度)(0.96 对 0.93)。VGG 网络的 AUC 为 0.988,略高于 SmallNet 网络的 AUC(0.978)。总的来说,我们会选择 VGG 网络用于将来的使用,因为它在我们的用例中展示了更好的整体统计数据。然而,早期的 Grad-CAM 结果可能会让我们暂停一下,以确定它是否真的具有普遍性(因为除了相关的解剖位置之外,VGG 还关注图像的边缘)。

在这一点上,最好下载你的两个网络。它们将位于 Colab 的 file 选项卡中,并以文件扩展名.hdf5结束(这是 Keras 用来存储模型权重的)。smallnet 模型文件的大小大约为 130 MB(不算太大);然而,VGG16 型号的大小为 1.3 GB,这是巨大的!一般来说,随着网络规模的增加,包含该网络权重的文件大小也会增加(因此,将所有内容加载到计算机内存中需要更长的时间/如果文件非常大,可能会超过计算机的内存限制)。

现在我们已经完成了正式的评估,让我们看看如何在不定义生成器的情况下使用我们的模型。

评估“外部”图像

下面是一个自封闭的代码块,它可以让您输出对任何图像的预测,只要图像被上传到 Colab 文件系统:

输入【单元格= 21】

from tensorflow.keras.preprocessing.image import load_img, img_to_array
from tensorflow.keras.models import load_model
import numpy as np

def predict_image(model, img_path):
  img = img_to_array(load_img(img_path, target_size=(224,224,3)))
  img = img * (1./255)
  img = np.expand_dims(img, axis=0)
  pred = model.predict(img)
  label = 'PNEUMONIA' if pred >= 0.5 else 'NORMAL'
  print("prediction: ", label, "P(Pneumonia): ", pred[0][0])

其中一些导入语句是我们已经导入的,但我只是将它们放在这里,以防您想跳到笔记本的末尾并运行一个经过训练的模型。该方法的第一行将加载一个图像,并将其转换为一个数组,该数组具有我们网络的目标大小(在本例中为 224x224x3)。然后,我们需要将该图像乘以 1/255,因为我们在训练过程中这样做了,并且我们的网络已经基于这些重新缩放的图像进行了学习。然后,我们将扩大图像的维度,以模拟我们的网络的自然输入,这是一个批处理。通过调用np.expand_dims(img, axis=0),我们创建了一个只有一张图片的假批处理。此时,可以将图像输入到model.predict方法中。我们获得预测概率并将其存储在pred中,然后创建一个可解释的标签(根据概率是否为> = 0.5,可以是肺炎,也可以是正常)。最后,我们将这些预测打印到屏幕上。

为了执行该方法,我们运行以下代码:

输入【单元格= 22】

m = load_model('model_smallnet.hdf5')
predict_image(m, 'chest_xray/train/NORMAL/IM-0410-0001.jpeg')

输出

prediction:  NORMAL P(Pneumonia):  0.0043188934

我们需要首先将模型加载到内存中(因此,如果断开连接,您需要将它上传回 Colab 笔记本)。然后我们调用predict_image方法,传入加载的模型和图像路径。

就这样,我们结束了!

需要改进的地方

我们的模型绝不完美(正如我们从 Grad-CAM 结果中看到的)。此外,Kaggle 比赛中使用胸部 x 射线图像的一些参赛者指出,测试数据集有一些错误标记的图像,可能会影响我们的整体准确性统计数据。此外,我们还发现,我们的训练和测试集存在类别不平衡,肺炎图像越来越多。在真实的临床环境中,更可能的是,我们有多个正常的 x 射线图像,但没有很多肺炎图像。我们可以通过向我们的fit_model方法中的model.fit调用传递一个class_weights参数来调整这种类不平衡(您应该查看 Keras 文档了解如何做到这一点!).“类权重”方法会为代表不足的类赋予更大的权重,因此网络表现为好像两个类在数据集中具有相似的分布。

为了解决我们的 Grad-CAM 问题,我们可以尝试继续训练我们的网络,如下所示:

vgg_model_cont, vgg_model_hist_cont = fit_model(train_generator,
    validation_generator, 'vgg', epochs=100, model_fname='model_vgg.hdf5')

这将继续训练我们存储在model_vgg.hdf5中的模型版本,再训练 100 个时期。我们可以持续更长时间;但是,如果您长时间使用 Colab 环境,Colab 将随机停止执行(免费层一次只允许 12 小时的计算)。此外,如果我们的验证损失没有减少,我们的EarlyStopping回调将停止训练,确保我们没有过度适应。

还有一些额外的“愿望清单”项目需要花费相当多的时间来实现,但是对于医疗领域的使用是必要的。首先,我们的输入图像是。jpg 文件;然而,大多数 x 射线文件是以一种称为“DICOM”图像的格式保存的。有 Python 库,比如pydicom,可以让你读取原始的 DICOM 文件。然而,将这些图像转化为可用图像的过程并不简单,因为 DICOM 图像包含的像素值的亮度在 10,000+值范围内,远远超过任何常规图像的 255 max。我们可以使用类似下面的代码行将 DICOM 图像转换成 png,然后在我们的训练管道中使用:

import pydicom
from PIL import Image
import numpy as np
import cv2

def read_dicom_to_png(image_path):
    ds = pydicom.dcmread()
    img = np.array(Image.fromarray(ds.pixel_array))
    mean, std = img.mean(), img.std()
    img = np.clip((img-mean)/std, -2.5, 2.5)
    img = ((img - img.min()) * (1/(img.max() - img.min()) * 255)).astype(np.uint8)
    img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    cv2.imwrite('output.png', img)

粗略地一行一行,我们使用pydicom.dcmread()读入图像。然后,我们将结果像素值(存储在ds.pixel_array中)转换成一个图像对象,并将其转换成一个数组(使用np.array(Image.fromarray(...)))。我们需要完成这一步,因为 Python 中的图像操作库本身并不理解 dicom 像素值。然后,我们得到图像的平均值和标准决策(使用img.mean()img.std())并“裁剪”(即设置边界)任何高于/低于平均值 2.5 个标准偏差的像素值(使用np.clip)。然后,使用我们方法的第五行中的公式,将这些值映射到范围 1–255。然后,我们将使用cv2.cvtColor将图像转换为三通道彩色图像。最后,我们将使用cv2.imwrite将映像写入磁盘。

还有其他需要注意的图像格式(比如 NifTI 图像);但是,它们都将遵循相同的一般步骤,将图像转换为可用于训练网络的内容(例如,使用格式阅读器库加载特定格式的图像,转换为图像数组,裁剪值,重新缩放为 1–255,转换为彩色图像,保存)。

最后,另一个“愿望清单”项目是获得放射科医师对网络预测准确性的意见。理想情况下,如果我们有一组放射科医生也在查看这些图像,我们可以评估评分者之间的可靠性(通过 Kappa 统计),以确定放射科医生之间是否一致,以及这些结果是否与网络预测一致。

概述

在这一章中,我们已经取得了巨大的进步。在设置这一切的过程中,我们学习了如何加载图像数据,清理它,并使用生成器来增加我们的图像数据。然后我们从头开始构建一个卷积神经网络,在这个过程中实现我们在第四章中介绍的概念。通过构建 SmallNet,我们看到了卷积运算对我们的图像大小和我们学习的参数总数的影响。我们还探讨了损失函数和激活函数等概念。然后,我们演示了如何使用回调来监控我们的培训过程,并改变学习率等事情,以鼓励进一步的培训进度,甚至完全停止培训过程。然后,我们继续使用迁移学习将 VGG16 从检测 1000 个不同的图像类别重新用于我们的肺炎分类任务。最后,我们训练了这些网络,并评估了它们的准确度、精确度、召回率和 AUC,以及它们如何“思考”(即,用 Grad-CAM 可视化输出)。

现在,我们已经确切地知道了这些卷积神经网络是如何创建的,以及它们实际上可以做什么,让我们讨论一下 ML 的其他领域,供您自行学习,在医疗领域实现 AI 算法时需要牢记的一般注意事项,以及如何自行继续学习。

七、医疗保健和人工智能的未来

在过去的几个章节中,我们已经浏览了构成“人工智能”的代码,但是所有这些内容只是 ML/AI 世界的一个小样本。尽管您用来实现这些算法的许多工具是相同的(比如 scikit-learn、Keras 和 TensorFlow),但是根据任务的不同,实现会有很大的不同。然而,我们为制作深度学习模型而设置的通用结构(即,让生成器➤定义模型➤定义回调➤训练)确实适用于许多不同的基于深度学习的任务。由于我们没有时间在这一章中谈论所有的事情,我们将讨论如何开始你自己的项目,如何理解错误,以及当你遇到这些错误时该怎么做。

跳出与编码直接相关的概念领域,我们将用三个与在现实世界中开发和部署这些算法时应该考虑的概念相关的部分来结束本章。如今,如何保护患者隐私等概念非常重要,尤其是在医学成像任务需要大量数据才能正确运行的情况下。相应地,我们需要确保人们的信息在这个过程中得到保护/他们同意他们的信息可以用于算法的训练。然后,我们将继续讨论医学领域中与 ML 相关的一些警告,包括如何识别“人工智能蛇油”和防止算法偏差(这是一个描述程序如何证明对一些个人有害的概念)的一般指南。最后,如果你选择从事人工智能研究并将训练好的模型部署到现实世界中,我们将讨论一些你应该报告的事情。

开始你自己的项目

这本书里的大部分内容不仅仅在书本身里有。我在这里列出的一切都来自代码文档、在线问答论坛、从事人工智能工作的公司编写的教程/指南以及我以前的一些课程。不用说,实际上创建真正“原创”的代码是非常困难的(也可以说是毫无意义的)。你打算做的大多数事情都会以某种形式存在于网上;然而,这取决于你是否能在网上找到什么样的工具,以及这些工具如何适应你要解决的问题。

就拿胸透分类问题来说。如果你在网上查找如何使用人工智能在胸部 x 光图像中检测肺炎,你很可能会被导向一些研究论文和数据集所在的 Kaggle 竞赛。你可以浏览一些比赛代码(在大多数 Kaggle 比赛的“代码”标签下可以找到);然而,您可能会发现自己费力地阅读大量文档记录不良的代码,这些代码相当不透明且难以阅读。

相反,你可以试着概括你的问题。我们的输入图像是胸部 x 光片,这一事实可以说没有什么特别的,我们想知道这是不是肺炎。相反,我们可以概括地说:“我想要某种 ML/AI 算法,它可以接受一幅图像并输出一个分类。”如果你在谷歌上输入“ML/AI 图像分类教程 keras ”,你会得到很多有据可查的代码示例和教程,指导你如何制作一个分类器神经网络。如果您在搜索查询中添加“迁移学习”,您可能也会找到关于 VGG16 的内容。如何 a)选择你想学习的教程,b)修改教程代码以适应你的目的,这完全取决于你自己。不管怎样,关键的教训是概括你的问题可以让你找到适合你的用例的东西。

排除故障

好了,现在让我们假设你已经找到了一个向你展示如何使用 VGG16 对图像进行分类的教程,你开始编写代码。然而,当您开始运行时,您开始看到包含单词“error”的消息被打印出来。让我们看一个错误可能是什么样子的例子:

in0 ut

def make_network(network_name):
    print("making model", networkname)

make_network('vgg')

输出

---------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-1-699ebc7cd358> in <module>()
      2     print("making model", networkname)
      3
----> 4 make_network('vgg')

<ipython-input-1-699ebc7cd358> in make_network(network_name)
      1 def make_network(network_name):
----> 2     print("making model", networkname)
      3
      4 make_network('vgg')

NameError: name 'networkname' is not defined

这条信息似乎相当神秘和令人困惑。然而,让我们来看看这个错误消息告诉我们什么。一般来说,从 Python 中读取错误的最佳方式是从底层开始。在这种情况下,“名称错误:未定义名称‘网络名称’。”好的,所以 Python 似乎认为变量“networkname”不存在(即,它没有被定义)。好的,但是我们不是通过向make_network方法传递一个值来定义它吗?好的,让我们看看“NameError”行上面的下一行。嗯,看起来我们的声明中提到了一个网络名称,但是等等,看起来networknamenetwork_name不一样。看来我们打错字了!如果我们进一步查看错误消息,我们将确切地看到是什么方法调用导致了错误的发生(在本例中,是对make_network('vgg')的调用)。但是,因为我们知道有一个错误是由于打字错误,我们可以找到我们的方法如下:

def make_network(network_name):
    print("making model", network_name)

make_network('vgg')

一切都会按计划进行。

也有一些情况下,自己解释错误消息变得非常困难,甚至不可能。此时,你最好求助于谷歌,尤其是一个名为 StackOverflow 的网站。然而,确实需要一点点的实验来达到你可以在谷歌中输入你的错误,并得到一些有意义的东西。以下是一些关于如何格式化问题的通用指南:

  1. 删除所有特定信息。如果我们在 Google 中键入“name error:name ' network name ' not defined ”,我们可能会从处理互联网网络而不是神经网络的堆栈溢出中获得许多“假阳性”结果。这是因为我们的查询太具体了。其他程序员想出完全相同的变量命名方案并弹出完全相同的名称错误的可能性非常低。相反,如果您键入“NameError: name is not defined”,您可能会得到更多的结果,因为所有的 NameError 消息都有相同的单词。

  2. 如果您认为您正在使用的库是相关的,那么也在您的查询中键入它。在这种情况下,我们没有使用任何特殊的库或者在我们的库上调用任何方法,所以我们添加东西没有任何意义。但是,如果我们在运行 scikit-learn 函数后遇到任何其他错误,最好将您的查询格式化为“这里是一般错误消息,scikit learn”

  3. 如果你不能更早地找到任何东西,你可以打开一个关于栈溢出的新问题,希望有人注意到并回答它。为此,您应该打开一个堆栈溢出帐户并提出一个问题。在问题正文中,您应该提供尽可能多的关于您正在运行的脚本的信息,您为调用该脚本/方法做了什么,以及您用来帮助其他人重现问题的任何其他数据。您还应该粘贴弹出的确切错误消息。这些论坛上的大多数人都想帮助别人,但是他们需要提问者做好他们份内的工作,提供足够的信息,这样就没有人会浪费时间来回寻找更多的信息。

在最糟糕的情况下,你可能会发现自己在找出问题所在时没有任何帮助。尽管这种情况很少发生(这更能说明一个事实,那就是你很难用一种能返回搜索结果的方式来表达你的问题),但这仍然是你应该知道如何处理的事情。当我发现自己处于这种情况时,我总是发现重新编码方法或代码段,使用不同的变量名,并且一次只进行一段是非常有益的。它迫使你后退一步,一行一行地检查一切。

还可能出现一些更隐蔽的错误,例如在训练或评估 ML 算法时出现的错误。例如,当我编写代码来评估 SmallNet 和 VGG16 时,我不断获得精度和 AUC,它们与网络最后一个时期的验证 AUC/精度相差甚远(验证 AUC 为 0.97,最终 AUC 为 0.54)。我的堆栈溢出查询包括以下内容:

  • " AUC 计算 sklearn 与 Keras "

  • " Keras 验证 AUC 不同于 sklearn AUC "

  • “测试生成器 AUC Keras 远离验证”

是最后一个问题让我找到了问题的真正解决方案。但我的前两个查询是由最初的想法激发的,即 Keras 在回调指标中计算的 AUC 使用的算法与我们的评估方法中调用的 scikit-learn 计算的 AUC 不同。虽然这些搜索确实产生了一些信息,表明 Keras 和 scikit 实现 AUC 计算的方式不同,但我没有看到任何东西可以解释我所注意到的 AUC 的巨大差异。然后,我发现我使用的评估代码使用了测试生成器,并决定研究生成器是否会打乱数据。这可能导致我们的 AUC 下降到 0.54(相当于随机猜测)。运行的假设是,当我传入测试生成器并调用 model.predict 方法时,测试生成器可能已经打乱了值,然后,当我获得实际的类名时,我将获得新打乱的类名,而不是与我想要预测的图像相关联的原始类名。果然,我遇到了一个堆栈溢出答案,上面写着“确保在生成器上设置了 shuffle=False。”原来生成器默认启用了一个shuffle=True参数,我忘了在代码中的测试集上设置shuffle=False。一旦我这样做了,评估结果与验证结果相匹配!

这是一个逻辑错误的例子。这种错误可能不会产生任何实际的程序错误,但会产生意想不到的结果。处理这些错误的最好方法是从最可能的假设开始,并开始深入越来越具体的假设。同样,重新实现您的代码可能会有所帮助,但是浏览您调用的所有方法的文档也会非常有用。

说到浏览文档,这里有一个你可能会看到的例子(参见图 7-1 )。

img/502243_1_En_7_Fig1_HTML.jpg

图 7-1

文档来自 https://keras.io/api/preprocessing/image/#flowfromdataframe-method

在这里,您可以看到一个如何调用该方法的示例和默认参数列表(列出了具有keyword=value对的任何参数)。如果您没有为这些默认参数手动指定一个新值,程序会认为您想要使用默认值。在代码之后,将会有一个对该方法的简短描述,后面是带有相关描述的参数列表。如果你幸运的话,你可能会发现一些库的作者甚至给出了使用该方法本身的示例代码。

在一个库的文档很少的情况下,其他人可能已经使用过这个库。您可以利用 GitHub(一个托管开源代码的网站)的力量来搜索人们以前对该代码的使用情况。只需在搜索栏中键入方法名称,然后单击结果的“代码”选项卡。任何包含该短语的公开代码都会出现在代码结果中,您可以看到他们实际上是如何使用它的。在这种情况下,我们看到在 GitHub 上查询flow_from_dataframe的结果是与名为Medical_Image_Analysis的存储库(也称为项目)相关联的代码,这可能会让我们更多地查看他们的代码以获得灵感。请参考图 7-2 中您可能会看到的一些示例搜索结果。

img/502243_1_En_7_Fig2_HTML.jpg

图 7-2

GitHub 代码搜索结果示例

如果你真的被卡住了,你也可以尝试复制代码,但是只有在你真正理解发生了什么的情况下才这样做。此外,如果您打算从 GitHub 获取一些代码,请先访问该库,并检查一个名为“LICENSE.txt”的文件,该文件将准确概述您可以使用该代码做什么,以及您有什么权利复制它(如果有的话)。

既然我们已经花了一些时间在杂草中弄清楚如何自己制作项目并学习如何调试,那么让我们开始考虑人工智能的含义以及在医学中制作基于人工智能的解决方案时必须考虑的主题。

考虑

这绝不是一个人在决定是否追求一个基于人工智能的项目时应该考虑的全面的清单;然而,它触及了目前人工智能领域一些最紧迫的话题。

患者隐私

当我们在医疗环境中谈论患者隐私时,首先想到的概念是 HIPAA。尽管 HIPAA 在安全性和数据匿名化方面倡导的准则可能不适用于所有用例,但仍然应该适用于任何人工智能医疗应用项目。最重要的是,患者隐私保护应该到位,不仅要确保人工智能开发者不会向外界泄露数据,还要确保算法不会学习私人患者数据,这将使其在现实世界中毫无用处。

应该采取哪些确切的保护措施来确保患者隐私?首先,应该从成像标题中去除任何识别成像信息。例如,默认情况下,DICOM 文件将记录有关患者识别号、患者年龄、出生日期和体重的信息,除非另行配置。用于训练神经网络的数据通常会驻留在某人的计算机或服务器上;非匿名的 DICOM 头对于任何想要访问数据的恶意行为者来说都是非常有用的。除了从 DICOM 头和其他相关临床文件中清除信息之外,存储这些数据的服务器和硬盘应该加密,并使用严格控制的访问列表进行密码保护。

就训练一个算法而言,完全有可能算法本身仅仅通过常规的使用,实际上就可以向外界“泄露”信息。一些机器学习算法,如自然语言处理算法(NLP),生成“新”数据作为其输出。例如,可以使用 NLP 算法进行文本生成(例如,生成医疗记录);然而,过度适应其训练数据的 NLP 算法可能只是泄露了该训练数据的一些位,这些位可用于识别个人。仔细选择要训练的算法,并确保信息不被过度拟合,可以证明对解决这个问题是有用的。

说到 ML 算法产生违反直觉的结果的领域,我们来谈谈算法偏差。

算法偏差

算法偏差是指人工智能算法(和其他程序算法)可能产生有偏差的结果。尤其是在种族和社会不平等的背景下,算法偏见是当今训练医学相关机器学习算法时的一个主要问题。为了了解为什么这可能是一个问题,考虑我们可能正在训练一个分类器来检测皮肤上的黑色素瘤的情况。

为了训练我们的网络,我们可以求助于流行的在线鼹鼠图像库。我们下载这个图像库,创建一个分类器,就像我们对肺炎数据集所做的那样,然后评估它的准确性。结果证明准确率超过 90%,这太棒了。然后,我们将这种经过训练的算法带到我们的医院系统/皮肤科医生那里,并要求他们使用它,他们最终报告说,该算法对黑色素瘤状态的预测与皮肤科医生一致,但仅适用于肤色较浅的个人。当患者的皮肤较黑时,该算法完全不能产生有效的结果。我们可能会试图找到原因。也许灯光不好,但这似乎不太可能。然后我们转向原始数据集,发现绝大多数图像来自浅色皮肤的个体。我们可能无意中制造了一个有偏见的算法。这种偏差来自于这样一个事实,即训练数据并不代表真实的患者群体,因此,我们的网络在其训练过程中了解到了这种偏差。

为了帮助克服偏见,我们应该尝试在我们最终的患者群体中对各种人口统计数据的算法评估进行分层。在这种情况下,我们可以使用 Fitzpatrick 皮肤类型量表为每个患者指定一种肤色(I =最亮,VI =最暗),并评估每个类别的分类准确性。在此基础上,我们可以使用 Cochran-Armitage 趋势测试(或您选择的任何其他统计测试)来确定是否有显著的趋势(例如,随着类别数量的增加,准确性下降)。

这种情况不仅仅是理论上的;这是目前现实世界中正在上演的一个问题。谷歌在 2021 年 3 月推出了一款名为“皮肤辅助”的应用。其结果尚未见分晓;然而,研究人员已经开始担心它在肤色较暗的个人中的工作能力,因为用于训练数据集的图像只有(据我们所知)2.7%的 V 型肤色和< 1%的 VI 型肤色。虽然我们不知道这种预测应用程序的最终功效,但我们可以想出一些方法来防止算法偏差影响最终用户。

第一种方法是从一开始就防止我们的数据集中出现偏差。要做到这一点,我们需要注意哪些政策可能会导致有人对数据集做出贡献,而不是做出贡献。例如,如果用于训练算法的数据是自愿提供的,我们应该确保志愿者都能够无障碍地提供数据(例如,缺乏技术、缺乏时间等)。).我们还可以确保我们的数据来自几个不同的医院系统,而不是一家医院,因为一些医院可能位于社会经济或种族不多样化的地区。

防止偏倚的第二种方法是选择性地增加数据集中代表性不足的病例。虽然编程方式超出了本章的范围,但有几个图像增强库(如imgaug)允许您根据图像的特征指定图像增强的频率。如果我们发现一个特定的群体在我们的数据集中代表性不足,我们可以使用这种方法来增加它在整个数据集中的代表性。

防止偏差的最后一种方法是确保评估是在完全独立的维持集上进行的,而不是在属于您最初使用的相同数据的测试集上进行的。虽然这样做更费时间(因为你需要找到多个数据集),但它应该可以在一组从未实际训练过的患者身上验证你的算法的性能,这可能会暴露出你的算法的总体缺点。如果您无法访问另一个数据集,您可以创建一个有限的算法展示,并在向公众发布之前监控人口统计子群的结果,以确定其运行情况。

既然我们已经讨论了算法偏差以及它如何产生意想不到的结果,那么让我们来谈谈基于人工智能的解决方案如何在现实世界中过度承诺而未充分交付。

蛇油+在现实世界中创造信任

人工智能的利弊之一是,有很多资金与采用人工智能的解决方案相关联,特别是在医疗领域。然而,邪恶的行为者已经开始声称他们的技术使用了“人工智能”,而实际上并没有,或者他们甚至走得更远,以人工智能来解决一个已经有着众所周知的算法解决方案的问题。不幸的是,一些医院管理者已经成为这些索赔的牺牲品,最终浪费了数百万美元。

总的来说,在过去的几年里,这些“蛇油”的说法是基于与人工智能相关的“炒作”。能够进行反向图像搜索和面部识别等操作的算法所占据的新闻周期很容易让公众认为人工智能已经在解决“棘手”的问题,并且可以很容易地扩展到医生级别的医疗诊断。虽然人工智能系统实际上超过了一些医生的准确性,但它们只倾向于在非常具体的子任务中这样做(如 x 射线图像上的肺炎检测)。

但是我们实际上如何预测蛇油索赔?我们可以回到本节前面的部分:概括问题。让我们来看一个普遍的人工智能问题,它被认为接近于蛇油(有一点医学上的扭曲):在医生被雇用之前预测医生的工作成功。我们训练算法的输入和输出是什么?输入可能是当前提供者在被雇用时的各种特征,输出可能是他们的工作成就(可能以一段时间后工资增长的百分比来衡量)。有可能已经训练了一种算法来高精度地预测这种情况,但是数据将来自哪里呢?它可能来自一些医疗保健系统,或者更有可能是开发公司本身。在这一点上,我们需要提出关于我们用例中算法有效性的问题(如算法偏差部分所述)。此外,我们需要认识到,该网络只能预测公司/医疗保健系统实际雇用的个人的“准确”结果,因此,它可能不会在其他医疗保健系统/公司中表现良好,这些系统/公司在提供者之间具有不同的宝贵技能。

为了在现实世界中产生信任,有必要尽可能多地尝试和解释人工智能过程。诸如用于任务的一般 ML 算法、用于训练该算法的数据及其评估统计数据等概念都是报告的必要内容。只要有可能,模型输出应该在某种程度上得到解释(就像我们在前一章的 Grad-CAM 结果中显示的那样)。最后一点在医学界尤其受到重视,因为人工智能仍然倾向于被视为“黑匣子”,没有任何程度的解释。

说到解释,让我们来谈谈当你在医疗保健系统中领导一个基于人工智能的项目时,如何从总体上谈论人工智能。

如何谈论人工智能

在某些情况下,你可能会发现自己处于这样的位置,要么直接实现一个人工智能算法,要么负责告诉别人项目的目标是什么。当与程序员一起工作时,尽可能明确是很重要的。让我们通过一个例子来说明如何指定一个问题:我们的胸部 x 光分类问题,从最后一章。如果我们向程序员解释我们想做什么,这些问题是我们需要回答的:

  • 这个项目的目标是什么?

    从胸部 x 光图像预测肺炎状态。

  • 这个人工智能的输入和输出是什么?你对潜在的架构有什么建议吗?

    输入将是胸部 x 射线图像,输出应该是一个单一的世界“正常”或“肺炎”,并附带一个置信度(范围从 0 到 1)。准确显示图像的哪些部分对最终的分类决策贡献最大也是很好的。由于这是一个图像分类问题,所以最好使用卷积神经网络或类似的擅长对图像进行分类的方法。

  • 哪些数据将用于训练这个网络?怎么下载?它的一般格式是什么?这个数据已经被标注了吗?我能相信这些标签吗?

    我们将使用 Kaggle 胸部 x 光数据集。这些可以从 Kaggle 竞赛网站下载(发给他们一个链接)。这些图像是。jpg 文件,在它们可以用作神经网络的输入之前不需要任何额外的处理(如果它们是 DICOM 图像,您应该在这里指定)。它们已经被标记为正常与肺炎,并且图像已经在标记有适当类别的文件夹中。在大多数情况下,标签是可信的,但我们可能需要另一位医生来查看测试数据,并确保它是准确的。

  • 我们想要优化什么指标?

    我们希望优化准确性,但也希望确保我们有非常少的假阴性(即,我们希望有非常高的灵敏度),因为错过肺炎病例是有害的。

  • 这个网络将在哪里运行?我可以联系谁来让它在这种环境下运行?那个平台的约束是什么?

    理想情况下,我们希望将这个网络集成到我们的 PACS 系统中,这样它就可以立即为我们提供预测,而无需我们执行单独的脚本。X 人可以告诉你需要做什么来把你的输出变成一种可以理解的格式。(注意这在上一章中没有涉及;然而,这也是开发人员的一个主要痛点,因为不是每个人都会运行 Colab 笔记本来从您的网络获得输出。在某些情况下,PACS 系统可能不允许执行单独的脚本,这使得程序员的工作变得困难。在这种情况下,最好妥协一下,说你愿意培训医生如何使用开发者制作的任何定制软件。)

  • 你需要这个网络继续接受训练吗?您将如何确定要添加哪些新的训练样本?

    是的,我们希望这个网络继续得到训练。理想情况下,任何使用该模型的应用程序都应该标记出模型与医生不一致的结果,并使用该案例作为训练样本。(注意,我们在上一章中没有明确涉及这一点,但是您也可以继续在不同的数据集上训练网络;你只需要一个新的火车发电机就行了)。

一旦你回答了这些问题,你就应该能够指出你到底需要为你的项目工作的开发人员做些什么。编程过程中出现的许多开发难点通常是由于误解了预期,明确了解这些解决方案可以让您为开发人员提供足够的信息来完成他们的任务。

包裹

正如我们在本章和前面的章节中所看到的,人工智能并不是真正的魔法,我们可以理解这些程序是如何学习并产生它们所做的结果的。虽然这本书没有太多涉及人工智能的潜力,但编程/计算机科学主题中涵盖的概念、一些基本的 ML 算法类型以及 ML 和深度学习算法的实现细节将为你开始自己的探索提供足够的基础。即使这本书涵盖了两个主要项目,医学研究的整个世界还是向你敞开了。使用 scikit-learn 和 PyCaret,您可以评估不同的数据集并尝试优化分类任务。使用 TensorFlow 和 Keras,您可以使用迁移学习对医学成像文献中尚未涉及的疾病进行图像分类。有了上一章建立的技能,你也将领先于你的同龄人,知道在评估呈现给你的人工智能解决方案时要注意什么。当你自己承担一个基于人工智能的项目时,你甚至有一些准备什么答案的基本例子。

总的来说,这本书旨在作为进一步知识增长的种子,我希望你可以继续学习如何使用一些基于人工智能的算法来造福医疗环境中的患者。

posted @   绝不原创的飞龙  阅读(29)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)
点击右上角即可分享
微信分享提示