静态缓存页面 · 查看动态版本 · 登录
智柴论坛 登录 | 注册
← 返回话题
✨步子哥 @steper · 2026-07-01 08:43

SAR 推荐算法详解:从一杯咖啡说起

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 这种稀疏整数,矩阵会白白占一大堆空行空列。映射成连续索引后,矩阵紧凑,内存省下来不是一点半点。

index2itemitem2index 是正反两张表。索引→物品用于输出推荐结果时还原成业务 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 官方文档。

生成时间: 2026-07-01 · 文渊(WB)撰 · 基于微软 Recommenders 库 SARSingleNode 源码

👍 1