☰
金融问答实战:基于预训练BERT的领域适配与微调指南
2026/10/12 2:36:19 网站建设 项目流程

简介:面向金融领域问答检索的 FinBERT-QA 完整工程包,适合 NLP、信息检索方向的研究者与工程师,用于解决金融文本段落召回与答案排序问题。资源共 62 个文件,压缩包约 142.71MB,主要包含 Python 训练/评估源码、pickle 格式的模型与索引文件、TSV 数据集、Jupyter Notebook 分析文档,以及 Dockerfile 环境配置脚本,目录分工清晰。目前已有 2089 人学习下载,是复现 BERT 在金融 QA 场景落地的参考样例。工程方案完整覆盖 Lucene 粗召回前 50 候选、BERT 精排序微调,以及基于 FiQA 数据集迁移适配的技术路径;并提供评估脚本,可基于 nDCG、MRR、Precision 指标直接验证约 20% 的平均提升效果,便于深入理解检索加精排双阶段流水线,适合进阶学习者与算法工程师快速搭建实验环境并开展二次开发。

1. 金融问答为什么值得单独做一次领域适配

做研报系统或者智能投顾平台的同学应该都有过这种经历:把一份五十页的财报丢给通用问答模型,问“这家公司去年第四季度净利润同比变化是多少”,模型答上来的内容看着通顺,但数字对不上,甚至把“下降”说成“上升”。问题不是出在模型不够强,而是出在它压根没“见过”足够多的金融文本。通用 BERT 预训练语料里,新闻、百科、网页占了大头,财报、研报、公告这类文本占比非常低,模型的词表和注意力习惯都没有为金融表达做过专门调整。

FinBERT-QA 这个方向解决的就是这件事:拿一个在通用语料上预训练好的 BERT 模型,先在金融语料上做领域继续预训练,让模型先“学会”金融语言的词法和句法习惯,再在下游做抽取式问答微调,让模型学会回答“某某公司 2022 年营业收入是多少”这类问题。它适合谁?适合正在做金融信息抽取、财报问答、公告检索增强问答的从业者;你不一定要自己训练一个巨型模型,但你需要知道从通用 BERT 到金融问答模型这条路上,数据怎么准备、参数怎么设、哪些坑一定会踩。

2. 通用 BERT 在金融问答上输在哪:先看清领域鸿沟

2.1 领域文本的词汇与数字表达差异

金融文本最难处理的不是长句,而是它的词汇分布和通用语料完全不一样。拿“质押率”“流动性覆盖率”“EPS”这些词来说,通用 BERT 的词表里可能压根没有完整词条,分词器会把“质押率”切成“质押”和“率”,把“EPS”切成“E”“PS”。词被切碎之后,模型的语义表示就失真了,最后做起始位置预测时自然容易答偏。这是我在实际跑金融问答时最先观察到的问题:预测出来的答案片段老是在某个词的前半截或者后半截断裂,打开 tokenizer 一看,根因就是分词把领域词拆散了。

数字表达是另一个重灾区。通用语料里的数字大多简单直接,而金融文本里的数字习惯带着单位、百分比、同比方向,比如“较上年同期增长 12.3 个百分点”“净利润 56.2 亿元”。BERT 在预训练阶段见过不少数字,但对这种带限定成分的金融数字组合,它的位置编码和注意力机制并没有专门建模。你问“增长了多少”,它可能定位到年份而不是百分比。所以要做的第一件事,不是急着调模型结构,而是先承认领域鸿沟存在,再决定用哪条技术路线补这条鸿沟。

2.2 三条技术路线怎么选:直接微调、继续预训练、从零预训练

我一般会把金融问答模型的技术路线分成三档,先给个对比表格,再逐条说明选型逻辑。

方案做法成本效果适用场景
直接微调加载通用 BERT 权重,只在下游问答数据上微调最低一般,数字和术语错误多数据量小、先跑通流程时
领域继续预训练用金融语料对通用 BERT 做 MLM 继续训练,再微调问答中好,术语表示和数字表达有明显提升手上有 2GB 以上金融文本时
从零预训练用金融语料从头训练一个 BERT最高不一定更好除非有特殊词表需求,否则不建议

直接微调最大的问题是你把领域适配的任务全部压给了下游问答数据,而问答数据量通常不大,模型还没来得及学“质押率”是完整概念,就开始拟合答案位置,结果就是训练集 F1 很高,一换新财报就露馅。领域继续预训练则让模型先在海量无标注金融文本上把词表和内部表示对齐,这是最接近标题所说“使用预训练的 BERT 语言模型”的标准做法——预训练的部分已经帮你做完了通用语义建模,你再补一层金融语义。

从零预训练除非你有几十 GB 高质量金融语料和足够的算力,否则效果大概率不如继续预训练。有个常见误区是以为“领域数据越多越好”,实际上继续预训练用 2 到 5GB 干净语料就能获得明显收益,再多提升边际反而变缓。我通常的建议是:先在无标注财报、公告、研报摘要上做一步 MLM 继续预训练,得到领域版权重,再进入问答微调阶段。这个方案对算力和工程改动都友好。

2.3 问答结构的实现方式:在 BERT 上加一个起始与结束分类头

做抽取式问答,不需要对 BERT 的结构做任何改动,只需要在它上面接两个线性层:一个预测答案起始位置,一个预测答案结束位置。输入是一段文本和一个问题,模型输出每个 token 作为答案起点的分数和作为答案终点的分数,最后在起点分数与终点分数构成的矩阵里找分数最高的区间,截取出答案。用 transformers 工具库的 AutoModelForQuestionAnswering 可以直接加载权重并自动接上这两个分类头,非常省事。

from transformers import AutoTokenizer, AutoModelForQuestionAnswering # 加载"已经做过金融语料继续预训练"的BERT权重 model_name = "your_financial_bert_checkpoint" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForQuestionAnswering.from_pretrained(model_name) question = "这家公司2022年营业收入是多少?" context = "某公司2022年实现营业收入56.2亿元,同比增长12.3%。" inputs = tokenizer(question, context, return_tensors="pt") outputs = model(**inputs) start_logits = outputs.start_logits end_logits = outputs.end_logits

上面这段代码里最关键的两个参数是 tokenizer 接收 question 和 context 时自动拼成的输入格式,以及最终输出 start_logits 和 end_logits 两组分数。训练阶段,我们把真实答案的起始字符位置换算成 token 位置,构造两个标签;推理阶段,则要在 start_logits 和 end_logits 上做约束解码——结束后位置必须在起始位置之后,且跨度不能超过预设的 max_answer_length。这看起来简单,但实际调优时大部分 bug 都出在字符位置到 token 位置的映射上,后面避坑章会专门讲。

2.4 验证集怎么搭才不会骗到自己

金融问答数据有一个隐蔽问题:同一家公司的财报可能既出现在训练集也出现在验证集,模型在训练时见过这篇文章的措辞,验证时碰到同一篇的另一道题,F1 虚高得吓人。我见过有人把验证集 F1 做到 90 以上,一上真实新数据直接掉到 60 多,原因就是训练与验证之间有文章级泄漏。

正确做法是先把文档划分成 train/dev 两份,确保同一篇报告只出现在其中一份,然后再把文档内的问答对打包分配。代码实现上就是在构建数据集时以 document_id 为 key 做 group split,而不是对每个问答对单独 random shuffle。这个步骤虽然不起眼,但它决定了你后面调参时看到的指标有没有参考价值。验证集设计还包括一个细节:不要只用一份财报的时间范围,要尽量覆盖不同年份、不同行业、不同表述习惯的文本,否则你验证的是模型背诵能力而不是泛化能力。

3. 把财报语料变成问答训练集:三种标注策略与格式转换

3.1 从财报和研报原文里清洗出可用文本

问答微调的第一步不是写训练代码,而是先解决数据问题。公开的财报 PDF、公告原文、研报摘要都可以作为正样本来源,但拿到的原始文本非常脏:PDF 转出来的文本有大量换行、空格异常、全角半角混用,表格里的数据会被拆成散落的数字,页眉页脚还会混入无关内容。我一般会先做一轮规则清洗,把明显不是正文的行删掉。

清洗规则处理方式
去除页眉页脚匹配页码、公司简称、报告期等重复行并删除
合并断行行尾无标点时直接拼接,避免“营业收 入”被切断
统一数字格式全角数字转半角,千分位逗号去除
表格文本化保留“项目:数值”的结构,丢弃表格线符号

清洗的质量直接决定后面规则抽取的准确率。我在实践中发现,清洗文本这一环至少要留两到三成的项目时间,宁可多花时间做干净,也不要急着进入标注阶段。毕竟这里出来的每一行,后面都会变成训练样本,噪声会直接影响模型对数字和单位的把握。清洗完成后保存成纯文本文件,每篇文档一个文件,标注一个 document_id 备用。

3.2 半自动标注:用规则从利润表类文本生成问答对

完全人工标注问答对成本太高,一个折中方案是用规则从结构化较强的财报句子里自动抽取问答对。财报里“营业收入”“净利润”“经营活动现金流”这类科目后面通常直接跟数字和同比描述,非常适合规则抽取。常见做法是先用正则匹配出“科目 + 数值 + 单位 + 同比描述”的句子,再按模板生成问题和答案。这个方案不能覆盖所有问题类型,但可以快速攒出一批高质量的种子数据。

import re pattern = r"(营业收入|净利润|归属于上市公司股东的净利润)[^。]{0,30}?(\d+\.?\d*)\s*(亿元|万元|元)" context = "某公司2022年实现营业收入56.2亿元,同比增长12.3%;净利润8.7亿元。" qa_pairs = [] for match in re.finditer(pattern, context): item, value, unit = match.groups() answer_span = context[match.start():match.end()] question = f"这家公司2022年{item}是多少?" qa_pairs.append({ "question": question, "answer": answer_span, "answer_start": match.start() })

这段规则的逻辑是先用正则把科目词、数值、单位捕获下来,再把整段匹配文本作为答案,answer_start 记录这段答案在原文中的起始字符位置。这里必须强调一个细节:正则里一定要限制匹配范围,不要把跨句子的大段文字吞进答案;否则 answer_start 的计算很容易因为括号内的括号、缩进等非预期字符而错位。生成完规则问答对后,需要人工抽查标注质量,我一般要求至少抽 20% 的样本人工核对一遍,重点看 answer_start 是否对应正确答案开头。

3.3 转成 SQuAD 格式:字符偏移对齐与长文本切片

得到原始问答对之后,要把它们整理成抽取式问答通用的 SQuAD 风格 JSON 结构。这个结构的核心是:每个文档段落包含 context,段落下面挂若干 qas,每个 qas 里有 question、id、answers,answers 里至少给出一个带有 answer_start 的答案。转格式的大部分工作量其实不在 JSON 嵌套,而在把人工标注的答案起始字符位置,换算成 tokenizer 之后模型能理解的 token 区间;char-to-token 对齐要交给 tokenizer 在预处理阶段完成,训练脚本里尽量用 offset_mapping 辅助转换。

import json def build_squad_sample(document_id, context, qas): sample = { "title": document_id, "paragraphs": [{ "context": context, "qas": [{ "id": q["id"], "question": q["question"], "answers": [{ "text": q["answer"], "answer_start": q["answer_start"] }] } for q in qas] }] } return sample dataset = [ build_squad_sample(doc_id, doc_text, doc_qas) for doc_id, (doc_text, doc_qas) in grouped_qa.items() ] with open("financial_qa_train.json", "w", encoding="utf-8") as f: json.dump({"data": dataset}, f, ensure_ascii=False, indent=2)

这里要注意 answer_start 是字符级别的起始位置,而不是 token 级别;后续训练脚本会由 tokenizer 根据 context 重新做偏移对齐。长文本切片是另一个必须处理的环节:BERT 输入长度有上限,超过上限的文档需要按滑动窗口切成多段,让每个窗口内都保留完整的上下文,并允许窗口之间有重叠。滑动窗口的重叠长度通常由 doc_stride 控制,金融财报句子较长,我把 doc_stride 设在 96 到 128 之间,既能保留窗口间的答案可定位性,又不会产生太多重复计算。

4. 用预训练 BERT 微调金融问答:训练脚本与三个必调参数

4.1 基于 transformers 的微调主流程

到这一步,你已经有了领域适配后的 BERT 权重(或者一个在做继续预训练的中间版本),也有了金融问答训练集。接下来要用 Transformers 的 Trainer 接口做下游微调。Trainer 的好处是帮我们封装了训练循环、日志、评估和断点续训,省掉大量样板代码。主流程里最要紧的是 DataCollator 和 post_process 函数,很多初次跑问答训练的人在这两个地方踩坑。

from transformers import ( AutoTokenizer, AutoModelForQuestionAnswering, TrainingArguments, Trainer, DefaultDataCollator ) tokenizer = AutoTokenizer.from_pretrained("your_financial_bert_checkpoint") model = AutoModelForQuestionAnswering.from_pretrained("your_financial_bert_checkpoint") args = TrainingArguments( output_dir="./finbert_qa", learning_rate=3e-5, per_device_train_batch_size=8, num_train_epochs=3, warmup_ratio=0.1, logging_steps=50, save_steps=500, evaluation_strategy="epoch", save_total_limit=2, fp16=True, ) data_collator = DefaultDataCollator() trainer = Trainer( model=model, args=args, train_dataset=train_dataset, eval_dataset=dev_dataset, data_collator=data_collator, tokenizer=tokenizer, ) trainer.train()

这里面的关键点在两个地方。一是 DataCollator 要选默认的 token 级 collator,不要自己去拼接 batch,否则 padding 方向不对,attention_mask 会全变成有效 token;二是模型名要保证是领域继续预训练后的检查点,而不是通用 BERT。参数层面,learning_rate、warmup_ratio、max_length/doc_stride 是三组大头,下面逐个给出推荐值。

4.2 三个必调参数:学习率、warmup 与文本长度策略

参数推荐值区间设置逻辑
learning_rate2e-5 到 5e-5低于 1e-5 收敛慢,高于 5e-5 容易灾难性遗忘
warmup_ratio0.05 到 0.15金融数据噪声大,起步用较低学习率稳定训练
max_length384 到 512句子长时用 512,显存有限时降到 384
doc_stride96 到 128窗口重叠太大会让同一答案重复出现,训练效率低

学习率是第一个要调的参数。金融问答训练集通常只有几千到几万条,通用 BERT 的权重已经学得很充分,过大的学习率会让模型快速覆盖掉原有能力,表现为验证 F1 在第一个 epoch 冲到高点随后暴跌。warmup 比例也不可小看,它决定了前若干步用多小的学习率热身,对稳定训练很有帮助,尤其在 batch size 较小的情况下。max_length 不是越大越好,超过 512 会触发位置编码截断,长文本靠滑动窗口解决,而不是靠硬调 max_length。我习惯先把 max_length 设为 384,doc_stride 设为 128,看到显存有余再把 max_length 提到 480,F1 通常会再涨一到两个点。

4.3 评估指标与调优循环:EM 和 F1 分开看

训练完不能只看 loss,抽取式问答的标准评估指标是 EM(完全匹配)和 F1(答案词级别的重叠度)。EM 高说明模型对标准答案的定位精准,但金融数字常有个位数差异,EM 会显得偏低;F1 则能体现部分匹配的稳定性。两个指标要分开解读:如果 EM 明显比 F1 低很多,说明模型能框住大概范围但边界不稳;如果两个都低,那基本是数据或领域适配有问题,先不要急着加模型复杂度。

from transformers import Trainer import numpy as np from collections import Counter def compute_metrics(pred): start_logits, end_logits = pred.predictions start_labels = pred.label_ids[0] end_labels = pred.label_ids[1] exact_match = 0.0 f1_sum = 0.0 total = len(start_labels) for i in range(total): pred_start = np.argmax(start_logits[i]) pred_end = np.argmax(end_logits[i]) pred_text = tokenizer.decode(pred.predictions[i][pred_start:pred_end + 1]) true_text = tokenizer.decode(tokenizer.convert_ids_to_tokens( pred.label_ids[2][pred_start:pred_end])) exact_match += (pred_text == true_text) tokens_pred = set(pred_text.split()) tokens_true = set(true_text.split()) common = tokens_pred & tokens_true if len(common) == 0: f1_sum += 0 else: precision = len(common) / len(tokens_pred) recall = len(common) / len(tokens_true) f1_sum += 2 * precision * recall / (precision + recall) return {"EM": exact_match / total, "F1": f1_sum / total} trainer.compute_metrics = compute_metrics

这段代码里的关键指标函数在生产环境中通常要做得更严谨,要先做 token 到文本的还原,再做标准化,例如对数字去除千分位逗号、对百分比做统一表达。调优循环建议以 F1 为主,EM 为辅:先保证 F1 稳定在可接受范围,再尝试调高 EM 对应的边界预测。每调一组参数,记录一版指标,不要一次同时改学习率和 max_length,否则出了问题无法定位是哪一项导致的回退。

5. FinBERT-QA 微调避坑指南:五类翻车现场与排查方案

5.1 报告期数字被分词器切碎导致数字预测漂移

现象:训练时 F1 正常,验证集上模型对“562.3 亿元”这种答案总是少预测一个“亿元”或者把小数点位预测错。排查时打开 tokenizer 后发现,“562.3”会被切成“562”、“.”、“3”三个 token,模型的起始位置预测在“.”上反复横跳。

原因:通用 BERT 的 BPE 词表对数字的切分粒度很粗,金融带单位数字在预训练阶段出现频率低,模型没有形成“一个完整数字应该作为一个整体被预测”的习惯。

解决:在数据预处理阶段单独做数字保护。常见做法是把连续的数字字符标记为一个整体 token 或者在分词后强制 merge,但这依赖分词器能力;我用的更简单方案是在清洗时把数字格式统一,并保证 Q&A 的 answer 文案和 context 里的原文完全一致,避免因为空格或全角差异造成对齐错位。如果问题依旧,可以在继续预训练阶段给语料里的数字前后加特殊标记,让模型先学会数字边界。

5.2 “下降”“同比减少”这类负向语义经常答反

现象:问“净利润同比变化情况”,答案应该包含“下降 15%”,模型却预测了同一个句子里的“增长”,晴天翻车。

原因:通用语料里对“下降”的表述远少于“增长”,金融文本里“同比减少”常以“同比下降 15.2%”这种紧凑形式出现,预训练阶段没见过几回,微调数据量不足以覆盖这类负向表达。

解决:数据增强优先补负向样本。我在构建训练集时会把所有带“增长”的句子,通过同义改写规则生成带“下降”的镜像样本,再把数字按合理幅度变化。另外在清洗时要保留原始句子里的副词,不要为了“规范化”把“较去年同期下降”改成“同比下降”,去掉了上下文线索,模型更难分辨方向。

5.3 长文档里答案定位直接失效

现象:文档长度超过 1000 字后,模型给出的答案片段经常跨段落,甚至从文档开头跳到结尾,验证 F1 骤降。

原因:超过 max_length 的内容被截断,滑动窗口没有配好 doc_stride,导致答案所在窗口与窗口之间的上下文不完整,模型找不到起始位置。

解决:回到第 3 章的切片策略,把 doc_stride 从 128 调到 256,并确认每个窗口都有足够的上下文重叠。排查时先取一个长文档样本,打印出窗口的 token 数量、答案落在哪个窗口,确认答案没有被截断在窗口外。还有一个细节:验证和训练要用同一套切片逻辑,不能训练时用宽窗口、预测时用窄窗口,否则位置映射会乱套。

5.4 训练集与验证集文章级泄漏导致指标虚高

现象:训练集 F1 一路涨到 95,验证集 F1 也在 92 以上,但模型换到新文档上只有 58,明显发虚。

原因:切分数据集时按问答对随机划分,同一份财报里的多个问题和对应答案,一部分进了训练集、一部分进了验证集。模型训练中已经背下了同一篇文章的段落特征,验证时遇到相似文本直接“回忆”。

解决:换用 document_id 级别的划分,把同一文档下的全部问答对归为同一组,再做分组切分。如果切分后发现某类行业文本全在验证集里而训练集缺失,那就要考虑按行业分组再分层抽样,保证两边的行业分布一致。

5.5 显存不足时的玄学崩溃

现象:batch_size 设成 8 能训练,设成 4 反而报显存错误,或者 fp16 开启后 loss 在训练到一半时变得巨大。

原因:batch size 改变会连带改变 attention 矩阵的内存峰值,且某些显卡驱动对 fp16 的 kernel 支持不完整。显存接近上限时,pytorch 的显存分配会不稳定,遂出现这种“调小反而崩”的反直觉现象。

解决:bath size 和 gradient_accumulation_steps 分开调。想要等效小 batch 但不崩,就保持 per_device_train_batch_size=8,把 gradient_accumulation_steps 设成 2 或 4;fp16 若在后半程出现 loss 爆炸,要检查数据里有没有 NaN 或极大数值,金融文本里偶尔会出现“-”符号被单独提取成样本的情况。设置 save_total_limit=2,多留一个检查点,保命用。

6. 从离线模型到可用问答系统:置信度校准与增量升级路径

6.1 给答案加一个可信度门槛,避免把垃圾答案抛给用户

微调完成之后,模型对每一个输入都会输出 start_logits 和 end_logits,哪怕文档里根本没有答案,它也会强行给出一个最高分区间。这在金融场景里不可接受,用户会看到模型把一段无关数字当成答案。常见做法是对两组 logits 做 softmax,把最大值对应的概率作为该端点的置信度,再取两端置信度的几何平均作为最后答案分。低于阈值的预测直接判为“无法回答”。

import torch import torch.nn.functional as F start_probs = F.softmax(start_logits, dim=-1) end_probs = F.softmax(end_logits, dim=-1) best_start = torch.argmax(start_probs) best_end = torch.argmax(end_probs) confidence = torch.sqrt(start_probs[0][best_start] * end_probs[0][best_end]) if confidence < 0.5: print("no answer, confidence too low") else: answer = tokenizer.decode(input_ids[0][best_start:best_end + 1]) print(answer)

这个置信度阈值需要在你自己的验证集上校准,金融文档里“有标准答案”的比例通常只有百分之六七十,把阈值设在 0.3 到 0.5 之间比较合适。调低阈值可以召回更多答案,但会增加误导性回答;调高阈值则更保守。我这边线上系统的经验是宁缺毋滥,给不出答案时明确说“未找到”,不硬答。这个习惯保住了很多信任。

6.2 按季度增量更新模型,保留领域适配的连续性

新财报发布后,旧模型面对新表述会逐渐失效。很多人选择每周全量重训,成本高且没必要。我一般采取增量微调:每季度把新增财报语料先做一遍规则清洗和领域继续预训练,再在包含旧数据和新数据的混合训练集上微调问答层。混合比例按 7:3 左右控制,保模型不会忘记旧知识。部署上,把模型版本号和置信度阈值一起记录,方便线上回滚。做金融问答这行的最大教训是:模型效果是数据质量的放大镜,数据脏,模型越努力错得越离谱。希望在一开始就把数据流程和验证流程搭扎实,这样后续的模型迭代都不会翻车。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询