【学习笔记】从零开始实现循环神经网络(无框架)

原文链接

  • 循环神经网络

    img

    • x为序列输入

    • U 为连接每一时刻输入层与隐藏层的权重矩阵

    • V为连接上一时刻与下一时刻隐藏层的权重矩阵
    • U为连接每一时刻隐藏层与输出层的权重矩阵
    • h为隐藏状态单元
    • o为输出状态单元

    对于每一时刻

    • $ht=f(Ux_t+Vh{t-1})$,其中f为激活函数,如tanh
    • $o_t=softmax(Wh_t)$
    • 函数初始化

      hidden_size = 50 # 隐藏层神经元个数
      vocab_size  = 4 # 词汇表大小
      def init_orthogonal(param):
          """
          正交初始化.
          """
          if param.ndim < 2:
              raise ValueError("参数维度必须大于2.")
          rows, cols = param.shape
          # 生成指定大小的服从标准正态分布(均值为0,方差为1)的随机数矩阵
          new_param = np.random.randn(rows, cols) 
          if rows < cols:
              new_param = new_param.T 
          # QR 矩阵分解, q为正交矩阵,r为上三角矩阵
          q, r = np.linalg.qr(new_param)
          # 让q均匀分布,https://arxiv.org/pdf/math-ph/0609050.pdf
              # 从上三角矩阵R中提取对角线元素,并将其存储在一个一维数组d中。参数0表示我们希望提取的对角线位于R的主对角线上。
          d = np.diag(r, 0)
              # 这行代码对d中的每个元素取符号,即将正数映射为+1,负数映射为-1,零保持不变。
              # 这样做是为了获得一个与d相同大小的数组ph,其中元素的值只能为+1、-1或0。
          ph = np.sign(d)
              # 将正交矩阵Q的每一列都乘以对应的ph数组元素,从而调整了Q的方向。
              # 乘以-1的列意味着对应的特征向量在QR分解中被反转了。通过这样的调整,确保了Q矩阵的对角线元素为正数。
          q *= ph
          if rows < cols:
              q = q.T
          new_param = q
          return new_param
      
      def init_rnn(hidden_size, vocab_size):
          """
          初始化循环神经网络参数.
          Args:
          hidden_size: 隐藏层神经元个数
          vocab_size: 词汇表大小
          """
          # 输入到隐藏层权重矩阵
          U = np.zeros((hidden_size, vocab_size))
          # 隐藏层到隐藏层权重矩阵
          V = np.zeros((hidden_size, hidden_size))
          # 隐藏层到输出层权重矩阵
          W = np.zeros((vocab_size, hidden_size))
          # 隐层bias
          b_hidden = np.zeros((hidden_size, 1))
          # 输出层bias
          b_out = np.zeros((vocab_size, 1))
          # 权重初始化
          U = init_orthogonal(U)
          V = init_orthogonal(V)
          W = init_orthogonal(W)   
          return U, V, W, b_hidden, b_out
      
    • 激活函数

      image-20240322233229214

      def sigmoid(x, derivative=False):
          """
          Args:
          x: 输入数据x
          derivative: 如果为True则计算梯度
          """
          x_safe = x + 1e-12
          f = 1 / (1 + np.exp(-x_safe))
          if derivative: 
              return f * (1 - f)
          else: 
              return f
      def tanh(x, derivative=False):
          x_safe = x + 1e-12
          f = (np.exp(x_safe)-np.exp(-x_safe))/(np.exp(x_safe)+np.exp(-x_safe))
          if derivative: 
              return 1-f**2
          else: 
              return f
      def softmax(x, derivative=False):
          x_safe = x + 1e-12
          f = np.exp(x_safe) / np.sum(np.exp(x_safe))
      
          if derivative:
              pass # 本文不计算softmax函数导数
          else: 
              return f
      
    • RNN前向传播

      def forward_pass(inputs, hidden_state, params):
          """
          RNN前向传播计算
          Args:
          inputs: 输入序列
          hidden_state: 初始化后的隐藏状态参数
          params: RNN参数
          """
          U, V, W, b_hidden, b_out = params
          outputs, hidden_states = [], []
      
          for t in range(len(inputs)):
              # 计算隐藏状态
              hidden_state = tanh(np.dot(U, inputs[t]) + np.dot(V, hidden_state) + b_hidden)
              # 计算输出
              out = softmax(np.dot(W, hidden_state) + b_out)
              # 保存中间结果
              outputs.append(out)
              hidden_states.append(hidden_state.copy())
          return outputs, hidden_states
      
    • RNN后向传播

      def clip_gradient_norm(grads, max_norm=0.25):
          """
          梯度剪裁防止梯度爆炸
          """ 
          max_norm = float(max_norm)
          total_norm = 0
          # 对梯度列表进行遍历
          for grad in grads:
              # 计算当前梯度的平方范数
              grad_norm = np.sum(np.power(grad, 2))
              total_norm += grad_norm
          # 所有梯度的总范数
          total_norm = np.sqrt(total_norm)
          # 计算剪裁系数
          clip_coef = max_norm / (total_norm + 1e-6)
          # 剪裁梯度
          if clip_coef < 1:
              for grad in grads:
                  grad *= clip_coef
          return grads
      def backward_pass(inputs, outputs, hidden_states, targets, params):
          """
          后向传播
          Args:
           inputs: 序列输入
           outputs: 输出
           hidden_states: 隐藏状态
           targets: 预测目标
           params: RNN参数
          """
          U, V, W, b_hidden, b_out = params
          d_U, d_V, d_W = np.zeros_like(U), np.zeros_like(V), np.zeros_like(W)
          d_b_hidden, d_b_out = np.zeros_like(b_hidden), np.zeros_like(b_out)
          # 跟踪隐藏层偏导及损失
          d_h_next = np.zeros_like(hidden_states[0])
          loss = 0
          # 对于输出序列当中每一个元素,反向遍历 s.t. t = N, N-1, ... 1, 0
          for t in reversed(range(len(outputs))):
              # 交叉熵损失
              loss += -np.mean(np.log(outputs[t]+1e-12) * targets[t])
              # d_loss/d_o
              d_o = outputs[t].copy()
              d_o[np.argmax(targets[t])] -= 1  
              # d_o/d_w
              d_W += np.dot(d_o, hidden_states[t].T)
              d_b_out += d_o   
              # d_o/d_h
              d_h = np.dot(W.T, d_o) + d_h_next
              # Backpropagate through non-linearity
              d_f = tanh(hidden_states[t], derivative=True) * d_h
              d_b_hidden += d_f  
              # d_f/d_u
              d_U += np.dot(d_f, inputs[t].T)
              # d_f/d_v
              d_V += np.dot(d_f, hidden_states[t-1].T)
              d_h_next = np.dot(V.T, d_f)
          grads = d_U, d_V, d_W, d_b_hidden, d_b_out    
          # 梯度裁剪
          grads = clip_gradient_norm(grads)
          return loss, grads
      
    • 梯度优化

      # 随机梯度下降法
      def update_parameters(params, grads, lr=1e-3):
          # lr, learning rate, 学习率
          for param, grad in zip(params, grads):
              param -= lr * grad
          return params
      
  • LSTM

    img

    img

    包含5个基本组件,它们是:

    • 单元状态(cell_state)-储存短期记忆和长期记忆的储存单元
    • 隐藏状态(hidden_state) -根据当前输入、先前的隐藏状态和当前单元状态信息计算得到的输出状态信息
    • 输入门(input_gate)-控制从当前输入流到单元状态的信息量
    • 遗忘门(forget_gate)-控制当前输入和先前单元状态中有多少信息流入当前单元状态
    • 输出门(output_gate)-控制有多少信息从当前单元状态进入隐藏状态

    img

    三个门控单元及一个细胞状态单元的输入都是一样的,都是当前时刻的输入$xt$以及上一个隐藏状态$h{t-1}$, 但是他们的参数是不同的,同时细胞状态单元使用的是$tanh$激活函数。

    遗忘门$ft$,上一细胞状态$c{t-1}$, 记忆门$i_t$ 及当前细胞状态一起决定输出细胞状态 $c_t$。

    输出门 $o_t$及 $c_t$共同决定隐藏状态输出 $h_t$。

    • 参数初始化

      # 输入单元 + 隐藏单元 拼接后的大小
      z_size = hidden_size + vocab_size 
      def init_lstm(hidden_size, vocab_size, z_size):
          """
          LSTM网络初始化
          Args:
           hidden_size: 隐藏单元大小
           vocab_size: 词汇表大小
           z_size: 隐藏单元 + 输入单元大小
          """
          # 遗忘门参数矩阵及偏置
          W_f = np.random.randn(hidden_size, z_size)
          b_f = np.zeros((hidden_size, 1))
          # 记忆门参数矩阵及偏置
          W_i = np.random.randn(hidden_size, z_size)
          b_i = np.zeros((hidden_size, 1))
          # 细胞状态参数矩阵及偏置
          W_g = np.random.randn(hidden_size, z_size)
          b_g = np.zeros((hidden_size, 1))
          # 输出门参数矩阵及偏置
          W_o = np.random.randn(hidden_size, z_size)
          b_o = np.zeros((hidden_size, 1))
          # 连接隐藏单元及输出的参数矩阵及偏置
          W_v = np.random.randn(vocab_size, hidden_size)
          b_v = np.zeros((vocab_size, 1))
          # 正交参数初始化
          W_f = init_orthogonal(W_f)
          W_i = init_orthogonal(W_i)
          W_g = init_orthogonal(W_g)
          W_o = init_orthogonal(W_o)
          W_v = init_orthogonal(W_v)
          return W_f, W_i, W_g, W_o, W_v, b_f, b_i, b_g, b_o, b_v
      
    • LSTM前向计算

      def forward(inputs, h_prev, C_prev, p):
          """
          Arguments:
          inputs: [..., x, ...],  x表示t时刻的数据,  x的维度为(n_x, m).
          h_prev: t-1时刻的隐藏状态数据,维度为 (n_a, m)
          C_prev: t-1时刻的细胞状态数据,维度为 (n_a, m)
          p:列表,包含LSTM初始化当中所有参数:
                              W_f: (n_a, n_a + n_x)
                              b_f: (n_a, 1)
                              W_i: (n_a, n_a + n_x)
                              b_i: (n_a, 1)
                              W_g: (n_a, n_a + n_x)
                              b_g: (n_a, 1)
                              W_o: (n_a, n_a + n_x)
                              b_o: (n_a, 1)
                              W_v: (n_v, n_a)
                              b_v: (n_v, 1)
          Returns:
          z_s, f_s, i_s, g_s, C_s, o_s, h_s, v_s:每一次前向传播后的中间输出
          outputs:t时刻的预估值, 维度为(n_v, m)
          """
          assert h_prev.shape == (hidden_size, 1)
          assert C_prev.shape == (hidden_size, 1)
          W_f, W_i, W_g, W_o, W_v, b_f, b_i, b_g, b_o, b_v = p
          # 保存各个单元的计算输出值
          x_s, z_s, f_s, i_s,  = [], [] ,[], []
          g_s, C_s, o_s, h_s = [], [] ,[], []
          v_s, output_s =  [], [] 
          h_s.append(h_prev)
          C_s.append(C_prev)
          for x in inputs:
              # 拼接输入及隐藏状态单元数据
              z = np.row_stack((h_prev, x))
              z_s.append(z)
              # 遗忘门计算
              f = sigmoid(np.dot(W_f, z) + b_f)
              f_s.append(f)
              # 输入门计算
              i = sigmoid(np.dot(W_i, z) + b_i)
              i_s.append(i) 
              # 细胞状态单元计算
              g = tanh(np.dot(W_g, z) + b_g)
              g_s.append(g)
              # 下一时刻细胞状态计算
              C_prev = f * C_prev + i * g 
              C_s.append(C_prev)
              # 输出门计算
              o = sigmoid(np.dot(W_o, z) + b_o)
              o_s.append(o)
              # 隐藏状态单元计算
              h_prev = o * tanh(C_prev)
              h_s.append(h_prev)
              # 计算预估值
              v = np.dot(W_v, h_prev) + b_v
              v_s.append(v)
              # softmax转换
              output = softmax(v)
              output_s.append(output)
          return z_s, f_s, i_s, g_s, C_s, o_s, h_s, v_s, output_s
      
    • LSTM后向传播

      def backward(z, f, i, g, C, o, h, v, outputs, targets, p = params):
          """
          Arguments:
          z,f,i,g,C,o,h,v,outputs:对应前向传播输出
          targets: 目标值
          p:W_f,b_f,W_i,b_i,W_g,b_g,W_o,b_o,W_v,b_v, LSTM参数
          Returns:
          loss:交叉熵损失
          grads:p中参数的梯度
          """
          W_f, W_i, W_g, W_o, W_v, b_f, b_i, b_g, b_o, b_v = p
          # 初始化梯度为0
          W_f_d = np.zeros_like(W_f)
          b_f_d = np.zeros_like(b_f)
      
          W_i_d = np.zeros_like(W_i)
          b_i_d = np.zeros_like(b_i)
      ​
          W_g_d = np.zeros_like(W_g)
          b_g_d = np.zeros_like(b_g)
      ​
          W_o_d = np.zeros_like(W_o)
          b_o_d = np.zeros_like(b_o)
      ​
          W_v_d = np.zeros_like(W_v)
          b_v_d = np.zeros_like(b_v)
      
          dh_next = np.zeros_like(h[0])
          dC_next = np.zeros_like(C[0])    
          # 记录loss
          loss = 0
          for t in reversed(range(len(outputs))):
              loss += -np.mean(np.log(outputs[t]) * targets[t])
              C_prev= C[t-1]
              #输出梯度
              dv = np.copy(outputs[t])
              dv[np.argmax(targets[t])] -= 1
              #隐藏单元状态对输出的梯度
              W_v_d += np.dot(dv, h[t].T)
              b_v_d += dv
              #隐藏单元梯度
              dh = np.dot(W_v.T, dv)        
              dh += dh_next
              do = dh * tanh(C[t])
              do = sigmoid(o[t], derivative=True)*do
              #输入单元梯度
              W_o_d += np.dot(do, z[t].T)
              b_o_d += do
              #下一时刻细胞状态梯度
              dC = np.copy(dC_next)
              dC += dh * o[t] * tanh(tanh(C[t]), derivative=True)
              dg = dC * i[t]
              dg = tanh(g[t], derivative=True) * dg 
              # 当前时刻细胞状态梯度
              W_g_d += np.dot(dg, z[t].T)
              b_g_d += dg
              # 输入单元梯度
              di = dC * g[t]
              di = sigmoid(i[t], True) * di
              W_i_d += np.dot(di, z[t].T)
              b_i_d += di
              # 遗忘单元梯度
              df = dC * C_prev
              df = sigmoid(f[t]) * df
              W_f_d += np.dot(df, z[t].T)
              b_f_d += df
              # 上一时刻隐藏状态及细胞状态梯度
              dz = (np.dot(W_f.T, df)
                   + np.dot(W_i.T, di)
                   + np.dot(W_g.T, dg)
                   + np.dot(W_o.T, do))
              dh_prev = dz[:hidden_size, :]
              dC_prev = f[t] * dC
          grads= W_f_d, W_i_d, W_g_d, W_o_d, W_v_d, b_f_d, b_i_d, b_g_d, b_o_d, b_v_d
          # 梯度裁剪
          grads = clip_gradient_norm(grads)
          return loss, grads
      
    • 训练过程

      RNN和LSTM网络的前后向传播都实现完毕,两者的训练过程一致,整个训练过程包括 输入数据->数据预处理->前向传播->后向传播->更新参数。

      # 遍历训练集当中的序列数据
      for inputs, targets in training_set:
          # 对数据进行预处理
          inputs_one_hot = one_hot_encode_sequence(inputs, vocab_size)
          targets_one_hot = one_hot_encode_sequence(targets, vocab_size)
          # 针对每个训练样本,定义一个隐状态参数
          hidden_state = np.zeros_like(hidden_state)
          # 前向传播
          outputs, hidden_states = forward_pass(inputs_one_hot, hidden_state, params)
          # 后向传播
          loss, grads = backward_pass(inputs_one_hot, outputs, hidden_states, targets_one_hot, params)
          # 梯度更新
          params = update_parameters(params, grads, lr=3e-4)
      

results matching ""

    No results matching ""