随着在线视频平台与流媒体服务的快速发展,电影内容的供给规模呈现出爆发式增长,用户在海量信息中进行有效选择的难度显著提升,推荐系统逐渐成为提升用户体验与平台留存的核心技术支撑。在实际应用中,传统的协同过滤方法依赖用户行为数据,虽在刻画群体偏好方面具有优势,但容易受到冷启动问题与数据稀疏性的制约;而基于内容的推荐方法能够利用文本、类型等结构化与非结构化信息进行建模,却往往难以捕捉用户隐含兴趣与跨内容的潜在关联。因此,如何在两类方法之间实现有效融合,构建兼顾表达能力与泛化能力的推荐模型,成为当前推荐系统研究中的关键问题。在这一背景下,结合电影文本描述、类型标签及评分等多源信息,通过深度学习方法构建内容表示,并引入协同信号进行联合建模,不仅能够提升推荐结果的相关性与多样性,也为解决新内容冷启动与兴趣迁移问题提供了可行路径。本研究正是在这一现实需求与技术发展背景下展开,旨在探索一种结构清晰、可扩展的混合推荐建模方案,以更好地适应复杂多变的内容分发场景。
本实验数据集来源于Kaggle,该数据集全面展现了截至2026年初电影数据库(TMDB)评分最高的10000部电影。它旨在帮助数据分析师和电影爱好者探索跨越数十年和多种语言的“顶级”电影的特征。这对于推荐系统来说非常有用。
Python版本:3.9
代码编辑器:jupyter notebook
这一部分主要完成实验所需环境与数据的准备工作。这里使用的是TMDB电影数据集,包含电影的基本信息、文本描述以及用户评分等内容,为后续构建基于内容和协同过滤的混合推荐模型提供数据基础。在代码层面,主要引入了PyTorch用于模型构建与训练,pandas和numpy用于数据处理,同时加载了一些常用的特征工程工具,如TF-IDF、SVD以及多标签编码等,为后续特征提取和降维做准备。
import torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import Dataset, DataLoaderimport pandas as pdimport numpy as npimport ast, warningsimport matplotlib.pyplot as pltwarnings.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
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
这一部分对原始电影数据进行清洗与结构化处理,核心目的是把原本较为杂乱的字段转化为模型可以直接使用的特征形式。包括去除关键字段缺失的数据、解析类别标签、提取上映年份以及补全数值字段等,同时为每部电影生成唯一索引,方便后续构建推荐模型时进行映射和训练。
print(f'Raw shape: {df.shape}') df = df.dropna(subset=['overview', 'title', 'vote_average']).reset_index(drop=True)def safe_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)df['movie_idx'] = df.indexN_MOVIES = len(df)print(f'Clean shape : {df.shape}')print(f'Total movies: {N_MOVIES}')
这一部分主要完成内容侧特征的构建以及训练目标的定义。思路是把电影的文本信息、类别信息和结构化数值信息统一编码成一个向量表示,用于内容分支建模;同时基于评分和投票数构造一个隐式偏好分数,作为后续模型的学习目标,使模型既能理解内容相似性,又能捕捉受欢迎程度。
tfidf = TfidfVectorizer(max_features=5000, stop_words='english', ngram_range=(1, 2)) tfidf_matrix = tfidf.fit_transform(df['overview'].fillna('')) svd = TruncatedSVD(n_components=100, random_state=42) text_features = svd.fit_transform(tfidf_matrix) 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']] ) 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'])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}]')
这一部分完成混合推荐模型的核心构建,包括数据集封装、内容分支(ContentBranch)、协同过滤分支(CFBranch)以及二者的融合结构。整体思路是让模型一方面从电影内容特征中学习语义表示,另一方面通过Embedding学习电影之间的隐式关系,再通过融合层输出最终评分,同时引入对齐损失,使两个分支的表示空间更加一致。
class HybridMovieDataset(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): return len(self.indices)
def __getitem__(self, i): idx = self.indices[i] return ( self.content[idx], torch.tensor(idx, dtype=torch.long), self.targets[idx] )targets = df['target'].valuestrain_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)cv, ids, tgt = next(iter(train_loader))class ContentBranch(nn.Module): def __init__(self, input_dim, embed_dim=64, dropout=0.3): super().__init__() 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) ) def forward(self, x): return self.encoder(x)
class CFBranch(nn.Module): def __init__(self, n_movies, embed_dim=64): super().__init__() self.item_emb = nn.Embedding(n_movies, embed_dim) nn.init.normal_(self.item_emb.weight, mean=0, std=0.01) def forward(self, ids): return self.item_emb(ids)
class
HybridRecommender(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)
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() )
def forward(self, content_vec, movie_ids): ce = self.content_branch(content_vec) cfe = self.cf_branch(movie_ids) score = self.fusion(torch.cat([ce, cfe], dim=1)).squeeze(1) return score, ce, cfe
def get_content_emb(self, x): return self.content_branch(x) def get_cf_emb(self, ids): return self.cf_branch(ids)
EMBED_DIM = 64model = 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() def alignment_loss(ce, cfe): cn = F.normalize(ce, p=2, dim=1) cfn = F.normalize(cfe, p=2, dim=1) return (1 - (cn * cfn).sum(dim=1)).mean()
def hybrid_loss(pred, target, ce, cfe, alpha=0.1): 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')
这一部分进入模型训练阶段,整体流程包括前向传播、损失计算、反向传播以及验证集评估,同时记录训练过程中的指标变化,并在验证集表现最优时保存模型参数。这里采用的是混合损失函数,一方面优化评分预测误差,另一方面约束两个分支的表示一致性,从而提升模型整体效果。
NUM_EPOCHS = 30history = {'train': [], 'val': [], 'align': []} best_val = float('inf') for epoch in range(1, NUM_EPOCHS + 1): 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)
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()) 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') 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}')
这一部分主要用于对模型训练过程进行可视化分析,通过绘制训练集与验证集的损失曲线,以及两个分支之间的对齐损失变化情况,可以直观判断模型是否收敛、是否存在过拟合,以及内容分支与协同过滤分支之间的融合效果。
fig, axes = plt.subplots(1, 2, figsize=(14, 4))e = range(1, NUM_EPOCHS + 1) # epoch范围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()
这一部分主要是将训练好的模型用于实际推荐任务中,包括提取三种不同表示(内容向量、协同过滤向量、融合向量),并基于向量相似度实现电影推荐。同时通过简单的类别重合指标,对不同推荐模式的效果进行对比分析,从而验证混合模型的优势。
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) with torch.no_grad(): for i in range(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()) content_embs = F.normalize(torch.cat(all_ce), p=2, dim=1) cf_embs = F.normalize(torch.cat(all_cfe), p=2, dim=1) hybrid_embs = F.normalize(torch.cat([content_embs, cf_embs], dim=1), p=2, dim=1) EMB_MAP = {'content': content_embs, 'cf': cf_embs, 'hybrid': hybrid_embs}def recommend(title, top_k=10, mode='hybrid'): embs = EMB_MAP[mode] 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) rows = [] for s, i in zip(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) }) if len(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))
def genre_overlap(title, top_k=10, mode='hybrid'): mask = df['title'].str.lower() == title.lower() ifnot mask.any(): return None 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 is not None: print(f'{movie:<30} {mode:<10} {go:>14.1%}')
本研究基于TMDB高评分电影数据构建了一个融合内容信息与协同信号的混合推荐模型,通过将文本语义特征、类型结构特征与影片隐式评分信息进行统一建模,有效缓解了单一推荐范式在信息利用上的局限。实验结果表明,模型在训练过程中收敛稳定,预测误差持续下降,同时通过引入分支对齐机制,使内容向量与协同向量在嵌入空间中实现一致性约束,从而提升了整体表征能力。在实际推荐效果上,混合模式相较于单一的Content或CF方式,在相似电影检索中表现出更好的语义相关性与类型一致性,能够在保证多样性的同时维持较高的匹配精度。整体来看,该模型在兼顾冷启动问题与用户偏好建模方面展现出较强的实用价值,为复杂场景下的个性化推荐提供了一种具有可扩展性的实现思路。
完整代码链接:https://gitee.com/aipaiseng/Deep-Learning-Practice/tree/master/
数据集链接:https://www.kaggle.com/datasets/dhritisisodia/tmdb-top-10000-movies-dataset-2026/data