参考

召回(一)协同过滤

  • 核心思想基于一个朴素的假设:相似的用户会喜欢相似的物品。

1、基于物品的协同过滤

  • ItemCF的思路建立在一个简单的假设上:用户的兴趣具有一定的连贯性,喜欢某个物品的用户往往也会对相似的物品感兴趣。它关注的是“和你喜欢的物品相似的还有什么”这一问题。

1.1 ItemCF 算法

流程

  • 实现流程主要包含以下两个步骤:
  1. 第一步:物品相似度计算

wij=C[i][j]N(i)N(j)这里N(i)表示与物品i有交互的用户总数,C[i][j]是两个物品的共现次数。w_{ij} = \frac{C[i][j]}{\sqrt{|N(i)| \cdot N(j)}} \\ 这里|N(i)|表示与物品i有交互的用户总数,C[i][j]是两个物品的共现次数。

  1. 第二步:候选物品推荐
    首先,系统会选取用户最近交互的物品作为兴趣种子(通常是几百个),然后为每个种子物品找到最相似的若干个候选物品(如Top-10),这样可以快速生成大量候选集合。接下来计算用户u对候选物品i的兴趣分数:

p(u,i)=jN(u)wijruj其中N(u)是用户u交互过的物品集合,wij是物品ij的相似度,ruj表示用户u对物品j的兴趣强度(可以是简单的1,也可以根据交互时间、类型等设置不同权重)。p(u,i) = \sum_{j \in N(u)} w_{ij} \cdot r_{uj} \\ 其中N(u)是用户u交互过的物品集合,w_{ij}是物品i和j的相似度,r_{uj}表示用户u对物品j的兴趣强度(可以是简单的1,也可以根据交互时间、类型等设置不同权重)。

最终,系统对所有候选物品按兴趣分数排序,选择Top-N物品作为ItemCF通道的推荐结果。这种“种子扩展”的方式既保证了推荐质量,又大大提高了计算效率。

扩展

  • 处理评分数据的相似度计算
  • 皮尔逊相关系数:当有评分数据时,皮尔逊相关系数能更好地捕获物品间的相似性模式。

wij=uUij(ruiri)(rujrj)uUij(ruiri)2uUij(rujrj)2其中Uij表示同时评价过物品ij的用户集合,rui是用户u对物品i的评分,ri是物品i的平均评分。w_{ij} = \frac{ \sum_{u \in U_{ij}} (r_{ui} - \overline{r}_i) (r_{uj} - \overline{r}_j) }{ \sqrt{\sum_{u \in U_{ij}} (r_{ui} - \overline{r}_i)^2 } \sqrt{\sum_{u \in U_{ij}} (r_{uj} - \overline{r}_j)^2 } } \\ 其中U_{ij}表示同时评价过物品i和j的用户集合,r_{ui}是用户u对物品i的评分,\overline{r}_i是物品i的平均评分。

  • 核心优势:皮尔逊相关系数通过中心化处理,能够:
    • 消除不同物品评分分布的差异(有些物品普遍评分高,有些偏低)
    • 关注用户评分的相对趋势而非绝对数值
    • 更好地识别“用户对两个物品评分模式一致”的相似性
  • 评分预测:基于皮尔逊相似度,可以预测用户对未接触物品的评分:

r^uj=rj+kSjwjk(rukrk)kSjwjk\hat{r}_{uj} = \overline{r}_j + \frac{ \sum_{k \in S_j} w_{jk} (r_{uk} - \overline{r}_k) }{ \sum_{k \in S_j} w_{jk} }

这个公式以物品j的平均评分为基准,根据用户对相似物品的评分偏好进行调整。

  • 实际应用考虑:虽然皮尔逊相关系数在理论上更适合评分数据,但在现代大规模推荐系统中,由于计算复杂度和数据稀疏性问题,多数系统仍倾向于使用简化的余弦相似度,并通过其他方式(如加权、归一化)来处理评分差异。

代码

item_cf.py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
"""
基于物品的协同过滤推荐算法。
"""

import numpy as np
from collections import defaultdict
import pandas as pd
from tqdm import tqdm
from operator import itemgetter


class ItemCF:
"""
优化的基于物品的协同过滤算法,用于CTR预测。
使用基于用户的倒排索引进行更快的计算和直接推荐生成。
"""

def __init__(self, k_neighbors=20, min_similarity=0.1):
"""
初始化ItemCF模型。

参数:
-----------
k_neighbors : int, 默认=20
用于推荐的邻居数量。
min_similarity : float, 默认=0.1
将物品视为邻居的最小相似度阈值。
"""
self.k_neighbors = k_neighbors
self.min_similarity = min_similarity

# 用于优化的数据结构
self.user_items = defaultdict(dict) # 用户 -> 物品评分字典 (CTR中为1.0)
self.item_users = defaultdict(set) # 物品 -> 交互用户集合
self.item_similarity = defaultdict(dict) # 物品 -> {邻居: 相似度}
self.user_map = {} # 原始用户ID -> 内部ID
self.item_map = {} # 原始物品ID -> 内部ID
self.reverse_user_map = {} # 内部ID -> 原始用户ID
self.reverse_item_map = {} # 内部ID -> 原始物品ID

def fit(self, user_item_interactions):
"""
使用用户-物品交互数据训练模型(带有0/1标签的CTR数据)。

参数:
-----------
user_item_interactions : 元组列表 (user_id, item_id, label) 或 DataFrame
训练数据,其中label为0(未点击)或1(点击)。
只有label=1的交互被视为正向交互。

返回:
--------
self : ItemCF实例
"""
# 如果需要,转换为DataFrame
if not isinstance(user_item_interactions, pd.DataFrame):
user_item_interactions = pd.DataFrame(
user_item_interactions, columns=["user_id", "item_id", "label"]
)

# 只过滤正向交互(点击)
positive_interactions = user_item_interactions[
user_item_interactions["label"] == 1
]

# 创建映射
unique_users = positive_interactions["user_id"].unique()
unique_items = positive_interactions["item_id"].unique()

self.user_map = {user: idx for idx, user in enumerate(unique_users)}
self.item_map = {item: idx for idx, item in enumerate(unique_items)}
self.reverse_user_map = {idx: user for user, idx in self.user_map.items()}
self.reverse_item_map = {idx: item for item, idx in self.item_map.items()}

# 构建数据结构: 用户 -> {物品: 评分}, 物品 -> {用户}
for _, row in positive_interactions.iterrows():
user_id = self.user_map[row["user_id"]]
item_id = self.item_map[row["item_id"]]

self.user_items[user_id][item_id] = 1.0 # CTR: 所有正向交互的评分为1.0
self.item_users[item_id].add(user_id)

# 计算物品相似度
self._calculate_item_similarity()

return self

def _calculate_item_similarity(self):
"""
使用基于用户的倒排索引优化计算物品相似度。
以更高效的格式存储相似度用于推荐。
"""
# 步骤1: 构建共现矩阵 C[i][j] = 共同用户数量
C = defaultdict(lambda: defaultdict(int))

# 对于每个用户,找到他们都交互过的所有物品对
for user_id, items in self.user_items.items():
items_list = list(items.keys())
# 对于该用户交互过的所有物品对
for i in range(len(items_list)):
for j in range(i + 1, len(items_list)):
i1, i2 = items_list[i], items_list[j]
C[i1][i2] += 1
C[i2][i1] += 1 # 对称

# 步骤2: 计算最终相似度分数并高效存储
for i1 in C:
for i2 in C[i1]:
if i1 >= i2: # 避免重复计算
continue

# 计算余弦相似度
similarity = C[i1][i2] / np.sqrt(
len(self.item_users[i1]) * len(self.item_users[i2])
)

if similarity >= self.min_similarity:
self.item_similarity[i1][i2] = similarity
self.item_similarity[i2][i1] = similarity

def recommend(self, user_id, n_recommendations=10, exclude_interacted=True):
"""
使用直接计算的优化推荐,无需predict_score。
基于物品的协同过滤:推荐与用户已交互物品相似的物品。

参数:
-----------
user_id : 原始用户ID
n_recommendations : int, 推荐数量
exclude_interacted : bool, 是否排除已交互的物品

返回:
--------
元组列表: [(item_id, score), ...]
"""
if user_id not in self.user_map:
return []

user_idx = self.user_map[user_id]

# 获取用户已交互的物品
user_interacted_items = self.user_items[user_idx]
interacted_items = (
set(user_interacted_items.keys()) if exclude_interacted else set()
)

if not user_interacted_items:
return [] # 用户没有交互记录

# 初始化字典
rank = defaultdict(float)

# 对于用户交互过的每个物品j(评分ruj)
for j, ruj in user_interacted_items.items():
# 获取与j最相似的前K个物品,按相似度降序排列
similar_items = sorted(
self.item_similarity[j].items(), key=itemgetter(1), reverse=True
)[: self.k_neighbors]

# 对于每个相似物品i及其相似度wji
for i, wji in similar_items:
# 跳过用户已交互的物品
if i in interacted_items:
continue

# 累积加权分数
# 由于CTR中所有正向交互的ruj = 1.0
rank[i] += wji * ruj

# 转换为(score, item_id)列表并按分数降序排列
recommendations = [
(score, self.reverse_item_map[item_idx]) for item_idx, score in rank.items()
]
recommendations.sort(reverse=True)

# 返回前N个推荐结果,格式为(item_id, score)
return [
(item_id, score) for score, item_id in recommendations[:n_recommendations]
]


def build_item_cf_model(feature_columns, model_config):
"""
使用标准化接口构建ItemCF模型。

参数:
feature_columns: ItemCF不使用(经典算法不需要特征列)
model_config: 模型配置字典,包含:
- k_neighbors: 邻居数量(默认: 20)
- min_similarity: 最小相似度阈值(默认: 0.1)

返回:
ItemCF模型实例
"""
k_neighbors = model_config.get("k_neighbors", 20)
min_similarity = model_config.get("min_similarity", 0.1)

return ItemCF(k_neighbors=k_neighbors, min_similarity=min_similarity)

1.2 Swing 算法

算法原理

  • 当你在电商平台购买了一件T恤后,系统会向你推荐什么?如果是其他款式的T恤,这属于替代性推荐;如果是配套的裤子或配饰,这就是互补性推荐。传统ItemCF在处理这类细粒度的商品关系时往往力不从心——它将所有用户-物品交互一视同仁,无论是用户深思熟虑的购买,还是偶然的误点击、好奇心驱动的浏览,都被赋予相同的权重。这种做法不仅难以区分真正的兴趣关联和随机噪声,更无法有效捕捉物品间的替代性与互补性关系。
  • 其核心洞察是:如果多个用户在其他共同购买行为较少的情况下,同时购买了同一对物品,那么这对物品之间的关联性就更可信。
  • Swing算法的实现可以分为两个主要步骤:先构建用户-物品二部图,再通过分析图中用户对的共同交互模式(即swing结构)来计算物品相似度。
  1. 第一步:用户-物品二部图构建。
  2. 第二步:物品相似度计算。
    构建好二部图后,我们来计算任意一对物品i与j的相似度。设 uiu_iuju_j 分别表示与物品 i 和 j 有过交互的用户集合,IuI_u 表示用户 u 交互过的所有物品集合。

具体计算过程如下:首先找到同时与物品 i 和 j 相连的用户集合 UiUjU_i \cap U_j;然后对集合中的每一对用户 (u,v),统计他们的其他共同购买行为。如果用户 u 与 v 的其他交互行为重合较少(即 IuIvI_u \cap I_v 较小),则认为他们在共同选择物品 i 和 j 上的行为更具特异性,应该为这对物品贡献更高的相似度得分。

Swing score 的基础计算公式为:

s(i,j)=uUiUjvUiUj1α+IuIv其中,α是平滑系数,用来防止分母过小导致数值不稳定。s(i,j) = \sum_{u \in U_i \cap U_j} \sum_{v \in U_i \cap U_j} \frac{1}{ \alpha + |I_u \cap I_v| } \\ 其中,\alpha 是平滑系数,用来防止分母过小导致数值不稳定。

用户权重调整:为了降低活跃用户对计算结果的过度影响,我们引入用户权重wu=1Iuw_u = \frac{1}{\sqrt{|I_u|} }。最终的物品相似度计算公式为:

s(i,j)=uUiUjvUiUjwuwv1α+IuIvs(i,j) = \sum_{u \in U_i \cap U_j} \sum_{v \in U_i \cap U_j} w_u \cdot w_v \cdot \frac{1}{ \alpha + |I_u \cap I_v| }

这个公式能够精准刻画物品之间的关联强度,为推荐系统提供更可靠的相似度度量。

代码

swing.py 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
"""
基于Swing评分的协同过滤推荐算法。
"""

import os
import logging

logger = logging.getLogger(__name__)
import numpy as np
import pandas as pd
from collections import defaultdict
from tqdm import tqdm
from operator import itemgetter


class Swing:
"""
基于Swing评分的协同过滤推荐系统,用于CTR预测。

Swing评分通过分析用户-物品二分图子结构并惩罚高活跃用户之间的
共同行为,来捕获更稳健的物品-物品关系。

针对二元标签模式(0/1 CTR数据)进行修改 - 仅使用正向交互(label=1)。
优化实现,仅计算有共同用户的物品对的相似度,避免O(|I|^2)复杂度。
"""

def __init__(self, alpha=1.0, k_neighbors=20, min_similarity=0):
"""
初始化Swing模型。

参数:
-----------
alpha : float, 默认=1.0
平滑参数,用于防止用户交互交集较小时的数值不稳定。
k_neighbors : int, 默认=20
进行推荐时考虑的邻居数量。
min_similarity : float, 默认=0
将物品视为邻居的最小相似度阈值。
"""
self.alpha = alpha
self.k_neighbors = k_neighbors
self.min_similarity = min_similarity
self.swing_similarity = {} # 使用字典进行稀疏存储
self.user_map = None
self.item_map = None
self.reverse_user_map = None
self.reverse_item_map = None
self.user_weights = None
self.user_items = {} # user_id -> 物品id集合(仅正向交互)
self.item_users = {} # item_id -> 用户id集合(仅正向交互)

def fit(self, user_item_interactions):
"""
使用用户-物品交互数据(带有0/1标签的CTR数据)训练模型。

参数:
-----------
user_item_interactions : 元组列表 (user_id, item_id, label) 或 DataFrame
训练数据,其中label为0(未点击)或1(点击)。
仅将label=1的交互视为正向交互。

返回:
--------
self : Swing实例
拟合后的模型。
"""
# 如果需要,转换为DataFrame
if not isinstance(user_item_interactions, pd.DataFrame):
user_item_interactions = pd.DataFrame(
user_item_interactions, columns=["user_id", "item_id", "label"]
)

# 仅过滤正向交互(点击)
positive_interactions = user_item_interactions[
user_item_interactions["label"] == 1
]

# 创建用户和物品映射
unique_users = positive_interactions["user_id"].unique()
unique_items = positive_interactions["item_id"].unique()

self.user_map = {user: idx for idx, user in enumerate(unique_users)}
self.item_map = {item: idx for idx, item in enumerate(unique_items)}
self.reverse_user_map = {idx: user for user, idx in self.user_map.items()}
self.reverse_item_map = {idx: item for item, idx in self.item_map.items()}

# 构建倒排索引:用户->物品 和 物品->用户(仅正向交互)
self._build_inverted_index(positive_interactions)

# 计算用户权重
self._calculate_user_weights()

# 计算Swing相似度矩阵(优化版)
self._calculate_swing_similarity_optimized()

return self

def _build_inverted_index(self, positive_interactions):
"""构建倒排索引以进行高效的相似度计算。"""
self.user_items = defaultdict(set)
self.item_users = defaultdict(set)

for _, row in positive_interactions.iterrows():
user_idx = self.user_map[row["user_id"]]
item_idx = self.item_map[row["item_id"]]

self.user_items[user_idx].add(item_idx)
self.item_users[item_idx].add(user_idx)

def _calculate_user_weights(self):
"""计算用户权重以减少高活跃用户的影响。"""
# 获取每个用户交互的物品数量
user_item_counts = np.array(
[len(self.user_items[u]) for u in range(len(self.user_map))]
)

# 计算用户权重为 1/sqrt(|I_u|)
self.user_weights = 1.0 / np.sqrt(user_item_counts)

def _calculate_swing_similarity_optimized(self):
"""
使用优化方法计算Swing相似度。
仅计算有共同用户的物品对的相似度。
时间复杂度:O(R * avg_user_items),其中R是评分数量。
"""
self.swing_similarity = defaultdict(dict)

# 对于每个用户,计算其交互的所有物品对之间的相似度
for user_idx in tqdm(
range(len(self.user_map)),
total=len(self.user_map),
desc="查找每个用户的物品对",
disable=not logger.isEnabledFor(logging.DEBUG),
):
user_items = list(self.user_items[user_idx])

# 对于该用户交互的每对物品
for i in range(len(user_items)):
for j in range(i + 1, len(user_items)):
item_i, item_j = user_items[i], user_items[j]

# 如果不存在则初始化相似度
if item_j not in self.swing_similarity[item_i]:
self.swing_similarity[item_i][item_j] = 0.0
if item_i not in self.swing_similarity[item_j]:
self.swing_similarity[item_j][item_i] = 0.0

# 现在为有共同用户的物品对计算实际的Swing评分
for item_i in tqdm(
self.swing_similarity,
total=len(self.swing_similarity),
desc="计算Swing评分",
disable=not logger.isEnabledFor(logging.DEBUG),
):
for item_j in self.swing_similarity[item_i]:
if item_i >= item_j: # 避免重复计算
continue

# 找到物品i和j的共同用户
common_users = self.item_users[item_i].intersection(
self.item_users[item_j]
)

if len(common_users) < 2:
continue # Swing计算至少需要2个用户

swing_score = 0.0

# 为所有共同用户对计算Swing评分
common_users_list = list(common_users)
for u_idx in range(len(common_users_list)):
for v_idx in range(len(common_users_list)):
if u_idx == v_idx:
continue

u, v = common_users_list[u_idx], common_users_list[v_idx]

# 找到用户u和v的物品交集
common_items_uv = self.user_items[u].intersection(
self.user_items[v]
)

# 使用用户权重计算贡献
user_weight_u = self.user_weights[u]
user_weight_v = self.user_weights[v]

contribution = (user_weight_u * user_weight_v) / (
self.alpha + len(common_items_uv)
)
swing_score += contribution

# 存储相似度(对称)
if swing_score >= self.min_similarity:
self.swing_similarity[item_i][item_j] = swing_score
self.swing_similarity[item_j][item_i] = swing_score

def recommend(self, user_id, n_recommendations=10, exclude_interacted=True):
"""
使用直接计算的优化推荐。
基于物品的协同过滤:推荐与用户已交互物品相似的物品。

参数:
-----------
user_id : 原始用户ID
n_recommendations : int, 推荐数量
exclude_interacted : bool, 是否排除已交互的物品

返回:
--------
元组列表: [(item_id, score), ...]
"""
if user_id not in self.user_map:
return []

user_idx = self.user_map[user_id]

# 获取用户已交互的物品
user_interacted_items = self.user_items[user_idx]
interacted_items = set(user_interacted_items) if exclude_interacted else set()

if not user_interacted_items:
return [] # 用户没有交互

# 初始化字典
rank = defaultdict(float)

# 对于用户已交互的每个物品j
for j in user_interacted_items:
# 获取与j最相似的前K个物品,按相似度降序排列
similar_items = sorted(
self.swing_similarity[j].items(), key=itemgetter(1), reverse=True
)[: self.k_neighbors]

# 对于每个相似物品i及其相似度wji
for i, wji in similar_items:
# 跳过用户已交互的物品
if i in interacted_items:
continue

# 累积加权评分
# 由于CTR中所有正向交互的权重都是1.0
rank[i] += wji

# 转换为(score, item_id)列表并按评分降序排列
recommendations = [
(score, self.reverse_item_map[item_idx]) for item_idx, score in rank.items()
]
recommendations.sort(reverse=True)

# 返回前N个推荐作为(item_id, score)
return [
(item_id, score) for score, item_id in recommendations[:n_recommendations]
]


def build_swing_model(feature_columns, model_config):
"""
使用标准化接口构建Swing模型。

参数:
feature_columns: Swing不使用(经典算法不需要特征列)
model_config: 模型配置字典,包含:
- alpha: 平滑参数(默认:1.0)
- k_neighbors: 邻居数量(默认:20)
- min_similarity: 最小相似度阈值(默认:0.0)

返回:
Swing模型实例
"""
alpha = model_config.get("alpha", 1.0)
k_neighbors = model_config.get("k_neighbors", 20)
min_similarity = model_config.get("min_similarity", 0.0)

return Swing(alpha=alpha, k_neighbors=k_neighbors, min_similarity=min_similarity)

1.3 Surprise算法

算法原理

  • Surprise算法的设计基于一个重要观察:真正的互补关系主要发生在不同商品类别之间,且购买时间间隔越短,互补性越强。同时,为了解决用户共购数据稀疏的问题,该算法通过商品聚类将稀疏的商品级别共现信号聚合到聚类级别,从而获得更可靠的互补关系证据。
  • Surprise算法从类别、商品和聚类三个层面来衡量互补相关性:
  1. 类别层面
    在这一层面,我们利用商品与类别之间的映射关系构建 user-category 矩阵。基于这个矩阵,可以计算类别i 与类别j 之间的相关性:

θi,j=p(ci,jcj)=N(ci,j)N(cj)其中,N(ci,j)表示先购买类别i后又购买类别j这一事件发生的次数,N(cj)表示购买类别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 的次数。

考虑到不同类别的数量差异,我们采用最大相对落点作为划分阈值来筛选相关类别。
最大相对落点示意图
例如,图(a)中T恤类选取了前八个相关类别,而图(b)中手机类选择了前三个相关类别。

  1. 商品层面
    针对类别相关的商品对,Surprise算法重点考虑两个因素:购买顺序的重要性(如先买手机后买充电宝比反之更合理)以及时间间隔的影响(间隔越短,互补性越强)。商品层面的互补相关性计算公式为:

s1(i,j)=uUiUj11+tuitujUi×Uj其中,tuituj分别表示用户u购买物品ij的时间,且只有当j属于i的相关类别且购买时间晚于i时才计入计算。s_1(i,j) = \frac{ \sum_{u \in U_i \cap U_j} \frac{1}{1+|t_{ui} - t_{uj}|} }{ \| U_i \| \times \| U_j \| } \\ 其中,t_{ui} 和 t_{uj} 分别表示用户 u 购买物品 i 和 j 的时间,且只有当 j 属于 i 的相关类别且购买时间晚于 i 时才计入计算。

  1. 聚类层面
    考虑到实际业务中商品数量可能达到数十亿的规模,传统聚类算法难以胜任。Surprise算法采用标签传播算法在大规模Item-Item图上进行聚类,这里的邻居关系通过Swing算法计算,边权重即为Swing分数。

这种方法不仅高效(通常15分钟内可对数十亿商品完成聚类),还能显著缓解数据稀疏问题。设 L(i) 表示商品 i 的聚类标签,则聚类层面的相似度可以表示为:

s2(i,j)=s1(L(i),L(j))s_2(i,j) = s_1( L(i) , L(j) )

  1. 线性组合
    最终,Surprise算法采用线性组合的方式融合不同层面的信息:

s(i,j)=ωs1(i,j)+(1ω)s2(i,j)其中,ω是权重超参数,s2(i,j)代表聚类层面的互补相关性。s(i,j) = \omega \cdot s_1(i,j) + (1-\omega) s_2(i,j) \\ 其中,\omega 是权重超参数,s_2(i,j) 代表聚类层面的互补相关性。

通过这种多层次的综合分析,Surprise算法成功拓展了Swing Score的应用范围,为互补商品推荐提供了一套系统而高效的解决方案。该方法不仅利用了类别信息和标签传播技术有效解决了数据稀疏问题,还能够准确捕捉商品间的时序和方向性关系,从而提升推荐系统的整体效果。

2、基于用户的协同过滤

  • 基于用户的协同过滤(UserCF)正是基于这样一个朴素而有效的想法:具有相似历史行为的用户,未来偏好也相似。

2.1 UserCF 算法

算法原理

  • 它的工作原理很直观:先找到与目标用户购买偏好相似的“邻居用户”,然后基于这些邻居对商品的选择来预测目标用户的偏好。
  • UserCF和前面介绍的ItemCF构成了协同过滤的两大基本方法,它们都属于基于邻域(Neighborhood-based)的协同过滤,核心思想是通过计算相似度来寻找邻居。不同之处在于视角的转换:ItemCF从物品出发,关注“与用户喜欢的物品相似的还有什么”;UserCF从用户出发,关注“与目标用户相似的人还喜欢什么”。虽然ItemCF在工业应用中更常见,但UserCF作为协同过滤的开山之作,其思想对后续方法有着深远影响,特别是其隐含的“用户可以用向量表示”的理念,为矩阵分解等方法奠定了基础。
  • 实现过程可以分解为两个核心步骤:首先计算用户之间的相似度,找出与目标用户偏好最接近的邻居用户;然后基于这些邻居用户的历史行为,预测目标用户对未购买商品的兴趣程度。

流程

  1. 第一步:用户相似度计算
    如何判断两个用户是否相似?我们来看几种常用的方法。
    假设用户u和用户v分别对应物品集合N(u)和N(v)(即他们各自有过行为的物品)。
  • 杰卡德相似系数:如果你的系统只记录用户是否对物品有过行为(比如是否点击、购买),而没有具体的评分,那么杰卡德系数是个好选择:

wuv=N(u)N(v)N(u)N(v)w_{uv} = \frac{ |N(u) \cap N(v)| }{ |N(u) \cup N(v)| }

这个公式可以直观理解为:分子是两人共同喜欢的物品数量,分母是两人喜欢的物品总数(去重后)。

  • 余弦相似度:将每个用户看成一个向量,计算向量间的夹角:

wuv=N(u)N(v)N(u)N(v)w_{uv} = \frac{ |N(u) \cap N(v)| }{ \sqrt{ |N(u)| \cdot |N(v)| } }

余弦相似度考虑了用户活跃度的差异。

  • 皮尔逊相关系数:当你有具体的评分数据时(比如5星评分),皮尔逊系数能更好地捕捉用户的偏好模式:

wuv=iI(ruiru)(rvirv)iI(ruiru)2iI(rvirv)2这里rui表示用户u对物品i的评分,ru是用户u的平均评分,I是两个用户都评价过的物品集合。w_{uv} = \frac{ \sum_{i \in I} (r_{ui} - \overline{r}_u) (r_{vi}- \overline{r}_v) }{ \sqrt{ \sum_{i \in I} (r_{ui}-\overline{r}_u)^2 } \sqrt{ \sum_{i \in I} (r_{vi} - \overline{r}_v)^2 } } \\ 这里r_{ui}表示用户u对物品i的评分,\overline{r}_u是用户u的平均评分,I是两个用户都评价过的物品集合。

皮尔逊系数通过中心化处理,有效消除了个人评分习惯的差异。有些人习惯给高分(“好人”),有些人比较严格,通过减去各自的平均分,我们关注的是评分的相对变化趋势,而不是绝对数值。

  • 计算完相似度后,我们通常选择相似度最高的K个用户作为“邻居”,这个K值需要仔细调整——太小可能信息不足,太大可能引入噪音。
  1. 第二步:候选物品推荐
    有了相似用户,下一步就是利用他们的偏好来预测目标用户对未交互过的物品的兴趣。
  • 简单加权平均:最直接的方法是用相似度作为权重,对邻居的评分进行加权平均:

r^up=vSuwuvrvpvSuwuv这里r^up是预测的用户u对物品p的评分,Su是邻居用户集合,wuv是相似度权重,rvp是邻居v对物品p的实际评分。\hat{r}_{up} = \frac{ \sum_{v \in S_u} w_{uv} r_{vp} }{ \sum_{v \in S_u} w_{uv} } \\ 这里\hat{r}_{up}是预测的用户u对物品p的评分,S_u是邻居用户集合,w_{uv}是相似度权重,r_{vp}是邻居v对物品p的实际评分。

  • 考虑评分偏置的版本:为了进一步消除个人评分习惯的影响,我们可以加入偏置修正:

r^up=ruvSuwuv(rvprv)vSuwuv这里rurv分别是用户uv的平均评分。\hat{r}_{up} = \overline{r}_u \frac{ \sum_{v \in S_u} w_{uv} (r_{vp} - \overline{r}_v ) }{ \sum_{v \in S_u} w_{uv} } \\ 这里\overline{r}_u和\overline{r}_v分别是用户u和v的平均评分。

  • 优化策略:让算法跑得更快
    UserCF看起来很简单,但有个大问题:当用户数量很大时,计算所有用户对之间的相似度会非常耗时。但仔细观察就会发现,很多用户对之间根本没有共同行为的物品,相似度必然为0,计算它们就是浪费时间。我们可以利用这个特点来优化算法。
  • 基于物品倒排表的优化:
    1. 构建倒排表:为每个物品维护一个用户列表,记录哪些用户对这个物品有过行为。这样就可以通过物品快速找到相关用户。
    2. 稀疏矩阵计算:创建一个矩阵 C[u][v]C[u][v] 来记录用户u和v的共同物品数量。遍历每个物品的用户列表,将列表中的用户两两配对,对应的 C[u][v]C[u][v] 值加1。
    3. 计算最终相似度:矩阵C给出了余弦相似度公式的分子,再除以分母 N(u)N(v)\sqrt{|N(u)||N(v)|} 就得到了用户相似度。
    4. 线上召回:有了用户相似度矩阵,UserCF的推荐流程通常采用以下策略来提高效率:
  • 首先,系统为目标用户找到最相似的K个用户作为“相似用户集合”(通常K=50-200)。然后收集这些相似用户交互过的所有物品作为候选集合,并计算目标用户u对候选物品i的兴趣分数:

p(u,i)=vSuN(i)wuvrvi其中Su是与用户u最相似的K个用户集合,N(i)是对物品i有过行为的用户集合,wuv是用户相似度,rvi表示用户v对物品i的兴趣强度(可以是简单的1,也可以根据评分、交互时间等设置权重)。p(u,i) = \sum_{v \in S_u \cap N(i)} w_{uv} \cdot r_{vi} \\ 其中S_u是与用户u最相似的K个用户集合,N(i)是对物品i有过行为的用户集合,w_{uv}是用户相似度,r_{vi}表示用户v对物品i的兴趣强度(可以是简单的1,也可以根据评分、交互时间等设置权重)。

最终,系统对所有候选物品按兴趣分数排序,选择Top-N物品作为UserCF通道的推荐结果。这种“相似用户扩展”的方式既保证了推荐的个性化质量,又避免了对全量物品的无效计算。

代码

user_cf.py 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""
基于用户的协同过滤推荐算法。
"""

import numpy as np
from collections import defaultdict
import pandas as pd
from tqdm import tqdm
from operator import itemgetter


class UserCF:
"""
用于CTR预测的优化基于用户的协同过滤算法。
使用基于物品的倒排索引进行更快的计算和直接推荐生成。
"""

def __init__(self, k_neighbors=20, min_similarity=0.1):
"""
初始化UserCF模型。

参数:
-----------
k_neighbors : int, 默认=20
进行推荐时考虑的邻居数量。
min_similarity : float, 默认=0.1
将用户视为邻居的最小相似度阈值。
"""
self.k_neighbors = k_neighbors
self.min_similarity = min_similarity

# 用于优化的数据结构
self.item_users = defaultdict(set) # 物品 -> 交互过的用户集合
self.user_items = defaultdict(dict) # 用户 -> 物品评分字典(CTR为1.0)
self.user_similarity = defaultdict(dict) # 用户 -> {邻居: 相似度}
self.user_map = {} # 原始用户id -> 内部id
self.item_map = {} # 原始物品id -> 内部id
self.reverse_user_map = {} # 内部id -> 原始用户id
self.reverse_item_map = {} # 内部id -> 原始物品id

def fit(self, user_item_interactions):
"""
使用用户-物品交互数据(带有0/1标签的CTR数据)训练模型。

参数:
-----------
user_item_interactions : 元组列表 (user_id, item_id, label) 或 DataFrame
训练数据,其中label为0(未点击)或1(点击)。
仅将label=1的交互视为正向交互。

返回:
--------
self : UserCF实例
"""
# 如果需要,转换为DataFrame
if not isinstance(user_item_interactions, pd.DataFrame):
user_item_interactions = pd.DataFrame(
user_item_interactions, columns=["user_id", "item_id", "label"]
)

# 仅过滤正向交互(点击)
positive_interactions = user_item_interactions[
user_item_interactions["label"] == 1
]

# 创建映射
unique_users = positive_interactions["user_id"].unique()
unique_items = positive_interactions["item_id"].unique()

self.user_map = {user: idx for idx, user in enumerate(unique_users)}
self.item_map = {item: idx for idx, item in enumerate(unique_items)}
self.reverse_user_map = {idx: user for user, idx in self.user_map.items()}
self.reverse_item_map = {idx: item for item, idx in self.item_map.items()}

# 构建数据结构:用户 -> {物品: 评分}
for _, row in positive_interactions.iterrows():
user_id = self.user_map[row["user_id"]]
item_id = self.item_map[row["item_id"]]

self.item_users[item_id].add(user_id)
self.user_items[user_id][item_id] = 1.0 # CTR:所有正向交互的评分为1.0

# 计算用户相似度
self._calculate_user_similarity()

return self

def _calculate_user_similarity(self):
"""
使用基于物品的倒排索引优化计算用户相似度。
以更高效的格式存储相似度用于推荐。
"""
# 步骤1:构建共现矩阵 C[u][v] = 共同物品数量
C = defaultdict(lambda: defaultdict(int))

# 对于每个物品,找到所有与之交互的用户对
for item_id, users in self.item_users.items():
users_list = list(users)
# 对于所有与该物品交互的用户对
for i in range(len(users_list)):
for j in range(i + 1, len(users_list)):
u1, u2 = users_list[i], users_list[j]
C[u1][u2] += 1
C[u2][u1] += 1 # 对称

# 步骤2:计算最终相似度分数并高效存储
for u1 in C:
for u2 in C[u1]:
if u1 >= u2: # 避免重复计算
continue

similarity = C[u1][u2] / np.sqrt(
len(self.user_items[u1]) * len(self.user_items[u2])
)

if similarity >= self.min_similarity:
self.user_similarity[u1][u2] = similarity
self.user_similarity[u2][u1] = similarity

def recommend(self, user_id, n_recommendations=10, exclude_interacted=True):
"""
使用直接计算的优化推荐,无需predict_score。
基于您提供的算法。

参数:
-----------
user_id : 原始用户ID
n_recommendations : int, 推荐数量
exclude_interacted : bool, 是否排除已交互的物品

返回:
--------
元组列表: [(item_id, score), ...]
"""
if user_id not in self.user_map:
return []

user_idx = self.user_map[user_id]

# 获取用户已交互的物品
interacted_items = (
set(self.user_items[user_idx].keys()) if exclude_interacted else set()
)

# 初始化字典
rank = defaultdict(float)

# 获取前K个相似用户,按相似度降序排列
similar_users = sorted(
self.user_similarity[user_idx].items(), key=itemgetter(1), reverse=True
)[: self.k_neighbors]

# 对于每个相似用户v及其相似度wuv
for v, wuv in similar_users:
# 对于用户v交互过的每个物品i(评分rvi)
for i, rvi in self.user_items[v].items():
# 跳过目标用户已交互的物品
if i in interacted_items:
continue

# 累积加权评分:rank[i] += wuv * rvi
rank[i] += wuv * rvi

# 转换为(score, item_id)列表并按评分降序排列
recommendations = [
(score, self.reverse_item_map[item_idx]) for item_idx, score in rank.items()
]
recommendations.sort(reverse=True)

# 返回前N个推荐作为(item_id, score)
return [
(item_id, score) for score, item_id in recommendations[:n_recommendations]
]


def build_user_cf_model(feature_columns, model_config):
"""
使用标准化接口构建UserCF模型。

参数:
feature_columns: UserCF不使用(经典算法不需要特征列)
model_config: 模型配置字典,包含:
- k_neighbors: 邻居数量(默认:20)
- min_similarity: 最小相似度阈值(默认:0.1)

返回:
UserCF模型实例
"""
k_neighbors = model_config.get("k_neighbors", 20)
min_similarity = model_config.get("min_similarity", 0.1)

return UserCF(k_neighbors=k_neighbors, min_similarity=min_similarity)

3、矩阵分解

  • UserCF和ItemCF虽然思路直观、易于理解,但它们都面临一个根本性的挑战:数据稀疏性
  • 在真实的推荐场景中,用户-物品交互矩阵往往是极度稀疏的——大部分用户只与极少数物品发生过交互。这导致两个问题:一是很难找到足够的共同评分来计算可靠的相似度;二是即使找到了相似用户或物品,他们的交互覆盖面也可能很有限。
  • 更深层的问题在于,邻域方法将相似度计算和推荐预测分离开来,先显式地计算相似度,再基于相似度进行推荐。这种两阶段的方法虽然直观,但缺乏对用户-物品交互数据的全局优化。能否换一种思路——不再显式计算相似度,而是通过学习用户和物品的隐向量表示,让向量空间中的距离自然地反映相似性?矩阵分解正是这一思想的体现,它标志着协同过滤从统计方法向机器学习方法的转变。
  • 协同过滤方法有个致命弱点:当评分数据非常稀疏时,很难找到足够的相似用户或物品。想想看,在一个有百万用户和十万电影的系统中,大部分用户只看过其中几十部电影,传统方法很可能因为共同评分太少而失效。
  • 这时候,矩阵分解登场了。它不再直接寻找相似性,而是换了个思路:假设用户的偏好和电影的特征都可以用几个关键因子来描述。比如,我们可以用“面向男性vs面向女性”和“严肃vs逃避现实”这两个维度来刻画电影,同时用用户对这两类特征的偏好程度来描述用户。这样,预测一个用户对某部电影的评分就变成了计算这两个向量的相似度。
  • 矩阵分解的核心想法建立在两个关键假设上:
  1. 第一个是低秩假设:虽然评分矩阵看起来很复杂,但实际上可能只受少数几个隐含因素影响,比如“面向男性vs面向女性”、“严肃vs逃避现实”等维度。
  2. 第二个是隐向量假设:每个用户和每部电影都能用一个包含这些隐含因子的向量来表示,用户向量反映了其对各种因子的偏好程度,而电影向量则描述了该电影在各个因子上的特征强度。
  • 这种做法的好处是显而易见的:即使两个用户没有看过相同的电影,只要他们在隐含因子上表现相似,我们就能为他们推荐相似的内容。这大大提高了模型处理稀疏数据的能力。
    隐语义模型
  • 我们将介绍两种矩阵分解模型:简单直接的基础模型(FunkSVD)和考虑评分偏差的改进模型(BiasSVD)。

3.1 基础模型FunkSVD

原理

  • 它的想法非常直接:把复杂的用户-物品评分矩阵分解成两个简单的矩阵——用户特征矩阵和物品特征矩阵。
  • 假设我们有m个用户和n个物品,想要用K个隐含因子来描述它们。那么用户u可以用一个K维向量 pup_u 来表示,物品i也可以用一个K维向量 qiq_i 来表示。预测用户u对物品i的评分就是这两个向量的内积:

r^ui=puTqi=k=1Kpu,kqi,k这里pu,k表示用户u在第k个隐含因子上的偏好程度,qi,k表示物品i在第k个隐含因子上的特征强度。\hat{r}_{ui} = p_u^T q_i = \sum_{k=1}^{K} p_{u,k}\cdot q_{i,k} \\ 这里 p_{u,k} 表示用户u在第k个隐含因子上的偏好程度,q_{i,k}表示物品i在第k个隐含因子上的特征强度。

现在问题变成了:如何找到这些隐含因子?我们采用一个很自然的思路——让预测评分尽可能接近真实评分。具体来说,我们要最小化所有已知评分的预测误差:

minP,Q12(u,i)<!swig6>(ruipuTqi)2这里K表示所有已知评分的用户物品对,rui是用户u对物品i的真实评分。\min_{P,Q} \frac{1}{2} \sum_{(u,i) \in \mathcal (r_{ui} - p_u^T q_i)^2 } \\ 这里\mathcal{K}表示所有已知评分的用户-物品对,r_{ui}是用户u对物品i的真实评分。

要解决这个优化问题,我们使用梯度下降法。对于每个观测到的评分,我们先计算预测误差 eui=ruipuTqie_{ui} = r_{ui} - p_u^T q_i,然后沿着误差减小的方向更新参数:

pu,kpu,k+ηeuiqi,kqi,kqi,k+ηeuipu,k其中η是学习率,控制每次更新的步长。p_{u,k} \leftarrow p_{u,k} + \eta \cdot e_{ui} \cdot q_{i,k} \\ q_{i,k} \leftarrow q_{i,k} + \eta \cdot e_{ui} \cdot p_{u,k} \\ 其中\eta 是学习率,控制每次更新的步长。

这个更新规则的直觉很简单:如果预测评分偏低了(eui>0e_{ui} > 0),我们就增大相关的参数值;如果预测偏高了,就减小参数值。

不过在实际应用中,我们通常还会加入L2正则化来防止过拟合:

minP,Q12(u,i)<!swig7>(ruipuTqi)2+λ(pu2+qi2)\min_{P,Q} \frac{1}{2} \sum_{(u,i) \in \mathcal (r_{ui} - p_u^T q_i)^2 } + \lambda (\| p_u\| ^2 + \| q_i\| ^2)

这样可以避免模型过度拟合训练数据,提高在新数据上的表现。

代码

funksvd.py 文件:

  • FunkSVD的核心在于学习用户和物品的隐向量表示。在实现中,我们使用Embedding层来表示这些隐向量,并通过内积计算预测评分。
  • 这里的embedding_dim对应公式中的隐含因子数量 KKuser_factors对应 pup_uitem_factors对应 pip_i,而Dot操作计算的就是 puTqip_u^T q_i。通过训练,模型会自动学习最优的隐向量表示,使预测评分尽可能接近真实评分。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
"""
FunkSVD 模型实现

这实现了基本的奇异值分解(SVD)协同过滤模型,不包含偏置项,也称为 FunkSVD。
该模型学习用户和物品的潜在因子来预测用户-物品交互。

评分预测:r_ui = p_u^T * q_i
其中 p_u 是用户潜在因子向量,q_i 是物品潜在因子向量。
"""

import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Dense, Embedding, Flatten, Dot, Input

from .utils import build_input_layer


def build_funksvd_model(feature_columns, model_config):
"""
构建 FunkSVD 协同过滤模型 - 用于召回的双塔结构。

FunkSVD 基于:评分 = 用户因子 · 物品因子^T

Args:
feature_columns: 特征列配置
model_config: 模型配置字典,包含:
- embedding_dim: 嵌入维度(对应 SVD 中的潜在因子数量)(默认:8)

Returns:
返回 (训练模型, 用户模型, 物品模型) 的元组
"""

# 从配置中提取参数,设置默认值
embedding_dim = model_config.get("embedding_dim", 8)

# 构建输入层
input_layer_dict = build_input_layer(feature_columns)

# 查找用户和物品 ID 输入
user_id_input = None
item_id_input = None
user_inputs = []
item_inputs = []
user_vocab_size = None
item_vocab_size = None

for fc in feature_columns:
if "user" in fc.group:
user_inputs.append(input_layer_dict[fc.name])
if fc.name == "user_id":
user_id_input = input_layer_dict[fc.name]
user_vocab_size = fc.vocab_size
elif "item" in fc.group:
item_inputs.append(input_layer_dict[fc.name])
if fc.name in ["movie_id", "item_id"]:
item_id_input = input_layer_dict[fc.name]
item_vocab_size = fc.vocab_size

if user_id_input is None or item_id_input is None:
raise ValueError("需要 user_id 和 item_id(或 movie_id)输入")

# === 用户塔 ===
# 用户潜在因子
user_factors = Embedding(
user_vocab_size,
embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="user_factors",
)(user_id_input)
user_factors = Flatten()(user_factors)

# === 物品塔 ===
# 物品潜在因子
item_factors = Embedding(
item_vocab_size,
embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="item_factors",
)(item_id_input)
item_factors = Flatten()(item_factors)

# === 独立的用户和物品模型 ===
user_model = Model(inputs=user_inputs, outputs=user_factors, name="user_tower")
item_model = Model(inputs=item_inputs, outputs=item_factors, name="item_tower")

# === 训练模型 ===
# 仅交互项(无偏置项)
prediction = Dot(axes=1)([user_factors, item_factors])

# 输出层
output = Dense(1, activation="sigmoid", name="output")(prediction)

# 训练模型
training_model = Model(
inputs=user_inputs + item_inputs, outputs=output, name="funksvd_training"
)

return training_model, user_model, item_model

3.2 改进模型BaisSVD

原理

  • 基础模型虽然简洁,但在实际使用中我们发现了一个问题:不同用户的评分习惯差异很大。有些用户天生就是“好人”,很少给低分;有些用户则比较严格,平均分都不高。同样,有些电影因为制作精良或者明星云集,普遍得到较高评分;而有些冷门或质量一般的电影则评分偏低。
  • 这些系统性的偏差如果不处理,会影响推荐的准确性。BiasSVD正是为了解决这个问题而提出的。它在基础模型的基础上引入了偏置项,让预测公式变成:

r^ui=μ+bu+bi+puTqi\hat{r}_{ui} = \mu + b_u + b_i + p_u^T q_i

这里新增了三个项: μ\mu 是所有评分的全局平均值,反映了整个系统的评分水平; bub_u是用户u的个人偏置,反映了该用户相对于平均水平是倾向于给高分还是低分; bib_i是物品i的偏置,反映了该物品相对于平均水平是受欢迎还是不受欢迎。

  • 相应地,优化目标也要调整:

minP,Q12(u,i)<!swig8>(ruiμbubipuTqi)2+λ(pu2+qi2+bu2+bi2)\min_{P,Q} \frac{1}{2} \sum_{(u,i) \in \mathcal (r_{ui} - \mu - b_u - b_i - p_u^T q_i)^2 } + \lambda (\| p_u\| ^2 + \| q_i\| ^2 + b_u^2 + b_i^2)

  • 在参数更新时,除了用户和物品的隐向量,我们还需要更新偏置项:

bubu+η(euiλbu)bibi+η(euiλbi)b_u \leftarrow b_u + \eta (e_{ui} - \lambda b_u ) \\ b_i \leftarrow b_i + \eta (e_{ui} - \lambda b_i ) \\

  • 这种改进看似简单,但效果显著。通过分离出系统性偏差,模型能够更准确地捕捉用户和物品之间的真实交互模式,从而提供更精准的推荐。

代码

biassvd.py 文件:

  • BiasSVD在FunkSVD的基础上增加了偏置项。在实现中,我们为用户和物品分别添加偏置Embedding,并在预测时将所有项相加。
  • 与FunkSVD相比,BiasSVD的关键区别在于:
    • 用户和物品都增加了独立的偏置Embedding(user_biasitem_bias
    • 添加了全局偏置项(global_bias),对应公式中的 μ\mu
    • 最终预测使用Add层将四个部分相加。
  • 偏置项的初始化使用"zeros",因为它们代表的是相对于平均水平的偏移。这种设计使得模型能够自动学习不同用户的打分习惯和不同物品的受欢迎程度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""
BiasedSVD 模型实现

这实现了带偏置的奇异值分解 (SVD) 协同过滤模型。
该模型学习用户和物品的潜在因子以及用户、物品和全局偏置来预测用户-物品交互。

评分预测: r_ui = global_bias + user_bias_u + item_bias_i + p_u^T * q_i
其中 p_u 是用户潜在因子向量,q_i 是物品潜在因子向量,
偏置项用于考虑一般的评分倾向。
"""

import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Dense, Embedding, Flatten, Dot, Add, Input

from .utils import build_input_layer


def build_biassvd_model(feature_columns, model_config):
"""
构建 BiasedSVD 协同过滤模型 - 用于召回的双塔结构。

BiasedSVD 基于: Rating = global_bias + user_bias + item_bias + user_factors · item_factors^T

参数:
feature_columns: 特征列配置
model_config: 模型配置字典,包含:
- embedding_dim: 嵌入维度 (对应 SVD 中的潜在因子数量) (默认: 8)

返回:
(training_model, user_model, item_model) 元组
"""

# 从配置中提取参数,设置默认值
embedding_dim = model_config.get("embedding_dim", 8)

# 构建输入层
input_layer_dict = build_input_layer(feature_columns)

# 查找用户和物品 ID 输入
user_id_input = None
item_id_input = None
user_inputs = []
item_inputs = []
user_vocab_size = None
item_vocab_size = None

for fc in feature_columns:
if "user" in fc.group:
user_inputs.append(input_layer_dict[fc.name])
if fc.name == "user_id":
user_id_input = input_layer_dict[fc.name]
user_vocab_size = fc.vocab_size
elif "item" in fc.group:
item_inputs.append(input_layer_dict[fc.name])
if fc.name in ["movie_id", "item_id"]:
item_id_input = input_layer_dict[fc.name]
item_vocab_size = fc.vocab_size

if user_id_input is None or item_id_input is None:
raise ValueError("需要 user_id 和 item_id (或 movie_id) 输入")

# === 用户塔 ===
# 用户潜在因子
user_factors = Embedding(
user_vocab_size,
embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="user_factors",
)(user_id_input)
user_factors = Flatten()(user_factors)

# 用户偏置
user_bias = Embedding(
user_vocab_size,
1,
embeddings_initializer="zeros",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="user_bias",
)(user_id_input)
user_bias = Flatten()(user_bias)

# 用户表示: [因子, 偏置]
user_representation = tf.keras.layers.Concatenate()([user_factors, user_bias])

# === 物品塔 ===
# 物品潜在因子
item_factors = Embedding(
item_vocab_size,
embedding_dim,
embeddings_initializer="normal",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="item_factors",
)(item_id_input)
item_factors = Flatten()(item_factors)

# 物品偏置
item_bias = Embedding(
item_vocab_size,
1,
embeddings_initializer="zeros",
embeddings_regularizer=tf.keras.regularizers.l2(0.02),
name="item_bias",
)(item_id_input)
item_bias = Flatten()(item_bias)

# 物品表示: [因子, 偏置]
item_representation = tf.keras.layers.Concatenate()([item_factors, item_bias])

# === 独立的用户和物品模型 ===
user_model = Model(
inputs=user_inputs, outputs=user_representation, name="user_tower"
)
item_model = Model(
inputs=item_inputs, outputs=item_representation, name="item_tower"
)

# === 训练模型 ===
# 计算交互项: user_factors · item_factors
interaction = Dot(axes=1)([user_factors, item_factors])

# 全局偏置 - 使用带常数输入的 Dense 层的简单方法
ones_input = tf.keras.layers.Lambda(lambda x: tf.ones_like(x))(interaction)
global_bias = Dense(
1, use_bias=True, kernel_initializer="zeros", name="global_bias"
)(ones_input)

# BiasedSVD 预测: global_bias + user_bias + item_bias + interaction
prediction = Add()([global_bias, user_bias, item_bias, interaction])

# 输出层
output = Dense(1, activation="sigmoid", name="output")(prediction)

# 训练模型
training_model = Model(
inputs=user_inputs + item_inputs, outputs=output, name="biassvd_training"
)

return training_model, user_model, item_model

总结

简单性能对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+-----------+---------------+--------------+----------------+---------------+
| model | hit_rate@10 | hit_rate@5 | precision@10 | precision@5 |
+===========+===============+==============+================+===============+
| item_cf | 0.6594 | 0.5459 | 0.1444 | 0.1826 |
+-----------+---------------+--------------+----------------+---------------+
| swing | 0.6194 | 0.5042 | 0.1282 | 0.1629 |
+-----------+---------------+--------------+----------------+---------------+
| user_cf | 0.6912 | 0.5927 | 0.1643 | 0.2063 |
+-----------+---------------+--------------+----------------+---------------+
+-----------+---------------+--------------+-----------+----------+----------------+---------------+
| model | hit_rate@10 | hit_rate@5 | ndcg@10 | ndcg@5 | precision@10 | precision@5 |
+===========+===============+==============+===========+==========+================+===============+
| FunkSVD | 0.0007 | 0.0003 | 0.0003 | 0.0001 | 0.0001 | 0.0001 |
+-----------+---------------+--------------+-----------+----------+----------------+---------------+
| BiasSVD | 0.0013 | 0.0012 | 0.0011 | 0.0011 | 0.0001 | 0.0002 |
+-----------+---------------+--------------+-----------+----------+----------------+---------------+

结束

通过本章的学习,我们深入探讨了协同过滤这一推荐系统的经典技术。从最初的邻域方法到后来的矩阵分解,协同过滤展现了推荐算法从简单统计到机器学习的演进历程。

  • ItemCF的实践价值:我们深入分析了ItemCF在工业界广泛应用的原因。除了计算效率和稳定性优势外,ItemCF还具有很好的可解释性——用户能够理解“因为你喜欢A,所以推荐B”的逻辑。Swing算法的引入进一步提升了ItemCF的效果,通过二部图分析有效降低了热门物品对相似度计算的干扰,这种基于图结构的优化思路在后续的图神经网络方法中得到了进一步发展。

  • UserCF的理论意义:虽然UserCF在大规模系统中应用受限,但其蕴含的“集体智慧”理念具有深远影响。UserCF能够发现用户的跨领域兴趣,这种探索性推荐的思想后来在深度学习模型中通过注意力机制和序列建模得到了更好的实现。UserCF面临的挑战也推动了推荐系统向更精细化的用户建模方向发展。

  • 矩阵分解的范式转变:矩阵分解不仅解决了传统协同过滤的稀疏性问题,更重要的是引入了向量化表示的概念。这种将用户和物品映射到连续向量空间的思想,为整个推荐系统领域开辟了新的技术路径。从FunkSVD到BiasSVD的演进,展现了如何通过模型改进来处理实际数据中的偏置问题。

协同过滤,特别是矩阵分解,在推荐系统发展史上具有承上启下的重要地位。它承接了早期基于规则和统计的方法,启发了后续基于深度学习的现代推荐技术。无论是双塔模型中的用户物品向量化,还是序列推荐中的embedding技术,都可以追溯到矩阵分解的核心思想。这种技术传承体现了推荐系统领域知识积累和创新发展的连续性。