起因
老板说"加个'你可能也喜欢' 推荐"。我们的数据:
- 用户 5 万
- 商品 1 万
- 用户-商品 浏览 / 购买记录 100 万条
不是 YouTube 级,深度学习 / DSSM / DeepFM 过于豪华。
传统协同过滤(implicit feedback ALS)几行代码就有 baseline,效果
比"按热度排序" 提升 30-50% CTR。下面是真实跑通流程。
解决方案
装
uv add implicit scipy pandas
implicit 库是 Ben Frederickson 写的 C++ ALS 实现,比 surprise /
spotlight 快 100x。
数据格式:sparse user-item matrix
import pandas as pd
from scipy.sparse import csr_matrix
events = pd.read_csv('user_events.csv')
# user_id,item_id,event_type
# 1234,567,view
# 1234,567,view
# 1234,789,purchase
# 隐式反馈权重:购买 > 加购 > 浏览
weights = {'view': 1, 'cart': 3, 'purchase': 10}
events['w'] = events['event_type'].map(weights)
# 聚合
agg = events.groupby(['user_id', 'item_id'])['w'].sum().reset_index()
# user_id,item_id,w
# 1234,567,2
# 1234,789,10
# 编码成连续整数索引
user_ids = agg['user_id'].unique()
item_ids = agg['item_id'].unique()
user_idx = {u: i for i, u in enumerate(user_ids)}
item_idx = {it: i for i, it in enumerate(item_ids)}
agg['ui'] = agg['user_id'].map(user_idx)
agg['ii'] = agg['item_id'].map(item_idx)
# sparse matrix
matrix = csr_matrix((agg['w'].values, (agg['ui'].values, agg['ii'].values)),
shape=(len(user_ids), len(item_ids)))
print(matrix.shape, matrix.nnz) # (50000, 10000) 1000000
ALS 训练
from implicit.als import AlternatingLeastSquares
model = AlternatingLeastSquares(
factors=64, # embedding 维度
regularization=0.01,
iterations=20,
alpha=40, # implicit feedback 信心权重
use_gpu=False, # GPU 可选
)
model.fit(matrix)
# 几秒钟完成
alpha 是 implicit ALS 的关键超参:
- 表示"我们对'用户买了'比'用户没买' 的信心差多少
- 论文建议 alpha=40 是好起点
- 调参可以 GridSearch on 验证集
给一个用户推荐 top-K
def recommend(user_id, k=10):
if user_id not in user_idx:
return [] # 冷启动
uid = user_idx[user_id]
item_ids_arr, scores = model.recommend(uid, matrix[uid], N=k)
return [(item_ids[i], float(s)) for i, s in zip(item_ids_arr, scores)]
print(recommend(user_id=1234, k=5))
# [(item_id, score), ...]
model.recommend() 内部:
- 拿用户 embedding (64 维)
- 跟所有 item embedding 算 dot product
- 排除已交互的 item
- 返回 top-K
毫秒级返回。
相似商品推荐("你看了这个,也可能看那个")
def similar_items(item_id, k=10):
if item_id not in item_idx:
return []
iid = item_idx[item_id]
item_ids_arr, scores = model.similar_items(iid, N=k+1)
# 第一个是它自己,跳过
return [(item_ids[i], float(s)) for i, s in zip(item_ids_arr[1:], scores[1:])]
商品详情页用:"看了这个的人还看了..."。
评估:split + 看 hit@K / MAP@K
import numpy as np
def hit_at_k(model, train_matrix, test_dict, k=10):
"""test_dict: {user_idx: set(item_idx)} 是 holdout 的真 interaction"""
hits = 0
total = 0
for uid, true_items in test_dict.items():
if uid >= train_matrix.shape[0]:
continue
recommended, _ = model.recommend(uid, train_matrix[uid], N=k)
if any(it in true_items for it in recommended):
hits += 1
total += 1
return hits / total
hit@10 = 0.20 意味着 20% 的用户 top-10 推荐里有真实喜欢的。
随机推荐 hit@10 ≈ k / num_items ≈ 0.001。
200x 提升 = 信号强 = 模型 work。
冷启动用户
新用户没历史 → 用户 embedding 不存在 → ALS 推不了。
fallback:
def recommend_with_fallback(user_id, k=10):
if user_id in user_idx:
return recommend(user_id, k)
# 冷启动:推热门
return popular_items_in_user_segment(user_id, k)
或者用 sign-up 时收集的偏好 / 人口学信息做内容-based 起步。
冷启动商品
新上架商品没 interaction → ALS 给不出 embedding。
解决:
- 用商品 metadata(类别 / 标签 / 描述)映射到现有 embedding 空间
- "two-tower" 模型:让 item tower 用 metadata 做 embedding
简单做法:新商品借用同类商品的 embedding 均值。
几个进阶 model
BM25 weighting
from implicit.nearest_neighbours import bm25_weight
weighted = bm25_weight(matrix, K1=100, B=0.8)
model = AlternatingLeastSquares(factors=64)
model.fit(weighted)
BM25 给罕见物品更高 weight,对长尾推荐更好。
Item-Item CF (用户少 + 商品多时)
from implicit.nearest_neighbours import CosineRecommender
model = CosineRecommender(K=10)
model.fit(matrix)
直接算 item-item 相似度,无 embedding 训练。
适合 100k+ items + 用户少场景。
BPR (Bayesian Personalized Ranking)
from implicit.bpr import BayesianPersonalizedRanking
model = BayesianPersonalizedRanking(factors=64, learning_rate=0.05)
model.fit(matrix)
learn-to-rank 风格,对"排序质量" 优化(不是 reconstruction)。
小数据集上经常更好。
部署到生产
# 训练完保存
import pickle
with open('als_model.pkl', 'wb') as f:
pickle.dump({
'model': model,
'user_idx': user_idx,
'item_idx': item_idx,
'item_ids': item_ids,
}, f)
# inference 服务
class RecAPI:
def __init__(self, path):
with open(path, 'rb') as f:
d = pickle.load(f)
self.model = d['model']
self.user_idx = d['user_idx']
self.item_ids = d['item_ids']
# ...
def recommend(self, user_id, k=10):
uid = self.user_idx.get(user_id)
if uid is None:
return []
item_arr, _ = self.model.recommend(uid, ...)
return [self.item_ids[i] for i in item_arr]
FastAPI 包一下,端口暴露给业务。
每日重训
cron / Airflow:
@task
def retrain_als():
events = load_recent_events(days=90)
matrix = build_matrix(events)
model = AlternatingLeastSquares(factors=64)
model.fit(matrix)
save_model(model, ...)
# 通知 inference 服务 reload
requests.post('http://rec-api/reload')
每天凌晨 1 点训。新 interaction 进入 embedding。
A/B test 验证
把 50% 用户走"推荐"接口,50% 走"按热度排"。
观察:
- CTR(点击率)
- conversion rate(转化率)
- 用户 session 时长
通常协同过滤 baseline 比"按热度" CTR 提升 30-100%。
与深度模型对比
| implicit ALS | LightFM | DeepFM | YouTube DNN | |
|---|---|---|---|---|
| 数据量需求 | 中(万级用户) | 中 | 大 | 极大 |
| 训练时间 | 秒-分钟 | 分钟 | 小时 | 天 |
| 冷启动支持 | 弱 | 中(hybrid) | 中 | 中 |
| 实施成本 | 极低 | 低 | 高 | 极高 |
| 效果上限 | 中等 | 中-高 | 高 | 极高 |
500 万以上交互 + 想榨干 5-10% CTR → 上深度模型。
否则 ALS 就够。
效果
我们小型电商上线 implicit ALS:
- 数据:5w 用户 + 1w SKU + 100w events
- 训练:30 秒(CPU)
- 推理:< 5ms / user
- A/B test:CTR +47%,conversion +18%
- vs 之前的"按销量排序"基线,显著改善
后续上 LightFM hybrid(加 metadata)再 +12%。
深度学习准备阶段,但 ALS 已经 cover 大头 ROI。
踩过的坑
-
matrix[uid]而不是matrix.getrow(uid):implicit 0.7 后 API 改了,
recommend(user, user_items)要传整行 sparse vector。 -
数据严重 popularity bias:只看热门,ALS embedding 也偏热门。
BM25 weight 缓解。 -
训练 / 推理使用不同 user_idx 映射 → 错位。永远 pickle 整套
{model, user_idx, item_idx, item_ids}。 -
新用户进来推不了:冷启动 fallback 一定要有,不然推荐 API
返空。 -
A/B test 早期看 metric 没差异:用户没注意新推荐位 / 流量太小
= 0 statistical power。至少 1 周 + 万级用户。
登录后参与评论。