我们的感知模式、语言和艺术作品都具有统计结构。学习这种结构是深度学习算法所擅长的。机器学习模型能够对图像、音乐和故事的统计潜在空间$(latent space)$进行学习,然后从这个空间中采样,创造出于模型在训练数据中所见到的艺术作品具有相似特征的新作品。当然,这种采样本身并不是艺术创作行为。它只是一种数学运算,算法并没有关于人类生活、人类情感或我们人生经验的基础知识。相反,它从一种与我们的经验完全不同的经验中进行学习。作为人类旁观者,只能靠我们的解释才能对模型生成的内容赋予意义。但在技艺高超的艺术家手中,算法生成可以变得非常有意义,并且很美。潜在空间采样会变成一支画笔,能够提高艺术家的能力,增强我们的创造力,并拓展我们的想象空间。——《Python 深度学习》

作曲家从繁琐的计算中解脱出来,从而能够全神贯注于解决新音乐形式所带来的一般性问题,并在修改输入数据值的同时探索这种形式的鲜为人知之处。例如,它可以测试所有的乐器组合,从独奏到室内管弦乐队再到大型管弦乐队。在电子计算机的帮助下,作曲家变成了一名飞行员:他按下按钮,引入坐标,并监控宇宙飞船在“声音空间”中的航行,飞船穿越声波的星座和星系,这是以前只能在遥不可及的梦中出现的场景。 —— Iannis Xenakis

  • 基于语言模型的文本生成
  • 基于深度学习方法的文本生成

基于马尔科夫的语言模型在数据驱动下的自然语言生成。利用数据和文字间的对齐语料,使用 N-gram 语言模型生成文本。在语言模型上加入句法分析$(关系抽取、实体识别、依存句法、短语结构等)$可以显著改善文本生成效果。因为这些都是建立在句子理解的基础上,文本生成的过程需要考虑历史信息,处理长距离的依赖关系情况,如语义连贯性。


标准定义:对于语言序列 $w_1, w_2, ..., w_n$ ,语言模型就是计算该序列出现的概率,即:
$$P(w_1, w_2,...,w_n)$$ 也即语言模型描述了这些单词组成这个语言序列句子的概率分布。

首先,由链式法则 chain rule 可以得到:

$$P(w_1, w_2,...,w_n) = P(w_1)P(w_2|w_1)···P(w_n|w_1,...,w_{n-1})$$

马尔科夫假设,当前词只依赖前面 n-1 个词。

$$P(w_i|w_1,w_2,...,w_{i-1}) = P(w_i|w_{i-n+1},...,w_{i-1})$$

基于上式,定义 n-gram 语言模型如下:

n=1 unigram: $$P(w_1,w_2,...,w_n) = \prod_{i=1}^{n}P(w_i)$$

n=2 bigram: $$P(w_1,w_2,...,w_n) = \prod_{i=1}^{n}P(w_i|w_{i-1})$$

n=3 trigram: $$P(w_1,w_2,...,w_n) = \prod_{i=1}^{n}P(w_i|w_{i-2},w_{i-1})$$

$$P(w_i|w_{i-1}) = \frac{P(w_iw_{i-1})}{P(w_{i-1})} = \frac{count(w_iw_{i-1})}{count(w_{i-1})}$$

应用场景:

  • 候选句打分:常应用在文本纠错、语音识别等场景中。比如: $P(I\;am\;happy) > P(I\;am\;happen)$
  • 文本生成:比如广告文案、机器人写作、机器翻译。

按照输入数据的不同,文本生成任务可以分为三类:

  • 文本到文本的生成
  • 结构化数据生成文本
  • 图像到文本的生成

基于深度学习的文本生成,常用的是 Seq2Seq 模型 $(Encoder-Decoder)$。利用 Attention 机制的 Seq2Seq 模型可以加强单词和特征之间的对齐,在生成文字的时候,模拟人类注意力转移的过程,生成更加复合人类习惯的文本。

用深度学习生成序列数据的通用方法,就是使用前面的标记作为输入,训练一个网络(通常是循环神经网络或卷积神经网络)来预测序列中接下来的一个或多个标记。标记通常是单词或字符,给定前面的标记,能够对下一个标记的概率进行建模的任何网络都叫作语言模型。语言模型能够捕捉到语言的潜在空间$(latent space)$,即语言的统计结构。

一旦训练好了这样一个语言模型,就可以从中采样$(sample,即生成新序列)$。向模型中输入一个初始文本字符串$(conditioning data, 即条件数据)$,要求模型生成下一个字符或下一个单词(甚至可以同时生成多个标记),然后将生成的输出添加到输入数据中,并多次重复这一过程。这个循环可以生成任意长度的序列,这些序列反应了模型训练数据的结构,它们与人类书写的句子几乎相同。


我们将会用到一个 LSTM 层,向其输入从文本语料中提取的 N 个字符组成的字符串,然后训练模型来生成第 N+1 个字符。模型的输出是对所有可能的字符做 Softmax,得到下一个字符的概率分布。这个 LSTM 叫作字符级的神经语言模型。

基于 $Seq2Seq$ 模型的文本生成有各种不同的 $decoding\;strategy$。文本生成中的 $decoding\;strategy$ 主要可以分为两大类:

  • $Argmax\;Decoding$:

    主要包括:$beam\;search$, $class-factored\;softmax$ 等。

  • $Stochastic\;Decoding$:

    主要包括 $temperature\;sampling$, $topK\;sampling$ 等。

在 $Seq2Seq$ 模型中,$RNN\;Encoder$ 对输入句子进行编码,生成一个大小固定的 $hidden\;state\;h_c$;基于输入句子的 $hidden\;state\;h_c$ 和先前生成的第 $1$ 到 $t-1$ 个词 $x_{1 : t−1}$,$RNN\;Decoder$ 会生成当前第 $t$ 个词的 $hidden\;state\;h_t$,最后通过 $softmax$ 函数得到第 $t$ 个词 $x_t$ 的 $vocabulary\;probability\;distribution\; P(x|x_{1:t−1})$。

两类 $decoding\;strategy$ 的主要区别就在于,如何从$vocabulary\;probability\;distribution\; P(x|x_{1:t−1})$ 中选取一个词 $x_t$ :

  • $Argmax\;Decoding$ 的做法是选择词表中 $probability$ 最大的词,即 $x_t = argmaxP(x|x_{1:t−1})$;
  • $Stochastic\;Decoding$ 则是基于概率分布 $P(x|x_{1:t−1})$ 随机 $sample$ 一个词 $x_t$,即 $x_t∼P(x|x_{1:t−1})$。

在做 $seq\;predcition$ 时,需要根据假设模型每个时刻 $softmax$ 的输出概率来 $sample$ 单词,合适的$sample$ 方法可能会获得更有效的结果。

核心思想:每一步取当前最大可能性的结果,作为最终结果。

具体方法:获得新生成的词是vocab中各个词的概率,取 $argmax$ 作为需要生成的词向量索引,继而生成后一个词。

核心思想: $beam\;search$尝试在广度优先基础上进行进行搜索空间的优化(类似于剪枝)达到减少内存消耗的目的。

具体方法:在 $decoding$ 的每个步骤,我们都保留着 $top-K$ 个可能的候选单词,然后到了下一个步骤的时候,我们对这 $K$ 个单词都做下一步 $decoding$,分别选出 $top K$,然后对这 $K^2$ 个候选句子再挑选出 $top K$ 个句子。以此类推一直到 $decoding$ 结束为止。当然 $Beam\;Search$ 本质上也是一个 $greedy\;decoding$ 的方法,所以我们无法保证自己一定可以得到最好的 $decoding$ 结果。

$Greedy\;Search$ 和 $Beam\;Search$ 存在的问题:

  1. 容易出现重复的、可预测的词;
  2. 句子/语言的连贯性差。

核心思想: 根据单词的概率分布随机采样。

具体方法:在 $softmax$ 中引入一个 $temperature$ 来改变 $vocabulary\;probability\;distribution$,使其更偏向 $high\;probability\;words$:

另一种表示:假设 $p(x)$ 为模型输出的原始分布,给定一个 $temperature$ 值,将按照下列方法对原始概率分布(即模型的 $softmax$ 输出) 进行重新加权,计算得到一个新的概率分布。

当 $temperature→0$,就变成 $greedy\;search$;当 $temperature→\infty$,就变成均匀采样$(uniform\;sampling)$。详见论文:The Curious Case of Neural Text Degeneration

可以缓解生成罕见单词的问题。比如说,我们可以每次只在概率最高的 $50$ 个单词中按照概率分布做采样。我只保留 $topk$ 个 $probability$ 的单词,然后在这些单词中根据概率做 $sampling$。

核心思想:对概率进行降序排序,然后对第k个位置之后的概率转换为 $0$。

具体方法:在 $decoding$ 过程中,从 $P(x|x_{1:t−1})$ 中选取 $probability$ 最高的前 $k$ 个 $tokens$,把它们的 $probability$ 加总得到 $p′=∑P(x|x_{1:t−1})$ ,然后将 $P(x|x_{1:t−1})$ 调整为 $P′(x|x_{1:t−1})=\frac{P(x|x_{1:t−1})}{p′}$ ,其中 $x∈V(k)!$ ,最后从 $P′(x|x_{1:t−1})$ 中 $sample$ 一个 $token$ 作为$output\;token$。详见论文:Hierarchical Neural Story Generation

但 $Top-k\;Sampling$ 存在的问题是,常数 $k$ 是提前给定的值,对于长短大小不一,语境不同的句子,我们可能有时需要比 $k$ 更多的 $tokens$。

核心思想:通过对概率分布进行累加,然后当累加的值超过设定的阈值 $p$,则对之后的概率进行置 $0$。

具体方法:提出了 $Top-p\;Sampling$ 来解决 $Top-k\;Sampling$ 的问题,基于 $Top-k\;Sampling$,它将 $p′=∑P(x|x_{1:t−1})$ 设为一个提前定义好的常数 $p′∈(0,1)$ ,而$selected\;tokens$ 根据句子 $history\;distribution$ 的变化而有所不同。详见论文:The Curious Case of Neural Text Degeneration

本质上 $Top-p\;Sampling$ 和 $Top-k\;Sampling$ 都是从 $truncated\;vocabulary\;distribution$ 中 $sample\;token$,区别在于置信区间的选择。

随机采样存在的问题:

  1. 生成的句子容易不连贯,上下文比较矛盾。
  2. 容易生成奇怪的句子,出现罕见词。

生成文本时,如何选择下一个字符至关重要。一种简单的方法是贪婪采样$(greedy\;sampling)$,就是始终选择可能性最大的下一个字符。但这种方法得到重复的、可预测的字符串,看起来不像是连贯的语言。

在采样的过程中引入随机性,即从下一个字符的概率分布中进行采样。这叫作随机采样$(stochastic\;sampling)$,在这种情况下,根据模型结果,如果下一个字符是 e 的概率为 0.3,那么就有 30% 的概率选择它。假设 $p(x)$ 为模型输出的原始分布,则基于 softmax 采样得到的新分布:
$$\pi(x_k) = \frac{e^{log(p(x_k))}}{\sum_{i=1}^{n}e^{log(p(x_i))}}$$


从模型的 Softmax 输出中进行概率采样是一种很巧妙的方法,它甚至可以在某些时候采样到不常见的字符,从而生成看起来更加有趣的句子,而且有时会得到训练数据中没有的、听起来像是真实存在的新单词,从而表现出创造性。但这种方法有一个问题,就是它在采样过程中无法控制随机性的大小。

考虑一个极端的例子——纯随机采样: 即从均匀概率分布中抽取下一个字符,其中每个字符的概率相同。这种方案具有最大的随机性,换句话说,这种概率分布具有最大的熵。当然,他不会生成任何有趣的内容。

再来看另一个极端——贪婪采样:贪婪采样也不会生成任何有趣的内容,它没有任何随机性,即相应的概率分布具有最小的熵。从"真实概率分布"(即模型 Softmax 函数输出的分布)中进行采样,是这两个极端之间的一个中间点。但是,还有许多其他中间点具有更大或更小的熵,你可能都想研究一下。更小的熵可以让生成的序列具有更加可预测的结构,因此看起来更真实。而更大的熵会得到更加出人意料且更有创造性的序列。从生成式模型中进行采样时,在生成过程中探索不同的随机性大小总是好的做法。我们人类是生成数据是否有趣的最终判断者,所以有趣是非常主观的,我们无法提前知道最佳熵的位置。

为了在采样过程中控制随机性的大小,我们引入一个叫作Softmax 温度的参数,用于表示采样概率分布的熵,即表示所选择的下一个字符会有多么出人意料或多么可预测。给定一个 temperature 值,将按照下列方法对原始概率分布(即模型 Softmax 输出)进行重新加权,计算得到一个新的概率分布。$$\pi(x_k) = \frac{e^{log(p(x_k))} / temperature}{\sum_{i=1}^{n}e^{log(p(x_i))} / temperature}, temperature \in [0,1)$$

import numpy as np

def reweight_distribution(original_distribution, temperature=0.5):
    # original_distribution 是概率值组成的一维 Numpy 数组。temperature 是一个因子,用于定量描述输出分布的熵。
    distribution = np.log(original_distribution) / temperature
    distribution = np.exp(distribution)
    # 返回原始分布重新加权后的结果。distribution 的求和可能不再等于1,因此需要归一化
    return distribution / np.sum(distribution)

更高的温度得到的是熵更大的分布,会生成更加出人意料、更加无结构的生成数据,而更低的温度对应更小的随机性,以及更加可预测的生成数据。

文本序列生成的程序流程:

  • 建立 $word2index$,$index2word$ 表。

生成批次(Batch generation)

  • 数据格式的预处理:$batch\_generator(data, batch\_size, n\_steps)$.
  • 数据的连续采样,要求相邻批次的序列是连续的。需要设置一个批次/周期内的序列个数 $batch\_size$ 以及每个序列中所含字符数 $n\_steps$ ,这样可以得到一个批次/周期数量。
def batch_generator(data, batch_size, n_steps):
    data = copy.copy(data)
    batch_steps = batch_size * n_steps
    n_batches = int(len(data) / batch_steps)
    data = data[:batch_size * n_batches]
    data = data.reshape((batch_size, -1))
    while True:
        np.random.shuffle(data)
        for n in range(0, data.shape[1], n_steps):
            x = data[:, n:n + n_steps]
            y = np.zeros_like(x)
            y[:, :-1], y[:, -1] = x[:, 1:], x[:, 0]
            yield x, y

假设训练集含有 $30$ 个字符,调用 $batch\_generator(data, 2, 6)$.

每个 $batch$ 的行数就是每个周期的句子数,列数就是 $\frac{len(data)}{batch_size}$得到的数值。

这个网络是由多层 $LSTM$ 堆叠组成,然后根据所有时刻的输出 $lstm\_outputs$,对词典中所有词的 $softmax$ 得到概率。

采样策略:贪婪采样,随机采样

采样函数可以选择 $Temperature$ $Sampling$ 或 $TopK$ $Sampling$,分别根据模型得到的原始概率分布进行重新加权或限定 $TopK$ 个概率构建新的概率分布,并从中抽取一个字符索引。

给定一个训练好的模型和一个种子文本片段,可以通过重复以下操作生成新的文本。

  1. 给定目前已生成的文本,从模型中得到下一个字符的概率。
  2. 根据某个温度对分布进行重新加权。
  3. 根据重新加权后的分布对下一个字符进行随机采样。
  4. 将新字符添加至文本末尾。

如何对生成的文本进行评价也是文本生成研究中重要的一环。

内在评价关注文本的正确性、流畅度和易理解性。常见的内在评价方法又可分为两类:

  • 采用 $BLEU$、$NIST$ 和 $ROUGE$ 等进行自动化评价,评估生成文本和参考文本间相似度来衡量生成质量。
  • 通过人工评价,从有用性等对文本进行打分。

找出输出句子与参考句子之间的 $n-gram$ 重叠部分并对(比参考句子)更短的输出句子施以惩罚的评价方法。

论文地址:BLEU: a Method for Automatic Evaluation of Machine Translation

它基于 $Ngram$ 的稀缺性对其进行加权。这就意味着对某个稀缺 $Ngram$ 的正确匹配能提高的分数,要多于对某个常见的 $Ngram$ 的正确匹配。

论文地址:Automatic Evaluation of Machine Translation QualityUsing N-gram Co-Occurrence Statistics

由于 $BLEU$ 只考虑了精确率而没有考虑召回率,因此,在 $2004$ 年,Chin-Yew Lin 提出了一种新的评估方法 $ROUGE$,该方法主要是从召回率的角度计算生成文本与参考文本之间的相似性,比较适用于文本摘要任务,因为文本摘要我们更考察生成文本包括了多少参考文本中包含的信息。作者在论文中总共提出了 $4$ 种不同的计算方式:$ROUGE-N$、$ROUGE-L$、$ROUGE-W$、$ROUGE-S$。

它对 $BLEU$ 进行了修改,聚焦于召回率而非准确率。换句话说,该方法看重的是参考翻译句中有多少 $Ngram$ 出现在输出句中,而不是输出句中有多少 $Ngram$ 出现在参考翻译句中。

论文地址:ROUGE: A Package for Automatic Evaluation of Summaries

$BLEU$ 只考虑精确率,$METEOR$ 则同时考虑精确率和召回率,采用加权的 $F$ 值来作为评估指标。

论文地址:Meteor: An Automatic Metric for MT Evaluation with High Levels of Correlation with Human Judgments

外在评价则关注生成文本在实际应用中的可用性。

困惑度(perplexity):是交叉熵的指数形式。给定一个包含 $n$ 个词的测试文本 $W =(w_1, w_2, ..., w_n)$ 和语言模型 $Ngram$:$P(w_1,w_2,...,w_n)=\prod ^n_{i=1}P(w_i|w_{i−N+1},...,w_{i−1})$ 的困惑度 $perplexity$ 可定义为交叉熵的指数形式:

$perplexity$ 和交叉熵一样都可以用来评价语言模型的好坏。 对于测试集其困惑度越小,准确率也就越高,语言模型也就越好

$perplexity$ 刻画的是语言模型预测一个语言样本的能力,比如已经知道了 $W = (w_1, w_2, ..., w_n)$ 这句话会出现在语料库之中,那么通过语言模型计算得到这句话的概率越高,说明语言模型对这个语料库拟合的越好。

本例将使用尼采的一些作品,用于训练语言模型。因此,我们的要学习的语言模型将是针对尼采的写作风格和主题的模型,而不是一个通用的模型。

首先下载预料,并将其转换为小写。

import keras
import numpy as np

path = keras.utils.get_file('nietzsche.txt', origin='https://s3.amazonaws.com/text-datasets/nietzsche.txt')
text = open(path).read().lower()
print('Corpus length:', len(text))

接下来,我们要提取长度为 maxlen 的序列,这些序列之间会存在部分重叠,对它们进行 one-hot 编码,然后将其打包成形状为 $(sequence, maxlen, unique\_characters)$ 的三维 Numpy 数组。与此同时,我们还需要准备一个数组 y,其中包含对应的目标,即在每一个所提取的序列之后出现的字符(已进行 one-hot 编码)。

maxlen = 60      # 提取 60 个字符组成的序列
step = 3         # 每 3 个字符采样一个新序列
sentences = []   # 保存提取的序列
next_chars = []  # 保存目标(即下一个字符)

for i in range(0, len(text)-maxlen, step):
    sentences.append(text[i:i+maxlen])
    next_chars.append(text[i+maxlen])
    
    print("Number of sequences: ", len(sentences))
    
    chars = sorted(list(set(text)))   # 语料中唯一字符组成的列表
    print("Unique characters: ", len(chars))
    # 字典,将唯一字符映射为它在列表 chars 中的索引
    char_indices = dict((char, chars.index(char)) for char in chars)
    
    print("Vectorization...")
    # 将字符 one-hot 编码为二进制数组
    x = np.zeros((len(sentences), maxlen, len(chars)), dtype=np.bool)
    y = np.zeros((len(sentences), len(chars)), dtype=np.bool)
    for i, sentence in enumerate(sentences):
        for t, char in enumerate(sentence):
            x[i, t, char_indices[char]] = 1
        y[i, char_indices[next_chars[i]]] = 1

这个网络是一个单层 LSTM,然后是一个 Dense 分类器和对所有可能字符的 Softmax。值得注意的是,循环神经网络并不是序列数据生成的唯一方法,最近已有研究表明一维卷积神经网络也可以用于序列数据生成。

from keras import layers

model = keras.models.Sequential()
model.add(layers.LSTM(128, input_shape=(maxlen, len(chars))))
model.add(layers.Dense(len(chars), activation='softmax'))

目标是经过 one-hot 编码的,所以训练模型需要使用 categorical_crossentropy 作为损失。

optimizer = keras.optimizers.RMSprop(lr=0.01)
model.compile(loss='categorical_crossentropy', optimizer=optimizer)

给定一个训练好的模型和一个种子文本片段,我们可以通过重复以下操作来生成新的文本。

  • 给定目前已生成的文本,从模型中得到下一个字符的概率分布。
  • 根据某个温度对分布进行重新加权。
  • 根据重新加权后的分布对下一个字符进行随机采样。
  • 将新字符添加到文本末尾。

下列代码将对模型得到的原始概率分布进行重新加权,并从中抽取一个字符索引。

def sample(preds, temperature=1.0):
    preds = np.asarray(preds).astype('float64')
    preds = np.log(preds) / temperature
    exp_preds = np.exp(preds)
    preds = exp_preds / np.sum(exp_preds)
    probas = np.random.multinomial(1, preds, 1)
    return np.argmax(probas)

最后,下面这个循环将反复训练并生成文本。在每轮过后都是用一系列不同的温度值来生成文本。这样我们可以看到,随着模型收敛,生成的文本如何变化,以及温度对采样的策略的影响。

import random
import sys

for epoch in range(1, 60):
    print("epoch: ", epoch)
    model.fit(x, y, batch_size=128, epochs=1)  # 将模型在数据上拟合一次
    # 随机选择一个文本种子
    start_index = random.randint(0, len(text)-maxlen-1)
    generated_text = text[start_index: start_index+maxlen]
    print('---Generating with seed: "' + generated_text + '"')
    
    # 尝试一系列不同的采样温度值
    for temperature in [0.2, 0.5, 1.0, 1.2]:
        print('------ temperature: ', temperature)
        sys.stdout.write(generated_text)
        
        for i in range(400):
            # 对目前生成的字符进行 one-hot 编码
            sampled = np.zeros((1, maxlen, len(chars)))
            for t, char in enumerate(generated_text):
                sampled[0, t, char_indices[char]] = 1.
            
            # 对下一个字符进行采样
            preds = model.predict(sampled, verbose=0)[0]
            next_index = sample(preds, temperature)
            next_char = chars[next_index]
            
            generated_text += next_char
            generated_text = generated_text[1:]
            
            sys.stdout.write(next_char)

较小的温度值会得到极端重复和可预测的文本,但局部结构是非常真实的,特别是所有单词都是真正的英文单词(单词其实就是字符的局部模式)。随着温度值越来越大,生成的文本也变得更加有趣、更出人意料,甚至更有创造性,它有时会创造出全新的单词,听起来有几分可信。

对于较大的温度值,局部模式开始分解,大部分单词看起来像是半随机的字符串。毫无疑问,在这个特定的设置下, 0.5 的温度值生成的文本最为有趣。一定要尝试多种采样策略,在学到的结构与随机性挚爱金,巧妙的平衡能够让生成的序列非常有趣。

值得注意,利用更多的数据训练一个更大的模型,并且训练时间更长,会让生成的样本看起来更连贯、更真实。但是,不要期待能够生成任何有意义的文本,除非是很偶然的情况。我们所做的一切只是从一个统计模型中对数据进行采样,这个模型是关于字符先后顺序的模型。


这是黄色的短代码框,常用来做提示,引起读者注意。

这是红色的短代码框,用于严重警告什么的。

这是浅蓝色的短代码框,用于显示一些信息。

Last modification:March 16, 2022
如果觉得我的文章对你有用,请随意赞赏