基于Sentence-Bert的检索式问答系统

文章目录

  • 前言
  • 环境
  • 构建数据集
  • 训练SBERT模型
  • 测试
  • 粗排
  • 精排
  • 优化策略
  • 结果
  • 总结

前言

常见的问答任务有四种:

  • 知识图谱问答:基于给定知识图谱生成问题对应的答案
  • 表格问答:基于给定表格集合生成问题对应的答案
  • 文本问答:基于给定文本生成问题对应的答案
  • 社区问答:基于从问答社区网站抓取的问答对进行问答任务

CSDN主站,有个问答频道,为了降低用户重复提问率,我们需要根据用户正在提的问题,从问答库中,匹配出最相似的已采纳的问题的答案,推荐给用户。因此,这里我们要做的是社区问答任务。

问答对:问答社区网站上提供的<问题, 答案>对数据集合。

社区问答,具体来说,就是给定输入问题,社区问答从问答对中检索与输入问题语义最为匹配的已有问题,并采用该已有问题对应的答案作为当前问题的答案。由此可见,社区问答最关键的环节是计算问题和已有问题之间的语义相似度,以及计算问题和答案之间的语义相关度。

基本概念清楚后,进入正题:

环境

lightgbm==3.3.2
hnswlib==0.6.2
sentence_transformers==1.2.0

windows上应该装不上hnswlib

其他的缺啥装啥

构建数据集

CSDN,有大量的无标注数据,但高质量的人工标注数据,少之又少。因此,我们这里也是使用无标注数据。但在构建数据的过程中,我们可以采取一些手段,将误差降到最小。

数据格式:

q_strquery文本
doc_strtarget文本

同一行的数据,为相似数据。即我们可以将同一行的对作为正样本,不同行的对作为负样本。

接下来,我们需要对这些样本标注。这里我们使用Sentence-Bert的预训练模型来计算句向量,再计算皮尔逊系数,作为标签。

关于Sentence-Bert原理,可以直接查看原论文:Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks

关于Sentence-Bert基本使用,可以查看官网 https://www.sbert.net/index.html

基于Sentence-Bert的检索式问答系统_第1张图片
从官网可以看到,all-mpnet-base-v2是当前最好的模型,因此,我们在构建数据集时,可以选用效果最好的模型,all-MiniLM-L6-v2是当前较为均衡的模型,该模型占用内存小,推理速度快,且效果不差,因此,我们在部署到线上时,选用该模型作为基础模型来进行预训练。

构建SentenceTransformer训练数据:

def build_vector(index, data, model):
    data_res = []
    count = 0
    for idx, i in zip(
                data.index,
                data.loc[:, ["qid", "doc_id", "q_str", "doc_str"]].values,
            ):
        count+=1
        logger.info(f"当前-----------{count}/{len(index)}-----------")
        qid, doi, sa, sb = i

        sav = model.encode(sa)
        sbv = model.encode(sb)

        sco, _ = pearsonr(sav, sbv)
        l = min(max(0, (1 + sco) / 2), 1)
        d = InputExample(texts=[sa, sb], label=l)
        data_res.append(d)
        for n_idx in np.random.choice(index, 1):
            if n_idx != idx and isinstance(sa, str) and isinstance(sb, str):
                sb_n = data.loc[n_idx, "doc_str"]
                sbnv = model.encode(sb_n)
                sco, _ = pearsonr(sav, sbnv)
                l = min(max(0, (0.3 + sco) / 2), 1)
                dn = InputExample(texts=[sa, sb_n], label=l)
                data_res.append(dn)
    return data_res


def test_build_dataset(config, options):
    dir_path = "./data/datasets/answer/sts_dset"
    data_full_train, data_full_dev = load_dataset(dir_path=dir_path, dd_cache=False)
    data_full_train.to_csv("./test/answer/data/train.csv", index=False)
    data_full_dev.to_csv("./test/answer/data/dev.csv", index=False)
    data_full_train = data_full_train.dropna()
    data_full_dev = data_full_dev.dropna()

    data_full_train_idx = data_full_train.index
    data_full_dev_idx = data_full_dev.index

    model_name="sentence-transformers/all-mpnet-base-v2"
    train_data_save_dir = os.path.join(dir_path, model_name.split('/')[-1])
    if not os.path.exists(train_data_save_dir):
        os.makedirs(train_data_save_dir)
    word_embedding_model = models.Transformer(
        model_name
    )
    pooling_model = models.Pooling(
        word_embedding_model.get_word_embedding_dimension(),
        pooling_mode_mean_tokens=True,
        pooling_mode_cls_token=False,
        pooling_mode_max_tokens=False,
    )
    model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
    data_train = build_vector(index=data_full_train_idx, data=data_full_train, model=model)
    data_dev = build_vector(index=data_full_dev_idx, data=data_full_dev, model=model)
    pd.to_pickle(data_train, f"{train_data_save_dir}/data_train_sts_float.pkl")
    pd.to_pickle(data_dev, f"{train_data_save_dir}/data_dev_sts_float.pkl")

皮尔逊相关系数用于度量两个变量(X和Y)之间的线性相关程度,其值介于-11之间。

在上述代码中,为了便于计算,我将皮尔逊相关系数的值从[-1,1]之间映射到了[0,1]之间,值越大,越相关,值越小,越不相关。

值得注意的是,我们这里的训练数据是 对,更为正确的做法是使用对作为训练数据。奈何没有高质量的人工标注数据,只能先用训练出一版模型看看效果。

训练SBERT模型

说实话,这训练代码,是真的简单,不信看代码:

import os
import pandas as pd
from sentence_transformers import SentenceTransformer, SentencesDataset, models
from sentence_transformers import InputExample, evaluation, losses
from torch.utils.data import DataLoader
from common.path.model.sentence_model import get_sentence_model_dir

class TrainSentectTransformerModel():
    def __init__(self, config, options):
        self.model_name="sentence-transformers/multi-qa-MiniLM-L6-cos-v1"
        self.build_dataset_model_name = "all-mpnet-base-v2"
        self.data_dir_path = "./data/datasets/answer/sts_dset"

        self.data_dir_path = os.path.join(self.data_dir_path, self.build_dataset_model_name)

        self.train_path = os.path.join(self.data_dir_path, "data_train_sts_float.pkl")
        self.dev_path = os.path.join(self.data_dir_path, "data_dev_sts_float.pkl")
        self.model = None
        self.model_save_dir = get_sentence_model_dir()
        self.model_save_path = os.path.join(self.model_save_dir,  self.model_name.split("/")[-1])

    def load(self):
        word_embedding_model = models.Transformer(
            self.model_name
        )
        pooling_model = models.Pooling(
            word_embedding_model.get_word_embedding_dimension(),
            pooling_mode_mean_tokens=True,
            pooling_mode_cls_token=False,
            pooling_mode_max_tokens=False,
        )
        self.model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
    

    def load_train_data(self):
        train_data = pd.read_pickle(self.train_path)
        train_data_list = []
        for item in train_data:
            sa, sb = item.texts
            label = float(item.label)
            dn = InputExample(texts=[sa, sb], label=label)
            train_data_list.append(dn)
        train_dataset = SentencesDataset(train_data_list, self.model)
        train_dataloader = DataLoader(train_dataset, shuffle=True, batch_size=32)
        return train_dataloader


    def load_dev_data(self):
        sentences1, sentences2, scores = [], [], []
        dev_data = pd.read_pickle(self.dev_path)
        for item in dev_data:
            sa, sb = item.texts
            label = item.label
            sentences1.append(sa)
            sentences2.append(sb)
            if label > 0.5:
                label = 1
            else:
                label = 0
            scores.append(label)
        return sentences1, sentences2, scores

    
    
    def train(self):
        self.load()
        train_dataloader = self.load_train_data()
        dev_sentences1, dev_sentences2, dev_scores = self.load_dev_data()

        train_loss = losses.CosineSimilarityLoss(self.model)
        evaluator = evaluation.BinaryClassificationEvaluator(dev_sentences1, dev_sentences2, dev_scores)
        self.model.fit(train_objectives=[(train_dataloader, train_loss)], epochs=50, warmup_steps=100,
          evaluator=evaluator, evaluation_steps=300, output_path= self.model_save_path)
        self.model.evaluate(evaluator)

    def __call__(self):
        self.train()
        

是吧,训练很简单,只有些数据处理的操作

测试

训练完成后,我们来试试效果:

def test_sentence_model(config, options):
    model_dir = "./data/models/sentence_model/multi-qa-MiniLM-L6-cos-v1"
    model = SentenceTransformer(model_dir)
    query_sentence = "hp服务器序列号"
    target_sentences = "xmind2021激活序列号"
    query_vector = model.encode([query_sentence])
    target_vectors = model.encode([target_sentences])
    score = cosine_similarity(query_vector, target_vectors)
    print(score[0][0])

输出:

0.46232918

再使用一条典型数据来测试下:

query_sentence = "引入Echart后无法引用echart.方法 先下载了Echarts包,然后在head里引入了echarts.js,定义div并赋予了大小"
target_sentences = "echarts隐藏柱体,但要在悬浮中显示数据 echarts需要隐藏某个柱状图的柱体,但是要在悬浮中有显示这个隐藏柱体的数据"

# score = 0.9297024

我们来分析下,这两条数据,有部分重叠的关键词,但整体语义,并不相关,语义相似度应该很低才对,但我们的模型,给出的分数竟然有0.92,出乎意料。

我们再来看下我们的训练数据:

q_str = "python 实现sql递归"
doc_str = "python实现递归的例子 用递归实现阶乘    def   func (n) :       if  n ==  1 :          return   1       else :          return  n * func(n- 1 )    用递归实现斐波那契数列      def   fibo (n) :       if  n ==  1   or  n ==  2 :          return   1       else :          return  fibo(n- 1 ) + fibo(n- 2 )     用递归实现二分查找      def   b_sort (l, aim, start= 0 , end=None) :       if  end ==  None : end = len(l)- 1       if  start <= end:         mid = (end-start) //  2  + start  #保证每次都是相应的数列位置           if  aim < l[mid]:              return  b_sort(l, aim, s"

我们的训练数据,q_strdoc_str之间也是存在部分关键词重叠,但二者语义是相关的。

因此,造成上面测试用例语义得分太高的原因显而易见了。训练时我们使用 对,预测时我们使用 对,训练与预测不一致,导致即使有部分关键词重叠,但整体语义相差较大,模型输出的得分较大。

那么,既然我们没有对格式的数据,我们做到这里,只能放弃了吗?

不!CSDN AI小组没有放弃!

首先,我们需要确定的是,这个模型,对于语义相关的数据,是有效的!(已经通过实验证实,确实是有效)

既然模型有效,那么,我们只需要过滤掉只有部分关键词重合,但整体语义不相关的数据就可以了。

怎么过滤呢?

答案是:我们再训练一个tfidf模型,计算query_aquery_btfidf得分,只有部分关键词重合的数据,其关键词得分应该是较低的。

那么,我们计算下之前使用过的两条querytfidf得分:

query_a = "引入Echart后无法引用echart.方法 先下载了Echarts包,然后在head里引入了echarts.js,定义div并赋予了大小"
query_b = "echarts隐藏柱体,但要在悬浮中显示数据 echarts需要隐藏某个柱状图的柱体,但是要在悬浮中有显示这个隐藏柱体的数据"

tfidf_score = 0.1512441662635543

确实是较低!(当然,并不是通过这一条数据得出的结论)

加入tfidf限制后,queryquery之间存在重叠关键词但语义不相关的问题得到了解决。

那么,语义匹配的问题,就解决了。接下来需要考虑的是,CSDN问答库中,有50w左右的已采纳数据,这么大的数据量,总不能用query去与所有数据一一计算相似度吧?显然,这是不现实的。

粗排

在大多数的问答系统中,一般分为三个模块:

  • 意图识别
  • 粗排
  • 精排

在这里,我们暂时没有做意图识别模块,也许,后续数据量大了,会加入意图识别。加入意图识别,有以下好处:

  • 缩小匹配范围
  • 提升匹配效率
  • 提升匹配准确率

如果你的数据量够大,至少每个类别下面有几十万的数据,你可以考虑加入意图识别模块来提升你问答系统整体的效果。

那么,我们要怎么构建自己的问答数据库呢?

由于我们的数据都是文本,要计算文本之间的语义相似度,首先我们需要将文本转换成向量,转成向量后,我们需要构建一个倒排索引表,将这些文本数据,存入倒排表中。类似Elasticsearch在建立索引的时候采用的倒排索引的机制(强烈建议去了解下)。

HNSW就是一种构建倒排索引以达到快速检索的算法,在这篇文章中,采用的便是这种算法。
有关HNSW的原理,推荐阅读:一文看懂HNSW算法理论的来龙去脉

好在python各种包多,不管啥算法,都有前人帮你实现了,你只要pip一下,就能用了。
hnsw的实现,有两个包,一个是Facebook研发的faiss,一个是hnswlib,这里我使用的是hnswlib,据说二者都是c++实现,使用起来没太大差别。

hnswlib使用手册:https://github.com/nmslib/hnswlib

class HNSW(object):
    def __init__(self, config, options):
        self.hnsw_config = {
            "M": 64,
            "ef": 2000
        }
        self.hnsw_model_path = get_sentence_hnsw_model_path()
        self.hnsw_vec_data_path = get_hnsw_vec_data_path()
        self.answer_pg_query = AnswerPgQuery(config, options)
        self.sentence_transform_model_path = get_sentence_transformers_model_path()
        self.method = "sentence_transformer"
        self.sentence_model = None
        self.hnsw = None

    def load(self):
        if os.path.exists(self.hnsw_model_path):
            logger.info("加载 hnsw ...")
            self.hnsw = self.load_hnsw()
        logger.info("加载 sentence transformer model ...")
        if torch.cuda.is_available():
            device = torch.device("cuda")
        else:
            device = torch.device("cpu")
        self.sentence_model = SentenceTransformer(
            self.sentence_transform_model_path, device=device)

    def load_data(self):
        data = []
        all_answer_data = self.answer_pg_query.fetch_all_answer_data()
        for item in tqdm(all_answer_data, desc=f"get vec {self.method}"):
            title = item[0]
            body = item[1]
            body = get_text_from_html(body)
            text_vec = self.sentence_model.encode([title + body])
            sentence_vec = text_vec[0]
            data.append(sentence_vec)
        joblib.dump(data, self.hnsw_vec_data_path)

        return data

    def train_hnsw(self):
        sentences_vec = self.load_data()
        cores = multiprocessing.cpu_count()
        num_elements = len(sentences_vec)
        logger.info("初始化 hnsw ...")

        # possible options are l2, cosine or ip
        import hnswlib
        p = hnswlib.Index(space='l2', dim=384)
        p.init_index(max_elements=num_elements,
                     ef_construction=self.hnsw_config['ef'], M=self.hnsw_config['M'])
        p.set_ef(10)
        p.set_num_threads(cores)
        logger.info("Adding first batch of %d elements" % (len(sentences_vec)))
        p.add_items(sentences_vec)
        labels, distances = p.knn_query(sentences_vec, k=1)
        print('labels: ', labels)
        print('distances: ', distances)
        print("Recall:{}".format(
            np.mean(labels.reshape(-1) == np.arange(len(sentences_vec)))))
        p.save_index(self.hnsw_model_path)
        del p

    def load_hnsw(self):
        import hnswlib
        hnsw = hnswlib.Index(space='l2', dim=384)
        hnsw.load_index(self.hnsw_model_path)
        return hnsw

    def add_elements(self, data_vec):
        import hnswlib
        hnsw = hnswlib.Index(space='l2', dim=384)
        hnsw.load_index(self.hnsw_model_path)

        current_elements_num = hnsw.element_count

        max_elements = current_elements_num + len(data_vec)

        hnsw_copy = copy.deepcopy(hnsw)
        del hnsw

        hnsw_copy.load_index(self.hnsw_model_path, max_elements)

        hnsw_copy.add_items(data_vec)

        hnsw_copy.save_index(self.hnsw_model_path)

    def search(self, text, k=5):
        text_vec = self.sentence_model.encode([text])
        q_labels, q_distances = self.hnsw.knn_query(text_vec, k=k)
        return q_labels, q_distances

    def get_search_result(self, text):
        q_labels, q_distances = self.search(text, k=10)
        indexs = q_labels[0]
        # 取得粗排结果

        res_str = ""
        for index in indexs:
            index = index + 1
            ret = self.answer_pg_query.query_answer_data_by_index([index])
            title = ret[0][1]
            body = ret[0][2]
            res_str += f"Query : {text} , Target : {title} \n"
        print(res_str)
        return

在构建句向量时,我使用的是前面训练好的SBERT模型。有些人可能会说,使用word2vec来构建句向量不行吗?
我的回答是:不行!
因为训练好的word2vec太大了,就拿这个例子来说,50w的数据,训练出来的word2vec的大小已经达到了GB级别,服务器上内存本来就紧张,你再加个GB级别的模型,服务器分分钟被你干崩溃,等着写事故报告吧!

由于开发时间问题,我这里只尝试了SBERT去构建句向量,其实,你还可以尝试使用AutoEncoder的方法去构建句向量。关于AutoEncoder原理,可以参考:深入理解AutoEncoder

在度量相似度时,hnswlib支持三种方式,如下图:
基于Sentence-Bert的检索式问答系统_第2张图片
这里我选择了Squared L2,哪一种方式更准确,我并没有去做对比实验,如果你构建句向量的模型足够准确,理论上差距不大。

我们来看看效果:

Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : Python重量计算 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : 有关python制作七段数码管的问题 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : python数字与字母分离 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : python昆虫繁殖问题 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : 各位朋友 如何用python语言表达 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : python复利计算利息 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : python如何用时间遍历很多个月 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : 简单的Python题求解 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : Python输入上课时间的总秒数,计算今天上课时间是多少小时多少分多少秒的方式表示出来 
Query : Python重量计算 重量计算 月球上的物体重量是地球上的16.5% , Target : Python上机实践,字符类型及其操作 

确实可以找到目标答案,从这里也可以看出,使用对去训练SBERT,虽然会带来负面作用,但可以粗略表示句向量。

从上面的代码中,可以看出,hnswlib还支持增量数据插入,这样,就不需要每次全量更新倒排索引表了,只需要将新增的数据插入到索引表中就可以,大大减少了计算量。

注意: 我们拿到的召回结果,只是query文本的句向量对应的下标索引,因此,我们的原始数据,需要保存在数据库中,这样,才能通过召回结果,找到源数据。

精排

粗排的过程,一般也称之为召回,取得召回的结果后,我们需要对召回的结果,进行精排。

精排的过程,其实就是将query与召回的结果,一一计算相似度,取出得分最大的那一条数据,作为输出。我们这里,精排模型使用的是我们一开始训练的SBERT模型,将query和召回的结果,转换成句向量,用query与召回结果一一计算余弦相似度。

    def get_tfidf_score(self, query_text, target_text):
        str_a_list = self.segment.segment(query_text)
        str_b_list = self.segment.segment(target_text)
        text_a = ' '.join(str_a_list)
        text_b = ' '.join(str_b_list)
        vec_a = self.tfidf.transform([text_a])
        vec_b = self.tfidf.transform([text_b])
        sim = cosine_similarity(vec_a, vec_b)[0][0]
        return sim


    def get_result(self, query):
        logger.info("获取召回结果...")
        q_labels, q_distances = self.hnsw.search(query)
        indexs = q_labels[0]
        
        # 取得粗排结果
        recall_res = []
        for index in indexs:
            index = index + 1
            ret = self.answer_pg_query.query_answer_data_by_index([index])[0]
            question_id = ret[0]
            title = ret[1]
            body = ret[2]
            answer_id = ret[3]
            tag_ids = ret[4]
            item = (query, question_id, title, body, answer_id, tag_ids)
            recall_res.append(item)
        
        # 准备精排需要的相似度特征
        lightgbm_df = pd.DataFrame(columns=['query', 'target_question_id', 'target_title', 'target_body', 'answer_id', 'tag_ids', 'bert_cos'])

        for idx, item in enumerate(recall_res):
            query, question_id, title, body, answer_id, tag_ids = item
            target = title + body
            bert_cos = self.text_similarity_bert.bert_sim(query, target, sim='cos')

            lightgbm_df.loc[idx] = [query, question_id, title, body, answer_id, tag_ids, bert_cos]
        
        # 精排

        lightgbm_df.sort_values(by=["bert_cos"], inplace=True, ascending=False)

        result = []

        for idx, row in lightgbm_df.iterrows():
            query_ret = {}
            if row['bert_cos'] > 0.9:
                logger.info(f"语义相似度为: {row['bert_cos']}")
                query_text = row['query']
                target_body = row['target_body']
                target_question_id = row['target_question_id']
                target_title = row['target_title']
                tfidf_score = self.get_tfidf_score(str(query_text), str(target_title) + str(target_body))
                logger.info(f"tfidf得分为: {tfidf_score}")
                logger.info(f"[query_text]: {str(query_text)}")
                logger.info(f"[target_body]: {str(target_body)}")

                score = int(row['bert_cos'] * 100)
                url = "https://ask.csdn.net/questions/{}".format(target_question_id)
                recommend_id = uuid.uuid4().hex
                answer_id = row['answer_id']
                tag_ids = row['tag_ids']
                tag_ids = tag_ids.strip()
                tag_id_list = tag_ids.split(',')

                if tag_id_list == ['']:
                    tag_id = None
                else:
                    tag_id = int(tag_id_list[0])

                method = random.choice([0, 1])
                # method = 1 -- 加入tfidf限制
                # method = 0 -- 不加入tfidf限制
                query_ret['method'] = 0
                if tfidf_score>= 0.2 and method == 1:
                    query_ret['method'] = 1
                    logger.info("加入tfidf限制...")
                elif method == 0:
                    query_ret['method'] = 0
                    logger.info("未加入tfidf限制...")

                query_ret['question_id'] = target_question_id
                query_ret['answer_id'] = answer_id
                query_ret['title'] = target_title
                query_ret['tag_id'] = tag_id
                query_ret['score'] = score
                query_ret['url'] = url
                query_ret['recommend_id'] = recommend_id
            result.append(query_ret)
            break
        
        return result

在取得精排的结果后,取分值最大的那条数据,且相似度分数要超过0.9,这个0.9并不是头脑发热设置的,而是通过数据分析得出的结论,限制分数阈值后,还需要计算query与相似度得分最高的那条结果的tfidf相似度,同理,这里也设置了tfidf score阈值,这个阈值,也是通过数据分析得出来的结论,两项限制都满足后,才会给用户推荐,这样做,大大降低了误推率。

其实,如果你的训练数据是对的话,在精排时,除了语义相似度外,你可以再构造一些其他的人工处理好的特征,如编辑距离皮尔逊相关系数KL散度等。

class TextSimilarityML(object):
    def __init__(self) -> None:
        # self.train_w2v = TrainWord2Vec()
        self.tfidf = joblib.load(get_sentence_tfidf_model_path())
        # self.w2v_model = KeyedVectors.load(get_sentence_word2vec_model_path())
        self.sentence_transformer_model = SentenceTransformer(get_sentence_transformers_model_path())

    @classmethod
    def tokenize(self , str_a):
        wordsa = pseg.cut(str_a)
        cuta = ""
        seta = set()
        for key in wordsa:
            cuta += key.word + " "
            seta.add(key.word)
        return [cuta , seta]


    def JaccardSim(self , str_a , str_b):
        seta = self.tokenize(str_a)[1]
        setb = self.tokenize(str_b)[1]
        sa_sb = 1.0 * len(seta & setb) / len(seta | setb)
        return sa_sb

    @staticmethod
    def cos_sim(a ,b):
        a = np.array(a)
        b = np.array(b)
        return np.sum(a * b) / (np.sqrt(np.sum(a**2)) * np.sqrt(np.sum(b**2)))


    @staticmethod
    def kl_divergence(p,q):
        return scipy.stats.entropy(p, q)


    @staticmethod
    def js_divergence(P,Q):
        M=(P+Q)/2
        return 0.5*scipy.stats.entropy(P, M)+0.5*scipy.stats.entropy(Q, M)


    @staticmethod
    def eucl_sim(a ,b):
        a = np.array(a)
        b = np.array(b)
        return 1 / (1 + np.sqrt((np.sum(a - b)**2)))


    @staticmethod
    def pearson_sim(a , b):
        a = np.array(a)
        b = np.array(b)

        a = a - np.average(a)
        b = b - np.average(b)
        return np.sum(a * b) / (np.sqrt(np.sum(a**2)) * np.sqrt(np.sum(b**2)))


    def editDistance(self , str1 , str2):
        m = len(str1)
        n = len(str2) 
        lensum = float(m + n)
        d = [[0] * (n+1) for _ in range(m+1)]
        for i in range(m+1):
            d[i][0] = i
        for j in range(n+1):
            d[0][j] = j
        
        for j in range(1 , n+1):
            for i in range(1 , m+1):
                if str1[i -1] == str2[j -1]:
                    d[i][j] = d[i-1][j-1]
                else:
                    d[i][j] = min(d[i-1][j] , d[i][j-1] , d[i-1][j-1]) + 1
        dist = d[-1][-1]
        ratio = (lensum -dist) / lensum
        return ratio

    def lcs(self, str_a , str_b):
        lengths = [[0 for j in range(len(str_b) + 1 )]
                    for i in range(len(str_a) + 1)]
        for i,x in enumerate(str_a):
            for j,y in enumerate(str_b):
                if x==y:
                    lengths[i+1][j+1] = lengths[i][j] + 1
                else:
                    lengths[i+1][j+1] = max(lengths[i+1][j] , lengths[i][j+1])
        
        result = ""
        x,y = len(str_a) , len(str_b)
        while x !=0 and y !=0:
            if lengths[x][y] == lengths[x - 1][y]:
                x -= 1
            elif lengths[x][y] == lengths[x][y-1]:
                y -= 1
            else:
                assert str_a[x-1] == str_b[y-1]
                result = str_a[x-1] + result
                x -= 1
                y -= 1
        longestdist = lengths[len(str_a)][len(str_b)]
        ratio = longestdist / min(len(str_a) , len(str_b))
        return ratio


    def tokenSimilarity(self , str_a , str_b , method='tfidf' , sim='cos'):
        vec_a , vec_b , model  = None , None , None
        if method == 'tfidf':
            str_a = self.tokenize(str_a)[0]
            str_b = self.tokenize(str_b)[0]
            vec_a = self.tfidf.transform([str_a]).toarray()
            vec_b = self.tfidf.transform([str_b]).toarray()
        elif method == "bert":
            vec_a = self.sentence_transformer_model.encode([str_a])
            vec_b = self.sentence_transformer_model.encode([str_b])
        else:
            NotImplementedError
        result = None

        if (vec_a is not None) and (vec_b is not None):
            if sim == 'cos':
                result = self.cos_sim(vec_a[0], vec_b[0])
            elif sim == 'eucl':
                result = self.eucl_sim(vec_a[0], vec_b[0])
            elif sim == 'pearson':
                result = self.pearson_sim(vec_a[0], vec_b[0])
            elif sim == 'wmd' and model:
                result = model.wmdistance(str_a, str_b)
            elif sim == 'js':
                result = self.js_divergence(vec_a[0], vec_b[0])
            elif sim == 'kl':
                result = self.kl_divergence(vec_a[0], vec_b[0])
        return result
        
    def gen_simility(self, str1, str2):
        return {
            "lcs": self.lcs(str1, str2),
            "edit_dist": self.editDistance(str1, str2),
            "jaccard": self.JaccardSim(str1, str2),
            "tfidf_cos": self.tokenSimilarity(str1, str2, method='tfidf', sim='cos'),
            "tfidf_eucl": self.tokenSimilarity(str1, str2, method='tfidf', sim='eucl'),
            "tfidf_pearson": self.tokenSimilarity(str1, str2, method='tfidf', sim='pearson'),
            "tfidf_kl": self.tokenSimilarity(str1, str2, method='tfidf', sim='kl'),
            "tfidf_js": self.tokenSimilarity(str1, str2, method='tfidf', sim='js'),
            "bert_cos": self.tokenSimilarity(str1, str2, method='bert', sim='cos'),
            "bert_eucl": self.tokenSimilarity(str1, str2, method='bert', sim='eucl'),
            "bert_pearson": self.tokenSimilarity(str1, str2, method='bert', sim='pearson'),
        }

构造好这些人工特征后,可以利用决策树的思想,训练各个特征的权重,所幸,在lightgbm中,就有这么一个方法,可以拿来即用:


import os
import logging
import joblib
import lightgbm as lgb
import numpy as np
from common.path.dataset.answer import get_lightgbm_train_data_path
from common.path.dataset.answer import get_lightgbm_dev_data_path
from common.path.model.sentence_model import get_sentence_lightgbm_ranker_model_path


logger = logging.getLogger(__name__)

class LihtgbmRankerTrain(object):
    def __init__(self) -> None:
        self.train_path = get_lightgbm_train_data_path()
        self.dev_path = get_lightgbm_dev_data_path()
        self.model_path = get_sentence_lightgbm_ranker_model_path()

        self.params = {
            'boosting_type': 'gbdt',
            'max_depth': 5,
            'objective': 'binary',
            # 'nthread': 3,  
            'num_leaves': 64,
            'learning_rate': 0.05,
            'max_bin': 512,
            'subsample_for_bin': 200,
            'subsample': 0.5,
            'subsample_freq': 5,
            'colsample_bytree': 0.8,
            'reg_alpha': 5,
            'reg_lambda': 10,
            'min_split_gain': 0.5,
            'min_child_weight': 1,
            'min_child_samples': 5,
            'scale_pos_weight': 1,
            # 'max_position': 20,
            'group': 'name:groupId',
            'metric': 'auc'
        }
        if not os.path.exists(self.model_path):
            self.model = None
            logger.warning("模型不存在,请先训练...")
        else:
            logger.info(f"加载模型: {self.model_path}")
            self.model = joblib.load(self.model_path)


    def load_data(self):
        train_data = joblib.load(self.train_path)
        dev_data = joblib.load(self.dev_path)
        train_x = []
        train_y = []
        for item in train_data:
            item = list(item)
            x = item[:-1]
            y = item[-1]
            train_x.append(x)
            train_y.append(y)
        dev_x = []
        dev_y = []
        for item in dev_data:
            item = list(item)
            x = item[:-1]
            y = item[-1]
            dev_x.append(x)
            dev_y.append(y)
        
        return train_x, train_y, dev_x, dev_y

    
    def train(self):
        train_x, train_y, dev_x, dev_y = self.load_data()
        train_x = np.array(train_x)
        train_y = np.array(train_y)
        dev_x = np.array(dev_x)
        dev_y = np.array(dev_y)

        query_train = [train_x.shape[0]]
        query_val = [dev_x.shape[0]]

        self.gbm = lgb.LGBMRanker(**self.params)
        self.gbm.fit(train_x , train_y , group=query_train , eval_set=[(dev_x , dev_y)] , eval_group=[query_val] , eval_at=[5 , 10 , 20] , early_stopping_rounds=50)
        joblib.dump(self.gbm, self.model_path)
    

    def predict(self, recall_data):
        result = self.model.predict(recall_data)
        return result

注意: 如果你是对的数据,你可以这样来精排,如果你和我一样,是对的数据,你这样精排的意义就不大。因为最后训练出来的权重,除了语义相似度特征的权重较大,其他特征的权重都接近0。(建议亲自动手试试,实践出真知!)

优化策略

在做完精排后,你以为事情就结束了?

其实远没有,用对的数据集,只能解决一部分问题,要想带来质的提升,一方面是你的问答库要非常全,这个需要长时间积累,另一方面,你需要标注对的数据,但这种数据非常难标注,往往需要专业的IT从业人员标注,才能获取到一个较为准确的结果。
但是,我们CSDN上的用户,都是专业的IT从业人员,在问答的前端页面上,我们可以增加几个按钮,让用户帮我们来标注,这样不但成本低,且标注效果好,所以,我在精排后返回的数据中,增加了一个recommend_id字段,用来标记推荐的结果,用户点击按钮后,会更新这条推荐结果的状态,如下图:基于Sentence-Bert的检索式问答系统_第3张图片

结果

基于Sentence-Bert的检索式问答系统_第4张图片
目标是5%,虽然达到了目标,但离真正地提升用户体验,还有很长一段路要走。

继续加油!

总结

1、作为一名合格的NLPer,不仅要考虑模型本身的效果,更要考虑如何构建高质量的数据集。模型与模型之间的差距并不会特别大,与其花大量时间在模型上,不如花一部分时间在数据上,也许,带来的收益会更大。

2、一个好的NLP项目,往往需要形成一个闭环,模型运行起来后,并不是再也不更新,我们需要持续收集用户反馈,持续跟进,持续分析badcase,持续迭代优化

最后,有对代码感兴趣的同学,可以看我之前写的一篇文章:FAQ式问答系统

你可能感兴趣的:(NLP成长之路,bert,自然语言处理,人工智能)