news 2026/10/11 1:19:15

用机器学习分析双色球:从数据清洗到特征工程的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用机器学习分析双色球:从数据清洗到特征工程的完整实践

简介:面向机器学习入门者与数据预测爱好者的实战资料包,以“采用机器学习分析双色球”为切入点,完整呈现从数据收集、清洗、独热编码、特征工程到模型训练、交叉验证与参数调优的端到端流程。资源共29个文件,以16个Python脚本为核心,涵盖TensorFlow、PyTorch、NumPy等框架的示例代码,并配有网络爬虫脚本、SQL导入导出工具、配置文件、运行日志和README说明,压缩包仅181KB,轻量便携。包内多个版本脚本可对比不同框架的建模思路,事件日志记录训练过程,适合动手复现。已有383人学习/浏览。通过研读代码可掌握多模型对比、冷热号统计与红蓝球分类预测的落地写法,同时理解彩票高随机性下的模型局限,适合在机器学习课程或业余项目中作趣味性实战参考。

1. 机器学习分析双色球:先搞清楚我们到底在预测什么

打开这个 zip 的时候,我猜你已经看过里面的目录结构了:一份历史开奖 CSV、一个训练脚本、一个预测脚本,外加一个写得半通不通的 README。用机器学习分析双色球,听起来像玄学,但把它拆开来看,其实是一个很标准的机器学习入门项目——数据处理、特征工程、模型训练、评估验证,全套流程都能练到。需要先把丑话说在前面:双色球开奖是独立随机事件,模型真正能学到的不是“下一期号码”,而是号码在历史样本里的分布形态、遗漏周期、和值区间这类统计规律。这篇文章我会从 zip 里的原始数据开始,一步步带你把它变成可训练的样本,再训练模型、评估效果,最后把脚本做成每周能自动跑的流程。

2. 数据准备:从 zip 解压到构造可训练的样本

2.1 解压后先确认数据字段,别急着跑训练脚本

我拿到这个 zip 后做的第一件事,不是双击解压然后立刻 python train.py,而是先看数据长什么样。常见的历史开奖数据是这样一份 CSV:

期号开奖日期红1红2红3红4红5红6蓝球
20240012024-01-02512172328317
20240022024-01-04291422263312

打开之前先做三件事:确认编码、确认列名、确认号码范围。我习惯用 pandas 直接读,编码优先试 utf-8-sig,Windows 导出的 CSV 十有八九是 GBK 或带 BOM 的 UTF-8,直接 utf-8 读会乱码。号码范围也必须跑一遍断言:红球必须在 1 到 33 之间,蓝球必须在 1 到 16 之间,超出这个范围的直接作为脏数据处理。

import pandas as pd df = pd.read_csv('data/balls.csv', encoding='utf-8-sig', parse_dates=['开奖日期']) df = df.sort_values('开奖日期').reset_index(drop=True) red_cols = [f'红{i}' for i in range(1, 7)] assert df[red_cols].min().min() >= 1, "红球出现小于1的非法值" assert df[red_cols].max().max() <= 33, "红球出现大于33的非法值" assert df['蓝球'].between(1, 16).all(), "蓝球出现非法值" df = df.drop_duplicates(subset=['期号'], keep='last').reset_index(drop=True) print(df.shape) print(df.dtypes)

这段代码做的事情很直白:按开奖日期排序、校验号码范围、按期号去重。逻辑说明:排序是为了后续滑窗切分时保证时间顺序正确,掉重是因为有些来源的数据会重复收录同一期。参数说明:encoding='utf-8-sig' 是读取带 BOM 的 UTF-8 文件的关键,换成 utf-8 时第一列列名会带上 \ufeff;parse_dates 让日期列直接变成 datetime 类型,后面算时间跨度不用再手动 to_datetime。

2.2 清洗异常行:停售期、缺失值和杂讯

双色球历史上不是每期都正常开奖,春节休市、系统故障、数据源拼接错位,都会产生脏行。清洗时我按这个顺序来:先砍掉红球列或蓝球列有缺失的行,再检查红球是否有重复号码——一注里 6 个红球不能重复,出现重复行八成是数据源拼接的时候把两行并成一行了,直接删。最后看期号是否连续递增,如果中间跳号严重,说明数据源缺了整段,这时候要回去重新找数据,别靠插值补。

df = df.dropna(subset=red_cols + ['蓝球']) def has_duplicate_red(row): return len(set(row[red_cols].values)) != 6 df = df[~df.apply(has_duplicate_red, axis=1)].reset_index(drop=True) print(f"清洗后剩余 {len(df)} 期数据")

清洗完之后还要顺手算几个基础统计列,后面做特征的时候要反复用:红球和值、红球跨度、奇偶比、区间比。这些是每期开奖号码的“当期画像”,单独看没意义,但放在窗口里取均值、取最近值,就是能喂给模型的特征。

df['红球和值'] = df[red_cols].sum(axis=1) df['红球跨度'] = df[red_cols].max(axis=1) - df[red_cols].min(axis=1) df['红球奇偶比'] = (df[red_cols] % 2).sum(axis=1) df['红球区间比'] = (df[red_cols] <= 11).sum(axis=1).astype(str) + '-' + \ ((df[red_cols] > 11) & (df[red_cols] <= 22)).sum(axis=1).astype(str) + '-' + \ (df[red_cols] > 22).sum(axis=1).astype(str)

逻辑说明:红球和值即当期 6 个红球之和,常见分布区间在 63 到 183 之间;跨度是最大号减最小号;奇偶比是奇数个数;区间比把 1-33 分成三段,统计每段各出几个。这四个量不是给模型直接预测的,而是用来刻画出号形态。

2.3 滑窗法:把开奖序列切成监督学习样本

机器学习模型不吃序列,吃的是“特征+标签”的表格。滑窗是处理这类时间序列最朴素也最稳妥的做法:对第 t 期,取它之前 30 期的数据算出特征,标签取第 t 期实际开奖结果。这样每个样本就是“过去 30 期长什么样 → 下一期出了什么号”。

这里有个关键选择:直接预测 6 个红球的具体数字是回归问题,模型很难收敛;更常见的做法是把问题转成“每个号码在下一期是否出现”的二分类。红球有 33 个候选号码,每个号码对每一期都生成一条样本,特征里带上号码本身的统计量,标签就是 0 或 1。这样样本量瞬间放大 33 倍,2000 期历史数据就能得到 6 万条训练样本,足够喂饱一个树模型。

import numpy as np def make_red_ball_samples(df, window=30): rows = [] red_arr = df[red_cols].values # shape: (n_periods, 6) sums = df['红球和值'].values spans = df['红球跨度'].values odd_counts = df['红球奇偶比'].values for t in range(window, len(df)): win_red = red_arr[t-window:t] # 窗口内所有红球 for n in range(1, 34): occur = np.argwhere(win_red == n) last_gap = window - occur[-1][0] if len(occur) > 0 else window freq_30 = (win_red == n).sum() / (window * 6) rows.append({ '期号': df['期号'].iloc[t], '号码': n, '遗漏值': last_gap, '近30期频率': freq_30, '窗口和值均值': sums[t-window:t].mean(), '窗口跨度均值': spans[t-window:t].mean(), '窗口奇偶比均值': odd_counts[t-window:t].mean(), 'label': 1 if n in red_arr[t] else 0 }) return pd.DataFrame(rows) samples = make_red_ball_samples(df, window=30) print(samples['label'].mean())

逻辑说明:外层循环遍历每一期,对每个候选号码 1 到 33 分别计算它的特征。遗漏值表示这个号码距离窗口末端(也就是第 t-1 期)已经多少期没出现过,这是彩票分析里最有价值的特征之一;近 30 期频率是它在这 30 期里出现的密度,用来区分热号和冷号。窗口和值均值、跨度均值、奇偶比均值刻画的是近期整体出号形态。参数说明:window=30 表示回看 30 期,这个值可以根据数据量调;历史数据超过 1500 期时,window 取 30 或 50 效果差别不大,但 window 太大会让遗漏值失去区分度。

3. 特征工程:给模型喂这 6 类号球特征

3.1 单期统计特征:和值、跨度、奇偶比、区间比

双色球分析里最常用的几类手工特征,是开奖号码的统计形态。和值高说明这期偏向出大号,跨度大说明号码分布很散,奇偶比失衡说明单双号不均。这些特征单独看是开奖结果的一部分,但放进窗口里取均值、取趋势,就成了描述近期形态的时序信号。

我一般在窗口上算三组统计量:最近一期的原始值、窗口内均值、窗口内标准差。原始值刻画当下状态,均值刻画整体水平,标准差刻画波动性。比如红球和值,近 30 期均值如果一直在 110 以上,说明近期偏大号;标准差突然拉大,说明号球分布不稳定,可能后续会回归。这类特征不需要做复杂编码,数值型直接进模型就行。

samples['窗口和值标准差'] = samples.groupby('期号')['窗口和值均值'].transform(lambda x: x.rolling(5).std())

这里我补充了一个 5 期滚动标准差,理由是:彩票走势本身没有强规律,但滚动的波动率能帮树模型捕捉到“形态正在变化”的信号。需要注意时效性——特征只能用到第 t-1 期为止的数据,绝不能把第 t 期的和值、跨度算进特征里,这是典型的未来函数,后面避坑章节还会专门展开。

3.2 跨期衍生特征:遗漏值、热冷强度和重复率

把号码当独立个体看,最重要的特征就是遗漏值和近 N 期频率。遗漏值指“这个号码已经连续多少期没出现”,理论上是几何分布,均值约 5 期;但实际序列经常出现 15 期以上的长遗漏,这时候号码的遗漏值就成了一个强信号。热冷强度可以把近 30 期频率做标准化:把频率除以 6/33 的理论概率,大于 1 是热号,小于 1 是冷号。

theoretical_prob = 6 / 33 samples['热冷强度'] = samples['近30期频率'] / theoretical_prob samples['遗漏值归一化'] = samples['遗漏值'] / 30.0

参数说明:热冷强度的分母是双色球红球单期理论出现概率,任何号码长期看都收敛到 6/33,偏离这个值只是短期统计波动。遗漏值归一化到 0 到 1 之间,对树模型来说不影响分裂,但如果之后要接逻辑回归或神经网络,这一步就很有必要。

重复率是另一个值得加的特征:看当前窗口里有没有号码重复出现,以及重复的密集度。具体做法是统计窗口内 30×6 个红球位置里,出现次数排名前 5 的号码各自出现了几次。这个特征的思想是:号码的开出存在短期的聚集效应,虽然不规律,但树模型可以自己决定要不要用这个信号。

3.3 特征归一化与保存特征清单

到这里特征维度差不多有 8 到 10 列,足够训练了。归一化对 LightGBM 这类树模型不是必须的,但如果后续要对比逻辑回归,就得把数值特征都做标准化。我更推荐的做法是:把特征列名存成一个列表,连同 model 一起保存。这个习惯能避免后面载入模型做预测时列顺序错乱——这个问题一旦出现,预测结果基本都是乱序概率,排查起来非常痛苦。

feature_cols = ['号码', '遗漏值', '近30期频率', '热冷强度', '窗口和值均值', '窗口跨度均值', '窗口奇偶比均值', '窗口和值标准差'] X = samples[feature_cols] y = samples['label']

逻辑说明:号码本身作为特征很重要。模型需要知道它现在在评估的是 1 号到 33 号里的哪一个号码,因为不同号码在不同时期的遗漏分布、频率特性都不完全一样。如果不把号码编码进去,等于强迫模型对所有候选号码一视同仁,效果会大打折扣。参数说明:特征列表里没有把期号放进去,因为期号是索引,不是信号。

4. 模型选择与训练:用 LightGBM 给红球出现概率排序

4.1 为什么不用 LSTM 直接预测号码

很多人一看到时序数据就想上 LSTM 或 Transformer,这在双色球场景里属于杀鸡用牛刀,而且大概率翻车。原因很现实:LSTM 需要的核心前提是序列里存在可学习的长程依赖,而双色球开奖是独立随机事件,序列的自相关性几乎为零。硬把几千期号码灌进 LSTM,模型只能把历史噪声背下来,验证集上表现和随机没差别。LightGBM 这类梯度提升树更适合这个场景:它对表格特征的处理非常高效,能自动组合特征,训练速度快,还能输出特征重要性,方便我们事后检查模型到底学了什么。

import lightgbm as lgb from sklearn.metrics import roc_auc_score split_point = int(len(samples) * 0.8) X_train, X_val = X.iloc[:split_point], X.iloc[split_point:] y_train, y_val = y.iloc[:split_point], y.iloc[split_point:] model = lgb.LGBMClassifier( objective='binary', learning_rate=0.05, num_leaves=31, max_depth=-1, min_data_in_leaf=20, feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=5, n_estimators=300, random_state=42 ) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], eval_metric='auc', callbacks=[lgb.early_stopping(50), lgb.log_evaluation(50)] )

逻辑说明:这里用的是单个二分类 LightGBM 模型,对所有 (期号, 号码) 样本统一训练。特征里带上了号码本身,所以模型能学到不同号码在不同状态下的出现倾向。eval_metric 用 AUC 而不是准确率,是因为正负样本比例约 6/33,准确率在类别不平衡下会虚高。参数说明:learning_rate=0.05 让模型学得慢但稳;num_leaves=31 控制了树的复杂度,数据信号弱时叶子数太多容易过拟合;feature_fraction 和 bagging_fraction 都设为 0.8,相当于给列和行都做采样,进一步压制方差。

4.2 时间序列切分:不能随机 shuffle

训练集和验证集的划分在这一步必须格外小心。很多人在 sklearn 里习惯了 train_test_split,上来就随机切,这在双色球场景里是致命的。原因很简单:我们的特征是滑窗构造的,相邻两期样本的窗口高度重叠,随机切分会让训练集里混入验证集的“邻居样本”,信息从窗口缝隙里漏过去,验证指标必然虚高。正确做法是按时间顺序切分,拿前 80% 的历史做训练,后 20% 做验证。

上面的代码里 split_point = int(len(samples) * 0.8) 就是按时间比例硬切,因为样本是按期号顺序排列的。如果你需要更严谨的验证,可以用 sklearn 的 TimeSeriesSplit 做 K 折,但每一折的训练集都必须严格在验证集之前。另一种做法是直接按日期切,比如 2022 年之前的数据训练,2023 年至今的数据验证,这样更接近真实的使用场景。

4.3 预测下一期号码:概率排序取 Top 6

模型训练完成后,预测时的逻辑和训练时要完全一致。对最新一期之前的 window 期数据构造同样的特征,生成 33 条候选样本,每条样本对应一个红球号码,用模型预测每个号码的出现概率,最后按概率从大到小排序,取前 6 个作为模型给出的参考红球。

def build_latest_samples(df, window=30): rows = [] t = len(df) red_arr = df[red_cols].values sums = df['红球和值'].values spans = df['红球跨度'].values odd_counts = df['红球奇偶比'].values win_red = red_arr[t-window:t] for n in range(1, 34): occur = np.argwhere(win_red == n) last_gap = window - occur[-1][0] if len(occur) > 0 else window freq_30 = (win_red == n).sum() / (window * 6) rows.append({ '号码': n, '遗漏值': last_gap, '近30期频率': freq_30, '热冷强度': freq_30 / (6 / 33), '窗口和值均值': sums[t-window:t].mean(), '窗口跨度均值': spans[t-window:t].mean(), '窗口奇偶比均值': odd_counts[t-window:t].mean(), '窗口和值标准差': sums[t-window:t].std() }) return pd.DataFrame(rows) latest = build_latest_samples(df, window=30) prob = model.predict_proba(latest[feature_cols])[:, 1] ranking = np.argsort(-prob) top6_red = sorted([int(i) + 1 for i in ranking[:6]]) print("模型参考红球:", top6_red)

逻辑说明:build_latest_samples 的窗口取的是从倒数第 30 期到最后一期的数据,正好不包含待预测的那一期。对每个候选号码计算遗漏值、频率等特征后,预测概率并排序。参数说明:预测时的 window 必须和训练时一致,否则特征分布对不上;如果训练时得过热冷强度,预测时也必须算同样的列。

5. 避坑与评估:5 个让双色球模型结果失真的排查点

5.1 特征用了当期数据,训练准确率 95%,开奖命中率接近随机

这是双色球机器学习项目里最容易犯、也最难排查的错误。现象是训练集 AUC 高得离谱,验证集也还行,但拿去预测下一期,命中率跟瞎猜没什么区别。原因几乎都是特征构造时引入了当期信息,典型的例子是用前后文滑窗算遗漏值时,把 t 期本身的号码也写进了窗口,或者算窗口和值时不小心把 t 期包含进去。解决方法是自查特征构造函数,确保每行样本只用到 t-window 到 t-1 期的数据,然后写一个断言:随机挑几期样本,手动改掉窗口里最后一期的号码,看特征值是否变化;如果变了,说明特征里混入了当期数据。

5.2 随机切分训练集,模型学会了“剧透”

现象是第一次训练时验证 AUC 0.72,兴冲冲上线,预测结果却完全不对。原因前面讲过,滑窗样本之间高度重叠,随机切分等于把未来的窗口片段嵌进了训练集。解决方法是强制按时间切分,并且记录切分的日期边界。我习惯把验证指标差的模型先留下,等几期真实开奖后,拿真实结果验证模型的排序效果,这比任何验证集指标都可信。

5.3 导入资源包失败 caused by: invalid zip archive: could not find eocd

这个报错我遇到过不止一次,而且经常是在别人发给我的数据包里。现象是解压工具报错,或者 unzip 命令直接提示 invalid zip archive: could not find eocd。原因是 zip 的 EOCD(中央目录结束标记)位于文件末尾,下载过程中断、剪贴板传送截断、杀毒软件隔离都会让文件末尾缺失数据,导致解压工具找不到 EOCD 记录。解决方法是先校验文件完整性:用ls -l看文件大小是否合理;再用 hexdump 检查文件末尾是否有 EOCD 标记PK\x05\x06。没有的话别硬解,找发件人重新发一份,或者用 7-Zip 打开测试压缩包是否损坏。

5.4 Windows 下中文列名乱码、中文路径保存模型失败

现象是 pandas 读 CSV 后列名显示乱码,或者 LightGBM 的模型保存到带中文的目录时报错。原因有两个层面:CSV 编码不是 UTF-8,是 GBK 或带 BOM 的 UTF-8;Windows 控制台默认代码页和 Python 字符串编码不一致,导致模型文件写入路径时编码爆炸。解决方法是读取时统一用 encoding='utf-8-sig',同时把结果文件和模型都保存到纯英文路径下。一个小习惯:所有数据文件先转成 UTF-8 规范再入库,别把原始编码带到后续流程里。

5.5 特征列顺序不一致,预测概率全错位

现象是模型加载旧权重后,predict_proba 输出的概率排序看起来有问题,比如冷号全排前面。原因是训练时的 feature_cols 和预测时的特征 DataFrame 列顺序不一致,LightGBM 按位置取特征,列名对不上时不会报错,只会默默用错数据。解决方法是训练完立刻把模型自带的 feature_name 保存下来:

feature_names = list(model.booster_.feature_name()) with open('feature_names.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(feature_names))

预测前再读出来,对预测特征做 reorder。这个动作几十秒,能省掉后续大量排查时间。

5.6 用随机基线对照,判断模型到底有没有用

双色球模型必须回答一个问题:模型输出是不是比随机抽号强。随机基线很好算:从 33 个红球里随机抽 6 个,期望命中数 = 6 × 6/33 ≈ 1.09,也就是平均每期命中 1 个红球。如果你的模型预测的 Top 6 红球平均命中数长期在 1.5 以下,说明它几乎没有学到任何有效信号。我每季度会做一次蒙特卡洛模拟,随机抽 1 万组作为基线,同时统计模型近 30 期的平均命中数,两边一比就能客观判断模型是否还在工作。这个方法也能帮你在早期就识别出特征失效,而不是等到连续几期不中才怀疑模型。

6. 落地技巧:把分析脚本做成每周可复用流程

6.1 用定时任务替代手工跑脚本

模型训练好、特征管线稳定之后,剩下的就是把流程自动化。我习惯把训练和预测拆成两个脚本:train_model.py 每周日跑一次,读取最新开奖数据,重新训练并保存模型;predict_next.py 在每期开奖前跑一次,输出参考号码。定时任务用系统自带的 cron 或 Windows 任务计划程序就能搞定,不需要引入额外框架。

# 每周日 20:00 重新训练模型 0 20 * * 0 cd /path/to/lottery && python train_model.py >> logs/train.log 2>&1 # 每周二、四、日 20:30 预测下一期 30 20 * * 2,4,7 cd /path/to/lottery && python predict_next.py >> logs/predict.log 2>&1

逻辑说明:训练放在每周日,是因为数据更新以周为粒度足够;预测放在开奖日当天,避免隔太久数据过期。参数说明:日志重定向一定要写,脚本一旦跑挂,日志是唯一的排查入口。

6.2 长期记录预测结果,只看命中率曲线

做预测不记录结果,等于白做。我维护一个 evaluation.csv,每期预测完把 Top 6 号码和实际开奖号码追加进去,隔几个月就统计一次累计命中率,并和随机基线对比。如果连续 30 期的平均命中数跌破随机基线,不要急着调参,先回去查特征是不是失效了,或者数据源是不是有新的脏数据。这套习惯帮我避开了很多“模型训练时好用时不好用”的玄学问题。

我自己的习惯是每个季度只重训一次模型,中间只做预测和记录,不频繁调参。彩票数据本身就充满噪声,调参调得太频繁只会让模型记住上一段噪声,而不是学到稳定的统计形态。更重要的是,我一直把它当成机器学习练习,而不是收益手段——模型给出的号码只做统计参考,不构成投注建议。理性看待随机事件,才能把精力放在真正有积累的事上。希望帮到你。

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

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

AMOS腹部多器官分割数据集:三轴向标注与临床级加载实战

简介&#xff1a;本资源是面向医学图像分析与深度学习研究者的高质量多模态分割数据集&#xff0c;聚焦CT与MR影像中16类腹部器官&#xff08;如脾脏、胃、左肾上腺等&#xff09;的精准2D切片分割任务&#xff0c;适用于算法验证、模型预训练及可视化教学。数据集完整覆盖轴位…

作者头像 李华
网站建设 2026/10/11 1:17:53

基于SSM的汽车维修管理系统的设计与实现

一、项目简介针对传统汽车维修厂管理效率低、维修流程不透明、配件库存混乱等问题&#xff0c;本项目基于SSM框架开发一套汽车维修管理系统&#xff0c;结合MySQL数据库进行数据存储&#xff0c;实现车辆登记、维修派工、配件管理、结算管理等全流程功能&#xff0c;帮助汽车维…

作者头像 李华
网站建设 2026/10/11 1:17:48

orcle下载与安装教程

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

作者头像 李华
网站建设 2026/10/11 1:16:05

江森自控楼宇自动化控制说明书:从PPT到可执行调试方案

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

作者头像 李华
网站建设 2026/10/11 1:15:45

Python __new__ vs __init__:对象创建机制与实战场景深度解析

如果你是靠 Python 吃饭的人&#xff0c;迟早会撞上__new__。我用 Python 这些年&#xff0c;真正把__new__用明白是在读某些框架源码和写元类代码的时候——之前自以为很懂__init__&#xff0c;结果发现它根本不是创建对象的地方&#xff0c;只是“装修”。__new__才是那个真正…

作者头像 李华
网站建设 2026/10/11 1:15:42

MOM系统运营实践:从MES到制造运营闭环的落地指南

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

作者头像 李华