ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

金融领域抽取式问答实战:FinBERT-QA的预训练、微调与负样本采样

金融领域抽取式问答实战:FinBERT-QA的预训练、微调与负样本采样 简介FinBERT-QA 是一套面向金融领域问答检索的完整开源实现适合具备自然语言处理与信息检索基础的研究者、算法工程师及金融科技方向的学生研读。它解决的核心问题是如何借助预训练 BERT 语言模型从 FiQA 数据集中精准检索出与金融问题相关的段落。系统先以 Lucene 工具包为每个查询召回前 50 个候选答案再通过模型对候选重新排序并采用 Transfer and Adapt 方法将通用 QA 任务上微调的 BERT 迁移至金融领域整体在 nDCG、MRR、Precision 三项指标上平均提升约 20%。资源包共 62 个文件约 142.71MB包含 9 个 Python 脚本、23 个 pickle 数据文件、4 个 tsv 与 2 个 ipynb 笔记本以及 Lucene 索引、Dockerfile、依赖清单和流程示意图覆盖数据生成、模型训练、预测与评估全链路。已有 2087 人学习下载可帮助读者复现完整问答流水线、理解迁移微调策略并掌握排序评估方法。1. 金融问答为什么不能直接套通用 BERT从 FinBERT-QA 的取舍说起如果你拿一个在维基百科和新闻语料上训出来的 BERT直接丢给「某公司 2023 年经营性现金流同比变化多少」这种问题大概率会得到一个语法通顺但数字完全不对的答案。这不是模型不够大而是金融文本的分布和通用语料差得太远财报里的「收入」可能指营业收入、净收入或递延收入一个词在通用语境和金融语境下的指向完全不同。FinBERT-QA 要解决的就是这件事——在金融领域做抽取式问答让模型从财报、公告、研报里定位答案片段而不是靠生成模型编一段话。这个方向适合两类人一是手里有金融文档、想搭一个能查数问答系统的工程师二是想理解领域预训练模型怎么落地到具体任务的学习者。它不需要你从零训一个 BERT核心思路是在金融语料上继续预训练再在问答数据上微调。整条链路里数据构造和负样本采样比模型结构更决定成败这也是后面几章要重点拆的部分。先记住一个结论FinBERT-QA 的收益主要来自领域预训练带来的词向量偏移而不是某个新奇的网络结构。2. 把 FinBERT-QA 拆开看预训练、检索、抽取三段到底怎么接2.1 抽取式问答和生成式问答在金融场景的分工金融问答按答案来源分两种。生成式问答让模型直接写答案适合「解释一下这个指标的含义」这类开放问题但数字容易幻觉。抽取式问答要求答案必须是原文里的一个连续片段适合「净利润是多少」这类事实型问题可溯源、可审计。FinBERT-QA 走的是抽取式路线输入是一段上下文加一个问题输出是上下文里答案的起止位置。为什么金融场景更偏向抽取式因为合规和审计要求答案能指回原文。你告诉用户「净利润 3.2 亿」用户会问这个数从哪来的。抽取式模型天然带位置信息可以把原文片段高亮出来。代价是它只能回答上下文里存在答案的问题所以工程上通常配一个检索模块先从海量文档里召回相关段落再交给抽取模型精读。FinBERT-QA 这个名字里的 QA 指的是问答微调阶段而 FinBERT 指的是领域预训练阶段。两阶段分开做的好处是预训练一次可以复用到问答、分类、实体识别多个下游任务微调阶段只动少量参数迭代快。2.2 领域预训练到底改了什么词表和语料分布通用 BERT 的词表是按通用语料频次建的金融术语经常被切成碎片。比如「EBITDA」在通用词表里可能被拆成「EB」「##IT」「##DA」三个子词模型要花额外容量去学这个组合的含义。领域预训练的第一个动作通常是扩充词表把金融高频词加进去让它们成为独立 token。第二个改动是继续预训练continue pretraining。用金融语料在 MLM掩码语言模型任务上再训若干步让上下文表示向金融分布偏移。这里有个容易翻车的地方继续预训练学得太狠会灾难性遗忘通用语言能力学得太浅又没效果。常见做法是控制学习率在 1e-5 到 5e-5 之间训练步数控制在几万步量级并在通用验证集上监控是否掉点。第三个改动是句对目标。金融文本里「营收增长」和「利润下滑」经常同时出现NSP下一句预测任务能帮模型学到这种对比关系。不过现在很多实现已经去掉 NSP改用 RoBERTa 式的动态掩码FinBERT-QA 类方案里两种都有选哪种取决于你的语料规模。2.3 检索加抽取的完整链路和最小可跑代码完整链路是文档切块 → 向量化建索引 → 问题向量检索 top-k 段落 → 拼接问题与段落 → 抽取模型定位答案。下面这段代码用 HuggingFace 的 transformers 搭一个最小抽取问答流程假设你已经有一个微调好的 FinBERT-QA 权重。from transformers import AutoTokenizer, AutoModelForQuestionAnswering import torch # 加载金融领域微调后的问答模型路径换成你自己的权重目录 model_name ./finbert-qa-checkpoint tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForQuestionAnswering.from_pretrained(model_name) model.eval() context 公司2023年实现营业收入12.4亿元同比增长8.3%归属于上市公司股东的净利润为1.7亿元同比下降2.1%。 question 2023年净利润是多少 # 截断策略max_length 要覆盖问题加段落金融段落通常较长 inputs tokenizer(question, context, return_tensorspt, max_length512, truncationonly_second, stride128) with torch.no_grad(): outputs model(**inputs) start torch.argmax(outputs.start_logits) end torch.argmax(outputs.end_logits) 1 answer tokenizer.convert_tokens_to_string( tokenizer.convert_ids_to_tokens(inputs[input_ids][0][start:end])) print(answer)逻辑说明truncationonly_second保证问题不被截断只截段落stride128让长段落有重叠窗口避免答案正好落在切分边界上。参数上max_length设 512 是 BERT 的硬上限如果你的段落普遍超过 400 字要么换 Longformer 类模型要么在检索阶段就把段落切得更细。start_logits和end_logits分别对应答案起止位置取 argmax 是最朴素的解码实际部署时要做「start 小于 end 且长度合理」的约束否则会输出跨半篇文档的荒谬片段。检索模块可以用 sentence-transformers 把段落编码成向量用 faiss 建索引。这一步的坑是通用句向量模型在金融文本上召回率一般最好用同一份金融语料微调过的句向量模型否则检索阶段就把正确答案漏掉了后面抽取模型再强也救不回来。3. 数据构造与负样本采样FinBERT-QA 效果的分水岭3.1 金融问答数据集长什么样标注成本怎么降抽取式问答的训练样本是三元组问题、包含答案的段落、答案在段落里的字符起止位置。金融领域公开数据少多数团队得自己标。全人工标成本极高常见做法是半自动先用规则从财报里抽「指标名 数值」对自动生成问题模板再人工校验。比如从「归属于上市公司股东的净利润为1.7亿元」可以自动生成「归属于上市公司股东的净利润是多少」答案位置就是「1.7亿元」这段字符。模板要覆盖多种问法多少、同比变化、环比变化、占比。人工只做抽检和纠错能把标注成本压到全人工的三成左右。要注意的是自动生成的问答对分布很偏几乎都是「指标查询」型。真实用户还会问「为什么净利润下降」这类因果问题在抽取式框架下很难有好答案因为原文往往没有一句话直接回答。工程上的处理是把这类问题路由到生成式模块或直接返回相关段落让用户自己看不要硬塞给抽取模型。3.2 负样本怎么采随机段落为什么不够用训练时如果负样本不含答案的段落全是随机采的模型会学到「只要问题和段落都跟金融有关就预测有答案」这种捷径上线后召回一堆无关段落还自信地圈出错误片段。好的负样本要难用检索模型召回但实际不含答案的段落也就是「hard negative」。具体做法是先用当前模型对训练集做一遍检索把排在前列但不含正确答案的段落收集起来作为下一轮训练的负样本。这个过程可以迭代两三轮每轮模型变强负样本也变难。参数上正负样本比例控制在 1:3 到 1:5 之间比较稳负样本太多会让模型过度保守该答的也不答了。# 用检索分数筛 hard negative 的简化逻辑 def build_hard_negatives(questions, passages, retriever, gold_map, top_k10): negatives [] for q in questions: hits retriever.search(q, top_ktop_k) # 返回按分数排序的段落 id gold gold_map[q] # 该问题的正确答案段落 id for pid, score in hits: if pid ! gold and score 0.6: # 分数高但不是正例算 hard negative negatives.append((q, pid)) return negatives逻辑说明score 0.6这个阈值不是固定的要看你的检索模型分数分布一般取正例最低分附近。阈值太高筛不出负样本太低又退化成随机负样本。top_k10是召回深度深度越大越可能捞到难负样本但计算量也上去。3.3 答案跨度对齐字符偏移和 token 偏移的转换坑标注给的是字符位置模型要的是 token 位置中间隔着分词器。中文金融文本里数字和单位经常被切得很碎「1.7亿元」可能变成「1」「.」「7」「亿」「元」五个 token。转换时要用tokenizer的offset_mapping把字符区间映射到 token 区间并且处理「答案起点落在某个 token 中间」的情况。enc tokenizer(question, context, return_offsets_mappingTrue, max_length512, truncationonly_second) offsets enc[offset_mapping] char_start, char_end 45, 52 # 标注的答案字符区间 token_start token_end None for i, (s, e) in enumerate(offsets): if s char_start e: token_start i if s char_end e: token_end i 1逻辑说明判断条件是「字符起点落在 token 区间内」而不是相等因为分词边界和字符边界很少完全对齐。如果token_start或token_end为 None说明答案被截断或标注越界这条样本要丢掉否则训练时 loss 会异常。这个转换是血泪经验很多团队第一版模型效果差就是栽在这里答案位置整体偏移一两个 token模型学出来的边界永远差一点。4. 微调 FinBERT-QA 的实操参数与显存控制4.1 学习率、batch size 和 epoch 的取值区间微调阶段的学习率要比预训练高一个量级常见区间是 2e-5 到 5e-5。金融问答数据量通常不大几千到几万条epoch 设 3 到 5 就够再多会过拟合。判断过拟合看验证集上的 EM精确匹配和 F1如果训练 loss 还在降但验证 F1 连续两个 epoch 不涨就该停。batch size 受显存限制。BERT-base 在 512 长度下单卡 16GB 大概能放 8 到 12 条。显存不够时用梯度累积把gradient_accumulation_steps设成 4等效 batch size 就上去了但要注意学习率要按等效 batch size 同比放大否则训练会变慢。参数推荐区间说明learning_rate2e-5 ~ 5e-5预训练阶段的 5~10 倍batch_size8 ~ 16单卡 16GB 下的安全值epoch3 ~ 5看验证集 F1 早停max_length384 ~ 512覆盖问题加段落warmup_ratio0.1前 10% 步数线性预热weight_decay0.01防过拟合4.2 混合精度和梯度检查点把显存压下来显存不够时优先开混合精度fp16能省三到四成显存速度也快。再不够就开梯度检查点用计算换显存代价是训练慢两成左右。这两个开关在 HuggingFace Trainer 里都是一行参数。from transformers import TrainingArguments args TrainingArguments( output_dir./finbert-qa-out, learning_rate3e-5, per_device_train_batch_size8, gradient_accumulation_steps4, # 等效 batch size 32 num_train_epochs4, warmup_ratio0.1, weight_decay0.01, fp16True, # 混合精度省显存 gradient_checkpointingTrue, # 再省显存训练变慢 evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelf1, )逻辑说明gradient_accumulation_steps4配合per_device_train_batch_size8等效 batch size 是 32学习率 3e-5 是按等效 batch 调的。load_best_model_at_endTrue保证最后用的是验证集 F1 最高的那个 checkpoint而不是最后一个 epoch 的。gradient_checkpointing和fp16同时开一般没问题但某些老版本 PyTorch 上会有数值不稳定遇到 loss 变 NaN 就先关掉梯度检查点试试。4.3 评估指标EM 和 F1 在金融问答里的实际含义EM 是预测答案和标准答案完全一致的比例金融场景下这个指标偏严因为「1.7亿元」和「1.70亿元」算不一致但语义相同。F1 按 token 重叠算更能反映部分正确的情况。实际看板要两个都盯EM 反映严格正确率F1 反映边界质量。还有一个业务指标是「答案可溯源率」即模型给出的答案位置确实落在原文对应片段上的比例。这个指标比 EM 更贴近上线要求因为用户关心的是能不能点回原文。评估时抽样一百条人工看答案位置对不对比只看自动指标靠谱。5. 上线前必查的五个坑从检索漏召到答案越界5.1 检索阶段漏召抽取模型再强也没用现象用户问的问题明明文档里有答案系统却返回「未找到」。原因检索 top-k 太小或者句向量模型没在金融语料上微调把正确段落排到了 k 名之外。解决先把 top-k 从 5 提到 20看漏召是否缓解如果还漏用金融语料微调句向量模型或者加一路 BM25 关键词召回做融合。融合时用 RRF倒数排名融合比直接加权分数稳因为两路分数尺度不一样。5.2 答案越界start 大于 end 或跨段落现象输出一段横跨半篇文档的文字或者 start 位置在 end 之后。原因解码时只取了 start 和 end 的 argmax没加约束。解决在解码时枚举合法的 start-end 对要求 start end 且长度不超过 30 个 token取联合概率最高的那对。这个约束在 HuggingFace 的question-answeringpipeline 里默认有自己写推理循环时容易忘。5.3 长文档截断把答案切掉现象训练时正常上线后长财报问答效果骤降。原因max_length512截断时答案正好落在被截掉的部分。解决用滑动窗口加 stride把长文档切成有重叠的片段分别推理再按分数合并。stride 设成 max_length 的四分之一左右比如 128。重叠太小会漏太大计算量翻倍。5.4 数字和单位的归一化没做现象模型答「1.7亿」标准答案是「170,000,000」EM 判错。原因评估和展示阶段没做数值归一化。解决在评估脚本里加一层归一化把「亿」「万」换算成统一单位再比。展示时保留原文写法不要自作主张换算用户要看的是原文。5.5 领域预训练过头导致通用能力崩掉现象金融问答涨了点但模型在其他任务上明显变差。原因继续预训练步数太多或学习率太高灾难性遗忘。解决预训练时混入一定比例的通用语料比例大概 1:4 到 1:9监控通用验证集指标掉超过两个点就回退。这个坑在只做单一任务的团队里容易被忽略等到要复用时才发现模型已经「偏科」得没法用了。6. 用对抗验证和错误分析把 FinBERT-QA 再推一档模型上线不是终点真正拉开差距的是错误分析。我一般会做两件事对抗验证和分层错误统计。对抗验证的做法是训一个二分类器区分「训练集样本」和「验证集样本」。如果分类器很容易区分说明两个集合分布不一致验证集指标不可信。金融问答里常见的分布漂移是训练集问题多是模板生成的验证集是真实用户问的用词和句式都不一样。发现漂移后要么补真实分布的训练数据要么在验证集上也用模板问题保证评估口径一致。分层错误统计是按问题类型切分看哪类问题错得多。下面这张表是我做过的典型分层结果你可以照着建自己的看板。问题类型EMF1主要错误原因单一指标查询0.780.85数字边界切分同比环比变化0.610.72需要跨句计算多指标对比0.430.58检索召回不全因果解释0.120.25抽取式框架不适用从表里能看出单一指标查询已经够用因果解释类问题不该硬塞给抽取模型。工程上的处理是把问题先分类事实型走 FinBERT-QA解释型走生成模型或返回相关段落。这个路由分类器可以用同一个 FinBERT 底座加一个分类头成本很低。还有一个具体技巧是答案重排。检索召回 top-20 段落抽取模型对每个段落都给出答案和分数最后按分数排序取最高。比只对 top-1 段落做抽取的 EM 能高五到八个点代价是推理时间线性增长。如果延迟敏感可以只对 top-5 做抽取再按分数加权。最后说个习惯每次改完模型或数据固定抽 50 条错误样本人工看一遍把错误归类记下来。我见过太多团队盯着自动指标调参调了两周不如看一遍错误样本发现标注里有一批答案位置标错了。模型的上限是数据决定的FinBERT-QA 也不例外。希望帮到你。本文还有配套的精品资源点击获取
返回列表