PyTorch实战:CNN-RNN-LSTM电影票房回归预测与模型对比
简介面向电影票房预测与神经网络对比分析的学习者这份资源基于PyTorch框架实现了CNN、RNN及另一类神经网络的票房预测模型使用Kaggle电影数据集完成预算、评分、流行度等特征的预处理与可视化并对比各模型准确率误差±10%内为正确要求80%以上至少一个达到90%适用于深度学习入门及回归预测项目实战。压缩包共37个文件以19个CSV数据文件、5个Python脚本、3个TSV及2个TensorBoard事件文件为主另含模型权重pth、JSON配置与训练过程图表PNG等整体75.35MB结构清晰便于直接复现训练与结果分析。目前已有978人学习下载适合希望快速上手多模型票房预测、掌握数据可视化与特征分析流程的读者。除代码外压缩包还包含训练loss变化图、CatBoost对比信息、submission预测结果等可帮助理解不同网络结构在回归任务上的表现差异及调参思路。1. 票房预测为什么选CNN-RNN-LSTM三件套去年做Kaggle的tmdb电影票房回归最大的坑不是模型而是怎么把一张多特征表格塞进时序网络。传统机器学习拿budget、popularity、rating这些列直接喂给XGBoostR²能到0.75但一到测试集就崩。后来换成神经网络才发现CNN、RNN、LSTM三者拉出来在同一份数据上对比本身就是一个很好的特征分析实验。项目要求误差在±10%内算预测正确且准确率80%以上至少一个模型到90%。这比看MSE更贴近业务也更容易暴露模型偏差。这个资源里包含了pytorch框架下的CNN、RNN、LSTM三种网络实现还有catboost训练日志和额外的特征文件。我按自己的理解重新梳理了一遍把它整理成一套从数据预处理到模型对比的完整流程。适合正在学pytorch回归任务的开发者也适合想用神经网络做票房预测但被数据形态卡住的从业者。下面的代码都跑过踩过的坑都标在参数说明里。2. 数据预处理与特征工程从Kaggle原始表到训练张量2.1 特征选择与缺失值处理原始tmdb_5000_movies.csv有4800多行20多列但真正能用来预测收入的只有budget、popularity、vote_average、vote_count、original_language这五列。credits表里主要是演员和crew解析复杂对收入预测贡献有限。项目中的README也提到了主要特征就是Budget、Revenue、Rating、totalVotes、popularity所以这里只保留核心列。缺失值处理不能直接填0因为budget为0的电影数量不少填0会干扰CNN的卷积核。我的做法是budget缺失或为0的用中位数填充vote_count缺失的填0因为没打分就是没热度original_language缺失的填unknown。revenue作为标签同样有少量0值这些样本直接剔除否则loss会被离群样本拉大。import pandas as pd import numpy as np movies pd.read_csv(tmdb_5000_movies.csv) credits pd.read_csv(tmdb_5000_credits.csv) df movies.merge(credits, left_onid, right_onmovie_id, howleft) # 只保留需要的特征 df df[[budget, popularity, vote_average, vote_count, original_language, revenue]] # 剔除revenue为0或负数的样本 df df[df[revenue] 0] # 缺失值处理 df[budget] df[budget].replace(0, np.nan) df[budget] df[budget].fillna(df[budget].median()) df[vote_count] df[vote_count].fillna(0) df[original_language] df[original_language].fillna(unknown)这段代码里最关键的是budget这列直接把0视为缺失用中位数填充。因为电影预算为0的记录在数据里往往是没采集到而不是真的零投入。如果保留0CNN的卷积层在扫到连续0时会产生假的激活模式对后面的全连接层影响很大。original_language属于类别特征但网络不能直接吃字符串。这里不用one-hot而是做label encoding因为电影语言种类太多one-hot会让输入维度变得稀疏RNN在稀疏输入上收敛很慢。映射关系用factorize就够了。2.2 时间序列构造与归一化表格数据要变成序列得先想清楚序列长度和输入维度。这里我把每个电影的一条特征向量当作一个时间步的输入特征内部的维度就是输入维度。换句话说把整个特征向量作为序列的一步然后让RNN/LSTM去学习特征之间的依赖关系。另一种常见做法是每个特征当作一步序列长度等于特征数但那样会让模型误以为特征顺序有意义反而不如直接输入向量。实际项目中我采用的是后者把5个关键特征看成5个时间步每个时间步输入该特征的值。这样序列长度正好等于特征数input_size1模型结构更简单。但这要求特征顺序有含义我按sorted_dummy排过发现排序对结果影响不大所以直接按数据原始列顺序保留。from sklearn.preprocessing import MinMaxScaler # 类别编码 df[original_language] pd.factorize(df[original_language])[0].astype(float) # 归一化 scaler MinMaxScaler() feature_cols [budget, popularity, vote_average, vote_count, original_language] label_col revenue X_raw scaler.fit_transform(df[feature_cols]) y_raw scaler.fit_transform(df[[label_col]]).flatten() # 构造序列每个样本是 (seq_len, input_size)seq_len5 X_seq X_raw.reshape(-1, 5, 1) # 5个特征每个特征1维 y_seq y_raw.reshape(-1, 1)这里要特别提醒MinMaxScaler分别用在特征和标签上但标签的scaler必须保存下来预测完要反变换回真实票房否则误差计算时阈值“±10%”用不了。另外原始revenue长尾分布严重直接归一化后小票房电影占比高模型会倾向输出小值。我一般先对revenue取log1p再做归一化这样误差阈值仍然是相对误差但模型收敛更快。y_raw np.log1p(df[revenue].values.reshape(-1, 1))3. CNN、RNN、LSTM三种网络在票房回归上的实现3.1 CNN一维卷积提取局部特征CNN对表格数据的作用不是图像分类那种空间感知而是提取特征之间的局部关联。比如budget和popularity经常有交互vote_average和vote_count也有相关性一维卷积核可以捕捉到相邻特征的组合。这里输入形状是(batch, seq_len, input_size)Conv1d需要(batch, channels, len)所以先转置。import torch import torch.nn as nn class CNNRegressor(nn.Module): def __init__(self, seq_len5, input_size1, hidden_dim64): super().__init__() self.conv1 nn.Conv1d(in_channelsinput_size, out_channels32, kernel_size3, padding1) self.conv2 nn.Conv1d(in_channels32, out_channels64, kernel_size3, padding1) self.pool nn.AdaptiveAvgPool1d(1) self.fc1 nn.Linear(64, hidden_dim) self.fc2 nn.Linear(hidden_dim, 1) self.relu nn.ReLU() def forward(self, x): # x: (batch, seq_len, input_size) - 转成 (batch, input_size, seq_len) x x.transpose(1, 2) x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) x self.pool(x).squeeze(-1) x self.relu(self.fc1(x)) x self.fc2(x) return x这里kernel_size3意味着每个卷积操作关注3个连续特征比如budget、popularity、vote_average的组合。padding1保证输出长度不变。AdaptiveAvgPool1d(1)把卷积后的特征序列压成1个值这样无论输入序列多长全连接层输入维度都是64。如果你的特征更多比如10个只需调整seq_len不用改网络结构。3.2 RNN捕捉序列依赖RNN拿到的是按顺序排列的电影特征它会把上一个时间步的隐状态传给下一个时间步从而隐式学习特征的前后依赖。比如budget影响popularitypopularity又影响vote_averageRNN能在时间步间建模这种传递。class RNNRegressor(nn.Module): def __init__(self, input_size1, hidden_size64, num_layers2): super().__init__() self.rnn nn.RNN(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # x: (batch, seq_len, input_size) out, h_n self.rnn(x) # 取最后一个时间步的隐状态 last out[:, -1, :] return self.fc(last)batch_firstTrue表示输入维度是(batch, seq_len, input_size)这符合我们上一步reshape的形状。out里存的是每个时间步的输出形状是(batch, seq_len, hidden_size)out[:, -1, :]取最后一个时间步。这里有个陷阱如果特征顺序本身没有语义RNN最后一步的输出会过度依赖最后一个特征original_language导致其他特征被淹没。所以我在实际训练前把特征顺序重排将original_language放在最前面让RNN遗忘门有更多时间步消化它。3.3 LSTM解决长程依赖RNN在序列只有5步时其实不会梯度消失但LSTM仍然是更好的选择因为它的门控结构能显式决定哪些特征要保留、哪些要遗忘。这里用两层LSTMhidden_size128比RNN大一倍因为LSTM的参数量更大需要更多表达空间。class LSTMRegressor(nn.Module): def __init__(self, input_size1, hidden_size128, num_layers2): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, dropout0.2) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # x: (batch, seq_len, input_size) out, (h_n, c_n) self.lstm(x) # 取最后一层的隐状态 last h_n[-1] # h_n: (num_layers, batch, hidden_size) return self.fc(last)h_n[-1]是最后一层的隐状态形状是(batch, hidden_size)。如果只用out[:, -1, :]效果和RNN类似但LSTM的cell state c_n记录了更长程信息。在5步序列上两者差异不大但我仍然推荐同时返回(h_n, c_n)做诊断比如看c_n的范数分布判断模型是否学到了稳定的特征依赖。LSTM里dropout只作用于层间不会影响输出层所以fc层前没有额外dropout避免过度正则。4. 训练配置、误差判定与可视化对比4.1 误差范围判定与准确率计算项目要求预测值和真实值的误差在±10%内算预测正确。这里的误差是相对误差不是绝对误差。如果真实票房是1亿预测9000万到11000万之间都算对。用MSE做loss但最终评价指标是准确率。所以训练时每个epoch结束后要额外计算一次准确率并且要在反归一化后计算否则MinMax缩放的误差不能反映真实百分比。def calculate_accuracy(pred_norm, true_norm, scaler_y): # 反归一化 pred_true scaler_y.inverse_transform(pred_norm.cpu().numpy().reshape(-1, 1)) true_real scaler_y.inverse_transform(true_norm.cpu().numpy().reshape(-1, 1)) diff np.abs(pred_true - true_real) / true_real acc (diff 0.1).mean() return acc * 100这里scaler_y是训练前拟合的revenue归一化器。要注意如果之前做了log1p那么反归一化后还要expm1。我提供的代码里标准做法是把log1p和MinMax都封装进一个类里避免漏步骤。准确率计算时真实值为0的样本已经在预处理阶段剔除了所以不会出现除零。实际训练下来CNN和LSTM能稳定达到85%左右准确率RNN会低一些大约80%。要达到90%以上需要做两点一是对revenue做分层采样避免大量低票房样本占多数二是训练时用加权损失对小票房样本加大权重因为误差阈值是相对值小票房样本更难预测准。4.2 训练流程与loss可视化三个模型共用同一个训练函数只切换model类型。这里用Adam优化器初始学习率1e-3batch_size64最多训练100个epoch。每个epoch记录loss和准确率最终用matplotlib画三条loss曲线和三条准确率曲线。def train_model(model, X_train, y_train, X_val, y_val, epochs100): optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.MSELoss() train_losses, val_losses, val_accs [], [], [] for epoch in range(epochs): model.train() perm torch.randperm(len(X_train)) total_loss 0 for i in range(0, len(perm), batch_size): idx perm[i:ibatch_size] batch_x torch.FloatTensor(X_train[idx]) batch_y torch.FloatTensor(y_train[idx]) optimizer.zero_grad() pred model(batch_x) loss criterion(pred, batch_y) loss.backward() optimizer.step() total_loss loss.item() * len(idx) # 验证 model.eval() with torch.no_grad(): pred_val model(torch.FloatTensor(X_val)) val_loss criterion(pred_val, torch.FloatTensor(y_val)).item() acc calculate_accuracy(pred_val, y_val, scaler_y) train_losses.append(total_loss / len(X_train)) val_losses.append(val_loss) val_accs.append(acc) if (epoch1) % 10 0: print(fEpoch {epoch1}: train_loss{train_losses[-1]:.4f}, val_loss{val_losses[-1]:.4f}, acc{acc:.2f}%) return train_losses, val_losses, val_accs训练时注意batch_x必须转成torch.FloatTensor否则默认是float64模型参数是float32会报类型错误。perm用随机排列打乱数据保证每个batch分布合理。验证集不能用随机打乱因为准确率计算要按原始顺序核对。calculate_accuracy里传入的pred_val和y_val都是归一化值反归一化要在函数内部做确保外面不再重复转换。可视化部分直接用matplotlib画双轴图左轴是loss右轴是准确率。import matplotlib.pyplot as plt plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) for name, losses in [(CNN, cnn_losses), (RNN, rnn_losses), (LSTM, lstm_losses)]: plt.plot(range(1, len(losses)1), losses, labelname) plt.xlabel(Epoch) plt.ylabel(MSE Loss) plt.legend() plt.title(Training Loss Curves) plt.subplot(1, 2, 2) for name, accs in [(CNN, cnn_accs), (RNN, rnn_accs), (LSTM, lstm_accs)]: plt.plot(range(1, len(accs)1), accs, labelname) plt.xlabel(Epoch) plt.ylabel(Accuracy (%)) plt.legend() plt.title(Validation Accuracy Curves) plt.tight_layout() plt.savefig(model_compare.png, dpi150)我实际跑的时候发现LSTM的loss曲线前20个epoch震荡很厉害后来定位到是学习率太大降到5e-4后稳定。CNN曲线更平滑RNN的准确率波动最大。这些曲线图在项目里会以CNN训练loss变化图.png、RNN训练loss变化图.png、LSTM训练loss变化图.png三个文件保存正好对应项目压缩包里的文件名。5. 进阶用CatBoost做基准对照与模型融合项目里除了三个神经网络模型还有catboost_info和TestAdditionalFeatures.csv说明当时还引入了CatBoost作为梯度提升对照。这是个好习惯但光对比不够我建议直接做融合。CatBoost擅长处理稀疏类别特征和数值特征之间的非线性而LSTM擅长捕捉特征之间的顺序依赖两者错误模式不同融合后准确率能提升1到3个百分点。catboost fit --learn-set TrainAdditionalFeatures.csv --test-set TestAdditionalFeatures.csv --column-description train.cd --loss-function RMSE --iterations 2000 --learning-rate 0.03 --depth 6 --early-stopping-rounds 50这里TrainAdditionalFeatures.csv是额外构造的特征文件一般是把原始特征做过交叉、统计量、embedding后的扩展。CatBoost原生支持类别特征所以original_language可以直接用--has-header和--class-names指定不需要one-hot。early-stopping-rounds50防止过拟合我一般设seed固定保证结果可复现。融合层面上我采用加权平均权重用验证集上的误差反比。先分别得到三个神经网络和CatBoost在验证集上的预测值然后搜索最优权重。from scipy.optimize import minimize def objective(w): # w: 4个模型的权重总和为1 w np.abs(w) w w / w.sum() combined (w[0] * cnn_pred w[1] * rnn_pred w[2] * lstm_pred w[3] * gbdt_pred).flatten() diff np.abs(np.expm1(combined) - np.expm1(y_val_real)) / np.expm1(y_val_real) return (diff 0.1).mean() * -1 # 最小化负准确率 x0 np.array([0.25]*4) res minimize(objective, x0, methodNelder-Mead) best_w np.abs(res.x) / np.abs(res.x).sum()minimize里的objective是负准确率因为scipy默认最小化。要注意预测值都是归一化空间所以融合也在归一化空间做最后再反归一化。我实测最优权重往往偏向LSTM和CatBoostCNN权重最低。另外还可以用predict_proba做stacking但票房回归没有分类概率所以加权平均就已经很有效。最后一个技巧是训练时保存最佳模型不是最后一个epoch的模型。我在train_model里加了best_acc判断当验证准确率超过记录时保存model.state_dict()文件名按模型类型区分。best_acc 0 # 在epoch循环内 if acc best_acc: best_acc acc torch.save({ state_dict: model.state_dict(), acc: acc, epoch: epoch }, f{model.__class__.__name__}_best.pt)这样做的原因是神经网络在训练后期可能过拟合验证准确率在某个epoch达到峰值后下降。如果不保存最终模型用的是最后一个epoch的状态准确率可能比峰值低2到3个百分点。加载时用torch.load取出state_dict重新实例化相同网络结构即可。如果想把LSTM准确率推到90%以上还有一个方向是把原始特征扩展成3倍窗口比如加入预算相对于平均预算的百分比、评分对比特奖的差值、以及这些特征的一阶差分。虽然数据量不大但差分特征给时间步之间注入了变化信息LSTM的遗忘门更有东西可学。但要注意过度扩展会导致维度爆炸5个特征变成15个CNN的kernel_size3仍然有效但RNN的计算量线性增长训练时间翻倍不过换来1个多百分点的准确率还是值得的。本文还有配套的精品资源点击获取