news 2026/10/9 15:00:38

DEAP脑电情绪二分类实战:FFT特征提取+SVM/KNN/决策树完整流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DEAP脑电情绪二分类实战:FFT特征提取+SVM/KNN/决策树完整流程

简介:面向刚接触脑电信号处理与机器学习的新手研究者,这份基于DEAP脑电数据集的脑电情绪二分类项目提供了从信号处理到模型训练的一站式完整流程。项目覆盖快速傅里叶变换(FFT)频域转换、滤波去伪迹与归一化等数据预处理步骤,并实现决策树、SVM、KNN三类经典模型,均可直接调用库完成训练与测试,便于快速理解脑电情绪识别的基本技术路线。压缩包共7个文件,以Python脚本为主(含特征提取、FFT处理、主流程整合等分工明确的模块),另附项目说明文档与辅助内容压缩包,整体仅8KB,轻量易用。目前已有134人浏览学习。通过该资源,读者可以掌握从原始脑电信号到特征提取、模型训练与评估的完整链路,并基于这些简洁的基础模型进一步扩展实验、逐步深入更高级的识别方案。

1. 用DEAP做脑电情绪二分类:一个能快速跑通全流程的入门资源

脑电情绪识别这几年在机器学习圈子里热度一直不低,但真正上手的人往往卡在第一步——数据怎么读、特征怎么提、模型怎么选。这份基于DEAP数据集的脑电情绪二分类资源,把从原始脑电信号到SVM/KNN/决策树出分类结果的完整链路都拆开了,很适合想要在脑电数据上做一次完整实验的初学者。它用FFT做频域特征提取,用三个可直接调库的经典分类器做二分类验证,代码结构清晰,能帮你绕过从「看了很多论文」到「跑通第一个实验」之间的那道坎。下面我把这个项目从数据格式到模型验证逐个拆开讲,顺便把几个容易翻车的细节也一并说清楚。

2. FFT特征提取管线:把32通道脑电压成128维向量

2.1 为什么情绪分类要先做FFT:时域信号到频带能量的映射

脑电信号本质上是微伏级别的时域电压波动,直接看波形很难区分情绪状态。但神经生理学上有个基本规律:不同情绪状态会改变大脑特定频带的能量分布。放松时alpha波活跃,情绪唤醒时beta和gamma波能量上升。把时域波形做FFT转到频域,等于把这个规律转成分类器能用的数值特征。

DEAP数据集官方采样率是512Hz,但网上流传最广的版本是降采样到128Hz的。一次试验63秒,128Hz采样就是8064个采样点,32个脑电通道。如果直接把原始时域信号作为特征,每个trial就是32乘以8064,超过25万维,别说SVM,神经网络都不好直接吃。用FFT把所有通道的频域包络压缩成几个频带的平均功率,维度直接降到32通道乘以4个频带等于128维,这个降维幅度是线性的,几乎没有信息损失——至少对于情绪二分类这个任务足够了。

2.2 拆解fft.py:频带划分与功率谱特征计算

这个项目里的fft.py核心逻辑并不复杂,就是rfft算频谱,然后按脑电频带切分功率谱取均值。我在复现时用scipy的rfft重写了核心函数,代码更短且不容易写错索引。

import numpy as np from scipy.fft import rfft, rfftfreq def band_power_features(signal, fs=128.0, bands=None): """ 对单通道脑电信号提取频带平均功率特征 signal : 一维数组,形状为 (n_samples,) fs : 采样率,DEAP降采样后为128Hz bands : 频带字典,键为频带名,值为(下限Hz, 上限Hz) """ if bands is None: bands = {'theta': (4, 8), 'alpha': (8, 13), 'beta': (13, 30), 'gamma': (30, 45)} n = len(signal) freqs = rfftfreq(n, d=1.0 / fs) spectrum = rfft(signal) # 功率谱 = 幅值谱的平方 power = np.abs(spectrum) ** 2 feats = [] for lo, hi in bands.values(): mask = (freqs >= lo) & (freqs < hi) feats.append(np.mean(power[mask])) return np.array(feats)

这里有几个关键点。rfft只计算正频率部分,输出长度大约是n的一半,计算量比完整FFT少一半,这对一次处理32个通道的循环很重要。d=1.0/fs这个参数决定频率轴的刻度,如果传错,后面所有频带索引都会错位,特征值看上去正常但含义全偏了。

频带划分沿用的是脑电研究里标准做法:theta管情绪加工,alpha管放松程度,beta和gamma管唤醒。原项目里还有个细节,频带边界用了左闭右开区间,避免同一个频率点被两个频带重复计算。碰到底部噪声偏高的时候,可以把theta下限从4Hz抬到5Hz,对去噪效果比较明显。

2.3 eeg_plot_code.py:画图确认信号质量

先说明一个现实:脑电数据不是拿到就能直接提特征的。眼动伪迹、肌电干扰、电极接触不良产生的漂移,都会让FFT算出来的频谱失真。eeg_plot_code.py这个绘图脚本就是用来做人工质检的。

import matplotlib.pyplot as plt import numpy as np def plot_trial_signal(data_trial, channel=0, fs=128): """ data_trial: 形状为 (n_channels, n_samples) 的单个trial channel : 指定要查看的通道索引 fs : 采样率 """ n_samples = data_trial.shape[1] time = np.arange(n_samples) / fs plt.figure(figsize=(12, 4)) plt.plot(time, data_trial[channel], lw=0.6, color='steelblue') plt.xlabel('Time (s)') plt.ylabel('Amplitude (uV)') plt.title(f'Channel {channel + 1} Raw Signal') plt.grid(alpha=0.3) plt.show()

看波形时重点关注两件事:一是整段信号有没有明显的直流漂移,也就是波形不是围绕0线上下波动,而是缓慢地整体抬升或下降,这种情况后期需要在预处理里做基线校正;二是有没有突然出现的尖峰,那往往是眨眼或头部动作留下的伪迹。正常脑电的幅值范围在正负50微伏左右,如果某个通道峰峰值到了两三百微伏,这个通道就要标记出来,要么剔除要么后续用ICA去伪迹。

频谱图也可以顺手看一眼。

def plot_spectrum(signal, fs=128): freqs = np.fft.rfftfreq(len(signal), 1 / fs) power = np.abs(np.fft.rfft(signal)) ** 2 plt.figure(figsize=(10, 3)) plt.plot(freqs, power, lw=0.8) plt.xlabel('Frequency (Hz)') plt.ylabel('Power') plt.xlim([0, 60]) plt.show()

正常脑电频谱应该在低频段能量偏大、高频段平滑衰减。如果看到50Hz处有异常尖峰,那是工频干扰,DEAP数据本身没这个问题,但自己采集数据时这是最常见的坑,后面章节细说。

3. 数据预处理与标签划分:把DEAP的40次试验变成可训练样本

3.1 DEAP数据长什么样:trial、通道、标签的组织方式

很多人在DEAP上第一次翻车,是因为搞不清数据文件的结构。DEAP的mat文件里有两个核心变量:data和labels。data的形状是(40, 40, 8064),含义是40次试验,40个通道,8064个采样点。40个通道里前32个是脑电通道,后8个是眼电、肌电、皮电这类生理信号,做脑电情绪识别时一般只取前32个。

labels的形状是(40, 4),分别对应valence、arousal、dominance、liking四个维度的评分,每个评分范围是1到9。项目要做的是情绪二分类,通常就是用valence(正负效价)或者arousal(唤醒度)做标签。一次试验持续63秒,其中前3秒是静息基线,后面60秒才是真正的刺激呈现阶段。基线数据很重要,后面预处理要用它做校正。

3.2 预处理管线:降采样、滤波、基线校正、归一化

这个项目里把预处理分为滤波、去伪迹、归一化几个步骤。我按自己的习惯把完整管线串了一遍,顺序是最关键的。

import scipy.io as sio import numpy as np from scipy import signal as sig def load_deap_subject(file_path): """ 加载单个被试的DEAP数据文件 注意:DEAP文件扩展名是.dat,但内部是MAT v5格式 """ mat = sio.loadmat(file_path) # squeeze_me会让shape变化,这里不用 data = mat['data'] # (40 trials, 40 channels, 8064) labels = mat['labels'] # (40, 4) return data, labels def preprocess_trial(raw_trial, fs=128, baseline_seconds=3): """ raw_trial: 单个trial,(40 channels, 8064) 返回: 去基线后的32通道数据 """ eeg = raw_trial[:32, :] # 只保留脑电通道 # 1. 滤波:4-45Hz带通,再补一个50Hz陷波 b_band, a_band = sig.butter(4, [4, 45], btype='bandpass', fs=fs) eeg = sig.filtfilt(b_band, a_band, eeg, axis=1) b_notch, a_notch = sig.iirnotch(50, 30, fs=fs) eeg = sig.filtfilt(b_notch, a_notch, eeg, axis=1) # 2. 基线校正:减去前3秒的均值 baseline_len = baseline_seconds * fs baseline_mean = eeg[:, :baseline_len].mean(axis=1, keepdims=True) eeg = eeg - baseline_mean # 3. 数据已经是128Hz降采样版,无需再降采样 return eeg

为什么滤波一定要放在基线校正之前?因为带通滤波本身会改变信号的直流分量,先滤波再减基线,去除漂移的效果更干净。filtfilt是零相位滤波,不产生相位偏移,但计算量比lfilter大一倍,对8064个点来说完全不是问题。iirnotch陷波滤波器是专门对付50Hz工频干扰的,DEAP数据干净不代表不需要这一步,写进管线里是为了让它能复用到自采数据。

有一个容易被忽略的细节:loadmat不要带squeeze_me参数。一旦squeeze_me=True,只有一个trial时数据维度会从3维变成2维,后面所有按索引切片的位置全部错位,这个Bug非常隐蔽。

3.3 怎么划出二分类标签:valence阈值与Trial取舍

标签划分是个看似简单但其实很关键的操作。DEAP的valence评分是1到9的连续值,二分类就得先定阈值。

def make_binary_label(labels, target_index=0, threshold=5.0, subject_mean=False): """ labels: (40, 4) 的原始评分 target_index: 0=valence, 1=arousal, 2=dominance, 3=liking threshold: 固定阈值,默认5.0 subject_mean: 是否用被试自己的均值做阈值 """ scores = labels[:, target_index] if subject_mean: threshold = scores.mean() return (scores > threshold).astype(int)

两种阈值策略差别很大。固定阈值5.0是DEAP论文里的常规做法,含义直白:评分5以上算正类。但现实情况是,不同被试打分的尺度不一样,有人喜欢打高分,有人习惯打低分,固定阈值会造成某些被试几乎全被分到同一个类别。按被试均值为阈值时,每个被试都有自己的分类边界,类别会更均衡,但跨被试泛化时结果会偏乐观一点。

我一般会两种都跑一遍,看稳定性。如果两类样本比例严重失衡,比如正负样本到7比3,SVM和KNN的决策边界都会偏向多数类,这时要考虑加权参数或者进行简单的过采样,这个在第五章再展开。

4. 模型选型与调参:SVM、KNN、决策树在128维特征上的表现

4.1 为什么选这三个模型:先建立基线再谈深度模型

很多人看到脑电情绪识别就想到深度学习,但这项目里刻意选用了SVM、KNN和决策树,这个选择是有道理的。DEAP二分类任务本质上是小样本问题,即便用满32个被试,每被试40个trial,总共也只有1280个样本。这个量级下,深度学习模型很难发挥优势,反而容易过拟合到训练集上,测试结果忽高忽低,黑匣子一样的运行过程也让排错变得困难。

SVM在小样本高维特征上是经典选择,RBF核能处理线性不可分的情况;KNN无训练过程,天然适合做基准对照;决策树提供完全可解释的决策路径,能直观看出模型在依赖哪些频带特征。这个组合比直接上神经网络更扎实,三个模型互相印证,不容易被某个模型的偶然表现带偏。

4.2 main_v1.py的完整流程:从特征矩阵到分类报告

main_v1.py把整个流程串起来了。梳理下来核心逻辑是这样的:读取数据、提取FFT特征、划分训练测试集、标准化、训练三个模型、输出分类报告。

import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.neighbors import KNeighborsClassifier from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import classification_report, accuracy_score def build_feature_matrix(data): """ data: (40 trials, 40 channels, 8064) 返回: (40 trials, 128 features) """ n_trials = data.shape[0] X = np.zeros((n_trials, 32 * 4)) for trial_idx in range(n_trials): feats = [] for channel in range(32): signal = data[trial_idx, channel, :] feats.extend(band_power_features(signal, fs=128)) X[trial_idx, :] = feats return X # 主流程(单被试示例) X = build_feature_matrix(subject_data) y = make_binary_label(subject_labels, target_index=0, threshold=5.0) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 标准化:必须先fit训练集,再transform测试集 scaler = StandardScaler().fit(X_train) X_train_scaled = scaler.transform(X_train) X_test_scaled = scaler.transform(X_test) models = { 'SVM': SVC(kernel='rbf', C=10, gamma='scale', random_state=42), 'KNN': KNeighborsClassifier(n_neighbors=5, weights='distance'), 'DecisionTree': DecisionTreeClassifier(max_depth=5, random_state=42) } for name, model in models.items(): model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) print(f'{name}: {accuracy_score(y_test, y_pred):.3f}') print(classification_report(y_test, y_pred, zero_division=0))

特征矩阵构建时用了两层循环,外层遍历trial,内层遍历通道。每个trial的128维特征由32个通道分别算出4个频带功率后拼接而成。代码里有个潜在的性能瓶颈:40个trial两层循环问题不大,但如果扩展到32个被试就是要循环1280次,FFT本身计算量也不小,建议把所有trial的原始信号先拼成一个大数组一次性做rfft,能省掉不少时间。

train_test_split里的stratify=y参数很关键,它保证划分后的训练集和测试集正负样本比例一致,避免出现测试集全是正类这种极端情形。random_state固定为42,确保每次运行结果可复现。

4.3 三个模型的参数建议:C、核函数、n_neighbors、max_depth

模型关键参数推荐值调参方向
SVMkernel / C / gammarbf / 10 / scaleC过大会过拟合,128维特征下C=1到10是安全区间
KNNn_neighbors / weights5 / distance样本量小,K值取3到7,距离权重优先
决策树max_depth / min_samples_split5 / 5深度超过5基本就过拟合了,除非特征数增加到256以上

SVM对特征缩放极其敏感,这就是为什么要先StandardScaler。如果不做标准化,脑电电力谱的数值范围很大,有些通道的beta功率可能是theta功率的几十倍,SVM的决策边界会被这些大数值特征绑架。gamma='scale'是根据特征数量自动计算的值,128维下效果不错,不用手动去调。

KNN用欧氏距离时,同样受特征量纲影响。weights='distance'让离得更近的近邻权重更大,比默认的uniform能提升一点点准确率,代价是计算量稍大,对128维的向量来说可以忽略。

决策树的max_depth=5是个保守值。脑电特征之间本身就存在相关性,比如相邻通道的alpha功率几乎肯定高度相关,树模型容易在这些冗余特征上反复分裂,深度一大必然过拟合。min_samples_split=5保证每个叶节点至少需要5个样本才能继续划分,进一步抑制过拟合。

5. 避坑:DEAP二分类常见的五个问题与排查记录

5.1 加载.dat文件报错:文件扩展名误导与scipy兼容

现象:用sio.loadmat加载DEAP的.dat文件时报错,说"not a mat file"或者文件格式无法识别。

原因:DEAP官方数据文件扩展名是.dat,但内部其实是MATLAB的MAT v5格式。有些人想当然地把它当成文本或二进制文件读,结果自然不对;有人则用h5py去读,因为新版MATLAB默认格式改成HDF5了,但DEAP发布较早用的还是旧版格式。

解决:老老实实用scipy.io.loadmat读,别管扩展名。如果编辑器里看不出文件头,可以用file命令检查文件类型。顺带确认一下loadmat参数,不要加squeeze_me=True,否则数据维度会被吃掉。

5.2 归一化放在划分之前:数据泄漏让结果虚高

现象:在单被试上跑SVM准确率能到95%以上,换到新被试立马掉回60%。

原因:代码里先对整个数据集做了StandardScaler,再划分训练测试集。标准化时已经用到了测试集的均值和方差,等于把测试集的信息提前泄露给了模型,测试准确率虚高是必然的。

解决:正确的顺序是先划分训练集和测试集,再用训练集的均值和方差去标准化测试集。代码里体现为scaler.fit(X_train),然后transform两个集合。很多人习惯在数据预处理阶段顺手做标准化,但在机器学习流程里,这一步必须放到划分之后。

5.3 固定阈值5.0导致类别不平衡:部分被试样本全被分到同一类

现象:模型评估结果出现极端值,某个被试的accuracy只有50%,但sensitivity是100%,specificity是0%。看混淆矩阵发现预测结果全是背景类。

原因:固定阈值5.0对评分习惯不同的被试不公平。某些被试的valence评分集中在3到7之间,均值可能只有4.2,正样本比例远低于负样本。极端情况下正样本只有两三个,模型为了总体准确率干脆全部预测多数类。

解决:先检查每个被试的正负样本分布,打印出来看。正负比失衡时改用被试自身均值作为阈值,或者在分类器里加上class_weight='balanced',将SVM和决策树都转换为代价敏感学习。伪影箱还好说,但如果30个trial里有25个是负类,这个被试的数据就要考虑从实验里剔除或做定向采样。

5.4 FFT窗口选取不当导致特征漂移

现象:同一个trial,前后提取两遍特征,数值分布完全对不上。

原因:有些FFT实现会对整段信号零填充,零填充会让频谱变平滑,但对短数据段效果很差。还有一种常见做法是加窗函数,比如汉明窗,这会改变频带功率的绝对值。原项目代码里如果不处理窗口,就是默认矩形窗,也有隐患——当信号不是周期整数倍时,矩形窗会产生频谱泄漏,θ波的能量漏到δ频带里。

解决:固定实现,统一处理方式。要么整个数据集都用不加窗的rfft,要么统一加汉明窗。考虑到DEAP的trial长度是8064点,对4Hz以上的频带来说,频率分辨率约有0.016Hz,频谱泄漏的影响不大。但代码里一旦出现加窗,就必须保证所有trial用同一个窗函数,否则特征的绝对值没有可比性。如果看频谱图发现θ带和α带之间存在明显的阶梯状突起,优先考虑是不是窗口和频率分辨率不匹配。

5.5 混入非脑电通道:后8个生理信号通道被当成了特征

现象:模型效果出奇地好,准确率接近100%,但评估时用的是同一被试的trial,泛化到跨被试任务时崩掉。

原因:DEAP的40通道里,前32是脑电,后8是EOG、EMG、GSR这类生理信号。眼电动则几百微伏,EEG只有几十微伏,FFT做出来眼电频带能量远高于脑电。分类器学到的是"这个样本眼电活跃度高",而不是情绪相关的脑电模式,所以被试内效果虚高。

解决:构建特征矩阵之前强制只取前32个通道,代码里用eeg = raw_trial[:32, :]截断。如果后端8个通道的生理信号想用也行,但需要单独建模,不要和脑电混在一起提特征,两者幅值量级不是一回事。

6. 验证实验:用五折交叉验证和混淆矩阵看模型真实水平

很多初学者的第一个实验都是单次划分训练测试集,跑出一个accuracy就收工了,这种验证方式在脑电数据上不够可靠。DEAP里每个被试的40个trial本身存在时间连续性,同一被试相邻trial之间信号模式高度相似,随机划分训练测试集时,模型很可能见过某个trial的"近亲",准确率偏乐观。

推荐的验证策略是分层五折交叉验证,配合逐被试的混淆矩阵看细节。代码可以这样组织:

from sklearn.model_selection import StratifiedKFold, cross_val_score def evaluate_models_cv(X, y, n_splits=5): cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) feature_scaler = StandardScaler() for name, model in models.items(): acc_list = [] for train_idx, test_idx in cv.split(X, y): X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx] X_train = feature_scaler.fit_transform(X_train) X_test = feature_scaler.transform(X_test) model.fit(X_train, y_train) acc_list.append(model.score(X_test, y_test)) vals = np.array(acc_list) print(f'{name}: {vals.mean():.3f} ± {vals.std():.3f}')

五折交叉验证里,每次折叠的测试集占20%,模型在5个不同子集上分别评估一次,均值和方差一起看才可靠。方差大说明模型在不同数据子集上表现不稳定,这个不稳定性本身比均值更有参考价值。

混淆矩阵是另一个必须看的指标,尤其当两类样本不平衡时:

from sklearn.metrics import confusion_matrix y_pred = models['SVM'].predict(X_test_scaled) cm = confusion_matrix(y_test, y_pred) print('Confusion matrix:') print(cm) # 输出格式: # [[TN, FP], # [FN, TP]]

只看accuracy很容易被多数类带偏。比如90%的样本都是负类,全预测负类的准确率就是90%,看起来挺高,实际模型什么都没学会。混淆矩阵能让你一眼看出模型是真正学到了两个类别的界限,还是只是靠类别先验信息在猜。

脱离单个被试的评估之外,跨被试验证才是脑电情绪识别里真正残酷的测试。用前20个被试训练,后12个被试测试,准确率通常会比被试内实验掉15到20个百分点。别把这个结果当成失败,跨被试性能下降代表模型学到的是个体特异性模式,而不是普遍的情绪神经机制。想提升跨被试泛化能力,可以尝试把特征换成微分熵,或者在预处理里加入被试级标准化,把不同被试的信号幅度拉齐。这个项目以单被试分析为主,对于入门来说这个起点足够扎实了。

我自己的习惯是,每次跑完一个脑电分类实验,都强制走一遍单被试混淆矩阵加跨被试折线图这两步,前者确认模型没有偷懒,后者给自己一个客观的泛化预期。先把这两个基本动作养成肌肉记忆,再往深度学习方向延伸也不迟。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 14:53:45

海上智慧风电场解决方案:从无人值班到区域能源协同

简介&#xff1a;面向海上风电场智能化建设与新能源项目规划人员&#xff0c;这份PDF系统梳理了智慧风电场解决方案的整体框架&#xff0c;回应了离岸远、可达性差、少人/无人值守等实际运维痛点。方案以数字化风电场为核心&#xff0c;强调数字模型与实物资产一一对应&#xf…

作者头像 李华
网站建设 2026/10/9 14:52:36

电力装备数字孪生落地指南:从建模到实时同步的避坑实践

简介&#xff1a;这份PDF文档围绕电力装备数字孪生关键技术研究及应用展开&#xff0c;面向电力系统智能化方向的科研人员、工程技术人员及高校相关专业师生&#xff0c;帮助读者理解数字孪生如何提升电力装备运行的安全性、可靠性与经济性。内容涵盖数据采集与融合、物理模型构…

作者头像 李华
网站建设 2026/10/9 14:49:01

Python调用DeepSeek代码生成接口实战指南

简介&#xff1a;本资源是一份面向Python开发者与AI工程实践者的DeepSeek代码生成接口实战指南&#xff0c;聚焦大模型编程提效场景&#xff0c;特别适合希望快速掌握API调用、提升代码产出质量的中初级开发者。文档以10个递进式实战案例为核心&#xff0c;覆盖函数生成、算法实…

作者头像 李华
网站建设 2026/10/9 14:48:59

AI集群网络面试核心:从IB与RoCE到拥塞控制的实战指南

1. 为什么AI集群网络能单独成为一个面试板块说实话&#xff0c;两三年前大家面AI Infra岗位&#xff0c;问的还是“用过什么框架、怎么调参、CUDA优化熟不熟”。但这两年风向明显变了&#xff0c;AI集群网络被提到了一个几乎必考的位置。我自己面过不少候选人也被人面过&#x…

作者头像 李华
网站建设 2026/10/9 14:48:34

ARCGIS笔记:用arcpy计算质心的完整流程与TaoToken配置要点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华