NLP

使用RNN模型构建人名分类器

RNN、LSTM、GRU

Posted by 新宇 on September 14, 2020

一、案例介绍

  • 关于人名分类问题:
    • 以一个人名为输入, 使用模型帮助我们判断它最有可能是来自哪一个国家的人名, 这在某些国际化公司的业务中具有重要意义, 在用户注册过程中, 会根据用户填写的名字直接给他分配可能的国家或地区选项, 以及该国家或地区的国旗, 限制手机号码位数等等.
  • 人名分类数据:
    • 数据下载地址: https://download.pytorch.org/tutorial/data.zip

数据文件预览:

各文件的人名数量:

二、优化思路

  • 数据处理
    • 使用全部数据进行训练
    • 对数据进行shuffle处理
      • random.shuffle()
    • 数据集过采样(未使用,Acc提升不高)
      • 原数据集样本分布极度不均衡,例如Russian有9408条数据,而Vietnamese只有73条
  • 模型优化
    • 采用GRU模型,相比于RNN,GRU收敛速度更快
    • 使用optim.Adam()优化器
    • 每一轮epoch输出的hidden,作为下一次epoch的隐层输入
  • 其他
    • 增加epoch轮数,本案例epoch=60

三、代码实现

1. 导入必备的工具包

# 从io中导入文件打开方法

from io import open
# 帮助使用正则表达式进行子目录的查询

import glob
import os
# 用于获得常见字母及字符规范化

import string
import unicodedata
# 导入随机工具random

import random
# 导入时间和数学工具包

import time
import math
# 导入torch工具

import torch
# 导入nn准备构建模型

import torch.nn as nn
# 引入制图工具包 

import matplotlib.pyplot as plt
# 设备选择 我们可以选择在cuda或者CPU上运行你的代码

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

2. 对data文件中的数据进行处理,满足训练要求

all_letters = string.ascii_letters + " .,;'"
# 获取常用字符数量

n_letters = len(all_letters) # n_letter: 57

# 关于编码问题我们暂且不去考虑
# 我们认为这个函数的作用就是去掉一些语言中的重音标记
# 如: Ślusàrski ---> Slusarski

def unicodeToAscii(s):
    return ''.join(
        c for c in unicodedata.normalize('NFD', s)
        if unicodedata.category(c) != 'Mn'
        and c in all_letters
    )


# 读取训练集文件

data_path = "./data/names//"

def readLines(filename):
    """从文件中读取每一行加载到内存中形成列表"""
    # 打开指定文件并读取所有内容, 使用strip()去除两侧空白符, 然后以'\n'进行切分

    lines = open(filename, encoding='utf-8').read().strip().split('\n')
    # 对应每一个lines列表中的名字进行Ascii转换, 使其规范化.最后返回一个名字列表

    return [unicodeToAscii(line) for line in lines]

# 构建的category_lines形如:{"English":["Lily", "Susan", "Kobe"], "Chinese":["Zhang San", "Xiao Ming"]}

category_lines = {}

# all_categories形如: ["English",...,"Chinese"]

all_categories = []

# 读取指定路径下的txt文件, 使用glob,path中可以使用正则表达式

for filename in glob.glob(data_path + '*.txt'):
    # 获取每个文件的文件名, 就是对应的名字类别

    category = os.path.splitext(os.path.basename(filename))[0]
    # 将其逐一装到all_categories列表中

    all_categories.append(category)
    # 然后读取每个文件的内容,形成名字列表

    lines = readLines(filename)
    # 按照对应的类别,将名字列表写入到category_lines字典中

    category_lines[category] = lines


# # 查看类别总数

# n_categories = len(all_categories)

# print("n_categories:", n_categories)

# # 随便查看其中的一些内容

# print(category_lines['Italian'][:5])

def lineToTensor(line):
    """将人名转化为对应onehot张量表示, 参数line是输入的人名"""
    # 首先初始化一个0张量, 它的形状(len(line), 1, n_letters) 
    # 代表人名中的每个字母用一个1 x n_letters的张量表示.

    tensor = torch.zeros(len(line), 1, n_letters)
    # 遍历这个人名中的每个字符索引和字符

    for li, letter in enumerate(line):
        # 使用字符串方法find找到每个字符在all_letters中的索引
        # 它也是我们生成onehot张量中1的索引位置

        tensor[li][0][all_letters.find(letter)] = 1
    # 返回结果

    return tensor

3. 构建RNN、LSTM、GRU模型

# 使用nn.RNN构建完成传统RNN使用类

class RNN(nn.Module):
    def __init__(self, input_size, hidden_size, output_size, num_layers=1):
        """初始化函数中有4个参数, 分别代表RNN输入最后一维尺寸, RNN的隐层最后一维尺寸, RNN层数"""

        super(RNN, self).__init__()       
        # 将hidden_size与num_layers传入其中

        self.hidden_size = hidden_size
        self.num_layers = num_layers  

        # 实例化预定义的nn.RNN, 它的三个参数分别是input_size, hidden_size, num_layers

        self.rnn = nn.RNN(input_size, hidden_size, num_layers)
        # 实例化nn.Linear, 这个线性层用于将nn.RNN的输出维度转化为指定的输出维度

        self.linear = nn.Linear(hidden_size, output_size)
        # 实例化nn中预定的Softmax层, 用于从输出层获得类别结果

        self.softmax = nn.LogSoftmax(dim=-1)


    def forward(self, input, hidden):
        """完成传统RNN中的主要逻辑, 输入参数input代表输入张量, 它的形状是1 x n_letters
           hidden代表RNN的隐层张量, 它的形状是self.num_layers x 1 x self.hidden_size"""
        # 因为预定义的nn.RNN要求输入维度一定是三维张量, 因此在这里使用unsqueeze(0)扩展一个维度

        input = input.unsqueeze(0)
        # 将input和hidden输入到传统RNN的实例化对象中,如果num_layers=1, rr恒等于hn

        rr, hn = self.rnn(input, hidden)
        # 将从RNN中获得的结果通过线性变换和softmax返回,同时返回hn作为后续RNN的输入

        return self.softmax(self.linear(rr)), hn


    def initHidden(self):
        """初始化隐层张量"""
        # 初始化一个(self.num_layers, 1, self.hidden_size)形状的0张量    

        return torch.zeros(self.num_layers, 1, self.hidden_size)  

# 使用nn.LSTM构建完成LSTM使用类

class LSTM(nn.Module):
    def __init__(self, input_size, hidden_size, output_size, num_layers=1):
        """初始化函数的参数与传统RNN相同"""

        super(LSTM, self).__init__()
        # 将hidden_size与num_layers传入其中

        self.hidden_size = hidden_size
        self.num_layers = num_layers

        # 实例化预定义的nn.LSTM

        self.lstm = nn.LSTM(input_size, hidden_size, num_layers)
        # 实例化nn.Linear, 这个线性层用于将nn.RNN的输出维度转化为指定的输出维度

        self.linear = nn.Linear(hidden_size, output_size)
        # 实例化nn中预定的Softmax层, 用于从输出层获得类别结果

        self.softmax = nn.LogSoftmax(dim=-1)


    def forward(self, input, hidden, c):
        """在主要逻辑函数中多出一个参数c, 也就是LSTM中的细胞状态张量"""

        # 使用unsqueeze(0)扩展一个维度

        input = input.unsqueeze(0)
        # 将input, hidden以及初始化的c传入lstm中

        rr, (hn, c) = self.lstm(input, (hidden, c))
        # 最后返回处理后的rr, hn, c

        return self.softmax(self.linear(rr)), hn, c

    def initHiddenAndC(self):  
        """初始化函数不仅初始化hidden还要初始化细胞状态c, 它们形状相同"""

        c = hidden = torch.zeros(self.num_layers, 1, self.hidden_size)
        return hidden, c

# 使用nn.GRU构建完成传统RNN使用类

# GRU与传统RNN的外部形式相同, 都是只传递隐层张量, 因此只需要更改预定义层的名字

class GRU(nn.Module):
    def __init__(self, input_size, hidden_size, output_size, num_layers=1):
        super(GRU, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers

        # 实例化预定义的nn.GRU, 它的三个参数分别是input_size, hidden_size, num_layers

        self.gru = nn.GRU(input_size, hidden_size, num_layers)
        self.linear = nn.Linear(hidden_size, output_size)
        self.softmax = nn.LogSoftmax(dim=-1)

    def forward(self, input, hidden):
        input = input.unsqueeze(0)
        rr, hn = self.gru(input, hidden)
        return self.softmax(self.linear(rr)), hn

    def initHidden(self):
        return torch.zeros(self.num_layers, 1, self.hidden_size)


# 因为是onehot编码, 输入张量最后一维的尺寸就是n_letters

input_size = n_letters

# 定义隐层的最后一维尺寸大小

n_hidden = 128

# 输出尺寸为语言类别总数n_categories

output_size = n_categories

# num_layer使用默认值, num_layers = 1

# 假如我们以一个字母B作为RNN的首次输入, 它通过lineToTensor转为张量
# 因为我们的lineToTensor输出是三维张量, 而RNN类需要的二维张量
# 因此需要使用squeeze(0)降低一个维度

input = lineToTensor('B').squeeze(0).to(device)

# 初始化一个三维的隐层0张量, 也是初始的细胞状态张量

hidden = c = torch.zeros(1, 1, n_hidden).to(device)

# 创建模型对象 

rnn = RNN(n_letters, n_hidden, n_categories).to(device)
lstm = LSTM(n_letters, n_hidden, n_categories).to(device)
gru = GRU(n_letters, n_hidden, n_categories).to(device)

rnn_output, next_hidden = rnn(input, hidden)
print("rnn:", rnn_output)
lstm_output, next_hidden, c = lstm(input, hidden, c)
print("lstm:", lstm_output)
gru_output, next_hidden = gru(input, hidden)
print("gru:", gru_output)


4. 构建训练函数并进行训练

def categoryFromOutput(output):
    """从输出结果中获得指定类别, 参数为输出张量output"""
    # 从输出张量中返回最大的值和索引对象, 我们这里主要需要这个索引

    top_n, top_i = output.topk(1)
    # top_i对象中取出索引的值

    category_i = top_i[0].item()
    # 根据索引值获得对应语言类别, 返回语言类别和索引值

    return all_categories[category_i], category_i

# 生成等差数列,默认step=1

x = torch.arange(1.,6.)
print(x)
torch.topk(x, 3)

output = gru_output

category, category_i = categoryFromOutput(output)
print("category:", category) 
print("category_i:", category_i)

# 定义损失函数为nn.NLLLoss,因为RNN的最后一层是nn.LogSoftmax, 两者的内部计算逻辑正好能够吻合. 

criterion = nn.NLLLoss()


def trainRNN(category_tensor, line_tensor, optimizer, hidden=None, c=None):
    """定义训练函数, 它的两个参数是category_tensor类别的张量表示, 相当于训练数据的标签,
       line_tensor名字的张量表示, 相当于对应训练数据"""

    # 在函数中, 首先通过实例化对象rnn初始化隐层张量

    hidden = rnn.initHidden()

    # 然后将模型结构中的梯度归0

    rnn.zero_grad()

    # 下面开始进行训练, 将训练数据line_tensor的每个字符逐个传入rnn之中, 得到最终结果

    for i in range(line_tensor.size()[0]):
        output, hidden = rnn(line_tensor[i].to(device), hidden.to(device))

    # 因为我们的rnn对象由nn.RNN实例化得到, 最终输出形状是三维张量, 为了满足于category_tensor
    # 进行对比计算损失, 需要减少第一个维度, 这里使用squeeze()方法

    loss = criterion(output.squeeze(0), category_tensor)

    # 损失进行反向传播

    loss.backward()
    optimizer.step()
    
    # 返回结果和损失的值 + 训练后的隐层输出hidden

    return output, loss.item(), hidden, None

# 与传统RNN相比多出细胞状态c

def trainLSTM(category_tensor, line_tensor, optimizer, hidden=None, c=None):
    hidden, c = lstm.initHiddenAndC()
    lstm.zero_grad()
    for i in range(line_tensor.size()[0]):
        # 返回output, hidden以及细胞状态c

        output, hidden, c = lstm(line_tensor[i].to(device), hidden.to(device), c.to(device))
    loss = criterion(output.squeeze(0), category_tensor)
    loss.backward()
    optimizer.step()

#     for p in lstm.parameters():
#         p.data.add_(-learning_rate, p.grad.data)

    return output, loss.item(), hidden, c 

# 与传统RNN完全相同, 只不过名字改成了GRU

def trainGRU(category_tensor, line_tensor, optimizer, hidden=None, c=None):
    hidden = gru.initHidden()
    gru.zero_grad()
    for i in range(line_tensor.size()[0]):
        output, hidden= gru(line_tensor[i].to(device), hidden.to(device))
    loss = criterion(output.squeeze(0), category_tensor)
    loss.backward()
    optimizer.step()
    
#     for p in gru.parameters():
#         p.data.add_(-learning_rate, p.grad.data)
    return output, loss.item(), hidden, None

def timeSince(since):
    "获得每次打印的训练耗时, since是训练开始时间"
    # 获得当前时间
    now = time.time()
    # 获得时间差,就是训练耗时
    s = now - since
    # 将秒转化为分钟, 并取整
    m = math.floor(s / 60)
    # 计算剩下不够凑成1分钟的秒数
    s -= m * 60
    # 返回指定格式的耗时
    return '%dm %ds' % (m, s)

# 设置训练迭代次数

epochs = 60
# 设置结果的打印间隔

print_every = 3000
# 获取总训练数据的数量

total = 0

# # 获取最大样本数量
# max_len = 1000
# for country, names in category_lines.items():
#     # 优化: 样本不均衡,取最少label对应的样本数量
#     # 效果不理想
#     while len(names) < max_len:
#         names = names * 2
#     category_lines[country] = names[:max_len]
#     total+=max_len

# 构建训练函数

import random
from torch import optim

key_vals_list = []
for country, names in category_lines.items():
    for name in names: 
        key_vals_list.append((country, name))
total = len(key_vals_list)
print('训练集样本总数: %d' % (total))
    
def train(train_type_fn):
    """训练过程的日志打印函数, 参数train_type_fn代表选择哪种模型训练函数, 如trainRNN"""
    # 每个制图间隔损失保存列表

    all_losses = []
    hidden = c = None
    optimizer = optim.Adam(gru.parameters(), lr = 0.0001)
    
    # 优化:这里不能随机训练,应使用全部训练数据:

    for epoch in range(epochs):
        # 优化:将训练集打乱顺序

        random.shuffle(key_vals_list)
        i = 0
        current_loss = 0
        # 获得训练开始时间戳

        start = time.time()
 
        for country, name in key_vals_list:
            line_tensor = lineToTensor(name)
            # 将训练数据和对应类别的张量表示传入到train函数中

            category_tensor = torch.tensor([all_categories.index(country)], dtype=torch.long)
            output, loss, hidden, c = train_type_fn(category_tensor.to(device), line_tensor.to(device), optimizer, hidden, c)      
            # 计算制图间隔中的总损失

            current_loss += loss
            # 打印损失

            if (i+1)%print_every == 0:
                print('epoch: %d, rate: %d%%, cost: %s, loss: %.4f' % (epoch+1, (i+1)*100.0/total, timeSince(start), current_loss/print_every*1.0))
                # 将保存该间隔中的平均损失到all_losses列表中

                all_losses.append(current_loss/print_every*1.0)
                current_loss = 0
            i = i + 1
        print('epoch: %d, rate: %d%%, cost: %s, loss: %.4f' % (epoch+1, i*100.0/total, timeSince(start), current_loss/(total%print_every)*1.0))
        all_losses.append(current_loss/(total%print_every)*1.0)
    # 返回对应的总损失列表和训练耗时

    return all_losses, int(time.time() - start)

# 调用train函数, 分别进行RNN, LSTM, GRU模型的训练

# 并返回各自的全部损失, 以及训练耗时用于制图

# print('-----trainRNN start-----')

# all_losses1, period1 = train(trainRNN)

# print('-----trainRNN end-----')

# print('-----trainLSTM start-----')

# all_losses2, period2 = train(trainLSTM)

# print('-----trainLSTM end-----')

print('-----trainLGRU start-----')
all_losses3, period3 = train(trainGRU)
print('-----trainGRU end-----')


# 绘制损失对比曲线, 训练耗时对比柱张图

# 创建画布0

plt.figure(0)
# 绘制损失对比曲线

# plt.plot(all_losses1, label="RNN")
# plt.plot(all_losses2, color="red", label="LSTM")

plt.plot(all_losses3, color="orange", label="GRU") 
plt.legend(loc='upper left') 

第50-60 epoch的loss损失折线图,可以看到损失已经几乎收敛:

5. 构建评估函数并进行预测

def evaluateRNN(line_tensor):
    """评估函数, 和训练函数逻辑相同, 参数是line_tensor代表名字的张量表示"""

    # 初始化隐层张量

    hidden = rnn.initHidden()
    # 将评估数据line_tensor的每个字符逐个传入rnn之中

    for i in range(line_tensor.size()[0]):
        output, hidden = rnn(line_tensor[i].to(device), hidden.to(device))
    # 获得输出结果

    return output.squeeze(0)

def evaluateLSTM(line_tensor):
    # 初始化隐层张量和细胞状态张量

    hidden, c = lstm.initHiddenAndC()
    # 将评估数据line_tensor的每个字符逐个传入lstm之中

    for i in range(line_tensor.size()[0]):
        output, hidden, c = lstm(line_tensor[i].to(device), hidden.to(device), c.to(device))
    return output.squeeze(0)

def evaluateGRU(line_tensor):
    hidden = gru.initHidden()
    # 将评估数据line_tensor的每个字符逐个传入gru之中

    for i in range(line_tensor.size()[0]):
        output, hidden = gru(line_tensor[i].to(device), hidden.to(device))
    return output.squeeze(0)

rnn_output = evaluateRNN(line_tensor)
lstm_output = evaluateLSTM(line_tensor)
gru_output = evaluateGRU(line_tensor)
print("rnn_output:", rnn_output)
print("gru_output:", lstm_output)
print("gru_output:", gru_output)


def predict(input_line, evaluate, n_predictions=3):
    """预测函数, 输入参数input_line代表输入的名字, 
       n_predictions代表需要取最有可能的top个"""

    # 以下操作的相关张量不进行求梯度

    with torch.no_grad():
        # 使输入的名字转换为张量表示, 并使用evaluate函数获得预测输出

        output = evaluate(lineToTensor(input_line))

        # 从预测的输出中取前3个最大的值及其索引

        topv, topi = output.topk(n_predictions, 1, True)
        # 创建盛装结果的列表

        predictions = []
        # 遍历n_predictions

        for i in range(n_predictions):
            # 从topv中取出的output值

            value = topv[0][i].item()
            # 取出索引并找到对应的类别

            category_index = topi[0][i].item()
            predictions.append(all_categories[category_index])
        
        return predictions

6. 获取测试集ACC

# 读取测试集 test_100.csv

# 格式为 国家名,人名

# 构建的category_lines形如:{"English":["Lily", "Susan", "Kobe"], "Chinese":["Zhang San", "Xiao Ming"]}

test_category_lines = {}

# all_categories形如: ["English",...,"Chinese"]

test_all_categories = []

with open('./data/test_100.csv') as f:
    lines = f.read().strip().split('\n')
    for line in lines:
        country, name = line.split(',')
        if country not in test_category_lines:
            test_category_lines[country] = []
        test_category_lines[country].append(name)

# 查看类别总数

n_test_categories = len(test_category_lines)
print("n_test_categories:", n_test_categories)

# 随便查看其中的一些内容

print(test_category_lines['English'][:5])

# 获取测试集top1,top3的准确率

# top1 代表只预测一个输出结果

# top3 代表预测三个输出结果

def evaluateTest(evaluate_fn, n_predictions=3):
    total = 0
    predict_num = 0
    for country, names in test_category_lines.items():
        num = 0
        for name in names:
            pridict_list = predict(name, evaluate_fn, n_predictions)
            if country in pridict_list:
                num+=1
        accuracy = num*1.0/len(names)
        predict_num += num
        total += len(names)
    print('total: %d , acc: %.2f' % (total, predict_num*1.0/total))

for evaluate_fn in [evaluateGRU]: 
    print('-----%s-----'% (evaluate_fn.__name__))
    for i in [1, 3]:
        print('top%d准确率:'%(i))
        evaluateTest(evaluate_fn, i)

7. 保存模型

# 首先设定模型的保存路径

PATH = './name_classify.pth'
# 保存模型的状态字典

torch.save(gru.state_dict(), PATH)


# 读取模型代码

# PATH = './name_classify.pth'

# gru.load_state_dict(torch.load(PATH))

四、测试集ACC

五、模型下载

name_classify.pth

提取码: dlqk

六、补充资料-模型架构图

1. RNN

2. LSTM

3. GRU