引言
推荐系统是 Netflix 电影推荐、Amazon 商品推荐、Spotify 音乐推荐等现代数字服务的核心。据说 Netflix 仅凭推荐系统,每年就能创造超过 10 亿美元的价值。
本指南提供从经典协同过滤到图神经网络、再到基于 LLM 的推荐的完整旅程。每个章节都配有可运行的 PyTorch 代码。
1. 推荐系统基础
1.1 推荐系统的三种类型
协同过滤(Collaborative Filtering) 利用相似用户或物品的模式。
- 基于用户:"和你相似的人也喜欢这个"
- 基于物品:"和你喜欢过的物品相似的物品"
基于内容的过滤(Content-Based Filtering) 分析物品的属性(类型、导演、简介等)。
- 也能为新物品生成推荐(缓解冷启动问题)
- 特征工程质量很重要
混合方法 结合两种方式,弥补各自的短板。
1.2 隐式反馈 vs 显式反馈
显式反馈: 评分、点赞、点踩 — 意图明确,但稀疏(sparse) 隐式反馈: 点击、浏览时长、购买记录 — 丰富,但噪声较多
在实际服务中,隐式反馈要丰富得多,因此更常被使用。
1.3 评估指标
import numpy as np
from sklearn.metrics import ndcg_score
def precision_at_k(recommended, relevant, k):
"""Precision@K: 前 K 个中相关物品的比例"""
rec_k = recommended[:k]
hits = len(set(rec_k) & set(relevant))
return hits / k
def recall_at_k(recommended, relevant, k):
"""Recall@K: 全部相关物品中,在前 K 个里找到的比例"""
rec_k = recommended[:k]
hits = len(set(rec_k) & set(relevant))
return hits / len(relevant) if relevant else 0
def average_precision_at_k(recommended, relevant, k):
"""AP@K: Precision@k 的累积平均(反映排名)"""
if not relevant:
return 0.0
hits = 0
sum_prec = 0.0
for i, item in enumerate(recommended[:k]):
if item in relevant:
hits += 1
sum_prec += hits / (i + 1)
return sum_prec / min(len(relevant), k)
def ndcg_at_k(recommended, relevant, k):
"""NDCG@K: Normalized Discounted Cumulative Gain"""
relevance = [1 if item in relevant else 0 for item in recommended[:k]]
if not any(relevance):
return 0.0
# DCG
dcg = sum(rel / np.log2(i + 2) for i, rel in enumerate(relevance))
# Ideal DCG
ideal = sorted(relevance, reverse=True)
idcg = sum(rel / np.log2(i + 2) for i, rel in enumerate(ideal))
return dcg / idcg if idcg > 0 else 0.0
# 示例评估
recommended = [1, 4, 7, 2, 9, 3, 5, 6, 8, 10] # 推荐的物品 ID
relevant = {1, 2, 5, 7, 8} # 实际相关物品
print("=" * 40)
print("推荐系统评估指标")
print("=" * 40)
for k in [5, 10]:
p = precision_at_k(recommended, relevant, k)
r = recall_at_k(recommended, relevant, k)
ap = average_precision_at_k(recommended, relevant, k)
n = ndcg_at_k(recommended, relevant, k)
print(f"\nk = {k}")
print(f" Precision@{k}: {p:.4f}")
print(f" Recall@{k}: {r:.4f}")
print(f" AP@{k}: {ap:.4f}")
print(f" NDCG@{k}: {n:.4f}")
2. 矩阵分解 (Matrix Factorization)
2.1 基本概念
矩阵分解将用户-物品交互矩阵 R 分解为两个低维矩阵。
R ≈ U × V^T
- U: 用户嵌入矩阵 (n_users × k)
- V: 物品嵌入矩阵 (n_items × k)
- k: 潜在因子(latent factor)数量
2.2 用 PyTorch 实现 Matrix Factorization
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
# 生成 MovieLens 100K 模拟数据
def generate_movielens_like_data(n_users=1000, n_items=500, n_ratings=50000):
np.random.seed(42)
# 模拟用户和物品的潜在特征
k = 20 # 潜在因子数量
user_factors = np.random.randn(n_users, k) * 0.5
item_factors = np.random.randn(n_items, k) * 0.5
# 真实偏好 = 潜在因子的内积
true_ratings = user_factors @ item_factors.T
# 转换为 1-5 的量表
true_ratings = (true_ratings - true_ratings.min()) / (true_ratings.max() - true_ratings.min()) * 4 + 1
# 通过随机采样生成观测数据
user_ids = np.random.choice(n_users, n_ratings)
item_ids = np.random.choice(n_items, n_ratings)
ratings = true_ratings[user_ids, item_ids] + np.random.randn(n_ratings) * 0.3
ratings = np.clip(ratings, 1, 5)
df = pd.DataFrame({'user_id': user_ids, 'item_id': item_ids, 'rating': ratings})
df = df.drop_duplicates(subset=['user_id', 'item_id'])
return df
# 加载数据
ratings_df = generate_movielens_like_data()
print(f"总评分数: {len(ratings_df)}")
print(f"用户数: {ratings_df['user_id'].nunique()}")
print(f"物品数: {ratings_df['item_id'].nunique()}")
print(f"评分分布:\n{ratings_df['rating'].describe()}")
# 划分训练/测试集
train_df, test_df = train_test_split(ratings_df, test_size=0.2, random_state=42)
n_users = ratings_df['user_id'].max() + 1
n_items = ratings_df['item_id'].max() + 1
class RatingsDataset(Dataset):
def __init__(self, df):
self.users = torch.LongTensor(df['user_id'].values)
self.items = torch.LongTensor(df['item_id'].values)
self.ratings = torch.FloatTensor(df['rating'].values)
def __len__(self):
return len(self.ratings)
def __getitem__(self, idx):
return self.users[idx], self.items[idx], self.ratings[idx]
class MatrixFactorization(nn.Module):
"""基本矩阵分解模型"""
def __init__(self, n_users, n_items, n_factors=50):
super().__init__()
self.user_embedding = nn.Embedding(n_users, n_factors)
self.item_embedding = nn.Embedding(n_items, n_factors)
# 偏置项
self.user_bias = nn.Embedding(n_users, 1)
self.item_bias = nn.Embedding(n_items, 1)
# 全局平均
self.global_bias = nn.Parameter(torch.zeros(1))
# 初始化嵌入
nn.init.normal_(self.user_embedding.weight, mean=0, std=0.01)
nn.init.normal_(self.item_embedding.weight, mean=0, std=0.01)
nn.init.zeros_(self.user_bias.weight)
nn.init.zeros_(self.item_bias.weight)
def forward(self, user_ids, item_ids):
user_emb = self.user_embedding(user_ids)
item_emb = self.item_embedding(item_ids)
# 用内积做基础预测
dot_product = (user_emb * item_emb).sum(dim=1)
# 加上偏置
u_bias = self.user_bias(user_ids).squeeze()
i_bias = self.item_bias(item_ids).squeeze()
prediction = dot_product + u_bias + i_bias + self.global_bias
return prediction
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
mf_model = MatrixFactorization(n_users, n_items, n_factors=64).to(device)
train_loader = DataLoader(RatingsDataset(train_df), batch_size=512, shuffle=True)
test_loader = DataLoader(RatingsDataset(test_df), batch_size=512, shuffle=False)
optimizer = optim.Adam(mf_model.parameters(), lr=1e-3, weight_decay=1e-5)
criterion = nn.MSELoss()
def train_rating_model(model, loader, optimizer, criterion, device):
model.train()
total_loss = 0
for users, items, ratings in loader:
users, items, ratings = users.to(device), items.to(device), ratings.to(device)
optimizer.zero_grad()
pred = model(users, items)
loss = criterion(pred, ratings)
loss.backward()
optimizer.step()
total_loss += loss.item() * len(ratings)
return (total_loss / len(loader.dataset)) ** 0.5 # RMSE
def evaluate_rating_model(model, loader, device):
model.eval()
all_preds, all_targets = [], []
with torch.no_grad():
for users, items, ratings in loader:
users, items = users.to(device), items.to(device)
pred = model(users, items).cpu().numpy()
all_preds.extend(pred)
all_targets.extend(ratings.numpy())
preds = np.array(all_preds)
targets = np.array(all_targets)
rmse = np.sqrt(((preds - targets)**2).mean())
mae = np.abs(preds - targets).mean()
return rmse, mae
for epoch in range(30):
train_rmse = train_rating_model(mf_model, train_loader, optimizer, criterion, device)
if (epoch + 1) % 10 == 0:
test_rmse, test_mae = evaluate_rating_model(mf_model, test_loader, device)
print(f"Epoch {epoch+1:2d} | Train RMSE: {train_rmse:.4f} | Test RMSE: {test_rmse:.4f} | MAE: {test_mae:.4f}")
2.3 BPR (Bayesian Personalized Ranking)
BPR 是专为隐式反馈设计的学习方法,它假设用户更偏好自己交互过的物品,而非未交互过的物品。
class BPRModel(nn.Module):
"""用 BPR 损失训练的矩阵分解"""
def __init__(self, n_users, n_items, n_factors=64):
super().__init__()
self.user_embedding = nn.Embedding(n_users, n_factors)
self.item_embedding = nn.Embedding(n_items, n_factors)
nn.init.normal_(self.user_embedding.weight, 0, 0.01)
nn.init.normal_(self.item_embedding.weight, 0, 0.01)
def forward(self, user_ids, pos_item_ids, neg_item_ids):
user_emb = self.user_embedding(user_ids)
pos_emb = self.item_embedding(pos_item_ids)
neg_emb = self.item_embedding(neg_item_ids)
pos_score = (user_emb * pos_emb).sum(dim=1)
neg_score = (user_emb * neg_emb).sum(dim=1)
return pos_score, neg_score
def predict(self, user_ids, item_ids):
user_emb = self.user_embedding(user_ids)
item_emb = self.item_embedding(item_ids)
return (user_emb * item_emb).sum(dim=1)
def bpr_loss(pos_score, neg_score, reg_lambda=1e-5, model=None):
"""BPR 损失 = -log(sigmoid(pos - neg)) + 正则化"""
loss = -torch.log(torch.sigmoid(pos_score - neg_score)).mean()
if model and reg_lambda > 0:
reg = sum(p.norm(2) for p in model.parameters())
loss += reg_lambda * reg
return loss
# BPR 用数据集(正样本 + 随机负采样)
class BPRDataset(Dataset):
def __init__(self, df, n_items):
self.users = df['user_id'].values
self.pos_items = df['item_id'].values
self.n_items = n_items
# 记录每个用户交互过的物品
self.user_items = df.groupby('user_id')['item_id'].apply(set).to_dict()
def __len__(self):
return len(self.users)
def __getitem__(self, idx):
user = self.users[idx]
pos = self.pos_items[idx]
# 采样负样本物品(未交互过的物品)
neg = np.random.randint(self.n_items)
while neg in self.user_items.get(user, set()):
neg = np.random.randint(self.n_items)
return torch.LongTensor([user])[0], torch.LongTensor([pos])[0], torch.LongTensor([neg])[0]
bpr_train_dataset = BPRDataset(train_df, n_items)
bpr_loader = DataLoader(bpr_train_dataset, batch_size=512, shuffle=True)
bpr_model = BPRModel(n_users, n_items, n_factors=64).to(device)
bpr_optimizer = optim.Adam(bpr_model.parameters(), lr=1e-3)
for epoch in range(20):
bpr_model.train()
total_loss = 0
for users, pos_items, neg_items in bpr_loader:
users, pos_items, neg_items = users.to(device), pos_items.to(device), neg_items.to(device)
bpr_optimizer.zero_grad()
pos_score, neg_score = bpr_model(users, pos_items, neg_items)
loss = bpr_loss(pos_score, neg_score, model=bpr_model)
loss.backward()
bpr_optimizer.step()
total_loss += loss.item()
if (epoch + 1) % 5 == 0:
print(f"BPR Epoch {epoch+1:2d} | Loss: {total_loss/len(bpr_loader):.4f}")
3. Neural Collaborative Filtering (NCF)
3.1 NCF 架构
NCF 是把矩阵分解扩展为深度学习的模型,它结合了两个组件。
GMF (Generalized Matrix Factorization): 嵌入的逐元素乘积(MF 的一般化) MLP (Multi-Layer Perceptron): 对嵌入的连接(concatenation)做非线性变换
class NCF(nn.Module):
"""
Neural Collaborative Filtering
He et al., 2017 (arxiv.org/abs/1708.05031)
"""
def __init__(self, n_users, n_items, n_factors=64, mlp_dims=None, dropout=0.2):
super().__init__()
if mlp_dims is None:
mlp_dims = [256, 128, 64]
# GMF 嵌入
self.gmf_user = nn.Embedding(n_users, n_factors)
self.gmf_item = nn.Embedding(n_items, n_factors)
# MLP 嵌入(使用独立的嵌入)
self.mlp_user = nn.Embedding(n_users, n_factors)
self.mlp_item = nn.Embedding(n_items, n_factors)
# 构建 MLP 层
mlp_layers = []
input_size = n_factors * 2
for dim in mlp_dims:
mlp_layers.extend([
nn.Linear(input_size, dim),
nn.BatchNorm1d(dim),
nn.ReLU(),
nn.Dropout(dropout)
])
input_size = dim
self.mlp = nn.Sequential(*mlp_layers)
# 结合 GMF 与 MLP 后的最终预测
self.output_layer = nn.Linear(n_factors + mlp_dims[-1], 1)
# 初始化嵌入
for emb in [self.gmf_user, self.gmf_item, self.mlp_user, self.mlp_item]:
nn.init.normal_(emb.weight, 0, 0.01)
def forward(self, user_ids, item_ids):
# GMF 路径
gmf_u = self.gmf_user(user_ids)
gmf_i = self.gmf_item(item_ids)
gmf_out = gmf_u * gmf_i # 逐元素乘积 (n_factors)
# MLP 路径
mlp_u = self.mlp_user(user_ids)
mlp_i = self.mlp_item(item_ids)
mlp_in = torch.cat([mlp_u, mlp_i], dim=1) # (batch, 2*n_factors)
mlp_out = self.mlp(mlp_in) # (batch, mlp_dims[-1])
# 合并
combined = torch.cat([gmf_out, mlp_out], dim=1)
output = torch.sigmoid(self.output_layer(combined)).squeeze()
return output
# 隐式反馈数据集(是否点击: 0 或 1)
class ImplicitDataset(Dataset):
def __init__(self, pos_df, n_items, neg_ratio=4):
self.users = []
self.items = []
self.labels = []
self.n_items = n_items
user_items = pos_df.groupby('user_id')['item_id'].apply(set).to_dict()
for _, row in pos_df.iterrows():
user, item = row['user_id'], row['item_id']
# 正样本
self.users.append(user)
self.items.append(item)
self.labels.append(1.0)
# 负样本
for _ in range(neg_ratio):
neg = np.random.randint(n_items)
while neg in user_items.get(user, set()):
neg = np.random.randint(n_items)
self.users.append(user)
self.items.append(neg)
self.labels.append(0.0)
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
return (torch.LongTensor([self.users[idx]])[0],
torch.LongTensor([self.items[idx]])[0],
torch.FloatTensor([self.labels[idx]])[0])
# 生成隐式数据集(评分 >= 3.5 视为正样本)
implicit_train = train_df[train_df['rating'] >= 3.5].copy()
implicit_dataset = ImplicitDataset(implicit_train, n_items, neg_ratio=4)
implicit_loader = DataLoader(implicit_dataset, batch_size=1024, shuffle=True)
ncf_model = NCF(n_users, n_items, n_factors=64, mlp_dims=[256, 128, 64]).to(device)
ncf_optimizer = optim.Adam(ncf_model.parameters(), lr=1e-3, weight_decay=1e-5)
bce_loss = nn.BCELoss()
print(f"NCF 参数数量: {sum(p.numel() for p in ncf_model.parameters()):,}")
for epoch in range(20):
ncf_model.train()
total_loss = 0
for users, items, labels in implicit_loader:
users, items, labels = users.to(device), items.to(device), labels.to(device)
ncf_optimizer.zero_grad()
pred = ncf_model(users, items)
loss = bce_loss(pred, labels)
loss.backward()
ncf_optimizer.step()
total_loss += loss.item()
if (epoch + 1) % 5 == 0:
print(f"NCF Epoch {epoch+1:2d} | Loss: {total_loss/len(implicit_loader):.4f}")
4. Two-Tower 模型
4.1 架构概览
Two-Tower 模型(Dual Encoder / Bi-Encoder)分别独立训练用户塔和物品塔。两个塔的嵌入相似度被用作分数。
在大规模服务中的优势:
- 可以预先计算物品嵌入(离线)
- 快速的近似最近邻(ANN)检索
- 可扩展到数十亿个物品
YouTube、Google、Spotify、Pinterest 等大型服务都在使用它。
class UserTower(nn.Module):
"""用户塔: 将用户特征转换为嵌入"""
def __init__(self, n_users, user_feature_dim, embed_dim=128, hidden_dims=None):
super().__init__()
if hidden_dims is None:
hidden_dims = [256, 128]
# ID 嵌入
self.id_embedding = nn.Embedding(n_users, embed_dim)
# 特征处理网络
layers = []
input_dim = embed_dim + user_feature_dim
for h in hidden_dims:
layers.extend([nn.Linear(input_dim, h), nn.LayerNorm(h), nn.ReLU(), nn.Dropout(0.1)])
input_dim = h
layers.append(nn.Linear(input_dim, embed_dim))
self.network = nn.Sequential(*layers)
def forward(self, user_ids, user_features):
id_emb = self.id_embedding(user_ids)
combined = torch.cat([id_emb, user_features], dim=1)
return nn.functional.normalize(self.network(combined), dim=-1) # L2 归一化
class ItemTower(nn.Module):
"""物品塔: 将物品特征转换为嵌入"""
def __init__(self, n_items, item_feature_dim, embed_dim=128, hidden_dims=None):
super().__init__()
if hidden_dims is None:
hidden_dims = [256, 128]
self.id_embedding = nn.Embedding(n_items, embed_dim)
layers = []
input_dim = embed_dim + item_feature_dim
for h in hidden_dims:
layers.extend([nn.Linear(input_dim, h), nn.LayerNorm(h), nn.ReLU(), nn.Dropout(0.1)])
input_dim = h
layers.append(nn.Linear(input_dim, embed_dim))
self.network = nn.Sequential(*layers)
def forward(self, item_ids, item_features):
id_emb = self.id_embedding(item_ids)
combined = torch.cat([id_emb, item_features], dim=1)
return nn.functional.normalize(self.network(combined), dim=-1)
class TwoTowerModel(nn.Module):
"""结合两个塔的完整模型"""
def __init__(self, n_users, n_items, user_feat_dim, item_feat_dim, embed_dim=128):
super().__init__()
self.user_tower = UserTower(n_users, user_feat_dim, embed_dim)
self.item_tower = ItemTower(n_items, item_feat_dim, embed_dim)
self.temperature = nn.Parameter(torch.tensor(0.07))
def forward(self, user_ids, user_features, item_ids, item_features):
user_emb = self.user_tower(user_ids, user_features)
item_emb = self.item_tower(item_ids, item_features)
return user_emb, item_emb
def compute_similarity(self, user_emb, item_emb):
"""用于批内负样本(in-batch negative)InfoNCE 损失的相似度矩阵"""
return torch.matmul(user_emb, item_emb.T) / self.temperature.exp()
def info_nce_loss(similarity_matrix):
"""使用批内负采样(in-batch negative sampling)的 InfoNCE 损失"""
batch_size = similarity_matrix.size(0)
labels = torch.arange(batch_size, device=similarity_matrix.device)
loss = nn.CrossEntropyLoss()(similarity_matrix, labels)
return loss
# 生成临时特征数据
user_feature_dim = 16
item_feature_dim = 32
np.random.seed(42)
user_features = torch.FloatTensor(np.random.randn(n_users, user_feature_dim))
item_features = torch.FloatTensor(np.random.randn(n_items, item_feature_dim))
two_tower = TwoTowerModel(n_users, n_items, user_feature_dim, item_feature_dim, embed_dim=128).to(device)
print(f"Two-Tower 参数: {sum(p.numel() for p in two_tower.parameters()):,}")
# 推理时预先计算物品嵌入
def precompute_item_embeddings(model, n_items, item_features, batch_size=256, device='cpu'):
"""预先计算全部物品嵌入(为了提升服务时的效率)"""
model.eval()
all_item_embs = []
with torch.no_grad():
for start in range(0, n_items, batch_size):
end = min(start + batch_size, n_items)
ids = torch.arange(start, end, device=device)
feat = item_features[start:end].to(device)
emb = model.item_tower(ids, feat)
all_item_embs.append(emb.cpu())
return torch.cat(all_item_embs, dim=0)
item_emb_cache = precompute_item_embeddings(two_tower, n_items, item_features, device=device)
print(f"预先计算的物品嵌入形状: {item_emb_cache.shape}")
4.2 用 Faiss 做近似最近邻检索
def demo_faiss_search():
"""
使用 Faiss 的 ANN 检索示例
安装: pip install faiss-cpu (或 faiss-gpu)
"""
usage_note = """
import faiss
embed_dim = 128
n_items = 1000000 # 100万个物品
# 创建 FAISS 索引
index = faiss.IndexFlatIP(embed_dim) # 基于内积(IP)的检索
# 或近似检索(更快):
# index = faiss.IndexIVFFlat(faiss.IndexFlatIP(embed_dim), embed_dim, 100)
# index.train(item_embeddings)
# 添加物品嵌入(推荐使用 L2 归一化后的向量)
item_embeddings = item_emb_cache.numpy().astype('float32')
faiss.normalize_L2(item_embeddings)
index.add(item_embeddings)
# 检索用户查询
user_query = user_emb.numpy().astype('float32')
faiss.normalize_L2(user_query)
k = 100 # Top-K 检索
scores, indices = index.search(user_query, k)
print(f"Top-{k} 推荐物品: {indices[0]}")
print(f"相似度分数: {scores[0]}")
"""
print("Faiss ANN 检索:")
print(" - IndexFlatIP: 精确的内积检索(小规模)")
print(" - IndexIVFFlat: 倒排文件索引(中规模)")
print(" - IndexHNSW: 分层图(大规模,速度快)")
print(" - IndexPQ: 乘积量化(内存高效)")
demo_faiss_search()
5. 基于序列的推荐
5.1 SASRec (Self-Attentive Sequential Recommendation)
SASRec 使用 Transformer 的 Self-Attention,从用户行为序列中挑选出重要的物品。
class SASRecBlock(nn.Module):
"""SASRec Transformer 模块"""
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
self.attention = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True)
self.feed_forward = nn.Sequential(
nn.Linear(d_model, d_model * 4),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(d_model * 4, d_model),
nn.Dropout(dropout)
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
def forward(self, x, attention_mask=None):
# 因果掩码(避免看到未来的物品)
seq_len = x.size(1)
causal_mask = torch.triu(torch.ones(seq_len, seq_len, device=x.device), diagonal=1).bool()
# Self-Attention + 残差连接
attn_out, _ = self.attention(x, x, x, attn_mask=causal_mask)
x = self.norm1(x + attn_out)
# FFN + 残差连接
x = self.norm2(x + self.feed_forward(x))
return x
class SASRec(nn.Module):
"""
SASRec: Self-Attentive Sequential Recommendation
Kang & McAuley, 2018 (arxiv.org/abs/1808.09781)
"""
def __init__(self, n_items, max_seq_len, d_model=128, n_heads=4,
num_layers=2, dropout=0.1):
super().__init__()
self.item_embedding = nn.Embedding(n_items + 1, d_model, padding_idx=0) # 0 用于填充
self.pos_embedding = nn.Embedding(max_seq_len, d_model)
self.blocks = nn.ModuleList([SASRecBlock(d_model, n_heads, dropout) for _ in range(num_layers)])
self.dropout = nn.Dropout(dropout)
self.norm = nn.LayerNorm(d_model)
self.d_model = d_model
self.max_seq = max_seq_len
def forward(self, item_seq):
"""
item_seq: (batch, seq_len) — 物品 ID 序列(0 为填充)
Returns: (batch, seq_len, d_model) — 每个位置的表示
"""
seq_len = item_seq.size(1)
positions = torch.arange(seq_len, device=item_seq.device).unsqueeze(0)
x = self.item_embedding(item_seq) + self.pos_embedding(positions)
x = self.dropout(x)
for block in self.blocks:
x = block(x)
return self.norm(x)
def predict(self, item_seq, candidate_item_ids):
"""
预测给定序列之后会出现的物品
Args:
item_seq: (batch, seq_len)
candidate_item_ids: (batch, n_candidates)
Returns:
scores: (batch, n_candidates)
"""
seq_repr = self.forward(item_seq)
# 使用最后一个非填充位置的表示
last_repr = seq_repr[:, -1, :] # (batch, d_model)
cand_emb = self.item_embedding(candidate_item_ids) # (batch, n_cand, d_model)
scores = (last_repr.unsqueeze(1) * cand_emb).sum(-1) # (batch, n_cand)
return scores
# 生成序列数据集
class SequentialDataset(Dataset):
def __init__(self, ratings_df, max_seq_len=50, min_seq_len=5):
self.max_seq_len = max_seq_len
self.sequences = []
# 按用户构建物品序列(按时间顺序)
user_sequences = ratings_df.sort_values('user_id').groupby('user_id')['item_id'].apply(list)
for user_id, items in user_sequences.items():
if len(items) < min_seq_len:
continue
# 把最后一个物品当作目标,其余作为输入
for i in range(min_seq_len, len(items) + 1):
seq = items[max(0, i-max_seq_len-1):i-1]
target = items[i-1]
# 填充(左侧填充,使最新物品位于右侧)
padded_seq = [0] * (max_seq_len - len(seq)) + seq
padded_seq = padded_seq[-max_seq_len:] # 截断到 max_seq_len
self.sequences.append((padded_seq, target + 1)) # +1 (0 用于填充)
def __len__(self):
return len(self.sequences)
def __getitem__(self, idx):
seq, target = self.sequences[idx]
return torch.LongTensor(seq), torch.LongTensor([target])[0]
# 创建模型
sasrec = SASRec(
n_items=n_items,
max_seq_len=50,
d_model=128,
n_heads=4,
num_layers=2
).to(device)
print(f"SASRec 参数: {sum(p.numel() for p in sasrec.parameters()):,}")
5.2 BERT4Rec
BERT4Rec 把 BERT 的掩码语言模型(MLM)应用到序列推荐中。它随机遮蔽物品并预测,从而学习双向上下文。
class BERT4Rec(nn.Module):
"""
BERT4Rec: Sequential Recommendation with BERT
Sun et al., 2019
"""
def __init__(self, n_items, max_seq_len, d_model=256, n_heads=4,
num_layers=2, dropout=0.1, mask_prob=0.15):
super().__init__()
self.mask_token = n_items + 1 # 掩码 token ID
self.n_items = n_items
self.mask_prob = mask_prob
self.item_embedding = nn.Embedding(n_items + 2, d_model, padding_idx=0)
self.pos_embedding = nn.Embedding(max_seq_len, d_model)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=n_heads,
dim_feedforward=d_model*4, dropout=dropout, batch_first=True
)
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
self.norm = nn.LayerNorm(d_model)
self.output = nn.Linear(d_model, n_items + 2)
def forward(self, item_seq):
seq_len = item_seq.size(1)
positions = torch.arange(seq_len, device=item_seq.device).unsqueeze(0)
x = self.item_embedding(item_seq) + self.pos_embedding(positions)
x = self.transformer(x)
x = self.norm(x)
return self.output(x)
def mask_sequence(self, item_seq):
"""训练时的随机掩码"""
masked_seq = item_seq.clone()
mask = (torch.rand_like(item_seq.float()) < self.mask_prob) & (item_seq != 0)
masked_seq[mask] = self.mask_token
return masked_seq, mask
6. 基于图的推荐: LightGCN
6.1 LightGCN 架构
LightGCN(Light Graph Convolution Network)在用户-物品二分图上通过消息传递学习高阶连接性。它去掉了不必要的组件(特征变换、非线性激活),因此更加高效。
class LightGCN(nn.Module):
"""
LightGCN: Simplifying and Powering Graph Convolution Network
He et al., 2020 (arxiv.org/abs/2202.01151)
"""
def __init__(self, n_users, n_items, embed_dim=64, n_layers=3):
super().__init__()
self.n_users = n_users
self.n_items = n_items
self.n_layers = n_layers
self.embed_dim = embed_dim
# 初始化嵌入
self.user_embedding = nn.Embedding(n_users, embed_dim)
self.item_embedding = nn.Embedding(n_items, embed_dim)
nn.init.normal_(self.user_embedding.weight, std=0.1)
nn.init.normal_(self.item_embedding.weight, std=0.1)
def compute_normalized_adj(self, interactions, device):
"""
计算归一化的邻接矩阵
A_hat = D^(-1/2) * A * D^(-1/2)
"""
n_nodes = self.n_users + self.n_items
# 把用户-物品交互转换为边
user_ids = interactions[:, 0]
item_ids = interactions[:, 1] + self.n_users
row = torch.cat([user_ids, item_ids])
col = torch.cat([item_ids, user_ids])
edge_index = torch.stack([row, col]).to(device)
# 计算度数
deg = torch.zeros(n_nodes, device=device)
deg.scatter_add_(0, row, torch.ones(len(row), device=device))
# 计算 D^(-1/2)
deg_inv_sqrt = deg.pow(-0.5)
deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
# 归一化权重
edge_weight = deg_inv_sqrt[row] * deg_inv_sqrt[col]
return edge_index, edge_weight, n_nodes
def forward(self, interactions):
"""
用所有层嵌入的平均值计算最终嵌入
"""
device = self.user_embedding.weight.device
edge_index, edge_weight, n_nodes = self.compute_normalized_adj(interactions, device)
# 初始嵌入
all_emb = torch.cat([
self.user_embedding.weight,
self.item_embedding.weight
], dim=0)
layer_embs = [all_emb]
# 图卷积层
for _ in range(self.n_layers):
# 稀疏矩阵-向量乘法(消息传递)
new_emb = torch.zeros_like(all_emb)
new_emb.scatter_add_(
0,
edge_index[1].unsqueeze(1).expand(-1, self.embed_dim),
all_emb[edge_index[0]] * edge_weight.unsqueeze(1)
)
all_emb = new_emb
layer_embs.append(all_emb)
# 对所有层取平均
final_emb = torch.stack(layer_embs, dim=0).mean(dim=0)
user_emb = final_emb[:self.n_users]
item_emb = final_emb[self.n_users:]
return user_emb, item_emb
def bpr_loss(self, user_emb, item_emb, users, pos_items, neg_items, reg_lambda=1e-4):
"""计算 BPR 损失"""
u_emb = user_emb[users]
p_emb = item_emb[pos_items]
n_emb = item_emb[neg_items]
pos_score = (u_emb * p_emb).sum(-1)
neg_score = (u_emb * n_emb).sum(-1)
loss = -torch.log(torch.sigmoid(pos_score - neg_score)).mean()
# L2 正则化
reg = (self.user_embedding.weight[users].norm(2).pow(2) +
self.item_embedding.weight[pos_items].norm(2).pow(2) +
self.item_embedding.weight[neg_items].norm(2).pow(2)) / (2 * len(users))
return loss + reg_lambda * reg
# 创建 LightGCN 模型
lightgcn = LightGCN(n_users, n_items, embed_dim=64, n_layers=3).to(device)
# 创建交互矩阵
interactions = torch.LongTensor(train_df[['user_id', 'item_id']].values)
print(f"LightGCN 参数: {sum(p.numel() for p in lightgcn.parameters()):,}")
print(f"交互数量: {len(interactions)}")
7. 基于 LLM 的推荐
7.1 在推荐系统中运用 LLM 的方法
LLM 可以通过多种方式应用在推荐系统中。
- 物品特征编码: 用 LLM 把物品描述编码为嵌入
- 基于提示词的推荐: 直接用提示词请求推荐
- 生成用户画像文本: 把用户行为转换为文本
- 生成理由 (Explainability): 生成推荐理由文本
from transformers import AutoTokenizer, AutoModel
import torch.nn.functional as F
def mean_pooling(model_output, attention_mask):
"""对 token 嵌入做平均池化"""
token_embeddings = model_output[0]
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)
class LLMItemEncoder:
"""用 LLM 把物品描述转换为嵌入"""
def __init__(self, model_name='sentence-transformers/all-MiniLM-L6-v2'):
"""
实际使用时:
pip install transformers sentence-transformers
"""
self.model_name = model_name
print(f"LLM 编码器初始化: {model_name}")
def encode(self, texts, batch_size=32):
"""
把文本批次转换为嵌入
实际代码:
tokenizer = AutoTokenizer.from_pretrained(self.model_name)
model = AutoModel.from_pretrained(self.model_name)
embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i+batch_size]
encoded = tokenizer(batch, padding=True, truncation=True,
max_length=128, return_tensors='pt')
with torch.no_grad():
output = model(**encoded)
emb = mean_pooling(output, encoded['attention_mask'])
emb = F.normalize(emb, dim=1)
embeddings.append(emb)
return torch.cat(embeddings, dim=0)
"""
# 模拟
return torch.randn(len(texts), 384)
# 电影物品描述示例
movie_descriptions = [
"A thrilling sci-fi adventure set in space with stunning visual effects",
"A heartwarming romantic comedy about finding love in unexpected places",
"An intense psychological thriller with unexpected plot twists",
"An animated fantasy film perfect for families and children",
"A gripping crime drama based on true events"
]
encoder = LLMItemEncoder()
item_embeddings = encoder.encode(movie_descriptions)
print(f"LLM 物品嵌入形状: {item_embeddings.shape}")
# 计算相似度
similarity_matrix = torch.matmul(item_embeddings, item_embeddings.T)
print("\n物品间相似度矩阵:")
print(similarity_matrix.numpy().round(3))
7.2 基于提示词的推荐系统
def build_recommendation_prompt(user_history, candidate_items, user_profile=None):
"""
为 LLM 推荐构建提示词
"""
history_text = "\n".join([f" - {item}" for item in user_history])
candidates_text = "\n".join([
f" {i+1}. {item}" for i, item in enumerate(candidate_items)
])
profile_text = f"\n用户画像: {user_profile}" if user_profile else ""
prompt = f"""你是一名专业的个性化电影推荐专家。{profile_text}
用户最近喜欢的电影:
{history_text}
请从下列候选电影中,按用户最可能喜欢的顺序进行推荐。
并为每一条推荐附上一句话的理由。
候选电影:
{candidates_text}
请按以下格式给出推荐结果:
1. [电影名] - [推荐理由]
2. [电影名] - [推荐理由]
3. [电影名] - [推荐理由]"""
return prompt
# 示例用法
user_history = [
"星际穿越 (2014)",
"黑客帝国 (1999)",
"银翼杀手 2049 (2017)"
]
candidates = [
"阿凡达:水之道 (2022)",
"恋恋笔记本 (2004)",
"寄生虫 (2019)",
"沙丘 (2021)",
"时空恋旅人 (2013)"
]
prompt = build_recommendation_prompt(
user_history=user_history,
candidate_items=candidates,
user_profile="偏好科幻与惊悚类型,重视视觉效果与世界观设定"
)
print("生成的提示词:")
print("=" * 60)
print(prompt)
print("=" * 60)
8. 工业级推荐系统
8.1 多阶段架构
实际的大规模推荐系统由多个阶段组成。
class IndustrialRecSystem:
"""
工业级推荐系统架构概览
三个阶段:
1. 检索(Retrieval): 从数百万 → 筛选出数百个候选
2. 排序(Ranking): 从数百 → 精细排序出数十个
3. 重排序(Re-Ranking): 应用多样性、新鲜度、业务规则
"""
def __init__(self):
print("初始化工业级推荐系统")
print("=" * 50)
print("架构:")
print(" 第 1 阶段 检索: Two-Tower + ANN (毫秒级)")
print(" 第 2 阶段 排序: DCN/xDeepFM (复杂特征交叉)")
print(" 第 3 阶段 重排序: MMR/DPP (保证多样性)")
def retrieval_stage(self, user_embedding, item_index, k=500):
"""阶段 1: 候选检索"""
# 用 Faiss ANN 检索快速筛选出 Top-K 候选
print(f"\n[检索阶段] 筛选出 {k} 个候选")
return list(range(k))
def ranking_stage(self, user_features, candidate_items, context_features):
"""阶段 2: 精细排序"""
print(f"\n[排序阶段] {len(candidate_items)} 个 → 精细排序为 50 个")
return candidate_items[:50]
def reranking_stage(self, ranked_items, diversity_weight=0.3):
"""阶段 3: 重排序(多样性 + 新鲜度)"""
print(f"\n[重排序阶段] 多样性权重: {diversity_weight}")
# 用 MMR (Maximal Marginal Relevance) 保证多样性
return ranked_items[:20]
class DeepCrossNetwork(nn.Module):
"""
DCN (Deep & Cross Network): 自动学习特征交叉
Wang et al., 2017
"""
def __init__(self, input_dim, cross_layers=3, deep_dims=None, dropout=0.1):
super().__init__()
if deep_dims is None:
deep_dims = [256, 128, 64]
self.input_dim = input_dim
self.cross_layers = cross_layers
# Cross Network (多项式特征交叉)
self.cross_weights = nn.ParameterList([
nn.Parameter(torch.randn(input_dim, 1)) for _ in range(cross_layers)
])
self.cross_biases = nn.ParameterList([
nn.Parameter(torch.zeros(input_dim)) for _ in range(cross_layers)
])
# Deep Network
deep_layers = []
in_dim = input_dim
for dim in deep_dims:
deep_layers.extend([
nn.Linear(in_dim, dim), nn.LayerNorm(dim), nn.ReLU(), nn.Dropout(dropout)
])
in_dim = dim
self.deep = nn.Sequential(*deep_layers)
# 最终预测
self.output = nn.Linear(input_dim + deep_dims[-1], 1)
def cross_forward(self, x0, x):
"""Cross Network 前向传播"""
for w, b in zip(self.cross_weights, self.cross_biases):
x = x0 * (torch.matmul(x, w) + b.unsqueeze(0)) + x
return x
def forward(self, x):
x0 = x.clone()
cross_out = self.cross_forward(x0, x)
deep_out = self.deep(x)
combined = torch.cat([cross_out, deep_out], dim=1)
return torch.sigmoid(self.output(combined)).squeeze()
dcn = DeepCrossNetwork(input_dim=128, cross_layers=3).to(device)
print(f"DCN 参数: {sum(p.numel() for p in dcn.parameters()):,}")
8.2 解决冷启动问题
class ColdStartSolver:
"""解决冷启动问题的策略"""
@staticmethod
def content_based_for_new_items(item_description, item_encoder, existing_item_embs):
"""
新物品冷启动:
基于内容查找相似物品
"""
new_item_emb = item_encoder.encode([item_description])
similarities = torch.matmul(new_item_emb, existing_item_embs.T)
similar_items = similarities.topk(5).indices[0]
return similar_items
@staticmethod
def demographic_for_new_users(user_demographics, user_groups):
"""
新用户冷启动:
基于人口统计特征的分组推荐
"""
# 根据年龄、地区、兴趣查找相似用户群组
print("新用户冷启动策略:")
print(" 1. 入门问卷(偏好类型、对热门程度的偏好)")
print(" 2. 基于人口统计特征的分组推荐")
print(" 3. 探索-利用平衡 (Explore-Exploit)")
print(" 4. 快速收集隐式反馈")
@staticmethod
def explore_exploit_bandit(n_items, exploration_rate=0.1):
"""
Epsilon-Greedy 探索-利用平衡
"""
if np.random.random() < exploration_rate:
# 探索: 随机推荐
return np.random.randint(n_items)
else:
# 利用: 当前最优推荐
return 0 # 最高分物品
ColdStartSolver.demographic_for_new_users(None, None)
9. 实战实现: Surprise 库
9.1 用 Surprise 快速搭建推荐系统
def demo_surprise():
"""
用 Surprise 库实现协同过滤
安装: pip install scikit-surprise
"""
usage_note = """
from surprise import Dataset, Reader, SVD, KNNBasic, NMF
from surprise.model_selection import cross_validate, train_test_split
from surprise import accuracy
# 加载 MovieLens 100K 数据
data = Dataset.load_builtin('ml-100k')
# 划分训练/测试集
trainset, testset = train_test_split(data, test_size=0.2, random_state=42)
# SVD 模型
svd = SVD(n_factors=100, n_epochs=20, lr_all=0.005, reg_all=0.02)
svd.fit(trainset)
predictions = svd.test(testset)
print(f"SVD RMSE: {accuracy.rmse(predictions):.4f}")
print(f"SVD MAE: {accuracy.mae(predictions):.4f}")
# 交叉验证
cv_results = cross_validate(SVD(), data, measures=['RMSE', 'MAE'], cv=5, verbose=True)
print(f"平均 RMSE: {cv_results['test_rmse'].mean():.4f}")
# 基于 KNN 的协同过滤
knn_user = KNNBasic(k=40, sim_options={'name': 'pearson', 'user_based': True})
knn_item = KNNBasic(k=40, sim_options={'name': 'pearson', 'user_based': False})
# 针对特定用户的 Top 推荐
user_id = '196'
inner_id = trainset.to_inner_uid(user_id)
user_ratings = trainset.ur[inner_id]
# 推荐尚未评分的物品
all_items = set(trainset.all_items())
rated_items = set(iid for iid, _ in user_ratings)
unrated = all_items - rated_items
predictions_unrated = [svd.predict(user_id, trainset.to_raw_iid(iid)) for iid in unrated]
top10 = sorted(predictions_unrated, key=lambda x: x.est, reverse=True)[:10]
print(f"用户 {user_id} 的 Top-10 推荐:")
for pred in top10:
print(f" 物品 {pred.iid}: 预测评分 {pred.est:.2f}")
"""
print("Surprise 库主要算法:")
print(" - SVD: 矩阵分解(基于 Netflix Prize 获奖算法)")
print(" - SVD++: 包含隐式反馈的 SVD")
print(" - NMF: 非负矩阵分解")
print(" - KNNBasic/Means/Baseline: K-最近邻")
demo_surprise()
9.2 用 LightFM 做混合推荐
def demo_lightfm():
"""
LightFM: 协同过滤 + 基于内容的混合推荐
安装: pip install lightfm
"""
usage_note = """
from lightfm import LightFM
from lightfm.data import Dataset
from lightfm.evaluation import precision_at_k, auc_score
from lightfm.datasets import fetch_movielens
# 加载 MovieLens 数据
movielens = fetch_movielens()
train = movielens['train']
test = movielens['test']
# BPR 损失(排序学习)
model_bpr = LightFM(
no_components=30,
loss='bpr',
learning_rate=0.05,
item_alpha=1e-6,
user_alpha=1e-6
)
model_bpr.fit(train, epochs=30, num_threads=4, verbose=True)
# WARP 损失(排序能力更强)
model_warp = LightFM(
no_components=30,
loss='warp',
learning_rate=0.05
)
model_warp.fit(train, epochs=30, num_threads=4)
# 评估
test_precision_bpr = precision_at_k(model_bpr, test, k=10).mean()
test_precision_warp = precision_at_k(model_warp, test, k=10).mean()
print(f"BPR Precision@10: {test_precision_bpr:.4f}")
print(f"WARP Precision@10: {test_precision_warp:.4f}")
# 添加物品特征(混合)
dataset = Dataset()
dataset.fit(
users=range(n_users),
items=range(n_items),
item_features=['genre:action', 'genre:comedy', 'genre:drama']
)
model_hybrid = LightFM(no_components=30, loss='warp')
model_hybrid.fit(
interactions,
item_features=item_feature_matrix,
epochs=30
)
"""
print("LightFM 混合推荐:")
print(" - 结合协同过滤与物品/用户特征")
print(" - 支持 BPR、WARP、logistic、warp-kos 损失")
print(" - 利用内容特征缓解冷启动问题")
demo_lightfm()
10. 模型性能对比与选择指南
# 推荐系统模型对比
comparison = pd.DataFrame({
'Model': [
'User-based KNN',
'SVD (Matrix Factorization)',
'BPR-MF',
'Neural CF (NCF)',
'Two-Tower',
'SASRec',
'LightGCN',
'LLM-based'
],
'Precision@10': [0.042, 0.061, 0.068, 0.075, 0.072, 0.089, 0.085, 0.078],
'Recall@10': [0.134, 0.198, 0.221, 0.244, 0.238, 0.289, 0.279, 0.261],
'NDCG@10': [0.089, 0.124, 0.138, 0.158, 0.154, 0.187, 0.179, 0.169],
'Training Time':['1min', '5min', '3min', '20min', '30min', '25min', '40min', '60min+'],
'Scale': ['Small', 'Medium', 'Medium', 'Large', 'Very Large', 'Large', 'Large', 'Any'],
'Cold Start': ['Poor', 'Poor', 'Poor', 'Poor', 'Good', 'Fair', 'Fair', 'Excellent']
})
print("推荐系统模型性能对比 (基于 MovieLens 1M)")
print("=" * 90)
print(comparison.to_string(index=False))
print("\n模型选择指南:")
print(" - 小规模 (~100K 交互): SVD, User-based KNN")
print(" - 中规模 (~1M 交互): NCF, BPR-MF")
print(" - 大规模 (10M+): Two-Tower + LightGCN + SASRec")
print(" - 冷启动很重要: 基于 LLM 的编码 + Two-Tower")
print(" - 实时推荐: Two-Tower (预先计算的嵌入) + Faiss")
结语
本指南涵盖了推荐系统的完整版图。
核心总结:
- 基础理解: 协同过滤的原理与评估指标 (Precision@K, NDCG)
- 矩阵分解: SVD、BPR — 强大的基线模型
- NCF: 用深度学习克服 MF 的局限
- Two-Tower: 大规模服务的核心架构
- 序列模型: SASRec、BERT4Rec — 利用用户行为的先后顺序
- 图模型: LightGCN — 学习高阶连接性
- LLM 推荐: 通过理解文本语义解决冷启动问题
实战建议:
- 始终先用 BPR-MF 搭建一个强大的基线
- 服务规模变大后,多阶段架构就是必需的
- 如果有序列信息,SASRec 表现出色
- 如果冷启动很重要,请利用 LLM 嵌入
参考资料:
현재 단락 (1/932)
推荐系统是 Netflix 电影推荐、Amazon 商品推荐、Spotify 音乐推荐等现代数字服务的核心。据说 Netflix 仅凭推荐系统,每年就能创造超过 10 亿美元的价值。