diff --git a/2024/02/04/dl_rec_al/index.html b/2024/02/04/dl_rec_al/index.html index 2895229..af27cc9 100644 --- a/2024/02/04/dl_rec_al/index.html +++ b/2024/02/04/dl_rec_al/index.html @@ -44,7 +44,7 @@ - + @@ -231,7 +231,7 @@ - 43k words + 44k words @@ -242,7 +242,7 @@ - 360 mins + 363 mins @@ -284,7 +284,7 @@

【深度学习】推荐系统算法总结

- Last updated on March 8, 2024 am + Last updated on March 11, 2024 pm

@@ -1088,6 +1088,15 @@

深度学习模型(Rank)

alt="image-20240308111249239" /> +
+ + +
+

对于1层cross和1层deep的DCN网络输入经过embedding和stack处理后维度为d,Cross部分网络参数为2d,Deep为d*d,当MLP的层数增多时,deep部分的参数量也急速增加。DCN网路的绝大部分参数都用于对隐性交叉特征进行建模。Cross部分的表达能力反而受限。

+

DCN-v2优化了cross网络的建模方式,增加了cross网络部分的表达能力;deep部分保持不变。

+

低维空间的交叉特征建模使得我们可以利用MoE。MoE由两部分组成:experts专家和gating门(一个关于输入x的函数)。我们可以使用多个专家,每个专家学习不同的交叉特征,最后通过gating将各个专家的学习结果整合起来,作为输出。这样就又能进一步增加对交叉特征的建模能力。

@@ -1156,7 +1165,7 @@

深度学习模型(Rank)

Updated on
-
March 8, 2024
+
March 11, 2024
diff --git a/local-search.xml b/local-search.xml index 5d6bc06..ef8c62c 100644 --- a/local-search.xml +++ b/local-search.xml @@ -46,7 +46,7 @@ /2024/02/04/dl_rec_al/ -

推荐系统算法总结

推荐系统近几年有了深度学习的助推发展之势迅猛,从前深度学习的传统推荐模型(协同过滤,矩阵分解,LR, FM, FFM,GBDT)到深度学习的浪潮之巅(DNN, Deep Crossing, DIN, DIEN, Wide&Deep,Deep&Cross, DeepFM, AFM, NFM, PNN, FNN,DRN)。推荐系统通过分析用户的历史行为给用户的兴趣建模,从而主动给用户推荐给能够满足他们兴趣和需求的信息

传统模型(Recall)

传统推荐算法宏观框架

数据集介绍

https://github.com/ZiyaoGeng/RecLearn/wiki/Movielens

rating

标签列表为:UserID::MovieID::Rating::Timestamp

user

标签列表为:UserID::Gender::Age::Occupation::Zip-code

movies

标签列表为:MovieID::Title::Genres

基于内容的推荐

这是一种比较简单的推荐方法,基于内容的推荐方法是非常直接的,它以物品的内容描述信息为依据来做出的推荐,本质上是基于对物品和用户自身的特征或属性的直接分析和计算。例如,假设已知电影A是一部喜剧,而恰巧我们得知某个用户喜欢看喜剧电影,那么我们基于这样的已知信息,就可以将电影A推荐给该用户。具体实现步骤:

基本流程

物品画像

本质上是基于对物品和用户自身的特征或属性的直接分析和计算

构建标签数据集

数据集的四列关键词为:userId, movieId, tag, timestamp

1
2
3
4
5
6
7
_tags = pd.read_csv("ml-latest-small/all-tags.csv", usecols=range(1, 3)).dropna()
#使用的范围是1-2列,dropna是将数值为nan的删除

tags = _tags.groupby("movieId").agg(list)
#根据某个名为movieId的列进行分组,并对每个组执行聚合操作,将每个组的标签列表合并为一个列表

tags.head()

聚合之后的列表数据集

构建物料数据

数据集关键标签为:movieId, title, genres


1
2
3
4
5
6
7
movies = pd.read_csv("ml-latest-small/movies.csv", index_col="movieId")
#参数告诉函数将CSV文件中的第一列(即电影ID列)作为DataFrame的索引

movies['genres'] = movies['genres'].apply(lambda x: x.split("|"))
#.apply(lambda x: x.split("|")) 用于对genres列中的每个元素应用一个lambda函数。这个lambda函数接受一个参数x,并返回x split by "|“的结果,即将字符串x按”|"分割成列表

movies.head()

物品标签处理

合并物品和标签的列表

1
2
3
4
5
6
7
8
9
10
11
movies_index = set(movies.index) & set(tags.index)
#set(movies.index) set(tags.index) 将索引转换为一个集合,去除了重复的索引值。
#&set(tags.index) 返回两个集合中共同的元素组成的新集合。

new_tags = tags.loc[list(movies_index)]
#根据之前得到的交集集合movies_index来筛选tags,只保留那些与movies DataFrame有相同索引的电影的标签

ret = movies.join(new_tags)
#这段代码的作用是将两个DataFrame(movies和tags)根据共同的索引(电影ID)进行合并

ret.head()

数据处理并补充缺失值

1
2
3
df = map(lambda x: (x[0], x[1], x[2], x[2]+x[3]) if x[3] is not np.nan else (x[0], x[1], x[2], []), ret.itertuples())
movies_dataset = pd.DataFrame(df, columns=['movieId', 'title', 'genres', 'tags'])
movies_dataset.head()

这段代码的作用是对retDataFrame中的tags列进行处理,将缺失值替换为空列表,并创建一个新的DataFrame,以便于后续的数据分析和处理

TF-IDF模型

引入相关的软件包,其中包含了TfidfModel相当于是TFIDF模型

1
2
3
from gensim.models import TfidfModel
from pprint import pprint
from gensim.corpora import Dictionary

通常用于创建词典,它将数据集中的所有唯一单词作为键,每个单词出现的次数作为值

1
2
3
4
5
6
7
8
dataset = movies_dataset["tags"].values
# 根据数据集建立词袋, 并统计词频, 将所有词放入一个词典, 使用索引进行获取
dct = Dictionary(dataset)

# 对于每条数据, 返回对应的词索引和词频
corpus = [dct.doc2bow(line) for line in dataset] # 这个东西统计每个单词在每部电影中出现的词频 tf

#doc2bow将文本转换为词袋模型的表示形式。在词袋模型中,文本被表示为一个矩阵

构建TF-IDF模型并进行展示

1
2
3
4
# 根据这个词频, 就可以训练Tf-IDF模型, 计算TF-IDF值
model = TfidfModel(corpus)
#corpus中的第一个文档(索引为0)应用TF-IDF模型
model[corpus[0]]

TFIDF模型结果展示

1
2
3
4
5
6
7
8
9
# 保存每个电影tf-idf值最高的30个标签
movie_profile = {}
for i, mid in enumerate(movies_dataset.index):
# 对于每部电影, 返回每个标签的tf-idf值
tfidf_vec = model[corpus[i]]
# 按照tfidf值排序, 然后取Top-N
movies_tags = sorted(tfidf_vec, key=lambda x: x[1], reverse=True)[:30]
# 根据关键词提取对应的名称
movie_profile[mid] = dict(map(lambda x: (dct[x[0]], x[1]), movies_tags))

得出最终的结果

1
2
movie_profile = create_movie_profile(movie_dataset)
movie_profile.head()

物料数据的处理结果

建立倒排索引

倒排索引就是用物品的其他数据作为索引,去提取他们对应的物品的ID列表

1
2
3
4
5
6
7
8
9
10
11
12
# 建立倒排索引  为了根据指定关键词迅速匹配到对应的电影,因此需要对物品画像的标签词,建立**倒排索引**
# 通常数据存储数据, 都是以物品的ID作为索引, 去提取物品的其他信息数据
# 而倒排索引就是用物品的其他数据作为索引, 去提取他们对应的物品的ID列表
def create_inverted_table(movie_profile):
inverted_table = {}
for mid, weights in movie_profile['weights'].iteritems():
for tag, weight in weights.items():
# 到inverted_table dict 用tag作为key去取值, 如果取不到就返回[]
_ = inverted_table.get(tag, [])
_.append((mid, weight))
inverted_table.setdefault(tag, _)
return inverted_table
1
inverted_table = create_inverted_table(movie_profile)

这样就可以直接根据标签去推荐电影

用户画像

构建步骤:

  1. 根据用户的评分历史,结合物品画像,将有观影记录的电影的画像标签作为初始标签反打到用户身上
  2. 通过对用户观影标签的次数进行统计,计算用户的每个初始标签的权重值,排序后选取TOP-N作为用户最终的画像标签
1
2
import collections
from functools import reduce
1
2
3
4
5
6
#读取了名为ratings.csv的文件,只使用了文件中的第二列和第三列
watch_record = pd.read_csv("ml-latest-small/ratings.csv", usecols=range(2), dtype={"userId":np.int32, "movieId": np.int32})

watch_record = watch_record.groupby("userId").agg(list)

#从电影评分数据集中读取用户ID和电影ID

用户ID和电影ID

产生Top-N的推荐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
user_profile = {}
for uid, mids in watch_record.itertuples():
# 这里把当前用户看过的电影从movie_profile中找出来
record_movie_profile = movie_profile.loc[list(mids)]

# 下面需要把这些电影的标签都合并到一块, 然后统计出现的次数, 这里的Counter和reduce用的秒
counter = collections.Counter(reduce(lambda x, y: list(x) + list(y), record_movie_profile['profile'].values))

# 兴趣词 从计数器对象中检索出出现次数最多的50个标签/关键词以及它们的计数
interest_words = counter.most_common(50)
maxcount = interest_words[0][1] #这些常见标签中的最大计数

# 这里归一化一下
interest_words = [(w, round(c/maxcount, 4)) for w, c, in interest_words]
user_profile[uid] = interest_words
1
user_profile[1]        # 用户1感兴趣的词

例子 用户1感兴趣的词语

这个地方根据用户观看的视频以及视频对应的关键词和每个关键词的权重,给出每个用户感兴趣的关键词的权重归一化之后的值

注意,这个地方给出的归一化的操作是使用频率进行归一化操作

下面给用户进行视频的推荐

输出对应的用户的uid,推荐的视频编号,以及视频的推荐概率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for uid, interest_words in user_profile.items():
result_table = {} # 电影id: [0.2, 0.5]
for interest_word, interest_weight in interest_words:
related_movies = inverted_table[interest_word]
for mid, relate_weight in related_movies:
_ = result_table.get(mid, [])
_.append(interest_weight) #只考虑用户的兴趣程度
# _.append(related_weight) # 只考虑兴趣词与电影的关联程度
# _.append(interest_weight * related_weight) # 二者都考虑
result_table.setdefault(mid, _)

rs_result = map(lambda x: (x[0], sum(x[1])), result_table.items())
rs_result = sorted(rs_result, key=lambda x: x[1], reverse=True)[:100]
print(uid)
pprint(rs_result)
break

冷启动算法

这里主要包括两个很厉害的技术:

* Word2Vec: 这个可以根据得到电影标签的词向量,根据这个词向量, 就能够得到tag之间的相似性,这样就能够根据用户看过的某个电影, 得到这个电影的标签,然后根据这些标签得到与其近似的标签,然后得到这些近似标签下的电影对该用户产生推荐

1
2
3
4
5
6
7
8
9
10
11
from gensim.models import Word2Vec

# 由于前面我们已经得到了每部影片的tags,物品画像里面。 所以这里我们就可以直接建立word2vec模型, 来进行标签的词向量计算
sentences = list(movie_profile["profile"].values) # 二维列表 每个元素是字符串

# 这里可以直接建立模型
model = Word2Vec(sentences, window=3, min_count=1)

words = input("words: ")
ret = model.wv.most_similar(positive=[words], topn=10) # 找到最相似的n 个词
print(ret)

* Doc2Vec:这个可以根据电影的所有标签,训练一个模型来得到最终电影的影片向量, 根据这个,就能够直接计算用户看过的某个电影与其他电影的相似性,然后根据这个相似性给用户推荐最相似的几篇文章。

1
2
3
4
5
6
7
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from gensim.test.utils import get_tmpfile

# 建立文档, words就是影片的tags, tags就是影片的id
documents = [TaggedDocument(words, [movie_id]) for movie_id, words in movie_profile["profile"].iteritems()]

documents

转化为文档的格式实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 训练Doc2Vec模型
model = Doc2Vec(documents, vector_size=100, window=3, min_count=1, epochs=20)

# 模型保存
# fname = get_tmpfile("my_doc2vec_model")
# model.save(fname)

# 获取某个电影的tages
words = movie_profile["profile"].loc[6]
print(words)
# 拿到该影片的Doc2vec向量
inferred_vector = model.infer_vector(words)
sims = model.docvecs.most_similar([inferred_vector], topn=10)
print(sims)

协同过滤算法

协同过滤(Collaborative Filtering)算法,基本思想是根据用户之前的喜好以及其他兴趣相近的用户的选择来给用户推荐物品(基于对用户历史行为数据的挖掘发现用户的喜好偏向,并预测用户可能喜好的产品进行推荐),一般是仅仅基于用户的行为数据(评价、购买、下载等),而不依赖于项的任何附加信息(物品自身特征)或者用户的任何附加信息(年龄,性别等)。目前应用比较广泛的协同过滤算法是基于邻域的方法,而这种方法主要有下面两种算法:

两种CF算法介绍

用户协同和物品协同的使用场景

*UserCF的推荐更社会化,反映了用户所在的小型兴趣群体中物品的热门程度;

* ItemCF的推荐更加个性化,反映了用户自己的兴趣传承

UserCF适合于新闻推荐

ItemCF适合于图书、电子商务和电影网站

UserCF的适用场合

ItemCF的适用场合

该实验使用的数据集来自:http://grouplens.org/datasets/movielens/

算法基本流程

不管是UserCF还是ItemCF, 行文逻辑都是下面的四个步骤: 1. 导入数据,读取文件得到"用户-电影"的评分数据, 并且分为训练集和测试集 2.计算用户(userCF)或者电影(itemcf)之间的相似度 3. 针对目标用户u,找到其最相似的k个用户/产品, 产生N个推荐 4. 产生推荐之后,通过准确率、召回率和覆盖率等进行评估。

工业界协同过滤的流程

  1. 数据处理
  1. 建立用户embedding和物品embedding, 或者可以像案例这样,直接建立共现矩阵, 也可以训练embedding
  2. 计算用户和N个用户的相似度, 保存N个相似用户曾经看过的TopK个物品
  3. 模型(矩阵)进行定期更新, 这个要根据不同项目组的情况,可能是一天更新一次, 也可能不是, 看具体的情况,更新的时候使用前N天(N一般3-10)的活跃用户的数据进行更新
  4. 每次召回一次N条, 刷完N条再继续召回
  1. 为了保证用户不疲劳, 一般情况下, 利用user-cf计算召回结果后,会做一定的类别去重, 保证召回覆盖度
  2. 实际过程中, 根据公司核心用户的数量大小, 考虑实现工具,如果数据量较大, 可使用spark进行用户协同的结果计算
  3. 如果用户量实在太过巨大, 可考虑使用稀疏存储的方式进行存储,即只存储含有1(或者其他值)的位置坐标索引index以及对应的值

用户协同过滤算法

TopN推荐的任务是预测用户会不会对某部电影评分,而不是预测用户在准备对某部电影评分的前提下给电影评多少分,下面我们开始, 从逻辑上看, 其实这个任务主要分为下面的步骤: 1.导入数据, 读取文件得到"用户-电影"的评分数据, 并且分为训练集和测试集 2.计算用户之间的相似度 3. 针对目标用户u, 找到其最相似的k个用户,产生N个推荐 4. 产生推荐之后, 通过准确率、召回率和覆盖率等进行评估。

引入依赖包和读取数据

1
2
3
4
5
6
7
8
9
10
11
import random
import numpy as np
import pandas as pd
import math
from operator import itemgetter

data_path = './ml-latest-small/'
data = pd.read_csv(data_path+'ratings.csv')
data.head()

data.pivot(index='userId', columns='movieId', values='rating') # 这样会发现有大量的稀疏, 所以才会用字典进行存放

存在大量的稀疏的情况,因此后续会对此进行数据的补全

将数据集划分成训练集和测试集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 声明两个字典, 分别是训练集和测试集
trainSet, testSet = {}, {}
trainSet_len, testSet_len = 0, 0
pivot = 0.75 # 训练集的比例

# 遍历data的每一行, 把userId, movidId, rating按照{user: {movidId: rating}}的方式存储, 当然定义一个随机种子进行数据集划分
for ele in data.itertuples(): # 遍历行这里推荐用itertuples, 比iterrows会高效很多
user, movie, rating = getattr(ele, 'userId'), getattr(ele, 'movieId'), getattr(ele, 'rating')
if random.random() < pivot:
trainSet.setdefault(user, {})
trainSet[user][movie] = rating
trainSet_len += 1
else:
testSet.setdefault(user, {})
testSet[user][movie] = rating
testSet_len += 1

print('Split trainingSet and testSet success!')
print('TrainSet = %s' % trainSet_len)
print('TestSet = %s' % testSet_len)
1
2
3
Split trainingSet and testSet success!
TrainSet = 75732
TestSet = 25104

建立用户相似度的表

如果直接遍历用户表会产生比较大的时间复杂度,不如直接建立一个物品到用户的倒排表,对每个物品都保存对该物品都产生过的用户列表

image-20240205142142169

具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
user_sim_matrix = {}

# 构建“电影-用户”倒排索引 # key = movidID, value=list of userIDs who have seen this move
print('Building movie-user table ...')
movie_user = {}
for user, movies in trainSet.items():
# 这里的user就是每个用户, movies还是个字典,{movieID: rating}
for movie in movies: # 这里的movie就是movieID了
if movie not in movie_user: # 如果movidID没在倒排索引里面
movie_user[movie] = set() # 声明这个键的值是set, 保证用户不重复
movie_user[movie].add(user) # 把用户加进去, 看上面的图就明白了
print('Build movie-user table success!')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 下面建立用户相似矩阵

# 计算总共的电影的数量
movie_count = len(movie_user)
print('Total movie number = %d' % movie_count)

print('Build user co-rated users matrix ...')
for movie, users in movie_user.items(): # movid是movieID, users是set集合
for u in users: # 对于每个用户, 都得双层遍历
for v in users:
if u == v:
continue
user_sim_matrix.setdefault(u, {}) # 把字典的值设置为字典的形式
user_sim_matrix[u].setdefault(v, 0)
user_sim_matrix[u][v] += 1 # 这里统计两个用户对同一部电影产生行为的次数, 这个就是余弦相似度的分子
print('Build user co-rated users matrix success!')

# 下面计算用户之间的相似性
print('Calculating user similarity matrix ...')
for u, related_users in user_sim_matrix.items():
for v, count in related_users.items(): # 这里面v是相关用户, count是共同对同一部电影打分的次数
user_sim_matrix[u][v] = count / math.sqrt(len(trainSet[u]) * len(trainSet[v])) # len 后面的就是用户对电影产生过行为的个数
print('Calculate user similarity matrix success!')

针对用户u,找到和他最相似的k个用户,并产生N个推荐

得到用户的兴趣相似度后,UserCF算法会对用户推荐和他兴趣最相似的K个用户喜欢的物品。下面公式度量了UserCF算法中用户u对物品i的感兴趣程度:

\[p(u, i)=\sum_{v \in S(u, K) \cap N(i)}w_{u v} r_{v i}\]

其中, \(S(u,k)\)包含和兴趣u兴趣最接近的K个用户,\(N(i)\)是对物品i有过行为的用户集合,\(w_{uv}\)是用户u和用户v的兴趣相似度,\(r_{vi}\)代表用户v对物品i的兴趣,因为使用单一行为的隐反馈数据, 所以这里\(r_{vi}=1\)

所以下面的代码逻辑是这样: * 首先, 给定我一个用户ID,我先拿到这个用户ID目前看过的所有电影, 以防后面推荐重了。
* 然后从相似性矩阵中,找到与当前用户最相近的K个用户 *遍历他们看过的电影, 如果当前用户没有看过, 该电影的权重等级累加 *最后给所有的电影进行排序, 推荐前n部给当前用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 找到最相似的20个用户, 产生10个推荐
k = 20
n = 10
aim_user = 3 # 目标用户ID
rank ={}
watched_movies = trainSet[aim_user] # 找出目标用户看到电影
# 下面从相似性矩阵中找到与aim_user最相近的K个用户

# v 表示相似用户, wuv表示相似程度
for v, wuv in sorted(user_sim_matrix[aim_user].items(), key=lambda x: x[1], reverse=True)[0:k]: # 字典按值从大到小排序, 相关性高到第

# 把v用户看过的电影推荐给目标用户
for movie in trainSet[v]:
if movie in watched_movies:
continue
rank.setdefault(movie, 0)
rank[movie] += wuv #这里给出的权重直接就是按照和用户的相似度的大小决定了最终的电影的大小推荐

# 产生最后的推荐列表
rec_movies = sorted(rank.items(), key=itemgetter(1), reverse=True)[:n] # itemgetter(1) 是简洁写法

产生的最终N个推荐

推荐结果的评估

  1. 召回率

    对用户u推荐N个物品记为\(R(u)\),令用户u在测试集上喜欢的物品集合为\(T(u)\), 那么召回率定义为: \[\operatorname{Recall}=\frac{\sum_{u}|R(u) \cap T(u)|}{\sum_{u}|T(u)|}\]这个意思就是说在用户真实购买或者看过的影片里面,我模型真正预测出了多少,这个考察的是模型推荐的一个全面性。分母的位置是测试集上的总的数量

  2. 准确率

    准确率定义为: \[\operatorname{Precision}=\frac{\sum_{u} \mid R(u) \capT(u)}{\sum_{u}|R(u)|}\] 这个意思在我推荐的所有物品中, 用户真正看的有多少,这个考察的是我模型推荐的一个准确性。

  3. 覆盖率

    覆盖率反映了推荐算法发掘长尾的能力, 覆盖率越高,说明推荐算法越能将长尾中的物品推荐给用户。 \[\text { Coverage }=\frac{\left|\bigcup_{u \in U} R(u)\right|}{|I|}\]该覆盖率表示最终的推荐列表中包含多大比例的物品。如果所有物品都被给推荐给至少一个用户,那么覆盖率是100%

  4. 新颖度

    用推荐列表中物品的平均流行度度量推荐结果的新颖度。如果推荐出的物品都很热门, 说明推荐的新颖度较低。由于物品的流行度分布呈长尾分布, 所以为了流行度的平均值更加稳定,在计算平均流行度时对每个物品的流行度取对数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 提供推荐的接口:def recommend(aim_user, k=20, n=10):

# 准确率、召回率和覆盖率
hit = 0
rec_count = 0 # 统计推荐的影片数量, 计算查准率
test_count = 0 # 统计测试集的影片数量, 计算查全率
all_rec_movies = set() # 统计被推荐出来的影片个数,无重复了,为了计算覆盖率
item_populatity = dict() # 计算新颖度

# 先计算每部影片的流行程度
for user, items in trainSet.items():
for item in items.keys():
if item not in item_populatity:
item_populatity[item] = 0
item_populatity[item] += 1 # 这里统计训练集中每部影片用户观看的总次数,代表每部影片的流行程度

# 计算评测指标
ret = 0
ret_cou = 0
for user, items in trainSet.items(): # 这里得保证测试集里面的用户在训练集里面才能推荐
test_movies = testSet.get(user, {})
rec_movies = recommend(user)
for movie, w in rec_movies:
if movie in test_movies:
hit += 1 #准确率++
all_rec_movies.add(movie)
ret += math.log(1+item_populatity[movie])
ret_cou += 1
rec_count += n
test_count += len(test_movies)

precision = hit / (1.0 * rec_count)
recall = hit / (1.0 * test_count)
coverage = len(all_rec_movies) / movie_count
ret /= ret_cou*1.0

print('precisioin = %.4f\nrecall = %.4f\ncoverage = %.4f\npopularity = %.4f' % (precision, recall, coverage, ret))

物品协同过滤算法

  1. 导入数据, 读取文件得到"用户-电影"的评分数据,并且分为训练集和测试
  2. 计算电影之间的相似度
  3. 针对目标用户u, 找到其最相似的k个用户, 产生N个推荐
  4. 产生推荐之后, 通过准确率、召回率和覆盖率等进行评估。

这一步之前都和基于用户的协同过滤算法一样,都进行了数据集的训练和测试的划分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 声明两个字典, 分别是训练集和测试集
trainSet, testSet = {}, {}
trainSet_len, testSet_len = 0, 0
pivot = 0.75 # 训练集的比例

# 遍历data的每一行, 把userId, movidId, rating按照{user: {movidId: rating}}的方式存储, 当然定义一个随机种子进行数据集划分
for ele in data.itertuples(): # 遍历行这里推荐用itertuples, 比iterrows会高效很多
user, movie, rating = getattr(ele, 'userId'), getattr(ele, 'movieId'), getattr(ele, 'rating')
if random.random() < pivot:
trainSet.setdefault(user, {})
trainSet[user][movie] = rating
trainSet_len += 1
else:
testSet.setdefault(user, {})
testSet[user][movie] = rating
testSet_len += 1

print('Split trainingSet and testSet success!')
print('TrainSet = %s' % trainSet_len)
print('TestSet = %s' % testSet_len)

UserItemCF相似, 这里同样需要建立一个倒排表,只不过这里的倒排变成了{用户:物品}的倒排表, 如下:

用户,物品 的倒排表

我们这里的存储正好是“用户-物品"评分表, 所以现在正好是倒排的形式,所以不用刻意建立建立倒排表, 直接遍历trainSet即可, 但是在这之前,我们先计算下每部电影的流行程度, 也就是被用户观看的总次数,这个在衡量相似度的时候作为分母, 这里的其他逻辑和UserCF基本一致了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 计算每部电影的流行程度, 也就是每部电影被用户看的总次数, 这个衡量相似度的时候作为分母
movie_popular = {}
for user, movies in trainSet.items(): # 这里的user就是每个用户, movies还是个字典, {movieID: rating}
for movie in movies: # 这里的movie就是movieID了
if movie not in movie_popular:
movie_popular[movie] = 0
movie_popular[movie] += 1
movie_count = len(movie_popular)
print('Total movie number = %d' % movie_count)
# 下面建立电影相似矩阵
print('Build user co-rated movies matrix ...')
movie_sim_matrix = {}
for user, movies in trainSet.items(): # 注意这个地方与UserCF的不同
for m1 in movies: # 对于每个电影, 都得双层遍历
for m2 in movies:
if m1 == m2:
continue
movie_sim_matrix.setdefault(m1, {}) # 把字典的值设置为字典的形式
movie_sim_matrix[m1].setdefault(m2, 0)
movie_sim_matrix[m1][m2] += 1 # 这里统计两个电影被同一个用户产生行为的次数, 这个就是余弦相似度的分子
print('Build user co-rated movies matrix success!')

# 下面计算电影之间的相似性
print('Calculating movies similarity matrix ...')
for m1, related_movies in movie_sim_matrix.items():
for m2, count in related_movies.items(): # 这里面m2是相关电影, count是共同被同一个用户打分的次数
# 这里注意零向量的处理, 即某电影的用户数为0
if movie_popular[m1] == 0 or movie_popular[m2] == 0:
movie_sim_matrix[m1][m2] = 0
else:
movie_sim_matrix[m1][m2] = count / math.sqrt(movie_popular[m1] * movie_popular[m2])
print('Calculate movies similarity matrix success!')

找到相似性最高的K个物品并进行N推荐

所以下面的代码逻辑是这样: * 首先, 给定我一个用户ID,我先拿到这个用户ID目前看过的所有电影, 以防后面推荐重了。
* 然后从相似性矩阵中,找到与当前用户看的物品的最相近的K个物品 *遍历他们看过的电影, 如果当前用户没有看过, 该电影的权重等级累加 *最后给所有的电影进行排序, 推荐前n部给当前用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 找到最相似的K个产品, 并推荐给n个用户
k = 20
n = 10
aim_user = 10 # 目标用户ID
rank ={}
watched_movies = trainSet[aim_user] # 找出目标用户看到电影
for movie, rating in watched_movies.items():
#遍历与物品item最相似的前k个产品,获得这些物品及相似分数
for related_movie, w in sorted(movie_sim_matrix[movie].items(), key=itemgetter(1), reverse=True)[:k]:
# 若该物品用户看过, 跳过
if related_movie in watched_movies:
continue

# 计算用户user对related_movie的偏好值, 初始化该值为0
rank.setdefault(related_movie, 0)
#通过与其相似物品对物品related_movie的偏好值相乘并相加。
#排名的依据—— > 推荐电影与该已看电影的相似度(累计) * 用户对已看电影的评分
rank[related_movie] += w * float(rating)
# 产生最后的推荐列表
rec_movies = sorted(rank.items(), key=itemgetter(1), reverse=True)[:n] # itemgetter(1) 是简洁写法

对模型的性能进行评估

这部分的内容和基于用户的协同过滤算法一样

  1. 召回率
  2. 准确率
  3. 覆盖率
  4. 新颖率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 准确率、召回率和覆盖率
hit = 0
rec_count = 0 # 统计推荐的影片数量, 计算查准率
test_count = 0 # 统计测试集的影片数量, 计算查全率
all_rec_movies = set() # 统计被推荐出来的影片个数, 无重复了, 为了计算覆盖率
item_populatity = dict() # 计算新颖度
# 先计算每部影片的流行程度
for user, items in trainSet.items():
for item in items.keys():
if item not in item_populatity:
item_populatity[item] = 0
item_populatity[item] += 1 # 这里统计训练集中每部影片用户观看的总次数, 代表每部影片的流行程度
# 计算评测指标
ret = 0
ret_cou = 0
for user, items in trainSet.items(): # 这里得保证测试集里面的用户在训练集里面才能推荐
test_movies = testSet.get(user, {})
rec_movies = recommend(user)
for movie, w in rec_movies:
if movie in test_movies:
hit += 1
all_rec_movies.add(movie)
ret += math.log(1+item_populatity[movie])
ret_cou += 1
rec_count += n
test_count += len(test_movies)
precision = hit / (1.0 * rec_count)
recall = hit / (1.0 * test_count)
coverage = len(all_rec_movies) / movie_count
ret /= ret_cou*1.0
print('precisioin = %.4f\nrecall = %.4f\ncoverage = %.4f\npopularity = %.4f' % (precision, recall, coverage, ret))

隐语义模型与矩阵分解

参考文档https://blog.csdn.net/wuzhongqiang/article/details/108173885

矩阵分解算法

矩阵分解算法:BacisSVD, RSVD, ASVD, SVD++

隐语义模型其实就是在想办法基于这个评分矩阵去找到上面例子中的那两个矩阵,也就是用户兴趣和物品的隐向量表达,然后就把这个评分矩阵分解成Q和P两个矩阵乘积的形式,这时候就可以基于这两个矩阵去预测某个用户对某个物品的评分了。然后基于这个评分去进行推荐

  1. 首先, 先初始化用户矩阵P和物品矩阵Q,P的维度是[users_num, F],Q的维度是[item_nums, F], 这个F是隐向量的维度。也就是通过隐向量的方式把用户的兴趣和F的特点关联了起来。初始化这两个矩阵的方式很多, 但根据经验,随机数需要和1/sqrt(F)成正比。
  2. 有了两个矩阵之后, 我就可以根据用户已经打分的数据去更新参数,这就是训练模型的过程, 方法很简单, 就是遍历用户, 对于每个用户,遍历它打分的电影,这样就拿到了该用户和电影的隐向量,然后两者相乘加上偏置就是预测的评分, 这时候与真实评分有个差距,根据上面的梯度下降就可以进行参数的更新
  3. 训练好模型之后, 就可以进行预测评分, 根据预测的评分对用户推荐
  4. 评估模型, 评估方式还是协同过滤里面的四种评估标准

矩阵分解算法将m × n维的共享矩阵R分解成m × k维的用户矩阵U和k ×n维的物品矩阵V相乘的形式。 其中m是用户数量, n是物品数量,k是隐向量维度, 也就是隐含特征个数,只不过这里的隐含特征变得不可解释了, 即我们不知道具体含义了,要模型自己去学。

最常用的方法是特征值分解(EVD)或者奇异值分解(SVD),EVD要求分解的矩阵是方阵,显然用户-物品矩阵不满足这个要求

SVD矩阵分解算法

这个算法的思路就是深度学习的思路

  1. 首先先初始化这两个矩阵
  2. 把用户评分矩阵里面已经评过分的那些样本当做训练集的label,把对应的用户和物品的隐向量当做features,这样就会得到(features, label)相当于训练集
  3. 通过两个隐向量乘积得到预测值pred
  4. 根据labelpred计算损失
  5. 然后反向传播,通过梯度下降的方式,更新两个隐向量的值
  6. 未评过分的那些样本当做测试集,通过两个隐向量就可以得到测试集的label
  7. 这样就填充完了矩阵, 下一步就可以进行推荐了

有个问题就是当参数很多的时候, 就是两个矩阵很大的时候,往往容易陷入过拟合的困境, 这时候,就需要在目标函数上面加上正则化的损失, 就变成了RSVD

读取数据和对测试数据和训练数据的划分的步骤和之前没有差别具体的步骤可以看之前的协同过滤算法

建立SVD模型

这里按照矩阵分解法初始化隐特征矩阵,转化为一个最优化问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def __init__(self, rating_data, F=5, alpha=0.1, lmbda=0.1, max_iter=100):
self.F = F # 这个表示隐向量的维度
self.P = dict() # 用户矩阵P 大小是[users_num, F]
self.Q = dict() # 物品矩阵Q 大小是[item_nums, F]
self.bu = dict() # 用户偏差系数
self.bi = dict() # 物品偏差系数
self.mu = 0.0 # 全局偏差系数
self.alpha = alpha # 学习率
self.lmbda = lmbda # 正则项系数
self.max_iter = max_iter # 最大迭代次数
self.rating_data = rating_data # 评分矩阵

# 初始化矩阵P和Q, 方法很多, 一般用随机数填充, 但随机数大小有讲究, 根据经验, 随机数需要和1/sqrt(F)成正比
cnt = 0 # 统计总的打分数, 初始化mu用
for user, items in self.rating_data.items():
self.P[user] = [random.random() / math.sqrt(self.F) for x in range(0, F)]
self.bu[user] = 0
cnt += len(items)
for item, rating in items.items():
if item not in self.Q:
self.Q[item] = [random.random() / math.sqrt(self.F) for x in range(0, F)]
self.bi[item] = 0
self.mu /= cnt

训练的过程:采用的是梯度下降法进行更新参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 有了矩阵之后, 就可以进行训练, 这里使用随机梯度下降的方式训练参数P和Q
def train(self):
for step in range(self.max_iter):
for user, items in self.rating_data.items():
for item, rui in items.items():
rhat_ui = self.predict(user, item) # 得到预测评分
# 计算误差
e_ui = rui - rhat_ui

self.bu[user] += self.alpha * (e_ui - self.lmbda * self.bu[user])
self.bi[item] += self.alpha * (e_ui - self.lmbda * self.bi[item])
# 随机梯度下降更新梯度
for k in range(0, self.F):
self.P[user][k] += self.alpha * (e_ui*self.Q[item][k] - self.lmbda * self.P[user][k])
self.Q[item][k] += self.alpha * (e_ui*self.P[user][k] - self.lmbda * self.Q[item][k])

self.alpha *= 0.1 # 每次迭代步长要逐步缩小

根据此产生推荐的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 这里产生推荐列表, 遍历物品列表, 如果用户看了, 那么就跳过, 否则, 预测用户对该电影的打分, 然后记录, 最后排名
movie_list = []
for user, items in trainSet.items():
for item in items.keys():
if item not in movie_list:
movie_list.append(item)
def recommend(aim_user, n=10):
rank = {}
watched_movies = trainSet[aim_user] # 目标用户看过的电影

for movie in movie_list:
if movie in watched_movies:
continue
# 如果当前用户没看过, 就预测打分, 并保存到rank
rank[movie] = basicsvd.predict(aim_user, movie)

return sorted(rank.items(), key=lambda x: x[1], reverse=True)[:n]

最后进行模型结果的评估:准确率、召回率、覆盖率

RSVD矩阵分解算法

在目标函数中加入正则化参数(加入惩罚项), 对于目标函数来说, \(Q\) 矩阵和 \(V\) 矩阵中的所有值都是变量,这些变量在不知道哪个变量会带来过拟合的情况下,对所有变量都进行惩罚:\[\begin{aligned}\mathrm{SSE} & =\frac{1}{2} \sum_{\mathrm{u}, \mathrm{i}}\mathrm{e}_{\mathrm{ui}}^2+\frac{1}{2} \lambda\sum_{\mathrm{u}}\left|\mathrm{p}_{\mathrm{u}}\right|^2+\frac{1}{2}\lambda \sum_{\mathrm{i}}\left|\mathrm{q}_{\mathrm{i}}\right|^2 \\& =\frac{1}{2} \sum_{\mathrm{u}, \mathrm{i}}\mathrm{e}_{\mathrm{ui}}^2+\frac{1}{2} \lambda \sum_{\mathrm{u}}\sum_{\mathrm{k}=0}^{\mathrm{K}} \mathrm{p}_{\mathrm{u},\mathrm{k}}^2+\frac{1}{2} \lambda \sum_{\mathrm{i}}\sum_{\mathrm{k}=0}^{\mathrm{K}} \mathrm{q}_{\mathrm{k}, \mathrm{i}}^2\end{aligned}\]

这时候目标函数对参数的导数就发生了变化, 前面的那块没变,无非就是加入了后面的梯度。所以此时对 \(\mathrm{p}_{\mathrm{u}, \mathrm{k}}\) 求导,得到: \[\begin{aligned}& \frac{\partial}{\partial p_{u, k}}\mathrm{SSE}=-\mathrm{e}_{\mathrm{ui}} \mathrm{q}_{\mathrm{k},\mathrm{i}}+\lambda \mathrm{p}_{\mathrm{u}, \mathrm{k}} \\& \frac{\partial}{\partial \mathrm{q}_{\mathrm{i}, \mathrm{k}}}\mathrm{SSE}=-\mathrm{e}_{\mathrm{u}, \mathrm{i}}\mathrm{p}_{\mathrm{u}, \mathrm{k}}+\lambda \mathrm{q}_{\mathrm{i},\mathrm{k}}\end{aligned}\]

这样, 正则化之后, 梯度的更新公式就变成了: \[\begin{aligned}p_{\mathrm{u}, \mathrm{k}} & =\mathrm{p}_{\mathrm{u},\mathrm{k}}+\eta\left(\mathrm{e}_{\mathrm{ui}} \mathrm{q}_{\mathrm{k},\mathrm{i}}-\lambda \mathrm{p}_{\mathrm{u}, \mathrm{k}}\right) \\\mathrm{q}_{\mathrm{k}, \mathrm{i}} & =\mathrm{q}_{\mathrm{k},\mathrm{i}}+\eta\left(\mathrm{e}_{\mathrm{ui}} \mathrm{p}_{\mathrm{u},\mathrm{k}}-\lambda \mathrm{q}_{\mathrm{i}, \mathrm{k}}\right)\end{aligned}\]

SVD++矩阵分解

SVD++, 它将用户历史评分的物品加入到了LFM模型里

之前的矩阵分解是只分解的当前的共现矩阵, 比如某个用户\(u\)对于某个物品\(i\)的评分, 就单纯的分解成用户\(u\)的隐向量与物品\(i\)的隐向量乘积再加上偏置项,这时候注意并没有考虑该用户评分的历史物品

这个式子是预测用户 \(\mathrm{u}\)对于物品 \(\mathrm{i}\) 的打分, \(\mathrm{N}(\mathrm{u})\) 表示用户 \(\mathrm{u}\) 打过分的历史物品, \(\mathrm{w}_{\mathrm{ij}}\) 表示物品 \(\mathrm{ij}\) 的相似度,当然这里的这个相似度不再是ItemCF那样, 通过向量计算的, 而是想向LFM那样,让模型自己学出这个参数来, 那么相应的就可以通过优化的思想: \[\mathrm{SSE}=\sum_{(\mathrm{u}, \mathrm{i}) \in \text { Train}}\left(\mathrm{r}_{\mathrm{ui}}-\sum_{\mathrm{j} \in\mathrm{N}(\mathrm{u})} \mathrm{w}_{\mathrm{ij}}\mathrm{r}_{\mathrm{uj}}\right)^2+\lambda \mathrm{w}_{\mathrm{ij}}^2\]

但是呢, 这么模型有个问题, 就是 \(\mathrm{w}\) 比较稠密, 存储需要很大的空间,因为如果有 \(\mathrm{n}\) 个物品,那么模型的参数就是 \(\mathrm{n}^2\),参数一多, 就容易造成过拟合。所以Koren提出应该对 \(\mathrm{w}\) 矩阵进行分解, 将参数降到了\(2 * \mathrm{n} * \mathrm{~F}\) :\[\hat{\mathrm{r}}_{\mathrm{ui}}=\frac{1}{\sqrt{|\mathrm{N}(\mathrm{u})|}}\sum_{\mathrm{j} \in \mathrm{N}(\mathrm{u})}\mathrm{x}_{\mathrm{i}}^{\mathrm{T}}\mathrm{y}_{\mathrm{j}}=\frac{1}{\sqrt{|\mathrm{N}(\mathrm{u})|}}\mathrm{x}_{\mathrm{i}}^{\mathrm{T}} \sum_{\mathrm{j} \in\mathrm{N}(\mathrm{u})} \mathrm{y}_{\mathrm{j}}\]

相当于用 \(\mathrm{x}_{\mathrm{i}}^{\mathrm{T}}\mathrm{y}_{\mathrm{j}}\) 代替了 \(\mathrm{w}_{\mathrm{ij}}\), 这里的 \(\mathrm{x}_{\mathrm{i}},\mathrm{y}_{\mathrm{j}}\) 是两个 \(\mathrm{F}\) 维的向量。有没有发现在这里,就出现了点 \(\mathrm{FM}\)的改进身影了。这里其实就是又对物品 \(\mathrm{i}\) 和某个用户 \(\mathrm{u}\)买过的历史物品又学习一波隐向量, 这次是 \(\mathrm{F}\) 维, 为了衡量出物品 \(\mathrm{i}\) 和历史物品 \(\mathrm{j}\) 之间的相似性来。这时候,参数的数量降了下来,并同时也考虑进来了用户的历史物品记录。所以这个和之前的LFM相加就得到了:\[\hat{r}_{u i}=\mu+b_u+b_i+p_u^T \cdot q_i+\frac{1}{\sqrt{|N(u)|}} x_i^T\sum_{j \in N(u)} y_j\]

FM+FFM模型

基本的框架

因子分解机(Factorization Machine, FM)和域感知因子分解机(Field-awareFactorization Machine, FFM)

FM+FFM发展历程

FM算法与使用

在逻辑回归里面, 如果想得到组合特征,往往需要人工在特征工程的时候手动的组合特征, 然后再进行篮选,但这个比较低效, 第一个是这个会有经验的成分在里面,第二个是可能会比较玄学, 不太好找到有用的组合特征。于是乎,采用POLY2模型进行特征的“暴力”组合就成了可行的选择。POLY2是二阶多项式模型,数学形式如下: \[y=w_0+\sum_{i=1}^n w_i x_i+\sum_{i=1}^{n-1} \sum_{i+1}^n w_{i j} x_i x_j\]

看到这个基本上不用怎么解释就明白了,这个模型对所有的特征进行了两两的交叉,然后又算得了一个权重,这个其实和逻辑回归依然是超级像的,如果我们在逻辑回归中,做特征工程的时候,也可以自己做出这样的一些特征来

POLY2缺点:任意两个参数相互独立,这时候如果数据非常稀疏, 再要训练这么多参数, 无疑是非常困难的,最终模型也不会很好。POLY2模型虽然是引入了特征的二阶交叉组合,但是由于其模型参数, 稀疏场景受限的问题使得FM登场了

FM算法思路:

对于稀疏的评分矩阵, 我们有办法分解成两个向量相乘的形式,那么为何不把这种思想用到解决POLY2的缺陷上呢?无非就是评分矩阵换成POLY2后面的W矩阵(所有二次项系数 \(w_{ij}\)组成的)就是把W矩阵进行分解成两个矩阵相乘的方式

对于二次项参数 \(\mathrm{w}_{\mathrm{ij}}\) 组成的对称阵\(\mathrm{W}\) (为了方面说明 \(\mathrm{FM}\) 的由来,对角元素设置为正实数),我们就可以分解成 \(\mathrm{V}^{\mathrm{T}} \mathrm{V}\)的形式, \(\mathrm{V}\)的第 \(j\) 列 \(v_j\) 表示的是第 \(j\) 维特征 \(x_j\) 的隐向量。换句话说,特征分量 \(x_i\) 和 \(x_j\) 的交叉系数就等于 \(x_i\) 和 \(x_j\) 对应的隐向量的内积,即每个参数 \(\mathrm{w}_{\mathrm{ij}}=<\mathrm{v}_{\mathrm{i}},\mathrm{v}_{\mathrm{j}}>\), 这就是 \(F M\) 模型的核心思想: \[\mathrm{W}^{\star}=\left[\begin{array}{cccc}\omega_{11} & \omega_{12} & \ldots & \omega_{1 \mathrm{n}}\\\omega_{21} & \omega_{22} & \ldots & \omega_{2 \mathrm{n}}\\\ldots & \ldots & \ldots & \ldots \\\omega_{\mathrm{n} 1} & \omega_{\mathrm{n} 2} & \ldots &\omega_{\mathrm{nn}}\end{array}\right]=\mathrm{V}^{\mathrm{T}}\mathrm{V}=\left[\begin{array}{c}\mathrm{V}_1 \\\mathrm{~V}_2 \\\ldots \\\mathrm{V}_{\mathrm{n}}\end{array}\right] \times\left[\mathrm{V}_1, \mathrm{~V}_2, \ldots,\mathrm{V}_{\mathrm{n}}\right]=\left[\begin{array}{cccc}\mathrm{v}_{11} & \mathrm{v}_{12} & \ldots & \mathrm{v}_{1\mathrm{k}} \\\mathrm{v}_{21} & \mathrm{v}_{22} & \ldots & \mathrm{v}_{2\mathrm{k}} \\\ldots & \ldots & \ldots & \ldots \\\mathrm{v}_{\mathrm{n} 1} & \mathrm{v}_{\mathrm{n} 2} & \ldots& \mathrm{v}_{\mathrm{nk}}\end{array}\right] \times\left[\begin{array}{c}\mathrm{v}_{11} \\\mathrm{v}_{12} \\\ldots \\\mathrm{v}_{1 \mathrm{k}}\end{array}\right.\]

这时候, 为了求 \(w_{i j}\),我们需要求出特征分量 \(x_i\) 的辅助向量\(v_i=\left(v_{i 1}, v_{i 2}, \ldots v_{ik}\right), v_j=\left(v_{j 1}, v_{j 2}, \ldots v_{j k}\right)\)所以, 有了这样的一个铺垫, 就可以写出FM的模型方程了,就是POLY2 的基础上,把 \(\mathrm{w}_{\mathrm{ij}}\)写成了两个隐向量相乘的方式。 \[\hat{y}(X)=\omega_0+\sum_{i=1}^n \omega_i x_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^n<v_i, v_j>x_i x_j\]FM的公式是一个通用的拟合方程,可以采用不同的损失函数用于解决regression、classification等问题,比如可以采用MSE(MeanSquare Error)loss function来求解回归问题,也可以采用Hinge/Cross-Entropyloss来求解分类问题。

安装相关的包pip install git+https://github.com/coreylynch/pyFM

1
2
3
4
5
6
# 导入包
from pyfm import pylibfm
from sklearn.feature_extraction import DictVectorizer
from sklearn.preprocessing import OneHotEncoder
import numpy as np
import pandas as pd

使用这个类最简单的方式就是把数据存成字典的形式,然后用DictVectorizer进行one-hot

1
2
3
4
5
6
7
8
9
train = [
{'user': '1', 'item': '5', 'age': 19},
{'user': '2', 'item': '43', 'age': 33},
{'user': '3', 'item': '20', 'age': 55},
{'user': '4', 'item': '10', 'age': 20}
]
v = DictVectorizer()
X = v.fit_transform(train) # 本身被压缩了
X.toarray()

one-hot形式

1
2
3
y = np.repeat(1, X.shape[0])
fm = pylibfm.FM()
fm.fit(X, y)

进行测试

1
2
3
# 测试集
test = v.transform({'user': "1", 'item': "10", 'age': 24})
fm.predict(test)

给出另一个例子说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#加载数据集
def loadData():
rating_data={1: {'A': 5, 'B': 3, 'C': 4, 'D': 4},
2: {'A': 3, 'B': 1, 'C': 2, 'D': 3, 'E': 3},
3: {'A': 4, 'B': 3, 'C': 4, 'D': 3, 'E': 5},
4: {'A': 3, 'B': 3, 'C': 1, 'D': 5, 'E': 4},
5: {'A': 1, 'B': 5, 'C': 5, 'D': 2, 'E': 1}
}
return rating_data

rating_data = loadData()
df = pd.DataFrame(rating_data).T
df = df.stack().reset_index()
df.columns = ['user', 'item', 'rating']
df['user'] = df['user'].astype('str')

item_map = {item: str(idx) for idx, item in enumerate(set(df['item']))}
df['item'] = df['item'].map(item_map)
train_data = df[['user', 'item']]
y = df['rating']
one = OneHotEncoder()
x = one.fit_transform(train_data)

# 建立模型
fm = pylibfm.FM(num_factors=10, num_iter=100, verbose=True, task='regression', initial_learning_rate=0.001, learning_rate_schedule='optimal')
fm.fit(x, y)

# 测试集
test = {'user': '1', 'item': '4'}
x_test = one.transform(pd.DataFrame(test, index=[0]))

pred_rating = fm.predict(x_test)
print('FM的预测评分:{}'.format(pred_rating[0]))

FM算法的回归与分类任务

回归任务

数据集的下载地址: http://www.grouplens.org/system/files/ml-100k.zip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 导入包
import numpy as np
from sklearn.feature_extraction import DictVectorizer
from pyfm import pylibfm

# 导入数据
def loadData(filename, path='ml-100k/'):
data = []
y = []
users = set()
items = set()
with open(path+filename) as f:
for line in f:
(user, movieid, rating, ts) = line.split('\t')
data.append({'user_id': str(user), 'movie_id': str(movieid)})
y.append(float(rating))
users.add(user)
items.add(movieid)

return (data, np.array(y), users, items)

数据类型:

数据类型展示

1
2
3
4
# 下面需要转成one-hot
v = DictVectorizer()
X_train = v.fit_transform(train_data)
X_test = v.transform(test_data)
1
2
3
4
5
# 建立FM模型 
fm = pylibfm.FM(num_factors=10, num_iter=100, verbose=True, task='regression', initial_learning_rate=0.001, learning_rate_schedule='optimal')

# 模型训练
fm.fit(X_train, y_train)

FM的具体参数函数如下: 这里面重点需要设置的已标出(详细的可以参考源码)* num_factors: 隐向量的维度, 也就是k *num_iter: 迭代次数, 由于使用的SGD, 随机梯度下降,要指明迭代多少个epoch * k0, k1: k0表示是否用偏置(看FM的公式),k1表示是否要第二项, 就是单个特征的, 这俩默认True * init_stdev:初始化隐向量时候的方差, 默认0.01 * validation_size:验证集的比例, 默认0.01 * learning_rate_schedule: 学习率衰减方式,有constant, optimal, 和invscaling三种方式, 具体公式看源码 *initial_learning_rate: 初始学习率, 默认0.01 *power_t, t0: 逆缩放学习率的指数,最优学习率分母常数,这两个和上面学习率衰减方式的计算有关 * task:分类或者回归任务, 要指明 * verbose: 是否打印当前的迭代次数, 训练误差 *shuffle_training: 是否在学习之前打乱训练集 * seed: 随机种子

1
2
3
4
5
# 评估
preds = fm.predict(X_test)

from sklearn.metrics import mean_squared_error
print('FM MSE: %.4f' % mean_squared_error(y_test, preds))

分类任务

创建一个随机的分类数据集并对数据集进行测试集和验证集的划分

1
2
3
4
5
6
7
from sklearn.datasets import make_classification   # 创建一个随机的分类数据集
from sklearn.model_selection import train_test_split
from sklearn.metrics import log_loss

X, y = make_classification(n_samples=1000, n_features=100, n_clusters_per_class=1) # 1000个训练样本, 100维的数据
data = [{v: k for k, v in dict(zip(i, range(len(i)))).items()} for i in X]
x_train, x_test, y_train, y_test = train_test_split(data, y, test_size=0.1, random_state=42)

对数据集进行one-hot的处理

1
2
3
v = DictVectorizer()
x_train = v.fit_transform(x_train)
x_test = v.transform(x_test)
1
2
3
4
5
6
7
8
# 建立模型
fm = pylibfm.FM(num_factors=50, num_iter=10, verbose=True, task='classification', initial_learning_rate=0.0001, learning_rate_schedule='optimal')

fm.fit(x_train, y_train)
y_pre = fm.predict(x_test)

# 评估模型
print('validation log loss: %.4f' % log_loss(y_test, y_pre))

FFM算法介绍与使用

FFM是基于FM进行的修改,FFM模型引入了特征域感知(filed-aware),我们先回顾一下FM模型公式: \[\hat{y}(X)=\omega_0+\sum_{i=1}^n \omega_i x_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^n<v_i, v_j>x_i x_j\] FFM就是一个特征对应多个隐向量。这样在与不同域(类)里面特征交叉的时候,用相应的隐向量去交叉计算权重,这样做的好处是学习隐向量的时候只需要考虑相应的域的数据,与不同类的特征进行关联采用不同的隐向量,这和不同类特征的内在差异也比较相符。 这其实就是FFM在FM的基础上做的改进,引入了域的概念,对于每个特征,针对不同的交叉域要学习不同的隐向量特征

引入不同的域的概念的分类

细品的话, 不同单词不同身份的时候,会有不同的embedding对待,其实这里的FFM域embedding,如果经过上面的铺垫感觉FFM差不多了,那么下面就是模型的方程了:

\[\hat{y}(X)=\omega_0+\sum_{i=1}^n \omega_i x_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^n<v_{i, f_j}, v_{j, f_i}>x_i x_j\] \(<v_{i, f_j}, v_{j,f_i}>\)注意这里的已经是加上域的内容之后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class FFM_Node(object):
'''
通常x是高维稀疏向量,所以用链表来表示一个x,链表上的每个节点是个3元组(j,f,v)
'''
__slots__ = ['j', 'f', 'v'] # 按照元组不是字典的方式存储类的成员属性

def __init__(self, j, f, v):
"""
j: Feature index (0-n-1)
f: field index(0-m-1)
v: value
"""
self.j = j
self.f = f
self.v = v

class FFM(object):
def __init__(self, m, n, k, eta, lambd):
"""
m: Number of fields
n: Number of features
k: Number of latent factors
eta: learning rate
lambd: regularization coefficient
"""
self.m = m
self.n = n
self.k = k

#超参数
self.eta = eta
self.lambd = lambd

# 初始化三维权重矩阵w~U(0, 1/sqrt(k))
self.w = np.random.rand(n, m, k) / math.sqrt(k)

# 初始化累积梯度平方和, AdaGrad时要用到
self.G = np.ones(shape=(n, m, k), dtype=np.float64)
self.log = Logistic()

# 这个是计算第三项
def phi(self, node_list):
"""
特征组合式的线性加权求和
param node_list: 用链表存储x中的非0值
"""
z = 0.0
for a in range(len(node_list)):
node1 = node_list[a]
j1 = node1.j
f1 = node1.f
v1 = node1.v
for b in range(a+1, len(node_list)):
node2 = node_list[b]
j2 = node2.j
f2 = node2.f
v2 = node2.v
w1 = self.w[j1, f2]
w2 = self.w[j2, f1]
z += np.dot(w1, w2) * v1 * v2

return z

def predict(self, node_list):
"""
输入x, 预测y的值
"""
z = self.phi(node_list)
y = self.log.decide_by_tanh(z)
return y

# 随机梯度下降
def sgd(self, node_list, y):
"""
根据一个样本更新模型参数:
node_list: 链表存储x中的非0值
y: 正样本1, 负样本-1
"""
kappa = -y / (1+math.exp(y*self.phi(node_list))) # 论文里面的那个导数
for a in range(len(node_list)):
node1 = node_list[a]
j1 = node1.j
f1 = node1.f
v1 = node1.v
for b in range(a+1, len(node_list)):
node2 = node_list[b]
j2 = node2.j
f2 = node2.f
v2 = node2.v
c = kappa * v1 * v2 # 这是求导数

# self.w[j1,f2]和self.w[j2,f1]是向量,导致g_j1_f2和g_j2_f1也是向量
g_j1_f2 = self.lambd * self.w[j1, f2] + c * self.w[j2, f1]
g_j2_f1 = self.lambd * self.w[j2, f1] + c * self.w[j1, f2]

# 计算各个维度上的梯度累积平方和
self.G[j1, f2] += g_j1_f2 ** 2
self.G[j2, f1] += g_j2_f1 ** 2

# Adagrad 算法
self.w[j1, f2] -= self.eta / np.sqrt(self.G[j1, f2]) * g_j1_f2 # sqrt(G)作为分母,所以G必须是大于0的正数
self.w[j2, f1] -= self.eta / np.sqrt(
self.G[j2, f1]) * g_j2_f1 # math.sqrt()只能接收一个数字作为参数,而numpy.sqrt()可以接收一个array作为参数,表示对array中的每个元素分别开方

# 训练
def train(self, sample_generator, max_echo, max_r2):
"""
根据一堆样本训练模型
sample_generator: 样本生成器,每次yield (node_list, y),node_list中存储的是x的非0值。通常x要事先做好归一化,即模长为1,这样精度会略微高一点
max_echo: 最大迭代次数
max_r2: 拟合系数r2达到阈值时即可终止学习
"""
for itr in range(max_echo):
print("echo: ", itr)
y_sum = 0.0
y_sqare_sum = 0.0
err_square_sum = 0.0 # 误差平方和
population = 0 # 样本总数
for node_list, y in sample_generator:
y = 0.0 if y == -1 else y # 真实的y取值为{-1,1},而预测的y位于(0,1),计算拟合效果时需要进行统一
self.sgd(node_list, y)
y_hat = self.predict(node_list)
y_sum += y
y_sqare_sum += y ** 2
err_square_sum += (y-y_hat) ** 2
population += 1

var_y = y_sqare_sum - y_sum * y_sum / population # y的方差
r2 = 1 - err_square_sum / var_y
print("r2: ", r2)
if r2 > max_r2:
print("r2 have reach", r2)
break

# 模型保存
def save_model(self, outfile):
'''
序列化模型
:param outfile:
:return:
'''
np.save(outfile, self.w)

def load_model(self, infile):
'''
加载模型
:param infile:
:return:
'''
self.w = np.load(infile)

调用的过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 设置参数   5个特征, 2个域, 2维的k
n = 5
m = 2
k = 2

# 路径
train_file = "dataset/train.txt"
valid_file = "dataset/test.txt"
model_file = "ffm.npy"

# 超参数
eta = 0.01
lambd = 1e-2
max_echo = 30
max_r2 = 0.9

# 训练模型,并保存模型参数
sample_generator = Sample(train_file)
ffm = FFM(m, n, k, eta, lambd)
ffm.train(sample_generator, max_echo, max_r2)
ffm.save_model(model_file)

逻辑回归模型与GBDT+LR模型

总体结构图

协同过滤和矩阵分解利用用户的物品“相似度”进行推荐,逻辑回归模型将问题看成了一个分类问题,通过预测正样本的概率对物品进行排序

这里的正样本可以是用户“点击”了某个商品或者“观看”了某个视频,均是推荐系统希望用户产生“正反馈”行为,因此逻辑回归模型将推荐问题转成成了一个点击率预估问题

逻辑回归LR算法

逻辑回归是在线性回归的基础上加了一个 Sigmoid函数(非线形)映射,使得逻辑回归称为了一个优秀的分类算法,学习逻辑回归模型,首先要记住一句话:逻辑回归假设数据服从伯努利分布,通过极大化似然函数的方法,运用梯度下降来求解参数,来达到将数据二分类的目的。

逻辑回归模型已经将推荐问题转换成了一个点击率预测的问题,而点击率预测就是一个典型的二分类, 正好适合逻辑回归进行处理,那么逻辑回归是如何做推荐的呢?

  1. 将用户年龄、性别、物品属性、物品描述、当前时间、当前地点等特征转成数值型向量

  2. 确定逻辑回归的优化目标,比如把点击率预测转换成二分类问题,这样就可以得到分类问题常用的损失作为目标, 训练模型

  3. 在预测的时候, 将特征向量输入模型产生预测,得到用户“点击”物品的概率

  4. 利用点击概率对候选物品排序, 得到推荐列表

每个特征的权重参数\(w\),我们一般是使用梯度下降的方式, 首先会先随机初始化一批\(w\),然后将特征向量(也就是我们上面数值化出来的特征)输入到模型,就会通过计算会得到模型的预测概率, 然后通过对目标函数求导得到每个\(w\)的梯度, 然后进行更新\(w\)

优点:

  1. LR模型形式简单,可解释性好,从特征的权重可以看到不同的特征对最后结果的影响。

  2. 训练时便于并行化,在预测时只需要对特征进行线性加权,所以性能比较好,往往适合处理海量id类特征,用id类特征有一个很重要的好处,就是防止信息损失(相对于范化的CTR 特征),对于头部资源会有更细致的描述

  3. 资源占用小,尤其是内存。在实际的工程应用中只需要存储权重比较大的特征及特征对应的权重

  4. 方便输出结果调整。逻辑回归可以很方便的得到最后的分类结果,因为输出的是每个样本的概率分数,我们可以很容易的对这些概率分数进行cutoff,也就是划分阈值(大于某个阈值的是一类,小于某个阈值的是一类)

  5. 工程化需要, 在深度学习技术之前, 逻辑回归凭借易于并行化,模型简单,训练开销小等特点,占领工程领域的主流,因为即使工程团队发现了复杂模型会提升效果,但一般如果没有把握击败逻辑回归的话仍然不敢尝试或者升级。

当然, 逻辑回归模型也有一定的局限性

  1. 表达能力不强, 无法进行特征交叉,特征筛选等一系列“高级“操作(这些工作都得人工来干,这样就需要一定的经验, 否则会走一些弯路), 因此可能造成信息的损失
  2. 准确率并不是很高。因为这毕竟是一个线性模型加了个sigmoid,形式非常的简单(非常类似线性模型),很难去拟合数据的真实分布
  3. 处理非线性数据较麻烦。逻辑回归在不引入其他方法的情况下,只能处理线性可分的数据,如果想处理非线性,首先对连续特征的处理需要先进行离散化(离散化的目的是为了引入非线性),如上文所说,人工分桶的方式会引入多种问题。
  4. LR需要进行人工特征组合,这就需要开发者有非常丰富的领域经验,才能不走弯路。这样的模型迁移起来比较困难,换一个领域又需要重新进行大量的特征工程。

GBDT原理

GBDT全称梯度提升决策树,GBDT(Gradient BoostingDecisionTree,梯度提升决策树)是一种机器学习算法,它通过优化损失函数的梯度来构建一组树模型的集合,并希望这些树模型能够协同工作以获得更准确的结果。

GBDT的工作原理:

  1. 初始化模型:使用一个简单的树模型作为起点。
  2. 训练模型:
    • 评估当前模型的损失函数。
    • 根据当前模型的预测值来计算损失函数的梯度。
    • 在当前模型的基础上,寻找能够最小化梯度下降的树模型。
  3. 组合模型:将新树模型的预测结果与原模型的预测结果结合,作为下一轮迭代的初始模型。
  4. 重复迭代:重复步骤2和3,直到达到预设的迭代次数或模型性能不再提升

GBDT的优点:

GBDT的缺点:

GBDT+LR算法原理

利用GBDT自动进行特征筛选和组合,进而生成新的离散特征向量,再把该特征向量当做LR模型的输入, 来产生最后的预测结果,这就是著名的GBDT+LR模型了。GBDT+LR使用最广泛的场景是CTR点击率预估,即预测当给用户推送的广告会不会被用户点击

GBDT+LR结构图

训练时,GBDT建树的过程相当于自动进行的特征组合和离散化,然后从根结点到叶子节点的这条路径就可以看成是不同特征进行的特征组合,用叶子节点可以唯一的表示这条路径,并作为一个离散特征传入LR 进行二次训练

比如上图中,有两棵树,x为一条输入样本,遍历两棵树后,x样本分别落到两颗树的叶子节点上,每个叶子节点对应LR一维特征,那么通过遍历树,就得到了该样本对应的所有LR特征。构造的新特征向量是取值0/1的。比如左树有三个叶子节点,右树有两个叶子节点,最终的特征即为五维的向量。对于输入x,假设他落在左树第二个节点,编码[0,1,0],落在右树第二个节点则编码[0,1],所以整体的编码为[0,1,0,0,1],这类编码作为特征,输入到线性分类模型(LRor FM)中进行分类。

预测时,会先走 GBDT的每棵树,得到某个叶子节点对应的一个离散特征(即一组特征组合),然后把该特征以one-hot 形式传入 LR 进行线性加权预测。

GBDT+LR编程实践

这个比赛的任务就是:开发预测广告点击率(CTR)的模型。给定一个用户和他正在访问的页面,预测他点击给定广告的概率是多少?比赛的地址链接:https://www.kaggle.com/c/criteo-display-ad-challenge/overview

导入相关的依赖包和数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import pandas as pd

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
import lightgbm as lgb
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder, LabelEncoder
from sklearn.metrics import log_loss

import gc
from scipy import sparse

import warnings
warnings.filterwarnings('ignore')


# 数据读取
path = 'data/'
df_train = pd.read_csv(path + 'train.csv')
df_test = pd.read_csv(path + 'test.csv')

数据预处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 数据读取
path = 'data/'
df_train = pd.read_csv(path + 'train.csv')
df_test = pd.read_csv(path + 'test.csv')

# 简单的数据预处理
# 去掉id列, 把测试集和训练集合并, 填充缺失值
df_train.drop(['Id'], axis=1, inplace=True)
df_test.drop(['Id'], axis=1, inplace=True)

df_test['Label'] = -1

data = pd.concat([df_train, df_test])
data.fillna(-1, inplace=True)

continuous_fea = ['I'+str(i+1) for i in range(13)]
category_fea = ['C'+str(i+1) for i in range(26)]

LR模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def lr_model(data, category_fea, continuous_fea):
# 连续特征归一化
scaler = MinMaxScaler()
for col in continuous_fea:
data[col] = scaler.fit_transform(data[col].values.reshape(-1, 1))

# 离散特征one-hot编码
for col in category_fea:
onehot_feats = pd.get_dummies(data[col], prefix=col)
data.drop([col], axis=1, inplace=True)
data = pd.concat([data, onehot_feats], axis=1)

# 把训练集和测试集分开
train = data[data['Label'] != -1]
target = train.pop('Label')
test = data[data['Label'] == -1]
test.drop(['Label'], axis=1, inplace=True)

# 划分数据集
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size=0.2, random_state=2020)

# 建立模型
lr = LogisticRegression()
lr.fit(x_train, y_train)
tr_logloss = log_loss(y_train, lr.predict_proba(x_train)[:, 1]) # −(ylog(p)+(1−y)log(1−p)) log_loss
val_logloss = log_loss(y_val, lr.predict_proba(x_val)[:, 1])
print('tr_logloss: ', tr_logloss)
print('val_logloss: ', val_logloss)

# 模型预测
y_pred = lr.predict_proba(test)[:, 1] # predict_proba 返回n行k列的矩阵,第i行第j列上的数值是模型预测第i个预测样本为某个标签的概率, 这里的1表示点击的概率
print('predict: ', y_pred[:10]) # 这里看前10个, 预测为点击的概率
1
2
# 训练和预测
lr_model(data.copy(), category_fea, continuous_fea)

LR预测的结果:

逻辑回归预测结果

GBDT建模

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def gbdt_model(data, category_fea, continuous_fea):

# 离散特征one-hot编码
for col in category_fea:
onehot_feats = pd.get_dummies(data[col], prefix=col)
data.drop([col], axis=1, inplace=True)
data = pd.concat([data, onehot_feats], axis=1)

# 训练集和测试集分开
train = data[data['Label'] != -1]
target = train.pop('Label')
test = data[data['Label'] == -1]
test.drop(['Label'], axis=1, inplace=True)

# 划分数据集
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size=0.2, random_state=2020)

# 建模
gbm = lgb.LGBMClassifier(boosting_type='gbdt', # 这里用gbdt
objective='binary',
subsample=0.8,
min_child_weight=0.5,
colsample_bytree=0.7,
num_leaves=100,
max_depth=12,
learning_rate=0.01,
n_estimators=10000
)
gbm.fit(x_train, y_train,
eval_set=[(x_train, y_train), (x_val, y_val)],
eval_names=['train', 'val'],
eval_metric='binary_logloss',
early_stopping_rounds=100,
)

tr_logloss = log_loss(y_train, gbm.predict_proba(x_train)[:, 1]) # −(ylog(p)+(1−y)log(1−p)) log_loss
val_logloss = log_loss(y_val, gbm.predict_proba(x_val)[:, 1])
print('tr_logloss: ', tr_logloss)
print('val_logloss: ', val_logloss)

# 模型预测
y_pred = gbm.predict_proba(test)[:, 1] # predict_proba 返回n行k列的矩阵,第i行第j列上的数值是模型预测第i个预测样本为某个标签的概率, 这里的1表示点击的概率
print('predict: ', y_pred[:10]) # 这里看前10个, 预测为点击的概率
1
2
# 模型训练和预测
gbdt_model(data.copy(), category_fea, continuous_fea)

预测的结果:

GBDT预测结果展示

LR + GBDT建模

下面就是把上面两个模型进行组合, GBDT负责对各个特征进行交叉和组合,把原始特征向量转换为新的离散型特征向量, 然后在使用逻辑回归模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def gbdt_lr_model(data, category_feature, continuous_feature): # 0.43616
# 离散特征one-hot编码

# 划分数据集
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size = 0.2, random_state = 2020)

#GBDT模型的搭建和训练过程
gbm = lgb.LGBMClassifier(objective='binary',
subsample= 0.8,
min_child_weight= 0.5,
colsample_bytree= 0.7,
num_leaves=100,
max_depth = 12,
learning_rate=0.01,
n_estimators=1000,
)

gbm.fit(x_train, y_train,
eval_set = [(x_train, y_train), (x_val, y_val)],
eval_names = ['train', 'val'],
eval_metric = 'binary_logloss',
early_stopping_rounds = 100,
)

model = gbm.booster_

gbdt_feats_train = model.predict(train, pred_leaf = True)
gbdt_feats_test = model.predict(test, pred_leaf = True)
gbdt_feats_name = ['gbdt_leaf_' + str(i) for i in range(gbdt_feats_train.shape[1])]
df_train_gbdt_feats = pd.DataFrame(gbdt_feats_train, columns = gbdt_feats_name)
df_test_gbdt_feats = pd.DataFrame(gbdt_feats_test, columns = gbdt_feats_name)

train = pd.concat([train, df_train_gbdt_feats], axis = 1)
test = pd.concat([test, df_test_gbdt_feats], axis = 1)
train_len = train.shape[0]
data = pd.concat([train, test])
del train
del test
gc.collect()

# # 连续特征归一化
scaler = MinMaxScaler()
for col in continuous_feature:
data[col] = scaler.fit_transform(data[col].values.reshape(-1, 1))

for col in gbdt_feats_name:
onehot_feats = pd.get_dummies(data[col], prefix = col)
data.drop([col], axis = 1, inplace = True)
data = pd.concat([data, onehot_feats], axis = 1)

train = data[: train_len]
test = data[train_len:]
del data
gc.collect()

x_train, x_val, y_train, y_val = train_test_split(train, target, test_size = 0.3, random_state = 2018)

#再放进LR模型中
lr = LogisticRegression()
lr.fit(x_train, y_train)
tr_logloss = log_loss(y_train, lr.predict_proba(x_train)[:, 1])
print('tr-logloss: ', tr_logloss)
val_logloss = log_loss(y_val, lr.predict_proba(x_val)[:, 1])
print('val-logloss: ', val_logloss)
y_pred = lr.predict_proba(test)[:, 1]
print(y_pred[:10])

GBDT+LR预测结果展示

深度学习模型(Rank)

]]>
+

推荐系统算法总结

推荐系统近几年有了深度学习的助推发展之势迅猛,从前深度学习的传统推荐模型(协同过滤,矩阵分解,LR, FM, FFM,GBDT)到深度学习的浪潮之巅(DNN, Deep Crossing, DIN, DIEN, Wide&Deep,Deep&Cross, DeepFM, AFM, NFM, PNN, FNN,DRN)。推荐系统通过分析用户的历史行为给用户的兴趣建模,从而主动给用户推荐给能够满足他们兴趣和需求的信息

传统模型(Recall)

传统推荐算法宏观框架

数据集介绍

https://github.com/ZiyaoGeng/RecLearn/wiki/Movielens

rating

标签列表为:UserID::MovieID::Rating::Timestamp

user

标签列表为:UserID::Gender::Age::Occupation::Zip-code

movies

标签列表为:MovieID::Title::Genres

基于内容的推荐

这是一种比较简单的推荐方法,基于内容的推荐方法是非常直接的,它以物品的内容描述信息为依据来做出的推荐,本质上是基于对物品和用户自身的特征或属性的直接分析和计算。例如,假设已知电影A是一部喜剧,而恰巧我们得知某个用户喜欢看喜剧电影,那么我们基于这样的已知信息,就可以将电影A推荐给该用户。具体实现步骤:

基本流程

物品画像

本质上是基于对物品和用户自身的特征或属性的直接分析和计算

构建标签数据集

数据集的四列关键词为:userId, movieId, tag, timestamp

1
2
3
4
5
6
7
_tags = pd.read_csv("ml-latest-small/all-tags.csv", usecols=range(1, 3)).dropna()
#使用的范围是1-2列,dropna是将数值为nan的删除

tags = _tags.groupby("movieId").agg(list)
#根据某个名为movieId的列进行分组,并对每个组执行聚合操作,将每个组的标签列表合并为一个列表

tags.head()

聚合之后的列表数据集

构建物料数据

数据集关键标签为:movieId, title, genres


1
2
3
4
5
6
7
movies = pd.read_csv("ml-latest-small/movies.csv", index_col="movieId")
#参数告诉函数将CSV文件中的第一列(即电影ID列)作为DataFrame的索引

movies['genres'] = movies['genres'].apply(lambda x: x.split("|"))
#.apply(lambda x: x.split("|")) 用于对genres列中的每个元素应用一个lambda函数。这个lambda函数接受一个参数x,并返回x split by "|“的结果,即将字符串x按”|"分割成列表

movies.head()

物品标签处理

合并物品和标签的列表

1
2
3
4
5
6
7
8
9
10
11
movies_index = set(movies.index) & set(tags.index)
#set(movies.index) set(tags.index) 将索引转换为一个集合,去除了重复的索引值。
#&set(tags.index) 返回两个集合中共同的元素组成的新集合。

new_tags = tags.loc[list(movies_index)]
#根据之前得到的交集集合movies_index来筛选tags,只保留那些与movies DataFrame有相同索引的电影的标签

ret = movies.join(new_tags)
#这段代码的作用是将两个DataFrame(movies和tags)根据共同的索引(电影ID)进行合并

ret.head()

数据处理并补充缺失值

1
2
3
df = map(lambda x: (x[0], x[1], x[2], x[2]+x[3]) if x[3] is not np.nan else (x[0], x[1], x[2], []), ret.itertuples())
movies_dataset = pd.DataFrame(df, columns=['movieId', 'title', 'genres', 'tags'])
movies_dataset.head()

这段代码的作用是对retDataFrame中的tags列进行处理,将缺失值替换为空列表,并创建一个新的DataFrame,以便于后续的数据分析和处理

TF-IDF模型

引入相关的软件包,其中包含了TfidfModel相当于是TFIDF模型

1
2
3
from gensim.models import TfidfModel
from pprint import pprint
from gensim.corpora import Dictionary

通常用于创建词典,它将数据集中的所有唯一单词作为键,每个单词出现的次数作为值

1
2
3
4
5
6
7
8
dataset = movies_dataset["tags"].values
# 根据数据集建立词袋, 并统计词频, 将所有词放入一个词典, 使用索引进行获取
dct = Dictionary(dataset)

# 对于每条数据, 返回对应的词索引和词频
corpus = [dct.doc2bow(line) for line in dataset] # 这个东西统计每个单词在每部电影中出现的词频 tf

#doc2bow将文本转换为词袋模型的表示形式。在词袋模型中,文本被表示为一个矩阵

构建TF-IDF模型并进行展示

1
2
3
4
# 根据这个词频, 就可以训练Tf-IDF模型, 计算TF-IDF值
model = TfidfModel(corpus)
#corpus中的第一个文档(索引为0)应用TF-IDF模型
model[corpus[0]]

TFIDF模型结果展示

1
2
3
4
5
6
7
8
9
# 保存每个电影tf-idf值最高的30个标签
movie_profile = {}
for i, mid in enumerate(movies_dataset.index):
# 对于每部电影, 返回每个标签的tf-idf值
tfidf_vec = model[corpus[i]]
# 按照tfidf值排序, 然后取Top-N
movies_tags = sorted(tfidf_vec, key=lambda x: x[1], reverse=True)[:30]
# 根据关键词提取对应的名称
movie_profile[mid] = dict(map(lambda x: (dct[x[0]], x[1]), movies_tags))

得出最终的结果

1
2
movie_profile = create_movie_profile(movie_dataset)
movie_profile.head()

物料数据的处理结果

建立倒排索引

倒排索引就是用物品的其他数据作为索引,去提取他们对应的物品的ID列表

1
2
3
4
5
6
7
8
9
10
11
12
# 建立倒排索引  为了根据指定关键词迅速匹配到对应的电影,因此需要对物品画像的标签词,建立**倒排索引**
# 通常数据存储数据, 都是以物品的ID作为索引, 去提取物品的其他信息数据
# 而倒排索引就是用物品的其他数据作为索引, 去提取他们对应的物品的ID列表
def create_inverted_table(movie_profile):
inverted_table = {}
for mid, weights in movie_profile['weights'].iteritems():
for tag, weight in weights.items():
# 到inverted_table dict 用tag作为key去取值, 如果取不到就返回[]
_ = inverted_table.get(tag, [])
_.append((mid, weight))
inverted_table.setdefault(tag, _)
return inverted_table
1
inverted_table = create_inverted_table(movie_profile)

这样就可以直接根据标签去推荐电影

用户画像

构建步骤:

  1. 根据用户的评分历史,结合物品画像,将有观影记录的电影的画像标签作为初始标签反打到用户身上
  2. 通过对用户观影标签的次数进行统计,计算用户的每个初始标签的权重值,排序后选取TOP-N作为用户最终的画像标签
1
2
import collections
from functools import reduce
1
2
3
4
5
6
#读取了名为ratings.csv的文件,只使用了文件中的第二列和第三列
watch_record = pd.read_csv("ml-latest-small/ratings.csv", usecols=range(2), dtype={"userId":np.int32, "movieId": np.int32})

watch_record = watch_record.groupby("userId").agg(list)

#从电影评分数据集中读取用户ID和电影ID

用户ID和电影ID

产生Top-N的推荐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
user_profile = {}
for uid, mids in watch_record.itertuples():
# 这里把当前用户看过的电影从movie_profile中找出来
record_movie_profile = movie_profile.loc[list(mids)]

# 下面需要把这些电影的标签都合并到一块, 然后统计出现的次数, 这里的Counter和reduce用的秒
counter = collections.Counter(reduce(lambda x, y: list(x) + list(y), record_movie_profile['profile'].values))

# 兴趣词 从计数器对象中检索出出现次数最多的50个标签/关键词以及它们的计数
interest_words = counter.most_common(50)
maxcount = interest_words[0][1] #这些常见标签中的最大计数

# 这里归一化一下
interest_words = [(w, round(c/maxcount, 4)) for w, c, in interest_words]
user_profile[uid] = interest_words
1
user_profile[1]        # 用户1感兴趣的词

例子 用户1感兴趣的词语

这个地方根据用户观看的视频以及视频对应的关键词和每个关键词的权重,给出每个用户感兴趣的关键词的权重归一化之后的值

注意,这个地方给出的归一化的操作是使用频率进行归一化操作

下面给用户进行视频的推荐

输出对应的用户的uid,推荐的视频编号,以及视频的推荐概率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for uid, interest_words in user_profile.items():
result_table = {} # 电影id: [0.2, 0.5]
for interest_word, interest_weight in interest_words:
related_movies = inverted_table[interest_word]
for mid, relate_weight in related_movies:
_ = result_table.get(mid, [])
_.append(interest_weight) #只考虑用户的兴趣程度
# _.append(related_weight) # 只考虑兴趣词与电影的关联程度
# _.append(interest_weight * related_weight) # 二者都考虑
result_table.setdefault(mid, _)

rs_result = map(lambda x: (x[0], sum(x[1])), result_table.items())
rs_result = sorted(rs_result, key=lambda x: x[1], reverse=True)[:100]
print(uid)
pprint(rs_result)
break

冷启动算法

这里主要包括两个很厉害的技术:

* Word2Vec: 这个可以根据得到电影标签的词向量,根据这个词向量, 就能够得到tag之间的相似性,这样就能够根据用户看过的某个电影, 得到这个电影的标签,然后根据这些标签得到与其近似的标签,然后得到这些近似标签下的电影对该用户产生推荐

1
2
3
4
5
6
7
8
9
10
11
from gensim.models import Word2Vec

# 由于前面我们已经得到了每部影片的tags,物品画像里面。 所以这里我们就可以直接建立word2vec模型, 来进行标签的词向量计算
sentences = list(movie_profile["profile"].values) # 二维列表 每个元素是字符串

# 这里可以直接建立模型
model = Word2Vec(sentences, window=3, min_count=1)

words = input("words: ")
ret = model.wv.most_similar(positive=[words], topn=10) # 找到最相似的n 个词
print(ret)

* Doc2Vec:这个可以根据电影的所有标签,训练一个模型来得到最终电影的影片向量, 根据这个,就能够直接计算用户看过的某个电影与其他电影的相似性,然后根据这个相似性给用户推荐最相似的几篇文章。

1
2
3
4
5
6
7
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from gensim.test.utils import get_tmpfile

# 建立文档, words就是影片的tags, tags就是影片的id
documents = [TaggedDocument(words, [movie_id]) for movie_id, words in movie_profile["profile"].iteritems()]

documents

转化为文档的格式实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 训练Doc2Vec模型
model = Doc2Vec(documents, vector_size=100, window=3, min_count=1, epochs=20)

# 模型保存
# fname = get_tmpfile("my_doc2vec_model")
# model.save(fname)

# 获取某个电影的tages
words = movie_profile["profile"].loc[6]
print(words)
# 拿到该影片的Doc2vec向量
inferred_vector = model.infer_vector(words)
sims = model.docvecs.most_similar([inferred_vector], topn=10)
print(sims)

协同过滤算法

协同过滤(Collaborative Filtering)算法,基本思想是根据用户之前的喜好以及其他兴趣相近的用户的选择来给用户推荐物品(基于对用户历史行为数据的挖掘发现用户的喜好偏向,并预测用户可能喜好的产品进行推荐),一般是仅仅基于用户的行为数据(评价、购买、下载等),而不依赖于项的任何附加信息(物品自身特征)或者用户的任何附加信息(年龄,性别等)。目前应用比较广泛的协同过滤算法是基于邻域的方法,而这种方法主要有下面两种算法:

两种CF算法介绍

用户协同和物品协同的使用场景

*UserCF的推荐更社会化,反映了用户所在的小型兴趣群体中物品的热门程度;

* ItemCF的推荐更加个性化,反映了用户自己的兴趣传承

UserCF适合于新闻推荐

ItemCF适合于图书、电子商务和电影网站

UserCF的适用场合

ItemCF的适用场合

该实验使用的数据集来自:http://grouplens.org/datasets/movielens/

算法基本流程

不管是UserCF还是ItemCF, 行文逻辑都是下面的四个步骤: 1. 导入数据,读取文件得到"用户-电影"的评分数据, 并且分为训练集和测试集 2.计算用户(userCF)或者电影(itemcf)之间的相似度 3. 针对目标用户u,找到其最相似的k个用户/产品, 产生N个推荐 4. 产生推荐之后,通过准确率、召回率和覆盖率等进行评估。

工业界协同过滤的流程

  1. 数据处理
  1. 建立用户embedding和物品embedding, 或者可以像案例这样,直接建立共现矩阵, 也可以训练embedding
  2. 计算用户和N个用户的相似度, 保存N个相似用户曾经看过的TopK个物品
  3. 模型(矩阵)进行定期更新, 这个要根据不同项目组的情况,可能是一天更新一次, 也可能不是, 看具体的情况,更新的时候使用前N天(N一般3-10)的活跃用户的数据进行更新
  4. 每次召回一次N条, 刷完N条再继续召回
  1. 为了保证用户不疲劳, 一般情况下, 利用user-cf计算召回结果后,会做一定的类别去重, 保证召回覆盖度
  2. 实际过程中, 根据公司核心用户的数量大小, 考虑实现工具,如果数据量较大, 可使用spark进行用户协同的结果计算
  3. 如果用户量实在太过巨大, 可考虑使用稀疏存储的方式进行存储,即只存储含有1(或者其他值)的位置坐标索引index以及对应的值

用户协同过滤算法

TopN推荐的任务是预测用户会不会对某部电影评分,而不是预测用户在准备对某部电影评分的前提下给电影评多少分,下面我们开始, 从逻辑上看, 其实这个任务主要分为下面的步骤: 1.导入数据, 读取文件得到"用户-电影"的评分数据, 并且分为训练集和测试集 2.计算用户之间的相似度 3. 针对目标用户u, 找到其最相似的k个用户,产生N个推荐 4. 产生推荐之后, 通过准确率、召回率和覆盖率等进行评估。

引入依赖包和读取数据

1
2
3
4
5
6
7
8
9
10
11
import random
import numpy as np
import pandas as pd
import math
from operator import itemgetter

data_path = './ml-latest-small/'
data = pd.read_csv(data_path+'ratings.csv')
data.head()

data.pivot(index='userId', columns='movieId', values='rating') # 这样会发现有大量的稀疏, 所以才会用字典进行存放

存在大量的稀疏的情况,因此后续会对此进行数据的补全

将数据集划分成训练集和测试集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 声明两个字典, 分别是训练集和测试集
trainSet, testSet = {}, {}
trainSet_len, testSet_len = 0, 0
pivot = 0.75 # 训练集的比例

# 遍历data的每一行, 把userId, movidId, rating按照{user: {movidId: rating}}的方式存储, 当然定义一个随机种子进行数据集划分
for ele in data.itertuples(): # 遍历行这里推荐用itertuples, 比iterrows会高效很多
user, movie, rating = getattr(ele, 'userId'), getattr(ele, 'movieId'), getattr(ele, 'rating')
if random.random() < pivot:
trainSet.setdefault(user, {})
trainSet[user][movie] = rating
trainSet_len += 1
else:
testSet.setdefault(user, {})
testSet[user][movie] = rating
testSet_len += 1

print('Split trainingSet and testSet success!')
print('TrainSet = %s' % trainSet_len)
print('TestSet = %s' % testSet_len)
1
2
3
Split trainingSet and testSet success!
TrainSet = 75732
TestSet = 25104

建立用户相似度的表

如果直接遍历用户表会产生比较大的时间复杂度,不如直接建立一个物品到用户的倒排表,对每个物品都保存对该物品都产生过的用户列表

image-20240205142142169

具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
user_sim_matrix = {}

# 构建“电影-用户”倒排索引 # key = movidID, value=list of userIDs who have seen this move
print('Building movie-user table ...')
movie_user = {}
for user, movies in trainSet.items():
# 这里的user就是每个用户, movies还是个字典,{movieID: rating}
for movie in movies: # 这里的movie就是movieID了
if movie not in movie_user: # 如果movidID没在倒排索引里面
movie_user[movie] = set() # 声明这个键的值是set, 保证用户不重复
movie_user[movie].add(user) # 把用户加进去, 看上面的图就明白了
print('Build movie-user table success!')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 下面建立用户相似矩阵

# 计算总共的电影的数量
movie_count = len(movie_user)
print('Total movie number = %d' % movie_count)

print('Build user co-rated users matrix ...')
for movie, users in movie_user.items(): # movid是movieID, users是set集合
for u in users: # 对于每个用户, 都得双层遍历
for v in users:
if u == v:
continue
user_sim_matrix.setdefault(u, {}) # 把字典的值设置为字典的形式
user_sim_matrix[u].setdefault(v, 0)
user_sim_matrix[u][v] += 1 # 这里统计两个用户对同一部电影产生行为的次数, 这个就是余弦相似度的分子
print('Build user co-rated users matrix success!')

# 下面计算用户之间的相似性
print('Calculating user similarity matrix ...')
for u, related_users in user_sim_matrix.items():
for v, count in related_users.items(): # 这里面v是相关用户, count是共同对同一部电影打分的次数
user_sim_matrix[u][v] = count / math.sqrt(len(trainSet[u]) * len(trainSet[v])) # len 后面的就是用户对电影产生过行为的个数
print('Calculate user similarity matrix success!')

针对用户u,找到和他最相似的k个用户,并产生N个推荐

得到用户的兴趣相似度后,UserCF算法会对用户推荐和他兴趣最相似的K个用户喜欢的物品。下面公式度量了UserCF算法中用户u对物品i的感兴趣程度:

\[p(u, i)=\sum_{v \in S(u, K) \cap N(i)}w_{u v} r_{v i}\]

其中, \(S(u,k)\)包含和兴趣u兴趣最接近的K个用户,\(N(i)\)是对物品i有过行为的用户集合,\(w_{uv}\)是用户u和用户v的兴趣相似度,\(r_{vi}\)代表用户v对物品i的兴趣,因为使用单一行为的隐反馈数据, 所以这里\(r_{vi}=1\)

所以下面的代码逻辑是这样: * 首先, 给定我一个用户ID,我先拿到这个用户ID目前看过的所有电影, 以防后面推荐重了。
* 然后从相似性矩阵中,找到与当前用户最相近的K个用户 *遍历他们看过的电影, 如果当前用户没有看过, 该电影的权重等级累加 *最后给所有的电影进行排序, 推荐前n部给当前用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 找到最相似的20个用户, 产生10个推荐
k = 20
n = 10
aim_user = 3 # 目标用户ID
rank ={}
watched_movies = trainSet[aim_user] # 找出目标用户看到电影
# 下面从相似性矩阵中找到与aim_user最相近的K个用户

# v 表示相似用户, wuv表示相似程度
for v, wuv in sorted(user_sim_matrix[aim_user].items(), key=lambda x: x[1], reverse=True)[0:k]: # 字典按值从大到小排序, 相关性高到第

# 把v用户看过的电影推荐给目标用户
for movie in trainSet[v]:
if movie in watched_movies:
continue
rank.setdefault(movie, 0)
rank[movie] += wuv #这里给出的权重直接就是按照和用户的相似度的大小决定了最终的电影的大小推荐

# 产生最后的推荐列表
rec_movies = sorted(rank.items(), key=itemgetter(1), reverse=True)[:n] # itemgetter(1) 是简洁写法

产生的最终N个推荐

推荐结果的评估

  1. 召回率

    对用户u推荐N个物品记为\(R(u)\),令用户u在测试集上喜欢的物品集合为\(T(u)\), 那么召回率定义为: \[\operatorname{Recall}=\frac{\sum_{u}|R(u) \cap T(u)|}{\sum_{u}|T(u)|}\]这个意思就是说在用户真实购买或者看过的影片里面,我模型真正预测出了多少,这个考察的是模型推荐的一个全面性。分母的位置是测试集上的总的数量

  2. 准确率

    准确率定义为: \[\operatorname{Precision}=\frac{\sum_{u} \mid R(u) \capT(u)}{\sum_{u}|R(u)|}\] 这个意思在我推荐的所有物品中, 用户真正看的有多少,这个考察的是我模型推荐的一个准确性。

  3. 覆盖率

    覆盖率反映了推荐算法发掘长尾的能力, 覆盖率越高,说明推荐算法越能将长尾中的物品推荐给用户。 \[\text { Coverage }=\frac{\left|\bigcup_{u \in U} R(u)\right|}{|I|}\]该覆盖率表示最终的推荐列表中包含多大比例的物品。如果所有物品都被给推荐给至少一个用户,那么覆盖率是100%

  4. 新颖度

    用推荐列表中物品的平均流行度度量推荐结果的新颖度。如果推荐出的物品都很热门, 说明推荐的新颖度较低。由于物品的流行度分布呈长尾分布, 所以为了流行度的平均值更加稳定,在计算平均流行度时对每个物品的流行度取对数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 提供推荐的接口:def recommend(aim_user, k=20, n=10):

# 准确率、召回率和覆盖率
hit = 0
rec_count = 0 # 统计推荐的影片数量, 计算查准率
test_count = 0 # 统计测试集的影片数量, 计算查全率
all_rec_movies = set() # 统计被推荐出来的影片个数,无重复了,为了计算覆盖率
item_populatity = dict() # 计算新颖度

# 先计算每部影片的流行程度
for user, items in trainSet.items():
for item in items.keys():
if item not in item_populatity:
item_populatity[item] = 0
item_populatity[item] += 1 # 这里统计训练集中每部影片用户观看的总次数,代表每部影片的流行程度

# 计算评测指标
ret = 0
ret_cou = 0
for user, items in trainSet.items(): # 这里得保证测试集里面的用户在训练集里面才能推荐
test_movies = testSet.get(user, {})
rec_movies = recommend(user)
for movie, w in rec_movies:
if movie in test_movies:
hit += 1 #准确率++
all_rec_movies.add(movie)
ret += math.log(1+item_populatity[movie])
ret_cou += 1
rec_count += n
test_count += len(test_movies)

precision = hit / (1.0 * rec_count)
recall = hit / (1.0 * test_count)
coverage = len(all_rec_movies) / movie_count
ret /= ret_cou*1.0

print('precisioin = %.4f\nrecall = %.4f\ncoverage = %.4f\npopularity = %.4f' % (precision, recall, coverage, ret))

物品协同过滤算法

  1. 导入数据, 读取文件得到"用户-电影"的评分数据,并且分为训练集和测试
  2. 计算电影之间的相似度
  3. 针对目标用户u, 找到其最相似的k个用户, 产生N个推荐
  4. 产生推荐之后, 通过准确率、召回率和覆盖率等进行评估。

这一步之前都和基于用户的协同过滤算法一样,都进行了数据集的训练和测试的划分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 声明两个字典, 分别是训练集和测试集
trainSet, testSet = {}, {}
trainSet_len, testSet_len = 0, 0
pivot = 0.75 # 训练集的比例

# 遍历data的每一行, 把userId, movidId, rating按照{user: {movidId: rating}}的方式存储, 当然定义一个随机种子进行数据集划分
for ele in data.itertuples(): # 遍历行这里推荐用itertuples, 比iterrows会高效很多
user, movie, rating = getattr(ele, 'userId'), getattr(ele, 'movieId'), getattr(ele, 'rating')
if random.random() < pivot:
trainSet.setdefault(user, {})
trainSet[user][movie] = rating
trainSet_len += 1
else:
testSet.setdefault(user, {})
testSet[user][movie] = rating
testSet_len += 1

print('Split trainingSet and testSet success!')
print('TrainSet = %s' % trainSet_len)
print('TestSet = %s' % testSet_len)

UserItemCF相似, 这里同样需要建立一个倒排表,只不过这里的倒排变成了{用户:物品}的倒排表, 如下:

用户,物品 的倒排表

我们这里的存储正好是“用户-物品"评分表, 所以现在正好是倒排的形式,所以不用刻意建立建立倒排表, 直接遍历trainSet即可, 但是在这之前,我们先计算下每部电影的流行程度, 也就是被用户观看的总次数,这个在衡量相似度的时候作为分母, 这里的其他逻辑和UserCF基本一致了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 计算每部电影的流行程度, 也就是每部电影被用户看的总次数, 这个衡量相似度的时候作为分母
movie_popular = {}
for user, movies in trainSet.items(): # 这里的user就是每个用户, movies还是个字典, {movieID: rating}
for movie in movies: # 这里的movie就是movieID了
if movie not in movie_popular:
movie_popular[movie] = 0
movie_popular[movie] += 1
movie_count = len(movie_popular)
print('Total movie number = %d' % movie_count)
# 下面建立电影相似矩阵
print('Build user co-rated movies matrix ...')
movie_sim_matrix = {}
for user, movies in trainSet.items(): # 注意这个地方与UserCF的不同
for m1 in movies: # 对于每个电影, 都得双层遍历
for m2 in movies:
if m1 == m2:
continue
movie_sim_matrix.setdefault(m1, {}) # 把字典的值设置为字典的形式
movie_sim_matrix[m1].setdefault(m2, 0)
movie_sim_matrix[m1][m2] += 1 # 这里统计两个电影被同一个用户产生行为的次数, 这个就是余弦相似度的分子
print('Build user co-rated movies matrix success!')

# 下面计算电影之间的相似性
print('Calculating movies similarity matrix ...')
for m1, related_movies in movie_sim_matrix.items():
for m2, count in related_movies.items(): # 这里面m2是相关电影, count是共同被同一个用户打分的次数
# 这里注意零向量的处理, 即某电影的用户数为0
if movie_popular[m1] == 0 or movie_popular[m2] == 0:
movie_sim_matrix[m1][m2] = 0
else:
movie_sim_matrix[m1][m2] = count / math.sqrt(movie_popular[m1] * movie_popular[m2])
print('Calculate movies similarity matrix success!')

找到相似性最高的K个物品并进行N推荐

所以下面的代码逻辑是这样: * 首先, 给定我一个用户ID,我先拿到这个用户ID目前看过的所有电影, 以防后面推荐重了。
* 然后从相似性矩阵中,找到与当前用户看的物品的最相近的K个物品 *遍历他们看过的电影, 如果当前用户没有看过, 该电影的权重等级累加 *最后给所有的电影进行排序, 推荐前n部给当前用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 找到最相似的K个产品, 并推荐给n个用户
k = 20
n = 10
aim_user = 10 # 目标用户ID
rank ={}
watched_movies = trainSet[aim_user] # 找出目标用户看到电影
for movie, rating in watched_movies.items():
#遍历与物品item最相似的前k个产品,获得这些物品及相似分数
for related_movie, w in sorted(movie_sim_matrix[movie].items(), key=itemgetter(1), reverse=True)[:k]:
# 若该物品用户看过, 跳过
if related_movie in watched_movies:
continue

# 计算用户user对related_movie的偏好值, 初始化该值为0
rank.setdefault(related_movie, 0)
#通过与其相似物品对物品related_movie的偏好值相乘并相加。
#排名的依据—— > 推荐电影与该已看电影的相似度(累计) * 用户对已看电影的评分
rank[related_movie] += w * float(rating)
# 产生最后的推荐列表
rec_movies = sorted(rank.items(), key=itemgetter(1), reverse=True)[:n] # itemgetter(1) 是简洁写法

对模型的性能进行评估

这部分的内容和基于用户的协同过滤算法一样

  1. 召回率
  2. 准确率
  3. 覆盖率
  4. 新颖率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 准确率、召回率和覆盖率
hit = 0
rec_count = 0 # 统计推荐的影片数量, 计算查准率
test_count = 0 # 统计测试集的影片数量, 计算查全率
all_rec_movies = set() # 统计被推荐出来的影片个数, 无重复了, 为了计算覆盖率
item_populatity = dict() # 计算新颖度
# 先计算每部影片的流行程度
for user, items in trainSet.items():
for item in items.keys():
if item not in item_populatity:
item_populatity[item] = 0
item_populatity[item] += 1 # 这里统计训练集中每部影片用户观看的总次数, 代表每部影片的流行程度
# 计算评测指标
ret = 0
ret_cou = 0
for user, items in trainSet.items(): # 这里得保证测试集里面的用户在训练集里面才能推荐
test_movies = testSet.get(user, {})
rec_movies = recommend(user)
for movie, w in rec_movies:
if movie in test_movies:
hit += 1
all_rec_movies.add(movie)
ret += math.log(1+item_populatity[movie])
ret_cou += 1
rec_count += n
test_count += len(test_movies)
precision = hit / (1.0 * rec_count)
recall = hit / (1.0 * test_count)
coverage = len(all_rec_movies) / movie_count
ret /= ret_cou*1.0
print('precisioin = %.4f\nrecall = %.4f\ncoverage = %.4f\npopularity = %.4f' % (precision, recall, coverage, ret))

隐语义模型与矩阵分解

参考文档https://blog.csdn.net/wuzhongqiang/article/details/108173885

矩阵分解算法

矩阵分解算法:BacisSVD, RSVD, ASVD, SVD++

隐语义模型其实就是在想办法基于这个评分矩阵去找到上面例子中的那两个矩阵,也就是用户兴趣和物品的隐向量表达,然后就把这个评分矩阵分解成Q和P两个矩阵乘积的形式,这时候就可以基于这两个矩阵去预测某个用户对某个物品的评分了。然后基于这个评分去进行推荐

  1. 首先, 先初始化用户矩阵P和物品矩阵Q,P的维度是[users_num, F],Q的维度是[item_nums, F], 这个F是隐向量的维度。也就是通过隐向量的方式把用户的兴趣和F的特点关联了起来。初始化这两个矩阵的方式很多, 但根据经验,随机数需要和1/sqrt(F)成正比。
  2. 有了两个矩阵之后, 我就可以根据用户已经打分的数据去更新参数,这就是训练模型的过程, 方法很简单, 就是遍历用户, 对于每个用户,遍历它打分的电影,这样就拿到了该用户和电影的隐向量,然后两者相乘加上偏置就是预测的评分, 这时候与真实评分有个差距,根据上面的梯度下降就可以进行参数的更新
  3. 训练好模型之后, 就可以进行预测评分, 根据预测的评分对用户推荐
  4. 评估模型, 评估方式还是协同过滤里面的四种评估标准

矩阵分解算法将m × n维的共享矩阵R分解成m × k维的用户矩阵U和k ×n维的物品矩阵V相乘的形式。 其中m是用户数量, n是物品数量,k是隐向量维度, 也就是隐含特征个数,只不过这里的隐含特征变得不可解释了, 即我们不知道具体含义了,要模型自己去学。

最常用的方法是特征值分解(EVD)或者奇异值分解(SVD),EVD要求分解的矩阵是方阵,显然用户-物品矩阵不满足这个要求

SVD矩阵分解算法

这个算法的思路就是深度学习的思路

  1. 首先先初始化这两个矩阵
  2. 把用户评分矩阵里面已经评过分的那些样本当做训练集的label,把对应的用户和物品的隐向量当做features,这样就会得到(features, label)相当于训练集
  3. 通过两个隐向量乘积得到预测值pred
  4. 根据labelpred计算损失
  5. 然后反向传播,通过梯度下降的方式,更新两个隐向量的值
  6. 未评过分的那些样本当做测试集,通过两个隐向量就可以得到测试集的label
  7. 这样就填充完了矩阵, 下一步就可以进行推荐了

有个问题就是当参数很多的时候, 就是两个矩阵很大的时候,往往容易陷入过拟合的困境, 这时候,就需要在目标函数上面加上正则化的损失, 就变成了RSVD

读取数据和对测试数据和训练数据的划分的步骤和之前没有差别具体的步骤可以看之前的协同过滤算法

建立SVD模型

这里按照矩阵分解法初始化隐特征矩阵,转化为一个最优化问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def __init__(self, rating_data, F=5, alpha=0.1, lmbda=0.1, max_iter=100):
self.F = F # 这个表示隐向量的维度
self.P = dict() # 用户矩阵P 大小是[users_num, F]
self.Q = dict() # 物品矩阵Q 大小是[item_nums, F]
self.bu = dict() # 用户偏差系数
self.bi = dict() # 物品偏差系数
self.mu = 0.0 # 全局偏差系数
self.alpha = alpha # 学习率
self.lmbda = lmbda # 正则项系数
self.max_iter = max_iter # 最大迭代次数
self.rating_data = rating_data # 评分矩阵

# 初始化矩阵P和Q, 方法很多, 一般用随机数填充, 但随机数大小有讲究, 根据经验, 随机数需要和1/sqrt(F)成正比
cnt = 0 # 统计总的打分数, 初始化mu用
for user, items in self.rating_data.items():
self.P[user] = [random.random() / math.sqrt(self.F) for x in range(0, F)]
self.bu[user] = 0
cnt += len(items)
for item, rating in items.items():
if item not in self.Q:
self.Q[item] = [random.random() / math.sqrt(self.F) for x in range(0, F)]
self.bi[item] = 0
self.mu /= cnt

训练的过程:采用的是梯度下降法进行更新参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 有了矩阵之后, 就可以进行训练, 这里使用随机梯度下降的方式训练参数P和Q
def train(self):
for step in range(self.max_iter):
for user, items in self.rating_data.items():
for item, rui in items.items():
rhat_ui = self.predict(user, item) # 得到预测评分
# 计算误差
e_ui = rui - rhat_ui

self.bu[user] += self.alpha * (e_ui - self.lmbda * self.bu[user])
self.bi[item] += self.alpha * (e_ui - self.lmbda * self.bi[item])
# 随机梯度下降更新梯度
for k in range(0, self.F):
self.P[user][k] += self.alpha * (e_ui*self.Q[item][k] - self.lmbda * self.P[user][k])
self.Q[item][k] += self.alpha * (e_ui*self.P[user][k] - self.lmbda * self.Q[item][k])

self.alpha *= 0.1 # 每次迭代步长要逐步缩小

根据此产生推荐的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 这里产生推荐列表, 遍历物品列表, 如果用户看了, 那么就跳过, 否则, 预测用户对该电影的打分, 然后记录, 最后排名
movie_list = []
for user, items in trainSet.items():
for item in items.keys():
if item not in movie_list:
movie_list.append(item)
def recommend(aim_user, n=10):
rank = {}
watched_movies = trainSet[aim_user] # 目标用户看过的电影

for movie in movie_list:
if movie in watched_movies:
continue
# 如果当前用户没看过, 就预测打分, 并保存到rank
rank[movie] = basicsvd.predict(aim_user, movie)

return sorted(rank.items(), key=lambda x: x[1], reverse=True)[:n]

最后进行模型结果的评估:准确率、召回率、覆盖率

RSVD矩阵分解算法

在目标函数中加入正则化参数(加入惩罚项), 对于目标函数来说, \(Q\) 矩阵和 \(V\) 矩阵中的所有值都是变量,这些变量在不知道哪个变量会带来过拟合的情况下,对所有变量都进行惩罚:\[\begin{aligned}\mathrm{SSE} & =\frac{1}{2} \sum_{\mathrm{u}, \mathrm{i}}\mathrm{e}_{\mathrm{ui}}^2+\frac{1}{2} \lambda\sum_{\mathrm{u}}\left|\mathrm{p}_{\mathrm{u}}\right|^2+\frac{1}{2}\lambda \sum_{\mathrm{i}}\left|\mathrm{q}_{\mathrm{i}}\right|^2 \\& =\frac{1}{2} \sum_{\mathrm{u}, \mathrm{i}}\mathrm{e}_{\mathrm{ui}}^2+\frac{1}{2} \lambda \sum_{\mathrm{u}}\sum_{\mathrm{k}=0}^{\mathrm{K}} \mathrm{p}_{\mathrm{u},\mathrm{k}}^2+\frac{1}{2} \lambda \sum_{\mathrm{i}}\sum_{\mathrm{k}=0}^{\mathrm{K}} \mathrm{q}_{\mathrm{k}, \mathrm{i}}^2\end{aligned}\]

这时候目标函数对参数的导数就发生了变化, 前面的那块没变,无非就是加入了后面的梯度。所以此时对 \(\mathrm{p}_{\mathrm{u}, \mathrm{k}}\) 求导,得到: \[\begin{aligned}& \frac{\partial}{\partial p_{u, k}}\mathrm{SSE}=-\mathrm{e}_{\mathrm{ui}} \mathrm{q}_{\mathrm{k},\mathrm{i}}+\lambda \mathrm{p}_{\mathrm{u}, \mathrm{k}} \\& \frac{\partial}{\partial \mathrm{q}_{\mathrm{i}, \mathrm{k}}}\mathrm{SSE}=-\mathrm{e}_{\mathrm{u}, \mathrm{i}}\mathrm{p}_{\mathrm{u}, \mathrm{k}}+\lambda \mathrm{q}_{\mathrm{i},\mathrm{k}}\end{aligned}\]

这样, 正则化之后, 梯度的更新公式就变成了: \[\begin{aligned}p_{\mathrm{u}, \mathrm{k}} & =\mathrm{p}_{\mathrm{u},\mathrm{k}}+\eta\left(\mathrm{e}_{\mathrm{ui}} \mathrm{q}_{\mathrm{k},\mathrm{i}}-\lambda \mathrm{p}_{\mathrm{u}, \mathrm{k}}\right) \\\mathrm{q}_{\mathrm{k}, \mathrm{i}} & =\mathrm{q}_{\mathrm{k},\mathrm{i}}+\eta\left(\mathrm{e}_{\mathrm{ui}} \mathrm{p}_{\mathrm{u},\mathrm{k}}-\lambda \mathrm{q}_{\mathrm{i}, \mathrm{k}}\right)\end{aligned}\]

SVD++矩阵分解

SVD++, 它将用户历史评分的物品加入到了LFM模型里

之前的矩阵分解是只分解的当前的共现矩阵, 比如某个用户\(u\)对于某个物品\(i\)的评分, 就单纯的分解成用户\(u\)的隐向量与物品\(i\)的隐向量乘积再加上偏置项,这时候注意并没有考虑该用户评分的历史物品

这个式子是预测用户 \(\mathrm{u}\)对于物品 \(\mathrm{i}\) 的打分, \(\mathrm{N}(\mathrm{u})\) 表示用户 \(\mathrm{u}\) 打过分的历史物品, \(\mathrm{w}_{\mathrm{ij}}\) 表示物品 \(\mathrm{ij}\) 的相似度,当然这里的这个相似度不再是ItemCF那样, 通过向量计算的, 而是想向LFM那样,让模型自己学出这个参数来, 那么相应的就可以通过优化的思想: \[\mathrm{SSE}=\sum_{(\mathrm{u}, \mathrm{i}) \in \text { Train}}\left(\mathrm{r}_{\mathrm{ui}}-\sum_{\mathrm{j} \in\mathrm{N}(\mathrm{u})} \mathrm{w}_{\mathrm{ij}}\mathrm{r}_{\mathrm{uj}}\right)^2+\lambda \mathrm{w}_{\mathrm{ij}}^2\]

但是呢, 这么模型有个问题, 就是 \(\mathrm{w}\) 比较稠密, 存储需要很大的空间,因为如果有 \(\mathrm{n}\) 个物品,那么模型的参数就是 \(\mathrm{n}^2\),参数一多, 就容易造成过拟合。所以Koren提出应该对 \(\mathrm{w}\) 矩阵进行分解, 将参数降到了\(2 * \mathrm{n} * \mathrm{~F}\) :\[\hat{\mathrm{r}}_{\mathrm{ui}}=\frac{1}{\sqrt{|\mathrm{N}(\mathrm{u})|}}\sum_{\mathrm{j} \in \mathrm{N}(\mathrm{u})}\mathrm{x}_{\mathrm{i}}^{\mathrm{T}}\mathrm{y}_{\mathrm{j}}=\frac{1}{\sqrt{|\mathrm{N}(\mathrm{u})|}}\mathrm{x}_{\mathrm{i}}^{\mathrm{T}} \sum_{\mathrm{j} \in\mathrm{N}(\mathrm{u})} \mathrm{y}_{\mathrm{j}}\]

相当于用 \(\mathrm{x}_{\mathrm{i}}^{\mathrm{T}}\mathrm{y}_{\mathrm{j}}\) 代替了 \(\mathrm{w}_{\mathrm{ij}}\), 这里的 \(\mathrm{x}_{\mathrm{i}},\mathrm{y}_{\mathrm{j}}\) 是两个 \(\mathrm{F}\) 维的向量。有没有发现在这里,就出现了点 \(\mathrm{FM}\)的改进身影了。这里其实就是又对物品 \(\mathrm{i}\) 和某个用户 \(\mathrm{u}\)买过的历史物品又学习一波隐向量, 这次是 \(\mathrm{F}\) 维, 为了衡量出物品 \(\mathrm{i}\) 和历史物品 \(\mathrm{j}\) 之间的相似性来。这时候,参数的数量降了下来,并同时也考虑进来了用户的历史物品记录。所以这个和之前的LFM相加就得到了:\[\hat{r}_{u i}=\mu+b_u+b_i+p_u^T \cdot q_i+\frac{1}{\sqrt{|N(u)|}} x_i^T\sum_{j \in N(u)} y_j\]

FM+FFM模型

基本的框架

因子分解机(Factorization Machine, FM)和域感知因子分解机(Field-awareFactorization Machine, FFM)

FM+FFM发展历程

FM算法与使用

在逻辑回归里面, 如果想得到组合特征,往往需要人工在特征工程的时候手动的组合特征, 然后再进行篮选,但这个比较低效, 第一个是这个会有经验的成分在里面,第二个是可能会比较玄学, 不太好找到有用的组合特征。于是乎,采用POLY2模型进行特征的“暴力”组合就成了可行的选择。POLY2是二阶多项式模型,数学形式如下: \[y=w_0+\sum_{i=1}^n w_i x_i+\sum_{i=1}^{n-1} \sum_{i+1}^n w_{i j} x_i x_j\]

看到这个基本上不用怎么解释就明白了,这个模型对所有的特征进行了两两的交叉,然后又算得了一个权重,这个其实和逻辑回归依然是超级像的,如果我们在逻辑回归中,做特征工程的时候,也可以自己做出这样的一些特征来

POLY2缺点:任意两个参数相互独立,这时候如果数据非常稀疏, 再要训练这么多参数, 无疑是非常困难的,最终模型也不会很好。POLY2模型虽然是引入了特征的二阶交叉组合,但是由于其模型参数, 稀疏场景受限的问题使得FM登场了

FM算法思路:

对于稀疏的评分矩阵, 我们有办法分解成两个向量相乘的形式,那么为何不把这种思想用到解决POLY2的缺陷上呢?无非就是评分矩阵换成POLY2后面的W矩阵(所有二次项系数 \(w_{ij}\)组成的)就是把W矩阵进行分解成两个矩阵相乘的方式

对于二次项参数 \(\mathrm{w}_{\mathrm{ij}}\) 组成的对称阵\(\mathrm{W}\) (为了方面说明 \(\mathrm{FM}\) 的由来,对角元素设置为正实数),我们就可以分解成 \(\mathrm{V}^{\mathrm{T}} \mathrm{V}\)的形式, \(\mathrm{V}\)的第 \(j\) 列 \(v_j\) 表示的是第 \(j\) 维特征 \(x_j\) 的隐向量。换句话说,特征分量 \(x_i\) 和 \(x_j\) 的交叉系数就等于 \(x_i\) 和 \(x_j\) 对应的隐向量的内积,即每个参数 \(\mathrm{w}_{\mathrm{ij}}=<\mathrm{v}_{\mathrm{i}},\mathrm{v}_{\mathrm{j}}>\), 这就是 \(F M\) 模型的核心思想: \[\mathrm{W}^{\star}=\left[\begin{array}{cccc}\omega_{11} & \omega_{12} & \ldots & \omega_{1 \mathrm{n}}\\\omega_{21} & \omega_{22} & \ldots & \omega_{2 \mathrm{n}}\\\ldots & \ldots & \ldots & \ldots \\\omega_{\mathrm{n} 1} & \omega_{\mathrm{n} 2} & \ldots &\omega_{\mathrm{nn}}\end{array}\right]=\mathrm{V}^{\mathrm{T}}\mathrm{V}=\left[\begin{array}{c}\mathrm{V}_1 \\\mathrm{~V}_2 \\\ldots \\\mathrm{V}_{\mathrm{n}}\end{array}\right] \times\left[\mathrm{V}_1, \mathrm{~V}_2, \ldots,\mathrm{V}_{\mathrm{n}}\right]=\left[\begin{array}{cccc}\mathrm{v}_{11} & \mathrm{v}_{12} & \ldots & \mathrm{v}_{1\mathrm{k}} \\\mathrm{v}_{21} & \mathrm{v}_{22} & \ldots & \mathrm{v}_{2\mathrm{k}} \\\ldots & \ldots & \ldots & \ldots \\\mathrm{v}_{\mathrm{n} 1} & \mathrm{v}_{\mathrm{n} 2} & \ldots& \mathrm{v}_{\mathrm{nk}}\end{array}\right] \times\left[\begin{array}{c}\mathrm{v}_{11} \\\mathrm{v}_{12} \\\ldots \\\mathrm{v}_{1 \mathrm{k}}\end{array}\right.\]

这时候, 为了求 \(w_{i j}\),我们需要求出特征分量 \(x_i\) 的辅助向量\(v_i=\left(v_{i 1}, v_{i 2}, \ldots v_{ik}\right), v_j=\left(v_{j 1}, v_{j 2}, \ldots v_{j k}\right)\)所以, 有了这样的一个铺垫, 就可以写出FM的模型方程了,就是POLY2 的基础上,把 \(\mathrm{w}_{\mathrm{ij}}\)写成了两个隐向量相乘的方式。 \[\hat{y}(X)=\omega_0+\sum_{i=1}^n \omega_i x_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^n<v_i, v_j>x_i x_j\]FM的公式是一个通用的拟合方程,可以采用不同的损失函数用于解决regression、classification等问题,比如可以采用MSE(MeanSquare Error)loss function来求解回归问题,也可以采用Hinge/Cross-Entropyloss来求解分类问题。

安装相关的包pip install git+https://github.com/coreylynch/pyFM

1
2
3
4
5
6
# 导入包
from pyfm import pylibfm
from sklearn.feature_extraction import DictVectorizer
from sklearn.preprocessing import OneHotEncoder
import numpy as np
import pandas as pd

使用这个类最简单的方式就是把数据存成字典的形式,然后用DictVectorizer进行one-hot

1
2
3
4
5
6
7
8
9
train = [
{'user': '1', 'item': '5', 'age': 19},
{'user': '2', 'item': '43', 'age': 33},
{'user': '3', 'item': '20', 'age': 55},
{'user': '4', 'item': '10', 'age': 20}
]
v = DictVectorizer()
X = v.fit_transform(train) # 本身被压缩了
X.toarray()

one-hot形式

1
2
3
y = np.repeat(1, X.shape[0])
fm = pylibfm.FM()
fm.fit(X, y)

进行测试

1
2
3
# 测试集
test = v.transform({'user': "1", 'item': "10", 'age': 24})
fm.predict(test)

给出另一个例子说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#加载数据集
def loadData():
rating_data={1: {'A': 5, 'B': 3, 'C': 4, 'D': 4},
2: {'A': 3, 'B': 1, 'C': 2, 'D': 3, 'E': 3},
3: {'A': 4, 'B': 3, 'C': 4, 'D': 3, 'E': 5},
4: {'A': 3, 'B': 3, 'C': 1, 'D': 5, 'E': 4},
5: {'A': 1, 'B': 5, 'C': 5, 'D': 2, 'E': 1}
}
return rating_data

rating_data = loadData()
df = pd.DataFrame(rating_data).T
df = df.stack().reset_index()
df.columns = ['user', 'item', 'rating']
df['user'] = df['user'].astype('str')

item_map = {item: str(idx) for idx, item in enumerate(set(df['item']))}
df['item'] = df['item'].map(item_map)
train_data = df[['user', 'item']]
y = df['rating']
one = OneHotEncoder()
x = one.fit_transform(train_data)

# 建立模型
fm = pylibfm.FM(num_factors=10, num_iter=100, verbose=True, task='regression', initial_learning_rate=0.001, learning_rate_schedule='optimal')
fm.fit(x, y)

# 测试集
test = {'user': '1', 'item': '4'}
x_test = one.transform(pd.DataFrame(test, index=[0]))

pred_rating = fm.predict(x_test)
print('FM的预测评分:{}'.format(pred_rating[0]))

FM算法的回归与分类任务

回归任务

数据集的下载地址: http://www.grouplens.org/system/files/ml-100k.zip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 导入包
import numpy as np
from sklearn.feature_extraction import DictVectorizer
from pyfm import pylibfm

# 导入数据
def loadData(filename, path='ml-100k/'):
data = []
y = []
users = set()
items = set()
with open(path+filename) as f:
for line in f:
(user, movieid, rating, ts) = line.split('\t')
data.append({'user_id': str(user), 'movie_id': str(movieid)})
y.append(float(rating))
users.add(user)
items.add(movieid)

return (data, np.array(y), users, items)

数据类型:

数据类型展示

1
2
3
4
# 下面需要转成one-hot
v = DictVectorizer()
X_train = v.fit_transform(train_data)
X_test = v.transform(test_data)
1
2
3
4
5
# 建立FM模型 
fm = pylibfm.FM(num_factors=10, num_iter=100, verbose=True, task='regression', initial_learning_rate=0.001, learning_rate_schedule='optimal')

# 模型训练
fm.fit(X_train, y_train)

FM的具体参数函数如下: 这里面重点需要设置的已标出(详细的可以参考源码)* num_factors: 隐向量的维度, 也就是k *num_iter: 迭代次数, 由于使用的SGD, 随机梯度下降,要指明迭代多少个epoch * k0, k1: k0表示是否用偏置(看FM的公式),k1表示是否要第二项, 就是单个特征的, 这俩默认True * init_stdev:初始化隐向量时候的方差, 默认0.01 * validation_size:验证集的比例, 默认0.01 * learning_rate_schedule: 学习率衰减方式,有constant, optimal, 和invscaling三种方式, 具体公式看源码 *initial_learning_rate: 初始学习率, 默认0.01 *power_t, t0: 逆缩放学习率的指数,最优学习率分母常数,这两个和上面学习率衰减方式的计算有关 * task:分类或者回归任务, 要指明 * verbose: 是否打印当前的迭代次数, 训练误差 *shuffle_training: 是否在学习之前打乱训练集 * seed: 随机种子

1
2
3
4
5
# 评估
preds = fm.predict(X_test)

from sklearn.metrics import mean_squared_error
print('FM MSE: %.4f' % mean_squared_error(y_test, preds))

分类任务

创建一个随机的分类数据集并对数据集进行测试集和验证集的划分

1
2
3
4
5
6
7
from sklearn.datasets import make_classification   # 创建一个随机的分类数据集
from sklearn.model_selection import train_test_split
from sklearn.metrics import log_loss

X, y = make_classification(n_samples=1000, n_features=100, n_clusters_per_class=1) # 1000个训练样本, 100维的数据
data = [{v: k for k, v in dict(zip(i, range(len(i)))).items()} for i in X]
x_train, x_test, y_train, y_test = train_test_split(data, y, test_size=0.1, random_state=42)

对数据集进行one-hot的处理

1
2
3
v = DictVectorizer()
x_train = v.fit_transform(x_train)
x_test = v.transform(x_test)
1
2
3
4
5
6
7
8
# 建立模型
fm = pylibfm.FM(num_factors=50, num_iter=10, verbose=True, task='classification', initial_learning_rate=0.0001, learning_rate_schedule='optimal')

fm.fit(x_train, y_train)
y_pre = fm.predict(x_test)

# 评估模型
print('validation log loss: %.4f' % log_loss(y_test, y_pre))

FFM算法介绍与使用

FFM是基于FM进行的修改,FFM模型引入了特征域感知(filed-aware),我们先回顾一下FM模型公式: \[\hat{y}(X)=\omega_0+\sum_{i=1}^n \omega_i x_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^n<v_i, v_j>x_i x_j\] FFM就是一个特征对应多个隐向量。这样在与不同域(类)里面特征交叉的时候,用相应的隐向量去交叉计算权重,这样做的好处是学习隐向量的时候只需要考虑相应的域的数据,与不同类的特征进行关联采用不同的隐向量,这和不同类特征的内在差异也比较相符。 这其实就是FFM在FM的基础上做的改进,引入了域的概念,对于每个特征,针对不同的交叉域要学习不同的隐向量特征

引入不同的域的概念的分类

细品的话, 不同单词不同身份的时候,会有不同的embedding对待,其实这里的FFM域embedding,如果经过上面的铺垫感觉FFM差不多了,那么下面就是模型的方程了:

\[\hat{y}(X)=\omega_0+\sum_{i=1}^n \omega_i x_i+\sum_{i=1}^{n-1}\sum_{j=i+1}^n<v_{i, f_j}, v_{j, f_i}>x_i x_j\] \(<v_{i, f_j}, v_{j,f_i}>\)注意这里的已经是加上域的内容之后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class FFM_Node(object):
'''
通常x是高维稀疏向量,所以用链表来表示一个x,链表上的每个节点是个3元组(j,f,v)
'''
__slots__ = ['j', 'f', 'v'] # 按照元组不是字典的方式存储类的成员属性

def __init__(self, j, f, v):
"""
j: Feature index (0-n-1)
f: field index(0-m-1)
v: value
"""
self.j = j
self.f = f
self.v = v

class FFM(object):
def __init__(self, m, n, k, eta, lambd):
"""
m: Number of fields
n: Number of features
k: Number of latent factors
eta: learning rate
lambd: regularization coefficient
"""
self.m = m
self.n = n
self.k = k

#超参数
self.eta = eta
self.lambd = lambd

# 初始化三维权重矩阵w~U(0, 1/sqrt(k))
self.w = np.random.rand(n, m, k) / math.sqrt(k)

# 初始化累积梯度平方和, AdaGrad时要用到
self.G = np.ones(shape=(n, m, k), dtype=np.float64)
self.log = Logistic()

# 这个是计算第三项
def phi(self, node_list):
"""
特征组合式的线性加权求和
param node_list: 用链表存储x中的非0值
"""
z = 0.0
for a in range(len(node_list)):
node1 = node_list[a]
j1 = node1.j
f1 = node1.f
v1 = node1.v
for b in range(a+1, len(node_list)):
node2 = node_list[b]
j2 = node2.j
f2 = node2.f
v2 = node2.v
w1 = self.w[j1, f2]
w2 = self.w[j2, f1]
z += np.dot(w1, w2) * v1 * v2

return z

def predict(self, node_list):
"""
输入x, 预测y的值
"""
z = self.phi(node_list)
y = self.log.decide_by_tanh(z)
return y

# 随机梯度下降
def sgd(self, node_list, y):
"""
根据一个样本更新模型参数:
node_list: 链表存储x中的非0值
y: 正样本1, 负样本-1
"""
kappa = -y / (1+math.exp(y*self.phi(node_list))) # 论文里面的那个导数
for a in range(len(node_list)):
node1 = node_list[a]
j1 = node1.j
f1 = node1.f
v1 = node1.v
for b in range(a+1, len(node_list)):
node2 = node_list[b]
j2 = node2.j
f2 = node2.f
v2 = node2.v
c = kappa * v1 * v2 # 这是求导数

# self.w[j1,f2]和self.w[j2,f1]是向量,导致g_j1_f2和g_j2_f1也是向量
g_j1_f2 = self.lambd * self.w[j1, f2] + c * self.w[j2, f1]
g_j2_f1 = self.lambd * self.w[j2, f1] + c * self.w[j1, f2]

# 计算各个维度上的梯度累积平方和
self.G[j1, f2] += g_j1_f2 ** 2
self.G[j2, f1] += g_j2_f1 ** 2

# Adagrad 算法
self.w[j1, f2] -= self.eta / np.sqrt(self.G[j1, f2]) * g_j1_f2 # sqrt(G)作为分母,所以G必须是大于0的正数
self.w[j2, f1] -= self.eta / np.sqrt(
self.G[j2, f1]) * g_j2_f1 # math.sqrt()只能接收一个数字作为参数,而numpy.sqrt()可以接收一个array作为参数,表示对array中的每个元素分别开方

# 训练
def train(self, sample_generator, max_echo, max_r2):
"""
根据一堆样本训练模型
sample_generator: 样本生成器,每次yield (node_list, y),node_list中存储的是x的非0值。通常x要事先做好归一化,即模长为1,这样精度会略微高一点
max_echo: 最大迭代次数
max_r2: 拟合系数r2达到阈值时即可终止学习
"""
for itr in range(max_echo):
print("echo: ", itr)
y_sum = 0.0
y_sqare_sum = 0.0
err_square_sum = 0.0 # 误差平方和
population = 0 # 样本总数
for node_list, y in sample_generator:
y = 0.0 if y == -1 else y # 真实的y取值为{-1,1},而预测的y位于(0,1),计算拟合效果时需要进行统一
self.sgd(node_list, y)
y_hat = self.predict(node_list)
y_sum += y
y_sqare_sum += y ** 2
err_square_sum += (y-y_hat) ** 2
population += 1

var_y = y_sqare_sum - y_sum * y_sum / population # y的方差
r2 = 1 - err_square_sum / var_y
print("r2: ", r2)
if r2 > max_r2:
print("r2 have reach", r2)
break

# 模型保存
def save_model(self, outfile):
'''
序列化模型
:param outfile:
:return:
'''
np.save(outfile, self.w)

def load_model(self, infile):
'''
加载模型
:param infile:
:return:
'''
self.w = np.load(infile)

调用的过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 设置参数   5个特征, 2个域, 2维的k
n = 5
m = 2
k = 2

# 路径
train_file = "dataset/train.txt"
valid_file = "dataset/test.txt"
model_file = "ffm.npy"

# 超参数
eta = 0.01
lambd = 1e-2
max_echo = 30
max_r2 = 0.9

# 训练模型,并保存模型参数
sample_generator = Sample(train_file)
ffm = FFM(m, n, k, eta, lambd)
ffm.train(sample_generator, max_echo, max_r2)
ffm.save_model(model_file)

逻辑回归模型与GBDT+LR模型

总体结构图

协同过滤和矩阵分解利用用户的物品“相似度”进行推荐,逻辑回归模型将问题看成了一个分类问题,通过预测正样本的概率对物品进行排序

这里的正样本可以是用户“点击”了某个商品或者“观看”了某个视频,均是推荐系统希望用户产生“正反馈”行为,因此逻辑回归模型将推荐问题转成成了一个点击率预估问题

逻辑回归LR算法

逻辑回归是在线性回归的基础上加了一个 Sigmoid函数(非线形)映射,使得逻辑回归称为了一个优秀的分类算法,学习逻辑回归模型,首先要记住一句话:逻辑回归假设数据服从伯努利分布,通过极大化似然函数的方法,运用梯度下降来求解参数,来达到将数据二分类的目的。

逻辑回归模型已经将推荐问题转换成了一个点击率预测的问题,而点击率预测就是一个典型的二分类, 正好适合逻辑回归进行处理,那么逻辑回归是如何做推荐的呢?

  1. 将用户年龄、性别、物品属性、物品描述、当前时间、当前地点等特征转成数值型向量

  2. 确定逻辑回归的优化目标,比如把点击率预测转换成二分类问题,这样就可以得到分类问题常用的损失作为目标, 训练模型

  3. 在预测的时候, 将特征向量输入模型产生预测,得到用户“点击”物品的概率

  4. 利用点击概率对候选物品排序, 得到推荐列表

每个特征的权重参数\(w\),我们一般是使用梯度下降的方式, 首先会先随机初始化一批\(w\),然后将特征向量(也就是我们上面数值化出来的特征)输入到模型,就会通过计算会得到模型的预测概率, 然后通过对目标函数求导得到每个\(w\)的梯度, 然后进行更新\(w\)

优点:

  1. LR模型形式简单,可解释性好,从特征的权重可以看到不同的特征对最后结果的影响。

  2. 训练时便于并行化,在预测时只需要对特征进行线性加权,所以性能比较好,往往适合处理海量id类特征,用id类特征有一个很重要的好处,就是防止信息损失(相对于范化的CTR 特征),对于头部资源会有更细致的描述

  3. 资源占用小,尤其是内存。在实际的工程应用中只需要存储权重比较大的特征及特征对应的权重

  4. 方便输出结果调整。逻辑回归可以很方便的得到最后的分类结果,因为输出的是每个样本的概率分数,我们可以很容易的对这些概率分数进行cutoff,也就是划分阈值(大于某个阈值的是一类,小于某个阈值的是一类)

  5. 工程化需要, 在深度学习技术之前, 逻辑回归凭借易于并行化,模型简单,训练开销小等特点,占领工程领域的主流,因为即使工程团队发现了复杂模型会提升效果,但一般如果没有把握击败逻辑回归的话仍然不敢尝试或者升级。

当然, 逻辑回归模型也有一定的局限性

  1. 表达能力不强, 无法进行特征交叉,特征筛选等一系列“高级“操作(这些工作都得人工来干,这样就需要一定的经验, 否则会走一些弯路), 因此可能造成信息的损失
  2. 准确率并不是很高。因为这毕竟是一个线性模型加了个sigmoid,形式非常的简单(非常类似线性模型),很难去拟合数据的真实分布
  3. 处理非线性数据较麻烦。逻辑回归在不引入其他方法的情况下,只能处理线性可分的数据,如果想处理非线性,首先对连续特征的处理需要先进行离散化(离散化的目的是为了引入非线性),如上文所说,人工分桶的方式会引入多种问题。
  4. LR需要进行人工特征组合,这就需要开发者有非常丰富的领域经验,才能不走弯路。这样的模型迁移起来比较困难,换一个领域又需要重新进行大量的特征工程。

GBDT原理

GBDT全称梯度提升决策树,GBDT(Gradient BoostingDecisionTree,梯度提升决策树)是一种机器学习算法,它通过优化损失函数的梯度来构建一组树模型的集合,并希望这些树模型能够协同工作以获得更准确的结果。

GBDT的工作原理:

  1. 初始化模型:使用一个简单的树模型作为起点。
  2. 训练模型:
    • 评估当前模型的损失函数。
    • 根据当前模型的预测值来计算损失函数的梯度。
    • 在当前模型的基础上,寻找能够最小化梯度下降的树模型。
  3. 组合模型:将新树模型的预测结果与原模型的预测结果结合,作为下一轮迭代的初始模型。
  4. 重复迭代:重复步骤2和3,直到达到预设的迭代次数或模型性能不再提升

GBDT的优点:

GBDT的缺点:

GBDT+LR算法原理

利用GBDT自动进行特征筛选和组合,进而生成新的离散特征向量,再把该特征向量当做LR模型的输入, 来产生最后的预测结果,这就是著名的GBDT+LR模型了。GBDT+LR使用最广泛的场景是CTR点击率预估,即预测当给用户推送的广告会不会被用户点击

GBDT+LR结构图

训练时,GBDT建树的过程相当于自动进行的特征组合和离散化,然后从根结点到叶子节点的这条路径就可以看成是不同特征进行的特征组合,用叶子节点可以唯一的表示这条路径,并作为一个离散特征传入LR 进行二次训练

比如上图中,有两棵树,x为一条输入样本,遍历两棵树后,x样本分别落到两颗树的叶子节点上,每个叶子节点对应LR一维特征,那么通过遍历树,就得到了该样本对应的所有LR特征。构造的新特征向量是取值0/1的。比如左树有三个叶子节点,右树有两个叶子节点,最终的特征即为五维的向量。对于输入x,假设他落在左树第二个节点,编码[0,1,0],落在右树第二个节点则编码[0,1],所以整体的编码为[0,1,0,0,1],这类编码作为特征,输入到线性分类模型(LRor FM)中进行分类。

预测时,会先走 GBDT的每棵树,得到某个叶子节点对应的一个离散特征(即一组特征组合),然后把该特征以one-hot 形式传入 LR 进行线性加权预测。

GBDT+LR编程实践

这个比赛的任务就是:开发预测广告点击率(CTR)的模型。给定一个用户和他正在访问的页面,预测他点击给定广告的概率是多少?比赛的地址链接:https://www.kaggle.com/c/criteo-display-ad-challenge/overview

导入相关的依赖包和数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import pandas as pd

from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
import lightgbm as lgb
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder, LabelEncoder
from sklearn.metrics import log_loss

import gc
from scipy import sparse

import warnings
warnings.filterwarnings('ignore')


# 数据读取
path = 'data/'
df_train = pd.read_csv(path + 'train.csv')
df_test = pd.read_csv(path + 'test.csv')

数据预处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 数据读取
path = 'data/'
df_train = pd.read_csv(path + 'train.csv')
df_test = pd.read_csv(path + 'test.csv')

# 简单的数据预处理
# 去掉id列, 把测试集和训练集合并, 填充缺失值
df_train.drop(['Id'], axis=1, inplace=True)
df_test.drop(['Id'], axis=1, inplace=True)

df_test['Label'] = -1

data = pd.concat([df_train, df_test])
data.fillna(-1, inplace=True)

continuous_fea = ['I'+str(i+1) for i in range(13)]
category_fea = ['C'+str(i+1) for i in range(26)]

LR模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def lr_model(data, category_fea, continuous_fea):
# 连续特征归一化
scaler = MinMaxScaler()
for col in continuous_fea:
data[col] = scaler.fit_transform(data[col].values.reshape(-1, 1))

# 离散特征one-hot编码
for col in category_fea:
onehot_feats = pd.get_dummies(data[col], prefix=col)
data.drop([col], axis=1, inplace=True)
data = pd.concat([data, onehot_feats], axis=1)

# 把训练集和测试集分开
train = data[data['Label'] != -1]
target = train.pop('Label')
test = data[data['Label'] == -1]
test.drop(['Label'], axis=1, inplace=True)

# 划分数据集
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size=0.2, random_state=2020)

# 建立模型
lr = LogisticRegression()
lr.fit(x_train, y_train)
tr_logloss = log_loss(y_train, lr.predict_proba(x_train)[:, 1]) # −(ylog(p)+(1−y)log(1−p)) log_loss
val_logloss = log_loss(y_val, lr.predict_proba(x_val)[:, 1])
print('tr_logloss: ', tr_logloss)
print('val_logloss: ', val_logloss)

# 模型预测
y_pred = lr.predict_proba(test)[:, 1] # predict_proba 返回n行k列的矩阵,第i行第j列上的数值是模型预测第i个预测样本为某个标签的概率, 这里的1表示点击的概率
print('predict: ', y_pred[:10]) # 这里看前10个, 预测为点击的概率
1
2
# 训练和预测
lr_model(data.copy(), category_fea, continuous_fea)

LR预测的结果:

逻辑回归预测结果

GBDT建模

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def gbdt_model(data, category_fea, continuous_fea):

# 离散特征one-hot编码
for col in category_fea:
onehot_feats = pd.get_dummies(data[col], prefix=col)
data.drop([col], axis=1, inplace=True)
data = pd.concat([data, onehot_feats], axis=1)

# 训练集和测试集分开
train = data[data['Label'] != -1]
target = train.pop('Label')
test = data[data['Label'] == -1]
test.drop(['Label'], axis=1, inplace=True)

# 划分数据集
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size=0.2, random_state=2020)

# 建模
gbm = lgb.LGBMClassifier(boosting_type='gbdt', # 这里用gbdt
objective='binary',
subsample=0.8,
min_child_weight=0.5,
colsample_bytree=0.7,
num_leaves=100,
max_depth=12,
learning_rate=0.01,
n_estimators=10000
)
gbm.fit(x_train, y_train,
eval_set=[(x_train, y_train), (x_val, y_val)],
eval_names=['train', 'val'],
eval_metric='binary_logloss',
early_stopping_rounds=100,
)

tr_logloss = log_loss(y_train, gbm.predict_proba(x_train)[:, 1]) # −(ylog(p)+(1−y)log(1−p)) log_loss
val_logloss = log_loss(y_val, gbm.predict_proba(x_val)[:, 1])
print('tr_logloss: ', tr_logloss)
print('val_logloss: ', val_logloss)

# 模型预测
y_pred = gbm.predict_proba(test)[:, 1] # predict_proba 返回n行k列的矩阵,第i行第j列上的数值是模型预测第i个预测样本为某个标签的概率, 这里的1表示点击的概率
print('predict: ', y_pred[:10]) # 这里看前10个, 预测为点击的概率
1
2
# 模型训练和预测
gbdt_model(data.copy(), category_fea, continuous_fea)

预测的结果:

GBDT预测结果展示

LR + GBDT建模

下面就是把上面两个模型进行组合, GBDT负责对各个特征进行交叉和组合,把原始特征向量转换为新的离散型特征向量, 然后在使用逻辑回归模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def gbdt_lr_model(data, category_feature, continuous_feature): # 0.43616
# 离散特征one-hot编码

# 划分数据集
x_train, x_val, y_train, y_val = train_test_split(train, target, test_size = 0.2, random_state = 2020)

#GBDT模型的搭建和训练过程
gbm = lgb.LGBMClassifier(objective='binary',
subsample= 0.8,
min_child_weight= 0.5,
colsample_bytree= 0.7,
num_leaves=100,
max_depth = 12,
learning_rate=0.01,
n_estimators=1000,
)

gbm.fit(x_train, y_train,
eval_set = [(x_train, y_train), (x_val, y_val)],
eval_names = ['train', 'val'],
eval_metric = 'binary_logloss',
early_stopping_rounds = 100,
)

model = gbm.booster_

gbdt_feats_train = model.predict(train, pred_leaf = True)
gbdt_feats_test = model.predict(test, pred_leaf = True)
gbdt_feats_name = ['gbdt_leaf_' + str(i) for i in range(gbdt_feats_train.shape[1])]
df_train_gbdt_feats = pd.DataFrame(gbdt_feats_train, columns = gbdt_feats_name)
df_test_gbdt_feats = pd.DataFrame(gbdt_feats_test, columns = gbdt_feats_name)

train = pd.concat([train, df_train_gbdt_feats], axis = 1)
test = pd.concat([test, df_test_gbdt_feats], axis = 1)
train_len = train.shape[0]
data = pd.concat([train, test])
del train
del test
gc.collect()

# # 连续特征归一化
scaler = MinMaxScaler()
for col in continuous_feature:
data[col] = scaler.fit_transform(data[col].values.reshape(-1, 1))

for col in gbdt_feats_name:
onehot_feats = pd.get_dummies(data[col], prefix = col)
data.drop([col], axis = 1, inplace = True)
data = pd.concat([data, onehot_feats], axis = 1)

train = data[: train_len]
test = data[train_len:]
del data
gc.collect()

x_train, x_val, y_train, y_val = train_test_split(train, target, test_size = 0.3, random_state = 2018)

#再放进LR模型中
lr = LogisticRegression()
lr.fit(x_train, y_train)
tr_logloss = log_loss(y_train, lr.predict_proba(x_train)[:, 1])
print('tr-logloss: ', tr_logloss)
val_logloss = log_loss(y_val, lr.predict_proba(x_val)[:, 1])
print('val-logloss: ', val_logloss)
y_pred = lr.predict_proba(test)[:, 1]
print(y_pred[:10])

GBDT+LR预测结果展示

深度学习模型(Rank)

对于1层cross和1层deep的DCN网络输入经过embedding和stack处理后维度为d,Cross部分网络参数为2d,Deep为d*d,当MLP的层数增多时,deep部分的参数量也急速增加。DCN网路的绝大部分参数都用于对隐性交叉特征进行建模。Cross部分的表达能力反而受限。

DCN-v2优化了cross网络的建模方式,增加了cross网络部分的表达能力;deep部分保持不变。

低维空间的交叉特征建模使得我们可以利用MoE。MoE由两部分组成:experts专家和gating门(一个关于输入x的函数)。我们可以使用多个专家,每个专家学习不同的交叉特征,最后通过gating将各个专家的学习结果整合起来,作为输出。这样就又能进一步增加对交叉特征的建模能力。

]]>
diff --git a/search.xml b/search.xml index 5b388e0..8e51e67 100644 --- a/search.xml +++ b/search.xml @@ -3683,6 +3683,15 @@ src="https://gitee.com/lihaibineric/picgo/raw/master/pic/image-20240308111249239 alt="image-20240308111249239" /> +
+ + +
+

对于1层cross和1层deep的DCN网络输入经过embedding和stack处理后维度为d,Cross部分网络参数为2d,Deep为d*d,当MLP的层数增多时,deep部分的参数量也急速增加。DCN网路的绝大部分参数都用于对隐性交叉特征进行建模。Cross部分的表达能力反而受限。

+

DCN-v2优化了cross网络的建模方式,增加了cross网络部分的表达能力;deep部分保持不变。

+

低维空间的交叉特征建模使得我们可以利用MoE。MoE由两部分组成:experts专家和gating门(一个关于输入x的函数)。我们可以使用多个专家,每个专家学习不同的交叉特征,最后通过gating将各个专家的学习结果整合起来,作为输出。这样就又能进一步增加对交叉特征的建模能力。

]]> 深度学习