从零手写多类SVM分类器:损失函数推导与向量化实现
1. 从零手写SVM分类器为什么我不建议你直接调库很多人第一次接触计算机视觉课程作业看到SVM这三个字母第一反应就是打开常用机器学习库三行代码拟合完事。我当初也动过这个念头但真正把作业要求读完才发现这一题的核心根本不是让你训练一个分类器而是让你亲手把多类SVM的损失函数和解析梯度推导并实现出来。换句话说它考的是你对线性分类器底层运作机制的理解而不是调包能力。这个作业通常出现在视觉识别课程的早期阶段配套的数据集是经典的图像分类小数据集包含10个类别、几万张32×32的小图。任务目标很明确实现一个基于合页损失Hinge Loss的多类SVM并且要同时完成两件容易出错的事——向量化计算损失和向量化计算梯度。这两件事如果只求跑通用循环硬写也能过但作业的评分脚本会卡时间循环版本在几万张图上跑一次梯度下降慢到让人怀疑人生。所以这篇内容适合三类人看正在做这个作业但卡在梯度推导上的同学、想搞明白SVM损失函数到底怎么算的初学者、以及写过但想回头把向量化技巧吃透的从业者。我会把损失函数的数学形式、梯度的逐元素推导、向量化的实现思路、数值梯度校验的方法以及我踩过的几个坑全部摊开讲清楚。你跟着走一遍应该能独立写出一个既正确又跑得快的版本。先说结论性的判断这一题真正的难点不在SVM本身而在矩阵维度对齐和梯度中指示函数的处理。我见过太多人损失算对了、梯度差一点点训练时loss下降但准确率上不去最后卡在数值校验那一步反复调。下面我按设计思路、核心推导、代码实现、问题排查四个层次展开。2. 整体设计思路与方案选型2.1 为什么选多类SVM而不是一对多多类SVM和一对多SVM是两种常见思路。一对多是训练10个二分类器每个负责区分“是不是某一类”预测时取分数最高的那个。多类SVM则是用一套权重矩阵直接输出10个类别的分数损失函数要求正确类别的分数比错误类别高出至少一个边界值。我选多类SVM的原因很实际它的权重矩阵是单一的形状是“特征维度×类别数”梯度推导干净向量化实现也统一。一对多需要维护10套权重每套还要处理正负样本不均衡代码量和出错概率都翻倍。作业的评分脚本通常也是按多类SVM的接口设计的你硬套一对多反而对不上。从数学上看多类SVM的评分函数是线性的给定一张图展平后的特征向量x第j类的分数就是权重矩阵第j列与x的点积再加上偏置项。这个形式简单到可以用一行矩阵乘法搞定这也是它适合教学的原因——所有复杂度都集中在损失和梯度上。2.2 合页损失的核心思想合页损失的名字来自它的形状像一个合页。它的逻辑是如果正确类别的分数已经比某个错误类别高出至少一个边界值Δ那这个错误类别就不再产生损失否则就产生“差距还差多少”的损失。用生活化的类比假设你在考试正确类别是“及格线”错误类别是“其他考生的分数”。规则是只要你的分数比某个其他考生高出Δ分就不用管他如果没高出就要按差距扣你的分。最终损失是所有其他考生扣分的总和。这个设计的好处是只惩罚那些真正造成混淆的类别已经分得很开的类别不贡献梯度训练更聚焦。Δ的取值通常设为1.0这是一个经验值。它不影响最优解的方向只影响权重的缩放。我试过设成0.5和2.0最终准确率差别很小但Δ太小会让损失对噪声更敏感太大则初期梯度偏大、收敛慢。作业里一般直接给1.0不用纠结。2.3 正则化项为什么不能省如果只有合页损失权重矩阵可以无限缩放而不改变分类结果这会导致解不唯一也容易过拟合。所以必须加L2正则化惩罚权重过大。正则化强度由超参数λ控制它和损失项之间是此消彼长的关系。这里有个容易忽略的点正则化只作用于权重W不作用于偏置b。因为偏置只是平移决策边界不控制边界的方向和陡峭程度惩罚它没有正则化的意义。我在第一次实现时把b也加进去了结果数值梯度校验一直对不上排查了半天才发现是这个原因。λ的选择需要调。太小则正则化形同虚设训练集准确率很高但验证集上不去太大则权重被压得太狠模型欠拟合。作业里通常会给一个推荐范围我一般从1e-3开始试配合学习率一起调。3. 核心细节解析与实操要点3.1 损失函数的逐样本计算逻辑先看单个样本的损失怎么算。给定样本特征x、正确类别y、权重W先算出所有类别的分数向量s W·x。然后遍历所有错误类别jj不等于y计算max(0, s_j - s_y Δ)把这些值累加就得到这个样本的合页损失。注意这里有个细节正确类别的分数s_y在每一项里都要减。也就是说每个错误类别都在和同一个正确分数比较。这个设计让正确类别的分数被“推高”错误类别的分数被“压低”两者之间的差距被拉开。整个数据集的损失是所有样本损失的平均值再加上正则化项。取平均而不是求和是为了让损失的量级不随样本数变化方便设置学习率。正则化项是λ乘以W所有元素平方和的一半那个1/2是为了求导时抵消平方的2让梯度形式更干净。3.2 梯度推导的关键指示函数梯度推导是这一题最容易翻车的地方。对权重W求导需要分两种情况对正确类别的那一列求导和对错误类别的那一列求导。对于某个样本如果错误类别j满足s_j - s_y Δ 0说明它产生了损失。此时对W的第j列求导梯度贡献是x因为s_j W_j·x对W_j求导得x对W的第y列求导梯度贡献是-x因为s_y前面有负号。如果某个错误类别没产生损失它对该样本的梯度贡献就是0。这里的关键是指示函数只有当margin大于0时梯度才非零。实现时可以用一个布尔掩码来标记哪些类别产生了损失然后只对这些类别累加梯度。我见过有人对所有类别都累加结果梯度偏大训练震荡。还有一个容易错的点如果一个样本的所有错误类别都没产生损失那它对W的梯度贡献是0但正则化项的梯度仍然存在。正则化项的梯度是λ·W这个是对整个矩阵的和样本无关。3.3 向量化把循环干掉循环版本写起来直观但跑得慢。向量化的核心思路是把“对每个样本、每个错误类别”的双重循环转成矩阵运算。具体做法先算分数矩阵S X·W形状是“样本数×类别数”。然后取出每个样本正确类别的分数构造一个“样本数×1”的向量广播减到S的每一列上得到“样本数×类别数”的差值矩阵。再加上Δ把正确类别位置的值设为0因为正确类别不参与损失然后取max(0, ·)最后对所有元素求和再除以样本数。梯度向量化稍微绕一点。需要构造一个掩码矩阵标记哪些位置产生了损失。然后用这个掩码对分数矩阵做加权再和特征矩阵做矩阵乘法。正确类别那一列的梯度要单独处理因为它的符号是负的。我建议先把逐样本的梯度写清楚再对照着做向量化不要一上来就硬凑矩阵维度。3.4 数值梯度校验别跳过这一步解析梯度写完一定要用数值梯度校验。方法是对权重矩阵的每个元素分别加上和减去一个极小的h算两次损失用(f(xh) - f(x-h)) / (2h)近似梯度和解析梯度比较相对误差。相对误差的计算公式是|解析梯度 - 数值梯度| / max(|解析梯度| |数值梯度|, 1e-8)。如果相对误差在1e-6到1e-4之间说明解析梯度基本正确如果大于1e-2肯定有bug。这一步不能省。我见过太多人解析梯度写错了但训练还能跑因为SVM对梯度的小误差有一定容忍度但准确率会卡在一个不上不下的水平。数值校验能帮你定位到具体是哪个元素的梯度不对比盲调高效得多。提示数值校验时h不要取太小1e-5左右比较合适。太小会受浮点精度影响太大则近似不准。另外校验前先把正则化强度设小一点比如1e-5避免正则化项掩盖损失项的梯度错误。4. 实操过程与核心环节实现4.1 数据预处理减均值与展平拿到图像数据后第一步是展平。每张32×32×3的图展平成3072维的向量所有样本堆成“样本数×3072”的矩阵X。然后做减均值算每个特征维度在所有样本上的均值从X里减掉。减均值的目的是让数据以0为中心这样梯度下降时各个方向的更新更均衡收敛更快。注意均值只能从训练集算然后应用到验证集和测试集不能把验证集的信息泄露进训练。这个细节在作业里经常被忽略但它是机器学习的基本纪律。有些版本还会做归一化把每个维度除以标准差。图像数据一般减均值就够了因为像素值本身就在0到255之间量级统一。如果做了归一化记得保存均值和标准差预测时要复用。4.2 权重初始化与偏置处理权重W用小随机数初始化比如乘以0.001的正态分布。不要全零初始化否则所有类别的分数相同梯度也相同对称性破不开。也不要初始化太大否则初期分数差异过大合页损失里大部分类别都产生损失梯度爆炸。偏置b初始化为0就行。有些实现会把偏置合并进权重做法是在特征向量末尾加一个1权重矩阵多一行。这样代码更统一但梯度校验时要注意最后一行。我倾向于分开处理逻辑更清晰。学习率的选择很关键。太大则损失震荡甚至发散太小则收敛慢。我一般先用1e-3试观察前100次迭代的损失曲线。如果损失下降很平滑说明学习率合适如果上下跳动就除以3再试如果下降太慢就乘以3。配合学习率衰减效果更好比如每轮乘以0.95。4.3 训练循环的完整实现训练循环的结构是每次迭代取一个batch前向算损失反向算梯度用梯度更新权重。batch大小通常取200到500太小则梯度噪声大太大则内存吃紧且收敛慢。更新公式是W W - learning_rate * dWb同理。注意这里用的是随机梯度下降不是批量梯度下降。每个batch算出的梯度只是整个数据集梯度的估计但计算量小得多迭代次数多整体收敛更快。我习惯在训练过程中记录每轮的损失和训练集准确率。如果损失不降反升八成是学习率太大或者梯度符号错了。如果损失降但准确率不涨可能是正则化太强或者数据有问题。这些现象和对应的排查方向下一节详细说。4.4 超参数调优的实操方法超参数主要有两个学习率和正则化强度λ。调参的方法是网格搜索选几组候选值分别训练看验证集准确率。候选值的选取有技巧。学习率按3倍或10倍递增比如1e-4、3e-4、1e-3、3e-3、1e-2。λ也类似从1e-5到1e-1。不要一上来就搜很大的范围先用粗网格定位大致区间再用细网格精调。我一般先固定λ1e-3调学习率找到让损失下降最快的那组然后固定学习率调λ找到验证集准确率最高的那组。最后用这两组值再跑一次完整训练看测试集表现。这个过程可能要跑十几次训练每次几分钟但比瞎调高效得多。注意验证集和测试集要严格分开。调参只看验证集测试集只在最后用一次。如果反复用测试集调参测试集就变成了验证集最终报告的性能会虚高。5. 常见问题与排查技巧实录5.1 损失不下降的几种可能损失不下降是最常见的问题原因可能有四类。第一类是学习率太小梯度更新幅度不够损失几乎不动。解决办法是加大学习率观察损失是否开始下降。第二类是梯度符号错了更新方向反了损失会震荡或上升。这时候要用数值校验确认梯度方向。第三类是数据没减均值特征量级差异大梯度下降走得很慢。第四类是正则化太强损失被正则化项主导模型学不动。排查顺序建议是先看损失曲线形状再查梯度校验最后调学习率。我遇到过一次损失完全不降查了半天发现是权重初始化成了全零所有类别分数相同梯度也相同对称性破不开。改成小随机数就好了。5.2 数值梯度校验不通过的定位方法校验不通过时不要盯着整个矩阵看要定位到具体元素。做法是打印出解析梯度和数值梯度差异最大的那几个位置看它们的索引对应哪个样本、哪个类别。常见原因有三个。一是正则化项没算对比如忘了乘λ或者忘了除以样本数。二是正确类别的梯度符号错了应该是负的写成了正的。三是指示函数用错了把没产生损失的类别也算进了梯度。我踩过的坑是把偏置b也加了正则化导致b的梯度校验一直差一点去掉就好了。还有一个隐蔽的坑数值校验时如果h取1e-5而损失值本身很大浮点误差会放大。这时候可以把损失除以样本数之后再校验或者用相对误差而不是绝对误差来判断。5.3 训练准确率上不去的排查思路如果损失在降但准确率不涨先看训练集准确率。如果训练集准确率也很低说明模型欠拟合可能是λ太大或者学习率太小。如果训练集准确率高但验证集低说明过拟合要加大λ或者加更多正则化。还有一种情况是准确率卡在某个值不动比如10%左右这通常是随机猜测的水平说明模型根本没学到东西。这时候要检查数据标签有没有对齐权重更新有没有生效学习率是不是太小。我有一次把标签和特征错位了训练了半天准确率一直是10%排查了好久才发现是数据加载的顺序问题。5.4 常见问题速查表现象可能原因排查方法解决方向损失完全不降学习率太小、梯度符号错、权重全零初始化看损失曲线、数值校验、检查初始化调大学习率、修正梯度、改随机初始化损失震荡或上升学习率太大、梯度符号错观察损失波动幅度减小学习率、数值校验数值校验不通过正则化算错、指示函数错、偏置被正则化定位差异最大的元素逐项检查公式训练准确率低欠拟合、λ太大、学习率小看训练集准确率减小λ、调大学习率验证准确率低过拟合、λ太小对比训练和验证准确率加大λ、增加数据准确率卡在随机水平标签错位、更新未生效检查数据对齐、打印权重变化修正数据加载、确认更新公式5.5 几个我踩过的坑和独家技巧第一个坑是忘记除以样本数。损失和梯度都要对样本数取平均漏掉的话量级差几百倍学习率完全没法设。我建议在代码里显式写一个N X.shape[0]每次算完都除以N形成肌肉记忆。第二个坑是正确类别的分数提取方式。用S[range(N), y]这种花式索引提取比循环快得多也不容易错。但要注意y必须是整数数组不能是浮点。第三个技巧是先小数据验证。不要一上来就在全量数据上跑先取100个样本把损失和梯度校验跑通再上全量。小数据上调试快出错也容易定位。第四个技巧是保存训练曲线。把每次迭代的损失和准确率存下来画成图。损失曲线的形状能告诉你很多信息平滑下降说明健康锯齿状说明学习率大平台期说明需要衰减学习率。第五个技巧是梯度检查用双精度。如果框架默认单精度数值校验的误差会比较大。校验时临时转成双精度校验完再转回来。6. 从作业到实战这套方法还能怎么用把这一题吃透之后你会发现它的价值远超一次作业。多类SVM的损失和梯度推导是理解所有线性分类器的基石。逻辑回归的交叉熵损失、softmax的梯度推导思路和这里一脉相承都是“定义分数、定义损失、对权重求导”三步走。向量化的技巧更是通用。把循环转成矩阵运算是深度学习框架底层做的事。你手写一遍再看框架的自动求导会有种“原来如此”的通透感。我后来做图像检索、特征分类的项目遇到需要自定义损失函数的时候这套推导和校验的方法直接就能迁移过去。数值梯度校验的习惯也值得保留。不管用什么框架自定义层或损失函数写完先用数值校验确认一遍能省掉大量调试时间。这个习惯我保持了几年帮我抓出过不少隐蔽的bug。如果你想把这一题再往前推一步可以试试这几个扩展方向把合页损失换成softmax交叉熵对比两者的收敛速度和最终准确率加入学习率衰减和动量观察训练曲线的变化用不同的λ做网格搜索画出验证集准确率随λ变化的曲线。这些扩展不需要额外数据改几行代码就能跑但对理解超参数的影响非常有帮助。最后分享一个我个人的体会这一题最值得花时间的不是把代码写对而是把梯度推导的每一步都想清楚。代码写错了可以调但推导逻辑不清楚换个损失函数就又不会了。我当初把推导在纸上手写了两遍第二遍不看任何参考写完再对照代码那之后类似的分类器实现就再没卡过。

相关新闻

公开数据分析实战:赞助人画像与资金流向模式识别

公开数据分析实战:赞助人画像与资金流向模式识别

1. 从一份公开数据表说起:这个分析到底在解决什么问题第一次拿到这份数据的时候,我盯着那张表看了很久。它记录的是某次大型选举周期中,个人和组织向各类竞选委员会提供的资金支持记录,字段包括支持者姓名、所在地区、职业、雇主、…

2026/10/13 21:16:07 阅读更多 →
Maya 2019零基础入门笔记:从界面认识到第一个场景渲染

Maya 2019零基础入门笔记:从界面认识到第一个场景渲染

咸鱼Maya笔记,这个系列的名字先解释一下。我既不是美术科班出身,也没有被某个大神手把手带过,纯粹是靠着下班后的零碎时间,把文档、教程和论坛帖一点一点啃下来的普通人。之所以自称咸鱼,是因为我接受自己学得慢这个事…

2026/10/13 21:16:07 阅读更多 →
内容矩阵工作台搭建实战:从分散表格到自动化内容管理中枢

内容矩阵工作台搭建实战:从分散表格到自动化内容管理中枢

最近团队一直在做内容生产的规范化,多平台账号、多栏目、每周几十篇稿件加视频脚本,全靠表格加聊天记录来回对。测试版跑了两周,最大的感受是——不再是"人追着内容跑",而是"内容在工作台里自己流转"。这篇文…

2026/10/13 21:16:07 阅读更多 →

最新新闻

Django与Flask协同构建Python在线题库考试系统全解析

Django与Flask协同构建Python在线题库考试系统全解析

最近在搭一套基于Python的《面向对象程序设计》在线题库考试系统,技术栈选了Django和Flask搭配的方案。这个项目的核心目标是让课程考核从传统的纸质试卷转成可自动组卷、在线答题、即时评分的线上流程,题库按知识点和难度管理,考试过程支持自…

2026/10/13 22:09:51 阅读更多 →
GPU租用平台实测:学生深度学习训练如何选型、省钱与避坑

GPU租用平台实测:学生深度学习训练如何选型、省钱与避坑

上个月实验室的GPU排队排到了第三天,一个研二的同学拿着跑到一半的BERT微调任务来找我吐槽,说再等下去课题节点就要错过了。这个场景在高校里其实一点都不陌生:深度学习相关课程越来越多,竞赛和论文任务一个接一个,但实…

2026/10/13 22:09:51 阅读更多 →
基于Arduino与离线语音模块的智能垃圾桶DIY指南

基于Arduino与离线语音模块的智能垃圾桶DIY指南

如果你在厨房垃圾桶前犹豫过要不要伸手去掀那个沾满油渍的盖子,那你已经懂了这个项目最原始的动机。我做的这个基于Arduino的语音识别垃圾箱,说白了就是让普通垃圾桶听懂一句话就自动开盖、保持几秒再自动合上,全程不碰桶盖。比起那些挂在云端…

2026/10/13 22:09:51 阅读更多 →
昇腾适配DeepSeek V4全流程解析:算子、图编译与性能调优实践

昇腾适配DeepSeek V4全流程解析:算子、图编译与性能调优实践

前阵子我们组接到一个内部项目:把DeepSeek V4完整跑通在昇腾上。这里的"跑通"不是指能出几个token就算交差,而是从模型权重、算子适配、图编译、精度对齐到性能压测全部走完,最后给业务侧交付一个可用的推理接口。整件事前后花了几…

2026/10/13 22:09:51 阅读更多 →
基于SpringBoot+Vue的饮食健康管理系统设计与实现要点解析

基于SpringBoot+Vue的饮食健康管理系统设计与实现要点解析

最近刚把一个基于SpringBootVue的饮食健康管理系统完整走通了一遍。这个项目我一直惦记着——从需求梳理、表结构设计,到前后端联调、打jar包部署,前前后后用了两周多的时间。这类系统在课程设计和实际健康类产品里都很常见,核心思路并不复杂…

2026/10/13 22:09:51 阅读更多 →
Docker中Nginx中文乱码与配置热更新实战

Docker中Nginx中文乱码与配置热更新实战

1. 项目概述:为什么在 Docker 里跑 Nginx 还要操心中文乱码和配置挂载? 你刚用 docker run -d -p 80:80 nginx 跑起一个 Nginx 容器,浏览器一刷——页面能打开,但网页里的中文标题、按钮文字、日志输出全变成方块或问号&#xf…

2026/10/13 22:08:49 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/12 18:11:17 阅读更多 →