无监督文本相识度

目录

前言

Bert_flow

(1)原理

(2)代码结构解读

(3)实践

3.1 下载预模型

3.2 定义自己的Processor

3.3 主流程中改一点

3.4定义自己的sh脚本

3.5 开始训练

3.6 预测

Bert_whitening

 Simcse

ESimCSE

TSDAE


​​​​​​​

前言

提前剧透:可以用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

评论 29
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值