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)
注意:通道丢弃比例不要超过0.3。
否则模型会学到「大部分通道都没用」,反而降低性能。

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类 数据增强策略 加噪(高斯/粉红) 时间扭曲 通道丢弃 数据归一化

从原始信号到最终可训练的数据,一共四步:
自定义Dataset类 → 数据增强(加噪/时间扭曲/通道丢弃) → 批量加载与混洗 → 数据归一化。
每一步都有坑,每一步也都有技巧。

我个人觉得,数据加载这部分花的时间,至少占整个项目周期的30%。
别急着调模型,先把数据管好。
数据管好了,模型训练事半功倍。


专注资料整理