news 2026/9/11 19:36:29

TensorFlow实战:融合CNN与协同过滤的电影推荐系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TensorFlow实战:融合CNN与协同过滤的电影推荐系统

简介:面向计算机专业学生与Python实战学习者的电影推荐系统完整源码,整合TensorFlow、CNN与协同过滤算法,适用于毕业设计、课程设计或期末大作业场景。项目源于个人大三高分作业,经导师指导评审,具备清晰的工程结构与可运行性,可快速理解推荐系统的数据处理、模型构建与评估流程。包体共27个文件,以Python脚本、ipynb交互式分析、CSV/DAT数据集及Markdown说明文档为主,包含基于矩阵分解的协同过滤实现与基于CNN的深度推荐实现两套方案,并内置ml-latest-small与ml-1m等常用数据集,方便直接调试运行。压缩包大小仅7.4MB,整体轻量易用。目前已有88人学习下载。通过该资源,读者可获得完整的推荐系统项目参考、模块化代码注释、数据集预处理思路以及README中的优化改进笔记,适合作为课设模板或入门推荐系统的实战练习素材。

1. 为什么电影推荐要把 CNN 和协同过滤拼在一起

一个推荐系统不能只靠一张评分矩阵。协同过滤算法擅长从用户与电影的交互里捕捉偏好,但新电影没有评分,新用户没有历史;CNN 擅长从电影海报这一类内容数据里抽视觉特征,却不知道“到底是谁喜欢它”。把两者放进同一个 TensorFlow 模型里,交互信号走 Embedding,视觉信号走 CNN,再交给多层网络联合打分,正好能同时缓解稀疏交互和冷启动两个问题。这也是很多高分电影推荐源码项目的基本骨架。下面按数据准备、模型搭建、超参数调整和源码落地四步讲,目标是让一份评分表加一批海报图片,变成一个能直接出推荐列表的端到端模型。环境没配好 TensorFlow 的话,请先打开 Anaconda 建一个干净环境,再往下走。

2. 数据准备与 Embedding:把用户、电影和海报变成模型输入

一个电影推荐项目拿到的原始材料通常是ratings.csvmovies.csv和一张张海报图片。评分表里是原始 userId、movieId,海报则堆在一个目录里按电影 ID 命名。这一章先解决两个前置问题:怎么把 ID 变成连续编码,怎么把图片路径变成能喂进 CNN 的张量。

2.1 评分数据的 ID 映射:不要直接喂原始 movieId

原始 ID 通常是 1、5、9 这样带空洞的编号,而 Embedding 层要求输入是 0 开始、连续的索引。不处理的话,model.fit会在查表时直接越界。我先用 pandas 做一次压缩编码:

# src/data.py import pandas as pd def load_ratings(path: str): df = pd.read_csv(path) df['user_enc'] = df['userId'].astype('category').cat.codes df['movie_enc'] = df['movieId'].astype('category').cat.codes num_users = df['user_enc'].nunique() num_movies = df['movie_enc'].nunique() return df, num_users, num_movies

这里的astype('category').cat.codes会把用户 ID 和电影 ID 压缩成 0 到 N-1 的连续编号。它不改变 ID 背后的语义,纯粹是查表前的必要工作。如果评分数据里有时间戳,我会在同一个函数里按时间排序后切分训练集和验证集,避免用未来的数据预测过去,省得后面指标虚高。

在这个项目里,三种原始数据的处理边界大致如下:

原始数据用途关键处理
ratings.csv用户-电影-评分三元组分别对用户、电影做连续编码
movies.csv电影 ID、标题、类型标题先留着,文本分支后续可做
posters/xxx.jpgCNN 分支输入缩放到统一尺寸并做归一化

这种处理方式和协同过滤算法在旅游推荐系统场景里碰到的问题一样:ID 映射、稀疏矩阵、内容缺失。电影推荐之所以常被选作落地案例,是因为评分数据公开、海报容易补全,调试链路最短。

2.2 用 tf.data 同时处理评分、ID 和海报图像

训练时不要用 pandas 一行行读图,也不要先全部载入内存。常见做法是用 TensorFlow 的tf.data构造输入流水线,让 CPU 在 GPU 算前向时同步准备下一批数据。

# src/data_pipeline.py import tensorflow as tf from tensorflow.keras.applications.vgg16 import preprocess_input def build_dataset(df, poster_dir, batch_size=64, shuffle=True): path_list = [f'{poster_dir}/{mid}.jpg' for mid in df['movieId']] raw = tf.data.Dataset.from_tensor_slices(( { 'user_id': df['user_enc'].values, 'movie_id': df['movie_enc'].values, 'poster': path_list }, df['rating'].values.astype('float32') )) def map_func(features, label): image = tf.io.read_file(features['poster']) image = tf.image.decode_jpeg(image, channels=3) image = tf.image.resize(image, (224, 224)) features['poster'] = preprocess_input(image) return features, label ds = raw.map(map_func, num_parallel_calls=tf.data.AUTOTUNE) if shuffle: ds = ds.shuffle(10000) return ds.batch(batch_size).prefetch(tf.data.AUTOTUNE)

tf.image.decode_jpeg输出的是 uint8 像素,resize之后仍然是 [0, 255] 区间;preprocess_input会把它转换成 VGG16 期望的输入分布。海报文件名如果遇到某个 ID 没有对应文件,我会在流水线外面先统一补灰色占位图,不要在map_func里写tf.cond,那样会增加 graph 分支,出问题反而难查。

这套流水线里有一个容易被忽略的设计:prefetch(tf.data.AUTOTUNE)能让数据准备和前向计算重叠。训练损失函数值开始跳动时,先看这里有没有成为瓶颈,再考虑调大num_parallel_calls

3. 模型主体:TensorFlow 里实现 CNN 特征编码与协同过滤打分

模型部分拆成三块:协同过滤分支负责用户和电影的交互信号,CNN 分支负责海报视觉信号,最后在融合层里让两路特征互相修正。这三块可以分别调试,不需要一上来就调整个大模型。

3.1 协同过滤分支:Embedding 到向量

协同过滤算法的神经网络表示并不神秘,就是把用户和电影分别映射成一个稠密向量,再让这两个向量发生交互。在 TensorFlow 里最直接的做法是 Embedding 加 Flatten:

# src/model.py import tensorflow as tf def build_cf_branch(num_users, num_movies, embed_dim=64): user_input = tf.keras.Input(shape=(1,), name='user_id') movie_input = tf.keras.Input(shape=(1,), name='movie_id') u_emb = tf.keras.layers.Embedding( num_users, embed_dim, name='user_embedding' )(user_input) m_emb = tf.keras.layers.Embedding( num_movies, embed_dim, name='movie_embedding' )(movie_input) u_vec = tf.keras.layers.Flatten()(u_emb) m_vec = tf.keras.layers.Flatten()(m_emb) return user_input, movie_input, u_vec, m_vec

Embedding层的参数在训练中会自动更新,评分接近的用户会在高维空间里逐渐靠到一起。你在 TensorBoard 里看到的向量分布,和 TensorFlow Playground 里那些二维点的直觉是一样的:相似的东西离得近。这里embed_dim是交互向量的维度,一般取 32 或 64,太小学不出偏好,太大会让训练集很快过拟合。

3.2 CNN 分支:用预训练 VGG16 编码海报

CNN 结构图里的卷积、池化、全连接是常见套路,但一个电影推荐项目如果从零训 CNN,海报量不够会把视觉特征学偏。我一般直接加载 ImageNet 预训练权重的 VGG16,只保留特征提取部分:

def build_poster_encoder(): base = tf.keras.applications.VGG16( include_top=False, weights='imagenet', input_shape=(224, 224, 3), pooling='avg' ) base.trainable = False inputs = tf.keras.Input(shape=(224, 224, 3), name='poster') x = base(inputs, training=False) x = tf.keras.layers.Dense(128, activation='relu', name='poster_feature')(x) return tf.keras.Model(inputs, x)

include_top=False表示不要最后的分类全连接层,pooling='avg'用全局平均池化把特征图变成 512 维向量。冻结整个 VGG16 主干后,CNN 分支实际上只训练一个 128 维的 Dense 层,参数量小很多,训练速度也快。如果有五万张以上海报,再考虑解冻最后两三个卷积块,学习率降到 1e-5 以下。

3.3 融合模型:三路输入拼进 MLP

把用户向量、电影向量、海报向量直接点积不是好选择。三个向量的来源、尺度和信息密度完全不同,点积会让数值大的那一路主导结果。常见的做法是把它们拼起来,再接一个多层感知机:

def build_model(num_users, num_movies, embed_dim=64): user_input, movie_input, u_vec, m_vec = build_cf_branch( num_users, num_movies, embed_dim ) poster_input = tf.keras.Input(shape=(224, 224, 3), name='poster') p_vec = build_poster_encoder()(poster_input) x = tf.keras.layers.Concatenate()([u_vec, m_vec, p_vec]) x = tf.keras.layers.Dense(128, activation='relu')(x) x = tf.keras.layers.Dropout(0.3)(x) x = tf.keras.layers.Dense(64, activation='relu')(x) x = tf.keras.layers.Dense(1, activation='linear')(x) model = tf.keras.Model( inputs={ 'user_id': user_input, 'movie_id': movie_input, 'poster': poster_input }, outputs=x ) return model

输出层用线性激活,因为它拟合的是 1 到 5 的评分值;如果要输出“是否点击”,再把activation换成sigmoid,损失函数同步换成binary_crossentropy即可。

这里有一个关键点:movie_embeddingposter_feature是同一部电影的两种不同表示。前者从评分交互里学,捕捉的是“看过它的人还在看什么”;后者从海报里学,捕捉的是“画面长相上的相似”。融合层负责决定两种信号各自的贡献,这也是这类源码项目最值得向面试官讲的模型设计点。

融合方式优点缺点
点积后直接输出参数少、收敛快难以表达异构特征的复杂关系
拼接后接 MLP能学非线性交互参数量大,需要 Dropout 和 BatchNorm 防过拟合

4. 训练与评估:参数怎么设,冷启动怎么缓解

模型能跑起来只是第一步。电影推荐系统源码最容易被扣分的地方,不是网络结构不够新,而是训练设置不讲究、评估指标只看 RMSE、遇到冷启动直接懵。

4.1 编译、回调与超参数选择

评分预测任务用mse作为损失函数。优化器优先 Adam,初始学习率从 1e-3 开始,配合学习率衰减:

model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='mse', metrics=[ 'mae', tf.keras.metrics.RootMeanSquaredError(name='rmse') ] ) callbacks = [ tf.keras.callbacks.EarlyStopping( monitor='val_rmse', patience=10, restore_best_weights=True ), tf.keras.callbacks.ReduceLROnPlateau( monitor='val_rmse', factor=0.5, patience=3, min_lr=1e-5 ) ] history = model.fit( train_ds, validation_data=val_ds, epochs=80, callbacks=callbacks )

EarlyStopping设置restore_best_weights=True才能拿到验证集上最好的那组参数,否则会把你停在最后几个 epoch 的偏差点上。ReduceLROnPlateau在指标停滞时把学习率减半,避免后期震荡。

如果你留意 2024 年 TensorFlow 与 PyTorch 的流行趋势,会发现论文复现更偏向 PyTorch,但这个主题选 TensorFlow 并不吃亏:Keras 高层 API 对这种“多输入、多模态”结构更友好,预训练权重调用和模型导出也省事。环境上用 Anaconda 装 TensorFlow 的方式下面还会提到。

超参数的起点可以参考这张表:

超参数起始值调整依据
embed_dim64交互数据多取 128,数据量不足时降到 32
dropout0.3验证集 loss 上升就加,欠拟合就降到 0.1
learning_rate1e-3解冻 CNN 后必须降到 1e-5 以内
batch_size64显存不足先降到 32,不要直接改数据形状

4.2 评估指标与冷启动处理

训练完成后不要只打印整体 RMSE。打分模型经常出现一种情况:所有预测都落在 3.5 附近,RMSE 很低,但排序能力几乎为零。所以还要看推荐列表有没有把高分电影排前面:

import numpy as np def evaluate_rmse(model, val_ds): preds, labels = [], [] for x, y in val_ds: preds.append(model.predict(x, verbose=0).reshape(-1)) labels.append(y.numpy().reshape(-1)) preds = np.concatenate(preds) labels = np.concatenate(labels) return float(np.sqrt(np.mean((preds - labels) ** 2)))

我还会按用户计算 Top-10 命中率:取预测分最高的十部电影,算这十部里真实评分大于等于 4 的比例。这个指标和最终用户体验更接近。

冷启动是 CNN 分支发挥作用的地方。一个全新电影没有交互记录,它的movie_embedding是随机初始化的,直接预测会输出一个被随机向量污染的结果。常见做法是用海报特征去找最相似的已知电影,再把那些电影的 ID 嵌入平均后作为初始值:

from sklearn.neighbors import NearestNeighbors movie_emb = model.get_layer('movie_embedding').get_weights()[0] poster_mat = poster_encoder.predict(all_posters) # 所有已知电影的海报特征 knn = NearestNeighbors(n_neighbors=5, metric='cosine').fit(poster_mat) _, indices = knn.kneighbors(new_poster_feature.reshape(1, -1)) new_movie_emb = movie_emb[indices].mean(axis=1)

这个操作等于用视觉内容给协同过滤做了一个“热身”。它与协同过滤算法在旅游推荐系统里的冷启动解法同源:新物品先用内容特征初始化,再等真实交互数据积累后覆盖。

4.3 TensorFlow 安装和三个容易踩的坑

环境部分,Anaconda 里安装 TensorFlow 的常见做法是建一个独立虚拟环境,避免污染 base 环境:

conda create -n tf python=3.9 -y conda activate tf pip install tensorflow

Windows 上只做实验的话,CPU 版 TensorFlow 完全够用,没必要提前装 CUDA。等到训练变慢再考虑 GPU 版和对应驱动。

代码层面有三个高频出现的坑:

第一,形状错位。Embedding 层的输入需要是(batch, 1)的形状,而tf.datauser_id常被写成(batch,)。解决办法是在map_func末尾加一个tf.expand_dims

第二,显存溢出。一上来 batch_size 从 64 改到 128 会让 CNN 分支把显存打满。优先降到 32,其次检查prefetch是不是设成了固定值,改成AUTOTUNE更稳妥。

第三,训练集和验证集处理不一致。训练集要 shuffle,验证集不要 shuffle,同时两边的poster路径和 ID 编码必须来自同一个映射表。最常见的错误是在切分数据后重新调了一次cat.codes,结果两个集合的 ID 对不上。

5. 源码落地经验:训练完怎么把 Top-N 推荐列表算出来

模型训练完不算项目结束。把权重变成“输入一个用户 ID,输出十部电影”的接口,才是推荐系统源码里真正能体现工程能力的部分。

5.1 把候选电影打包成 batch 推理,避免逐条 for 循环

线上推荐时,最朴素的做法是把用户 ID、待选电影 ID、海报路径拼成一个 batch,一次前向算出所有分数:

def recommend_for_user(model, user_id, movie_ids, poster_paths, top_k=10): user_ids = np.full(len(movie_ids), user_id, dtype=np.int32) images = np.stack([decode_poster_standalone(p) for p in poster_paths]) scores = model.predict({ 'user_id': user_ids, 'movie_id': np.array(movie_ids), 'poster': images }, batch_size=128, verbose=0)[:, 0] order = np.argsort(scores)[::-1][:top_k] return [(movie_ids[i], float(scores[i])) for i in order]

这里传给模型的movie_ids必须是第 2 章里的连续编码,不能是原始 ID;海报数组也要按movie_ids的顺序对齐。如果候选电影有几万部,逐张decode_jpeg会成为瓶颈,可以先压缩成小图或把解码结果提前缓存为 TFRecord。

5.2 冷热用户分开评估,别只看整体指标

模型有没有真的学到东西,我会用一个很小的验证脚本把用户按历史交互长度分组,分别计算 RMSE:

# evaluation_report.py preds = model.predict(val_ds, verbose=0).reshape(-1) val_df['pred'] = preds val_df['user_len'] = val_df['userId'].map(user_history_len) for cond_name, cond in [("cold", val_df['user_len'] <= 5), ("warm", val_df['user_len'] >= 20)]: part = val_df[cond] rmse = ((part['rating'] - part['pred']) ** 2).mean() ** 0.5 print(cond_name, f"{rmse:.4f}")

如果 warm 用户和 cold 用户的 RMSE 差距很小,说明 CNN 分支确实用海报内容顶住了缺失的交互信号;如果差距超过 0.4 分,就要回到数据流水线里检查海报对齐,而不是急着换网络结构。这种按用户历史长度分层的评估脚本,不需要改动模型,是验证混合推荐结构是否真正生效最直接的做法。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 19:34:13

5V和9V稳压电路(GPS、摄像头、图传)

MP9943GQ-Z官方芯片手册地址&#xff1a;规格书 PDF 在线查看 - ICSpec。 一、引脚介绍 MP9943GQ-Z芯片是QFN-8的封装&#xff0c;8个引脚&#xff0c;其功能如下表 引脚号名称1FB反馈引脚。对输出电压通过电阻分压进行采样&#xff0c;从而和内部0.8V参考电压进行比较&#…

作者头像 李华
网站建设 2026/9/11 19:33:55

猫抓插件:从嗅探到分片合并,网页媒体资源一次拿全

猫抓插件&#xff1a;从嗅探到分片合并&#xff0c;网页媒体资源一次拿全 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 你正在看一节没有下载按钮…

作者头像 李华
网站建设 2026/9/11 19:33:02

Authelia OpenID Connect 1.0 集成 Dashy:SSO 单点登录配置完整指南

Authelia OpenID Connect 1.0 集成 Dashy&#xff1a;SSO 单点登录配置完整指南 【免费下载链接】authelia The Single Sign-On Multi-Factor portal for web apps. OpenID Certified™ and Post-Quantum Cryptography Ready. 项目地址: https://gitcode.com/GitHub_Trending…

作者头像 李华
网站建设 2026/9/11 19:32:38

python += 与=的区别

1 a 12 print(a, a, id(a))3 b a4 a 15 print(a, a, id(a))6 print(b, b, id(b))7 8 print(- * 20)9 a a 1 10 print(a, a, id(a)) 11 print(b, b, id(b)) 12 13 #输出如下&#xff1a; 14 a 1 140721411760528 15 a 2 140721411760560 16 b 1 140721411760528 17 ------…

作者头像 李华
网站建设 2026/9/11 19:29:35

数字化协同能力如何影响企业股价波动

1. 研究背景与问题提出 在当今这个数字化浪潮席卷全球的时代&#xff0c;企业间的竞争早已超越了传统产品与服务的范畴。我注意到一个有趣的现象&#xff1a;那些在二级市场上突然出现大幅折价的"特价股票"&#xff0c;往往与其背后企业的数字化协同能力存在某种微妙…

作者头像 李华