简介这份资源是2025年五一数学建模竞赛C题的完整参赛作品面向备战数学建模竞赛的学生、从事社交媒体分析与推荐系统优化的科研人员及工程师。内容围绕用户行为预测与内容推荐优化展开融合LSTM、Nadam优化算法、XGBoost与加权回归模型针对新增关注数预测、用户关注行为二分类、在线状态判别及分时段互动预测四个问题给出完整建模思路与求解过程。压缩包内为1个PDF文件约2.96MB涵盖赛题分析、模型构建、结果评价与改进讨论并附Python代码实现便于读者理解与复现实验。目前已有240人学习下载。读者可借此掌握时间序列预测、分类模型与特征工程的实战方法理解FocalLoss、时间衰减因子、CNN-XGBoost组合等技巧的应用场景并获得一份可直接参考的竞赛论文写作与代码实现范本适合作为赛前训练与算法进阶的学习材料。1. 从一份五一赛 C 题说起LSTM 加 XGBoost 到底能预测什么社交媒体平台每天产生海量交互数据但真正让推荐系统头疼的不是数据量而是「用户下一步会不会关注这个博主」「他明天几点上线」「上线之后会跟谁互动」这类具体问题。2025 年五一数学建模竞赛 C 题就围绕这个场景展开给定 2024 年 7 月 11 日到 7 月 20 日十天内的用户与博主交互记录要求预测新增关注数、新关注行为、在线状态以及时段粒度的互动关系。这份资源包包含完整论文和 Python 代码四个问题分别用到了 LSTM、FocalLoss、XGBoost 和 CNN-XGBoost 混合模型适合做时间序列预测、二分类、多标签排序的从业者拿来拆解复现。如果你正在找一套「从特征工程到模型融合」的完整落地案例或者想看看数学建模竞赛里怎么把深度学习模型和树模型串起来用这份材料值得花时间跑一遍。2. LSTM 预测新增关注数四维向量输入与 Nadam 收敛调参2.1 为什么用 LSTM 而不是 ARIMA 或 Prophet问题一要求根据 7 月 11 日到 20 日的历史交互数据预测 7 月 21 日各博主的新增关注数。表面上看是个时间序列回归问题但传统 ARIMA 或 Prophet 在这里会翻车原因是输入不是单一标量序列而是「用户-博主」对之间的多维行为序列。每个时间步包含观看、点赞、评论、关注四个维度的计数而且不同用户对不同博主的交互频率差异极大稀疏性很强。LSTM 的门控机制能选择性记忆长期依赖遗忘门可以丢弃过时的交互模式输入门则保留近期高权重信号这比固定阶数的差分模型灵活得多。常见做法是把每个「用户-博主」对的历史行为拼成一个四维向量序列然后按博主聚合。但这里有个容易忽略的细节聚合方式直接影响预测目标。如果直接对四维向量求和会丢失行为类型之间的区分度如果分别建模再相加又忽略了行为之间的时序耦合。论文里的处理是保留四维结构让 LSTM 自己学权重最后通过全连接层映射到标量输出。这个设计在代码里对应input_size4hidden_size需要根据数据量调一般 64 到 128 之间。2.2 数据预处理与四维向量构建拿到原始交互记录后第一步是按天聚合。原始数据通常是「用户ID、博主ID、日期、行为类型、次数」这样的长表需要转成「用户ID、博主ID、日期、观看数、点赞数、评论数、关注数」的宽表。缺失日期补零因为 LSTM 需要等长序列。十天窗口就是十个时间步每个时间步四维特征。import pandas as pd import numpy as np # 假设 raw_df 列: user_id, blogger_id, date, action_type, count # action_type 取值: view, like, comment, follow def build_sequence(raw_df, window_days10): # 透视成宽表 pivot raw_df.pivot_table( index[user_id, blogger_id, date], columnsaction_type, valuescount, fill_value0 ).reset_index() # 确保四种行为列都存在 for col in [view, like, comment, follow]: if col not in pivot.columns: pivot[col] 0 # 按用户-博主分组取最近 window_days 天 sequences [] labels [] for (uid, bid), group in pivot.groupby([user_id, blogger_id]): group group.sort_values(date) # 补齐缺失日期 date_range pd.date_range(endgroup[date].max(), periodswindow_days) group group.set_index(date).reindex(date_range, fill_value0) seq group[[view, like, comment, follow]].values # shape: (10, 4) sequences.append(seq) # 标签是第 11 天的 follow 增量这里需要额外查表 labels.append(0) # 占位实际从标签表读取 return np.array(sequences), np.array(labels)这段代码的关键点在于reindex补齐缺失日期。很多新手会直接groupby后取values结果不同样本的时间步长度不一致喂给 LSTM 直接报维度错误。另一个坑是pivot_table的fill_value0只能填充透视后存在的组合如果某个用户-博主对在十天里从未产生某种行为该列可能整列缺失需要手动补零列。2.3 LSTM 模型结构与 Nadam 优化器配置论文里的 LSTM 结构是单层还是多层没有明确写但从「最后时刻隐藏状态接线性层」的描述看应该是单层 LSTM 加一个全连接输出。损失函数用 MSE优化器用 Nadam。Nadam 本质是 Adam 加 Nesterov 动量收敛速度比纯 Adam 快尤其在训练初期梯度方向不稳定时优势明显。import torch import torch.nn as nn class AttentionLSTM(nn.Module): def __init__(self, input_size4, hidden_size64, num_layers1): super().__init__() self.lstm nn.LSTM( input_sizeinput_size, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue ) self.fc nn.Linear(hidden_size, 1) def forward(self, x): # x shape: (batch, seq_len, input_size) lstm_out, (h_n, c_n) self.lstm(x) # 取最后时刻隐藏状态 last_hidden lstm_out[:, -1, :] # (batch, hidden_size) out self.fc(last_hidden) return out.squeeze(-1) # 训练配置 model AttentionLSTM(input_size4, hidden_size64) criterion nn.MSELoss() optimizer torch.optim.NAdam(model.parameters(), lr0.001)NAdam的lr设 0.001 是论文里的值但实际跑的时候如果 loss 震荡厉害可以降到 5e-4。batch_firstTrue这个参数很容易漏漏了的话输入维度要转成(seq_len, batch, input_size)调试时容易搞混。另外hidden_size不是越大越好十天窗口的数据量有限64 通常够用128 以上容易过拟合。2.4 训练循环与新增关注数排序输出训练循环里需要记录验证集 loss防止过拟合。论文没有提验证集划分但实操中一般按时间切分前八天训练后两天验证。预测 7 月 21 日时用全部十天数据重新训练或微调。def train_model(model, train_loader, val_loader, epochs100): criterion nn.MSELoss() optimizer torch.optim.NAdam(model.parameters(), lr0.001) best_val_loss float(inf) for epoch in range(epochs): model.train() train_loss 0 for x_batch, y_batch in train_loader: optimizer.zero_grad() pred model(x_batch) loss criterion(pred, y_batch) loss.backward() optimizer.step() train_loss loss.item() # 验证 model.eval() val_loss 0 with torch.no_grad(): for x_batch, y_batch in val_loader: pred model(x_batch) val_loss criterion(pred, y_batch).item() if val_loss best_val_loss: best_val_loss val_loss torch.save(model.state_dict(), best_lstm.pth) return model训练完成后对每个博主聚合其所有用户-博主对的预测值得到该博主的新增关注数预测。排序取前五论文结果是 B21 486、B5 454、B60 350、B15 338、B13 247。这个排序的合理性可以通过历史热力图验证B21 在十天里大多数日期都领先说明模型学到了稳定的博主热度信号。注意如果某个博主在训练集中样本极少LSTM 的预测会偏向均值排序时容易被高频博主淹没。常见做法是对样本数少于阈值的博主单独做规则兜底比如用历史均值填充。3. FocalLoss 做新关注二分类兴趣度加权与 Softmax 概率映射3.1 问题二的本质是类别极不平衡的二分类问题二要求预测指定用户在 7 月 22 日是否会新关注某个博主。这比问题一更难因为正样本真正发生关注极少大部分用户-博主对是「看了但没关注」。如果用普通交叉熵模型会倾向于全部预测为「不关注」准确率看起来很高但召回率惨不忍睹。FocalLoss 就是为解决这个问题设计的通过调制因子(1-p)^gamma降低易分类样本的权重让模型聚焦在难分类的正样本上。论文里还引入了兴趣度加权把观看、点赞、评论、关注和时间衰减组合成一个标量I_UB再送进 Softmax。这个设计的直觉是用户对博主的兴趣是累积的最近的行为权重更大。但实操中要注意兴趣度公式里的权重w1到w5不是拍脑袋定的可以用逻辑回归或简单的网格搜索在验证集上调。3.2 兴趣度计算与时间衰减因子兴趣度公式是I_UB w1*x1 w2*x2 w3*x3 w4*x4 w5*t_UB其中t_UB是博主发布内容的时间对用户兴趣的影响。论文没有展开t_UB的具体计算常见做法是用「当前日期减去博主最近发布时间」的倒数或指数衰减。import numpy as np def compute_interest(user_blogger_df, decay_lambda0.1): user_blogger_df: 包含 user_id, blogger_id, view, like, comment, follow, days_since_post # 权重可通过验证集调优 w np.array([0.1, 0.3, 0.4, 0.5, 0.2]) # 时间衰减越近的发布t_ub 越大 t_ub np.exp(-decay_lambda * user_blogger_df[days_since_post]) features np.column_stack([ user_blogger_df[view], user_blogger_df[like], user_blogger_df[comment], user_blogger_df[follow], t_ub ]) interest features w return interestdecay_lambda控制衰减速度0.1 意味着 10 天前的发布权重降到约 0.37。如果数据里博主发布频率很高可以调大到 0.2 让模型更关注近期内容。权重w的初始化可以按行为强度排序关注 评论 点赞 观看但具体值要靠验证集上的 F1 分数来选。3.3 FocalLoss 的 PyTorch 实现与参数选择FocalLoss 有两个关键参数alpha控制正负样本权重gamma控制难易样本的聚焦程度。论文里alpha0.25gamma2.0这是 RetinaNet 论文的经典配置。但在社交媒体场景下正样本比例可能低到 1% 以下alpha可以进一步降到 0.1 甚至 0.05。import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0, reductionmean): super().__init__() self.alpha alpha self.gamma gamma self.reduction reduction def forward(self, inputs, targets): # inputs: 未经过 sigmoid 的 logits # targets: 0 或 1 BCE_loss F.binary_cross_entropy_with_logits(inputs, targets, reductionnone) pt torch.exp(-BCE_loss) # p_t p if y1 else 1-p focal_loss self.alpha * (1 - pt) ** self.gamma * BCE_loss if self.reduction mean: return focal_loss.mean() elif self.reduction sum: return focal_loss.sum() else: return focal_loss注意inputs是 logits 不是概率如果模型最后一层已经加了 sigmoid需要改成F.binary_cross_entropy并手动计算pt。gamma2.0在正样本极少时可能过于激进导致训练不稳定可以先从 1.0 开始试。3.4 关注概率阈值与结果输出模型输出 logits 后经过 sigmoid 得到关注概率。论文里阈值取 0.5但实际业务中可以根据召回率要求调整。比如平台希望尽量不漏掉潜在关注可以把阈值降到 0.3代价是误报增多。def predict_follow(model, dataloader, threshold0.5): model.eval() results [] with torch.no_grad(): for x_batch, meta in dataloader: logits model(x_batch) probs torch.sigmoid(logits) preds (probs threshold).long() for i, pred in enumerate(preds): if pred 1: results.append({ user_id: meta[user_id][i], blogger_id: meta[blogger_id][i], prob: probs[i].item() }) return results论文结果是 U7 关注 B7、U6749 关注 B17、U5769 关注 B42 等。这些结果可以通过用户历史行为验证U7 与 B4 互动最多但 B7 可能是近期内容质量提升的博主模型捕捉到了兴趣迁移。提示如果某个用户对所有博主的预测概率都低于阈值说明该用户当天可能没有新关注行为不要强行输出结果。论文里只列了五个用户实际预测时应该对所有活跃用户都跑一遍。4. XGBoost 在线状态分类与时间衰减互动排序4.1 特征工程活跃天数、互动数、首次互动时间与活跃时间标准差问题三先用 XGBoost 做二分类预测用户 7 月 21 日是否在线再对在线用户预测互动博主。在线状态的特征设计很关键论文选了四个近 10 天活跃天数、日均互动数、首次互动时间、活跃时间标准差。这四个特征分别刻画了用户的活跃频率、活跃强度、作息类型和活跃时间集中度。首次互动时间这个特征容易被忽略但它能区分「早起型」和「晚睡型」用户。如果某个用户历史首次互动都在早上 6 点到 8 点那他在线预测时也应该偏向这个时段。活跃时间标准差则反映用户行为是否规律标准差小说明用户每天差不多时间上线预测置信度更高。import pandas as pd import numpy as np def build_online_features(df, user_id, target_date, window10): df: 包含 user_id, date, hour, action_type, count user_df df[df[user_id] user_id].copy() user_df[date] pd.to_datetime(user_df[date]) target_date pd.to_datetime(target_date) # 取近 window 天 start_date target_date - pd.Timedelta(dayswindow) recent user_df[(user_df[date] start_date) (user_df[date] target_date)] # 活跃天数 active_days recent[date].nunique() # 日均互动数 total_interactions recent[count].sum() avg_interactions total_interactions / window # 首次互动时间按天取最早小时再求平均 first_hours recent.groupby(date)[hour].min() avg_first_hour first_hours.mean() if len(first_hours) 0 else 12 # 活跃时间标准差 hour_std recent[hour].std() if len(recent) 1 else 0 return np.array([active_days, avg_interactions, avg_first_hour, hour_std])window10对应论文里的十天窗口。如果用户在某些天完全没有记录active_days会小于 10这是合理的信号。avg_first_hour在用户从未互动时设为 12 作为中性值避免 NaN 导致 XGBoost 报错。4.2 XGBoost 二分类参数配置与在线预测XGBoost 的参数比 LSTM 多但调参逻辑更直观。max_depth控制树深度learning_rate控制每棵树的贡献n_estimators是树的数量。对于在线状态这种二分类任务objectivebinary:logisticeval_metriclogloss。import xgboost as xgb from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, f1_score # 假设 X 是特征矩阵y 是在线标签 X_train, X_val, y_train, y_val train_test_split(X, y, test_size0.2, random_state42) model xgb.XGBClassifier( objectivebinary:logistic, max_depth4, learning_rate0.1, n_estimators100, subsample0.8, colsample_bytree0.8, reg_alpha0.1, reg_lambda1.0, random_state42 ) model.fit( X_train, y_train, eval_set[(X_val, y_val)], early_stopping_rounds10, verboseFalse ) # 预测 y_pred_proba model.predict_proba(X_val)[:, 1] y_pred (y_pred_proba 0.5).astype(int) print(fAccuracy: {accuracy_score(y_val, y_pred):.4f}) print(fF1: {f1_score(y_val, y_pred):.4f})max_depth4是保守设置防止过拟合。如果数据量上万可以加到 6。early_stopping_rounds10在验证集 loss 不再下降时提前停止省时间。论文里预测 U22405 离线从历史数据看该用户 7 月 10 日活动量仅 10远低于其他日期模型捕捉到了这个异常。4.3 时间衰减因子与互动博主排序确定用户在线后需要预测他会跟哪些博主互动。论文引入时间衰减因子gamma^(10-k)其中k是距离当前日期的天数gamma取 0.8。这意味着 10 天前的互动权重只有0.8^10 ≈ 0.107而昨天的互动权重是0.8^1 0.8。def compute_weighted_interest(user_blogger_history, gamma0.8): user_blogger_history: 包含 blogger_id, days_ago, like, comment, follow user_blogger_history[weight] gamma ** user_blogger_history[days_ago] user_blogger_history[weighted_score] ( user_blogger_history[weight] * (user_blogger_history[like] user_blogger_history[comment] user_blogger_history[follow]) ) # 按博主聚合 blogger_scores user_blogger_history.groupby(blogger_id)[weighted_score].sum() blogger_scores blogger_scores.sort_values(ascendingFalse) return blogger_scores.head(3)这里忽略了观看行为因为观看的互动意图弱。如果数据里观看次数远大于其他行为加入观看会稀释信号。gamma0.8是论文值实际可以调如果用户兴趣变化快降到 0.6如果兴趣稳定升到 0.9。论文结果是 U9 与 B24、B23、B6 互动U16 与 B42、B17、B3 互动。这些排序可以通过历史互动热力图验证通常排名靠前的博主在近几天都有较高互动。注意如果某个用户历史互动博主少于 3 个排序结果会不足 3 个这时候不要强行补全输出实际数量即可。5. CNN-XGBoost 时段粒度预测24 小时特征向量与混合模型融合5.1 为什么问题四需要引入 CNN问题四要求预测用户在 7 月 23 日每个小时的在线状态和互动博主。相比问题三的「是否在线」这里多了「什么时候在线」的维度。直接把 24 小时展开成 24 个二分类任务会忽略小时之间的局部相关性比如用户可能在 14 点到 16 点连续活跃这三个小时的特征应该互相影响。CNN 的卷积核正好能捕捉这种局部模式1D 卷积在时间维度上滑动提取相邻小时的联合特征。论文里的 CNN-XGBoost 是串行结构CNN 先提取特征展平后送进 XGBoost 做分类和回归。这个设计比端到端训练更稳定因为 XGBoost 对特征尺度不敏感CNN 输出的特征可以直接用。5.2 24 小时时段特征构建每个小时构造三个特征该时段是否活跃、该时段互动数、当天总互动数。十天窗口就是 10 个样本每个样本是 24×3 的矩阵。def build_hourly_features(df, user_id, target_date, window10): 返回 shape: (window, 24, 3) user_df df[df[user_id] user_id].copy() user_df[date] pd.to_datetime(user_df[date]) target_date pd.to_datetime(target_date) features [] for day_offset in range(window, 0, -1): date target_date - pd.Timedelta(daysday_offset) day_df user_df[user_df[date] date] # 当天总互动数 total_interactions day_df[count].sum() hourly np.zeros((24, 3)) for hour in range(24): hour_df day_df[day_df[hour] hour] if len(hour_df) 0: hourly[hour, 0] 1 # 活跃 hourly[hour, 1] hour_df[count].sum() # 该时段互动数 hourly[hour, 2] total_interactions # 当天总互动数 features.append(hourly) return np.array(features) # (window, 24, 3)day_offset从window到 1 是保证时间顺序最近的日期在最后。如果顺序反了CNN 的卷积核会学到错误的时序模式。5.3 CNN 特征提取与 XGBoost 分类CNN 部分用一维卷积kernel_size3表示同时看相邻三个小时。padding1保持输出长度不变。池化层用MaxPool1d(2)把 24 小时降到 12 小时减少计算量。import torch import torch.nn as nn class CNNFeatureExtractor(nn.Module): def __init__(self, input_channels3, hidden_channels16): super().__init__() self.conv1 nn.Conv1d(input_channels, hidden_channels, kernel_size3, padding1) self.relu nn.ReLU() self.pool nn.MaxPool1d(2) self.conv2 nn.Conv1d(hidden_channels, hidden_channels*2, kernel_size3, padding1) def forward(self, x): # x shape: (batch, 3, 24) x self.relu(self.conv1(x)) x self.pool(x) x self.relu(self.conv2(x)) x self.pool(x) # 展平 x x.view(x.size(0), -1) return x # 使用示例 cnn CNNFeatureExtractor() # 假设 hourly_features shape: (batch, 10, 24, 3) # 需要转成 (batch*10, 3, 24) batch_size, window, hours, channels 32, 10, 24, 3 x torch.randn(batch_size * window, channels, hours) features cnn(x) # features shape: (batch*10, hidden_channels*2*6)hidden_channels16是保守值数据量大可以加到 32。conv2的输出长度经过两次池化从 24 降到 6展平后是32*6192维。这个特征向量再送进 XGBoost。5.4 混合模型训练与时段互动预测训练分两阶段先训练 CNN 提取特征再训练 XGBoost 做分类。CNN 可以用自编码器或对比学习预训练但简单起见可以直接用随机初始化因为 XGBoost 对特征质量有一定容忍度。# 阶段一提取 CNN 特征 cnn.eval() all_features [] all_labels [] with torch.no_grad(): for x_batch, y_batch in train_loader: # x_batch shape: (batch, window, 24, 3) batch_size, window, hours, channels x_batch.shape x_reshaped x_batch.view(batch_size * window, channels, hours) features cnn(x_reshaped) features features.view(batch_size, -1) # 聚合 window 维度 all_features.append(features.numpy()) all_labels.append(y_batch.numpy()) X_cnn np.vstack(all_features) y_cnn np.concatenate(all_labels) # 阶段二XGBoost 分类 xgb_model xgb.XGBClassifier( objectivebinary:logistic, max_depth4, learning_rate0.1, n_estimators100 ) xgb_model.fit(X_cnn, y_cnn)features.view(batch_size, -1)把 window 维度也展平了这样每个样本是一个长向量。如果 window 很大可以先对 window 维度做平均池化再展平。论文结果是 U1951 在 3:00 和 16:00 与 B21 互动15:00 与 B68 互动。这个结果可以通过用户历史互动热力图验证U1951 在凌晨和下午的互动频率确实较高。提示CNN-XGBoost 的训练时间比纯 XGBoost 长如果数据量不大可以直接用 XGBoost 加手工特征效果差距可能不到 2%。混合模型的价值在于特征自动提取适合特征工程成本高的场景。6. 从跑通到跑好模型验证、参数敏感性与一个反直觉的调参习惯6.1 时间序列交叉验证的正确做法很多人做时间序列预测时直接用train_test_split随机划分这是血泪教训级别的错误。随机划分会让未来数据泄露到训练集验证分数虚高上线后直接翻车。正确做法是按时间切分比如前 7 天训练第 8 天验证第 9-10 天测试。或者用滚动窗口交叉验证每次训练集增加一天验证集后移一天。def time_series_cv(df, n_splits3): dates sorted(df[date].unique()) fold_size len(dates) // (n_splits 1) for i in range(n_splits): train_end fold_size * (i 1) val_end train_end fold_size train_dates dates[:train_end] val_dates dates[train_end:val_end] train_df df[df[date].isin(train_dates)] val_df df[df[date].isin(val_dates)] yield train_df, val_dfn_splits3表示三次滚动每次验证集大小相同。如果数据只有十天fold_size可能只有 2-3 天验证结果波动会比较大可以适当减少n_splits。6.2 关键参数敏感性分析LSTM 的hidden_size、XGBoost 的max_depth、FocalLoss 的gamma是对结果影响最大的三个参数。论文没有给敏感性分析但实操中值得跑一遍网格搜索。参数取值范围影响建议LSTM hidden_size32, 64, 128太小欠拟合太大过拟合从 64 开始数据量大于 1 万可试 128XGBoost max_depth3, 4, 6, 8深度越大越容易过拟合在线分类用 4互动排序用 6FocalLoss gamma1.0, 2.0, 3.0越大越聚焦难样本正样本比例低于 1% 时用 2.0否则 1.0Nadam lr1e-4, 5e-4, 1e-3太大震荡太小收敛慢从 1e-3 开始loss 震荡降 5e-4这张表不是绝对的但能帮你快速定位调参方向。如果验证集 loss 一直不降先检查学习率是不是太大如果训练集 loss 很低但验证集很高检查hidden_size或max_depth是不是太大。6.3 一个反直觉的习惯先跑通基线再上模型我见过太多人一上来就搭 LSTM 加 Attention结果调了两周还不如线性回归。从那以后我每次做预测任务都强制走一遍基线流程先用历史均值或最近一天的值做预测记录 MSE 或 F1再用逻辑回归或决策树跑一版最后才上深度学习。如果深度学习比基线提升不到 5%要么数据有问题要么特征没做好要么任务本身就不适合复杂模型。这份五一赛 C 题的代码里问题一的 LSTM 如果换成「过去十天新增关注数的均值」排名前五的博主可能差不多但具体数值会有偏差。问题三的 XGBoost 如果换成逻辑回归在线分类的准确率可能只差两三个百分点。这不是说模型没用而是说基线能帮你判断模型到底贡献了多少。跑基线还有一个好处暴露数据泄露。如果基线模型在验证集上表现异常好比如准确率 99%大概率是特征里混入了标签信息。这种问题在复杂模型里很难发现因为模型会自己学会利用泄露特征。希望帮到你。本文还有配套的精品资源点击获取
