公文段落标题分类器实战:28维XGBoost实现标题层级识别(F1=0.99+)

📅 2026/7/13 15:02:28
公文段落标题分类器实战:28维XGBoost实现标题层级识别(F1=0.99+)
公文段落标题分类器实战28维XGBoost实现标题层级识别F10.99在公文自动化处理中准确识别段落是标题还是正文、是几级标题是后续所有工作的基础。本文记录了我在 912 篇真实公文、3 万 段落样本上从零构建段落级标题分类器的完整过程从 28 维特征工程含 6 个上下文特征、到按文档划分避免数据泄露、再到类别不平衡处理最终模型在 h1/h2/body/toc 上 F1 全部达到 0.99已集成到文档转换流水线。文章目录公文段落标题分类器实战28维XGBoost实现标题层级识别F10.99一、问题背景为什么需要段落级标题分类二、标签体系11 类段落标签三、特征工程28维特征的设计思路3.1 基础排版特征16维3.2 文本模式特征6维3.3 上下文结构特征6维—— 解决目录和标题混淆的核心3.4 特征总览四、数据集构建按文档划分避免数据泄露4.1 数据概况4.2 为什么不能按段落随机划分4.3 小类别处理五、模型训练XGBoost多分类5.1 为什么选XGBoost5.2 处理类别不平衡5.3 模型参数六、效果评估6.1 整体指标6.2 各类别详细指标6.3 特征重要性 Top 10七、工程集成推理接口八、踩过的坑坑1按段落随机划分准确率虚高坑2目录条目和二级标题混淆坑3类别不平衡导致小类全错坑4小类别在验证集中可能为 0九、总结与后续优化方向9.1 做了什么9.2 还可以怎么优化一、问题背景为什么需要段落级标题分类在做公文结构化提取的时候第一个要解决的问题就是这段文字到底是什么是一级标题、二级标题还是正文是目录条目还是附录说明不同的标签后续处理完全不同标题 → 参与构建文档结构树生成目录正文 → 进行关键条款识别、内容摘要目录 → 跳过或单独处理避免和正文标题混淆附录 → 单独存储不参与正文检索一开始我用的是纯规则字号大的是标题、加粗的是标题、以第一章开头的是 h1……但很快就遇到了问题不同文档排版差异大有的文档 h1 用 22 号字有的用 18 号目录条目和正文标题长得像目录里也有第一章 总则字号也不小正文里也有加粗居中不一定是标题可能只是强调附录、前言、页眉页脚这些边缘段落规则很难覆盖所以决定上机器学习——用 XGBoost 做多分类把排版特征、文本特征、上下文特征都喂进去让模型自己学。二、标签体系11 类段落标签先明确我们要分多少类。根据公文的常见结构我定义了 11 种段落标签标签中文名称说明h1一级标题章、篇等最高层级标题h2二级标题节、条等次级标题h3三级标题款、项等更细层级h4四级标题目等最细层级body正文段落普通正文内容toc目录条目目录中的章节条目preamble前言/序言正文前的引言部分appendix附录/附件正文后的补充材料header页眉每页顶部重复内容footer页脚每页底部重复内容unknown未知无法判断或样本过少的⚠️ 注意实际训练时样本数少于 5 的类别会被合并到unknown避免过拟合。这是处理类别不平衡的常用技巧——先合并小类等数据多了再细分。三、特征工程28维特征的设计思路这是整篇文章最核心的部分。我把特征分成了两大类基础排版特征16维和上下文结构特征6维加上一些文本特征总共 28 维。3.1 基础排版特征16维这是最直观的特征从 Word 文档的段落属性里直接提取dataclassclassParagraphFeatures:段落排版特征# 字体样式font_size:float0.0# 字号磅值is_bold:boolFalse# 是否加粗is_italic:boolFalse# 是否斜体is_centered:boolFalse# 是否居中# 缩进与间距indent_left:float0.0# 左缩进indent_first:float0.0# 首行缩进line_spacing:float0.0# 行间距space_before:float0.0# 段前间距space_after:float0.0# 段后间距# 文本统计text_length:int0# 文本长度字符数char_count:int0# 字符数去空格# 页面信息page_ratio:float0.0# 在页面中的位置比例page_num:int0# 所在页码line_count:int0# 占几行这些特征里字号、段前间距、加粗、居中是区分标题和正文的最强信号——标题通常字号更大、段前段后有间距、加粗居中。3.2 文本模式特征6维光看排版还不够有些正文段落也有加粗居中的。这时候文本的长相就很重要了# 编号前缀has_number_prefix:boolFalse# 阿拉伯数字前缀如 1. has_chinese_number_prefix:boolFalse# 中文数字前缀如 第一章has_bracket_prefix:boolFalse# 括号前缀如 一has_dot_prefix:boolFalse# 顿号/点号前缀# 关键词contains_key_title_words:boolFalse# 含总则/附则/章/节等标题关键词has_appendix_prefix:boolFalse# 以附件/附录开头这几个特征非常关键。比如has_chinese_number_prefix是否以第一章/第一条开头这是法规类文档标题的典型模式。3.3 上下文结构特征6维—— 解决目录和标题混淆的核心这是我觉得最有价值的一部分特征。目录条目toc和正文标题h2单看本身几乎一模一样——都是第一章 总则字号也差不多。但把上下文放进来区别就明显了特征含义直觉next_is_article下一段是否为第X条正文 h2 后面紧跟条文目录后面还是标题或正文prev_is_toc_title前一段是否为目录二字目录条目紧挨着目录标题dist_to_toc距最近目录标记的归一化距离目录区块内的段落离目录近next_font_size下一段字号标题和正文字号交替变化有规律prev_font_size前一段字号同上chapter_has_body_to_next当前第X章到下一第X章之间是否有 body目录中章节连续正文中间夹着正文重点说一下chapter_has_body_to_next这个特征它是我解决 toc/h2 混淆的杀手锏def_enrich_context_features(self,samples:List[ParagraphSample]):填充上下文特征基于前后段落文本无标签泄露# 按 doc_id 分组保持原顺序doc_groups:Dict[str,List[int]]defaultdict(list)foridx,sinenumerate(samples):doc_groups[s.doc_id].append(idx)fordoc_id,indicesindoc_groups.items():nlen(indices)# 预扫描记录第X章短标题的位置chapter_positions[iforiinrange(n)ifRE_CHAPTER.match(samples[indices[i]].text.strip())andlen(samples[indices[i]].text.strip())50]foriinrange(n):cursamples[indices[i]]cur_fcur.features# next_is_article下一段是否为第X条条文ifi1n:next_textsamples[indices[i1]].text.strip()cur_f.next_is_articlebool(RE_ARTICLE.match(next_text))# chapter_has_body_to_next当前第X章到下一第X章之间是否有 bodyifiinchapter_positions:# 找到下一个第X章的位置next_chapter_idxNoneforcpinchapter_positions:ifcpi:next_chapter_idxcpbreakifnext_chapter_idx:# 检查两个章节标题之间是否有 body 段落长文本或条文has_bodyFalseforjinrange(i1,next_chapter_idx):mid_textsamples[indices[j]].text.strip()iflen(mid_text)30orRE_ARTICLE.match(mid_text):has_bodyTruebreaksamples[indices[i]].features.chapter_has_body_to_nexthas_body逻辑很简单目录里“第一章 XX” → “第二章 YY” → “第三章 ZZ”中间都是短标题没有长正文 →has_body False正文中“第一章 XX” → 一大堆正文和条文 → “第二章 YY”中间有很多长段落 →has_body True这个特征在后面的特征重要性里排到了第 8 位效果非常好。 经验分享上下文特征在结构化文档分类中往往比单个段落的特征更有效。因为文档本身是有结构的——标题后面跟着正文正文不会突然跳转到另一个大标题。利用这种结构连贯性可以解决很多单段落看不清楚的问题。3.4 特征总览类别维度数代表特征字体样式4font_size, is_bold, is_italic, is_centered缩进间距5indent_left, indent_first, line_spacing, space_before, space_after文本统计5text_length, char_count, page_ratio, page_num, line_count编号前缀4has_number_prefix, has_chinese_number_prefix, has_bracket_prefix, has_dot_prefix关键词2contains_key_title_words, has_appendix_prefix上下文6next_is_article, prev_is_toc_title, dist_to_toc, next_font_size, prev_font_size, chapter_has_body_to_next合计26四、数据集构建按文档划分避免数据泄露4.1 数据概况912 篇真实公文3 万 段落样本置信度 ≥ 0.5文档类型涵盖法规、通知、意见、方案等标签分布典型的长尾分布标签大致占比说明body~90%绝大多数是正文h1~3%一级标题h2~3%二级标题toc~1%目录条目appendix/preamble/header/footer1%小类别4.2 为什么不能按段落随机划分这是个很容易踩的坑。如果直接train_test_split(X, y, test_size0.2)看起来 80/20 分很合理但实际上同一篇文档的段落之间高度相似——同一文档的 h1 字号一样、加粗一样、排版一样。这样划分的结果就是验证集里的段落训练集里大概率有同文档的兄弟姐妹模型相当于见过这篇文档准确率虚高。正确的做法是按文档划分defget_train_val_split(self,val_ratio0.1,stratifiedTrue):# 按 doc_id 分组而不是按段落doc_idsself.df[doc_id].unique().tolist()# 按文档的主导类别做分层抽样doc_labelsself.df.groupby(doc_id)[label].agg(lambdax:x.value_counts().index[0]# 取每篇文档的主导类别)ifstratified:train_docs,val_docstrain_test_split(doc_ids,test_sizeval_ratio,stratifydoc_labels.values)else:train_docs,val_docstrain_test_split(doc_ids,test_sizeval_ratio)# 再根据文档ID获取段落train_dfself.df[self.df[doc_id].isin(train_docs)]val_dfself.df[self.df[doc_id].isin(val_docs)]X_traintrain_df[FEATURE_COLUMNS].values y_traintrain_df[label_encoded].values X_valval_df[FEATURE_COLUMNS].values y_valval_df[label_encoded].valuesreturnX_train,y_train,X_val,y_val,train_df,val_df这样保证了训练集的文档和验证集的文档完全不重叠模型真的需要泛化到没见过的文档评估结果更接近真实生产环境⚠️ 这是个非常重要的细节。很多人做文本分类时忽略了同一文档/同一作者/同一来源的相关性导致评估结果过于乐观上线后效果大跌。4.3 小类别处理样本数少于 5 的类别训练时合并为unknownlabel_countsself.df[label].value_counts()rare_labelslabel_counts[label_countsmin_samples_per_class].index.tolist()ifrare_labels:self.df.loc[self.df[label].isin(rare_labels),label]unknown原因很简单样本太少的话模型学不到什么有效特征反而可能干扰大类的学习。等数据积累多了再拆出来。五、模型训练XGBoost多分类5.1 为什么选XGBoost方案优点缺点规则匹配简单、可解释、快边界case多准确率上限约85%XGBoost表格数据效果好、训练快、可解释需要手动特征工程BERT微调端到端语义理解强数据量小易过拟合、推理慢、排版信息用不上选 XGBoost 的原因结构化特征为主字号、间距、编号前缀这些都是结构化数值树模型天生适合数据量不算大3万样本树模型足够没必要上深度学习推理快批量处理几百篇文档瞬间完成可解释特征重要性一目了然方便调优和排查问题5.2 处理类别不平衡body 占了 90%如果不处理模型可能直接全都预测成 body也能有 90% 准确率。我的做法还是sqrt 缩放 权重上限label_countsCounter(y_train)max_countmax(label_counts.values())weights{}forlabel,countinlabel_counts.items():# 平方根缩放比反比温和weightnp.sqrt(max_count/count)# 权重上限防止小类别权重爆炸weightmin(weight,10.0)weights[label]weight sample_weightsnp.array([weights[y]foryiny_train])为什么用 sqrt 不用直接反比因为如果直接用max_count / counttoc 类约1%的权重会是 body 的 100 倍模型会为了分对少量目录样本而牺牲大量正文的准确率。sqrt 把权重差异压下来了平衡更好。5.3 模型参数modelxgb.XGBClassifier(objectivemulti:softprob,num_classnum_train_classes,eval_metricmlogloss,max_depth8,learning_rate0.05,n_estimators1000,subsample0.85,colsample_bytree0.8,min_child_weight2,gamma0.05,reg_alpha0.1,reg_lambda1.0,random_state42,n_jobs-1,tree_methodhist,early_stopping_rounds80,)model.fit(X_train,y_train_mapped,sample_weightsample_weights,eval_set[(X_val,y_val_mapped)],verbose50,)参数基本是经验值 手动微调。数据量不大过拟合风险不低所以加了reg_alpha和reg_lambda做正则化subsample和colsample_bytree也降了一些。六、效果评估6.1 整体指标指标值说明Accuracy0.9997看起来很高但因为 body 占绝大多数参考意义有限Weighted F10.9995同上被大类拉高了Macro F10.857更公平的指标各类别平均评估样本数3,106 条来自 91 篇验证文档 小知识在类别极不平衡的场景下accuracy 和 weighted_f1 会严重虚高。这时候一定要看 macro_f1 或者每个类别的详细指标。6.2 各类别详细指标类别PrecisionRecallF1支持数body0.99971.00000.99982,899h11.00001.00001.000091h21.00001.00001.000089appendix1.00001.00001.000014toc1.00001.00001.000011preamble1.00001.00001.00001重点观察h1、h2 F1 1.0一级、二级标题识别完全正确说明排版编号前缀的特征已经足够强toc F1 1.0目录条目也分对了上下文特征立了大功body F1 0.9998正文几乎全对appendix F1 1.0has_appendix_prefix这个特征太准了注意h3 在验证集中样本数为 0小类别有些文档里没有所以评估时被过滤了。这是小样本的常见现象——不是模型不行是验证集里刚好没有。6.3 特征重要性 Top 10排名特征重要性说明1has_chinese_number_prefix0.188中文数字前缀最强信号2font_size0.156字号3space_before0.146段前间距4has_appendix_prefix0.104附件/附录前缀5page_num0.093页码6space_after0.065段后间距7indent_first0.059首行缩进8chapter_has_body_to_next0.056章节间是否有正文9line_spacing0.043行间距10is_centered0.020是否居中前 4 个特征的重要性加起来就超过了 59%说明编号前缀是第一判据——“第一章”第一条这种模式是标题的最强信号字号和段前间距紧随其后——大字号 段前有空行就是标题的典型特征上下文特征有效chapter_has_body_to_next排第 80.056 的重要性在 26 维特征里算很高了 思考题如果让你只用 5 个特征做分类器你会选哪 5 个参考上面的表我会选has_chinese_number_prefix、font_size、space_before、has_appendix_prefix、chapter_has_body_to_next。这 5 个加起来重要性约 0.65效果应该不会差太多。七、工程集成推理接口模型训练好了怎么用我封装了一个TitleClassifier类classTitleClassifier:标题分类推理器def__init__(self,model_path:str):withopen(model_path,rb)asf:bundlepickle.load(f)self.modelbundle[model]self.class_namesbundle.get(class_names)self.feature_columnsbundle.get(feature_columns,FEATURE_COLUMNS)defpredict(self,features:Dict)-Tuple[str,float,Dict]: 单条预测 Args: features: 段落特征字典与 FEATURE_COLUMNS 对应 Returns: (预测标签, 置信度, 各类别概率) # 按训练时的列顺序构造特征向量xnp.array([[features.get(col,0)forcolinself.feature_columns]])# 布尔转整数推理阶段也需要因为输入可能是布尔值fori,colinenumerate(self.feature_columns):ifisinstance(x[0,i],bool):x[0,i]int(x[0,i])pred_idint(self.model.predict(x)[0])probsself.model.predict_proba(x)[0]labelself.class_names[pred_id]confidencefloat(probs[pred_id])prob_dict{self.class_names[i]:float(probs[i])foriinrange(len(probs))}returnlabel,confidence,prob_dict还有批量预测接口predict_batch处理整份文档时更高效。使用方式很简单classifierTitleClassifier(output/model/xgboost_title_classifier.pkl)label,conf,probsclassifier.predict({font_size:22.0,is_bold:True,is_centered:True,has_chinese_number_prefix:True,# ... 其他特征})print(f预测:{label}, 置信度:{conf:.4f})实际生产中这个分类器已经集成到了infer_to_markdown.py的文档转换流水线里——每提取一个段落先预测它的标签再根据标签决定怎么转成 Markdown。八、踩过的坑坑1按段落随机划分准确率虚高问题一开始用train_test_split按段落随机 8/2 分macro F1 能到 0.95感觉效果好得离谱。发现后来用按文档划分重跑了一遍降到 0.85才意识到问题——同一文档的段落排版太像了按段落划分等于把答案泄露给了模型。解决按文档 ID 划分训练/验证集确保泛化能力评估的真实性。坑2目录条目和二级标题混淆问题目录里的第一章 总则和正文里的第一章 总则单看段落本身几乎一模一样模型分不清。解决引入上下文特征——特别是chapter_has_body_to_next两章之间有没有正文直接把 toc 的准确率拉满了。坑3类别不平衡导致小类全错问题一开始不加样本权重模型把所有 appendix 和 toc 都预测成 body因为 body 太多了学不学小类 loss 都很低。解决加 sqrt 缩放的类别权重小类权重上限设为 10 倍。效果明显改善。坑4小类别在验证集中可能为 0问题preamble 类只有几个样本按文档划分后有时候验证集里一个都没有评估时 F1 是 0看起来好像模型很差。解决用分层抽样按文档的主导类别分层确保小类别在验证集中有代表评估时过滤掉训练集中没出现过的类别XGBoost 3.x 要求标签从 0 连续编码关注 macro_f1 的同时也要看每个类别的支持数九、总结与后续优化方向9.1 做了什么设计了 11 类标签体系覆盖标题/正文/目录/附录等常见段落类型构建了 26 维特征排版特征 文本模式特征 上下文结构特征核心创新chapter_has_body_to_next等上下文特征解决目录与标题混淆问题按文档划分数据集避免数据泄露评估更真实XGBoost 多分类器h1/h2/toc/appendix F1 全部 1.0body F1 0.99989.2 还可以怎么优化方向预期收益难度增加 h3/h4 样本提升小类 F1低加入 N-gram 文本特征捕捉更多文本模式中用 LightGBM/CatBoost 对比可能提升 0.5-1 个点低引入序列模型CRF/LSTM利用段落顺序依赖高主动学习标置信度低的样本用最少标注获得最大提升中我个人最看好的是序列模型——段落标签其实是有强序列依赖的h1 后面跟着 h2 或 bodybody 不会突然跳到 h1用 CRF 或者 BIO 标注的序列标注模型应该能进一步提升效果。其次是主动学习挑出模型不确定的样本人工标注性价比最高。相关阅读LLM时代还要学机器学习算法吗XGBoost 2.1.x从原理到实战附代码互动时间你在做结构化文档分类时遇到过最棘手的问题是什么是目录和正文混淆还是类别不平衡或者是别的坑欢迎在评论区分享我会一一回复。本文基于 XGBoost 3.3.0 scikit-learn 1.8.0 实现。