前言
常见的问答任务有四种:
- 知识图谱问答:基于给定知识图谱生成问题对应的答案
- 表格问答:基于给定表格集合生成问题对应的答案
- 文本问答:基于给定文本生成问题对应的答案
- 社区问答:基于从问答社区网站抓取的问答对进行问答任务
在CSDN 主站,有个问答频道,为了降低用户重复提问率,我们需要根据用户正在提的问题,从问答库中,匹配出最相似的已采纳的问题的答案,推荐给用户。因此,这里我们要做的是社区问答任务。
问答对:问答社区网站上提供的<问题, 答案> 对数据集合。
社区问答,具体来说,就是给定输入问题,社区问答从问答对中检索与输入问题语义最为匹配的已有问题,并采用该已有问题对应的答案作为当前问题的答案。由此可见,社区问答最关键的环节是计算问题和已有问题之间的语义相似度,以及计算问题和答案之间的语义相关度。
基本概念清楚后,进入正题:
环境
lightgbm==3.3.2
hnswlib==0.6.2
sentence_transformers==1.2.0
windows 上应该装不上hnswlib
其他的缺啥装啥
构建数据集
在CSDN ,有大量的无标注数据,但高质量的人工标注数据,少之又少。因此,我们这里也是使用无标注数据。但在构建数据的过程中,我们可以采取一些手段,将误差降到最小。
数据格式:
q_str 为query 文本 doc_str 为target 文本
同一行的数据,为相似数据。即我们可以将同一行的<q_str, doc_str> 对作为正样本,不同行的<q_str, doc_str> 对作为负样本。
接下来,我们需要对这些样本标注。这里我们使用Sentence-Bert 的预训练模型来计算句向量,再计算皮尔逊系数,作为标签。
关于Sentence-Bert 原理,可以直接查看原论文:Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks
关于Sentence-Bert 基本使用,可以查看官网 https://www.sbert.net/index.html
从官网可以看到,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) 之间的线性相关程度,其值介于-1 与1 之间。
在上述代码中,为了便于计算,我将皮尔逊相关系数的值从[-1,1] 之间映射到了[0,1] 之间,值越大,越相关,值越小,越不相关。
值得注意的是,我们这里的训练数据是<query, answer> 对,更为正确的做法是使用<query, query> 对作为训练数据。奈何没有高质量的人工标注数据,只能先用<query, answer> 训练出一版模型看看效果。
训练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需要隐藏某个柱状图的柱体,但是要在悬浮中有显示这个隐藏柱体的数据"
我们来分析下,这两条数据,有部分重叠的关键词,但整体语义,并不相关,语义相似度应该很低才对,但我们的模型,给出的分数竟然有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_str 与 doc_str 之间也是存在部分关键词重叠,但二者语义是相关的。
因此,造成上面测试用例语义得分太高的原因显而易见了。训练时我们使用 <query, answer> 对,预测时我们使用 <query, query> 对,训练与预测不一致,导致即使有部分关键词重叠,但整体语义相差较大,模型输出的得分较大。
那么,既然我们没有<query, query> 对格式的数据,我们做到这里,只能放弃了吗?
不!CSDN AI小组没有放弃!
首先,我们需要确定的是,这个模型,对于语义相关的数据,是有效的!(已经通过实验证实,确实是有效)
既然模型有效,那么,我们只需要过滤掉只有部分关键词重合,但整体语义不相关的数据就可以了。
怎么过滤呢?
答案是:我们再训练一个tfidf 模型,计算query_a 与 query_b 的tfidf 得分,只有部分关键词重合的数据,其关键词得分应该是较低的。
那么,我们计算下之前使用过的两条query 的tfidf 得分:
query_a = "引入Echart后无法引用echart.方法 先下载了Echarts包,然后在head里引入了echarts.js,定义div并赋予了大小"
query_b = "echarts隐藏柱体,但要在悬浮中显示数据 echarts需要隐藏某个柱状图的柱体,但是要在悬浮中有显示这个隐藏柱体的数据"
tfidf_score = 0.1512441662635543
确实是较低!(当然,并不是通过这一条数据得出的结论)
加入tfidf 限制后,query 与query 之间存在重叠关键词但语义不相关的问题得到了解决。
那么,语义匹配的问题,就解决了。接下来需要考虑的是,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 ...")
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 支持三种方式,如下图: 这里我选择了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上机实践,字符类型及其操作
确实可以找到目标答案,从这里也可以看出,使用<query, answer> 对去训练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])
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 阈值,这个阈值,也是通过数据分析得出来的结论,两项限制都满足后,才会给用户推荐,这样做,大大降低了误推率。
其实,如果你的训练数据是<query, query> 对的话,在精排时,除了语义相似度外,你可以再构造一些其他的人工处理好的特征,如编辑距离、皮尔逊相关系数、KL散度等。
class TextSimilarityML(object):
def __init__(self) -> None:
self.tfidf = joblib.load(get_sentence_tfidf_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',
'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,
'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
注意: 如果你是<query, query> 对的数据,你可以这样来精排,如果你和我一样,是<query, answer> 对的数据,你这样精排的意义就不大。因为最后训练出来的权重,除了语义相似度特征的权重较大,其他特征的权重都接近0 。(建议亲自动手试试,实践出真知!)
优化策略
在做完精排后,你以为事情就结束了?
其实远没有,用<query, answer> 对的数据集,只能解决一部分问题,要想带来质的提升,一方面是你的问答库要非常全,这个需要长时间积累,另一方面,你需要标注<query, query> 对的数据,但这种数据非常难标注,往往需要专业的IT 从业人员标注,才能获取到一个较为准确的结果。 但是,我们CSDN 上的用户,都是专业的IT 从业人员,在问答的前端页面上,我们可以增加几个按钮,让用户帮我们来标注,这样不但成本低,且标注效果好,所以,我在精排后返回的数据中,增加了一个recommend_id 字段,用来标记推荐的结果,用户点击按钮后,会更新这条推荐结果的状态,如下图:
结果
目标是5% ,虽然达到了目标,但离真正地提升用户体验,还有很长一段路要走。
继续加油!
总结
1、作为一名合格的NLPer ,不仅要考虑模型本身的效果,更要考虑如何构建高质量的数据集。模型与模型之间的差距并不会特别大,与其花大量时间在模型上,不如花一部分时间在数据上,也许,带来的收益会更大。
2、一个好的NLP项目,往往需要形成一个闭环,模型运行起来后,并不是再也不更新,我们需要持续收集用户反馈,持续跟进,持续分析badcase ,持续迭代优化
最后,有对代码感兴趣的同学,可以看我之前写的一篇文章:FAQ式问答系统
|