像建房子一样打造变形金刚,追梦女孩要刚强

发布时间 2023-08-01 20:10:33作者: 鸽鸽的书房

Transformer鼎鼎大名人尽皆知,2017年就问津于世,鸽鸽2023年才学习它,任何时候圆梦都不算晚!本文记录了我像建房子一样从头到尾打造变形金刚的全过程,目的是熟悉pytorch和深入理解transformer。

先看下我设定的任务难度,我们要解决的是经典的seq2seq翻译任务。使用的数据集是中英新闻评论,共有373735条非常高质量的中英句对(还有其他中英平行语料比如translation2019zh以后都可以加进来)。

这是个高翻学院的学生都可能望而生畏的任务呀,机器能做到吗?期待地搓搓手!

中英机器翻译任务

import pandas as pd
pd.set_option("max_colwidth", None)
df = pd.read_csv(r"D:\动手学深度学习\代码练习\news-commentary-v18.en-zh.tsv",sep="\t",header = None,nrows=600, names = ["English","Chinese"])
df.head()
English Chinese
0 Is It Time to Give Up on 1.5°C? 是时候放弃1.5°C目标了吗?
1 MILAN – Net-zero commitments are all the rage. 发自米兰—净零承诺当前正处于风口上。
2 Countries, companies, and others worldwide have committed to eliminating their net greenhouse-gas emissions by a particular date – for some, as early as 2030. 世界各地的国家、企业和其他国家都承诺要在某个特定日期前消除温室气体净排放 — — 某些国家的设定早到2030年。
3 But net-zero targets are not tantamount to limiting global warming to the Paris climate agreement’s goal of 1.5° Celsius – or any particular level of warming, for that matter. It is the path to net-zero emissions that makes all the difference. 但净零目标并不等同于将全球变暖限制在巴黎气候协定的1.5°C目标或是任何特定变暖水平,而达成净零排放的路径才是真正实现变革之处。
4 This is well understood among experts. 专家们对此早已深有体会。

材料准备:数据处理

刚开始加载数据集就难倒英雄汉了,分为三步:

  1. 参考网上torch.utils.data.Dataset的教程,继承Dataset类来建立自己的customDataset类;
  2. 用 random_split 把数据随机拆分成训练集(~ 300000)、验证集(30000)和测试集(30000);
  3. 用 torch.utils.data.dataloader将Dataset自定义数据类对象封装成一个迭代器,实现shuffle、传入batch、多进程等。

参考:[Pytorch构建数据集——torch.utils.data.Dataset()和torch.utils.data.DataLoader()](

import pandas as pd
import torch
from torch.utils.data import Dataset

# 自定义Dataset类
class textDataset(Dataset):
    def __init__(self,dataset_path):
        super().__init__()
        self.dataset = pd.read_csv(dataset_path, encoding='utf8', sep='\t',header = None, nrows=600).dropna(axis=0) #删除有空值的行

        
    def __len__(self):
        return len(self.dataset)
    
    def __getitem__(self, idx):
        
        input_data = pd.DataFrame([])
        label = pd.DataFrame([])
        
        input_data = self.dataset.iloc[idx, 1]
        label = self.dataset.iloc[idx, 0]
        return label, input_data
en_zh_path = r"D:\动手学深度学习\代码练习\news-commentary-v18.en-zh.tsv"
en_zh_dataset = textDataset(en_zh_path)
en_zh_dataset[0]
('Is It Time to Give Up on 1.5°C?', '是时候放弃1.5°C目标了吗?')
# 划分数据集
from torch.utils.data import random_split
train_dataset, eval_dataset, test_dataset = random_split(
    dataset=en_zh_dataset,
    lengths=[len(en_zh_dataset)-200, 100, 100],
    generator=torch.Generator().manual_seed(0)
)
# 小批量加载训练数据
from torch.utils.data import DataLoader
train_iter = DataLoader(train_dataset,   # 封装的对象
                               batch_size=2,     # 输出的batch size
                               shuffle=True,     # 随机输出
                               num_workers=0)    # 只有1个进程

# 试试以for循环形式输出
i = 0
for data, target in train_iter:
    print(data, target)
    i+=1
    if i>0:
        break
('The consequences were far-reaching.', 'Aside from trade within the UK and between a few small Commonwealth countries, there is no natural international demand for sterling.') ('这一事件的后果是深远的。', '除了英国国内和少数英联邦小国之间的贸易外国际上对英镑缺乏自发需求。')

材料编号:建立词表

《语言本能》一书指出语言是人类大脑组织的天然构件、一种无意识的本能倾向,看到或听到语言我们会条件反射地自动识别含义。但对于计算机而言,数字是它唯一能读懂的符号。因此,我们要构建词典,并把每个字映射为数字,再传输给机器。具体过程分为分词和索引:

分词

因为是中英语料,需要把中英文都进行tokenization,这篇文章使用 torchtext 的 get_tokenizer 进行英文分词,jieba进行中文分词,官网教程使用 spacy 分词。我把两者结合,用spacy进行英文分词,用jieba进行中文分词。

# 修改自官网:https://pytorch.org/tutorials/beginner/translation_transformer.html
from typing import Iterable, List
SRC_LANGUAGE = 'en'
TGT_LANGUAGE = 'zh'
language_index = {SRC_LANGUAGE: 0, TGT_LANGUAGE: 1}

token_transform = {}
vocab_transform = {}

# 英文分词
from torchtext.data import get_tokenizer
token_transform[SRC_LANGUAGE] = get_tokenizer('spacy', language='en_core_web_sm')

# 中文分词
import jieba
def chinese_tokenizer(chinese):
    return [x for x in list(jieba.cut(chinese)) if x not in {' ', '\t'}]
token_transform[TGT_LANGUAGE] = chinese_tokenizer

def yield_tokens(data_iter: Iterable, language: str) -> List[str]:
    for data_sample in data_iter:
        yield token_transform[language](data_sample[language_index[language]])

索引

用build_vocab_from_iterator来完成单词和索引的映射。

# 特殊符号
UNK_IDX, PAD_IDX, BOS_IDX, EOS_IDX = 0, 1, 2, 3
special_symbols = ['<unk>', '<pad>', '<bos>', '<eos>']

from torchtext.vocab import build_vocab_from_iterator

for ln in [SRC_LANGUAGE, TGT_LANGUAGE]:
    vocab_transform[ln] = build_vocab_from_iterator(yield_tokens(train_dataset, ln),
                                                    min_freq=1,
                                                    specials=special_symbols,
                                                    special_first=True)
for ln in [SRC_LANGUAGE, TGT_LANGUAGE]:
    vocab_transform[ln].set_default_index(UNK_IDX)
# 测试一下
vocab_transform[ln]['<unk>']
0

开始建房子:模型搭建

下面进入激动人心的环节:搭建一个由Transformer作为核心的Seq2Seq网络!

模型搭建看似复杂,实则是一个先打磨小零件、再组装成大部件、最后整体组装的过程。我们的模型大部件有三个:嵌入层、Transformer模型、输出层。感觉像个工程师或者建筑师呢!

零件的观察

组成每个部件的零件都是由nn.Module作为原型继承而来的神经网络模块,所以我们先观察下nn.Module,分为两个部分:

  1. 在用于初始化的构造函数中,明确有哪些层和每一层的属性(维度、dropout等);
  2. 在前向传播函数中,明确这一模块输入和输出的张量(层与层如何连接起来)。

每次使用一个nn.Module类时(比如nn.Linear),需要先传入参数实例化它(在构造函数中),再用这个层执行前向传播(传入输入得到输出)。

清楚套路之后就撸起袖子干!

# https://pytorch.org/docs/stable/generated/torch.nn.Module.html
import torch.nn as nn
import torch.nn.functional as F

class Model(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 20, 5)
        self.conv2 = nn.Conv2d(20, 20, 5)

    def forward(self, x):
        x = F.relu(self.conv1(x))
        return F.relu(self.conv2(x))

嵌入层

位置编码

为了给输入序列的每个词语加上位置编码信息,需要将“位置编码”添加到编码器和解码器堆栈底部的嵌入中。位置编码和嵌入的维度dmodel相同,所以可以相加。

\[\begin{aligned} P E_{(p o s, 2 i)} & =\sin \left(\text { pos } / 10000^{2 i / d_{\mathrm{model}}}\right) \\ P E_{(p o s, 2 i+1)} & =\cos \left(\text { pos } / 10000^{2 i / d_{\mathrm{model}}}\right) \end{aligned} \]

其中 pos 是位置,i 是维度,d_model=512。

对于每个维度,我们发现PE函数就是关于pos的正余弦函数,只是不同维度的频率不同,且偶数维度是正弦、奇数维度是余弦。为什么使用这样的位置编码?个人理解,好处是维持(0,1)之间的规律性分布,有利于后续训练,且能保证每个位置的位置向量具有唯一性。

import math
pos= 1
for i in range(10):
    print(f'PE({pos},{2*i})={math.sin(pos/(10000**(2*i/512)))}\tPE({pos},{2*i+1})={math.cos(pos/(10000**(2*i/512)))}')
pos= 2
print('-'*60)
for i in range(10):
    print(f'PE({pos},{2*i})={math.sin(pos/(10000**(2*i/512)))}\tPE({pos},{2*i+1})={math.cos(pos/(10000**(2*i/512)))}')
PE(1,0)=0.8414709848078965	PE(1,1)=0.5403023058681398
PE(1,2)=0.8218561900175316	PE(1,3)=0.5696950086931313
PE(1,4)=0.8019617952147853	PE(1,5)=0.5973753250812079
PE(1,6)=0.7818871142367982	PE(1,7)=0.6234200354419579
PE(1,8)=0.761720408471602	PE(1,9)=0.6479058722668407
PE(1,10)=0.7415397964310654	PE(1,11)=0.6709088837606595
PE(1,12)=0.7214141170941294	PE(1,13)=0.6925039145429416
PE(1,14)=0.7014037442363948	PE(1,15)=0.7127641879129212
PE(1,16)=0.6815613503552693	PE(1,17)=0.7317609757987247
PE(1,18)=0.661932619898653	PE(1,19)=0.7495633440304463
------------------------------------------------------------
PE(2,0)=0.9092974268256817	PE(2,1)=-0.4161468365471424
PE(2,2)=0.9364147386330829	PE(2,3)=-0.35089519414026626
PE(2,4)=0.9581443762382829	PE(2,5)=-0.28628544196824235
PE(2,6)=0.9748881849382299	PE(2,7)=-0.22269491881909587
PE(2,8)=0.9870462513484951	PE(2,9)=-0.16043596136428848
PE(2,10)=0.9950112741753455	PE(2,11)=-0.0997625393820518
PE(2,12)=0.9991642001884493	PE(2,13)=-0.04087665668540447
PE(2,14)=0.9998709403194724	PE(2,15)=0.016065575142331992
PE(2,16)=0.9974799976053368	PE(2,17)=0.07094825140380359
PE(2,18)=0.9923208561881375	PE(2,19)=0.12369041342821066

Working of positional encoding in Transformer Neural Networks

下面是 PositionalEncoding 类的实现(及逐行解释):

其中Dropout 层在训练过程中随机将一些输入设置为零,以防止过拟合。

from torch import Tensor
class PositionalEncoding(nn.Module):
    # `emb_size` 表示嵌入向量的维度,`dropout` 表示 dropout 的概率,`maxlen` 表示输入序列的最大长度
    def __init__(self,
                 emb_size: int,
                 dropout: float,
                 maxlen: int = 5000):
        # 调用了父类 nn.Module 的构造函数
        super(PositionalEncoding, self).__init__()
        # 计算位置编码中的分母,`den` 是一个张量,其每个元素的值都是 $10000^{2i/d_{\text{model}}}$
        den = torch.exp(- torch.arange(0, emb_size, 2)* math.log(10000) / emb_size)
        # 生成位置向量 `pos`,它是一个形状为 `(maxlen, 1)` 的张量,表示输入序列中每个位置的位置编号
        pos = torch.arange(0, maxlen).reshape(maxlen, 1)
        #生成位置编码张量 `pos_embedding`
        # 创建一个形状为 `(maxlen, emb_size)` 的全零张量
        pos_embedding = torch.zeros((maxlen, emb_size))
        # 将其中的偶数列填充为 $\sin$ 函数的值,奇数列填充为 $\cos$ 函数的值
        # 这里使用了广播机制,可以将 `pos` 和 `den` 相乘后得到一个形状为 `(maxlen, emb_size/2)` 的张量
        # 然后通过切片操作将其分别赋值给偶数列和奇数列
        pos_embedding[:, 0::2] = torch.sin(pos * den)
        pos_embedding[:, 1::2] = torch.cos(pos * den)
        # 对 `pos_embedding` 进行扩展,将其形状从 `(maxlen, emb_size)` 扩展为 `(maxlen, 1, emb_size)`
        # 为了方便后续与输入张量相加
        pos_embedding = pos_embedding.unsqueeze(-2)
        
        # 分别将 dropout 层和位置编码张量注册为 `PositionalEncoding` 类的属性
        # 需要注意的是,位置编码张量是通过 `register_buffer` 方法注册的,这意味着它不会被当作模型参数来更新
        self.dropout = nn.Dropout(dropout)
        self.register_buffer('pos_embedding', pos_embedding)
    
    
    # 这是 `PositionalEncoding` 类的前向传播方法。它接受一个形状为 `(seq_len, batch_size, emb_size)` 的张量 `token_embedding`
    # 在前向传播过程中,将输入张量与位置编码张量相加,并且对相加结果进行 dropout 操作后返回
    def forward(self, token_embedding: Tensor):
        return self.dropout(token_embedding + self.pos_embedding[:token_embedding.size(0), :]) # 由于位置编码张量的长度是固定的,要根据输入序列的实际长度对其进行切片

我们传入参数看看会得到什么结果。

emb_size = 512
dropout = 0.5
maxlen = 5000
token_embedding = torch.rand((maxlen, 32, emb_size))
pe = PositionalEncoding(emb_size, dropout, maxlen)
pe(token_embedding).shape
torch.Size([5000, 32, 512])

嵌入层

# helper Module to convert tensor of input indices into corresponding tensor of token embeddings
class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, emb_size):
        super(TokenEmbedding, self).__init__()
        self.embedding = nn.Embedding(vocab_size, emb_size)
        self.emb_size = emb_size

    def forward(self, tokens: Tensor):
        return self.embedding(tokens.long()) * math.sqrt(self.emb_size)

总结

这个小项目今天才刚开始,我们只进行到准备阶段。不过第一天尝试的感觉是,NLP工程师的建模过程就像建房子一样好玩(我小时候梦想之一就是设计房子),每个模块有特定功能,整体组合起来和谐统一。明天的任务是设计模型的编码器-解码器架构,期待!

代码参考

JasonFengGit/Neural-Model-Translation: A Chinese to English Neural Model Translation Project (github.com)

https://pytorch.org/tutorials/beginner/translation_transformer.html