简介这是一份基于粒子群优化算法PSO优化卷积双向门控循环单元融合注意力机制CNN-BiGRU-Attention的时间序列预测项目实例文档面向具备一定机器学习与深度学习基础的研究人员、工程师以及关注预测建模和超参数自动调优的从业者。资源以单个docx文档形式提供压缩包约84KB但内容组织完整目录涵盖项目背景、目标与意义、挑战及解决方案、模型架构、模型描述与代码示例、特点与创新等章节便于按需查阅。目前已有50人学习或浏览。文档不仅给出了CNN局部特征提取、BiGRU双向时间依赖、Attention动态权重分配与PSO超参数优化的原理和实现代码还包含GUI设计过程完整覆盖从环境准备、数据处理、模型构建与训练到性能评估的流程并分析了多样化时序特征提取、高维搜索空间、梯度消失、过拟合及实时预测等工程难点同时给出金融预测、气象分析、能源负荷、交通流量等多领域应用示例适合用于快速复现、二次开发与知识拓展。1. 从调参调到怀疑人生到让PSO替你做决定这套时序预测项目能帮你省下两周时间做时间序列预测的工程师多半有过这种经历模型结构选好了数据也洗干净了结果卡在超参数上——卷积核设多大、GRU隐层放几个单元、学习率给多少每个都得试一跑就是几个小时试完还不一定收敛。这套基于PSO-CNN-BiGRU-Attention的完整项目源码核心就是用粒子群优化算法PSO把调参这件事自动化让模型自己去搜最优的超参数组合你只需要把数据喂进去等它把结果吐出来。它不是个只跑通一次的demo而是把环境配置、数据处理、模型构建、训练评估、GUI展示全链路都封装好了的工程包适合已经懂一点深度学习、正在做时序预测项目但被调参和代码整合折磨的从业者。接下来我从架构原理讲到避坑细节全部按可复现的标准来拆。2. 模型架构拆解CNN、BiGRU、注意力机制各自在解决什么问题2.1 CNN层先用卷积把局部时序特征捞出来时间序列预测里CNN不是用来处理图像的而是用来在序列上滑动卷积核提取局部模式。比如电力负荷数据里经常出现的“每天同一时段的小高峰”或者股票数据里连续几根K线形成的短期形态都是局部特征。常见的做法是设置一维卷积Conv1D卷积核大小取3或5步长为1padding保持序列长度不变。代码里对应的实现大概是这样的from tensorflow.keras.layers import Conv1D, MaxPooling1D # 假设输入形状是 (batch_size, timesteps, features) conv_out Conv1D(filters64, kernel_size3, paddingsame, activationrelu)(inputs) pool_out MaxPooling1D(pool_size2)(conv_out)卷积核大小决定的是“看多宽的局部窗口”kernel_size3意味着每次只看3个时间步的局部关系适合波动频繁的数据kernel_size5则覆盖更宽的窗口适合趋势性更强的数据。filters是卷积核数量数量越多能提取的特征维度越多但参数量也越大训练越慢。MaxPooling在这里做降采样压缩序列长度让后面的循环网络处理更短的序列减少计算量。这段代码跑完之后输出的特征图序列仍然保留了时间顺序这才是能喂给BiGRU的正确格式。用PSO去优化CNN部分的超参数时最值得调的就是filters和kernel_size因为这俩直接决定了特征提取的粒度和模型容量。2.2 BiGRU层双向看序列前后文信息都不放过GRU是LSTM的轻量级替代门控结构少一个参数更少训练更快在小数据集上经常比LSTM更稳。单向GRU只能从前往后看但很多时序预测任务里当前时刻的值不仅依赖过去也依赖未来的上下文——比如一句话里某个词的含义要看了后面的词才能确定气象数据里某时刻的湿度异常可能要到后几个小时才表现出影响。BiGRU就是正向跑一遍GRU、反向再跑一遍GRU两个方向的隐藏状态拼起来作为输出。实现上直接调用Keras的Bidirectional包装层from tensorflow.keras.layers import Bidirectional, GRU # x 的维度: (batch_size, timesteps, features) gru_out Bidirectional(GRU(units64, return_sequencesTrue))(x)这里的units是每个方向上的GRU单元数输出维度是2倍units因为前后两个方向的隐藏状态被拼接了。return_sequencesTrue很重要——必须保持输出序列完整因为后面还要接Attention层attention需要对每一个时间步计算权重。如果设成FalseSequence到Sequence的映射就断了Attention层根本没法做。BiGRU层的超参数里units是PSO优化的重点对象。units太小记忆能力不足units太大训练变慢不说还容易过拟合。常见搜索范围在32到128之间。2.3 Attention层让模型学会“关键时刻多看一眼”BiGRU虽然能捕捉双向依赖但它对序列里每个时间步是一视同仁地编码的。实际问题里不是这样明天会不会下雨更依赖前12小时的气压变化趋势而不是30天前的某一天。Attention机制做的事就是给每个时间步算一个权重让模型把有限的表达能力集中在关键时间步上。实现思路是对BiGRU的输出序列先做一个全连接映射得到打分score再用softmax归一化成权重最后对序列做加权求和得到一条定长向量送入全连接输出层。from tensorflow.keras.layers import Dense, Lambda import tensorflow as tf # scores 形状: (batch_size, timesteps, 1) scores Dense(1, activationtanh)(gru_out) # 去掉最后一维变成 (batch_size, timesteps) scores tf.squeeze(scores, axis-1) weights tf.nn.softmax(scores, axis-1) # 加权求和得到 (batch_size, 2 * units) context tf.reduce_sum(gru_out * tf.expand_dims(weights, axis-1), axis1)这段代码是Attention的核心逻辑原理是先用一个Dense层把每个时间步的隐状态压缩成单维打分再softmax变成权重分布。如果训练数据里某些时间步对预测结果贡献极大对应位置的权重就会偏向1其余位置趋近0。这也顺带给了模型可解释性——你可以把权重画出来看看模型到底在关注哪些时间段这在金融、气象领域做分析报告时非常好用。Attention没有额外需要PSO去搜的超参数它的超参数就是Dense层里的神经元数保持1就行加了反而增加过拟合风险。2.4 前向传播维度变化从原始序列到预测值的一次完整流动把整条链路串起来的维度变化值得仔细盘一遍。假设输入是96个时间步、每个时间步5个特征的序列形状是(batch, 96, 5)。经过Conv1D之后如果filters64、paddingsame长度不变形状变为(batch, 96, 64)。接着MaxPooling池化步长2时间步变成48形状是(batch, 48, 64)。BiGRU的return_sequencesTrue输出维度是128形状变为(batch, 48, 128)。Attention层做加权求和去掉时间维度得到(batch, 128)。最后接一个Dense层输出预测未来某个时刻的值形状是(batch, 1)。这个维度链条是排查bug的第一手工具。模型跑不起来或者是shape报错的时候先把这个链条画在草稿纸上逐层核对90%的维度问题一眼就能看出来。3. PSO优化搜索空间设计与收敛判断3.1 PSO在干什么把超参数组合当成一群鸟在飞PSO粒子群优化的思路很直观把一组超参数组合比如learning_rate、units、filters、batch_size看成搜索空间里的一个粒子位置每个粒子有自己的速度根据个体历史最优位置pbest和群体历史最优位置gbest来更新自己的速度和位置。粒子的位置本身就编码了一组超参数适应度函数就是模型在这组超参数下的验证集误差比如验证集MAE。粒子群迭代若干轮之后gbest就是找到的最优超参数组合。这套项目里的PSO和模型训练是嵌套关系外层PSO每评估一组超参数就要完整训练一次CNN-BiGRU-Attention模型然后在验证集上算误差。所以粒子数不宜设太多一般取5到10即可迭代轮数根据你的训练时间预算来定通常是5到15轮。粒子数乘以迭代轮数就是你总共要训练模型的次数——耗时是大头要有心理准备。3.2 搜索空间设置范围定得准收敛才快搜索空间是PSO优化效果的决定性因素。范围设小了最优解可能在范围外范围设大了搜索效率低训练次数多。我一般会这样设置初始搜索范围超参数搜索范围说明学习率[0.0001, 0.01]对数尺度搜索Keras里的LearningRateScheduler需要配合CNN卷积核数量[32, 128]步长取32的整数倍方便缩放CNN卷积核大小[2, 5]整数搜索必须小于时间窗口长度BiGRU单元数[32, 128]整数搜索选偶数batch_size[16, 64]需要考虑GPU显存3.3 PSO核心更新逻辑import numpy as np def pso_update(positions, velocities, pbest, gbest, w0.729, c11.49445, c21.49445): # w: 惯性权重控制粒子的搜索惯性较大则全局搜索能力强 # c1, c2: 个体学习因子和群体学习因子控制向局部最优和全局最优逼近的趋势 r1 np.random.rand(*positions.shape) r2 np.random.rand(*positions.shape) velocities w * velocities c1 * r1 * (pbest - positions) c2 * r2 * (gbest - positions) positions positions velocities return positions, velocities代码里每个粒子的位置更新由三部分构成惯性项让粒子保持原来的运动方向个体认知项把粒子拉向自己找到过的最优位置社会认知项把粒子拉向全群体找到的最优位置。w0.729和c1c21.49445是经典参数组合来自Clerc的收敛性分析论文绝大多数场景下不需要改动。如果你发现PSO收敛太慢可以适当调大w到0.8左右增强全局搜索能力如果发现粒子来回震荡不收敛就调小w到0.6。更新完位置之后要做一件容易被忽略的事把超出搜索范围的粒子位置做越界处理通常就是钳制到边界值上。不做这步粒子速度会越积越大位置飞出去之后对应的超参数比如学习率变成负数模型直接崩。4. 完整复现链路从环境准备到GUI部署的落地实现4.1 环境准备与依赖检查拿到项目之后先把环境对齐。这套代码基于TensorFlow推荐版本是TensorFlow 2.xKeras作为高层API直接从TF里导入。Python建议3.8到3.10之间太新的版本有时候会撞上CUDA兼容性问题。项目代码里这一步做了几件事清空工作区变量、关闭报警信息、检查所需工具箱是否齐全、没有就自动安装。日常自己跑项目时可以简化成下面这样import warnings warnings.filterwarnings(ignore) # 检查关键依赖是否可用 try: import tensorflow as tf import numpy as np import pandas as pd import matplotlib.pyplot as plt print(依赖检查通过) print(TensorFlow版本:, tf.__version__) except ImportError as e: print(缺少依赖:, e)GPU加速的检查也别跳过。如果你的机器上装了CUDA和cuDNNTensorFlow会自动使用GPU如果只用了CPU训练速度会差5到10倍。可以用一行命令确认print(GPU可用:, tf.config.list_physical_devices(GPU))如果这行返回的是空列表说明TensorFlow没检测到GPU要么没装CUDA要么版本不匹配。这种问题排查起来很耗时最省事的路径是直接用Anaconda建一个Python 3.9环境然后用conda安装cudatoolkit和cudnn再用pip装TensorFlow。4.2 数据窗口化把原始序列切成监督学习的输入输出对时间序列预测的第一个人为坑就是原始数据是一串连续序列而深度监督学习需要的是(x, y)配对样本。数据窗口化的做法是设定一个滑动窗口长度look_back用前look_back个时间步预测未来某个时刻的值。比如look_back96就是拿过去96个小时的数据去预测下一个小时的值。实现代码import numpy as np def create_dataset(data, look_back96, predict_step1): X, y [], [] for i in range(len(data) - look_back - predict_step 1): X.append(data[i:i look_back, :]) # 前 look_back 个时间步的所有特征 y.append(data[i look_back predict_step - 1, 0]) # 预测第 0 列的取值 return np.array(X), np.array(y)这里predict_step表示预测未来第几个时间步等于1就是预测下一个时刻。X的形状是(样本数, 96, 特征数)y的形状是(样本数,)。窗口长度越大每个样本包含的历史信息越多但样本总量越少窗口太短则可能截断了影响预测的关键周期。如果你的数据有明确的日周期性窗口至少得覆盖一个完整周期比如小时数据最好取24的倍数。4.3 归一化与数据集划分时间序列预测里数据归一化直接关系到模型能不能收敛。常见的做法是使用MinMaxScaler把数据压缩到[0,1]区间特别注意必须先在训练集上fit计算最小值和最大值再用这个scaler单独transform测试集。这一步是为了防止测试集的数据分布信息泄漏进训练过程属于老手和新手最容易区分的行为差异。from sklearn.preprocessing import MinMaxScaler scaler MinMaxScaler(feature_range(0, 1)) scaled_data scaler.fit_transform(raw_data[:, :]) # 所有特征列一起归一化 train_size int(len(scaled_data) * 0.8) train_data scaled_data[:train_size, :] test_data scaled_data[train_size:, :]划分比例没有绝对标准常见设置是80%训练、20%测试。但如果你做的是金融序列建议按时间顺序划分不要随机打乱——时间序列最忌讳的就是随机抽样破坏时间依赖结构。训练完成后做反归一化把预测值还原到原始尺度再算误差pred_inv scaler.inverse_transform(pred).reshape(-1, 1) true_inv scaler.inverse_transform(y_test).reshape(-1, 1)如果省略反归一化直接算RMSE数值看起来会很奇怪对不上业务量纲也无法和其他模型横向对比。4.4 模型定义与训练选项模型构建的核心代码是把CNN、BiGRU、Attention按顺序组装起来。这里给出完整的模型搭建代码from tensorflow.keras.models import Model from tensorflow.keras.layers import Input, Conv1D, MaxPooling1D, Bidirectional, GRU, Dense, Dropout import tensorflow as tf def build_model(timesteps, features, filters64, kernel_size3, gru_units64): inputs Input(shape(timesteps, features)) # 卷积层提取局部特征 x Conv1D(filtersfilters, kernel_sizekernel_size, paddingsame, activationrelu)(inputs) x MaxPooling1D(pool_size2)(x) # Dropout 防止过拟合训练时随机丢弃 20% 的神经元输出 x Dropout(0.2)(x) # 双向 GRU 捕获前后文依赖 x Bidirectional(GRU(gru_units, return_sequencesTrue))(x) # 注意力机制为每个时间步分配权重 scores Dense(1, activationtanh)(x) scores tf.squeeze(scores, axis-1) weights tf.nn.softmax(scores, axis-1) context tf.reduce_sum(x * tf.expand_dims(weights, axis-1), axis1) # 输出层 outputs Dense(1)(context) model Model(inputs, outputs) return model训练时设置回调函数用早停防止过拟合用学习率衰减让模型在后段更稳定from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model.compile(optimizeradam, lossmse, metrics[mae]) callbacks [ EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience5, min_lr1e-6) ] history model.fit( X_train, y_train, epochs100, batch_size32, validation_split0.1, callbackscallbacks, verbose1 )EarlyStopping的patience10意思是验证集loss连续10轮不下降就停止训练restore_best_weightsTrue会把模型权重回滚到验证集loss最优的那一轮。ReduceLROnPlateau是学习率减半的策略防止loss在一个平台期反复震荡。这两者的组合是工程实践里的标配可以有效避免无谓的算力消耗。5. 避坑手册在这套流程里我踩过的六个坑5.1 损失函数输出NaN现象训练开始没几个epochloss直接变成NaN训练中断。原因最常见的是数据里有NaN值没处理干净或者学习率设得过大梯度爆炸。我遇到的一次是原始数据里某几天传感器故障记录值是空值pandas读进来变成了NaN模型前向传播算出来的损失直接崩了。解决在数据窗口化之前做一步缺失值检查print(np.isnan(data).sum())。数据里有NaN就先用前向填充或插值处理掉。如果数据本身没问题就把学习率从默认的0.001降到0.0001或者在模型里加一个ClipByValue操作来限制梯度的最大值。5.2 测试集和训练集之间发生数据泄漏现象训练集loss很高测试集loss却很低而且测试集预测曲线看起来格外贴合真实值。原因这是最危险的一个坑表面上看“效果很好”实际上是因为归一化或窗口化的时候测试集的信息被提前用进了训练过程。常见操作是在整个数据集上做MinMaxScaler之后再划分训练测试集这样的scaler已经看到了测试集的极值分布。另一个常见做法是在随机打乱数据之后才划分导致时间上靠后的样本混进训练集。解决严格按照先fit训练集再transform测试集的流程并且在任何情况下保持时间顺序划分。判断是否泄漏有一个血腥经验如果测试集指标好到不科学第一步就是检查归一化和数据划分的顺序。5.3 PSO搜索空间过宽导致优化极慢现象PSO跑了十几轮还收敛不到理想范围模型训练次数比预期多了好几倍。原因搜索空间设置得太大比如卷积核数量定在[16, 512]PSO大部分时间在毫无希望的区域里搜索。粒子数设置也过大比如设了20个粒子每个粒子训一次模型可能要5分钟一轮迭代就是100分钟。解决把粒子数控制在5到8个迭代轮数控制在10以内。搜索空间用粗粒度先扫一轮确定大概的最优区域再把范围收紧做细粒度搜索。算力预算有限时先固定模型结构手工调到能跑通再用PSO做精调。5.4 BiGRU训练时显存溢出的问题现象OOM报错程序直接终止。原因输入时间步过长加上batch_size过大。比如时间步是192BiGRU的units是128双向扩展两倍attention又要保留整条序列中间张量占用非常高。解决先把batch_size从32降到16或8不行就把时间窗口缩短到96或72再不行减少GRU单元数。显存这东西是硬约束模型结构上的超参数是要一起权衡的不能单独追求某个指标。5.5 注意力权重分布趋于均匀等于没加Attention现象训练完成后把注意力权重视觉化发现所有时间步的权重都差不多没有明显的“关注点”。原因Attention层的score机制没有训练起来原因是梯度信号太弱。如果Dense(1)输出的score被tanh激活函数压到了饱和区softmax结果自然就均匀了。解决把score层的激活函数换成线性或者去掉激活函数让打分值可以直接从负无穷到正无穷。另一个做法是在损失函数里加一个注意力权重分布的正则项鼓励权重尽量集中。5.6 模型过拟合验证集指标明显差于训练集现象训练集loss一直在降验证集loss降一段之后开始反弹训练集和验证集指标差距越来越大。原因模型容量相对于数据量过大了。CNN的filters和BiGRU的units都设到128数据量只有几千条模型在训练集上死记硬背了样本噪声。解决先加Dropout概率到0.3到0.5之间再调低filters和units把模型容量降下来。经过PSO优化的时候把“验证集损失”作为适应性函数而不是“训练集损失”如果发现PSO推荐出的超参数组合都是过拟合的问题往往出在这个环节。6. 验证与进阶预测完怎么确认结果是靠谱的6.1 多指标评估MAE、RMSE、MAPE各看什么模型跑完不是生产结束评估才是判断能不能用的开始。MAE给了误差的平均水平RMSE放大了大误差的惩罚MAPE对业务量级不敏感适合对比不同数据集的模型表现。计算代码如下from sklearn.metrics import mean_absolute_error, mean_squared_error mae mean_absolute_error(true_inv, pred_inv) rmse np.sqrt(mean_squared_error(true_inv, pred_inv)) mape np.mean(np.abs((true_inv - pred_inv) / true_inv)) * 100 r2 1 - np.sum((true_inv - pred_inv) ** 2) / np.sum((true_inv - np.mean(true_inv)) ** 2) print(fMAE: {mae:.4f}, RMSE: {rmse:.4f}, MAPE: {mape:.2f}%, R2: {r2:.4f})我一般会同时看RMSE和MAPE。如果RMSE远大于MAE说明存在少数几个非常大的预测误差点这通常是数据里的异常时段造成的比如突发性事件导致的数据突变。这时候单独优化模型没用需要检查异常点是不是需要单独处理。6.2 残差分布可视化模型有没有系统性偏差除了数值指标把残差画出来看一下分布形态非常有价值。残差是真实值减预测值。如果残差随机分布在零轴两侧说明模型没有系统性偏差如果残差在某个区间内有明显的正偏或负偏说明模型在该区间存在结构性欠拟合。项目里提供的残差分布图就是干这个用的。6.3 注意力权重的应用价值我觉得这套模型最有意思的进阶用法是把注意力权重当成分析报表的输出。金融场景里你可以把权重对应到具体的时间窗口告诉业务方“模型做这个预测时最看重的是过去第12到24小时的数据”这远比给出一个黑盒预测值有说服力。实现思路是在模型预测阶段把最后一层Attention的权重导出# 构建一个只输出 attention weights 的辅助模型 attention_model Model(inputsmodel.input, outputsscores_tensor)注意这里的scores_tensor是模型定义时的中间层张量保存模型时需要把它作为输出的一部分一起保存。这种用法在气象预测、设备故障诊断这种需要解释依据的行业场景里往往比模型本身更受欢迎。6.4 在线预测与模型更新的落地习惯最后收一个实践技巧趁着训练好的模型参数还在内存里写一个简单的在线预测函数用最新到的数据实时更新预测结果。数据每来一个时间步就丢掉最旧的时间步把最新的拼进输入窗口。这样保持了预测的连贯性且不需要每一轮都重训模型。从那以后我每次部署这类时序模型都会强制走一遍“固定随机种子 - 划分数据 - 训练 - 反归一化评估 - 保存模型权重”的完整流程每一环都用脚本固化下来绝不在notebook里手动跳步。这样做的原因很简单时序预测项目的可复现性太脆弱了任何一步靠手点鼠标操作后面回看结果时都说不清楚当初是怎么跑出来的。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
Vim查找替换:从基础搜索到文本结构化重构 1. 为什么“查找与替换”是 Vim 生存的底层能力,而不是一个可有可无的功能很多人学 Vim,是从i进入插入模式、Esc退出、:wq保存退出开始的。这没错,但如果你只停留在这个层面,那 Vim 对你而言,不过是个带点复古滤镜的编… · 2026/9/25 11:04:02
AI Skills 时代的必备工具:Turbo AI Rules 一键管理你的智能编程规则和技能 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 11:03:49
PostgreSQL报错:boolean列默认值被integer类型拒绝的修复 后端的同学应该都体会过,跑一个 SQL 脚本正要收工,psql 突然甩出一行刺眼的红色:ERROR: column "is_active" is of type boolean but default expression is of type integer。第一次看到这个报错的人往往会愣一下——我只是给布尔… · 2026/9/25 12:23:18
SpringBoot微信小程序外卖点单系统:从数据库设计到毕设避坑全解析 简介:一份完整的基于微信小程序的外卖点单系统毕业论文(docx格式),内容覆盖系统背景、功能需求、设计思路与SpringBoot实现全过程,适合计算机相关专业学生用作毕业设计选题参考或论文撰写模板。资源包内仅含1个docx文档… · 2026/9/25 12:23:18
一行代码不用改!用TaoToken统一通道搞定HSF到MCP Server的平滑迁移 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 12:23:06
【深度评测】DeepSeek V3.2-Exp 接入 TaoToken:DSA 加持下的大模型配置与验证 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 12:22:53
创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:31
MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:37