从数据加载到特征工程:Python处理DEAP脑电数据的实战避坑指南

如果你刚开始接触DEAP数据集,可能会觉得它结构清晰、数据规整,上手应该不难。但真正动手用Python处理时,才会发现从.mat文件加载到最终特征提取的每一步都藏着不少“暗礁”。我见过不少研究生和算法工程师,在情绪识别项目初期,把大量时间浪费在了数据预处理的各种细节问题上,而不是模型设计本身。这篇文章就来自我处理DEAP数据时的真实笔记,记录了那些最容易让人栽跟头的环节,以及如何用Python代码优雅地跨过去。无论你是做情绪计算的研究人员,还是希望将生理信号应用于产品交互的开发者,这些经验都能帮你节省大量调试时间。

1. 数据加载与维度解析:避开第一个“维度陷阱”

拿到DEAP预处理数据(data_preprocessed_python文件夹)后,很多人会直接用scipy.io.loadmat加载.mat文件,然后看着datalabels这两个变量开始琢磨。这里第一个坑就出现了:数据维度的实际含义与直觉理解往往不一致

官方文档说明data是40×40×8064的三维数组,但如果你直接打印shape,可能会得到(40, 40, 8064),然后想当然地认为第一个40是试次,第二个40是通道,8064是时间点。这个理解基本正确,但实际操作时需要更小心。

1.1 正确加载与验证数据维度

首先,不要用默认的loadmat,因为MATLAB和Python在矩阵存储顺序上存在差异。MATLAB是列优先(Fortran顺序),而NumPy默认是行优先(C顺序)。虽然对于纯数据加载这可能不是大问题,但在后续处理中,不当的维度理解会导致特征提取完全错误。

import numpy as np
import scipy.io as sio

def load_deap_subject(file_path, subject_id):
    """
    加载单个被试的DEAP预处理数据
    
    参数:
        file_path: 数据文件夹路径
        subject_id: 被试编号(1-32)
    
    返回:
        data_array: 脑电数据,形状为(试次, 通道, 时间点)
        labels_array: 标签数据,形状为(试次, 4)
        channel_names: 通道名称列表(如果可用)
    """
    # 构建文件名,注意DEAP文件命名从s01开始
    file_name = f's{subject_id:02d}.mat'
    full_path = os.path.join(file_path, file_name)
    
    # 加载.mat文件,设置squeeze_me=True自动压缩单维度
    mat_data = sio.loadmat(full_path, squeeze_me=True, struct_as_record=False)
    
    # 提取数据和标签
    data = mat_data['data']
    labels = mat_data['labels']
    
    # 验证维度
    if data.ndim != 3:
        raise ValueError(f"数据维度异常,期望3维,实际得到{data.ndim}维")
    
    if data.shape[0] != 40 or data.shape[1] != 40:
        print(f"警告:数据形状为{data.shape},非标准40×40×8064")
    
    return data, labels

注意:DEAP数据的前32个通道是EEG信号,后8个是外周生理信号(GSR、呼吸等)。如果你只关心脑电,需要明确区分。

加载后,我建议立即进行维度验证和基本统计:

# 加载第一个被试的数据
data, labels = load_deap_subject('data_preprocessed_python', 1)

print(f"数据形状: {data.shape}")
print(f"标签形状: {labels.shape}")

# 检查数据范围(EEG信号通常以微伏为单位)
print(f"数据范围: [{data.min():.2f}, {data.max():.2f}]")
print(f"数据均值: {data.mean():.4f} ± {data.std():.4f}")

# 查看前5个试次的标签(效价、唤醒度、优势度、喜欢程度)
print("\n前5个试次标签:")
for i in range(5):
    print(f"试次{i+1}: 效价={labels[i, 0]:.2f}, "
          f"唤醒度={labels[i, 1]:.2f}, "
          f"优势度={labels[i, 2]:.2f}, "
          f"喜欢程度={labels[i, 3]:.2f}")

1.2 通道选择与重参考处理

DEAP数据已经进行了预处理,包括降采样到128Hz、带通滤波和眼电伪迹去除。但原始数据是使用Biosemi系统采集的,采用的是平均参考。在某些分析中,你可能需要重新参考,比如转换为乳突参考或全脑平均参考。

这里有个细节:DEAP的32个EEG通道是按照国际10-20系统放置的,但顺序可能与你熟悉的顺序不同。我强烈建议创建一个通道位置映射表:

通道索引 通道名称 脑区 10-20系统位置
0 Fp1 前额 左前额
1 AF3 前额 左前额中部
2 F3 额叶 左额
3 F7 额叶 左额前部
4 FC5 额中央 左额中央
5 FC1 额中央 左额中央中部
6 C3 中央 左中央
7 T7 颞叶 左颞
8 CP5 中央顶 左中央后部
9 CP1 中央顶 左中央后部中部
10 P3 顶叶 左顶
11 P7 顶叶 左顶后部
12 PO3 枕顶 左枕顶
13 O1 枕叶 左枕
14 Oz 枕叶 中线枕
15 Pz 顶叶 中线顶
16 Fp2 前额 右前额
17 AF4 前额 右前额中部
18 Fz 额叶 中线额
19 F4 额叶 右额
20 F8 额叶 右额前部
21 FC6 额中央 右额中央
22 FC2 额中央 右额中央中部
23 Cz 中央 中线中央
24 C4 中央 右中央
25 T8 颞叶 右颞
26 CP6 中央顶 右中央后部
27 CP2 中央顶 右中央后部中部
28 P4 顶叶 右顶
29 P8 顶叶 右顶后部
30 PO4 枕顶 右枕顶
31 O2 枕叶 右枕

如果你需要重新参考,这里有个实用函数:

def rereference_eeg(data, ref_channels=None, method='average'):
    """
    对EEG数据进行重参考
    
    参数:
        data: 原始EEG数据,形状为(试次, 通道, 时间点)
        ref_channels: 参考通道索引列表,如果为None则使用所有通道
        method: 参考方法,'average'为平均参考,'specific'为特定通道参考
    
    返回:
        rereferenced_data: 重参考后的数据
    """
    # 只处理前32个EEG通道
    eeg_data = data[:, :32, :].copy()
    
    if method == 'average':
        # 计算所有通道的平均作为参考
        if ref_channels is None:
            ref_signal = eeg_data.mean(axis=1, keepdims=True)
        else:
            ref_signal = eeg_data[:, ref_channels, :].mean(axis=1, keepdims=True)
        
        # 减去参考信号
        rereferenced = eeg_data - ref_signal
        
    elif method == 'specific':
        if ref_channels is None:
            raise ValueError("使用'specific'方法时需要指定ref_channels")
        
        # 使用特定通道作为参考
        ref_signal = eeg_data[:, ref_channels, :].mean(axis=1, keepdims=True)
        rereferenced = eeg_data - ref_signal
    
    # 将处理后的EEG数据放回原数据数组
    result = data.copy()
    result[:, :32, :] = rereferenced
    
    return result

2. 采样率与时间轴处理:时间对齐的精确控制

DEAP的预处理数据已经降采样到128Hz,每个试次63秒,所以总时间点是63×128=8064。这个计算看起来简单,但在实际处理中,时间轴对齐问题经常被忽视,导致后续的事件相关电位(ERP)分析或时频分析出现相位偏差。

2.1 构建精确的时间轴

首先,我们需要明确每个试次的时间零点在哪里。在DEAP实验中,每个音乐视频片段前有3秒的基线记录,然后是60秒的视频观看,所以总共63秒。但预处理后的数据是否包含了完整的63秒?时间零点对应的是视频开始还是其他时刻?

根据我的经验,预处理数据通常包含完整的63秒,其中:

  • 前3秒(0-3秒)是基线期
  • 后60秒(3-63秒)是刺激期

构建时间轴时:

def create_time_axis(sampling_rate=128, total_duration=63, baseline_duration=3):
    """
    创建与DEAP数据对应的时间轴
    
    参数:
        sampling_rate: 采样率(Hz)
        total_duration: 总时长(秒)
        baseline_duration: 基线期时长(秒)
    
    返回:
        time_axis: 时间点数组(秒)
        baseline_mask: 基线期布尔掩码
        stimulus_mask: 刺激期布尔掩码
    """
    # 计算总样本数
    n_samples = int(total_duration * sampling_rate)
    
    # 创建时间轴(从0开始)
    time_axis = np.linspace(0, total_duration, n_samples, endpoint=False)
    
    # 创建基线期和刺激期掩码
    baseline_mask = time_axis < baseline_duration
    stimulus_mask = time_axis >= baseline_duration
    
    return time_axis, baseline_mask, stimulus_mask

# 使用示例
time_axis, baseline_mask, stimulus_mask = create_time_axis()

print(f"时间轴长度: {len(time_axis)} 个点")
print(f"时间范围: {time_axis[0]:.3f} 到 {time_axis[-1]:.3f} 秒")
print(f"基线期样本数: {baseline_mask.sum()}")
print(f"刺激期样本数: {stimulus_mask.sum()}")

2.2 基线校正的正确方法

基线校正是EEG处理中的关键步骤,目的是去除每个试次开始前的直流偏移和慢波漂移。DEAP数据已经进行了一定的预处理,但根据你的分析需求,可能还需要重新进行基线校正。

常见的错误是使用整个试次的均值进行校正,这会导致信号失真。正确的方法应该是使用基线期(前3秒)的均值:

def apply_baseline_correction(data, baseline_mask):
    """
    应用基线校正
    
    参数:
        data: EEG数据,形状为(试次, 通道, 时间点)
        baseline_mask: 基线期布尔掩码,形状为(时间点,)
    
    返回:
        corrected_data: 基线校正后的数据
    """
    # 确保数据是浮点型,避免整数运算问题
    if data.dtype != np.float32 and data.dtype != np.float64:
        data = data.astype(np.float32)
    
    # 计算每个试次、每个通道的基线均值
    # baseline_mask需要扩展维度以匹配数据形状
    baseline_data = data[:, :, baseline_mask]
    baseline_mean = baseline_data.mean(axis=2, keepdims=True)
    
    # 应用校正:减去基线均值
    corrected_data = data - baseline_mean
    
    return corrected_data

# 实际应用
corrected_data = apply_baseline_correction(data[:, :32, :], baseline_mask)

# 验证校正效果
print("基线校正验证:")
print(f"校正前基线均值: {data[:, :32, baseline_mask].mean():.4f}")
print(f"校正后基线均值: {corrected_data[:, :, baseline_mask].mean():.4f}")

提示:对于某些高级分析(如时频分析),你可能需要在变换后的域进行基线校正,而不是在时域。这取决于你的具体分析流程。

2.3 处理不完整或异常试次

虽然DEAP数据质量较高,但在实际分析中,你可能会遇到某些试次数据异常的情况。我建议在预处理流程中加入质量检查步骤:

def check_trial_quality(data, threshold_uv=200, max_bad_channels=5):
    """
    检查试次数据质量
    
    参数:
        data: 单个试次的数据,形状为(通道, 时间点)
        threshold_uv: 振幅阈值(微伏),超过此值视为异常
        max_bad_channels: 允许的最大坏通道数
    
    返回:
        is_good: 试次是否合格(布尔值)
        bad_channels: 坏通道索引列表
        metrics: 质量指标字典
    """
    metrics = {}
    
    # 1. 检查振幅范围
    peak_to_peak = data.max(axis=1) - data.min(axis=1)
    bad_channels_amplitude = np.where(peak_to_peak > threshold_uv)[0]
    
    # 2. 检查方差(零方差或极低方差可能表示坏通道)
    channel_variances = data.var(axis=1)
    variance_threshold = channel_variances.mean() * 0.01  # 低于均值1%视为异常
    bad_channels_variance = np.where(channel_variances < variance_threshold)[0]
    
    # 3. 检查NaN值
    nan_channels = np.where(np.isnan(data).any(axis=1))[0]
    
    # 合并所有坏通道
    all_bad_channels = np.unique(np.concatenate([
        bad_channels_amplitude,
        bad_channels_variance,
        nan_channels
    ]))
    
    # 计算指标
    metrics['peak_to_peak_mean'] = peak_to_peak.mean()
    metrics['peak_to_peak_std'] = peak_to_peak.std()
    metrics['variance_mean'] = channel_variances.mean()
    metrics['bad_channel_count'] = len(all_bad_channels)
    metrics['bad_channel_indices'] = all_bad_channels.tolist()
    
    # 判断试次是否合格
    is_good = len(all_bad_channels) <= max_bad_channels
    
    return is_good, all_bad_channels, metrics

# 批量检查所有试次
def batch_quality_check(data, subject_id):
    """
    批量检查一个被试的所有试次
    """
    results = []
    bad_trials = []
    
    for trial_idx in range(data.shape[0]):
        trial_data = data[trial_idx, :32, :]  # 只检查EEG通道
        is_good, bad_channels, metrics = check_trial_quality(trial_data)
        
        results.append({
            'subject': subject_id,
            'trial': trial_idx,
            'is_good': is_good,
            'bad_channel_count': metrics['bad_channel_count'],
            'bad_channels': metrics['bad_channel_indices'],
            'peak_to_peak_mean': metrics['peak_to_peak_mean']
        })
        
        if not is_good:
            bad_trials.append(trial_idx)
    
    return results, bad_trials

3. 标签处理与情绪空间映射:超越二分类思维

DEAP数据提供了四个维度的连续标签:效价(Valence)、唤醒度(Arousal)、优势度(Dominance)和喜欢程度(Liking)。大多数研究只关注效价和唤醒度,但处理这些连续标签时,有几个关键决策点会影响你的模型性能。

3.1 连续标签与离散分类的转换

最直接的方法是将连续标签(1-9)二值化,通常以5为分界点。但这里有个微妙之处:中性地带(4.5-5.5)的样本应该如何处理?

def discretize_labels(labels, threshold=5.0, remove_ambiguous=True, ambiguous_range=(4.5, 5.5)):
    """
    将连续标签离散化为二分类标签
    
    参数:
        labels: 原始标签数组,形状为(试次, 4)
        threshold: 分类阈值
        remove_ambiguous: 是否去除模糊样本
        ambiguous_range: 模糊范围
    
    返回:
        binary_labels: 二值标签字典
        valid_indices: 有效样本索引
    """
    # 提取效价和唤醒度
    valence = labels[:, 0]
    arousal = labels[:, 1]
    
    # 初始化有效样本掩码
    valid_mask = np.ones(len(labels), dtype=bool)
    
    if remove_ambiguous:
        # 去除效价和唤醒度都在模糊范围内的样本
        valence_ambiguous = (valence >= ambiguous_range[0]) & (valence <= ambiguous_range[1])
        arousal_ambiguous = (arousal >= ambiguous_range[0]) & (arousal <= ambiguous_range[1])
        ambiguous_mask = valence_ambiguous & arousal_ambiguous
        
        valid_mask = ~ambiguous_mask
        print(f"去除模糊样本: {ambiguous_mask.sum()} 个")
    
    # 应用有效样本掩码
    valence_valid = valence[valid_mask]
    arousal_valid = arousal[valid_mask]
    valid_indices = np.where(valid_mask)[0]
    
    # 二值化
    valence_binary = (valence_valid > threshold).astype(int)  # 1=高效价,0=低效价
    arousal_binary = (arousal_valid > threshold).astype(int)   # 1=高唤醒,0=低唤醒
    
    # 创建四象限分类(效价×唤醒度)
    # 0: 低效价低唤醒,1: 低效价高唤醒,2: 高效价低唤醒,3: 高效价高唤醒
    quadrant_labels = valence_binary * 2 + arousal_binary
    
    return {
        'valence': valence_binary,
        'arousal': arousal_binary,
        'quadrant': quadrant_labels,
        'indices': valid_indices
    }

# 使用示例
binary_labels = discretize_labels(labels, remove_ambiguous=True)

print(f"有效样本数: {len(binary_labels['indices'])}")
print(f"高效价样本: {(binary_labels['valence'] == 1).sum()}")
print(f"高唤醒样本: {(binary_labels['arousal'] == 1).sum()}")
print(f"四象限分布:")
for i in range(4):
    count = (binary_labels['quadrant'] == i).sum()
    print(f"  象限{i}: {count} 个样本")

3.2 标签分布不平衡问题

DEAP数据在情绪空间中的分布并不均匀。高效价高唤醒的样本通常较多,而低效价低唤醒的样本较少。这种不平衡会影响分类器的训练。

我常用的解决策略包括:

  1. 重采样技术(过采样少数类或欠采样多数类)
  2. 类别权重调整(在损失函数中给少数类更高权重)
  3. 数据增强(对EEG信号进行轻微变换生成新样本)

这里提供一个基于类别权重的处理示例:

from sklearn.utils.class_weight import compute_class_weight

def compute_class_weights(labels):
    """
    计算类别权重以处理不平衡数据
    
    参数:
        labels: 类别标签数组
    
    返回:
        class_weights: 类别权重字典
    """
    classes = np.unique(labels)
    weights = compute_class_weight('balanced', classes=classes, y=labels)
    
    return dict(zip(classes, weights))

# 为四象限分类计算权重
quadrant_weights = compute_class_weights(binary_labels['quadrant'])

print("四象限分类权重:")
for quadrant, weight in quadrant_weights.items():
    count = (binary_labels['quadrant'] == quadrant).sum()
    print(f"  象限{quadrant}: {count}个样本,权重={weight:.3f}")

# 在PyTorch或TensorFlow中使用这些权重
# PyTorch示例:
# criterion = nn.CrossEntropyLoss(weight=torch.tensor(list(quadrant_weights.values())))

3.3 连续标签的回归任务

除了分类,你还可以将情绪识别视为回归任务,直接预测效价和唤醒度的连续值。这时需要注意标签的标准化:

def prepare_regression_labels(labels, valid_indices=None):
    """
    准备回归任务的标签
    
    参数:
        labels: 原始标签数组
        valid_indices: 有效样本索引(如果已进行过滤)
    
    返回:
        regression_labels: 标准化后的回归标签
        scaler: 用于后续逆变换的标准化器
    """
    from sklearn.preprocessing import StandardScaler
    
    if valid_indices is not None:
        labels = labels[valid_indices]
    
    # 提取效价和唤醒度
    valence_arousal = labels[:, :2]
    
    # 标准化(均值为0,方差为1)
    scaler = StandardScaler()
    normalized_labels = scaler.fit_transform(valence_arousal)
    
    return normalized_labels, scaler

# 使用示例
regression_labels, label_scaler = prepare_regression_labels(labels, binary_labels['indices'])

print(f"回归标签形状: {regression_labels.shape}")
print(f"效价均值(标准化后): {regression_labels[:, 0].mean():.4f}")
print(f"唤醒度均值(标准化后): {regression_labels[:, 1].mean():.4f}")

4. 特征工程与提取:从时域到时频域的实用技巧

特征提取是情绪识别中最关键的环节之一。DEAP数据的时间分辨率(128Hz)和时长(63秒)为多种特征提取方法提供了可能。以下是几种我实践中发现有效的特征提取策略。

4.1 时域特征提取

时域特征计算简单,对计算资源要求低,适合作为基线特征:

def extract_time_domain_features(data, window_size=128, overlap=0.5):
    """
    提取时域特征
    
    参数:
        data: EEG数据,形状为(试次, 通道, 时间点)
        window_size: 滑动窗口大小(样本数)
        overlap: 窗口重叠比例
    
    返回:
        features: 时域特征数组
        feature_names: 特征名称列表
    """
    n_trials, n_channels, n_samples = data.shape
    
    # 计算滑动窗口参数
    step_size = int(window_size * (1 - overlap))
    n_windows = (n_samples - window_size) // step_size + 1
    
    # 预分配特征数组
    n_features_per_channel = 6  # 均值、方差、偏度、峰度、Hjorth参数等
    features = np.zeros((n_trials, n_channels * n_features_per_channel, n_windows))
    
    feature_names = []
    
    for ch in range(n_channels):
        for w in range(n_windows):
            start_idx = w * step_size
            end_idx = start_idx + window_size
            window_data = data[:, ch, start_idx:end_idx]
            
            # 1. 均值
            mean_val = window_data.mean(axis=1)
            
            # 2. 方差
            var_val = window_data.var(axis=1)
            
            # 3. 偏度(三阶中心矩)
            from scipy.stats import skew
            skewness = skew(window_data, axis=1)
            
            # 4. 峰度(四阶中心矩)
            from scipy.stats import kurtosis
            kurt = kurtosis(window_data, axis=1)
            
            # 5. Hjorth活动性(方差)
            activity = var_val
            
            # 6. Hjorth移动性(一阶导数方差的平方根除以活动性)
            diff_signal = np.diff(window_data, axis=1)
            mobility = np.sqrt(diff_signal.var(axis=1) / (var_val + 1e-10))
            
            # 存储特征
            base_idx = ch * n_features_per_channel
            features[:, base_idx, w] = mean_val
            features[:, base_idx + 1, w] = var_val
            features[:, base_idx + 2, w] = skewness
            features[:, base_idx + 3, w] = kurt
            features[:, base_idx + 4, w] = activity
            features[:, base_idx + 5, w] = mobility
        
        # 生成特征名称
        channel_name = f'Ch{ch:02d}'
        feature_names.extend([
            f'{channel_name}_mean',
            f'{channel_name}_var',
            f'{channel_name}_skew',
            f'{channel_name}_kurt',
            f'{channel_name}_activity',
            f'{channel_name}_mobility'
        ])
    
    # 将特征重塑为(试次, 特征×窗口)
    features_reshaped = features.reshape(n_trials, -1)
    
    return features_reshaped, feature_names

# 提取时域特征示例
time_features, time_feature_names = extract_time_domain_features(data[:, :32, :])

print(f"时域特征形状: {time_features.shape}")
print(f"特征数量: {len(time_feature_names)}")
print(f"前5个特征名: {time_feature_names[:5]}")

4.2 频域特征提取(功率谱密度)

频域特征对情绪识别特别重要,因为不同情绪状态与特定频带(α、β、θ、γ波)的功率变化相关:

def extract_frequency_domain_features(data, sampling_rate=128, band_definitions=None):
    """
    提取频域特征(频带功率)
    
    参数:
        data: EEG数据,形状为(试次, 通道, 时间点)
        sampling_rate: 采样率
        band_definitions: 频带定义字典
    
    返回:
        band_powers: 频带功率特征
        band_names: 频带名称列表
    """
    from scipy.signal import welch
    
    if band_definitions is None:
        # 默认EEG频带定义
        band_definitions = {
            'delta': (1, 4),
            'theta': (4, 8),
            'alpha': (8, 13),
            'beta': (13, 30),
            'gamma': (30, 45)
        }
    
    n_trials, n_channels, n_samples = data.shape
    n_bands = len(band_definitions)
    
    # 预分配特征数组
    band_powers = np.zeros((n_trials, n_channels * n_bands))
    band_names = []
    
    for ch in range(n_channels):
        for trial_idx in range(n_trials):
            # 计算功率谱密度
            frequencies, psd = welch(
                data[trial_idx, ch, :],
                fs=sampling_rate,
                nperseg=min(256, n_samples // 4),
                noverlap=min(128, n_samples // 8)
            )
            
            # 计算各频带功率
            for band_idx, (band_name, (low_freq, high_freq)) in enumerate(band_definitions.items()):
                # 找到频带对应的频率索引
                freq_mask = (frequencies >= low_freq) & (frequencies <= high_freq)
                
                if freq_mask.any():
                    # 计算频带内功率的平均值(对数变换)
                    band_power = np.log(np.mean(psd[freq_mask]) + 1e-10)
                    feature_idx = ch * n_bands + band_idx
                    band_powers[trial_idx, feature_idx] = band_power
        
        # 生成特征名称
        for band_name in band_definitions.keys():
            band_names.append(f'Ch{ch:02d}_{band_name}_power')
    
    return band_powers, band_names

# 提取频域特征示例
freq_features, freq_feature_names = extract_frequency_domain_features(data[:, :32, :])

print(f"频域特征形状: {freq_features.shape}")
print(f"频带功率特征示例(第一个试次,前5个特征):")
for i in range(5):
    print(f"  {freq_feature_names[i]}: {freq_features[0, i]:.4f}")

4.3 时频特征提取(小波变换)

时频分析能同时捕捉频率成分随时间的变化,这对非平稳的EEG信号特别有用:

def extract_wavelet_features(data, sampling_rate=128, wavelet='morl', scales=None):
    """
    使用连续小波变换提取时频特征
    
    参数:
        data: EEG数据,形状为(试次, 通道, 时间点)
        sampling_rate: 采样率
        wavelet: 小波类型
        scales: 尺度参数,如果为None则自动生成
    
    返回:
        wavelet_features: 小波特征
        feature_info: 特征信息
    """
    import pywt
    from scipy import signal
    
    n_trials, n_channels, n_samples = data.shape
    
    if scales is None:
        # 自动生成尺度,对应1-45Hz频率范围
        # 小波中心频率与尺度的关系:f = fc * fs / scale
        # 对于morlet小波,fc ≈ 0.8125
        fc = pywt.central_frequency(wavelet)
        # 计算对应1Hz的尺度
        scale_low = fc * sampling_rate / 45  # 45Hz
        scale_high = fc * sampling_rate / 1   # 1Hz
        num_scales = 20
        scales = np.linspace(scale_low, scale_high, num_scales)
    
    # 预分配特征数组
    # 我们取每个尺度在每个时间点的能量均值作为特征
    n_scales = len(scales)
    wavelet_features = np.zeros((n_trials, n_channels * n_scales))
    
    for ch in range(n_channels):
        print(f"处理通道 {ch+1}/{n_channels}", end='\r')
        
        for trial_idx in range(n_trials):
            signal_data = data[trial_idx, ch, :]
            
            # 执行连续小波变换
            coefficients, frequencies = pywt.cwt(
                signal_data, 
                scales, 
                wavelet, 
                sampling_period=1.0/sampling_rate
            )
            
            # 计算每个尺度的平均能量
            for scale_idx in range(n_scales):
                energy = np.mean(np.abs(coefficients[scale_idx])**2)
                feature_idx = ch * n_scales + scale_idx
                wavelet_features[trial_idx, feature_idx] = np.log(energy + 1e-10)
    
    print()  # 换行
    
    # 生成特征名称
    feature_names = []
    for ch in range(n_channels):
        for scale_idx in range(n_scales):
            # 计算该尺度对应的中心频率
            fc = pywt.central_frequency(wavelet)
            center_freq = fc * sampling_rate / scales[scale_idx]
            feature_names.append(f'Ch{ch:02d}_wavelet_{center_freq:.1f}Hz')
    
    return wavelet_features, {'scales': scales, 'feature_names': feature_names}

# 注意:小波变换计算量较大,建议先在小样本上测试
# 示例(注释掉以避免长时间运行):
# wavelet_features, wavelet_info = extract_wavelet_features(data[:5, :5, :])  # 只处理前5个试次和前5个通道

4.4 特征选择与降维

提取大量特征后,下一步是选择最相关的特征。我通常采用多阶段特征选择策略:

def select_features_with_mrmr(features, labels, n_features_to_select=50):
    """
    使用mRMR(最大相关最小冗余)进行特征选择
    
    参数:
        features: 特征矩阵,形状为(样本数, 特征数)
        labels: 标签数组
        n_features_to_select: 要选择的特征数量
    
    返回:
        selected_features: 选择后的特征
        selected_indices: 选择的特征索引
        feature_scores: 特征得分
    """
    # 注意:这里简化实现,实际应用中可能需要安装mrmr_selection包
    # 或者使用其他特征选择方法
    
    from sklearn.feature_selection import SelectKBest, f_classif, mutual_info_classif
    
    # 方法1:基于方差过滤(去除低方差特征)
    from sklearn.feature_selection import VarianceThreshold
    variance_selector = VarianceThreshold(threshold=0.01)
    features_variance_filtered = variance_selector.fit_transform(features)
    
    # 方法2:基于单变量统计检验
    # 对于分类任务
    if len(np.unique(labels)) < 10:  # 假设是分类任务
        selector = SelectKBest(score_func=f_classif, k=min(n_features_to_select*2, features_variance_filtered.shape[1]))
        features_selected = selector.fit_transform(features_variance_filtered, labels)
        selected_indices_step2 = selector.get_support(indices=True)
        
        # 获取原始特征索引
        variance_indices = np.where(variance_selector.get_support())[0]
        selected_indices = variance_indices[selected_indices_step2]
    
    # 方法3:基于互信息(对非线性关系更敏感)
    mi_scores = mutual_info_classif(features, labels, random_state=42)
    top_mi_indices = np.argsort(mi_scores)[-n_features_to_select:]
    
    # 综合选择:取前两种方法的交集或并集
    # 这里简单取并集
    final_indices = np.unique(np.concatenate([selected_indices[:n_features_to_select//2], top_mi_indices]))
    
    # 如果特征数仍过多,进一步筛选
    if len(final_indices) > n_features_to_select:
        # 基于互信息得分排序
        final_indices_scores = mi_scores[final_indices]
        top_indices = final_indices[np.argsort(final_indices_scores)[-n_features_to_select:]]
        final_indices = top_indices
    
    selected_features = features[:, final_indices]
    
    return selected_features, final_indices, mi_scores

# 特征选择示例(假设是分类任务)
# 注意:这里需要实际的标签数据
# selected_features, selected_idx, feature_scores = select_features_with_mrmr(
#     time_features, 
#     binary_labels['valence'], 
#     n_features_to_select=100
# )

5. 与EEGLAB的协同工作流:发挥工具链的最大效能

虽然Python在机器学习和深度学习方面有优势,但EEGLAB在EEG预处理和可视化方面仍然是行业标准。建立Python与EEGLAB的协同工作流可以发挥两者的优势。

5.1 数据格式转换:Python与EEGLAB/MATLAB互通

经常需要在Python和MATLAB/EEGLAB之间传递数据。以下是一些实用函数:

def save_for_eeglab(data, labels, channel_names, file_path):
    """
    将Python处理的数据保存为EEGLAB可读格式
    
    参数:
        data: EEG数据,形状为(试次, 通道, 时间点)
        labels: 标签数据
        channel_names: 通道名称列表
        file_path: 保存路径
    """
    import h5py
    
    # 将数据重塑为EEGLAB期望的格式:(通道, 时间点, 试次)
    # EEGLAB通常使用(通道, 时间点)的2D数组,多个试次可以保存在结构中
    eeglab_format = np.transpose(data, (1, 2, 0))
    
    # 保存为.mat文件(使用v7.3格式以支持h5py,兼容性更好)
    with h5py.File(file_path, 'w') as f:
        # 保存EEG数据
        f.create_dataset('EEG/data', data=eeglab_format)
        
        # 保存通道信息
        f.create_dataset('EEG/chanlocs/labels', 
                        data=[name.encode('utf-8') for name in channel_names])
        
        # 保存采样率
        f.create_dataset('EEG/srate', data=128)
        
        # 保存试次信息
        f.create_dataset('EEG/trials', data=data.shape[0])
        
        # 保存时间点信息
        f.create_dataset('EEG/pnts', data=data.shape[2])
        
        # 保存事件信息(将标签作为事件类型)
        events = []
        for trial_idx in range(data.shape[0]):
            # 创建事件结构
            event = {
                'type': f'valence_{labels[trial_idx, 0]:.1f}_arousal_{labels[trial_idx, 1]:.1f}',
                'latency': trial_idx * data.shape[2] + 1,  # 试次开始位置
                'duration': data.shape[2]
            }
            events.append(event)
        
        # 注意:这里简化处理,实际需要更复杂的事件结构保存
    
    print(f"数据已保存到 {file_path},可在EEGLAB中使用load命令加载")

def load_eeglab_set(file_path):
    """
    加载EEGLAB的.set文件(通过h5py)
    
    参数:
        file_path: .set文件路径
    
    返回:
        eeg_data: EEG数据数组
        metadata: 元数据字典
    """
    import h5py
    
    with h5py.File(file_path, 'r') as f:
        # EEGLAB的.set文件是HDF5格式
        # 数据结构可能因EEGLAB版本而异
        
        # 尝试查找数据
        if 'EEG/data' in f:
            data = f['EEG/data'][:]
            # 转换为Python标准格式:(试次, 通道, 时间点)
            if data.ndim == 3:
                data = np.transpose(data, (2, 0, 1))
            elif data.ndim == 2:
                # 单试次数据
                data = data[np.newaxis, :, :]
        
        # 提取元数据
        metadata = {}
        if 'EEG/srate' in f:
            metadata['srate'] = f['EEG/srate'][()]
        if 'EEG/chanlocs/labels' in f:
            labels = f['EEG/chanlocs/labels'][:]
            metadata['channels'] = [label.decode('utf-8') for label in labels]
    
    return data, metadata

5.2 利用EEGLAB进行ICA去伪迹

独立成分分析(ICA)是去除眼电、心电等伪迹的有效方法。虽然Python有ICA实现,但EEGLAB的ICA工具更成熟且经过充分验证。我通常的工作流是:

  1. 在Python中进行基本预处理(滤波、重参考)
  2. 将数据导出到EEGLAB进行ICA
  3. 识别并去除伪迹成分
  4. 将干净数据导回Python进行后续分析

这里提供一个自动化脚本的框架:

def run_eeglab_ica_automation(input_path, output_path, matlab_engine=None):
    """
    通过MATLAB引擎调用EEGLAB进行ICA处理
    
    参数:
        input_path: 输入数据路径
        output_path: 输出数据路径
        matlab_engine: MATLAB引擎实例
    
    返回:
        处理状态和消息
    """
    if matlab_engine is None:
        # 尝试启动MATLAB引擎
        try:
            import matlab.engine
            eng = matlab.engine.start_matlab()
        except ImportError:
            print("未找到MATLAB引擎,请安装MATLAB Engine API for Python")
            return False, "MATLAB引擎不可用"
    else:
        eng = matlab_engine
    
    # 添加EEGLAB到MATLAB路径
    eeglab_path = '/path/to/eeglab'  # 需要修改为实际路径
    eng.addpath(eeglab_path, nargout=0)
    
    # 构建MATLAB命令
    matlab_script = f"""
    % 加载数据
    EEG = pop_loadset('filename', '{input_path}');
    
    % 运行ICA
    EEG = pop_runica(EEG, 'icatype', 'runica', 'extended', 1);
    
    % 自动识别眼电成分(使用ADJUST插件)
    % 需要先安装ADJUST插件
    % EEG = pop_adjust(EEG);
    
    % 或者手动选择成分后去除
    % 这里假设我们已经知道要去除的成分索引
    % bad_components = [1, 3, 5];  % 示例
    
    % 去除成分
    % EEG = pop_subcomp(EEG, bad_components, 0);
    
    % 保存结果
    pop_saveset(EEG, 'filename', '{output_path}');
    """
    
    # 执行MATLAB脚本
    try:
        eng.eval(matlab_script, nargout=0)
        return True, "ICA处理完成"
    except Exception as e:
        return False, f"处理失败: {str(e)}"

5.3 可视化与结果验证

可视化是验证预处理效果的关键。以下是一些有用的可视化函数:

def plot_eeg_trial(trial_data, channel_names, trial_idx=0, start_time=0, duration=5, 
                   sampling_rate=128, figsize=(15, 10)):
    """
    绘制单个试次的EEG波形
    
    参数:
        trial_data: 试次数据,形状为(试次, 通道, 时间点)
        channel_names: 通道名称列表
        trial_idx: 试次索引
        start_time: 起始时间(秒)
        duration: 显示时长(秒)
        sampling_rate: 采样率
        figsize: 图形大小
    """
    import matplotlib.pyplot as plt
    
    # 计算时间轴
    n_samples = int(duration * sampling_rate)
    start_sample = int(start_time * sampling_rate)
    end_sample = start_sample + n_samples
    
    # 提取数据
    plot_data = trial_data[trial_idx, :, start_sample:end_sample]
    time_axis = np.linspace(start_time, start_time + duration, n_samples, endpoint=False)
    
    # 创建图形
    fig, axes = plt.subplots(8, 4, figsize=figsize, sharex=True)
    axes = axes.flatten()
    
    # 绘制每个通道
    for ch_idx in range(min(32, len(channel_names))):
        ax = axes[ch_idx]
        ax.plot(time_axis, plot_data[ch_idx, :], linewidth=0.8)
        ax.set_ylabel(channel_names[ch_idx], fontsize=8)
        ax.tick_params(labelsize=7)
        
        # 设置y轴范围(基于数据范围)
        y_min = plot_data[ch_idx, :].min()
        y_max = plot_data[ch_idx, :].max()
        y_padding = (y_max - y_min) * 0.1
        ax.set_ylim(y_min - y_padding, y_max + y_padding)
    
    # 设置公共标签
    for i in range(32, len(axes)):
        axes[i].axis('off')
    
    fig.text(0.5, 0.04, '时间 (秒)', ha='center', fontsize=12)
    fig.text(0.04, 0.5, '振幅 (μV)', va='center', rotation='vertical', fontsize=12)
    fig.suptitle(f'试次 {trial_idx+1} 的EEG波形 ({start_time}-{start_time+duration}秒)', fontsize=14)
    
    plt.tight_layout()
    return fig

def plot_topographic_map(data, channel_names, trial_idx=0, time_point=None, 
                         sampling_rate=128, figsize=(10, 8)):
    """
    绘制地形图
    
    参数:
        data: EEG数据
        channel_names: 通道名称列表
        trial_idx: 试次索引
        time_point: 时间点(秒),如果为None则使用平均值
        sampling_rate: 采样率
        figsize: 图形大小
    """
    import matplotlib.pyplot as plt
    from scipy.interpolate import griddata
    
    # 通道位置(简化版,实际需要准确的3D坐标)
    # 这里使用2D近似投影
    channel_positions_2d = {
        'Fp1': (-0.3, 0.8), 'Fp2': (0.3, 0.8),
        'F3': (-0.5, 0.5), 'F4': (0.5, 0.5), 'Fz': (0, 0.6),
        'C3': (-0.6, 0), 'C4': (0.6, 0), 'Cz': (0, 0.2),
        'P3': (-0.5, -0.5), 'P4': (0.5, -0.5), 'Pz': (0, -0.3),
        'O1': (-0.3, -0.8), 'O2': (0.3, -0.8), 'Oz': (0, -0.7)
        # 可以添加更多通道...
    }
    
    # 提取数据
    if time_point is None:
        # 使用整个试次的平均值
        channel_values = data[trial_idx, :32, :].mean(axis=1)
    else:
        # 使用特定时间点
        sample_idx = int(time_point * sampling_rate)
        channel_values = data[trial_idx, :32, sample_idx]
    
    # 创建插值网格
    grid_x, grid_y = np.mgrid[-1:1:100j, -1:1:100j]
    
    # 准备已知点数据
    points = []
    values = []
    for ch_idx, ch_name in enumerate(channel_names[:32]):
        if ch_name in channel_positions_2d:
            x, y = channel_positions_2d[ch_name]
            points.append([x, y])
            values.append(channel_values[ch_idx])
    
    points = np.array(points)
    values = np.array(values)
    
    # 插值
    grid_z = griddata(points, values, (grid_x, grid_y), method='cubic', fill_value=0)
    
    # 绘制
    fig, ax = plt.subplots(figsize=figsize)
    contour = ax.contourf(grid_x, grid_y, grid_z, levels=50, cmap='RdBu_r')
    
    # 添加通道位置点
    for ch_name, (x, y) in channel_positions_2d.items():
        ax.plot(x, y, 'ko', markersize=6)
        ax.text(x, y + 0.05, ch_name, ha='center', fontsize=8)
    
    # 添加颜色条
    plt.colorbar(contour, ax=ax, label='振幅 (μV)')
    
    # 设置标题
    if time_point is None:
        title = f'试次 {trial_idx+1} 的平均地形图'
    else:
        title = f'试次 {trial_idx+1} 在 {time_point:.2f} 秒的地形图'
    
    ax.set_title(title)
    ax.set_aspect('equal')
    ax.axis('off')
    
    return fig

5.4 性能评估与模型验证

最后,建立可靠的评估流程至关重要。对于情绪识别任务,我建议使用分层交叉验证,并考虑被试独立性:

def evaluate_emotion_classification(features, labels, subject_ids=None, n_splits=5):
    """
    评估情绪分类性能
    
    参数:
        features: 特征矩阵
        labels: 标签数组
        subject_ids: 被试ID数组(用于被试独立的交叉验证)
        n_splits: 交叉验证折数
    
    返回:
        results: 评估结果字典
    """
    from sklearn.model_selection import StratifiedKFold, GroupKFold, cross_validate
    from sklearn.preprocessing import StandardScaler
    from sklearn.pipeline import Pipeline
    from sklearn.svm import SVC
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.metrics import accuracy_score, f1_score, confusion_matrix
    
    # 创建评估管道
    pipelines = {
        'SVM': Pipeline([
            ('scaler', StandardScaler()),
            ('classifier', SVC(kernel='rbf', C=1.0, gamma='scale', random_state=42))
        ]),
        'RandomForest': Pipeline([
            ('scaler', StandardScaler()),
            ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
        ])
    }
    
    # 定义评估指标
    scoring = {
        'accuracy': 'accuracy',
        'f1_weighted': 'f1_weighted',
        'precision': 'precision_weighted',
        'recall': 'recall_weighted'
    }
    
    results = {}
    
    for model_name, pipeline in pipelines.items():
        print(f"评估 {model_name}...")
        
        if subject_ids is not None:
            # 使用GroupKFold确保同一被试的数据不在训练集和测试集同时出现
            cv = GroupKFold(n_splits=min(n_splits, len(np.unique(subject_ids))))
            cv_splits = cv.split(features, labels, groups=subject_ids)
        else:
            # 使用分层K折交叉验证
            cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
            cv_splits = cv.split(features, labels)
        
        # 执行交叉验证
        cv_results = cross_validate(
            pipeline, features, labels,
            cv=cv_splits,
            scoring=scoring,
            return_train_score=True,
            n_jobs=-1
        )
        
        # 收集结果
        results[model_name] = {
            'test_accuracy': cv_results['test_accuracy'],
            'test_f1_weighted': cv_results['test_f1_weighted'],
            'train_accuracy': cv_results['train_accuracy'],
            'fit_time': cv_results['fit_time'],
            'score_time': cv_results['score_time']
        }
        
        # 打印平均性能
        print(f"  {model_name} 平均准确率: {cv_results['test_accuracy'].mean():.3f} ± {cv_results['test_accuracy'].std():.3f}")
        print(f"  {model_name} 平均F1分数: {cv_results['test_f1_weighted'].mean():.3f} ± {cv_results['test_f1_weighted'].std():.3f}")
    
    return results

def plot_confusion_matrix(y_true, y_pred, class_names=None, figsize=(8, 6)):
    """
    绘制混淆矩阵
    
    参数:
        y_true: 真实标签
        y_pred: 预测标签
        class_names: 类别名称列表
        figsize: 图形大小
    """
    import matplotlib.pyplot as plt
    import seaborn as sns
    from sklearn.metrics import confusion_matrix
    
    cm = confusion_matrix(y_true, y_pred)
    
    if class_names is None:
        class_names = [f'Class {i}' for i in range(cm.shape[0])]
    
    fig, ax = plt.subplots(figsize=figsize)
    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', 
                xticklabels=class_names, yticklabels=class_names, ax=ax)
    
    ax.set_xlabel('预测标签')
    ax.set_ylabel('真实标签')
    ax.set_title('混淆矩阵')
    
    return fig

处理DEAP数据时,我最大的体会是:细节决定成败。那些看似微小的预处理选择——比如基线校正的时间窗口、重参考的方法、特征提取的参数——往往比模型架构的选择影响更大。特别是在情绪识别这种信噪比低的任务中,干净、一致的数据预处理流程是获得可靠结果的基石。上面这些代码片段都是我实际项目中提炼出来的,但每个项目都有其特殊性,你需要根据具体的研究问题和数据特点进行调整。最重要的是建立系统化的预处理流程,并记录每一个决策点,这样才能确保结果的可复现性。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐