啊实习以后因为各种事就好久没写过博客了。最近在工作上处理的都是中文语料,也尝试了一些最近放出来的预训练模型(ERNIE,BERT-CHINESE,WWM-BERT-CHINESE),比对之后还是觉得百度的ERNIE效果会比较好,而且使用十分方便,所以今天就详细地记录一下。希望大家也都能在自己的项目上取得进展~
关于ERNIE模型本身的话这篇不会做过多介绍,网上的介绍文档也很多了,相信从事NLP的同学们肯定都非常熟悉啦。不是很熟悉的同学可以参考我之前的文章: 站在BERT肩膀上的NLP新秀们(PART I)。另外这里就贴几个我看过的关于ERNIE模型的资料:
okay,当我们了解了ERNIE模型的大体框架及原理之后,接下来就可以深入理解一下具体的实现啦。ERNIE是基于百度自己的深度学习框架PaddlePaddle搭建的(百度推这个PP的力度还是蛮大的,记得都有好几次开放免费提供算力),大家平时炼丹用的更多的可能是tensoflow和pytorch,这里关于运行ERNIE的PP环境安装可以参考:安装指南。
模型预训练的输入是基于百科类、资讯类、论坛对话类数据构造具有上下文关系的句子对数据,利用百度内部词法分析工具对句对数据进行字、词、实体等不同粒度的切分,然后基于 tokenization.py 中的 CharTokenizer 对切分后的数据进行 token 化处理,得到明文的 token 序列及切分边界,然后将明文数据根据词典 config/vocab.txt 映射为 id 数据,在训练过程中,根据切分边界对连续的 token 进行随机 mask 操作。经过上述预处理之后的输入样例为:
1 1048 492 1333 1361 1051 326 2508 5 1803 1827 98 164 133 2777 2696 983 121 4 19 9 634 551 844 85 14 2476 1895 33 13 983 121 23 7 1093 24 46 660 12043 2 1263 6 328 33 121 126 398 276 315 5 63 44 35 25 12043 2;0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1;0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55;-1 0 0 0 0 1 0 1 0 0 1 0 0 1 0 1 0 0 0 0 0 0 1 0 1 0 0 1 0 1 0 0 0 0 1 0 0 0 0 -1 0 0 0 1 0 0 1 0 1 0 0 1 0 1 0 -1;0
一共有五个部分组成,每个部分之间用分号;
隔开:
注意这里返回的时候把label
标签放到了第四个字段…
为什么不按输入的顺序来呢…搞得我看了半天还以为代码出错了…
def parse_line(self, line, max_seq_len=512):
line = line.strip().decode().split(";")
assert len(line) == 5, "One sample must have 5 fields!"
(token_ids, sent_ids, pos_ids, seg_labels, label) = line
token_ids = [int(token) for token in token_ids.split(" ")]
sent_ids = [int(token) for token in sent_ids.split(" ")]
pos_ids = [int(token) for token in pos_ids.split(" ")]
seg_labels = [int(seg_label) for seg_label in seg_labels.split(" ")]
assert len(token_ids) == len(sent_ids) == len(pos_ids) == len(
seg_labels
), "[Must be true]len(token_ids) == len(sent_ids) == len(pos_ids) == len(seg_labels)"
label = int(label)
if len(token_ids) > max_seq_len:
return None
return [token_ids, sent_ids, pos_ids, label, seg_labels]
我们知道,相较于BERT,ERNIE最大的改进就是中文 + 短语/实体掩码(这个短语掩码的操作后来也被BERT采用训练出了WWM-BERT),所以我们首先来看看ERNIE的掩码机制是怎么样实现的。
def mask(batch_tokens,
seg_labels,
mask_word_tags,
total_token_num,
vocab_size,
CLS=1,
SEP=2,
MASK=3):
"""
batch_tokens: 一个batch里的句子表示
seg_labels:表示分词边界信息,0表示词首、1表示非词首、-1为占位符
mask_word_tags:a list of True OR False,表示mask_word或者mask_char
"""
max_len = max([len(sent) for sent in batch_tokens])
mask_label = []
mask_pos = []
# 确定mask的概率
prob_mask = np.random.rand(total_token_num)
# 注意:每个句子第一个位置为[CLS]标记符,所以这里low=1
replace_ids = np.random.randint(1, high=vocab_size, size=total_token_num)
pre_sent_len = 0
prob_index = 0
for sent_index, sent in enumerate(batch_tokens):
mask_flag = False
mask_word = mask_word_tags[sent_index]
prob_index += pre_sent_len
if mask_word:
# 根据输入的seg_label进行依概率mask词语
beg = 0
for token_index, token in enumerate(sent):
seg_label = seg_labels[sent_index][token_index]
if seg_label == 1:
continue
if beg == 0:
if seg_label != -1:
beg = token_index
continue
prob = prob_mask[prob_index + beg]
if prob > 0.15:
pass
else:
# 如果该位置概率小于15%,则进行mask
# 并且15%中会按照80%,10%,10%的改进进行MASK,随机替换单词,原本单词不变
for index in xrange(beg, token_index):
prob = prob_mask[prob_index + index]
base_prob = 1.0
if index == beg:
base_prob = 0.15
if base_prob * 0.2 < prob <= base_prob:
mask_label.append(sent[index])
sent[index] = MASK
mask_flag = True
mask_pos.append(sent_index * max_len + index)
elif base_prob * 0.1 < prob <= base_prob * 0.2:
mask_label.append(sent[index])
sent[index] = replace_ids[prob_index + index]
mask_flag = True
mask_pos.append(sent_index * max_len + index)
else:
mask_label.append(sent[index])
mask_pos.append(sent_index * max_len + index)
if seg_label == -1:
beg = 0
else:
beg = token_index
else:
# 对单字char进行mask操作
for token_index, token in enumerate(sent):
prob = prob_mask[prob_index + token_index]
if prob > 0.15:
# 大于15%,不进行mask
continue
elif 0.03 < prob <= 0.15:
# 在 3%和15%之间,用[MASK替换
if token != SEP and token != CLS:
mask_label.append(sent[token_index])
sent[token_index] = MASK
mask_flag = True
mask_pos.append(sent_index * max_len + token_index)
elif 0.015 < prob <= 0.03:
# 在1.5%和3%之间,用随机字替换
if token != SEP and token != CLS:
mask_label.append(sent[token_index])
sent[token_index] = replace_ids[prob_index +
token_index]
mask_flag = True
mask_pos.append(sent_index * max_len + token_index)
else:
# 否则不进行替换,保留原单字
if token != SEP and token != CLS:
mask_label.append(sent[token_index])
mask_pos.append(sent_index * max_len + token_index)
pre_sent_len = len(sent)
mask_label = np.array(mask_label).astype("int64").reshape([-1, 1])
mask_pos = np.array(mask_pos).astype("int64").reshape([-1, 1])
return batch_tokens, mask_label, mask_pos
ERNIE代码很方便使用,但是有一个不足的地方就是目前官方还没有给出infer.py文件,也就是模型训练之后给出快速推理结果的文件。github上简直万人血书求接口呀
所以我们的目的就是需要改写源码,完成这样一个接口:输入为我们需要预测的文件predict.tsv,调用接口后输出为相应任务的结果pred_result。下面我们以分类任务为例,改写一个infer接口
在文件中完成predict函数
def predict(exe, test_program, test_pyreader, graph_vars):
train_fetch_list = [
graph_vars["loss"].name, graph_vars["accuracy"].name,
graph_vars["num_seqs"].name
]
test_pyreader.start()
total_cost, total_acc, total_num_seqs, total_label_pos_num, total_pred_pos_num, total_correct_num = 0.0, 0.0, 0.0, 0.0, 0.0, 0.0
qids, labels, scores, preds = [], [], [], []
time_begin = time.time()
fetch_list = [
graph_vars["loss"].name, graph_vars["accuracy"].name,
graph_vars["probs"].name, graph_vars["labels"].name,
graph_vars["num_seqs"].name, graph_vars["qids"].name
]
while True:
try:
np_loss, np_acc, np_probs, np_labels, np_num_seqs, np_qids = exe.run(
program=test_program, fetch_list=fetch_list)
total_cost += np.sum(np_loss * np_num_seqs)
total_acc += np.sum(np_acc * np_num_seqs)
total_num_seqs += np.sum(np_num_seqs)
labels.extend(np_labels.reshape((-1)).tolist())
if np_qids is None:
np_qids = np.array([])
qids.extend(np_qids.reshape(-1).tolist())
scores.extend(np_probs[:, 1].reshape(-1).tolist())
np_preds = np.argmax(np_probs, axis=1).astype(np.float32)
total_label_pos_num += np.sum(np_labels)
total_pred_pos_num += np.sum(np_preds)
total_correct_num += np.sum(np.dot(np_preds, np_labels))
except fluid.core.EOFException:
test_pyreader.reset()
break
time_end = time.time()
return scores, labels, preds
修改predict_only=True时的逻辑
if args.predict_only:
test_pyreader.decorate_tensor_provider(
reader.data_generator(
args.test_set,
batch_size=args.batch_size,
epoch=1,
shuffle=False))
print("test result:")
res_df = pd.read_csv(args.test_set, sep='\t', encoding='utf-8')
np_probs, np_labels, np_preds = predict(exe, test_prog, test_pyreader, graph_vars)
res_df['prob'] = np_probs
res_df['pred'] = np_preds
res_df.to_excel('result.xlsx')
在该文件中添加一个参数do_predict
run_type_g.add_arg("predict_only", bool, False, "Whether to perform prediction on test data set only.")
上面扯得都是务虚的,接下来我们务实的来看看ERNIE这个预训练模型的具体应用。和BERT相比,ERNIE的使用更加简单,在之前介绍过的BERT模型实战之多文本分类中,我们需要手动改写一个适应自己任务的Processor
,而对于ERNIE来说,简单到只需要三步:
对于最近大火的预训练模型来说,绝大多数我们是不太可能自己从头开始训练的,最多使用的是官方开源的模型进行特定任务的Finetune。所以第一步就是下载模型代码以及相应的参数。
接下去就是准备我们任务的数据,使其符合ERNIE模型输入要求。一般来说字段之间都是label
和text_a
用制表符分割,对于句对任务还需要额外的text_b
字段。在后面我们会具体介绍每种任务的示例输入。
ok,前面我们一直强调ERNIE是超友好上手超快的模型,下面我们结合实际任务来看一看到底有多简单~
情感分类是属于非常典型的NLP基础任务之一,因为之前BERT写过文本分类,所以这里我们就稍微换一换口味~这里我们只考虑最简单情况的情感分类任务,即给定一个输入句子,要求模型给出一个情感标签,可以是只有正负的二分类,也可以是包括中性情感的三分类。ok,我们来看看数据,网上随便找了一个财经新闻数据集,数据来源于雪球网上万得资讯发布的正负面新闻标题,数据集中包含17149条新闻数据,包括日期、公司、代码、正/负面、标题、正文6个字段,其中正面新闻12514条,负面新闻4635条。大概长这样:
处理成ERNIE分类任务所需要的输入,大概长这样:
将处理完成的数据和前面下载好的预训练模型参数放置到合适的位置,就可以开始写我们跑模型的脚本文件了:
set -eux
export FLAGS_sync_nccl_allreduce=1
export CUDA_VISIBLE_DEVICES=0
MODEL_PATH=‘你下载预训练模型参数的路径’
TASK_DATA_PATH=‘你的数据路径’
python -u run_classifier.py \
--use_cuda true \
--verbose true \
--do_train true \
--do_val true \
--do_test true \
--batch_size 24 \
--init_pretraining_params ${MODEL_PATH}/params \
--train_set ${TASK_DATA_PATH}/train.tsv \
--dev_set ${TASK_DATA_PATH}/dev.tsv \
--test_set ${TASK_DATA_PATH}/test.tsv \
--vocab_path config/vocab.txt \
--checkpoints ./checkpoints \
--save_steps 1000 \
--weight_decay 0.01 \
--warmup_proportion 0.0 \
--validation_steps 100 \
--epoch 10 \
--max_seq_len 256 \
--ernie_config_path config/ernie_config.json \
--learning_rate 5e-5 \
--skip_steps 10 \
--num_iteration_per_drop_scope 1 \
--num_labels 2 \
--random_seed 1
嗯,这样一个任务就结束了…运行脚本后等待输出结果即可~
当然如果你还想玩点花样的话,就可以多看看论文。比如复旦之前有一篇文章是在BERT的基础上,将ABSA情感分类的单句分类任务转变成了句子对的相似度匹配任务。简单来说就是通过构建辅助句子,把输入这家餐馆的锅包肉超好吃
变成了这家餐馆的锅包肉超好吃 + 菜品口感的情感是正的
,论文表明这一trick是会比单句分类的效果更好。更具体的细节可以参考原文:
Utilizing BERT for Aspect-Based Sentiment Analysis via Constructing Auxiliary Sentence
命名实体识别也是NLP的一个基础任务,之前在博客中也有过介绍:【论文笔记】命名实体识别论文
关于NER的处理思路也是跟上面情感分类的大同小异,只不过NER是属于序列标注任务,在运行脚本的时候注意使用源码中的run_senquence_labeling.py
set -eux
export FLAGS_sync_nccl_allreduce=1
export CUDA_VISIBLE_DEVICES=0
MODEL_PATH=‘你下载预训练模型参数的路径’
TASK_DATA_PATH=‘你的数据路径’
python -u run_sequence_labeling.py \
--use_cuda true \
--do_train true \
--do_val true \
--do_test true \
--batch_size 16 \
--init_pretraining_params ${MODEL_PATH}/params \
--num_labels 7 \
--label_map_config ${TASK_DATA_PATH}/label_map.json \
--train_set ${TASK_DATA_PATH}/train.tsv \
--dev_set ${TASK_DATA_PATH}/dev.tsv \
--test_set ${TASK_DATA_PATH}/test.tsv \
--vocab_path config/vocab.txt \
--ernie_config_path config/ernie_config.json \
--checkpoints ./checkpoints \
--save_steps 100000 \
--weight_decay 0.01 \
--warmup_proportion 0.0 \
--validation_steps 100 \
--epoch 3 \
--max_seq_len 256 \
--learning_rate 5e-5 \
--skip_steps 10 \
--num_iteration_per_drop_scope 1 \
--random_seed 1
另外这里再安利一个百度的词法句法处理工具:LAC
Gayhub上比源码更有价值的是对应的issue,一个好的开源项目会吸引很多人的关注,issue区里会有很多有趣的思考,所以大家千万不要错过噢~下面就列几个我觉得比较有意思的issue供大家参考。
刚打开ERNIE脚本打算跑的同学可能会发现,它的batch_size竟然是8192,我的天哪(小岳岳脸),这不得炸!于是乎你非常机智地把batch_size改为了32,美滋滋地输入bash script/pretrain.py
,然后自信地敲下Enter键。嗯???报错???
报的什么错大家感兴趣的自己去复现吧~
对,在pretrain的时候这里的batch_size指的是所有输入token的总数,所以才会那么大~
正如我开篇说的,ERNIE的最大创新就是它的mask机制,这一点的代码实现也在issue区被热烈讨论
有时候我们会需要获取句子 Embedding 和 token Embeddings,可参照下面的方案
将一个句子的某个词语mask后,然后使用模型去预测这个词语,得到候选词和词语的概率
Paddle模型的部署可以在官方说明文档中找到。
最后一部分打算说一下关于使用预训练模型的一些小tips:
以上~
2019.07.09