機器閱讀理解背景
機器閱讀理解是自然語言處理中最熱門、最前沿的研究課題之一。閱讀是人們獲得信息的基本手段,沒有閱讀就沒有理解,沒有理解就沒有交流。市面上已有很多聊天機器人產品,但人們發現這些機器人往往答非所問。究其原因,就是目前採用的技術是「文本比對」的黑盒方式,而實際上機器人並不理解人類在和它說什麼。大家知道,人們在交流是有語境的,通過聯想,人們可以方便地理解對方在說什麼,但是讓機器了解語境確實是一件非常困難的事。為了解決這個問題,研究者提出了許多改進的方式,不斷提高模型理解對話與文章的能力。而且,一大批閱讀理解數據集的發布強有力地推動了技術的發展。
機器閱讀除了研究價值之外,還有很多有意義的應用,比如文本摘要可以省去人們閱讀全文的時間,問答系統可以從海量文檔中精確地找到用戶問題的答案。機器閱讀也是翻譯和對話的基礎,這對計算機輔助人工服務有重大價值。
在人工智慧浪潮席捲世界的今天,讓計算機學會閱讀有著重要的意義。一方面,閱讀能力涉及人類的核心智能,也是終極人工智慧必不可少的組成部分;另一方面,隨著文本數據的爆炸性增長,利用模型將文本信息的理解自動化可節省大量人力物力成本,在許多行業有著廣泛的應用價值。
雖然當前機器閱讀理解的能力遠遜於人類,但我們可以利用計算機的超快運算速度和超大存儲能力找到彎道超車的辦法。所謂「熟讀唐詩三百首,不會吟詩也會吟」,而現在的計算機一秒內就可以讀詩千萬首,我們完全有理由期待它能取得突破性的成果。例如,2018年橫空出世的BERT模型融合了大數據和大模型,在機器閱讀理解等多個自然語言處理領域取得可喜的突破。
機器閱讀理解簡介
機器閱讀理解:機器閱讀理解,又稱閱讀理解問答,要求機器閱讀並理解人類自然語言文本,在此基礎上,解答跟文本信息相關的問題。該任務通常被用來衡量機器自然語言理解能力,可以幫助人類從大量文本中快速聚焦相關信息,降低人工信息獲取成本,在文本問答、信息抽取、對話系統等領域具有極強的應用價值。近年來,機器閱讀理解受到工業界和學術界越來越廣泛的關注,是自然語言處理領域的研究熱點之一。
基礎任務:機器閱讀理解基礎任務是根據問題,從非結構化文檔中尋找合適的答案,因此,研究人員通常將機器閱讀理解形式化為一個關於(文檔,問題,答案)三元組的監督學習問題。
任務類型:根據答案的形式,機器閱讀理解任務被細分為完型填空式、多項選擇式、片段抽取式和自由作答式四類,這四類任務從易到難,見證了機器閱讀理解技術的發展。
機器閱讀理解任務:
完型填空:完型填空式任務通常將文檔中的某個實體用佔位符替換,機器閱讀殘缺段落後尋找正確的詞進行補充,使原文完整。這類任務代表的數據集有CNN &Daily Mail,CBT(The Children's Book Test),CMRC2017等,其中CNN &Daily Mail是2015年穀歌發布的首個大型閱讀理解數據集,收集了9.3萬篇CNN和22萬篇Daily Mail新聞稿,通過實體替換技術構造百萬級別的三元組語料庫。
多項選擇:多項選擇數據集為(文檔,問題,候選答案集,答案)四元組形式,機器閱讀文檔和問題後,從候選答案集中挑選正確的答案,如MCText,RACE。RACE數據集源自初高中英語考試試題,包含約2.8萬篇文章和10萬個專家問題,用於測試機器的理解和推理能力。
片段抽取:片段抽取式任務要求從原文中抽取一段連續的句子或短語作為問題的答案,相比於完型填空任務填充單一實體,該任務面臨更大的搜索空間,因此更具有挑戰性。2016年,史丹福大學發布了SQuAD數據集,該數據集包括500多篇WIKI百科文章以及人工構建的10萬多問題。2018年,Rajpurkar等人對SQuAD數據集進一步擴增,在原有基礎上新增5萬多無法回答的對抗性問題,這些問題在原文中都存在似是而非的迷惑性答案,模型不僅僅需要準確應答受原文支持的可回答問題,還需要避免對不可回答問題作出回應。因此,該任務難度更高,更能檢驗機器的閱讀理解能力。
自由作答:與片段抽取式任務不同,自由作答式閱讀理解的答案形式更加靈活,正確答案可能需要從原文進行推理或歸納總結,不限制於是否來自原文句子片段,與現實人類作答習慣更為貼近。代表數據集有CoQA、MS-MARCO、DuReader等,通常涉及到多輪迴答、多跳推理等技術。
各項任務熱度圖:
片段抽取:學者C.Snow在2002年的一篇論文中定義閱讀理解是「通過交互從書面文字中提取與構造文章語義的過程」。而機器閱讀理解的目標是利用人工智慧技術,使計算機具有和人類一樣理解文章的能力。下圖給出了一個機器閱讀理解的樣例。其中,模型需要從文章中的一段原文回答問題。
機器閱讀理解架構:
機器閱讀理解工業架構:
BiDAF:Bidirectional Attention Flow for Machine Comprehension,出自2017年ICLR的一篇論文
核心內容:這篇論文主要對Attention機製做了改進,為此作者總結了MC任務上過去常用的三類attention
1.Attention Reader:通過動態attention機制從文本中提取相關信息(context vector),再根據該信息給出預測結果。
2.Attention-Sum Reader:只計算一次attention weights,然後直接餵給輸出層做出最後的預測,也就是利用attention機制直接獲取文本中各位置作為答案的概率,和pointer network類似的思想,效果很依賴對query的表示。
3.Multi-hop Attention:計算多次attention
主要貢獻:在此上面的基礎上,作者對注意力機製做出了改進,具體BiDAF attention的特點如下
並沒有把context編碼進固定大小的vector,而是讓vector可以流動,減少早期加權和的信息損失
Memory-less,在每個時刻,僅僅對query和當前時刻的context paragraph進行計算,並不直接依賴上一時刻的attention,這使得後面的attention計算不會受到之前錯誤的attention信息的影響
計算了query-to-context(Q2C)和context-to-query(C2Q)兩個方向的attention信息,認為C2Q和Q2C實際上能夠相互補充。實驗發現模型在開發集上去掉C2Q與去掉Q2C相比,分別下降了12和10個百分點,顯然C2Q這個方向上的attention更為重要
模型架構:分為六層
word-Embedding Layer(詞嵌入層)
Character embedding layer(字符嵌入層)
Contextual embedding layer(上下文嵌入層)
Attention flow layer(注意流程)
Modeling layer(模型層)
Output layer(輸出層)
word-Embedding Layer(詞嵌入層):
論文中使用Glove
Character embedding layer(字符嵌入層):
論文中使用了1D-CNN,主要解決OOV問題
Highway network:
1-t:carry gate
高速神經網絡的作用是調整單詞嵌入和字符嵌入步驟的相對貢獻配比,邏輯是,如果我們處理的是一個像「misunderestimate」這樣的oov詞,會希望增加該詞1D-CNN表示的相對重要性,因為我們知道它的Glove表示可能是一些隨機的胡言亂語。另一方面,當我們處理一個常見而且含義明確的單詞時,如「table」時,我們可能希望Glove和1D-CNN之間的貢獻配比更為平等。
數據集是從維基百科中摘取的一部分,json格式如下圖所示:
一個段落中有context和多個問答實例。
代碼實戰
1.數據預處理
class Preprocessor: def __init__(self, datasets_fp, max_length=384, stride=128): self.datasets_fp = datasets_fp self.max_length = max_length self.max_clen = 384 self.max_qlen = 128 self.stride = stride self.charset = set() self.build_charset()
def build_charset(self): for fp in self.datasets_fp: self.charset |= self.dataset_info(fp)
self.charset = sorted(list(self.charset)) self.charset = ['[PAD]', '[CLS]', '[SEP]'] + self.charset + ['[UNK]'] #構建索引id idx = list(range(len(self.charset))) #charset to id self.ch2id = dict(zip(self.charset, idx)) #id to charset self.id2ch = dict(zip(idx, self.charset))
def dataset_info(self, inn): charset = set() #加載json數據集,pio讀取比原生json讀取速度快 dataset = pio.load(inn) #返回context,問題和答案 for _, context, question, answer, _ in self.iter_cqa(dataset): #取不重複的字符然後取併集 charset |= set(context) | set(question) | set(answer) return charset
def iter_cqa(self, dataset): for data in dataset['data']: for paragraph in data['paragraphs']: context = paragraph['context'] for qa in paragraph['qas']: qid = qa['id'] question = qa['question'] for answer in qa['answers']: text = answer['text'] answer_start = answer['answer_start'] yield qid, context, question, text, answer_start
def encode(self, context, question): question_encode = self.convert2id(question, begin=True, end=True) left_length = self.max_length - len(question_encode) context_encode = self.convert2id(context, maxlen=left_length, end=True) cq_encode = question_encode + context_encode
assert len(cq_encode) == self.max_length
return cq_encode
def convert2id(self, sent, maxlen=None, begin=False, end=False): ch = [ch for ch in sent] ch = ['[CLS]'] * begin + ch
if maxlen is not None: ch = ch[:maxlen - 1 * end] ch += ['[SEP]'] * end ch += ['[PAD]'] * (maxlen - len(ch)) else: ch += ['[SEP]'] * end
ids = list(map(self.get_id, ch))
return ids
def get_id(self, ch): return self.ch2id.get(ch, self.ch2id['[UNK]']) def get_dataset(self, ds_fp): cs, qs, be = [], [], [] for _, c, q, b, e in self.get_data(ds_fp): cs.append(c) qs.append(q) be.append((b, e)) return map(np.array, (cs, qs, be))
def get_data(self, ds_fp): dataset = pio.load(ds_fp) for qid, context, question, text, answer_start in self.iter_cqa(dataset): cids = self.get_sent_ids(context, self.max_clen) qids = self.get_sent_ids(question, self.max_qlen) b, e = answer_start, answer_start + len(text) if e >= len(cids): b = e = 0 yield qid, cids, qids, b, e
def get_sent_ids(self, sent, maxlen): return self.convert2id(sent, maxlen=maxlen, end=True)
2.BiDAF模型架構
class BiDAF: def __init__( self, clen, qlen, emb_size, max_features=5000, num_highway_layers=2, encoder_dropout=0, num_decoders=2, decoder_dropout=0, ): self.clen = clen self.qlen = qlen self.max_features = max_features self.emb_size = emb_size self.num_highway_layers = num_highway_layers self.encoder_dropout = encoder_dropout self.num_decoders = num_decoders self.decoder_dropout = decoder_dropout
def build_model(self): cinn = tf.keras.layers.Input(shape=(self.clen,), name='CInn') qinn = tf.keras.layers.Input(shape=(self.qlen,), name='QInn') embedding_layer = tf.keras.layers.Embedding(self.max_features, self.emb_size) cemb = embedding_layer(cinn) qemb = embedding_layer(qinn)
for i in range(self.num_highway_layers): highway_layer = layers.Highway(name=f'Highway{i}') chighway = tf.keras.layers.TimeDistributed(highway_layer, name=f'CHighway{i}') qhighway = tf.keras.layers.TimeDistributed(highway_layer, name=f'QHighway{i}')
cemb = chighway(cemb) qemb = qhighway(qemb)
encoder_layer = tf.keras.layers.Bidirectional( tf.keras.layers.LSTM( self.emb_size, recurrent_dropout=self.encoder_dropout, return_sequences=True, name='RNNEncoder' ), name='BiRNNEncoder' )
cencode = encoder_layer(cemb) qencode = encoder_layer(qemb) similarity_layer = layers.Similarity(name='SimilarityLayer') similarity_matrix = similarity_layer([cencode, qencode]) c2q_att_layer = layers.C2QAttention(name='C2QAttention') q2c_att_layer = layers.Q2CAttention(name='Q2CAttention')
c2q_att = c2q_att_layer(similarity_matrix, qencode) q2c_att = q2c_att_layer(similarity_matrix, cencode)
merged_ctx_layer = layers.MergedContext(name='MergedContext') merged_ctx = merged_ctx_layer(cencode, c2q_att, q2c_att)
modeled_ctx = merged_ctx for i in range(self.num_decoders): decoder_layer = tf.keras.layers.Bidirectional( tf.keras.layers.LSTM( self.emb_size, recurrent_dropout=self.decoder_dropout, return_sequences=True, name=f'RNNDecoder{i}' ), name=f'BiRNNDecoder{i}' ) modeled_ctx = decoder_layer(merged_ctx)
span_begin_layer = layers.SpanBegin(name='SpanBegin') span_begin_prob = span_begin_layer([merged_ctx, modeled_ctx])
span_end_layer = layers.SpanEnd(name='SpanEnd') span_end_prob = span_end_layer([cencode, merged_ctx, modeled_ctx, span_begin_prob])
output_layer = layers.Combine(name='CombineOutputs') out = output_layer([span_begin_prob, span_end_prob])
inn = [cinn, qinn]
self.model = tf.keras.models.Model(inn, out) self.model.summary(line_length=128)
optimizer = tf.keras.optimizers.Adadelta(lr=1e-2) self.model.compile( optimizer=optimizer, loss=negative_avg_log_error, metrics=[accuracy] )class Highway(tf.keras.layers.Layer):
def __init__(self, activation='relu', *args, **kwargs): self.activation = activation super().__init__(*args, **kwargs)
def build(self, input_shape): dim = input_shape[-1] bias_init = tf.keras.initializers.Constant(-1) self.dense1 = tf.keras.layers.Dense(dim, bias_initializer=bias_init) self.dense2 = tf.keras.layers.Dense(dim)
self.dense1.build(input_shape) self.dense2.build(input_shape)
super().build(input_shape)
def call(self, x): dim = x.shape[-1]
transform_gate = self.dense1(x) transform_gate = tf.keras.layers.Activation('sigmoid')(transform_gate)
carry_gate = tf.keras.layers.Lambda(lambda x: 1.0 - x, output_shape=(dim,))(transform_gate) transformed_data = self.dense2(x) transformed_data = tf.keras.layers.Activation(self.activation)(transformed_data) transformed_gated = tf.keras.layers.multiply([transform_gate, transformed_data]) identity_gated = tf.keras.layers.multiply([carry_gate, x]) value = tf.keras.layers.add([transformed_gated, identity_gated])
return value
def compute_output_shape(self, input_shape): return input_shape
def get_config(self) config = super().get_config() config['activation'] = self.activation config['transform_gate_bias'] = self.transform_gate_bias return config計算context和question相似度
class Similarity(tf.keras.layers.Layer):
def build(self, input_shape): word_vector_dim = input_shape[0][-1] weight_vector_dim = word_vector_dim * 3
self.kernel = self.add_weight( name='SimilarityWeight', shape=(weight_vector_dim, 1), initializer='uniform', trainable=True, )
self.bias = self.add_weight( name='SimilarityBias', shape=(), initializer='ones', trainable=True, )
super().build(input_shape)
def compute_similarity(self, repeated_cvectors, repeated_qvectors): element_wise_multiply = repeated_cvectors * repeated_qvectors
concat = tf.keras.layers.concatenate([ repeated_cvectors, repeated_qvectors, element_wise_multiply ], axis=-1)
dot_product = tf.tensordot(concat, self.kernel, axes=1) dot_product = tf.squeeze(dot_product, axis=-1)
return tf.keras.activations.linear(dot_product + self.bias)
def call(self, inputs): c_vector, q_vector = inputs
n_cwords = c_vector.shape[1] n_qwords = q_vector.shape[1]
cdim_repeat = tf.convert_to_tensor([1, 1, n_qwords, 1]) qdim_repeat = tf.convert_to_tensor([1, n_cwords, 1, 1])
repeated_cvectors = tf.tile(tf.expand_dims(c_vector, axis=2), cdim_repeat) repeated_qvectors = tf.tile(tf.expand_dims(q_vector, axis=1), qdim_repeat)
similarity = self.compute_similarity(repeated_cvectors, repeated_qvectors)
return similarity注意力流層
class C2QAttention(tf.keras.layers.Layer):
def call(self, similarity, qencode): qencode = tf.expand_dims(qencode, axis=1)
c2q_att = tf.keras.activations.softmax(similarity, axis=-1) c2q_att = tf.expand_dims(c2q_att, axis=-1) c2q_att = tf.math.reduce_sum(c2q_att * qencode, -2)
return c2q_att
class Q2CAttention(tf.keras.layers.Layer):
def call(self, similarity, cencode): max_similarity = tf.math.reduce_max(similarity, axis=-1) c2q_att = tf.keras.activations.softmax(max_similarity) c2q_att = tf.expand_dims(c2q_att, axis=-1)
weighted_sum = tf.math.reduce_sum(c2q_att * cencode, axis=-2) weighted_sum = tf.expand_dims(weighted_sum, 1)
num_repeat = cencode.shape[1]
q2c_att = tf.tile(weighted_sum, [1, num_repeat, 1])
return q2c_attmerge後送入LSTM層
class MergedContext(tf.keras.layers.Layer):
def call(self, cencode, c2q_att, q2c_att): m1 = cencode * c2q_att m2 = cencode * q2c_att
concat = tf.keras.layers.concatenate([ cencode, c2q_att, m1, m2], axis=-1 )
return concat
def compute_output_shape(self, input_shape): shape, _, _ = input_shape return shape[:-1] + (shape[-1] * 4, )Output layer
class SpanBegin(tf.keras.layers.Layer):
def build(self, input_shape): last_dim = input_shape[0][-1] + input_shape[1][-1] inn_shape_dense1 = input_shape[0][:-1] + (last_dim, ) self.dense1 = tf.keras.layers.Dense(1) self.dense1.build(inn_shape_dense1)
super().build(input_shape)
def call(self, inputs): merged_ctx, modeled_ctx = inputs
span_begin_inn = tf.concat([merged_ctx, modeled_ctx], axis=-1) span_begin_weight = tf.keras.layers.TimeDistributed(self.dense1)(span_begin_inn) span_begin_weight = tf.squeeze(span_begin_weight, axis=-1) span_begin_prob = tf.keras.activations.softmax(span_begin_weight)
return span_begin_prob
def compute_output_shape(self, input_shape): return input_shape[0][:-1]
class SpanEnd(tf.keras.layers.Layer):
def build(self, input_shape): emb_size = input_shape[0][-1] // 2 inn_shape_bilstm = input_shape[0][:-1] + (emb_size * 14, ) inn_shape_dense = input_shape[0][:-1] + (emb_size * 10, )
self.bilstm = tf.keras.layers.Bidirectional( tf.keras.layers.LSTM(emb_size, return_sequences=True)) self.bilstm.build(inn_shape_bilstm)
self.dense = tf.keras.layers.Dense(1) self.dense.build(inn_shape_dense)
super().build(input_shape)
def call(self, inputs): cencode, merged_ctx, modeled_ctx, span_begin_prob = inputs
_span_begin_prob = tf.expand_dims(span_begin_prob, axis=-1) weighted_sum = tf.math.reduce_sum(_span_begin_prob * modeled_ctx, axis=-2)
weighted_ctx = tf.expand_dims(weighted_sum, axis=1) tile_shape = tf.concat([[1], [cencode.shape[1]], [1]], axis=0) weighted_ctx = tf.tile(weighted_ctx, tile_shape) m1 = modeled_ctx * weighted_ctx
span_end_repr = tf.concat([merged_ctx, modeled_ctx, weighted_ctx, m1], axis=-1) span_end_repr = self.bilstm(span_end_repr) span_end_inn = tf.concat([merged_ctx, span_end_repr], axis=-1) span_end_weights = tf.keras.layers.TimeDistributed(self.dense)(span_end_inn) span_end_prob = tf.keras.activations.softmax(tf.squeeze(span_end_weights, axis=-1))
return span_end_prob
def compute_output_shape(self, input_shape): return input_shape[1][:-1]
class Combine(tf.keras.layers.Layer):
def call(self, inputs): return tf.stack(inputs, axis=1)loss function
def negative_avg_log_error(y_true, y_pred):
def sum_of_log_prob(inputs): y_true, y_pred_start, y_pred_end = inputs
begin_idx = tf.dtypes.cast(y_true[0], dtype=tf.int32) end_idx = tf.dtypes.cast(y_true[1], dtype=tf.int32)
begin_prob = y_pred_start[begin_idx] end_prob = y_pred_end[end_idx]
return tf.math.log(begin_prob) + tf.math.log(end_prob)
y_true = tf.squeeze(y_true) y_pred_start = y_pred[:, 0, :] y_pred_end = y_pred[:, 1, :]
inputs = (y_true, y_pred_start, y_pred_end) batch_prob_sum = tf.map_fn(sum_of_log_prob, inputs, dtype=tf.float32)
return -tf.keras.backend.mean(batch_prob_sum, axis=0, keepdims=True)accuracy
def accuracy(y_true, y_pred):
def calc_acc(inputs): y_true, y_pred_start, y_pred_end = inputs
begin_idx = tf.dtypes.cast(y_true[0], dtype=tf.int32) end_idx = tf.dtypes.cast(y_true[1], dtype=tf.int32)
start_probability = y_pred_start[begin_idx] end_probability = y_pred_end[end_idx]
return (start_probability + end_probability) / 2.0
y_true = tf.squeeze(y_true) y_pred_start = y_pred[:, 0, :] y_pred_end = y_pred[:, 1, :]
inputs = (y_true, y_pred_start, y_pred_end) acc = tf.map_fn(calc_acc, inputs, dtype=tf.float32)
return tf.math.reduce_mean(acc, axis=0)訓練模型
bidaf.model.fit( [train_c, train_q], train_y, batch_size=16, epochs=100, validation_data=([test_c, test_q], test_y) )部分訓練流程。。。在1080ti下有點小慢