`Al-bert利用自己训练数据集预训练以及测试LCQMC语义相似度测试## 标题(一)
数据预处理解析
本次主要是针对al-bert与训练过程进行解析,希望对大家有所帮助,预训练过程主要分1、数据构造过程;2、与训练过程;首先简述数据构造过程:(1)首先针对文档的每一行数据进行中文全词处理,针对同一文档的句子进行拼接以概率90%为最大长度target_seq_length左右;(2)然后形成token_a和tokens_b进行拼接形成tokens,再次过程形成50%概率token_a和tokens_b顺序颠倒作为bert训练第二个任务的数据;(3)然后按照论文中的替代词语mask的方法构造训练数据集,体现预测全词的代码是只要是连续几个位置是一个jieba词语,这几个位置就作为是否作为预测同一结果,具体过程如下解析
1.1 create_pretraining_data.py 解析
首先,albert还是利用了bert的训练方法,所以数据处理非常相似,除了利用全词模式的更改;本文章只针对代码进行解析;代码在github搜索就能得到
作者提供了一个样例数据,这样的话,其实是非常让用户方便的进行自己数据进行预训练自己数据的。本次测试是利用了代码中的有关蚂蚁金服的数据测试了albert利用无监督预训练的过程。本次利用debug模式的顺序讲解
提供测试数据news_zh_1.txt结构如下:
由于相关子课题研究还没有结束,课题组非行政机构只承担建议义务。
.........
张健坦言,能否在高考语文中出现一个新的形式——政论或申论形式的传统文化论述题,这一方向应该是研究和创新的改革方向之一。
悬灸技术培训专家教你艾灸降血糖,为爸妈收好了!
.........
近年来随着我国经济条件的改善和人们生活水平的提高,我国糖尿病的患病率也在逐年上升。
可以看出数据每一行代表一条训练数据,空格行代表分割一段内容。具体的数据处理操作就知道数据为什么这样操作;
flags.DEFINE_string("input_file", "./data/news_zh_1.txt","训练数据输入路径.")
flags.DEFINE_string(
"output_file", "./data/tf_news_2016_zh_raw_news2016zh_1.tfrecord",
"数据输出路径,由于训练可能数据较大,所以采用tfrecords格式")
flags.DEFINE_string("vocab_file", "./albert_config/vocab.txt", "对应的模型词典")
flags.DEFINE_bool("do_lower_case", True, "对应英文是否要去除大写")
flags.DEFINE_bool( "do_whole_word_mask", True,"是否使用全词mask的方法,这个是中文的关键点")
flags.DEFINE_integer("max_seq_length", 512, "最大句子长度")
flags.DEFINE_integer("max_predictions_per_seq", 51, "最大预测词语的长度")
flags.DEFINE_integer("random_seed", 12345, "针对随机mask的概率采用随机种子")
flags.DEFINE_integer("dupe_factor", 10, "训练数据集复制的次数,意思是数据集在构造mask数据集时,每一mask后,再次mask一次 (由于每一次数据mask是随机的,所以dupe_factor每一次得到的mask结果不同).")
flags.DEFINE_float("masked_lm_prob", 0.10, "Masked LM probability.")
flags.DEFINE_float("short_seq_prob", 0.1,"Probability of creating sequences which are shorter than the ""maximum length.")
flags.DEFINE_bool("non_chinese", False, "是针对英语还是中文,默认false是中文")
以上是对参数进行基本解释;接下来运行在def main(_):函数:
def main(_):
tf.logging.set_verbosity(tf.logging.INFO)
tokenizer = tokenization.FullTokenizer(
vocab_file=FLAGS.vocab_file, do_lower_case=FLAGS.do_lower_case)
这里的分词模块仍然是英文的分词,仍然是字符级别的分割;main的代码整个核心函数是create_training_instances()函数。
instances = create_training_instances(
input_files, tokenizer, FLAGS.max_seq_length, FLAGS.dupe_factor,
FLAGS.short_seq_prob, FLAGS.masked_lm_prob, FLAGS.max_predictions_per_seq,
rng)
其中参数解释:
input_files:原始训练数据;
tokenizer:分词工具;其他的已经在上面进行解释
def create_training_instances(input_files, tokenizer, max_seq_length,
dupe_factor, short_seq_prob, masked_lm_prob,
max_predictions_per_seq, rng):
all_documents = [[]] #所有处理后的数据集
以下代码是对文件中的每一行数据放入到all_documents中,做了一些空格以及格式转换处理,把每一行数据形成字符列表例如[“今”,“天”,“天”,“气”,、、、],并且对数据进行打乱顺序:
for input_file in input_files:
with open(input_file, "r",encoding='utf-8') as reader:
lines=reader.readlines()
for strings in tqdm(lines):
strings = strings.strip().replace(" ", " ").replace(" ", " ") # 如果有两个或三个空格,替换为一个空格
line = tokenization.convert_to_unicode(strings)
if not line:
continue
line = line.strip()
# Empty lines are used as document delimiters
if not line:
all_documents.append([])
tokens = tokenizer.tokenize(line)
if tokens:
all_documents[-1].append(tokens)
# Remove empty documents
all_documents = [x for x in all_documents if x]
rng.shuffle(all_documents)#针对数据进行打乱
获取需要训练的原始字符列表
vocab_words = list(tokenizer.vocab.keys())
以下操作是针对每一行数据进行mask具体操作。
for _ in range(dupe_factor):
for document_index in range(len(all_documents)):
instances.extend(
create_instances_from_document_albert(
# change to albert style for sentence order prediction(SOP), 2019-08-28, brightmart
all_documents, document_index, max_seq_length, short_seq_prob,
masked_lm_prob, max_predictions_per_seq, vocab_words, rng))
第一个 for循环就是针对所有数据重复dupe_factor次,每一次同一条数据mask的位置不同;第二个for循环是多个文件的情况的操作,本例只有一个数据;所以只进行了一次循环。那么重点就是create_instances_from_document_albert()函数他就是mask的具体操作。具体代码如下:
def create_instances_from_document_albert(
all_documents, document_index, max_seq_length, short_seq_prob,
masked_lm_prob, max_predictions_per_seq, vocab_words, rng):
"""Creates `TrainingInstance`s for a single document.
This method is changed to create sentence-order prediction (SOP) followed by idea from paper of ALBERT, 2019-08-28, brightmart
"""
document = all_documents[document_index] # 得到一个文档
max_num_tokens = max_seq_length - 3 #这个-3就是为了增加 [CLS], [SEP], [SEP]这三个字符,[CLS]是每一个样本的类别字符,[SEP]如果输入是句子对的话就利用这个进行分割
有一定的比例,如10%的概率,使用比较短的序列长度,以缓解预训练的长序列和调优阶段(可能的)短序列的不一致情况,所以增加了这样的概率,代码如下:
其实就是为了使数据具有一定分布包含所有句子长度的可能,而不是没一个训练数据的长度都是max_num_tokens的长度,这里长度为509。
#以下代码就是利用了利用0.15的概率随机取出2到max_num_tokens的长度。
if rng.random() < short_seq_prob:
target_seq_length = rng.randint(2, max_num_tokens)
设法使用实际的句子,而不是任意的截断句子,从而更好的构造句子连贯性预测的任务
instances = [] #处理好的句子训练数据
current_chunk = [] # 当前处理的文本段,包含多个句子
current_length = 0
for i in tqdm(range(len(document))): # 从文档的第一个位置开始,按个往下看
segment = document[i] # 对于每一个句子,segment是列表,代表的是按字分开的一个完整句子,如 segment=['我', '是', '一', '爷', '们', ',',
#'我', '想', '我', '会', '给', '我', '媳', '妇', '最', '好', '的',
# '幸', '福', '。']
接下来就是中文需要特殊处理的部分代码,如果是中文就需要进行全部mask,具体使用函数如下,使用 get_new_segment(segment)进行全词mask:
if FLAGS.non_chinese == False: # if non chinese is False, that means it is chinese, then do something to make chinese whole word mask works.
segment = get_new_segment(segment) # whole word mask for chinese: 结合分词的中文的whole mask设置即在需要的地方加上“##”
get_new_segment()的具体实现如下,需要对以下函数进行详细说明,使用了jieba分词进行语义分割;分割之后得到seq_cws_dict字典,字典格式
seq_cws_dict= {‘城市化’: 1, ‘最后’: 1, ‘老城’: 1, ‘何处’: 1, ‘时代’: 1, ‘文化’: 1, ‘该往’: 1, ‘自觉’: 1, ‘南京’: 1, ‘的’: 1, ‘呼唤’: 1, ‘去’: 1}
def get_new_segment(segment): # 新增的方法 ####
"""
输入一句话,返回一句经过处理的话: 为了支持中文全称mask,将被分开的词,将上特
殊标记("#"),使得后续处理模块,能够知道哪些字是属于同一个词。
:param segment: 一句话. e.g. ['悬', '灸', '技', '术', '培', '训', '专', '家', '教', '你', '艾', '灸', '降', '血', '糖', ',',
'为', '爸', '妈', '收', '好', '了', '!']
:return: 一句处理过的话 e.g. ['悬', '##灸', '技', '术', '培', '训', '专', '##家', '教', '你', '艾', '##灸',
'降', '##血', '##糖', ',', '为', '爸', '##妈', '收', '##好', '了', '!']
"""
seq_cws = jieba.lcut("".join(segment)) # 分词
seq_cws_dict = {x: 1 for x in seq_cws} # 分词后的词加入到词典dict
new_segment = [] #处理中文分词后的结果。
剩余的代码就是针对判断jieba分词的词语位置,形成如果是一个词语,就进行模型能够训练的全词mask形式,例如: ‘降’, ‘##血’, ‘##糖’, 但是貌似该算法,仍然是每一个字符进行预测的,并且处理不能超过长度为3的词语否则做为单个字符处理,训练代码会进行解析。接下来就是while循环,首先判断是否是中文字符,如果不是中文字符不做任何处理,直接添加,
while i < len(segment): # 从句子的第一个字开始处理,知道处理完整个句子
if len(re.findall('[\u4E00-\u9FA5]', segment[i])) == 0: # 如果找不到中文的,原文加进去即不用特殊处理。
new_segment.append(segment[i])
i += 1
continue
具体添加i从句子开始0位置开始,最大词语长度限定3,首先判断词语位置不能超过segment长度,然后判断i:i+length 是否在seq_cws_dict字典中,如果在第一个词语字符添加到new_segment中,剩余字符添加增加##这样的格式,用来区分词语开始和中间结果。然后i变成 i += length从这个位置在开始添加,has_add标志为是否还有词语添加,然后再次while循环,进行同样的的判断,依次重复,并且把has_add重新设置为False,具体结合如下代码:
has_add = False
for length in range(3, 0, -1):
if i + length > len(segment):
continue
if ''.join(segment[i:i + length]) in seq_cws_dict:
new_segment.append(segment[i])
for l in range(1, length):
new_segment.append('##' + segment[i + l])
i += length
has_add = True
break
这样 segment = get_new_segment(segment) # whole word mask for chinese: 结合分词的中文的whole mask设置即在需要的地方加上“##”就基本完成了,也算是数据处理的核心代码。接下来两行代码就是把处理好的句子添加到current_chunk列表中,并且 current_length += len(segment) # 累计到为止位置接触到句子的总长度,这样,坐着的意思就是,没一个文档是保持同一个话题的,否则会有部分current_chunk是损坏的数据。然后是判断是否达到预设最大句子长度:
if i == len(document) - 1 or current_length >= target_seq_length:
# 如果累计的序列长度达到了目标的长度,或当前走到了文档结尾==>构造并添加到“A[SEP]B“中的A和B中;这里解释比较清楚了,
如果没有达到一个训练语句的长度,则i+=1,取出下一个句子进行
segment = get_new_segment(segment) # whole word mask for chinese: 结合分词的中文的whole mask设置即在需要的地方加上“##”
current_chunk.append(segment) # 将一个独立的句子加入到当前的文本块中
current_length += len(segment) # 累计到为止位置接触到句子的总长度
这样的操作直到达到进行A[SEP]B的操作代码,达到后,首先随机选出A句子的结束位置a_end位置,然后把A的代码extend到token_a中,然后把剩余的句子列表extend到token_b中。然后再利用50%的概率,tokens_a和tokens_b进行交换位置主要原因在于构造第二个任务is_random_next就是实际标示,如果为true就是tokens_a和tokens_b进行交换为负样本。具体代码如下:
a_end = 1
if len(current_chunk) >= 2: # 当前块,如果包含超过两个句子,取当前块的一部分作为“A[SEP]B“中的A部分
a_end = rng.randint(1, len(current_chunk) - 1)
# 将当前文本段中选取出来的前半部分,赋值给A即tokens_a
tokens_a = []
for j in range(a_end):
tokens_a.extend(current_chunk[j])
# 构造“A[SEP]B“中的B部分(有一部分是正常的当前文档中的后半部;在原BERT的实现中一部分是随机的从另一个文档中选取的,)
tokens_b = []
for j in range(a_end, len(current_chunk)):
tokens_b.extend(current_chunk[j])
# 有百分之50%的概率交换一下tokens_a和tokens_b的位置
# print("tokens_a length1:",len(tokens_a))
# print("tokens_b length1:",len(tokens_b)) # len(tokens_b) = 0
if len(tokens_a) == 0 or len(tokens_b) == 0: i += 1; continue
if rng.random() < 0.5: # 交换一下tokens_a和tokens_b
is_random_next = True
temp = tokens_a
tokens_a = tokens_b
tokens_b = temp
else:
is_random_next = False
接下来对本次训练数据进行截断,保证最大长度在 max_num_tokens 范围内, truncate_seq_pair(tokens_a, tokens_b, max_num_tokens, rng),由于 current_chunk.append(segment) # 将一个独立的句子加入到当前的文本块中
是增加一个句子,并且判断是 if i == len(document) - 1 or current_length >= target_seq_length: 大于等于,所以大部分的len(tokens_a)+len(tokens_b)会大于最大长度。所以要进行截断。
把tokens_a & tokens_b加入到按照bert的风格,即以[CLS]tokens_a[SEP]tokens_b[SEP]的形式,结合到一起,作为最终的tokens;
也带上segment_ids,前面部分segment_ids的值是0,后面部分的值是1.代码如下:
tokens = []
segment_ids = []
tokens.append("[CLS]")
segment_ids.append(0)
for token in tokens_a:
tokens.append(token)
segment_ids.append(0)
tokens.append("[SEP]")
segment_ids.append(0)
for token in tokens_b:
tokens.append(token)
segment_ids.append(1)
tokens.append("[SEP]")
segment_ids.append(1)
接下来就是 创建masked LM的任务的数据 Creates the predictions for the masked LM objective,这个是创建数据的另一个重点,
(tokens, masked_lm_positions,
masked_lm_labels) = create_masked_lm_predictions(
tokens, masked_lm_prob, max_predictions_per_seq, vocab_words, rng),具体代码如下:
def create_masked_lm_predictions(tokens, masked_lm_prob,
max_predictions_per_seq, vocab_words, rng):
"""Creates the predictions for the masked LM objective."""
我们先看一下坐着的解释,其实也很清楚了
# Whole Word Masking means that if we mask all of the wordpieces
# corresponding to an original word. When a word has been split into
# WordPieces, the first token does not have any marker and any subsequence
# tokens are prefixed with ##. So whenever we see the ## token, we
# append it to the previous set of word indexes.
#
# Note that Whole Word Masking does not change the training code
# at all – we still predict each WordPiece independently, softmaxed
# over the entire vocabulary.
全词mask意味着如果词片断(也就是字符)对应一个原始ci(在这里表示是jieba分词结果),当一个词语被分割词语,第一个字符不加标记,后边的子序列加前缀##在前面已经讲过了。所以当遇到##,就把当前字符的下标添加到前一个字符的列表里,这里的index并不是真个词汇列表的index而是在当前句子中的位置。后一句话是全词mask并没有改变训练代码,仍然是每一个字符预测,这说的是整个词汇列表的softmax预测整个vocabulary。
cand_indexes = [] #用来记录每一个词语的分割index,上面已经解释清楚,
#如果是新词就添加本身在句子中的下标,如果是词语的子序列,则把当前字符的下标
#添加到前一个字符的列表里
for (i, token) in enumerate(tokens):
if token == "[CLS]" or token == "[SEP]":
continue
if (FLAGS.do_whole_word_mask and len(cand_indexes) >= 1 and
token.startswith("##")):
cand_indexes[-1].append(i)
else:
cand_indexes.append([i])
rng.shuffle(cand_indexes)然后把真个训练数据集打乱,数据打乱的原因就是为了下边for循环依次把他们作为预测labels的原因。但句子index其实还是代表了词语在句子中的顺序。以下代码确定预测的字符个数。
num_to_predict = min(max_predictions_per_seq,
max(1, int(round(len(tokens) * masked_lm_prob))))
#output_tokes是为了获得mask之后结果,也就是整个模型的输入,下面的代码是去掉##
if FLAGS.non_chinese == False: # if non chinese is False, that means it is chinese, then try to remove "##" which is added previously
output_tokens = [t[2:] if len(re.findall('##[\u4E00-\u9FA5]', t)) > 0 else t for t in tokens] # 去掉"##"
之后的代码核心就是,80%的概率采用“mASK”字符替代原始字符,10%概率替代为与原始字符相同,10%概率在vocab_words(21128个数)随机寻找一个字符替代。那么构造训练数据主要包含了构造结果的两个内容:
masked_lms = [] #形成命名元祖,内容包含对应句子index以及实际字符label
covered_indexes = set() #句子哪些index形成label
具体代码如下:
masked_lms = []
covered_indexes = set() #句子中哪些字符需要在训练中预测,对应的下标index
for index_set in cand_indexes:#在打乱的下标中构造num_to_predict个label.
if len(masked_lms) >= num_to_predict:/#如果大于限定预测个数则终止
break
# If adding a whole-word mask would exceed the maximum number of
# predictions, then just skip this candidate.
if len(masked_lms) + len(index_set) > num_to_predict:#与上面判断一样的作用
continue
is_any_index_covered = False
for index in index_set:
if index in covered_indexes:
is_any_index_covered = True
break
if is_any_index_covered:
continue
for index in index_set:
covered_indexes.add(index) #构造训练label的index添加到covered_indexes列表中
masked_token = None
# 80% of the time, replace with [MASK]
if rng.random() < 0.8: #以80%的概率用mask替代
masked_token = "[MASK]"
else:
# 10% of the time, keep original
if rng.random() < 0.5: #10%的概率还是原来的字符
if FLAGS.non_chinese == False: # if non chinese is False, that means it is chinese, then try to remove "##" which is added previously
masked_token = tokens[index][2:] if len(re.findall('##[\u4E00-\u9FA5]', tokens[index])) > 0 else \
tokens[index] # 去掉"##"
else:
masked_token = tokens[index]
# 10% of the time, replace with random word
else:
masked_token = vocab_words[rng.randint(0, len(vocab_words) - 1)]
output_tokens[index] = masked_token #用mask替代原来的字符
masked_lms.append(MaskedLmInstance(index=index, label=tokens[index])) #增加label到命名三元组中。
剩余的代码就比较简单了,就是利用最后得到的label分别保存到 masked_lm_positions = [] #这个是保存label在句子中的位置信息
masked_lm_labels = [] #实际label的字符信息
具体代码如下:
masked_lm_positions = []
masked_lm_labels = []
for p in masked_lms:
masked_lm_positions.append(p.index)
masked_lm_labels.append(p.label)
return (output_tokens, masked_lm_positions, masked_lm_labels)
最后返回了:
output_tokens, 训练数据集的输入,用mask替代的句子
masked_lm_positions, 位置信息
masked_lm_labels , 实际mask的字符信息;
就知道以上就是构造bert训练方式之一Masked LM的过程,也就是上面这个代码的作用:
# 创建masked LM的任务的数据 Creates the predictions for the masked LM objective
(tokens, masked_lm_positions,
masked_lm_labels) = create_masked_lm_predictions(
tokens, masked_lm_prob, max_predictions_per_seq, vocab_words, rng)。
然后在利用tokens(用mask替代之后的训练模型输入)、segment_ids(区分A和B句子的0,1信息)、masked_lm_positions(训练数据label在句子中的位置信息)、masked_lm_labels(训练数据集label字符信息)构造TrainingInstance实例,代码如下:
instance = TrainingInstance( # 创建训练实例的对象
tokens=tokens,
segment_ids=segment_ids,
is_random_next=is_random_next,
masked_lm_positions=masked_lm_positions,
masked_lm_labels=masked_lm_labels)
然后instances.append(instance)形成最后的训练数据,函数跳出返回上一级:
#打乱训练数据并且返回
rng.shuffle(instances)
return instances
接下来的工作1、构造Next sentence预测数据;2、把构造的训练数据写入tfrecord中。工作在:
def write_instance_to_example_files(instances, tokenizer, max_seq_length,
max_predictions_per_seq, output_files):
writers = []
for output_file in output_files:
writers.append(tf.python_io.TFRecordWriter(output_file))
writer_index = 0
total_written = 0
以上代码就是打开一个tf文件,由于并非所有的数据集都是长度max_seq_length=512,所以要对不够长度的数据集进行0扩充,并且原始字符利用vocablary中字典对应的下标替换 tokenizer.convert_tokens_to_ids(instance.tokens)成id,并且用input_mask记录句子的实际长度:
for (inst_index, instance) in enumerate(instances):
input_ids = tokenizer.convert_tokens_to_ids(instance.tokens)
input_mask = [1] * len(input_ids) #作用就是记录训练数据的实际长度,
segment_ids = list(instance.segment_ids)
assert len(input_ids) <= max_seq_length
while len(input_ids) < max_seq_length:#如果句子小于512,则用0扩充
input_ids.append(0)
input_mask.append(0)
segment_ids.append(0)
然后在对label进行处理,获取label在句子中的index,并且把label也转化为vocablary词典对应的词id,masked_lm_weights记录实际需要预测的label长度,用1.0记录。同时不够max_predictions_per_seq长度的进行用0扩充。
masked_lm_positions = list(instance.masked_lm_positions)
masked_lm_ids = tokenizer.convert_tokens_to_ids(instance.masked_lm_labels)
masked_lm_weights = [1.0] * len(masked_lm_ids)
while len(masked_lm_positions) < max_predictions_per_seq:
masked_lm_positions.append(0)
masked_lm_ids.append(0)
masked_lm_weights.append(0.0)
next_sentence_label = 1 if instance.is_random_next else 0 这句话就是判断B是否是A句子的下一个句子的label ,如果是random则不是下一个句子,在这里用1表示,有点费解,但是并不影响预测结果,至此基本构造数据讲解完了。下一张讲解训练过程https://editor.youkuaiyun.com/md?articleId=103567243