机器阅读理解——样本数据处理与Baseline模型搭建训练
前言样本数据处理数据测试模型部分模型构建模型训练部分推理结果总结前言
最近看到今年早些时候百度的“语言与智能技术竞赛”比赛,里面有五个赛道,三个赛道与信息抽取有关,分别是机器阅读理解、关系抽取、事件抽取。最近正好对信息抽取任务比较感兴趣,所以拿来复现一下baseline模型,同时参考参考大佬们的想法,学习下思想和技巧。
参考比赛:Tweet Sentiment Extraction、语言与智能技术竞赛-机器阅读理解,这个两个赛题都涉及到了信息抽取
Tweet Sentiment Extraction:通过给定的tweet,以及其情感倾向,从该条tweet中抽取可以代表其情感倾向的词句。
语言与智能技术竞赛-机器阅读理解:给定背景内容context,根据所给问题,从context中抽取对应的答案。
其实Tweet Sentiment Extraction也可以看作阅读理解问题,只是把tweet和emotion作为context,而问题固定(抽取情感语句)。
这篇是信息抽取系列的第一篇:即机器阅读理解,比起其他两个信息抽取任务来说,可以算是小打小闹了,baseline模型也比较简单,通过两个softmax,找到答案的start和end即可。
但在数据集构建时真的不知道出了多少bug,最后自己总结一个模版,可以用在中文和英文并且对标注的数据有很大的噪声容忍度的SQuAD任务的数据集生成方法。
样本数据处理
数据样例:数据为json格式,每条样本包含context、question、id、answers:text、answer_start(答案的起始位置)。
注意这个answer_start很重要,后面有妙用,如果当文中出现重复的答案片段时,要保证你的y值和answer_start保持一致,而且answer_start可以用来处理过长(>512)的context,把每一个样本数据都利用起来。
'''{"data": [{"title": "", "paragraphs": [{"context": "第35集雪见缓缓张开眼睛,景天又惊又喜之际,长卿和紫萱的仙船驶至,见众人无恙,也十分高兴。\众人登船,用尽合力把自身的真气和水分输给她。\雪见终于醒过来了,但却一脸木然,全无反应。众人向常胤求助,却发现人世界竟没有雪见的身世纪录。\长卿询问清微的身世,清微语带双关说一切上了天界便有答案。长卿驾驶仙船,众人决定立马动身,往天界而去。\众人来到一荒山,长卿指出,魔界和天界相连。由魔界进入通过神魔之井,便可登天。众人至魔界入口,仿若一黑色的蝙蝠洞,但始终无法进入。\后来花楹发现只要有翅膀便能飞入。于是景天等人打下许多乌鸦,模仿重楼的翅膀,制作数对翅膀状巨物。刚佩戴在身,便被吸入洞口。众人摔落在地,抬头发现魔界守卫。\景天和众魔套交情,自称和魔尊重楼相熟,众魔不理,打了起来。", "qas": [{"question": "仙剑奇侠传3第几集上天界", "id": "0a25cb4bc1ab6f474c699884e04601e4", "answers": [{"text": "第35集", "answer_start": 0}]}]},'''
数据提取,这里就不多介绍怎么把样本以结构的形式取出,我的方法比较笨。
def data_load(path):with open(path) as json_file:data = json.load(json_file)context = [x['context'] for x in data['data'][0]['paragraphs']]question = [x['qas'][0]['question'] for x in data['data'][0]['paragraphs']]answers_text = [x['qas'][0]['answers'][0]['text'] for x in data['data'][0]['paragraphs']]answer_start = [x['qas'][0]['answers'][0]['answer_start'] for x in data['data'][0]['paragraphs']]return context,question,answers_text,answer_start
训练样本处理:前方高能
主要思路:找到answer与context重合的区域,对context进行编码,再反解码后,如果反解码出来的文字在原生context中的位置处于答案重合区域,则该文字的token视为答案的一部分,找到连续答案token取第一个为start_index,最后一个为end_index。
def train_data_proceed(tokenizer,context,question,answers_text,answer_start,MAX_LEN==512):ct = len(context)input_ids = np.zeros((ct,MAX_LEN),dtype='int32')attention_mask = np.zeros((ct,MAX_LEN),dtype='int32')start_tokens = np.zeros((ct,MAX_LEN),dtype='int32')end_tokens = np.zeros((ct,MAX_LEN),dtype='int32')'''这里我们不再用transfomers自带的tokenizer直接编码文本而是自己定义input_ids和attention_mask,是为了方便计算start_tokens和end_tokens'''for k in range(ct):context_k = context[k]question_k = question[k]answers_text_k = answers_text[k]answer_start_k = answer_start[k]'''坑1:在处理context的时候发现tokenizer对文本中的' '空格是不编码的,因此会导致反解码时,会省略空格,使得context 与 context_encode_decode顺序错乱。因此这里repalce掉答案出现之间的空格,相应的answer_start也向前移动x,x为取出空格的数量。'''answer_start_k = answer_start_k - len(re.findall(' ',context_k[:answer_start_k]))context_k = context_k.replace(' ','')'''定义最后 input_ids 的形式[cls] question [sep] context [sep] [pad] [pad] ......'''if len(question_k) + 3 + len(context_k)>= MAX_LEN:'''如果形成的input_ids长度大于MAX_LEN,则考虑对context进行截取,定义截取context中答案前后 长为X 字符的片段则有公式:answer_start_k+len(answers_text_k)+x - (answer_start_k-x)+3+len(question_k) <= MAX_LEN解得:2x = MAX_LEN-len(answers_text_k) - 3 -len(question_k)'''x = (MAX_LEN-len(answers_text_k) - 3 -len(question_k))//2-1end = answer_start_k+len(answers_text_k)+xif answer_start_k-x <0:begain = 0idx = answer_start_kelse:begain = answer_start_k-xidx = xcontext_k = context_k[begain:end]else:idx = answer_start_kchars = np.zeros((len(context_k)))chars[idx:idx+len(answers_text_k)]=1'''计算答案与文本重合的区域,通过answer_start来定位答案,可以避免多答案的问题。'''enc_context = tokenizer.encode(context_k) enc_question = tokenizer.encode(question_k) offsets = [] idx=0for t in enc_context[1:]:w = tokenizer.decode([t])'''跳过 [cls] 对context_encode 进行反解码'''if '#' in w and len(w)>1:w = w.replace('#','')'''处理中发现,token反解码cm ml等单位计量时,会生成##cm的格式,这里做去除'''if w == '[UNK]':'''如果反解码出'[UNK]'则定以其长度为1,用‘。’代替'''w = '。'offsets.append((idx,idx+len(w)))idx += len(w)'''得到反解码出来的每一个元素在context中的位置 offsets'''toks = []for i,(a,b) in enumerate(offsets):'''如果该位置在context中属于答案重合部分,即之前标记的1,则标记为答案片段'''sm = np.sum(chars[a:b])if sm>0: toks.append(i) input_ids[k,:len(enc_question)+len(enc_context)-1] = enc_question + enc_context[1:]attention_mask[k,:len(enc_question)+len(enc_context)-1] = 1if len(toks)>0:start_tokens[k,toks[0]+len(enc_question)] = 1end_tokens[k,toks[-1]+len(enc_question)] = 1'''生成input_ids、attention_mask作为输入x取答案片段最开始的token_idex与最后的token_idex作为Y值start_tokens、end_tokens'''return input_ids,attention_mask,start_tokens,end_tokens
测试集数据处理同理,但不需要给Y值
def test_data_proceed(tokenizer,context_t,question_t,answers_text_t,answer_start_t,MAX_LEN):ct = len(context_t)input_ids_t = np.zeros((ct,MAX_LEN),dtype='int32')attention_mask_t = np.zeros((ct,MAX_LEN),dtype='int32')for k in range(len(context_t)):enc_context_t = tokenizer.encode(context_t[k]) enc_question_t = tokenizer.encode(question_t[k])if len(enc_question_t)+len(enc_context_t)-1 > MAX_LEN:x = enc_question_t + enc_context_t[1:]input_ids_t[k] = x[:MAX_LEN]attention_mask_t[k,:MAX_LEN] = 1else:input_ids_t[k,:len(enc_question_t)+len(enc_context_t)-1] = enc_question_t + enc_context_t[1:]attention_mask_t[k,:len(enc_question_t)+len(enc_context_t)-1] = 1return input_ids_t,attention_mask_t
数据测试
def random_int_list(start, stop, length):start, stop = (int(start), int(stop)) if start <= stop else (int(stop), int(start))length = int(abs(length)) if length else 0random_list = []for i in range(length):random_list.append(random.randint(start, stop))return random_listprint('#'*25)print('### check train_data')print('#'*25)for i in random_int_list(0,len(context),10):x_ = [tokenizer.decode([t]) for t in input_ids[i]]token_ans = ''.join(x_[np.argmax(start_tokens[i]):np.argmax(end_tokens[i])+1])print(token_ans+' '+answers_text[i])'''1200字左右 1200字左右一天两次 一天两次龙岩市 龙岩市荆州地区 荆州地区深圳纽仕达电商之家 深圳纽仕达电商之家25度左右 25度左右第六期 第六期狼爪 狼爪298.0元 298.0元所有的肌肤类型 所有的肌肤类型'''print('#'*25)print('### check test_data')print('#'*25)for i in random_int_list(0,len(context_t),5):x_ = [tokenizer.decode([t]) for t in input_ids_t[i]]token_ans = ''.join(x_)print(token_ans)
模型部分
模型构建
Tensorflow:2.0.0
Transformers:3.1.0
模型1:
def build_model(pretrained_path,config,MAX_LEN):ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)att = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32) bert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)x = bert_model(ids,attention_mask=att)'''取最后一层的hidden_layers,分别接两个liner+softmax,得到start和end'''x1 = tf.keras.layers.Dropout(0.1)(x[0]) x1 = tf.keras.layers.Conv1D(1,1)(x1)'''(None, 96, 768)(None, 96, 1)769个参数相当于做了一次加权+b如果不指定该函数,将不会使用任何激活函数(即使用线性激活函数:a(x)=x)flatten后得到展开的768接一个softmax'''x1 = tf.keras.layers.Flatten()(x1)x1 = tf.keras.layers.Activation('softmax')(x1)x2 = tf.keras.layers.Dropout(0.1)(x[0]) x2 = tf.keras.layers.Conv1D(1,1)(x2)x2 = tf.keras.layers.Flatten()(x2)x2 = tf.keras.layers.Activation('softmax')(x2)model = tf.keras.models.Model(inputs=[ids, att], outputs=[x1,x2])optimizer = tf.keras.optimizers.Adam(learning_rate=3e-5)pile(loss='categorical_crossentropy', optimizer=optimizer)return model
模型2:
def build_model_2(pretrained_path,config,MAX_LEN):ids = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)att = tf.keras.layers.Input((MAX_LEN,), dtype=tf.int32)config.output_hidden_states = Truebert_model = TFBertModel.from_pretrained(pretrained_path,config=config,from_pt=True)x, _, hidden_states = bert_model(ids,attention_mask=att)layer_1 = hidden_states[-1]layer_2 = hidden_states[-2]'''不同于模型1,模型2取了最后两层hidden_layers分别接了两个一维卷积后作softmax,因为start和end来源于不同的hidden_layer,模型学习的目的性更强,特征也更加丰富'''x1 = tf.keras.layers.Dropout(0.1)(layer_1)#(512,768)x1 = tf.keras.layers.Conv1D(128, 2, padding='same')(x1)#(512,128)x1 = tf.keras.layers.LeakyReLU()(x1)'''ReLU是将所有的负值都设为零,相反,Leaky ReLU是给所有负值赋予一个非零斜率。'''#x1 = tf.keras.layers.Conv1D(64, 2, padding='same')(x1)#(512,64)x1 = tf.keras.layers.Dense(1, dtype='float32')(x1)#(512,1)start_logits = tf.keras.layers.Flatten()(x1)#(512,)start_logits = tf.keras.layers.Activation('softmax')(start_logits)x2 = tf.keras.layers.Dropout(0.1)(layer_2)x2 = tf.keras.layers.Conv1D(128, 2, padding='same')(x2)x2 = tf.keras.layers.LeakyReLU()(x2)x2 = tf.keras.layers.Conv1D(64, 2, padding='same')(x2)x2 = tf.keras.layers.Dense(1, dtype='float32')(x2)end_logits = tf.keras.layers.Flatten()(x2)end_logits = tf.keras.layers.Activation('softmax')(end_logits)model = tf.keras.models.Model(inputs=[ids, att], outputs=[start_logits,end_logits])optimizer = tf.keras.optimizers.Adam(learning_rate=2e-5)pile(loss='categorical_crossentropy', optimizer=optimizer)return model
模型训练
这里采用的是K折训练,单模型stacking,将数据集随机划分成5份,取其中4份作为训练集,1份作为测试集,分别训练5个val_loss最低的模型,分别对验证集进行推理,将5个模型的softmax结果加权平均后,argmax得到预测的答案start和end位置。
def main():VER='v0'DISPLAY=2 # USE display=1 FOR INTERACTIVEoof_start = np.zeros((input_ids.shape[0],MAX_LEN))oof_end = np.zeros((input_ids.shape[0],MAX_LEN))preds_start = np.zeros((input_ids_t.shape[0],MAX_LEN))preds_end = np.zeros((input_ids_t.shape[0],MAX_LEN))skf = StratifiedKFold(n_splits=5,shuffle=True,random_state=777)for fold,(idxT,idxV) in enumerate(skf.split(input_ids,answer_start)):'''fold:当前K折折次idxT:当前训练集indexidxV:当前测试集indexlen(idxV)*4=len(idxT)'''print('#'*25)print('### FOLD %i'%(fold+1))print('#'*25)K.clear_session()model = build_model_2(pretrained_path,config,MAX_LEN)sv = tf.keras.callbacks.ModelCheckpoint('./model_/%s-roberta-%i.h5'%(VER,fold), monitor='val_loss', verbose=2, save_best_only=True,save_weights_only=True, mode='auto', save_freq='epoch')model.fit([input_ids[idxT,], attention_mask[idxT,]],[start_tokens[idxT,],end_tokens[idxT,]], epochs=1, batch_size=8, verbose=DISPLAY, callbacks=[sv],validation_data=([input_ids[idxV,],attention_mask[idxV,]], [start_tokens[idxV,], end_tokens[idxV,]]))print('Loading model...')model.load_weights('./model_/%s-roberta-%i.h5'%(VER,fold))print('Predicting Test...')preds = model.predict([input_ids_t,attention_mask_t],verbose=DISPLAY)preds_start += preds[0]/skf.n_splitspreds_end += preds[1]/skf.n_splits'''5折验证结果进行投票,选出得分最高的点作为开始和结束点'''all_ = []for k in range(len(input_ids_t)):a = np.argmax(preds_start[k,])b = np.argmax(preds_end[k,])if a>b: st = context_t[k]else:x_ = [tokenizer.decode([t]) for t in input_ids_t[k]]st = ''.join(x_[a:b+1])all_.append(st)ans_data = pd.DataFrame(context_t,columns=['context'])ans_data['question'] = question_tans_data['answers_text'] = answers_text_tans_data['pred_answers'] = all_ans_data.to_csv('result.csv')
部分推理结果
抽取了一些验证集上的推理结果,可以看到模型基本都能准确给出正确答案。
总结
有关于其他的数据增强、训练技巧这里就不做过多介绍了,想要了解的朋友可以去看下大牛们的技术报告。
总的来说,阅读理解baseline模型还是非常简单的,大家可以采取不同的方法取标注指针,不一定是模型的最后两层隐藏层。
在实际训练中,所有子模型都只训练了一轮就达到了过拟合,有几个可能:1.数据太简单,2.模型太复杂了,3.学习速率太高,可以考虑使用warm_up。
接下来我会找时间去复现关系抽取、事件抽取这两个任务类型,涉及到NER,应该会更加有意思。
完整训练代码,可以参考我的github: /zhengyanzhao1997/TF-NLP-model/blob/main/model/train/SQuAD_baseline.py
部分参考资料:
样本数据构造与模型训练参考思路:/cdeotte/tensorflow-roberta-0-705/comments
模型参考思路:/p/139779541
信息抽取(一)机器阅读理解——样本数据处理与Baseline模型搭建训练(语言与智能技术竞赛)