SASRec论文代码复现讲解(教你如何读代码)倾囊相授!

SASRec

paper:  Self-Attentive Sequential Recommendation

publication:2018 IEEE International Conference on Data Mining

本文是我个人的学习总结,我也是初学者,但是我有很认真在思考做笔记,如果你也是初学者,我相信这对你的帮助很大,因为下面是作为刚入门的我的思考过程,这次先讲代码中部分函数,后边我会在本文慢慢更新,记得关注。可以先试着看看下边的内容,或许对你有所帮助!在此之前,我希望你能去仔细读一下原文,带着你疑惑的地方来看看代码如何实现,我强烈建议刚入门的学者可以手动一行一行的敲一下,这真的很管用!话不多说,上代码讲解! transformer的文章可以看我之前发的一篇:Transformer代码(Pytorch实现和详解)!!!-CSDN博客

我想讲一下我读代码的方法:目前,我比较喜欢一个模块一个模块 or 一个函数一个函数去解决,我可能先看数据怎么处理划分的,每个模块我会用代码测试一下,可以看一下输出结果跟自己想的一不一样,遇到不会的函数就去百度+自己写代码测试,非常好用。然后看懂一个模块之后,自己手动敲一遍,这看起来比较费事,但是对你的代码能力提升非常快!

DataSet

MovieLens-1M 是电影评分数据集,地址:MovieLens 1M Dataset | GroupLens

但是作者应该是把数据集处理成了下边的样子,形式就是(user, item)

user-用户ID;item-电影ID

我去官网看了一下,应该都是5星级的评分,所以这里的数据都表示用户喜欢的电影,我猜的~

非常简单对不对

Arguments

备注:这里如果不知道argparse的话,可以去了解一下。


parser = argparse.ArgumentParser()
parser.add_argument('--dataset', required=False,default = 'ml-1m')
parser.add_argument('--train_dir', required=False,default = 'default')
parser.add_argument('--batch_size', default=128, type=int)
parser.add_argument('--lr', default=0.001, type=float)
parser.add_argument('--maxlen', default=50, type=int)
parser.add_argument('--hidden_units', default=50, type=int)
parser.add_argument('--num_blocks', default=2, type=int)
parser.add_argument('--num_epochs', default=201, type=int)
parser.add_argument('--num_heads', default=1, type=int)
parser.add_argument('--dropout_rate', default=0.5, type=float)
parser.add_argument('--l2_emb', default=0.0, type=float)
parser.add_argument('--device', default='cpu', type=str)
parser.add_argument('--inference_only', default=False, type=str2bool)
parser.add_argument('--state_dict_path', default=None, type=str)

args = parser.parse_args()

我们通过args来管理我们模型用到的参数,如果是需要训练之前手动设置的参数叫“超参数”,还有一种是模型训练过程中得到的参数。这里就是超参数:

dataset:数据集名称,默认是“ml-1m”

train_dir:训练目录,默认是“default”,后边路径会拼接

batch_size:批量大小,就是一个批量有多少个样本。

lr:学习率

maxlen:用户序列长度(user:[item1, item2, item3…………] 就是item的长度)

hidden_units:隐藏层,就是项目和位置嵌入的维度。

num_blocks:transformer中提到

num_epochs:训练轮数。 一轮的话就是需要将所有的样本过一遍。

num_heads:注意力机制的头数

dropout_rate:dropout率

l2_emb:这个暂时不知道

device:设备,是“cpu”或者“cuda”,cuda就是GPU

inference_only:这里inference指的是用训练好的模型预测,不需要再进行训练。

state_dict_path:保存模型之后的state_dict的文件路径,用来加载训练好的模型。

data_partition

这个函数的主要作用是将数据集划分成训练集、验证集、测试集,仔细看看怎么操作的。

from collections import defaultdict

def data_partition(fname):
    usernum = 0
    itemnum = 0
    User = defaultdict(list)
    #默认字典,key不存在时返回默认值。 {user:[itme1,item2……]}
    user_train = {}
    user_valid = {}
    user_test = {}
    # assume user/item index starting from 1
    f = open('data/%s.txt' % fname, 'r')
    for line in f:
        u, i = line.rstrip().split(' ')
        u = int(u)
        i = int(i)
        #usernum、itemnum记录最大的用户、项目编号,正好是用户、项目的总数量,因为,都是从1开始编号的。
        usernum = max(u, usernum)
        itemnum = max(i, itemnum)
        User[u].append(i)

    for user in User:
        #当前用户的序列长度
        nfeedback = len(User[user])
        #如果当前用户序列长度小于3
        if nfeedback < 3:
            #把当前user的交互序列都给了训练集,验证和测试集为空
            user_train[user] = User[user]
            user_valid[user] = []
            user_test[user] = []
        else:
            user_train[user] = User[user][:-2]
            #让字典的value值是列表,而不是单个元素
            user_valid[user] = []
            user_valid[user].append(User[user][-2])
            user_test[user] = []
            user_test[user].append(User[user][-1])
    return [user_train, user_valid, user_test, usernum, itemnum]


#test
user_train, user_valid, user_test, usernum, itemnum = data_partition('ml-1m')
print('========================user_valid================================')
print(user_valid)
print('========================user_test================================')
print(user_test)
  • 文件路径,以及该如何读文件这都很常规的操作了吧。
  • 去了解并测试不懂的函数,比如:defaultdict()、rstrip()。
  • 代码的逻辑很简单,主要是各个数据的形式。你可以打印去看一下User、user_train、user_valid、user_test,记住他们的样子。

测试技巧

如果你对于测试无从下手,可以试试以下的步骤:

  • 可以试着先创建一个单独的test.py文件,用来测试。
  • 将代码粘贴过来,包括需要的库,以及准备好数据集,当然你也可以自己对代码稍作改动。
  • 如果你想知道中间某个变量的值,可以使用print函数打印出来;针对for循环里边的语句,可以打断点,然后调试,一层一层的看变量值。
  • 针对不懂的函数,比如defaultdict(), 先去百度了解功能和使用,再去单独测试,再回去看代码!

我想这对你来说小菜一叠~~

知识补充!

正样本:用户交互过的项目

负样本:用户还没有交互过的项目

WarpSampler

 这一部分主要是进行样本采样,会结合队列Queue和多进程来实现,大致就是创建多个进程,每个进程都会采集一个batch个用户样本(包括用户的正负样本),放到Queue中,如果Queue满了,采样的进程就会先阻塞。WarpSampler中的函数next_batch每次能取一个batch的样本数据。

#随机采样一个负样本
def random_neq(l, r, s):
    t = np.random.randint(l, r)
    while t in s:
        t = np.random.randint(l, r)
    return t

#采样函数
def sample_function(user_train, usernum, itemnum, batch_size, maxlen, result_queue, SEED):
    def sample():
        '''
        :return: (user, seq, pos, neg)
        '''
        user = np.random.randint(1, usernum+1)
        while len(user_train[user]) <= 1 : user = np.random.randint(1, usernum+1)

        seq = np.zeros([maxlen], dtype = np.int32)
        pos = np.zeros([maxlen], dtype = np.int32)
        neg = np.zeros([maxlen], dtype = np.int32)
        nxt = user_train[user][-1]
        idx = maxlen - 1

        ts = set(user_train[user]) # 当前user序列中交互过的item集合
        for i in reversed(user_train[1][:-1]):
            seq[idx] = i
            pos[idx] = nxt
            if nxt != 0: neg[idx] = random_neq(1, itemnum + 1, ts)
            nxt = i
            idx -= 1
            if idx == -1: break

        return (user, seq, pos, neg)

    np.random.seed(SEED)
    while True:
        one_batch = []
        print("##############")
        for i in range(batch_size):
            one_batch.append(sample())
        #queue满了会阻塞
        result_queue.put(zip(*one_batch))

class WarpSampler(object):
    def __init__(self, User, usernum, itemnum, batch_size=64, maxlen=10, n_workers=1):
        self.result_queue = Queue(maxsize = n_workers*10)
        self.processors = []
        for i in range(n_workers):
            self.processors.append(
                Process(target = sample_function, args=(User,
                                                        usernum,
                                                        itemnum,
                                                        batch_size,
                                                        maxlen,
                                                        self.result_queue,
                                                        np.random.randint(2e9)
                                                        )))
            self.processors[-1].daemon = True
            self.processors[-1].start()
    def next_batch(self):
        return self.result_queue.get()

    def close(self):
        for p in self.processors:
            p.terminate()
            p.join()

SASRec

拿到模型主干之后,先看  init() 里边都定义了啥。这里先定义了usernum、itemnum、device。接着是两个嵌入层:item_emb 和 pos_emb,他们的嵌入维度都是hidden_units,还有一个dropout层,用来对嵌入之后的结果随机失活。定义了四个ModuleList来存储各个层。num_blocks是堆叠层的数量,每一层有两个子层:一个多头自注意力层,后跟LayerNorm;一个前馈网络层,后跟LayerNorm。

第二步看一下forward() 里边如何组织网络的,这里直接调用log2feats方法,我们进到该方法里边看。里边的操作都是transformer的正常操作,从上到下一次是:

这里输入序列log_seq的样子:{ user1 : [item1, item2, item3……] }

 def log2feats(self, log_seqs):
    seqs = self.item_emb(torch.LongTensor(log_seqs).to(self.dev))#项目嵌入
    seqs *= self.item_emb.embedding_dim ** 0.5#缩放
    positions = np.tile(np.array(range(log_seqs.shape[1])), [log_seqs.shape[0], 1])#生成位置编码
    seqs += self.pos_emb(torch.LongTensor(positions).to(self.dev))#将位置编码进行位置嵌入,并且加到项目的嵌入向量序列中,位置感知
    seqs = self.emb_dropout(seqs)#dropout操作

如上代码所示,首先对输入seq进行item_emb,然后对seq进行缩放;自定义生成位置编码,对位置编码进行pos_emb, 然后将位置嵌入和项目嵌入想加,这样将位置信息也融合到序列里边了。对嵌入之后的序列进行dropout操作。这都是常规操作了吧。


timeline_mask = torch.BoolTensor(log_seqs == 0).to(self.dev)         

seqs *= ~timeline_mask.unsqueeze(-1)

上面这俩行代码的意思是,首先创建一个布尔张量,形状和log_seqs一样,只不过log_seqs中等于0的位置置为True,其他为False。将掩码在最后一个维度进行扩展以便与seqs相乘进行广播操作,并将他取反,也就是True表示log_seq不等于0的位置。综上所述就是将嵌入之后的seqs的某些位置置为0了,这些位置正好是原序列log_seq中item等于0的位置。

tl = seqs.shape[1] # time dim len for enforce causality
attention_mask = ~torch.tril(torch.ones((tl, tl), dtype=torch.bool, device=self.dev))

上面代码,创建注意力掩码矩阵,transformer中有讲,它创建了一个下三角形矩阵,将下三角部分置为 True,其余部分置为 False,并且使用取反操作符 ~ 将其取反,以便在注意力计算中将这些位置的值屏蔽掉。tl取的是seq的第二个dim的长度,也就是用户序列中项目的长度,表示每个项目对其他项目的注意力是多少。为什么是三角形状呢,因为预测当前位置的时候要屏蔽掉之后的信息。

        for i in range(len(self.attention_layers)):
            seqs = torch.transpose(seqs, 0, 1)#转置操作
            Q = self.attention_layernorms[i](seqs)#将seq传入注意力层的归一化层,输出q值
            mha_outputs, _ = self.attention_layers[i](Q, seqs, seqs, #多头注意力输出
                                            attn_mask=attention_mask)#注意力掩码
                                            # key_padding_mask=timeline_mask
                                            # need_weights=False) this arg do not work?
            seqs = Q + mha_outputs#残差连接
            seqs = torch.transpose(seqs, 0, 1)

            seqs = self.forward_layernorms[i](seqs)
            seqs = self.forward_layers[i](seqs)
            seqs *=  ~timeline_mask.unsqueeze(-1)

        log_feats = self.last_layernorm(seqs) # (U, T, C) -> (U, -1, C)

这段代码遍历num_blocks个encoder层,每个encoder层,先后分别进行了attention的层归一化,self-attention层, 参差连接,feedforward的层归一化,feedforward层。最后对输出结果在进行一次层归一化。输出结果。        

multihead_attn = nn.MultiheadAttention(embed_dim, num_heads)
attn_output, attn_output_weights = multihead_attn(query, key, value)

这个是官网调用多头注意力返回的两个输出结果,一个是注意力输出,一个是注意力权重,这里我们没有用到权重就直接忽略了。

至此,我们得到了经过Encoder之后的输出结果。


    def forward(self, user_ids, log_seqs, pos_seqs, neg_seqs): # for training        
        log_feats = self.log2feats(log_seqs) # user_ids hasn't been used yet

        pos_embs = self.item_emb(torch.LongTensor(pos_seqs).to(self.dev))
        neg_embs = self.item_emb(torch.LongTensor(neg_seqs).to(self.dev))

        pos_logits = (log_feats * pos_embs).sum(dim=-1)
        neg_logits = (log_feats * neg_embs).sum(dim=-1)

        # pos_pred = self.pos_sigmoid(pos_logits)
        # neg_pred = self.neg_sigmoid(neg_logits)

        return pos_logits, neg_logits # pos_pred, neg_pred

这是forward方法,首先是让原始的用户-项目序列经过了自注意力得到特征表示(QKV一系列操作):log_feats, 然后对正负样本进行嵌入,将log_feats和pos_emb、neg_emb分别逐点相乘,在最后一个维度求和,得到正负样本的逻辑回归得分。


    def predict(self, user_ids, log_seqs, item_indices): # for inference
        log_feats = self.log2feats(log_seqs) # user_ids hasn't been used yet

        final_feat = log_feats[:, -1, :] # only use last QKV classifier, a waste

        item_embs = self.item_emb(torch.LongTensor(item_indices).to(self.dev)) # (U, I, C)

        logits = item_embs.matmul(final_feat.unsqueeze(-1)).squeeze(-1)

        # preds = self.pos_sigmoid(logits) # rank same item list for different users

        return logits # preds # (U, I)

同样还是将用户序列,经过自注意力转换为特征表示。final_feat = log_feats[:, -1, :] 这里是取最后一个时间步,[batch_size, seq_len, d_model],这里就是指的seq_len的最后一个item。后边的可以打断点看看里边是什么样子。

SASRec完整代码:

class SASRec(torch.nn.Module):
    def __init__(self, user_num, item_num, args):
        super(SASRec, self).__init__()

        self.user_num = user_num # 用户数量
        self.item_num = item_num # 项目数量
        self.dev = args.device   # 设备参数

        # TODO: loss += args.l2_emb for regularizing embedding vectors during training
        # https://stackoverflow.com/questions/42704283/adding-l1-l2-regularization-in-pytorch
        #嵌入层
        self.item_emb = torch.nn.Embedding(self.item_num+1, args.hidden_units, padding_idx=0)
        self.pos_emb = torch.nn.Embedding(args.maxlen, args.hidden_units) # TO IMPROVE
        #定义Dropout层
        self.emb_dropout = torch.nn.Dropout( p = args.dropout_rate)

        #ModuleList
        self.attention_layernorms = torch.nn.ModuleList()
        self.attention_layers = torch.nn.ModuleList()
        self.forward_layernorms = torch.nn.ModuleList()
        self.forward_layers = torch.nn.ModuleList()

        self.last_layernorm = torch.nn.LayerNorm(args.hidden_units, eps=1e-8)

        for _ in range(args.num_blocks):
            #注意力-层归一化
            new_attn_layernorm = torch.nn.LayerNorm(args.hidden_units, eps=1e-8)
            self.attention_layernorms.append(new_attn_layernorm)

            #多头注意力层
            new_attn_layer = torch.nn.MultiheadAttention(args.hidden_units,
                                                            args.num_heads,
                                                            args.dropout_rate)
            self.attention_layers.append(new_attn_layer)

            #FeedForward-层归一化
            new_fwd_layernorm = torch.nn.LayerNorm(args.hidden_units, eps=1e-8)
            self.forward_layernorms.append(new_fwd_layernorm)

            #FeedForward层
            new_fwd_layer = PointWiseFeedForward(args.hidden_units, args.dropout_rate)
            self.forward_layers.append(new_fwd_layer)

            # self.pos_sigmoid = torch.nn.Sigmoid()
            # self.neg_sigmoid = torch.nn.Sigmoid()

    
    def log2feats(self, log_seqs):
        seqs = self.item_emb(torch.LongTensor(log_seqs).to(self.dev))#项目嵌入
        seqs *= self.item_emb.embedding_dim ** 0.5#缩放
        positions = np.tile(np.array(range(log_seqs.shape[1])), [log_seqs.shape[0], 1])#生成位置编码
        seqs += self.pos_emb(torch.LongTensor(positions).to(self.dev))#将位置编码进行位置嵌入,并且加到项目的嵌入向量序列中,位置感知
        seqs = self.emb_dropout(seqs)#dropout操作

        timeline_mask = torch.BoolTensor(log_seqs == 0).to(self.dev)#
        seqs *= ~timeline_mask.unsqueeze(-1) # broadcast in last dim

        tl = seqs.shape[1] # time dim len for enforce causality
        attention_mask = ~torch.tril(torch.ones((tl, tl), dtype=torch.bool, device=self.dev))

        for i in range(len(self.attention_layers)):
            seqs = torch.transpose(seqs, 0, 1)#转置操作
            Q = self.attention_layernorms[i](seqs)#将seq传入注意力层的归一化层,输出q值
            mha_outputs, _ = self.attention_layers[i](Q, seqs, seqs, #多头注意力输出
                                            attn_mask=attention_mask)#注意力掩码
                                            # key_padding_mask=timeline_mask
                                            # need_weights=False) this arg do not work?
            seqs = Q + mha_outputs#残差连接
            seqs = torch.transpose(seqs, 0, 1)

            seqs = self.forward_layernorms[i](seqs)
            seqs = self.forward_layers[i](seqs)
            seqs *=  ~timeline_mask.unsqueeze(-1)

        log_feats = self.last_layernorm(seqs) # (U, T, C) -> (U, -1, C)

        return log_feats
    #前向传播逻辑
    def forward(self, user_ids, log_seqs, pos_seqs, neg_seqs): # for training        
        log_feats = self.log2feats(log_seqs) # user_ids hasn't been used yet

        pos_embs = self.item_emb(torch.LongTensor(pos_seqs).to(self.dev))
        neg_embs = self.item_emb(torch.LongTensor(neg_seqs).to(self.dev))

        pos_logits = (log_feats * pos_embs).sum(dim=-1)
        neg_logits = (log_feats * neg_embs).sum(dim=-1)

        # pos_pred = self.pos_sigmoid(pos_logits)
        # neg_pred = self.neg_sigmoid(neg_logits)

        return pos_logits, neg_logits # pos_pred, neg_pred

    def predict(self, user_ids, log_seqs, item_indices): # for inference
        log_feats = self.log2feats(log_seqs) # user_ids hasn't been used yet

        final_feat = log_feats[:, -1, :] # only use last QKV classifier, a waste

        item_embs = self.item_emb(torch.LongTensor(item_indices).to(self.dev)) # (U, I, C)

        logits = item_embs.matmul(final_feat.unsqueeze(-1)).squeeze(-1)

        # preds = self.pos_sigmoid(logits) # rank same item list for different users

        return logits # preds # (U, I)

  • 13
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值