☰
基于项目聚类与时间衰减的协同过滤推荐算法优化实践
2026/10/14 10:25:17 网站建设 项目流程

简介:这份资源是2018年夏季科研训练项目的完整实现包,面向推荐系统初学者与算法研究者,聚焦协同过滤中用户兴趣随时间漂移、推荐多样性不足两大痛点。项目将项目聚类与时间衰减两大策略融合:通过聚类把相似物品归组以挖掘潜在兴趣、拓宽推荐范围,借助艾宾浩斯遗忘曲线拟合时间衰减因子,让近期行为获得更高权重,从而提升推荐的时效性与人性化程度。压缩包共139个文件、约113.29MB,以67篇pdf参考文献、12个Python源码、6份dat数据集、6个doc实验记录及若干xml、pptx、png等为主,涵盖用户-项目交互矩阵构建、余弦相似度与皮尔逊相关系数计算、基于用户/项目的协同过滤建模、时间衰减因子应用及曲线拟合评估等关键环节。目前已有934人学习下载,适合希望从理论到代码完整复现改进型协同过滤算法、并借鉴科研项目组织方式的读者参考。

1. 当推荐系统开始「记性变差」:项目聚类与时间衰减到底在解决什么

做过推荐系统的同行大概都遇到过这种场景:离线指标 AUC 涨了两个点,信心满满上线,结果首页点击率反而掉了。排查一圈发现,用户三个月前点过的技术文章还在被反复推,新上线的项目却迟迟进不了召回池。这不是模型不行,是协同过滤的「记性」出了问题——它把用户所有历史行为一视同仁,既记不住兴趣会漂移,也分不清哪些项目本来就该归为一类。

基于项目聚类与时间衰减的协同过滤推荐算法,核心就干两件事:一是用聚类把相似项目先归堆,缓解稀疏矩阵里「邻居找不到」的尴尬;二是给交互行为加时间衰减权重,让近期行为说话更算数。它适合谁?适合手里有中等规模行为日志、跑过基础 ItemCF 或矩阵分解、但被冷启动和兴趣漂移折磨的团队。这套思路不依赖深度模型,单机就能复现,是性价比很高的一次改造。

2. 项目聚类怎么选:把相似度矩阵从稀疏变稠密

协同过滤最怕的就是用户-项目交互矩阵太稀疏。一个用户可能只碰过几百个项目,而项目总量是几万甚至几十万,算出来的项目相似度要么是零,要么是噪声。项目聚类的价值在于:先把项目按内容特征或共现关系聚成若干簇,簇内项目共享相似度信息,等于给稀疏矩阵做了一次「插值」。

2.1 特征工程:项目侧到底拿什么来聚类

聚类效果好不好,八成看特征。常见做法分两路:一路用项目的内容特征,比如文本类项目用 TF-IDF 或句向量,商品类项目用类目、品牌、价格分桶;另一路用行为共现特征,把「同一批用户都交互过的项目」当作相似信号。

我一般会两路都做,然后拼接。纯内容特征的问题是「看起来像但用户不买账」,纯共现特征的问题是冷门项目根本没有共现记录。拼接之后做归一化,能兼顾语义和真实行为。

import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.preprocessing import normalize # item_texts: 项目id -> 文本描述(标题+标签拼接) # cooc_matrix: 项目共现矩阵,shape=(n_items, n_items),来自用户行为 tfidf = TfidfVectorizer(max_features=5000, min_df=2) content_feat = tfidf.fit_transform(item_texts) # 内容特征 content_feat = normalize(content_feat) # L2归一化 # 共现矩阵做行归一化,避免热门项目主导 cooc_norm = normalize(cooc_matrix, norm='l1', axis=1) # 拼接:内容权重0.6,共现权重0.4,可调 combined = np.hstack([ content_feat.toarray() * 0.6, cooc_norm * 0.4 ])

这段代码的关键在权重分配。内容权重给太高,聚类会偏向语义相似但行为不相关的项目;共现权重给太高,热门项目会把整个簇带偏。0.6/0.4 是我在几个中等规模数据集上试出来的起点,实际项目里建议用轮廓系数扫一遍 0.3 到 0.7。

2.2 聚类算法选型:K-Means 不是唯一答案

很多人一提聚类就上 K-Means,但项目特征往往是高维稀疏的,K-Means 的欧氏距离在这种空间里区分度很差。我的经验是:

  • 项目数在 1 万以内、特征稠密,K-Means 够用,速度快;
  • 特征稀疏、簇形状不规则,用 MiniBatchKMeans 或 Birch,内存友好;
  • 想要层次结构、方便后续按粒度召回,用层次聚类,但要注意 O(n²) 的复杂度。

K 值怎么定?不要拍脑袋。用肘部法看畸变程度,或者直接看业务:如果你希望每个簇大概覆盖 50 到 200 个项目,用总数除一下就有个初值,再微调。

from sklearn.cluster import MiniBatchKMeans from sklearn.metrics import silhouette_score best_k, best_score = 0, -1 for k in [20, 40, 60, 80, 100]: km = MiniBatchKMeans(n_clusters=k, batch_size=1024, random_state=42) labels = km.fit_predict(combined) # 稀疏高维下轮廓系数会偏低,只做相对比较 score = silhouette_score(combined, labels, sample_size=2000) if score > best_score: best_k, best_score = k, score print(f"选定簇数: {best_k}, 轮廓系数: {best_score:.4f}")

这里有个坑要提前说:轮廓系数在高维稀疏数据上普遍偏低,0.1 到 0.3 都算正常,别看到 0.15 就以为聚类失败了。它只用来横向比较不同 K 值,不是绝对标准。

2.3 用簇中心做召回:把「找邻居」变成「找邻居簇」

聚类完成后,召回逻辑要改。传统 ItemCF 是「找和目标项目最相似的 K 个项目」,现在变成两步:先定位目标项目所属的簇,再在簇内找相似项目,簇间相似度用簇中心距离近似。

这样做的好处是召回池更稳定,不会因为某个项目临时爆火就把整个推荐列表带偏。代价是损失了一部分跨簇的惊喜推荐,所以实际系统里我一般保留 20% 的配额给全局相似度召回,剩下 80% 走簇内。

3. 时间衰减怎么设:让近期行为拿到该有的权重

时间衰减是这套算法的另一半。协同过滤默认所有历史行为等权,但用户兴趣是有半衰期的——三个月前收藏的项目,和昨天刚交互的项目,重要性显然不一样。时间衰减就是给每个交互乘一个随时间递减的系数。

3.1 指数衰减与半衰期:参数怎么定

最常用的是指数衰减:

weight = exp(-λ * Δt)

其中 Δt 是当前时间与交互时间的差值,λ 控制衰减速度。更直观的写法是用半衰期 T:

weight = 0.5 ^ (Δt / T)

半衰期 T 的含义是「经过 T 时间后,行为权重降到一半」。T 怎么定?看业务节奏:

业务类型建议半衰期理由
新闻资讯1~3 天热点生命周期短
短视频3~7 天兴趣漂移快
电商商品14~30 天购买决策周期长
技术课程30~90 天学习兴趣相对稳定

这个表是起点,不是标准答案。我一般会先按业务直觉定一个值,然后用线上 A/B 的点击率反推调整。

import math from datetime import datetime def time_decay_weight(interact_time, now_time, half_life_days): """ interact_time: 交互时间 datetime now_time: 当前时间 datetime half_life_days: 半衰期,单位天 """ delta_days = (now_time - interact_time).total_seconds() / 86400.0 if delta_days < 0: delta_days = 0 # 防止时间戳异常导致权重爆炸 weight = math.pow(0.5, delta_days / half_life_days) return weight # 示例:半衰期30天 now = datetime(2024, 6, 1) w_1day = time_decay_weight(datetime(2024, 5, 31), now, 30) # 约0.977 w_30day = time_decay_weight(datetime(2024, 5, 2), now, 30) # 0.5 w_90day = time_decay_weight(datetime(2024, 3, 3), now, 30) # 约0.125

注意那个delta_days < 0的保护。线上数据里时间戳错乱是家常便饭,不加这行,一个未来时间戳能让权重变成天文数字,直接把推荐结果带飞。

3.2 把衰减权重灌进相似度计算

有了项目聚类和时间衰减,相似度计算要同时用上两者。传统余弦相似度是:

sim(i, j) = Σ(u_i * u_j) / (||u_i|| * ||u_j||)

现在把每个交互乘上时间衰减权重 w:

sim(i, j) = Σ(w_u * r_ui * w_u * r_uj) / (||W·u_i|| * ||W·u_j||)

其中 W 是对角矩阵,对角线是每个交互的时间权重。实现上不用真的构造对角矩阵,逐元素乘就行。

import numpy as np def weighted_cosine_sim(interaction_matrix, time_weights): """ interaction_matrix: shape=(n_users, n_items),原始交互 time_weights: shape=(n_users, n_items),每个交互的时间衰减权重 """ weighted = interaction_matrix * time_weights # 逐元素加权 norms = np.linalg.norm(weighted, axis=0, keepdims=True) norms[norms == 0] = 1e-9 # 防止除零 normalized = weighted / norms sim_matrix = normalized.T @ normalized # 项目-项目相似度 np.fill_diagonal(sim_matrix, 0) # 自身相似度置零 return sim_matrix

这段代码里norms == 0的处理很关键。冷门项目可能所有交互权重都趋近于零,归一化时直接除零报错。给个极小值兜底,比加 epsilon 更稳。

3.3 聚类与衰减的融合顺序:先加权还是先聚类

这是个容易被忽略但影响很大的问题。我的做法是:先算时间衰减权重,用加权后的交互矩阵去算共现特征,再做聚类。理由是聚类应该反映「当前有效」的项目关系,而不是被陈旧行为污染。

如果反过来先聚类再加权,簇结构里混入了大量过期信息,衰减的效果会被稀释。这个顺序差异在离线评估里可能只差零点几个点,但线上长期跑下来,先加权的版本对新鲜内容的响应明显更快。

4. 离线评估与线上验证:指标怎么读才不骗自己

算法改完,最危险的动作就是只看一个 AUC 就上线。协同过滤的评估要分两层:离线看排序质量,线上看真实反馈。

4.1 离线指标:Recall@K 比 AUC 更贴近召回场景

AUC 衡量的是整体排序能力,但推荐系统真正关心的是「Top K 里有没有用户喜欢的」。所以离线阶段我优先看 Recall@K 和 NDCG@K。

def recall_at_k(recommended, ground_truth, k): """ recommended: 推荐列表,按分数降序 ground_truth: 用户真实交互集合 """ top_k = set(recommended[:k]) hit = len(top_k & set(ground_truth)) return hit / len(ground_truth) if ground_truth else 0.0 def ndcg_at_k(recommended, ground_truth, k): dcg = 0.0 for idx, item in enumerate(recommended[:k]): if item in ground_truth: dcg += 1.0 / math.log2(idx + 2) idcg = sum(1.0 / math.log2(i + 2) for i in range(min(len(ground_truth), k))) return dcg / idcg if idcg > 0 else 0.0

评估时要注意时间切分:用 T 时刻之前的行为训练,预测 T 到 T+Δ 之间的交互。随机切分会导致时间泄漏,离线指标虚高,上线就翻车。这是血泪经验,别问我怎么知道的。

4.2 线上 A/B:关注长期指标而非单日点击

线上验证至少跑两周。第一周看点击率、转化率这些短期指标,第二周重点看留存和人均交互深度。时间衰减类算法的一个典型现象是:短期点击可能持平甚至微降,因为推荐结果变「保守」了,但用户留存和长期活跃会改善。

如果只跑三天就下结论,很容易把一个正确的方向毙掉。我一般会设一个「探索配额」,比如 10% 的流量走无衰减的基线,用来对比长期效果。

5. 避坑指南:这套算法最容易翻车的五个地方

5.1 现象:聚类后推荐结果高度同质化

原因:K 值设得太小,或者内容特征权重过高,导致一个簇里全是语义相近的项目,召回多样性丧失。

解决:把 K 值调大,或者降低内容特征权重、提高共现权重。另外在召回阶段强制保留跨簇配额,比如每个用户至少 20% 的推荐来自非目标簇。

5.2 现象:时间衰减开了之后,新用户推荐质量暴跌

原因:新用户交互本来就少,衰减一乘,有效信号更稀疏,相似度计算几乎全是零。

解决:对新用户做衰减豁免,或者用全局热门做兜底召回。我的做法是交互数少于 5 条的用户,时间权重统一设为 1.0,不做衰减。

5.3 现象:离线指标涨了,线上点击率没动

原因:离线评估用了随机切分,时间泄漏导致指标虚高;或者离线用的半衰期和线上实际业务节奏不匹配。

解决:改成时间切分重新评估,同时用线上数据反推半衰期。具体做法是拿几组不同半衰期的配置跑小流量,看哪组的次日留存最好。

5.4 现象:相似度矩阵计算越来越慢

原因:项目数增长后,稠密相似度矩阵内存爆炸。1 万个项目的矩阵就是 1 亿个浮点数,10 万项目直接没法存。

解决:不要存全量矩阵。用稀疏存储,或者只保留每个项目的 Top 200 邻居。聚类之后,簇内相似度可以实时算,簇间用簇中心近似,内存占用能降一个数量级。

5.5 现象:时间戳异常导致权重计算溢出

原因:日志里混入了未来时间戳或空值,衰减公式里 Δt 为负,指数函数直接爆炸。

解决:在权重函数入口做双重保护——空值填默认时间,负值截断为零。这个保护必须放在最底层,别指望上游数据清洗能百分百干净。

6. 一个可复现的最小验证脚本与调参习惯

如果你想把整套流程跑通,我建议从一个最小闭环开始:构造模拟交互数据,跑通「加权 → 聚类 → 相似度 → 召回 → 评估」五步,再往真实数据上迁。

import numpy as np from sklearn.cluster import MiniBatchKMeans np.random.seed(42) # 1. 模拟:200用户 x 500项目,稀疏交互 n_users, n_items = 200, 500 interaction = (np.random.rand(n_users, n_items) < 0.05).astype(float) # 2. 时间权重:模拟每个交互的时间衰减 time_weights = np.random.uniform(0.1, 1.0, size=(n_users, n_items)) weighted = interaction * time_weights # 3. 项目聚类:用加权后的转置矩阵做特征 item_feat = weighted.T # shape=(n_items, n_users) km = MiniBatchKMeans(n_clusters=20, random_state=42) item_labels = km.fit_predict(item_feat) # 4. 簇内相似度 + 全局相似度混合召回 def recommend(user_idx, top_k=10): scores = weighted[user_idx] @ weighted.T # 简化版打分 # 簇内加权:同簇项目分数乘1.2 user_cluster = item_labels[np.argmax(weighted[user_idx])] for i in range(n_items): if item_labels[i] == user_cluster: scores[i] *= 1.2 return np.argsort(scores)[::-1][:top_k] # 5. 简单评估 recs = recommend(0, top_k=10) print("用户0的推荐列表:", recs) print("所属簇分布:", item_labels[recs])

这个脚本的价值不在精度,而在让你快速看到聚类和衰减如何影响召回结果。跑通之后,把模拟数据换成真实日志,把简化打分换成加权余弦相似度,就是一套可上线的原型。

调参上我有个习惯:每次只动一个参数,记录三组数据——离线 Recall@20、线上点击率、次日留存。三个指标同向变化才认为改动有效,只有一个涨就继续观察。这套习惯帮我避开了很多「离线涨线上跌」的玄学问题。

最后说一句实在的:项目聚类和时间衰减都不是银弹,它们解决的是协同过滤在稀疏和漂移场景下的两个具体痛点。如果你的数据本身稠密、用户兴趣稳定,上这套反而增加复杂度。判断标准很简单——先看你的推荐结果里,过期内容和冷门项目占比高不高,高就值得做,不高就先别折腾。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询