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

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

在公文自动化处理中,准确识别段落是标题还是正文、是几级标题,是后续所有工作的基础。本文记录了我在 912 篇真实公文、3 万+ 段落样本上,从零构建段落级标题分类器的完整过程:从 28 维特征工程(含 6 个上下文特征)、到按文档划分避免数据泄露、再到类别不平衡处理,最终模型在 h1/h2/body/toc 上 F1 全部达到 0.99+,已集成到文档转换流水线。

文章目录

一、问题背景:为什么需要段落级标题分类

在做公文结构化提取的时候,第一个要解决的问题就是:这段文字到底是什么?

是一级标题、二级标题,还是正文?是目录条目,还是附录说明?不同的标签,后续处理完全不同:

  • 标题 → 参与构建文档结构树,生成目录
  • 正文 → 进行关键条款识别、内容摘要
  • 目录 → 跳过或单独处理,避免和正文标题混淆
  • 附录 → 单独存储,不参与正文检索

一开始我用的是纯规则:字号大的是标题、加粗的是标题、以"第一章"开头的是 h1......但很快就遇到了问题:

  1. 不同文档排版差异大:有的文档 h1 用 22 号字,有的用 18 号
  2. 目录条目和正文标题长得像:目录里也有"第一章 总则",字号也不小
  3. 正文里也有加粗居中:不一定是标题,可能只是强调
  4. 附录、前言、页眉页脚:这些"边缘"段落规则很难覆盖

所以决定上机器学习------用 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 的原因:

  1. 结构化特征为主:字号、间距、编号前缀这些都是结构化数值,树模型天生适合
  2. 数据量不算大:3万样本,树模型足够,没必要上深度学习
  3. 推理快:批量处理几百篇文档瞬间完成
  4. 可解释:特征重要性一目了然,方便调优和排查问题

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_alphareg_lambda 做正则化,subsamplecolsample_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.0has_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%,说明:

  1. 编号前缀是第一判据------"第一章""第一条"这种模式,是标题的最强信号
  2. 字号和段前间距紧随其后------大字号 + 段前有空行,就是标题的典型特征
  3. 上下文特征有效chapter_has_body_to_next 排第 8,0.056 的重要性,在 26 维特征里算很高了

🤔 思考题:如果让你只用 5 个特征做分类器,你会选哪 5 个?参考上面的表,我会选:has_chinese_number_prefixfont_sizespace_beforehas_appendix_prefixchapter_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,看起来好像模型很差。

解决

  1. 用分层抽样(按文档的主导类别分层),确保小类别在验证集中有代表
  2. 评估时过滤掉训练集中没出现过的类别(XGBoost 3.x 要求标签从 0 连续编码)
  3. 关注 macro_f1 的同时,也要看每个类别的支持数

九、总结与后续优化方向

9.1 做了什么

  1. 设计了 11 类标签体系:覆盖标题/正文/目录/附录等常见段落类型
  2. 构建了 26 维特征:排版特征 + 文本模式特征 + 上下文结构特征
  3. 核心创新chapter_has_body_to_next 等上下文特征,解决目录与标题混淆问题
  4. 按文档划分数据集:避免数据泄露,评估更真实
  5. 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 实现。

相关推荐
Ivanqhz2 小时前
DRN(深度强化学习推荐网络)
人工智能·线性代数·机器学习·矩阵·dnn
一楼的猫4 小时前
网文AI辅助怎么过审?番茄200+维度检测与去AI味实战
人工智能·学习·机器学习·自然语言处理·ai写作
醒过来摸鱼5 小时前
机器学习知识框架:从数据处理到模型部署
人工智能·机器学习
lisw056 小时前
智能CAD:理论、算法、应用与展望!
人工智能·机器学习·软件工程
ShallWeL7 小时前
AUC、F1、召回率怎么选
人工智能·算法·机器学习
STLearner17 小时前
ICML 2026 | LLM×Graph论文总结[1]【图基础模型,文本属性图,多模态属性图,图对齐,图提示学习,关系深度学习
论文阅读·人工智能·python·深度学习·学习·机器学习·数据挖掘
Young Doro19 小时前
SAC 算法
线性代数·算法·机器学习
_Meilinger_1 天前
碎片笔记|分类问题与回归问题的关系
深度学习·机器学习·监督学习·损失函数·回归问题·分类问题·条件概率
学究天人1 天前
数学公理体系大全:第七章 连续统假设与力迫法简介
人工智能·算法·机器学习·数学建模·动态规划·图论·抽象代数