目录
前言
提前剧透:可以用bert无监督做相似度哦,效果还行!!!
文本相识度问题在很多任务都需要,是一个基础任务,相关方法很多,今天来说说基于预训练的方法即bert。如果当前场景没有label,而又想直接“拿来主义”,直接加载公布的pretrain模型来获得vec编码,可能并达不到我们的预期。
这里做了两个实验
一个使用bert的实验结果,这是网上大多数的例子,可以看到“啦啦啦啦啦啦”和“天空为什么是蓝色的”相似度(余弦相似度)依然很高
一个是百度ernie的实验结果,这里极端了一点,可以看到标点和文本依然具有很高的相识度。
所以
(1)在不进行fintune情况下,计算出的相似性值没有多少参考意义,如果想把阈值卡的高点,那卡多少?这就很玄学了,但是其在一定程度上可以反映出相对相似性,就是谁比谁相似
(2)在不进行fintune情况下,上面的ernie实验结果是取的pooled output即cls位置的输出,还有一个token 的编码输出即sequence_output,然后对其做pooling得到当前句子编码也是一个办法,这个其实就是bert-as-service的默认做法(对应上图中第一个实验就是用的bert-as-service),总之两个效果都不是很理想吧,甚至不如word2vec。
所以总结一下,在用这些预训练模型的时候还是要微调,或者我们可以找到在相似公开数据集任务比如相似、问答等数据集上面微调了的,效果会预期好一点,如果有自己的数据集就更好了。
但是最近有一些基于预训练模型的无监督方法,笔者这里列举了一下,从上到下,逐渐sota,这里不讲过多的理论,可以看相关论文和解读博客,本篇主要讲一下代码实现方便应用到自己的数据集。
笔者实现了部分的pytorch版本:
https://github.com/Mryangkaitong/unsupervised_learning/tree/main/Semantic%20similarity
Bert_flow
可是我们就是没有label,还就是想基于预训练模型做文本相识度怎么办呢?来啦来啦,那就是CMU 和字节跳动合作,最新发表在 EMNLP2020 的 《On the Sentence Embeddings from Pre-trained Language Models》
论文 :https://arxiv.org/pdf/2011.05864.pdf
github: https://github.com/bohanli/BERT-flow
(1)原理
(1)用原始bert直接encoder,其实词频率会影响词向量空间分布
(2)低频词分布偏向稀疏
为此论文将bert的编码映射到一个高斯分布即flow,在训练的时候bert部分的参数不变,训练的是flow该部分,如下:
更多原理大家可以拜读论文,下面主要从代码角度剖析,并讲讲怎么应用到我们领域,笔者也做了一个实验,感觉不错。
(2)代码结构解读
代码是基于tf1.0的(有点不友好了),使用的是tf.estimator.Estimator高级API, 该API大体上就是创建模型,然后创建data输入,后面就是自动训练、评估、预测了。
主流程代码在run_siamese.py
711行就是用tf.estimator.Estimator创建网络,其中网络具体定义在702行
719、755、786行就是具体定义训练、验证、测试集的数据输入。
826、845、856行就是具体的训练、验证、测试集run的过程。
我们这里主要看两个吧,一个是模型具体是什么样子,一个是数据读入是什么样子,有利于我们写自己数据的读入函数用起来。
先看模型即702行的model_fn_builder即其定义在287行
大体上就是将数据读入,然后通过317行的create_model得到输出,然后根据343、365、418行是训练、验证还是测试返回相应的结果。那最重要的就是create_model啦
可以看到比较关键的是218行和222行的get_embedding函数,其返回了当前句子对的编码,后面就是计算一些loss返回啦,但!!!!!!下面的255行非常重要,这里就体现了无监督,当我们将FLAGS.num_examples设置为0时,这里通过256行的 tf.zeros_like得到一个同shape的全0的per_example_loss,per_example_loss其实是使用label的有监督的loss(看252行,所以该代码是支持有监督的,即有监督时该部分loss不为0,一起优化更新),那么无监督的loss就剩下268行,也就是论文所说的最大化BERT句子表示的边缘似然函数来学习基于流的生成模型的loss了。记住哦,无监督时需要将num_examples设置为0!!!!!!!!!!!
那看看get_embedding函数吧怎么得到emb
148到185行是原bert的编码,187行后是论文提出的flow。需要注意在取bert编码的时候,有多种方式,比如直接取[CLS]处的作为句子编码,一个是avg即token平均,还有avg-last-2最后两层的token 的平均等等,作者实验室取最后两层好一点,我们最后实验也是用的该种方式。
最关键的flow就是195行和196行啦
最关键的是67行的glow_ops.encoder_decoder即
https://github.com/bohanli/BERT-flow/blob/main/flow/glow_ops_1x1.py#L786
从这里网上看会发现很多函数,都是迭代,都是卷积conv,抽丝剥茧,其实这里就是最最重要的了,大家直接看就好了,很多是推导结果,笔者也没有深究,感兴趣的可以看看到最关键的地方突然不讲了(。。)。
从上面看出来全程我们的句子对是分开各自算各自的emb的,之间没什么交互。下面我们来看看数据输入部分。
比如那train 的来看吧
这里主要逻辑就是736行的file_based_convert_examples_to_features其是将我们数据集制作成tf_record格式供模型读入。file_based_input_fn_builder就是读取tf_record,制作成模型读取API。train_examples我们稍后说。
看看file_based_convert_examples_to_features吧
两个注意点吧,一个是557行这里的逻辑,如果label_list列表大于一,那么label就是分类,即int类型的,否则就是回归的即label使float得即560行。
第二个就是566行的tf_record保存位置是通过output_parent_dir传递进来的,我们训练的时候指定output_parent_dir,它就会保存到这里。
那退回到train_examples,他是怎么来的呢?
即611行通过processor来的,其是598行行来的processors是一个字典,其里面定义了各个数据集的数据读取器,如下
其中579行就是我仿照567行到578行写了一个自己的即OrderProcessor,很简单很简单。
好啦有了上面的大体分析,开始训练我们自己的模型
(3)实践
3.1 下载预模型
因为我们要在中文上面实验,首先下载一个中文bert预训练模型,去bert官方github上面下载就行:
https://storage.googleapis.com/bert_models/2018_11_03/chinese_L-12_H-768_A-12.zip
3.2 定义自己的Processor
在siamese_utils.py参考其他processor定义自己的。笔者这里是:要继承DataProcessor
class OrderProcessor(DataProcessor):
def __init__(self):
self.train_file = "train.xlsx"
self.dev_file = "dev.xlsx"
self.test_file = "test.xlsx"
self.label_column = "label"
self.text_a_column = "text_a"
self.text_b_column = "text_b"
self.contains_header = True
self.test_text_a_column = None
self.test_text_b_column = None
self.test_contains_header = True
def get_labels(self):
return [0.]
def _read_xlsx(self, input_file, set_type):
"""Reads a tab separated value file."""
examples = []
data = pd.read_excel(input_file)
pbar = tqdm(total=len(data))
for index, row in data.iterrows():
pbar.update(1)
text_a = str(row[self.text_a_column])
text_b = str(row[self.text_b_column])
guid = "%s-%s" % (set_type, index)
if set_type=="dev":
label = float(row[self.label_column])
else:
label = float(self.get_labels()[0])
examples.append(
InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
pbar.close()
return examples
def get_train_examples(self, data_dir):
"""See base class."""
return self._read_xlsx(os.path.join(data_dir, self.train_file), "train")
def get_dev_examples(self, data_dir):
"""See base class."""
return self._read_xlsx(os.path.join(data_dir, self.dev_file), "dev")
def get_test_examples(self, data_dir):
"""See base class."""
return self._read_xlsx(os.path.join(data_dir, self.test_file), "test")
其实这里train、dev、test都是同一份数据,train和test都不需要标签,即这里就随便给了一个0, 这里可以根据自己的数据集随便改,到最后返回的数据格式就行
InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
InputExample也是个类,源代码就有,guid就是个id,随便搞搞,text_a和text_b就是个句子对,label就随便赋个值。
注意这里的get_train_examples、get_dev_examples、get_test_examples函数要有,还有一个get_labels,其返回值是一个列表,这里是一个元素,其实这里就是后续的label_list,还记得上述讲的file_based_convert_examples_to_features的吧,当label_list是一个元素时,label是float的,即变成了回归问题,所以我们相似不相似,其实后续是当一个0-1的回归问题处理的。
3.3 主流程中改一点
第一导入我们定义的OrderProcessor,并在processors中加上
其key即task_name 随便取吧,记住就行。
在589行加上我们定义的task_name如order,注意看这里是当成一个回归问题了,其实这里有个小坑,之前笔者是没有在589行这里改动的,即默认我们的任务是当做分类任务的,训练也没有问题,但是在预测的时候出现了问题即
可以看到只有回归问题,才会有预测结果,否则NotImplementedError了,同时这里还有一个主意点就是890行的FLAGS.predict_pool当其为真时我们得到的是emb,当False时我们得到的是相似度,后续我们就设为False吧,当然可以改改这里的代码,根据自己的需求。
3.4定义自己的sh脚本
在scripts定义train_order.sh
#!/bin/bash
CURDIR=$(cd $(dirname $0); cd ..; pwd)
BERT_DIR='/root/BERT-flow/chinese_L-12_H-768_A-12'
data_dir='/root/BERT-flow/order_dataset'
OUTPUT_PARENT_DIR="../exp"
CACHED_DIR=${OUTPUT_PARENT_DIR}/cached_data
export INIT_CKPT=$BERT_DIR/bert_model.ckpt
INIT_CKPT_predict='/root/BERT-flow/exp/exp_t_order_ep_1.00_lr_5.00e-05_bsz_8_e_avg-last-2_f_11_1.00e-03/model.ckpt-705'
EXP_NAME='exp_t_order_ep_1.00_lr_5.00e-05_bsz_8_e_avg-last-2_f_11_1.00e-03'
if [ -z "$TASK_NAME" ]; then
export TASK_NAME="order"
fi
if [[ $1 == "train" ]];then
echo "train"
exec python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_train=true \
--do_eval=true \
--data_dir=${data_dir} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--init_checkpoint=${INIT_CKPT} \
--max_seq_length=128 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
--exp_name_prefix=exp \
-cached_dir=${CACHED_DIR} \
-sentence_embedding_type=avg-last-2 \
--flow=1 --flow_loss=1 \
--num_examples=0 \
--num_train_epochs=1.0 \
--train_batch_size=8 \
--eval_batch_size=8 \
--flow_learning_rate=1e-3 \
${@:2}
elif [[ $1 == "eval" ]];then
echo "eval"
python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_eval=true \
--data_dir=${GLUE_DIR}/${TASK_NAME} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--init_checkpoint=${INIT_CKPT} \
--max_seq_length=64 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
${@:2}
elif [[ $1 == "predict" ]];then
echo "predict"
python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_predict=true \
--data_dir=${GLUE_DIR}/${TASK_NAME} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--init_checkpoint=${INIT_CKPT} \
--max_seq_length=64 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
${@:2}
python3 scripts/eval_stsb.py \
--glue_path=${GLUE_DIR} \
--task_name=${TASK_NAME} \
--pred_path=${OUTPUT_PARENT_DIR}/${EXP_NAME}/test_results.tsv \
--is_test=1
elif [[ $1 == "predict_pool" ]];then
echo "predict_dev"
python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_predict=true \
--data_dir=${GLUE_DIR}/${TASK_NAME} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--max_seq_length=64 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
--predict_pool=True \
${@:2}
elif [[ $1 == "predict_dev" ]];then
echo "predict_dev"
python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_predict=true \
--data_dir=${data_dir} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--init_checkpoint=${INIT_CKPT_predict} \
--max_seq_length=128 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
--do_predict_on_dev=True \
--exp_name=${EXP_NAME} \
--sentence_embedding_type=avg-last-2 \
--flow=1 --flow_loss=1 \
--num_examples=0 \
--num_train_epochs=1.0 \
--flow_learning_rate=1e-3 \
--predict_batch_size=8 \
${@:2}
elif [[ $1 == "predict_full" ]];then
echo "predict_dev"
python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_predict=true \
--data_dir=${GLUE_DIR}/${TASK_NAME} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--max_seq_length=64 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
--do_predict_on_full=True \
--predict_pool=True \
${@:2}
elif [[ $1 == "do_senteval" ]];then
echo "do_senteval"
python3 ${CURDIR}/run_siamese.py \
--task_name=${TASK_NAME} \
--do_senteval=true \
--data_dir=${GLUE_DIR}/${TASK_NAME} \
--vocab_file=${BERT_DIR}/vocab.txt \
--bert_config_file=${BERT_DIR}/bert_config.json \
--init_checkpoint=${INIT_CKPT} \
--max_seq_length=64 \
--output_parent_dir=${OUTPUT_PARENT_DIR} \
${@:2}
else
echo "NotImplementedError"
fi
其实原代码定义了一个基础train_siamese.sh,然后后面根据自己的数据集去调用train_siamese.sh,笔者这里就索性根据train_siamese.sh写一个自己的吧。
BERT_DIR:原始bert目录
data_dir:数据集所在目录
OUTPUT_PARENT_DIR:结果保存的目录模型都保存在该目录下
CACHED_DIR:tf_record保存目录,这里是${OUTPUT_PARENT_DIR}/cached_data,这个一定要提前创建好
INIT_CKPT:就是初始化bert的热启模型,后续我们训练好后预测就把这里改成我们训练好的模型地方,当前开始训练就用下载的中文bert,看这里的
INIT_CKPT_predict就是训练好的模型,当然训练的时候这个参数没有也行,反正也不用
EXP_NAME:这个就是预测的时候,结果(预测结果)保存的目录
TASK_NAME:就是我们的任务,还记得我们上面定义的processor的key吗
下面就是定义train和predict了,这里笔者就改了两块即trian 和 predict_dev
这里比较关键的是34行,这里要设置为0,即无监督,理由的话上述代码解析说过了
注意这里的90,91行用的配置文件还是原始bert的,但是模型是92行用的训练完的
3.5 开始训练
cd scripts
sh train_order.sh train
训练完后,会在定义的OUTPUT_PARENT_DIR目录下看到大概两个文件夹吧一个是cached_data里面就是中间过程保存的tf_record,有order_train.tf_record和order_eval.tf_record,另一个文件夹是形如exp_t_order_ep_1.00_lr_5.00e-05_bsz_8_e_avg-last-2_f_11_1.00e-03就是保存的模型,因为当前我们--do_eval=true所以在里面会看到一个eval_results.txt评价结果:(这里是有标签,才评价的,如果我们是无监督,就不用评价了直接--do_eval=False)
3.6 预测
sh train_order.sh predict_dev
时间有点久吧,结束后在exp_t_order_ep_1.00_lr_5.00e-05_bsz_8_e_avg-last-2_f_11_1.00e-03文件夹下有dev_results.tsv,就是预测结果
这里和原来文本做了一个汇合
import pandas as pd
save_file = 'result.xlsx'
input_file = '/root/BERT-flow/order_dataset/dev.xlsx'
origin_data = pd.read_excel(input_file)
predict_input_file = '/root/BERT-flow/exp/exp_t_order_ep_1.00_lr_5.00e-05_bsz_8_e_avg-last-2_f_11_1.00e-03/dev_results.tsv'
label = pd.read_csv(predict_input_file, sep='\t', header=None)
label_list = label.values.tolist()
label_list = list(map(lambda x:float(x[0]), label_list))
result = []
for index, row in origin_data.iterrows():
result.append([row["text_a"], row["text_b"], label_list[index], row["label"]])
result = pd.DataFrame(result, columns=["text_a", "text_b", "predict", "label"])
result = result.sort_values("predict", ascending=False)
result.to_excel(save_file, index=False)
即
有些预测很高,label 标的是0,看了看label是错的,总的来说还不错,这可是无监督哦
Bert_whitening
苏神idea, 只需一个线性变化
原理: https://kexue.fm/archives/8069
无监督语义相似度哪家强?我们做了个比较全面的评测 - 科学空间|Scientific Spaces
代码:GitHub - bojone/BERT-whitening: 简单的向量白化改善句向量质量
核心的东西就是compute_kernel_bias和transform_and_normalize连个线性变化函数
可以用bert快速试一下大家自己领域的数据集,看看会不会好一点
import numpy as np
import pandas as pd
from bert_serving.client import BertClient
def compute_kernel_bias(vecs):
"""计算kernel和bias
最后的变换:y = (x + bias).dot(kernel)
"""
vecs = np.concatenate(vecs, axis=0)
mu = vecs.mean(axis=0, keepdims=True)
cov = np.cov(vecs.T)
u, s, vh = np.linalg.svd(cov)
W = np.dot(u, np.diag(s**0.5))
W = np.linalg.inv(W.T)
return W, -mu
def transform_and_normalize(vecs, kernel=None, bias=None):
"""应用变换,然后标准化
"""
if not (kernel is None or bias is None):
vecs = (vecs + bias).dot(kernel)
return vecs / (vecs**2).sum(axis=1, keepdims=True)**0.5
bc = BertClient("localhost")
text_a_list = "障碍现象:【电话不通】;用户来电报线路拨打山东方向大部分号码包括手机号码不通,客保获取不到信息,用户要求报障,请网管查看。"
data = pd.read_excel(r"C:\Users\15009\Desktop\数据标注\test.xlsx")
text_b_list = []
for index, row in data.iterrows():
text_b_list.append(row["text"])
#预训练模型的句向量
text_a_vec = bc.encode([text_a_list])
text_b_vec = bc.encode(text_b_list)
#总体统计
kernel, bias = compute_kernel_bias([
text_b_vec, text_a_vec
])
#同向化后的
text_a_vec = transform_and_normalize(text_a_vec, kernel, bias)
text_b_vec = transform_and_normalize(text_b_vec, kernel, bias)
text_a_vec = np.repeat(text_a_vec, text_b_vec.shape[0],axis=0)
#分数
score = (cur_a_vec * cur_b_vec).sum(axis=1)
data["predcit"] = score
data.to_excel(r'C:\Users\Hou\Desktop\result.xlsx')
Simcse
核心idea:两次输入,改变一下dropout,来预训练,即同一条样本经过两次网络,将其视为正样本(dropout不同带来噪声),同一个batch内和其他样本构成负样本。
这里说一下实现也是很简单就是:将同一句话放两次,同时送入模型到达前后两次dropout不同的效果。
原作者开源:https://github.com/princeton-nlp/SimCSE
原作者写的代码考虑的比较全,比较难看懂,可以看一下苏神的简单版本:
https://github.com/bojone/SimCSE
中文任务还是SOTA吗?我们给SimCSE补充了一些实验 - 科学空间|Scientific Spaces
核心代码如下
首先我们来理解一下,假设我们的batch_size是2[sent_1,sent_2]。
那么109行的数据生成器其实生成是[sent_1,sent_1,sent_2,sent_2]即每句话都重复了两次。默认情况下,同一个batch内,不同样本的dropout是不一样的,相当于:
x * np.random.binomial(1, p=1-p, size=x.shape) / (1 - p)[问的苏神]
注意这里的label其实不用,为了适应框架就随便初始化一个,label的构建在simcse_loss里面
再来看看125行的simcse_loss,笔者这里借助numpy模拟了一下129-132
可以看到最后构建的y_true其实是一个4*4即[batch*2,batch*2]的矩阵,对于第一条样本而言,其和第二条样本是正样本,所以红框是true,其他即一个batch内为负样本false,同理,第二条样本和第一条样本是正样本,所以绿框为true,其他为false。其实第一条样本和第二条样本是同一个sent,通过一起送入达到“取一条样本不同dropout的目的”
136行也得到一个[batch*2,batch*2]的矩阵,其代表的含义是每一个样本和同一个batch内的其他样本的预测值。
137行这个减法很有意思,其实在经过l2_normalize后,136行得到的预测矩阵对角线都是1,因为本身和本身是1。
最后经过一个多分类crossentropy就完成了
ESimCSE
simsce的加强版:主要创新就是Word Repetition(单词重复)和Momentum Contrast(动量对比)解决了simsce的一些缺点。
ESimCSE:无监督语义新SOTA,引入动量对比学习扩展负样本,效果远超SimCSE
TSDAE
用seq2seq的方式无监督训练,预测时只用encoder
https://github.com/UKPLab/sentence-transformers/tree/master/examples/unsupervised_learning/tsdae