DKT模型及其TensorFlow實現(Deep knowledge tracing with Tensorflow)

Kintoki發表於2021-12-25

2017年2月15日,谷歌舉辦了首屆TensorFlow Dev Summit,並且釋出了TensorFlow 1.0 正式版。 3月18號,上海的谷歌開發者社群(GDG)組織了針對峰會的專場回顧活動。本文是我在活動上分享的一些回顧,主要介紹了在流利說我們是如何使用TensorFlow來構建學生模型並應用在自適應系統裡面的。首發於流利說技術團隊公眾號原文連結

一、應用背景

自適應學習是什麼

自適應學習是現在教育科技領域談得比較多的一個概念,它的核心問題可以用一句話概括,即通過個性化的規劃學習路徑,提高學生的學習效率。為什麼需要自適應學習?在傳統的教學過程中,每個學生的學習路徑是一致的,由於學生個人基礎和學習能力的差異性,這種千人一面的做法對大部分學生來說這種方式其實比較低效。由此我們很自然的可以想到:如果我們能夠根據學生的能力,去匹配合適的教學內容,就應該可以提高他們的學習效率。而這正是自適應系統希望達成的目標。

學生模型

那麼自適應學習是如何達成這個目標的呢?這包含了兩個核心問題,首先是學生的能力的評估,正確的評估學生的能力是後續一切工作的基礎,這是學生模型關心的問題。 其次是在評估好學生的能力後,如何推送合適的內容,這是教學模型所關心的問題。本篇文章我們來講講如何利用TensorFlow來構建學生模型。

為了選擇一個合適的學生模型,首先需要了解學生學習的過程。一個典型的學習過程是一個時間序列,使用者在這個時間序列的各個時刻進行了一些學習行為,從而提高了自身的能力。我們可以假設學生的能力是可以通過學生在各個時刻回答問題的對錯來反映的。要注意的是,由於學生學習時間的跨度可能很大,不能認為學生的水平保持不變,所以直接使用一些評測的方法(做了學生能力不變的假設)是不合適的。

Deep Knowledge Tracing

為了對學習序列建模,並評估學生各個時刻的能力,我們採用了Deep Knowledge Tracing(DKT)模型,這個模型是由Stanford大學的Piech Chris等人在NIPS 2015發表的,其本質是一個Seq2Seq的RNN模型,我們來看下模型的結構圖:

DKT模型及其TensorFlow實現(Deep knowledge tracing with Tensorflow)

上圖是DKT模型按照時間展開的示意圖,其輸入序列\(x_1, x_2, x_3 ...\)對應了\(t_1, t_2, t_3 ...\)時刻學生答題資訊的編碼,隱層狀態對應了各個時刻學生的知識點掌握情況,模型的輸出序列對應了各時刻學生回答題庫中的所有習題答對的概率。

DKT模型及其TensorFlow實現(Deep knowledge tracing with Tensorflow)

現在以上圖為例來看看模型的各層結構。簡單起見,假設題庫總共有4道習題,那麼首先可以確定的輸出層的節點數量為4,對應了各題回答正確的概率。接著,如果我們對輸出採用one-hot編碼,輸入層的節點數就是題目數量 * 答題結果 = 4 * 2 = 8個。首先將輸入層全連線到RNN的隱層,接著建立隱層到輸出層的全連線,最後使用Sigmoid函式作為啟用函式,一個基礎的DKT模型就構建完畢了。接著為了訓練模型,定義如下的損失函式:

\[\tag{1} L = \sum_{t}\ell(y^{T}\delta(q_{t+1}), a_{t+1}) \]

其中\(y\)\(t\)時刻的模型預測輸出,\(q_{t+1}\)\(t+1\)時刻使用者回答的題目ID(one-hot向量),\(a_{t+1}\)\(t+1\)時刻的使用者答題的對錯, \(\ell\)binary cross entropy損失函式。下面我們用幾十行TensorFlow程式碼來實現一下這個模型。

二、模型構建

首先初始化模型引數,並且用tf.placeholder來接收模型的輸入:

# encoding:utf-8
import tensorflow as tf


class TensorFlowDKT(object):
  def __init__(self, config):
    self.hidden_neurons = hidden_neurons = config["hidden_neurons"]
    self.num_skills = num_skills = config["num_skills"]  # 題庫的題目數量
    self.input_size = input_size = config["input_size"]  # 輸入層節點數,等於題庫數量 * 2
    self.batch_size = batch_size = config["batch_size"]
    self.keep_prob_value = config["keep_prob"]

    # 接收輸入
    self.input_data = tf.placeholder(tf.float32, [batch_size, None, input_size])  # 答題資訊
    self.sequence_len = tf.placeholder(tf.int32, [batch_size])  # 一個batch中每個序列的有效長度
    self.max_steps = tf.placeholder(tf.int32)  # max seq length of current batch.
    self.keep_prob = tf.placeholder(tf.float32)  # dropout keep prob

    # 接收標籤資訊
    self.target_id = tf.placeholder(tf.int32, [batch_size, None]) # 回答的題目ID
    self.target_correctness = tf.placeholder(tf.float32, [batch_size, None]) # 答題對錯情況

接著構建RNN層:

# create rnn cell
hidden_layers = []
for idx, hidden_size in enumerate(hidden_neurons):
  lstm_layer = tf.contrib.rnn.BasicLSTMCell(num_units=hidden_size, state_is_tuple=True)
  hidden_layer = tf.contrib.rnn.DropoutWrapper(cell=lstm_layer,output_keep_prob=self.keep_prob)
  hidden_layers.append(hidden_layer)
  self.hidden_cell = tf.contrib.rnn.MultiRNNCell(cells=hidden_layers, state_is_tuple=True)

  # dynamic rnn
  state_series, self.current_state = tf.nn.dynamic_rnn(cell=self.hidden_cell, inputs=self.input_data, sequence_length=self.sequence_len,dtype=tf.float32)

這裡我們用tf.dynamic_rnn構建了一個多層迴圈神經網路,cell引數用來指定了隱層神經元的結構,sequence_len參數列示一個batch中各個序列的有效長度。state_series表示隱層的輸出,是一個三階的Tensor,self.current_state表示batch各個序列的最後一個step的隱狀態。

輸出層:

# output layer
output_w = tf.get_variable("W", [hidden_neurons[-1], num_skills])
output_b = tf.get_variable("b", [num_skills])
self.state_series = tf.reshape(state_series, [batch_size*self.max_steps, hidden_neurons[-1]])
self.logits = tf.matmul(self.state_series, output_w) + output_b
self.mat_logits = tf.reshape(self.logits, [batch_size, self.max_steps, num_skills])
self.pred_all = tf.sigmoid(self.mat_logits)  # predict 輸出

輸出層我們構建了兩個變數作為隱層到輸出層的連線的引數,並用tf.sigmoid作為啟用函式。到這裡我們已經可以得到模型的預測輸出self.pred_all,這也是一個三階的張量,shape(batch_size, self.max_steps, num_skills)

為了訓練模型,還需要計算模型損失函式和梯度,我們結合預測和標籤資訊來獲得損失函式:

# compute loss
flat_logits = tf.reshape(self.logits, [-1])
flat_target_correctness = tf.reshape(self.target_correctness, [-1])
flat_base_target_index = tf.range(batch_size * self.max_steps) * num_skills
flat_bias_target_id = tf.reshape(self.target_id, [-1])
flat_target_id = flat_bias_target_id + flat_base_target_index
flat_target_logits = tf.gather(flat_logits, flat_target_id)
self.pred = tf.sigmoid(tf.reshape(flat_target_logits, [batch_size, self.max_steps]))
self.binary_pred = tf.cast(tf.greater_equal(self.pred, 0.5), tf.int32)
loss = tf.nn.sigmoid_cross_entropy_with_logits(labels=flat_target_correctness, logits=flat_target_logits)
self.loss = tf.reduce_sum(loss)

獲得梯度並更新引數:

self.lr = tf.Variable(0.0, trainable=False)
trainable_vars = tf.trainable_variables()
self.grads, _ = tf.clip_by_global_norm(tf.gradients(self.loss, trainable_vars), 4)
optimizer = tf.train.GradientDescentOptimizer(self.lr)
self.train_op = optimizer.apply_gradients(zip(self.grads, trainable_vars))

需要注意的是,在用tf.gradients得到梯度後,我們使用了tf.clip_by_global_norm方法,這主要是為了防止梯度爆炸的現象。最後應用了一次梯度下降得到的self.train_op就是計算圖的訓練結點。得到訓練結點後,我們的計算圖(Graph)就已經構造完畢,接著只需要建立一個tf.Session物件,並呼叫其run()方法來執行計算圖就可以進行模型訓練和測試了。由於訓練和測試的接收的feed_dict類似,我們定義step方法來用作訓練和測試,如下:

def step(self, sess, input_x, target_id, target_correctness, sequence_len, is_train):
  _, max_steps, _ = input_x.shape
  input_feed = {self.input_data: input_x,
                self.target_id: target_id,
                self.target_correctness: target_correctness,
                self.max_steps: max_steps,
                self.sequence_len: sequence_len}
  if is_train:
    input_feed[self.keep_prob] = self.keep_prob_value
    train_loss, _, _ = sess.run([self.loss, self.train_op, self.current_state], input_feed)
    return train_loss
  else:
    input_feed[self.keep_prob] = 1
    bin_pred, pred, pred_all = sess.run([self.binary_pred, self.pred, self.pred_all], input_feed)
    return bin_pred, pred, pred_all

定義assign_lr方法來設定學習率:

def assign_lr(self, session, lr_value):
session.run(tf.assign(self.lr, lr_value))

至此,TensorFlowDKT類就構造完畢了,我們可以這樣使用它:

# process data ...

# config and create model
# config = ...
model = TensorFlowDKT(config)

# create session and init all variables
sess = tf.Session()
sess.run(tf.global_variables_initializer())

for i in range(num_epoch):
  # train
  # lr_value = ...
  model.assign_lr(sess, lr_value)  # assign learning rate
  train_generator.shuffle()  # random shuffle before each epoch
  while not train_generator.end:
    input_x, target_id, target_correctness, seqs_len, _ = train_generator.next_batch()
    overall_loss += model.step(sess, input_x, target_id, target_correctness, seqs_len, is_train=True)
  # test
  while not test_generator.end:
    input_x, target_id, target_correctness, seqs_len, _ = test_generator.next_batch()
    binary_pred, pred, _ = model.step(sess, input_x, target_id, target_correctness, seqs_len, is_train=False)
  # calculate metrics ...

Demo的完整程式碼,見https://github.com/lingochamp/tensorflow-dkt

三、工程實踐

流利說的懂你英語課程,到16年12月份為止,已經積累了數億量級使用者答題資料,在處理這些資料優化模型指標的過程中,我們也積累了一些實踐經驗。

Truncated BPTT

我們收集到的學習資料裡,最長的序列長度超過五萬。出於計算效率的考慮,包括TensorFlow在內的多數深度框架在進行BPTT的時候都會將序列按照時間維度展開,這在序列長度達到五萬的情況下是不現實的(視訊記憶體會爆)。所以我們需要將長的序列切斷分為多個序列,然後儲存前一部分序列訓練的隱狀態作為接下來一部分序列的初始狀態輸入,這樣來進行長序列的訓練。

多GPU加速

當資料到達數億的量級以後,進行一次訓練已經需要比較多的時間了,這個時候我們可以通過多GPU並行來加速訓練。這裡我們使用Multi Tower結構,這是一種資料並行的多GPU方案,我們來看下它的示意圖:

DKT模型及其TensorFlow實現(Deep knowledge tracing with Tensorflow)

可以看到在Multi Tower結構裡,每個GPU持有一個模型例項,這些例項之間共享引數的。訓練開始後,每次我們將多個batch資料分別餵給各個模型例項,在各GPU裝置分別求得梯度資訊。 接著,我們將收集到的梯度返回到CPU,取平均以後,用來更新模型的引數。 由於模型的引數是共享的,這也就意味著所有模型例項的引數都得到了更新。接著我們來看下,以TensorFlowDKT類為例,我們如何用Multi Tower結構來構造訓練結點:

def multi_gpu_model(num_gpus=1, model_config=None):
  tower_grads = []
  for i in range(num_gpus):
    with tf.device("/gpu:%d" % i):
      with tf.name_scope("tower_%d" % i):  # 用name_scope區分各模型
        model = TensorFlowDKT(model_config)  # 為每個GPU構造一個模型例項
        tf.add_to_collection("train_model", model)  # add_to_collection,方便feed資料
        tower_grads.append(model.grads)  # 收集梯度資訊
        tf.add_to_collection("loss", model.loss)
    tf.get_variable_scope().reuse_variables()  # 重用同一作用域內的變數

  with tf.device("cpu:0"):
    averaged_gradients = average_gradients(tower_grads)  # 對返回的梯度取平均
    opt = tf.train.GradientDescentOptimizer(0.5)
    train_op = opt.apply_gradients(zip(averaged_gradients, tf.trainable_variables()))
  return train_op

其中average_gradients方法的程式碼可以參考https://github.com/tensorflow/models/blob/master/tutorials/image/cifar10/cifar10_multi_gpu_train.py 。接著我們構造一個方法來返回feed_dict

def generate_feed_dict(data_generator):
  feed_dict = {}
  models = tf.get_collection("train_model")  # get_collection,取回模型
  for model in models:
    inputs, target_id, target_correctness, seqs_len, max_len = data_generator.next_batch()
    feed_dict[model.input_data] = inputs
    feed_dict[model.target_id] = target_id
    feed_dict[model.target_correctness] = target_correctness
    feed_dict[model.max_steps] = max_len
    feed_dict[model.sequence_len] = seqs_len
    feed_dict[model.keep_prob] = model.keep_prob_value
  return feed_dict

最後由於dynamic_rnn的一些Operation尚不支援GPU,在訓練開始前,我們需要配置一下Session避免出錯:

sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True))

完成上面的步驟,我們就可以用多GPU來加速模型訓練了。

模型匯出

在流利說,學生模型訓練是用Python API完成的,而學生模型預測服務則是用C++實現的。關於如何從C++如何從Protobuf檔案中載入Graph可以參考https://www.tensorflow.org/tutorials/image_recognition。這裡有一個模型匯出的問題,即Python 中的tf.train.write_graph方法只能夠儲存模型的圖結構,而不能儲存變數的值到Protobuf檔案中。這個問題可以通過將Variables轉換為tf.constant來解決, tensorflow.python.tools.freeze_graph提供了這樣的方法。

結語

TensorFlow是一個十分簡單易用的機器學習框架,也是目前最流行的深度學習框架,它可以讓機器學習研究者更少的關注底層的問題,而專注於問題的解決和演算法的優化上。流利說演算法團隊從16年初開始就將TensorFlow應用到內部的機器學習專案裡面,積累了很多相關的使用經驗,從而幫助我們的用更智慧演算法來服務使用者。

References

  1. Piech, Chris, et al. "Deep knowledge tracing." Advances in Neural Information Processing Systems. 2015.
  2. https://www.tensorflow.org/
  3. https://github.com/tensorflow/tensorflow

相關文章