SAR 推荐算法详解:从一杯咖啡说起
源码出自微软 Recommenders 库(MIT 协议),SARSingleNode 类。本文逐行拆解其肌理,务使初学者亦能通晓。
SAR 是个什么东西
街角那家咖啡店的老板,记得你上礼拜点过两次耶加雪菲。今天你推门进去,他头也不抬:"还是耶加?或者试试这批新到的西达摩,跟耶加一个产区,风味相近。"
这老板做的事,就是 SAR 干的事——推荐你已经喜欢的物品的相似物品。
SAR,全名 Simple Algorithm for Recommendations,单节点版叫 SARSingleNode。名字里带个 "Simple",倒也诚实。它不搞深度学习那一套反向传播,不学 embedding,不迭代梯度下降。它就做三件事:
- 摸清每个用户对每个物品有多喜欢(用户亲和度)
- 摸清物品与物品之间有多像(物品相似度)
- 把这两样东西乘起来,得出推荐分
就这么三步。朴素,但快得吓人,而且好解释——你能说清"为什么推荐这个",不像神经网络是个黑箱。
它属于 item-based 协同过滤家族里的一员。但和经典的 item-based CF 比起来,SAR 有两个特别之处:一是它不只用余弦相似度,手里攥着七种相似度度量可挑;二是它内置了时间衰减机制,昨天的行为比去年的行为更值钱。
好,道理讲完了。接下来逐层拆,看这三步在代码里到底是怎么落地的。
第一步:摸清每个用户的口味——用户亲和度矩阵
亲和度矩阵长什么样
想象一张大表,行是用户,列是物品,格子里的数字是"这个用户对这个物品有多喜欢"。这张表,行话叫用户亲和度矩阵(user affinity matrix),记作 $U$,形状是 n_users × n_items。
代码里这么造它:
def compute_affinity_matrix(self, df, rating_col):
return sparse.coo_matrix(
(df[rating_col], (df[self.col_user_id], df[self.col_item_id])),
shape=(self.n_users, self.n_items),
).tocsr()就这一行。sparse.coo_matrix 接三个东西:数据(评分)、行坐标(用户索引)、列坐标(物品索引)。COO 格式即"坐标格式",专门记稀疏数据——毕竟一个用户买过的物品几十件,相对几万件的总量,稀疏得很,存全表太亏。
末尾 .tocsr() 把它转成 CSR(Compressed Sparse Row)格式。为什么转?CSR 对按行操作(取某用户的所有评分)特别快,后面算推荐分要频繁干这事。
这里有个小机关。COO 格式允许同一坐标出现多次,转 CSR 时会自动相加。所以如果同一用户对同一物品有多次交互(比如先点了一次耶加,隔天又点了一次),CSR 转换后这些值会被累加。这正是我们想要的——多次交互,亲和度自然更高。
时间衰减:昨天的咖啡比去年的香
光有评分还不够。你三年前买过一次耶加雪菲,和上周连点三次,这两件事的分量能一样吗?
SAR 用指数衰减处理这事。代码里这么算:
def compute_time_decay(self, df, decay_column):
if self.time_now is None:
self.time_now = df[self.col_timestamp].max()
df[decay_column] *= exponential_decay(
value=df[self.col_timestamp],
max_val=self.time_now,
half_life=self.time_decay_half_life,
)
return df.groupby([self.col_user, self.col_item]).sum().reset_index()核心是 exponential_decay 函数,数学上是这么个东西:
$$w(t) = 2^{-\frac{t_{\text{now}} - t}{t_{1/2}}}$$
其中 $t$ 是事件发生的时间戳,$t_{\text{now}}$ 是"现在"(默认取数据里最大的时间戳,即最近一次交互),$t_{1/2}$ 是半衰期。
半衰期什么意思?距今一个半衰期之前的事件,权重折半;距今两个半衰期,权重变四分之一;三个半衰期,八分之一。指数往下掉。
构造函数里那个 time_decay_coefficient=30,是把"30 天"当半衰期。代码里 self.time_decay_half_life = time_decay_coefficient * 24 * 60 * 60,把天换算成秒——因为时间戳通常用秒计。
衰减之后,按 user-item 分组求和。这一步把"同一用户对同一物品的多次(已衰减)交互"汇总成一个亲和度值。
打个比方。你上周买了耶加(距今 7 天,半衰期 30 天,权重约 0.85),半年前也买过一次(距今 180 天,权重约 0.015)。那你的耶加亲和度就是 $0.85 + 0.015 \approx 0.865$。近的吃香,远的 fades away。这才合理。
timedecay_formula=False 时,时间衰减整个跳过,直接用原始评分。简单场景够用。
第二步:物品们有多像——共现矩阵
共现矩阵是怎么算出来的
光知道用户喜欢什么还不够,得知道物品之间什么关系。SAR 用一个朴素到家的思路:两个物品被同一批人同时喜欢,它俩就像。
这思路的落地,叫共现矩阵(co-occurrence matrix),记作 $C$,形状是 n_items × n_items。$C_{ij}$ 表示"同时交互过物品 i 和物品 j 的用户数"。
代码:
def compute_cooccurrence_matrix(self, df):
user_item_hits = sparse.coo_matrix(
(np.repeat(1, df.shape[0]), (df[self.col_user_id], df[self.col_item_id])),
shape=(self.n_users, self.n_items),
).tocsr()
item_cooccurrence = user_item_hits.transpose().dot(user_item_hits)
item_cooccurrence = item_cooccurrence.multiply(
item_cooccurrence >= self.threshold
)
return item_cooccurrence.astype(df[self.col_rating].dtype)三句话,三步。
第一句:造一个"命中矩阵" user_item_hits。注意这里数据是 np.repeat(1, ...),全填 1。意思是不管你打了几分,只看你有没有交互过。买过就是 1,没买过就是 0。
第二句:user_item_hits.transpose().dot(user_item_hits)。转置再相乘。数学上:
$$C = U_{\text{hits}}^{\top} \cdot U_{\text{hits}}$$
$U_{\text{hits}}$ 是 n_users × n_items 的命中矩阵,转置后是 n_items × n_users,再乘原矩阵,得到 n_items × n_items。每一格 $C_{ij}$ 就是"同时命中 i 和 j 的用户数"。矩阵乘法的几何含义,在这里变成了"共现计数"。
这一步妙就妙在,用矩阵乘法一句搞定,不用循环遍历。几十万用户、几万物品,稀疏矩阵乘法在 scipy 手里几秒钟的事。
第三句:item_cooccurrence.multiply(item_cooccurrence >= self.threshold)。阈值过滤。threshold=1 是默认值,意思是共现次数少于 1 次的(其实就是 0 次)置零——这本来就是 0,没影响。但如果把 threshold 调到 5,那共现不到 5 次的物品对,统统归零。为什么?因为一两个用户碰巧都买了,不足以说明两个物品真有关系。设个门槛,滤掉噪声。
对角线藏着物品热度
共现矩阵的对角线 $C_{ii}$,意思是"交互过物品 i 的用户数"——因为物品 i 和它自己"共现",就是统计有多少人买过它。这就是物品的热度。
代码里专门取出来存着:
self.item_frequencies = item_cooccurrence.diagonal()后面 get_popularity_based_topk 基于热度推荐,用的就是这个。
第三步:七把尺子量相似
共现矩阵 $C$ 算出来了,但它还不是"相似度"。$C_{ij}$ 是个绝对计数,受物品热度影响——热门物品跟谁都共现得多,但这不代表它真像。得归一化,得提炼。
SAR 提供七种归一化方式,也就是七种相似度度量。设 $N$ 为总用户数,$f_i = C_{ii}$ 为物品 i 的频率。
1. 共现(cooccurrence)
$$\text{sim}(i, j) = C_{ij}$$
最朴素。直接拿共现次数当相似度。好处是简单,坏处是偏向热门物品——畅销书跟谁都"像"。代码里直接 self.item_similarity = item_cooccurrence,原样用。
2. 余弦(cosine)
$$\text{sim}(i, j) = \frac{C_{ij}}{\sqrt{C_{ii} \cdot C_{jj}}}$$
把共现矩阵的两个列向量(物品 i 和物品 j 在所有用户上的共现向量)做余弦。分母用各自频率的开方归一化,抹掉热度的影响。经典做法,稳妥。
3. 包含指数(inclusion index)
$$\text{sim}(i, j) = \frac{C_{ij}}{\min(C_{ii}, C_{jj})}$$
也叫条件概率对称化。意思是"买了较冷门那个物品的人里,有多大比例也买了另一个"。用 $\min$ 而非 $\max$,所以它衡量的是"小众物品被大众物品包含的程度"。
4. 杰卡德(jaccard)
$$\text{sim}(i, j) = \frac{C_{ij}}{C_{ii} + C_{jj} - C_{ij}}$$
交集除以并集。分子 $C_{ij}$ 是同时买两者的人数(交集),分母 $C_{ii} + C_{jj} - C_{ij}$ 是买过至少一个的人数(并集)。集合论里最自然的相似度。SAR 的默认选项就是 jaccard。
5. 提升度(lift)
$$\text{sim}(i, j) = \frac{N \cdot C_{ij}}{C_{ii} \cdot C_{jj}}$$
衡量"买 i 和买 j 这两件事是不是相关的"。若 i 和 j 独立,$C_{ij} \approx \frac{C_{ii} \cdot C_{jj}}{N}$,lift ≈ 1。lift > 1 正相关,< 1 负相关。这玩意儿在购物篮分析里用得多,"买啤酒的人也买尿布"那种。
6. 互信息(mutual information)
信息论那套。衡量"知道用户买了 i,能减少多少关于他是否买 j 的不确定性"。公式涉及对数和边缘概率,比上面几个复杂。适合物品分布长尾、想挖掘弱关联的场景。
7. 词典编纂者互信息(lexicographers mutual information)
互信息的一个变体,语料库语言学里来的。和标准 MI 的区别在于归一化方式不同,对小样本共现更敏感。冷门物品之间的微弱共现,它比标准 MI 更容易捕捉到。
七把尺子怎么挑
| 相似度 | 适合场景 | 注意点 |
|---|---|---|
| cooccurrence | 数据稀疏、只想快 | 偏向热门 |
| cosine | 通用、稳妥 | 中庸之选 |
| inclusion index | 关注小众物品被包含关系 | 不对称感的场景 |
| jaccard | 默认首选,集合相似度经典 | 通用 |
| lift | 购物篮分析、关联挖掘 | 值域不固定 |
| mutual information | 长尾分布、弱关联挖掘 | 计算略重 |
| lexicographers MI | 冷门物品共现敏感 | 小样本友好 |
实战里,jaccard 当起手没问题。若发现推荐结果总偏向热门,换 lift 或 MI 试试。
把口味和相似度拼起来——生成推荐
亲和度矩阵 $U$ 有了,相似度矩阵 $S$ 有了。第三步,乘起来。
评分公式
score 方法核心就一行:
test_scores = self.user_affinity[user_ids, :].dot(self.item_similarity)数学上:
$$\text{score}(u, j) = \sum_{i} U_{u,i} \cdot S_{i,j}$$
意思直白:用户 u 对物品 j 的推荐分,等于"u 对所有物品的亲和度"与"这些物品和 j 的相似度"的加权和。亲和度高的物品贡献大,和 j 相似度高的物品贡献大,两者乘积再求和。
换个说法。你买过耶加(亲和度高),耶加和西达摩很像(相似度高),那西达摩的推荐分就被抬上去了。要是你还买过肯亚(亲和度也高),肯亚和西达摩没那么像(相似度低),肯亚对西达摩的贡献就小。所有你买过的物品都来投一票,票数加权汇总,就是候选物品的推荐分。
这正是 item-based 协同过滤的标准操作。SAR 把它写成了一个矩阵乘法,高效。
归一化:把分数拉回原始评分尺度
normalize=True 时,分数会被 rescale 到训练集评分的 $[\text{rating\_min}, \text{rating\_max}]$ 区间。
if self.normalize:
counts = self.unity_user_affinity[user_ids, :].dot(self.item_similarity)
user_min_scores = (
np.tile(counts.min(axis=1)[:, np.newaxis], test_scores.shape[1])
- self.rating_min
)
user_max_scores = (
np.tile(counts.max(axis=1)[:, np.newaxis], test_scores.shape[1])
- self.rating_max
)
test_scores = rescale(test_scores, self.rating_min, self.rating_max,
user_min_scores, user_max_scores)这里有个巧妙设计:训练时额外造一个 unity_user_affinity,把所有评分当 1.0 算一遍亲和度。推荐时用它算出每个用户的"理论分数区间",再把实际分数线性映射回原始评分尺度。
为什么要归一化?因为不同相似度度量值域差异大——jaccard 在 [0,1],lift 可能到几十。归一化后,无论用哪种相似度,输出分数都在评分原尺度上,可比性好,也方便设阈值。
不重复推荐:把见过的物品踢出去
remove_seen=True 时:
if remove_seen:
test_scores += self.user_affinity[user_ids, :] * -np.inf把用户在训练集里交互过的物品分数加 $-\infty$,等于判了死刑。推荐时自然不会把已经买过的东西再推一遍。简单粗暴,有效。
冷启动与各种推荐姿势
SAR 不止 recommend_k_items 一种推荐方式。它有好几手。
基于热度的推荐
def get_popularity_based_topk(self, top_k=10, sort_top_k=True, items=True):直接拿 item_frequencies(共现矩阵对角线)排序,推荐最热门的物品。这是冷启动的兜底——新用户没历史记录,item-based CF 没法用,那就先推热门。虽不个性化,但总比随机强。
基于物品种子的推荐
def get_item_based_topk(self, items, top_k=10, sort_top_k=True):给一组种子物品,推荐和它们最像的。这招对冷启动用户特别有用——新用户注册时填了几个喜欢的物品,就用这些当种子,算出伪亲和度,再走正常的推荐流程。
代码里构造一个 pseudo_affinity(伪亲和度矩阵),把种子物品当作用户的"历史",然后照常 pseudo_affinity.dot(self.item_similarity)。模型不用重新训练,现成相似度矩阵直接复用。
找相似用户
def get_topk_most_similar_users(self, user, top_k, sort_top_k=True):基于用户亲和度向量算用户间相似度:self.user_affinity[user_idx].dot(self.user_affinity.T)。两个用户买过的物品重叠越多,相似度越高。这是 user-based CF 的思路,但 SAR 顺手也提供了。
预测特定分数
def predict(self, test):和 recommend_k_items 不同,predict 只算 test 集里指定的 (user, item) 对的分数,不排序、不取 top-k。适合做离线评估——你有一批测试集,想算 RMSE、MAE 之类指标,就用它。
藏在代码里的工程功夫
SAR 代码看着不长,但几处工程细节值得说道。
索引映射:省内存的头等大事
def set_index(self, df):
self.index2item = dict(enumerate(df[self.col_item].unique()))
self.index2user = dict(enumerate(df[self.col_user].unique()))
self.item2index = {v: k for k, v in self.index2item.items()}
self.user2index = {v: k for k, v in self.index2user.items()}
self.n_users = len(self.user2index)
self.n_items = len(self.index2item)用户的 ID、物品的 ID,业务里通常是字符串("user_abc123"、"item_xyz789")。但稀疏矩阵的坐标必须是整数。所以先把字符串 ID 映射成 0, 1, 2, ... 连续整数索引。
为什么强调"连续"?因为稀疏矩阵的形状是 (n_users, n_items),如果 ID 是 1, 5, 100, 9999 这种稀疏整数,矩阵会白白占一大堆空行空列。映射成连续索引后,矩阵紧凑,内存省下来不是一点半点。
index2item 和 item2index 是正反两张表。索引→物品用于输出推荐结果时还原成业务 ID;物品→索引用于把新数据映射进矩阵。
稀疏矩阵格式转换
代码里反复出现 coo_matrix(...).tocsr()。COO 适合构造(允许重复坐标),CSR 适合运算(按行切片快、矩阵乘法高效)。这是 scipy 稀疏矩阵的标准操作范式。
另外 score 里有这么一句:
if isinstance(test_scores, sparse.spmatrix):
test_scores = test_scores.toarray()矩阵乘法结果可能还是稀疏的,但后面要逐元素操作(归一化、remove_seen),转成稠密数组更方便。这里要留意内存——用户数 × 物品数若很大,toarray() 会吃不少内存。
重复检查:防患于未然
if df[select_columns].duplicated().any():
raise ValueError("There should not be duplicates in the dataframe")fit 开头就检查重复。因为时间衰减那步会按 user-item 分组求和,若数据本身有完全重复行(同 user、同 item、同 rating、同 timestamp),逻辑会出岔子。早报错好过晚 debug。
数值类型校验
if not np.issubdtype(df[self.col_rating].dtype, np.number):
raise TypeError("Rating column data type must be numeric")评分列必须是数值。若传进来个字符串评分("好评"/"差评"),直接挡掉。这也是防御性编程的一环。
SAR 的长处与短处
长处
快。 全程矩阵运算,没有迭代训练。几百万交互记录,fit 几秒到几十秒。对比矩阵分解、深度学习模型动辄训练几小时,SAR 简直是闪电。
可解释。 推荐结果能说清来路——"因为你买过 A,A 和 B 相似度 0.8"。黑箱模型给不了这种透明度。金融、医疗等强合规场景,这点尤其值钱。
无需调参地狱。 主要就两个旋钮:相似度类型、时间衰减半衰期。网格搜索几下就定。不像神经网络 learning rate、batch size、层数、dropout 一堆要调。
冷启动有兜底。 热度推荐 + 物品种子推荐,新用户新物品不至于抓瞎。
七种相似度任选。 不同数据分布、不同业务诉求,能换不同的相似度度量,灵活性好。
短处
不建模顺序。 SAR 把用户的历史交互看作一个集合,不关心先后。但你先买手机再买手机壳,和先买手机壳再买手机,意图可能不同。序列建模这事,SAR 干不了,得靠 SASRec、BERT4Rec 那类模型。
冷启动仍弱。 兜底方案只是兜底。新物品没人交互过,共现矩阵里它整列是 0,相似度算不出来。得靠内容特征补救(item embeddings),SAR 本身不处理这个。
特征利用单一。 SAR 只用 user-item 交互。用户的年龄、物品的类别、上下文时间——这些 side information 它都不看。深度学习模型能把这些特征都吃进去,SAR 不能。
热度偏向。 即便用了 jaccard 归一化,热门物品仍容易霸榜。长尾物品曝光机会少。若业务重视长尾发掘,得另想办法。
什么时候用 SAR
- 冷启动前的快速基线。 新业务没积累够数据训复杂模型,SAR 先顶上,效果不差,上线快。
- 强可解释性场景。 合规要求高的金融、医疗推荐。
- 实时性要求高。 增量更新便宜——来一批新交互,重新算共现和相似度即可,不用重训整个模型。
- 中小规模数据。 用户物品各几十万量级,SAR 游刃有余。再大,得考虑分布式版本或近似最近邻检索。
尾声
回头再看那咖啡店老板。
他记得你买过耶加(亲和度),他知道耶加和西达摩风味相近(相似度),他把这两件事一拼,给你推了西达摩(推荐分)。SAR 干的,就是把这老板脑子里的活儿,用稀疏矩阵和数学公式复刻了一遍。
三步走:摸口味、量相似、拼起来。七种相似度是七把不同的尺子,时间衰减是给近期行为加的权重,归一化是让分数可比,remove_seen 是不让你买到重复的东西。工程上,索引映射省内存,稀疏矩阵格式转换提速,重复检查防 bug。
它不花哨,不深度学习,不可端到端。但它快、透明、好部署。在推荐系统这个动辄比拼模型复杂度的行当里,SAR 这种"简单到不好意思说"的算法,反而是很多线上系统的第一选择。
复杂的未必好,好用的未必复杂。这道理,费曼讲了一辈子,SAR 也身体力行了。
源码出处:Microsoft Recommenders 库,
recommenders/models/sar/sar_singlenode.py,MIT 协议。参考:SAR 原始论文 Index Sharing 及 Recommenders 官方文档。