PyTorch 深度学习编程(四)
原文:Programming Pytorch for Deep Learning
译者:飞龙
第八章:PyTorch 在生产中
现在您已经学会了如何使用 PyTorch 对图像、文本和声音进行分类,下一步是看看如何将 PyTorch 应用程序部署到生产环境中。在本章中,我们创建应用程序,通过 HTTP 和 gRPC 在 PyTorch 模型上运行推断。然后,我们将这些应用程序打包到 Docker 容器中,并将它们部署到在 Google Cloud 上运行的 Kubernetes 集群中。
在下半部分,我们将看一下 TorchScript,这是 PyTorch 1.0 中引入的一项新技术,它允许我们使用即时(JIT)跟踪来生成优化的模型,这些模型可以从 C++中运行。我们还简要介绍了如何使用量化压缩模型。首先,让我们看一下模型服务。
模型服务
在过去的六章中,我们一直在 PyTorch 中构建模型,但构建模型只是构建深度学习应用程序的一部分。毕竟,一个模型可能具有惊人的准确性(或其他相关指标),但如果它从不进行任何预测,那么它是否有价值呢?我们希望有一种简单的方法来打包我们的模型,以便它们可以响应请求(无论是通过网络还是其他方式,我们将看到),并且可以在生产环境中运行,而不需要太多的努力。
幸运的是,Python 允许我们使用 Flask 框架快速启动 Web 服务。在本节中,我们构建一个简单的服务,加载我们基于 ResNet 的猫或鱼模型,接受包含图像 URL 的请求,并返回一个 JSON 响应,指示图像是否包含猫或鱼。
注意
如果我们向模型发送一张狗的图片会发生什么?模型会告诉您它是鱼还是猫。它没有其他选择之外的概念,总是会选择一个。一些深度学习从业者在训练过程中添加一个额外的类别Unknown,并加入一些不属于所需类别的标记示例。这在一定程度上有效,但实质上是试图让神经网络学习不是猫或鱼的所有内容,这对您和我来说都很难表达,更不用说一系列矩阵计算了!另一个选择是查看最终softmax生成的概率输出。如果模型产生的预测大致是 50/50 的猫/鱼或分布在您的类别中,那么可能建议Unknown。
构建 Flask 服务
让我们启动一个启用 Web 服务的模型版本。Flask是一个用 Python 创建 Web 服务的流行框架,我们将在本章中一直使用它作为基础。使用pip或conda安装 Flask 库:
conda install -c anaconda flask
pip install flask
创建一个名为catfish的新目录,并将您的模型定义复制到其中作为model.py:
from torchvision import models
CatfishClasses = ["cat","fish"]
CatfishModel = models.ResNet50()
CatfishModel.fc = nn.Sequential(nn.Linear(transfer_model.fc.in_features,500),
nn.ReLU(),
nn.Dropout(), nn.Linear(500,2))
请注意,我们在这里没有指定预训练模型,因为我们将在 Flask 服务器启动过程中加载我们保存的权重。然后创建另一个 Python 脚本catfish_server.py,在其中我们将启动我们的 Web 服务:
from flask import Flask, jsonify
from . import CatfishModel
from torchvision import transforms
import torch
import os
def load_model():
return model
app = Flask(__name__)
@app.route("/")
def status():
return jsonify({"status": "ok"})
@app.route("/predict", methods=['GET', 'POST'])
def predict():
img_url = request.image_url
img_tensor = open_image(BytesIO(response.content))
prediction = model(img_tensor)
predicted_class = CatfishClasses[torch.argmax(prediction)]
return jsonify({"image": img_url, "prediction": predicted_class})
if __name__ == '__main__':
app.run(host=os.environ["CATFISH_HOST"], port=os.environ["CATFISH_PORT"])
您可以通过设置CATFISH_HOST和CATFISH_PORT环境变量在命令行上启动 Web 服务器:
CATFISH_HOST=127.0.0.1 CATFISH_PORT=8080 python catfish_server.py
如果您将您的 Web 浏览器指向http://127.0.0.1:8080,您应该会得到一个status: "ok"的 JSON 响应,如图 8-1 所示。
图 8-1. CATFISH 的 OK 响应
注意
我们将在本章后面更详细地讨论这一点,但不要直接将 Flask 服务部署到生产环境,因为内置服务器不适合生产使用。
要进行预测,找到一个图像 URL,并将其作为GET请求发送到/predict路径,其中包括image_url参数。您应该看到一个 JSON 响应,显示 URL 和预测的类别,如图 8-2 所示。
图 8-2. CATFISH 的预测
Flask 中的魔法在于@app.route()注解。这使我们能够附加普通的 Python 函数,当用户访问特定端点时将运行这些函数。在我们的predict()方法中,我们从GET或POSTHTTP 请求中提取img_url参数,将该 URL 打开为一个 PIL 图像,并通过一个简单的torchvision转换管道将其调整大小并将图像转换为张量。
这给我们一个形状为[3,224,224]的张量,但由于我们模型的工作方式,我们需要将其转换为大小为 1 的批次,即[1,3,224,224]。因此,我们再次使用unsqueeze()来通过在现有维度前插入一个新的空轴来扩展我们的张量。然后我们可以像往常一样将其传递给模型,这会给我们预测张量。与以前一样,我们使用torch.argmax()来找到张量中具有最高值的元素,并用它来索引CatfishClasses数组。最后,我们返回一个 JSON 响应,其中包含类的名称和我们执行预测的图像 URL。
如果你在这一点上尝试服务器,你可能会对分类性能感到有些失望。我们不是花了很多时间训练它吗?是的,我们是,但是在重新创建模型时,我们只是创建了一组具有标准 PyTorch 初始化的层!所以难怪它不好。让我们完善load_model()以加载我们的参数。
注意
这里我们只返回预测的类,而不是所有类别的完整预测集。当然你也可以返回预测张量,但要注意完整的张量输出会使攻击者更容易通过更多的信息泄漏来构建模型的副本。
设置模型参数
在第二章中,我们讨论了训练后保存模型的两种方法,一种是使用torch.save()将整个模型写入磁盘,另一种是保存模型的所有权重和偏置的state_dict()(但不包括结构)。对于我们基于生产的服务,我们需要加载一个已经训练好的模型,那么我们应该使用什么呢?
在我看来,你应该选择state_dict方法。保存整个模型是一个吸引人的选择,但是你将变得对模型结构的任何更改甚至训练设置的目录结构变得非常敏感。这很可能会导致在其他地方运行的单独服务中加载它时出现问题。如果我们要进行稍微不同布局的迁移,我们希望不必重新制作所有内容。
我们最好不要将保存的state_dicts()的文件名硬编码,这样我们可以将模型更新与服务解耦。这意味着我们可以轻松地使用新模型重新启动服务,或者回滚到早期的模型。我们将文件名作为参数传递,但应该指向哪里呢?暂时假设我们可以设置一个名为CATFISH_MODEL_LOCATION的环境变量,并在load_model()中使用它:
def load_model():
m = CatfishModel()
location = os.environ["CATFISH_MODEL_LOCATION"]
m.load_state_dict(torch.load(location))
return m
现在,将你在第四章中保存的模型权重文件之一复制到目录中,并将CATFISH_MODEL_LOCATION设置为指向该文件:
export CATFISH_MODEL_LOCATION=catfishweights.pt
重新启动服务器,你应该看到服务的准确性有了很大提升!
我们现在有一个工作的最小 Web 服务(你可能希望有更多的错误处理,但我把这留给你来练习!)。但是我们如何在服务器上运行它,比如在 AWS 或 Google Cloud 上?或者只是在别人的笔记本电脑上?毕竟,我们安装了一堆库来使其工作。我们可以使用 Docker 将所有内容打包到一个容器中,该容器可以在任何 Linux(或 Windows,使用新的 Windows Subsystem for Linux!)环境中安装,只需几秒钟。
构建 Docker 容器
在过去几年中,Docker 已成为应用程序打包的事实标准之一。尖端的集群环境,如 Kubernetes,将 Docker 作为部署应用程序的核心(您将在本章后面看到),它甚至在企业中也取得了很大的进展。
如果您之前没有接触过 Docker,这里有一个简单的解释:它的模型是基于集装箱的概念。您指定一组文件(通常使用 Dockerfile),Docker 用这些文件构建一个镜像,然后在容器中运行该镜像,容器是您系统上的一个隔离进程,只能看到您指定的文件和您告诉它运行的程序。然后您可以共享 Dockerfile,以便其他人构建自己的镜像,但更常见的方法是将创建的镜像推送到注册表,这是一个包含可以被任何有访问权限的人下载的 Docker 镜像列表。这些注册表可以是公共的或私有的;Docker 公司运行Docker Hub,这是一个包含超过 100,000 个 Docker 镜像的公共注册表,但许多公司也运行私有注册表供内部使用。
我们需要编写自己的 Dockerfile。这可能听起来有点令人不知所措。我们需要告诉 Docker 安装什么?我们的代码?PyTorch?Conda?Python?Linux 本身?幸运的是,Dockerfile 可以继承自其他镜像,因此我们可以,例如,继承标准 Ubuntu 镜像,并从那里安装 Python、PyTorch 和其他所有内容。但我们可以做得更好!可以选择一些 Conda 镜像,这些镜像将为我们提供一个基本的 Linux、Python 和 Anaconda 安装基础。以下是一个示例 Dockerfile,可用于构建我们服务的容器镜像:
FROM continuumio/miniconda3:latest
ARG model_parameter_location
ARG model_parameter_name
ARG port
ARG host
ENV CATFISH_PORT=$port
ENV CATFISH_HOST=$host
ENV CATFISH_MODEL_LOCATION=/app/$model_parameter_name
RUN conda install -y flask \
&& conda install -c pytorch torchvision \
&& conda install waitress
RUN mkdir -p /app
COPY ./model.py /app
COPY ./server.py /app
COPY $model_location/$model_weights_name /app/
COPY ./run-model-service.sh /
EXPOSE $port
ENTRYPOINT ["/run-model-service.sh"]
这里发生了一些事情,让我们来看看。几乎所有 Dockerfile 中的第一行都是FROM,列出了此文件继承的 Docker 镜像。在这种情况下,它是continuumio/miniconda3:latest。这个字符串的第一部分是镜像名称。镜像也有版本,所以冒号后面的所有内容都是一个标签,指示我们想要下载哪个版本的镜像。还有一个魔术标签latest,我们在这里使用它来下载我们想要的镜像的最新版本。您可能希望将服务固定在特定版本上,以免基础镜像可能导致您的问题后续更改。
ARG和ENV处理变量。ARG指定在构建镜像时由 Docker 提供的变量,然后该变量可以在 Dockerfile 中稍后使用。ENV允许您指定在运行时将注入容器的环境变量。在我们的容器中,我们使用ARG来指定端口是可配置选项,然后使用ENV确保配置在启动时对我们的脚本可用。
完成了这些操作后,RUN和COPY允许我们操作继承的镜像。RUN在镜像内部运行实际命令,任何更改都会保存为镜像的新层,叠加在基础层之上。COPY从 Docker 构建上下文中获取内容(通常是构建命令发出的目录中的任何文件或任何子目录),并将其插入到镜像文件系统的某个位置。通过使用RUN创建了/app后,我们使用COPY将代码和模型参数移动到镜像中。
EXPOSE指示 Docker 应将哪个端口映射到外部世界。默认情况下,没有打开任何端口,所以我们在这里添加一个,从文件中之前的ARG命令中获取。最后,ENTRYPOINT是创建容器时运行的默认命令。在这里,我们指定了一个脚本,但我们还没有创建它!在构建 Docker 镜像之前,让我们先做这个:
#!/bin/bash
#run-model-service.sh
cd /app
waitress-serve --call 'catfish_server:create_app'
等等,这里发生了什么?waitress是从哪里来的?问题在于,在之前运行基于 Flask 的服务器时,它使用了一个仅用于调试目的的简单 Web 服务器。如果我们想将其投入生产,我们需要一个适用于生产的 Web 服务器。Waitress 满足了这一要求。我们不需要详细讨论它,但如果您想了解更多信息,可以查看Waitress 文档。
设置好这一切后,我们最终可以使用docker build来创建我们的镜像:
docker build -t catfish-service .
我们可以通过使用docker images来确保镜像在我们的系统上可用:
>docker images
REPOSITORY TAG IMAGE ID
catfish-service latest e5de5ad808b6
然后可以使用docker run来运行我们的模型预测服务:
docker run catfish-service -p 5000:5000
我们还使用-p参数将容器的端口 5000 映射到我们计算机的端口 5000。您应该能够像以前一样返回到*http://localhost:5000/predict*。
当在本地运行docker images时,您可能会注意到我们的 Docker 镜像超过 4GB!考虑到我们没有写太多代码,这相当大。让我们看看如何使镜像更小,同时使我们的镜像更适合部署。
本地与云存储
显然,存储我们保存的模型参数的最简单方法是在本地文件系统上,无论是在我们的计算机上还是在 Docker 容器内的文件系统上。但是这样做有几个问题。首先,模型被硬编码到镜像中。此外,很可能在构建镜像并投入生产后,我们需要更新模型。使用我们当前的 Dockerfile,即使模型的结构没有改变,我们也必须完全重建镜像!其次,我们镜像的大部分大小来自参数文件的大小。您可能没有注意到它们往往相当大!试试看:
ls -l
total 641504
-rw------- 1 ian ian 178728960 Feb 4 2018 resnet101-5d3b4d8f.pth
-rw------- 1 ian ian 241530880 Feb 18 2018 resnet152-b121ed2d.pth
-rw------- 1 ian ian 46827520 Sep 10 2017 resnet18-5c106cde.pth
-rw------- 1 ian ian 87306240 Dec 23 2017 resnet34-333f7ec4.pth
-rw------- 1 ian ian 102502400 Oct 1 2017 resnet50-19c8e357.pth
如果我们在每次构建时将这些模型添加到文件系统中,我们的 Docker 镜像可能会相当大,这会使推送和拉取变慢。我建议的是如果您在本地运行,可以使用本地文件系统或 Docker 卷映射容器,但如果您正在进行云部署,那么可以利用云的优势。模型参数文件可以上传到 Azure Blob Storage、Amazon Simple Storage Service(Amazon S3)或 Google Cloud Storage,并在启动时拉取。
我们可以重写我们的load_model()函数在启动时下载参数文件:
from urllib.request import urlopen
from shutil import copyfileobj
from tempfile import NamedTemporaryFile
def load_model():
m = CatfishModel()
parameter_url = os.environ["CATFISH_MODEL_LOCATION"]
with urlopen(url) as fsrc, NamedTemporaryFile() as fdst:
copyfileobj(fsrc, fdst)
m.load_state_dict(torch.load(fdst))
return m
当然,有许多种使用 Python 下载文件的方法;Flask 甚至带有requests模块,可以轻松下载文件。然而,一个潜在的问题是,许多方法在将文件写入磁盘之前会将整个文件下载到内存中。大多数情况下,这是有道理的,但是当下载模型参数文件时,它们可能会达到几十 GB。因此,在这个新版本的load_model()中,我们使用urlopen()和copyfileobj()来执行复制操作,并使用NamedTemporaryFile()来给我们一个在块结束时可以删除的目标,因为在那时,我们已经加载了参数,因此不再需要文件!这使我们能够简化我们的 Dockerfile:
FROM continuumio/miniconda3:latest
ARG port
ARG host
ENV CATFISH_PORT=$port
RUN conda install -y flask \
&& conda install -c pytorch torch torchvision \
&& conda install waitress
RUN mkdir -p /app
COPY ./model.py /app
COPY ./server.py /app
COPY ./run-model-service.sh /
EXPOSE $port
ENTRYPOINT ["/run-model-service.sh"]
当我们使用docker run运行时,我们传入环境变量:
docker run catfish-service --env CATFISH_MODEL_LOCATION=[URL]
该服务现在从 URL 中提取参数,并且 Docker 镜像可能比原始镜像小约 600MB-700MB。
注意
在这个例子中,我们假设模型参数文件位于一个公开可访问的位置。如果您部署一个模型服务,您可能不会处于这种情况,而是会从云存储层(如 Amazon S3、Google Cloud Storage 或 Azure Blob Storage)中拉取。您将需要使用各自提供商的 API 来下载文件并获取凭据以访问它,这两点我们在这里不讨论。
我们现在有一个能够通过 HTTP 与 JSON 进行通信的模型服务。现在我们需要确保在它进行预测时能够监控它。
日志和遥测
我们当前的服务中没有的一件事是任何日志记录的概念。虽然服务非常简单,也许不需要大量的日志记录(除非在捕获错误状态时),但对于我们来说,跟踪实际预测的内容是有用的,如果不是必不可少的。在某个时候,我们将想要评估模型;如果没有生产数据,我们该如何做呢?
假设我们有一个名为send_to_log()的方法,它接受一个 Python dict并将其发送到其他地方(也许是一个备份到云存储的 Apache Kafka 集群)。每次进行预测时,我们可以通过这种方法发送适当的信息:
import uuid
import logging
logging.basicConfig(level=logging.INFO)
def predict():
img_url = request.image_url
img_tensor = open_image(BytesIO(response.content))
start_time = time.process_time()
prediction = model(img_tensor)
end_time = time.process_time()
predicted_class = CatfishClasses[torch.argmax(prediction)]
send_to_log(
{"image": img_url,
"prediction": predicted_class},
"predict_tensor": prediction,
"img_tensor": img_tensor,
"predict_time": end_time-start_time,
"uuid":uuid.uuid4()
})
return jsonify({"image": img_url, "prediction": predicted_class})
def send_to_log(log_line):
logger.info(log_line)
通过对每个请求计算预测所需时间的几个补充,该方法现在会向记录器或外部资源发送消息,提供重要细节,如图像 URL、预测类别、实际预测张量,甚至完整的图像张量,以防所提供的 URL 是瞬态的。我们还包括一个生成的通用唯一标识符(UUID),以便以后始终可以唯一引用此预测,也许如果其预测类别需要更正。在实际部署中,您将包括user_id等内容,以便下游系统可以提供一个设施,让用户指示预测是正确还是错误,偷偷地生成更多用于模型进一步训练迭代的训练数据。
有了这些,我们就准备好将我们的容器部署到云端了。让我们快速看一下如何使用 Kubernetes 来托管和扩展我们的服务。
在 Kubernetes 上部署
本书的范围不包括深入讨论 Kubernetes,因此我们将坚持基础知识,包括如何快速启动和运行服务。Kubernetes(也称为 k8s)正在迅速成为云中的主要集群框架。它源自谷歌最初的集群管理软件 Borg,包含所有部件和粘合剂,形成了一种弹性和可靠的运行服务的方式,包括负载均衡器、资源配额、扩展策略、流量管理、共享密钥等。
您可以在本地计算机或云账户上下载和设置 Kubernetes,但推荐的方式是使用托管服务,其中 Kubernetes 本身的管理由云提供商处理,您只需安排您的服务。我们使用谷歌 Kubernetes 引擎(GKE)服务进行部署,但您也可以在亚马逊、Azure 或 DigitalOcean 上进行部署。
在谷歌 Kubernetes 引擎上设置
要使用 GKE,您需要一个谷歌云账户。此外,在 GKE 上运行服务并不是免费的。好消息是,如果您是谷歌云的新用户,您将获得价值 300 美元的免费信用额度,我们可能不会花费超过一两美元。
一旦您拥有账户,请为您的系统下载gcloud SDK。安装完成后,我们可以使用它来安装kubectl,这是我们将用来与我们将要创建的 Kubernetes 集群进行交互的应用程序:
gcloud login
gcloud components install kubectl
然后,我们需要创建一个新的项目,这是谷歌云在您的账户中组织计算资源的方式:
gcloud projects create ml-k8s --set-as-default
接下来,我们重建我们的 Docker 镜像并对其进行标记,以便将其推送到谷歌提供的内部注册表(我们需要使用gcloud进行身份验证),然后我们可以使用docker push将我们的容器镜像发送到云端。请注意,我们还使用v1版本标记标记了我们的服务,这是之前没有做的:
docker build -t gcr.io/ml-k8s/catfish-service:v1 .
gcloud auth configure-docker
docker push gcr.io/ml-k8s/catfish-service:v1
创建一个 k8s 集群
现在我们可以创建我们的 Kubernetes 集群。在以下命令中,我们创建了一个具有两个 n1-standard-1 节点的集群,这是谷歌最便宜和最低功率的实例。如果您真的要省钱,可以只创建一个节点的集群。
gcloud container clusters create ml-cluster --num-nodes=2
这可能需要几分钟来完全初始化新的集群。一旦准备就绪,我们就可以使用kubectl部署我们的应用程序!
kubectl run catfish-service
--image=gcr.io/ml-k8s/catfish-service:v1
--port 5000
--env CATFISH_MODEL_LOCATION=[URL]
请注意,我们在这里将模型参数文件的位置作为环境参数传递,就像我们在本地机器上使用docker run命令一样。使用kubectl get pods查看集群上正在运行的 pod。pod是一个包含一个或多个容器的组合,并附有如何运行和管理这些容器的规范。对于我们的目的,我们在一个 pod 中的一个容器中运行我们的模型。这是您应该看到的内容:
NAME READY STATUS RESTARTS AGE
gcr.io/ml-k8s/catfish-service:v1 1/1 Running 0 4m15s
好的,现在我们可以看到我们的应用正在运行,但我们如何与它进行交流呢?为了做到这一点,我们需要部署一个服务,在这种情况下是一个负载均衡器,将外部 IP 地址映射到我们的内部集群:
kubectl expose deployment catfish-service
--type=LoadBalancer
--port 80
--target-port 5000
然后您可以使用kubectl get services查看正在运行的服务以获取外部 IP 地址:
kubectl get service
NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE
catfish-service 10.3.251.122 203.0.113.0 80:30877/TCP 3d
现在你应该能够像在本地机器上一样访问http://external-ip/predict。成功!我们还可以在不登录的情况下查看我们的 pod 日志:
kubectl logs catfish-service-xxdsd
>> log response
我们现在在 Kubernetes 集群中运行一个部署。让我们探索一些它提供的功能。
扩展服务
假设我们决定一个 pod 无法处理进入我们预测服务的所有流量。在传统部署中,我们必须启动新服务器,将它们添加到负载均衡器中,并解决如果其中一个服务器失败该怎么办的问题。但是使用 Kubernetes,我们可以轻松完成所有这些。让我们确保运行三个服务的副本:
kubectl scale deployment hello-web --replicas=3
如果您继续查看kubectl get pods,您很快会看到 Kubernetes 正在从您的 Docker 镜像中启动另外两个 pod,并将它们连接到负载均衡器。更好的是,让我们看看如果我们删除其中一个 pod 会发生什么:
kubectl delete pod [PODNAME]
kubectl get pods
您会看到我们指定的 pod 已被删除。但是—您还应该看到正在启动一个新的 pod 来替换它!我们告诉 Kubernetes 我们应该运行三个镜像的副本,因为我们删除了一个,集群会启动一个新的 pod 来确保副本计数是我们请求的。这也适用于更新我们的应用程序,所以让我们也看看这个。
更新和清理
当涉及推送更新到我们的服务代码时,我们创建一个带有v2标签的容器的新版本:
docker build -t gcr.io/ml-k8s/catfish-service:v2 .
docker push gcr.io/ml-k8s/catfish-service:v2
然后我们告诉集群使用新镜像进行部署:
kubectl set image deployment/catfish-service
catfish-service=gcr.io/ml-k8s/catfish-service:v2
通过kubectl get pods持续监控,您会看到正在部署具有新镜像的新 pod,并且正在删除具有旧镜像的 pod。Kubernetes 会自动处理连接的排空和从负载均衡器中删除旧 pod。
最后,如果您已经玩够了集群,应该清理一下,以免出现任何意外费用:
kubectl delete service catfish-service
gcloud container clusters delete ml-k8s
这就是我们对 Kubernetes 的迷你之旅;您现在已经知道足够多,可以做出危险的决定,但是一定要查看Kubernetes 网站作为进一步了解该系统的起点(相信我,这方面有很多信息!)
我们已经讨论了如何部署基于 Python 的代码,但也许令人惊讶的是,PyTorch 并不仅限于 Python。在下一节中,您将看到 TorchScript 如何引入更广泛的 C++世界,以及对我们正常的 Python 模型的一些优化。
TorchScript
如果您还记得介绍(我知道!)的话,您会知道 PyTorch 和 TensorFlow 之间的主要区别在于 TensorfFlow 具有模型的基于图形的表示,而 PyTorch 具有基于执行的即时执行和基于磁带的微分。即时方法允许您执行各种动态方法来指定和训练模型,使 PyTorch 对研究目的具有吸引力。另一方面,基于图形的表示可能是静态的,但它从稳定性中获得力量;可以应用优化到图形表示中,确保不会发生任何变化。正如 TensorFlow 已经在 2.0 版本中转向支持即时执行一样,PyTorch 的 1.0 版本引入了 TorchScript,这是一种在不完全放弃 PyTorch 灵活性的情况下带来图形系统优势的方法。这通过两种可以混合和匹配的方式来实现:跟踪和直接使用 TorchScript。
跟踪
PyTorch 1.0 带有一个 JIT 跟踪引擎,它将现有的 PyTorch 模块或函数转换为 TorchScript。它通过将一个示例张量传递到模块中,并返回一个包含原始代码的 TorchScript 表示的ScriptModule结果来实现这一点。
让我们看看跟踪 AlexNet:
model = torchvision.models.AlexNet()
traced_model = torch.jit.trace(model,
torch.rand(1, 3, 224, 224))
现在,这将起作用,但您将从 Python 解释器收到这样的消息,这会让您停下来思考:
TracerWarning: Trace had nondeterministic nodes. Nodes:
%input.15 :
Float(1, 9216) = aten::dropout(%input.14, %174, %175),
scope: AlexNet/Sequential[classifier]/Dropout[0]
%input.18 :
Float(1, 4096) = aten::dropout(%input.17, %184, %185),
scope: AlexNet/Sequential[classifier]/Dropout[3]
This may cause errors in trace checking.
To disable trace checking, pass check_trace=False to torch.jit.trace()
_check_trace([example_inputs], func, executor_options,
module, check_tolerance, _force_outplace)
/home/ian/anaconda3/lib/
python3.6/site-packages/torch/jit/__init__.py:642:
TracerWarning: Output nr 1. of the traced function does not
match the corresponding output of the Python function. Detailed error:
Not within tolerance rtol=1e-05 atol=1e-05 at input[0, 22]
(0.010976361110806465 vs. -0.005604125093668699)
and 996 other locations (99.00%)
_check_trace([example_inputs], func,
executor_options, module, check_tolerance
_force_outplace)
这里发生了什么?当我们创建 AlexNet(或其他模型)时,模型是在训练模式下实例化的。在许多模型(如 AlexNet)的训练过程中,我们使用Dropout层,它会在张量通过网络时随机关闭激活。JIT 所做的是将我们生成的随机张量通过模型两次,进行比较,并注意到Dropout层不匹配。这揭示了跟踪设施的一个重要注意事项;它无法处理不确定性或控制流。如果您的模型使用这些特性,您将不得不至少部分使用 TorchScript 进行转换。
在 AlexNet 的情况下,修复很简单:我们将通过使用model.eval()将模型切换到评估模式。如果再次运行跟踪行,您会发现它完成而没有任何抱怨。我们还可以print()跟踪的模型以查看其组成部分:
print(traced_model)
TracedModuleAlexNet: TracedModuleSequential: TracedModule[Conv2d]()
(1): TracedModule[ReLU]()
(2): TracedModule[MaxPool2d]()
(3): TracedModule[Conv2d]()
(4): TracedModule[ReLU]()
(5): TracedModule[MaxPool2d]()
(6): TracedModule[Conv2d]()
(7): TracedModule[ReLU]()
(8): TracedModule[Conv2d]()
(9): TracedModule[ReLU]()
(10): TracedModule[Conv2d]()
(11): TracedModule[ReLU]()
(12): TracedModule[MaxPool2d]()
)
(classifier): TracedModuleSequential: TracedModule[Dropout]()
(1): TracedModule[Linear]()
(2): TracedModule[ReLU]()
(3): TracedModule[Dropout]()
(4): TracedModule[Linear]()
(5): TracedModule[ReLU]()
(6): TracedModule[Linear]()
)
)
如果调用print(traced_model.code),我们还可以看到 JIT 引擎创建的代码。
def forward(self,
input_1: Tensor) -> Tensor:
input_2 = torch._convolution(input_1, getattr(self.features, "0").weight,
getattr(self.features, "0").bias,
[4, 4], [2, 2], [1, 1], False, [0, 0], 1, False, False, True)
input_3 = torch.threshold_(input_2, 0., 0.)
input_4, _0 = torch.max_pool2d_with_indices
(input_3, [3, 3], [2, 2], [0, 0], [1, 1], False)
input_5 = torch._convolution(input_4, getattr
(self.features, "3").weight, getattr(self.features, "3").bias,
[1, 1], [2, 2], [1, 1], False, [0, 0], 1, False, False, True)
input_6 = torch.threshold_(input_5, 0., 0.)
input_7, _1 = torch.max_pool2d_with_indices
(input_6, [3, 3], [2, 2], [0, 0], [1, 1], False)
input_8 = torch._convolution(input_7, getattr(self.features, "6").weight,
getattr
(self.features, "6").bias,
[1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True)
input_9 = torch.threshold_(input_8, 0., 0.)
input_10 = torch._convolution(input_9, getattr
(self.features, "8").weight, getattr(self.features, "8").bias,
[1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True)
input_11 = torch.threshold_(input_10, 0., 0.)
input_12 = torch._convolution(input_11, getattr
(self.features, "10").weight, getattr(self.features, "10").bias,
[1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True)
input_13 = torch.threshold_(input_12, 0., 0.)
x, _2 = torch.max_pool2d_with_indices
(input_13, [3, 3], [2, 2], [0, 0], [1, 1], False)
_3 = ops.prim.NumToTensor(torch.size(x, 0))
input_14 = torch.view(x, [int(_3), 9216])
input_15 = torch.dropout(input_14, 0.5, False)
_4 = torch.t(getattr(self.classifier, "1").weight)
input_16 = torch.addmm(getattr(self.classifier, "1").bias,
input_15, _4, beta=1, alpha=1)
input_17 = torch.threshold_(input_16, 0., 0.)
input_18 = torch.dropout(input_17, 0.5, False)
_5 = torch.t(getattr(self.classifier, "4").weight)
input_19 = torch.addmm(getattr(self.classifier, "4").bias,
input_18, _5, beta=1, alpha=1)
input = torch.threshold_(input_19, 0., 0.)
_6 = torch.t(getattr(self.classifier, "6").weight)
_7 = torch.addmm(getattr(self.classifier, "6").bias, input,
_6, beta=1, alpha=1)
return _7
然后可以使用torch.jit.save保存模型(代码和参数):
torch.jit.save(traced_model, "traced_model")
这涵盖了跟踪的工作原理。让我们看看如何使用 TorchScript。
脚本化
您可能想知道为什么我们不能跟踪一切。尽管跟踪器在其所做的事情上很擅长,但它也有局限性。例如,像以下简单函数这样的函数不可能通过单次传递进行跟踪:
import torch
def example(x, y):
if x.min() > y.min():
r = x
else:
r = y
return r
通过函数的单个跟踪将使我们沿着一条路径而不是另一条路径,这意味着函数将无法正确转换。在这些情况下,我们可以使用 TorchScript,这是 Python 的一个有限子集,并生成我们的编译代码。我们使用注释告诉 PyTorch 我们正在使用 TorchScript,因此 TorchScript 实现将如下所示:
@torch.jit.script
def example(x, y):
if x.min() > y.min():
r = x
else:
r = y
return r
幸运的是,我们的函数中没有使用 TorchScript 中没有的构造或引用任何全局状态,因此这将正常工作。如果我们正在创建一个新的架构,我们需要继承自torch.jit.ScriptModule而不是nn.Module。您可能想知道如果所有模块都必须继承自这个不同的类,我们如何可以使用其他模块(比如基于 CNN 的层)。一切都稍微不同吗?解决方法是我们可以随意使用显式 TorchScript 和跟踪对象来混合和匹配。
让我们回到第三章中的 CNNNet/AlexNet 结构,看看如何使用这些方法的组合将其转换为 TorchScript。为简洁起见,我们只实现features组件:
class FeaturesCNNNet(torch.jit.ScriptModule):
def __init__(self, num_classes=2):
super(FeaturesCNNNet, self).__init__()
self.features = torch.jit.trace(nn.Sequential(
nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(64, 192, kernel_size=5, padding=2),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(192, 384, kernel_size=3, padding=1),
nn.ReLU(),
nn.Conv2d(384, 256, kernel_size=3, padding=1),
nn.ReLU(),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2)
), torch.rand(1,3,224,224))
@torch.jit.script_method
def forward(self, x):
x = self.features(x)
return x
这里有两件事需要注意。首先,在类内部,我们需要使用 @torch.jit.script_method 进行注释。其次,尽管我们可以单独跟踪每个单独的层,但我们利用了 nn.Sequential 包装层,只需通过它来触发跟踪。你可以自己实现 classifier 块,以了解这种混合工作的感觉。请记住,你需要将 Dropout 层切换到 eval() 模式而不是训练模式,并且由于 features 块进行的下采样,你的输入跟踪张量的形状需要是 [1, 256, 6, 6]。是的,你可以像我们为跟踪模块所做的那样使用 torch.jit.save 来保存这个网络。让我们看看 TorchScript 允许和禁止什么。
TorchScript 的限制
与 Python 相比,至少在我看来,TorchScript 的最大限制是可用类型数量减少了。表 8-1 列出了可用和不可用的类型。
表 8-1. TorchScript 中可用的 Python 类型
| 类型 | 描述 |
|---|---|
tensor | 一个 PyTorch 张量,可以是任何数据类型、维度或后端 |
tuple[T0, T1,…] | 包含子类型 T0、T1 等的元组(例如,tuple[tensor, tensor]) |
boolean | 布尔值 |
str | 字符串 |
int | 整数 |
float | 浮点数 |
list | 类型为 T 的列表 |
optional[T] | 要么为 None,要么为类型 T |
dict[K, V] | 键为类型 K,值为类型 V 的字典;K 只能是 str、int 或 float |
在标准 Python 中可以做但在 TorchScript 中不能做的另一件事是具有混合返回类型的函数。以下在 TorchScript 中是非法的:
def maybe_a_string_or_int(x):
if x > 3:
return "bigger than 3!"
else
return 2
当然,在 Python 中也不是一个好主意,但语言的动态类型允许这样做。TorchScript 是静态类型的(有助于应用优化),因此在 TorchScript 注释代码中你根本无法这样做。此外,TorchScript 假定传递给函数的每个参数都是张量,如果你不知道发生了什么,可能会导致一些奇怪的情况:
@torch.jit.script
def add_int(x,y):
return x + y
print(add_int.code)
>> def forward(self,
x: Tensor,
y: Tensor) -> Tensor:
return torch.add(x, y, alpha=1)
为了强制不同的类型,我们需要使用 Python 3 的类型装饰器:
@torch.jit.script
def add_int(x: int, y: int) -> int:
return x + y
print(add_int.code)
>> def forward(self,
x: int,
y: int) -> int:
return torch.add(x, y)
正如你已经看到的,类是受支持的,但有一些细微差别。类中的所有方法都必须是有效的 TorchScript,但尽管这段代码看起来有效,它将失败:
@torch.jit.script
class BadClass:
def __init__(self, x)
self.x = x
def set_y(y)
self.y = y
这又是 TorchScript 静态类型的一个结果。所有实例变量都必须在 __init__ 中声明,不能在其他地方引入。哦,不要想着在类中包含任何不在方法中的表达式——这些都被 TorchScript 明确禁止了。
TorchScript 作为 Python 的一个子集的一个有用特性是,翻译可以以逐步的方式进行,中间代码仍然是有效的可执行 Python。符合 TorchScript 的代码可以调用不符合规范的代码,虽然在所有不符合规范的代码转换之前你无法执行 torch.jit.save(),但你仍然可以在 Python 下运行所有内容。
这些是我认为 TorchScript 的主要细微差别。你可以阅读更多关于PyTorch 文档中的内容,其中深入探讨了诸如作用域(主要是标准 Python 规则)之类的内容,但这里提供的概述足以将你在本书中迄今看到的所有模型转换过来。让我们看看如何在 C++中使用我们的一个 TorchScript 启用的模型。
使用 libTorch
除了 TorchScript 之外,PyTorch 1.0 还引入了libTorch,这是一个用于与 PyTorch 交互的 C++库。有各种不同级别的 C++交互可用。最低级别是ATen和autograd,这是 PyTorch 本身构建在其上的张量和自动微分的 C++实现。在这些之上是一个 C++前端,它在 C++中复制了 Pythonic PyTorch API,一个与 TorchScript 的接口,最后是一个扩展接口,允许定义和暴露新的自定义 C++/CUDA 运算符给 PyTorch 的 Python 实现。在本书中,我们只关注 C++前端和与 TorchScript 的接口,但有关其他部分的更多信息可在PyTorch 文档中找到。让我们从获取libTorch开始。
获取 libTorch 和 Hello World
在我们做任何事情之前,我们需要一个 C++编译器和一种在我们的机器上构建 C++程序的方法。这是本书中少数几个部分之一,类似 Google Colab 之类的东西不适用,因此如果您没有轻松访问终端窗口,可能需要在 Google Cloud、AWS 或 Azure 中创建一个 VM。 (所有忽略了我的建议不要构建专用机器的人现在都感到自鸣得意,我打赌!)libTorch的要求是一个 C++编译器和CMake,所以让我们安装它们。对于基于 Debian 的系统,请使用以下命令:
apt install cmake g++
如果您使用的是基于 Red Hat 的系统,请使用以下命令:
yum install cmake g++
接下来,我们需要下载libTorch本身。为了让接下来的事情变得更容易,我们将使用基于 CPU 的libTorch分发,而不是处理启用 GPU 的分发带来的额外 CUDA 依赖。创建一个名为torchscript_export的目录并获取分发:
wget https://download.pytorch.org/libtorch/cpu/libtorch-shared-with-deps-latest.zip
使用unzip来展开 ZIP 文件(它应该创建一个新的libtorch目录),并创建一个名为helloworld的目录。在这个目录中,我们将添加一个最小的CMakeLists.txt,CMake将用它来构建我们的可执行文件:
cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(helloworld)
find_package(Torch REQUIRED)
add_executable(helloworld helloworld.cpp)
target_link_libraries(helloworld "${TORCH_LIBRARIES}")
set_property(TARGET helloword PROPERTY CXX_STANDARD 11)
然后helloworld.cpp如下:
#include <torch/torch.h>
#include <iostream>
int main() {
torch::Tensor tensor = torch::ones({2, 2});
std::cout << tensor << std::endl;
}
创建一个build目录并运行**cmake**,确保我们提供libtorch分发的绝对路径:
mkdir build
cd build
cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch ..
cd ..
现在我们可以运行简单的make来创建我们的可执行文件:
make
./helloworld
1 1
1 1
[ Variable[CPUType]{2,2} ]
祝贺您使用libTorch构建了您的第一个 C++程序!现在,让我们扩展一下,看看如何使用该库加载我们之前用torch.jit.save()保存的模型。
导入 TorchScript 模型
我们将从第三章中导出我们的完整 CNNNet 模型,并将其加载到 C++中。在 Python 中,创建 CNNNet 的实例,将其切换到eval()模式以忽略Dropout,跟踪并保存到磁盘:
cnn_model = CNNNet()
cnn_model.eval()
cnn_traced = torch.jit.trace(cnn_model, torch.rand([1,3,224,224]))
torch.jit.save(cnn_traced, "cnnnet")
在 C++世界中,创建一个名为load-cnn的新目录,并添加这个新的CMakeLists.txt文件:
cmake_minimum_required(VERSION 3.0 FATAL_ERROR)
project(load-cnn)
find_package(Torch REQUIRED)
add_executable(load-cnn.cpp load-cnn.cpp)
target_link_libraries(load-cnn "${TORCH_LIBRARIES}")
set_property(TARGET load-cnn PROPERTY CXX_STANDARD 11)
让我们创建我们的 C++程序load-cnn.cpp:
#include <torch/script.h>
#include <iostream>
#include <memory>
int main(int argc, const char* argv[]) {
std::shared_ptr<torch::jit::script::Module> module = torch::jit::load("cnnnet");
assert(module != nullptr);
std::cout << "model loaded ok\n";
// Create a vector of inputs.
std::vector<torch::jit::IValue> inputs;
inputs.push_back(torch::rand({1, 3, 224, 224}));
at::Tensor output = module->forward(inputs).toTensor();
std::cout << output << '\n'
}
这个小程序中有一些新东西,尽管其中大部分应该让您想起 Python PyTorch API。我们的第一步是使用torch::jit::load加载我们的 TorchScript 模型(与 Python 中的torch.jit.load不同)。我们进行空指针检查以确保模型已正确加载,然后我们继续使用随机张量测试模型。虽然我们可以很容易地使用torch::rand来做到这一点,但在与 TorchScript 模型交互时,我们必须创建一个torch::jit::IValue输入向量,而不仅仅是一个普通张量,因为 TorchScript 在 C++中的实现方式。完成后,我们可以将张量传递给我们加载的模型,最后将结果写回标准输出。我们以与之前编译我们的程序相同的方式编译它:
mkdir build
cd build
cmake -DCMAKE_PREFIX_PATH=/absolute/path/to/libtorch ..
cd ..
make
./load-cnn
0.1775
0.9096
[ Variable[CPUType]{2} ]
看吧!一个 C++程序,可以轻松地执行自定义模型。请注意,C++接口在撰写本文时仍处于测试阶段,因此这里的一些细节可能会发生变化。在愤怒使用之前,请务必查看文档!
结论
希望您现在了解如何将经过训练(和调试!)的模型转换为可以通过 Kubernetes 部署的 Docker 化 Web 服务。您还看到了如何使用 JIT 和 TorchScript 功能优化我们的模型,以及如何在 C++中加载 TorchScript 模型,为我们提供了神经网络的低级集成承诺,以及在 Python 中。
显然,仅凭一章,我们无法涵盖有关模型服务的生产使用的所有内容。我们已经部署了我们的服务,但这并不是故事的结束;需要不断监控服务,确保其保持准确性,重新训练并针对基线进行测试,以及比我在这里介绍的更复杂的服务和模型参数版本控制方案。我建议您尽可能记录详细信息,并利用该日志信息进行重新训练以及监控目的。
至于 TorchScript,现在还处于早期阶段,但其他语言的一些绑定(例如 Go 和 Rust)开始出现;到 2020 年,将很容易将 PyTorch 模型与任何流行语言连接起来。
我故意省略了一些与本书范围不太符合的细节。在介绍中,我承诺您可以使用一块 GPU 完成本书中的所有操作,因此我们没有讨论 PyTorch 对分布式训练和推断的支持。此外,如果您阅读有关 PyTorch 模型导出的信息,几乎肯定会遇到许多关于 Open Neural Network Exchange(ONNX)的引用。这个标准由微软和 Facebook 联合撰写,在 TorchScript 出现之前是导出模型的主要方法。模型可以通过类似于 TorchScript 的跟踪方法导出,然后在其他框架(如 Caffe2、Microsoft Cognitive Toolkit 和 MXNet)中导入。ONNX 仍然受到 PyTorch v1.x的支持和积极开发,但似乎 TorchScript 是模型导出的首选方式。如果您感兴趣,可以查看“进一步阅读”部分,了解更多关于 ONNX 的详细信息。
成功创建、调试和部署了我们的模型后,我们将在最后一章中看看一些公司如何使用 PyTorch。
进一步阅读
-
使用 PyTorch 进行分布式训练
¹ 使用 Kubernetes 的云原生 DevOps 由 John Arundel 和 Justin Domingus(O'Reilly)深入探讨了这一框架。
第九章:PyTorch 在实践中
在我们的最后一章中,我们将看看 PyTorch 如何被其他人和公司使用。您还将学习一些新技术,包括调整图片大小、生成文本和创建可以欺骗神经网络的图像。与之前章节略有不同的是,我们将集中在如何使用现有库快速上手,而不是从头开始使用 PyTorch。我希望这将成为进一步探索的跳板。
让我们从检查一些最新的方法开始,以充分利用您的数据。
数据增强:混合和平滑
回到第四章,我们看了各种增强数据的方法,以帮助减少模型在训练数据集上的过拟合。在深度学习研究中,能够用更少的数据做更多事情自然是一个活跃的领域,在本节中,我们将看到两种越来越受欢迎的方法,以从您的数据中挤出最后一滴信号。这两种方法也将使我们改变如何计算我们的损失函数,因此这将是对我们刚刚创建的更灵活的训练循环的一个很好的测试。
mixup
mixup是一种有趣的增强技术,它源于对我们希望模型做什么的侧面看法。我们对模型的正常理解是,我们向其发送一张像图 9-1 中的图像,并希望模型返回一个结果,即该图像是一只狐狸。
图 9-1。一只狐狸
但是,正如您所知,我们不仅从模型中得到这些;我们得到一个包含所有可能类别的张量,希望该张量中具有最高值的元素是狐狸类。实际上,在理想情况下,我们将有一个张量,除了狐狸类中的 1 之外,其他都是 0。
除了神经网络很难做到这一点!总会有不确定性,我们的激活函数如softmax使得张量很难达到 1 或 0。mixup 利用这一点提出了一个问题:图 9-2 的类是什么?
图 9-2。一只猫和一只狐狸的混合
对我们来说,这可能有点混乱,但是它是 60%的猫和 40%的狐狸。如果我们不试图让我们的模型做出明确的猜测,而是让它针对两个类别呢?这意味着我们的输出张量在训练中不会遇到接近但永远无法达到 1 的问题,我们可以通过不同的分数改变每个混合图像,提高我们模型的泛化能力。
但是我们如何计算这个混合图像的损失函数呢?如果p是混合图像中第一幅图像的百分比,那么我们有以下简单的线性组合:
p * loss(image1) + (1-p) * loss(image2)
它必须预测这些图像,对吧?我们需要根据这些图像在最终混合图像中的比例来缩放,因此这种新的损失函数似乎是合理的。要选择p,我们可以像在许多其他情况下那样,使用从正态分布或均匀分布中抽取的随机数。然而,mixup 论文的作者确定,从beta分布中抽取的样本在实践中效果要好得多。不知道 beta 分布是什么样子?嗯,我在看到这篇论文之前也不知道!图 9-3 展示了在给定论文中描述的特征时它的样子。
图 9-3。Beta 分布,其中⍺ = β
U 形状很有趣,因为它告诉我们,大部分时间,我们混合的图像主要是一张图像或另一张图像。再次,这是直观的,因为我们可以想象网络在工作中会更难以解决 50/50 混合比例而不是 90/10 的情况。
这是一个修改后的训练循环,它接受一个新的额外数据加载器mix_loader,并将批次混合在一起:
def train(model, optimizer, loss_fn, train_loader, val_loader,
epochs=20, device, mix_loader):
for epoch in range(epochs):
model.train()
for batch in zip(train_loader,mix_loader):
((inputs, targets),(inputs_mix, targets_mix)) = batch
optimizer.zero_grad()
inputs = inputs.to(device)
targets = targets.to(device)
inputs_mix = inputs_mix.to(device)
target_mix = targets_mix.to(device)
distribution = torch.distributions.beta.Beta(0.5,0.5)
beta = distribution.expand(torch.zeros(batch_size).shape).sample().to(device)
# We need to transform the shape of beta
# to be in the same dimensions as our input tensor
# [batch_size, channels, height, width]
mixup = beta[:, None, None, None]
inputs_mixed = (mixup * inputs) + (1-mixup * inputs_mix)
# Targets are mixed using beta as they have the same shape
targets_mixed = (beta * targets) + (1-beta * inputs_mix)
output_mixed = model(inputs_mixed)
# Multiply losses by beta and 1-beta,
# sum and get average of the two mixed losses
loss = (loss_fn(output, targets) * beta
+ loss_fn(output, targets_mixed)
* (1-beta)).mean()
# Training method is as normal from herein on
loss.backward()
optimizer.step()
…
这里发生的是在获取两个批次后,我们使用torch.distribution.Beta生成一系列混合参数,使用expand方法生成一个[1, batch_size]的张量。我们可以遍历批次并逐个生成参数,但这样更整洁,记住,GPU 喜欢矩阵乘法,所以一次跨批次进行所有计算会更快(这在第七章中展示了,当修复我们的BadRandom转换时,记住!)。我们将整个批次乘以这个张量,然后使用广播将要混合的批次乘以1 - mix_factor_tensor。
然后我们计算两个图像的预测与目标之间的损失,最终的损失是这些损失之和的平均值。发生了什么?如果你查看CrossEntropyLoss的源代码,你会看到注释损失在每个 minibatch 的观察中进行平均。还有一个reduction参数,默认设置为mean(到目前为止我们使用了默认值,所以你之前没有看到它!)。我们需要保持这个条件,所以我们取我们合并的损失的平均值。
现在,拥有两个数据加载器并不会带来太多麻烦,但它确实使代码变得更加复杂。如果你运行这段代码,可能会出错,因为最终批次从加载器中出来时不平衡,这意味着你将不得不编写额外的代码来处理这种情况。mixup 论文的作者建议你可以用随机洗牌来替换混合数据加载器。我们可以使用torch.randperm()来实现这一点:
shuffle = torch.randperm(inputs.size(0))
inputs_mix = inputs[shuffle]
targets_mix = targets[shuffle]
在这种方式下使用 mixup 时,要注意更有可能出现碰撞,即最终将相同的参数应用于相同的图像集,可能会降低训练的准确性。例如,你可能有 cat1 与 fish1 混合,然后抽取一个 beta 参数为 0.3。然后在同一批次中的后续步骤中,你再次抽取 fish1 并将其与 cat1 混合,参数为 0.7—这样就得到了相同的混合!一些 mixup 的实现—特别是 fast.ai 的实现—通过用以下内容替换我们的混合参数来解决这个问题:
mix_parameters = torch.max(mix_parameters, 1 - mix_parameters)
这确保了非混洗的批次在与混合批次合并时始终具有最高的分量,从而消除了潜在的问题。
哦,还有一件事:我们在图像转换流程之后执行了 mixup 转换。此时,我们的批次只是我们相加在一起的张量。这意味着 mixup 训练不应该仅限于图像。我们可以对任何转换为张量的数据使用它,无论是文本、图像、音频还是其他任何类型的数据。
我们仍然可以做更多工作让我们的标签更加有效。现在进入另一种方法,这种方法现在是最先进模型的主要特点:标签平滑。
标签平滑
与 mixup 类似,标签平滑有助于通过使模型对其预测不那么确定来提高模型性能。我们不再试图强迫它预测预测类别为1(这在前一节中讨论的所有问题中都有问题),而是将其改为预测 1 减去一个小值,epsilon。我们可以创建一个新的损失函数实现,将我们现有的CrossEntropyLoss函数与这个功能包装在一起。事实证明,编写自定义损失函数只是nn.Module的另一个子类:
class LabelSmoothingCrossEntropyLoss(nn.Module):
def __init__(self, epsilon=0.1):
super(LabelSmoothingCrossEntropyLoss, self).__init__()
self.epsilon = epsilon
def forward(self, output, target):
num_classes = output.size()[-1]
log_preds = F.log_softmax(output, dim=-1)
loss = (-log_preds.sum(dim=-1)).mean()
nll = F.nll_loss(log_preds, target)
final_loss = self.epsilon * loss / num_classes +
(1-self.epsilon) * nll
return final_loss
在计算损失函数时,我们按照CrossEntropyLoss的实现计算交叉熵损失。我们的final_loss由负对数似然乘以 1 减 epsilon(我们的平滑标签)加上损失乘以 epsilon 除以类别数构成。这是因为我们不仅将预测类别的标签平滑为 1 减 epsilon,还将其他标签平滑为不是被迫为零,而是在零和 epsilon 之间的值。
这个新的自定义损失函数可以替代书中任何地方使用的CrossEntropyLoss进行训练,并与 mixup 结合使用,是从输入数据中获得更多的一种非常有效的方法。
现在我们将从数据增强转向另一个当前深度学习趋势中的热门话题:生成对抗网络。
计算机,增强!
深度学习能力不断增强的一个奇怪后果是,几十年来,我们计算机人一直在嘲笑那些电视犯罪节目,其中侦探点击按钮,使模糊的摄像头图像突然变得清晰、聚焦。我们曾经嘲笑和嘲弄 CSI 等节目做这种事情。但现在我们实际上可以做到这一点,至少在一定程度上。这里有一个巫术的例子,将一个较小的 256×256 图像缩放到 512×512,见图 9-4 和 9-5。
图 9-4. 256×256 分辨率下的邮箱
图 9-5. 512×512 分辨率下的 ESRGAN 增强邮箱
神经网络学习如何幻想新的细节来填补不存在的部分,效果可能令人印象深刻。但这是如何工作的呢?
超分辨率简介
这是一个非常简单的超分辨率模型的第一部分。起初,它几乎与你迄今为止看到的任何模型完全相同:
class OurFirstSRNet(nn.Module):
def __init__(self):
super(OurFirstSRNet, self).__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=8, stride=4, padding=2),
nn.ReLU(inplace=True),
nn.Conv2d(64, 192, kernel_size=2, padding=2),
nn.ReLU(inplace=True),
nn.Conv2d(192, 256, kernel_size=2, padding=2),
nn.ReLU(inplace=True)
)
def forward(self, x):
x = self.features(x)
return x
如果我们通过网络传递一个随机张量,我们最终得到一个形状为[1, 256, 62, 62]的张量;图像表示已经被压缩为一个更小的向量。现在让我们引入一个新的层类型,torch.nn.ConvTranspose2d。你可以将其视为一个反转标准Conv2d变换的层(具有自己的可学习参数)。我们将添加一个新的nn.Sequential层,upsample,并放入一系列这些新层和ReLU激活函数。在forward()方法中,我们将输入通过其他层后通过这个整合层:
class OurFirstSRNet(nn.Module):
def __init__(self):
super(OurFirstSRNet, self).__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=8, stride=4, padding=2),
nn.ReLU(inplace=True),
nn.Conv2d(64, 192, kernel_size=2, padding=2),
nn.ReLU(inplace=True),
nn.Conv2d(192, 256, kernel_size=2, padding=2),
nn.ReLU(inplace=True)
)
self.upsample = nn.Sequential(
nn.ConvTranspose2d(256,192,kernel_size=2, padding=2),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(192,64,kernel_size=2, padding=2),
nn.ReLU(inplace=True),
nn.ConvTranspose2d(64,3, kernel_size=8, stride=4,padding=2),
nn.ReLU(inplace=True)
)
def forward(self, x):
x = self.features(x)
x = self.upsample(x)
return x
如果现在用一个随机张量测试模型,你将得到一个与输入完全相同大小的张量!我们构建的是一个自动编码器,一种网络类型,通常在将其压缩为更小维度后重新构建其输入。这就是我们在这里做的;features顺序层是一个编码器,将图像转换为大小为[1, 256, 62, 62]的张量,upsample层是我们的解码器,将其转换回原始形状。
用于训练图像的标签当然是我们的输入图像,但这意味着我们不能使用像我们相当标准的CrossEntropyLoss这样的损失函数,因为,嗯,我们没有类别!我们想要的是一个告诉我们输出图像与输入图像有多大不同的损失函数,为此,计算图像像素之间的均方损失或均绝对损失是一种常见方法。
注意
尽管以像素为单位计算损失非常合理,但事实证明,许多最成功的超分辨率网络使用增强损失函数,试图捕捉生成图像与原始图像的相似程度,容忍像素损失以获得更好的纹理和内容损失性能。一些列在“进一步阅读”中的论文深入讨论了这一点。
现在,这使我们回到了与输入相同大小的输入,但如果我们在其中添加另一个转置卷积会怎样呢?
self.upsample = nn.Sequential(...
nn.ConvTranspose2d(3,3, kernel_size=2, stride=2)
nn.ReLU(inplace=True))
试试吧!您会发现输出张量是输入的两倍大。如果我们有一组与该大小相同的地面真实图像作为标签,我们可以训练网络以接收大小为x的图像并为大小为2x的图像生成图像。在实践中,我们倾向于通过扩大两倍所需的量,然后添加一个标准的卷积层来执行这种上采样,如下所示:
self.upsample = nn.Sequential(......
nn.ConvTranspose2d(3,3, kernel_size=2, stride=2),
nn.ReLU(inplace=True),
nn.Conv2d(3,3, kernel_size=2, stride=2),
nn.ReLU(inplace=True))
我们这样做是因为转置卷积有添加锯齿和 moire 图案的倾向,因为它扩展图像。通过扩展两次,然后缩小到我们需要的大小,我们希望为网络提供足够的信息来平滑这些图案,并使输出看起来更真实。
这些是超分辨率背后的基础。目前大多数性能优越的超分辨率网络都是使用一种称为生成对抗网络的技术进行训练的,这种技术在过去几年中席卷了深度学习世界。
GANs 简介
深度学习(或任何机器学习应用)中的一个普遍问题是产生标记数据的成本。在本书中,我们大多数情况下通过使用精心标记的样本数据集来避免这个问题(甚至一些预先打包的易于训练/验证/测试集!)。但在现实世界中,产生大量标记数据。确实,到目前为止,您学到的技术,如迁移学习,都是关于如何用更少的资源做更多的事情。但有时您需要更多,生成对抗网络(GANs)有办法帮助。
GANs 是由 Ian Goodfellow 在 2014 年的一篇论文中提出的,是一种提供更多数据以帮助训练神经网络的新颖方法。而这种方法主要是“我们知道你喜欢神经网络,所以我们添加了另一个。”
伪造者和评论家
GAN 的设置如下。两个神经网络一起训练。第一个是生成器,它从输入张量的向量空间中获取随机噪声,并产生虚假数据作为输出。第二个网络是鉴别器,它在生成的虚假数据和真实数据之间交替。它的工作是查看传入的输入并决定它们是真实的还是虚假的。GAN 的简单概念图如图 9-6 所示。
图 9-6。一个简单的 GAN 设置
GANs 的伟大之处在于,尽管细节最终变得有些复杂,但总体思想很容易传达:这两个网络相互对立,在训练过程中,它们尽力击败对方。在过程结束时,生成器应该生成与真实输入数据的分布匹配的数据,以迷惑鉴别器。一旦达到这一点,您可以使用生成器为所有需求生成更多数据,而鉴别器可能会退休到神经网络酒吧淹没忧愁。
训练 GAN
训练 GAN 比训练传统网络稍微复杂一些。在训练循环中,我们首先需要使用真实数据开始训练鉴别器。我们计算鉴别器的损失(使用 BCE,因为我们只有两类:真实或虚假),然后进行反向传播以更新鉴别器的参数,就像往常一样。但这一次,我们不调用优化器来更新。相反,我们从生成器生成一批数据并通过模型传递。我们计算损失并进行另一次反向传播,因此此时训练循环已计算了两次通过模型的损失。现在,我们根据这些累积梯度调用优化器进行更新。
在训练的后半段,我们转向生成器。我们让生成器访问鉴别器,然后生成一批新数据(生成器坚持说这些都是真实的!)并将其与鉴别器进行测试。我们根据这些输出数据形成一个损失,鉴别器说是假的每个数据点都被视为错误答案——因为我们试图欺骗它——然后进行标准的反向/优化传递。
这是 PyTorch 中的一个通用实现。请注意,生成器和鉴别器只是标准的神经网络,因此从理论上讲,它们可以生成图像、文本、音频或任何类型的数据,并且可以由迄今为止看到的任何类型的网络构建:
generator = Generator()
discriminator = Discriminator()
# Set up separate optimizers for each network
generator_optimizer = ...
discriminator_optimizer = ...
def gan_train():
for epoch in num_epochs:
for batch in real_train_loader:
discriminator.train()
generator.eval()
discriminator.zero_grad()
preds = discriminator(batch)
real_loss = criterion(preds, torch.ones_like(preds))
discriminator.backward()
fake_batch = generator(torch.rand(batch.shape))
fake_preds = discriminator(fake_batch)
fake_loss = criterion(fake_preds, torch.zeros_like(fake_preds))
discriminator.backward()
discriminator_optimizer.step()
discriminator.eval()
generator.train()
generator.zero_grad()
forged_batch = generator(torch.rand(batch.shape))
forged_preds = discriminator(forged_batch)
forged_loss = criterion(forged_preds, torch.ones_like(forged_preds))
generator.backward()
generator_optimizer.step()
请注意,PyTorch 的灵活性在这里非常有帮助。没有专门为更标准的训练而设计的训练循环,构建一个新的训练循环是我们习惯的事情,我们知道需要包含的所有步骤。在其他一些框架中,训练 GAN 有点更加繁琐。这很重要,因为训练 GAN 本身就是一个困难的任务,如果框架阻碍了这一过程,那就更加困难了。
模式崩溃的危险
在理想的情况下,训练过程中发生的是,鉴别器一开始会擅长检测假数据,因为它是在真实数据上训练的,而生成器只允许访问鉴别器而不是真实数据本身。最终,生成器将学会如何欺骗鉴别器,然后它将迅速改进以匹配数据分布,以便反复产生能够欺骗评论者的伪造品。
但是困扰许多 GAN 架构的一件事是模式崩溃。如果我们的真实数据有三种类型的数据,那么也许我们的生成器会开始生成第一种类型,也许它开始变得相当擅长。鉴别器可能会决定任何看起来像第一种类型的东西实际上是假的,甚至是真实的例子本身,然后生成器开始生成看起来像第三种类型的东西。鉴别器开始拒绝所有第三种类型的样本,生成器选择另一个真实例子来生成。这个循环无休止地继续下去;生成器永远无法进入一个可以从整个分布中生成样本的阶段。
减少模式崩溃是使用 GAN 的关键性能问题,也是一个正在进行研究的领域。一些方法包括向生成的数据添加相似性分数,以便可以检测和避免潜在的崩溃,保持一个生成图像的重放缓冲区,以便鉴别器不会过度拟合到最新批次的生成图像,允许从真实数据集中添加实际标签到生成器网络等等。
接下来,我们通过检查一个执行超分辨率的 GAN 应用程序来结束本节。
ESRGAN
增强超分辨率生成对抗网络(ESRGAN)是一种在 2018 年开发的网络,可以产生令人印象深刻的超分辨率结果。生成器是一系列卷积网络块,其中包含残差和稠密层连接的组合(因此是 ResNet 和 DenseNet 的混合),移除了BatchNorm层,因为它们似乎会在上采样图像中产生伪影。对于鉴别器,它不是简单地产生一个结果,说这是真实的或这是假的,而是预测一个真实图像相对更真实的概率比一个假图像更真实,这有助于使模型产生更自然的结果。
运行 ESRGAN
为了展示 ESRGAN,我们将从GitHub 存储库下载代码。使用**git**克隆:
git clone https://github.com/xinntao/ESRGAN
然后我们需要下载权重,这样我们就可以在不训练的情况下使用模型。使用自述文件中的 Google Drive 链接,下载RRDB_ESRGAN_x4.pth文件并将其放在*./models中。我们将对 Helvetica 的缩小版本进行上采样,但可以随意将任何图像放入./LR目录。运行提供的test.py脚本,您将看到生成的上采样图像并保存在results*目录中。
这就是超分辨率的全部内容,但我们还没有完成图像处理。
图像检测的进一步探索
我们在第二章到第四章的图像分类都有一个共同点:我们确定图像属于一个类别,猫或鱼。显然,在实际应用中,这将扩展到一个更大的类别集。但我们也希望图像可能包含猫和鱼(这对鱼可能是个坏消息),或者我们正在寻找的任何类别。场景中可能有两个人、一辆车和一艘船,我们不仅希望确定它们是否出现在图像中,还希望确定它们在图像中的位置。有两种主要方法可以实现这一点:目标检测和分割。我们将看看这两种方法,然后转向 Facebook 的 PyTorch 实现的 Faster R-CNN 和 Mask R-CNN,以查看具体示例。
目标检测
让我们看看我们的盒子里的猫。我们真正想要的是让网络将猫放在另一个盒子里!特别是,我们希望有一个边界框,包围模型认为是猫的图像中的所有内容,如图 9-7 所示。
图 9-7. 盒子里的猫在一个边界框中
但我们如何让我们的网络解决这个问题呢?请记住,这些网络可以预测您想要的任何内容。如果在我们的 CATFISH 模型中,我们除了预测一个类别之外,还产生四个额外的输出怎么样?我们将有一个输出大小为6的Linear层,而不是2。额外的四个输出将使用*x[1]、x[2]、y[1]、y[2]*坐标定义一个矩形。我们不仅要提供图像作为训练数据,还必须用边界框增强它们,以便模型有东西可以训练,当然。我们的损失函数现在将是类别预测的交叉熵损失和边界框的均方损失的组合损失。
这里没有魔法!我们只需设计模型以满足我们的需求,提供具有足够信息的数据进行训练,并包含一个告诉网络它的表现如何的损失函数。
与边界框的泛滥相比,分割 是一种替代方法。我们的网络不是生成框,而是输出与输入相同大小的图像掩模;掩模中的像素根据它们所属的类别着色。例如,草可能是绿色的,道路可能是紫色的,汽车可能是红色的,等等。
由于我们正在输出图像,您可能会认为我们最终会使用与超分辨率部分类似的架构。这两个主题之间存在很多交叉,近年来变得流行的一种模型类型是U-Net架构,如图 9-8 所示。³
图 9-8. 简化的 U-Net 架构
正如您所看到的,经典的 U-Net 架构是一组卷积块,将图像缩小,另一系列卷积将其缩放回目标图像。然而,U-Net 的关键在于从左侧块到右侧对应块的横跨线,这些线与输出张量连接在一起,当图像被缩放回来时,这些连接允许来自更高级别卷积块的信息传递,保留可能在卷积块减少输入图像时被移除的细节。
您会发现基于 U-Net 的架构在 Kaggle 分割竞赛中随处可见,从某种程度上证明了这种结构对于分割是一个不错的选择。已经应用到基本设置的另一种技术是我们的老朋友迁移学习。在这种方法中,U 的第一部分取自预训练模型,如 ResNet 或 Inception,U 的另一侧加上跳跃连接,添加到训练网络的顶部,并像往常一样进行微调。
让我们看看一些现有的预训练模型,可以直接从 Facebook 获得最先进的目标检测和分割。
Faster R-CNN 和 Mask R-CNN
Facebook Research 开发了maskrcnn-benchmark库,其中包含目标检测和分割算法的参考实现。我们将安装该库并添加代码来生成预测。在撰写本文时,构建模型的最简单方法是使用 Docker(当 PyTorch 1.2 发布时可能会更改)。从https://github.com/facebookresearch/maskrcnn-benchmark克隆存储库,并将此脚本predict.py添加到demo目录中,以设置使用 ResNet-101 骨干的预测管道:
import matplotlib.pyplot as plt
from PIL import Image
import numpy as np
import sys
from maskrcnn_benchmark.config import cfg
from predictor import COCODemo
config_file = "../configs/caffe2/e2e_faster_rcnn_R_101_FPN_1x_caffe2.yaml"
cfg.merge_from_file(config_file)
cfg.merge_from_list(["MODEL.DEVICE", "cpu"])
coco_demo = COCODemo(
cfg,
min_image_size=500,
confidence_threshold=0.7,
)
pil_image = Image.open(sys.argv[1])
image = np.array(pil_image)[:, :, [2, 1, 0]]
predictions = coco_demo.run_on_opencv_image(image)
predictions = predictions[:,:,::-1]
plt.imsave(sys.argv[2], predictions)
在这个简短的脚本中,我们首先设置了COCODemo预测器,确保我们传入的配置设置了 Faster R-CNN 而不是 Mask R-CNN(后者会产生分割输出)。然后我们打开一个在命令行上设置的图像文件,但是我们必须将其转换为BGR格式而不是RGB格式,因为预测器是在 OpenCV 图像上训练的,而不是我们迄今为止使用的 PIL 图像。最后,我们使用imsave将predictions数组(原始图像加上边界框)写入一个新文件,也在命令行上指定。将一个测试图像文件复制到这个demo目录中,然后我们可以构建 Docker 镜像:
docker build docker/
我们从 Docker 容器内运行脚本,并生成类似于图 9-7 的输出(我实际上使用了该库来生成该图像)。尝试尝试不同的confidence_threshold值和不同的图片。您还可以切换到e2e_mask_rcnn_R_101_FPN_1x_caffe2.yaml配置,尝试 Mask R-CNN 并生成分割蒙版。
要在这些模型上训练您自己的数据,您需要提供一个数据集,为每个图像提供边界框标签。该库提供了一个名为BoxList的辅助函数。以下是一个数据集的骨架实现,您可以将其用作起点:
from maskrcnn_benchmark.structures.bounding_box import BoxList
class MyDataset(object):
def __init__(self, path, transforms=None):
self.images = # set up image list
self.boxes = # read in boxes
self.labels = # read in labels
def __getitem__(self, idx):
image = # Get PIL image from self.images
boxes = # Create a list of arrays, one per box in x1, y1, x2, y2 format
labels = # labels that correspond to the boxes
boxlist = BoxList(boxes, image.size, mode="xyxy")
boxlist.add_field("labels", labels)
if self.transforms:
image, boxlist = self.transforms(image, boxlist)
return image, boxlist, idx
def get_img_info(self, idx):
return {"height": img_height, "width": img_width
然后,您需要将新创建的数据集添加到maskrcnn_benchmark/data/datasets/init.py和maskrcnn_benchmark/config/paths_catalog.py中。然后可以使用存储库中提供的train_net.py脚本进行训练。请注意,您可能需要减少批量大小以在单个 GPU 上训练这些网络中的任何一个。
这就是目标检测和分割的全部内容,但是请参阅“进一步阅读”以获取更多想法,包括标题为 You Only Look Once(YOLO)架构的内容。与此同时,我们将看看如何恶意破坏模型。
对抗样本
你可能在网上看到过关于图像如何阻止图像识别正常工作的文章。如果一个人将图像举到相机前,神经网络会认为它看到了熊猫或类似的东西。这些被称为对抗样本,它们是发现架构限制以及如何最好地防御的有趣方式。
创建对抗样本并不太困难,特别是如果你可以访问模型。这里有一个简单的神经网络,用于对来自流行的 CIFAR-10 数据集的图像进行分类。这个模型没有什么特别之处,所以可以随意将其替换为 AlexNet、ResNet 或本书中迄今为止介绍的任何其他网络:
class ModelToBreak(nn.Module):
def __init__(self):
super(ModelToBreak, self).__init__()
self.conv1 = nn.Conv2d(3, 6, 5)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(6, 16, 5)
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = x.view(-1, 16 * 5 * 5)
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
一旦网络在 CIFAR-10 上训练完成,我们可以为图 9-9 中的图像获得预测。希望训练已经足够好,可以报告这是一只青蛙(如果没有,可能需要再多训练一会儿!)。我们要做的是稍微改变我们的青蛙图片,让神经网络感到困惑,认为它是其他东西,尽管我们仍然可以清楚地认出它是一只青蛙。
图 9-9。我们的青蛙示例
为此,我们将使用一种名为快速梯度符号方法的攻击方法。⁴ 这个想法是拿我们想要误分类的图像并像往常一样通过模型运行它,这给我们一个输出张量。通常情况下,对于预测,我们会查看张量中哪个值最高,并将其用作我们类的索引,使用argmax()。但这一次,我们将假装再次训练网络,并将结果反向传播回模型,给出模型相对于原始输入(在这种情况下,我们的青蛙图片)的梯度变化。
完成后,我们创建一个新的张量,查看这些梯度并用+1 替换一个条目,如果梯度为正则用-1。这给我们这个图像推动模型决策边界的方向。然后我们乘以一个小标量(在论文中称为epsilon)来生成我们的恶意掩码,然后将其添加到原始图像中,创建一个对抗样本。
这里有一个简单的 PyTorch 方法,当提供批次的标签、模型和用于评估模型的损失函数时,返回输入批次的快速梯度符号张量:
def fgsm(input_tensor, labels, epsilon=0.02, loss_function, model):
outputs = model(input_tensor)
loss = loss_function(outputs, labels)
loss.backward(retain_graph=True)
fsgm = torch.sign(inputs.grad) * epsilon
return fgsm
通过实验通常可以找到 Epsilon。通过尝试各种图像,我发现0.02对这个模型效果很好,但你也可以使用类似网格或随机搜索的方法来找到将青蛙变成船的值!
在我们的青蛙和模型上运行这个函数,我们得到一个掩码,然后我们可以将其添加到我们的原始图像中生成我们的对抗样本。看看图 9-10 看看它是什么样子!
model_to_break = # load our model to break here
adversarial_mask = fgsm(frog_image.unsqueeze(-1),
batch_labels,
loss_function,
model_to_break)
adversarial_image = adversarial_mask.squeeze(0) + frog_image
图 9-10。我们的对抗性青蛙
显然,我们创建的图像对我们的人眼来说仍然是一只青蛙。(如果对你来说看起来不像青蛙,那么你可能是一个神经网络。立即报告自己进行 Voight-Kampff 测试。)但如果我们从模型对这个新图像的预测中得到一个结果会发生什么?
model_to_break(adversarial_image.unsqueeze(-1))
# look up in labels via argmax()
>> 'cat'
我们打败了模型。但这是否像最初看起来的那样成为问题呢?
黑盒攻击
你可能已经注意到,要生成愚弄分类器的图像,我们需要了解使用的模型的很多信息。我们面前有整个模型的结构以及在训练模型时使用的损失函数,我们需要在模型中进行前向和后向传递以获得我们的梯度。这是计算机安全领域中所知的白盒攻击的一个典型例子,我们可以窥视代码的任何部分来弄清楚发生了什么并利用我们能找到的任何东西。
那么这有关系吗?毕竟,大多数你在网上遇到的模型都不会让你窥视内部。黑盒攻击,即只有输入和输出的攻击,实际上可能吗?很遗憾,是的。考虑我们有一组输入和一组要与之匹配的输出。输出是标签,可以使用模型的有针对性查询来训练一个新模型,你可以将其用作本地代理并以白盒方式进行攻击。就像你在迁移学习中看到的那样,对代理模型的攻击可以有效地作用于实际模型。我们注定要失败吗?
防御对抗性攻击
我们如何防御这些攻击?对于像将图像分类为猫或鱼这样的任务,这可能不是世界末日,但对于自动驾驶系统、癌症检测应用等,这可能真的意味着生与死的区别。成功地防御各种类型的对抗性攻击仍然是一个研究领域,但迄今为止的重点包括提炼和验证。
通过使用模型来训练另一个模型来提炼似乎有所帮助。使用本章前面概述的新模型的标签平滑也似乎有所帮助。使模型对其决策不那么确定似乎可以在一定程度上平滑梯度,使我们在本章中概述的基于梯度的攻击不那么有效。
更强大的方法是回到早期计算机视觉时代的一些部分。如果我们对传入数据进行输入验证,可能可以防止对抗性图像首先到达模型。在前面的例子中,生成的攻击图像有一些像素与我们看到青蛙时期望的非常不匹配。根据领域的不同,我们可以设置一个过滤器,只允许通过一些过滤测试的图像。你理论上也可以制作一个神经网络来做这个,因为攻击者必须尝试用相同的图像破坏两个不同的模型!
现在我们真的已经结束了关于图像的讨论。但让我们看看过去几年发生的文本网络方面的一些发展。
眼见不一定为实:变压器架构
在过去的十年中,迁移学习一直是使基于图像的网络变得如此有效和普遍的一个重要特征,但文本一直是一个更难解决的问题。然而,在过去几年中,已经迈出了一些重要的步骤,开始揭示了在文本中使用迁移学习的潜力,用于各种任务,如生成、分类和回答问题。我们还看到了一种新型架构开始占据主导地位:变压器网络。这些网络并不来自赛博特隆,但这种技术是我们看到的最强大的基于文本的网络背后的技术,OpenAI 于 2019 年发布的 GPT-2 模型展示了其生成文本的惊人质量,以至于 OpenAI 最初推迟了模型的更大版本,以防止其被用于不良目的。我们将研究变压器的一般理论,然后深入探讨如何使用 Hugging Face 的 GPT-2 和 BERT 实现。
专注
通往变压器架构的途中的初始步骤是注意力机制,最初引入到 RNN 中,以帮助序列到序列的应用,如翻译。⁵
注意力机制试图解决的问题是翻译句子如“猫坐在垫子上,她发出了咕噜声。”我们知道该句中的她指的是猫,但让标准的 RNN 理解这个概念很困难。它可能有我们在第五章中讨论过的隐藏状态,但当我们到达她时,我们已经有了很多时间步和每个步骤的隐藏状态!
那么attention的作用是为每个时间步添加一组额外的可学习权重,将网络聚焦在句子的特定部分。这些权重通常通过softmax层传递,生成每个步骤的概率,然后将注意力权重的点积与先前的隐藏状态进行计算。图 9-11 展示了关于我们句子的这个简化版本。
图 9-11. 指向“cat”的注意向量
这些权重确保当隐藏状态与当前状态组合时,“cat”将成为决定“she”时间步输出向量的主要部分,这将为将其翻译成法语提供有用的上下文!
我们不会详细介绍attention在具体实现中如何工作,但知道这个概念足够强大,足以在 2010 年代中期推动了谷歌翻译的显著增长和准确性。但更多的东西即将到来。
注意力机制就是你需要的一切
在开创性的论文“注意力就是你需要的一切”中,谷歌研究人员指出,我们花了很多时间将注意力添加到已经相对较慢的基于 RNN 的网络上(与 CNN 或线性单元相比)。如果我们根本不需要 RNN 呢?该论文表明,通过堆叠基于注意力的编码器和解码器,您可以创建一个完全不依赖于 RNN 隐藏状态的模型,为今天主导文本深度学习的更大更快的 Transformer 铺平了道路。
关键思想是使用作者称之为多头注意力,它通过使用一组Linear层在所有输入上并行化attention步骤。借助这些技巧,并从 ResNet 借鉴一些残差连接技巧,Transformer 迅速开始取代 RNN 用于许多基于文本的应用。两个重要的 Transformer 版本,BERT 和 GPT-2,代表了当前的最先进技术,本书付印时。
幸运的是,Hugging Face 有一个库在 PyTorch 中实现了这两个模型。它可以使用pip或conda进行安装,您还应该git clone该存储库本身,因为我们稍后将使用一些实用脚本!
pip install pytorch-transformers
conda install pytorch-transformers
首先,我们将看一下 BERT。
BERT
谷歌 2018 年的双向编码器表示转换器(BERT)模型是将强大模型的迁移学习成功应用的首批案例之一。BERT 本身是一个庞大的基于 Transformer 的模型(在其最小版本中有 1.1 亿个参数),在维基百科和 BookCorpus 数据集上进行了预训练。传统上,Transformer 和卷积网络在处理文本时存在的问题是,因为它们一次看到所有数据,这些网络很难学习语言的时间结构。BERT 通过在预训练阶段随机屏蔽文本输入的 15%,并强制模型预测已被屏蔽的部分来解决这个问题。尽管在概念上很简单,但最大模型中 3.4 亿个参数的庞大规模与 Transformer 架构的结合,为一系列与文本相关的基准测试带来了新的最先进结果。
当然,尽管 BERT 是由 Google 与 TensorFlow 创建的,但也有适用于 PyTorch 的 BERT 实现。现在让我们快速看一下其中一个。
FastBERT
在您自己的分类应用程序中开始使用 BERT 模型的一种简单方法是使用FastBERT库,该库将 Hugging Face 的存储库与 fast.ai API 混合在一起(稍后我们将在 ULMFiT 部分更详细地看到)。它可以通过常规方式使用pip进行安装:
pip install fast-bert
以下是一个可以用来在我们在第五章中使用的 Sentiment140 Twitter 数据集上微调 BERT 的脚本:
import torch
import logger
from pytorch_transformers.tokenization import BertTokenizer
from fast_bert.data import BertDataBunch
from fast_bert.learner import BertLearner
from fast_bert.metrics import accuracy
device = torch.device('cuda')
logger = logging.getLogger()
metrics = [{'name': 'accuracy', 'function': accuracy}]
tokenizer = BertTokenizer.from_pretrained
('bert-base-uncased',
do_lower_case=True)
databunch = BertDataBunch([PATH_TO_DATA],
[PATH_TO_LABELS],
tokenizer,
train_file=[TRAIN_CSV],
val_file=[VAL_CSV],
test_data=[TEST_CSV],
text_col=[TEST_FEATURE_COL], label_col=[0],
bs=64,
maxlen=140,
multi_gpu=False,
multi_label=False)
learner = BertLearner.from_pretrained_model(databunch,
'bert-base-uncased',
metrics,
device,
logger,
is_fp16=False,
multi_gpu=False,
multi_label=False)
learner.fit(3, lr='1e-2')
在导入之后,我们设置了device、logger和metrics对象,这些对象是BertLearner对象所需的。然后我们创建了一个BERTTokenizer来对我们的输入数据进行标记化,在这个基础上我们将使用bert-base-uncased模型(具有 12 层和 1.1 亿参数)。接下来,我们需要一个包含训练、验证和测试数据集路径的BertDataBunch对象,以及标签列的位置、批处理大小和我们输入数据的最大长度,对于我们的情况来说很简单,因为它只能是推文的长度,那时是 140 个字符。做完这些之后,我们将通过使用BertLearner.from_pretrained_model方法来设置 BERT 模型。这个方法传入了我们的输入数据、BERT 模型类型、我们在脚本开始时设置的metric、device和logger对象,最后一些标志来关闭我们不需要但方法签名中没有默认值的训练选项。
最后,fit()方法负责在我们的输入数据上微调 BERT 模型,运行自己的内部训练循环。在这个例子中,我们使用学习率为1e-2进行三个 epochs 的训练。训练后的 PyTorch 模型可以通过learner.model进行访问。
这就是如何开始使用 BERT。现在,进入比赛。
GPT-2
现在,当谷歌悄悄地研究 BERT 时,OpenAI 正在研究自己版本的基于 Transformer 的文本模型。该模型不使用掩码来强制模型学习语言结构,而是将架构内的注意机制限制在简单地预测序列中的下一个单词,类似于第五章中的 RNN 的风格。结果,GPT 在 BERT 的出色性能下有些落后,但在 2019 年,OpenAI 推出了GPT-2,这是该模型的新版本,重新定义了文本生成的标准。
GPT-2 背后的魔力在于规模:该模型训练于 800 万个网站的文本,最大变体的 GPT-2 拥有 15 亿个参数。虽然它仍然无法在特定基准上击败 BERT,比如问答或其他 NLP 任务,但它能够从基本提示中创建出极为逼真的文本,这导致 OpenAI 将全尺寸模型锁在了闭门之后,以防止被武器化。然而,他们发布了模型的较小版本,其中 117 和 340 亿个参数。
这里是 GPT-2 可以生成的输出示例。所有斜体部分都是由 GPT-2 的 340M 模型编写的:
杰克和吉尔骑着自行车上山。天空是灰白色的,风在吹,导致大雪纷飞。下山真的很困难,我不得不向前倾斜一点点才能继续前行。但接着有一个我永远不会忘记的自由时刻:自行车完全停在山坡上,我就在其中间。我没有时间说一句话,但我向前倾斜,触碰了刹车,自行车开始前进。
除了从杰克和吉尔切换到I,这是一个令人印象深刻的文本生成。对于短文本,它有时几乎无法与人类创作的文本区分开。随着生成文本的继续,它揭示了幕后的机器,但这是一个令人印象深刻的成就,它现在可以写推文和 Reddit 评论。让我们看看如何在 PyTorch 中实现这一点。
使用 GPT-2 生成文本
与 BERT 一样,OpenAI 发布的官方 GPT-2 版本是一个 TensorFlow 模型。与 BERT 一样,Hugging Face 发布了一个 PyTorch 版本,该版本包含在同一个库(pytorch-transformers)中。然而,围绕原始 TensorFlow 模型构建了一个蓬勃发展的生态系统,而目前在 PyTorch 版本周围并不存在。因此,这一次,我们将作弊:我们将使用一些基于 TensorFlow 的库来微调 GPT-2 模型,然后导出权重并将其导入模型的 PyTorch 版本。为了节省我们太多的设置,我们还在 Colab 笔记本中执行所有 TensorFlow 操作!让我们开始吧。
打开一个新的 Google Colab 笔记本,并安装我们正在使用的库,Max Woolf 的gpt-2-simple,它将 GPT-2 微调封装在一个单一软件包中。通过将此添加到单元格中进行安装:
!pip3 install gpt-2-simple
接下来,您需要一些文本。在此示例中,我使用了 PG Wodehouse 的My Man Jeeves的公共领域文本。我还不打算在从 Project Gutenberg 网站使用wget下载文本后对文本进行任何进一步处理:
!wget http://www.gutenberg.org/cache/epub/8164/pg8164.txt
现在我们可以使用库进行训练。首先确保您的笔记本连接到 GPU(在 Runtime→Change Runtime Type 中查看),然后在单元格中运行此代码:
import gpt_2_simple as gpt2
gpt2.download_gpt2(model_name="117M")
sess = gpt2.start_tf_sess()
gpt2.finetune(sess,
"pg8164.txt",model_name="117M",
steps=1000)
用您正在使用的文本文件替换文本文件。当模型训练时,它将每一百步输出一个样本。在我的情况下,看到它从模糊的莎士比亚剧本变成接近伍德豪斯散文的东西很有趣。这可能需要一个小时或两个小时来训练 1,000 个时代,所以在云端的 GPU 忙碌时,去做一些更有趣的事情吧。
完成后,我们需要将权重从 Colab 中取出并放入您的 Google Drive 帐户,以便您可以将它们下载到运行 PyTorch 的任何地方:
gpt2.copy_checkpoint_to_gdrive()
这将指引您打开一个新的网页,将认证代码复制到笔记本中。完成后,权重将被打包并保存到您的 Google Drive 中,文件名为run1.tar.gz。
现在,在运行 PyTorch 的实例或笔记本上,下载该 tar 文件并解压缩。我们需要重命名一些文件,使这些权重与 GPT-2 的 Hugging Face 重新实现兼容:
mv encoder.json vocab.json
mv vocab.bpe merges.txt
现在我们需要将保存的 TensorFlow 权重转换为与 PyTorch 兼容的权重。方便的是,pytorch-transformers存储库附带了一个脚本来执行此操作:
python [REPO_DIR]/pytorch_transformers/convert_gpt2_checkpoint_to_pytorch.py
--gpt2_checkpoint_path [SAVED_TENSORFLOW_MODEL_DIR]
--pytorch_dump_folder_path [SAVED_TENSORFLOW_MODEL_DIR]
然后可以在代码中创建一个新的 GPT-2 模型实例:
from pytorch_transformers import GPT2LMHeadModel
model = GPT2LMHeadModel.from_pretrained([SAVED_TENSORFLOW_MODEL_DIR])
或者,只是为了玩弄模型,您可以使用run_gpt2.py脚本获得一个提示,输入文本并从基于 PyTorch 的模型获取生成的样本:
python [REPO_DIR]/pytorch-transformers/examples/run_gpt2.py
--model_name_or_path [SAVED_TENSORFLOW_MODEL_DIR]
随着 Hugging Face 在其存储库中整合所有模型的一致 API,训练 GPT-2 可能会变得更加容易,但目前使用 TensorFlow 方法是最容易入门的。
BERT 和 GPT-2 目前是文本学习中最流行的名称,但在我们结束之前,我们将介绍当前最先进模型中的黑马:ULMFiT。
ULMFiT
与 BERT 和 GPT-2 这两个庞然大物相比,ULMFiT基于一个老式的 RNN。看不到 Transformer,只有 AWD-LSTM,这是由 Stephen Merity 最初创建的架构。在 WikiText-103 数据集上训练,它已被证明适合迁移学习,尽管是老式的架构,但在分类领域已被证明与 BERT 和 GPT-2 具有竞争力。
虽然 ULMFiT 本质上只是另一个可以像其他模型一样在 PyTorch 中加载和使用的模型,但它的自然家园是 fast.ai 库,该库位于 PyTorch 之上,并为快速掌握深度学习并快速提高生产力提供了许多有用的抽象。为此,我们将看看如何在 Twitter 数据集上使用 fast.ai 库中的 ULMFiT,该数据集在第五章中使用过。
我们首先使用 fast.ai 的 Data Block API 为微调 LSTM 准备数据:
data_lm = (TextList
.from_csv("./twitter-data/",
'train-processed.csv', cols=5,
vocab=data_lm.vocab)
.split_by_rand_pct()
.label_from_df(cols=0)
.databunch())
这与第五章中的torchtext助手非常相似,只是产生了 fast.ai 称为databunch的东西,从中其模型和训练例程可以轻松获取数据。接下来,我们创建模型,但在 fast.ai 中,这种情况有些不同。我们创建一个learner,与之交互以训练模型,而不是模型本身,尽管我们将其作为参数传递。我们还提供了一个 dropout 值(我们使用了 fast.ai 培训材料中建议的值):
learn = language_model_learner(data_lm, AWD_LSTM, drop_mult=0.3)
一旦我们有了learner对象,我们可以找到最佳学习率。这与我们在第四章中实现的类似,只是它内置在库中,并使用指数移动平均值来平滑图表,我们的实现中相当尖锐:
learn.lr_find()
learn.recorder.plot()
从图 9-12 中的图表来看,1e-2是我们开始出现急剧下降的地方,因此我们将选择它作为我们的学习率。Fast.ai 使用一种称为fit_one_cycle的方法,它使用 1cycle 学习调度器(有关 1cycle 的更多详细信息,请参见“进一步阅读”),并使用非常高的学习率在数量级更少的时代内训练模型。
图 9-12. ULMFiT 学习率图
在这里,我们只训练一个周期,并保存网络的微调头部(编码器):
learn.fit_one_cycle(1, 1e-2)
learn.save_encoder('twitter_encoder')
随着语言模型的微调完成(您可能希望在训练中尝试更多周期),我们为实际分类问题构建了一个新的databunch:
twitter_classifier_bunch = TextList
.from_csv("./twitter-data/",
'train-processed.csv', cols=5,
vocab=data_lm.vocab)
.split_by_rand_pct()
.label_from_df(cols=0)
.databunch())
这里唯一的真正区别是,我们通过使用label_from_df提供实际标签,并且从之前执行的语言模型训练中传入一个vocab对象,以确保它们使用相同的单词到数字的映射,然后我们准备创建一个新的text_classifier_learner,其中库在幕后为您创建所有模型。我们将微调的编码器加载到这个新模型上,并开始再次进行训练过程:
learn = text_classifier_learner(data_clas, drop_mult=0.5)
learn.load_encoder('fine_tuned_enc')
learn.lr_find()
learn.recorder.plot()
learn.fit_one_cycle(1, 2e-2, moms=(0.8,0.7))
通过少量代码,我们得到了一个报告准确率为 76%的分类器。我们可以通过训练语言模型更多周期,添加不同的学习率并在训练时冻结部分模型来轻松改进,所有这些都是 fast.ai 支持的,定义在learner上的方法。
使用什么?
在深度学习文本模型的当前前沿进行了一番快速的介绍后,您可能心中有一个问题:“这一切都很棒,但我应该实际使用哪一个?”一般来说,如果您正在处理分类问题,我建议您从 ULMFiT 开始。BERT 令人印象深刻,但在准确性方面,ULMFiT 与 BERT 竞争,并且它还有一个额外的好处,即您不需要购买大量的 TPU 积分来充分利用它。对于大多数人来说,单个 GPU 微调 ULMFiT 可能已经足够了。
至于 GPT-2,如果您想要生成的文本,那么是的,它更适合,但对于分类目的,接近 ULMFiT 或 BERT 的性能会更难。我认为可能有趣的一件事是让 GPT-2 在数据增强上自由发挥;如果您有一个类似 Sentiment140 的数据集,我们在整本书中一直在使用它,为什么不在该输入上微调一个 GPT-2 模型并使用它生成更多数据呢?
结论
本章介绍了 PyTorch 的更广泛世界,包括可以导入到自己项目中的现有模型的库,一些可应用于任何领域的尖端数据增强方法,以及可能破坏模型的对抗样本以及如何防御它们。希望当我们结束这段旅程时,你能理解神经网络是如何组装的,以及如何让图像、文本和音频作为张量流经它们。你应该能够训练它们,增强数据,尝试不同的学习率,并在模型出现问题时进行调试。一旦所有这些都完成了,你就知道如何将它们打包到 Docker 中,并让它们为更广泛的世界提供服务。
接下来我们去哪里?考虑查看 PyTorch 论坛和网站上的其他文档。我强烈推荐访问 fast.ai 社区,即使你最终不使用该库;这是一个充满活力的社区,充满了好主意和尝试新方法的人,同时也对新手友好!
跟上深度学习的前沿变得越来越困难。大多数论文都发表在arXiv,但论文的发表速度似乎以近乎指数级增长;当我写这个结论时,XLNet刚刚发布,据说在各种任务上击败了 BERT。永无止境!为了帮助解决这个问题,我在这里列出了一些 Twitter 账号,人们经常推荐有趣的论文。我建议关注它们,以了解当前和有趣的工作,然后你可以使用工具如arXiv Sanity Preserver来更轻松地深入研究。
最后,我在这本书上训练了一个 GPT-2 模型,它想说几句话:
深度学习是我们如何处理当今深度学习应用的关键驱动力,预计深度学习将继续扩展到新领域,如基于图像的分类,在 2016 年,NVIDIA 推出了 CUDA LSTM 架构。随着 LSTM 变得越来越流行,LSTM 也成为了一种更便宜、更易于生产的用于研究目的的构建方法,而 CUDA 已经证明在深度学习市场上是一种非常有竞争力的架构。
幸运的是,你可以看到在我们作者失业之前还有很长的路要走。但也许你可以帮助改变这一点!
进一步阅读
-
Ian Goodfellow 的GAN 讲座
-
You Only Look Once (YOLO) —— 一系列快速目标检测模型,具有非常易读的论文
-
CleverHans —— 一个为 TensorFlow 和 PyTorch 提供对抗生成技术的库
-
The Illustrated Transformer —— 通过 Transformer 架构进行深入探索
一些推荐关注的 Twitter 账号:
-
@jeremyphoward —— fast.ai 的联合创始人
-
@miles_brundage —— OpenAI 的研究科学家(政策)
-
@BrundageBot —— 一个每天生成有趣论文摘要的 Twitter 机器人(警告:通常每天推出 50 篇论文!)
-
@pytorch —— 官方 PyTorch 账号
¹ 请查看张宏毅等人(2017 年)的论文“混合:超越经验风险最小化”。
² 请查看 Ian J. Goodfellow 等人(2014 年)的论文“生成对抗网络”。
³ 请查看 Olaf Ronneberger 等人(2015 年)的论文“U-Net:用于生物医学图像分割的卷积网络”。
⁴ 请参阅 Ian Goodfellow 等人撰写的“解释和利用对抗样本”(2014 年)。
⁵ 请参阅 Dzmitry Bahdanau 等人撰写的“通过联合学习对齐和翻译进行神经机器翻译”(2014 年)。
⁶ 请参阅 Ashish Vaswani 等人撰写的“注意力机制就是一切”(2017 年)。