RNN時序推薦的論文總結及Recsys數據集上的實驗

RNN時序推薦的論文總結及Recsys數據集上的實驗

關於Session-Based推薦和RNN在推薦中在近年陸續出現,本文主要是受Session-Based Recommendation With Recurrent Neural Networks這篇文章的啟發,它出現相對較早,發表於ICLR 2016.

當中最吸引眼球的字眼可能是RNN,但對於工業界而言,能解決什麼問題比使用了什麼新模型更具備實際意義。就這個角度而言,最重要的反而是將行為數據中的時序信息考慮到模型中。

當前主流推薦演算法都將用戶行為看作是順序可互換的,但在特定的一些場景下,用戶行為的先後順序蘊含著與其偏好或意圖相關的重要信息。比如在圖書領域,大部分用戶可能先關注某作者的一些暢銷作品,漸漸了解作者之後就開始看一些相對小眾的更能體現作者特點的作品。又比如用戶網購時在幾個商品之間的跳轉順序能反映出她/他的意圖。

在RNN之前也有一些簡單模型可以做序列建模。比如序列關聯規則,它找出如 A 
ightarrow B 
ightarrow C 這樣的頻繁子序列,箭頭表示的是行為的發生順序,意味著依次點擊(/購買/安裝...) A,B,C 這三個App是個有統計學意義的規律,可以依此作出推薦。若干年前在做一個應用分發場景的推薦時就是採用的序列關聯規則,當時考慮到App的場景和圖書一樣有「漸進性」,比如大部分用戶一開始會安裝一些「裝機必備」的熱門App,後面才開始探索某個類目下的深度App。但序列關聯規則的缺點是它通過簡單的計數總結出規律,這些規律對於目標而言不一定是最優的,這限制了它的效果,印象中當時這個模型上線之後效果的確也只是與itemcf差不多。

在逼近真實優化目標這點上RNN顯然比序列關聯規則好,而它的模型形式也保證了擬合能力,因此是一個值得關注的方向。以下以一個極簡的推薦場景為背景,總結RNN做序列建模時在模型結構、損失函數這兩方面上的一些思路。

RNN與推薦場景如何結合

類似於RNN在文本領域中根據目前讀到的詞來預測下一個詞是什麼,RNN在推薦中要做的是根據用戶歷史點擊過的物品ID以其及中的順序,來預測她/他下一個會點的是哪個物品ID。注意到這時候僅用到用戶對物品ID的行為,因此可以看作是協同過濾的擴展。

假設全量物品ID集合為 a,b,c,d ,並收集到如下點擊序列:

SESSIONID|點擊發生時間|物品ID

0|2018-01-01 15:00|a

0|2018-01-01 15:10|b

0|2018-01-01 15:12|c

1|2018-01-01 15:10|a

1|2018-01-01 15:14|c

目標是在給出時刻 1:t-1 的點擊序列 s ,去預測時刻 t 會點擊 a,b,c,d 中的哪一個物品,比如訓練數據中出現輸入 s=a 輸出 b 、輸入 s=(a,b) 輸出 c 這些現象。將全量物品ID集合大小記為 K .於是可以採用包含輸入層、一個隱藏層、輸出層的循環網路結構來進行建模:

  • 輸入層長度為全量物品ID集合的大小,在這裡是4
  • 隱藏層是有2個神經元的RNN單元,可以是經典RNN、LSTM或GRU
  • 輸出層長度同樣為全量物品ID集合的大小4
  • RNN循環次數等於序列長度,比如SESSIONID=0時循環3次

假設隱藏層採用的是RNN單元,輸入物品ID為 b 時得到預測分數的過程如下:

  1. 模型首先通過one-hot編碼得到長度為4的輸入Tensor記為 x_t
  2. 經過大小為 [2,4]W_h 變換之後得到當下輸入的信息 W_h x_t
  3. 經過大小為 [4,4]U 變換之後得到前一個時刻的狀態相對於當下的信息 Uh_{t-1}
  4. Uh_{t-1}W_h x_t 相加之後經過激活函數 sigma ,得到當下的狀態 h_t
  5. h_t 經過一個全連接得到預測分數 sigma(W_f h_t)

當使用GRU而不是經典RNN單元時結構類似,只是在計算當下狀態 h_t時用兩個「門」來控制歷史和當下的比重,如下圖所示:

GRU可以看成是三個大步驟:

  1. 第一步是計算兩個門,reset gate r_t 和update gate z_t ,它們都是關於 h_{t-1}x_t 的函數,形式類似, r_t=sigma(W_rx_t + U_rh_{t-1})z_t=sigma(W_zx_t + U_zh_{t-1})
  2. 第二步是計算candidate activation function hat{h_t} ,這與傳統RNN計算當下狀態 h_t類似,只不過在得到 hat{h_t} 時歷史狀態 h_{t-1}要先與reset gate r_t 作element-wise乘法,決定在這一步歷史信息是否被清零,即 hat{h}_t=sigma(W_hat{h}x_t + U_hat{h}(h_{t-1} odot r_t))
  3. 第二步是根據update gate對candidate activation function hat{h_t}和歷史狀態 h_{t-1}作線性加權求和,得到最終的當下狀態 h_{t}h_t=(1-z_t)odot h_{t-1}+z_todot hat{h}_t

容易看出由於引入了兩個門,因此GRU比經典RNN多了2組 (W,U) 參數。

值得一提的是Session-Based Recommendation With Recurrent Neural Networks中用了兩個開源數據集做實驗,結果都是GRU比經典RNN或LSTM效果更好,而該論文的作者也嘗試過將輸入層由one-hot編碼換成embedding、加多幾層RNN、每個時刻不僅輸入 x_t 而是輸入 (x_1, ..., x_t) 的線性加權向量等做法,他們的實驗結果是都不如one-hot編碼、只用一層RNN以及只用 x_t .

定義損失函數

不管是採用哪種循環單元,都是為了得到當下狀態 h_th_t 經過一個全連接得到各個物品ID下的預測分數 sigma(W_f h_t)。損失函數就是定義在預測分數和真實輸出上。

將截至當前時刻的1:t 的序列記為 s ,那麼激活前的預測分數 W_f h_t 的第 k 個元素可以相應地記為 hat{r_{s,k}},指的是輸入為 s 的條件下輸出為第 k 個物品ID的分數。

假如將該問題看成是多分類問題的話, sigma 可以採用softmax激活函數,這樣 sigma(W_f h_t) 代表的是輸入為 s 的條件下輸出為 k 預估概率

P(x^{(t+1)}=k)=frac{e^{hat{r_{s,k}}}}{sum_{k=1}^K e^{hat{r_{s,k}}}}

從而得到一個樣本點上的交叉熵損失

-y^{(x+1)}logP(x^{(t+1)}=k)

當物品量很大時,看成多分類問題並採用softmax的話效率較低,因此一般採用Negative Sampling的思想(這在word2vec中也有所體現,算是一種常見的手法了)。

首先將多分類看成是多個二分類問題,sigma(W_f h_t) 中的  sigma 就不再是以向量整體為輸入的softmax激活函數,而是分別作用在各個元素上的sigmoid了,比如同樣是到輸入為 s 的條件下輸出為 k 預估概率:

P(x^{(t+1)}=k)=frac{1}{1 + e^{-hat{r_{s,k}}}}

這樣相比起 frac{e^{hat{r_{s,k}}}}{sum_{k=1}^K e^{hat{r_{s,k}}}}frac{1}{1 + e^{-hat{r_{s,k}}}} 代表的含義就不再衡量點擊 K 個物品ID中的第 k 個還是其它的 K-1 個的可能性更大,而是對於K 個物品ID中的第 k 個,點擊還是不點擊的概率更大。

假設輸入為 s 的條件下輸出為的確為 k,那麼正樣本點對應的交叉熵損失為 -logsigma(hat{r}_{s,k}) ,再從其它K-1 個負樣本中抽樣若干個負樣本記為 N_s ,共同組成損失函數

-logsigma(hat{r}_{s,k})-sum_{kin N_s}logsigma(-hat{r}_{s,k})

這樣相當於是將推薦看成是排序問題,並基於正負樣本得到point-wise損失,希望使得正樣本的預測得分 hat{r}_{s,k} 儘可能高,而負樣本的預測得分 hat{r}_{s,k} 儘可能低。

但一般而言,排序問題中的pair-wise損失比point-wise更能逼近排序度量,它們的目標是使得正樣本上的得分比負樣本的得分高,這就和AUC所衡量的目標比較接近了。

Session-Based Recommendation With Recurrent Neural Network中將 hat{r}_{s,k} > hat{r}_{s,k} 定義為binary變數,取值為1的預估概率為 sigma(hat{r}_{s,k} - hat{r}_{s,k}) 。對於一個正樣本點只需要抽樣出一個負樣本點和它結對,從而得到損失函數為

-frac{1}{N}sum_{j=1}^{N_s}log(sigma(hat{r}_{s,k} - hat{r}_{s,k})))

Session-Based Recommendation With Recurrent Neural NSession-Based在抽樣負樣本時基於物品ID的熱門程度來抽樣,這種做法在推薦中也是很常見的。

這樣做的本質原因在於協同過濾場景下很難得知用戶確切不喜歡哪個物品,她/他對物品ID沒有點擊行為可能是由於她/他沒看到過這個物品ID導致的,而熱門物品被用戶看到的可能性比較大,因此用戶不點擊某個熱門物品就有更大的把握認為她/他不喜歡這個物品,從而可以用作負樣本。

Recsys數據集上的實驗

實驗用的還是論文中使用的其中一個數據集,來自Recsys Chanllenge 2015,是電商網站的數據,包含帶時間戳和SESSEION_ID的一系列點擊或購買行為。原本的任務是根據這些數據預測用戶接下來是否會購買、如果會購買的話買什麼。

但這裡只用它的點擊行為數據,並且將任務定義為根據用戶目前的點擊行為流,預測他下一個點擊的是什麼。損失函數採用上文提到的pairwise損失

-frac{1}{N}sum_{j=1}^{N_s}log(sigma(hat{r}_{s,k} - hat{r}_{s,k})))

暫時只取了小量級數據來實驗,發現要迭代比較多個epoch才能在訓練集上有較好的擬合效果,而且當截取的序列越長更是這樣。

另外是對負樣本採樣得到的pair-wise損失,數據量少時比較依賴採樣結果。比如說總共只有 1,2,3 這3個物品ID,只包含正樣本的點擊序列是

1,2|2,3|1,2|2,3

那麼當負樣本採樣正好把「真正的負樣本」補上去時效果是最好的,即當

1,2,1|2,3,1|1,2,3|2,3,2

這也許是說明在數據少時直接當成多分類來做就好。

實驗做得有點粗糙,目前程序最大的瓶頸還在訓練樣本構造上,明顯的問題一是純Python代碼處理性能低(其它用到TF的生產任務是用Spark做數據處理的唔.. 可以考慮)、還不支持同一個時間戳下m個樣本做batch或batch間序列長度不一致。

附實驗代碼

# coding=utf-8import tensorflow as tfimport randomdef construct_pos_samples(in_path, out_path, max_session_length): """ 從原始日誌提取點擊序列 原始日誌格式(默認在單個SESSION內已經按時間升序排列、去掉長度為1的SESSION): SESSION_ID,時間戳,ITEM_ID, 物品類型 8,2014-04-06T08:52:12.647Z,214838855,0 對長度為N的SESSION提取長度為 max_session_length 的點擊序列,max_session_length < N 提取出來格式為: t-1時刻的ITEM_ID,t時刻ITEM_ID :param in_path: 原始日誌路徑 :param out_path: 寫出文件路徑 :param max_session_length: 點擊序列長度最大值 :return: 物品候選集、總SESSION數目 """ item_set = dict() session_length = 0 all_session_id = set() with open(out_path, w) as out_f: with open(in_path, r) as in_f: last_session_id = 0 last_item_id = "" candidate_size = 0 for line in in_f.readlines(): session_id, time_stmp, item_id, category = line.strip().split(",") session_id = int(session_id) all_session_id.add(session_id) if session_id == last_session_id and session_length < max_session_length: session_length += 1 out_f.write("{0} {1}
".format(last_item_id, item_id)) elif session_id != last_session_id: session_length = 0 last_session_id = session_id last_item_id = item_id item_set.setdefault(item_id, candidate_size) candidate_size = len(item_set) out_f.flush() return item_set, len(all_session_id)def construct_pos_neg_sample(in_path, out_path, item_set): """ 根據點擊序列的正樣本採樣負樣本,在去除正樣本ITEM_ID的候選集中按均勻分布隨機抽取 """ all_item = set(item_set.keys()) with open(out_path, w) as write_f: with open(in_path) as read_f: for line in read_f.readlines(): x, y = line.strip().split(" ") neg_y = random.choice(list(all_item.difference({y}))) write_f.write("{0} {1} {2}
".format(item_set[x], item_set[y], item_set[neg_y])) write_f.flush()def construct_tfrecord_sample(in_path, out_path): tfrecord_write = tf.python_io.TFRecordWriter(out_path) with open(in_path, r) as read_f: for line in read_f.readlines(): x, y, neg_y = line.strip().split(" ") one_example = tf.train.Example(features=tf.train.Features( feature= { x_val: tf.train.Feature(float_list=tf.train.FloatList(value=[1.0])), x_idx: tf.train.Feature(int64_list=tf.train.Int64List(value=[int(x)])), y_val: tf.train.Feature(float_list=tf.train.FloatList(value=[1.0])), y_idx: tf.train.Feature(int64_list=tf.train.Int64List(value=[int(y)])), neg_y_val: tf.train.Feature(float_list=tf.train.FloatList(value=[1.0])), neg_y_idx: tf.train.Feature(int64_list=tf.train.Int64List(value=[int(neg_y)])) } )) tfrecord_write.write(one_example.SerializeToString()) tfrecord_write.flush() tfrecord_write.close()def sample_generator(path, candidate_size, num_repeat): serialized_example = tf.data.TFRecordDataset(path) serialized_example = serialized_example.batch(batch_size) serialized_example = serialized_example.repeat(num_repeat) parsed_examples = serialized_example.map(lambda _: _parse_example_to_dense(_, candidate_size)) return parsed_examples.make_one_shot_iterator()def _parse_example_to_dense(batch_serialized_example, candidate_size): feature_schema = { x: tf.SparseFeature(index_key="x_idx", value_key="x_val", dtype=tf.float32, size=candidate_size), y_idx: tf.FixedLenFeature([], dtype=tf.int64), neg_y_idx: tf.FixedLenFeature([], dtype=tf.int64) } parsed_example = tf.parse_example(batch_serialized_example, features=feature_schema) one_x = tf.sparse_tensor_to_dense(parsed_example[x]) return one_x, parsed_example[y_idx], parsed_example[neg_y_idx]if __name__ == __main__: MAX_SESSION_LENGTH = 1 data_dir = "/deeplearning1/wumengling/rnn" click_stream_path = data_dir + /yoochoose-clicks.dat.small pos_sample_path = data_dir + /click_stream.samples.pos sample_path = data_dir + /click_stream.samples tfrecord_path = data_dir + /click_stream.tfrecord item_candidate, num_session = construct_pos_samples(click_stream_path, pos_sample_path, MAX_SESSION_LENGTH) print([len(item_candidate), num_session]) construct_pos_neg_sample(pos_sample_path, sample_path, item_candidate) construct_tfrecord_sample(sample_path, tfrecord_path) category_size = len(item_candidate) print(category_size) batch_size = 1 num_hidden_units = 4 epoch = 100 gru = tf.nn.rnn_cell.GRUCell(num_units=num_hidden_units) train_state = tf.zeros([batch_size, num_hidden_units]) # 狀態到輸出層的連接權重 w = tf.Variable(tf.zeros([num_hidden_units, category_size])) b = tf.Variable(tf.zeros([1, category_size])) train_iterator = sample_generator(tfrecord_path, category_size, epoch) train_session_loss = 0.0 for i in range(MAX_SESSION_LENGTH): if i == 0: train_state = tf.zeros([batch_size, num_hidden_units]) train_session_loss = 0.0 # 每個SESSION開始時將狀態置為0 train_x, train_y, train_neg_y = train_iterator.get_next() with tf.device(/gpu:0): train_y = tf.cast(train_y, tf.int32) train_neg_y = tf.cast(train_neg_y, tf.int32) _, train_state = gru(tf.reshape(train_x, [batch_size, category_size]), train_state) # 由於用pair-wise損失,因此將正負樣本ITEM_ID對應的權重抽取出來 pos_w = tf.reshape(w[:, train_y[0]:(train_y[0] + 1)], [num_hidden_units, 1]) pos_b = tf.reshape(b[:, train_y[0]:(train_y[0] + 1)], [1, 1]) neg_w = tf.reshape(w[:, train_neg_y[0]:(train_neg_y[0] + 1)], [num_hidden_units, 1]) neg_b = tf.reshape(b[:, train_neg_y[0]:(train_neg_y[0] + 1)], [1, 1]) # 輸出層的pair-wise損失 train_session_loss += tf.nn.sigmoid_cross_entropy_with_logits( labels=tf.reshape([1.0], [batch_size, 1]), logits=tf.reshape(tf.sigmoid( (tf.matmul(train_state, pos_w) + pos_b) - (tf.matmul(train_state, neg_w) + neg_b) ), [batch_size, 1])) adagrad_opt = tf.train.AdagradOptimizer(learning_rate=0.01) train_op = adagrad_opt.minimize(train_session_loss) true_pred_label = [] eval_iterator = sample_generator(tfrecord_path, category_size, epoch) eval_state = tf.zeros([batch_size, num_hidden_units]) for i in range(MAX_SESSION_LENGTH): if i == 0: eval_state = tf.zeros([batch_size, num_hidden_units]) eval_x, eval_y, eval_neg_y = eval_iterator.get_next() eval_y = tf.cast(eval_y, tf.int32) eval_neg_y = tf.cast(eval_neg_y, tf.int32) _, eval_state = gru(tf.reshape(eval_x, [batch_size, category_size]), eval_state) # 對於只取top1分類結果來說,用softmax當成多分類預測或用sigmoid當成多個二分類預測的效果是一樣的 prob = tf.nn.softmax(tf.matmul(eval_state, w) + b) true_pred_label.append([eval_y[0], tf.arg_max(prob, dimension=1)[0]]) init_op = tf.initialize_all_variables() with tf.Session() as sess: sess.run(init_op) for i in range(50): print(sess.run([train_op, train_session_loss])) for i in range(50): print(sess.run(true_pred_label))

參考資料

SESSION-BASED RECOMMENDATIONS WITH RECURRENT NEURAL NETWORKS

其中兩位論文作者Alenxandros Karatzoglou、Balazs Hidasi的演講視頻:

youtube.com/watch?

youtube.com/watch?

Learning Deep Structured Semantic Models for Web Search using Clickthrough Data

From RankNet to LambdaRank to LambdaMART: An Overview

Sequence to Sequence Learning with Neural Networks

colah.github.io/posts/2

tensorflow.org/tutorial


推薦閱讀:

TAG:推薦系統 | 深度學習DeepLearning | 機器學習 |