文本生成任务

主要讨论

  • 文本生成的方法:inference

  • 增加文本生成的多样性:variational auto encoder

  • 可以控制的文本生成、文本风格迁移

  • Generative Adversarial Networks

  • Data to text

log loss:

  • [s1, s2, …, s_n] –> softmax(s) = exp(s_i) / sum_i exp(s_i)

  • p_i log q_i

关于文本生成

之前的课程中,我们主要讨论了Natural Language Understanding,也就是给你一段文字,如何从各个方面去理解它。常见的NLU任务有:文本分类,情感分类,命名实体识别(Named Entity Recognition, NER),Relation Extraction等等。也就是说,从文字中提取出我们想要了解的关键信息。

这节课我们来讨论文本生成的一些方法。

对于文本生成,我们关心哪些问题?

  • 与文本理解相反,我们有一些想要表达的信息,这些信息可能来自于对话的历史,可能来自于结构化的数据 (structured data, data-to-text generation)。现在我们要考虑的是如何把这些我们想要表达的信息转换成自然语言的方式。这一任务在构建聊天机器人中显得尤为重要。目前看来,基于模板 (template) 的方法仍然是最保险的,但是在研究领域中,人们越来越关注基于神经网络的文本生成方法

  • 基于上文的文本补全任务,故事生成,生成式聊天机器人

  • 人们一直希望计算机可以完成一些人类才可以完成的创造性任务,例如作画。AI作画实际上已经不是什么新闻了,Portrait of Edmond de Belamy,一幅AI创作的画像,拍卖出了43.2万美金的高价。

  • 那么AI能不能写文章讲故事呢?关于文本生成的研究相对来说没有特别客观的评价指标,所以很多时候人们会按照自己的主观评价来判断模型的好坏。例如给定故事的上文,AI系统能不能很好地补全这个故事呢?

  • 文本补全这个任务本质上就是训练一个语言模型,当然也有人尝试使用Seq2Seq的方法做文本生成。目前看来最强的模型是基于GPT-2预训练的语言模型。很多研究者使用GPT-2来进行文本生成相关的实验。由于训练GPT-2这样规模的语言模型需要大量的算力和数据资源,所以大部分的研究都关注在如何使用模型,也就是inference的步骤,而不在于模型的训练环节。

Greedy Decoding

autoregressive: 基于之前生成的文字来生成后续的文字?

P(y_i | y_1, … y_{i-1})

parallel generation

大部分基于神经网络的文本生成模型采用的是一种条件语言模型的方法,也就是说,我们有一些先决条件,例如 auto encoder 中的隐向量,然后我们基于这个隐向量来生成句子。

大部分语言模型的基本假设是从左往右的条件概率模型,也就是说,给定了单词1至n-1,我们希望生成第n个单词。假设我们现在采用一个基于LSTM的语言模型,在当前第i个位置上,我们预测下一个生成单词的概率分布为 p = (p_1, p_2, … p_|V|),那么在当前位置上我们应该生成什么单词呢?

argmax_i p_i = 2

一个最简单的方法是使用Greedy Decoding,也就是说,我们直接采用 argmax_i (p_i) 即可。当然,同学们很容易联想到,这种decoding的方法是有问题的,因为每次都选择最大概率的单词并不能保证我们生成出来的句子的总体概率分布是最大的。事实上,大部分时候这样生成的句子其实是不好的。然而我们没有办法遍历所有可能的句子:首先句子的长度是不确定的;即使我们假定自己知道句子的长度 l,如果在每个位置上考虑每个可能的单词,我们需要考虑 |V|^l 种可能的情况,在计算资源上也是不现实的。

一种妥协的方法是采用 Beam Searchhttps://shimo.im/docs/rHwdq8wd8txyXjP6)。也就是说,在decoding的每个步骤,我们都保留着 top K 个可能的候选单词,然后到了下一个步骤的时候,我们对这 K 个单词都做下一步 decoding,分别选出 top K,然后对这 K^2 个候选句子再挑选出 top K 个句子。以此类推一直到 decoding 结束为止。当然 Beam Search 本质上也是一个 greedy decoding 的方法,所以我们无法保证自己一定可以得到最好的 decoding 结果。

p(x_1, x_2, …, x_n) = log (p(x_1) * p(x_2 | x_1) … p(x_n | x_1, …, x_{n-1})) / n

Greedy Decoding的问题

  • 容易出现很无聊的回答:I don’t know.

  • 容易重复自己:I don’t know. I don’t know. I don’t know. I don’t know. I don’t know. I don’t know.

  • Beam search K = 200

Sampling

argmax 不一定是最好的

vocab(y_i) = [0.9, 0.05, 0.01, 0.01, 0.01, …., 0.01] softmax(logits/temperature)

sample(vocab(y_i))

sample很多个句子,然后用另一个模型来打分,找出最佳generated text

sampling over the full vocabulary:我们可以在生成文本的时候引入一些随机性。例如现在语言模型告诉我们下一个单词在整个单词表上的概率分布是 p = (p_1, p_2, … p_|V|),那么我们就可以按照这个概率分布进行随机采样,然后决定下一个单词生成什么。采样相对于greedy方法的好处是,我们生成的文字开始有了一些随机性,不会总是生成很机械的回复了。

1 - 0.98^n

Sampling的问题

  • 生成的话容易不连贯,上下文比较矛盾。

  • 容易生成奇怪的话,出现罕见词

top-k sampling 可以缓解生成罕见单词的问题。比如说,我们可以每次只在概率最高的50个单词中按照概率分布做采样。

我只保留top-k个probability的单词,然后在这些单词中根据概率做sampling

Neucleus Sampling

The Curious Case of Neural Text Degeneration

https://arxiv.org/pdf/1904.09751.pdf

img

这篇文章在前些日子引起了不小的关注。文章提出了一种做sampling的方法,叫做 Neucleus Sampling。

Neucleus Sampling的基本思想是,我们不做beam search,而是做top p sampling。

设置一个threshold,p=0.95

top-k sampling 和 neucleus sampling 的代码:https://gist.github.com/thomwolf/1a5a29f6962089e871b94cbd09daf317

Variational Auto Encoder (VAE)

Auto Encoder 自编码器

NLP中的一个重要问题是获得一种语言的表示,无论是单词的表示还是句子的表示。为了获得句子的表示,一种直观的思路是训练一个auto encoder,也就是说一个encoder用来编码一个句子,把一个句子转换成一个vector;另一个decoder用来解码一个句子,也就是说把一个vector解码成一个句子。auto encoder 事实上是一种数据压缩的方法。

Encoder(text) –> vector

Decoder(vector) –> text

Encoder:得到很好的文本表示,这个文本表示你可用用于任何其他的任务。

Decoder: conditional language model

generalize能力不一定好。过拟合。

预期:希望类似的句子,能够变成比较相近的vector。不类似的句子,能够距离比较远。

Decoder(0,200,-23, 122) –> text?

我爱[MASK]然语[MASK]处理 –> vector –> 我爱自然语言处理

在 auto encoder 的基础上又衍生出了各种类型的 auto encoder,例如 denoising auto encoder (https://www.cs.toronto.edu/~larocheh/publications/icml-2008-denoising-autoencoders.pdf)。denoising auto encoder 的基本思想是要加强 auto encoder 的 robustness。也就是说,我们希望把输入句子的一部分给“污染” (corrupt) 了,但是我们希望在经过编码和解码的过程之后,我们能够得到原来的正确的句子。事实上 BERT 的 masking 就是一种“污染”的手段。

Encoder(corrupt(text)) –> vector

Decoder(vector) –> text

随机产生一个vector –> decoder –> 生成一个句子

mapping

N(0, 1) –> 各种各样的文字

从一个分布去生成一些东西

为了训练出可以用来sample文字的模型,人们发明了variational auto encoder (VAE)。VAE与普通auto encoder的不同之处在于,我们添加了一个constraint,希望encoder编码的每个句子都能够局限在某些特定的位置。例如,我们可以要求每个句子的encoding在空间上满足一个多维标准高斯分布。

vector ~ N(0, 1)

什么是VAE?

网上有很多VAE的论文,博客,建议感兴趣的同学可以选择性阅读。我们这节课不会讨论太多的数学公式,而是从比较high level的层面介绍一下VAE模型以及它所解决的一些问题。

简单来说,VAE本质上是一种生成模型,我们希望能够通过隐向量z生成数据样本x。在文本生成的问题中,这个x往往表示的是一些文本/句子等内容。

img

下面是 Kingma 在 VAE 论文中定义的优化目标。

文本–> 向量表示 –> 文本

auto encoder: sentence –> vector –> sentence

Loss = -log P_{sentence}(dec(enc(sentence)))

img

z -> z’ -> decoder(z) –> 一个句子

crossentropyloss(decoder(encoder(x)), x)

我们对z没有任何的约束条件

q: encoder

p: decoder

KL divergence: 计算两个概率分布的差值

z: 把句子变成一个概率分布

z: (\mu, \sigma) –> 正态分布的参数

用z做采样

KL Divergence的定义

img

sampling

N(0,1): sampling: 0.1, 0.05, 0.2, -0.1, -100

我们可以发现,VAE模型本质上就是要最大化样本的生成概率,并且最小化样本encode之后的参数表示与某种分布(正态分布)的KL散度。之所以我们会限制数据被编码后的向量服从某个局部的正态分布,是因为我们不希望这些数据被编码之后杂乱地散布在一个空间上,而是希望信息能够得到一定程度上的压缩。之所以让他们服从一个分布而不是一些固定的值,是因为我们希望模型中能够有一些随机性,好让模型的解码器能够生成各种各样的句子。

有了这个VAE模型的架构之后,人们就可以在各种任务上玩出各种不同的花样了。

例如对于图像来说,这里的imgimg可能是CNN模型,对于自然语言来说,它们可能是一些RNN/LSTM之类的模型。

下面我们来看一些VAE在NLP领域的具体模型。

Generating Sentences from a Continuous Space

https://arxiv.org/pdf/1511.06349.pdf

img

img

从上图可以看到,这篇论文的思路非常简单,就是把一个句子用RNN编码起来,编码之后得到的隐向量输出两个信息\mu和\simga,分别表示一个正太分布的平均值和标准差。然后这个分布应该尽可能地接近标准正态分布,在KL散度的表示下。并且如果我们用这个分布去采样得到新的向量表示,那么decoder应该要尽可能好地复原我们原来的这个句子。

具体的实验细节我们就不展开了,但是我们看一些论文中展示的生成的句子。

img

下面看看VAE当中编码的空间是否具有某种连续性。

img

代码阅读:

  • https://github.com/timbmg/Sentence-VAE/blob/master/model.py

  • 练习:这份代码已经一年多没有更新了,感兴趣的同学可以把它更新到最新版本的PyTorch上,作为写代码练习,并且在自己的数据集上做一些实验,看看能否得到与论文中类似的效果(sentence interpolation)。

GAN: generative adversarial networks

  • generator: G(z) –> x 一张逼真的汽车照片

  • discriminator: D(x) –> 这个到底是不是一张汽车的照片 二分类

Discriminator的目标

D(G(z)) –> False

D(true photo) –> True

Generator 的目标 D(G(z)) –> True

可控制的文本生成

Toward Controlled Generation of Text

https://arxiv.org/pdf/1703.00955.pdf

  • Controlled Text Generation: 控制生成文本的一些特征

  • Learning disentangled latent representations: 对于文本不同的特征有不同的向量表示

模型

img

To model and control the attributes of interest in an interpretable way, we augment the unstructured variables z with a set of structured variables c each of which targets a salient and independent semantic feature of sentences.

这篇文章试图解决这样一个问题,能不能把一句话编码成几个向量(z和c)。z和c分别包含了一些不同的关于句子的信息。

img

模型包含几个部分,一个generator可以基于若干个向量(z和c)生成句子,几个encoder可以从句子生成z和c的分布,几个discriminator用来判断模型编码出的向量(c)是否符合example的正确分类。这个模型的好处是,我们在某种程度上分离了句子的信息。例如如果向量c用来表示的是句子的情感正负,那么模型就具备了生成正面情感的句子和负面情感句子的能力。

img

参考代码

https://github.com/wiseodd/controlled-text-generation

更多阅读

VAE论文:Auto-Encoding Variational Bayes https://arxiv.org/pdf/1312.6114.pdf

An Introduction to Variational Autoencoders https://arxiv.org/pdf/1906.02691.pdf

Stype Transfer

文本 –> 内容z,风格c

z, 换一个风格c’ –> 同样内容,不同风格的文本

文本生成的应用:文本风格迁移

Style Transfer from Non-Parallel Text by Cross-Alignment

论文:https://papers.nips.cc/paper/7259-style-transfer-from-non-parallel-text-by-cross-alignment.pdf

代码:https://github.com/shentianxiao/language-style-transfer/blob/master/code/style_transfer.py

style transfer 其实也是controlled text generation的一种,只是它control的是文本的风格。文本风格有很多种,例如情感的正负面,文章是随意的还是严肃的。

img

img

一个很好的repo,总结了文本风格迁移领域的paper

https://github.com/fuzhenxin/Style-Transfer-in-Text

Generative Adversarial Networks (GAN) 在NLP上的应用

最早Ian Goodfellow的关于GAN的文章,其基本做法就是一个generator和一个discriminator(辅助角色),然后让两个模型互相竞争对抗,在对抗的过程中逐渐提升各自的模型能力。而其中的generator就是我们希望能够最终optimize并且被拿来使用的模型。

早期GAN主要成功应用都在于图像领域。其关键原因在于,图像的每个像素都是三个连续的RGB数值。discriminator如果给图像计算一个概率分数,当我们在优化generator希望提高这个分数的时候,我们可以使用Back Propagation算法计算梯度,然后做梯度上升/下降来完成我们想要优化的目标。

discriminator: 二分类问题 图片–>分类

D(G(z)) –> cross entropyloss –> backprop 到generator

文本–>

LSTM –> P_vocab() –> argmax 文字 –> discriminator

LSTM –> P_vocab() –> discriminator

而文本生成是一个不同的问题,其特殊之处在于我们在做文本生成的时候有一步argmax的操作,也就是说当我们做inference生成文字的时候,在输出层使用了argmax或者sampling的操作。当我们把argmax或者sampling得到的文字传给discriminator打分的时候,我们无法用这个分数做back propagation对生成器做优化操作。

真正的sample –> one hot vector ([1, 0, 0, 0, 0, 0])

预测一个输出单词的时候:([0.8, 0.1, 0, 0.05, 0, 0.05]) –> gumbel_softmax –> discriminator判断一下

为了解决这个问题,人们大致走了两条路线,一条是将普通的argmax转变成可导的Gumbel-softmax,然后我们就可以同时优化generator和discriminator了。

预测一个输出单词的时候:([0.8, 0.1, 0, 0.05, 0, 0.05]) –> gumbel_softmax –> discriminator判断一下

https://arxiv.org/pdf/1611.04051.pdf

https://www.zhihu.com/question/62631725

另外一种方法是使用Reinforcement Learning中的Policy Gradient来估算模型的gradient,并做优化。

根据当前的policy来sample steps。

NLP: policy就是我们的语言模型,也就是说根据当前的hidden state, 决定我下一步要生成什么单词。

P_vocab –> argmax

P_vocab –> sampling

backpropagation –> 没有办法更新模型

文本翻译 – 优化BLEU?

训练? cross entropy loss

policy gradient直接优化BLEU

可以不可以找个方法估算gradient。

Policy: 当前执行的策略,在文本生成模型中,这个Policy一般就是指我们的decoder(LSTM)

Policy Gradient: 根据当前的policy执行任务,然后得到reward,并估算每个参数的gradient, SGD

这里就涉及到一些Reinforcement Learning当中的基本知识。我们可以认为一个语言模型,例如LSTM,是在做一连串连续的决策。每一个decoding的步骤,每个hidden state对应一个状态state,每个输出对应一个observation。如果我们每次输出一个文字的时候使用sampling的方法,Reinforcement Learning有一套成熟的算法可以帮助我们估算模型的梯度,这种算法叫做policy gradient。如果采用这种方法,我们也可以对模型进行优化。

https://arxiv.org/pdf/1609.05473.pdf

这一套policy gradient的做法在很多文本生成(例如翻译,image captioning)的优化问题上也经常见到。

翻译:优化BLEU

Improved Image Captioning via Policy Gradient optimization of SPIDEr

http://openaccess.thecvf.com/content_ICCV_2017/papers/Liu_Improved_Image_Captioning_ICCV_2017_paper.pdf

还有一些方法是,我们不做最终的文本采样,我们直接使用模型输出的在单词表上的输出分布,或者是使用LSTM中的一些hidden vector来传给discriminator,并直接优化语言模型。

我个人的看法是GAN在文本生成上的作用大小还不明确,一部分原因在于我们没有一种很好的机制去评估文本生成的好坏。我们看到很多论文其实对模型的好坏没有明确的评价,很多时候是随机产生几个句子,然后由作者来评价一下生成句子的好坏。

Data-to-text

img

  • Content selection: 选择什么数据需要进入到我们的文本之中

  • Sentence planning: 决定句子的结构

  • Surface realization: 把句子结构转化成具体的字符串

img

问题定义

  • 输入: A table of records。每个record包含四个features: type, entity, value, home or away

  • 输出: 一段文字描述

相关资料

https://github.com/Morde-kaiser/LearningNotes/blob/master/GAN-Overview-Chinese.pdf

William Wang关于GAN in NLP的slides: http://sameersingh.org/files/ppts/naacl19-advnlp-part1-william-slides.pdf

这篇博文也讲的很好

https://zhuanlan.zhihu.com/p/29168803

参考该知乎专栏文章 https://zhuanlan.zhihu.com/p/36880287

BERT&ELMo&co

【译】The Illustrated BERT, ELMo, and co.

ELMo: Contextualized Word Vectors

本文由Adam Liu授权转载,源链接 https://blog.csdn.net/qq_41664845/article/details/84787969

原文链接:The Illustrated BERT, ELMo, and co. (How NLP Cracked Transfer Learning)

作者:Jay Alammar

修改:褚则伟 zeweichu@gmail.com

BERT论文地址:BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding https://arxiv.org/abs/1810.04805

前言

2018年可谓是自然语言处理(NLP)的元年,在我们如何以最能捕捉潜在语义关系的方式 来辅助计算机对的句子概念性的理解 这方面取得了极大的发展进步。此外, NLP领域的一些开源社区已经发布了很多强大的组件,我们可以在自己的模型训练过程中免费的下载使用。(可以说今年是NLP的ImageNet时刻,因为这和几年前计算机视觉的发展很相似)

img

上图中,最新发布的BERT是一个NLP任务的里程碑式模型,它的发布势必会带来一个NLP的新时代。BERT是一个算法模型,它的出现打破了大量的自然语言处理任务的记录。在BERT的论文发布不久后,Google的研发团队还开放了该模型的代码,并提供了一些在大量数据集上预训练好的算法模型下载方式。Goole开源这个模型,并提供预训练好的模型,这使得所有人都可以通过它来构建一个涉及NLP的算法模型,节约了大量训练语言模型所需的时间,精力,知识和资源。

img

BERT集成了最近一段时间内NLP领域中的一些顶尖的思想,包括但不限于 Semi-supervised Sequence Learning (by Andrew Dai and Quoc Le), ELMo (by Matthew Peters and researchers from AI2 and UW CSE), ULMFiT (by fast.ai founder Jeremy Howard and Sebastian Ruder), and the OpenAI transformer (by OpenAI researchers Radford, Narasimhan, Salimans, and Sutskever), and the Transformer (Vaswani et al).。

你需要注意一些事情才能恰当的理解BERT的内容,不过,在介绍模型涉及的概念之前可以使用BERT的方法。

示例:句子分类

使用BERT最简单的方法就是做一个文本分类模型,这样的模型结构如下图所示:

img

为了训练一个这样的模型,(主要是训练一个分类器),在训练阶段BERT模型发生的变化很小。该训练过程称为微调,并且源于 Semi-supervised Sequence Learning 和 ULMFiT.。

为了更方便理解,我们下面举一个分类器的例子。分类器是属于监督学习领域的,这意味着你需要一些标记的数据来训练这些模型。对于垃圾邮件分类器的示例,标记的数据集由邮件的内容和邮件的类别2部分组成(类别分为“垃圾邮件”或“非垃圾邮件”)。

模型架构

现在您已经了解了如何使用BERT的示例,让我们仔细了解一下他的工作原理。

img

BERT的论文中介绍了2种版本:

  • BERT BASE - 与OpenAI Transformer的尺寸相当,以便比较性能

  • BERT LARGE - 一个非常庞大的模型,它完成了本文介绍的最先进的结果。

BERT的基础集成单元是Transformer的Encoder。关于Transformer的介绍可以阅读作者之前的文章:The Illustrated Transformer,该文章解释了Transformer模型 - BERT的基本概念以及我们接下来要讨论的概念。

2个BERT的模型都有一个很大的编码器层数,(论文里面将此称为Transformer Blocks) - 基础版本就有12层,进阶版本有24层。同时它也有很大的前馈神经网络( 768和1024个隐藏层神经元),还有很多attention heads(12-16个)。这超过了Transformer论文中的参考配置参数(6个编码器层,512个隐藏层单元,和8个注意头)

模型输入

输入的第一个字符为[CLS],在这里字符[CLS]表达的意思很简单 - Classification (分类)。

BERT与Transformer 的编码方式一样。将固定长度的字符串作为输入,数据由下而上传递计算,每一层都用到了self attention,并通过前馈神经网络传递其结果,将其交给下一个编码器。

img

这样的架构,似乎是沿用了Transformer 的架构(除了层数,不过这是我们可以设置的参数)。那么BERT与Transformer 不同之处在哪里呢?可能在模型的输出上,我们可以发现一些端倪。

模型输出

每个位置返回的输出都是一个隐藏层大小的向量(基本版本BERT为768)。以文本分类为例,我们重点关注第一个位置上的输出(第一个位置是分类标识[CLS]) 。如下图

该向量现在可以用作我们选择的分类器的输入,在论文中指出使用单层神经网络作为分类器就可以取得很好的效果。原理如下:

img

例子中只有垃圾邮件和非垃圾邮件,如果你有更多的label,你只需要增加输出神经元的个数即可,另外把最后的激活函数换成softmax即可。

Parallels with Convolutional Nets(BERT VS卷积神经网络)

对于那些具有计算机视觉背景的人来说,这个矢量切换应该让人联想到VGGNet等网络的卷积部分与网络末端的完全连接的分类部分之间发生的事情。你可以这样理解,实质上这样理解也很方便。

img

词嵌入的新时代〜

BERT的开源随之而来的是一种词嵌入的更新。到目前为止,词嵌入已经成为NLP模型处理自然语言的主要组成部分。诸如Word2vec和Glove 等方法已经广泛的用于处理这些问题,在我们使用新的词嵌入之前,我们有必要回顾一下其发展。

Word Embedding Recap

为了让机器可以学习到文本的特征属性,我们需要一些将文本数值化的表示的方式。Word2vec算法通过使用一组固定维度的向量来表示单词,计算其方式可以捕获到单词的语义及单词与单词之间的关系。使用Word2vec的向量化表示方式可以用于判断单词是否相似,对立,或者说判断“男人‘与’女人”的关系就如同“国王”与“王后”。(这些话是不是听腻了〜 emmm水文必备)。另外还能捕获到一些语法的关系,这个在英语中很实用。例如“had”与“has”的关系如同“was”与“is”的关系。

这样的做法,我们可以使用大量的文本数据来预训练一个词嵌入模型,而这个词嵌入模型可以广泛用于其他NLP的任务,这是个好主意,这使得一些初创公司或者计算资源不足的公司,也能通过下载已经开源的词嵌入模型来完成NLP的任务。

ELMo:语境问题

上面介绍的词嵌入方式有一个很明显的问题,因为使用预训练好的词向量模型,那么无论上下文的语境关系如何,每个单词都只有一个唯一的且已经固定保存的向量化形式“。Wait a minute “ - 出自(Peters et. al., 2017, McCann et. al., 2017, and yet again Peters et. al., 2018 in the ELMo paper )

“ Wait a minute ”这是一个欧美日常梗,示例:

​ 我:兄弟,你认真学习深度,没准能拿80W年薪啊。

​ 你:Wait a minute,这么好,你为啥不做。

这和中文的同音字其实也类似,用这个举一个例子吧, ‘长’ 这个字,在 ‘长度’ 这个词中表示度量,在 ‘长高’ 这个词中表示增加。那么为什么我们不通过”长’周围是度或者是高来判断它的读音或者它的语义呢?嗖嘎,这个问题就派生出语境化的词嵌入模型。

img

EMLo改变Word2vec类的将单词固定为指定长度的向量的处理方式,它是在为每个单词分配词向量之前先查看整个句子,然后使用bi-LSTM来训练它对应的词向量。

img

ELMo为解决NLP的语境问题作出了重要的贡献,它的LSTM可以使用与我们任务相关的大量文本数据来进行训练,然后将训练好的模型用作其他NLP任务的词向量的基准。

ELMo的秘密是什么?

ELMo会训练一个模型,这个模型接受一个句子或者单词的输入,输出最有可能出现在后面的一个单词。想想输入法,对啦,就是这样的道理。这个在NLP中我们也称作Language Modeling。这样的模型很容易实现,因为我们拥有大量的文本数据且我们可以在不需要标签的情况下去学习。

img

上图介绍了ELMo预训练的过程的步骤的一部分:

我们需要完成一个这样的任务:输入“Lets stick to”,预测下一个最可能出现的单词,如果在训练阶段使用大量的数据集进行训练,那么在预测阶段我们可能准确的预测出我们期待的下一个单词。比如输入“机器”,在‘’学习‘和‘买菜’中它最有可能的输出会是‘学习’而不是‘买菜’。

从上图可以发现,每个展开的LSTM都在最后一步完成预测。

对了真正的ELMo会更进一步,它不仅能判断下一个词,还能预测前一个词。(Bi-Lstm)

img

ELMo通过下图的方式将hidden states(的初始的嵌入)组合咋子一起来提炼出具有语境意义的词嵌入方式(全连接后加权求和)

img

ELMo pretrained embedding可以在AllenNLP的repo下找到

https://github.com/allenai/allennlp/blob/master/tutorials/how_to/elmo.md

顺便说一下AllenNLP有个非常不错的关于NLP的教程

https://github.com/allenai/writing-code-for-nlp-research-emnlp2018

ELMo的几位作者都是NLP圈内的知名人士

更多ELMo的模型图片

img

图片来源(https://tsenghungchen.github.io/posts/elmo/)

img

图片来源(https://www.mihaileric.com/posts/deep-contextualized-word-representations-elmo/)

ULM-FiT:NLP领域应用迁移学习

ULM-FiT机制让模型的预训练参数得到更好的利用。所利用的参数不仅限于embeddings,也不仅限于语境embedding,ULM-FiT引入了Language Model和一个有效微调该Language Model来执行各种NLP任务的流程。这使得NLP任务也能像计算机视觉一样方便的使用迁移学习。

The Transformer:超越LSTM的结构

Transformer论文和代码的发布,以及其在机器翻译等任务上取得的优异成果,让一些研究人员认为它是LSTM的替代品,事实上却是Transformer比LSTM更好的处理long-term dependancies(长程依赖)问题。Transformer Encoding和Decoding的结构非常适合机器翻译,但是怎么利用他来做文本分类的任务呢?实际上你只用使用它来预训练可以针对其他任务微调的语言模型即可。

OpenAI Transformer:用于语言模型的Transformer解码器预训练

事实证明,我们并不需要一个完整的transformer结构来使用迁移学习和一个很好的语言模型来处理NLP任务。我们只需要Transformer的解码器就行了。The decoder is a good choice because it’s a natural choice for language modeling (predicting the next word) since it’s built to mask future tokens – a valuable feature when it’s generating a translation word by word.

img

该模型堆叠了十二个Decoder层。 由于在该设置中没有Encoder,因此这些Decoder将不具有Transformer Decoder层具有的Encoder - Decoder attention层。 然而,取而代之的是一个self attention层(masked so it doesn’t peak at future tokens)。

通过这种结构调整,我们可以继续在相似的语言模型任务上训练模型:使用大量的未标记数据集训练,来预测下一个单词。举个列子:你那7000本书喂给你的模型,(书籍是极好的训练样本~比博客和推文好很多。)训练框架如下:

img

Transfer Learning to Downstream Tasks

通过OpenAI的transformer的预训练和一些微调后,我们就可以将训练好的模型,用于其他下游NLP任务啦。(比如训练一个语言模型,然后拿他的hidden state来做分类。),下面就介绍一下这个骚操作。(还是如上面例子:分为垃圾邮件和非垃圾邮件)

img

OpenAI论文概述了许多Transformer使用迁移学习来处理不同类型NLP任务的例子。如下图例子所示:

img

BERT: From Decoders to Encoders

OpenAI transformer为我们提供了基于Transformer的精密的预训练模型。但是从LSTM到Transformer的过渡中,我们发现少了些东西。ELMo的语言模型是双向的,但是OpenAI的transformer是前向训练的语言模型。我们能否让我们的Transformer模型也具有Bi-Lstm的特性呢?

R-BERT:“Hold my beer”

Masked Language Model

BERT说:“我要用 transformer 的 encoders”

Ernie不屑道:“呵呵,你不能像Bi-Lstm一样考虑文章”

BERT自信回答道:“我们会用masks”

解释一下Mask:

语言模型会根据前面单词来预测下一个单词,但是self-attention的注意力只会放在自己身上,那么这样100%预测到自己,毫无意义,所以用Mask,把需要预测的词给挡住。

如下图:

img

Two-sentence Tasks

我们回顾一下OpenAI transformer处理不同任务的输入转换,你会发现在某些任务上我们需要2个句子作为输入,并做一些更为智能的判断,比如是否相似,比如 给出一个维基百科的内容作为输入,同时在放入一条针对该条目的问题,那么我们的算法模型能够处理这个问题吗?

为了使BERT更好的处理2个句子之间的关系,预训练的过程还有一个额外的任务:给定2个句子(A和B),A与B是否相似?(0或者1)

特殊NLP任务

BERT的论文为我们介绍了几种BERT可以处理的NLP任务:

  1. 短文本相似

  2. 文本分类

  3. QA机器人

  4. 语义标注

img

BERT用做特征提取

微调方法并不是使用BERT的唯一方法,就像ELMo一样,你可以使用预选训练好的BERT来创建语境化词嵌入。然后你可以将这些嵌入提供给现有的模型。

img

哪个向量最适合作为上下文嵌入? 我认为这取决于任务。 本文考察了六种选择(与微调模型相比,得分为96.4):

img

如何使用BERT

使用BERT的最佳方式是通过 BERT FineTuning with Cloud TPUs (https://colab.research.google.com/github/tensorflow/tpu/blob/master/tools/colab/bert_finetuning_with_cloud_tpus.ipynb) 谷歌云上托管的笔记。如果你未使用过谷歌云TPU可以试试看,这是个不错的尝试。另外BERT也适用于TPU,CPU和GPU

下一步是查看BERT仓库中的代码:

  1. 该模型在modeling.py(BertModel类)中构建,与vanilla Transformer编码器完全相同。

  2. run_classifier.py是微调过程的一个示例。它还构建了监督模型的分类层。如果要构建自己的分类器,请查看该文件中的create_model()方法。

  3. 可以下载几种预先训练的模型。涵盖102种语言的多语言模型,这些语言都是在维基百科的数据基础上训练而成的。

  4. BERT不会将单词视为tokens。相反,它注重WordPieces。 tokenization.py是将你的单词转换为适合BERT的wordPieces的tokensizer。

我自己给BERT的代码增加了一些注解

https://github.com/ZeweiChu/bert/blob/master/modeling.py

重点关注其中的:

BERT的很多任务基于GLUE benchmark

https://gluebenchmark.com/tasks/

https://openreview.net/pdf?id=rJ4km2R5t7

最近还有一个SuperGLUE

https://w4ngatang.github.io/static/papers/superglue.pdf

您还可以查看BERT的PyTorch实现 (https://github.com/huggingface/pytorch-transformers)。 AllenNLP库使用此实现允许将BERT嵌入与任何模型一起使用。

最近NVIDIA开源了他们53分钟训练BERT的代码

https://github.com/NVIDIA/DeepLearningExamples/tree/master/PyTorch/LanguageModeling/BERT


参考文献

BERT全文翻译成中文

https://zhuanlan.zhihu.com/p/59775981

图解 BERT 模型:从零开始构建 BERT

https://flashgene.com/archives/20062.html

NLP必读:十分钟读懂谷歌BERT模型

https://zhuanlan.zhihu.com/p/51413773

BERT Explained: State of the art language model for NLP

https://towardsdatascience.com/bert-explained-state-of-the-art-language-model-for-nlp-f8b21a9b6270

大规模无监督预训练语言模型与应用上

Subword Modeling

以单词作为模型的基本单位有一些问题:

  • 单词量有限,我们一般会把单词量固定在50k-300k,然后没有见过的单词只能用UNK表示

  • zipf distribution: given some corpus of natural language utterances, the frequency of any word is inversely proportional to its rank in the frequency table. Thus the most frequent word will occur approximately twice as often as the second most frequent word, three times as often as the third most frequent word, etc.: the rank-frequency distribution is an inverse relation.

  • 模型参数量太大,100K * 300 = 30M个参数,仅仅是embedding层

  • 对于很多语言,例如英语来说,很多时候单词是由几个subword拼接而成的

  • 对于中文来说,很多常用的模型会采用分词后得到的词语作为模型的基本单元,同样存在上述问题

可能的解决方案:

  • 使用subword information,例如字母作为语言的基本单元 Char-CNN

  • 用wordpiece

解决方案:character level modeling

  • 使用字母作为模型的基本输入单元

Ling et. al, Finding Function in Form: Compositional Character Models for Open Vocabulary Word Representation

用BiLSTM把单词中的每个字母encode到一起

img

Yoon Kim et. al, Character-Aware Neural Language Models

img

根据以上模型示意图思考以下问题:

  • character emebdding的的维度是多少?4

  • 有几个character 4-gram的filter?filter-size=4? 红色的 5个filter

  • max-over-time pooling: 3-gram 4维, 2-gram 3维 4-gram 55维

  • 为什么不同的filter (kernel size)长度会导致不同长度的feature map? seq_length - kernel_size + 1

fastText

  • 与word2vec类似,但是每个单词是它的character n-gram embeddings + word emebdding

解决方案:使用subword作为模型的基本单元

Botha & Blunsom (2014): Composional Morphology for Word Representations and Language Modelling

img

subword embedding

img

Byte Pair Encoding (需要知道什么是BPE)

Neural Machine Translation of Rare Words with Subword Units

关于什么是BPE可以参考下面的文章

https://www.cnblogs.com/huangyc/p/10223075.html

https://leimao.github.io/blog/Byte-Pair-Encoding/

  • 首先定义所有可能的基本字符(abcde…)

  • 然后开始循环数出最经常出现的pairs,加入到我们的候选字符(基本组成单元)中去

a, b, c, d, …, z, A, B, …., Z.. !, @, ?, st, est, lo, low,

控制单词表的大小

  • 我只要确定iteration的次数 30000个iteartion,30000+原始字母表当中的字母数 个单词

happiest

h a p p i est

LSTM

emb(h), emb(a), emb(p), emb(p), emb(i), emb(est)

happ, iest

emb(happ), emb(iest)

img

https://www.aclweb.org/anthology/P16-1162.pdf

中文词向量

Meng et. al, Is Word Segmentation Necessary for Deep Learning of Chinese Representations?

简单来说,这篇文章的作者生成通过他们的实验发现Chinese Word Segmentation对于语言模型、文本分类,翻译和文本关系分类并没有什么帮助,直接使用单个字作为模型的输入可以达到更好的效果。

We benchmark neural word-based models which rely on word segmentation against neural char-based models which do not involve word segmentation in four end-to-end NLP benchmark tasks: language modeling, machine translation, sentence matching/paraphrase and text classification. Through direct comparisons between these two types of models, we find that charbased models consistently outperform wordbased models.

word-based models are more vulnerable to data sparsity and the presence of out-of-vocabulary (OOV) words, and thus more prone to overfitting

Jiwei Li

https://nlp.stanford.edu/~bdlijiwei/

中文分词工具

建议同学们可以在自己的项目中尝试以下工具

预训练句子/文档向量

既然有词向量,那么我们是否可以更进一步,把句子甚至一整个文档也编码成一个向量呢?

在之前的课程中我们已经涉及到了一些句子级别的任务,例如文本分类,常常就是把一句或者若干句文本分类成一定的类别。此类模型的一般实现方式是首先把文本编码成某种文本表示方式,例如averaged word embeddings,或者双向LSTM头尾拼接,或者CNN模型等等。

文本分类

  • 文本通过某种方式变成一个向量

  • WORDAVG

  • LSTM

  • CNN

  • 最后是一个linear layer 300维句子向量 –》 2 情感分类

猫图片/狗图片

图片 –> ResNet –> 2048维向量 –> (2, 2048) –> 2维向量 binary cross entropy loss

ResNet 预训练模型

文本 –> TextResNet –> 2048维向量

apply to any downstream tasks

TextResNet:LSTM模型

不同的任务(例如不同的文本分类:情感分类,话题分类)虽然最终的输出不同,但是往往拥有着相似甚至完全一样的编码层。如果我们能够预训练一个非常好的编码层,那么后续模型的负担就可以在一定程度上得到降低。这样的思想很多是来自图像处理的相关工作。例如人们在各类图像任务中发现,如果使用在ImageNet上预训练过的深层CNN网络(例如ResNet),只把最终的输出层替换成自己需要的样子,往往可以取得非常好的效果,且可以在少量数据的情况下训练出优质的模型。

在句子/文本向量预训练的领域涌现出了一系列的工作,下面我们选取一些有代表性的工作供大家学习参考。

Skip-Thought

Kiros et. al, Skip-Thought Vectors

skip-gram: distributional semantics of words 用中心词–》周围词

skip-thought: distributional semantics of sentences 用中心句–》周围句

两个句子如果总是在同一个环境下出现,那么这两个句子可能有某种含义上的联系

如何把句子map成一个向量:compositional model,RNN, LSTM, CNN, WordAvg, GRU

Skip-thought 模型的思想非常简单,我们训练一个基于GRU的模型作为句子的编码器。事实上Skip-thought这个名字与Skip-gram有着千丝万缕的联系,它们基于一个共同的思想,就是一句话(一个单词)的含义与它所处的环境(context,周围句子/单词)高度相关。

如下图所示,Skipthought采用一个GRU encoder,使用编码器最后一个hidden state来表示整个句子。然后使用这个hidden state作为初始状态来解码它之前和之后的句子。

decoder: 两个conditional语言模型。

基于中心句的句子向量,优化conditional log likelihood

img

一个encoder GRU

img

两个decoder GRU

img

训练目标

img

然后我们就可以把encoder当做feature extractor了。

类似的工作还有FastSent。FastSent直接使用词向量之和来表示整个句子,然后用该句子向量来解码周围句子中的单个单词们。

InferSent

Supervised Learning of Universal Sentence Representations from Natural Language Inference Data

Natural Language Inference (NLI)

  • 给定两个句子,判断这两个句子之间的关系

  • entailment 承接关系

  • neutral 没有关系

  • contradiction 矛盾

  • (non_entailment)

SNLI任务

给定两个句子,预测这两个句子的关系是entailment, contradiction,还是neutral

一个简单有效的模型

img

Encoder是BiLSTM + max pooling

img

模型效果

img

SentEval

SentEval: An Evaluation Toolkit for Universal Sentence Representations

一个非常通用的benchmark,用来评估句子embedding是否能够很好地应用于downstream tasks。

Github: https://github.com/facebookresearch/SentEval

Document Vector

事实上研究者在句子向量上的各种尝试是不太成功的。主要体现在这些预训练向量并不能非常好地提升模型在各种下游任务上的表现,人们大多数时候还是从头开始训练模型。

在document vector上的尝试就更不尽如人意了,因为一个文本往往包含非常丰富的信息,而一个向量能够编码的信息量实在太小。

Learning Deep Structured Semantic Models for Web Search using Clickthrough Data

https://www.microsoft.com/en-us/research/publication/learning-deep-structured-semantic-models-for-web-search-using-clickthrough-data/

Hierarchical Attention Networks for Document Classification

https://www.cs.cmu.edu/~./hovy/papers/16HLT-hierarchical-attention-networks.pdf

ELMo, BERT

ELMO paper: https://arxiv.org/pdf/1802.05365.pdf

Transformer中的Encoder

特征工程与模型调优

机器学习特征工程

机器学习流程与概念

机器学习建模流程

机器学习特征工程一览

机器学习特征工程介绍

特征清洗



数值型数据上的特征工程

数值型数据通常以标量的形式表示数据,描述观测值、记录或者测量值。本文的数值型数据是指连续型数据而不是离散型数据,表示不同类目的数据就是后者。数值型数据也可以用向量来表示,向量的每个值或分量代表一个特征。整数和浮点数是连续型数值数据中最常见也是最常使用的数值型数据类型。即使数值型数据可以直接输入到机器学习模型中,你仍需要在建模前设计与场景、问题和领域相关的特征。因此仍需要特征工程。让我们利用 python 来看看在数值型数据上做特征工程的一些策略。我们首先加载下面一些必要的依赖(通常在 Jupyter botebook 上)。

1
2
3
4
5
6
7
8
9
> import pandas as pd
>
> import matplotlib.pyplot as plt
>
> import numpy as np
>
> import scipy.stats as spstats
>
> %matplotlib inline

原始度量

正如我们先前提到的,根据上下文和数据的格式,原始数值型数据通常可直接输入到机器学习模型中。原始的度量方法通常用数值型变量来直接表示为特征,而不需要任何形式的变换或特征工程。通常这些特征可以表示一些值或总数。让我们加载四个数据集之一的 Pokemon 数据集,该数据集也在 Kaggle 上公布了。

1
2
3
poke_df = pd.read_csv('datasets/Pokemon.csv', encoding='utf-8') 

poke_df.head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

我们的Pokemon数据集截图

Pokemon 是一个大型多媒体游戏,包含了各种口袋妖怪(Pokemon)角色。简而言之,你可以认为他们是带有超能力的动物!这些数据集由这些口袋妖怪角色构成,每个角色带有各种统计信息。

数值

如果你仔细地观察上图中这些数据,你会看到几个代表数值型原始值的属性,它可以被直接使用。下面的这行代码挑出了其中一些重点特征。

1
poke_df[['HP', 'Attack', 'Defense']].head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

带(连续型)数值数据的特征

这样,你可以直接将这些属性作为特征,如上图所示。这些特征包括 Pokemon 的 HP(血量),Attack(攻击)和 Defense(防御)状态。事实上,我们也可以基于这些字段计算出一些基本的统计量。

1
poke_df[['HP', 'Attack', 'Defense']].describe()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

数值特征形式的基本描述性统计量

这样你就对特征中的统计量如总数、平均值、标准差和四分位数有了一个很好的印象。

记数

原始度量的另一种形式包括代表频率、总数或特征属性发生次数的特征。让我们看看 millionsong 数据集中的一个例子,其描述了某一歌曲被各种用户收听的总数或频数。

1
2
3
popsong_df = pd.read_csv('datasets/song_views.csv',encoding='utf-8')

popsong_df.head(10)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

数值特征形式的歌曲收听总数

根据这张截图,显而易见 listen_count 字段可以直接作为基于数值型特征的频数或总数。

二值化

基于要解决的问题构建模型时,通常原始频数或总数可能与此不相关。比如如果我要建立一个推荐系统用来推荐歌曲,我只希望知道一个人是否感兴趣或是否听过某歌曲。我不需要知道一首歌被听过的次数,因为我更关心的是一个人所听过的各种各样的歌曲。在这个例子中,二值化的特征比基于计数的特征更合适。我们二值化 listen_count 字段如下。

1
2
3
4
5
> watched = np.array(popsong_df['listen_count'])
>
> watched[watched >= 1] = 1
>
> popsong_df['watched'] = watched

你也可以使用 scikit-learn 中 preprocessing 模块的 Binarizer 类来执行同样的任务,而不一定使用 numpy 数组。

1
2
3
4
5
6
7
8
9
from sklearn.preprocessing import Binarizer

bn = Binarizer(threshold=0.9)

pd_watched =bn.transform([popsong_df['listen_count']])[0]

popsong_df['pd_watched'] = pd_watched

popsong_df.head(11)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

歌曲收听总数的二值化结构

你可以从上面的截图中清楚地看到,两个方法得到了相同的结果。因此我们得到了一个二值化的特征来表示一首歌是否被每个用户听过,并且可以在相关的模型中使用它。

数据舍入

处理连续型数值属性如比例或百分比时,我们通常不需要高精度的原始数值。因此通常有必要将这些高精度的百分比舍入为整数型数值。这些整数可以直接作为原始数值甚至分类型特征(基于离散类的)使用。让我们试着将这个观念应用到一个虚拟数据集上,该数据集描述了库存项和他们的流行度百分比。

1
2
3
4
5
6
7
items_popularity =pd.read_csv('datasets/item_popularity.csv',encoding='utf-8')

items_popularity['popularity_scale_10'] = np.array(np.round((items_popularity['pop_percent'] * 10)),dtype='int')

items_popularity['popularity_scale_100'] = np.array(np.round((items_popularity['pop_percent'] * 100)),dtype='int')

items_popularity

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

不同尺度下流行度舍入结果

基于上面的输出,你可能猜到我们试了两种不同的舍入方式。这些特征表明项目流行度的特征现在既有 1-10 的尺度也有 1-100 的尺度。基于这个场景或问题你可以使用这些值同时作为数值型或分类型特征。

相关性

高级机器学习模型通常会对作为输入特征变量函数的输出响应建模(离散类别或连续数值)。例如,一个简单的线性回归方程可以表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

其中输入特征用变量表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

权重或系数可以分别表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

目标是预测响应 *y*.

在这个例子中,仅仅根据单个的、分离的输入特征,这个简单的线性模型描述了输出与输入之间的关系。

然而,在一些真实场景中,有必要试着捕获这些输入特征集一部分的特征变量之间的相关性。上述带有相关特征的线性回归方程的展开式可以简单表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

此处特征可表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

表示了相关特征。现在让我们试着在 Pokemon 数据集上设计一些相关特征。

1
2
3
atk_def = poke_df[['Attack', 'Defense']]

atk_def.head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

从输出数据框中,我们可以看到我们有两个数值型(连续的)特征,Attack 和 Defence。现在我们可以利用 scikit-learn 建立二度特征。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pf = PolynomialFeatures(degree=2,

interaction_only=False,include_bias=False)

res = pf.fit_transform(atk_def)

res

**Output**

**------**

array([[ 49., 49., 2401., 2401., 2401.],

[ 62., 63., 3844., 3906., 3969.],

[ 82., 83., 6724., 6806., 6889.],

...,

[ 110., 60., 12100., 6600., 3600.],

[ 160., 60., 25600., 9600., 3600.],

[ 110., 120., 12100., 13200., 14400.]])

上面的特征矩阵一共描述了 5 个特征,其中包括新的相关特征。我们可以看到上述矩阵中每个特征的度,如下所示。

1
pd.DataFrame(pf.powers_, columns=['Attack_degree','Defense_degree'])

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

基于这个输出,现在我们可以通过每个特征的度知道它实际上代表什么。在此基础上,现在我们可以对每个特征进行命名如下。这仅仅是为了便于理解,你可以给这些特征取更好的、容易使用和简单的名字。

1
2
3
intr_features = pd.DataFrame(res, columns=['Attack','Defense','Attack^2','Attack x Defense','Defense^2'])

intr_features.head(5)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

数值型特征及其相关特征

因此上述数据代表了我们原始的特征以及它们的相关特征。

分区间处理数据

处理原始、连续的数值型特征问题通常会导致这些特征值的分布被破坏。这表明有些值经常出现而另一些值出现非常少。除此之外,另一个问题是这些特征的值的变化范围。比如某个音乐视频的观看总数会非常大(Despacito,说你呢)而一些值会非常小。直接使用这些特征会产生很多问题,反而会影响模型表现。因此出现了处理这些问题的技巧,包括分区间法和变换。

分区间(Bining),也叫做量化,用于将连续型数值特征转换为离散型特征(类别)。可以认为这些离散值或数字是类别或原始的连续型数值被分区间或分组之后的数目。每个不同的区间大小代表某种密度,因此一个特定范围的连续型数值会落在里面。对数据做分区间的具体技巧包括等宽分区间以及自适应分区间。我们使用从 2016 年 FreeCodeCamp 开发者和编码员调查报告中抽取出来的一个子集中的数据,来讨论各种针对编码员和软件开发者的属性。

1
2
3
fcc_survey_df =pd.read_csv('datasets/fcc_2016_coder_survey_subset.csv',encoding='utf-8')

fcc_survey_df[['ID.x', 'EmploymentField', 'Age','Income']].head()

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

来自FCC编码员调查数据集的样本属性

对于每个参加调查的编码员或开发者,ID.x 变量基本上是一个唯一的标识符而其他字段是可自我解释的。

等宽分区间

就像名字表明的那样,在等宽分区间方法中,每个区间都是固定宽度的,通常可以预先分析数据进行定义。基于一些领域知识、规则或约束,每个区间有个预先固定的值的范围,只有处于范围内的数值才被分配到该区间。基于数据舍入操作的分区间是一种方式,你可以使用数据舍入操作来对原始值进行分区间,我们前面已经讲过。

现在我们分析编码员调查报告数据集的 Age 特征并看看它的分布。

1
2
3
4
5
6
7
8
9
fig, ax = plt.subplots()

fcc_survey_df['Age'].hist(color='#A9C5D3',edgecolor='black',grid=False)

ax.set_title('Developer Age Histogram', fontsize=12)

ax.set_xlabel('Age', fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

描述开发者年龄分布的直方图

上面的直方图表明,如预期那样,开发者年龄分布仿佛往左侧倾斜(上年纪的开发者偏少)。现在我们根据下面的模式,将这些原始年龄值分配到特定的区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Age Range: Bin

\---------------

0 - 9 : 0

10 - 19 : 1

20 - 29 : 2

30 - 39 : 3

40 - 49 : 4

50 - 59 : 5

60 - 69 : 6

... and so on

我们可以简单地使用我们先前学习到的数据舍入部分知识,先将这些原始年龄值除以 10,然后通过 floor 函数对原始年龄数值进行截断。

1
2
3
fcc_survey_df['Age_bin_round'] = np.array(np.floor(np.array(fcc_survey_df['Age']) / 10.))

fcc_survey_df[['ID.x', 'Age','Age_bin_round']].iloc[1071:1076]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

通过舍入法分区间

你可以看到基于数据舍入操作的每个年龄对应的区间。但是如果我们需要更灵活的操作怎么办?如果我们想基于我们的规则或逻辑,确定或修改区间的宽度怎么办?基于常用范围的分区间方法将帮助我们完成这个。让我们来定义一些通用年龄段位,使用下面的方式来对开发者年龄分区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Age Range : Bin

\---------------

0 - 15 : 1

16 - 30 : 2

31 - 45 : 3

46 - 60 : 4

61 - 75 : 5

75 - 100 : 6

基于这些常用的分区间方式,我们现在可以对每个开发者年龄值的区间打标签,我们将存储区间的范围和相应的标签。

1
2
3
4
5
6
7
8
9
10
11
bin_ranges = [0, 15, 30, 45, 60, 75, 100]

bin_names = [1, 2, 3, 4, 5, 6]

fcc_survey_df['Age_bin_custom_range'] = pd.cut(np.array(fcc_survey_df['Age']),bins=bin_ranges)

fcc_survey_df['Age_bin_custom_label'] = pd.cut(np.array(fcc_survey_df['Age']),bins=bin_ranges, labels=bin_names)

\# view the binned features

fcc_survey_df[['ID.x', 'Age', 'Age_bin_round','Age_bin_custom_range','Age_bin_custom_label']].iloc[10a71:1076]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

开发者年龄的常用分区间方式

自适应分区间

使用等宽分区间的不足之处在于,我们手动决定了区间的值范围,而由于落在某个区间中的数据点或值的数目是不均匀的,因此可能会得到不规则的区间。一些区间中的数据可能会非常的密集,一些区间会非常稀疏甚至是空的!自适应分区间方法是一个更安全的策略,在这些场景中,我们让数据自己说话!这样,我们使用数据分布来决定区间的范围。

基于分位数的分区间方法是自适应分箱方法中一个很好的技巧。量化对于特定值或切点有助于将特定数值域的连续值分布划分为离散的互相挨着的区间。因此 q 分位数有助于将数值属性划分为 q 个相等的部分。关于量化比较流行的例子包括 2 分位数,也叫中值,将数据分布划分为2个相等的区间;4 分位数,也简称分位数,它将数据划分为 4 个相等的区间;以及 10 分位数,也叫十分位数,创建 10 个相等宽度的区间,现在让我们看看开发者数据集的 Income 字段的数据分布。

1
2
3
4
5
6
7
8
9
fig, ax = plt.subplots()

fcc_survey_df['Income'].hist(bins=30, color='#A9C5D3',edgecolor='black',grid=False)

ax.set_title('Developer Income Histogram',fontsize=12)

ax.set_xlabel('Developer Income', fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

描述开发者收入分布的直方图

上述的分布描述了一个在收入上右歪斜的分布,少数人赚更多的钱,多数人赚更少的钱。让我们基于自适应分箱方式做一个 4-分位数或分位数。我们可以很容易地得到如下的分位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
quantile_list = [0, .25, .5, .75, 1.]

quantiles =

fcc_survey_df['Income'].quantile(quantile_list)

quantiles



**Output**

**------**

0.00 6000.0

0.25 20000.0

0.50 37000.0

0.75 60000.0

1.00 200000.0

Name: Income, dtype: float64

现在让我们在原始的分布直方图中可视化下这些分位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fig, ax = plt.subplots()

fcc_survey_df['Income'].hist(bins=30, color='#A9C5D3',edgecolor='black',grid=False)

for quantile in quantiles:

qvl = plt.axvline(quantile, color='r')

ax.legend([qvl], ['Quantiles'], fontsize=10)

ax.set_title('Developer Income Histogram with Quantiles',fontsize=12)

ax.set_xlabel('Developer Income', fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

带分位数形式描述开发者收入分布的直方图

上面描述的分布中红色线代表了分位数值和我们潜在的区间。让我们利用这些知识来构建我们基于分区间策略的分位数。

1
2
3
4
5
6
7
8
9
10
11
12
13
quantile_labels = ['0-25Q', '25-50Q', '50-75Q', '75-100Q']

fcc_survey_df['Income_quantile_range'] = pd.qcut(

fcc_survey_df['Income'],q=quantile_list)

fcc_survey_df['Income_quantile_label'] = pd.qcut(

fcc_survey_df['Income'],q=quantile_list,labels=quantile_labels)

fcc_survey_df[['ID.x', 'Age', 'Income','Income_quantile_range',

'Income_quantile_label']].iloc[4:9]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

基于分位数的开发者收入的区间范围和标签

通过这个例子,你应该对如何做基于分位数的自适应分区间法有了一个很好的认识。一个需要重点记住的是,分区间的结果是离散值类型的分类特征,当你在模型中使用分类数据之前,可能需要额外的特征工程相关步骤。我们将在接下来的部分简要地讲述分类数据的特征工程技巧。

统计变换

我们讨论下先前简单提到过的数据分布倾斜的负面影响。现在我们可以考虑另一个特征工程技巧,即利用统计或数学变换。我们试试看 Log 变换和 Box-Cox 变换。这两种变换函数都属于幂变换函数簇,通常用来创建单调的数据变换。它们的主要作用在于它能帮助稳定方差,始终保持分布接近于正态分布并使得数据与分布的平均值无关。

Log变换

log 变换属于幂变换函数簇。该函数用数学表达式表示为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

读为以 b 为底 x 的对数等于 y。这可以变换为

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

表示以b为底指数必须达到多少才等于x。自然对数使用 b=e,e=2.71828,通常叫作欧拉常数。你可以使用通常在十进制系统中使用的 b=10 作为底数。

当应用于倾斜分布时 Log 变换是很有用的,因为他们倾向于拉伸那些落在较低的幅度范围内自变量值的范围,倾向于压缩或减少更高幅度范围内的自变量值的范围。从而使得倾斜分布尽可能的接近正态分布。让我们对先前使用的开发者数据集的 Income 特征上使用log变换。

1
2
3
fcc_survey_df['Income_log'] = np.log((1+fcc_survey_df['Income']))

fcc_survey_df[['ID.x', 'Age', 'Income','Income_log']].iloc[4:9]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

开发者收入log变换后结构

Income_log 字段描述了经过 log 变换后的特征。现在让我们来看看字段变换后数据的分布。

基于上面的图,我们可以清楚地看到与先前倾斜分布相比,该分布更加像正态分布或高斯分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
income_log_mean =np.round(np.mean(fcc_survey_df['Income_log']), 2)

fig, ax = plt.subplots()

fcc_survey_df['Income_log'].hist(bins=30,color='#A9C5D3',edgecolor='black',grid=False)

plt.axvline(income_log_mean, color='r')

ax.set_title('Developer Income Histogram after Log Transform',fontsize=12)

ax.set_xlabel('Developer Income (log scale)',fontsize=12)

ax.set_ylabel('Frequency', fontsize=12)

ax.text(11.5, 450, r'$\mu$='+str(income_log_mean),fontsize=10)

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

经过log变换后描述开发者收入分布的直方图

Box-Cox变换

Box-Cox 变换是另一个流行的幂变换函数簇中的一个函数。该函数有一个前提条件,即数值型值必须先变换为正数(与 log 变换所要求的一样)。万一出现数值是负的,使用一个常数对数值进行偏移是有帮助的。数学上,Box-Cox 变换函数可以表示如下。

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

生成的变换后的输出y是输入 x 和变换参数的函数;当 λ=0 时,该变换就是自然对数 log 变换,前面我们已经提到过了。λ 的最佳取值通常由最大似然或最大对数似然确定。现在让我们在开发者数据集的收入特征上应用 Box-Cox 变换。首先我们从数据分布中移除非零值得到最佳的值,结果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
income = np.array(fcc_survey_df['Income'])

income_clean = income[~np.isnan(income)]

l, opt_lambda = spstats.boxcox(income_clean)

print('Optimal lambda value:', opt_lambda)



**Output**

**------**

Optimal lambda value: 0.117991239456

现在我们得到了最佳的值,让我们在取值为 0 和 λ(最佳取值 λ )时使用 Box-Cox 变换对开发者收入特征进行变换。

1
2
3
4
5
fcc_survey_df['Income_boxcox_lambda_0'] = spstats.boxcox((1+fcc_survey_df['Income']),lmbda=0)

fcc_survey_df['Income_boxcox_lambda_opt'] = spstats.boxcox(fcc_survey_df['Income'],lmbda=opt_lambda)

fcc_survey_df[['ID.x', 'Age', 'Income', 'Income_log','Income_boxcox_lambda_0','Income_boxcox_lambda_opt']].iloc[4:9]

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

经过 Box-Cox 变换后开发者的收入分布

变换后的特征在上述数据框中描述了。就像我们期望的那样,Income_log 和 Income_boxcox_lamba_0具有相同的取值。让我们看看经过最佳λ变换后 Income 特征的分布。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>income_boxcox_mean = np.round(np.mean(fcc_survey_df['Income_boxcox_lambda_opt']),2)
>
>fig, ax = plt.subplots()
>
>fcc_survey_df['Income_boxcox_lambda_opt'].hist(bins=30, color='#A9C5D3',edgecolor='black', grid=False)
> plt.axvline(income_boxcox_mean, color='r')
>
> ax.set_title('Developer Income Histogram after Box–Cox Transform',fontsize=12)
>
> ax.set_xlabel('Developer Income (Box–Cox transform)',fontsize=12)
>
> ax.set_ylabel('Frequency', fontsize=12)
>
> ax.text(24, 450, r'$\mu$='+str(income_boxcox_mean),fontsize=10)
>

不会做特征工程的 AI 研究员不是好数据科学家!上篇 - 连续数据的处理方法

经过Box-Cox变换后描述开发者收入分布的直方图

分布看起来更像是正态分布,与我们经过 log 变换后的分布相似。

类别型数据上的特征工程

在深入研究特征工程之前,让我们先了解一下分类数据。通常,在自然界中可分类的任意数据属性都是离散值,这意味着它们属于某一特定的有限类别。在模型预测的属性或者变量(通常被称为响应变量 response variables)中,这些也经常被称为类别或者标签。这些离散值在自然界中可以是文本或者数字(甚至是诸如图像这样的非结构化数据)。分类数据有两大类——定类(Nominal)和定序(Ordinal)

在任意定类分类数据属性中,这些属性值之间没有顺序的概念。如下图所示,举个简单的例子,天气分类。我们可以看到,在这个特定的场景中,主要有六个大类,而这些类之间没有任何顺序上的关系(刮风天并不总是发生在晴天之前,并且也不能说比晴天来的更小或者更大)

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

将天气作为分类属性

与天气相类似的属性还有很多,比如电影、音乐、电子游戏、国家、食物和美食类型等等,这些都属于定类分类属性。

定序分类的属性值则存在着一定的顺序意义或概念。例如,下图中的字母标识了衬衫的大小。显而易见的是,当我们考虑衬衫的时候,它的“大小”属性是很重要的(S 码比 M 码来的小,而 M 码又小于 L 码等等)。

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

衬衫大小作为定序分类属性

鞋号、受教育水平和公司职位则是定序分类属性的一些其它例子。既然已经对分类数据有了一个大致的理解之后,接下来我们来看看一些特征工程的策略。

在接受像文本标签这样复杂的分类数据类型问题上,各种机器学习框架均已取得了许多的进步。通常,特征工程中的任意标准工作流都涉及将这些分类值转换为数值标签的某种形式,然后对这些值应用一些编码方案。我们将在开始之前导入必要的工具包。

1
2
3
import pandas as pd

import numpy as np

定类属性转换(LabelEncoding)

定类属性由离散的分类值组成,它们没有先后顺序概念。这里的思想是将这些属性转换成更具代表性的数值格式,这样可以很容易被下游的代码和流水线所理解。我们来看一个关于视频游戏销售的新数据集。这个数据集也可以在 Kaggle 和我的 GitHub 仓库中找到。

1
2
3
vg_df = pd.read_csv('datasets/vgsales.csv', encoding='utf-8')

vg_df[['Name', 'Platform', 'Year', 'Genre', 'Publisher']].iloc[1:7]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

游戏销售数据

让我们首先专注于上面数据框中“视频游戏风格(Genre)”属性。显而易见的是,这是一个类似于“发行商(Publisher)”和“平台(Platform)”属性一样的定类分类属性。我们可以很容易得到一个独特的视频游戏风格列表,如下。

1
2
3
4
5
6
7
8
9
genres = np.unique(vg_df['Genre'])

genres

Output

\------

array(['Action', 'Adventure', 'Fighting', 'Misc', 'Platform', 'Puzzle', 'Racing', 'Role-Playing', 'Shooter', 'Simulation', 'Sports', 'Strategy'], dtype=object)

输出结果表明,我们有 12 种不同的视频游戏风格。我们现在可以生成一个标签编码方法,即利用 scikit-learn 将每个类别映射到一个数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from sklearn.preprocessing import LabelEncoder

gle = LabelEncoder()

genre_labels = gle.fit_transform(vg_df['Genre'])

genre_mappings = {index: label for index, label in enumerate(gle.classes_)}

genre_mappings

Output

\------

{0: 'Action', 1: 'Adventure', 2: 'Fighting', 3: 'Misc', 4: 'Platform', 5: 'Puzzle', 6: 'Racing', 7: 'Role-Playing', 8: 'Shooter', 9: 'Simulation', 10: 'Sports', 11: 'Strategy'}

因此,在 LabelEncoder 类的实例对象 gle 的帮助下生成了一个映射方案,成功地将每个风格属性映射到一个数值。转换后的标签存储在 genre_labels 中,该变量允许我们将其写回数据表中。

1
2
3
vg_df['GenreLabel'] = genre_labels

vg_df[['Name', 'Platform', 'Year', 'Genre', 'GenreLabel']].iloc[1:7]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

视频游戏风格及其编码标签

如果你打算将它们用作预测的响应变量,那么这些标签通常可以直接用于诸如 sikit-learn 这样的框架。但是如前所述,我们还需要额外的编码步骤才能将它们用作特征。

定序属性编码

定序属性是一种带有先后顺序概念的分类属性。这里我将以本系列文章第一部分所使用的神奇宝贝数据集进行说明。让我们先专注于 「世代(Generation)」 属性。

1
2
3
4
5
6
7
8
9
10
11
> poke_df = pd.read_csv('datasets/Pokemon.csv', encoding='utf-8')
>
> poke_df = poke_df.sample(random_state=1, frac=1).reset_index(drop=True)
>
> np.unique(poke_df['Generation'])
>
> Output
>
> \------
>
> array(['Gen 1', 'Gen 2', 'Gen 3', 'Gen 4', 'Gen 5', 'Gen 6'], dtype=object)

根据上面的输出,我们可以看到一共有 6 代,并且每个神奇宝贝通常属于视频游戏的特定世代(依据发布顺序),而且电视系列也遵循了相似的时间线。这个属性通常是定序的(需要相关的领域知识才能理解),因为属于第一代的大多数神奇宝贝在第二代的视频游戏或者电视节目中也会被更早地引入。神奇宝贝的粉丝们可以看下下图,然后记住每一代中一些比较受欢迎的神奇宝贝(不同的粉丝可能有不同的看法)。

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

基于不同类型和世代选出的一些受欢迎的神奇宝贝

因此,它们之间存在着先后顺序。一般来说,没有通用的模块或者函数可以根据这些顺序自动将这些特征转换和映射到数值表示。因此,我们可以使用自定义的编码\映射方案。

1
2
3
4
5
gen_ord_map = {'Gen 1': 1, 'Gen 2': 2, 'Gen 3': 3, 'Gen 4': 4, 'Gen 5': 5, 'Gen 6': 6} 

poke_df['GenerationLabel'] = poke_df['Generation'].map(gen_ord_map)

poke_df[['Name', 'Generation', 'GenerationLabel']].iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

神奇宝贝世代编码

从上面的代码中可以看出,来自 pandas 库的 map(…) 函数在转换这种定序特征的时候非常有用。

编码分类属性–独热编码方案(One-hot Encoding Scheme)

如果你还记得我们之前提到过的内容,通常对分类数据进行特征工程就涉及到一个转换过程,我们在前一部分描述了一个转换过程,还有一个强制编码过程,我们应用特定的编码方案为特定的每个类别创建虚拟变量或特征分类属性。

你可能想知道,我们刚刚在上一节说到将类别转换为数字标签,为什么现在我们又需要这个?原因很简单。考虑到视频游戏风格,如果我们直接将 GenereLabel 作为属性特征提供给机器学习模型,则模型会认为它是一个连续的数值特征,从而认为值 10 (体育)要大于值 6 (赛车),然而事实上这种信息是毫无意义的,因为体育类型显然并不大于或者小于赛车类型,这些不同值或者类别无法直接进行比较。因此我们需要另一套编码方案层,它要能为每个属性的所有不同类别中的每个唯一值或类别创建虚拟特征。

考虑到任意具有 m 个标签的分类属性(变换之后)的数字表示,独热编码方案将该属性编码或变换成 m 个二进制特征向量(向量中的每一维的值只能为 0 或 1)。那么在这个分类特征中每个属性值都被转换成一个 m 维的向量,其中只有某一维的值为 1。让我们来看看神奇宝贝数据集的一个子集。

1
poke_df[['Name', 'Generation', 'Legendary']].iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

神奇宝贝数据集子集

这里关注的属性是神奇宝贝的「世代(Generation)」和「传奇(Legendary)」状态。第一步是根据之前学到的将这些属性转换为数值表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from sklearn.preprocessing import OneHotEncoder, LabelEncoder

\# transform and map pokemon generations

gen_le = LabelEncoder()

gen_labels = gen_le.fit_transform(poke_df['Generation'])

poke_df['Gen_Label'] = gen_labels

\# transform and map pokemon legendary status

leg_le = LabelEncoder()

leg_labels = leg_le.fit_transform(poke_df['Legendary'])

poke_df['Lgnd_Label'] = leg_labels

poke_df_sub = poke_df[['Name', 'Generation', 'Gen_Label', 'Legendary', 'Lgnd_Label']]

poke_df_sub.iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

转换后的标签属性

Gen_LabelLgnd_Label 特征描述了我们分类特征的数值表示。现在让我们在这些特征上应用独热编码方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# encode generation labels using one-hot encoding scheme

gen_ohe = OneHotEncoder()

gen_feature_arr = gen_ohe.fit_transform(poke_df[['Gen_Label']]).toarray()

gen_feature_labels = list(gen_le.classes_)

gen_features = pd.DataFrame(gen_feature_arr, columns=gen_feature_labels)

\# encode legendary status labels using one-hot encoding scheme

leg_ohe = OneHotEncoder()

leg_feature_arr = leg_ohe.fit_transform(poke_df[['Lgnd_Label']]).toarray()

leg_feature_labels = ['Legendary_'+str(cls_label) for cls_label in leg_le.classes_]

leg_features = pd.DataFrame(leg_feature_arr, columns=leg_feature_labels)

通常来说,你可以使用 fit_transform 函数将两个特征一起编码(通过将两个特征的二维数组一起传递给函数,详情查看文档)。但是我们分开编码每个特征,这样可以更易于理解。除此之外,我们还可以创建单独的数据表并相应地标记它们。现在让我们链接这些特征表(Feature frames)然后看看最终的结果。

1
2
3
4
5
poke_df_ohe = pd.concat([poke_df_sub, gen_features, leg_features], axis=1)

columns = sum([['Name', 'Generation', 'Gen_Label'], gen_feature_labels, ['Legendary', 'Lgnd_Label'], leg_feature_labels], [])

poke_df_ohe[columns].iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

神奇宝贝世代和传奇状态的独热编码特征

此时可以看到已经为「世代(Generation)」生成 6 个虚拟变量或者二进制特征,并为「传奇(Legendary)」生成了 2 个特征。这些特征数量是这些属性中不同类别的总数。某一类别的激活状态通过将对应的虚拟变量置 1 来表示,这从上面的数据表中可以非常明显地体现出来。

考虑你在训练数据上建立了这个编码方案,并建立了一些模型,现在你有了一些新的数据,这些数据必须在预测之前进行如下设计。

1
2
3
new_poke_df = pd.DataFrame([['PikaZoom', 'Gen 3', True], ['CharMyToast', 'Gen 4', False]], columns=['Name', 'Generation', 'Legendary'])

new_poke_df

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

新数据

你可以通过调用之前构建的 LabelEncoderOneHotEncoder 对象的 transform() 方法来处理新数据。请记得我们的工作流程,首先我们要做转换。

1
2
3
4
5
6
7
8
9
new_gen_labels = gen_le.transform(new_poke_df['Generation'])

new_poke_df['Gen_Label'] = new_gen_labels

new_leg_labels = leg_le.transform(new_poke_df['Legendary'])

new_poke_df['Lgnd_Label'] = new_leg_labels

new_poke_df[['Name', 'Generation', 'Gen_Label', 'Legendary', 'Lgnd_Label']]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

转换之后的分类属性

在得到了数值标签之后,接下来让我们应用编码方案吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
new_gen_feature_arr = gen_ohe.transform(new_poke_df[['Gen_Label']]).toarray()

new_gen_features = pd.DataFrame(new_gen_feature_arr, columns=gen_feature_labels)

new_leg_feature_arr = leg_ohe.transform(new_poke_df[['Lgnd_Label']]).toarray()

new_leg_features = pd.DataFrame(new_leg_feature_arr, columns=leg_feature_labels)

new_poke_ohe = pd.concat([new_poke_df, new_gen_features, new_leg_features], axis=1)

columns = sum([['Name', 'Generation', 'Gen_Label'], gen_feature_labels, ['Legendary', 'Lgnd_Label'], leg_feature_labels], [])

new_poke_ohe[columns]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

独热编码之后的分类属性

因此,通过利用 scikit-learn 强大的 API,我们可以很容易将编码方案应用于新数据。

你也可以通过利用来自 pandas 的 to_dummies() 函数轻松应用独热编码方案。

1
2
3
gen_onehot_features = pd.get_dummies(poke_df['Generation'])

pd.concat([poke_df[['Name', 'Generation']], gen_onehot_features], axis=1).iloc[4:10]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

使用 pandas 实现的独热编码特征

上面的数据表描述了应用在「世代(Generation)」属性上的独热编码方案,结果与之前的一致。

区间计数方案(Bin-counting Scheme)

到目前为止,我们所讨论的编码方案在分类数据方面效果还不错,但是当任意特征的不同类别数量变得很大的时候,问题开始出现。对于具有 m 个不同标签的任意分类特征这点非常重要,你将得到 m 个独立的特征。这会很容易地增加特征集的大小,从而导致在时间、空间和内存方面出现存储问题或者模型训练问题。除此之外,我们还必须处理“维度诅咒”问题,通常指的是拥有大量的特征,却缺乏足够的代表性样本,然后模型的性能开始受到影响并导致过拟合。

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

因此,我们需要针对那些可能具有非常多种类别的特征(如 IP 地址),研究其它分类数据特征工程方案。区间计数方案是处理具有多个类别的分类变量的有效方案。在这个方案中,我们使用基于概率的统计信息和在建模过程中所要预测的实际目标或者响应值,而不是使用实际的标签值进行编码。一个简单的例子是,基于过去的 IP 地址历史数据和 DDOS 攻击中所使用的历史数据,我们可以为任一 IP 地址会被 DDOS 攻击的可能性建立概率模型。使用这些信息,我们可以对输入特征进行编码,该输入特征描述了如果将来出现相同的 IP 地址,则引起 DDOS 攻击的概率值是多少。这个方案需要历史数据作为先决条件,并且要求数据非常详尽。

特征哈希方案

特征哈希方案(Feature Hashing Scheme)是处理大规模分类特征的另一个有用的特征工程方案。在该方案中,哈希函数通常与预设的编码特征的数量(作为预定义长度向量)一起使用,使得特征的哈希值被用作这个预定义向量中的索引,并且值也要做相应的更新。由于哈希函数将大量的值映射到一个小的有限集合中,因此多个不同值可能会创建相同的哈希,这一现象称为冲突。典型地,使用带符号的哈希函数,使得从哈希获得的值的符号被用作那些在适当的索引处存储在最终特征向量中的值的符号。这样能够确保实现较少的冲突和由于冲突导致的误差累积。

哈希方案适用于字符串、数字和其它结构(如向量)。你可以将哈希输出看作一个有限的 b bins 集合,以便于当将哈希函数应用于相同的值\类别时,哈希函数能根据哈希值将其分配到 b bins 中的同一个 bin(或者 bins 的子集)。我们可以预先定义 b 的值,它成为我们使用特征哈希方案编码的每个分类属性的编码特征向量的最终尺寸。

因此,即使我们有一个特征拥有超过 1000 个不同的类别,我们设置 b = 10 作为最终的特征向量长度,那么最终输出的特征将只有 10 个特征。而采用独热编码方案则有 1000 个二进制特征。我们来考虑下视频游戏数据集中的「风格(Genre)」属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
unique_genres = np.unique(vg_df[['Genre']])

print("Total game genres:", len(unique_genres))

print(unique_genres)

Output

\------

Total game genres: 12

['Action' 'Adventure' 'Fighting' 'Misc' 'Platform' 'Puzzle' 'Racing' 'Role-Playing' 'Shooter' 'Simulation' 'Sports' 'Strategy']

我们可以看到,总共有 12 中风格的游戏。如果我们在“风格”特征中采用独热编码方案,则将得到 12 个二进制特征。而这次,我们将通过 scikit-learn 的 FeatureHasher 类来使用特征哈希方案,该类使用了一个有符号的 32 位版本的 Murmurhash3 哈希函数。在这种情况下,我们将预先定义最终的特征向量大小为 6。

1
2
3
4
5
6
7
from sklearn.feature_extraction import FeatureHasher

fh = FeatureHasher(n_features=6, input_type='string')

hashed_features = fh.fit_transform(vg_df['Genre'])

hashed_features = hashed_features.toarray()pd.concat([vg_df[['Name', 'Genre']], pd.DataFrame(hashed_features)], axis=1).iloc[1:7]

不会做特征工程的 AI 研究员不是好数据科学家!下篇 - 离散数据的处理方法

风格属性的特征哈希

基于上述输出,「风格(Genre)」属性已经使用哈希方案编码成 6 个特征而不是 12 个。我们还可以看到,第 1 行和第 6 行表示相同风格的游戏「平台(Platform)」,而它们也被正确编码成了相同的特征向量。

时间型

avatar
avatar
avatar

文本型

avatar
avatar
avatar
avatar

语言模型

语言模型

学习目标

  • 学习语言模型,以及如何训练一个语言模型
  • 学习torchtext的基本使用方法
    • 构建 vocabulary
    • word to inde 和 index to word
  • 学习torch.nn的一些基本模型
    • Linear
    • RNN
    • LSTM
    • GRU
  • RNN的训练技巧
    • Gradient Clipping
  • 如何保存和读取模型

我们会使用 torchtext 来创建vocabulary, 然后把数据读成batch的格式。请大家自行阅读README来学习torchtext。

先了解下torchtext库:torchtext介绍和使用教程

In [1]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torchtext
from torchtext.vocab import Vectors
import torch
import numpy as np
import random

USE_CUDA = torch.cuda.is_available()

# 为了保证实验结果可以复现,我们经常会把各种random seed固定在某一个值
random.seed(53113)
np.random.seed(53113)
torch.manual_seed(53113)
if USE_CUDA:
torch.cuda.manual_seed(53113)

BATCH_SIZE = 32 #一个batch多少个句子
EMBEDDING_SIZE = 650 #每个单词多少维
MAX_VOCAB_SIZE = 50000 #单词总数
  • 我们会继续使用上次的text8作为我们的训练,验证和测试数据
  • torchtext提供了LanguageModelingDataset这个class来帮助我们处理语言模型数据集
  • BPTTIterator可以连续地得到连贯的句子

In [2]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TEXT = torchtext.data.Field(lower=True) 
# .Field这个对象包含了我们打算如何预处理文本数据的信息,这里定义单词全部小写

train, val, test = \
torchtext.datasets.LanguageModelingDataset.splits(
path=".",
train="text8.train.txt",
validation="text8.dev.txt",
test="text8.test.txt",
text_field=TEXT)
# torchtext提供了LanguageModelingDataset这个class来帮助我们处理语言模型数据集

TEXT.build_vocab(train, max_size=MAX_VOCAB_SIZE)
# build_vocab可以根据我们提供的训练数据集来创建最高频单词的单词表,max_size帮助我们限定单词总量。
print("vocabulary size: {}".format(len(TEXT.vocab)))
1
vocabulary size: 50002

In [4]:

1
test

Out[4]:

1
<torchtext.data.example.Example at 0x121738b00>

In [ ]:

1
2


In [9]:

1
2
3
4
print(TEXT.vocab.itos[0:50]) 
# 这里越靠前越常见,增加了两个特殊的token,<unk>表示未知的单词,<pad>表示padding。
print("------"*10)
print(list(TEXT.vocab.stoi.items())[0:50])
1
2
3
['<unk>', '<pad>', 'the', 'of', 'and', 'one', 'in', 'a', 'to', 'zero', 'nine', 'two', 'is', 'as', 'eight', 'for', 's', 'five', 'three', 'was', 'by', 'that', 'four', 'six', 'seven', 'with', 'on', 'are', 'it', 'from', 'or', 'his', 'an', 'be', 'this', 'he', 'at', 'which', 'not', 'also', 'have', 'were', 'has', 'but', 'other', 'their', 'its', 'first', 'they', 'had']
------------------------------------------------------------
[('<unk>', 0), ('<pad>', 1), ('the', 2), ('of', 3), ('and', 4), ('one', 5), ('in', 6), ('a', 7), ('to', 8), ('zero', 9), ('nine', 10), ('two', 11), ('is', 12), ('as', 13), ('eight', 14), ('for', 15), ('s', 16), ('five', 17), ('three', 18), ('was', 19), ('by', 20), ('that', 21), ('four', 22), ('six', 23), ('seven', 24), ('with', 25), ('on', 26), ('are', 27), ('it', 28), ('from', 29), ('or', 30), ('his', 31), ('an', 32), ('be', 33), ('this', 34), ('he', 35), ('at', 36), ('which', 37), ('not', 38), ('also', 39), ('have', 40), ('were', 41), ('has', 42), ('but', 43), ('other', 44), ('their', 45), ('its', 46), ('first', 47), ('they', 48), ('had', 49)]

In [10]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
VOCAB_SIZE = len(TEXT.vocab) # 50002
train_iter, val_iter, test_iter = \
torchtext.data.BPTTIterator.splits(
(train, val, test),
batch_size=BATCH_SIZE,
device=-1,
bptt_len=50, # 反向传播往回传的长度,这里我暂时理解为一个样本有多少个单词传入模型
repeat=False,
shuffle=True)
# BPTTIterator可以连续地得到连贯的句子,BPTT的全称是back propagation through time。
'''
Iterator:标准迭代器

BucketIerator:相比于标准迭代器,会将类似长度的样本当做一批来处理,
因为在文本处理中经常会需要将每一批样本长度补齐为当前批中最长序列的长度,
因此当样本长度差别较大时,使用BucketIerator可以带来填充效率的提高。
除此之外,我们还可以在Field中通过fix_length参数来对样本进行截断补齐操作。

BPTTIterator: 基于BPTT(基于时间的反向传播算法)的迭代器,一般用于语言模型中。
'''
1
2
3
The `device` argument should be set by using `torch.device` or passing a string as an argument. This behavior will be deprecated soon and currently defaults to cpu.
The `device` argument should be set by using `torch.device` or passing a string as an argument. This behavior will be deprecated soon and currently defaults to cpu.
The `device` argument should be set by using `torch.device` or passing a string as an argument. This behavior will be deprecated soon and currently defaults to cpu.

Out[10]:

1
'\nIterator:标准迭代器\n\nBucketIerator:相比于标准迭代器,会将类似长度的样本当做一批来处理,\n因为在文本处理中经常会需要将每一批样本长度补齐为当前批中最长序列的长度,\n因此当样本长度差别较大时,使用BucketIerator可以带来填充效率的提高。\n除此之外,我们还可以在Field中通过fix_length参数来对样本进行截断补齐操作。\n\nBPTTIterator: 基于BPTT(基于时间的反向传播算法)的迭代器,一般用于语言模型中。\n'

In [11]:

1
2
3
print(next(iter(train_iter))) # 一个batch训练集维度
print(next(iter(val_iter))) # 一个batch验证集维度
print(next(iter(test_iter))) # 一个batch测试集维度
1
2
3
4
5
6
7
8
9
10
11
[torchtext.data.batch.Batch of size 32]
[.text]:[torch.LongTensor of size 50x32]
[.target]:[torch.LongTensor of size 50x32]

[torchtext.data.batch.Batch of size 32]
[.text]:[torch.LongTensor of size 50x32]
[.target]:[torch.LongTensor of size 50x32]

[torchtext.data.batch.Batch of size 32]
[.text]:[torch.LongTensor of size 50x32]
[.target]:[torch.LongTensor of size 50x32]

模型的输入是一串文字,模型的输出也是一串文字,他们之间相差一个位置,因为语言模型的目标是根据之前的单词预测下一个单词。

In [12]:

1
2
3
4
it = iter(train_iter)
batch = next(it)
print(" ".join([TEXT.vocab.itos[i] for i in batch.text[:,1].data])) # 打印一个输入的句子
print(" ".join([TEXT.vocab.itos[i] for i in batch.target[:,1].data])) # 打印一个输出的句子
1
2
combine in pairs and then group into trios of pairs which are the smallest visible units of matter this parallels with the structure of modern atomic theory in which pairs or triplets of supposedly fundamental quarks combine to create most typical forms of matter they had also suggested the possibility
in pairs and then group into trios of pairs which are the smallest visible units of matter this parallels with the structure of modern atomic theory in which pairs or triplets of supposedly fundamental quarks combine to create most typical forms of matter they had also suggested the possibility of

In [ ]:

1
2


In [13]:

1
2
3
4
5
for j in range(5): # 这种取法是在一个固定的batch里取数据,发现一个batch里的数据是连不起来的。
print(j)
print(" ".join([TEXT.vocab.itos[i] for i in batch.text[:,j].data]))
print(j)
print(" ".join([TEXT.vocab.itos[i] for i in batch.target[:,j].data]))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0
anarchism originated as a term of abuse first used against early working class radicals including the diggers of the english revolution and the sans <unk> of the french revolution whilst the term is still used in a pejorative way to describe any act that used violent means to destroy the
0
originated as a term of abuse first used against early working class radicals including the diggers of the english revolution and the sans <unk> of the french revolution whilst the term is still used in a pejorative way to describe any act that used violent means to destroy the organization
1
combine in pairs and then group into trios of pairs which are the smallest visible units of matter this parallels with the structure of modern atomic theory in which pairs or triplets of supposedly fundamental quarks combine to create most typical forms of matter they had also suggested the possibility
1
in pairs and then group into trios of pairs which are the smallest visible units of matter this parallels with the structure of modern atomic theory in which pairs or triplets of supposedly fundamental quarks combine to create most typical forms of matter they had also suggested the possibility of
2
culture few living ainu settlements exist many authentic ainu villages advertised in hokkaido are simply tourist attractions language the ainu language is significantly different from japanese in its syntax phonology morphology and vocabulary although there have been attempts to show that they are related the vast majority of modern scholars
2
few living ainu settlements exist many authentic ainu villages advertised in hokkaido are simply tourist attractions language the ainu language is significantly different from japanese in its syntax phonology morphology and vocabulary although there have been attempts to show that they are related the vast majority of modern scholars reject
3
zero the apple iie card an expansion card for the lc line of macintosh computers was released essentially a miniaturized apple iie computer on a card utilizing the mega ii chip from the apple iigs it allowed the macintosh to run eight bit apple iie software through hardware emulation although
3
the apple iie card an expansion card for the lc line of macintosh computers was released essentially a miniaturized apple iie computer on a card utilizing the mega ii chip from the apple iigs it allowed the macintosh to run eight bit apple iie software through hardware emulation although video
4
in papers have been written arguing that the anthropic principle would explain the physical constants such as the fine structure constant the number of dimensions in the universe and the cosmological constant the three primary versions of the principle as stated by john d barrow and frank j <unk> one
4
papers have been written arguing that the anthropic principle would explain the physical constants such as the fine structure constant the number of dimensions in the universe and the cosmological constant the three primary versions of the principle as stated by john d barrow and frank j <unk> one nine

In [14]:

1
2
3
4
5
6
for i in range(5): # 这种取法是在每个batch里取某一个相同位置数据,发现不同batch间相同位置的数据是可以连起来的。这里有点小疑问。
batch = next(it)
print(i)
print(" ".join([TEXT.vocab.itos[i] for i in batch.text[:,2].data]))
print(i)
print(" ".join([TEXT.vocab.itos[i] for i in batch.target[:,2].data]))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0
reject that the relationship goes beyond contact i e mutual borrowing of words between japanese and ainu in fact no attempt to show a relationship with ainu to any other language has gained wide acceptance and ainu is currently considered to be a language isolate culture traditional ainu culture is
0
that the relationship goes beyond contact i e mutual borrowing of words between japanese and ainu in fact no attempt to show a relationship with ainu to any other language has gained wide acceptance and ainu is currently considered to be a language isolate culture traditional ainu culture is quite
1
quite different from japanese culture never shaving after a certain age the men had full beards and <unk> men and women alike cut their hair level with the shoulders at the sides of the head but trimmed it <unk> behind the women tattooed their mouths arms <unk> and sometimes their
1
different from japanese culture never shaving after a certain age the men had full beards and <unk> men and women alike cut their hair level with the shoulders at the sides of the head but trimmed it <unk> behind the women tattooed their mouths arms <unk> and sometimes their <unk>
2
<unk> starting at the onset of puberty the soot deposited on a pot hung over a fire of birch bark was used for colour their traditional dress is a robe spun from the bark of the elm tree it has long sleeves reaches nearly to the feet is folded round
2
starting at the onset of puberty the soot deposited on a pot hung over a fire of birch bark was used for colour their traditional dress is a robe spun from the bark of the elm tree it has long sleeves reaches nearly to the feet is folded round the
3
the body and is tied with a girdle of the same material women also wear an <unk> of japanese cloth in winter the skins of animals were worn with <unk> of <unk> and boots made from the skin of dogs or salmon both sexes are fond of earrings which are
3
body and is tied with a girdle of the same material women also wear an <unk> of japanese cloth in winter the skins of animals were worn with <unk> of <unk> and boots made from the skin of dogs or salmon both sexes are fond of earrings which are said
4
said to have been made of grapevine in former times as also are bead necklaces called <unk> which the women prized highly their traditional cuisine consists of the flesh of bear fox wolf badger ox or horse as well as fish fowl millet vegetables herbs and roots they never ate
4
to have been made of grapevine in former times as also are bead necklaces called <unk> which the women prized highly their traditional cuisine consists of the flesh of bear fox wolf badger ox or horse as well as fish fowl millet vegetables herbs and roots they never ate raw

定义模型

  • 继承nn.Module
  • 初始化函数
  • forward函数
  • 其余可以根据模型需要定义相关的函数

In [15]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import torch
import torch.nn as nn


class RNNModel(nn.Module):
""" 一个简单的循环神经网络"""

def __init__(self, rnn_type, ntoken, ninp, nhid, nlayers, dropout=0.5):
# rnn_type;有两个层供选择'LSTM', 'GRU'
# ntoken:VOCAB_SIZE=50002
# ninp:EMBEDDING_SIZE = 650,输入层维度
# nhid:EMBEDDING_SIZE = 1000,隐藏层维度,这里是我自己设置的,用于区分ninp层。
# nlayers:纵向有多少层神经网络

''' 该模型包含以下几层:
- 词嵌入层
- 一个循环神经网络层(RNN, LSTM, GRU)
- 一个线性层,从hidden state到输出单词表
- 一个dropout层,用来做regularization
'''
super(RNNModel, self).__init__()
self.drop = nn.Dropout(dropout)
self.encoder = nn.Embedding(ntoken, ninp)
# 定义输入的Embedding层,用来把每个单词转化为词向量

if rnn_type in ['LSTM', 'GRU']: # 下面代码以LSTM举例

self.rnn = getattr(nn, rnn_type)(ninp, nhid, nlayers, dropout=dropout)
# getattr(nn, rnn_type) 相当于 nn.rnn_type
# nlayers代表纵向有多少层。还有个参数是bidirectional: 是否是双向LSTM,默认false
else:
try:
nonlinearity = {'RNN_TANH': 'tanh', 'RNN_RELU': 'relu'}[rnn_type]
except KeyError:
raise ValueError( """An invalid option for `--model` was supplied,
options are ['LSTM', 'GRU', 'RNN_TANH' or 'RNN_RELU']""")
self.rnn = nn.RNN(ninp, nhid, nlayers, nonlinearity=nonlinearity, dropout=dropout)
self.decoder = nn.Linear(nhid, ntoken)
# 最后线性全连接隐藏层的维度(1000,50002)


self.init_weights()

self.rnn_type = rnn_type
self.nhid = nhid
self.nlayers = nlayers

def init_weights(self):
initrange = 0.1
self.encoder.weight.data.uniform_(-initrange, initrange)
self.decoder.bias.data.zero_()
self.decoder.weight.data.uniform_(-initrange, initrange)

def forward(self, input, hidden):

''' Forward pass:
- word embedding
- 输入循环神经网络
- 一个线性层从hidden state转化为输出单词表
'''

# input.shape = seg_length * batch = torch.Size([50, 32])
# 如果觉得想变成32*50格式,可以在LSTM里定义batch_first = True
# hidden = (nlayers * 32 * hidden_size, nlayers * 32 * hidden_size)
# hidden是个元组,输入有两个参数,一个是刚开始的隐藏层h的维度,一个是刚开始的用于记忆的c的维度,
# 这两个层的维度一样,并且需要先初始化,hidden_size的维度和上面nhid的维度一样 =1000,我理解这两个是同一个东西。
emb = self.drop(self.encoder(input)) #
# emb.shape=torch.Size([50, 32, 650]) # 输入数据的维度
# 这里进行了运算(50,50002,650)*(50, 32,50002)
output, hidden = self.rnn(emb, hidden)
# output.shape = 50 * 32 * hidden_size # 最终输出数据的维度,
# hidden是个元组,输出有两个参数,一个是最后的隐藏层h的维度,一个是最后的用于记忆的c的维度,这两个层维度相同
# hidden = (h层维度:nlayers * 32 * hidden_size, c层维度:nlayers * 32 * hidden_size)


output = self.drop(output)
decoded = self.decoder(output.view(output.size(0)*output.size(1), output.size(2)))
# output最后的输出层一定要是二维的,只是为了能进行全连接层的运算,所以把前两个维度拼到一起,(50*32,hidden_size)
# decoded.shape=(50*32,hidden_size)*(hidden_size,50002)=torch.Size([1600, 50002])

return decoded.view(output.size(0), output.size(1), decoded.size(1)), hidden
# 我们要知道每一个位置预测的是哪个单词,所以最终输出要恢复维度 = (50,32,50002)
# hidden = (h层维度:2 * 32 * 1000, c层维度:2 * 32 * 1000)

def init_hidden(self, bsz, requires_grad=True):
# 这步我们初始化下隐藏层参数
weight = next(self.parameters())
# weight = torch.Size([50002, 650])是所有参数的第一个参数
# 所有参数self.parameters(),是个生成器,LSTM所有参数维度种类如下:
# print(list(iter(self.parameters())))
# torch.Size([50002, 650])
# torch.Size([4000, 650])
# torch.Size([4000, 1000])
# torch.Size([4000]) # 偏置项
# torch.Size([4000])
# torch.Size([4000, 1000])
# torch.Size([4000, 1000])
# torch.Size([4000])
# torch.Size([4000])
# torch.Size([50002, 1000])
# torch.Size([50002])
if self.rnn_type == 'LSTM':
return (weight.new_zeros((self.nlayers, bsz, self.nhid), requires_grad=requires_grad),
weight.new_zeros((self.nlayers, bsz, self.nhid), requires_grad=requires_grad))
# return = (2 * 32 * 1000, 2 * 32 * 1000)
# 这里不明白为什么需要weight.new_zeros,我估计是想整个计算图能链接起来
# 这里特别注意hidden的输入不是model的参数,不参与更新,就跟输入数据x一样

else:
return weight.new_zeros((self.nlayers, bsz, self.nhid), requires_grad=requires_grad)
# GRU神经网络把h层和c层合并了,所以这里只有一层。

初始化一个模型

In [16]:

1
2
3
4
nhid = 1000 # 我自己设置的维度,用于区分embeding_size=650
model = RNNModel("LSTM", VOCAB_SIZE, EMBEDDING_SIZE, nhid, 2, dropout=0.5)
if USE_CUDA:
model = model.cuda()

In [17]:

1
model

Out[17]:

1
2
3
4
5
6
RNNModel(
(drop): Dropout(p=0.5)
(encoder): Embedding(50002, 650)
(rnn): LSTM(650, 1000, num_layers=2, dropout=0.5)
(decoder): Linear(in_features=1000, out_features=50002, bias=True)
)

In [23]:

1
list(model.parameters())[0].shape

Out[23]:

1
torch.Size([50002, 650])
  • 我们首先定义评估模型的代码。
  • 模型的评估和模型的训练逻辑基本相同,唯一的区别是我们只需要forward pass,不需要backward pass

In [68]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 先从下面训练模式看起,在看evaluate
def evaluate(model, data):
model.eval() # 预测模式
total_loss = 0.
it = iter(data)
total_count = 0.
with torch.no_grad():
hidden = model.init_hidden(BATCH_SIZE, requires_grad=False)
# 这里不管是训练模式还是预测模式,h层的输入都是初始化为0,hidden的输入不是model的参数
# 这里model里的model.parameters()已经是训练过的参数。
for i, batch in enumerate(it):
data, target = batch.text, batch.target
# # 取出验证集的输入的数据和输出的数据,相当于特征和标签
if USE_CUDA:
data, target = data.cuda(), target.cuda()
hidden = repackage_hidden(hidden) # 截断计算图
with torch.no_grad(): # 验证阶段不需要更新梯度
output, hidden = model(data, hidden)
#调用model的forward方法进行一次前向传播,得到return输出值
loss = loss_fn(output.view(-1, VOCAB_SIZE), target.view(-1))
# 计算交叉熵损失

total_count += np.multiply(*data.size())
# 上面计算交叉熵的损失是平均过的,这里需要计算下总的损失
# total_count先计算验证集样本的单词总数,一个样本有50个单词,一个batch32个样本
# np.multiply(*data.size()) =50*32=1600
total_loss += loss.item()*np.multiply(*data.size())
# 每次batch平均后的损失乘以每次batch的样本的总的单词数 = 一次batch总的损失

loss = total_loss / total_count # 整个验证集总的损失除以总的单词数
model.train() # 训练模式
return loss

In [9]:

1
2
3
4
5
import torch
import numpy as np
a = torch.ones((5,3))
print(a.size())
np.multiply(*a.size())
1
torch.Size([5, 3])

Out[9]:

1
15

我们需要定义下面的一个function,帮助我们把一个hidden state和计算图之前的历史分离。

In [69]:

1
2
3
4
5
6
7
8
9
# Remove this part
def repackage_hidden(h):
"""Wraps hidden states in new Tensors, to detach them from their history."""
if isinstance(h, torch.Tensor):
# 这个是GRU的截断,因为只有一个隐藏层
# 判断h是不是torch.Tensor
return h.detach() # 截断计算图,h是全的计算图的开始,只是保留了h的值
else: # 这个是LSTM的截断,有两个隐藏层,格式是元组
return tuple(repackage_hidden(v) for v in h)

定义loss function和optimizer

In [70]:

1
2
3
4
5
loss_fn = nn.CrossEntropyLoss() # 交叉熵损失
learning_rate = 0.001
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, 0.5)
# 每调用一次这个函数,lenrning_rate就降一半,0.5就是一半的意思

训练模型:

  • 模型一般需要训练若干个epoch
  • 每个epoch我们都把所有的数据分成若干个batch
  • 把每个batch的输入和输出都包装成cuda tensor
  • forward pass,通过输入的句子预测每个单词的下一个单词
  • 用模型的预测和正确的下一个单词计算cross entropy loss
  • 清空模型当前gradient
  • backward pass
  • gradient clipping,防止梯度爆炸
  • 更新模型参数
  • 每隔一定的iteration输出模型在当前iteration的loss,以及在验证集上做模型的评估

In [13]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import copy
GRAD_CLIP = 1.
NUM_EPOCHS = 2

val_losses = []
for epoch in range(NUM_EPOCHS):
model.train() # 训练模式
it = iter(train_iter)
# iter,生成迭代器,这里train_iter也是迭代器,不用iter也可以
hidden = model.init_hidden(BATCH_SIZE)
# 得到hidden初始化后的维度
for i, batch in enumerate(it):
data, target = batch.text, batch.target
# 取出训练集的输入的数据和输出的数据,相当于特征和标签
if USE_CUDA:
data, target = data.cuda(), target.cuda()
hidden = repackage_hidden(hidden)
# 语言模型每个batch的隐藏层的输出值是要继续作为下一个batch的隐藏层的输入的
# 因为batch数量很多,如果一直往后传,会造成整个计算图很庞大,反向传播会内存崩溃。
# 所有每次一个batch的计算图迭代完成后,需要把计算图截断,只保留隐藏层的输出值。
# 不过只有语言模型才这么干,其他比如翻译模型不需要这么做。
# repackage_hidden自定义函数用来截断计算图的。
model.zero_grad() # 梯度归零,不然每次迭代梯度会累加
output, hidden = model(data, hidden)
# output = (50,32,50002)
loss = loss_fn(output.view(-1, VOCAB_SIZE), target.view(-1))
# output.view(-1, VOCAB_SIZE) = (1600,50002)
# target.view(-1) =(1600),关于pytorch中交叉熵的计算公式请看下面链接。
# https://blog.csdn.net/geter_CS/article/details/84857220
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), GRAD_CLIP)
# 防止梯度爆炸,设定阈值,当梯度大于阈值时,更新的梯度为阈值
optimizer.step()
if i % 1000 == 0:
print("epoch", epoch, "iter", i, "loss", loss.item())

if i % 10000 == 0:
val_loss = evaluate(model, val_iter)

if len(val_losses) == 0 or val_loss < min(val_losses):
# 如果比之前的loss要小,就保存模型
print("best model, val loss: ", val_loss)
torch.save(model.state_dict(), "lm-best.th")
else: # 否则loss没有降下来,需要优化
scheduler.step() # 自动调整学习率
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
# 学习率调整后需要更新optimizer,下次训练就用更新后的
val_losses.append(val_loss) # 保存每10000次迭代后的验证集损失损失
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
epoch 0 iter 0 loss 10.821578979492188
best model, val loss: 10.782116411285918
epoch 0 iter 1000 loss 6.5122528076171875
epoch 0 iter 2000 loss 6.3599748611450195
epoch 0 iter 3000 loss 6.13856315612793
epoch 0 iter 4000 loss 5.473214626312256
epoch 0 iter 5000 loss 5.901871204376221
epoch 0 iter 6000 loss 5.85321569442749
epoch 0 iter 7000 loss 5.636535167694092
epoch 0 iter 8000 loss 5.7489800453186035
epoch 0 iter 9000 loss 5.464158058166504
epoch 0 iter 10000 loss 5.554863452911377
best model, val loss: 5.264891533569864
epoch 0 iter 11000 loss 5.703625202178955
epoch 0 iter 12000 loss 5.6448974609375
epoch 0 iter 13000 loss 5.372857570648193
epoch 0 iter 14000 loss 5.2639479637146
epoch 1 iter 0 loss 5.696778297424316
best model, val loss: 5.124550380139679
epoch 1 iter 1000 loss 5.534722805023193
epoch 1 iter 2000 loss 5.599489212036133
epoch 1 iter 3000 loss 5.459986686706543
epoch 1 iter 4000 loss 4.927192211151123
epoch 1 iter 5000 loss 5.435710906982422
epoch 1 iter 6000 loss 5.4059576988220215
epoch 1 iter 7000 loss 5.308575630187988
epoch 1 iter 8000 loss 5.405811786651611
epoch 1 iter 9000 loss 5.1389055252075195
epoch 1 iter 10000 loss 5.226413726806641
best model, val loss: 4.946829228873176
epoch 1 iter 11000 loss 5.379891395568848
epoch 1 iter 12000 loss 5.360724925994873
epoch 1 iter 13000 loss 5.176026344299316
epoch 1 iter 14000 loss 5.110936641693115

In [ ]:

1
2
3
4
5
6
# 加载保存好的模型参数
best_model = RNNModel("LSTM", VOCAB_SIZE, EMBEDDING_SIZE, nhid, 2, dropout=0.5)
if USE_CUDA:
best_model = best_model.cuda()
best_model.load_state_dict(torch.load("lm-best.th"))
# 把模型参数load到best_model里

使用最好的模型在valid数据上计算perplexity

In [15]:

1
2
3
4
val_loss = evaluate(best_model, val_iter)
print("perplexity: ", np.exp(val_loss))
# 这里不清楚语言模型的评估指标perplexity = np.exp(val_loss)
# 清楚的朋友欢迎交流下
1
perplexity:  140.72803934425724

使用最好的模型在测试数据上计算perplexity

In [16]:

1
2
test_loss = evaluate(best_model, test_iter)
print("perplexity: ", np.exp(test_loss))
1
perplexity:  178.54742013696125

使用训练好的模型生成一些句子。

In [18]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
hidden = best_model.init_hidden(1) # batch_size = 1
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
input = torch.randint(VOCAB_SIZE, (1, 1), dtype=torch.long).to(device)
# (1,1)表示输出格式是1行1列的2维tensor,VOCAB_SIZE表示随机取的值小于VOCAB_SIZE=50002
# 我们input相当于取的是一个单词
words = []
for i in range(100):
output, hidden = best_model(input, hidden)
# output.shape = 1 * 1 * 50002
# hidden = (2 * 1 * 1000, 2 * 1 * 1000)
word_weights = output.squeeze().exp().cpu()
# .exp()的两个作用:一是把概率更大的变得更大,二是把负数经过e后变成正数,下面.multinomial参数需要正数
word_idx = torch.multinomial(word_weights, 1)[0]
# 按照word_weights里面的概率随机的取值,概率大的取到的机会大。
# torch.multinomial看这个博客理解:https://blog.csdn.net/monchin/article/details/79787621
# 这里如果选择概率最大的,会每次生成重复的句子。
input.fill_(word_idx) # 预测的单词index是word_idx,然后把word_idx作为下一个循环预测的input输入
word = TEXT.vocab.itos[word_idx] # 根据word_idx取出对应的单词
words.append(word)
print(" ".join(words))
1
s influence clinton decision de gaulle is himself sappho s iv one family banquet was made published by paul <unk> and by a persuaded to prevent arcane of animate poverty based at copernicus bachelor in search services and in a cruise corps references eds the robin series july four one nine zero eight summer gutenberg one nine six four births one nine two eight deaths timeline of this method by the fourth amendment the german ioc known for his <unk> from <unk> one eight nine eight one seven eight nine management was established in one nine seven zero they had

In [42]:

1
torch.randint(50002, (1, 1))

Out[42]:

1
tensor([[11293]])

In [ ]:

1
 

SQuAD-BiDAF

代码是在githubBiDAF-pytorch上下载的,我把代码弄成了下面jupyter notebook格式,代码是在kaggle GPU跑的,

数据集如果不能下载的可以到我的网盘下载,包括数据集和训练好的模型,比较大:百度网盘下载地址

整个代码跑下来,训练集可以跑通,测试集当时跑的时候kaggle内存不够了,报错了,有兴趣可以试下最终的效果。

In [ ]:

1
2


In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# This Python 3 environment comes with many helpful analytics libraries installed
# It is defined by the kaggle/python docker image: https://github.com/kaggle/docker-python
# For example, here's several helpful packages to load in

import numpy as np # linear algebra
import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)

# Input data files are available in the "../input/" directory.
# For example, running this (by clicking run or pressing Shift+Enter) will list the files in the input directory

import os
print(os.listdir("../input"))

# Any results you write to the current directory are saved as output.

In [ ]:

1
!cp -r /kaggle/input/bidaf-pytorch-master/BiDAF-pytorch-master /kaggle/working

In [ ]:

1
os.chdir("BiDAF-pytorch-master")

In [ ]:

1
os.chdir("..")

In [ ]:

1
2
!ls
!pwd

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from __future__ import print_function
from collections import Counter
import string
import re
import argparse
import sys
import argparse
import copy, json, os
from time import gmtime, strftime
import torch
from torch import nn, optim
import torch.nn.functional as F
import tensorflow as tf
from torchtext import data
import torchtext
from torchtext import datasets
from torchtext.vocab import GloVe
import nltk
from tensorboardX import SummaryWriter

一、定义初始变量参数

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 有关argparse的官方文档操作请查看:https://docs.python.org/3/library/argparse.html#module-argparse,
# 下面的参数代码注释,我也不是特别懂,仅供参考
# 关于parser.add_argument()的详解请查看:https://blog.csdn.net/u013177568/article/details/62432761/
# 对于下面函数add_argument()第一个是选项是必须写的参数,该参数接受选项参数或者是位置参数(一串文件名)
# 第二个是default默认值,如果第一个选项参数没有单独指定,那选项参数的值就是默认值
# 第三个是参数数据类型,代表你的选项参数必须是是int还是float字符型数据。
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('--char-dim', default=8, type=int)
# char-dim 默认值是8
parser.add_argument('--char-channel-width', default=5, type=int)
# char-channel-width 默认值是5 以下类似
parser.add_argument('--char-channel-size', default=100, type=int)
parser.add_argument('--context-threshold', default=400, type=int)
parser.add_argument('--dev-batch-size', default=100, type=int)
parser.add_argument('--dev-file', default='dev-v1.1.json')
parser.add_argument('--dropout', default=0.2, type=float)
parser.add_argument('--epoch', default=12, type=int)
parser.add_argument('--exp-decay-rate', default=0.999, type=float)
parser.add_argument('--gpu', default=0, type=int)
parser.add_argument('--hidden-size', default=100, type=int)
parser.add_argument('--learning-rate', default=0.5, type=float)
parser.add_argument('--print-freq', default=250, type=int)
parser.add_argument('--train-batch-size', default=60, type=int)
parser.add_argument('--train-file', default='train-v1.1.json')
parser.add_argument('--word-dim', default=100, type=int)
args = parser.parse_args(args=[])
# .parse_args()是将之前所有add_argument定义的参数在括号里进行赋值,没有赋值(args=[]),就返回参数各自default的默认值。
# 返回值args相当于是个参数命名空间的集合,可以调用上面第一项选项参数的名字,就可以得到default值了。
# 比如调用上面参数方式:args.char_dim,args.char_channel_width....默认情况下,中划线会转换为下划线.
return args

In [ ]:

1
args = parse_args()

二、SQuAD问答数据预处理

1、查看数据集结构

SQuAD问答数据介绍:https://rajpurkar.github.io/SQuAD-explorer/ 这个数据集有两个文件,验证集和测试集:train-v1.1.json,dev-v1.1.json

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
with open('data/squad/dev-v1.1.json', 'r', encoding='utf-8') as f:
try:
while True:
line = f.readline() # 该方法每次读出一行内容
if line:
print("type(line)",type(line)) # 直接打印就是字符串格式
r = json.loads(line)
print("type(r)",type(r)) # 使用json.loads将字符串转化为字典
print(r)
else:
break
except:
f.close()

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 数据架构如下
{
"data": [
{
"title": "Super_Bowl_50", # 第一个主题
"paragraphs": [
{
"context": " numerals 50.......", # 每个主题会有很多context短文,这里只列出一个
"qas": [ # 这个列表里放问题和答案的位置,每篇context会有很有很多answer和question,这里只列出一个
{
"answers": [ # 一个问题会有三个答案,三个答案都是对的,只是在context不同或相同位置
{ # 下面三个答案都在相同的位置
"answer_start": 177, # 答案在文中的起始位置是第177的字符。
"text": "Denver Broncos"
},
{
"answer_start": 177,
"text": "Denver Broncos"
},
{
"answer_start": 177,
"text": "Denver Broncos"
}
],
"question": "Which NFL team represented the AFC at Super Bowl 50?",
"id": "56be4db0acb8001400a502ec"
}

]
}

]
},

{
"title": "Warsaw", # 第二个主题
"paragraphs":
},

{
"title": "Normans", # 第三个主题
"paragraphs":
},

{
"title": "Nikola_Tesla", # 第四个主题
"paragraphs":
},
........... # 还有很多

],
"version": "1.1"
}

2、定义分词方法

In [ ]:

1
2
3
4
def word_tokenize(tokens):
tokens = [token.replace("''", '"').replace("``", '"') for token in nltk.word_tokenize(tokens)]
# nltk.word_tokenize(tokens)分词,replace规范化引号,方便后面处理
return tokens

3、清洗数据,并生成数据迭代器

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
class SQuAD():
def __init__(self, args):

# 以下定好中间输出缓存文件的路径
path = 'data/squad'
dataset_path = path + '/torch_text/'
train_examples_path = dataset_path + 'train_examples.pt'
dev_examples_path = dataset_path + 'dev_examples.pt'

print("preprocessing data files...")
if not os.path.exists(f'{path}/{args.train_file}l'):
# 字符串前以f开头表示在字符串内支持大括号内的 python 表达式
# args.train_file = 'train-v1.1.json'
print(f'{path}/{args.train_file}')
self.preprocess_file(f'{path}/{args.train_file}')
# preprocess_file下面函数有定义,完成文件的预处理
if not os.path.exists(f'{path}/{args.dev_file}l'):
# args.dev_file = 'dev-v1.1.json'
self.preprocess_file(f'{path}/{args.dev_file}')

# 下面是用torchtext处理数据的步骤看不懂了,有知道的可以交流下
self.RAW = data.RawField()# 这个是完全空白的field,意味着不经过任何处理
# explicit declaration for torchtext compatibility
self.RAW.is_target = False
self.CHAR_NESTING = data.Field(batch_first=True, tokenize=list, lower=True)
self.CHAR = data.NestedField(self.CHAR_NESTING, tokenize=word_tokenize)
self.WORD = data.Field(batch_first=True, tokenize=word_tokenize, lower=True, include_lengths=True)
self.LABEL = data.Field(sequential=False, unk_token=None, use_vocab=False)

dict_fields = {'id': ('id', self.RAW),
's_idx': ('s_idx', self.LABEL),
'e_idx': ('e_idx', self.LABEL),
'context': [('c_word', self.WORD), ('c_char', self.CHAR)],
'question': [('q_word', self.WORD), ('q_char', self.CHAR)]}

list_fields = [('id', self.RAW), ('s_idx', self.LABEL), ('e_idx', self.LABEL),
('c_word', self.WORD), ('c_char', self.CHAR),
('q_word', self.WORD), ('q_char', self.CHAR)]

if os.path.exists(dataset_path):
print("loading splits...")
train_examples = torch.load(train_examples_path)
dev_examples = torch.load(dev_examples_path)

self.train = data.Dataset(examples=train_examples, fields=list_fields)
self.dev = data.Dataset(examples=dev_examples, fields=list_fields)
else:
print("building splits...")
# 划分训练集和验证集
self.train, self.dev = data.TabularDataset.splits(
path=path,
train=f'{args.train_file}l',
validation=f'{args.dev_file}l',
format='json',
fields=dict_fields)

os.makedirs(dataset_path)
torch.save(self.train.examples, train_examples_path)
torch.save(self.dev.examples, dev_examples_path)

#cut too long context in the training set for efficiency.
if args.context_threshold > 0:
self.train.examples = [e for e in self.train.examples if len(e.c_word) <= args.context_threshold]

print("building vocab...")
self.CHAR.build_vocab(self.train, self.dev) # 字符向量没有设置vector
self.WORD.build_vocab(self.train, self.dev, vectors=GloVe(name='6B', dim=args.word_dim))
# 加载Glove向量,args.word_dim = 100

print("building iterators...")
device = torch.device(f"cuda:{args.gpu}" if torch.cuda.is_available() else "cpu")
# 生成迭代器
self.train_iter, self.dev_iter = \
data.BucketIterator.splits((self.train, self.dev),
batch_sizes=[args.train_batch_size, args.dev_batch_size],
device=device,
sort_key=lambda x: len(x.c_word))

def preprocess_file(self,path):
dump = []
abnormals = [' ', '\n', '\u3000', '\u202f', '\u2009']
# 空白无效字符列表

with open(path, 'r', encoding='utf-8') as f:
data = json.load(f) # 直接文件句柄转化为字典
data = data['data'] # 返回值data是个列表,字典是列表的元素

for article in data:
# 每个article是一个字典,一个字典包含一个title的信息
for paragraph in article['paragraphs']:
# 每个paragraph是一个字典,一个字典里有一个context和qas的信息,qas是问题和答案。
context = paragraph['context']
# context的内容,是字符串,如:" numerals 50............."
tokens = word_tokenize(context) # 对context进行分词
for qa in paragraph['qas']:
# 每个qa是一个字典,一个字典包含一对answers和question的信息
id = qa['id']
# 取出这对answers和question的id信息,如:"56be4db0acb8001400a502ec"
question = qa['question']
# 取出question,如:"Which NFL team represented the AFC at Super Bowl 50?"
for ans in qa['answers']:
# ans为每个答案,共有三个标准答案,可以相同,可以不同,统一为3个。
answer = ans['text']
# 问题的每个回答,如:"Denver Broncos"
s_idx = ans['answer_start']
# 每个回答的start位置,数值代表context中第几个字符,如:177
e_idx = s_idx + len(answer)
# 每个回答的end位置


# 下面重新更新字符的起始位置,使用字符计算位置改为使用单词计算位置
# 请看下面单元格的示例输出有助理解。
l = 0
s_found = False
for i, t in enumerate(tokens):
# 循环t次,t为分词后的单词数量
while l < len(context):
if context[l] in abnormals:
# context中有空白无效字符,就计数
l += 1
else: # 一碰到不是空白字符的就break
break
# exceptional cases
if t[0] == '"' and context[l:l + 2] == '\'\'':
# 专门计算context=''an 这种长度,这个长度为4
t = '\'\'' + t[1:]
elif t == '"' and context[l:l + 2] == '\'\'':
# 专门计算context='' 这种长度
# 上面t[0] == '"'表达式包含了这种,所以我认为这个表达式没用上
t = '\'\''

l += len(t)
if l > s_idx and s_found == False:
# 只要计数超过起始位置值,这个单词就是start的单词
s_idx = i
s_found = True
if l >= e_idx:
# 这里不出错的话,等于e_idx就是end的单词
e_idx = i
break

# 这里把三个answer分开,每个answer都放进字典中,并作为一个样本
dump.append(dict([('id', id),
('context', context),
('question', question),
('answer', answer),
('s_idx', s_idx),
('e_idx', e_idx)]))
with open(f'{path}l', 'w', encoding='utf-8') as f:
for line in dump:
# line为字典,一个样本存储
json.dump(line, f)
#dump:将dict类型转换为json字符串格式,写入到文件
print('', file=f) # 这里print的作用就是换行用的。

In [ ]:

1
data = SQuAD(args)

上面不太明白的举例子

In [ ]:

1
2
3
4
5
6
# 举例子
a = " \u2009\n\u3000Super Bowl 50 was ''an'' American football \u3000game to determine the champion of the National Football League (NFL) for the 2015 season. The American Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24\u201310 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi's Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the \"golden anniversary\" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as \"Super Bowl L\"), so that the logo could prominently feature the Arabic numerals 50."
tokens = word_tokenize(a)
print(nltk.word_tokenize(a)) # 所有的“\u2009”,“\n”,“\u3000”等空白字符都去掉了
print("----"*20)
print(tokens)

In [ ]:

1
2
3
4
5
6
7
8
# 
print(a[0]) # 空白字符打印不出来
print(a[1]) # 空白字符打印不出来
print(a[2]) # 空白字符打印不出来
print(a[3]) # 空白字符打印不出来
print(a[4])
print(a[5])
a[1]

In [ ]:

1
2
3
4
5
6
# 下面特别注意
print(tokens[4][0]== '"') # 虽然切分后看起来是"''",但实际上是'"'
print(tokens[4][0]== "''")
print(tokens[5]== '"')
print(len('"')) # 这种长度为1
print(len("''")) # 这种长度为2

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 查看输出理解
s_idx = 177
e_idx = s_idx + len("Denver Broncos")
l=0
context = " \u2009\n\u3000Super Bowl 50 was ''an'' American football \u3000game to determine the champion of the National Football League (NFL) for the 2015 season. The American Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24\u201310 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi's Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the \"golden anniversary\" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as \"Super Bowl L\"), so that the logo could prominently feature the Arabic numerals 50."
tokens = word_tokenize(context)
abnormals = [' ', '\n', '\u3000', '\u202f', '\u2009']
s_found = False
for i, t in enumerate(tokens):
print("t=",t)
while l < len(context):
if context[l] in abnormals:
l += 1
else:
break
print("l",l)
# exceptional cases
if t[0] == '"' and context[l:l + 2] == '\'\'':
print("1111111111111111111")
print(t)
print(t[1:])
t = '\'\'' + t[1:]
print(t)
elif t == '"' and context[l:l + 2] == '\'\'':
# 看输出结果,这个表达式没有用到
print("22222222222222222222")
print(t)
t = '\'\''
print("len(t)",len(t))
l += len(t)
print("l",l)
if l > s_idx and s_found == False:
s_idx = i
print("s_idx",s_idx)
s_found = True
if l >= e_idx:
e_idx = i
print("e_idx",e_idx)
break

In [ ]:

1
2
3
4
5
batch = next(iter(data.train_iter)) #一个batch的信息
print(batch)
# 训练集的batch_sizes=60
# batch.c_word = 60x293,293是60个样本中最长样本token的单词数
# batch.c_char = 60x293x25,25是某个单词字符的最大的数量

In [ ]:

1
2
print(batch.q_word)
print(batch.q_char[0])

In [ ]:

1
2
3
4
5
6
7
8
# 下面为args新增参数,并赋值
# hasattr() getattr() setattr() 函数使用方法详解https://www.cnblogs.com/cenyu/p/5713686.html
setattr(args, 'char_vocab_size', len(data.CHAR.vocab)) # 设置属性args.char_vocab_size的值 = len(data.CHAR.vocab)
setattr(args, 'word_vocab_size', len(data.WORD.vocab))
setattr(args, 'dataset_file', f'data/squad/{args.dev_file}')
setattr(args, 'prediction_file', f'prediction{args.gpu}.out')
setattr(args, 'model_time', strftime('%H:%M:%S', gmtime())) # 时间
print('data loading complete!')

BIDAF

avatar

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class LSTM(nn.Module):
def __init__(self, input_size, hidden_size, batch_first=False, num_layers=1, bidirectional=False, dropout=0.2):
# input_size=args.hidden_size * 2 = 200,
# hidden_size=args.hidden_size = 100,
# bidirectional=True,
# batch_first=True,
# dropout=args.dropout = 0.2
super(LSTM, self).__init__()
self.rnn = nn.LSTM(input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bidirectional=bidirectional,
batch_first=batch_first)
self.reset_params() # 重置参数
self.dropout = nn.Dropout(p=dropout)

def reset_params(self):
for i in range(self.rnn.num_layers):
nn.init.orthogonal_(getattr(self.rnn, f'weight_hh_l{i}')) # hidden-hidden weights
# weight_hh_l{i}、weight_ih_l{i}、bias_hh_l{i}、bias_ih_l{i} 都是nn.LSTM源码里的参数
# getattr取出源码里参数的值,用nn.init.orthogonal_正交进行重新初始化
# nn.init初始化方法看这个链接:https://www.aiuai.cn/aifarm613.html
nn.init.kaiming_normal_(getattr(self.rnn, f'weight_ih_l{i}')) # input-hidden weights
nn.init.constant_(getattr(self.rnn, f'bias_hh_l{i}'), val=0) # hidden-hidden bias
nn.init.constant_(getattr(self.rnn, f'bias_ih_l{i}'), val=0) # input-hidden bias
getattr(self.rnn, f'bias_hh_l{i}').chunk(4)[1].fill_(1)
# .chunk看下这个链接:https://blog.csdn.net/XuM222222/article/details/92380538
# .fill_(1),下划线代表直接替换,看链接:https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.fill.html

if self.rnn.bidirectional: # 双向,需要初始化反向的参数
nn.init.orthogonal_(getattr(self.rnn, f'weight_hh_l{i}_reverse'))
nn.init.kaiming_normal_(getattr(self.rnn, f'weight_ih_l{i}_reverse'))
nn.init.constant_(getattr(self.rnn, f'bias_hh_l{i}_reverse'), val=0)
nn.init.constant_(getattr(self.rnn, f'bias_ih_l{i}_reverse'), val=0)
getattr(self.rnn, f'bias_hh_l{i}_reverse').chunk(4)[1].fill_(1)

def forward(self, x):
# x是一个元组(c, c_lens)
x, x_len = x
# x = (batch, seq_len, hidden_size * 2)
# x_len = (batch) 一个batch中所有context或question的样本长度
x = self.dropout(x)

# 下面一顿操作和第七课机器翻译的一样,
# 看下这篇博客理解:https://www.cnblogs.com/sbj123456789/p/9834018.html
x_len_sorted, x_idx = torch.sort(x_len, descending=True)
x_sorted = x.index_select(dim=0, index=x_idx)
_, x_ori_idx = torch.sort(x_idx)

x_packed = nn.utils.rnn.pack_padded_sequence(x_sorted, x_len_sorted, batch_first=True)
x_packed, (h, c) = self.rnn(x_packed)

x = nn.utils.rnn.pad_packed_sequence(x_packed, batch_first=True)[0]
x = x.index_select(dim=0, index=x_ori_idx)
h = h.permute(1, 0, 2).contiguous().view(-1, h.size(0) * h.size(2)).squeeze()
h = h.index_select(dim=0, index=x_ori_idx)
# x = (batch, seq_len, hidden_size * 2)
# h = (1, batch, hidden_size * 2) 这个维度不用管
return x, h

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Linear(nn.Module):
def __init__(self, in_features, out_features, dropout=0.0):
super(Linear, self).__init__()

self.linear = nn.Linear(in_features=in_features, out_features=out_features)
# in_features = hidden_size * 2
# out_features = hidden_size * 2
if dropout > 0:
self.dropout = nn.Dropout(p=dropout)
self.reset_params()

def reset_params(self):
nn.init.kaiming_normal_(self.linear.weight)
nn.init.constant_(self.linear.bias, 0)

def forward(self, x):
if hasattr(self, 'dropout'): # 判断self有没有'dropout'这个参数,返回bool值
x = self.dropout(x)
x = self.linear(x)
return x

In [ ]:

1
args.char_dim

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# 看英文论文或这篇博客理解模型:https://blog.csdn.net/u014665013/article/details/79793395
class BiDAF(nn.Module):
def __init__(self, args, pretrained):
# pretrained = data.WORD.vocab.vectors = (108777, 100)
super(BiDAF, self).__init__()
self.args = args

# 1. Character Embedding Layer 是模型示意图左边的层的名字,从下往上
# 字符编码层
self.char_emb = nn.Embedding(args.char_vocab_size, args.char_dim, padding_idx=1)
# args.char_vocab_size = 1307,args.char_dim = 8
nn.init.uniform_(self.char_emb.weight, -0.001, 0.001)
# 初始化权重

self.char_conv = nn.Conv2d(1, args.char_channel_size, (args.char_dim, args.char_channel_width))
# args.char_channel_size = 100 卷积核数量
# (args.char_dim, args.char_channel_width) = (8,5) 过滤器大小

# 2. Word Embedding Layer
# 单词编码层
# initialize word embedding with GloVe
self.word_emb = nn.Embedding.from_pretrained(pretrained, freeze=True)
# 初始化词向量权重,用的Glove向量

# highway network
assert self.args.hidden_size * 2 == (self.args.char_channel_size + self.args.word_dim)
for i in range(2):
setattr(self, f'highway_linear{i}',
nn.Sequential(Linear(args.hidden_size * 2, args.hidden_size * 2),
nn.ReLU()))
# 设置highway_linear0 = nn.Sequential(Linear(args.hidden_size * 2, args.hidden_size * 2)
# 设置highway_linear1 = nn.Sequential(Linear(args.hidden_size * 2, args.hidden_size * 2)
# args.hidden_size = 100

setattr(self, f'highway_gate{i}',
nn.Sequential(Linear(args.hidden_size * 2, args.hidden_size * 2),
nn.Sigmoid()))

# 3. Contextual Embedding Layer
# 上下文,和答案嵌入层,用的LSTM
# 下面LSTM定位到了自定义的class LSTM(nn.Module)。
self.context_LSTM = LSTM(input_size=args.hidden_size * 2,
hidden_size=args.hidden_size,
bidirectional=True,
batch_first=True,
dropout=args.dropout)

# 4. Attention Flow Layer
# 注意力层
self.att_weight_c = Linear(args.hidden_size * 2, 1)
self.att_weight_q = Linear(args.hidden_size * 2, 1)
self.att_weight_cq = Linear(args.hidden_size * 2, 1)

# 5. Modeling Layer
self.modeling_LSTM1 = LSTM(input_size=args.hidden_size * 8,
hidden_size=args.hidden_size,
bidirectional=True,
batch_first=True,
dropout=args.dropout)

self.modeling_LSTM2 = LSTM(input_size=args.hidden_size * 2,
hidden_size=args.hidden_size,
bidirectional=True,
batch_first=True,
dropout=args.dropout)

# 6. Output Layer
self.p1_weight_g = Linear(args.hidden_size * 8, 1, dropout=args.dropout)
self.p1_weight_m = Linear(args.hidden_size * 2, 1, dropout=args.dropout)
self.p2_weight_g = Linear(args.hidden_size * 8, 1, dropout=args.dropout)
self.p2_weight_m = Linear(args.hidden_size * 2, 1, dropout=args.dropout)

self.output_LSTM = LSTM(input_size=args.hidden_size * 2,
hidden_size=args.hidden_size,
bidirectional=True,
batch_first=True,
dropout=args.dropout)

self.dropout = nn.Dropout(p=args.dropout)

def forward(self, batch):
# batch里面有'id','s_idx','e_idx', 'c_word','c_char','q_word', 'q_char'数据
# TODO: More memory-efficient architecture
def char_emb_layer(x):
"""
:param x: (batch, seq_len, word_len)
:return: (batch, seq_len, char_channel_size)
"""
# x = (batch_sizes,seq_len,word_len)
batch_size = x.size(0)
x = self.dropout(self.char_emb(x))
# (batch, seq_len, word_len, char_dim)
x = x.view(-1, self.args.char_dim, x.size(2)).unsqueeze(1)
# (batch * seq_len, 1, char_dim, word_len) 1是输入的channel的维度
x = self.char_conv(x).squeeze()
# (batch * seq_len, char_channel_size, 1, conv_len) ->
# (batch * seq_len, char_channel_size, conv_len) conv_len不用管,下一步都会pool掉
x = F.max_pool1d(x, x.size(2)).squeeze()
# (batch * seq_len, char_channel_size, 1) -> (batch * seq_len, char_channel_size)
x = x.view(batch_size, -1, self.args.char_channel_size)
# (batch, seq_len, char_channel_size)

return x

def highway_network(x1, x2):
"""
:param x1: (batch, seq_len, char_channel_size)
:param x2: (batch, seq_len, word_dim)
:return: (batch, seq_len, hidden_size * 2)
"""

x = torch.cat([x1, x2], dim=-1)
# x = (batch, seq_len, char_channel_size + word_dim)
for i in range(2):
h = getattr(self, f'highway_linear{i}')(x) # 调用Linear的forward方法
# h = (batch, seq_len, hidden_size * 2)
g = getattr(self, f'highway_gate{i}')(x)
# g = (batch, seq_len, hidden_size * 2)
x = g * h + (1 - g) * x
# (batch, seq_len, hidden_size * 2)
return x

def att_flow_layer(c, q):
"""
:param c: (batch, c_len, hidden_size * 2)
:param q: (batch, q_len, hidden_size * 2)
:return: (batch, c_len, q_len)
"""
c_len = c.size(1)
q_len = q.size(1)

# (batch, c_len, q_len, hidden_size * 2)
#c_tiled = c.unsqueeze(2).expand(-1, -1, q_len, -1)
# (batch, c_len, q_len, hidden_size * 2)
#q_tiled = q.unsqueeze(1).expand(-1, c_len, -1, -1)
# (batch, c_len, q_len, hidden_size * 2)
#cq_tiled = c_tiled * q_tiled
#cq_tiled = c.unsqueeze(2).expand(-1, -1, q_len, -1) * q.unsqueeze(1).expand(-1, c_len, -1, -1)
# # 4. Attention Flow Layer
# # 注意力层
# self.att_weight_c = Linear(args.hidden_size * 2, 1)
# self.att_weight_q = Linear(args.hidden_size * 2, 1)
# self.att_weight_cq = Linear(args.hidden_size * 2, 1)
cq = []
# 1、相似度计算方式,看下这篇博客理解:https://blog.csdn.net/u014665013/article/details/79793395
for i in range(q_len):
qi = q.select(1, i).unsqueeze(1)
# (batch, 1, hidden_size * 2)
# .select看这个:https://blog.csdn.net/hungryof/article/details/51802829
ci = self.att_weight_cq(c * qi).squeeze()
# (batch, c_len, 1)
cq.append(ci)
cq = torch.stack(cq, dim=-1)
# (batch, c_len, q_len) cp是共享相似度矩阵


# 2、计算对每一个 context word 而言哪些 query words 和它最相关。
# context-to-query attention(C2Q):
s = self.att_weight_c(c).expand(-1, -1, q_len) + \
self.att_weight_q(q).permute(0, 2, 1).expand(-1, c_len, -1) + cq
# (batch, c_len, q_len)
a = F.softmax(s, dim=2)
# (batch, c_len, q_len)
c2q_att = torch.bmm(a, q)
# (batch, c_len, q_len) * (batch, q_len, hidden_size * 2) -> (batch, c_len, hidden_size * 2)


# 3、计算对每一个 query word 而言哪些 context words 和它最相关
# query-to-context attention(Q2C):
b = F.softmax(torch.max(s, dim=2)[0], dim=1).unsqueeze(1)
# (batch, 1, c_len)
q2c_att = torch.bmm(b, c).squeeze()
# (batch, 1, c_len) * (batch, c_len, hidden_size * 2) -> (batch, hidden_size * 2)
q2c_att = q2c_att.unsqueeze(1).expand(-1, c_len, -1)
# (batch, c_len, hidden_size * 2) (tiled)
# q2c_att = torch.stack([q2c_att] * c_len, dim=1)


# 4、最后将context embedding和C2Q、Q2C的结果(三个矩阵)拼接起来
x = torch.cat([c, c2q_att, c * c2q_att, c * q2c_att], dim=-1)
# (batch, c_len, hidden_size * 8)
return x

def output_layer(g, m, l):
"""
:param g: (batch, c_len, hidden_size * 8)
:param m: (batch, c_len ,hidden_size * 2)
# l = c_lens
:return: p1: (batch, c_len), p2: (batch, c_len)
"""
p1 = (self.p1_weight_g(g) + self.p1_weight_m(m)).squeeze()
# (batch, c_len)
m2 = self.output_LSTM((m, l))[0]
# (batch, c_len, hidden_size * 2)
p2 = (self.p2_weight_g(g) + self.p2_weight_m(m2)).squeeze()
# (batch, c_len)
return p1, p2

# 1. Character Embedding Layer
# 令:一个batch中单词数量最多的样本长度为seq_len
# 令:一个batch中某个单词长度最长的单词长度为word_len

c_char = char_emb_layer(batch.c_char)
# batch.c_char = (batch,seq_len,word_len) 后两个维度对应context
# c_char = (batch, seq_len, char_channel_size)

q_char = char_emb_layer(batch.q_char)
# batch.c_char = (batch,seq_len,word_len) 后两个维度对应question
# c_char = (batch, seq_len, char_channel_size)

# 2. Word Embedding Layer
c_word = self.word_emb(batch.c_word[0])
# batch.c_word[0] = (batch,seq_len) 后一个维度对应context
# c_word = (batch, seq_len, word_dim) word_dim是Glove词向量维度
q_word = self.word_emb(batch.q_word[0])
# batch.q_word[0] = (batch,seq_len) 后一个维度对应question
# q_word = (batch, seq_len, word_dim)
c_lens = batch.c_word[1]
# c_lens:一个batch中所有context的样本长度
q_lens = batch.q_word[1]
# q_lens:一个batch中所有question的样本长度

# Highway network
c = highway_network(c_char, c_word)
# c = (batch, seq_len, hidden_size * 2)
q = highway_network(q_char, q_word)
# q = (batch, seq_len, hidden_size * 2)

# 3. Contextual Embedding Layer
c = self.context_LSTM((c, c_lens))[0]
# c = (batch, seq_len, hidden_size * 2)
q = self.context_LSTM((q, q_lens))[0]
# q = (batch, seq_len, hidden_size * 2)

# 4. Attention Flow Layer
g = att_flow_layer(c, q)
# (batch, c_len, hidden_size * 8)

# 5. Modeling Layer
m = self.modeling_LSTM2((self.modeling_LSTM1((g, c_lens))[0], c_lens))[0]
# self.modeling_LSTM1((g, c_lens))[0] = (batch, c_len, hidden_size * 2) # 2因为是双向
# m = (batch, c_len, hidden_size * 2) 2因为是双向

# 6. Output Layer
p1, p2 = output_layer(g, m, c_lens) # 预测开始位置和结束位置
# (batch, c_len), (batch, c_len)
return p1, p2

In [ ]:

1
2
3
4
x = torch.rand((2,5,6))
print(x)
y = x.select(1, 2)
print(y)

In [ ]:

1
2
3
4
5
6
print(len(data.WORD.vocab)) # 108777个单词
print(data.WORD.vocab.vectors.shape) # 词向量维度

print(data.WORD.vocab.itos[:50]) # 前50个词频最高的单词
print("------"*10)
print(list(data.WORD.vocab.stoi.items())[0:50]) # 对应的索引

In [ ]:

1
2
3
4
print(len(data.CHAR.vocab)) # 1307个单词
print(data.CHAR.vocab.itos[:50]) # 108777个单词
print("------"*10)
print(list(data.CHAR.vocab.stoi.items())[0:50]) # 对应的索引

In [ ]:

1
2
device = torch.device(f"cuda:{args.gpu}" if torch.cuda.is_available() else "cpu")
model = BiDAF(args, data.WORD.vocab.vectors).to(device)

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class EMA():
def __init__(self, mu):
# mu = args.exp_decay_rate = 0.999
self.mu = mu
self.shadow = {}

def register(self, name, val):
# name:各个参数层的名字, param.data;参数层的数据
self.shadow[name] = val.clone() # 建立字典
# clone()得到的Tensor不仅拷贝了原始的value,而且会计算梯度传播信息,copy_()只拷贝数值

def get(self, name):
return self.shadow[name]

def update(self, name, x):
assert name in self.shadow
new_average = (1.0 - self.mu) * x + self.mu * self.shadow[name]
self.shadow[name] = new_average.clone()

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def test(model, ema, args, data):
device = torch.device(f"cuda:{args.gpu}" if torch.cuda.is_available() else "cpu")
criterion = nn.CrossEntropyLoss()
loss = 0
answers = dict()
model.eval()

backup_params = EMA(0)
for name, param in model.named_parameters():
if param.requires_grad:
backup_params.register(name, param.data) # 重新建立字典
param.data.copy_(ema.get(name))

with torch.set_grad_enabled(False):
for batch in iter(data.dev_iter):
p1, p2 = model(batch)
print(p1.shape,p2.shape)
print(batch.s_idx,batch.e_idx)
batch_loss = criterion(p1, batch.s_idx-1) + criterion(p2, batch.e_idx-1)
print("batch_loss",batch_loss)
print("----"*40)
loss += batch_loss.item()

# (batch, c_len, c_len)
batch_size, c_len = p1.size()
ls = nn.LogSoftmax(dim=1)
mask = (torch.ones(c_len, c_len) * float('-inf')).to(device).tril(-1).unsqueeze(0).expand(batch_size, -1, -1)
score = (ls(p1).unsqueeze(2) + ls(p2).unsqueeze(1)) + mask
score, s_idx = score.max(dim=1)
score, e_idx = score.max(dim=1)
s_idx = torch.gather(s_idx, 1, e_idx.view(-1, 1)).squeeze()

for i in range(batch_size):
id = batch.id[i]
answer = batch.c_word[0][i][s_idx[i]:e_idx[i]+1]
answer = ' '.join([data.WORD.vocab.itos[idx] for idx in answer])
answers[id] = answer

for name, param in model.named_parameters():
if param.requires_grad:
param.data.copy_(backup_params.get(name))

with open(args.prediction_file, 'w', encoding='utf-8') as f:
print(json.dumps(answers), file=f)

results = evaluate.main(args)
return loss, results['exact_match'], results['f1']

In [ ]:

1
2
3
4
for name, param in model.named_parameters():
print(name)
print(param.requires_grad)
print(param.data.shape)

In [ ]:

1
strftime('%H:%M:%S', gmtime())

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
iterator = data.train_iter
n= 0
for j in range(2):
print("j=",j)
for i, batch in enumerate(iterator):
print("当前epoch",int(iterator.epoch))
print("-----"*10)
print(i)
print(batch)
n+=1
if n>3:
break

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def train(args, data):
device = torch.device(f"cuda:{args.gpu}" if torch.cuda.is_available() else "cpu")
model = BiDAF(args, data.WORD.vocab.vectors).to(device) # 定义主模型类实例

ema = EMA(args.exp_decay_rate) # args.exp_decay_rate = 0.999
for name, param in model.named_parameters():
if param.requires_grad:
ema.register(name, param.data) # 参数名字和对应的参数数据形成字典
parameters = filter(lambda p: p.requires_grad, model.parameters())
# p.requires_grad = True or False 保留有梯度的参数
optimizer = optim.Adadelta(parameters, lr=args.learning_rate)
# args.learning_rate = 0.5,优化器选用Adadelta
criterion = nn.CrossEntropyLoss()
# 交叉熵损失

writer = SummaryWriter(log_dir='runs/' + args.model_time)
# args.model_time = strftime('%H:%M:%S', gmtime()) 文件夹命名为写入文件的当地时间

model.train()
loss, last_epoch = 0, -1
max_dev_exact, max_dev_f1 = -1, -1

iterator = data.train_iter
for i, batch in enumerate(iterator):
present_epoch = int(iterator.epoch)
#print("当前epoch",present_epoch)# 这个我打印了下,一直是0,觉得有问题
if present_epoch == args.epoch:
# args.epoch=12
break
if present_epoch > last_epoch:
print('epoch:', present_epoch + 1)
last_epoch = present_epoch

p1, p2 = model(batch)
# (batch, c_len), (batch, c_len)

optimizer.zero_grad()

batch_loss = criterion(p1, batch.s_idx) + criterion(p2, batch.e_idx)
# 最后的目标函数:batch.s_idx是答案开始的位置,batch.e_idx是答案结束的位置
loss += batch_loss.item()
batch_loss.backward()
optimizer.step()

for name, param in model.named_parameters():
if param.requires_grad:
ema.update(name, param.data) # 更新训练完后的的参数数据

if (i + 1) % args.print_freq == 0:
print("i",i)
dev_loss, dev_exact, dev_f1 = test(model, ema, args, data)
c = (i + 1) // args.print_freq

writer.add_scalar('loss/train', loss, c)
writer.add_scalar('loss/dev', dev_loss, c)
writer.add_scalar('exact_match/dev', dev_exact, c)
writer.add_scalar('f1/dev', dev_f1, c)
print(f'train loss: {loss:.3f} / dev loss: {dev_loss:.3f}'
f' / dev EM: {dev_exact:.3f} / dev F1: {dev_f1:.3f}')

if dev_f1 > max_dev_f1:
max_dev_f1 = dev_f1
max_dev_exact = dev_exact
best_model = copy.deepcopy(model)

loss = 0
model.train()

writer.close()
# print(f'max dev EM: {max_dev_exact:.3f} / max dev F1: {max_dev_f1:.3f}')

return best_model

In [ ]:

1
2
print('training start!')
best_model = train(args, data)

In [ ]:

1
2


In [ ]:

1
https://github.com/pytorch/pytorch/issues/4144

NLP中的ConvNet

​ NLP/AI是近几年来飞速发展的领域,很多的模型和算法只能在论文、讲义和博客中找到,而不会出现在任何的教科书中。凡是课程中提到的论文,大家都能够阅读一遍。对于重要的论文(我会特别标明或者在课上强调,例如BERT, transformer等),建议认真阅读,搞清楚模型的细节。其余的论文,建议至少能够阅读,了解论文的创新点和中心思想。

如何读论文?

对于如何读论文,每个人有自己不同的方法。我的建议是:

  • 最快读论文的方法:上各大中文网站(知乎,CSDN,微信公众号等)寻找该论文的中文解读,大部分有名的论文都会有很多的解读文章。

  • 读论文时候的重点章节:大部分NLP的论文的主要两个章节是,Model, Experiments。基本上看完这两个章节就了解了论文的核心思想。另外我也会特别关注论文使用的数据,因为这些数据我们可能可以拿来用在自己的项目上。

  • 如果想要更加深入地学习该论文的内容,可以上网去寻找与该论文相关的资料,包括作者的个人主页,他/她发布的论文slides,论文代码等等。顺便说一下,如果你想要复现论文的结果,但是在网上找不到代码,不要急于自己实现,可以写邮件给论文的第一作者与通讯作者(最后一位),礼貌地询问对方是否可以将源码和数据提供给你,理论上论文作者有义务公开自己的代码和数据。如果没有代码可以公开,要不然可能是论文太新,还没有公开代码,要不然可能是论文中某些部分的实现有困难,不那么容易复现。

  • 另外如果你想要更深入地学习这个论文相关的领域,可以读一下Related Work中提到的一些文章。

NLP中的 ConvNet 精选论文

MNIST

convolutional kernel: local feature detector

图像:

  • 平移不变性

  • pixel features

Hinton

  • Capsule Network

  • ConvNet的缺陷:

  • 没有处理旋转不变性

  • 图片大小发生改变

文本

  • ngram

  • ngram 之间的联系 n-n-gram

曾经有一段时间由于Yann Lecun加入Facebook AI Research担任Director的关系,FB投入了很多的精力研发把ConvNet用在Text问题上。ConvNet主打的一个强项就是速度比RNN快,Encoder可以并行。后来可能是由于Google的Transformer开始统治这个领域,导致大家慢慢在ConvNet上的关注度越来越小。

transformer (BERT) 就是 filter size 为 1 的 convolutional neural network 。

不过这一系列以ConvNet为核心的NLP模型依然非常值得学习。ConvNet的一个长处在于它可以很自然地得到 ngram 的表示。由于NLP最近的进展日新月异,可能几天或者几个月之后又有一系列基于ConvNet的模型重登SOTA,谁知道呢。

对于不了解什么是Convolutional Neural Network的同学,建议阅读斯坦福cs231的课程资料 http://cs231n.github.io/convolutional-networks/ 网上的中文翻译很多,例如:https://zhuanlan.zhihu.com/p/22038289?refer=intelligentunit

Yoon Kim Convolutional Neural Networks for Sentence Classification

https://aclweb.org/anthology/D14-1181

这篇文章首次提出了在text上使用convolutional network,并且取得了不错的效果。后续很多把ConvNet用在NLP任务上都是基于这篇论文的模型改进。

模型架构图

img

embedding层

img

convolution层

img

img

Max over time pooling

img

输出层

一个affine transformation加上dropout

img

模型的效果

可以媲美当时的众多传统模型。从今天的眼光来看这个模型的思路还是挺简单的,不过当时大家开始探索把CNN用到text问题上的时候,这一系列模型架构的想法还是很新颖的。

img

我们的代码实现

用ConvNet做文本分类的部分代码。有些部分可能的实现可能和模型有一定出入,不过我的模型实现效果也很不错,仅供参考。

https://github.com/ZeweiChu/PyTorch-Course/blob/master/notebooks/4.sentiment_with_mask.ipynb

感兴趣的同学可以参考更多Yoon Kim的工作

http://www.people.fas.harvard.edu/~yoonkim/

Yoon Kim的导师Alex Rush

http://nlp.seas.harvard.edu/rush.html

他们的一项工作OpenNMT-py

https://github.com/OpenNMT/OpenNMT-py

Alex Rush的一些优秀学生

Sam Wiseman https://swiseman.github.io/ 他做了很多VAE的工作

Zhang et. al., Character-level Convolutional Networks for Text Classification

https://papers.nips.cc/paper/5782-character-level-convolutional-networks-for-text-classification.pdf

这篇文章在char层面上使用ConvNet,当时在分类任务上取得了SOTA的效果。后来人们经常把这套方法用来做单词表示的学习,例如ELMo就是用CharCNN来encode单词的。

关键Modules

Convolutional Module

img

k是kernel size。

max pooling

img

模型架构图

img

在ELMo上的character embedding

img

模型代码

https://github.com/srviest/char-cnn-text-classification-pytorch/blob/master/model.py

Gehring et. al., Convolutional Sequence to Sequence Learning

https://arxiv.org/pdf/1705.03122.pdf

参考博客资料

https://ycts.github.io/weeklypapers/convSeq2seq/

用ConvNet做Seq2Seq模型,其实这篇文章中有很多Transformer的影子,并且模型效果也很好。可能由于同时期的Transformer光芒过于耀眼,掩盖了这一篇同样非常重量级的文章。

我的建议是,这篇文章可以简要阅读,了解ConvNet可以怎么样被运用到Text Modeling问题上。由于现在学术界和工业界的主流是各种Transformer模型的变种,且Transformer的模型相对更简洁易懂,所以建议同学们在后面花更多的时间在Transformer上。最近很多NLP的面试都会问到一些与Transformer和BERT相关的问题,可能很多人不太了解这篇Conv Seq2Seq的论文。

Positional Embedddings

img

对每个单词分别做word embedding w_i和positional embedding p_i,然后单词的embedding的w_i + p_i。p_i是模型的参数,在训练中会被更新。

如果没有positional embedding,CNN是无法知晓单词的位置信息的。因为不同于LSTM,如果没有postional embedding,在CNN encoder中的单词位置其实没有区别。

Convolutional Block Structure

Encoder和Decoder第l层的输入

img

每一层都包含一个一维Convolution,以及一个non-linearity单元,其中conv block/layer的kernel宽度为k,其output包含k个输入元素的信息。参数为

img

输出为

img

然后使用一个Gated Linear Units作为non-linearity。

img

encoder和decoder都有好多层,每一层都加上了residual connection。

img

我们在encoder每一层的左右两边都添加padding,这样可以保证每一层经过convolution之后输出的长度和原来一样。decoder和encoder稍有不同,因为我们必须保证我们在decoder一个位置的单词的时候没有看到这个位置后面的单词。所以我们的做法是,在decoder每一层左右两边都加上k-1个padding,做完conv之后把右边的k个单位移除。

最后的一个标准套路是把hidden state做个affine transformation,然后Softmax变成单词表上的一个概率分布。

img

Multi-step Attention

Decoder的每一层都有单独的Attention。

img

g_i是当前单词的embedding,

img

然后我们用这个新造的 d_i^l 对 encoder 的每个位置做attention。

img

然后非常常规的,用attention score对encoder hidden states做加权平均。唯一不同的是,这里还直接加上了输入的embedding。

img

作者说他们发现直接加上这个词向量的embedding还是很有用的。

模型架构图

img

Normalization策略

为了保持模型训练的稳定性,我们希望模型中间的向量的variance不要太大。

  • 输出+residual之后乘以\sqrt{5},这样可以让这些vector每个维度的variance减半。其实很多时候这些确保模型稳定度的细节挺关键的,大家可能也知道transformer中也增加了一些减少variance的方法。如果不是调模型专家就会忽视这些细节,然后模型就训练不好了。

img

还有更多的模型参数初始化细节,感兴趣的同学可以自己去认真阅读paper。

实验结果

img

在翻译任务上超越了GNMT (Google Neural Machine Translation),其实这个比较能说明问题,因为当时的GNMT是State of the Art。

img

然后他们还展示了ConvS2S的速度比GNMT更快。

总结来说,ConvS2S其实是一篇很有价值的文章,Decoder的设计比较精致, 不知道这篇文章对后来的Transformer产生了多少的影响,当然他们可以说是同时期的作品。

代码

主要代码在Fairseq的下面这个文件中

https://github.com/ZeweiChu/fairseq/blob/master/fairseq/models/fconv.py

Fairseq是一个值得关注一波的工具包,由Facebook开发,主要开发者有

关于文本分类的更多参考资料

基于深度学习的文本分类

https://zhuanlan.zhihu.com/p/34212945

seq2seq

Seq2Seq, Attention

在这份notebook当中,我们会(尽可能)复现Luong的attention模型

由于我们的数据集非常小,只有一万多个句子的训练数据,所以训练出来的模型效果并不好。如果大家想训练一个好一点的模型,可以参考下面的资料。

更多阅读

课件

论文

PyTorch代码

更多关于Machine Translation

  • Beam Search
  • Pointer network 文本摘要
  • Copy Mechanism 文本摘要
  • Converage Loss
  • ConvSeq2Seq
  • Transformer
  • Tensor2Tensor

TODO

  • 建议同学尝试对中文进行分词

NER

In [137]:

1
2
3
4
5
6
7
8
9
10
11
12
import os
import sys
import math
from collections import Counter #计数器
import numpy as np
import random

import torch
import torch.nn as nn
import torch.nn.functional as F

import nltk

读入中英文数据

  • 英文我们使用nltk的word tokenizer来分词,并且使用小写字母
  • 中文我们直接使用单个汉字作为基本单元

In [138]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def load_data(in_file):
cn = []
en = []
num_examples = 0
with open(in_file, 'r') as f:
for line in f:
#print(line) #Anyone can do that. 任何人都可以做到。
line = line.strip().split("\t") #分词后用逗号隔开
#print(line) #['Anyone can do that.', '任何人都可以做到。']
en.append(["BOS"] + nltk.word_tokenize(line[0].lower()) + ["EOS"])
#BOS:beginning of sequence EOS:end of
# split chinese sentence into characters
cn.append(["BOS"] + [c for c in line[1]] + ["EOS"])
#中文一个一个字分词,可以尝试用分词器分词
return en, cn

train_file = "nmt/en-cn/train.txt"
dev_file = "nmt/en-cn/dev.txt"
train_en, train_cn = load_data(train_file)
dev_en, dev_cn = load_data(dev_file)

In [0]:

1
print(train_en[:10])
1
[['BOS', 'anyone', 'can', 'do', 'that', '.', 'EOS'], ['BOS', 'how', 'about', 'another', 'piece', 'of', 'cake', '?', 'EOS'], ['BOS', 'she', 'married', 'him', '.', 'EOS'], ['BOS', 'i', 'do', "n't", 'like', 'learning', 'irregular', 'verbs', '.', 'EOS'], ['BOS', 'it', "'s", 'a', 'whole', 'new', 'ball', 'game', 'for', 'me', '.', 'EOS'], ['BOS', 'he', "'s", 'sleeping', 'like', 'a', 'baby', '.', 'EOS'], ['BOS', 'he', 'can', 'play', 'both', 'tennis', 'and', 'baseball', '.', 'EOS'], ['BOS', 'we', 'should', 'cancel', 'the', 'hike', '.', 'EOS'], ['BOS', 'he', 'is', 'good', 'at', 'dealing', 'with', 'children', '.', 'EOS'], ['BOS', 'she', 'will', 'do', 'her', 'best', 'to', 'be', 'here', 'on', 'time', '.', 'EOS']]

In [0]:

1
print(train_cn[:10])
1
[['BOS', '任', '何', '人', '都', '可', '以', '做', '到', '。', 'EOS'], ['BOS', '要', '不', '要', '再', '來', '一', '塊', '蛋', '糕', '?', 'EOS'], ['BOS', '她', '嫁', '给', '了', '他', '。', 'EOS'], ['BOS', '我', '不', '喜', '欢', '学', '习', '不', '规', '则', '动', '词', '。', 'EOS'], ['BOS', '這', '對', '我', '來', '說', '是', '個', '全', '新', '的', '球', '類', '遊', '戲', '。', 'EOS'], ['BOS', '他', '正', '睡', '着', ',', '像', '个', '婴', '儿', '一', '样', '。', 'EOS'], ['BOS', '他', '既', '会', '打', '网', '球', ',', '又', '会', '打', '棒', '球', '。', 'EOS'], ['BOS', '我', '們', '應', '該', '取', '消', '這', '次', '遠', '足', '。', 'EOS'], ['BOS', '他', '擅', '長', '應', '付', '小', '孩', '子', '。', 'EOS'], ['BOS', '她', '会', '尽', '量', '按', '时', '赶', '来', '的', '。', 'EOS']]

In [0]:

1
2


构建单词表

In [139]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
UNK_IDX = 0
PAD_IDX = 1
def build_dict(sentences, max_words=50000):
word_count = Counter()
for sentence in sentences:
for s in sentence:
word_count[s] += 1 #word_count这里应该是个字典
ls = word_count.most_common(max_words)
#按每个单词数量排序前50000个,这个数字自己定的,不重复单词数没有50000
print(len(ls)) #train_en:5491
total_words = len(ls) + 2
#加的2是留给"unk"和"pad"
#ls = [('BOS', 14533), ('EOS', 14533), ('.', 12521), ('i', 4045), .......
word_dict = {w[0]: index+2 for index, w in enumerate(ls)}
#加的2是留给"unk"和"pad",转换成字典格式。
word_dict["UNK"] = UNK_IDX
word_dict["PAD"] = PAD_IDX
return word_dict, total_words

en_dict, en_total_words = build_dict(train_en)
cn_dict, cn_total_words = build_dict(train_cn)
inv_en_dict = {v: k for k, v in en_dict.items()}
#en_dict.items()把字典转换成可迭代对象,取出键值,并调换键值的位置。
inv_cn_dict = {v: k for k, v in cn_dict.items()}
1
2
5491
3193

In [1]:

1
2
# print(en_dict)
# print(en_total_words)

In [3]:

1
2
print(cn_dict)
print(cn_total_words)

In [4]:

1
print(inv_en_dict)

In [5]:

1
print(inv_cn_dict)

把单词全部转变成数字

In [140]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def encode(en_sentences, cn_sentences, en_dict, cn_dict, sort_by_len=True):
'''
Encode the sequences.
'''
length = len(en_sentences)
#en_sentences=[['BOS', 'anyone', 'can', 'do', 'that', '.', 'EOS'],....

out_en_sentences = [[en_dict.get(w, 0) for w in sent] for sent in en_sentences]
#out_en_sentences=[[2, 328, 43, 14, 28, 4, 3], ....
#.get(w, 0),返回w对应的值,没有就为0.因题库比较小,这里所有的单词向量都有非零索引。


out_cn_sentences = [[cn_dict.get(w, 0) for w in sent] for sent in cn_sentences]

# sort sentences by english lengths
def len_argsort(seq):
return sorted(range(len(seq)), key=lambda x: len(seq[x]))
#sorted()排序,key参数可以自定义规则,按seq[x]的长度排序,seq[0]为第一句话长度

# 把中文和英文按照同样的顺序排序
if sort_by_len:
sorted_index = len_argsort(out_en_sentences)
#print(sorted_index)
#sorted_index=[63, 1544, 1917, 2650, 3998, 6240, 6294, 6703, ....
#前面的索引都是最短句子的索引

out_en_sentences = [out_en_sentences[i] for i in sorted_index]
#print(out_en_sentences)
#out_en_sentences=[[2, 475, 4, 3], [2, 1318, 126, 3], [2, 1707, 126, 3], ......

out_cn_sentences = [out_cn_sentences[i] for i in sorted_index]

return out_en_sentences, out_cn_sentences

train_en, train_cn = encode(train_en, train_cn, en_dict, cn_dict)
dev_en, dev_cn = encode(dev_en, dev_cn, en_dict, cn_dict)

In [6]:

1
2
3
k=10000
print(" ".join([inv_cn_dict[i] for i in train_cn[k]])) #通过inv字典获取单词
print(" ".join([inv_en_dict[i] for i in train_en[k]]))
1
2
BOS 他 来 这 里 的 目 的 是 什 么 ? EOS
BOS for what purpose did he come here ? EOS

把全部句子分成batch

In [0]:

1
2
print(np.arange(0, 100, 15))
print(np.arange(0, 15))
1
2
[ 0 15 30 45 60 75 90]
[ 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14]

In [141]:

1
2
3
4
5
6
7
8
9
def get_minibatches(n, minibatch_size, shuffle=True):
idx_list = np.arange(0, n, minibatch_size) # [0, 1, ..., n-1]
if shuffle:
np.random.shuffle(idx_list) #打乱数据
minibatches = []
for idx in idx_list:
minibatches.append(np.arange(idx, min(idx + minibatch_size, n)))
#所有batch放在一个大列表里
return minibatches

In [10]:

1
get_minibatches(100,15) #随机打乱的

Out[10]:

1
2
3
4
5
6
7
[array([75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89]),
array([45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59]),
array([30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44]),
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]),
array([15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]),
array([60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74]),
array([90, 91, 92, 93, 94, 95, 96, 97, 98, 99])]

In [142]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def prepare_data(seqs):
#seqs=[[2, 12, 167, 23, 114, 5, 27, 1755, 4, 3], ........
lengths = [len(seq) for seq in seqs]#每个batch里语句的长度统计出来
n_samples = len(seqs) #一个batch有多少语句
max_len = np.max(lengths) #取出最长的的语句长度,后面用这个做padding基准
x = np.zeros((n_samples, max_len)).astype('int32')
#先初始化全零矩阵,后面依次赋值
#print(x.shape) #64*最大句子长度

x_lengths = np.array(lengths).astype("int32")
#print(x_lengths)
#这里看下面的输入语句发现英文句子长度都一样,中文句子长短不一。
#说明英文句子是特征,中文句子是标签。


for idx, seq in enumerate(seqs):
#取出一个batch的每条语句和对应的索引
x[idx, :lengths[idx]] = seq
#每条语句按行赋值给x,x会有一些零值没有被赋值。

return x, x_lengths #x_mask

def gen_examples(en_sentences, cn_sentences, batch_size):
minibatches = get_minibatches(len(en_sentences), batch_size)
all_ex = []
for minibatch in minibatches:
mb_en_sentences = [en_sentences[t] for t in minibatch]
#按打乱的batch序号分数据,打乱只是batch打乱,一个batach里面的语句还是顺序的。
#print(mb_en_sentences)

mb_cn_sentences = [cn_sentences[t] for t in minibatch]
mb_x, mb_x_len = prepare_data(mb_en_sentences)
#返回的维度为:mb_x=(64 * 最大句子长度),mb_x_len=最大句子长度
mb_y, mb_y_len = prepare_data(mb_cn_sentences)

all_ex.append((mb_x, mb_x_len, mb_y, mb_y_len))
#这里把所有batch数据集合到一起。
#依次为英文句子,英文长度,中文句子翻译,中文句子长度,这四个放在一个列表中
#一个列表为一个batch的数据,所有batch组成一个大列表数据


return all_ex

batch_size = 64
train_data = gen_examples(train_en, train_cn, batch_size)
random.shuffle(train_data)
dev_data = gen_examples(dev_en, dev_cn, batch_size)

In [28]:

1
train_data[0]

Out[28]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
(array([[   2,   12,  707,   23,    7,  295,    4,    3],
[ 2, 12, 120, 1207, 517, 604, 4, 3],
[ 2, 8, 90, 433, 64, 1470, 126, 3],
[ 2, 12, 144, 46, 9, 94, 4, 3],
[ 2, 25, 10, 9, 535, 639, 4, 3],
[ 2, 25, 10, 64, 377, 2512, 4, 3],
[ 2, 12, 43, 309, 9, 96, 4, 3],
[ 2, 43, 328, 1475, 25, 469, 11, 3],
[ 2, 82, 1043, 34, 1991, 2514, 4, 3],
[ 2, 5, 54, 7, 181, 1694, 4, 3],
[ 2, 30, 51, 472, 6, 294, 11, 3],
[ 2, 5, 241, 16, 65, 551, 4, 3],
[ 2, 14, 8, 36, 2516, 680, 11, 3],
[ 2, 8, 30, 9, 66, 333, 4, 3],
[ 2, 12, 10, 34, 40, 777, 4, 3],
[ 2, 29, 54, 9, 138, 1633, 4, 3],
[ 2, 43, 8, 309, 9, 96, 11, 3],
[ 2, 47, 12, 39, 59, 190, 11, 3],
[ 2, 29, 85, 14, 150, 221, 4, 3],
[ 2, 12, 70, 37, 36, 242, 4, 3],
[ 2, 5, 239, 64, 2521, 1696, 4, 3],
[ 2, 5, 14, 13, 36, 314, 4, 3],
[ 2, 5, 234, 7, 45, 44, 4, 3],
[ 2, 5, 76, 226, 17, 621, 4, 3],
[ 2, 29, 180, 9, 269, 266, 4, 3],
[ 2, 85, 5, 22, 6, 708, 11, 3],
[ 2, 6, 788, 48, 37, 889, 4, 3],
[ 2, 8, 63, 124, 45, 95, 4, 3],
[ 2, 921, 10, 21, 640, 350, 4, 3],
[ 2, 52, 10, 6, 296, 44, 11, 3],
[ 2, 681, 10, 190, 24, 146, 11, 3],
[ 2, 19, 1480, 838, 7, 596, 4, 3],
[ 2, 29, 90, 472, 2036, 132, 4, 3],
[ 2, 8, 90, 9, 66, 645, 4, 3],
[ 2, 5, 192, 257, 7, 684, 4, 3],
[ 2, 5, 68, 36, 384, 1686, 4, 3],
[ 2, 12, 10, 120, 38, 23, 4, 3],
[ 2, 18, 47, 965, 106, 112, 4, 3],
[ 2, 8, 30, 37, 9, 250, 4, 3],
[ 2, 31, 20, 129, 20, 900, 11, 3],
[ 2, 29, 519, 118, 2044, 1313, 4, 3],
[ 2, 29, 22, 6, 294, 229, 4, 3],
[ 2, 25, 189, 1056, 335, 151, 4, 3],
[ 2, 8, 67, 89, 57, 887, 4, 3],
[ 2, 41, 8, 72, 59, 362, 11, 3],
[ 2, 51, 923, 2534, 26, 364, 4, 3],
[ 2, 22, 8, 1209, 914, 834, 11, 3],
[ 2, 19, 48, 9, 1127, 847, 4, 3],
[ 2, 25, 224, 70, 13, 425, 4, 3],
[ 2, 19, 949, 62, 1112, 657, 4, 3],
[ 2, 87, 10, 6, 751, 443, 11, 3],
[ 2, 19, 144, 99, 9, 539, 4, 3],
[ 2, 19, 599, 242, 117, 103, 4, 3],
[ 2, 14, 8, 22, 9, 386, 11, 3],
[ 2, 16, 20, 60, 7, 45, 4, 3],
[ 2, 25, 145, 133, 10, 1974, 4, 3],
[ 2, 25, 10, 426, 17, 343, 4, 3],
[ 2, 5, 22, 239, 6, 461, 4, 3],
[ 2, 14, 13, 8, 162, 242, 11, 3],
[ 2, 8, 67, 13, 159, 59, 4, 3],
[ 2, 140, 3452, 1220, 33, 601, 4, 3],
[ 2, 5, 79, 1937, 35, 232, 4, 3],
[ 2, 18, 1612, 35, 779, 926, 4, 3],
[ 2, 12, 197, 599, 6, 632, 4, 3]], dtype=int32),
array([8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8],
dtype=int32),
array([[ 2, 9, 793, ..., 0, 0, 0],
[ 2, 9, 504, ..., 0, 0, 0],
[ 2, 8, 114, ..., 0, 0, 0],
...,
[ 2, 5, 154, ..., 0, 0, 0],
[ 2, 214, 171, ..., 838, 4, 3],
[ 2, 9, 74, ..., 0, 0, 0]], dtype=int32),
array([10, 12, 9, 10, 8, 10, 7, 13, 17, 8, 11, 10, 11, 9, 9, 12, 8,
12, 10, 9, 14, 9, 9, 6, 9, 10, 9, 10, 13, 11, 14, 13, 14, 8,
8, 10, 10, 9, 8, 7, 14, 12, 13, 13, 13, 12, 13, 8, 11, 11, 10,
12, 10, 9, 6, 10, 8, 11, 9, 11, 10, 12, 21, 9], dtype=int32))

没有Attention的版本

下面是一个更简单的没有Attention的encoder decoder模型

In [143]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class PlainEncoder(nn.Module):
def __init__(self, vocab_size, hidden_size, dropout=0.2):
#以英文为例,vocab_size=5493, hidden_size=100, dropout=0.2
super(PlainEncoder, self).__init__()
self.embed = nn.Embedding(vocab_size, hidden_size)
#这里的hidden_size为embedding_dim:一个单词的维度
#torch.nn.Embedding(num_embeddings, embedding_dim, .....)
#这里的hidden_size = 100

self.rnn = nn.GRU(hidden_size, hidden_size, batch_first=True)
#第一个参数为input_size :输入特征数量
#第二个参数为hidden_size :隐藏层特征数量

self.dropout = nn.Dropout(dropout)

def forward(self, x, lengths):
#x是输入的batch的所有单词,lengths:batch里每个句子的长度
#因为需要把最后一个hidden state取出来,需要知道长度,因为句子长度不一样
##print(x.shape,lengths),x.sahpe = torch.Size([64, 10])
# lengths= =tensor([10, 10, 10, ..... 10, 10, 10])

sorted_len, sorted_idx = lengths.sort(0, descending=True)
#按照长度排序,descending=True长的在前。
#返回两个参数,句子长度和未排序前的索引
# sorted_idx=tensor([41, 40, 46, 45,...... 19, 18, 63])
# sorted_len=tensor([10, 10, 10, ..... 10, 10, 10])

x_sorted = x[sorted_idx.long()] #句子用新的idx,按长度排好序了

embedded = self.dropout(self.embed(x_sorted))
#print(embedded.shape)=torch.Size([64, 10, 100])
#tensor([[[-0.6312, -0.9863, -0.3123, ..., -0.7384, 0.9230, -0.4311],....

packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, sorted_len.long().cpu().data.numpy(), batch_first=True)
#这个函数就是用来处理不同长度的句子的,https: // www.cnblogs.com / sbj123456789 / p / 9834018. html

packed_out, hid = self.rnn(packed_embedded)
#hid.shape = torch.Size([1, 64, 100])

out, _ = nn.utils.rnn.pad_packed_sequence(packed_out, batch_first=True)
#out.shape = torch.Size([64, 10, 100]),

_, original_idx = sorted_idx.sort(0, descending=False)
out = out[original_idx.long()].contiguous()
hid = hid[:, original_idx.long()].contiguous()
#out.shape = torch.Size([64, 10, 100])
#hid.shape = torch.Size([1, 64, 100])

return out, hid[[-1]] #有时候num_layers层数多,需要取出最后一层

In [124]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class PlainDecoder(nn.Module):
def __init__(self, vocab_size, hidden_size, dropout=0.2):
super(PlainDecoder, self).__init__()
self.embed = nn.Embedding(vocab_size, hidden_size)
self.rnn = nn.GRU(hidden_size, hidden_size, batch_first=True)
self.out = nn.Linear(hidden_size, vocab_size)
self.dropout = nn.Dropout(dropout)

def forward(self, y, y_lengths, hid):
#print(y.shape)=torch.Size([64, 12])
#print(hid.shape)=torch.Size([1, 64, 100])
#中文的y和y_lengths
sorted_len, sorted_idx = y_lengths.sort(0, descending=True)
y_sorted = y[sorted_idx.long()]
hid = hid[:, sorted_idx.long()] #隐藏层也要排序

y_sorted = self.dropout(self.embed(y_sorted))
# batch_size, output_length, embed_size

packed_seq = nn.utils.rnn.pack_padded_sequence(y_sorted, sorted_len.long().cpu().data.numpy(), batch_first=True)
out, hid = self.rnn(packed_seq, hid) #加上隐藏层
#print(hid.shape)=torch.Size([1, 64, 100])
unpacked, _ = nn.utils.rnn.pad_packed_sequence(out, batch_first=True)
_, original_idx = sorted_idx.sort(0, descending=False)
output_seq = unpacked[original_idx.long()].contiguous()
#print(output_seq.shape)=torch.Size([64, 12, 100])
hid = hid[:, original_idx.long()].contiguous()
#print(hid.shape)=torch.Size([1, 64, 100])
output = F.log_softmax(self.out(output_seq), -1)
#print(output.shape)=torch.Size([64, 12, 3195])

return output, hid

In [144]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class PlainSeq2Seq(nn.Module):
def __init__(self, encoder, decoder):
#encoder是上面PlainEncoder的实例
#decoder是上面PlainDecoder的实例
super(PlainSeq2Seq, self).__init__()
self.encoder = encoder
self.decoder = decoder

#把两个模型串起来
def forward(self, x, x_lengths, y, y_lengths):
encoder_out, hid = self.encoder(x, x_lengths)
#self.encoder(x, x_lengths)调用PlainEncoder里面forward的方法
#返回forward的out和hid

output, hid = self.decoder(y=y,y_lengths=y_lengths,hid=hid)
#self.dencoder()调用PlainDecoder里面forward的方法

return output, None

def translate(self, x, x_lengths, y, max_length=10):
#x是一个句子,用数值表示
#y是句子的长度
#y是“bos”的数值索引=2

encoder_out, hid = self.encoder(x, x_lengths)
preds = []
batch_size = x.shape[0]
attns = []
for i in range(max_length):
output, hid = self.decoder(y=y,
y_lengths=torch.ones(batch_size).long().to(y.device),
hid=hid)

#刚开始循环bos作为模型的首个输入单词,后续更新y,下个预测单词的输入是上个输出单词
y = output.max(2)[1].view(batch_size, 1)
preds.append(y)

return torch.cat(preds, 1), None

In [145]:

1
2
3
4
5
6
7
8
9
10
11
12
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dropout = 0.2
hidden_size = 100

#传入中文和英文参数
encoder = PlainEncoder(vocab_size=en_total_words,
hidden_size=hidden_size,
dropout=dropout)
decoder = PlainDecoder(vocab_size=cn_total_words,
hidden_size=hidden_size,
dropout=dropout)
model = PlainSeq2Seq(encoder, decoder)

In [146]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# masked cross entropy loss
class LanguageModelCriterion(nn.Module):
def __init__(self):
super(LanguageModelCriterion, self).__init__()

def forward(self, input, target, mask):
#target=tensor([[5,108,8,4,3,0,0,0,0,0,0,0],....
# mask=tensor([[1,1 ,1,1,1,0,0,0,0,0,0,0],.....
#print(input.shape,target.shape,mask.shape)
#torch.Size([64, 12, 3195]) torch.Size([64, 12]) torch.Size([64, 12])

# input: (batch_size * seq_len) * vocab_size
input = input.contiguous().view(-1, input.size(2))

# target: batch_size * 1=768*1
target = target.contiguous().view(-1, 1)
mask = mask.contiguous().view(-1, 1)
#print(-input.gather(1, target))
output = -input.gather(1, target) * mask
#这里算得就是交叉熵损失,前面已经算了F.log_softmax
#.gather的作用https://blog.csdn.net/edogawachia/article/details/80515038
#output.shape=torch.Size([768, 1])
#mask作用是把padding为0的地方重置为零,因为input.gather时,为0的地方不是零了

output = torch.sum(output) / torch.sum(mask)
#均值损失

return output

In [147]:

1
2
3
model = model.to(device)
loss_fn = LanguageModelCriterion().to(device)
optimizer = torch.optim.Adam(model.parameters())

pythonIn [151]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def train(model, data, num_epochs=2):
for epoch in range(num_epochs):
model.train()
total_num_words = total_loss = 0.
for it, (mb_x, mb_x_len, mb_y, mb_y_len) in enumerate(data):
#(英文batch,英文长度,中文batch,中文长度)

mb_x = torch.from_numpy(mb_x).to(device).long()
mb_x_len = torch.from_numpy(mb_x_len).to(device).long()

#前n-1个单词作为输入,后n-1个单词作为输出,因为输入的前一个单词要预测后一个单词
mb_input = torch.from_numpy(mb_y[:, :-1]).to(device).long()
mb_output = torch.from_numpy(mb_y[:, 1:]).to(device).long()
#
mb_y_len = torch.from_numpy(mb_y_len-1).to(device).long()
#输入输出的长度都减一。

mb_y_len[mb_y_len<=0] = 1

mb_pred, attn = model(mb_x, mb_x_len, mb_input, mb_y_len)
#返回的是类PlainSeq2Seq里forward函数的两个返回值

mb_out_mask = torch.arange(mb_y_len.max().item(), device=device)[None, :] < mb_y_len[:, None]
#mb_out_mask=tensor([[1, 1, 1, ..., 0, 0, 0],[1, 1, 1, ..., 0, 0, 0],
#mb_out_mask.shape= (64*19),这句代码咱不懂,这个mask就是padding的位置设置为0,其他设置为1
#mb_out_mask就是LanguageModelCriterion的传入参数mask。

mb_out_mask = mb_out_mask.float()

loss = loss_fn(mb_pred, mb_output, mb_out_mask)

num_words = torch.sum(mb_y_len).item()
#一个batch里多少个单词

total_loss += loss.item() * num_words
#总损失,loss计算的是均值损失,每个单词都是都有损失,所以乘以单词数

total_num_words += num_words
#总单词数

# 更新模型
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.)
#为了防止梯度过大,设置梯度的阈值

optimizer.step()

if it % 100 == 0:
print("Epoch", epoch, "iteration", it, "loss", loss.item())


print("Epoch", epoch, "Training loss", total_loss/total_num_words)
if epoch % 5 == 0:
evaluate(model, dev_data) #评估模型
train(model, train_data, num_epochs=2)
1
2
3
4
5
6
7
8
9
Epoch 0 iteration 0 loss 4.277793884277344
Epoch 0 iteration 100 loss 3.5520756244659424
Epoch 0 iteration 200 loss 3.483494997024536
Epoch 0 Training loss 3.6435126089915557
Evaluation loss 3.698509503997669
Epoch 1 iteration 0 loss 4.158623218536377
Epoch 1 iteration 100 loss 3.412541389465332
Epoch 1 iteration 200 loss 3.3976175785064697
Epoch 1 Training loss 3.5087569079050698

In [135]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def evaluate(model, data):
model.eval()
total_num_words = total_loss = 0.
with torch.no_grad():#不需要更新模型,不需要梯度
for it, (mb_x, mb_x_len, mb_y, mb_y_len) in enumerate(data):
mb_x = torch.from_numpy(mb_x).to(device).long()
mb_x_len = torch.from_numpy(mb_x_len).to(device).long()
mb_input = torch.from_numpy(mb_y[:, :-1]).to(device).long()
mb_output = torch.from_numpy(mb_y[:, 1:]).to(device).long()
mb_y_len = torch.from_numpy(mb_y_len-1).to(device).long()
mb_y_len[mb_y_len<=0] = 1

mb_pred, attn = model(mb_x, mb_x_len, mb_input, mb_y_len)

mb_out_mask = torch.arange(mb_y_len.max().item(), device=device)[None, :] < mb_y_len[:, None]
mb_out_mask = mb_out_mask.float()

loss = loss_fn(mb_pred, mb_output, mb_out_mask)

num_words = torch.sum(mb_y_len).item()
total_loss += loss.item() * num_words
total_num_words += num_words
print("Evaluation loss", total_loss/total_num_words)

In [ ]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#翻译个句子看看结果咋样
def translate_dev(i):
#随便取出句子
en_sent = " ".join([inv_en_dict[w] for w in dev_en[i]])
print(en_sent)
cn_sent = " ".join([inv_cn_dict[w] for w in dev_cn[i]])
print("".join(cn_sent))

mb_x = torch.from_numpy(np.array(dev_en[i]).reshape(1, -1)).long().to(device)
#把句子升维,并转换成tensor

mb_x_len = torch.from_numpy(np.array([len(dev_en[i])])).long().to(device)
#取出句子长度,并转换成tensor

bos = torch.Tensor([[cn_dict["BOS"]]]).long().to(device)
#bos=tensor([[2]])

translation, attn = model.translate(mb_x, mb_x_len, bos)
#这里传入bos作为首个单词的输入
#translation=tensor([[ 8, 6, 11, 25, 22, 57, 10, 5, 6, 4]])

translation = [inv_cn_dict[i] for i in translation.data.cpu().numpy().reshape(-1)]
trans = []
for word in translation:
if word != "EOS": # 把数值变成单词形式
trans.append(word) #
else:
break
print("".join(trans))

for i in range(100,120):
translate_dev(i)
print()

数据全部处理完成,现在我们开始构建seq2seq模型

Encoder

  • Encoder模型的任务是把输入文字传入embedding层和GRU层,转换成一些hidden states作为后续的context vectors

下面的注释我先把原理捋清楚吧

In [0]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Encoder(nn.Module):
def __init__(self, vocab_size, embed_size, enc_hidden_size, dec_hidden_size, dropout=0.2):
super(Encoder, self).__init__()
self.embed = nn.Embedding(vocab_size, embed_size)

self.rnn = nn.GRU(embed_size, enc_hidden_size, batch_first=True, bidirectional=True)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(enc_hidden_size * 2, dec_hidden_size)

def forward(self, x, lengths):
sorted_len, sorted_idx = lengths.sort(0, descending=True)
x_sorted = x[sorted_idx.long()]
embedded = self.dropout(self.embed(x_sorted))

packed_embedded = nn.utils.rnn.pack_padded_sequence(embedded, sorted_len.long().cpu().data.numpy(), batch_first=True)
packed_out, hid = self.rnn(packed_embedded)
out, _ = nn.utils.rnn.pad_packed_sequence(packed_out, batch_first=True)
_, original_idx = sorted_idx.sort(0, descending=False)
out = out[original_idx.long()].contiguous()
hid = hid[:, original_idx.long()].contiguous()

hid = torch.cat([hid[-2], hid[-1]], dim=1)
hid = torch.tanh(self.fc(hid)).unsqueeze(0)

return out, hid

Luong Attention

  • 根据context vectors和当前的输出hidden states,计算输出

In [0]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Attention(nn.Module):
def __init__(self, enc_hidden_size, dec_hidden_size):
super(Attention, self).__init__()

self.enc_hidden_size = enc_hidden_size
self.dec_hidden_size = dec_hidden_size

self.linear_in = nn.Linear(enc_hidden_size*2, dec_hidden_size, bias=False)
self.linear_out = nn.Linear(enc_hidden_size*2 + dec_hidden_size, dec_hidden_size)

def forward(self, output, context, mask):
# output: batch_size, output_len, dec_hidden_size
# context: batch_size, context_len, 2*enc_hidden_size

batch_size = output.size(0)
output_len = output.size(1)
input_len = context.size(1)

context_in = self.linear_in(context.view(batch_size*input_len, -1)).view(
batch_size, input_len, -1) # batch_size, context_len, dec_hidden_size

# context_in.transpose(1,2): batch_size, dec_hidden_size, context_len
# output: batch_size, output_len, dec_hidden_size
attn = torch.bmm(output, context_in.transpose(1,2))
# batch_size, output_len, context_len

attn.data.masked_fill(mask, -1e6)

attn = F.softmax(attn, dim=2)
# batch_size, output_len, context_len

context = torch.bmm(attn, context)
# batch_size, output_len, enc_hidden_size

output = torch.cat((context, output), dim=2) # batch_size, output_len, hidden_size*2

output = output.view(batch_size*output_len, -1)
output = torch.tanh(self.linear_out(output))
output = output.view(batch_size, output_len, -1)
return output, attn

Decoder

  • decoder会根据已经翻译的句子内容,和context vectors,来决定下一个输出的单词

In [0]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Decoder(nn.Module):
def __init__(self, vocab_size, embed_size, enc_hidden_size, dec_hidden_size, dropout=0.2):
super(Decoder, self).__init__()
self.embed = nn.Embedding(vocab_size, embed_size)
self.attention = Attention(enc_hidden_size, dec_hidden_size)
self.rnn = nn.GRU(embed_size, hidden_size, batch_first=True)
self.out = nn.Linear(dec_hidden_size, vocab_size)
self.dropout = nn.Dropout(dropout)

def create_mask(self, x_len, y_len):
# a mask of shape x_len * y_len
device = x_len.device
max_x_len = x_len.max()
max_y_len = y_len.max()
x_mask = torch.arange(max_x_len, device=x_len.device)[None, :] < x_len[:, None]
y_mask = torch.arange(max_y_len, device=x_len.device)[None, :] < y_len[:, None]
mask = (1 - x_mask[:, :, None] * y_mask[:, None, :]).byte()
return mask

def forward(self, ctx, ctx_lengths, y, y_lengths, hid):
sorted_len, sorted_idx = y_lengths.sort(0, descending=True)
y_sorted = y[sorted_idx.long()]
hid = hid[:, sorted_idx.long()]

y_sorted = self.dropout(self.embed(y_sorted)) # batch_size, output_length, embed_size

packed_seq = nn.utils.rnn.pack_padded_sequence(y_sorted, sorted_len.long().cpu().data.numpy(), batch_first=True)
out, hid = self.rnn(packed_seq, hid)
unpacked, _ = nn.utils.rnn.pad_packed_sequence(out, batch_first=True)
_, original_idx = sorted_idx.sort(0, descending=False)
output_seq = unpacked[original_idx.long()].contiguous()
hid = hid[:, original_idx.long()].contiguous()

mask = self.create_mask(y_lengths, ctx_lengths)

output, attn = self.attention(output_seq, ctx, mask)
output = F.log_softmax(self.out(output), -1)

return output, hid, attn

Seq2Seq

  • 最后我们构建Seq2Seq模型把encoder, attention, decoder串到一起

In [0]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder):
super(Seq2Seq, self).__init__()
self.encoder = encoder
self.decoder = decoder

def forward(self, x, x_lengths, y, y_lengths):
encoder_out, hid = self.encoder(x, x_lengths)
output, hid, attn = self.decoder(ctx=encoder_out,
ctx_lengths=x_lengths,
y=y,
y_lengths=y_lengths,
hid=hid)
return output, attn

def translate(self, x, x_lengths, y, max_length=100):
encoder_out, hid = self.encoder(x, x_lengths)
preds = []
batch_size = x.shape[0]
attns = []
for i in range(max_length):
output, hid, attn = self.decoder(ctx=encoder_out,
ctx_lengths=x_lengths,
y=y,
y_lengths=torch.ones(batch_size).long().to(y.device),
hid=hid)
y = output.max(2)[1].view(batch_size, 1)
preds.append(y)
attns.append(attn)
return torch.cat(preds, 1), torch.cat(attns, 1)

训练

In [0]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
dropout = 0.2
embed_size = hidden_size = 100
encoder = Encoder(vocab_size=en_total_words,
embed_size=embed_size,
enc_hidden_size=hidden_size,
dec_hidden_size=hidden_size,
dropout=dropout)
decoder = Decoder(vocab_size=cn_total_words,
embed_size=embed_size,
enc_hidden_size=hidden_size,
dec_hidden_size=hidden_size,
dropout=dropout)
model = Seq2Seq(encoder, decoder)
model = model.to(device)
loss_fn = LanguageModelCriterion().to(device)
optimizer = torch.optim.Adam(model.parameters())

In [2]:

1
train(model, train_data, num_epochs=30)

In [0]:

1
2
3
for i in range(100,120):
translate_dev(i)
print()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
BOS you have nice skin . EOS
BOS 你 的 皮 膚 真 好 。 EOS
你好害怕。

BOS you 're UNK correct . EOS
BOS 你 部 分 正 确 。 EOS
你是全子的声音。

BOS everyone admired his courage . EOS
BOS 每 個 人 都 佩 服 他 的 勇 氣 。 EOS
他的袋子是他的勇氣。

BOS what time is it ? EOS
BOS 几 点 了 ? EOS
多少时间是什么?

BOS i 'm free tonight . EOS
BOS 我 今 晚 有 空 。 EOS
我今晚有空。

BOS here is your book . EOS
BOS 這 是 你 的 書 。 EOS
这儿是你的书。

BOS they are at lunch . EOS
BOS 他 们 在 吃 午 饭 。 EOS
他们在午餐。

BOS this chair is UNK . EOS
BOS 這 把 椅 子 很 UNK 。 EOS
這些花一下是正在的。

BOS it 's pretty heavy . EOS
BOS 它 真 重 。 EOS
它很美的脚。

BOS many attended his funeral . EOS
BOS 很 多 人 都 参 加 了 他 的 葬 礼 。 EOS
多多衛年轻地了他。

BOS training will be provided . EOS
BOS 会 有 训 练 。 EOS
别将被付錢。

BOS someone is watching you . EOS
BOS 有 人 在 看 著 你 。 EOS
有人看你。

BOS i slapped his face . EOS
BOS 我 摑 了 他 的 臉 。 EOS
我把他的臉抱歉。

BOS i like UNK music . EOS
BOS 我 喜 歡 流 行 音 樂 。 EOS
我喜歡音樂。

BOS tom had no children . EOS
BOS T o m 沒 有 孩 子 。 EOS
汤姆没有照顧孩子。

BOS please lock the door . EOS
BOS 請 把 門 鎖 上 。 EOS
请把門開門。

BOS tom has calmed down . EOS
BOS 汤 姆 冷 静 下 来 了 。 EOS
汤姆在做了。

BOS please speak more loudly . EOS
BOS 請 說 大 聲 一 點 兒 。 EOS
請說更多。

BOS keep next sunday free . EOS
BOS 把 下 周 日 空 出 来 。 EOS
繼續下週一下一步。

BOS i made a mistake . EOS
BOS 我 犯 了 一 個 錯 。 EOS
我做了一件事。

机器翻译与文本摘要

机器翻译

img

img

img

img

现在的机器翻译模型都是由数据驱动的。什么数据?

  • 新闻

  • 公司网页

  • 法律/专利文件,联合国documents

  • 电影/电视字幕

IBM fire a linguist, their machine translation system improves by 1%

Parallel Data

  • 我们希望使用双语的,有对应关系的数据

  • 大部分数据都是由文档级别的

如何评估翻译模型?

  • 人工评估最好,但是非常费时费力

  • 还有哪些问题需要人类评估?

  • 需要一些自动评估的手段

  • BLUE (Bilingual Evaluation Understudy), Papineni et al. (2002)

  • 计算系统生成翻译与人类参考翻译之间的n-gram overlap

  • BLEU score与人类评测的相关度非常高

  • https://www.aclweb.org/anthology/P02-1040.pdf

  • precision based metric

  • 自动评估依然是一个有价值的研究问题

precision: 在我翻译的单词当中,有哪些单词是正确的。

unigram, bigram, trigram, 4-gram precision

BLEU-4: average of the 4 kinds of grams

BLEU-3

统计学翻译模型

img

Encoder-decoder 模型

x:英文

y:中文

P(y|x) x: noisy input

img

P(y|x) = P(x, y) / P(x) = P(x|y)P(y) / P(x)

argmax_y P(y|x) = argmax_y P(x|y)P(y)

P(x|y)

P(y)

Encoder-Decoder Model

img

img

RNN(x) –> c (c能够完全包含整个句子的信息?

RNN(c) –> y (c作为输入进入每一个decoding step)

训练方式是什么?损失函数是什么?

  • cross entropy loss, 作业一中的context模型

  • SGD, Adam

GRU

https://arxiv.org/pdf/1406.1078.pdf

img

img

Attention机制

img

img

图片来自 Bahdanau et al., Neural Machine Translation by Jointly Learning to Align and Translate https://arxiv.org/pdf/1409.0473.pdf

img

img

图片来自Luong et al., Effective Approaches to Attention-based Neural Machine Translation

https://arxiv.org/pdf/1508.04025.pdf

Google Neural Machine Translation

https://arxiv.org/pdf/1609.08144.pdf

img

img

img

Zero-shot NMT

img

img

Transformer模型

https://shimo.im/docs/gPwkqCXrkJyRW89V

这个模型非常重要

模型 x –> encoder decoder model –> \hat{y}

cross entropy loss (\hat{y}, y)

训练 P(y_i | x, y_1, …, y_{i-1}) 训练的时候,我们知道y_1 … y_{i-1}

在预测的时候,我们不知道y_1 … y_{i-1}

怎么样统一训练和测试

Model Inference

在各类文本生成任务中,其实文本的生成与训练是两种不同的情形。在训练的过程中,我们假设模型在生成下一个单词的时候知道所有之前的单词(groud truth)。然而在真正使用模型生成文本的时候,每一步生成的文本都来自于模型本身。这其中训练和预测的不同导致了模型的效果可能会很差。为了解决这一问题,人们发明了各种提升模型预测水平的方法,例如Beam Search。

Beam Search

Kyunghyun Cho Lecture Notes Page 94-96 https://arxiv.org/pdf/1511.07916.pdf

Encoder(我喜欢自然语言处理) –> c

Decoder(c) –> y_1

Decoder(c, y_1) –> y_2

Decoder(c, y_1, y_2) –> y_3

…..

EOS

argmax_y P(y|x)

greedy search

argmax y_1

Beam 横梁

————————————————

一种固定宽度的装置

————————————————

在后续的课程中我们还会介绍一些别的方法用于生成文本。

美国总统和中国主席打电话

–> K = 无穷大 |V|^seq_len

American, U.S. , United

….

decoding step: K

K x |V| –> K

K x |V| –> K

开源项目

FairSeq https://github.com/pytorch/fairseq

Tensor2Tensor https://github.com/tensorflow/tensor2tensor

Trax https://github.com/google/trax

文本摘要

文本摘要这个任务定义非常简单,给定一段长文章,我们希望生成一段比较精简的文本摘要,可以覆盖整篇文章的信息。

文本摘要按照任务的定义大致可以分为两类。

  • 抽取式:给定一个包含多个句子的长文本,选择其中的一些句子作为短文本。这本质上是个分类问题,也就是判断哪些句子需要保留,哪些句子需要丢弃。二分类任务

  • 生成式:与抽取式文本摘要不同,这里我们不仅仅是希望选出一些句子,而是希望能够总结归纳文本的信息,用自己的话复述一遍。直接上transformer模型

gold standard

评估手段: ROUGE

ROUGE评估的是系统生成文本和参考文本之间 n-gram overlap 的 recall。

Candidate Summary

the cat was found under the bed

Reference Summary

the cat was under the bed

针对这一个例子,ROUGE-1分数为1, ROUGE-2为4/5。

s: the cat was found under the bed

p: the cat was under the bed

ROUGE-L,基于 longest common subsequence的F1 score

例如上面这个案例 LCS = 6

P = 6/7

R = 6/6

F1 = 2 / (6/6 + 7/6 ) = 12/13

harmoic mean

img

img

https://arxiv.org/pdf/1908.08345.pdf

img

img

上期学员的博客

https://blog.csdn.net/Chen_Meng_/article/details/103756716

CopyNet

https://arxiv.org/pdf/1603.06393.pdf

|