循环神经网络的从零开始实现(RNN)
循环神经网络(Recurrent Neural Network,RNN)是深度学习中的一类核心模型,专为处理序列数据而设计。不同于传统的前馈神经网络,RNN具有循环连接,能够捕捉序列中的时间依赖关系。本文将从零开始,通过代码演示,带你一步步实现一个简单的RNN,并应用于文本生成任务。我们将使用Python和NumPy,不依赖高级框架(如TensorFlow或PyTorch),从而深入理解其内部机制。### 1. RNN的核心原理RNN的核心思想是在每个时间步保留一个"隐藏状态",该状态会随时间步更新,并影响后续输出。数学上,一个简单的RNN单元可以表示为:- 隐藏状态更新 : ( h_t = \tanh(W_{ih} \cdot x_t + b_{ih} + W_{hh} \cdot h_{t-1} + b_{hh}) )- 输出计算 : ( y_t = W_{hy} \cdot h_t + b_{hy} )其中,( x_t ) 是当前时间步的输入,( h_{t-1} ) 是上一时间步的隐藏状态,( W ) 和 ( b ) 是可学习的权重和偏置。这种循环结构使得RNN能够处理任意长度的序列。### 2. 从零实现RNN:前向传播我们将从零构建一个RNN类,包括前向传播。假设任务是一个简单的字符级语言模型,输入是字符的one-hot编码,输出是下一个字符的预测。pythonimport numpy as npclass RNN: def __init__(self, input_size, hidden_size, output_size): """ 初始化RNN参数 :param input_size: 输入维度(词汇表大小) :param hidden_size: 隐藏层大小 :param output_size: 输出维度(通常等于词汇表大小) """ self.hidden_size = hidden_size # 初始化权重矩阵(使用小随机数) self.W_xh = np.random.randn(hidden_size, input_size) * 0.01 # 输入到隐藏层 self.W_hh = np.random.randn(hidden_size, hidden_size) * 0.01 # 隐藏层到隐藏层 self.W_hy = np.random.randn(output_size, hidden_size) * 0.01 # 隐藏层到输出 self.b_h = np.zeros((hidden_size, 1)) # 隐藏层偏置 self.b_y = np.zeros((output_size, 1)) # 输出层偏置 def forward(self, inputs, h_prev=None): """ 前向传播:遍历时间步 :param inputs: 输入序列,形状为 (seq_len, input_size) :param h_prev: 初始隐藏状态,若为None则初始化为零 :return: outputs: 每个时间步的输出,形状为 (seq_len, output_size) h_next: 最终隐藏状态 """ seq_len = inputs.shape[0] if h_prev is None: h_prev = np.zeros((self.hidden_size, 1)) h_current = h_prev outputs = [] hidden_states = [] # 保存所有隐藏状态,用于后续反向传播 for t in range(seq_len): x_t = inputs[t].reshape(-1, 1) # 当前时间步输入,形状 (input_size, 1) # 计算隐藏状态:h_t = tanh(W_xh * x_t + W_hh * h_{t-1} + b_h) h_current = np.tanh(np.dot(self.W_xh, x_t) + np.dot(self.W_hh, h_current) + self.b_h) # 计算输出:y_t = W_hy * h_t + b_y y_t = np.dot(self.W_hy, h_current) + self.b_y outputs.append(y_t) hidden_states.append(h_current) # 将输出堆叠成矩阵形式(seq_len, output_size) outputs = np.array(outputs).squeeze(axis=2) # 移除多余的维度 return outputs, h_current, hidden_states# 测试前向传播if __name__ == "__main__": # 假设词汇表大小为10,隐藏层大小为5,序列长度为3 input_size = 10 hidden_size = 5 output_size = 10 rnn = RNN(input_size, hidden_size, output_size) # 模拟一个输入序列:每个时间步是one-hot编码(随机生成) seq_len = 3 inputs = np.random.rand(seq_len, input_size) # 实际中应为one-hot outputs, h_final, _ = rnn.forward(inputs) print("输出形状:", outputs.shape) # 应为 (3, 10) print("最终隐藏状态形状:", h_final.shape) # 应为 (5, 1)代码解释 :- __init__ 方法初始化所有权重和偏置。我们使用小随机数防止梯度爆炸。- forward 方法遍历每个时间步,更新隐藏状态并计算输出。hidden_states 列表保存所有隐藏状态,为后续反向传播做准备。- 测试部分验证了前向传播的正确性,输出形状符合预期。### 3. 实现反向传播(BPTT)反向传播是训练RNN的关键,它通过时间反向传播(Backpropagation Through Time, BPTT)计算梯度。这里我们实现一个简化版本,仅处理单个样本(序列长度固定)。pythonclass RNNWithBackprop(RNN): def backward(self, inputs, targets, outputs, hidden_states, learning_rate=0.01): """ 反向传播(BPTT) :param inputs: 输入序列,形状 (seq_len, input_size) :param targets: 目标输出,形状 (seq_len, output_size)(one-hot编码) :param outputs: 前向传播的输出,形状 (seq_len, output_size) :param hidden_states: 前向传播的隐藏状态列表 :param learning_rate: 学习率 """ seq_len = inputs.shape[0] # 初始化梯度 dW_xh = np.zeros_like(self.W_xh) dW_hh = np.zeros_like(self.W_hh) dW_hy = np.zeros_like(self.W_hy) db_h = np.zeros_like(self.b_h) db_y = np.zeros_like(self.b_y) dh_next = np.zeros((self.hidden_size, 1)) # 从后续时间步传回的梯度 # 从最后一个时间步开始反向传播 for t in reversed(range(seq_len)): # 输出层梯度 dy = outputs[t].reshape(-1, 1) - targets[t].reshape(-1, 1) # 交叉熵损失简化 dW_hy += np.dot(dy, hidden_states[t].T) db_y += dy # 隐藏层梯度 dh = np.dot(self.W_hy.T, dy) + dh_next # 包括来自输出和下一时间步的梯度 # tanh的导数:1 - tanh^2 dh_raw = (1 - hidden_states[t] ** 2) * dh dW_xh += np.dot(dh_raw, inputs[t].reshape(1, -1)) if t > 0: dW_hh += np.dot(dh_raw, hidden_states[t-1].T) else: dW_hh += np.dot(dh_raw, np.zeros((self.hidden_size, 1)).T) # 初始隐藏状态梯度为零 db_h += dh_raw # 更新dh_next用于下一轮(即更早的时间步) dh_next = np.dot(self.W_hh.T, dh_raw) # 更新参数(梯度下降) self.W_xh -= learning_rate * dW_xh self.W_hh -= learning_rate * dW_hh self.W_hy -= learning_rate * dW_hy self.b_h -= learning_rate * db_h self.b_y -= learning_rate * db_y def train_step(self, inputs, targets, learning_rate=0.01): """一次训练步骤:前向 + 反向""" outputs, _, hidden_states = self.forward(inputs) self.backward(inputs, targets, outputs, hidden_states, learning_rate) # 计算损失(交叉熵) loss = 0 for t in range(inputs.shape[0]): loss += -np.log(outputs[t][np.argmax(targets[t])] + 1e-8) # 防止log(0) return loss# 测试训练步骤if __name__ == "__main__": # 创建一个简单数据集:输入序列为 [1,2,3],目标为 [2,3,4](索引表示字符) vocab_size = 10 hidden_size = 5 rnn = RNNWithBackprop(vocab_size, hidden_size, vocab_size) # 将索引转换为one-hot编码 def to_onehot(indices, vocab_size): seq = np.zeros((len(indices), vocab_size)) for i, idx in enumerate(indices): seq[i, idx] = 1 return seq input_indices = [1, 2, 3] target_indices = [2, 3, 4] inputs = to_onehot(input_indices, vocab_size) targets = to_onehot(target_indices, vocab_size) # 训练几个迭代 for epoch in range(100): loss = rnn.train_step(inputs, targets, learning_rate=0.1) if epoch % 20 == 0: print(f"Epoch {epoch}, Loss: {loss:.4f}")代码解释 :- backward 方法实现了BPTT。我们从最后一个时间步开始,计算输出层的梯度,然后反向传播到隐藏层。关键点是dh_next变量,它携带了从未来时间步传回的梯度,确保时间依赖被正确计算。- 梯度更新使用简单的梯度下降。注意dW_hh的更新中,当t=0时,前一个隐藏状态视为零。- train_step 方法封装了前向传播、反向传播和损失计算。损失使用交叉熵,用于分类任务。- 测试部分模拟了一个简单序列,训练100次后损失应显著下降,表明模型学会了预测下一个字符。### 4. 应用示例:字符级文本生成为了展示RNN的实际效果,我们训练一个简单的字符级语言模型。由于完整训练需要大量数据,这里提供一个框架代码,你可以在真实数据集(如莎士比亚文本)上运行。pythondef generate_text(rnn, start_char_idx, vocab_size, length=10): """生成文本:从起始字符开始,逐步预测下一个字符""" x = np.zeros((1, vocab_size)) x[0, start_char_idx] = 1 generated = [start_char_idx] h = None for _ in range(length): output, h, _ = rnn.forward(x, h) # 从输出分布中采样(使用softmax概率) prob = np.exp(output) / np.sum(np.exp(output)) next_idx = np.random.choice(range(vocab_size), p=prob.flatten()) generated.append(next_idx) # 更新输入为刚预测的字符 x = np.zeros((1, vocab_size)) x[0, next_idx] = 1 return generated# 假设训练完成后,可以使用此函数生成序列# 例如:idx_to_char = {0:'a', 1:'b', ...}# generated_indices = generate_text(rnn, start_idx=0, vocab_size=26, length=20)# print(''.join([idx_to_char[i] for i in generated_indices]))### 5. 总结本文从零实现了循环神经网络的完整流程,包括前向传播、反向传播(BPTT)和参数更新。通过纯NumPy代码,我们深入理解了RNN的核心机制:隐藏状态的循环更新、时间依赖的梯度传播。关键点如下:- 前向传播 :每个时间步更新隐藏状态,并计算输出。隐藏状态是RNN记忆的载体。- 反向传播 :BPTT通过时间展开计算梯度,需要小心处理梯度爆炸/消失问题(实际中常使用梯度裁剪或LSTM/GRU)。- 训练:通过梯度下降最小化损失,模型学会从序列中提取模式。尽管这个实现是教学性的,但它是理解更复杂RNN变体(如LSTM、GRU)的基础。在实际工程中,建议使用深度学习框架(如PyTorch)以提高效率,但掌握底层实现能让你在调试和设计新架构时游刃有余。通过调整隐藏层大小、学习率或使用更长的序列,你可以让这个简单的RNN在文本生成、时间序列预测等任务上发挥威力。