推荐系统–排序

课件地址:https://github.com/wangshusen/RecommenderSystem

结合小红书的业务场景和内部实践,讲解主流的工业界推荐系统技术。

主要内容:

排序01:多目标模型

第一部分是模型结构。模型把用户特征、物品特征、统计特征、场景特征作为输入,输出对多个指标的预估。 第二部分内容是降采样和校准。在实际的推荐系统中,正负样本的比例严重不平衡,负样本数量远多于正样本,因此需要对负样本做降采样。以点击率为例,对负样本做降采样会导致模型高估点击率,因此需要用公式做校准。

粗排和精排用到的方法都差不多,但精排的模型比较大,因此需要先用粗排模型从几千篇笔记中选择几百篇用于精排。

用户点击以后才会出现点赞、收藏和转发行为:

排序的依据是笔记的点击率、点赞率、收藏率、转发率等多种分数。

多目标模型:

模型输出某个用户对于某篇笔记的预估点赞率、点击率、收藏率以及转发率

训练:类别不平衡。

预估值校准

排序02:多目标排序模型的改进MMoE

Multi-gate Mixture-of-Experts (MMoE) 是一种多目标排序模型。MMoE 用多个独立的塔提取特征,并对多塔提取的特征向量做加权平均,然后送入多头。MMoE 的训练容易出现极化现象(polarize),可以用 dropout 解决。

这里假设神经网络的目标只有点击率和点赞率这两个目标,因此只需要两组权重:p和q

极化现象:softmax函数导致

极化解决方法:

排序03:预估分数的融合

前面通过MMoE获得了点赞、点击、转发率等指标的预估值,得到这些指标之后,需要将它们融合成一个分数。这节课介绍工业界常用的几种融分公式。

用预估的视频时长做排序,用排名来计算得分:

排序04:视频播放建模

视频播放的建模主要有两个目标:视频播放时长、视频完播率。这节课讨论如何在多目标排序模型添加与播放时长、完播率相关的预估任务。

视频越长,完播率越低:

排序05:排序模型的特征

推荐系统中的排序模型需要用户画像、物品画像、统计特征、场景特征。

排序06:粗排的三塔模型

粗排三塔模型,它介于前期融合的双塔模型和后期融合的精排模型之间。用户塔可以很大,因为它只需要做1次推理。物品塔可以比较大,因为物品塔的输出可以缓存在PS。交叉塔必须很小,如果有n个物品,交叉塔需要做n次推理。三塔模型的计算量主要是上层的全连接层。

推荐系统–召回

召回01:基于物品的协同过滤(ItemCF)

Item Based Collaborative Filtering,缩写 ItemCF

ItemCF 的原理:如果用户喜欢物品1,而且物品1与物品2相似,那么用户很可能喜欢物品2。

1. 如何计算两个物品之间的相似度。

2. 如何预估用户对候选物品的兴趣。

3. 如何利用索引在线上快速做召回。

ItemCF的实现

两个物体的受众重合度越高,表示两个物体越相似。

余弦相似度

ItemCF完整流程:

step1:事先做离线计算

step2 线上做召回:

为什么要用索引:

召回02:Swing召回通道

ItemCF :(缺点)

Swing模型:(为用户设置权重)

召回03:基于用户的协同过滤(UserCF)

UserCF 的原理:如果用户1跟用户2相似,而且用户2喜欢某物品,那么用户1很可能喜欢该物品。

关键:1. 如何计算两个用户之间的相似度。 2. 如何预估用户对候选物品的兴趣。 3. 如何利用索引在线上快速做召回。

用户相似度:

如果是热门物体,那么大概率两个用户都会喜欢,因此需要降低热门物体的权重:

UserCF召回的完整流程

step1:离线计算

step2:线上做召回

召回04: 向量召回 ,离散特征处理

one-hot encoding (独热编码) 和 embedding (嵌入)

召回05: 向量召回 ,矩阵补充、最近邻查找

矩阵补充(matrix completion),它是一种向量召回通道。矩阵补充的本质是对用户 ID 和物品 ID 做 embedding,并用两个 embedding 向量的内积预估用户对物品的兴趣。值得注意的是,矩阵补充存在诸多缺点,在实践中效果远不及双塔模型。 做向量召回需要做最近邻查找(nearest neighbor search)。

用绿色的信息做训练,来预测灰色的值,进而为用户做召回

近似最近邻查找:

step1:划分区域

step2:用一个向量表示但各区域,给定一个物体,则返回所在的区域的物体

step3:只需计算该区域所在的相似度。

召回06:双塔模型:矩阵补充的升级版

双塔模型(two-tower)也叫 DSSM,是推荐系统中最重要的召回通道,没有之一。双塔模型有两个塔:用户塔、物品塔。两个塔各输出一个向量,作为用户、物品的表征。两个向量的内积或余弦相似度作为对兴趣的预估。有三种训练双塔模型的方式:pointwise、pairwise、listwise。

双塔模型训练:

pointwise:

pariwise:

listwise:

召回07:双塔模型–正负样本选择

双塔模型(two-tower,也叫 DSSM)正负样本的选取。正样本是有点击的物品。负样本是被召回、排序淘汰的物品,分为简单负样本和困难负样本。

负样本:

简单负样本:Batch内负样本

热门样本成为负样本的概率过大,解决办法:

困难负样本:

召回08:双塔模型的线上服务和模型更新

在开始线上服务之前,需要把物品向量存储到Milvus、Faiss、HnswLib这类向量数据库,供最近邻查找(KNN 或 ANN)。当用户发起推荐请求时,用户塔用用户ID和用户画像现算一个用户向量,作为query,去向量数据库中做最近邻查找。

模型需要定期做更新,分为全量更新(天级别)和增量更新(实时)。全量更新会训练整个模型,包括embedding和全连接层。而增量更新只需要训练embedding层。

模型更新:

全量更新:跟新用户和物体向量

增量更新:每隔几小时实现用户模型更新

为什么需要这个:用户的兴趣可能会随时改变,因此需要随时做用户的更新,且只更新用户embedding参数,其他参数不需要更新。

注意:每天的全量更新是基于昨天的全量更新后的模型进行训练的。

问题:能否只做增量更新,不做全量更新

召回09:双塔模型的改进–自监督学习

改进双塔模型的方法,叫做自监督学习(self-supervised learning),用在双塔模型上可以提升业务指标。这种方法由谷歌在2021年提出,工业界(包括小红书)普遍验证有效。

长尾效应:

参考文献: Tiansheng Yao et al. Self-supervised Learning for Large-scale Item Recommendations. In CIKM, 2021.

自监督学习:

对物品进行不同的特征变换得到的特征向量同类之间尽可能的相同,不同物体之间尽可能不同。

特征变换方法:

1、Random Mask

2、Dropout

3、互补特征

最好的办法 :Random mask(将一组相关联的特征全部mask)

召回10:其他召回通道

地理位置召回包括GeoHash召回和同城召回作者召回包括关注作者、有交互作者、相似作者。缓存召回是储存精排打分高、而且未曝光的笔记。

6个其他召回通道:

【深度估计 Transformer】Vision Transformers for Dense Prediction

作者单位:Intel Labs

https://arxiv.org/abs/2103.13413

https://github.com/isl-org/DPT

Hugging Face

密集预测视觉Transformer(DPT),其在单目深度估计、语义分割等任务上表现SOTA!

我们介绍了密集视觉Transformer,该架构利用视觉Transformer代替卷积网络作为密集预测任务的骨干。我们将视觉Transformer各个阶段的token组装成各种分辨率的图像表示形式,并使用卷积解码器将它们逐步组合为全分辨率预测。

Transformer主干以恒定且相对较高的分辨率处理表示,并且在每个阶段都具有全局感受野。与全卷积网络相比,这些特性使密集视觉Transformer可以提供更细粒度和更全局一致的预测。

实验表明,这种架构密集的预测任务上产生了实质性的改进,特别是当有大量的训练数据可用时。对于单眼深度估计,与最先进的完全卷积网络(FCN,fully-convolutional networks.)相比,其相对性能提高了高达28%。应用于语义分割时,Dense vision transformer在ADE20K上是49.02%的mIoU。同时进一步表明,当处于新技术设置,该架构可以在较小的数据集上进行微调,如NYUv2、KITTIPascal Context

Introduction

密集预测架构主要基于卷积网络,其设计通常遵循一种从逻辑上将网络分为编码器解码器的模式编码器通常基于一个图像分类网络(主干),在像ImageNet这样的大型语料库上进行预训练。解码器聚合来自编码器的特征,并将其转换为最终的密集预测。密集预测的体系结构研究往往集中在解码器及其聚合策略上。主干架构对整个模型有很大的影响,因为编码器中丢失的任何信息都不可能在解码器中恢复。

卷积主干逐步对输入图像进行向下采样以提取多个尺度上的特征。下采样使接受域的逐渐增加,低级特征分组为抽象的高级特征,同时确保网络的内存和计算需求保持易于处理。然而,下采样有明显的缺点,这在密集的预测任务中特别突出:特征分辨率和粒度在模型的更深层次的阶段丢失,因此很难在解码器中恢复。虽然特征分辨率和粒度可能对某些任务并不重要,如图像分类,但它们对于密集预测至关重要,在理想情况下,体系结构应该能够在或接近输入图像的分辨率时分辨特征。

现提出的减轻特征粒度损失的各种技术。这些包括训练在更高的输入分辨率(如果计算预算允许),扩大卷积,迅速增加接受域没有下采样,适当放置从编码器的多个阶段跳过连接到解码器,或者最近的通过在整个网络并行连接多分辨率表示。虽然这些技术可以显着提高预测质量,但网络仍然受到其基本构建块—卷积的瓶颈。卷积和非线性一起构成了图像分析网络的基本计算单元。根据定义,卷积是具有有限接受域的线性算子。有限的接受域和单个卷积的有限表达性需要顺序堆叠成非常深的结构,以获得非常广泛的背景和足够高的表征能力。 然而,这需要产生许多需要大量的间接表示。降采样中间表示是必要的,以保持内存消耗在现有计算机架构中可行的水平。

在本工作中,介绍了密集的预测变压器(DPT)。DPT是一种密集的预测体系结构,它基于编码器-解码器的设计,利用transformer作为编码器的基本计算构建块。具体来说,使用最近提出的Vision Transformer(ViT)作为主干架构ViT提供的bag-of-words表示重新组装为不同分辨率的类图像特征表示,使用卷积解码器逐步将特征表示组合到最终的密集预测中。与完全卷积网络不同,ViT主干在计算初始图像嵌入后放弃显式下采样操作,并在所有处理阶段保持恒定维度的表示。此外,在每个阶段都有一个全局性的接受域,实验表明,这些特性对于密集的预测任务特别有利,因为它们会导致细粒度和全局相一致的预测。

单眼深度估计和语义分割的实验。对于通用单眼深度估计的任务,其中有大规模的训练数据可用,与该任务中性能最好的全卷积网络相比,DPT提供的性能提高了超过28%。该架构还可以微调到小的单眼深度预测数据集,如NYUv2和KITTI,当它也设置了新的技术状态。实验利用语义分割实验进一步提供了DPT的强性能的证据。对于这个任务,DPT在具有挑战性的ADE20K和Pascal Context数据集上设置了一个新的最新技术状态。定性结果表明,与卷积网络相比,这些改进可以归因于更细粒度和更全局一致的预测。

Transformer encoder 

在一个高水平上,Vision Transformer在图像的bag-of-words表示上进行操作。单独嵌入到特征空间中的图像补丁,或者从图像中提取的深度特征,扮演“words”的角色。在本工作的其余部分中,将嵌入的“word”称为tokens 。Transformer使用multi-headed self-attention(MHSA)的顺序块对tokens进行转换,该块将tokens相互联系起来来转换表示。

image–extracted–image patch–word–token(变化):特征空间中的图像patch

image patch:图像补丁是较大形式的像素容器例如,假设有一张 100 像素 x 100 像素的图像。如果您将这些图像划分为 10×10 块,那么将拥有一个包含 100 个块的图像(即每个块为 100 像素)。如果开发了一种算法,可以在 10px x 10px 上运行,那么 10px x 10px 就是补丁大小。例如,CNN 的池化层采用更大的块并将它们变成一个像素,可以将其视为信号处理中的窗口。在图像处理补丁窗口大多数情况下是可以互换的,但是当您的算法主要关注一堆像素共享相似属性的事实时,通常会在上下文中使用补丁。例如,补丁用于稀疏表示或图像压缩的上下文,而窗口用于边缘检测或图像增强。

Multi-headed self-attention(MHSA) Net:一种新的人物再识别模型,称为多头自我注意网络(MHSA-Net),用于从人物图像中剔除不重要的信息捕捉关键的局部信息。MHSA-Net包含两个主要的新颖组件:多头自我注意分支(MHSAB)和注意力竞争机制(ACM)。MHSAM自适应地捕获关键的局域人信息,然后产生有效的图像多样性嵌入,用于人的匹配。ACM进一步帮助过滤剔除注意力、噪音和非关键信息。通过广泛的消融研究,验证了结构化的自我注意分支和注意竞争机制都有助于MHSA-Net的性能提高。MHSA-Net在有遮挡的图像上作用深远。

对于应用程序来说,重要的是,transformer在所有计算过程中保持token数量。由于token与image patches有一对一的对应关系,这意味着ViT编码器在所有transformer阶段保持初始嵌入的空间分辨率。此外,MHSA是一个全局操作,因为每个token都可以关注并影响其他token。因此,transformer在初始嵌入后的每个阶段都有一个全局的接受域。这与卷积网络形成了鲜明的对比,卷积网络随着特征通过连续的卷积和下采样层时,逐渐增加了它们的接受域。 

具体而言,ViT通过处理所有不重叠的正方形斑块从图像中提取嵌入的补丁从图像中获得p^{2}像素的大小。这些补丁被扁平(flattened into)到向量中,并使用线性投影单独嵌入。另一种,更具样本效率的ViT变体通过对图像应用ResNet50来提取嵌入,并使用生成的特征映射的像素特征作为标记。由于transformer是set-to-set functions,因此它们本质上并不保留单个token的空间位置信息。因此,图像嵌入与可学习的位置嵌入连接,以将这些信息添加到表示中。在NLP中的工作之后,ViT还添加了一个与输入图像不相关的special token(readout token),并作为最终用于分类的全局图像表示。在此将这个特殊的标记称为读出标记。

Convolutional decoder

解码器将tokens组合成不同分辨率的类图像特征表示。特征表示逐渐融合到最终的密集预测中。文章提出了一个简单的三阶段重组操作,以从transformer encoder任意层输出的token中恢复类图像的表示形式

不管具体的transformer主干如何,都在四个不同的阶段和四个不同的分辨率上重新组装特征。以更低分辨率组装transformer深层的特征,而早期层的特征以更高分辨率组装。当使用ViT-Large时,从 l ={5,12,18,24}层重新组装tokens,而使用ViT-Base,使用 l ={3,6,9,12}层。当使用ViT-Hybrid时,使用了来自嵌入网络的第一和第二个ResNet块和阶段 l ={9,12}的特性。默认体系结构使用投影作为读出操作,并使用\hat{D}=256维度生成特性映射,将这些架构分别称为DPT-Base、DPT-Large和DPTHybrid。

最后,使用基于RefineNet的特征融合块结合从连续阶段提取的特征图,并在每个融合阶段对表示向上采样两次。最终的表示大小的分辨率是输入图像的一半,在此,附加一个特定于任务的输出头来产生最终的预测。完整架构的示意图概述如上图所示。

鲁棒性的单目深度估计: Mixing Datasets for Zero-shot Cross-dataset Transfer

Towards Robust Monocular Depth Estimation: Mixing Datasets for Zero-shot Cross-dataset Transfer, TPAMI 2022

MiDaS v3.1https://github.com/isl-org/MiDaS(最近也在更新)

Hugging Face

这篇文章提出了一种监督的深度估计方法,其中使用一些很有价值的策略使得最后深度估计的结果具有较大提升。具体来讲文章的策略可以归纳为:
1)数据集 : 现有的深度数据集的场景不够丰富, 不能训练出一个在任意场景下都健壮的模型. 因此作者选择结合这些数据集。使用多个深度数据集(各自拥有不同的scale和shift属性)加入进行训练,增大数据量与实现场景的互补

但是组合这些数据集有三个挑战:

  • 深度表达不同, 有的是0表示最近, 有的是0表示最远
  • 部分数据集没有提供缩放信息
  • 部分数据集提供了单张图像的相对深度(disparity), 但是跨数据集的相对深度无法直接转换


2)提出了一种scale-shift invariable的loss(具有深度和偏移不变性的损失函数)用于去监督深度的回归过程,从而使得可以更加有效使用现有数据;
3)采用从3D电影中进行采样的方式扩充数据集,从而进一步增加数据量;
4)使用带有原则属性的多目标训练方法,从而得到一种更加行之有效的优化方法;
结合上述的优化策略与方法,文章的最后得到的模型具有较强的泛化能力,从而摆脱了之前一些公开数据集场景依赖严重的问题。

优势:

  1. 问题的转化
    为了解决数据本身存在深度不一致的问题, 转化成设计一个对深度不敏感的loss
  2. 预训练模型
    可以直接得到任意单张图像的深度信息, 用于下游任务的训练, 

效果:

Single-View View Synthesis :图片-网络-深度图-网络-新视点

Single-View View Synthesis in the Wild with Learned Adaptive
Multiplane Images SIGGRAPH 2022.

现有方法已经显示出利用单眼深度估计和具有分层深度表示的颜色修复的有希望的结果。 然而,这些方法处理复杂3D 几何场景的能力仍然有限。 我们提出了一种基于多平面的新方法图像 (MPI) 表示。适应野外场景布局多样化,解决高维MPI内容生成难的问题,我们设计了一个网络结构,由两部分组成,一个用于平面深度调整,另一个用于深度感知颜色预测。前者使用 RGBD 上下文特征和注意机制调整初始平面位置。 给定调整后的深度值,后者通过特征屏蔽策略实现适当的平面间交互,分别预测每个平面的颜色和密度。

为了训练我们的方法,我们通过简单而有效的 warp-back 策略仅使用不受约束的单视图图像集合来构建大规模立体训练数据。 在合成数据集和真实数据集上进行的实验表明,我们训练的模型效果非常好,并取得了最先进的结果

Code:https://github.com/yxuhan/AdaMPI

网络结构:

训练过程:

结果:

TorchScript—模型部署

摘自:https://zhuanlan.zhihu.com/p/486914187

官网:https://pytorch.org/docs/stable/jit.html

PyTorch 无疑是现在最成功的深度学习训练框架之一,是各种顶会顶刊论文实验的大热门。比起其他的框架,PyTorch 最大的卖点是它对动态网络的支持,比其他需要构建静态网络的框架拥有更低的学习成本。PyTorch 源码 Readme 中还专门为此做了一张动态图:


对研究员而言, PyTorch 能极大地提高想 idea、做实验、发论文的效率,是训练框架中的豪杰,但是它不适合部署。动态建图带来的优势对于性能要求更高的应用场景而言更像是缺点,非固定的网络结构给网络结构分析并进行优化带来了困难,多数参数都能以 Tensor 形式传输也让资源分配变成一件闹心的事。另外由于图是由 python 代码构建的,一方面部署要依赖 python 环境,另一方面模型也毫无保密性可言。

而 TorchScript 就是为了解决这个问题而诞生的工具。包括代码的追踪及解析、中间表示的生成、模型优化、序列化等各种功能,可以说是覆盖了模型部署的方方面面。

TorchScript

动态图模型通过牺牲一些高级特性来换取易用性,那到底 JIT 有哪些特性,在什么情况下不得不用到 JIT 呢?下面主要通过介绍 TorchScript(PyTorch 的 JIT 实现)来分析 JIT 到底带来了哪些好处。

  1. 模型部署

PyTorch 的 1.0 版本发布的最核心的两个新特性就是 JIT 和 C++ API,这两个特性一起发布不是没有道理的,JIT 是 Python 和 C++ 的桥梁,我们可以使用 Python 训练模型,然后通过 JIT 将模型转为语言无关的模块,从而让 C++ 可以非常方便得调用,从此「使用 Python 训练模型,使用 C++ 将模型部署到生产环境」对 PyTorch 来说成为了一件很容易的事。而因为使用了 C++,我们现在几乎可以把 PyTorch 模型部署到任意平台和设备上:树莓派、iOS、Android 等等…

2. 性能提升

既然是为部署生产所提供的特性,那免不了在性能上面做了极大的优化,如果推断的场景对性能要求高,则可以考虑将模型(torch.nn.Module)转换为 TorchScript Module,再进行推断。

3. 模型可视化

TensorFlow 或 Keras 对模型可视化工具(TensorBoard等)非常友好,因为本身就是静态图的编程模型,在模型定义好后整个模型的结构和正向逻辑就已经清楚了;但 PyTorch 本身是不支持的,所以 PyTorch 模型在可视化上一直表现得不好,但 JIT 改善了这一情况。现在可以使用 JIT 的 trace 功能来得到 PyTorch 模型针对某一输入的正向逻辑,通过正向逻辑可以得到模型大致的结构,但如果在 `forward` 方法中有很多条件控制语句,这依然不是一个好的方法,所以 PyTorch JIT 还提供了 Scripting 的方式。

TorchScript Module 的两种生成方式

1. 编码(Scripting)

可以直接使用 TorchScript Language 来定义一个 PyTorch JIT Module,然后用 torch.jit.script 来将他转换成 TorchScript Module 并保存成文件。而 TorchScript Language 本身也是 Python 代码,所以可以直接写在 Python 文件中。

使用 TorchScript Language 就如同使用 TensorFlow 一样,需要前定义好完整的图。对于 TensorFlow 我们知道不能直接使用 Python 中的 if 等语句来做条件控制,而是需要用 tf.cond,但对于 TorchScript 我们依然能够直接使用 if 和 for 等条件控制语句,所以即使是在静态图上,PyTorch 依然秉承了「易用」的特性。TorchScript Language 是静态类型的 Python 子集,静态类型也是用了 Python 3 的 typing 模块来实现,所以写 TorchScript Language 的体验也跟 Python 一模一样,只是某些 Python 特性无法使用(因为是子集),可以通过 TorchScript Language Reference 来查看和原生 Python 的异同。

理论上,使用 Scripting 的方式定义的 TorchScript Module 对模型可视化工具非常友好,因为已经提前定义了整个图结构。

2. 追踪(Tracing)

使用 TorchScript Module 的更简单的办法是使用 Tracing,Tracing 可以直接将 PyTorch 模型(torch.nn.Module)转换成 TorchScript Module。「追踪」顾名思义,就是需要提供一个「输入」来让模型 forward 一遍,以通过该输入的流转路径,获得图的结构。这种方式对于 forward 逻辑简单的模型来说非常实用,但如果 forward 里面本身夹杂了很多流程控制语句,则可能会有问题,因为同一个输入不可能遍历到所有的逻辑分枝。

此外,还可以混合使用上面两种方式。

模型转换

作为模型部署的一个范式,通常我们都需要生成一个模型的中间表示(IR),这个 IR 拥有相对固定的图结构,所以更容易优化,让我们看一个例子:

import torch 
from torchvision.models import resnet18 
 
# 使用PyTorch model zoo中的resnet18作为例子 
model = resnet18() 
model.eval() 
 
# 通过trace的方法生成IR需要一个输入样例 
dummy_input = torch.rand(1, 3, 224, 224) 
 
# IR生成 
with torch.no_grad(): 
    jit_model = torch.jit.trace(model, dummy_input) 

JIT 是一种概念,全称是 Just In Time Compilation,中文译为「即时编译」,是一种程序优化的方法

到这里就将 PyTorch 的模型转换成了 TorchScript 的 IR。这里我们使用了 trace 模式来生成 IR,所谓 trace 指的是进行一次模型推理,在推理的过程中记录所有经过的计算,将这些记录整合成计算图。

那么这个 IR 中到底都有些什么呢?我们可以可视化一下其中的 layer1 看看:

jit_layer1 = jit_model.layer1 
print(jit_layer1.graph) 
 
# graph(%self.6 : __torch__.torch.nn.modules.container.Sequential, 
#       %4 : Float(1, 64, 56, 56, strides=[200704, 3136, 56, 1], requires_grad=0, device=cpu)): 
#   %1 : __torch__.torchvision.models.resnet.___torch_mangle_10.BasicBlock = prim::GetAttr[name="1"](%self.6) 
#   %2 : __torch__.torchvision.models.resnet.BasicBlock = prim::GetAttr[name="0"](%self.6) 
#   %6 : Tensor = prim::CallMethod[name="forward"](%2, %4) 
#   %7 : Tensor = prim::CallMethod[name="forward"](%1, %6) 
#   return (%7) 

是不是有点摸不着头脑?TorchScript 有它自己对于 Graph 以及其中元素的定义,对于第一次接触的人来说可能比较陌生,但是没关系,我们还有另一种可视化方式:

print(jit_layer1.code) 
 
# def forward(self, 
#     argument_1: Tensor) -> Tensor: 
#   _0 = getattr(self, "1") 
#   _1 = (getattr(self, "0")).forward(argument_1, ) 
#   return (_0).forward(_1, ) 

没错,就是代码!TorchScript 的 IR 是可以还原成 python 代码的,如果你生成了一个 TorchScript 模型并且想知道它的内容对不对,那么可以通过这样的方式来做一些简单的检查。

刚才的例子中我们使用 trace 的方法生成IR。除了 trace 之外,PyTorch 还提供了另一种生成 TorchScript 模型的方法:script。这种方式会直接解析网络定义的 python 代码,生成抽象语法树 AST,因此这种方法可以解决一些 trace 无法解决的问题,比如对 branch/loop 等数据流控制语句的建图。script方式的建图有很多有趣的特性,会在未来的分享中做专题分析,敬请期待。

模型优化

聪明的同学可能发现了,上面的可视化中只有resnet18forward的部分,其中的子模块信息是不是丢失了呢?如果没有丢失,那么怎么样才能确定子模块的内容是否正确呢?别担心,还记得我们说过 TorchScript 支持对网络的优化吗,这里我们就可以用一个pass解决这个问题:

# 调用inline pass,对graph做变换 
torch._C._jit_pass_inline(jit_layer1.graph) 
print(jit_layer1.code) 
 
# def forward(self, 
#     argument_1: Tensor) -> Tensor: 
#   _0 = getattr(self, "1") 
#   _1 = getattr(self, "0") 
#   _2 = _1.bn2 
#   _3 = _1.conv2 
#   _4 = _1.bn1 
#   input = torch._convolution(argument_1, _1.conv1.weight, None, [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True, True) 
#   _5 = _4.running_var 
#   _6 = _4.running_mean 
#   _7 = _4.bias 
#   input0 = torch.batch_norm(input, _4.weight, _7, _6, _5, False, 0.10000000000000001, 1.0000000000000001e-05, True) 
#   input1 = torch.relu_(input0) 
#   input2 = torch._convolution(input1, _3.weight, None, [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True, True) 
#   _8 = _2.running_var 
#   _9 = _2.running_mean 
#   _10 = _2.bias 
#   out = torch.batch_norm(input2, _2.weight, _10, _9, _8, False, 0.10000000000000001, 1.0000000000000001e-05, True) 
#   input3 = torch.add_(out, argument_1, alpha=1) 
#   input4 = torch.relu_(input3) 
#   _11 = _0.bn2 
#   _12 = _0.conv2 
#   _13 = _0.bn1 
#   input5 = torch._convolution(input4, _0.conv1.weight, None, [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True, True) 
#   _14 = _13.running_var 
#   _15 = _13.running_mean 
#   _16 = _13.bias 
#   input6 = torch.batch_norm(input5, _13.weight, _16, _15, _14, False, 0.10000000000000001, 1.0000000000000001e-05, True) 
#   input7 = torch.relu_(input6) 
#   input8 = torch._convolution(input7, _12.weight, None, [1, 1], [1, 1], [1, 1], False, [0, 0], 1, False, False, True, True) 
#   _17 = _11.running_var 
#   _18 = _11.running_mean 
#   _19 = _11.bias 
#   out0 = torch.batch_norm(input8, _11.weight, _19, _18, _17, False, 0.10000000000000001, 1.0000000000000001e-05, True) 
#   input9 = torch.add_(out0, input4, alpha=1) 
#   return torch.relu_(input9) 

这里我们就能看到卷积、batch_norm、relu等熟悉的算子了。

上面代码中我们使用了一个名为inlinepass,将所有子模块进行内联,这样我们就能看见更完整的推理代码。pass是一个来源于编译原理的概念,一个 TorchScript 的 pass 会接收一个图,遍历图中所有元素进行某种变换,生成一个新的图。我们这里用到的inline起到的作用就是将模块调用展开,尽管这样做并不能直接影响执行效率,但是它其实是很多其他pass的基础。PyTorch 中定义了非常多的 pass 来解决各种优化任务,未来我们会做一些更详细的介绍。

序列化

不管是哪种方法创建的 TorchScript 都可以进行序列化,比如:

# 将模型序列化 
jit_model.save('jit_model.pth') 
# 加载序列化后的模型 
jit_model = torch.jit.load('jit_model.pth') 

序列化后的模型不再与 python 相关,可以被部署到各种平台上。

PyTorch 提供了可以用于 TorchScript 模型推理的 c++ API,序列化后的模型终于可以不依赖 python 进行推理了:

// 加载生成的torchscript模型 
auto module = torch::jit::load('jit_model.pth'); 
// 根据任务需求读取数据 
std::vector<torch::jit::IValue> inputs = ...; 
// 计算推理结果 
auto output = module.forward(inputs).toTensor(); 

与 torch.onnx 的关系:ONNX 是业界广泛使用的一种神经网络中间表示,PyTorch 自然也对 ONNX 提供了支持。torch.onnx.export函数可以帮助我们把 PyTorch 模型转换成 ONNX 模型,这个函数会使用 trace 的方式记录 PyTorch 的推理过程。聪明的同学可能已经想到了,没错,ONNX 的导出,使用的正是 TorchScript 的 trace 工具。具体步骤如下:

  1. 使用 trace 的方式先生成一个 TorchScipt 模型,如果你转换的本身就是 TorchScript 模型,则可以跳过这一步。
  2. 使用许多 pass 对 1 中生成的模型进行变换,其中对 ONNX 导出最重要的一个 pass 就是ToONNX,这个 pass 会进行一个映射,将 TorchScript 中primaten空间下的算子映射到onnx空间下的算子。
  3. 使用 ONNX 的 proto 格式对模型进行序列化,完成 ONNX 的导出。

D-NeRF—针对动态场景的NeRF

主页:https://www.albertpumarola.com/research/D-NeRF/index.html

https://github.com/albertpumarola/D-NeRF

D-NeRF: Neural Radiance Fields for Dynamic Scenes https://arxiv.org/abs/2011.13961

Abstract


NeRF只能重建静态场景,本文提出的方法可以把神经辐射场扩展到动态领域,可以在单相机围绕场景旋转一周的情况下重建物体的刚性和非刚性运动。由此把时间作为附加维度加到输入中,同时把学习过程分为两个阶段:第一个把场景编码到基准空间,另一个在特定时间把这种基准表达形式map到变形场景中。两个map都用fcn学习,训练完后可以通过控制相机视角和时间变量达到物体运动的效果。

Introduction

和已有的4d方式不同点有:

  • 只需单相机
  • 无需预先算好一个三维重建
  • 可以端到端

想做的就是在原始nerf五维输入上加个时间t,完成

到density和radiance的输出。不过如果直接加,时间冗余没有很好的利用,效果并不好。所以分两个模块:

而且可以产生完整三维mesh,捕捉时变几何。

Pipeline

Formulation

Method


Canonical network(规范网络)

希望找到一种场景的表示能把所有图的相关点的信息都汇聚起来。网络 Ψx 用来编码基准空间中的密度和颜色,给一个坐标x,通过fcn输出256维向量再和d concatenate起来输出density和color


Deformation network(变形网络)

Ψt 训练用来估计某一具体时刻的场景和基准空间中场景的变形场(deformation field),给一个坐标x,输出是这个点和基准空间中的这个点的位移。并且把基准空间场景设为t=0

也用了位置编码,x十维,d,t都是四维

Volume rendering

带时间的渲染公式为:

p(ℎ,t) 表示canonical space中由x(h)变换来的点所以其实渲染的时候还是在canonical space里边进行渲染的离散化形式。

Experiment


可以出mesh,depth:

还可以合成阴影:

相比其他方法:

MPViT : Multi-Path Vision Transformer for Dense Prediction

https://arxiv.org/abs/2112.11010(CVPR2022)

https://github.com/youngwanLEE/MPViT

Introduction

  • 在这项工作中,作者以不同于现有Transformer的视角,探索多尺度path embedding与multi-path结构,提出了Multi-path Vision Transformer(MPViT)
  • 通过使用 overlapping convolutional patch embedding,MPViT同时嵌入相同大小的patch特征。然后,将不同尺度的Token通过多条路径独立地输入Transformer encoders,并对生成的特征进行聚合,从而在同一特征级别上实现精细和粗糙的特征表示。
  • 在特征聚合步骤中,引入了一个global-to-local feature interaction(GLI)过程,该过程将卷积局部特征与Transformer的全局特征连接起来,同时利用了卷积的局部连通性和Transformer的全局上下文。

Vision Transformers for dense predictions.

  • 密集的计算机视觉任务,如目标检测和分割,需要有效的多尺度特征表示,以检测或分类不同大小的物体或区域。Vision Transformer(ViT)构建了一个简单的多阶段结构(即精细到粗糙),用于使用单尺度patch的多尺度表示。然而ViT的变体专注于降低自注意的二次复杂度,较少关注构建有效的多尺度表示。
  • CoaT通过使用一种co-scale机制,同时表示精细和粗糙的特征,允许并行地跨层注意,从而提高了检测性能。然而,co-scale机制需要大量的计算和内存开销,因为它为基础模型增加了额外的跨层关注(例如,CoaT-Lite)。因此,对于ViT体系结构的多尺度特征表示仍有改进的空间。

Comparison to Concurrent work.

  • CrossViT利用了不同的patch大小和单级结构中的双路径,如ViT和XCiT。然而,CrossViT的分支之间的相互作用只通过[CLS]token发生,而MPViT允许所有不同规模的patch相互作用。此外,与CrossViT(仅限分类)不同的是,MPViT更普遍地探索更大的路径维度(例如,超过两个维度),并采用多阶段结构进行密集预测。

Method

Conv-stem

输入图像大小为:H×W×3,两层卷积:采用两个3×3的卷积,通道分别为C2/2,C2,stride为2,生成特征的大小为H/4×W/4×C2,其中C2为stage 2的通道大小。

  • 说明:每个卷积之后都是Batch Normalization 和一个Hardswish激活函数。
  • In LeViT , a convolutional stem block shows better low-level representation (i.e., without losing salient information) than non-overlapping patch embedding.

从stage 2到stage 5,作者在每个阶段对所提出的Multi-scale Patch Embedding(MS-PatchEmbed)和Multi-path Transformer(MP-Transformer)块进行堆叠

Multi-Scale Patch Embedding

通过改变stride和padding来调整token的序列长度。也就是说,可以输出具有不同patch大小的相同大小(即分辨率)的特征。因此,作者并行地形成了几个具有不同卷积核大小的卷积patch embedding层。例如,如图1所示,可以生成相同序列长度的不同大小的vision token,patch大小分别为3×3,5×5,7×7。

由于具有相同通道和滤波器大小的连续卷积操作扩大了接受域,并且需要更少的参数,在实践中选择了连续的3×3卷积层。为了减少参数量,在实践中选择了两个连续的3×3卷积层代替5×5卷积。对于triple-path结构,使用三个连续的3×3卷积,通道大小为C’,padding为1,步幅为s,其中s在降低空间分辨率时为2,否则为1。

  • 说明:为了减少模型参数和计算开销,采用3×3深度可分离卷积,包括3×3深度卷积和1×1点卷积。
  • 每个卷积之后都是Batch Normalization 和一个Hardswish激活函数。

接着,不同大小的token embedding features 分别输入到transformer encoder中。

Multi-path Transformer

原因:Transformer中的self-attention可以捕获长期依赖关系(即全局上下文),但它很可能会忽略每个patch中的结构性信息和局部关系。相反,cnn可以利用平移不变性中的局部连通性,使得CNN在对视觉对象进行分类时,对纹理有更强的依赖性,而不是形状。

因此,MPViT以一种互补的方式将CNN与Transformer结合起来。

  • 为了表示局部特征 
  • 采用了一个 depthwise residual bottleneck block,包括1×1卷积、3×3深度卷积和1×1卷积和残差连接。
  • 为了减轻多路径结构的计算负担,使用了CoaT中提出的有效的因素分解自注意:

Global-to-Local Feature Interaction

将局部特征和全局特征聚合起来:

为了保持可比性的参数和FLOPs,增加路径的数量需要减少通道C或层数L(即,transformer encoder的数量)。作者通过减少C而不是L,从单路径(即CoaT-Lite baseline)扩展到triple-path。在消融研究中,验证了减少C比减少L获得更好的性能(见表5)。由于stage2的特征分辨率较高,导致计算成本较高,作者在stage2中将triple-path模型的路径数设置为2。从stage3开始,三路径模型有3条路径。

作者还发现,虽然 triple-path和双路径在ImageNet分类中产生相似的精度,但 triple-path模型在密集预测任务中表现出更好的性能。因此,建立了基于 triple-path结构的MPViT模型。MPViT的详细情况见表1。

Experiments

Ablation study

对MPViT-XS的每个组件进行消融研究,以研究提出的多路径结构对图像分类和使用Mask R-CNN检测的有效性。

Exploring path dimension.

SynSin:单张图片端到端新视点重建

整体思路:图片-网络-深度图->(直接转换)点云-网络-新视点(CVPR2020)

SynSin: End-to-end View Synthesis from a Single Image

项目主页: https://www.robots.ox.ac.uk/~ow/synsin.html

来自牛津大学、FAIR、Facebook 和密歇根大学的研究者提出了一种单一图像视图合成方法,允许从单一输入图像生成新的场景视图。它被训练在真实的图像上,没有使用任何真实的 3D 信息;引入了一种新的可微点云渲染器,用于将潜在的 3D 点云特征转换为目标视图;细化网络对投影特征进行解码,插入缺失区域,生成逼真的输出图像;生成模型内部的 3D 组件允许在测试时对潜在特征空间进行可解释的操作,例如,可以从单个图像动画轨迹。与以前的工作不同,SynSin 可以生成高分辨率的图像,并推广到其他输入分辨率,在 Matterport、Replica 和 RealEstate10K 数据集上超越基线和前期工作。

整体网络:

首先将图片输入特征和深度网络得到特征map和深度图,接着通过相机参数变换为带特征的点云,接着根据相对变换矩阵T,将带特征的点云渲染到二维像素位置上,接着通过一个GAN生成最终的新视角图片。

该论文的渲染方式值得关注,其并不是简单的使用zbuffer和固定投影像素位置渲染的,在z深度和平面广度上都是软映射的。平面广度上是线性递减的辐射,在深度上取k近邻并排序后,按照次序递减:

这种软渲染的方式估计会牺牲较大的渲染精度,但是对于优化问题来说非常合适不过。

总结:构建了一个先进的端到端模型SynSin,它可以获取单个RGB图像,然后从不同的角度生成同一场景的新图像,无需任何3D监督。我们系统主要是预测一个3D点云,后者通过PyTorch3D使用我们的可微渲染器投射到新的视图上,并且将渲染的点云传递到生成对抗网络(GAN)来合成输出图像。当前的方法通常是使用密集体素网格,它们在单个对象的合成场景中显示出优秀的应用前景,但无法扩展到复杂的真实场景。利用点云的灵活性,SynSin不仅能够实现这一点,而且比体素网格等替代方法更有效地推广到各种分辨率。SynSin的高效率可以帮助我们探索广泛的应用,如生成更好的3D照片和360度视频。

ONNX 模型的修改与调试

转自:模型部署入门教程(五):ONNX 模型的修改与调试

一直以来,我们都是通过 PyTorch 来导出 ONNX 模型的,基本没有单独探究过 ONNX 模型的构造知识。
不知道大家会不会有这样一些疑问:ONNX 模型在底层是用什么格式存储的?如何不依赖深度学习框架,只用 ONNX 的 API 来构造一个 ONNX 模型?如果没有源代码,只有一个 ONNX 模型,该如何对这个模型进行调试?别急,今天我们就来为大家一一揭晓。
在这期教程里,我们将围绕 ONNX 这一套神经网络定义标准本身,探究 ONNX 模型的构造、读取、子模型提取、调试。首先,我们会学习 ONNX 的底层表示方式。之后,我们会用 ONNX API 构造和读取模型。最后,我们会利用 ONNX 提供的子模型提取功能,学习如何调试 ONNX 模型。

ONNX 的底层实现

ONNX 的存储格式

ONNX 在底层是用 Protobuf 定义的。Protobuf,全称 Protocol Buffer,是 Google 提出的一套表示和序列化数据的机制。使用 Protobuf 时,用户需要先写一份数据定义文件,再根据这份定义文件把数据存储进一份二进制文件。可以说,数据定义文件就是数据类,二进制文件就是数据类的实例。
这里给出一个 Protobuf 数据定义文件的例子:

message Person { 
  required string name = 1; 
  required int32 id = 2; 
  optional string email = 3; 
} 

这段定义表示在 Person 这种数据类型中,必须包含 nameid 这两个字段,选择性包含 email字段。根据这份定义文件,用户就可以选择一种编程语言,定义一个含有成员变量 nameidemail 的 Person 类,把这个类的某个实例用 Protobuf 存储成二进制文件;反之,用户也可以用二进制文件和对应的数据定义文件,读取出一个 Person 类的实例。
而对于 ONNX ,Protobuf 的数据定义文件在其开源库,这些文件定义了神经网络中模型、节点、张量的数据类型规范;而二进制文件就是我们熟悉的“.onnx”文件,每一个 onnx 文件按照数据定义规范,存储了一个神经网络的所有相关数据。直接用 Protobuf 生成 ONNX 模型还是比较麻烦的。幸运的是,ONNX 提供了很多实用 API,我们可以在完全不了解 Protobuf 的前提下,构造和读取 ONNX 模型。


ONNX 的结构定义


在用 API 对 ONNX 模型进行操作之前,我们还需要先了解一下 ONNX 的结构定义规则,学习一下 ONNX 在 Protobuf 定义文件里是怎样描述一个神经网络的。
回想一下,神经网络本质上是一个计算图。计算图的节点是算子,边是参与运算的张量。而通过可视化 ONNX 模型,我们知道 ONNX 记录了所有算子节点的属性信息,并把参与运算的张量信息存储在算子节点的输入输出信息中。事实上,ONNX 模型的结构可以用类图大致表示如下:

如图所示,一个 ONNX 模型可以用 ModelProto 类表示。ModelProto 包含了版本、创建者等日志信息,还包含了存储计算图结构的 graphGraphProto 类则由输入张量信息、输出张量信息、节点信息组成。张量信息 ValueInfoProto 类包括张量名、基本数据类型、形状。节点信息 NodeProto 类包含了算子名、算子输入张量名、算子输出张量名。
让我们来看一个具体的例子。假如我们有一个描述 output=a*x+b 的 ONNX 模型 model,用 print(model) 可以输出以下内容:

ir_version: 8 
graph { 
  node { 
    input: "a" 
    input: "x" 
    output: "c" 
    op_type: "Mul" 
  } 
  node { 
    input: "c" 
    input: "b" 
    output: "output" 
    op_type: "Add" 
  } 
  name: "linear_func" 
  input { 
    name: "a" 
    type { 
      tensor_type { 
        elem_type: 1 
        shape { 
          dim {dim_value: 10} 
          dim {dim_value: 10} 
        } 
      } 
    } 
  } 
  input { 
    name: "x" 
    type { 
      tensor_type { 
        elem_type: 1 
        shape { 
          dim {dim_value: 10} 
          dim {dim_value: 10} 
        } 
      } 
    } 
  } 
  input { 
    name: "b" 
    type { 
      tensor_type { 
        elem_type: 1 
        shape { 
          dim {dim_value: 10} 
          dim {dim_value: 10} 
        } 
      } 
    } 
  } 
  output { 
    name: "output" 
    type { 
      tensor_type { 
        elem_type: 1 
        shape { 
          dim { dim_value: 10} 
          dim { dim_value: 10} 
        } 
      } 
    } 
  } 
} 
opset_import {version: 15} 

对应上文中的类图,这个模型的信息由 ir_versionopset_import 等全局信息和 graph 图信息组成。而 graph 包含一个乘法节点、一个加法节点、三个输入张量 a, x, b 以及一个输出张量 output。在下一节里,我们会用 API 构造出这个模型,并输出这段结果。

读写 ONNX 模型

构造 ONNX 模型


在上一小节中,我们知道了 ONNX 模型是按以下的结构组织起来的:

  • ModelProto
    • GraphProto
      • NodeProto
      • ValueInfoProto

现在,让我们抛开 PyTorch,尝试完全用 ONNX 的 Python API 构造一个描述线性函数 output=a*x+b 的 ONNX 模型。我们将根据上面的结构,自底向上地构造这个模型。
首先,我们可以用 helper.make_tensor_value_info 构造出一个描述张量信息的 ValueInfoProto 对象。如前面的类图所示,我们要传入张量名、张量的基本数据类型、张量形状这三个信息。在 ONNX 中,不管是输入张量还是输出张量,它们的表示方式都是一样的。因此,这里我们用类似的方式为三个输入 a, x, b 和一个输出 output 构造 ValueInfoProto 对象。如下面的代码所示:

import onnx 
from onnx import helper 
from onnx import TensorProto 
 
a = helper.make_tensor_value_info('a', TensorProto.FLOAT, [10, 10]) 
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [10, 10]) 
b = helper.make_tensor_value_info('b', TensorProto.FLOAT, [10, 10]) 
output = helper.make_tensor_value_info('output', TensorProto.FLOAT, [10, 10]) 

之后,我们要构造算子节点信息 NodeProto,这可以通过在 helper.make_node 中传入算子类型、输入算子名、输出算子名这三个信息来实现。我们这里先构造了描述 c=a*x 的乘法节点,再构造了 output=c+b 的加法节点。如下面的代码所示:

mul = helper.make_node('Mul', ['a', 'x'], ['c']) 
add = helper.make_node('Add', ['c', 'b'], ['output']) 

在计算机中,图一般是用一个节点集和一个边集表示的。而 ONNX 巧妙地把边的信息保存在了节点信息里,省去了保存边集的步骤。在 ONNX 中,如果某节点的输入名和之前某节点的输出名相同,就默认这两个节点是相连的。如上面的例子所示:Mul 节点定义了输出 cAdd 节点定义了输入 c,则 Mul 节点和 Add 节点是相连的。
正是因为有这种边的隐式定义规则,所以 ONNX 对节点的输入有一定的要求:一个节点的输入,要么是整个模型的输入,要么是之前某个节点的输出。如果我们把 a, x, b 中的某个输入节点从计算图中拿出(这个操作会在之后的代码中介绍),或者把 Mul 的输出从 c 改成 d,则最终的 ONNX 模型都是不满足标准的。

一个不满足标准的 ONNX 模型可能无法被推理引擎正确识别。ONNX 提供了 API onnx.checker.check_model 来判断一个 ONNX 模型是否满足标准。

接下来,我们用 helper.make_graph 来构造计算图 GraphProtohelper.make_graph 函数需要传入节点、图名称、输入张量信息、输出张量信息这 4 个参数。如下面的代码所示,我们把之前构造出来的 NodeProto 对象和 ValueInfoProto 对象按照顺序传入即可。

graph = helper.make_graph([mul, add], 'linear_func', [a, x, b], [output]) 

这里 make_graph 的节点参数有一个要求:计算图的节点必须以拓扑序给出。

拓扑序是与有向图的相关的数学概念。如果按拓扑序遍历所有节点的话,能保证每个节点的输入都能在之前节点的输出里找到(对于 ONNX 模型,我们把计算图的输入张量也看成“之前的输出”)。

如果对这个概念不熟也没有关系,我们以刚刚构造出来的这个计算图为研究对象,通过下图展示的两个例子来直观理解拓扑序。

这里我们只关注 Mul 和 Add 节点以及它们之间的边 c。在情况 1 中:如果我们的节点以 [Mul, Add] 顺序给出,那么遍历到 Add 时,它的输入 c 可以在之前的Mul的输出中找到。但是,如情况 2 所示:如果我们的节点以 [Add, Mul] 的顺序给出,那么 Add 就找不到输入边,计算图也无法成功构造出来了。这里的 [Mul, Add] 就是符合有向图的拓扑序的,而 [Add, Mul] 则不满足。

最后,我们用 helper.make_model 把计算图 GraphProto 封装进模型 ModelProto 里,一个 ONNX 模型就构造完成了。make_model 函数中还可以添加模型制作者、版本等信息,为了简单起见,我们没有添加额外的信息。如下面的代码所示:

model = helper.make_model(graph) 

构造完模型之后,我们用下面这三行代码来检查模型正确性、把模型以文本形式输出、存储到一个 “.onnx” 文件里。这里用 onnx.checker.check_model 来检查模型是否满足 ONNX 标准是必要的,因为无论模型是否满足标准,ONNX 都允许我们用 onnx.save 存储模型。我们肯定不希望生成一个不满足标准的模型。

onnx.checker.check_model(model) 
print(model) 
onnx.save(model, 'linear_func.onnx') 

成功执行这些代码的话,程序会以文本格式输出模型的信息,其内容应该和我们在上一节展示的输出一样。
整理一下,用 ONNX Python API 构造模型的代码如下:

import onnx 
from onnx import helper 
from onnx import TensorProto 
 
# input and output 
a = helper.make_tensor_value_info('a', TensorProto.FLOAT, [10, 10]) 
x = helper.make_tensor_value_info('x', TensorProto.FLOAT, [10, 10]) 
b = helper.make_tensor_value_info('b', TensorProto.FLOAT, [10, 10]) 
output = helper.make_tensor_value_info('output', TensorProto.FLOAT, [10, 10]) 
 
# Mul 
mul = helper.make_node('Mul', ['a', 'x'], ['c']) 
 
# Add 
add = helper.make_node('Add', ['c', 'b'], ['output']) 
 
# graph and model 
graph = helper.make_graph([mul, add], 'linear_func', [a, x, b], [output]) 
model = helper.make_model(graph) 
 
# save model 
onnx.checker.check_model(model) 
print(model) 
onnx.save(model, 'linear_func.onnx') 

老规矩,我们可以用 ONNX Runtime 运行模型,来看看模型是否正确:

import onnxruntime 
import numpy as np 
 
sess = onnxruntime.InferenceSession('linear_func.onnx') 
a = np.random.rand(10, 10).astype(np.float32) 
b = np.random.rand(10, 10).astype(np.float32) 
x = np.random.rand(10, 10).astype(np.float32) 
 
output = sess.run(['output'], {'a': a, 'b': b, 'x': x})[0] 
 
assert np.allclose(output, a * x + b) 

一切顺利的话,这段代码不会有任何报错信息。这说明我们的模型等价于执行 a * x + b 这个计算。


读取并修改 ONNX 模型

通过用 API 构造 ONNX 模型,我们已经彻底搞懂了 ONNX 由哪些模块组成。现在,让我们看看该如何读取现有的”.onnx”文件并从中提取模型信息。
首先,我们可以用下面的代码读取一个 ONNX 模型:

import onnx 
model = onnx.load('linear_func.onnx') 
print(model) 

之前在输出模型时,我们传给 onnx.save 的是一个 ModelProto 的对象。同理,用上面的 onnx.load 读取 ONNX 模型时,我们收获的也是一个 ModelProto 的对象。输出这个对象后,我们应该得到和之前完全相同的输出。
接下来,我们来看看怎么把图 GraphProto、节点 NodeProto、张量信息 ValueInfoProto 读取出来:

graph = model.graph 
node = graph.node 
input = graph.input 
output = graph.output 
print(node) 
print(input) 
print(output) 

使用如上这些代码,我们可以分别访问模型的图、节点、张量信息。这里大家或许会有疑问:该怎样找出 graph.node,graph.input 中 node, input 这些属性名称呢?其实,属性的名称就写在每个对象的输出里。我们以 print(node) 的输出为例:

[input: "a" 
input: "x" 
output: "c" 
op_type: "Mul" 
, input: "c" 
input: "b" 
output: "output" 
op_type: "Add" 
] 

在这段输出中,我们能看出 node 其实就是一个列表,列表中的对象有属性 input, output, op_type(这里 input 也是一个列表,它包含的两个元素都显示出来了)。我们可以用下面的代码来获取 node 里第一个节点 Mul 的属性:

node_0 = node[0] 
node_0_inputs = node_0.input 
node_0_outputs = node_0.output 
input_0 = node_0_inputs[0] 
input_1 = node_0_inputs[1] 
output = node_0_outputs[0] 
op_type = node_0.op_type 
 
print(input_0) 
print(input_1) 
print(output) 
print(op_type) 
 
# Output 
""" 
a 
x 
c 
Mul 
""" 

当我们想知道 ONNX 模型某数据对象有哪些属性时,我们不必去翻 ONNX 文档,只需要先把数据对象输出一下,然后在输出结果找出属性名即可。
读取 ONNX 模型的信息后,修改 ONNX 模型就是一件很轻松的事了。我们既可以按照上一小节的模型构造方法,新建节点和张量信息,与原有模型组合成一个新的模型,也可以在不违反 ONNX 规范的前提下直接修改某个数据对象的属性。
这里我们来看一个直接修改模型属性的例子:

import onnx 
model = onnx.load('linear_func.onnx') 
 
node = model.graph.node 
node[1].op_type = 'Sub' 
 
onnx.checker.check_model(model) 
onnx.save(model, 'linear_func_2.onnx') 

在读入之前的 linear_func.onnx 模型后,我们可以直接修改第二个节点的类型 node[1].op_type,把加法变成减法。这样,我们的模型描述的是 a * x - b 这个线性函数。大家感兴趣的话,可以用 ONNX Runtime 运行新模型 linear_func_2.onnx,来验证一下它和 a * x - b 是否等价。

调试 ONNX 模型

在实际部署中,如果用深度学习框架导出的 ONNX 模型出了问题,一般要通过修改框架的代码来解决,而不会从 ONNX 入手,我们把 ONNX 模型当成一个不可修改的黑盒看待。
现在,我们已经深入学习了 ONNX 的原理,可以尝试对 ONNX 模型本身进行调试了。在这一节里,让我们看看该如何巧妙利用 ONNX 提供的子模型提取功能,对 ONNX 模型进行调试。

子模型提取

ONNX 官方为开发者提供了子模型提取(extract)的功能。子模型提取,顾名思义,就是从一个给定的 ONNX 模型中,拿出一个子模型。这个子模型的节点集、边集都是原模型中对应集合的子集。让我们来用 PyTorch 导出一个复杂一点的 ONNX 模型,并在它的基础上执行提取操作:

import torch 
 
class Model(torch.nn.Module): 
 
    def __init__(self): 
        super().__init__() 
        self.convs1 = torch.nn.Sequential(torch.nn.Conv2d(3, 3, 3), 
                                          torch.nn.Conv2d(3, 3, 3), 
                                          torch.nn.Conv2d(3, 3, 3)) 
        self.convs2 = torch.nn.Sequential(torch.nn.Conv2d(3, 3, 3), 
                                          torch.nn.Conv2d(3, 3, 3)) 
        self.convs3 = torch.nn.Sequential(torch.nn.Conv2d(3, 3, 3), 
                                          torch.nn.Conv2d(3, 3, 3)) 
        self.convs4 = torch.nn.Sequential(torch.nn.Conv2d(3, 3, 3), 
                                          torch.nn.Conv2d(3, 3, 3), 
                                          torch.nn.Conv2d(3, 3, 3)) 
    def forward(self, x): 
        x = self.convs1(x) 
        x1 = self.convs2(x) 
        x2 = self.convs3(x) 
        x = x1 + x2 
        x = self.convs4(x) 
        return x 
 
model = Model() 
input = torch.randn(1, 3, 20, 20) 
 
torch.onnx.export(model, input, 'whole_model.onnx') 


这个模型的可视化结果如下图所示(提取子模型需要输入边的序号,为了大家方面阅读,这幅图标出了之后要用到的边的序号):

在前面的章节中,我们学过,ONNX 的边用同名张量表示的。也就是说,这里的边序号,实际上是前一个节点的输出张量序号和后一个节点的输入张量序号。由于这个模型是用 PyTorch 导出的,这些张量序号都是 PyTorch 自动生成的。


接着,我们可以下面的代码提取出一个子模型:

import onnx  
 
onnx.utils.extract_model('whole_model.onnx', 'partial_model.onnx', ['22'], ['28']) 

子模型的可视化结果如下图所示:

通过观察代码和输出图,应该不难猜出这段代码的作用是把原计算图从边 22 到边 28 的子图提取出来,并组成一个子模型。onnx.utils.extract_model 就是完成子模型提取的函数,它的参数分别是原模型路径、输出模型路径、子模型的输入边(输入张量)、子模型的输出边(输出张量)。
直观地来看,子模型提取就是把输入边到输出边之间的全部节点都取出来。那么,这个功能在使用上有什么限制呢?基于 whole_model.onnx, 我们来看一看三个子模型提取的示例。

添加额外输出

我们在提取时新设定了一个输出张量,如下面的代码所示:

onnx.utils.extract_model('whole_model.onnx', 'submodel_1.onnx', ['22'], ['27', '31']) 

我们可以看到子模型会添加一条把张量输出的新边,如下图所示:

添加冗余输入

如果我们还是像开始一样提取边 22 到边 28 之间的子模型,但是多添加了一个输入 input.1,那么提取出的子模型会有一个冗余的输入 input.1,如下面的代码所示:

onnx.utils.extract_model('whole_model.onnx', 'submodel_2.onnx', ['22', 'input.1'], ['28']) 

从下图可以看到:无论给这个输入传入什么值,都不会影响子模型的输出。可以认为如果只用子模型的部分输入就能得到输出,那么那些”较早“的多出来的输入就是冗余的。

输入信息不足

这次,我们尝试提取的子模型输入是边 24,输出是边 28。如下面的代码和图所示:

# Error 
onnx.utils.extract_model('whole_model.onnx', 'submodel_3.onnx', ['24'], ['28']) 

从图中可以看出,想通过边 24 计算边 28 的结果,至少还需要输入边 26,或者更上面的边。仅凭借边 24 是无法计算出边 28 的结果的,因此这样提取子模型会报错。

通过上面几个使用示例,我们可以整理出子模型提取的实现原理:新建一个模型,把给定的输入和输出填入。之后把图的所有有向边反向,从输出边开始遍历节点,碰到输入边则停止,把这样遍历得到的节点做为子模型的节点。
如果还没有彻底弄懂这个提取原理,没关系,我们只要尽量保证在填写子模型的输入输出时,让输出恰好可以由输入决定即可。

输出 ONNX 中间节点的值

在使用 ONNX 模型时,最常见的一个需求是能够用推理引擎输出中间节点的值。这多见于深度学习框架模型和 ONNX 模型的精度对齐中,因为只要能够输出中间节点的值,就能定位到精度出现偏差的算子。我们来看看如何用子模型提取实现这一任务。
在刚刚的第一个子模型提取示例中,我们添加了一条原来模型中不存在的输出边。用同样的原理,我们可以在保持原有输入输出不变的同时,新增加一些输出,提取出一个能输出中间节点的”子模型“。例如:

 onnx.utils.extract_model('whole_model.onnx', 'more_output_model.onnx', ['input.1'], ['31', '23', '25', '27'])

在这个子模型中,我们在保持原有的输入 input.1,输出 31 的同时,把其他几个边加入了输出中。如下图所示:

这样,用 ONNX Runtime 运行 more_output_model.onnx 这个模型时,我们就能得到更多的输出了。
为了方便调试,我们还可以把原模型拆分成多个互不相交的子模型。这样,在每次调试时,可以只对原模型的部分子模块调试。比如:

onnx.utils.extract_model('whole_model.onnx', 'debug_model_1.onnx', ['input.1'], ['23']) 
onnx.utils.extract_model('whole_model.onnx', 'debug_model_2.onnx', ['23'], ['25']) 
onnx.utils.extract_model('whole_model.onnx', 'debug_model_3.onnx', ['23'], ['27']) 
onnx.utils.extract_model('whole_model.onnx', 'debug_model_4.onnx', ['25', '27'], ['31']) 

在这个例子中,我们把原来较为复杂的模型拆成了四个较为简单的子模型,如下图所示。在调试时,我们可以先调试顶层的子模型,确认顶层子模型无误后,把它的输出做为后面子模型的输入。
比如对于这些子模型,我们可以先调试第一个子模型,并存储输出 23。之后把张量 23 做为第二个和第三个子模型的输入,调试这两个模型。最后用同样方法调试第四个子模型。可以说,有了子模型提取功能,哪怕是面对一个庞大的模型,我们也能够从中提取出有问题的子模块,细致地只对这个子模块调试。

子模型提取固然是一个便利的 ONNX 调试工具。但是,在实际的情况中,我们一般是用 PyTorch 等框架导出 ONNX 模型。这里有两个问题:

  1. 一旦 PyTorch 模型改变,ONNX 模型的边序号也会改变。这样每次提取同样的子模块时都要重新去 ONNX 模型里查序号,如此繁琐的调试方法是不会在实践中采用的。
  2. 即使我们能保证 ONNX 的边序号不发生改变,我们也难以把 PyTorch 代码和 ONNX 节点对应起来——当模型结构变得十分复杂时,要识别 ONNX 中每个节点的含义是不可能的。