理论基础

NLP的生成任务一般是要生成一个序列,那么在生成序列的过程中,每一步都能够计算出当前位置所有单词的概率,而通过这个概率选择单词,就可以看做是在解码。当然,t时刻生成的单词概率,会受到t-1时刻选择结果的影响。因此不同的解码方法,会带来不同的结果。Beam Search是一种宽度受限的搜索方法,常用于NLP的生成任务,用在解码端。常用的解码策略如下:

贪心搜索(Greed Search)

贪心搜索的思想非常简单,生成序列的每一步,都选择概率最大的那个单词 (即 argmax)。将其用作下一个单词,并在下一步中将其作为输入提供。继续前进,直到产生 或达到某个最大长度。
贪心搜索由于缺乏回溯,输出可能很差 (例如,不合语法,不自然,荒谬)
image.png

全局搜索(Exhaustive Search)

众所周知,贪心思想往往找不到全局最优解,只能得到局部最优解。所以另一种极端的做法就是全局搜索。叫穷举搜索也可以,笔者喜欢叫全局搜索。每次都选择所有的单词。
如果词表大小为M,序列长度为N,全局搜索将计算所有的序列共 2022-03-18-生成模型的解码策略 - 图2 种情况,然后选择概率最大的序列。

集束搜索(Beam Search)

众所周知,穷举思想往往能找到全局最优解,但是性能过差,实用性上还不如贪心思想。而通常这种情况下,都会出现一种折中方案。

  1. 集束搜索就是这样一种折中方案。它并不是每一步找最大的一个,也不是找所有,而是**跟踪 _K_ 个最可能的部分序列** _K _是集束搜索的参数,成为束宽(beam size)。beam search通过 _K_ 扩大了搜索空间,相比greed search更容易找到全局最优解,相比贪心搜索更快,复杂度从降低到。

image.png

值得注意的是,beam search是每一步选择后保留最大的K个选择,并不是选择前选最大的K 个。因此复杂度是2022-03-18-生成模型的解码策略 - 图4而不是2022-03-18-生成模型的解码策略 - 图5。Beam Search不保证全局最优,但是比Greedy Search搜索空间更大,一般结果比Greedy Search要好。Greedy search可以看做是 beam size = 1时的 Beam Search。

苏神对 beam search 的理解

苏剑林. (Sep. 01, 2018). 《玩转Keras之seq2seq自动生成标题 》[Blog post]. Retrieved from https://spaces.ac.cn/archives/5861

2022-03-18-生成模型的解码策略 - 图6

基于采样的解码

苏剑林. (Jun. 16, 2020). 《如何应对Seq2Seq中的“根本停不下来”问题? 》[Blog post]. Retrieved from https://spaces.ac.cn/archives/7500

对于Seq2Seq来说,我们很多时候希望得到确定性的结果,所以多数场景下我们都是用Beam Search。但是Beam Searc的生成结果可能会出现过于单一的现象(即类似“好的”、“不知道”、“谢谢”这类比较“安全”的回复),或者有时候我们希望增加输出的多样性,这时候就需要随机性解码算法!

解码算法大致可以分为两类:

  1. 确定性解码算法,就是当输入文本固定之后,解码出来的输出文本也是固定的,这类算法包含贪心搜索(Greedy Search)和束搜索(Beam Search);
  2. 随机性解码算法,顾名思义,就是哪怕输入文本固定了,解码出来的输出文本也不是固定的,比如从训练语言模型进行随机采样就是这种算法(参考《现在可以用Keras玩中文GPT2了》)。它包含三种情况:原生随机解码、top-k随机解码、Nucleus随机解码。

Softmax Temperature

对于文本生成中出现不太连贯和通顺,产生语无伦次的胡言乱语的情况,可以采用带温度参数的Softmax,温度Softmax,可以将Softmax后的概率进行平滑处理,公式如下:
2022-03-18-生成模型的解码策略 - 图7

2022-03-18-生成模型的解码策略 - 图8代表了Softmax的一个平滑的程度,

  • 2022-03-18-生成模型的解码策略 - 图9时,概率分布2022-03-18-生成模型的解码策略 - 图10趋向于One-hot分布,变得更尖锐;
    • 因此输出的多样性较少 (概率集中在顶层词汇上)
  • 2022-03-18-生成模型的解码策略 - 图11时,概率分布2022-03-18-生成模型的解码策略 - 图12趋向于均匀分布
    • 因此输出更多样化 (概率分布在词汇中)

2022-03-18-生成模型的解码策略 - 图13

原生随机采样

原生随机解码很简单,在每个步骤2022-03-18-生成模型的解码策略 - 图14,从概率分布2022-03-18-生成模型的解码策略 - 图15中随机采样一个token(而不是argmax取概率最大的词)。比如第一步算2022-03-18-生成模型的解码策略 - 图16,然后按概率随机采样一个token,比如c;然后第二步算2022-03-18-生成模型的解码策略 - 图17,接着按概率随机采样一个token,比如a;那么第三步就算2022-03-18-生成模型的解码策略 - 图18,再按概率随机采样;…;依此类推,直到采样到停止。

相比于按概率“掐尖”,这样会增大所选词的范围,引入更多的随机性。

Top-K采样

Top-K Sampling在“得分高”和“多样性”方面做一个折中。 GPT-2就是采用了这种采样方案,这也是其生成故事效果不错的原因之一。

Top-K Sampling的原理是:

  • 在每个步骤2022-03-18-生成模型的解码策略 - 图19,从概率分布2022-03-18-生成模型的解码策略 - 图20的前2022-03-18-生成模型的解码策略 - 图21个最可能的单词中,进行随机采样
  • 与纯采样类似,但截断概率分布,然后重新归一化概率后再采样
  • 此时,2022-03-18-生成模型的解码策略 - 图22是贪婪搜索,2022-03-18-生成模型的解码策略 - 图23是纯采样
  • 增加2022-03-18-生成模型的解码策略 - 图24以获得更多样化 / 风险的输出
  • 减少2022-03-18-生成模型的解码策略 - 图25以获得更通用 / 安全的输出

2022-03-18-生成模型的解码策略 - 图26
但是在使用Top-K采样时需要注意的一个问题是,它固定了下一次采样的样本集的大小,不会动态适应从下一个单词概率分布2022-03-18-生成模型的解码策略 - 图27。这可能会导致某些单词可能是从非常尖锐的分布中采样的,而另一些单词则是从更平坦的分布中采样的。这可能会使得模型产生乱序的尖峰分布,并限制模型用于平坦分布的创造力。

  1. 1、预测下一个字的出现概率
  2. next_proba = model.predict(inputs, output_ids, ...)
  3. 2、保留其中最高的topk概率,并重新归一化
  4. k_indices = probas.argpartition(-topk,axis=1)[:, -topk:] # 仅保留topk
  5. probas = np.take_along_axis(probas, k_indices, axis=1) # topk概率
  6. probas /= probas.sum(axis=1, keepdims=True) # 重新归一化
  7. 3、按照概率大小进行随机采样一个
  8. 4、取出该概率索引对应的字 id,加到 token_ids后面
  9. output_ids = np.concatenate([output_ids, sample_ids], 1)
  10. 5、判断是否为结束标记
  11. 是:保存生成序列
  12. 否:循环上述的步骤

Top-p采样 / Nucleus Sampling

Nucleus Sampling也叫Top-p Sampling,这个方法固定候选集合的概率密度和在整个概率分布中的比例,也就是构造一个最小候选集 V,使得候选集 V 的概率和大于设定的阈值 p,即:
2022-03-18-生成模型的解码策略 - 图28
然后,将选出来的候选集 V 重新归一化集合内词的概率,并把集合外词的概率设为0。这样,单词集合的大小(也就是集合中单词的数量)可以根据下一个单词的概率分布动态地增加或减少。
2022-03-18-生成模型的解码策略 - 图29
若上图示例设置2022-03-18-生成模型的解码策略 - 图30,定义候选集2022-03-18-生成模型的解码策略 - 图31为所有单词累计概率超过0.92的最小单词子集。 在第一步采样中,包括了9个最有可能的单词,而在第二步采样中,只需选择前3个单词即可超过92%。这种方法可以使得模型,在当下一个单词的可预测性不确定时,保留较多的单词,例如2022-03-18-生成模型的解码策略 - 图32;而当下一个单词看起来比较可预测时,只保留几个单词,例如2022-03-18-生成模型的解码策略 - 图33

  1. 1、预测下一个字的出现概率
  2. next_proba = model.predict(inputs, output_ids, ...)
  3. 2、保留其中最高的topk概率,并重新归一化
  4. k_indices = probas.argpartition(-topk,axis=1)[:, -topk:]
  5. probas = np.take_along_axis(probas, k_indices, axis=1)
  6. probas /= probas.sum(axis=1, keepdims=True)
  7. 3、构造一个最小候选概率集V,使得V的概率和大于设定的阈值 p,并重新归一化
  8. cumsum_probas = np.cumsum(probas, axis=1) # 累积概率
  9. flag = np.roll(cumsum_probas >= topp, 1, axis=1) # 标记超过topp的部分
  10. flag[:, 0] = False # 结合上面的np.roll,实现平移一位的效果
  11. probas[flag] = 0 # 后面的全部置零
  12. probas /= probas.sum(axis=1, keepdims=True) # 重新归一化
  13. 4、按照概率大小进行随机采样一个
  14. 5、取出该概率索引对应的字 id,加到 token_ids后面
  15. output_ids = np.concatenate([output_ids, sample_ids], 1)
  16. 6、判断是否为结束标记
  17. 是:保存生成序列
  18. 否:循环上述的步骤

Beam Search 算法细节详解

Beam Search的使用,往往可以得到比Greedy Search更好的结果,道理很容易理解,高手下棋想三步,深思熟虑才能走得远。

beam search 伪代码.jpeg
beam search的逻辑比较简单,假设beam size为2022-03-18-生成模型的解码策略 - 图35,字典大小为2022-03-18-生成模型的解码策略 - 图36,满足2022-03-18-生成模型的解码策略 - 图37,伪代码如上所示。对于某个step t,我们首先对当前活跃的2022-03-18-生成模型的解码策略 - 图38个节点做拓展产生2022-03-18-生成模型的解码策略 - 图39个后续节点,具体做法是:先拿到每个节点的 2022-03-18-生成模型的解码策略 - 图40个概率值(总计2022-03-18-生成模型的解码策略 - 图41个),再对保留每个节点的2022-03-18-生成模型的解码策略 - 图42得到2022-03-18-生成模型的解码策略 - 图43个后续节点。随后对此处2022-03-18-生成模型的解码策略 - 图44个节点取全局的2022-03-18-生成模型的解码策略 - 图45个节点作为u+1步的活跃节点。

初始条件
u=0的时候采用节点初始化活跃节点。
终止条件
beam search进行的过程中,当某个活跃节点上产生符号的时候,从这个节点回溯得到的序列便形成一条解码序列(可称为hypothesis)。拓展2022-03-18-生成模型的解码策略 - 图46节点和选取2022-03-18-生成模型的解码策略 - 图47个节点可以用一次计算完成!
beam size
当beam size较小时,Beam Search会有跟Greedy search类似的问题(Ungrammatical、unnatural、nonsensical、incorrect),当beam size = 1时,Beam Search退化成Greedy search。增大beam size之后,可以解决上面出现的一些问题。但是,更大的beam size会花费更多的计算量,并且会引入一些其他问题。当beam size过高,在翻译任务中会降低BLEU score,因为过高的beam size会导致生成的句子都比较短;在闲聊对话任务中,过高的beam size会让输出过于普通(即无法给人惊喜)。

Beam Search 代码实现

1、随机初始化概率

  1. import numpy as np
  2. import heapq
  3. '''
  4. 概率初始化
  5. '''
  6. #第一步,每个单词的概率
  7. prob_1 = np.random.dirichlet(np.ones(3))
  8. #第二步,每种情况下每个单词的概率
  9. prob_2 = np.random.dirichlet(np.ones(3),size=3)
  10. #第三步,每种情况下每个单词的概率
  11. prob_3 = np.random.dirichlet(np.ones(3),size=[3,3])
  12. print("prob_1:\n ", prob_1, "\n")
  13. print("prob_2:\n ", prob_2, "\n")
  14. print("prob_3:\n ", prob_3, "\n")

2、贪婪搜索示例

  1. decode_dict={0:'a',1:'b',2:'c'}
  2. def greedy_search_decoder(prob_1,prob_2,prob_3,decode_dict):
  3. result=[]
  4. key1 = argmax(prob_1)
  5. print("解码向量为",prob_1,",所以选",decode_dict[key1])
  6. result.append(decode_dict[key1])
  7. key2 = argmax(prob_2[key1])
  8. print("已经选了",decode_dict[key1],",此时解码向量为",prob_2[key1],",所以选",decode_dict[key2])
  9. result.append(decode_dict[key2])
  10. key3 = argmax(prob_3[key1][key2])
  11. print("已经选了",decode_dict[key1],decode_dict[key2],",此时解码向量为",prob_3[key1][key2],",所以选",decode_dict[key3])
  12. result.append(decode_dict[key3])
  13. result_str = "最终的选择:"
  14. result_str = decode_dict[key1] + "_" + decode_dict[key2] + "_" + decode_dict[key3]
  15. print(result_str)
  16. return result
  17. print("\ngreedy_search_decoder:")
  18. greedy_search_decoder(prob_1,prob_2,prob_3,decode_dict)

3、集束搜索示例

  1. decode_dict={0:'a',1:'b',2:'c'}
  2. # beam search
  3. def beam_search_decoder(prob_1,prob_2,prob_3,decode_dict, k=2):
  4. step = 1
  5. prev_all_candidate = heapq.nlargest(k, range(len(prob_1)), prob_1.__getitem__)
  6. print("step",step,":")
  7. print("解码向量为",prob_1,",候选",[decode_dict[i] for i in prev_all_candidate])
  8. print("#######")
  9. result_str = "最终的选择:"
  10. for i in prev_all_candidate:
  11. result_str = result_str + decode_dict[int(i)] + '_'
  12. result_str = result_str.strip('_') + ','
  13. print(result_str.strip(','))
  14. print("#######")
  15. step = step + 1
  16. prob_dict = {}
  17. print("step",step,":")
  18. for item in prev_all_candidate:
  19. next_all_candidate = heapq.nlargest(k, range(len(prob_2[item])), prob_2[item].__getitem__)
  20. print("选择",decode_dict[item],"解码向量为",prob_2[item])
  21. for raw in next_all_candidate:
  22. prob_dict[str(item)+'_'+str(raw)] = prob_1[item] * prob_2[item][raw]
  23. print("候选",decode_dict[raw],"候选概率为",prob_dict[str(item)+'_'+str(raw)])
  24. print("#######")
  25. order_dict = sorted(prob_dict.items(), key=lambda d:d[1], reverse=True)
  26. final_candidate = [ i[0] for i in order_dict[0:k]]
  27. result_str = "最终的选择:"
  28. for i in final_candidate:
  29. for j in i.split('_'):
  30. result_str = result_str + decode_dict[int(j)] + '_'
  31. result_str = result_str.strip('_') + ','
  32. print(result_str.strip(','))
  33. print("#######")
  34. step = step + 1
  35. prob_dict = {}
  36. print("step",step,":")
  37. prev_all_candidate = final_candidate
  38. for item in prev_all_candidate:
  39. item1, item2 = item.split("_")
  40. item1 = int(item1)
  41. item2 = int(item2)
  42. next_all_candidate = heapq.nlargest(k, range(len(prob_3[item1][item2])), prob_3[item1][item2].__getitem__)
  43. print("选择",decode_dict[item1],decode_dict[item2],"解码向量为",prob_3[item1][item2])
  44. for raw in next_all_candidate:
  45. prob_dict[str(item1)+'_'+str(item2)+'_'+str(raw)] = prob_1[item1] * prob_2[item1][item2] * prob_3[item1][item2][raw]
  46. print("候选",decode_dict[raw],"候选概率为",prob_dict[str(item1)+'_'+str(item2)+'_'+str(raw)])
  47. print("#######")
  48. order_dict = sorted(prob_dict.items(), key=lambda d:d[1], reverse=True)
  49. final_candidate = [ i[0] for i in order_dict[0:k]]
  50. result_str = "最终的选择:"
  51. for i in final_candidate:
  52. for j in i.split('_'):
  53. result_str = result_str + decode_dict[int(j)] + '_'
  54. result_str = result_str.strip('_') + ','
  55. print(result_str.strip(','))
  56. print("\nbeam_search_decoder:")
  57. beam_search_decoder(prob_1,prob_2,prob_3,decode_dict, k=2)

4、 集束搜索(苏神实现

  1. def gen_sent(s, topk=3, maxlen=64):
  2. """beam search解码
  3. 每次只保留topk个最优候选结果;如果topk=1,那么就是贪心搜索
  4. """
  5. xid = np.array([str2id(s)] * topk) # 输入转id
  6. yid = np.array([[2]] * topk) # 解码均以<start>开头,这里<start>的id为2
  7. scores = [0] * topk # 候选答案分数
  8. for i in range(maxlen): # 强制要求输出不超过maxlen字
  9. proba = model.predict([xid, yid])[:, i, 3:] # 直接忽略<padding>、<unk>、<start>
  10. log_proba = np.log(proba + 1e-6) # 取对数,方便计算
  11. arg_topk = log_proba.argsort(axis=1)[:,-topk:] # 每一项选出topk
  12. _yid = [] # 暂存的候选目标序列
  13. _scores = [] # 暂存的候选目标序列得分
  14. if i == 0:
  15. for j in range(topk):
  16. _yid.append(list(yid[j]) + [arg_topk[0][j]+3])
  17. _scores.append(scores[j] + log_proba[0][arg_topk[0][j]])
  18. else:
  19. for j in range(topk):
  20. for k in range(topk): # 遍历topk*topk的组合
  21. _yid.append(list(yid[j]) + [arg_topk[j][k]+3])
  22. _scores.append(scores[j] + log_proba[j][arg_topk[j][k]])
  23. _arg_topk = np.argsort(_scores)[-topk:] # 从中选出新的topk
  24. _yid = [_yid[k] for k in _arg_topk]
  25. _scores = [_scores[k] for k in _arg_topk]
  26. yid = np.array(_yid)
  27. scores = np.array(_scores)
  28. best_one = np.argmax(scores)
  29. if yid[best_one][-1] == 3:
  30. return id2str(yid[best_one])
  31. # 如果maxlen字都找不到<end>,直接返回
  32. return id2str(yid[np.argmax(scores)])

PS:Keras 版本 beam search 可以参考苏神写的通用自回归生成模型解码基类 bert4keras.snippets.AutoRegressiveDecoder()

Beam Search 的问题

nlg过程的优化

Penalize longer sequence

当计算序列可能在文本中出现的概率时,越长的序列会使得概率越小(如下公式),会导致较短的句子更容易被选出。因为score函数的每一项都是负的,序列越长,score往往就越小。
2022-03-18-生成模型的解码策略 - 图48
为了解决对长句子惩罚的问题,可以对序列的概率进行归一化,让不同长度的序列有个标准化的概率:
2022-03-18-生成模型的解码策略 - 图49
或者使用跟序列长度相关的归一化方法,如下公式:
2022-03-18-生成模型的解码策略 - 图50
根据调整后的结果来选择best one。

Repetitive Problem

Beam search在做文本生成的时候,当生成的文本较长时,通常都会出现生成的文本重复的问题,这也是文本生成中一个较为常见的问题。
有研究发现,当文章不断重复的时候,可以让Loss不停地下降,所以导致的文本生成中出现重复。

  1. 最简单和常用的解决文本生成重复问题的方法,是设置不重复使用n-grams。如果发现生成的是重复的,那就抛弃。这种方法简单且通常比较有用。
  2. 第二种方法是,在Loss中加一个与文本相似度相关的惩罚项,如下公式:

2022-03-18-生成模型的解码策略 - 图51

当生成文本与前文相似度较高时,2022-03-18-生成模型的解码策略 - 图52会使得序列预测的概率更低,生成的Loss变大,从而解决重复问题。

  1. 第三种方法是使用Unlikelihood Objective方法。它的思想是,生成一个不想要的token的集合,使得每次生成文本的时候,生成集合里的token的概率会被降低。略….

Generic Problem

在人说话的时候是会有一些概率表达的波动的,每个词说出来并不一定是概率最高的,而是偶尔会给人惊喜的词。Beam Search总会选择概率较大的来输出,不太符合人说话时的概率波动。而且在文本生成任务中,它容易会生成出空洞、重复、前后矛盾的文本。
2022-03-18-生成模型的解码策略 - 图53
从上图中右边的图看,Sampling的解码方式更符合人们说话的情况。因此,在文本生成任务中Sampling的解码方式更合适。

任务类型 适用解码 应用实例
高熵任务 Sampling 文本/对话生成
低熵任务 Beam Search 机器翻译

解码算法:总结

http://www.showmeai.tech/article-detail/252#s0-3-4

  • 贪心解码是一种简单的译码方法;给低质量输出
  • Beam搜索(特别是高beam大小) 搜索高概率输出
    • 比贪婪提供更好的质量,但是如果 Beam 尺寸太大,可能会返回高概率但不合适的输出(如通用的或是短的)
  • 采样方法来获得更多的多样性和随机性
    • 适合开放式/创意代 (诗歌,故事)
    • 2022-03-18-生成模型的解码策略 - 图55个抽样允许控制多样性
  • Softmax 温度控制的另一种方式多样性
    • 它不是一个解码算法!这种技术可以应用在任何解码算法。

参考

解码算法系列之集束搜索(Beam Search)
Beam Search快速理解及代码解析(下)
玩转Keras之seq2seq自动生成标题-苏剑林
如何应对Seq2Seq中的“根本停不下来”问题?
NLG过程的优化-小白也能学好深度学习
斯坦福NLP课程 | 第15讲 - NLP文本生成任务