深度学习-循环神经网络的从零实现
循环神经网络(RNN)作为处理序列数据的利器,在自然语言处理、时间序列预测等领域有着广泛应用。本文将基于 PyTorch 框架,从零开始实现一个 RNN 模型,并用于文本预测任务,帮助读者深入理解 RNN 的工作原理。
一、RNN 基础理论
循环神经网络的核心优势在于其能够处理可变长度的序列数据,并通过 "记忆" 机制捕捉序列中的时序依赖关系。与前馈神经网络不同,RNN 在每一步计算时会接收当前输入和上一步的隐藏状态,从而实现信息的传递。
基本结构包含:
输入层:接收序列数据
隐藏层:保存历史信息,计算公式为:
输出层:基于当前隐藏状态生成预测,计算公式为:
二、数据集准备
本文使用《时间机器》文本数据作为训练集,首先需要对文本进行预处理:
import torch
import NaturalLanguage_Dataset
# 批量大小和序列长度
batch_size, num_steps = 32, 35
# 加载数据并构建词汇表
train_iter, vocab = NaturalLanguage_Dataset.load_data_time_machine(batch_size, num_steps, max_tokens=10000)
数据处理关键步骤:
- 将文本分割为词元(token)
- 构建词汇表(将词元映射到整数索引)
- 生成批量数据(每个批量包含
batch_size个序列,每个序列长度为num_steps)
三、RNN 核心组件实现
3.1 参数初始化
RNN 需要初始化三类参数:输入到隐藏层、隐藏层到隐藏层、隐藏层到输出层的权重和偏置:
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size # 输入输出维度等于词汇表大小
# 正态分布初始化参数
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01
# 隐藏层参数
w_xh = normal((num_inputs, num_hiddens)) # 输入到隐藏层权重
w_hh = normal((num_hiddens, num_hiddens)) # 隐藏层到隐藏层权重
b_h = torch.zeros(num_hiddens, device=device) # 隐藏层偏置
# 输出层参数
w_hq = normal((num_hiddens, num_outputs)) # 隐藏层到输出层权重
b_q = torch.zeros(num_outputs, device=device) # 输出层偏置
# 所有参数需要计算梯度
params = [w_xh, w_hh, b_h, w_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
3.2 隐藏状态初始化
隐藏状态需要在序列开始时初始化,通常初始化为全零向量:
def init_rnn_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device),)
3.3 RNN 前向传播
下面的rnn函数定义了如何在一个时间步内计算隐状态和输出。循环神经网络模型通过inputs最外层的维度。实现循环,以便逐时间步更新小批量数据的隐状态H。此外,这里使用tanh函数作为激活函数。当元素在实数上满足均匀分布时,tanh函数的平均值为0。实现 RNN 的核心计算逻辑,循环处理序列中的每个时间步:
def rnn(inputs, state, params):
w_xh, w_hh, b_h, w_hq, b_q = params
H, = state # 解包隐藏状态
outputs = []
# 遍历序列中的每个时间步
for X in inputs:
# 计算隐藏状态(使用tanh激活函数)
H = torch.tanh(torch.mm(X, w_xh) + torch.mm(H, w_hh) + b_h)
# 计算输出
Y = torch.mm(H, w_hq) + b_q
outputs.append(Y)
# 拼接所有时间步的输出,并返回新的隐藏状态
return torch.cat(outputs, dim=0), (H,)
3.4 封装 RNN 模型
将上述组件封装为一个类,方便使用:
class RNNModuleScratch:
def __init__(self, vocab_size, num_hiddens, device, get_params, init_state, forward_fn):
self.vocab_size = vocab_size # 词汇表大小
self.num_hiddens = num_hiddens # 隐藏层维度
self.params = get_params(vocab_size, num_hiddens, device) # 模型参数
self.init_state = init_state # 初始化状态函数
self.forward_fn = forward_fn # 前向传播函数
# 前向传播(处理输入)
def __call__(self, X, state):
# 将输入转换为独热编码(X形状:(批量大小, 时间步) -> 转换为(时间步, 批量大小, 词汇表大小))
X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
return self.forward_fn(X, state, self.params)
# 初始化隐藏状态
def begin_state(self, batch_size, device):
return self.init_state(batch_size, self.num_hiddens, device)
四、文本预测实现
让我们首先定义预测函数来生成prefix之后的新字符,其中的prefix是一个用户提供的包含多个字符的字符串。在循环中的开始字符时,我们将状态传递到下一个时间步,但不会产生任何输出。这被称为预热(warm-up)期,因为在此期间模型会自我更新(例如,更新状态),但不会进行预测。预热期结束后,隐状态的值通常比刚开始的初始值更适合预测,从而预测字符并输出它们。训练好的模型可以用于文本生成,核心是根据前缀预测后续字符:
def predict_ch8(prefix, num_preds, net, vocab, device):
# 初始化隐藏状态
state = net.begin_state(batch_size=1, device=device)
# 前缀转换为索引序列
outputs = [vocab[prefix[0]]]
# 获取下一个输入(取最后一个输出作为输入)
get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
# 预热期:处理前缀中的字符,更新隐藏状态
for y in prefix[1:]:
_, state = net(get_input(), state)
outputs.append(vocab[y])
# 预测阶段:生成指定数量的字符
for _ in range(num_preds):
y, state = net(get_input(), state)
outputs.append(int(y.argmax(dim=1).reshape(1))) # 取概率最大的字符
# 将索引转换为字符
return ''.join([vocab.idx_to_token[i] for i in outputs])
五、模型训练
5.1 梯度裁剪
RNN 训练中容易出现梯度爆炸问题,通过梯度裁剪解决:
def grad_clipping(net, theta):
if isinstance(net, nn.Module):
params = [p for p in net.parameters() if p.requires_grad]
else:
params = net.params
# 计算梯度范数
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
if norm > theta: # 如果范数超过阈值,则裁剪
for param in params:
param.grad[:] *= theta / norm
5.2 训练循环
这段代码定义了训练循环的一个epoch过程。初始化隐藏状态和计时器,用Accumulator记录总损失和词元数。遍历训练数据,若首次迭代或随机抽样,初始化隐藏状态;否则分离状态避免梯度回流过远。调整输入输出形状并移至设备,前向传播得预测和新状态,计算损失。根据优化器类型,执行反向传播、梯度裁剪和参数更新。最后返回困惑度(损失指数)和训练速度,实现了RNN训练的核心流程。
实现完整的训练过程:
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
state, timer = None, d2l.Timer()
metric = d2l.Accumulator(2) # 存储总损失和总词元数
for X, Y in train_iter:
# 初始化或重置隐藏状态
if state is None or use_random_iter:
state = net.begin_state(batch_size=X.shape[0], device=device)
else:
# 分离隐藏状态,避免梯度回流过远
for s in state:
s.detach_()
# 准备输入输出
y = Y.T.reshape(-1) # 调整标签形状
X, y = X.to(device), y.to(device)
# 前向传播
y_hat, state = net(X, state)
l = loss(y_hat, y.long()).mean() # 计算损失
# 反向传播和参数更新
if isinstance(updater, torch.optim.Optimizer):
updater.zero_grad()
l.backward()
grad_clipping(net, 1) # 梯度裁剪
updater.step()
else:
l.backward()
grad_clipping(net, 1)
updater(batch_size=1)
metric.add(l * y.numel(), y.numel())
# 返回困惑度(指数化的平均损失)和训练速度
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()
5.3 启动训练
这段代码是RNN模型的完整训练函数。首先定义交叉熵损失和动画器可视化困惑度。根据网络类型初始化优化器(PyTorch模块用SGD优化器,自定义网络用sgd函数)。定义预测函数生成文本。循环训练指定轮次,每轮调用train_epoch_ch8更新模型,每10轮打印预测结果并记录困惑度。训练结束后输出最终困惑度、训练速度及预测文本,实现了模型训练与效果跟踪的完整流程。
def train_ch8(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False):
loss = nn.CrossEntropyLoss()
animator = d2l.Animator(xlabel='epoch', ylabel='perplexity', legend=['train'], xlim=[10, num_epochs])
# 初始化优化器
if isinstance(net, nn.Module):
updater = torch.optim.SGD(net.parameters(), lr)
else:
updater = lambda batch_size: d2l.sgd(net.params, lr, batch_size)
# 定义预测函数
predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)
# 训练循环
for epoch in range(num_epochs):
ppl, speed = train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter)
if (epoch + 1) % 10 == 0:
print(predict('time traveller')) # 每10轮打印一次预测结果
animator.add(epoch + 1, [ppl])
# 输出最终结果
print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
print(predict('time traveller'))
print(predict('traveller'))
六、实验结果与分析
6.1 训练设置
num_hiddens = 512 # 隐藏层维度
device = d2l.try_gpu() # 使用GPU(如果可用)
num_epochs, lr = 500, 1 # 训练轮数和学习率
6.2 两种抽样方法对比
- 顺序抽样:保持序列的连续性,隐藏状态在批次间传递
net = RNNModuleScratch(len(vocab), num_hiddens, device, get_params, init_rnn_state, rnn)
train_ch8(net, train_iter, vocab, lr, num_epochs, device)
输出结果:

![]()
- 随机抽样:每个批次的序列随机抽取,隐藏状态在批次间重置
net = RNNModuleScratch(len(vocab), num_hiddens, device, get_params, init_rnn_state, rnn)
train_ch8(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=True)
输出结果:


6.3 结果分析
- 困惑度(Perplexity):越低表示模型性能越好,理想情况下接近 1
- 生成文本质量:训练充分的模型能生成语法相对通顺、与原文风格相似的文本
- 训练速度:GPU 加速能显著提升训练效率
七、总结与展望
本文从零开始实现了一个基础 RNN 模型,包括参数初始化、前向传播、文本预测和模型训练等完整流程。通过实验可以发现,基础 RNN 在处理长序列时仍有局限(如梯度消失 / 爆炸、长期依赖捕捉能力弱)。
后续可以尝试:
- 改用 LSTM 或 GRU 等改进型循环神经网络
- 增加注意力机制提升长序列处理能力
- 使用预训练词向量替换独热编码
- 尝试更复杂的优化器(如 Adam)和正则化方法
通过从零实现的过程,我们能更深入地理解 RNN 的工作原理,为学习更复杂的序列模型打下基础。
八、代码汇总
import matplotlib.pyplot as plt
import math
import torch
import NaturalLanguage_Dataset
from torch import nn
from d2l import torch as d2l
from torch.nn import functional as F
batch_size,num_steps=32,35
train_iter,vocab=NaturalLanguage_Dataset.load_data_time_machine(batch_size,num_steps,max_tokens=10000)
#独热编码
# print(F.one_hot(torch.tensor([0,2]),len(vocab)))
X=torch.arange(10).reshape((2,5))
# print(X.shape) `````````````````````````````````````
#获取参数
def get_params(vocab_size,num_hiddens,device):
num_inputs=num_outputs=vocab_size
def normal(shape):
return torch.randn(size=shape,device=device)*0.01
#隐藏层参数
w_xh=normal((num_inputs,num_hiddens))
w_hh=normal((num_hiddens,num_hiddens))
#一维,广播机制
b_h=torch.zeros(num_hiddens,device=device)
#输出层参数
w_hq=normal((num_hiddens,num_outputs))
#一维,广播机制
b_q=torch.zeros(num_outputs,device=device)
params=[w_xh,w_hh,b_h,w_hq,b_q]
for param in params:
param.requires_grad_(True)
return params
def init_rnn_state(batch_size,num_hiddens,device):
return (torch.zeros((batch_size,num_hiddens),device=device),)
def rnn(inputs,state,params):
w_xh,w_hh,b_h,w_hq,b_q=params
H,=state
outputs=[]
for X in inputs:
H=torch.tanh(torch.mm(X,w_xh)+torch.mm(H,w_hh)+b_h)
Y=torch.mm(H,w_hq)+b_q
outputs.append(Y)
return torch.cat(outputs,dim=0),(H,)
class RNNModuleScratch:
#参数:vocab_size(词汇表大小)、num_hiddens(隐藏层维度)、device(运行设备,如 GPU/CPU)
#get_params(获取模型参数的函数)、init_state(初始化隐藏状态的函数)、forward_fn(前向传播逻辑的函数)。
def __init__(self,vocab_size,num_hiddens,device,get_params,init_state,forward_fn):
self.vocab_size,self.num_hiddens=vocab_size,num_hiddens
self.params=get_params(vocab_size,num_hiddens,device)
self.init_state,self.forward_fn=init_state,forward_fn
#向前传播
def __call__(self,X,state):
X=F.one_hot(X.T,self.vocab_size).type(torch.float32)
return self.forward_fn(X,state,self.params)
#初始化隐藏状态
def begin_state(self,batch_size,device):
return self.init_state(batch_size,self.num_hiddens,device)
num_hiddens=512
net=RNNModuleScratch(len(vocab),num_hiddens,d2l.try_gpu(),get_params,init_rnn_state,rnn)
state=net.begin_state(X.shape[0],d2l.try_gpu())
Y,new_state=net(X.to(d2l.try_gpu()),state)
# print(Y.shape,len(new_state),new_state[0].shape)
def predict_ch8(prefix,num_preds,net,vocab,device):
#得到隐藏状态
state=net.begin_state(batch_size=1,device=device)
#初始化输出序列
outputs=[vocab[prefix[0]]]
get_input=lambda: torch.tensor([outputs[-1]],device=device).reshape((1,1))
#预热期
for y in prefix[1:]:
_,state=net(get_input(),state)
outputs.append(vocab[y])
#预测num_steps
for _ in range(num_preds):
y,state=net(get_input(),state)
outputs.append(int(y.argmax(dim=1).reshape(1)))
return ''.join([vocab.idx_to_token[i] for i in outputs])
#梯度剪裁
def grad_clipping(net,theta):
if isinstance(net,nn.Module):
params=[p for p in net.parameters() if p.requires_grad]
else:
params=net.params
norm=torch.sqrt(sum(torch.sum((p.grad**2)) for p in params))
if norm >theta:
for param in params:
param.grad[:]*=theta/norm
#updater:优化器(或自定义更新规则)如:sgd
def train_epoch_ch8(net,train_iter,loss,updater,device,use_random_iter):
state,timer=None,d2l.Timer()
metric=d2l.Accumulator(2)
for X,Y in train_iter:
if state is None or use_random_iter:
state=net.begin_state(batch_size=X.shape[0],device=device)
else:
if isinstance(net,nn.Module) and not isinstance((state,tuple)):
#state对于nn.GRU是张量
state.detach_()
else:
#state对于nn.LSTM或对于我们从零开始的模型是张量
for s in state:
s.detach_()
y=Y.T.reshape(-1)
X,y=X.to(device),y.to(device)
y_hat,state=net(X,state)
l=loss(y_hat,y.long()).mean()
if isinstance(updater,torch.optim.Optimizer):
updater.zero_grad()
l.backward()
grad_clipping(net,l)
updater.step()
else:
l.backward()
grad_clipping(net,l)
updater(batch_size=1)
metric.add(l*y.numel(),y.numel())
return math.exp(metric[0]/metric[1]),metric[1]/timer.stop()
def train_ch8(net,train_iter,vocab,lr,num_epochs,device,use_random_iter=False):
loss=nn.CrossEntropyLoss()
animator=d2l.Animator(xlabel='epoch',ylabel='perplexity',legend=['train'],xlim=[10,num_epochs])
#初始化
if isinstance(net,nn.Module):
updater=torch.optim.SGD(net.parameters(),lr)
else:
updater=lambda batch_size:d2l.sgd(net.params,lr,batch_size)
predict=lambda prefix:predict_ch8(prefix,50,net,vocab,device)
for epoch in range(num_epochs):
ppl,speed=train_epoch_ch8(net,train_iter,loss,updater,device,use_random_iter)
if (epoch+1)%10==0:
print(predict('time traveller'))
animator.add(epoch+1,[ppl])
print(f'困惑度{ppl:.1f},{speed:.1f}词元/秒{str(device)}')
print(predict('time traveller'))
print(predict('traveller'))
#顺序抽样方法
num_epochs,lr=500,1
train_ch8(net,train_iter,vocab,lr,num_epochs,d2l.try_gpu())
#随机抽样方法
net=RNNModuleScratch(len(vocab),num_hiddens,d2l.try_gpu(),get_params,init_rnn_state,rnn)
# train_ch8(net,train_iter,vocab,lr,num_epochs,d2l.try_gpu(),use_random_iter=True)
plt.show()
补充性知识
梯度剪裁
梯度剪裁(Gradient Clipping)是在深度学习训练过程中,尤其是在循环神经网络(RNN)及其变种(如 LSTM、GRU)等容易出现梯度问题的模型训练里,经常使用的一种技术手段,用于解决梯度爆炸问题,以下是详细介绍:
1. 梯度爆炸问题
在深度学习模型训练时,通过反向传播算法来计算梯度并更新模型参数。在一些情况下,比如使用深层神经网络或者处理长序列的 RNN 模型时,梯度在反向传播过程中会不断累积。当梯度的值变得非常大,以指数形式增长时,就会出现梯度爆炸现象。
梯度爆炸会导致模型训练不稳定,参数更新幅度过大,使得模型无法收敛,甚至出现参数变为无穷大,导致训练无法继续进行 。例如,在训练 RNN 对长文本进行建模时,随着序列长度增加,反向传播的路径变长,就更容易出现梯度爆炸。
2. 梯度剪裁原理
梯度剪裁的核心思想是限制梯度的大小,避免梯度值过大。具体做法是设置一个阈值(通常用 \(\theta\) 表示),当计算得到的梯度的范数(常用 L2 范数,即欧几里得范数)超过这个阈值时,就对梯度进行缩放,使其范数等于该阈值。
假设模型的所有可训练参数为 \(\theta_1, \theta_2, ..., \theta_n\),对应的梯度为 \(g_1, g_2, ..., g_n\),计算梯度的 L2 范数公式为:\(||g||_2 = \sqrt{\sum_{i=1}^{n} g_i^2}\)如果 \(||g||_2 > \theta\),则对梯度进行如下缩放:\(g_i' = g_i \times \frac{\theta}{||g||_2} \quad (i = 1, 2, ..., n)\)其中,\(g_i'\) 是剪裁后的梯度值。这样操作后,梯度的方向不变,但是大小被限制在指定的阈值范围内,使得模型参数更新更加稳定。
3. 梯度剪裁的实现
以 PyTorch 为例,实现梯度剪裁的代码如下:
import torch
def grad_clipping(net, theta):
if isinstance(net, torch.nn.Module):
params = [p for p in net.parameters() if p.requires_grad]
else:
params = net.params
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
if norm > theta:
for param in params:
param.grad[:] *= theta / norm
在上述代码中,net 是深度学习模型,theta 是设定的梯度剪裁阈值。首先,收集模型中所有需要计算梯度的参数,然后计算这些参数梯度的 L2 范数 。如果范数超过阈值,就对每个参数的梯度进行剪裁。
4. 梯度剪裁的作用与局限性
- 作用:有效解决梯度爆炸问题,使得模型训练更加稳定,提高模型的收敛性,尤其在处理长序列数据或者深层神经网络时效果显著。例如在语音识别任务中,使用 RNN-LSTM 模型时,结合梯度剪裁技术,能够帮助模型更好地学习语音序列中的长期依赖关系。
- 局限性:虽然梯度剪裁解决了梯度爆炸问题,但它并不能解决梯度消失问题。并且,剪裁阈值的选择较为关键,阈值设置不当可能会导致模型收敛速度变慢,或者仍然无法有效解决梯度爆炸问题。
梯度剪裁是深度学习训练中应对梯度爆炸的有效手段,合理使用能够提升模型训练的稳定性和效果。
在深度学习领域,独热编码(One-Hot Encoding)是一种常用的特征编码方式,以下是关于它的详细介绍:
独热编码
1. 定义与原理
独热编码,也叫一位有效编码,其核心思想是使用 N 位状态寄存器来对 N 个可能的取值进行编码,每个状态都由独立的寄存器位表示,并且在任意时刻只有一位有效。
以文本数据处理为例,假设我们有一个包含 “苹果”“香蕉”“橘子” 这三个类别的水果词汇表。在独热编码中,会将每个类别映射为一个长度等于词汇表大小的向量。对于 “苹果”,编码后可能是 [1, 0, 0];“香蕉” 编码后是 [0, 1, 0];“橘子” 编码后是 [0, 0, 1] 。可以看到,在每个向量中,只有对应类别位置上的值为 1,其余位置的值均为 0 。
2. 实现方式
- Python 实现:借助
scikit-learn库实现对离散特征的独热编码。假设我们有一个包含动物种类的列表:
from sklearn.preprocessing import OneHotEncoder
import numpy as np
data = np.array([['狗'], ['猫'], ['兔子']])
encoder = OneHotEncoder()
encoded_data = encoder.fit_transform(data).toarray()
print(encoded_data)
- PyTorch 实现:在深度学习框架 PyTorch 中,可使用
torch.nn.functional.one_hot函数对整数张量进行独热编码。例如,对一个包含类别索引的张量进行编码:
import torch
import torch.nn.functional as F
# 假设类别索引张量,这里有3个样本,每个样本的类别索引分别是0, 1, 2
idx = torch.tensor([0, 1, 2])
# 假设共有3个类别
encoded = F.one_hot(idx, num_classes=3)
print(encoded)
3. 在深度学习中的应用场景
- 文本分类:在自然语言处理中,将单词或字符转换为独热编码向量,作为神经网络的输入。例如,构建一个简单的文本情感分类模型,需要先将文本中的单词通过独热编码的方式进行向量化处理,然后输入到神经网络中进行训练。但由于文本中单词数量往往非常庞大,会导致独热编码后的向量维度极高且稀疏,实际中常结合词嵌入(如 Word2Vec、GloVe)等方法来优化。
- 图像分类:在一些简单的图像分类任务中,对于图像的标签可以使用独热编码。比如,将猫狗分类任务中的 “猫” 标签编码为 [1, 0],“狗” 标签编码为 [0, 1] ,方便模型进行学习和预测。
- 多分类问题:对于具有多个离散类别的分类任务,都可以使用独热编码对类别标签进行处理,让神经网络更好地理解和学习不同类别之间的差异。
4. 优缺点
- 优点
-
- 编码简单直观:能够清晰地将离散的类别信息转化为机器可识别的向量形式,易于理解和实现。
- 可扩展性好:当有新的类别加入时,只需在向量末尾增加一个维度即可,不会影响已有的编码结构。
- 避免了类别间的顺序关系:对于像水果类别这样不存在顺序关系的离散变量,独热编码不会给模型引入错误的顺序信息。
- 缺点
-
- 高维度与稀疏性:当类别数量较多时,独热编码会生成高维度且稀疏的向量,占用大量内存,并且会增加模型的计算量和训练时间。
- 缺乏语义信息:每个编码向量都是独立的,无法体现不同类别之间潜在的语义关联。例如在文本中,“苹果” 和 “香蕉” 都属于水果,具有一定语义相关性,但独热编码无法体现这种关系。
独热编码是一种基础且重要的特征编码方式,在深度学习中有着广泛应用,虽然存在一些局限性,但在很多场景下通过与其他技术结合,仍然能发挥重要作用。
detach()和detach_()的区别与介绍
在 PyTorch 中,detach() 和 detach_() 都是用于处理张量(Tensor)的方法,主要用于切断计算图中的梯度传播,它们在功能上有相似之处,但也有一些区别,下面结合你提供的代码来详细解释它们的作用:
共同作用
两者的核心作用是将张量从计算图中 “分离” 出来,使得张量后续的计算不会再参与梯度的计算和传播。在训练循环神经网络(如代码中涉及的 GRU、LSTM 等)时,模型的状态(state)可能会在多个时间步或者多个批次中传递,如果不将其从计算图中分离,随着训练的进行,计算图会变得非常庞大,不仅会占用大量内存,还可能导致梯度计算异常复杂甚至出现梯度爆炸等问题。通过 detach() 或 detach_() 操作,可以避免这种情况,只保留张量的数值,而不再关联其梯度相关的信息 。
区别
detach():这是一个返回新张量的方法,新张量与原张量共享数据(即视图,对新张量数据的修改会影响原张量,除非进行了拷贝等操作),但新张量的requires_grad属性被设置为False,不再参与梯度计算。原张量的计算图连接被切断,但原张量本身的requires_grad等梯度相关属性不会改变 。例如:
import torch
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
z = y.detach()
# z 的 requires_grad 为 False,后续对 z 的计算不会影响 x 的梯度
detach_():这是一个原地(in-place)操作的方法,它直接修改原张量,将原张量从计算图中分离,原张量的requires_grad会被设置为False,后续关于该张量的计算也不再参与梯度传播。它没有返回值,直接作用于原张量 。比如:
import torch
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
y.detach_()
# 此时 y 已经从计算图中分离,后续对 y 的操作不影响梯度
代码中的具体体现
代码中有针对循环神经网络的状态(state)进行 detach() 或 detach_() 操作:
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# state对于nn.GRU是个张量
state.detach_()
else:
# state对于nn.LSTM或对于我们从零开始实现的模型是个张量
for s in state:
s.detach_()
这里根据 state 的具体类型(是针对 nn.GRU 单个张量形式的状态,还是针对 nn.LSTM 等多个张量组成的状态),使用 detach_() 来原地切断状态张量与计算图的连接。这样做的目的是,在不需要让循环神经网络的状态参与梯度计算和传播时(比如在一些特定的状态初始化和传递场景下),避免状态的梯度影响模型参数的更新,同时也能控制计算图的复杂度,提升训练效率和稳定性 。
总之,detach() 和 detach_() 都能实现切断张量梯度传播的功能,detach() 返回新张量,detach_() 原地修改原张量,在实际使用中可根据需求选择合适的方法,在你给出的循环神经网络训练代码里,用 detach_() 来处理状态张量是为了合理管理计算图,保障训练过程的高效与稳定 。
介绍numel()的作用
def train_epoch_ch8(net,train_iter,loss,updater,device,use_random_iter):
state,timer=None,d2l.Timer()
metric=d2l.Accumulator(2)
for X,Y in train_iter:
if state is None or use_random_iter:
state=net.begin_state(batch_size=X.shape[0],device=device)
else:
if isinstance(net,nn.Module) and not isinstance((state,tuple)):
#state对于nn.GRU是张量
state.detach_()
else:
#state对于nn.LSTM或对于我们从零开始的模型是张量
for s in state:
s.detach_()
y=Y.T.reshape(-1)
X,y=X.to(device),y.to(device)
y_hat,state=net(X,state)
l=loss(y_hat,y.long()).mean()
if isinstance(updater,torch.optim.Optimizer):
updater.zero_grad()
l.backward()
grad_clipping(net,l)
updater.step()
else:
l.backward()
grad_clipping(net,l)
updater(batch_size=1)
metric.add(l*y.numel(),y.numel())
return math.exp(metric[0]/metric[1]),metric[1]/timer.stop()
在 PyTorch 中,numel() 是张量(torch.Tensor)的一个方法,用于返回张量中元素的总数量。结合你提供的代码片段 metric.add(l * y.numel(), y.numel()) ,下面详细解释其作用:
1. y.numel() 的作用
- 计算张量元素总数:对于标签张量
y(经过前面的y = Y.T.reshape(-1)等操作后,它是一个一维或多维的张量 ),y.numel()会返回y中所有元素的个数。例如,如果y是形状为torch.Size([64])(64 个元素的一维张量 ),那么y.numel()的结果就是64;如果y是形状为torch.Size([32, 10])(32 行 10 列的二维张量 ),y.numel()结果就是32 * 10 = 320。
- 在代码中的意义:在训练循环神经网络(从代码里的
grad_clipping等操作能看出是在做循环网络相关训练,比如文本序列任务 )时,这里的y通常代表一个批次的标签数据。y.numel()用于统计当前批次标签的总元素数量,也就是当前批次的样本相关的 “计数单元”(比如在语言模型中,可能是词元数量 )。
2. 在 metric.add(l * y.numel(), y.numel()) 中的作用
- 累加总损失:
l是当前批次计算得到的平均损失(通过l = loss(y_hat, y.long()).mean()计算而来 ),l * y.numel()就是当前批次的总损失(平均损失乘以样本元素总数,把平均损失还原回总损失 )。然后metric.add方法(metric是d2l.Accumulator类型,用于累加指标 )会把这个总损失累加到metric中专门记录总损失的位置。
- 累加样本(元素)总数:
y.numel()作为当前批次的元素总数,会被累加到metric中记录样本(元素 )总数的位置。后续在计算整个训练周期的平均损失时,就可以用累加的总损失除以累加的元素总数(即metric[0] / metric[1],假设metric第一个位置存总损失,第二个位置存总元素数 ),得到更准确的平均损失指标。
举个简单例子,假设当前批次 y 有 10 个元素(y.numel() = 10 ),计算出的平均损失 l = 0.5 ,那么 l * y.numel() = 5(总损失 ),metric.add(5, 10) 就会把总损失 5 和元素总数 10 分别累加到 metric 里,方便后续统计整个训练过程的平均损失等信息。
总之,numel() 在这里的核心价值是精准统计每个批次的标签元素数量,让损失的累加和平均计算更准确,从而更好地监控模型在训练过程中的表现 。
更多推荐
所有评论(0)