首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >没数据也能玩转BERT!无监督语义匹配实战

没数据也能玩转BERT!无监督语义匹配实战

作者头像
zenRRan
发布2019-10-21 11:17:24
2.1K0
发布2019-10-21 11:17:24
举报

在实际业务中,对给定Query检索特定范围内的词是十分常见的需求。

对于字面上的匹配总体来说并不复杂,但实际效果就仅限于有字符交集的词语。若是想要上升到语义之间有相关度,就可以化归为学术界常见的语义匹配的问题。

然而,在实际工业界或项目中,或是限于经费,或是只是小试牛刀,没有标注好的语料进行训练,那么如何在无监督上把语义匹配玩转呢?

最近我们请来了(曾经?)大红大紫的BERT,来做无监督的Query-词的语义匹配。

难点分析与思路

那么,你说的这个无监督的Query-词的语义匹配,到底难在哪里呢?

无训练语料

首先自然是无监督啦,在千奇百怪的实际业务中很难在网上找到适合训练的语料,而基于种种原因不能或不想标注的话,你大概率要和有监督say no了。

无监督的弱势十分明显,给定一个query和词,模型都不知道他们是否相关,那怎么办?笔者就想到了近来如火如荼的大规模预训练语言模型,这些由大公司在极大规模语料上预训练好的模型,它们给句子的向量编码已经包含足够多的信息了,若是再辅以和业务相关的语料微调,就更好了。

如何获取句子向量表示

预训练模型哪家强?最近BERT这么火,就拿它来试试水。之后笔者会出word2vec及其改良篇的语义匹配,敬请期待。

这里你可能会问,大家都拿BERT来做有监督,在它后面再加一两层网络然后用自己业务的有监督数据微调,要怎么做无监督啊?

其实很简单,既然BERT是由多个transformer堆叠而成,而每层transformer都会输出【batch size * sequence length * hidden size】的向量,那么我们拿其中某层transformer的输出再改造改造作为输入句子的语义编码就好了。

如何匹配Query-词

Query通过transformer拿到向量表示,那么词也以此拿到向量表示,而后将query和所有词语的表示计算相似度,按照阈值或者最大n个取出相似的词。

具体实现

分析完难点,并根据难点找到思路后,就可以开始动手实现了。

首先大头自然是获取BERT句子表示了,这里我们用到了腾讯AI-Lab大佬肖涵博士的bert-as-service.

里面分成了Server端Client端,其中Server端就是加载BERT预训练模型和根据Client传过来的句子返回向量编码,而Client端只需要向Server传原始句子,得到向量编码后利用编码干活即可。

为了方便起见可以将Server和Client都安装在同一台机器上,具体的使用方法肖涵大佬已经在Getting Started 部分写的清清楚楚了,这里就不赘述。提一下需要注意且最常用的几个点,更多的细节和疑问详见里面的FAQ

  1. 输出选哪一层?句子向量怎么得到?输出的默认选择是pooling_strategy=REDUCE_MEAN and pooling_layer=-2. 鉴于transformer有多层,具体使用哪层可以自行实验,但一般来说-2(即倒数第二层),-3效果较好,因为-1过于靠近预训练时的目标。而获取句子编码方面是将整个句子所有字编码取平均,若是经过微调之后,选择CLS标签也不错。
  2. 相似度怎么衡量?输出的query和词的句子向量计算完cos相似度之后,不建议用阈值,而应当选择cos相似度最高的几个词,因为cos计算相似度时所有维度权重相同,而编码后的向量足足有768维,其中对实际业务query影响较大的维度不多。
  3. 是否支持微调的BERT?支持加载微调之后的模型,只需要利用tuned_model_dir参数表明即可,如何方便快速地对BERT模型进行微调并保存成service可以加载的格式,后面会提到。

通过bert-as-service,我们得到了所有词的向量表示,并且每当来一个query都可以通过service得到它的表示。

那么怎么计算相似度呢?最简单粗暴的方法莫过于暴力轮询,一个for循环挨个计算cosine,想想还是算了,不够优雅便捷。笔者转念一想,这不就是word2vec的模式吗?给出一个词的向量,找词表里所有词最接近的topn,自然而然就想到了用gensim。

预处理的过程如上图所示,首先将词库中每个词通过BERT得到对应的向量表示,而后存储成word2vec格式,即首行为 词数 向量长度,而后每行为词语名+空格分隔的小数。

有了词库的向量后,整个语义匹配的流程如下:

预先加载好Server的BERT模型和gensim的词库向量,对于新来的每个query,首先通过BERT得到向量表示,然后扔到gensim中查找最接近的几个词语返回。

看到这里似乎可以结束了?不不不,路还长着呢,以上如果是做个demo,练练手什么的自然足够,但是要在实际中使用则远远不够。接下来则以上面为基本框架来对每个部分进行改良。

模型改良

效果优化一:长短不一

首先是效果方面,用户的query一般远长于词,而BERT的768维向量隐式包含了句子长度的信息,导致匹配会优先选择长度相近的词,来看几个例子。

假设词库为 ["蛋", "苹果", "香蕉", "西瓜", "西伯利亚龙卷风"],我们输入个 “想吃根香蕉”看看:

看起来挺正常的,cosine相似度最接近为1,香蕉拿到了最高分,正常。然后输入"风"看看,最接近的应该是西伯利亚龙卷风吧:

竟然是“蛋”最高分,不太对啊,而且正解的龙卷风竟然是最低分,再试一个长点的query:

一句话囊括了香蕉、苹果、西瓜,没想到相似度他们都败给了龙卷风。。

由于Query和词大多数情况都是不等长的,那么这种问题发生的概率较大,但是要怎么解决呢?笔者有两种方案:

  1. 向量降维,768维显然包含了过多冗余的信息,那么通过PCA等方法将其降维再匹配,或许效果会好。
  2. Query删减重组,首先可以根据业务需要去除无关词及停用词,例如“今天”,“呢”,“看起来”等,而后再以n-gram组合的方式进行语义匹配。 例如将分词并删减后的句子的2-gram及3-gram构成列表,比如 "今天天气真好啊",分词后为["今天", "天气", "真好", "啊"], 2-gram组合即为 ["今天天气", "天气真好", "真好啊"], 3-gram组合为["今天天气真好", "天气真好啊"], 2,3-gram组合就是将2-gram的列表和3-gram拼接在一起。将组合结果依次进行语义匹配,投票选择分数最高的topn作为最终结果。

需要提醒的是,Query删减的方法可以解决长Query匹配短词语的问题,但不能解决短query匹配长词语的问题,不过胜在简单易实现,也没什么超参数。因此两种方法都值得一试。

效果优化二:BERT微调

前文提到,如果有业务相关的数据用于微调会更好,这里指的业务相关不一定要完全和任务一样,例如这里是语义匹配,如果手里有该业务的意图分类的训练语料,那也可以用来微调,实验证明效果会好一些。

那么如何快速方便地对BERT进行微调呢?

考虑到用tensorflow开发效率较低,而用pytorch又不好导出成bert-as-service需要的tensorflow checkpoint,于是笔者考虑用Keras来实现,而经过搜寻找到了大佬开源的keras_bert包,能够在Keras中快速加载BERT模型,再辅以Keras本身简洁的网络接口,很快就可以在BERT后加上简单的网络再一起训练,并且最终导出成tensorflow的checkpoint,交给service导入。

代码部分参考苏大佬的《当Bert遇上Keras:这可能是Bert最简单的打开姿势》, 在其之上进行修改,这里介绍几个关键的部分,详细代码见我的github:https://github.com/zedom1/Repo-for-gist/blob/master/keras_finetune_bert.py

class data_generator:
    def __init__(self, data, tokenizer, batch_size=32):
        self.data = data
        self.tokenizer = tokenizer
        self.batch_size = batch_size
        self.steps = len(self.data) // self.batch_size
        if len(self.data) % self.batch_size != 0:
            self.steps += 1
    def __len__(self):
        return self.steps
    def __iter__(self):
        while True:
            idxs = list(range(len(self.data)))
            np.random.shuffle(idxs)
            X1, X2, Y = [], [], []
            for i in idxs:
                d = self.data[i]
                x1, x2 = self.tokenizer.encode(first=d[0], second=d[1], max_len=maxlen)
                y = d[2]
                X1.append(x1)
                X2.append(x2)
                Y.append(y)
                if len(X1) == self.batch_size or i == idxs[-1]:
                    X1 = seq_padding(X1)
                    X2 = seq_padding(X2)
                    yield [X1, X2], Y
                    [X1, X2, Y] = [], [], []def create_model(train=True):
    bert_model = load_trained_model_from_checkpoint(config_path, checkpoint_path, seq_len=None)
    if train:
        for l in bert_model.layers:
            l.trainable = True
    x1_in = Input(shape=(None,))
    x2_in = Input(shape=(None,))

    x = bert_model([x1_in, x2_in])
    # get [CLS] as feature
    x = Lambda(lambda x: x[:, 0])(x)
    p = Dense(units=2, activation='softmax')(x)

    model = Model([x1_in, x2_in], p)
    return model
saver = Saver()
sess = keras.backend.get_session()
save_path = saver.save(sess, "./model.ckpt")

其中读取文件获取训练数据的部分在train函数中,而处理文本、转成编码的地方在data_generator,若是只有单个输入只需要把__iter__函数里的self.tokenizer.encode的second参数去掉即可,而在BERT后增加一些层则在create_model函数中,会keras的话一下就可以上手了。

而训练完之后,最后三行就是将keras微调好的bert存成tensorflow的checkpoint,是不是十分简单呢?在每轮训练过后都将该轮微调模型保存起来,这样在整体训练完之后可以自由选择某个epoch之后的模型作为最终结果。

说点题外话,除了官方释放的BERT模型之外,百度、哈工大讯飞等各界大佬也利用自己的中文语料训练了BERT模型并开源,可以多做几次实验横向对比选择最好的。此处将这几位大佬的开源BERT汇总一下:

官方中文BERT:https://github.com/google-research/bert

百度ERNIE:https://github.com/ArthurRizar/tensorflow_ernie (官方的是paddlepaddle版的,这位大佬将参数文件转换成tensorflow版)

哈工大讯飞:https://github.com/ymcui/Chinese-BERT-wwm

效率优化一:BERT优化

在解决了模型效果方面的问题后,还有效率方面的考虑。在实际业务中,有许多强大无比的模型就是因运行速度过慢,无法达到实际业务需求而被放弃。

而BERT哪怕在经过大佬重写优化后效率还是不尽人意,毕竟要经过12/24层transformer,怎么快的起来。

而更令人沮丧的是,实际用户的query千奇百怪,多个字少个字意思就可能完全变了,靠缓存少量query和向量很难解决实际需要。

此处有两个地方可以优化,一个是BERT了,另一个是gensim取最近topn词语。由于在效果对比上优化后的BERT还是不如SIF,因此此处的效率优化仅提供方向,对效果更好的SIF的介绍,请期待word2vec及其改良篇。

首先来看BERT,一个最简单方便的提升效率方法就是利用bert-as-service里的并行化,将多个query构造成一个batch,以batch为单位交给server编码。

第二个思路是尝试知识蒸馏。显然工业界的大佬们也发现了BERT在实际应用中推理速度过慢的问题,于是就有大佬提出知识蒸馏的方法,用一个复杂度较小的模型去拟合BERT的结果,相当于BERT作为老师手把手教学生,实际使用时我们使用那个复杂度较小的模型即可。

这样虽然精度上会有所下降,但是效率上会有提升,前文也有提到,很多强大无比的模型却因为效率达不到要求而被弃用,这里BERT可以作为典型,不过知识蒸馏的出现让它可以继续发光发热。有兴趣的可以关注一下这方面的进展:https://github.com/qiangsiwei/bert_distill

效率优化二:Gensim优化

然后就是gensim取最近topn词语的优化了,虽然gensim内部是通过numpy矩阵相乘进行运算的,但对于大词表而言还是不够快。

我们可以牺牲一点准确率换取效率的上升,由此就有近似匹配的用武之地了,典型的如 Annoy:https://github.com/spotify/annoy

它们通过对向量预先建立索引,查询时从建立好的索引树搜索,达到对轮询搜索的近似,当然准确率只会低于轮询不会高。

建立索引树的数目可以人为设定,树越多准确率越高(上限即为轮询搜索的准确率),相应的检索速度就会慢。而从原本的word2vec构建annoy索引也十分简单,只需三个函数寥寥数十行,详细代码见我的github:https://github.com/zedom1/Repo-for-gist/blob/master/annoy_build.py

# 构建索引,只需构建一次def build_index(tree_num = 100):
  global id2word, word2id

  # 自定义的读取word2vec的函数
  items_vec = load_gensim()
  # 向量维度为200
  a = AnnoyIndex(200)
  i = 0
  id2word, word2id = dict(), dict()
  for word in items_vec.vocab:
    a.add_item(i, items_vec[word])
    id2word[i] = word
    word2id[word] = i
    i += 1
  a.build(tree_num)
  a.save(ann_save_path)
  pkl.dump(id2word, open(id2word_path, "wb"))
  pkl.dump(word2id, open(word2id_path, "wb"))

# 实际运行时加载索引def annoy_init():
  global id2word, word2id, annoy_index
  id2word = pkl.load(open(id2word_path, "rb"))
  word2id = pkl.load(open(word2id_path, "rb"))
  annoy_index = AnnoyIndex(200)
  annoy_index.load(ann_save_path)
# 近似检索,query为编码后的向量
def annoy_search(query, topn=10):
  global id2word, word2id, annoy_index
  idxes, dists = annoy_index.get_nns_by_vector(query, topn, include_distances=True)
  idxes = [id2word[i] for i in idxes]
  similars = list(zip(idxes, dists))
  return similars

感悟

近来预训练语言模型确实大大降低了后续业务的门槛,不需要各家再辛苦训练自己的embedding了,各种方便的轮子也降低了使用的门槛,不过这也代表着我们应该更集中于根据实际业务调整方案,比较各种方案的优劣并实验尝试。

实际上手之后才会发现哪怕各种轮子都有了,该做的实验还是不少,坑还是有,只是位置不一样了。。

另外有监督和无监督相比效果上自然会好一些,但数据准备、标注准备等等同样也要花一番功夫,数据不当、标注选择不当反而起反效果,且时间周期要长得多,经费花费也大,值不值得就见仁见智了。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-10-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 深度学习自然语言处理 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 难点分析与思路
  • 具体实现
  • 模型改良
  • 感悟
相关产品与服务
NLP 服务
NLP 服务(Natural Language Process,NLP)深度整合了腾讯内部的 NLP 技术,提供多项智能文本处理和文本生成能力,包括词法分析、相似词召回、词相似度、句子相似度、文本润色、句子纠错、文本补全、句子生成等。满足各行业的文本智能需求。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档