LSTM模型做二分类任务1(PyTorch实现)
一、把数据集划分成训练集、验证集和测试集?
我们通常将数据集划分为训练集、验证集和测试集,每个部分都有其特定的作用
训练集(Training Set):模型直接从这个数据集学习特征和模式。
用于训练模型,即通过训练集的数据来学习模型的参数(如权重和偏置)。
模型在训练集上通过反向传播和优化算法(如Adam)来最小化损失函数。
验证集(Validation Set):模型不直接从这个数据集学习,但影响训练决策。
用于在训练过程中评估模型的表现,以便进行模型选择和调参。
通过验证集,我们可以监控模型在未见过的数据上的表现,避免过拟合。
例如,我们可以根据验证集上的表现来决定早停(Early Stopping)的时机,或者调整超参数(如学习率、隐藏层维度等)。
测试集(Test Set):在整个训练过程中完全不被使用,确保评估的客观性。
用于最终评估模型的泛化能力。测试集在训练过程中完全不被使用,直到训练结束。
测试集上的表现反映了模型在真实世界中未知数据上的表现。
注意:由于在训练时需要前向传播求损失,然后再反向传播更新模型参数;而在验证时仅仅通过前向传播求验证损失。
在下面的任务中我们就是为了设置早停策略,通过比较验证损失val_loss来保存最佳
为什么要把训练集再分成训练集和验证集?
如果我们只使用训练集和测试集,那么我们在训练过程中无法知道模型是否过拟合了训练集。因为测试集只能用于最终评估,我们不能使用测试集来调整模型或选择模型,否则会导致测试集被间接地用于训练,从而高估模型的泛化能力。
只有训练损失与验证损失同时递减,才能说明模型泛化能力好;
如果训练损失在下降,而验证损失一直不下降说明如果再继续训练就会出现过拟合了,这个时候要提前停止训练并保存最优模型(验证损失最低的)。
使用验证集,我们可以在训练过程中定期评估模型,根据验证集上的表现来调整超参数,并选择最佳的模型。然后,我们使用测试集来评估最终模型的泛化能力。
训练集+验证集+测试集 VS 训练集+测试集的区别:
训练集+验证集+测试集:我们将原始数据分成三部分,训练集用于训练,验证集用于模型选择和调参,测试集用于最终评估。这是一种标准的做法,可以更好地评估模型的泛化能力。
训练集+测试集:我们只将数据分成两部分,训练集用于训练,测试集用于评估。这种情况下,我们无法在训练过程中使用验证集来监控模型和调参,容易导致过拟合,并且无法进行模型选择(比如早停)。
二、LSTM实战案例(模型逆向攻击日志数据集)
0.导入相关的库
这里面有一个新见的库:seaborn 用于绘制混淆矩阵的热力地图。
Seaborn 是一个基于 matplotlib 且数据结构与 pandas 统一的统计图制作库。
1.加载数据集
上面是图片是我的特征向量和标签的json格式的数据集文件,由于数据集加载这部分不是本章重点,所以不再赘述。
假设现在已经通过一个封装好的load_data()方法,已经返回了特征向量X和标签y。其中X.shape(200,150,7)、y.shape(200,)
2.划分数据集(80%训练+20%验证, 30%测试)
一次划分:先划分出来训练集和测试集(7:3)
二次划分:再从训练集中划分出验证集(8:2)
返回X_train、y_train;X_val、y_val;X_test、y_test;
3.特征缩放(标准化)
对X_train、X_val、X_test分别进行标准化处理,返回X_train_scaled、X_val_scaled、X_test_scaled。
具体过程:以X_train为例,X_train.shape为(112,150,7)
1.首先,先将三维的X_train通过reshape(-1,7)方法转换为2D数组。
(-1,7) 等价于(112*150,7)
2.使用fit_transform()方法计算均值和标准差,并转换数据。
fit_transform()方法 == 先使用fit()方法 + 再使用transform()方法
- fit()方法:计算均值和标准差,以便后续的缩放操作。它不会对数据进行转换,只是计算并存储这些参数。
- transform()方法:转换数据。
记住:
1、先在训练集上fit(),后在训练集、验证集、测试集上transform()
2、3D数据在标准化之前需要先使用reshape(-1,features_size)转成2D数据,然后经过标准化处理后再转回来。
3.再通过reshape(X_train.shape)方法还原回来特征形状。
注意:在训练集上使用fit_transform(),在测试集和验证集上只使用transform()
4.创建 DataLoader批次加载器
接下来我们需要将(X_train_scaled, y_train)、(X_val_scaled, y_val)、(X_test_scaled, y_test)分别传入我们自定义的DataSet类,分别返回train_dataset、val_dataset、test_dataset;
再将train_dataset、val_dataset、test_dataset传入torch.utils.data.DataLoader类,分别返回train_loader、val_loader、test_loader。
为什么要这样做?这个知识点详细请跳转到下面这篇博客中的三、小批量梯度下降章节https://blog.csdn.net/m0_59777389/article/details/153636175?spm=1011.2124.3001.6209
在封装我们的数据集时,必须继承实用工具(utils)中的 DataSet 的类,这个过程需要重写__init__、__getitem__、__len__三个方法,分别是为了加载数据集、获取数据索引、获取数据总量。
5.构建模型(重点)
"""自定义LSTM分类模型
参数:
- input_dim: 输入特征的维度
- hidden_dim: 隐藏层的维度
- output_dim: 输出层的维度(通常为1,因为这是一个二分类问题)
- n_layers: LSTM层的数量
- dropout: Dropout层的dropout概率
"""
模型的__init__()方法:主要是用于初始化模型参数的,并且搭建整个神经网络模型的。如下图所示:
这里的神经网络模型结构为:
->nn.LSTM(input_dim, hidden_dim, n_layers,batch_first=True, dropout=dropout) # 定义LSTM层
->nn.Dropout(dropout) # 定义Dropout层(作用:防止过拟合)
->nn.Linear(hidden_dim, output_dim) # 定义全连接层(将hidden_dim映射到output_dim)
->nn.Sigmoid() # 定义Sigmoid激活函数(将输出映射到(0,1)区间)
在定义LSTM层时我将batch_first=True了,但是__init__()的参数中我并没有指定batch_first参数,这样写对吗?另外你再解释一下batch_first=True是什么意思?有什么作用?
答:
1.batch_first=True是什么意思?
在PyTorch中,LSTM层的输入默认维度顺序是(seq_len, batch, input_size),即序列长度在前,批次大小在后。
但是,当我们设置
batch_first=True时,输入和输出的维度顺序将变为(batch, seq_len, input_size),即批次大小在前。
当
batch_first=False(默认值)时:
输入形状: (seq_len, batch, input_size)
输出形状: (seq_len, batch, hidden_size)当
batch_first=True时:
输入形状: (batch, seq_len, input_size)
输出形状: (batch, seq_len, hidden_size)2.为什么我们要使用
batch_first=True呢?通常,我们的数据组织方式是以批次为第一个维度的,比如我们有一个张量,形状为(batch_size, sequence_length, features)。这样更符合我们的直观理解,因为我们在处理数据时通常先按批次处理。
例如,我们有一个包含32个样本的批次,每个样本有10个时间步,每个时间步有5个特征,那么我们的输入张量形状就是(32, 10, 5)。
如果我们使用默认的
batch_first=False,那么我们需要将输入张量转换为(10, 32, 5)才能输入LSTM层,这可能会带来一些不便。因此,设置
batch_first=True可以让我们避免在输入LSTM之前调整维度顺序,使得代码更加直观。3.在定义LSTM层时我将batch_first=True了,但是__init__()方法的参数中我并没有指定batch_first参数,这样写是正确的,本来就应该这样写。
不需要在
__init__方法参数中添加batch_first,因为:
batch_first是LSTM层的参数,不是模型类的参数(用于外部传参,可以改变的)你在创建LSTM层时已经直接设置了它(内部固定写死的)
总之,一般使用DataLoader批量加载数据的时候,都要在定义nn.LSTM层时设置batch_first=True
模型的forward()方法:用于前向传播过程中,具体的操作。
在上图中我们可以看到,
首先,需要初始化隐藏状态h0和细胞状态c0(这里是用0填充的)并将h0和c0放到GPU上,h0 和 c0 的形状: (n_layers, batch_size, hidden_dim);
然后,将x, (h0, c0)输入到LSTM,返回lstm_out, (hn, cn);(这个过程比较复杂,如果感兴趣请去看LSTM的原理,这里我们不赘述)
接着,我们需要通过lstm_out[:, -1, :]操作取每个序列的最后一个时间步的输出last_lstm_out;(-1表示序列的最后一个时间步的下标),通过这个操作相当于降维了:lstm_out(16,150,64) -> last_lstm_out(16,64)。
注意:取每个序列的最后一个时间步的输出last_lstm_out之后,要记得对last_lstm_out使用dropout,防止过拟合。
最后,就是分别通过全连接层、再通过Sigmoid激活函数,此时返回形状为 (batch_size, 1);
我在这里做了一个squeeze操作,移除了最后一个维度,形状变为 (batch_size,)。这是因为真实标签是一维数组,为了后面在做测试的时候,让模型输出的pred_y与真实标签y维度一致。(当然这里也可以直接返回通过Sigmoid激活函数之后的输出,不做squeeze操作,不过后面在做测试的时候需要把真实标签y的形状reshape成二维的列向量就行了)
测试:只取output的最后一个时间步的隐藏状态
lstm_out = torch.randn(16, 150, 64) # 16个样本,每个样本150个时间步,每个时间步64个特征 print(lstm_out) print(lstm_out.shape)输出:
tensor([[[-1.8707, -0.6794, -0.1703, ..., 0.1776, -0.1101, -0.1647],
[-0.2021, 0.7888, -0.9574, ..., -0.9473, -0.7599, 1.2282],
[-0.0662, -0.3341, 0.7425, ..., -0.6443, -0.1219, 1.1780],
...,
[ 0.7821, 0.2164, 0.4434, ..., 0.3323, 0.0520, 2.1495],
[ 0.3234, 0.4131, -1.0398, ..., 1.1657, 0.0976, 0.4193],
[-1.2332, 0.0785, -1.0180, ..., 1.2127, 0.0754, 0.5740]],[[ 0.4871, 0.6760, -0.0452, ..., -0.0956, 0.7223, 0.2410],
[ 0.9383, -0.9841, -0.3879, ..., -0.7236, -0.6700, -0.4763],
[ 0.9720, -0.3246, -0.2011, ..., -0.1065, 0.2752, 0.7799],
...,
[ 3.3505, 0.4136, 0.7960, ..., -0.4927, 1.6054, -0.4607],
[ 1.4111, -0.0840, 0.4895, ..., 0.8858, -1.4625, 0.1890],
[ 0.0132, 0.8347, 0.5775, ..., -1.1897, -0.3169, -0.0996]],[[-1.5659, 0.5573, -0.1124, ..., 0.4236, -0.3952, -0.0877],
[-1.6665, -1.3872, -0.1420, ..., 0.1521, 2.0090, 0.6887],
[ 0.7853, 1.0151, -0.6576, ..., -0.2066, 1.6503, -0.2559],
...,
[ 0.2859, 0.1691, -0.1844, ..., -0.5393, 0.4212, -0.6696],
[-0.2669, -0.6699, 0.9827, ..., 0.9614, 0.0540, 0.8014],
[-1.2266, -0.0785, 2.2120, ..., -0.2371, 1.7394, -1.3353]],...,
[[-1.6111, -0.1952, -0.0727, ..., 2.3489, -1.0382, 0.7262],
[ 2.2573, 0.9253, 0.2724, ..., 1.1263, 0.5576, -0.5277],
[-0.1499, -0.8148, -0.1669, ..., 0.7669, -0.1836, -0.7848],
...,
[ 1.2975, 0.8881, 0.4835, ..., 0.1819, 1.5072, 1.1313],
[ 0.6793, 1.8737, 1.1889, ..., -0.0768, 0.4340, 0.5262],
[ 0.7356, 0.7803, 1.3421, ..., 0.1636, -0.6233, 0.4539]],[[-0.0290, -0.1030, 1.1579, ..., -1.8016, -0.1866, 0.0061],
[-0.9076, 0.6781, 0.7278, ..., 0.5644, 1.4593, 0.0571],
[ 1.0055, -1.2006, 0.9629, ..., -1.0520, -0.8257, 0.8989],
...,
[-0.6574, 2.4848, 0.0490, ..., 0.7014, -1.0352, -0.4521],
[-0.6112, -0.0974, 0.7430, ..., -0.2740, 2.9617, 0.1575],
[ 1.3955, 2.2767, -1.4854, ..., 0.7403, 0.9316, 2.0881]],[[ 0.7596, -1.3041, -0.1047, ..., -0.8039, 0.3766, -0.4940],
[-1.1452, -0.1129, 1.4303, ..., -1.1768, -0.8231, 0.3138],
[ 0.4168, 0.0711, 0.6345, ..., -0.3294, -1.4570, 0.3510],
...,
[ 0.6431, 0.2386, -1.4828, ..., -0.1227, -0.0887, 0.2915],
[ 0.2548, 0.6102, 1.8154, ..., -1.5753, -0.0761, -0.1968],
[ 1.1818, -0.3178, 0.5499, ..., -0.2600, 0.5431, -0.8910]]])
torch.Size([16, 150, 64])out = lstm_out[:, -1, :] # 取每个样本的最后一个时间步的输出,形状为 (16, 64) print(out) print(out.shape)输出:
tensor([[-1.2332, 0.0785, -1.0180, ..., 1.2127, 0.0754, 0.5740],
[ 0.0132, 0.8347, 0.5775, ..., -1.1897, -0.3169, -0.0996],
[-1.2266, -0.0785, 2.2120, ..., -0.2371, 1.7394, -1.3353],
...,
[ 0.7356, 0.7803, 1.3421, ..., 0.1636, -0.6233, 0.4539],
[ 1.3955, 2.2767, -1.4854, ..., 0.7403, 0.9316, 2.0881],
[ 1.1818, -0.3178, 0.5499, ..., -0.2600, 0.5431, -0.8910]])
torch.Size([16, 64])
dropout = nn.Dropout(p=0.5) # 50%的概率将元素设置为0,每执行一次dropout,就有50%的元素会被设置为0 out = dropout(out) print(out) print(out.size(0) * out.size(1)) # 总元素数 print((out == 0).sum()) # 统计有多少个元素被设置为0tensor([[-2.4665, 0.1571, -0.0000, ..., 0.0000, 0.1507, 0.0000],
[ 0.0264, 0.0000, 1.1550, ..., -0.0000, -0.6339, -0.1992],
[-0.0000, -0.0000, 0.0000, ..., -0.4742, 3.4789, -2.6707],
...,
[ 1.4713, 0.0000, 0.0000, ..., 0.0000, -0.0000, 0.0000],
[ 2.7911, 4.5535, -2.9709, ..., 0.0000, 0.0000, 4.1762],
[ 0.0000, -0.6357, 0.0000, ..., -0.5200, 1.0861, -0.0000]])
1024
tensor(522)
# 对上一步操作返回的out进行全连接层映射 【注意】操作可叠加 fc = nn.Linear(64, 1) # 64个特征映射到1个输出(全连接层,输出层) out = fc(out) out输出:
tensor([[ 1.0132],
[ 1.3960],
[ 0.9772],
[ 1.3545],
[-0.0395],
[ 0.0946],
[-1.0372],
[-0.3325],
[ 0.1005],
[ 0.7154],
[-1.1141],
[-0.5344],
[ 0.2924],
[ 1.8825],
[-2.3383],
[-0.2945]], grad_fn=<AddmmBackward0>)
# 对全连接层的输出应用Sigmoid激活函数 【注意】操作可叠加 sigmoid = nn.Sigmoid() out = sigmoid(out) # 将输出映射到(0,1)区间 print(out) print(out.shape)tensor([[0.7336],
[0.8016],
[0.7265],
[0.7949],
[0.4901],
[0.5236],
[0.2617],
[0.4176],
[0.5251],
[0.6716],
[0.2471],
[0.3695],
[0.5726],
[0.8679],
[0.0880],
[0.4269]], grad_fn=<SigmoidBackward0>)
torch.Size([16, 1])
# squeeze:移除最后一个维度,形状为 (batch_size,) 【注意】操作可叠加 out = out.squeeze(1) print(out) print(out.shape)tensor([0.7336, 0.8016, 0.7265, 0.7949, 0.4901, 0.5236, 0.2617, 0.4176, 0.5251, 0.6716, 0.2471, 0.3695, 0.5726, 0.8679, 0.0880, 0.4269], grad_fn=<SqueezeBackward1>) torch.Size([16])
想要深入了解LSTM模型构建的入门案例请跳转:https://blog.csdn.net/m0_59777389/article/details/149350040?spm=1011.2124.3001.6209
6.模型训练+验证(同一个epoch下)
训练模型前,需要提前初始化模型、并指定损失函数和优化器。
并将模型移动到GPU上。
LSTMClassifier分类器结构如下图:
由上图可以看出,
1.训练与验证是在同一个epoch中进行的,顺序是先训练后验证。但是训练和验证又分别在各自的DataLoader中分批进行的。
2.训练过程需要进行前向传播求损失,又要反向传播更新梯度;但是验证过程仅仅只需要前向传播求损失。
3.以往的案例中我都是直接losses.append(loss.item()) 将损失添加到列表中,为了后面打印损失变化曲线。在这里为啥要做累加每个批次的损失的操作?
train_loss += loss.item() * inputs.size(0) # 累加每个批次的损失(乘以样本数)
这行代码没看懂,为什么要累加每个批次的损失(乘以样本数)?这样做的目的是啥?
答:
在PyTorch中,
nn.BCELoss()默认返回的是批次内所有样本的平均损失;而我们通常计算一个epoch的平均损失时,是用该epoch的总损失除以样本总数。
但是,由于我们每个批次的样本数可能不同(最后一个批次可能小于batch_size),所以我们需要记录每个批次的损失乘以该批次的样本数,这样累加后得到的就是整个epoch的总损失。然后,在计算平均损失时,我们用总损失除以总样本数(即len(train_loader.dataset))。
train_loss += loss.item() * inputs.size(0) # 累加每个批次的损失(乘以样本数)
loss.item(): 获取当前批次的平均损失值
inputs.size(0): 获取当前批次的样本数量
loss.item() * inputs.size(0): 将平均损失转换为批次总损失
这样操作是为了:
正确处理不同批次大小(特别是最后一个批次可能不满)
准确计算整个数据集的平均损失
确保损失统计的数学正确性
这是一种标准的PyTorch训练模式,确保损失计算的准确性!
7.测试模型
1.之前的案例中,我们在测试过程中,将概率转换为类别 (0或1)的时候,都是将二维数组先进行布尔判断,然后再赋值的方式将模型的输出结果转换成0或1的,如下:
但是,在这里有一个更加巧妙的方法:
直接通过float()方法可以将为True的位置直接转成1,为False的位置转成0;
2.all_preds.extend(preds.cpu().numpy()) # 转换为 numpy 数组并添加到列表中
这行代码我没看懂?请一步一步的拆解。
答:
我们一步步拆解这行代码:all_preds.extend(preds.cpu().numpy())
preds:这是一个PyTorch张量,包含了一个批次的预测结果(0或1)。
cpu():将张量从当前设备(可能是GPU)移动到CPU。如果张量已经在CPU上,这个操作不会改变什么,但为了安全起见,我们通常这样做以确保张量在CPU上,因为numpy数组只能在CPU上处理。
numpy():将PyTorch张量转换为NumPy数组。
preds.cpu().numpy()的结果是一个NumPy数组,其形状为(batch_size,),因为每个样本有一个预测值。
all_preds是一个Python列表,用于收集所有批次的预测结果。
.extend():这是Python列表的一个方法,用于将可迭代对象(如数组)中的每个元素添加到列表的末尾。所以,这行代码的意思是将当前批次的预测结果(一个NumPy数组)中的每个元素依次添加到
all_preds列表中。举例说明:
假设一个批次有3个样本,preds.cpu().numpy()得到数组:[0, 1, 0]
如果all_preds原来是[1, 0],那么执行后all_preds变为[1, 0, 0, 1, 0]extend()与append()的区别:
append()方法是将整个对象作为单个元素添加到列表末尾。
如果使用append,那么上述例子就会变成:all_preds.append([0,1,0]) -> [1, 0, [0, 1, 0]],这显然不是我们想要的。因此,当我们想要将一个可迭代对象中的每个元素单独添加到列表中时,使用extend;当我们想要将整个对象作为一个元素添加时,使用append。
在机器学习中,我们通常使用extend来收集所有批次的预测结果,以便最后形成一个一维的列表,包含所有样本的预测。
最后就是打印评价指标了:
附录
1. 训练集shuffle=True,验证/测试集shuffle=False
在深度学习的实践中,这种设置(训练集shuffle=True,验证/测试集shuffle=False)已经成为了标准规范。这背后的原因涉及模型泛化能力、训练效率以及评估的严谨性。以下是详细的深度解析:
1. 为什么训练集(Train)需要打乱?
核心目的:防止模型学到“虚假相关性”或“记忆顺序”。
打破数据间的相关性:在原始数据集中,样本往往是按某种顺序排列的(例如按类别、采集时间或特定传感器 ID)。如果模型按固定顺序学习,它可能会捕获到序列中的统计偏差,而不是特征本身的规律。
平滑梯度方向:深度学习使用随机梯度下降(SGD)。如果每个 Batch 的数据构成非常单一(比如一个 Batch 全是 A 类,下一个全是 B 类),会导致梯度在更新时剧烈波动。打乱数据能确保每个 Batch 的数据分布更接近整体分布,使训练过程更平稳、收敛更快。
防止过拟合:固定的顺序会让模型产生某种“记忆”,它可能学会“第 10 个样本后通常会出现类别 X”这种无意义的规律。
2. 为什么验证/测试集(Val/Test)通常不打乱?
核心目的:保证评估的一致性、可重复性以及效率。
结果的可复现性:验证集的作用是作为一个“标尺”来衡量模型性能。如果每次验证时数据顺序都变,虽然理论上最终指标(如平均准确率)不变,但在调试模型或对比不同版本时,这种不必要的随机性会增加分析难度。
不需要梯度的更新:验证和测试阶段只是进行前向传播(Forward Pass)。由于不涉及权重更新,样本之间的顺序完全不会影响模型当前的预测性能。
便于排查错误:如果不打乱,你可以很容易地将模型的预测结果与原始标签一一对应。比如你发现模型在第 500 到 600 个样本上表现极差,你可以直接定位到原始数据中这部分样本,分析其特征。
效率考量:内存读取和预取(Prefetching)在顺序读取时通常比随机读取效率更高,虽然在 GPU 训练中这点差异微乎其微,但也是一种良好的工程实践。
3. 如果将验证/测试集也打乱,是错误的做法吗?
简单来说:不违法,但没必要,且可能带来不便。
数学结果上:只要你完整地跑完一遍验证集,无论顺序如何,得到的Loss 和 Accuracy 是完全一致的。因此,从纯粹的数学评估角度看,打乱并不是“错误”的。
工程逻辑上:
计算浪费:
shuffle=True会在每个 Epoch 开始时消耗额外的 CPU 算力来重新生成随机索引序列。无法通过索引追踪:如果你在训练日志中想查看某个特定 Bad Case(坏样本),打乱顺序会让你很难通过 Batch 序号快速找到对应的原始数据。
总结与对比
| 数据集类型 | 是否 Shuffle | 主要原因 |
| 训练集 (Train) | 必须 (True) | 增强泛化能力,平滑梯度,防止模型学习样本顺序。 |
| 验证集 (Val) | 不建议 (False) | 确保评估结果可复现,方便对比不同 Epoch 的模型表现。 |
| 测试集 (Test) | 不建议 (False) | 方便进行最终结果分析、生成预测报告以及进行样本对照。 |
进阶建议
如果你需要对模型进行非常严格的性能分析(例如计算每个样本的置信度,或者将模型预测结果导出为 CSV 进行后续分析),请务必保持测试集不打乱,这样预测结果的索引才能与你原始数据的 CSV/数据库索引完美对应。
2. 3维特征数据如何进行标准化?
# --- B. 特征缩放 (与原脚本相同) --- scaler = StandardScaler() X_train_reshaped = X_train.reshape(-1, num_features) # 先将训练集转换为2D数组,进行缩放 num_features:每个时间步的特征数量 scaler.fit(X_train_reshaped) # 用于计算数据集的均值和标准差等统计量,以便后续的缩放操作。它不会对数据进行转换,只是计算并存储这些参数。 # scaler.transform:使用fit方法计算得到的均值和标准差来对数据进行标准化(即缩放) X_train_scaled = scaler.transform(X_train_reshaped).reshape(X_train.shape) # fit() + transform() 与 fit_transform() 在结果上是等效的 # 在训练集上使用fit_transform(),在测试集和验证集上使用transform() X_val_scaled = scaler.transform(X_val.reshape(-1, num_features)).reshape(X_val.shape) X_test_scaled = scaler.transform(X_test.reshape(-1, num_features)).reshape(X_test.shape) print("特征缩放完成。") print("-" * 50)记住:
1、先在训练集上fit(),后在训练集、验证集、测试集上transform()
2、3D数据在标准化之前需要先使用reshape(-1,features_size)转成2D数据,然后经过标准化处理后再转回来。
总结得非常到位!你提到的这两点是深度学习数据预处理中极其关键的“工程细节”。为了帮你加深理解并防止踩坑,我针对这两点补充一些深度解析和为什么必须这样做的原因:
1. 为什么必须先在训练集上fit,再对所有集transform?
核心原则:严禁“数据泄露 (Data Leakage)”。
保持一致的“标尺”:
StandardScaler的本质是计算均值 $\mu$ 和标准差 $\sigma$。如果验证集或测试集参与了fit,就相当于模型在训练之前通过预处理过程“偷看”了未来数据的分布信息。模拟真实场景:在实际部署模型时,新产生的数据(推理数据)是单条或小批量的,你无法计算新数据的均值和标准差。因此,模型必须习惯使用“训练时学到的分布”来处理所有未来的输入。
结果对比:如果每个集都独立
fit,那么同一个数值(比如“100”)在不同集合中标准化后的结果可能不同(因为均值变了),这会导致模型逻辑混乱。
2. 为什么 3D 数据必须reshape到 2D 才能标准化?
核心原因:StandardScaler的设计逻辑是基于“特征列”的。
API 限制:Scikit-learn 的
StandardScaler期望接收的输入是。对于 LSTM 常用的 3D 形状
,它无法直接识别哪一维是特征。
跨时间步的一致性:* 通过
reshape(-1, num_features),你实际上是将所有样本的所有时间步都堆叠在了一起。这样做能确保模型学习到的是该特征在整个时间维度上的全局统计规律。
例子:如果你在预测气温,你希望“气温”这个特征在所有时间步(第1小时到第24小时)都使用同一个均值和标准差进行缩放,而不是每个小时都有一套不同的缩放标准。
💡 补充一个代码层面的小技巧
在 PyTorch 的 pipeline 中,这种“转 2D -> 缩放 -> 转回 3D”的操作非常常见。为了确保代码健壮,你可以这样写:
# 记录原始形状,方便后续 reshape 还原 original_shape = X_train.shape # (N, L, C) # 转换、缩放、还原 X_train_scaled = scaler.fit_transform(X_train.reshape(-1, num_features)).reshape(original_shape)延伸思考:
如果你的数据中存在异常值(Outliers),StandardScaler可能会受到很大影响(因为它对均值和方差敏感)。在这种情况下,你是否考虑过使用RobustScaler(基于分位数)来替代它?
3. LSTM模型的参数、input和output
针对nn.LSTM的参数、输入(Input)和输出(Output),我为你梳理了一份清晰的指南。理解这些维度的关键在于区分模型初始化参数(决定网络结构)与转发数据形状(决定数据流动)。
1. 模型初始化参数 (Initialization Parameters)
当你定义model = nn.LSTM(...)时,主要设置以下参数:
| 参数名 | 含义 | 说明 |
input_size | 输入特征维度 | 每个时间步输入的向量长度(如词向量维度)。 |
hidden_size | 隐状态维度 | 隐藏层神经元数量,决定了模型的“记忆容量”。 |
num_layers | 层数 | 堆叠 LSTM 的层数(默认 1)。 |
batch_first | Batch 优先 | 若为True,输入形状为False。 |
bidirectional | 双向 | 若为True,则为双向 LSTM,输出维度会翻倍。 |
dropout | 丢弃率 | 除最后一层外,层与层之间的 Dropout 概率。 |
2. 输入数据 (Input)
LSTM 的forward函数通常接收两个输入:数据 (input)和初始状态 (h0, c0)。
input:如果
batch_first=False(默认): 形状为如果
batch_first=True: 形状为其中:
= 序列长度,
= Batch 大小,
=
input_size
h_0(Hidden State): 形状为。
c_0(Cell State): 形状与h_0一致。其中:
(双向) 或
(单向);
=
hidden_size注:如果不提供,默认为全 0。
3. 输出数据 (Output)
LSTM 会返回两个部分:所有时间步的输出和最后一个时间步的状态。
output: 包含序列中每个时间步的隐状态。
形状:
(若
batch_first=True则在首位)。
通常用于后续的序列标注或连接全连接层。
(h_n, c_n):h_n: 最后一个时间步的隐状态。形状为。
c_n: 最后一个时间步的细胞状态。形状同上。如果你只需要整个序列的“语义向量”,通常取
h_n的最后一层。
4. 关键维度关系总结 (Cheat Sheet)
假设你有一个单向、单层的 LSTM,且batch_first=True:
特别注意:
如果开启了
bidirectional=True,output的最后一个维度大小将变为。
请真正搞懂LSTM模型参数,请见:
https://blog.csdn.net/m0_59777389/article/details/154486864?spm=1011.2415.3001.5331
4. LSTM模型训练完之后,需要我们知道的几个模型属性
对于nn.LSTM()模型而言,模型训练完之后,模型会把学到的知识存哪几个变量里,有哪些重要的模型属性需要我们知道?
在nn.LSTM训练完成后,它所学到的“知识”(即网络权重)主要存储在模型的参数变量中。你可以通过state_dict()方便地查看它们。以下是知识存储的载体和你需要掌握的关键属性:
1. 知识存放在哪些变量里?
LSTM 的知识本质上是四种门控(遗忘门、输入门、细胞门、输出门)的线性变换权重。它们被合并存储在以下变量中:
权重 (Weights)
weight_ih_l[k]: 第层输入到隐藏层(Input-to-Hidden)的权重。
形状:
。
它包含了四个门的权重矩阵拼接在一起。
weight_hh_l[k]: 第层隐藏层到隐藏层(Hidden-to-Hidden)的权重。
形状:
。
这是 LSTM “记忆”上一个时刻信息的关键。
偏置 (Biases)
bias_ih_l[k]和bias_hh_l[k]: 对应输入和隐藏层的偏置项。形状:
。
注意:PyTorch 为了计算效率,将四个门(
)的权重拼接在一个大矩阵里。如果你有 128 个隐藏单元,
weight_ih的形状通常是(512, input_size),即。
2. 必须掌握的重要属性
除了权重变量,在定义或调用模型时,这几个属性决定了模型的结构和数据流向:
结构属性
input_size: 输入特征的维度。
hidden_size: 隐藏状态的维度。这决定了模型“记忆力”的容量。num_layers: LSTM 的层数(堆叠深度)。默认为 1。batch_first: 极其重要。- 如果为
True,输入形状为(batch, seq, feature)。 - 如果为
False(默认),形状为(seq, batch, feature)。
- 如果为
bidirectional: 布尔值。如果为True,模型会同时学习正向和反向的信息,输出维度会翻倍(2 * hidden_size)。dropout: 层间失活概率。注意它只在num_layers > 1时对中间层有效,单层 LSTM 无效。
运行属性
training: 布尔值。当执行
model.train()时为True,执行model.eval()时为False。这会影响dropout是否生效。
3. 运行时的关键状态(非模型属性)
虽然这些不是存储在模型里的固定知识,但理解它们对推理至关重要:
h_n(Hidden State): 最后一时刻的隐藏状态,通常被当作整个序列的“特征向量”。c_n(Cell State): 最后一时刻的细胞状态,携带了长期记忆信息。
4. 如何访问这些“知识”?
如果你想获取特定的参数进行可视化(例如分析权重分布),可以使用以下方法:
# 1. 查看所有参数名称和形状 for name, param in lstm.named_parameters(): print(f"参数名: {name}, 形状: {param.shape}") # 2. 只获取第一层的输入权重 w_ih = lstm.weight_ih_l0.data5. 易混淆概念:知识 vs. 状态
这是一个非常关键的区别,很多初学者会混淆:
知识 (Parameters): 即上面提到的
weight和bias。它们在训练结束后是固定的,存在模型文件中(.pth或.pt)。状态 (States): 即
h_n(Hidden State) 和c_n(Cell State)。它们是模型在处理特定输入序列时产生的临时记忆。当你处理完一个 Batch,这些状态通常会被清空或重置。
它们不属于模型训练学到的永久知识,而是模型对当前输入的“瞬时反应”。
💡 实用建议
如果你发现模型在训练集上表现很好,但在测试集上很差,可以检查weight_hh_l[k]的梯度或数值分布。如果数值爆炸或全为 0,通常意味着长序列训练出现了梯度消失或梯度爆炸问题。
