公文段落标题分类器实战:28维XGBoost实现标题层级识别(F1=0.99+)
在公文自动化处理中,准确识别段落是标题还是正文、是几级标题,是后续所有工作的基础。本文记录了我在 912 篇真实公文、3 万+ 段落样本上,从零构建段落级标题分类器的完整过程:从 28 维特征工程(含 6 个上下文特征)、到按文档划分避免数据泄露、再到类别不平衡处理,最终模型在 h1/h2/body/toc 上 F1 全部达到 0.99+,已集成到文档转换流水线。
文章目录
- 公文段落标题分类器实战:28维XGBoost实现标题层级识别(F1=0.99+)
-
- 一、问题背景:为什么需要段落级标题分类
- [二、标签体系:11 类段落标签](#二、标签体系:11 类段落标签)
- 三、特征工程:28维特征的设计思路
-
- [3.1 基础排版特征(16维)](#3.1 基础排版特征(16维))
- [3.2 文本模式特征(6维)](#3.2 文本模式特征(6维))
- [3.3 上下文结构特征(6维)------ 解决目录和标题混淆的核心](#3.3 上下文结构特征(6维)—— 解决目录和标题混淆的核心)
- [3.4 特征总览](#3.4 特征总览)
- 四、数据集构建:按文档划分,避免数据泄露
-
- [4.1 数据概况](#4.1 数据概况)
- [4.2 为什么不能按段落随机划分](#4.2 为什么不能按段落随机划分)
- [4.3 小类别处理](#4.3 小类别处理)
- 五、模型训练:XGBoost多分类
-
- [5.1 为什么选XGBoost](#5.1 为什么选XGBoost)
- [5.2 处理类别不平衡](#5.2 处理类别不平衡)
- [5.3 模型参数](#5.3 模型参数)
- 六、效果评估
-
- [6.1 整体指标](#6.1 整体指标)
- [6.2 各类别详细指标](#6.2 各类别详细指标)
- [6.3 特征重要性 Top 10](#6.3 特征重要性 Top 10)
- 七、工程集成:推理接口
- 八、踩过的坑
-
- 坑1:按段落随机划分,准确率虚高
- 坑2:目录条目和二级标题混淆
- 坑3:类别不平衡导致小类全错
- [坑4:小类别在验证集中可能为 0](#坑4:小类别在验证集中可能为 0)
- 九、总结与后续优化方向
-
- [9.1 做了什么](#9.1 做了什么)
- [9.2 还可以怎么优化](#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 文档的段落属性里直接提取:
python
@dataclass
class ParagraphFeatures:
"""段落排版特征"""
# 字体样式
font_size: float = 0.0 # 字号(磅值)
is_bold: bool = False # 是否加粗
is_italic: bool = False # 是否斜体
is_centered: bool = False # 是否居中
# 缩进与间距
indent_left: float = 0.0 # 左缩进
indent_first: float = 0.0 # 首行缩进
line_spacing: float = 0.0 # 行间距
space_before: float = 0.0 # 段前间距
space_after: float = 0.0 # 段后间距
# 文本统计
text_length: int = 0 # 文本长度(字符数)
char_count: int = 0 # 字符数(去空格)
# 页面信息
page_ratio: float = 0.0 # 在页面中的位置比例
page_num: int = 0 # 所在页码
line_count: int = 0 # 占几行
这些特征里,字号、段前间距、加粗、居中是区分标题和正文的最强信号------标题通常字号更大、段前段后有间距、加粗居中。
3.2 文本模式特征(6维)
光看排版还不够,有些正文段落也有加粗居中的。这时候文本的"长相"就很重要了:
python
# 编号前缀
has_number_prefix: bool = False # 阿拉伯数字前缀(如 "1. ")
has_chinese_number_prefix: bool = False # 中文数字前缀(如 "第一章")
has_bracket_prefix: bool = False # 括号前缀(如 "(一)")
has_dot_prefix: bool = False # 顿号/点号前缀
# 关键词
contains_key_title_words: bool = False # 含"总则/附则/章/节"等标题关键词
has_appendix_prefix: bool = False # 以"附件/附录"开头
这几个特征非常关键。比如 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 混淆的杀手锏:
python
def _enrich_context_features(self, samples: List[ParagraphSample]):
"""填充上下文特征(基于前后段落文本,无标签泄露)"""
# 按 doc_id 分组,保持原顺序
doc_groups: Dict[str, List[int]] = defaultdict(list)
for idx, s in enumerate(samples):
doc_groups[s.doc_id].append(idx)
for doc_id, indices in doc_groups.items():
n = len(indices)
# 预扫描:记录"第X章"短标题的位置
chapter_positions = [i for i in range(n)
if RE_CHAPTER.match(samples[indices[i]].text.strip())
and len(samples[indices[i]].text.strip()) < 50]
for i in range(n):
cur = samples[indices[i]]
cur_f = cur.features
# next_is_article:下一段是否为"第X条"条文
if i + 1 < n:
next_text = samples[indices[i + 1]].text.strip()
cur_f.next_is_article = bool(RE_ARTICLE.match(next_text))
# chapter_has_body_to_next:当前"第X章"到下一"第X章"之间是否有 body
if i in chapter_positions:
# 找到下一个"第X章"的位置
next_chapter_idx = None
for cp in chapter_positions:
if cp > i:
next_chapter_idx = cp
break
if next_chapter_idx:
# 检查两个章节标题之间是否有 body 段落(长文本或条文)
has_body = False
for j in range(i + 1, next_chapter_idx):
mid_text = samples[indices[j]].text.strip()
if len(mid_text) > 30 or RE_ARTICLE.match(mid_text):
has_body = True
break
samples[indices[i]].features.chapter_has_body_to_next = has_body
逻辑很简单:
- 目录里 :"第一章 XX" → "第二章 YY" → "第三章 ZZ",中间都是短标题,没有长正文 →
has_body = False - 正文中 :"第一章 XX" → (一大堆正文和条文) → "第二章 YY",中间有很多长段落 →
has_body = True
这个特征在后面的特征重要性里排到了第 8 位,效果非常好。
💡 经验分享:上下文特征在结构化文档分类中往往比单个段落的特征更有效。因为文档本身是有结构的------标题后面跟着正文,正文不会突然跳转到另一个大标题。利用这种"结构连贯性",可以解决很多单段落看不清楚的问题。
3.4 特征总览
| 类别 | 维度数 | 代表特征 |
|---|---|---|
| 字体样式 | 4 | font_size, is_bold, is_italic, is_centered |
| 缩进间距 | 5 | indent_left, indent_first, line_spacing, space_before, space_after |
| 文本统计 | 5 | text_length, char_count, page_ratio, page_num, line_count |
| 编号前缀 | 4 | has_number_prefix, has_chinese_number_prefix, has_bracket_prefix, has_dot_prefix |
| 关键词 | 2 | contains_key_title_words, has_appendix_prefix |
| 上下文 | 6 | next_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/footer | <1% | 小类别 |
4.2 为什么不能按段落随机划分
这是个很容易踩的坑。如果直接 train_test_split(X, y, test_size=0.2),看起来 80/20 分很合理,但实际上同一篇文档的段落之间高度相似------同一文档的 h1 字号一样、加粗一样、排版一样。
这样划分的结果就是:验证集里的段落,训练集里大概率有同文档的"兄弟姐妹",模型相当于"见过"这篇文档,准确率虚高。
正确的做法是按文档划分:
python
def get_train_val_split(self, val_ratio=0.1, stratified=True):
# 按 doc_id 分组,而不是按段落
doc_ids = self.df["doc_id"].unique().tolist()
# 按文档的主导类别做分层抽样
doc_labels = self.df.groupby("doc_id")["label"].agg(
lambda x: x.value_counts().index[0] # 取每篇文档的主导类别
)
if stratified:
train_docs, val_docs = train_test_split(
doc_ids, test_size=val_ratio, stratify=doc_labels.values
)
else:
train_docs, val_docs = train_test_split(
doc_ids, test_size=val_ratio
)
# 再根据文档ID获取段落
train_df = self.df[self.df["doc_id"].isin(train_docs)]
val_df = self.df[self.df["doc_id"].isin(val_docs)]
X_train = train_df[FEATURE_COLUMNS].values
y_train = train_df["label_encoded"].values
X_val = val_df[FEATURE_COLUMNS].values
y_val = val_df["label_encoded"].values
return X_train, y_train, X_val, y_val, train_df, val_df
这样保证了:
- 训练集的文档和验证集的文档完全不重叠
- 模型真的需要"泛化"到没见过的文档
- 评估结果更接近真实生产环境
⚠️ 这是个非常重要的细节。很多人做文本分类时忽略了"同一文档/同一作者/同一来源"的相关性,导致评估结果过于乐观,上线后效果大跌。
4.3 小类别处理
样本数少于 5 的类别,训练时合并为 unknown:
python
label_counts = self.df["label"].value_counts()
rare_labels = label_counts[label_counts < min_samples_per_class].index.tolist()
if rare_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 缩放 + 权重上限:
python
label_counts = Counter(y_train)
max_count = max(label_counts.values())
weights = {}
for label, count in label_counts.items():
# 平方根缩放:比反比温和
weight = np.sqrt(max_count / count)
# 权重上限:防止小类别权重爆炸
weight = min(weight, 10.0)
weights[label] = weight
sample_weights = np.array([weights[y] for y in y_train])
为什么用 sqrt 不用直接反比?因为如果直接用 max_count / count,toc 类(约1%)的权重会是 body 的 100 倍,模型会为了分对少量目录样本而牺牲大量正文的准确率。sqrt 把权重差异压下来了,平衡更好。
5.3 模型参数
python
model = xgb.XGBClassifier(
objective="multi:softprob",
num_class=num_train_classes,
eval_metric="mlogloss",
max_depth=8,
learning_rate=0.05,
n_estimators=1000,
subsample=0.85,
colsample_bytree=0.8,
min_child_weight=2,
gamma=0.05,
reg_alpha=0.1,
reg_lambda=1.0,
random_state=42,
n_jobs=-1,
tree_method="hist",
early_stopping_rounds=80,
)
model.fit(
X_train, y_train_mapped,
sample_weight=sample_weights,
eval_set=[(X_val, y_val_mapped)],
verbose=50,
)
参数基本是经验值 + 手动微调。数据量不大,过拟合风险不低,所以加了 reg_alpha 和 reg_lambda 做正则化,subsample 和 colsample_bytree 也降了一些。
六、效果评估
6.1 整体指标
| 指标 | 值 | 说明 |
|---|---|---|
| Accuracy | 0.9997 | 看起来很高,但因为 body 占绝大多数,参考意义有限 |
| Weighted F1 | 0.9995 | 同上,被大类拉高了 |
| Macro F1 | 0.857 | 更公平的指标,各类别平均 |
| 评估样本数 | 3,106 条 | 来自 91 篇验证文档 |
💡 小知识:在类别极不平衡的场景下,accuracy 和 weighted_f1 会严重虚高。这时候一定要看 macro_f1 或者每个类别的详细指标。
6.2 各类别详细指标
| 类别 | Precision | Recall | F1 | 支持数 |
|---|---|---|---|---|
| body | 0.9997 | 1.0000 | 0.9998 | 2,899 |
| h1 | 1.0000 | 1.0000 | 1.0000 | 91 |
| h2 | 1.0000 | 1.0000 | 1.0000 | 89 |
| appendix | 1.0000 | 1.0000 | 1.0000 | 14 |
| toc | 1.0000 | 1.0000 | 1.0000 | 11 |
| preamble | 1.0000 | 1.0000 | 1.0000 | 1 |
重点观察:
- h1、h2 F1 = 1.0:一级、二级标题识别完全正确,说明排版+编号前缀的特征已经足够强
- toc F1 = 1.0:目录条目也分对了,上下文特征立了大功
- body F1 = 0.9998:正文几乎全对
- appendix F1 = 1.0 :
has_appendix_prefix这个特征太准了
注意:h3 在验证集中样本数为 0(小类别,有些文档里没有),所以评估时被过滤了。这是小样本的常见现象------不是模型不行,是验证集里刚好没有。
6.3 特征重要性 Top 10
| 排名 | 特征 | 重要性 | 说明 |
|---|---|---|---|
| 1 | has_chinese_number_prefix |
0.188 | 中文数字前缀(最强信号) |
| 2 | font_size |
0.156 | 字号 |
| 3 | space_before |
0.146 | 段前间距 |
| 4 | has_appendix_prefix |
0.104 | 附件/附录前缀 |
| 5 | page_num |
0.093 | 页码 |
| 6 | space_after |
0.065 | 段后间距 |
| 7 | indent_first |
0.059 | 首行缩进 |
| 8 | chapter_has_body_to_next |
0.056 | 章节间是否有正文 |
| 9 | line_spacing |
0.043 | 行间距 |
| 10 | is_centered |
0.020 | 是否居中 |
前 4 个特征的重要性加起来就超过了 59%,说明:
- 编号前缀是第一判据------"第一章""第一条"这种模式,是标题的最强信号
- 字号和段前间距紧随其后------大字号 + 段前有空行,就是标题的典型特征
- 上下文特征有效 :
chapter_has_body_to_next排第 8,0.056 的重要性,在 26 维特征里算很高了
🤔 思考题:如果让你只用 5 个特征做分类器,你会选哪 5 个?参考上面的表,我会选:
has_chinese_number_prefix、font_size、space_before、has_appendix_prefix、chapter_has_body_to_next。这 5 个加起来重要性约 0.65,效果应该不会差太多。
七、工程集成:推理接口
模型训练好了,怎么用?我封装了一个 TitleClassifier 类:
python
class TitleClassifier:
"""标题分类推理器"""
def __init__(self, model_path: str):
with open(model_path, "rb") as f:
bundle = pickle.load(f)
self.model = bundle["model"]
self.class_names = bundle.get("class_names")
self.feature_columns = bundle.get("feature_columns", FEATURE_COLUMNS)
def predict(self, features: Dict) -> Tuple[str, float, Dict]:
"""
单条预测
Args:
features: 段落特征字典(与 FEATURE_COLUMNS 对应)
Returns:
(预测标签, 置信度, 各类别概率)
"""
# 按训练时的列顺序构造特征向量
x = np.array([[features.get(col, 0) for col in self.feature_columns]])
# 布尔转整数(推理阶段也需要,因为输入可能是布尔值)
for i, col in enumerate(self.feature_columns):
if isinstance(x[0, i], bool):
x[0, i] = int(x[0, i])
pred_id = int(self.model.predict(x)[0])
probs = self.model.predict_proba(x)[0]
label = self.class_names[pred_id]
confidence = float(probs[pred_id])
prob_dict = {self.class_names[i]: float(probs[i])
for i in range(len(probs))}
return label, confidence, prob_dict
还有批量预测接口 predict_batch,处理整份文档时更高效。
使用方式很简单:
python
classifier = TitleClassifier("output/model/xgboost_title_classifier.pkl")
label, conf, probs = classifier.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.0,body F1 0.9998
9.2 还可以怎么优化
| 方向 | 预期收益 | 难度 |
|---|---|---|
| 增加 h3/h4 样本 | 提升小类 F1 | 低 |
| 加入 N-gram 文本特征 | 捕捉更多文本模式 | 中 |
| 用 LightGBM/CatBoost 对比 | 可能提升 0.5-1 个点 | 低 |
| 引入序列模型(CRF/LSTM) | 利用段落顺序依赖 | 高 |
| 主动学习:标置信度低的样本 | 用最少标注获得最大提升 | 中 |
我个人最看好的是序列模型------段落标签其实是有强序列依赖的(h1 后面跟着 h2 或 body,body 不会突然跳到 h1),用 CRF 或者 BIO 标注的序列标注模型,应该能进一步提升效果。其次是主动学习,挑出模型不确定的样本人工标注,性价比最高。
相关阅读:
互动时间:
你在做结构化文档分类时,遇到过最棘手的问题是什么?是目录和正文混淆,还是类别不平衡?或者是别的坑?欢迎在评论区分享,我会一一回复。
本文基于 XGBoost 3.3.0 + scikit-learn 1.8.0 实现。