docs: fun-rec

This commit is contained in:
camera-2018
2023-04-25 19:17:30 +08:00
parent b7362898ff
commit 7b8e8a23f4
39 changed files with 10844 additions and 1 deletions

View File

@@ -0,0 +1,288 @@
# Swing(Graph-based)
## 动机
大规模推荐系统需要实时对用户行为做出海量预测,为了保证这种实时性,大规模的推荐系统通常严重依赖于预先计算好的产品索引。产品索引的功能为:给定种子产品返回排序后的候选相关产品列表。
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片2relations.png" alt="在这里插入图片描述" style="zoom:30%;" />
</div>
相关性产品索引主要包含两部分:替代性产品和互补性产品。例如图中的不同种类的衬衫构成了替代关系,而衬衫和风衣裤子等构成了互补关系。用户通常希望在完成购买行为之前尽可能看更多的衬衫,而用户购买过衬衫之后更希望看到与之搭配的单品而不是其他衬衫了。
## 之前方法局限性
- 基于 Cosine, Jaccard, 皮尔逊相关性等相似度计算的协同过滤算法,在计算邻居关联强度的时候只关注于 Item-based (常用因为item相比于用户变化的慢且新Item特征比较容易获得)Item-based CF 只关注于 Item-User-Item 的路径把所有的User-Item交互都平等得看待从而忽视了 User-Item 交互中的大量噪声,推荐精度存在局限性。
- 对互补性产品的建模不足,可能会导致用户购买过手机之后还继续推荐手机,但用户短时间内不会再继续购买手机,因此产生无效曝光。
## 贡献
提出了高效建立产品索引图的技术。
主要包括:
- Swing 算法利用 user-item 二部图的子结构捕获产品间的替代关系。
- Surprise 算法利用商品分类信息和用户共同购买图上的聚类技术来建模产品之间的组合关系。
## Swing算法
Swing 通过利用 User-Item-User 路径中所包含的信息,考虑 User-Item 二部图中的鲁棒内部子结构计算相似性。
- 什么是内部子结构?
以经典的啤酒尿布故事为例,张三同时购买了啤酒和尿布,这可能是一种巧合。但两个甚至多个顾客都同时购买了啤酒尿布,这就证明啤酒和尿布具有相关关系。这样共同购买啤酒和尿布的用户越多,啤酒和尿布的相关度就会越高。
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片example_of_swing.png" alt="在这里插入图片描述" style="zoom:30%;" />
</div>
图中的红色四边形就是一种Swing子结构这种子结构可以作为给王五推荐尿布的依据。
- 通俗解释:若用户 $u$ 和用户 $v$ 之间除了购买过 $i$ 外,还购买过商品 $j$ ,则认为两件商品是具有某种程度上的相似的。也就是说,商品与商品之间的相似关系,是通过用户关系来传递的。为了衡量物品 $i$ 和 $j$ 的相似性,比较同时购买了物品 $i$ 和 $j$ 的用户 $u$ 和用户 $v$ 如果这两个用户共同购买的物品越少,即这两个用户原始兴趣不相似,但仍同时购买了两个相同的物品 $i$ 和 $j$ 则物品 $i$ 和 $j$ 的相似性越高。
- 计算公式
$$s(i,j)=\sum\limits_{u\in U_i\cap U_j} \sum\limits_{v \in U_i\cap U_j}w_u*w_v* \frac{1}{\alpha+|I_u \cap I_v|}$$
其中$U_i$ 是点击过商品i的用户集合$I_u$ 是用户u点击过的商品集合$\alpha$是平滑系数。
$w_u=\frac{1}{\sqrt{|I_u|}},w_v=\frac{1}{\sqrt{|I_v|}}$ 是用户权重参数,来降低活跃用户的影响。
- 代码实现
- Python 建议自行debug方便理解
```python
from itertools import combinations
import pandas as pd
alpha = 0.5
top_k = 20
def load_data(train_path):
train_data = pd.read_csv(train_path, sep="\t", engine="python", names=["userid", "itemid", "rate"])#提取用户交互记录数据
print(train_data.head(3))
return train_data
def get_uitems_iusers(train):
u_items = dict()
i_users = dict()
for index, row in train.iterrows():#处理用户交互记录
u_items.setdefault(row["userid"], set())
i_users.setdefault(row["itemid"], set())
u_items[row["userid"]].add(row["itemid"])#得到user交互过的所有item
i_users[row["itemid"]].add(row["userid"])#得到item交互过的所有user
print("使用的用户个数为:{}".format(len(u_items)))
print("使用的item个数为{}".format(len(i_users)))
return u_items, i_users
def swing_model(u_items, i_users):
# print([i for i in i_users.values()][:5])
# print([i for i in u_items.values()][:5])
item_pairs = list(combinations(i_users.keys(), 2)) #全排列组合对
print("item pairs length{}".format(len(item_pairs)))
item_sim_dict = dict()
for (i, j) in item_pairs:
user_pairs = list(combinations(i_users[i] & i_users[j], 2)) #item_i和item_j对应的user取交集后全排列 得到user对
result = 0
for (u, v) in user_pairs:
result += 1 / (alpha + list(u_items[u] & u_items[v]).__len__()) #分数公式
if result != 0 :
item_sim_dict.setdefault(i, dict())
item_sim_dict[i][j] = format(result, '.6f')
return item_sim_dict
def save_item_sims(item_sim_dict, top_k, path):
new_item_sim_dict = dict()
try:
writer = open(path, 'w', encoding='utf-8')
for item, sim_items in item_sim_dict.items():
new_item_sim_dict.setdefault(item, dict())
new_item_sim_dict[item] = dict(sorted(sim_items.items(), key = lambda k:k[1], reverse=True)[:top_k])#排序取出 top_k个相似的item
writer.write('item_id:%d\t%s\n' % (item, new_item_sim_dict[item]))
print("SUCCESS: top_{} item saved".format(top_k))
except Exception as e:
print(e.args)
if __name__ == "__main__":
train_data_path = "./ratings_final.txt"
item_sim_save_path = "./item_sim_dict.txt"
top_k = 10 #与item相似的前 k 个item
train = load_data(train_data_path)
u_items, i_users = get_uitems_iusers(train)
item_sim_dict = swing_model(u_items, i_users)
save_item_sims(item_sim_dict, top_k, item_sim_save_path)
```
- Spark仅为核心代码需要补全配置才能跑通
```scala
object Swing {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder()
.appName("test")
.master("local[2]")
.getOrCreate()
val alpha = 1 //分数计算参数
val filter_n_items = 10000 //想要计算的item数量 测试的时候取少点
val top_n_items = 500 //保存item的score排序前500个相似的item
val model = new SwingModel(spark)
.setAlpha(alpha.toDouble)
.setFilter_N_Items(filter_n_items.toInt)
.setTop_N_Items(top_n_items.toInt)
val url = "file:///usr/local/var/scala/common/part-00022-e17c0014.snappy.parquet"
val ratings = DataLoader.getRatingLog(spark, url)
val df = model.fit(ratings).item2item()
df.show(3,false)
// df.write.mode("overwrite").parquet(dest_url)
}
}
```
```scala
/**
* swing
* @param ratings 打分dataset
* @return
*/
def fit(ratings: Dataset[Rating]): SwingModel = {
def interWithAlpha = udf(
(array_1: Seq[GenericRowWithSchema],
array_2: Seq[GenericRowWithSchema]) => {
var score = 0.0
val u_set_1 = array_1.toSet
val u_set_2 = array_2.toSet
val user_set = u_set_1.intersect(u_set_2).toArray //取交集得到两个item共同user
for (i <- user_set.indices; j <- i + 1 until user_set.length) {
val user_1 = user_set(i)
val user_2 = user_set(j)
val item_set_1 = user_1.getAs[Seq[String]]("_2").toSet
val item_set_2 = user_2.getAs[Seq[String]]("_2").toSet
score = score + 1 / (item_set_1
.intersect(item_set_2)
.size
.toDouble + alpha.get)
}
score
}
)
val df = ratings.repartition(defaultParallelism).cache()
val groupUsers = df
.groupBy("user_id")
.agg(collect_set("item_id")) //聚合itme_id
.toDF("user_id", "item_set")
.repartition(defaultParallelism)
println("groupUsers")
groupUsers.show(3, false)//user_id|[item_id_set]: 422|[6117,611,6117]
val groupItems = df
.join(groupUsers, "user_id")
.rdd
.map { x =>
val item_id = x.getAs[String]("item_id")
val user_id = x.getAs[String]("user_id")
val item_set = x.getAs[Seq[String]]("item_set")
(item_id, (user_id, item_set))
}//i_[user(item_set)]
.toDF("item_id", "user")
.groupBy("item_id")
.agg(collect_set("user"), count("item_id"))
.toDF("item_id", "user_set", "count")
.filter("size(user_set) > 1")//过滤掉没有交互的
.sort($"count".desc) //根据count倒排item_id数量
.limit(filter_n_items.get)//item可能百万级别但后面召回的需求量小所以只取前n个item进行计算
.drop("count")
.repartition(defaultParallelism)
.cache()
println("groupItems") //得到与itme_id有交互的user_id
groupItems.show(3, false)//item_id|[[user_id,[item_set]],[user_id,[item_set]]]: 67|[[562,[66, 813, 61, 67]],[563,[67, 833, 62, 64]]]
val itemJoined = groupItems
.join(broadcast(groupItems))//内连接两个item列表
.toDF("item_id_1", "user_set_1", "item_id_2", "user_set_2")
.filter("item_id_1 > item_id_2")//内连接 item两两配对
.withColumn("score", interWithAlpha(col("user_set_1"), col("user_set_2")))//将上面得到的与item相关的user_set输入到函数interWithAlpha计算分数
.select("item_id_1", "item_id_2", "score")
.filter("score > 0")
.repartition(defaultParallelism)
.cache()
println("itemJoined")
itemJoined.show(5)//得到两两item之间的分数结果 item_id_1 item_id_2 score
similarities = Option(itemJoined)
this
}
/**
* 从fit结果对item_id进行聚合并排序每个item后截取n个item并返回。
* @param num 取n个item
* @return
*/
def item2item(): DataFrame = {
case class itemWithScore(item_id: String, score: Double)
val sim = similarities.get.select("item_id_1", "item_id_2", "score")
val topN = sim
.map { x =>
val item_id_1 = x.getAs[String]("item_id_1")
val item_id_2 = x.getAs[String]("item_id_2")
val score = x.getAs[Double]("score")
(item_id_1, (item_id_2, score))
}
.toDF("item_id", "itemWithScore")
.groupBy("item_id")
.agg(collect_set("itemWithScore"))
.toDF("item_id", "item_set")//item_id |[[item_id1:score],[item_id2:score]]
.rdd
.map { x =>
val item_id_1 = x.getAs[String]("item_id")
val item_set = x //对itme_set中score进行排序操作
.getAs[Seq[GenericRowWithSchema]]("item_set")
.map { x =>
val item_id_2 = x.getAs[String]("_1")
val score = x.getAs[Double]("_2")
(item_id_2, score)
}
.sortBy(-_._2)//根据score进行排序
.take(top_n_items.get)//取top_n
.map(x => x._1 + ":" + x._2.toString)
(item_id_1, item_set)
}
.filter(_._2.nonEmpty)
.toDF("id", "sorted_items")
topN
}
}
```
## Surprise算法
首先在行为相关性中引入连续时间衰减因子,然后引入基于交互数据的聚类方法解决数据稀疏的问题,旨在帮助用户找到互补商品。互补相关性主要从三个层面考虑,类别层面,商品层面和聚类层面。
- 类别层面
首先通过商品和类别的映射关系,我们可以得到 user-category 矩阵。随后使用简单的相关性度量可以计算出类别 $i,j$ 的相关性。
$\theta_{i,j}=p(c_{i,j}|c_j)=\frac{N(c_{i,j})}{N(c_j)}$
即,$N(c_{i,j})$为在购买过i之后购买j类的数量$N(c_{j})$为购买j类的数量。
由于类别直接的种类差异,每个类别的相关类数量存在差异,因此采用最大相对落点来作为划分阈值。
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片max_drop.png" alt="在这里插入图片描述" style="zoom:30%;" />
</div>
例如图(a)中T恤的相关类选择前八个图(b)中手机的相关类选择前三个。
- 商品层面
商品层面的相关性挖掘主要有两个关键设计:
- 商品的购买顺序是需要被考虑的,例如在用户购买手机后推荐充电宝是合理的,但在用户购买充电宝后推荐手机是不合理的。
- 两个商品购买的时间间隔也是需要被考虑的,时间间隔越短越能证明两个商品的互补关系。
最终商品层面的互补相关性被定义为:
$s_{1}(i, j)=\frac{\sum_{u \in U_{i} \cap U_{j}} 1 /\left(1+\left|t_{u i}-t_{u j}\right|\right)}{\left\|U_{i}\right\| \times\left\|U_{j}\right\|}$,其中$j$属于$i$的相关类,且$j$ 的购买时间晚于$i$。
- 聚类层面
- 如何聚类?
传统的聚类算法(基于密度和 k-means )在数十亿产品规模下的淘宝场景中不可行,所以作者采用了标签传播算法。
- 在哪里标签传播?
Item-item 图,其中又 Swing 计算的排名靠前 item 为邻居,边的权重就是 Swing 分数。
- 表现如何?
快速而有效15分钟即可对数十亿个项目进行聚类。
最终聚类层面的相关度计算同上面商品层面的计算公式
- 线性组合:
$s(i, j)=\omega * s_{1}(i, j)+(1-\omega) * s_{2}(i, j)$,其中$\omega=0.8$是作者设置的权重超参数。
Surprise算法通过利用类别信息和标签传播技术解决了用户共同购买图上的稀疏性问题。
**参考资料**
- [Large Scale Product Graph Construction for Recommendation in E-commerce](https://arxiv.org/pdf/2010.05525)
- [推荐召回-Swing](https://zhuanlan.zhihu.com/p/383346471)

View File

@@ -0,0 +1,269 @@
# 基于物品的协同过滤
## 基本思想
基于物品的协同过滤ItemCF
+ 预先根据所有用户的历史行为数据,计算物品之间的相似性。
+ 然后,把与用户喜欢的物品相类似的物品推荐给用户。
举例来说,如果用户 1 喜欢物品 A ,而物品 A 和 C 非常相似,则可以将物品 C 推荐给用户1。ItemCF算法并不利用物品的内容属性计算物品之间的相似度 主要通过分析用户的行为记录计算物品之间的相似度, 该算法认为, 物品 A 和物品 C 具有很大的相似度是因为喜欢物品 A 的用户极可能喜欢物品 C。
![图片](http://ryluo.oss-cn-chengdu.aliyuncs.com/JavagdvaYX0HSW4PdssV.png!thumbnail)
## 计算过程
基于物品的协同过滤算法和基于用户的协同过滤算法很像, 所以我们这里直接还是拿上面 Alice 的那个例子来看。
![图片](http://ryluo.oss-cn-chengdu.aliyuncs.com/JavaE306yXB4mGmjIxbn.png!thumbnail)
如果想知道 Alice 对物品5打多少分 基于物品的协同过滤算法会这么做:
+ 首先计算一下物品5和物品1 2 3 4之间的相似性。
+ 在Alice找出与物品 5 最相近的 n 个物品。
+ 根据 Alice 对最相近的 n 个物品的打分去计算对物品 5 的打分情况。
**手动计算:**
1. 手动计算物品之间的相似度
>物品向量: $物品 1(3,4,3,1) ,物品2(1,3,3,5) ,物品3(2,4,1,5) ,物品4(3,3,5,2) ,物品5(3,5,41)$
>
>+ 下面计算物品 5 和物品 1 之间的余弦相似性:
> $$
> \operatorname{sim}(\text { 物品1, 物品5 })=\operatorname{cosine}(\text { 物品1, 物品5 } )=\frac{9+20+12+1}{\operatorname{sqrt}(9+16+9+1)*\operatorname{sqrt}(9+25+16+1)}
> $$
>
>+ 皮尔逊相关系数类似。
>
2. 基于 `sklearn` 计算物品之间的皮尔逊相关系数:
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/JavaJOyFti58um61zPsa.png!thumbnail" alt="图片" style="zoom:80%;" />
3. 根据皮尔逊相关系数, 可以找到与物品5最相似的2个物品是 item1 和 item4 下面基于上面的公式计算最终得分:
$$
P_{Alice, 物品5}=\bar{R}_{物品5}+\frac{\sum_{k=1}^{2}\left(w_{物品5,物品 k}\left(R_{Alice, 物品k}-\bar{R}_{物品k}\right)\right)}{\sum_{k=1}^{2} w_{物品k, 物品5}} \\
=\frac{13}{4}+\frac{0.97*(5-3.2)+0.58*(4-3.4)}{0.97+0.58}=4.6
$$
## ItemCF编程实现
1. 构建物品-用户的评分矩阵
```python
import numpy as np
import pandas as pd
def loadData():
items = {'A': {'Alice': 5.0, 'user1': 3.0, 'user2': 4.0, 'user3': 3.0, 'user4': 1.0},
'B': {'Alice': 3.0, 'user1': 1.0, 'user2': 3.0, 'user3': 3.0, 'user4': 5.0},
'C': {'Alice': 4.0, 'user1': 2.0, 'user2': 4.0, 'user3': 1.0, 'user4': 5.0},
'D': {'Alice': 4.0, 'user1': 3.0, 'user2': 3.0, 'user3': 5.0, 'user4': 2.0},
'E': {'user1': 3.0, 'user2': 5.0, 'user3': 4.0, 'user4': 1.0}
}
return items
```
2. 计算物品间的相似度矩阵
```python
item_data = loadData()
similarity_matrix = pd.DataFrame(
np.identity(len(item_data)),
index=item_data.keys(),
columns=item_data.keys(),
)
# 遍历每条物品-用户评分数据
for i1, users1 in item_data.items():
for i2, users2 in item_data.items():
if i1 == i2:
continue
vec1, vec2 = [], []
for user, rating1 in users1.items():
rating2 = users2.get(user, -1)
if rating2 == -1:
continue
vec1.append(rating1)
vec2.append(rating2)
similarity_matrix[i1][i2] = np.corrcoef(vec1, vec2)[0][1]
print(similarity_matrix)
```
```
A B C D E
A 1.000000 -0.476731 -0.123091 0.532181 0.969458
B -0.476731 1.000000 0.645497 -0.310087 -0.478091
C -0.123091 0.645497 1.000000 -0.720577 -0.427618
D 0.532181 -0.310087 -0.720577 1.000000 0.581675
E 0.969458 -0.478091 -0.427618 0.581675 1.000000
```
3. 从 Alice 购买过的物品中,选出与物品 `E` 最相似的 `num` 件物品。
```python
target_user = ' Alice '
target_item = 'E'
num = 2
sim_items = []
sim_items_list = similarity_matrix[target_item].sort_values(ascending=False).index.tolist()
for item in sim_items_list:
# 如果target_user对物品item评分过
if target_user in item_data[item]:
sim_items.append(item)
if len(sim_items) == num:
break
print(f'与物品{target_item}最相似的{num}个物品为:{sim_items}')
```
```
与物品E最相似的2个物品为['A', 'D']
```
4. 预测用户 Alice 对物品 `E` 的评分
```python
target_user_mean_rating = np.mean(list(item_data[target_item].values()))
weighted_scores = 0.
corr_values_sum = 0.
target_item = 'E'
for item in sim_items:
corr_value = similarity_matrix[target_item][item]
user_mean_rating = np.mean(list(item_data[item].values()))
weighted_scores += corr_value * (item_data[item][target_user] - user_mean_rating)
corr_values_sum += corr_value
target_item_pred = target_user_mean_rating + weighted_scores / corr_values_sum
print(f'用户{target_user}对物品{target_item}的预测评分为:{target_item_pred}')
```
```
用户 Alice 对物品E的预测评分为4.6
```
# 协同过滤算法的权重改进
* base 公式
$$
w_{i j}=\frac{|N(i) \bigcap N(j)|}{|N(i)|}
$$
+ 该公式表示同时喜好物品 $i$ 和物品 $j$ 的用户数,占喜爱物品 $i$ 的比例。
+ 缺点:若物品 $j$ 为热门物品,那么它与任何物品的相似度都很高。
* 对热门物品进行惩罚
$$
w_{i j}=\frac{|N(i) \cap N(j)|}{\sqrt{|N(i)||N(j)|}}
$$
* 根据 base 公式在的问题,对物品 $j$ 进行打压。打压的出发点很简单,就是在分母再除以一个物品 $j$ 被购买的数量。
* 此时,若物品 $j$ 为热门物品,那么对应的 $N(j)$ 也会很大,受到的惩罚更多。
* 控制对热门物品的惩罚力度
$$
w_{i j}=\frac{|N(i) \cap N(j)|}{|N(i)|^{1-\alpha}|N(j)|^{\alpha}}
$$
* 除了第二点提到的办法,在计算物品之间相似度时可以对热门物品进行惩罚外。
* 可以在此基础上,进一步引入参数 $\alpha$ ,这样可以通过控制参数 $\alpha$来决定对热门物品的惩罚力度。
* 对活跃用户的惩罚
* 在计算物品之间的相似度时,可以进一步将用户的活跃度考虑进来。
$$
w_{i j}=\frac{\sum_{\operatorname{\text {u}\in N(i) \cap N(j)}} \frac{1}{\log 1+|N(u)|}}{|N(i)|^{1-\alpha}|N(j)|^{\alpha}}
$$
+ 对于异常活跃的用户,在计算物品之间的相似度时,他的贡献应该小于非活跃用户。
# 协同过滤算法的问题分析
协同过滤算法存在的问题之一就是泛化能力弱:
+ 即协同过滤无法将两个物品相似的信息推广到其他物品的相似性上。
+ 导致的问题是**热门物品具有很强的头部效应, 容易跟大量物品产生相似, 而尾部物品由于特征向量稀疏, 导致很少被推荐**。
比如下面这个例子:
![图片](http://ryluo.oss-cn-chengdu.aliyuncs.com/JavaxxhHm3BAtMfsy2AV.png!thumbnail)
+ 左边矩阵中,$A, B, C, D$ 表示的是物品。
+ 可以看出,$D $ 是一件热门物品,其与 $A、B、C$ 的相似度比较大。因此,推荐系统更可能将 $D$ 推荐给用过 $A、B、C$ 的用户。
+ 但是,推荐系统无法找出 $A,B,C$ 之间相似性的原因是交互数据太稀疏, 缺乏相似性计算的直接数据。
所以这就是协同过滤的天然缺陷:**推荐系统头部效应明显, 处理稀疏向量的能力弱**。
为了解决这个问题, 同时增加模型的泛化能力。2006年**矩阵分解技术(Matrix Factorization, MF**)被提出:
+ 该方法在协同过滤共现矩阵的基础上, 使用更稠密的隐向量表示用户和物品, 挖掘用户和物品的隐含兴趣和隐含特征。
+ 在一定程度上弥补协同过滤模型处理稀疏矩阵能力不足的问题。
# 课后思考
1. **什么时候使用UserCF什么时候使用ItemCF为什么**
> 1UserCF
>
> + 由于是基于用户相似度进行推荐, 所以具备更强的社交特性, 这样的特点非常适于**用户少, 物品多, 时效性较强的场合**。
>
> + 比如新闻推荐场景, 因为新闻本身兴趣点分散, 相比用户对不同新闻的兴趣偏好, 新闻的及时性,热点性往往更加重要, 所以正好适用于发现热点,跟踪热点的趋势。
> + 另外还具有推荐新信息的能力, 更有可能发现惊喜, 因为看的是人与人的相似性, 推出来的结果可能更有惊喜,可以发现用户潜在但自己尚未察觉的兴趣爱好。
>
> 2ItemCF
>
> + 这个更适用于兴趣变化较为稳定的应用, 更接近于个性化的推荐, 适合**物品少,用户多,用户兴趣固定持久, 物品更新速度不是太快的场合**。
> + 比如推荐艺术品, 音乐, 电影。
2.**协同过滤在计算上有什么缺点?有什么比较好的思路可以解决(缓解)?**
> 该问题答案参考上一小节的**协同过滤算法的问题分析**。
**3.上面介绍的相似度计算方法有什么优劣之处?**
> cosine相似度计算简单方便一般较为常用。但是当用户的评分数据存在 bias 时,效果往往不那么好。
>
> + 简而言之,就是不同用户评分的偏向不同。部分用户可能乐于给予好评,而部分用户习惯给予差评或者乱评分。
> + 这个时候根据cosine 相似度计算出来的推荐结果效果会打折扣。
>
> 举例来说明,如下图(`X,Y,Z` 表示物品,`d,e,f`表示用户):
>
> ![图片](http://ryluo.oss-cn-chengdu.aliyuncs.com/JavaWKvITKBhYOkfXrzs.png!thumbnail)
>
> + 如果使用余弦相似度进行计算,用户 d 和 e 之间较为相似。但是实际上,用户 d 和 f 之间应该更加相似。只不过由于 d 倾向于打高分e 倾向于打低分导致二者之间的余弦相似度更高。
> + 这种情况下,可以考虑使用皮尔逊相关系数计算用户之间的相似性关系。
4.**协同过滤还存在其他什么缺陷?有什么比较好的思路可以解决(缓解)?**
> + 协同过滤的优点就是没有使用更多的用户或者物品属性信息,仅利用用户和物品之间的交互信息就能完成推荐,该算法简单高效。
> + 但这也是协同过滤算法的一个弊端。由于未使用更丰富的用户和物品特征信息,这也导致协同过滤算法的模型表达能力有限。
> + 对于该问题逻辑回归模型LR可以更好地在推荐模型中引入更多特征信息提高模型的表达能力。
# 参考资料
* [基于用户的协同过滤来构建推荐系统https://mp.weixin.qq.com/s/ZtnaQrVIpVOPJpqMdLWOcw](https://mp.weixin.qq.com/s/ZtnaQrVIpVOPJpqMdLWOcw)
* [协同过滤算法概述https://chenk.tech/posts/8ad63d9d.html](https://chenk.tech/posts/8ad63d9d.html)
* B站黑马推荐系统实战课程

View File

@@ -0,0 +1,370 @@
# 隐语义模型与矩阵分解
协同过滤算法的特点:
+ 协同过滤算法的特点就是完全没有利用到物品本身或者是用户自身的属性, 仅仅利用了用户与物品的交互信息就可以实现推荐,是一个可解释性很强, 非常直观的模型。
+ 但是也存在一些问题,处理稀疏矩阵的能力比较弱。
为了使得协同过滤更好处理稀疏矩阵问题, 增强泛化能力。从协同过滤中衍生出矩阵分解模型(Matrix Factorization, MF)或者叫隐语义模型:
+ 在协同过滤共现矩阵的基础上, 使用更稠密的隐向量表示用户和物品。
+ 通过挖掘用户和物品的隐含兴趣和隐含特征, 在一定程度上弥补协同过滤模型处理稀疏矩阵能力不足的问题。
# 隐语义模型
隐语义模型最早在文本领域被提出用于找到文本的隐含语义。在2006年 被用于推荐中, 它的核心思想是通过隐含特征latent factor联系用户兴趣和物品item 基于用户的行为找出潜在的主题和分类, 然后对物品进行自动聚类,划分到不同类别/主题(用户的兴趣)。
以项亮老师《推荐系统实践》书中的内容为例:
>如果我们知道了用户A和用户B两个用户在豆瓣的读书列表 从他们的阅读列表可以看出用户A的兴趣涉及侦探小说、科普图书以及一些计算机技术书 而用户B的兴趣比较集中在数学和机器学习方面。 那么如何给A和B推荐图书呢 先说说协同过滤算法, 这样好对比不同:
>* 对于UserCF首先需要找到和他们看了同样书的其他用户兴趣相似的用户然后给他们推荐那些用户喜欢的其他书。
>* 对于ItemCF需要给他们推荐和他们已经看的书相似的书比如作者B看了很多关于数据挖掘的书可以给他推荐机器学习或者模式识别方面的书。
>
>而如果是隐语义模型的话, 它会先通过一些角度把用户兴趣和这些书归一下类, 当来了用户之后, 首先得到他的兴趣分类, 然后从这个分类中挑选他可能喜欢的书籍。
隐语义模型和协同过滤的不同主要体现在隐含特征上, 比如书籍的话它的内容, 作者, 年份, 主题等都可以算隐含特征。
以王喆老师《深度学习推荐系统》中的一个原理图为例,看看是如何通过隐含特征来划分开用户兴趣和物品的。
<img src="https://img-blog.csdnimg.cn/20200822212051499.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:80%;" />
## 音乐评分实例
假设每个用户都有自己的听歌偏好, 比如用户 A 喜欢带有**小清新的** **吉他伴奏的** **王菲**的歌曲,如果一首歌正好**是王菲唱的, 并且是吉他伴奏的小清新** 那么就可以将这首歌推荐给这个用户。 也就是说是**小清新, 吉他伴奏, 王菲**这些元素连接起了用户和歌曲。
当然每个用户对不同的元素偏好不同, 每首歌包含的元素也不一样, 所以我们就希望找到下面的两个矩阵:
1. 潜在因子—— 用户矩阵Q
这个矩阵表示不同用户对于不同元素的偏好程度, 1代表很喜欢 0代表不喜欢 比如下面这样:
<div align=center>
<img src="https://img-blog.csdnimg.cn/2020082222025968.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
2. 潜在因子——音乐矩阵P
表示每种音乐含有各种元素的成分, 比如下表中, 音乐A是一个偏小清新的音乐 含有小清新的Latent Factor的成分是0.9 重口味的成分是0.1 优雅成分0.2...
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200822220751394.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
**计算张三对音乐A的喜爱程度**
利用上面的这两个矩阵将对应向量进行内积计算我们就能得出张三对音乐A的喜欢程度
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200822221627219.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:55%;" />
</div>
+ 张三对**小清新**的偏好 * 音乐A含有**小清新**的成分 + 张三对**重口味**的偏好 * 音乐A含有**重口味**的成分 + 张三对**优雅**的偏好 * 音乐A含有**优雅**的成分...
+ 根据隐向量其实就可以得到张三对音乐A的打分 $$0.6 * 0.9 + 0.8 * 0.1 + 0.1 * 0.2 + 0.1 * 0.4 + 0.7 * 0 = 0.68$$。
**计算所有用户对不同音乐的喜爱程度**
按照这个计算方式, 每个用户对每首歌其实都可以得到这样的分数, 最后就得到了我们的评分矩阵:
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200822222141231.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:60%;" />
</div>
+ 红色部分表示用户没有打分,可以通过隐向量计算得到的。
**小结**
+ 上面例子中的小清晰, 重口味, 优雅这些就可以看做是隐含特征, 而通过这个隐含特征就可以把用户的兴趣和音乐的进行一个分类, 其实就是找到了每个用户每个音乐的一个隐向量表达形式与深度学习中的embedding等价
+ 这个隐向量就可以反映出用户的兴趣和物品的风格,并能将相似的物品推荐给相似的用户等。 **有没有感觉到是把协同过滤算法进行了一种延伸, 把用户的相似性和物品的相似性通过了一个叫做隐向量的方式进行表达**
+ 现实中,类似于上述的矩阵 $P,Q$ 一般很难获得。有的只是用户的评分矩阵,如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200822223313349.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:60%;" />
</div>
+ 这种矩阵非常的稀疏,如果直接基于用户相似性或者物品相似性去填充这个矩阵是不太容易的。
+ 并且很容易出现长尾问题, 而矩阵分解就可以比较容易的解决这个问题。
+ 矩阵分解模型:
+ 基于评分矩阵将其分解成Q和P两个矩阵乘积的形式获取用户兴趣和物品的隐向量表达。
+ 然后,基于两个分解矩阵去预测某个用户对某个物品的评分了。
+ 最后,基于预测评分去进行物品推荐。
# 矩阵分解算法
## 算法原理
在矩阵分解的算法框架下, **可以通过分解协同过滤的共现矩阵(评分矩阵)来得到用户和物品的隐向量**,原理如下:。
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200823101513233.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
+ 矩阵分解算法将 $m\times n$ 维的共享矩阵 $R$ ,分解成 $m \times k$ 维的用户矩阵 $U$ 和 $k \times n$ 维的物品矩阵 $V$ 相乘的形式。
+ 其中,$m$ 是用户数量, $n$ 是物品数量, $k$ 是隐向量维度, 也就是隐含特征个数。
+ 这里的隐含特征没有太好的可解释性,需要模型自己去学习。
+ 一般而言, $k$ 越大隐向量能承载的信息内容越多,表达能力也会更强,但相应的学习难度也会增加。所以,我们需要根据训练集样本的数量去选择合适的数值,在保证信息学习相对完整的前提下,降低模型的学习难度。
## 评分预测
在分解得到用户矩阵和物品矩阵后,若要计算用户 $u$ 对物品 $i$ 的评分,公式如下:
$$
\operatorname{Preference}(u, i)=r_{u i}=p_{u}^{T} q_{i}=\sum_{k=1}^{K} p_{u, k} q_{i,k}
$$
+ 其中,向量 $p_u$ 表示用户 $u$ 的隐向量,向量 $q_i$ 表示物品 $i$ 的隐向量。
+ 用户向量和物品向量的内积 $p_{u}^{T} q_{i}$ 可以表示为用户 $u$ 对物品 $i$ 的预测评分。
+ $p_{u,k}$ 和 $q_{i,k}$ 是模型的参数, $p_{u,k}$ 度量的是用户 $u$ 的兴趣和第 $k$ 个隐类的关系,$q_{i,k}$ 度量了第 $k$ 个隐类和物品 $i$ 之间的关系。
## 矩阵分解求解
常用的矩阵分解方法有特征值分解(EVD)或者奇异值分解(SVD 具体原理可参考:
> [奇异值分解svd原理详解及推导](https://blog.csdn.net/wuzhongqiang/article/details/108168238)
+ 对于 EVD 它要求分解的矩阵是方阵, 绝大部分场景下用户-物品矩阵不满足这个要求。
+ 传统的 SVD 分解, 会要求原始矩阵是稠密的。但现实中用户的评分矩阵是非常稀疏的。
+ 如果想用奇异值分解, 就必须对缺失的元素进行填充(比如填 0 )。
+ 填充不但会导致空间复杂度增高,且补全内容不一定准确。
+ 另外SVD 分解计算复杂度非常高,而用户-物品的评分矩阵较大,不具备普适性。
## FunkSVD
2006年的Netflix Prize之后 Simon Funk公布了一个矩阵分解算法叫做**Funk-SVD**, 后来被 Netflix Prize 的冠军Koren称为**Latent Factor Model(LFM)**。
Funk-SVD的思想很简单 **把求解上面两个矩阵的参数问题转换成一个最优化问题, 可以通过训练集里面的观察值利用最小化来学习用户矩阵和物品矩阵**
**算法过程**
1. 根据前面提到的,在有用户矩阵和物品矩阵的前提下,若要计算用户 $u$ 对物品 $i$ 的评分, 可以根据公式:
$$
\operatorname{Preference}(u, i)=r_{u i}=p_{u}^{T} q_{i}=\sum_{k=1}^{K} p_{u, k} q_{i,k}
$$
+ 其中,向量 $p_u$ 表示用户 $u$ 的隐向量,向量 $q_i$ 表示物品 $i$ 的隐向量。
2. 随机初始化一个用户矩阵 $U$ 和一个物品矩阵 $V$,获取每个用户和物品的初始隐语义向量。
3. 将用户和物品的向量内积 $p_{u}^{T} q_{i}$ 作为用户对物品的预测评分 $\hat{r}_{u i}$。
+ $\hat{r}_{u i}=p_{u}^{T} q_{i}$ 表示的是通过建模,求得的用户 $u$ 对物品的预测评分。
+ 在用户对物品的评分矩阵中,矩阵中的元素 $r_{u i}$ 才是用户对物品的真实评分。
4. 对于评分矩阵中的每个元素,计算预测误差 $e_{u i}=r_{u i}-\hat{r}_{u i}$,对所有训练样本的平方误差进行累加:
$$
\operatorname{SSE}=\sum_{u, i} e_{u i}^{2}=\sum_{u, i}\left(r_{u i}-\sum_{k=1}^{K} p_{u,k} q_{i,k}\right)^{2}
$$
+ 从上述公式可以看出,$SSE$ 建立起了训练数据和预测模型之间的关系。
+ 如果我们希望模型预测的越准确,那么在训练集(已有的评分矩阵)上的预测误差应该仅可能小。
+ 为方便后续求解,给 $SSE$ 增加系数 $1/2$
$$
\operatorname{SSE}=\frac{1}{2} \sum_{u, i} e_{u i}^{2}=\frac{1}{2} \sum_{u, i}\left(r_{u i}-\sum_{k=1}^{K} p_{u k} q_{i k}\right)^{2}
$$
5. 前面提到,模型预测越准确等价于预测误差越小,那么优化的目标函数变为:
$$
\min _{\boldsymbol{q}^{*}, \boldsymbol{p}^{*}} \frac{1}{2} \sum_{(u, i) \in K}\left(\boldsymbol{r}_{\mathrm{ui}}-p_{u}^{T} q_{i}\right)^{2}
$$
+ $K$ 表示所有用户评分样本的集合,**即评分矩阵中不为空的元素**,其他空缺值在测试时是要预测的。
+ 该目标函数需要优化的目标是用户矩阵 $U$ 和一个物品矩阵 $V$。
6. 对于给定的目标函数,可以通过梯度下降法对参数进行优化。
+ 求解目标函数 $SSE$ 关于用户矩阵中参数 $p_{u,k}$ 的梯度:
$$
\frac{\partial}{\partial p_{u,k}} S S E=\frac{\partial}{\partial p_{u,k}}\left(\frac{1}{2}e_{u i}^{2}\right) =e_{u i} \frac{\partial}{\partial p_{u,k}} e_{u i}=e_{u i} \frac{\partial}{\partial p_{u,k}}\left(r_{u i}-\sum_{k=1}^{K} p_{u,k} q_{i,k}\right)=-e_{u i} q_{i,k}
$$
+ 求解目标函数 $SSE$ 关于 $q_{i,k}$ 的梯度:
$$
\frac{\partial}{\partial q_{i,k}} S S E=\frac{\partial}{\partial q_{i,k}}\left(\frac{1}{2}e_{u i}^{2}\right) =e_{u i} \frac{\partial}{\partial q_{i,k}} e_{u i}=e_{u i} \frac{\partial}{\partial q_{i,k}}\left(r_{u i}-\sum_{k=1}^{K} p_{u,k} q_{i,k}\right)=-e_{u i} p_{u,k}
$$
7. 参数梯度更新
$$
p_{u, k}=p_{u,k}-\eta (-e_{ui}q_{i, k})=p_{u,k}+\eta e_{ui}q_{i, k} \\
q_{i, k}=q_{i,k}-\eta (-e_{ui}p_{u,k})=q_{i, k}+\eta e_{ui}p_{u, k}
$$
+ 其中,$\eta$ 表示学习率, 用于控制步长。
+ 但上面这个有个问题就是当参数很多的时候, 就是两个矩阵很大的时候, 往往容易陷入过拟合的困境, 这时候, 就需要在目标函数上面加上正则化的损失, 就变成了RSVD 关于RSVD的详细内容 可以参考下面给出的链接, 由于篇幅原因, 这里不再过多的赘述。
**加入正则项**
为了控制模型的复杂度。在原有模型的基础上,加入 $l2$ 正则项,来防止过拟合。
+ 当模型参数过大,而输入数据发生变化时,可能会造成输出的不稳定。
+ $l2$ 正则项等价于假设模型参数符合0均值的正态分布从而使得模型的输出更加稳定。
$$
\min _{\boldsymbol{q}^{*}, \boldsymbol{p}^{*}} \frac{1}{2} \sum_{(u, i) \in K}\left(\boldsymbol{r}_{\mathrm{ui}}-p_{u}^{T} q_{i}\right)^{2}
+ \lambda\left(\left\|p_{u}\right\|^{2}+\left\|q_{i}\right\|^{2}\right)
$$
## BiasSVD
在推荐系统中,评分预测除了与用户的兴趣偏好、物品的特征属性相关外,与其他的因素也相关。例如:
+ 例如,对于乐观的用户来说,它的评分行为普遍偏高,而对批判性用户来说,他的评分记录普遍偏低,即使他们对同一物品的评分相同,但是他们对该物品的喜好程度却并不一样。
+ 对物品来说也是类似的。以电影为例,受大众欢迎的电影得到的评分普遍偏高,而一些烂片的评分普遍偏低,这些因素都是独立于用户或产品的因素,和用户对产品的的喜好无关。
因此, Netfix Prize中提出了另一种LFM 在原来的基础上加了偏置项, 来消除用户和物品打分的偏差, 即预测公式如下:
$$
\hat{r}_{u i}=\mu+b_{u}+b_{i}+p_{u}^{T} \cdot q_{i}
$$
这个预测公式加入了3项偏置参数 $\mu,b_u,b_i$, 作用如下:
- $\mu$ 该参数反映的是推荐模型整体的平均评分,一般使用所有样本评分的均值。
- $b_u$:用户偏差系数。可以使用用户 $u$ 给出的所有评分的均值, 也可以当做训练参数。
- 这一项表示了用户的评分习惯中和物品没有关系的那种因素。 比如有些用户比较苛刻, 对什么东西要求很高, 那么他评分就会偏低, 而有些用户比较宽容, 对什么东西都觉得不错, 那么评分就偏高
- $b_i$:物品偏差系数。可以使用物品 $i$ 收到的所有评分的均值, 也可以当做训练参数。
- 这一项表示了物品接受的评分中和用户没有关系的因素。 比如有些物品本身质量就很高, 因此获得的评分相对比较高, 有的物品本身质量很差, 因此获得的评分相对较低。
加了用户和物品的打分偏差之后, 矩阵分解得到的隐向量更能反映不同用户对不同物品的“真实”态度差异, 也就更容易捕捉评价数据中有价值的信息, 从而避免推荐结果有偏。
**优化函数**
在加入正则项的FunkSVD的基础上BiasSVD 的目标函数如下:
$$
\begin{aligned}
\min _{q^{*}, p^{*}} \frac{1}{2} \sum_{(u, i) \in K} &\left(r_{u i}-\left(\mu+b_{u}+b_{i}+q_{i}^{T} p_{u}\right)\right)^{2} \\
&+\lambda\left(\left\|p_{u}\right\|^{2}+\left\|q_{i}\right\|^{2}+b_{u}^{2}+b_{i}^{2}\right)
\end{aligned}
$$
可得偏置项的梯度更新公式如下:
+ $\frac{\partial}{\partial b_{i}} S S E=-e_{u i}+\lambda b_{i}$
+ $ \frac{\partial}{\partial b_{u}} S S E=-e_{u i}+\lambda b_{u} \ $
# 编程实现
本小节使用如下图表来预测Alice对物品5的评分
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200827150237921.png#pic_center" alt="在这里插入图片描述" style="zoom:80%;" />
</div>
基于矩阵分解算法的流程如下:
1. 首先, 它会先初始化用户矩阵 $P$ 和物品矩阵 $Q$ $P$ 的维度是`[users_num, K]`$Q$ 的维度是`[items_num, K]`
+ 其中,`F`表示隐向量的维度。 也就是把通过隐向量的方式把用户的兴趣和`F`的特点关联了起来。
+ 初始化这两个矩阵的方式很多, 但根据经验, 随机数需要和`1/sqrt(F)`成正比。
2. 根据预测评分和真实评分的偏差,利用梯度下降法进行参数更新。
+ 遍历用户及其交互过的物品,对已交互过的物品进行评分预测。
+ 由于预测评分与真实评分存在偏差, 再根据第3节的梯度更新公式更新参数。
3. 训练完成后,利用用户向量与目标物品向量的内积进行评分预测。
**完整代码如下:**
```python
import random
import math
class BiasSVD():
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 # 全局偏置系数
self.alpha = alpha # 学习率
self.lmbda = lmbda # 正则项系数
self.max_iter = max_iter # 最大迭代次数
self.rating_data = rating_data # 评分矩阵
for user, items in self.rating_data.items():
# 初始化矩阵P和Q, 随机数需要和1/sqrt(F)成正比
self.P[user] = [random.random() / math.sqrt(self.F) for x in range(0, F)]
self.bu[user] = 0
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
# 采用随机梯度下降的方式训练模型参数
def train(self):
cnt, mu_sum = 0, 0
for user, items in self.rating_data.items():
for item, rui in items.items():
mu_sum, cnt = mu_sum + rui, cnt + 1
self.mu = mu_sum / cnt
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
# 评分预测
def predict(self, user, item):
return sum(self.P[user][f] * self.Q[item][f] for f in range(0, self.F)) + self.bu[user] + self.bi[
item] + self.mu
# 通过字典初始化训练样本分别表示不同用户1-5对不同物品A-E)的真实评分
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()
# 建立模型
basicsvd = BiasSVD(rating_data, F=10)
# 参数训练
basicsvd.train()
# 预测用户1对物品E的评分
for item in ['E']:
print(item, basicsvd.predict(1, item))
# 预测结果E 3.685084274454321
```
# 课后思考
1. 矩阵分解算法后续有哪些改进呢?针对这些改进是为了解决什么的问题呢请大家自行探索RSVD消除用户和物品打分偏差等。
2. 矩阵分解的优缺点分析
* 优点:
* 泛化能力强: 一定程度上解决了稀疏问题
* 空间复杂度低: 由于用户和物品都用隐向量的形式存放, 少了用户和物品相似度矩阵, 空间复杂度由$n^2$降到了$(n+m)*f$
* 更好的扩展性和灵活性:矩阵分解的最终产物是用户和物品隐向量, 这个深度学习的embedding思想不谋而合 因此矩阵分解的结果非常便于与其他特征进行组合和拼接, 并可以与深度学习无缝结合。
+ 缺点:
+ 矩阵分解算法依然是只用到了评分矩阵, 没有考虑到用户特征, 物品特征和上下文特征, 这使得矩阵分解丧失了利用很多有效信息的机会。
+ 同时在缺乏用户历史行为的时候, 无法进行有效的推荐。
+ 为了解决这个问题, **逻辑回归模型及后续的因子分解机模型** 凭借其天然的融合不同特征的能力, 逐渐在推荐系统领域得到了更广泛的应用。
# 参考资料
* 王喆 - 《深度学习推荐系统》
* 项亮 - 《推荐系统实战》
* [奇异值分解(SVD)的原理详解及推导](https://blog.csdn.net/wuzhongqiang/article/details/108168238)
* [Matrix factorization techniques for recommender systems论文](https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=5197422&tag=1)
* [隐语义模型(LFM)和矩阵分解(MF)](https://blog.csdn.net/wuzhongqiang/article/details/108173885)

View File

@@ -0,0 +1,345 @@
# 协同过滤算法
## 基本思想
协同过滤Collaborative Filtering推荐算法是最经典、最常用的推荐算法。基本思想是
+ 根据用户之前的喜好以及其他兴趣相近的用户的选择来给用户推荐物品。
+ 基于对用户历史行为数据的挖掘发现用户的喜好偏向, 并预测用户可能喜好的产品进行推荐。
+ 一般是仅仅基于用户的行为数据(评价、购买、下载等), 而不依赖于项的任何附加信息(物品自身特征)或者用户的任何附加信息(年龄, 性别等)。
+ 目前应用比较广泛的协同过滤算法是基于邻域的方法,主要有:
+ 基于用户的协同过滤算法UserCF给用户推荐和他兴趣相似的其他用户喜欢的产品。
+ 基于物品的协同过滤算法ItemCF给用户推荐和他之前喜欢的物品相似的物品。
不管是 UserCF 还是 ItemCF 算法, 重点是计算用户之间(或物品之间)的相似度。
## 相似性度量方法
1. **杰卡德Jaccard相似系数**
`Jaccard` 系数是衡量两个集合的相似度一种指标,计算公式如下:
$$
sim_{uv}=\frac{|N(u) \cap N(v)|}{|N(u)| \cup|N(v)|}
$$
+ 其中 $N(u)$$N(v)$ 分别表示用户 $u$ 和用户 $v$ 交互物品的集合。
+ 对于用户 $u$ 和 $v$ ,该公式反映了两个交互物品交集的数量占这两个用户交互物品并集的数量的比例。
由于杰卡德相似系数一般无法反映具体用户的评分喜好信息,所以常用来评估用户是否会对某物品进行打分, 而不是预估用户会对某物品打多少分。
2. **余弦相似度**
余弦相似度衡量了两个向量的夹角夹角越小越相似。余弦相似度的计算如下其与杰卡德Jaccard相似系数只是在分母上存在差异
$$
sim_{uv}=\frac{|N(u) \cap N(v)|}{\sqrt{|N(u)|\cdot|N(v)|}}
$$
从向量的角度进行描述,令矩阵 $A$ 为用户-物品交互矩阵,矩阵的行表示用户,列表示物品。
+ 设用户和物品数量分别为 $m,n$,交互矩阵$A$就是一个 $m$ 行 $n$ 列的矩阵。
+ 矩阵中的元素均为 $0/1$。若用户 $i$ 对物品 $j$ 存在交互,那么 $A_{i,j}=1$,否则为 $0$ 。
+ 那么,用户之间的相似度可以表示为:
$$
sim_{uv} = cos(u,v) =\frac{u\cdot v}{|u|\cdot |v|}
$$
+ 向量 $u,v$ 在形式都是 one-hot 类型,$u\cdot v$ 表示向量点积。
上述用户-物品交互矩阵在现实中是十分稀疏的,为了节省内存,交互矩阵会采用字典进行存储。在 `sklearn` 中,余弦相似度的实现:
```python
from sklearn.metrics.pairwise import cosine_similarity
i = [1, 0, 0, 0]
j = [1, 0, 1, 0]
cosine_similarity([i, j])
```
3. **皮尔逊相关系数**
在用户之间的余弦相似度计算时,将用户向量的内积展开为各元素乘积和:
$$
sim_{uv} = \frac{\sum_i r_{ui}*r_{vi}}{\sqrt{\sum_i r_{ui}^2}\sqrt{\sum_i r_{vi}^2}}
$$
+ 其中,$r_{ui},r_{vi}$ 分别表示用户 $u$ 和用户 $v$ 对物品 $i$ 是否有交互(或具体评分值)。
皮尔逊相关系数与余弦相似度的计算公式非常的类似,如下:
$$
sim(u,v)=\frac{\sum_{i\in I}(r_{ui}-\bar r_u)(r_{vi}-\bar r_v)}{\sqrt{\sum_{i\in I }(r_{ui}-\bar r_u)^2}\sqrt{\sum_{i\in I }(r_{vi}-\bar r_v)^2}}
$$
+ 其中,$r_{ui},r_{vi}$ 分别表示用户 $u$ 和用户 $v$ 对物品 $i$ 是否有交互(或具体评分值)
+ $\bar r_u, \bar r_v$ 分别表示用户 $u$ 和用户 $v$ 交互的所有物品交互数量或者评分的平均值;
相较于余弦相似度,皮尔逊相关系数通过使用用户的平均分对各独立评分进行修正,减小了用户评分偏置的影响。在`scipy`中,皮尔逊相关系数的实现:
```python
from scipy.stats import pearsonr
i = [1, 0, 0, 0]
j = [1, 0.5, 0.5, 0]
pearsonr(i, j)
```
**适用场景**
+ $Jaccard$ 相似度表示两个集合的交集元素个数在并集中所占的比例 所以适用于隐式反馈数据0-1
+ 余弦相似度在度量文本相似度、用户相似度、物品相似度的时候都较为常用。
+ 皮尔逊相关度,实际上也是一种余弦相似度。不过先对向量做了中心化,范围在 $-1$ 到 $1$。
+ 相关度量的是两个变量的变化趋势是否一致,两个随机变量是不是同增同减。
+ 不适合用作计算布尔值向量0-1之间相关度。
# 基于用户的协同过滤
## 基本思想
基于用户的协同过滤UserCF
+ 例如,我们要对用户 $A$ 进行物品推荐,可以先找到和他有相似兴趣的其他用户。
+ 然后,将共同兴趣用户喜欢的,但用户 $A$ 未交互过的物品推荐给 $A$。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210629232540289.png" alt="image-20210629232540289" style="zoom: 80%;" />
## 计算过程
以下图为例给用户推荐物品的过程可以形象化为一个猜测用户对物品进行打分的任务表格里面是5个用户对于5件物品的一个打分情况就可以理解为用户对物品的喜欢程度。
![image-20210629232622758](http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20210629232622758.png)
UserCF算法的两个步骤
+ 首先,根据前面的这些打分情况(或者说已有的用户向量)计算一下 Alice 和用户1 2 3 4的相似程度 找出与 Alice 最相似的 n 个用户。
+ 根据这 n 个用户对物品 5 的评分情况和与 Alice 的相似程度会猜测出 Alice 对物品5的评分。如果评分比较高的话 就把物品5推荐给用户 Alice 否则不推荐。
**具体过程:**
1. 计算用户之间的相似度
+ 根据 1.2 节的几种方法, 我们可以计算出各用户之间的相似程度。对于用户 Alice选取出与其最相近的 $N$ 个用户。
2. 计算用户对新物品的评分预测
+ 常用的方式之一:利用目标用户与相似用户之间的相似度以及相似用户对物品的评分,来预测目标用户对候选物品的评分估计:
$$
R_{\mathrm{u}, \mathrm{p}}=\frac{\sum_{\mathrm{s} \in S}\left(w_{\mathrm{u}, \mathrm{s}} \cdot R_{\mathrm{s}, \mathrm{p}}\right)}{\sum_{\mathrm{s} \in S} w_{\mathrm{u}, \mathrm{s}}}
$$
+ 其中,权重 $w_{u,s}$ 是用户 $u$ 和用户 $s$ 的相似度, $R_{s,p}$ 是用户 $s$ 对物品 $p$ 的评分。
+ 另一种方式:考虑到用户评分的偏置,即有的用户喜欢打高分, 有的用户喜欢打低分的情况。公式如下:
$$
R_{\mathrm{u}, \mathrm{p}}=\bar{R}_{u} + \frac{\sum_{\mathrm{s} \in S}\left(w_{\mathrm{u}, \mathrm{s}} \cdot \left(R_{s, p}-\bar{R}_{s}\right)\right)}{\sum_{\mathrm{s} \in S} w_{\mathrm{u}, \mathrm{s}}}
$$
+ 其中,$\bar{R}_{s}$ 表示用户 $s$ 对物品的历史平均评分。
3. 对用户进行物品推荐
+ 在获得用户 $u$ 对不同物品的评价预测后, 最终的推荐列表根据预测评分进行排序得到。
**手动计算:**
根据上面的问题, 下面手动计算 Alice 对物品 5 的得分:
1. 计算 Alice 与其他用户的相似度(基于皮尔逊相关系数)
+ 手动计算 Alice 与用户 1 之间的相似度:
>用户向量 $\text {Alice}:(5,3,4,4) , \text{user1}:(3,1,2,3) , \text {user2}:( 4,3,4,3) , \text {user3}:(3,3,1,5) , \text {user4}:(1,5,5,2) $
>
>+ 计算Alice与user1的余弦相似性:
>$$
>\operatorname{sim}(\text { Alice, user1 })=\cos (\text { Alice, user } 1)=\frac{15+3+8+12}{\operatorname{sqrt}(25+9+16+16) * \operatorname{sqrt}(9+1+4+9)}=0.975
>$$
>
>+ 计算Alice与user1皮尔逊相关系数:
> + $Alice\_ave =4 \quad user1\_ave =2.25 $
> + 向量减去均值: $\text {Alice}:(1,-1, 0,0) \quad \text { user1 }:(0.75,-1.25,-0.25,0.75)$
>
>+ 计算这俩新向量的余弦相似度和上面计算过程一致, 结果是 0.852 。
>
+ 基于 sklearn 计算所有用户之间的皮尔逊相关系数。可以看出,与 Alice 相似度最高的用户为用户1和用户2。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/JavaWJkjP2fHH27Rajrj.png!thumbnail" alt="图片" style="zoom:80%;" />
2. **根据相似度用户计算 Alice对物品5的最终得分**
用户1对物品5的评分是3 用户2对物品5的打分是5 那么根据上面的计算公式, 可以计算出 Alice 对物品5的最终得分是
$$
P_{Alice, 物品5}=\bar{R}_{Alice}+\frac{\sum_{k=1}^{2}\left(w_{Alice,user k}\left(R_{userk, 物品5}-\bar{R}_{userk}\right)\right)}{\sum_{k=1}^{2} w_{Alice, userk}}=4+\frac{0.85*(3-2.4)+0.7*(5-3.8)}{0.85+0.7}=4.87
$$
+ 同样方式,可以计算用户 Alice 对其他物品的评分预测。
3. **根据用户评分对用户进行推荐**
+ 根据 Alice 的打分对物品排个序从大到小:$$物品1>物品5>物品3=物品4>物品2$$。
+ 如果要向 Alice 推荐2款产品的话 我们就可以推荐物品 1 和物品 5 给 Alice。
至此, 基于用户的协同过滤算法原理介绍完毕。
## UserCF编程实现
1. 建立实验使用的数据表:
```python
import numpy as np
import pandas as pd
def loadData():
users = {'Alice': {'A': 5, 'B': 3, 'C': 4, 'D': 4},
'user1': {'A': 3, 'B': 1, 'C': 2, 'D': 3, 'E': 3},
'user2': {'A': 4, 'B': 3, 'C': 4, 'D': 3, 'E': 5},
'user3': {'A': 3, 'B': 3, 'C': 1, 'D': 5, 'E': 4},
'user4': {'A': 1, 'B': 5, 'C': 5, 'D': 2, 'E': 1}
}
return users
```
+ 这里使用字典来建立用户-物品的交互表。
+ 字典`users`的键表示不同用户的名字,值为一个评分字典,评分字典的键值对表示某物品被当前用户的评分。
+ 由于现实场景中,用户对物品的评分比较稀疏。如果直接使用矩阵进行存储,会存在大量空缺值,故此处使用了字典。
2. 计算用户相似性矩阵
+ 由于训练数据中共包含 5 个用户,所以这里的用户相似度矩阵的维度也为 $5 \times 5$。
```python
user_data = loadData()
similarity_matrix = pd.DataFrame(
np.identity(len(user_data)),
index=user_data.keys(),
columns=user_data.keys(),
)
# 遍历每条用户-物品评分数据
for u1, items1 in user_data.items():
for u2, items2 in user_data.items():
if u1 == u2:
continue
vec1, vec2 = [], []
for item, rating1 in items1.items():
rating2 = items2.get(item, -1)
if rating2 == -1:
continue
vec1.append(rating1)
vec2.append(rating2)
# 计算不同用户之间的皮尔逊相关系数
similarity_matrix[u1][u2] = np.corrcoef(vec1, vec2)[0][1]
print(similarity_matrix)
```
```
1 2 3 4 5
1 1.000000 0.852803 0.707107 0.000000 -0.792118
2 0.852803 1.000000 0.467707 0.489956 -0.900149
3 0.707107 0.467707 1.000000 -0.161165 -0.466569
4 0.000000 0.489956 -0.161165 1.000000 -0.641503
5 -0.792118 -0.900149 -0.466569 -0.641503 1.000000
```
3. 计算与 Alice 最相似的 `num` 个用户
```python
target_user = ' Alice '
num = 2
# 由于最相似的用户为自己,去除本身
sim_users = similarity_matrix[target_user].sort_values(ascending=False)[1:num+1].index.tolist()
print(f'与用户{target_user}最相似的{num}个用户为:{sim_users}')
```
```
与用户 Alice 最相似的2个用户为['user1', 'user2']
```
4. 预测用户 Alice 对物品 `E` 的评分
```python
weighted_scores = 0.
corr_values_sum = 0.
target_item = 'E'
# 基于皮尔逊相关系数预测用户评分
for user in sim_users:
corr_value = similarity_matrix[target_user][user]
user_mean_rating = np.mean(list(user_data[user].values()))
weighted_scores += corr_value * (user_data[user][target_item] - user_mean_rating)
corr_values_sum += corr_value
target_user_mean_rating = np.mean(list(user_data[target_user].values()))
target_item_pred = target_user_mean_rating + weighted_scores / corr_values_sum
print(f'用户{target_user}对物品{target_item}的预测评分为:{target_item_pred}')
```
```
用户 Alice 对物品E的预测评分为4.871979899370592
```
## UserCF优缺点
User-based算法存在两个重大问题
1. 数据稀疏性
+ 一个大型的电子商务推荐系统一般有非常多的物品用户可能买的其中不到1%的物品,不同用户之间买的物品重叠性较低,导致算法无法找到一个用户的邻居,即偏好相似的用户。
+ 这导致UserCF不适用于那些正反馈获取较困难的应用场景(如酒店预订, 大件物品购买等低频应用)。
1. 算法扩展性
+ 基于用户的协同过滤需要维护用户相似度矩阵以便快速的找出 $TopN$ 相似用户, 该矩阵的存储开销非常大,存储空间随着用户数量的增加而增加。
+ 故不适合用户数据量大的情况使用。
由于UserCF技术上的两点缺陷 导致很多电商平台并没有采用这种算法, 而是采用了ItemCF算法实现最初的推荐系统。
# 算法评估
由于UserCF和ItemCF结果评估部分是共性知识点 所以在这里统一标识。
## 召回率
对用户 $u$ 推荐 $N$ 个物品记为 $R(u)$, 令用户 $u$ 在测试集上喜欢的物品集合为$T(u)$ 那么召回率定义为:
$$
\operatorname{Recall}=\frac{\sum_{u}|R(u) \cap T(u)|}{\sum_{u}|T(u)|}
$$
+ 含义:在模型召回预测的物品中,预测准确的物品占用户实际喜欢的物品的比例。
## 精确率
精确率定义为:
$$
\operatorname{Precision}=\frac{\sum_{u} \mid R(u) \cap T(u)|}{\sum_{u}|R(u)|}
$$
+ 含义:推荐的物品中,对用户准确推荐的物品占总物品的比例。
+ 如要确保召回率高,一般是推荐更多的物品,期望推荐的物品中会涵盖用户喜爱的物品。而实际中,推荐的物品中用户实际喜爱的物品占少数,推荐的精确率就会很低。故同时要确保高召回率和精确率往往是矛盾的,所以实际中需要在二者之间进行权衡。
## 覆盖率
覆盖率反映了推荐算法发掘长尾的能力, 覆盖率越高, 说明推荐算法越能将长尾中的物品推荐给用户。
$$
\text { Coverage }=\frac{\left|\bigcup_{u \in U} R(u)\right|}{|I|}
$$
+ 含义:推荐系统能够推荐出来的物品占总物品集合的比例。
+ 其中 $|I|$ 表示所有物品的个数;
+ 系统的用户集合为$U$;
+ 推荐系统给每个用户推荐一个长度为 $N$ 的物品列表$R(u)$.
+ 覆盖率表示最终的推荐列表中包含多大比例的物品。如果所有物品都被给推荐给至少一个用户, 那么覆盖率是100%。
## 新颖度
用推荐列表中物品的平均流行度度量推荐结果的新颖度。 如果推荐出的物品都很热门, 说明推荐的新颖度较低。 由于物品的流行度分布呈长尾分布, 所以为了流行度的平均值更加稳定, 在计算平均流行度时对每个物品的流行度取对数。
- Oscar Celma 在博士论文 "[Music Recommendation and Discovery in the Long Tail](http://mtg.upf.edu/static/media/PhD_ocelma.pdf) " 中研究了新颖度的评测。
# 参考资料
* [基于用户的协同过滤来构建推荐系统https://mp.weixin.qq.com/s/ZtnaQrVIpVOPJpqMdLWOcw](https://mp.weixin.qq.com/s/ZtnaQrVIpVOPJpqMdLWOcw)
* [协同过滤算法概述https://chenk.tech/posts/8ad63d9d.html](https://chenk.tech/posts/8ad63d9d.html)
* B站黑马推荐系统实战课程

View File

@@ -0,0 +1,332 @@
# 前言
这是 Airbnb 于2018年发表的一篇论文主要介绍了 Airbnb 在 Embedding 技术上的应用,并获得了 KDD 2018 的 Best Paper。Airbnb 是全球最大的短租平台,包含了数百万种不同的房源。这篇论文介绍了 Airbnb 如何使用 Embedding 来实现相似房源推荐以及实时个性化搜索。在本文中Airbnb 在用户和房源的 Embedding 上的生成都是基于谷歌的 Word2Vec 模型,<u>故阅读本文要求大家了解 Word2Vec 模型,特别是 Skip-Gram 模型**(重点***</u>。
本文将从以下几个方面来介绍该论文:
- 了解 Airbnb 是如何利用 Word2Vec 技术生成房源和用户的Embedding并做出了哪些改进。
- 了解 Airbnb 是如何利用 Embedding 解决房源冷启动问题。
- 了解 Airbnb 是如何衡量生成的 Embedding 的有效性。
- 了解 Airbnb 是如何利用用户和房源 Embedding 做召回和搜索排序。
考虑到本文的目的是为了让大家快速了解 Airbnb 在 Embedding 技术上的应用故不会完全翻译原论文。如需进一步了解建议阅读原论文或文末的参考链接。原论文链接https://dl.acm.org/doi/pdf/10.1145/3219819.3219885
# Airbnb 的业务背景
在介绍 Airbnb 在 Embedding 技术上的方法前,先了解 Airbnb 的业务背景。
- Airbnb 平台包含数百万种不同的房源,用户可以通过**浏览搜索结果页面**来寻找想要的房源。Airbnb 技术团队通过复杂的机器学习模型,并使用上百种信号对搜索结果中的房源进行排序。
- 当用户在查看某一个房源时,接下来的有两种方式继续搜索:
- 返回搜索结果页,继续查看其他搜索结果。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653049527431-0b09af70-bda0-4a30-8082-6aa69548213a.png" alt="img" style="zoom:50%;" />
- 在当前房源的详情页下,「相似房源」板块(你可能还喜欢)所推荐的房源。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653049385995-7a775df1-a36f-4795-9e79-8e577bcf2097.png" alt="img" style="zoom:50%;" />
- Airbnb 平台 99% 的房源预订来自于搜索排序和相似房源推荐。
# Embedding 方法
Airbnb 描述了两种 Embedding 的构建方法,分别为:
- 用于描述短期实时性的个性化特征 Embedding**listing Embeddings**
- **listing 表示房源的意思,<u>它将贯穿全文,请务必了解</u>。**
- 用于描述长期的个性化特征 Embedding**user-type & listing type Embeddings**
## Listing Embeddings
Listing Embeddings 是基于用户的点击 session 学习得到的,用于表示房源的短期实时性特征。给定数据集 $ \mathcal{S} $ ,其中包含了 $ N $ 个用户的 $ S $ 个点击 session序列
- 每个 session $ s=\left(l_{1}, \ldots, l_{M}\right) \in \mathcal{S} $ ,包含了 $ M $ 个被用户点击过的 listing ids 。
- 对于用户连续两次点击若时间间隔超过了30分钟则启动新的 session。
在拿到多个用户点击的 session 后,可以基于 Word2Vec 的 Skip-Gram 模型来学习不同 listing 的 Embedding 表示。最大化目标函数 $ \mathcal{L} $
$$
\mathcal{L}=\sum_{s \in \mathcal{S}} \sum_{l_{i} \in s}\left(\sum_{-m \geq j \leq m, i \neq 0} \log \mathbb{P}\left(l_{i+j} \mid l_{i}\right)\right)
$$
概率 $ \mathbb{P}\left(l_{i+j} \mid l_{i}\right) $ 是基于 soft-max 函数的表达式。表示在一个 session 中,已知中心 listing $ l_i $ 来预测上下文 listing $ l_{i+j} $ 的概率:
$$
\mathbb{P}\left(l_{i+j} \mid l_{i}\right)=\frac{\exp \left(\mathbf{v}_{l_{i}}^{\top} \mathbf{v}_{l_{i+j}}^{\prime}\right)}{\sum_{l=1}^{|\mathcal{V}|} \exp \left(\mathbf{v}_{l_{i}}^{\top} \mathbf{v}_{l}^{\prime}\right)}
$$
- 其中, $ \mathbf{v}_{l_{i}} $ 表示 listing $ l_i $ 的 Embedding 向量, $ |\mathcal{V}| $ 表示全部的物料库的数量。
考虑到物料库 $ \mathcal{V} $ 过大,模型中参数更新的时间成本和 $ |\mathcal{V}| $ 成正比。为了降低计算复杂度,要进行负采样。负采样后,优化的目标函数如下:
$$
\underset{\theta}{\operatorname{argmax}} \sum_{(l, c) \in \mathcal{D}_{p}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime^{\prime}} \mathbf{v}_{l}}}+\sum_{(l, c) \in \mathcal{D}_{n}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{l}}}
$$
至此,对 Skip-Gram 模型和 NEG 了解的同学肯定很熟悉,上述方法和 Word2Vec 思想基本一致。
下面,将进一步介绍 Airbnb 是如何改进 Listing Embedding 的学习以及其他方面的应用。
**1正负样本集构建的改进**
- 使用 booked listing 作为全局上下文
- booked listing 表示用户在 session 中最终预定的房源,一般只会出现在结束的 session 中。
- Airbnb 将最终预定的房源,始终作为滑窗的上下文,即全局上下文。如下图:
- 如图,对于当前滑动窗口的 central listing实线箭头表示context listings虚线指向booked listing表示 global context listing。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653053823336-0564b2da-c993-46aa-9b22-f5cbb784dae2.png" alt="img" style="zoom:50%;" />
- booked listing 作为全局正样本,故优化的目标函数更新为:
$$
\underset{\theta}{\operatorname{argmax}} \sum_{(l, c) \in \mathcal{D}_{p}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime^{\prime}} \mathbf{v}_{l}}}+\sum_{(l, c) \in \mathcal{D}_{n}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{l}}} +
\log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{l_b}}}
$$
- 优化负样本的选择
- 用户通过在线网站预定房间时,通常只会在同一个 market (将要停留区域)内进行搜索。
- 对于用户点击过的样本集 $ \mathcal{D}_{p} $ (正样本集)而言,它们大概率位于同一片区域。考虑到负样本集 $ \mathcal{D}_{n} $ 是随机抽取的,大概率来源不同的区域。
- Airbnb 发现这种样本的不平衡,在学习同一片区域房源的 Embedding 时会得到次优解。
- 解决办法也很简单,对于每个滑窗中的中心 lisitng其负样本的选择新增了与其位于同一个 market 的 listing。至此优化函数更新如下
$$
\underset{\theta}{\operatorname{argmax}} \sum_{(l, c) \in \mathcal{D}_{p}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime^{\prime}} \mathbf{v}_{l}}}+\sum_{(l, c) \in \mathcal{D}_{n}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{l}}} +\log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{l_b}}} +
\sum_{(l, m_n ) \in \mathcal{D}_{m_n}} \log \frac{1}{1+e^{\mathbf{v}_{m_n}^{\prime} \mathbf{v}_{l}}}
$$
+ $ \mathcal{D}_{m_n} $ 表示与滑窗中的中心 listing 位于同一区域的负样本集。
**2Listing Embedding 的冷启动**
- Airbnb 每天都有新的 listings 产生,而这些 listings 却没有 Embedding 向量表征。
- Airbnb 建议利用其他 listing 的现有的 Embedding 来为新的 listing 创建 Embedding。
- 在新的 listing 被创建后,房主需要提供如位置、价格、类型等在内的信息。
- 然后利用房主提供的房源信息为其查找3个相似的 listing并将它们 Embedding 的均值作为新 listing 的 Embedding表示。
- 这里的相似包含了位置最近10英里半径内房源类型相似价格区间相近。
- 通过该手段Airbnb 可以解决 98% 以上的新 listing 的 Embedding 冷启动问题。
**3Listing Embedding 的评估**
经过上述的两点对 Embedding 的改进后,为了评估改进后 listing Embedding 的效果。
- Airbnb 使用了800万的点击 session并将 Embedding 的维度设为32。
评估方法包括:
- 评估 Embedding 是否包含 listing 的地理位置相似性。
- 理论上,同一区域的房源相似性应该更高,不同区域房源相似性更低。
- Airbnb 利用 k-means 聚类将加利福尼亚州的房源聚成100个集群来验证类似位置的房源是否聚集在一起。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653056809526-15401069-6fff-40d8-ac5e-35871d3f254a.png" alt="img" style="zoom:50%;" />
- 评估不同类型、价格区间的房源之间的相似性。
- 简而言之,我们希望类型相同、价格区间一致的房源它们之间的相似度更高。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653056981037-18edee91-493a-4d5b-b066-57f0b200032d.png" alt="img" style="zoom:50%;" />
- 评估房源的隐式特征
- Airbnb 在训练房源listing的 Embedding时并没有用到房源的图像信息。
- 对于一些隐式信息,例如架构、风格、观感等是无法直接学习。
- 为了验证基于 Word2Vec 学习到的 Embedding是否隐含了它们在外观等隐式信息上的相似性Airbnb 内部开发了一款内部相似性探索工具。
- 大致原理就是,利用训练好的 Embedding 进行 K 近邻相似度检索。
- 如下,与查询房源在 Embedding 相似性高的其他房源,它们之间的外观风格也很相似。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653057336798-fd8451cb-84b6-40fb-8733-1e3d08a39793.png" alt="img" />
## User-type & Listing-type Embedding
前面提到的 Listing Embedding它是基于用户的点击 sessions 学习得到的。
- 同一个 session 内的点击时间间隔低于30分钟所以**它们更适合短期session 内的个性化需求**。
- 在用户搜索 session 期间,该方法有利于向用户展示与点击过的 listing 更相似的其他 listings 。
Airbnb 除了挖掘 Listing 的短期兴趣特征表示外,还对 User 和 Listing 的长期兴趣特征表示进行了探索。长期兴趣的探索是有利于 Airbnb 的业务发展。例如,用户当前在洛杉矶进行搜索,并且过去在纽约和伦敦预定过其他房源。那么,向用户推荐与之前预定过的 listing 相似的 listings 是更合适的。
- 长期兴趣的探索是基于 booking session用户的历史预定序列
- 与前面 Listing Embedding 的学习类似Airbnb 希望借助了 Skip-Gram 模型学习不同房源的 Embedding 表示。
但是,面临着如下的挑战:
- booking sessions $ \mathcal{S}_{b} $ 数据量的大小远远小于 click sessions $ \mathcal{S} $ ,因为预定本身就是一件低频率事件。
- 许多用户过去只预定了单个数量的房源无法从长度为1的 session 中学习 Embedding
- 对于任何实体,要基于 context 学习到有意义的 Embedding该实体至少在数据中出现5-10次。
- 但平台上大多数 listing_ids 被预定的次数低于5-10次。
- 用户连续两次预定的时间间隔可能较长,在此期间用户的行为(如价格敏感点)偏好可能会发生改变(由于职业的变化)。
为了解决该问题Airbnb 提出了基于 booking session 来学习用户和房源的 Type Embedding。给定一个 booking sessions 集合 $ \mathcal{S}_{b} $ ,其中包含了 $ M $ 个用户的 booking session
- 每个 booking session 表示为: $ s_{b}=\left(l_{b 1}, \ldots, l_{b M}\right) $
- 这里 $ l_{b1} $ 表示 listing_id学习到 Embedding 记作 $ \mathbf{v}_{l_{i d}} $
**1什么是Type Embedding **
在介绍 Type Embedding 之前,回顾一下 Listing Embedding
- 在 Listing Embedding 的学习中,只学习房源的 Embedding 表示,未学习用户的 Embedding。
- 对于 Listing Embedding与相应的 Lisitng ID 是一一对应的, 每个 Listing 它们的 Embedding 表示是唯一的。
对于 Type Embedding ,有如下的区别:
- 对于不同的 Listing它们的 Type Embedding **可能是相同的**User 同样如此)。
- Type Embedding 包含了 User-type Embedding 和 Listing-type Embedding。
为了更直接快速地了解什么是 Listing-type 和 User-type举个简单的例子
- 小王是一名西藏人性别男今年21岁就读于中国山东的蓝翔技校的挖掘机专业。
- 通常,对于不同的用户(如小王),给定一个 ID 编码,然后学习相应的 User Embedding。
- 但前面说了,用户数据过于稀疏,学习到的 User Embedding 特征表达能力不好。
- 另一种方式:利用小王身上的用户标签,先组合出他的 User-type然后学习 Embedding 表示。
- 小王的 User-type西藏人_男_学生_21岁_位置中国山东_南翔技校_挖掘机专业。
- 组合得到的 User-type 本质上可视为一个 Category 特征,然后学习其对应的 Embedding 表示。
下表给出了原文中Listing-type 和 User-type 包含的属性及属性的值:
- 所有的属性都基于一定的规则进行了分桶buckets。例如21岁被分桶到 20-30 岁的区间。
- 对于首次预定的用户,他的属性为 buckets 的前5行因为预定之前没有历史预定相关的信息。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653125260611-7d33731b-9167-4fcc-b83b-0a2407ea89ca.png" alt="img" style="zoom: 67%;" />
看到过前面那个简单的例子后,现在可以看一个原文的 Listing-type 的例子:
- 一个来自 US 的 Entire Home listinglt1它是一个二人间c21 床b1一个卧室bd21 个浴室bt2每晚平均价格为 60.8 美元pn3每晚每个客人的平均价格为 29.3 美元pg35 个评价r3所有均 5 星好评5s4100% 的新客接受率nu3
- 因此该 listing 根据上表规则可以映射为Listing-type = US_lt1_pn3_pg3_r3_5s4_c2_b1_bd2_bt2_nu3。
**2Type Embedding 的好处**
前面在介绍 Type Embedding 和 Listing Embedding 的区别时,提到过不同 User 或 Listing 他们的 Type 可能相同。
- 故 User-type 和 Listing-type 在一定程度上可以缓解数据稀疏性的问题。
- 对于 user 和 listing 而言,他们的属性可能会随着时间的推移而变化。
- 故它们的 Embedding 在时间上也具备了动态变化属性。
**3Type Embedding 的训练过程**
Type Embedding 的学习同样是基于 Skip-Gram 模型,但是有两点需要注意:
- 联合训练 User-type Embedding 和 Listing-type Embedding
- 如下图a在 booking session 中,每个元素代表的是 User-type, Listing-type组合。
- 为了学习在相同向量空间中的 User-type 和 Listing-type 的 EmbeddingsAirbnb 的做法是将 User-type 插入到 booking sessions 中。
- 形成一个User-type, Listing-type组成的元组序列这样就可以让 User-type 和 Listing-type 的在 session 中的相对位置保持一致了。
- User-type 的目标函数:
$$
\underset{\theta}{\operatorname{argmax}} \sum_{\left(u_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+e^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}}+\sum_{\left(u_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+e^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}}
$$
+ $ \mathcal{D}_{\text {book }} $ 中的 $ u_t $ (中心词)表示 User-type $ c $ (上下文)表示用户最近的预定过的 Listing-type。 $ \mathcal{D}_{\text {neg}} $ 中的 $ c $ 表示 negative Listing-type。
+ $ u_t $ 表示 User-type 的 Embedding $ \mathbf{v}_{c}^{\prime} $ 表示 Listing-type 的Embedding。
- Listing-type 的目标函数:
$$
\begin{aligned}
\underset{\theta}{\operatorname{argmax}} & \sum_{\left(l_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+\exp ^{-\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}}+\sum_{\left(l_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+\exp ^{\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}} \\
\end{aligned}
$$
+ 同理,不过窗口中的中心词为 Listing-type 上下文为 User-type。
- Explicit Negatives for Rejections
- 用户预定房源以后,还要等待房源主人的确认,主人可能接受或者拒绝客人的预定。
- 拒接的原因可能包括,客人星级评定不佳,资料不完整等。
- 前面学习到的 User-type Embedding 包含了客人的兴趣偏好Listing-type Embedding 包含了房源的属性特征。
- 但是,用户的 Embedding 未包含更容易被哪类房源主人拒绝的潜语义信息。
- 房源的 Embedding 未包含主人对哪类客人的拒绝偏好。
- 为了提高用户预定房源以后被主人接受的概率。同时降低房源主人拒绝客人的概率。Airbnb 在训练 User-type 和 Listing-type 的 Embedding时将用户预定后却被拒绝的样本加入负样本集中如下图b
- 更新后Listing-type 的目标函数:
$$
\begin{aligned}
\underset{\theta}{\operatorname{argmax}} & \sum_{\left(u_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+\exp ^{-\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}}+\sum_{\left(u_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+\exp ^{\mathbf{v}_{c}^{\prime} \mathbf{v}_{u_{t}}}} \\
&+\sum_{\left(u_{t}, l_{t}\right) \in \mathcal{D}_{\text {reject }}} \log \frac{1}{1+\exp ^{\mathrm{v}_{{l_{t}}}^{\prime} \mathrm{v}_{u_{t}}}}
\end{aligned}
$$
- 更新后User-type 的目标函数:
$$
\begin{aligned}
\underset{\theta}{\operatorname{argmax}} & \sum_{\left(l_{t}, c\right) \in \mathcal{D}_{b o o k}} \log \frac{1}{1+\exp ^{-\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}}+\sum_{\left(l_{t}, c\right) \in \mathcal{D}_{n e g}} \log \frac{1}{1+\exp ^{\mathrm{v}_{c}^{\prime} \mathbf{v}_{l_{t}}}} \\
&+\sum_{\left(l_{t}, u_{t}\right) \in \mathcal{D}_{\text {reject }}} \log \frac{1}{1+\exp ^{\mathrm{v}^{\prime}_{u_{t}} \mathrm{v}_{l_{t}}}}
\end{aligned}
$$
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653131985447-e033cb39-235b-4f46-9634-3b7faec284be.png" alt="img" style="zoom:50%;" />
# 实验部分
前面介绍了两种 Embedding 的生成方法,分别为 Listing Embedding 和 User-type & Listing-type Embedding。本节的实验部分将会介绍它们是如何被使用的。回顾 Airbnb 的业务背景,当用户查看一个房源时,他们有两种方式继续搜索:返回搜索结果页,或者查看房源详情页的「相似房源」。
## 相似房源检索
在给定学习到的 Listing Embedding通过计算其向量 $ v_l $ 和来自同一区域的所有 Listing 的向量 $ v_j $ 之间的余弦相似度,可以找到给定房源 $ l $ 的相似房源。
- 这些相似房源可在同一日期被预定(如果入住-离开时间已确定)。
- 相似度最高的 $ K $ 个房源被检索为相似房源。
- 计算是在线执行的,并使用我们的分片架构并行进行,其中部分 Embedding 存储在每个搜索机器上。
A/B 测试显示,基于 Embedding 的解决方案使「相似房源」点击率增加了21最终通过「相似房源」产生的预订增加了 4.9%。
## 实时个性化搜索排名
Airbnb 的搜索排名的大致流程为:
- 给定查询 $ q $ ,返回 $ K $ 条搜索结果。
- 基于排序模型 GBDT对预测结果进行排序。
- 将排序后的结果展示给用户。
**1Query Embedding**
原文中似乎并没有详细介绍 Airbnb 的搜索技术,在参考的博客中对他们的 Query Embedding 技术进行了描述。如下:
> Airbnb 对搜索的 Query 也进行了 Embedding和普通搜索引擎的 Embedding 不太相同的是,这里的 Embedding 不是用自然语言中的语料库去训练的,而是用 Search Session 作为关系训练数据,训练方式更类似于 Item2VecAirbnb 中 Queue Embedding 的一个很重要的作用是捕获用户模糊查询与相关目的地的关联,这样做的好处是可以使搜索结果不再仅仅是简单地进行关键字匹配,而是通过更深层次的语义和关系来找到关联信息。比如下图所示的使用 Query Embedding 之前和之后的两个示例Airbnb 非常人性化地在搜索栏的添加了自动补全,通过算法去 “猜想” 用户的真实目的,大大提高了用户的检索体验)
**2特征构建**
对于各查询,给定的训练数据形式为: $ D_s = \left(\mathbf{x}_{i}, y_{i}\right), i=1 \ldots K $ ,其中 $ K $ 表示查询返回的房源数量。
- $ \mathbf{x}_{i} $ 表示第 $ i $ 个房源结果的 vector containing features
- 由 listing featuresuser featuresquery features 以及 cross-features 组成。
- $ y_{i} \in\{0,0.01,0.25,1,-0.4\} $ 表示第 $ i $ 个结果的标签。
- $ y_i=1 $ 表示用户预定了房源,... $ y_i=-0.4 $ 表示房主拒绝了用户。
下面,介绍 Airbnb 是如何利用前面的两种种 Embedding 进行特征构建的。
- 如果用一句话来概括,这些基于 Embedding 的构建特征均为余弦相似度。
- 新构建的特征均为样本 $ \mathbf{x}_{i} $ 特征的一部分。
构建的特征如下表所示:
- 表中的 Embedding Features 包含了8种类型前6种类型的特征计算方式相同。
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653139981920-a100085b-007b-4a9c-9edf-74297e9115ae.png" alt="img" style="zoom:50%;" />
**① 基于 Listing Embedding Features 的特征构建**
- Airbnb 保留了用户过去两周6种不同类型的历史行为如下图
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653140202230-1f49e1dd-5c8c-4445-bd0b-9a17788a7b3f.png" alt="img" style="zoom:50%;" />
- 对于每个行为,还要将其按照 market (地域)进行划分。以 $ H_c $ 为例:
- 假如 $ H_c $ 包含了 New YorK 和 Los Angeles 两个 market 的点击记录,则划分为 $ H_c(NY) $ 和 $ H_c(LA) $ 。
- 计算候选房源和不同行为之间的相似度。
- 上述6种行为对应的相似度特征计算方式是相同的以 $ H_c $ 为例:
$$
\operatorname{EmbClickSim}\left(l_{i}, H_{c}\right)=\max _{m \in M} \cos \left(\mathbf{v}_{l_{i}}, \sum_{l_{h} \in m, l_{h} \in H_{c}} \mathbf{v}_{l_{h}}\right)
$$
- 其中, $ M $ 表示 market 的集合。第二项实际上为 Centroid EmbeddingEmbedding 的均值)。
- 除此之外Airbnb 还计算了候选房源的 Embedding 与 latest long click 的 Embedding 之间的余弦相似度。
$$
\operatorname{EmbLastLongClickSim }\left(l_{i}, H_{l c}\right)=\cos \left(\mathbf{v}_{l_{i}}, \mathbf{v}_{l_{\text {last }}}\right)
$$
**② 基于 User-type & Listing-type Embedding Features 的特征构建**
- 对于候选房源 $ l_i $ ,先查到其对应的 Listing-type $ l_t $ ,再找到用户的 User-type $ u_t $ 。
- 最后,计算 $ u_t $ 与 $ l_t $ 对应的 Embedding 之间的余弦相似度:
$$
\text { UserTypeListingTypeSim }\left(u_{t}, l_{t}\right)=\cos \left(\mathbf{v}_{u_{t}}, \mathbf{v}_{l_{t}}\right)
$$
为了验证上述特征的构建是否有效Airbnb 还做了特征重要性排序,如下表:
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1653142188111-1975bcc4-22a2-45cf-bff0-2783ecb00a0c.png" alt="img" style="zoom:50%;" />
**3)模型**
特征构建完成后,开始对模型进行训练。
- Airbnb 在搜索排名中使用的是 GBDT 模型,该模型是一个回归模型。
- 模型的训练数据包括数据集 $ \mathcal{D} $ 和 search labels 。
最后,利用 GBDT 模型来预测线上各搜索房源的在线分数。得到预测分数后,将按照降序的方式展现给用户。
# 参考链接
+ [Embedding 在大厂推荐场景中的工程化实践 - 卢明冬的博客 (lumingdong.cn)](https://lumingdong.cn/engineering-practice-of-embedding-in-recommendation-scenario.html#Airbnb)
+ [KDD'2018 Best Paper-Embedding技术在Airbnb实时搜索排序中的应用 (qq.com)](https://mp.weixin.qq.com/s/f9IshxX29sWg9NhSa7CaNg)
+ [再评Airbnb的经典Embedding论文 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/162163054)
+ [Airbnb爱彼迎房源排序中的嵌入(Embedding)技术 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/43295545)

View File

@@ -0,0 +1,629 @@
# 双塔召回模型
---
双塔模型在推荐领域中是一个十分经典的模型无论是在召回还是粗排阶段都会是首选。这主要是得益于双塔模型结构使得能够在线预估时满足低延时的要求。但也是因为其模型结构的问题使得无法考虑到user和item特之间的特征交叉使得影响模型最终效果因此很多工作尝试调整经典双塔模型结构在保持在线预估低延时的同时保证双塔两侧之间有效的信息交叉。下面针对于经典双塔模型以及一些改进版本进行介绍。
## 经典双塔模型
DSSM(Deep Structured Semantic Model)是由微软研究院于CIKM在2013年提出的一篇工作该模型主要用来解决NLP领域语义相似度任务 利用深度神经网络将文本表示为低维度的向量用来提升搜索场景下文档和query匹配的问题。DSSM 模型的原理主要是通过用户搜索行为中query 和 doc 的日志数据通过深度学习网络将query和doc映射到到共同维度的语义空间中通过最大化query和doc语义向量之 间的余弦相似度,从而训练得到隐含语义模型,即 query 侧特征的 embedding 和 doc 侧特征的 embedding进而可以获取语句的低维 语义向量表达 sentence embedding可以预测两句话的语义相似度。模型结构如下所示
<div align=center>
<img src="https://pic4.zhimg.com/v2-7f75cc71f5e959d6efa95289d2f5ac13_r.jpg" style="zoom:45%;"/>
</div>
从上图可以看出该网络结构比较简单是一个由几层DNN组成网络我们将要搜索文本(Query)和要匹配的文本(Document)的 embedding 输入到网络,网络输出为 128 维的向量,然后通过向量之间计算余弦相似度来计算向量之间距离,可以看作每一个 query 和 document 之间相似分数,然后在做 softmax。
而在推荐系统中最为关键的问题是如何做好用户与item的匹配问题因此对于推荐系统中DSSM模型的则是为 user 和 item 分别构建独立的子网络塔式结构利用user和item的曝光或点击日期进行训练最终得到user侧的embedding和item侧的embedding。因此在推荐系统中常见的模型结构如下所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220522103456450.png" style="zoom:60%;"/>
</div>
从模型结构上来看主要包括两个部分user侧塔和item侧塔对于每个塔分别是一个DNN结构。通过两侧的特征输入通过DNN模块到user和item的embedding然后计算两者之间的相似度(常用內积或者余弦值,下面会说这两种方式的联系和区别)因此对于user和item两侧最终得到的embedding维度需要保持一致即最后一层全连接层隐藏单元个数相同。
在召回模型中将这种检索行为视为多类分类问题类似于YouTubeDNN模型。将物料库中所有的item视为一个类别因此损失函数需要计算每个类的概率值
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220522110742879.png" style="zoom:60%;"/>
</div>
其中$s(x,y)$表示两个向量的相似度,$P(y|x;\theta)$表示预测类别的概率,$M$表示物料库所有的item。但是在实际场景中由于物料库中的item数量巨大在计算上式时会十分的耗时因此会采样一定的数量的负样本来近似计算后面针对负样本的采样做一些简单介绍。
以上就是推荐系统中经典的双塔模型,之所以在实际应用中非常常见,是因为**在海量的候选数据进行召回的场景下,速度很快,效果说不上极端好,但一般而言效果也够用了**。之所以双塔模型在服务时速度很快,是因为模型结构简单(两侧没有特征交叉),但这也带来了问题,双塔的结构无法考虑两侧特征之间的交互信息,**在一定程度上牺牲掉模型的部分精准性**。例如在精排模型中来自user侧和item侧的特征会在第一层NLP层就可以做细粒度的特征交互而对于双塔模型user侧和item侧的特征只会在最后的內积计算时发生这就导致很多有用的信息在经过DNN结构时就已经被其他特征所模糊了因此双塔结构由于其结构问题先天就会存在这样的问题。下面针对这个问题来看看一下现有模型的解决思路。
## SENet双塔模型
SENet由Momenta在2017年提出当时是一种应用于图像处理的新型网络结构。后来张俊林大佬将SENet引入了精排模型[FiBiNET](https%3A//arxiv.org/abs/1905.09433)中其作用是为了将大量长尾的低频特征抛弃弱化不靠谱低频特征embedding的负面影响强化高频特征的重要作用。那SENet结构到底是怎么样的呢为什么可以起到特征筛选的作用
<div align=center>
<img src="https://camo.githubusercontent.com/ccf54fc4fcac46667d451f22368e31cf86855bc8bfbff40b7675d524bc899ecf/68747470733a2f2f696d672d626c6f672e6373646e696d672e636e2f32303231303730333136313830373133392e706e673f782d6f73732d70726f636573733d696d6167652f77617465726d61726b2c747970655f5a6d46755a33706f5a57356e6147567064476b2c736861646f775f31302c746578745f6148523063484d364c7939696247396e4c6d4e7a5a473475626d56304c336431656d6876626d6478615746755a773d3d2c73697a655f312c636f6c6f725f4646464646462c745f3730237069635f63656e746572" style="zoom:80%;"/>
</div>
从上图可以看出SENET主要分为三个步骤Squeeze, Excitation, Re-weight
- Squeeze阶段我们对每个特征的Embedding向量进行数据压缩与信息汇总即在Embedding维度计算均值
$$z_i = F_{sq}(e_i) = \frac{1}{k} \sum_{t=1}^k e_i^{(t)}$$
其中k表示Embedding的维度Squeeze阶段是将每个特征的Squeeze转换成单一的数值。
- Excitation阶段这阶段是根据上一阶段得到的向量进行缩放即将上阶段的得到的 $1 \times f$ 的向量$Z$先压缩成 $1 \times \frac{f}{r}$ 长度,然后在放回到 $1 \times f$ 的维度,其中$r$表示压缩的程度。这个过程的具体操作就是经过两层DNN。
$$A = F_{ex}(Z) = \sigma_2(W_2\sigma_1(W_1Z)) $$
该过程可以理解为对于当前所有输入的特征通过相互发生关联来动态地判断哪些特征重要哪些特征不重要而这体现在Excitation阶段的输出结果 $A$,其反应每个特征对应的重要性权重。
- Re-weight阶段是将Excitation阶段得到的每个特征对应的权重 $A$ 再乘回到特征对应的Embedding里就完成了对特征重要性的加权操作。
$$V=F_{ReWeight }(A,E)=[a_1 \cdot e_1,⋯,a_f \cdot e_f]=[v_1,⋯,v_f]$$
以上简单的介绍了一下SENet结构可以发现这种结构可以通过对特征embedding先压缩再交互再选择进而实现特征选择的效果。
此外张俊林大佬还将SENet应用于双塔模型中[(SENet双塔模型在推荐领域召回粗排的应用及其它)](https://zhuanlan.zhihu.com/p/358779957),模型结构如下所示:
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220522152508824.png" style="zoom:70%;"/>
</div>
从上图可以发现具体地是将双塔中的user塔和Item侧塔的特征输入部分加上一个SENet模块通过SENet网络动态地学习这些特征的重要性通过小权重抑制噪音或者无效低频特征通过大权重放大重要特征影响的目的。
之所以SENet双塔模型是有效的呢张俊林老师的解释是双塔模型的问题在于User侧特征和Item侧特征交互太晚在高层交互会造成细节信息也就是具体特征信息的损失影响两侧特征交叉的效果。而SENet模块在最底层就进行了特征的过滤使得很多无效低频特征即使被过滤掉这样更多有用的信息被保留到了双塔的最高层使得两侧的交叉效果很好同时由于SENet模块选择出更加重要的信息使得User侧和Item侧特征之间的交互表达方面增强了DNN双塔的能力。
因此SENet双塔模型主要是从特征选择的角度提高了两侧特征交叉的有效性减少了噪音对有效信息的干扰进而提高了双塔模型的效果。此外除了这样的方式还可以通过增加通道的方式来增强两侧的信息交互。即对于user和item两侧不仅仅使用一个DNN结构而是可以通过不同结构(如FMDCN等)来建模user和item的自身特征交叉例如下图所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/v2-9c2f7a30c6cadc47be23d6797f095b61_b.jpg" style="zoom:80%;"/>
</div>
这样对于user和item侧会得到多个embedding类似于多兴趣的概念。通过得到的多个user和item的embedding然后分别计算余弦值再相加(两侧的Embedding维度需要对齐),进而增加了双塔两侧的信息交互。而这种方法在腾讯进行过尝试,他们提出的“并联”双塔就是按照这样的思路,感兴趣的可以了解一下。
## 多目标的双塔模型
现如今多任务学习在实际的应用场景也十分的常见主要是因为实际场景中业务复杂往往有很多的衡量指标例如点击评论收藏关注转发等。在多任务学习中往往会针对不同的任务使用一个独有的tower然后优化不同任务损失。那么针对双塔模型应该如何构建多任务学习框架呢
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220523113206177.png" style="zoom:60%;"/>
</div>
如上图所示在user侧和item侧分别通过多个通道(DNN结构)为每个任务得到一个user embedding和item embedding然后针对不同的目标分别计算user 和 item 的相似度,并计算各个目标的损失,最后的优化目标可以是多个任务损失之和,或者使用多任务学习中的动态损失权重。
这种模型结构可以针对多目标进行联合建模通过多任务学习的结构一方面可以利用不同任务之间的信息共享为一些稀疏特征提供其他任务中的迁移信息另一方面可以在召回时直接使用一个模型得到多个目标预测解决了多个模型维护困难的问题。也就是说在线上通过这一个模型就可以同时得到多个指标例如视频场景一个模型就可以直接得到点赞品论转发等目标的预测值进而通过这些值计算分数获得最终的Top-K召回结果。
## 双塔模型的细节
关于双塔模型,其模型结构相比排序模型来说很简单,没有过于复杂的结构。但除了结构,有一些细节部分容易被忽视,而这些细节部分往往比模型结构更加重要,因此下面主要介绍一下双塔模型中需要主要的一些细节问题。
### 归一化与温度系数
在[Google的双塔召回模型](https://dl.acm.org/doi/pdf/10.1145/3298689.3346996)中重点介绍了两个trick将user和item侧输出的embedding进行归一化以及对于內积值除以温度系数实验证明这两种方式可以取得十分好的效果。那为什么这两种方法会使得模型的效果更好呢
- 归一化对user侧和item侧的输入embedding进行L2归一化
$$u(x,\theta) \leftarrow = \frac{u(x,\theta)}{||u(x,\theta)||_2}$$
$$v(x,\theta) \leftarrow = \frac{v(x,\theta)}{||v(x,\theta)||_2}$$
- 温度系数:在归一化之后的向量计算內积之后,除以一个固定的超参 $r$ ,论文中命名为温度系数。
$$s(u,v) = \frac{<u(x,\theta), v(x,\theta)>}{r}$$
那为什么需要进行上述的两个操作呢?
- 归一化的操作主要原因是因为向量点积距离是非度量空间,不满足三角不等式,而归一化的操作使得点击行为转化成了欧式距离。
首先向量点积是向量对应位相乘并求和,即向量內积。而向量內积**不保序**,例如空间上三个点(A=(10,0),B=(0,10),C=(11,0)),利用向量点积计算的距离 dis(A,B) < dis(A,C),但是在欧式距离下这是错误的。而归一化的操作则会让向量点积转化为欧式距离,例如 $user_{emb}$ 表示归一化user的embedding $item_{emb}$ 表示归一化 item 的embedding那么两者之间的欧式距离 $||user_{emb} - item_{emb}||$ 如下, 可以看出归一化的向量点积已转化成了欧式距离。
$$||user_{emb} - item_{emb}||=\sqrt{||user_{emb}||^2+||item_{emb}||^2-2<user_{emb},item_{emb}>} = \sqrt{2-2<user_{emb},item_{emb}>}$$
那没啥非要转为欧式距离呢这是因为ANN一般是通过计算欧式距离进行检索这样转化成欧式空间保证训练和检索一致。
### 模型的应用
在实际的工业应用场景中,分为离线训练和在线服务两个环节。
- 在离线训练阶段同过训练数据训练好模型参数。然后将候选库中所有的item集合离线计算得到对应的embedding并存储进ANN检索系统比如faiss。为什么将离线计算item集合主要是因为item的会相对稳定不会频繁的变动而对于用户而言如果将用户行为作为user侧的输入那么user的embedding会随着用户行为的发生而不断变化因此对于user侧的embedding需要实时的计算。
- 在线服务阶段正是因为用户的行为变化需要被即使的反应在用户的embedding中以更快的反应用户当前的兴趣即可以实时地体现用户即时兴趣的变化。因此在线服务阶段需要实时的通过拼接用户特征输入到user侧的DNN当中进而得到user embedding在通过user embedding去 faiss中进行ANN检索召回最相似的K个item embedding。
可以看到双塔模型结构十分的适合实际的应用场景,在快速服务的同时,还可以更快的反应用户即时兴趣的变化。
### 负样本采样
相比于排序模型而言召回阶段的模型除了在结构上的不同在样本选择方面也存在着很大的差异可以说样本的选择很大程度上会影响召回模型的效果。对于召回模型而言其负样本并不能和排序模型一样只使用展现未点击样本因为召回模型在线上面临的数据分布是全部的item而不仅仅是展现未点击样本。因此在离线训练时需要让其保证和线上分布尽可能一致所以在负样本的选择样要尽可能的增加很多未被曝光的item。下面简单的介绍一些常见的采样方法
#### 全局随机采样
全局随机采样指从全局候选item里面随机抽取一定数量item做为召回模型的负样本。这样的方式实现简单也可以让模型尽可能的和线上保持一致的分布尽可能的多的让模型对于全局item有区分的能力。例如YoutubeDNN算法。
但这样的方式也会存在一定的问题由于候选的item属于长尾数据即“八二定律”也就是说少数热门物料占据了绝大多数的曝光与点击。因此存随机的方式只能让模型在学到粗粒度上差异对一些尾部item并不友好。
#### 全局随机采样 + 热门打压
针对于全局随机采样的不足一个直观的方法是针对于item的热度item进行打压即对于热门的item很多用户可能会点击需要进行一定程度的欠采样使得模型更加关注一些非热门的item。 此外在进行负样本采样时应该对一些热门item进行适当的过采样这可以尽可能的让模型对于负样本有更加细粒度的区分。例如在word2vec中负采样方法是根据word的频率对 negative words进行随机抽样降 低 negative words 量级。
之所以热门item做负样本时要适当过采样增加负样本难度。因为对于全量的item模型可以轻易的区分一些和用户兴趣差异性很大的item难点在于很难区分一些和用户兴趣相似的item。因此在训练模型时需要适当的增加一些难以区分的负样本来提升模型面对相似item的分区能力。
#### Hard Negative增强样本
Hard Negative指的是选取一部分匹配度适中的item能够增加模型在训练时的难度提升模型能学习到item之间细粒度上的差异。至于 如何选取在工业界也有很多的解决方案。
例如Airbnb根据业务逻辑来采样一些hard negative (增加与正样本同城的房间作为负样本,增强了正负样本在地域上的相似性;增加与正样本同城的房间作为负样本,增强了正负样本在地域上的相似性,),详细内容可以查看[原文](https://www.kdd.org/kdd2018/accepted-papers/view/real-time-personalization-using-embeddings-for-search-ranking-at-airbnb)
例如百度和facebook依靠模型自己来挖掘Hard Negative都是用上一版本的召回模型筛选出"没那么相似"的<user,item>对,作为额外负样本,用于训练下一版本召回模型。 详细可以查看[Mobius](http://research.baidu.com/Public/uploads/5d12eca098d40.pdf) 和 [EBR](https://arxiv.org/pdf/2006.11632.pdf)
#### Batch内随机选择负采样
基于batch的负采样方法是将batch内选择除了正样本之外的其它Item做为负样本其本质就是利用其他样本的正样本随机采样作为自己的负样本。这样的方法可以作为负样本的选择方式特别是在如今分布式训练以及增量训练的场景中是一个非常值得一试的方法。但这种方法也存在他的问题基于batch的负采样方法受batch的影响很大当batch的分布与整体的分布差异很大时就会出现问题同时batch内负采样也会受到热门item的影响需要考虑打压热门item的问题。至于解决的办法Google的双塔召回模型中给出了答案想了解的同学可以去学习一下。
总的来说负样本的采样方法,不光是双塔模型应该重视的工作,而是所有召回模型都应该仔细考虑的方法。
## 代码实现
下面使用一点资讯提供的数据实践一下DSSM召回模型。该模型的实现主要参考DeepCtr和DeepMatch模块。
### 模型训练数据
1、数据预处理
用户侧主要包含一些用户画像属性(用户性别,年龄,所在省市,使用设备及系统);新闻侧主要包括新闻的创建时间,题目,所属 一级、二级类别,题片个数以及关键词。下面主要是对着两部分数据的简单处理:
```python
def proccess(file):
if file=="user_info_data_5w.csv":
data = pd.read_csv(file_path + file, sep="\t",index_col=0)
data["age"] = data["age"].map(lambda x: get_pro_age(x))
data["gender"] = data["gender"].map(lambda x: get_pro_age(x))
data["province"]=data["province"].fillna(method='ffill')
data["city"]=data["city"].fillna(method='ffill')
data["device"] = data["device"].fillna(method='ffill')
data["os"] = data["os"].fillna(method='ffill')
return data
elif file=="doc_info.txt":
data = pd.read_csv(file_path + file, sep="\t")
data.columns = ["article_id", "title", "ctime", "img_num","cate","sub_cate", "key_words"]
select_column = ["article_id", "title_len", "ctime", "img_num","cate","sub_cate", "key_words"]
# 去除时间为nan的新闻以及除脏数据
data= data[(data["ctime"].notna()) & (data["ctime"] != 'Android')]
data['ctime'] = data['ctime'].astype('str')
data['ctime'] = data['ctime'].apply(lambda x: int(x[:10]))
data['ctime'] = pd.to_datetime(data['ctime'], unit='s', errors='coerce')
# 这里存在nan字符串和异常数据
data["sub_cate"] = data["sub_cate"].astype(str)
data["sub_cate"] = data["sub_cate"].apply(lambda x: pro_sub_cate(x))
data["img_num"] = data["img_num"].astype(str)
data["img_num"] = data["img_num"].apply(photoNums)
data["title_len"] = data["title"].apply(lambda x: len(x) if isinstance(x, str) else 0)
data["cate"] = data["cate"].fillna('其他')
return data[select_column]
```
2、构造训练样本
该部分主要是根据用户的交互日志中前6天的数据作为训练集第7天的数据作为测试集来构造模型的训练测试样本。
```python
def dealsample(file, doc_data, user_data, s_data_str = "2021-06-24 00:00:00", e_data_str="2021-06-30 23:59:59", neg_num=5):
# 先处理时间问题
data = pd.read_csv(file_path + file, sep="\t",index_col=0)
data['expo_time'] = data['expo_time'].astype('str')
data['expo_time'] = data['expo_time'].apply(lambda x: int(x[:10]))
data['expo_time'] = pd.to_datetime(data['expo_time'], unit='s', errors='coerce')
s_date = datetime.datetime.strptime(s_data_str,"%Y-%m-%d %H:%M:%S")
e_date = datetime.datetime.strptime(e_data_str,"%Y-%m-%d %H:%M:%S") + datetime.timedelta(days=-1)
t_date = datetime.datetime.strptime(e_data_str,"%Y-%m-%d %H:%M:%S")
# 选取训练和测试所需的数据
all_data_tmp = data[(data["expo_time"]>=s_date) & (data["expo_time"]<=t_date)]
# 处理训练数据集 防止穿越样本
# 1. merge 新闻信息,得到曝光时间和新闻创建时间; inner join 去除doc_data之外的新闻
all_data_tmp = all_data_tmp.join(doc_data.set_index("article_id"),on="article_id",how='inner')
# 发现还存在 ctime大于expo_time的交互存在 去除这部分错误数据
all_data_tmp = all_data_tmp[(all_data_tmp["ctime"]<=all_data_tmp["expo_time"])]
# 2. 去除与新闻的创建时间在测试数据时间内的交互 ()
train_data = all_data_tmp[(all_data_tmp["expo_time"]>=s_date) & (all_data_tmp["expo_time"]<=e_date)]
train_data = train_data[(train_data["ctime"]<=e_date)]
print("有效的样本数:",train_data["expo_time"].count())
# 负采样
if os.path.exists(file_path + "neg_sample.pkl") and os.path.getsize(file_path + "neg_sample.pkl"):
neg_samples = pd.read_pickle(file_path + "neg_sample.pkl")
# train_neg_samples.insert(loc=2, column="click", value=[0] * train_neg_samples["user_id"].count())
else:
# 进行负采样的时候对于样本进行限制,只对一定时间范围之内的样本进行负采样
doc_data_tmp = doc_data[(doc_data["ctime"]>=datetime.datetime.strptime("2021-06-01 00:00:00","%Y-%m-%d %H:%M:%S"))]
neg_samples = negSample_like_word2vec(train_data, doc_data_tmp[["article_id"]].values, user_data[["user_id"]].values, neg_num=neg_num)
neg_samples = pd.DataFrame(neg_samples, columns= ["user_id","article_id","click"])
neg_samples.to_pickle(file_path + "neg_sample.pkl")
train_pos_samples = train_data[train_data["click"] == 1][["user_id","article_id", "expo_time", "click"]] # 取正样本
neg_samples_df = train_data[train_data["click"] == 0][["user_id","article_id", "click"]]
train_neg_samples = pd.concat([neg_samples_df.sample(n=train_pos_samples["click"].count()) ,neg_samples],axis=0) # 取负样本
print("训练集正样本数:",train_pos_samples["click"].count())
print("训练集负样本数:",train_neg_samples["click"].count())
train_data_df = pd.concat([train_neg_samples,train_pos_samples],axis=0)
train_data_df = train_data_df.sample(frac=1) # shuffle
print("训练集总样本数:",train_data_df["click"].count())
test_data_df = all_data_tmp[(all_data_tmp["expo_time"]>e_date) & (all_data_tmp["expo_time"]<=t_date)][["user_id","article_id", "expo_time", "click"]]
print("测试集总样本数:",test_data_df["click"].count())
print("测试集总样本数:",test_data_df["click"].count())
all_data_df = pd.concat([train_data_df, test_data_df],axis=0)
print("总样本数:",all_data_df["click"].count())
return all_data_df
```
3、负样本采样
该部分主要采用基于item的展现次数对全局item进行负采样。
```python
def negSample_like_word2vec(train_data, all_items, all_users, neg_num=10):
"""
为所有item计算一个采样概率根据概率为每个用户采样neg_num个负样本返回所有负样本对
1. 统计所有item在交互中的出现频次
2. 根据频次进行排序并计算item采样概率频次出现越多采样概率越低打压热门item
3. 根据采样概率,利用多线程为每个用户采样 neg_num 个负样本
"""
pos_samples = train_data[train_data["click"] == 1][["user_id","article_id"]]
pos_samples_dic = {}
for idx,u in enumerate(pos_samples["user_id"].unique().tolist()):
pos_list = list(pos_samples[pos_samples["user_id"] == u]["article_id"].unique().tolist())
if len(pos_list) >= 30: # 30是拍的 需要数据统计的支持确定
pos_samples_dic[u] = pos_list[30:]
else:
pos_samples_dic[u] = pos_list
# 统计出现频次
article_counts = train_data["article_id"].value_counts()
df_article_counts = pd.DataFrame(article_counts)
dic_article_counts = dict(zip(df_article_counts.index.values.tolist(),df_article_counts.article_id.tolist()))
for item in all_items:
if item[0] not in dic_article_counts.keys():
dic_article_counts[item[0]] = 0
# 根据频次排序, 并计算每个item的采样概率
tmp = sorted(list(dic_article_counts.items()), key=lambda x:x[1], reverse=True) # 降序
n_articles = len(tmp)
article_prob = {}
for idx, item in enumerate(tmp):
article_prob[item[0]] = cal_pos(idx, n_articles)
# 为每个用户进行负采样
article_id_list = [a[0] for a in article_prob.items()]
article_pro_list = [a[1] for a in article_prob.items()]
pos_sample_users = list(pos_samples_dic.keys())
all_users_list = [u[0] for u in all_users]
print("start negative sampling !!!!!!")
pool = multiprocessing.Pool(core_size)
res = pool.map(SampleOneProb((pos_sample_users,article_id_list,article_pro_list,pos_samples_dic,neg_num)), tqdm(all_users_list))
pool.close()
pool.join()
neg_sample_dic = {}
for idx, u in tqdm(enumerate(all_users_list)):
neg_sample_dic[u] = res[idx]
return [[k,i,0] for k,v in neg_sample_dic.items() for i in v]
```
### DSSM 模型
1、模型构建
模型构建部分主要是将输入的user 特征以及 item 特征处理完之后分别送入两侧的DNN结构。
```python
def DSSM(user_feature_columns, item_feature_columns, dnn_units=[64, 32],
temp=10, task='binary'):
# 构建所有特征的Input层和Embedding层
feature_encode = FeatureEncoder(user_feature_columns + item_feature_columns)
feature_input_layers_list = list(feature_encode.feature_input_layer_dict.values())
# 特征处理
user_dnn_input, item_dnn_input = process_feature(user_feature_columns,\
item_feature_columns, feature_encode)
# 构建模型的核心层
if len(user_dnn_input) >= 2:
user_dnn_input = Concatenate(axis=1)(user_dnn_input)
else:
user_dnn_input = user_dnn_input[0]
if len(item_dnn_input) >= 2:
item_dnn_input = Concatenate(axis=1)(item_dnn_input)
else:
item_dnn_input = item_dnn_input[0]
user_dnn_input = Flatten()(user_dnn_input)
item_dnn_input = Flatten()(item_dnn_input)
user_dnn_out = DNN(dnn_units)(user_dnn_input)
item_dnn_out = DNN(dnn_units)(item_dnn_input)
# 计算相似度
scores = CosinSimilarity(temp)([user_dnn_out, item_dnn_out]) # (B,1)
# 确定拟合目标
output = PredictLayer()(scores)
# 根据输入输出构建模型
model = Model(feature_input_layers_list, output)
return model
```
2、CosinSimilarity相似度计算
在余弦相似度计算,主要是注意使用归一化以及温度系数的技巧。
```python
def call(self, inputs, **kwargs):
"""inputs 是一个列表"""
query, candidate = inputs
# 计算两个向量的二范数
query_norm = tf.norm(query, axis=self.axis) # (B, 1)
candidate_norm = tf.norm(candidate, axis=self.axis)
# 计算向量点击,即內积操作
scores = tf.reduce_sum(tf.multiply(query, candidate), axis=-1)#(B,1)
# 相似度除以二范数, 防止除零
scores = tf.divide(scores, query_norm * candidate_norm + 1e-8)
# 对score的范围限制到(-1, 1)之间
scores = tf.clip_by_value(scores, -1, 1)
# 乘以温度系数
score = scores * self.temperature
return score
```
### 模型训练
1、稀疏特征编码
该部分主要是针对于用户侧和新闻侧的稀疏特征进行编码并将训练样本join上两侧的特征。
```python
# 数据和测试数据
data, user_data, doc_data = get_all_data()
# 1.Label Encoding for sparse features,and process sequence features with `gen_date_set` and `gen_model_input`
feature_max_idx = {}
feature_encoder = {}
user_sparse_features = ["user_id", "device", "os", "province", "city", "age", "gender"]
for feature in user_sparse_features:
lbe = LabelEncoder()
user_data[feature] = lbe.fit_transform(user_data[feature]) + 1
feature_max_idx[feature] = user_data[feature].max() + 1
feature_encoder[feature] = lbe
doc_sparse_features = ["article_id", "cate", "sub_cate"]
doc_dense_features = ["title_len", "img_num"]
for feature in doc_sparse_features:
lbe = LabelEncoder()
if feature in ["cate","sub_cate"]:
# 这里面会出现一些float的数据导致无法编码
doc_data[feature] = lbe.fit_transform(doc_data[feature].astype(str)) + 1
else:
doc_data[feature] = lbe.fit_transform(doc_data[feature]) + 1
feature_max_idx[feature] = doc_data[feature].max() + 1
feature_encoder[feature] = lbe
data["article_id"] = feature_encoder["article_id"].transform(data["article_id"].tolist())
data["user_id"] = feature_encoder["user_id"].transform(data["user_id"].tolist())
# join 用户侧和新闻侧的特征
data = data.join(user_data.set_index("user_id"), on="user_id", how="inner")
data = data.join(doc_data.set_index("article_id"), on="article_id", how="inner")
sparse_features = user_sparse_features + doc_sparse_features
dense_features = doc_dense_features
features = sparse_features + dense_features
mms = MinMaxScaler(feature_range=(0, 1))
data[dense_features] = mms.fit_transform(data[dense_features])
```
2、配置特征以及模型训练
构建模型所需的输入特征同时构建DSSM模型及训练。
```python
embedding_dim = 8
user_feature_columns = [SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
SparseFeat("gender", feature_max_idx['gender'], embedding_dim),
SparseFeat("age", feature_max_idx['age'], embedding_dim),
SparseFeat("device", feature_max_idx['device'], embedding_dim),
SparseFeat("os", feature_max_idx['os'], embedding_dim),
SparseFeat("province", feature_max_idx['province'], embedding_dim),
SparseFeat("city", feature_max_idx['city'], embedding_dim), ]
item_feature_columns = [SparseFeat('article_id', feature_max_idx['article_id'], embedding_dim),
DenseFeat('img_num', 1),
DenseFeat('title_len', 1),
SparseFeat('cate', feature_max_idx['cate'], embedding_dim),
SparseFeat('sub_cate', feature_max_idx['sub_cate'], embedding_dim)]
model = DSSM(user_feature_columns, item_feature_columns,
user_dnn_hidden_units=(32, 16, embedding_dim), item_dnn_hidden_units=(32, 16, embedding_dim)) # FM(user_feature_columns,item_feature_columns)
model.compile(optimizer="adagrad", loss = "binary_crossentropy", metrics=[tf.keras.metrics.Recall(), tf.keras.metrics.Precision()] ) #
history = model.fit(train_model_input, train_label, batch_size=256, epochs=4, verbose=1, validation_split=0.2, )
```
3、生成embedding用于召回
利用训练过的模型获取所有item的embeddings同时获取所有测试集的user embedding保存之后用于之后的召回工作。
```python
all_item_model_input = {"article_id": item_profile['article_id'].values,
"img_num": item_profile['img_num'].values,
"title_len": item_profile['title_len'].values,
"cate": item_profile['cate'].values,
"sub_cate": item_profile['sub_cate'].values,}
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
item_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
user_embs = user_embedding_model.predict(test_user_model_input, batch_size=2 ** 12)
item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
user_idx_2_rawid, doc_idx_2_rawid = {}, {}
for i in range(len(user_embs)):
user_idx_2_rawid[i] = test_user_model_input["user_id"][i]
for i in range(len(item_embs)):
doc_idx_2_rawid[i] = all_item_model_input["article_id"][i]
# 保存一份
pickle.dump((user_embs, user_idx_2_rawid, feature_encoder["user_id"]), open(file_path + 'user_embs.pkl', 'wb'))
pickle.dump((item_embs, doc_idx_2_rawid, feature_encoder["article_id"]), open(file_path + 'item_embs.pkl', 'wb'))
```
### ANN召回
1、为测试集用户召回
通过annoy tree为所有的item构建索引并通过测试集中所有的user embedding为每个用户召回一定数量的item。
```python
def get_DSSM_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk):
"""近邻检索这里用annoy tree"""
# 把doc_embs构建成索引树
f = user_embs.shape[1]
t = AnnoyIndex(f, 'angular')
for i, v in enumerate(doc_embs):
t.add_item(i, v)
t.build(10)
# 每个用户向量, 返回最近的TopK个item
user_recall_items_dict = collections.defaultdict(dict)
for i, u in enumerate(user_embs):
recall_doc_scores = t.get_nns_by_vector(u, topk, include_distances=True)
# recall_doc_scores是(([doc_idx], [scores])) 这里需要转成原始doc的id
raw_doc_scores = list(recall_doc_scores)
raw_doc_scores[0] = [doc_idx_2_rawid[i] for i in raw_doc_scores[0]]
# 转换成实际用户id
user_recall_items_dict[user_idx_2_rawid[i]] = dict(zip(*raw_doc_scores))
# 默认是分数从小到大排的序, 这里要从大到小
user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
pickle.dump(user_recall_items_dict, open(file_path + 'DSSM_u2i_dict.pkl', 'wb'))
return user_recall_items_dict
```
2、测试召回结果
为测试集用户的召回结果进行测试。
```python
user_recall_items_dict = get_DSSM_recall_res(user_embs, item_embs, user_idx_2_rawid, doc_idx_2_rawid, topk=TOP_NUM)
test_true_items = {line[0]:line[1] for line in test_set}
s = []
precision = []
for i, uid in tqdm(enumerate(list(user_recall_items_dict.keys()))):
# try:
pred = [x for x, _ in user_recall_items_dict[uid]]
filter_item = None
recall_score = recall_N(test_true_items[uid], pred, N=TOP_NUM)
s.append(recall_score)
precision_score = precision_N(test_true_items[uid], pred, N=TOP_NUM)
precision.append(precision_score)
print("recall", np.mean(s))
print("precision", np.mean(precision))
```
## 参考
- [负样本为王评Facebook的向量化召回算法](https://zhuanlan.zhihu.com/p/165064102)
- [多目标DSSM召回实战](https://mp.weixin.qq.com/s/aorZ43WozKrD2AudR6AnOg)
- [召回模型中的负样本构造](https://zhuanlan.zhihu.com/p/358450850)
- [Youtube双塔模型](https://dl.acm.org/doi/10.1145/3298689.3346996)
- [张俊林SENet双塔模型在推荐领域召回粗排的应用及其它](https://zhuanlan.zhihu.com/p/358779957)
- [双塔召回模型的前世今生(上篇)](https://zhuanlan.zhihu.com/p/430503952)
- [双塔召回模型的前世今生(下篇)](https://zhuanlan.zhihu.com/p/441597009)
- [Learning Deep Structured Semantic Models for Web Search using Clickthrough Data](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/cikm2013_DSSM_fullversion.pdf)

View File

@@ -0,0 +1,124 @@
# FM 模型结构
FM 模型用于排序时,模型的公式定义如下:
$$
\hat{y}(\mathbf{x}):=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+\sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}
$$
+ 其中,$i$ 表示特征的序号,$n$ 表示特征的数量;$x_i \in \mathbb{R}$ 表示第 $i$ 个特征的值。
+ $v_i,v_j \in \mathbb{R}^{k} $ 分别表示特征 $x_i,x_j$ 对应的隐语义向量Embedding向量 $\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle:=\sum_{f=1}^{k} v_{i, f} \cdot v_{j, f}$ 。
+ $w_0,w_i\in \mathbb{R}$ 均表示需要学习的参数。
**FM 的一阶特征交互**
在 FM 的表达式中,前两项为特征的一阶交互项。将其拆分为用户特征和物品特征的一阶特征交互项,如下:
$$
\begin{aligned}
& w_{0}+\sum_{i=1}^{n} w_{i} x_{i} \\
&= w_{0} + \sum_{t \in I}w_{t} x_{t} + \sum_{u\in U}w_{u} x_{u} \\
\end{aligned}
$$
+ 其中,$U$ 表示用户相关特征集合,$I$ 表示物品相关特征集合。
**FM 的二阶特征交互**
观察 FM 的二阶特征交互项,可知其计算复杂度为 $O\left(k n^{2}\right)$ 。为了降低计算复杂度,按照如下公式进行变换。
$$
\begin{aligned}
& \sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j} \\
=& \frac{1}{2} \sum_{i=1}^{n} \sum_{j=1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}-\frac{1}{2} \sum_{i=1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{i}\right\rangle x_{i} x_{i} \\
=& \frac{1}{2}\left(\sum_{i=1}^{n} \sum_{j=1}^{n} \sum_{f=1}^{k} v_{i, f} v_{j, f} x_{i} x_{j}-\sum_{i=1}^{n} \sum_{f=1}^{k} v_{i, f} v_{i, f} x_{i} x_{i}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{}\left(\sum_{j=1}^{n} v_{j, f} x_{j}\right)-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{2}-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right)
\end{aligned}
$$
+ 公式变换后,计算复杂度由 $O\left(k n^{2}\right)$ 降到 $O\left(k n\right)$。
由于本文章需要将 FM 模型用在召回,故将二阶特征交互项拆分为用户和物品项。有:
$$
\begin{aligned}
& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{i=1}^{n} v_{i, f} x_{i}\right)^{2}-\sum_{i=1}^{n} v_{i, f}^{2} x_{i}^{2}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{u \in U} v_{u, f} x_{u} + \sum_{t \in I} v_{t, f} x_{t}\right)^{2}-\sum_{u \in U} v_{u, f}^{2} x_{u}^{2} - \sum_{t\in I} v_{t, f}^{2} x_{t}^{2}\right) \\
=& \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{u \in U} v_{u, f} x_{u}\right)^{2} + \left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} + 2{\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} - \sum_{u \in U} v_{u, f}^{2} x_{u}^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right)
\end{aligned}
$$
+ 其中,$U$ 表示用户相关特征集合,$I$ 表示物品相关特征集合。
# FM 用于召回
基于 FM 召回,我们可以将 $\hat{y}(\mathbf{x}):=w_{0}+\sum_{i=1}^{n} w_{i} x_{i}+\sum_{i=1}^{n} \sum_{j=i+1}^{n}\left\langle\mathbf{v}_{i}, \mathbf{v}_{j}\right\rangle x_{i} x_{j}$ 作为用户和物品之间的匹配分。
+ 在上一小节中,对于 FM 的一阶、二阶特征交互项,已将其拆分为用户项和物品项。
+ 对于同一用户,即便其与不同物品进行交互,但用户特征内部之间的一阶、二阶交互项得分都是相同的。
+ 这就意味着在比较用户与不同物品之间的匹配分时只需要比较1物品内部之间的特征交互得分2用户和物品之间的特征交互得分。
**FM 的一阶特征交互**
+ 将全局偏置和用户一阶特征交互项进行丢弃,有:
$$
FM_{一阶} = \sum_{t \in I} w_{t} x_{t}
$$
**FM 的二阶特征交互**
+ 将用户特征内部的特征交互项进行丢弃,有:
$$
\begin{aligned}
& FM_{二阶} = \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} + 2{\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) \\
&= \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) + \sum_{f=1}^{k}\left( {\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} \right)
\end{aligned}
$$
合并 FM 的一阶、二阶特征交互项,得到基于 FM 召回的匹配分计算公式:
$$
\text{MatchScore}_{FM} = \sum_{t \in I} w_{t} x_{t} + \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right) + \sum_{f=1}^{k}\left( {\sum_{u \in U} v_{u, f} x_{u}}{\sum_{t \in I} v_{t, f} x_{t}} \right)
$$
在基于向量的召回模型中,为了 ANN近似最近邻算法 或 Faiss 加速查找与用户兴趣度匹配的物品。基于向量的召回模型,一般最后都会得到用户和物品的特征向量表示,然后通过向量之间的内积或者余弦相似度表示用户对物品的兴趣程度。
基于 FM 模型的召回算法,也是向量召回算法的一种。所以下面,将 $\text{MatchScore}_{FM}$ 化简为用户向量和物品向量的内积形式,如下:
$$
\text{MatchScore}_{FM} = V_{item} V_{user}^T
$$
+ 用户向量:
$$
V_{user} = [1; \quad {\sum_{u \in U} v_{u} x_{u}}]
$$
+ 用户向量由两项表达式拼接得到。
+ 第一项为常数 $1$,第二项是将用户相关的特征向量进行 sum pooling 。
+ 物品向量:
$$
V_{item} = [\sum_{t \in I} w_{t} x_{t} + \frac{1}{2} \sum_{f=1}^{k}\left(\left(\sum_{t \in I} v_{t, f} x_{t}\right)^{2} - \sum_{t \in I} v_{t, f}^{2} x_{t}^{2}\right); \quad
{\sum_{t \in I} v_{t} x_{t}} ]
$$
+ 第一项表示物品相关特征向量的一阶、二阶特征交互。
+ 第二项是将物品相关的特征向量进行 sum pooling 。
# 思考题
1. 为什么不直接将 FM 中学习到的 User Embedding ${\sum_{u \in U} v_{u} x_{u}}$ 和 Item Embedding $\sum_{t \in I} v_{t} x_{t}$ 的内积做召回呢?
答:这样做,也不是不行,但是效果不是特别好。**因为用户喜欢的未必一定是与自身最匹配的也包括一些自身性质极佳的iteme.g.,热门item**,所以,**非常有必要将"所有Item特征一阶权重之和"和“所有Item特征隐向量两两点积之和”考虑进去**,但是也还必须写成点积的形式。
# 代码实战
正在完善...
# 参考链接
+ [paper.dvi (ntu.edu.tw)](https://www.csie.ntu.edu.tw/~b97053/paper/Rendle2010FM.pdf)
+ [FM推荐算法中的瑞士军刀 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/343174108)

View File

@@ -0,0 +1,589 @@
## 写在前面
YouTubeDNN模型是2016年的一篇文章虽然离着现在有些久远 但这篇文章无疑是工业界论文的典范, 完全是从工业界的角度去思考如何去做好一个推荐系统并且处处是YouTube工程师留给我们的宝贵经验 由于这两天用到了这个模型,今天也正好重温了下这篇文章,所以借着这个机会也整理出来吧, 王喆老师都称这篇文章是"神文" 可见其不一般处。
今天读完之后, 给我的最大感觉,首先是从工程的角度去剖析了整个推荐系统,讲到了推荐系统中最重要的两大模块: 召回和排序, 这篇论文对初学者非常友好,之前的论文模型是看不到这么全面的系统的,总有一种管中规豹的感觉,看不到全局,容易着相。 其次就是这篇文章给出了很多优化推荐系统中的工程性经验, 不管是召回还是排序上都有很多的套路或者trick比如召回方面的"example age", "负采样""非对称消费,防止泄露",排序方面的特征工程,加权逻辑回归等, 这些东西至今也都非常的实用,所以这也是这篇文章厉害的地方。
本篇文章依然是以paper为主线 先剖析paper里面的每个细节当然我这里也参考了其他大佬写的文章王喆老师的几篇文章写的都很好链接我也放在了下面建议也看看。然后就是如何用YouTubeDNN模型代码复现部分由于时间比较短自己先不复现了调deepmatch的包跑起来然后在新闻推荐数据集上进行了一些实验 尝试了论文里面讲述的一些方法这里主要是把deepmatch的YouTubeDNN模型怎么使用以及我整个实验过程的所思所想给整理下 因为这个模型结构本质上并不是很复杂(三四层的全连接网络),就不自己在实现一遍啦, 一些工程经验或者思想,我觉得才是这篇文章的精华部分。
## 引言与推荐系统的漏斗范式
### 引言部分
本篇论文是工程性论文(之前的DIN也是偏工程实践的论文) 行文风格上以实际应用为主, 我们知道YouTube是全球性的视频网站 所以这篇文章主要讲述了YouTube视频推荐系统的基本架构以及细节以及各种处理tricks。
在Introduction部分 作者首先说了在工业上的YouTube视频推荐系统主要面临的三大挑战:
1. Scale(规模): 视频数量非常庞大大规模数据下需要分布式学习算法以及高效的线上服务系统文中体现这一点的是召回模型线下训练的时候采用了负采样的思路线上服务的时候采用了hash映射然后近邻检索的方式来满足实时性的需求 这个之前我整理过faiss包和annoy包的使用 感兴趣的可以看看。 其实,再拔高一层,我们推荐系统的整体架构呈漏斗范式,也是为了保证能从大规模情景下实时推荐。
2. Freshness(新鲜度): YouTube上的视频是一个动态的 用户实时上传,且实时访问, 那么这时候, 最新的视频往往就容易博得用户的眼球, 用户一般都比较喜欢看比较新的视频, 而不管是不是真和用户相关(这个感觉和新闻比较类似呀) 这时候,就需要模型有建模新上传内容以及用户最新发生的行为能力。 为了让模型学习到用户对新视频有偏好, 后面策略里面加了一个"example age"作为体现。我们说的"探索与利用"中的探索,其实也是对新鲜度的把握。
3. Noise(噪声): 由于数据的稀疏和不可见的其他原因, 数据里面的噪声非常之多,这时候,就需要让这个推荐系统变得鲁棒,怎么鲁棒呢? 这个涉及到召回和排序两块,召回上需要考虑更多实际因素,比如非对称消费特性,高活用户因素,时间因素,序列因素等,并采取了相应的措施, 而排序上做更加细致的特征工程, 尽量的刻画出用户兴趣以及视频的特征 优化训练目标,使用加权的逻辑回归等。而召回和排序模型上,都采用了深度神经网络,通过特征的相互交叉,有了更强大的建模能力, 相比于之前用的MF(矩阵分解) 建模能力上有了很大的提升, 这些都有助于帮助减少噪声, 使得推荐结果更加准确。
所以从文章整体逻辑上看, 后面的各个细节,其实都是围绕着挑战展开的,找到当前推荐面临的问题,就得想办法解决问题,所以这篇文章的行文逻辑也是非常清晰的。
知道了挑战, 那么下面就看看YouTubeDNN的整体推荐系统架构。
### YouTubeDNN推荐系统架构
整个推荐架构图如下, 这个算是比较原始的漏斗结构了:
<div align=center>
<img src="https://img-blog.csdnimg.cn/1c5dbd6d6c1646d09998b18d45f869e5.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
这篇文章之所以写的好, 是给了我们一个看推荐系统的宏观视角, 这个系统主要是两大部分组成: 召回和排序。召回的目的是根据用户部分特征,从海量物品库,快速找到小部分用户潜在感兴趣的物品交给精排,重点强调快,精排主要是融入更多特征,使用复杂模型,来做个性化推荐,强调准。
而对于这两块的具体描述, 论文里面也给出了解释, 我这里简单基于我目前的理解扩展下主流方法:
1. 召回侧
<div align=center>
<img src="https://img-blog.csdnimg.cn/5ebcd6f882934b7e9e2ffb9de2aee29d.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
召回侧模型的输入一般是用户的点击历史, 因为我们认为这些历史能更好的代表用户的兴趣, 另外还有一些人口统计学特征,比如性别,年龄,地域等, 都可以作为召回侧模型的输入。 而最终模型的输出,就是与该用户相关的一个候选视频集合, 量级的话一般是几百。
<br>召回侧, 目前根据我的理解,大致上有两大类召回方式,一类是策略规则,一类是监督模型+embedding其中策略规则往往和真实场景有关比如热度历史重定向等等不同的场景会有不同的召回方式这种属于"特异性"知识。
<br>后面的模型+embedding思路是一种"普适"方法我上面图里面梳理出了目前给用户和物品打embedding的主流方法 这些方法大致成几个系列比如FM系列(FM,FFM等) 用户行为序列基于图和知识图谱系列经典双塔系列等这些方法看似很多很复杂其实本质上还是给用户或者是物品打embedding而已只不过考虑的角度方式不同。 这里的YouTubeDNN召回模型也是这里的一种方式而已。
2. 精排侧
<div align=center>
<img src="https://img-blog.csdnimg.cn/08953c0e8a00476f90bd9e206d4a02c6.png#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
召回那边对于每个用户, 给出了几百个比较相关的候选视频, 把几百万的规模降到了几百, 当然,召回那边利用的特征信息有限,并不能很好的刻画用户和视频特点,所以, 在精排侧,主要是想利用更多的用户,视频特征,刻画特点更加准确些,从这几百个里面选出几个或者十几个推荐给用户。 而涉及到准, 主要的发力点一般有三个:特征工程, 模型设计以及训练方法。 这三个发力点文章几乎都有所涉及, 除了模式设计有点审时度势之外,特征工程以及训练方法的处理上非常漂亮,具体的后面再整理。<br>
精排侧这一块的大致发展趋势从ctr预估到多目标 而模型演化上,从人工特征工程到特征工程自动化。主要是三大块, CTR预估主要分为了传统的LRFM大家族以及后面自动特征交叉的DNN家族而多目标优化目前是很多大公司的研究现状更是未来的一大发展趋势如何能让模型在各个目标上面的学习都能"游刃有余"是一件非常具有挑战的事情毕竟不同的目标可能会互相冲突互相影响所以这里的研究热点又可以拆分成网络结构演化以及loss设计优化等 而网络结构演化中,又可以再一次细分。 当然这每个模型或者技术几乎都有对应paper我们依然可以通过读paper的方式把这些关键技术学习到。
这两阶段的方法, 就能保证我们从大规模视频库中实时推荐, 又能保证个性化,吸引用户。 当然,随着时间的发展, 可能数据量非常非常大了, 此时召回结果规模精排依然无法处理,所以现在一般还会在召回和精排之间,加一个粗排进一步筛选作为过渡, 而随着场景越来越复杂, 精排产生的结果也不是直接给到用户而是会再后面加一个重排后处理下这篇paper里面其实也简单的提了下这种思想在排序那块会整理到。 所以如今的漏斗, 也变得长了些。
<div align=center>
<img src="https://img-blog.csdnimg.cn/aeae52971a1345a98b310890ea81be53.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
论文里面还提到了对模型的评估方面, 线下评估的时候,主要是采用一些常用的评估指标(精确率,召回率, 排序损失或者auc这种) 但是最终看算法和模型的有效性, 是通过A/B实验 在A/B实验中会观察用户真实行为比如点击率 观看时长, 留存率这种, 这些才是我们终极目标, 而有时候, A/B实验的结果和线下我们用的这些指标并不总是相关 这也是推荐系统这个场景的复杂性。 我们往往也会用一些策略,比如修改模型的优化目标,损失函数这种, 让线下的这个目标尽量的和A/B衡量的这种指标相关性大一些。 当然,这块又是属于业务场景问题了,不在整理范畴之中。 但2016年竟然就提出了这种方式 所以我觉得,作为小白的我们, 想了解工业上的推荐系统, 这篇paper是不二之选。
OK 从宏观的大视角看完了漏斗型的推荐架构我们就详细看看YouTube视频推荐架构里面召回和排序模块的模型到底长啥样子 为啥要设计成这个样子? 为了应对实际中出现的挑战,又有哪些策略?
## YouTubeDNN的召回模型细节剖析
上面说过, 召回模型的目的是在大量YouTube视频中检索出数百个和用户相关的视频来。
这个问题,我们可以看成一个多分类的问题,即用户在某一个时刻点击了某个视频, 可以建模成输入一个用户向量, 从海量视频中预测出被点击的那个视频的概率。
换成比较准确的数学语言描述, 在时刻$t$下, 用户$U$在背景$C$下对每个视频$i$的观看行为建模成下面的公式:
$$
P\left(w_{t}=i \mid U, C\right)=\frac{e^{v_{i} u}}{\sum_{j \in V} e^{v_{j} u}}
$$
这里的$u$表示用户向量, 这里的$v$表示视频向量, 两者的维度都是$N$ 召回模型的任务,就是通过用户的历史点击和山下文特征, 去学习最终的用户表示向量$u$以及视频$i$的表示向量$v_i$ 不过这俩还有个区别是$v_i$本身就是模型参数, 而$u$是神经网络的输出(函数输出),是输入与模型参数的计算结果。
>解释下这个公式, 为啥要写成这个样子其实是word2vec那边借鉴过来的$e^{ (v_{i} u)}$表示的是当前用户向量$u$与当前视频$v_i$的相似程度,$e$只是放大这个相似程度而已, 不用管。 为啥这个就能表示相似程度呢? 因为两个向量的点积运算的含义就是可以衡量两个向量的相似程度, 两个向量越相似, 点积就会越大。 所以这个应该解释明白了。 再看分母$\sum_{j \in V} e^{v_{j} u}$, 这个显然是用户向量$u$与所有视频$v$的一个相似程度求和。 那么两者一除, 依然是代表了用户$u$与输出的视频$v_i$的相似程度只不过归一化到了0-1之间 毕竟我们知道概率是0-1之间的 这就是为啥这个概率是右边形式的原因。 因为右边公式表示了用户$u$与输出的视频$v_i$的相似程度, 并且这个相似程度已经归一化到了0-1之间 我们给定$u$希望输出$v_i$的概率越大,因为这样,当前的视频$v_i$和当前用户$u$更加相关,正好对应着点击行为不是吗?
那么,这个召回模型到底长啥样子呢?
### 召回模型结构
召回模型的结构如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/724ff38c1d6448399edb658b1b27e18e.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这个模型结构呢,相比之前的模型, 比较简单就是一个DNN。
它的输入主要是用户侧的特征包括用户观看的历史video序列 用户搜索的历史tokens 然后就是用户的人文特征,比如地理位置, 性别,年龄这些。 这些特征处理上,和之前那些模型的也比较类似,
* 用户历史序列历史搜索tokens这种序列性的特征: 一般长这样`[item_id5, item_id2, item_id3, ...]` 这种id特征是高维稀疏首先会通过一个embedding层转成低维稠密的embedding特征即历史序列里面的每个id都会对应一个embedding向量 这样历史序列就变成了多个embedding向量的形式 这些向量一般会进行融合常见的是average pooling即每一维求平均得到一个最终向量来表示用户的历史兴趣或搜索兴趣。
>这里值的一提的是这里的embedding向量得到的方式 论文中作者这里说是通过word2vec方法计算的 关于word2vec这里就不过多解释也就是每个item事先通过w2v方式算好了的embedding直接作为了输入然后进行pooling融合。<br><br>除了这种算好embedding方式之外还可以过embedding层跟上面的DNN一起训练这些都是常规操作之前整理的精排模型里面大都是用这种方式。
论文里面使用了用户最近的50次观看历史用户最近50次搜索历史token embedding维度是256维 采用的average pooling。 当然这里还可以把item的类别信息也隐射到embedding 与前面的concat起来。
* 用户人文特征, 这种特征处理方式就是离散型的依然是labelEncoder然后embedding转成低维稠密 而连续型特征,一般是先归一化操作,然后直接输入,当然有的也通过分桶,转成离散特征,这里不过多整理,特征工程做的事情了。 当然,这里还有一波操作值得注意,就是连续型特征除了用了$x$本身,还用了$x^2$$logx$这种, 可以加入更多非线性,增加模型表达能力。<br>
这些特征对新用户的推荐会比较有帮助,常见的用户的地理位置, 设备, 性别,年龄等。
* 这里一个比较特色的特征是example age这个特征后面需要单独整理。
这些特征处理好了之后拼接起来就成了一个非常长的向量然后就是过DNN这里用了一个三层的DNN 得到了输出, 这个输出也是向量。
Ok到这里平淡无奇 前向传播也大致上快说完了, 还差最后一步。 最后这一步就是做多分类问题然后求损失这就是training那边做的事情。 但是在详细说这个之前, 我想先简单回忆下word2vec里面的skip-gram Model 这个模型,如果回忆起来,这里理解起来就非常的简单了。
这里只需要看一张图即可, 这个来自cs231N公开课PPT 我之前整理w2v的时候用到的详细内容可看我[这篇博客](https://zhongqiang.blog.csdn.net/article/details/106948860) 这里的思想其实也是从w2v那边过来的。
<div align=center>
<img src="https://img-blog.csdnimg.cn/20200624193409649.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3d1emhvbmdxaWFuZw==,size_1,color_FFFFFF,t_70#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
skip-gram的原理咱这里就不整理了 这里就只看这张图这其实就是w2v训练的一种方式当然是最原始的。 word2vec的核心思想呢 就是共现频率高的词相关性越大所以skip-gram采用中心词预测上下文词的方式去训练词向量模型的输入是中心词做样本采用滑动窗口的形式和这里序列其实差不多窗口滑动一次就能得到一个序列[word1, word2, ...wordn] 而这个序列里面呢? 就会有中心词(比如中间那个) 两边向量的是上下文词。 如果我们输入中心词之后,模型能预测上下文词的概率大,那说明这个模型就能解决词相关性问题了。
>一开始, 我们的中心单词$w_t$就是one-hot的表示形式也就是在词典中的位置这里的形状是$V \times1$ $V$表示词库里面有$V$个单词, 这里的$W$长上面那样, 是一个$d\times V$的矩阵, $d$表示的是词嵌入的维度, 那么用$W*w_t$(矩阵乘法)就会得到中心词的词向量表示$v_c$ 大小是$d\times1$。这个就是中心词的embedding向量。 其实就是中心词过了一个embedding层得到了它的embedding向量。
><br>然后就是$v_c$和上下文矩阵$W'$相乘, 这里的$W'$是$V\times d$的一个矩阵, 每一行代表每个单词作为上下文的时候的词向量表示, 也就是$u_w$ 每一列是词嵌入的维度。 这样通过$W'*v_c$就会得到一个$V\times 1$的向量,这个表示的就是中心单词$w_t$与每个单词的相似程度。
><br>最后我们通过softmax操作把这个相似程度转成概率 选择概率最大的index输出。
这就是这个模型的前向传播过程。
有了这个过程, 再理解YouTubeDNN顶部就非常容易了 我单独截出来:
<div align=center>
<img src="https://img-blog.csdnimg.cn/98811e09226f42a2be981b0aa3449ab3.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:90%;" />
</div>
只看这里的这个过程, 其实就是上面skip-gram过程 不一样的是右边这个中心词向量$v_c$是直接过了一个embedding层得到的而左边这个用户向量$u$是用户的各种特征先拼接成一个大的向量然后过了一个DNN降维。 训练方式上,这两个也是一模一样的,无非就是左边的召回模型,多了几层全连接而已。
> 这样也就很容易的理解模型训练好了之后用户向量和item向量到底在哪里取了吧。
> * 用户向量其实就是全连接的DNN网络的输出向量其实即使没有全连接原始的用户各个特征拼接起来的那个长向量也能用不过维度可能太大了所以DNN在这里的作用一个是特征交叉另一个还有降维的功效。
> * item向量: 这个其实和skip-gram那个一样每个item其实是用两个embedding向量的比如skip-gram那里就有一个作为中心词时候的embedding矩阵$W$和作为上下文词时候的embedding矩阵$W'$ 一般取的时候会取前面那个$W$作为每个词的词向量。 这里其实一个道理只不过这里最前面那个item向量矩阵是通过了w2v的方式训练好了直接作为的输入如果不事先计算好对应的是embedding层得到的那个矩阵。 后面的item向量矩阵就是这里得到用户向量之后后面进行softmax之前的这个矩阵 **YouTubeDNN最终是从这个矩阵里面拿item向量**。
这就是知识串联的魅力其实熟悉了word2vec 这个召回模型理解非常简单。
这其实就是这个模型训练阶段最原始的剖析,实际训练的时候,依然是采用了优化方法, 这个和word2vec也是一样采用了负采样的方式(当然实现细节上有区别),因为视频的数量太大,每次做多分类,最终那个概率分母上的加和就非常可怕了,所以就把多分类问题转成了多个二分类的问题。 也就是不用全部的视频,而是随机选择出了一些没点的视频, 标记为0 点了的视频标记为1 这样就成了二分类的问题。 关于负样本采样原理, 我之前也整理了[一篇博客](https://blog.csdn.net/wuzhongqiang/article/details/106979179?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522164310239216780274177509%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=164310239216780274177509&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-106979179.nonecase&utm_term=word2vec&spm=1018.2226.3001.4450)
>负类基于样本分布抽取而来。负采样是针对类别数很多情况下的常用方法。当然,负样本的选择也是有讲究的,详细的看[这篇文章](https://www.zhihu.com/question/334844408/answer/2299283878), 我后面实验主要用了下面两种
>* 展示数据随机选择负例
>* 随机负例与热门打压
<div align=center>
<img src="https://img-blog.csdnimg.cn/6fe56d71de8a4d769a583f27a3ce9f40.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这样整个召回模型训练部分的"基本操作"就基本整理完了。关于细节部分,后面代码里面会描述下, 但是在训练召回模型过程中,还有一些经验性的知识也非常重要。 下面重点整理一下。
### 训练数据的选取和生成
模型训练的时候, 为了计算更加高效,采用了负采样的方法, 但正负样本的选取,以及训练样本的来源, 还有一些注意事项。
首先训练样本来源于全部的YouTube观看记录而不仅仅是被推荐的观看记录
<div align=center>
<img src="https://img-blog.csdnimg.cn/faf8a8abf7b54b779287acadc015b6a0.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
否则对于新视频会难以被曝光,会使最终推荐结果有偏;同时系统也会采集用户从其他渠道观看的视频,从而可以快速应用到协同过滤中;
其次, 是训练数据来源于用户的隐式数据, 且**用户看完了的视频作为正样本** 注意这里是看完了, 有一定的时长限制, 而不是仅仅曝光点击,有可能有误点的。 而负样本,是从视频库里面随机选取,或者在曝光过的里面随机选取用户没看过的作为负样本。
==这里的一个经验==是**训练数据中对于每个用户选取相同的样本数, 保证用户在损失函数等权重** 因为这样可以减少高度活跃用户对于loss的影响。可以改进线上A/B测试的效果。
<div align=center>
<img src="https://img-blog.csdnimg.cn/35386af8fd064de3a87cb418b008e444.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这里的==另一个经验==是**避免让模型知道不该知道的信息**
<div align=center>
<img src="https://img-blog.csdnimg.cn/0765134e1ca445c693058aaaaf20ae74.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这里作者举了一个例子是如果模型知道用户最后的行为是搜索了"Taylor Swift" 那么模型可能会倾向于推荐搜索页面搜"Taylor Swift"时搜索的视频, 这个不是推荐模型期望的行为。 解法方法是**扔掉时序信息** 历史搜索tokens随机打乱 使用无序的搜索tokens来表示搜索queryies(average pooling)。
>基于这个例子就把时序信息扔掉理由挺勉强的解决这种特殊场景的信息泄露会有更针对性的方法比如把搜索query与搜索结果行为绑定让它们不可分。 感觉时序信息还是挺重要的, 有专门针对时序信息建模的研究。
在生成样本的时候, 如果我们的用户比较少,行为比较少, 是不足以训练一个较好的召回模型,此时一个用户的历史观看序列,可以采用滑动窗口的形式生成多个训练样本, 比如一个用户的历史观看记录是"abcdef" 那么采用滑动窗口, 可以是abc预测d, bcd预测e, cde预测f这样一个用户就能生成3条训练样本。 后面实验里面也是这么做的。 但这时候一定要注意一点,就是**信息泄露**这个也是和word2vec的cbow不一样的地方。
论文中上面这种滑动制作样本的方式依据是用户的"asymmetric co-watch probabilities(非对称观看概率)",即一般情况下,用户开始浏览范围较广, 之后浏览范围逐渐变窄。
下图中的$w_{tN}$表示当前样本, 原来的做法是它前后的用户行为都可以用来产生特征行为输入(word2vec的CBOW做样本的方法)。 而作者担心这一点会导致信息泄露, 模型**不该知道的信息是未来的用户行为** 所以作者的做法是只使用更早时间的用户行为来产生特征, 这个也是目前通用的做法。 两种方法的对比如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/049cbeb814f843fd97638ef02d6c5703.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
(a)是许多协同过滤会采取的方法利用全局的观看信息作为输入包括时间节点N前N后的观看这种方法忽略了观看序列的不对称性而本文中采取(b)所示的方法,只把历史信息当作输入,用历史来预测未来
<div align=center>
<img src="https://img-blog.csdnimg.cn/4ac0c81e5f4f4276a4ed0e4c6329f458.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
模型的测试集, 往往也是用户最近一次观看行为, 后面的实验中,把用户最后一次点击放到了测试集里面去。这样可以防止信息穿越。
数据集的细节和tricks基本上说完 更细的东西,就得通过代码去解释了。 接下来, 再聊聊作者加入的非常有意思的一个特征叫做example age。
### "Example Age"特征
这个特征我想单独拿出来说,是因为这个是和场景比较相关的特征,也是作者的经验传授。 我们知道,视频有明显的生命周期,例如刚上传的视频比之后更受欢迎,也就是用户往往喜欢看最新的东西,而不管它是不是和用户相关,所以视频的流行度随着时间的分布是高度非稳态变化的(下面图中的绿色曲线)
<div align=center>
<img src="https://img-blog.csdnimg.cn/15dfce743bd2490a8adb21fd3b2b294e.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
但是我们模型训练的时候,是基于历史数据训练的(历史观看记录的平均),所以模型对播放某个视频预测值的期望会倾向于其在训练数据时间内的平均播放概率(平均热度) 上图中蓝色线。但如上面绿色线,实际上该视频在训练数据时间窗口内热度很可能不均匀, 用户本身就喜欢新上传的内容。 所以为了让模型学习到用户这种对新颖内容的bias 作者引入了"example age"这个特征来捕捉视频的生命周期。
"example age"定义为$t_{max}-t$ 其中$t_{max}$是训练数据中所有样本的时间最大值(有的文章说是当前时间,但我总觉得还是选取的训练数据所在时间段的右端点时间比较合适,就比如我用的数据集, 最晚时间是2021年7月的总不能用现在的时间吧) 而$t$为当前样本的时间。**线上预测时, 直接把example age全部设为0或一个小的负值这样就不依赖于各个视频的上传时间了**。
>其实这个操作, 现在常用的是位置上的除偏, 比如商品推荐的时候, 用户往往喜欢点击最上面位置的商品或广告, 但这个bias模型依然是不知道 为了让模型学习到这个东西, 也可以把商品或者广告的位置信息做成一个feature 训练的时候告诉模型。 而线上推理的那些商品, 这个feature也都用一样的。 异曲同工的意思有没有。<br><br>那么这样的操作为啥会work呢 example age这个我理解是有了这个特征 就可以把某视频的热度分布信息传递给模型了, 比如某个example age时间段该视频播放较多 而另外的时间段播放较少, 这样模型就能发现用户的这种新颖偏好, 消除热度偏见。<br><br>这个地方看了一些文章写说, 这样做有利于让模型推新热内容, 总感觉不是很通。 我这里理解是类似让模型消除位置偏见那样, 这里消除一种热度偏见。 <br><br>我理解是这样假设没有这样一个example age特征表示视频新颖信息或者一个位置特征表示商品的位置信息那模型训练的样本可能是用户点击了这个item就是正样本 但此时有可能是用户真的喜欢这个item 也有可能是因为一些bias 比如用户本身喜欢新颖, 用户本身喜欢点击上面位置的item等 但模型推理的时候都会误认为是用户真的喜欢这个item。 所以为了让模型了解到可能是存在后面这种bias 我们就把item的新颖信息 item的位置信息等做成特征 在模型训练的时候就告诉模型,用户点了这个东西可能是它比较新或者位置比较靠上面等,这样模型在训练的时候, 就了解到了这些bias等到模型在线推理的时候呢 我们把这些bias特征都弄成一样的这样每个样品在模型看来就没有了新颖信息和位置信息bias(一视同仁了),只能靠着相关性去推理, 这样才能推到用户真正感兴趣的东西吧。<br><br>而有些文章记录的, 能够推荐更热门的视频啥的, 我很大一个疑问就是推理的时候不是把example age用0表示吗 模型应该不知道这些视频哪个新不新吧。 当然,这是我自己的看法,感兴趣的可以帮我解答下呀。
`example age`这个特征到这里还没完, 原来加入这种时间bias的传统方法是使用`video age` 即一个video上传到样本生成的这段时间跨度 这么说可能有些懵, 看个图吧, 原来这是两个东西:
<div align=center>
<img src="https://img-blog.csdnimg.cn/10475c194c0044a3a93b01a3193e294f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
王喆老师那篇文章里面也谈到了这两种理解, 对于某个视频的不同样本,其实这两种定义是等价的,因为他们的和是一个常数。
$$
t_{\text {video age }}+t_{\text {example age }}=\text { Const }
$$
详细证明可以看参考的第三篇文章。但`example age`的定义有下面两点好处:
1. 线上预测时`example age`是常数值, 所有item可以设置成统一的 但如果是`video age`的话,这个根每个视频的上传时间有关, 那这样在计算用户向量的时候就依赖每个候选item了。 而统一的这个好处就是用户向量只需要计算一次。
2. 对不同的视频,对应的`example age`所在范围一致, 只依赖训练数据选取的时间跨度,便于归一化操作。
### 实验结果
这里就简单过下就好, 作者这里主要验证了下DNN的结构对推荐效果的影响对于DNN的层级作者尝试了0~4层 实验结果是**层数越多越好, 但4层之后提升很有限 层数越多训练越困难**
<div align=center>
<img src="https://img-blog.csdnimg.cn/fd1849a8881444fbb12490bad7598125.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
作者这里还启发了一个事情, 从"双塔"的角度再看YouTubeDNN召回模型 这里的DNN个结构其实就是一个用户塔 输入用户的特征最终通过DNN编码出了用户的embedding向量。
而得到用户embedding向量到后面做softmax那块不是说了会经过一个item embedding矩阵吗 其实这个矩阵也可以用一个item塔来实现 和用户embedding计算的方式类似 首先各个item通过一个物品塔(输入是item 特征, 输出是item embedding)这样其实也能得到每个item的embedding然后做多分类或者是二分类等。 所以**YouTubeDNN召回模型本质上还是双塔结构** 只不过上面图里面值体现了用户塔。 我看deepmatch包里面实现的时候 用户特征和item特征分开输入的 感觉应该就是实现了个双塔。源码倒是没看, 等看了之后再确认。
### 线上服务
线上服务的时候, YouTube采用了一种最近邻搜索的方法去完成topK推荐这其实是工程与学术trade-off的结果 model serving过程中对几百万个候选集一一跑模型显然不现实 所以通过召回模型得到用户和video的embedding之后 用最近邻搜索的效率会快很多。
我们甚至不用把任何model inference的过程搬上服务器只需要把user embedding和video embedding存到redis或者内存中就好了。like this:
<div align=center>
<img src="https://img-blog.csdnimg.cn/86751a834d224ad69220b5040e0e03c9.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
在线上可以根据用户兴趣Embedding采用类似Faiss等高效Embedding检索工具快速找出和用户兴趣匹配的物品 高效embedding检索工具 我目前接触到了两个一个是Faiss 一个是annoy 关于这两个工具的使用, 我也整理了两篇文章:
* [annoy(快速近邻向量搜索包)学习小记](https://blog.csdn.net/wuzhongqiang/article/details/122516942?spm=1001.2014.3001.5501)
* [Faiss(Facebook开源的高效相似搜索库)学习小记](https://blog.csdn.net/wuzhongqiang/article/details/122516942?spm=1001.2014.3001.5501)
之前写新闻推荐比赛的时候用过Faiss 这次实验中使用的是annoy工具包。
另外多整理一点:
>我们做线上召回的时候, 其实可以有两种:
>1. item_2_item: 因为我们有了所有item的embedding了 那么就可以进行物品与物品之间相似度计算每个物品得到近似的K个 这时候,就和协同过滤原理一样, 之间通过用户观看过的历史item就能进行相似召回了 工程实现上一般会每个item建立一个相似度倒排表
>2. user_2_item: 将item用faiss或者annoy组织成index然后用user embedding去查相近item
## 基于Deepmatch包YouTubeDNN的使用方法
由于时间原因, 我这里并没有自己写代码复现YouTubeDNN模型这个结构也比较简单 几层的DNN自己再写一遍剖析架构也没有啥意思 所以就采用浅梦大佬写的deepmatch包 直接用到了自己的数据集上做了实验。 关于Deepmatch源码 还是看[deepmatch项目](https://github.com/shenweichen/DeepMatch) 这里主要是整理下YouTubeDNN如何用。
项目里面其实给出了如何使用YouTubeDNN采用的是movielens数据集 见[这里](https://github.com/shenweichen/DeepMatch/blob/master/examples/run_youtubednn.py)
我这里就基于我做实验用的新闻推荐数据集, 把代码的主要逻辑过一遍。
### 数据集
实验用的数据集是新闻推荐的一个数据集是做func-rec项目时候一个伙伴分享的来自于某个推荐比赛因为这个数据集是来自工业上的真实数据所以使用起来比之前用的movielens数据集可尝试的东西多一些并且原数据有8个多G总共3个文件: 用户画像,文章画像, 点击日志用户数量100多万6000多万次点击 文章规模是几百,数据量也比较丰富,所以后面就打算采用这个统一的数据集, 重新做实验对比目前GitHub上的各个模型。关于数据集每个文件详细描述后面会更新到GitHub项目。
这里只整理我目前的使用过程, 由于有8个多G的数据我这边没法直接跑所以对数据进行了采样 采样方法写成了一个jupyter文件。 主要包括:
1. 分块读取数据, 无法一下子读入内存
2. 对于每块数据基于一些筛选规则进行记录的删除比如只用了后7天的数据 删除了一些文章不在物料池的数据, 删除不合法的点击记录(曝光时间大于文章上传时间) 删除没有历史点击的用户删除观看时间低于3s的视频 删除历史点击序列太短和太长的用户记录
3. 删除完之后重新保存一份新数据集大约3个G然后再从这里面随机采样了20000用户进行了后面实验
通过上面的一波操作, 我的小本子就能跑起来了当然可能数据比较少最终训练的YouTubeDNN效果并不是很好。详细看后面GitHub的: `点击日志数据集初步处理与采样.ipynb`
### 简单数据预处理
这个也是写成了一个笔记本, 主要是看了下采样后的数据,序列长度分布等,由于上面做了一些规整化,这里有毛病的数据不是太多,并没有太多处理, 但是用户数据里面的年龄,性别源数据是给出了多种可能, 每个可能有概率值,我这里选出了概率最大的那个,然后简单填充了缺失。
最后把能用到的用户画像和文章画像统一拼接到了点击日志数据,又保存了一份。 作为YouTubeDNN模型的使用数据 其他模型我也打算使用这份数据了。
详见`EDA与数据预处理.ipynb`
### YouTubeDNN召回
这里就需要解释下一些代码了, 首先拿到采样的数据集,我们先划分下训练集和测试集:
* 测试集: 每个用户的最后一次点击记录
* 训练集: 每个用户除最后一次点击的所有点击记录
这个具体代码就不在这里写了。
```python
user_click_hist_df, user_click_last_df = get_hist_and_last_click(click_df)
```
这么划分的依据,就是保证不能发生数据穿越,拿最后的测试,不能让模型看到。
接下来就是YouTubeDNN模型的召回从构造数据集 -> 训练模型 -> 产生召回结果,我写到了一个函数里面去。
```cpp
def youtubednn_recall(data, topk=200, embedding_dim=8, his_seq_maxlen=50, negsample=0,
batch_size=64, epochs=1, verbose=1, validation_split=0.0):
"""通过YouTubeDNN模型计算用户向量和文章向量
param: data:
topk:
"""
user_id_raw = data[['user_id']].drop_duplicates('user_id')
doc_id_raw = data[['article_id']].drop_duplicates('article_id')
# 类别数据编码
base_features = ['user_id', 'article_id', 'city', 'age', 'gender']
feature_max_idx = {}
for f in base_features:
lbe = LabelEncoder()
data[f] = lbe.fit_transform(data[f])
feature_max_idx[f] = data[f].max() + 1
# 构建用户id词典和doc的id词典方便从用户idx找到原始的id
user_id_enc = data[['user_id']].drop_duplicates('user_id')
doc_id_enc = data[['article_id']].drop_duplicates('article_id')
user_idx_2_rawid = dict(zip(user_id_enc['user_id'], user_id_raw['user_id']))
doc_idx_2_rawid = dict(zip(doc_id_enc['article_id'], doc_id_raw['article_id']))
# 保存下每篇文章的被点击数量, 方便后面高热文章的打压
doc_clicked_count_df = data.groupby('article_id')['click'].apply(lambda x: x.count()).reset_index()
doc_clicked_count_dict = dict(zip(doc_clicked_count_df['article_id'], doc_clicked_count_df['click']))
train_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)
# 构造youtubeDNN模型的输入
train_model_input, train_label = gen_model_input(train_set, his_seq_maxlen)
test_model_input, test_label = gen_model_input(test_set, his_seq_maxlen)
# 构建模型并完成训练
model = train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split)
# 获得用户embedding和doc的embedding 并进行保存
user_embs, doc_embs = get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid)
# 对每个用户,拿到召回结果并返回回来
user_recall_doc_dict = get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk)
return user_recall_doc_dict
```
这里面说一下主要逻辑,主要是下面几步:
1. 用户id和文章id我们要先建立索引-原始id的字典因为我们模型里面是要把id转成embedding模型的表示形式会是{索引: embedding}的形式, 如果我们想得到原始id必须先建立起映射来
2. 把类别特征进行label Encoder 模型输入需要, embedding层需要这是构建词典常规操作 这里要记录下每个特征特征值的个数,建词典索引的时候用到,得知道词典大小
3. 保存了下每篇文章被点击数量, 方便后面对高热文章实施打压
4. 构建数据集
```python
rain_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)
```
这个需要解释下, 虽然我们上面有了一个训练集,但是这个东西是不能直接作为模型输入的, 第一个原因是正样本太少样本数量不足我们得需要滑动窗口每个用户再滑动构造一些第二个是不满足deepmatch实现的模型输入格式所以gen_data_set这个函数是用deepmatch YouTubeDNN的第一个范式基本上得按照这个来只不过我加了一些策略上的尝试:
```python
def gen_data_set(click_data, doc_clicked_count_dict, negsample, control_users=False):
"""构造youtubeDNN的数据集"""
# 按照曝光时间排序
click_data.sort_values("expo_time", inplace=True)
item_ids = click_data['article_id'].unique()
train_set, test_set = [], []
for user_id, hist_click in tqdm(click_data.groupby('user_id')):
# 这里按照expo_date分开每一天用滑动窗口滑可能相关性更高些,另外这样序列不会太长因为eda发现有点击1111个的
#for expo_date, hist_click in hist_date_click.groupby('expo_date'):
# 用户当天的点击历史id
pos_list = hist_click['article_id'].tolist()
user_control_flag = True
if control_users:
user_samples_cou = 0
# 过长的序列截断
if len(pos_list) > 50:
pos_list = pos_list[-50:]
if negsample > 0:
neg_list = gen_neg_sample_candiate(pos_list, item_ids, doc_clicked_count_dict, negsample, methods='multinomial')
# 只有1个的也截断 去掉,当然我之前做了处理,这里没有这种情况了
if len(pos_list) < 2:
continue
else:
# 序列至少是2
for i in range(1, len(pos_list)):
hist = pos_list[:i]
# 这里采用打压热门item策略降低高展item成为正样本的概率
freq_i = doc_clicked_count_dict[pos_list[i]] / (np.sum(list(doc_clicked_count_dict.values())))
p_posi = (np.sqrt(freq_i/0.001)+1)*(0.001/freq_i)
# p_posi=0.3 表示该item_i成为正样本的概率是0.3
if user_control_flag and i != len(pos_list) - 1:
if random.random() > (1-p_posi):
row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 1, len(hist[::-1])]
train_set.append(row)
for negi in range(negsample):
row = [user_id, hist[::-1], neg_list[i*negsample+negi], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 0, len(hist[::-1])]
train_set.append(row)
if control_users:
user_samples_cou += 1
# 每个用户序列最长是50 即每个用户正样本个数最多是50个, 如果每个用户训练样本数量到了30个训练集不能加这个用户了
if user_samples_cou > 30:
user_samples_cou = False
# 整个序列加入到test_set 注意,这里一定每个用户只有一个最长序列,相当于测试集数目等于用户个数
elif i == len(pos_list) - 1:
row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], 0, 0, len(hist[::-1])]
test_set.append(row)
random.shuffle(train_set)
random.shuffle(test_set)
return train_set, test_set
```
关键代码逻辑是首先点击数据按照时间戳排序,然后按照用户分组,对于每个用户的历史点击, 采用滑动窗口的形式,边滑动边构造样本, 第一个注意的地方,是每滑动一次生成一条正样本的时候, 要加入一定比例的负样本进去, 第二个注意最后一整条序列要放到test_set里面。<br><br>我这里面加入的一些策略负样本候选集生成我单独写成一个函数因为尝试了随机采样和打压热门item采样两种方式 可以通过methods参数选择。 另外一个就是正样本里面也按照热门实现了打压, 减少高热item成为正样本概率增加高热item成为负样本概率。 还加了一个控制用户样本数量的参数,去保证每个用户生成一样多的样本数量,打压下高活用户。
5. 构造模型输入
这个也是调包的定式操作,必须按照这个写法来:
```python
def gen_model_input(train_set, his_seq_max_len):
"""构造模型的输入"""
# row: [user_id, hist_list, cur_doc_id, city, age, gender, label, hist_len]
train_uid = np.array([row[0] for row in train_set])
train_hist_seq = [row[1] for row in train_set]
train_iid = np.array([row[2] for row in train_set])
train_u_city = np.array([row[3] for row in train_set])
train_u_age = np.array([row[4] for row in train_set])
train_u_gender = np.array([row[5] for row in train_set])
train_u_example_age = np.array([row[6] for row in train_set])
train_label = np.array([row[7] for row in train_set])
train_hist_len = np.array([row[8] for row in train_set])
train_seq_pad = pad_sequences(train_hist_seq, maxlen=his_seq_max_len, padding='post', truncating='post', value=0)
train_model_input = {
"user_id": train_uid,
"click_doc_id": train_iid,
"hist_doc_ids": train_seq_pad,
"hist_len": train_hist_len,
"u_city": train_u_city,
"u_age": train_u_age,
"u_gender": train_u_gender,
"u_example_age":train_u_example_age
}
return train_model_input, train_label
```
上面构造数据集的时候,是把每个特征加入到了二维数组里面去, 这里得告诉模型,每一个维度是啥特征数据。如果相加特征,首先构造数据集的时候,得把数据加入到数组中, 然后在这个函数里面再指定新加入的特征是啥。 下面的那个词典, 是为了把数据输入和模型的Input层给对应起来通过字典键进行标识。
6. 训练YouTubeDNN
这一块也是定式, 在建模型事情,要把特征封装起来,告诉模型哪些是离散特征,哪些是连续特征, 模型要为这些特征建立不同的Input层处理方式是不一样的
```python
def train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split):
"""构建youtubednn并完成训练"""
# 特征封装
user_feature_columns = [
SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
VarLenSparseFeat(SparseFeat('hist_doc_ids', feature_max_idx['article_id'], embedding_dim,
embedding_name="click_doc_id"), his_seq_maxlen, 'mean', 'hist_len'),
SparseFeat('u_city', feature_max_idx['city'], embedding_dim),
SparseFeat('u_age', feature_max_idx['age'], embedding_dim),
SparseFeat('u_gender', feature_max_idx['gender'], embedding_dim),
DenseFeat('u_example_age', 1,)
]
doc_feature_columns = [
SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim)
# 这里后面也可以把文章的类别画像特征加入
]
# 定义模型
model = YoutubeDNN(user_feature_columns, doc_feature_columns, num_sampled=5, user_dnn_hidden_units=(64, embedding_dim))
# 模型编译
model.compile(optimizer="adam", loss=sampledsoftmaxloss)
# 模型训练这里可以定义验证集的比例如果设置为0的话就是全量数据直接进行训练
history = model.fit(train_model_input, train_label, batch_size=batch_size, epochs=epochs, verbose=verbose, validation_split=validation_split)
return model
```
然后就是建模型编译训练即可。这块就非常简单了当然模型方面有些参数可以了解下另外一个注意点就是这里用户特征和item特征进行了分开 这其实和双塔模式很像, 用户特征最后编码成用户向量, item特征最后编码成item向量。
7. 获得用户向量和item向量
模型训练完之后就能从模型里面拿用户向量和item向量 我这里单独写了一个函数:
```python
获取用户embedding和文章embedding
def get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid, save_path='embedding/'):
doc_model_input = {'click_doc_id':np.array(list(doc_idx_2_rawid.keys()))}
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
doc_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
# 保存当前的item_embedding 和 user_embedding 排序的时候可能能够用到但是需要注意保存的时候需要和原始的id对应
user_embs = user_embedding_model.predict(test_model_input, batch_size=2 ** 12)
doc_embs = doc_embedding_model.predict(doc_model_input, batch_size=2 ** 12)
# embedding保存之前归一化一下
user_embs = user_embs / np.linalg.norm(user_embs, axis=1, keepdims=True)
doc_embs = doc_embs / np.linalg.norm(doc_embs, axis=1, keepdims=True)
# 将Embedding转换成字典的形式方便查询
raw_user_id_emb_dict = {user_idx_2_rawid[k]: \
v for k, v in zip(user_idx_2_rawid.keys(), user_embs)}
raw_doc_id_emb_dict = {doc_idx_2_rawid[k]: \
v for k, v in zip(doc_idx_2_rawid.keys(), doc_embs)}
# 将Embedding保存到本地
pickle.dump(raw_user_id_emb_dict, open(save_path + 'user_youtube_emb.pkl', 'wb'))
pickle.dump(raw_doc_id_emb_dict, open(save_path + 'doc_youtube_emb.pkl', 'wb'))
# 读取
#user_embs_dict = pickle.load(open('embedding/user_youtube_emb.pkl', 'rb'))
#doc_embs_dict = pickle.load(open('embedding/doc_youtube_emb.pkl', 'rb'))
return user_embs, doc_embs
```
获取embedding的这两行代码是固定操作 下面做了一些归一化操作以及把索引转成了原始id的形式。
8. 向量最近邻检索为每个用户召回相似item
```python
def get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk):
"""近邻检索这里用annoy tree"""
# 把doc_embs构建成索引树
f = user_embs.shape[1]
t = AnnoyIndex(f, 'angular')
for i, v in enumerate(doc_embs):
t.add_item(i, v)
t.build(10)
# 可以保存该索引树 t.save('annoy.ann')
# 每个用户向量, 返回最近的TopK个item
user_recall_items_dict = collections.defaultdict(dict)
for i, u in enumerate(user_embs):
recall_doc_scores = t.get_nns_by_vector(u, topk, include_distances=True)
# recall_doc_scores是(([doc_idx], [scores])) 这里需要转成原始doc的id
raw_doc_scores = list(recall_doc_scores)
raw_doc_scores[0] = [doc_idx_2_rawid[i] for i in raw_doc_scores[0]]
# 转换成实际用户id
try:
user_recall_items_dict[user_idx_2_rawid[i]] = dict(zip(*raw_doc_scores))
except:
continue
# 默认是分数从小到大排的序, 这里要从大到小
user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()}
# 保存一份
pickle.dump(user_recall_items_dict, open('youtube_u2i_dict.pkl', 'wb'))
return user_recall_items_dict
```
用了用户embedding和item向量就可以通过这个函数进行检索 这块主要是annoy包做近邻检索的固定格式 检索完毕为用户生成最相似的200个候选item。
以上就是使用YouTubeDNN做召回的整个流程。 效果如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/e904362d28fd4bdbacb5715ff2abaac2.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
这个字典长这样:
<div align=center>
<img src="https://img-blog.csdnimg.cn/840e3abaf30845499f0926c61ba88635.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
接下来就是评估模型的效果这里我采用了简单的HR@N计算的 具体代码看GitHub吧 结果如下:
<div align=center>
<img src="https://img-blog.csdnimg.cn/eb6ccadaa98e46bd87e594ee11e957a7.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
结果不怎么样啊,唉, 难道是数据量太少了? 总归是跑起来且能用了。
详细代码见尾部GitHub链接吧 硬件设施到位的可以尝试多用一些数据试试看哈哈。
## YouTubeDNN新闻推荐数据集的实验记录
这块就比较简单了,简单的整理下我用上面代码做个的实验,尝试了论文里面的几个点,记录下:
1. 负采样方式上尝试了随机负采样和打压高热item两种方式 从我的实验结果上来看, 带打压的效果略好一点点
<div align=center>
<img src="https://img-blog.csdnimg.cn/7cf27f1b849049f0b4bd98d0ebb7925f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBATWlyYWNsZTgwNzA=,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
2. 特征上, 尝试原论文给出的example age的方式做一个样本的年龄特征出来
这个年龄样本我是用的训练集的最大时间减去曝光的时间然后转成小时间隔算的而测试集里面的统一用0表示 但效果好差。 看好多文章说这个时间单位是个坑,不知道是小时,分钟,另外这个特征我只做了简单归一化,感觉应该需要做归一化
<div align=center>
<img src="https://img-blog.csdnimg.cn/1ea482f538c94b8bb07a69023b14ca9b.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
3. 尝试了控制用户数量,即每个用户的样本数量保持一样,效果比上面略差
<div align=center>
<img src="https://img-blog.csdnimg.cn/8653b76d0b434d1088da196ce94bb954.png#pic_center" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
4. 开始模型评估,我尝试用最后一天的,而不是最后一次点击的, 感觉效果不如最后一次点击作为测试集效果好
当然,上面实验并没有太大说服力,第一个是我采样的数据量太少,模型本身训练的不怎么样,第二个这些策略相差的并不是很大, 可能有偶然性。
并且我这边做一次实验,要花费好长时间,探索就先到这里吧, example age那个确实是个迷 其他的感觉起来, 打压高活效果要比不打压要好。
另外要记录下学习小tricks:
> 跑一次这样的实验,我这边一般会花费两个小时左右的时间, 而这个时间在做实验之前,一定要做规划才能好好的利用起来, 比如,我计划明天上午要开始尝试各种策略做实验, 今天晚上的todo里面就要记录好 我会尝试哪些策略,记录一个表, 调整策略,跑模型的时候,我这段空档要干什么事情, todo里面都要记录好比如我这段空档就是解读这篇paper写完这篇博客基本上是所有实验做完我这篇博客也差不多写完正好哈哈<br><br>这个空档利用一定要提前在todo里面写好而不是跑模型的时候再想这个时候往往啥也干不下去并且还会时不时的看模型跑或者盯着进度条发呆那这段时间就有些浪费了呀即使这段时间不学习看个久违的电视剧 久违的书或者keep下不香吗哈哈 但得提前规划。<br><br>可能每个人习惯不一样,对于我,是这样哈,所以记录下 ;)
## 总结
由于这篇文章里面的工程经验太多啦,我前面介绍的时候,可能涉及到知识的一些扩展补充,把经验整理的比较凌乱,这里再统一整理下, 这些也都是工业界常用的一些经验了:
召回部分:
1. 训练数据的样本来源应该是全部物料, 而不仅仅是被推荐的物料,否则对于新物料难以曝光
2. 训练数据中对于每个用户选取相同的样本数, 保证用户在损失函数等权重, 这个虽然不一定非得这么做但考虑打压高活用户或者是高活item的影响还是必须的
3. 序列无序化: 用户的最近一次搜索与搜索之后的播放行为有很强关联,为了避免信息泄露,将搜索行为顺序打乱。
4. 训练数据构造: 预测接下来播放而不是用传统cbow中的两侧预测中间的考虑是可以防止信息泄露并且可以学习到用户的非对称视频消费模式
5. 召回模型中类似word2vecvideo 有input embedding和output embedding两组embedding并不是共享的 input embedding论文里面是用w2v事先训练好的 其实也可以用embedding层联合训练
6. 召回模型的用户embedding来自网络输出 而video的embedding往往用后面output处的
7. 使用 `example age` 特征处理 time bias这样线上检索时可以预先计算好用户向量
**参考资料**
* [重读Youtube深度学习推荐系统论文](https://zhuanlan.zhihu.com/p/52169807)
* [YouTube深度学习推荐系统的十大工程问题](https://zhuanlan.zhihu.com/p/52169807)
* [你真的读懂了Youtube DNN推荐论文吗](https://zhuanlan.zhihu.com/p/372238343)
* [推荐系统经典论文(二)】YouTube DNN](https://zhuanlan.zhihu.com/p/128597084)
* [张俊林-推荐技术发展趋势与召回模型](https://www.icode9.com/content-4-764359.html)
* [揭开YouTube深度推荐系统模型Serving之谜](https://zhuanlan.zhihu.com/p/61827629)
* [Deep Neural Networks for YouTube Recommendations YouTubeDNN推荐召回与排序](https://www.pianshen.com/article/82351182400/)

View File

@@ -0,0 +1,308 @@
# 背景介绍
**文章核心思想**
+ 在大规模的推荐系统中利用双塔模型对user-item对的交互关系进行建模学习 $\{usercontext\}$ 向量与 $\{item\}$ 向量.
+ 针对大规模流数据提出in-batch softmax损失函数与流数据频率估计方法(Streaming Frequency Estimation)可以更好的适应item的多种数据分布。
**文章主要贡献**
+ 提出了改进的流数据频率估计方法针对流数据来估计item出现的频率利用实验分析估计结果的偏差与方差模拟实验证明该方法在数据动态变化时的功效
+ 提出了双塔模型架构:提供了一个针对大规模的检索推荐系统,包括了 in-batch softmax 损失函数与流数据频率估计方法减少了负采样在每个batch中可能会出现的采样偏差问题。
# 算法原理
给定一个查询集 $Query: \left\{x_{i}\right\}_{i=1}^{N}$ 和一个物品集$Item:\left\{y_{j}\right\}_{j=1}^{M}$。
+ $x_{i} \in X,\quad y_{j} \in \mathcal{Y}$ 是由多种特征例如稀疏ID和 Dense 特征)组成的高维混合体。
+ 推荐的目标是对于给定一个 $query$,检索到一系列 $item$ 子集用于后续排序推荐任务。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506202824884.png" alt="image-20220506202824884" style="zoom:50%;" />
## 模型目标
模型结构如上图所示,论文旨在对用户和物品建立两个不同的模型,将它们投影到相同维度的空间:
$$
u: X \times \mathbb{R}^{d} \rightarrow \mathbb{R}^{k}, v: y \times \mathbb{R}^{d} \rightarrow \mathbb{R}^{k}
$$
模型的输出为用户与物品向量的内积:
$$
s(x, y)=\langle u(x, \theta), v(y, \theta)\rangle
$$
模型的目标是为了学习参数 $\theta$ 样本集被表示为如下格式 $\{query, item, reward \}$
$$
\mathcal{T}:=\left\{\left(x_{i}, y_{i}, r_{i}\right)\right\}_{i=1}^{T}
$$
* 在推荐系统中,$r_i$ 可以扩展来捕获用户对不同候选物品的参与度。
* 例如,在新闻推荐中 $r_i$ 可以是用户在某篇文章上花费的时间。
## 模型流程
1. 给定用户 $x$,基于 softmax 函数从物料库 $M$ 中选中候选物品 $y$ 的概率为:
$$
\mathcal{P}(y \mid x ; \theta)=\frac{e^{s(x, y)}}{\sum_{j \in[M]} e^{s\left(x, y_{j}\right)}}
$$
* 考虑到相关奖励 $r_i$ ,加权对数似然函数的定义如下:
$$
L_{T}(\theta):=-\frac{1}{T} \sum_{i \in[T]} r_{i} \cdot \log \left(\mathcal{P}\left(y_{i} \mid x_{i} ; \theta\right)\right)
$$
2. 原表达式 $\mathcal{P}(y \mid x ; \theta)$ 中的分母需要遍历物料库中所有的物品,计算成本太高,故对分母中的物品要进行负采样。为了提高负采样的速度,一般是直接从训练样本所在 Batch 中进行负样本选择。于是有:
$$
\mathcal{P}_{B}\left(y_{i} \mid x_{i} ; \theta\right)=\frac{e^{s\left(x_{i}, y_{i}\right)}}{\sum_{j \in[B]} e^{s\left(x_{i}, y_{j}\right)}}
$$
* 其中,$B$ 表示与样本 $\{x_i,y_j\}$ 同在一个 Batch 的物品集合。
* 举例来说对于用户1Batch 内其他用户的正样本是用户1的负样本。
3. 一般而言,负采样分为 Easy Negative Sample 和 Hard Negative Sample。
+ 这里的 Easy Negative Sample 一般是直接从全局物料库中随机选取的负样本,由于每个用户感兴趣的物品有限,而物料库又往往很大,故即便从物料库中随机选取负样本,也大概率是用户不感兴趣的。
+ 在真实场景中,热门物品占据了绝大多数的购买点击。而这些热门物品往往只占据物料库物品的少部分,绝大部分物品是冷门物品。
+ 在物料库中随机选择负样本,往往被选中的是冷门物品。这就会造成马太效应,热门物品更热,冷门物品更冷。
+ 一种解决方式时,在对训练样本进行负采样时,提高热门物品被选为负样本的概率,工业界的经验做法是物品被选为负样本的概率正比于物品点击次数的 0.75 次幂。
+ 前面提到 Batch 内进行负采样,热门物品出现在一个 Batch 的概率正比于它的点击次数。问题是,热门物品被选为负样本的概率过高了(一般正比于点击次数的 0.75 次幂),导致热门物品被过度打压。
+ 在本文中,为了避免对热门物品进行过度惩罚,进行了纠偏。公式如下:
$$
s^{c}\left(x_{i}, y_{j}\right)=s\left(x_{i}, y_{j}\right)-\log \left(p_{j}\right)
$$
+ 在内积 $s(x_i,y_j)$ 的基础上,减去了物品 $j$ 的采样概率的对数。
4. 纠偏后,物品 $y$ 被选中的概率为:
$$
\mathcal{P}_{B}^{c}\left(y_{i} \mid x_{i} ; \theta\right)=\frac{e^{s^{c}\left(x_{i}, y_{i}\right)}}{e^{s^{c}\left(x_{i}, y_{i}\right)}+\sum_{j \in[B], j \neq i} e^{s^{c}\left(x_{i}, y_{j}\right)}}
$$
+ 此时batch loss function 的表示式如下:
$$
L_{B}(\theta):=-\frac{1}{B} \sum_{i \in[B]} r_{i} \cdot \log \left(\mathcal{P}_{B}^{c}\left(y_{i} \mid x_{i} ; \theta\right)\right)
$$
+ 通过 SGD 和学习率,来优化模型参数 $\theta$
$$
\theta \leftarrow \theta-\gamma \cdot \nabla L_{B}(\theta)
$$
5. Normalization and Temperature
* 最后一层,得到用户和物品的特征 Embedding 表示后,再进行进行 $l2$ 归一化:
$$
\begin{aligned}
u(x, \theta) \leftarrow u(x, \theta) /\|u(x, \theta)\|_{2}
\\
v(y, \theta) \leftarrow v(y, \theta) /\|v(y, \theta)\|_{2}
\end{aligned}
$$
+ 本质上,其实就是将用户和物品的向量内积转换为了余弦相似度。
* 对于内积的结果,再除以温度参数 $\tau$
$$
s(x, y)=\langle u(x, \theta), v(y, \theta)\rangle / \tau
$$
+ 论文提到,这样有利于提高预测准确度。
+ 从实验结果来看,温度参数 $\tau$ 一般小于 $1$,所以感觉就是放大了内积结果。
**上述模型训练过程可以归纳为:**
1从实时数据流中采样得到一个 batch 的训练样本。
2基于流频估计法估算物品 $y_i$ 的采样概率 $p_i$ 。
3计算损失函数 $L_B$ ,再利用 SGD 方法更新参数。
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506211935092.png" alt="image-20220506211935092" style="zoom: 50%;" />
## 流频估计算法
考虑一个随机的数据 batch ,每个 batch 中包含一组物品。现在的问题是如何估计一个 batch 中物品 $y$ 的命中概率。具体方法如下:
+ 利用全局步长,将对物品采样频率 $p$ 转换为 对 $\delta$ 的估计,其中 $\delta$ 表示连续两次采样物品之间的平均步数。
+ 例如,某物品平均 50 个步后会被采样到,那么采样频率 $p=1/\delta=0.02$ 。
**具体的实现方法为:**
1. 建立两个大小为 $H$ 的数组 $A,B$ 。
2. 通过哈希函数 $h(\cdot)$ 可以把每个物品映射为 $[H]$ 范围内的整数。
+ 映射的内容可以是 ID 或者其他的简单特征值。
+ 对于给定的物品 $y$,哈希后的整数记为 $h(y)$,本质上它表示物品 $y$ 在数组中的序号。
3. 数组 $A$ 中存放的 $A[h(y)]$ 表示物品 $y$ 上次被采样的时间, 数组 $B$ 中存放的 $B[h(y)]$ 表示物品 $y$ 的全局步长。
+ 假设在第 $t$ 步时采样到物品 $y$,则 $A[h(y)]$ 和 $B[h(y)]$ 的更新公式为:
$$
B[h(y)] \leftarrow(1-\alpha) \cdot B[h(y)]+\alpha \cdot(t-A[h(y)])
$$
+ 在$B$ 被更新后,将 $t$ 赋值给 $A[h(y)]$ 。
4. 对整个batch数据采样后取数组 $B$ 中 $B[h(y)]$ 的倒数,作为物品 $y$ 的采样频率,即:
$$
\hat{p}=1 / B[h(y)]
$$
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506220529932.png" alt="image-20220506220529932" style="zoom:50%;" />
**从数学理论上证明这种迭代更新的有效性:**
假设物品 $y$ 被采样到的时间间隔序列为 $\Delta=\left\{\Delta_{1}, \ldots, \Delta_{t}\right\}$ 满足独立同分布,这个随机变量的均值为$\delta=E[\Delta]$。对于每一次采样迭代:$\delta_{i}=(1-\alpha) \delta_{i-1}+\alpha \Delta_{i}$,可以证明时间间隔序列的均值和方差满足:
$$
\begin{aligned}
& E\left(\delta_{t}\right)-\delta=(1-\alpha)^{t} \delta_{0}-(1-\alpha)^{t-1} \delta
\\ \\
& E\left[\left(\delta_{t}-E\left[\delta_{t}\right]\right)^{2}\right] \leq(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha E\left[\left(\Delta_{1}-\alpha\right)^{2}\right]
\end{aligned}
$$
1. **对于均值的证明:**
$$
\begin{aligned}
E\left[\delta_{t}\right] &=(1-\alpha) E\left[\delta_{t-1}\right]+\alpha \delta \\
&=(1-\alpha)\left[(1-\alpha) E\left[\delta_{t-2}\right]+\alpha \delta\right]+\alpha \delta \\
&=(1-\alpha)^{2} E\left[\delta_{t-2}\right]+\left[(1-\alpha)^{1}+(1-\alpha)^{0}\right] \alpha \delta \\
&=(1-\alpha)^{3} E\left[\delta_{t-3}\right]+\left[(1-\alpha)^{2}+(1-\alpha)^{1}+(1-\alpha)^{0}\right] \alpha \delta \\
&=\ldots \ldots \\
&=(1-\alpha)^{t} \delta_{0}+\left[(1-\alpha)^{t-1}+\ldots+(1-\alpha)^{1}+(1-\alpha)^{0}\right] \alpha \delta \\
&=(1-\alpha)^{t} \delta_{0}+\left[1-(1-\alpha)^{t-1}\right] \delta
\end{aligned}
$$
+ 根据均值公式可以看出:$t \rightarrow \infty \text { 时, }\left|E\left[\delta_{t}\right]-\delta\right| \rightarrow 0 $ 。
+ 即当采样数据足够多的时候,数组 $B$ (每多少步采样一次)趋于真实采样频率。
+ 因此递推式合理,且当初始值 $\delta_{0}=\delta /(1-\alpha)$,递推式为无偏估计。
2. **对于方差的证明:**
$$
\begin{aligned}
E\left[\left(\delta_{t}-E\left[\delta_{t}\right]\right)^{2}\right] &=E\left[\left(\delta_{t}-\delta+\delta-E\left[\delta_{t}\right]\right)^{2}\right] \\
&=E\left[\left(\delta_{t}-\delta\right)^{2}\right]+2 E\left[\left(\delta_{t}-\delta\right)\left(\delta-E\left[\delta_{t}\right]\right)\right]+\left(\delta-E\left[\delta_{t}\right]\right)^{2} \\
&=E\left[\left(\delta_{t}-\delta\right)^{2}\right]-\left(E\left[\delta_{t}\right]-\delta\right)^{2} \\
& \leq E\left[\left(\delta_{t}-\delta\right)^{2}\right]
\end{aligned}
$$
+ 对于 $E\left[\left(\delta_{i}-\delta\right)^{2}\right]$
$$
\begin{aligned}
E\left[\left(\delta_{i}-\delta\right)^{2}\right] &=E\left[\left((1-\alpha) \delta_{i-1}+\alpha \Delta_{i}-\delta\right)^{2}\right] \\
&=E\left[\left((1-\alpha) \delta_{i-1}+\alpha \Delta_{i}-(1-\alpha+\alpha) \delta\right)^{2}\right] \\
&=E\left[\left((1-\alpha)\left(\delta_{i-1}-\delta\right)+\alpha\left(\Delta_{i}-\delta\right)\right)^{2}\right] \\
&=(1-\alpha)^{2} E\left[\left(\delta_{i-1}-\delta\right)^{2}\right]+\alpha^{2} E\left[\Delta_{i}-\delta\right]^{2}+2 \alpha(1-\alpha) E\left[\left(\delta_{i-1}-\delta\right)\left(\Delta_{i}-\delta\right)\right]
\end{aligned}
$$
+ 由于 $\delta_{i-1}$ 和 $\Delta_{i}$ 独立,所以上式最后一项为 0因此
$$
E\left[\left(\delta_{i}-\delta\right)^{2}\right]=(1-\alpha)^{2} E\left[\left(\delta_{i-1}-\delta\right)^{2}\right]+\alpha^{2} E\left[\Delta_{i}-\delta\right]^{2}
$$
+ 与均值的推导类似,可得:
$$
\begin{aligned}
E\left[\left(\delta_{t}-\delta\right)^{2}\right] &=(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha^{2} \frac{1-(1-\alpha)^{2 t-2}}{1-(1-\alpha)^{2}} E\left[\left(\Delta_{1}-\delta\right)^{2}\right] \\
& \leq(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha E\left[\left(\Delta_{1}-\delta\right)^{2}\right]
\end{aligned}
$$
+ 由此可证明:
$$
E\left[\left(\delta_{t}-E\left[\delta_{t}\right]\right)^{2}\right] \leq(1-\alpha)^{2 t}\left(\delta_{0}-\delta\right)^{2}+\alpha E\left[\left(\Delta_{1}-\alpha\right)^{2}\right]
$$
+ 对于方差,上式给了一个估计方差的上界。
## 多重哈希
上述流动采样频率估计算法存在的问题:
+ 对于不同的物品,经过哈希函数映射的整数可能相同,这就会导致哈希碰撞的问题。
+ 由于哈希碰撞,对导致对物品采样频率过高的估计。
**解决方法:**
* 使用 $m$ 个哈希函数,取 $m$ 个估计值中的最大值来表示物品连续两次被采样到之间的步长。
**具体的算法流程:**
1. 分别建立 $m$ 个大小为 $H$ 的数组 $\{A\}_{i=1}^{m}$$\{B\}_{i=1}^{m}$,一组对应的独立哈希函数集合 $\{h\}_{i=1}^{m}$ 。
2. 通过哈希函数 $h(\cdot)$ 可以把每个物品映射为 $[H]$ 范围内的整数。对于给定的物品 $y$,哈希后的整数记为$h(y)$
3. 数组 $A_i$ 中存放的 $A_i[h(y)]$ 表示在第 $i$ 个哈希函数中物品 $y$ 上次被采样的时间。数组 $B_i$ 中存放的 $B_i[h(y)]$ 表示在第 $i$ 个哈希函数中物品 $y$ 的全局步长。
4. 假设在第 $t$ 步采样到物品 $y$,分别对 $m$ 个哈希函数对应的 $A[h(y)]$ 和 $B[h(y)]$ 进行更新:
$$
\begin{aligned}
& B_i[h(y)] \leftarrow(1-\alpha) \cdot B_i[h(y)]+\alpha \cdot(t-A_i[h(y)])\\ \\
& A_i[h(y)]\leftarrow t
\end{aligned}
$$
5. 对整个 batch 数据采样后,取 $\{B\}_{i=1}^{m}$ 中最大的 $B[h(y)]$ 的倒数,作为物品 $y$ 的采样频率,即:
$$
\hat{p}=1 / \max _{i}\left\{B_{i}[h(y)]\right\}
$$
<img src="https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506223731749.png" alt="image-20220506223731749" style="zoom:50%;" />
# YouTube 神经召回模型
本文构建的 YouTube 神经检索模型由查询和候选网络组成。下图展示了整体的模型架构。
![image-20220506224501697](https://ryluo.oss-cn-chengdu.aliyuncs.com/%E5%9B%BE%E7%89%87image-20220506224501697.png)
在任何时间点,用户正在观看的视频,即种子视频,都会提供有关用户当前兴趣的强烈信号。因此,本文利用了大量种子视频特征以及用户的观看历史记录。候选塔是为了从候选视频特征中学习而构建的。
* Training Label
* 视频点击被用作正面标签。对于每次点击,我们都会构建一个 rewards 来反映用户对视频的不同程度的参与。
* $r_i$ = 0观看时间短的点击视频$r_i$ = 1表示观看了整个视频。
* Video Features
* YouTube 使用的视频特征包括 categorical 特征和 dense 特征。
* 例如 categorical 特征有 video id 和 channel id 。
* 对于 categorical 特征,都会创建一个嵌入层以将每个分类特征映射到一个 Embedding 向量。
* 通常 YouTube 要处理两种类别特征。从原文的意思来看,这两类应该是 one-hot 型和 multi-hot 型。
* User Features
* 使用**用户观看历史记录**来捕捉 seed video 之外的兴趣。将用户最近观看的 k个视频视为一个词袋BOW),然后将它们的 Embedding 平均。
* 在查询塔中,最后将用户和历史 seed video 的特征进行融合,并送入输入前馈神经网络。
* 类别特征的 Embedding 共享
* 原文For the same type of IDs, embeddings are shared among the related features. For example, the same set of video id embeddings is used for seed, candidate and users past watches. We did experiment with non-shared embeddings, but did not observe significant model quality improvement.
* 大致意思就是,对于相同 ID 的类别,他们之间的 Embedding 是共享的。例如对于 seed video出现的地方包括用户历史观看以及作为候选物品故只要视频的 ID 相同Embedding也是相同的。如果不共享也没啥提升。
# 参考链接
+ [Sampling-bias-corrected neural modeling for large corpus item recommendations | Proceedings of the 13th ACM Conference on Recommender Systems](https://dl.acm.org/doi/abs/10.1145/3298689.3346996)
+ [【推荐系统经典论文(九)】谷歌双塔模型 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/137538147)
+ [借Youtube论文谈谈双塔模型的八大精髓问题 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/369152684)

View File

@@ -0,0 +1,114 @@
# 前言
在自然语言处理NLP领域谷歌提出的 Word2Vec 模型是学习词向量表示的重要方法。其中带有负采样SGNSSkip-gram with negative sampling的 Skip-Gram 神经词向量模型在当时被证明是最先进的方法之一。各位读者需要自行了解 Word2Vec 中的 Skip-Gram 模型,本文只会做简单介绍。
在论文 Item2VecNeural Item Embedding for Collaborative Filtering 中,作者受到 SGNS 的启发,提出了名为 Item2Vec 的方法来生成物品的向量表示,然后将其用于基于物品的协同过滤。
# 基于负采样的 Skip-Gram 模型
Skip-Gram 模型的思想很简单:给定一个句子 $(w_i)^K_{i=1}$,然后基于中心词来预测它的上下文。目标函数如下:
$$
\frac{1}{K} \sum_{i=1}^{K} \sum_{-c \leq j \leq c, j \neq 0} \log p\left(w_{i+j} \mid w_{i}\right)
$$
+ 其中,$c$ 表示上下文的窗口大小;$w_i$ 表示中心词;$w_{i+j}$ 表示上下文。
+ 表达式中的概率 $p\left(w_{j} \mid w_{i}\right)$ 的公式为:
$$
p\left(w_{j} \mid w_{i}\right)=\frac{\exp \left(u_{i}^{T} v_{j}\right)}{\sum_{k \in I_{W}} \exp \left(u_{i}^{T} v_{k}^{T}\right)}
$$
+ $u_{i} \in U\left(\subset \mathbb{R}^{m}\right),v_{i} \in V\left(\subset \mathbb{R}^{m}\right)$,分别对应中心和上下文词的 Embedding 特征表示。
+ 这里的意思是每个单词有2个特征表示作为中心词 $u_i$ 和上下文 $v_i$ 时的特征表示不一样。
+ $I_{W} \triangleq\{1, \ldots,|W|\}$ $|W|$ 表示语料库中词的数量。
简单来理解一下 Skip-Gram 模型的表达式:
+ 对于句子中的某个词 $w_i$,当其作为中心词时,希望尽可能准确预测它的上下文。
+ 我们可以将其转换为多分类问题:
+ 对于中心词 $w_i$ 预测的上下文 $w_j$,其 $label=1$ ;那么,模型对上下文的概率预测 $p\left(w_{j} \mid w_{i}\right)$ 越接近1越好。
+ 若要 $p\left(w_{j} \mid w_{i}\right)$ 接近1对于分母项中的 $k\ne j$,其 $\exp \left(u_{i}^{T} v_{k}^{T}\right)$ 越小越好(等价于将其视为了负样本)。
注意到分母项由于需要遍历语料库中所有的单词从而导致计算成本过高。一种解决办法是基于负采样NEG的方式来降低计算复杂度
$$
p\left(w_{j} \mid w_{i}\right)=\sigma\left(u_{i}^{T} v_{j}\right) \prod_{k=1}^{N} \sigma\left(-u_{i}^{T} v_{k}\right)
$$
+ 其中,$\sigma(x)=1/1+exp(-x)$$N$ 表示负样本的数量。
其它细节:
+ 单词 $w$ 作为负样本时,被采样到的概率:
$$
\frac{[\operatorname{counter}(w)]^{0.75}}{\sum_{u \in \mathcal{W}}[\operatorname{counter}(u)]^{0.75}}
$$
+ 单词 $w$ 作为中心词时,被丢弃的概率:
$$
\operatorname{prob}(w)=1-\sqrt{\frac{t}{f(w)}}
$$
# Item2Vec模型
Item2Vec 的原理十分十分简单,它是基于 Skip-Gram 模型的物品向量训练方法。但又存在一些区别,如下:
+ 词向量的训练是基于句子序列sequence但是物品向量的训练是基于物品集合set
+ 因此,物品向量的训练丢弃了空间、时间信息。
Item2Vec 论文假设对于一个集合的物品,它们之间是相似的,与用户购买它们的顺序、时间无关。当然,该假设在其他场景下不一定使用,但是原论文只讨论了该场景下它们实验的有效性。由于忽略了空间信息,原文将共享同一集合的每对物品视为正样本。目标函数如下:
$$
\frac{1}{K} \sum_{i=1}^{K} \sum_{j \neq i}^{K} \log p\left(w_{j} \mid w_{i}\right)
$$
+ 对于窗口大小 $K$,由设置的决定。
在 Skip-Gram 模型中,提到过每个单词 $w_i$ 有2个特征表示。在 Item2Vec 中同样如此,论文中是将物品的中心词向量 $u_i$ 作为物品的特征向量。作者还提到了其他两种方式来表示物品向量:
+ **add**$u_i + v_i$
+ **concat**$\left[u_{i}^{T} v_{i}^{T}\right]^{T}$
原文还补充到,这两种方式有时候会有很好的表现。
# 总结
+ Item2Vec 的原理很简单,就是基于 Word2Vec 的 Skip-Gram 模型,并且还丢弃了时间、空间信息。
+ 基于 Item2Vec 得到物品的向量表示后,物品之间的相似度可由二者之间的余弦相似度计算得到。
+ 可以看出Item2Vec 在计算物品之间相似度时,仍然依赖于不同物品之间的共现。因为,其无法解决物品的冷启动问题。
+ 一种解决方法:取出与冷启物品类别相同的非冷启物品,将它们向量的均值作为冷启动物品的向量表示。
原论文链接:[[1603.04259\] Item2Vec: Neural Item Embedding for Collaborative Filtering (arxiv.org)](https://arxiv.org/abs/1603.04259)

View File

@@ -0,0 +1,341 @@
# 背景和引入
在所有的NLP任务中首先面临的第一个问题是我们该如何表示单词。这种表示将作为inputs输入到特定任务的模型中如机器翻译文本分类等典型NLP任务。
## 同义词表达单词
一个很容易想到的解决方案是使用同义词来表示一个单词的意义。
比如***WordNet***,一个包含同义词和有“is a”关系的词的词库。
**导包**
```python
!pip install --user -U nltk
```
```python
!python -m nltk.downloader popular
```
**如获取"good"的同义词**
```python
from nltk.corpus import wordnet as wn
poses = { 'n':'noun', 'v':'verb', 's':'adj (s)', 'a':'adj', 'r':'adv'}
for synset in wn.synsets("good"):
print("{}: {}".format(poses[synset.pos()],", ".join([l.name() for l in synset.lemmas()])))
```
**如获取与“pandas”有"is a"关系的词**
```python
panda = wn.synset("panda.n.01")
hyper = lambda s: s.hypernyms()
list(panda.closure(hyper))
```
***WordNet的问题***
1. 单词与单词之间缺少些微差异的描述。比如“高效”只在某些语境下是"好"的同义词
2. 丢失一些词的新含义。比如“芜湖”,“蚌埠”等词的新含义
3. 相对主观
4. 需要人手动创建和调整
5. 无法准确计算单词的相似性
## one-hot编码
在传统NLP中人们使用one-hot向量一个向量只有一个值为1其余的值为0来表示单词
motel = [0 0 0 0 0 0 0 0 0 0 1 0 0 0 0]
hotel = [0 0 0 0 0 0 0 1 0 0 0 0 0 0 0]
one-hot向量的维度是词汇表的大小500000
注:上面示例词向量的维度为方便展示所以比较小
**one-hot向量表示单词的问题**
1. 这些词向量是***正交向量***,无法通过数学计算(如点积)计算相似性
2. 依赖WordNet等同义词库建立相似性效果也不好
## dense word vectors表达单词
如果我们可以使用某种方法为每个单词构建一个合适的dense vector如下图那么通过点积等数学计算就可以获得单词之间的某种联系
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片1.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
# Word2vec
## 语言学基础
首先,我们引入一个上世纪五十年代,一个语言学家的研究成果:**“一个单词的意义由它周围的单词决定”**
“You shall know a word by the company it keeps” (J. R. Firth 1957: 11)
这是现代NLP中一个最为成功的理念。
我们先引入上下文context的概念当单词 w 出现在文本中时,其**上下文context**是出现在w附近的一组单词在固定大小的窗口内如下图
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片2.png" alt="在这里插入图片描述" style="zoom:60%;" />
</div>
这些上下文单词context words决定了banking的意义
## Word2vec概述
Word2vec(Mikolov et al. 2013)是一个用来学习dense word vector的算法
1. 我们使用**大量的文本语料库**
2. 词汇表中的每个单词都由一个**词向量dense word vector**表示
3. 遍历文本中的每个位置 t都有一个**中心词 ccenter 和上下文词 o“outside”**如图1中的banking
4. 在整个语料库上使用数学方法**最大化单词o在单词c周围出现了这一事实**从而得到单词表中每一个单词的dense vector
5. 不断调整词向量dense word vector以达到最好的效果
## Skip-gram(SG)
Word2vec包含两个模型**Skip-gram与CBOW**。下面,我们先讲**Skip-gram**模型,用此模型详细讲解概述中所提到的内容。
概述中我们提到,我们希望**最大化单词o在单词c周围出现了这一事实**而我们需要用数学语言表示“单词o在单词c周围出现了”这一事件如此才能进行词向量的不断调整。
很自然地,我们需要**使用概率工具描述事件的发生**,我们想到用条件概率$P(o|c)$表示“给定中心词c,它的上下文词o在它周围出现了”
下图展示了以“into”为中心词窗口大小为2的情况下它的上下文词。以及相对应的$P(o|c)$
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片3.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
我们滑动窗口再以banking为中心词
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片4.png"在这里插入图片描述" style="zoom:50%;" />
</div>
那么,如果我们在整个语料库上不断地滑动窗口,我们可以得到所有位置的$P(o|c)$,我们希望在所有位置上**最大化单词o在单词c周围出现了这一事实**,由极大似然法,可得:
$$
max\prod_{c} \prod_{o}P(o|c)
$$
此式还可以依图3写为
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片5.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
加log,加负号,缩放大小可得:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片7.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
上式即为**skip-gram的损失函数**,最小化损失函数,就可以得到合适的词向量
得到式1后产生了两个问题
1. P(o|c)怎么表示?
2. 为何最小化损失函数能够得到良好表示的词向量dense word vector
回答1我们使用**中心词c和上下文词o的相似性**来计算$P(o|c)$,更具体地,相似性由**词向量的点积**表示:$u_o \cdot v_c$。
使用词向量的点积表示P(o|c)的原因1.计算简单 2.出现在一起的词向量意义相关,则希望它们相似
又P(o|c)是一个概率,所以我们在整个语料库上使用**softmax**将点积的值映射到概率如图6
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片6.png" alt="在这里插入图片描述" style="zoom:70%;" />
</div>
注:注意到上图,中心词词向量为$v_{c}$,而上下文词词向量为$u_{o}$。也就是说每个词会对应两个词向量,**在词w做中心词时使用$v_{w}$作为词向量,而在它做上下文词时,使用$u_{w}$作为词向量**。这样做的原因是为了求导等操作时计算上的简便。当整个模型训练完成后,我们既可以使用$v_{w}$作为词w的词向量也可以使用$u_{w}$作为词w的词向量亦或是将二者平均。在下一部分的模型结构中我们将更清楚地看到两个词向量究竟在模型的哪个位置。
回答2由上文所述$P(o|c)=softmax(u_{o^T} \cdot v_c)$。所以损失函数是关于$u_{o}$和$v_c$的函数,我们通过梯度下降法调整$u_{o}$和$v_c$的值,最小化损失函数,即得到了良好表示的词向量。
## Word2vec模型结构
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片8.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
如图八所示这是一个输入为1 X V维的one-hot向量V为整个词汇表的长度这个向量只有一个1值其余为0值表示一个词单隐藏层**隐藏层的维度为N这里是一个超参数这个参数由我们定义也就是词向量的维度**输出为1 X V维的softmax层的模型。
$W^{I}$为V X N的参数矩阵$W^{O}$为N X V的参数矩阵。
模型的输入为1 X V形状的one-hot向量V为整个词汇表的长度这个向量只有一个1值其余为0值表示一个词。隐藏层的维度为N这里是一个超参数这个参数由我们定义也就是词向量的维度。$W^{I}$为V X N的参数矩阵。
我们这里考虑Skip-gram算法输入为中心词c的one-hot表示
由输入层到隐藏层,根据矩阵乘法规则,可知,**$W^{I}$的每一行即为词汇表中的每一个单词的词向量v**,1 X V 的 inputs 乘上 V X N 的$W^{I}$隐藏层即为1 X N维的$v_{c}$。
而$W^{O}$中的每一列即为词汇表中的每一个单词的词向量u。根据乘法规则1 X N 的隐藏层乘上N X V的$W^{O}$参数矩阵得到的1 X V 的输出层的每一个值即为$u_{w^T} \cdot v_c$,加上softmax变化即为$P(w|c)$。
有V个w,其中的P(o|c)即实际样本中的上下文词的概率,为我们最为关注的值。
## CBOW
如上文所述Skip-gram为给定中心词预测周围的词即求P(o|c),如下图所示:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220424105817437.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
而CBOW为给定周围的词预测中心词即求P(c|o),如下图所示:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片10.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
注意在使用CBOW时上文所给出的模型结构并没有变在这里我们输入多个上下文词o在隐藏层**将这多个上下文词经过第一个参数矩阵的计算得到的词向量相加作为隐藏单元的值**。其余均不变,$W^{O}$中的每一列依然为为词汇表中的每一个单词的词向量u。
# 负采样 Negative Sampling
## softmax函数带来的问题
我们再看一眼通过softmax得到的$P(o|c)$,如图:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220424105958191.png" alt="在这里插入图片描述" style="zoom:50%;" />
</div>
可以看到,$P(o|c)$的分母需要在整个单词表上做乘积和exp运算这无疑是非常消耗计算资源的Word2vec的作者针对这个问题做出了改进。
他提出了两种改进的方法Hierarchical Softmax和Negative Sampling因为Negative Sampling更加常见所以我们下面只介绍Negative Sampling感兴趣的朋友可以在文章下面的参考资料中学习Hierarchical Softmax。
## 负采样Negative Sampling
我们依然以Skip-gram为例CBOW与之差别不大感兴趣的朋友们依然可以参阅参考资料
我们首先给出负采样的损失函数:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片12.png" alt="在这里插入图片描述" style="zoom:40%;" />
</div>
其中$\sigma$为sigmoid函数$1/(1+e^{-x})$, $u_{o}$为实际样本中的上下文词的词向量,而$u_{k}$为我们在单词表中随机选出按一定的规则随机选出具体可参阅参考资料的K个单词。
由函数单调性易知,**$u_{o^T} \cdot v_c$越大,损失函数越小,而$u_{k^T} \cdot v_c$越小**损失函数越大。这与原始的softmax损失函数优化目标一致即$maxP(o|c)$,而且避免了在整个词汇表上的计算。
# 核心代码与核心推导
## Naive softmax 损失函数
损失函数关于$v_c$的导数:
$$
\frac{\partial{J_{naive-softmax}(\boldsymbol v_c,o,\boldsymbol U)}}{\partial \boldsymbol v_c} \\=
-\frac{\partial{log(P(O=o|C=c))}}{\partial \boldsymbol v_c} \\ =
-\frac{\partial{log(exp( \boldsymbol u_o^T\boldsymbol v_c))}}{\partial \boldsymbol v_c} + \frac{\partial{log(\sum_{w=1}^{V}exp(\boldsymbol u_w^T\boldsymbol v_c))}}{\partial \boldsymbol v_c} \\=
-\boldsymbol u_o + \sum_{w=1}^{V} \frac{exp(\boldsymbol u_w^T\boldsymbol v_c)}{\sum_{w=1}^{V}exp(\boldsymbol u_w^T\boldsymbol v_c)}\boldsymbol u_w \\=
-\boldsymbol u_o+ \sum_{w=1}^{V}P(O=w|C=c)\boldsymbol u_w \\=
\boldsymbol U^T(\hat{\boldsymbol y} - \boldsymbol y)
$$
可以看到涉及整个U矩阵的计算计算量很大关于$u_w$的导数读者可自行推导
损失函数及其梯度的求解
来自https://github.com/lrs1353281004/CS224n_winter2019_notes_and_assignments
```python
def naiveSoftmaxLossAndGradient(
centerWordVec,
outsideWordIdx,
outsideVectors,
dataset
):
""" Naive Softmax loss & gradient function for word2vec models
Arguments:
centerWordVec -- numpy ndarray, center word's embedding
in shape (word vector length, )
(v_c in the pdf handout)
outsideWordIdx -- integer, the index of the outside word
(o of u_o in the pdf handout)
outsideVectors -- outside vectors is
in shape (num words in vocab, word vector length)
for all words in vocab (tranpose of U in the pdf handout)
dataset -- needed for negative sampling, unused here.
Return:
loss -- naive softmax loss
gradCenterVec -- the gradient with respect to the center word vector
in shape (word vector length, )
(dJ / dv_c in the pdf handout)
gradOutsideVecs -- the gradient with respect to all the outside word vectors
in shape (num words in vocab, word vector length)
(dJ / dU)
"""
# centerWordVec: (embedding_dim,1)
# outsideVectors: (vocab_size,embedding_dim)
scores = np.matmul(outsideVectors, centerWordVec) # size=(vocab_size, 1)
probs = softmax(scores) # size=(vocab, 1)
loss = -np.log(probs[outsideWordIdx]) # scalar
dscores = probs.copy() # size=(vocab, 1)
dscores[outsideWordIdx] = dscores[outsideWordIdx] - 1 # dscores=y_hat - y
gradCenterVec = np.matmul(outsideVectors, dscores) # J关于vc的偏导数公式 size=(vocab_size, 1)
gradOutsideVecs = np.outer(dscores, centerWordVec) # J关于u的偏导数公式 size=(vocab_size, embedding_dim)
return loss, gradCenterVec, gradOutsideVecs
```
## 负采样损失函数
负采样损失函数关于$v_c$的导数:
$$
\frac{\partial{J_{neg-sample}(\boldsymbol v_c,o,\boldsymbol U)}}{\partial\boldsymbol v_c} \\=
\frac{\partial (-log(\sigma (\boldsymbol u_o^T\boldsymbol v_c))-\sum_{k=1}^{K} log(\sigma (-\boldsymbol u_k^T\boldsymbol v_c)))}{\partial \boldsymbol v_c} \\=
-\frac{\sigma(\boldsymbol u_o^T\boldsymbol v_c)(1-\sigma(\boldsymbol u_o^T\boldsymbol v_c))}{\sigma(\boldsymbol u_o^T\boldsymbol v_c)}\frac{\partial \boldsymbol u_o^T\boldsymbol v_c}{\partial \boldsymbol v_c} -
\sum_{k=1}^{K}\frac{\partial log(\sigma(-\boldsymbol u_k^T\boldsymbol v_c))}{\partial \boldsymbol v_c} \\=
-(1-\sigma(\boldsymbol u_o^T\boldsymbol v_c))\boldsymbol u_o+\sum_{k=1}^{K}(1-\sigma(-\boldsymbol u_k^T\boldsymbol v_c))\boldsymbol u_k
$$
可以看到其只与$u_k$和$u_o$有关,避免了在整个单词表上的计算
负采样方法的损失函数及其导数的求解
```python
def negSamplingLossAndGradient(
centerWordVec,
outsideWordIdx,
outsideVectors,
dataset,
K=10
):
negSampleWordIndices = getNegativeSamples(outsideWordIdx, dataset, K)
indices = [outsideWordIdx] + negSampleWordIndices
gradCenterVec =np.zeros(centerWordVec.shape) # (embedding_size,1)
gradOutsideVecs = np.zeros(outsideVectors.shape) # (vocab_size, embedding_size)
loss = 0.0
u_o = outsideVectors[outsideWordIdx] # size=(embedding_size,1)
z = sigmoid(np.dot(u_o, centerWordVec)) # size=(1, )
loss -= np.log(z) # 损失函数的第一部分
gradCenterVec += u_o * (z - 1) # J关于vc的偏导数的第一部分
gradOutsideVecs[outsideWordIdx] = centerWordVec * (z - 1) # J关于u_o的偏导数计算
for i in range(K):
neg_id = indices[1 + i]
u_k = outsideVectors[neg_id]
z = sigmoid(-np.dot(u_k, centerWordVec))
loss -= np.log(z)
gradCenterVec += u_k * (1-z)
gradOutsideVecs[neg_id] += centerWordVec * (1 - z)
return loss, gradCenterVec, gradOutsideVecs
```
**参考资料**
- Mikolov T, Sutskever I, Chen K, et al. Distributed representations of words and phrases and their compositionality[J]. Advances in neural information processing systems, 2013, 26.
- https://www.cnblogs.com/peghoty/p/3857839.html
- http://web.stanford.edu/class/cs224n/

View File

@@ -0,0 +1,399 @@
# Billion-scale Commodity Embedding for E-commerce Recommendation in Alibaba
这篇论文是阿里巴巴在18年发表于KDD的关于召回阶段的工作。该论文提出的方法是在基于图嵌入的方法上通过引入side information来解决实际问题中的数据稀疏和冷启动问题。
## 动机
在电商领域,推荐已经是不可或缺的一部分,旨在为用户的喜好提供有趣的物品,并且成为淘宝和阿里巴巴收入的重要引擎。尽管学术界和产业界的各种推荐方法都取得了成功,如协同过滤、基于内容的方法和基于深度学习的方法,但由于用户和项目的数十亿规模,传统的方法已经不能满足于实际的需求,主要的问题体现在三个方面:
- 可扩展性:现有的推荐方法无法扩展到在拥有十亿的用户和二十亿商品的淘宝中。
- 稀疏性:存在大量的物品与用户的交互行为稀疏。即用户的交互到多集中于以下部分商品,存在大量商品很少被用户交互。
- 冷启动:在淘宝中,每分钟会上传很多新的商品,由于这些商品没有用户行为的信息(点击、购买等),无法进行很好的预测。
针对于这三个方面的问题, 本文设计了一个两阶段的推荐框架:**召回阶段和排序阶段**这也是推荐领域最常见的模型架构。而本文提及的EGES模型主要是解决了匹配阶段的问题通过用户行为计算商品间两两的相似性然后根基相似性选出topK的商品输入到排序阶段。
为了学习更好的商品向量表示本文通过用户的行为历史中构造一个item-item 图然后应用随机游走方法在item-item 图为每个item获取到一个序列然后通过Skip-Gram的方式为每个item学习embedding(这里的item序列类似于语句其中每个item类比于句子中每个word),这种方式被称为图嵌入方法(Graph Embedding)。文中提出三个具体模型来学习更好的物品embedding更好的服务于召回阶段。
## 思路
根据上述所面临的三个问题本文针对性的提出了三个模型予以解决Base Graph EmbeddingBGEGraph Embedding with Side InformationGESEnhanced Graph Embedding with Side InformationEGES
考虑可扩展性的问题,图嵌入的随机游走方式可以在物品图上捕获**物品之间高阶相似性**即Base Graph EmbeddingBGE方法。其不同于CF方法除了考虑物品的共现还考虑到了行为的序列信息。
考虑到稀疏性和冷启物品问题在图嵌入的基础上考虑了节点的属性信息。希望具有相似属性的物品可以在空间上相似即希望通过头部物品提高属性信息的泛化能力进而帮助尾部和冷启物品获取更加准确的embedding即Graph Embedding with Side InformationGES方法。
考虑到不同属性信息对于学习embedding的贡献不同因此在聚合不同的属性信息时动态的学习不同属性对于学习节点的embedding所参与的重要性权重即Enhanced Graph Embedding with Side InformationEGES
## 模型结构与原理
文中所提出的方法是基于经典的图嵌入模型DeepWalk进行改进其目标是通过物品图G学习一个映射函数$f:V -> R^d$ 将图上节点映射成一个embedding。具体的步骤包括两步1.通过随机游走为图上每个物品生成序列2.通过Skip-Gram算法学习每个物品的embedding。因此对于该方法优化的目标是在给定的上下文物品的前提下最大化物品v的条件概率即物品v对于一个序列里面的其他物品要尽可能的相似。接下来看一些每个模型具体内容。
### 构建物品图
在介绍三个模型之前我们首先需要构建好item-item图。由于基于CF的方法仅考虑物品之间的共现忽略了行为的序列信息(即序列中相邻的物品之间的语义信息)因此item-item图的构建方式如下图所示。
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328133138263.png" style="zoom:80%;"/>
</div>
首先根据用户的session行为序列构建网络结构即序列中相邻两个item之间在存在边并且是有向带权图。物品图边上的权重为所有用户行为序列中两个 item 共现的次数,最终构造出来简单的有向有权图。
值得注意的是,本文通过行为序列中物品的共现来表示其中的**语义信息**,并将这种语义信息理解为**物品之间的相似性**并将共现频次作为相似性的一个度量值。其次基于用户的历史行为序列数据一般不太可能取全量的历史序列数据一方面行为数据量过大一方面用户的兴趣会随时间发生演变因此在处理行为序列时会设置了一个窗口来截断历史序列数据切分出来的序列称为session。
由于实际中会存在一些现实因素,数据中会有一些噪音,需要特殊处理,主要分为三个方面:
- 从行为方面考虑用户在点击后停留的时间少于1秒可以认为是误点需要移除。
- 从用户方面考虑淘宝场景中会有一些过度活跃用户。本文对活跃用户的定义是三月内购买商品数超过1000或者点击数超过3500就可以认为是一个无效用户需要去除。
- 从商品方面考虑存在一些商品频繁的修改即ID对应的商品频繁更新这使得这个ID可能变成一个完全不同的商品这就需要移除与这个ID相关的这个商品。
在构建完item-item图之后接下来看看三个模型的具体内容。
### 图嵌入(BGE)
对于图嵌入模型第一步先进行随机游走得到物品序列第二部通过skip-gram为图上节点生成embedding。那么对于随机游走的思想如何利用随机游走在图中生成的序列不同于DeepWalk中的随机游走本文的采样策略使用的是带权游走策略不同权重的游走到的概率不同其本质上就是node2vec传统的node2vec方法可以直接支持有向带权图。因此在给定图的邻接矩阵M后(表示节点之间的边权重),随机游走中每次转移的概率为:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328144516898.png" style="zoom:80%;"/>
</div>
其中$M_{ij}$为边$e_{ij}$上的权重,$N_{+}(v_i)$表示节点$v_i$所有邻居节点集合并且随机游走的转移概率的对每个节点所有邻接边权重的归一化结果。在随即游走之后每个item得到一个序列如下图所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220418142135912.png" style="zoom:47%;"/>
</div>
然后类似于word2vec为每个item学习embedding于是优化目标如下
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328144931957.png" style="zoom:77%;"/>
</div>
其中w 为窗口大小。考虑独立性假设的话,上面的式子可以进一步化简:
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328145101109.png" style="zoom:77%;"/>
</div>
这样看起来就很直观了,在已知物品 i 时,最大化序列中(上下文)其他物品 j 的条件概率。为了近似计算采样了Negative sampling上面的优化目标可以化简得到如下式子
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328145318718.png" style="zoom:80%;"/>
</div>
其中$N(v_i)'$表示负样本集合,负采样个数越多,结果越好。
### 基于side information的图嵌入GES
尽管BGE将行为序列关系编码进物品的embedding中从而从用户行为中捕捉高阶相似性。但是这里有个问题对于新加入的商品由于未和用户产生过交互所以不会出现在item-item图上进而模型无法学习到其embedding即无法解决冷启动问题。
为了解决冷启问题本文通过使用side information 类别,店铺, 价格等加入模型的训练过程中使得模型最终的泛化能力体现在商品的side information上。这样通过**side information学习到的embedding来表示具体的商品**使得相似side information的物品可以得到在空间上相近的表示进而来增强 BGE。
那么对于每个商品如何通过side information的embedidng来表示呢对于随机游走之后得到的商品序列其中每个每个商品由其id和属性(品牌,价格等)组成。用公式表示,对于序列中的每一个物品可以得到$W^0_V,...W_V^n$,n+1个向量表示$W^0_V$表示物品v剩下是side information的embedding。然后将所有的side information聚合成一个整体来表示物品聚合方式如下
$$H_v = \frac{1}{n+1}\sum_{s=0}^n W^s_v$$
其中,$H_v$是商品 v 的聚合后的 embedding 向量。
### 增强型EGSEGES
尽管 GES 相比 BGE 在性能上有了提升但是在聚合多个属性向量得到商品的embedding的过程中不同 side information的聚合依然存在问题。在GES中采用 average-pooling 是在假设不同种类的 side information 对商品embedding的贡献是相等的但实际中却并非如此。例如购买 Iphone 的用户更可能倾向于 Macbook 或者 Ipad相比于价格属性品牌属性相对于苹果类商品具有更重要的影响。因此根据实际现状不同类型的 side information 对商品的表示是具有不同的贡献值的。
针对上述问题作者提出了weight pooling方法来聚合不同类型的 side information。具体地EGES 与 GES 的区别在聚合不同类型 side information计算不同的权重根据权重聚合 side information 得到商品的embedding如下图所示
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328154950289.png" style="zoom:80%;"/>
</div>
其中 $a_i$ 表示每个side information 用于计算权重的参数向量最终通过下面的公式得到商品的embedding
$$H_v = \frac{\sum_{j=0}^n e^{a_v^j} W_v^j}{\sum_{j=0}^n e^{a_v^j}}$$
这里对参数 $a_v^j$ 先做指数变换目的是为了保证每个边界信息的贡献都能大于0然后通过归一化为每个特征得到一个o-1之内的权重。最终物品的embedding通过权重进行加权聚合得到进而优化损失函数
$$L(v,u,y)=-[ylog( \sigma (H_v^TZ_u)) + (1-y)log(1 - \sigma(H_v^TZ_u))]$$
y是标签符号等于1时表示正样本等于0时表示负样本。$H_v$表示商品 v 的最终的隐层表示,$Z_u$表示训练数据中的上下文节点的embedding。
以上就是这三个模型主要的区别下面是EGES的伪代码。
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328155406291.png" style="zoom:80%;"/>
</div>
其中**WeightedSkipGram**函数为带权重的SkipGram算法。
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220328155533704.png" style="zoom:80%;"/>
</div>
## 代码实现
下面我们简单的来看一下模型代码的实现,参考的内容在[这里](https://github.com/wangzhegeek/EGES)其中实验使用的是jd 2019年比赛中提供的数据。
### 构建物品图
首先对用户的下单(type=2)行为序列进行session划分其中30分钟没有产生下一个行为划分为一个session。
```python
def cnt_session(data, time_cut=30, cut_type=2):
# 商品属性 id 被交互时间 商品种类
sku_list = data['sku_id']
time_list = data['action_time']
type_list = data['type']
session = []
tmp_session = []
for i, item in enumerate(sku_list):
# 两个商品之间如果被交互的时间大于1小时划分成不同的session
if type_list[i] == cut_type or (i < len(sku_list)-1 and \
(time_list[i+1] - time_list[i]).seconds/60 > time_cut) or i == len(sku_list)-1:
tmp_session.append(item)
session.append(tmp_session)
tmp_session = []
else:
tmp_session.append(item)
return session # 返回多个session list
```
获取到所有session list之后(这里不区分具体用户)对于session长度不超过1的去除(没有意义)。
接下来就是构建图主要是先计算所有session中相邻的物品共现频次(通过字典计算)。然后通过入度节点、出度节点以及权重分别转化成list通过network来构建有向图。
```python
node_pair = dict()
# 遍历所有session list
for session in session_list_all:
for i in range(1, len(session)):
# 将session共现的item存到node_pair中用于构建item-item图
# 将共现次数所谓边的权重即node_pair的key为边(src_node,dst_node),value为边的权重(共现次数)
if (session[i - 1], session[i]) not in node_pair.keys():
node_pair[(session[i - 1], session[i])] = 1
else:
node_pair[(session[i - 1], session[i])] += 1
in_node_list = list(map(lambda x: x[0], list(node_pair.keys())))
out_node_list = list(map(lambda x: x[1], list(node_pair.keys())))
weight_list = list(node_pair.values())
graph_list = list([(i,o,w) for i,o,w in zip(in_node_list,out_node_list,weight_list)])
# 通过 network 构建图结构
G = nx.DiGraph().add_weighted_edges_from(graph_list)
```
### 随机游走
先是基于构建的图进行随机游走其中p和q是参数用于控制采样的偏向于DFS还是BFS其实也就是node2vec。
```python
walker = RandomWalker(G, p=args.p, q=args.q)
print("Preprocess transition probs...")
walker.preprocess_transition_probs()
```
对于采样的具体过程是根据边的归一化权重作为采样概率进行采样。其中关于如何通过AliasSampling来实现概率采样的可以[参考](https://blog.csdn.net/haolexiao/article/details/65157026)具体的是先通过计算create_alias_table然后根据边上两个节点的alias计算边的alias。其中可以看到这里计算alias_table是根据边的归一化权重。
```python
def preprocess_transition_probs(self):
"""预处理随即游走的转移概率"""
G = self.G
alias_nodes = {}
for node in G.nodes():
# 获取每个节点与邻居节点边上的权重
unnormalized_probs = [G[node][nbr].get('weight', 1.0)
for nbr in G.neighbors(node)]
norm_const = sum(unnormalized_probs)
# 对每个节点的邻居权重进行归一化
normalized_probs = [
float(u_prob)/norm_const for u_prob in unnormalized_probs]
# 根据权重创建alias表
alias_nodes[node] = create_alias_table(normalized_probs)
alias_edges = {}
for edge in G.edges():
# 获取边的alias
alias_edges[edge] = self.get_alias_edge(edge[0], edge[1])
self.alias_nodes = alias_nodes
self.alias_edges = alias_edges
return
```
在构建好Alias之后进行带权重的随机游走。
```python
session_reproduce = walker.simulate_walks(num_walks=args.num_walks,
walk_length=args.walk_length, workers=4,verbose=1)
```
其中这里的随机游走是根据p和q的值来选择是使用Deepwalk还是node2vec。
```python
def _simulate_walks(self, nodes, num_walks, walk_length,):
walks = []
for _ in range(num_walks):
# 打乱所有起始节点
random.shuffle(nodes)
for v in nodes:
# 根据p和q选择随机游走或者带权游走
if self.p == 1 and self.q == 1:
walks.append(self.deepwalk_walk(
walk_length=walk_length, start_node=v))
else:
walks.append(self.node2vec_walk(
walk_length=walk_length, start_node=v))
return walks
```
### 加载side information并构造训练正样本
主要是将目前所有的sku和其对应的side infromation进行left join没有的特征用0补充。然后对所有的特征进行labelEncoder()
```python
sku_side_info = pd.merge(all_skus, product_data, on='sku_id', how='left').fillna(0) # 为商品加载side information
for feat in sku_side_info.columns:
if feat != 'sku_id':
lbe = LabelEncoder()
# 对side information进行编码
sku_side_info[feat] = lbe.fit_transform(sku_side_info[feat])
else:
sku_side_info[feat] = sku_lbe.transform(sku_side_info[feat])
```
通过图中的公式可以知道优化目标是让在一个窗口内的物品尽可能相似采样若干负样本使之与目标物品不相似。因此需要将一个窗口内的所有物品与目标物品组成pair作为训练正样本。这里不需要采样负样本负样本是通过tf中的sample softmax方法自动进行采样。
```python
def get_graph_context_all_pairs(walks, window_size):
all_pairs = []
for k in range(len(walks)):
for i in range(len(walks[k])):
# 通过窗口的方式采取正样本具体的是让随机游走序列的起始item与窗口内的每个item组成正样本对
for j in range(i - window_size, i + window_size + 1):
if i == j or j < 0 or j >= len(walks[k]):
continue
else:
all_pairs.append([walks[k][i], walks[k][j]])
return np.array(all_pairs, dtype=np.int32)
```
#### EGES模型
构造完数据之后在funrec的基础上实现了EGES模型
```python
def EGES(side_information_columns, items_columns, merge_type = "weight", share_flag=True,
l2_reg=0.0001, seed=1024):
# side_information 所对应的特征
feature_columns = list(set(side_information_columns))
# 获取输入层,查字典
feature_encode = FeatureEncoder(feature_columns, linear_sparse_feature=None)
# 输入的值
feature_inputs_list = list(feature_encode.feature_input_layer_dict.values())
# item id 获取输入层的值
items_Map = FeatureMap(items_columns)
items_inputs_list = list(items_Map.feature_input_layer_dict.values())
# 正样本的id在softmax中需要传入正样本的id
label_columns = [DenseFeat('label_id', 1)]
label_Map = FeatureMap(label_columns)
label_inputs_list = list(label_Map.feature_input_layer_dict.values())
# 通过输入的值查side_information的embedding返回所有side_information的embedding的list
side_embedding_list = process_feature(side_information_columns, feature_encode)
# 拼接 N x num_feature X Dim
side_embeddings = Concatenate(axis=1)(side_embedding_list)
# items_inputs_list[0] 为了查找每个item 用于计算权重的 aplha 向量
eges_inputs = [side_embeddings, items_inputs_list[0]]
merge_emb = EGESLayer(items_columns[0].vocabulary_size, merge_type=merge_type,
l2_reg=l2_reg, seed=seed)(eges_inputs) # B * emb_dim
label_idx = label_Map.feature_input_layer_dict[label_columns[0].name]
softmaxloss_inputs = [merge_emb,label_idx]
item_vocabulary_size = items_columns[0].vocabulary_size
all_items_idx = EmbeddingIndex(list(range(item_vocabulary_size)))
all_items_embeddings = feature_encode.embedding_layers_dict[side_information_columns[0].name](all_items_idx)
if share_flag:
softmaxloss_inputs.append(all_items_embeddings)
output = SampledSoftmaxLayer(num_items=item_vocabulary_size, share_flage=share_flag,
emb_dim=side_information_columns[0].embedding_dim,num_sampled=10)(softmaxloss_inputs)
model = Model(feature_inputs_list + items_inputs_list + label_inputs_list, output)
model.__setattr__("feature_inputs_list", feature_inputs_list)
model.__setattr__("label_inputs_list", label_inputs_list)
model.__setattr__("merge_embedding", merge_emb)
model.__setattr__("item_embedding", get_item_embedding(all_items_embeddings, items_Map.feature_input_layer_dict[items_columns[0].name]))
return model
```
其中EGESLayer为聚合每个item的多个side information的方法其中根据merge_type可以选择average-pooling或者weight-pooling
```python
class EGESLayer(Layer):
def __init__(self,item_nums, merge_type="weight",l2_reg=0.001,seed=1024, **kwargs):
super(EGESLayer, self).__init__(**kwargs)
self.item_nums = item_nums
self.merge_type = merge_type #聚合方式
self.l2_reg = l2_reg
self.seed = seed
def build(self, input_shape):
if not isinstance(input_shape, list) or len(input_shape) < 2:
raise ValueError('`EGESLayer` layer should be called \
on a list of at least 2 inputs')
self.feat_nums = input_shape[0][1]
if self.merge_type == "weight":
self.alpha_embeddings = self.add_weight(
name='alpha_attention',
shape=(self.item_nums, self.feat_nums),
dtype=tf.float32,
initializer=tf.keras.initializers.RandomUniform(minval=-1, maxval=1, seed=self.seed),
regularizer=l2(self.l2_reg))
def call(self, inputs, **kwargs):
if self.merge_type == "weight":
stack_embedding = inputs[0] # (B * num_feate * embedding_size)
item_input = inputs[1] # (B * 1)
alpha_embedding = tf.nn.embedding_lookup(self.alpha_embeddings, item_input) #(B * 1 * num_feate)
alpha_emb = tf.exp(alpha_embedding)
alpha_i_sum = tf.reduce_sum(alpha_emb, axis=-1)
merge_embedding = tf.squeeze(tf.matmul(alpha_emb, stack_embedding),axis=1) / alpha_i_sum
else:
stack_embedding = inputs[0] # (B * num_feate * embedding_size)
merge_embedding = tf.squeeze(tf.reduce_mean(alpha_emb, axis=1),axis=1) # (B * embedding_size)
return merge_embedding
def compute_output_shape(self, input_shape):
return input_shape
def get_config(self):
config = {"merge_type": self.merge_type, "seed": self.seed}
base_config = super(EGESLayer, self).get_config()
base_config.update(config)
return base_config
```
至此已经从原理到代码详细的介绍了关于EGES的内容。
## 参考
[Billion-scale Commodity Embedding for E-commerce Recommendation in Alibaba](https://arxiv.org/abs/1803.02349)
[深度学习中不得不学的Graph Embedding方法](https://zhuanlan.zhihu.com/p/64200072)
[【Embedding】EGES阿里在图嵌入领域中的探索](https://blog.csdn.net/qq_27075943/article/details/106244434)
[推荐系统遇上深度学习(四十六)-阿里电商推荐中亿级商品的embedding策略](https://www.jianshu.com/p/229b686535f1)

View File

@@ -0,0 +1,579 @@
# Graph Convolutional Neural Networks for Web-Scale Recommender Systems
该论文是斯坦福大学和Pinterest公司与2018年联合发表与KDD上的一篇关于GCN成功应用于工业级推荐系统的工作。该论文提到的PinSage模型是在GraphSAGE的理论基础进行了更改以适用于实际的工业场景。下面将简单介绍一下GraphSAGE的原理以及Pinsage的核心和细节。
## GraphSAGE原理
GraphSAGE提出的前提是因为基于直推式(transductive)学习的图卷积网络无法适应工业界的大多数业务场景。我们知道的是基于直推式学习的图卷积网络是通过拉普拉斯矩阵直接为图上的每个节点学习embedding表示每次学习是针对于当前图上所有的节点。然而在实际的工业场景中图中的结构和节点都不可能是固定的会随着时间的变化而发生改变。例如在Pinterest公司的场景下每分钟都会上传新的照片素材同时也会有新用户不断的注册那么图上的节点会不断的变化。在这样的场景中直推式学习的方法就需要不断的重新训练才能够为新加入的节点学习embedding导致在实际场景中无法投入使用。
在这样的背景下,斯坦福大学提出了一种归纳(inductive)学习的GCN方法——GraphSAGE即**通过聚合邻居信息的方式为给定的节点学习embedding**。不同于直推式(transductive)学习GraphSAGE是通过学习聚合节点邻居生成节点Embedding的函数的方式为任意节点学习embedding进而将GCN扩展成归纳学习任务。
对于想直接应用GCN或者GraphSAGE的我们而言不用非要去理解其背后晦涩难懂的数学原理可以仅从公式的角度来理解GraphSAGE的具体操作。
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220423094435223.png" style="zoom:90%;"/>
</div>
上面这个公式可以非常直观的让我们理解GraphSAGE的原理。
- $h_v^0$表示图上节点的初始化表示,等同于节点自身的特征。
- $h_v^k$表示第k层卷积后的节点表示其来源于两个部分
- 第一部分来源于节点v的邻居节点集合$N(v)$利用邻居节点的第k-1层卷积后的特征$h_u^{k-1}$进行 $\sum_{u \in N(v)} \frac{h_u^{k-1}}{|N(v)|}$ )后,在进行线性变换。这里**借助图上的边将邻居节点的信息通过边关系聚合到节点表示中(简称卷积操作)**。
- 第二部分来源于节点v的第k-1成卷积后的特征$h_v^{k-1}$,进行线性变换。总的来说图卷积的思想是**在对自身做多次非线性变换时,同时利用边关系聚合邻居节点信息。**
- 最后一次卷积结果作为节点的最终表示$Z_v$,以用于下游任务(节点分类,链路预测或节点召回)。
可以发现相比传统的方法(MLPCNNDeepWalk 或 EGES)GCN或GraphSAGE存在一些优势
1. 相比于传统的深度学习方法(MLP,CNN)GCN在对自身节点进行非线性变换时同时考虑了图中的邻接关系。从CNN的角度理解GCN通过堆叠多层结构在图结构数据上拥有更大的**感受野**,利用更加广域内的信息。
2. 相比于图嵌入学习方法(DeepWalkEGES)GCN在学习节点表示的过程中在利用节点自身的属性信息之外更好的利用图结构上的边信息。相比于借助随机采样的方式来使用边信息GCN的方式能从全局的角度利用的邻居信息。此外类似于GraphSAGE这种归纳(inductive)学习的GCN方法通过学习聚合节点邻居生成节点Embedding的函数的方式更适用于图结构和节点会不断变化的工业场景。
在采样得到目标节点的邻居集之后那么如何聚合邻居节点的信息来更新目标节点的嵌入表示呢下面就来看看GraphSAGE中提及的四个聚合函数。
## GraphSAGE的采样和聚合
通过上面的公式可以知道得到节点的表示主要依赖于两部分其中一部分其邻居节点。因此对于GraphSAGE的关键主要分为两步Sample采样和Aggregate聚合。其中Sample的作用是从庞大的邻居节点中选出用于聚合的邻居节点集合$N(v)$以达到降低迭代计算复杂度而聚合操作就是如何利用邻居节点的表示来更新节点v的表示已达到聚合作用。具体的过程如下伪代码所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220406135753358.png" style="zoom:90%;"/>
</div>
GraphSAGE的minibatch算法的思路是针对Batch内的所有节点通过采样和聚合节点为每一个节点学习一个embedding。
#### 邻居采样
GraphSAGE的具体采样过程是首先根据中心节点集合$B^k$对集合中每个中心节点通过随机采样的方式对其邻居节点采样固定数量S个(如果邻居节点数量大于S采用无放回抽样如果小于S则采用有放回抽样),形成的集合表示为$B^{k-1}$以此类推每次都是为前一个得到的集合的每个节点随机采样S个邻居最终得到第k层的所有需要参与计算的节点集合$B^{0}$。值得注意的有两点:**为什么需要采样并且固定采样数量S** **为什么第k层所采样的节点集合表示为$B^0$**
进行邻居采样并固定采样数量S主要是因为1. 采样邻居节点避免了在全图的搜索以及使用全部邻居节点所导致计算复杂度高的问题2. 可以通过采样使得部分节点更同质化即两个相似的节点具有相同表达形式。3. 采样固定数量是保持每个batch的计算占用空间是固定的方便进行批量训练。
第k层所采样的节点集合表示为$B^0$主要是因为采样和聚合过程是相反的即采样时我们是从中心节点组层进行采样而聚合的过程是从中心节点的第k阶邻居逐层聚合得到前一层的节点表示。因此可以认为聚合阶段是将k阶邻居的信息聚合到k-1阶邻居上k-1阶邻居的信息聚合到k-2阶邻居上....1阶邻居的信息聚合到中心节点上的过程。
#### 聚合函数
如何对于采样到的节点集进行聚合介绍的4种方式Mean 聚合、Convolutional 聚合、LSTM聚合以及Pooling聚合。由于邻居节点是无序的所以希望构造的聚合函数具有**对称性(即输出的结果不因输入排序的不同而改变)**,同时拥有**较强的表达能力**。
- Mean 聚合:首先会对邻居节点按照**element-wise**进行均值聚合然后将当前节点k-1层得到特征$h_v^{k-1}$与邻居节点均值聚合后的特征 $MEAN(h_u^k | u\in N(v))$**分别**送入全连接网络后**相加**得到结果。
- Convolutional 聚合这是一种基于GCN聚合方式的变种首先对邻居节点特征和自身节点特征求均值得到的聚合特征送入到全连接网络中。与Mean不同的是这里**只经过一个全连接层**。
- LSTM聚合由于LSTM可以捕捉到序列信息因此相比于Mean聚合这种聚合方式的**表达能力更强**但由于LSTM对于输入是有序的因此该方法不具备**对称性**。作者对于无序的节点进行随机排列以调整LSTM所需的有序性。
- Pooling聚合对于邻居节点和中心节点进行一次非线性转化将结果进行一次基于**element-wise**的**最大池化**操作。该种方式具有**较强的表达能力**的同时还具有**对称性**。
综上可以发现GraphSAGE之所以可以用于大规模的工业场景主要是因为模型主要是通过学习聚合函数通过归纳式的学习方法为节点学习特征表示。接下来看看PinSAGE 的主要内容。
## PinSAGE
### 背景
PinSAGE 模型是Pinterest 在GraphSAGE 的基础上实现的可以应用于实际工业场景的召回算法。Pinterest 公司的主要业务是采用瀑布流的形式向用户展现图片无需用户翻页新的图片会自动加载。因此在Pinterest网站上有大量的图片(被称为pins)而用户可以将喜欢的图片分类即将pins钉在画板 boards上。可以发现基于这样的场景pin相当于普通推荐场景中item用户**钉**的行为可以认为是用于的交互行为。于是PinSAGE 模型主要应用的思路是基于GraphSAGE 的原理学习到聚合方法,并为每个图片(pin)学习一个向量表示然后基于pin的向量表示做**item2item的召回**。
可以知道的是PinSAGE 是在GraphSAGE的基础上进行改进以适应实际的工业场景因此除了改进卷积操作中的邻居采样策略以及聚合函数的同时还有一些工程技巧上的改进使得在大数据场景下能更快更好的进行模型训练。因此在了解GraphSAGE的原理后我们详细的了解一下本文的主要改进以及与GraphSAGE的区别。
### 重要性采样
在实际场景当中一个item可能被数以百万千万的用户交互过所以不可能聚合所有邻居节点是不可行的只可能是采样部分邻居进行信息聚合。但是如果采用GraphSAGE中随机采样的方法由于采样的邻居有限(这里是相对于所有节点而言)会存在一定的偏差。因此PinSAGE 在采样中考虑了更加重要的邻居节点,即卷积时只注重部分重要的邻居节点信息,已达到高效计算的同时又可以消除偏置。
PinSAGE使用重要性采样方法即需要为每个邻居节点计算一个重要性权重根据权重选取top-t的邻居作为聚合时的邻居集合。其中计算重要性的过程是以目标节点为起点进行random-walk采样结束之后计算所有节点访问数的L1-normalized作为重要性权重同时这个权重也会在聚合过程中加以使用(**加权聚合**)。
这里对于**计算权重之后如何得到top-t的邻居节点**原文并没有直接的叙述。这里可以有两种做法第一种就是直接采用重要权重这种方法言简意赅比较直观。第二种做法就是对游走得到的所有邻居进行随机抽样而计算出的权重可以用于聚合阶段。个人理解第二种做法的可行性出于两点原因其一是这样方法可以避免存在一些item由于权重系数低永远不会被选中的问题其二可能并不是将所有重要性的邻居进行聚合更合理毕竟重要性权重是通过随机采样而得到的具有一定的随机性。当然以上两种方法都是可行的方案可以通过尝试看看具体哪种方法会更有效。
### 聚合函数
PinSAGE中提到的Convolve算法单层图卷积操作相当于GraphSAGE算法的聚合过程在实际执行过程中通过对每一层执行一次图卷积操作以得到不同阶邻居的信息具体过程如下图所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220406202027832.png" style="zoom:110%;"/>
</div>
上述的单层图卷积过程如下三步:
1. 聚合邻居: 先将所有的邻居节点经过一次非线性转化(一层DNN),再由聚合函数(Pooling聚合) $\gamma$(如元素平均,**加权和**等将所有邻居信息聚合成目标节点的embedding。这里的加权聚合采用的是通过random-walk得到的重要性权重。
2. 更新当前节点的embedding将目标节点当前的向量 $z_u$ 与步骤1中聚合得到的邻居向量 $n_u$ 进行拼接,在通过一次非线性转化。
3. 归一化操作:对目标节点向量 $z_u$ 归一化。
Convolve算法的聚合方法与GraphSAGE的Pooling聚合函数相同主要区别在于对更新得到的向量 $z_u$ 进行归一化操作,**可以使训练更稳定,以及在近似查找最近邻的应用中更有效率。**
### 基于**mini-batch**堆叠多层图卷积
与GraphSAGE类似采用的是基于mini-batch 的方式进行训练。之所以这么做的原因是因为什么呢在实际的工业场景中由于用户交互图非常庞大无法对于所有的节点同时学习一个embedding因此需要从原始图上寻找与 mini-batch 节点相关的子图。具体地是说对于mini-batch内的所有节点会通过采样的方式逐层的寻找相关邻居节点再通过对每一层的节点做一次图卷积操作以从k阶邻居节点聚合信息。
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220406204431024.png" style="zoom:60%;"/>
</div>
如上图所示对于batch内的所有节点(图上最顶层的6个节点)依次根据权重采样得到batch内所有节点的一阶邻居(图上第二层的所有节点);然后对于所有一阶邻居再次进行采样,得到所有二阶邻居(图上的最后一层)。节点采样阶段完成之后,与采样的顺序相反进行聚合操作。首先对二阶邻居进行单次图卷积,将二阶节点信息聚合已更新一阶节点的向量表示(其中小方块表示的是一层非线性转化)其次对一阶节点再次进行图卷积操作将一阶节点的信息聚合已更新batch内所有节点的向量表示。仅此对于一个batch内的所有的样本通过卷积操作学习到一个embedding而每一个batch的学习过程中仅**利用与mini-batch内相关节点的子图结构。**
### **训练过程**
PinSage在训练时采用的是 Margin Hinge Loss 损失函数主要的思想是最大化正例embedding之间的相关性同时还要保证负例之间相关性相比正例之间的相关性小于某个阈值(Margin)。具体的公式如下:
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220406210833675.png" style="zoom:100%;"/>
</div>
其中$Z_p$是学习得到的目标节点embedding$Z_i$是与目标节点相关item的embedding$Z_{n_k}$是与目标节点不相关item的embedding$\Delta$为margin值具体大小需要调参。那么对于相关节点i以及不相关节点nk具体都是如何定义的这对于召回模型的训练意义重大让我们看看具体是如何定义的。
对于正样本而言,文中的定义是如果用户在点击的 item q之后立即点击了 item i即认为 < q, i >构成正样本对。直观的我们很好理解这句话不过在参考DGL中相关代码实现时发现这部分的内容和原文中有一定的出入。具体地代码中将所有的训练样本构造成用户-项目二部图然后对batch内的每个 item q根据item-user-item的元路径进行随机游走得到被同一个用户交互过的 item i因此组成<q,i>正样本对。对于负样本部分,相对来说更为重要,因此内容相对比较多,将在下面的负样本生成部分详细介绍。
这里还有一个比较重要的细节需要注意,由于模型是用于 item to item的召回因此优化目标是与正样本之间的表示尽可能的相近与负样本之间的表示尽可能的远。而图卷积操作会使得具有邻接关系的节点表示具有同质性因此结合这两点就需要在构建图结构的时要将**训练样本之间可能存在的边在二部图上删除**,避免因为边的存在使得因卷积操作而导致的信息泄露。
### 工程技巧
由于PinSAGE是一篇工业界的论文其中会涉及与实际工程相关的内容这里在了解完算法思想之后再从实际落地的角度看看PinSAGE给我们介绍的工程技巧。
**负样本的生成**
召回模型最主要的任务是从候选集合中选出用户可能感兴趣的item直观的理解就是让模型将用户喜欢的和不喜欢的进行区分。然而由于候选集合的庞大数量许多item之间十分相似导致模型划分出来用户喜欢的item中会存在一些难以区分的item(即与用户非常喜欢item比较相似的那一部分)。因此对于召回模型不仅能区分用户喜欢和不喜欢的 item同时还能区分与用户喜欢的 item 十分相似的那一部分item。那么如果做到呢这主要是交给 easy negative examples 和 hard negative examples 两种负样本给模型学习。
- easy 负样本这里对于mini-batch内的所有pair(训练样本对)会共享500负样本这500个样本从batch之外的所有节点中随机采样得到。这么做可以减少在每个mini-batch中因计算所有节点的embedding所需的时间文中指出这和为每个item采样一定数量负样本无差异。
- hard 负样本这里使用hard 负样本的原因是根据实际场景的问题出发模型需要从20亿的物品item集合中识别出最相似的1000个即模型需要从2百万 item 中识别出最相似的那一个 item。也就是说模型的区分能力不够细致为了解决这个问题加入了一些hard样本。对于hard 负样本,应该是与 q 相似 以及和 i 不相似的物品,具体地的生成方式是将图上的节点计算相对节点 q 的个性化PageRank分值根据分值的排序随机从2000~5000的位置选取节点作为负样本。
负样本的构建是召回模型的中关键的内容,在各家公司的工作都予以体现,具体的大家可以参考 Facebook 发表的[《Embedding-based Retrieval in Facebook Search》]([https://arxiv.org/pdf/2006.11632v1.pdf](https://links.jianshu.com/go?to=https%3A%2F%2Farxiv.org%2Fpdf%2F2006.11632v1.pdf))
**渐进式训练(Curriculum training)**
由于hard 负样本的加入模型的训练时间加长由于与q过于相似导致loss比较小导致梯度更新的幅度比较小训练起来比较慢那么渐进式训练就是为了来解决这个问题。
如何渐进式先在第一轮训练使用easy 负样本,帮助模型先快速收敛(先让模型有个最基本的分辨能力)到一定范围然后在逐步分加入hard负样本(方式是在第n轮训练时给每个物品的负样本集合增加n-1个 hard 负样本),以调整模型细粒度的区分能力(让模型能够区分相似的item)。
**节点特征(side information)**
这里与EGES的不同这里的边信息不是端到端训练得到而是通过事前的预处理得到的。对于每个节点(即 pin),都会有一个图片和一点文本信息。因此对于每个节点使用图片的向量、文字的向量以及节点的度拼接得到。这里其实也解释了为什么在图卷积操作时,会先进行一个非线性转化,其实就是将不同空间的特征进行转化(融合)。
**构建 mini-batch**
不同于常规的构建方式PinSAGE中构建mini-batch的方式是基于生产者消费者模式。什么意思的就是将CPU和GPU分开工作让CPU负责取特征重建索引邻接列表负采样等工作让GPU进行矩阵运算即CPU负责生产每个batch所需的所有数据GPU则根据CPU生产的数据进行消费(运算)。这样由于考虑GPU的利用率无法将所有特征矩阵放在GPU只能存在CPU中然而每次查找会导致非常耗时通过上面的方式使得图卷积操作过程中就没有GPU与CPU的通信需求。
**多GPU训练超大batch**
前向传播过程中各个GPU等分minibatch共享一套模型参数反向传播时将每个GPU中的参数梯度都聚合到一起同步执行SGD。为了保证因海量数据而使用的超大batchsize的情况下模型快速收敛以及泛化精度采用warmup过程即在第一个epoch中将学习率线性提升到最高后面的epoch中再逐步指数下降。
**使用MapReduce高效推断**
在模型训练结束之后需要为所有节点计算一个embedding如果按照训练过程中的前向传播过程来生成会存在大量重复的计算。因为当计算一个节点的embedding的时候其部分邻居节点已经计算过了同时如果该节点作为其他节点邻居时也会被再次计算。针对这个问题本文采用MapReduce的方法进行推断。该过程主要分为两步具体如下图所示
<div align=center>
<img src="https://cdn.jsdelivr.net/gh/swallown1/blogimages@main/images/image-20220407132111547.png" style="zoom:60%;"/>
</div>
1. 将item的embedding进行聚合即利用item的图片、文字和度等信息的表示进行join(拼接)在通过一层dense后得到item的低维向量。
2. 然后根据item来匹配其一阶邻居(join)然后根据item进行pooling(其实就是GroupBy pooling)得到一次图卷积操作。通过堆叠多次直接得到全量的embedding。
其实这块主要就是通过MapReduce的大数据处理能力直接对全量节点进行一次运算得到其embedding避免了分batch所导致的重复计算。
## 代码解析
了解完基本的原理之后最关键的还是得解析源码以证实上面讲的细节的准确性。下面基于DGL中实现的代码看看模型中的一些细节。
### 数据处理
在弄清楚模型之前最重要的就是知道送入模型的数据到底是什么养的以及PinSAGE相对于GraphSAGE最大的区别就在于如何采样邻居如何构建负样本等。
首先需要明确的是,无论是**邻居采样**还是**样本的构造**都发生在图结构上因此最主要的是需要先构建一个user和item组成的二部图。
```python
# ratings是所有的用户交互
# 过滤掉为出现在交互中的用户和项目
distinct_users_in_ratings = ratings['user_id'].unique()
distinct_movies_in_ratings = ratings['movie_id'].unique()
users = users[users['user_id'].isin(distinct_users_in_ratings)]
movies = movies[movies['movie_id'].isin(distinct_movies_in_ratings)]
# 将电影特征分组 genres (a vector), year (a category), title (a string)
genre_columns = movies.columns.drop(['movie_id', 'title', 'year'])
movies[genre_columns] = movies[genre_columns].fillna(False).astype('bool')
movies_categorical = movies.drop('title', axis=1)
## 构建图
graph_builder = PandasGraphBuilder()
graph_builder.add_entities(users, 'user_id', 'user') # 添加user类型节点
graph_builder.add_entities(movies_categorical, 'movie_id', 'movie') # 添加movie类型节点
# 构建用户-电影的无向图
graph_builder.add_binary_relations(ratings, 'user_id', 'movie_id', 'watched')
graph_builder.add_binary_relations(ratings, 'movie_id', 'user_id', 'watched-by')
g = graph_builder.build()
```
在构建完原图之后,需要将交互数据(ratings)分成训练集和测试集,然后根据测试集从原图中抽取出与训练集中相关节点的子图。
```python
# train_test_split_by_time 根据时间划分训练集和测试集
# 将用户的倒数第二次交互作为验证,最后一次交互用作测试
# train_indices 为用于训练的用户与电影的交互
train_indices, val_indices, test_indices = train_test_split_by_time(ratings, 'timestamp', 'user_id')
# 只使用训练交互来构建图形,测试集相关的节点不应该出现在训练过程中。
# 从原图中提取与训练集相关节点的子图
train_g = build_train_graph(g, train_indices, 'user', 'movie', 'watched', 'watched-by')
```
### 正负样本采样
在得到训练图结构之后为了进行PinSAGE提出的item2item召回任务需要构建相应的训练样本。对于训练样本主要是构建正样本对和负样本对前面我们已经提到了正样本对是基于 item to user to item的随即游走得到的对于负样本DGL的实现主要是随机采样即只有easy sample未实现hard sample。具体地DGL中主要是通过sampler_module.ItemToItemBatchSampler方法进行采样主要代码如下
```python
class ItemToItemBatchSampler(IterableDataset):
def __init__(self, g, user_type, item_type, batch_size):
self.g = g
self.user_type = user_type
self.item_type = item_type
self.user_to_item_etype = list(g.metagraph()[user_type][item_type])[0]
self.item_to_user_etype = list(g.metagraph()[item_type][user_type])[0]
self.batch_size = batch_size
def __iter__(self):
while True:
# 随机采样batch_size个节点作为head 即论文中的q
heads = torch.randint(0, self.g.number_of_nodes(self.item_type), (self.batch_size,))
# 本次元路径表示从item游走到user再从user游走到item总共二跳取出二跳节点(电影节点)作为tails(即正样本)
# 得到与heads被同一个用户消费过的其他item做正样本
# 这么做可能存在问题,
# 1. 这种游走肯定会使正样本集中于少数热门item
# 2. 如果item只被一个用户消费过二跳游走岂不是又回到起始item这种case还是要处理的
tails = dgl.sampling.random_walk(
self.g,
heads,
metapath=[self.item_to_user_etype, self.user_to_item_etype])[0][:, 2]
# 随机采样做负样本, 没有hard negative
# 这么做会存在被同一个用户交互过的movie也会作为负样本
neg_tails = torch.randint(0, self.g.number_of_nodes(self.item_type), (self.batch_size,))
mask = (tails != -1)
yield heads[mask], tails[mask], neg_tails[mask]
```
上面的样本采样过程只是一个简单的示例,如果面对实际问题,需要自己来重新完成这部分的内容。
### 邻居节点采样
再得到训练样本之后接下来主要是在训练图上为heads节点采用其邻居节点。在DGL中主要是通过sampler_module.NeighborSampler来实现具体地通过**sample_blocks**方法回溯生成各层卷积需要的block即所有的邻居集合。其中需要注意的几个地方基于随机游走的重要邻居采样DGL已经实现具体参考**[dgl.sampling.PinSAGESampler](https://link.zhihu.com/?target=https%3A//docs.dgl.ai/generated/dgl.sampling.PinSAGESampler.html%3Fhighlight%3Dpinsagesampler)**其次避免信息泄漏代码中先将head → tails,head → neg_tails从frontier中先删除再生成block。
```python
class NeighborSampler(object): # 图卷积的邻居采样
def __init__(self, g, user_type, item_type, random_walk_length, random_walk_restart_prob,
num_random_walks, num_neighbors, num_layers):
self.g = g
self.user_type = user_type
self.item_type = item_type
self.user_to_item_etype = list(g.metagraph()[user_type][item_type])[0]
self.item_to_user_etype = list(g.metagraph()[item_type][user_type])[0]
# 每层都有一个采样器,根据随机游走来决定某节点邻居的重要性(主要的实现已封装在PinSAGESampler中)
# 可以认为经过多次游走,落脚于某邻居节点的次数越多,则这个邻居越重要,就更应该优先作为邻居
self.samplers = [
dgl.sampling.PinSAGESampler(g, item_type, user_type, random_walk_length,
random_walk_restart_prob, num_random_walks, num_neighbors)
for _ in range(num_layers)]
def sample_blocks(self, seeds, heads=None, tails=None, neg_tails=None):
"""根据随机游走得到的重要性权重,进行邻居采样"""
blocks = []
for sampler in self.samplers:
frontier = sampler(seeds) # 通过随机游走进行重要性采样,生成中间状态
if heads is not None:
# 如果是在训练需要将heads->tails 和 head->neg_tails这些待预测的边都去掉
eids = frontier.edge_ids(torch.cat([heads, heads]), torch.cat([tails, neg_tails]), return_uv=True)
if len(eids) > 0:
old_frontier = frontier
frontier = dgl.remove_edges(old_frontier, eids)
# 只保留seeds这些节点将frontier压缩成block
# 并设置block的input/output nodes
block = compact_and_copy(frontier, seeds)
# 本层的输入节点就是下一层的seeds
seeds = block.srcdata[dgl.NID]
blocks.insert(0, block)
return blocks
```
其次**sample_from_item_pairs**方法是通过上面得到的heads, tails, neg_tails分别构建基于正样本对以及基于负样本对的item-item图。由heads→tails生成的pos_graph用于计算pairwise loss中的pos_score由heads→neg_tails生成的neg_graph用于计算pairwise loss中的neg_score。
```python
class NeighborSampler(object): # 图卷积的邻居采样
def __init__(self, g, user_type, item_type, random_walk_length, ....):
pass
def sample_blocks(self, seeds, heads=None, tails=None, neg_tails=None):
pass
def sample_from_item_pairs(self, heads, tails, neg_tails):
# 由heads->tails构建positive graph num_nodes设置成原图中所有item节点
pos_graph = dgl.graph(
(heads, tails),
num_nodes=self.g.number_of_nodes(self.item_type))
# 由heads->neg_tails构建negative graphnum_nodes设置成原图中所有item节点
neg_graph = dgl.graph(
(heads, neg_tails),
num_nodes=self.g.number_of_nodes(self.item_type))
# 去除heads, tails, neg_tails以外的节点将大图压缩成小图避免与本轮训练不相关节点的结构也传入模型提升计算效率
pos_graph, neg_graph = dgl.compact_graphs([pos_graph, neg_graph])
# 压缩后的图上的节点是原图中的编号
# 注意这时pos_graph与neg_graph不是分开编号的两个图它们来自于同一幅由heads, tails, neg_tails组成的大图
# pos_graph和neg_graph中的节点相同都是heads+tails+neg_tails即这里的seedspos_graph和neg_graph只是边不同而已
seeds = pos_graph.ndata[dgl.NID] # 字典 不同类型节点为一个tensor为每个节点的id值
blocks = self.sample_blocks(seeds, heads, tails, neg_tails)
return pos_graph, neg_graph, blocks
```
### PinSAGE
在得到所有所需的数据之后,看看模型结构。其中主要分为三个部分:**节点特征映射****多层卷积模块 **和 **给边打分**
```python
class PinSAGEModel(nn.Module):
def __init__(self, full_graph, ntype, textsets, hidden_dims, n_layers):
super().__init__()
# 负责将节点上的各种特征都映射成向量,并聚合在一起,形成这个节点的原始特征向量
self.proj = layers.LinearProjector(full_graph, ntype, textsets, hidden_dims)
# 负责多层图卷积得到各节点最终的embedding
self.sage = layers.SAGENet(hidden_dims, n_layers)
# 负责根据首尾两端的节点的embedding计算边上的得分
self.scorer = layers.ItemToItemScorer(full_graph, ntype)
def forward(self, pos_graph, neg_graph, blocks):
""" pos_graph, neg_graph, blocks 的最后一层都对应batch中 heads+tails+neg_tails 这些节点
"""
# 得到batch中heads+tails+neg_tails这些节点的最终embedding
h_item = self.get_repr(blocks)
# 得到heads->tails这些边上的得分
pos_score = self.scorer(pos_graph, h_item)
# 得到heads->neg_tails这些边上的得分
neg_score = self.scorer(neg_graph, h_item)
# pos_graph与neg_graph边数相等因此neg_score与pos_score相减
# 返回margin hinge loss这里的margin是1
return (neg_score - pos_score + 1).clamp(min=0)
def get_repr(self, blocks):
"""
通过self.sage经过多层卷积得到输出节点上的卷积结果再加上这些输出节点上原始特征的映射结果
得到输出节点上最终的向量表示
"""
h_item = self.proj(blocks[0].srcdata) # 将输入节点上的原始特征映射成hidden_dims长的向量
h_item_dst = self.proj(blocks[-1].dstdata) # 将输出节点上的原始特征映射成hidden_dims长的向量
return h_item_dst + self.sage(blocks, h_item)
```
**节点特征映射:**由于节点使用到了多种类型int,float array,text的原始特征这里使用了一个DNN层来融合成固定的长度。
```python
class LinearProjector(nn.Module):
def __init__(self, full_graph, ntype, textset, hidden_dims):
super().__init__()
self.ntype = ntype
# 初始化参数,这里为全图中所有节点特征初始化
# 如果特征类型是float就定义一个nn.Linear线性变化为指定维度
# 如果特征类型是int就定义Embedding矩阵将id型特征转化为向量
self.inputs = _init_input_modules(full_graph, ntype, textset, hidden_dims)
def forward(self, ndata):
projections = []
for feature, data in ndata.items():
# NID是计算子图中节点、边在原图中的编号没必要用做特征
if feature == dgl.NID:
continue
module = self.inputs[feature] # 根据特征名取出相应的特征转化器
# 对文本属性进行处理
if isinstance(module, (BagOfWords, BagOfWordsPretrained)):
length = ndata[feature + '__len']
result = module(data, length)
else:
result = module(data) # look_up
projections.append(result)
# 将每个特征都映射后的hidden_dims长的向量element-wise相加
return torch.stack(projections, 1).sum(1) # [nodes, hidden_dims]
```
**多层卷积模块:**根据采样得到的节点blocks然后通过进行逐层卷积得到各节点最终的embedding。
```python
class SAGENet(nn.Module):
def __init__(self, hidden_dims, n_layers):
"""g : 二部图"""
super().__init__()
self.convs = nn.ModuleList()
for _ in range(n_layers):
self.convs.append(WeightedSAGEConv(hidden_dims, hidden_dims, hidden_dims))
def forward(self, blocks, h):
# 这里根据邻居节点进逐层聚合
for layer, block in zip(self.convs, blocks):
h_dst = h[:block.number_of_nodes('DST/' + block.ntypes[0])] #前一次卷积的结果
h = layer(block, (h, h_dst), block.edata['weights'])
return h
```
其中WeightedSAGEConv为根据邻居权重的聚合函数。
```python
class WeightedSAGEConv(nn.Module):
def __init__(self, input_dims, hidden_dims, output_dims, act=F.relu):
super().__init__()
self.act = act
self.Q = nn.Linear(input_dims, hidden_dims)
self.W = nn.Linear(input_dims + hidden_dims, output_dims)
self.reset_parameters()
self.dropout = nn.Dropout(0.5)
def reset_parameters(self):
gain = nn.init.calculate_gain('relu')
nn.init.xavier_uniform_(self.Q.weight, gain=gain)
nn.init.xavier_uniform_(self.W.weight, gain=gain)
nn.init.constant_(self.Q.bias, 0)
nn.init.constant_(self.W.bias, 0)
def forward(self, g, h, weights):
"""
g : 基于batch的子图
h : 节点特征
weights : 边的权重
"""
h_src, h_dst = h # 邻居节点特征,自身节点特征
with g.local_scope():
# 将src节点上的原始特征映射成hidden_dims长存储于'n'字段
g.srcdata['n'] = self.act(self.Q(self.dropout(h_src)))
g.edata['w'] = weights.float()
# src节点上的特征'n'乘以边上的权重,构成消息'm'
# dst节点将所有接收到的消息'm'相加起来存入dst节点的'n'字段
g.update_all(fn.u_mul_e('n', 'w', 'm'), fn.sum('m', 'n'))
# 将边上的权重w拷贝成消息'm'
# dst节点将所有接收到的消息'm'相加起来存入dst节点的'ws'字段
g.update_all(fn.copy_e('w', 'm'), fn.sum('m', 'ws'))
# 邻居节点的embedding的加权和
n = g.dstdata['n']
ws = g.dstdata['ws'].unsqueeze(1).clamp(min=1) # 边上权重之和
# 先将邻居节点的embedding做加权平均
# 再拼接上一轮卷积后dst节点自身的embedding
# 再经过线性变化与非线性激活得到这一轮卷积后各dst节点的embedding
z = self.act(self.W(self.dropout(torch.cat([n / ws, h_dst], 1))))
# 本轮卷积后各dst节点的embedding除以模长进行归一化
z_norm = z.norm(2, 1, keepdim=True)
z_norm = torch.where(z_norm == 0, torch.tensor(1.).to(z_norm), z_norm)
z = z / z_norm
return z
```
**给边打分:** 经过SAGENet得到了batch内所有节点的embedding这时需要根据学习到的embedding为pos_graph和neg_graph中的每个边打分即计算正样本对和负样本的內积。具体逻辑是根据两端节点embedding的点积然后加上两端节点的bias。
```python
class ItemToItemScorer(nn.Module):
def __init__(self, full_graph, ntype):
super().__init__()
n_nodes = full_graph.number_of_nodes(ntype)
self.bias = nn.Parameter(torch.zeros(n_nodes, 1))
def _add_bias(self, edges):
bias_src = self.bias[edges.src[dgl.NID]]
bias_dst = self.bias[edges.dst[dgl.NID]]
# 边上两顶点的embedding的点积再加上两端节点的bias
return {'s': edges.data['s'] + bias_src + bias_dst}
def forward(self, item_item_graph, h):
"""
item_item_graph : 每个边 为 pair 对
h : 每个节点隐层状态
"""
with item_item_graph.local_scope():
item_item_graph.ndata['h'] = h
# 边两端节点的embedding做点积保存到s
item_item_graph.apply_edges(fn.u_dot_v('h', 'h', 's'))
# 为每个边加上偏置,即加上两个顶点的偏置
item_item_graph.apply_edges(self._add_bias)
# 算出来的得分为 pair 的预测得分
pair_score = item_item_graph.edata['s']
return pair_score
```
### 训练过程
介绍完“数据处理”和“PinSAGE模块”之后接下来就是通过训练过程将上述两部分串起来详细的见代码
```python
def train(dataset, args):
#从dataset中加载数据和原图
g = dataset['train-graph']
...
device = torch.device(args.device)
# 为节点随机初始化一个id用于做embedding
g.nodes[user_ntype].data['id'] = torch.arange(g.number_of_nodes(user_ntype))
g.nodes[item_ntype].data['id'] = torch.arange(g.number_of_nodes(item_ntype))
# 负责采样出batch_size大小的节点列表: heads, tails, neg_tails
batch_sampler = sampler_module.ItemToItemBatchSampler(
g, user_ntype, item_ntype, args.batch_size)
# 由一个batch中的heads,tails,neg_tails构建训练这个batch所需要的
# pos_graph,neg_graph 和 blocks
neighbor_sampler = sampler_module.NeighborSampler(
g, user_ntype, item_ntype, args.random_walk_length,
args.random_walk_restart_prob, args.num_random_walks, args.num_neighbors,
args.num_layers)
# 每次next()返回: pos_graph,neg_graph和blocks做训练之用
collator = sampler_module.PinSAGECollator(neighbor_sampler, g, item_ntype, textset)
dataloader = DataLoader(
batch_sampler,
collate_fn=collator.collate_train,
num_workers=args.num_workers)
# 每次next()返回blocks做训练中测试之用
dataloader_test = DataLoader(
torch.arange(g.number_of_nodes(item_ntype)),
batch_size=args.batch_size,
collate_fn=collator.collate_test,
num_workers=args.num_workers)
dataloader_it = iter(dataloader)
# 准备模型
model = PinSAGEModel(g, item_ntype, textset, args.hidden_dims, args.num_layers).to(device)
opt = torch.optim.Adam(model.parameters(), lr=args.lr)
# 训练过程
for epoch_id in range(args.num_epochs):
model.train()
for batch_id in tqdm.trange(args.batches_per_epoch):
pos_graph, neg_graph, blocks = next(dataloader_it)
for i in range(len(blocks)):
blocks[i] = blocks[i].to(device)
pos_graph = pos_graph.to(device)
neg_graph = neg_graph.to(device)
loss = model(pos_graph, neg_graph, blocks).mean()
opt.zero_grad()
loss.backward()
opt.step()
```
至此DGL PinSAGE example的主要实现代码已经全部介绍完了感兴趣的可以去官网对照源代码自行学习。
## 参考
[Graph Convolutional Neural Networks for Web-Scale Recommender Systems](https://arxiv.org/abs/1806.01973)
[PinSAGE 召回模型及源码分析(1): PinSAGE 简介](https://zhuanlan.zhihu.com/p/275942839)
[全面理解PinSage](https://zhuanlan.zhihu.com/p/133739758)
[[论文笔记]PinSAGE——Graph Convolutional Neural Networks for Web-Scale Recommender Systems](https://zhuanlan.zhihu.com/p/461720302)

View File

@@ -0,0 +1,415 @@
## 写在前面
MIND模型(Multi-Interest Network with Dynamic Routing) 是阿里团队2019年在CIKM上发的一篇paper该模型依然是用在召回阶段的一个模型解决的痛点是之前在召回阶段的模型比如双塔YouTubeDNN召回模型等在模拟用户兴趣的时候总是基于用户的历史点击最后通过pooling的方式得到一个兴趣向量用该向量来表示用户的兴趣但是该篇论文的作者认为**用一个向量来表示用户的广泛兴趣未免有点太过于单一**,这是作者基于天猫的实际场景出发的发现,每个用户每天与数百种产品互动, 而互动的产品往往来自于很多个类别,这就说明用户的兴趣极其广泛,**用一个向量是无法表示这样广泛的兴趣的**,于是乎,就自然而然的引出一个问题,**有没有可能用多个向量来表示用户的多种兴趣呢?**
这篇paper的核心是胶囊网络**该网络采用了动态路由算法能非常自然的将历史商品聚成多个集合每个集合的历史行为进一步推断对应特定兴趣的用户表示向量。这样对于一个特定的用户MND输出了多个表示向量它们代表了用户的不同兴趣。当用户再有新的交互时通过胶囊网络还能实时的改变用户的兴趣表示向量做到在召回阶段的实时个性化**。那么,胶囊网络究竟是怎么做到的呢? 胶囊网络又是什么原理呢?
**主要内容**
* 背景与动机
* 胶囊网络与动态路由机制
* MIND模型的网络结构与细节剖析
* MIND模型之简易代码复现
* 总结
## 背景与动机
本章是基于天猫APP的背景来探索十亿级别的用户个性化推荐。天猫的推荐的流程主要分为召回阶段和排序阶段。召回阶段负责检索数千个与用户兴趣相关的候选物品之后排序阶段预测用户与这些候选物品交互的精确概率。这篇文章做的是召回阶段的工作来对满足用户兴趣的物品的有效检索。
作者这次的出发点是基于场景出发,在天猫的推荐场景中,作者发现**用户的兴趣存在多样性**。平均上10亿用户访问天猫每个用户每天与数百种产品互动。交互后的物品往往属于不同的类别说明用户兴趣的多样性。 一张图片会更加简洁直观:
![在这里插入图片描述](https://img-blog.csdnimg.cn/67ec071db0d44eb2ac0ee499980bf8a9.png#pic_center)
因此如果能在**召回阶段建立用户多兴趣模型来模拟用户的这种广泛兴趣**,那么作者认为是非常有必要的,因为召回阶段的任务就是根据用户兴趣检索候选商品嘛。
那么,如何能基于用户的历史交互来学习用户的兴趣表示呢? 以往的解决方案如下:
* 协同过滤的召回方法(itemcf和usercf)是通过历史交互过的物品或隐藏因子直接表示用户兴趣, 但会遇到**稀疏或计算问题**
* 基于深度学习的方法用低维的embedding向量表示用户比如YoutubeDNN召回模型双塔模型等都是把用户的基本信息或者用户交互过的历史商品信息等过一个全连接层最后编码成一个向量用这个向量来表示用户兴趣但作者认为**这是多兴趣表示的瓶颈**,因为需要压缩所有与用户多兴趣相关的信息到一个表示向量,所有用户多兴趣的信息进行了混合,导致这种多兴趣并无法体现,所以往往召回回来的商品并不是很准确,除非向量维度很大,但是大维度又会带来高计算。
* DIN模型在Embedding的基础上加入了Attention机制来选择的捕捉用户兴趣的多样性但采用Attention机制**对于每个目标物品,都需要重新计算用户表示**,这在召回阶段是行不通的(海量)所以DIN一般是用于排序。
所以作者想在召回阶段去建模用户的多兴趣但以往的方法都不好使为了解决这个问题就提出了动态路由的多兴趣网络MIND。为了推断出用户的多兴趣表示提出了一个多兴趣提取层该层使用动态路由机制自动的能将用户的历史行为聚类然后每个类簇中产生一个表示向量这个向量能代表用户某种特定的兴趣而多个类簇的多个向量合起来就能表示用户广泛的兴趣了。
这就是MIND的提出动机以及初步思路了这里面的核心是Multi-interest extractor layer 而这里面重点是动态路由与胶囊网络,所以接下来先补充这方面的相关知识。
## 胶囊网络与动态路由机制
### 胶囊网络初识
Hinton大佬在2011年的时候就首次提出了"胶囊"的概念, "胶囊"可以看成是一组聚合起来输出整个向量的小神经元组合,这个向量的每个维度(每个小神经元),代表着某个实体的某个特征。
胶囊网络其实可以和神经网络对比着看可能更好理解我们知道神经网络的每一层的神经元输出的是单个的标量值接收的输入也是多个标量值所以这是一种value to value的形式而胶囊网络每一层的胶囊输出的是一个向量值接收的输入也是多个向量所以它是vector to vector形式的。来个图对比下就清楚了
![在这里插入图片描述](https://img-blog.csdnimg.cn/1f698efd1f7e4b76babb061e52133e45.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center)
左边的图是普通神经元的计算示意,而右边是一个胶囊内部的计算示意图。 神经元这里不过多解释,这里主要是剖析右边的这个胶囊计算原理。从上图可以看出, 输入是两个向量$v_1,v_2$,首先经过了一个线性映射,得到了两个新向量$u_1,u_2$,然后呢,经过了一个向量的加权汇总,这里的$c_1$,$c_2$可以先理解成权重,具体计算后面会解释。 得到汇总后的向量$s$接下来进行了Squash操作整体的计算公式如下
$$
\begin{aligned}
&u^{1}=W^{1} v^{1} \quad u^{2}=W^{2} v^{2} \\
&s=c_{1} u^{1}+c_{2} u^{2} \\
&v=\operatorname{Squash}(s) =\frac{\|s\|^{2}}{1+\|s\|^{2}} \frac{s}{\|s\|}
\end{aligned}
$$
这里的Squash操作可以简单看下主要包括两部分右边的那部分其实就是向量归一化操作把norm弄成1而左边那部分算是一个非线性操作如果$s$的norm很大那么这个整体就接近1 而如果这个norm很小那么整体就会接近0 和sigmoid很像有没有
这样就完成了一个胶囊的计算,但有两点需要注意:
1. 这里的$W^i$参数是可学习的,和神经网络一样, 通过BP算法更新
2. 这里的$c_i$参数不是BP算法学习出来的而是采用动态路由机制现场算出来的这个非常类似于pooling层我们知道pooling层的参数也不是学习的而是根据前面的输入现场取最大或者平均计算得到的。
所以这里的问题,就是怎么通过动态路由机制得到$c_i$,下面是动态路由机制的过程。
### 动态路由机制原理
我们先来一个胶囊结构:
![在这里插入图片描述](https://img-blog.csdnimg.cn/12fca14263d943318bf3d83180b55e01.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center)
这个$c_i$是通过动态路由机制计算得到,那么动态路由机制究竟是啥子意思? 其实就是通过迭代的方式去计算,没有啥神秘的,迭代计算的流程如下图:
![在这里插入图片描述](https://img-blog.csdnimg.cn/82746b6ff8ac47fab6a89788d8d50f9e.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center)
首先我们先初始化$b_i$,与每一个输入胶囊$u_i$进行对应,这哥们有个名字叫做"routing logit" 表示的是输出的这个胶囊与输入胶囊的相关性和注意力机制里面的score值非常像。由于一开始不知道这个哪个胶囊与输出的胶囊有关系所以默认相关性分数都一样然后进入迭代。
在每一次迭代中,首先把分数转成权重,然后加权求和得到$s$,这个很类似于注意力机制的步骤,得到$s$之后,通过归一化操作,得到$a$,接下来要通过$a$和输入胶囊的相关性以及上一轮的$b_i$来更新$b_i$。最后那个公式有必要说一下在干嘛:
>如果当前的$a$与某一个输入胶囊$u_i$非常相关,即内积结果很大的话,那么相应的下一轮的该输入胶囊对应的$b_i$就会变大, 那么, 在计算下一轮的$a$的时候,与上一轮$a$相关的$u_i$就会占主导,相当于下一轮的$a$与上一轮中和他相关的那些$u_i$之间的路径权重会大一些,这样从空间点的角度观察,就相当于$a$点朝与它相关的那些$u$点更近了一点。
通过若干次迭代之后,得到最后的输出胶囊向量$a$会慢慢的走到与它更相关的那些$u$附近,而远离那些与它不相干的$u$。所以上面的这个迭代过程有点像**排除异常输入胶囊的感觉**。
![在这里插入图片描述](https://img-blog.csdnimg.cn/2bc074c460fa403f8a98fa24aa4a31a3.png#pic_center)
而从另一个角度来考虑,这个过程其实像是聚类的过程,因为胶囊的输出向量$v$经过若干次迭代之后,会最终停留到与其非常相关的那些输入胶囊里面,而这些输入胶囊,其实就可以看成是某个类别了,因为既然都共同的和输出胶囊$v$比较相关,那么彼此之间的相关性也比较大,于是乎,经过这样一个动态路由机制之后,就不自觉的,把输入胶囊实现了聚类。把和与其他输入胶囊不同的那些胶囊给排除了出去。
所以,这个动态路由机制的计算设计的还是比较巧妙的, 下面是上述过程的展开计算过程, 这个和RNN的计算有点类似
![在这里插入图片描述](https://img-blog.csdnimg.cn/c189e1258de64e42b576884844e718a4.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center)
这样就完成了一个胶囊内部的计算过程了。
Ok 有了上面的这些铺垫再来看MIND就会比较简单了。下面正式对MIND模型的网络架构剖析。
## MIND模型的网络结构与细节剖析
### 网络整体结构
MIND网络的架构如下
![在这里插入图片描述](https://img-blog.csdnimg.cn/33b251f8dcb242ad82b2ed0313f6df73.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center)
初步先分析这个网络结构的运作: 首先接收的输入有三类特征用户base属性历史行为属性以及商品的属性用户的历史行为序列属性过了一个多兴趣提取层得到了多个兴趣胶囊接下来和用户base属性拼接过DNN得到了交互之后的用户兴趣。然后在训练阶段用户兴趣和当前商品向量过一个label-aware attention然后求softmax损失。 在服务阶段,得到用户的向量之后,就可以直接进行近邻检索,找候选商品了。 这就是宏观过程但是多兴趣提取层以及这个label-aware attention是在做什么事情呢 如果单独看这个图感觉得到多个兴趣胶囊之后直接把这些兴趣胶囊以及用户的base属性拼接过全连接那最终不就成了一个用户向量此时label-aware attention的意义不就没了 所以这个图初步感觉画的有问题,和论文里面描述的不符。所以下面先以论文为主,正式开始描述具体细节。
### 任务目标
召回任务的目标是对于每一个用户$u \in \mathcal{U}$从十亿规模的物品池$\mathcal{I}$检索出包含与用户兴趣相关的上千个物品集。
#### 模型的输入
对于模型,每个样本的输入可以表示为一个三元组:$\left(\mathcal{I}_{u}, \mathcal{P}_{u}, \mathcal{F}_{i}\right)$,其中$\mathcal{I}_{u}$代表与用户$u$交互过的物品集,即用户的历史行为;$\mathcal{P}_{u}$表示用户的属性,例如性别、年龄等;$\mathcal{F}_{i}$定义为目标物品$i$的一些特征例如物品id和种类id等。
#### 任务描述
MIND的核心任务是学习一个从原生特征映射到**用户表示**的函数,用户表示定义为:
$$
\mathrm{V}_{u}=f_{u s e r}\left(\mathcal{I}_{u}, \mathcal{P}_{u}\right)
$$
其中,$\mathbf{V}_{u}=\left(\overrightarrow{\boldsymbol{v}}_{u}^{1}, \ldots, \overrightarrow{\boldsymbol{v}}_{u}^{K}\right) \in \mathbb{R}^{d \times k}$是用户$u$的表示向量,$d$是embedding的维度$K$表示向量的个数,即兴趣的数量。如果$K=1$那么MIND模型就退化成YouTubeDNN的向量表示方式了。
目标物品$i$的embedding函数为
$$
\overrightarrow{\mathbf{e}}_{i}=f_{\text {item }}\left(\mathcal{F}_{i}\right)
$$
其中,$\overrightarrow{\mathbf{e}}_{i} \in \mathbb{R}^{d \times 1}, \quad f_{i t e m}(\cdot)$表示一个embedding&pooling层。
#### 最终结果
根据评分函数检索(根据**目标物品与用户表示向量的内积的最大值作为相似度依据**DIN的Attention部分也是以这种方式来衡量两者的相似度得到top N个候选项
$$
f_{\text {score }}\left(\mathbf{V}_{u}, \overrightarrow{\mathbf{e}}_{i}\right)=\max _{1 \leq k \leq K} \overrightarrow{\mathbf{e}}_{i}^{\mathrm{T}} \overrightarrow{\mathbf{V}}_{u}^{\mathrm{k}}
$$
### Embedding & Pooling层
Embedding层的输入由三部分组成用户属性$\mathcal{P}_{u}$、用户行为$\mathcal{I}_{u}$和目标物品标签$\mathcal{F}_{i}$。每一部分都由多个id特征组成则是一个高维的稀疏数据因此需要Embedding技术将其映射为低维密集向量。具体来说
* 对于$\mathcal{P}_{u}$的id特征年龄、性别等是将其Embedding的向量进行Concat组成用户属性Embedding$\overrightarrow{\mathbf{p}}_{u}$
* 目标物品$\mathcal{F}_{i}$通常包含其他分类特征id品牌id、店铺id等 这些特征有利于物品的冷启动问题需要将所有的分类特征的Embedding向量进行平均池化得到一个目标物品向量$\overrightarrow{\mathbf{e}}_{i}$
* 对于用户行为$\mathcal{I}_{u}$由物品的Embedding向量组成用户行为Embedding列表$E_{u}=\overrightarrow{\mathbf{e}}_{j}, j \in \mathcal{I}_{u}$ 当然这里不仅只有物品embedding哈也可能有类别品牌等其他的embedding信息。
### Multi-Interest Extractor Layer(核心)
作者认为,单一的向量不足以表达用户的多兴趣。所以作者采用**多个表示向量**来分别表示用户不同的兴趣。通过这个方式,在召回阶段,用户的多兴趣可以分别考虑,对于兴趣的每一个方面,能够更精确的进行物品检索。
为了学习多兴趣表示,作者利用胶囊网络表示学习的动态路由将用户的历史行为分组到多个簇中。来自一个簇的物品应该密切相关,并共同代表用户兴趣的一个特定方面。
由于多兴趣提取器层的设计灵感来自于胶囊网络表示学习的动态路由,所以这里作者回顾了动态路由机制。当然,如果之前对胶囊网络或动态路由不了解,这里读起来就会有点艰难,但由于我上面进行了铺垫,这里就直接拿过原文并解释即可。
#### 动态路由
动态路由是胶囊网络中的迭代学习算法用于学习低水平胶囊和高水平胶囊之间的路由对数logit$b_{ij}$,来得到高水平胶囊的表示。
我们假设胶囊网络有两层,即低水平胶囊$\vec{c}_{i}^{l} \in \mathbb{R}^{N_{l} \times 1}, i \in\{1, \ldots, m\}$和高水平胶囊$\vec{c}_{j}^{h} \in \mathbb{R}^{N_{h} \times 1}, j \in\{1, \ldots, n\}$,其中$m,n$表示胶囊的个数, $N_l,N_h$表示胶囊的维度。 路由对数$b_{ij}$计算公式如下:
$$
b_{i j}=\left(\vec{c}_{j}^{h}\right)^{T} \mathrm{~S}_{i j} \vec{c}_{i}^{l}
$$
其中$\mathbf{S}_{i j} \in \mathbb{R}^{N_{h} \times N_{l}}$表示待学习的双线性映射矩阵【在胶囊网络的原文中称为转换矩阵】
通过计算路由对数,将高阶胶囊$j$的候选向量计算为所有低阶胶囊的加权和:
$$
\vec{z}_{j}^{h}=\sum_{i=1}^{m} w_{i j} S_{i j} \vec{c}_{i}^{l}
$$
其中$w_{ij}$定义为连接低阶胶囊$i$和高阶胶囊$j$的权重【称为耦合系数】而且其通过对路由对数执行softmax来计算
$$
w_{i j}=\frac{\exp b_{i j}}{\sum_{k=1}^{m} \exp b_{i k}}
$$
最后,应用一个非线性的“压缩”函数来获得一个高阶胶囊的向量【胶囊网络向量的模表示由胶囊所代表的实体存在的概率】
$$
\vec{c}_{j}^{h}=\operatorname{squash}\left(\vec{z}_{j}^{h}\right)=\frac{\left\|\vec{z}_{j}^{h}\right\|^{2}}{1+\left\|\vec{z}_{j}^{h}\right\|^{2}} \frac{\vec{z}_{j}^{h}}{\left\|\vec{z}_{j}^{h}\right\|}
$$
路由过程重复进行3次达到收敛。当路由结束高阶胶囊值$\vec{c}_{j}^{h}$固定,作为下一层的输入。
Ok下面我们开始解释其实上面说的这些就是胶囊网络的计算过程只不过和之前所用的符号不一样了。这里拿个图
![在这里插入图片描述](https://img-blog.csdnimg.cn/02fd2e79c97c4345bb228b3bb2eb517c.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center)
首先,论文里面也是个两层的胶囊网络,低水平层->高水平层。 低水平层有$m$个胶囊,每个胶囊向量维度是$N_l$,用$\vec{c}_{i}^l$表示的,高水平层有$n$个胶囊,每个胶囊$N_h$维,用$\vec{c}_{j}^h$表示。
单独拿出每个$\vec{c}_{j}^h$,其计算过程如上图所示。首先,先随机初始化路由对数$b_{ij}=0$,然后开始迭代,对于每次迭代:
$$
w_{i j}=\frac{\exp b_{i j}}{\sum_{k=1}^{m} \exp b_{i k}} \\
\vec{z}_{j}^{h}=\sum_{i=1}^{m} w_{i j} S_{i j} \vec{c}_{i}^{l} \\ \vec{c}_{j}^{h}=\operatorname{squash}\left(\vec{z}_{j}^{h}\right)=\frac{\left\|\vec{z}_{j}^{h}\right\|^{2}}{1+\left\|\vec{z}_{j}^{h}\right\|^{2}} \frac{\vec{z}_{j}^{h}}{\left\|\vec{z}_{j}^{h}\right\|} \\ b_{i j}=\left(\vec{c}_{j}^{h}\right)^{T} \mathrm{~S}_{i j} \vec{c}_{i}^{l}
$$
只不过这里的符合和上图中的不太一样,这里的$w_{ij}$对应的是每个输入胶囊的权重$c_{ij}$ 这里的$\vec{c}_{j}^h$对应上图中的$a$ 这里的$\vec{z}_{j}^h$对应的是输入胶囊的加权组合。这里的$\vec{c}_{i}^l$对应上图中的$v_i$,这里的$S_{ij}$对应的是上图的权重$W_{ij}$,只不过这个可以换成矩阵运算。 和上图中不同的是路由对数$b_{ij}$更新那里,没有了上一层的路由对数值,但感觉这样会有问题。
所以,这样解释完之后就会发现,其实上面的一顿操作就是说的传统的动态路由机制。
#### B2I动态路由
作者设计的多兴趣提取层就是就是受到了上述胶囊网络的启发。
如果把用户的行为序列看成是行为胶囊, 把用户的多兴趣看成兴趣胶囊,那么多兴趣提取层就是利用动态路由机制学习行为胶囊`->`兴趣胶囊的映射关系。但是原始路由算法无法直接应用于处理用户行为数据。因此,提出了**行为(Behavior)到兴趣(Interest)B2I动态路由**来自适应地将用户的行为聚合到兴趣表示向量中,它与原始路由算法有三个不同之处:
1. **共享双向映射矩阵**。在初始动态路由中,使用固定的或者说共享的双线性映射矩阵$S$而不是单独的双线性映射矩阵, 在原始的动态路由中,对于每个输出胶囊$\vec{c}_{j}^h$,都会有对应的$S_{ij}$,而这里是每个输出胶囊,都共用一个$S$矩阵。 原因有两个:
1. 一方面,用户行为是可变长度的,从几十个到几百个不等,因此使用共享的双线性映射矩阵是有利于泛化。
2. 另一方面,希望兴趣胶囊在同一个向量空间中,但不同的双线性映射矩阵将兴趣胶囊映射到不同的向量空间中。因为映射矩阵的作用就是对用户的行为胶囊进行线性映射嘛, 由于用户的行为序列都是商品,所以希望经过映射之后,到统一的商品向量空间中去。路由对数计算如下:
$$
b_{i j}=\overrightarrow{\boldsymbol{u}}_{j}^{T} \mathrm{S\overrightarrow{e}}_{i}, \quad i \in \mathcal{I}_{u}, j \in\{1, \ldots, K\}
$$
其中,$\overrightarrow{\boldsymbol{e}}_{i} \in \mathbb{R}^{d}$是历史物品$i$的embedding$\vec{u}_{j} \in \mathbb{R}^{d}$表示兴趣胶囊$j$的向量。$S \in \mathbb{R}^{d \times d}$是每一对行为胶囊(低价)到兴趣胶囊(高阶)之间 的共享映射矩阵。
2. **随机初始化路由对数**。由于利用共享双向映射矩阵$S$如果再初始化路由对数为0将导致相同的初始的兴趣胶囊。随后的迭代将陷入到一个不同兴趣胶囊在所有的时间保持相同的情景。因为每个输出胶囊的运算都一样了嘛(除非迭代的次数不同,但这样也会导致兴趣胶囊都很类似),为了减轻这种现象,作者通过高斯分布进行随机采样来初始化路由对数$b_{ij}$,让初始兴趣胶囊与其他每一个不同,其实就是希望在计算每个输出胶囊的时候,通过随机化的方式,希望这几个聚类中心离得远一点,这样才能表示出广泛的用户兴趣(我们已经了解这个机制就仿佛是聚类,而计算过程就是寻找聚类中心)。
3. **动态的兴趣数量**,兴趣数量就是聚类中心的个数,由于不同用户的历史行为序列不同,那么相应的,其兴趣胶囊有可能也不一样多,所以这里使用了一种启发式方式自适应调整聚类中心的数量,即$K$值。
$$
K_{u}^{\prime}=\max \left(1, \min \left(K, \log _{2}\left(\left|\mathcal{I}_{u}\right|\right)\right)\right)
$$
这种调整兴趣胶囊数量的策略可以为兴趣较小的用户节省一些资源,包括计算和内存资源。这个公式不用多解释,与行为序列长度成正比。
最终的B2I动态路由算法如下
![在这里插入图片描述](https://img-blog.csdnimg.cn/37cc4943b91c494d987a8aa844077c42.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center)
应该很好理解了吧。
### Label-aware Attention Layer
通过多兴趣提取器层从用户的行为embedding中生成多个兴趣胶囊。不同的兴趣胶囊代表用户兴趣的不同方面相应的兴趣胶囊用于评估用户对特定类别的偏好。所以在训练的期间最后需要设置一个Label-aware的注意力层对于当前的商品根据相关性选择最相关的兴趣胶囊。这里其实就是一个普通的注意力机制和DIN里面的那个注意力层基本上是一模一样计算公式如下
$$
\begin{aligned}
\overrightarrow{\boldsymbol{v}}_{u} &=\operatorname{Attention}\left(\overrightarrow{\boldsymbol{e}}_{i}, \mathrm{~V}_{u}, \mathrm{~V}_{u}\right) \\
&=\mathrm{V}_{u} \operatorname{softmax}\left(\operatorname{pow}\left(\mathrm{V}_{u}^{\mathrm{T}} \overrightarrow{\boldsymbol{e}}_{i}, p\right)\right)
\end{aligned}
$$
首先这里的$\overrightarrow{\boldsymbol{e}}_{i}$表示当前的商品向量,$V_u$表示用户的多兴趣向量组合,里面有$K$个向量,表示用户的$K$的兴趣。用户的各个兴趣向量与目标商品做内积然后softmax转成权重然后反乘到多个兴趣向量进行加权求和。 但是这里需要注意的一个小点,就是这里做内积求完相似性之后,先做了一个指数操作,**这个操作其实能放大或缩小相似程度**,至于放大或者缩小的程度,由$p$控制。 比如某个兴趣向量与当前商品非常相似,那么再进行指数操作之后,如果$p$也很大,那么显然这个兴趣向量就占了主导作用。$p$是一个可调节的参数来调整注意力分布。当$p$接近0每一个兴趣胶囊都得到相同的关注。当$p$大于1时随着$p$的增加,具有较大值的点积将获得越来越多的权重。考虑极限情况,当$p$趋近于无穷大时,注意机制就变成了一种硬注意,选关注最大的值而忽略其他值。在实验中,发现使用硬注意导致更快的收敛。
>理解:$p$小意味着所有的相似程度都缩小了, 使得之间的差距会变小,所以相当于每个胶囊都会受到关注,而越大的话,使得各个相似性差距拉大,相似程度越大的会更大,就类似于贫富差距, 最终使得只关注于比较大的胶囊。
### 训练与服务
得到用户向量$\overrightarrow{\boldsymbol{v}}_{u}$和标签物品embedding$\vec{e}_{i}$后,计算用户$u$与标签物品$i$交互的概率:
$$
\operatorname{Pr}(i \mid u)=\operatorname{Pr}\left(\vec{e}_{i} \mid \vec{v}_{u}\right)=\frac{\exp \left(\vec{v}_{u}^{\mathrm{T} \rightarrow}\right)}{\sum_{j \in I} \exp \left(\vec{v}_{u}^{\mathrm{T}} \vec{e}_{j}\right)}
$$
目标函数是:
$$
L=\sum_{(u, i) \in \mathcal{D}} \log \operatorname{Pr}(i \mid u)
$$
其中$\mathcal{D}$是训练数据包含用户物品交互的集合。因为物品的数量可伸缩到数十亿所以不能直接算。因此。使用采样的softmax技术并且选择Adam优化来训练MIND。
训练结束后抛开label-aware注意力层MIND网络得到一个用户表示映射函数$f_{user}$。在服务期间,用户的历史序列与自身属性喂入到$f_{user}$每个用户得到多兴趣向量。然后这个表示向量通过一个近似邻近方法来检索top N物品。
这就是整个MIND模型的细节了。
## MIND模型之简易代码复现
下面参考Deepctr用简易的代码实现下MIND并在新闻推荐的数据集上进行召回任务。
### 整个代码架构
整个MIND模型算是参考deepmatch修改的一个简易版本
```python
def MIND(user_feature_columns, item_feature_columns, num_sampled=5, k_max=2, p=1.0, dynamic_k=False, user_dnn_hidden_units=(64, 32),
dnn_activation='relu', dnn_use_bn=False, l2_reg_dnn=0, l2_reg_embedding=1e-6, dnn_dropout=0, output_activation='linear', seed=1024):
"""
:param k_max: 用户兴趣胶囊的最大个数
"""
# 目前这里只支持item_feature_columns为1的情况即只能转入item_id
if len(item_feature_columns) > 1:
raise ValueError("Now MIND only support 1 item feature like item_id")
# 获取item相关的配置参数
item_feature_column = item_feature_columns[0]
item_feature_name = item_feature_column.name
item_vocabulary_size = item_feature_column.vocabulary_size
item_embedding_dim = item_feature_column.embedding_dim
behavior_feature_list = [item_feature_name]
# 为用户特征创建Input层
user_input_layer_dict = build_input_layers(user_feature_columns)
item_input_layer_dict = build_input_layers(item_feature_columns)
# 将Input层转化成列表的形式作为model的输入
user_input_layers = list(user_input_layer_dict.values())
item_input_layers = list(item_input_layer_dict.values())
# 筛选出特征中的sparse特征和dense特征方便单独处理
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), user_feature_columns)) if user_feature_columns else []
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), user_feature_columns)) if user_feature_columns else []
varlen_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), user_feature_columns)) if user_feature_columns else []
# 由于这个变长序列里面只有历史点击文章没有类别啥的所以这里直接可以用varlen_feature_columns
# deepctr这里单独把点击文章这个放到了history_feature_columns
seq_max_len = varlen_feature_columns[0].maxlen
# 构建embedding字典
embedding_layer_dict = build_embedding_layers(user_feature_columns+item_feature_columns)
# 获取当前的行为特征(doc)的embedding这里面可能又多个类别特征所以需要pooling下
query_embed_list = embedding_lookup(behavior_feature_list, item_input_layer_dict, embedding_layer_dict) # 长度为1
# 获取行为序列(doc_id序列, hist_doc_id) 对应的embedding这里有可能有多个行为产生了行为序列所以需要使用列表将其放在一起
keys_embed_list = embedding_lookup([varlen_feature_columns[0].name], user_input_layer_dict, embedding_layer_dict) # 长度为1
# 用户离散特征的输入层与embedding层拼接
dnn_input_emb_list = embedding_lookup([col.name for col in sparse_feature_columns], user_input_layer_dict, embedding_layer_dict)
# 获取dense
dnn_dense_input = []
for fc in dense_feature_columns:
if fc.name != 'hist_len': # 连续特征不要这个
dnn_dense_input.append(user_input_layer_dict[fc.name])
# 把keys_emb_list和query_emb_listpooling操作 这是因为可能每个商品不仅有id还可能用类别品牌等多个embedding向量这种需要pooling成一个
history_emb = PoolingLayer()(NoMask()(keys_embed_list)) # (None, 50, 8)
target_emb = PoolingLayer()(NoMask()(query_embed_list)) # (None, 1, 8)
hist_len = user_input_layer_dict['hist_len']
# 胶囊网络
# (None, 2, 8) 得到了两个兴趣胶囊
high_capsule = CapsuleLayer(input_units=item_embedding_dim, out_units=item_embedding_dim,
max_len=seq_max_len, k_max=k_max)((history_emb, hist_len))
# 把用户的其他特征拼接到胶囊网络上来
if len(dnn_input_emb_list) > 0 or len(dnn_dense_input) > 0:
user_other_feature = combined_dnn_input(dnn_input_emb_list, dnn_dense_input)
# (None, 2, 32) 这里会发现其他的用户特征是每个胶囊复制了一份,然后拼接起来
other_feature_tile = tf.keras.layers.Lambda(tile_user_otherfeat, arguments={'k_max': k_max})(user_other_feature)
user_deep_input = Concatenate()([NoMask()(other_feature_tile), high_capsule]) # (None, 2, 40)
else:
user_deep_input = high_capsule
# 接下来过一个DNN层获取最终的用户表示向量 如果是三维输入, 那么最后一个维度与w相乘所以这里如果不自己写可以用Dense层的列表也可以
user_embeddings = DNN(user_dnn_hidden_units, dnn_activation, l2_reg_dnn,
dnn_dropout, dnn_use_bn, output_activation=output_activation, seed=seed,
name="user_embedding")(user_deep_input) # (None, 2, 8)
# 接下来过Label-aware layer
if dynamic_k:
user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb, hist_len))
else:
user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb))
# 接下来
item_embedding_matrix = embedding_layer_dict[item_feature_name] # 获取doc_id的embedding层
item_index = EmbeddingIndex(list(range(item_vocabulary_size)))(item_input_layer_dict[item_feature_name]) # 所有doc_id的索引
item_embedding_weight = NoMask()(item_embedding_matrix(item_index)) # 拿到所有item的embedding
pooling_item_embedding_weight = PoolingLayer()([item_embedding_weight]) # 这里依然是当可能不止item_id或许还有brand_id, cat_id等需要池化
# 这里传入的是整个doc_id的embedding user_embedding, 以及用户点击的doc_id然后去进行负采样计算损失操作
output = SampledSoftmaxLayer(num_sampled)([pooling_item_embedding_weight, user_embedding_final, item_input_layer_dict[item_feature_name]])
model = Model(inputs=user_input_layers+item_input_layers, outputs=output)
# 下面是等模型训练完了之后获取用户和item的embedding
model.__setattr__("user_input", user_input_layers)
model.__setattr__("user_embedding", user_embeddings)
model.__setattr__("item_input", item_input_layers)
model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name]))
return model
```
简单说下流程, 函数式API搭建模型的方式首先我们需要传入封装好的用户特征描述以及item特征描述比如
```python
# 建立模型
user_feature_columns = [
SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim),
VarLenSparseFeat(SparseFeat('hist_doc_ids', feature_max_idx['article_id'], embedding_dim,
embedding_name="click_doc_id"), his_seq_maxlen, 'mean', 'hist_len'),
DenseFeat('hist_len', 1),
SparseFeat('u_city', feature_max_idx['city'], embedding_dim),
SparseFeat('u_age', feature_max_idx['age'], embedding_dim),
SparseFeat('u_gender', feature_max_idx['gender'], embedding_dim),
]
doc_feature_columns = [
SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim)
# 这里后面也可以把文章的类别画像特征加入
]
```
首先, 函数会对传入的这种特征建立模型的Input层主要是`build_input_layers`函数。建立完了之后获取到Input层列表这个是为了最终定义模型用的keras要求定义模型的时候是列表的形式。
接下来是选出sparse特征和Dense特征来这个也是常规操作了因为不同的特征后面处理方式不一样对于sparse特征后面要接embedding层Dense特征的话直接可以拼接起来。这就是筛选特征的3行代码。
接下来是为所有的离散特征建立embedding层通过函数`build_embedding_layers`。建立完了之后把item相关的embedding层与对应的Input层接起来作为query_embed_list 而用户历史行为序列的embedding层与Input层接起来作为keys_embed_list这两个有单独的用户。而Input层与embedding层拼接是通过`embedding_lookup`函数完成的。 这样完成了之后就能通过Input层-embedding层拿到item的系列embedding以及历史序列里面item系列embedding之所以这里是系列embedding是有可能不止item_id这一个特征还可能有品牌id, 类别id等好几个所以接下来把系列embedding通过pooling操作得到最终表示item的向量。 就是这两行代码:
```python
# 把keys_emb_list和query_emb_listpooling操作 这是因为可能每个商品不仅有id还可能用类别品牌等多个embedding向量这种需要pooling成一个
history_emb = PoolingLayer()(NoMask()(keys_embed_list)) # (None, 50, 8)
target_emb = PoolingLayer()(NoMask()(query_embed_list)) # (None, 1, 8)
```
而像其他的输入类别特征, 依然是Input层与embedding层拼起来留着后面用这个存到了dnn_input_emb_list中。 而dense特征 不需要embedding层直接通过Input层获取到然后存到列表里面留着后面用。
上面得到的history_emb就是用户的历史行为序列这个东西接下来要过兴趣提取层去学习用户的多兴趣当然这里还需要传入行为序列的真实长度。因为每个用户行为序列不一样长通过mask让其等长了但是真实在胶囊网络计算的时候这些填充的序列是要被mask掉的。所以必须要知道真实长度。
```python
# 胶囊网络
# (None, 2, 8) 得到了两个兴趣胶囊
high_capsule = CapsuleLayer(input_units=item_embedding_dim, out_units=item_embedding_dim,max_len=seq_max_len, k_max=k_max)((history_emb, hist_len))
```
通过这步操作,就得到了两个兴趣胶囊。 至于具体细节,下一节看。 然后把用户的其他特征拼接上来,这里有必要看下代码究竟是怎么拼接的:
```python
# 把用户的其他特征拼接到胶囊网络上来
if len(dnn_input_emb_list) > 0 or len(dnn_dense_input) > 0:
user_other_feature = combined_dnn_input(dnn_input_emb_list, dnn_dense_input)
# (None, 2, 32) 这里会发现其他的用户特征是每个胶囊复制了一份,然后拼接起来
other_feature_tile = tf.keras.layers.Lambda(tile_user_otherfeat, arguments={'k_max': k_max})(user_other_feature)
user_deep_input = Concatenate()([NoMask()(other_feature_tile), high_capsule]) # (None, 2, 40)
else:
user_deep_input = high_capsule
```
这里会发现使用了一个Lambda层这个东西的作用呢其实是将用户的其他特征在胶囊个数的维度上复制了一份再拼接这就相当于在每个胶囊的后面都拼接上了用户的基础特征。这样得到的维度就成了(None, 2, 40)2是胶囊个数 40是兴趣胶囊的维度+其他基础特征维度总和。这样拼完了之后,接下来过全连接层
```python
# 接下来过一个DNN层获取最终的用户表示向量 如果是三维输入, 那么最后一个维度与w相乘所以这里如果不自己写可以用Dense层的列表也可以
user_embeddings = DNN(user_dnn_hidden_units, dnn_activation, l2_reg_dnn,
dnn_dropout, dnn_use_bn, output_activation=output_activation, seed=seed,
name="user_embedding")(user_deep_input) # (None, 2, 8)
```
最终得到的是(None, 2, 8)的向量,这样就解决了之前的那个疑问, 最终得到的兴趣向量个数并不是1个而是多个兴趣向量了因为上面用户特征拼接是每个胶囊后面都拼接一份同样的特征。另外就是原来DNN这里的输入还可以是3维的这样进行运算的话是最后一个维度与W进行运算相当于只在第3个维度上进行了降维操作后者非线性操作这样得到的兴趣个数是不变的。
这样有了两个兴趣的输出之后接下来就是过LabelAwareAttention层了对这两个兴趣向量与当前item的相关性加注意力权重最后变成1个用户的最终向量。
```python
user_embedding_final = LabelAwareAttention(k_max=k_max, pow_p=p,)((user_embeddings, target_emb))
```
这样就得到了用户的最终表示向量当然这个操作仅是训练的时候服务的时候是拿的上面DNN的输出即多个兴趣这里注意一下。
拿到了最终的用户向量,如何计算损失呢? 这里用了负采样层进行操作。关于这个层具体的原理,后面我们可能会出一篇文章总结。
接下来有几行代码也需要注意:
```python
# 下面是等模型训练完了之后获取用户和item的embedding
model.__setattr__("user_input", user_input_layers)
model.__setattr__("user_embedding", user_embeddings)
model.__setattr__("item_input", item_input_layers)
model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name]))
```
这几行代码是为了模型训练完我们给定输入之后拿embedding用的设置好了之后通过
```python
user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding)
item_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding)
user_embs = user_embedding_model.predict(test_user_model_input, batch_size=2 ** 12)
# user_embs = user_embs[:, i, :] # i in [0,k_max) if MIND
item_embs = item_embedding_model.predict(all_item_model_input, batch_size=2 ** 12)
```
这样就能拿到用户和item的embedding 接下来近邻检索完成召回过程。 注意MIND的话这里是拿到的多个兴趣向量的。
## 总结
今天这篇文章整理的MIND这是一个多兴趣的召回模型核心是兴趣提取层该层通过动态路由机制能够自动的对用户的历史行为序列进行聚类得到多个兴趣向量这样能在召回阶段捕获到用户的广泛兴趣从而召回更好的候选商品。
**参考**
* Multi-Interest Network with Dynamic Routing for Recommendation at Tmall
* [ AI上推荐 之 MIND(动态路由与胶囊网络的奇光异彩)](https://blog.csdn.net/wuzhongqiang/article/details/123696462?spm=1001.2014.3001.5501)
* [Dynamic Routing Between Capsule ](https://arxiv.org/pdf/1710.09829.pdf)
* [CIKM2019MIND---召回阶段的多兴趣模型](https://zhuanlan.zhihu.com/p/262638999)
* [B站胶囊网络课程](https://www.bilibili.com/video/BV1eW411Q7CE?p=2)
* [胶囊网络识别交通标志](https://blog.csdn.net/shebao3333/article/details/79008688)

View File

@@ -0,0 +1,404 @@
## 写在前面
SDM模型(Sequential Deep Matching Model)是阿里团队在2019年CIKM上的一篇paper。和MIND模型一样是一种序列召回模型研究的依然是如何通过用户的历史行为序列去学习到用户的丰富兴趣。 对于MIND我们已经知道是基于胶囊网络的动态路由机制设计了一个动态兴趣提取层把用户的行为序列通过路由机制聚类然后映射成了多个兴趣胶囊以此来获取到用户的广泛兴趣。而SDM模型是先把用户的历史序列根据交互的时间分成了短期和长期两类然后从**短期会话**和**长期行为**中分别采取**相应的措施(短期的RNN+多头注意力, 长期的Att Net)** 去学习到用户的短期兴趣和长期行为偏好,并**巧妙的设计了一个门控网络==有选择==的将长短期兴趣进行融合**,以此得到用户的最终兴趣向量。 这篇paper中的一些亮点比如长期偏好的行为表示多头注意力机制学习多兴趣长短期兴趣的融合机制等又给了一些看待问题的新角度同时给出了我们一种利用历史行为序列去捕捉用户动态偏好的新思路。
这篇paper依然是从引言开始 介绍SDM模型提出的动机以及目前方法存在的不足(why) 接下来就是SDM的网络模型架构(what) 这里面的关键是如何从短期会话和长期行为两个方面学习到用户的短期长期偏好(how),最后,依然是简易代码实现。
大纲如下:
* 背景与动机
* SDM的网络结构与细节
* SDM模型代码复现
## 背景与动机
这里要介绍该模型提出的动机即why要有这样的一个模型
一个好的推荐系统应该是能精确的捕捉用户兴趣偏好以及能对他们当前需求进行快速响应的,往往工业上的推荐系统,为了能快速响应, 一般会把整个推荐流程分成召回和排序两个阶段,先通过召回,从海量商品中得到一个小的候选集,然后再给到排序模型做精确的筛选操作。 这也是目前推荐系统的一个范式了。在这个过程中,召回模块所检索到的候选对象的质量在整个系统中起着至关重要的作用。
淘宝目前的召回模型是一些基于协同过滤的模型, 这些模型是通过用户与商品的历史交互建模,从而得到用户的物品的表示向量,但这个过程是**静态的**,而用户的行为或者兴趣是时刻变化的, 对于协同过滤的模型来说,并不能很好的捕捉到用户整个行为序列的动态变化。
那我们知道了学习用户历史行为序列很重要, 那么假设序列很长呢这时候直接用模型学习长序列之间的演进可能不是很好因为很长的序列里面可能用户的兴趣发生过很大的转变很多商品压根就没有啥关系这样硬学反而会导致越学越乱就别提这个演进了。所以这里是以会话为单位对长序列进行切分。作者这里的依据就是用户在同一个Session下其需求往往是很明确的 这时候,交互的商品也往往都非常类似。 但是Session与Session之间可能需求改变那么商品类型可能骤变。 所以以Session为单位来学习商品之间的序列信息感觉要比整个长序列学习来的靠谱。
作者首先是先把长序列分成了多个会话, 然后**把最近的一次会话,和之前的会话分别视为了用户短期行为和长期行为分别进行了建模,并采用不同的措施学习用户的短期兴趣和长期兴趣,然后通过一个门控机制融合得到用户最终的表示向量**。这就是SDM在做的事情
长短期行为序列联合建模,其实是在给我们提供一种新的学习用户兴趣的新思路, 那么究竟是怎么做的呢?以及为啥这么做呢?
* 对于短期用户行为, 首先作者使用了LSTM来学习序列关系 而接下来是用一个Multi-head attention机制学习用户的多兴趣。
先分析分析作者为啥用多头注意力机制,作者这里依然是基于实际的场景出发,作者发现,**用户的兴趣点在一个会话里面其实也是多重的**。这个可能之前的很多模型也是没考虑到的,但在商品购买的场景中,这确实也是个事实, 顾客在买一个商品的时候,往往会进行多方比较, 考虑品牌,颜色,商店等各种因素。作者认为用普通的注意力机制是无法反映广泛的兴趣了,所以用多头注意力网络。
多头注意力机制从某个角度去看也有类似聚类的功效首先它接收了用户的行为序列然后从多个角度学习到每个商品与其他商品的相关性然后根据与其他商品的相关性加权融合这样相似的item向量大概率就融合到了一块组成一个向量所谓用户的多兴趣可能是因为这些行为商品之间可以从多个空间或者角度去get彼此之间的相关性这里面有着用户多兴趣的表达信息。
* 用户的长期行为也会影响当前的决策作者在这里举了一个NBA粉丝的例子说如果一个是某个NBA球星的粉丝那么他可能在之前会买很多有关这个球星的商品如果现在这个时刻想买鞋的时候大概率会考虑和球星相关的。所以作者说**长期偏好和短期行为都非常关键**。但是长期偏好或者行为往往是复杂广泛的,就像刚才这个例子里面,可能长期行为里面,买的与这个球星相关商品只占一小部分,而就只有这一小部分对当前决策有用。
这个也是之前的模型利用长期偏好方面存在的问题,那么如何选择出长期偏好里面对于当前决策有用的那部分呢? 作者这里设计了一个门控的方式融合短期和长期,这个想法还是很巧妙的,后面介绍这个东西的时候说下我的想法。
所以下面总结动机以及本篇论文的亮点:
* 动机: 召回模型需要捕获用户的动态兴趣变化,这个过程中利用好用户的长期行为和短期偏好非常关键,而以往的模型有下面几点不足:
* 协同过滤模型: 基于用户的交互进行静态建模,无法感知用户的兴趣变化过程,易召回同质性的商品
* 早期的一些序列推荐模型: 要么是对整个长序列直接建模,但这样太暴力,没法很好的学习商品之间的序列信息,有些是把长序列分成会话,但忽视了一个会话中用户的多重兴趣
* 有些方法在考虑用户的长期行为方面,只是简单的拼接或者加权求和,而实际上用户长期行为中只有很少一小部分对当前的预测有用,这样暴力融合反而会适得其反,起不到效果。另外还有一些多任务或者对抗方法, 在工业场景中不适用等。
* 这些我只是通过我的理解简单总结,详细内容看原论文相关工作部分。
* 亮点:
* SDM模型 考虑了用户的短期行为和长期兴趣,以会话的形式进行分割,并对这两方面分别建模
* 短期会话由于对当前决策影响比较大,那么我们就学习的全面一点, 首先RNN学习序列关系其次通过多头注意力机制捕捉多兴趣然后通过一个Attention Net加权得到短期兴趣表示
* 长期会话通过Attention Net融合然后过DNN得到用户的长期表示
* 我们设计了一个门控机制类似于LSTM的那种门控能巧妙的融合这两种兴趣得到用户最终的表示向量
这就是动机与背景总结啦。 那么接下来SDM究竟是如何学习短期和长期表示又是如何融合的 为什么要这么玩?
## SDM的网络结构与细节剖析
### 问题定义
这里本来直接看模型结构,但感觉还是先过一下问题定义吧,毕竟这次涉及到了会话,还有几个小规则。
$\mathcal{U}$表示用户集合,$\mathcal{I}$表示item集合模型考虑在时间$t$,是否用户$u$会对$i$产生交互。 对于$u$ 我们能够得到它的历史行为序列,那么先说一下如何进行会话的划分, 这里有三个规则:
1. 相同会话ID的商品(后台能获取)算是一个会话
2. 相邻的商品时间间隔小于10分钟(业务自己调整)算一个会话
3. 同一个会话中的商品不能超过50个多出来的放入下一个会话
这样划分开会话之后, 对于用户$u$的短期行为定义是离目前最近的这次会话, 用$\mathcal{S}^{u}=\left[i_{1}^{u}, \ldots, i_{t}^{u}, \ldots, i_{m}^{u}\right]$表示,$m$是序列长度。 而长期的用户行为是过去一周内的会话,但不包括短期的这次会话, 这个用$\mathcal{L}^{u}$表示。网络推荐架构如下:
![在这里插入图片描述](https://img-blog.csdnimg.cn/841c97c541484f908282be881ec32fd8.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_1,color_FFFFFF,t_70,g_se,x_16#pic_center)
这个感觉并不用过多解释。看过召回的应该都能懂, 接收了用户的短期行为和长期行为,然后分别通过两个盲盒得到表示向量,再通过门控融合就得到了最终的用户表示。
下面要开那三个盲盒操作,即短期行为学习,长期行为学习以及门控融合机制。但在这之前,得先说一个东西,就是输入层这里, 要带物品的side infomation比如物品的item ID, 物品的品牌ID商铺ID 类别ID等等 那你说,为啥要单独说呢? 之前的模型不也有, 但是这里在利用方式上有些不一样需要注意。
### Input Embedding with side Information
在淘宝的推荐场景中,作者发现, 顾客与物品产生交互行为的时候,不仅考虑特定的商品本身,还考虑产品, 商铺价格等这个显然。所以这里对于一个商品来说不仅要用到Item ID还用了更多的side info信息包括`leat category, fist level category, brand,shop`
所以,假设用户的短期行为是$\mathcal{S}^{u}=\left[i_{1}^{u}, \ldots, i_{t}^{u}, \ldots, i_{m}^{u}\right]$ 这里面的每个商品$i_t^u$其实有5个属性表示了每个属性本质是ID但转成embedding之后就得到了5个embedding 所以这里就涉及到了融合问题。 这里用$\boldsymbol{e}_{{i}^u_t} \in \mathbb{R}^{d \times 1}$来表示每个$i_t^u$但这里不是embedding的pooling操作而是Concat
$$
\boldsymbol{e}_{i_{t}^{u}}=\operatorname{concat}\left(\left\{\boldsymbol{e}_{i}^{f} \mid f \in \mathcal{F}\right\}\right)
$$
其中,$\boldsymbol{e}_{i}^{f}=\boldsymbol{W}^{f} \boldsymbol{x}_{i}^{f} \in \mathbb{R}^{d_{f} \times 1}$ 这个公式看着负责其实就是每个side info的id过embedding layer得到各自的embedding。这里embedding的维度是$d_f$ 等拼接起来之后,就是$d$维了。这个点要注意。
另外就是用户的base表示向量了这个很简单 就是用户的基础画像得到embedding直接也是Concat这个常规操作不解释
$$
\boldsymbol{e}_{u}=\operatorname{concat}\left(\left\{\boldsymbol{e}_{u}^{p} \mid p \in \mathcal{P}\right\}\right)
$$
$e_u^p$是特征$p$的embedding。
Ok输入这里说完了之后就直接开盲盒 不按照论文里面的顺序来了。想看更多细节的就去看原论文吧,感觉那里面说的有些啰嗦。不如直接上图解释来的明显:
![在这里插入图片描述](https://img-blog.csdnimg.cn/d297bf36d8c54b349dc666259b891927.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA57-75rua55qE5bCPQOW8ug==,size_2,color_FFFFFF,t_70,g_se,x_16#pic_center)
这里正好三个框把盒子框住了,下面剖析出每个来就行啦。
### 短期用户行为建模
这里短期用户行为是下面的那个框, 接收的输入首先是用户最近的那次会话里面各个商品加入了side info信息之后有了最终的embedding表示$\left[\boldsymbol{e}_{i_{1}^{u}}, \ldots, \boldsymbol{e}_{i_{t}^{u}}\right]$。
这个东西首先要过LSTM学习序列信息这个感觉不用多说直接上公式
$$
\begin{aligned}
\boldsymbol{i} \boldsymbol{n}_{t}^{u} &=\sigma\left(\boldsymbol{W}_{i n}^{1} \boldsymbol{e}_{i_{t}^{u}}+\boldsymbol{W}_{i n}^{2} \boldsymbol{h}_{t-1}^{u}+b_{i n}\right) \\
f_{t}^{u} &=\sigma\left(\boldsymbol{W}_{f}^{1} \boldsymbol{e}_{i_{t}^{u}}+\boldsymbol{W}_{f}^{2} \boldsymbol{h}_{t-1}^{u}+b_{f}\right) \\
\boldsymbol{o}_{t}^{u} &=\sigma\left(\boldsymbol{W}_{o}^{1} \boldsymbol{e}_{i}^{u}+\boldsymbol{W}_{o}^{2} \boldsymbol{h}_{t-1}^{u}+b_{o}\right) \\
\boldsymbol{c}_{t}^{u} &=\boldsymbol{f}_{t} \boldsymbol{c}_{t-1}^{u}+\boldsymbol{i} \boldsymbol{n}_{t}^{u} \tanh \left(\boldsymbol{W}_{c}^{1} \boldsymbol{e}_{i_{t}^{u}}+\boldsymbol{W}_{c}^{2} \boldsymbol{h}_{t-1}^{u}+b_{c}\right) \\
\boldsymbol{h}_{t}^{u} &=\boldsymbol{o}_{t}^{u} \tanh \left(\boldsymbol{c}_{t}^{u}\right)
\end{aligned}
$$
这里采用的是多输入多输出, 即每个时间步都会有一个隐藏状态$h_t^u$输出出来那么经过LSTM之后原始的序列就有了序列相关信息得到了$\left[\boldsymbol{h}_{1}^{u}, \ldots, \boldsymbol{h}_{t}^{u}\right]$, 把这个记为$\boldsymbol{X}^{u}$。这里的$\boldsymbol{h}_{t}^{u} \in \mathbb{R}^{d \times 1}$表示时间$t$的序列偏好表示。
接下来, 这个东西要过Multi-head self-attention层这个东西的原理我这里就不多讲了这个东西可以学习到$h_i^u$系列之间的相关性,这个操作从某种角度看,也很像聚类, 因为我们这里是先用多头矩阵把$h_i^u$系列映射到多个空间,然后从各个空间中互求相关性
$$
\text { head }{ }_{i}^{u}=\operatorname{Attention}\left(\boldsymbol{W}_{i}^{Q} \boldsymbol{X}^{u}, \boldsymbol{W}_{i}^{K} \boldsymbol{X}^{u}, \boldsymbol{W}_{i}^{V} \boldsymbol{X}^{u}\right)
$$
得到权重后,对原始的向量加权融合。 让$Q_{i}^{u}=W_{i}^{Q} X^{u}$ $K_{i}^{u}=W_{i}^{K} \boldsymbol{X}^{u}$$V_{i}^{u}=W_{i}^{V} X^{u}$ 背后计算是:
$$
\begin{aligned}
&f\left(Q_{i}^{u}, K_{i}^{u}\right)=Q_{i}^{u T} K_{i}^{u} \\
&A_{i}^{u}=\operatorname{softmax}\left(f\left(Q_{i}^{u}, K_{i}^{u}\right)\right)
\end{aligned} \\ \operatorname{head}_{i}^{u}=V_{i}^{u} A_{i}^{u T}
$$
这里如果有多头注意力基础的话非常好理解啊,不多解释,可以看我[这篇文章](https://blog.csdn.net/wuzhongqiang/article/details/104414239?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522164872966516781683952272%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=164872966516781683952272&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-104414239.nonecase&utm_term=Attention+is+all&spm=1018.2226.3001.4450)补一下。
这是一个头的计算, 接下来每个头都这么算,假设有$h$个头,这里会通过上面的映射矩阵$W$系列,先把原始的$h_i^u$向量映射到$d_{k}=\frac{1}{h} d$维度,然后计算$head_i^u$也是$d_k$维,这样$h$个head进行拼接正好是$d$维, 接下来过一个全连接或者线性映射得到MultiHead的输出。
$$
\hat{X}^{u}=\text { MultiHead }\left(X^{u}\right)=W^{O} \text { concat }\left(\text { head }_{1}^{u}, \ldots, \text { head }_{h}^{u}\right)
$$
这样就相当于更相似的$h_i^u$融合到了一块,而这个更相似又是从多个角度得到的,于是乎, 作者认为,这样就能学习到用户的多兴趣。
得到这个东西之后接下来再过一个User Attention 因为作者发现,对于相似历史行为的不同用户,其兴趣偏好也不太一样。
所以加入这个用户Attention层想挖掘更细粒度的用户个性化信息。 当然这个就是普通的embedding层了 用户的base向量$e_u$作为query与$\hat{X}^{u}$的每个向量做Attention然后加权求和得最终向量
$$
\begin{aligned}
\alpha_{k} &=\frac{\exp \left(\hat{\boldsymbol{h}}_{k}^{u T} \boldsymbol{e}_{u}\right)}{\sum_{k=1}^{t} \exp \left(\hat{\boldsymbol{h}}_{k}^{u T} \boldsymbol{e}_{u}\right)} \\
\boldsymbol{s}_{t}^{u} &=\sum_{k=1}^{t} \alpha_{k} \hat{\boldsymbol{h}}_{k}^{u}
\end{aligned}
$$
其中$s_{t}^{u} \in \mathbb{R}^{d \times 1}$,这样短期行为兴趣就修成了正果。
### 用户长期行为建模
从长期的视角来看,用户在不同的维度上可能积累了广泛的兴趣,用户可能经常访问一组类似的商店,并反复购买属于同一类别的商品。 所以长期行为$\mathcal{L}^{u}$来自于不同的特征尺度。
$$
\mathcal{L}^{u}=\left\{\mathcal{L}_{f}^{u} \mid f \in \mathcal{F}\right\}
$$
这里面包含了各种side特征。这里就和短期行为那里不太一样了长期行为这里是从特征的维度进行聚合也就是把用户的历史长序列分成了多个特征比如用户历史点击过的商品历史逛过的店铺历史看过的商品的类别品牌等分成了多个特征子集然后这每个特征子集里面有对应的id比如商品有商品id, 店铺有店铺id等对于每个子集过user Attention layer和用户的base向量求Attention 相当于看看用户喜欢逛啥样的商店, 喜欢啥样的品牌,啥样的商品类别等等,得到每个子集最终的表示向量。每个子集的计算过程如下:
$$
\begin{aligned}
\alpha_{k} &=\frac{\exp \left(\boldsymbol{g}_{k}^{u T} \boldsymbol{e}_{u}\right)}{\sum_{k=1}^{\left|\mathcal{L}_{f}^{u}\right|} \exp \left(\boldsymbol{g}_{k}^{u T} \boldsymbol{e}_{u}\right)} \\
z_{f}^{u} &=\sum_{k=1}^{\left|\mathcal{L}_{f}^{u}\right|} \alpha_{k} \boldsymbol{g}_{k}^{u}
\end{aligned}
$$
每个子集都会得到一个加权的向量把这个东西拼起来然后过DNN。
$$
\begin{aligned}
&z^{u}=\operatorname{concat}\left(\left\{z_{f}^{u} \mid f \in \mathcal{F}\right\}\right) \\
&\boldsymbol{p}^{u}=\tanh \left(\boldsymbol{W}^{p} z^{u}+b\right)
\end{aligned}
$$
这里的$\boldsymbol{p}^{u} \in \mathbb{R}^{d \times 1}$ 这样就得到了用户的长期兴趣表示。
### 短长期兴趣融合
长短期兴趣融合这里,作者发现之前模型往往喜欢直接拼接起来,或者加和,注意力加权等,但作者认为这样不能很好的将两类兴趣融合起来,因为长期序列里面,其实只有很少的一部分行为和当前有关。那么这样的话,直接无脑融合是有问题的。所以这里作者用了一种较为巧妙的方式,即门控机制:
$$
G_{t}^{u}=\operatorname{sigmoid}\left(\boldsymbol{W}^{1} \boldsymbol{e}_{u}+\boldsymbol{W}^{2} s_{t}^{u}+\boldsymbol{W}^{3} \boldsymbol{p}^{u}+b\right) \\
o_{t}^{u}=\left(1-G_{t}^{u}\right) \odot p^{u}+G_{t}^{u} \odot s_{t}^{u}
$$
这个和LSTM的这种门控机制很像首先门控接收的输入有用户画像$e_u$,用户短期兴趣$s_t^u$ 用户长期兴趣$p^u$经过sigmoid函数得到了$G_{t}^{u} \in \mathbb{R}^{d \times 1}$,用来决定在$t$时刻短期和长期兴趣的贡献程度。然后根据这个贡献程度对短期和长期偏好加权进行融合。
为啥这东西就有用了呢? 实验中证明了这个东西有用,但这里给出我的理解哈,我们知道最终得到的短期或者长期兴趣都是$d$维的向量, 每一个维度可能代表着不同的兴趣偏好,比如第一维度代表品牌,第二个维度代表类别,第三个维度代表价格,第四个维度代表商店等等,当然假设哈,真实的向量不可解释。
那么如果我们是直接相加或者是加权相加,其实都意味着长短期兴趣这每个维度都有很高的保留, 但其实上,万一长期兴趣和短期兴趣维度冲突了呢? 比如短期兴趣里面可能用户喜欢这个品牌,长期用户里面用户喜欢那个品牌,那么听谁的? 你可能说短期兴趣这个占更大权重呗,那么普通加权可是所有向量都加的相同的权重,品牌这个维度听短期兴趣的,其他维度比如价格,商店也都听短期兴趣的?本身存在不合理性。那么反而直接相加或者加权效果会不好。
而门控机制的巧妙就在于我会给每个维度都学习到一个权重而这个权重非0即1(近似哈) 那么接下来融合的时候,我通过这个门控机制,取长期和短期兴趣向量每个维度上的其中一个。比如在品牌方面听谁的,类别方面听谁的,价格方面听谁的,只会听短期和长期兴趣的其中一个的。这样就不会有冲突发生,而至于具体听谁的,交给网络自己学习。这样就使得用户长期兴趣和短期兴趣融合的时候,每个维度上的信息保留变得**有选择**。使得兴趣的融合方式更加的灵活。
==这其实又给我们提供了一种两个向量融合的一种新思路,并不一定非得加权或者拼接或者相加了,还可以通过门控机制让网络自己学==
## SDM模型的简易复现
下面参考DeepMatch用简易的代码实现下SDM并在新闻推荐的数据集上进行召回任务。
首先下面分析SDM的整体架构从代码层面看运行流程 然后就这里面几个关键的细节进行说明。
### 模型的输入
对于SDM模型由于它是将用户的行为序列分成了会话的形式所以在构造SDM模型输入方面和前面的MIND以及YouTubeDNN有很大的不同了所以这里需要先重点强调下输入。
在为SDM产生数据集的时候 需要传入短期会话的长度以及长期会话的长度, 这样, 对于一个行为序列构造数据集的时候要按照两个长度分成短期行为和长期行为两种并且每一种都需要指明真实的序列长度。另外由于这里用到了文章的side info信息所以我这里在之前列的基础上加入了文章的两个类别特征分别是cat_1和cat_2作为文章的side info。 这个产生数据集的代码如下:
```python
"""构造sdm数据集"""
def get_data_set(click_data, seq_short_len=5, seq_prefer_len=50):
"""
:param: seq_short_len: 短期会话的长度
:param: seq_prefer_len: 会话的最长长度
"""
click_data.sort_values("expo_time", inplace=True)
train_set, test_set = [], []
for user_id, hist_click in tqdm(click_data.groupby('user_id')):
pos_list = hist_click['article_id'].tolist()
cat1_list = hist_click['cat_1'].tolist()
cat2_list = hist_click['cat_2'].tolist()
# 滑动窗口切分数据
for i in range(1, len(pos_list)):
hist = pos_list[:i]
cat1_hist = cat1_list[:i]
cat2_hist = cat2_list[:i]
# 序列长度只够短期的
if i <= seq_short_len and i != len(pos_list) - 1:
train_set.append((
# 用户id, 用户短期历史行为序列, 用户长期历史行为序列, 当前行为文章, label
user_id, hist[::-1], [0]*seq_prefer_len, pos_list[i], 1,
# 用户短期历史序列长度, 用户长期历史序列长度,
len(hist[::-1]), 0,
# 用户短期历史序列对应类别1 用户长期历史行为序列对应类别1
cat1_hist[::-1], [0]*seq_prefer_len,
# 历史短期历史序列对应类别2 用户长期历史行为序列对应类别2
cat2_hist[::-1], [0]*seq_prefer_len
))
# 序列长度够长期的
elif i != len(pos_list) - 1:
train_set.append((
# 用户id, 用户短期历史行为序列,用户长期历史行为序列, 当前行为文章, label
user_id, hist[::-1][:seq_short_len], hist[::-1][seq_short_len:], pos_list[i], 1,
# 用户短期行为序列长度,用户长期行为序列长度,
seq_short_len, len(hist[::-1])-seq_short_len,
# 用户短期历史行为序列对应类别1 用户长期历史行为序列对应类别1
cat1_hist[::-1][:seq_short_len], cat1_hist[::-1][seq_short_len:],
# 用户短期历史行为序列对应类别2 用户长期历史行为序列对应类别2
cat2_hist[::-1][:seq_short_len], cat2_hist[::-1][seq_short_len:]
))
# 测试集保留最长的那一条
elif i <= seq_short_len and i == len(pos_list) - 1:
test_set.append((
user_id, hist[::-1], [0]*seq_prefer_len, pos_list[i], 1,
len(hist[::-1]), 0,
cat1_hist[::-1], [0]*seq_perfer_len,
cat2_hist[::-1], [0]*seq_prefer_len
))
else:
test_set.append((
user_id, hist[::-1][:seq_short_len], hist[::-1][seq_short_len:], pos_list[i], 1,
seq_short_len, len(hist[::-1])-seq_short_len,
cat1_hist[::-1][:seq_short_len], cat1_hist[::-1][seq_short_len:],
cat2_list[::-1][:seq_short_len], cat2_hist[::-1][seq_short_len:]
))
random.shuffle(train_set)
random.shuffle(test_set)
return train_set, test_set
```
思路和之前的是一样的无非就是根据会话的长短把之前的一个长行为序列划分成了短期和长期两个然后加入两个新的side info特征。
### 模型的代码架构
整个SDM模型算是参考deepmatch修改的一个简易版本
```python
def SDM(user_feature_columns, item_feature_columns, history_feature_list, num_sampled=5, units=32, rnn_layers=2,
dropout_rate=0.2, rnn_num_res=1, num_head=4, l2_reg_embedding=1e-6, dnn_activation='tanh', seed=1024):
"""
:param rnn_num_res: rnn的残差层个数
:param history_feature_list: short和long sequence field
"""
# item_feature目前只支持doc_id 再加别的就不行了,其实这里可以改造下
if (len(item_feature_columns)) > 1:
raise ValueError("SDM only support 1 item feature like doc_id")
# 获取item_feature的一些属性
item_feature_column = item_feature_columns[0]
item_feature_name = item_feature_column.name
item_vocabulary_size = item_feature_column.vocabulary_size
# 为用户特征创建Input层
user_input_layer_dict = build_input_layers(user_feature_columns)
item_input_layer_dict = build_input_layers(item_feature_columns)
# 将Input层转化成列表的形式作为model的输入
user_input_layers = list(user_input_layer_dict.values())
item_input_layers = list(item_input_layer_dict.values())
# 筛选出特征中的sparse特征和dense特征方便单独处理
sparse_feature_columns = list(filter(lambda x: isinstance(x, SparseFeat), user_feature_columns)) if user_feature_columns else []
dense_feature_columns = list(filter(lambda x: isinstance(x, DenseFeat), user_feature_columns)) if user_feature_columns else []
if len(dense_feature_columns) != 0:
raise ValueError("SDM dont support dense feature") # 目前不支持Dense feature
varlen_feature_columns = list(filter(lambda x: isinstance(x, VarLenSparseFeat), user_feature_columns)) if user_feature_columns else []
# 构建embedding字典
embedding_layer_dict = build_embedding_layers(user_feature_columns+item_feature_columns)
# 拿到短期会话和长期会话列 之前的命名规则在这里起作用
sparse_varlen_feature_columns = []
prefer_history_columns = []
short_history_columns = []
prefer_fc_names = list(map(lambda x: "prefer_" + x, history_feature_list))
short_fc_names = list(map(lambda x: "short_" + x, history_feature_list))
for fc in varlen_feature_columns:
if fc.name in prefer_fc_names:
prefer_history_columns.append(fc)
elif fc.name in short_fc_names:
short_history_columns.append(fc)
else:
sparse_varlen_feature_columns.append(fc)
# 获取用户的长期行为序列列表 L^u
# [<tf.Tensor 'emb_prefer_doc_id_2/Identity:0' shape=(None, 50, 32) dtype=float32>, <tf.Tensor 'emb_prefer_cat1_2/Identity:0' shape=(None, 50, 32) dtype=float32>, <tf.Tensor 'emb_prefer_cat2_2/Identity:0' shape=(None, 50, 32) dtype=float32>]
prefer_emb_list = embedding_lookup(prefer_fc_names, user_input_layer_dict, embedding_layer_dict)
# 获取用户的短期序列列表 S^u
# [<tf.Tensor 'emb_short_doc_id_2/Identity:0' shape=(None, 5, 32) dtype=float32>, <tf.Tensor 'emb_short_cat1_2/Identity:0' shape=(None, 5, 32) dtype=float32>, <tf.Tensor 'emb_short_cat2_2/Identity:0' shape=(None, 5, 32) dtype=float32>]
short_emb_list = embedding_lookup(short_fc_names, user_input_layer_dict, embedding_layer_dict)
# 用户离散特征的输入层与embedding层拼接 e^u
user_emb_list = embedding_lookup([col.name for col in sparse_feature_columns], user_input_layer_dict, embedding_layer_dict)
user_emb = concat_func(user_emb_list)
user_emb_output = Dense(units, activation=dnn_activation, name='user_emb_output')(user_emb) # (None, 1, 32)
# 长期序列行为编码
# 过AttentionSequencePoolingLayer --> Concat --> DNN
prefer_sess_length = user_input_layer_dict['prefer_sess_length']
prefer_att_outputs = []
# 遍历长期行为序列
for i, prefer_emb in enumerate(prefer_emb_list):
prefer_attention_output = AttentionSequencePoolingLayer(dropout_rate=0)([user_emb_output, prefer_emb, prefer_sess_length])
prefer_att_outputs.append(prefer_attention_output)
prefer_att_concat = concat_func(prefer_att_outputs) # (None, 1, 64) <== Concat(item_embeddingcat1_embedding,cat2_embedding)
prefer_output = Dense(units, activation=dnn_activation, name='prefer_output')(prefer_att_concat)
# print(prefer_output.shape) # (None, 1, 32)
# 短期行为序列编码
short_sess_length = user_input_layer_dict['short_sess_length']
short_emb_concat = concat_func(short_emb_list) # (None, 5, 64) 这里注意下, 对于短期序列描述item的side info信息进行了拼接
short_emb_input = Dense(units, activation=dnn_activation, name='short_emb_input')(short_emb_concat) # (None, 5, 32)
# 过rnn 这里的return_sequence=True 每个时间步都需要输出h
short_rnn_output = DynamicMultiRNN(num_units=units, return_sequence=True, num_layers=rnn_layers,
num_residual_layers=rnn_num_res, # 这里竟然能用到残差
dropout_rate=dropout_rate)([short_emb_input, short_sess_length])
# print(short_rnn_output) # (None, 5, 32)
# 过MultiHeadAttention # (None, 5, 32)
short_att_output = MultiHeadAttention(num_units=units, head_num=num_head, dropout_rate=dropout_rate)([short_rnn_output, short_sess_length]) # (None, 5, 64)
# user_attention # (None, 1, 32)
short_output = UserAttention(num_units=units, activation=dnn_activation, use_res=True, dropout_rate=dropout_rate)([user_emb_output, short_att_output, short_sess_length])
# 门控融合
gated_input = concat_func([prefer_output, short_output, user_emb_output])
gate = Dense(units, activation='sigmoid')(gated_input) # (None, 1, 32)
# temp = tf.multiply(gate, short_output) + tf.multiply(1-gate, prefer_output) 感觉这俩一样?
gated_output = Lambda(lambda x: tf.multiply(x[0], x[1]) + tf.multiply(1-x[0], x[2]))([gate, short_output, prefer_output]) # [None, 1,32]
gated_output_reshape = Lambda(lambda x: tf.squeeze(x, 1))(gated_output) # (None, 32) 这个维度必须要和docembedding层的维度一样否则后面没法sortmax_loss
# 接下来
item_embedding_matrix = embedding_layer_dict[item_feature_name] # 获取doc_id的embedding层
item_index = EmbeddingIndex(list(range(item_vocabulary_size)))(item_input_layer_dict[item_feature_name]) # 所有doc_id的索引
item_embedding_weight = NoMask()(item_embedding_matrix(item_index)) # 拿到所有item的embedding
pooling_item_embedding_weight = PoolingLayer()([item_embedding_weight]) # 这里依然是当可能不止item_id或许还有brand_id, cat_id等需要池化
# 这里传入的是整个doc_id的embedding user_embedding, 以及用户点击的doc_id然后去进行负采样计算损失操作
output = SampledSoftmaxLayer(num_sampled)([pooling_item_embedding_weight, gated_output_reshape, item_input_layer_dict[item_feature_name]])
model = Model(inputs=user_input_layers+item_input_layers, outputs=output)
# 下面是等模型训练完了之后获取用户和item的embedding
model.__setattr__("user_input", user_input_layers)
model.__setattr__("user_embedding", gated_output_reshape) # 用户embedding是取得门控融合的用户向量
model.__setattr__("item_input", item_input_layers)
# item_embedding取得pooling_item_embedding_weight, 这个会发现是负采样操作训练的那个embedding矩阵
model.__setattr__("item_embedding", get_item_embedding(pooling_item_embedding_weight, item_input_layer_dict[item_feature_name]))
return model
```
函数式API搭建模型的方式首先我们需要传入封装好的用户特征描述以及item特征描述比如
```python
# 建立模型
user_feature_columns = [
SparseFeat('user_id', feature_max_idx['user_id'], 16),
SparseFeat('gender', feature_max_idx['gender'], 16),
SparseFeat('age', feature_max_idx['age'], 16),
SparseFeat('city', feature_max_idx['city'], 16),
VarLenSparseFeat(SparseFeat('short_doc_id', feature_max_idx['article_id'], embedding_dim, embedding_name="doc_id"), SEQ_LEN_short, 'mean', 'short_sess_length'),
VarLenSparseFeat(SparseFeat('prefer_doc_id', feature_max_idx['article_id'], embedding_dim, embedding_name='doc_id'), SEQ_LEN_prefer, 'mean', 'prefer_sess_length'),
VarLenSparseFeat(SparseFeat('short_cat1', feature_max_idx['cat_1'], embedding_dim, embedding_name='cat_1'), SEQ_LEN_short, 'mean', 'short_sess_length'),
VarLenSparseFeat(SparseFeat('prefer_cat1', feature_max_idx['cat_1'], embedding_dim, embedding_name='cat_1'), SEQ_LEN_prefer, 'mean', 'prefer_sess_length'),
VarLenSparseFeat(SparseFeat('short_cat2', feature_max_idx['cat_2'], embedding_dim, embedding_name='cat_2'), SEQ_LEN_short, 'mean', 'short_sess_length'),
VarLenSparseFeat(SparseFeat('prefer_cat2', feature_max_idx['cat_2'], embedding_dim, embedding_name='cat_2'), SEQ_LEN_prefer, 'mean', 'prefer_sess_length'),
]
item_feature_columns = [SparseFeat('doc_id', feature_max_idx['article_id'], embedding_dim)]
```
这里需要注意的一个点是短期和长期序列的名字,必须严格的`short_ prefer_`进行标识,因为在模型搭建的时候就是靠着这个去找到短期和长期序列特征的。
逻辑其实也比较清晰首先是建立Input层然后是embedding层 接下来根据命名选择出用户的base特征列 短期行为序列和长期行为序列。长期序列的话是过`AttentionPoolingLayer`层进行编码这里本质上注意力然后融合但这里注意的一个点就是for循环也就是长期序列行为里面的特征列比如商品cat_1, cat_2是for循环的形式求融合向量再拼接起来过DNN和论文图保持一致。
短期序列编码部分,是`item_embedding,cat_1embedding cat_2embedding`拼接起来,过`DynamicMultiRNN`层学习序列信息, 过`MultiHeadAttention`学习多兴趣,最后过`UserAttentionLayer`进行向量融合。 接下来长期兴趣向量和短期兴趣向量以及用户base向量过门控融合机制得到最终的`user_embedding`
而后面的那块是为了模型训练完之后拿用户embedding和item embedding用的 这个在MIND那篇文章里作了解释。
## 总结
今天整理的是SDM这也是一个标准的序列推荐召回模型主要还是研究用户的序列不过这篇paper里面一个有意思的点就是把用户的行为训练以会话的形式进行切分然后再根据时间分成了短期会话和长期会话然后分别采用不同的策略去学习用户的短期兴趣和长期兴趣。
* 对于短期会话可能和当前预测相关性较大所以首先用RNN来学习序列信息然后采用多头注意力机制得到用户的多兴趣 隐隐约约感觉多头注意力机制还真有种能聚类的功效接下来就是和用户的base向量进行注意力融合得到短期兴趣
* 长期会话序列中每个side info信息进行分开然后分别进行注意力编码融合得到
为了使得长期会话中对当前预测有用的部分得以体现,在融合短期兴趣和长期兴趣的时候,采用了门控的方式,而不是普通的拼接或者加和等操作,使得兴趣保留信息变得**有选择**。
这其实就是这篇paper的故事了借鉴的地方首先是多头注意力机制也能学习到用户的多兴趣 这样对于多兴趣,就有了胶囊网络与多头注意力机制两种思路。 而对于两个向量融合,这里又给我们提供了一种门控融合机制。
**参考**
* SDM原论文
* [AI上推荐 之 SDM模型(建模用户长短期兴趣的Match模型)](https://blog.csdn.net/wuzhongqiang/article/details/123856954?spm=1001.2014.3001.5501)
* [一文读懂Attention机制](https://zhuanlan.zhihu.com/p/129316415)
* [【推荐系统经典论文(十)】阿里SDM模型](https://zhuanlan.zhihu.com/p/137775247?from_voters_page=true)
* [SDM-深度序列召回模型](https://zhuanlan.zhihu.com/p/395673080)
* [推荐广告中的序列建模](https://blog.csdn.net/qq_41010971/article/details/123762312?spm=1001.2014.3001.5501)

View File

@@ -0,0 +1,65 @@
# 背景和目的
召回早前经历的第一代协同过滤技术让模型可以在数量级巨大的item集中找到用户潜在想要看到的商品。这种方式有很明显的缺点一个是对于用户而言只能通过他历史行为去构建候选集并且会基于算力的局限做截断。所以推荐结果的多样性和新颖性比较局限导致推荐的有可能都是用户看过的或者买过的商品。之后在Facebook开源了FASSI库之后基于内积模型的向量检索方案得到了广泛应用也就是第二代召回技术。这种技术通过将用户和物品用向量表示然后用内积的大小度量兴趣借助向量索引实现大规模的全量检索。这里虽然改善了第一代的无法全局检索的缺点然而这种模式下存在索引构建和模型优化目标不一致的问题索引优化是基于向量的近似误差而召回问题的目标是最大化topK召回率。且这类方法也不方便在用户和物品之间做特征组合。
所以阿里开发了一种可以承载各种深度模型来检索用户潜在兴趣的推荐算法解决方案。这个TDM模型是基于树结构利用树结构对全量商品进行检索将复杂度由O(N)下降到O(logN)。
# 模型结构
**树结构**
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220420213149324.png" alt="image-20210308142624189" style="zoom: 15%;" />
</div>
如上图树中的每一个叶子节点对应一个商品item非叶子结点表示的是item的集合**(这里的树不限于二叉树)**。这种层次化结构体现了粒度从粗到细的item架构。
**整体结构**
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220420200433442.png" alt="image-20210308142624189" style="zoom: 70%;" />
</div>
# 算法详解
1. 基于树的高效检索
算法通常采用beam-search的方法根据用户对每层节点挑选出topK将挑选出来的这几个topK节点的子节点作为下一层的候选集最终会落到叶子节点上。
这么做的理论依据是当前层的最有优topK节点的父亲必然属于上次的父辈节点的最优topK
$$
p^{(j)}(n|u) = {{max \atop{n_{c}\in{\{n's children nodes in level j+1\}}}}p^{(j+1)}(n_{c}|u) \over {\alpha^{j}}}
$$
其中$p^{(j)}(n|u)$表示用户u对j层节点n感兴趣的概率$\alpha^{j}$表示归一化因子。
2. 对兴趣进行建模
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220420214040264.png" alt="image-20210308142624189" style="zoom: 50%;" />
</div>
如上图用户对叶子层item6感兴趣可以认为它的兴趣是1同层别的候选节点的兴趣为0顺着着绿色线路上去的节点都标记为1路线上的同层别的候选节点都标记为0。这样的操作就可以根据1和0构建用于每一层的正负样本。
样本构建完成后可以在模型结构左侧采用任意的深度学习模型来承担用户兴趣判别器的角色输入就是当前层构造的正负样本输出则是用户节点对的兴趣度这个将被用作检索过程中选取topK的评判指标。**在整体结构图中我们可以看到节点特征方面使用的是node embedding**,说明在进入模型前已经向量化了。
3. 训练过程
<div align=center>
<img src="http://ryluo.oss-cn-chengdu.aliyuncs.com/图片image-20220420220831318.png" alt="image-20210308142624189" style="zoom: 15%;" />
</div>
整体联合训练的方式如下:
1. 构造随机二叉树
2. 基于树模型生成样本
3. 训练DNN模型直到收敛
4. 基于DNN模型得到样本的Embedding重新构造聚类二叉树
5. 循环上述24过程
具体的在初始化树结构的时候首先借助商品的类别信息进行排序将相同类别的商品放到一起然后递归的将同类别中的商品等量的分到两个子类中直到集合中只包含一项利用这种自顶向下的方式来初始化一棵树。基于该树采样生成深度模型训练所需的样本然后进一步训练模型训练结束之后可以得到每个树节点对应的Embedding向量利用节点的Embedding向量采用K-Means聚类方法来重新构建一颗树最后基于这颗新生成的树重新训练深层网络。
**参考资料**
- [阿里妈妈深度树检索技术(TDM) 及应用框架的探索实践](https://mp.weixin.qq.com/s/sw16_sUsyYuzpqqy39RsdQ)
- [阿里TDMTree-based Deep Model](https://zhuanlan.zhihu.com/p/78941783)
- [阿里妈妈TDM模型详解](https://zhuanlan.zhihu.com/p/93201318)
- [Paddle TDM 模型实现](https://github.com/PaddlePaddle/PaddleRec/blob/master/models/treebased/README.md)