此部分描述如何使用RecStudio进行模块化模型设计,如Keras使用一样,搭积木式的定义模型结构并接入数据集训练。 目前只支持召回模型(Retriever)。

1. 召回模型结构

召回模型训练和测试流程如下。其中绿色块代表召回模型的组件,紫色块代表训练阶段的数据流,黄色块代表测试阶段的数据流。

20221011组会报告-召回模型.png

所有召回模型的父类都是BaseRetriever,它的forward方法会返回output字典,你可以调整一些布尔参数和sampler的情况获得个性化的输出:

  • 如果模型指定了sampler:
  • 基础的output只包含'score'字段,由以下四个字段组成:
    • pos_score:正样本得分
    • neg_score:负样本得分
    • log_pos_prob:正样本占item总数的比例的对数
    • log_neg_prob:负样本占item总数的比例的对数
  • 若return_neg_item为True,则output包含'neg_item'字段,返回负样本的向量表征
  • 若return_neg_id为True,则output包含'neg_id'字段,返回负样本的id
  • 若return_query为True,则output包含'query'字段,返回query的向量表征
  • 若return_item为True,则output包含'item'字段,返回正样本的向量表征
  • 如果模型没有sampler:
  • 基础的output只包含'score'字段,由以下一个字段组成:
    • pos_score:正样本得分
  • 若full_score为True,则output的'score'字段还会包含'all_score'字段,返回所有物品的得分
  • 若return_query为True,则output包含'query'字段,返回query的向量表征
  • 若return_item为True,则output包含'item'字段,返回正样本的向量表征

Test/Eval阶段中,metric分为两种:

  1. rank_metrics:
  2. 包括ndcg, precision, recall, map, mrr, hit
  3. 输入为预测值、目标值、k值(即topk的k值)
  4. pred_metrics:
  5. 包括mae, mse, auc, logloss
  6. 输入为预测值、目标值、阈值(即划分是否为正例的阈值,视情况设置)

接下来介绍召回模型的组件。

query_encoder

  • query_encoder是一个编码器,形式上是一个torch.nn.Module实例。query被query_encoder映射到维度为self.embed_dim的空间以后,才能与item(同样被映射到维度为self.embed_dim的空间)计算相互的关联度,从而为query选出最合适的item。
  • query_encoder的输入为一个batch,batch中有【键:值】对,具体的key根据具体的编码器而定。
  • query_encoder的输出为输入的user_id对应的self.embed_dim维向量编码。
  • RecStudio中现有的query_encoder如下表。注意在搭建模型时,只有对应数据集类型相同的query_encoder可以互相替换:
query_encoder 初始化参数 输入batch中需要的键值对 API路径 对应数据集类型
MultiDAEQueryEncoder
- fiid:物品id的特征名,如'item_id'
- num_items:物品总数,如1600
- embed_dim:最终embedding维度,如200
- dropout_rate:dropout率,如0.5
- encoder_dims:AE中encoder的各隐层维度,如[64, 32]
- decoder_dims:AE中decoder的各隐层维度,如[32, 64]
- activation:AE中MLP的激活函数,如'relu'
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
batch实例:
{'user_id': tensor([511,574,....='cuda:0')}, 'in_item_id':tensor([[185, 340, ...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为256,代表batch中有256个用户;in_item_id的维度为(256,29),代表交互物品数最多的用户与29个物品产生了交互(其他用户用0进行padding)。
recstudio.model.ae.multidae.MultiDAEQueryEncoder AEDataset
MultiVAEQueryEncoder
- fiid:物品id的特征名,如'item_id'
- num_items:物品总数,如1600
- embed_dim:最终embedding维度,如200
- dropout_rate:dropout率,如0.5
- encoder_dims:AE中encoder的各隐层维度,如[64, 32]
- decoder_dims:AE中decoder的各隐层维度,如[32, 64]
- activation:AE中MLP的激活函数,如'relu'
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
batch实例:
{'user_id': tensor([511,574,....='cuda:0')}, 'in_item_id':tensor([[185, 340, ...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为256,代表batch中有256个用户;in_item_id的维度为(256,29),代表交互物品数最多的用户与29个物品产生了交互(其他用户用0进行padding)。
recstudio.model.ae.multivae.MultiVAEQueryEncoder AEDataset
SASRecQueryEncoder
- fiid:物品id的特征名,如'item_id'
- embed_dim:最终embedding维度,如200
- max_seq_len:用户交互序列的最大长度,如20
- n_head:TransformerEncoderLayer的head数,如2
- hidden_size:TransformerEncoderLayer中FFN的维度,如128
- dropout:dropout率,如0.5
- activation:AE中MLP的激活函数,如'relu'
- layer_norm_eps:TransformerEncoderLayer中layer norm部分的eps值,如1e-12
- n_layer:TransformerEncoder中Layer个数
- item_encoder:物品编码器,应与模型的item_encoder保持一致
- bidirectional:注意力mask时是否采用双向,如False。
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
'seqlen':用户对应的交互序列的长度
batch实例:
{'user_id': tensor([244, 878, 79...='cuda:0'), 'seqlen': tensor([ 1, 1, 1, ...='cuda:0'), 'in_item_id': tensor([[ 252, 0,...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为1024,代表batch中有1024个用户;in_item_id的维度为(1024, 11),代表切分后交互物品数最多的用户与11个物品产生了交互(其他用户用0进行padding)。
recstudio.model.seq.sasrec.SASRecQueryEncoder SeqDataset
CaserQueryEncoder
- fiid:物品id的特征名,如'item_id'
- fuid:用户id的特征名,如'user_id'
- num_users:用户总数,如944
- num_items:物品总数,如1683
- embed_dim:最终输出的embedding维度,如64
- max_seq_len:用户交互序列的最大长度,如20
- n_v:vertical filter的个数,如8
- n_h:horizontal filter的个数,如16
- dropout:dropout率,如0.2
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
'seqlen':用户对应的交互序列的长度
batch实例:
{'user_id': tensor([244, 878, 79...='cuda:0'), 'seqlen': tensor([ 1, 1, 1, ...='cuda:0'), 'in_item_id': tensor([[ 252, 0,...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为1024,代表batch中有1024个用户;in_item_id的维度为(1024, 11),代表切分后交互物品数最多的用户与11个物品产生了交互(其他用户用0进行padding)。
recstudio.model.seq.caser.CaserQueryEncoder SeqDataset
HGNQueryEncoder
- fiid:物品id的特征名,如'item_id'
- fuid:用户id的特征名,如'user_id'
- num_users:用户总数,如944
- embed_dim:最终输出的embedding维度,如64
- max_seq_len:用户交互序列的最大长度,如20
- item_encoder:物品编码器,应与模型的item_encoder保持一致
- pooling_type:最终aggregation时所用的pooling规则,必须为'max'或者'mean'
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
'seqlen':用户对应的交互序列的长度
batch实例:
{'user_id': tensor([244, 878, 79...='cuda:0'), 'seqlen': tensor([ 1, 1, 1, ...='cuda:0'), 'in_item_id': tensor([[ 252, 0,...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为1024,代表batch中有1024个用户;in_item_id的维度为(1024, 11),代表切分后交互物品数最多的用户与11个物品产生了交互(其他用户用0进行padding)。
SeqDataset
NARMQueryEncoder
- fiid:物品id的特征名,如'item_id'
- embed_dim:最终输出的embedding维度,如64
- layer_num:stacking的gru层数,如1
- dropout_rate:List形式,分别为gru层和全连接层的dropout率,如[0.25, 0.5]
- item_encoder:物品编码器,应与模型的item_encoder保持一致
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
'seqlen':用户对应的交互序列的长度
batch实例:
{'user_id': tensor([244, 878, 79...='cuda:0'), 'seqlen': tensor([ 1, 1, 1, ...='cuda:0'), 'in_item_id': tensor([[ 252, 0,...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为1024,代表batch中有1024个用户;in_item_id的维度为(1024, 11),代表切分后交互物品数最多的用户与11个物品产生了交互(其他用户用0进行padding)。
recstudio.model.seq.narm.NARMQueryEncoder SeqDataset
STAMPQueryEncoder
- fiid:物品id的特征名,如'item_id'
- embed_dim:最终输出的embedding维度,如64
- item_encoder:物品编码器,应与模型的item_encoder保持一致
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
'seqlen':用户对应的交互序列的长度
batch实例:
{'user_id': tensor([244, 878, 79...='cuda:0'), 'seqlen': tensor([ 1, 1, 1, ...='cuda:0'), 'in_item_id': tensor([[ 252, 0,...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为1024,代表batch中有1024个用户;in_item_id的维度为(1024, 11),代表切分后交互物品数最多的用户与11个物品产生了交互(其他用户用0进行padding)。
recstudio.model.seq.stamp.STAMPQueryEncoder SeqDataset
TransRecQueryEncoder
- fiid:物品id的特征名,如'item_id'
- fuid:用户id的特征名,如'user_id'
- num_users:用户总数,如944
- embed_dim:最终输出的embedding维度,如64
- item_encoder:物品编码器,应与模型的item_encoder保持一致
'user_id':用户id
'in_{fiid}':用户交互过的物品,padding用0
'seqlen':用户对应的交互序列的长度
batch实例:
{'user_id': tensor([244, 878, 79...='cuda:0'), 'seqlen': tensor([ 1, 1, 1, ...='cuda:0'), 'in_item_id': tensor([[ 252, 0,...='cuda:0')}
上例中fiid为'item_id',所以'in_{fiid}'就是'in_item_id'。user_id的维度为1024,代表batch中有1024个用户;in_item_id的维度为(1024, 11),代表切分后交互物品数最多的用户与11个物品产生了交互(其他用户用0进行padding)。
recstudio.model.seq.transrec.TransRecQueryEncoder SeqDataset

item_encoder

  • item_encoder是一个编码器,形式上是一个torch.nn.Module实例。。item被item_encoder映射到维度为self.embed_dim的空间以后,才能与query(同样被映射到维度为self.embed_dim的空间)计算相互的关联度,从而为query选出最合适的item。
  • item_encoder的输入为一个batch,batch中有【键:值】对,具体的key根据具体的编码器而定。
  • item_encoder的输出为输入的item_id对应的self.embed_dim维向量编码。

scorer

  • score_func是一个打分器,用于计算用户和物品的关联度,形式上是一个torch.nn.Module实例。对于query_encoder输出的一个用户向量和item_encoder输出的一个物品向量,score_func计算两者之间的关联度,并给出一个分数。
  • score_func的输入为两个向量,其中一个是用户编码,另一个是物品编码。
  • score_func的输出为一个float型分数,代表用户和物品的相关度。
  • RecStudio中可以调用的scorer如下表:
scorer名称 计算方式 API
InnerProductScorer 内积,即 recstudio.model.scorer.InnerProductScorer
CosineScorer 余弦相似度,即 recstudio.model.scorer.CosineScorer
EuclideanScorer 欧几里得距离的平方的相反数,即 recstudio.model.scorer.EuclideanScorer
MLPScorer 将query和item拼接后送入MLP,由MLP给出分数(MLP需要自定义并作为MLPScorer初始化参数) recstudio.model.scorer.MLPScorer
NormScorer (query-item)的p-范数,即 recstudio.model.scorer.NormScorer
GMFScorer General Matrix Factorization,query和item逐元素相乘后经过一个线性层和激活层,即 recstudio.model.scorer.GMFScorer
FusionMFMLPScorer 融合了MF和MLP,query和item逐元素相乘后的结果和MLPScorer的结果连接,再经过一个线性层和激活层,即 recstudio.model.scorer.FusionMFMLPScorer

loss

  • loss_func负责在训练阶段评估模型给一个样本的标签与样本真实标签之间的差距,它必须是[recstudio.loss_func.FullScoreLoss, recstudio.loss_func.PairwiseLoss, recstudio.loss_func.PointWiseLoss]三类中的某一类的实例。
  • loss_func的输入输出形式视情况而定。
  • 如果loss_func是recstudio.loss_func.PointWiseLoss实例,那么输入是一个query和一个item,输出是两者之间的loss(即不相关程度)。
  • 如果loss_func是recstudio.loss_func.PairwiseLoss实例,那么输入是一个query、一个对应的正样本item和一个对应的负样本item,输出是正样本item和负样本item得分之间的相近程度。
  • 如果loss_func是recstudio.loss_func.FullScoreLoss实例,那么输入是一个query和所有的item,输出是query对应的正样本和负样本的区分度“有多差”。
  • loss_func的输出为一个float值,代表模型所给标签与真实标签之间的差距。
  • RecStudio中可以调用的loss如下表:
loss名称 计算指标 类型 API
SoftmaxLoss FullScoreLoss recstudio.model.loss_func.SoftmaxLoss
BPRLoss PairwiseLoss recstudio.model.loss_func.BPRLoss
Top1Loss PairwiseLoss recstudio.model.loss_func.Top1Loss
SampledSoftmaxLoss PairwiseLoss recstudio.model.loss_func.SampledSoftmaxLoss
WeightedBPRLoss PairwiseLoss recstudio.model.loss_func.WeightedBPRLoss
BinaryCrossEntropyLoss PairwiseLoss recstudio.model.loss_func.BinaryCrossEntropyLoss
WeightedBinaryCrossEntropyLoss PairwiseLoss recstudio.model.loss_func.WeightedBinaryCrossEntropyLoss
HingeLoss PairwiseLoss recstudio.model.loss_func.HingeLoss
InfoNCELoss PairwiseLoss recstudio.model.loss_func.InfoNCELoss
NCELoss PairwiseLoss recstudio.model.loss_func.NCELoss
CCLLoss PairwiseLoss recstudio.model.loss_func.CCLLoss
BCEWithLogitLoss PointwiseLoss recstudio.model.loss_func.BCEWithLogitLoss
MSELoss PointwiseLoss recstudio.model.loss_func.MSELoss

sampler

  • sampler是一个采样器,用于给一个正样本采集对应的负样本,形式上是一个recstudio.ann.sampler.Sampler实例。
  • sampler的输入为一个(或一组)query。
  • sampler的输出为一组(或多组)int值,每一组int值代表为一个query采样的负样本的编号。
  • 当loss为FullScoreLoss实例时,sampler应为None

2. 示例

以HGN为例,给出用户自定义query encoder、要使用的数据集特征和召回模型的方法:

from recstudio.model.basemodel import BaseRetriever
from recstudio.ann import sampler
from recstudio.model import scorer, loss_func, module
from recstudio.utils import get_logger
from recstudio.data.dataset import SeqDataset
import time
import torch

# 指定数据集,这里使用ml-100k
ml_100k = SeqDataset(name='ml-100k')

# 划分训练集、验证集、测试集
trn, val, tst = ml_100k.build(split_ratio=[0.8, 0.2, 0.2])

# 指定特征
trn.use_field = {'user_id', 'item_id', 'gendre', 'occupation','movie_title'}
val.use_field = {'user_id', 'item_id', 'gendre', 'occupation','movie_title'}
tst.use_field = {'user_id', 'item_id', 'gendre', 'occupation','movie_title'}

# 自定义query encoder,这里以HGN的query_encoder作为例子
class MyQueryEncoder(torch.nn.Module):
    def __init__(self, fuid, fiid, num_users, embed_dim, max_seq_len, item_encoder, pooling_type='mean') -> None:
        super().__init__()
        self.fuid = fuid
        self.fiid = fiid
        self.item_encoder = item_encoder
        self.pooling_type = pooling_type
        self.user_embedding = torch.nn.Embedding(num_users, embed_dim, 0)
        self.W_g_1 = torch.nn.Linear(embed_dim, embed_dim, bias=False)
        self.W_g_2 = torch.nn.Linear(embed_dim, embed_dim, bias=False)
        self.b_g = torch.nn.Parameter(torch.empty(embed_dim), requires_grad=True)
        self.w_g_3 = torch.nn.Linear(embed_dim, 1, bias=False)
        self.W_g_4 = torch.nn.Linear(embed_dim, max_seq_len)

    def forward(self, batch):
        U = self.user_embedding(batch[self.fuid])
        S = self.item_encoder(batch['in_'+self.fiid])
        S_F = S * torch.sigmoid(self.W_g_1(S) + self.W_g_2(U).view(U.size(0), 1, -1) + self.b_g)
        weight = torch.sigmoid(self.w_g_3(S_F) + (U@self.W_g_4.weight[:S.size(1)].T).view(U.size(0), -1, 1))    # BxLx1
        S_I = S_F * weight
        if self.pooling_type == 'mean':
            s = S_I.sum(1) / weight.sum(1)
        elif self.pooling_type == 'max':
            s = torch.max(S_I, dim=1).values
        else:
            raise ValueError("`pooling_type` only support `avg` and `max`")
        query = U + s + S.sum(1)
        return query

config = {
    'embed_dim': 64,
    'max_seq_len': 20,
    'pooling_type': 'mean'
}

item_encoder = torch.nn.Embedding(trn.num_items, config['embed_dim'], 0)

# 自定义召回模型
HGN = BaseRetriever(
    query_encoder = MyQueryEncoder(trn.fuid, trn.fiid, 
                                trn.num_users, config['embed_dim'],
                               config['max_seq_len'], item_encoder, config['pooling_type']),
    item_encoder = item_encoder,
    scorer = scorer.InnerProductScorer(),
    loss = loss_func.BPRLoss(),
    sampler = sampler.UniformSampler(trn.num_items)
)

# 记录日志
log_path = time.strftime(f"HGN-ml_100k-%Y-%m-%d-%H-%M-%S.log", time.localtime())
logger = get_logger(log_path)

# 训练
HGN.fit(trn, val, negative_count=1)

# 测试
HGN.evaluate(tst)