1. 项目概述:当数学建模遇上深度学习
那年研究生数学建模D题的赛题,我至今记忆犹新。它本质上是一个典型的、数据驱动的复杂系统分析与预测问题,通常涉及高维、非线性、时序或空间关联的数据。对于习惯了传统统计模型和优化算法的建模者来说,这类题目既是挑战,也是拥抱新工具的绝佳机会。题目要求我们基于给定的数据集,构建模型来预测、分类或识别某种模式。这正是深度学习的“主场”。
我选择的解题核心是BP神经网络(Backpropagation Neural Network)和卷积神经网络(Convolutional Neural Network, CNN),并用PyTorch框架实现。这不是为了炫技,而是基于问题特性的务实选择。BP神经网络,作为最经典的多层前馈网络,是处理表格数据、进行非线性回归和分类的“万能近似器”基础。而CNN,凭借其局部连接和权值共享的特性,天生擅长从具有网格拓扑结构的数据(如图像、时序信号、甚至某些经过重构的表格数据)中提取空间或局部特征。将两者结合或对比使用,能让我们从不同维度“攻击”问题。
这篇分享,我会详细拆解当年解题的全过程:从对题目的理解与数据预处理,到两种网络模型的设计、PyTorch实现、训练调优,再到最终的模型集成与结果分析。我会重点分享那些在标准教程里不会写的“坑”和“技巧”,比如如何将抽象的数学建模问题“翻译”成神经网络能理解的输入输出格式,如何在有限的比赛时间内进行高效的模型调试,以及如何写出既清晰又高效的PyTorch代码。无论你是正在备战数模的研究生,还是刚接触PyTorch和深度学习想找个实战项目练手的朋友,希望这篇基于真实赛题复盘的经验,能给你带来实实在在的启发。
2. 解题思路与模型选型分析
面对数学建模赛题,第一步永远不是急着写代码,而是彻底理解问题,并将问题“映射”到合适的数学模型上。D题通常不会直接说“请用神经网络解题”,它可能描述一个物理过程、一个经济现象或一个社会系统的观测数据。我们的任务就是透过现象看本质,识别出这属于哪类机器学习任务。
2.1 问题抽象与任务定义
首先,我们需要明确几个关键点:
- 输入是什么?赛题提供的数据集。可能是纯数值表格(每行一个样本,每列一个特征),也可能是图像、时间序列,或者是它们的混合。需要仔细阅读数据说明,理解每个字段的物理意义。
- 输出是什么?题目要求预测的目标变量。可能是连续值(回归问题,如预测房价、销量),也可能是离散类别(分类问题,如判断设备故障类型、用户信用等级),或者是复杂的结构化输出(如时间序列预测、多标签分类)。D题往往要求预测未来某个时刻的状态或对样本进行分级评价。
- 数据有何特性?数据是否存在缺失、异常?特征之间量纲差异是否巨大?是否存在明显的时序或空间相关性?这些特性直接决定了我们后续的预处理步骤和模型选择。
以我曾处理过的一个类似D题为例:题目提供了某地区历史气象数据(温度、湿度、气压等)和电力负荷数据,要求预测未来24小时的负荷曲线。这显然是一个多变量时间序列回归预测问题。输入是过去一段时间窗口内的多变量序列,输出是未来一段时间窗口的单变量序列。
2.2 为什么选择BP神经网络和CNN?
基于问题抽象,我们来分析模型选型的逻辑:
BP神经网络(全连接网络)的适用场景:
- 核心优势:强大的非线性拟合能力。只要网络足够深、足够宽,理论上可以逼近任何连续函数。这对于挖掘高维特征间复杂的、非线性的相互作用规律非常有效。
- 在本类问题中的应用:当我们的特征是基于领域知识构建的、彼此独立的“手工特征”时,BP网络是首选。例如,在上述负荷预测问题中,如果我们已经计算出了过去24小时的平均负荷、最高负荷、最低负荷、负荷波动方差等统计特征作为输入,那么这些特征之间的关系就适合用全连接网络来学习。
- 选型理由:实现简单,调参思路相对成熟,是检验问题是否具有非线性可学习性的“基线模型”。如果连一个精心调参的BP网络都学不好,那可能意味着特征工程没到位,或者数据本身噪声太大。
卷积神经网络(CNN)的适用场景:
- 核心优势:自动提取局部特征,并具有平移不变性。通过卷积核在输入数据上滑动,它能高效地捕捉局部模式(如边缘、纹理、周期信号中的特定波形)。
- 在本类问题中的“创造性”应用:对于时间序列数据,我们可以将其视为一个一维“图像”(通道数等于特征维数,长度等于时间步长)。一维CNN能自动学习序列中的局部时间模式(例如,负荷在每天早高峰的上升模式)。对于某些空间分布数据(如不同监测站点的数据),甚至可以重构为二维网格,使用二维CNN。这是将CNN应用于非图像数据的精髓,也是解题的亮点之一。
- 选型理由:相比全连接网络,CNN的参数更少,训练更快,且更不容易过拟合,因为它通过权值共享隐式地引入了“局部性”先验知识。对于具有明显局部相关性的数据(如时序、空间数据),CNN往往能取得比纯BP网络更好的效果。
最终策略:在实际解题中,我通常会采用“双模型对比验证”或“特征融合”的策略。即分别用BP网络(处理全局统计特征)和CNN(处理原始序列的局部特征)进行建模,对比其性能。有时,还会将BP网络提取的高层抽象特征与CNN提取的局部特征进行拼接,再输入到一个更小的融合网络中进行最终预测,这常常能提升模型的鲁棒性和精度。
2.3 PyTorch框架的优势
在紧张的比赛环境中,框架的选择至关重要。我选择PyTorch而非其他框架(如TensorFlow),主要基于以下几点实战考量:
- 动态计算图(Eager Execution):这使得调试变得极其直观。你可以像写普通Python代码一样逐行执行,随时用
print()或调试器查看张量的形状和值。在模型结构探索和快速原型验证阶段,这能节省大量时间。 - Pythonic的API设计:PyTorch的代码风格非常贴近Python,学习曲线相对平缓。自定义网络层、损失函数、训练循环都非常灵活直观,这对于需要快速实现创新想法的数学建模比赛来说是个巨大优势。
- 强大的生态系统:
torch.nn模块提供了丰富的网络层,torch.optim提供了各种优化器,torch.utils.data的Dataset和DataLoader让数据加载和批处理变得异常简单。这些都能让我们专注于模型和问题本身,而不是底层实现。 - 社区与资源:PyTorch拥有活跃的社区,遇到任何问题几乎都能快速找到解决方案或参考代码,这在分秒必争的比赛中是宝贵的“外援”。
注意:在比赛开始前,确保你的编程环境(Anaconda + PyTorch + CUDA if available)已经搭建并测试无误。不要在比赛期间才折腾环境,那会打乱节奏、影响心态。
3. 数据预处理与特征工程实战
模型决定上限,特征决定下限。在深度学习时代,特征工程的重要性并未降低,而是演变为如何更好地将领域知识融入数据表示,以及如何为神经网络准备“可口”的输入。
3.1 数据清洗与探索性分析
拿到数据后,我首先会用Pandas进行加载和初步探索。
import pandas as pd import numpy as np import matplotlib.pyplot as plt # 加载数据 data = pd.read_csv('competition_data.csv') print(data.info()) # 查看数据概览,有无缺失,类型 print(data.describe()) # 查看数值分布 print(data.head())关键检查点:
- 缺失值处理:对于时间序列,常用前后插值法
data.fillna(method='ffill').fillna(method='bfill')。对于其他数据,可根据情况用均值、中位数或基于其他特征的预测值填充。如果缺失太多,考虑是否直接删除该特征或样本。 - 异常值检测:通过箱线图或
describe()查看数据的上下限。对于明显脱离物理意义或统计分布的异常点,需要谨慎处理。在负荷预测中,一个远高于历史极值的负荷点可能是抄表错误,可以用前后时刻的平均值或滑动窗口的中位数替代。 - 重复值检查:
data.duplicated().sum(),直接删除重复行。
3.2 特征构造:为BP网络和CNN准备“食材”
这是将原始数据转化为模型输入的关键一步,思路因模型而异。
为BP网络构造特征:BP网络喜欢固定长度的、有明确意义的特征向量。我们需要从原始数据中提炼出这样的特征。
- 对于时序数据:采用滑动窗口法。假设我们要用过去T个时间步的数据预测未来K步。对于每个时间点t,我们构造一个特征向量,包含从t-T+1到t时刻的所有特征值。此外,可以加入一些统计特征,如该窗口内的均值、方差、最大值、最小值、斜率等,这些特征往往具有很强的预测能力。
- 示例代码(构造滑动窗口特征):
def create_sliding_windows(data, window_size, forecast_horizon): X, y = [], [] for i in range(len(data) - window_size - forecast_horizon + 1): X.append(data[i:i+window_size]) # 输入窗口 y.append(data[i+window_size : i+window_size+forecast_horizon, target_column_idx]) # 输出目标 return np.array(X), np.array(y) - 领域特征:结合题目背景。在负荷预测中,“是否节假日”、“小时”、“星期几”是非常强的特征,需要进行独热编码(One-Hot Encoding)或周期性编码(如将小时转换为
sin(2π*hour/24), cos(2π*hour/24))。
为CNN构造特征:CNN的输入通常保持原始数据的局部结构。对于一维时序数据,我们直接使用滑动窗口得到的原始序列片段作为输入。关键在于输入张量的形状。
- 形状定义:在PyTorch中,CNN的输入通常是四维张量
(batch_size, channels, height, width)。对于一维CNN,我们常用三维张量(batch_size, channels, length)。batch_size: 批大小。channels: 类比图像的RGB通道。在多元时间序列中,每个特征变量就是一个通道。例如,有温度、湿度、气压3个特征,那么channels=3。length: 时间序列窗口的长度。
- 数据重构:我们需要将
(num_samples, window_size, num_features)的数组,转换为(num_samples, num_features, window_size)。# 假设 X_bp 形状为 (1000, 24, 5) -> 1000个样本,窗口长度24,5个特征 X_cnn = np.transpose(X_bp, (0, 2, 1)) # 转置后形状为 (1000, 5, 24)
3.3 数据标准化与数据集划分
神经网络对输入数据的尺度非常敏感。我们必须进行标准化。
- 方法:最常用的是Z-score标准化,即减去均值,除以标准差。切记:必须用训练集的均值和标准差去标准化验证集和测试集,避免数据泄露。
from sklearn.preprocessing import StandardScaler scaler_X = StandardScaler() scaler_y = StandardScaler() # 假设 train_X 形状为 (n_samples, n_features) 对于BP网络,或展平后处理 # 对于CNN,通常对每个特征通道单独标准化 train_X_original_shape = train_X.shape # 重塑为 (n_samples, n_features) 或按通道处理 train_X_reshaped = train_X.reshape(-1, train_X.shape[-1]) scaler_X.fit(train_X_reshaped) train_X_scaled = scaler_X.transform(train_X_reshaped).reshape(train_X_original_shape) # 对标签y也进行标准化(回归问题) scaler_y.fit(train_y) train_y_scaled = scaler_y.transform(train_y) - 数据集划分:对于时间序列数据,不能随机打乱划分,必须按时间顺序划分,防止未来信息泄露。通常按比例,如前70%训练,中间15%验证,最后15%测试。
实操心得:特征工程和数据预处理会占用整个项目60%以上的时间,但其效果直接决定了模型的性能天花板。多花时间在这里进行探索和实验,比如尝试不同的窗口大小、不同的统计特征组合、不同的编码方式,并用一个简单的线性模型或浅层网络快速验证其特征有效性,远比盲目堆叠复杂的网络层更有效率。
4. PyTorch模型构建详解
数据准备就绪后,我们进入核心环节:用PyTorch定义我们的BP网络和CNN模型。我会采用面向对象的方式,定义清晰且易于扩展的nn.Module子类。
4.1 BP神经网络模型实现
BP网络的核心是全连接层(nn.Linear)。设计的关键在于确定层数、每层的神经元数量,以及激活函数的选择。
import torch import torch.nn as nn import torch.nn.functional as F class BPNet(nn.Module): """ 一个简单的多层BP神经网络。 假设输入特征维度:input_size 输出维度:output_size (对于回归是1,对于多分类是类别数) """ def __init__(self, input_size, hidden_sizes, output_size, dropout_rate=0.2): super(BPNet, self).__init__() # 动态构建隐藏层 layers = [] prev_size = input_size for i, hidden_size in enumerate(hidden_sizes): layers.append(nn.Linear(prev_size, hidden_size)) layers.append(nn.BatchNorm1d(hidden_size)) # 批归一化,加速收敛 layers.append(nn.ReLU()) # 激活函数 layers.append(nn.Dropout(dropout_rate)) # Dropout防止过拟合 prev_size = hidden_size # 输出层 layers.append(nn.Linear(prev_size, output_size)) # 将层序列包装成 Sequential self.network = nn.Sequential(*layers) def forward(self, x): # x 的形状: (batch_size, input_size) # 如果输入是三维的(如来自滑动窗口),需要先展平 if x.dim() > 2: x = x.view(x.size(0), -1) # 展平为 (batch_size, input_size) return self.network(x)关键参数与设计选择:
hidden_sizes: 例如[128, 64, 32],代表三个隐藏层,神经元数依次递减。这是一个经验性的设计,通常从大到小,像漏斗一样逐步提炼信息。可以从一个适中的规模(如[64])开始,根据验证集效果增加深度或宽度。BatchNorm1d: 批归一化层。它通过对每个批次的数据进行归一化,使得网络每一层的输入分布相对稳定,极大地加速了训练过程,并允许使用更高的学习率。这是现代深度网络几乎必备的组件。Dropout: 在训练时随机“关闭”一部分神经元,是一种简单有效的正则化手段,能减少神经元之间的复杂共适应关系,防止过拟合。dropout_rate通常设置在0.2到0.5之间。- 激活函数:
ReLU及其变种(如LeakyReLU)是目前最常用的,因为它们能缓解梯度消失问题,计算简单。输出层的激活函数取决于任务:回归任务通常不用激活函数(或使用线性激活);二分类用Sigmoid;多分类用Softmax(通常与nn.CrossEntropyLoss结合,其内部已包含Softmax)。
4.2 一维卷积神经网络模型实现
对于时间序列,我们使用一维卷积(nn.Conv1d)。
class TimeSeriesCNN(nn.Module): """ 用于时间序列预测的一维CNN。 输入形状: (batch_size, num_channels, sequence_length) 输出形状: (batch_size, output_size) """ def __init__(self, input_channels, seq_length, output_size, conv_channels=[32, 64], kernel_sizes=[3, 3], fc_sizes=[128], dropout_rate=0.2): super(TimeSeriesCNN, self).__init__() self.conv_layers = nn.ModuleList() prev_channels = input_channels # 构建卷积层块 for i, (out_channels, kernel_size) in enumerate(zip(conv_channels, kernel_sizes)): conv_block = nn.Sequential( nn.Conv1d(in_channels=prev_channels, out_channels=out_channels, kernel_size=kernel_size, padding='same'), # padding='same'保持长度不变 nn.BatchNorm1d(out_channels), nn.ReLU(), nn.Dropout(dropout_rate), nn.MaxPool1d(kernel_size=2, stride=2) # 池化层,下采样 ) self.conv_layers.append(conv_block) prev_channels = out_channels # 计算经过池化后的序列长度 seq_length = seq_length // 2 # 展平后的特征维度 self.flattened_size = prev_channels * seq_length # 全连接层(相当于BP网络的后半部分) self.fc_layers = nn.ModuleList() prev_size = self.flattened_size for fc_size in fc_sizes: self.fc_layers.append(nn.Sequential( nn.Linear(prev_size, fc_size), nn.BatchNorm1d(fc_size), nn.ReLU(), nn.Dropout(dropout_rate) )) prev_size = fc_size # 输出层 self.output_layer = nn.Linear(prev_size, output_size) def forward(self, x): # x 形状: (batch, channels, length) for conv_block in self.conv_layers: x = conv_block(x) # 展平 x = x.view(x.size(0), -1) for fc_block in self.fc_layers: x = fc_block(x) x = self.output_layer(x) return x关键参数与设计选择:
input_channels: 对应特征数量。例如,温度、湿度、气压三个特征,此处为3。conv_channels和kernel_sizes: 定义了卷积层的深度和卷积核大小。[32, 64]和[3,3]意味着两个卷积层,分别有32和64个滤波器,卷积核宽度均为3。较小的核(如3或5)适合捕捉短时局部模式。padding='same': 这是PyTorch 1.9+引入的便捷参数,自动计算填充以保证输入输出长度一致。在早期版本中,需要手动计算padding=(kernel_size-1)//2。MaxPool1d: 最大池化层,进行下采样,逐步减少序列长度,扩大感受野,同时提供一定的平移不变性。stride=2和kernel_size=2是最常见的配置,每次将长度减半。- 卷积层后的全连接层:卷积层提取的是局部特征,我们需要通过全连接层将这些特征组合起来,进行最终的回归或分类决策。
4.3 模型初始化与检查
定义好模型后,务必进行初始化并检查输入输出形状是否匹配。
# 假设输入数据形状 batch_size = 32 # 对于BP网络(假设特征已展平) bp_input_size = 24 * 5 # 窗口长度24 * 特征数5 bp_model = BPNet(input_size=bp_input_size, hidden_sizes=[128, 64], output_size=1) # 回归任务,输出1 # 对于CNN cnn_model = TimeSeriesCNN(input_channels=5, seq_length=24, output_size=1, conv_channels=[32, 64]) # 创建随机输入数据检查形状 bp_dummy_input = torch.randn(batch_size, 24, 5) # 模拟一个批次的数据 cnn_dummy_input = torch.randn(batch_size, 5, 24) # 注意通道维在前 print("BP网络输入形状:", bp_dummy_input.shape) bp_output = bp_model(bp_dummy_input) print("BP网络输出形状:", bp_output.shape) print("\nCNN输入形状:", cnn_dummy_input.shape) cnn_output = cnn_model(cnn_dummy_input) print("CNN输出形状:", cnn_output.shape)这个步骤能提前发现网络结构设计中的维度错误,避免在训练时出现令人困惑的报错。
注意事项:在比赛时间有限的情况下,不要一开始就设计非常深的网络。从一个简单的3-5层网络开始,快速验证数据流和训练循环的正确性。模型复杂度应该与数据量和任务难度相匹配,小数据上过深的网络极易过拟合。
5. 模型训练、验证与调优全流程
模型定义好后,接下来就是训练的核心循环。这部分代码的健壮性和效率直接影响实验迭代速度。
5.1 数据加载与训练循环搭建
PyTorch的DataLoader是管理批数据、打乱、并行加载的利器。
from torch.utils.data import DataLoader, TensorDataset # 将numpy数组转换为PyTorch张量 train_X_tensor = torch.FloatTensor(train_X_scaled) train_y_tensor = torch.FloatTensor(train_y_scaled).view(-1, 1) # 确保标签是二维 (n_samples, 1) val_X_tensor = torch.FloatTensor(val_X_scaled) val_y_tensor = torch.FloatTensor(val_y_scaled).view(-1, 1) # 创建Dataset和DataLoader train_dataset = TensorDataset(train_X_tensor, train_y_tensor) val_dataset = TensorDataset(val_X_tensor, val_y_tensor) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) # 训练集需要打乱 val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False) # 验证集不需要打乱 # 定义设备 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f'Using device: {device}') model = BPNet(...).to(device) # 或 cnn_model.to(device) # 定义损失函数和优化器 criterion = nn.MSELoss() # 回归任务用均方误差损失 # criterion = nn.CrossEntropyLoss() # 分类任务用交叉熵损失 optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # Adam是默认的首选优化器 scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=10) # 学习率调度器训练循环模板:
num_epochs = 200 train_losses, val_losses = [], [] for epoch in range(num_epochs): # 训练阶段 model.train() running_train_loss = 0.0 for batch_X, batch_y in train_loader: batch_X, batch_y = batch_X.to(device), batch_y.to(device) optimizer.zero_grad() # 清零梯度 outputs = model(batch_X) # 前向传播 loss = criterion(outputs, batch_y) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新参数 running_train_loss += loss.item() * batch_X.size(0) epoch_train_loss = running_train_loss / len(train_loader.dataset) train_losses.append(epoch_train_loss) # 验证阶段 model.eval() running_val_loss = 0.0 with torch.no_grad(): # 禁用梯度计算,节省内存和计算 for batch_X, batch_y in val_loader: batch_X, batch_y = batch_X.to(device), batch_y.to(device) outputs = model(batch_X) loss = criterion(outputs, batch_y) running_val_loss += loss.item() * batch_X.size(0) epoch_val_loss = running_val_loss / len(val_loader.dataset) val_losses.append(epoch_val_loss) scheduler.step(epoch_val_loss) # 根据验证损失调整学习率 # 打印日志 if (epoch + 1) % 20 == 0: print(f'Epoch [{epoch+1}/{num_epochs}], Train Loss: {epoch_train_loss:.4f}, Val Loss: {epoch_val_loss:.4f}, LR: {optimizer.param_groups[0]["lr"]:.6f}')5.2 核心超参数调优策略
在有限的时间内,系统性地调参至关重要。我通常会遵循以下优先级:
- 学习率(Learning Rate, LR):这是最重要的参数。可以从0.001(Adam的默认值)开始。如果训练损失不下降,尝试增大(如0.01);如果训练过程震荡剧烈或损失变成NaN,尝试减小(如0.0001)。使用
ReduceLROnPlateau调度器能自动在验证损失停滞时降低LR,非常实用。 - 批大小(Batch Size):在GPU内存允许范围内,较大的批大小(如64, 128)能使梯度估计更稳定,可能有助于收敛。但有时小批量(如16, 32)能带来更好的泛化性能。可以尝试调整。
- 网络结构:
- 深度/宽度:如果模型欠拟合(训练集和验证集损失都高),可以尝试增加层数(深度)或每层神经元数(宽度)。
- Dropout率:如果模型过拟合(训练损失低,验证损失高),可以适当增加
dropout_rate(如从0.2调到0.5)。
- 优化器:
Adam在大多数情况下表现良好且无需太多调参。如果效果不佳,可以尝试AdamW(修正了权重衰减)或传统的SGD with momentum,后者有时能找到更尖锐的最小值,但需要仔细调整LR和动量参数。 - 权重初始化:PyTorch默认的初始化通常工作良好。对于深层网络,可以尝试
nn.init.kaiming_normal_(针对ReLU激活函数优化)。
高效的调参方法:手动依次调整效率低。如果条件允许,可以使用网格搜索(Grid Search)或随机搜索(Random Search)对少数几个关键参数(如LR、隐藏层大小、dropout率)进行组合实验。更高级的可以使用Optuna或Ray Tune等自动调参库。但在数模比赛中,手动基于经验的迭代通常更可控。
5.3 训练监控与早停
为了防止过拟合和节省时间,早停(Early Stopping)是必备技巧。
best_val_loss = float('inf') patience = 30 # 容忍验证损失不下降的轮数 patience_counter = 0 best_model_state = None for epoch in range(num_epochs): # ... 训练和验证代码 ... # 在每个epoch验证后 if epoch_val_loss < best_val_loss: best_val_loss = epoch_val_loss best_model_state = model.state_dict().copy() # 深拷贝保存最佳状态 patience_counter = 0 # 可以在这里保存模型 checkpoint # torch.save({'model_state_dict': model.state_dict(), ...}, 'best_model.pth') else: patience_counter += 1 if patience_counter >= patience: print(f'Early stopping triggered at epoch {epoch+1}') break # 训练结束后,加载最佳模型 model.load_state_dict(best_model_state)同时,绘制训练和验证损失曲线,直观判断模型状态。
plt.figure(figsize=(10, 5)) plt.plot(train_losses, label='Training Loss') plt.plot(val_losses, label='Validation Loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.legend() plt.title('Training and Validation Loss Curve') plt.grid(True) plt.show()理想的曲线是两条线都平稳下降,并最终接近。如果训练损失持续下降而验证损失上升,就是典型的过拟合。
实操心得:在比赛环境中,我通常会为BP和CNN模型各准备一个基础的训练脚本,并开启TensorBoard或简单的日志记录,实时监控损失。第一个能成功跑通并开始下降的模型,哪怕很简单,也能给你巨大的信心。不要追求第一个epoch就完美,先让它“动起来”。
6. 结果分析、模型集成与论文写作要点
模型训练完成后,工作只完成了一半。如何科学地评估模型,并将结果有效地呈现在论文中,是取得好成绩的关键。
6.1 模型评估与反标准化
训练时我们对数据进行了标准化,预测结果也是标准化后的值。为了计算有物理意义的误差指标,需要进行反标准化。
# 预测验证集或测试集 model.eval() all_predictions = [] all_targets = [] with torch.no_grad(): for batch_X, batch_y in val_loader: batch_X = batch_X.to(device) outputs = model(batch_X) all_predictions.append(outputs.cpu().numpy()) all_targets.append(batch_y.cpu().numpy()) predictions_scaled = np.vstack(all_predictions) targets_scaled = np.vstack(all_targets) # 反标准化 predictions = scaler_y.inverse_transform(predictions_scaled) targets = scaler_y.inverse_transform(targets_scaled) # 计算评估指标 from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score mae = mean_absolute_error(targets, predictions) rmse = np.sqrt(mean_squared_error(targets, predictions)) r2 = r2_score(targets, predictions) print(f'MAE: {mae:.2f}') print(f'RMSE: {rmse:.2f}') print(f'R² Score: {r2:.4f}')- MAE(平均绝对误差):直观,与目标值单位一致。
- RMSE(均方根误差):对大的误差惩罚更重,更常用。
- R²(决定系数):表示模型对数据波动的解释能力,越接近1越好。
绘制预测值与真实值的对比散点图或时序对比图,能直观展示模型性能。
plt.figure(figsize=(10, 6)) plt.scatter(targets, predictions, alpha=0.5) plt.plot([targets.min(), targets.max()], [targets.min(), targets.max()], 'r--', lw=2) # 对角线 plt.xlabel('True Values') plt.ylabel('Predictions') plt.title('True vs Predicted Values') plt.grid(True) plt.show()6.2 模型集成策略
单一模型可能不稳定或存在偏差。集成学习能有效提升模型的鲁棒性和预测精度。在比赛中,时间允许的话,可以尝试:
- 简单平均法:训练多个同构但不同随机种子初始化的模型(如5个BP网络,5个CNN),对它们的预测结果取平均。
- 加权平均法:根据每个模型在验证集上的表现(如RMSE的倒数)分配权重,性能好的模型权重高。
- Stacking:将多个基模型(BP, CNN)的预测结果作为新的特征,训练一个元模型(如线性回归或简单的神经网络)进行最终预测。这种方法潜力最大,但也更复杂,容易过拟合,需要谨慎使用。
示例代码(简单平均集成):
def ensemble_predict(models, data_loader, device): """多个模型预测并取平均""" all_model_preds = [] for model in models: model.eval() predictions = [] with torch.no_grad(): for batch_X, _ in data_loader: # 注意这里不需要标签 batch_X = batch_X.to(device) outputs = model(batch_X) predictions.append(outputs.cpu().numpy()) preds = np.vstack(predictions) all_model_preds.append(preds) # 沿第一个轴(模型轴)取平均 ensemble_pred = np.mean(np.array(all_model_preds), axis=0) return ensemble_pred6.3 论文写作与可复现性
数学建模论文的核心是清晰地将你的工作传达给评委。
- 模型部分:不要只贴代码。要用流程图(如数据预处理流程、网络结构图)和公式(如损失函数、卷积操作公式)清晰地描述你的BP网络和CNN模型。说明每层的作用、参数数量、激活函数选择理由。
- 结果部分:用表格对比不同模型(BP, CNN, 集成模型)在验证集和测试集上的评估指标(MAE, RMSE, R²)。用图表展示预测曲线与真实曲线的对比。对误差进行分析:模型在哪些样本或时间段预测误差较大?可能的原因是什么?(如节假日突变、数据噪声)。
- 可复现性:在附录或提供的代码中,必须注明所有关键超参数(学习率、批大小、网络结构、随机种子等)。使用
requirements.txt文件列出所有依赖库及其版本。良好的代码注释和结构也是加分项。 - 创新与总结:强调你将CNN创造性应用于时间序列分析的思路,对比传统BP网络的优势(如更少的参数、更好的局部特征提取能力)。总结模型的优缺点,并提出可能的改进方向(如引入注意力机制、使用更复杂的循环神经网络如LSTM/GRU进行对比)。
最后的心得:研究生数模比赛不仅是技术的比拼,更是项目管理、团队协作和快速学习能力的考验。在有限的时间里,先完成后完美。搭建一个端到端、可运行的基础管道(数据读取->预处理->模型->训练->评估)比一开始就追求一个复杂完美的模型更重要。在此基础上,通过迭代改进数据、模型和超参,逐步提升分数。保持代码模块化,方便快速替换和实验。最后,相信你的模型,但更要相信你对问题的分析和理解。祝你在比赛中取得佳绩!