记忆网络之Key-Value Memory Networks tensorflow实现

记忆网络之Key-Value Memory Networks tensorflow实现

======================================================================

我是一条华丽的分割线-------第二次更新,突然get到一个点那就是更新应该放在最上面而不是最下面

======================================================================

前面几天又抽时间调了下模型,首先根据上面的结果呢,我就在思考,为什么准确度和loss波动这么大,而且验证集效果这么差呢。后来想到一个致命的问题,那就是我们在处理数据的时候,如果一个QA对有好几个答案,我们就会把它作为好几条训练样本,这样就导致了,相同的特征和问题,但是却有好几个不一样的标签,也就是样本本身就有冲突,这是模型解决不了的。所以改进的第一个问题就是智取第一个答案作为训练样本。结果如下所示:

从上那个图可以发现,训练及准确度提升到了100%,loss降到了0,而且震荡现象也改善了。说明我们的模型取得了巨大的进步。但是,仍然存在一个问题就是测试集效果依然不好,准确度只有40%多。于是我加了dropout功能,在dropout之分别取0.5(程度更厉害)和0.8的时候结果如下图所示:

从上图可以发现,dropout程度越厉害,训练集效果越差,测试集的改善也并不明显,而且又重新出现了B矩阵的尖峰现象。很是诡异,等我再挑挑参数看看效果吧~~

======================================================================

我是一条华丽的分割线-------第一次更新

======================================================================

好消息是模型已经收敛了,训练集上的准确度可以达到0.8~0.9,loss也降到了1左右。

坏消息是测试集上表现依旧不佳。准确度只有0.3左右~~具体的改进方案见github

======================================================================

我是一条华丽的分割线-------下面是正文

======================================================================

前面我们介绍了Key-Value Memory Networks这篇论文,这里我们介绍一下该论文使用tensorflow的实现方法。其实github上面有一个[实现方案](github.com/siyuanzhao/k),但是该方案用于仿真bAbI任务的数据集,与QA任务还有一定的区别,又与之前一篇End-to-End MemNN已经对该数据进行了仿真实现,所以这篇文章更想尝试一下QA任务的实现方案,这也是本文的主要关注点所在。其实二者的主要区别在于数据集的处理和Key-Value memory的表示,所以我们将结合github.com/siyuanzhao/kgithub.com/dapurv5/neur(主要借鉴其数据处理部分)两个代码进行介绍。最终整合之后的完整代码可以到我的github上面进行下载~~接下来我们从数据处理、模型实现、模型训练几个部分进行介绍。(github连接:lc222/key-value-MemNN

1,数据处理

这里使用论文中提出的MovieQA数据集,可以到Facebook官方github上面进行下载,此外,其实官网也给出了torch的实现方案,有兴趣的同学也可以进行参考学习。数据下载地址如下所示:thespermwhale.com/jasew

解压之后发现主要包括knowledge_source和questions两个文件夹,分别保存了知识库和QA对。这里为了方便起见,我们使用KB作为知识源,而不使用wiki文章,因为wiki文章处理起来过于复杂,可以看看官网的代码,而相比之下KB结构相对比较简单,做key hashing等操作借助图的数据结构还可以方便的实现。这里也主要对该操作进行介绍,其他的都可以参考上面那个连接进行了解,当然也可以直接使用我github上面分享出来的处理完数据集,将精力放在模型构建上面。下面我们看一下如何将KB知识库构建成一个知识图谱的形式(这里借助networkx第三方库):

HIGH_DEGREE_THRESHOLD = 50
   class KnowledgeGraph(object):
       def __init__(self, graph_path, unidirectional=True):
           """
           初始化知识图谱,如果unidirectional==False,也就是使用反向三元组,将(e2,invR,e1)也添加到图中
           """
           # 初始化一个图结构
           self.G = nx.DiGraph()
           # 读取知识库中的每个三元组,并将其添加到图中
           with open(graph_path, 'r') as graph_file:
               for line in graph_file:
                   line = clean_line(line)
                   e1, relation, e2 = line.split(PIPE)
                   self.G.add_edge(e1, e2, {"relation": relation})
                   # 将反向三元组添加到图中
                   if not unidirectional:
                       self.G.add_edge(e2, e1, {"relation": self.get_inverse_relation(relation)})

           # 记录图中所有的高度节点,如果节点的入度大于HIGH_DEGREE_THRESHOLD,则为高度节点
           self.high_degree_nodes = set([])
           indeg = self.G.in_degree()
           for v in indeg:
               if indeg[v] > HIGH_DEGREE_THRESHOLD:
                   self.high_degree_nodes.add(v)
           self.all_entities = set(nx.nodes(self.G))

       def get_inverse_relation(self, relation):
           return "INV_" + relation

       def get_all_paths(self, source, target, cutoff):
           '''
           得到source和target之间的所有的路径。
           :param source: 起始节点
           :param target: 终止节点
           :param cutoff: 是否启用cutoff
           :return: 返回两个列表,一个是使用节点表示的path,另一个是使用边表示的path。如下所示:
           [ [e1, e2], [e1, e3, e2]], [[r1], [r2, r3] ]
           '''
           if source == target:
               return [], []
           paths_of_entities = []
           paths_of_relations = []
           #遍历源和目的之间所有的path
           for path in nx.all_simple_paths(self.G, source, target, cutoff):
               #可以将path直接添加到paths_of_entities中
               paths_of_entities.append(path)
               relations_path = []
               #对上面每个path取出relation,并组成新的path,然后添加到paths_of_relations中
               for i in range(0, len(path) - 1):
                   relation = self.G[path[i]][path[i + 1]]['relation']
                   relations_path.append(relation)
               paths_of_relations.append(relations_path)
           return paths_of_entities, paths_of_relations

       def get_candidate_neighbors(self, node, num_hops=2, avoid_high_degree_nodes=True):
           '''
           得到与一个节点相关的周边跳数在num_hops之内的所有节点
           :param node:要寻找的节点
           :param num_hops:跳数
           :param avoid_high_degree_nodes:是否去除高度节点
           :return:返回得到的所有节点
           '''
           result = set([])
           q = [node]
           visited = set([node])
           dist = {node: 0}
           while len(q) > 0:
               u = q.pop(0)
               result.add(u)
               for nbr in self.G.neighbors(u):
                   if nbr in self.high_degree_nodes and avoid_high_degree_nodes:
                       continue
                   if nbr not in visited:
                       visited.add(nbr)
                       dist[nbr] = dist[u] + 1
                       if dist[nbr] <= num_hops:
                           q.append(nbr)
           result.remove(node)
           return result

       def get_adjacent_entities(self, node):
           return set(self.G.neighbors(node))

       def get_relation(self, source, target):
           return self.G[source][target]['relation']

       def log_statistics(self):
           print "NUM_NODES", len(self.get_entities())

       def get_entities(self):
           return self.all_entities

       def get_high_degree_entities(self):
           return self.high_degree_nodes

有了这个类,我们就可以十分方便的获得与一个问题相关的知识子集,也就是论文中所谓的“Key Hashing”的功能,这样我们就可以将QA对和KB知识库统一起来建立一个新的数据集,也就是每个QA对都与其相关联的知识子集向一一对应。也就是我github上面的wiki-entities_train_kv.text文件。可以先看一下其结构,如下所示,以csv的形式存储,主要包含questions,qu_entities,ans_entities,sources,relations,targets几项:

处理完了数据之后接下来我们看一下训练时的数据读取部分的函数,主要包含了get_maxlen()、data_loader()、prepare_batch()三个函数。分别用于获取数据中各项的最大长度,以便后来填充使用;载入数据;还有对一个batch的数据进行填充和扩展:

def read_file_as_dict(input_path):
       '''
       读取csv文件,并将其保存到字典中。csv文件中有两列,保存的是vocab的字典映射关系
       :param input_path: 要读取得csv文件
       :return: 保存的字典
       '''
       d = {}
       with open(input_path) as input_file:
           reader = csv.DictReader(input_file, delimiter='\t', fieldnames=['col1', 'col2'])
           for row in reader:
               d[row['col1']] = int(row['col2'])
       return d 

   def get_maxlen(*paths):
       '''
       得到输入数据中各项指标的最大长度
       :param paths: 输入数据文件,可以有很多个,train,test,dev数据集
       :return: 各项的最大长度信息,保存在字典maxlen中
       '''
       maxlen = defaultdict(int)
       for path in paths:
           with open(path, 'r') as examples_file:
               fields = ['question', 'qn_entities', 'ans_entities', 'sources', 'relations', 'targets']
               reader = csv.DictReader(examples_file, delimiter='\t', fieldnames=fields)
               for row in reader:
                   example = {}
                   example['question'] = row['question'].split(' ')
                   example['qn_entities'] = row['qn_entities'].split('|')
                   example['ans_entities'] = row['ans_entities'].split('|')
                   example['sources'] = row['sources'].split('|')
                   example['relations'] = row['relations'].split('|')
                   example['targets'] = row['targets'].split('|')

                   maxlen['question'] = max(len(example['question']), maxlen['question'])
                   maxlen['qn_entities'] = max(len(example['qn_entities']), maxlen['qn_entities'])
                   maxlen['ans_entities'] = max(len(example['ans_entities']), maxlen['ans_entities'])
                   maxlen['sources'] = max(len(example['sources']), maxlen['sources'])
                   maxlen['relations'] = maxlen['sources']
                   maxlen['targets'] = maxlen['sources']
       return maxlen

   def data_loader(data_file, vocab_idx, entity_idx):
       '''
       输入数据的载入函数,读取数据保存在列表中,方便训练时读取操作
       :param data_file: 要读取得文件
       :param vocab_idx: vocab字典映射关系
       :param entity_idx: 实体Entity的字典映射关系
       :return: 返回文件数据,以列表形式,列表中每个元素是文件中的一行,并以字典形式保存各项数据
       '''
       with open(data_file, 'r') as f:
           fields = ['question', 'qn_entities', 'ans_entities', 'sources', 'relations', 'targets']
           reader = csv.DictReader(f, delimiter='\t', fieldnames=fields)
           examples = []
           for line in reader:
               example = {}
               #将数据中的单词转化为相应的索引
               example['question'] = [vocab_idx[word]-1 for word in line['question'].split(' ')]
               example['qn_entities'] = [vocab_idx[word]-1 for word in line['qn_entities'].split('|')]
               example['ans_entities'] = [entity_idx[word]-1 for word in line['ans_entities'].split('|')]
               example['sources'] = [vocab_idx[word]-1 for word in line['sources'].split('|')]
               example['relations'] = [vocab_idx[word]-1 for word in line['relations'].split('|')]
               example['targets'] = [vocab_idx[word]-1 for word in line['targets'].split('|')]
               examples.append(example)

       return examples

   def pad(arr, L):
       '''
       对数据进行PAD操作,将arr的长度补全至L,使用0进行填充
       :param arr: 要补全的数组
       :param L: 补全后的长度
       :return: 补全后的数组
       '''
       arr_cpy = list(arr)
       assert (len(arr_cpy) <= L)
       while len(arr_cpy) < L:
           arr_cpy.append(0)
       return arr_cpy

   def prepare_batch(batch_examples, maxlen, batch_size, entity_size):
       '''
       对一个batch数据进行填充和扩展
       :param batch_examples: mini-batch的数据
       :param maxlen: 各项指标的最大长度,要进行填充
       :param batch_size: minibatch大小
       :return: 处理完之后的数据,是一个字典,每一项都是一个数组,对应模型的一个placeholder
       '''
       batch_dict = {}
       batch_dict['question'] = gather_single_column_from_batch(batch_examples, maxlen, 'question', batch_size)
       #batch_dict['qn_entities'] = gather_single_column_from_batch(batch_examples,maxlen, 'qn_entities', batch_size)
       batch_dict['sources'] = gather_single_column_from_batch(batch_examples, maxlen, 'sources', batch_size)
       batch_dict['relations'] = gather_single_column_from_batch(batch_examples, maxlen, 'relations', batch_size)
       batch_dict['targets'] = gather_single_column_from_batch(batch_examples, maxlen, 'targets', batch_size)
       batch_dict['keys'], batch_dict['values'] = gather_key_and_value_from_batch(batch_examples, maxlen, batch_size)
       labels = []
       for i in xrange(batch_size):
           for ans in batch_examples[i]['ans_entities']:
               ans2arr = [0]*entity_size
               ans2arr[ans] = 1
               labels.append(np.array(ans2arr))
       batch_dict['answer'] = np.array(labels)
       return batch_dict

   def gather_single_column_from_batch(batch_examples, maxlen, column_name, batch_size):
       '''
       对minibatch数据的某一列进行pad和按照answer个数进行扩展。最终处理完数据的长度会大于batchsize,因为每个例子往往会有好几个答案。
       :param batch_examples: batch数据
       :param maxlen: 各项数据最大长度
       :param column_name: 要处理的列名
       :param batch_size: batchsize大小
       :return: 处理完之后的一列数据,是列表形式保存
       '''
       column = []
       for i in xrange(batch_size):
           num_ans = len(batch_examples[i]['ans_entities'])
           example = pad(batch_examples[i][column_name], maxlen[column_name])
           for j in xrange(num_ans):
               column.append(np.array(example))
       return np.array(column)

   def gather_key_and_value_from_batch(batch_examples, maxlen, batch_size):
       '''
       获得数据相关的key和value,其实就是把知识库三元组的主语和关系当做key,把宾语当做value
       :param batch_examples: minibatch的数据
       :param maxlen: 各项的最大长度,其中key和value的最大长度取得是其长度和memory_slot中的最小值
       :param batch_size: 
       :return:  
       '''
       column_key = []
       column_val = []
       for i in xrange(batch_size):
           example_length = len(batch_examples[i]['sources'])
           memories_key = []
           memories_val = []
           src = batch_examples[i]['sources']
           rel = batch_examples[i]['relations']
           tar = batch_examples[i]['targets']
           if maxlen['keys'] > example_length:
               #pad sources, relations and targets in each example
               src = pad(src, maxlen['keys'])
               rel = pad(rel, maxlen['relations'])
               tar = pad(tar, maxlen['targets'])
               example_indices_to_pick = range(len(src))
           else:
               example_indices_to_pick = random.sample(range(example_length), maxlen['keys'])

           for memory_index in example_indices_to_pick:
               memories_key.append(np.array([src[memory_index], rel[memory_index]]))
               memories_val.append(tar[memory_index])

           num_ans = len(batch_examples[i]['ans_entities'])
           for j in xrange(num_ans):
               column_key.append(np.array(memories_key))
               column_val.append(np.array(memories_val))
       return np.array(column_key), np.array(column_val)

2,模型构建

处理完数据,接下来我们看一下模型搭建部分的代码,其实和End-To-End的代码很相似,只是稍微改了下以适应Key-Value Memory的形式,代码如下所示,已经做了相应的注释,对照论文应该很容易理解,不再详细介绍。

def position_encoding(sentence_size, embedding_size):
       """
       Position Encoding described in section 4.1 [1]
       """
       encoding = np.ones((embedding_size, sentence_size), dtype=np.float32)
       ls = sentence_size+1
       le = embedding_size+1
       for i in range(1, le):
           for j in range(1, ls):
               encoding[i-1, j-1] = (i - (le-1)/2) * (j - (ls-1)/2)
       encoding = 1 + 4 * encoding / embedding_size / sentence_size
       return np.transpose(encoding)

   class KVMemNN():

       def __init__(self, batch_size, vocab_size, entity_size, query_size,
                    memory_slot, embedding_size, hops=2, l2_lambda=None, name='KeyValueMenN2N'):
           self.batch_size = batch_size
           self.vocab_size = vocab_size
           self.query_size = query_size
           self.entity_size = entity_size
           self.memory_slot = memory_slot
           self.embedding_size = embedding_size
           self.hops = hops
           self.name = name
           self.encoding = tf.constant(position_encoding(self.query_size, self.embedding_size), name="encoding")
           self.build_inputs()

           with tf.variable_scope('query_encoded'):
               #将Question进行embedding映射,并通过position_encoded表示为一个向量的形式
               query_embed = tf.nn.embedding_lookup(self.A, self.query)
               query_encoded = tf.reduce_sum(query_embed*self.encoding, axis=1) #[None, query_size, embedding_size]-->[None, embedding_size]

           #self.q,因为有多个hop,所以使用列表来保存每层的输入
           self.q = [query_encoded]

           with tf.variable_scope('key_value_encoded'):
               #对key和value进行embedding编码,因为这里key并没有句子的结构形式,所以直接使用BOW表示成一个向量的形式,而value只有一个单词,就不用表示了
               key_embed = tf.nn.embedding_lookup(self.A, self.key)
               key_encoded = tf.reduce_sum(key_embed, axis=2) #[None, memory_size, 2, embedding_size]-->[None, memory_size, embedding_size]
               value_embed = tf.nn.embedding_lookup(self.A, self.value) #[None, memory_size, embedding_size]

           for i in range(self.hops):
           #共hops层
               with tf.variable_scope('hops_{}'.format(i)):
                   #使用softmax得到Question与key memory之间的相关性。也就是论文中的“Key Addressing”部分
                   q_temp = tf.expand_dims(self.q[-1], axis=-1) #[None, embedding_size, 1]
                   q_temp = tf.transpose(q_temp, [0, 2, 1]) #[None, 1, embedding_szie]
                   # softmax get the prob
                   p = tf.nn.softmax(tf.reduce_sum(key_encoded*q_temp, axis=2)) #[None, memory_size]

                   #根据p,进行“Value Reading”使用value memory计算输出
                   p_temp = tf.transpose(tf.expand_dims(p, axis=-1), [0, 2, 1]) #[None, 1, memory_size]
                   value_temp = tf.transpose(value_embed, [0, 2, 1]) #[None, embedding_size, memory_size]
                   o = tf.reduce_sum(value_temp*p_temp, axis=2) #[None, embedding_szie]

                   #计算下一层的输入,使用R对q进行映射,然后加上o
                   R_temp = self.Rs[i]
                   q_next = tf.matmul(self.q[-1], R_temp) + o
                   self.q.append(q_next)

           with tf.name_scope("output"):
               #输出层,
               self.out = self.q[-1]
               logits = tf.matmul(self.out, self.B) #[None, entity_size]

           with tf.name_scope("loss"):
               cross_entropy = tf.reduce_mean(
                   tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=self.answer, name='loss'))

               if l2_lambda:
                   vars = tf.trainable_variables()
                   lossL2 = tf.add_n([tf.nn.l2_loss(v) for v in vars])
                   self.loss = cross_entropy + l2_lambda * lossL2
               else:
                   self.loss = cross_entropy

           with tf.name_scope("accuracy"):
               #计算精确度
               #probs = tf.nn.softmax(tf.cast(logits, tf.float32))
               self.predict = tf.argmax(logits, 1, name="predict")
               correct_predictions = tf.equal(self.predict, tf.argmax(self.answer, 1))
               self.accuracy = tf.reduce_mean(tf.cast(correct_predictions, "float"), name="accuracy")

       def build_inputs(self):
           self.query = tf.placeholder(tf.int32, [None, self.query_size], name='query')
           #self.query_entity = tf.placeholder(tf.int32, [None, self.query_entity_size], name='query_entity')
           self.answer = tf.placeholder(tf.float32, [None, self.entity_size], name='answer')
           self.key = tf.placeholder(tf.int32, [None, self.memory_slot, 2], name='key_memory')
           self.value = tf.placeholder(tf.int32, [None, self.memory_slot], name='value_memory')
           #self.keep_dropout = tf.placeholder(tf.float32, name='keep_dropout')

           self.A = tf.get_variable('A', [self.vocab_size, self.embedding_size], initializer=tf.contrib.layers.xavier_initializer())
           self.B = tf.get_variable('B', [self.embedding_size, self.entity_size], initializer=tf.contrib.layers.xavier_initializer())
           self.Rs = []

           for i in range(self.hops):
               R = tf.get_variable('R_{}'.format(i), [self.embedding_size, self.embedding_size], initializer=tf.contrib.layers.xavier_initializer())
               self.Rs.append(R)

3,模型训练

这部分的代码也是直接借用denny的CNN部分的训练代码,来看一下最后的训练结果。上面给出的第二个链接里面的模型我测试了一下,很慢,原因在于其模型很多地方有重复计算的部分,十一放假之前房在服务器上跑,回来了才跑了7个epoch==,而且模型最终也没有收敛,效果比较差。然后回来改了改,用我自己的模型跑了跑,速度提升了很多,基本上一个小时可以跑一个epoch的样子,但是还是没有办法收敛,loss一直在10左右徘徊,acc也一直在0.3以下的样子。大概看了一下tensorboard上面的曲线,感觉是B上出了问题,如下图。可以看出来B有很多的尖峰出现,然后我尝试加了下梯度截断,效果好像并不是很明显。所以决定尝试着改小一点学习率看看是否能改善收敛问题。其次就是,在模型定一部分,模型输出我的定义总感觉哪里有点问题的样子,也就是B矩阵的使用,跟论文中有一定的区别。回头也尝试着改改这块,后续的调参和模型改进就关注我的github吧, 因为这个东西比较耗时,就慢慢来,等待着什么时候能有比较好的效果吧,虽然不知道能不能等来。。下面上几张图:


模型架构图:

loss曲线:

acc曲线:

B矩阵的尖峰:

编辑于 2017-10-15

文章被以下专栏收录