一、人工智能及其用例介绍
在医疗保健领域,关于人工智能(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 突出显示了普通程序和人工智能程序之间的差异。
图 1-1
展示了普通程序和人工智能程序之间的主要区别。请注意人工智能程序中涉及的训练和测试数据
人工智能程序如何执行前面的例子的细节将在本书后面阐述,但是一些突出的概念可以从前面的场景中得到。首先,在一个人工智能程序中,程序本身试图根据我们指定的一些标准来提高它的性能。第二,在一个人工智能程序中,由于我们没有指定参数,我们应该使用某种形式的训练和测试数据来确保程序学习如何优化主要指标,并在学习过程中推广它已经看到的数据。如果我们窥视人工智能程序学习的东西,我们不一定能保证它已经形成了与我们类似的决策过程。我们所知道的是,相对于我们的指标,它的表现相当准确。
让我们想一个不那么做作的例子来说明人工智能可能被用在什么地方。如何通过脑部扫描来检测病人是否有肿瘤?对于放射科医生来说,这是一项简单的任务。从 PACS 系统调出核磁共振图像,在扫描中寻找异常。好的,现在我如何检测 1000 个病人的扫描结果中是否有肿瘤?嗯,把这项工作交给一个放射科医生,甚至一个放射科医生团队,仍然意味着这项任务需要一段时间才能完成。为了加快速度,我们可以考虑用一个程序来做这件事。但是我们如何向程序指定如何寻找肿瘤呢?我们不一定能从几年的医学院和住院医师培训中获得经验。我们也不能采用早期的非人工智能方法,因为每个患者在 MRI 系列中可能不会在完全相同的位置上有脑肿瘤,也不会所有肿瘤看起来都一样(事实上,MRI 可能是用不同的成像参数拍摄的,等等。).因此,我们剩下的唯一真正的解决方案是用人工智能来完成这项任务。
有一些人工智能算法(称为神经网络)可以学习如何在扫描中检测对象,甚至完成更复杂的任务,如分割(即,准确标记图像中的哪些像素/体素对应于某个对象,在我们的情况下,是脑瘤)。然而,过程将是相同的。我们将使用一些带注释的数据 MRI 扫描(即,如果存在肿瘤,扫描会准确显示 MRI 切片的哪些部分包含肿瘤),对算法之前没有见过的一些数据进行测试,以评估其准确性,然后在我们的 1000 张图像上运行经过训练的 AI 程序(通常可互换地称为“模型”)。同样,我们不知道人工智能是如何完成这个过程的,也不知道它到底会寻找什么(尽管我们可以用一些技巧来可视化程序的一些部分),但我们知道它在一定程度上完成了自己的工作(由它在测试数据和 1000 次扫描中的表现决定)。
现在我们已经对什么是人工智能和什么不是人工智能有了基本的了解,让我们澄清一下人工智能的正式定义。大致来说,人工智能只是由机器展示的智能。它包含了大部分的过程,包括学习/训练阶段的一些模式,然后测试/评估结果程序。我们最终会看到,目前被称为人工智能的东西并不是你我所认为的真正的智能。相反,在大多数适用于医学成像和研究的情况下,人工智能可以被视为高级模式识别。在前面的例子中,我们的人工智能程序的任务是识别身体质量指数和体重分类的模式,并在核磁共振成像中找到指示肿瘤存在的模式。然而,现有的人工智能形式可以执行更复杂的任务,如处理人类语音,创建类似人类的对话,玩人类游戏,等等。迄今为止,这一领域的进展集中在非常好地执行特定任务上;然而,它在开发一种可推广到特定任务之外的机器智能方面没有取得很大进展(然而,谁知道未来会发生什么)。
好的,这就是 AI。现在什么是机器学习?机器学习可以定义为一组随着经验而变得更好的算法。当我说“经验”时,我的意思是当暴露于足够的训练数据时,程序本身的结果将根据一些最终的度量标准(在大多数情况下,这是某种形式的准确性)而改变和优化。机器学习是人工智能的一个子集。常见的机器学习算法包括线性回归(是的,这种类型的线性回归,您可以在 excel 中使用,以对一些数据进行最佳拟合)。在线性回归的情况下,我们不一定要区分训练数据和测试数据;然而,无论给我们什么样的数据,我们都试图得到最佳的猜测。通过特定的算法,程序可以在报告“足够好”的结果(可能是也可能不是最适合数据的最佳线)之前,通过多次迭代反复完善该猜测。其他机器学习算法包括决策树(在给定输入的一些属性的情况下,它将有效地制作流程图,以确定给定输入属于哪个类别)、聚类算法(试图将点分组为预先指定数量的组)和基于实例的算法(试图根据未知点与先前训练数据的接近程度来预测标签)。
深度学习,另一个你可能听说过的术语,完成和机器学习一样的任务;然而,它使用了一套特定的算法,其中包括使用人工神经元,这些人工神经元的工作方式与我们自己的神经元类似。正如我们身体中的单个神经元具有动作电位的行为一样,这些人工神经元也是如此。这些人工神经元相互连接,因为一个神经元的输出输入到另一个神经元的输入,导致多组神经元(称为层)的信号积累。网络的层数越多,它就变得越“深入”,就越有能力从训练数据中学习更多重要信息,从而使它能够在训练数据集之外进行归纳。还有另一种称为自然语言处理(NLP)的深度学习子集,可以帮助计算机处理、解释和生成文本数据。深度学习算法包含了当今人工智能的大部分主要进展(或者至少是最常被谈论的进展)。由于神经网络的多功能性和我们现在可以获得的巨大计算能力(与二十年前相比),深度学习算法对工业和研究变得非常有用,有助于从图像中检测物体、为类似人类的对话生成文本等任务。这些算法如图 1-2 所示。
图 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 的配对,其中 M 和 W 更喜欢彼此而不是他们最终匹配的人。实际上,这意味着这个算法的目标是防止私奔(即,如果一切顺利,我们将知道有人没有获得他们的第一偏好,因为第一偏好拒绝了那个人)。实现这种提供稳定匹配的保证是很重要的,因为(在现实世界中)我们希望确保所有的住院医生和医院都得到他们真正想要的住院医生(而不是出现更好的住院医生-医院对是可能的但没有发生的情况)。
那么我们如何解决这个问题呢?一种尝试是列出所有可能的男女配对,然后检查哪些是稳定的。这样做会给我们一个所有可能的稳定匹配列表;然而,这也将是非常计算昂贵的。为了探究它在计算上有多昂贵,假设我们有三个男人和三个女人。列举所有的可能性会产生六个不同的可能匹配(如果你愿意,你可以自己证明),这还不算太坏。但更普遍的是,这种策略会要求你列出 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 项来找到它。将该约会添加到我们的最终设置中,并检查它是否与其他约会冲突,只需要很少的时间。当我们选择新的潜在约会添加到我们的集合中时,我们然后搜索在我们刚刚安排的约会之后开始的下一个约会。这将需要我们查看不同的约会等等。总的来说,我们需要搜索大约)的约会,这很好,但可以使用一些改进。然而,如果我们最初按照完成时间升序对这些约会进行排序,我们可以加快在主集合中找到最早兼容约会的过程。与对集合进行排序相关的初始操作量有些高(实际上这大约是n∫log(n)),但是每当 n 很大时,实际上需要的操作比以前少。这给了我们问题中的一点优化,但是我们如何证明我们的解决方案实际上是最优的,并得到我们真正想要的。毕竟,当我们想到“贪婪”的事情时,我们往往会想到许多最终导致糟糕结果的短视决策。在我们的案例中,我们可以证明贪婪有时候是好的。
我们可以通过证明我们的方法的两个性质来做到这一点:(1)贪婪选择性质,定义为通过进行局部最优贪婪选择可以找到全局最优解的事实,以及(2)最优子结构性质,定义为最优解由最优子部分组成的事实。让我们开始证明我们的方法满足这两个属性。
为了证明第一个性质,只要表明如果这个问题有一个最优解,它总是包含我们排序的预约集中的第一个病人(即,它从预约最早结束的病人开始),称为 p 就足够了。为了解释为什么,假设存在一些最优的约会集合,它们不是以约会 p 开始的(称这个集合为 B )和另一个以约会开始的集合( A )。我们可以证明,任何最优集合都可以被构造为从第一个约会开始,如下所示:删除非 p 约会,并用 p 替换它。我们可以执行这个操作,因为我们知道 p 不会与现在存在的 B 中的任何内容重叠。 A 和 B 中的约会数是相同的,但是我们已经表明任何最优解都可以从贪婪选择开始。
很好,但是如果我们从贪婪的选择开始,那如何证明连续做出贪婪的选择会导致最优解呢?我们通过证明最优子结构来做到这一点。先来一个大概的猜想。如果集合 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)是指存在一些常数 c 和 k 使得对于所有n≥k0≤2n≤c∫n。在这种情况下,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 )。
图 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,它将有两个解:
)
让我们先尝试一下如何在 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_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
很好,我们仍然得到相同的输出,并且我们已经对代码进行了一点清理(它看起来有点不像一堆变量和数字)。
- 边注:变量(如
a
和sqrt_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"
。我们可以做以下事情来获得我们想要的值:
-
将输入字符串“拆分”成三部分:一部分包含 ax²,另一部分包含 bx,最后一部分包含 c。如果我们可以删除等式中的“+”部分,这三部分大致可以拆分。
-
对于 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 中,列表的第一个元素实际上是列表的“第零”个元素),我们将用空字符串替换字符串x²
。然后我们将调用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_root
和neg_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² ") > -1
、quadratic.find("x ") > -1
和quadratic.find(" = 0") > -1
都大于-1(即存在于我们的字符串中)并满足不等式(例如,x² 存在于索引 1 处,索引 1 为> -1,因此quadratic.find("x² ") > -1
为True
)。如果前面的例子都为真,那么执行if
下的代码块(比if
多缩进一级)。如果不是,Python 将在if
语句的同一层寻找一个关键字else
或elif
(也称为 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 )。
图 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 = 0
,quads[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")
这很复杂,但让我们来分解一下变化是什么:
-
在
root_finder
中,我们现在已经删除了一些打印语句(保留了在输入错误时打印的语句)。我们添加了return
语句。Return 语句允许我们在函数之间传递值,并将函数的输出赋给变量。print
到目前为止,我们一直使用的语句不能让我们捕获输出并将其赋给一个变量。这里,我们以元组(包含两个值的数据结构)的形式返回pos_root
和neg_root
,或者返回值None
,这是 Python 中的保留字,除了等于None
的另一个值/变量之外,它不等于任何东西。我们这样做是为了检查输出是否有效(如果无效,输出将等于None
)。 -
我们将
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 文件,只要所有的字典都有相同的键集。我们确实满足这个条件,因为我们所有的字典都有一个equation
、positive root
和negative 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 root
和negative 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 ,我们找到
)
然后,它将所有这些残差值相加,得出一个称为残差平方和的值。这是普通最小二乘回归试图最小化的量。一些微积分实际上显示了使用这种方法的线性回归有一个“封闭形式”的解决方案(即,它可以在一个步骤中运行)。相应地,该算法找到适当的斜率和截距。在这一点上,模型完成了训练,我们有了一个通用方程,可以预测一个人在服用减肥药物后体重减轻了多少。然后,我们需要确定该算法在真实世界数据上的效果如何(在我们的测试集中)。
该算法的输出非常容易理解。在大多数 Python 库中(甚至在 Excel 中),您可以获得对回归有贡献的每个变量的斜率列表以及截距,从而得到一个看起来像
)
的方程
其中βn代表一个斜率。如果我们试图将这种线性回归的结果绘制成图形,我们可能会运气不好,因为有五个维度(四个预测维度+一个输出维度),并且很难在计算机上描绘三维图形以上的任何东西。相反,我们可以看看βn(β)的绝对值,看看哪些是最大的。由此,我们可以做出合理的假设,即最大的贝塔系数对结果的贡献最大。在各种流行病学研究中,线性回归被广泛使用,尤其是在给定一些个体数据的情况下试图量化结果的有效性(例如,健康运动对减肥的影响)时。
逻辑回归
逻辑回归类似于线性回归,因为它可以接受多个可能的预测值,并找到斜率/截距,使直线、斜率或超平面最佳拟合。但是,线性回归和逻辑回归之间的主要区别在于来自这些函数的输出值的范围。线性回归输出范围从负无穷大到正无穷大的值。但是,逻辑回归只能输出从 0 到 1 的值。考虑到逻辑回归的有限输出范围,它非常适合涉及可能性/概率预测的应用,并且可以帮助预测二元结果(例如,疾病对非疾病),因为疾病状态可以编码为 1,而非疾病状态可以编码为 0。在医学应用中,逻辑回归模型通常应用于病例对照研究,因为β一旦指数化,就可以解释为优势比(见边注),从而产生很大程度的可解释性。
我们试图拟合的逻辑回归方程如下:
)
进一步简化
)
该等式的左侧相当于我们通常认为的某件事“可能性”的自然对数(即某件事发生的概率 y 除以某件事不发生的概率 1-y)。因此,我们可以插入 X (我们对个体的预测)的值,并找到他们发生事件 y 的几率。然而,在大多数情况下(如病例对照研究),我们不能单独报告几率(因为病例对照研究有预设的疾病规模,即“病例”人群和正常人群,即“对照”人群)。相反,我们可以取两个独立个体的预测比值,并确定比值比。
例如,如果我们在给定某人先前吸烟史的情况下预测患肺癌的概率,我们可以在病例对照研究中对个体拟合逻辑回归方程。在这种情况下,我们只有两个β,截距(β0 和β1(用于指示先前的吸烟史)。假设 β 0 等于 1β1= 3.27。如果我们想比较吸烟者和不吸烟者患肺癌的几率,我们会计算)。我们可以说,有吸烟史的人患肺癌的几率是没有吸烟史的人的 26.31 倍。注意,我在某人有吸烟史的情况下代入 X = 1,如果没有,则代入 X = 0(这里 X 作为指示变量)。
图 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 通常越高越好)。
-
灵敏度也称为“召回”,可通过以下公式计算:
)
-
特异性可以如下计算:
)
-
)医学研究者通常在 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 所示)。
图 4-2
k-最近邻示例。这里,我们可以看到修改 k 如何导致白色和灰色类点之间的不同决策边界
这里,图表中的每个像素都被着色为给定特定 k 的 k-最近邻输出的类。当 k 较小时,我们可以看到图表的白色和灰色区域之间的边界非常不规则,这是有意义的,因为使用较少的点来分类对象。当 k 较大时,我们看到决策边界的形状更加规则,因为一个类需要更多的 k-最近邻才能成为多数。较大的 k 值可以更好地理解点实际上是如何相互分离的,但是也可能会对一些点进行错误分类。然而,当 k 很小时,我们可能会学习到不那么有用的决策边界,并且可能只对训练数据起作用。
支持向量机
我将只简单地提到这个算法,因为当深入细节时,它往往会变得非常数学化。支持向量机算法基于这样的假设运行,即可能存在将两类数据分开的线、平面或超平面。目标是找到这种分离,使得最终平面和实际数据点之间的界限尽可能地高(即,找到可以完美分离两类数据的线,也称为“硬界限”)。然而,在某些情况下,我们可以通过允许一些数据点被错误分类来获得更大的余量(这种余量被称为“软余量”)。这样做,我们可以得到一条线,除了一些异常值之外,它仍然有很大的边距来分隔数据。我们可以在图 4-3 中看到一个硬边界和软边界分类器的比较示例。
图 4-3
硬边界和软边界分类器的 SVM 边界。“支持向量”表示为空心圆或正方形。违反硬边界假设的圆形或正方形用虚线边框标记。决策边界是带有虚线边界的黑色实线
这里,左边的图像代表一个硬边界分类器,因为没有一个圆或正方形点跨越线周围的“边界”边界。右边的图像表示一个软边距分类器,因为一些圆/正方形被允许出现在线周围的边距区域中,即使它们违反了先前的边距。
有时,我们必须对数据进行变换(比如求平方),以找到将数据点相互分离的最佳直线、平面或超平面。我们可以对数据应用多种可能的变换,支持向量机可以帮助我们找到要应用的最佳变换,从而为我们提供一个能够最好地分离手头数据的平面。例如,在下面的情况下(图 4-4 ,我们可以使用 SVM 算法来找到最好地分离这些数据的平面。
图 4-4
SVM 用了一个很难在二维空间中线性分离的例子
在左侧,很难找到一条线或多项式来适当地分隔这些数据的类别(其中点的颜色代表其类别)。但是,如果我们对数据应用一个变换(在这种情况下,一个称为径向基函数核的变换),我们可以找到一个平面来为我们分隔这些数据。SVM 算法让我们有能力找到这个平面。SVM 也可以被实现用于回归任务。
- 在现实世界中的使用:Son 等人在 2010 年发表了支持向量机的使用,用于预测心力衰竭患者是否会坚持药物治疗。他们的输入数据点预测了性别、每日用药频率、用药知识、纽约心脏协会功能分类、射血分数、简易精神状态检查分数以及他们是否有配偶。他们的输出是心力衰竭患者是否在服药。他们能够实现接近 80%的检测准确率,这是令人印象深刻的,因为他们只有 76 个人的小数据集。自那以后,支持向量机已被用于许多医学预测应用,包括预测痴呆症、患者是否需要住院等等。类似地,k-最近邻已被用于基于先前的患者数据来确定个体是否有患心脏病的风险。
决策树和基于树的集成算法
决策树有助于产生 ML 世界中一些最易解释的结果。决策树不太像真正的树。相反,它们的结构就像一棵倒置的树,顶部有根,叶子和树枝的数量随着你的深入而增加,如图 4-5 所示。
图 4-5
预测乙型和丁型肝炎感染/恢复/未知状态的决策树结构
在顶部,有一个根(正式称为节点),它有左右两个分支。每个分支也有一个节点(它也有自己的左右分支等等)。这些分支中的一些不会进一步分裂成其他左/右分支(这些被称为叶)。在决策树生成算法中,将为树的每个节点学习决策规则。该节点可以是类似于“如果患者乙肝表面抗原滴度测试阳性,则转到左分支;否则,去正确的分支。”这些分支也有自己的决策节点,直到它们到达一个叶节点,该叶节点通常给出患者的分类(例如,他们有疾病或他们没有疾病)或给出一些数字,该数字代表到达决策树该部分的训练数据的其他实例的平均标记值。
需要学习的决策树的关键部分是“分割”什么特征(即,在每个节点测试)以及是否值得分割该特征。
分类和回归树
分类和回归树(CART)是一种机器学习算法,允许我们学习决策树。该算法通过反复尝试要分割的特征来工作。无论哪种分割产生最佳结果,都被选择应用于数据(根据该规则在树中创建一个带有分支的新节点)。然后,该算法试图为每个分支找到新的分裂。然而,这种算法不一定是最好的,因为它只依赖于在那个时间点选择最佳分割,而不是尝试多种不同的树来查看一旦整个算法运行时什么是最好的。这种类型的算法被称为“贪婪”算法(在这种情况下,该算法是贪婪的,因为它决定了在训练过程中的单个点上看到的最佳分割)。
但是我们如何评价哪种拆分是“最好的”呢?对于分类任务,我们可以使用一个称为“基尼杂质”的术语可以通过将一个类中该节点的训练点比例乘以其补数(1-该比例)来计算每个节点的基尼系数。我们对所有类别的这些值求和,并对每个节点的值进行加权,以确定哪个分裂特征将导致最低的可能基尼不纯系数(0 =分配给每个分支的所有实例都属于同一类别,这意味着我们有一个完美的分类器;任何更高的值意味着在每个节点都有被错误分类的实例)。在一个等式中,基尼系数可以表示为:
)
其中 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 所示。
图 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 中可以看到一个弯管图的例子。
图 4-7
弯头图示例,弯头在 k = 3 左右
在这里,我们可以看到,在 k = 3 个集群附近,图中有一个“肘形”(即,在增加一个额外的集群时,WSS 损失急剧减少,也称为收益递减)。还有其他方法,例如轮廓法,该方法考虑了一个点与其自己的聚类有多相似以及它与其他聚类有多相似(在该方法中,为每个尝试的 k 产生一个轮廓分数,并且最高的轮廓分数被认为是聚类的最佳数量)。另一种称为间隙统计的测量方法也有助于确定最佳聚类数,方法是使用从零参考分布(通过自举生成)确定的期望值计算聚类内变化。产生最高值间隙统计的 k 是具有最佳聚类数的 k。
k-means 聚类算法的变体也可以产生分层聚类,其中我们产生一个树状图(一个紧密相关的点在树上彼此更靠近的树;参见图 4-8 。
图 4-8
描述物种间关联性的树状图示例
这些树通常用于测量物种之间的进化关系,甚至可以模拟我们肠道中微生物群之间的关系(基于微生物群样本中发现的物种之间的 DNA 相似性)。
需要注意的是,k-means 聚类对规模很敏感,这意味着我们在使用这些方法时需要重新调整和集中我们的数据(类似于 k-nearest neighbors)。
主成分分析
主成分分析(PCA)可以帮助我们可视化高维数据。这是通过识别可以产生新轴的预测因子组合来实现的。然后,我们可以使用这些新的轴来重新绘制数据,以帮助显示数据本身的变化程度。PCA 的最终产品是多维组合的一组轴(称为主成分)。我们可以使用这些轴中的一些(通常是前两个)来绘制我们现有的数据,但是是在一个新的坐标系中。在某些情况下,与仅在一对轴上可视化数据相比,这可以帮助看起来没有差异的数据看起来更加不同/可分。在图 4-9 中可以看到一个 PCA 转换的例子。
图 4-9
PCA 转换步骤。首先,绘制数据,然后找到 PCA 轴,然后将数据转换到 PCA 空间,在新的轴上重新绘制数据
粗略地说,PCA 旨在找到主成分轴,当投影到该轴上时,使数据中的方差最大化。要了解这意味着什么,请参考图 4-10 。
图 4-10
此处显示了 PC1 和 PC2 的差异。请注意 PC1 的方差比 PC2 高
在这张图片中,我们可以看到黑线将成为很好的轴,因为它们可以让我们清楚地看到点之间的差异。这些轴是 x 轴描述的任何特征和 y 轴描述的一点特征的组合。然后,我们继续寻找其他轴,一旦先前的轴应用于数据,这些轴可以帮助将点彼此分开。最终,我们得到了主成分(有很多)的排序,然后是它们可以解释的变异百分比。我们选择使用尽可能多的主成分轴来解释很大程度的变化,直到出现收益递减点(这可以通过“scree plot”来评估,该图看起来类似于 k-means 聚类中的肘方法生成的图)。
人工神经网络和深度学习
人工神经网络(ann)和深度学习(DL)被认为是目前 ML 世界的宠儿。这些算法有助于从输入数据中提取信息,以生成输入数据的“中间”表示。该中间表示通常是输入的某种变换,可以用来容易地“学习”该变换输入的哪些方面是输出的最佳预测。我们看到了转换输入如何在让支持向量机如此好地分类数据方面发挥作用,但这些转换将 SVM 带到了另一个完全不同的水平,定期学习人类通常无法理解的转换。
当我们谈论神经网络时,最明显的词是“neural ”,表示与神经元的某种关系。几乎每一篇介绍性的文章都展示了神经网络是如何部分受到人类神经元及其行为的启发;然而,这种比较开始与今天的神经网络大相径庭。无论如何,我们将涵盖这种比较,因为它有助于激发一个简单的神经网络的基本结构。
基础(感知器,多层感知器)
神经网络由称为神经元的单个单元组成。每个神经元可以具有多个输入,并且具有可以作为输入馈入其他神经元的输出(正如生物神经元具有分别充当多个输入和多个输出的树突和轴突末梢;参见图 4-11 。
图 4-11
生物神经元的结构
为了让生物神经元激发动作电位,膜电位需要超过特定的阈值。类似地,人工神经网络可以被设计来模仿这种行为(尽管这并不一定适用于所有的神经网络)。
抛开人工神经网络背后的生物学思维,人工神经网络的实际实现包括两个主要的构造:权重和偏差。对于人工神经网络的每个输入,权重将输入乘以某个数字。偏差是一个加到所有加权输入总和上的数字(正数或负数)。通常将某个函数(也称为“激活函数”)应用于加权输入和偏差的最终和:有时该函数只是恒等式(即和本身),有时它是 sigmoid 函数(将最终值限制在 0 和 1 之间),有时它可能是另一个函数,仅当和为正时,该函数才等于权重和偏差的最终和;否则,它为零(这称为 ReLU 函数)。一个人工神经元的图片可以在图 4-12 中找到。
图 4-12
具有输入 x1 和 x2 以及偏置项的人工神经元(又名“感知器”)。注意,w1 和 w2 可以采用不同的值,如线条粗细的差异所示
在这个图像中,我们可以看到这个人工神经网络单元(正式称为“感知器”)有两个输入,X1 和 X2,以及一个输出。它还有一个偏差,记为“b”,每个输入的权重称为“w1”和“w2”我们还看到,函数“f”(激活函数)应用于权重乘以输入加上偏差之和,以产生最终输出。
许多感知器可以排列在一起形成一层神经网络。这些层中的许多层可以链接在一起,形成一种称为多层感知器的人工神经网络,也称为 MLP(如图 4-13 )。
图 4-13
具有三个输入、三个全连接层和一个输出的全多层感知器
MLP 通常具有被视为输入图层的第一个图层。这一层的每个感知器采用表格数据(例如,电子表格中的行)中的数据点的任一特征的值,或者甚至可以表示图像中像素的值(每个像素一个感知器)。然后,这个输入层被密集地连接到下一层感知器(即,每个感知器连接到下一层中的所有其他感知器一次)。然后,这些感知器可以被输入到另一层,以此类推。最后,倒数第二层通常被送入单个或多个输出感知器。如果我们只是试图在给定一些输入值的情况下预测一个数字,那么单个感知器输出将是有用的。如果我们想要预测输入数据的类别(例如,无糖尿病、糖尿病前期、糖尿病),多个感知器输出将是有用的。在分类任务中,我们将找到产生最高值的输出感知器,或者甚至可以得到类似于属于由每个输出感知器指定的类别的输入的概率的东西(通过应用称为“softmax”的函数)。
你可能已经注意到我们在这个例子中包含了相当多的感知器,但是在我们的网络中包含这么多感知器有什么好处呢?考虑每个神经元可以接受多个输入,并将这些输入转换成完全不同的数字。这些数字中的每一个都可以输入到其他神经元中,这些神经元输出不同的数字,以此类推。在 MLP 的每一层中,我们将网络的输入完全转换成别的东西。这些表示中的一些可以使这些网络更容易找到最适合分类或回归任务的规则。
到目前为止,我们还没有触及的一个话题是,网络实际上是如何为这些连接和感知机中的每一个学习正确的权重和偏差的。人工神经网络通过一个叫做“反向传播”的过程做到这一点本质上,一个训练数据点(或多个训练数据点,也称为“批”)通过网络发送,并记录一组输出。对于每个输出,网络根据我们自己指定的称为“损失函数”的函数来计算它的“错误”程度(例如,对于回归,该错误可能只是输出数与地面真实数的平方之差)。网络在这一点上的目标是尝试并找到调整网络中存在的每个权重和偏差的最佳方式,以最小化损失。反向传播如何工作的证据可以在网上很多地方找到,但它确实涉及到相当多的多变量微积分。一旦网络调整了它的权重和偏差,它就通过新的训练数据(或者甚至可能再次通过训练数据)来继续更新权重和偏差。
重要的是,你可能会看到提到一种叫做“学习率”的东西,它对于确定神经网络能够多快收敛到使其误差最小化的最优解是很重要的。这是通过在看到一些训练数据后乘以每个权重和偏差的调整量来实现的。在大多数情况下,我们希望学习率相对较低,因为我们不想超过最优解,永远达不到它。但是,如果设置得太低,可能会导致网络需要很长时间才能收敛到最终解决方案。我们自己设定学习率,所以它是一个超参数。其他技术(如“批量标准化”,集中/缩放网络中每个感知器的输出)也可以帮助稳定神经网络的学习过程。
您可能需要调整的另一个主要超参数是“时期”的数量一个时期是一个超参数,它定义了人工神经网络/MLP 模型遍历整个数据集的次数。训练一个网络所需的历元数量没有规则,它完全取决于网络架构。一些简单的网络可能只需要十几个纪元来训练。更复杂的网络可能需要数十万个。为了帮助确定要设置的最佳时期,最好对神经网络的训练设置“停止条件”(例如,如果在十个时期后网络的总误差(也称为“损失”)没有减少 10%,则停止网络的训练)。由于神经网络可能会过度拟合训练数据,因此设置此停止条件非常重要。为了防止过度拟合,可以使用“正则化”技术(通常涉及类似于“放弃”的东西,也就是从网络中随机删除感知机,或者惩罚大权重的 L1/L2 范数正则化,类似于我们在套索/岭回归中看到的)。
您还可以设置其他超参数,如批量大小(网络在更新权重之前看到的训练数据点的数量)、优化器选择(除梯度下降之外还有其他算法确定网络如何学习)、权重初始化(确定网络在任何训练之前的第一个权重和偏差;通常这是随机的)、损失函数(你试图优化的)、层大小(网络的每层有多少个感知器)等等。
一般来说,至少在某些方面,MLP 是许多流行的神经网络架构的组件。大多数用于图像分类的复杂网络都有最后的两到三层,它们只是密集连接的感知机,有助于分类或回归任务。MLP 本身也可以用于预测表格数据。然而,MLPs 的规模变得难以处理,尤其是当使用图像作为输入时。考虑到如果一幅图像的大小为 400px x 400px,并用作网络的输入,这意味着有 160,000 个输入感知器进入该网络,因为每个感知器代表一个像素。此外,这些感知器中的每一个都需要连接到 MLP 中间层中的更多感知器,这表示要学习更多的权重和偏差。之后,使用这种架构处理图像在计算上变得非常困难。这就是我们转向卷积神经网络的地方。
卷积神经网络
为了帮助解决训练神经网络处理相当大的图像输入的问题,研究人员提出了对输入图像本身应用“卷积”的想法。卷积是一种线性运算,它将网络中的部分输入图像相乘并生成输出值。决定乘法如何发生的结构是过滤器(也称为内核)。将输入图像乘以过滤器将产生一个较小的图像。在多个卷积(即,多次运行滤波器乘以图像输入并产生输出图像)上这样做可以极大地减小输入的大小,甚至可以产生网络随后可以学习的图像的中间表示(称为“特征图”)。
但是到底什么是过滤器呢?过滤器是排列成矩阵(即网格)的一组数字。通常这些过滤器相当小(3x3,5x5)。包含在这些过滤器本身中的数字也由神经网络学习。为了产生特征图,过滤器以滑动方式(从左到右,然后从上到下)将输入图像的每个 3x3、5x5(或过滤器的任何大小)部分中的像素值相乘。该乘法运算然后产生另一个图像,该图像可能比原始输入图像小,但是看起来肯定与输入图像大不相同。在图 4-14 中可以找到卷积的示例。
图 4-14
图像上的卷积运算。这里,滤波器值在图像中的 3×3 补片上相乘。通过对该乘法的值求和来计算单个值。该值输出到输出图像,滤镜继续在原始图像上滑动
在卷积神经网络中,每一层都由一组应用于图像输入的过滤器(您可以指定数量)组成。假定输入到神经网络的大多数图像都是彩色图像(有一个红色通道、一个绿色通道和一个蓝色通道),则滤镜的大小为 WxHx3 元素(其中 W =滤镜的宽度,H =滤镜的高度)。还有描述如何将这些内核应用于图像的操作的附加参数。其中一个参数称为填充,它有助于我们处理图像边缘发生的问题(如果我们一直向右移动,在图像的右侧大小,过滤器将继续移动不存在的像素,并超出图像边界)。我们可以通过在图像中添加填充(通常是值为 0 的像素)来解决这个问题。我们还可以设置过滤器的步幅,它决定了过滤器在每个方向上移动时“跳过”多少像素。如果我们保持步长为 1,输入和输出图像将具有相同的大小。如果我们增加步幅,我们告诉过滤器在每一步移动两个像素,而不是一个像素,从而减少输入的大小。
当输入图像通过网络时,还有其他方法可以帮助减小输入图像的大小。一种方法叫做“最大池化”这是一个通过传递滤镜(通常是大小为 2x2 的东西)和 stride(通常是 1 或 2)来操作的滤镜,并且只输出一个值(滤镜所在的图像块中的最大值像素)。在图 4-15 中可以找到 max pooling 对图像所做的示例。
图 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 分数(
)
).
您可能还需要提供一条校准曲线,以显示算法在何处高估或低估了某个特定类别的可能性。这些曲线是通过将 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 )。
图 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 折交叉验证的每次迭代中,通常如何选取训练和验证折叠。
图 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
,以防键入库的全名变得令人厌烦。第三行让您能够只获得感兴趣的库的特定部分(在本例中是subFunctionY
和Z
)。第四行的作用与第二行和第三行的组合相同。它使您能够导入主库的一些子组件,并对其进行重命名。第四个函数使您能够将库的所有部分导入名称空间。在这种情况下,您将能够访问该库的所有功能,而不需要在它前面加上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 中的一个绘图库。我们将导入名为plt
的pyplot
子模块来创建一些图形。
读入数据并隔离列
接下来,我们将实际读入我们的数据:
输入【单元格= 3】
df = pd.read_csv('NEISS.TXT', delimiter='\t')
如果你要查看 NEISS.txt 文件(参见图 5-1 ,你会看到它实际上有一堆列,都是由制表符分隔的。通常 pandas(这里是pd
) read_csv 函数假设列之间的默认分隔符(也称为分隔符)是一个,
。然而,在这种情况下,我们需要指定它是制表符分隔的,这可以通过指定一个名为“delimiter”的命名参数来实现,该参数的值为\t
(这是一个制表符)。
图 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 。
图 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 的东西。
图 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_split
、GridSearchCV
和cross_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_imbalanced
、X_test
、y_train_imbalanced
、y_test
中)。
接下来,我们需要使用 SMOTE 过采样方法将我们的训练不平衡数据集转换为实际平衡的数据集。
在这段代码的第二行,我们使用SMOTE()
创建了 SMOTE 采样器的一个新实例,并将其赋给变量oversample
。然后我们调用oversample
变量上的fit_resample
方法(传入不平衡的 X 和 y 训练数据)来生成平衡的 X 和 y 训练数据(存储在X_train
和y_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 。
图 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 。
图 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_test
和y_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 所示。
图 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_Part
、Diagnosis
等)。)对数据帧使用.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 所示。
图 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 。
图 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 。
图 5-9
从 PyCaret 的定型 GBM 模型输出 AUC
我们可以看到 AUC 接近 0.89,这很好。让我们来看看混淆矩阵:
输入【单元格= 23】
plot_model(gbm, 'confusion_matrix')
输出
参见图 5-10 。
图 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
找到