文档简介:
模型设计的代码需要用到上一节数据处理的Python类,定义如下:
import random import numpy as np from PIL import Image class MovieLen(object):
def __init__(self, use_poster): self.use_poster = use_poster # 声明每个数据文件的路径
usr_info_path = "./work/ml-1m/users.dat" if use_poster:
rating_path = "./work/ml-1m/new_rating.txt" else:
rating_path = "./work/ml-1m/ratings.dat" movie_info_path = "./work/ml-1m/mo
vies.dat" self.poster_path = "./work/ml-1m/posters/" # 得到电影数据 self.movie_info,
self.movie_cat, self.movie_title = self.get_movie_info(movie_info_path) # 记录电影的最大ID
self.max_mov_cat = np.max([self.movie_cat[k] for k in self.movie_cat])
self.max_mov_tit = np.max([self.movie_title[k] for k in self.movie_title])
self.max_mov_id = np.max(list(map(int, self.movie_info.keys()))) #
记录用户数据的最大ID self.max_usr_id = 0 self.max_usr_age = 0 self.max_usr_job = 0
# 得到用户数据 self.usr_info = self.get_usr_info(usr_info_path) # 得到评分数据
self.rating_info = self.get_rating_info(rating_path) # 构建数据集 self.dataset
= self.get_dataset(usr_info=self.usr_info,
rating_info=self.rating_info,
movie_info=self.movie_info) # 划分数据集,
获得数据加载器 self.train_dataset = self.dataset[:int(len(self.dataset)*0.9)]
self.valid_dataset = self.dataset[int(len(self.dataset)*0.9):]
print("##Total dataset instances: ", len(self.dataset))
print("##MovieLens dataset information: \nusr num: {}\n" "movies num: {}".
format(len(self.usr_info),len(self.movie_info))) # 得到电影数据 def get_movie_info
(self, path): # 打开文件,编码方式选择ISO-8859-1,读取所有数据到data中 with open
(path, 'r', encoding="ISO-8859-1") as f:
data = f.readlines() # 建立三个字典,分别用户存放电影所有信息,电影的名字信息
、类别信息 movie_info, movie_titles, movie_cat = {}, {}, {} # 对电影名字、
类别中不同的单词计数 t_count, c_count = 1, 1 count_tit = {} # 按行读取数据并处理
for item in data:
item = item.strip().split("::")
v_id = item[0]
v_title = item[1][:-7]
cats = item[2].split('|')
v_year = item[1][-5:-1]
titles = v_title.split() # 统计电影名字的单词,并给每个单词一个序号,
放在movie_titles中 for t in titles: if t not in movie_titles:
movie_titles[t] = t_count
t_count += 1 # 统计电影类别单词,并给每个单词一个序号,
放在movie_cat中 for cat in cats: if cat not in movie_cat:
movie_cat[cat] = c_count
c_count += 1 # 补0使电影名称对应的列表长度为15 v_tit =
[movie_titles[k] for k in titles] while len(v_tit)<15:
v_tit.append(0) # 补0使电影种类对应的列表长度为6 v_cat =
[movie_cat[k] for k in cats] while len(v_cat)<6:
v_cat.append(0) # 保存电影数据到movie_info中 movie_info[v_id] =
{'mov_id': int(v_id), 'title': v_tit, 'category': v_cat, 'years': int(v_year)}
return movie_info, movie_cat, movie_titles def get_usr_info(self, path):
# 性别转换函数,M-0, F-1 def gender2num(gender): return 1 if gender == 'F'
else 0 # 打开文件,读取所有行到data中 with open(path, 'r') as f:
data = f.readlines() # 建立用户信息的字典 use_info = {}
max_usr_id = 0 #按行索引数据 for item in data: # 去除每一行中和数据无关的部分
item = item.strip().split("::")
usr_id = item[0] # 将字符数据转成数字并保存在字典中 use_info[usr_id] =
{'usr_id': int(usr_id), 'gender': gender2num(item[1]), 'age': int(item[2]), 'job': int(item[3])}
self.max_usr_id = max(self.max_usr_id, int(usr_id))
self.max_usr_age = max(self.max_usr_age, int(item[2]))
self.max_usr_job = max(self.max_usr_job, int(item[3])) return use_info
# 得到评分数据 def get_rating_info(self, path): # 读取文件里的数据 with open(path, 'r') as f:
data = f.readlines() # 将数据保存在字典中并返回 rating_info = {} for item in data:
item = item.strip().split("::")
usr_id,movie_id,score = item[0],item[1],item[2] if usr_id not in rating_info.keys():
rating_info[usr_id] = {movie_id:float(score)} else:
rating_info[usr_id][movie_id] = float(score) return rating_info #
构建数据集 def get_dataset(self, usr_info, rating_info, movie_info): trainset =
[] for usr_id in rating_info.keys():
usr_ratings = rating_info[usr_id] for movie_id in usr_ratings:
trainset.append({'usr_info': usr_info[usr_id], 'mov_info': movie_info[movie_id],
'scores': usr_ratings[movie_id]}) return trainset def load_data(self, dataset=None,
mode='train'): use_poster = False # 定义数据迭代Batch大小 BATCHSIZE = 256 data_length = len(dataset)
index_list = list(range(data_length)) # 定义数据迭代加载器 def data_generator():
# 训练模式下,打乱训练数据 if mode == 'train':
random.shuffle(index_list) # 声明每个特征的列表 usr_id_list,usr_gender_list,
usr_age_list,usr_job_list = [], [], [], []
mov_id_list,mov_tit_list,mov_cat_list,mov_poster_list = [], [], [], []
score_list = [] # 索引遍历输入数据集 for idx, i in enumerate(index_list):
# 获得特征数据保存到对应特征列表中 usr_id_list.append(dataset[i]['usr_info']['usr_id'])
usr_gender_list.append(dataset[i]['usr_info']['gender'])
usr_age_list.append(dataset[i]['usr_info']['age'])
usr_job_list.append(dataset[i]['usr_info']['job'])
mov_id_list.append(dataset[i]['mov_info']['mov_id'])
mov_tit_list.append(dataset[i]['mov_info']['title'])
mov_cat_list.append(dataset[i]['mov_info']['category'])
mov_id = dataset[i]['mov_info']['mov_id'] if use_poster: # 不使用图像特征时,
不读取图像数据,加快数据读取速度 poster = Image.open(self.poster_path+'mov_id{}.jpg'.format(str(mov_id[0])))
poster = poster.resize([64, 64]) if len(poster.size) <= 2:
poster = poster.convert("RGB")
mov_poster_list.append(np.array(poster))
score_list.append(int(dataset[i]['scores'])) # 如果读取的数据量达到当前的batch大小,
就返回当前批次 if len(usr_id_list)==BATCHSIZE: # 转换列表数据为数组形式,reshape到固定形状
usr_id_arr = np.array(usr_id_list)
usr_gender_arr = np.array(usr_gender_list)
usr_age_arr = np.array(usr_age_list)
usr_job_arr = np.array(usr_job_list)
mov_id_arr = np.array(mov_id_list)
mov_cat_arr = np.reshape(np.array(mov_cat_list), [BATCHSIZE, 6]).astype(np.int64)
mov_tit_arr = np.reshape(np.array(mov_tit_list), [BATCHSIZE,
1, 15]).astype(np.int64) if use_poster:
mov_poster_arr = np.reshape(np.array(mov_poster_list)/127.5 - 1,
[BATCHSIZE, 3, 64, 64]).astype(np.float32) else:
mov_poster_arr = np.array([0.])
scores_arr = np.reshape(np.array(score_list), [-1, 1]).astype(np.float32)
# 放回当前批次数据 yield [usr_id_arr, usr_gender_arr, usr_age_arr, usr_job_arr], \
[mov_id_arr, mov_cat_arr, mov_tit_arr, mov_poster_arr], scores_arr
# 清空数据 usr_id_list, usr_gender_list, usr_age_list, usr_job_list = [], [], [], []
mov_id_list, mov_tit_list, mov_cat_list, score_list = [], [], [], []
mov_poster_list = [] return data_generator
# 解压数据集 !unzip -o -q -d ~/work/ ~/data/data19736/ml-1m.zip
神经网络模型设计是电影推荐任务中重要的一环。它的作用是提取图像、文本或者语音的特征,利用这些特征完成分类、检测、文本分析等任务。在电影推荐任务中,我们将设计一个神经网络模型,提取用户数据、电影数据的特征向量,然后计算这些向量的相似度,利用相似度的大小去完成推荐。
根据第一章中对建模思路的分析,神经网络模型的设计包含如下步骤:
- 分别将用户、电影的多个特征数据转换成特征向量。
- 对这些特征向量,使用全连接层或者卷积层进一步提取特征。
- 将用户、电影多个数据的特征向量融合成一个向量表示,方便进行相似度计算。
- 计算特征之间的相似度。
依据这个思路,我们设计一个简单的电影推荐神经网络模型:

图1:网络结构的设计
该网络结构包含如下内容:
-
提取用户特征和电影特征作为神经网络的输入,其中:
- 用户特征包含四个属性信息,分别是用户ID、性别、职业和年龄。
- 电影特征包含三个属性信息,分别是电影ID、电影类型和电影名称。
-
提取用户特征。使用Embedding层将用户ID映射为向量表示,输入全连接层,并对其他三个属性也做类似的处理。然后将四个属性的特征分别全连接并相加。
-
提取电影特征。将电影ID和电影类型映射为向量表示,输入全连接层,电影名字用文本卷积神经网络得到其定长向量表示。然后将三个属性的特征表示分别全连接并相加。
-
得到用户和电影的向量表示后,计算二者的余弦相似度。最后,用该相似度和用户真实评分的均方差作为该回归模型的损失函数。
衡量相似度的计算有多种方式,比如计算余弦相似度、皮尔森相关系数、Jaccard相似系数等等,或者通过计算欧几里得距离、曼哈顿距离、明可夫斯基距离等方式计算相似度。余弦相似度是一种简单好用的向量相似度计算方式,通过计算向量之间的夹角余弦值来评估他们的相似度,本节我们使用余弦相似度计算特征之间的相似度。
为何如此设计网络呢?
网络的主体框架已经在第一章中做出了分析,但还有一些细节点没有确定。
-
如何将“数字”转变成“向量”?
如NLP章节的介绍,使用词嵌入(Embedding)的方式可将数字转变成向量。
-
如何合并多个向量的信息?例如:如何将用户四个特征(ID、性别、年龄、职业)的向量合并成一个向量?
最简单的方式是先将不同特征向量(ID 32维、性别 16维、年龄 16维、职业 16维)通过4个全连接层映射到4个等长的向量(200维度),再将4个等长的向量按位相加即可得到1个包含全部信息的向量。
电影类型的特征是将多个数字(代表出现的单词)转变成的多个向量(6个),可以通过相同的方式合并成1个向量。
-
如何处理文本信息?
如NLP章节的介绍,使用卷积神经网络(CNN)和长短记忆神经网络(LSTM)处理文本信息会有较好的效果。因为电影标题是相对简单的短文本,所以我们使用卷积网络结构来处理电影标题。
-
尺寸大小应该如何设计? 这涉及到信息熵的理念:越丰富的信息,维度越高。所以,信息量较少的原始特征可以用更短的向量表示,例如性别、年龄和职业这三个特征向量均设置成16维,而用户ID和电影ID这样较多信息量的特征设置成32维。综合了4个原始用户特征的向量和综合了3个电影特征的向量均设计成200维度,使得它们可以蕴含更丰富的信息。当然,尺寸大小并没有一贯的最优规律,需要我们根据问题的复杂程度,训练样本量,特征的信息量等多方面信息探索出最有效的设计。
第一章的设计思想结合上面几个细节方案,即可得出上图展示的网络结构。
接下来我们进入代码实现环节,首先看看如何将数据映射为向量。在自然语言处理中,我们常使用词嵌入(Embedding)的方式完成向量变换。
Embedding介绍
Embedding是一个嵌入层,将输入的非负整数矩阵中的每个数值,转换为具有固定长度的向量。
在NLP任务中,一般把输入文本映射成向量表示,以便神经网络的处理。在数据处理章节,我们已经将用户和电影的特征用数字表示。嵌入层Embedding可以完成数字到向量的映射。
飞桨支持Embedding API,该接口根据输入从Embedding矩阵中查询对应Embedding信息,并会根据输入参数num_embeddings和embedding_dim自动构造一个二维Embedding矩阵。
class paddle.nn.Embedding (num_embeddings, embedding_dim, padding_idx=None, sparse=False, weight_attr=None, name=None)
常用参数含义如下:
- num_embeddings (int):表示嵌入字典的大小。
- embedding_dim :表示每个嵌入向量的大小。
- sparse (bool):是否使用稀疏更新,在词嵌入权重较大的情况下,使用稀疏更新能够获得更快的训练速度及更小的内存/显存占用。
- weight_attr (ParamAttr):指定嵌入向量的配置,包括初始化方法,具体用法请参见 ParamAttr ,一般无需设置,默认值为None。
我们需要特别注意,embedding函数在输入Tensor shape的最后一维后面添加embedding_dim的维度,所以输出的维度数量会比输入多一个。以下面的代码为例,当输入的Tensor尺寸是[1]、embedding_dim是32时,输出Tensor的尺寸是[1,32]。
import paddle from paddle.nn import Linear, Embedding, Conv2D import numpy as np
import paddle.nn.functional as F import paddle.nn as nn # 声明用户的最大ID,
在此基础上加1(算上数字0) USR_ID_NUM = 6040 + 1 # 声明Embedding 层,
将ID映射为32长度的向量 usr_emb = Embedding(num_embeddings=USR_ID_NUM,
embedding_dim=32,
sparse=False) # 声明输入数据,将其转成tensor arr_1 = np.array([1],
dtype="int64").reshape((-1))
print(arr_1)
arr_pd1 = paddle.to_tensor(arr_1)
print(arr_pd1) # 计算结果 emb_res = usr_emb(arr_pd1) # 打印结果 print("数字 1 的embedding结果是:
", emb_res.numpy(), "\n形状是:", emb_res.shape)
W0510 14:17:46.990694 118 gpu_context.cc:244] Please NOTE: device: 0, GPU Compute
Capability: 7.0, Driver API Version: 10.1, Runtime API Version: 10.1 W0510 14:17:46.995707 118 gpu_context.cc:272] device: 0, cuDNN Version: 7.6.
[1] Tensor(shape=[1], dtype=int64, place=Place(gpu:0), stop_gradient=True, [1]) 数字 1 的embedding结果是: [[ 0.02368815 0.01219996 -0.00823128 -0.02978373 0.015901 -0.01567403 -0.02949063 0.01960909 0.00287736 0.02580381 -0.01716401 0.02730818 -0.00820427 0.01684101 -0.02887885 0.00482129 0.00490872 0.01330269 -0.02448237 -0.00270003 -0.01551332 -0.0038403 0.01186426 0.00623586 0.01695438 -0.02498322 0.02353216 0.02606978 -0.003106 0.00167086 -0.00091827 -0.00629074]] 形状是: [1, 32]
使用Embedding时,需要注意num_embeddings和embedding_dim这两个参数。num_embeddings表示词表大小;embedding_dim表示Embedding层维度。
使用的ml-1m数据集的用户ID最大为6040,考虑到0号ID的存在,因此这里我们需要将num_embeddings设置为6041(=6040+1)。embedding_dim表示将数据映射为embedding_dim维度的向量。这里将用户ID数据1转换成了维度为32的向量表示。32是设置的超参数,读者可以自行调整大小。
通过上面的代码,我们简单了解了Embedding的工作方式,但是Embedding层是如何将数字映射为高维度的向量的呢?
实际上,Embedding层和Conv2D, Linear层一样,Embedding层也有可学习的权重,通过矩阵相乘的方法对输入数据进行映射。Embedding中将输入映射成向量的实际步骤是:
-
将输入数据转换成one-hot格式的向量;
-
one-hot向量和Embedding层的权重进行矩阵相乘得到Embedding的结果。
下面展示了另一个使用Embedding函数的案例。该案例从0到9的10个ID数字中随机取出了3个,查看使用默认初始化方式的Embedding结果,再查看使用KaimingNormal(0均值的正态分布)初始化方式的Embedding结果。实际上,无论使用哪种参数初始化的方式,这些参数都是要在后续的训练过程中优化的,只是更符合任务场景的初始化方式可以使训练更快收敛,部分场景可以取得略好的模型精度。
# 声明用户的最大ID,在此基础上加1(算上数字0) USR_ID_NUM = 10 # 声明Embedding 层,
将ID映射为16长度的向量 usr_emb = Embedding(num_embeddings=USR_ID_NUM,
embedding_dim=16,
sparse=False) # 定义输入数据,输入数据为不超过10的整数,
将其转成tensor arr = np.random.randint(0, 10, (3)).reshape((-1)).astype('int64')
print("输入数据是:", arr)
arr_pd = paddle.to_tensor(arr)
emb_res = usr_emb(arr_pd)
print("默认权重初始化embedding层的映射结果是:", emb_res.numpy())
# 观察Embedding层的权重 emb_weights = usr_emb.state_dict()
print(emb_weights.keys())
print("\n查看embedding层的权重形状:", emb_weights['weight'].shape) # 声明Embedding 层,
将ID映射为16长度的向量,自定义权重初始化方式 # 定义KaimingNorma初始化方式 init = nn.initializer.KaimingNormal()
param_attr = paddle.ParamAttr(initializer=init)
usr_emb2 = Embedding(num_embeddings=USR_ID_NUM,
embedding_dim=16,
weight_attr=param_attr)
emb_res = usr_emb2(arr_pd)
print("\KaimingNormal初始化权重embedding层的映射结果是:", emb_res.numpy())
输入数据是: [7 8 4] 默认权重初始化embedding层的映射结果是: [[-0.30219606 0.04819411 0.35495412
-0.3642243 -0.36151826 -0.40167788 -0.11418844 -0.05883922 -0.19489087 0.27258605 0.27192017 -0.16807187 -0.26405373 0.38354018 -0.14843857 0.13665266] [-0.27798435 0.47645292 -0.02857365 0.38537413 -0.01224227 -0.07392766 -0.26656023 0.44906002 -0.29267475 0.40447238 -0.46071985 0.4707619 0.04047209 0.3846125 -0.2702147 -0.1358081 ] [-0.32356432 0.44799465 0.16237244 -0.07174106 -0.4018759 -0.0672017 -0.33784467 -0.00767259 -0.46760723 0.43617278 0.20620881 0.33908042 0.09195426 -0.02735831 0.44423872 0.44703734]] odict_keys(['weight']) 查看embedding层的权重形状: [10, 16] \KaimingNormal初始化权重embedding层的映射结果是: [[-0.8732991 -0.296549
-0.6499242 -0.18128943 0.2104175 0.2845017 0.47690234 -0.3240256 -0.60670716 -0.36758235 -0.1349561 -0.3722007 -0.5186614 0.458018 -0.1959971 -0.24634318] [ 0.19082223 0.33218718 -0.08041648 -0.26168904 -0.20773531 0.37868896 -0.25120804 -0.35964993 0.40198246 0.09764699 -0.6328808 0.31921613 -1.1502644 1.0776317 0.1328861 0.07993323] [ 0.4113859 -0.1422932 -0.6185957 0.5662455 -0.29964566 -0.24534303 0.22549151 -0.5049157 -0.15325141 -0.24140377 0.86391056 -0.07348516 -0.16333051 0.16322982 -0.23241279 -0.06384748]]
<>:29: DeprecationWarning: invalid escape sequence \K <>:29: DeprecationWarning: invalid escape sequence \K <>:29: DeprecationWarning: invalid escape sequence \K /tmp/ipykernel_118/1089486854.py:29: DeprecationWarning: invalid escape sequence \K print("\KaimingNormal初始化权重embedding层的映射结果是:", emb_res.numpy())
上面代码中,我们在[0, 10]范围内随机产生了3个整数,因此数据的最大值为整数9,最小为0。因此,输入数据映射为每个one-hot向量的维度是10,定义Embedding权重的第一个维度USR_ID_NUM为10。
这里输入的数据shape是[3, 1],Embedding层的权重形状则是[10, 16],Embedding在计算时,首先将输入数据转换成one-hot向量,one-hot向量的长度和Embedding层的输入参数size的第一个维度有关。比如这里我们设置的是10,所以输入数据将被转换成维度为[3, 10]的one-hot向量,参数size决定了Embedding层的权重形状。最终维度为[3, 10]的one-hot向量与维度为[10, 16]Embedding权重相乘,得到最终维度为[3, 16]的映射向量。
我们也可以对Embeding层的权重进行初始化,如果不设置初始化方式,则采用默认的初始化方式。
神经网络处理文本数据时,需要用数字代替文本,Embedding层则是将输入数字数据映射成了高维向量,然后就可以使用卷积、全连接、LSTM等网络层处理数据了,接下来我们开始设计用户和电影数据的特征提取网络。
理解Embedding后,我们就可以开始构建提取用户特征的神经网络了。

图2:提取用户特征网络示意
用户特征网络主要包括:
- 将用户ID数据映射为向量表示,通过全连接层得到ID特征。
- 将用户性别数据映射为向量表示,通过全连接层得到性别特征。
- 将用户职业数据映射为向量表示,通过全连接层得到职业特征。
- 将用户年龄数据影射为向量表示,通过全连接层得到年龄特征。
- 融合ID、性别、职业、年龄特征,得到用户的特征表示。
在用户特征计算网络中,我们对每个用户数据做embedding处理,然后经过一个全连接层,激活函数使用ReLU,得到用户所有特征后,将特征整合,经过一个全连接层得到最终的用户数据特征,该特征的维度是200维,用于和电影特征计算相似度。
1. 提取用户ID特征
开始构建用户ID的特征提取网络,ID特征提取包括两个部分。首先,使用Embedding将用户ID映射为向量;然后,使用一层全连接层和ReLU激活函数进一步提取用户ID特征。 相比较电影类别和电影名称,用户ID只包含一个数字,数据更为简单。这里需要考虑将用户ID映射为多少维度的向量合适,使用维度过大的向量表示用户ID容易造成信息冗余,维度过低又不足以表示该用户的特征。理论上来说,如果使用二进制表示用户ID,用户最大ID是6040,小于2的13次方,因此,理论上使用13维度的向量已经足够了,为了让不同ID的向量更具区分性,我们选择将用户ID映射为维度为32维的向量。
下面是用户ID特征提取代码实现:
# 自定义一个用户ID数据 usr_id_data = np.random.randint(0, 6040, (2)).reshape((-1)).astype('int64')
print("输入的用户ID是:", usr_id_data)
USR_ID_NUM = 6040 + 1 # 定义用户ID的embedding层和fc层 usr_emb = Embedding(num_embeddings=USR_ID_NUM,
embedding_dim=32,
sparse=False)
usr_fc = Linear(in_features=32, out_features=32)
usr_id_var = paddle.to_tensor(usr_id_data)
usr_id_feat = usr_fc(usr_emb(usr_id_var))
usr_id_feat = F.relu(usr_id_feat)
print("用户ID的特征是:", usr_id_feat.numpy(), "\n其形状是:", usr_id_feat.shape)
输入的用户ID是: [404 55] 用户ID的特征是: [[0.02347164 0. 0.00114313 0. 0. 0.01086394 0. 0. 0. 0.01131915 0.02601143 0.0191745 0. 0. 0. 0.03315771 0. 0. 0.0051814 0.01694535 0. 0.02378889 0.00864096 0.02959694 0.0082613 0. 0.02341099 0. 0. 0. 0. 0.0023096 ] [0.03071208 0. 0. 0.00296267 0.01383776 0. 0. 0. 0.00759998 0.00332768 0.00829613 0.0228811 0. 0.01196356 0. 0.01992911 0.01161416 0.00963254 0. 0.01359453 0.00222658 0.03191457 0. 0.02897926 0. 0. 0.03653618 0. 0. 0.00512143 0. 0. ]] 其形状是: [2, 32]
注意到,将用户ID映射为one-hot向量时,Embedding层参数size的第一个参数是,在用户的最大ID基础上加上1。原因很简单,从上一节数据处理已经发现,用户ID是从1开始计数的,最大的用户ID是6040。并且已经知道通过Embedding映射输入数据时,是先把输入数据转换成one-hot向量。向量中只有一个 1 的向量才被称为one-hot向量,比如,0 用四维的on-hot向量表示是[1, 0 ,0 ,0],同时,4维的one-hot向量最大只能表示3。所以,要把数字6040用one-hot向量表示,至少需要用6041维度的向量。
接下来我们会看到,类似的Embeding层也适用于处理用户性别、年龄和职业,以及电影ID等特征,实现代码均是类似的。
# 自定义一个用户性别数据 usr_gender_data = np.array((0, 1)).reshape(-1).astype('int64')
print("输入的用户性别是:", usr_gender_data) # 用户的性别用0, 1 表示 # 性别最大ID是1,
所以Embedding层size的第一个参数设置为1 + 1 = 2 USR_ID_NUM = 2 # 对用户性别信息做映射,
并紧接着一个FC层 USR_GENDER_DICT_SIZE = 2 usr_gender_emb = Embedding(num_embeddings=USR_GENDER_DICT_SIZE,
embedding_dim=16)
usr_gender_fc = Linear(in_features=16, out_features=16)
usr_gender_var = paddle.to_tensor(usr_gender_data)
usr_gender_feat = usr_gender_fc(usr_gender_emb(usr_gender_var))
usr_gender_feat = F.relu(usr_gender_feat)
print("用户性别特征的数据特征是:", usr_gender_feat.numpy(), "\n其形状是:", usr_gender_feat.shape)
print("\n性别 0 对应的特征是:", usr_gender_feat.numpy()[0, :])
print("性别 1 对应的特征是:", usr_gender_feat.numpy()[1, :])
输入的用户性别是: [0 1] 用户性别特征的数据特征是: [[0.24384888 0.17959395 0.04321878 0.03975486 0. 0. 0.36707422 0. 0. 0. 0. 0.09878127 0.06470658 0. 0.0828817 0.1003792 ] [0.14861679 0. 0.16529012 0.25208503 0.23453823 0. 0.28215045 0. 0.333888 0.20844592 0.21952112 0.17279935 0. 0.16142237 0.35091862 0.3652628 ]] 其形状是: [2, 16] 性别 0 对应的特征是: [0.24384888 0.17959395 0.04321878 0.03975486 0. 0. 0.36707422 0. 0. 0. 0. 0.09878127 0.06470658 0. 0.0828817 0.1003792 ] 性别 1 对应的特征是: [0.14861679 0. 0.16529012 0.25208503 0.23453823 0. 0.28215045 0. 0.333888 0.20844592 0.21952112 0.17279935 0. 0.16142237 0.35091862 0.3652628 ]
# 自定义一个用户年龄数据 usr_age_data = np.array((1, 18)).reshape(-1).astype('int64')
print("输入的用户年龄是:", usr_age_data) # 对用户年龄信息做映射,并紧接着一个Linear层
# 年龄的最大ID是56,所以Embedding层size的第一个参数设置为56 + 1 = 57 USR_AGE_DICT_SIZE =
56 + 1 usr_age_emb = Embedding(num_embeddings=USR_AGE_DICT_SIZE,
embedding_dim=16)
usr_age_fc = Linear(in_features=16, out_features=16)
usr_age = paddle.to_tensor(usr_age_data)
usr_age_feat = usr_age_emb(usr_age)
usr_age_feat = usr_age_fc(usr_age_feat)
usr_age_feat = F.relu(usr_age_feat)
print("用户年龄特征的数据特征是:", usr_age_feat.numpy(), "\n其形状是:", usr_age_feat.shape)
print("\n年龄 1 对应的特征是:", usr_age_feat.numpy()[0, :])
print("年龄 18 对应的特征是:", usr_age_feat.numpy()[1, :])
输入的用户年龄是: [ 1 18] 用户年龄特征的数据特征是: [[0.02967796 0. 0.4201206 0. 0. 0. 0.10887109 0.16244933 0.17550364 0. 0. 0. 0. 0. 0.29681575 0.15350553] [0.04744595 0. 0.12343244 0. 0. 0. 0.20867339 0.25492352 0.161194 0. 0.296985 0.3580197 0.16696471 0. 0. 0.24892162]] 其形状是: [2, 16] 年龄 1 对应的特征是: [0.02967796 0. 0.4201206 0. 0. 0. 0.10887109 0.16244933 0.17550364 0. 0. 0. 0. 0. 0.29681575 0.15350553] 年龄 18 对应的特征是: [0.04744595 0. 0.12343244 0. 0. 0. 0.20867339 0.25492352 0.161194 0. 0.296985 0.3580197 0.16696471 0. 0. 0.24892162]
# 自定义一个用户职业数据 usr_job_data = np.array((0, 20)).reshape(-1).astype('int64')
print("输入的用户职业是:", usr_job_data) # 对用户职业信息做映射,并紧接着一个Linear层
# 用户职业的最大ID是20,所以Embedding层size的第一个参数设置为20 + 1 = 21 USR_JOB_DICT_S
IZE = 20 + 1 usr_job_emb = Embedding(num_embeddings=USR_JOB_DICT_SIZE,embedding_dim=16)
usr_job_fc = Linear(in_features=16, out_features=16)
usr_job = paddle.to_tensor(usr_job_data)
usr_job_feat = usr_job_emb(usr_job)
usr_job_feat = usr_job_fc(usr_job_feat)
usr_job_feat = F.relu(usr_job_feat)
print("用户年龄特征的数据特征是:", usr_job_feat.numpy(), "\n其形状是:", usr_job_feat.shape)
print("\n职业 0 对应的特征是:", usr_job_feat.numpy()[0, :])
print("职业 20 对应的特征是:", usr_job_feat.numpy()[1, :])
输入的用户职业是: [ 0 20] 用户年龄特征的数据特征是: [[0. 0. 0. 0. 0. 0.35793442 0.08542033 0. 0.4056552 0.4409296 0. 0. 0. 0.06639221 0. 0.14746074] [0.01027822 0. 0. 0.04573794 0.20069996 0.18759565 0. 0.1698367 0.21855173 0. 0. 0. 0. 0.29107115 0. 0. ]] 其形状是: [2, 16] 职业 0 对应的特征是: [0. 0. 0. 0. 0. 0.35793442 0.08542033 0. 0.4056552 0.4409296 0. 0. 0. 0.06639221 0. 0.14746074] 职业 20 对应的特征是: [0.01027822 0. 0. 0.04573794 0.20069996 0.18759565 0. 0.1698367 0.21855173 0. 0. 0. 0. 0.29107115 0. 0. ]
FC_ID = Linear(in_features=32, out_features=200)
FC_JOB = Linear(in_features=16, out_features=200)
FC_AGE = Linear(in_features=16, out_features=200)
FC_GENDER = Linear(in_features=16, out_features=200) # 收集所有的用户特征
_features = [usr_id_feat, usr_job_feat, usr_age_feat, usr_gender_feat]
_features = [k.numpy() for k in _features]
_features = [paddle.to_tensor(k) for k in _features]
id_feat = F.tanh(FC_ID(_features[0]))
job_feat = F.tanh(FC_JOB(_features[1]))
age_feat = F.tanh(FC_AGE(_features[2]))
genger_feat = F.tanh(FC_GENDER(_features[-1])) # 对特征求和 usr_feat = id_feat + job_feat + age_feat + genger_feat
print("用户融合后特征的维度是:", usr_feat.shape)
用户融合后特征的维度是: [2, 200]
这里使用全连接层进一步提取特征,而不是直接相加得到用户特征的原因有两点:
- 一是用户每个特征数据维度不一致,无法直接相加;
- 二是用户每个特征仅使用了一层全连接层,提取特征不充分,多使用一层全连接层能进一步提取特征。而且,这里用高维度(200维)的向量表示用户特征,能包含更多的信息,每个用户特征之间的区分也更明显。
上述实现中需要对每个特征都使用一个全连接层,实现较为复杂,一种简单的替换方式是,先将每个用户特征沿着长度维度进行级联,然后使用一个全连接层获得整个用户特征向量,两种方式的对比见下图:
图3:两种特征方式对比示意
两种方式均可实现向量的合并,虽然两者的数学公式不同,但它们的表达方式是类似的。
下面是方式2的代码实现。
usr_combined = Linear(in_features=80, out_features=200) # 收集所有的用户特征
_features = [usr_id_feat, usr_job_feat, usr_age_feat, usr_gender_feat]
print("打印每个特征的维度:", [f.shape for f in _features])
_features = [k.numpy() for k in _features]
_features = [paddle.to_tensor(k) for k in _features]
# 对特征沿着最后一个维度级联 usr_feat = paddle.concat(_features, axis=1)
usr_feat = F.tanh(usr_combined(usr_feat))
print("用户融合后特征的维度是:", usr_feat.shape)
打印每个特征的维度: [[2, 32], [2, 16], [2, 16], [2, 16]] 用户融合后特征的维度是: [2, 200]
上述代码中,使用了paddle.concat API,表示沿着第几个维度将输入数据级联到一起。
paddle.concat (x, axis=0, name=None)
常用参数含义如下:
- x (list|tuple):待联结的Tensor list或者Tensor tuple ,x中所有Tensor的数据类型应该一致。
- axis (int|Tensor,可选) :指定对输入x进行运算的轴,默认值为0。
至此我们已经完成了用户特征提取网络的设计,包括ID特征提取、性别特征提取、年龄特征提取、职业特征提取和特征融合模块,下面我们将所有的模块整合到一起,放到Python类中,完整代码实现如下:
import random import math class Model(nn.Layer): def __init__(self, use_poster,
use_mov_title, use_mov_cat, use_age_job,fc_sizes): super(Model, self).__init__()
# 将传入的name信息和bool型参数添加到模型类中 self.use_mov_poster = use_poster
self.use_mov_title = use_mov_title
self.use_usr_age_job = use_age_job
self.use_mov_cat = use_mov_cat
self.fc_sizes = fc_sizes # 使用上节定义的数据处理类,获取数据集的信息,
并构建训练和验证集的数据迭代器 Dataset = MovieLen(self.use_mov_poster)
self.Dataset = Dataset
self.trainset = self.Dataset.train_dataset
self.valset = self.Dataset.valid_dataset
self.train_loader = self.Dataset.load_data(dataset=self.trainset, mode='train')
self.valid_loader = self.Dataset.load_data(dataset=self.valset, mode='valid') "
"" define network layer for embedding usr info """ USR_ID_NUM = Dataset.max_usr_id + 1 #
对用户ID做映射,并紧接着一个FC层 self.usr_emb = Embedding(num_embeddings=USR_ID_NUM,embedding_dim=32)
self.usr_fc = Linear(32, 32) # 对用户性别信息做映射,并紧接着一个FC层 USR_GENDER_DIC
T_SIZE = 2 self.usr_gender_emb = Embedding(num_embeddings=USR_GENDER_DICT_SIZE,embedding_dim=16)
self.usr_gender_fc = Linear(16, 16) # 对用户年龄信息做映射,并紧接着一个FC层 USR_AGE_
DICT_SIZE = Dataset.max_usr_age + 1 self.usr_age_emb = Embedding(num_embeddings
=USR_AGE_DICT_SIZE,embedding_dim=16)
self.usr_age_fc = Linear(16, 16) # 对用户职业信息做映射,并紧接着一个FC层
USR_JOB_DICT_SIZE = Dataset.max_usr_job + 1 self.usr_job_emb = Embedding
(num_embeddings=USR_JOB_DICT_SIZE,embedding_dim=16)
self.usr_job_fc = Linear(16, 16) # 新建一个FC层,用于整合用户数据信息 self.
usr_combined = Linear(80, 200) # 新建一个Linear层,用于整合电影特征 self.mov_concat
_embed = Linear(in_features=96, out_features=200)
user_sizes = [200] + self.fc_sizes
acts = ["relu" for _ in range(len(self.fc_sizes))]
self._user_layers = [] for i in range(len(self.fc_sizes)):
linear = Linear(
in_features=user_sizes[i],
out_features=user_sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=nn.initializer.Normal(
std=1.0 / math.sqrt(user_sizes[i])))) # 向模型中添加了一个
paddle.nn.Linear 子层 self.add_sublayer('linear_user_%d' % i, linear)
self._user_layers.append(linear) if acts[i] == 'relu':
act = nn.ReLU() # 向模型中添加了一个 paddle.nn.ReLU() 子层 self
.add_sublayer('user_act_%d' % i, act)
self._user_layers.append(act) # 定义计算用户特征的前向运算过程
def get_usr_feat(self, usr_var): """ get usr features""" # 获取到用户数据 usr_id,
usr_gender, usr_age, usr_job = usr_var # 将用户的ID数据经过embedding和FC计算,
得到的特征保存在feats_collect中 feats_collect = []
usr_id = self.usr_emb(usr_id)
usr_id = self.usr_fc(usr_id)
usr_id = F.relu(usr_id)
feats_collect.append(usr_id) # 计算用户的性别特征,并保存在feats_collect中
usr_gender = self.usr_gender_emb(usr_gender)
usr_gender = self.usr_gender_fc(usr_gender)
usr_gender = F.relu(usr_gender)
feats_collect.append(usr_gender) # 选择是否使用用户的年龄-职业特征
if self.use_usr_age_job: # 计算用户的年龄特征,并保存在feats_collect中
usr_age = self.usr_age_emb(usr_age)
usr_age = self.usr_age_fc(usr_age)
usr_age = F.relu(usr_age)
feats_collect.append(usr_age) # 计算用户的职业特征,并保存在feats_
collect中 usr_job = self.usr_job_emb(usr_job)
usr_job = self.usr_job_fc(usr_job)
usr_job = F.relu(usr_job)
feats_collect.append(usr_job) # 将用户的特征级联,
并通过FC层得到最终的用户特征 print([f.shape for f in feats_collect])
usr_feat = paddle.concat(feats_collect, axis=1)
user_features = F.tanh(self.usr_combined(usr_feat)) #通过3层全链接层,
获得用于计算相似度的用户特征和电影特征 for n_layer in self._user_layers:
user_features = n_layer(user_features) return user_features
#下面使用定义好的数据读取器,实现从用户数据读取到用户特征计算的流程:
## 测试用户特征提取网络 fc_sizes=[128, 64, 32]
model = Model(use_poster=False, use_mov_title=True, use_mov_cat=True,
use_age_job=True,fc_sizes=fc_sizes)
model.eval()
data_loader = model.train_loader for idx, data in enumerate(data_loader()):
# 获得数据,并转为动态图格式, usr, mov, score = data # print(usr.shape)
# 只使用每个Batch的第一条数据 usr_v = [[var[0]] for var in usr]
print("输入的用户ID数据:{}\n性别数据:{} \n年龄数据:{} \n职业数据{}".format(*usr_v))
usr_v = [paddle.to_tensor(np.array(var)) for var in usr_v]
usr_feat = model.get_usr_feat(usr_v)
print("计算得到的用户特征维度是:", usr_feat.shape) break
##Total dataset instances: 1000209 ##MovieLens dataset information: usr num: 6040 movies num: 3883 输入的用户ID数据:[5178] 性别数据:[0] 年龄数据:[50] 职业数据[0] [[1, 32], [1, 16], [1, 16], [1, 16]] 计算得到的用户特征维度是: [1, 32]
上面使用了向量级联+全连接的方式实现了四个用户特征向量的合并,为了捕获特征向量的深层次语义信息,合并后的向量还加入了3层全链接结构。在下面处理电影特征的部分我们会看到使用另外一种向量合并的方式(向量相加)处理电影类型的特征(6个向量合并成1个向量),然后再加上全连接。
# 自定义一个电影ID数据 mov_id_data = np.array((1, 2)).reshape(-1).astype('int64')
# 对电影ID信息做映射,并紧接着一个FC层 MOV_DICT_SIZE = 3952 + 1 mov_emb =
Embedding(num_embeddings=MOV_DICT_SIZE, embedding_dim=32)
mov_fc = Linear(32, 32)
print("输入的电影ID是:", mov_id_data)
mov_id_data = paddle.to_tensor(mov_id_data)
mov_id_feat = mov_fc(mov_emb(mov_id_data))
mov_id_feat = F.relu(mov_id_feat)
print("计算的电影ID的特征是", mov_id_feat.numpy(), "\n其形状是:", mov_id_feat.shape)
print("\n电影ID为 {} 计算得到的特征是:{}".format(mov_id_data.numpy()[0], mov_id_feat.numpy()[0]))
print("电影ID为 {} 计算得到的特征是:{}".format(mov_id_data.numpy()[1], mov_id_feat.numpy()[1]))
输入的电影ID是: [1 2] 计算的电影ID的特征是 [[0. 0. 0.0344948 0. 0. 0.01723779 0.00151697 0. 0. 0.04818694 0. 0.00199255 0. 0. 0. 0. 0. 0. 0.00550474 0.03392927 0.00320259 0.02673938 0.03200788 0. 0. 0.0053771 0. 0.02337028 0.01172634 0. 0. 0.02325258] [0. 0.00508729 0. 0.01128812 0.03763831 0.00117204 0.04002637 0. 0.0037948 0. 0. 0. 0. 0. 0.00327912 0.00171694 0.02369349 0.03612199 0. 0. 0. 0. 0. 0.0012748 0. 0.00542149 0.01313424 0.00820849 0. 0.02373876 0.01443977 0.00015521]] 其形状是: [2, 32] 电影ID为 1 计算得到的特征是:[0. 0. 0.0344948 0. 0. 0.01723779 0.00151697 0. 0. 0.04818694 0. 0.00199255 0. 0. 0. 0. 0. 0. 0.00550474 0.03392927 0.00320259 0.02673938 0.03200788 0. 0. 0.0053771 0. 0.02337028 0.01172634 0. 0. 0.02325258] 电影ID为 2 计算得到的特征是:[0. 0.00508729 0. 0.01128812 0.03763831 0.00117204 0.04002637 0. 0.0037948 0. 0. 0. 0. 0. 0.00327912 0.00171694 0.02369349 0.03612199 0. 0. 0. 0. 0. 0.0012748 0. 0.00542149 0.01313424 0.00820849 0. 0.02373876 0.01443977 0.00015521]
2. 提取电影类别特征
与电影ID数据不同的是,每个电影有多个类别,提取类别特征时,如果对每个类别数据都使用一个全连接层,电影最多的类别数是6,会导致类别特征提取网络参数过多而不利于学习。我们对于电影类别特征提取的处理方式是:
- 通过Embedding网络层将电影类别数字映射为特征向量;
- 对Embedding后的向量沿着类别数量维度进行求和,得到一个类别映射向量;
- 通过一个全连接层计算类别特征向量。
数据处理章节已经介绍到,每个电影的类别数量是不固定的,且一个电影最大的类别数量是6,类别数量不足6的通过补0到6维。因此,每个类别的数据维度是6,每个电影类别有6个Embedding向量。我们希望用一个向量就可以表示电影类别,可以对电影类别数量维度降维, 这里对6个Embedding向量通过求和的方式降维,得到电影类别的向量表示。
下面是电影类别特征提取的实现方法:
# 自定义一个电影类别数据 mov_cat_data = np.array(((1, 2, 3, 0, 0, 0), (2, 3, 4, 0, 0, 0)))
.reshape(2, -1).astype('int64') # 对电影ID信息做映射,并紧接着一个Linear层 MOV_DICT_SIZE =
6 + 1 mov_emb = Embedding(num_embeddings=MOV_DICT_SIZE, embedding_dim=32)
mov_fc = Linear(in_features=32, out_features=32)
print("输入的电影类别是:", mov_cat_data[:, :])
mov_cat_data = paddle.to_tensor(mov_cat_data) # 1. 通过Embedding映射电影类别数据; mov_ca
t_feat = mov_emb(mov_cat_data) # 2. 对Embedding后的向量沿着类别数量维度进行求和,得到一个
类别映射向量; mov_cat_feat = paddle.sum(mov_cat_feat, axis=1, keepdim=False) # 3. 通过一
个全连接层计算类别特征向量。 mov_cat_feat = mov_fc(mov_cat_feat)
mov_cat_feat = F.relu(mov_cat_feat)
print("计算的电影类别的特征是", mov_cat_feat.numpy(), "\n其形状是:", mov_cat_feat.shape)
print("\n电影类别为 {} 计算得到的特征是:{}".format(mov_cat_data.numpy()[0, :], mov_cat_feat.numpy()[0]))
print("\n电影类别为 {} 计算得到的特征是:{}".format(mov_cat_data.numpy()[1, :], mov_cat_feat.numpy()[1]))
输入的电影类别是: [[1 2 3 0 0 0] [2 3 4 0 0 0]] 计算的电影类别的特征是 [[0. 0. 0. 1.5420063 1.0931857 0. 0.8528621 1.1500634 0. 0. 0. 0.24374259 0.8104427 0.57466364 0. 0.15235186 0.7508758 0.2375395 1.2854273 0. 0. 0. 1.463468 0.5166065 0.7419301 0. 0.8440274 0.31018406 0. 0.09525555 0.32042602 0. ] [0. 0.01670983 0. 1.2781187 0.8346788 0. 0.37966564 1.0817952 0. 0. 0. 0.3001263 1.3019797 0.37096965 0. 0.19666117 0.40992758 0.21654108 1.3651938 0. 0.6437582 0. 1.3053846 0.142402 0.21435474 0. 0.89379585 0.47872669 0. 0. 0.6058731 0. ]] 其形状是: [2, 32] 电影类别为 [1 2 3 0 0 0] 计算得到的特征是:[0. 0. 0. 1.5420063 1.0931857 0. 0.8528621 1.1500634 0. 0. 0. 0.24374259 0.8104427 0.57466364 0. 0.15235186 0.7508758 0.2375395 1.2854273 0. 0. 0. 1.463468 0.5166065 0.7419301 0. 0.8440274 0.31018406 0. 0.09525555 0.32042602 0. ] 电影类别为 [2 3 4 0 0 0] 计算得到的特征是:[0. 0.01670983 0. 1.2781187 0.8346788 0. 0.37966564 1.0817952 0. 0. 0. 0.3001263 1.3019797 0.37096965 0. 0.19666117 0.40992758 0.21654108 1.3651938 0. 0.6437582 0. 1.3053846 0.142402 0.21435474 0. 0.89379585 0.47872669 0. 0. 0.6058731 0. ]
待合并的6个向量具有相同的维度,直接按位相加即可得到综合的向量表示。当然,我们也可以采用向量级联的方式,将6个32维的向量级联成192维的向量,再通过全连接层压缩成32维度,代码实现上要臃肿一些。
3. 提取电影名称特征
与电影类别数据一样,每个电影名称具有多个单词。我们对于电影名称特征提取的处理方式是:
- 通过Embedding映射电影名称数据,得到对应的特征向量;
- 对Embedding后的向量使用卷积层+全连接层进一步提取特征;
- 对特征进行降采样,降低数据维度。
提取电影名称特征时,使用了卷积层加全连接层的方式提取特征。这是因为电影名称单词较多,最大单词数量是15,如果采用和电影类别同样的处理方式,即沿着数量维度求和,显然会损失很多信息。考虑到15这个维度较高,可以使用卷积层进一步提取特征,同时通过控制卷积层的步长,降低电影名称特征的维度。
如果只是简单的经过一层或二层卷积后,特征的维度依然很大,为了得到更低维度的特征向量,有两种方式,一种是利用求和降采样的方式,另一种是继续使用神经网络层进行特征提取并逐渐降低特征维度。这里,我们采用“简单求和”的降采样方式压缩电影名称特征的维度,通过飞桨的reduce_sum API实现。
下面是提取电影名称特征的代码实现:
# 自定义两个电影名称数据 mov_title_data = np.array(((1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
(2, 3, 4, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))).reshape(2, 1, 15)
.astype('int64') # 对电影名称做映射,紧接着FC和pool层 MOV_TITLE_DICT_SIZE = 1000 + 1 mov_title_
emb = Embedding(num_embeddings=MOV_TITLE_DICT_SIZE, embedding_dim=32)
mov_title_conv = Conv2D(in_channels=1, out_channels=1, kernel_size=(3, 1), stride=(2, 1), padding=0)
# 使用 3 * 3卷积层代替全连接层 mov_title_conv2 = Conv2D(in_channels=1,
out_channels=1, kernel_size=(3, 1), stride=1, padding=0)
mov_title_data = paddle.to_tensor(mov_title_data)
print("电影名称数据的输入形状: ", mov_title_data.shape) # 1. 通过Embedding映射电影名称数据;
mov_title_feat = mov_title_emb(mov_title_data)
print("输入通过Embedding层的输出形状: ", mov_title_feat.shape) # 2. 对Embedding后的向量使用卷
积层进一步提取特征; mov_title_feat = F.relu(mov_title_conv(mov_title_feat))
print("第一次卷积之后的特征输出形状: ", mov_title_feat.shape)
mov_title_feat = F.relu(mov_title_conv2(mov_title_feat))
print("第二次卷积之后的特征输出形状: ", mov_title_feat.shape)
batch_size = mov_title_data.shape[0] # 3. 最后对特征进行降采样,keepdim=False会让输出的维度减少,
而不是用[2,1,1,32]的形式占位; mov_title_feat = paddle.sum(mov_title_feat, axis=2, keepdim=False)
print("reduce_sum降采样后的特征输出形状: ", mov_title_feat.shape)
mov_title_feat = F.relu(mov_title_feat)
mov_title_feat = paddle.reshape(mov_title_feat, [batch_size, -1])
print("电影名称特征的最终特征输出形状:", mov_title_feat.shape)
print("\n计算的电影名称的特征是", mov_title_feat.numpy(), "\n其形状是:", mov_title_feat.shape)
print("\n电影名称为 {} 计算得到的特征是:{}".format(mov_title_data.numpy()[0,:, 0], mov_title_feat.numpy()[0]))
print("\n电影名称为 {} 计算得到的特征是:{}".format(mov_title_data.numpy()[1,:, 0], mov_title_feat.numpy()[1]))
电影名称数据的输入形状: [2, 1, 15] 输入通过Embedding层的输出形状: [2, 1, 15, 32] 第一次卷积之后的特征输出形状: [2, 1, 7, 32] 第二次卷积之后的特征输出形状: [2, 1, 5, 32] reduce_sum降采样后的特征输出形状: [2, 1, 32] 电影名称特征的最终特征输出形状: [2, 32] 计算的电影名称的特征是 [[1.3754315e+00 2.7587619e-01 1.9266498e-01 1.5585530e+00 0.0000000e+00 0.0000000e+00 7.3066831e-01 0.0000000e+00 9.3057525e-01 8.0920178e-01 1.5832400e-01 0.0000000e+00 0.0000000e+00 0.0000000e+00 1.4185984e+00 2.0689912e-02 1.2514207e+00 1.3592010e-02 1.7805848e-02 0.0000000e+00 8.5589814e-01 0.0000000e+00 1.3909513e+00 3.8348594e-01 1.4075129e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00 1.0326455e-01 4.6334004e-01 6.2843807e-02] [1.3808274e+00 2.6948732e-01 1.9868097e-01 1.5629089e+00 2.7519925e-02 0.0000000e+00 7.3248041e-01 1.8798964e-02 9.1862303e-01 7.8973246e-01 1.4667867e-01 2.9448545e-02 1.4881353e-03 0.0000000e+00 1.4290810e+00 1.5900003e-02 1.2217724e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00 8.5169816e-01 0.0000000e+00 1.4008083e+00 3.9506012e-01 1.3901806e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00 1.1702511e-02 1.0985479e-01 4.6334004e-01 6.3290432e-02]] 其形状是: [2, 32] 电影名称为 [1] 计算得到的特征是:[1.3754315 0.2758762 0.19266498 1.558553 0. 0. 0.7306683 0. 0.93057525 0.8092018 0.158324 0. 0. 0. 1.4185984 0.02068991 1.2514207 0.01359201 0.01780585 0. 0.85589814 0. 1.3909513 0.38348594 1.4075129 0. 0. 0. 0. 0.10326455 0.46334004 0.06284381] 电影名称为 [2] 计算得到的特征是:[1.3808274e+00 2.6948732e-01 1.
9868097e-01 1.5629089e+00 2.7519925e-02 0.0000000e+00 7.3248041e-01 1.8798964e-02 9.1862303e-01 7.8973246e-01 1.4667867e-01 2.9448545e-02 1.4881353e-03 0.0000000e+00 1.4290810e+00 1.5900003e-02 1.2217724e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00 8.5169816e-01 0.0000000e+00 1.4008083e+00 3.9506012e-01 1.3901806e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00 1.1702511e-02 1.0985479e-01 4.6334004e-01 6.3290432e-02]
上述代码中,通过Embedding层已经获得了维度是[batch_size, 1, 15, 32]电影名称特征向量,因此,该特征可以视为是通道数量为1的特征图,很适合使用卷积层进一步提取特征。这里我们使用两个3×13\times13×1大小的卷积核的卷积层提取特征,输出通道保持不变,仍然是1。特征维度中15是电影名称中单词的数量(最大数量),使用3×13\times13×1的卷积核,由于卷积感受野的原因,进行卷积时会综合多个单词的特征,同时设置卷积的步长参数stride为(2, 1),即可对电影名称的维度降维,同时保持每个名称的向量长度不变,以防过度压缩每个名称特征的信息。
从输出结果来看,第一个卷积层之后的输出特征维度依然较大,可以使用第二个卷积层进一步提取特征。获得第二个卷积的特征后,特征的维度已经从7×327\times327×32,降低到了5×325\times325×32,因此可以直接使用求和(向量按位相加)的方式沿着电影名称维度进行降采样(5×325\times325×32 -> 1×321\times321×32),得到最终的电影名称特征向量。
需要注意的是,降采样后的数据尺寸依然比下一层要求的输入向量多出一维 [2, 1, 32],所以最终输出前需调整下形状。
mov_combined = Linear(in_features=96, out_features=200)
# 收集所有的电影特征 _features = [mov_id_feat, mov_cat_feat, mov_title_feat]
_features = [k.numpy() for k in _features]
_features = [paddle.to_tensor(k) for k in _features]
# 对特征沿着最后一个维度级联 mov_feat = paddle.concat(_features, axis=1)
mov_feat = mov_combined(mov_feat)
mov_feat = F.tanh(mov_feat)
print("融合后的电影特征维度是:", mov_feat.shape)
融合后的电影特征维度是: [2, 200]
至此已经完成了电影特征提取的网络设计,包括电影ID特征提取、电影类别特征提取和电影名称特征提取。
下面将这些模块整合到一个Python类中,完整代码如下:
class MovModel(nn.Layer): def __init__(self, use_poster, use_mov_title,
use_mov_cat, use_age_job,fc_sizes): super(MovModel, self).__init__() #
将传入的name信息和bool型参数添加到模型类中 self.use_mov_poster = use_poster
self.use_mov_title = use_mov_title
self.use_usr_age_job = use_age_job
self.use_mov_cat = use_mov_cat
self.fc_sizes = fc_sizes # 获取数据集的信息,并构建训练和验证集的数据迭代器
Dataset = MovieLen(self.use_mov_poster)
self.Dataset = Dataset
self.trainset = self.Dataset.train_dataset
self.valset = self.Dataset.valid_dataset
self.train_loader = self.Dataset.load_data(dataset=self.trainset, mode='train')
self.valid_loader = self.Dataset.load_data(dataset=self.valset, mode='valid')
""" define network layer for embedding usr info """ # 对电影ID信息做映射,
并紧接着一个Linear层 MOV_DICT_SIZE = Dataset.max_mov_id + 1 self.mov_emb =
Embedding(num_embeddings=MOV_DICT_SIZE, embedding_dim=32)
self.mov_fc = Linear(32, 32) # 对电影类别做映射 CATEGORY_DICT_SIZE =
len(Dataset.movie_cat) + 1 self.mov_cat_emb = Embedding(num_embeddings=CATEGORY_
DICT_SIZE, embedding_dim=32)
self.mov_cat_fc = Linear(32, 32) # 对电影名称做映射 MOV_TITLE_DICT_SIZE =
len(Dataset.movie_title) + 1 self.mov_title_emb = Embedding(num_embeddings=MOV
_TITLE_DICT_SIZE, embedding_dim=32)
self.mov_title_conv = Conv2D(in_channels=1, out_channels=1,
kernel_size=(3, 1), stride=(2,1), padding=0)
self.mov_title_conv2 = Conv2D(in_channels=1, out_channels=1, kernel_size=(3, 1),
stride=1, padding=0) # 新建一个Linear层,用于整合电影特征 self.mov_concat_embed = Linear
(in_features=96, out_features=200) #电影特征和用户特征使用了不同的全连接层,
不共享参数 movie_sizes = [200] + self.fc_sizes
acts = ["relu" for _ in range(len(self.fc_sizes))]
self._movie_layers = [] for i in range(len(self.fc_sizes)):
linear = Linear(
in_features=movie_sizes[i],
out_features=movie_sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=nn.initializer.Normal(
std=1.0 / math.sqrt(movie_sizes[i]))))
self.add_sublayer('linear_movie_%d' % i, linear)
self._movie_layers.append(linear) if acts[i] == 'relu':
act = nn.ReLU()
self.add_sublayer('movie_act_%d' % i, act)
self._movie_layers.append(act) # 定义电影特征的前向计算过程 def
get_mov_feat(self, mov_var): """ get movie features""" # 获得电影数据 mov_id,
mov_cat, mov_title, mov_poster = mov_var
feats_collect = [] # 获得batchsize的大小 batch_size = mov_id.shape[0] #
计算电影ID的特征,并存在feats_collect中 mov_id = self.mov_emb(mov_id)
mov_id = self.mov_fc(mov_id)
mov_id = F.relu(mov_id)
feats_collect.append(mov_id) # 如果使用电影的种类数据,计算电影种类特征的映射 if
self.use_mov_cat: # 计算电影种类的特征映射,对多个种类的特征求和得到最终特征 mov_cat = self.mov_cat_emb(mov_cat)
print(mov_title.shape)
mov_cat = paddle.sum(mov_cat, axis=1, keepdim=False)
mov_cat = self.mov_cat_fc(mov_cat)
feats_collect.append(mov_cat) if self.use_mov_title: # 计算电影名字的特征映射,
对特征映射使用卷积计算最终的特征 mov_title = self.mov_title_emb(mov_title)
mov_title = F.relu(self.mov_title_conv2(F.relu(self.mov_title_conv(mov_title))))
mov_title = paddle.sum(mov_title, axis=2, keepdim=False)
mov_title = F.relu(mov_title)
mov_title = paddle.reshape(mov_title, [batch_size, -1])
feats_collect.append(mov_title) # 使用一个全连接层,整合所有电影特征,
映射为一个200维的特征向量 mov_feat = paddle.concat(feats_collect, axis=1)
mov_features = F.tanh(self.mov_concat_embed(mov_feat)) for n_layer in self._movie_layers:
mov_features = n_layer(mov_features) return mov_features
由上述电影特征处理的代码可以观察到:
- 电影ID特征的计算方式和用户ID的计算方式相同。
- 对于包含多个元素的电影类别数据,采用将所有元素的映射向量求和的结果,然后加上全连接结构作为最终的电影类别特征表示。考虑到电影类别的数量有限,这里采用简单的求和特征融合方式。
- 对于电影的名称数据,其包含的元素数量多于电影种类元素数量,则采用卷积计算的方式,之后再将计算的特征沿着数据维度进行求和。读者也可自行设计这部分特征的计算网络,并观察最终训练结果。
下面使用定义好的数据读取器,实现从电影数据中提取电影特征。
## 测试电影特征提取网络 fc_sizes=[128, 64, 32]
model = MovModel(use_poster=False, use_mov_title=True, use_mov_cat=True, use_age_job=True,fc_sizes=fc_sizes)
model.eval()
data_loader = model.train_loader for idx, data in enumerate(data_loader())
: # 获得数据,并转为动态图格式 usr, mov, score = data # 只使用每个Batch的第一条数据 mov_v = [var[0:1] for var in mov]
_mov_v = [np.squeeze(var[0:1]) for var in mov]
print("输入的电影ID数据:{}\n类别数据:{} \n名称数据:{} ".format(*_mov_v))
mov_v = [paddle.to_tensor(var) for var in mov_v]
mov_feat = model.get_mov_feat(mov_v)
print("计算得到的电影特征维度是:", mov_feat.shape) break
##Total dataset instances: 1000209 ##MovieLens dataset information: usr num: 6040 movies num: 3883 输入的电影ID数据:3190 类别数据:[ 4 12 0 0 0 0] 名称数据:[4392 0 0 0 0 0 0 0 0 0 0 0 0 0 0] [1, 1, 15] 计算得到的电影特征维度是: [1, 32]
相似度计算
计算得到用户特征和电影特征后,我们还需要计算特征之间的相似度。如果一个用户对某个电影很感兴趣,并给了五分评价,那么该用户和电影特征之间的相似度是很高的。
衡量向量距离(相似度)有多种方案:欧式距离、曼哈顿距离、切比雪夫距离、余弦相似度等,本节我们使用忽略尺度信息的余弦相似度构建相似度矩阵。余弦相似度又称为余弦相似性,是通过计算两个向量的夹角余弦值来评估他们的相似度,如下图,两条红色的直线表示两个向量,之间的夹角可以用来表示相似度大小,角度为0时,余弦值为1,表示完全相似。
余弦相似度的公式为:
similarity=cos(θ)=A⋅BA+B=∑inAi×Bi∑in(Ai)2+∑in(Bi)2similarity
= cos(\theta) = \frac{A\cdot B}{A + B} = \frac{\sum_{i}^{n}A_i
\times B_i}{\sqrt{\sum_{i}^{n}(A_i)^2 + \sum_{i}^{n}(B_i)^2}}
similarity=cos(θ)=A+BA⋅B=∑in(Ai)2+∑in(Bi)2∑inAi×Bi
下面是计算相似度的实现方法,输入用户特征和电影特征,计算出两者之间的相似度。另外,我们将用户对电影的评分作为相似度衡量的标准,由于相似度的数据范围是[0, 1],还需要把计算的相似度扩大到评分数据范围,评分分为1-5共5个档次,所以需要将相似度扩大5倍。使用飞桨scale API,可以对输入数据进行缩放。计算余弦相似度可以使用cosine_similarity API 完成。
def similarty(usr_feature, mov_feature): res = F.cosine_similarity(usr_feature, mov_feature)
res = paddle.scale(res, scale=5) return usr_feat, mov_feat, res #
使用上文计算得到的用户特征和电影特征计算相似度 usr_feat, mov_feat, _sim = similarty(usr_feat, mov_feat)
print("相似度得分是:", np.squeeze(_sim.numpy()))
相似度得分是: 1.563953
从结果中我们发现相似度很小,主要有以下原因:
- 神经网络并没有训练,模型参数都是随机初始化的,提取出的特征没有规律性。
- 计算相似度的用户数据和电影数据相关性很小。
下一节我们就开始训练,让这个网络能够输出有效的用户特征向量和电影特征向量。
class Model(nn.Layer): def __init__(self, use_poster, use_mov_title, use_mov_cat,
use_age_job): super(Model, self).__init__() # 将传入的name信息和bool型参数添加到模型类中
self.use_mov_poster = use_poster
self.use_mov_title = use_mov_title
self.use_usr_age_job = use_age_job
self.use_mov_cat = use_mov_cat # 获取数据集的信息,并构建训练和验证集的数据迭代器
Dataset = MovieLen(self.use_mov_poster)
self.Dataset = Dataset
self.trainset = self.Dataset.train_dataset
self.valset = self.Dataset.valid_dataset
self.train_loader = self.Dataset.load_data(dataset=self.trainset, mode='train')
self.valid_loader = self.Dataset.load_data(dataset=self.valset, mode='valid')
""" define network layer for embedding usr info """ USR_ID_NUM = Dataset.max_usr_id + 1
# 对用户ID做映射,并紧接着一个Linear层 self.usr_emb = Embedding(num_embeddings
=USR_ID_NUM, embedding_dim=32, sparse=False)
self.usr_fc = Linear(in_features=32, out_features=32) # 对用户性别信息做映射,
并紧接着一个Linear层 USR_GENDER_DICT_SIZE = 2 self.usr_gender_emb = Embedding(num_e
mbeddings=USR_GENDER_DICT_SIZE, embedding_dim=16)
self.usr_gender_fc = Linear(in_features=16, out_features=16) # 对用户年龄信息做映射,
并紧接着一个Linear层 USR_AGE_DICT_SIZE = Dataset.max_usr_age + 1 self.usr_age_emb = Embeddi
ng(num_embeddings=USR_AGE_DICT_SIZE, embedding_dim=16)
self.usr_age_fc = Linear(in_features=16, out_features=16) # 对用户职业信息做映射,
并紧接着一个Linear层 USR_JOB_DICT_SIZE = Dataset.max_usr_job + 1 self.usr_job_emb = Embe
dding(num_embeddings=USR_JOB_DICT_SIZE, embedding_dim=16)
self.usr_job_fc = Linear(in_features=16, out_features=16) # 新建一个Linear层,
用于整合用户数据信息 self.usr_combined = Linear(in_features=80, out_features=200) """
define network layer for embedding usr info """ # 对电影ID信息做映射,并紧接着一个Linear层
MOV_DICT_SIZE = Dataset.max_mov_id + 1 self.mov_emb = Embedding(num_embedding
s=MOV_DICT_SIZE, embedding_dim=32)
self.mov_fc = Linear(in_features=32, out_features=32) # 对电影类别做映射
CATEGORY_DICT_SIZE = len(Dataset.movie_cat) + 1 self.mov_cat_emb = Embedding(num_e
mbeddings=CATEGORY_DICT_SIZE, embedding_dim=32, sparse=False)
self.mov_cat_fc = Linear(in_features=32, out_features=32) # 对电影名称做映射
MOV_TITLE_DICT_SIZE = len(Dataset.movie_title) + 1 self.mov_title_emb = Embedding(num
_embeddings=MOV_TITLE_DICT_SIZE, embedding_dim=32, sparse=False)
self.mov_title_conv = Conv2D(in_channels=1, out_channels=1, kernel_size=(3, 1),
stride=(2,1), padding=0)
self.mov_title_conv2 = Conv2D(in_channels=1, out_channels=1, kernel_size=(3, 1),
stride=1, padding=0) # 新建一个FC层,用于整合电影特征
self.mov_concat_embed = Linear(in_features=96, out_features=200)
user_sizes = [200] + self.fc_sizes
acts = ["relu" for _ in range(len(self.fc_sizes))]
self._user_layers = [] for i in range(len(self.fc_sizes)):
linear = Linear(
in_features=user_sizes[i],
out_features=user_sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=nn.initializer.Normal(
std=1.0 / math.sqrt(user_sizes[i]))))
self.add_sublayer('linear_user_%d' % i, linear)
self._user_layers.append(linear) if acts[i] == 'relu':
act = nn.ReLU()
self.add_sublayer('user_act_%d' % i, act)
self._user_layers.append(act) #电影特征和用户特征使用了不同的全连接层,
不共享参数 movie_sizes = [200] + self.fc_sizes
acts = ["relu" for _ in range(len(self.fc_sizes))]
self._movie_layers = [] for i in range(len(self.fc_sizes)):
linear = nn.Linear(
in_features=movie_sizes[i],
out_features=movie_sizes[i + 1],
weight_attr=paddle.ParamAttr(
initializer=nn.initializer.Normal(
std=1.0 / math.sqrt(movie_sizes[i]))))
self.add_sublayer('linear_movie_%d' % i, linear)
self._movie_layers.append(linear) if acts[i] == 'relu':
act = nn.ReLU()
self.add_sublayer('movie_act_%d' % i, act)
self._movie_layers.append(act) # 定义计算用户特征的前向运算过程 def
get_usr_feat(self, usr_var): """ get usr features""" # 获取到用户数据 usr_id,
usr_gender, usr_age, usr_job = usr_var # 将用户的ID数据经过embedding和Linear计算,
得到的特征保存在feats_collect中 feats_collect = []
usr_id = self.usr_emb(usr_id)
usr_id = self.usr_fc(usr_id)
usr_id = F.relu(usr_id)
feats_collect.append(usr_id) # 计算用户的性别特征,并保存在feats_collect中
usr_gender = self.usr_gender_emb(usr_gender)
usr_gender = self.usr_gender_fc(usr_gender)
usr_gender = F.relu(usr_gender)
feats_collect.append(usr_gender) # 选择是否使用用户的年龄-职业特征 if
self.use_usr_age_job: # 计算用户的年龄特征,并保存在feats_collect中 usr_age = self.usr_age_emb(usr_age)
usr_age = self.usr_age_fc(usr_age)
usr_age = F.relu(usr_age)
feats_collect.append(usr_age) # 计算用户的职业特征,并保存在feats
_collect中 usr_job = self.usr_job_emb(usr_job)
usr_job = self.usr_job_fc(usr_job)
usr_job = F.relu(usr_job)
feats_collect.append(usr_job) # 将用户的特征级联,并通过Linear层得到最终的用户特征
usr_feat = paddle.concat(feats_collect, axis=1)
user_features = F.tanh(self.usr_combined(usr_feat)) #通过3层全链接层,
获得用于计算相似度的用户特征和电影特征 for n_layer in self._user_layers:
user_features = n_layer(user_features) return user_features #
定义电影特征的前向计算过程 def get_mov_feat(self, mov_var): """ get movie features"""
# 获得电影数据 mov_id, mov_cat, mov_title, mov_poster = mov_var
feats_collect = [] # 获得batchsize的大小 batch_size = mov_id.shape[0] #
计算电影ID的特征,并存在feats_collect中 mov_id = self.mov_emb(mov_id)
mov_id = self.mov_fc(mov_id)
mov_id = F.relu(mov_id)
feats_collect.append(mov_id) # 如果使用电影的种类数据,计算电影种类特征的映射
if self.use_mov_cat: # 计算电影种类的特征映射,对多个种类的特征求和得到最终特征
mov_cat = self.mov_cat_emb(mov_cat)
mov_cat = paddle.sum(mov_cat, axis=1, keepdim=False)
mov_cat = self.mov_cat_fc(mov_cat)
feats_collect.append(mov_cat) if self.use_mov_title: # 计算电影名字的特征映射,
对特征映射使用卷积计算最终的特征 mov_title = self.mov_title_emb(mov_title)
mov_title = F.relu(self.mov_title_conv2(F.relu(self.mov_title_conv(mov_title))))
mov_title = paddle.sum(mov_title, axis=2, keepdim=False)
mov_title = F.relu(mov_title)
mov_title = paddle.reshape(mov_title, [batch_size, -1])
feats_collect.append(mov_title) # 使用一个全连接层,整合所有电影特征,映射为一个200维的特征向量
mov_feat = paddle.concat(feats_collect, axis=1)
mov_features = F.tanh(self.mov_concat_embed(mov_feat)) for n_layer in self._movie_layers:
mov_features = n_layer(mov_features) return mov_features # 定义个性化推荐算法的前向计算
def forward(self, usr_var, mov_var): # 计算用户特征和电影特征 usr_feat = self.get_usr_feat(usr_var)
mov_feat = self.get_mov_feat(mov_var) #通过3层全连接层,获得用于计算相似度的用户特征和电影特征
for n_layer in self._user_layers:
user_features = n_layer(user_features) for n_layer in self._movie_layers:
mov_features = n_layer(mov_features) # 根据计算的特征计算相似度 res = F.cosine_similarity
(user_features, mov_features) # 将相似度扩大范围到和电影评分相同数据范围 res = paddle.scale(res, scale=5)
return usr_feat, mov_feat, res