第一天



1.1 深度学习与神经网络
1.1.1 深度学习的介绍
目标
- 知道什么是深度学习
- 知道深度学习和机器学习的区别
- 能够说出深度学习的主要应用场景
- 知道深度学习的常见框架
1. 深度学习的概念
深度学习(英语:deep learning)是机器学习的分支,是一种以人工神经网络为架构,对数据进行特征学习的算法。
2. 机器学习和深度学习的区别
2.1 区别1 :特征提取
从特征提取的角度出发:
- 机器学习需要有人工的特征提取的过程
- 深度学习没有复杂的人工特征提取的过程,特征提取的过程可以通过深度神经网络自动完成
2.2 区别2:数据量
从数据量的角度出发:
- 深度学习需要大量的训练数据集,会有更高的效果
- 深度学习训练深度神经网络需要大量的算力,因为其中有更多的参数
3. 深度学习的应用场景
-
图像识别
- 物体识别
- 场景识别
- 人脸检测跟踪
- 人脸身份认证
-
自然语言处理技术
- 机器翻译
- 文本识别
- 聊天对话
-
语音技术
- 语音识别
4. 常见的深度学习框架
目前企业中常见的深度学习框架有很多,TensorFlow, Caffe2, Keras, Theano, PyTorch, Chainer, DyNet, MXNet, and CNTK等等
其中tensorflow和Kears是google出品的,使用者很多,但是其语法晦涩而且和python的语法不尽相同,对于入门玩家而言上手难度较高。
所以在之后的课程中我们会使用facebook出的PyTorch,PyTorch的使用和python的语法相同,整个操作类似Numpy的操作,并且 PyTorch使用的是动态计算,会让代码的调试变的更加简单
1.1.2 神经网络的介绍
目标
- 知道神经网络的概念
- 知道什么是神经元
- 知道什么是单层神经网络
- 知道什么是感知机
- 知道什么是多层神经网络
- 知道激活函数是什么,有什么作用
- 理解神经网络的思想
1. 人工神经网络的概念
人工神经网络(英语:Artificial Neural Network,ANN),简称神经网络(Neural Network,NN)或类神经网络,是一种模仿生物神经网络(动物的中枢神经系统,特别是大脑)的结构和功能的数学模型,用于对函数进行估计或近似。
和其他机器学习方法一样,神经网络已经被用于解决各种各样的问题,例如机器视觉和语音识别。这些问题都是很难被传统基于规则的编程所解决的。
2. 神经元的概念
在生物神经网络中,每个神经元与其他神经元相连,当它“兴奋”时,就会向相连的神经元发送化学物质,从而改变这些神经元内的电位;如果某神经元的电位超过了一个“阈值”,那么它就会被激活,即“兴奋”起来,向其他神经元发送化学物质。
1943 年,McCulloch 和 Pitts 将上述情形抽象为上图所示的简单模型,这就是一直沿用至今的 M-P 神经元模型。把许多这样的神经元按一定的层次结构连接起来,就得到了神经网络。
一个简单的神经元如下图所示:
其中:
- a1,a2,…,an 为各个输入的分量
- w1,w2,⋯,wn 为各个输入分量对应的权重参数
- b 为偏置
- f 为激活函数,常见的激活函数有tanh,sigmoid,relu
- t 为神经元的输出
使用数学公式表示就是:

可见,一个神经元的功能是求得输入向量与权向量的内积后,经一个非线性传递函数得到一个标量结果。
3. 单层神经网络
是最基本的神经元网络形式,由有限个神经元构成,所有神经元的输入向量都是同一个向量。由于每一个神经元都会产生一个标量结果,所以单层神经元的输出是一个向量,向量的维数等于神经元的数目。
示意图如下:
4. 感知机
感知机由两层神经网络组成,输入层接收外界输入信号后传递给输出层(输出+1正例,-1反例),输出层是 M-P 神经元
其中从$w_0,w_1\cdots w_n$都表示权重
感知机的作用:
把一个n维向量空间用一个超平面分割成两部分,给定一个输入向量,超平面可以判断出这个向量位于超平面的哪一边,得到输入时正类或者是反类,对应到2维空间就是一条直线把一个平面分为两个部分。
5. 多层神经网络
多层神经网络就是由单层神经网络进行叠加之后得到的,所以就形成了层的概念,常见的多层神经网络有如下结构:
- 输入层(Input layer),众多神经元(Neuron)接受大量输入消息。输入的消息称为输入向量。
- 输出层(Output layer),消息在神经元链接中传输、分析、权衡,形成输出结果。输出的消息称为输出向量。
- 隐藏层(Hidden layer),简称“隐层”,是输入层和输出层之间众多神经元和链接组成的各个层面。隐层可以有一层或多层。隐层的节点(神经元)数目不定,但数目越多神经网络的非线性越显著,从而神经网络的强健性(robustness)更显著。
示意图如下:
概念:全连接层
全连接层:当前一层和前一层每个神经元相互链接,我们称当前这一层为全连接层。
思考:假设第N-1层有m个神经元,第N层有n个神经元,当第N层是全连接层的时候,则N-1和N层之间有1,这些参数可以如何表示?
从上图可以看出,所谓的全连接层就是在前一层的输出的基础上进行一次$Y=Wx+b$的变化(不考虑激活函数的情况下就是一次线性变化,所谓线性变化就是平移(+b)和缩放的组合(*w))
6. 激活函数
在前面的神经元的介绍过程中我们提到了激活函数,那么他到底是干什么的呢?
假设我们有这样一组数据,三角形和四边形,需要把他们分为两类
通过不带激活函数的感知机模型我们可以划出一条线, 把平面分割开
假设我们确定了参数w和b之后,那么带入需要预测的数据,如果y>0,我们认为这个点在直线的右边,也就是正类(三角形),否则是在左边(四边形)
但是可以看出,三角形和四边形是没有办法通过直线分开的,那么这个时候该怎么办?
可以考虑使用多层神经网络来进行尝试,比如在前面的感知机模型中再增加一层
对上图中的等式进行合并,我们可以得到: $$ y = (w_{1-11}w_{2-1}+\cdots)x_1+(w_{1-21}w_{2-1}+\cdots)x_2 + (w_{2-1}+\cdots)b_{1-1} $$ 上式括号中的都为w参数,和公式$y = w_1x_1 + w_2x_2 +b$完全相同,依然只能够绘制出直线
所以可以发现,即使是多层神经网络,相比于前面的感知机,没有任何的改进。
但是如果此时,我们在前面感知机的基础上加上非线性的激活函数之后,输出的结果就不在是一条直线
如上图,右边是sigmoid函数,对感知机的结果,通过sigmoid函数进行处理
如果给定合适的参数w和b,就可以得到合适的曲线,能够完成对最开始问题的非线性分割
所以激活函数很重要的一个作用就是增加模型的非线性分割能力
常见的激活函数有:
看图可知:
- sigmoid 只会输出正数,以及靠近0的输出变化率最大
- tanh和sigmoid不同的是,tanh输出可以是负数
- Relu是输入只能大于0,如果你输入含有负数,Relu就不适合,如果你的输入是图片格式,Relu就挺常用的,因为图片的像素值作为输入时取值为[0,255]。
激活函数的作用除了前面说的增加模型的非线性分割能力外,还有
- 提高模型鲁棒性
- 缓解梯度消失问题
- 加速模型收敛等
这些好处,大家后续会慢慢体会到,这里先知道就行
6. 神经网络示例
一个男孩想要找一个女朋友,于是实现了一个女友判定机,随着年龄的增长,他的判定机也一直在变化
14岁的时候:
无数次碰壁之后,男孩意识到追到女孩的可能性和颜值一样重要,于是修改了判定机:
在15岁的时候终于找到呢女朋友,但是一顿时间后他发现有各种难以忍受的习惯,最终决定分手。一段空窗期中,他发现找女朋友很复杂,需要更多的条件才能够帮助他找到女朋友,于是在25岁的时候,他再次修改了判定机:
在更新了女友判定机之后,问题又来了,很多指标不能够很好的量化,如何颜值,什么样的叫做颜值高,什么样的叫做性格好等等,为了解决这个问题,他又更新了判定机,最终得到超级女友判定机
上述的超级女友判定机其实就是神经网络,它能够接受基础的输入,通过隐藏层的线性的和非线性的变化最终的到输出
通过上面例子,希望大家能够理解深度学习的思想:
输出的最原始、最基本的数据,通过模型来进行特征工程,进行更加高级特征的学习,然后通过传入的数据来确定合适的参数,让模型去更好的拟合数据。
这个过程可以理解为盲人摸象,多个人一起摸,把摸到的结果乘上合适的权重,进行合适的变化,让他和目标值趋近一致。整个过程只需要输入基础的数据,程序自动寻找合适的参数。
Pytorch的安装
目标
- 知道如何安装pytorch
1. Pytorch的介绍
Pytorch是一款facebook发布的深度学习框架,由其易用性,友好性,深受广大用户青睐。
2. Pytorch的版本
3. Pytorch的安装
安装地址介绍:Start Locally | PyTorch
带GPU安装步骤:
conda install pytorch torchvision cudatoolkit=9.0 -c pytorch
不带GPU安装步骤
conda install pytorch-cpu torchvision-cpu -c pytorch
安装之后打开ipython
输入:
In [1]:import torch
In [2]: torch.__version__
Out[2]: '1.0.1'
注意:安装模块的时候安装的是pytorch ,但是在代码中都是使用torch
Pytorch的入门使用
目标
- 知道张量和Pytorch中的张量
- 知道pytorch中如何创建张量
- 知道pytorch中tensor的常见方法
- 知道pytorch中tensor的数据类型
- 知道pytorch中如何实现tensor在cpu和cuda中转化
1. 张量Tensor
张量是一个统称,其中包含很多类型:
- 0阶张量:标量、常数,0-D Tensor
- 1阶张量:向量,1-D Tensor
- 2阶张量:矩阵,2-D Tensor
- 3阶张量
- ...
- N阶张量
2. Pytorch中创建张量
-
使用python中的列表或者序列创建tensor
torch.tensor([[1., -1.], [1., -1.]]) tensor([[ 1.0000, -1.0000], [ 1.0000, -1.0000]]) -
使用numpy中的数组创建tensor
torch.tensor(np.array([[1, 2, 3], [4, 5, 6]])) tensor([[ 1, 2, 3], [ 4, 5, 6]]) -
使用torch的api创建tensor
-
torch.empty(3,4)创建3行4列的空的tensor,会用无用数据进行填充 -
torch.ones([3,4])创建3行4列的全为1的tensor -
torch.zeros([3,4])创建3行4列的全为0的tensor -
torch.rand([3,4])创建3行4列的随机值的tensor,随机值的区间是[0, 1)>>> torch.rand(2, 3) tensor([[ 0.8237, 0.5781, 0.6879], [ 0.3816, 0.7249, 0.0998]])
-
torch.randint(low=0,high=10,size=[3,4])创建3行4列的随机整数的tensor,随机值的区间是[low, high)>>> torch.randint(3, 10, (2, 2)) tensor([[4, 5], [6, 7]])
-
torch.randn([3,4])创建3行4列的随机数的tensor,随机值的分布式均值为0,方差为1
-
3. Pytorch中tensor的常用方法
-
获取tensor中的数据(当tensor中只有一个元素可用):
tensor.item()In [10]: a = torch.tensor(np.arange(1)) In [11]: a Out[11]: tensor([0]) In [12]: a.item() Out[12]: 0
-
转化为numpy数组
In [55]: z.numpy() Out[55]: array([[-2.5871205], [ 7.3690367], [-2.4918075]], dtype=float32) -
获取形状:
tensor.size()In [72]: x Out[72]: tensor([[ 1, 2], [ 3, 4], [ 5, 10]], dtype=torch.int32) In [73]: x.size() Out[73]: torch.Size([3, 2]) -
形状改变:
tensor.view((3,4))。类似numpy中的reshape,是一种浅拷贝,仅仅是形状发生改变In [76]: x.view(2,3) Out[76]: tensor([[ 1, 2, 3], [ 4, 5, 10]], dtype=torch.int32) -
获取阶数:
tensor.dim()In [77]: x.dim() Out[77]: 2
-
获取最大值:
tensor.max()In [78]: x.max() Out[78]: tensor(10, dtype=torch.int32)
-
转置:
tensor.t()In [79]: x.t() Out[79]: tensor([[ 1, 3, 5], [ 2, 4, 10]], dtype=torch.int32) -
tensor[1,3]获取tensor中第一行第三列的值 -
tensor[1,3]=100对tensor中第一行第三列的位置进行赋值100 -
tensor的切片
In [101]: x
Out[101]:
tensor([[1.6437, 1.9439, 1.5393],
[1.3491, 1.9575, 1.0552],
[1.5106, 1.0123, 1.0961],
[1.4382, 1.5939, 1.5012],
[1.5267, 1.4858, 1.4007]])
In [102]: x[:,1]
Out[102]: tensor([1.9439, 1.9575, 1.0123, 1.5939, 1.4858])
4. tensor的数据类型
tensor中的数据类型非常多,常见类型如下:
上图中的Tensor types表示这种type的tensor是其实例
-
获取tensor的数据类型:
tensor.dtypeIn [80]: x.dtype Out[80]: torch.int32
-
创建数据的时候指定类型
In [88]: torch.ones([2,3],dtype=torch.float32) Out[88]: tensor([[9.1167e+18, 0.0000e+00, 7.8796e+15], [8.3097e-43, 0.0000e+00, -0.0000e+00]]) -
类型的修改
In [17]: a Out[17]: tensor([1, 2], dtype=torch.int32) In [18]: a.type(torch.float) Out[18]: tensor([1., 2.]) In [19]: a.double() Out[19]: tensor([1., 2.], dtype=torch.float64)
5. tensor的其他操作
-
tensor和tensor相加
In [94]: x = x.new_ones(5, 3, dtype=torch.float) In [95]: y = torch.rand(5, 3) In [96]: x+y Out[96]: tensor([[1.6437, 1.9439, 1.5393], [1.3491, 1.9575, 1.0552], [1.5106, 1.0123, 1.0961], [1.4382, 1.5939, 1.5012], [1.5267, 1.4858, 1.4007]]) In [98]: torch.add(x,y) Out[98]: tensor([[1.6437, 1.9439, 1.5393], [1.3491, 1.9575, 1.0552], [1.5106, 1.0123, 1.0961], [1.4382, 1.5939, 1.5012], [1.5267, 1.4858, 1.4007]]) In [99]: x.add(y) Out[99]: tensor([[1.6437, 1.9439, 1.5393], [1.3491, 1.9575, 1.0552], [1.5106, 1.0123, 1.0961], [1.4382, 1.5939, 1.5012], [1.5267, 1.4858, 1.4007]]) In [100]: x.add_(y) #带下划线的方法会对x进行就地修改 Out[100]: tensor([[1.6437, 1.9439, 1.5393], [1.3491, 1.9575, 1.0552], [1.5106, 1.0123, 1.0961], [1.4382, 1.5939, 1.5012], [1.5267, 1.4858, 1.4007]]) In [101]: x #x发生改变 Out[101]: tensor([[1.6437, 1.9439, 1.5393], [1.3491, 1.9575, 1.0552], [1.5106, 1.0123, 1.0961], [1.4382, 1.5939, 1.5012], [1.5267, 1.4858, 1.4007]])注意:带下划线的方法(比如:
add_)会对tensor进行就地修改 -
tensor和数字操作
In [97]: x +10 Out[97]: tensor([[11., 11., 11.], [11., 11., 11.], [11., 11., 11.], [11., 11., 11.], [11., 11., 11.]]) -
CUDA中的tensor
CUDA(Compute Unified Device Architecture),是NVIDIA推出的运算平台。 CUDA™是一种由NVIDIA推出的通用并行计算架构,该架构使GPU能够解决复杂的计算问题。
torch.cuda这个模块增加了对CUDA tensor的支持,能够在cpu和gpu上使用相同的方法操作tensor通过
.to方法能够把一个tensor转移到另外一个设备(比如从CPU转到GPU)#device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") if torch.cuda.is_available(): device = torch.device("cuda") # cuda device对象 y = torch.ones_like(x, device=device) # 创建一个在cuda上的tensor x = x.to(device) # 使用方法把x转为cuda 的tensor z = x + y print(z) print(z.to("cpu", torch.double)) # .to方法也能够同时设置类型 >>tensor([1.9806], device='cuda:0') >>tensor([1.9806], dtype=torch.float64)
通过前面的学习,可以发现torch的各种操作几乎和numpy一样
梯度下降和反向传播
目标
- 知道什么是梯度下降
- 知道什么是反向传播
1. 梯度是什么?
梯度:是一个向量,导数+变化最快的方向(学习的前进方向)
回顾机器学习
收集数据$x$ ,构建机器学习模型$f$,得到$f(x,w) = Y_{predict}$
判断模型好坏的方法: $$ \begin{align*} loss & = (Y_{predict}-Y_{true})^2 &(回归损失) \ loss & = Y_{true} \cdot log(Y_{predict}) &(分类损失) \end{align*} $$
目标:通过调整(学习)参数$w$,尽可能的降低$loss$,那么我们该如何调整$w$呢?
随机选择一个起始点$w_0$,通过调整$w_0$,让loss函数取到最小值
w的更新方法:
- 计算$w$的梯度(导数)
$$ \begin{align*} \nabla w = \frac{f(w+0.000001)-f(w-0.000001)}{2*0.000001}
\end{align*} $$
- 更新$w$ $$ w = w - \alpha \nabla w $$
其中:
- ∇w<0 ,意味着w将增大
- $\nabla w >0 $ ,意味着w将减小
总结:梯度就是多元函数参数的变化趋势(参数学习的方向),只有一个自变量时称为导数
2. 偏导的计算
2.1 常见的导数计算
-
多项式求导数:$f(x) = x^5$ ,$f^{'}(x) = 5x^{(5-1)}$
-
基本运算求导:$f(x) = xy$ ,$f^{'}(x) = y$
-
指数求导:$f(x) = 5e^x$ ,$f^{'}(x) = 5e^x$
-
对数求导:$f(x) = 5lnx$ ,$f^{'}(x) = \frac{5}{x}$,ln 表示log以e为底的对数
-
导数的微分形式: $$ \begin{align*} & f^{'}(x) = & \frac{d f(x)}{dx} \ & 牛顿 &莱布尼兹 \end{align*} $$
那么:如何求$f(x) = (1+e^{-x})^{-1}$ 的导数呢?那就可以使用
f(x)=(1+e−x)−1 ==> f(a)=a−1,a(b)=(1+b),b(c)=ec,c(x)=−x
则有: $$ \begin{align*} \frac{d f(x)}{dx} & = \frac{df}{da} \times \frac{da}{db} \times \frac{db}{dc}\times \frac{dc}{dx} \ &=-a^{-2} \times 1\times e^c \times (-1) \ &= -(1+e^{-x})^{-2} \times e^{-x} \times (-1) \ &= e^{-x}(1+e^{-x})^{-2} \end{align*} $$
2.2 多元函数求偏导
一元函数,即有一个自变量。类似$f(x)$
多元函数,即有多个自变量。类似$f(x,y,z),三个自变量x,y,z$
多元函数求偏导过程中:对某一个自变量求导,其他自变量当做常量即可
例1: $$ \begin{align*} &f(x,y,z) &= &ax+by+cz \ &\frac{df(x,y,z)}{dx} &= &a \ &\frac{df(x,y,z)}{dy} &= &b \ &\frac{df(x,y,z)}{dz} &= &c \end{align*} $$
例2: $$ \begin{align*} &f(x,y) &= &xy \ &\frac{df(x,y)}{dx} &= & y\ &\frac{df(x,y)}{dy} &= &x \end{align*} $$ 例3: $$ \begin{align*} &f(x,w) &= &(y-xw)^2 \ &\frac{df(x,w)}{dx} &= & -2w(y-xw)\ &\frac{df(x,w)}{dw} &= & -2x(y-xw) \end{align*} $$ 练习:
已知$J(a,b,c) = 3(a+bc),令u=a+v,v = bc$,求a,b,c各自的偏导数。 $$ \begin{align*} 令:& J(a,b,c) = 3u\ \frac{dJ}{da} &=\frac{dJ}{du} \times \frac{du}{da} = 3\times1 \ \frac{dJ}{db} &=\frac{dJ}{du} \times \frac{du}{dv} \times \frac{dv}{db} = 3\times1\times c \ \frac{dJ}{dc} &=\frac{dJ}{du} \times \frac{du}{dv} \times \frac{dv}{dc} = 3\times1\times b \ \end{align*} $$
3. 反向传播算法
3.1 计算图和反向传播
计算图:通过图的方式来描述函数的图形
在上面的练习中,$J(a,b,c) = 3(a+bc),令u=a+v,v = bc$,把它绘制成计算图可以表示为:
绘制成为计算图之后,可以清楚的看到向前计算的过程
之后,对每个节点求偏导可有:
那么反向传播的过程就是一个上图的从右往左的过程,自变量$a,b,c$各自的偏导就是连线上的梯度的乘积: $$ \begin{align*} \frac{dJ}{da} &= 3 \times 1 \ \frac{dJ}{db} &= 3 \times 1 \times c \ \frac{dJ}{dc} &= 3 \times 1 \times b \end{align*} $$
3.2 神经网络中的反向传播
3.2.1 神经网络的示意图
w1,w2,....wn表示网络第n层权重
wn[i,j]表示第n层第i个神经元,连接到第n+1层第j个神经元的权重。
3.2.2 神经网络的计算图
其中:
- ∇out是根据损失函数对预测值进行求导得到的结果
- f函数可以理解为激活函数
**问题:**那么此时$w_1[1,2]$的偏导该如何求解呢?
通过观察,发现从$out$ 到$w_1[1,2]$的来连接线有两条
结果如下: $$ \frac{dout}{dW_1[1,2]} = x1f^{'}(a2)(W_2[2,1]*f^{'}(b1)W_3[1,1]\nabla out +W_2[2,2]*f^{'}(b2)W_3[2,1]\nabla out) $$ 公式分为两部分:
- 括号外:左边红线部分
- 括号内
- 加号左边:右边红线部分
- 加号右边:蓝线部分
但是这样做,当模型很大的时候,计算量非常大
所以反向传播的思想就是对其中的某一个参数单独求梯度,之后更新,如下图所示:
计算过程如下 $$ \begin{align*} &\nabla W_3[1,1] = f(b_1)\nabla out & (计算W_3[1,1]梯度)\ &\nabla W_3[2,1] = f(b_2)\nabla out & (计算W_3[2,1]梯度)\ \ &\nabla b_1= f^{'}(b_1)W_3[1,1]\nabla out & (计算W_3[2,1]梯度)\ &\nabla b_2= f^{'}(b_2)W_3[2,1]\nabla out & (计算W_3[2,1]梯度)\
\end{align*} $$ 更新参数之后,继续反向传播
计算过程如下: $$ \begin{align*} &\nabla W_2[1,2] = f(a_1)* \nabla b_2 \ &\nabla a_2 = f^{'}(a_2)(w_2[2,1]\nabla b_1 +W_2[2,2] \nabla b_2) \end{align} $$ 继续反向传播
计算过程如下: $$ \begin{align*} &▽W_1[1,2]= x_1*▽a_2\ &▽x_1= (W_1[1,1]*▽a_1+w_1[1,2]*▽a_2)x_1’ \end{align} $$
通用的描述如下 $$ \nabla w^{l}{i,j} = f(a^l_i)* \nabla a^{i+1}{j}\ \nabla a^{l}i = f'(a^l_i)*(\sum{j=1}^{m}w_{i,j}*\nabla a_j^{l+1}) $$
Pytorch完成线性回归
目标
- 知道
requires_grad的作用 - 知道如何使用
backward - 知道如何手动完成线性回归
1. 向前计算
对于pytorch中的一个tensor,如果设置它的属性 .requires_grad为True,那么它将会追踪对于该张量的所有操作。或者可以理解为,这个tensor是一个参数,后续会被计算梯度,更新该参数。
1.1 计算过程
假设有以下条件(1/4表示求均值,xi中有4个数),使用torch完成其向前计算的过程 $$ \begin{align*} &o = \frac{1}{4}\sum_iz_i \ &z_i = 3(x_i+2)^2\ 其中:&\ &z_i|_{x_i=1}=27\ \end{align*} $$ 如果x为参数,需要对其进行梯度的计算和更新
那么,在最开始随机设置x的值的过程中,需要设置他的requires_grad属性为True,其默认值为False
import torch x = torch.ones(2, 2, requires_grad=True) #初始化参数x并设置requires_grad=True用来追踪其计算历史 print(x) #tensor([[1., 1.], # [1., 1.]], requires_grad=True) y = x+2 print(y) #tensor([[3., 3.], # [3., 3.]], grad_fn=<AddBackward0>) z = y*y*3 #平方x3 print(x) #tensor([[27., 27.], # [27., 27.]], grad_fn=<MulBackward0>) out = z.mean() #求均值 print(out) #tensor(27., grad_fn=<MeanBackward0>)
从上述代码可以看出:
- x的requires_grad属性为True
- 之后的每次计算都会修改其
grad_fn属性,用来记录做过的操作- 通过这个函数和grad_fn能够组成一个和前一小节类似的计算图
1.2 requires_grad和grad_fn
a = torch.randn(2, 2)
a = ((a * 3) / (a - 1))
print(a.requires_grad) #False
a.requires_grad_(True) #就地修改
print(a.requires_grad) #True
b = (a * a).sum()
print(b.grad_fn) # <SumBackward0 object at 0x4e2b14345d21>
with torch.no_gard():
c = (a * a).sum() #tensor(151.6830),此时c没有gard_fn
print(c.requires_grad) #False
注意:
为了防止跟踪历史记录(和使用内存),可以将代码块包装在with torch.no_grad():中。在评估模型时特别有用,因为模型可能具有requires_grad = True的可训练的参数,但是我们不需要在此过程中对他们进行梯度计算。
2. 梯度计算
对于1.1 中的out而言,我们可以使用backward方法来进行反向传播,计算梯度
out.backward(),此时便能够求出导数$\frac{d out}{dx}$,调用x.gard能够获取导数值
得到
tensor([[4.5000, 4.5000],
[4.5000, 4.5000]])
因为: $$ \frac{d(O)}{d(x_i)} = \frac{3}{2}(x_i+2) $$ 在$x_i$等于1时其值为4.5
注意:在输出为一个标量的情况下,我们可以调用输出tensor的backword() 方法,但是在数据是一个向量的时候,调用backward()的时候还需要传入其他参数。
很多时候我们的损失函数都是一个标量,所以这里就不再介绍损失为向量的情况。
loss.backward()就是根据损失函数,对参数(requires_grad=True)的去计算他的梯度,并且把它累加保存到x.gard,此时还并未更新其梯度
注意点:
-
tensor.data:-
在tensor的require_grad=False,tensor.data和tensor等价
-
require_grad=True时,tensor.data仅仅是获取tensor中的数据
-
-
tensor.numpy():require_grad=True不能够直接转换,需要使用tensor.detach().numpy()
3. 线性回归实现
下面,我们使用一个自定义的数据,来使用torch实现一个简单的线性回归
假设我们的基础模型就是y = wx+b,其中w和b均为参数,我们使用y = 3x+0.8来构造数据x、y,所以最后通过模型应该能够得出w和b应该分别接近3和0.8
- 准备数据
- 计算预测值
- 计算损失,把参数的梯度置为0,进行反向传播
- 更新参数
import torch
import numpy as np
from matplotlib import pyplot as plt
#1. 准备数据 y = 3x+0.8,准备参数
x = torch.rand([50])
y = 3*x + 0.8
w = torch.rand(1,requires_grad=True)
b = torch.rand(1,requires_grad=True)
def loss_fn(y,y_predict):
loss = (y_predict-y).pow(2).mean()
for i in [w,b]:
#每次反向传播前把梯度置为0
if i.grad is not None:
i.grad.data.zero_()
# [i.grad.data.zero_() for i in [w,b] if i.grad is not None]
loss.backward()
return loss.data
def optimize(learning_rate):
# print(w.grad.data,w.data,b.data)
w.data -= learning_rate* w.grad.data
b.data -= learning_rate* b.grad.data
for i in range(3000):
#2. 计算预测值
y_predict = x*w + b
#3.计算损失,把参数的梯度置为0,进行反向传播
loss = loss_fn(y,y_predict)
if i%500 == 0:
print(i,loss)
#4. 更新参数w和b
optimize(0.01)
# 绘制图形,观察训练结束的预测值和真实值
predict = x*w + b #使用训练后的w和b计算预测值
plt.scatter(x.data.numpy(), y.data.numpy(),c = "r")
plt.plot(x.data.numpy(), predict.data.numpy())
plt.show()
print("w",w)
print("b",b)
图形效果如下:
打印w和b,可有
w tensor([2.9280], requires_grad=True) b tensor([0.8372], requires_grad=True)
可知,w和b已经非常接近原来的预设的3和0.8
Pytorch完成基础的模型
目标
- 知道Pytorch中Module的使用方法
- 知道Pytorch中优化器类的使用方法
- 知道Pytorch中常见的损失函数的使用方法
- 知道如何在GPU上运行代码
- 能够说出常见的优化器及其原理
1. Pytorch完成模型常用API
在前一部分,我们自己实现了通过torch的相关方法完成反向传播和参数更新,在pytorch中预设了一些更加灵活简单的对象,让我们来构造模型、定义损失,优化损失等
那么接下来,我们一起来了解一下其中常用的API
1.1 nn.Module
nn.Modul 是torch.nn提供的一个类,是pytorch中我们自定义网络的一个基类,在这个类中定义了很多有用的方法,让我们在继承这个类定义网络的时候非常简单
当我们自定义网络的时候,有两个方法需要特别注意:
__init__需要调用super方法,继承父类的属性和方法farward方法必须实现,用来定义我们的网络的向前计算的过程
用前面的y = wx+b的模型举例如下:
from torch import nn
class Lr(nn.Module):
def __init__(self):
super(Lr, self).__init__() #继承父类init的参数
self.linear = nn.Linear(1, 1)
def forward(self, x):
out = self.linear(x)
return out
注意:
nn.Linear为torch预定义好的线性模型,也被称为全链接层,传入的参数为输入的数量,输出的数量(in_features, out_features),是不算(batch_size的列数)nn.Module定义了__call__方法,实现的就是调用forward方法,即Lr的实例,能够直接被传入参数调用,实际上调用的是forward方法并传入参数
# 实例化模型 model = Lr() # 传入数据,计算结果 predict = model(x)
1.2 优化器类
优化器(optimizer),可以理解为torch为我们封装的用来进行更新参数的方法,比如常见的随机梯度下降(stochastic gradient descent,SGD)
优化器类都是由torch.optim提供的,例如
torch.optim.SGD(参数,学习率)torch.optim.Adam(参数,学习率)
注意:
- 参数可以使用
model.parameters()来获取,获取模型中所有requires_grad=True的参数 - 优化类的使用方法
- 实例化
- 所有参数的梯度,将其值置为0
- 反向传播计算梯度
- 更新参数值
示例如下:
optimizer = optim.SGD(model.parameters(), lr=1e-3) #1. 实例化 optimizer.zero_grad() #2. 梯度置为0 loss.backward() #3. 计算梯度 optimizer.step() #4. 更新参数的值
1.3 损失函数
前面的例子是一个回归问题,torch中也预测了很多损失函数
- 均方误差:
nn.MSELoss(),常用于回归问题 - 交叉熵损失:
nn.CrossEntropyLoss(),常用于分类问题
使用方法:
model = Lr() #1. 实例化模型
criterion = nn.MSELoss() #2. 实例化损失函数
optimizer = optim.SGD(model.parameters(), lr=1e-3) #3. 实例化优化器类
for i in range(100):
y_predict = model(x_true) #4. 向前计算预测值
loss = criterion(y_true,y_predict) #5. 调用损失函数传入真实值和预测值,得到损失结果
optimizer.zero_grad() #5. 当前循环参数梯度置为0
loss.backward() #6. 计算梯度
optimizer.step() #7. 更新参数的值
1.4 把线性回归完整代码
import torch
from torch import nn
from torch import optim
import numpy as np
from matplotlib import pyplot as plt
# 1. 定义数据
x = torch.rand([50,1])
y = x*3 + 0.8
#2 .定义模型
class Lr(nn.Module):
def __init__(self):
super(Lr,self).__init__()
self.linear = nn.Linear(1,1)
def forward(self, x):
out = self.linear(x)
return out
# 2. 实例化模型,loss,和优化器
model = Lr()
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=1e-3)
#3. 训练模型
for i in range(30000):
out = model(x) #3.1 获取预测值
loss = criterion(y,out) #3.2 计算损失
optimizer.zero_grad() #3.3 梯度归零
loss.backward() #3.4 计算梯度
optimizer.step() # 3.5 更新梯度
if (i+1) % 20 == 0:
print('Epoch[{}/{}], loss: {:.6f}'.format(i,30000,loss.data))
#4. 模型评估
model.eval() #设置模型为评估模式,即预测模式
predict = model(x)
predict = predict.data.numpy()
plt.scatter(x.data.numpy(),y.data.numpy(),c="r")
plt.plot(x.data.numpy(),predict)
plt.show()
输出如下:
注意:
model.eval()表示设置模型为评估模式,即预测模式
model.train(mode=True) 表示设置模型为训练模式
在当前的线性回归中,上述并无区别
但是在其他的一些模型中,训练的参数和预测的参数会不相同,到时候就需要具体告诉程序我们是在进行训练还是预测,比如模型中存在Dropout,BatchNorm的时候
2. 在GPU上运行代码
当模型太大,或者参数太多的情况下,为了加快训练速度,经常会使用GPU来进行训练
此时我们的代码需要稍作调整:
-
判断GPU是否可用
torch.cuda.is_available()torch.device("cuda:0" if torch.cuda.is_available() else "cpu") >>device(type='cuda', index=0) #使用gpu >>device(type='cpu') #使用cpu -
把模型参数和input数据转化为cuda的支持类型
model.to(device) x_true.to(device)
-
在GPU上计算结果也为cuda的数据类型,需要转化为numpy或者torch的cpu的tensor类型
predict = predict.cpu().detach().numpy()
detach()的效果和data的相似,但是detach()是深拷贝,data是取值,是浅拷贝
修改之后的代码如下:
import torch
from torch import nn
from torch import optim
import numpy as np
from matplotlib import pyplot as plt
import time
# 1. 定义数据
x = torch.rand([50,1])
y = x*3 + 0.8
#2 .定义模型
class Lr(nn.Module):
def __init__(self):
super(Lr,self).__init__()
self.linear = nn.Linear(1,1)
def forward(self, x):
out = self.linear(x)
return out
# 2. 实例化模型,loss,和优化器
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
x,y = x.to(device),y.to(device)
model = Lr().to(device)
criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=1e-3)
#3. 训练模型
for i in range(300):
out = model(x)
loss = criterion(y,out)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (i+1) % 20 == 0:
print('Epoch[{}/{}], loss: {:.6f}'.format(i,30000,loss.data))
#4. 模型评估
model.eval() #
predict = model(x)
predict = predict.cpu().detach().numpy() #转化为numpy数组
plt.scatter(x.cpu().data.numpy(),y.cpu().data.numpy(),c="r")
plt.plot(x.cpu().data.numpy(),predict,)
plt.show()
3. 常见的优化算法介绍
3.1 梯度下降算法(batch gradient descent BGD)
每次迭代都需要把所有样本都送入,这样的好处是每次迭代都顾及了全部的样本,做的是全局最优化,但是有可能达到局部最优。
3.2 随机梯度下降法 (Stochastic gradient descent SGD)
针对梯度下降算法训练速度过慢的缺点,提出了随机梯度下降算法,随机梯度下降算法算法是从样本中随机抽出一组,训练后按梯度更新一次,然后再抽取一组,再更新一次,在样本量及其大的情况下,可能不用训练完所有的样本就可以获得一个损失值在可接受范围之内的模型了。
torch中的api为:torch.optim.SGD()
3.3 小批量梯度下降 (Mini-batch gradient descent MBGD)
SGD相对来说要快很多,但是也有存在问题,由于单个样本的训练可能会带来很多噪声,使得SGD并不是每次迭代都向着整体最优化方向,因此在刚开始训练时可能收敛得很快,但是训练一段时间后就会变得很慢。在此基础上又提出了小批量梯度下降法,它是每次从样本中随机抽取一小批进行训练,而不是一组,这样即保证了效果又保证的速度。
3.4 动量法
mini-batch SGD算法虽然这种算法能够带来很好的训练速度,但是在到达最优点的时候并不能够总是真正到达最优点,而是在最优点附近徘徊。
另一个缺点就是mini-batch SGD需要我们挑选一个合适的学习率,当我们采用小的学习率的时候,会导致网络在训练的时候收敛太慢;当我们采用大的学习率的时候,会导致在训练过程中优化的幅度跳过函数的范围,也就是可能跳过最优点。我们所希望的仅仅是网络在优化的时候网络的损失函数有一个很好的收敛速度同时又不至于摆动幅度太大。
所以Momentum优化器刚好可以解决我们所面临的问题,它主要是基于梯度的移动指数加权平均,对网络的梯度进行平滑处理的,让梯度的摆动幅度变得更小。 $$ \begin{align*} &gradent = 0.8\nabla w + 0.2 history_gradent &,\nabla w 表示当前一次的梯度\ &w = w - \alpha* gradent &,\alpha表示学习率 \end{align*} $$
(注:t+1的的histroy_gradent 为第t次的gradent)
3.5 AdaGrad
AdaGrad算法就是将每一个参数的每一次迭代的梯度取平方累加后在开方,用全局学习率除以这个数,作为学习率的动态更新,从而达到自适应学习率的效果 $$ \begin{align*} &gradent = history_gradent + (\nabla w)^2 \ &w = w - \frac{\alpha}{\sqrt{gradent}+\delta} \nabla w ,&\delta为小常数,为了数值稳定大约设置为10^{-7} \end{align*} $$
3.6 RMSProp
Momentum优化算法中,虽然初步解决了优化中摆动幅度大的问题,为了进一步优化损失函数在更新中存在摆动幅度过大的问题,并且进一步加快函数的收敛速度,RMSProp算法对参数的梯度使用了平方加权平均数。 $$ \begin{align*} & gradent = 0.8history_gradent + 0.2(\nabla w)^2 \ & w = w - \frac{\alpha}{\sqrt{gradent}+\delta} \nabla w \end{align*} $$
3.7 Adam
Adam(Adaptive Moment Estimation)算法是将Momentum算法和RMSProp算法结合起来使用的一种算法,能够达到防止梯度的摆幅多大,同时还能够加开收敛速度 $$ \begin{align*} & 1. 需要初始化梯度的累积量和平方累积量 \ & v_w = 0,s_w = 0 \ & 2. 第 t 轮训练中,我们首先可以计算得到Momentum和RMSProp的参数更新:\ & v_w = 0.8v + 0.2 \nabla w \qquad,Momentum计算的梯度\ & s_w = 0.8s + 0.2(\nabla w)^2 \qquad,RMSProp计算的梯度\ & 3. 对其中的值进行处理后,得到:\ & w = w - \frac{\alpha}{\sqrt{s_w}+\delta} v_w \end{align*} $$ torch中的api为:torch.optim.Adam()
3.8 效果演示:
Pytorch中的数据加载
目标
- 知道数据加载的目的
- 知道pytorch中Dataset的使用方法
- 知道pytorch中DataLoader的使用方法
- 知道pytorch中的自带数据集如何获取
1. 模型中使用数据加载器的目的
在前面的线性回归模型中,我们使用的数据很少,所以直接把全部数据放到模型中去使用。
但是在深度学习中,数据量通常是都非常多,非常大的,如此大量的数据,不可能一次性的在模型中进行向前的计算和反向传播,经常我们会对整个数据进行随机的打乱顺序,把数据处理成一个个的batch,同时还会对数据进行预处理。
所以,接下来我们来学习pytorch中的数据加载的方法
2. 数据集类
2.1 Dataset基类介绍
在torch中提供了数据集的基类torch.utils.data.Dataset,继承这个基类,我们能够非常快速的实现对数据的加载。
torch.utils.data.Dataset的源码如下:
class Dataset(object):
"""An abstract class representing a Dataset.
All other datasets should subclass it. All subclasses should override
``__len__``, that provides the size of the dataset, and ``__getitem__``,
supporting integer indexing in range from 0 to len(self) exclusive.
"""
def __getitem__(self, index):
raise NotImplementedError
def __len__(self):
raise NotImplementedError
def __add__(self, other):
return ConcatDataset([self, other])
可知:我们需要在自定义的数据集类中继承Dataset类,同时还需要实现两个方法:
__len__方法,能够实现通过全局的len()方法获取其中的元素个数__getitem__方法,能够通过传入索引的方式获取数据,例如通过dataset[i]获取其中的第i条数据
2.2 数据加载案例
下面通过一个例子来看看如何使用Dataset来加载数据
数据来源:http://archive.ics.uci.edu/ml/datasets/SMS+Spam+Collection
数据介绍:SMS Spam Collection是用于骚扰短信识别的经典数据集,完全来自真实短信内容,包括4831条正常短信和747条骚扰短信。正常短信和骚扰短信保存在一个文本文件中。 每行完整记录一条短信内容,每行开头通过ham和spam标识正常短信和骚扰短信
数据实例:
实现如下:
from torch.utils.data import Dataset,DataLoader
import pandas as pd
data_path = r"data\SMSSpamCollection"
class CifarDataset(Dataset):
def __init__(self):
lines = open(data_path,"r")
#对数据进行处理,前4个为label,后面的为短信内容
lines = [[i[:4].strip(),i[4:].strip()] for i in lines]
#转化为dataFrame
self.df = pd.DataFrame(lines,columns=["label","sms"])
def __getitem__(self, index):
single_item = self.df.iloc[index,:]
return single_item.values[0],single_item.values[1]
def __len__(self):
return self.df.shape[0]
之后对Dataset进行实例化,可以跌倒获取其中的数据
d = CifarDataset()
for i in range(len(d)):
print(i,d[i])
输出如下:
....
5571 ('ham', 'Pity, * was in mood for that. So...any other suggestions?')
5572 ('ham', "The guy did some bitching but I acted like i'd be interested in buying something else next week and he gave it to us for free")
5573 ('ham', 'Rofl. Its true to its name')
3. 迭代数据集
使用上述的方法能够进行数据的读取,但是其中还有很多内容没有实现:
- 批处理数据(Batching the data)
- 打乱数据(Shuffling the data)
- 使用多线程
multiprocessing并行加载数据。
在pytorch中torch.utils.data.DataLoader提供了上述的所用方法
DataLoader的使用方法示例:
from torch.utils.data import DataLoader
dataset = CifarDataset()
data_loader = DataLoader(dataset=dataset,batch_size=10,shuffle=True,num_workers=2)
#遍历,获取其中的每个batch的结果
for index, (label, context) in enumerate(data_loader):
print(index,label,context)
print("*"*100)
其中参数含义:
- dataset:提前定义的dataset的实例
- batch_size:传入数据的batch的大小,常用128,256等等
- shuffle:bool类型,表示是否在每次获取数据的时候提前打乱数据
num_workers:加载数据的线程数
数据迭代器的返回结果如下:
555 ('spam', 'ham', 'spam', 'ham', 'ham', 'ham', 'ham', 'spam', 'ham', 'ham') ('URGENT! We are trying to contact U. Todays draw shows that you have won a £800 prize GUARANTEED. Call 09050003091 from....", 'swhrt how u dey,hope ur ok, tot about u 2day.love n miss.take care.')
***********************************************************************************
556 ('ham', 'ham', 'ham', 'ham', 'ham', 'ham', 'ham', 'ham', 'ham', 'spam') ('He telling not to tell any one. If so treat for me hi hi hi', 'Did u got that persons story', "Don kn....1000 cash prize or a prize worth £5000')
注意:
len(dataset) = 数据集的样本数len(dataloader) = math.ceil(样本数/batch_size) 即向上取整
4 pytorch自带的数据集
pytorch中自带的数据集由两个上层api提供,分别是torchvision和torchtext
其中:
torchvision提供了对图片数据处理相关的api和数据- 数据位置:
torchvision.datasets,例如:torchvision.datasets.MNIST(手写数字图片数据)
- 数据位置:
torchtext提供了对文本数据处理相关的API和数据- 数据位置:
torchtext.datasets,例如:torchtext.datasets.IMDB(电影评论文本数据)
- 数据位置:
下面我们以Mnist手写数字为例,来看看pytorch如何加载其中自带的数据集
使用方法和之前一样:
- 准备好Dataset实例
- 把dataset交给dataloder 打乱顺序,组成batch
4.1 torchversion.datasets
torchversoin.datasets中的数据集类(比如torchvision.datasets.MNIST),都是继承自Dataset
意味着:直接对torchvision.datasets.MNIST进行实例化就可以得到Dataset的实例
但是MNIST API中的参数需要注意一下:
torchvision.datasets.MNIST(root='/files/', train=True, download=True, transform=)
root参数表示数据存放的位置train:bool类型,表示是使用训练集的数据还是测试集的数据download:bool类型,表示是否需要下载数据到root目录transform:实现的对图片的处理函数
4.2 MNIST数据集的介绍
数据集的原始地址:http://yann.lecun.com/exdb/mnist/
MNIST是由Yann LeCun等人提供的免费的图像识别的数据集,其中包括60000个训练样本和10000个测试样本,其中图拍了的尺寸已经进行的标准化的处理,都是黑白的图像,大小为28X28
执行代码,下载数据,观察数据类型:
import torchvision dataset = torchvision.datasets.MNIST(root="./data",train=True,download=True,transform=None) print(dataset[0])
下载的数据如下:
代码输出结果如下:
Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz Downloading http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz Downloading http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz Processing... Done! (<PIL.Image.Image image mode=L size=28x28 at 0x18D303B9C18>, tensor(5))
可以其中数据集返回了两条数据,可以猜测为图片的数据和目标值
返回值的第0个为Image类型,可以调用show() 方法打开,发现为手写数字5
import torchvision dataset = torchvision.datasets.MNIST(root="./data",train=True,download=True,transform=None) print(dataset[0]) img = dataset[0][0] img.show() #打开图片
图片如下:
由上可知:返回值为(图片,目标值),这个结果也可以通过观察源码得到
使用Pytorch实现手写数字识别
目标
- 知道如何使用Pytorch完成神经网络的构建
- 知道Pytorch中激活函数的使用方法
- 知道Pytorch中
torchvision.transforms中常见图形处理函数的使用 - 知道如何训练模型和如何评估模型
1. 思路和流程分析
流程:
- 准备数据,这些需要准备DataLoader
- 构建模型,这里可以使用torch构造一个深层的神经网络
- 模型的训练
- 模型的保存,保存模型,后续持续使用
- 模型的评估,使用测试集,观察模型的好坏
2. 准备训练集和测试集
准备数据集的方法前面已经讲过,但是通过前面的内容可知,调用MNIST返回的结果中图形数据是一个Image对象,需要对其进行处理
为了进行数据的处理,接下来学习torchvision.transfroms的方法
2.1 torchvision.transforms的图形数据处理方法
2.1.1 torchvision.transforms.ToTensor
把一个取值范围是[0,255]的PIL.Image或者shape为(H,W,C)的numpy.ndarray,转换成形状为[C,H,W]
其中(H,W,C)意思为(高,宽,通道数),黑白图片的通道数只有1,其中每个像素点的取值为[0,255],彩色图片的通道数为(R,G,B),每个通道的每个像素点的取值为[0,255],三个通道的颜色相互叠加,形成了各种颜色
示例如下:
from torchvision import transforms import numpy as np data = np.random.randint(0, 255, size=12) img = data.reshape(2,2,3) print(img.shape) img_tensor = transforms.ToTensor()(img) # 转换成tensor print(img_tensor) print(img_tensor.shape)
输出如下:
shape:(2, 2, 3)
img_tensor:tensor([[[215, 171],
[ 34, 12]],
[[229, 87],
[ 15, 237]],
[[ 10, 55],
[ 72, 204]]], dtype=torch.int32)
new shape:torch.Size([3, 2, 2])
注意:
transforms.ToTensor对象中有__call__方法,所以可以对其示例能够传入数据获取结果
2.1.2 torchvision.transforms.Normalize(mean, std)
给定均值:mean,shape和图片的通道数相同(指的是每个通道的均值),方差:std,和图片的通道数相同(指的是每个通道的方差),将会把Tensor规范化处理。
即:Normalized_image=(image-mean)/std。
例如:
from torchvision import transforms
import numpy as np
import torchvision
data = np.random.randint(0, 255, size=12)
img = data.reshape(2,2,3)
img = transforms.ToTensor()(img) # 转换成tensor
print(img)
print("*"*100)
norm_img = transforms.Normalize((10,10,10), (1,1,1))(img) #进行规范化处理
print(norm_img)
输出如下:
tensor([[[177, 223],
[ 71, 182]],
[[153, 120],
[173, 33]],
[[162, 233],
[194, 73]]], dtype=torch.int32)
***************************************************************************************
tensor([[[167, 213],
[ 61, 172]],
[[143, 110],
[163, 23]],
[[152, 223],
[184, 63]]], dtype=torch.int32)
注意:在sklearn中,默认上式中的std和mean为数据每列的std和mean,sklearn会在标准化之前算出每一列的std和mean。
但是在api:Normalize中并没有帮我们计算,所以我们需要手动计算
-
当mean为全部数据的均值,std为全部数据的std的时候,才是进行了标准化。
-
如果mean(x)不是全部数据的mean的时候,std(y)也不是的时候,Normalize后的数据分布满足下面的关系 $$ \begin{align*} &new_mean = \frac{mean-x}{y}&, mean为原数据的均值,x为传入的均值x \ &new_std = \frac{std}{y} &,y为传入的标准差y\ \end{align*} $$
2.1.3 torchvision.transforms.Compose(transforms)
将多个transform组合起来使用。
例如
transforms.Compose([
torchvision.transforms.ToTensor(), #先转化为Tensor
torchvision.transforms.Normalize(mean,std) #在进行正则化
])
2.2 准备MNIST数据集的Dataset和DataLoader
准备训练集
import torchvision
#准备数据集,其中0.1307,0.3081为MNIST数据的均值和标准差,这样操作能够对其进行标准化
#因为MNIST只有一个通道(黑白图片),所以元组中只有一个值
dataset = torchvision.datasets.MNIST('/data', train=True, download=True,
transform=torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(
(0.1307,), (0.3081,))
]))
#准备数据迭代器
train_dataloader = torch.utils.data.DataLoader(dataset,batch_size=64,shuffle=True)
准备测试集
import torchvision
#准备数据集,其中0.1307,0.3081为MNIST数据的均值和标准差,这样操作能够对其进行标准化
#因为MNIST只有一个通道(黑白图片),所以元组中只有一个值
dataset = torchvision.datasets.MNIST('/data', train=False, download=True,
transform=torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(
(0.1307,), (0.3081,))
]))
#准备数据迭代器
train_dataloader = torch.utils.data.DataLoader(dataset,batch_size=64,shuffle=True)
3. 构建模型
补充:全连接层:当前一层的神经元和前一层的神经元相互链接,其核心操作就是$y = wx$,即矩阵的乘法,实现对前一层的数据的变换
模型的构建使用了一个三层的神经网络,其中包括两个全连接层和一个输出层,第一个全连接层会经过激活函数的处理,将处理后的结果交给下一个全连接层,进行变换后输出结果
那么在这个模型中有两个地方需要注意:
- 激活函数如何使用
- 每一层数据的形状
- 模型的损失函数
3.1 激活函数的使用
前面介绍了激活函数的作用,常用的激活函数为Relu激活函数,他的使用非常简单
Relu激活函数由import torch.nn.functional as F提供,F.relu(x)即可对x进行处理
例如:
In [30]: b Out[30]: tensor([-2, -1, 0, 1, 2]) In [31]: import torch.nn.functional as F In [32]: F.relu(b) Out[32]: tensor([0, 0, 0, 1, 2])
3.2 模型中数据的形状(【添加形状变化图形】)
- 原始输入数据为的形状:
[batch_size,1,28,28] - 进行形状的修改:
[batch_size,28*28],(全连接层是在进行矩阵的乘法操作) - 第一个全连接层的输出形状:
[batch_size,28],这里的28是个人设定的,你也可以设置为别的 - 激活函数不会修改数据的形状
- 第二个全连接层的输出形状:
[batch_size,10],因为手写数字有10个类别
构建模型的代码如下:
import torch
from torch import nn
import torch.nn.functional as F
class MnistNet(nn.Module):
def __init__(self):
super(MnistNet,self).__init__()
self.fc1 = nn.Linear(28*28*1,28) #定义Linear的输入和输出的形状
self.fc2 = nn.Linear(28,10) #定义Linear的输入和输出的形状
def forward(self,x):
x = x.view(-1,28*28*1) #对数据形状变形,-1表示该位置根据后面的形状自动调整
x = self.fc1(x) #[batch_size,28]
x = F.relu(x) #[batch_size,28]
x = self.fc2(x) #[batch_size,10]
可以发现:pytorch在构建模型的时候形状上并不会考虑batch_size
3.3 模型的损失函数
首先,我们需要明确,当前我们手写字体识别的问题是一个多分类的问题,所谓多分类对比的是之前学习的2分类
回顾之前的课程,我们在逻辑回归中,我们使用sigmoid进行计算对数似然损失,来定义我们的2分类的损失。
-
在2分类中我们有正类和负类,正类的概率为$P(x) = \frac{1}{1+e^{-x}} = \frac{e^x}{1+e^x}$,那么负类的概率为$1-P(x)$
-
将这个结果进行计算对数似然损失$-\sum y log(P(x))$就可以得到最终的损失
那么在多分类的过程中我们应该怎么做呢?
-
多分类和2分类中唯一的区别是我们不能够再使用sigmoid函数来计算当前样本属于某个类别的概率,而应该使用softmax函数。
-
softmax和sigmoid的区别在于我们需要去计算样本属于每个类别的概率,需要计算多次,而sigmoid只需要计算一次
softmax的公式如下: $$ \sigma(z)j = \frac{e^{z_j}}{\sum^K{k=1}e^{z_K}} ,j=1 \cdots k $$
例如下图:
假如softmax之前的输出结果是2.3, 4.1, 5.6,那么经过softmax之后的结果是多少呢? $$ Y1 = \frac{e^{2.3}}{e^{2.3}+e^{4.1}+e^{5.6}} \ Y2 = \frac{e^{4.1}}{e^{2.3}+e^{4.1}+e^{5.6}} \ Y3 = \frac{e^{5.6}}{e^{2.3}+e^{4.1}+e^{5.6}} \ $$
对于这个softmax输出的结果,是在[0,1]区间,我们可以把它当做概率
和前面2分类的损失一样,多分类的损失只需要再把这个结果进行对数似然损失的计算即可
即: $$ \begin{align*} & J = -\sum Y log(P) &, 其中 P = \frac{e^{z_j}}{\sum^K_{k=1}e^{z_K}} ,Y表示真实值 \end{align*} $$ 最后,会计算每个样本的损失,即上式的平均值
我们把softmax概率传入对数似然损失得到的损失函数称为交叉熵损失
在pytorch中有两种方法实现交叉熵损失
-
criterion = nn.CrossEntropyLoss() loss = criterion(input,target) -
#1. 对输出值计算softmax和取对数 output = F.log_softmax(x,dim=-1) #2. 使用torch中带权损失 loss = F.nll_loss(output,target)
带权损失定义为:$l_n = -\sum w_{i} x_{i}$,其实就是把$log(P)$作为$x_i$,把真实值Y作为权重
4. 模型的训练
训练的流程:
- 实例化模型,设置模型为训练模式
- 实例化优化器类,实例化损失函数
- 获取,遍历dataloader
- 梯度置为0
- 进行向前计算
- 计算损失
- 反向传播
- 更新参数
mnist_net = MnistNet()
optimizer = optim.Adam(mnist_net.parameters(),lr= 0.001)
def train(epoch):
mode = True
mnist_net.train(mode=mode) #模型设置为训练模型
train_dataloader = get_dataloader(train=mode) #获取训练数据集
for idx,(data,target) in enumerate(train_dataloader):
optimizer.zero_grad() #梯度置为0
output = mnist_net(data) #进行向前计算
loss = F.nll_loss(output,target) #带权损失
loss.backward() #进行反向传播,计算梯度
optimizer.step() #参数更新
if idx % 10 == 0:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, idx * len(data), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item()))
5. 模型的保存和加载
5.1 模型的保存
torch.save(mnist_net.state_dict(),"model/mnist_net.pt") #保存模型参数 torch.save(optimizer.state_dict(), 'results/mnist_optimizer.pt') #保存优化器参数
5.2 模型的加载
mnist_net.load_state_dict(torch.load("model/mnist_net.pt"))
optimizer.load_state_dict(torch.load("results/mnist_optimizer.pt"))
6. 模型的评估
评估的过程和训练的过程相似,但是:
- 不需要计算梯度
- 需要收集损失和准确率,用来计算平均损失和平均准确率
- 损失的计算和训练时候损失的计算方法相同
- 准确率的计算:
- 模型的输出为[batch_size,10]的形状
- 其中最大值的位置就是其预测的目标值(预测值进行过sotfmax后为概率,sotfmax中分母都是相同的,分子越大,概率越大)
- 最大值的位置获取的方法可以使用
torch.max,返回最大值和最大值的位置 - 返回最大值的位置后,和真实值(
[batch_size])进行对比,相同表示预测成功
def test():
test_loss = 0
correct = 0
mnist_net.eval() #设置模型为评估模式
test_dataloader = get_dataloader(train=False) #获取评估数据集
with torch.no_grad(): #不计算其梯度
for data, target in test_dataloader:
output = mnist_net(data)
test_loss += F.nll_loss(output, target, reduction='sum').item()
pred = output.data.max(1, keepdim=True)[1] #获取最大值的位置,[batch_size,1]
correct += pred.eq(target.data.view_as(pred)).sum() #预测准备样本数累加
test_loss /= len(test_dataloader.dataset) #计算平均损失
print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format(
test_loss, correct, len(test_dataloader.dataset),
100. * correct / len(test_dataloader.dataset)))
7. 完整的代码如下:
import torch
from torch import nn
from torch import optim
import torch.nn.functional as F
import torchvision
train_batch_size = 64
test_batch_size = 1000
img_size = 28
def get_dataloader(train=True):
assert isinstance(train,bool),"train 必须是bool类型"
#准备数据集,其中0.1307,0.3081为MNIST数据的均值和标准差,这样操作能够对其进行标准化
#因为MNIST只有一个通道(黑白图片),所以元组中只有一个值
dataset = torchvision.datasets.MNIST('/data', train=train, download=True,
transform=torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize((0.1307,), (0.3081,)),]))
#准备数据迭代器
batch_size = train_batch_size if train else test_batch_size
dataloader = torch.utils.data.DataLoader(dataset,batch_size=batch_size,shuffle=True)
return dataloader
class MnistNet(nn.Module):
def __init__(self):
super(MnistNet,self).__init__()
self.fc1 = nn.Linear(28*28*1,28)
self.fc2 = nn.Linear(28,10)
def forward(self,x):
x = x.view(-1,28*28*1)
x = self.fc1(x) #[batch_size,28]
x = F.relu(x) #[batch_size,28]
x = self.fc2(x) #[batch_size,10]
# return x
return F.log_softmax(x,dim=-1)
mnist_net = MnistNet()
optimizer = optim.Adam(mnist_net.parameters(),lr= 0.001)
# criterion = nn.NLLLoss()
# criterion = nn.CrossEntropyLoss()
train_loss_list = []
train_count_list = []
def train(epoch):
mode = True
mnist_net.train(mode=mode)
train_dataloader = get_dataloader(train=mode)
print(len(train_dataloader.dataset))
print(len(train_dataloader))
for idx,(data,target) in enumerate(train_dataloader):
optimizer.zero_grad()
output = mnist_net(data)
loss = F.nll_loss(output,target) #对数似然损失
loss.backward()
optimizer.step()
if idx % 10 == 0:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, idx * len(data), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item()))
train_loss_list.append(loss.item())
train_count_list.append(idx*train_batch_size+(epoch-1)*len(train_dataloader))
torch.save(mnist_net.state_dict(),"model/mnist_net.pkl")
torch.save(optimizer.state_dict(), 'results/mnist_optimizer.pkl')
def test():
test_loss = 0
correct = 0
mnist_net.eval()
test_dataloader = get_dataloader(train=False)
with torch.no_grad():
for data, target in test_dataloader:
output = mnist_net(data)
test_loss += F.nll_loss(output, target, reduction='sum').item()
pred = output.data.max(1, keepdim=True)[1] #获取最大值的位置,[batch_size,1]
correct += pred.eq(target.data.view_as(pred)).sum()
test_loss /= len(test_dataloader.dataset)
print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format(
test_loss, correct, len(test_dataloader.dataset),
100. * correct / len(test_dataloader.dataset)))
if __name__ == '__main__':
test()
for i in range(5): #模型训练5轮
train(i)
test()
循环神经网络和自然语言处理介绍
目标
- 知道
token和tokenization - 知道
N-gram的概念和作用 - 知道文本向量化表示的方法
1. 文本的tokenization
1.1 概念和工具的介绍
tokenization就是通常所说的分词,分出的每一个词语我们把它称为token。
常见的分词工具很多,比如:
jieba分词:https://github.com/fxsjy/jieba- 清华大学的分词工具THULAC:
https://github.com/thunlp/THULAC-Python
1.2 中英文分词的方法
- 把句子转化为词语
- 比如:
我爱深度学习可以分为[我,爱, 深度学习]
- 比如:
- 把句子转化为单个字
- 比如:
我爱深度学习的token是[我,爱,深,度,学,习]
- 比如:
2. N-garm表示方法
前面我们说,句子可以用但个字,词来表示,但是有的时候,我们可以用2个、3个或者多个词来表示。
N-gram一组一组的词语,其中的N表示能够被一起使用的词的数量
例如:
In [59]: text = "深度学习(英语:deep learning)是机器学习的分支,是一种以人工神经网络为架构,对数据进行表征学习的算法。" In [60]: cuted = jieba.lcut(text) In [61]: [cuted[i:i+2] for i in range(len(cuted)-1)] #N-gram 中n=2时 Out[61]:[['深度', '学习'], ['学习', '('], ['(', '英语'], ['英语', ':'], [':', 'deep'], ['deep', ' '], [' ', 'learning'], ['learning', ')'], [')', '是'], ['是', '机器'], ['机器', '学习'], ['学习', '的'], ['的', '分支'], ['分支', ','], [',', '是'], ['是', '一种'], ['一种', '以'], ['以', '人工神经网络'], ['人工神经网络', '为'], ['为', '架构'], ['架构', ','], [',', '对'], ['对', '数据'], ['数据', '进行'], ['进行', '表征'], ['表征', '学习'], ['学习', '的'], ['的', '算法'], ['算法', '。']]
在传统的机器学习中,使用N-gram方法往往能够取得非常好的效果,但是在深度学习比如RNN中会自带N-gram的效果。
3. 向量化
因为文本不能够直接被模型计算,所以需要将其转化为向量
把文本转化为向量有两种方法:
- 转化为one-hot编码
- 转化为word embedding
3.1 one-hot 编码
在one-hot编码中,每一个token使用一个长度为N的向量表示,N表示词典的数量
即:把待处理的文档进行分词或者是N-gram处理,然后进行去重得到词典,假设我们有一个文档:深度学习,那么进行one-hot处理后的结果如下:
| token | one-hot encoding |
|---|---|
| 深 | 1000 |
| 度 | 0100 |
| 学 | 0010 |
| 习 | 0001 |
3.2 word embedding
word embedding是深度学习中表示文本常用的一种方法。和one-hot编码不同,word embedding使用了浮点型的稠密矩阵来表示token。根据词典的大小,我们的向量通常使用不同的维度,例如100,256,300等。其中向量中的每一个值是一个参数,其初始值是随机生成的,之后会在训练的过程中进行学习而获得。
如果我们文本中有20000个词语,如果使用one-hot编码,那么我们会有20000*20000的矩阵,其中大多数的位置都为0,但是如果我们使用word embedding来表示的话,只需要20000* 维度,比如20000*300
形象的表示就是:
| token | num | vector |
|---|---|---|
| 词1 | 0 | [w11,w12,w13...w1N] ,其中N表示维度(dimension) |
| 词2 | 1 | [w21,w22,w23...w2N] |
| 词3 | 2 | [w31,w23,w33...w3N] |
| ... | …. | ... |
| 词m | m | [wm1,wm2,wm3...wmN],其中m表示词典的大小 |
我们会把所有的文本转化为向量,把句子用向量来表示
但是在这中间,我们会先把token使用数字来表示,再把数字使用向量来表示。
即:token---> num ---->vector
3.3 word embedding API
torch.nn.Embedding(num_embeddings,embedding_dim)
参数介绍:
num_embeddings:词典的大小embedding_dim:embedding的维度
使用方法:
embedding = nn.Embedding(vocab_size,300) #实例化 input_embeded = embedding(input) #进行embedding的操作
3.4 数据的形状变化
思考:每个batch中的每个句子有10个词语,经过形状为[20,4]的Word emebedding之后,原来的句子会变成什么形状?
每个词语用长度为4的向量表示,所以,最终句子会变为[batch_size,10,4]的形状。
增加了一个维度,这个维度是embedding的dim
文本情感分类
目标
- 知道文本处理的基本方法
- 能够使用数据实现情感分类的
1. 案例介绍
为了对前面的word embedding这种常用的文本向量化的方法进行巩固,这里我们会完成一个文本情感分类的案例
现在我们有一个经典的数据集IMDB数据集,地址:http://ai.stanford.edu/~amaas/data/sentiment/,这是一份包含了5万条流行电影的评论数据,其中训练集25000条,测试集25000条。数据格式如下:
下图左边为名称,其中名称包含两部分,分别是序号和情感评分,(1-4为neg,5-10为pos),右边为评论内容
根据上述的样本,需要使用pytorch完成模型,实现对评论情感进行预测
2. 思路分析
首先可以把上述问题定义为分类问题,情感评分分为1-10,10个类别(也可以理解为回归问题,这里当做分类问题考虑)。那么根据之前的经验,我们的大致流程如下:
- 准备数据集
- 构建模型
- 模型训练
- 模型评估
知道思路之后,那么我们一步步来完成上述步骤
3. 准备数据集
准备数据集和之前的方法一样,实例化dataset,准备dataloader,最终我们的数据可以处理成如下格式:
其中有两点需要注意:
- 如何完成基础打Dataset的构建和Dataloader的准备
- 每个batch中文本的长度不一致的问题如何解决
- 每个batch中的文本如何转化为数字序列
3.1 基础Dataset的准备
import torch
from torch.utils.data import DataLoader,Dataset
import os
import re
data_base_path = r"data\aclImdb"
#1. 定义tokenize的方法
def tokenize(text):
# fileters = '!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n'
fileters = ['!','"','#','$','%','&','\(','\)','\*','\+',',','-','\.','/',':',';','<','=','>','\?','@'
,'\[','\\','\]','^','_','`','\{','\|','\}','~','\t','\n','\x97','\x96','”','“',]
text = re.sub("<.*?>"," ",text,flags=re.S)
text = re.sub("|".join(fileters)," ",text,flags=re.S)
return [i.strip() for i in text.split()]
#2. 准备dataset
class ImdbDataset(Dataset):
def __init__(self,mode):
super(ImdbDataset,self).__init__()
if mode=="train":
text_path = [os.path.join(data_base_path,i) for i in ["train/neg","train/pos"]]
else:
text_path = [os.path.join(data_base_path,i) for i in ["test/neg","test/pos"]]
self.total_file_path_list = []
for i in text_path:
self.total_file_path_list.extend([os.path.join(i,j) for j in os.listdir(i)])
def __getitem__(self, idx):
cur_path = self.total_file_path_list[idx]
cur_filename = os.path.basename(cur_path)
label = int(cur_filename.split("_")[-1].split(".")[0]) -1 #处理标题,获取label,转化为从[0-9]
text = tokenize(open(cur_path).read().strip()) #直接按照空格进行分词
return label,text
def __len__(self):
return len(self.total_file_path_list)
# 2. 实例化,准备dataloader
dataset = ImdbDataset(mode="train")
dataloader = DataLoader(dataset=dataset,batch_size=2,shuffle=True)
#3. 观察数据输出结果
for idx,(label,text) in enumerate(dataloader):
print("idx:",idx)
print("table:",label)
print("text:",text)
break
输出如下:
idx: 0
table: tensor([3, 1])
text: [('I', 'Want'), ('thought', 'a'), ('this', 'great'), ('was', 'recipe'), ('a', 'for'), ('great', 'failure'), ('idea', 'Take'), ('but', 'a'), ('boy', 's'), ('was', 'y'), ('it', 'plot'), ('poorly', 'add'), ('executed', 'in'), ('We', 'some'), ('do', 'weak'), ('get', 'completely'), ('a', 'undeveloped'), ('broad', 'characters'), ('sense', 'and'), ('of', 'than'), ('how', 'throw'), ('complex', 'in'), ('and', 'the'), ('challenging', 'worst'), ('the', 'special'), ('backstage', 'effects'), ('operations', 'a'), ('of', 'horror'), ('a', 'movie'), ('show', 'has'), ('are', 'known'), ('but', 'Let'), ('virtually', 'stew'), ('no', 'for'), ...('show', 'somehow'), ('rather', 'destroy'), ('than', 'every'), ('anything', 'copy'), ('worth', 'of'), ('watching', 'this'), ('for', 'film'), ('its', 'so'), ('own', 'it'), ('merit', 'will')]
明显,其中的text内容出现对应,和想象的不太相似,出现问题的原因在于Dataloader中的参数collate_fn
collate_fn的默认值为torch自定义的default_collate,collate_fn的作用就是对每个batch进行处理,而默认的default_collate处理出错。
解决问题的思路:
手段1:考虑先把数据转化为数字序列,观察其结果是否符合要求,之前使用DataLoader并未出现类似错误
手段2:考虑自定义一个collate_fn,观察结果
这里使用方式2,自定义一个collate_fn,然后观察结果:
def collate_fn(batch):
#batch是list,其中是一个一个元组,每个元组是dataset中__getitem__的结果
batch = list(zip(*batch))
labes = torch.tensor(batch[0],dtype=torch.int32)
texts = batch[1]
del batch
return labes,texts
dataloader = DataLoader(dataset=dataset,batch_size=2,shuffle=True,collate_fn=collate_fn)
#此时输出正常
for idx,(label,text) in enumerate(dataloader):
print("idx:",idx)
print("table:",label)
print("text:",text)
break
3.2 文本序列化
再介绍word embedding的时候,我们说过,不会直接把文本转化为向量,而是先转化为数字,再把数字转化为向量,那么这个过程该如何实现呢?
这里我们可以考虑把文本中的每个词语和其对应的数字,使用字典保存,同时实现方法把句子通过字典映射为包含数字的列表。
实现文本序列化之前,考虑以下几点:
- 如何使用字典把词语和数字进行对应
- 不同的词语出现的次数不尽相同,是否需要对高频或者低频词语进行过滤,以及总的词语数量是否需要进行限制
- 得到词典之后,如何把句子转化为数字序列,如何把数字序列转化为句子
- 不同句子长度不相同,每个batch的句子如何构造成相同的长度(可以对短句子进行填充,填充特殊字符)
- 对于新出现的词语在词典中没有出现怎么办(可以使用特殊字符代理)
思路分析:
- 对所有句子进行分词
- 词语存入字典,根据次数对词语进行过滤,并统计次数
- 实现文本转数字序列的方法
- 实现数字序列转文本方法
import numpy as np
class Word2Sequence():
UNK_TAG = "UNK"
PAD_TAG = "PAD"
UNK = 0
PAD = 1
def __init__(self):
self.dict = {
self.UNK_TAG :self.UNK,
self.PAD_TAG :self.PAD
}
self.fited = False
def to_index(self,word):
"""word -> index"""
assert self.fited == True,"必须先进行fit操作"
return self.dict.get(word,self.UNK)
def to_word(self,index):
"""index -> word"""
assert self.fited , "必须先进行fit操作"
if index in self.inversed_dict:
return self.inversed_dict[index]
return self.UNK_TAG
def __len__(self):
return self(self.dict)
def fit(self, sentences, min_count=1, max_count=None, max_feature=None):
"""
:param sentences:[[word1,word2,word3],[word1,word3,wordn..],...]
:param min_count: 最小出现的次数
:param max_count: 最大出现的次数
:param max_feature: 总词语的最大数量
:return:
"""
count = {}
for sentence in sentences:
for a in sentence:
if a not in count:
count[a] = 0
count[a] += 1
# 比最小的数量大和比最大的数量小的需要
if min_count is not None:
count = {k: v for k, v in count.items() if v >= min_count}
if max_count is not None:
count = {k: v for k, v in count.items() if v <= max_count}
# 限制最大的数量
if isinstance(max_feature, int):
count = sorted(list(count.items()), key=lambda x: x[1])
if max_feature is not None and len(count) > max_feature:
count = count[-int(max_feature):]
for w, _ in count:
self.dict[w] = len(self.dict)
else:
for w in sorted(count.keys()):
self.dict[w] = len(self.dict)
self.fited = True
# 准备一个index->word的字典
self.inversed_dict = dict(zip(self.dict.values(), self.dict.keys()))
def transform(self, sentence,max_len=None):
"""
实现吧句子转化为数组(向量)
:param sentence:
:param max_len:
:return:
"""
assert self.fited, "必须先进行fit操作"
if max_len is not None:
r = [self.PAD]*max_len
else:
r = [self.PAD]*len(sentence)
if max_len is not None and len(sentence)>max_len:
sentence=sentence[:max_len]
for index,word in enumerate(sentence):
r[index] = self.to_index(word)
return np.array(r,dtype=np.int64)
def inverse_transform(self,indices):
"""
实现从数组 转化为文字
:param indices: [1,2,3....]
:return:[word1,word2.....]
"""
sentence = []
for i in indices:
word = self.to_word(i)
sentence.append(word)
return sentence
if __name__ == '__main__':
w2s = Word2Sequence()
w2s.fit([
["你", "好", "么"],
["你", "好", "哦"]])
print(w2s.dict)
print(w2s.fited)
print(w2s.transform(["你","好","嘛"]))
print(w2s.transform(["你好嘛"],max_len=10))
完成了wordsequence之后,接下来就是保存现有样本中的数据字典,方便后续的使用。
实现对IMDB数据的处理和保存
#1. 对IMDB的数据记性fit操作
def fit_save_word_sequence():
from wordSequence import Word2Sequence
ws = Word2Sequence()
train_path = [os.path.join(data_base_path,i) for i in ["train/neg","train/pos"]]
total_file_path_list = []
for i in train_path:
total_file_path_list.extend([os.path.join(i, j) for j in os.listdir(i)])
for cur_path in tqdm(total_file_path_list,ascii=True,desc="fitting"):
ws.fit(tokenize(open(cur_path).read().strip()))
ws.build_vocab()
# 对wordSequesnce进行保存
pickle.dump(ws,open("./model/ws.pkl","wb"))
#2. 在dataset中使用wordsequence
ws = pickle.load(open("./model/ws.pkl","rb"))
def collate_fn(batch):
MAX_LEN = 500
#MAX_LEN = max([len(i) for i in texts]) #取当前batch的最大值作为batch的最大长度
batch = list(zip(*batch))
labes = torch.tensor(batch[0],dtype=torch.int)
texts = batch[1]
#获取每个文本的长度
lengths = [len(i) if len(i)<MAX_LEN else MAX_LEN for i in texts]
texts = torch.tensor([ws.transform(i, MAX_LEN) for i in texts])
del batch
return labes,texts,lengths
#3. 获取输出
dataset = ImdbDataset(ws,mode="train")
dataloader = DataLoader(dataset=dataset,batch_size=20,shuffle=True,collate_fn=collate_fn)
for idx,(label,text,length) in enumerate(dataloader):
print("idx:",idx)
print("table:",label)
print("text:",text)
print("length:",length)
break
输出如下
idx: 0
table: tensor([ 7, 4, 3, 8, 1, 10, 7, 10, 7, 2, 1, 8, 1, 2, 2, 4, 7, 10,
1, 4], dtype=torch.int32)
text: tensor([[ 50983, 77480, 82366, ..., 1, 1, 1],
[ 54702, 57262, 102035, ..., 80474, 56457, 63180],
[ 26991, 57693, 88450, ..., 1, 1, 1],
...,
[ 51138, 73263, 80428, ..., 1, 1, 1],
[ 7022, 78114, 83498, ..., 1, 1, 1],
[ 5353, 101803, 99148, ..., 1, 1, 1]])
length: [296, 500, 221, 132, 74, 407, 500, 130, 54, 217, 80, 322, 72, 156, 94, 270, 317, 117, 200, 379]
思考:前面我们自定义了MAX_LEN作为句子的最大长度,如果我们需要把每个batch中的最长的句子长度作为当前batch的最大长度,该如何实现?
4. 构建模型
这里我们只练习使用word embedding,所以模型只有一层,即:
- 数据经过word embedding
- 数据通过全连接层返回结果,计算
log_softmax
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import optim
from build_dataset import get_dataloader,ws,MAX_LEN
class IMDBModel(nn.Module):
def __init__(self,max_len):
super(IMDBModel,self).__init__()
self.embedding = nn.Embedding(len(ws),300,padding_idx=ws.PAD) #[N,300]
self.fc = nn.Linear(max_len*300,10) #[max_len*300,10]
def forward(self, x):
embed = self.embedding(x) #[batch_size,max_len,300]
embed = embed.view(x.size(0),-1)
out = self.fc(embed)
return F.log_softmax(out,dim=-1)
5. 模型的训练和评估
训练流程和之前相同
- 实例化模型,损失函数,优化器
- 遍历dataset_loader,梯度置为0,进行向前计算
- 计算损失,反向传播优化损失,更新参数
train_batch_size = 128
test_batch_size = 1000
imdb_model = IMDBModel(MAX_LEN)
optimizer = optim.Adam(imdb_model.parameters())
criterion = nn.CrossEntropyLoss()
def train(epoch):
mode = True
imdb_model.train(mode)
train_dataloader =get_dataloader(mode,train_batch_size)
for idx,(target,input,input_lenght) in enumerate(train_dataloader):
optimizer.zero_grad()
output = imdb_model(input)
loss = F.nll_loss(output,target) #traget需要是[0,9],不能是[1-10]
loss.backward()
optimizer.step()
if idx %10 == 0:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, idx * len(input), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item()))
torch.save(imdb_model.state_dict(), "model/mnist_net.pkl")
torch.save(optimizer.state_dict(), 'model/mnist_optimizer.pkl')
def test():
test_loss = 0
correct = 0
mode = False
imdb_model.eval()
test_dataloader = get_dataloader(mode, test_batch_size)
with torch.no_grad():
for target, input, input_lenght in test_dataloader:
output = imdb_model(input)
test_loss += F.nll_loss(output, target,reduction="sum")
pred = torch.max(output,dim=-1,keepdim=False)[-1]
correct = pred.eq(target.data).sum()
test_loss = test_loss/len(test_dataloader.dataset)
print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format(
test_loss, correct, len(test_dataloader.dataset),
100. * correct / len(test_dataloader.dataset)))
if __name__ == '__main__':
test()
for i in range(3):
train(i)
test()
这里我们仅仅使用了一层全连接层,其分类效果不会很好,这里重点是理解常见的模型流程和word embedding的使用方法
循环神经网络
目标
- 能够说出循环神经网络的概念和作用
- 能够说出循环神经网络的类型和应用场景
- 能够说出LSTM的作用和原理
- 能够说出GRU的作用和原理
1. 循环神经网络的介绍
为什么有了神经网络还需要有循环神经网络?
在普通的神经网络中,信息的传递是单向的,这种限制虽然使得网络变得更容易学习,但在一定程度上也减弱了神经网络模型的能力。特别是在很多现实任务中,网络的输出不仅和当前时刻的输入相关,也和其过去一段时间的输出相关。此外,普通网络难以处理时序数据,比如视频、语音、文本等,时序数据的长度一般是不固定的,而前馈神经网络要求输入和输出的维数都是固定的,不能任意改变。因此,当处理这一类和时序相关的问题时,就需要一种能力更强的模型。
循环神经网络(Recurrent Neural Network,RNN)是一类具有短期记忆能力的神经网络。在循环神经网络中,神经元不但可以接受其它神经元的信息,也可以接受自身的信息,形成具有环路的网络结构。换句话说:神经元的输出可以在下一个时间步直接作用到自身(
入)
通过简化图,我们看到RNN比传统的神经网络多了一个循环圈,这个循环表示的就是在下一个时间步(Time Step)上会返回作为输入的一部分,我们把RNN在时间点上展开,得到的图形如下:
或者是:
在不同的时间步,RNN的输入都将与之前的时间状态有关,$t_n$时刻网络的输出结果是该时刻的输入和所有历史共同作用的结果,这就达到了对时间序列建模的目的。
RNN的不同表示和功能可以通过下图看出:
- 图1:固定长度的输入和输出 (e.g. 图像分类)
- 图2:序列输出 (e.g.图像转文字)
- 图3:数列输入 (e.g. 文本分类)
- 图4:异步的序列输入和输出(e.g.文本翻译).
- 图5:同步的序列输入和输出 (e.g. 根据视频的每一帧来对视频进行分类)
2. LSTM和GRU
2.1 LSTM的基础介绍
假如现在有这样一个需求,根据现有文本预测下一个词语,比如天上的云朵漂浮在__,通过间隔不远的位置就可以预测出来词语是天上,但是对于其他一些句子,可能需要被预测的词语在前100个词语之前,那么此时由于间隔非常大,随着间隔的增加可能会导致真实的预测值对结果的影响变的非常小,而无法非常好的进行预测(RNN中的长期依赖问题(long-Term Dependencies))
那么为了解决这个问题需要LSTM(Long Short-Term Memory网络)
LSTM是一种RNN特殊的类型,可以学习长期依赖信息。在很多问题上,LSTM都取得相当巨大的成功,并得到了广泛的应用。
一个LSMT的单元就是下图中的一个绿色方框中的内容:
其中$\sigma$表示sigmod函数,其他符号的含义:
2.2 LSTM的核心
LSTM的核心在于单元(细胞)中的状态,也就是上图中最上面的那根线。
但是如果只有上面那一条线,那么没有办法实现信息的增加或者删除,所以在LSTM是通过一个叫做门的结构实现,门可以选择让信息通过或者不通过。
这个门主要是通过sigmoid和点乘(pointwise multiplication)实现的
我们都知道,$sigmoid$的取值范围是在(0,1)之间,如果接近0表示不让任何信息通过,如果接近1表示所有的信息都会通过
2.3 逐步理解LSTM
2.3.1 遗忘门
遗忘门通过sigmoid函数来决定哪些信息会被遗忘
在下图就是$h_{t-1}和x_t$进行合并(concat)之后乘上权重和偏置,通过sigmoid函数,输出0-1之间的一个值,这个值会和前一次的细胞状态(Ct−1)进行点乘,从而决定遗忘或者保留
2.3.2 输入门
下一步就是决定哪些新的信息会被保留,这个过程有两步:
- 一个被称为
输入门的sigmoid 层决定哪些信息会被更新 tanh会创造一个新的候选向量$\widetilde{C}_{t}$,后续可能会被添加到细胞状态中
例如:
我昨天吃了苹果,今天我想吃菠萝,在这个句子中,通过遗忘门可以遗忘苹果,同时更新新的主语为菠萝
现在就可以更新旧的细胞状态$C_{t-1}$为新的$C_{ t }$ 了。
更新的构成很简单就是:
- 旧的细胞状态和遗忘门的结果相乘
- 然后加上 输入门和tanh相乘的结果
2.3.3 输出门
最后,我们需要决定什么信息会被输出,也是一样这个输出经过变换之后会通过sigmoid函数的结果来决定那些细胞状态会被输出。
步骤如下:
- 前一次的输出和当前时间步的输入的组合结果通过sigmoid函数进行处理得到$O_t$
- 更新后的细胞状态$C_t$会经过tanh层的处理,把数据转化到(-1,1)的区间
- tanh处理后的结果和$O_t$进行相乘,把结果输出同时传到下一个LSTM的单元
2.4 GRU,LSTM的变形
GRU(Gated Recurrent Unit),是一种LSTM的变形版本, 它将遗忘和输入门组合成一个“更新门”。它还合并了单元状态和隐藏状态,并进行了一些其他更改,由于他的模型比标准LSTM模型简单,所以越来越受欢迎。
LSTM内容参考地址:https://colah.github.io/posts/2015-08-Understanding-LSTMs/
3. 双向LSTM
单向的 RNN,是根据前面的信息推出后面的,但有时候只看前面的词是不够的, 可能需要预测的词语和后面的内容也相关,那么此时需要一种机制,能够让模型不仅能够从前往后的具有记忆,还需要从后往前需要记忆。此时双向LSTM就可以帮助我们解决这个问题
由于是双向LSTM,所以每个方向的LSTM都会有一个输出,最终的输出会有2部分,所以往往需要concat的操作
循环神经网络实现文本情感分类
目标
- 知道LSTM和GRU的使用方法及输入输出的格式
- 能够应用LSTM和GRU实现文本情感分类
1. Pytorch中LSTM和GRU模块使用
1.1 LSTM介绍
LSTM和GRU都是由torch.nn提供
通过观察文档,可知LSMT的参数,
torch.nn.LSTM(input_size,hidden_size,num_layers,batch_first,dropout,bidirectional)
input_size:输入数据的形状,即embedding_dimhidden_size:隐藏层神经元的数量,即每一层有多少个LSTM单元num_layer:即RNN的中LSTM单元的层数batch_first:默认值为False,输入的数据需要[seq_len,batch,feature],如果为True,则为[batch,seq_len,feature]dropout:dropout的比例,默认值为0。dropout是一种训练过程中让部分参数随机失活的一种方式,能够提高训练速度,同时能够解决过拟合的问题。这里是在LSTM的最后一层,对每个输出进行dropoutbidirectional:是否使用双向LSTM,默认是False
实例化LSTM对象之后,不仅需要传入数据,还需要前一次的h_0(前一次的隐藏状态)和c_0(前一次memory)
即:lstm(input,(h_0,c_0))
LSTM的默认输出为output, (h_n, c_n)
output:(seq_len, batch, num_directions * hidden_size)--->batch_first=Falseh_n:(num_layers * num_directions, batch, hidden_size)c_n:(num_layers * num_directions, batch, hidden_size)
1.2 LSTM使用示例
假设数据输入为 input ,形状是[10,20],假设embedding的形状是[100,30]
则LSTM使用示例如下:
batch_size =10 seq_len = 20 embedding_dim = 30 word_vocab = 100 hidden_size = 18 num_layer = 2 #准备输入数据 input = torch.randint(low=0,high=100,size=(batch_size,seq_len)) #准备embedding embedding = torch.nn.Embedding(word_vocab,embedding_dim) lstm = torch.nn.LSTM(embedding_dim,hidden_size,num_layer) #进行mebed操作 embed = embedding(input) #[10,20,30] #转化数据为batch_first=False embed = embed.permute(1,0,2) #[20,10,30] #初始化状态, 如果不初始化,torch默认初始值为全0 h_0 = torch.rand(num_layer,batch_size,hidden_size) c_0 = torch.rand(num_layer,batch_size,hidden_size) output,(h_1,c_1) = lstm(embed,(h_0,c_0)) #output [20,10,1*18] #h_1 [2,10,18] #c_1 [2,10,18]
输出如下
In [122]: output.size() Out[122]: torch.Size([20, 10, 18]) In [123]: h_1.size() Out[123]: torch.Size([2, 10, 18]) In [124]: c_1.size() Out[124]: torch.Size([2, 10, 18])
通过前面的学习,我们知道,最后一次的h_1应该和output的最后一个time step的输出是一样的
通过下面的代码,我们来验证一下:
In [179]: a = output[-1,:,:]
In [180]: a.size()
Out[180]: torch.Size([10, 18])
In [183]: b.size()
Out[183]: torch.Size([10, 18])
In [184]: a == b
Out[184]:
tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]],
dtype=torch.uint8)
1.3 GRU的使用示例
GRU模块torch.nn.GRU,和LSTM的参数相同,含义相同,具体可参考文档
但是输入只剩下gru(input,h_0),输出为output, h_n
其形状为:
output:(seq_len, batch, num_directions * hidden_size)h_n:(num_layers * num_directions, batch, hidden_size)
大家可以使用上述代码,观察GRU的输出形式
1.4 双向LSTM
如果需要使用双向LSTM,则在实例化LSTM的过程中,需要把LSTM中的bidriectional设置为True,同时h_0和c_0使用num_layer*2
观察效果,输出为
batch_size =10 #句子的数量 seq_len = 20 #每个句子的长度 embedding_dim = 30 #每个词语使用多长的向量表示 word_vocab = 100 #词典中词语的总数 hidden_size = 18 #隐层中lstm的个数 num_layer = 2 #多少个隐藏层 input = torch.randint(low=0,high=100,size=(batch_size,seq_len)) embedding = torch.nn.Embedding(word_vocab,embedding_dim) lstm = torch.nn.LSTM(embedding_dim,hidden_size,num_layer,bidirectional=True) embed = embedding(input) #[10,20,30] #转化数据为batch_first=False embed = embed.permute(1,0,2) #[20,10,30] h_0 = torch.rand(num_layer*2,batch_size,hidden_size) c_0 = torch.rand(num_layer*2,batch_size,hidden_size) output,(h_1,c_1) = lstm(embed,(h_0,c_0)) In [135]: output.size() Out[135]: torch.Size([20, 10, 36]) In [136]: h_1.size() Out[136]: torch.Size([4, 10, 18]) In [137]: c_1.size() Out[137]: torch.Size([4, 10, 18])
在单向LSTM中,最后一个time step的输出的前hidden_size个和最后一层隐藏状态h_1的输出相同,那么双向LSTM呢?
双向LSTM中:
output:按照正反计算的结果顺序在第2个维度进行拼接,正向第一个拼接反向的最后一个输出
hidden state:按照得到的结果在第0个维度进行拼接,正向第一个之后接着是反向第一个
-
前向的LSTM中,最后一个time step的输出的前hidden_size个和最后一层向前传播h_1的输出相同
-
示例:
-
#-1是前向LSTM的最后一个,前18是前hidden_size个 In [188]: a = output[-1,:,:18] #前项LSTM中最后一个time step的output In [189]: b = h_1[-2,:,:] #倒数第二个为前向 In [190]: a.size() Out[190]: torch.Size([10, 18]) In [191]: b.size() Out[191]: torch.Size([10, 18]) In [192]: a == b Out[192]: tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], dtype=torch.uint8)
-
-
后向LSTM中,最后一个time step的输出的后hidden_size个和最后一层后向传播的h_1的输出相同
-
示例
-
#0 是反向LSTM的最后一个,后18是后hidden_size个 In [196]: c = output[0,:,18:] #后向LSTM中的最后一个输出 In [197]: d = h_1[-1,:,:] #后向LSTM中的最后一个隐藏层状态 In [198]: c == d Out[198]: tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]], dtype=torch.uint8)
-
1.4 LSTM和GRU的使用注意点
- 第一次调用之前,需要初始化隐藏状态,如果不初始化,默认创建全为0的隐藏状态
- 往往会使用LSTM or GRU 的输出的最后一维的结果,来代表LSTM、GRU对文本处理的结果,其形状为
[batch, num_directions*hidden_size]。- 并不是所有模型都会使用最后一维的结果
- 如果实例化LSTM的过程中,batch_first=False,则
output[-1] or output[-1,:,:]可以获取最后一维 - 如果实例化LSTM的过程中,batch_first=True,则
output[:,-1,:]可以获取最后一维
- 如果结果是
(seq_len, batch_size, num_directions * hidden_size),需要把它转化为(batch_size,seq_len, num_directions * hidden_size)的形状,不能够不是view等变形的方法,需要使用output.permute(1,0,2),即交换0和1轴,实现上述效果 - 使用双向LSTM的时候,往往会分别使用每个方向最后一次的output,作为当前数据经过双向LSTM的结果
- 即:
torch.cat([h_1[-2,:,:],h_1[-1,:,:]],dim=-1) - 最后的表示的size是
[batch_size,hidden_size*2]
- 即:
- 上述内容在GRU中同理
2. 使用LSTM完成文本情感分类
在前面,我们使用了word embedding去实现了toy级别的文本情感分类,那么现在我们在这个模型中添加上LSTM层,观察分类效果。
为了达到更好的效果,对之前的模型做如下修改
- MAX_LEN = 200
- 构建dataset的过程,把数据转化为2分类的问题,pos为1,neg为0,否则25000个样本完成10个类别的划分数据量是不够的
- 在实例化LSTM的时候,使用dropout=0.5,在model.eval()的过程中,dropout自动会为0
2.1 修改模型
class IMDBLstmmodel(nn.Module):
def __init__(self):
super(IMDBLstmmodel,self).__init__()
self.hidden_size = 64
self.embedding_dim = 200
self.num_layer = 2
self.bidriectional = True
self.bi_num = 2 if self.bidriectional else 1
self.dropout = 0.5
#以上部分为超参数,可以自行修改
self.embedding = nn.Embedding(len(ws),self.embedding_dim,padding_idx=ws.PAD) #[N,300]
self.lstm = nn.LSTM(self.embedding_dim,self.hidden_size,self.num_layer,bidirectional=True,dropout=self.dropout)
#使用两个全连接层,中间使用relu激活函数
self.fc = nn.Linear(self.hidden_size*self.bi_num,20)
self.fc2 = nn.Linear(20,2)
def forward(self, x):
x = self.embedding(x)
x = x.permute(1,0,2) #进行轴交换
h_0,c_0 = self.init_hidden_state(x.size(1))
_,(h_n,c_n) = self.lstm(x,(h_0,c_0))
#只要最后一个lstm单元处理的结果,这里多去的hidden state
out = torch.cat([h_n[-2, :, :], h_n[-1, :, :]], dim=-1)
out = self.fc(out)
out = F.relu(out)
out = self.fc2(out)
return F.log_softmax(out,dim=-1)
def init_hidden_state(self,batch_size):
h_0 = torch.rand(self.num_layer * self.bi_num, batch_size, self.hidden_size).to(device)
c_0 = torch.rand(self.num_layer * self.bi_num, batch_size, self.hidden_size).to(device)
return h_0,c_0
2.2 完成训练和测试代码
为了提高程序的运行速度,可以考虑把模型放在gup上运行,那么此时需要处理一下几点:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")model.to(device)- 除了上述修改外,涉及计算的所有tensor都需要转化为CUDA的tensor
- 初始化的
h_0,c_0 - 训练集和测试集的
input,traget
- 初始化的
- 在最后可以通过
tensor.cpu()转化为torch的普通tensor
train_batch_size = 64
test_batch_size = 5000
# imdb_model = IMDBModel(MAX_LEN) #基础model
imdb_model = IMDBLstmmodel().to(device) #在gpu上运行,提高运行速度
# imdb_model.load_state_dict(torch.load("model/mnist_net.pkl"))
optimizer = optim.Adam(imdb_model.parameters())
criterion = nn.CrossEntropyLoss()
def train(epoch):
mode = True
imdb_model.train(mode)
train_dataloader =get_dataloader(mode,train_batch_size)
for idx,(target,input,input_lenght) in enumerate(train_dataloader):
target = target.to(device)
input = input.to(device)
optimizer.zero_grad()
output = imdb_model(input)
loss = F.nll_loss(output,target) #traget需要是[0,9],不能是[1-10]
loss.backward()
optimizer.step()
if idx %10 == 0:
pred = torch.max(output, dim=-1, keepdim=False)[-1]
acc = pred.eq(target.data).cpu().numpy().mean()*100.
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}\t ACC: {:.6f}'.format(epoch, idx * len(input), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item(),acc))
torch.save(imdb_model.state_dict(), "model/mnist_net.pkl")
torch.save(optimizer.state_dict(), 'model/mnist_optimizer.pkl')
def test():
mode = False
imdb_model.eval()
test_dataloader = get_dataloader(mode, test_batch_size)
with torch.no_grad():
for idx,(target, input, input_lenght) in enumerate(test_dataloader):
target = target.to(device)
input = input.to(device)
output = imdb_model(input)
test_loss = F.nll_loss(output, target,reduction="mean")
pred = torch.max(output,dim=-1,keepdim=False)[-1]
correct = pred.eq(target.data).sum()
acc = 100. * pred.eq(target.data).cpu().numpy().mean()
print('idx: {} Test set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.2f}%)\n'.format(idx,test_loss, correct, target.size(0),acc))
if __name__ == "__main__":
test()
for i in range(10):
train(i)
test()
2.3 模型训练的最终输出
...
Train Epoch: 9 [20480/25000 (82%)] Loss: 0.017165 ACC: 100.000000
Train Epoch: 9 [21120/25000 (84%)] Loss: 0.021572 ACC: 98.437500
Train Epoch: 9 [21760/25000 (87%)] Loss: 0.058546 ACC: 98.437500
Train Epoch: 9 [22400/25000 (90%)] Loss: 0.045248 ACC: 98.437500
Train Epoch: 9 [23040/25000 (92%)] Loss: 0.027622 ACC: 98.437500
Train Epoch: 9 [23680/25000 (95%)] Loss: 0.097722 ACC: 95.312500
Train Epoch: 9 [24320/25000 (97%)] Loss: 0.026713 ACC: 98.437500
Train Epoch: 9 [15600/25000 (100%)] Loss: 0.006082 ACC: 100.000000
idx: 0 Test set: Avg. loss: 0.8794, Accuracy: 4053/5000 (81.06%)
idx: 1 Test set: Avg. loss: 0.8791, Accuracy: 4018/5000 (80.36%)
idx: 2 Test set: Avg. loss: 0.8250, Accuracy: 4087/5000 (81.74%)
idx: 3 Test set: Avg. loss: 0.8380, Accuracy: 4074/5000 (81.48%)
idx: 4 Test set: Avg. loss: 0.8696, Accuracy: 4027/5000 (80.54%)
可以看到模型的测试准确率稳定在81%左右。
大家可以把上述代码改为GRU,或者多层LSTM继续尝试,观察效果
Pytorch中的序列化容器
目标
- 知道梯度消失和梯度爆炸的原理和解决方法
- 能够使用
nn.Sequential完成模型的搭建 - 知道
nn.BatchNorm1d的使用方法 - 知道
nn.Dropout的使用方法
1. 梯度消失和梯度爆炸
在使用pytorch中的序列化 容器之前,我们先来了解一下常见的梯度消失和梯度爆炸的问题
1.1 梯度消失
假设我们有四层极简神经网络:每层只有一个神经元
$获取w1的梯度有:▽w1 = x1*f(a1)’w2f(b1)’w3▽out$
假设我们使用sigmoid激活函数,即f为sigmoid函数,sigmoid的导数如下图
假设每层都取得sigmoid导函数的最大值1/4,那么在反向传播时,$X1=0.5,w1=w2=w3=0.5$
$\nabla w1< \frac{1}{2} * \frac{1}{4}* \frac{1}{2}* \frac{1}{4}\frac{1}{2}\nabla out = \frac{1}{2^7} \nabla out$
当权重初始过小或使用易饱和神经元(sigmoid,tanh,) sigmoid在y=0,1处梯度接近0,而无法更新参数,时神经网络在反向传播时也会呈现指数倍缩小,产生“消失”现象。
1.2 梯度爆炸
假设$X2=2,w1=w2=w3=2$
$\nabla w1 = f'{a}2f‘{a}*x2\nabla out = 2^3f'(a)^2 \nabla out $
当权重初始过大时,梯度神经网络在反向传播时也会呈现指数倍放大,产生“爆炸”现象。
1.3 解决梯度消失或者梯度爆炸的经验
2. nn.Sequential
nn.Sequential是一个有序的容器,其中传入的是构造器类(各种用来处理input的类),最终input会被Sequential中的构造器类依次执行
例如:
layer = nn.Sequential(
nn.Linear(input_dim, n_hidden_1),
nn.ReLU(True), #inplace=False 是否对输入进行就地修改,默认为False
nn.Linear(n_hidden_1, n_hidden_2),
nn.ReLU(True),
nn.Linear(n_hidden_2, output_dim) # 最后一层不需要添加激活函数
)
在上述就够中,可以直接调用layer(x),得到输出
x的被执行顺序就是Sequential中定义的顺序:
- 被隐层1执行,形状变为[batch_size,n_hidden_1]
- 被relu执行,形状不变
- 被隐层2执行,形状变为[batch_size,n_hidden_2]
- 被relu执行,形状不变
- 被最后一层执行,形状变为[batch_size,output_dim]
3. nn.BatchNorm1d
batch normalization 翻译成中文就是批规范化,即在每个batch训练的过程中,对参数进行归一化的处理,从而达到加快训练速度的效果。
以sigmoid激活函数为例,他在反向传播的过程中,在值为0,1的时候,梯度接近0,导致参数被更新的幅度很小,训练速度慢。但是如果对数据进行归一化之后,就会尽可能的把数据拉倒[0-1]的范围,从而让参数更新的幅度变大,提高训练的速度。
batchNorm一般会放到激活函数之后,即对输入进行激活处理之后再进入batchNorm
layer = nn.Sequential(
nn.Linear(input_dim, n_hidden_1),
nn.ReLU(True),
nn.BatchNorm1d(n_hidden_1)
nn.Linear(n_hidden_1, n_hidden_2),
nn.ReLU(True),
nn.BatchNorm1d(n_hidden_2)
nn.Linear(n_hidden_2, output_dim)
)
4. nn.Dropout
dropout在前面已经介绍过,可以理解为对参数的随机失活
- 增加模型的稳健性
- 可以解决过拟合的问题(增加模型的泛化能力)
- 可以理解为训练后的模型是多个模型的组合之后的结果,类似随机森林。
layer = nn.Sequential(
nn.Linear(input_dim, n_hidden_1),
nn.ReLU(True),
nn.BatchNorm1d(n_hidden_1)
nn.Dropout(0.3) #0.3 为dropout的比例,默认值为0.5
nn.Linear(n_hidden_1, n_hidden_2),
nn.ReLU(True),
nn.BatchNorm1d(n_hidden_2)
nn.Dropout(0.3)
nn.Linear(n_hidden_2, output_dim)
)
走进聊天机器人
学习目标
- 知道常见的bot的分类
- 知道企业中常见的流程和方法
1. 目前企业中的常见的聊天机器人
- QA BOT(问答机器人):回答问题
- 代表 :智能客服、
- 比如:提问和回答
- TASK BOT (任务机器人):帮助人们做事情
- 代表:siri
- 比如:设置明天早上9点的闹钟
- CHAT BOT(聊天机器人):通用、开放聊天
- 代表:微软小冰
2. 常见的聊天机器人怎么实现的
2.1 问答机器人的常见实现手段
-
信息检索、搜索 (简单,效果一般,对数据问答对的要求高)
关键词:tfidf、SVM、朴素贝叶斯、RNN、CNN
-
知识图谱(相对复杂,效果好,很多论文)
在图形数据库中存储知识和知识间的关系、把问答转化为查询语句、能够实现推理
2.2 任务机器人的常见实现思路
- 语音转文字
- 意图识别、领域识别、文本分类
- 槽位填充:比如买机票的机器人 使用命令体识别填充
从{位置}到{位置}的票2个位置的 - 回话管理、回话策略
- 自然语言生成
- 文本转语音
2.3 闲聊机器人的常见实现思路
- 信息检索(简单、能够回答的话术有限)
- seq2seq 和变种(答案覆盖率高,但是不能保证答案的通顺等)
3. 企业中的聊天机器人是如何实现的
3.1 阿里小蜜-电商智能助理是如何实现的
参考地址:https://juejin.im/entry/59e96f946fb9a04510499c7f
3.1.1 主要交互过程
从图可以看出:
- 输入:语音转化为文本,进行理解之后根据上下文得到语义的表示
- 输出:根据语义的表是和生成方法得到文本,再把文本转化为语音输出
3.1.2 技术架构
可以看出其流程为:
- 判断用户意图
- 如果意图为面向目标:可能是问答型或者是任务型
- 如果非面向目标:可能是语聊型
3.1.3 检索模型流程(小蜜还用了其他的模型,这里以此为例)
通过上图可知,小蜜的检索式回答的流程大致为:
- 对问题进行处理
- 根据问题进行召回,使用了提前准备的结构化的语料和训练的模型
- 对召回的结果进行组长和日志记录
- 对召回的结果进行相似度计算,情感分析和属性识别
- 返回组装的结果
3.2 58同城智能客服帮帮如何实现的
参考地址:http://www.6aiq.com/article/1536149308075?p=1&m=0
3.2.1 58客服体系
58的客服主要用户为公司端和个人端,智能客服主要实现自动回答,如果回答不好会转到人工客服,其中自动回答需要覆盖的问题包括:业务咨询、投诉建议等
3.2.2 58智能客服整体架构
整体来看,58的客服架构分为三个部分
- 基础服务,实现基础的NLP的功能和意图识别
- 应用对话部分实现不同意图的模型,同时包括编辑运营等内容
- 提供对外的接口
3.2.3 业务咨询服务流程
大致流程
KB-bot的流程大致为:
- 对问题进行基础处理
- 对答案通过tfidf等方法进行召回
- 对答案通过规则、深度神经网络等方法进行重排序
- 返回答案排序列表
使用融合的模型
在问答模型的深度网络模型中使用了多套模型进行融合来获取结果
- 在模型层应用了 FastText、TextCNN 和 Bi-LSTM 等模型
- 在特征层尝试使用了单字、词、词性、词语属性等多种特征
通过以上两个模型来组合获取相似的问题,返回相似问题ID对应的答案
3.2.4 58的闲聊机器人
58同城的闲聊机器人使用三种方法包括:
- 基于模板匹配的方法
- 基于搜索的方式获取(上上图)
- 使用seq2seq的神经网络来实现
3.2.5 解决不了转人工服务
智能客服解决不了的可以使用人工客服来实现
需求分析和流程介绍
目标
- 能够说出实现聊天机器人的需求
- 能够说出实现聊天机器人的流程
1. 需求分析
在黑马头条的小智同学板块实现聊天机器人,能够起到智能客服的效果,能够为使用app的用户解决基础的问题,而不用额外的人力。
但是由于语料的限制,所以这里使用了编程相关的问题,能够回答类似:python是什么,python有什么优势等问题
2. 效果演示
3. 实现流程
3.1 整体架构
整个流程的描述如下:
- 接受用户的问题之后,对问题进行基础的处理
- 对处理后的问题进行分类,判断其意图
- 如果用户希望闲聊,那么调用闲聊模型返回结果
- 如果用户希望咨询问题,那么调用问答模型返回结果
3.2 闲聊模型
闲聊模型使用了seq2seq模型实现
包含:
- 对数据的embedding
- 编码层
- attention机制的处理
- 解码层
3.4 问答模型
问答模型使用了召回和排序的机制来实现,保证获取的速度的同时保证了准确率
- 问题分析:对问题进行基础的处理,包括分词,词性的获取,词向量的获取
- 问题的召回:通过机器学习的方法进行海选,海选出大致满足要求的相似问题的前K个
- 问题的排序:通过深度学习的模型对问题计算准确率,进行排序
- 设置阈值,返回结果
环境准备
目标
- 能够使用anaconda创建虚拟环境
- 能够安装fasttext
- 能够安装pysparnn
1. Anaconda环境准备
-
下载地址:
https://mirror.tuna.tsinghua.edu.cn/help/anaconda/ -
下载对应电脑版本软件,安装
- windows :双击exe文件
- unix:给sh文件添加可执行权限,执行sh文件
-
添加到环境变量
- windows安装过程中勾选
- unix:
export PATH="/root/miniconda3/bin:$PATH"
-
创建虚拟环境
conda create -n 名字 python=3.6(版本)- 查看所有虚拟环境:
conda env list
-
切换到虚拟环境
conda activate 名字
-
退出虚拟环境
conda deactivate 名字
2. fasttext安装
文档地址:https://fasttext.cc/docs/en/support.html
github地址:<https://github.com/facebookresearch/fastText
安装步骤:
- 下载
git clone https://github.com/facebookresearch/fastText.git - cd
cd fastText - 安装
python setup.py install
3. pysparnn安装
文档地址:https://github.com/facebookresearch/pysparnn
安装步骤:
- 下载:
git clone https://github.com/facebookresearch/pysparnn.git - 安装:
python setupy.py install
语料准备
目标
- 准备分词词典
- 准备停用词
- 准备问答对
- 爬虫采集相似问题
1. 分词词典
最终词典的格式:
词语 词性(不要和jieba默认的词性重复)
1.1 词典来源
-
各种输入法的词典
例如:
https://pinyin.sogou.com/dict/cate/index/97?rf=dictindex例如:
https://shurufa.baidu.com/dict_list?cid=211 -
手动收集,根据目前的需求,我们可以手动收集如下词典
- 机构名称,例如:
传智,传智播客,黑马程序员 - 课程名词,例如:
python,人工智能+python,c++等
- 机构名称,例如:
1.2 词典处理
输入法的词典都是特殊格式,需要使用特殊的工具才能够把它转化为文本格式
工具名称:深蓝词库转换.exe
下载地址:https://github.com/studyzy/imewlconverter
1.3 对多个词典文件内容进行合并
下载使用不同平台的多个词典之后,把所有的txt文件合并到一起供之后使用
2. 准备停用词
2.1 什么是停用词?
对句子进行分词之后,句子中不重要的词
2.2 停用词的准备
常用停用词下载地址:https://github.com/goto456/stopwords
2.3 手动筛选和合并
对于停用词的具体内容,不同场景下可能需要保留和去除的词语不一样
比如:词语哪个,很多场景可以删除,但是在判断语义的时候则不行
3. 问答对的准备
3.1 现有问答对的样式
问答对有两部分,一部分是咨询老师整理的问答对,一部分是excel中的问答对,
最终我们需要把问答对分别整理到两个txt文档中,如下图(左边是问题,右边是答案):
Excel中的问答对如下图:
3.2 excel中问答对的处理
Excel中的问答对直接使用pandas就能够处理
python_qa_path = "./data/Python短问答-11月汇总.xlsx"
def load_duanwenda():
import pandas as pd
ret = pd.read_excel(python_qa_path)
column_list = ret.columns
assert '问题' in column_list and "答案" in column_list,"excel 中必须包含问题和答案"
for q,a in zip(ret["问题"],ret["答案"]):
q = re.sub("\s+"," ",q)
q = q.strip()
print(q,a)
4. 相似问答对的采集
4.1 采集相似问答对的目的
后续在判断问题相似度的时候,需要有语料用来进行模型的训练,输入两个句子,输出相似度,这个语料不好获取,所以决定从百度知道入手,采集百度知道上面的相似问题,如下图所示:
上面采集的数据会存在部分噪声,部分问题搜索到的结果语义上并不是太相似
4.2 手动构造数据
根据前面的问答对的内容,把问题大致分为了若干类型,对不同类型的问题设计模板,然后构造问题,问题模块如下:
templete = [
#概念
["{kc}","什么是{kc}","{kc}是什么","给我介绍一下{kc}","{kc}可以干什么","能简单说下什么是{kc}吗","我想了解{kc}"],
#课程优势
["{kc}课程有什么特点","{jgmc}的{kc}课程有什么特点","{jgmc}的{kc}课程有什么优势","为什么我要来{jgmc}学习{kc}","{jgmc}的{kc}课程有什么优势","为什么要到{jgmc}学习{kc}","{jgmc}的{kc}跟其他机构有什么区别?","为什么选择{jgmc}来学习{kc}?"],
#语言优势
#["{kc}","什么是{kc}","{kc}是什么","给我介绍一下{kc}","{kc}可以干什么","能简单说下什么是{kc}吗"],
#特点
["{kc}有什么特点","{kc}有什么优势","{kc}有什么亮点","{kc}有那些亮点","{kc}有那些优势","{kc}有那些特点","{kc}的亮点是什么","{kc}的优势是什么","{kc}的特点是什么"],
#发展前景
["{kc}的发展怎么样?","{kc}的前景怎么样?","{kc}的发展前景如何?","{kc}的未来怎样","{kc}的前景好么" ],
#就业
["{kc}好就业么","{kc}就业机会多么","{kc}的岗位多吗","{kc}工作好找吗","{kc}的市场需求怎么样","{kc}的就业环境怎么样"],
#就业方向
["{kc}学完以后能具体从事哪方面工作?","{kc}的就业岗位有哪些?","{kc}课程学完应聘哪方面工作?","{kc}可以从事哪方面工作?",],
#用途
["{kc}学完可以做什么","{kc}能干什么","学{kc}能干什么","能举例说下{kc}能做什么吗?","{kc}毕业了能干什么","{kc}主要应用在什么领域"],
#就业薪资
["{kc}学完工资多少","学完{kc}能拿多少钱","{kc}的就业薪资多少","{kc}就业的平均是工资多少"],
#学习难度
["{kc}简单么","{kc}容易么","{kc}课程容易么","{kc}上手快么","{kc}课程难么"],
#校区
["在那些城市开设了{kc}","哪里可以学习{kc}","学习{kc}可以去那些城市","{kc}在哪里开班了"],
#学费
["{kc}学费","{kc}多少钱","{kc}的学费多少","{kc}是怎么收费的?","学习{kc}要花多少钱","{kc}是怎么收费的","{kc}课程的价格","{kc}课程的价格是多少"],
#适合人群
["什么人可以学{kc}","哪些人可以学{kc}","学习{kc}有什么要求","学习{kc}需要那些条件","没有基础可以学{kc}吗","学历低可以学习{kc}吗?","成绩不好可以学习{kc}吗?","什么样的人适合学习{kc}?"],
#学习时间
["{kc}需要学多久","{kc}需要多久才能就业","{kc}需要学习多长时间","{kc}的学时是多少","{kc}的课时是多少","{kc}课时","{kc}课时长度","{kc}的课程周期?","0基础学{kc}多久能才就业"],
#学习内容
["{kc}学什么","{kc}学习那些内容","我们在{kc}中学习那些内容","在{kc}中大致都学习什么内容"],
#项目内容
["{kc}的项目有哪些","{kc}有哪些项目","{kc}上课都有哪些实战","{kc}做什么项目?","{kc}项目有多少个?","{kc}课程中有项目吗?"],
#学习某课程的好处
["为什么要学习{kc}?","学习{kc}有哪些好处?","学习{kc}的理由?","为什么我要来学习{kc}"],
#上课时间
["上课时间","你们那边每天的上课时间是怎样的呢?"],
#英语要求
["学习{kc}对英语有要求么","来{jgmc}学习对英语有要求吗?"]
]
其中大括号的内容kc表示课程,jgmc表示机构名称
接下来,需要完成两件事
- 最终我们会把前面准备好的课程字典和机构名称字典中的词语放入大括号中
- 把kc相同的内容构造成相似问题
文本分词
目标
- 完成停用词的准备
- 完成分词方法的封装
1. 准备词典和停用词
1.1 准备词典
1.2 准备停用词
stopwords = set([i.strip() for i in open(config.stopwords_path).readlines()])
2. 准备按照单个字切分句子的方法
def _cut_by_word(sentence):
# 对中文按照字进行处理,对英文不分为字母
sentence = re.sub("\s+"," ",sentence)
sentence = sentence.strip()
result = []
temp = ""
for word in sentence:
if word.lower() in letters:
temp += word.lower()
else:
if temp != "": #不是字母
result.append(temp)
temp = ""
if word.strip() in filters: #标点符号
continue
else: #是单个字
result.append(word)
if temp != "": #最后的temp中包含字母
result.append(temp)
return result
3. 完成分词方法的封装
lib 下创建cut_sentence.py文件,完成分词方法的构建
import logging
import jieba
import jieba.posseg as psg
import config
import re
import string
#关闭jieba log输出
jieba.setLogLevel(logging.INFO)
#加载词典
jieba.load_userdict(config.keywords_path)
#单字分割,英文部分
letters = string.ascii_lowercase
#单字分割 去除的标点
filters= [",","-","."," "]
#停用词
stopwords = set([i.strip() for i in open(config.stopwords_path).readlines()])
def cut(sentence,by_word=False,use_stopwords=False,with_sg=False):
assert by_word!=True or with_sg!=True,"根据word切分时候无法返回词性"
if by_word:
return _cut_by_word(sentence)
else:
ret = psg.lcut(sentence)
if use_stopwords:
ret = [(i.word,i.flag) for i in ret if i.word not in stopwords]
if not with_sg:
ret = [i.word for i in ret]
return ret
def _cut_by_word(sentence):
# 对中文按照字进行处理,对英文不分为字母
sentence = re.sub("\s+"," ",sentence)
sentence = sentence.strip()
result = []
temp = ""
for word in sentence:
if word.lower() in letters:
temp += word.lower()
else:
if temp != "": #不是字母
result.append(temp)
temp = ""
if word.strip() in filters: #标点符号
continue
else: #是单个字
result.append(word)
if temp != "": #最后的temp中包含字母
result.append(temp)
return result
动手练习
目标
- 动手准备好词典
- 动手准备问答对
- 动手构造问答对
- 对问题进行分词,单独存储
分类的目的和分类的方法
目标
- 能够说出项目中进行文本的目的
- 能够说出意图识别的方法
- 能够说出常见的分类的方法
1. 文本分类的目的
回顾之前的流程,我们可以发现文本分类的目的就是为了进行意图识别
在当前我们的项目的下,我们只有两种意图需要被识别出来,所以对应的是2分类的问题
可以想象,如果我们的聊天机器人有多个功能,那么我们需要分类的类别就有多个,这样就是一个多分类的问题。例如,如果希望聊天机器人能够播报当前的时间,那么我们就需要准备关于询问时间的语料,同时其目标值就是一个新的类别。在训练后,通过这个新的模型,判断出用户询问的是当前的时间这个类别,那么就返回当前的时间。
同理,如果还希望聊天机器人能够播报未来某一天的天气,那么这个机器人就还需要增加一个新的进行分类的意图,重新进行训练
2. 机器学习中常见的分类方法
在前面的机器学习的课程中我们学习了朴素贝叶斯,决策树等方法都能够帮助我们进行文本的分类,那么我们具体该怎么做呢?
2.1 步骤
- 特征工程:对文本进行处理,转化为能够被计算的向量来表示。我们可以考虑使用所有词语的出现次数,也可以考虑使用tfidf这种方法来处理
- 对模型进行训练
- 对模型进行评估
2.2 优化
使用机器学习的方法进行文本分类的时候,为了让结果更好,我们经常从两个角度出发
- 特征工程的过程中处理的更加细致,比如文本中类似你,我,他这种词语可以把它剔除;某些词语出现的次数太少,可能并不具有代表意义;某些词语出现的次数太多,可能导致影响的程度过大等等都是我们可以考虑的地方
- 使用不同的算法进行训练,获取不同算法的结果,选择最好的,或者是使用集成学习方法
3. 深度学习实现文本分类
前面我们简单回顾了使用机器学习如何来进行文本分类,那么使用深度学习该如何实现呢?
在深度学习中我们常见的操作就是:
- 对文本进行embedding的操作,转化为向量
- 之后再通过多层的神经网络进行线性和非线性的变化得到结果
- 变换后的结果和目标值进行计算得到损失函数,比如对数似然损失等
- 通过最小化损失函数,去更新原来模型中的参数
fastText实现文本分类
目标
- 知道fastext是什么
- 能够应用fasttext进行文本分类
- 能够完成项目中意图识别的代码
1. fastText的介绍
文档地址:https://fasttext.cc/docs/en/support.html
fastText is a library for efficient learning of word representations and sentence classification.
fastText是一个单词表示学习和文本分类的库
优点:在标准的多核CPU上, 在10分钟之内能够训练10亿词级别语料库的词向量,能够在1分钟之内给30万多类别的50多万句子进行分类。
fastText 模型输入一个词的序列(一段文本或者一句话),输出这个词序列属于不同类别的概率。
2. 安装和基本使用
2.1 安装步骤:
- 下载
git clone https://github.com/facebookresearch/fastText.git - cd
cd fastText - 安装
python setup.py install
2.2 基本使用
-
把数据准备为需要的格式
-
进行模型的训练、保存和加载、预测
#1. 训练 model = fastText.train_supervised("./data/text_classify.txt",wordNgrams=1,epoch=20) #2. 保存 model.save_model("./data/ft_classify.model") #3. 加载 model = fastText.load_model("./data/ft_classify.model") textlist = [句子1,句子2] #4. 预测,传入句子列表 ret = model.predict(textlist)
3. 意图识别实现
3.1 数据准备
数据准备最终需要的形式如下:
以上格式是fastText要求的格式,其中chat、QA字段可以自定义,就是目标值,__label__之前的为特征值,需要使用\t进行分隔,特征值需要进行分词,__label__后面的是目标值
3.1.1 准备特征文本
使用之前通过模板构造的样本和通过爬虫抓取的百度上的相似问题,
3.1.2 准备闲聊文本
使用小黄鸡的语料,地址:dgk_lost_conv/results at master · aceimnorstuvwxz/dgk_lost_conv · GitHub
3.1.3 把文本转化为需要的格式
对两部分文本进行分词、合并,转化为需要的格式
def prepar_data():
#小黄鸡 作为闲聊
xiaohaungji = "./corpus/recall/小黄鸡未分词.conv"
handle_chat_corpus(xiaohaungji)
# mongodb中的数据,问题和相似问题作为 问答
handle_mongodb_corpus()
def keywords_in_line(line):
"""相似问题中去除关键字不在其中的句子
"""
keywords_list = ["传智播客","传智","黑马程序员","黑马","python"
"人工智能","c语言","c++","java","javaee","前端","移动开发","ui",
"ue","大数据","软件测试","php","h5","产品经理","linux","运维","go语言",
"区块链","影视制作","pmp","项目管理","新媒体","小程序","前端"]
for keyword in keywords_list:
if keyword in line:
return True
return False
def handle_chat_corpus(path):
chat_num = 0
with open("./corpus/recall/text_classify.txt","a") as f:
for line in open(path,"r"):
if line.strip() == "E" or len(line.strip())<1:
continue
elif keywords_in_line(line):
continue
elif line.startswith("M"):
line = line[2:]
line = re.sub("\s+"," ",line)
line_cuted = " ".join(jieba_cut(line.strip())).strip()
lable = "\t__label__{}\n".format("chat")
f.write(line_cuted+lable)
chat_num +=1
print(chat_num)
def handle_QA_corpus():
by_hand_data_path = "./corpus/recall/手动构造的问题.json" #手动构造的数据
by_hand_data = json.load(open(by_hand_data_path))
qa_num = 0
f = open("./corpus/recall/text_classify.txt","a")
for i in by_hand_data:
for j in by_hand_data[i]:
for x in j:
x = re.sub("\s+", " ", x)
line_cuted = " ".join(jieba_cut(x.strip())).strip()
lable = "\t__label__{}\n".format("QA")
f.write(line_cuted + lable)
qa_num+=1
#mogodb导出的数据
for line in open("./corpus/recall/爬虫抓取的问题.csv"):
line = re.sub("\s+", " ", line)
line_cuted = " ".join(jieba_cut(line.strip()))
lable = "\t__label__{}\n".format("QA")
f.write(line_cuted + lable)
qa_num += 1
f.close()
print(qa_num)
3.1.4 思考:
是否可以把文本分割为单个字作为特征呢?
修改上述代码,准备一份以单个字作为特征的符合要求的文本
3.2 模型的训练
import logging
import fastText
import pickle
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.DEBUG)
ft_model = fastText.train_supervised("./data/text_classify.txt",wordNgrams=1,epoch=20)
ft_model.save_model("./data/ft_classify.model")
训练完成后看看测试的结果
ft_model = fastText.load_model("./data/ft_classify.model")
textlist = [
# "人工智能 和 人工智障 有 啥 区别", #QA
# "我 来 学 python 是不是 脑袋 有 问题 哦", #QA
# "什么 是 python", #QA
# "人工智能 和 python 有 什么 区别", #QA
# "为什么 要 学 python", #QA
# "python 该 怎么 学", #CHAT
# "python", #QA
"jave", #CHAT
"php", #QA
"理想 很 骨感 ,现实 很 丰满",
"今天 天气 真好 啊",
"你 怎么 可以 这样 呢",
"哎呀 , 我 错 了",
]
ret = ft_model.predict(textlist)
print(ret)
3.2.2 模型的准确率该如何观察呢?
观察准备去,首先需要对文本进行划分,分为训练集和测试集,之后再使用测试集观察模型的准确率
3.3 模型的封装
为了在项目中更好的使用模型,需要对模型进行简单的封装,输入文本,返回结果
这里我们可以使用把单个字作为特征和把词语作为特征的手段结合起来实现
"""
构造模型进行预测
"""
import fastText
import config
from lib import cut
class Classify:
def __init__(self):
self.ft_word_model = fastText.load_model(config.fasttext_word_model_path)
self.ft_model = fastText.load_model(config.fasttext_model_path)
def is_qa(self,sentence_info):
python_qs_list = [" ".join(sentence_info["cuted_sentence"])]
result = self.ft_mode.predict(python_qs_list)
python_qs_list = [" ".join(cut(sentence_info["sentence"],by_word=True))]
words_result = self.ft_word_mode.predict(python_qs_list)
acc,word_acc = self.get_qa_prob(result,words_result)
if acc>0.95 or word_acc>0.95:
#是QA
return True
else:
return False
def get_qa_prob(self,result,words_result):
label, acc, word_label, word_acc = zip(*result, *words_result)
label = label[0]
acc = acc[0]
word_label = word_label[0]
word_acc = word_acc[0]
if label == "__label__chat":
acc = 1 - acc
if word_label == "__label__chat":
word_acc = 1 - word_acc
return acc,word_acc
fastText的原理剖析
目标
- 能够说出fasttext的架构
- 能够说出fasttext速度快的原因
- 能够说出fastText中层次化的softmax是如何实现的
1. fastText的模型架构
fastText的架构非常简单,有三层:输入层、隐含层、输出层(Hierarchical Softmax)
输入层:是对文档embedding之后的向量,包含有N-garm特征
隐藏层:是对输入数据的求和平均
输出层:是文档对应标签
如下图所示:
1.1 N-garm的理解
1.1.1 bag of word
bag of word 又称为bow,称为词袋。是一种只统计词频的手段。
例如:在机器学习的课程中通过朴素贝叶斯来预测文本的类别,我们学习的countVectorizer和TfidfVectorizer都可以理解为一种bow模型。
1.1.2 N-gram模型
但是在很多情况下,词袋模型是不满足我们的需求的。
例如:我爱她 和她爱我在词袋模型下面,概率完全相同,但是其含义确实差别非常大。
为了解决这个问题,就有了N-gram模型,它不仅考虑词频,还会考虑当前词前面的词语,比如我爱,她爱。
N-gram模型的描述是:第n个词出现与前n-1个词相关,而与其他任何词不相关。(当然在很多场景下和前n-1个词也会相关,但是为了简化问题,经常会这样去计算)
例如:I love deep learning这个句子,在n=2的情况下,可以表示为{i love},{love deep},{deep learning},n=3的情况下,可以表示为{I love deep},{love deep learning}。
在n=2的情况下,这个模型被称为Bi-garm(二元n-garm模型)
在n=3 的情况下,这个模型被称为Tri-garm(三元n-garm模型)
具体可以参考 ed3book chapter3
所以在fasttext的输入层,不仅有分词之后的词语,还有包含有N-gram的组合词语一起作为输入
2. fastText中的层次化的softmax-对传统softmax的优化方法1
为了提高效率,在fastText中计算分类标签的概率的时候,不再是使用传统的softmax来进行多分类的计算,而是使用的哈夫曼树(Huffman,也成为霍夫曼树),使用层次化的softmax(Hierarchial softmax)来进行概率的计算。
2.1 哈夫曼树和哈夫曼编码
2.1.1 哈夫曼树的定义
哈夫曼树概念:给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。
哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
2.1.2 哈夫曼树的相关概念
二叉树:每个节点最多有2个子树的有序树,两个子树分别称为左子树、右子树。有序的意思是:树有左右之分,不能颠倒
叶子节点:一棵树当中没有子结点的结点称为叶子结点,简称“叶子”
路径和路径长度:在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1。
结点的权及带权路径长度:若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。
树的带权路径长度:树的带权路径长度规定为所有叶子结点的带权路径长度之和
树的高度:树中结点的最大层次。包含n个结点的二叉树的高度至少为log2 (n+1)。
2.1.3 哈夫曼树的构造算法
- 把${W_1,W_2,W_3 \dots W_n}$看成n棵树的森林
- 在森林中选择两个根节点权值最小的树进行合并,作为一颗新树的左右子树,新树的根节点权值为左右子树的和
- 删除之前选择出的子树,把新树加入森林
- 重复2-3步骤,直到森林只有一棵树为止,概树就是所求的哈夫曼树
例如:圆圈中的表示每个词语出现的次数,以这些词语为叶子节点构造的哈夫曼树过程如下:
可见:
- 权重越大,距离根节点越近
- 叶子的个数为n,构造哈夫曼树中新增的节点的个数为n-1
2.2.1 哈夫曼编码
在数据通信中,需要将传送的文字转换成二进制的字符串,用0,1码的不同排列来表示字符。
例如,需传送的报文为AFTER DATA EAR ARE ART AREA,这里用到的字符集为A,E,R,T,F,D,各字母出现的次数为{8,4,5,3,1,1}。现要求为这些字母设计编码。要区别6个字母,最简单的二进制编码方式是等长编码,固定采用3位二进制,可分别用000、001、010、011、100、101对A,E,R,T,F,D进行编码发送
但是很明显,上述的编码的方式并不是最优的,即整理传送的字节数量并不是最少的。
为了提高数据传送的效率,同时为了保证任一字符的编码都不是另一个字符编码的前缀,这种编码称为前缀编码[前缀编码],可以使用哈夫曼树生成哈夫曼编码解决问题
可用字符集中的每个字符作为叶子结点生成一棵编码二叉树,为了获得传送报文的最短长度,可将每个字符的出现频率作为字符结点的权值赋予该结点上,显然字使用频率越小权值越小,权值越小叶子就越靠下,于是频率小编码长,频率高编码短,这样就保证了此树的最小带权路径长度效果上就是传送报文的最短长度
因此,求传送报文的最短长度问题转化为求由字符集中的所有字符作为叶子结点,由字符出现频率作为其权值所产生的哈夫曼树的问题。利用哈夫曼树来设计二进制的前缀编码,既满足前缀编码的条件,又保证报文编码总长最短。
下图中label1 .... label6分别表示A,E,R,T,F,D
2.3 梯度计算

上图中,红色为哈夫曼编码,即label5的哈夫曼编码为1001,那么此时如何定义条件概率$P(Label5|contex)$呢?
以Label5为例,从根节点到Label5中间经历了4次分支,每次分支都可以认为是进行了一次2分类,根据哈夫曼编码,可以把路径中的每个非叶子节点0认为是负类,1认为是正类(也可以把0认为是正类)
由机器学习课程中逻辑回归使用sigmoid函数进行2分类的过程中,一个节点被分为正类的概率是$\delta(X^{T}\theta) = \frac{1}{1+e^{-X^T\theta}}$,被分类负类的概率是:$1-\delta(X^T\theta)$,其中$\theta$就是图中非叶子节点对应的参数$\theta$。
对于从根节点出发,到达Label5一共经历4次2分类,将每次分类结果的概率写出来就是:
- 第一次:$P(1|X,\theta_1) = \delta(X^T\theta_1) $ ,即从根节点到23节点的概率是在知道X和$\theta_1$的情况下取值为1的概率
- 第二次:$P(0|X,\theta_2) =1- \delta(X^T\theta_2) $
- 第三次:$P(0 |X,\theta_3) =1- \delta(X^T\theta_4) $
- 第四次:$P(1|X,\theta_4) = \delta(X^T\theta_4) $
但是我们需要求的是$P(Label|contex)$, 他等于前4词的概率的乘积,公式如下($d_j^w$是第j个节点的哈夫曼编码) $$ P(Label|context) = \prod_{j=2}^5P(d_j|X,\theta_{j-1}) $$
其中: $$ P(d_j|X,\theta_{j-1}) = \left{ \begin{aligned} &\delta(X^T\theta_{j-1}), & d_j=1;\ &1-\delta(X^T\theta_{j-1}) & d_j=0; \end{aligned} \right. $$
或者也可以写成一个整体,把目标值作为指数,之后取log之后会前置: $$ P(d_j|X,\theta_{j-1}) = [\delta(X^T\theta_{j-1})]^{d_j} \cdot [1-\delta(X^T\theta_{j-1})]^{1-d_j} $$
在机器学习中的逻辑回归中,我们经常把二分类的损失函数(目标函数)定义为对数似然损失,即 $$ l =-\frac{1}{M} \sum_{label\in labels} log\ P(label|context) $$
式子中,求和符号表示的是使用样本的过程中,每一个label对应的概率取对数后的和,之后求取均值。
带入前面对$P(label|context)$的定义得到: $$ \begin{align*} l & = -\frac{1}{M}\sum_{label\in labels}log \prod_{j=2}{[\delta(X^T\theta_{j-1})]^{d_j} \cdot [1-\delta(X^T\theta_{j-1})]^{1-d_j}} \ & =-\frac{1}{M} \sum_{label\in labels} \sum_{j=2}{d_j\cdot log[\delta(X^T\theta_{j-1})]+ (1-d_j) \cdot log [1-\delta(X^T\theta_{j-1})]} \end{align*} $$ 有了损失函数之后,接下来就是对其中的$X,\theta$进行求导,并更新,最终还需要更新最开始的每个词语词向量
层次化softmax的好处:传统的softmax的时间复杂度为L(Labels的数量),但是使用层次化softmax之后时间复杂度的log(L) (二叉树高度和宽度的近似),从而在多分类的场景提高了效率
3. fastText中的negative sampling(负采样)-对传统softmax的优化方法2
negative sampling,即每次从除当前label外的其他label中,随机的选择几个作为负样本。具体的采样方法:
如果所有的label为$V$,那么我们就将一段长度为1的线段分成$V$份,每份对应所有label中的一类label。当然每个词对应的线段长度是不一样的,高频label对应的线段长,低频label对应的线段短。每个label的线段长度由下式决定: $$ len(w) = \frac{count(label)^{\alpha}}{\sum_{w \in labels} count(labels)^{\alpha}},a在fasttext中为0.75,即负采样的数量和原来词频的平方根成正比 $$ 在采样前,我们将这段长度为1的线段划分成$M$等份,这里$M>>V$,这样可以保证每个label对应的线段都会划分成对应的小块。而M份中的每一份都会落在某一个label对应的线段上。在采样的时候,我们只需要从$M$个位置中采样出neg个位置就行,此时采样到的每一个位置对应到的线段所属的词就是我们的负例。
简单的理解就是,从原来所有的样本中,等比例的选择neg个负样本作(遇到自己则跳过),作为训练样本,添加到训练数据中,和正例样本一起来进行训练。
Negative Sampling也是采用了二元逻辑回归来求解模型参数,通过负采样,我们得到了neg个负例,将正例定义为$label_0$,负例定义为$label_i,i=1,2,3...neg$
定义正例的概率为$P\left( label_{0}|\text {context}\right)=\sigma\left(x_{\mathrm{k}}^{T} \theta\right), y_{i}=1$
则负例的概率为:$P\left( label_{i}|\text {context}\right)=1-\sigma\left(x_{\mathrm{k}}^{T} \theta\right), y_{i}=0,i=1,2,3..neg$
此时对应的对数似然函数为: $$ L=\sum_{i=0}^{n e g} y_{i} \log \left(\sigma\left(x_{label_0}^{T} \theta\right)\right)+\left(1-y_{i}\right) \log \left(1-\sigma\left(x_{label_0}^{T} \theta\right)\right) $$ 具体的训练时候损失的计算过程(源代码已经更新):
可以看出:一个neg+1个样本进行了训练,得到了总的损失。
之后会使用梯度上升的方法进行梯度计算和参数更新,仅仅每次只用一波样本(一个正例和neg个反例)更新梯度,来进行迭代更新
具体的更新伪代码如下:
其中内部大括号部分为w相关参数的梯度计算过程,e为w的梯度和学习率的乘积,具体参考:word2vec 中的数学原理详解(五)基于 Negative Sampling 的模型_word2vec 皮果提-CSDN博客
好处:
- 提高训练速度,选择了部分数据进行计算损失,同时整个对每一个label而言都是一个二分类,损失计算更加简单,只需要让当前label的值的概率尽可能大,其他label的都为反例,概率会尽可能小
- 改进效果,增加部分负样本,能够模拟真实场景下的噪声情况,能够让模型的稳健性更强
闲聊机器人的介绍
目标
- 了解闲聊机器人是什么
介绍
在项目准备阶段我们知道,用户说了一句话后,会判断其意图,如果是想进行闲聊,那么就会调用闲聊模型返回结果,这是我们会在项目中实现的功能。
目前市面上的常见闲聊机器人有微软小冰这种类型的模型,很久之前还有小黄鸡这种体验更差的模型
常见的闲聊模型都是一种seq2seq的结构,在后面的课程中我们会学习并使用seq2seq来实现我们的闲聊机器人
Seq2Seq模型的原理
目标
- 知道seq2seq的常见应用场景
- 能够说出常见的seq2seq的结构
- 能够使用代码完成基础的seq2seq的结构
1. Seq2Seq的介绍
Sequence to sequence (seq2seq)是由encoder(编码器)和decoder(解码器)两个RNN的组成的。其中encoder负责对输入句子的理解,转化为context vector,decoder负责对理解后的句子的向量进行处理,解码,获得输出。上述的过程和我们大脑理解东西的过程很相似,听到一句话,理解之后,尝试组装答案,进行回答
那么此时,就有一个问题,在encoder的过程中得到的context vector作为decoder的输入,那么这样一个输入,怎么能够得到多个输出呢?
其实就是当前一步的输出,作为下一个单元的输入,然后得到结果
outputs = []
while True:
output = decoderd(output)
outputs.append(output)
那么循环什么时候停止呢?
在训练数据集中,可以再输出的最后面添加一个结束符<END>,如果遇到该结束符,则可以终止循环
outputs = []
while output!="<END>":
output = decoderd(output)
outputs.append(output)
这个结束符只是一个标记,很多人也会使用<EOS>(End Of Sentence)
总之:Seq2seq模型中的encoder接受一个长度为M的序列,得到1个 context vector,之后decoder把这一个context vector转化为长度为N的序列作为输出,从而构成一个M to N的模型,能够处理很多不定长输入输出的问题,比如:文本翻译,问答,文章摘要,关键字写诗等等
2. Seq2Seq模型的实现
下面,我们通过一个简单的列子,来看看普通的Seq2Seq模型应该如何实现。
需求:完成一个模型,实现往模型输入一串数字,输出这串数字+0
例如:
- 输入
123456789,输出1234567890; - 输入
52555568,输出525555680
2.1 实现流程
- 文本转化为序列(数字序列,
torch.LongTensor) - 使用序列,准备数据集,准备
Dataloader - 完成编码器
- 完成解码器
- 完成seq2seq模型
- 完成模型训练的逻辑,进行训练
- 完成模型评估的逻辑,进行模型评估
2.2 文本转化为序列
由于输入的是数字,为了把这写数字和词典中的真实数字进行对应,可以把这些数字理解为字符串
那么我们需要做的就是:
- 把字符串对应为数字
- 把数字转化为字符串
完成逻辑和之前相同,创建word_sequence.py文件,实现上述逻辑
class NumSequence:
UNK_TAG = "UNK" #未知词
PAD_TAG = "PAD" #填充词,实现文本对齐,即一个batch中的句子长度都是相同的,短句子会被padding
EOS_TAG = "EOS" #句子的开始
SOS_TAG = "SOS" #句子的结束
UNK = 0
PAD = 1
EOS = 2
SOS = 3
def __init__(self):
self.dict = {
self.UNK_TAG : self.UNK,
self.PAD_TAG : self.PAD,
self.EOS_TAG : self.EOS,
self.SOS_TAG : self.SOS
}
#得到字符串和数字对应的字典
for i in range(10):
self.dict[str(i)] = len(self.dict)
#得到数字和字符串对应的字典
self.index2word = dict(zip(self.dict.values(),self.dict.keys()))
def __len__(self):
return len(self.dict)
def transform(self,sequence,max_len=None,add_eos=False):
"""
sequence:句子
max_len :句子的最大长度
add_eos:是否添加结束符
"""
sequence_list = list(str(sequence))
seq_len = len(sequence_list)+1 if add_eos else len(sequence_list)
if add_eos and max_len is not None:
assert max_len>= seq_len, "max_len 需要大于seq+eos的长度"
_sequence_index = [self.dict.get(i,self.UNK) for i in sequence_list]
if add_eos:
_sequence_index += [self.EOS]
if max_len is not None:
sequence_index = [self.PAD]*max_len
sequence_index[:seq_len] = _sequence_index
return sequence_index
else:
return _sequence_index
def inverse_transform(self,sequence_index):
result = []
for i in sequence_index:
if i==self.EOS:
break
result.append(self.index2word.get(int(i),self.UNK_TAG))
return result
# 实例化,供后续调用
num_sequence = NumSequence()
if __name__ == '__main__':
num_sequence = NumSequence()
print(num_sequence.dict)
print(num_sequence.index2word)
print(num_sequence.transform("1231230",add_eos=True))
2.3 准备数据集
2.3.1 准备Dataset
这里,我们使用随机创建的[0,100000000]的整型,来准备数据集
from torch.utils.data import Dataset,DataLoader
import numpy as np
from word_sequence import num_sequence
import torch
import config
class RandomDataset(Dataset):
def __init__(self):
super(RandomDataset,self).__init__()
self.total_data_size = 500000
np.random.seed(10)
self.total_data = np.random.randint(1,100000000,size=[self.total_data_size])
def __getitem__(self, idx):
"""返回input,target,input_length,target_length(真实长度)"""
input = str(self.total_data[idx])
return input, input+ "0",len(input),len(input)+1
def __len__(self):
return self.total_data_size
通过随机数的结果,可以看到,大部分的数字长度为8,在目标值后面添加上0和EOS之后,最大长度为10
所以常见config配置文件,添加上max_len:文本最大长度,方便后续的修改
2.3.2 准备DataLoader
在准备DataLoader的过程中,可以通过定义的collate_fn来实现对dataset中batch数据的处理
其中需要注意:
- 需要对batch中的数据进行排序,根据数据的真实长度进行降序排序(后面需要用到)
- 需要调用
文本序列化的方法,把文本进行序列化的操作,同时target需要进行add eos的操作 - 最后返回序列的LongTensor格式
- 在
DataLoader中有drop_last参数,当数据量无法被batch_size整除时,最后一个batch的数据个数和之前的数据个数长度不同,可以考虑进行删除
def collate_fn(batch):
#1. 对batch进行排序,按照长度从长到短的顺序排序
batch = sorted(batch,key=lambda x:x[3],reverse=True)
input,target,input_length,target_length = zip(*batch)
#2.进行padding的操作
input = torch.LongTensor([num_sequence.transform(i,max_len=config.max_len) for i in input])
target = torch.LongTensor([num_sequence.transform(i,max_len=config.max_len,add_eos=True) for i in target])
input_length = torch.LongTensor(input_length)
target_length = torch.LongTensor(target_length)
return input,target,input_length,target_length
data_loader = DataLoader(dataset=RandomDataset(),batch_size=config.batch_size,collate_fn=collate_fn,drop_last=True)
2.4 准备编码器
编码器(encoder)的目的就是为了对文本进行编码,把编码后的结果交给后续的程序使用,所以在这里我们可以使用Embedding+GRU的结构来使用,使用最后一个time step的输出(hidden state)作为句子的编码结果
注意点:
- Embedding和GRU的参数,这里我们让GRU中batch放在前面
- 输出结果的形状
- 在LSTM和GRU中,每个
time step的输入会进行计算,得到结果,整个过程是一个和句子长度相关的一个循环,手动实现速度较慢- pytorch中实现了
nn.utils.rnn.pack_padded_sequence对padding后的句子进行打包的操作能够更快获得LSTM or GRU的结果 - 同时实现了
nn.utils.rnn.pad_packed_sequence对打包的内容进行解包的操作
- pytorch中实现了
nn.utils.rnn.pack_padded_sequence使用过程中需要对batch中的内容按照句子的长度降序排序
实现代码如下:
import torch.nn as nn
from word_sequence import num_sequence
import config
class NumEncoder(nn.Module):
def __init__(self):
super(NumEncoder,self).__init__()
self.vocab_size = len(num_sequence)
self.dropout = config.dropout
self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=config.embedding_dim,padding_idx=num_sequence.PAD)
self.gru = nn.GRU(input_size=config.embedding_dim,
hidden_size=config.hidden_size,
num_layers=1,
batch_first=True)
def forward(self, input,input_length):
"""
input:[batch_size,max_len]
input_length:[batch_size]
"""
embeded = self.embedding(input) #[batch_size,max_len , embedding_dim]
#对文本对齐之后的句子进行打包,能够加速在LSTM or GRU中的计算过程
embeded = nn.utils.rnn.pack_padded_sequence(embeded,lengths=input_length,batch_first=True)
#hidden:[1,batch_size,vocab_size]
out,hidden = self.gru(embeded)
#对前面打包后的结果再进行解包
out,outputs_length = nn.utils.rnn.pad_packed_sequence(out,batch_first=True,padding_value=num_sequence.PAD)
# out [batch_size,seq_len,hidden_size]
return out,hidden
2.5 实现解码器
加码器主要负责实现对编码之后结果的处理,得到预测值,为后续计算损失做准备
此时需要思考:
-
使用什么样的损失函数,预测值需要是什么格式的
- 结合之前的经验,我们可以理解为当前的问题是一个分类的问题,即每次的输出其实对选择一个概率最大的词
- 真实值的形状是
[batch_size,max_len],从而我们知道输出的结果需要是一个[batch_size,max_len,vocab_size]的形状 - 即预测值的最后一个维度进行计算log_softmax,然后和真实值进行相乘,从而得到损失
-
如何把编码结果
[1,batch_size,hidden_size]进行操作,得到预测值。解码器也是一个RNN,即也可以使用LSTM or GRU的结构,所以在解码器中:-
通过循环,每次计算的一个time step的内容
-
编码器的结果作为初始的隐层状态,定义一个
[batch_size,1]的全为SOS的数据作为最开始的输入,告诉解码器,要开始工作了 -
通过解码器预测一个输出
[batch_size,hidden_size](会进行形状的调整为[batch_size,vocab_size]),把这个输出作为输入再使用解码器进行解码 -
上述是一个循环,循环次数就是句子的最大长度,那么就可以得到
max_len个输出 -
把所有输出的结果进行concate,得到
[batch_size,max_len,vocab_size]
-
-
在RNN的训练过程中,使用前一个预测的结果作为下一个step的输入,可能会导致
一步错,步步错的结果,如果提高模型的收敛速度?- 可以考虑在训练的过程中,把真实值作为下一步的输入,这样可以避免
步步错的局面 - 同时在使用真实值的过程中,仍然使用预测值作为下一步的输入,两种输入随机使用
- 上述这种机制我们把它称为
Teacher forcing,就像是一个指导老师,在每一步都会对我们的行为进行纠偏,从而达到在多次训练之后能够需要其中的规律 - 
- 可以考虑在训练的过程中,把真实值作为下一步的输入,这样可以避免
import torch
import torch.nn as nn
import config
import random
import torch.nn.functional as F
from word_sequence import num_sequence
class NumDecoder(nn.Module):
def __init__(self):
super(NumDecoder,self).__init__()
self.max_seq_len = config.max_len
self.vocab_size = len(num_sequence)
self.embedding_dim = config.embedding_dim
self.dropout = config.dropout
self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=num_sequence.PAD)
self.gru = nn.GRU(input_size=self.embedding_dim,
hidden_size=config.hidden_size,
num_layers=1,
batch_first=True,
dropout=self.dropout)
self.log_softmax = nn.LogSoftmax()
self.fc = nn.Linear(config.hidden_size,self.vocab_size)
def forward(self, encoder_hidden,target,target_length):
# encoder_hidden [batch_size,hidden_size]
# target [batch_size,max_len]
#初始的全为SOS的输入
decoder_input = torch.LongTensor([[num_sequence.SOS]]*config.batch_size)
#解码器的输出,用来后保存所有的输出结果
decoder_outputs = torch.zeros(config.batch_size,config.max_len,self.vocab_size)
decoder_hidden = encoder_hidden #[batch_size,hidden_size]
for t in range(config.max_len):
decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
#在不同的time step上进行复制,decoder_output_t [batch_size,vocab_size]
decoder_outputs[:,t,:] = decoder_output_t
#在训练的过程中,使用 teacher forcing,进行纠偏
use_teacher_forcing = random.random() > 0.5
if use_teacher_forcing:
#下一次的输入使用真实值
decoder_input =target[:,t].unsqueeze(1) #[batch_size,1]
else:
#使用预测值,topk中k=1,即获取最后一个维度的最大的一个值
value, index = torch.topk(decoder_output_t, 1) # index [batch_size,1]
decoder_input = index
return decoder_outputs,decoder_hidden
def forward_step(self,decoder_input,decoder_hidden):
"""
:param decoder_input:[batch_size,1]
:param decoder_hidden: [1,batch_size,hidden_size]
:return: out:[batch_size,vocab_size],decoder_hidden:[1,batch_size,didden_size]
"""
embeded = self.embedding(decoder_input) #embeded: [batch_size,1 , embedding_dim]
out,decoder_hidden = self.gru(embeded,decoder_hidden) #out [1, batch_size, hidden_size]
out = out.squeeze(0) #去除第0维度的1
#进行全连接形状变化,同时进行求取log_softmax
out = F.log_softmax(self.fc(out),dim=-1)#out [batch_Size,1, vocab_size]
out = out.squeeze(1)
return out,decoder_hidden
2.6 完成seq2seq模型
调用之前的encoder和decoder,完成模型的搭建
import torch
import torch.nn as nn
class Seq2Seq(nn.Module):
def __init__(self,encoder,decoder):
super(Seq2Seq,self).__init__()
self.encoder = encoder
self.decoder = decoder
def forward(self, input,target,input_length,target_length):
#进行编码
encoder_outputs,encoder_hidden = self.encoder(input,input_length)
#进行解码
decoder_outputs,decoder_hidden = self.decoder(encoder_hidden,target,target_length)
return decoder_outputs,decoder_hidden
2.7 完成训练逻辑
思路流程和之前相同
import torch
import config
from torch import optim
import torch.nn as nn
from encoder import NumEncoder
from decoder import NumDecoder
from seq2seq import Seq2Seq
from dataset import data_loader as train_dataloader
from word_sequence import num_sequence
encoder = NumEncoder()
decoder = NumDecoder()
model = Seq2Seq(encoder,decoder)
print(model)
#自定义初始化参数
#for name, param in model.named_parameters():
# if 'bias' in name:
# torch.nn.init.constant_(param, 0.0)
# elif 'weight' in name:
# torch.nn.init.xavier_normal_(param)
# model.load_state_dict(torch.load("model/seq2seq_model.pkl"))
optimizer = optim.Adam(model.parameters())
# optimizer.load_state_dict(torch.load("model/seq2seq_optimizer.pkl"))
criterion= nn.NLLLoss(ignore_index=num_sequence.PAD,reduction="mean")
def get_loss(decoder_outputs,target):
#很多时候如果tensor进行了转置等操作,直接调用view进行形状的修改是无法成功的
#target = target.contiguous().view(-1) #[batch_size*max_len]
target = target.view(-1)
decoder_outputs = decoder_outputs.view(config.batch_size*config.max_len,-1)
return criterion(decoder_outputs,target)
def train(epoch):
for idx,(input,target,input_length,target_len) in enumerate(train_dataloader):
optimizer.zero_grad()
##[seq_len,batch_size,vocab_size] [batch_size,seq_len]
decoder_outputs,decoder_hidden = model(input,target,input_length,target_len)
loss = get_loss(decoder_outputs,target)
loss.backward()
optimizer.step()
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, idx * len(input), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item()))
torch.save(model.state_dict(), "model/seq2seq_model.pkl")
torch.save(optimizer.state_dict(), 'model/seq2seq_optimizer.pkl')
if __name__ == '__main__':
for i in range(10):
train(i)
2.8 完成模型评估逻辑
完成评估逻辑,和decoder中的训练过程稍微不同,可以在其中新建evaluation的方法,传入encoder_hidden,得到预测的结果
def evaluation(self,encoder_hidden): #[1, 20, 14]
batch_size = encoder_hidden.size(1) #评估的时候和训练的batch_size不同,不适用config的配置
decoder_input = torch.LongTensor([[num_sequence.SOS] * batch_size])
decoder_outputs = torch.zeros(batch_size,config.max_len, self.vocab_size) # [batch_size,seq_len,vocab_size]
decoder_hidden = encoder_hidden
#评估,不再使用teacher forcing,完全使用预测值作为下一次的输入
for t in range(config.max_len):
decoder_output_t, decoder_hidden = self.forward_step(decoder_input, decoder_hidden)
decoder_outputs[:,t,:] = decoder_output_t
value, index = torch.topk(decoder_output_t, 1) # index [20,1]
decoder_input = index.transpose(0, 1)
#获取输出的id
decoder_indices = [] #[[1,2,4],[23,3,2]]
for i in range(config.max_len):
value,index = torch.topk(decoder_outputs[:,i,:],k=1,dim=-1)
decoder_indices.append(index.view(-1).numpy())
#transpose 调整为按句子输出
decoder_indices = np.array(decoder_indices).transpose()
return decoder_indices
之后再seq2seq的model中,添加evaluation的逻辑
import torch
import torch.nn as nn
class Seq2Seq(nn.Module):
def __init__(self,encoder,decoder):
super(Seq2Seq,self).__init__()
self.encoder = encoder
self.decoder = decoder
def forward(self, input,target,input_length,target_length):
encoder_outputs,encoder_hidden = self.encoder(input,input_length)
decoder_outputs,decoder_hidden = self.decoder(encoder_hidden,target,target_length)
return decoder_outputs,decoder_hidden
def evaluation(self,inputs,input_length):
encoder_outputs,encoder_hidden = self.encoder(inputs,input_length)
decoded_sentence = self.decoder.evaluation(encoder_hidden)
return decoded_sentence
创建eval.py,完成模型评估的逻辑
import torch
import config
from torch import optim
import torch.nn as nn
from encoder import NumEncoder
from decoder import NumDecoder
from seq2seq import Seq2Seq
from dataset import data_loader as train_dataloader
from word_sequence import num_sequence
import numpy as np
import random
encoder = NumEncoder()
decoder = NumDecoder()
model = Seq2Seq(encoder,decoder)
model.load_state_dict(torch.load("model/seq2seq_model.pkl"))
def evalaute():
data = [str(i) for i in np.random.randint(0, 100000000, [10])]
data = sorted(data,key=lambda x:len(x),reverse=True)
print(data)
_data_length = torch.LongTensor([len(i) for i in data])
_data = torch.LongTensor([num_sequence.transform(i,max_len=config.max_len) for i in data])
output = seq2seq.evaluate(_data,_data_length)
print([num_sequence.inverse_transform(i) for i in output])
if __name__ == '__main__':
evalaute()
在model训练一个epoch之后,loss已经很低了,评估输出如下(为True表示预测正确):
39304187 >>>>> 393041870 True 41020882 >>>>> 410208820 True 85784317 >>>>> 857843170 True 1394232 >>>>> 13942320 True 44548446 >>>>> 445484460 True 49457730 >>>>> 494577300 True 82451872 >>>>> 824518720 True 64380958 >>>>> 643809580 True 97501723 >>>>> 975017230 True 21656800 >>>>> 216568000 True
完整代码参考:PytorchTutorial/seq2seq at master · SpringMagnolia/PytorchTutorial · GitHub
Seq2Seq实现闲聊机器人
目标
- 知道如何处理文本数据
- 知道如何使用seq2seq完成闲聊机器人代码的编写
1. 准备训练数据
单轮次的聊天数据非常不好获取,所以这里我们从github上使用一些开放的数据集来训练我们的闲聊模型
数据地址:GitHub - codemayq/chinese-chatbot-corpus: 中文公开聊天语料库
主要的数据有两个:
2. 数据的处理和保存
由于数据中存到大量的噪声,可以对其进行基础的处理,然后分别把input和target使用两个文件保存,即input中的第N行尾问,target的第N行为答
后续可能我们可能会把单个字作为特征(存放在input_word.txt),也可能会把词语作为特征(input.txt)
2.1 小黄鸡的语料的处理
def format_xiaohuangji_corpus(word=False):
"""处理小黄鸡的语料"""
if word:
corpus_path = "./chatbot/corpus/xiaohuangji50w_nofenci.conv"
input_path = "./chatbot/corpus/input_word.txt"
output_path = "./chatbot/corpus/output_word.txt"
else:
corpus_path = "./chatbot/corpus/xiaohuangji50w_nofenci.conv"
input_path = "./chatbot/corpus/input.txt"
output_path = "./chatbot/corpus/output.txt"
f_input = open(input_path,"a")
f_output = open(output_path,"a")
pair = []
for line in tqdm(open(corpus_path),ascii=True):
if line.strip() == "E":
if not pair:
continue
else:
assert len(pair) == 2,"长度必须是2"
if len(pair[0].strip())>=1 and len(pair[1].strip())>=1:
f_input.write(pair[0]+"\n")
f_output.write(pair[1]+"\n")
pair = []
elif line.startswith("M"):
line = line[1:]
if word:
pair.append(" ".join(list(line.strip())))
else:
pair.append(" ".join(jieba_cut(line.strip())))
2.2 微博语料的处理
def format_weibo(word=False):
"""
微博数据存在一些噪声,未处理
:return:
"""
if word:
origin_input = "./chatbot/corpus/stc_weibo_train_post"
input_path = "./chatbot/corpus/input_word.txt"
origin_output = "./chatbot/corpus/stc_weibo_train_response"
output_path = "./chatbot/corpus/output_word.txt"
else:
origin_input = "./chatbot/corpus/stc_weibo_train_post"
input_path = "./chatbot/corpus/input.txt"
origin_output = "./chatbot/corpus/stc_weibo_train_response"
output_path = "./chatbot/corpus/output.txt"
f_input = open(input_path,"a")
f_output = open(output_path, "a")
with open(origin_input) as in_o,open(origin_output) as out_o:
for _in,_out in tqdm(zip(in_o,out_o),ascii=True):
_in = _in.strip()
_out = _out.strip()
if _in.endswith(")") or _in.endswith("」") or _in.endswith(")"):
_in = re.sub("(.*)|「.*?」|\(.*?\)"," ",_in)
_in = re.sub("我在.*?alink|alink|(.*?\d+x\d+.*?)|#|】|【|-+|_+|via.*?:*.*"," ",_in)
_in = re.sub("\s+"," ",_in)
if len(_in)<1 or len(_out)<1:
continue
if word:
_in = re.sub("\s+","",_in) #转化为一整行,不含空格
_out = re.sub("\s+","",_out)
if len(_in)>=1 and len(_out)>=1:
f_input.write(" ".join(list(_in)) + "\n")
f_output.write(" ".join(list(_out)) + "\n")
else:
if len(_in) >= 1 and len(_out) >= 1:
f_input.write(_in.strip()+"\n")
f_output.write(_out.strip()+"\n")
f_input.close()
f_output.close()
2.3 处理后的结果
3. 构造文本序列化和反序列化方法
和之前的操作相同,需要把文本能转化为数字,同时还需实现方法把数字转化为文本
# word_sequence.py
import config
import pickle
class Word2Sequence():
UNK_TAG = "UNK"
PAD_TAG = "PAD"
SOS_TAG = "SOS"
EOS_TAG = "EOS"
UNK = 0
PAD = 1
SOS = 2
EOS = 3
def __init__(self):
self.dict = {
self.UNK_TAG :self.UNK,
self.PAD_TAG :self.PAD,
self.SOS_TAG :self.SOS,
self.EOS_TAG :self.EOS
}
self.count = {}
self.fited = False
def to_index(self,word):
"""word -> index"""
assert self.fited == True,"必须先进行fit操作"
return self.dict.get(word,self.UNK)
def to_word(self,index):
"""index -> word"""
assert self.fited , "必须先进行fit操作"
if index in self.inversed_dict:
return self.inversed_dict[index]
return self.UNK_TAG
def __len__(self):
return len(self.dict)
def fit(self, sentence):
"""
:param sentence:[word1,word2,word3]
:param min_count: 最小出现的次数
:param max_count: 最大出现的次数
:param max_feature: 总词语的最大数量
:return:
"""
for a in sentence:
if a not in self.count:
self.count[a] = 0
self.count[a] += 1
self.fited = True
def build_vocab(self, min_count=1, max_count=None, max_feature=None):
# 比最小的数量大和比最大的数量小的需要
if min_count is not None:
self.count = {k: v for k, v in self.count.items() if v >= min_count}
if max_count is not None:
self.count = {k: v for k, v in self.count.items() if v <= max_count}
# 限制最大的数量
if isinstance(max_feature, int):
count = sorted(list(self.count.items()), key=lambda x: x[1])
if max_feature is not None and len(count) > max_feature:
count = count[-int(max_feature):]
for w, _ in count:
self.dict[w] = len(self.dict)
else:
for w in sorted(self.count.keys()):
self.dict[w] = len(self.dict)
# 准备一个index->word的字典
self.inversed_dict = dict(zip(self.dict.values(), self.dict.keys()))
def transform(self, sentence,max_len=None,add_eos=False):
"""
实现吧句子转化为数组(向量)
:param sentence:
:param max_len:
:return:
"""
assert self.fited, "必须先进行fit操作"
r = [self.to_index(i) for i in sentence]
if max_len is not None:
if max_len>len(sentence):
if add_eos:
r+=[self.EOS]+[self.PAD for _ in range(max_len-len(sentence)-1)]
else:
r += [self.PAD for _ in range(max_len - len(sentence))]
else:
if add_eos:
r = r[:max_len-1]
r += [self.EOS]
else:
r = r[:max_len]
else:
if add_eos:
r += [self.EOS]
# print(len(r),r)
return r
def inverse_transform(self,indices):
"""
实现从数组 转化为 向量
:param indices: [1,2,3....]
:return:[word1,word2.....]
"""
sentence = []
for i in indices:
word = self.to_word(i)
sentence.append(word)
return sentence
#之后导入该word_sequence使用
word_sequence = pickle.load(open("./pkl/ws.pkl","rb")) if not config.use_word else pickle.load(open("./pkl/ws_word.pkl","rb"))
if __name__ == '__main__':
from word_sequence import Word2Sequence
from tqdm import tqdm
import pickle
word_sequence = Word2Sequence()
#词语级别
input_path = "../corpus/input.txt"
target_path = "../corpus/output.txt"
for line in tqdm(open(input_path).readlines()):
word_sequence.fit(line.strip().split())
for line in tqdm(open(target_path).readlines()):
word_sequence.fit(line.strip().split())
#使用max_feature=5000个数据
word_sequence.build_vocab(min_count=5,max_count=None,max_feature=5000)
print(len(word_sequence))
pickle.dump(word_sequence,open("./pkl/ws.pkl","wb"))
4. 构建Dataset和DataLoader
创建dataset.py 文件,准备数据集
import torch
import config
from torch.utils.data import Dataset,DataLoader
from word_sequence import word_sequence
class ChatDataset(Dataset):
def __init__(self):
super(ChatDataset,self).__init__()
input_path = "../corpus/input.txt"
target_path = "../corpus/output.txt"
if config.use_word:
input_path = "../corpus/input_word.txt"
target_path = "../corpus/output_word.txt"
self.input_lines = open(input_path).readlines()
self.target_lines = open(target_path).readlines()
assert len(self.input_lines) == len(self.target_lines) ,"input和target文本的数量必须相同"
def __getitem__(self, index):
input = self.input_lines[index].strip().split()
target = self.target_lines[index].strip().split()
if len(input) == 0 or len(target)==0:
input = self.input_lines[index+1].strip().split()
target = self.target_lines[index+1].strip().split()
#此处句子的长度如果大于max_len,那么应该返回max_len
return input,target,min(len(input),config.max_len),min(len(target),config.max_len)
def __len__(self):
return len(self.input_lines)
def collate_fn(batch):
#1.排序
batch = sorted(batch,key=lambda x:x[2],reverse=True)
input, target, input_length, target_length = zip(*batch)
# 2.进行padding的操作
input = torch.LongTensor([word_sequence.transform(i, max_len=config.max_len) for i in input])
target = torch.LongTensor([word_sequence.transform(i, max_len=config.max_len, add_eos=True) for i in target])
input_length = torch.LongTensor(input_length)
target_length = torch.LongTensor(target_length)
return input, target, input_length, target_length
data_loader = DataLoader(dataset=ChatDataset(),batch_size=config.batch_size,shuffle=True,collate_fn=collate_fn,drop_last=True)
if __name__ == '__main__':
for idx, (input, target, input_lenght, target_length) in enumerate(data_loader):
print(idx)
print(input)
print(target)
print(input_lenght)
print(target_length)
5. 完成encoder编码器逻辑
import torch.nn as nn
from word_sequence import word_sequence
import config
class Encoder(nn.Module):
def __init__(self):
super(Encoder,self).__init__()
self.vocab_size = len(word_sequence)
self.dropout = config.dropout
self.embedding_dim = config.embedding_dim
self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=word_sequence.PAD)
self.gru = nn.GRU(input_size=self.embedding_dim,
hidden_size=config.hidden_size,
num_layers=1,
batch_first=True,
dropout=config.dropout)
def forward(self, input,input_length):
embeded = self.embedding(input)
embeded = nn.utils.rnn.pack_padded_sequence(embeded,lengths=input_length,batch_first=True)
#hidden:[1,batch_size,vocab_size]
out,hidden = self.gru(embeded)
out,outputs_length = nn.utils.rnn.pad_packed_sequence(out,batch_first=True,padding_value=word_sequence.PAD)
#hidden [1,batch_size,hidden_size]
return out,hidden
6. 完成decoder解码器的逻辑
import torch
import torch.nn as nn
import config
import random
import torch.nn.functional as F
from word_sequence import word_sequence
class Decoder(nn.Module):
def __init__(self):
super(Decoder,self).__init__()
self.max_seq_len = config.max_len
self.vocab_size = len(word_sequence)
self.embedding_dim = config.embedding_dim
self.dropout = config.dropout
self.embedding = nn.Embedding(num_embeddings=self.vocab_size,embedding_dim=self.embedding_dim,padding_idx=word_sequence.PAD)
self.gru = nn.GRU(input_size=self.embedding_dim,
hidden_size=config.hidden_size,
num_layers=1,
batch_first=True,
dropout=self.dropout)
self.log_softmax = nn.LogSoftmax()
self.fc = nn.Linear(config.hidden_size,self.vocab_size)
def forward(self, encoder_hidden,target,target_length):
# encoder_hidden [batch_size,hidden_size]
# target [batch_size,seq-len]
decoder_input = torch.LongTensor([[word_sequence.SOS]]*config.batch_size).to(config.device)
decoder_outputs = torch.zeros(config.batch_size,config.max_len,self.vocab_size).to(config.device) #[batch_size,seq_len,14]
decoder_hidden = encoder_hidden #[batch_size,hidden_size]
for t in range(config.max_len):
decoder_output_t , decoder_hidden = self.forward_step(decoder_input,decoder_hidden)
decoder_outputs[:,t,:] = decoder_output_t
value, index = torch.topk(decoder_output_t, 1) # index [batch_size,1]
decoder_input = index
return decoder_outputs,decoder_hidden
def forward_step(self,decoder_input,decoder_hidden):
"""
:param decoder_input:[batch_size,1]
:param decoder_hidden: [1,batch_size,hidden_size]
:return: out:[batch_size,vocab_size],decoder_hidden:[1,batch_size,didden_size]
"""
embeded = self.embedding(decoder_input) #embeded: [batch_size,1 , embedding_dim]
out,decoder_hidden = self.gru(embeded,decoder_hidden) #out [1, batch_size, hidden_size]
out = out.squeeze(0)
out = F.log_softmax(self.fc(out),dim=-1)#[batch_Size, vocab_size]
out = out.squeeze(1)
# print("out size:",out.size(),decoder_hidden.size())
return out,decoder_hidden
7.完成seq2seq的模型
import torch
import torch.nn as nn
class Seq2Seq(nn.Module):
def __init__(self,encoder,decoder):
super(Seq2Seq,self).__init__()
self.encoder = encoder
self.decoder = decoder
def forward(self, input,target,input_length,target_length):
encoder_outputs,encoder_hidden = self.encoder(input,input_length)
decoder_outputs,decoder_hidden = self.decoder(encoder_hidden,target,target_length)
return decoder_outputs,decoder_hidden
def evaluation(self,inputs,input_length):
encoder_outputs,encoder_hidden = self.encoder(inputs,input_length)
decoded_sentence = self.decoder.evaluation(encoder_hidden)
return decoded_sentence
8. 完成训练逻辑
为了加速训练,可以考虑在gpu上运行,那么在我们自顶一个所以的tensor和model都需要转化为CUDA支持的类型。
当前的数据量为500多万条,在GTX1070(8G显存)上训练,大概需要90分一个epoch,耐心的等待吧
import torch
import config
from torch import optim
import torch.nn as nn
from encoder import Encoder
from decoder import Decoder
from seq2seq import Seq2Seq
from dataset import data_loader as train_dataloader
from word_sequence import word_sequence
encoder = Encoder()
decoder = Decoder()
model = Seq2Seq(encoder,decoder)
#device在config文件中实现
model.to(config.device)
print(model)
model.load_state_dict(torch.load("model/seq2seq_model.pkl"))
optimizer = optim.Adam(model.parameters())
optimizer.load_state_dict(torch.load("model/seq2seq_optimizer.pkl"))
criterion= nn.NLLLoss(ignore_index=word_sequence.PAD,reduction="mean")
def get_loss(decoder_outputs,target):
target = target.view(-1) #[batch_size*max_len]
decoder_outputs = decoder_outputs.view(config.batch_size*config.max_len,-1)
return criterion(decoder_outputs,target)
def train(epoch):
for idx,(input,target,input_length,target_len) in enumerate(train_dataloader):
input = input.to(config.device)
target = target.to(config.device)
input_length = input_length.to(config.device)
target_len = target_len.to(config.device)
optimizer.zero_grad()
##[seq_len,batch_size,vocab_size] [batch_size,seq_len]
decoder_outputs,decoder_hidden = model(input,target,input_length,target_len)
loss = get_loss(decoder_outputs,target)
loss.backward()
optimizer.step()
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, idx * len(input), len(train_dataloader.dataset),
100. * idx / len(train_dataloader), loss.item()))
torch.save(model.state_dict(), "model/seq2seq_model.pkl")
torch.save(optimizer.state_dict(), 'model/seq2seq_optimizer.pkl')
if __name__ == '__main__':
for i in range(10):
train(i)
训练10个epoch之后的效果如下,可以看出损失依然很高:
Train Epoch: 9 [2444544/4889919 (50%)] Loss: 4.923604
Train Epoch: 9 [2444800/4889919 (50%)] Loss: 4.364594
Train Epoch: 9 [2445056/4889919 (50%)] Loss: 4.613254
Train Epoch: 9 [2445312/4889919 (50%)] Loss: 4.143538
Train Epoch: 9 [2445568/4889919 (50%)] Loss: 4.412729
Train Epoch: 9 [2445824/4889919 (50%)] Loss: 4.516526
Train Epoch: 9 [2446080/4889919 (50%)] Loss: 4.124945
Train Epoch: 9 [2446336/4889919 (50%)] Loss: 4.777015
Train Epoch: 9 [2446592/4889919 (50%)] Loss: 4.358538
Train Epoch: 9 [2446848/4889919 (50%)] Loss: 4.513412
Train Epoch: 9 [2447104/4889919 (50%)] Loss: 4.202757
Train Epoch: 9 [2447360/4889919 (50%)] Loss: 4.589584
9.小结
效果不好
Attention的原理和实现
目标
- 知道Attention的作用
- 知道Attention的实现机制
- 能够使用代码完成Attention代码的编写
1. Attention的介绍
在普通的RNN结构中,Encoder需要把一个句子转化为一个向量,然后在Decoder中使用,这就要求Encoder把源句子中所有的信息都包含进去,但是当句子长度过长的时候,这个要求就很难达到,或者说会产生瓶颈(比如,输入一篇文章等场长内容),当然我们可以使用更深的RNN和大多的单元来解决这个问题,但是这样的代价也很大。那么有没有什么方法能够优化现有的RNN结构呢?
为此,Bahdanau等人在2015年提出了Attenion机制,Attention翻译成为中文叫做注意力,把这种模型称为Attention based model。就像我们自己看到一副画,我们能够很快的说出画的主要内容,而忽略画中的背景,因为我们注意的,更关注的往往是其中的主要内容。
通过这种方式,在我们的RNN中,我们有通过LSTM或者是GRU得到的所有信息,那么这些信息中只去关注重点,而不需要在Decoder的每个time step使用全部的encoder的信息,这样就可以解决第一段所说的问题了
那么现在要讲的Attention机制就能够帮助我们解决这个问题
2. Attenion的实现机制
假设我们现在有一个文本翻译的需求,即机器学习翻译为machine learning。那么这个过程通过前面所学习的Seq2Seq就可以实现
上图的左边是Encoder,能够得到hidden_state在右边使用
Deocder中蓝色方框中的内容,是为了提高模型的训练速度而使用teacher forcing手段,否则的话会把前一次的输出作为下一次的输入(但是在Attention模型中不再是这样了)
那么整个过程中如果使用Attention应该怎么做呢?
在之前我们把encoder的最后一个输出,作为decoder的初始的隐藏状态,现在我们不再这样做
2.1 Attention的实现过程
-
初始化一个Decoder的隐藏状态$z_0$
-
这个$z_o$会和encoder第一个time step的output进行match操作(或者是socre操作),得到$\alpha_0^1$ ,这里的match可以使很多中操作,比如:
-
encoder中的每个output都和$z_0$进行计算之后,得到的结果进行softmax,让他们的和为1(可以理解为权重)
-
之后把所有的softmax之后的结果和原来encoder的输出$h_i$进行相加求和得到$c^0$ $$ 即: c^0 = \sum\hat{\alpha}_0^ih^i $$
-
得到$c^0$之后,把它作为decoder的input,同和传入初始化的$z^0$,得到第一个time step的输出和hidden_state($Z^1$)
-
把$Z_1$再和所有的encoder的output进行match操作,得到的结果进行softmax之后作为权重和encoder的每个timestep的结果相乘求和得到$c^1$
-
再把$c^1$作为decoder的input,和$Z^1$作为输入得到下一个输出,如此循环,只到最终decoder的output为终止符
-
上述参考:Hung-yi Lee
-
整个过程写成数学公式如下: $$ \begin{align*} \alpha_{ij} &= \frac{exp(score(h_i,\overline{h}_j))}{\sum exp(score(h_n,\overline{h}m))} & [attention \quad weight]\ c_i &=\sum \alpha{ij}\overline{h}_s & [context\quad vector] \ \alpha_i &= f(c_i,h_i) = tanh(W_c[c_i;h_i]) &[attenton \quad result] \end{align*} $$
- 先计算attention权重
- 在计算上下文向量,图中的$c^i$
- 最后计算结果,往往会把当前的output([batch_size,1,hidden_size])和上下文向量进行拼接然后使用
2.2 不同Attention的介绍
在上述过程中,使用decoder的状态和encoder的状态的计算后的结果作为权重,乘上encoder每个时间步的输出,这需要我们去训练一个合适的match函数,得到的结果就能够在不同的时间步上使用不同的encoder的相关信息,从而达到只关注某一个局部的效果,也就是注意力的效果
2.2.1 Soft-Attention 和 Hard-Attention
最开始Bahdanau等人提出的Attention机制通常被称为soft-attention,所谓的soft-attention指的是encoder中输入的每个词语都会计算得到一个注意力的概率。
在进行图像捕捉的时候,提出了一种hard-attenion的方法,希望直接从input中找到一个和输出的某个词对应的那一个词。但是由于NLP中词语和词语之间往往存在联系,不会只关注某一个词语,所以都会使用soft-attention,所以这里的就不多介绍hard-attention

2.2.3 Global-Attention 和Local Attention
Bahdanau等人提出的Bahdanau Attention 被称为local attention,后来Luong等人提出的Luong Attention是一种全局的attenion。
所谓全局的attenion指的是:使用的全部的encoder端的输入的attenion的权重
local-attenion就是使用了部分的encoder端的输入的权重(当前时间步上的encoder的hidden state),这样可以减少计算量,特别是当句子的长度比较长的时候。
2.2.4 Bahdanau Attention和 Luong Attenion的区别
区别在于两个地方:
-
attention的计算数据和位置
Bahdanau Attention会使用前一次的隐藏状态来计算attention weight,所以我们会在代码中的GRU之前使用attention的操作,同时会把attention的结果和word embedding的结果进行concat,作为GRU的输出(参考的是pytorch Toritul)。Bahdanau使用的是双向的GRU,会使用正反的encoder的output的concat的结果作为encoder output,如下图所示Luong Attenion使用的是当前一次的decoder的output来计算得到attention weight,所以在代码中会在GRU的后面进行attention的操作,同时会把context vector和gru的结果进行concat的操作,最终的output。Luong使用的是多层GRU,只会使用最后一层的输出(encoder output)
-
计算attention weights的方法不同
-
Bahdanau Attention的match函数,$a_i^j = v^T_a tanh (W_aZ_{i-1},+U_ah_j)$,计算出所有的$a_i^j$之后,在计算softmax,得到$\hat{a}_i^j$,即$\hat{a}_i^j = \frac{exp(a_i^j)}{\sum exp(a_i^j)}$其中
- vaT是一个参数矩阵,需要被训练,Wa是实现对Zi−1的形状变化,
- Ua实现对hj的形状变化(矩阵乘法,理解为线性回归,实现数据形状的对齐),
- Zi−1是decoder端前一次的隐藏状态,hj是encoder的output
-
Luong Attenion整体比Bahdanau Attention更加简单,他使用了三种方法来计算得到权重
-
最终两个attention的结果区别并不太大,所以以后我们可以考虑使用Luong attention完成代码
3. Attention的代码实现
完成代码之前,我们需要确定我们的思路,通过attention的代码,需要实现计算的是attention weight
通过前面的学习,我们知道attention_weight = f(hidden,encoder_outputs),主要就是实现Luong attention中的三种操作
class Attention(nn.Module):
def __init__(self,method,batch_size,hidden_size):
super(Attention,self).__init__()
self.method = method
self.hidden_size = hidden_size
assert self.method in ["dot","general","concat"],"method 只能是 dot,general,concat,当前是{}".format(self.method)
if self.method == "dot":
pass
elif self.method == "general":
self.Wa = nn.Linear(hidden_size,hidden_size,bias=False)
elif self.method == "concat":
self.Wa = nn.Linear(hidden_size*2,hidden_size,bias=False)
self.Va = nn.Parameter(torch.FloatTensor(batch_size,hidden_size))
def forward(self, hidden,encoder_outputs):
"""
:param hidden:[1,batch_size,hidden_size]
:param encoder_outputs: [batch_size,seq_len,hidden_size]
:return:
"""
batch_size,seq_len,hidden_size = encoder_outputs.size()
hidden = hidden.squeeze(0) #[batch_size,hidden_size]
if self.method == "dot":
return self.dot_score(hidden,encoder_outputs)
elif self.method == "general":
return self.general_score(hidden,encoder_outputs)
elif self.method == "concat":
return self.concat_score(hidden,encoder_outputs)
def _score(self,batch_size,seq_len,hidden,encoder_outputs):
# 速度太慢
# [batch_size,seql_len]
attn_energies = torch.zeros(batch_size,seq_len).to(config.device)
for b in range(batch_size):
for i in range(seq_len):
#encoder_output : [batch_size,seq_len,hidden_size]
#deocder_hidden :[batch_size,hidden_size]
#torch.Size([256, 128]) torch.Size([128]) torch.Size([256, 24, 128]) torch.Size([128])
# print("attn size:",hidden.size(),hidden[b,:].size(),encoder_output.size(),encoder_output[b,i].size())
attn_energies[b,i] = hidden[b,:].dot(encoder_outputs[b,i]) #dot score
return F.softmax(attn_energies).unsqueeze(1) # [batch_size,1,seq_len]
def dot_score(self,hidden,encoder_outputs):
"""
dot attention
:param hidden:[batch_size,hidden_size] --->[batch_size,hidden_size,1]
:param encoder_outputs: [batch_size,seq_len,hidden_size]
:return:
"""
#hiiden :[hidden_size] -->[hidden_size,1] ,encoder_output:[seq_len,hidden_size]
hidden = hidden.unsqueeze(-1)
attn_energies = torch.bmm(encoder_outputs, hidden)
attn_energies = attn_energies.squeeze(-1) #[batch_size,seq_len,1] ==>[batch_size,seq_len]
return F.softmax(attn_energies).unsqueeze(1) # [batch_size,1,seq_len]
def general_score(self,hidden,encoder_outputs):
"""
general attenion
:param batch_size:int
:param hidden: [batch_size,hidden_size]
:param encoder_outputs: [batch_size,seq_len,hidden_size]
:return:
"""
x = self.Wa(hidden) #[batch_size,hidden_size]
x = x.unsqueeze(-1) #[batch_size,hidden_size,1]
attn_energies = torch.bmm(encoder_outputs,x).squeeze(-1) #[batch_size,seq_len,1]
return F.softmax(attn_energies,dim=-1).unsqueeze(1) # [batch_size,1,seq_len]
def concat_score(self,hidden,encoder_outputs):
"""
concat attention
:param batch_size:int
:param hidden: [batch_size,hidden_size]
:param encoder_outputs: [batch_size,seq_len,hidden_size]
:return:
"""
#需要先进行repeat操作,变成和encoder_outputs相同的形状,让每个batch有seq_len个hidden_size
x = hidden.repeat(1,encoder_outputs.size(1),1) ##[batch_size,seq_len,hidden_size]
x = torch.tanh(self.Wa(torch.cat([x,encoder_outputs],dim=-1))) #[batch_size,seq_len,hidden_size*2] --> [batch_size,seq_len,hidden_size]
#va [batch_size,hidden_size] ---> [batch_size,hidden_size,1]
attn_energis = torch.bmm(x,self.Va.unsqueeze(2)) #[batch_size,seq_len,1]
attn_energis = attn_energis.squeeze(-1)
# print("concat attention:",attn_energis.size(),encoder_outputs.size())
return F.softmax(attn_energis,dim=-1).unsqueeze(1) #[batch_size,1,seq_len]
完成了attention weight的计算之后,需要再对代码中forward_step的内容进行修改
def forward_step(self,decoder_input,decoder_hidden,encoder_outputs):
"""
:param decoder_input:[batch_size,1]
:param decoder_hidden: [1,batch_size,hidden_size]
:param encoder_outputs: encoder中所有的输出,[batch_size,seq_len,hidden_size]
:return: out:[batch_size,vocab_size],decoder_hidden:[1,batch_size,didden_size]
"""
embeded = self.embedding(decoder_input) #embeded: [batch_size,1 , embedding_dim]
#TODO 可以把embeded的结果和前一次的context(初始值为全0tensor) concate之后作为结果
#rnn_input = torch.cat((embeded, last_context.unsqueeze(0)), 2)
# gru_out:[256,1, 128] decoder_hidden: [1, batch_size, hidden_size]
gru_out,decoder_hidden = self.gru(embeded,decoder_hidden)
gru_out = gru_out.squeeze(1)
#TODO 注意:如果是单层,这里使用decoder_hidden没问题(output和hidden相同)
# 如果是多层,可以使用GRU的output作为attention的输入
#开始使用attention
attn_weights = self.attn(decoder_hidden,encoder_outputs)
# attn_weights [batch_size,1,seq_len] * [batch_size,seq_len,hidden_size]
context = attn_weights.bmm(encoder_outputs) #[batch_size,1,hidden_size]
gru_out = gru_out.squeeze(0) # [batch_size,hidden_size]
context = context.squeeze(1) # [batch_size,hidden_size]
#把output和attention的结果合并到一起
concat_input = torch.cat((gru_out, context), 1) #[batch_size,hidden_size*2]
concat_output = torch.tanh(self.concat(concat_input)) #[batch_size,hidden_size]
output = F.log_softmax(self.fc(concat_output),dim=-1) #[batch_Size, vocab_size]
# out = out.squeeze(1)
return output,decoder_hidden,attn_weights
attetnion的Bahdanau实现可以参考:practical-pytorch/seq2seq-translation/seq2seq-translation.ipynb at master · spro/practical-pytorch · GitHub
Beam Search
目标
- 知道beam search的概念和原理
- 能够在代码中使用Beam search 完成预测过程
1. Beam Search的介绍
在进行模型评估的过程中,每次我们选择概率最大的token id作为输出,那么整个输出的句子的概率就是最大的么?

Beam search的又被称作束集搜索,是一种seq2seq中用来优化输出结果的算法(不在训练过程中使用)。
例如:传统的获取解码器输出的过程中,每次只选择概率最大的那个结果,作为当前时间步的输出,等到输出结束,我们会发现,整个句子可能并不通顺。虽然在每一个时间步上的输出确实是概率最大的,但是整体的概率确不一定最大的,我们经常把它叫做greedy search[贪心算法]
为了解决上述的问题,可以考虑计算全部的输出的概率乘积,选择最大的哪一个,但是这样的话,意味着如果句子很长,候选词很多,那么需要保存的数据就会非常大,需要计算的数据量就很大
那么Beam Search 就是介于上述两种方法的一个这种的方法,假设Beam width=2,表示每次保存的最大的概率的个数,这里每次保存两个,在下一个时间步骤一样,也是保留两个,这样就可以达到约束搜索空间大小的目的,从而提高算法的效率。
beam width =1 时,就是贪心算法,beam width=候选词的时候,就是计算全部的概率。beam width 是一个超参数。
比如在下图中:
使用一个树状图来表示每个time step的可能输出,其中的数字表示是条件概率
黄色的箭头表示的是一种greedy search,概率并不是最大的
如果把beam width设置为2,那么后续可以找到绿色路径的结果,这个结果是最大的

下图是要给beam width=3的例子
- 首先输入
start token <s>,然后得到四个输出(这里假设一个就四个输出:x,y,z,</s>),选择概率最大三个,x,y,w - 然后分别把x,y,z放到下一个time step中作为输入,分别得到三个不同的输出,找到三个输出中概率最大的三个,x,y,y
- 继续重复上述步骤,直到获得结束符(概率最大)或者是达到句子的最大长度,那么此时选择概率乘积最大的一个。
- 拼接整个路径上概率最大的所有结果,比如这里可能是
<s>,y,y,x,w,</s>
2. Beam serach的实现
在上述描述的思路中,我们需要注意以下几个内容:
- 数据该如何保存,每一次的输出的最大的beam width个结果,和之后之前的结果该如何保存
- 保存了之后的概率应该如何比较大小,保留下概率最大的三个
- 不能够仅仅只保存当前概率最大的信息,还需要有当前概率最大的三个中,前面的路径的输出结果
2.1 数据结构-堆-的认识
对于上面所说的,保留有限个数据,同时需要根据大小来保留,可以使用一种带有优先级的数据结构来实现,这里我们可以使用堆这种数据结构
堆是一种优先级的队列,但是他其实并不是队列,我们常说的队列都是先进先出或者是先进后出,但是堆只根据优先级的高低来取出数据。
和堆在一起的另外一种数据结构叫做栈,有入栈和出栈的操作,可以理解为是一种先进后出的数据结构,关于栈,大家可以下来在了解。
在python自带的模块中,有一个叫做heapq的模块,提供了堆所有的方法。通过下面的代码我们来了解下heapq的使用方法
my_heap = [] #使用列表保存数据
#往列表中插入数据,优先级使用插入的内容来表示,就是一个比较大小的操作,越大优先级越高
heapq.heappush(my_heap,[29,True,"xiaohong"])
heapq.heappush(my_heap,[28,False,"xiaowang"])
heapq.heappush(my_heap,[29,False,"xiaogang"])
for i in range(3):
ret= heapq.heappop(my_heap) #pop操作,优先级最小的数据
print(ret)
#输出如下:
[28, False, 'xiaowang']
[29, False, 'xiaogang']
[29, True, 'xiaohong']
可以发现,输出的顺序并不是数据插入的顺序,而是根据其优先级,从小往大pop(False<True)。
2.2 使用堆来实现beam search
为了实现数据的的保存,我们可以把beam search中的数据保存在堆中,同时在往这个堆中添加数据的同时,判断数据的个数,仅仅保存beam width个数据
class Beam:
def __init__(self):
self.heap = list() #保存数据的位置
self.beam_width = config.beam_width #保存数据的总数
def add(self,probility,complete,seq,decoder_input,decoder_hidden):
"""
添加数据,同时判断总的数据个数,多则删除
:param probility: 概率乘积
:param complete: 最后一个是否为EOS
:param seq: list,所有token的列表
:param decoder_input: 下一次进行解码的输入,通过前一次获得
:param decoder_hidden: 下一次进行解码的hidden,通过前一次获得
:return:
"""
heapq.heappush(self.heap,[probility,complete,seq,decoder_input,decoder_hidden])
#判断数据的个数,如果大,则弹出。保证数据总个数小于等于3
if len(self.heap)>self.beam_width:
heapq.heappop(self.heap)
def __iter__(self):#让该beam能够被迭代
return iter(self.heap)
实现方法,完成模型eval过程中的beam search搜索
思路:
- 构造
<SOS>开始符号等第一次输入的信息,保存在堆中 - 取出堆中的数据,进行forward_step的操作,获得当前时间步的output,hidden
- 从output中选择topk(k=beam width)个输出,作为下一次的input
- 把下一个时间步骤需要的输入等数据保存在一个新的堆中
- 获取新的堆中的优先级最高(概率最大)的数据,判断数据是否是EOS结尾或者是否达到最大长度,如果是,停止迭代
- 如果不是,则重新遍历新的堆中的数据
代码如下
# decoder中的新方法
def evaluatoin_beamsearch_heapq(self,encoder_outputs,encoder_hidden):
"""使用 堆 来完成beam search,对是一种优先级的队列,按照优先级顺序存取数据"""
batch_size = encoder_hidden.size(1)
#1. 构造第一次需要的输入数据,保存在堆中
decoder_input = torch.LongTensor([[word_sequence.SOS] * batch_size]).to(config.device)
decoder_hidden = encoder_hidden #需要输入的hidden
prev_beam = Beam()
prev_beam.add(1,False,[decoder_input],decoder_input,decoder_hidden)
while True:
cur_beam = Beam()
#2. 取出堆中的数据,进行forward_step的操作,获得当前时间步的output,hidden
#这里使用下划线进行区分
for _probility,_complete,_seq,_decoder_input,_decoder_hidden in prev_beam:
#判断前一次的_complete是否为True,如果是,则不需要forward
#有可能为True,但是概率并不是最大
if _complete == True:
cur_beam.add(_probility,_complete,_seq,_decoder_input,_decoder_hidden)
else:
decoder_output_t, decoder_hidden,_ = self.forward_step(_decoder_input, _decoder_hidden,encoder_outputs)
value, index = torch.topk(decoder_output_t, config.beam_width) # [batch_size=1,beam_widht=3]
#3. 从output中选择topk(k=beam width)个输出,作为下一次的input
for m, n in zip(value[0], index[0]):
decoder_input = torch.LongTensor([[n]]).to(config.device)
seq = _seq + [n]
probility = _probility * m
if n.item() == word_sequence.EOS:
complete = True
else:
complete = False
#4. 把下一个实践步骤需要的输入等数据保存在一个新的堆中
cur_beam.add(probility,complete,seq,
decoder_input,decoder_hidden)
#5. 获取新的堆中的优先级最高(概率最大)的数据,判断数据是否是EOS结尾或者是否达到最大长度,如果是,停止迭代
best_prob,best_complete,best_seq,_,_ = max(cur_beam)
if best_complete == True or len(best_seq)-1 == config.max_len: #减去sos
return self._prepar_seq(best_seq)
else:
#6. 则重新遍历新的堆中的数据
prev_beam = cur_beam
def _prepar_seq(self,seq):#对结果进行基础的处理,共后续转化为文字使用
if seq[0].item() == word_sequence.SOS:
seq= seq[1:]
if seq[-1].item() == word_sequence.EOS:
seq = seq[:-1]
seq = [i.item() for i in seq]
return seq
2.3 修改seq2seq
在seq2seq中使用evaluatoin_beamsearch_heapq查看效果,会发现使用beam search的效果比单独使用attention的效果更好
使用小黄鸡语料(50万个问答),单个字作为token,5个epoch之后的训练结果,左边为问,右边是回答
你在干什么 >>>>> 你想干啥? 你妹 >>>>> 不是我 你叫什么名字 >>>>> 你猜 你个垃圾 >>>>> 你才是,你 你是傻逼 >>>>> 是你是傻 笨蛋啊 >>>>> 我不是,你
闲聊机器人的优化
目标
- 知道如何优化模型的效果
- 知道常见的优化手段
1. seq2seq中使用teacher forcing
在前面的seq2seq的案例中,我们介绍了teacher frocing是什么,当时我们的输入和输出很相似,所以当时我们的teacher forcing是在每个time step中实现的,那么现在我们的输入和输出不同的情况下,该如何使用呢?
我们可以在每个batch遍历time step的外层使用teacher forcing
代码如下:
use_teacher_forcing = random.random() > 0.5
if use_teacher_forcing: #使用teacher forcing
for t in range(config.max_len):
decoder_output_t, decoder_hidden, decoder_attn_t = self.forward_step(decoder_input, decoder_hidden,
encoder_outputs)
decoder_outputs[:, t, :] = decoder_output_t
#使用正确的输出作为下一步的输入
decoder_input = target[:, t].unsqueeze(1) # [batch_size,1]
else:#不适用teacher forcing,使用预测的输出作为下一步的输入
for t in range(config.max_len):
decoder_output_t ,decoder_hidden,decoder_attn_t = self.forward_step(decoder_input,decoder_hidden,encoder_outputs)
decoder_outputs[:,t,:] = decoder_output_t
value, index = torch.topk(decoder_output_t, 1) # index [batch_size,1]
decoder_input = index
2. 使用梯度裁剪
前面,我们给大家介绍了梯度消失(梯度过小,在多层计算后导致其值太小而无法计算)和梯度爆炸(梯度过大,导致其值在多层的计算后太大而无法计算)。
在常见的深度神经网络中,特别是RNN中,我们经常会使用梯度裁剪的手段,来抑制过大的梯度,能够有效防止梯度爆炸。
梯度裁剪的实现非常简单,仅仅只需要设置一个阈值,把梯度大于该阈值时设置为该阈值。
实现代码:
loss.backward() #进行梯度裁剪 nn.utils.clip_grad_norm_(model.parameters(),[5,10,15]) optimizer.step()
3. 其他优化方法
- 根据特定的问题,使用分类模型进行训练,然后再训练单独的回个该为题的为模型
- 比如询问名字,可以使用fasttext先进行意图识别,命中
询问名字分类后,直接返回名字 - 或者是手动构造和名字相关的很多问题,来进行训练,从而能够更加个性化的回答出结果
- 比如询问名字,可以使用fasttext先进行意图识别,命中
- 直接对现有的语料进行修改和清洗,把语料中更多的答案进行替换,比如咨询名字的,咨询天气的等,这样能够更大程度上的回答出更加规范的答案
- 使用2.4 会讲的搜索模型,不再使用这种生成模型
问答机器人介绍
目标
- 知道问答机器人是什么
- 知道问答机器人实现的逻辑
1. 问答机器人
在前面的课程中,我们已经对问答机器人介绍过,这里的问答机器人是我们在分类之后,对特定问题进行回答的一种机器人。至于回答的问题的类型,取决于我们的语料。
当前我们需要实现的问答机器人是一个回答编程语言(比如python是什么,python难么等)相关问题的机器人
2. 问答机器人的实现逻辑
主要实现逻辑:从现有的问答对中,选择出和问题最相似的问题,并且获取其相似度(一个数值),如果相似度大于阈值,则返回这个最相似的问题对应的答案
问答机器人的实现可以大致分为三步步骤:
- 对问题的处理
- 对答案进行的机器学习召回
- 对召回的结果进行排序
2.1 对问题的处理
对问题的处理过程中,我们可以考虑以下问题:
- 对问题进行基础的清洗,去除特殊符号等
- 问题主语的识别,判断问题中是否包含特定的主语,比如
python等,提取出来之后,方便后续对问题进行过滤。- 可以看出,不仅需要对用户输入的问题进行处理,获取主语,还需要对现有问答对进行处理
- 获取问题的词向量,可以考虑使用词频,tdidf等值,方便召回的时候使用
2.2 问题的召回
召回:可以理解为是一个海选的操作,就是从现有的问答对中选择可能相似的前K个问题。
为什么要进行召回?
主要目的是为了后续进行排序的时候,减少需要计算的数据量,比如有10万个问答对,直接通过深度学习肯定是可以获取所有的相似度,但是速度慢。
所以考虑使用机器学习的方法进行一次海选
那么,如何实现召回呢?
前面我们介绍,召回就是选择前K个最相似的问题,所以召回的实现就是想办法通过机器学习的手段计算器相似度。
可以思考的方法:
- 使用词袋模型,获取词频矩阵,计算相似度
- 使用tfidf,获取tdidf的矩阵,计算相似度
上述的方法理论上都可行,知识当候选计算的词语数量太多的时候,需要挨个计算相似度,非常耗时。
所以可以考虑以下两点:
- 通过前面获取的主语,对问题进行过滤
- 使用聚类的方法,对数据先聚类,再计算某几个类别中的相似度,而不用去计算全部。
但是还有一个问题,供大家慢慢思考:
不管是词频,还是tdidf,获取的结果肯定是没有考虑文字顺序的,效果不一定是最好的,那么此时,应该如何让最后召回的效果更好呢?
2.3 问题的排序
排序过程,使用了召回的结果作为输入,同时输出的是最相似的那一个。
整个过程使用深度学习实现。深度学习虽然训练的速度慢,但是整体效果肯定比机器学习好(机器学习受限于特征工程,数据量等因素,没有办法深入的学会不同问题之间的内在相似度),所以通过自建的模型,获取最后的相似度。
使用深度学习的模型这样一个黑匣子,在训练数据足够多的时候,能够学习到用户的各种不同输入的问题,当我们把目标值(相似的问题)给定的情况下,让模型自己去找到这些训练数据目标值和特征值之间相似的表示方法。
那么此时,有以下两个问题:
-
使用什么数据,来训练模型,最后返回模型的相似度
训练的数据的来源:可以考虑根据现有的问答对去手动构造,但是构造的数据不一定能够覆盖后续用户提问的全部问题。所以可以考虑通过程序去采集网站上相似的问题,比如百度知道的搜索结果。
-
模型该如何构建
模型可以有两个输入,输出为一个数值,两个输入的处理方法肯定是一样的。这种网络结构我们经常把它称作孪生神经网络。
很明显,我们队输入的数据需要进行编码的操作,比如word embedding + LSTM/GRU/BIGRU等
两个编码之后的结果,我们可以进行组合,然后通过一个多层的神经网络,输出一个数字,把这个数值定义为我们的相似度。
当然我们的深层的神经网络在最开始的时候也并不是计算的相似度,但是我们的训练数据的目标值是相似度,在N多次的训练之后,确定了输入和输出的表示方法之后,那么最后的模型输出就是相似度了。
前面我们介绍了问答机器人的实现的大致思路,那么接下来,我们就来一步步的实现它
问答机器人的召回
目标
- 知道召回的目的
- 能够说出召回的流程
- 能够优化基础的召回逻辑
1. 召回的流程
流程如下:
- 准备数据,问答对的数据等
- 问题转化为向量
- 计算相似度
2. 对现有问答对的准备
这里说的问答对,是带有标准答案的问题,后续命中问答对中的问题后,会返回该问题对应的答案
为了后续使用方便,我们可以把现有问答对的处理成如下的格式,可以考虑存入数据库或者本地文件:
{
"问题1":{
"主体":["主体1","主体3","主体3"..],
"问题1分词后的句子":["word1","word2","word3"...],
"答案":"答案"
},
"问题2":{
...
}
}
代码如下:
# lib/get_qa_dcit.py
def get_qa_dict():
chuanzhi_q_path = "./问答对/Q.txt"
chuanzhi_a_path = "./问答对/A.txt"
QA_dict = {}
for q,a in zip(open(chuanzhi_q_path).readlines(),open(chuanzhi_a_path).readlines()):
QA_dict[q.strip()] = {}
QA_dict[q.strip()]["ans"] = a.strip()
QA_dict[q.strip()]["entity"] = sentence_entity(q.strip())[-1]
#准备短问答的问题
python_duan_path = "./data/Python短问答-11月汇总.xlsx"
ret = pd.read_excel(python_duan_path)
column_list = ret.columns
assert '问题' in column_list and "答案" in column_list, "excel 中必须包含问题和答案"
for q, a in zip(ret["问题"], ret["答案"]):
q = re.sub("\s+", " ", q)
QA_dict[q.strip()] = {}
QA_dict[q.strip()]["ans"] = a
cuted,entiry = sentence_entity(q.strip())[-1]
QA_dict[q.strip()]["entity"] = entiry
QA_dict[q.strip()]["q_cuted"] = cuted
return QA_dict
QA_dict = get_qa_dict()
3. 把问题转化为向量
把问答对中的问题,和用户输出的问题,转化为向量,为后续计算相似度做准备。
这里,我们使用tfidf对问答对中的问题进行处理,转化为向量矩阵。
TODO,使用单字,使用n-garm,使用BM25,使用word2vec等,让其结果更加准确
from sklearn.feature_extraction.text import TfidfVectorizer
from lib import QA_dict
def build_q_vectors():
"""对问题建立索引"""
lines_cuted= [q["q_cuted"] for q in QA_dict]
tfidf_vectorizer = TfidfVectorizer()
features_vec = tfidf_vectorizer.fit_transform(lines_cuted)
#返回tfidf_vectorizer,后续还需要对用户输入的问题进行同样的处理
return tfidf_vectorizer,features_vec,lines_cuted
4. 计算相似度
思路很简单。对用户输入的问题使用tfidf_vectorizer进行处理,然后和features_vec中的每一个结果进行计算,获取相似度。
但是由于耗时可能会很久,所以考虑使用其他方法来实现
4.1 pysparnn的介绍
官方地址:https://github.com/facebookresearch/pysparnn
pysparnn是一个对sparse数据进行相似邻近搜索的python库,这个库是用来实现 高维空间中寻找最相似的数据的。
4.2 pysparnn的使用方法
pysparnn的使用非常简单,仅仅需要以下步骤,就能够完成从高维空间中寻找相似数据的结果
- 准备源数据和待搜索数据
- 对源数据进行向量化,把向量结果和源数据构造搜索的索引
- 对待搜索的数据向量化,传入索引,获取结果
import pysparnn.cluster_index as ci
from sklearn.feature_extraction.text import TfidfVectorizer
#1. 原始数据
data = [
'hello world',
'oh hello there',
'Play it',
'Play it again Sam',
]
#2. 原始数据向量化
tv = TfidfVectorizer()
tv.fit(data)
features_vec = tv.transform(data)
# 原始数据构造索引
cp = ci.MultiClusterIndex(features_vec, data)
# 待搜索的数据向量化
search_data = [
'oh there',
'Play it again Frank'
]
search_features_vec = tv.transform(search_data)
#3. 索引中传入带搜索数据,返回结果
cp.search(search_features_vec, k=1, k_clusters=2, return_distance=False)
>> [['oh hello there'], ['Play it again Sam']]
使用注意点:
- 构造索引是需要传入向量和原数据,最终的结果会返回源数据
- 传入待搜索的数据时,需要传入一下几个参数:
search_features_vec:搜索的句子的向量k:最大的几个结果,k=1,返回最大的一个k_clusters:对数据分为多少类进行搜索return_distance:是否返回距离
4.3 使用pysparnn完成召回的过程
#构造索引
cp = ci.MultiClusterIndex(features_vec, lines_cuted)
#对用户输入的句子进行向量化
search_vec = tfidf_vec.transform(ret)
#搜索获取结果,返回最大的8个数据,之后根据`main_entiry`进行过滤结果
cp_search_list = cp.search(search_vec, k=8, k_clusters=10, return_distance=True)
exist_same_entiry = False
search_lsit = []
for _temp_call_line in cp_search_list[0]:
cur_entity = QA_dict[_temp_call_line[1]]["main_entity"]
if len(set(main_entity) & set(cur_entity))>0: #命名体的集合存在交集的时候返回
exist_same_entiry = True
search_lsit.append(_temp_call_line[1])
if exist_same_entiry: #存在相同的主体的时候
return search_lsit
else:
# print(cp_search_list)
return [i[1] for i in cp_search_list[0]]
在这个过程中,需要注意,提前把cp,tfidf_vec等内容提前准备好,而不应该在每次接收到用户的问题之后重新生成一遍,否则效率会很低
4.4 pysparnn的原理介绍
参考地址:https://nlp.stanford.edu/IR-book/html/htmledition/cluster-pruning-1.html
前面我们使用的pysparnn使用的是一种cluster pruning(簇修剪)的技术,即,开始的时候对数据进行聚类,后续再有限个类别中进行数据的搜索,根据计算的余弦相似度返回结果。
数据预处理过程如下:
- 随机选择$\sqrt{N}$个样本作为leader
- 选择非leader的数据(follower),使用余弦相似度计算找到最近的leader
当获取到一个问题q的时候,查询过程:
- 计算每个leader和q的相似度,找到最相似的leader
- 然后计算问题q和leader所在簇的相似度,找到最相似的k个,作为最终的返回结果
在上述的过程中,可以设置两个大于0的数字b1和b2
- b1表示在
数据预处理阶段,每个follower选择b1个最相似的leader,而不是选择单独一个lader,这样不同的簇是有数据交叉的; - b2表示在查询阶段,找到最相似的b2个leader,然后再计算不同的leader中下的topk的结果
前面的描述就是b1=b2=1的情况,通过增加b1和b2的值,我们能够有更大的机会找到更好的结果,但是这样会需要更加大量的计算。
在pysparnn中实例化索引的过程中
即:ci.MultiClusterIndex(features, records_data, num_indexes)中,num_indexes能够设置b1的值,默认为2。
在搜索的过程中,cp.search(search_vec, k=8, k_clusters=10, return_distance=True,num_indexes),num_Indexes可以设置b2的值,默认等于b1的值。
召回过程优化
目标
- 知道优化的方法和思路
- 知道BM25方法的原理和实现
- 能够使用word2vector完成优化过程
1. 优化思路
前面的学习,我们能够返回相似的召回结果,但是,如何让这些结果更加准确呢?
我们可以从下面的角度出发:
- tfidf使用的是词频和整个文档的词语,如果用户问题的某个词语没有出现过,那么此时,计算出来的相似度可能就不准确。该问题的解决思路:
- 对用户输入的问题进行文本的对齐,比如,使用训练好的word2vector,往句子中填充非主语的其他词语的相似词语。例如
python 好学 么 -->填充后是 :python 好学 么 简单 难 嘛,这里假设word2vector同学会了好学,简单,难他们之间是相似的 - 使用word2vector对齐的好处除了应对未出现的词语,还能够提高主语的重要程度,让主语位置的tfidf的值更大,从而让相似度更加准确
- 对用户输入的问题进行文本的对齐,比如,使用训练好的word2vector,往句子中填充非主语的其他词语的相似词语。例如
- tfidf是一个词袋模型,没有考虑词和词之间的顺序
- 使用n-garm和词一起作为特征,转化为特征向量
- 不去使用tfidf处理句子得到向量。
- 使用BM25算法
- 或者 使用fasttext、word2vector,把句子转化为向
2. 通过BM25算法代替TFIDF
2.1 BM25算法原理
BM25(BM=best matching)是TDIDF的优化版本,首先我们来看看TFIDF是怎么计算的 $$ tfidf_i = tf*idf = \frac{词i的数量}{词语总数}*log\frac{总文档数}{包含词i的文档数} $$ 其中tf称为词频,idf为逆文档频率
那么BM25是如何计算的呢? $$ BM25(i) = \frac{词i的数量}{总词数}*\frac{(k+1)C}{C+k(1-b+b\frac{|d|}{avdl})}*log(\frac{总文档数}{包含i的文档数}) \ C = tf=\frac{词i的数量}{总词数},k>0,b\in [0,1],d为文档i的长度,avdl是文档平均长度 $$ 大家可以看到,BM25和tfidf的计算结果很相似,唯一的区别在于中多了一项,这一项是用来对tf的结果进行的一种变换。
把$1-b+b\frac{d}{avdl}$中的b看成0,那么此时中间项的结果为$\frac{(k+1)tf}{k+tf}$,通过设置一个k,就能够保证其最大值为$1$,达到限制tf过大的目的。
即: $$ \begin{align} &\frac{(k+1)tf}{k+tf}= \frac{k+1}{1+\frac{k}{tf}} \qquad \qquad \qquad,上下同除tf \end{align} $$ k不变的情况下,上式随着tf的增大而增大,上限为k+1,但是增加的程度会变小,如下图所示。
在一个句子中,某个词重要程度应该是随着词语的数量逐渐衰减的,所以中间项对词频进行了惩罚,随着次数的增加,影响程度的增加会越来越小。通过设置k值,能够保证其最大值为k+1,k往往取值1.2。
其变化如下图(无论k为多少,中间项的变化程度会随着次数的增加,越来越小):
同时$1-b+b\frac{d}{avdl}$的作用是用来对文本的长度进行归一化。
例如在考虑整个句子的tdidf的时候,如果句子的长度太短,那么计算的总的tdidf的值是要比长句子的tdidf的值要低的。所以可以考虑对句子的长度进行归一化处理。
可以看到,当句子的长度越短,$1-b+b\frac{|d|}{avdl}$的值是越小,作为分母的位置,会让整个第二项越大,从而达到提高短文本句子的BM25的值的效果。当b的值为0,可以禁用归一化,b往往取值0.75
其变化效果如下:
2.2 BM25算法实现
通过前面的学习,我们知道其实BM25和Tfidf的区别不大,所以我们可以在之前sciket-learn的TfidfVectorizer基础上进行修改,获取我们的BM25的计算结果,主要也是修改其中的fit方法和transform方法
在sklearn的TfidfVectorizer中,首先接受参数,其次会调用TfidfTransformer来完成其他方法的调用
-
继承TfidfVectorizer完成 参数的接受
from sklearn.feature_extraction.text import TfidfVectorizer,TfidfTransformer,_document_frequency from sklearn.base import BaseEstimator,TransformerMixin from sklearn.preprocessing import normalize from sklearn.utils.validation import check_is_fitted import numpy as np import scipy.sparse as sp class Bm25Vectorizer(CountVectorizer): def __init__(self,k=1.2,b=0.75, norm="l2", use_idf=True, smooth_idf=True,sublinear_tf=False,*args,**kwargs): super(Bm25Vectorizer,self).__init__(*args,**kwargs) self._tfidf = Bm25Transformer(k=k,b=b,norm=norm, use_idf=use_idf, smooth_idf=smooth_idf, sublinear_tf=sublinear_tf) @property def k(self): return self._tfidf.k @k.setter def k(self, value): self._tfidf.k = value @property def b(self): return self._tfidf.b @b.setter def b(self, value): self._tfidf.b = value def fit(self, raw_documents, y=None): """Learn vocabulary and idf from training set. """ X = super(Bm25Vectorizer, self).fit_transform(raw_documents) self._tfidf.fit(X) return self def fit_transform(self, raw_documents, y=None): """Learn vocabulary and idf, return term-document matrix. """ X = super(Bm25Vectorizer, self).fit_transform(raw_documents) self._tfidf.fit(X) return self._tfidf.transform(X, copy=False) def transform(self, raw_documents, copy=True): """Transform documents to document-term matrix. """ check_is_fitted(self, '_tfidf', 'The tfidf vector is not fitted') X = super(Bm25Vectorizer, self).transform(raw_documents) return self._tfidf.transform(X, copy=False) -
完成自己的
Bm25transformer,只需要再原来基础的代码上进心修改部分即可。sklearn中的转换器类的实现要求,不能直接继承已有的转换器类class Bm25Transformer(BaseEstimator, TransformerMixin): def __init__(self,k=1.2,b=0.75, norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False): self.k = k self.b = b ##################以下是TFIDFtransform代码########################## self.norm = norm self.use_idf = use_idf self.smooth_idf = smooth_idf self.sublinear_tf = sublinear_tf def fit(self, X, y=None): """Learn the idf vector (global term weights) Parameters ---------- X : sparse matrix, [n_samples, n_features] a matrix of term/token counts """ X = X.toarray() self.avdl = X.sum()/X.shape[0] #句子的平均长度 # print("原来的fit的数据:\n",X) #计算每个词语的tf的值 self.tf = X.sum(0)/X.sum() #[M] #M表示总词语的数量 self.tf = self.tf.reshape([1,self.tf.shape[0]]) #[1,M] # print("tf\n",self.tf) ##################以下是TFIDFtransform代码########################## if not sp.issparse(X): X = sp.csc_matrix(X) if self.use_idf: n_samples, n_features = X.shape df = _document_frequency(X) # perform idf smoothing if required df += int(self.smooth_idf) n_samples += int(self.smooth_idf) # log+1 instead of log makes sure terms with zero idf don't get # suppressed entirely. idf = np.log(float(n_samples) / df) + 1.0 self._idf_diag = sp.spdiags(idf, diags=0, m=n_features, n=n_features, format='csr') return self def transform(self, X, copy=True): """Transform a count matrix to a tf or tf-idf representation Parameters ---------- X : sparse matrix, [n_samples, n_features] a matrix of term/token counts copy : boolean, default True Whether to copy X and operate on the copy or perform in-place operations. Returns ------- vectors : sparse matrix, [n_samples, n_features] """ ########### 计算中间项 ############### cur_tf = np.multiply(self.tf, X.toarray()) #[N,M] #N表示数据的条数,M表示总词语的数量 norm_lenght = 1 - self.b + self.b*(X.toarray().sum(-1)/self.avdl) #[N] #N表示数据的条数 norm_lenght = norm_lenght.reshape([norm_lenght.shape[0],1]) #[N,1] X = (self.k+1)*cur_tf /(cur_tf +self.k*norm_lenght) ############# 结算结束 ################ if hasattr(X, 'dtype') and np.issubdtype(X.dtype, np.floating): # preserve float family dtype X = sp.csr_matrix(X, copy=copy) else: # convert counts or binary occurrences to floats X = sp.csr_matrix(X, dtype=np.float64, copy=copy) n_samples, n_features = X.shape if self.sublinear_tf: np.log(X.data, X.data) X.data += 1 if self.use_idf: check_is_fitted(self, '_idf_diag', 'idf vector is not fitted') expected_n_features = self._idf_diag.shape[0] if n_features != expected_n_features: raise ValueError("Input has n_features=%d while the model" " has been trained with n_features=%d" % ( n_features, expected_n_features)) # *= doesn't work X = X * self._idf_diag if self.norm: X = normalize(X, norm=self.norm, copy=False) return X @property def idf_(self): ##################以下是TFIDFtransform代码########################## # if _idf_diag is not set, this will raise an attribute error, # which means hasattr(self, "idf_") is False return np.ravel(self._idf_diag.sum(axis=0))完整代码参考:
https://github.com/SpringMagnolia/Bm25Vectorzier/blob/master/BM25Vectorizer.py -
测试简单使用,观察和tdidf的区别:
from BM25Vectorizer import Bm25Vectorizer from sklearn.feature_extraction.text import TfidfVectorizer if __name__ == '__main__': # format_weibo(word=False) # format_xiaohuangji_corpus(word=True) bm_vec = Bm25Vectorizer() tf_vec = TfidfVectorizer() # 1. 原始数据 data = [ 'hello world', 'oh hello there', 'Play it', 'Play it again Sam,24343,123', ] # 2. 原始数据向量化 bm_vec.fit(data) tf_vec.fit(data) features_vec_bm = bm_vec.transform(data) features_vec_tf = tf_vec.transform(data) print("Bm25 result:",features_vec_bm.toarray()) print("*"*100) print("Tfidf result:",features_vec_tf.toarray())输出如下:
Bm25 result: [[0. 0. 0. 0.47878333 0. 0. 0. 0. 0. 0.8779331 ] [0. 0. 0. 0.35073401 0. 0.66218791 0. 0. 0.66218791 0. ] [0. 0. 0. 0. 0.70710678 0. 0.70710678 0. 0. 0. ] [0.47038081 0.47038081 0.47038081 0. 0.23975776 0. 0.23975776 0.47038081 0. 0. ]] ********************************************************************************** Tfidf result: [[0. 0. 0. 0.6191303 0. 0. 0. 0. 0. 0.78528828] [0. 0. 0. 0.48693426 0. 0.61761437 0. 0. 0.61761437 0. ] [0. 0. 0. 0. 0.70710678 0. 0.70710678 0. 0. 0. ] [0.43671931 0.43671931 0.43671931 0. 0.34431452 0. 0.34431452 0.43671931 0. 0. ]]
2.3 修改之前的召回代码
修改之前召回的代码只需要把调用tfidfvectorizer改成调用Bm25vectorizer
3. 使用Fasttext实现获取句子向量
3.1 基础方法介绍
这里我们可以使用fasttext,word2vector等方式实现获取词向量,然后对一个句子中的所有词语的词向量进行平均,获取整个句子的向量表示,即sentence Vector,该实现方法在fasttext和Word2vector中均有实现,而且通过参数的控制,实现N-garm的效果
假设我们有文本a.txt如下:
我 很 喜欢 她
今天 天气 不错
我 爱 深度学习
那么我们可以实现获取句子向量的方法如下
from fastText import FastText
#训练模型,设置n-garm=2
model = FastText.train_unsupervised(input="./a.txt",minCount=1,wordNgrams=2)
#获取句子向量,是对词向量的平均
model.get_sentence_vector("我 是 谁")
3.2 训练模型和封装代码
这里我们使用之前采集的相似文本数据作为训练样本
步骤如下:
- 进行分词之后写入文件中
- 进行模型的训练
- 使用模型获取句子向量,并且封装代码
- 将之前的BM25的代码替换为该代码
3.2.1 分词写入文件
这里我们使用单个字作为特征,只需要注意,英文使用单个词作为特征
"""
使用单个字作为特征,进行fasttext训练,最后封装代码获取召回结果
"""
import string
def word_split(line):
#对中文按照字进行处理,对英文不分为字母
#即 I爱python --> i 爱 python
letters = string.ascii_lowercase+"+"+"/" #c++,ui/ue
result = []
temp = ""
for word in line:
if word.lower() in letters:
temp+=word.lower()
else:
if temp !="":
result.append(temp)
temp = ""
result.append(word)
if temp!="":
result.append(temp)
return result
def process_data():
path1 = r"corpus\final_data\merged_q.txt"
path2 = r"corpus\final_data\merged_sim_q.txt"
save_path = r"corpus\recall_fasttext_data\data.txt"
filter = set()
with open(path1) as f,open(save_path,"a") as save_f:
for line in f:
line = line.strip()
if line not in filter:
filter.add(line)
_temp = " ".join(word_split(line))
save_f.write(_temp+"\n")
with open(path2) as f,open(save_path,"a") as save_f:
for line in f:
line = line.strip()
if line not in filter:
filter.add(line)
_temp = " ".join(word_split(line))
save_f.write(_temp+"\n")
3.2.2 训练模型
-
训练fasttext的model,用来生成词向量
def train_model(fasttext_model_path): logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO) save_path = r"corpus\recall_fasttext_data\data.txt" model = FastText.train_unsupervised(save_path,epoch=20,minCount=3,wordNgrams=2) model.save_model(fasttext_model_path)
-
对现有的QA问答对,生成向量,传入pysparnn中构建索引
def get_base_text_vectors(cp_dump_path,model): #保存到本地pkl文件,防止每次都生成一次 if os.path.exists(cp_dump_path): cp = pickle.load(open(cp_dump_path,"rb")) else: print(QA_dict) q_lines = [q for q in QA_dict] q_cuted_list = [" ".join(word_split(i)) for i in q_lines] lines_vectors = [] for q_cuted in q_cuted_list: lines_vectors.append(model.get_sentence_vector(q_cuted)) cp = ci.MultiClusterIndex(lines_vectors,q_lines) pickle.dump(cp,open(cp_dump_path,"wb")) return cp -
传入用户的问题,进行分词和句子向量的获取,获取搜索的结果
def get_search_vectors(cp,model,search_line): line_cuted = " ".join(word_split(search_line)) line_vec = model.get_sentence_vector(line_cuted) #这里的line_vec中可以有多个句子的向量表示,能够返回每个句子的搜索结果 cp_search_list = cp.search(line_vec,k=10,k_clusters=10,return_distance=True) #TODO 对搜索的结果进行关键字的过滤 return cp_search_list -
测试模型的效果
from fastext_vectors import get_search_vectors,train_model,get_base_text_vectors import fastText if __name__ == '__main__': fasttext_model_path = "corpus/build_questions/fasttext_recall.model" cp_dump_path = "corpus/build_questions/cp_recall.pkl" # train_model(fasttext_model_path) model = fastText.load_model(fasttext_model_path) cp = get_base_text_vectors(cp_dump_path,model) ret = get_search_vectors(cp,model,"女孩学python容易么?") print(ret)输出如下:
[[('0.0890376', '学习Python需要什么基础,学起来更容易?'), ('0.090688944', '学习PHP的女生多吗?女生可以学吗?'), ('0.092773676', 'Python适合什么人学习?'), ('0.09416294', 'Python语言适合什么样的人学?'), ('0.102790296', 'python语言容易学习吗?'), ('0.1050359', '学习测试的女生多吗?女生可以学吗?'), ('0.10546541', 'Python好学吗?'), ('0.11058545', '学习Python怎样?'), ('0.11080605', '怎样学好Python?'), ('0.11124289', '学生怎么上课的?')]]
3.2.3 基础封装
#lib/SentenceVectorizer
"""
使用fasttext 实现sentence to vector
"""
import fastText
from fastText import FastText
import config
from lib import cut
import logging
import os
class SentenceVectorizer:
def __init__(self):
if os.path.exists(config.recall_fasttext_model_path):
self.model = fastText.load_model(config.recall_fasttext_model_path)
else:
# self.process_data()
self.model = self.build_model()
self.fited = False
def fit_transform(self,sentences):
"""处理全部问题数据"""
lines_vectors = self.fit(sentences)
return lines_vectors
def fit(self,lines):
lines_vectors = []
for q_cuted in lines:
lines_vectors.append(self.model.get_sentence_vector(q_cuted))
self.fited = True
return lines_vectors
def transform(self,sentence):
"""处理用户输入的数据"""
assert self.fited = True
line_vec = self.model.get_sentence_vector(" ".join(sentence))
return line_vec
def build_model(self):
logging.basicConfig(format='%(asctime)s : %(levelname)s : %(message)s', level=logging.INFO)
model = FastText.train_unsupervised(config.recall_fasttext_data_path, epoch=20, minCount=3, wordNgrams=2)
model.save_model(config.recall_fasttext_model_path)
return model
def process_data(self):
path1 = r"corpus\final_data\merged_q.txt"
path2 = r"corpus\final_data\merged_sim_q.txt"
save_path = r"corpus\recall_fasttext_data\data.txt"
filter = set()
with open(path1) as f, open(save_path, "a") as save_f:
for line in f:
line = line.strip()
if line not in filter:
filter.add(line)
_temp = " ".join(cut(line,by_word=True))
save_f.write(_temp + "\n")
with open(path2) as f, open(save_path, "a") as save_f:
for line in f:
line = line.strip()
if line not in filter:
filter.add(line)
_temp = " ".join(cut(line,by_word=True))
save_f.write(_temp + "\n")
问答机器人排序模型
目标
- 知道模型中排序中的概念和目的
- 知道模型中排序的实现方法
1. 排序模型的介绍
前面的课程中为了完成一个问答机器人,我们先进行了召回,相当于是通过海选的方法找到呢大致相似的问题。
通过现在的排序模型,我们需要精选出最相似的哪一个问题,返回对应的答案
2. 排序模型的实现思路
我们需要实现的排序模型是两个输入,即两个问题,输出的是一个相似度。所以和之前的深度学习模型一样,我们需要实现的步骤如下:
- 准备数据
- 构建模型
- 模型评估
- 对外提供接口返回结果
2.1 准备数据
这里的数据,我们使用之前采集的百度问答的相似问题和手动构造的数据。那么,我们需要把他格式化为最终模型需要的格式,即两个输入和输出的相似度。
2.1.1 两个输入
这里的输入,我们可以使用单个字作为特征,也可以使用一个分词之后的词语作为特征。所以在实现准备输入数据方法的过程中,可以提前准备。
2.1.2 相似度准备
这里我们使用每个问题搜索结果的前两页认为他们是相似的,相似度为1,最后两页的结果是不相似的,相似度为0。
2.2 构建模型
介绍模型的构建之前,我们先介绍下孪生神经网络(Siamese Network)和其名字的由来。
Siamese和Chinese有点像。Siamese是古时候泰国的称呼,中文译作暹罗。Siamese在英语中是“孪生”、“连体”的意思。为什么孪生和泰国有关系呢?
十九世纪泰国出生了一对连体婴儿,当时的医学技术无法使两人分离出来,于是两人顽强地生活了一生,1829年被英国商人发现,进入马戏团,在全世界各地表演,1839年他们访问美国北卡罗莱那州后来成为马戏团的台柱,最后成为美国公民。1843年4月13日跟英国一对姐妹结婚,恩生了10个小孩,昌生了12个,姐妹吵架时,兄弟就要轮流到每个老婆家住三天。1874年恩因肺病去世,另一位不久也去世,两人均于63岁离开人间。两人的肝至今仍保存在费城的马特博物馆内。从此之后“暹罗双胞胎”(Siamese twins)就成了连体人的代名词,也因为这对双胞胎让全世界都重视到这项特殊疾病。
所以孪生神经网络就是有两个共享权值的网络的组成,或者只用实现一个,另一个直接调用,有两个输入,一个输出。1993年就已经被用来进行支票签名的验证。
孪生神经网络通过两个输入,被DNN进行编码,得到向量的表示之后,根据实际的用途来制定损失函数。比如我们需要计算相似度的时候,可以使用余弦相似度,或者使用$exp^{-||h^{left}-h^{right}||}$来确定向量的距离。
孪生神经网络被用于有多个输入和一个输出的场景,比如手写字体识别、文本相似度检验、人脸识别等
在计算相似度之前,我们可以考虑在传统的孪生神经网络的基础上,在计算相似度之前,把我们的编码之后的向量通过多层神经网络进行非线性的变化,结果往往会更加好,那么此时其网络结构大致如下:
其中Network1和network2为权重参数共享的两个形状相同的网络,用来对输入的数据进行编码,包括(word-embedding,GRU,biGRU等),Network3部分是一个深层的神经网络,包含(batchnorm、dropout、relu、Linear等层)
2.3 模型的评估
编写预测和评估的代码,预测的过程只需要修改获得结果,不需要上图中的损失计算的过程
3. 代码实现
3.1 数据准备
3.1.1 对文本进行分词分开存储
这里的分词可以对之前的分词方法进行修改
def cut_sentence_by_word(sentence):
# 对中文按照字进行处理,对英文不分为字母
letters = string.ascii_lowercase + "+" + "/" # c++,ui/ue
result = []
temp = ""
for word in line:
if word.lower() in letters:
temp += word.lower()
else:
if temp != "":
result.append(temp)
temp = ""
result.append(word)
if temp != "":
result.append(temp)
return result
def jieba_cut(sentence,by_word=False,with_sg=False,use_stopwords=False):
if by_word:
return cut_sentence_by_word(sentence)
ret = psg.lcut(sentence)
if use_stopwords:
ret = [(i.word, i.flag) for i in ret if i.word not in stopwords_list]
if not with_sg:
ret = [i[0] for i in ret]
return ret
3.1.2 准备word Sequence代码
该处的代码和seq2seq中的代码相同,直接使用
3.1.3 准备Dataset和DataLoader
和seq2seq中的代码大致相同
3.2 模型的搭建
前面做好了准备工作之后,就需要开始进行模型的搭建。
虽然我们知道了整个结构的大致情况,但是我们还是不知道其中具体的细节。
2016年AAAI会议上,有一篇Siamese Recurrent Architectures for Learning Sentence Similarity的论文(地址:https://www.aaai.org/ocs/index.php/AAAI/AAAI16/paper/download/12195/12023)。整个结构如下图:
可以看到word 经过embedding之后进行LSTM的处理,然后经过exp来确定相似度,可以看到整个模型是非常简单的,之后很多人在这个结构上增加了更多的层,比如加入attention、dropout、pooling等层。
那么这个时候,请思考下面几个问题:
-
attention在这个网络结构中该如何实现
-
之前我们的attention是用在decoder中,让decoder的hidden和encoder的output进行运算,得到attention的weight,再和decoder的output进行计算,作为下一次decoder的输入
-
那么在当前我们可以把
句子A的output理解为句子B的encoder的output,那么我们就可以进行attention的计算了和这个非常相似的有一个attention的变种,叫做
self attention。前面所讲的Attention是基于source端和target端的隐变量(hidden state)计算Attention的,得到的结果是源端的每个词与目标端每个词之间的依赖关系。Self Attention不同,它分别在source端和target端进行,仅与source input或者target input自身相关的Self Attention,捕捉source端或target端自身的词与词之间的依赖关系。
-
-
dropout用在什么地方
- dropout可以用在很多地方,比如embedding之后
- BiGRU结构中
- 或者是相似度计算之前
-
pooling是什么如何使用
- pooling叫做池化,是一种降采样的技术,用来减少特征(feature)的数量。常用的方法有
max pooling或者是average pooling
- pooling叫做池化,是一种降采样的技术,用来减少特征(feature)的数量。常用的方法有
3.2.1 编码部分
def forward(self, *input):
sent1, sent2 = input[0], input[1]
#这里使用mask,在后面计算attention的时候,让其忽略pad的位置
mask1, mask2 = sent1.eq(0), sent2.eq(0)
# embeds: batch_size * seq_len => batch_size * seq_len * batch_size
x1 = self.embeds(sent1)
x2 = self.embeds(sent2)
# batch_size * seq_len * dim => batch_size * seq_len * hidden_size
output1, _ = self.lstm1(x1)
output2, _ = self.lstm1(x2)
# 进行Attention的操作,同时进行形状的对齐
# batch_size * seq_len * hidden_size
q1_align, q2_align = self.soft_attention_align(output1, output2, mask1, mask2)
# 拼接之后再传入LSTM中进行处理
# batch_size * seq_len * (8 * hidden_size)
q1_combined = torch.cat([output1, q1_align, self.submul(output1, q1_align)], -1)
q2_combined = torch.cat([output2, q2_align, self.submul(output2, q2_align)], -1)
# batch_size * seq_len * (2 * hidden_size)
q1_compose, _ = self.lstm2(q1_combined)
q2_compose, _ = self.lstm2(q2_combined)
# 进行Aggregate操作,也就是进行pooling
# input: batch_size * seq_len * (2 * hidden_size)
# output: batch_size * (4 * hidden_size)
q1_rep = self.apply_pooling(q1_compose)
q2_rep = self.apply_pooling(q2_compose)
# Concate合并到一起,用来进行计算相似度
x = torch.cat([q1_rep, q2_rep], -1)
atttention的计算
实现思路:
- 先获取attention_weight
- 在使用attention_weight和encoder_output进行相乘
def soft_attention_align(self, x1, x2, mask1, mask2):
'''
x1: batch_size * seq_len_1 * hidden_size
x2: batch_size * seq_len_2 * hidden_size
'''
# attention: batch_size * seq_len_1 * seq_len_2
attention_weight = torch.matmul(x1, x2.transpose(1, 2))
#mask1 : batch_size,seq_len1
mask1 = mask1.float().masked_fill_(mask1, float('-inf'))
#mask2 : batch_size,seq_len2
mask2 = mask2.float().masked_fill_(mask2, float('-inf'))
# weight: batch_size * seq_len_1 * seq_len_2
weight1 = F.softmax(attention_weight + mask2.unsqueeze(1), dim=-1)
#batch_size*seq_len_1*hidden_size
x1_align = torch.matmul(weight1, x2)
#同理,需要对attention_weight进行permute操作
weight2 = F.softmax(attention_weight.transpose(1, 2) + mask1.unsqueeze(1), dim=-1)
x2_align = torch.matmul(weight2, x1)
Pooling实现
池化的过程有一个窗口的概念在其中,所以max 或者是average指的是窗口中的值取最大值还是取平均估值。整个过程可以理解为拿着窗口在源数据上取值
窗口有窗口大小(kernel_size,窗口多大)和步长(stride,每次移动多少)两个概念
-
>>> input = torch.tensor([[[1,2,3,4,5,6,7]]]) >>> F.avg_pool1d(input, kernel_size=3, stride=2) tensor([[[ 2., 4., 6.]]]) #[1,2,3] [3,4,5] [5,6,7]的平均估值
def apply_pooling(self, x):
# input: batch_size * seq_len * (2 * hidden_size)
#进行平均池化
p1 = F.avg_pool1d(x.transpose(1, 2), x.size(1)).squeeze(-1)
#进行最大池化
p2 = F.max_pool1d(x.transpose(1, 2), x.size(1)).squeeze(-1)
# output: batch_size * (4 * hidden_size)
return torch.cat([p1, p2], 1)
3.2.2 相似度计算部分
相似度的计算我们可以使用一个传统的距离计算公式,或者是exp的方法来实现,但是其效果不一定好,所以这里我们使用一个深层的神经网络来实现,使用pytorch中的Sequential对象来实现非常简单
self.fc = nn.Sequential(
nn.BatchNorm1d(self.hidden_size * 8),
nn.Linear(self.hidden_size * 8, self.linear_size),
nn.ELU(inplace=True),
nn.BatchNorm1d(self.linear_size),
nn.Dropout(self.dropout),
nn.Linear(self.linear_size, self.linear_size),
nn.ELU(inplace=True),
nn.BatchNorm1d(self.linear_size),
nn.Dropout(self.dropout),
nn.Linear(self.linear_size, 2),
nn.Softmax(dim=-1)
)
在上述过程中,我们使用了激活函数ELU,而没有使用RELU,因为在有噪声的数据中ELU的效果往往会更好。
$ELU(x)=max(0,x)+min(0,α∗(exp(x)−1))$,其中$\alpha$在torch中默认值为1。
通过下图可以看出他和RELU的区别,RELU在小于0的位置全部为0,但是ELU在小于零的位置是从0到-1的。可以理解为正常的数据汇总难免出现噪声,小于0的值,而RELU会直接把他处理为0,认为其实正常值,但是ELU却会保留他,所以ELU比RELU更有鲁棒性
3.2.3 损失函数部分
在孪生神经网络中我们经常会使用对比损失(Contrastive Loss),作为损失函数,对比损失是Yann LeCun提出的用来判断数据降维之后和源数据是否相似的问题。在这里我们用它来判断两个句子的表示是否相似。
对比损失的计算公式如下: $$ L = \frac{1}{2N}\sum^N_{n=1}(yd^2 + (1-y)max(margin-d,0)^2) $$ 其中$d = ||a_n-b_n||_2$,代表两个两本特征的欧氏距离,y表示是否匹配,y=1表示匹配,y=0表示不匹配,margin是一个阈值,比如margin=1。
上式可分为两个部分,即:
- y = 1时,只剩下左边,$\sum yd^2$,即相似的样本,如果距离太大,则效果不好,损失变大
- y=0的时候,只剩下右边部分,即样本不相似的时候,如果距离小的话,效果反而不好,损失变大
下图红色是相似样本的损失,蓝色是不相似样本的损失
但是前面我们已经计算出了相似度,所以在这里我们有两个操作
- 使用前面的相似度的结果,把整个问题转化为分类(相似,不相似)的问题,或者是转化为回归问题(相似度是多少)
- 不是用前面相似度的计算结果部分,只用编码之后的结果,然后使用对比损失。最后在获取距离的时候使用欧氏距离来计算器相似度
使用DNN+均方误差来计算得到结果
def train(model,optimizer,loss_func,epoch):
model.tarin()
for batch_idx, (q,simq,q_len,simq_len,sim) in enumerate(train_loader):
optimizer.zero_grad()
output = model(q.to(config.device),simq.to(config.device))
loss = loss_func(output,sim.to(config.deivce))
loss.backward()
optimizer.step()
if batch_idx%100==0:
print("...")
torch.save(model.state_dict(), './DNN/data/model_paramters.pkl')
torch.save(optimizer.state_dict(),"./DNN/data/optimizer_paramters.pkl")
model = SiameseNetwork().cuda()
loss = torch.nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
for epoch in range(1,config.epoch+1):
train(model,optimizer,loss,epoch)
使用对比损失来计算得到结果
#contrastive_loss.py
import torch
import torch.nn
class ContrastiveLoss(torch.nn.Module):
"""
Contrastive loss function.
"""
def __init__(self, margin=1.0):
super(ContrastiveLoss, self).__init__()
self.margin = margin
def forward(self, x0, x1, y):
# 欧式距离
diff = x0 - x1
dist_sq = torch.sum(torch.pow(diff, 2), 1)
dist = torch.sqrt(dist_sq)
mdist = self.margin - dist
#clamp(input,min,max),和numpy中裁剪的效果相同
dist = torch.clamp(mdist, min=0.0)
loss = y * dist_sq + (1 - y) * torch.pow(dist, 2)
loss = torch.sum(loss) / 2.0 / x0.size()[0]
return loss
之后只需要把原来的损失函数改为当前的损失函数即可
3.3 不同模型的结果对比
代码封装和对外提供接口
目标
- 能够完成封装的代码
- 能够使用grpc对外提供接口
- 能够使用supervisord完成服务的管理
1. 完成代码的封装
代码封装过程中,需要注意,在整个结构中,我们有很多的结算结果是dump到本地的,为了防止后续每次的重复计算。所以laod的结果,应该提前加载到内容,而不是每次调用load义词
1.1 完成意图识别代码封装
完成判断用户意图的代码,即在使用fasttext的模型,判断用户输入句子的分类
import fastText
import re
from lib import jieba_cut
fc_word_mode = fastText.load_model("./classify/data/ft_classify.model")
fc_word_mode = fastText.load_model("./classify/data/ft_classify_words.model")
def is_QA(sentence_info):
python_qs_list = [" ".join(sentence_info["cuted_sentence"])]
result = fc_word_mode.predict(python_qs_list)
python_qs_list = [" ".join(sentence_info["cuted_word_sentence"])]
words_result = fc_word_mode.predict(python_qs_list)
for index, (label,acc,word_label,word_acc) in enumerate(zip(*result,*words_result)):
label = label[0]
acc = acc[0]
word_label = word_label[0]
word_acc = word_acc[0]
#以label_qa为准,如果预测结果是label_chat,则label_qa的概率=1-labele_chat
if label == "__label__chat":
label = "__label__QA"
acc = 1-acc
if word_label == "__label__chat":
word_label = "__label__QA"
word_acc = 1 - word_acc
if acc>0.95 or word_acc>0.95:
#是QA
return True
else:
return False
1.2 完成对chatbot代码的封装
提供predict的接口
"""
准备闲聊的模型
"""
import pickle
from lib import jieba_cut
import numpy as np
from chatbot import Sequence2Sequence
class Chatbot:
def __init__(self,ws_path="./chatbot/data/ws.pkl",save_path="./chatbot/model/seq2seq_chatbot.ckpt"):
self.ws_chatbot = pickle.load(open(ws_path, "rb"))
self.save_path = save_path
#TODO .....
def predict(self,s):
"""
:param s:没有分词的
:param ws:
:param ws_words:
:return:
"""
#TODO ...
return ans
1.3 完成对问答系统召回的封装
"""
进行召回的方法
"""
import os
import pickle
class Recall:
def __init__(self,topk=20):
# 准备问答的mode等模块
self.topk = topk
def predict(self,sentence):
"""
:param sentence:
:param debug:
:return: [recall list],[entity]
"""
#TODO recall
return recall_list
def get_answer(self,s):
return self.QA_dict[s]
1.4 完成对问答排序模型的封装
"""
深度学习排序
"""
import tensorflow as tf
import pickle
from DNN2 import SiamsesNetwork
from lib import jieba_cut
class DNNSort():
def __init__(self):
#使用词语和单字两个模型的均值作为最后的结果
self.dnn_sort_words = DNNSortWords()
self.dnn_sort_single_word = DNNSortSingleWord()
def predict(self,s,c_list):
sort1 = self.dnn_sort_words.predict(s,c_list)
sort2 = self.dnn_sort_single_word.predict(s,c_list)
for i in sort1:
sort1[i] = (sort1[i]+ sort2[i])/2
sorts = sorted(sort1.items(),key=lambda x:x[-1],reverse=True)
return sorts[0][0],sorts[0][1]
class DNNSortWords:
def __init__(self,ws_path="./DNN2/data/ws_80000.pkl",save_path="./DNN2/model_keras/esim_model_softmax.ckpt"):
self.ws = pickle.load(open(ws_path, "rb"))
self.save_path = save_path
#TOOD ...
def predict(self,s,c_list):
"""
:param s:没有分词的
:param c_list: 带比较的列表
:param ws:
:param ws_words:
:return:
"""
#TOOD ...
return sim_dict
class DNNSortSingleWord:
def __init__(self,ws_path="./DNN2/data/ws_word.pkl",save_path="./DNN2/data/esim_word_model_softmax.ckpt"):
self.ws = pickle.load(open(ws_path, "rb"))
self.save_path = save_path
#TOOD ...
def predict(self,s,c_list):
"""
:param s:没有分词的
:param c_list: 带比较的列表
:param ws:
:param ws_words:
:return:
"""
#TOOD ...
return sim_dict
1.5 实现对聊天记录的保存
不同的用户,连续10分钟内的对话认为是一轮对话,如果10分还没有下一次对话,认为该轮对话结束,如果10分钟后开始对话,认为是下一轮对话。是要是为了保存不同轮中的聊天主题,后续可以实现基本的对话管理。比如用户刚问了python相关的问题,后续如果问题中不带主体,那么就把redis中的python作为其主体
主要实现逻辑为:
- 使用redis存储用户基本的数据
- 使用mongodb存储对话记录
具体思路如下:
- 根据用户id,获取对话id,根据对话id判断当前的对话是否存在
- 如果对话id存在:
- 更新对话的entity,上一次对话的时间,设置对话id的过期时间
- 保存数据到mongodb
- 如果对话id不存在:
- 创建用户的基础信息(user_id,entiry,对话时间)
- 把用户的基础信息存入redis,同时设置对话id和过期四火箭
- 保存数据到mongodb中
"""
获取,更新用户的信息
"""
from pymongo import MongoClient
import redis
from uuid import uuid1
import time
import json
"""
### redis
{
user_id:"id",
user_background:{}
last_entity:[]
last_conversation_time:int(time):
}
userid_conversation_id:""
### monodb 存储对话记录
{user_id:,conversion_id:,from:user/bot,message:"",create_time,entity:[],attention:[]}
"""
HOST = "localhost"
CNVERSION_EXPERID_TIME = 60 * 10 # 10分钟,连续10分钟没有通信,意味着会话结束
class MessageManager:
def __init__(self):
self.client = MongoClient(host=HOST)
self.m = self.client["toutiao"]["dialogue"]
self.r = redis.Redis(host=HOST, port=6379, db=10)
def last_entity(self, user_id):
"""最近一次的entity"""
return json.loads(self.r.hget(user_id, "entity"))
def gen_conversation_id(self):
return uuid1().hex
def bot_message_pipeline(self, user_id, message):
"""保存机器人的回复记录"""
conversation_id_key = "{}_conversion_id".format(user_id)
conversation_id = self.user_exist(conversation_id_key)
if conversation_id:
# 更新conversation_id的过期时间
self.r.expire(conversation_id_key, CNVERSION_EXPERID_TIME)
data = {"user_id": user_id,
"conversation_id": conversation_id,
"from": "bot",
"message": message,
"create_time": int(time.time()),
}
self.m.save(data)
else:
raise ValueError("没有会话id,但是机器人尝试回复....")
def user_message_pipeline(self, user_id, message, create_time, attention, entity=[]):
# 确定用户相关的信息
# 1. 用户是否存在
# 2.1 用户存在,返回用户的最近的entity,存入最近的对话
# 3.1 判断是否为新的对话,如果是新对话,开启新的回话,update用户的对话信息
# 3.2 如果不是新的对话,update用户的对话信息
# 3. 更新用户的基本信息
# 4 返回用户相关信息
# 5. 调用预测接口,发来对话的结构
# 要保存的data数据,缺少conversation_id
data = {
"user_id": user_id,
"from": "user",
"message": message,
"create_time": create_time,
"entity": json.dumps(entity),
"attention": attention,
}
conversation_id_key = "{}_conversion_id".format(user_id)
conversation_id = self.user_exist(conversation_id_key)
print("conversation_id",conversation_id)
if conversation_id:
if entity:
# 更新当前用户的 last_entity
self.r.hset(user_id, "last_entity", json.dumps(entity))
# 更新最后的对话时间
self.r.hset(user_id, "last_conversion_time", create_time)
# 设置conversation id的过期时间
self.r.expire(conversation_id_key, CNVERSION_EXPERID_TIME)
# 保存聊天记录到mongodb中
data["conversation_id"] = conversation_id
self.m.save(data)
print("mongodb 保存数据成功")
else:
# 不存在
user_basic_info = {
"user_id": user_id,
"last_conversion_time": create_time,
"last_entity": json.dumps(entity)
}
self.r.hmset(user_id, user_basic_info)
print("redis存入 user_basic_info success")
conversation_id = self.gen_conversation_id()
print("生成conversation_id",conversation_id)
# 设置会话的id
self.r.set(conversation_id_key, conversation_id, ex=CNVERSION_EXPERID_TIME)
# 保存聊天记录到mongodb中
data["conversation_id"] = conversation_id
self.m.save(data)
print("mongodb 保存数据成功")
def user_exist(self, conversation_id_key):
"""
判断用户是否存在
:param user_id:用户id
:return:
"""
conversation_id = self.r.get(conversation_id_key)
if conversation_id:
conversation_id = conversation_id.decode()
print("load conversation_id",conversation_id)
return conversation_id
2. 使用GRPC对外提供服务
2.1 安装grpc相关环境
gRPC 的安装:`pip install grpcio` 安装 ProtoBuf 相关的 python 依赖库:`pip install protobuf` 安装 python grpc 的 protobuf 编译工具:`pip install grpcio-tools`
2.2 定义GRPC的接口
//chatbot.proto 文件
syntax = "proto3";
message ReceivedMessage {
string user_id = 1; //用户id
string user_message = 2; //当前用户传递的消息
int32 create_time = 3; //当前消息发送的时间
}
message ResponsedMessage {
string user_response = 1; //返回给用户的消息
int32 create_time = 2; //返回给用户的时间
}
service ChatBotService {
rpc Chatbot (ReceivedMessage) returns (ResponsedMessage);
}
2.3 编译生成protobuf文件
使用下面的命令编译,得到chatbot_pb2.py和chatbot_pb2_grpc.py文件
python -m grpc_tools.protoc -I. –python_out=. –grpc_python_out=. ./chatbot.proto
2.4 使用grpc提供服务
import dialogue
from classify import is_QA
from dialogue.process_sentence import process_user_sentence
from chatbot_grpc import chatbot_pb2_grpc
from chatbot_grpc import chatbot_pb2
import time
class chatServicer(chatbot_pb2_grpc.ChatBotServiceServicer):
def __init__(self):
#提前加载各种模型
self.recall = dialogue.Recall(topk=20)
self.dnnsort = dialogue.DNNSort()
self.chatbot = dialogue.Chatbot()
self.message_manager = dialogue.MessageManager()
def Chatbot(self, request, context):
user_id = request.user_id
message = request.user_message
create_time = request.create_time
#对用户的输出进行基础的处理,如分词
message_info = process_user_sentence(message)
if is_QA(message_info):
attention = "QA"
#实现对对话数据的保存
self.message_manager.user_message_pipeline(user_id, message, create_time, attention, entity=message_info["entity"])
recall_list,entity = self.recall.predict(message_info)
line, score = self.dnnsort.predict(message,recall_list)
if score > 0.7:
ans = self.recall.get_answer(line)
user_response = ans["ans"]
else:
user_response = "不好意思,这个问题我还没学习到..."
else:
attention = "chat"
# 实现对对话数据的保存
self.message_manager.user_message_pipeline(user_id,message,create_time,attention,entity=message_info["entity"])
user_response = self.chatbot.predict(message)
self.message_manager.bot_message_pipeline(user_id,user_response)
user_response = user_response
create_time = int(time.time())
return chatbot_pb2.ResponsedMessage(user_response=user_response,create_time=create_time)
def serve():
import grpc
from concurrent import futures
# 多线程服务器
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
# 注册本地服务
chatbot_pb2_grpc.add_ChatBotServiceServicer_to_server(chatServicer(), server)
# 监听端口
server.add_insecure_port("[::]:9999")
# 开始接收请求进行服务
server.start()
# 使用 ctrl+c 可以退出服务
try:
time.sleep(1000)
except KeyboardInterrupt:
server.stop(0)
if __name__ == '__main__':
serve()
3. 使用supervisor完成对服务的管理
3.1 编写简单的执行脚本
#!/bin/bash cd `$dirname`|exit 0 source activate ds python grpc_predict.py
3.2 安装、配置supervisor
supervisor现在的官方版本还是python2的,但是可以使用下面的命令安装python3版本
pip3 install git+https://github.com/Supervisor/supervisor
-
完成supervisor的配置文件的编写,conf中使用分号作为注释符号
;conf.d [program:chat_service] command=/root/chat_service/run.sh ;执行的命令 stdout_logfile=/root/chat_service/log/out.log ;log的位置 stderr_logfile=/root/chat_service/log/error.log ;错误log的位置 directory=/root/chat_service ;路径 autostart=true ;是否自动启动 autorestart=true ;是否自动重启 startretries=10 ;失败的最大尝试次数
-
在supervisor的基础配置中添加上述配置文件
;/etc/supervisord/supervisor.conf [include] files=/root/chat_service/conf.d
-
运行supervisord
supervisord -c /etc/supervisord/supervisor.conf











































































































被折叠的 条评论
为什么被折叠?



