首页 / 资讯中心 / 文章详情

FinBERT-QA实战:基于BERT微调与检索增强的金融问答系统

FinBERT-QA实战:基于BERT微调与检索增强的金融问答系统 ★ FEATURED ARTICLE
简介FinBERT-QA是一套面向金融领域问答的完整解决方案基于预训练语言模型微调结合检索引擎检索与候选段落重排序技术适用于信息检索、自然语言处理及金融文本分析方向的研究者与开发者。资源包共六十二个文件类型涵盖二十三个序列化格式的模型与中间数据、九个Python源码脚本含训练、评估、预测与数据处理模块、四个表格数据文件、两个交互式分析文档并配有容器部署文件、依赖清单与使用说明便于快速复现环境。压缩包约一百四十二点七一兆字节目录按检索、源码、数据、笔记等模块划分结构清晰可直接对照学习整个问答流水线从检索引擎检索前五十候选到语言模型重排序并输出答案。该方案针对金融问答数据集任务二采用迁移与自适应策略先在通用问答任务上微调模型再通过金融数据适配场景使三项排名评估指标平均提升约百分之二十。目前已有两千余人学习浏览适合需要完整基线、研究迁移学习方法或快速搭建金融问答原型的读者获取全套代码、数据处理脚本、模型与评估工具。1. 一段话讲清 FinBERT-QA金融问答不是「问一句答一句」一个做金融数据审核的开发者跟我抱怨过研报里写「公司三季度净利润同比下滑 18%」业务人员真正想知道的是「同比基期是多少」「这个数字出现在财报原文哪一段」。他先后拿通用问答模型试过十次有七八次答非所问不是模型不够强是通用模型见过的财报句式和术语太少。FinBERT-QA 这个项目解决的就是这件事用预训练的 BERT 语言模型做底座在金融语料上做 fine-tune前置一个检索器把候选段落先找回来把问答压成「从候选段里抽取答案片段」这个任务而不是让模型自由发挥。适合正在做金融文档问答、研报摘要、财报字段抽取的从业者也适合刚想摸清问答任务完整 pipeline 怎么落地的同学。2. 为什么选择「预训练 微调」做金融问答领域偏移与两段式结构预训练 BERT 语言模型在大规模通用文本上学习的其实是语言结构词序、指代、句法关系。这些底层能力迁移到金融领域依然有效但问题是「语言结构」和「答案在哪」之间还隔着一层领域知识。金融文本里的关键信息藏在数字、百分比、相对年份和特定术语里BERT 的 tokenizer 会把「18.7%」拆成 18、.、7、% 四个 token模型的 attention 对这类数字 token 的语义敏感度天生就低于对「人名、地名、组织名」的敏感度因为它在预训练阶段见到的实体大多是通用实体。这就是领域偏移的根源。在 SQuAD 上 fine-tune 出来的通用问答模型答案分布偏向人名地名组织名而金融问答的答案绝大多数是数字、期限、口径名称。你拿它去问「这家公司去年的 EBITDA margin 是多少」文档里写的是「EBITDA 率从上年的 32.1% 降至 28.4%」通用模型可能回你「比上年下降 3.7 个百分点」——它学会了比较句的局部模式却没理解「去年的 32.1%」才是问题的锚点。所以要单独在金融语料上微调这正是 FinBERT-QA 这套资源的核心价值。下面分三个点把它的结构选型讲透。2.1 领域偏移为什么通用问答模型在财报上翻车领域偏移的具体表现有三个层面词汇层、句式层、答案分布层。词汇层最好理解金融文档里充斥着 EBITDA、Non-GAAP、经营性现金流、摊销、计提这类词。BERT 的预训练词表虽然覆盖这些词但模型对它们语义关系的建模很浅。例如「EBITDA margin 从上年的 32.1% 降至 28.4%」这句话模型未必不知道 EBITDA 是术语但它不确定「margin」和「比率」是同一件事。通用语料的预训练不会让模型建立这种金融概念映射。句式层更隐蔽。金融文本大量使用「相较于上年同期」「剔除XX因素后」「以报告期末汇率折算」这类相对表达。问题问的是「去年基数」答案在原文里却是「上年同期数人民币 12.3 亿元」这种写法。通用 SQuAD 模型见过的是「谁在哪一年完成了什么」这种叙事结构面对「同比、环比、期初、期末」这类带有计算关系的句式时span 定位能力会明显退化。答案分布层的差异最要命。SQuAD 的答案通常是一个名词短语或一个人名金融问答的答案是「18.7%」「2023 财年」「人民币 4.2 亿元」。数字 token 在 BERT 中不是独立词表项而是被拆成子词比如「4.2 亿」在英文 uncased 词表里会被拆成 4、.、2、亿这种拆分让模型很难对「数值 单位 口径」这个整体建立稳定的表示。我拆过不少这类项目最后发现最有效的办法就是在目标领域数据上重新微调让模型重新学习「数字片段也是答案」这件事。2.2 抽取式问答为何是金融文档场景更稳的选择先摆一张三种问答形式的对比这个表决定了你拿到资源之后用哪个训练入口。| 类别 | 输出形式 | 典型模型 | 金融场景优点 | 金融场景缺点 | | --- | --- | --- | --- | --- | | 抽取式 span extraction | 答案必须是原文的一段连续文本 | BERT QA Head | 答案可溯源适合合规审计 | 不能生成原文没有的说法 | | 生成式 generative | 模型自己组织自然语言 | T5、LLM | 回答灵活、可以综合多段信息 | 容易幻觉编造数字不可控 | | 多选式 multiple choice | 从候选选项里选一个 | BERT 分类头 | 结构清晰、评估简单 | 需要先构造候选选项落地成本高 |生成式问答在金融场景里最让人担心的是幻觉。模型读到的 EBITDA 是 28.4%生成时可能写成 24.8%这种错误在审计场景是不可接受的。抽取式模型天生没有编造能力它只从给定 context 里选一个连续片段答案再离谱也能回溯到原文位置。FinBERT-QA 采用的正是抽取式配合一个 BERT 阅读器reader结构。很多初学者会问为什么不直接用生成式大模型因为在文档合规、投研审核这类场景里「答案可审计」比「回答流畅」优先级高得多。你可以接受一个回答只给出一段原文摘录但不能接受模型把数字说错还说得理直气壮。抽取式问答在信息检索information-retrieval链路里还有一个天然优势它可以无缝接到检索器后面检索器给出候选段落阅读器从候选段中抽取答案两段式结构清晰每一段都能单独验证效果。这也是我在类似项目里最常用的方案。2.3 FiQA 数据集规模、字段与微调样本的构造逻辑FiQA 是金融领域问答 benchmark 里常用的一个规模不大几千个问题对应一批真实金融文本。它的价值在于语料是真实的新闻、公告、分析观点问题也是业务侧的实际提问方式而不是从文本里倒推出来的假问题这比拿 SQuAD 硬套金融文本可靠得多。数据包里一份典型样本通常包含 question、document id、evidence 三个关键字段。question 是自然语言问题document id 指向原始文档evidence 是答案所在的原文片段或支持观点。做 BERT fine-tune 的时候核心工作是把 evidence 与 document 对齐成「context 中某段连续文本」的标注形式。麻烦在于 evidence 经常是「支持整个判断的段落」而不是一个直接答案所以预处理时要么用 answer 字符串在原文里 find 起点要么把 evidence 整段当作 context、答案位置在段落内部继续精确定位。这两种方式的训练效果差别很大后面「避坑记录」里专门有一条讲这个。提示拿到数据包先别急着写训练脚本优先确认「答案是不是永远来自原文连续片段」。如果有些问题没有标准答案你需要先决定是过滤还是单独建模这个决定会在评估阶段直接影响 EM 和 F1。3. 环境搭建与数据预处理把原始 JSON 变成 BERT 能吃的训练样本这一章解决从零到能跑训练的问题。FinBERT-QA 的工程主体是 Python 生态依赖不算多但版本组合要固定。该资源对应的训练脚本通常基于 transformers 和 datasets 库我用的是下面这一套版本组合跑过多个类似项目稳定性足够。3.1 环境配置torch-transformers 版本组合与 CUDA 层python -m venv finbert-qa source finbert-qa/bin/activate pip install torch2.1.0 transformers4.35.0 datasets2.15.0 rank_bm250.2.2 tqdm先解释为什么把版本写死。transformers 4.35 之后 Trainer 的参数有过调整老训练脚本容易遇到已移除的参数报错datasets 2.15 的Dataset.from_dict行为和新版差异不大但 cache 机制有变化固定版本能保证你照着资源里的脚本跑不会无故翻车。torch 2.1 对 fp16 的支持比 1.x 时代稳定得多在 8G 显存上训练小模型时fp16 开关值得开。我一般会建议第一步先跑一个极小的冒烟测试加载bert-base-uncasedtokenize 一句话打印 input_ids 和 attention_mask确认 CUDA 可用。这一步花两分钟能排除一半的环境问题。如果显存只有 6G 左右训练 batch 需要调小或者用 CPU 训练做样例验证——训练可以等 GPU但环境验证不要上 GPU 才做。3.2 数据预处理把 QA 样本对齐到 token offsetFiQA 原始数据通常是 JSON 结构questions 数组里挂 document_iddocuments 用字典存文本evidence 在 question 内部。第一步是把它们摊平成「一问题对一段落」的训练样本。直接看代码。import json from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(bert-base-uncased) def preprocess_fiqa(raw, max_len384): samples [] for q in raw[questions]: doc_id q.get(document_id) or q.get(doc_id) context raw[documents].get(str(doc_id), ) if not context: continue evidences q.get(evidence, []) for ev in evidences: answer ev.get(answer, ) start context.find(answer) if start 0: # answer 可能是证据里的观点而不是原文明文先记录不处理 continue samples.append({ question: q[question], context: context, answer: answer, start: start, }) return samples这里有两个容易被忽略的地方。第一answer 在原文里可能出现了多次str.find返回的是第一次出现的位置不一定是 evidence 指向的那个位置。如果你的数据包里有证据段落本身我更推荐先在 evidence 段落里定位再把 evidence 在 document 里的偏移加上这样避免多义答案定位错。第二start 是字符偏移不能直接当 BERT 的 start_position 用因为 tokenizer 会把「18.7%」拆成 18、.、7、% 多个 token字符位置和 token 位置之间的换算必须用 offset mapping。这一步换算错训练时 loss 不会报错但模型永远学不对答案起点。def encode_sample(sample, tokenizer, max_len384): q sample[question] c sample[context] enc tokenizer( q, c, max_lengthmax_len, truncationonly_second, return_offsets_mappingTrue, paddingmax_length, ) start_char sample[start] end_char start_char len(sample[answer]) offsets enc[offset_mapping] start_token -1 end_token -1 for i, (s, e) in enumerate(offsets): if s 0 and e 0: continue if start_token -1 and s start_char e: start_token i if end_token -1 and s end_char e: end_token i break return { input_ids: enc[input_ids], attention_mask: enc[attention_mask], token_type_ids: enc[token_type_ids], start_positions: start_token, end_positions: end_token, }这段代码的逻辑说明truncationonly_second表示只截断 context 而保留 question金融问题本身的长度有限答案都在文档里所以优先保住文档上下文。offset mapping 是 tokenizer 返回的字符区间列表特殊 token 如 [CLS]、[SEP] 的区间是 0,0要跳过。start_token 取「第一个覆盖 start_char 的 token」end_token 取「第一个覆盖 end_char 的 token」这样答案的边界不会落到 token 中间。注意 end_token 是闭区间包含的给模型做 loss 时直接用它当 end 标签。预处理完我习惯顺手统计一下 token 长度分布。如果大量样本的 input_ids 都顶到 384说明 max_len 不够或者 context 选得太长需要调整后面的检索参数而不是硬着头皮训练。4. 落地实现BM25 检索 BERT 阅读器的两段式训练两段式结构的核心动机是BERT 的输入长度上限是 512 token金融文档动辄几千字直接把整篇文档喂给模型既不现实也没必要。先让检索器retriever从文档库里找出与问题最相关的 10 到 20 个段落再用 BERT 阅读器在这些候选段里精读找答案。这个思路在信息检索领域里叫「粗排 精排」问答系统里叫 retriever-readerFinBERT-QA 的资源里同时包含这两部分的训练与推理脚本。4.1 检索器构建 BM25 索引并控制 top_k检索器我用 rank_bm25 库纯 Python 实现不需要额外服务适合资源自带的脚本直接跑。BM25 是传统词频统计方法虽然没有语义理解能力但在金融领域有一个隐藏优势术语高度固定措辞变体少「EBITDA」在问题和文档里通常都是同一个词词频统计的召回效果并不差而且完全可解释。from rank_bm25 import BM25Okapi def tokenize_en(text): return text.lower().split() def build_bm25_index(doc_id_list, doc_texts): tokenized [tokenize_en(t) for t in doc_texts] return BM25Okapi(tokenized), tokenized def retrieve_top_k(bm25, tokenized_docs, question, doc_id_list, top_k20): q_tokens tokenize_en(question) scores bm25.get_scores(q_tokens) order sorted(range(len(scores)), keylambda i: scores[i], reverseTrue) results [] for idx in order[:top_k]: results.append({ doc_id: doc_id_list[idx], text: tokenized_docs[idx][:1], # 占位 score: scores[idx], }) return resultsBM25 的 k1 和 b 参数控制词频饱和度和文档长度归一化。默认 k11.5、b0.75 在绝大多数场景下表现稳定金融文档长短差异大的时候可以把 b 调到 0.5降低长文本惩罚。真正影响效果的是 top_k。top_k 太大喂给 BERT 的候选段落多总 token 数容易超过长度限制而且相似段落多了会让阅读器分散注意力top_k 太小正确答案可能根本不在候选里后面怎么微调都白搭。我一般用验证集做一次召回率检查跑一批问题看正确答案出现在 top_k 候选里的比例低于 90% 就加大 top_k高于 95% 再考虑缩小。如果你不想用 BM25资源里也常见 dense retriever 方案用 sentence transformer 或 BERT 的 [CLS] 向量做余弦相似度检索。但 dense retriever 需要额外训练或加载大型向量模型在小规模 FiQA 数据上收益往往不明显BM25 足够先跑通。这是典型的「先简单后复杂」路线别一上来就上向量检索。4.2 阅读器微调从预训练 BERT 语言模型到金融 QA 模型检索器负责把范围缩小阅读器负责真正回答问题。阅读器就是在 BERT 预训练权重上叠一个 QA 输出头start logits end logits用金融问答样本做 fine-tune。训练代码直接看。from transformers import ( AutoModelForQuestionAnswering, AutoTokenizer, Trainer, TrainingArguments, ) model AutoModelForQuestionAnswering.from_pretrained(bert-base-uncased) tokenizer AutoTokenizer.from_pretrained(bert-base-uncased) training_args TrainingArguments( output_dir./finbert-qa-out, learning_rate3e-5, per_device_train_batch_size8, per_device_eval_batch_size16, gradient_accumulation_steps2, num_train_epochs3, warmup_ratio0.1, fp16True, evaluation_strategyepoch, save_strategyepoch, logging_steps20, )参数怎么改说几个实打实的结论。learning_rate 2e-5 到 5e-5 是 BERT 微调的常见区间3e-5 起步不要直接冲 5e-5金融数据量不大学习率稍高容易在验证集上震荡。per_device_train_batch_size 由显存决定8G 显存用 8 没问题配 gradient_accumulation_steps2 等效 batch size 16梯度稳定性够用。warmup_ratio 0.1 是最常见的配置让前 10% 的 step 学习率线性爬升能避免前期 loss 剧烈跳动。fp16 在 Ampere 及之后的卡上提速明显但如果你的环境没有 CUDA 或者卡比较老这个开关要关掉否则训练直接报错。数据传进 Trainer 之前需要把预处理后的字典转成 datasets 格式。import torch from datasets import Dataset def build_train_dataset(samples, tokenizer): encoded [encode_sample(s, tokenizer) for s in samples] valid [e for e in encoded if e[start_positions] 0] return Dataset.from_dict({ input_ids: [e[input_ids] for e in valid], attention_mask: [e[attention_mask] for e in valid], token_type_ids: [e[token_type_ids] for e in valid], start_positions: [e[start_positions] for e in valid], end_positions: [e[end_positions] for e in valid], }) train_dataset build_train_dataset(train_samples, tokenizer) eval_dataset build_train_dataset(eval_samples, tokenizer) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, ) trainer.train()注意build_train_dataset里我过滤了所有 start_positions 为 -1 的样本。前面预处理时如果答案在原文中定位不到start 会保持 -1这种样本如果不过滤训练时模型会拿 -1 当标签loss 立刻爆炸。过滤比例如果超过 10%说明答案定位逻辑有问题先回头检查 evidence 字段而不是直接丢弃。训练结束后模型保存在 output_dirtrainer.save_model()会同时保存模型权重和 tokenizer。推理时重点在于把模型输出的 start/end logits 转回文本这一步的做法和训练略有不同预测时没有标准答案要从 start logits 和 end logits 里联合选一个合法区间常见做法是限制 end start并且选择概率和最大的片段。5. 避坑记录跑完训练才发现数据对齐的问题最伤时间这个章节列几条我在拆这个资源时实际踩过的坑全部是「现象 → 原因 → 解决」结构。每一条都花过不止一个下午排查写出来帮你省点时间。5.1 坑answer.start 和 token offset 交错后 loss 不降现象训练正常启动loss 从两位数缓慢下降但到第二个 epoch 还在 5 以上验证集的 EM 接近 0。看起来模型在学但实际什么都没学会。原因预处理时直接把字符级别的 start 和 end 当作 token 级别标签传给了模型。BERT 的 start_position 是 token index不是字符 index。比如「18.7%」占 4 个字符但 5 个 token差一位就够让所有预测偏移模型学到的是「永远预测一个错位的答案」。解决强制在预处理里使用 offset_mapping把字符区间换算成 token 区间。换算之后打印三五个样本核对tokenizer.decode(input_ids[start:end1])必须等于原答案字符串。我后来把这个校验直接写进了预处理脚本跑完自动输出一行核对结果再也不是黑匣子。5.2 坑max_len384 的截断裁掉了答案尾巴现象验证集 F1 还行EM 奇低人工检查发现预测答案往往比标准答案少几个字符甚至少半句话。原因context 太长答案文本刚好落在第 384 个 token 之后被截断了。BERT 的 max_len 限制不允许无限输入截断策略默认是保留开头尾部答案直接消失。特别容易出现在答案位于文档中后段的样本里。解决两处配合。第一检索器 top_k 从 20 降到 10候选段落更短答案落在截断区外的概率降低。第二训练时采用滑动窗口把长 context 切成有重叠的片段每个片段长度 384stride 64这样位于任意位置的答案都有机会完整出现在某个窗口内。预测时多个窗口可能给出多个候选答案用 start logits end logits 的联合概率取最大者。5.3 坑FiQA 部分问题标注的 evidence 与 question 对不上现象训练 loss 正常收敛验证 F1 也不差但人工抽查时模型答非所问答案文本和问题完全不沾边。原因FiQA 的 evidence 字段有时是「支持某一观点」的整段文字不是「直接回答该问题」的句子。字符串定位时如果问题答案恰好是一段观点总结模型学到的映射就是「找一段大而全的文本」而不是「精确定位答案」。这个问题在原始数据里存在一定比例属于数据噪声。解决预处理时增加人工抽检环节随机抽 50 条样本打印 question、context、answer 三者的对应关系。发现 evidence 与问题相关性弱的样本优先换用文档中实际包含答案文本的句子重新定位而不是删掉整条样本。如果这类噪声超过 5%建议在预处理阶段就把它们过滤掉否则模型会被带偏。5.4 坑老训练脚本在 transformers 新版本上 API 报错现象照着资源里的脚本启动训练Trainer 直接报 TypeError报错信息指向evaluation_strategy或save_strategy参数。原因transformers 新版本里这几个参数的判定逻辑有调整旧脚本使用的参数组合在某些版本下不再兼容。版本变化带来的 API 波动在 NLP 项目里很常见不是你的代码写错了。解决固定版本安装像我第 3 章里写的transformers4.35.0不要用最新版。如果已经装了新版本查看当前版本的参数说明把evaluation_strategy改成eval_strategy这类新参数名。排查这一类问题最快的方式是直接看 traceback 指向的参数名然后在官方文档里搜比猜有效率。6. 验证与进阶从 EM/F1 到答案可审计性训练完成之后验证环节决定了这个模型能否真正上线。常规跑法是用 SQuAD 评估脚本算 EM 和 F1但对金融问答来说这两个数字只是起点。from evaluate import load squad_metric load(squad) predictions [ {id: test_0, prediction_text: 28.4%, no_answer_probability: 0.0} ] references [ {id: test_0, answers: {text: [28.4%], answer_start: [1023]}} ] results squad_metric.compute(predictionspredictions, referencesreferences) print(results)EM 对金融场景的重要性远高于 F1。合规审计要的是「原文里有这句话」而不是「意思差不多」一句「比上年下降」和「上年为」在语义上接近但在数字核对里就是两个答案。F1 高而 EM 低的情况多发生在百分比、日期这类需要精确定位的答案上这时候我会单独看错误样本把答错的预测分成数字取错、实体取错、边界判断错三类看哪一类占比最高再决定要不要加规则后处理。进阶方向有一个很实用的做法把微调后的 BERT 模型蒸馏到更小的结构上。金融场景往往需要私有化部署bert-base 的 110M 参数在 CPU 推理时速度不够用 distilbert 做 student 模型在 FiQA 数据上再蒸馏一轮参数量缩一半F1 通常只掉 1 到 3 个点推理速度却能提升接近一倍。另一个方向是 ONNX Runtime 量化动态量化后 CPU 推理提速明显代价同样是少量精度损失适合对延迟敏感的低并发场景。还有一个我强烈建议做的习惯评估完把错误样本打印出来随机看 20 条不要只看指标。指标只能告诉你模型好不好但看错误样本能告诉你模型哪里不好。数字取错多半是答案边界判断问题实体取错可能是检索器没召回正确段落边界判断错往往是 context 里多个相似实体干扰。这些观察比 F1 涨跌一个点有价值得多。从那以后我每次跑 FiQA 微调都会把「start 转 token offset、top_k 召回率检查、截断窗口 overlap」这三个点写进检查清单训练前先跑 20 条样本的小批量确认 loss 在降再放全量数据。这套流程在 FinBERT-QA 上适用换到其他领域问答项目同样成立。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站