
简介FinBERT-QA是一套面向金融领域问答的检索增强系统基于预训练BERT构建用于解决FiQA数据集任务2中的金融段落检索与候选答案重排问题。系统采用两阶段策略先用Lucene索引检索每个查询的前50个候选段落再利用BERT重排序器精排并融合Transfer and Adapt方法将TensorFlow版预训练模型迁移至PyTorch后先在通用QA任务上微调再用FiQA金融语料做领域适应。压缩包内共62个文件约142.71MB主要包含Python源码、pickle预处理数据、tsv标注语料和ipynb交互式分析笔记文件按检索器、训练脚本、评估模块、notebook等分别组织便于按pipeline顺序阅读。目前已有2087人学习下载适合自然语言处理、信息检索方向的研究者或金融文本挖掘工程师参考。通过该资源读者可以获得从索引构建、模型微调到推理评估的完整代码以及nDCG、MRR、Precision三项指标平均提升约20%的实验分析能直接复现并改造到自己的金融问答或段落检索任务中。1. FinBERT-QA把通用 BERT 改成金融问答值得投入吗金融行业做文本问答最尴尬的处境是通用 BERT 在新闻标题上跑得不错一换到招股书、年报、监管函准确率立刻掉一截。FinBERT-QA 的思路其实很直接——先用金融语料把 BERT 继续预训练一遍让模型先“懂行话”再拿去微调问答任务。这个方向适合两类人一类是手里有一堆金融文本、但不知道怎么把 BERT 用得更准的算法工程师另一类是正在做智能客服或投研助手想评估“领域继续预训练”值不值得投入产研资源的负责人。本文会把这套方案拆成可执行的步骤涵盖语料构造、继续预训练、问答微调、推理部署和踩坑记录目标是让新手能跟着复现熟手能直接拿去对照自己的方案边界。很多人以为 FinBERT 只是“拿金融语料继续训练一下”真正动手才发现预训练的选择、问答数据的构造、模型输出与业务字段的对齐每个环节都有坑。下面按一条完整的落地路径来讲。2. 金融预训练从语料选择到继续预训练的三个决策点既然标题叫 FinBERT-QA那第一步必然是把通用 BERT 变成“懂金融的 BERT”。这里要分清一个概念FinBERT-QA 不是从零训练而是基于一个已经收敛的 BERT 权重用金融语料做领域自适应预训练然后再做问答微调。这个方案的核心假设是通用语言理解能力已经在通用语料里学好了金融语料只需要帮模型补齐术语、句式和实体之间的关联。2.1 选哪个 BERT 底座英文场景优先 FinBERT中文场景优先 RoBERTa-wwm做金融问答第一关是选底座。英文场景下FinBERTProsusAI 开源的金融领域 BERT往往比通用 BERT 起步点更高因为它已经用 10-K、10-Q 等年报文本继续预训练过但它的词表是英文中文场景完全用不上。中文场景下我一般更推荐哈工大讯飞联合发布的 RoBERTa-wwm-ext 作为底座而不是直接用 BERT-base-Chinese原因是中文金融文本里专有名词和数字出现了大量被分割的问题RoBERTa 的全词掩码机制能覆盖得更完整。如果业务语料里粤语或藏语等非规范表达多还可以考虑 MacBERT但那样的话预训练数据量会更大成本上升明显。这里有个容易误判的地方BERT 和 RoBERTa 的权重下载并不复杂Hugging Face 上直接能拿到但“下载 BERT 模型参数”不等于“模型适合你的领域”。通用 BERT 对“质押、回购、商誉减值”这类词的理解基本停留在字面继续预训练才是真正让它“懂行”的关键步骤。2.2 继续预训练的三个决策点语料规模、掩码策略、冻结策略继续预训练不是照搬 BERT 的预训练脚本就行。结合我的实操经验三个决策点直接影响最终效果。第一语料规模。金融语料不是越多越好。常见的一个误区是拿几十 GB 的全量新闻去继续预训练一方面训练成本压不住另一方面通用新闻和通用语料高度重合领域漂移很小。我一般会把语料控制在 1-5 GB优先选年报、招股书、研报、监管问答这些“有金融味的文本”。如果实在凑不齐至少保证语料里的术语分布接近业务场景比如做信贷审核就多放些风控文件做投研就多放公司公告。第二掩码策略。继续预训练时BERT 的 MLM 任务默认是随机 mask 15% 的 token。但这个比例在金融文本里不一定合适——金融文本里数字和实体密度高随机 mask 会把大量上下文信息抹掉。我的习惯是把 mask 比例降到 10%-12%同时把“人名、机构名、日期、金额”这类实体提出来做实体级掩码这样模型能更集中地学习实体与上下文的关系。第三冻结策略。领域继续预训练常见的做法是冻结底层 embedding 和部分底层 Transformer 层只训练高层。理由很朴素底层学的是词的通用表示高层学的才是语义组合。金融文本里有大量“预期、不及预期、超预期、下修”这类近义但情绪相反的表达这些语义差异主要靠高层捕捉。我一般会冻结 embedding 和前 4 层用 3e-5 的学习率训练高层如果语料足够干净也可以不冻结但学习率要降到 2e-5 以下防止灾难性遗忘。2.3 继续预训练的最小复现流程脚本、参数与资源预估下面给出一个可以直接跑的继续预训练片段。这个脚本基于 Hugging Face Transformers兼容 PyTorch。from transformers import ( RobertaTokenizerFast, RobertaForMaskedLM, DataCollatorForLanguageModeling, TrainingArguments, Trainer, ) from datasets import load_dataset # 读取已经清洗好的金融语料txt 格式一行一篇文档 dataset load_dataset(text, data_files{train: ./finance_corpus/*.txt}) # 中文场景用 roberta-wwm-ext英文场景换成 prosusai/finbert tokenizer RobertaTokenizerFast.from_pretrained(hfl/rbt3, max_len512) model RobertaForMaskedLM.from_pretrained(hfl/rbt3) def tokenize_function(examples): return tokenizer( examples[text], truncationTrue, max_length512, paddingmax_length, return_tensorspt, # 关键关闭自动 padding交给 DataCollator 动态处理 ) tokenized_dataset dataset.map(tokenize_function, batchedTrue) # 用实体级掩码替代随机掩码时可以在这里自定义 mask 逻辑 # 最简单的方式是先随机 mask 10%再把句中的金额/机构名替换为 [MASK] data_collator DataCollatorForLanguageModeling( tokenizertokenizer, mlmTrue, mlm_probability0.10, # 金融文本建议从 0.15 降到 0.10 ) train_args TrainingArguments( output_dir./finbert-qa-checkpoint, overwrite_output_dirTrue, num_train_epochs3, per_device_train_batch_size8, gradient_accumulation_steps4, # 等效 batch size 32 learning_rate3e-5, warmup_ratio0.1, logging_steps200, save_steps2000, fp16True, # V100 及以上可开启 ) trainer Trainer( modelmodel, argstrain_args, data_collatordata_collator, train_datasettokenized_dataset[train], ) trainer.train() trainer.save_model(./finbert-qa-checkpoint/final) tokenizer.save_pretrained(./finbert-qa-checkpoint/final)这段代码里有三个关键参数值得说明。mlm_probability0.10是上面提到的掩码比例调整如果你的语料里术语密度不高保持 0.15 也可以但金融文本建议先压到 0.12 以下试一轮看下游 QA 任务的提升幅度再决定。gradient_accumulation_steps4是为了在小显存上模拟较大的 batchBERT 类模型的 MLM 任务对 batch size 比较敏感太小容易震荡。fp16True能省一半显存但注意如果你的数据里有大量长文本fp16 的梯度溢出概率会增加需要把loss_scale设为动态。关于资源预估一张 24G 显存的 3090 或 A5000处理 2 GB 左右的金融语料、训练 3 个 epoch大约需要 12-20 小时。如果公司只有 16G 显存的卡可以把max_length降到 384batch size 降到 4时间会翻倍但基本能跑。如果连单卡 16G 都不满足建议直接用云端按小时租卡不必自己掏卡。3. 做一份能用的金融问答数据从抽取式 QA 到生成式 QA 的过渡方案继续预训练只是第一步FinBERT-QA 的“QA”才是交付价值的环节。金融领域问答和通用问答最大的区别是答案常常藏在半结构化文本里比如“2023 年营收是多少”这样的问题答案是一个数字但它散落在年报的“主要会计数据”表格里而不是一句完整的话里。所以数据构造决定了模型能力的上限。3.1 第一优先用 SQuAD 格式构造抽取式问答数据我自己的经验是金融问答第一版不要直接上生成式先做一个抽取式 QA。原因是抽取式任务的评估指标EM、F1清晰答案可控出错时容易定位问题。SQuAD 格式是最常见的选择。下面是一个金融领域的 SQuAD 格式样本把问题和答案对齐到原文{ context: 报告期内公司实现营业收入 12.53 亿元同比增长 18.2%归属于上市公司股东的净利润为 1.86 亿元同比下降 5.4%。经营活动产生的现金流量净额为 2.31 亿元。, qas: [ { question: 公司报告期内的营业收入是多少, id: fin_0001_01, answers: [ { text: 12.53 亿元, answer_start: 10 } ] }, { question: 归属于上市公司股东的净利润同比变化如何, id: fin_0001_02, answers: [ { text: 下降 5.4%, answer_start: 43 } ] } ] }构造这份数据的实操建议是不要让人从头标注太慢。先写一个正则规则从年报里抽取“营业收入、净利润、毛利率、资产负债率”等关键指标及其数值再用规则自动生成问题模板最后让人工做一遍校验。模板的多样性决定了模型的泛化能力问题里尽量包含“多少、同比、变化、占比”等多种问法而不是统一用“XX 是多少”。3.2 提高金融 QA 数据质量的四个细节数字归一化、实体对齐、跨句答案、否定问题构造数据时有四类金融特有的情况需要处理否则模型训练出来会很“虚”。数字归一化金融文本里“12.53 亿元”和“1,253,000,000 元”是同一个值。最好在构造数据前把数字统一成“数值单位”的格式否则模型永远学不会“等值判断”。实体对齐如果你后续要接知识图谱或结构化数据答案文本的边界要和实体对齐。比如“公司”这个词在问答里经常是对“XX 股份有限公司”的指代最好在数据里映射到正式的实体名。跨句答案很多金融问题要跨句子才能找到答案比如“公司去年营收多少”答案可能在前面段落描述了营收后面段落给了同比。这种数据要主动构造一部分否则模型只会做单句匹配。否定问题金融文本里“不存在减值风险”“未发生重大变化”这类否定表达很常见。数据里必须包含否定问题如“公司是否存在商誉减值风险”否则模型会把所有“减值”都当成“有风险”。3.3 从抽取式到生成式的过渡数据增强与模型输出控制如果你的业务最终需要生成式答案比如“请简述公司的偿债能力”那要走的路径是先用抽取式模型输出短答案片段再把这些片段拼进模板形成“可解释的回答”。这一招比直接上生成式模型稳妥得多。生成式模型在金融领域最大的问题是“一本正经地胡说八道”如果模型没有足够强的领域约束很容易输出“公司经营状况良好无重大风险”这类空话——而这句话在严格的合规审查里可能直接引发事故。我的常见做法是抽取式 FinBERT 作为主干输出答案片段和置信度然后用一个简单的规则或小模型把片段组织成通顺的句子。等数据积累到 5 万条以上再考虑用 BART 或 T5 做生成式微调这时候模型已经有足够的抽取式先验幻觉概率会低很多。4. 用 BERT 跑通金融问答训练脚本、推理函数与参数边界数据准备好之后就可以进入 FinBERT-QA 的核心训练环节。这里最需要注意的一点是你用来继续预训练的底座和用来微调 QA 的底座必须是同一个。很多人先下载了 BERT-base-Chinese 做继续预训练微调时又换成了另一个模型导致词表不匹配tokenizer 割出来的 token 完全对不上模型等于从零学。这个坑我在实际项目里反复见到下面会详细讲怎么避开。4.1 最小可用的 QA 微调训练脚本这里用 Google 官方 BERT 的run_squad.py思路简化成一段管理起来更清晰的代码前提是你把数据转成了 Hugging Face 的datasets.Dataset格式。import torch from transformers import ( AutoTokenizer, AutoModelForQuestionAnswering, TrainingArguments, Trainer, ) from datasets import load_dataset # 关键这个路径必须和继续预训练产出的权重一致 model_path ./finbert-qa-checkpoint/final tokenizer AutoTokenizer.from_pretrained(model_path) model AutoModelForQuestionAnswering.from_pretrained(model_path) # 读取已转好的 SQuAD 格式数据 raw_dataset load_dataset(json, data_files./fin_qa_train.json) train_data raw_dataset[train] def preprocess_train(examples): inputs tokenizer( examples[question], examples[context], truncationonly_second, max_length384, stride128, paddingmax_length, return_offsets_mappingTrue, ) offset_mapping inputs.pop(offset_mapping) start_positions [] end_positions [] for i, (offset, answer) in enumerate(zip(offset_mapping, examples[answers])): start_char answer[answer_start][0] end_char start_char len(answer[text][0]) seq_ids inputs.sequence_ids(i) # 找到 context 在 token 序列里的起止位置 context_start 0 context_end len(seq_ids) - 1 while seq_ids[context_start] ! 1: context_start 1 while seq_ids[context_end] ! 1: context_end - 1 # 答案不在当前片段内置为 CLS 位置让模型学习“无答案” if not (offset[context_start][0] start_char and offset[context_end][1] end_char): start_positions.append(0) end_positions.append(0) else: idx_start context_start idx_end context_end while idx_start idx_end and offset[idx_start][0] start_char: idx_start 1 start_positions.append(idx_start - 1) while idx_end idx_start and offset[idx_end][1] end_char: idx_end - 1 end_positions.append(idx_end 1) inputs[start_positions] start_positions inputs[end_positions] end_positions return inputs tokenized_train train_data.map( preprocess_train, batchedTrue, remove_columnstrain_data.column_names, )这段代码的核心在offset_mapping的使用。BERT 的 tokenizer 会把一个中文字切成一个 token但英文和数字可能被切碎成多个 token所以不能用字符位置直接映射到 token 位置。offset_mapping记录的就是每个 token 对应原始文本的字符区间通过它找到答案在 token 序列里的起止位置。truncationonly_second这里很关键意思是超长时只截断 context 不截断 question保证问题信息不丢失。训练参数我的默认配置是learning_rate3e-5batch_size16warmup_ratio0.1num_train_epochs3。这个配置在大多数金融 QA 数据上都能给出不错的效果。如果数据量少于 1 万条建议把 epoch 提到 5学习率降到 2e-5防止欠拟合如果数据量超过 5 万条epoch 降到 2 就够再训多容易过拟合到训练集的提问方式。4.2 推理函数一个能直接接进业务的 QA 函数训练完之后要拿出去用这里给一个推理函数适合放到 Flask 或 FastAPI 服务里。def answer_question(question: str, context: str, model, tokenizer, top_k: int 3): inputs tokenizer( question, context, truncationonly_second, max_length384, stride128, return_tensorspt, return_offsets_mappingTrue, ) offset_mapping inputs.pop(offset_mapping) with torch.no_grad(): outputs model(**inputs) start_logits outputs.start_logits[0] end_logits outputs.end_logits[0] # 排除 padding token 和 CLS 位置限制答案长度在 3-30 个 token 之间 invalid_start torch.zeros_like(start_logits) invalid_end torch.zeros_like(end_logits) start torch.argmax(start_logits, dim-1) end torch.argmax(end_logits, dim-1) # 过滤掉 start end 的情况BERT 输出不稳定时常见 if start end: start, end end, start answer_start_char offset_mapping[0][start][0] answer_end_char offset_mapping[0][end][1] answer context[answer_start_char:answer_end_char] confidence torch.softmax(start_logits, dim-1)[start] * torch.softmax(end_logits, dim-1)[end] return {answer: answer, confidence: float(confidence)}这个推理函数里藏着两个金融问答特有的处理。第一个是start end的情况BERT 在长文本上经常预测出答案起点在终点之后直接返回空串会浪费一次请求我一般把它纠正为 start 和 end 互换虽然这个答案可能不太准但至少不会让调用方因为字段缺失而报错。第二个是confidence的计算用 start 和 end 的 softmax 概率乘积来估计可信度低于 0.5 时建议让业务侧走“无法回答”的兜底流程而不是把可能错误的答案直接展示给用户。4.3 参数边界什么情况调什么别一上来就调参很多新手一跑完训练就急着开调参工具其实大多数金融 QA 场景根本到不了那个程度。我建议先看三个指标验证集 EM精确匹配率、F1、以及预测错误样本的类型分布。如果 EM 低但 F1 高说明答案边界基本对但总是多一两个字这时调的不是模型而是答案后处理比如去掉“约为、人民币”等前缀。如果 F1 也低才需要看是不是数据问题。BERT 类 QA 模型的性能瓶颈通常在数据而不是模型。数据量、问题多样性、答案边界标注的一致性这三者的优先级永远高于学习率。调参只是最后一步。5. FinBERT-QA 的四个常见坑现象、原因与修复跑了这么多 FinBERT-QA 项目真正让我花时间去排查的往往不是模型结构而是工程链条上的细节问题。下面这四条是最常出现的按“现象→原因→解决”记录。5.1 微调时所有答案都指向同一个 token现象验证集上所有预测的 start 和 end 都趋近于同一个位置F1 极低看起来模型没有学到任何东西。原因80% 的情况是 label 计算错了。上一节代码里如果answer_start是字符位置而你没有做 offset mapping 转换就会出现全部 label 偏移到同一个或附近的 token 上。还有一个隐蔽原因answer_start所在的 context 位置和 question 的位置混了特别是当 question 里出现了和 answer 相同的文本时模型会倾向从 question 里找答案。解决先检查训练集里任取 100 条的预测 start 和 label start 的对应关系看差值是否恒定如果恒定直接修正 offset如果不恒定检查sequence_ids的判断逻辑确认 context 的 token 范围没有把 question 包进去。5.2 继续预训练后微调 QA效果反而不如不预训练现象加了金融语料继续预训练的模型QA 的 EM/F1 比直接用通用 BERT 还低。原因典型的灾难性遗忘或语料不匹配。继续预训练时学习率太高通用能力被冲刷掉或者语料里都是热门财经新闻而你下游要做的是年报问答语料分布和任务分布差距太大继续预训练帮了倒忙。解决把继续预训练的学习率降到 2e-5 以下并把通用语料和金融语料按一定比例混合比如金融语料 80%、通用语料 20%这样模型不会丢掉通用能力。如果你已经训完了还有一个后悔药用继续预训练前的 BERT 权重初始化再做一次微调看效果差异判断是预训练的问题还是 QA 微调的问题。5.3 长文档问答时回答出现在无关段落现象给模型一篇 50 页的年报问“公司 2022 年主营业务收入是多少”模型回答出来的是 2021 年的数据或者干脆答出另一家子公司的数据。原因BERT 的 max length 是 512 token长文档必须切段。切段方式决定了答案是否在片段内。上一节代码里stride128是片段重叠长度但如果切片是直接从第 0 个 token 开始的而答案在第 3000 个 token 处模型根本看不到答案只能靠猜。解决不能把整篇文档喂进去。常见做法是用检索模块先召回候选段落再把候选段落作为 context 给 QA 模型。召回粒度控制在每段 200-400 字比较合适。还有一个务实方案把文档的标题、章节号拼接进每个片段开头比如“【第三章 经营情况讨论与分析】”这样模型至少知道这段话在讲什么。5.4 GPU 显存不够微调直接 OOM现象用 16G 显存的卡训练batch size 设为 16刚跑两步就 OOM。原因BERT-base 是 110M 参数QA 模型在AutoModelForQuestionAnswering里还必须同时保存 start/end 两个分类头的梯度显存占用比序列标注和分类都高。加上金融文本长度大多是 400-500 tokenpadding 后显存直接爆。解决不只是调 batch size。第一把max_length从 384 降到 256 或 128大多数金融问答的答案片段并不长长度限制是为了切长文档不是训练必需。第二开启gradient_accumulation_steps把 batch size 降到 4用梯度累积补足。第三检查是否开了 fp16如果没开V100 以上建议开启显存占用能降 30% 以上。6. 落地验证用自己的金融数据评测 FinBERT-QA 的边界FinBERT-QA 的价值不能只看公开数据集上的指标关键要看它在你自己的数据上能不能用。下面这套验证方法是直接基于业务场景设计的。6.1 用三类数据做边界评估盲目地拿一个测试集跑一遍不够我一般会把验证数据分成三类长度适中的标准 QA200 字以内的 context、需要跨段推理的 QA长文档、以及包含否定表达或数字归一化的 QA。这三类数据分别评估才知道模型适合什么场景。标准 QA 上 F1 低于 75%说明整体方案有问题跨段推理的 QA 低于 60% 很正常因为 BERT 类模型本质上不擅长跨段推理否定表达的正确率如果低于 80%则要在微调数据里补充更多否定样本。6.2 用消融实验验证“继续预训练QA 微调”的真实收益有一个常被忽略的验证点继续预训练到底带来了多少提升做法很简单用同一个 QA 训练数据分别用通用 BERT 和 FinBERT-QA 底座训练两份模型在同一测试集上对比。很多项目做完这个对比后我发现继续预训练的收益在 1-3 个 F1 点之间并未出现“换一个底座就起飞”的效果。这个结论对于要不要投入更大算力去继续预训练其实是有决策价值的。6.3 自己动手跑一遍最小评测脚本from transformers import AutoModelForQuestionAnswering, AutoTokenizer from datasets import load_dataset # 加载训练好的 FinBERT-QA model_path ./finbert-qa-checkpoint/final tokenizer AutoTokenizer.from_pretrained(model_path) model AutoModelForQuestionAnswering.from_pretrained(model_path) # 加载自己的评测集格式和训练数据一致 eval_data load_dataset(json, data_files./fin_qa_eval.json)[train] def evaluate(model, tokenizer, eval_data): em 0 f1_total 0 total len(eval_data) for item in eval_data: context item[context] question item[question] pred answer_question(question, context, model, tokenizer, top_k1) gold item[answers][text][0] pred_text pred[answer].strip() # EM完全匹配才算 if pred_text gold: em 1 # F1按字符集合计算金融场景对数字敏感所以不用 token F1 gold_chars set(gold) pred_chars set(pred_text) intersection gold_chars pred_chars if len(gold_chars) 0 or len(pred_chars) 0: f1_total 0 else: precision len(intersection) / len(pred_chars) recall len(intersection) / len(gold_chars) f1_total 2 * precision * recall / (precision recall) return {EM: em / total * 100, F1: f1_total / total * 100} print(evaluate(model, tokenizer, eval_data))这段脚本里我特意用了字符级集合 F1而不是 Hugging Face 官方默认的 token 级 F1。金融场景里“亿元”和“元”是两个完全不同的量级token 级评估会把它们算成部分匹配字符级能更严格地反映出数字单位是否答对。缺点是中文文本字符集合式计算出的 F1 会偏低但这个偏低是真实的提前暴露问题比上线后才发现好得多。以我的项目经验FinBERT-QA 在金融领域的可行性已经被反复验证但它不是银弹。对于“这个数字是多少”这类事实量抽取预训练 BERT 的稳定性和可控性比大语言模型更可靠因为它能给出原文位置来溯源输出边界清晰。如果只是要求“说得像人话”那大语言模型做得更好但那已经不是 BERT 的边界了。我的习惯是FinBERT-QA 做第一层问答和证据抽取输出答案片段和置信度置信度过低时再交给大语言模型生成补充表述并在界面里附上原文引用。这样既能保证关键数字不出错又能让回答有温度。如果你刚开始做这个方向我建议先别急着训练拿一个已经微调好的 QA 模型跑通你手头的 100 条真实金融问答观察错误分布再去决定要不要投入继续预训练的算力。贝叶斯一点说用一个星期数据验证替你把两个月时间省下来希望帮到你。本文还有配套的精品资源点击获取