您的位置:首页 > 财经 > 产业 > 8-5 循环神经网络 RNN 的实现

8-5 循环神经网络 RNN 的实现

2024/10/6 12:22:59 来源:https://blog.csdn.net/weixin_45798993/article/details/141270597  浏览:    关键词:8-5 循环神经网络 RNN 的实现

循环神经网络的从零开始实现

本节将根据 8-4节中的描述, 从头开始基于循环神经网络实现字符级语言模型。 这样的模型将在H.G.Wells的时光机器数据集上训练。 和前面 8-3节中介绍过的一样, 我们先读取数据集。

%matplotlib inline
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2lbatch_size, num_steps = 32, 35
# num_steps 每一次我们看多长的一个序列,批量中每个序列的长度
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
# vocab 词表索引的表,有利于相互转换

独热编码

回想一下,在train_iter中,每个词元都表示为一个数字索引, 将这些索引直接输入神经网络可能会使学习变得困难。 我们通常将每个词元表示为更具表现力的特征向量。 最简单的表示称为独热编码(one-hot encoding)

简言之,将每个索引映射为相互不同的单位向量: 假设词表中不同词元的数目为 N N N(即len(vocab)), 词元索引的范围为 0 0 0 n − 1 n-1 n1。 如果词元的索引是整数 i i i, 那么我们将创建一个长度为 N N N的全 0 0 0向量, 并将第 i i i处的元素设置为 1 1 1。 此向量是原始词元的一个独热向量。 索引为 0 0 0 2 2 2的独热向量如下所示:

F.one_hot(torch.tensor([0, 2]), len(vocab))

请添加图片描述
我们每次采样的小批量数据形状是二维张量: (批量大小,时间步数)。 one_hot函数将这样一个小批量数据转换成三维张量, 张量的最后一个维度等于词表大小(len(vocab))。 我们经常转换输入的维度,以便获得形状为 (时间步数,批量大小,词表大小)的输出。 这将使我们能够更方便地通过最外层的维度, 一步一步地更新小批量数据的隐状态。

X = torch.arange(10).reshape((2, 5))
F.one_hot(X.T, 28).shape

请添加图片描述

初始化模型参数

接下来,我们初始化循环神经网络模型的模型参数。 隐藏单元数num_hiddens是一个可调的超参数。 当训练语言模型时,输入和输出来自相同的词表。 因此,它们具有相同的维度,即词表的大小。

def get_params(vocab_size, num_hiddens, device):
# 这是函数的定义,接受三个参数:
# vocab_size:词汇量大小,通常用于定义输入和输出层的维度。
# num_hiddens:隐藏层的神经元数量。
# device:指定运算和存储张量的设备,如 'cpu' 或 'cuda'。num_inputs = num_outputs = vocab_sizedef normal(shape):# 定义了一个内部函数 normal,用于生成正态分布的随机数张量。# 参数 shape 是要生成的张量的形状。return torch.randn(size=shape, device=device) * 0.01# 使用 torch.randn 生成指定形状和设备上的正态分布随机数。# 随机数乘以 0.01,用于初始化参数时保证值较小,有助于模型的稳定训练。# 隐藏层参数W_xh = normal((num_inputs, num_hiddens))# 使用 normal 函数创建形状为 (num_inputs, num_hiddens) 的张量,用于输入层到隐藏层的权重。W_hh = normal((num_hiddens, num_hiddens))# 同样使用 normal 函数创建形状为 (num_hiddens, num_hiddens) 的张量,用于隐藏层到隐藏层的权重(RNN中常见的循环权重)。b_h = torch.zeros(num_hiddens, device=device)# 创建一个大小为 num_hiddens 的零张量,作为隐藏层的偏置参数,存储在指定的 device 上。# 输出层参数W_hq = normal((num_hiddens, num_outputs))# 使用 normal 函数创建形状为 (num_hiddens, num_outputs) 的张量,用于隐藏层到输出层的权重。b_q = torch.zeros(num_outputs, device=device)# 创建一个大小为 num_outputs 的零张量,作为输出层的偏置参数,存储在指定的 device 上。# 附加梯度params = [W_xh, W_hh, b_h, W_hq, b_q]for param in params:param.requires_grad_(True)return params

循环神经网络模型

为了定义循环神经网络模型, 我们首先需要一个init_rnn_state函数在初始化时返回隐状态。 这个函数的返回是一个张量,张量全用0填充, 形状为(批量大小,隐藏单元数)。 在后面的章节中我们将会遇到隐状态包含多个变量的情况, 而使用元组可以更容易地处理些。

def init_rnn_state(batch_size, num_hiddens, device):return (torch.zeros((batch_size, num_hiddens), device=device), )

下面的rnn函数定义了如何在一个时间步内计算隐状态和输出。 循环神经网络模型通过inputs最外层的维度实现循环, 以便逐时间步更新小批量数据的隐状态H。 此外,这里使用 tanh ⁡ \tanh tanh函数作为激活函数。当元素在实数上满足均匀分布时, tanh ⁡ \tanh tanh函数的平均值为 0 0 0

def rnn(inputs, state, params):
# inputs:输入数据,通常是一个三维张量,其形状为 (时间步数量,批量大小,词表大小)。
# state:网络的初始状态,通常包含前一个时间步的隐藏状态。
# params:网络参数的列表,包括权重和偏置。# inputs的形状:(时间步数量,批量大小,词表大小)W_xh, W_hh, b_h, W_hq, b_q = paramsH, = state # 解包 state 获取当前的隐藏状态 Houtputs = [] # 初始化一个空列表用于存储每个时间步产生的输出# X的形状:(批量大小,词表大小)for X in inputs:H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)# 计算当前时间步的隐藏状态 H:# torch.mm(X, W_xh):当前输入 X 和权重 W_xh 的矩阵乘法# torch.mm(H, W_hh):前一个时间步的隐藏状态 H 和权重 W_hh 的矩阵乘法# 然后将这两个结果加上偏置 b_h,并通过 tanh 激活函数处理Y = torch.mm(H, W_hq) + b_q# torch.mm(H, W_hq):当前隐藏状态 H 和输出层权重 W_hq 的矩阵乘法# 加上偏置 b_qoutputs.append(Y)return torch.cat(outputs, dim=0), (H,)# 将 outputs 列表中的输出沿着第一个维度(时间步)拼接,形成最终的输出张量。# 返回这个输出张量和最后一个时间步的隐藏状态 H。

定义了所有需要的函数之后,接下来我们创建一个类来包装这些函数, 并存储从零开始实现的循环神经网络模型的参数。

class RNNModelScratch: #@save"""从零开始实现的循环神经网络模型"""def __init__(self, vocab_size, num_hiddens, device,get_params, init_state, forward_fn):# vocab_size:词汇表的大小。# num_hiddens:隐藏层的单元数。# device:用于运行模型的设备,例如 'cpu' 或 'cuda'。# get_params:一个函数,用于初始化模型参数。# init_state:一个函数,用于初始化网络的隐藏状态。# forward_fn:一个函数,定义了模型的前向传播逻辑。self.vocab_size, self.num_hiddens = vocab_size, num_hiddensself.params = get_params(vocab_size, num_hiddens, device)self.init_state, self.forward_fn = init_state, forward_fndef __call__(self, X, state):# 定义了类的 __call__ 方法,使得类的实例可以像函数那样被调用。# X:输入数据。# state:模型的初始状态。X = F.one_hot(X.T, self.vocab_size).type(torch.float32)# 将输入 X 转置后进行 one-hot 编码,得到的张量形状会是 (时间步数量,批量大小,词表大小)。# 将编码后的张量类型转换为 torch.float32。return self.forward_fn(X, state, self.params)# 调用 forward_fn 函数进行前向传播,传入处理后的输入 X、初始状态 state 和模型参数 self.params。def begin_state(self, batch_size, device):# 定义一个方法 begin_state,用于生成模型的初始状态。# batch_size:批次大小。# device:指定状态张量存储的设备。return self.init_state(batch_size, self.num_hiddens, device)

让我们检查输出是否具有正确的形状。 例如,隐状态的维数是否保持不变。

num_hiddens = 512
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,init_rnn_state, rnn)
# 创建 RNNModelScratch 类的一个实例,称为 net。
# len(vocab):传入词汇表的长度作为 vocab_size。
# num_hiddens:传入定义的隐藏单元数。
# d2l.try_gpu():尝试使用 GPU 运行模型,如果 GPU 可用,则使用 GPU,否则使用 CPU。
# get_params:传入一个函数,用于初始化网络参数。
# init_rnn_state:传入一个函数,用于初始化网络的初始状态。
# rnn:传入一个函数,定义了网络的前向传播逻辑。
state = net.begin_state(X.shape[0], d2l.try_gpu())
# 调用 net 的 begin_state 方法来初始化模型的状态。
# X.shape[0]:传入批次大小,这通常是输入数据 X 的第一个维度,表示批量中的样本数量。
# d2l.try_gpu():再次调用,以确保状态存储在正确的设备上。
Y, new_state = net(X.to(d2l.try_gpu()), state)
# 将输入数据 X 转移到 GPU(如果可用),并调用 net 实例。
# 传入转移后的 X 和初始化的 state,执行网络的前向传播。
# Y:网络的输出。
# new_state:网络在处理完当前批次后的最新状态。
Y.shape, len(new_state), new_state[0].shape

请添加图片描述
我们可以看到输出形状是(时间步数 × \times ×批量大小,词表大小), 而隐状态形状保持不变,即(批量大小,隐藏单元数)。

预测

让我们首先定义预测函数来生成prefix之后的新字符, 其中的prefix是一个用户提供的包含多个字符的字符串。 在循环遍历prefix中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出。 这被称为预热(warm-up)期, 因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。 预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们

def predict_ch8(prefix, num_preds, net, vocab, device):
# prefix:输入的前缀字符串,模型将在此基础上生成新字符。
# num_preds:指定要生成的字符数。
# net:RNN模型实例。
# vocab:词汇表,用于字符与索引之间的转换。
# device:指定计算和数据存储的设备,如 'cpu' 或 'cuda'。"""在prefix后面生成新字符"""state = net.begin_state(batch_size=1, device=device)# 初始化模型状态。因为是生成文本,所以批次大小(batch_size)设置为1。outputs = [vocab[prefix[0]]]# 将前缀的第一个字符的索引作为输出列表的起始元素。get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))# 定义一个 lambda 函数 get_input,它生成当前要输入到模型中的数据。# 使用输出列表中的最后一个元素(即最近生成的字符的索引),将其放入张量中,并确保张量的形状为 (1, 1)。for y in prefix[1:]:  # 预热期# 对前缀中的第一个字符之后的每个字符进行循环。# 这个循环称为“预热期”,目的是使用已知的前缀来更新网络的状态,为接下来的预测做准备。_, state = net(get_input(), state)# 调用模型 net,更新状态,但不收集这部分的输出。这里只关心状态的更新。outputs.append(vocab[y])# 将前缀中当前字符的索引添加到输出列表中。for _ in range(num_preds):  # 预测num_preds步y, state = net(get_input(), state)# 使用更新后的状态和最后一个输出作为输入,调用模型生成下一个字符。outputs.append(int(y.argmax(dim=1).reshape(1)))# 从模型的输出 y 中选择概率最高的字符索引,并将其添加到输出列表中。return ''.join([vocab.idx_to_token[i] for i in outputs])# 将输出列表中的每个索引转换回对应的字符,并将这些字符拼接成一个字符串,这是最终生成的文本。

现在我们可以测试predict_ch8函数。 我们将前缀指定为time traveller, 并基于这个前缀生成10个后续字符。 鉴于我们还没有训练网络,它会生成荒谬的预测结果。

predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu())

请添加图片描述

梯度裁剪

请添加图片描述

def grad_clipping(net, theta):  """裁剪梯度"""if isinstance(net, nn.Module):# 判断 net 是否是 PyTorch 的 nn.Module 类的实例。# 这用于区分不同类型的模型,因为不同类型的模型梯度的存储方式可能不同。params = [p for p in net.parameters() if p.requires_grad]# 如果 net 是 nn.Module 的实例,从模型中提取所有需要梯度计算的参数。# 这使用了列表推导式和 net.parameters() 方法。else:params = net.paramsnorm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))# 计算所有参数梯度的范数。# 首先,对每个参数的梯度的平方求和,然后对所有参数的求和结果求和,最后取平方根。# 这个值代表了整个参数集的梯度大小。if norm > theta:for param in params:param.grad[:] *= theta / norm# 对每个参数的梯度进行裁剪。# 梯度裁剪的操作是通过乘以一个小于1的因子来进行的,# 这个因子是阈值 theta 除以梯度范数 norm。# 这样做可以保持梯度方向不变,同时缩小梯度的大小,使其不超过阈值。

训练

在训练模型之前,让我们定义一个函数在一个迭代周期内训练模型。 它与我们训练 3.6节模型的方式有三个不同之处。

  1. 序列数据的不同采样方法(随机采样和顺序分区)将导致隐状态初始化的差异。

  2. 我们在更新模型参数之前裁剪梯度。 这样的操作的目的是,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散。

  3. 我们用困惑度来评价模型。如 8-4.4节所述, 这样的度量确保了不同长度的序列具有可比性。

具体来说,当使用顺序分区时, 我们只在每个迭代周期的开始位置初始化隐状态。 由于下一个小批量数据中的第 i i i个子序列样本 与当前第 i i i个子序列样本相邻, 因此当前小批量数据最后一个样本的隐状态, 将用于初始化下一个小批量数据第一个样本的隐状态。 这样,存储在隐状态中的序列的历史信息 可以在一个迭代周期内流经相邻的子序列。 然而,在任何一点隐状态的计算, 都依赖于同一迭代周期中前面所有的小批量数据, 这使得梯度计算变得复杂。 为了降低计算量,在处理任何一个小批量数据之前, 我们先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。

当使用随机抽样时,因为每个样本都是在一个随机位置抽样的, 因此需要为每个迭代周期重新初始化隐状态。 与 3.6节中的 train_epoch_ch3函数相同, updater是更新模型参数的常用函数。 它既可以是从头开始实现的d2l.sgd函数, 也可以是深度学习框架中内置的优化函数。

def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
# net:待训练的神经网络模型。
# train_iter:训练数据的迭代器。
# loss:损失函数。
# updater:用于更新模型参数的函数或优化器。
# device:指定模型运行的设备(如CPU或GPU)。
# use_random_iter:是否使用随机迭代来初始化状态,通常在处理顺序数据时考虑。"""训练网络一个迭代周期"""state, timer = None, d2l.Timer()# state:模型的隐藏状态,初始设为 None。# timer:计时器,用于跟踪训练周期的时间。metric = d2l.Accumulator(2)  # 训练损失之和,词元数量# 性能指标:使用 Accumulator 实例来累积训练过程中的损失总和和处理的词元数量for X, Y in train_iter:if state is None or use_random_iter:# 在第一次迭代或使用随机抽样时初始化statestate = 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)# 前向传播:将输入 X 和当前状态 state 通过网络 net 获取预测输出 y_hat 和更新后的状态。l = loss(y_hat, y.long()).mean()# 计算损失:使用损失函数计算预测 y_hat 和实际目标 y 之间的损失,并取均值。if isinstance(updater, torch.optim.Optimizer):updater.zero_grad()l.backward()grad_clipping(net, 1)updater.step()else:l.backward()grad_clipping(net, 1)# 因为已经调用了mean函数updater(batch_size=1)metric.add(l * y.numel(), y.numel())return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()# 返回结果:计算困惑度(指数形式的平均损失)和每秒处理的词元数量

循环神经网络模型的训练函数既支持从零开始实现, 也可以使用高级API来实现。

def train_ch8(net, train_iter, vocab, lr, num_epochs, device,use_random_iter=False):# net:要训练的网络模型。# train_iter:训练数据的迭代器。# vocab:词汇表,用于字符和索引之间的映射。# lr:学习率。# num_epochs:训练的总周期数。# device:指定模型运行的设备(如CPU或GPU)。# use_random_iter:是否使用随机迭代来初始化状态,对于某些类型的RNN训练很重要,默认为 False。"""训练模型(定义见第8章)"""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)# 如果 net 是 nn.Module 的实例,使用 torch.optim.SGD 作为优化器,参数包括网络的参数和学习率。# 否则,定义一个 lambda 函数作为自定义的参数更新器,使用随机梯度下降算法。predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)# 定义一个 lambda 函数 predict,用于生成文本。它调用 predict_ch8,生成 50 个字符长的文本。# 训练和预测for epoch in range(num_epochs):ppl, speed = train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter)# 使用 train_epoch_ch8 函数进行训练,返回该周期的困惑度和处理速度(词元每秒)。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'))

现在,我们训练循环神经网络模型。 因为我们在数据集中只使用了10000个词元, 所以模型需要更多的迭代周期来更好地收敛。

num_epochs, lr = 500, 1
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())

请添加图片描述
请添加图片描述
最后,让我们检查一下使用随机抽样方法的结果。

net = RNNModelScratch(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)

请添加图片描述
请添加图片描述
从零开始实现上述循环神经网络模型, 虽然有指导意义,但是并不方便。 在下一节中,我们将学习如何改进循环神经网络模型。 例如,如何使其实现地更容易,且运行速度更快。

小结

  • 我们可以训练一个基于循环神经网络的字符级语言模型,根据用户提供的文本的前缀生成后续文本。

  • 一个简单的循环神经网络语言模型包括输入编码循环神经网络模型输出生成

  • 循环神经网络模型在训练以前需要初始化状态,不过随机抽样和顺序划分使用初始化方法不同。

  • 当使用顺序划分时,我们需要分离梯度以减少计算量。

  • 在进行任何预测之前,模型通过预热期进行自我更新(例如,获得比初始值更好的隐状态)

  • 梯度裁剪可以防止梯度爆炸,但不能应对梯度消失。


循环神经网络的简洁实现

虽然 8-5节 对了解循环神经网络的实现方式具有指导意义,但并不方便。 本节将展示如何使用深度学习框架的高级API提供的函数更有效地实现相同的语言模型。 我们仍然从读取时光机器数据集开始。

import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2lbatch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

定义模型

高级API提供了循环神经网络的实现。 我们构造一个具有256个隐藏单元的单隐藏层的循环神经网络层rnn_layer。 事实上,我们还没有讨论多层循环神经网络的意义。 现在仅需要将多层理解为一层循环神经网络的输出被用作下一层循环神经网络的输入就足够了。

num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

我们使用张量来初始化隐状态,它的形状是(隐藏层数,批量大小,隐藏单元数)。

state = torch.zeros((1, batch_size, num_hiddens))
state.shape

请添加图片描述
通过一个隐状态和一个输入,我们就可以用更新后的隐状态计算输出。 需要强调的是,rnn_layer的“输出”(Y)不涉及输出层的计算: 它是指每个时间步的隐状态,这些隐状态可以用作后续输出层的输入。

X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)
Y.shape, state_new.shape

请添加图片描述
与 8-5节类似, 我们为一个完整的循环神经网络模型定义了一个RNNModel类。 注意,rnn_layer只包含隐藏的循环层,我们还需要创建一个单独的输出层。

#@save
class RNNModel(nn.Module):"""循环神经网络模型"""def __init__(self, rnn_layer, vocab_size, **kwargs):super(RNNModel, self).__init__(**kwargs)self.rnn = rnn_layerself.vocab_size = vocab_sizeself.num_hiddens = self.rnn.hidden_size# 如果RNN是双向的(之后将介绍),num_directions应该是2,否则应该是1if not self.rnn.bidirectional:self.num_directions = 1self.linear = nn.Linear(self.num_hiddens, self.vocab_size)else:self.num_directions = 2self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)def forward(self, inputs, state):X = F.one_hot(inputs.T.long(), self.vocab_size)X = X.to(torch.float32)Y, state = self.rnn(X, state)# 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)# 它的输出形状是(时间步数*批量大小,词表大小)。output = self.linear(Y.reshape((-1, Y.shape[-1])))return output, statedef begin_state(self, device, batch_size=1):if not isinstance(self.rnn, nn.LSTM):# nn.GRU以张量作为隐状态return  torch.zeros((self.num_directions * self.rnn.num_layers,batch_size, self.num_hiddens),device=device)else:# nn.LSTM以元组作为隐状态return (torch.zeros((self.num_directions * self.rnn.num_layers,batch_size, self.num_hiddens), device=device),torch.zeros((self.num_directions * self.rnn.num_layers,batch_size, self.num_hiddens), device=device))

训练与预测

在训练模型之前,让我们基于一个具有随机权重的模型进行预测。

device = d2l.try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
d2l.predict_ch8('time traveller', 10, net, vocab, device)

请添加图片描述
很明显,这种模型根本不能输出好的结果。 接下来,我们使用 8-5节中 定义的超参数调用train_ch8,并且使用高级API训练模型。

num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)

请添加图片描述
请添加图片描述
与上一节相比,由于深度学习框架的高级API对代码进行了更多的优化, 该模型在较短的时间内达到了较低的困惑度。

小结

  • 深度学习框架的高级API提供了循环神经网络层的实现。

  • 高级API的循环神经网络层返回一个输出一个更新后的隐状态,我们还需要计算整个模型的输出层

  • 相比从零开始实现的循环神经网络,使用高级API实现可以加速训练。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com