Micro Behaviors:A New Perspective in E-commerce Recommendation 文章閱讀以及代碼實驗【數據集來自京東2019年比賽數據】
概述
《Micro Behaviors:A New Perspective in E-commerce Recommendation》一文主要探究了微觀行爲對於預測任務的影響,爲了更加準確的描述用戶的每一個微觀行爲,文中提出了RIB模型,鑑於文中沒有公佈github代碼,所以我在這裏就根據文章的說法進行了簡單的復現。
這篇文章的優缺點都非常明顯,優點在於論文對於用戶的微觀行爲進行了非常詳細的統計分析,闡述了爲什麼要做微觀行爲的意義,在創新度和說服力上都非常強。
缺點在於這篇文章對於模型的介紹並不是十分清晰,前後使用的變量符號也不統一,讀起來就有點費解。在本blog中我根據自己的理解統一了一下符號含義,如有不對請多多指正!
這個blog主要的任務也就是在於還原代碼的實現過程,數據集來源:https://jdata.jd.com/html/detail.html?id=8 京東2019年的比賽數據集。
不是原po的數據集,因爲數據原因,對於模型也有些許變動,但是基本思路是一致的。
下面就開始實現代碼的部分啦!拿到數據集後, 大家一起加油吧!
論文解讀以及代碼實現過程分解
問題定義
- 商品集合 P = {},即有 N 個物品。
- 操作集合 A = {}, 即有M種操作。
- 停留時間集合 D = {}, 即有K種停留時間–作者人爲分成的K段,但是我們這裏沒有,所以沒有這個D。
任務:給定用戶的歷史操作數據即()的三元組序列,去預測下一個操作的物品。
ps:因爲我不太確定這個停留時間應該如何計算,所以這裏我沒有使用dk,只是輸入了()的二元組序列。
數據集介紹
原文的數據集介紹見原文,本blog只介紹我使用的數據集。~~~~嘿嘿😁
ps:原文中出現了商品的category id ,但是我看了一遍文章,也沒有看出來哪裏使用了item information。
數據預處理
然後因爲我做的其實是session recommend 時候實現的本文章,所以本文的所有處理邏輯也是session recommend,如果看官也是做session recommend 的話可以直接使用我處理的邏輯,不然的話可以不做這一部分的數據預處理,可以直接使用user id,即後面我使用session id 的地方,看官直接使用user id即可。
準備訓練集和測試集
一個數據我們需要準備4個東西:data_padding,data_operation_padding, data_masks, data_targets,因爲後面要用到item序列和operation序列,所以一起生成了,但是需要注意,訓練的時候只能使用訓練集中的item 和operation 序列:
基本代碼如下:
def construct_data(data,max_session_length,item2id,item_tail=['0'],item_col='sku_id',operation_col='type'):
session_dict = {}
data_masks = []
data_padding = []
data_operation_padding = []
data_targets = []
data_is = []
data_os = []
for index, row in tqdm(data.iterrows()):
session_id = row['sessionId']
item = str(row[item_col])
item = item2id[item] #item2dict key is str
operation = str(row[operation_col])
if session_id not in session_dict:
session_dict[session_id] = {}
session_dict[session_id]['items'] = []
session_dict[session_id]['operations'] = []
session_dict[session_id]['items'].append(item)
session_dict[session_id]['operations'].append(operation)
for sess in tqdm(session_dict.keys()):
items = session_dict[sess]['items']
operations =session_dict[sess]['operations']
data_is.append(items)
data_os.append(operations)
if len(items) > max_session_length: # 需要進行截斷
items = items[-max_session_length:]
operations = operations[-max_session_length:]
for i in range(1, len(items)):
tar = items[-i]
inputs = items[:-i]
inputs_operations = operations[:-i]
mask = [1] * len(inputs) + [0] * (max_session_length - len(inputs))
data_pad = inputs + item_tail * (max_session_length - len(inputs))
data_op_pad = inputs_operations+item_tail * (max_session_length - len(inputs))
data_masks.append(mask)
data_padding.append(data_pad)
data_operation_padding.append(data_op_pad)
data_targets += [tar]
data_processed = (data_padding,data_operation_padding, data_masks, data_targets)
return data_processed,data_is,data_os
def construct_RIB_data(train,test,item2id,item_tail=['0']):
session_length = train.groupby('sessionId').size()
max_session_length = int(session_length.quantile(q=0.99))
train_processed,train_is,train_os = construct_data(train,max_session_length,item2id,item_tail=item_tail)
test_processed,_,_ = construct_data(test, max_session_length, item2id, item_tail=item_tail)
return train_processed,train_is,train_os,test_processed
train_processed,_,_,test_processed = construct_RIB_data(train_data,test_data,item2id_dict)
爲了方便我們還可以在這裏存儲一個picke文件,因爲生成一次需要挺長時間的,後面哪一步失敗了都重頭來就太累了。
pickle.dump(train_processed,open('./data/output/train_processed.pkl','wb'))
pickle.dump(test_processed,open('./data/output/test_processed.pkl','wb'))
embeddling layer
這篇文章所有的代碼邏輯都極其非常的naive,我們先看原文是怎麼說的.
4.1節部分首先說我們可以用one-hot去表示這些不同的物品和操作,????(我真的黑人問號),然後說這樣表示太稀疏了,所以我們用word2vec對P,A,D分別embedding,然後concat起來,這樣維度就比原來小多了。。。。。。
The vocabulary sized of P,A,D are V,M,K respectively, and there are VKM tuples in total. Therefore, the input data is extremely sparse and high-dimensional…The new representation of xt,et is dense with dimension of dP+dA+dD,which is much smaller than V* M * K.
首先這裏就有一個問題,原來的表示維度也不過是 V+M+K,不知道爲什麼作者寫成了✖️,這裏希望有人幫助我解答一下~
那麼這裏的代碼就非常簡單了,先把操作序列整理成 item 序列的文本以及操作序列的文本格式,例子如下圖所示:
數據處理需要特別注意:item和操作的編號都從1開始!!因爲後面padding需要用到0
train_items.txt 文件:
train_operations.txt 文件
然後開始調用gensim 包,這裏採用skip-gram ,hidden_size=100.這裏後期考慮變換一下。
import gensim
from gensim.models import word2vec
def train_embedding(sentence_file,model_file):
sentences = word2vec.LineSentence(sentence_file)
model = word2vec.Word2Vec(sentences, hs=1,min_count=1,window=3,size=100)
model.save(model_file)
return model
item_model = train_embedding(sentence_file='./data/output/train_items.txt',model_file='./data/output/items.model')
operation_model = train_embedding(sentence_file='./data/output/train_operations.txt',model_file='./data/output/operations.model')
後面使用了rnn,所以我們這裏爲了方便,把padding用的[0]都加上去。
但是我不想動原有的模型,所以就加載出來之後新添加了一個詞,所以這裏也體現了模型的動態可更迭行~~~
#item_model.train([['0','0','0','0','0']],total_examples=1,epochs=1)
item_model = word2vec.Word2Vec.load('./data/output/items.model')
item_model.build_vocab(sentences=[['0','0','0','0','0']],update=True)
#item_model.wv[['1','2','3','0']]
operation_model = word2vec.Word2Vec.load('./data/output/operations.model')
operation_model.build_vocab(sentences=[['0','0','0','0','0']],update=True)
模型部分
4.2節就是本文的模型介紹部分,花裏胡哨的其實簡而言之一句話:我們用gru做了模型的序列化部分。
作者花時間說了bptt的梯度消失和彌散問題,然後說了lstm,又說gru比lstm簡單,然後用gru,我也是醉了,
然後這裏出現了一句神奇的話不知道我的解讀對不對:
in our task, each ht means the representation of the tth product and the micro-behaviors on it.
4.3 指的是,用一個attention layer 把之前輸出的那些隱藏層鏈接起來,就用了一個非常簡樸的attention 公式。
然後大家發現這裏使用了 K這個符號,在之前的指代中K爲dwell time 的類別個數,在這裏怎麼都說不通,所以我覺得,這裏可能是V,就是item 的個數,因爲後面loss function直接就交叉熵了, 所以說這裏輸出的應該就是每個item 的概率,所以這裏的維度就開始迷醉了,難道最後還有一個mlp??
但是文章的模型圖畫到output layer就結束了。
這裏真的非常迷醉,我先實現一下我的版本,然後解釋一下:
然後文章說了使用歸一化的embedding算loss function,其實也沒有很大的毛病,按照這裏的理解應該是instance 歸一化,即每一個instance 歸一化。無所謂了~
這裏的歸一化應該是softmax歸一化。直接softmax好了
class RIB(Module):
def __init__(self,feature_size,hidden_size,k_size):
super(RIB,self).__init__()
self.gru_cell = nn.GRU(input_size=feature_size*2,hidden_size=hidden_size,num_layers=2,batch_first=True)
self.M_layer_1 = nn.Linear(hidden_size, k_size, bias=True)
self.A_layer_1 = nn.Linear(k_size, feature_size, bias=True)
self.optimizer = torch.optim.Adam(self.parameters(),lr=0.001)
self.loss_function = self.loss_cross_fn()
@staticmethod
def loss_cross_fn(self,T, O, eps=0.0000001):
O = torch.log(O + eps)
tmp = -torch.mul(T, O)
loss = torch.sum(tmp)
return loss
def forward(self,input_e):
output, hidden = gru(input_e, None)
mt1 = self.M_layer_1(output)
mt2 = torch.tanh(mt1)
at1 = self.A_layer_1(mt2)
at2 = nn.functional.softmax(at1, dim=-1)
o1 = torch.sum(output * at2, dim=1)
return o1
模型訓練
#劃分batch
import math
def generate_batch_slices(len_data,shuffle=True,batch_size=128): #padding,masks,targets
n_batch = math.ceil(len_data / batch_size)
shuffle_args = np.arange(n_batch*batch_size)
if shuffle:
np.random.shuffle(shuffle_args)
slices = np.split(shuffle_args,n_batch) #np.split必須能整除才能等分
slices = [i[i<len_data] for i in slices]
return slices
def get_batch_embedding(data,slices):
b_is, b_os, b_ms,b_ts = data[0][slices],data[1][slices],data[2][slices],data[3][slices]
b_is_trans = np.array([[item_wordlist_dict[i] for i in is_] for is_ in b_is])
b_is_e = net_item_embedding(torch.Tensor(b_is_trans).long())
b_os_trans = np.array([[operation_wordlist_dict[i] for i in is_] for is_ in b_os])
b_os_e = net_operation_embedding(torch.Tensor(b_os_trans).long())
b_fs_e = torch.cat([b_is_e, b_os_e], dim=2)
b_ts_trans = np.array([item_wordlist_dict[t] for t in b_ts])
b_ts_e = net_item_embedding(torch.Tensor(b_ts_trans).long())
return b_fs_e,b_ts_e
#往torch 中加載 embeddings
item_wordlist = list(set(item_model.wv.vocab))
print(len(item_wordlist))
item_wordlist_dict = {}
for i,word in enumerate(item_wordlist):
word = word
item_wordlist_dict[word] = i
item_vocab_size = len(item_wordlist)
print(item_vocab_size)
embed_size = 100
weight = torch.zeros(item_vocab_size, embed_size)
for k,v in item_wordlist_dict.items():
weight[v, :] = torch.from_numpy(np.array(item_model.wv[[k]].data))
net_item_embedding = nn.Embedding.from_pretrained(weight)
operation_wordlist = list(set(operation_model.wv.vocab))
print(len(operation_wordlist))
operation_wordlist_dict = {}
for i,word in enumerate(operation_wordlist):
word = word
operation_wordlist_dict[word] = i
operation_vocab_size = len(operation_wordlist)
print(operation_vocab_size)
embed_size = 100
operation_weight = torch.zeros(operation_vocab_size, embed_size)
for k,v in operation_wordlist_dict.items():
operation_weight[v, :] = torch.from_numpy(np.array(operation_model.wv[[k]].data))
net_operation_embedding = nn.Embedding.from_pretrained(operation_weight)
del operation_weight
net = RIB(feature_size=100,hidden_size=100,k_size=50)
net.scheduler.step()
total_loss_list = []
for epoch in range(5):
print('epoch:',epoch)
train_slices = generate_batch_slices(len(train_processed[0]))
net.train()
total_loss = 0
for slice in train_slices:
net.optimizer.zero_grad()
slice_f_embedding,slice_target_embedding = get_batch_embedding(train_processed,slice)
slice_o_embedding = net(slice_f_embedding)
loss = net.loss_cross_fn(slice_target_embedding,slice_o_embedding)
loss.backward()
net.optimizer.step()
total_loss+=loss.item()
print('the total loss of %d epoch is %.4f'%(epoch,total_loss))
total_loss_list.append(total_loss)
torch.save(net.state_dict,'./data/ouput/model_tmp_%d_state_dict.pkl' % epoch)