机械学习基础以及在pynq-Z2上部署Faster-RCNN的项目学习2

目录

一.训练过程常用技巧

1.1保存和读取数据模型

1.2.记录训练集和验证集的正确率变化

1.3.决定什么时候停止训练

1.4.改进程序结构

1.5.正规化输入和输出值

1.6.使用Dropout帮助泛化模型

1.7.使用BatchNorm正规化批次

1.8.理解模型的eval和train模式

1.9.最终代码

 二.递归模型RNN,LSTM与GRU

2.1.递归模型的应用场景

2.2.最简单的递归模型-RNN(tanh)

 2.3.在Pytorch中使用RNN

2.4.RNN的简单例子

 2.5.长短记忆模型-LSTM

2.6.简化版长短记忆模型-GRU

2.7.在Pytorch中使用LSTM和GRU

三.应用递归模型的例子

3.1.例子一:文本情感分类

3.2.使用预生成embedding编码库

 3.2.1.word2vec

3.2.2.transfomers(BERT)

3.3.例子二:预测股价走势

四.双向递归模型(BRNN)-根据上下文补全单词

4.1.双向递归模型

4.2.例子:根据上下文补全单词



一.训练过程常用技巧

1.1保存和读取数据模型

在 pytorch 中各种操作都是围绕 tensor 对象来的,模型的参数也是 tensor,如果我们把训练好的 tensor 保存到硬盘然后下次再从硬盘读取就可以直接使用了。

我们先来看看如何保存单个 tensor,以下代码运行在 python 的 REPL 中:

# 引用 pytorch
>>> import torch

# 新建一个 tensor 对象
>>> a = torch.tensor([1, 2, 3], dtype=torch.float)

# 保存 tensor 到文件 1.pt
>>> torch.save(a, "1.pt")

# 从文件 1.pt 读取 tensor
>>> b = torch.load("1.pt")
>>> b
tensor([1., 2., 3.])

torch.save 保存 tensor 的时候会使用 python 的 pickle 格式,这个格式保证在不同的 python 版本间兼容,但不支持压缩内容,所以如果 tensor 非常大保存的文件将会占用很多空间,我们可以在保存前压缩,读取前解压缩以减少文件大小:

# 引用压缩库
>>> import gzip

# 保存 tensor 到文件 1.pt,保存时使用 gzip 压缩
>>> torch.save(a, gzip.GzipFile("1.pt.gz", "wb"))

# 从文件 1.pt 读取 tensor,读取时使用 gzip 解压缩
>>> b = torch.load(gzip.GzipFile("1.pt.gz", "rb"))
>>> b
tensor([1., 2., 3.])

torch.save 不仅支持保存单个 tensor 对象,还支持保存 tensor 列表或者词典 (实际上它还可以保存 tensor 以外的 python 对象,只要 pickle 格式支持),我们可以调用 state_dict 获取一个包含模型所有参数的集合,再用 torch.save 就可以保存模型的状态:

>>> from torch import nn
>>> class MyModel(nn.Module):
...     def __init__(self):
...         super().__init__()
...         self.layer1 = nn.Linear(in_features=8, out_features=100)
...         self.layer2 = nn.Linear(in_features=100, out_features=50)
...         self.layer3 = nn.Linear(in_features=50, out_features=1)
...     def forward(self, x):
...         hidden1 = nn.functional.relu(self.layer1(x))
...         hidden2 = nn.functional.relu(self.layer2(hidden1))
...         y = self.layer3(hidden2)
...         return y
...
>>> model = MyModel()
>>> model.state_dict()
OrderedDict([('layer1.weight', tensor([[ 0.2261,  0.2008,  0.0833, -0.2020, -0.0674,  0.2717, -0.0076,  0.1984],
        省略途中输出
          0.1347,  0.1356]])), ('layer3.bias', tensor([0.0769]))])

>>> torch.save(model.state_dict(), gzip.GzipFile("model.pt.gz", "wb"))

读取模型状态可以使用 load_state_dict 函数,不过你需要保证模型的参数定义没有发生变化,否则读取会出错:

>>> new_model = MyModel()
>>> new_model.load_state_dict(torch.load(gzip.GzipFile("model.pt.gz", "rb")))
<All keys matched successfully>

一个很重要的细节是,如果你读取模型状态后不是准备继续训练,而是用于预测其他数据,那么你应该调用 eval 函数来禁止自动微分等功能,这样可以加快运算速度:

>>> new_model.eval()

pytorch 不仅支持保存和读取模型状态,还支持保存和读取整个模型包括代码和参数,但我不推荐这种做法,因为使用的时候会看不到模型定义,并且模型依赖的类库或者函数不会一并保存起来所以你还是得预先加载它们否则会出错:

>>> torch.save(model, gzip.GzipFile("model.pt.gz", "wb"))
>>> new_model = torch.load(gzip.GzipFile("model.pt.gz", "rb"))

1.2.记录训练集和验证集的正确率变化

我们可以在训练过程中记录训练集和验证集的正确率变化,以观察是否可以收敛,训练速度如何,以及是否发生过拟合问题,以下是代码例子:

# 引用 pytorch 和 pandas 和显示图表使用的 matplotlib
import pandas
import torch
from torch import nn
from matplotlib import pyplot

# 定义模型
class MyModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=100)
        self.layer2 = nn.Linear(in_features=100, out_features=50)
        self.layer3 = nn.Linear(in_features=50, out_features=1)

    def forward(self, x):
        hidden1 = nn.functional.relu(self.layer1(x))
        hidden2 = nn.functional.relu(self.layer2(hidden1))
        y = self.layer3(hidden2)
        return y

# 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
# 这是为了让训练过程可重现,你也可以选择不这样做
torch.random.manual_seed(0)

# 创建模型实例
model = MyModel()

# 创建损失计算器
loss_function = torch.nn.MSELoss()

# 创建参数调整器
optimizer = torch.optim.SGD(model.parameters(), lr=0.0000001)

# 从 csv 读取原始数据集
df = pandas.read_csv('salary.csv')
dataset_tensor = torch.tensor(df.values, dtype=torch.float)

# 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
random_indices = torch.randperm(dataset_tensor.shape[0])
traning_indices = random_indices[:int(len(random_indices)*0.6)]
validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
testing_indices = random_indices[int(len(random_indices)*0.8):]
traning_set_x = dataset_tensor[traning_indices][:,:-1]
traning_set_y = dataset_tensor[traning_indices][:,-1:]
validating_set_x = dataset_tensor[validating_indices][:,:-1]
validating_set_y = dataset_tensor[validating_indices][:,-1:]
testing_set_x = dataset_tensor[testing_indices][:,:-1]
testing_set_y = dataset_tensor[testing_indices][:,-1:]

# 记录训练集和验证集的正确率变化
traning_accuracy_history = []
validating_accuracy_history = []

# 开始训练过程
for epoch in range(1, 500):
    print(f"epoch: {epoch}")

    # 根据训练集训练并修改参数
    # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
    model.train()

    traning_accuracy_list = []
    for batch in range(0, traning_set_x.shape[0], 100):
        # 切分批次,一次只计算 100 组数据
        batch_x = traning_set_x[batch:batch+100]
        batch_y = traning_set_y[batch:batch+100]
        # 计算预测值
        predicted = model(batch_x)
        # 计算损失
        loss = loss_function(predicted, batch_y)
        # 从损失自动微分求导函数值
        loss.backward()
        # 使用参数调整器调整参数
        optimizer.step()
        # 清空导函数值
        optimizer.zero_grad()
        # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
        with torch.no_grad():
            traning_accuracy_list.append(1 - ((batch_y - predicted).abs() / batch_y).mean().item())
    traning_accuracy = sum(traning_accuracy_list) / len(traning_accuracy_list)
    traning_accuracy_history.append(traning_accuracy)
    print(f"training accuracy: {traning_accuracy}")

    # 检查验证集
    # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
    model.eval()
    predicted = model(validating_set_x)
    validating_accuracy = 1 - ((validating_set_y - predicted).abs() / validating_set_y).mean()
    validating_accuracy_history.append(validating_accuracy.item())
    print(f"validating x: {validating_set_x}, y: {validating_set_y}, predicted: {predicted}")
    print(f"validating accuracy: {validating_accuracy}")

# 检查测试集
predicted = model(testing_set_x)
testing_accuracy = 1 - ((testing_set_y - predicted).abs() / testing_set_y).mean()
print(f"testing x: {testing_set_x}, y: {testing_set_y}, predicted: {predicted}")
print(f"testing accuracy: {testing_accuracy}")

# 显示训练集和验证集的正确率变化
pyplot.plot(traning_accuracy_history, label="traning")
pyplot.plot(validating_accuracy_history, label="validing")
pyplot.ylim(0, 1)
pyplot.legend()
pyplot.show()

# 手动输入数据预测输出
while True:
    try:
        print("enter input:")
        r = list(map(float, input().split(",")))
        x = torch.tensor(r).view(1, len(r))
        print(model(x)[0,0].item())
    except Exception as e:
        print("error:", e)

经过 500 轮训练后会生成以下的图表:

我们可以从图表看到训练集和验证集的正确率都随着训练逐渐上升,并且两个正确率非常接近,这代表训练很成功,模型针对训练集掌握了规律并且可以成功预测没有经过训练的验证集,但实际上我们很难会看到这样的图表,这是因为例子中的数据集是精心构建的并且生成了足够大量的数据。

我们还可能会看到以下类型的图表,分别代表不同的状况:

如果有足够的数据,数据遵从某种规律并且杂质较少,划分训练集和验证集的时候分布均匀,并且使用适当的模型,即可达到理想的状况,但实际很难做到😩。通过分析训练集和验证集的正确率变化我们可以定位问题发生在哪里,其中过拟合问题可以用提早停止 (Early Stopping) 的方式解决 (在第一篇文章已经提到过),接下来我们看看如何决定什么时候停止训练。

1.3.决定什么时候停止训练

还记得第一篇提到的训练流程吗?我们将会了解如何在代码中实现这个训练流程:

实现判断是否发生过拟合,可以简单的记录历史最高的验证集正确率,如果经过很多次训练都没有刷新最高正确率则结束训练。记录最高正确率的同时我们还需要保存模型的状态,这时模型摸索到了足够多的规律,但是还没有修改参数适应训练集中的杂质,用来预测未知数据可以达到最好的效果。这种手法又称提早停止 (Early Stopping),是机器学习中很常见的手法。

代码实现如下:

# 引用 pytorch 和 pandas 和显示图表使用的 matplotlib
import pandas
import torch
from torch import nn
from matplotlib import pyplot

# 定义模型
class MyModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=100)
        self.layer2 = nn.Linear(in_features=100, out_features=50)
        self.layer3 = nn.Linear(in_features=50, out_features=1)

    def forward(self, x):
        hidden1 = nn.functional.relu(self.layer1(x))
        hidden2 = nn.functional.relu(self.layer2(hidden1))
        y = self.layer3(hidden2)
        return y

# 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
# 这是为了让训练过程可重现,你也可以选择不这样做
torch.random.manual_seed(0)

# 创建模型实例
model = MyModel()

# 创建损失计算器
loss_function = torch.nn.MSELoss()

# 创建参数调整器
optimizer = torch.optim.SGD(model.parameters(), lr=0.0000001)

# 从 csv 读取原始数据集
df = pandas.read_csv('salary.csv')
dataset_tensor = torch.tensor(df.values, dtype=torch.float)

# 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
random_indices = torch.randperm(dataset_tensor.shape[0])
traning_indices = random_indices[:int(len(random_indices)*0.6)]
validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
testing_indices = random_indices[int(len(random_indices)*0.8):]
traning_set_x = dataset_tensor[traning_indices][:,:-1]
traning_set_y = dataset_tensor[traning_indices][:,-1:]
validating_set_x = dataset_tensor[validating_indices][:,:-1]
validating_set_y = dataset_tensor[validating_indices][:,-1:]
testing_set_x = dataset_tensor[testing_indices][:,:-1]
testing_set_y = dataset_tensor[testing_indices][:,-1:]

# 记录训练集和验证集的正确率变化
traning_accuracy_history = []
validating_accuracy_history = []

# 记录最高的验证集正确率
validating_accuracy_highest = 0
validating_accuracy_highest_epoch = 0

# 开始训练过程
for epoch in range(1, 10000):
    print(f"epoch: {epoch}")

    # 根据训练集训练并修改参数
    # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
    model.train()

    traning_accuracy_list = []
    for batch in range(0, traning_set_x.shape[0], 100):
        # 切分批次,一次只计算 100 组数据
        batch_x = traning_set_x[batch:batch+100]
        batch_y = traning_set_y[batch:batch+100]
        # 计算预测值
        predicted = model(batch_x)
        # 计算损失
        loss = loss_function(predicted, batch_y)
        # 从损失自动微分求导函数值
        loss.backward()
        # 使用参数调整器调整参数
        optimizer.step()
        # 清空导函数值
        optimizer.zero_grad()
        # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
        with torch.no_grad():
            traning_accuracy_list.append(1 - ((batch_y - predicted).abs() / batch_y).mean().item())
    traning_accuracy = sum(traning_accuracy_list) / len(traning_accuracy_list)
    traning_accuracy_history.append(traning_accuracy)
    print(f"training accuracy: {traning_accuracy}")

    # 检查验证集
    # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
    model.eval()
    predicted = model(validating_set_x)
    validating_accuracy = 1 - ((validating_set_y - predicted).abs() / validating_set_y).mean()
    validating_accuracy_history.append(validating_accuracy.item())
    print(f"validating x: {validating_set_x}, y: {validating_set_y}, predicted: {predicted}")
    print(f"validating accuracy: {validating_accuracy}")

    # 记录最高的验证集正确率与当时的模型状态,判断是否在 100 次训练后仍然没有刷新记录
    if validating_accuracy > validating_accuracy_highest:
        validating_accuracy_highest = validating_accuracy
        validating_accuracy_highest_epoch = epoch
        torch.save(model.state_dict(), "model.pt")
        print("highest validating accuracy updated")
    elif epoch - validating_accuracy_highest_epoch > 100:
        # 在 100 次训练后仍然没有刷新记录,结束训练
        print("stop training because highest validating accuracy not updated in 100 epoches")
        break

# 使用达到最高正确率时的模型状态
print(f"highest validating accuracy: {validating_accuracy_highest}",
    f"from epoch {validating_accuracy_highest_epoch}")
model.load_state_dict(torch.load("model.pt"))

# 检查测试集
predicted = model(testing_set_x)
testing_accuracy = 1 - ((testing_set_y - predicted).abs() / testing_set_y).mean()
print(f"testing x: {testing_set_x}, y: {testing_set_y}, predicted: {predicted}")
print(f"testing accuracy: {testing_accuracy}")

# 显示训练集和验证集的正确率变化
pyplot.plot(traning_accuracy_history, label="traning")
pyplot.plot(validating_accuracy_history, label="validing")
pyplot.ylim(0, 1)
pyplot.legend()
pyplot.show()

# 手动输入数据预测输出
while True:
    try:
        print("enter input:")
        r = list(map(float, input().split(",")))
        x = torch.tensor(r).view(1, len(r))
        print(model(x)[0,0].item())
    except Exception as e:
        print("error:", e)

 最终输出如下:

省略开始的输出

stop training because highest validating accuracy not updated in 100 epoches
highest validating accuracy: 0.93173748254776 from epoch 645
testing x: tensor([[48.,  1., 18.,  ...,  5.,  0.,  5.],
        [22.,  1.,  2.,  ...,  2.,  1.,  2.],
        [24.,  0.,  1.,  ...,  3.,  2.,  0.],
        ...,
        [24.,  0.,  4.,  ...,  0.,  1.,  1.],
        [39.,  0.,  0.,  ...,  0.,  5.,  5.],
        [36.,  0.,  5.,  ...,  3.,  0.,  3.]]), y: tensor([[14000.],
        [10500.],
        [13000.],
        ...,
        [15500.],
        [12000.],
        [19000.]]), predicted: tensor([[15612.1895],
        [10705.9873],
        [12577.7988],
        ...,
        [16281.9277],
        [10780.5996],
        [19780.3281]], grad_fn=<AddmmBackward>)
testing accuracy: 0.9330222606658936

训练集与验证集的正确率变化如下,可以看到我们停在了一个很好的地方😸,继续训练下去也不会有什么改进:

1.4.改进程序结构

我们还可以对程序结构进行以下的改进:

  • 分离准备数据集和训练的过程
  • 训练过程中分批读取数据
  • 提供接口使用训练好的模型

至此为止我们看到的训练代码都是把准备数据集,训练,训练后评价和使用写在一个程序里面的,这样做容易理解但在实际业务中会比较浪费时间,如果你发现一个模型不适合,需要修改模型那么你得从头开始。我们可以分离准备数据集和训练的过程,首先读取原始数据并且转换到 tensor 对象再保存到硬盘,然后再从硬盘读取 tensor 对象进行训练,这样如果需要修改模型但不需要修改输入输出转换到 tensor 的编码时,可以节省掉第一步。

在实际业务上数据可能会非常庞大,做不到全部读取到内存中再分批次,这时我们可以在读取原始数据并且转换到 tensor 对象的时候进行分批,然后训练的过程中逐批从硬盘读取,这样就可以防止内存不足的问题。

最后我们可以提供一个对外的接口来使用训练好的模型,如果你的程序是 python 写的那么直接调用即可,但如果你的程序是其他语言写的,可能需要先建立一个 python 服务器提供 REST 服务,或者使用 TorchScript 进行跨语言交互,详细可以参考官方的教程

总结起来我们会拆分以下过程:

  • 读取原始数据集并转换到 tensor 对象
    • 分批次保存 tensor 对象到硬盘
  • 分批次从硬盘读取 tensor 对象并进行训练
    • 训练时保存模型状态到硬盘 (一般选择保存验证集正确率最高时的模型状态)
  • 提供接口使用训练好的模型

以下是改进后的示例代码:

import os
import sys
import pandas
import torch
import gzip
import itertools
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据码农条件预测工资的模型"""
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=100)
        self.layer2 = nn.Linear(in_features=100, out_features=50)
        self.layer3 = nn.Linear(in_features=50, out_features=1)

    def forward(self, x):
        hidden1 = nn.functional.relu(self.layer1(x))
        hidden2 = nn.functional.relu(self.layer2(hidden1))
        y = self.layer3(hidden2)
        return y

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 从 csv 读取原始数据集,分批每次读取 2000 行
    for batch, df in enumerate(pandas.read_csv('salary.csv', chunksize=2000)):
        dataset_tensor = torch.tensor(df.values, dtype=torch.float)

        # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
        random_indices = torch.randperm(dataset_tensor.shape[0])
        traning_indices = random_indices[:int(len(random_indices)*0.6)]
        validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
        testing_indices = random_indices[int(len(random_indices)*0.8):]
        training_set = dataset_tensor[traning_indices]
        validating_set = dataset_tensor[validating_indices]
        testing_set = dataset_tensor[testing_indices]

        # 保存到硬盘
        save_tensor(training_set, f"data/training_set.{batch}.pt")
        save_tensor(validating_set, f"data/validating_set.{batch}.pt")
        save_tensor(testing_set, f"data/testing_set.{batch}.pt")
        print(f"batch {batch} saved")

def train():
    """开始训练"""
    # 创建模型实例
    model = MyModel()

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.SGD(model.parameters(), lr=0.0000001)

    # 记录训练集和验证集的正确率变化
    traning_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return max(0, 1 - ((actual - predicted).abs() / actual.abs()).mean().item())

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        traning_accuracy_list = []
        for batch in read_batches("data/training_set"):
            # 切分小批次,有助于泛化模型
             for index in range(0, batch.shape[0], 100):
                # 划分输入和输出
                batch_x = batch[index:index+100,:-1]
                batch_y = batch[index:index+100,-1:]
                # 计算预测值
                predicted = model(batch_x)
                # 计算损失
                loss = loss_function(predicted, batch_y)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    traning_accuracy_list.append(calc_accuracy(batch_y, predicted))
        traning_accuracy = sum(traning_accuracy_list) / len(traning_accuracy_list)
        traning_accuracy_history.append(traning_accuracy)
        print(f"training accuracy: {traning_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            validating_accuracy_list.append(calc_accuracy(batch[:,-1:],  model(batch[:,:-1])))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 100 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 100:
            # 在 100 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 100 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        testing_accuracy_list.append(calc_accuracy(batch[:,-1:],  model(batch[:,:-1])))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(traning_accuracy_history, label="traning")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    parameters = [
        "Age",
        "Gender (0: Male, 1: Female)",
        "Years of work experience",
        "Java Skill (0 ~ 5)",
        "NET Skill (0 ~ 5)",
        "JS Skill (0 ~ 5)",
        "CSS Skill (0 ~ 5)",
        "HTML Skill (0 ~ 5)"
    ]

    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel()
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 询问输入并预测输出
    while True:
        try:
            x = torch.tensor([int(input(f"Your {p}: ")) for p in parameters], dtype=torch.float)
            # 转换到 1 行 1 列的矩阵,这里其实可以不转换但推荐这么做,因为不是所有模型都支持非批次输入
            x = x.view(1, len(x))
            y = model(x)
            print("Your estimated salary:", y[0,0].item(), "\n")
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

 执行以下命令即可走一遍完整的流程,如果你需要调整模型,可以直接重新运行 train 避免 prepare 的时间消耗:

python3 example.py prepare
python3 example.py train
python3 example.py eval

注意以上代码在打乱数据集和分批的处理上与以往的代码不一样,以上的代码会分段读取 csv 文件,然后对每一段打乱再切分训练集,验证集和测试集,这样做同样可以保证数据在各个集合中分布均匀。最终训练集和验证集的正确率变化如下:

1.5.正规化输入和输出值

目前为止我们在训练的时候都是直接给模型原始的输入值,然后用原始的输出值去调整参数,这样做的问题是,如果输入值非常大导函数值也会非常大,如果输出值非常大需要调整参数的次数会非常多,过去我们用一个非常非常小的学习比率 (0.0000001) 来避开这个问题,但其实有更好的办法,那就是正规化输入和输出值。这里的正规化指的是让输入值和输出值按一定比例缩放,让大部分的值都落在 -1 ~ 1 的区间中。在根据码农条件预测工资的例子中,我们可以把年龄和工作经验年数乘以 0.01 (范围 0 ~ 100 年),各项技能乘以 0.02 (范围 0 ~ 5),工资乘以 0.0001 (以万为单位),对 dataset_tensor 进行以下操作即可实现:

# 对每一行乘以指定的系数
dataset_tensor *= torch.tensor([0.01, 1, 0.01, 0.2, 0.2, 0.2, 0.2, 0.2, 0.0001])

然后再修改学习比率为 0.01:

optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

比较训练 300 次的正确率变化如下:

正规化输入和输出值前

正规化输入和输出值后

可以看到效果相当惊人😈,正规化输入和输出值后训练速度变快了并且正确率的变化曲线平滑了很多。实际上这是必须做的,部分数据集如果没有经过正规化根本无法学习,让模型接收和输出更小的值 (-1 ~ 1 的区间) 可以防止导函数值爆炸和使用更高的学习比率加快训练速度。

此外,别忘了在使用模型的时候缩放输入和输出值:

x = torch.tensor([int(input(f"Your {p}: ")) for p in parameters], dtype=torch.float)
x *= torch.tensor([0.01, 1, 0.01, 0.2, 0.2, 0.2, 0.2, 0.2])
# 转换到 1 行 1 列的矩阵,这里其实可以不转换但推荐这么做,因为不是所有模型都支持非批次输入
x = x.view(1, len(x))
y = model(x) * 10000
print("Your estimated salary:", y[0,0].item(), "\n")

1.6.使用Dropout帮助泛化模型

在之前的内容中已经提到过,如果模型能力过于强大或者数据杂质较多,则模型有可能会适应数据中的杂质以达到更高的正确率 (过拟合现象),这时候虽然训练集的正确率会上升,但验证集的正确率会维持甚至下降,模型应对未知数据的能力会降低。防止过拟合现象,增强模型应对未知数据的能力又称泛化模型 (Generalize Model),泛化模型的手段之一是使用 Dropout,Dropout 会在训练过程中随机屏蔽一部分的神经元,让这些神经元的输出为 0,同时增幅没有被屏蔽的神经元输出让输出值合计接近原有的水平,这样做的好处是模型会尝试摸索怎样在一部分神经元被屏蔽后仍然可以正确预测结果 (减弱跨层神经元之间的关联),最终导致模型更充分的掌握数据的规律。

即屏蔽掉不好的一部分数据(坏数据)使模型更加具有普适性

下图是使用 Dropout 以后的神经元网络例子 (3 输入 2 输出,3 层每层各 5 隐藏值):

接下来我们看看在 Pytorch 中怎么使用 Dropout:

# 引用 pytorch 类库
>>> import torch

# 创建屏蔽 20% 的 Dropout 函数
>>> dropout = torch.nn.Dropout(0.2)

# 定义一个 tensor (假设这个 tensor 是某个神经元网络层的输出结果)
>>> a = torch.tensor(range(1, 11), dtype=torch.float)
>>> a
tensor([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.])

# 应用 Dropout 函数
# 我们可以看到没有屏蔽的值都会相应的增加 (除以 0.8) 以让合计值维持原有的水平
# 此外屏蔽的数量会根据概率浮动,不一定 100% 等于我们设置的比例 (这里有屏蔽 1 个值的也有屏蔽 3 个值的)
>>> dropout(a)
tensor([ 0.0000,  2.5000,  3.7500,  5.0000,  6.2500,  7.5000,  8.7500, 10.0000,
        11.2500, 12.5000])
>>> dropout(a)
tensor([ 1.2500,  2.5000,  3.7500,  5.0000,  6.2500,  7.5000,  8.7500,  0.0000,
        11.2500,  0.0000])
>>> dropout(a)
tensor([ 1.2500,  2.5000,  3.7500,  5.0000,  6.2500,  7.5000,  8.7500,  0.0000,
        11.2500, 12.5000])
>>> dropout(a)
tensor([ 1.2500,  2.5000,  3.7500,  5.0000,  6.2500,  7.5000,  0.0000, 10.0000,
        11.2500,  0.0000])
>>> dropout(a)
tensor([ 1.2500,  2.5000,  3.7500,  5.0000,  0.0000,  7.5000,  8.7500, 10.0000,
        11.2500,  0.0000])
>>> dropout(a)
tensor([ 1.2500,  2.5000,  0.0000,  5.0000,  0.0000,  7.5000,  8.7500, 10.0000,
        11.2500, 12.5000])
>>> dropout(a)
tensor([ 0.0000,  2.5000,  3.7500,  5.0000,  6.2500,  7.5000,  0.0000, 10.0000,
         0.0000,  0.0000])

接下来我们看看怎样应用 Dropout 到模型中,首先我们重现一下过拟合现象,增加模型的神经元数量并且减少训练集的数据量即可:

模型部分的代码:

class MyModel(nn.Module):
    """根据码农条件预测工资的模型"""
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=200)
        self.layer2 = nn.Linear(in_features=200, out_features=100)
        self.layer3 = nn.Linear(in_features=100, out_features=1)

    def forward(self, x):
        hidden1 = nn.functional.relu(self.layer1(x))
        hidden2 = nn.functional.relu(self.layer2(hidden1))
        y = self.layer3(hidden2)
        return y

训练部分的代码 (每个批次只训练前 16 个数据):

for batch in read_batches("data/training_set"):
    # 切分小批次,有助于泛化模型
     for index in range(0, batch.shape[0], 16):
        # 划分输入和输出
        batch_x = batch[index:index+16,:-1]
        batch_y = batch[index:index+16,-1:]
        # 计算预测值
        predicted = model(batch_x)
        # 计算损失
        loss = loss_function(predicted, batch_y)
        # 从损失自动微分求导函数值
        loss.backward()
        # 使用参数调整器调整参数
        optimizer.step()
        # 清空导函数值
        optimizer.zero_grad()
        # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
        with torch.no_grad():
            traning_accuracy_list.append(calc_accuracy(batch_y, predicted))
        # 只训练前 16 个数据
        break

固定训练 1000 次以后的正确率:

training accuracy: 0.9706422178819776
validating accuracy: 0.8514168351888657
highest validating accuracy: 0.8607834208011628 from epoch 223
testing accuracy: 0.8603586450219154

以及正确率变化的趋势:

试着在模型中加入两个 Dropout,分别对应第一层与第二层的输出 (隐藏值):

class MyModel(nn.Module):
    """根据码农条件预测工资的模型"""
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=200)
        self.layer2 = nn.Linear(in_features=200, out_features=100)
        self.layer3 = nn.Linear(in_features=100, out_features=1)
        self.dropout1 = nn.Dropout(0.2)
        self.dropout2 = nn.Dropout(0.2)

    def forward(self, x):
        hidden1 = self.dropout1(nn.functional.relu(self.layer1(x)))
        hidden2 = self.dropout2(nn.functional.relu(self.layer2(hidden1)))
        y = self.layer3(hidden2)
        return y

 这时候再来训练会得出以下的正确率:

training accuracy: 0.9326518730819225
validating accuracy: 0.8692235469818115
highest validating accuracy: 0.8728838726878166 from epoch 867
testing accuracy: 0.8733032837510109

以及正确率变化的趋势:

我们可以看到训练集的正确率没有盲目的上升,并且验证集与测试集的正确率都各上升了 1% 以上,说明 Dropout 是有一定效果的。

使用 Dropout 时应该注意以下的几点:

  • Dropout 应该针对隐藏值使用,不能放在第一层的前面 (针对输入) 或者最后一层的后面 (针对输出)
  • Dropout 应该放在激活函数后面 (因为激活函数是神经元的一部分)
  • Dropout 只应该在训练过程中使用,评价或实际使用模型时应该调用 model.eval() 切换模型到评价模式,以禁止 Dropout
  • Dropout 函数应该定义为模型的成员,这样调用 model.eval() 可以索引到模型对应的所有 Dropout 函数
  • Dropout 的屏蔽比例没有最佳值,你可以针对当前的数据和模型多试几次找出最好的结果

提出 Dropout 手法的原始论文在这里,如果你有兴趣可以查看。

1.7.使用BatchNorm正规化批次

BatchNorm 是另外一种提升训练效果的手法,在一些场景下可以提升训练效率和抑制过拟合,BatchNorm 和 Dropout 一样针对隐藏值使用,会对每个批次的各项值 (每一列) 进行正规化,计算公式如下:

总结来说就是让每一列中的各个值减去这一列的平均值,然后除以这一列的标准差,再按一定比例调整。

在 python 中使用 BatchNorm 的例子如下:

# 创建 batchnorm 函数,3 代表列数
>>> batchnorm = torch.nn.BatchNorm1d(3)

# 查看 batchnorm 函数内部的权重与偏移
>>> list(batchnorm.parameters())
[Parameter containing:
tensor([1., 1., 1.], requires_grad=True), Parameter containing:
tensor([0., 0., 0.], requires_grad=True)]

# 随机创建一个 10 行 3 列的 tensor
>>> a = torch.rand((10, 3))
>>> a
tensor([[0.9643, 0.6933, 0.0039],
        [0.3967, 0.8239, 0.3490],
        [0.4011, 0.8903, 0.3053],
        [0.0666, 0.5766, 0.4976],
        [0.4928, 0.1403, 0.8900],
        [0.7317, 0.9461, 0.1816],
        [0.4461, 0.9987, 0.8324],
        [0.3714, 0.6550, 0.9961],
        [0.4852, 0.7415, 0.1779],
        [0.6876, 0.1538, 0.3429]])

# 应用 batchnorm 函数
>>> batchnorm(a)
tensor([[ 1.9935,  0.1096, -1.4156],
        [-0.4665,  0.5665, -0.3391],
        [-0.4477,  0.7985, -0.4754],
        [-1.8972, -0.2986,  0.1246],
        [-0.0501, -1.8245,  1.3486],
        [ 0.9855,  0.9939, -0.8611],
        [-0.2523,  1.1776,  1.1691],
        [-0.5761, -0.0243,  1.6798],
        [-0.0831,  0.2783, -0.8727],
        [ 0.7941, -1.7770, -0.3581]], grad_fn=<NativeBatchNormBackward>)

# 手动重现 batchnorm 对第一列的计算
>>> aa = a[:,:1]
>>> aa
tensor([[0.9643],
        [0.3967],
        [0.4011],
        [0.0666],
        [0.4928],
        [0.7317],
        [0.4461],
        [0.3714],
        [0.4852],
        [0.6876]])
>>> (aa - aa.mean()) / (((aa - aa.mean()) ** 2).mean() + 0.00001).sqrt()
tensor([[ 1.9935],
        [-0.4665],
        [-0.4477],
        [-1.8972],
        [-0.0501],
        [ 0.9855],
        [-0.2523],
        [-0.5761],
        [-0.0831],
        [ 0.7941]])

 修改模型使用 BatchNorm 的代码如下:

class MyModel(nn.Module):
    """根据码农条件预测工资的模型"""
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=200)
        self.layer2 = nn.Linear(in_features=200, out_features=100)
        self.layer3 = nn.Linear(in_features=100, out_features=1)
        self.batchnorm1 = nn.BatchNorm1d(200)
        self.batchnorm2 = nn.BatchNorm1d(100)
        self.dropout1 = nn.Dropout(0.1)
        self.dropout2 = nn.Dropout(0.1)

    def forward(self, x):
        hidden1 = self.dropout1(self.batchnorm1(nn.functional.relu(self.layer1(x))))
        hidden2 = self.dropout2(self.batchnorm2(nn.functional.relu(self.layer2(hidden1))))
        y = self.layer3(hidden2)
        return y

需要同时调整学习比率:

# 创建参数调整器
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)

固定训练 1000 次的结果如下,可以看到在这个场景下 BatchNorm 没有发挥作用🤕,反而减慢了学习速度和影响可达到的最高正确率 (你可以试试增加训练次数):

training accuracy: 0.9048486271500588
validating accuracy: 0.8341873311996459
highest validating accuracy: 0.8443503141403198 from epoch 946
testing accuracy: 0.8452585405111313

使用 BatchNorm 时应该注意以下的几点:

  • BatchNorm 应该针对隐藏值使用,和 Dropout 一样
  • BatchNorm 需要指定隐藏值数量,应该与对应层的输出数量匹配
  • BatchNorm 应该放在 Dropout 前面,有部分人会选择把 BatchNorm 放在激活函数前,也有部分人选择放在激活函数后
    • Linear => ReLU => BatchNorm => Dropout
    • Linear => BatchNorm => ReLU => Dropout
  • BatchNorm 只应该在训练过程中使用,和 Dropout 一样
  • BatchNorm 函数应该定义为模型的成员,和 Dropout 一样
  • 使用 BatchNorm 的时候应该相应的减少 Dropout 的屏蔽比例
  • 部分场景可能不适用 BatchNorm (据说更适用于对象识别和图片分类),需要实践才能出真知 ☭

提出 BatchNorm 手法的原始论文在这里,如果你有兴趣可以查看。

1.8.理解模型的eval和train模式

在前面的例子中我们使用了 eval 和 train 函数切换模型到评价模式和训练模式,评价模式会禁用自动微分,Dropout 和 BatchNorm,那么这两个模式是如何实现的呢?

pytorch 的模型都基于 torch.nn.Module 这个类,不仅是我们自己定义的模型,nn.Sequentialnn.Linearnn.ReLUnn.Dropoutnn.BatchNorm1d 等等的类型都会基于 torch.nn.Moduletorch.nn.Module 有一个 training 成员代表模型是否处于训练模式,而 eval 函数用于递归设置所有 Module 的 training 为 Falsetrain 函数用于递归设置所有 Module 的 training 为 True。我们可以手动设置这个成员看看是否能起到相同效果:

>>> a = torch.tensor(range(1, 11), dtype=torch.float)
>>> dropout = torch.nn.Dropout(0.2)

>>> dropout.training = False
>>> dropout(a)
tensor([ 1.,  2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10.])

>>> dropout.training = True
>>> dropout(a)
tensor([ 1.2500,  2.5000,  3.7500,  0.0000,  0.0000,  7.5000,  8.7500, 10.0000,
         0.0000, 12.5000])

 理解这一点后,你可以在模型中添加只在训练或者评价的时候执行的代码,根据 self.training 判断即可。

1.9.最终代码

根据码农条件预测工资的最终代码如下:

import os
import sys
import pandas
import torch
import gzip
import itertools
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据码农条件预测工资的模型"""
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(in_features=8, out_features=200)
        self.layer2 = nn.Linear(in_features=200, out_features=100)
        self.layer3 = nn.Linear(in_features=100, out_features=1)
        self.batchnorm1 = nn.BatchNorm1d(200)
        self.batchnorm2 = nn.BatchNorm1d(100)
        self.dropout1 = nn.Dropout(0.1)
        self.dropout2 = nn.Dropout(0.1)

    def forward(self, x):
        hidden1 = self.dropout1(self.batchnorm1(nn.functional.relu(self.layer1(x))))
        hidden2 = self.dropout2(self.batchnorm2(nn.functional.relu(self.layer2(hidden1))))
        y = self.layer3(hidden2)
        return y

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 从 csv 读取原始数据集,分批每次读取 2000 行
    for batch, df in enumerate(pandas.read_csv('salary.csv', chunksize=2000)):
        dataset_tensor = torch.tensor(df.values, dtype=torch.float)

        # 正规化输入和输出
        dataset_tensor *= torch.tensor([0.01, 1, 0.01, 0.2, 0.2, 0.2, 0.2, 0.2, 0.0001])

        # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
        random_indices = torch.randperm(dataset_tensor.shape[0])
        traning_indices = random_indices[:int(len(random_indices)*0.6)]
        validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
        testing_indices = random_indices[int(len(random_indices)*0.8):]
        training_set = dataset_tensor[traning_indices]
        validating_set = dataset_tensor[validating_indices]
        testing_set = dataset_tensor[testing_indices]

        # 保存到硬盘
        save_tensor(training_set, f"data/training_set.{batch}.pt")
        save_tensor(validating_set, f"data/validating_set.{batch}.pt")
        save_tensor(testing_set, f"data/testing_set.{batch}.pt")
        print(f"batch {batch} saved")

def train():
    """开始训练"""
    # 创建模型实例
    model = MyModel()

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.SGD(model.parameters(), lr=0.05)

    # 记录训练集和验证集的正确率变化
    traning_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return max(0, 1 - ((actual - predicted).abs() / actual.abs()).mean().item())

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        traning_accuracy_list = []
        for batch in read_batches("data/training_set"):
            # 切分小批次,有助于泛化模型
             for index in range(0, batch.shape[0], 100):
                # 划分输入和输出
                batch_x = batch[index:index+100,:-1]
                batch_y = batch[index:index+100,-1:]
                # 计算预测值
                predicted = model(batch_x)
                # 计算损失
                loss = loss_function(predicted, batch_y)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    traning_accuracy_list.append(calc_accuracy(batch_y, predicted))
        traning_accuracy = sum(traning_accuracy_list) / len(traning_accuracy_list)
        traning_accuracy_history.append(traning_accuracy)
        print(f"training accuracy: {traning_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            validating_accuracy_list.append(calc_accuracy(batch[:,-1:],  model(batch[:,:-1])))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 100 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 100:
            # 在 100 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 100 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        testing_accuracy_list.append(calc_accuracy(batch[:,-1:],  model(batch[:,:-1])))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(traning_accuracy_history, label="traning")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    parameters = [
        "Age",
        "Gender (0: Male, 1: Female)",
        "Years of work experience",
        "Java Skill (0 ~ 5)",
        "NET Skill (0 ~ 5)",
        "JS Skill (0 ~ 5)",
        "CSS Skill (0 ~ 5)",
        "HTML Skill (0 ~ 5)"
    ]

    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel()
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 询问输入并预测输出
    while True:
        try:
            x = torch.tensor([int(input(f"Your {p}: ")) for p in parameters], dtype=torch.float)
            # 正规化输入
            x *= torch.tensor([0.01, 1, 0.01, 0.2, 0.2, 0.2, 0.2, 0.2])
            # 转换到 1 行 1 列的矩阵,这里其实可以不转换但推荐这么做,因为不是所有模型都支持非批次输入
            x = x.view(1, len(x))
            # 预测输出
            y = model(x)
            # 反正规化输出
            y *= 10000
            print("Your estimated salary:", y[0,0].item(), "\n")
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

最终训练结果如下,验证集和测试集正确率达到了 94.3% (前一篇分别是 93.3% 和 93.1%):

epoch: 848
training accuracy: 0.929181088420252
validating accuracy: 0.9417830203473568
stop training because highest validating accuracy not updated in 100 epoches
highest validating accuracy: 0.9437697219848633 from epoch 747
testing accuracy: 0.9438129015266895

正确率变化如下:

算是圆满成功了叭🥳。

 二.递归模型RNN,LSTM与GRU

2.1.递归模型的应用场景

在前面的文章中我们看到的多层线性模型能处理的输入数量是固定的,如果一个模型能接收两个输入那么你就不能给它传一个或者三个。而有时候我们需要根据数量不一定的输入来预测输出,例如文本就是数量不一定的输入,“这部片非常好看” 有 7 个字,“这部片很无聊” 有 6 个字,如果我们想根据文本判断是正面评价还是负面评价,那么就需要使用支持不定长度 (即可以接收 6 个又可以接收 7 个) 输入的模型。时序性的数据数量也是不一定的,例如一个运动中的球,从某个时间点开始的第 0 秒在位置 1,第 1 秒在位置 3,第 2 秒在位置 5,那么正确的模型应该可以预测出第 3 秒在位置 7,如下图所示。当然,时序性的数据可以固定一个窗口(例如最近的 5 条数据)来处理,这样输入数量就是一定的,但灵活性就降低了,窗口设置过小可能会导致没有足够的信息用于预测输出,过大则会影响性能。

递归模型 (Recursive Model) 可以用于处理不定长度的输入,用法是一次只传固定数量的输入给模型,可以分多次传,传的次数根据数据而定。以上述例子来说,“这部片非常好看” 每次传一个字需要传 7 次,“这部片很无聊” 每次传一个字需要传 6 次。而递归模型每收到一次输入都会返回一次输出,有的场景只会使用最后一次输出的结果 (例如这个例子),而有的场景则会使用每一次输出的结果。

换成代码可以这样理解:

model = MyRecursiveModel()
model('这')
model('部')
model('片')
model('非')
model('常')
model('好')
last_output = model('看')
print(last_output)

 接下来我们看看几个经典的递归模型是怎么实现的。

2.2.最简单的递归模型-RNN(tanh)

RNN tanh (Recurrent Neural Network - tanh) 是最简单的递归模型,计算公式如下,数学不好的第一印象可能会觉得妈呀一看数学公式就头昏脑胀了🙀,我们先一个个参数来分析,H 是递归模型内部记录的一个隐藏值矩阵,Ht 代表当前时序的值,而 H(t-1) 代表前一个时序的值,t 可以置换到具体的数字,例如 Ht0 代表隐藏值矩阵最开始的状态 (一般会使用 0 初始化),Ht1 代表隐藏值矩阵第一次更新以后的状态,Ht2 代表隐藏值矩阵第二次更新以后的状态,计算 Ht1  H(t-1) 代表 Ht0,计算 Ht2 时 H(t-1) 代表 Ht1Wx 是针对输入的权重值,Bx 是针对输入的偏移值,Wh 是针对隐藏的权重值,Bh 是针对隐藏的偏移值;tanh 用于把实数转换为 -1 ~ 1 之间的范围。这个公式和之前我们看到的人工神经元很相似,只是把每一次的输入和当前隐藏值经过计算后的值加在一起,然后使用 tanh 作为激活函数生成新的隐藏值,隐藏值会当作每一次的输出使用。

如果你觉得文本难以理解,可以看展开以后的公式:

可以看到每次的输出结果都会根据之前的输入计算,tanh 用于非线性过滤和控制值范围在 -1 ~ 1 之间。

你也可以参考下面的计算图来理解,图中展示了 RNN tanh 模型如何计算三次输入和返回三次输出 (注意最后加了一层额外的线性模型用于把 RNN 返回的隐藏值转换为预测输出):

(看不清请在新标签单独打开图片,或者另存为到本地以后查看)

 2.3.在Pytorch中使用RNN

因为递归模型支持不定长度的数据,而 pytorch 围绕 tensor 来进行运算,tensor 的维度又是固定的,要在 pytorch 中使用递归模型需要很繁琐的处理,下图说明了处理的整个流程:

我们再来看看怎样在代码里面实现这个流程:

# 引用 pytorch 类库
>>> import torch

# 准备数据集
>>> data1 = torch.tensor([1, 2, 3], dtype=torch.float)
>>> data2 = torch.tensor([3, 5, 7, 9, 11], dtype=torch.float)
>>> datalist = [data1, data2]

# 合并到一个 tensor 并填充数据集到相同长度
# 这里使用 batch_first 指定第一维度为批次数量
>>> padded = torch.nn.utils.rnn.pad_sequence(datalist, batch_first=True)
>>> padded
tensor([[ 1.,  2.,  3.,  0.,  0.],
        [ 3.,  5.,  7.,  9., 11.]])

# 另建一个 tensor 来保存各组数据的实际长度
>>> lengths = torch.tensor([len(x) for x in datalist])
>>> lengths
tensor([3, 5])

# 建立 RNN 模型,每次接收 1 个输入,内部拥有 8 个隐藏值 (每次都会返回 8 个最新的隐藏值)
# 指定 num_layers 可以在内部叠加 RNN 模型,这里不叠加所以只指定 1
>>> rnn_model = torch.nn.RNN(input_size = 1, hidden_size = 8, num_layers = 1, batch_first = True)

# 建立 Linear 模型,每次接收 8 个输入 (RNN 模型返回的隐藏值),返回 1 个输出
>>> linear_model = torch.nn.Linear(in_features = 8, out_features = 1)

# 改变 tensor 维度到 batch_size, input_size, step_size
# 即 批次数量, 输入次数, 每次传给模型的输入数量
>>> x = padded.reshape(padded.shape[0], padded.shape[1], 1)
>>> x
tensor([[[ 1.],
         [ 2.],
         [ 3.],
         [ 0.],
         [ 0.]],

        [[ 3.],
         [ 5.],
         [ 7.],
         [ 9.],
         [11.]]])

# 把数据 tensor 和长度 tensor 合并到一个结构体
# 这样做的意义是可以避免 RNN 计算填充的那些 0
# enforce_sorted 表示数据事先没有排序过,如果不指定这个选项会出错
>>> packed = torch.nn.utils.rnn.pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False)
>>> packed
PackedSequence(data=tensor([[ 3.],
        [ 1.],
        [ 5.],
        [ 2.],
        [ 7.],
        [ 3.],
        [ 9.],
        [11.]]), batch_sizes=tensor([2, 2, 2, 1, 1]), sorted_indices=tensor([1, 0]), unsorted_indices=tensor([1, 0]))

# 把结构体传给模型
# 模型会返回各个输入对应的输出,维度是 实际处理的输入数量,  hidden_size
# 模型还会返回最新的隐藏值
>>> output, hidden = rnn_model(packed)
>>> output
PackedSequence(data=tensor([[-0.3055,  0.2916,  0.2736, -0.0502, -0.4033, -0.1438, -0.6981,  0.6284],
        [-0.2343,  0.2279,  0.0595,  0.1867, -0.2527, -0.0518, -0.1913,  0.5276],
        [-0.0556,  0.2870,  0.3035, -0.3519, -0.4015,  0.1584, -0.9157,  0.6472],
        [-0.1488,  0.2706,  0.1115, -0.0131, -0.2350,  0.1252, -0.4981,  0.5706],
        [-0.0179,  0.1201,  0.4751, -0.5256, -0.3701,  0.1289, -0.9834,  0.7087],
        [-0.1094,  0.1283,  0.1698, -0.1136, -0.1999,  0.1847, -0.7394,  0.5756],
        [ 0.0426,  0.1866,  0.5581, -0.6716, -0.4857,  0.0039, -0.9964,  0.7603],
        [ 0.0931,  0.2418,  0.6602, -0.7674, -0.6003, -0.0989, -0.9991,  0.8172]],
       grad_fn=<CatBackward>), batch_sizes=tensor([2, 2, 2, 1, 1]), sorted_indices=tensor([1, 0]), unsorted_indices=tensor([1, 0]))
>>> hidden
tensor([[[-0.1094,  0.1283,  0.1698, -0.1136, -0.1999,  0.1847, -0.7394,
           0.5756],
         [ 0.0931,  0.2418,  0.6602, -0.7674, -0.6003, -0.0989, -0.9991,
           0.8172]]], grad_fn=<IndexSelectBackward>)

# 把连在一起的输出解压到一个 tensor
# 解压后的维度是 batch_size, input_size, hidden_size
# 注意第二个返回值是各组输出的实际长度,等于之前的 lengths,所以我们不需要
>>> unpacked, _ = torch.nn.utils.rnn.pad_packed_sequence(output, batch_first=True)
>>> unpacked
tensor([[[-0.2343,  0.2279,  0.0595,  0.1867, -0.2527, -0.0518, -0.1913,
           0.5276],
         [-0.1488,  0.2706,  0.1115, -0.0131, -0.2350,  0.1252, -0.4981,
           0.5706],
         [-0.1094,  0.1283,  0.1698, -0.1136, -0.1999,  0.1847, -0.7394,
           0.5756],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,
           0.0000],
         [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,
           0.0000]],

        [[-0.3055,  0.2916,  0.2736, -0.0502, -0.4033, -0.1438, -0.6981,
           0.6284],
         [-0.0556,  0.2870,  0.3035, -0.3519, -0.4015,  0.1584, -0.9157,
           0.6472],
         [-0.0179,  0.1201,  0.4751, -0.5256, -0.3701,  0.1289, -0.9834,
           0.7087],
         [ 0.0426,  0.1866,  0.5581, -0.6716, -0.4857,  0.0039, -0.9964,
           0.7603],
         [ 0.0931,  0.2418,  0.6602, -0.7674, -0.6003, -0.0989, -0.9991,
           0.8172]]], grad_fn=<IndexSelectBackward>)

# 提取最后一个输出 (根据业务而定)
# 提取后的维度是 batch_size, hidden_size
# 可以看到使用 RNN tanh 时最后一个输出的值等于之前返回的 hidden
>>> last_hidden = unpacked.gather(1, (lengths - 1).reshape(-1, 1, 1).repeat(1, 1, unpacked.shape[2]))
>>> last_hidden
tensor([[[-0.1094,  0.1283,  0.1698, -0.1136, -0.1999,  0.1847, -0.7394,
           0.5756]],

        [[ 0.0931,  0.2418,  0.6602, -0.7674, -0.6003, -0.0989, -0.9991,
           0.8172]]], grad_fn=<GatherBackward>)

# 把 RNN 模型返回的隐藏值传给 Linear 模型,得出预测输出
>>> predicted = linear_model(last_hidden)
>>> predicted
tensor([[[0.1553]],

        [[0.1431]]], grad_fn=<AddBackward0>)

 之后根据实际输出计算误差,然后根据自动微分调整参数即可进行训练。

2.4.RNN的简单例子

现在我们来通过一个简单的例子实践 RNN 模型,假设有一个球,球只可以双方向匀速移动,把某一点定做位置 0,点的右边按一定间隔定做位置 1, 2, 3 ...,点的左边按一定间隔定做位置 -1, -2, -3, 现在有球的移动位置数据,例如:

1,3,5,7,9

表示记录了 5 次球的移动位置,每次球都移动了 2 个位置的距离,如果我们要建立一个 RNN 模型根据球的历史位置预测将来位置,那么传入 1,3,5,7 模型应该可以返回 9,如果球向反方向运动,传入 9,7,5,3 模型应该可以返回 1

我准备了 10000 条这样的数据,可以从 303248153.github.io/ball.csv at master · 303248153/303248153.github.io · GitHub 下载。

以下是训练和使用模型的代码,跟上一篇文章介绍的代码结构基本上相同,注意代码会切分数据到输入 (除了最后一个位置) 和输出 (最后一个位置):

import os
import sys
import torch
import gzip
import itertools
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据球的历史位置预测将来位置的模型,假设球只能双方向匀速移动"""
    def __init__(self):
        super().__init__()
        self.rnn = nn.RNN(
            input_size = 1,
            hidden_size = 8,
            num_layers = 1,
            batch_first = True
        )
        self.linear = nn.Linear(in_features=8, out_features=1)

    def forward(self, x, lengths):
        # 附加长度信息,避免 RNN 计算填充的数据
        packed = nn.utils.rnn.pack_padded_sequence(
            x, lengths, batch_first=True, enforce_sorted=False)
        # 使用递归模型计算,返回各个输入对应的输出和最终的隐藏状态
        # 因为 RNN tanh 直接把隐藏值作为输出,所以 output 的最后一个值提取出来会等于 hidden
        # 平时为了方便可以直接使用 hidden,但这里为了演示怎么提取会使用 output
        output, hidden = self.rnn(packed)
        # output 内部会连接所有隐藏状态,shape = 实际输入数量合计, hidden_size
        # 为了接下来的处理,需要先整理 shape = batch_size, 每组的最大输入数量, hidden_size
        # 第二个返回值是各个 tensor 的实际长度,内容和 lengths 相同,所以可以省略掉
        unpacked, _ = nn.utils.rnn.pad_packed_sequence(output, batch_first=True)
        # 提取最后一个输入对应的输出, shape = batch_size, 1, hidden_size
        # 对于大部分递归模型, last_hidden 的值实际上会等于 hidden
        last_hidden = unpacked.gather(1, (lengths - 1).reshape(-1, 1, 1).repeat(1, 1, unpacked.shape[2]))
        # 传给线性模型把隐藏值转换到预测输出
        y = self.linear(last_hidden.reshape(last_hidden.shape[0], last_hidden.shape[2]))
        return y

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def prepare_save_batch(batch, pending_tensors):
    """准备训练 - 保存单个批次的数据"""
    # 整合长度不等的 tensor 列表到一个 tensor,不足的长度会填充 0
    dataset_tensor = nn.utils.rnn.pad_sequence(pending_tensors, batch_first=True)

    # 正规化数据,因为大部分数据都落在 -50 ~ 50 的区间中,这里可以全部除以 50
    dataset_tensor /= 50

    # 另外保存各个 tensor 的长度
    lengths_tensor = torch.tensor([t.shape[0] for t in pending_tensors])

    # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
    random_indices = torch.randperm(dataset_tensor.shape[0])
    traning_indices = random_indices[:int(len(random_indices)*0.6)]
    validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
    testing_indices = random_indices[int(len(random_indices)*0.8):]
    training_set = (dataset_tensor[traning_indices], lengths_tensor[traning_indices])
    validating_set = (dataset_tensor[validating_indices], lengths_tensor[validating_indices])
    testing_set = (dataset_tensor[testing_indices], lengths_tensor[testing_indices])

    # 保存到硬盘
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 从 csv 读取原始数据集,分批每次处理 2000 行
    # 因为 pandas 不支持读取动态长度的 csv 文件,这里需要使用原始方法读取
    batch = 0
    pending_tensors = []
    for line in open('ball.csv', 'r'):
        t = torch.tensor([int(x) for x in line.split(',')], dtype=torch.float)
        pending_tensors.append(t)
        if len(pending_tensors) >= 2000:
            prepare_save_batch(batch, pending_tensors)
            batch += 1
            pending_tensors.clear()
    if pending_tensors:
        prepare_save_batch(batch, pending_tensors)
        batch += 1
        pending_tensors.clear()

def train():
    """开始训练"""
    # 创建模型实例
    model = MyModel()

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.SGD(model.parameters(), lr=0.1)

    # 记录训练集和验证集的正确率变化
    traning_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return 1 - ((actual - predicted).abs() / (actual.abs() + 0.0001)).mean().item()

    # 划分输入和输出的工具函数,输出为最后一个位置,输入为之前的位置
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, input_size
        batch_x = batch[0][begin:end]
        # shape = batch_size, 1
        batch_x_lengths = batch[1][begin:end] - 1
        # shape = batch_size, 1
        batch_y = batch_x.gather(1, batch_x_lengths.reshape(-1, 1))
        # shape = batch_size, input_size, step_size = batch_size, input_size, 1
        batch_x = batch_x.reshape(batch_x.shape[0], batch_x.shape[1], 1)
        return batch_x, batch_x_lengths, batch_y

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        traning_accuracy_list = []
        for batch in read_batches("data/training_set"):
            # 切分小批次,有助于泛化模型
             for index in range(0, batch[0].shape[0], 100):
                # 划分输入和输出
                batch_x, batch_x_lengths, batch_y = split_batch_xy(batch, index, index+100)
                # 计算预测值
                predicted = model(batch_x, batch_x_lengths)
                # 计算损失
                loss = loss_function(predicted, batch_y)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    traning_accuracy_list.append(calc_accuracy(batch_y, predicted))
        traning_accuracy = sum(traning_accuracy_list) / len(traning_accuracy_list)
        traning_accuracy_history.append(traning_accuracy)
        print(f"training accuracy: {traning_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
            predicted = model(batch_x, batch_x_lengths)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 100 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 100:
            # 在 100 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 100 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
        predicted = model(batch_x, batch_x_lengths)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(traning_accuracy_history, label="traning")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel()
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 询问输入并预测输出
    while True:
        try:
            x = torch.tensor([int(x) for x in input("History ball locations: ").split(",")], dtype=torch.float)
            # 正规化输入
            x /= 50
            # 转换矩阵大小,shape = batch_size, input_size, step_size
            x = x.reshape(1, x.shape[0], 1)
            lengths = torch.tensor([x.shape[1]])
            # 预测输出
            y = model(x, lengths)
            # 反正规化输出
            y *= 50
            print("Next ball location:", y[0, 0].item(), "\n")
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

执行以下命令即可准备数据集并训练模型:

python3 example.py prepare
python3 example.py train 

最终输出如下,验证集和测试集正确度都达到了 92% (这个例子仅用于演示怎样使用 RNN,如果你有兴趣可以试试怎样提高正确度):

epoch: 2351
training accuracy: 0.9427350702928379
validating accuracy: 0.9283818986266852
stop training because highest validating accuracy not updated in 100 epoches
highest validating accuracy: 0.9284620460122823 from epoch 2250
testing accuracy: 0.9274853881448507

接下来执行以下命令即可使用模型:

python3 example.py eval

试试根据历史位置预测将来位置,可以看到预测值比较接近我们预期的实际值🙂️:

History ball locations: 1,2,3
Next ball location: 3.8339991569519043

History ball locations: 3,5,7
Next ball location: 9.035120964050293

History ball locations: 9,7,5,3
Next ball location: 0.9755149483680725

History ball locations: 2,0,-2
Next ball location: -3.913722276687622

History ball locations: 0,-3,-6
Next ball location: -9.093448638916016

 2.5.长短记忆模型-LSTM

在看 RNN tanh 计算公式的时候,你可能会想起第三篇文章在介绍激活函数时提到的梯度消失 (Vanishing Gradient) 问题,如果给模型传了 10 次输入,那么输出就会叠加 10 次 tanh,这就导致前面的输入对最终输出的影响非常非常的小,例如 我对这部电视机非常满意,便宜又清晰,我之前买的 LG 电视机经常失灵送去维修好几次都没有解决实在是太垃圾了,判断这句话是正面评价还是负面评价需要看前半部分,但 RNN tanh 的最终输出受后半部分的影响更多,前半部分基本上会被忽略,所以无法作出正确的判断。把 tanh 函数换成 relu 函数一定程度上可以缓解这个问题,但输入的传递次数非常多的时候还是无法避免出现梯度消失。

为了解决这个问题发明出来的就是长短记忆模型,略称 LSTM (Long Short-Term Memory)。长短记忆模型的特征是除了隐藏状态 (Hidden State) 以外还有一个细胞状态 (Cell State),并且有输入门 (Input Gate),细胞门 (Cell Gate),忘记门 (Forget Gate) 负责更新细胞状态,和输出门 (Output Gate) 负责从细胞状态提取隐藏状态,具体的计算公式如下:

如果你觉得公式难以理解可以参考下面的计算图,图中展示了 LSTM 模型如何计算三次输入和返回三次输出 (注意最后加了线性模型用于转换隐藏值到预测输出):

(看不清请在新标签单独打开图片,或者另存为到本地以后查看)

LSTM 模型的细胞状态在叠加的过程中只会使用乘法和加法,所以可以避免梯度消失问题,并且输入门可以决定输入是否重要,如果不重要则减少对细胞状态的影响 (返回接近 0 的值),忘记门可以在遇到某些输入的时候忘记细胞状态中记录的部分值,输出门可以根据输入决定如何提取细胞状态到隐藏值 (输出),这些门让 LSTM 可以学习更长的数据,并且可以发挥更强的判断能力,但同时也让 LSTM 的计算量变大,需要使用更长的时间才能训练成功。

2.6.简化版长短记忆模型-GRU

GRU (Gated Recurrent Unit) 是简化版的长短记忆模型,去掉了细胞状态并且只使用三个门,新增门负责计算更新隐藏状态的值,重置门负责过滤部分更新隐藏状态的值 (针对根据之前隐藏值计算的部分),更新门负责计算更新比例,具体的计算公式如下:

如果你觉得公式难以理解可以参考下面的计算图,图中展示了 GRU 模型如何计算三次输入和返回三次输出 (注意最后加了线性模型用于转换隐藏值到预测输出):

(看不清请在新标签单独打开图片,或者另存为到本地以后查看)

可以看到 GRU 模型的隐藏状态在叠加的过程中也是只使用了乘法和加法,所以可以避免梯度消失问题。GRU 模型比 LSTM 模型稍弱,但是计算量也相应减少了,给我们多了一个不错的中间选择😼。

2.7.在Pytorch中使用LSTM和GRU

pytorch 帮我们封装了 LSTM 和 GRU 模型,基本上我们只需要替换代码中的 RNN 到 LSTM 或 GRU 即可,示例代码如下:

>>> rnn_model = torch.nn.RNN(input_size = 1, hidden_size = 8, num_layers = 1, batch_first = True)

>>> rnn_model = torch.nn.LSTM(input_size = 1, hidden_size = 8, num_layers = 1, batch_first = True)

>>> rnn_model = torch.nn.GRU(input_size = 1, hidden_size = 8, num_layers = 1, batch_first = True)

三.应用递归模型的例子

3.1.例子一:文本情感分类

文本感情分类是一个典型的例子,简单的来说就是给出一段话,判断这段话是正面还是负面的,例如淘宝或者京东上对商品的评价,豆瓣上对电影的评价,更高级的情感分类还能对文本中的感情进行细分。因为涉及到自然语言,文本感情分类也属于自然语言处理 (NLP, Nature Langure Processing),我们接下来将会使用 ami66 在 github 上公开的数据,来实现根据商品评论内容识别是正面评论还是负面评论。

在处理文本之前我们需要对文本进行切分,切分方法可以分为按字切分和按单词切分,按单词切分的精度更高但要求使用分词类库。处理中文时我们可以使用开源的 jieba 类库来按单词切分,执行 pip3 install jieba --user 即可安装,使用例子如下:

# 按字切分
>>> words = [c for c in "我来到北京清华大学"]
>>> words
['我', '来', '到', '北', '京', '清', '华', '大', '学']

# 按单词切分
>>> import jieba
>>> words = list(jieba.cut("我来到北京清华大学"))
>>> words
['我', '来到', '北京', '清华大学']

接下来我们需要使用数值来表示字或者单词,这也有几种方法,第一种方法是 onehot,即准备一个和字数量一样的序列,然后用每个元素代表每个字,这种方法并不实用,因为如果要处理的文本里面有几万种不同的字,那么就需要几万长度的序列,处理起来将会非常非常慢;第二种方法是 binary,即使用二进制来表示每个字的索引值,这种方法可以减少序列长度但是会影响训练效果;第三种方法是 embedding,使用向量 (浮点数组成的序列) 来表示每个字或者单词,这个向量可以预先根据某种规律生成,也可以在训练过程中调整,这种方法是目前最流行的方法。预先根据某种规律生成的 embedding 编码还会让语义近似的单词的值更接近,例如 苹果 和 橙子 的向量将会比较接近。接下来的例子将会使用在训练过程中调整的 embedding 编码,然后再介绍几种预先生成的 embedding 编码库。

处理文本并传给模型的流程如下,这里仅负责把单词转换为数值,embedding 处理在模型中 (后面介绍的预生成 embedding 编码会在传给模型前处理):

模型的结构如下,首先用 Embedding 负责转换各个单词的数值到向量,然后用 LSTM 处理各个单词对应的向量,之后用两层线性模型来识别 LSTM 返回的最终隐藏值,最后用 sigmoid 函数把值转换到 0 ~ 1 之间:

pytorch 中的 torch.nn.Embedding 会随机给每个数值分配一个向量,序列中的值会在训练过程中自动调整,最终这个向量会代表单词的某些属性,含义接近的单词向量的值也会接近。

最终的 sigmoid 不仅用于控制值范围,还可以让调整参数更容易,试想两个句子都是好评,如果没有 sigmoid,那么则需要调整线性模型的输出值接近 1,大于或小于都得调整,如果有 sigmoid,那么只需要调整线性模型的输出值大于 6 即可,例如第一个句子输出 8,第二个句子输出 16,两个经过 sigmoid 以后都是 1。

训练和使用模型的代码如下,与之前的代码相比需要注意以下几点:

  • 预测输出超过 0.5 的时候会判断是好评,未超过 0.5 的时候会判断是差评
  • 计算正确率的时候会使用预测输出和实际输出的匹配数量和总数量之间的比例
  • 需要保存单词到数值的索引,用于计算总单词数量和实际使用模型时转换单词到数值
  • 为了加快训练速度,参数调整器从 SGD 换成了 Adadelta
    • Adadelta 是 SGD 的一个扩展,支持自动调整学习比例 (SGD 只能固定学习比例),你可以参考这篇文章了解工作原理
import os
import sys
import torch
import gzip
import itertools
import jieba
import json
import random
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据评论分析是好评还是差评"""
    def __init__(self, total_words):
        super().__init__()
        self.embedding = nn.Embedding(
            num_embeddings=total_words,
            embedding_dim=16,
            padding_idx=0
        )
        self.rnn = nn.LSTM(
            input_size = 16,
            hidden_size = 32,
            num_layers = 1,
            batch_first = True
        )
        self.linear = nn.Sequential(
            nn.Linear(in_features=32, out_features=16),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(in_features=16, out_features=1),
            nn.Sigmoid())

    def forward(self, x, lengths):
        # 转换单词对应的数值到向量
        embedded = self.embedding(x)
        # 附加长度信息,避免 RNN 计算填充的数据
        packed = nn.utils.rnn.pack_padded_sequence(
            embedded, lengths, batch_first=True, enforce_sorted=False)
        # 使用递归模型计算,因为当前场景只需要最后一个输出,所以直接使用 hidden
        # 注意 LSTM 的第二个返回值同时包含最新的隐藏状态和细胞状态
        output, (hidden, cell) = self.rnn(packed)
        # 转换隐藏状态的维度到 批次大小, 隐藏值数量
        hidden = hidden.reshape(hidden.shape[1], hidden.shape[2])
        # 使用多层线性模型识别递归模型返回的隐藏值
        # 最后使用 sigmoid 把值范围控制在 0 ~ 1
        y = self.linear(hidden)
        return y

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def save_word_to_index(word_to_index):
    """保存单词到数值的索引"""
    json.dump(word_to_index, open('data/word_to_index.json', 'w'), indent=2, ensure_ascii=False)

def load_word_to_index():
    """读取单词到数值的索引"""
    return json.load(open('data/word_to_index.json', 'r'))

def prepare_save_batch(batch, pending_tensors):
    """准备训练 - 保存单个批次的数据"""
    # 打乱单个批次的数据
    random.shuffle(pending_tensors)

    # 划分输入和输出 tensor,另外保存各个输入 tensor 的长度
    in_tensor_unpadded = [p[0] for p in pending_tensors]
    in_tensor_lengths = torch.tensor([t.shape[0] for t in in_tensor_unpadded])
    out_tensor = torch.tensor([p[1] for p in pending_tensors])

    # 整合长度不等的 in_tensor_unpadded 到单个 tensor,不足的长度会填充 0
    in_tensor = nn.utils.rnn.pad_sequence(in_tensor_unpadded, batch_first=True)

    # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
    random_indices = torch.randperm(in_tensor.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.6)]
    validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
    testing_indices = random_indices[int(len(random_indices)*0.8):]
    training_set = (in_tensor[training_indices], in_tensor_lengths[training_indices], out_tensor[training_indices])
    validating_set = (in_tensor[validating_indices], in_tensor_lengths[validating_indices], out_tensor[validating_indices])
    testing_set = (in_tensor[testing_indices], in_tensor_lengths[testing_indices], out_tensor[testing_indices])

    # 保存到硬盘
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 准备词语到数值的索引
    # 预留 PAD 为填充,EOF 为语句结束
    word_to_index = { '<PAD>': 0, '<EOF>': 1 }

    # 从 txt 读取原始数据集,分批每次处理 2000 行
    # 这里使用原始方法读取,最后一个标注为 1 代表好评,为 0 代表差评
    batch = 0
    pending_tensors = []
    for line in open('goods_zh.txt', 'r'):
        parts = line.split(',')
        phase = ",".join(parts[:-2])
        positive = int(parts[-1])
        # 使用 jieba 分词,然后转换单词到索引
        words = jieba.cut(phase)
        word_indices = []
        for word in words:
            if word.isascii() or word in (',', '。', '!'):
                continue # 过滤标点符号
            if word in word_to_index:
                word_indices.append(word_to_index[word])
            else:
                new_index = len(word_to_index)
                word_to_index[word] = new_index
                word_indices.append(new_index)
        word_indices.append(1) # 代表语句结束
        # 输入是各个单词对应的索引,输出是是否正面评价
        pending_tensors.append((torch.tensor(word_indices), torch.tensor([positive])))
        if len(pending_tensors) >= 2000:
            prepare_save_batch(batch, pending_tensors)
            batch += 1
            pending_tensors.clear()
    if pending_tensors:
        prepare_save_batch(batch, pending_tensors)
        batch += 1
        pending_tensors.clear()

    # 保存词语到单词的索引
    save_word_to_index(word_to_index)

def train():
    """开始训练"""
    # 创建模型实例
    total_words = len(load_word_to_index())
    model = MyModel(total_words)

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.Adadelta(model.parameters())

    # 记录训练集和验证集的正确率变化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return ((actual >= 0.5) == (predicted >= 0.5)).sum().item() / actual.shape[0]

    # 划分输入和输出的工具函数
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, input_size
        batch_x = batch[0][begin:end]
        # shape = batch_size, 1
        batch_x_lengths = batch[1][begin:end]
        # shape = batch_size, 1
        batch_y = batch[2][begin:end].reshape(-1, 1).float()
        return batch_x, batch_x_lengths, batch_y

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        training_accuracy_list = []
        for batch in read_batches("data/training_set"):
            # 切分小批次,有助于泛化模型
            for index in range(0, batch[0].shape[0], 100):
                # 划分输入和输出
                batch_x, batch_x_lengths, batch_y = split_batch_xy(batch, index, index+100)
                # 计算预测值
                predicted = model(batch_x, batch_x_lengths)
                # 计算损失
                loss = loss_function(predicted, batch_y)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    training_accuracy_list.append(calc_accuracy(batch_y, predicted))
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
            predicted = model(batch_x, batch_x_lengths)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 20 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
        predicted = model(batch_x, batch_x_lengths)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    # 读取词语到单词的索引
    word_to_index = load_word_to_index()

    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel(len(word_to_index))
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 询问输入并预测输出
    while True:
        try:
            phase = input("Review: ")
            # 分词
            words = list(jieba.cut(phase))
            # 转换到数值列表
            word_indices = [word_to_index[w] for w in words if w in word_to_index]
            word_indices.append(word_to_index['EOF'])
            # 构建输入
            x = torch.tensor(word_indices).reshape(1, -1)
            lengths = torch.tensor([len(word_indices)])
            # 预测输出
            y = model(x, lengths)
            print("Positive Score:", y[0, 0].item(), "\n")
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

 执行以下命令即可准备数据集和训练模型:

python3 example.py prepare
python3 example.py train

训练成功以后的输出如下,我们可以看到验证集和测试集正确率都达到了 93%:

epoch: 70
training accuracy: 0.9745314468456309
validating accuracy: 0.9339881613022567
stop training because highest validating accuracy not updated in 20 epoches
highest validating accuracy: 0.9348816130225674 from epoch 49
testing accuracy: 0.933661672216056

正确率的变化如下:

执行以下命令即可使用训练好的模型:

python3 example.py eval 

使用例子如下:

Review: 这手机吃后台特别严重,不建议购买
Positive Score: 0.010371988639235497

Review: 这样很好,穿着特别舒适,很喜欢的一双鞋子,夏天也比较透气
Positive Score: 1.0

Review: 性价比还是不错的,使用到现在还没有出现问题
Positive Score: 1.0

Review: 服务态度差,物流慢
Positive Score: 0.009614041075110435

Review: 这手机有问题,反应到客服没人理
Positive Score: 0.00456244358792901

Review: 强烈建议购买
Positive Score: 0.9984269142150879

Review: 强烈不建议购买
Positive Score: 0.03579584136605263

注意如果使用的单词不在索引中那么这个单词会被忽略,要解决这个问题可以增加数据量涵盖尽量多的单词,或者使用接下来介绍的预生成 embedding 编码库。

现在我们有一个程序可以智能判断对方说的是好话还是坏话了😤,因为现实中的商城或者电影评价网站一般都会同时要求用户打分所以这个例子的实用价值不大,但它仍然是一个很好的例子帮助我们理解怎样使用递归模型处理自然语言。

3.2.使用预生成embedding编码库

以上的例子会在训练过程中调整 embedding 编码,这种做法很方便,但只能识别在索引中的单词 (数据集中包含的单词),如果使用了未知的单词那么模型有可能无法正确预测结果。我们可以使用预生成的 embedding 编码库来解决这个问题,这些库是根据海量数据生成的(通常使用百科问答或者新闻等公开数据),包含了非常非常多的单词,并且语义接近的单词的向量也会很接近,训练的时候只要使用部分单词就可以同时适用于语义接近的更多单词。

注意使用这些库不需要在训练过程中调整向量,torch.nn.Embedding.from_pretrained 支持导入预先训练好的编码库,并且不会在训练过程中调整它们。

 3.2.1.word2vec

使用 word2vec 首先我们需要安装 gensim 库,使用以下命令即可安装:

pip3 install gensim --user

接下来我们需要一个预先生成好的编码库,你可以在 github 上搜索 word2vec chinese 或者 word2vec 中文,也可以用自己的语料库生成。这里我简单介绍怎样使用自己的语料库生成,来源是上面的评论数据,你也可以试着从这里下载更大的文本数据。

第一步是使用 jieba 分词,然后全部写到一个文件,单词之间用空格隔开:

import jieba
f = open('chinese.text8', 'w')
for line in open('goods_zh.txt', 'r'):
    line = "".join(line.split(',')[:-2])
    words = jieba.cut(line)
    words = [w for w in words if not (w.isascii() or w in (",", "。", "!"))]
    f.write(" ".join(words))
    f.write(" ")

 第二步是使用 word2vec 生成并保存编码库:

from gensim.models import word2vec
sentences = word2vec.Text8Corpus('chinese.text8')
model = word2vec.Word2Vec(sentences, size=200)
model.save("chinese.model")

试着使用生成好的编码库:

# 寻找语义接近的单词,挺准确的吧🙀
>>> from gensim.models import word2vec
>>> w = word2vec.Word2Vec.load("chinese.model")
>>> w.wv.most_similar(["手机"])
[('机子', 0.6180450916290283), ('新手机', 0.5946457386016846), ('新机', 0.4700007736682892), ('机器', 0.4531888961791992), ('荣耀', 0.4304167628288269), ('红米', 0.42995956540107727), ('电脑', 0.4163869023323059), ('笔记本', 0.4093247652053833), ('坚果', 0.4016817808151245), ('产品', 0.3963530957698822)]
>>> w.wv.most_similar(["物流"])
[('送货', 0.8435776233673096), ('快递', 0.7946128249168396), ('发货', 0.7307696342468262), ('递给', 0.7279399037361145), ('配送', 0.6557953357696533), ('处理速度', 0.6505168676376343), ('用电', 0.6292495131492615), ('速递', 0.6150853633880615), ('货发', 0.6149879693984985), ('反应速度', 0.5916593074798584)]

# 定位单词对应的数值
>>> w.wv.vocab.get("手机").index
5

# 定位单词对应的数值对应的向量
>>> w.wv.vectors[5]
array([-1.4774184e-01,  5.9569430e-01,  9.1274220e-01,  8.2012570e-01,
       省略途中输出
       -7.7284634e-01, -8.3093870e-01,  9.6443129e-01, -1.6938221e+00],
      dtype=float32)

在前面的例子中使用这个编码库的代码如下,改动了 prepare 和模型部分,虽然模型使用了 torch.nn.Embedding 但预生成的编码库不会随着训练而变化,此外这份代码不会在语句结尾添加 EOF 对应的向量 (在这个例子中不影响效果):

import os
import sys
import torch
import gzip
import itertools
import jieba
import json
import random
from gensim.models import word2vec
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据评论分析是好评还是差评"""
    def __init__(self, w2v):
        super().__init__()
        self.embedding = nn.Embedding.from_pretrained(
            torch.FloatTensor(w2v.wv.vectors))
        self.rnn = nn.LSTM(
            input_size = 200,
            hidden_size = 32,
            num_layers = 1,
            batch_first = True
        )
        self.linear = nn.Sequential(
            nn.Linear(in_features=32, out_features=16),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(in_features=16, out_features=1),
            nn.Sigmoid())

    def forward(self, x, lengths):
        # 转换单词对应的数值到向量
        embedded = self.embedding(x)
        # 附加长度信息,避免 RNN 计算填充的数据
        packed = nn.utils.rnn.pack_padded_sequence(
            embedded, lengths, batch_first=True, enforce_sorted=False)
        # 使用递归模型计算,因为当前场景只需要最后一个输出,所以直接使用 hidden
        # 注意 LSTM 的第二个返回值同时包含最新的隐藏状态和细胞状态
        output, (hidden, cell) = self.rnn(packed)
        # 转换隐藏状态的维度到 批次大小, 隐藏值数量
        hidden = hidden.reshape(hidden.shape[1], hidden.shape[2])
        # 使用多层线性模型识别递归模型返回的隐藏值
        # 最后使用 sigmoid 把值范围控制在 0 ~ 1
        y = self.linear(hidden)
        return y

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def load_word2vec_model():
    """读取 word2vec 编码库"""
    return word2vec.Word2Vec.load("chinese.model")

def prepare_save_batch(batch, pending_tensors):
    """准备训练 - 保存单个批次的数据"""
    # 打乱单个批次的数据
    random.shuffle(pending_tensors)

    # 划分输入和输出 tensor,另外保存各个输入 tensor 的长度
    in_tensor_unpadded = [p[0] for p in pending_tensors]
    in_tensor_lengths = torch.tensor([t.shape[0] for t in in_tensor_unpadded])
    out_tensor = torch.tensor([p[1] for p in pending_tensors])

    # 整合长度不等的 in_tensor_unpadded 到单个 tensor,不足的长度会填充 0
    in_tensor = nn.utils.rnn.pad_sequence(in_tensor_unpadded, batch_first=True)

    # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
    random_indices = torch.randperm(in_tensor.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.6)]
    validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
    testing_indices = random_indices[int(len(random_indices)*0.8):]
    training_set = (in_tensor[training_indices], in_tensor_lengths[training_indices], out_tensor[training_indices])
    validating_set = (in_tensor[validating_indices], in_tensor_lengths[validating_indices], out_tensor[validating_indices])
    testing_set = (in_tensor[testing_indices], in_tensor_lengths[testing_indices], out_tensor[testing_indices])

    # 保存到硬盘
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 准备词语到数值的索引
    w2v = load_word2vec_model()

    # 从 txt 读取原始数据集,分批每次处理 2000 行
    # 这里使用原始方法读取,最后一个标注为 1 代表好评,为 0 代表差评
    batch = 0
    pending_tensors = []
    for line in open('goods_zh.txt', 'r'):
        parts = line.split(',')
        phase = ",".join(parts[:-2])
        positive = int(parts[-1])
        # 使用 jieba 分词,然后转换单词到索引
        words = jieba.cut(phase)
        word_indices = []
        for word in words:
            if word.isascii() or word in (',', '。', '!'):
                continue # 过滤标点符号
            vocab = w2v.wv.vocab.get(word)
            if vocab:
                word_indices.append(vocab.index)
        if not word_indices:
            continue # 没有单词在编码库中
        # 输入是各个单词对应的索引,输出是是否正面评价
        pending_tensors.append((torch.tensor(word_indices), torch.tensor([positive])))
        if len(pending_tensors) >= 2000:
            prepare_save_batch(batch, pending_tensors)
            batch += 1
            pending_tensors.clear()
    if pending_tensors:
        prepare_save_batch(batch, pending_tensors)
        batch += 1
        pending_tensors.clear()

def train():
    """开始训练"""
    # 创建模型实例
    w2v = load_word2vec_model()
    model = MyModel(w2v)

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.Adadelta(model.parameters())

    # 记录训练集和验证集的正确率变化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return ((actual >= 0.5) == (predicted >= 0.5)).sum().item() / actual.shape[0]

    # 划分输入和输出的工具函数
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, input_size
        batch_x = batch[0][begin:end]
        # shape = batch_size, 1
        batch_x_lengths = batch[1][begin:end]
        # shape = batch_size, 1
        batch_y = batch[2][begin:end].reshape(-1, 1).float()
        return batch_x, batch_x_lengths, batch_y

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        training_accuracy_list = []
        for batch in read_batches("data/training_set"):
            # 切分小批次,有助于泛化模型
            for index in range(0, batch[0].shape[0], 100):
                # 划分输入和输出
                batch_x, batch_x_lengths, batch_y = split_batch_xy(batch, index, index+100)
                # 计算预测值
                predicted = model(batch_x, batch_x_lengths)
                # 计算损失
                loss = loss_function(predicted, batch_y)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    training_accuracy_list.append(calc_accuracy(batch_y, predicted))
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
            predicted = model(batch_x, batch_x_lengths)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 20 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
        predicted = model(batch_x, batch_x_lengths)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    # 读取词语到单词的索引
    w2v = load_word2vec_model()

    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel(w2v)
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 询问输入并预测输出
    while True:
        try:
            phase = input("Review: ")
            # 分词
            words = list(jieba.cut(phase))
            # 转换到数值列表
            word_indices = []
            for word in words:
                if word.isascii() or word in (',', '。', '!'):
                    continue # 过滤标点符号
                vocab = w2v.wv.vocab.get(word)
                if vocab:
                    word_indices.append(vocab.index)
            if not word_indices:
                raise ValueError("No known words")
            # 构建输入
            x = torch.tensor(word_indices).reshape(1, -1)
            lengths = torch.tensor([len(word_indices)])
            # 预测输出
            y = model(x, lengths)
            print("Positive Score:", y[0, 0].item(), "\n")
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

如果你试着用这份代码来训练会发现第一个 epoch 就已经达到 90% 以上的正确率,并且继续训练下去可以达到比直接使用 torch.nn.Embedding 更高的正确率,使用预生成编码库的效果惊人呀🥳。

如果你对 word2vec 的原理感兴趣可以参考这篇文章,同样在博客园上。

3.2.2.transfomers(BERT)
 

transfomers 是一个用于处理自然语言的类库,包含了目前世界上最先进的模型,我们将会看到如何使用其中的 BERT 模型来处理中文。

使用以下命令安装:

pip3 install transformers

 transfomers 支持自动下载和使用预先训练好的模型,以下是使用 BERT 中文模型的代码 (第一次使用時会自动下载),有分词器和模型两部分:

>>> from transformers import AutoTokenizer, AutoModel
>>> tt = AutoTokenizer.from_pretrained("bert-base-chinese")
>>> tm = AutoModel.from_pretrained("bert-base-chinese")

# 转换中文语句到数值列表
>>> tt.encode("五星好评赞")
[101, 758, 3215, 1962, 6397, 6614, 102]

# 生成各个单词对应的向量
>>> codes, hidden = tm(torch.tensor([[101, 758, 3215, 1962, 6397, 6614, 102]]))
>>> codes.shape
torch.Size([1, 7, 768])
>>> hidden.shape
torch.Size([1, 768])

如果你细心观察可能会发现上面并没有实际分词,而是根据每个字单独生成了索引,这是因为 bert-base-chinese 是按字来划分的,你可以试试其他模型 (我不确定是否有这样的现成模型😫)。另外转换为向量时,第二个返回值代表了最终的内部状态,这点跟递归模型比较像,第二个返回值还可以用来代表整个句子的编码,尽管精度会有所降低。

在前面的例子中使用 transfomers 的代码如下,注意准备数据集和训练都需要相当长的时间,这可以说是用牛刀杀鸡👿:

import os
import sys
import torch
import gzip
import itertools
import json
import random
from transformers import AutoTokenizer, AutoModel
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据评论分析是好评还是差评"""
    def __init__(self):
        super().__init__()
        self.rnn = nn.LSTM(
            input_size = 768,
            hidden_size = 32,
            num_layers = 1,
            batch_first = True
        )
        self.linear = nn.Sequential(
            nn.Linear(in_features=32, out_features=16),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(in_features=16, out_features=1),
            nn.Sigmoid())

    def forward(self, x, lengths):
        # transformers 已经帮我们转换为向量
        embedded = x
        # 附加长度信息,避免 RNN 计算填充的数据
        packed = nn.utils.rnn.pack_padded_sequence(
            embedded, lengths, batch_first=True, enforce_sorted=False)
        # 使用递归模型计算,因为当前场景只需要最后一个输出,所以直接使用 hidden
        # 注意 LSTM 的第二个返回值同时包含最新的隐藏状态和细胞状态
        output, (hidden, cell) = self.rnn(packed)
        # 转换隐藏状态的维度到 批次大小, 隐藏值数量
        hidden = hidden.reshape(hidden.shape[1], hidden.shape[2])
        # 使用多层线性模型识别递归模型返回的隐藏值
        # 最后使用 sigmoid 把值范围控制在 0 ~ 1
        y = self.linear(hidden)
        return y

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def load_transfomer_tokenizer():
    """获取 transformers 的分词器"""
    return AutoTokenizer.from_pretrained("bert-base-chinese")

def load_transfomer_model():
    """获取 transofrmers 的模型"""
    return AutoModel.from_pretrained("bert-base-chinese")

def prepare_save_batch(batch, pending_tensors):
    """准备训练 - 保存单个批次的数据"""
    # 打乱单个批次的数据
    random.shuffle(pending_tensors)

    # 划分输入和输出 tensor,另外保存各个输入 tensor 的长度
    in_tensor_unpadded = [p[0] for p in pending_tensors]
    in_tensor_lengths = torch.tensor([t.shape[0] for t in in_tensor_unpadded])
    out_tensor = torch.tensor([p[1] for p in pending_tensors])

    # 整合长度不等的 in_tensor_unpadded 到单个 tensor,不足的长度会填充 0
    in_tensor = nn.utils.rnn.pad_sequence(in_tensor_unpadded, batch_first=True)

    # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
    random_indices = torch.randperm(in_tensor.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.6)]
    validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
    testing_indices = random_indices[int(len(random_indices)*0.8):]
    training_set = (in_tensor[training_indices], in_tensor_lengths[training_indices], out_tensor[training_indices])
    validating_set = (in_tensor[validating_indices], in_tensor_lengths[validating_indices], out_tensor[validating_indices])
    testing_set = (in_tensor[testing_indices], in_tensor_lengths[testing_indices], out_tensor[testing_indices])

    # 保存到硬盘
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 加载 transformer 分词器和模型
    tt = load_transfomer_tokenizer()
    tm = load_transfomer_model()

    # 从 txt 读取原始数据集,分批每次处理 2000 行
    # 这里使用原始方法读取,最后一个标注为 1 代表好评,为 0 代表差评
    batch = 0
    pending_tensors = []
    for line in open('goods_zh.txt', 'r'):
        parts = line.split(',')
        phase = ",".join(parts[:-2])
        positive = int(parts[-1])

        # 使用 transformer 分词,然后转换各个数值到向量
        word_indices = tt.encode(phase)
        word_indices = word_indices[:510] # bert-base-chinese 不支持过长的序列
        words_tensor, hidden = tm(torch.tensor([word_indices]))
        words_tensor = words_tensor.reshape(words_tensor.shape[1], words_tensor.shape[2])
        # 输入是各个单词对应的向量,输出是是否正面评价
        pending_tensors.append((words_tensor, torch.tensor([positive])))
        if len(pending_tensors) >= 500:
            prepare_save_batch(batch, pending_tensors)
            batch += 1
            pending_tensors.clear()
    if pending_tensors:
        prepare_save_batch(batch, pending_tensors)
        batch += 1
        pending_tensors.clear()

def train():
    """开始训练"""
    # 创建模型实例
    model = MyModel()

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.Adadelta(model.parameters())

    # 记录训练集和验证集的正确率变化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return ((actual >= 0.5) == (predicted >= 0.5)).sum().item() / actual.shape[0]

    # 划分输入和输出的工具函数
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, input_size
        batch_x = batch[0][begin:end]
        # shape = batch_size, 1
        batch_x_lengths = batch[1][begin:end]
        # shape = batch_size, 1
        batch_y = batch[2][begin:end].reshape(-1, 1).float()
        return batch_x, batch_x_lengths, batch_y

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        training_accuracy_list = []
        for batch in read_batches("data/training_set"):
            # 切分小批次,有助于泛化模型
            for index in range(0, batch[0].shape[0], 100):
                # 划分输入和输出
                batch_x, batch_x_lengths, batch_y = split_batch_xy(batch, index, index+100)
                # 计算预测值
                predicted = model(batch_x, batch_x_lengths)
                # 计算损失
                loss = loss_function(predicted, batch_y)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    training_accuracy_list.append(calc_accuracy(batch_y, predicted))
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
            predicted = model(batch_x, batch_x_lengths)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 20 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
        predicted = model(batch_x, batch_x_lengths)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    # 加载 transformer 分词器和模型
    tt = load_transfomer_tokenizer()
    tm = load_transfomer_model()

    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel()
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 询问输入并预测输出
    while True:
        try:
            phase = input("Review: ")
            # 使用 transformer 分词,然后转换各个数值到向量
            word_indices = tt.encode(phase)
            word_indices = word_indices[:510] # bert-base-chinese 不支持过长的序列
            words_tensor, hidden = tm(torch.tensor([word_indices]))
            # 构建输入
            x = words_tensor
            lengths = torch.tensor([len(word_indices)])
            # 预测输出
            y = model(x, lengths)
            print("Positive Score:", y[0, 0].item(), "\n")
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

如果你实际训练使用会发现它不仅能判断商品评论是正面还是负面的,也能判断普通语句是好话还是坏话,可以说相当的神奇。

transfomers 还可以用来做翻译和文本自动生成,因为里面的模型太高级了所以我目前没有理解它们是怎么工作的🙉,希望以后有机会可以详细介绍。

3.3.例子二:预测股价走势

如果你是一个股民,你可能会试图找出那些涨涨跌跌之间的规律,包括使用 MACD, KDJ 等指标,这里我们试试应用机器学习预测股价走势,看看结果如何。

训练和验证使用的数据是中国银行 (601988) 的每日收盘价和交易量,可以从以下地址下载:

csv 中包含了 日期,开盘价,最高价,最低价,收盘价,调整后收盘价,交易量,输入和输出规定如下

  • 输入: 收盘价 (标准化除以 100), 交易量 (标准化除以 1 亿)
  • 输出: T+2 的涨跌 (涨 1 跌 0, T+2 指下下个交易日)

模型是 GRU + 2 层线性模型,最终使用 sigmoid 转换输出到 0 ~ 1 之间的值,数据划分训练集包含 1500 条数据,验证集和测试集包含 100 条数据,时序按 训练集 => 验证集 => 测试集 排列。

注意传递数据给模型的时候会按 32 条数据分批传递,模型需要保留隐藏状态使得分批传递与完整传递可以得出相同的结果。

训练和使用模型的代码如下:

import os
import sys
import torch
import gzip
import itertools
import random
import pandas
import math
from torch import nn
from matplotlib import pyplot

CSV_FILENAME = "601988.SS.csv"
TRAINING_RECORDS = 1500
VALIDATING_RECORDS = 100
TESTING_RECORDS = 100

class MyModel(nn.Module):
    """根据历史收盘价和成交量预测股价走势"""
    def __init__(self):
        super().__init__()
        self.rnn = nn.GRU(
            input_size = 2,
            hidden_size = 50,
            num_layers = 1,
            batch_first = True
        )
        self.linear = nn.Sequential(
            nn.Linear(in_features=50, out_features=20),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(in_features=20, out_features=1),
            nn.Sigmoid())
        self.reset_hidden()

    def forward(self, x):
        # 调整维度
        x = x.reshape(1, x.shape[0], x.shape[1])
        # 使用递归模型计算,需要所有输出,并且还需要保存隐藏状态
        # 保存隐藏状态时需要使用 detach 切断内部的计算路径
        output, hidden = self.rnn(x, self.rnn_hidden)
        self.rnn_hidden = hidden.detach()
        # 转换输出的维度到 批次大小, 隐藏值数量
        output = output.reshape(output.shape[1], output.shape[2])
        # 使用多层线性模型计算递归模型返回的输出
        y = self.linear(output)
        return y

    def reset_hidden(self):
        """重置隐藏状态"""
        self.rnn_hidden = torch.zeros(1, 1, 50)

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 从 csv 读取原始数据集
    df = pandas.read_csv(CSV_FILENAME)
    in_list = [] # 收盘价和成交量作为输入
    out_list = [] # T+2 的涨跌作为输出
    for value in df.values:
        volume = value[-1] / 100000000 # 成交量除以一亿
        price = value[-3] / 100 # 收盘价除以 100
        if math.isnan(volume) or math.isnan(price):
            continue # 原始数据中是 null
        in_list.append((price, volume))
    for index in range(len(in_list)-2):
        price_t0 = in_list[index][0]
        price_t2 = in_list[index+2][0]
        out_list.append(1. if price_t2 > price_t0 else 0.)
    in_list = in_list[:len(out_list)]

    # 生成输入和输出
    in_tensor = torch.tensor(in_list)
    out_tensor = torch.tensor(out_list).reshape(-1, 1)

    # 划分训练集,验证集和测试集
    testing_start = -TESTING_RECORDS
    validating_start = testing_start - VALIDATING_RECORDS
    training_start = validating_start - TRAINING_RECORDS
    training_in = in_tensor[training_start:validating_start]
    training_out = out_tensor[training_start:validating_start]
    validating_in = in_tensor[validating_start:testing_start]
    validating_out = out_tensor[validating_start:testing_start]
    testing_in = in_tensor[testing_start:]
    testing_out = out_tensor[testing_start:]

    # 保存到硬盘
    save_tensor((training_in, training_out), f"data/training_set.pt")
    save_tensor((validating_in, validating_out), f"data/validating_set.pt")
    save_tensor((testing_in, testing_out), f"data/testing_set.pt")
    print("saved dataset")

def train():
    """开始训练"""
    # 创建模型实例
    model = MyModel()

    # 创建损失计算器
    loss_function = torch.nn.MSELoss()

    # 创建参数调整器
    optimizer = torch.optim.Adadelta(model.parameters())

    # 记录训练集和验证集的正确率变化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = 0
    validating_accuracy_highest_epoch = 0

    # 计算正确率的工具函数
    def calc_accuracy(actual, predicted):
        return ((actual >= 0.5) == (predicted >= 0.5)).sum().item() / actual.shape[0]
 
    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 重置模型的隐藏状态
        model.reset_hidden()

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        training_accuracy_list = []
        training_in, training_out = load_tensor("data/training_set.pt")
        for index in range(0, training_in.shape[0], 32):
            # 划分输入和输出
            batch_x = training_in[index:index+32]
            batch_y = training_out[index:index+32]
            # 计算预测值
            predicted = model(batch_x)
            # 计算损失
            loss = loss_function(predicted, batch_y)
            # 从损失自动微分求导函数值
            loss.backward()
            # 使用参数调整器调整参数
            optimizer.step()
            # 清空导函数值
            optimizer.zero_grad()
            # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
            with torch.no_grad():
                training_accuracy_list.append(calc_accuracy(batch_y, predicted))
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_in, validating_out = load_tensor("data/validating_set.pt")
        predicted = model(validating_in)
        validating_accuracy = calc_accuracy(validating_out, predicted)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 200 次训练后仍然没有刷新记录
        # 因为数据量很少,仅在训练集正确率超过 70% 时执行这里的逻辑
        if training_accuracy > 0.7:
            if validating_accuracy > validating_accuracy_highest:
                validating_accuracy_highest = validating_accuracy
                validating_accuracy_highest_epoch = epoch
                save_tensor(model.state_dict(), "model.pt")
                print("highest validating accuracy updated")
            elif epoch - validating_accuracy_highest_epoch > 200:
                # 在 200 次训练后仍然没有刷新记录,结束训练
                print("stop training because highest validating accuracy not updated in 200 epoches")
                break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_in, testing_out = load_tensor("data/testing_set.pt")
    predicted = model(testing_in)
    testing_accuracy = calc_accuracy(testing_out, predicted)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集的误差变化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validating")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel()
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 加载历史数据
    training_in, _ = load_tensor("data/training_set.pt")
    model(training_in)

    # 预测未来数据
    price_list = []
    trend_list = []
    df = pandas.read_csv(CSV_FILENAME)
    for value in df.values[-TESTING_RECORDS-VALIDATING_RECORDS:]:
        volume = float(value[-1])
        price = float(value[-3])
        if math.isnan(volume) or math.isnan(price):
            continue # 原始数据中是 null
        in_tensor = torch.tensor([[price / 100, volume / 100000000]])
        trend = model(in_tensor)[0].item()
        price_list.append(price)
        trend_list.append(trend)

    # 根据预测数据模拟买卖 100 万
    # 规则为预测 T+2 涨则买入,预测 T+2 跌则卖出,不计算印花税和分红
    money = 1000000
    stock = 0
    matched = 0
    total = 0
    buy_list = []
    sell_list = []
    for index in range(len(price_list)):
        price = price_list[index]
        trend = trend_list[index]
        will_rise = trend > 0.5
        will_drop = trend < 0.5
        if stock == 0 and will_rise:
            unit = int(money / price / 100) # 1 手 100 股
            money -= price * unit * 100
            stock += unit
            buy_list.append(price)
            sell_list.append(0)
            print(f"buy {unit}, money {money}, stock {stock}")
        elif stock != 0 and will_drop:
            unit = stock
            money += price * unit * 100
            stock -= unit
            buy_list.append(0)
            sell_list.append(price)
            print(f"sell {unit}, money {money}, stock {stock}")
        else:
            buy_list.append(0)
            sell_list.append(0)
    money_final = money + price_list[-1] * stock * 100
    print(f"final money {money_final}")
    print(f"stock price goes from {price_list[0]} to {price_list[-1]} in this range")

    # 显示为图表
    pyplot.plot(price_list, label="price")
    pyplot.plot(buy_list, label="buy", marker="$b$", linestyle = "None")
    pyplot.plot(sell_list, label="sell", marker="$s$", linestyle = "None")
    pyplot.ylim(min(price_list) - 0.05, max(price_list) + 0.05)
    pyplot.legend()
    pyplot.show()

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

训练结束以后的输出如下,这不是一个理想的结果🙄:

epoch: 1004
training accuracy: 0.8902925531914894
validating accuracy: 0.53
stop training because highest validating accuracy not updated in 200 epoches
highest validating accuracy: 0.67 from epoch 803
testing accuracy: 0.5

训练集和验证集的正确率变化如下:

验证模型的部分会基于没有训练过的未知数据 (合计 200 条) 模拟交易,首先准备 100 万,预测 T+2 涨就买,预测 T+2 跌就卖,一天只操作一次,每次买卖都是最大数量,不考虑印花税和分红,模拟结果如下:

final money 1089605.9999999998
stock price goes from 3.67 to 3.45 in this range 

模拟交易的图表表现如下:

只看模拟结果可能会觉得模型很厉害,但实际上这只是个偶然,这次训练不能算是成功,因为正确率不高,和瞎猜差不多🤢。训练没有成功的原因有下:

  • 股价的不确定因素太多了,只靠每天的收盘价和交易量是没有办法正确预测出趋势的
  • 一般来说股价趋势短期预测比长期预测的准确率要高很多,因为短期预测的不确定因素比较少,但我没有找到公开的高频股价数据
  • 单只股票的数据量很少,而且每只股票的股性都不一样 (依赖于操盘手),很难训练出通用的模型

除了上面的模型以外我还试了很多方式,例如把涨跌幅作为输入或者输出与加大减少模型的结构,但都没有找到可以确切预测出走势的模型。

你可能会忍不住去试试更多方式,甚至找到效果比较好的模型,但我作为一个老股民劝你一句,股海无边,回头是岸呀🤕。

四.双向递归模型(BRNN)-根据上下文补全单词

4.1.双向递归模型

到这里为止我们看到的例子都是按原有顺序把输入传给递归模型的,例如传递第一天股价会返回根据第一天股价预测的涨跌,再传递第二天股价会返回根据第一天股价和第二天股价预测的涨跌,以此类推,这样的模型也称单向递归模型。如果我们要根据句子的一部分预测下一个单词,可以像下图这样做,这时 天气 会根据 今天 计算, 很好 会根据 今天  天气 计算:

那么如果想要预测在句子中间的单词呢?例如给出 今天 和 很好 预测 天气,因为只能根据前面的单词预测,单向递归模型的效果会打折,这时候双向递归模型就派上用场了。双向递归模型 (BRNN, Bidirectional Recurrent Neural Network) 会先按原有顺序把输入传给递归模型,然后再按反向顺序把输入传给递归模型,然后合并正向输出和反向输出。如下图所示,hf 代表正向输出,hb 代表反向输出,把它们合并到一块就可以实现根据上下文预测中间的内容,今天 会根据反向的 天气 和 很好 计算,天气 会根据正向的 今天 和反向的 很好 计算,很好 会根据正向的 今天 和 天气 计算。

在 pytorch 中使用双向递归模型非常简单,只要在创建的时候传入参数 bidirectional = True 即可:

self.rnn = nn.GRU(
    input_size = 20,
    hidden_size = 50,
    num_layers = 1,
    batch_first = True,
    bidirectional = True
)

 单向递归模型会返回维度为 批次大小,输入次数,隐藏值数量 的 tensor,而双向递归模型会返回维度为 批次大小,输入次数,隐藏值数量*2 的 tensor。

你可能还会有疑问,双向递归模型会怎样处理批次呢?如果批次中每组数据的输入次数都不一样,那么反向计算的时候会不会从那些填充的 0 开始计算呢?以下是一个小实验,我们可以看到反向计算的时候 pytorch 会跳过结尾的填充值,不需要做特殊的处理🥳。

>>> import torch
>>> from torch import nn
>>> x = torch.zeros((3, 3, 1))
>>> lengths = torch.tensor([1, 2, 3])
>>> rnn = torch.nn.GRU(input_size=1, hidden_size=1, batch_first=True, bidirectional=True)
>>> packed = nn.utils.rnn.pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False)
>>> output, hidden = rnn(packed)
>>> unpacked, _ = torch.nn.utils.rnn.pad_packed_sequence(output, batch_first=True)
>>> unpacked
tensor([[[0.2916, 0.2377],
         [0.0000, 0.0000],
         [0.0000, 0.0000]],

        [[0.2916, 0.2239],
         [0.3949, 0.2377],
         [0.0000, 0.0000]],

        [[0.2916, 0.2243],
         [0.3949, 0.2239],
         [0.4263, 0.2377]]], grad_fn=<IndexSelectBackward>)

此外,如果你想使用双向递归模型来实现分类(例如文本情感分类),那么可以只抽出 (torch.gather) 每组数据的最后一个正向隐藏值和第一个反向隐藏值,然后把它们组合 (torch.cat) 一起传递到多层线性模型,尽管大多数情况下单向递归模型足以实现分类功能。提取组合的代码例子如下 (unpacked 来源于上一个例子):

>>> hidden_size = unpacked.shape[2]//2
>>> forward_last = unpacked[:,:,:hidden_size].gather(1, (lengths - 1).reshape(-1, 1, 1).repeat(1, 1, hidden_size))
>>> forward_last
tensor([[[0.2916]],

        [[0.3949]],

        [[0.4263]]], grad_fn=<GatherBackward>)
>>> backward_first = unpacked[:,:1,hidden_size:]
>>> backward_first
tensor([[[0.2377]],

        [[0.2239]],

        [[0.2243]]], grad_fn=<SliceBackward>)
>>> combined = torch.cat((forward_last, backward_first), dim=2)
>>> combined
tensor([[[0.2916, 0.2377]],

        [[0.3949, 0.2239]],

        [[0.4263, 0.2243]]], grad_fn=<CatBackward>)
>>> combined.shape
torch.Size([3, 1, 2])

4.2.例子:根据上下文补全单词

还记得我们小学语文做的填空题吗,这回我们试试写一个程序帮我们自动填空吧👦,为了这个例子我消耗了一个多月的时间,走了很多冤枉路,下图是最终使用的训练流程和模型结构:

以下是踩过的坑一览🤕:

  • 输入和输出的编码需要分开 (使用不同的向量)
  • 输入的编码最好不固定 (跟随训练调整),输出的编码需要固定 (否则模型会作弊让所有单词的输出编码相同)
  • 输出的编码只能由 0 和 1 组成,不能直接使用浮点数组成的向量 (模型无法调整所有输出到精确的向量,只能调整到方向大致相同的值然后用 Sigmoid 处理)
  • 输出的编码向量长度大约 50 以上即可避免 13000 个单词转换到 0 和 1 以后的编码冲突,向量长度越大效果越好但需要更多内存和训练时间
  • 每个句子需要添加表示开头和结尾的符号 (<BEG> 与 <EOF>),它们会当作预测第一个单词和最后一个单词的输入,比使用 0 效果要好一些
  • 输出中表示开头和结尾的向量不参与损失的计算 (预测它们没有意义)
  • 根据预测输出的向量查找对应的单词可以计算欧几里得距离,找出距离最接近的单词
  • 参数调整器可以使用 Adam,在这个例子中比 Adadelta 快一些

这个例子最大的特点是输出的编码使用了 Embedding 的变种,使得编码近似于 binary。传统的做法是使用 onehot + softmax,但随着单词数量增多需要的处理时间和内存大小会暴增,我目前的机器是训练不过来的。输出编码使用 Embedding 变种的好处还有可以同时找出接近的单词,但计算欧几里得距离的效率会比 onehot + softmax 直接得出最可能单词索引的时间差很多。

首先我们需要使用 word2vec 生成输出使用的编码,来源是京东商品评论(下载地址请参考上一篇文章),每个单词对应一个长度 100 的向量:

import jieba
f = open('chinese.text8', 'w')
for line in open('goods_zh.txt', 'r'):
    line = "".join(line.split(',')[:-2])
    words = list(jieba.cut(line))
    words = [w for w in words if not (w.isascii() or w in (",", "。", "!"))]
    words.insert(0, "<BEG>")
    words.append("<EOF>")
    f.write(" ".join(words))
    f.write(" ")

import torch
from gensim.models import word2vec
sentences = word2vec.Text8Corpus('chinese.text8')
model = word2vec.Word2Vec(sentences, size=100)

生成编码以后我们需要把编码中的浮点数转换为 0 或者 1,执行以下代码后编码中小于 0 的值会当作 0,大于或等于 0 的值会当作 1:

v = torch.tensor(model.wv.vectors)
v1 = (v > 0).float()
model.wv.vectors = v1.numpy()

 然后再来测试一下编码是否有冲突(两个单词对应完全相同的向量),如果它们输出相同那就代表没有问题:

print("wv shape:", v1.shape)
print("wv unique shape:", v1.unique(dim=0).shape)

最后保存编码模型到硬盘:

model.save("chinese.model")

接下来使用以下代码训练和使用模型:

import os
import sys
import torch
import gzip
import itertools
import jieba
import json
import random
from gensim.models import word2vec
from torch import nn
from matplotlib import pyplot

class MyModel(nn.Module):
    """根据上下文预测句子中的单词"""
    def __init__(self, w2v):
        super().__init__()
        self.hidden_size = 500
        self.embedded_in_size = 100
        self.embedded_out_size = 100
        self.linear_l1_size = 600
        self.linear_l2_size = 300
        self.embedding_in = nn.Embedding(
            num_embeddings=len(w2v.wv.vocab),
            embedding_dim=self.embedded_in_size,
            padding_idx=0
        )
        self.rnn = nn.LSTM(
            input_size = self.embedded_in_size,
            hidden_size = self.hidden_size,
            num_layers = 1,
            batch_first = True,
            bidirectional = True
        )
        self.linear = nn.Sequential(
            nn.Linear(in_features=self.hidden_size*2, out_features=self.linear_l1_size),
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(in_features=self.linear_l1_size, out_features=self.linear_l2_size),
            nn.ReLU(),
            nn.Dropout(0.05),
            nn.Linear(in_features=self.linear_l2_size, out_features=self.embedded_out_size),
            nn.Sigmoid())

    def forward(self, x, lengths):
        # 转换单词对应的数值到输入使用的向量
        embedded_in = self.embedding_in(x)
        # 附加长度信息,避免 RNN 计算填充的数据
        packed = nn.utils.rnn.pack_padded_sequence(
            embedded_in, lengths, batch_first=True, enforce_sorted=False)
        # 使用递归模型计算,接下来的步骤需要所有输出,所以忽略最新的隐藏状态
        output, _ = self.rnn(packed)
        # output 内部会连接所有隐藏状态,shape = 实际输入数量合计, hidden_size
        # 为了接下来的处理,需要先整理 shape = batch_size, 每组的最大输入数量, hidden_size
        # 第二个返回值是各个 tensor 的实际长度,内容和 lengths 相同,所以可以省略掉
        unpacked, _ = nn.utils.rnn.pad_packed_sequence(output, batch_first=True)
        # 整理正向输出和反向输出,例如有 8 个单词,2 个填充
        # B 1 2 3 4 5 6 7 8 E 0 0
        # 0 B 1 2 3 4 5 6 7 8 E 0 (对应正向)
        # 1 2 3 4 5 6 7 8 E 0 0 0 (对应反向)
        h = self.hidden_size
        hidden_forward = torch.cat((torch.zeros(unpacked.shape[0], 1, h), unpacked[:,:,:h]), dim=1)[:,:-1,:]
        hidden_backward = torch.cat((unpacked[:,:,h:], torch.zeros(unpacked.shape[0], 1, h)), dim=1)[:,1:,:]
        hidden = torch.cat((hidden_forward, hidden_backward), dim=2)
        # 使用多层线性模型推测各个单词以接近原有句子
        y = self.linear(hidden)
        return y

    def calc_loss(self, loss_function, batch_y, predicted, batch_x_lengths):
        # 剪切 batch_y 使得维度与 predicted 相同,因为子批次的最大长度可能与批次的最大长度不一致
        batch_y = batch_y[:,:predicted.shape[1],:]
        # 根据实际长度清零头尾和填充的部分
        # 不能就地修改否则会导致 gradient computation has been modified by an inplace operation 错误
        mask = torch.ones(predicted.shape)
        for index, length in enumerate(batch_x_lengths):
            mask[index,0,:] = 0
            mask[index,length-1:,:] = 0
        predicted = predicted * mask
        batch_y = batch_y * mask
        return loss_function(predicted, batch_y)

def save_tensor(tensor, path):
    """保存 tensor 对象到文件"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """从文件读取 tensor 对象"""
    return torch.load(gzip.GzipFile(path, "rb"))

def load_word2vec_model():
    """读取 word2vec 编码库"""
    return word2vec.Word2Vec.load("chinese.model")

def prepare_save_batch(batch, pending_tensors):
    """准备训练 - 保存单个批次的数据"""
    # 打乱单个批次的数据
    random.shuffle(pending_tensors)

    # 划分输入和输出 tensor,另外保存各个输入 tensor 的长度
    in_tensor_unpadded = [p[0] for p in pending_tensors]
    in_tensor_lengths = torch.tensor([t.shape[0] for t in in_tensor_unpadded])
    out_tensor_unpadded = [p[1] for p in pending_tensors]

    # 整合长度不等的 tensor 到单个 tensor,不足的长度会填充 0
    in_tensor = nn.utils.rnn.pad_sequence(in_tensor_unpadded, batch_first=True)
    out_tensor = nn.utils.rnn.pad_sequence(out_tensor_unpadded, batch_first=True)

    # 切分训练集 (60%),验证集 (20%) 和测试集 (20%)
    random_indices = torch.randperm(in_tensor.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.6)]
    validating_indices = random_indices[int(len(random_indices)*0.6):int(len(random_indices)*0.8):]
    testing_indices = random_indices[int(len(random_indices)*0.8):]
    training_set = (in_tensor[training_indices], in_tensor_lengths[training_indices], out_tensor[training_indices])
    validating_set = (in_tensor[validating_indices], in_tensor_lengths[validating_indices], out_tensor[validating_indices])
    testing_set = (in_tensor[testing_indices], in_tensor_lengths[testing_indices], out_tensor[testing_indices])

    # 保存到硬盘
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """准备训练"""
    # 数据集转换到 tensor 以后会保存在 data 文件夹下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 准备词语到数值的索引
    w2v = load_word2vec_model()
    beg_index = w2v.wv.vocab["<BEG>"].index
    eof_index = w2v.wv.vocab["<EOF>"].index

    # 提前转换输出的编码
    embedding_out = nn.Embedding.from_pretrained(torch.FloatTensor(w2v.wv.vectors))

    # 从 txt 读取原始数据集,分批每次处理 2000 行
    # 这里使用原始方法读取,最后一个标注为 1 代表好评,为 0 代表差评
    batch = 0
    pending_tensors = []
    for line in open("goods_zh.txt", "r"):
        parts = line.split(',')
        phase = ",".join(parts[:-2])
        positive = int(parts[-1])
        # 使用 jieba 分词,然后转换单词到索引
        words = jieba.cut(phase)
        word_indices = [beg_index] # 代表语句开始
        for word in words:
            vocab = w2v.wv.vocab.get(word)
            if vocab:
                word_indices.append(vocab.index)
        word_indices.append(eof_index) # 代表语句结束
        if len(word_indices) <= 2:
            continue # 没有单词在编码库中
        # 输入是各个句子对应的索引值列表,输出是各个各个句子对应的向量列表
        tensor_in = torch.tensor(word_indices)
        tensor_out = embedding_out(tensor_in)
        pending_tensors.append((tensor_in, tensor_out))
        if len(pending_tensors) >= 2000:
            prepare_save_batch(batch, pending_tensors)
            batch += 1
            pending_tensors.clear()
    if pending_tensors:
        prepare_save_batch(batch, pending_tensors)
        batch += 1
        pending_tensors.clear()

def train():
    """开始训练"""
    # 创建模型实例
    w2v = load_word2vec_model()
    model = MyModel(w2v)

    # 创建损失计算器
    loss_function = torch.nn.BCELoss()

    # 创建参数调整器
    optimizer = torch.optim.Adam(model.parameters())

    # 记录训练集和验证集的正确率变化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 记录最高的验证集正确率
    validating_accuracy_highest = -1
    validating_accuracy_highest_epoch = 0

    # 读取批次的工具函数
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield load_tensor(path)

    # 计算正确率的工具函数,除去头尾和填充值
    def calc_accuracy(actual, predicted, lengths):
        acc = 0
        for x in range(len(lengths)):
            l = lengths[x]
            predicted_record = (predicted[x][1:l-1] > 0.5).int()
            actual_record = actual[x][1:l-1].int()
            acc += (predicted_record == actual_record).sum().item() / predicted_record.numel()
        acc /= len(lengths)
        return acc
 
    # 划分输入和长度的工具函数
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, input_size
        batch_x = batch[0][begin:end]
        # shape = batch_size, 1
        batch_x_lengths = batch[1][begin:end]
        # shape = batch_size. input_size, embedded_size
        batch_y = batch[2][begin:end]
        return batch_x, batch_x_lengths, batch_y

    # 开始训练过程
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根据训练集训练并修改参数
        # 切换模型到训练模式,将会启用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.train()
        training_accuracy_list = []
        for batch_index, batch in enumerate(read_batches("data/training_set")):
            # 切分小批次,有助于泛化模型
            training_batch_accuracy_list = []
            for index in range(0, batch[0].shape[0], 100):
                # 划分输入和长度
                batch_x, batch_x_lengths, batch_y = split_batch_xy(batch, index, index+100)
                # 计算预测值
                predicted = model(batch_x, batch_x_lengths)
                # 计算损失
                loss = model.calc_loss(loss_function, batch_y, predicted, batch_x_lengths)
                # 从损失自动微分求导函数值
                loss.backward()
                # 使用参数调整器调整参数
                optimizer.step()
                # 清空导函数值
                optimizer.zero_grad()
                # 记录这一个批次的正确率,torch.no_grad 代表临时禁用自动微分功能
                with torch.no_grad():
                    training_batch_accuracy_list.append(calc_accuracy(batch_y, predicted, batch_x_lengths))
            # 输出批次正确率
            training_batch_accuracy = sum(training_batch_accuracy_list) / len(training_batch_accuracy_list)
            training_accuracy_list.append(training_batch_accuracy)
            print(f"epoch: {epoch}, batch: {batch_index}: batch accuracy: {training_batch_accuracy}")
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 检查验证集
        # 切换模型到验证模式,将会禁用自动微分,批次正规化 (BatchNorm) 与 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
            predicted = model(batch_x, batch_x_lengths)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted, batch_x_lengths))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 记录最高的验证集正确率与当时的模型状态,判断是否在 20 次训练后仍然没有刷新记录
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次训练后仍然没有刷新记录,结束训练
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用达到最高正确率时的模型状态
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 检查测试集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_x_lengths, batch_y = split_batch_xy(batch)
        predicted = model(batch_x, batch_x_lengths)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted, batch_x_lengths))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 显示训练集和验证集的正确率变化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用训练好的模型"""
    # 读取 word2vec 编码库
    w2v = load_word2vec_model()

    # 创建模型实例,加载训练好的状态,然后切换到验证模式
    model = MyModel(w2v)
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 获取单词索引到向量的 tensor
    embedding_tensor = torch.tensor(w2v.wv.vectors)

    # 查找最接近单词数量的函数,根据欧几里得距离比较
    # 也可以使用 w2v.wv.similar_by_vector
    def find_similar_words(target_tensor):
        top_words = 10
        similar_words = []
        for word, vocab in w2v.wv.vocab.items():
            index = vocab.index
            distance = torch.dist(embedding_tensor[index], target_tensor, 2).item()
            if len(similar_words) < top_words or distance < similar_words[-1][1]:
                similar_words.append((word, distance))
                similar_words.sort(key=lambda v: v[1])
                if len(similar_words) > top_words:
                    similar_words.pop()
        return similar_words

    # 询问输入并预测输出
    # __ 为预测目标,例如下次还来__购买 表示预测 __ 处的单词,只支持一个预测目标
    while True:
        try:
            phase = input("Sentence: ")
            phase = phase.replace("\t", "").replace("__", "\t")
            if "\t" not in phase:
                raise ValueError("Please use __ to represent predict target")
            if phase.count("\t") > 1:
                raise ValueError("Please only use one predict target")
            # 分词
            words = list(jieba.cut(phase))
            # 转换到数值列表
            word_indices = [1] # 代表语句开始
            for word in words:
                if word == '\t':
                    word_indices.append(0) # 预测目标
                    continue
                vocab = w2v.wv.vocab.get(word)
                if vocab:
                    word_indices.append(vocab.index)
            word_indices.append(2) # 代表语句结束
            if len(word_indices) <= 2:
                raise ValueError("No known words")
            # 构建输入
            x = torch.tensor(word_indices).reshape(1, -1)
            lengths = torch.tensor([len(word_indices)])
            # 预测输出
            predicted = model(x, lengths)
            # 找出最接近的单词一览
            target_index = word_indices.index(0)
            target_tensor = (predicted[0, target_index] > 0.5).float()
            similar_words = find_similar_words(target_tensor)
            for word, distance in similar_words:
                print(word, distance)
        except Exception as e:
            print("error:", e)

def main():
    """主函数"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 给随机数生成器分配一个初始值,使得每次运行都可以生成相同的随机数
    # 这是为了让过程可重现,你也可以选择不这样做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根据命令行参数选择操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

执行以下命令准备训练需要的数据和开始训练:

python3 example.py prepare
python3 example.py train

训练结果如下(使用 CPU 训练需要大约两天时间🤢),这里的正确率代表预测输出和实际输出向量中有多少个值是相等的:

training accuracy: 0.8106725109454498
validating accuracy: 0.7361285656628191
stop training because highest validating accuracy not updated in 20 epoches
highest validating accuracy: 0.7382469316157465 from epoch 18
testing accuracy: 0.7378169895469142 

执行以下命令可以使用训练好的模型:

python3 example.py eval 

以下是一些使用例子,__ (两个下划线)代表预测目标的单词,会输出最接近的 10 个单词: 

Sentence: 衣服质量__哦
不错 0.0
很棒 3.872983455657959
挺不错 4.0
物有所值 4.582575798034668
物超所值 4.795831680297852
很赞 4.795831680297852
超好 4.795831680297852
太好了 4.795831680297852
好 5.0
太棒了 5.0
Sentence: 鞋子轻便__,好穿,值得推荐。
修身 3.316624879837036
身材 3.464101552963257
显 3.464101552963257
贴身 3.464101552963257
休闲 3.605551242828369
软和 3.605551242828369
保暖 3.7416574954986572
凉快 3.7416574954986572
柔软 3.7416574954986572
轻快 3.7416574954986572
Sentence: 鞋子轻便舒服,好穿,值得__。
拥有 3.316624879837036
够买 3.605551242828369
信赖 3.7416574954986572
购买 4.242640495300293
信耐 4.582575798034668
推荐 4.795831680297852
入手 4.795831680297852
表扬 4.795831680297852
点赞 5.0
下手 5.0
Sentence: 鞋子轻便舒服,好穿,__推荐。
值得 1.4142135381698608
放心 4.690415859222412
值 4.795831680297852
物美价廉 5.099019527435303
价廉物美 5.099019527435303
价格便宜 5.196152210235596
加油 5.196152210235596
一百分 5.196152210235596
很赞 5.196152210235596
赞赞赞 5.196152210235596
Sentence: 发货__很赞,东西也挺好
速度 2.4494898319244385
迅速 4.898979663848877
给力 5.0
力 5.0
价格便宜 5.0
没得说 5.196152210235596
超值 5.196152210235596
很赞 5.196152210235596
小哥 5.291502475738525
小巧 5.291502475738525
Sentence: 半个月就出现这问题 ,__直接说找附近站点售后 ,浪费时间,还得自己修,差评一个
客服 0.0
商家 4.690415859222412
卖家 4.898979663848877
售后 5.099019527435303
没人 5.099019527435303
店家 5.196152210235596
补发 5.291502475738525
人工 5.291502475738525
客户 5.385164737701416
机器人 5.385164737701416
Sentence: 不错给老公买了好几个了,穿着特别__
舒服 0.0
舒适 3.316624879837036
挺舒服 4.242640495300293
帅气 4.690415859222412
脚疼 4.690415859222412
很帅 4.795831680297852
凉快 4.898979663848877
合身 5.0
暖和 5.099019527435303
老公 5.291502475738525
Sentence: 不错给__买了好几个了,穿着特别舒服
老爸 2.8284270763397217
爸爸 3.0
弟弟 3.0
妹妹 3.0
女朋友 3.0
男朋友 3.1622776985168457
老妈 3.1622776985168457
女儿 3.316624879837036
表弟 3.316624879837036
家人 3.316624879837036 

可以看到预测出来的效果还不错😈,尽管部分语句没有完全准确的预测出原有的单词但是语义很接近。如果你想得到更好的效果,可以增加输出向量长度 (word2vec 生成时的 size 参数,对应 embedded_out_size),输入向量长度(embedded_in_size),和模型的隐藏值数量(hidden_size, linear_l1_size, linear_l2_size),但会需要更多的训练时间和内存🤢。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值
>