从零开始的FM(2) pytorch实现ctr模型线上部署

在(1)中介绍了FM模型的理论和python实现二分类模型。作为用于CTR预估的模型之一,FM重点在于实现ctr。

一、数据集

电商数据中的用户行为日志数据。召回完成,在排序阶段,需要考虑用户特征和物品特征,用户特征来源于用户画像,物品特征来源于物品自身固有属性;用户画像一部分是通过物品画像得到。

1、物品画像

在电商领域,以脐橙为例,物品画像通常包含如下维度:
1、关键词:商品标题和详情页的文字部分提取关键词(topN),其数据格式为 keyword = [(光滑,0.32),(饱满,0.34),...]
2、实体词:商品标题和详情页的文字部分提起实体词(topN),其数据格式为:entity=["云南省","赣南",...]
3、价格:商品本身的价格是一个连续型特征,进行分桶处理为类别特征,如果划分10个区间,则商品的价格为10个特征之一:price=[0,0,1,0,...]
4、分类:脐橙本身又分为了多个种类,每个脐橙属于一个分类。如果区分大分类和小分类,则可得到两个分类特征。分类为类别特征:category=[0,1,0,...]
5、产地: 商品产地为类别特征:area=[0,1,0,...]
如果商品有和时间或者节气、节日等强相关的特征,可以将其加入物品画像。

2、用户画像

很明显,用户画像是基于物品画像的。用户购买、收藏、点击等行为日志通过ios端或者android客户端埋点获得,过滤清洗之后存入hdfs,供后续推荐算法使用。

1、关于日志

问题1:需要考虑用户哪些行为是有价值的。很明显,用户购买、收藏了某个商品,他是喜欢这个商品的。那么构建用户画像只使用这类日志可以么?答案是No,因为对于一个日活有限的电商平台而言,这类日志很少。一般要加上浏览日志。
问题2:用户浏览、购买、收藏了一个商品,产生了2+N(代表多次浏览该商品)条日志,日志如何处理。一个方案,只保留购买行为的日志,因为购买和用户喜欢是最强相关的。
问题3:日志时间。如果喜欢浏览了商品A2秒,商品B20秒,如何确定其喜好。设定阈值,用户点进去一个商品然后快速返回,不能表示其喜好该商品。

2、用户画像字段

这里,用户画像直接使用物品画像进行构建,放弃了用户自身属性(年龄、性别等)。因为这些属性大部分用户都为空。实际场景中,很多属性是app不能获得的。
1、关键词:用户操作过的商品的关键词,按照权重加权求和。数据格式为 keyword = [(光滑,1.32),(饱满,2.34),...]
2、实体词:用户操作过的商品的实体词,按照实体词总数取分数。数据格式为:entity=[("云南省,0.1"),("赣南",0.2),...]
3、价格:用户的价格画像为每个价格区间的比例:price=[("0-100",0.2),("100-500",0.4),...]
4、分类:用户分类为操作过的商品的每个分类的比例:category=[(1,0.2),(2,0.1),...]
5、产地: 用户操作过的商品的产地的分布:area=[("云南省",0.1),("安徽省",0.3),...]

3、模型输入向量生成

用户向量+物品向量
假设有2000个关键词,1000个实体词,10种价格区间,10个分类,50个产地,则最终的用户向量维度为:\(2000+1000+10+10+50=3070\),物品向量维度为3070。
ps:这里没有使用物品之外的特征,比如时间信息、app相关信息,行为信息等数据。
故,最终的模型输入特征向量:input_vector = np.zeros(6140+1,dtype=np.float),然后在对应特征位置赋值。
生成的numpy数组保存为xxx.npy

二、torch实现FM

用于CTR时,模型输出为sigmoid之后的概率值:[0,1]。
分为几个模块

1、数据集加载

import torch
from torch.utils.data import Dataset
import numpy as np
from dataprocess import DataLoad # 自定义的npy数据读取类

class CtrDataset(Dataset):
    """
    Custom dataset class for dataset in order to use efficient 
    dataloader tool provided by PyTorch.
    """

    def __init__(self, train=True,split_=0.8):
        """
        Initialize file path and train/test mode.

        Inputs:
        - train: bool.是否为训练阶段
        - split_: 训练数据比例。
        """

        self.train = train
        train_data,test_data = DataLoad().split_sample(split_)
        if self.train:
            self.train_x = train_data[:, :-1]
            self.train_y = train_data[:, -1]
        else:
            self.test_x = test_data[:,:-1]
            self.test_y = test_data[:,-1]


    def __getitem__(self, idx):
        '''
        self.train_data的值:[[0,1,...],[],...],y要修改为:[[1],[0],...]的格式。
        '''
        if self.train:
            dataI, targetI = self.train_x[idx, :], self.train_y[idx]
            targetI = np.array(targetI)
            targetI = torch.from_numpy(targetI)
            targetI = torch.unsqueeze(targetI,-1)
            return dataI,targetI
        else:
            dataI, targetI = self.test_x[idx, :], self.test_y[idx]
            targetI = np.array(targetI)
            targetI = torch.from_numpy(targetI)
            targetI = torch.unsqueeze(targetI, -1)
            return dataI, targetI

    def __len__(self):
        if self.train:
            return len(self.train_x)
        else:
            return len(self.test_x)

2、 DataLoader加载数据

train_data = CtrDataset( train=True,split_=split_)
test_data = CtrDataset( train=True,split_=split_)
loader_train = DataLoader(train_data, batch_size=50,
                          shuffle=True)

常用操作有:batch_size(每个batch的大小), shuffle(是否进行shuffle操作), num_workers(加载数据的时候使用几个子进程)。

3、选择使用CPU还是GPU进行训练

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")

4、FM模型

class FMLayer(nn.Module):
    def __init__(self, n=10, k=5):
        """
        :param n: 特征维度
        :param k: 隐向量维度
        """
        super(FMLayer, self).__init__()
        self.dtype = torch.float
        self.n = n
        self.k = k
        self.linear = nn.Linear(self.n, 1)   # 前两项线性层
        '''
        torch.nn.Parameter是继承自torch.Tensor的子类,其主要作用是作为nn.Module中的可训练参数使用。它与torch.Tensor的区别就是nn.Parameter会自动被认为是module的可训练参数,即加入到parameter()这个迭代器中去;而module中非nn.Parameter()的普通tensor是不在parameter中的。
注意到,nn.Parameter的对象的requires_grad属性的默认值是True,即是可被训练的,这与torth.Tensor对象的默认值相反。
在nn.Module类中,pytorch也是使用nn.Parameter来对每一个module的参数进行初始化的。'''
        self.v = nn.Parameter(torch.randn(self.n, self.k))   # 交互矩阵
        nn.init.uniform_(self.v, -0.1, 0.1)

    def fm_layer(self, x):
        # x 属于 R^{batch*n}
        linear_part = self.linear(x)
        #print("linear_part",linear_part.shape)
        # linear_part = torch.unsqueeze(linear_part, 1)
        # print(linear_part.shape)
        # 矩阵相乘 (batch*p) * (p*k)
        inter_part1 = torch.mm(x, self.v)  # out_size = (batch, k) # 矩阵a和b矩阵相乘。 vi,f * xi
        # 矩阵相乘 (batch*p)^2 * (p*k)^2
        inter_part2 = torch.mm(torch.pow(x, 2), torch.pow(self.v, 2))  # out_size = (batch, k)
        # 这里torch求和一定要用sum
        inter = 0.5 * torch.sum(torch.sub(torch.pow(inter_part1, 2), inter_part2),1,keepdim=True)
        #print("inter",inter.shape)
        output = linear_part + inter
        output = torch.sigmoid(output)
        #print(output.shape) # out_size = (batch, 1)
        return output
    def forward(self, x):
        return self.fm_layer(x)

上述为FM公式的torch版本。作为网络模型,还需要定义损失函数和训练过程。
模型输出已经是经过sigmoid的概率值,直接使用交叉熵作为损失函数。

 def fit(self,data,optimizer,epochs=100):
        """
        Training a model and valid accuracy.

        Inputs:
        - loader_train: I
        - optimizer: Abstraction of optimizer used in training process, e.g., "torch.optim.Adam()""torch.optim.SGD()".
        - epochs: Integer, number of epochs.
        """
        criterion = F.binary_cross_entropy
        for epoch in range(epochs):
            for t, (batch_x, batch_y) in enumerate(data):
                batch_x = batch_x.to(device)
                batch_y = batch_y.to(device)
                total = self.forward(batch_x)
                loss = criterion(total, batch_y)
                optimizer.zero_grad()
                loss.backward()
                optimizer.step()

            loader_test = DataLoader(test_data, batch_size=50,
                                     shuffle=True)

            r = self.test(loader_test)
            print('Epoch %d , loss = %.4f' % (epoch, r))


    def test(self,data):
        '''
        测试集测试
        :return:
        '''
        criterion = F.binary_cross_entropy
        all_loss = 0
        i = 0
        for t, (batch_x, batch_y) in enumerate(data):
            batch_x = batch_x.to(device)
            batch_y = batch_y.to(device)
            total = self.forward(batch_x)
            loss = criterion(total, batch_y)
            all_loss += loss.item()
            i += 1
        return all_loss/i

三、模型训练和保存

使用flask作为web服务框架。
为了线上部署,使用torchscript进行模型的保存。
https://pytorch.org/tutorials/beginner/Intro_to_TorchScript_tutorial.html
https://discuss.pytorch.org/t/infer-torch-model-via-gunicorn-wsgi/60437

fm = FMLayer(n=features,k=30)
fm = fm.to(device)
optimizer = optim.Adam(fm.parameters(), lr=1e-4, weight_decay=0.0)
fm.fit(loader_train, optimizer, epochs=100)
fm = fm.to("cpu")
temp = torch.zeros((1,6140))
traced_model = torch.jit.trace(fm,temp)
torch.jit.save(traced_model, 'model.pt') 

使用torch.save(model,path)进行保存的模型,在加载的使用,要求可以找到原始的FMLayer类,直接xx.py没有问题。但是,如果web服务使用gunicorn进行启动,就会报错:

AttributerError:Can't get attribute 'FMLayer' on <module '__main__' from '/usr/local/bin/gunicorn'

因为:torch.load(model_path)的时候,需要在当前位置有模型类。而使用gunicorn的时候,它会在gunicorn那里寻找模型类。
使用torch.jit.load(model_path, map_location='cpu')可以不用在当前位置有对应的模型类。

四、线上部署

对外提供api接口,接收输入数据:用户id和召回算法得到的物品id,返回排序后的物品id列表。
使用docker部署注意事项:
1、完整的requirements.txt
2、gunicorn 的配置 daemon = "false"
3、时区改变:RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
4、主机和容器数据同步,日志和新的模型文件.

 volumes:                       #映射的数据卷
      - ./app:/www/web
      - ./nginx/conf:/etc/nginx
      - ./nginx/logs:/www/web_logs

五、模型更新和线上服务更新

使用每天的日志训练模型并实时更新线上模型,通过flask_apscheduler模块在web服务中执行定时任务。

posted @ 2021-03-25 17:25  木叶流云  阅读(1845)  评论(2编辑  收藏  举报