(注:使用文中代码的时候需要加上冒号后的缩进,否则报语法错误。如需复制代码建议点击文末左下角阅读原文。)
1.项目背景
随着在线视频平台与流媒体服务的快速发展,电影内容的供给规模呈现出爆发式增长,用户在海量信息中进行有效选择的难度显著提升,推荐系统逐渐成为提升用户体验与平台留存的核心技术支撑。在实际应用中,传统的协同过滤方法依赖用户行为数据,虽在刻画群体偏好方面具有优势,但容易受到冷启动问题与数据稀疏性的制约;而基于内容的推荐方法能够利用文本、类型等结构化与非结构化信息进行建模,却往往难以捕捉用户隐含兴趣与跨内容的潜在关联。因此,如何在两类方法之间实现有效融合,构建兼顾表达能力与泛化能力的推荐模型,成为当前推荐系统研究中的关键问题。在这一背景下,结合电影文本描述、类型标签及评分等多源信息,通过深度学习方法构建内容表示,并引入协同信号进行联合建模,不仅能够提升推荐结果的相关性与多样性,也为解决新内容冷启动与兴趣迁移问题提供了可行路径。本研究正是在这一现实需求与技术发展背景下展开,旨在探索一种结构清晰、可扩展的混合推荐建模方案,以更好地适应复杂多变的内容分发场景。
2.数据集介绍
本实验数据集来源于Kaggle,该数据集全面展现了截至2026年初电影数据库(TMDB)评分最高的10000部电影。它旨在帮助数据分析师和电影爱好者探索跨越数十年和多种语言的“顶级”电影的特征。这对于推荐系统来说非常有用。
3.技术工具
Python版本:3.9
代码编辑器:jupyter notebook
4.实验过程
4.1导入数据
这一部分主要完成实验所需环境与数据的准备工作。这里使用的是TMDB电影数据集,包含电影的基本信息、文本描述以及用户评分等内容,为后续构建基于内容和协同过滤的混合推荐模型提供数据基础。在代码层面,主要引入了PyTorch用于模型构建与训练,pandas和numpy用于数据处理,同时加载了一些常用的特征工程工具,如TF-IDF、SVD以及多标签编码等,为后续特征提取和降维做准备。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader
import pandas as pd
import numpy as np
import ast, warnings
import matplotlib.pyplot as plt
warnings.filterwarnings('ignore') # 忽略不必要的警告信息
# 文本特征与数据预处理相关工具
from sklearn.feature_extraction.text import TfidfVectorizer # 文本向量化(用于内容分支)
from sklearn.preprocessing import MinMaxScaler, MultiLabelBinarizer # 归一化、多标签编码
from sklearn.decomposition import TruncatedSVD # 降维(缓解高维稀疏问题)
from sklearn.model_selection import train_test_split # 数据集划分
# 设置设备(优先使用GPU)
device = torch.device('cuda'if torch.cuda.is_available() else'cpu')
# 固定随机种子,保证实验可复现
torch.manual_seed(42)
np.random.seed(42)
# 读取电影数据集(包含电影内容信息与评分等)
df = pd.read_csv('/kaggle/input/datasets/dhritisisodia/tmdb-top-10000-movies-dataset-2026/tmdb_top_10k_movies_2026.csv.csv')
运行
4.2数据预处理
这一部分对原始电影数据进行清洗与结构化处理,核心目的是把原本较为杂乱的字段转化为模型可以直接使用的特征形式。包括去除关键字段缺失的数据、解析类别标签、提取上映年份以及补全数值字段等,同时为每部电影生成唯一索引,方便后续构建推荐模型时进行映射和训练。
print(f'Raw shape: {df.shape}') # 查看原始数据规模
# 删除关键字段缺失的数据(简介、标题、评分是核心信息)
df = df.dropna(subset=['overview', 'title', 'vote_average']).reset_index(drop=True)
# 安全解析字符串形式的列表(如 genre_ids)
defsafe_parse(x):
try:
return ast.literal_eval(x) ifisinstance(x, str) else []
except:
return []
df['genre_ids_parsed'] = df['genre_ids'].apply(safe_parse) # 解析电影类别
# 提取上映年份(时间特征)
df['release_year'] = pd.to_datetime(df['release_date'], errors='coerce').dt.year.fillna(0).astype(int)
# 对数值字段做缺失值填充
df['vote_count'] = df['vote_count'].fillna(0)
df['popularity'] = df['popularity'].fillna(0)
# 为每部电影创建唯一索引(后续Embedding或索引映射会用到)
df['movie_idx'] = df.index
# 数据规模信息
N_MOVIES = len(df)
print(f'Clean shape : {df.shape}')
print(f'Total movies: {N_MOVIES}')
运行
4.3特征工程
这一部分主要完成内容侧特征的构建以及训练目标的定义。思路是把电影的文本信息、类别信息和结构化数值信息统一编码成一个向量表示,用于内容分支建模;同时基于评分和投票数构造一个隐式偏好分数,作为后续模型的学习目标,使模型既能理解内容相似性,又能捕捉受欢迎程度。
tfidf = TfidfVectorizer(max_features=5000, stop_words='english', ngram_range=(1, 2)) # 文本向量化(考虑1-2gram)
tfidf_matrix = tfidf.fit_transform(df['overview'].fillna('')) # 将电影简介转为稀疏向量
svd = TruncatedSVD(n_components=100, random_state=42) # 降维,缓解高维稀疏问题
text_features = svd.fit_transform(tfidf_matrix) # (N, 100) 文本语义特征
mlb = MultiLabelBinarizer()
genre_features = mlb.fit_transform(df['genre_ids_parsed']) # 多标签编码(每个类别一个维度)
N_GENRES = genre_features.shape[1]
scaler = MinMaxScaler()
numeric_features = scaler.fit_transform(
df[['vote_average', 'vote_count', 'popularity', 'release_year']] # 数值特征归一化
) # (N, 4)
content_features = np.hstack([text_features, genre_features, numeric_features]).astype(np.float32) # 拼接所有特征
CONTENT_DIM = content_features.shape[1]
# 打印各部分特征维度
print(f'Text : {text_features.shape}')
print(f'Genre : {genre_features.shape}')
print(f'Numeric: {numeric_features.shape}')
print(f'Total content dim: {CONTENT_DIM}')
运行
# 构造隐式评分:综合评分和投票数(避免只看高分但样本少的情况)
df['implicit_score'] = df['vote_average'] * np.log1p(df['vote_count'])
# Normalize to [0, 1] 作为模型训练目标
s_min = df['implicit_score'].min()
s_max = df['implicit_score'].max()
df['target'] = (df['implicit_score'] - s_min) / (s_max - s_min)
# 划分训练集和验证集
train_idx, val_idx = train_test_split(df.index.tolist(), test_size=0.2, random_state=42)
# 数据分布信息
print(f'Train: {len(train_idx)} | Val: {len(val_idx)}')
print(f'Target range: [{df["target"].min():.3f}, {df["target"].max():.3f}]')
运行
4.4构建模型
这一部分完成混合推荐模型的核心构建,包括数据集封装、内容分支(ContentBranch)、协同过滤分支(CFBranch)以及二者的融合结构。整体思路是让模型一方面从电影内容特征中学习语义表示,另一方面通过Embedding学习电影之间的隐式关系,再通过融合层输出最终评分,同时引入对齐损失,使两个分支的表示空间更加一致。
classHybridMovieDataset(Dataset):
def__init__(self, indices, content_features, targets):
self.indices = indices
self.content = torch.tensor(content_features, dtype=torch.float32) # 内容特征矩阵
self.targets = torch.tensor(targets, dtype=torch.float32) # 目标评分
def__len__(self): returnlen(self.indices)
def__getitem__(self, i):
idx = self.indices[i]
return (
self.content[idx], # 内容特征向量
torch.tensor(idx, dtype=torch.long), # 电影ID(用于CF分支Embedding)
self.targets[idx] # 目标值
)
targets = df['target'].values
train_dataset = HybridMovieDataset(train_idx, content_features, targets)
val_dataset = HybridMovieDataset(val_idx, content_features, targets)
# 构建数据加载器
train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True, drop_last=True)
val_loader = DataLoader(val_dataset, batch_size=256, shuffle=False)
# 查看一个batch数据结构
cv, ids, tgt = next(iter(train_loader))
classContentBranch(nn.Module):
def__init__(self, input_dim, embed_dim=64, dropout=0.3):
super().__init__()
# 多层全连接网络,将内容特征映射到低维embedding空间
self.encoder = nn.Sequential(
nn.Linear(input_dim, 256), nn.LayerNorm(256), nn.GELU(), nn.Dropout(dropout),
nn.Linear(256, 128), nn.LayerNorm(128), nn.GELU(), nn.Dropout(dropout),
nn.Linear(128, embed_dim)
)
defforward(self, x): return self.encoder(x)
classCFBranch(nn.Module):
def__init__(self, n_movies, embed_dim=64):
super().__init__()
# 为每个电影学习一个Embedding向量(协同过滤思想)
self.item_emb = nn.Embedding(n_movies, embed_dim)
nn.init.normal_(self.item_emb.weight, mean=0, std=0.01) # 初始化Embedding
defforward(self, ids): return self.item_emb(ids)
classHybridRecommender(nn.Module):
def__init__(self, content_dim, n_movies, embed_dim=64, dropout=0.3):
super().__init__()
# 两个分支
self.content_branch = ContentBranch(content_dim, embed_dim, dropout)
self.cf_branch = CFBranch(n_movies, embed_dim)
# 融合层:拼接两个Embedding后进行预测
self.fusion = nn.Sequential(
nn.Linear(embed_dim * 2, 128), nn.LayerNorm(128), nn.GELU(), nn.Dropout(dropout),
nn.Linear(128, 64), nn.GELU(),
nn.Linear(64, 1), nn.Sigmoid() # 输出归一化评分
)
defforward(self, content_vec, movie_ids):
ce = self.content_branch(content_vec) # 内容Embedding
cfe = self.cf_branch(movie_ids) # CF Embedding
score = self.fusion(torch.cat([ce, cfe], dim=1)).squeeze(1) # 拼接后预测
return score, ce, cfe
defget_content_emb(self, x): return self.content_branch(x)
defget_cf_emb(self, ids): return self.cf_branch(ids)
EMBED_DIM = 64
model = HybridRecommender(CONTENT_DIM, N_MOVIES, EMBED_DIM).to(device)
# 统计可训练参数量
params = sum(p.numel() for p in model.parameters() if p.requires_grad)
mse_loss = nn.MSELoss() # 主损失:回归误差
defalignment_loss(ce, cfe):
# 计算两个分支Embedding的余弦相似度,鼓励表示一致
cn = F.normalize(ce, p=2, dim=1)
cfn = F.normalize(cfe, p=2, dim=1)
return (1 - (cn * cfn).sum(dim=1)).mean()
defhybrid_loss(pred, target, ce, cfe, alpha=0.1):
# 总损失 = MSE + 对齐损失
main = mse_loss(pred, target)
align = alignment_loss(ce, cfe)
return main + alpha * align, main.item(), align.item()
# 优化器与学习率调度
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-5)
print('Loss : MSE + 0.1 × alignment')
print('Optimizer : AdamW lr=3e-4, wd=1e-4')
print('Scheduler : CosineAnnealingLR T_max=30')
运行
4.5模型训练
这一部分进入模型训练阶段,整体流程包括前向传播、损失计算、反向传播以及验证集评估,同时记录训练过程中的指标变化,并在验证集表现最优时保存模型参数。这里采用的是混合损失函数,一方面优化评分预测误差,另一方面约束两个分支的表示一致性,从而提升模型整体效果。
NUM_EPOCHS = 30
history = {'train': [], 'val': [], 'align': []} # 记录训练、验证和对齐损失
best_val = float('inf') # 用于保存最优验证损失
for epoch inrange(1, NUM_EPOCHS + 1):
# ── train ──────────────────────────────────────────
model.train() # 训练模式
t_loss, a_loss = [], []
for cv, ids, tgt in train_loader:
cv, ids, tgt = cv.to(device), ids.to(device), tgt.to(device) # 数据送入设备
pred, ce, cfe = model(cv, ids) # 前向传播
loss, ml, al = hybrid_loss(pred, tgt, ce, cfe) # 计算总损失(主损失+对齐损失)
optimizer.zero_grad() # 梯度清零
loss.backward() # 反向传播
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪,防止梯度爆炸
optimizer.step() # 更新参数
t_loss.append(ml) # 记录主损失
a_loss.append(al) # 记录对齐损失
# ── validate ───────────────────────────────────────
model.eval() # 验证模式
v_loss = []
with torch.no_grad(): # 关闭梯度计算
for cv, ids, tgt in val_loader:
cv, ids, tgt = cv.to(device), ids.to(device), tgt.to(device)
pred, _, _ = model(cv, ids) # 仅关注预测结果
v_loss.append(mse_loss(pred, tgt).item()) # 验证集MSE
# ── epoch stats ────────────────────────────────────
at, av, aa = np.mean(t_loss), np.mean(v_loss), np.mean(a_loss) # 计算平均损失
history['train'].append(at)
history['val'].append(av)
history['align'].append(aa)
scheduler.step() # 更新学习率
# 保存验证集表现最好的模型
if av < best_val:
best_val = av
torch.save(model.state_dict(), 'best_hybrid.pt')
# 每隔5轮输出一次训练信息
if epoch % 5 == 0or epoch == 1:
print(f'Ep {epoch:02d} | train={at:.5f} val={av:.5f} align={aa:.4f}')
print(f'nBest val loss: {best_val:.5f}')
运行
4.6模型评估
这一部分主要用于对模型训练过程进行可视化分析,通过绘制训练集与验证集的损失曲线,以及两个分支之间的对齐损失变化情况,可以直观判断模型是否收敛、是否存在过拟合,以及内容分支与协同过滤分支之间的融合效果。
fig, axes = plt.subplots(1, 2, figsize=(14, 4))
e = range(1, NUM_EPOCHS + 1) # epoch范围
# 左图:训练集与验证集的MSE损失曲线
axes[0].plot(e, history['train'], label='Train MSE', color='steelblue', lw=2)
axes[0].plot(e, history['val'], label='Val MSE', color='coral', lw=2, linestyle='--')
axes[0].set_title('Prediction Loss (MSE)')
axes[0].legend()
axes[0].grid(alpha=0.3)
# 右图:两个分支的对齐损失(余弦距离)
axes[1].plot(e, history['align'], color='mediumseagreen', lw=2)
axes[1].set_title('Branch Alignment Loss (1 − cos)')
axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.show()
运行
4.7模型预测
这一部分主要是将训练好的模型用于实际推荐任务中,包括提取三种不同表示(内容向量、协同过滤向量、融合向量),并基于向量相似度实现电影推荐。同时通过简单的类别重合指标,对不同推荐模式的效果进行对比分析,从而验证混合模型的优势。
model.load_state_dict(torch.load('best_hybrid.pt', map_location=device)) # 加载最优模型
model.eval()
all_ce, all_cfe = [], []
feat_t = torch.tensor(content_features, dtype=torch.float32) # 内容特征张量
ids_t = torch.arange(N_MOVIES, dtype=torch.long) # 电影ID序列
# 分批提取embedding,避免显存不足
with torch.no_grad():
for i inrange(0, N_MOVIES, 512):
cv = feat_t[i:i+512].to(device)
ids = ids_t[i:i+512].to(device)
all_ce.append(model.get_content_emb(cv).cpu()) # 内容向量
all_cfe.append(model.get_cf_emb(ids).cpu()) # CF向量
# 拼接并归一化
content_embs = F.normalize(torch.cat(all_ce), p=2, dim=1) # (N, 64)
cf_embs = F.normalize(torch.cat(all_cfe), p=2, dim=1) # (N, 64)
hybrid_embs = F.normalize(torch.cat([content_embs, cf_embs], dim=1), p=2, dim=1) # (N, 128)
# 不同模式下的embedding映射
EMB_MAP = {'content': content_embs, 'cf': cf_embs, 'hybrid': hybrid_embs}
defrecommend(title, top_k=10, mode='hybrid'):
embs = EMB_MAP[mode] # 选择embedding类型
# 精确匹配或模糊匹配电影标题
mask = df['title'].str.lower() == title.lower()
ifnot mask.any():
mask = df['title'].str.lower().str.contains(title.lower(), na=False)
ifnot mask.any():
print(f'Not found: {title}'); return pd.DataFrame()
q_idx = df[mask].index[0] # 查询电影索引
# 计算余弦相似度(向量点积)
sims = torch.mm(embs[q_idx].unsqueeze(0), embs.T).squeeze(0)
scores, indices = sims.topk(top_k + 1) # 取top-k相似电影
rows = []
for s, i inzip(scores.tolist(), indices.tolist()):
if i == q_idx: continue# 跳过自身
m = df.iloc[i]
rows.append({
'Title': m['title'],
'Year': int(m['release_year']),
'Lang': m['original_language'],
'Vote': round(m['vote_average'], 2),
'Sim': round(s, 4)
})
iflen(rows) == top_k: break
return pd.DataFrame(rows)
# 示例:不同模式下的推荐结果
for mode in ['content', 'cf', 'hybrid']:
print(f'n── {mode.upper()} ──')
recs = recommend('Inception', top_k=5, mode=mode)
print(recs[['Title', 'Year', 'Vote', 'Sim']].to_string(index=False))
运行
defgenre_overlap(title, top_k=10, mode='hybrid'):
# 计算推荐结果中与目标电影类别的重合比例
mask = df['title'].str.lower() == title.lower()
ifnot mask.any(): returnNone
q_genres = set(df[mask].iloc[0]['genre_ids_parsed']) # 原电影类别
recs = recommend(title, top_k, mode)
if recs.empty: return0.0
hits = 0
for t in recs['Title']:
m = df[df['title'] == t]
ifnot m.empty and q_genres & set(m.iloc[0]['genre_ids_parsed']):
hits += 1
return hits / top_k # 重合比例
# 测试不同模式推荐效果
test_movies = ['Inception', 'The Dark Knight', 'Parasite', 'Spirited Away']
print(f'{"Movie":<30}{"Mode":<10}{"Genre Overlap":>15}')
print('-' * 58)
for movie in test_movies:
for mode in ['content', 'cf', 'hybrid']:
go = genre_overlap(movie, top_k=10, mode=mode)
if go isnotNone:
print(f'{movie:<30}{mode:<10}{go:>14.1%}')
运行
5.总结
本研究基于TMDB高评分电影数据构建了一个融合内容信息与协同信号的混合推荐模型,通过将文本语义特征、类型结构特征与影片隐式评分信息进行统一建模,有效缓解了单一推荐范式在信息利用上的局限。实验结果表明,模型在训练过程中收敛稳定,预测误差持续下降,同时通过引入分支对齐机制,使内容向量与协同向量在嵌入空间中实现一致性约束,从而提升了整体表征能力。在实际推荐效果上,混合模式相较于单一的Content或CF方式,在相似电影检索中表现出更好的语义相关性与类型一致性,能够在保证多样性的同时维持较高的匹配精度。整体来看,该模型在兼顾冷启动问题与用户偏好建模方面展现出较强的实用价值,为复杂场景下的个性化推荐提供了一种具有可扩展性的实现思路。
1971