传奇NLP攻城狮成长之路(一)

从本期开始,集智AI学园(小仙女)将开始以每周一篇的频率给大家推送“传奇NLP攻城狮成长之路”系列教程啦!

与外面那些妖艳的碧池相比,“传奇NLP攻城狮成长之路”系列教程有什么特点嘞?

第一,主题明确

现在经常有些公众号偶尔转发一篇深度学习教程,但这些单节的小教程即不连贯,也看不到什么主题,看完感觉自己还是什么也没掌握,不知道拿来能干啥。 而我们此次推出的“传奇NLP攻城狮成长之路”,全套教程以自然语言处理技术(NLP)为主题,让你快速掌握深度学习技术在NLP领域的应用,从小白摇身变为NLP攻城狮。

第二,技术最新

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

全套教程基于Facebook最新发布的PyTorch深度学习框架。曾经有学术界大牛感慨道:我从用Theano开始,用过Tensorflow,最后才发现PyTorch才是真爱!

第三,每周连载,免费!免费!免费!

本系列全套教程都将在集智AI学园公众号上发布,周周更新,为读者负责到底,让你能把真正的技术学习到手!

我们已经为大家准备好了10期的教程内容,在这些教程中不但有项目实例,有时候还会给大家布置点小作业,锻炼大家用学到的知识解决问题的能力。

  • 本期:PyTorch概要简析
  • 下期:小试牛刀:编写一个词袋分类器
  • 第三期:使用RNN做一个名字分类器
  • 第四期:起名大师:使用RNN生成个好名字
  • 第五期:AI编剧:使用RNN创作莎士比亚剧本
  • 第六期:AI翻译官:采用注意力机制的翻译系统
  • 第七期:探索词向量世界
  • 第八期:词向量高级:单词语义编码器
  • 第九期:长短记忆神经网络(LSTM)序列建模
  • 第十期:体验PyTorch动态编程,双向LSTM+CRF

我们打算从PyTorch基础开始讲解,但不会讲太多细节。在简单的介绍一些概念后,我们会快速的切入NLP主题的相关技术。 教程中会提到“反向传播”、“链式求导”、“语言建模”等概念,如果不懂也不用担心,只要把它们当成一个方法来用就行啦。 反正本系列教程的最终目的就是让你具备独立编写深度学习代码的能力!

关于PyTorch,有同学可能会问,PyTorch是不是没有Windows的版本?

Facebook的确没有推出PyTorch的官方版本,但是这并没有难倒我们中国网友,知乎上已经有大神找到了在Windows上安装PyTorch的方法:

https://zhuanlan.zhihu.com/p/26871672

或者干脆不要自己捣鼓软件环境,直接使用配置好环境的Floyd镜像,启动就能跑代码,免去自己配置环境的麻烦:教程 | Windows用户指南:如何用Floyd跑PyTorch

好!

话就讲到这里

让我们开始本期的教程!

import torch
import torch.autograd as autograd
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
torch.manual_seed(1)
<torch._C.Generator at 0x10c71f810>

如果上面的代码能正确运行,说明你的PyTorch环境已经可用,下面我们就PyTorch的一些核心元素给大家做一个简单的介绍。

【本期福利】

在集智AI学园公众号回复:“传奇NLP攻城狮01”,可以获取本节课程的Jupyter Notebook文档哦!

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

《PyTorch 概要简析》

本期目录:

  • 1.PyTorch 核心模块 Tensor 简介
  • 2.计算图以及自动微分
  • 3.深度学习要点:仿射映射、非线性、目标函数
  • 4.优化与训练
  • 5.总结

1、 PyTorch 核心模块 Tensor 简介

Tensor的中文翻译是“张量”,可别小看这个玩意。在我们后面要讲的深度学习运算中,所有的计算都是通过张量来进行的!如果你不熟悉张量,目前也不用了解太深,只要将它理解为是一个n维的矩阵就行啦。

1.1 让我们就来创建一些Tensor吧!

我们可以把 Python  list 类型转化成一个 Tensor
(下边代码可左右滑动)

# 使用指定的数据创建Tensor,我们先来创建一个维度为1的Tensor,这时候我们可以把它看做一个1维的向量。
V_data = [1., 2., 3.] 
V = torch.Tensor(V_data)
print(V)

# 创建一个2维的Tensor
M_data = [[1., 2., 3.], [4., 5., 6]]
M = torch.Tensor(M_data)
print(M)

# 创建一个3维的Tensor,维度为:2x2x2
T_data = [[[1.,2.], [3.,4.]],
        [[5.,6.], [7.,8.]]]
T = torch.Tensor(T_data)
print(T)

1
2
3
[torch.FloatTensor of size 3]

1 2 3
4 5 6
[torch.FloatTensor of size 2x3]

(0 ,.,.) = 
1 2
3 4
(1 ,.,.) = 
5 6
7 8
[torch.FloatTensor of size 2x2x2]

3维的Tensor是不是不好理解呀? 其实这玩意和我们经常使用的多维数组是一个意思,不管是几个维度都是可以通过索引(下标)来取值的。 对于1维的Tensor,我们可以通过索引取到的是一个“值”; 对于2维的Tensor,通过索引可以取到一个“向量”; 对于3维的Tensor,通过索引可以取到一个“矩阵”

# 取一个值
print(V[0])

# 取一个向量
print(M[0])

# 取一个矩阵
print(T[0])


1.0

1
2
3
[torch.FloatTensor of size 3] 
1 2
3 4
[torch.FloatTensor of size 2x2]

刚才我们创建的Tensor,默认都是Float类型的(torch.FloatTensor),我们可以在创建Tensor的时候指定Tensor的类型,比如使用 torch.LongTensor() 创建一个长整型的 Tensor。

我们也可以创建一个充满随机数的Tensor,只要在创建的时候指定Tensor的维度即可。

下面我们就创建一个维度为(3,4,5)的Tensor,即一个拥有3个元素的Tensor,其中每个元素是4行5列的矩阵。

x = torch.randn((3, 4, 5))
print(x)


(0 ,.,.) = 
-2.9718 1.7070 -0.4305 -2.2820 0.5237
0.0004 -1.2039 3.5283 0.4434 0.5848
0.8407 0.5510 0.3863 0.9124 -0.8410
1.2282 -1.8661 1.4146 -1.8781 -0.4674

(1 ,.,.) = 
-0.7576 0.4215 -0.4827 -1.1198 0.3056
1.0386 0.5206 -0.5006 1.2182 0.2117
-1.0613 -1.9441 -0.9596 0.5489 -0.9901
-0.3826 1.5037 1.8267 0.5561 1.6445

(2 ,.,.) = 
0.4973 -1.5067 1.7661 -0.3569 -0.1713
0.4068 -0.4284 -1.1299 1.4274 -1.4027
1.4825 -1.1559 1.6190 0.9581 0.7747
0.1940 0.1687 0.3061 1.0743 -1.0327
[torch.FloatTensor of size 3x4x5]

1.2 使用Tensor运算

你可以像操纵一个变量那样操作Tensor进行运算。

x = torch.Tensor([ 1., 2., 3. ])
y = torch.Tensor([ 4., 5., 6. ])
z = x + y
print(z)


5
7
9
[torch.FloatTensor of size 3]

你可以在官方网站http://pytorch.org/docs/torch.html上找到Tensor支持的所有操作。

Tensor的拼接(concatenation)是一个非常重要的操作,所以我们在这里着重讲一下。

# 默认情况下,Tensor是基于第一个维度进行拼接的。
# 比如在下面的例子中,2行+3行得到一个5行5列的Tensor。
x_1 = torch.randn(2, 5)
y_1 = torch.randn(3, 5)
z_1 =torch.cat([x_1, y_1])
print(z_1)

# 我们也可以指定维度进行拼接。
# 比如我们指定要拼接的维度(axis)为1(默认0),对于下面的例子就是3列+5列得到2行8列的Tensor。
x_2 = torch.randn(2, 3)
y_2 = torch.randn(2, 5)
z_2 = torch.cat([x_2, y_2], 1) 
print(z_2)

1.0930 0.7769 -1.3128 0.7099 0.9944
-0.2694 -0.6491 -0.1373 -0.2954 -0.7725
-0.2215 0.5074 -0.6794 -1.6115 0.5230
-0.8890 0.2620 0.0302 0.0013 -1.3987
1.4666 -0.1028 -0.0097 -0.8420 -0.2067
[torch.FloatTensor of size 5x5]


1.0672 0.1732 -0.6873 0.3620 0.3776 -0.2443 -0.5850 2.0812
0.3111 0.2358 -1.0658 -0.1186 0.4903 0.8349 0.8894 0.4148
[torch.FloatTensor of size 2x8]


# 如果你要拼接的Tensor维度不匹配,就会报错。
# 我们来熟悉下这个错误,以后如果在项目中遇到这个错误,可以快速定位。
torch.cat([x_1, x_2])


---------------------------------------------------------------------------

RuntimeError Traceback (most recent call last)

<ipython-input-7-1438963ed9fa> in <module>()
1 # 如果你要拼接的Tensor维度不匹配,就会报错。
2 # 我们来熟悉下这个错误,以后如果在项目中遇到这个错误,可以快速定位。
----> 3 torch.cat([x_1, x_2])


RuntimeError: inconsistent tensor sizes at /Users/soumith/miniconda2/conda-bld/pytorch_1493757035034/work/torch/lib/TH/generic/THTensorMath.c:2559

1.3 非常重要!Tensor 变形

使用.view()方法可以改变Tensor的形状(重设维度),这点也非常重要,为什么要这样说哪?

因为有很多深度神经网络都要求输入数据是一个固定的形状(维度),所以我们经常会用到.view()方法对输入Tensor进行变形。

x = torch.randn(2, 3, 4)
print(x)
# Reshape to 2 rows, 12 columns
# 将2x3x4的Tensor转化为2行12列的Tensor
# 大家可以通过观察输出,来推导变形的过程
print(x.view(2, 12)) 

# 这个和上面代码的实现效果是一样的
# 如果变形的维度参数设置为-1,则由系统自动推导变形的形状(3*4)
print(x.view(2, -1)) 


(0 ,.,.) = 
0.0507 -0.9644 -2.0111 0.5245
2.1332 -0.0822 0.8388 -1.3233
0.0701 1.2200 0.4251 -1.2328

(1 ,.,.) = 
-0.6195 1.5133 1.9954 -0.6585
-0.4139 -0.2250 -0.6890 0.9882
0.7404 -2.0990 1.2582 -0.3990
[torch.FloatTensor of size 2x3x4]



Columns 0 to 9 
0.0507 -0.9644 -2.0111 0.5245 2.1332 -0.0822 0.8388 -1.3233 0.0701 1.2200
-0.6195 1.5133 1.9954 -0.6585 -0.4139 -0.2250 -0.6890 0.9882 0.7404 -2.0990

Columns 10 to 11 
0.4251 -1.2328
1.2582 -0.3990
[torch.FloatTensor of size 2x12]



Columns 0 to 9 
0.0507 -0.9644 -2.0111 0.5245 2.1332 -0.0822 0.8388 -1.3233 0.0701 1.2200
-0.6195 1.5133 1.9954 -0.6585 -0.4139 -0.2250 -0.6890 0.9882 0.7404 -2.0990

Columns 10 to 11 
0.4251 -1.2328
1.2582 -0.3990
[torch.FloatTensor of size 2x12]

2、计算图以及自动微分

计算图的概念对于高效的深度学习编程至关重要,因为它可以让您无需自己编写反向传播梯度。简单来说一个计算图表示的就是你的数据是如何组合起来直到输出的。因为计算图详细说明了你的哪个参数参与了那种运算,所以它包含足够的信息来计算导数。这可能听起来很玄乎,所以就让我们通过使用 autograd.Variable 来探究一下这究竟是怎么回事。

一般来说,对于早期的深度学习程序,都是需要程序员自己编写反向传播的步骤。而像PyTorch这样的新型深度学习框架,可以通过计算图自动计算反向传播。如果你不熟悉计算图和反向传播算法,我这里放一个动图让你可以有一个简单的理解:

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

关于反向传播算法,其实就是个神经网络优化算法,它让神经网络在训练中更加精确。网上的算法解析有很多,在这里推荐两个:

https://www.zhihu.com/question/27239198?rf=24827633

http://galaxy.agh.edu.pl/%7Evlsi/AI/backpten/backprop.html

理论讲烦了,我们还是拿实例来说明问题吧。

首先,从程序员的角度来看。我们在上面创建的torch.Tensor对象中存储了什么?显然它存储了数据和形状,也许还有一些我们看不到的东西。但是当我们将两个Tensor相加时,我们就得到了一个输出Tensor(和)。而这个输出Tensor也仅仅保存着它的数据和形状,它并不知道自己究竟是由两个Tensor相加得到的,还是从数据文件里读取的。

在PyTorch中,仅仅靠Tensor是无法完成反向传播的任务的,因为它无法在计算中自动记录自己的父级关系(即无法自动构造计算图),所以我们要使用“Variable”类型。

# Variable 可以从 Tensor 中创建
x = autograd.Variable( torch.Tensor([1., 2., 3]), requires_grad=True )
# 可以使用.data属性来取出 Variable 中的数据
print(x.data)

# Tensor 支持的运算,Variable 都支持
y = autograd.Variable( torch.Tensor([4., 5., 6]), requires_grad=True )
z = x + y
print(z.data)

# 但是 Variable 的特点是,它在运算中可以自动建立父子节点关系
print(z.creator)


1
2
3
[torch.FloatTensor of size 3]


5
7
9
[torch.FloatTensor of size 3]

<torch.autograd._functions.basic_ops.Add object at 0x106bc9748>

对于上面的程序来说, Variable 知道是什么创建的它。比如 z 知道它自己不是从文件中读入的,也不是乘法或指数运算的结果。

但是,这对于自动计算梯度来说,能起到什么作用哪?

# 让我们把z中的值都加起来(5+7+9)
s = z.sum()
print(s)
print(s.creator)

Variable containing:
21
[torch.FloatTensor of size 1]
<torch.autograd._functions.reduce.Sum object at 0x106bc9668>

我们可以看到,Variable s 中不但保存了z的累加值,还保存了指向它父级单位的“指针”。

从数学上讲,Variable s中的累加和与x的第一个分量的导数是什么?应该是:

现在,Variable S 知道它是来自 Tensor z 元素的累加和,而z知道它自己是x与y的和,那么:

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

所以Variable s包含足够的信息来确定我们想要的导数是1!

当然,我们在这里讲的是简化的导数计算流程,这不是我们讨论的重点,所以大家不理解也没有关系。我们的重点是,相比于Tensor,Variable携带足够的信息让自己可以在反向传播中被计算。实际上,是PyTorch的编写者赋予了sum()函数以及“+”操作自动计算梯度、自动运行反向传播算法的能力。所以大家只要了解它们可以自动运行反向传播算法就行了,本教程不再对这些算法做更多的讨论。

让我们来体验一下PyTorch自动计算梯度的方式:(注意如果你多次运行本段代码,这一块的梯度会累加。PyTorch会在Variable的.grad属性中累加梯度,因为对某些模型来说是非常方便的)。

s.backward() 
 
# 在哪个变量调用这个方法,反向传播就从那个变量开始
print(x.grad)


Variable containing:
1
1
1
[torch.FloatTensor of size 3]

下面我们将演示一个有关自动反向传播的错误使用案例,初学者可能经常会遇到这样的错误,请大家注意!

x = torch.randn((2,2))
y = torch.randn((2,2))
z = x + y 
# z 是 Tensor 运算的结果,是无法参与反向传播运算的

var_x = autograd.Variable( x )
var_y = autograd.Variable( y )
var_z = var_x + var_y 
# var_z 是 Variable 运算的结果,可以进行反向传播运算
print(var_z.creator)

var_z_data = var_z.data
# 将 Variable z 的数据取出来,赋值给var_z_data
new_var_z = autograd.Variable( var_z_data ) 
# 再通过 var_z_data创建一个 Variable new_var_z

# 那么问题来了,通过 new_var_z 可以进行反向传播,从而一直传播到 Variable x,y吗?
# 不行!
print(new_var_z.creator)


<torch.autograd._functions.basic_ops.Add object at 0x10d89c748>
None

为什么不行哪?是因为我们从 var_z 中取出 data ,然后用 data 创建 new_var_z 实际上切断了 new_var_z 与其它变量的联系,它不在计算图中,所以没有父节点,更无法参与反向传播计算。

这是使用autograd.Variables进行计算的最基本,最重要的规则(请注意,这在其它深度学习框架中同样重要):

如果您想让输出层计算的误差可以反向传播到网络的某个部分,你 一定不能 将这个部分的变量从整个计算链中分离出来。如果这样做了,误差函数将无法传播到这个部分,以至于其参数无法更新。

我用粗体字说,因为这个错误可以以非常微妙的发生在你的身上,更严重的是这种错误并不会导致你的代码崩溃或出现警告,因此难以被发现,但是它会导致你的神经网络一直得不到收敛,所以你一定要小心。

3、 深度学习要点:

仿射映射、非线性、目标函数

深度学习以非常巧妙的方式将非线性函数和线性函数结合到一起。引入非线性函数给创造强大的深度学习模型提供了条件。在这一节,我们将深入了解深度学习的核心模块,并尝试定义一个目标函数,观察一个模型究竟是如何进行训练的。

3.1 仿射映射

深度学习一个重要的功能就是可以进行仿射映射(Affine Maps),所谓仿射映射表达成函数是这个样子的:

A代表一个矩阵,x、b代表向量。在深度神经网络的训练过程中,网络学习到的就是这里的A和b。

PyTorch进行仿射映射时处理的是输入参数的列,而不是行,这个大家需要注意一下。通过下面的例子我们可以看到,输入数据的5列被映射成了3列,在映射的计算过程中使用了矩阵A,以及偏置量b。

# 下面是从5列到3列的映射
lin = nn.Linear(5, 3) 
# 创建一个维度为2x5的数据。
data = autograd.Variable( torch.randn(2, 5) ) 
print(data)
print(lin(data)) 


Variable containing:
-0.4705 0.8503 -0.4165 -0.7499 1.0632
0.0073 -1.4252 -0.0781 -0.5138 1.1375
[torch.FloatTensor of size 2x5]

Variable containing:
0.4825 0.0247 0.4566
-0.0652 -0.7002 -0.4353
[torch.FloatTensor of size 2x3]

3.2 非线性函数

首先让我们看看为什么需要非线性函数。假设我们已经有下面两个仿射映射:

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

如果把两个仿射映射结合起来,比如f(g(x)),神经网络会因此变得更加强大吗?

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

在化简结果中,我们看到其实 AC 还是一个矩阵, Ad+b 还是一个向量,把两个仿射映射结合起来得到的还是一个仿射映射,神经网络并没有获得更强的功能。

然而当我们将非线性变换加入进来,事情就完全不一样了,我们可以构建更加强大的模型。

tanh(x), sigmoid(x), ReLU(x)是最常用的非线性变换。你或许会问,非线性函数有很多,为啥特别选择这些函数?

选择这些函数的原因是,这些函数都有梯度,并且容易计算。而计算梯度是深度学习最必要的操作。比如下面就是一个求梯度的操作:

《传奇NLP攻城狮成长之路(一)》

提示:你可能在一些深度学习理论书籍中看到它们常用的非线性函数是sigmoid(x),但是在实际应用中sigmoid(x)并没有应用的那么普遍。因为使用这个函数有时候会导致梯度消失(变得非常小)的问题,梯度变得非常小就意味着学习速度将变得很慢。所以很多人常用 tanh 或者 ReLU 作为非线性函数。

# 在 PyTorch 中,大部分的非线性函数包括在 torch.nn.functional 这个模块中了
# 我们在之前已经把这个模块引入为 F,
data = autograd.Variable( torch.randn(2, 2) )
print(data)
print(F.relu(data))


Variable containing:
-1.0246 -1.0300
-1.0129 0.0055
[torch.FloatTensor of size 2x2]

Variable containing:
1.00000e-03 *
0.0000 0.0000
0.0000 5.5350
[torch.FloatTensor of size 2x2]

3.3 Softmax以及概率函数

Softmax(x)函数也是非线性函数之一,但是这个函数比较特殊,一般只应用在深度神经网络的最后一层。这是因为这个函数会将输入的向量转化为一个概率分布。这个函数的定义如下所示。

可以看到这个函数的输出是一个概率分布,所有的值都为正且相加起来为1。

# 我们也可以在 torch.nn.functional 中找到 Softmax 函数
data = autograd.Variable( torch.randn(5) )
print(data)
print(F.softmax(data))
print(F.softmax(data).sum()) 
# 把所有概率加起来的和为1
print(F.log_softmax(data)) 
# PyTorch 还提供了 log_softmax


Variable containing:
-0.9347
-0.9882
1.3801
-0.1173
0.9317
[torch.FloatTensor of size 5]

Variable containing:
0.0481
0.0456
0.4867
0.1089
0.3108
[torch.FloatTensor of size 5]

Variable containing:
1
[torch.FloatTensor of size 1]

Variable containing:
-3.0350
-3.0885
-0.7201
-2.2176
-1.1686
[torch.FloatTensor of size 5]

3.4 目标函数

目标函数就是神经网络在训练的过程中需要最小化的函数(有时候它也被称为 loss function 或者 cost function)。

一般计算损失,进行反向传播的流程是这样的:

首先选择训练实例(一条训练数据),通过神经网络运行训练实例,然后计算输出的损失。然后通过损失函数的导数(通过反向传播)来更新模型的参数。在训练模型的过程中,如果模型得出的结果是错误的,那么损失值就会很高。如果模型得出的结果是正确的,那么损失值就会很低。

将目标函数最小化对神经网络有什么好处哪?

它能使我们的神经网络“泛化”。泛化就是让神经网络在“训练数据集”、“测试数据集”、以及在实际使用中产生的误差都很小。

在“有监督学习”的“分类任务”中,经常会采用“负对数似然估计”作为目标函数。因为对于这些分类任务来说,最小化这个函数意味着:最小化正确输出的负对数概率(或等效地,最大化正确输出的对数概率)。

4、优化与训练

既然我们可以计算损失函数,那么我们怎么用它来优化神经网络哪?我们在前面了解到autograd.Variable是具备自动计算梯度的能力的。那么,因为我们的损失函数就是Variable之间的运算,我们可以利用这一点让它自动计算梯度!然后我们可以执行标准的梯度更新方法。设θ为我们的参数,L(θ)为损失函数,η是正的学习率参数,那么:

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

现在业界存在大量的优化算法,他们对学习率以及学习方法做了很多的探索和改进。PyTorch 为我们封装好了这些方法,所以我们只要拿来用即可,不需要在意这些算法实现的细节。在实际的深度学习项目中,经常要尝试对比不同的优化方法 以选出来一个最适合自己项目的。不过一般来说,通过使用 Adam 或者 RMSProp 算法代替 SGD 能使模型表现的更好一些。

从下面的两个动图中,可以看到多种优化算法在损失曲面和损失鞍点上的性能表现:

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

《传奇NLP攻城狮成长之路(一)》
《传奇NLP攻城狮成长之路(一)》

5、总结

在本期教程中我们主要讲了PyTorch的基本用法,另外还提到了一点深度学习的理论和数学知识。

PyTorch简单易学,它的基本用法我相信大家都是可以掌握的。

而关于深度学习的数学知识,大家能理解当然是最好的,如果现在不能理解也不用特别在意。

因为随着学习的深入,你自然就能对这些理论产生新的理解和认识。并且,随着犹如PyTorch这样强大深度学习框架的推出,你基本不需要去顾及诸如反向传播这样的细节问题。你现在只要把深度学习看做一种通用数学建模方法就可以了,而你需要做的只是设计模型的形状(网络结构),选择模型优化的方向(即损失函数)即可。

在下一期的教程中,我们将编写一个完整的神经网络,并实现“词袋模型分类器”的功能。词袋模型是NLP领域常用且重要的模型,但它非常简单,所以你可以提前了解一下。

让我们下期再见!

想学习pytorch的话可以来集智AI学园找我或者小仙女,不会让你失望 de的

    原文作者:张江
    原文地址: https://zhuanlan.zhihu.com/p/30866872
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞