面向医疗保健的-Keras-和-TensorFlow2-人工智能教程-全-
面向医疗保健的 Keras 和 TensorFlow2 人工智能教程(全)
一、保健市场:入门书
本章概述了医疗保健系统,特别关注美国医疗市场。医疗保健系统是为了满足人们的保健和健康需求而组织起来的。该系统包括几个利益相关者,他们聚集在一起为人们提供有效的护理。
在本章结束时,你将理解医疗保健环境是如何运作的,以及每个群体所扮演的角色。您还将了解关于数据保护的监管法律,这将有助于您作为开发人员就可以使用哪种数据做出更好的决定。最后,你将了解行业格局。我们还将讨论人工智能如何改变我们周围的医疗保健系统,并向好的方面发展。
医疗保健市场的不同利益相关方
如图 1-1 所示,在为消费者提供综合医疗系统的过程中,涉及到不同的群体。
图 1-1
医疗供应链
让我们更深入地了解医疗保健提供渠道的主要参与者。
监管者
所有团体/行为者都受到各种政府和非政府机构的监管。美国的医疗保健主要由卫生与公众服务部(HHS)下属的多个部门和机构管理。联邦或中央政府通过 HHS 管理各种项目、研究、指导、资金等等。该部门反过来与州和地方政府以及私人参与者合作,以确保在医疗保健的质量、可及性和成本之间保持持续的平衡。
HHS 的总体目标以四个关键理念为指导,重点是
-
作为消费者的病人
-
作为负责实体的提供者
-
建立成果支付
-
预防
接下来的几节将介绍 HHS 的三个主要官员。
美国食品药品监督管理局(美国食品和药物管理局)
FDA 的主要职责是确保药品、生物制品和医疗器械的安全和批准。它还负责确保提供给美国公民的食物是安全、纯净和健康的。
FDA 还通过创新使医疗产品更有效、更安全、更实惠,并通过帮助公众更好地获取改善健康所需的信息,在促进公共健康方面发挥作用。
医疗保险和医疗补助服务中心(CMS)
CMS 管理联邦和州支付计划,即医疗保险和医疗补助。它还有助于管理儿童健康保险计划(CHIP ),并保护在未经患者同意或知情的情况下传输敏感的患者健康信息。
医疗保险和医疗补助创新中心(CMMI)
创新中心允许医疗保险和医疗补助计划测试改善护理、降低成本和更好地调整支付系统以支持以病人为中心的实践的模式。创新主要围绕着让患者在家保持健康,以及让提供者/医生通过提供更高的价值来让患者保持健康。
运动员
付款人或付款人是一个统称,用于指负责支付任何已交付医疗保健服务的组织或国家机构。他们的目标是通过最大限度地提高患者医疗保健结果的质量来控制医疗保健成本。
付费者的三个主要功能是保持患者健康、管理直接护理的成本和最大化结果。这些功能详见图 1-2 。
图 1-2
付款人的不同职能
2018 年,超过三分之二的国家卫生支出来自私人保险(包括美国家庭、私人企业和联邦、州和地方政府等资助者的贡献)、医疗保险和医疗补助计划。
在许多市场中,医疗保健是一个国家问题,而在更发达的市场中,它是由公共和私人合作伙伴推动的。美国用公共资金在医疗上的支出约占其 GDP 的 8.5 %,与其他国家相当。然而,就占国内生产总值的百分比而言,私人支出几乎是其他国家的四倍。
图 1-3 显示了不同支付者的医疗保健支出,其中大部分由私人支付者主导,其次是医疗保险和医疗补助等政府项目。
图 1-3
2018 年美国医疗保健支出按支付者分布
如图 1-3 所示,由于医疗保健的大部分支出来自保险,所以最好看一下患者可以选择的保险,这些保险会因年龄、收入和就业状况而异。
表 1-1 提供了不同保险项目和费用的完整概述。在我们的第一个案例研究中,我们将使用索赔数据,这些数据是通过与付款人合作来维护的,因此您应该了解不同的付款人计划。
表 1-1
个人保险选择,简单比较
|保险类型
|
保险名称
|
描述
|
合格
|
费用
|
| --- | --- | --- | --- | --- |
| 政府 | 医疗保险制度 | 联邦政府资助的健康计划,覆盖残疾成人和 65 岁以上的老人 | 65 岁以上的成年人 | 联邦医疗保险 A 部分免费;其他零件的低保费 |
| 政府 | 联邦医疗补助制度 | 由州和联邦政府管理的联合健康计划,覆盖低收入人群 | 低收入成人和儿童 | 无保费或保费非常低 |
| 私人的 | 各种私人保险公司 | 您在交易所购买的保险或直接从健康保险公司(UHG、安泰、凯撒、Anthem 等)购买的保险。) | 除医疗保险和医疗补助受益人以外的所有美国公民 | 整体保费更高 |
| 私人的 | 雇主赞助的 | 你通过雇主购买的保险 | 任何为提供健康保险的雇主工作的人,通常还有他们的家属 | 保险费通常由雇主补贴 |
| 其他人 | 编织 | 由美国国防部向军人及其家属提供 | 国防人员及其家属;现役或退役 | 随着等级的不同而不同 |
| 其他人 | 退伍军人事务部 | 提供给退伍军人和一些退役军人,由退伍军人事务部经营 | 没有被开除军籍的现役陆军、海军或空军人员 | 严重残疾人士专用 |
提供者
提供者是向患者提供医疗保健服务的个人或组织。医疗保健市场中有四类主要的提供商:
-
集成交付网络(IDN):医院系统
-
综合支付者-提供者网络(IPPNs):医生、护士和其他护理人员
-
临床综合网络(CINs):诊所、疗养院
-
责任医疗组织(ACOs):替代站点,如梅奥诊所、克利夫兰诊所等多专业团体诊所。
这些群体并不相互排斥;他们可能会在不同的层面上整合,以便更好地控制医疗成本和质量。例子包括美国医院公司、尊严健康等。表 1-2 列出了不同类型的供应商。
表 1-2
不同类型提供者的描述
|提供商类型
|
描述
|
| --- | --- |
| IDNs(地名) | 一个由医院和其他医疗机构组成的网络,它们共同合作,在整个护理过程中为患者提供服务。这些不同的设施归母公司所有。 |
| IPPNs | 一个由医院、提供者设施和自我管理保险计划组成的网络,该网络收取保险费,提供医疗服务,并为网络内的部分或全部患者报销这些程序的费用。 |
| 宫颈癌前病变 | 一个由松散关联的提供者机构(拥有不同的所有者)组成的网络,这些机构在同一社区内合作,以实现医疗保健的三重目标。尽管不属于同一母公司,但允许 CIN 与付款方签订集体合同。 |
| 最佳适应控制(AdaptiveControlOptimization) | 与付款方(商业或政府)签订合同的医院、医生和其他医疗保健提供商的网络。它协调对特定人群的护理。 |
医疗保健信息的监管
美国的医疗保健信息受到联邦保护。这意味着卫生与公众服务部和美国联邦贸易委员会等联邦机构负责数据的生成、收集和分发(参见 https://sitn.hms.harvard.edu/flash/2019/health-data-privacy/
乔丹·哈罗达和丹·奥特所著的《美国健康数据保护法的历史》,题为健康数据隐私:更新 HIPAA 以适应当今的技术挑战,2019 年 5 月 15 日,图 1)。
关键重大事件:
-
定义受保护的健康信息(PHI):2003 年,HIPPA 隐私规则定义了哪些健康信息应该受到保护。这包括付款、病史和付款人信息。
-
维护电子健康记录:2009 年,《健康信息技术促进经济和临床健康法案》( HITECH)引入并鼓励电子格式的健康记录。激励是通过医疗保险和医疗补助计划来管理的。第二,任何对电子健康记录的安全破坏,如果影响超过 500 人,都属于破坏通知规则的范围。
-
最终综合规则:该规则于 2013 年推出,在某种意义上赋予了患者更多权力,那些自己支付医疗费用的人可以从健康计划中获得私人信息,这样就不会基于过去的病史实施偏见或区别对待。它还赋予患者更多的权利,因为在使用前需要个人的预授权。此外,患者可以要求获得其医疗记录的电子副本(即使是跨不同的医疗保健系统)。
随着技术变得越来越先进,侵犯个人隐私的方式也越来越先进。此外,我们可以在联邦范围内控制由受政府合规法律管辖的组织收集的医疗保健数据的使用,但最近“总是社交化”的趋势导致不同的人公开他们医疗健康的各个方面,如在 Twitter 上报告药物的不良事件。数字设备和蓬勃发展的物联网生态系统开始在临床系统之外产生大量数据,而这目前不受政府法律的监管。
这导致人们认为我们需要更严格的法律,像目前在欧盟地区的 GDPR 那样,保护“个人数据”,包括各种身体、生理、基因、精神、商业、文化或社会身份数据,以变得普遍。
人工智能在医疗保健中的应用
如果让我来定义为什么我们试图用人工智能来解决医疗保健中的问题,我会用九个词:
-
降低成本
-
改善结果
-
提高质量
为了对上述任何一个杠杆起作用,人工智能将进行筛选、诊断、结果/预后和对治疗的反应。让我简单解释一下这些术语。
排查
筛查是在疾病开始出现任何迹象或症状之前对其进行识别。疾病的早期检测,尤其是慢性疾病的早期检测,能够以低得多的成本带来更好的结果。
这意味着理想的筛选应该在时间内完成,以便结果可以改变,具有高度精确的模型/过程,具有成本效益。
诊断
诊断是在病人身上发现疾病的过程。它帮助我们到达受疾病高度影响的身体部位,因此这是非常重要的一步,也是人工智能经常使用的一步。
预后
预后是另一个术语,用于衡量对患病患者的治疗结果。它可以通过各种指标来衡量,如患者重新入院的天数或患者的存活几率。
对治疗的反应
不同的病人对治疗有不同的反应,因此基于一个人的基因构成,我们正试图开发更有反应的治疗方法。这也被称为个性化医疗。由于庞大的数据和缺乏算法来删除用于分析的无关信息,典型的遗传数据处理可能需要大量时间,但随着数据存储和处理技术的进步以及创新的 ML 算法,个性化医学不再是一个深远的目标。
行业格局如何?
随着人工智能和技术的进步,推进医疗保健的方法也在进步。许多公司使用各种技术来解决多种医疗保健问题,如健康保险覆盖范围、管理护理流程、可访问性等。
我可以与你分享一份顶级公司的名单,以及他们目前在做什么,但我会与你分享一个非常有效而简单的方法来观察任何行业的新兴趋势。
我们将使用 Crunchbase 的数据集。Crunchbase 是一个寻找私营和上市公司商业信息的平台。Crunchbase 信息包括投资和融资信息、创始成员和处于领导地位的个人、并购、新闻和行业趋势。
Crunchbase 提供了不同版本的数据 API。其企业和应用程序 API 是有定价的,同时通过开放数据地图免费访问其网站上的有限数据。我们将使用开放数据地图中的数据开始。
你可以查看资金、领导力等信息。但是为了了解行业状况,我们将使用公司的简短描述。
让我们开始吧。
图 1-4
Crunchbase 基本数据访问表单
-
首先,在这个链接上注册 Crunchbase 数据 API,然后点击开始按钮:
https://about.crunchbase.com/crunchbase-basic-access/
-
填写表单,它看起来有点像图 1-4 。
-
在 Crunchbase 结束尽职调查后,您将在您注册的电子邮件地址收到一封电子邮件。这将是您的用户密钥。
# Loading required libraries
from urllib import parse
import requests
import pandas as pd
import numpy as np
import re
def create_urlquery_from_args(args):
url_query = ""
for k,v in args.items():
# quote_plus helps us handle special characters like ~_. and spaces
url_query += '&' + parse.quote_plus(k) + '=' + parse.quote_plus(v)
return url_query
# Setup the basic url for rest api query
API_BASE_URL = 'https://api.crunchbase.com/'
API_VERSION = '3.1' # soon to be updated to v4
API_URL = API_BASE_URL + 'v' + API_VERSION + '/odm-organizations'
API_KEY = "xxxxxxxxx" #<--- Enter the user key you received from crunchbase
为了了解更多关于 Odm API 可用端点的信息,请访问 https://data.crunchbase.com/v3.1/reference#odm-organizations
.
这可以帮助您生成示例查询,并向您展示预期的结果:
# We are interested in getting organization name and their descriptions
query = 'healthcare' # this will search for keyword 'healthcare' in organization name, it's aliases and short text
param_dict = {"query":query,"organization_types":"company","user_key":API_KEY}
rest_api_url = API_URL + '?' + create_urlquery_from_args(param_dict)
# Making Get Request
headers = {
'accept': 'application/json',
'content-type': 'application/json',
}
resp = requests.get(rest_api_url, headers = headers)
# Checking api call status and seeing few values of the data
if resp.status_code != 200:
raise ApiError('GET /tasks/ {}'.format(resp.status_code))
# Parsing JSON data
company_data = resp.json()
for items in company_data['data']["items"][:10]:
print('{} ---> {}'.format(items['properties']['name'], items['properties']['short_description']))
# Let us create a dataframe from analysis
data = pd.DataFrame([[items['properties']['name'], items['properties']['short_description']] for items in company_data['data']["items"]], columns = ["name","short_description"])
注意,在数据的分页属性中提供了分页信息,因此您可以再次请求以获得下一页的结果。参见图 1-5 。
图 1-5
Crunchbase API 的 JSON 输出中的分页属性
所以现在你有必要的数据从中得出初步的见解。让我们开始编码吧!
# plotly library
import chart_studio.plotly as py
from plotly.offline import init_notebook_mode, iplot
init_notebook_mode(connected=True)
import plotly.graph_objs as go
# word cloud library
from wordcloud import WordCloud
# matplotlib library
import matplotlib.pyplot as plt
#stopwords
from nltk.corpus import stopwords
# Let us remove some frequent words from this domain
common_words = "|".join(["healthcare","medical","service","health","care","AI","data","solution","software","platform","provide","company","technology"])
data["short_description"] = data["short_description"].apply(lambda x: re.sub(common_words,"",x.lower()))
data["short_description"] = data["short_description"].apply(lambda x: " ".join([word for word in x.split() if word not in stopwords.words("english")]))
plt.subplots(figsize = (8,8))
wordcloud = WordCloud (
background_color = 'white',
width = 512,
height = 512,
).generate(' '.join(data["short_description"]))
plt.imshow(wordcloud) # image show
plt.axis('off') # to off the axis of x and y
plt.savefig('word_cloud_trend.png')
plt.show()
从图 1-6 中可以看出,这些解决方案以患者和医院为目标,主要关注可及性和应对慢性病。
图 1-6
来自目标公司简短描述的词云
你可以在此基础上看到另一个版本的单词云,这一次是根据单词的重要性进行加权,而重要性又取决于它出现的频率。如果一个单词在一个文档中频繁出现,那么它就没有一个单词在文档中稀疏出现重要。这个分数也称为单词 tf-idf 分数。
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer()
tf_idf_fit = vectorizer.fit_transform(data["short_description"])
weights = np.asarray(tf_idf_fit.mean(axis=0)).ravel().tolist()
words = vectorizer.get_feature_names()
weight_list = {x:y for x,y in zip(words, weights)}
wordcloud.generate_from_frequencies(weight_list)
plt.imshow(wordcloud) # image show
plt.axis('off') # to off the axis of x and y
plt.savefig('word_cloud_trend.png')
plt.show()
图 1-7 讲的是同一个故事。您还可以找到一些临床数据和医疗设备以及公司名称的提及。
图 1-7
Wordcloud 通过单词的 TF-IDF 得分进行加权
你当然可以扩展分析,摆弄数据,但我的想法是与你分享一种方法,从一个懒惰的角度来看待不断扩张的行业格局。
结论
你已经走了很长的路。我希望你现在对人工智能和医疗保健感兴趣。医疗保健,像任何其他系统一样,有其自身的缺陷和差距。在接下来的七个案例研究中,您将填补这些空白。但在此之前,您将学习如何设置您的系统并获取案例研究所需的数据。您还将非常简要地了解 TensorFlow 2.0 中最新和最棒的内容。
二、简介和设置
在第一章中,我介绍了医疗保健市场的基本情况,主要是美国市场。这种介绍非常简单,你甚至可以理解你自己国家的医疗保健系统;我之所以这么说,是因为许多系统不发达的国家正从美国汲取灵感,因此它们的结构和秩序可能会保持基本相同,但特定的生态系统会有一些本土风味。
现在让我们换个方式来探索这个名为 TensorFlow 的图书馆的庞然大物,以及它的新版本有什么特别之处。这个想法并不是详尽地涵盖每个主题,而是激发你的兴趣,让你开始探索你感兴趣的主题。您还将学习如何设置您的系统,以及在学习过程中可以应用的一些最佳实践。
TensorFlow 2 简介
TensorFlow 最初是谷歌的开源深度学习库,现已发展成为一个包含四个主要组件的生态系统:
-
TensorFlow Core
-
TensorFlow JS
-
TensorFlow Lite
-
TensorFlow Extended
它于 2015 年 11 月在 Apache 2.0 许可下首次推出,此后发展迅速。它现在由工具、库和资源组成,面向寻求构建 ML 和 DL 支持的应用程序的研究团体(现在甚至是企业)。
TensorFlow Core
TensorFlow Core 是帮助你开发和训练 ML/DL 模型的核心开源库。TensorFlow 2 专注于简单性和易用性,具有渴望执行、直观的高级 API 和在任何平台上灵活构建模型等更新。
TensorFlow Core 有多个扩展和库,有助于使用 TensorFlow 构建高级模型或方法,例如
-
TensorBoard
-
跟踪并可视化准确性和损失等指标
-
查看权重和偏差随时间的变化
-
显示数据
-
-
TensorFlow Federated: 这是一个框架,允许你在分散的数据上构建 DL/ML 应用。这本书提供了一整章的内容,你可以在其中深入了解这个库。
-
神经结构化学习 : 它利用信号的结构。换句话说,它试图利用输入数据之间的模式或相似性来训练 ML 模型。因此,在训练期间,标记和未标记的数据都被使用。
官方文档:
www.TensorFlow.org/neural_structured_learning/framework
-
服务模型 : 这是一个为生产环境设计的系统。在这里服务意味着部署,所以这是一个快速和肮脏的方式来部署你的 ML 模型给世界看。它可以与 TensorFlow 模型和其他第三方模型和数据集成。你是否曾经考虑过对你的 ML 模型进行文档化,但是对于如何做有点困惑?下面的代码片段(摘自官方文档)显示了将你的应用程序进行文档化是多么容易。
官方文档:
www.TensorFlow.org/tfx
# Download the TensorFlow Serving Docker image and repo
docker pull TensorFlow/serving
git clone https://github.com/TensorFlow/serving
# Location of demo models
TESTDATA="$(pwd)/serving/TensorFlow_serving/servables/TensorFlow/testdata"
# Start TensorFlow Serving container and open the REST API port
docker run -t --rm -p 8501:8501 \
-v "$TESTDATA/saved_model_half_plus_two_cpu:/models/half_plus_two" \
-e MODEL_NAME=half_plus_two \
TensorFlow/serving &
# Query the model using the predict API
curl -d '{"instances": [1.0, 2.0, 5.0]}' \
-X POST http://localhost:8501/v1/models/half_plus_two:predict
# Returns => { "predictions": [2.5, 3.0, 4.5] }
TensorFlow JS
TensorFlow JS 使 ML 模型能够在浏览器中运行,没有安装库/扩展/包的任何麻烦。只要打开一个网页,你的程序就可以运行了。
TensorFlow.js 支持 WebGL,当 GPU 可用时,它可以在后台加速您的代码。
您可以在主设备中连接或嵌入外部硬件,如笔记本电脑的网络摄像头(视觉输入),或移动设备的陀螺仪或加速度计等传感器输入。是不是很神奇?
图 2-1 显示了组成 TensorFlow JS 的不同层。
图 2-1
TensorFlow JS
TensorFlow Lite
TensorFlow Lite 是一个用于设备上推理的框架。TensorFlow Lite 适用于从微型微控制器到功能强大的移动电话的各种设备。它支持设备上的机器学习推理,具有低延迟和小二进制大小。
TensorFlow Lite 由两个主要组件组成:
-
tensorlow lite 解译器
-
tensorlow lite converter
TensorFlow Extended
TensorFlow Extended (TFX)通过多个独立且可扩展的组件,帮助您构建完整的端到端机器学习流水线。这些组件是
-
tensorflow 数据验证
-
TensorFlow 变换
-
TensorFlow 模型分析
-
TensorFlow 服务
-
ML 元数据
在图 2-2 中,你可以看到 TensorFlow Extended 覆盖了多少来自典型机器学习流水线的组件。
图 2-2
TensorFlow 扩展的组件
在 YouTube 上搜索“TensorFlow Extended a End to End Machine Learning Platform for tensor flow”,可以找到 tensor flow Extended 的非常好的概述。
TensorFlow 1.x vs 2.x
首先,如果你从未使用过 TensorFlow 1,好消息是你不会被 TensorFlow 2 的代码迷惑。但是如果你和我一样使用过 TensorFlow 1.x,那么这一节将帮助你更好地理解其中的区别。
如果我必须用一行来总结这种差异,我会说 TF 2.x 提供了更高级别的 API,这些 API 抽象了更低级别的细节,如创建和操作计算图形、张量运算等。让我们以此为基础。
TF 1.x 是什么?
先说 TF 1.x 中典型的工作流是如何定义的。在 TF 1.x 中,我们首先需要通过构建一个叫做计算图的东西来构建神经网络的蓝图。为了构建一个计算图,我们定义所有我们需要执行的常量、变量和操作。
在创建计算图之后,我们使用一个会话对象来执行该图,在该对象中对张量和操作进行了评估。 评估这里简单来说就是梯度的实际计算和参数的更新。
基本的 TensorFlow 允许你和张量一起玩。张量基本上是一个 n 维数组。所有类型的数据(即标量、向量和矩阵)都是特殊类型的张量,它们是
-
常数:常数是值不变的张量。
-
变量:变量张量可以在一个会话内更新它们的值。例如神经网络的权重和偏差。变量在使用前需要显式初始化。
-
占位符:占位符通常用于在训练神经网络时输入新的训练样本。在会话中运行图形时,我们为占位符赋值。它们不像变量一样被初始化。
使用这些不同的张量,我们使用 TF 1.x 定义任何神经网络或计算流程。下面的示例显示了 TF 1.x 代码,它定义了隐藏层的线性输出在通过激活函数之前是如何创建的:
import TensorFlow.compat.v1 as tf
tf.disable_v2_behavior()
in_a = tf.placeholder(dtype=tf.float32, shape=(4))
def nn_tfv1(x):
with tf.variable_scope("matmul"):
W = tf.get_variable("W", initializer=tf.ones(shape=(4,4)))
b = tf.get_variable("b", initializer=tf.zeros(shape=(4)))
return x * W + b
out_a = nn_tfv1(in_a)
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
hidden_output = sess.run([out_a],
feed_dict={in_a: [1, 0, 1, 0]})
有几点需要注意:
-
声明了一个具有特定数据类型和确定形状的占位符。
-
TensorFlow 使用范围来允许变量共享。大致有两种类型的作用域:名称作用域或变量作用域。向所有变量、操作和常量的名称添加前缀。另一方面,
tf.name_scope()
忽略用tf.get_variable()
创建的变量,因为它假设您知道哪个是变量,以及您想在什么范围内使用它们。因此,我们使用一个 matmul 作用域来定义 W 和 b 变量,因为它们是为 matmul 操作定义的。 -
global_variables_initializer()
允许变量在整个会话中初始化、保持和更新值。 -
我们使用在
Session
类中定义的run
方法进行评估run (fetches, feed_dict=None, options=None, run_metadata)
-
如果 fetches 是一个列表,
run
返回一个列表对象。如果它是单个张量,那么它返回 Python 数据类型。
另外,feed_dict
用于通过占位符tf
传入输入数据。
我认为这为 TF 1.x 的基础知识提供了一个非常高但必要的概述。现在让我们看看 TF 2.x 是如何改变这一切的。
拥抱 TF 2.x
让我们讨论一些使 TF2.x 对开发者友好的关键方面。
急切的执行
TensorFlow 2.x 原生支持“急切执行”。不再需要首先静态定义一个计算图,然后执行它,这将抑制即时错误记录、更快的调试和本机 Python 控制。
import TensorFlow as tf
a = tf.constant([[1,0], [0,1]], dtype = float)
print(a)
tf.Tensor(
[[1\. 0.]
[0\. 1.]], shape=(2, 2), dtype=float32)
手稿
AutoGraph 采用 eager 风格的 Python 代码,并自动将其转换为图形生成代码。
为了使用 Python 代码,我们需要添加一个装饰器@tf.function
。这将代码转换成一个等价的静态图。@tf.function
标记用于实时编译(JIT)的代码,这使得能够将 Python 函数编译成 TensorFlow 函数,因此总的来说,通过简单的 Python 逻辑,我们获得了与 TF 低级 API 相同的优化。
为了便于比较,请看下面的代码:
def huber_loss(a):
if tf.abs(a) <= delta:
loss = a * a / 2
else:
loss = delta * (tf.abs(a) - delta / 2)
return loss
在上面的函数中使用 decorator 基本上将它转换成类似于
def tf__huber_loss(a):
with tf.name_scope('huber_loss'):
def if_true():
with tf.name_scope('if_true'):
loss = a * a / 2
return loss,
def if_false():
with tf.name_scope('if_false'):
loss = delta * (tf.abs(a) - delta / 2)
return loss,
loss = ag__.utils.run_cond(tf.less_equal(tf.abs(a), delta), if_true,
if_false)
return loss
TensorFlow 数据集
TensorFlow 数据集提供了一种简单的方法来处理异构数据,如柱形图、文本、图像等。同时使处理大量和各种数据以及执行复杂的转换成为可能。
创建:创建数据
-
from_tensor_slices()
:单个(或多个)数量(或张量),支持批量 -
from_tensors()
:与上面类似,但不支持批处理 -
from_generator()
:从generator_function
获取输入
转换:转换数据
-
batch()
:将数据分成一系列预定义的大小 -
repeat()
:重复数据 -
shuffle()
:随机洗牌 -
map()
:对数据的所有元素应用函数 -
filter()
:使用函数/表达式过滤数据
优化:
GPU 和 TPU 从根本上减少了执行一个训练步骤所需的时间。实现最佳性能需要一个高效的输入流水线,在当前步骤完成之前为下一步提供信息。tf.data API 帮助我们实现了这一点。在 www.TensorFlow.org/guide/data_performance
.
有更多信息,你将在不同的案例研究中的不同地方有效地利用 tf.data,所以要保持警惕。
tf.keras
tf.keras 提供了更高的 API 级别,有三种不同的编程模型:顺序 API、函数 API 和模型子类化。
- Sequential API: Sequential 将层的线性堆栈组合成一个
tf.keras.Model
。每一层都是可调用的(输入一个张量),每一层都返回一个张量作为输出。
tf.keras.Sequential(
layers=None, name=None
)
自变量
| `layers` | 要添加到模型的可选图层列表 | | `name` | 模型的可选名称 |- Functional API:允许多个输入和输出,并建立神经网络的非线性拓扑,例如具有残差网络的神经网络。
tf.keras.Model(
*args, **kwargs
)
自变量
| `inputs` | 模型的输入:`a keras.Input`对象或`keras.Input`对象列表 | | `outputs` | 模型的输出 | | `name` | 字符串,模型的名称 |- 模型子类化:它允许你定义自己的自定义层。为了创建一个自定义层,您必须子类化
tf.keras.layers.Layer
并实现以下功能:-
__init__
:可选用于定义该层要使用的所有子层。它将所有超参数作为自变量。 -
build
:用于创建层的权重。可以用add_weight()
增加重量。 -
call
:用于定义正向传递,并在激活权重和偏差的线性输入后计算该层的输出
-
Note
使用tf.Keras
而不是Keras
,以便更好地与其他 TensorFlow APIs 集成,如急切执行和tf.data
等。
估计量
Estimators API 是在 1.1 版中添加到 TensorFlow 中的,它提供了对较低级 TensorFlow 核心操作的高级抽象。它与 Estimator 实例一起工作,Estimator 实例是 TensorFlow 对完整模型的高级表示。参见图 2-3 。
图 2-3
TF 堆栈
Keras 类似于 Estimators API,因为它抽象了深度学习模型组件,如层、激活函数和优化器,使开发人员更容易使用。参见图 2-4 。
图 2-4
TF 2.x 堆栈
因此,Estimator API 和 Keras API 都在低级核心 TensorFlow API 上提供了高级 API,您可以使用其中任何一个来训练您的模型。但是估计器 API 更好地与 TF 生态系统集成,并且针对训练和分发进行了优化,因此有时是首选的。
您可以将您的 Keras 模型转换为估计器对象,从而获得两个世界的最佳效果。转到 www.TensorFlow.org/tutorials/estimator/keras_model_to_estimator
.
最佳使用建议
使用 TF 2.x 时可以遵循一些最佳实践
-
尽可能使用来自
tf.keras
的高级 API,除非需要,否则不要默认使用 v1 来增强性能。 -
添加一个
tf.function
装饰器,使其在带有 AutoGraph 的图形模式下高效运行。 -
使用
tf.data
数据集编写高性能数据输入流水线,以利用诸如混洗、批处理和预取等功能。 -
使用模型子类化编写定制层,并将其用作功能或顺序 Keras 模型中的任何其他层。
安装和设置
在本节中,您将了解如何设置您的系统。这绝不是一个完整的指南,但它给你一些想法。我总是建议遵循官方网页上的最新文档。
Python 安装
我总是建议使用 Anaconda 来设置您的系统。要在 Windows 上设置它,请根据您的机器进入 www.anaconda.com/products/individual#windows
.
,选择正确的安装程序(图 2-5 )。
图 2-5
Anaconda Windows 安装
下载完成后点击“我同意”按钮,选择安装 Anaconda 的目标文件夹,点击下一步按钮(图 2-6 )。
图 2-6
选择目标文件夹
选择是否将 Anaconda 添加到您的 PATH 环境变量中(图 2-7 )。我不建议将 Anaconda 添加到 PATH 环境变量中,因为这会干扰其他软件。相反,通过从开始菜单打开 Anaconda Navigator 或 Anaconda 提示符来使用 Anaconda 软件。
图 2-7
选择道路
单击安装按钮。如果您想查看 Anaconda 正在安装的包,请单击 Show Details,然后单击 Next 按钮。
使用虚拟环境
对于每个案例研究,每次设置不同的虚拟环境是一个很好的实践,这样不同的版本就不会相互冲突。
为了创造一个康达的环境,
-
以管理员身份打开 Anaconda 提示符。
-
在终端中,输入
conda create -n virtual_env_name python=3.7 pip scikit-learn matplotlib numpy pandas
-
激活虚拟环境:
-
确保安装了 ipykernel:
conda activate virtual_env_name
- 将新的虚拟环境添加到 Jupyter:
pip install --user ipykernel
python -m ipykernel install --user --name='environment_name'
如果您错误地创建了一个环境,或者出于任何其他原因想要删除它,请记下以下命令:
## To remove conda environment
conda env remove -n 'environment_name'
## To remove the environment from Jupyter
jupyter kernelspec uninstall 'environment_name'
库和版本
对于您要使用的所有库,您可以安装最新版本,但可能会有一些相互依赖的情况,您需要确保维护正确的版本。
TensorFlow 和 GPU
去 www.TensorFlow.org/install/source_windows
找 CUDA 和 cuDNN 哪个版本配你的 TensorFlow 版本(图 2-8 )。我个人喜欢使用 2.2.0 或 2.3.0,但不是更高版本,因为最新版本中可能有很多未知的错误,非常旧的版本可能不适合或可能过时。
图 2-8
TensorFlow 和 GPU 版本
挑好 TensorFlow 版本以及对应的 cuDNN 和 CUDA 版本后,去 Nvidia 网站。
Nvidia CUDA 工具包支持创建 GPU 加速的应用程序。
Nvidia CUDA 深度神经网络库(cuDNN)为标准操作提供了高效的实现,如向前和向后卷积、池化、规范化和激活层。
这两者都是在 TensorFlow 环境中启用 GPU 所必需的。
cuDNN 库包含三个文件:
-
\
bin\cudnn64_7.dll (the version number may be different)
-
\include\cudnn.h
-
\lib\x64\cudnn.lib.
您应该将它们分别复制到以下位置:
-
%CUDA_Installation_directory%\bin\cudnn64_7.dll
-
%CUDA_Installation_directory%\include\cudnn.h
-
%CUDA_Installation_directory%\lib\x64\cudnn.lib
默认情况下,%CUDA_Installation_directory%
指向C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.1.
见图 2-9 。
图 2-9
CUDA 安装
将以下路径添加到环境系统变量中:
-
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.1\bin
-
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.1\libnvvp
-
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v10.1\lib\x64
对于其他操作系统,操作说明非常容易理解,并且在官方网站上有提及。
其他人
对于 TensorFlow Federated 这样的包,有一个经过测试的 TensorFlow 版本与之关联(图 2-10 )。如果您使用 pip 安装 Federated,您的 TensorFlow 版本可能会改变,因此请在运行任何代码之前进行预检查。
图 2-10
TensorFlow Federated 及其兼容版本
此外,要使用 nltk 和 scispacy 这样的包,需要一些预置。
对于 nltk,在开始使用这个包之前,确保使用nltk.download()
下载了所有需要的预包。
对于 scispacy 型号,您需要在您想要的型号链接上进行 pip 安装:
pip install scispacy
pip install <Model URL>
模型链接可以从 https://allenai.github.io/scispacy/
.
中获得
Note
Spacy 于 2021 年 1 月 31 日发布了 3.0 版。对于这些案例研究,您应该使用 v2.0,因为它是测试代码的基础。
结论
你现在有了一个坚实的基础:你知道你将与什么一起工作(第二章)和你将进入的生态系统(第一章),并准备在接下来的章节中深入探讨。我建议您按照上面的步骤来设置 Python 环境。与案例研究相关的大部分代码可以在本书的官方 GitHub 页面上的 Jupyter 笔记本中找到。
三、通过分析患者 EHR 记录预测再入院
一个出院的病人在规定的时间内回到医院,在医学术语中被称为再入院。这些再入院时间范围从 30 天到 1 年不等。监控最大保险项目医疗保险和医疗补助的 CMS 将再次住院定义为“从同一家或另一家急症医院出院后 30 天内入住急症医院。”
为什么分析这些数据如此重要?很明显,由于时间限制,如果患者在短时间内再次入院,就会引起对医疗质量的怀疑。因此,有必要将再入院率作为质量基准进行分析。像 CMS 这样的支付者计划已经将他们的报销决定与这一指标联系起来,作为《患者保护和平价医疗法案》的一部分,该法案因不寻常的高再入院率而惩罚医疗保健系统。这种惩罚可以降低 3%的报销。患者再次入院与死亡率和发病率的增加有关。因此,对医生来说,不仅要对病人入院时的疾病进行护理,还要对他们过去病历中的任何问题进行护理,这变得非常重要。
在本案例研究中,您将看到如何通过检查各种因素来预测再入院,如合并症、实验室检查值、图表事件和患者的人口统计学特征。
什么是 EHR 数据?
为了理解 EHR 的数据,让我们跟随病人咨询医生的旅程。
一般来说,当一个病人开始表现出现代世界中某种疾病的一些症状时,他们会上网或者咨询他们的朋友和家人。如果病情恶化,患者可以选择去看医生。
医生(也称为提供者)可以安排成像或实验室测试来更好地诊断问题,开处方,并记录患者的依从性和处方结果。所有这些都存储在病人的病历中。图 3-1 显示了流水线的流向。前三个步骤展示了如何创建病历。
图 3-1
病人护理的步骤
你可能已经注意到,我一直在交替使用医疗和健康记录。就我们的目的而言,我们只是使用患者的临床信息,没有区别,但实际上两者之间有一点点区别。
EMR(电子病历)跟踪一段时间内的医疗数据,包含筛查/检查信息以及对患者在某些诊断参数方面表现的观察。
EHR(电子健康记录)是 EMR 加上许多其他患者级数据。它超越了在提供商端收集的标准临床数据,还包括其他护理元素,如来自可穿戴设备的数据、患者的基因组数据以及 ECG、呼吸等信号数据。
图 3-2 显示了不同患者护理期间不同事件的时间线视图。
图 3-2
患者医疗事件时间线视图
在您的案例中,您将使用 MIMIC 3 数据集。下面是如何获得对它的访问并设置它进行分析。
我希望你对 EHR 的数据有好的了解。让我们更深入地研究 MIMIC-3 数据集,它在您完成某项测试后可用,并被许可用于研究目的。这是一个被高度引用的数据集,您将在两个案例研究中使用它,所以我希望您和我一样对开始使用它感到兴奋。
模拟 3 数据:设置和介绍
MIMIC 代表重症监护医疗信息市场。它是一个名为 PhysioNet 的大型数据集的一部分,该数据集是由许多机构提交的大型开源生理和临床数据集合。它包括与 2001 年至 2012 年间在贝斯以色列女执事医疗中心重症监护室的 40,000 多名患者相关的去身份化健康相关数据。
该数据库包括人口统计数据、在床边进行的生命体征测量(每小时约 1 个数据点)、实验室检验结果、程序、药物、护理人员记录、影像报告和死亡率(医院内外)等信息。图 3-3 给出了 MIMIC-3 数据集的概述。
图 3-3
Mimic-3 数据概述。来源:www . nature . com/articles/sdata 201635
接近
要获得访问权限,请按照 https://mimic.physionet.org/gettingstarted/access/
.
处的说明进行操作
主要遵循以下步骤:
-
创建一个 PhysioNet 帐户。
-
完成 CITI 培训课程。
-
请求 MIMIC 3 数据集。
-
进入模拟 3 号。
-
登录您的生理网账户后,在
https://physionet.org/content/mimiciii/1.4/
访问 MIMIC 3 数据集。 -
转到页面底部的文件部分。
-
Note
你需要输入一个证明人,如同事,并清楚地说明你获得访问权的目的。将向证明人发送电子邮件进行核实。除非你想把它用于商业目的,否则 PhysioNet 已经足够慷慨,可以毫无麻烦地给予许可。
网站上的说明很容易理解。如果你在哪里遇到困难,就用谷歌搜索一下。
简介和设置
如果您可以访问 AWS 或 GCP,有一个好消息:MIMIC 3 数据集存在于他们的数据集市中,随时可供查询。
最近,麻省理工学院计算生理学实验室(LCP)开始通过 AWS 公共数据集计划在 AWS 云上托管 MIMIC 3 数据集。您现在可以通过 S3 使用 MIMIC 3 数据集,而无需下载、复制或付费存储。相反,您可以使用 Amazon EC2、Athena、AWS Lambda 或 Amazon EMR 等 AWS 服务在 AWS 云中分析 MIMIC 3 数据集。
要访问云上的这些数据库,请遵循 https://mimic.physionet.org/gettingstarted/cloud/
.
中详述的步骤
我假设您没有访问这些云设施的权限,所以您将下载 zip 文件并将其用于您的目的。
在您深入研究您的问题并开始使用 MIMIC 之前,让我们先了解一下它的基础知识。正如您所看到的,MIMIC 3 可以作为一个不同的 CSV 的 zip 文件获得,这意味着它是一个组织得非常好的关系数据库。要查看 MIMIC 3 的模式或实体关系图,请访问
或者
/mit-lcp.github.io/mimic-schema-spy/relationships.html
第一个链接是使用 DbSchema 生成的,而第二个是使用开源 schema spy 生成的。我个人喜欢 DbSchema。
关于 MIMIC 要知道的一些事情:
图 3-4
每个模拟表中的 ROW_ID 列
-
所有的表至少有一个唯一的标识符,即
ROW_ID
。见图 3-4 。-
这个
ROW-ID
只是为了使行级别的值是唯一的。 -
这不应该用于连接链接变量。
-
从功能的角度来看,表可以只有一个主键,也可以有多个键的组合。这个主键或一组主键唯一地表示该表中的数据。见图 3-4 。
-
-
最重要的 id 是
-
SUBJECT_ID
:指一个独特的患者 -
HADM_ID
:指患者入院事件。 -
ICUSTAY_ID
:指患者的 ICU 发作
-
-
字典表:MIMIC 有五个以 D_XXXX 开头的字典表。它们有助于将编码信息转换成文本等人类可读的格式。
-
当前程序术语(CPT)代码的高级词典
-
D_ICD_DIAGNOSES
:疾病和相关健康问题国际统计分类词典(ICD-9)与诊断相关的代码 -
D_ICD_PROCEDURES
:疾病和相关健康问题国际统计分类词典(ICD-9)与程序相关的代码 -
D_ITEMS
:出现在模拟数据库中的本地代码(ITEMIDs
)的字典,与实验室测试相关的代码除外- 例如,
CHARTEVENTS
的每一行都与一个ITEMID
相关联。通过连接ITEMID
上的CHARTEVENTS
和D_ITEMS
,你可以找到测量的概念,如血压、呼吸频率等。
- 例如,
-
D_LABELITEMS
:出现在与实验室测试相关的模拟数据库中的本地代码(ITEMIDs
)的字典
-
-
该数据库包含动态数据,例如患者 id、患者的人口统计信息和 ICU 停留 id,以及静态数据,例如来自与时间上的每次访问相关联的实验室值的测量值等。
-
在数据收集期间,建立了两个不同的重症监护信息系统:Philips CareVue 和 iMDsoft MetaVision ICU。除了与液体摄入相关的数据(CareVue 和 MetaVision 系统之间的结构存在显著差异)之外,在构建数据库表时,数据被合并。详见
https://mimic.physionet.org/mimicdata/io/
.
-
根据 HIPAA 合规性对数据进行去标识。还记得第章 1 吗?
-
日期被随机偏移。但是音程是保留的。
-
在日期转换期间,一天中的时间、一周中的某一天以及近似的季节性保持不变。
-
年龄超过 89 岁的患者的出生日期被改变,以掩盖他们的真实年龄,并符合 HIPAA 的规定。这些病人出现在数据库中,年龄超过 300 岁。
-
使用查找和正则表达式从自由文本中删除了受保护的健康信息。
-
数据
从研究再入院问题的各种研究来看,有四类预测患者再入院的主要因素。他们是
-
社会和人口统计信息,如年龄、种族和付款人
-
入院相关信息,如出院时间、第一监护病房、转院次数、住院时间
-
尿素、血小板、白蛋白等重要元素的实验室结果。
-
患者的临床数据,如血压、心率、血糖等。
-
共病,即先前存在的慢性疾病,可影响患者疾病的严重程度。Elixhauser 用 ICD-9 编码将它们编成 29 类。最后,Quan 等人基于检查先前定义之间的不一致性,提出了增强的 ICD-9 编码方法。
表 3-1 详细列出了为预测再入院而需要计算的所有数值。
表 3-1
再次入院的预测因素
| 社会和人口统计 | 年龄 | | 性别 | | 种族划分 | | 付款人 | | 出生日期 | | 入学相关 | 放电持续时间 | | 第一监护病房 | | 卸货地点 | | 24 小时内的转院次数(对于 ICU 住院 ID) | | 重要元素的实验室结果 | 血小板(细胞×10³/微升) | | 血细胞比容% | | 白蛋白(克/分升) | | 钠(mg/dL) | | 钾(毫克/分升) | | 钙(毫克/分升) | | 患者的临床数据 | 血糖水平 | | 呼吸率 | | 血压(收缩压和舒张压) | | 心率 | | 体温 | | 共病评分 | 埃利克斯豪泽-全评分 |社会和人口统计
要获得社会和人口统计数据,您需要入院和患者数据。加载两个数据集,并按照“社会和人口统计”选项卡中的布局获取每个主题 ID 的要素。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import random
# Text Processing
import re
admissions = pd.read_csv("./Data/ADMISSIONS.csv", index_col = None)
patients = pd.read_csv("./Data/PATIENTS.csv", index_col = None)
# Convert all the date columns
admissions.ADMITTIME = pd.to_datetime(admissions.ADMITTIME, format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
admissions.DISCHTIME = pd.to_datetime(admissions.DISCHTIME, format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
admissions.DEATHTIME = pd.to_datetime(admissions.DEATHTIME, format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
patients.DOB = pd.to_datetime(patients.DOB, format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
你将按科目对录取数据进行分类,以了解录取后的情况。
admissions = admissions.sort_values(['SUBJECT_ID','ADMITTIME'])
admissions.reset_index(drop = True, inplace = True)
现在,由于您已经从患者表中获得了患者的入院时间和出生日期,您可以计算出患者的年龄。结果如图 3-5 所示。
图 3-5
年龄和年龄区间内受试者数量直方图
patient_age = {row[1]: row[2] for row in patients[['SUBJECT_ID','DOB']].itertuples()}
admissions["AGE"] = [int((adm_time.date() - patient_age[subj_id].date()).days/365)
for adm_time, subj_id in zip(admissions["ADMITTIME"], admissions["SUBJECT_ID"])]
age_plot = admissions.AGE.hist()
age_plot.set_xlabel('Age of Patients')
age_plot.set_ylabel('Count of Patients')
这个柱状图表明,年龄或多或少分布在 100 岁以上,但是有很多患者的年龄达到了 300 岁。不要迷惑。这仅仅是因为由于 HIPAA 合规性,年龄超过 89 岁的患者在 MIMIC 3 数据集中被标注为 300。89 岁以上的患者相对较少,根据他们在 ICU 停留的时间和人口统计模式,更容易识别他们,因此采取了这些措施。
您将做两件事来为您的用例获得正确的年龄图/分布。
-
您将把当前数据集中年龄为 300 岁的人随机分布到 90 到 100 岁之间的任何年龄。
-
您将移除年轻患者,最好是 18 岁以下的患者,因为他们的再入院机会很低,因为很少出现任何现有的共病和一般较好的健康状况。这也有助于纠正再入院和非再入院类别之间的任何不平衡。
admissions.loc[admissions.AGE >= 300,"AGE"] = random.choices(list(range(90,100)),k = sum(admissions.AGE >= 300))
admissions = admissions[admissions.AGE >18]
最后,您会看到数据中有超过 41 个种族,但每种类型的支持度(受试者数量)都很低。因此,你将会排挤一些种族来获得更好的代表性,而只是稍微影响精确度。见图 3-6 。
图 3-6
每个种族类型的患者人数
admissions.ETHNICITY.value_counts().head(10).sort_values().plot(kind = "barh")
def normalize_ethnicity(x):
"""
Helper Function to Normalize Ethnicity into "WHITE", "HISPANIC", "ASIAN", "BLACK" and "OTHERS"
"""
if "WHITE" in x:
return "WHITE"
elif "HISPANIC" in x:
return "HISPANIC"
elif "ASIAN" in x:
return "ASIAN"
elif "BLACK" in x:
"BLACK"
else:
return "OTHERS"
admissions.ETHNICITY.value_counts()
admissions.ETHNICITY = admissions.ETHNICITY.apply(lambda x: normalize_ethnicity(x) if pd.notnull(x) else x)
这有助于获得您想要的所有患者水平的特征。您将把受试者 ID 上的数据与录取表合并。这张录取表将用于训练你的算法。为什么要等?让我们快速看看如何合并录取表中的数据,并获得您想要的功能。
招生相关
与种族数据的原因类似,您可以将各种出院地点归入规范化类别,这将为每个地点提供更好的支持。
让我们把出院地点分成三类:医疗机构、家庭和其他:
def normalize_discharge(x):
"""
Helper Function to Normalize Discharge Location into "HOME", "MEDICAL_FACILITY", and "OTHERS"
"""
if "HOME" in x:
return "HOME"
elif len(re.findall("OTHER|DEAD",x)) > 0:
return "OTHER"
else:
return "MEDICAL_FACILITY"
admissions.DISCHARGE_LOCATION = admissions.DISCHARGE_LOCATION.apply(lambda x: normalize_discharge(x) if pd.notnull(x) else x)
通过从入院时间中减去出院时间,可以很容易地计算出以天为单位的出院地点:
admissions["DISCHARGE_DURATION"] = (admissions["DISCHTIME"] - admissions["ADMITTIME"]).dt.total_seconds()/(24*60*60)
现在,要获得您的列车标志再入院/不再入院,您需要获得每个患者到下一次入院的天数。要做到这一点,请遵循两步走的方法:
-
将下次入院时间与上次入院时间错开。
-
从下次入院时间中减去出院时间,得到下次入院前的天数。
# Step 1:- Add the next Admit Time
admissions = admissions.sort_values(['SUBJECT_ID','ADMITTIME']) #make sure the admittime is sorted before the shift operation
admissions['NEXT_ADMITTIME'] = admissions.groupby('SUBJECT_ID').ADMITTIME.shift(-1)
# Step 2:- Subtract Discharge Time from Next Admit Time
admissions['DAYS_NEXT_ADMIT']= (admissions.NEXT_ADMITTIME - admissions.DISCHTIME).dt.total_seconds()/(24*60*60)
admissions["IS_READMISSION"] = admissions.DAYS_NEXT_ADMIT.apply(lambda x: 0 if pd.isnull(x) else (0 if x >30 else 1))
此外,您只需要计划外的医疗护理,并且您的患者群体不应代表新生儿,因此请过滤掉“选择性”和“新生儿”:
admissions.ADMISSION_TYPE.value_counts()
admissions = admissions[~admissions.ADMISSION_TYPE.isin(["ELECTIVE", "NEWBORN"])].reset_index(drop = True)
# Lastly we will remove those any death related admission events.
admissions = admissions[admissions.HOSPITAL_EXPIRE_FLAG == 0].reset_index(drop = True)
admissions = admissions[["SUBJECT_ID", "HADM_ID", "AGE", "ADMISSION_TYPE","DISCHARGE_DURATION","DISCHARGE_LOCATION","INSURANCE","ETHNICITY","IS_READMISSION"]]
admissions = pd.merge(admissions, patients[["SUBJECT_ID","GENDER"]], how="left", on = "SUBJECT_ID")
到目前为止,您已经过滤了相关事件,并获得了入院相关特征的出院持续时间。您还生成了 30 天内再次入院与不再次入院的目标标签。
现在,您可以继续获取其他功能。
- 准备数据:
icustays = pd.read_csv("./Data/ICUSTAYS.csv", index_col = None)
transfers = pd.read_csv("./Data/TRANSFERS.csv", index_col = None)
# Convert all the date columns
icustays.INTIME = pd.to_datetime(icustays.INTIME, format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
icustays.OUTTIME = pd.to_datetime(icustays.OUTTIME, format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
transfers.dropna(subset=["ICUSTAY_ID"], inplace = True)
transfers.ICUSTAY_ID = transfers.ICUSTAY_ID.astype(int)
转院次数是决定患者病情危重程度的重要因素,根据合并症的不同,可以使用多个重症监护室。
由于ICUSTAYS
表中的ICUSTAY_ID
对 24 小时内的所有 ICU 入院进行分组,因此患者有可能从一种类型的 ICU 转移到另一种类型的 ICU,并具有相同的ICUSTAY_ID
。要获得特定ICUSTAYID
的确切传输次数,您可以使用TRANSFERS
表。
TRANSFERS
表包含EVENTTYPE
,其中包含两个值transfer
和admit
。您将汇总一个ICUSTAY
事件(一个独特的ICUSTAY_ID
)的所有转院,以便在 24 小时内为该患者完成转院。
transfers_num = transfers.groupby(["SUBJECT_ID","HADM_ID","ICUSTAY_ID"])['EVENTTYPE'].apply(lambda x : sum(x=="transfer")).reset_index()
transfers_num.columns = ["SUBJECT_ID","HADM_ID","ICUSTAY_ID", "NUM_TRANSFERS"]
# Updating ICU Data with number of transfer a patient undergoes once admitted
icustays = pd.merge(icustays, transfers_num, on=["SUBJECT_ID","HADM_ID","ICUSTAY_ID"], how="left")
# Making sure that if a key (SUBJECT_ID,HADM_ID,"ICUSTAY_ID") is not found then number of transfers for that key automatically becomes 0
icustays.NUM_TRANSFERS.fillna(0, inplace = True)
# ICU Transfers within 24hrs for a unique hospital admission
icustays_transfers_num = icustays.groupby(["SUBJECT_ID","HADM_ID"])["NUM_TRANSFERS"].sum().reset_index()
现在让我们来计算一次住院的相同 ICU 转院次数(独特的HADM_ID).
# ICU Transfers across days (>24 hours) for a unique hospital admission
icustays_num = icustays.groupby(["SUBJECT_ID","HADM_ID"])["ICUSTAY_ID"].nunique().reset_index()
icustays_num.columns = ["SUBJECT_ID","HADM_ID","ICU_TRANSFERS"]
在 ICU 住院期间,患者健康的另一个重要决定因素是 LOS(住院时间)。您可以从ICUSTAYS
表本身获得这些信息。
# Average Length of stay in ICU for a patient
icustays_avg_los = icustays.groupby(["SUBJECT_ID","HADM_ID"])["LOS"].mean().reset_index()
你也应该得到入院的第一监护病房。
icustays = icustays.sort_values(['SUBJECT_ID','HADM_ID','INTIME'])
icustays_firstcare = icustays.groupby(['SUBJECT_ID','HADM_ID'])['FIRST_CAREUNIT'].nth(0).reset_index()
合并SUBJECT_ID, HADM_ID.
上所有不同的数据帧
import functools
_dfs = [icustays_num, icustays_avg_los, icustays_transfers_num, icustays_firstcare]
icustays_final = functools.reduce(lambda left,right: pd.merge(left,right,on=["SUBJECT_ID","HADM_ID"], how="inner"), _dfs)
icustays_final["TOTAL_TRANSFERS"] = icustays_final["ICU_TRANSFERS"] + icustays_final["NUM_TRANSFERS"]
最后,如果第一监护病房与新生儿有关,那么它对于分析来说是不必要的,也是无足轻重的,所以你只需放弃这样的 ICU 停留。
icustays_final = icustays_final[~icustays_final.FIRST_CAREUNIT.isin(["NICU","NWARD"])].reset_index(drop = True).drop(["NUM_TRANSFERS","ICU_TRANSFERS"], axis = 1)
更多信息,请参考 https://mimic.physionet.org/mimictables/transfers/
。
患者的临床数据
患者的临床数据显示在CHARTEVENTS
表中。从历史上看,医生通常会保留患者关键临床数据和病史的完整记录,如人口统计、生命体征、诊断、药物治疗等。
现在,实验室事件和患者的临床数据都是相当大的文件,大小接近 32GB,因此能够无缝地处理它们变得势在必行。你将采用一种聪明的方式来读入和处理这些文件。
正如您已经知道的,您正在与CHARTEVENTS
的子集一起工作,这些子集是帮助了解患者健康状况的基本要素。您将尝试从如此大的表中查找仅用于那些图表事件的信息。
对于出现在CHARTEVENTS
中的每个患者的临床事件,都有一个与之相关联的ITEMID
,其定义出现在D_ITEM
表中。我们来看看临床值对应的ITEMID
s。
dictionary_itemid = pd.read_csv("./Data/D_ITEMS.csv", index_col = None)
dictionary_itemid.dropna(subset=["LABEL"], inplace = True)
# We only need those ITEM IDs which links to chart events
dictionary_itemid = dictionary_itemid[dictionary_itemid.LINKSTO.isin(["chartevents"])]
要获得ITEMID
s,请遵循以下步骤:
-
将你希望作为描述出现的单词组合起来。
-
用你的领域知识过滤掉
ITEMID
s。
dictionary_itemid = pd.read_csv("./Data/D_ITEMS.csv", index_col = None)
dictionary_itemid.dropna(subset=["LABEL"], inplace = True)
# We only need those ITEM IDs which links to chart events
dictionary_itemid = dictionary_itemid[dictionary_itemid.LINKSTO.isin(["chartevents"])]
dictionary_itemid[[ True if ("sys" in x.lower() and len(re.findall("bp|blood pressure|blood",x.lower())) > 0) else False for x in dictionary_itemid.LABEL]]
sys_bp_itemids = [51, 442, 6701, 220050, 220179]
dictionary_itemid[[ True if ("dia" in x.lower() and len(re.findall("bp|blood pressure|blood",x.lower())) > 0) else False for x in dictionary_itemid.LABEL]]
dia_bp_itemids = [8368, 8440, 8555, 220051, 220180]
dictionary_itemid[[ True if ("resp" in x.lower() and len(re.findall("rate",x.lower())) > 0) else False for x in dictionary_itemid.LABEL]]
respr_itemids = [615, 618, 3603, 224690, 220210]
dictionary_itemid[[ True if ("glucose" in x.lower()) else False for x in dictionary_itemid.LABEL]]
glucose_itemids = [1455, 1310, 807, 811, 3744, 3745, 1529, 2338, 225664, 220621, 226537]
# Similarly
heartrate_itemids = [211, 220045]
temp_itemids = [676, 678, 223761, 223762]
读取CHARTEVENTS
数据。保留您在ICUSTAY
中找到的HADM_ID
和相关的ITEMID
hadm_filter = icustays_final.HADM_ID.tolist()
total_itemids = sys_bp_itemids+dia_bp_itemids+respr_itemids+glucose_itemids+temp_itemids+heartrate_itemids
n_rows = 100000
# create the iterator
chartevents_iterator = pd.read_csv(
"./Data/CHARTEVENTS.csv",
iterator=True,
chunksize=n_rows,
usecols = ["SUBJECT_ID", "HADM_ID", "ICUSTAY_ID", "ITEMID", "VALUE", "VALUENUM", "VALUEUOM"])
# concatenate according to a filter to get our labevents data
chartevents = pd.concat(
[chartevent_chunk[np.logical_and(chartevent_chunk['HADM_ID'].isin(hadm_filter),
chartevent_chunk['ITEMID'].isin(total_itemids))] if str(chartevent_chunk.HADM_ID.dtype) == 'int64'
else chartevent_chunk[np.logical_and(chartevent_chunk['HADM_ID'].isin([float(x) for x in hadm_filter]),
chartevent_chunk['ITEMID'].isin(total_itemids))]
for chartevent_chunk in chartevents_iterator])
chartevents.dropna(axis = 0, subset = ["VALUENUM"], inplace = True)
chartevents.drop('VALUE', axis = 1, inplace = True)
由于CHARTEVENTS
数据是从两个不同的系统中收集的,因此检查事件的不同报告单位变得很重要。我们赶紧来看看吧。
# Since the data is collected from two different systems let us check for units for each of our patients clinical data
print("Systolic BP :-
",chartevents[chartevents.ITEMID.isin(sys_bp_itemids)].VALUEUOM.unique())
print("Diastolic BP :-
",chartevents[chartevents.ITEMID.isin(dia_bp_itemids)].VALUEUOM.unique())
print("Respiratory Rate :-
",chartevents[chartevents.ITEMID.isin(respr_itemids)].VALUEUOM.unique())
print("Glucose Levels :-
",chartevents[chartevents.ITEMID.isin(glucose_itemids)].VALUEUOM.unique())
print("Heart Rate :-
",chartevents[chartevents.ITEMID.isin(heartrate_itemids)].VALUEUOM.unique())
print("Temperature :-
",chartevents[chartevents.ITEMID.isin(temp_itemids)].VALUEUOM.unique())
输出
###############################################
Systolic BP :- ['mmHg']
Diastolic BP :- ['mmHg']
Respiratory Rate :- ['insp/min' 'BPM']
Glucose Levels :- [nan 'mg/dL']
Heart Rate :- ['bpm' 'BPM']
Temperature :- ['?F' '?C' 'Deg. F' 'Deg. C']
##############################################
上面有三个观察结果:
-
insp/min 与 BPM 相同,因此这里不需要转换。
-
您不会估算葡萄糖中的 NA,因为该值与单位存在时的范围相同。
-
你需要把华氏温度转换成摄氏温度。
让我们用它们的描述性标签来代替ITEMID
来帮助阅读,并使它们指向一个单独的类别。
# Let us Replace ItemIds by their respective Chart Event Names to aid readability
mapping = {"Systolic_BP":sys_bp_itemids,
"Diastolic_BP":dia_bp_itemids,
"Resp_Rate":respr_itemids,
"Glucose":glucose_itemids,
"Heart_Rate":heartrate_itemids,
"Temperature":temp_itemids}
item_id_map = {item_id: k for k,v in mapping.items() for item_id in v}
chartevents["ITEMID"] = chartevents["ITEMID"].replace(item_id_map)
让我们把华氏温度转换成摄氏温度:
cond1 = np.logical_and(np.logical_or(chartevents["VALUEUOM"] == "?F", chartevents["VALUEUOM"] == "Deg. F"),
pd.notnull(chartevents["VALUEUOM"])).tolist()
cond2 = np.logical_or(chartevents["VALUEUOM"] != "?F", chartevents["VALUEUOM"] != "Deg. F").tolist()
condval1 = ((chartevents["VALUENUM"]-32)*5/9).tolist()
condval2= chartevents["VALUENUM"].tolist()
chartevents["VALUENUM"] = np.select([cond1, cond2], [condval1,condval2])
这将为您带来数据中所有患者的标准化图表数据。为了进行分析,您将使用两种度量方法:一种是集中趋势(平均值)的度量方法,另一种是可变性(标准差)的度量方法:
charts = chartevents.pivot_table(index=['SUBJECT_ID', 'HADM_ID'],
columns='ITEMID', values='VALUENUM',
aggfunc=[np.mean, np.std]).reset_index()
charts.columns = charts.columns.get_level_values(0)+'_'+charts.columns.get_level_values(1)
取决于 MIMIC 中捕获的数据,将会有许多空值,一般来说,标准差列的空值数量会大于平均值列,因为HADM_ID
有许多单个值,但差别并不大,如您所见:
输出
###############################################
SUBJECT_ID_ 0
HADM_ID_ 0
mean_Diastolic_BP 10988
mean_Glucose 610
mean_Heart_Rate 111
mean_Resp_Rate 134
mean_Systolic_BP 10981
mean_Temperature 241
std_Diastolic_BP 11215
std_Glucose 2557
std_Heart_Rate 121
std_Resp_Rate 173
std_Systolic_BP 11212
std_Temperature 687
###############################################
这些空值中的一部分可以通过使用上次入院访视的值进行回填来纠正:
charts = charts.groupby(['SUBJECT_ID_']).apply(lambda x: x.bfill())
让我们检查一下您能够纠正多少个空值。看起来您能够从列中删除几个空值。
输出
###############################################
SUBJECT_ID_ 0
HADM_ID_ 0
mean_Diastolic_BP 9053
mean_Glucose 526
mean_Heart_Rate 97
mean_Resp_Rate 116
mean_Systolic_BP 9047
mean_Temperature 210
std_Diastolic_BP 9258
std_Glucose 2131
std_Heart_Rate 107
std_Resp_Rate 150
std_Systolic_BP 9255
std_Temperature 600
###############################################
实验室活动
与CHARTEVENTS,
类似,您将首先找到与您想要关注的实验室事件相对应的ITEMD
,然后使用它来读取实验室数据。
hadm_filter = icustays_final.HADM_ID.tolist()
total_labitems = [51265, 51221, 50862, 50983, 50971, 50893]
n_rows = 100000
# create the iterator
labevents_iterator = pd.read_csv(
"./Data/LABEVENTS.csv",
iterator=True,
chunksize=n_rows)
# concatenate according to a filter to get our labevents data
labevents = pd.concat(
[labevent_chunk[np.logical_and(labevent_chunk['HADM_ID'].isin(hadm_filter),
labevent_chunk['ITEMID'].isin(total_labitems))]if str(labevent_chunk.HADM_ID.dtype) == 'int64'
else labevent_chunk[np.logical_and(labevent_chunk['HADM_ID'].isin([float(x) for x in hadm_filter]),
labevent_chunk['ITEMID'].isin(total_labitems))]
for labevent_chunk in labevents_iterator])
让我们用真实的名字代替ITEMID
s。
labevents_label = dictionary_labitemid[dictionary_labitemid.ITEMID.isin(total_labitems)]
item_id_map = dict(zip(labevents_label.ITEMID,labevents_label.LABEL))
labevents["ITEMID"] = labevents["ITEMID"].replace(item_id_map)
让我们快速检查一下是否需要对任何单位进行归一化。
labevents.groupby(["ITEMID"])['VALUEUOM'].apply(lambda x: set(x))
输出
###############################################
Albumin {nan, g/dL}
Calcium, Total {nan, mg/dL}
Hematocrit {nan, %}
Platelet Count {nan, K/uL}
Potassium {nan, mEq/L}
Sodium {nan, mEq/L}
###############################################
看起来你很擅长处理实验室事件。所有不同的事件都有一个单一类型的单位。
与CHARTEVENTS,
类似,您将计算实验室事件值的平均值和标准偏差,然后填充任何缺失值。
labs = labevents.pivot_table(index=['SUBJECT_ID', 'HADM_ID'],
columns='ITEMID', values='VALUENUM',
aggfunc=[np.mean, np.std]).reset_index()
labs.columns = labs.columns.get_level_values(0)+'_'+labs.columns.get_level_values(1)
labs = labs.groupby(['SUBJECT_ID_']).apply(lambda x: x.bfill())
labs.isnull().sum()
输出
###############################################
SUBJECT_ID_ 0
HADM_ID_ 0
mean_Albumin 16302
mean_Calcium, Total 1849
mean_Hematocrit 17
mean_Platelet Count 30
mean_Potassium 139
mean_Sodium 153
std_Albumin 30443
std_Calcium, Total 5083
std_Hematocrit 746
std_Platelet Count 836
std_Potassium 1026
std_Sodium 1104
###############################################
共病评分
合并症对预测患者死亡率很重要,较高的合并症会对死亡率产生不利影响。Elixhauser 和 Quan 的广泛研究给出了患者共病水平的数值。更多详情请点击 www.ncbi.nlm.nih.gov/pmc/articles/PMC6381763/
.
创建这种共病评分的大多数想法都是从 MIMIC 的原始回购计算出来的;参见 https://github.com/MIT-LCP/mimic-code/blob/master/concepts/comorbidity/elixhauser_quan.sql
.
diagnosis_icd = pd.read_csv("./Data/DIAGNOSES_ICD.csv", index_col = None)
mapping = {'congestive_heart_failure':['39891','40201','40211','40291','40401','40403','40411','40413','40491','40493','4254','4255','4257','4258','4259',
'428'],
'cardiac_arrhythmias':['42613','42610','42612','99601','99604','4260','4267','4269','4270','4271','4272','4273','4274','4276','4278','4279','7850','V450','V533'],
'valvular_disease':['0932','7463','7464','7465','7466','V422','V433',
'394','395','396','397','424'],
'pulmonary_circulation_disorder':['4150','4151','4170','4178','4179',
'416'],
'peripheral_vascular_disorder':['0930','4373','4431','4432','4438','4439','4471','5571','5579','V434','440','441'],
'hypertension':['401','402','403','404','405'],
'paralysis':['3341','3440','3441','3442','3443','3444','3445','3446','3449','342','343'],
'other_neurological':['33392','3319','3320','3321','3334','3335','3362','3481','3483','7803','7843', '334','335','340','341','345'],
'chronic_pulmonary_disease':['4168','4169','5064','5081','5088', '490','491','492','493','494','495','496','500','501','502','503','504','505'],
'diabetes_w_complications':['2504','2505','2506','2507','2508','2509'],
'hypothyroidism':['2409','2461','2468', '243','244'],
'renal_failure':['40301','40311','40391','40402','40403','40412','40413','40492','40493', '5880','V420','V451', '585','586','V56'],
'liver_disease':['07022','07023','07032','07033','07044','07054',
'0706','0709','4560','4561','4562','5722','5723','5724','5728','5733','5734','5738','5739','V427','570','571'],
'chronic_ulcer':['5317','5319','5327','5329','5337','5339','5347','5349'],
'hiv_aids':['042','043','044'],
'lymphoma':['2030','2386','200','201','202'],
'metastasis_solid_tumor':['140','141','142','143','144','145','146','147','148','149','150','151','152','153','154','155','156','157','158','159','160','161','162','163','164','165','166','167','168','169','170','171','172','174','175','176','177','178','179' ,'180','181','182','183','184','185','186','187','188','189','190','191','192','193','194','195'],
'rheumatoid_arthiritis':['72889','72930','7010','7100','7101','7102','7103','7104','7108','7109','7112','7193','7285', '446','714','720','725'],
'coagulation_deficiency':['2871','2873','2874','2875', '286'],
'obesity':['2780'],
'weight_loss':['7832','7994', '260','261','262','263'],
'fluid_electrolyte_disorders':['2536','276'],
'blood_loss_anemia':['2800'],
'deficiency_anemia':['2801','2808','2809', '281'],
'alcohol_abuse':['2652','2911','2912','2913','2915','2918','2919', '3030', '3039','3050','3575','4255','5353','5710','5711','5712','5713','V113', '980'],
'drug_abuse':['V6542', '3052','3053','3054','3055','3056','3057','3058','3059', '292','304'],
'psychoses':['29604','29614','29644','29654','2938','295','297','298'],
'depression':['2962','2963','2965','3004','309','311']}
mapping_score = pd.DataFrame({'congestive_heart_failure':9,
'cardiac_arrhythmias':8,
'valvular_disease':0,
'pulmonary_circulation_disorder':3,
'peripheral_vascular_disorder':4,
'hypertension':-2,
'paralysis':4,
'other_neurological':5,
'chronic_pulmonary_disease':3,
'diabetes_w_complications':1,
'hypothyroidism':0,
'renal_failure':7,
'liver_disease':7,
'chronic_ulcer':0,
'hiv_aids':0,
'lymphoma':8,
'metastasis_solid_tumor':17,
'rheumatoid_arthiritis':0,
'coagulation_deficiency':12,
'obesity':-5,
'weight_loss':10,
'fluid_electrolyte_disorders':11,
'blood_loss_anemia':-3,
'deficiency_anemia':0,
'alcohol_abuse':0,
'drug_abuse':-11,
'psychoses':-6,
'depression':-5}, index = [0])
你应该把 ICD_9 编码映射到它所代表的合并症上。您将使用get_mapping
函数根据 ICD-9 编码获得共病标签。
def get_mapping(icd_code, mapping):
for k,v in mapping.items():
if str(icd_code) in v:
return k
elif str(icd_code)[:4] in v:
return k
elif str(icd_code)[:3] in v:
return k
return None
diagnosis_icd["ICD9_CODE"] = diagnosis_icd.ICD9_CODE.apply(lambda x: get_mapping(x, mapping) if pd.notnull(x) else None)
diagnosis_icd.dropna(subset = ['ICD9_CODE'], axis =0, inplace = True)
让我们向上旋转表格,将共病表示为一个列,将该受试者的共病次数和住院次数表示为单元格值。
diagnosis_icd = diagnosis_icd.drop_duplicates(['SUBJECT_ID', 'HADM_ID','ICD9_CODE'])[['SUBJECT_ID', 'HADM_ID','ICD9_CODE']].pivot_table(index=['SUBJECT_ID', 'HADM_ID'],
columns='ICD9_CODE',
aggfunc=len, fill_value = 0).reset_index()
最后,你把这些共病乘以 Elixhauser 给出的效应值,然后再由 Quan 改进。
diagnosis_icd["ELIXHAUSER_SID30"] = diagnosis_icd.iloc[:,2:].multiply(np.array(mapping_score[list(diagnosis_icd.iloc[:,2:].columns)]), axis='columns').fillna(0).sum(axis = 1)
diagnosis_icd = diagnosis_icd[['SUBJECT_ID', 'HADM_ID','ELIXHAUSER_SID30']]
最后一步,将所有数据合并在一起进行分析,并检查是否有丢失的数据。
import functools
_dfs = [admissions, diagnosis_icd, charts, labs, icustays_final]
train_data = functools.reduce(lambda left,right: pd.merge(left,right,on=["SUBJECT_ID","HADM_ID"], how="inner"), _dfs)
合并数据集中的空值数量为
输出
###############################################
SUBJECT_ID 0
HADM_ID 0
AGE 0
ADMISSION_TYPE 0
DISCHARGE_DURATION 0
DISCHARGE_LOCATION 0
INSURANCE 0
ETHNICITY 3777
IS_READMISSION 0
ADMITTIME 0
GENDER 0
ELIXHAUSER_SID30 0
mean_Diastolic_BP 7046
mean_Glucose 185
mean_Heart_Rate 40
mean_Resp_Rate 56
mean_Systolic_BP 7042
mean_Temperature 54
std_Diastolic_BP 7200
std_Glucose 1346
std_Heart_Rate 44
std_Resp_Rate 71
std_Systolic_BP 7197
std_Temperature 153
mean_Albumin 9488
mean_Calcium, Total 611
mean_Hematocrit 5
mean_Platelet Count 8
mean_Potassium 23
mean_Sodium 32
std_Albumin 20102
std_Calcium, Total 2162
std_Hematocrit 218
std_Platelet Count 240
std_Potassium 325
std_Sodium 374
LOS 2
FIRST_CAREUNIT 0
TOTAL_TRANSFERS 0
###############################################
您将遵循三个步骤来填充缺失的值。采用这种交错方法时,要记住在任何时候进行插补时,你都在使用尽可能接近的近似值。
-
最初,您在受试者 ID 和医院就诊级别回填缺失的实验室和临床值,因为这是最接近的估计值。但是现在最接近的估计是在受试者 ID 级别回填所有数值,假设单个患者可能具有与他们上次就诊时相同的特征。
-
第二,你按
SUBJECT_ID
分组,并按平均值这样的集中趋势进行估算。 -
最后,你按种族、年龄和性别分组,并估算平均值。这将为您填充所有缺少的值。
更多的细节在官方的 GitHub 报告中。
患者代表建模
为临床预测任务开发的机器学习模型能够帮助护理人员决定适当的治疗。然而,这些临床决策工具通常不是针对特定亚人群开发的,或者是针对单个亚人群开发的,并且可能受到数据匮乏的影响。这些不同亚群的存在引起了一个多方面的问题:
-
为整个患者群体建立的单一模型并不意味着在不同的患者亚群体中有同样好的表现。
-
在每个不同的患者亚群体中学习的独立模型没有利用在患者亚群体中常见的共享知识。
在您的数据集中,您处理的是一组不同的个体,因此对整个群体使用一个模型会降低性能。此外,每个群体有不同的模型会抑制跨群体的学习,因此会导致整体性能下降。
H. Suresh,J. Gong 等人在他们的论文《学习多任务学习:ICU 中的异质性患者群体》中首次提出了针对 ICU 中的异质性患者群体的想法。作者将死亡率预测作为 ICU 患者的一个问题,并展示了多任务学习设置如何帮助解释模拟数据中的不同人群。
您将尝试解决这个问题,但需要稍加修改:
-
患者代表
-
群组发现
自编码器简介
自编码器是通过无监督或半监督训练技术学习的前馈神经网络。通常,自编码器学习的方式是通过重新创建输入,这利用了编码器和解码器。本质上,自编码器致力于最小化重建误差。这种重构由解码器从编码器的压缩表示中完成。
自编码器中可以使用任何类型的数据面板数据、文本数据甚至图像数据。这只是意味着编码器和解码器的级联网络可以由不同类型的神经网络层构成:密集、rnn/lstm 和卷积。
根据下面列出的各种因素,可以有不同类型的自编码器。这些因素包括
-
瓶颈层的维度:欠完整(如香草自编码器、稀疏自编码器等。)和过完备(像去噪自编码器)
-
用于训练的神经元数量:稀疏自编码器
-
训练方法:堆叠自编码器、去噪自编码器等。
-
预期输出:可变自编码器(生成式)与传统(非生成式,如去噪和普通自编码器)
图 3-7 显示了一个普通的自编码器。每个自编码器都包含一个瓶颈层,它限制了输入的潜在表示的维数。
图 3-7
Vanilla 自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编
TensorFlow 中的要素列
为了完成您的任务,首先您需要选择与患者特征相关的列,并帮助更好地表示它们。表 3-2 显示了反映患者特征不同方面的不同栏目。
表 3-2
代表患者的特征
| 人口统计数据 | 年龄 | 数字/连续 | | 保险 | 绝对的 | | 种族划分 | 绝对的 | | 性别 | 绝对的 | | 并存病 | elixhauser _ sid 30 | 数字/连续 | | 患者临床数据 | 第一监护病房 | 绝对的 | | 所有实验室和图表事件相关功能 | 数字/连续 |现在,为了将您的特征编码到神经网络中,您将使用 TensorFlow 中提供的一个称为特征列的优秀特征。它们对 TF2.0 并不陌生,但作为一个概念相对较新(2017 年末推出)。
所有深度网络都处理数字(tf.float32 ),但是正如您所看到的,即使对于输入数据,您也可以有一系列的输入数据类型,从分类数据到数字数据,甚至是自由文本列。要素列有助于将原始数据无缝转换为数字格式,并尝试输入要素的不同表示。
您将使用DenseFeatures
层将它们输入到您的 Keras 模型中:
tf.keras.layers.DenseFeatures()
这是一个基于给定的feature_columns
产生密集张量的层。更多信息请访问 www.tensorflow.org/versions/r2.0/api_docs/python/tf/keras/layers/DenseFeatures
.
表 3-3 帮助您找出不同的特性列以及它们处理的数据类型。
表 3-3
TF 2.0 中的功能列
|特征列类型
|
描述
|
数据类型
|
| --- | --- | --- |
| 数字列 | 代表真正有价值的特征。数据保持不变。 | 数字的 |
| 分桶柱 | 将实值特征分类,并对其进行一次性编码。桶由边界/切割决定。 | 数字的 |
| 带词汇的分类列 | One-hot 编码一组固定的分类值。 | 绝对的 |
| 嵌入柱 | 通常用于分类值数量非常大的情况,因此会生成一个较低维度的表示,而不是稀疏的一键编码。 | 绝对的 |
| 带有哈希桶的分类列 | 对不同的分类值进行哈希运算,并放入其中一个哈希桶。可以优化桶的数量。注意:当应用散列时,可能会导致冲突。 | 绝对的 |
| 交叉特征 | 在需要功能交互的情况下使用。注意:并不是所有的组合都被创建。相反,使用散列方法。 | 绝对的 |
使用 tf.data 创建输入流水线
tf.data API 使您能够构建自定义输入流水线,并处理从不同格式读取的大量数据。它提供了包含元素序列的抽象 td.data.Dataset。这些元素可以是任何类型。
对于您的情况,您将使用tf.data.Dataset.from_tensor_slices
。它是一种静态方法,将不同的元素组合到一个数据集中,例如将预测变量和目标变量组合到一个数据集中。更多信息请访问 www.tensorflow.org/api_docs/python/tf/data/Dataset#from_tensor_slices
。
import os
import tensorflow as tf
from tensorflow import feature_column
from tensorflow.keras import layers
tf.keras.backend.set_floatx('float32')
tf.random.set_seed(123)
np.random.seed(123)
random.seed(123)
os.environ['PYTHONHASHSEED']=str(123)
def df_to_dataset(dataframe, target_col_name, shuffle=True, batch_size=32, autoenc=True):
"""
A utility method to create a tf.data dataset from a Pandas Dataframe
"""
dataframe = dataframe.copy()
labels = dataframe.pop(target_col_name)
if autoenc:
ds = tf.data.Dataset.from_tensor_slices((dict(dataframe), feature_layer(dict(dataframe)).numpy()))
else:
ds = tf.data.Dataset.from_tensor_slices((dict(dataframe), labels))
if shuffle:
ds = ds.shuffle(buffer_size=len(dataframe))
ds = ds.batch(batch_size)
return ds
在df_to_dataset
函数中需要注意的一点是,使用tensor_slices
函数创建训练标签是多么容易,甚至对于作为输出的数据帧也是如此!
Note
尽管 target 列对自编码器没有意义,但该函数仍然是通用的,供以后使用。
在创建特性列之前,必须确保数据框架中的变量名符合 TensorFlow 的variable_scope
。您可以在此找到更多信息:
github.com/tensorflow/tensorflow/blob/r1.2/tensorflow/python/framework/ops.py%2523L2993
import pandas as pd
import numpy as np
import random
import re
data =pd.read_csv("./train.csv", index_col = None)
data.columns = [re.sub(r"[,.;@#?!&$]+\ *", " ",x).replace('/\s\s+/g', ' ').replace(" ","_") for x in data.columns]
在定义了feature
列之后,您将创建一个层来将它们输入到您的 Keras 模型中。为此,您将使用DenseFeatures
层。此外,由于您只有数字列和分类列,您将让数字列保持原样,并对分类变量进行一次性编码。
但在此之前,让我们确保在将数字列吸收到神经网络中之前,已经对它们进行了缩放。在训练你的自编码器之前,这是一个非常重要的步骤,因为所有的神经网络都是基于梯度下降的。拥有未缩放的数据会使您损失巨大,并且网络实际上不会正确收敛,因为这会导致某些要素的权重具有更多的表示。
num_cols = ['AGE', 'ELIXHAUSER_SID30', 'mean_Diastolic_BP', 'mean_Glucose',
'mean_Heart_Rate', 'mean_Resp_Rate', 'mean_Systolic_BP',
'mean_Temperature', 'std_Diastolic_BP', 'std_Glucose', 'std_Heart_Rate',
'std_Resp_Rate', 'std_Systolic_BP', 'std_Temperature', 'mean_Albumin',
'mean_Calcium_Total', 'mean_Hematocrit', 'mean_Platelet_Count',
'mean_Potassium', 'mean_Sodium', 'std_Albumin', 'std_Calcium_Total',
'std_Hematocrit', 'std_Platelet_Count', 'std_Potassium', 'std_Sodium']
from sklearn import preprocessing
min_max_scaler = preprocessing.MinMaxScaler()
data_minmax = min_max_scaler.fit(data[num_cols])
data_num = data_minmax.transform(data[num_cols])
data_scaled = pd.concat([pd.DataFrame(data_num, columns = num_cols),
data[['INSURANCE', 'ETHNICITY', 'GENDER', 'FIRST_CAREUNIT','IS_READMISSION']]],
axis = 1)
您还将把数据分成训练集和验证集,以便稍后测试 autoencoder 的性能。
from sklearn.model_selection import train_test_split
train, val = train_test_split(data_scaled, test_size=0.2)
创建功能列
您终于可以创建您的特征列了。在下面的代码中,您可以看到如何处理数值列和分类列:
feature_columns = []
# numeric cols
for numeric_cols in num_cols:
feature_columns.append(feature_column.numeric_column(numeric_cols))
# categorical cols
for cat_cols in ['INSURANCE', 'ETHNICITY', 'GENDER', 'FIRST_CAREUNIT']:
categorical_column = feature_column.categorical_column_with_vocabulary_list(
cat_cols, train[cat_cols].unique())
indicator_column = feature_column.indicator_column(categorical_column)
feature_columns.append(indicator_column)
feature_layer = layers.DenseFeatures(feature_columns)
构建堆叠式自编码器
现在,您将把 train 和 validation pandas 数据帧转换为 TensorFlow 的Dataset
类。请注意,在下面的代码中,除了训练和验证数据之外,您还为下一个任务保留了完整的未训练数据,这就是群组发现。
batch_size = 32
train_ds = df_to_dataset(train,
target_col_name='IS_READMISSION',
batch_size=batch_size)
val_ds = df_to_dataset(val,
target_col_name='IS_READMISSION',
batch_size=batch_size)
full_ds = df_to_dataset(data_scaled,
target_col_name='IS_READMISSION',
batch_size=batch_size,
shuffle = False)
# To modularize the shape of output layer in the autoencoder
output_shape = feature_layer(next(iter(train_ds))[0]).numpy().shape[1]
创建自编码器非常简单。你只需要记住几件事:
-
有两种不同的子模型代表编码器和解码器。
-
尝试逐步减小
DenseLayer
的尺寸。 -
确保输入和输出张量形状匹配。
-
既然是回归问题,可以用 mse 作为你的损失函数。如果您的所有特征都是 0 或 1,就像黑白图像的情况一样,您还可以使用二进制交叉熵损失来更快地收敛网络。
-
在列车组上没有观察到过度配合。这可能在不知不觉中发生,因为您使用的是超过 2k 个参数的非常小的数据。
encoder = tf.keras.Sequential([
feature_layer,
layers.Dense(32, activation = "selu", kernel_initializer="lecun_normal"),
layers.Dense(16, activation = "selu", kernel_initializer="lecun_normal"),
layers.Dense(8, activation = "selu", kernel_initializer="lecun_normal"),
layers.Dense(4, activation = "selu", kernel_initializer="lecun_normal"),
layers.Dense(2, activation = "selu", kernel_initializer="lecun_normal")
])
decoder = tf.keras.Sequential([
layers.Dense(4, activation = "selu", kernel_initializer="lecun_normal", input_shape=[2]),
layers.Dense(8, activation = "selu",kernel_initializer="lecun_normal"),
layers.Dense(16, activation = "selu",kernel_initializer="lecun_normal"),
layers.Dense(32, activation = "selu",kernel_initializer="lecun_normal"),
layers.Dense(output_shape, activation = "selu", kernel_initializer="lecun_normal"),
])
stacked_ae = tf.keras.Sequential([encoder, decoder])
stacked_ae.compile(loss='mse', metrics = "mean_absolute_error",
optimizer= tf.keras.optimizers.Adam(learning_rate=0.01))
history = stacked_ae.fit(train_ds,
validation_data = val_ds,
epochs=15)
关于上面代码中发生的一些事情:
图 3-8
SELU 激活函数
-
请注意将要素图层用作编码器子模型的输入图层。
-
所有密集层尺寸(32、16、8)以交错方式减小,并且小于最大尺寸,在您的例子中是 41,等于
output_shape
。这迫使网络学习特征的更精简的表示。 -
注意卢瑟作为激活函数的使用。SELU 或缩放指数线性单位是一种相对较新的激活函数,具有许多优点,如权重和偏差的内部归一化,它将权重的平均值集中为零,并保证不会发生消失和爆炸梯度问题,这在直观上是有意义的,因为权重遵循标准正态分布。激活功能如图 3-8 所示。该图像改编自 Z. Huang 等人的论文“SNDCNN:用于语音识别的具有缩放指数线性单元的自归一化深度 CNN”。
让我们看看不同时期的性能指标和损失图表如何寻找验证图表。图表中的背离意味着要么拟合不足,要么拟合过度。在您的案例中,您没有观察到这样的问题。参见图 3-9 。
图 3-9
损失和性能指标的培训和验证图
# Plotting libraries and parameters
import matplotlib.pyplot as plt
plt.figure(figsize=(12,8))
import seaborn as sns
mae = history.history['mean_absolute_error']
val_mae = history.history['val_mean_absolute_error']
loss = history.history['loss']
val_loss = history.history['val_loss']
epochs_range = range(15)
plt.subplot(1, 2, 1)
plt.plot(epochs_range, mae, label='Training MAE')
plt.plot(epochs_range, val_mae, label='Validation MAE')
plt.legend(loc='upper right')
plt.title('Training and Validation MAE')
plt.subplot(1, 2, 2)
plt.plot(epochs_range, loss, label='Training Loss')
plt.plot(epochs_range, val_loss, label='Validation Loss')
plt.legend(loc='upper right')
plt.title('Training and Validation Loss')
plt.show()
让我们也保存您的模型以供将来参考。
stacked_ae.save('trained_model')
群组发现
现在,让我们使用上一节中训练的自编码器的患者级特征的压缩表示。您将转移焦点,以查看您的数据中存在多少患者群,您将针对这些患者群进行多任务学习。
什么是理想的群组集合?
在深入研究不同的技术之前,下面是聚类算法应该如何表现:
-
能够使用完整的数据进行聚类
-
感知噪声,以便具有不同特征的小患者群体不会扭曲聚类
-
健康的集群规模和相似的患病率。由算法形成的聚类应该具有适当的 n 大小和相同的患病率,这基本上意味着再入院和未再入院患者的数量应该相似。
-
不像 GMM 的情况那样预先假设属于一个聚类的点的分布。
-
您也不太关心在数据中找到嵌入的结构。你也不要太专注于寻找密集的星团,把其他的都渲染成噪音。由于这些原因,分层聚类或基于密度的聚类超出了范围。
这意味着您可以使用基于质心的聚类算法,如 k-means。现在,k-means 并不适合上面列出的所有预期行为,但是您仍然可以通过更改初始化策略和聚类数来缓解其中的一些问题。此外,你将保持对边界点的额外关注。如果有很多边界点,那么也许你将不得不选择另一种聚类算法,比如 GMM。
优化 K 均值性能
K-means 存在于 sklearn 库中,并提供了多种对数据进行聚类的选项。在 https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html
的官方页面文档中有很多关于这方面的信息。
这里需要注意的关键参数是
-
n_clusters
:聚类数。因为它是基于质心的聚类算法,所以您需要预先提供聚类的数量。 -
init
:选择初始质心的参数。 -
n_init
:质心初始化的次数(用不同的种子)。 -
max_iter
:运行 k 均值的次数 -
algorithm
:使用哪种算法,eklan 还是 auto。您不会接触到此参数,因为算法是根据数据(密集或稀疏)自动选择的。
让我们一个接一个地讨论每一个参数。
init
参数告诉算法决定初始质心的方法。默认的方法是随机选择,但是基于 David Arthur 等人在 2006 年发表的题为“K-means++小心播种的优点”的论文,有一种更聪明的方法来初始化这些集群。总的来说,k-means++试图以所有质心彼此远离的方式来选择质心。它从选择一个随机点作为质心开始,然后选择下一个质心,使得它的选择概率与它离最近的质心的距离成比例。这是反复进行的,直到质心的总数与n_clusters
参数值相匹配。
n_init
,一个与init
参数密切相关的参数,用于选择具有最佳惯性的聚类,同时也稳定了init
参数的结果,因此您不需要对该参数进行大量实验。保持固定值 10。
接下来,我们接着看max_iter
。该参数帮助 k-means 收敛并找到质心周围点的最佳分布。这在确定集群的整体健康状况方面起着重要的作用,例如数据点的总数、总体轮廓得分或惯性,以及数据点的普遍性。
最后,最重要的参数是n_clusters
。它可以帮助你看到你的数据中有多少聚类。您将尝试通过两种方法来确定这个数字:
-
惯性(又称肘方法):组内方差之和
-
轮廓得分:这同时考虑了簇内和簇间距离。它在-1 到 1 之间变化,其中接近 1 的值表示数据点与其所在的聚类对齐得很好,而与相邻聚类对齐得较差(因此与惯性相比,它能说明这两种情况),而远离 1 的值则表示数据点被错误分类。
既然您正在决定如何处理init
和n_init
参数,让我们快速查看一下max_iter
和n_clusters
参数。
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
plt.figure(figsize=(12,8))
import seaborn as sns
codings = encoder.predict(full_ds)
k_means_data = pd.concat([data[["SUBJECT_ID","IS_READMISSION"]],
pd.DataFrame(codings, columns = ["val1","val2"])],
axis = 1)
kmeans_iter1 = KMeans(n_clusters=4, init="k-means++", n_init=5,
max_iter=1, random_state=123)
kmeans_iter2 = KMeans(n_clusters=4, init="k-means++", n_init=5,
max_iter=2, random_state=123)
kmeans_iter3 = KMeans(n_clusters=4, init="k-means++", n_init=5,
max_iter=3, random_state=123)
kmeans_iter1.fit(codings)
kmeans_iter2.fit(codings)
kmeans_iter3.fit(codings)
如果你画出这三个不同版本的质心和标签,这个图看起来会如图 3-10 所示。
图 3-10
不同迭代次数的聚类
您可以看到,max_iter
对集群性能没有太大影响,因此这里也将max_iter
固定为 3。
通过惯性和轮廓分数分析来决定聚类的数量
现在,您必须决定的唯一事情是集群的数量。为此,您将看到惯性值和轮廓分数。如果他们都同意一个数字,你就接受它。
kmeans__ncluster = [KMeans(n_clusters=x, init="k-means++",
max_iter = 3,
n_init = 5,
random_state=123).fit(codings)
for x in range(1, 10)]
inertias = [kmeans_model.inertia_ for kmeans_model in kmeans__ncluster]
from sklearn.metrics import silhouette_score
silhouette_scores = [silhouette_score(codings, kmeans_model.labels_)
for kmeans_model in kmeans__ncluster[1:]]
plt.figure(figsize=(12, 6))
plt.subplot(121)
plt.plot(range(1, 10), inertias, "ro-")
plt.xlabel("Number of Clusters", fontsize=15)
plt.ylabel("Inertia", fontsize=15)
plt.subplot(122)
plt.plot(range(2, 10), silhouette_scores, "ro-")
plt.xlabel("Number of Clusters", fontsize=15)
plt.ylabel("Silhouette score", fontsize=15)
plt.show()
图 3-11 显示集群大小为 4 时集群性能最佳。
图 3-11
不同聚类数的惯性和轮廓分数
检查集群运行状况
让我们快速检查每个集群的样本量以及再入院患者的患病率。
k = 4
kmeans = KMeans(n_clusters=k, init="k-means++", n_init=5, max_iter = 3, random_state=123)
cluster_predictions = kmeans.fit_predict(codings)
k_means_data["cluster_label"] = cluster_predictions
# Appending the cluster prediction to the main data
data["cluster_label"] = cluster_predictions
count_labels = k_means_data.groupby(['cluster_label','IS_READMISSION'])['SUBJECT_ID'].count().reset_index()
sample_count = pd.pivot_table(count_labels, index="cluster_label", columns=['IS_READMISSION'], values="SUBJECT_ID").reset_index()
sample_count.columns = [sample_count.columns.name + "_" +str(x) if type(x)!=str else x for x in list(sample_count.columns)]
sample_count.reset_index(drop = True, inplace = True)
sample_count["Total_Samples"] = sample_count[["IS_READMISSION_0","IS_READMISSION_1"]].apply(sum, axis =1)
sample_count["Readmission_Percentage"] = (sample_count["IS_READMISSION_1"]/sample_count["Total_Samples"])*100
图 3-12 显示了四个患者队列的样本量和再入院百分比。
图 3-12
样本和阳性类在每个聚类中的分布
现在你有四组病人可以一起工作了。最终的预测模型应该总体上表现良好,而且在这些不同的患者队列中也表现良好。您将在下一节看到如何做到这一点。
多任务学习模式
什么是多任务学习?
想象一下,建立一个图像分类系统,你想检测图像中的人。如果你只有一个标签来描述图像中是否有人,那么你完全可以创建一个分类模型。但是,如果您有机会通过针对其他目标优化您的模型来使模型更加健壮,并帮助推广您的人物检测解决方案,会怎么样呢?
多任务模式通过训练某些辅助但相关的任务来帮助你进一步提高你的学习能力。在上述图像分类示例的情况下,辅助类可以用于边界框识别。这可以帮助它学习一些特征,例如一个人的盒子,它的宽度较低,但高度较长。如果出现这种性质的 bbox,则该图像更可能是带有人的图像。图 3-13 显示了边界框如何通过共享关于边界框尺寸的信息来帮助您进一步提高图像分类能力。
图 3-13
一个可以归类为“人”的形象
训练 MTL 模型的不同方法
训练 MTL 模型有多种方法。一些突出的例子是
图 3-14
深度神经网络中多任务学习的硬参数共享
- 硬参数共享:它包括共享隐藏层参数,同时为每个任务提供单独的输出层。图 3-14 是改编自 Sebastian Ruder 关于 MTL 的博客的抽象图。
图 3-15
深度神经网络中多任务学习的软参数共享
- 软参数分享:这个有点不一样。在这里,所有的任务都有自己的模型,然后使用迹范数来正则化这个不同模型的所有参数,以允许重用所学习的信息。您可以将跟踪规范理解为度量复杂性的东西。如果你有一个更复杂的模型和一个更简单的模型,两者都能很好地理解数据,你会选择哪一个?简单一点的,对吧?这就是软参数共享的情况。见图 3-15 。
图 3-16
持续增量学习
- 持续的增量学习:这是一种相对较新的方法,你可以把它看作是一种硬参数共享的形式,但是是以一种新的方式来看待 MTL。它是最近由孙玉等人在 AAAI 20 年会上发表的一篇题为“厄尼 2.0:语言理解的持续预训练框架”的论文中提出的该方法如图 3-16 所示。
为了简单起见,您将尝试硬参数共享方法,因为它使用最广泛,并且足以引入 MTL。
训练你的 MTL 模型
首先,将每个样本的分类预测与原始数据对齐。
import pandas as pd
import numpy as np
data["cluster_labels"] = cluster_predictions
data.columns = [re.sub(r"[,.;@#?!&$]+\ *", " ",x).replace('/\s\s+/g', ' ').replace(" ","_") for x in data.columns]
接下来,您将包括最终模型定型的所有数值和分类列。
# Updating the num_cols and categorical_cols
num_cols = ['AGE', 'DISCHARGE_DURATION', 'ELIXHAUSER_SID30', 'mean_Diastolic_BP', 'mean_Glucose',
'mean_Heart_Rate', 'mean_Resp_Rate', 'mean_Systolic_BP',
'mean_Temperature', 'std_Diastolic_BP', 'std_Glucose', 'std_Heart_Rate',
'std_Resp_Rate', 'std_Systolic_BP', 'std_Temperature', 'mean_Albumin',
'mean_Calcium_Total', 'mean_Hematocrit', 'mean_Platelet_Count',
'mean_Potassium', 'mean_Sodium', 'std_Albumin', 'std_Calcium_Total',
'std_Hematocrit', 'std_Platelet_Count', 'std_Potassium', 'std_Sodium','LOS','TOTAL_TRANSFERS']
target_col = ['IS_READMISSION']
categorical_col = ['ADMISSION_TYPE','DISCHARGE_LOCATION','INSURANCE', 'ETHNICITY', 'GENDER', 'FIRST_CAREUNIT']
现在,您可以将数据扩展并分成训练集和验证集。
# Updating Scaling with new numerical columns
from sklearn import preprocessing
min_max_scaler = preprocessing.MinMaxScaler()
data_minmax = min_max_scaler.fit(data[num_cols])
data_num = data_minmax.transform(data[num_cols])
data_scaled = pd.concat([pd.DataFrame(data_num, columns = num_cols),
data[categorical_col + target_col + ["cluster_labels"]]],
axis = 1)
from sklearn.model_selection import train_test_split
train, val = train_test_split(data_scaled, test_size=0.2)
下一组函数帮助您创建多输出标签:
-
gen_labels
:为每个输出(或集群)创建 1 或 0 的输出 -
df_to_dataset_multio
:返回用于训练和将每个输出层映射到输出聚类的一组特征 -
get_data_generator
:生成一批训练样本的生成函数
def gen_labels(readm_val, cluster_val):
"""
Helper function to generate labels for multi-output system
"""
res = [0,0,0,0]
if readm_val:
res[cluster_val] = 1
return res
def df_to_dataset_multio(dataframe, target_col_name = 'IS_READMISSION'):
"""
A utility method to create a Input data for the MTL NN
"""
dataframe = dataframe.copy()
labels = [gen_labels(row[1], row[2]) for row in dataframe[[target_col_name, 'cluster_labels']].itertuples()]
assert np.sum(labels) == dataframe[target_col_name].sum()
dataframe.drop([target_col_name, 'cluster_labels'], axis = 1, inplace = True)
# Generating Tensorflow Dataset
train_ds = feature_layer(dict(dataframe)).numpy()
y_train_ds = {'cluster_0':np.array([x[0] for x in labels]),
'cluster_1':np.array([x[1] for x in labels]),
'cluster_2':np.array([x[2] for x in labels]),
'cluster_3':np.array([x[3] for x in labels])}
return train_ds, y_train_ds
train_ds, train_col_map = df_to_dataset_multio(train)
val_ds, val_col_map = df_to_dataset_multio(val)
def get_data_generator(df, cluster_map, batch_size=32):
"""
Generator function
which yields the input data and output for different clusters
"""
feats, cluster_0, cluster_1, cluster_2, cluster_3 = [], [], [], [], []
while True:
for i in range(len(df)):
feats.append(df[i])
cluster_0.append(cluster_map['cluster_0'][i])
cluster_1.append(cluster_map['cluster_1'][i])
cluster_2.append(cluster_map['cluster_2'][i])
cluster_3.append(cluster_map['cluster_3'][i])
if len(feats) >= batch_size:
yield np.array(feats), [np.array(cluster_0), np.array(cluster_1), np.array(cluster_2), np.array(cluster_3)]
feats, cluster_0, cluster_1, cluster_2, cluster_3 = [], [], [], [], []
最后,你创建一个如图 3-17 所示的模型,它展示了你将要为你的 MTL 任务构建的架构。
图 3-17
模型架构
input_layer = layers.Input(shape = (train_ds.shape[1]))
_ = layers.Dense(32, activation = "selu", kernel_initializer="lecun_normal")(input_layer)
_ = layers.Dense(16, activation = "selu", kernel_initializer="lecun_normal")(_)
last_shared_layer = layers.Dense(8, activation = "selu", kernel_initializer="lecun_normal")(_)
_ = layers.Dense(4, activation = "selu", kernel_initializer="lecun_normal")(last_shared_layer)
cluster_0 = layers.Dense(1, activation = "sigmoid", name='cluster_0')(_)
_ = layers.Dense(4, activation = "selu", kernel_initializer="lecun_normal")(last_shared_layer)
cluster_1 = layers.Dense(1, activation = "sigmoid", name='cluster_1')(_)
_ = layers.Dense(4, activation = "selu", kernel_initializer="lecun_normal")(last_shared_layer)
cluster_2 = layers.Dense(1, activation = "sigmoid", name='cluster_2')(_)
_ = layers.Dense(4, activation = "selu", kernel_initializer="lecun_normal")(last_shared_layer)
cluster_3 = layers.Dense(1, activation = "sigmoid", name='cluster_3')(_)
mtl_model = tf.keras.Model(inputs = input_layer,
outputs = [cluster_0, cluster_1, cluster_2, cluster_3])
mtl_model.compile (optimizer=tf.keras.optimizers.Adam(learning_rate=0.01),
loss={'cluster_0': 'binary_crossentropy',
'cluster_1': 'binary_crossentropy',
'cluster_2': 'binary_crossentropy',
'cluster_3': 'binary_crossentropy'},
loss_weights={'cluster_0': 0.25,
'cluster_1': 0.25,
'cluster_2': 0.25,
'cluster_3': 0.25},
metrics={'cluster_0': 'AUC',
'cluster_1': 'AUC',
'cluster_2': 'AUC',
'cluster_3': 'AUC'})
batch_size = 32
valid_batch_size = 32
train_gen = get_data_generator(train_ds, train_col_map, batch_size=batch_size)
valid_gen = get_data_generator(val_ds, val_col_map, batch_size=valid_batch_size)
history = mtl_model.fit_generator(train_gen,
steps_per_epoch=len(train)//batch_size,
epochs=10,
validation_data=valid_gen,
validation_steps=len(val)//valid_batch_size)
从上面的代码中可以明显看出,构建神经网络是多么的多才多艺。在多输出的情况下,可以对每个输出使用不同的 loss 和 loss_weights 度量。
最后,请看图 3-18 中的性能和损耗图。一些聚类学习得非常好,如聚类 1 和 3,而聚类 2 中有轻微的过拟合,因为聚类 2 的损失图显示验证损失通常高于训练损失。
图 3-18
MTL 模型的验证和培训绩效
结论
首先,祝贺您研究并理解了制药行业中最复杂的数据之一。EHR 数据包含各种数据,如表格和文本,一些 EHR 系统还包含图像。
其次,我们在本章中讨论了许多主题和 TensorFlow 特有的功能。您了解了特性列的使用以及如何构建输入流水线。您还详细研究了自编码器和集群。最后,向你介绍了多任务学习及其类型。多任务学习是一个新兴的领域,特别是在自然语言处理中,任务通常是复杂的,单个全局模型不能学习所有的复杂性,因此多任务模型是有用的。我希望你学到了很多,并期待更多。
四、根据临床记录预测医疗账单代码
临床记录包含关于医生开出的处方程序和诊断的信息,并且在当前的医疗系统中用于准确计费,但是它们并不容易获得。我们必须手动提取它们,或者使用一些辅助技术来无缝地执行这个过程。
这增加了支付者和提供者的管理成本。仅医疗服务提供商在保险和医疗账单成本上就花费了大约 2820 亿美元。良好的记录和质量跟踪是额外的成本。与每种类型的就诊相关的专业收入相比,急诊就诊产生的账单成本最高,相当于收入的 25.2%。
在本章中,您将深入了解 BERT 和 transformer 架构,探索最新的 transformer 模型。您还将了解如何将不同的微调技术应用于 transformer 模型。最后,您将学习在 NLP 中使用迁移学习的概念,并将多标签分类作为下游任务。
从非结构化临床记录中预测诊断和程序可以节省时间、消除错误并最大限度地降低成本,所以让我们开始吧。
介绍
首先,我说的这些 ICD 电码是什么?那些熟悉 ICD 电码的人可能会混淆 ICD-9 和 ICD-10 电码之间的区别。
ICD 代表国际疾病分类,它是由卫生与公众服务部管理和维护的一套标准代码(还记得第一章的 HHS 吗?).这些代码用于准确测量结果和为患者提供的护理,同时还为研究和临床决策提供了一种结构化的疾病和症状报告方式。
HHS 要求 HIPAA 法案下的所有实体必须将其 ICD 代码转换为 ICD-10 格式。这样做有各种原因,但最主要的是
-
跟踪新的疾病和健康状况:旧系统包含大约 17.8K 个不同的 ICD 代码,但 ICD-10 将超过 15 万种状况和疾病映射到不同的代码。
-
更大的空间允许更好和更准确地定义 ICD 编码,并支持流行病学研究,如疾病的共病或严重程度等。
-
防止报销欺诈
由于 MIMIC 3 包含新的代码系统被授权之前的 EHR 数据,您可以轻松地继续使用现有的 ICD 数据,但请记住这一点,以防您看到新的 EHR 数据。别担心。您可以亲自动手,将从这里学到的知识应用到新的 ICD 公约中。
由于有许多 ICD-9 代码,实际上,您只需尝试识别前 15 个 ICD-9 代码,这取决于有多少住院患者贴上了特定的 ICD-9 代码标签。
我已经深入讨论了 MIMIC 3 数据,所以让我们只关注选择正确的表和概述准备数据的步骤。让我们深入研究一下。
图 4-1 显示了 ICD-9 和 ICD-10 CM 代码的差异。注意,ICD 码有两种类型。
图 4-1
ICD 9 厘米和 ICD 10 厘米诊断编码系统
-
CM(临床修改) :住院和门诊数据的诊断编码
-
PCS(程序编码系统) :住院数据的程序编码
数据
我在上一章深入讨论了 MIMIC 3 数据集,所以让我们直接开始创建数据。
注释事件
此表包含与患者入院后记录的所有临床记录相关的文本。在NOTEEVENTS
表中要查看的两个重要列是CATEGORY
和DESCRIPTION
。CATEGORY
包含匿名的临床记录,DESCRIPTION
告诉我们这些是完整的报告还是附录。
因为用例的中心是降低提供商和支付者的管理成本,所以该信息的最佳来源是“出院总结-报告”。
n_rows = 100000
# create the iterator
noteevents_iterator = pd.read_csv(
"./Data/NOTEEVENTS.csv",
iterator=True,
chunksize=n_rows)
# concatenate according to a filter to get our noteevents data
noteevents = pd.concat( [noteevents_chunk[np.logical_and(noteevents_chunk.CATEGORY.isin(["Discharge summary"]), noteevents_chunk.DESCRIPTION.isin(["Report"]))]
for noteevents_chunk in noteevents_iterator])
noteevents.HADM_ID = noteevents.HADM_ID.astype(int)
现在您已经有了自己的数据集,让我们稍微探索一下。
主键上的重复:尽管SUBJECT_ID
和HADM_ID
对应该有一个唯一的记录,但是NOTEEVENTS
数据集中还是有重复的。
经过进一步调查,看起来记录在不同的日期对相同的入院 ID 有不同的出院摘要文本。这看起来像一个不可能的事件,因此是一个更数据的问题。现在,您将对CHARTDATE
列中的数据进行排序,并保留第一个条目。
try:
assert len(noteevents.drop_duplicates(["SUBJECT_ID","HADM_ID"])) == len(noteevents)
except AssertionError as e:
print("There are duplicates on Primary Key Set")
noteevents.CHARTDATE = pd.to_datetime(noteevents.CHARTDATE , format = '%Y-%m-%d %H:%M:%S', errors = 'coerce')
pd.set_option('display.max_colwidth',50)
noteevents.sort_values(["SUBJECT_ID","HADM_ID","CHARTDATE"], inplace =True)
noteevents.drop_duplicates(["SUBJECT_ID","HADM_ID"], inplace = True)
noteevents.reset_index(drop = True, inplace = True)
在移动到下一个数据源查看文本数据之前,还有一件事要做。您可以在下面看到文本的样本摘要:
Admission Date: [**2118-6-2**] Discharge Date: [**2118-6-14**]
Date of Birth: Sex: F
Service: MICU and then to [**Doctor Last Name **] Medicine
HISTORY OF PRESENT ILLNESS: This is an 81-year-old female
with a history of emphysema (not on home O2), who presents
with three days of shortness of breath thought by her primary
care doctor to be a COPD flare. Two days prior to admission,
she was started on a prednisone taper and one day prior to
admission she required oxygen at home in order to maintain
oxygen saturation greater than 90%. She has also been on
levofloxacin and nebulizers, and was not getting better, and
presented to the [**Hospital1 18**] Emergency Room.
您可以看到某些可用于清理文本的图案:
-
匿名日期、患者姓名、医院和医生姓名
-
使用一种模式如“主题:文本”如“入院日期:
[**2118-6-2**]:, "HISTORY OF PRESENT ILLNESS: This is an 81-year-old female....
-
使用换行符 ("\n ")
您将利用所有这些模式来清理数据,并确保每个独特的句子得到正确记录。
你要做两件事。首先,你要确保所有不相关的话题都从出院小结中删除。对于这一点,你会发现最常见的话题。
import re
import itertools
def clean_text(text):
return [x for x in list(itertools.chain.from_iterable([t.split("<>") for t in text.replace("\n"," ").split("|")])) if len(x) > 0]
most_frequent_tags = [re.match("^(.*?):",x).group() for text in noteevents.TEXT for x in text.split("\n\n") if pd.notnull(re.match("^(.*?):",x))]
pd.Series(most_frequent_tags).value_counts().head(10)
图 4-2 中显示了最常见主题标签的摘录。
图。4-2
出院小结中最常见的话题
irrelevant_tags = ["Admission Date:", "Date of Birth:", "Service:", "Attending:", "Facility:", "Medications on Admission:", "Discharge Medications:", "Completed by:", "Dictated By:" , "Department:" , "Provider:"]
updated_text = ["<>".join(["|".join(re.split("\n\d|\n\s+",re.sub("^(.*?):","",x).strip())) for x in text.split("\n\n") if pd.notnull(re.match("^(.*?):",x)) and re.match("^(.*?):",x).group() not in irrelevant_tags ]) for text in noteevents.TEXT]
updated_text = [re.sub("(\[.*?\])", "", text) for text in updated_text]
updated_text = ["|".join(clean_text(x)) for x in updated_text]
noteevents["CLEAN_TEXT"] = updated_text
对于上面的示例,下面是清理后的文本。很漂亮,对吧?
'This is an 81-year-old female with a history of emphysema (not on home O2), who presents with three days of shortness of breath thought by her primary care doctor to be a COPD flare. Two days prior to admission, she was started on a prednisone taper and one day prior to admission she required oxygen at home in order to maintain oxygen saturation greater than 90%. She has also been on levofloxacin and nebulizers, and was not getting better, and presented to the Emergency Room.',
'Fevers, chills, nausea, vomiting, night sweats, change in weight, gastrointestinal complaints, neurologic changes
, rashes, palpitations, orthopnea. Is positive for the following: Chest pressure occasionally with shortness of breath with exertion, some shortness of breath that is positionally related, but is improved with nebulizer treatment.'
诊断 _ICD
这是 ICD-9 代码表。它包含与受试者入院事件相关的所有 ICD-9 代码。正如引言中所讨论的,您正在为手头的问题寻找前 15 个最常见的 ICD-9 代码。
top_values = (icd9_code.groupby('ICD9_CODE').
agg({"SUBJECT_ID": "nunique"}).
reset_index().sort_values(['SUBJECT_ID'], ascending = False).ICD9_CODE.tolist()[:15])
icd9_code = icd9_code[icd9_code.ICD9_CODE.isin(top_values)]
理解语言建模如何工作
在您直接使用 BERT 之前,让我们先了解一下它是如何工作的,构建模块是什么,为什么需要它,等等。
谷歌 AI 语言团队在 2018 年发布的题为“BERT:用于语言理解的深度双向变压器的预训练”的论文,是非研究社区对语言建模的新形式和变压器模型的应用真正感到兴奋的时候。2017 年,谷歌大脑团队在一篇题为“注意力是你所需要的一切”的论文中介绍了变形金刚模型。
很有趣,对吧?注意力被引入是为了以更人性化的方式学习语言,例如通过关联句子中的单词。注意力有助于更好地为 NLP 中的转导问题建立句子模型,从而改进编码器-解码器架构。
编码器-解码器架构依次建立在 RNNs、LSTMs 和 Bi-LSTMs 之上,它们在某个阶段是序列建模的最新技术。它们都属于循环网络类。因为一个句子是一个单词序列,所以你需要一个序列建模网络,在这个网络中,当前输入在序列的第二个元素中重复出现,以便更好地理解单词。这个信息链有助于对一个句子的有意义的表达进行编码。
我在这里想说的是,要真正理解 BERT 或任何其他基于 transformer 的架构模型,您需要对许多相互关联的概念有深刻的理解。为了让讨论集中在 BERT 上,我将主要讨论注意力和 BERT 架构。
集中注意力
先说个例子。如果我让你告诉我下列句子的意思,你会怎么说?
-
狗很可爱。我喜欢和他们在一起。
-
狗很可爱。我喜欢和他们在一起。
对于这两个句子,人们很容易理解说话者对狗有积极的情感。但是下面这句话呢?
- 狗很可爱。我喜欢和他们在一起。
对于这句话,虽然不是决定性的,但我们可以说这句话应该是关于狗的积极的东西。这叫做注意力。为了理解一个句子,我们只依靠某些单词,而其他的都是垃圾(从理解的角度来看)。
递归网络族虽然有助于建模序列,但对于非常大的句子来说是失败的,因为编码上下文信息的固定长度表示只能捕获这么多的相关性。但是如果我们只从一个大句子中挑选重要的句子呢?那我们就不用担心残留了。
我喜欢从信息论的角度来理解这一点。我们只需要使用 2 的幂的不同组合就可以对所有的整数建模,如图 4-3 所示。
图 4-3
作为 2 的幂的整数
所以要得到任何数,我们要做的就是取两个向量的点积:
注意力以非常相似的方式工作。它采用序列的上下文向量或编码向量,只对重要的方面进行加权。虽然在我们的例子中,我们从整数转移到实数。参见图 4-4 。
图 4-4
展示了添加前馈层如何帮助我们学习注意力权重
Dzmitry Bahdanau 等人在 2014 年发表的题为“通过联邦学习对齐和翻译进行神经机器翻译”的论文中首次讨论了注意力的概念。
在图 4-4 中,注意来自最后一个解码器单元的绿色箭头。这是由 S t-1 表示的解码器状态。我们结合隐藏状态和最后一个隐藏层的输出的方式可以提供各种关注,如表 4-1 所示。这也称为分数或编码器输出的能量。该组合函数或评分函数被设计成最大化解码器的隐藏状态和编码器输出之间的相似性。这样就产生了更多连贯的单词,给 MTL(多语言翻译)系统带来了更大的能力。
表 4-1
用于计算解码器和编码器状态之间相似性的不同评分函数
|注意名称
|
纸
|
| --- | --- |
| 加法或串联:最后一个解码器单元的隐藏状态被添加到编码器单元的隐藏状态。假设维度是 d,那么连接的维度就变成了 2d。 | Bahdanau 等人,2014,“通过联邦学习对齐和翻译的神经机器翻译” |
| 点积:最后一个解码器单元的隐藏状态乘以编码器单元的隐藏状态。假设维度是 d,那么连接的维度就变成了 d。 | Luong 等人,2015,“基于注意力的神经机器翻译的有效方法” |
| 比例点积:同上,只是增加了一个比例因子,使数值标准化,并在 Softmax 函数的可微分范围内。 | 瓦斯瓦尼等人,2017,“注意力是你所需要的一切” |
| 一般(点积):编码器隐藏状态在计算分数之前通过一个前馈网。 | Luong 等人,2015,“基于注意力的神经机器翻译的有效方法” |
一些你应该记住的细节:
-
为了使这个过程更快,您利用 Keras 的 TimeDistributedLayer,它确保每个时间单位的前馈发生得更快。(只是密密麻麻一层。)
-
最后合并的编码器隐藏状态作为输入被馈送到第一解码器单元。这个解码器单元的输出被称为第一解码器隐藏状态。
-
所有分数都通过 Softmax 层传递,以给出注意力权重,然后乘以编码器的隐藏状态,以获得来自每个编码器单元的上下文向量 C t 。
最后,注意力有很多种:
-
当地和全球的关注
-
自我关注
-
多头注意力
为了完整起见,我将简要地讨论它们,因为每一个都值得写一篇自己的文章,因此,为了有一个总体的理解,我只包括定义。我将在 transformer 架构讨论中详细讨论多头关注。请参考表 4-2 了解不同类型注意力的概述。
表 4-2
不同类型的关注
|注意力
|
描述
|
纸
|
| --- | --- | --- |
| 当地和全球的关注 | 全局:所有编码器单元都被赋予了重要性。Local :上下文向量生成只考虑输入的一部分。该输入以位置 p t 为中心,宽度为 p t -2L 到 p t +2L,其中 L 是窗口长度。 | 灵感来源于徐等,2015,“展示、参与、讲述:视觉注意下的神经图像字幕生成” |
| 自我关注 | 工作原理类似于上面编码器-解码器架构中所解释的注意事项;我们只是用输入序列本身替换目标序列。 | 程等,2016,“长短期记忆-机器阅读的网络” |
| 多头注意力 | 多头注意力是实现自我注意力的一种方式,但是有多个键。稍后将详细介绍。 | 瓦斯瓦尼等人,2017,“注意力是你所需要的一切” |
转变 NLP 空间:变压器架构
Transformer 模型可以说是为 NLP 迁移学习任务带来了 ImageNet 运动。到目前为止,这需要大量数据集来捕获上下文、大量计算资源,甚至更多时间。但是,一旦 transformer 架构出现,它就可以更好地捕捉上下文,因为可以并行化,所以训练时间更短,并且还可以为许多任务设置 SOTA。图 4-5 显示了 Vaswani 等人题为“你只需要关注”的论文中的变压器架构
图 4-5
变压器模型
对于门外汉来说,该模型一开始可能会令人望而生畏,但如果在筒仓中理解不同的概念,则非常容易理解。
要理解变形金刚,你需要理解
-
编码器-解码器框架
-
多头注意力
-
位置编码
-
剩余连接
编码器和解码器模块与上面的关注主题一起讨论,残差连接只是为了确保来自目标损失的残差可以容易地帮助准确地改变权重,因为有时由于非线性,梯度不会产生期望的效果。
位置编码
变压器能够并行处理更大的数据和更多的参数,实现更快的训练。但是怎么可能呢?通过移除所有有状态的细胞,如 RNN、GRU 或 LSTM,这是可能的。
但是,我们如何确保句子的句法语法没有被打乱,并且句子的单词有一定的顺序感呢?我们通过使用一个密集的向量来编码一个单词在序列中的位置。
思考这个问题的一个非常简单的方法是将每个单词标记为正整数(无界)。但是如果我们得到一个很长的句子或者不同长度的句子呢?在这两种情况下,拥有一个无界的数字表示是行不通的。
好的,那么有界表示可以工作吗?让我们对[a 到 b]之间的所有内容进行排序,其中 a 代表第一个单词,b 代表最后一个单词,其他内容位于两者之间。因为它是一个 10 个单词的句子的模型,你必须增加索引,对于一个 20 个单词的句子,增量是
。因此,增量没有相同的意义。作者提出了图 4-6 中位置编码矢量的公式。
图 4-6
位置编码
理解这一点的一个好方法,不需要太多的数学知识,就是
-
位置编码器是一个 d 维向量。这个向量被添加到单词的单词向量表示中,因此 dpe = dmodel。
-
它是一个大小为(n,d)的矩阵,其中 n 是序列中的单词数,d 是单词嵌入的维数。
-
它是每个位置的唯一向量。
-
sin 和 cos 函数的组合允许模型很好地学习相对位置。因为任何偏移 k,PEpos+k 可以表示为 PEpos 的线性函数。
-
由于残留块的存在,该位置信息也保留在较深层中。
多头注意力
多头关注是 transformer 架构的主要创新。我们来详细了解一下。本文使用一个通用的框架来定义注意。
它引入了三个术语:
-
钥匙(K)
-
查询(Q)
-
价值(伏特)
单词的每次嵌入都应该有这三个向量。它们是通过矩阵乘法得到的。这从嵌入向量中捕获了特定的信息子空间。
一个抽象的理解是这样的:你试图通过使用一个查询来识别某些键、值对。您试图确定其关注分数的单词是查询。
由于天气不好,交通堵塞。
假设你的查询是流量。查询向量捕获单词 traffic 的一些语义,可能是它的 pos 标签或与旅行/通勤相关的标签,等等。类似地,对于键和值向量,也捕捉到了一些细微差别。
现在,您到达单词天气,类似地,您捕获 K、Q 和 v。如果交通的查询与天气的关键字具有高相似性,则天气的值对单词交通的自我关注向量贡献很大。见图 4-7 。
图 4-7
自我关注。图片改编自《注意力是你需要的全部》
在多头注意力中,有多个这样的矩阵乘法,可以让你每次捕捉到不同的子空间。它们都是并行完成的。参见图 4-8 。
图 4-8
多头自我关注。图片改编自《注意力是你需要的全部》
以上两项是变压器模型中的主要创新。毫无疑问,它能够很好地捕捉句子语义。以下是一些值得一提的其他细节:
-
解码器模型包含掩蔽的多头注意力模型。它屏蔽掉查询词之后的所有词。价值向量被屏蔽,然后转移到自我关注向量。
-
来自被掩蔽的注意块的自我注意向量充当其上方的多头注意块的值向量。
-
使用跳跃连接(灵感来自 ResNet,由何等人在“图像识别的深度残差学习”中介绍)来防止信号丢失。
-
有多个编码器-解码器模块堆叠在一起,图 4-5 显示了最后一对。Softmax 仅被添加到最后一个解码器块。
Note
来自最后一个编码器的输出被传递到所有的解码器单元,而不仅仅是最后一个。
BERT:来自变压器的双向编码器表示
BERT 为将 NLP 的 ImageNet 运动带入现实奠定了基础。现在我们有了一个 BERT 模型动物园,这基本上意味着几乎每种应用程序都有一个 BERT 模型。
从架构的角度来看,BERT 只不过是堆叠的变压器(只有编码器模块)。但它在处理输入数据和训练方面带来了一些新的创新。在深入研究代码之前,让我们简单地讨论一下它们。
投入
BERT 作者分享了一些输入文本的创新方法。我已经讨论了长度中的位置嵌入,所以让我们快速跳到令牌和段嵌入。参见图 4-9 。
图 4-9
BERT 输入表示。图片改编自《BERT:深度双向转换者语言理解预训练》
令牌嵌入
令牌嵌入只是以数字形式表示每个令牌的一种方式。在 BERT 中,这是一个 768 维的向量。这里有趣的是工件记号化技术。它帮助 BERT 维持了一个相当大的图书馆,有 30,522 个,但没有在未收录的单词上妥协。
我们举个例子来理解一下。假设最初你有一个只有五个单词的字典,它们在语料库中的数量是已知的:
-
教堂,5
-
孩子,3
-
返回,8
-
赚,10
-
提升,5
结尾的代表单词边界。单词块算法检查文本中的每个字符,并试图找到频率最高的字符对。
假设系统遇到一个像 Churn 这样的不在词汇表中的单词。对于这个单词,BERT 会做如下处理:
-
c : 5 + 3 = 8
-
c+h:5+3 = 8
-
c + h + u: 5,由于总数下降而被拒绝
-
n : 10
-
r + n : 8,拒绝,因为它也减少了 n 的计数。
-
u + r :8,u + r 的计数
因此,创建的令牌是
[ch,ur,n .]
我上面讨论的是 BPE 或二进制编码。正如您所观察到的,它以贪婪的方式根据频率合并单个字符。单词块算法略有不同,在某种程度上,字符合并仍然基于频率,但最终决定是基于出现的可能性(查看哪些单词块更有可能出现)。
片段嵌入
伯特接受两种不同训练任务的训练:
-
分类:确定输入句子的类别
-
下一个句子预测:预测下一个句子或理想地/连贯地跟随前一个句子的句子(如在训练语料库中存在的)
为了预测下一个句子,BERT 需要一种方法来区分这两个句子,因此在每个句子的末尾引入了一个特殊的标记[SEP]。
因为我已经谈到了位置嵌入,所以我不会在这里再次讨论它。
培养
BERT 模型针对两项任务进行了预训练:
-
掩蔽语言建模
-
下一句预测
掩蔽语言建模
引入屏蔽语言建模主要是为了允许模型以双向方式学习,并使模型能够捕捉序列中任何随机单词的上下文。
分类层被添加到编码器输出的顶部。这些输出通过时间分布的密集层,将它们转换成词汇的维数,然后计算每个单词的概率。参见图 4-10 。
图 4-10
掩蔽语言建模
-
为了使模型位置不可知,同时给出足够的上下文,在的每个序列中只有 15% 的单词被随机屏蔽。
-
如图 4-10 所示,并非所有被屏蔽的单词都被替换为[MASK]标记。而是选择了以下方法:
-
80%的时间使用了[MASK]标记。
-
10%的情况下,这些单词被替换成随机单词。
-
剩下的 10%的时间单词保持不变。
-
如果你深入思考,你会想到很多关于选择这些百分比的问题。没有进行消融研究来支持这些经验数据;但是,有一些直觉。
-
使用随机单词会让 BERT 学习错误的嵌入吗?理想情况下不会,因为它在反向传播过程中被正确的标签所纠正。这样做也是为了引入方差。
-
为什么不保留 100%【面具】令牌?这样做是为了避免微调过程中的任何混乱,如果没有找到[MASK]标记,它将根据任务给出一些随机输出。
下一句预测
根据作者的说法,学习如何将两个句子联系起来可以显著提高问答和自然语言推理等任务的性能。
在这里,他们也提出了某些比率,并使用这些比率为 NSP 创建了一个训练数据:
-
对于语料库中 50%的句子,下一个句子是与语料库中存在的句子相同的句子。
-
剩下的 50%,下一句随机抽取。
这给了我们一个二进制分类器来训练。[CLS]令牌用于二进制分类,其最终状态被传递到 FFN 加软件最大层。
我希望您现在对基于 transformer 的模型,尤其是 BERT 有了更深的理解。我认为这应该足以让您将 BERT 应用于该案例,并学习如何对其进行微调。
建模
现在让我们深入研究建模。您已经在上面的“数据”部分准备好了数据。您正在尝试进行多标签分类。你必须以这样的方式准备你的数据。
对于您的任务,您将使用来自高丽大学 DMIS(数据挖掘和信息系统)实验室的 BERT 大模型。您这样做是因为它是为数不多的为 BERT 提供定制词汇表的预训练模型之一。大多数免费的预训练模型保持相同的词汇,在我看来这是一种不好的做法。
第二,你还将利用拥抱脸小组的变形金刚库,它为语言理解(NLU)和自然语言生成(NLG)任务提供通用架构(伯特、GPT-2、罗伯塔、XLM、蒸馏伯特、XLNet)。
但在此之前,让我们先了解一下 BERT 模型的词汇。发布后,您将形成您的数据并进行多标签分类。
伯特深潜
除了更好的性能之外,拥有自定义词汇表的一个好处是能够看到哪些概念被捕获。您将使用一个 UML 数据库来识别词汇表中哪些概念正在被捕获;为此,您将看到子词标记(没有“##”),并选取长度大于 3 的所有标记。
为此,您必须设置 scispacy 库。它建立在 spacy 之上,对于应用 NLP 工作来说是一个非常快速和有用的库。请参见第二章中的安装步骤。
Scispacy 提供了一种链接知识库的方法。概念提取对字符串重叠起作用。它涵盖了大多数公开可用的主要生物医学数据库,如 UMLs、Mesh、RxNorm 等。
此外,您将使用基于生物医学数据的大型空间模型。确保您已经通过下载并链接到 spacy 来设置模型。保留匹配的默认参数,因为这只是一个探索性的练习,您的建模不会直接受到此选择的影响。官方文档在 https://github.com/allenai/scispacy
.
词汇实际上包含什么?
在深入训练分类模型或使用微调进一步改进它之前,您应该仔细检查一下您拥有的词汇表。它甚至包括生物医学概念吗?平均令牌长度是多少?(生物医学词汇一般有体面的令牌长度一般> 5 个字符。)
让我们一个一个来看看这些问题。
- 找到任何生物医学概念
为了找到生物医学的概念,你将利用一个广泛的 UMLs 知识库。它通过一个简单的界面与科学联系起来。
首次运行时,链接可能需要一些时间,具体取决于您的电脑配置。首先,从导入库和加载相关模型开始。
# Load Hugging-face transformers
from transformers import TFBertModel, BertConfig, BertTokenizerFast
import tensorflow as tf
# For data processing
import pandas as pd
from sklearn.model_selection import train_test_split
# Load pre-trained model tokenizer (vocabulary)
tokenizer = BertTokenizerFast.from_pretrained('dmis-lab/biobert-large-cased-v1.1')
接下来,让我们找出唯一令牌的总数。
vocab = tokenizer.vocab.keys()
# Total Length
print("Total Length of Vocabulary words are : ", len(vocab))
词汇单词的总长度为 58996,几乎是谷歌团队分享的第一个 BERT 模型的两倍。猜猜为什么?
嗯,词汇量的大小是基于你能够用词汇表的子词对语料库中的每个词进行编码的清晰程度来决定的。谷歌没有分享代码,所以确切的原因不得而知,但我打赌上述大小足以以优化的方式表示语料库中的不同单词。你可以在 https://github.com/google-research/bert#learning-a-new-wordpiece-vocabulary
从谷歌官方回购了解更多信息。
让我们连接 UMLs 数据库。
import spacy
import scispacy
from scispacy.linking import EntityLinker
nlp = spacy.load('en_core_sci_lg')
linker = EntityLinker(resolve_abbreviations=False, name="umls") # keeping default thresholds for match percentage.
nlp.add_pipe(linker)
# subword vs whole word selection based on length
target_vocab = [word[2:] for word in vocab if "##" in word and (len(word[2:]) > 3)] + [word[2:] for word in vocab if "##" not in word and (len(word) > 3)]
umls_concept_extracted = [[umls_ent for entity in doc.ents for umls_ent in entity._.umls_ents] for doc in nlp.pipe(target_vocab)]
umls_concept_cui = [linker.kb.cui_to_entity[concepts[0][0]] for concepts in umls_concept_extracted if len(concepts) > 0]
# Capturing all the information shared from the UMLS DB in a dataframe
umls_concept_df = pd.DataFrame(umls_concept_cui)
UMLs 为它的每个 TXXX 标识符提供一个类名。TXXX 是每个 CUI 编号的父项代码,是 UMLs KB 使用的唯一概念标识符。接下来,让我们将 TXXX ids 映射到人类可读的标签。
# To obtain this file please login to https://www.nlm.nih.gov/research/umls/index.html
# Shared in Github Repo of the book :)
type2namemap = pd.read_csv("SRDEF", sep ="|", header = None)
type2namemap = type2namemap.iloc[:,:3]
type2namemap.columns = ["ClassType","TypeID","TypeName"]
typenamemap = {row["TypeID"]:row["TypeName"] for i,row in type2namemap.iterrows()}
为每个类型 ID 创建计数。
concept_df = pd.Series([typenamemap[typeid] for types in umls_concept_df.types for typeid in types]).value_counts().reset_index()
concept_df.columns = ["concept","count"]
让我们想象一下这 20 个最重要的概念。见图 4-11 。
图 4-11
生物医学概念在伯特词汇中的分布
哇,这些词汇实际上包含了各种生物医学概念,如疾病、身体部位、有机化学物质(化合物)和药理物质(用于治疗病理障碍)。看起来你有适合你任务的模型。所有这些概念在 EHR 笔记中也很常见。
接下来,让我们看看您在数据集中观察到的子词和实际标记的标记长度。
subword_len = [len(x.replace("##","")) for x in vocab]
token_len = [len(x) for x in vocab]
import seaborn as sns
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
with sns.plotting_context(font_scale=2):
fig, axes = plt.subplots(1,2, figsize=(10, 6))
sns.countplot(subword_len, palette="Set2", ax=axes[0])
sns.despine()
axes[0].set_title("Subword length distribution")
axes[0].set_xlabel("Length in characters")
axes[0].set_ylabel("Frequency")
sns.countplot(token_len, palette="Set2", ax=axes[1])
sns.despine()
axes[1].set_title("Token length distribution")
axes[1].set_xlabel("Length in characters")
axes[1].set_ylabel("Frequency")
在图 4-12 中,您确实看到了分布在【5-8】之间的平均值,这是一个很好的指标,表明您正在使用一个正确的预训练模型。
图 4-12
词汇标记的长度分布
如果你想仔细阅读词汇表中的不同单词,你可以访问下面的链接:
https://huggingface.co/dmis-lab/biobert-large-cased-v1.1/blob/main/vocab.txt
。
培养
BERT 可用于多种方式的微调:
-
微调:您在 BERT 模型的最后一个预训练层的顶部添加另一组层,然后用特定于任务的数据集训练整个模型,尽管在此过程中您必须确保预训练模型的权重不被破坏,因此您将它们冻结一些时期,然后在另一组时期恢复到 BERT 层的完全反向传播。这也叫热身。
-
从最后一组层中提取权重:提取的上下文嵌入被用作下游任务的输入。它们是固定向量,因此不可训练。原始论文中讨论了四种不同类型的方法(表 7)。
-
12 层的加权和。权衡可以是经验性的。
-
使用最后一个隐藏层。
-
提取倒数第二个隐藏层(倒数第二)。
-
连接最后四个隐藏层。
-
-
单词嵌入:从 BERT 的编码器层获取单词嵌入。包装器存在于拥抱脸的变形库中。
微调被认为是更好地控制模型性能的最佳方法,所以您将采用这种方法。
由于您将训练多标签分类,因此让我们为其准备最终数据集。你正在做一个实际的决定,不要保留只有三个或更少标记的短句。
# Making icd9_code unique at SUBJECT ID and HADM_ID level by clubbing different ICD9_CODE
icd9_code = icd9_code.groupby(["SUBJECT_ID","HADM_ID"])["ICD9_CODE"].apply(list).reset_index()
full_data = pd.merge(noteevents, icd9_code, how="left", on = ["SUBJECT_ID","HADM_ID"])
# Removing any SUBJECT_ID and HADM_ID pair not having the top 15 ICD9 Codes
full_data = full_data.dropna(subset = ["ICD9_CODE"]).reset_index(drop = True)
# Make sure we have text of considerable length
full_data.CLEAN_TEXT = [" ".join([y for y in x.split("|") if len(y.split()) > 3]) for x in full_data.CLEAN_TEXT]
您还将使用full_data
变量创建训练和验证集。此外,您的目标将是一个独热矩阵,每个样本有一个其所属的 ICD-9 代码的标签,其余的标签为零。
# Binarizing the multi- labels
from sklearn.preprocessing import MultiLabelBinarizer
from sklearn.model_selection import train_test_split
mlb = MultiLabelBinarizer()
mlb_fit = mlb.fit(full_data.ICD9_CODE.tolist())
train_X,val_X,train_y,val_y = train_test_split(full_data[["SUBJECT_ID"," ","CLEAN_TEXT"]],full_data.ICD9_CODE.values, test_size=0.2, random_state=42)
你终于准备好加载拥抱脸变压器库,并从 DMIS 实验室获得伯特模型。
# Load Huggingface transformers
from transformers import TFBertModel, BertConfig, BertTokenizerFast
import tensorflow as tf
import numpy as np
# For data processing
import pandas as pd
from sklearn.model_selection import train_test_split
# Load pre-trained model tokenizer (vocabulary)
tokenizer = BertTokenizerFast.from_pretrained('dmis-lab/biobert-large-cased-v1.1')
# Import BERT Model
from transformers import BertModel, BertConfig, TFBertModel
bert = TFBertModel.from_pretrained("./dmis-lab/biobert-large-cased-v1.1",
from_pt = True)
DMIS 团队共享的模型是 pytorch 模型,因此不能直接用于您的任务。您将使用 transformers 库中提供的包装函数将 pytorch 模型转换为 TensorFlow BERT 模型。
您必须确保传递了参数from_pt = True
,这表示您正试图从 Python 预训练文件创建 TFBertModel。
接下来,决定您将要使用的模型参数。
EPOCHS = 5
BATCH_SIZE = 32
MAX_LEN = 510
LR = 2e-5
NUM_LABELS = 15 # Since we have 15 classes to predict for
理想情况下,你决定MAX_LEN
。您可以绘制语料库中句子长度的直方图,但是由于文本通常很长,您已经根据标记的数量为句子取了最大长度。
目前,学习速度保持不变,没有热身。所使用的设计参数,如激活函数的选择、批量大小等。,只是经验性的设计选择,因此您可以探索和试验不同的设计选择。
就像在第三章中一样,您将创建一个生成器函数,该函数将生成批量维度的输入数据。
X = (BATCH_SIZE, {'input_ids':[0 to VOCAB LENGTH],'token_type_ids':[1/0],'attention_mask':[1/0]}
BERT 将字典作为输入:
-
输入 id表示根据 BERT 模型词汇的标记化单词的索引
-
令牌类型 ID也称为段 ID。因为您正在训练一个序列分类问题,所以所有的令牌类型 id 都是零。
-
注意力屏蔽是一个 1/0 向量,它告诉我们应该关注哪个单词。一般来说,所有的单词都被认为是重要的,但这可以根据设计决策很容易地改变。
请注意,您还将句子填充到可能的最大标记长度。
def df_to_dataset(dataframe,
dataframe_labels,
batch_size = BATCH_SIZE,
max_length = MAX_LEN,
tokenizer = tokenizer):
"""
Loads data into a tf.data.Dataset for finetuning a given model.
"""
while True:
for i in range(len(dataframe)):
if (i+1) % batch_size == 0:
multiplier = int((i+1)/batch_size)
print(multiplier)
_df = dataframe.iloc[(multiplier-1)*batch_size:multiplier*batch_size,:]
input_df_dict = tokenizer(
_df.CLEAN_TEXT.tolist(),
add_special_tokens=True,
max_length=max_length, # TO truncate larger sentences, similar to truncation = True
truncation=True,
return_token_type_ids=True,
return_attention_mask=True,
padding='max_length', # right padded
)
input_df_dict = {k:np.array(v) for k,v in input_df_dict.items()}
yield input_df_dict, mlb_fit.transform(dataframe_labels[(multiplier-1)*batch_size:multiplier*batch_size])
train_gen = df_to_dataset(train_X.reset_index(drop = True),
train_y)
val_gen = df_to_dataset(val_X.reset_index(drop = True),
val_y)
from tensorflow.keras import layers
def create_final_model(bert_model = bert):
input_ids = layers.Input(shape=(MAX_LEN,), dtype=tf.int32, name='input_ids')
token_type_ids = layers.Input((MAX_LEN,), dtype=tf.int32, name='token_type_ids')
attention_mask = layers.Input((MAX_LEN,), dtype=tf.int32, name='attention_mask')
# Use pooled_output(hidden states of [CLS]) as sentence level embedding
cls_output = bert_model({'input_ids': input_ids, 'attention_mask': attention_mask, 'token_type_ids': token_type_ids})[1]
x = layers.Dense(512, activation='selu')(cls_output)
x = layers.Dense(256, activation='selu')(x)
x = layers.Dropout(rate=0.1)(x)
x = layers.Dense(NUM_LABELS, activation='sigmoid')(x)
model = tf.keras.models.Model(inputs={'input_ids': input_ids, 'attention_mask': attention_mask, 'token_type_ids': token_type_ids}, outputs=x)
return model
model = create_final_model(bert_model = bert)
此外,请确保您只学习自定义层,至少对于几个第一时代;然后就可以学习全网了。为此,您将冻结 BERT 层,只训练自定义层。
for layers in bert.layers:
print(layers.name)
layers.trainable= False
让我们检查一下模型的外观;参见图 4-13 。特别注意可训练和不可训练参数的数量。
图 4-13
模型摘要
model.summary()
这里需要注意的一点是,您使用的是 sigmoid 函数,而不是 Softmax 函数,因为您试图识别特定的 ICD 码是否存在,因此 Softmax 足以满足相同的要求。
model.compile(optimizer= tf.keras.optimizers.Adam(learning_rate=LR),
loss='binary_crossentropy',
metrics=['AUC'])
由于这是一个大模型,可能需要很多时间来训练,因此建立一个 TensorBoard 来跟踪损失和 AUC 会很好。
# You can change the directory name
LOG_DIR = 'tb_logs'
import os
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
tensorboard_callback = tf.keras.callbacks.TensorBoard(log_dir=LOG_DIR, histogram_freq=1)
with tf.device('/device:GPU:0'):
history = model.fit(train_gen,
steps_per_epoch=len(train_X)//BATCH_SIZE,
epochs=EPOCHS,
validation_data=val_gen,
callbacks=[tensorboard_callback])
您可以使用或不使用 GPU 来训练模型,但请确保您使用的硬件启用了 GPU。如果您没有设置,请重新查看第二章的注释。
要了解您是否有可用的 GPU,请运行以下命令:
tf.test.gpu_device_name()
对于 NVIDIA GeForce GTX 1660Ti,这种模型的训练在 CPU 上可能需要很多时间,在 GPU 上可能需要一点时间。一个历元大约需要四个小时,而在 CPU 机器上几乎需要五倍的时间。因此,我不会在这里讨论模型的结果。
这里有一些加强训练的想法:
-
在几个时期内,您可以保持 BERT 层冻结,但最终为了在下游任务中获得稍好的性能,您也可以解冻并训练 BERT 层的参数。
-
尝试使用一个更精炼的模型。经过提炼的模型是对参数需求较少的模型,在许多下游任务上实现了几乎相同的性能。这使得整体训练非常快。
-
另一个修改可以在数据集生成中进行。
input_token_dict
可以对全部数据进行处理,也可以对每批数据进行子集处理。
结论
好了,带着这些想法,我想结束这一章。在这一章中,你学习了变形金刚,多重注意力概念,以及伯特长度。您应用所有这些学到的概念,通过使用拥抱人脸库来训练多标签分类模型。
你在这一章学到的变形金刚的基础在未来几年将会非常重要,因为有很多论文试图利用变形金刚完成各种任务。它们被用于图像问题、药物预测、图形网络等等。
尽管人们越来越有兴趣在不损失太多性能的情况下更快地从这种模型中做出推断,但罗杰斯等人的论文“当伯特玩彩票时,所有的彩票都中奖”表明,您可以删除伯特的许多组件,它仍然有效。本文根据彩票假设分析了 BERT 修剪,发现即使是“坏”彩票也可以被微调到良好的准确度。它仍然是推进 NLU 边界的一个非常重要的里程碑。我劝你去读读 XLNext,Longformer and Reformer,Roberta 等。它们是其他基于 transformer 或受其启发的架构,在某些任务上比 BERT 表现得更好。您将使用 BERT 模型来开发问答系统。在此之前,继续阅读和学习。
五、使用图卷积网络从收据图像中提取结构化数据
就像任何其他销售工作一样,制药公司的销售代表总是在现场。在外地意味着产生大量报销食品和旅行的收据。跟踪不遵循公司准则的账单变得很困难。在本案例研究中,您将探索如何从收据图像中提取信息,并构建各种信息。
您还将学习如何在模板文档(遵循标准模板或实体集的文档)上使用不同的信息提取技术。您将构建从开箱即用的 OCR 到图形卷积网络(GCR)的信息提取用例。gcr 相对较新,属于图形神经网络类,这是一种正在积极研究和应用的思想。
数据
您将在此案例中使用的数据是 ICDAR 2019 年扫描收据 OCR 和信息提取数据集上的稳健阅读挑战。网站链接为 https://rrc.cvc.uab.es/?ch=13
.
,在网站注册后可以很容易地从下载部分获得。您可能会发现博客/文章提到原始数据中的数据问题,因为一些数据被错误地标注,但这已被团队纠正。
您要做的是识别某些实体,即公司、日期、地址和总数。图 5-1 显示了一些带有标签及其值的图像样本。
图 5-1
样本图像及其标签
数据集被分成训练/验证集(trainval)和测试集(test)。trainval 组包括 626 个收据图像,而测试组包含大约 361 个图像。
有两种标记数据可用:
-
OCR 输出:数据集中的每个图像都用文本边界框(bbox)和每个文本 bbox 的副本进行注释。位置被标注为具有四个顶点的矩形,从顶部开始按顺时针顺序排列。
- 你可以简化这种表示。你实际需要的是(x min ,y min )和(x max ,y max ),分别是矩形的右上角和左下角。
-
节点标签:数据集中的每个图像都用一个文本文件进行了注释。
现在 OCR 输出级别没有标签,所以您必须找到一种方法将每个文本 bbox 建模为四个标签中的任何一个。
将节点标签映射到 OCR 输出
如果您仔细阅读标签和文字,您可以观察到某些情况,例如:
-
OCR 文本被分成多行,而标签输出包含相同文本的串联版本。因此,您可以用两种方式进行子串搜索,因为有时标签文本比输出短,尤其是日期标签。
-
总额有时用货币报告,有时不用,所以这有点不一致,但应该没问题,因为您将只关注总额标签的数字部分。
让我们从加载数据开始。从比赛网站下载数据,解压,把文件夹名改成ICDAR_SROIE
,然后为了更好的组织,把文件夹放在一个Data
文件夹里。
您还将在目录中创建一个名为processed
的文件夹来存储文本的边界框及其标签,但这并不简单,因为其中有一些细微差别,我将在本章中进一步讨论。
import pandas as pd
import numpy as np
import glob
import os
PROCESSED_PATH = "./Data/ICDAR_SROIE/processed/"
# Loading ocr and label data
receipt_train_img = {os.path.split(x)[-1].replace(".jpg",""):x for x in glob.glob("./Data/ICDAR_SROIE/0325updated.task1train(626p)/*.jpg") if not os.path.split(x)[-1].replace(".jpg","").endswith(")")}
ocr_data = {os.path.split(x)[-1].replace(".txt",""):x for x in glob.glob("./Data/ICDAR_SROIE/0325updated.task1train(626p)/*.txt") if not os.path.split(x)[-1].replace(".txt","").endswith(")")}
label_data = {os.path.split(x)[-1].replace(".txt",""):x for x in glob.glob("./Data/ICDAR_SROIE/0325updated.task2train(626p)/*.txt") if not os.path.split(x)[-1].replace(".txt","").endswith(")")}
# Checking if all the sets have the same number of labeled data
assert len(receipt_train_img) == len(ocr_data) == len(label_data)
接下来,创建三个函数:
-
读取 OCR 输出,只需保持(x min ,y min )和(x max ,y max ),即(x 1 ,y 1 )和(x 3 ,y 3 )。
-
将标签数据作为字典读取。
-
将 OCR 输出映射到标签。
import json
def extract_ocr_data_fromtxt(file_path, key, save = False):
"""
Extract the bounding box coordinates from txt and returns a pandas dataframe
"""
with open(file_path, 'r') as in_file:
stripped = (line.strip() for line in in_file)
lines = [line.split(",")[:2] + line.split(",")[4:6] + [",".join(line.split(",")[8:])] for line in stripped if line]
df = pd.DataFrame(lines, columns = ['xmin', 'ymin','xmax', 'ymax','text'])
# Option to save as a csv
if save:
if not os.path.exists(PROCESSED_PATH):
os.mkdir(PROCESSED_PATH)
df.to_csv(os.path.join(PROCESSED_PATH,key + '.csv'), index =None)
return df
def extract_label_data_fromtxt(file_path):
"""
Read the label json and return as a dictionary
"""
with open(file_path) as f:
json_data = json.load(f)
return json_data
def map_labels(text,k):
"""
Maps label to ocr output using certain heuristics and logic
"""
text_n = None
k_n = None
try:
text_n = float(text)
except Exception as e:
pass
try:
k_n = float(k)
except Exception as e:
pass
# if both are text then we are doing a substring match
if (pd.isnull(text_n) and pd.isnull(k_n)):
if (text in k) or (k in text):
return True
# if both are numerical then we just check for complete match
elif (text_n is not None) and (k_n is not None):
return text == k
# special case to handle total, using endswith
# as sometimes symbols are attached to ocr output
elif (k_n is not None) and (text_n is None):
return text.endswith(k)
return False
注意映射函数map_labels
并不是创建标签的完美方式。total 标签可能有很多误报,如图 5-2 所示,total 标签不匹配。但这并不经常发生,因此可以手动纠正或按原样标记。让我们保持标签不变。
最后,创建一个包装器函数,将映射的数据保存在一个单独的文件夹中。
def mapped_label_ocr(key):
"""
Wrapper function to yield result of mapping in desired format
"""
data = extract_ocr_data_fromtxt(ocr_data[key],key)
label_dict = extract_label_data_fromtxt(label_data[key])
data['labels'] = ["".join([k for k,v in label_dict.items() if map_labels(text, v)]) for text in data.text]
if not os.path.exists(PROCESSED_PATH):
os.mkdir(PROCESSED_PATH)
data.to_csv(os.path.join(PROCESSED_PATH,key + '.csv'), index =None)
return data
# save the data
mapped_data = {key: mapped_label_ocr(key) for key in ocr_data.keys()}
让我们快速检查一下您应用的启发式方法是否有效。图 5-2 和 5-3 显示了两个用于比较的例子。
图 5-3
示例 2:启发式标记
图 5-2
示例 1:启发式标记
这两个例子都表明,由于数据不一致,简单的子串搜索无法使用。因此,你要去模糊路线,并试图模糊搜索文本与一个非常高的地方截止。
为此,您将使用 fuzzywuzzy 包,这是一个非常有效的包,它提供了对各种类型的模糊匹配(Levenstein,phonical,等等)的访问。)以各种方式应用(令牌、字符级等。).
import json
from fuzzywuzzy import fuzz
def extract_ocr_data_fromtxt(file_path, key, save = False):
"""
Extract the bounding box coordinates from txt and returns a pandas dataframe
"""
.....
def extract_label_data_fromtxt(file_path):
"""
Read the label json and return as a dictionary
"""
....
def map_labels(text,k):
"""
Maps label to ocr output using certain heuristics and logic
"""
.....
# if both are text then we are doing a fuzzy match
if (pd.isnull(text_n) and pd.isnull(k_n)):
if fuzz.token_set_ratio(text,k) > 90:
return True
.....
此外,有时公司名称会成为地址的一部分。为此,您需要修改您的包装函数并优先选择地址。
节点特征
为了用 GCN 对这些收据建模,您需要将它们转换成图形。在 OCR 过程中提取的每个单词都可以被视为一个单独的节点。
这些节点可以是以下类型:
-
公司
-
地址
-
日期
-
总数
-
不明确的
每个节点都有一个与之关联的特征向量,它将告诉我们该节点携带的数据。理想情况下,您可以使用任何高级 LM 模型从文本中提取信息,但是在这种特殊情况下,文本不需要大量的语义上下文,因此使用任何 LM 模型都是多余的。相反,您可以使用简单的文本特征生成流水线。您将生成以下要素:
-
SpecialCharacterCount
:特殊字符总数 -
isFloat
:如果文本表示浮点数,则该列的值为 1。 -
isDate
:看文字是否代表日期。 -
TotalDistinctNumber
:文本中有多少个不同的数字。与其他实体相比,地址通常包含许多数字(如门牌号、街道号和 Pin/邮政编码),因此这是一个有用的特性。 -
BigNumLength
:最大数的长度。pin/邮政编码的长度将大于门牌号和行号。此外,帐单的总数可能是最高的数字。 -
IsContainsNum
:文本是否包含数值实体。 -
POSTagDistribution
:查看每段文字的下列位置标签的分布(总计数)。为此,您将使用空间位置标记(https://spacy.io/api/annotation#pos-tagging
)-
SYM
:货币符号(票据总值可以有货币符号) -
NUM
:基数 -
CCONJ
:连词(地址可以有很多连词) -
PROPN
:专有名词
-
所以每个节点总共有 10 个特性。
您将为已处理的数据帧维护一个内存中的对象,但我们也将它保存在一个单独的目录中,供以后参考
PROCESSED_TEXT_PATH = "./Data/ICDAR_SROIE/processed_text_features"
if not os.path.exists(PROCESSED_TEXT_PATH):
os.mkdir(PROCESSED_TEXT_PATH)
import spacy
import string
import collections
import re
from dateutil.parser import parse
from itertools import groupby
import en_core_web_sm
nlp = en_core_web_sm.load()
def get_text_features(text):
# SpecialCharacterCount
special_chars = string.punctuation
SpecialCharacterCount = np.sum([v for k, v in collections.Counter(text).items() \
if k in special_chars])
# isFloat
try:
float(text)
isFloat = 1
except Exception as e:
isFloat = 0
# isDate
try:
parse(text, fuzzy=True)
isDate = int(True and len(text) > 5)
except Exception as e:
isDate = 0
# TotalDistinctNumber
num_list = re.findall(r"(\d+)", text)
num_list = [float(x) for x in num_list]
TotalDistinctNumber = len(num_list)
# BigNumLength
BigNumLength = np.max(num_list) if TotalDistinctNumber > 0 else 0
# DoesContainsNum
DoesContainsNum = 1 if TotalDistinctNumber > 0 else 0
# POSTagDistribution
spacy_text = nlp(text)
pos_list = [token.pos_ for token in spacy_text]
POSTagDistribution = {}
for k in ['SYM','NUM','CCONJ','PROPN']:
POSTagDistribution['POSTagDistribution' + k] = [0]
POSTagDistribution.update({'POSTagDistribution'+ value: [len(list(freq))] for value, freq in groupby(sorted(pos_list)) if value in ['SYM','NUM','CCONJ','PROPN']})
pos_features = pd.DataFrame.from_dict(POSTagDistribution)
other_features = pd.DataFrame([[SpecialCharacterCount, isFloat, isDate,
TotalDistinctNumber, BigNumLength, DoesContainsNum]],
columns = ["SpecialCharacterCount","isFloat","isDate", "TotalDistinctNumber","BigNumLength", "DoesContainsNum"])
df = pd.concat([other_features, pos_features], axis = 1)
return df
如前所述,您将使用文本值创建 10 个要素。虽然代码是不言自明的,但仍有一些问题需要讨论。
-
您正在使用 dateutil 包来提取和识别日期值,但是它并不完美,导致了许多误报,所以现在有了另一个条件,即文本的长度应该至少为 5。这消除了被捕获的误报。
-
就性能而言,itertools 是一个非凡的包,因此您应该始终尝试在您的应用程序中利用它。还有其他方法可以获得列表元素的频率,但这种方法确实很好,也是最优的。
将结果存储在单独的数据帧中。
mapped_data_text_features = {}
for k, v in mapped_data.items():
_df = pd.concat([get_text_features(x) for x in v.text], axis = 0)
final_df = pd.concat([v.reset_index(drop = True), _df.reset_index(drop = True)], axis = 1)
final_df.to_csv(os.path.join(PROCESSED_TEXT_PATH,k+".csv"), index = None)
mapped_data_text_features[k] = final_df
在你进一步阅读本章之前,还有两件事需要了解。
-
在你的数据集中,没有给出单词和节点之间的联系。
-
如何决定训练的输入数据?是批量节点还是单个节点矩阵?
分层布局
Lohani 等人在他们题为“使用图形卷积网络的发票读取系统”的论文中讨论了如何为发票系统的节点/单词建模。这些关系是在最近邻概念的基础上形成的。见图 5-4 。
图 5-4
基于最近邻概念创建边。图片来源:Lohani 等人题为“使用图卷积网络的发票读取系统”的论文
每个字节点在每个方向上只有一个邻居。这可以推广到本案例研究之外的任何半结构化文档图建模问题。
作者在他们的论文中提出了两个主要步骤来创建这种分层布局。
谱线形成
-
根据顶部坐标 对单词进行排序。
-
将行组成一组单词,遵守以下规则:
如果 Top(Wa) ≤ Bottom(Wb)和 Bottom(Wa) ≥ Top(Wb ),则两个词(Wa 和 Wb)在同一行
-
根据左边坐标对每行单词进行排序
这给出了链接形成的方向。你从左上角开始,在右下角结束。这也确保了您只看到一个单词/节点一次。
import itertools
def get_line_numbers(key):
"""
Get line number for each word.
"""
################ 1 ##################
df = mapped_data_text_features[key]
df.sort_values(by=['ymin'], inplace=True)
df.reset_index(drop=True, inplace=True)
# To avoid spacing issue, lets reduce ymax by some small value
df["ymax"] = df["ymax"].apply(lambda x: int(x) - 0.5)
################ 2 ##################
# In order to get line number we start with left most word/phrase/node
# and then check all non-matching words and store their indices from L->R
word_idx = []
for i, row in df.iterrows():
flattened_word_idx = list(itertools.chain(*word_idx))
#print(flat_master)
# check if the word has not already been checked
if i not in flattened_word_idx:
top_wa = int(row['ymin'])
bottom_wa = int(row['ymax'])
# Store the word
idx = [i]
for j, row_dash in df.iterrows():
if j not in flattened_word_idx:
# check a different word, double check
if not i == j:
top_wb = int(row_dash['ymin'])
bottom_wb = int(row_dash['ymax'] )
# Valid for all the words next to Wax
if (top_wa <= bottom_wb) and (bottom_wa >= top_wb):
idx.append(j)
#print(line)
word_idx.append(idx)
# Create line number for each node
word_df = pd.DataFrame([[j,i+1] for i,x in enumerate(word_idx) for j in x], columns= ["word_index","line_num"])
# put the line numbers back to the list
final_df = df.merge(word_df, left_on=df.index, right_on='word_index')
final_df.drop('word_index', axis=1, inplace=True)
################ 3 ##################
final_df = final_df.sort_values(by=['line_num','xmin'],ascending=True)\
.groupby('line_num').head(len(final_df))\
.reset_index(drop=True)
final_df['word_id'] = list(range(len(final_df)))
return final_df
因为轴是颠倒的,
-
顶部坐标是 Ymin(最左边的坐标)。
-
您需要运行两个
for
循环,将数据帧中的每个单词与其他单词的垂直位置进行比较。 -
最终数据帧的输出按其行号排序。
Note
上述策略在大量重叠边界框的情况下可能会失败,但现在不是这种情况,所以我们可以接受。
最后,将结果存储在一个单独的变量中。
mapped_data_text_features_line = {key:get_line_numbers(key) for key,_ in mapped_data_text_features.items()}
接下来,作者讨论了实际链接形成的图形形成。
图形建模算法
-
从最上面的一行到最下面的一行,阅读每行的单词。
-
对于每个单词,执行以下操作:
-
用它检查垂直投影中的单词。
-
计算每个人的 RDL 和 RDR。
-
选择水平方向上具有最小 RDL 和 RDR 量值的最近邻单词,前提是这些单词在该方向上没有边缘。
- 如果两个单词具有相同的 RDL 或 RDR,则选择具有较高顶部坐标的单词。
-
类似地重复步骤 2.1 到 2.3,通过进行水平投影,计算 RDT 和 RDB,并在模糊的情况下选择具有较高左坐标的单词,来检索垂直方向上的最近邻单词。
-
在一个单词和它的四个最近的邻居(如果有的话)之间画边。
-
首先,让我们创建一个目录来保存连接节点图。
GRAPH_IMAGE_PATH = "./Data/ICDAR_SROIE/processed_graph_images"
if not os.path.exists(GRAPH_IMAGE_PATH):
os.mkdir(GRAPH_IMAGE_PATH)
然后,创建一个包含不同信息的类,即:
-
连接列表:包含连接节点信息的嵌套列表
-
G : Networkx 图形对象。Networkx 是用于处理网络对象的 Python 库。
-
已处理数据帧:包含节点连接的数据帧。
class NetworkData():
def __init__(self, final_connections, G, df):
self.final_connections = final_connections
self.G = G
self.df = df
def get_connection_list():
return self.final_connections
def get_networkx_graph():
return self.G
def get_processed_data():
return self.df
Note
在这里,您可以使用 getter 函数,也可以只引用类对象。
import networkx as nx
from sklearn.preprocessing import MinMaxScaler
def graph_modelling(key, save_graph =False):
# Horizontal edge formation
df = mapped_data_text_features_line[key]
df_grouped = df.groupby('line_num')
# for directed graph
left_connections = {}
right_connections = {}
for _,group in df_grouped:
wa = group['word_id'].tolist()
#2
# In case of a single word in a line this will be an empty dictionary
_right_dict = {wa[i]:{'right':wa[i+1]} for i in range(len(wa)-1) }
_left_dict = {wa[i+1]:{'left':wa[i]} for i in range(len(wa)-1) }
#add the indices in the dataframes
for i in range(len(wa)-1):
df.loc[df['word_id'] == wa[i], 'right'] = int(wa[i+1])
df.loc[df['word_id'] == wa[i+1], 'left'] = int(wa[i])
left_connections.update(_left_dict)
right_connections.update(_right_dict)
# Vertical edge formation
bottom_connections = {}
top_connections = {}
for i, row in df.iterrows():
if i not in bottom_connections.keys():
for j, row_dash in df.iterrows():
# since our dataframe is sorted by line number and we are looking for vertical connections
# we will make sure that we are only searching for a word/phrase next in row.
if j not in bottom_connections.values() and i < j:
if row_dash['line_num'] > row['line_num']:
bottom_connections[i] = j
top_connections[j] = i
#add it to the dataframe
df.loc[df['word_id'] == i , 'bottom'] = j
df.loc[df['word_id'] == j, 'top'] = i
# break once the condition is met
break
# Merging Neighbours from all 4 directions
final_connections = {}
# Taking all the keys that have a connection in either horizontal or vertical direction
# Note : Since these are undirected graphs we can take either of (right, left) OR (top, bottom)
for word_ids in (right_connections.keys() | bottom_connections.keys()):
if word_ids in right_connections: final_connections.setdefault(word_ids, []).append(right_connections[word_ids]['right'])
if word_ids in bottom_connections: final_connections.setdefault(word_ids, []).append(bottom_connections[word_ids])
# Create a networkx graph for ingestion into stellar graph model
G = nx.from_dict_of_lists(final_connections)
# Adding node features
scaler = MinMaxScaler()
scaled_features = scaler.fit_transform(df[['SpecialCharacterCount', 'isFloat', 'isDate', 'TotalDistinctNumber',
'BigNumLength', 'DoesContainsNum', 'POSTagDistributionSYM',
'POSTagDistributionNUM', 'POSTagDistributionCCONJ',
'POSTagDistributionPROPN', 'line_num']])
node_feature_map = {y:x for x,y in zip(scaled_features, df.word_id)}
for node_id, node_data in G.nodes(data=True):
node_data["feature"] = node_feature_map[node_id]
if save_graph:
# There are multiple layouts but KKL is most suitable for non-centric layout
layout = nx.kamada_kawai_layout(G)
# Plotting the Graphs
plt.figure(figsize=(10,5))
# Get current axes
ax = plt.gca()
ax.set_title(f'Graph form of {key}')
nx.draw(G, layout, with_labels=True)
plt.savefig(os.path.join(GRAPH_IMAGE_PATH, key +".jpg"), format="JPG")
plt.close()
networkobject = NetworkData(final_connections, G, df)
return networkobject
代码非常直观。这些是代码中发生的高级事情:
-
水平连接
-
它们只能在同一行中的单词之间形成,因此您可以根据行号对处理的数据进行分组。
-
为了更清楚起见,您维护了右连接和左连接,但是对于无向图,右连接字典就足够了。
-
-
垂直连接
-
它们永远不能在属于同一行的单词之间形成。
-
使用了两个
for
循环,因为您必须沿着不同的线路遍历。 -
同样,方向是不相关的,但保持清晰。
-
-
右侧和底部字典都用于为 networkx 图创建邻接表。
-
最后,缩放和归一化结点要素。您还将行号作为特征之一,因为它是带有地址/公司编号等的临时文档。在顶部出现,总在底部出现。
调用上面的代码,你得到的结果如图 5-5 所示。
图 5-5
不同账单的网络布局示例
mapped_net_obj = {key: graph_modelling(key, save_graph=True) for key,_ in mapped_data_text_features_line.items()}
输入数据流水线
在你正在使用的星图库中,你不能训练不同的网络,但是可以使用所有网络的联合。这将导致一个具有大邻接矩阵的大图。参见图 5-6 。
图 5-6
多重图的并与训练过程。来源:github . com/tkipf/gcn
您将利用 Networkx 中的内置功能。
U = nx.union_all([obj.G for k,obj in mapped_net_obj.items()], rename=[k+"-" for k in mapped_net_obj.keys()])
现在,既然您终于有了想要的数据,是时候详细了解一下图和图卷积网络了。
什么是图表,我们为什么需要它们?
在计算机科学理论中,我们将图定义为一种数据结构,它由一组有限的顶点(也称为节点)和一组连接这些节点的边组成。根据图是有向的还是无向的,图的边可以是有序的或无序的。
除了边的方向,不同类型的图之间还有其他区别:
-
一个图可以加权也可以不加权。在加权图中,每条边都有一个权重。
-
如果一个无向图 G 的每一对不同的顶点之间都有一条路,则称 G 为连通。
-
简单的图没有自循环,这意味着没有边连接顶点和它自己。
所以可以有多种术语和方法来区分图形。现在问题来了,为什么我们会关心机器学习中的图呢?
本书的大多数读者通常熟悉四种类型的数据,即
-
文本
-
结构化/表格化
-
声音的
-
形象
所有这些数据都可以由众所周知的神经网络架构来表示,但有一类特殊的数据不能,它被称为非欧几里德数据集。与上述 1D 或 2D 数据集相比,此类数据集可以更精确地表示更复杂的项目和概念。
让我们明白这一点。
比方说你要分类一句话:
- 约翰是个好人。
在案例 1 中,您只有 pos 标签,您可以很好地在 GRU/LSTM/RNN 单元格中对其建模,以分类捕获单词之间的线性和非层次连接。
但是,在第二种情况下,您还会得到关于它们之间的依赖关系的信息。你打算如何为他们建模?参见图 5-7 。
图 5-7
建模连接的数据,恰当的例子
这就是图表的用武之地。它们可以帮助您自然而有效地对这种层次结构进行建模,比其他数据集(如社交网络数据、化学分子数据、树/本体和流形)更有效,这些数据集通过层次结构、互连性和多维度保存丰富的信息。在这些情况下,图更适合。
图形有助于建模这样的非欧几里德数据库。它还允许我们表示节点的内在特征,同时还提供关于关系和结构的信息,并且非常容易表示为用于学习的神经网络。
大多数神经网络可以归类为称为多部图的东西,这基本上是可以分成不同节点集的图。这些节点集不与同一集的节点共享边。参见图 5-8 。
图 5-8
作为多部图的神经网络
在计算机系统中,图形是用一种叫做邻接矩阵的东西来表示的。邻接矩阵由连接的实体之间的边权重组成。它显示了图的三个最重要的属性:
-
关系
-
关系强度(边缘权重)
-
关系的方向
有向图的邻接矩阵不会沿着对角线对称,因为有向图的边只朝一个方向。对于无向图,邻接矩阵总是对称的。
此外,度数显示了图形的复杂程度。一个顶点的度数表示与之相连的顶点总数。在无向图中,它是连接的组件的简单总和,而对于有向图,根据关系的方向,度数被进一步分为入站和出站度数。参见图 5-9 。
图 5-9
具有邻接矩阵的无向图
捕捉图形信息的另一个矩阵是拉普拉斯矩阵。
度矩阵中的每个值减去邻接矩阵中相应的值。拉普拉斯矩阵基本上有助于确定图形函数有多平滑。换句话说,当从一个顶点移动到下一个顶点时,值的变化不应该是突然的。对于密集连接的集群来说更是如此。然而,对于孤立的节点,平滑度会降低,各种任务的性能也会降低,这可以使用图形来完成,因此图形连接得越多,它包含的信息就越多。
图形卷积网络
图的卷积
图形卷积网络的工作原理是将卷积应用于图形网络。但这意味着什么呢?让我们看看。
你理解卷积传统上,给定一个输入图像表示,你试图学习一个核矩阵,这有助于你从相邻像素聚集信息。参见图 5-10 中的图示。
图 5-10
图像的卷积运算
这里发生的主要事情是,您可以从相邻像素聚集信息。这是为各种任务建模图形数据时借用的概念,例如
-
节点分类:预测节点的类型。
-
链路预测:预测任意两个节点之间是否正在形成新的连接/链路
-
社区检测:识别在图中是否有任何确定的集群形成,很大程度上类似于密集链接的集群,但在统计意义上更大。(想象一下 PageRank。)
-
网络相似度:如果该图或其子网与另一个图或其子网相似。
卷积处理图形数据的方式有一些细微的差别。
-
图像具有刚性(有很强的方向感,以至于将一个像素值从中心像素的左侧移动到右侧会改变意义)和规则(像素在几何上等距)连接结构。但是图表肯定不会。
-
图形学习应该不管输入数据的大小而工作。
就像像素是用来表示图像一样,在图中也有称为节点特征和边特征的东西。
节点特征在语义上标识节点是关于什么的,而边特征可以帮助标识两个节点之间共享的不同关系。
我将要谈论的网络是 Kipf 和 Wellling 在 2017 年题为“使用图形卷积网络的半监督分类”的论文中提出的 GCN 网络。此网络不考虑边缘功能,但您不会将它们用于您的应用程序。大多数复杂网络都需要边要素。正如在分子化学中,双键比单键强得多,所以两者不能以同样的方式处理;它们必须以不同的方式使用。但是,在您的案例中,边代表发票中不同文本实体之间的连接,因此具有相同的含义,因此您可以取消可以模拟边要素的网络。
但对于好奇的人来说,这里有两篇论文是由 Kipf 和 Welling 分享的对 GCN 建筑的改进:
-
吉尔默等人于 2017 年在 ICML 创作的《MPNN》
-
2018 年 ICLR veli kovi 等人的“图形注意力网络”
周、崔、张等人在“图神经网络:方法与应用综述”中的论文也给出了很好的综述
了解 GCNs
图 5-11 解释了卷积如何在图数据上工作,给定一个无向图 G = (V,e)具有节点 v i ∊ V,边(v i ,v j ) ∊ E 和一个大小为 NXN 的邻接矩阵(a ),其中 n 表示节点的数量,特征矩阵(h)的大小为 NXK,其中 k 是特征向量的维数。要从每个节点的邻居中找到特征值,您需要将矩阵 A 和 h 相乘。
图 5-11
图的卷积运算
正如您在更新的节点功能矩阵中看到的,有两点可以改进:
-
您可以防止由于节点连接程度的差异而导致的规模问题。某些节点是高度连接的,而一些不是,因此自然地,与稀疏连接的节点相比,高度连接的节点将具有更高的特征值。
-
每个节点已经完全忘记了自己的特性,全部从标签中学习,所以你需要确保当前的信息没有完全丢失。
首先,您要确保每个节点也能够保留来自自身的信息。为此,您通过添加一个连接到它自身来更新图,这基本上意味着邻接矩阵现在沿着对角线都是 1。
由于比例可以通过用节点的度数进行归一化来校正,所以您将这些值乘以 D -1 。由于 D 是一个对角矩阵,D -1 只是往复所有对角元素。注意,这个 D 矩阵是在上面创建的自循环之后更新的矩阵。
Kipf 和 Welling 在他们提出的想法中指出,与高度连接的层相比,度数较低的节点将对其邻居施加更多的影响。基本上,向所有节点传递信息的节点不会提供任何关于节点的“独特”信息。为此,作者建议将 D -1 AH 的结式矩阵与 D -1 相乘。因为你要规格化两次,所以你要确保除以。这样,在计算第 I 个节点的聚合要素表示时,不仅要考虑第 I 个节点的度,还要考虑第 j 个节点的度。这也被称为光谱规则。
在这个想法中需要注意的一点是,Kipf 等人提出这个想法时要记住,edge 在这里没有任何作用。如果即使是高度连通的节点的连接也有不同的边特征,那么上述假设并不总是成立。
最后,更新后的节点特征矩阵如下所示:
H 更新 = f ( A , D , H )
一个完整的方程大概是这样的:
Relu 或任何其他非线性激活可以应用于此。这里 W 是大小为(KxK’)的可训练权重矩阵,其中 K’是下一层的特征向量的维数。这基本上有助于通过随深度减小尺寸来解决过拟合问题。
GCNs 中的层堆叠
图形的所有邻居都以这种方式更新。一旦所有的节点都更新了它们的直接邻居,就有了第一层的输出。
第二层也从二级连接中获取信息,这基本上意味着,由于在第一步中每个节点已经从其子节点中建模了信息,如果在下一层中再次运行相同的步骤,子节点的子节点的这些特征将被添加到父节点中。基本上,网络越深,本地的邻域就越大。参见图 5-12 。
图 5-12
GCN 中的图层。图片来源 helper。ipam。加州大学洛杉矶分校。edu/publications/glw S4/glw S4 _ 15546。pdf
培养
对于像您这样的节点分类问题,培训主要包括以下步骤。
-
通过 GCN 层执行前向传播。
-
按行应用 sigmoid 函数(即,针对 GCN 中最后一层的每个节点)。
-
计算已知节点标签上的交叉熵损失。
-
反向传播损失并更新每层中的权重矩阵 W。
注意,存在最终权重矩阵,其将每个节点的最终隐藏状态表示与节点分类任务预期的类的数量进行映射。所以,如果你把类的数量称为 C,这个权矩阵的形状是(K ',C)。假设节点的最后特征表示具有 K’的维度,权重矩阵的总数= L+1,其中 L 是 GCN 层数。
建模
虽然在 TensorFlow 中构建自己的 GCN 层并不困难,但有一些库通过提供预构建的 API,使 Keras 和 TF 2.0 更容易进行图形深度学习。一个这样的库是 StellarGraph。它的官方 GitHub 上有超过 17k 颗星,并且有一个活跃的社区。StellarGraph 可以从各种数据源(networkx graphs、pandas,甚至 numpy 数组)获取数据。
因为您已经准备好了所有图的并集,所以让我们直接从 networkx 加载您的数据。
G_sg = sg.from_networkx(U, node_features="feature")
print(G_sg.info())
####################### Output ################
StellarGraph: Undirected multigraph
Nodes: 33626, Edges: 46820
Node types:
default: [33626]
Features: float32 vector, length 11
Edge types: default-default->default
Edge types:
default-default->default: [46820]
Weights: all 1 (default)
Features: none
如你所见,总共有 33626 个节点和 46820 条边。这仍然是一个小图,但它对训练目的非常有用。
训练测试分割和目标编码
接下来,确保在节点 id 和目标之间有一对一的映射。为此,您将从已处理的数据中创建此数据,并将所有空标签替换为“others”
labelled_data = pd.DataFrame([[k+"-"+str(node_idx), label]
for k,obj in mapped_net_obj.items()\
for node_idx,label in zip(obj.df.word_id,obj.df.labels)],
columns = ["node_id","node_target"])
labelled_data = labelled_data.replace(r'^\s*$', "others", regex=True)
目标类的分布如下所示。
| | index | node_target |
|---:|:--------|--------------:|
| 0 | others | 28861 |
| 1 | address | 1692 |
| 2 | total | 1562 |
| 3 | date | 764 |
| 4 | company | 747 |
最有代表性的是other
类,这是意料之中的。在节点预测中存在一些类别不平衡。我敦促你尝试纠正这种不平衡,然后重新训练模型。
最后,在创建模型之前,让我们也创建您的训练和验证数据。
您还将对多类输出进行二值化,并为多类分类问题设置模型。
train,val = model_selection.train_test_split(labelled_data, random_state = 42,train_size = 0.8, stratify = labelled_data.node_target)
# Encoding the targets
target_encoding = preprocessing.LabelBinarizer()
train_targets = target_encoding.fit_transform(train.node_target)
val_targets = target_encoding.fit_transform(val.node_target)
在 StellarGraph 中创建培训流程
接下来,您将使用一个内置的generator
函数来生成恒星网络图中的节点批次。
generator = FullBatchNodeGenerator(G_sg)
一旦创建了生成器对象,您就可以调用flow
函数并传递目标标签和节点来获得一个可以用作 Keras 数据生成器的对象。
train_flow = generator.flow(train.node_id, train_targets)
val_flow = generator.flow(val.node_id, val_targets)
训练和模型性能图
你形成了一个非常基本的 Keras 模型。您添加了两个大小为 8 和 4 的 GCN 图层。这两层也暗示着你要去的是每个节点的二级邻居。对于每个激活(节点嵌入),使用 SELU 激活函数来防止渐变消失问题。
你还引入了一个辍学,以防止过拟合。
因为您的输入和输出是使用generator
对象创建的,您将从 GCN 层获得输入和输出张量来了解输入和输出。
最后,输出被送入一个密集层,其形状等于目标标签的数量。基本上,每个节点嵌入乘以最终权重矩阵,然后应用激活来查看哪个类最有可能用于该节点。参见图 5-13 。
图 5-13
GCN 模式总结
# Model Formation
# two layers of GCN
gcn = GCN(layer_sizes=[8, 4], activations=["selu", "selu"], generator=generator, dropout=0.5)
# expose in and out to create keras model
x_inp, x_out = gcn.in_out_tensors()
# usual output layer
predictions = layers.Dense(units=train_targets.shape[1], activation="softmax")(x_out)
# define model
model = Model(inputs=x_inp, outputs=predictions)
# compile model
model.compile(
optimizer=optimizers.Adam(lr=0.01),
loss=losses.categorical_crossentropy,
metrics=["AUC"])
正如您所看到的,您可以引入更多的参数,并创建一个更加有效的模型。但是现在模型的性能对于你的任务来说还过得去。
现在拟合模型并检查结果。
from tensorflow.keras.callbacks import EarlyStopping
es_callback = EarlyStopping(monitor="val_auc", patience=10, restore_best_weights=True)
history = model.fit(
train_flow,
epochs=10,
validation_data=val_flow,
verbose=2,
callbacks=[es_callback])
Epoch 1/10
1/1 - 1s - loss: 1.7024 - auc: 0.4687 - val_loss: 1.5375 - val_auc: 0.7021
Epoch 2/10
1/1 - 0s - loss: 1.5910 - auc: 0.5962 - val_loss: 1.4360 - val_auc: 0.8740
Epoch 3/10
1/1 - 0s - loss: 1.4832 - auc: 0.7261 - val_loss: 1.3445 - val_auc: 0.9170
Epoch 4/10
1/1 - 0s - loss: 1.3891 - auc: 0.8178 - val_loss: 1.2588 - val_auc: 0.9189
Epoch 5/10
1/1 - 0s - loss: 1.2993 - auc: 0.8753 - val_loss: 1.1768 - val_auc: 0.9175
Epoch 6/10
1/1 - 0s - loss: 1.2219 - auc: 0.8958 - val_loss: 1.0977 - val_auc: 0.9160
Epoch 7/10
1/1 - 0s - loss: 1.1405 - auc: 0.9068 - val_loss: 1.0210 - val_auc: 0.9146
Epoch 8/10
1/1 - 0s - loss: 1.0638 - auc: 0.9120 - val_loss: 0.9469 - val_auc: 0.9134
Epoch 9/10
1/1 - 0s - loss: 0.9890 - auc: 0.9131 - val_loss: 0.8767 - val_auc: 0.9129
Epoch 10/10
1/1 - 0s - loss: 0.9191 - auc: 0.9140 - val_loss: 0.8121 - val_auc: 0.9120
更高历元的训练在第 18 个历元达到早期停止标准,并产生如图 5-14 所示的训练和验证曲线。
图 5-14
训练和验证性能曲线
看起来不存在过拟合的情况,但是您肯定可以使用更多的参数并尝试提高性能。可以进行以下更改:
-
包括该节点的更多特征。
-
尝试除最小-最大缩放器之外的不同归一化技术。
-
更密集的预测模型有助于更好地捕捉细微差别。
- 请注意,从 GCN 层获得输入和输出后,可以像构建任何正常的 Keras 模型一样构建模型。
-
处理阶级不平衡。
结论
我希望你对被介绍到这种新的神经网络感到兴奋。它为处理真实世界的数据打开了许多大门。不要把自己局限在这里讨论的 GCN 模型中。外面有一个巨大的知识海洋!
你学到了一项一流的技术,但 GCN 也有不足之处:
-
它不考虑节点边。
-
它对同构图形(具有单一类型节点/边的图形)非常有用。
-
节点局部性在其分类中仍然起着很好的作用。尝试通过从特征集中移除行号参数并重新建模来进行一些消融建模。
我们生活的世界是紧密相连的,随着我们在本世纪的前进,这种联系将会更加紧密。用如何对这种数据建模的知识武装自己将是一项受人尊敬的技能,并且肯定会有助于推进你的职业和兴趣。
六、处理医疗保健中低训练数据的可用性
训练数据的可用性是机器学习应用中的一个关键瓶颈。通过在医疗保健等专业领域工作,这一点得到了进一步增强,在这些领域中,人们需要非常熟练地理解数据,然后标记或标注数据,以供机器学习使用。除了寻找技能管家之外,组织还需要在时间和成本方面进行大量投资。
你已经学会了一种处理有限信息可用性的方法,那就是迁移学习。与迁移学习不同,迁移学习是一种处理低训练数据的算法方法,在本章中,您将使用数据优先的方法,尝试理解和建模数据,以便创建训练标签。
您将了解处理低训练数据的不同方法以及应用这些方法的挑战。最后,您将通过一个实践案例来探索如何使用通气管为生物医学关系抽取增加训练数据。
介绍
创建具有高质量训练标签的数据集需要投入大量的时间和金钱,有时甚至需要高度专业化领域的领域专家。因此,我们必须找到更聪明的方法,以这样或那样的方式利用我们未标记数据的数据模式,帮助我们在看不见的数据上创建训练标签。
半监督学习
半监督学习涉及使用小的金标数据集和未标记数据。半监督学习有四个关键步骤:
-
你使用少量的金标数据来训练一个选择的模型,很像标准的监督学习。
-
然后,使用未标记的数据来预测使用训练好的模型标签的输出。由于该模型仅在少数样本上训练,因此很难说预测是高度准确的,因此来自这种模型的标签输出被称为伪标签。
-
然后,收集黄金标签数据和大量伪标签数据,并创建一个新的训练集。
-
您使用这个新集合重新训练您的模型。
-
重复这个过程,直到性能指标图表(跨时段)变平。
在第五章中,你处理了节点分类问题,你必须预测公司名称、地址、日期和账单的总成本。可用的训练数据较少,但您能够在训练标签上以合理的准确度进行预测,因为模型不仅学习节点特征,还学习其边连接,因此强大的图形神经网络可以很好地学习这个小数据集。
尽管任何模型都可以用于在一个小的金标数据集加上伪标签上进行训练,但是有两个主要的模型策略已经被广泛利用。
甘斯
发电商敌对网络(GANs)包括两个互为对手的网络,它们相互竞争直到达到理想的平衡状态。这两个网络是发生器和鉴别器。见图 6-1 。
图 6-1
发电机对抗网络
生成器:学习生成真实数据
鉴别器:学习鉴别发电机的假数据和真数据
训练 GAN 的关键步骤包括
-
来自真实数据和虚假数据的样本被用来单独训练鉴别器。在这里,虚假数据是由噪声分布产生的。
-
那么鉴别器的权重被冻结,并且生成器被训练。
-
或者,这些网络被训练,彼此竞争,直到它们达到平衡状态(梯度流正常化)。
训练两个网络所涉及的损失函数是基于鉴别器网络的真实与虚假预测。在使用 GANs 的半监督学习中,鉴别器网络不仅输出真实或虚假的分布,还输出所有相关标签的分布。
如果输入被归类为任何类别标签,则被归类为真实,如图 6-2 所示。
图 6-2
半监督 GAN 架构
鉴别器现在有一个双重目标,首先区分真实图像和虚假图像(也称为非监督任务),其次将真实图像分类到它们各自的类别(监督任务)。
对于每个迭代,您执行以下操作:
-
训练受监督的鉴别器。取一批训练标签,训练多类网络。
-
训练无监督鉴别器。取一批未标记数据和一批伪样本,训练二进制分类器,反向传播二进制损失。
-
训练发电机(就像简单的 GAN 一样)。
阅读 Odena 题为“使用生成对抗网络的半监督学习”的论文,进一步深入了解半监督学习在 GANs 中的使用。
自编码器
我在第三章介绍了自编码器,在那里您使用它们来编码您的训练特征,以获得一个低维、密集的表示,以便它可以用于聚类。见图 6-3 。
图 6-3
Vanilla 自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编自编
这个想法还是一样的,但是这次不仅仅是优化重建损失,还将使用低维密集向量来预测输出。见图 6-4 。
图 6-4
半监督学习的自编码器
现在,你们中的许多人可能会想,这些重建损失有时并不那么低,因此在瓶颈层中可能会得到次优的表示。嗯,不完全是这样。
您不需要捕获输入的所有语义来预测标签。您可以使用捕捉部分含义的制图表达,以便将损失 1 降至最低。
尽管如此,当一般表示(损失 2)也有助于预测类别标签时,实现了最佳结果。
人们已经先行一步,尝试了不同的方法来最小化损失 1 和损失 2。你可以阅读的一些文件是
-
Valpola 等人的“利用梯形网络的半监督学习”
-
“探索用于生物医学关系抽取的半监督变分自编码器”
迁移学习
你在第四章中详细探讨了迁移学习及其在自然语言任务中的工作原理。迁移学习的工作原理是使用相似领域的大量标记数据来训练神经网络,这样它就可以很好地学习较低级别的特征,然后您可以使用该架构,使用您拥有的少量标记数据来微调手头的任务。
这是一种非常强大的技术,但是它有一些限制:
-
您的任务的输入数据可能与这种预训练网络的训练集有很大不同。
-
预训练的任务和新的任务有很大的不同,例如分类和跨度提取。
-
过拟合和不必要的使用大型模型:有时你的任务不需要使用复杂的数百万个参数,所以在这些情况下迁移学习可能是多余的。
迁移学习也可以在完全无监督的环境中使用,在这种环境中不需要大量的训练标签。这也叫自我监督。例如,当您训练一个好的语言模型时,您尝试执行以下操作:
-
掩蔽语言建模
-
下一句预测
这两种技术都不需要带标签的数据集,但却给出了一个能够完成各种任务的真正好的网络。
弱监督学习
弱监督学习是处理有限数据的另一种方式。这里的想法是利用当前数据中的模式,使用嘈杂的、启发式的和有限的来源来标记数据。
与上面讨论的技术一样,它有效地缓解了需要大量训练数据才能完成 ML 任务的问题。阅读斯坦福人工智能实验室团队的论文“浮潜:弱监督下的快速训练数据创建”,该论文探索了弱监督的工作原理。见图 6-5 。
图 6-5
知识来源多样,监管不力。资料来源:ai.googleblog.com
在这一章中,你将会用到弱学习的概念。你还将探索由斯坦福人工智能实验室开发的潜水图书馆。
探索浮潜
sculpt 是一个编程库,便于创建、建模和管理训练数据集,而无需手动标记。其工作流程是围绕数据编程设计的,由三个阶段组成:
-
写标签功能/弱监管
这包括使用手工设计的功能、利用外部数据库的远程监督功能等。这些标记函数没有很好的回忆,但相当精确。如果选择次精度函数,其召回率通常会更高。因此,您的标注函数集应该是两种类型的混合。
通气管中的标签功能是用
@labeling_function
装饰器创建的。装饰器可以应用于任何返回单个数据点标签的 Python 函数。每个 LF 函数输出三个值(二进制类):
图 6-6
使用浮潜概率的模型训练。来源:ai . Stanford . edu/blog/weak-supervision/
-
合并低频输出
基于标注函数的质量以及它们的一致性和不一致性,snuck 的生成模型组合标注函数以输出标注。例如,如果两个标注函数在其输出中具有高度相关性,则生成模型会尝试避免此类函数的重复计算。这也说明了为什么生成模型比最大计数要好。通气管还提供了大量的分析参数,可以显示 LF 的性能。你将在本章中探索它们。
-
模特培训
浮潜的输出使用概率标签,然后可以用来训练任何判别模型。这个判别模型填补了低召回率的空白。见图 6-6 。
在本章中,您将探索标记功能以及如何深入应用它们,但还有一些其他方式可以让通气管提高整个标记过程的性能(图 6-7 )。浮潜团队引入了另外两个概念:
图 6-7
不同的编程接口。资料来源:Snorkel.org
-
转换函数
-
切片功能(SFs)
就像您倾向于数据扩充来增加您的数据集一样,类似地,在通气管中,您可以使用策略将 TF 写入每个训练数据点(确定如何将变换应用于每个点或一些点,等等)。)来生成扩充的训练集。一些常见的方法可以是用同义词替换单词,或者用其他实体替换命名的实体。与标记函数类似,您使用一个transformation_function
装饰器,它包装一个函数,该函数接受一个数据点并返回该数据点的转换版本。
通常,在您的训练数据中,您会发现某些子部分或切片比其他子部分或切片更重要,例如接受重症监护的患者被用于药物性能测试,因此,不仅全局性能,而且此类局部切片的失败次数也更少。
通气管提供了一种在这种切片上测量性能的方法。SFs 输出二进制掩码,指示数据点是否在切片中。切片中的那些被监控。任何模型都可以利用 SFs 来学习切片专家表示,这些表示与注意机制相结合,以做出切片感知预测。
数据探索
介绍
您将使用疾病和治疗实体之间的数据捕获关系。它最初是由 Barbara Rosario 和 Marti A. Hearst 在计算语言学协会第 42 届年会(ACL 2004)上发表的题为“生物科学文本中语义关系的分类”的论文中分享的研究提供的,巴塞罗那,2004 年 7 月( https://biotext.berkeley.edu/dis_treat_data.html
)
)。
该文本随机取自 Medline 2001,这是一个书目数据库,包含超过 2600 万篇生命科学期刊文章的参考文献,集中于生物医学。
关于数据的一些要点:
-
数据集涵盖了治疗和疾病之间的多种关系,例如
-
治愈:治疗治愈疾病,无论其是否被临床证实。
-
只有疾病:句子中没有提到治疗。
-
仅治疗:句子中未提及疾病。
-
预防:治疗预防或抑制疾病的发生。
-
副作用:疾病是治疗的结果。
-
含糊:关系语义不清。
-
不治愈:治疗无效。
-
复杂:同一实体参与多个相互关联的关系,或者可能存在多对多的关系。
-
-
<label>
表示它后面的单词是实体的第一个,</label>
表示它前面的单词是实体的最后一个。 -
有未标记的数据共享用于测试。
您将从上述链接下载带有角色和关系文件的句子,并按如下所示放置文件:
Data
├── sentences_with_roles_and_relations.txt
├── labeled_titles.txt
├── labeled_abstracts.txt
从文本文件中加载数据。你不会和所有的关系一起工作;你只需要关注治疗、预防和副作用之间的关系。其余的被丢弃。
import re
import pandas as pd
import numpy as np
import os
f = open('./Data/sentences_with_roles_and_relations.txt', encoding = "ISO-8859-1")
f_data = []
for line in f.readlines():
line = line[:-1] # Remove linebreak
f_data.append(line.split('||'))
f.close()
rows = []
for l in f_data:
if l[1] not in ['NONE', 'TREATONLY', 'DISONLY', 'TO_SEE', 'VAGUE', 'TREAT_NO_FOR_DIS']:
sent = ' '.join(l[0].split())
dis_re = re.compile('<DIS.*>(.*)</DIS.*>')
disease = dis_re.search(sent).group(1)
treat_re = re.compile('<TREAT.*>(.*)</TREAT.*>')
treat = treat_re.search(sent).group(1)
sent = re.sub(r'<.*?> ', '', sent).strip()
# Handles sentences ending with <*> structure
sent = re.sub(r'<.*?>', '', sent)
rows.append([sent, l[1], treat.strip(), disease.strip()])
biotext_df = pd.DataFrame(data=rows, columns=['sentence', 'relation', 'term1', 'term2'])
上面的代码利用了已经包含关系标签的文件,但是您也可以使用文件夹中存在的其他文件,但是需要做一些预处理,以便根据您的目的利用它。
biotext_df.relation.value_counts()
输出
TREAT_FOR_DIS 830
PREVENT 63
SIDE_EFF 30
你可以看到在关系中有许多不平衡,其中大部分被“为治疗而治疗”或“治愈”关系占据。这可以在标签建模期间通过传递包含每个类的比例的类不平衡数组来处理。
标签功能
你所拥有的是来自生物医学期刊的关于治疗、疾病及其关系的标记数据。实际上,您可以为类似的信息提取任务创建三种主要类型的标注函数。
-
句法信息:句法信息帮助您捕捉单词之间的语法依赖,并帮助您发现关系类的常见模式。
-
n:使用外部本体,如 UML,来捕获除治疗和疾病之外的生物医学实体。
-
Regex :有一些特定的模式可以精确地指示关系类型。例如,像防止、防止、减少或减少这样的词可以很容易地指示防止关系类。
正则表达式
开始创建标签函数的一个快速方法是扫描属于您想要预测的类别的一串文本。
您可以从查看文本的不同 n 元语法的计数图开始。为此,您将使用 sklearn 模块,具体来说就是sklearn.feature_extraction.text.CountVectorizer
。
sklearn.feature_extraction.text.CountVectorizer: "Convert a collection of text documents to a matrix of token counts"
但是在直接运行Countvectorizer
之前,为了使练习更加有效,您应该执行一些预处理步骤:
-
将单词规范化到它们的词条,这样语义相同的单词不会被不同地计数,例如“provide”和“provide”
-
删除所有数字提及。
-
删除常见的英语停用词。
-
降低文本。
您将使用 nltk 包中的 WordNet Lemmatizer 对单个事物进行 lemmatize。使用 WordNet Lemmatizer 的一个重要方面是,您需要为单词提供一个合适的 pos 标签。如果没有做到这一点,可能会导致突然的或没有引理化。
我们用一个例子来理解这个。
首先导入相关的包和类。
from nltk import pos_tag
from nltk.stem import WordNetLemmatizer
lemmatizer = WordNetLemmatizer()
如果提供一个没有任何 pos 标签上下文的单词,这个单词不会被词条化。
lemmatizer.lemmatize("sitting")
输出
坐着的
如果您提供了 pos 标签的上下文,那么就可以进行词汇化。
lemmatizer.lemmatize("sitting", pos = "v")
输出
使就座
WordNet 有五种类型的 pos 标签:
-
形容词
-
形容词卫星
-
副词
-
名词
-
动词
你们大多数人都听说过形容词、副词、名词和动词,但形容词卫星可能是一个新名词。形容词附属体是一类专门用于特定语境的形容词。例如,只能有“干旱气候;不能有“皮肤干燥”。然而,用于创建 pos 标签的 PennTreeBank 不区分卫星形容词和普通形容词,因此您会将它们都视为形容词。
有了上面的信息,让我们来设计你的预处理函数。对于词汇化,您将维护一个 pos 标签到其用于 WordNet 词汇化器的标签的标签映射。
mapping_pos_label = {"JJ":'a',
"RB":'r',
"NN": 'n',
"VB":'v'}
接下来,定义一个函数,如果单词的 pos 标签是形容词(JJ)、副词(RB)、名词(NN)或动词(VB),则返回 WordNet pos label。
def get_pos_label(w, postag, mapping_pos_label):
for k, v in mapping_pos_label.items():
if postag.startswith(k):
return v
return "n"
注意,在上面的函数中,如果正则表达式没有找到匹配项,则返回一个名词标记,因为默认情况下,WordNet Lemmatizer 使用名词作为 pos 标记。
您已经拥有了创建预处理函数所需的一切。
import re
def preprocess_text(text):
text = text.lower()
text = " ".join([lemmatizer.lemmatize(w,
pos= get_pos_label(w,
pos_w,
mapping_pos_label))\
for w, pos_w in pos_tag(text.split()) \
if w not in list(set(stopwords.words('english')))])
text = re.sub(r'\d+', '', text)
return text
您可以在使用CountVectorizer
之前使用这个预处理函数,或者在CountVectorizer
函数中传递它。既然后者看起来更整洁,那就用它吧。
cv = CountVectorizer(preprocessor = preprocess_text,
ngram_range = (1,3),
min_df = 0.01)
除了preprocessor
,你还看到另外两个参数。它们的值是根据经验选择的。请随意试验。结果如图 6-8 所示。
-
告诉你应该考虑计算的短语长度。
-
如果是浮动的,你可以假设至少一定比例的样本应该提到词汇。如果是整数,假设至少有那么多行应该提到词汇。
count_mat = cv.fit_transform(biotext_df[biotext_df.relation.isin(["TREAT_FOR_DIS"])].sentence)
count_df = pd.DataFrame(count_mat.todense(), columns=cv.get_feature_names())
count_df = count_df.sum().reset_index()
count_df.columns = ["word","val"]
count_df = count_df.sort_values('val', ascending = False)
import plotly.express as px
fig = px.pie(count_df.head(20), values='val', names='word', title="Top Words for 'TREAT_FOR_DIS' Text")
fig.show()
图 6-8
TREAT_FOR_DIS 类别中最常用的单词/短语
类似地,对SIDE_EFF
和PREVENT
类重复该过程。见图 6-9 和 6-10 。
图 6-10
SIDE_EFF 类别中最常用的单词/短语
图 6-9
预防类别中最常用的单词/短语
上面的三张图对语料库中的关键词提供了一些非常有用的见解,可以帮助您形成一些基于正则表达式的 LFs。
treatment_keywords = ['treatment', 'therapy','effective','treat', "reduce"]
请注意,对于治疗关键词,您会看到许多肿瘤相关的术语,如肺癌、乳腺癌等。但我会避免把它们当作标签,因为它们可能只是因为你的语料库有限。您应该尝试创建更健壮的函数来控制精度。
同样的,
prevent_keywords = ["protect", "prevent", "inhibit", "block", "control", 'effect']
side_effect_keywords = ["risk","follow", "associate", "toxic"]
句法的
有些词不会很频繁出现,但仍然有助于将疾病与治疗方法及其各种关系联系起来。
为了找到这样的单词,你将利用文本的句法结构。具体来说,您将处理句子的依存解析树。这是一种计算语言学家技术,用于分析句子的语法结构,建立“中心”词并建立这些词之间的关系。更多信息,请参考 https://nlp.stanford.edu/software/nndep.html
。
您将使用 networkx 库将依赖关系树解析成一个图,并寻找疾病和治疗路径之间出现的单词模式。
通常可以有多条路径连接两条路径,但是您最感兴趣的是最短的依赖路径。这是优选的,因为它仅包含在任何两个实体之间建立关系的必要信息。
例如,考虑来自 PREVENT 类的以下语句:
改良乳罩在预防哺乳期妇女乳腺炎中的应用。
这里
修饰|胸罩是治疗和乳腺炎是疾病。
依赖图看起来有点像图 6-11 。
图 6-11
依赖图
Note
引入“|”而不是“,”是有原因的。稍后将详细介绍。
现在,如果您想要遍历从修改的 bra 到乳腺炎的依赖关系,那么在这两个实体之间有多个单词和依赖关系跳转。然而,SDP 相当简单。
SDP 是
改良|胸罩-预防-乳腺炎
其他一些例子有
-
结论:这些数据首次证明了慢性压力可以抑制针对肺炎的细菌|疫苗的 IgG 抗体反应的稳定性,为痴呆症护理相关的健康风险提供了额外的证据。
细菌性|疫苗—稳定性—肺炎
-
解磷定对有机磷化合物诱导的肌肉|纤维|坏死的保护作用。
解磷定—作用—肌肉|纤维|坏死
您可以看到 SDP 是如何完美地捕捉相关信息,仅基于句子结构将两个实体联系起来,因此您将使用它来识别一些新的关系或单词。
为此,您将使用之前在第四章中使用的 scispacy 包来分析 BERT 模型的词汇。您还将加载 networkx 库来查找最短的依赖路径。
import spacy
import scispacy
import networkx as nx
from scispacy.linking import EntityLinker
nlp = spacy.load('en_core_sci_lg')
在深入研究主要代码之前,您应该了解一些事情。
-
您使用 scispacy 进行依存解析,而不是 spacy 通用文本解析器,原因很简单,scispacy 的依存解析是在 GENIA 1.0 corpus 和 OntoNotes 5.0 上训练的,这提高了生物医学文本解析器的准确性和健壮性。
-
Spacy 不在空白上标记,而您的大多数标记(由人类注释者或其他人)都是基于空白的。这可能会导致目标令牌的 pos 标签出现一些错位,因为它可能会根据空间逻辑被令牌化为更小的成分。为了解决这个问题,你将
- 编写一个重新合并逻辑来合并被拆分的实体(疾病或治疗)。一般来说,带括号的单词表现出不规则的行为。
-
你会注意到在上面的例子中,在疾病和治疗阶段使用了“|”字符来代替空格。这是因为您希望在 SDP 计算的依赖关系树中将这些短语用作单个实体,而不是单独的实体。
有关 scispacy 的更多信息,请参考 Neuman 等人的“ScispaCy:生物医学自然语言处理的快速和健壮模型”。
您从编写重组逻辑开始。为此,您可以使用 spacy 的Doc
类的合并功能。它就地合并不是空格的标记。实际上,Doc
对象中可用的标记变成了空格分隔。
def remerge_sent(sent):
i = 0
while i < len(sent)-1:
tok = sent[i]
if not tok.whitespace_:
ntok = sent[i+1]
# in-place operation.
sent.merge(tok.idx, ntok.idx+len(ntok))
i += 1
return sent
接下来初始化一个空列表。
sdp_list = {'PREVENT': [],
'SIDE_EFF': [],
'TREAT_FOR_DIS': []}
在主代码中,您采取以下主要步骤:
-
首先运行两个
for
循环,一个用于疾病和治疗的不同关系类型,另一个用于课堂上的不同句子。 -
使用 networkx 库初始化一个空图。
-
对于每个令牌,通过维护一个单独的边列表,并使用
add_edges_from
函数将它们添加到 Networkx Graph 对象中,来添加与其所有子令牌的关系。 -
您还可以使用
add_nodes_from
函数添加一个节点及其属性。
-
-
您还维护了一个包含不同信息的 Python 字典(meta_info ),您可以利用它进行分析。
for KEY in sdp_list.keys():
for i,row in biotext_df[biotext_df.relation.isin([KEY])].iterrows():
# Entities to find SDP between
entity1 = row["term1"].replace(" ","|").replace("`","")
entity2 = row["term2"].replace(" ","|").replace("`","")
# Adjusting for Space
new_sentence = row["sentence"].replace(row["term1"], entity1)
new_sentence = new_sentence.replace(row["term2"], entity2)
# Spacy Pipeline
doc = nlp(new_sentence)
doc = remerge_sent(doc)
entity1_idx = [token.i for token in doc if token.text in [entity1]][0]
entity2_idx = [token.i for token in doc if token.text in [entity2]][0]
# Load Networkx Graph
G = nx.Graph()
# Load spacy's dependency tree into a networkx graph
edges = []
for token in doc:
for child in token.children:
G.add_nodes_from([(token.i, {"pos": token.pos_,
"text": token.text}),
(child.i, {"pos": child.pos_,
"text": child.text})])
edges.append((token.i,
child.i))
# Addding Edges
G.add_edges_from(edges)
meta_info = {}
meta_info["entity1"] = entity1
meta_info["entity2"] = entity2
meta_info["entity1_idx"] = entity1_idx
meta_info["entity2_idx"] = entity2_idx
meta_info["graph_object"] = G
shortest_path_list = nx.all_shortest_paths(G, source = entity1_idx, target = entity2_idx)
meta_info["word_list"] = [(G.node[n]['text'], G.node[n]['pos']) \
for shortest_path in shortest_path_list \
for i,n in enumerate(shortest_path) \
if i>0 and i<len(shortest_path)-1]
sdp_list[KEY].append(meta_info)
既然你有了树关系的 SDP 列表,让我们分析一下在句子的依存路径中你得到了哪些单词/短语。
与前面采用的策略类似,您将使用 WordNet 词汇排序器对您的单词进行词汇排序。
mapping_pos_label_spacy = {"ADJ":'a',
"ADV":'r',
"NOUN": 'n',
"VERB":'v'}
lemmatized_list = [[lemmatizer.lemmatize(word[0].lower(),
get_pos_label(word[0],
word[1],
mapping_pos_label_spacy)) \
for word in val['word_list']] \
for val in sdp_list["TREAT_FOR_DIS"] \
if len(val['word_list']) > 0]
接下来,创建一个名为get_top_words,
的函数,其中
-
从词汇化的单词中提取单个单词列表。
-
创造 1-3 克代币。
-
找到频率并排序。
def get_top_words(lemmatized_list, n):
"""
Show Top 'n' words
"""
count_df = pd.Series([" ".join(word_phrase) \
for word_list in lemmatized_list \
for i in range(1,4) \
for word_phrase in nltk.ngrams(word_list, i)]).value_counts().reset_index()
count_df.columns = ["word","counts"]
count_df = count_df[count_df.counts > 1]
for i,row in count_df.head(n).iterrows():
print(row["word"] ,"---->", row["counts"])
这样,您可以获得这三个类的以下值。
-
为疾病治疗
-
预防
patient ----> 189
treatment ----> 134
treat ----> 59
use ----> 43
effective ----> 36
effect ----> 31
therapy ----> 23
treat patient ----> 20
trial ----> 19
management ----> 16
undergo ----> 16
study ----> 15
perform ----> 13
show ----> 13
rate ----> 13
effectiveness ----> 13
improve ----> 11
efficacy ----> 11
result ----> 11
receive ----> 11
- 侧面 _ 效果
prevent ----> 9
prevention ----> 6
effective ----> 4
use ----> 4
reduce ----> 4
vaccine ----> 3
patient ----> 3
effect ----> 3
study ----> 2
incidence ----> 2
effective prevent ----> 2
risk ----> 2
stability ----> 2
trial ----> 2
safe ----> 2
associate ----> 5
rate ----> 4
risk ----> 4
case ----> 3
eye ----> 3
administration ----> 2
complication ----> 2
neurotoxicity ----> 2
patient ----> 2
associate risk ----> 2
develop ----> 2
had eye ----> 2
had ----> 2
正如您所观察到的,上面突出显示的单词现在已经“微弱地”添加了新的信息来帮助对关系进行分类。此外,它们中的一些在野生搜索中没有意义,但是在 SDP 上下文中,出现假阳性的机会减少了,例如 TREAT_FOR_DIS 句子中的“patient”。
远程监督
有许多单词或短语带有语义,因此它们可以用基于统计频率的分析来代替。为了识别这样的短语,您将利用 UMLs 本体,它捕获超过 110 个医学概念,如治疗或预防程序、药理物质、保健活动、病理功能等。
您在第四章中学习了 UML,所以这里您将查看代码并分析输出。
首先,确保将 UML 流水线添加到空间中。为此,您只需调用EntityLinker
类来添加umls
数据库。
linker = EntityLinker(resolve_abbreviations=False, name="umls")
# keeping default thresholds for match percentage.
nlp.add_pipe(linker)
# UMLs provides a class name to each of its TXXX identifier, TXXX is code for parents for each of the CUI numbers a unique concept
# identifier used by UMLs Kb
# To obtain this file please login to https://www.nlm.nih.gov/research/umls/index.html
# Shared in Github Repo of the book :)
type2namemap = pd.read_csv("SRDEF", sep ="|", header = None)
type2namemap = type2namemap.iloc[:,:3]
type2namemap.columns = ["ClassType","TypeID","TypeName"]
typenamemap = {row["TypeID"]:row["TypeName"] for i,row in type2namemap.iterrows()}
然后,为每个关系类创建一个概念数据帧,其中包含特定概念出现的频率。与之前只关注频率的设置不同,这里你还将寻找独特性。
KEY = "TREAT_FOR_DIS"
umls_concept_extracted = [[umls_ent for entity in doc.ents for umls_ent in entity._.umls_ents] for doc in nlp.pipe(biotext_df[biotext_df.relation.isin([KEY])].sentence.tolist())]
umls_concept_cui = [linker.kb.cui_to_entity[concept[0]] for concepts in umls_concept_extracted for concept in concepts]
# Capturing all the information shared from the UMLS DB in a dataframe
umls_concept_df = pd.DataFrame(umls_concept_cui)
concept_df = pd.Series([typenamemap[typeid] for types in umls_concept_df.types for typeid in types]).value_counts().reset_index()
concept_df.columns = ["concept","count"]
umls_concept_df["Name"] = pd.Series([[typenamemap[typeid] for typeid in types] for types in umls_concept_df.types])
基于每个键的concept_df
数据框架,表 6-1 显示了可以用来区分关系类型的主要 UML 类型。
表 6-1
每个关系的 UML 类型
|关系
|
UML 类型
|
理由
|
概念示例
|
| --- | --- | --- | --- |
| 为疾病治疗 | 治疗或预防程序 | 疗法和治疗 | 外科手术、化疗/放疗/阿司匹林疗法、治疗方案等。 |
| 为疾病治疗 | 精神产品 | 方法、目标和过程 | 方法、目标和过程 |
| 为疾病治疗 | 定性概念 | 评估质量 | 有效性,典型,简单,完整 |
| 为疾病治疗 | 患者或残疾人群体 | 捕获单词患者及其别名 | 病人,病人,等等。 |
| 为疾病治疗 | 时间概念 | 与提及的时间和持续时间相关 | 年份、术后时期、每周、暂时等。 |
| 为疾病治疗 | 保健活动 | 评估和报告 | 评估和报告 |
| 预防 | 免疫因素 | 识别其活动影响免疫系统功能或在其中发挥作用的活性物质 | 疫苗和联合疗法 |
| 预防 | 想法或概念 | 结论或结果 | 结论 |
| 预防 | 职业活动 | 职业分析和活动 | 经济分析 |
| 侧面 _ 效果 | 迹象或症状 | 显示药物的效果 | 发育期痛 |
| 侧面 _ 效果 | 受伤或中毒 | 显示药物的效果 | 伤口/损伤 |
| 侧面 _ 效果 | 身体部分、器官或器官组成部分 | 显示药物的效果 | 任何身体部位 |
| 侧面 _ 效果 | 病理功能 | 不良反应和影响 | 脑出血,药物不良反应,自然流产 |
流水线
为了展示 scub 的功能,您需要创建一个实验,将您的数据分成两个数据集:
-
一个名为
train_df
的无标签训练数据集,潜航器的LabelModel
将使用它来学习标签 -
一个名为
val_df
的手工标注的开发数据集,您将使用它来确定您的 LFs 是否工作
您将通过分层方式进行采样来维护目标类的分布。
from sklearn.model_selection import train_test_split
train_df, val_df, train_labels, val_labels = train_test_split(
biotext_df,
biotext_df['relation'],
test_size=0.4,
stratify = biotext_df['relation'],
random_state = 42
)
如前所述,通气管有三个主要接口
-
标签功能
-
转换函数
-
切片功能
我将在本章中深入讨论标签功能。标记函数确定性地确定数据的类别。这些功能可以在任何级别(文本/段落/元数据)工作,并且可以利用多种信息源(模型/外部数据库/本体)
为了编写标签函数,您需要为您的问题定义标签模式。
除了数据中出现的类之外,还必须定义一个 present 标签,因为只有在有足够证据的情况下,这才允许 scub 为某个类投票。如果你从通气管得到了很多的弃权值,那么你将不得不增加 LFs 的覆盖率。
# Define our numeric labels as integers
ABSTAIN = -1
TREAT_FOR_DIS = 0
PREVENT = 1
SIDE_EFF = 2
def map_labels(x):
"""Map string labels to integers"""
if x == 'TREAT_FOR_DIS':
return TREAT_FOR_DIS
elif x == 'PREVENT':
return PREVENT
elif x == 'SIDE_EFF':
return SIDE_EFF
val_labels = val_labels.apply(map_labels, convert_dtype=True)
写你的 LFs
标注功能的程序界面为snorkel.labeling.LabelingFunction
。它们用名称、函数引用、函数需要的任何资源以及在标记函数运行之前要在数据记录上运行的任何预处理程序的列表来实例化。
定义 LF 函数有两种方法:
-
使用基类
LabelingFunction
。 -
使用装饰器
labeling_function
。
snorkel.labeling.LabelingFunction(name, f, resources=None, pre=None)
- "name" = Name of the LF.
- "f" = Function that implements the LF logic.
- "resources" = Labeling resources passed into f
- "pre" = Preprocessors to run on the data
snorkel.labeling.labeling_function(name=None, resources=None, pre=None)
- "name" = Name of the LF.
- "resources" = Labeling resources passed into f
- "pre" = Preprocessors to run on the data
您将使用 decorator 方法,因为它简单得多。
对于不了解 decorator 的人来说,decorator 基本上是拿一个函数,添加一些功能(也就是装饰它),通过调用它来返回它。
与装饰者一起工作
根据你的分析,你已经为每个关系类列出了下列单词。因此,您只需编写一个标注函数,如果找到了它们各自的单词,该函数将返回关系类,否则将放弃标注。
treatment_keywords = ['treatment', 'therapy','effective','treat', "reduce"]
prevent_keywords = ["protect", "prevent", "inhibit", "block", "control", 'effect']
side_effect_keywords = ["risk","follow", "associate", "toxic"]
@labeling_function()
def sent_contains_TREAT_FOR_DIS(x):
text = x.sentence.lower()
lemmatized_word = [lemmatizer.lemmatize(w,
pos= get_pos_label(w,
pos_w,
mapping_pos_label))\
for w, pos_w in pos_tag(text.split()) \
if w not in list(set(stopwords.words('english')))]
return TREAT_FOR_DIS if any([ True if key in lemmatized_word else False for key in treatment_keywords]) else ABSTAIN
@labeling_function()
def sent_contains_SIDE_EFF(x):
text = x.sentence.lower()
lemmatized_word = [lemmatizer.lemmatize(w,
pos= get_pos_label(w,
pos_w,
mapping_pos_label))\
for w, pos_w in pos_tag(text.split()) \
if w not in list(set(stopwords.words('english')))]
return SIDE_EFF if any([ True if key in lemmatized_word else False for key in side_effect_keywords]) else ABSTAIN
@labeling_function()
def sent_contains_PREVENT(x):
text = x.sentence.lower()
lemmatized_word = [lemmatizer.lemmatize(w,
pos= get_pos_label(w,
pos_w,
mapping_pos_label))\
for w, pos_w in pos_tag(text.split()) \
if w not in list(set(stopwords.words('english')))]
return PREVENT if any([ True if key in lemmatized_word else False for key in prevent_keywords]) else ABSTAIN
是的,就是这么简单。
通气管中的预处理器
但是上面的代码有一个问题。对于每个函数,每次都必须重复词条化和文本 lower 逻辑。你不能预先对你的数据进行预处理,然后在每个函数中不重复逻辑地使用它吗?
好吧,通气管有一个预处理器,映射一个数据点到一个新的数据点。
可以使用预处理器,让你在转换或增强的数据点上写 LFs。
您将@preprocessor(...)
装饰器添加到预处理函数中来创建预处理器。预处理器也有额外的功能,比如内存化(即输入/输出缓存,所以它不会为每个使用它的 LF 重新执行)。
from snorkel.preprocess import preprocessor
@preprocessor(memoize = True)
def get_syntactic_info(x):
# Entities to find SDP between
entity1 = x.term1.replace(" ","|").replace("`","")
entity2 = x.term2.replace(" ","|").replace("`","")
# Adjusting for Space
new_sentence = x.sentence.replace(x.term1, entity1)
new_sentence = new_sentence.replace(x.term2, entity2)
# Spacy Pipeline
doc = nlp(new_sentence)
doc = remerge_sent(doc)
entity1_idx = [token.i for token in doc if token.text in [entity1]][0]
entity2_idx = [token.i for token in doc if token.text in [entity2]][0]
# Load Networkx Graph
G = nx.Graph()
# Load spacy's dependency tree into a networkx graph
edges = []
for token in doc:
for child in token.children:
G.add_nodes_from([(token.i, {"pos": token.pos_,
"text": token.text}),
(child.i, {"pos": child.pos_,
"text": child.text})])
edges.append((token.i,
child.i))
# Addding Edges
G.add_edges_from(edges)
shortest_path_list = nx.all_shortest_paths(G, source = entity1_idx, target = entity2_idx)
word_list = [(G.node[n]['text'], G.node[n]['pos']) \
for shortest_path in shortest_path_list \
for i,n in enumerate(shortest_path) \
if i>0 and i<len(shortest_path)-1]
lemmatized_list = [lemmatizer.lemmatize(word[0].lower(),
get_pos_label(word[0],
word[1],
mapping_pos_label_spacy)) \
for word in word_list]
x.sdp_word = lemmatized_list
return x
类似地,您可以从每个关系类的 SDP 路径中了解重要的单词。因此,您从初始化它们开始。
treatment_sdp_keywords = ['patient', 'use','trial','management', "study", "show", "improve"]
prevent_sdp_keywords = ["reduce", "vaccine", "incidence", "stability"]
side_effect_sdp_keywords = ["rate","case", "administration", "complication", "develop"]
@labeling_function(pre=[get_syntactic_info])
def sent_sdp_TREAT_FOR_DIS(x):
return TREAT_FOR_DIS if any([True if key in x.sdp_word else False for key in treatment_sdp_keywords]) else ABSTAIN
@labeling_function(pre=[get_syntactic_info])
def sent_sdp_SIDE_EFF(x):
return SIDE_EFF if any([True if key in x.sdp_word else False for key in side_effect_sdp_keywords]) else ABSTAIN
@labeling_function(pre=[get_syntactic_info])
def sent_sdp_PREVENT(x):
return PREVENT if any([True if key in x.sdp_word else False for key in prevent_sdp_keywords]) else ABSTAIN
看看现在代码变得多么简单和干净。
最后,你也有基于距离的弱学习者。类似于上面完成的预处理,您使用预处理装饰器来做另一个预处理。
@preprocessor(memoize = True)
def get_umls_concepts(x):
umls_concept_extracted = [[umls_ent for entity in doc.ents for umls_ent in entity._.umls_ents] for doc in nlp.pipe([x.sentence])]
try:
umls_concept_cui = [linker.kb.cui_to_entity[concept[0]] for concepts in umls_concept_extracted for concept in concepts]
# Capturing all the information shared from the UMLS DB in a dataframe
umls_concept_df = pd.DataFrame(umls_concept_cui)
concept_df = pd.Series([typenamemap[typeid] for types in umls_concept_df.types for typeid in types]).value_counts().reset_index()
concept_df.columns = ["concept","count"]
x["umls_concepts"] = concept_df.concept.tolist()
except Exception as e:
x["umls_concepts"] = []
return x
基于表 6-1 ,你也从句子中知道了主导的和重要的 UML 概念。
treatment_umls_concepts = ['Therapeutic or Preventive Procedure',
'Intellectual Product',
'Qualitative Concept',
'Patient or Disabled Group',
"Temporal Concept",
"Health Care Activity"]
prevent_umls_concepts = ["Immunologic Factor",
"Idea or Concept",
"Finding",
"Occupational Activity"]
side_effect_umls_concepts = ["Sign or Symptom",
"Injury or Poisoning",
"Body Part, Organ, or Organ Component",
"Pathologic Function"]
最后,为这个远程监控设置编写标签函数。
@labeling_function(pre=[get_umls_concepts])
def sent_umls_TREAT_FOR_DIS(x):
return TREAT_FOR_DIS if any([True if key in x.umls_concepts else False for key in treatment_umls_concepts]) else ABSTAIN
@labeling_function(pre=[get_umls_concepts])
def sent_umls_SIDE_EFF(x):
return SIDE_EFF if any([True if key in x.umls_concepts else False for key in prevent_umls_concepts]) else ABSTAIN
@labeling_function(pre=[get_umls_concepts])
def sent_umls_PREVENT(x):
return PREVENT if any([True if key in x.umls_concepts else False for key in side_effect_umls_concepts]) else ABSTAIN
培养
对于训练,你必须将你的弱标签应用到每个句子中。由于您的数据存储在 pandas 数据帧中,您将利用一个名为PandasLFApplier
的内置函数。
是一个给出标签矩阵的LFApplier
类。这是一个 NumPy 数组 L,每个 LF 一列,每个数据点一行,其中 L[i,j]是第 j 个标注函数为第 I 个数据点输出的标注。您将为训练集创建一个标签矩阵。
lfs = [sent_contains_TREAT_FOR_DIS, sent_contains_SIDE_EFF, sent_contains_PREVENT,
sent_sdp_TREAT_FOR_DIS, sent_sdp_SIDE_EFF, sent_sdp_PREVENT,
sent_umls_TREAT_FOR_DIS, sent_umls_SIDE_EFF, sent_umls_PREVENT]
# Instantiate our LF applier with our list of LabelFunctions (just one for now)
applier = PandasLFApplier(lfs=lfs)
# Apply the LFs to the data to generate a list of labels
L_train = applier.apply(df=train_df)
L_dev = applier.apply(df=val_df)
估价
在一个简单的名为LFAnalysis
的函数中,通气管很好地为我们打包了大量的分析。报告了许多汇总统计数据(见图 6-12 ):
-
极性:该 LF 输出的唯一标签集合(不包括弃权)
-
覆盖率:LF 标记的数据集的分数
-
重叠:具有至少两个(非弃权)标签的数据点的分数。
-
冲突:该 LF 和至少一个其他 LF 标签不一致的数据集部分(非弃权标签)
-
正确:该 LF 正确标注的数据点数(如果有金色标注)
-
不正确:该 LF 标注错误的数据点数(如果有金色标注)
-
经验精度:该 LF 的经验精度(如果有金标)
# Run a label function analysis on the results, to describe their output against the labeled development data
LFAnalysis(L=L_dev, lfs=lfs).lf_summary(val_labels.values)
图 6-12
具有各种指标的 LFAnalysis 输出
一些观察结果:
-
您会看到 TREAT_FOR_DIS 在覆盖率和准确性指标上表现得非常好。
-
与其他标签函数相比,PREVENT 的 SDP 标签具有更好的经验准确性。
-
SIDE_EFF 在 UMLs LF 上的表现似乎不是那么好。您可以在整个句子中或者仅仅在 SDP 中检查 UMLs 标签的组合。你将不得不迭代地使这些 LFs 变得更好。
生成最终标签
到目前为止,你已经走过了很多地方。你有
-
加载并准备数据
-
将其分为训练集和测试集
-
浏览数据寻找灵感
-
创造了 LF
-
查看了预处理步骤以及如何记忆它们
-
根据验证数据评估这些 LFs 的性能
您终于可以生成标签了。通气管提供了两种生成最终标签的主要方法。一个是MajorityLabelVoter
,它基本上给样本分配了大多数 LFs 给出的标签。
这通常产生低于或在某些情况下等于潜航的噪声意识更强的生成模型的性能,因此作为一个基准。理解这种次性能的一种非常直观的方式是,在MajorityLabel
中,所有的 LFs 都被平等对待。然而,正如你看到的 SIDE_EFF,“regex”比基于“umls”的 LFs 更有意义。
from snorkel.labeling.model import MajorityLabelVoter
majority_model = MajorityLabelVoter(cardinality = 3)
preds_train = majority_model.predict(L=L_train)
正如您所看到的,您需要为MajorityLabelVoter
提供一个基数值,它基本上就是非 absent 类的数量。
这有助于建立基线。现在,您可以轻松地转而使用一种更具噪声意识和加权的投票策略。该策略的细节不在本章讨论范围之内,但是对于感兴趣的人,请阅读 Ratner 等人题为“数据编程:快速创建大型训练集”的论文。
from snorkel.labeling.model import LabelModel
label_model = LabelModel(cardinality=3, verbose=True)
在拟合模型之前,您应该了解可供您使用的不同选项。
LabelModel.fit()
允许您使用以下超参数:
-
n_epochs
:要训练的时期数(其中每个时期是单个优化步骤) -
lr
:基础学习率(也会受到lr_scheduler
选择和设置的影响) -
l2
:以 L2 为中心的正规化力量 -
optimizer
:使用哪个优化器["sgd "、" adam "、" adamax"]) -
optimizer_config
:优化器的设置 -
lr_scheduler
:使用哪个lr_scheduler
([“常数”、“线性”、“指数”、“步长”])中的一个 -
lr_scheduler_config
:设置LRScheduler
-
prec_init
: LF 精度初始化/先验 -
seed
:用于初始化随机数生成器的随机种子 -
log_freq
:每隔一定时间报告一次损失(步骤) -
mu_eps
:将学习到的条件概率限制为【mu_eps,1-mu_eps】
现在,您将使用默认值来训练模型,但是我强烈建议您进行实验,并了解更多关于这些超参数对优化的影响。
label_model.fit(L_train=L_train, n_epochs=100, seed=42)
让我们看看生成模型与多数投票基线相比如何。
majority_acc = majority_model.score(L=L_dev, Y=val_labels, tie_break_policy="random")[
"accuracy"
]
print(f"{'Majority Vote Accuracy:':<25} {majority_acc * 100:.1f}%")
label_model_acc = label_model.score(L=L_dev, Y=val_labels, tie_break_policy="random")["accuracy"
]
print(f"{'Label Model Accuracy:':<25} {label_model_acc * 100:.1f}%")
多数票准确率:80.8%
标签模型准确率:87.6%
如你所见,标签模型比多数投票高出 7.5%。这是一次重大的提升。虽然没有什么结论可以说,但您应该始终通过改变超参数来试验性能的敏感性。
在对验证集的性能进行评分时,您会注意到策略的使用,
断绝关系的政策包括
-
abstain
:返回弃权票(-1)。 -
true-random
:在并列选项中随机选择。 -
random
:使用确定性散列在并列选项中随机选择(不同运行中的值保持一致)。
结论
没有从数据中进行弱学习的完美方法。你只需要比随机更好。您的 LFs 可以不同地预测一个数据点的输出。你只需要通过分析数据,编写 LF,然后提炼和调试,不断产生想法。随着数据以更快的准确性和速度增长,组织必须采用这种创新方法来开始标记数据和训练强大的模型。我希望这一章能激发你的好奇心,让你了解更多关于这些方法的知识。如果是的话,那么这就是我们的胜利。
七、联邦学习和医疗保健
随着越来越多的计算机和硬件技术变得如此容易获得,从临床机构到保险公司,从患者到制药行业,不同的医疗保健利益相关方提供了大量的分析数据。这些海量数据是一座金矿,有助于发现有助于设计人工智能集成医疗系统的见解,旨在以合理的成本提供更好的结果和质量。
无论医疗保健数据产生的数量有多少,它们仍然是碎片化的,法律、伦理和隐私方面的考虑阻碍了用于稳健研究的大规模数据分析。例如,正如您在第 3 和 4 章中所看到的,贝斯伊斯雷尔女执事医疗中心收集的 EHR 数据虽然仍然是一个大型数据集,但缺少白人和非白人人口分布、年龄分布差异等方面的信息。而包含更多这种无代表群体的数据可能存在于其他地方。因此,需要一种更巧妙的思维方式。
联邦学习通过将模型引入数据,而不是相反,帮助我们解决隐私和法律限制等问题。在这一章中,你将深入研究联邦机器学习。什么是 TensorFlow Federated?有哪些不同的隐私机制?本章的目的不是向您介绍一个新颖的案例研究,而是更多地了解 TensorFlow 联合生态系统(联合、隐私和加密)及其功能。
介绍
联邦学习(FL)是一个分布式机器学习概念,它允许对分散的数据进行模型训练,同时解决每个利益相关者的数据传输、隐私和安全问题。一个外语系统有四个主要组成部分,它们同步工作以进行联邦学习:
-
中央服务器/节点:协调本地模型的培训和部署,并作为创建全球模型的平台。本地模型是在本地节点/边缘设备上训练的模型,而全局模型是使用来自本地节点的权重更新权重的模型。
-
本地服务器/设备/节点:这是真实世界数据所在的地方。它们通常是收集客户数据的已安装机器的边缘设备。
-
本地模型:这是任何类型的机器学习模型,它对本地服务器中存在的数据进行训练。这些模型学习特定于本地设备的数据。
-
全局模型:由不同局部模型的信息组合而成的最终模型。
联邦学习是如何工作的?
联邦学习培训有四个关键步骤。
步骤 1:从中心节点转移初始模型(见图 7-1
图 7-1
第一步
-
从中央服务器获得的初始模型根据模型所有者可用的数据进行训练(即,该模型根据中央服务器上可用的数据进行训练)。
-
这个全局模型然后通过网络被传送到所有的本地节点。
第二步:模特训练
-
任何类型的机器学习模型,从朴素贝叶斯和 SVM 这样的基本模型到 DeepNets,都可以被训练。
-
一小部分客户被选择用于本地模型训练,因为选择大量的客户在性能和成本上具有递减的回报。
-
本地节点的计算资源用于训练,这节省了中央服务器的计算时间和资源。
-
有时,本地节点上的数据不足,这可能使该节点对全局模型的贡献无效,因此需要安全聚合等技术,这种技术允许使用公钥-私钥在节点之间共享数据。此外,这种技术有助于防止个人数据泄露问题。
步骤 3:本地模型转移到中心节点(见图 7-2
图 7-2
第三步
-
训练之后,所有模型都可以传回中央服务器。对于边缘设备,这可能导致巨大的网络开销(跨设备培训),而在跨孤岛的联邦培训(作为本地节点的组/机构)中,这种影响不太明显。
-
有时,模型可能会受到敌对攻击,这些攻击有助于识别用于训练模型的用户敏感数据。因此,为了防止这种攻击,可以使用实现差分隐私或安全聚合等技术的隐私保护层。注意,差分隐私原则上也可以应用于本地,而不是全局。在后面的章节中会有更多的介绍。
步骤 4:中心节点聚集来自所有本地模型的结果。
-
联合平均不仅仅是输出概率或多数表决的简单平均。
-
需要学习的任何参数,例如深度学习模型对权重更新起作用。因此,全局权重向量是通过对损失度量进行加权并用观察到的样本数进行归一化来决定的。通过这种方式,您可以获得更多的权重表示,这在统计上(样本数)会带来更好的性能。
-
根据结果如何从本地节点传输,可以有许多其他平均技术。
联邦学习的类型
根据数据在 FL 训练过程中如何分布在多个本地节点上,您可以将 FL 分为三个主要类别。
水平联邦学习
在水平联邦学习中,不同本地节点的数据集具有相同的特征空间集,但是样本的重叠量是最小的。
这是跨设备设置的自然划分,不同的节点/用户试图改进一项共同的任务,比如在移动应用程序上使用 GBoard 打字时的键盘建议,或者使用可穿戴设备数据进行疾病的风险预测。见图 7-3 。
图 7-3
横向联邦学习。来源:Kurupathi 等人的“面向隐私保护人工智能的联邦学习调查”
垂直联邦学习
这里,不同本地节点的数据集具有相同的样本/人集合,但是特征空间的重叠量可以根据组织数据而不同。
当多个组织进行协调时,他们可以期待实施垂直 FL。使用特征对齐方法来对齐不同个体的特征,然后训练单个模型。对齐是隐私保护的,这意味着不容易识别受保护的信息。这可以通过加密来实现。您将在安全聚合讨论中了解更多相关信息。
一些例子是保险公司和银行公司在共享客户的公共数据上的联合协作。标签可以是默认利率或任何欺诈交易。在医疗保健领域,不同的医院可以共享他们擅长的不同检查的信息,以绘制出患者的综合病史。见图 7-4 。
图 7-4
垂直联邦学习。来源:Kurupathi 等人的“面向隐私保护人工智能的联邦学习调查”
联邦迁移学习
这是在特征空间和样本不同的情况下实现的。比如说有一群医院想做乳腺癌研究。每家医院都有一组不同的患者(样本),他们可能会捕获不同的指标(特征空间),在两个维度上都有一些最小的捕获。
通常,使用有限的公共样本集在两个特征空间之间学习公共表示,然后将其应用于获得仅单侧特征样本的预测。见图 7-5 。
图 7-5
联邦迁移学习。来源:Kurupathi 等人的“面向隐私保护人工智能的联邦学习调查”
隐私机制
只有当除了介绍中讨论的流程之外,还实现了多种隐私机制时,FL 才在现实世界中被广泛接受。虽然数据集驻留在本地节点上,但是可以对模型参数进行重新设计,以获得有关数据的信息。此外,可以一起应用多种隐私机制技术,以确保个人身份/数据的更强大的安全性。
如图 7-6 所示,恶意攻击者可以尝试将梯度与本地梯度更新进行匹配,并重建数据。
图 7-6
梯度匹配攻击
有许多隐私机制技术,但在这一章中,我们将讨论两种在当前的 FL 系统中最常用的技术。
要更深入地了解隐私机制和衡量其有效性的方法,请参考瓦格纳等人在 2018 年发布的论文“技术隐私指标:系统调查”。
安全聚合
安全聚合是一种保护隐私的机器学习技术,当以安全的方式从各个用户设备更新时,它依赖于多方计算来计算模型参数的和。
2017 年,谷歌最初在题为“隐私保护机器学习的实用安全聚合”的论文中提出了一种安全聚合技术。关于数学细节,你可以看看论文,但现在让我们直观地理解它。
-
公钥和私钥是使用模式生成的。
-
公共密钥与每个本地节点共享。
-
这些密钥用于加密模型参数的更改。
-
所有的本地节点使用像加法或乘法这样的数学运算来累积模型权重。
-
累积的更改被发送到中央服务器,中央服务器使用私钥解密数据。
在上述过程中需要注意两件事
-
我们可以对加密数据本身进行 ALU(算术和逻辑)运算,因为加密本质上是同态的。我们可以在不解密的情况下对数据进行 ALU 运算。
-
中央服务器看到累积的结果,这些结果可以使用私钥解密。
此外,某些用户可能会由于网络问题而突然退出。只有当总和来自至少 n 个本地节点时,中央服务器上发生的任何变化才会发生。
在内部,TFF (TensorFlow Federated)使用 TensorFlow Encrypted 来执行这个练习,但为了简单起见,让我们使用 pallier 包来看看这是如何工作的。
您将使用 python-pallier,它使用 Paillier 加密系统(一种同态加密方案,参见 https://blog.openmined.org/the-paillier-cryptosystem/
了解同态加密如何工作)。
import phe
import numpy as np
# Generate Public and Private Key
public_key, private_key = phe.generate_paillier_keypair(n_length=1024)
weight1 = np.random.rand(10)
weight2 = np.random.rand(10)
# Note : This is a simple addition but it can be more complex as well
sum_of_local_weights = np.add(weight1, weight2)
print("Addition Of w1 and w2: " + str(sum_of_local_weights))
encrypted_w1 = [public_key.encrypt(i) for i in weight1]
encrypted_w2 = [public_key.encrypt(j) for j in weight2]
encrypted_sum_of_w1_and_w2 = [i+j for i,j in zip(encrypted_w1, encrypted_w2)]
decryped_sum_of_w1_and_w2 = [private_key.decrypt(k) for k in encrypted_sum_of_w1_and_w2]
print("Addition Of Encrypted Number: " + str(decryped_sum_of_w1_and_w2))
输出
Addition Of w1 and w2: [0.01965569 1.38181926 0.95724207 1.40539024 0.56162914 1.26444545
0.84660776 0.55585975 1.60470971 0.74662359]
Addition Of Encrypted Number: [0.01965569240712506, 1.381819260975988, 0.957242068080129, 1.4053902417875905, 0.5616291366817605, 1.2644454455590868, 0.8466077626079891, 0.5558597475342251, 1.604709707486859, 0.7466235859816883]
您可以看到,通过共享聚合结果本身,同态加密和多方(以确保健壮性和更好的正常运行时间)可以多么容易地保护个人数据。
正如您现在可能已经想到的,这种技术在计算方面包含大量开销,这些开销可以随着本地节点的数量和参数向量的大小而扩展。
差异隐私
差分隐私是一种隐私机制,它试图量化由于在本地节点(本地 DP)或在聚集级别(全局 DP)向数据添加噪声而导致的隐私量,使得最终分析保持不变。我们通过一个例子来了解一下。
假设你在班上做了一项调查,看看有多少学生对绿色色盲。你计划在解释一些概念时包括大量可能使用绿色的视觉效果。
目的:大多数人对绿色不是色盲吗?
假设您管理调查,结果如图 7-7 所示。
Note
为简单起见,我们使用非常小的样本量。
图 7-7
差异隐私
让我们假设你的二次研究告诉你,来自某个种族的人倾向于对绿色表现出色盲。因此,如果给你提供数据 D,你可以以某种方式识别教室中的那些特定个人。但是,如果你添加一些噪音,使数字变得不直观,无法精确到教室的某个部分,那该怎么办呢?
*这正是差别隐私所保证的。它保护了参与分析的个人,但不影响结果,就像上面发现班上大多数人对绿色不敏感的情况一样。
差分隐私引入了一个称为ε的度量,它量化了数据分布的接近程度:
如果 ε = 0,那么你就有了一个精确的分布,你就达到了峰值隐私。f(D n )表示数据函数,f(D’n)表示加入噪声后的数据函数。
在实践中,拉普拉斯分布和正态分布被用于生成对查询的回答,因为这些函数更有可能预测更接近平均值的数字(< =1 与平均值的标准偏差;对于标准常态是 68%,而对于拉普拉斯是 74% (b=1),但没有给出正确的答案。这里的平均值就是你的真实值。
如你所知,如果从任何分布中抽取足够多的随机样本,你可以用中心极限定理来估计它的平均值。同样,如果向包含来自本地节点的数据的数据库发出多个查询,则可以形成平均值的估计。
举个例子,
-
调查中非色盲学生的数量是多少?
-
属于“这个”族裔的学生人数是多少?
-
总人数是多少?
因此,每次您抛出一个数字,您就给了对手一个更好的机会来猜测来自本地节点或其任何特征的正确数字/数据。
在实现差分隐私时,您必须确保两个概率分布尽可能接近。在小样本情况下,噪声可以完全改变数据;对于大量样本,噪声的影响有限(因为引入了更多的变化,所以单个噪声无法很好地掩盖所有样本)。因此,设计噪声函数有时是一项极其困难的任务。
由于噪声有时会淹没小样本,您可以引入另一个名为 δ 的参数,这是一个帮助您丢弃罕见类别的阈值。因此,独特的差分隐私机制实际上是两个因素的函数:
-
阈值( δ
-
噪音量( ε
TensorFlow Privacy 是 TensorFlow 生态系统中的一个库,用于训练具有训练数据隐私的机器学习模型。该库提供了三个不同的特性:
-
训练算法,特别是梯度下降
-
它通过剪切梯度来限制单个数据点在结果梯度计算中的影响。
-
它通过向剪切的梯度添加随机噪声,使得梯度值与训练批次中的任何特定点无关。
-
-
隐私机制的选择和配置(超参数调整)以应用于收集的每个集合(模型梯度、批量标准化权重更新、度量)
-
绩效指标
-
隐私预算
-
埃普西隆
-
Note
差分隐私是一种独立的隐私维护技术,可用于 FL 架构,在这种情况下,更新来自多方。
TensorFlow 联邦
TensorFlow Federated (TFF)是一个通过模拟实验在本地应用联邦学习的开源框架。TFF 使开发人员能够在他们的模型和数据上模拟包含的联邦学习算法,以及试验新的算法。
TFF 的界面分为两层:
-
联邦学习(FL) API :这一层提供了一组高级接口,允许开发人员将联邦培训和评估的实现应用到他们现有的 TensorFlow 模型中。
-
联邦核心(FC) API :系统的核心是一组底层接口,通过结合 TensorFlow 和分布式通信操作符来表达新颖的联邦算法。
输入数据
您将使用疟疾数据集,该数据集包含总共 27,558 个细胞图像,其中包含来自分割细胞的薄血涂片图像的相同寄生和未感染细胞实例。数据集可以从 https://ceb.nlm.nih.gov/proj/malaria/cell_images.zip
.
中获得
TensorFlow 联合图书馆生态系统中存在模拟数据集,但疟疾数据集接近医疗保健领域。在下一章中,您将看到医学图像分析如何处理 2D 和 3D 图像数据,因此这是一个良好的开端。
疟疾数据集包含两个类,如图 7-8 所示:
图 7-8
寄生和未感染细胞的例子
-
被寄生的(即被感染的细胞)
-
非寄生细胞(又称未感染细胞)
首先从本地目录加载数据,并查看感染和未感染图像样本的分布。
import os
import glob
BASE_DIR = os.path.join('./Data')
parasitized_dir = os.path.join(BASE_DIR,'Parasitized')
uninfected_dir = os.path.join(BASE_DIR,'Uninfected')
parasitized_files = glob.glob(parasitized_dir+'/*.png')
uninfected_files = glob.glob(uninfected_dir+'/*.png')
len(parasitized_files), len(uninfected_files)
输出
(13779, 13779)
看起来你对这两个类有一个平衡的表示。
联邦学习需要一个联合数据集(来自多个用户的数据集合,也称为本地节点)。任何联邦数据都应该是非 iid 的,这意味着不同的客户机应该至少有一些合理的相似分布(特定于本地节点的特征会影响每个系统上的数据分布)。
在您的情况下,您不会有不同的数据集分布,但通过可视化来探索它们会很好。
自定义数据加载流水线
如果您使用的是 tff 库中已经存在的模拟数据集,您可以简单地调用函数load_data().
_train, _test = tff.simulation.datasets.<dbname>.load_data()
load_data()
返回的数据集是tff.simulation.ClientData
的实例,它枚举本地节点的集合来构造一个代表特定节点数据的tf.data.Dataset
,并查询各个数据元素的结构。
因为您没有使用预先模拟的数据集,所以您需要自己构建一个。因为您的目录结构是按照以下方式组织的
Data/
...Parasitized/
......image_1.png
......image_2.png
...Uninfected/
......image_1.png
......image_2.png
您可以利用 tf.keras 预处理函数image_dataset_from_directory
。
调用image_dataset_from_directory(data_directory, labels="inferred")
将返回一个tf.data.Dataset
,它从子目录Parasitized
和Uninfected
中产生一批图像,以及标签 0 和 1 (0 对应于Parasitized
,1 对应于Uninfected
)。
tf.keras.preprocessing.image_dataset_from_directory(
BASE_DIR, labels='inferred', label_mode='int',
class_names=None, color_mode='rgb', batch_size=32, image_size=(256,
256), shuffle=True, seed=None, validation_split=None, subset=None,
interpolation='bilinear', follow_links=False
)
在上面的函数中,还有一个调整图像大小的选项,但是要调整图像大小,您需要知道正确的调整后的形状。由于这些是细胞图像,它们可能有不同的形状。让我们快速检查一下,然后使用预处理函数加载数据。
由于您有大约 30k 个图像,顺序加载每个图像会花费一些时间,因此您应该尝试在不同的 CPU 内核上并行化操作,并使用 OpenCV 库返回每个图像的形状。
首先加载库,并使用内置的 os 库来计算 CPU 数量。
from joblib import Parallel, delayed
import os
nprocs = os.cpu_count()
为了不中断其他应用程序的计算资源,您使用的 CPU 比总数少一个。这只是一个可以遵循的好习惯。
您将使用 OpenCV 3 来读取图像。可以通过运行以下命令来下载它:
pip install opencv-python==3.4.6.27
def load_image_shape(img):
return cv2.imread(img).shape
results = Parallel(n_jobs=nprocs-1)(delayed(load_image_shape)(img_file) for img_file in parasitized_files + uninfected_files)
print('Min Dimensions:', np.min(results, axis=0))
print('Avg Dimensions:', np.mean(results, axis=0))
print('Median Dimensions:', np.median(results, axis=0))
print('Max Dimensions:', np.max(results, axis=0))
Min Dimensions: [40 46 3]
Avg Dimensions: [132.98345308 132.48715437 3\. ]
Median Dimensions: [130\. 130\. 3.]
Max Dimensions: [385 394 3]
注意,这个过程只是加快了加载速度,但是您仍然要在内存中加载完整的数据,对于大型数据集,通常不建议这样做。在这种情况下,可以使用生成器,它会在需要时加载数据。
因此,图像形状的中值尺寸为 130,因此您可以安全地将所有图像缩放为标准形状(128,128,3)。
此外,为了整形,Keras 预处理库将使用双线性插值,这是默认选项,因此您只需使用它( bi 在这里表示图像的二维(x,y))。
import numpy as np
import pandas as pd
import tensorflow as tf
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
import tensorflow_federated as tff
IMG_HEIGHT = 128
IMG_WIDTH = 128
BATCH_SIZE = 32
train_ds = tf.keras.preprocessing.image_dataset_from_directory(BASE_DIR,
seed=123,
labels='inferred',
label_mode='int',
image_size=(IMG_HEIGHT, IMG_WIDTH),
color_mode='rgb',
subset="training",
shuffle=True,
validation_split = 0.2,
batch_size= BATCH_SIZE)
Found 27558 files belonging to 2 classes.
Using 22047 files for training
.
val_ds = tf.keras.preprocessing.image_dataset_from_directory(BASE_DIR,
seed=123,
labels='inferred',
label_mode='int',
image_size=(IMG_HEIGHT, IMG_WIDTH),
color_mode='rgb',
subset="validation",
shuffle=True,
validation_split = 0.2,
batch_size= BATCH_SIZE)
Found 27558 files belonging to 2 classes.
Using 5511 files for validation.
Note
到目前为止,TF 2.2.0 是我们旅程中的版本,它不支持image_dataset_from_directory
函数,所以建议使用最新的 TensorFlow 联邦库,它默认安装 TF 2.3.0。在 TF 2.3 和更高版本中,支持image_dataset_from_directory
。
您还可以看到标签映射到的类名。
class_names = train_ds.class_names
print(class_names)
输出:
['Parasitized', 'Uninfected']
这意味着整数 0 是类Parasitized
,1 是类Uninfected
。
疟疾数据集是一个大型数据集。根据您的机器设置,您可以将全部数据加载到内存中,也可以不加载。为了避免运行时出现任何问题,您将把联邦数据创建放在一个try-catch
块中。
NUM_CLIENTS = 10 # Local Nodes
CLIENT_LR = 1e-2
SERVER_LR = 1e-2 # Central Node
NUM_BATCH_CLIENT = int(len(train_ds)/NUM_CLIENTS)
import collections
client_train_dataset = collections.OrderedDict()
skip = 0
try :
for i in range(1, NUM_CLIENTS+1):
client_name = "Client_" + str(i)
take = NUM_BATCH_CLIENT
client_data = train_ds.skip(skip).take(take)
x_train, y_train = zip(*client_data)
print(f"Adding data from Batch No {skip} to {take*i} for client : {client_name}")
# We are going to unbatch and load the data to prevent data dropping in creating client data later on
data = collections.OrderedDict((('label', [y for x in y_train for y in x]),
('pixels', [y for x in x_train for y in x])))
client_train_dataset[client_name] = client_data
skip = take*i
except Exception as e:
print("Memory Error - Client Data creation stopped")
print(f"Total number of clients created are {len(client_train_dataset)}")
NUM_CLIENTS = len(client_train_dataset)
输出
Adding data from Batch No 0 to 68 for client : Client_1
Adding data from Batch No 68 to 136 for client : Client_2
Adding data from Batch No 136 to 204 for client : Client_3
Adding data from Batch No 204 to 272 for client : Client_4
Adding data from Batch No 272 to 340 for client : Client_5
Memory Error - Client Data creation stopped
Total number of clients created are 4
在上面的代码中,您试图创建一个有序字典,以便在从张量切片创建客户数据时保持客户的顺序。
正如我所说的,您可以从预期数量的客户端开始,但是根据本地可用的计算资源,您也可以预期更少的客户端数量。在这里,您最终只剩下四个客户端,因此减少了用于训练的数据。现在你不应该担心这个问题,因为对于一个更大的机器来说,这样的问题很容易解决。
此外,根据 TFF 团队的说法,“我们近期的未来路线图包括一个高性能的运行时,用于非常大的数据集和大量客户端的实验。”
接下来,通过传递客户端数据的键值对,在模拟环境中创建客户端数据(参见图 7-9 )。
图 7-9
来自联合数据的图像
train_dataset = tff.simulation.FromTensorSlicesClientData(client_train_dataset)
sample_dataset = train_dataset.create_tf_dataset_for_client(train_dataset.client_ids[0])
sample_element = next(iter(sample_dataset))
本地节点的训练样本总数为
len(sample_dataset)
输出
2176
plt.imshow(sample_element['pixels'].numpy().astype('uint8'))
plt.grid(False)
plt.show()
此时,一旦有了可用的联邦数据,因为这是一个模拟环境,所以可以做几个测试来检查客户机数据的非 iid 行为的强度。我将把这个练习留给您去探索和试验,但是请记住,在现实世界中,这种类型的分析是不可能的,因为数据不是集中可用的。
预处理输入数据
对于预处理,您必须确保以下几点:
-
数据质量
-
通过将像素强度乘以 1/255 来重新调整所有通道的比例,您已经标准化了像素值。
-
合适的比例:装载时已经保证
-
增强以创建更多数据并避免过拟合 OOB/验证数据集。因为案例研究是为了发现联邦原则,所以您现在将跳过这一步,回到第八章。
-
-
培训改进:
-
使用梯度下降创建训练批次
-
随机产生随机性,使损失与样本选择无关
-
预取某些样本以减少训练滞后的可能性,因为样本用于训练,所以您必须运行预处理
-
SHUFFLE_BUFFER = len(sample_dataset) # How much data to shuffle
EPOCHS = 5 # Number of epochs to run for training @ individual node
PREFETCH_BUFFER = 100 # Preloading some number of samples to aid faster training.
# Normalizing the pixel values
normalization_layer = tf.keras.layers.experimental.preprocessing.Rescaling(1.0/255)
def preprocess(dataset):
def batch(sample):
_x = normalization_layer(sample['pixels'])
return collections.OrderedDict(
x = _x
y = tf.reshape(sample['label'], [-1, 1]))
return dataset.repeat(EPOCHS).shuffle(SHUFFLE_BUFFER).batch(
BATCH_SIZE).map(batch).prefetch(PREFETCH_BUFFER)
创建联合数据
既然已经准备好了预处理函数,就可以通过创建客户机数据集的迭代器来创建最终的联邦数据。
此外,在现实世界的设置中,您通常从大量客户端中选择一个客户端样本,因为其中只有一小部分可用(跨设备设置)。
selected_clients = np.random.choice(train_dataset.client_ids,NUM_CLIENTS, replace = False)
federated_train_data = (preprocess(train_dataset.create_tf_dataset_for_client(i)) for i in selected_clients)
您还可以使用前面创建的示例批处理来创建预处理联邦数据集的示例,因为它可以在以后用于输入规范。
sample_federated_dataset = preprocess(sample_dataset)
联合通信
在 TFF 框架内,任何在本地训练的模型都需要包装在tff.learning.Model
接口中。这允许两件事:
-
帮助计算各个节点的联合指标和性能
-
一组变量在每个本地节点上的筒仓中受到影响。
首先创建一个训练模型函数,它构建您正在使用的 NNet 体系结构。
-
Conv2D 层对输入图像进行卷积运算,以捕捉每个像素的局部效应。
-
池层是为了降低维度,集中信息。
-
为了防止过度适应,在训练过程中你会去掉随机的神经元。
-
最后,在展平用于预测的下降图层的 2-D 输出后,添加一个密集图层。
def train_model():
model = Sequential([
tf.keras.layers.InputLayer(input_shape=(IMG_HEIGHT,IMG_WIDTH, 3)),
# Ingesting a 2-d Image with 3 channels
tf.keras.layers.Conv2D(16, 3, padding='same', activation='relu'),
# Max pooling to reduce dimensions
tf.keras.layers.MaxPooling2D(),
tf.keras.layers.Conv2D(32, 3, padding='same', activation='relu'),
tf.keras.layers.MaxPooling2D(),
tf.keras.layers.Conv2D(64, 3, padding='same', activation='relu'),
tf.keras.layers.MaxPooling2D(),
# Dropout to prevent over-fitting
tf.keras.layers.Dropout(0.2),
# Flattening to feed data for sigmoid activation
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dense(len(class_names)-1, activation = 'sigmoid')
])
return model
def federated_train_model():
local_train_model = train_model()
return tff.learning.from_keras_model(
local_train_model,
input_spec=sample_federated_dataset.element_spec,
loss=tf.keras.losses.BinaryCrossentropy(),
metrics=[tf.keras.metrics.AUC()])
接下来,您将为中央服务器创建流程,以便使用来自所有本地节点的参数更新来更新中央模型。
parameter_iteration_process = tff.learning.build_federated_averaging_process(
federated_train_model,
client_optimizer_fn = lambda: tf.keras.optimizers.SGD(learning_rate= CLIENT_LR),
server_optimizer_fn = lambda: tf.keras.optimizers.SGD(learning_rate= SERVER_LR))
TFF 构建了一对联邦计算,并将它们打包到一个tff.templates.IterativeProcess
中,这些计算作为一对称为initialize
和next
的属性可用。
-
initialize
表示服务器上联合平均过程的状态。它包括-
模型:分配给所有设备的初始参数
-
优化器状态:为联邦指标计算和平均而维护。它跟踪梯度更新。
-
三角洲聚集物
-
-
next_fn
将利用client_update
和server_update
,代表一个联合平均周期。
state = parameter_iteration_process.initialize()
state, metrics = parameter_iteration_process.next(state, federated_train_data)
print('round 1, metrics={}'.format(metrics))
输出
round 1, metrics=OrderedDict([('broadcast', ()), ('aggregation', OrderedDict([('value_sum_process', ()), ('weight_sum_process', ())])), ('train', OrderedDict([('auc', 0.5897039), ('loss', 0.6823319)]))])
同样,你可以有多个回合。
NUM_ROUNDS = 6 # Total 5 rounds of training
for round_num in range(2, NUM_ROUNDS):
state, metrics = parameter_iteration_process.next(state, federated_train_data)
print('round {:2d}, metrics={}'.format(round_num, metrics))
输出
round 2, metrics=OrderedDict([('broadcast', ()), ('aggregation', OrderedDict([('value_sum_process', ()), ('weight_sum_process', ())])), ('train', OrderedDict([('auc', 0.60388386), ('loss', 0.67804503)]))])
round 3, metrics=OrderedDict([('broadcast', ()), ('aggregation', OrderedDict([('value_sum_process', ()), ('weight_sum_process', ())])), ('train', OrderedDict([('auc', 0.61434853), ('loss', 0.6752475)]))])
round 4, metrics=OrderedDict([('broadcast', ()), ('aggregation', OrderedDict([('value_sum_process', ()), ('weight_sum_process', ())])), ('train', OrderedDict([('auc', 0.62443274), ('loss', 0.67076266)]))])
round 5, metrics=OrderedDict([('broadcast', ()), ('aggregation', OrderedDict([('value_sum_process', ()), ('weight_sum_process', ())])), ('train', OrderedDict([('auc', 0.6333971), ('loss', 0.6674127)]))])
你们中的一些人可能会发现训练过程(收敛)有点慢。实际上,这是由于较低的服务器学习率。我把它保持在 0.1。如果将它保持为 1,这意味着每次迭代都全力贡献给中心模型的参数。换句话说,更新是完全学习的。
Note
如果您在 Jupyter 笔记本中运行相同的代码,您必须允许异步操作。在 Python 中,可以通过调用
import nest_asyncio
nest_asyncio.apply()
估价
TensorFlow 库提供了build_federated_evaluation,
,它允许通过联邦通信(跨本地节点)来聚合指标。
def evaluate(train_fn, state, train_data, test_data):
# Print training metrics
evaluation = tff.learning.build_federated_evaluation(train_fn)
train_metrics = evaluation(state.model, train_data)
print("Training Metrics: AUC : {}, Binary Cross Entropy Loss: {}".format(
train_metrics['auc'],
train_metrics['loss']))
# Print testing metrics
test_metrics = evaluation(state.model, test_data)
print("Validation Metrics: AUC: {}, Binary Cross Entropy Loss: {}".format(
test_metrics['auc'],
test_metrics['loss']))
您必须通过与训练数据相同格式的验证集。为此,您需要创建一个client_test_dataset
,它是一个字典,包含每个本地节点或服务器节点的验证数据。
然后使用上面定义的preprocess()
函数对所有的验证进行评估处理。
val_dataset = tff.simulation.FromTensorSlicesClientData(client_test_dataset)
federated_val_data = [preprocess(val_dataset.create_tf_dataset_for_client(i)) for i in selected_clients]
evaluate(federated_train_model, state, federated_train_data, federated_val_data)
输出
Training Metrics: AUC : 0.6697379946708679, Binary Cross Entropy Loss: 0.6773737072944641
Validation Metrics: AUC: 0.6535744071006775, Binary Cross Entropy Loss: 0.6790395379066467
在这一节中,我讨论了 TF learning API。TFF 还提供了核心 API,您可以在其中修改 TFF 提供的几个不同组件,如联合平均技术和联合通信(跨设备网络负载和本地处理)。
结论
联邦学习是一个不断发展的领域,随着保护私有和昂贵数据的需求变得普遍,它一定会发展壮大。在这一章中,您详细介绍了差分隐私和多方通信的隐私机制,但是新的研究还在不断出现。秦斌等人的“联邦学习系统调查:数据隐私和保护的愿景、宣传和现实”是一篇出色的论文,揭示了联邦学习的不同层面。
话虽如此,联邦学习并不是进行受保护学习的唯一方式。人们也在研究没有中央服务器来协调工作的对等系统;相反,它是自治的。这种系统在现实世界中的可靠性还有待证实。
最后,Owkin、Google 和 Apple 等几家公司正在积极投资联合技术,特别是围绕患者的药物发现、打字推荐和改进聊天机器人。在我看来,ML 产品进入市场解决国家间本地问题的速度意味着它是一项重要的技术。*
八、医学成像
医学图像分析在过去的三十年里有了巨大的发展。最初,该领域的分析被视为应用模式识别和精算计算机视觉方法,但随着高级图像处理和基于深度学习的方法的广泛使用,该领域不仅在算法进步方面,而且在处理各种数据方面都发展迅速,因为在此期间出现了不同的模式。
在这个案例研究中,你将接触到医学成像的许多不同方面。您将特别关注不同类型的医疗数据,以及这些医疗图像数据是如何捕获、数字化存储和分发的。你将不会触及这些图像是如何基于组织-能量相互作用和相关统计数据而形成的物理学。
您将使用二维和三维图像深入研究图像分割和分类的两个终端应用。最后,您将探索当前存在的各种挑战,如图像质量、可解释性和对抗性攻击。
什么是医学影像?
医学成像涉及对不同成像模式(例如 X 射线、CT、MRI 等)上的生物医学图像的科学分析。监测健康(通过筛查)、疾病和伤害的诊断和治疗。
这些生物医学图像是在宏观、中观和微观等不同尺度上对人体、器官或组织的测量。这些标尺的穿透深度和图像分辨率不同,如图 8-1 和 8-2 所示。
图 8-2
基于尺度的光学成像技术比较。资料来源:Subhamoy Mandal 等人,“将生物成像扩展到第五维度”
图 8-1
光学分辨技术综述。资料来源:光学学会
生物医学图像来源于测量人体不同物理特性的不同成像模式。
图像模态
图像模态是通过利用与技术/设备中使用的能量类型的相互作用,以 n 维图像的形式捕捉器官/组织特征的各种方式。举个例子,
-
X 射线成像中的辐射吸收
-
超声波中的声压
-
磁共振成像中的射频信号幅度
MRI、超声、X 射线和 CT 是一些主要的成像方式,但还有更多,如图 2 所示。存在如此多的模态是因为一个简单的原因,即单一的技术不足以捕捉人体解剖和生理。
为了提供这些技术的简要概述,表 8-1 对主要模态进行了比较和对比。
表 8-1
比较不同的图像形态
|南号码
|
形式
|
应用
|
主要特点
|
缺点
|
辐射
|
| --- | --- | --- | --- | --- | --- |
| one | x 射线 | 非均匀组成的材料,如骨骼。这些图像有助于评估是否存在疾病、损伤或异物。 | 获得图像通过使用 x 光片。无创无痛。 | 有时结构重叠会造成翻译上的问题。 | 电离 |
| Two | 计算机化 X 线体层照相术 | 非均匀组成的材料,如骨骼。这些图像有助于评估是否存在疾病、损伤或异物。 | 扫描是使用 x 光和后来的计算机被用来构建一个系列横截面的图像。这就消除了叠加。 | 高剂量的电离辐射,因此将来会引起致癌的疾病。 | 电离 |
| three | 核磁共振成像 | 一般用于分析撕裂的韧带和肿瘤。也有助于检查大脑和脊髓。 | 使用磁信号和无线电波。 | 强信号会导致幽闭恐惧症倾向。 | 非电离的 |
| four | 超声 | 主要是胎儿成像。也用于腹部器官、心脏、乳房、肌肉、肌腱、动脉和静脉的成像。 | 使用高频率声音信号对诸如器官的内部结构成像,软组织和未出生的婴儿。 | 容易产生噪声,并且该过程由放射科医师驱动,因此容易出现人为错误。 | 非电离的 |
那么,我们为什么会对理解这些模式感兴趣呢?
首先,要理解依赖于手头的用例,我们必须仔细选择要使用的模态。
图 8-3
核磁共振扫描比 CT 更清楚地显示受伤的脑组织
-
提高发现问题的敏感性(异物/血管问题等)。)
-
与像 X 射线这样的 2-D 成像模态相比,3-D 成像模态允许更好的定位。
-
更好地描绘组织类型。例如,如图 8-3 所示,如果目标是发现中风中受伤的脑组织,您可以看到,与 CT 相比,MRI 图像清楚地显示了受损区域,而 CT 的大部分区域是黑暗的。
其次,这些模式在获取价值的方式上可能有所不同。因为数字图像有像素强度,所以有不同的度量来测量数字医学图像中的信息值。
CT 扫描和 X 射线 Hounsfield 单位(HU)用于测量电离辐射的强度。更高的 HU 意味着辐射更难通过,因此有更高的衰减。参见图 8-4 。
图 8-4
Hounsfield 标度范围从-1000 到+ 1000。资料来源:Osborne 等人,www . southsudanmedica l journal . com/
注意许多灰色的阴影。人眼甚至计算机在某些情况下都不可能在如此小的梯度上工作,因此使用一种称为开窗的技术来观察感兴趣的区域,如软组织、肺和骨骼。确定窗位 L 和宽度 W。然后,仅在范围L-W/2 到 L + w /2 内保持梯度,其余部分完全变成黑色(小于 L - W/2)和白色(大于 L + W/2)。所有这些重要的决定都是在我们对这些图像建模之前做出的。
最后,不同的模式可以使用不同的造影剂来突出某些组织区域。由于不同组织对药物的吸收率不同,某些组织就显得特别突出。CT 成像使用碘基,而磁共振成像使用钆基,通常口服(如片剂)或静脉注射(直接泵入血流)。如图 8-5 所示,由于使用了对比剂,经过一段时间(20-30 秒延迟)后,您可以看到致癌结节在肝脏中突出显示。
图 8-5
造影剂突出某些组织区域
数据存储
在我们深入探讨如何处理多维图像格式并在其上建立机器学习模型之前,让我们快速了解一下医学图像数据将采用的标准文件格式以及不同的组成部分。
典型的医学图像由四个基本部分组成:
-
像素深度
-
像素数据
-
[计]元数据
-
光度解释
我们用一个很简单的例子来理解这一点。假设你有一张黑白图像,其中有各种不同的灰度强度,并且你知道各种其他信息,如这张图像的人物、内容和时间。
根据此描述,各种元素对应于您刚刚了解到的基本组件。
-
黑白图像告诉我们所使用的通道以及图像的光度解释。图像可以是单色的,也可以是彩色的。
-
不同的强度等级暗示了两件事。首先,它告诉我们像素深度,即用于编码信息的位数。例如,一个 8 位像素可以包括 28 个亮度级(无符号为 0 到 255)。其次,它告诉我们像素强度值以及像素值的范围。
-
其他信息是元数据,如研究日期、图像模态、患者性别、形状等。
特殊数据需要特殊格式。从射线照相设备收集的图像主要有六种不同的格式。
-
医学数字成像和通信
-
神经成像信息学技术倡议
-
PAR/REC (Philips MRI 扫描仪格式)
-
分析(梅奥医学成像)
-
NRRD(接近原始栅格数据)
在这六种不同的格式中,DICOM 和 NIFTI 是使用最广泛的。DICOM 和 NIFTI 之间的主要区别在于,DICOM 中的原始图像数据存储为二维切片文件的集合,这使得该结构对于三维数据分析来说有点麻烦,而在 NIFTI 中,我们有完整的三维图像。
除了处理、存储、打印和传输信息之外,所有这些不同的格式还可以帮助您获得您的机器学习模型所需的所有功能,作为一名数据科学家,这是您应该集中精力的地方:哪种格式可以提供哪种信息。
在前面的小节中,为了让您熟悉这些格式,您将使用 DICOM 的一个例子和 NIFTI 的另一个例子。
处理二维和三维图像
在我们讨论过的各种方法中,只有 X 射线成像能产生二维图像。CT 和 MRI 创建三维图像,因为它们从不同的角度捕捉信息。还有另外两种形式可以帮助我们捕捉二维数据:
-
眼底成像:用于扫描眼部微小血管的健康状况。通常用于识别糖尿病视网膜病变(DR)。
-
病理成像:通过对细胞进行染色,使不同的细胞结构呈现不同的颜色,然后进行数字化处理,从而获得的细胞水平成像(记得上一章)。
然而,在本章中,我们将讨论通过 X 射线设备进行二维图像分析。
类似地,3d 图像不限于 CT 和 MRI。其他形式如超声波和 PET/SPECT 扫描也能产生 3d 图像,用于了解人体的不同部位。我们可以将 3d 图像视为 2d 图像的堆叠,使得这些图像从不同角度拍摄,然后拼接在一起以创建全面的 3d 视图。
在这一章中,我们将讨论通过 MRI 设备进行三维图像分析。
你可能有时会听到术语四维图像。好吧,别惊讶。它只是在不同的时间或不同的子模型中捕获的几个三维图像,就像 MRI ??、?? 等的情况。我不会报道它,但如果你有兴趣,我建议你看看李等人的论文,题为“医学影像和放射治疗的进展”
处理二维图像
你将接受北美放射学会的 RSNA 肺炎检测挑战。它组织在 Kaggle 上,拥有 DICOM 格式的数据。尽管该挑战旨在定位胸片上的肺活量,但 DICOM 元数据文件还包含以下图像标签:
-
标准
-
没有肺部阴影/不正常
-
肺部阴影
因此,您也将使用相同的标签进行影像分类。
RSNA 每年都组织医学影像竞赛。查看不同数据集和比赛的空间: www.rsna.org/education/ai-resources-and-training/ai-image-challenge
。
Python 中的 DICOM
您的目录应该如下所示:
-
从 Kaggle 笔记本的数据部分下载单独的 ZIP 文件,并按照上面所示的格式创建目录。
-
stage_2_detailed_class_info.csv
包含每个患者 id 的标签,而train
和test
文件夹中的每个 DICOM 文件被命名为patient-id
。 -
stage_2_train_labels.csv
包含来自train
和test
文件夹的每个患者 id 的目标标签肺炎或非肺炎。
Data/
...2d_lung_opacity_challenge/
......Train/
.........000db696-cf54-4385-b10b-6b16fbb3f985.dcm
.........000fe35a-2649-43d4-b027-e67796d412e0.dcm
......Test/
.........00b4e593-fcf8-488c-ae55-751034e26f16.dcm
.........00f376d8-24a0-45b4-a2fa-fef47e2f9f9e.dcm
......stage_2_detailed_class_info.csv
DICOM 文件包含标题元数据和原始图像像素阵列的组合。在 Python 中,你可以使用一个名为pydicom
的库来处理 DICOM 文件。
还记得我们讨论过除了典型的图像分析流水线之外,不同的图像模态如何引入新的预处理步骤吗?幸运的是,在您的案例中,您已经有了预处理数据。RSNA 共享的数据在两个方面进行预处理:
-
将高动态范围转换为 8 位编码,其值范围为 0 到 255 灰度。
-
图像通常以较高的分辨率捕获,但出于实用目的,图像被调整为 1024 x 1024 矩阵。
对于那些还在考虑如何进行窗口和大小调整的人来说,如果这样的预处理还没有完成的话,这里有一些代码:
def windowed_image(img, center, width):
img_min = center - width // 2
img_max = center + width // 2
windowed_image = img.copy()
windowed_image[windowed_image < img_min] = img_min
windowed_image[windowed_image > img_max] = img_max
return windowed_image
您可以合并目标和类数据,以便更好地理解分布。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import tensorflow as tf
import pydicom
import glob
import os
BASE_DIR = "./Data"
DATA_DIR = os.path.join(BASE_DIR,"2d_lung_opacity_challenge/")
classes = pd.read_csv(glob.glob(os.path.join(DATA_DIR,"*.csv"))[0])
target = pd.read_csv(glob.glob(os.path.join(DATA_DIR,"*.csv"))[1])
train_labels = pd.merge(classes, target[["patientId","Target"]], on = "patientId", how="left")
因为有多个patientId
,所以单个图像可以有多个边界框。扔掉重复的。
assert train_labels.drop_duplicates().shape == train_labels.drop_duplicates('patientId').shape:
train_labels = train_labels.drop_duplicates().reset_index(drop = True)
print(train_labels.groupby(['class', 'Target']).size().reset_index(name='Patient Count').to_markdown())
| | class | Target | Patient Count |
|---:|:-----------------------------|---------:|----------------:|
| 0 | Lung Opacity | 1 | 6012 |
| 1 | No Lung Opacity / Not Normal | 0 | 11821 |
| 2 | Normal | 0 | 8851 |
哪里有肺部阴影,哪里就有肺炎。然而,医学上肺部阴影不能完全和唯一地确定肺炎,因为诊断需要其他临床信息,如实验室数据、症状等。但为了简单起见,所有肺部阴影都称为肺炎。然而,在现实世界中,你不能做同样的假设;你必须咨询适当的医学研究人员和放射科医生来做出这样的假设。
接下来,非肺炎可以分为无肺部阴影/不正常和正常。正常的图像是健康胸部的图像。你不能说没有肺部阴影/不正常。让我们来看看其中的几个。
def draw(input_ids):
# A maximum of 3 images in a row
ncols, nrows = min(3,len(input_ids)), len(input_ids)//min(3,len(input_ids)) +1 if len(input_ids)%min(3,len(input_ids)) !=0 else len(input_ids)//min(3,len(input_ids))
# figure size, inches
figsize = [10, 8]
# create figure (fig), and array of axes (ax)
fig, ax = plt.subplots(nrows=nrows, ncols=ncols, figsize=figsize)
# plot image for single sub-plot
for i, axi in enumerate(ax.flat):
try:
dicom_path = input_ids[i]
data = pydicom.read_file(dicom_path)
# one can also use plt.cm.bone
axi.imshow(data.pixel_array, cmap="gray")
# get indices of row/column
rowid = i // ncols
colid = i % ncols
except IndexError as e:
continue
# For some of you who want to add bounding box info to plots as
# well can access by row-id and col-id on the array of axes
# ax[row-id][col-id].plot()
plt.tight_layout(True)
plt.show()
np.random.seed(123)
examples_non_normal = np.random.choice(train_labels[train_labels["class"].\
isin(["No Lung Opacity / Not Normal"])].patientId,
size = 3,
replace = False)
examples_non_normal = [os.path.join(DATA_DIR,"Train",x+".dcm") for x in examples_non_normal]
draw(examples_non_normal)
图 8-6 中的一些观察结果。
-
肺部阴影图像和无肺部阴影/不正常图像具有一些相似的特征。
-
电线和管子的存在,这表明除了目标值为 0 的肺炎之外,可能还有其他一些观察到的疾病。
-
在大多数情况下,间隙/不透明性(充满液体/病原体的间隙等)的性质。)对于两种类型是不同的,尽管由于类似于 COPD 或哮喘的外来物质在肺中的扩散,它可以重叠。
-
-
由于胸腔积液,液体或异物的积聚可以
-
渗出来让肺看起来更小。参见无肺部阴影/不正常行中的样本 3。
-
上述情况很容易与肺部阴影混淆,因此在这种情况下可能需要咨询几位放射科医生才能得出结论。
-
图 8-6
三种不同标签的样品
对类别标签进行分析而不仅仅是盲目跟踪目标标签的目的是让您意识到,医学图像分析需要一定的领域知识来理解和实现一个强大的图像分析系统。尤其是如果你计划将模型投入使用,FDA 将会调查与你的模型相关的风险,在这种情况下,这种微妙的理解将会派上用场。
基于 DICOM 元数据的 EDA
您可以定义一个函数来从 DICOM 文件中选择重要的元数据。
-
患者年龄
-
病人性别:只有两类,男性和女性
-
像素间距:较高的像素间距意味着图像质量较低
-
查看位置:AP(射线从胸部到背部,卧位;通常用于病人或老年人)和 PA(射线从背部到胸部,站立位置)
def get_metadata(patient_id):
"""
Returns metadata from each dicom file
"""
data = pydicom.read_file(os.path.join(DATA_DIR,"Train",patient_id+".dcm"),
stop_before_pixels=False)
_id = data.PatientID
_age = data.PatientAge
_sex = data.PatientSex
# col_spacing (horizontal)
_pixelspacing_x = data.PixelSpacing[1]
# row_spacing (vertical)
_pixelspacing_y = data.PixelSpacing[0]
_viewpos = data.ViewPosition
_mean = np.mean(data.pixel_array)
_min = np.min(data.pixel_array)
_max = np.max(data.pixel_array)
return pd.DataFrame([[_id, _age, _sex, _pixelspacing_x, _pixelspacing_y, _viewpos ,_min, _max, _mean]],
columns = ["patientId","age","sex","pixel_spacing_x","pixel_spacing_y","view_pos",
"min_pixint","max_pixint","mean_pixint"])
使用并行处理,您可以捕获所有元数据,以查看其相关性和对目标变量的影响。
from joblib import Parallel, delayed, parallel_backend
from tqdm import tqdm
train_dicom = Parallel(n_jobs=os.cpu_count()-1, backend="threading")(delayed(get_metadata)(pt_id) for pt_id in tqdm(train_labels.patientId))
然后连接从每个 DICOM 返回的单个数据帧。
train_dicom_df = pd.concat(train_dicom, axis = 0)
最后,将目标/标签数据集与元数据数据帧合并,并创建用于分析的数据。参见图 8-7 。
图 8-7
患者在不同观察位置的分布
# Train Labels with Metadata
train_labels_w_md = pd.merge(train_labels, train_dicom_df, on = "patientId", how="left")
查看位置
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
sns.countplot(x='view_pos', hue='class', data=train_labels_w_md, ax=axes[0])
sns.countplot(x='view_pos', hue='Target', data=train_labels_w_md, ax=axes[1])
基于以下原因,视图位置看起来是一个重要的变量:
-
虽然 PA 或 AP 位置的患者数量相似,但 AP 位置的肺炎患者更多(Target = 1)。
-
此外,对于 AP 视图,肺部不透明度标记很明显,而对于 PA 视图,正常标记更明显。
年龄
根据目标和类别标签绘制年龄分布图,以检查分布情况。
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
p = sns.distplot(train_labels_w_md[train_labels_w_md['class']=='No Lung Opacity / Not Normal']['age'],
hist=True,
kde=False,
color='red',
label='No Lung Opacity / Not Normal', ax=axes[0])
p = sns.distplot(train_labels_w_md[train_labels_w_md['class']=='Normal']['age'],
hist=True,
kde=False,
color='cornflowerblue',
label='Normal', ax=axes[0])
p = sns.distplot(train_labels_w_md[train_labels_w_md['class']=='Lung Opacity']['age'],
hist=True,
kde=False,
color='lime',
label='Lung Opacity', ax=axes[0])
_ = p.legend()
p = sns.distplot(train_labels_w_md[train_labels_w_md['Target']==0]['age'],
hist=True,
kde=False,
color='gray',
label='0', ax=axes[1])
p = sns.distplot(train_labels_w_md[train_labels_w_md['Target']==1]['age'],
hist=True,
kde=False,
color='lime',
label='1', ax=axes[1])
_ = p.legend()
正如您在图 8-8 中看到的,年龄类别对于任何目标(0 或 1)都没有显示出任何明显的特征,对于类别标签也是如此。然而,可以形成间距为 20 的某些组。
图 8-8
患者的年龄分布
性
你在性专栏做了两项分析。
-
检查患者在不同目标和不同性别标签中的分布。
-
Although the distribution is not structurally different, women generally have a higher pneumonia percentage (see Figure 8-9).
图 8-9
患者在性别栏中的分布
-
-
年龄和性别相关性
- 两性显示相同的分布模式,正常患者的平均年龄通常低于无肺部阴影/不正常患者(见图 8-10 )。
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
sns.countplot(x='sex', hue='class', data=train_labels_w_md, ax=axes[0])
sns.countplot(x='sex', hue='Target', data=train_labels_w_md, ax=axes[1])
train_labels_w_md["age"] = train_labels_w_md.age.apply(lambda x:int(x))
fig, axes = plt.subplots(1, 2, figsize=(14, 7))
sns.boxplot(x='sex', y = 'age', hue='class', data=train_labels_w_md, ax=axes[0])
sns.boxplot(x='sex', y = 'age', hue='Target', data=train_labels_w_md, ax=axes[1])
图 8-10
用箱线图来观察不同性别的年龄差异
像素间距
像素间距代表每个像素的大小。每个像素代表图像上的某个区域。不同的像素间距会导致空间信息的不均匀分布。让我们看看这种差异有多明显。
首先,将像素间距四舍五入两点。
train_labels_w_md["pixel_spacing_x_norm"] = train_labels_w_md.pixel_spacing_x.apply(lambda x: round(float(x),2))
train_labels_w_md["pixel_spacing_y_norm"] = train_labels_w_md.pixel_spacing_y.apply(lambda x: round(float(x),2))
接下来,绘制患者计数。
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
plot = sns.countplot(x='pixel_spacing_x_norm', hue='class', data=train_labels_w_md, ax=axes[0][0])
plot.set_xticklabels([x for x in plot.get_xticklabels()],rotation=90)
plot.legend(loc='upper right')
plot = sns.countplot(x='pixel_spacing_x_norm', hue='Target', data=train_labels_w_md, ax=axes[0][1])
plot.set_xticklabels([x for x in plot.get_xticklabels()],rotation=90)
plot.legend(loc='upper right')
sns.countplot(x='pixel_spacing_y_norm', hue='class', data=train_labels_w_md, ax=axes[1][0])
plot.set_xticklabels([x for x in plot.get_xticklabels()],rotation=90)
plot.legend(loc='upper right')
sns.countplot(x='pixel_spacing_y_norm', hue='Target', data=train_labels_w_md, ax=axes[1][1])
plot.set_xticklabels([x for x in plot.get_xticklabels()],rotation=90)
plot.legend(loc='upper right')
图 8-11 中有两个主要观察结果:
图 8-11
按像素间距划分的患者分布
-
像素间距有很大的变化,范围从 0.13 到 0.2。
-
对于 0.15 至 0.17 之间的间距,更容易观察到肺部阴影。
尽管您不会直接使用像素间距,但您将使空间信息一致,为此,您会将图像重新采样为 1 毫米 X 1 毫米。
平均强度
最后,您将看到标签和目标的平均强度是如何变化的。如果您发现目标或标签有不同的模式,您肯定会尝试包含元数据
在图 8-12 中,肺炎和非肺炎患者遵循相似的分布,因此他们没有给出任何类别的任何特殊信息。
图 8-12
平均强度下的患者分布
Note
同样,您可以使用 DICOM 元数据进行更多的分析。我希望您对处理 DICOM 文件和元数据有了相当详细的了解。
处理三维图像
处理三维图像与处理二维图像没有太大的不同,但是为了让事情变得更有挑战性,让我们学习一下 NIFTI 中的三维图像处理。与上一节一样,这将帮助您为前面的图像分割案例研究做好准备。
您将使用位于 www.med.upenn.edu/cbica/brats2020/data.html
的佩雷尔曼医学院的 BRATS 2020 数据集。BRATS 2020 数据包含各种模式的 NIFTI 文件(通过改变 MRI 机器中的脉冲序列制作),即
-
本地(??)
-
增强后 ?? 加权(T1CE)
-
?? 加权(??)
-
?? 流体衰减反演恢复(??-FLAIR)
如果你有兴趣了解脉冲序列之间的差异,可以访问 https://radiopaedia.org/articles/mri-pulse-sequences-1?lang=us
了解更多。
既然您的数据将是三维数据,那么让我们来理解是什么使它成为三维数据。在医疗系统中,我们的身体可以分为三个平面:
-
轴向/横向:从上到下
-
矢状面:身体从左到右
-
冠状:从后到前(从后到前)
这就是核磁共振成像的三维图像。在上面的 DICOM 图像中,图像仅在冠状面拍摄,因此您看到的是二维图像。要了解更多关于这个主题的信息,请参考 https://teachmeanatomy.info/the-basics/anatomical-terminology/planes/
。
尽管 MRI 数据来自不同的临床协议和多台扫描仪,但它们已经以三种方式进行了预处理:
图 8-13
医学图像的配准
- 与相同的解剖模板共同配准:由于我们正在采集多种模式的 MRI,如果患者在采集这些图像之间移动(即使是轻微的移动),当所有序列组合在一起用于分割任务时,可能会导致未对准,因此需要进行一个称为配准的过程来避免这种错误。既然这个已经给你做好了,就不需要你操心了。见图 8-13 。
图 8-14
去除头骨的图像示例。(a)核磁共振成像;(b)颅骨剥离 MRI 图像。来源:Arun 等人在 2017 年发表的“SVM-LWT 实现了基于模糊聚类的脑肿瘤检测图像分析”
-
以相同的分辨率进行插值:这仅仅意味着所有四个序列的空间信息在整个 3d 体积上是一致的。
-
颅骨剥离:在 MRI 图像中,当解决诸如脑肿瘤分割等任务时,去除颅骨边界是一个好的做法,原因很简单,颅骨边界不提供任何有助于解决分割问题的信息,因此我们只是将其剥离。见图 8-14 。
Note
如果明天你开始使用一个不同的数据集,确保你检查了这三件事。
nifti 格式
NIFTI 文件格式不是由扫描仪生成的,因此元数据信息不如 DICOM 文件格式丰富,但它仍然有一些元数据。此外,它将图像系列表示为单个文件。
与 DICOM 相比,NIFTI 文件中的坐标系略有不同。了解这种差异是有好处的,因为通常您可能希望以 NIFTI 格式保存文件,因为整个图像系列都存在于单个文件中,这与 DICOM 不同,因此更易于共享和维护。参见图 8-15 。
图 8-15
NIFTI 图像中的坐标系
最后,NIFTI 中的测量单位可以不同,不像 DICOM 那样固定为毫米。NIFTI 单独存储测量单位的信息(例如,您在 DICOM 中看到的像素间距信息)。
因为与 DICOM 相比,NIFTI 头没有那么复杂,所以实际上我们很少使用 NIFTI 头信息。下面是一些需要注意的重要信息:
-
像素间距
-
三个平面的尺寸
-
XYZ-T 单位
磁共振图像处理简介
让我们快速设置您的输入流水线。
BASE_DIR = "./Data/3d_brain_tumor_segmentation/MICCAI_BraTS2020_TrainingData/"
label_paths = glob.glob(os.path.join(BASE_DIR,"**","*seg.nii"))
flair_paths = glob.glob(os.path.join(BASE_DIR,"**","*flair.nii"))
t1_paths = glob.glob(os.path.join(BASE_DIR,"**","*??.nii"))
t1ce_paths = glob.glob(os.path.join(BASE_DIR,"**","*t1ce.nii"))
t2_paths = glob.glob(os.path.join(BASE_DIR,"**","*??.nii"))
# Let's create a dictionary of dictionary to order the data
full_data = {i:{'label':label,
'flair':flair,
'??':??,
't1ce':t1ce,
'??':??} for i, (label,flair,??,t1ce,??) \
in enumerate(zip(label_paths,
flair_paths,
t1_paths,
t1ce_paths,
t2_paths))}
你已经知道有四个不同的序列,每个序列都可以在轴向、矢状和冠状面上以三种不同的方式观察。
patient_id = 5
k=1
plt.figure(figsize=(20,20))
for i,seq in enumerate(["flair","??","t1ce","??"]):
img = io.imread(full_data[patient_id][seq], plugin='simpleitk')
for j in range(3):
if (j==0):
plt.subplot(4,3,k)
plt.imshow(img[100,:,:])
# x-y plane
plt.title("Axial/Traverse View")
plt.ylabel(seq.upper())
k=k+1
elif (j==1):
plt.subplot(4,3,k)
plt.imshow(img[:,100,:])
plt.title("Coronal View")
k+=1
else:
plt.subplot(4,3,k)
plt.imshow(img[:,:,100])
plt.title("Sagittal View")
k+=1
在图 8-16 中,您可以清楚地看到不同的模态如何在不同的视图中突出大脑的不同部分,并提供补充信息。
图 8-16
磁共振成像模式和视图的交叉
对于同一个患者,我们也来看看目标标签。
# For the same patient let's also have a look at the target label
img = io.imread(full_data[patient_id]['label'], plugin='simpleitk')
plt.figure(figsize = (20,20))
k = 1
for i in [50, 75,100, 125]:
plt.subplot(1,4,k)
plt.imshow(img[i,:,:])
plt.title("Labels:- " + ", ".join([str(i) for i in np.unique(img[i,:,:])]))
k+=1
如图 8-17 所示,分段任务有四种不同的标签。肿瘤部分用绿色、黄色和蓝色(1、2 和 4)标记,而背景用紫色(0)标记
图 8-17
分段标签(轴向视图)
从图 8-16 和 8-17 中的上述曲线图可以清楚地看出
-
不是所有的切片都重要。
-
对于不同的序列,像素强度不是均匀分布的。
-
分割标签的像素强度高度不平衡(因为图像的大部分是紫色,其次是黄色标签 2)。
让我们探讨一下上面的第 1 点和第 2 点,看看你从这些观察中得到了什么。
非均匀像素分布
让我们快速看看不同的序列是如何使像素强度发生变化的。这将帮助您决定每个序列的规范化策略。
import seaborn as sns
sns.set_style('whitegrid')
fig, axes = plt.subplots(nrows=4, ncols=4)
fig.tight_layout(pad=1, w_pad=1, h_pad=0.5)
fig.set_size_inches(20,20)
k=1
for patient_id in [5,10,20,50]:
for i,seq in enumerate(["flair","??","t1ce","??"]):
img = io.imread(full_data[patient_id][seq], plugin='simpleitk')
if (i==0):
plt.subplot(4,4,k)
plt.hist(x= img.reshape(-1,1))
plt.title(seq.upper())
plt.ylabel("Patient "+str(patient_id))
k=k+1
else:
plt.subplot(4,4,k)
plt.hist(x= img.reshape(-1,1))
plt.title(seq.upper())
k+=1
从图 8-18 中可以清楚地看到
图 8-18
患者不同磁共振成像序列的强度变化
-
大多数像素的亮度为 0,并且是右偏的。
-
对于不同的序列,对于异常值处理可以观察到不同的截止值。对于 ?? 和 T1CE,它大约是 500,而对于 FLAIR 和 ??,它从 300 到 600 不等。
-
您必须对此进行规范化,并处理偏斜。
相关性检验
为了分析您是否需要考虑所有的切片,您可以跨深度维度进行相关性测试。您将遍历图像的深度并计算相关性。为了使您的工作更容易,您只需将它转换成熊猫数据帧,然后计算相关性。其思想是,如果相邻或接近相邻切片的像素强度没有变化,它们将产生 NA 形式的相关性,因为它们的协方差为 0。
让我们快速绘制一些图表,看看哪些切片是不相关的。
k = 1
from itertools import chain
fig, axes = plt.subplots(nrows=1, ncols=4)
fig.tight_layout(pad=0.5, w_pad=2, h_pad=0.5)
fig.set_size_inches(13,5)
for i,seq in enumerate(["flair","??","t1ce","??"]):
_indices = []
for patient_id in range(5,85,10):
img = io.imread(full_data[patient_id][seq], plugin='simpleitk')
depth_dimension = img.shape[0]
_slice = np.array([list(img[i,:,:].reshape(-1,1)) for i in range(depth_dimension)])
_slice = np.squeeze(_slice,axis = 2).T
slice_df = pd.DataFrame(_slice)
# correlation matrix
_df = slice_df.corr()
# indices or slice numbers whose correlation is nan
_indices.append([y for x in np.argwhere(_df.isnull().all(axis=1).values) for y in x])
plt.subplot(1,4,k)
plt.hist(x= list(chain.from_iterable(_indices)))
plt.title(seq.upper())
k+=1
从图 8-19 中,你可以清楚地观察到
图 8-19
跨切片的不变强度
- 总体趋势中从 0-5 和 140-154 的切片数显示所有序列的强度没有变化,因此这些切片可以忽略。
裁剪和填充
还有其他类型的预处理可以完成,例如将切片裁剪到一个较低的维度,然后将它们填充到一个标准维度。这通常是为了减小卷的大小。您将遵循另一种方法来减少图像体积上不必要的卷积。但是,你必须确保在这样做之后没有错位,如图 8-20 所示。
图 8-20
裁剪原始图像以减少尺寸
虽然您可以明显地看到大小已经减小而没有任何信息损失,但我认为最有效的方法是在创建体训练数据的 3d 块时处理它,方法是在块体上设置阈值,比如至少 10%或 5%的非零像素强度。在后面的章节中会有更多的介绍。
二维图像的图像分类
名为“处理 2-D 图像”的部分详细介绍了许多数据属性以及您可以用它们做什么。还记得关于像素间距以及如何对图像进行重新采样以均匀分布空间信息的讨论吗?这个预处理步骤帮助您有效地使用 CNN 进行处理,使得内核从一个图像单元中学习相同的信息。核是用于从图像中提取特征的过滤器(二维矩阵)。
图像预处理
直方图均衡
有时由于对比度差,X 射线图像需要增强以突出小的纹理和细节。这样做基本上是为了扩大图像像素值的范围。参见图 8-21
图 8-21
直方图均衡。来源:维基
现在,如果您的整个图像被限制在一个像素范围内,您可以简单地将当前的像素分布映射到一个更宽更均匀的分布,但是如果已经存在高强度和低强度的区域(也就是更大范围的像素值),您必须局部应用一种叫做自适应直方图均衡化的东西。
具体来说,您将使用 CLAHE 方法。它可以增强图像的局部对比度,增强图像各部分的边缘和曲线的可视性。
-
对比度限制:如果该区域的任何直方图超过对比度限制,它们将被剪切。
-
自适应直方图均衡化:将图像分成小块,称为瓦片。这些图块是直方图均衡化的。
您将使用 OpenCv 来直方图规范化您的图像。参见图 8-22 中的结果。
图 8-22
直方图均衡的结果
def histogram_equalization(img, clip_limit, grid_size):
"""
Histogram Equalization
"""
clahe = cv2.createCLAHE(clipLimit = clip_limit,
tileGridSize = grid_size)
img_clahe = clahe.apply(img)
return img_clahe
像素的各向同性均衡
为了确保均匀的像素间距,您必须对图像进行插值和重新采样。
def resample(img, x_pixel, y_pixel):
new_size = [1, 1]
size = np.array([x_pixel, y_pixel])
img_shape = np.array(img.shape)
new_shape = img_shape * size
new_shape = np.round(new_shape)
resize_factor = new_shape / img_shape
resampled_img = scipy.ndimage.interpolation.zoom(img, resize_factor)
return resampled_img
虽然你的像素现在间隔适当,但这导致了形状较低的图像,这意味着不同的像素间隔将导致不同的图像大小,所以现在你必须通过裁剪/填充/插值来重塑它们。我通常更喜欢插值而不是固定大小,因为原始形状和目标形状之间的差异并不大。同样,您将使用 Opencv。
模型创建
由于您在 DICOM 元数据中发现了一些重要信息,您将创建一个双输入单输出神经网络,其中一个分支接收一批二维图像,另一个分支接收缩放的特征列。参见图 8-23
图 8-23
医学图像分类模型
你们中的一些人可能不知道我接下来要用到的不同层次和术语。我推荐阅读杜默林等人分享的这本优秀指南,名为《深度学习卷积算法指南》。
我们先从 TensorFlow 库导入相关库开始。
from tensorflow.keras.models import Model, Sequential
from tensorflow.keras.layers import (
Input,
Conv2D,
Dropout,
MaxPooling2D,
concatenate,
BatchNormalization,
Flatten,
Dense
)
from tensorflow.keras.optimizers import Adam
METRICS = [
tf.keras.metrics.AUC(name='auc'),
]
接下来,创建一个函数作为卷积块(如图 8-23 中的虚线所示)。您提供了几个控件来创建此卷积块,即
-
一个具有任意数量滤镜的卷积层和一个特定
kernel_size.
的卷积 -
一个
BatchNormalization
层,用于批量标准化数据,从而减少数据之间的协变量偏移。 -
pooling
利用核中像素亮度的最大值来压缩信息,从而缩小图像的特征空间。 -
dropout
通过在训练时随机丢弃神经元来防止过拟合。
def convolution_block(input_layer, num_filters, kernel_size,
strides, padding = 'valid',
activation = 'selu',
batch_normalization = False,
pool_kernel = None, dropout_rate = None):
layer = Conv2D(num_filters, kernel_size, strides = strides,
padding=padding, activation=activation)(input_layer)
if batch_normalization:
layer = BatchNormalization()(layer)
if pool_kernel:
layer = MaxPooling2D(pool_kernel)(layer)
if dropout_rate:
layer = Dropout(dropout_rate)(layer)
return layer
现在您可以构建您的主函数来创建您想要的网络。
-
首先创建两个输入层,它告诉模型预期的输入大小。
-
然后,根据您想要训练的参数数量,您可以不断添加到卷积块中,并相应地选择内核和池。我通常更喜欢从大内核和无池开始。然后我会更深入地介绍这两者。
def build_model():
input_img = Input(TARGET_SHAPE+(1,))
input_feats = Input((6,))
cb1 = convolution_block(input_img, num_filters = 128, kernel_size = 8,
strides = 1, padding = 'valid',
batch_normalization = True,
activation = 'selu',
pool_kernel = None, dropout_rate = None)
cb2 = convolution_block(cb1, num_filters = 32, kernel_size = 8,
strides = 1, padding = 'valid',
activation = 'selu',
pool_kernel = 2, dropout_rate = None)
cb3 = convolution_block(cb2, num_filters = 8, kernel_size = 8,
strides = 1, padding = 'valid',
activation = 'selu',
pool_kernel = 2, dropout_rate = 0.2)
cb4 = convolution_block(cb3, num_filters = 4, kernel_size = 8,
strides = 1, padding = 'valid',
activation = 'selu',
pool_kernel = 2, dropout_rate = 0.2)
conv_flat = Flatten()(cb4)
cl1 = Dense(128, activation='selu')(conv_flat)
cl2 = Dense(64, activation='selu')(cl1)
cl3 = Dense(32, activation='selu')(cl2)
# Feature block
fl1 = Dense(4, activation='selu')(input_feats)
concat_layer = concatenate([cl3, fl1], axis = 1)
# prediction block
pl1 = Dense(16, activation = 'selu')(concat_layer)
pl2 = Dense(8, activation = 'selu')(pl1)
output = Dense(1, activation = 'sigmoid')(pl2)
return Model([input_img, input_feats], output)
准备输入数据
Keras 中的现成生成器不支持这样的多输入,因此您必须创建自己的定制生成器。
首先对视图位置和年龄箱进行一次性编码,这是您发现的与目标变量相关的两个最重要的特征变量。
bin_labels = ['0_20', '20_40', '40_60', '60_80', '80_plus']
train_labels_w_md['age_bucketed'] = pd.cut(train_labels_w_md['age'].astype(int),
bins = [0, 20, 40, 60, 80, max(train_labels_w_md['age'].astype(int))],
labels = bin_labels)
视图位置已经是一个分类变量,因此可以直接进行一键编码。
from sklearn.preprocessing import LabelBinarizer
age_binarizer = LabelBinarizer()
age_binarizer.fit(train_labels_w_md['age_bucketed'])
transformed_age = age_binarizer.transform(train_labels_w_md['age_bucketed'])
transformed_age_ohe = pd.DataFrame(transformed_age)
transformed_age_ohe.columns = ["age_bin_trans_"+str(i) for i in range(len(age_binarizer.classes_))]
view_pos_binarizer = LabelBinarizer()
view_pos_binarizer.fit(train_labels_w_md['view_pos'])
transformed_view_pos = view_pos_binarizer.transform(train_labels_w_md['view_pos'])
transformed_view_pos_ohe = pd.DataFrame(transformed_view_pos)
transformed_view_pos_ohe.columns = ["view_pos_trans"]
data = pd.concat([train_labels_w_md, transformed_age_ohe, transformed_view_pos_ohe], axis=1)
接下来,为图像数组定义一个预处理函数。除了直方图均衡化和各向同性均衡化,您还可以将图像转换为标准形状,并通过将每个像素除以 255(图像像素的最大值)来归一化像素值。
您还添加了另一个充当通道的维度。这样做是为了满足 Conv2D 层的要求。
def get_train_images(dicom_path, target_shape):
img = pydicom.read_file(dicom_path)
img_equalized = histogram_equalization(img.pixel_array, 4, (8,8))
img_isotropic = resample(img.pixel_array, img.PixelSpacing[1], img.PixelSpacing[0])
img_standardized = cv2.resize(img_isotropic, target_shape, interpolation = cv2.INTER_CUBIC)
# Pixel Standardization
img_standardized = np.array(img_standardized)/255
res = np.expand_dims(img_standardized, axis = 2)
return res
创建训练集和验证集。
from sklearn.model_selection import train_test_split
train, val = train_test_split(data,test_size=0.25, random_state=42)
TARGET_SHAPE = (224,224)
BATCH_SIZE = 32
最后,您创建您的生成器,类似于您在第四章中创建的生成器。你把你的多输入让给了网络。
def get_data_generator(df, target_shape, shuffle = True, batch_size=32):
"""
Generator function which yields the input data and output for different clusters
"""
img, feat_set, y = [], [], []
if shuffle:
df = df.sample(frac=1).reset_index(drop=True)
while True:
for i,row in df.iterrows():
feat_set.append(np.array(row[[x for x in df.columns if "_trans" in x]].tolist()))
img.append(get_train_images(os.path.join(DATA_DIR,"Train",row['patientId'] + ".dcm"), TARGET_SHAPE))
y.append(np.array([row['Target']]))
if len(feat_set) >= batch_size:
yield (np.array(img), np.array(feat_set)), y
img, feat_set, y = [], [], []
培养
在训练中,您分别为训练集和验证集调用generator
函数。请注意,并不总是建议以这种方式创建生成器,因为数据流水线并没有针对预取和创建批处理数据时的许多数据操作进行优化。如果有更多的计算和 RAM 来预处理数据并以期望的格式存储数据,就可以避免这种情况。
train_generator = get_data_generator(train, TARGET_SHAPE, True, BATCH_SIZE)
val_generator = get_data_generator(val, TARGET_SHAPE, True, BATCH_SIZE)
model = build_model()
model.compile(optimizer= 'adam',
loss = tf.keras.losses.BinaryCrossentropy(from_logits=True),
metrics=METRICS)
如图 8-24 所示,基于架构,训练参数的数量为 526,217,与我们对 ImageNet 等超大型图像模型的预期相差甚远。因此,根据您的计算资源,可以随意构建不同的架构,并试验性能和收敛速度。
图 8-24
模型摘要
history = model.fit(train_generator,
steps_per_epoch= len(train)//BATCH_SIZE,
epochs=10,
validation_data=val_generator,
validation_steps= len(val)//BATCH_SIZE)
三维图像的图像分割
当我讲述每种方法的主要挑战和主要发展/解决方案时,我已经深入讨论了各种图像分析方法。在本节中,您将重点关注三维图像的图像分割问题。
让我们快速回顾一下图像分割的关键点:
-
什么事?图像分割基于训练数据将给定图像分割成各种片段,也称为感兴趣区域。
-
生物医学细分的主要挑战:
-
捕获图像中的噪声会导致不均匀的强度。
-
目标器官或病变的大小和形状可能因患者而异。
-
病变/目标器官占据整个图像的非常小的区域的类别不平衡可以导致 ML 模型学习更多关于背景或局部最小值的知识。
-
图像预处理
根据您在上一节中对 BRATS 数据的分析以及为 MRI 图像推荐的其他常规预处理,您将执行以下预处理:
-
偏场校正
-
移除不需要的切片
创建用于训练的面片时,您将标准化中心像素强度并忽略空体积。
偏场校正
当捕获 MRI 图像时,偏置场可以通过减少诸如边缘的高频内容来模糊图像。它还影响像素的强度,使得相同的组织显示灰度变化。
对于肉眼来说,这种差异并不意味着什么,但对于 ML 算法来说,它可以产生巨大的差异。让我们纠正这种偏见。为此,您将使用 SimpleITK 库,它提供了 N4 字段校正。N4 偏置场校正算法是一种用于校正 MRI 图像数据中存在的低频强度不均匀性的流行方法,称为偏置场或增益场。更多详情请见 https://simpleitk.readthedocs.io/en/master/link_N4BiasFieldCorrection_docs.html
由于边缘/轮廓受偏置场的影响,您必须使用阈值算法来分离背景和前景像素。为此,您将使用 Otsu 的方法。注意 SimpleITK 库中还有其他自动阈值算法,如最大熵、三角形等。我敦促你尝试这些不同的变化。
Otsu 的方法计算量很大,因此可能需要很长时间才能完成,所以您将把这种偏差校正的结果保存在一个单独的文件夹中。
NEW_BASE_DIR = os.path.join(os.path.split(BASE_DIR)[0],
"PROCESSED_IMAGE")
你首先读取图像,然后使用 Otsu 的方法创建一个遮罩。这个遮罩只包含 1 和 0 来分隔前景和背景像素。之后,使用阈值遮罩对输入图像进行实地校正。
def correct_bias_field(input_path, output_path):
inputImage = sitk.ReadImage(input_path)
maskImage = sitk.OtsuThreshold( inputImage,
0, # Background Value
1, # Foreground Value
250 # Number of Histograms
)
# Casting to allow real pixel value
inputImage = sitk.Cast( inputImage, sitk.sitkFloat32 )
corrector = sitk.N4BiasFieldCorrectionImageFilter()
output = corrector.Execute( inputImage, maskImage)
# Since our original image followed the 16-bit pixel format
outputCasted = sitk.Cast(output,sitk.sitkVectorUInt16)
sitk.WriteImage(outputCasted,output_path)
您可以为每个患者和每个图像序列调用上述函数,并相应地保存结果。
processed_full_data = {}
for patient_id,v in full_data.items():
processed_full_data[patient_id] = {}
for seq,input_path in v.items():
print(f"Started Bias Correction for Patient {patient_id} and Sequence {seq.upper()}")
folder_name = os.path.split(os.path.split(input_path)[0])[-1]
file_name = os.path.split(input_path)[-1]
output_path = os.path.join(NEW_BASE_DIR,folder_name, file_name)
# Automatically create the directory that doesn't exist
if not os.path.exists(os.path.join(NEW_BASE_DIR,folder_name)):
os.makedirs(os.path.join(NEW_BASE_DIR,folder_name))
# Updating the new paths for 4 sequences
if seq == "label":
processed_full_data[patient_id].update({seq:input_path})
else:
processed_full_data[patient_id].update({seq:output_path})
correct_bias_field(input_path, output_path)
break
移除不需要的切片
这是创建训练数据之前预处理流水线的最后一步。将结果保存为 HDF5 文件格式,这样可以将单个数据拼接到一个文件中。
您将保存所有序列堆叠在一起的最终图像文件,以创建大小为4,135,240,240
的 4-D 卷和大小为135,240,240.
的标签卷。您将使用 h5py Python 包来保存 HDF5 文件。
import h5py
NEW_BASE_DIR = os.path.join(os.path.split(BASE_DIR)[0],
"PROCESSED_IMAGE","SLICE_CORRECTED")
# Automatically create the directory that doesn't exist
if not os.path.exists(NEW_BASE_DIR):
os.makedirs(NEW_BASE_DIR)
您运行连续的for
循环来遍历路径,并将 h5py 文件保存在上面创建的新目录中。
for patient_id,v in processed_full_data.items():
image_vol_w_seq = {}
image_mask = []
for seq,input_path in v.items():
image_volume = io.imread(input_path, plugin='simpleitk')
slices_to_keep = np.array([_slice for i,_slice in enumerate(image_volume) if i not in (list(range(5))+list(range(140,155)))])
if seq == "label":
# To enable one-hot encoding of these categories
# we make a continous range of classes from 0 to 3
slices_to_keep[slices_to_keep == 4] = 3
image_mask = np.copy(slices_to_keep)
else:
image_vol_w_seq[seq] = slices_to_keep
final_image = np.stack((image_vol_w_seq['flair'],
image_vol_w_seq['??'],
image_vol_w_seq['t1ce'],
image_vol_w_seq['??'])).astype('float')
# Check individual size of mask and train images
assert image_mask.shape == slices_to_keep.shape
assert final_image.shape == (4, ) + slices_to_keep.shape
# Initialize the HDF5 File
_path = os.path.join(NEW_BASE_DIR, f'{str(patient_id+1).zfill(3)}.h5')
_hf = h5py.File(_path, 'w')
# Use create_dataset to give dataset name and provide numpy array
_hf.create_dataset('X', data = final_image)
_hf.create_dataset('Y', data = image_mask)
# Close to write to the disk
_hf.close()
模型创建
3D U-Net 体系结构的灵感来自 U-Net 体系结构,该体系结构在成为医学图像分割的 SOTA 一段时间后积累了巨大的人气。该架构是由弗赖堡大学与谷歌的 Deepmind 团队合作在题为“3D U-Net:从稀疏注释中学习密集体积分割”的论文中介绍的。图 8-25 是同一篇论文中展示 U-Net 架构的图片。
图 8-25
3D U-Net 架构。蓝框代表要素地图。通道的数量在每个特征图上标出
U-Net 架构由一条收缩路径(左侧)和一条扩张路径(右侧)组成。
收缩路径遵循卷积网络的典型架构。
卷积层之后是非线性激活和汇集操作,以防止过拟合。有时添加 BatchNormalization 或其变体,以确保一批数据中的协变量变化不会突然影响梯度学习过程。协变量变化是观察到的批次间数据分布的变化。
在每个下采样步骤,特征通道被加倍,而扩展路径包括上采样和连接,随后是常规卷积运算。在此路径中,您尝试通过扩展特征尺寸来恢复压缩特征。从图 8-25 中绿色箭头指示的收缩路径中,以符合所需特征图形状的方式进行上采样。
U-Net 的主要优势在于,在进行上采样的同时,您还可以连接来自编码器/收缩网络的特征图。
让我们开始编码吧。首先,导入创建模型所需的相关层。
from tensorflow.keras.models import Model
from tensorflow.keras.layers import (
Input,
Activation,
Conv3D,
Conv3DTranspose,
MaxPooling3D,
UpSampling3D,
SpatialDropout3D,
concatenate,
BatchNormalization
)
from tensorflow.keras.optimizers import Adam
您创建了卷积块,它基本上创建了收缩和扩展路径。就像前几章一样,您继续使用 SELU 作为您的首选激活方式。
您还将使用批处理规范化层,它处理一批数据的协变量变化。您可以通过使用标志变量来控制它的使用。
需要注意的一点是data_format = 'channels_first'
的使用。这样做是为了告诉层,输入图像具有作为第一维的通道。不要迷茫;你的输入图像实际上是一个五维张量。
5+D tensor with shape: batch_shape + (channels, conv_dim1, conv_dim2, conv_dim3) if data_format='channels_first' or 5+D tensor with shape: batch_shape + (conv_dim1, conv_dim2, conv_dim3, channels) if data_format='channels_last'
def convolution_block(input_layer, n_filters, batch_normalization=False,
kernel=(3, 3, 3), activation='selu',
padding='same', strides=(1, 1, 1)):
"""
Creates Convolutional Block
"""
layer = Conv3D(n_filters, kernel, activation = 'selu', data_format = 'channels_first', padding = padding, strides = strides)(input_layer)
if batch_normalization:
layer = BatchNormalization(axis=1)(layer)
return layer
有时,为了防止过拟合,您可能希望在最大池化之后添加一个 dropout 层,但为了简单起见,我们不介绍它。对于那些想要的人,可以通过将 max-pooling 输出传递给 Dropout 层来添加SpatialDropout3D layer
。
以类似的方式,对于扩展路径,定义一个上卷积运算。要得到同样大小的图像,有各种方法。在下面的函数中,你可以看到其中的两个,分别是反卷积和上采样。
-
去卷积:使用滤波器、内核、填充和步长,就像卷积层一样,以获得所需大小的图像。
-
上采样:通过传递用于压缩图像的池大小,将图像调整到所需的大小。
def up_convolution(n_filters, pool_size, kernel_size = (2, 2, 2),
strides = (2, 2, 2),
deconvolution = False):
if deconvolution:
return Conv3DTranspose(filters=n_filters, data_format = 'channels_first',
kernel_size=kernel_size, strides=strides)
else:
return UpSampling3D(size=pool_size, data_format = 'channels_first')
创建如图 8-25 所示的架构,并返回模型进行训练。
def unet_model_3d(loss_function, input_shape=(4, 24, 160, 160),
pool_size = 2, n_labels = 3,
initial_learning_rate = 0.001,
deconvolution=False, depth = 4, n_base_filters = 32, metrics=[],
batch_normalization = True):
"""
U-Net 3D Model
"""
# Input Layer for the Image patch
inputs = Input(input_shape)
current_layer = inputs
levels = list()
# add levels with max pooling
for layer_depth in range(depth):
layer1 = convolution_block(input_layer = current_layer,
n_filters = \
n_base_filters * (2 ** layer_depth),
batch_normalization = \
batch_normalization)
layer2 = convolution_block(input_layer=layer1,
n_filters = \
n_base_filters * (2 ** layer_depth)* 2,
batch_normalization = \
batch_normalization)
# Do Max-Pooling until reaching the bridge
if layer_depth < depth - 1:
current_layer = MaxPooling3D(pool_size = pool_size, data_format = 'channels_first')(layer2)
levels.append([layer1, layer2, current_layer])
else:
current_layer = layer2
levels.append([layer1, layer2])
# add levels with up-convolution or up-sampling
for layer_depth in range(depth - 2, -1, -1):
up_convolution_layer = up_convolution(pool_size = pool_size,
deconvolution = deconvolution,
n_filters = \
current_layer.shape[1])(current_layer)
# Concatenate Higher and Lower Dimensions
concat = concatenate([up_convolution_layer, levels[layer_depth][1]], axis=1)
current_layer = convolution_block(
n_filters = levels[layer_depth][1].shape[1],
input_layer = concat, batch_normalization = batch_normalization)
current_layer = convolution_block(
n_filters=levels[layer_depth][1].shape[1],
input_layer=current_layer,
batch_normalization=batch_normalization)
final_convolution = Conv3D(n_labels, (1, 1, 1),
data_format = 'channels_first',
activation = 'sigmoid')(current_layer)
model = Model(inputs = inputs, outputs = final_convolution)
if not isinstance(metrics, list): metrics = [metrics]
model.compile(optimizer=Adam(lr = initial_learning_rate),
loss = loss_function,
metrics=metrics)
return model
准备输入数据
为了准备输入数据,您需要了解在给定上述模型的情况下,分段在理想情况下是如何工作的。
理想情况下,您希望获得全部信息,无论是通道/序列、深度维度等。,用于创建训练标签,但正如您可以从一个 HDF5 文件的文件大小中看到的,解压缩时图像大小将非常大。此外,这将导致卷积时更大的核大小,因此需要学习更多的参数。
所以你只能选择这两种方法之一:
-
通过一次输入堆叠图像中的每个切片和标签图像中的相应切片来训练分割模型,就像进行 2-D 卷积一样,但随后您会有意放弃沿深度维度呈现的空间信息,这些信息在确定肿瘤类型方面起着关键作用。一些肿瘤在轴位视图中看起来很小,但在矢状位视图中可能非常明显,因此这种技术不能提供良好的结果。
-
您还可以从体积立方体创建小的三维体积块,并捕获所有维度的空间信息。现在,这不是一个完美的技术,因为您可能会错过一些托管信息,但它允许您一次捕获更多的信息,因此它是首选的。
如图 8-26 所示,您将创建您的培训数据。根据每个图像卷需要的最大补丁数,重复此过程 n 次。
图 8-26
创建训练数据补丁的详细流程
首先创建标准化函数,该函数将像素亮度集中到平均值 0 和标准偏差 1。对于每个序列,你在深度上循环,在(240,240)
图像上居中缩放,然后将它们堆叠在同一个地方。
import tensorflow as tf
def standardize(image):
"""
Centers the image with mean of zero and sd = 1
"""
# initialize to array of zeros, with same shape as the image
standardized_image = np.zeros(image.shape)
# iterate over sequences
for c in range(image.shape[0]):
# iterate over the depth dimension
for z in range(image.shape[1]):
image_slice = image[c,z,:,:]
# subtract the mean from image_slice
centered = image_slice - np.mean(image_slice)
# divide by the standard deviation (only if it is different from zero)
if np.std(centered):
centered_scaled = centered / np.std(centered)
standardized_image[c, z, :, :] = centered_scaled
else:
standardized_image[c, z, :, :] = image_slice
return standardized_image
接下来,为训练数据创建所需的修补程序。为此,您需要为新图像创建一个新文件夹。
NEW_BASE_DIR = os.path.join(os.path.split(BASE_DIR)[0],
"FINAL_TRAIN_IMAGE")
# Automatically create the directory that doesn't exist
if not os.path.exists(NEW_BASE_DIR):
os.makedirs(NEW_BASE_DIR)
函数中发生了很多事情,但这些是正在发生的主要步骤。
-
首先,获取所需的补片大小、标签图像中的类别数量,以及从患者图像中获取所需补片数量的尝试次数。您需要尝试多次,因为您希望阈值至少有 4%的肿瘤区域。
-
通过为所有轴(x、y 和 z)选择一个随机起点,可以选择一个随机面片。
-
由于您选择了多个修补程序,因此修补程序的起点可能会相同。为此,您可以为已经选择的起点维护一个单独的列表,以避免任何重叠。
-
您可以比较轴元组或单个轴点。我选择了前者,但你可以尝试任何一个。
-
-
在标签图像上,您引入了一个新的维度,对肿瘤标签进行一次性编码。正如您在下面的代码示例中看到的,引入了一个新的维度。
您将使用tf.keras.utils.to_categorical
来完成这项工作。
Converts a class vector (integers) to binary class matrix.
Read more at https://www.tensorflow.org/api_docs/python/tf/keras/utils/to_categorical
tf.keras.utils.to_categorical([0,1,2,3], num_classes=4)
输出:-
array([[1., 0., 0., 0.],
[0., 1., 0., 0.],
[0., 0., 1., 0.],
[0., 0., 0., 1.]], dtype=float32)
tf.keras.utils.to_categorical([[0,1,0,3],[0,0,2,3]], num_classes=4)
输出:
-
您删除了背景类,因为您对预测它不感兴趣。但是请记住,这仍然不会改变由于不平衡导致的稀疏性。
-
如果通过了背景比率,则标准化输入图像并将其与标签一起保存。
array([[[1., 0., 0., 0.],
[0., 1., 0., 0.],
[1., 0., 0., 0.],
[0., 0., 0., 1.]],
[[1., 0., 0., 0.],
[1., 0., 0., 0.],
[0., 0., 1., 0.],
[0., 0., 0., 1.]]], dtype=float32)
Note
对于那些想知道如何决定输出尺寸的人,请重温裁剪图像练习。你可以看到,在对头骨进行裁剪后,你剩下的尺寸是 164,123,因此你可以选择一个想要的尺寸(180, 160).
def get_multiple_patchs(image, label, patient_id,
save_dir,
out_dim = (180,160,24),
num_classes = 4,
max_tries = 1000,
num_patches = 5,
background_threshold=0.96):
"""
Extract random sub-volume from original images.
"""
num_channels, orig_z, orig_x, orig_y = image.shape
out_x, out_y, out_z = out_dim
all_patches = []
tries = 0
# try until you fail :P
prev_start = []
while (tries < max_tries) and (len(all_patches) < num_patches):
# Start from the corner randomly sample a voxel (volume box)
start_x = np.random.randint(0, orig_x - out_x + 1)
start_y = np.random.randint(0, orig_y - out_y + 1)
start_z = np.random.randint(0, orig_z - out_z + 1)
# Make sure you are choosing a unique starting point each time
while (start_x,start_y,start_z) in prev_start:
start_x = np.random.randint(0, orig_x - out_x + 1)
start_y = np.random.randint(0, orig_y - out_y + 1)
start_z = np.random.randint(0, orig_z - out_z + 1)
# extract relevant area of label
y = label[start_z: start_z + out_z,
start_x: start_x + out_x,
start_y: start_y + out_y]
# One-hot encode the tumor categories to add a 4-th dimension
y = tf.keras.utils.to_categorical(y,num_classes)
# compute the background ratio
bgrd_ratio = np.sum(y[:,:,:,0])/(out_x*out_y*out_z)
# increment tries counter
tries += 1
# check if background ratio is less than the maximum background
# threshold
if bgrd_ratio < background_threshold:
# make copy of the sub-volume and take all the channels/seq
X = np.copy(image[:,
start_z: start_z + out_z,
start_x: start_x + out_x,
start_y: start_y + out_y])
X_std = standardize(X)
# we will also make sure that we bring the num class dimension
# as the first axis
y = np.moveaxis(y, 3, 0)
# Exclude the background class as we don't want to predict it
y = y[1:, :, :, :]
all_patches.append([X_std, y])
# Initialize the HDF5 File
_path = os.path.join(save_dir, f'{str(patient_id).zfill(3) + "_" + str(len(all_patches))}.h5')
_hf = h5py.File(_path, 'w')
# Use create_dataset to give dataset name and provide numpy array
_hf.create_dataset('X', data = X_std)
_hf.create_dataset('Y', data = y)
# Close to write to the disk
_hf.close()
return all_patches
最后,将所有补丁保存到培训目录中。
processed_path = glob.glob(os.path.join(os.path.join(os.path.split(BASE_DIR)[0],
"PROCESSED_IMAGE","SLICE_CORRECTED"),"*.h5"))
for _path in processed_path:
with h5py.File(_path, 'r') as f:
_image = f.get("X")
_label = f.get("Y")
_patient_id = int(os.path.split(_path)[-1].replace(".h5",""))
x = get_multiple_patchs(_image, _label, _patient_id, NEW_BASE_DIR,
out_dim = (180, 160,24),
num_classes = 4,
max_tries = 1000,
num_patches = 5,
background_threshold=0.96)
您现在已经准备好训练您的模型了。
培养
由于您正在处理大量的大尺寸图像,通常不建议一次加载所有的图像。您将使用发电机加载它们。
并非 TensorFlow-Keras 中的所有现成生成器都需要图像文件。有一些出色的生成器,但遗憾的是,它们不适用于 HDF5 数据,因此您必须自己编写一个。
Note
感兴趣的,可以查看本次回购的flow_from_* functions
:https://keras.io/api/preprocessing/image/
.
我正在利用这个优秀教程中的代码。跟随它来拓宽你的理解: https://stanford.edu/~shervine/blog/keras-how-to-generate-data-on-the-fly
.
发电机的代码在本书的 GitHub repo 中共享。一定要去看看。
为了训练你的模型,最后一步是确定一个损失函数。最有可能的是,在处理多个类时,您会倾向于选择交叉熵损失,但交叉熵损失在处理高度不平衡的数据集时并不那么有效。
我们来了解一下。图 8-27 显示了前景像素用 1 表示,背景像素用 0 表示的图像块。
图 8-27
实际和预测的图像像素
您可以看到,随着非重叠区域的增加,二进制交叉熵几乎呈线性增加,而 dice 系数即使没有重叠也不会达到 0,也没有线性减少,因此它可以更好地处理不平衡。
邹等人的题为“基于空间重叠指数的图像分割质量的统计验证”的论文详细讨论了 dice 系数和统计验证。我强烈建议你看一看。
您可以根据您的用例进一步调整损失函数。因为您的 DL 模型实际上输出的不仅仅是概率,所以您可以修改实际的 Dice 函数来处理概率而不是实际的二进制值。
该书的 GitHub repo 中分享了 Soft Dice 函数的代码,可以查看一下。另外,请注意,在阅读一些论文时,您不会发现任何关于 epsilon 的内容,但在实施过程中您会发现。不要害怕;在现实世界的实现中,使用拉普拉斯平滑来避免除法误差是一种常见的做法。
最终确定损失函数后,您现在需要一个性能指标来评估训练好的模型。为此,您将使用 dice 系数,这是评估细分模型性能的标准指标。
from tensorflow.keras import backend as K
K.set_image_data_format("channels_first")
def dice_coefficient(y_true, y_pred, axis=(1, 2, 3),
laplace_smoothing_factor=0.00001):
dice_numerator =2 *K.sum(y_pred*y_true,axis) + laplace_smoothing_factor
dice_denominator = K.sum(y_pred,axis) + K.sum(y_true,axis) + laplace_smoothing_factor
# For multiple classes take the mean across each axis
dice_coefficient = K.mean(dice_numerator/dice_denominator)
return dice_coefficient
def soft_dice_loss(y_true, y_pred, axis=(1, 2, 3),
laplace_smoothing_factor=0.00001):
"""
Compute mean soft dice loss over all Multiple classes.
"""
dice_numerator =2 *K.sum(y_pred*y_true,axis) + laplace_smoothing_factor
dice_denominator = K.sum(y_pred**2,axis) + K.sum(y_true**2,axis) + laplace_smoothing_factor
dice_loss = 1 - K.mean(dice_numerator / dice_denominator)
return dice_loss
详见 https://en.wikipedia.org/wiki/S%C3%B8rensen%E2%80%93Dice_coefficient
。
model = unet_model_3d(depth = 3,
pool_size= 2,
input_shape=(4,180, 160,24),
n_base_filters = 32,
loss_function=soft_dice_loss, metrics=[dice_coefficient])
import h5py
NEW_BASE_DIR = os.path.join(os.path.split(BASE_DIR)[0],
"FINAL_TRAIN_IMAGE")
all_patches = glob.glob(os.path.join(NEW_BASE_DIR,"*.h5"))
from sklearn.model_selection import train_test_split
train_data, val_data = train_test_split(all_patches, test_size=0.33, random_state=42)
BATCH_SIZE = 5
# Get generators for training and validation sets
train_generator = BatchDataGenerator(train_data, batch_size = BATCH_SIZE, dim = (180, 160, 24))
valid_generator = BatchDataGenerator(val_data, batch_size = BATCH_SIZE, dim = (180, 160, 24))
为了训练一个生成器,你要传递一个叫做steps per epoch.
的东西,这个步骤数就是一批中要训练的样本数。如果有 100 个训练样本,您想要 5 个批次,那么steps per epoch = ceil(num_samples/batch_size).
steps_per_epoch = len(train_data)//BATCH_SIZE
n_epochs=10
validation_steps = len(val_data)//BATCH_SIZE
history = model.fit(train_generator,
steps_per_epoch=steps_per_epoch,
epochs=n_epochs,
use_multiprocessing=False,
validation_data=valid_generator,
validation_steps=validation_steps)
Epoch 1/10
120/120 [==============================] - 860s 7s/step - loss: 0.3559 - dice_coefficient: 0.4725 - val_loss: 0.4446 - val_dice_coefficient: 0.4008
Epoch 2/10
120/120 [==============================] - 802s 7s/step - loss: 0.3350 - dice_coefficient: 0.5026 - val_loss: 0.3232 - val_dice_coefficient: 0.5164
Epoch 3/10
120/120 [==============================] - 4914s 41s/step - loss: 0.3214 - dice_coefficient: 0.5237 - val_loss: 0.5040 - val_dice_coefficient: 0.3589
Epoch 4/10
120/120 [==============================] - 505s 4s/step - loss: 0.3194 - dice_coefficient: 0.5305 - val_loss: 0.3561 - val_dice_coefficient: 0.4605
Epoch 5/10
120/120 [==============================] - 517s 4s/step - loss: 0.3023 - dice_coefficient: 0.5525 - val_loss: 0.4168 - val_dice_coefficient: 0.4328
Epoch 6/10
120/120 [==============================] - 518s 4s/step - loss: 0.3066 - dice_coefficient: 0.5552 - val_loss: 0.3478 - val_dice_coefficient: 0.5206
Epoch 7/10
120/120 [==============================] - 521s 4s/step - loss: 0.3054 - dice_coefficient: 0.5596 - val_loss: 0.3900 - val_dice_coefficient: 0.4615
Epoch 8/10
120/120 [==============================] - 238s 2s/step - loss: 0.2914 - dice_coefficient: 0.5746 - val_loss: 0.3515 - val_dice_coefficient: 0.5378
Epoch 9/10
120/120 [==============================] - 294s 2s/step - loss: 0.3022 - dice_coefficient: 0.5697 - val_loss: 0.3471 - val_dice_coefficient: 0.5286
Epoch 10/10
120/120 [==============================] - 374s 3s/step - loss: 0.2864 - dice_coefficient: 0.5864 - val_loss: 0.3279 - val_dice_coefficient: 0.5300
性能赋值
参见图 8-28 获取您的模型的性能和损耗图。注意,该模型表现良好,dice 系数随着每个时期而提高。有多种方法可以通过使用更大的过滤器尺寸和漏失来进一步改进模型,以防止过拟合和更大的深度。
图 8-28
三维 U-Net 模型的性能和损耗图
医学图像的迁移学习
迁移学习是一种从大型预训练网络中释放知识的方法,该网络在巨大的带注释的数据集上进行训练,以解决领域或其训练目的之外的任务。现在几乎所有的计算机视觉问题都使用迁移学习,实现 SOTA。
但与自然图像不同,自然图像始终由三个通道组成,不同集合之间的差异较小,医学图像可以有根本的不同。它们不仅可以具有变化的通道长度,而且这些图像的像素强度是基于医疗设备及其应用的物理特性来决定的。
因此,让我们了解迁移学习是否适用于医学图像,如果不适用,我们可以做些什么来实现它。
这一部分主要从谷歌大脑和康奈尔大学在 NIPS 2019 年发表的题为“输血:理解医学成像的迁移学习”的论文中提取思想。
作者试图通过以下方式来理解它:
-
性能评估:将从随机初始化训练并直接应用于任务的模型与在 ImageNet 上为相同任务预先训练的模型进行比较。
-
表示分析:使用典型相关分析比较不同模型的隐藏表示。
-
对收敛的影响:由于特征被重用,模型收敛所需的时间显著减少。
在这三个实验的基础上,作者得出了如下结论:
-
迁移学习不会显著影响医学成像任务的表现。这意味着预训练模型是过度参数化的。
-
对于上下文,ImageNet 有 1000 个类别,而医学图像有小得多的预测向量。
-
输入图像的大小差别很大。作为 ImageNet 一部分的自然图像具有 224 X 224 的尺寸,而医学图像可以具有更大的尺寸,例如 512 X 512、1024 X 1024 等。这种图像太大,不能直接输入神经网络。
-
-
发现使用来自网络的最后两层的预先训练的权重对收敛具有最大的影响。
我认为医学图像的迁移学习不如自然图像有效。对于医学图像,我们仍然应该坚持基于图像的形态和手头的任务来训练我们自己的网络的方法。
结论
祝贺你读完了这么长的一章。本章涵盖了与医学图像和人工智能应用相关的广泛信息。您探索了用于捕捉医学图像的不同模态,以及某些模态如何对某些解剖结构有用。然后,我们讨论了存储这些图像的两种不同格式 DICOM 和 NIFTI,以及如何利用与图像相关的元数据来拓宽您对所接收图像的理解。通常,这被更令人着迷的深度学习应用程序忽略了,但元数据包含了很多见解。最后,您了解了二维和三维图像以及可用于解决分类和分割问题的不同架构。
你在本章中学到的东西有巨大的现实价值。分类和定位可用于疾病筛查,也可用于紧急诊断和偶然发现。最近,随着 COVID 病例的大量出现,导致医护人员意外短缺,像分割这样的技术被用于诊断病情危急的患者,因此他们首先得到护理。分割还有助于识别肿瘤轮廓和肿瘤学放射治疗的敏感区域。
最后,深度学习和医疗人工智能领域不仅限于分类或细分。还有许多其他领域有应用高级人工智能技术的巨大潜力,例如从不同序列捕获的图像的配准,从设备到医生可用的图像的重建,以及具有临床背景的图像检索,可以帮助医生跟踪过去对一个病例做了什么。
参考
-
B.H. Menze,A. Jakab,S. Bauer,J. Kalpathy-Cramer,K. Farahani,J. Kirby,等人,“多模态脑肿瘤图像分割基准(BRATS),”IEEE 医学成像汇刊 34(10),1993-2024(2015)DOI:10.1109/TMI . 20143676
-
南 Bakas,H. Akbari,A. Sotiras,M. Bilello,M. Rozycki,J.S. Kirby 等人,“利用专家分割标签和放射特征推进癌症基因组图谱神经胶质瘤 MRI 集合”,自然科学数据,4:170117(2017)DOI:10.1038/sdata . 2017.117。
-
南 Bakas,M. Reyes,A. Jakab,S. Bauer,M. Rempfler,a .克里米等人,“在 BRATS 挑战中确定用于脑肿瘤分割、进展评估和总体生存预测的最佳机器学习算法”,arXiv 预印本 arXiv:1811.02629 (2018)。
-
南 Bakas,H. Akbari,A. Sotiras,M. Bilello,M. Rozycki,J. Kirby 等人,“TCGA-GBM 集合术前扫描的分割标签和放射特征”,癌症成像档案,2017 年。DOI:10.7937/K9/tcia . 2017 . klxwjj 1 q。
-
南 Bakas,H. Akbari,A. Sotiras,M. Bilello,M. Rozycki,J. Kirby 等人,“TCGA-LGG 集合术前扫描的分割标签和放射特征”,癌症成像档案,2017 年。DOI:10.7937/K9/tcia . 2017 . gjq 7 r0ef。
九、机器有所有的答案,除了生命的目的是什么
到目前为止,我们已经谈了这么多。向我们致敬。在过去的所有案例中,你都知道你在寻找什么,结果会是什么,无论是高危患者的再入院率,ICD-9 编码预测,还是肿瘤识别。但有时我们有的只是问题,我们不知道可能的答案。无论是医疗保健还是任何其他行业,研究、公司文档或任何公共信息中都嵌入了如此多的知识,有时我们会意识不到,因为浏览如此广泛的信息集会变得有些不知所措。所以,让我们让机器开始工作吧。
在本章中,我将简要回顾问答系统是如何构建的,然后您将使用新冠肺炎数据集为自己构建一个问答系统。我们都面临着疫情的情况,我们对目前药物的副作用以及共病如何影响治疗知之甚少。此外,如果没有像问答这样的技术,理解在如此短的时间内嵌入到数百万个文档中的关于 SARS 病毒家族的信息是不可能的。所以让我们直接进入它。
介绍
医疗保健行业的工作人员以及普通公众需要通过一个系统快速有效地访问生物医学信息,该系统能够理解复杂的生物医学概念,并能够找到最佳文档来支持特定的响应。
问答领域的研究,尤其是生物医学问答,受到了各种竞赛和会议的推动,如 **TREC ** 文本检索会议(TREC)****和 BioASQ。 BioASQ 组织生物医学语义索引和问答(QA)挑战。这些挑战包括与分层文本分类、机器学习、信息检索、来自文本和结构化数据的 QA、多文档摘要和许多其他领域相关的任务。
**在医疗保健的各个领域,如科学(CORD-19)、临床(emrQA)或消费者健康(MediQA、LiveQA-Med),通过独立研究和竞争发布了各种数据集。
尽管人们对此很感兴趣,但仍面临许多挑战:
-
小型且不复杂的数据集:与 SQUAD v1 和 v2 数据集(通用领域)相比,大多数可用数据集的规模较小,通常不需要复杂的推理。
-
本体和知识库未被利用 : NCBI 和 BioPortal 托管了一堆与医疗领域相关的本体和知识图,但通常一个独立的基于深度学习的解决方案无法利用它们。某些最近的论文正在出现,它们丰富了训练时的嵌入,或者使用现有的知识库对检索到的文档进行重新排序。
-
缺乏可解释性:由于医疗保健领域的性质,有时对特定答案的解释可以帮助用户更好地理解推理,并相应地树立信心。
总的来说,问答系统大致有四种主要类型:
-
开放/封闭领域:在检索器(信息检索)和阅读器/生成器(机器理解)框架中,来自知识源的大量段落被编码并存储在内存中。检索模型能够查询存储器以识别与问题嵌入具有最大内积的顶部相关段落。
-
知识库:将查询转换为 RDF 三元组,并基于知识或本体(如 DbPedia 或语义图)回答问题。
-
问题蕴涵:重用来自训练数据库中类似问题的答案来制定响应。
-
视觉问答:从图像中回答问题。
你将在一个基于 IR 的 QA 系统上工作。这些系统从大量文档中查找并提取文本片段,并且是真实世界 QA 的最接近的实现,因为您首先决定从哪些文档中查找答案,然后查找答案。
除非你生活在岩石下,否则你一定用过谷歌。让我们在 Google 上搜索一个问题,它非常“简单”,是一个最近集成了 QA 功能的 IR 系统。见图 [9-1 。
图 9-1
谷歌搜索
如您所见,查询产生了
-
链接列表,其中每个链接的相关段落突出显示了某些关键字
-
给出实际答案的片段框
后端的 Google 使用多种技术来
-
检索文档
-
突出那些文档中的重要关键词/段落
-
给出最终答案
-
从给定的查询重构多个查询
-
搜索历史
但是对于我们这些普通人来说,我们可以简单地理解 IR-QA 的工作方式,如图 9-2 所示。
-
检索器充当搜索引擎,对相关文档进行排序和检索。
-
理解通常是一个 seq-seq 模型,它试图从上下文中概率性地识别出哪个短语在给定的问题中最有可能被看到,是的,你猜对了:这通常使用问答数据集来完成。
图 9-2
IR-QA 系统流程图
获取数据
您将使用 CORD-19 数据集来构建您的问答模型。它包括超过 400,000 篇关于新冠肺炎、新型冠状病毒和相关冠状病毒的学术文章。您可以通过在 www.kaggle.com/allen-institute-for-ai/CORD-19-research-challenge
.
报名参加 CORD-19 研究挑战比赛,从 Kaggle 获得该数据集
CORD-19 数据集带有metadata.csv
,这是一个记录 CORD-19 数据集中所有可用论文基本信息的文件。这是一个开始探索的好地方!
除了元数据,Kaggle 上的document_parses
文件夹中还有取自 PubmedCentral 和 pdf(研究期刊-微软)的全文文章。这些是 JSON 文件,包含有关全文文章/PDF 的信息,如 SHA-ID、作者列表、摘要段落列表、全文、参考书目等。
对于这个案例研究,您将只使用metadata.csv
,它包含一篇文章的标题和摘要信息。你可以很容易地扩展你在这里学到的原则,包括全文的段落。这是你应该尝试的事情。
所以现在,从 Kaggle 下载metadata.csv
并把它放在工作目录的./Data
文件夹中。
Note
注意metadata.readme
的出现。这将跟踪数据经历的变化。这些数据由 Allen AI 和其他合作者共同维护。
加载元数据并查看存在的内容:
import os
import pandas as pd
data_dir = "./Data/"
metadata_path = os.path.join(data_dir,"metadata.csv")
metadata_df = pd.read_csv(metadata_path, dtype={'Microsoft Academic Paper ID': str, 'pubmed_id': str})
metadata_df = metadata_df.dropna(subset=['abstract', 'title']).reset_index(drop=True)
metadata_df = metadata_df.drop_duplicates(['abstract', 'title']).reset_index(drop = True)
在这里加载元数据,确保全文 id 存储为字符串。删除缺少摘要和标题或在摘要和标题级别重复的行。这可能是因为主文档可以有多个信息源或引用多个文档(全文)。但是您可以忽略这些细节,继续进行分析。
保留我们在这个案例研究中关注的列。
#Subsetting Columns
final_metadata = metadata_df[['abstract', 'title']]
final_metadata["id"] = [str(i) for i in range(final_metadata.shape[0])]
对于那些计划整合全文以及摘要和标题的人,请访问 https://github.com/allenai/cord19#metadatacsv-overview
以了解不同栏目的含义。
因为您正在处理基于 transformer 的语言模型,所以您知道它们只捕获有限的上下文,最大长度固定为 512 个标记。这意味着如果摘要超过 512 个标记,它将不会被完全使用,剩余的标记将会丢失。此外,这 512 个标记不是从空白分割中得到的,而是转换器自己的内部标记化机制(BERT 架构的 wordpiece)和它使用的词汇。
为了处理这个问题,您将运行一个固定长度和步幅的窗口,并将摘要分成几个较小的块,以根据窗口长度来捕获上下文。图 9-3 显示了如何创建数据以便检索和理解。
因为您将使用针对 MedNLI 进行了微调的 Covid-BERT,所以您将加载它以决定标记化长度(在“检索机制”一节中讨论)。现在,你可以把它想象成一个用于从文本中编码信息的 BERT 模型。
图 9-3
在摘要上运行窗口以防止上下文的突然丢失
您可以从 https://huggingface.co/Darkrider/covidbert_mednli
下载一个预训练的模型,并在您的工作目录下创建一个名为pretrained_model
的文件夹并保存在那里,或者您可以直接将字符串/Darkrider/covidbert_mednli
传递给AutoTokenizer.from_pretrained().
然后,您可以使用拥抱脸的变形金刚包加载模型。
from nltk.tokenize import sent_tokenize
import numpy as np
from transformers import AutoTokenizer
TOKENIZER = AutoTokenizer.from_pretrained('./pre_trained_model/training_nli_covidbert-mednli/0_Transformer')
MAX_LEN = 300
STRIDE = 1
接下来,编写一个将摘要分成不同段落的函数。
def get_para_segments(text, stride, max_len, id_, title, tokenizer):
"""
Get Running length window of certain length with a particular stride
"""
# tokenizer = AutoTokenizer.from_pretrained('./pre_trained_model/training_nli_covidbert-mednli/0_Transformer')
text_map = {i:sent for i, sent in enumerate(sent_tokenize(text))}
text_lenmap = {i:len(input_id) for i,input_id in enumerate(tokenizer(list(text_map.values()))['input_ids'])}
para = []
i = 0
if len(text_map) > 1:
while i < len(text_map):
for j in text_map.keys():
if j > i:
new_para_sub_len = np.sum(list(text_lenmap.values())[i:j])
if j == (len(text_map) -1):
para.append("".join(list(text_map.values())[i:(j+1)]))
i = 999999 # some big value
if new_para_sub_len <= max_len:
continue
else:
para.append( "".join(list(text_map.values())[i:j]))
i = i+stride
else:
para.append(text_map[0])
# at least 5 words should be there in the paragraph
para = [paragraph for paragraph in para if len(paragraph.split()) > 5]
return [[id_, str(id_) + "_" + str(i), title, paragraph] for i,paragraph in enumerate(para)]
上面的代码中主要发生了三件事:
-
您为摘要的每个句子提供一个 ID。这些句子是从 nltk 的句子标记化得到的。
-
您还可以使用相同的 ID 创建一个映射,并从 BERT 模型中获得标记化后的长度。
-
你继续迭代每个句子,直到你达到最大长度或者是可能的句子的结尾。
最后,在元数据数据帧块上调用该函数。但是在您这样做之前,请确保您在Data
文件夹中创建了一个名为passage
的文件夹。
Pickle 用于将 Python 对象序列化为字节流(1 和 0)。这使得将数据加载到您的工作环境变得容易。
from tqdm import tqdm
import pickle
for i,df in enumerate(np.array_split(final_metadata, 10)):
print(i)
passage_list = [get_para_segments(row["abstract"],STRIDE, MAX_LEN,row["id"], row["title"], TOKENIZER) for i,row in tqdm(df.iterrows())]
with open('./Data/passage/passage_'+str(i)+'.pkl', 'wb') as f:
pickle.dump(passage_list, f)
del passage_list
设计你的问答
如图 9-2 所示,一个 Q & A 系统中有多个组件。主要功能在检索器和阅读/理解模块之间划分。每个模块还包含多个部分,可以根据用例的复杂性和预期性能来删除或添加这些部分。让我们深入研究它们,看看是什么组成了一个检索器模块和一个读取模块。
检索器模块
检索器模块由三个主要部分组成:
-
查询释义
-
检索机制(核心)
-
重新分级
查询释义
查询解释是在语义上询问相同的查询,但在语言上改变它的过程。比如,“服用柯华新有什么好处?”可以转述为“使用科瓦欣的优势是什么?”
可以有多种方式来解释查询。这一点在 NeurIPS 2016 年的一篇论文中得到了很好的体现,该论文题为“具有潜在单词包的释义生成”。傅等人提出,来自 WordNet 的词汇替换,如本体和 seq2seq 模型(生成模型),并不能完全捕捉句子的所有语言方面。这些语言方面可以是
-
形态学:对词根、前缀、后缀等词和词类的研究。(例如说-说-说)
-
同义词:与其他词相似的词(如 big-large、airplane-jet)
-
蕴涵:如果 A 句包含 B 句,那么 B 句不为真,A 句也不为真(例如,天空飞机,球场球拍)
-
转喻:Google、Quora 等搜索引擎。
作者使用源句子中的单词来预测他们的邻居,并使用目标句子中的单词作为目标弓。参见图 9-4 。
图 9-4
深度创成式 BOW 模型示例
论文中使用的数据集不能用于像生物医学这样的专业领域。像 MedSTS 这样的数据集给出了一对相似的生物医学句子,可以用来尝试论文中的观点。
郑等人的另一篇题为“BERT-QE:用于文档重新排序的上下文化查询扩展”的论文关注的不是实际创建新的查询,而是试图从要检索的段落内找到上下文证据。这减少了由于使用本体(不解决多义性和/或用法的语义)或其他基于语法正确性的方法而产生的虚假查询而导致的误报。
它分三个阶段进行:
图 9-5
伯特-QE 模型的第一和第二阶段
-
阶段 1 :从 BM25(基于术语的匹配,在下一节中讨论)中获取前 n 个文档,并使用在 MSMARCO 和 ROBUST04 数据集上训练的微调 BERT 模型来找到相关性分数。这将选择给定查询的相关文档。
-
阶段 2 :对于这些文档中的每一个,你现在选择组块,这些组块是从具有大小为 m 的滑动窗口的文档中提取的子短语,使得两个相邻的组块重叠多达 m/2 个单词。这将选择给定查询的相关块。见图 9-5 。
-
阶段 3 :从阶段 2 中选择的组块与原始查询结合使用,以计算最终的重新排序。首先,使用选择的反馈块和查询相关性分数作为块和文档相关性的权重来评估文档的相关性。
使用𝞪作为超参数,您衡量(查询,文档)和(组块,文档)的相关性分数的重要性。
您将使用第二种方法,因为它是领域不可知的,并且您可以通过使用特定于 COVID 的语料库来捕获语义。您将使用来自 https://huggingface.co/deepset/covid_bert_base
的 deepset 的 Covid 微调模型。
检索机制
检索器生成一组候选段落。由于文档的数量可能非常大,特别是对于开放领域的问答系统,具有高效的检索机制是非常重要的。它可以是基于术语的或基于语义的。
基于术语/短语
查询文本和上下文文本都由向量表示,其中每个维度表示词汇表中的一个单词。现在,由于每个上下文只包含可能术语的子集,它们的术语向量通常是稀疏的。
两个文本(例如一个文档和一个查询)之间的相似性可以通过这些向量之间的点积来计算,同时还可以使用像 TF-IDF 或 BM25 这样的技术来考虑术语的重要性。
BM25 有助于使术语频率饱和,并通过惩罚不包含与查询相关的术语的较大文档来考虑文档长度。如果你有兴趣了解更多,请前往 www.kmwllc.com/index.php/2020/03/20/understanding-tf-idf-and-bm25/
.
为了有效地进行大规模的基于术语的匹配,您需要创建内容的倒排索引。倒排索引是一种散列表,它将单词映射到它们所在的文档。所有主要的搜索引擎,如 Elasticsearch、Solr 和 Anserini,都使用倒排索引来获取给定单词集的文档。
创建倒排索引有三个主要步骤:
-
加载文档。
-
分析一下。
-
删除“我”、“这个”、“我们”、“是”、“一个”等停用词。
-
词干词根使单词规范化。
-
-
制作倒排索引。
- 在一般搜索中,您会找到一个文档,然后找到其中的单词,但是在反向搜索中,您会直接查询术语,然后找到与它们相关的文档 id。
有关倒排索引如何工作的详细信息,请参阅
www.elastic.co/guide/en/elasticsearch/guide/current/inverted-index.html
基于语义的
几乎所有的搜索引擎都提供了传递索引词的同义词的能力,但是词汇表中的潜在术语可能非常大。我们的老朋友嵌入来了,它基本上试图量化你的文本在不同语义类别中的比例。
有各种方法来训练这些嵌入用于语义检索。我们来讨论其中的一些。在深入研究之前,您需要理解,这里可以使用任何类型的嵌入,但是您想要的嵌入是经过“相似性”任务训练的,例如端到端问答、句子相似性、自然语言推理(NLI)等。
-
密集段落检索:使用两个独立的 BERT 网络对段落和查询进行编码,以考虑它们的不同性质,如长度、风格和语法,从而优化两种编码的点积,以更好地对查询-段落对进行排序。
-
NLI:自然语言推理是在给定一个“前提”的情况下,确定一个“假设”是真(蕴涵)、假(矛盾)还是未确定(中性)的任务
-
句子相似度:给定一对句子,标记 1/0,表示句子是否相似,微调模型权重,减少目标和预测标签之间的二元交叉熵损失。
因为你最关心的是相似性任务的优化,这反过来意味着你需要采用最接近理解自然语言的方法,我将只详细讨论基于 NLI 的方法。
给定一对文本,通过预测三类来预测相似性:蕴涵(意思是相似)、矛盾(意思是完全不相似)和中性(意思是前提和假设完全独立)。随着第三种状态的加入,模型能够更好地理解句子。参见图 9-6 。
图 9-6
用 NLI 创造句子表征
Conneau 等人在题为“从自然语言推理数据的通用句子表示的监督学习”的论文中提出了使用 NLI 进行句子表示的想法。
您将使用一个预训练模型,该模型使用不同的 NLI 数据集,尤其是与生物医学领域相关的数据集。这些 MedNLI 数据集是从 PhysioNet 获得的,就是您第一个案例中访问 MIMIC 3 数据集的那个网站。好消息是,你不需要通过任何培训就可以访问这个新的数据集。参见图 9-7 。
图 9-7
physionet mednli 数据
因为您将在您的段落列表中使用大维度嵌入,所以建议对它们进行索引以便快速检索。为此,您将特别使用 FAISS。
FAISS 是一个带有 Python 绑定的 C++库,用于对数百万或数十亿个向量进行向量相似性匹配。更多详情可登陆 https://engineering.fb.com/2017/03/29/data-infrastructure/faiss-a-library-for-efficient-similarity-search/
.
重新分级
重新排名是获得最佳排名段落的最后一枚钉子,问答模型可以在这些段落上运行。
MS-MARCO 是段落重新排序时使用最广泛的数据集。为了重新排序,你需要对一组肯定和否定的段落进行查询。您可以自由选择任何比率的积极与消极的通道进行查询。这个比例也决定了你训练数据的大小。你可以在 https://github.com/microsoft/MSMARCO-Passage-Ranking#ranking-task
.
了解更多
您不能直接将 MS-MARCO 数据集用于您的领域,因为数据集中的大多数问题与医学无关,这会导致训练和评估数据之间的领域不匹配。
为了克服这一挑战,MacAvaney 等人在他们题为“SLEDGE-Z:新冠肺炎文献搜索的零射击基线”的论文中使用了 MedSyn,这是一种针对各种医学状况的外行人和专家术语的词典,以过滤医学问题。是的,你在这里的想法是正确的,你也可以用我们之前讨论过的 UML 本体来代替它,但是这个本体的美妙之处在于这些术语是更一般的人类对话术语,而不是基于科学文献的术语。
因此,您将使用针对排名任务进行微调的 CovidBert 变换器来对结果进行重新排名。
理解
有各种各样的机器理解/问题回答模型/技术利用了最先进的深度学习方法,如神经变分推理模型(VS-NET),具有自匹配注意力的 RNNs,甚至卷积网络。但是在本案例研究中,您将通过 BERT 模型利用 transformer 架构来了解什么是机器理解以及 BERT 是如何理解的。
在第四章中,你学到了很多关于 BERT 架构的知识。如果你还没有读过那一章,请浏览“理解语言建模是如何工作的”一节,以获得更多的了解。
在问答任务中,给你一个问题和一个包含问题答案的段落。目标是从段落中提取给定问题的答案。
用于问答的 BERT
为了准备在 BERT 上训练问答模型的输入,有五个主要步骤。您不必对每一步都进行编码,因为它们是通过使用外部库来处理的。
图 9-8
QnA 的 BERT 输入
-
当使用 BERT 进行问答任务时,您将输入的问题和段落表示为一个单独的压缩序列。
-
[CLS]标记被添加到问题的开头。它在选择答案方面没有任何作用,但是浓缩了问题的上下文。
-
[SEP]标记加在问题和文章的末尾。
-
BERT 还使用片段嵌入来区分问题和包含答案的段落。BERT 创建了两个片段嵌入,一个用于问题,另一个用于段落,以区分问题和段落。然后,这些嵌入被添加到标记的一次性表示中(使用标记嵌入的 BERT 标记化),以在问题和段落之间进行分离。
-
还向每个标记添加位置嵌入,以指示其在序列中的位置。参见图 9-8 。
当我说模型必须从段落中提取答案时,它实际上必须返回包含答案的文本跨度。这是通过找到文本范围的开始和结束索引来完成的。
在微调过程中,您只需要引入一个起始向量 S 和一个结束向量 E。单词 I 作为答案范围的开始的概率被计算为 T i 和 S 之间的点积,随后是段落中所有单词的 Softmax。类似地,存在 E 向量来计算结束索引。
这里需要注意的一点是,S 和 E 都是 768 维向量,等于令牌嵌入的维数。对于一次迭代,相同的权重被应用于每个令牌嵌入。参见图 9-9 。
图 9-9
计算答案范围的开始索引
我希望这能非常简洁地回顾一下你是如何为问答培训设置 BERT 的。在本案例研究中,您将使用一个经过微调的 BERT 模型,而不是从头开始进行培训。
如果你想知道 BERT 是如何在问答中施展魔法的,请阅读题为“BERT 如何回答问题?对变压器表示的逐层分析”。它对三个主要参数进行了分析,这三个参数是可解释性、可移植性和模块化。
微调问答数据集
更常见的情况是,您不会仅仅为了问答任务而从头开始训练一个 transformer 模型。您将主要针对各种任务对其进行微调。它的工作原理就像你在前面章节中看到的迁移学习例子一样。
问答有多种形式。在本案例研究中,你将进行提取式问答,包括使用一篇文章作为理解的背景来回答问题,然后突出回答问题的那段文章。这包括微调预测通道中的开始位置和结束位置的模型。
这种任务的一个流行数据集是小队数据集。它由一组维基百科文章上的 10 万多个问题组成,每个问题的答案都是相应段落的文本片段。SQUAD 2.0 更进一步,将 100k 个问题与 50k+个看起来类似于可回答问题的不可回答问题结合在一起。
在 https://huggingface.co/graviraja/covidbert_squad
的阵容数据集中有一个微调过的伯特模型。你将使用这个模型来完成你的理解任务。
如果你想从头开始学习如何微调预训练模型,拥抱脸在 h ttps://huggingface.co/transformers/custom_datasets.html#question-answering-with-squad-2-0
提供了一个很好的教程。
最近发布了 COVID-QA ( https://github.com/deepset-ai/COVID-QA
),这是一个由 2019 个带注释的问题/答案对组成的问题回答数据集。它可以用来进一步微调你的理解模型。我把这个任务留给你去尝试。
最终设计和代码
根据您对 QnA 不同组成部分的理解,让我们快速列出设计 QnA 所需的步骤。参见图 9-10 。
图 9-10
问答系统设计
步骤 0:准备文档数据
首先加载在将摘要转换成段落后保存的 pickle 文件。
import glob
import pickle
import pandas as pd
all_metadata = []
for i,files in enumerate(glob.glob("./Data/passage/passage_*.pkl")):
with open(files, 'rb') as f:
data_list = pickle.load(f)
all_metadata.extend([data_pair for data in data_list \
for data_pair in data])
all_metadata_df = pd.DataFrame(all_metadata, columns = ["id","passage_id","title","passage"])
第一步:伯特-QE 扩展
步骤 1.1:使用 BM-25 提取查询的前 k 个文档
由于有很多文档,我将从all_metadata_df
中随机抽取 50,000 个段落来执行相关任务。如果您有大量的 RAM,您可以尝试使用全部或部分数据。
由于 BM-25 处理术语,您需要对这些术语的段落进行标记,以创建一个倒排索引,然后使用 BM-25 检索它们。在本练习中,您将使用 rank-bm25 软件包。
对于 tokenizers,您将使用一个基本的 spacy 包。请注意,spacy 发布了 3.0 版,它大量使用了转换器来提高准确性,但对于我们的目的(即标记化)来说效率不高。以前的空间流水线非常精确,因此您将使用 en_core_web_sm 空间包来实现这一目的。
from spacy.tokenizer import Tokenizer
from spacy.lang.en import English
nlp = English()
# Create a blank Tokenizer with just the English vocab
tokenizer = Tokenizer(nlp.vocab)
您将创建一个名为BM25RankedResults
的类,它基本上是索引您的数据,而允许您查询并返回前 200 个文档。
from rank_bm25 import BM25Okapi
import numpy as np
class BM25RankedResults:
"""
BM25 Results from the abstract.
Usage:
bm25 = BM25RankedResults(metadata_df) # metadata_df is a pandas dataframe with 'title' and 'abstract' columns
topbm25 = bm25.search("What is coronavirus", num=10) # Return `num` top-results
"""
def __init__(self, corpus: pd.DataFrame):
self.corpus = corpus
self.columns = corpus.columns
token_list = pd.Series([[str(token) for token in doc if str(token)] \
for doc in tokenizer.pipe(corpus.passage,
batch_size=5000)])
self.index = token_list.to_frame()
self.index.columns = ['terms']
self.index.index = self.corpus.index
self.bm25 = BM25Okapi(self.index.terms.tolist())
self.bm25 = BM25Okapi(token_list)
def search(self, query, num = 200):
"""
Return top `num` results that better match the query
"""
search_terms = query.split()
doc_scores = self.bm25.get_scores(search_terms) # get scores
ind = np.argsort(doc_scores)[::-1][:num] # sort results
results = self.corpus.iloc[ind][self.columns] # Initialize results_df
results['score'] = doc_scores[ind] # Insert 'score' column
results = results[results.score > 0]
return results.passage_id.tolist()
passage_data = all_metadata_df.sample(50000)
bm25 = BM25RankedResults(passage_data) # Covid Search Engine
如果您想处理完整的数据并有足够的内存,您还可以加载一个预构建的 Lucene 索引,并使用 Pyserini (v 9.3.1)通过 BM-25 进行查询。它速度更快,可扩展性更强。
我已经为用于创建段落的配置构建了索引,该配置的步幅为 1,段落的最大长度为 300 个标记。你可以从 https://drive.google.com/file/d/1A824rH3iNg8tRjCYsH2aD50YQMNR6FVI/view
那里得到那些文件。
现在加载预构建的二进制文件并调用SimpleSearcher
类。
from pyserini.search import SimpleSearcher
bm25 = SimpleSearcher('./Data/indexes')
# Example
search_hits = bm25.search('what is coronavirus', k= 200)
bm25_passage = [hit.docid for hit in search_hits]
我将对剩下的代码使用pyserini
方法,因为它非常快,但是BM25RankedResults
类在处理文本方面提供了更多的灵活性(清理、词汇化等)。)也可以使用。
步骤 1.2:前 200 个文档的相关性分数
如上一节所述,计算相关性分数有三个阶段。尽管作者在不同阶段试验了不同的变压器模型,但对于您的用例,您将使用来自 deepset 的 covidBert 模型,该模型可从 https://huggingface.co/deepset/covid_bert_base
.
获得
要加载这个模型,您将使用句子转换器库。这是一个优秀的库,可以快速帮助计算句子和段落的密集向量表示。它支持各种变压器网络,如伯特,罗伯塔,XLM 罗伯塔等。
如果您的笔记本电脑上安装了 cuda,您还可以将设备传递给模型来执行操作。对于 NVIDIA-GPU 卡可以通过device = 'cuda'
。
from sentence_transformers import SentenceTransformer,util
covid_bert = SentenceTransformer("deepset/covid_bert_base", device = 'cuda')
因为伯特-QE 是基于找到前 n 个段落/组块,所以让我们写一个包装器函数来基于两个向量之间的余弦相似性获得前 k 个值。
首先,使用 covidBert 模型对列表中的所有文本进行编码。
然后,使用来自句子转换器实用程序文件的内置函数来计算余弦分数,然后根据余弦相似度返回前 k 个匹配。
如果您想直接使用余弦得分指标,可以使用一个标志变量。
def get_top_k_vals(list1, list2, k = 100, model = covid_bert, return_cosine_mat = False):
# Compute embedding for both lists
embeddings1 = model.encode(list1, convert_to_tensor = True)
embeddings2 = model.encode(list2, convert_to_tensor = True)
# Compute cosine-similarity
cosine_scores = util.pytorch_cos_sim(embeddings1, embeddings2)
if return_cosine_mat:
return cosine_scores.numpy()
# Select top kd documents/passage
_topkd = np.argsort(cosine_scores.numpy()[0])[::-1][:k]
return _topkd, cosine_scores.numpy()[0][_topkd]
您还需要计算带有 top chunks 的查询的 Softmax,所以让我们定义一个可以处理 NumPy 数组的 Softmax 函数。
def softmax(x):
"""Compute softmax values for each sets of scores in x."""
e_x = np.exp(x - np.max(x))
return e_x / e_x.sum(axis=0)
现在,您已经准备好编写主函数了。这些步骤如原始文件中所述。为了更好地理解,我将代码注释成不同的阶段。
from collections import OrderedDict
def bert_qe(query, bm25_model, passage_id_map, bert_model = covid_bert,
alpha = 0.4, document_size = 500, chunk_size = 8):
"""
Re-ranks BM-25 document based on relevancy of query to chunks of a passage.
"""
print("\tPhase 1")
# Phase 1
topbm25 = bm25_model.search(query, document_size)
#doc index to passage map
passage_index_map = OrderedDict({idx:passage_id_map[passages] if isinstance(passages,str) \
else passage_id_map[passages.docid] for idx,passages in enumerate(topbm25)})
passageid_index_map = OrderedDict({idx:passages if isinstance(passages,str) \
else passages.docid for idx,passages in enumerate(topbm25)})
_topdocidx, _topdocscores = get_top_k_vals([query],
list(passage_index_map.values()),
k = document_size, model = bert_model)
# Store Top Contextually matching docs
passage_scores = {idx:score for idx,score in zip(_topdocidx, _topdocscores)}
print("\tPhase 2")
# Phase 2
# Create chunks of length "n" and stride them with a length of "n/2"
_chunks = [[" ".join(phrase) for i, phrase in enumerate(nltk.ngrams(passage_index_map[idx].split(), chunk_size)) if i%(chunk_size/2)==0] for idx in _topdocidx]
# Flatten the list
all_chunks = list(chain.from_iterable(_chunks))
# Get top chunks based on relevancy score with the query
_topchunkidx, _topchunkscores = get_top_k_vals([query],
all_chunks,
k = int(len(all_chunks)/2), model = bert_model)
top_chunks = np.array(all_chunks)[_topchunkidx]
# Apply softmax over query and chunk relevancy score,
# This acts as weights to chunk and document relevancy
_topchunksoftmax = softmax(_topchunkscores)
# Phase 3
print("\tPhase 3")
scores = get_top_k_vals(list(passage_index_map.values()),
list(top_chunks),
k = len(top_chunks),
model = bert_model,
return_cosine_mat = True)
# Multiply the weights of chunk with query to relevancy of chunk with the document
# and sum over all the top chunks (kc in the paper)
docchunk_score = np.sum(np.multiply(_topchunksoftmax, np.array(scores)), axis = 1)
# weighing importance of query relevance and query chunk-doc relevance
final_score = alpha*_topdocscores + (1-alpha)*docchunk_score
passage_score = dict(zip([passageid_index_map[idx] for idx in _topdocidx],final_score))
return passage_score
步骤 2:语义段落检索
为了以相当快的速度实现语义检索,您将利用 Faiss。Faiss 是一个用于高效相似性搜索和密集向量聚类的库。它包含在任意大小的向量集中搜索的算法,甚至是不适合 RAM 的向量。
Faiss 只使用 32 位浮点矩阵。这意味着您必须在构建索引之前更改输入的数据类型。
这里,您将使用 IndexFlatIP 索引。这是一个执行最大内积搜索的简单索引。
有关该指数的完整列表,请访问
github.com/facebookresearch/faiss/wiki/Faiss-indexes
首先加载 covidbert-nli 模型,以获得所有段落的编码。这与您用于标记化的模型相同,用于从摘要中创建连续长度的段落。
# Instantiate the sentence-level covid-BERT NLI model
from sentence_transformers import SentenceTransformer,util
covid_nli = SentenceTransformer('./pre_trained_model/training_nli_covidbert-mednli', device = 'cuda')
# Convert abstracts to vectors
embeddings = covid_nli.encode(passage_data.passage.to_list(), show_progress_bar=True)
现在可以编写 Faiss 索引的代码了。请注意 Faiss 不支持字符串 id,因此需要为映射到整数值的passage_ids
创建一个外部映射。
此外,要使用通道向量创建索引,您将
-
将通道向量的数据类型更改为 float32。
-
建立一个索引,并向它传递它将操作的向量的维数。
-
将索引传递给
IndexIDMap
,这个对象使您能够为索引向量提供一个定制的 id 列表。 -
将通道向量及其 ID 映射添加到索引中。
import faiss
# Building FAISS Index
embeddings = np.array([embedding for embedding in embeddings]).astype("float32")
# Instantiate the index
embedding_index = faiss.IndexFlatIP(embeddings.shape[1])
# Pass the passage index to IndexIDMap
embedding_index = faiss.IndexIDMap(embedding_index)
# Numerical map
passage_num_map = {int(i):x for i,x in enumerate(all_metadata_df.passage_id.values)}
# Add vectors and their IDs
embedding_index.add_with_ids(embeddings, np.array(list(passage_num_map.keys()), np.int64))
现在可以使用 Faiss 库保存这个索引。在下一章中,您可以使用这个索引来部署您的问答模型。
faiss.write_index(index, "./Data/faiss_cord-19-passage.index")
您可以使用read_index
命令加载 Faiss 索引。
embedding_index = faiss.read_index("./Data/faiss_cord-19-passage.index")
步骤 3:在 Med-Marco 数据集上使用微调的 Covid BERT 模型进行段落重新排序
您处于检索器步骤的最后一步,其中您使用来自伯特-QE 和语义检索的段落,使用在 Med-Marco 数据集上的重新排序任务中训练的伯特模型进行重新排序。
您可以从 https://huggingface.co/Darkrider/covidbert_medmarco
下载预训练好的模型,并在您的工作目录下创建一个名为pretrained_model
的文件夹并保存在那里。或者可以直接将字符串/Darkrider/covidbert_medmarco
传递给CrossEncoder().
下载后,您可以将它放在您的pre_trained_model
文件夹中。
from sentence_transformers.cross_encoder import CrossEncoder
covid_marco = CrossEncoder("./pre_trained_model/training_medmarco_covidbert")
句子转换器提供了两个包装器函数来比较一对句子。第一种是双编码器,第二种是交叉编码器。
双编码器为给定的句子产生一个句子嵌入。您将句子 A 和 B 独立地传递给 a BERT,这导致了句子嵌入 u 和 v。然后可以使用余弦相似度来比较这些句子嵌入。相比之下,对于交叉编码器,您同时将两个句子传递到转换器网络。它产生一个介于 0 和 1 之间的输出值,表示输入句子对的相似度。
因为对于你的文章排序任务,你不关心单个的嵌入,而是关心两个句子有多相似,你将使用交叉编码器。参见图 9-11 。
图 9-11
句子对的双编码器与交叉编码器
如图 9-10 所示,你现在已经有了来自 BERT 查询扩展技术和语义向量匹配的重要段落。您可以在“检索器”一节中解释的 Med-Marco 数据集上使用微调的 BERT 模型对它们进行重新排序。
为此,您首先要编写一个包装函数来整理第 1 步和第 2 步的结果。这个函数基本上从步骤 1 和 2 中获取段落 id,并将它们传递给经过宏训练的模型(步骤 3)。
def get_ranked_passages(query, bm25_model, bert_model, passage_id_map, faiss_index, bert_qe_alpha = 0.4):
print("Step 1 : BERT-QE Expansion")
#BERT-QE
bertqe_dict = bert_qe(query, bm25_model = bm25_model, passage_id_map = passage_id_map,
bert_model = bert_model, alpha = 0.4, document_size = 500, chunk_size = 8)
print("Step 2 : Semantic Passage Retrieval")
# Semantic Search
_,indices = faiss_index.search(np.expand_dims(covid_nli.encode(query), axis = 0), k=500)
semantic_passage_ids = [passage_num_map[idx] for idx in indices[0]]
# passages to be re-ranked
total_passage_ids = list(bertqe_dict.keys())+ semantic_passage_ids
return list(set(total_passage_ids))
最后,根据查询,检索最终排序的文档。
# Some queries we want to search for in the document
queries = ["What is Coronavirus"]
# Map of Passage id to Passage Text
passage_id_map = pd.Series(all_metadata_df.passage.values,index=all_metadata_df.passage_id).to_dict()
#Search in a loop for the individual queries
for i,query in enumerate(queries):
print(f"Ranking Passages for {i+1} of {len(queries)} query/queries")
passage_ids = get_ranked_passages(query, bm25_model = bm25, passage_id_map = passage_id_map,
bert_model = covid_bert,
faiss_index = embedding_index,
bert_qe_alpha = 0.4)
#Concatenate the query and all passages and predict the scores for the pairs [query, passage]
model_inputs = [[query, passage_id_map[passage_id]] for passage_id in passage_ids]
print("Step 3 : Passage Re-ranking using Fine-Tuned Covid BERT ")
scores = covid_marco.predict(model_inputs)
#Sort the scores in decreasing order
results = [{'input': inp, 'score': score} for inp, score in zip(passage_ids, scores)]
results = sorted(results, key=lambda x: x['score'], reverse=True)
输出
Ranking Passages for 1 of 1 query/queries
Step 1 : BERT-QE Expansion
Phase 1
Phase 2
Phase 3
Step 2 : Semantic Passage Retrieval
Step 3 : Passage Re-ranking using Fine-Tuned Covid BERT
注意,结果是一个包含passage_id
和排名分数的字典。你现在终于准备好做理解了。您还可以根据分数对结果进行分组。让我们保持 0.3 的截止值
final_results = {res_dict['input']:res_dict['score'] for res_dict in results if res_dict['score'] > 0.3}
len(final_results)
输出
107
您将看到如何通过使用智能检索技术,将您的文档搜索空间从 60 万个减少到大约 100 个文档/段落。
第四步:理解
拥抱脸为使用流水线的模型推断提供了一个简单的接口。这些对象抽象出了模型推理的大部分复杂代码。它们涵盖许多任务,例如
-
ConversationalPipeline
-
FeatureExtractionPipeline
-
FillMaskPipeline
-
QuestionAnsweringPipeline
-
SummarizationPipeline
-
TextClassificationPipeline
-
TextGenerationPipeline
-
TokenClassificationPipeline
-
TranslationPipeline
-
ZeroShotClassificationPipeline
-
Text2TextGenerationPipeline
-
TableQuestionAnsweringPipeline
好吧,这些任务中的每一项都值得单独写一章,但是在这个案例研究中,您只关心QuestionAnsweringPipeline
。
from transformers import pipeline
comprehension_model = pipeline("question-answering", model='graviraja/covidbert_squad',tokenizer='graviraja/covidbert_squad', device=-1)
这些参数是
-
task(str)
:定义返回哪条流水线的任务。“问答”返回一个QuestionAnsweringPipeline.
-
model(str or PreTrainedModel(pytorch) or TFPreTrainedModel(Tensorflow))
:流水线用来进行预测的模型。这可以是一个模型标识符(字符串),或者是一个继承自PreTrainedModel
(py torch)或TFPreTrainedModel
(tensor flow)的预训练模型的实际实例。 -
tokenizer (str or PreTrainedTokenizer)
:标记器,流水线将使用它来为模型编码数据。这可以是一个模型标识符,也可以是从PreTrainedTokenizer
继承的一个实际的预训练标记器。如果没有提供,将加载给定模型的默认标记器(如果它是一个字符串)。 -
use_fast (bool)
:如果可能的话,是否使用快速标记器。快速记号赋予器是使用 Rust 实现的。 -
device
是一个 kwarg 参数。您可以使用“-1”来启用 GPU。
要使用它,您需要传递问题和上下文。
# sample example
comprehension_model(question="What is coronavirus", context=all_metadata_df.passage.tolist()[0])
输出
{'score': 0.02539900690317154,
'start': 529,
'end': 547,
'answer': 'community-acquired'}
现在,在 Kaggle 的 CORD-19 任务中,有几组问题被分成九个任务:
-
关于传播、潜伏期和环境稳定性,我们知道些什么?
-
我们对新冠肺炎风险因素了解多少?
-
我们对病毒遗传学、起源和进化了解多少?
-
我们对疫苗和疗法了解多少?
-
我们对非药物干预了解多少?
-
关于医疗保健已经发表了什么?
-
我们对诊断和监控了解多少?
-
关于信息共享和跨部门协作,已经发表了哪些内容?
-
关于伦理和社会科学的考虑已经发表了什么?
这些任务中的每一个都有一组在 COVID 文献搜索中经常被问到的问题,因此您也将包括它们。感谢@kaggle/dirktheeng
整理这些问题。
以下是这些问题的一小部分:
covid_kaggle_questions = [
{
"task": "What is known about transmission, incubation, and environmental stability?",
"questions": [
"Is the virus transmitted by aerosol, droplets, food, close contact, fecal matter, or water?",
"How long is the incubation period for the virus?",
...
]
},
{
"task": "What do we know about COVID-19 risk factors?",
"questions": [
"What risk factors contribute to the severity of 2019-nCoV?",
"How does hypertension affect patients?",
"How does heart disease affect patients?",
...
}
]
让我们回过头来修改问题for
循环,该循环对段落进行排序以便理解,并为每个任务的问题创建一个数据框架。
使用来自 Hugging Face 的问答流水线,您基本上可以得到一个这种形式的字典。
{'score': 0.622232091629833, 'start': 34, 'end': 96, 'answer': 'COVID-19 happens in respiratory tract'}
因为您传递了一个列表passage_ids
(也称为上下文),所以您得到了这个字典的列表。创建理解输出(comp_output
变量)时,确保通过passage_id
和passage_rank
得分。您正在存储这些信息,以便在以后的部署中使用(第十章)。
最后,将所有任务中每个问题的所有答案存储在名为all_comprehension_df.
的数据帧中
# Map of Passage id to Passage Text
passage_id_map = pd.Series(all_metadata_df.passage.values,index=all_metadata_df.passage_id).to_dict()
# Numerical map for semantic passage retrieval
passage_num_map = pd.Series(all_metadata_df.passage_id.values,index=pd.Series(range(len(all_metadata_df)))).to_dict()
# Map of Passage id to Paper Title
passage_id_title_map = pd.Series(all_metadata_df.title.values,index=all_metadata_df.passage_id).to_dict()
all_comprehension_df_list = []
#Search in a loop for the individual queries
for task_query_dict in covid_kaggle_questions:
for i,query in enumerate(task_query_dict["questions"]):
print(f"Ranking Passages for {i+1} of {len(task_query_dict['questions'])} query/queries")
passage_ids = get_ranked_passages(query, bm25_model = bm25, passage_id_map = passage_id_map,bert_model = covid_bert, faiss_index = embedding_index, bert_qe_alpha = 0.4)
#Concatenate the query and all passages and predict the scores for the pairs [query, passage]
model_inputs = [[query, passage_id_map[passage_id]] for passage_id in passage_ids]
print("Step 3 : Passage Re-ranking using Fine-Tuned Covid BERT ")
scores = covid_marco.predict(model_inputs)
#Sort the scores in decreasing order
results = [{'input': inp, 'score': score} for inp, score in zip(passage_ids, scores)]
results = sorted(results, key=lambda x: x['score'], reverse=True)
# Filtering passages above a certain threshold
final_results = {res_dict['input']:res_dict['score'] for res_dict in results if res_dict['score'] > 0.3}
print("Step 4 : Comprehension ")
# Comprehension
comp_output = [[comprehension_model(question="What is coronavirus",
context = passage_id_map[pass_id]), pass_id, pass_score] \
for pass_id, pass_score in final_results.items() if len(passage_id_map[pass_id].split()) > 5]
# Adding pass id and score to the comprehension
[comp_output[i][0].update({'pass_id': comp_output[i][1],
'pass_rank_score': comp_output[i][2]}) for i in range(len(comp_output))]
# Converting list of dictionaries of ranked results to dataframe.
comprehension_df = pd.DataFrame([comp_[0] for comp_ in comp_output])
# adding query and the task
comprehension_df["query"] = query
comprehension_df["task"] = task_query_dict["task"]
# Finally, using passage_id to replace with actual Paper Title and Context
comprehension_df["title"] = [passage_id_title_map[pass_id] for pass_id in comprehension_df.pass_id]
all_comprehension_df_list.append(comprehension_df)
all_comprehension_df = pd.concat(all_comprehension_df_list, axis = 0)
现在你保存熊猫数据帧。
all_comprehension_df.to_csv("all_question_comprehension.csv", index = None)
结论
你在这一章学到了很多。您从不同类型的问答系统开始,然后为一个封闭领域的问答系统构建了一个系统设计,该系统涉及在应用理解之前对正确的文档进行排序的多种方法。您还学习了新技术,例如用于内部产品搜索的 FAISS。这在问答之外还有应用,可以用在任何大规模生产环境中。
请随意回答不同的问题。尽管您将在下一章使用 CORD-19 任务中的问题,但您仍然可以传递自己的查询并更好地理解 COVID。**
十、你现在需要一个观众
今天,很大一部分 ML 研究和建模工作都被搁置在 Jupyter 笔记本或多个 Python 脚本中。数据科学家需要对其他 It 系统和企业架构有大量的了解,才能将东西投入生产并在真实系统上运行。行业趋势已经从“数据科学家”转变为“全栈数据科学家”
我们所有的现代 ML 应用程序代码只不过是带有复杂的数据管理设置过程的库。在这一章中,您将学习如何在 Docker 的帮助下将模型投入生产,Docker 可以再现您用来开发 ML 代码的环境,这将导致可再现的输出,从而提供可移植性。您还将使用 Heroku 部署带有实时 URL 的应用程序。
揭开网络的神秘面纱
如今,大多数企业应用程序都是 web 应用程序。下载一个.exe
文件来运行最新软件的日子已经一去不复返了。如今,大多数软件都运行在云中。这导致了公司和消费者在规模、体验和成本方面的变化。我们正在将更大的计算能力放入更小的设备中,并通过互联网生活在一个“永远连接”的世界中。随着时代的变化,技术的变化是必然的。
现代软件系统遵循 CI/CD 方法(持续集成和持续部署)。持续集成的目的是将源代码与适当的测试集成在一起,而部署将获取这些代码并打包以供部署。人工智能要想成功,它需要成为这个系统的一部分。
数据科学家在遇到问题时,会从 Jupyter notebook/Python 脚本开始,创建一个解决问题的模型。一旦模型达到所需的精度,它将被存储为文件格式,如.h5
、.pkl
或.onnx
,以便其他数据科学家或最终用户加载和使用。为了将其集成到传统上用 JS/C#/Java 或 C++编写的现代应用程序中,我们必须编写一个可以在其环境中调用这种模型的包装器,因为大多数数据流水线都是用这种语言编写的。这不仅仅是集成的问题,也是存储和提供运行这种模型的计算资源的问题,因为这种模型很可能需要 GPU。因此我们不能一直交换文件。我们需要像软件开发一样管理模型生命周期。
应用程序如何通信?
web 应用程序连接到 web 服务器,web 服务器只不过是一个远程计算机单元(类似于 CPU)。图 10-1 解释了 web 技术是如何从静态 HTML 发展到高级应用程序,如 Gmail、脸书等。讨论中忽略的一件重要事情是数据库技术的发展。虽然传统的应用程序是建立在 SQL 数据库上的,但是现在有了更先进的数据库技术,如 MongoDB、Cassandra、Neo4J 等。
图 10-1
网络技术的演变
一般来说,这些网站由公司 IT 部门维护的本地服务器提供支持,但是随着应用程序变得复杂且高度关联(与数据、人员和其他应用程序),很难按比例扩展服务器。这没有商业意义,也没有资源来维护这样一个高性能的系统。
云技术
然后是云技术。对于门外汉来说,云是一个随需应变的计算机系统,可供许多用户通过互联网使用。这种随需应变的系统有助于我们通过虚拟化获得所需的存储和处理能力(即通过软件将服务器划分为更小的虚拟机)。
随着云以非常低的成本提供企业级技术,许多服务开始涌现。这些技术的视图如图 10-2 所示。
图 10-2
各种基于云的服务。资料来源:redhat.com
这年头现场很少见。它可能用于一些只能通过公司内部网访问的内部软件/网站。
在 IaaS 中,只有基础架构是租用的(也就是说,将具有特定存储、RAM 和计算资源的机器委托给你)。想象一下买一个 CPU。现在你可以做任何事情:安装软件,制作应用程序,甚至建立一个网站。是的,你可以用你的电脑托管一个网站,但是你能保证正常运行时间和速度吗?
使用 PaaS 时,您只关心开发您的代码和数据脚本。您不关心需要多少个虚拟机来高效运行代码,也不关心为每个虚拟机分别提供操作系统、库版本等。
SaaS 一般都是基于网络的工具,如 Google Colab、脸书、LinkedIn 等。之所以这样称呼它们,是因为你不需要设置任何东西就可以使用它们。您所需要的只是一个与云通信的互联网连接。
码头工人和库柏工人
为什么是 Docker?
现代 web 应用程序包含许多依赖项。其中一些依赖于操作系统。其中一些依赖于所使用的不同库的版本。随着越来越多的库被独立开发,这种情况只会越来越多。你可以使用来自一个开发者的一个库和来自另一个开发者的另一个库,这就是 ML 的情况。
如果您必须集成在多台机器上测试的代码(在开发中),然后最终将它集成到一个临时服务器上,这可能会非常麻烦。为了管理这样的问题,一种新的开发模式正在兴起,叫做容器化应用。它基本上将代码、用于运行代码的库和操作系统级信息作为一个单独、隔离的单元。这个独立的单元可以在另一台机器上运行,而不用担心为运行应用程序代码而配置机器。Docker 是当今使用最广泛的容器技术,在 ML 社区中非常流行。
操作系统虚拟化
Docker 容器运行在主机操作系统之上,并为容器内运行的代码提供了一个标准化的环境。当开发环境的操作系统和测试操作系统相同时,Docker 是合适的。这些容器化的单元基本上解决了 ML 中的 DevOps 问题,因为你现在可以和代码一起获得所有具有正确版本的依赖库,甚至是操作系统(即开发者环境的精确副本)。
与通过虚拟机管理程序等应用程序创建虚拟机来实现的硬件虚拟化相比,使用 Docker 的这种操作系统虚拟化允许您实现高效的资源利用,因为您现在可以在 Docker 容器之间动态分配资源,尽管它们与虚拟机使用相同的服务器,虚拟机将资源分配给各自的单元。
忽必烈忽必烈忽必烈忽必烈忽必烈忽必烈忽必烈忽必烈忽必烈忽必烈
现在,想象一个成熟的应用程序,比如亚马逊,使用多个这样的容器图像。一个是允许搜索结果出现,一个是推荐新商品,一个是捕捉用户行为和与 web 应用程序的交互接触点。我们能否根据它们的使用情况来扩展它们?是的。对于编排独立的 Docker 容器,我们使用 Kubernetes。
对 Kubernetes 或 Docker 的详细介绍超出了本书的范围,但是网上有一些很好的资源,比如关于 https://mlinproduction.com/
的文章。
部署 QnA 系统
我已经介绍了基础知识。现在,您可以部署问答设置并创建 web 应用程序了。
首先,您需要一个框架来处理您的部署和集成需求,例如前端和后端通信、客户端和服务器端伸缩等。为此,您将使用 Flask。让我们深入研究一下。
建立一个烧瓶结构
Flask 是一个基于 web 的微服务框架,允许您通过 API 公开任何业务逻辑/功能。虽然我不会涉及很多 Flask,但对于第一次使用 Flask 的人来说,这里有一些基础知识。
首先创建一个名为covidquest
的文件夹。您将使用它作为应用程序的文件夹。
安装 Flask,这样你就可以通过 pip 频道下载最新的 Flask。
设置好之后,让我们创建 Flask 应用程序。
制作 Flask 应用程序需要两个基本要素,一个处理客户端(前端),另一个处理服务器端(后端)。
web 应用程序设置包含两个文件。因此,您将按如下方式创建这两个文件:
-
app.py :处理客户端通信并生成响应的 Python 脚本。
-
index.htm:你的 GUI 界面。它允许用户提交输入(也称为请求)进行计算,并呈现返回的结果,就像您在“应用程序如何通信”一节中学习的一样
您可以从 https://github.com/NeverInAsh/covidquest
克隆 app 文件。这将作为您的起点,但是让我们快速查看一下每个文件中的基本内容。
深入了解 app.py
from flask import Flask, render_template, request
import pandas as pd
import numpy as np
import sys
app = Flask(__name__, template_folder='./templates/')
@app.before_first_request
def at_startup():
global answer_df, question_map, top_k_map
answer_df = pd.read_csv("./all_question_comprehension.csv", index_col=None)
question_map = {'1': 'Is the virus transmitted by aerosol, droplets, food, close contact, fecal matter, or water?',
... skipped lines
'30': 'Can 2019-nCoV infect patients a second time?'}
top_k_map = {'0': 5, '1': 10, '2': 20, '3': 30, '4': 50}
@app.route('/')
def home():
return render_template("index.html")
def create_answer(text, start, end):
output = [text[0:start],
text[start:end],
text[end:len(text)]]
return output
@app.route('/top_k_results', methods=['GET', 'POST'])
def top_k_results():
question_select = "0"
weight = "0.2"
top_k = "0"
if request.method == "POST":
question_select = request.form.get('question_select', '')
weight = request.form.get('weight', '')
top_k = request.form.get('top_k', '')
query = question_map[question_select]
# Filtering answer dataframe for the query
_df = answer_df[answer_df['query'].isin([query])]
_df = _df.drop_duplicates(subset=['passage_id']).reset_index(drop=True)
_df["final_score"] = np.float(
weight)*_df["score"] + (1-np.float(weight))*_df["pass_rank_score"]
_df = _df.sort_values(
'final_score', ascending=False).reset_index(drop=True)
# results-dictionary
results = [{'passage': create_answer(row['passage'], row['start'], row['end']),
'title':row['title'],
'task':row['task']} for i, row in _df.head(top_k_map[top_k]).iterrows()]
return render_template("index.html", question_select=question_select,
weight=weight, top_k=top_k, results=results)
if __name__ == '__main__':
port = int(os.environ.get("PORT", 5000))
app.run('0.0.0.0', port)
您的app.py
按以下方式组织:
图 10-3
用于获取用户输入的表单
-
首先,导入所有用于编写后端逻辑的相关库。
-
然后创建一个
app
对象,它是 Flask 对象的一个实例。使用它,您可以配置整个应用程序。例如,通过显式给出到templates
文件夹的链接,确保 Flask 知道要呈现哪个网页。templates
文件夹用于存储应用程序的所有 HTML 文件,而所有 CSS 和.js
文件(用于前端/客户端的其他技术)存储在静态文件夹中。 -
app 对象还帮助为端点/函数设置路由,这些端点/函数又调用 URL。(URL 是端点的地址。)这是使用装饰器
@app.route(<url>, methods),
完成的,它是一种用于通信的 HTTP 方法。 -
最常见的数据通信/传输方法是 GET 和 POST。GET 向服务器发送未加密的信息,而 POST 屏蔽这些信息并在请求体中传递数据。
-
您使用家庭端点作为您网站的登录页面。它只是呈现索引文件。
-
您还可以使用像
@app.before_first_request
这样的装饰器,它确保在服务器准备好进行通信之前,所有生成请求响应所需的文件/变量都已加载。 -
app.route()
用于将特定的 URL 与函数进行映射。例如,您正在使用 URL“/”映射网站的登录页面/主页。类似地,您用函数top_k_results.
映射“/top_k_results” -
render_tempalte()
用于呈现 HTML,它是用户界面的框架,供客户端交互。Flask 使用 Jinja 模板库来渲染模板。点击https://jinja.palletsprojects.com/en/2.11.x/
了解更多信息。 -
主代码逻辑存储在
top_k_results()
端点,从网站表单中收集数据(图 10-3 )。这个数据是-
询问
-
理解分数在最终分数中的权重,是理解分数和中-宏观等级分数的线性加权和
-
针对所提问题显示的前 k 个结果
-
-
上面的数据是通过请求体中的 POST 方法返回的,并且都是字符串,所以您将它转换为 write 数据类型,并且还获得一个实际值,而不是 HTML 元素的值。
-
您返回一个
render_template()
函数来呈现与端点相关联的 HTML 或 URL。请注意,您传递了许多变量和render_template()
。这有助于使用后端数据将逻辑嵌入到标记中。这是使用 Jinja 模板完成的(下面将详细讨论)。 -
最后,通过使用服务器监听请求的地址和端口号调用 Flask 应用程序来运行它。
了解 index.html
您的索引文件看起来像这样
<form action="{{url_for('top_k_results',_anchor='resultsView')}}" method="post">
<div class="container my-4">
<p class="font-weight-bold">Questions</p>
<select class="mdb-select md-form" id="question-select" name="question_select">
<option value="" disabled selected>Choose your question</option>
<option value='1' {% if question_select=='1' %} selected {% endif %}>Is the virus transmitted by aerosol,
droplets, food, close contact, fecal matter, or water?</option>
<option value='2' {% if question_select=='2' %} selected {% endif %}>How long is the incubation period for
the virus?</option>
....
<button type="submit" class="btn btn-primary btn-block btn-large">Get Top Results</button>
</form>
</div>
您使用一个表单从前端获取 post 请求。你看到的怪异模板{{}}叫做 Jinja 模板。它帮助创建 HTML、XML 和其他标记格式,这些格式通过 HTTP 响应返回给用户。
您可以使用从您与之交互的端点作为响应传递的任何变量。这很有帮助。在你的用例中,你事先不知道用户希望看到多少回答,所以这不是静态的。
看看复制一个你想要的结果数量的模板有多简单?
<ul class="timeline">
{% for result in results %}
<li class="timeline-item bg-white rounded ml-3 p-4 shadow">
<div class="timeline-arrow"></div>
<h2 class="h5 mb-0">{{result.title}}</h2><span class="small text-gray"><i class="fa fa-clock-o mr-1"></i>{{result.task}}</span>
<p class="text-small mt-2 font-weight-light">{{result.passage[0]}}<strong><span
style="color:orange">{{result.passage[1]}}</span></strong>{{result.passage[2]}}</p>
</li>
{% endfor %}
</ul>
到现在为止,你应该对你的 Flask 应用程序及其结构有了很好的了解。在我结束这部分之前,我想让你看看你的 Flask 应用程序的目录树。
| all_question_comprehension.csv
| app.py
|
+---static
| | favicon-32x32.png
| |
| +---css
| | bootstrap.min.css
| | choices.min.css
| | font-awesome.min.css
| | index.css
| | jquery.mCustomScrollbar.min.css
| |
| \---js
| bootstrap.bundle.min.js
| choices.min.js
| index.js
| jquery-3.3.1.slim.min.js
| jquery.mCustomScrollbar.concat.min.js
|
+---templates
| index.html
|
要运行 Flask app,使用操作系统的命令行工具进入项目文件夹目录,输入flask run
,如图 10-4 所示。
图 10-4
运行 flask 的 Windows 命令以在本地主机上启动应用程序
将你的申请归档
到目前为止,您已经构建了您的应用程序。现在可以在服务器上部署它了。虽然对于您的用例来说,由于您没有使用太多的库和包,因此没有必要对您的应用程序进行 dockerize,但是这是可以随着时间而改变的,因此可以缩短您的应用程序的生命周期。
此外,您在 Windows 上编码,但是大多数部署服务器是基于 Unix 的内核。如果代码利用 GPU,很有可能当您将这个应用程序投入使用时,会出现包问题和硬件资源使用问题。
因此,要创建一个独立的便携式机器,并保持真实的当前配置,您需要 Docker 平稳地完成将您的应用程序从笔记本电脑带到生产环境的旅程。
Note
要在您的系统上安装 Docker,请参考位于 https://docs.docker.com/desktop/
的非常简单的指南。
创建 Docker 图像
为了创建 Docker 映像,即包含运行应用程序所需的所有配置和依赖信息的单个文件,您必须创建 Docker 文件。它包含所有的启动命令,这些命令在容器脱离后执行。容器是图像的运行实例。例如,房子的蓝图是图像,实际的房子是容器。就像您可以使用一个蓝图来创建许多房子一样,Docker 映像可以用来创建许多在单独的容器中运行的实例。
以下命令用于创建 Dockerfile 文件:
-
从
-
复制
-
工作目录
-
揭露
-
奔跑
-
CMD 或 ENTRYPOINT
基本图像和来自命令
每个 Docker 容器都是一个图像,在一堆只读层之上有一个读/写层。这意味着你从一个操作系统发行版开始,比如说 Linux Ubuntu,它是你的只读层,然后继续添加不同的层,比如 Anaconda,来设置你的 Python 环境和库,比如 Flask、pandas 和 NumPy,来运行你的应用程序。见图 10-5 。
图 10-5
Docker 容器是堆叠的图像
您可以使用 FROM 命令获取基础映像。这是构建 Dockerfile 文件的必要命令。对于您的应用程序,您将使用 continuum Anaconda 发行版。这张图片可以在 Docker hub 上找到,这是一个容器应用的集合: https://hub.docker.com/r/continuumio/anaconda3
.
复制并曝光
使用复制命令,你基本上传递你的文件和文件夹到 Docker 镜像。在您的情况下,这是包含您的 Flask 应用程序的covidquest
文件夹。一旦复制完成,你就可以从 Docker 镜像中启动这个应用了。
EXPOSE 命令告诉 Docker OS 的网络为服务器打开一些端口来监听请求。
工作方向、运行和命令
WORKDIR 帮助您设置工作目录,在您的情况下,这个目录就是app.py
文件所在的位置。这通常是您使用 COPY 命令将文件复制到的目录。
RUN 命令帮助您安装一组依赖项和库,以便在容器内运行应用程序。不是单独安装每个依赖项,而是使用一个包含所有特定版本所需文件的requirement.txt
文件。
这不仅可以用于运行库安装,还可以用于运行任何其他命令行命令。显然,它随您选择的基础图像而变化。
docker 文件中的最后一个命令是 CMD,它是容器的启动命令。这就像你在你的本地。
Dockerfile
现在您已经掌握了这些知识,您终于可以使用这些命令来构建您的 Docker 映像了。
你首先复制你的covidquest
文件夹,并将其重命名为covidquest_docker.
在这个文件夹中,你创建你的 docker 文件.
,它将是一个无扩展名的文件。您的目录现在看起来会像这样:
| Dockerfile
|
\---covidquest
| all_question_comprehension.csv
| app.py
| requirements.txt
|
+---static
| | favicon-32x32.png
| |
| +---css
| | bootstrap.min.css
| | choices.min.css
| | font-awesome.min.css
| | index.css
| | jquery.mCustomScrollbar.min.css
| |
| \---js
| bootstrap.bundle.min.js
| choices.min.js
| index.js
| jquery-3.3.1.slim.min.js
| jquery.mCustomScrollbar.concat.min.js
|
\---templates
index.html
将以下命令添加到 docker 文件中。您可以使用任何文本编辑器,但是要确保 docker 文件没有扩展名。
FROM continuumio/anaconda3
MAINTAINER Anshik, https://www.linkedin.com/in/anshik-8b159173/
RUN mkdir /app
COPY ./covidquest /app
WORKDIR /app
EXPOSE 5000
RUN pip install -r requirements.txt
CMD flask run --host 0.0.0.0
还有一点需要注意的是,requirements.txt
保存在app
文件夹中,因为使用这个映像派生出来的多个容器将确切地知道哪些库用于构建这个应用程序逻辑。
建立码头形象
最后,使用以下命令构建 Docker 映像(参见图 10-6 ):
图 10-6
建立码头工人形象
docker build -t <docker_image_name> .
Note
t 标志用于给新创建的映像命名。
根据您的网络速度,此过程可能需要一些时间。图 10-7 显示图像是否已经创建。
图 10-7
Docker 图像列表
创建映像后,您可以使用以下命令运行容器。-p flag
下面的命令用于向主机发布容器的端口。这里,您将 Docker 容器中的端口 5000 映射到主机上的端口 5000,这样您就可以在 localhost:5000 访问应用程序。见图 10-8 。
图 10-8
运行 Docker 容器
即使您按下 Ctrl + C 或 CMD + C,容器仍将在后台运行。
请注意,每个 Docker 容器都与一个 ID 相关联。您可以使用命令docker container ls
找出有多少个容器正在运行,如图 10-9 所示。
图 10-9
列出码头集装箱
确保在使用后关闭容器(图 10-10 )。如果不这样做,它会抛出如下错误:
图 10-10
关掉容器
(tfdeploy) C:\Users\bansa\Desktop\Book\Chapter 10\covidquest_docker>docker run -p 5000:5000 -d covidquest
4778247c6c95a5a5093edd1279b03a1e41e243afb6ab84788752c9629fbaf69b
docker: Error response from daemon: driver failed programming external connectivity on endpoint funny_jemison (dc7d4acc7671b41c701558a8c4200406ec9f0474e360e8aea38b075cc1c2d5d0): Bind for 0.0.0.0:5000 failed: port is already allocated.
当构建 Docker 映像covidquest
并运行 Docker 容器时,会产生大量垃圾,比如
-
停止的容器
-
至少一个容器未使用的网络
-
图像(参见图 10-7
-
构建缓存
您可以通过运行命令删除所有这些不需要的文件并回收空间
docker system prune
用 Heroku 让它活起来
既然您已经将应用程序进行了 docker 化,那么您可以将它带到任何您想要的地方,并将它部署到一个实际的地址。但是在此之前,让我们先了解一下开发服务器。
到目前为止,您一直使用的是 Flask 自己的开发服务器。从某种意义上说,这个服务器非常有限,它不能很好地处理多个用户或多个请求。
当在生产环境中运行 web 应用程序时,您希望它能够处理多个用户和多个请求,这样就不会有明显的页面和静态文件加载时间。
为了使服务器更加“适合生产”,您可以使用 Gunicorn。Gunicorn 是一个用于 WSGI (Web 服务网关接口)应用程序的纯 Python HTTP 服务器。它允许您通过在 Heroku(也称为 dynos)委托的机器上运行多个 Python 进程来并发运行任何 Python 应用程序。
为了让您的应用程序在生产环境中运行,您需要进行某些更改。您需要更改 Docker 文件:
FROM continuumio/anaconda3
MAINTAINER Anshik, https://www.linkedin.com/in/anshik-8b159173/
## make a local directory
RUN mkdir /app
COPY ./covidquest /app
# Not required by Heroku
# EXPOSE 5000
WORKDIR /app
RUN pip install -r requirements.txt
# CMD flask run --host 0.0.0.0
CMD gunicorn app:app --bind 0.0.0.0:$PORT --reload
您还可以添加一个 Procfile。Procfile 是一种用于声明描述应用程序运行方式的进程类型的格式。流程类型声明其名称和命令行命令。这是一个原型,可以实例化为一个或多个运行的流程,如 Docker 容器。
它是一个无扩展文件,包含以下进程,基本上是一个 gunicorn 进程,告诉app.py
文件它必须运行,因为它包含处理请求的函数/端点:
web: gunicorn app:app --log-file=-
您的covidquest_docker
目录现在看起来像这样:
| Dockerfile
|
\---covidquest
| all_question_comprehension.csv
| app.py
| Procfile
| requirements.txt
|
+---static
| | favicon-32x32.png
| |
| +---css
| | bootstrap.min.css
| | choices.min.css
| | font-awesome.min.css
| | index.css
| | jquery.mCustomScrollbar.min.css
| |
| \---js
| bootstrap.bundle.min.js
| choices.min.js
| index.js
| jquery-3.3.1.slim.min.js
| jquery.mCustomScrollbar.concat.min.js
|
\---templates
index.html
你终于准备好深入 Heroku 了。Heroku 是一个 PaaS 系统,它通过完全托管的数据服务来帮助构建数据驱动的应用。要了解更多关于 Heroku 的信息,请观看视频“Heroku 解释:冰山、伐木工人和公寓”
您将通过使用 Heroku CLI 来实现这一目的。Heroku 命令行界面(CLI)使得直接从终端创建和管理 Heroku 应用程序变得简单。这是使用 Heroku 的重要部分。您可以从 https://devcenter.heroku.com/articles/heroku-cli
开始按照 CLI 安装。
运行图 10-11 所示的命令,检查是否成功设置了 Heroku。
图 10-11
检查 Heroku 版本
接下来,你必须登录 Heroku。您可以通过在命令行输入命令heroku login
来实现,该命令会将您重定向到浏览器进行登录。成功登录后(图 10-12 ,关闭选项卡,返回 CLI。
图 10-12
英雄库登录
现在,您可以使用命令heroku create <app-name>
创建 Heroku 应用程序。这为 Heroku 接收你的源代码做好了准备。Heroku 不允许你取别人已经取过的名字。但在此之前,确保您移动到app
目录(图 10-13 )。
图 10-13
创建 Heroku 应用程序
Heroku 在registry.heroku.com
上运行一个容器注册表。在 CLI 中,您可以使用命令登录
heroku container:login
或者通过 Docker CLI
docker login --username=<email-id> --password=$(heroku auth:token) registry.heroku.com
但是在您将应用程序推送到 Heroku 容器注册表之前,您需要告诉 Heroku CLI 您想要为哪个应用程序运行此命令。为此,您可以使用git init
将文件夹转换成 Git 存储库。如果已经是 Git 回购,那就不用担心了。
之后,您为 repo 添加应用程序名称,并创建一个 git remote。Git 远程是位于其他服务器上的存储库版本。您可以通过将代码推送到与您的应用相关联的 Heroku 托管的特殊遥控器来部署您的应用。
heroku git:remote -a <your_app_name>
要构建一个映像并将其推送到容器注册表,请确保您的目录包含一个 Dockerfile 并运行命令heroku container:
push
web.
参见图 10-14 。
图 10-14
用 Heroku 建立并推广码头工人形象
在您成功地将一个映像推送到容器注册中心之后,您可以创建一个新的版本。每当您部署代码、更改配置变量或修改应用程序的附加资源时,Heroku 都会创建一个新版本并重启您的应用程序。您可以通过使用
heroku container:release web
最后,您可以使用以下命令打开您的应用程序。这将在浏览器中打开应用程序(图 10-15 )。
图 10-15
使用 URL 部署的应用程序
heroku open
由于您正在使用免费层,该应用程序将在 30 分钟的空闲时间后关闭。为了让你的应用永远保持运行,你可以探索付费应用。
结论
这是一段漫长的旅程。如果你做到了这一步,你就是一个摇滚明星。我希望在这次超过七个案例研究的旅程中,您对当前医疗保健系统提供的机会以及为什么您需要应用高级人工智能和人工智能技能来规模化医疗保健感到好奇和兴奋。
您了解了不同的民族如何拥有不同的采用率(第三章),以及如何从 EHR 文本中提取 ICD-9 代码,以帮助处理数十亿美元的保险系统使用最新的语言理解模型“变形金刚”。然后,您探索了像 GCNs 这样的高级模型,这些模型不仅利用实体信息,还利用它们之间的联系,以便更好地从可用数据中学习。
在第六章中,你探讨了任何行业最大的痛点,尤其是医疗保健行业,因为获取模型的训练数据需要大量的专业知识。您了解了即将推出的一款充满动力的产品——浮潜,它让半监督学习变得可行。
第七章向您介绍了使用联邦学习训练 ML 模型的另一种方式。医疗保健在消费者(患者)、创造者(制药公司)和分销商(医生和政府机构)之间取得了恰当的平衡。由于有如此多的利益相关者涉及不平等的权力和资源,这就引出了一个问题,即我们如何保护个人隐私的权利,同时又能促进科学的发展。您了解了如何使用隐私保护机制来实现这一点。
第八章详细讨论了各种类型的医学图像数据及其各种格式。您还了解了如何处理两种不同且非常流行的图像结构,2d 和 3d,并分别解决了这些图像上的一些最重要的检测和分割任务。您还了解了如何使用迭代器优化数据流水线。
第九章带你来到我们将如何与计算机系统互动的未来。在之前的十年里,完成一项任务(比如买衣服)的点击次数已经大大减少了。随着 UI 和金融技术的进步,我们正在走向一个我们将只是与机器聊天的时代,QnA 是迈向这一时代的第一步。
最后,您部署了您构建的内容,因为如果世界看不到它,它将不会使任何人受益。
我希望你能继承从这本书中学到的知识,并且这些知识点燃了你拥抱、开发和部署你脑海中的下一个伟大的 ML 应用程序想法的火焰。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~