3. 数据加载与增强:让模型看到更真实的神经信号
做神经信号深度学习,我踩过最大的坑是什么?
不是模型结构选错了,而是数据加载那块儿没处理好。
你想想看,模型再牛,喂进去的是垃圾,吐出来的能是黄金吗?
这一章,咱们就聊聊数据加载与增强的那些事儿。
说白了,就是怎么把原始神经信号,变成模型爱吃的样子。
3.1 自定义Dataset类:把数据管起来
PyTorch里有个好东西,叫torch.utils.data.Dataset。
我习惯自己写一个类继承它,这样数据怎么读、怎么预处理,全在我掌控之中。
举个例子,假设你的神经信号存在.npy文件里:
import torch
from torch.utils.data import Dataset
import numpy as np
class NeuralSignalDataset(Dataset):
def __init__(self, file_list, transform=None):
self.file_list = file_list
self.transform = transform
def __len__(self):
return len(self.file_list)
def __getitem__(self, idx):
# 加载单个样本
data = np.load(self.file_list[idx])
# 假设数据形状是 (channels, time_steps)
signal = data['signal']
label = data['label']
# 转成tensor
signal = torch.FloatTensor(signal)
label = torch.LongTensor([label])[0]
# 如果有数据增强,在这里做
if self.transform:
signal = self.transform(signal)
return signal, label
这里有个细节:__getitem__里每次只加载一个样本。
我刚开始做的时候,图省事把所有数据一次性读到内存里,结果256G的机器直接爆了。
嗯,神经信号数据量往往很大,尤其是高密度电极阵列,一个实验就能产出几个T的数据。
__init__里只存文件路径,不加载数据。这样内存占用极低,而且支持数据量远超内存的场景。
3.2 数据增强策略:给数据加点「噪声」
神经信号有个特点:它天然就带噪声。
但模型训练时,如果只喂干净数据,它学到的特征会很脆弱。
一旦实际部署时遇到一点干扰,模型就崩了。
所以,我建议在训练时主动加一些「扰动」。
下面是我常用的三种策略:
3.2.1 加噪(Additive Noise)
给信号加上高斯噪声或粉红噪声。
这能提升模型的鲁棒性,让它学会忽略背景噪声。
class AddGaussianNoise:
def __init__(self, std=0.01):
self.std = std
def __call__(self, signal):
noise = torch.randn_like(signal) * self.std
return signal + noise
注意:噪声强度不能太大。
我试过把std设成0.1,结果模型直接学废了,连基本的波形都认不出来。
一般建议从0.005开始调,观察验证集性能变化。
3.2.2 时间扭曲(Time Warping)
神经信号的时序很重要,但不同实验、不同受试者之间,时间尺度会有差异。
时间扭曲就是模拟这种差异。
class TimeWarp:
def __init__(self, warp_factor=0.1):
self.warp_factor = warp_factor
def __call__(self, signal):
# 简单实现:随机拉伸或压缩时间轴
length = signal.shape[-1]
new_length = int(length * (1 + self.warp_factor * (torch.rand(1).item() - 0.5)))
# 用插值实现重采样
signal = torch.nn.functional.interpolate(
signal.unsqueeze(0), size=new_length, mode='linear'
).squeeze(0)
# 如果变长了,截断;变短了,补零
if new_length > length:
signal = signal[..., :length]
else:
pad = torch.zeros(signal.shape[0], length - new_length)
signal = torch.cat([signal, pad], dim=-1)
return signal
这个操作要小心。
我曾经在脑电信号上用过,结果把P300的波形特征给扭曲没了。
所以,增强策略一定要结合你的任务特点来设计。
3.2.3 通道丢弃(Channel Dropout)
模拟电极接触不良或通道损坏的情况。
随机丢弃一部分通道,强迫模型从剩余通道中提取信息。
class ChannelDropout:
def __init__(self, drop_prob=0.1):
self.drop_prob = drop_prob
def __call__(self, signal):
# signal shape: (channels, time)
channels = signal.shape[0]
mask = torch.ones(channels)
mask[torch.rand(channels) < self.drop_prob] = 0
return signal * mask.unsqueeze(1)
否则模型会学到「大部分通道都没用」,反而降低性能。
3.3 批量加载与混洗:训练效率的关键
数据加载器(DataLoader)是PyTorch的标配。
但参数怎么设,这里头有门道。
from torch.utils.data import DataLoader
dataset = NeuralSignalDataset(file_list, transform=your_transform)
dataloader = DataLoader(
dataset,
batch_size=32, # 根据显存调整
shuffle=True, # 训练时一定要打乱
num_workers=4, # 多进程加载,加速
pin_memory=True, # 锁页内存,加速GPU传输
drop_last=True # 丢弃最后一个不完整的batch
)
我个人的经验:
- batch_size:从32开始试,看显存占用。神经信号通常通道多、时间长,batch_size设太大容易OOM。
- num_workers:一般设成CPU核心数的一半。设太多反而会因为进程切换开销而变慢。
- shuffle:训练集必须打乱。验证集和测试集不要打乱。
为什么训练要打乱?
因为如果不打乱,模型会学到数据的「顺序特征」。
比如前100个样本都是A类,后100个都是B类,模型会倾向于预测当前batch的类别。
这在实际部署时完全没用。
3.4 数据归一化技巧:让信号站在同一起跑线
神经信号的幅值差异很大。
有的通道信号幅值在±100μV,有的只有±10μV。
如果不做归一化,模型会天然偏向幅值大的通道。
常用的归一化方法:
| 方法 | 公式 | 适用场景 |
|---|---|---|
| Z-score标准化 | (x - μ) / σ | 信号近似高斯分布时 |
| Min-Max归一化 | (x - min) / (max - min) | 信号幅值范围已知且稳定 |
| 通道独立归一化 | 每个通道单独做Z-score | 通道间幅值差异大时 |
| 全局归一化 | 所有通道一起做Z-score | 通道间幅值差异小时 |
我推荐的做法:通道独立Z-score标准化。
原因很简单:神经信号每个通道的统计特性可能完全不同。
比如眼电通道(EOG)和脑电通道(EEG),幅值差了一个数量级。
class ChannelWiseNormalize:
def __init__(self, eps=1e-8):
self.eps = eps
def __call__(self, signal):
# signal shape: (channels, time)
mean = signal.mean(dim=-1, keepdim=True)
std = signal.std(dim=-1, keepdim=True)
return (signal - mean) / (std + self.eps)
千万不要在验证集上重新计算!否则就是数据泄露。
我遇到过这样一个案例:
有个同学在验证集上也做了独立归一化,结果验证集准确率比训练集还高。
他高兴坏了,以为模型泛化能力超强。
后来发现,是因为验证集的噪声模式被归一化「抹平」了,模型捡了个便宜。
实际部署时,模型表现一塌糊涂。
3.5 本章知识体系
下面这张图,帮你理清数据加载与增强的核心逻辑:
从原始信号到最终可训练的数据,一共四步:
自定义Dataset类 → 数据增强(加噪/时间扭曲/通道丢弃) → 批量加载与混洗 → 数据归一化。
每一步都有坑,每一步也都有技巧。
我个人觉得,数据加载这部分花的时间,至少占整个项目周期的30%。
别急着调模型,先把数据管好。
数据管好了,模型训练事半功倍。