# LSTM模型预测文本序列 ## 神经网络基础概念 参考资料: [3Blue1Brown深度学习](https://www.bilibili.com/video/BV1bx411M7Zx/?spm_id_from=333.999.0.0&vd_source=d3f7e7e717aa7d163087d89cd260bddb) ### 神经网络的基本结构与前向传播 #### 基本结构 神经网络,顾名思义源自人的大脑结构,但不同于生物学上的神经细胞的二元状态(激活/未激活),深度学习中的神经元是一个连续的数字 ![image-20221116144231937](https://pic.shellmiao.com/2022/11/16/63748656e173e.png) 在下面的任务中,需要识别28x28像素的手写数字图片;如图,每个像素的灰度值即可代表第一层输入层的神经元 ![image-20221116144339921](https://pic.shellmiao.com/2022/11/16/6374869b64d8f.png) 将28x28的图片像素平铺后,即可得到784个输入层神经元,如下图 ![image-20221116144034179](https://pic.shellmiao.com/2022/11/16/637485e8d2ecc.png) 在上面的模型中,可以看到,除了784个神经元组成的输入层,以及最右侧10个神经元组成的输出层(神经元的值越大,则代表模型预测图片对应该数字的可能性越大),中间还有两个16个神经元组成的隐含层。 > 为什么要加入两个隐含层呢,以及为什么要将其设置为16个神经元呢?这里可以简单地理解为隐含层处理了识别数字的子任务(例如:识别横、竖、圆等,虽然实际上并非如此,因为场景有限机器并没有像人脑识别世界一样去学习,而是收敛到了一个相对稳定能完成识别任务的区域),因此这里的隐含层的设置其实有很多可以设置的空间。 而每一层神经元互相连接的线,就是前向传播与反向传播的通道,代表了两层神经元之间的关系。 #### 前向传播 其中,每条线包含了一个权重值,数学上的表示为: $$ 设第一层神经元为a_1^{(0)}、a_2^{(0)}、a_3^{(0)}、a_4^{(0)}...\\ 第二层神经元为a_1^{(1)}、a_2^{(1)}、a_3^{(1)}、a_4^{(1)}...\\ 两层之间的连接为w_{11}、w_{21}...\\ 则计算a_1^{(1)}时:a_1^{(1)}=sigmoid(w_{11}a_1^{(0)}+w_{21}a_2^{(0)}+w_{31}a_3^{(0)}+...+bias) $$ 其中sigmoid函数用于对结果进行归一化(因为最终得到的结果应该是神经元的值,而神经元的值在0-1之间),其函数图像如下: ![image-20221116150801958](https://pic.shellmiao.com/2022/11/16/63748c5110efd.png) 而bias是偏置,目的是改变权重与输入相乘所得结果的范围,能够表明此神经元是否更容易被激活 将上述数学表达式换为矩阵表达如下: ![image-20221116151451515](https://pic.shellmiao.com/2022/11/16/63748dea82bcf.png) $$ 前向传播函数:a^{(1)}=sigmoid(Wa^{0}+b) $$ 这里可以看出,此模型本质是一个以所有权重w以及偏置b为参数的函数,以784个神经元代表的图像为输入,以10个神经元代表的结果为输出。而训练模型的过程就是调节这些参数的过程。 ### 神经网络的训练-成本函数与反向传播 #### 成本函数 在训练的最开始,我们会随机的初始化所有的权重w和偏置b,此时进行输入后得到的结果必定是错误的。因此需要定义一个函数来代表错误的程度,这里可以定义为如下图 ![image-20221116152605946](https://pic.shellmiao.com/2022/11/16/6374908d2b950.png) $$ 即\sum(实际输出-应有输出)^2 $$ 成本函数的定义不唯一,还会有其他方式定义的成本函数,依据具体情况而定。 #### 梯度下降 但是只知道错误的程度并不够,还需要知道如何调整所有的参数来获取更加正确的输出。 因此,可以先考虑一个一元函数如下图: ![image-20221116153029224](https://pic.shellmiao.com/2022/11/16/63749194719be.png) 通过斜率可以清楚w调整的方向,并且当调整的数值与斜率的绝对值成正比时,w将最终收敛于函数的一个局部最小值。 回到成本函数,训练模型的过程其实就是努力最小化成本函数的过程,而这一过程其实并不复杂,想象一个结构十分简单的神经网络,每一层只有一个神经元,就可以得到下图: ![image-20221116154017479](https://pic.shellmiao.com/2022/11/16/637493e079e60.png) $$ Cost=(a^{(L)}-y)^2\\ a^{(L)}=sigmoid(z^{(L)})\\ z^{(L)}=w^{(L)}a^{(L-1)}+b^{(L)}\\ 其中因变量为Cost,自变量为w、b\\ 因此,梯度只需要分别对w、b求导即可(使用求导链式法则)\\ \frac{\partial Cost}{\partial w^{(L)}}=\frac{\partial Cost}{\partial a^{(L)}}\frac{\partial a^{(L)}}{\partial z^{(L)}}\frac{\partial z^{(L)}}{\partial w^{(L)}}=[2(a^{(L)}-y)][sigmoid^{'}(z^{(L))})]a^{(L-1)}\\ \frac{\partial Cost}{\partial b^{(L)}}=\frac{\partial Cost}{\partial a^{(L)}}\frac{\partial a^{(L)}}{\partial z^{(L)}}\frac{\partial z^{(L)}}{\partial b^{(L)}}=[2(a^{(L)}-y)][sigmoid^{'}(z^{(L))})]\\ 即可得到对于w、b的梯度向量 $$ 获取梯度向量后,即可通过下列公式进行学习 $$ w_{new}=w_{old}-\eta\frac{\partial Cost}{\partial w_old}\\ (\eta为学习率) $$ 而对于隐含层中的参数,同样可以基于链式法则求得梯度 $$ 通过求取\frac{\partial Cost}{\partial a^{(L-1)}},再求\frac{\partial a^{(L-1)}}{\partial w^{(L-1)}}、\frac{\partial a^{(L-1)}}{\partial b^{(L-1)}}即可\\以此类推可以求得所有w、b的梯度 $$ 不断输入训练数据进行正向传播预测,计算成本函数,然后反向传播进行梯度下降,最终收敛至稳定,这就是神经网络训练的基本过程 ## RNN(循环神经网络)结构与运行机制 ### RNN的前向传播 理解了基础的神经网络,循环神经网络其实就不难理解了。上图: ![image-20221116164436280](https://pic.shellmiao.com/2022/11/16/6374a2f4a9bd7.png) 对于传统的神经网络,输入x1得到y1,输入x2得到y2。但是现实中很多任务需要将x1、x2关联,例如,输入“i”得到“我”,然后输入“like”得到“喜欢”;如果没有记住之前的输入“i”,那么“like的输出也可能是“像”。因此引入RNN模型,能够记忆之前的输入。 那么RNN是如何记忆的呢?如下图: ![image-20221116164731873](https://pic.shellmiao.com/2022/11/16/6374a3a41758d.png) 从左到右是时间的变化,可以看到随着时间的变化,有了不同的输入x,不同的输出o,以及不同的隐含层神经元值s $$ s_{t}=Ux_{t}+Ws_{t-1}\\ o_t=Vs_t\\ 因此,U、W、V为此模型的自变量参数 $$ 在传统神经网络中,隐含层的前向传播一般如下: $$ s=wx+b $$ 但在RNN中,隐含层的前向传播如下: $$ s_t=w_xx_t+w_ss_{t-1}+b $$ 可以看到,这里的前向传播引入了上一时刻的隐含层神经元值,因此RNN具有了记忆的功能 ### RNN的反向传播 RNN也采用梯度下降反向传播的方法进行训练,而RNN的反向传播,自然也需要与时序有关。由于不同时序下模型使用的参数均为同一套参数,因此在不同时序下调整的参数也是相同的。训练过程如下图: ![image-20221116165221072](https://pic.shellmiao.com/2022/11/16/6374a4c558328.png) 这里,成本函数的定义为不同时序下的输出对应的成本函数之和,即 $$ Cost=\sum^{T}_{t=0}Cost_t $$ 因此,在计算梯度下降时,可得到下列公式: $$ 当使用交叉熵函数为损失函数,激活函数使用softmax时\\ \frac{\partial Cost}{\partial V}=\sum_{t=0}^T\frac{\partial Cost_t}{\partial V}\\ \frac{\partial Cost}{\partial W}=\sum_{t=0}^T\sum_{k=0}^t\frac{\partial Cost_t}{\partial s_t}(\prod_{j=k+1}^t\frac{\partial s_j}{\partial s_{j-1}})\frac{\partial s_k}{\partial W}\\ \frac{\partial Cost}{\partial U}=\sum_{t=0}^T\sum_{k=0}^t\frac{\partial Cost_t}{\partial s_t}(\prod_{j=k+1}^t\frac{\partial s_j}{\partial s_{j-1}})\frac{\partial s_k}{\partial U}\\ $$ 可以看到对于U、W的梯度公式,包含了每个时序的损失值。这里个人没有细致推导(引用了网上的结果),记个TODO - [ ] 推导反向传播公式 ## LSTM(长短期记忆网络)结构与运行机制 ### LSTM与普通RNN 继续观察刚才的公式,另一个值得注意的地方是,W和U的偏导的计算结果中都包含了一个连乘项。若连乘的项目数量不断增加,即t极大时,若$\frac{\partial s_{j-1}}{\partial s_j}$介于0~1之间,则连乘结果将无限接近于0,反之若大于1,则连乘结果将趋近正无穷。 这就是RNN不可避免的梯度消失*(Gradient Vanishing)*和梯度爆炸*(Gradient Explosion)*问题。 梯度消失/爆炸现象导致在梯度下降的计算中会出现极小/极大的梯度值,从而致使模型参数在训练中难以被继续更新。 因此,一般的RNN模型难以应对较长序列的输入,隐藏层节点的记忆能力有限,无法持续存储过去长远的信息。 针对这一问题,改进后的长短期记忆网络*(Long Short Term Memory Network)*横空出世。它通过增加一个并行的记忆通道,专门用于存储长期信息,称作*Cell State(可以理解为用于记忆的细胞)*。该通道通过累加运算消除了连乘带来的影响。也因此,LSTM在NLP任务中比RNN更为常用。 LSTM的结构如下图: ![image-20221116175642353](https://pic.shellmiao.com/2022/11/16/6374b3dd6f6e3.png) RNN的结构如下图: ![image-20221116175805992](https://pic.shellmiao.com/2022/11/16/6374b42e1967a.png) 可以看到,LSTM在不同的时序下多传输和保留了一类数据,即$c_t$,这就是LSTM新增的记忆通道:Cell State ### LSTM的前向传播 ![image-20221116180311953](https://pic.shellmiao.com/2022/11/16/6374b5600e51c.png) $$ 遗忘因子:f_t=sigmoid(W_{xf}x_t+W_{hf}h_{t-1}+b_f)\\ 更新因子g_t:g_t=tanh(W_{xg}x_t+W_{hg}h_{t-1}+b_g)\\ 更新银子i_t:i_t=sigmoid(W_{xi}x_t+W_{hi}h_{t-1}+b_i)\\ 合并长期记忆与短期记忆得到c_t:c_t=c_{t-1}*f_t+g_t*i_t\\ 输出因子o_t:o_t=sigmoid(W_{xo}x_t+W_{ho}h_{t-1}+b_o)\\ 输出因子m_t:m_t=tanh(c_t)\\ 合并长期记忆与短期记忆得到h_t:h_t=o_t*m_t\\ 最终该时序下输出为y_t=W_{yh}h_t+b_y $$ 这里具体的原理本人也没有进行推导,再记个TODO - [ ] 理解LSTM长短期记忆原理 ## LSTM模型预测文本序列实践 简单了解了原理,接下来通过预测文本来实践一下 如下是莎士比亚的一个剧本TXT文件: ![image-20221117003854271](https://pic.shellmiao.com/2022/11/17/63751224aab67.png) ### 数据预处理 首先读入数据 ```python input_filepath = "./shakespeare.txt" text = open(input_filepath, 'r').read() ``` 可以输出看到结果如下 ![image-20221117014139263](https://pic.shellmiao.com/2022/11/17/637520d276e19.png) 生成词库 ```python vocab = sorted(set(text)) # set为一个无序不重复元素集,因此不重复地包含了text中所有的字符 ``` ![image-20221117014313326](https://pic.shellmiao.com/2022/11/17/637521308e157.png) 生成由 字符->数字 的字典 ```python char2idx = {char:idx for idx, char in enumerate(vocab)} # 这里使用了python的简洁语法糖,以char:idx的形式对vocab进行遍历,并赋值到{}字典中 ``` ![image-20221117014429959](https://pic.shellmiao.com/2022/11/17/6375217d301b5.png) 将词库转换为numpy中的array ```python idx2char = np.array(vocab) # 为了后续能够提供给tensorflow作数据集 ``` ![image-20221117014502275](https://pic.shellmiao.com/2022/11/17/6375219d7d145.png) 将数据加载到dataset中,并处理数据 ```python # 将array转换为tensor格式的数据 char_dataset = tf.data.Dataset.from_tensor_slices(text_as_int) # 使用tensor的方法将数据集每个100个字符进行batch分序列 seq_length = 100 seq_dataset = char_dataset.batch(seq_length + 1, drop_remainder = True) # 这样就可以通过输入第1到100个字符,让模型预测第101个字符来训练 ``` ![image-20221117014553986](https://pic.shellmiao.com/2022/11/17/637521d13359e.png) 将数据分割成输入和输出两个部分 ```python def split_input_target(id_text): # 将序列作如下变化 abcde -> abcd, bcde return id_text[0:-1], id_text[1:] seq_dataset = seq_dataset.map(split_input_target) # 这里获得了模型的输入与预期输出,即输入第1到100个字符,让模型预测输出第2到101个字符 ``` ![image-20221117014727453](https://pic.shellmiao.com/2022/11/17/6375222ea36da.png) 打乱数据,batch分组,batch_size=64 ```python batch_size = 64 buffer_size = 10000 # 将数据打乱顺序并分组,分组主要是为了防止对单一样本进行梯度下降,导致长时间无法找到对于全局来说最优的梯度,分组可以保证始终取部分样本的梯度均值 seq_dataset = seq_dataset.shuffle(buffer_size).batch(batch_size, drop_remainder=True) ``` ### 构造模型 定义模型常量 ```python vocab_size = len(vocab)# 词库大小,即此位置下的字符为词库中某字符的概率是多少 embedding_dim = 256 rnn_units = 1024 ``` 定义model模型 ```python inputs = keras.Input(batch_input_shape=(batch_size,None)) # 这里的size中的None代表可能为任何正整数,因此后续的输入长度可以为任意长度 outputs = keras.layers.Embedding(vocab_size, embedding_dim)(inputs) # 这里是一个嵌入层,用于将词的下标(这里是字符在词库中的下标)映射到词向量上(一个数据映射到一个向量上),这里我认为可以理解为变为one-hot编码(one-hot编码十分稀疏,embedding会更稠密一些) outputs = keras.layers.LSTM(units = rnn_units,stateful = True,recurrent_initializer='glorot_uniform',return_sequences = True)(outputs) # keras库封装好的LSTM层 outputs = keras.layers.Dense(vocab_size)(outputs) # 输出层 model = Model(inputs, outputs) model.summary() ``` ![image-20221117015421074](https://pic.shellmiao.com/2022/11/17/637523cc4a4f7.png) 定义损失函数和优化器 ```python # 定义优化器和自定义损失函数 def loss(labels, logits): return keras.losses.sparse_categorical_crossentropy(labels, logits, from_logits=True) # 这里是一个交叉熵函数 model.compile(optimizer = 'adam', loss = loss) ``` 其他交叉熵函数及其区别和应用场景可以看这里:[传送门](https://zhuanlan.zhihu.com/p/112314557) ### 训练模型 checkpoints,顾名思义,保存检查点可以多次训练 ```python output_dir = "./text_generation_lstm3_checkpoints" if not os.path.exists(output_dir): os.mkdir(output_dir) checkpoint_prefix = os.path.join(output_dir, 'ckpt_{epoch}') checkpoint_callback = keras.callbacks.ModelCheckpoint(filepath = checkpoint_prefix,save_weights_only = True) ``` 开始训练 ```python epochs = 100 history = model.fit(seq_dataset, epochs = epochs,callbacks = [checkpoint_callback]) # 会自动找到最近保存的变量文件 new_checkpoint = tf.train.latest_checkpoint(output_dir) ``` ### 使用模型进行预测 加载模型 ```python # Model 定义预测模型 inputs = keras.Input(batch_input_shape=(1,None)) print(inputs.shape) outputs = keras.layers.Embedding(vocab_size, embedding_dim)(inputs) print(outputs.shape) outputs = keras.layers.LSTM(units = rnn_units,stateful = True,recurrent_initializer='glorot_uniform', return_sequences = True)(outputs) print(outputs.shape) outputs = keras.layers.Dense(vocab_size)(outputs) print(outputs.shape) model2 = Model(inputs, outputs) model2.load_weights(tf.train.latest_checkpoint(output_dir)) model2.summary() ``` 预测模型做预测 ```python def generate_text(model, start_string, num_generate = 1000): input_eval = [char2idx[ch] for ch in start_string] input_eval = tf.expand_dims(input_eval, 0) text_generated = [] model.reset_states() for _ in range(num_generate): # 输入进入模型,得到预测 predictions = model(input_eval) # 去掉一维的结构 predictions = tf.squeeze(predictions, 0) # 根据此位置的词库中不同字符的可能性进行采样,获得预测出的字符 predicted_id = tf.random.categorical(predictions, num_samples = 1)[-1, 0].numpy() # 将预测出的字符最后一位添加到已经生成的字符串末尾 text_generated.append(idx2char[predicted_id]) # 将预测出的字符最后一位作为输入再次输入到模型中预测 input_eval = tf.expand_dims([predicted_id], 0) return start_string + ''.join(text_generated) new_text = generate_text(model2, "Al",10) print(new_text) ``` ![image-20221117022243277](https://pic.shellmiao.com/2022/11/17/63752a7264c2d.png) 这里理论上应该还有一个采样函数,用于自定义在采样时的偏好,时间关系这里就不多写了,有兴趣可以参考[采样策略](https://clownote.github.io/2020/08/20/DeepLearningWithPython/Deep-Learning%20with-Python-ch8_1/#%E9%87%87%E6%A0%B7%E7%AD%96%E7%95%A5) ### 总结 最终训练得到的模型一共进行了100轮训练,最终得到的效果对单词的预测完整度尚可,但句子较少构成语义。同时keras的封装带来方便的同时也掩盖了很多算法的实现,之后还是需要继续完善这里的理解。