ARTICLE DETAIL

资讯详情

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

FinBERT-QA实战:金融问答系统从FiQA数据集到检索式问答的完整落地路径

FinBERT-QA实战:金融问答系统从FiQA数据集到检索式问答的完整落地路径 简介FinBERT-QA 是一套面向金融领域问答检索的深度学习项目源码适合具备一定自然语言处理与信息检索基础的研究者、算法工程师及金融科技方向的学生参考。其核心思路是先用 Lucene 为每个查询召回前 50 个候选答案再借助预训练 BERT 模型对候选重新排序并通过 Transfer and Adapt 方法先在通用问答任务上微调、再迁移至 FiQA 金融数据集在 nDCG、MRR、Precision 三项指标上平均提升约 20%。资源包共 62 个文件约 142.71MB包含 9 个 Python 脚本、23 个 pickle 数据文件、4 个 tsv 语料、2 个 Jupyter Notebook以及 Lucene 索引、Dockerfile、依赖清单与流程示意图等覆盖数据生成、模型训练、预测与评估的完整链路。目前已有 2087 人学习下载可帮助读者理解金融问答的检索与重排实现并复现相关实验。1. 金融问答系统为什么需要 FinBERT-QA从 FiQA 数据集到检索式问答的落地路径金融领域的问答系统跟通用问答完全不是一回事。你问「苹果公司去年营收多少」通用模型可能给你一段维基百科式的介绍但金融从业者要的是精确数字、财报原文出处、以及可追溯的文档段落。更麻烦的是金融文本里充斥着大量专业术语和缩写——EBITDA、ROE、久期、凸性、信用利差——通用 BERT 在这些词上的语义表示往往不够准确因为它的预训练语料以新闻、百科、网页为主金融文本占比极低。FinBERT-QA 就是冲着这个痛点来的它用金融领域语料对 BERT 做进一步预训练再在 FiQA 数据集上做问答任务的微调最终输出一个能在大规模金融文档中检索并抽取答案的模型。这套资源适合谁做金融 NLP 应用的工程师、想了解检索式问答完整链路的算法同学、以及需要在自己的业务数据上复现 FiQA 评测结果的从业者。它不是一个开箱即用的 API而是一套包含数据预处理、检索器训练、阅读器微调和评测脚本的完整工程。2. FinBERT-QA 的技术架构检索器与阅读器的两阶段设计2.1 为什么是两阶段而不是端到端检索式问答的核心矛盾在于文档库可能有几十万篇你不可能把每一篇都塞进 BERT 跑一遍。BERT 的最大输入长度通常是 512 个 token一篇财报动辄上万字直接做端到端抽取在算力上不现实。所以 FinBERT-QA 采用了两阶段架构第一阶段用检索器Retriever从海量文档中快速筛出 Top-K 相关段落第二阶段用阅读器Reader对候选段落做精细的答案抽取。检索器通常基于 BM25 或稠密向量检索阅读器则是 FinBERT 微调后的问答模型。这种设计的好处是检索器负责「召回」阅读器负责「精确」两者各司其职。常见做法是检索器先召回 50 到 100 个候选段落阅读器再从中定位答案 span。参数上Top-K 的选择直接影响最终效果K 太小会漏掉正确答案K 太大会增加阅读器的计算负担和噪声干扰。我一般会在验证集上扫一遍 K10、20、50、100看 F1 和延迟的权衡曲线再定。2.2 FiQA 数据集的结构与预处理FiQA 数据集是金融问答领域的基准之一包含问题、答案以及对应的文档段落。原始数据通常以 JSON 或 CSV 格式提供每条样本包含 question、answer、context 等字段。预处理阶段需要做几件事第一把文档切分成适合 BERT 输入的段落通常按 200 到 300 个 token 切分相邻段落保留 50 个 token 的重叠避免答案被切断第二构建检索器的索引如果用 BM25 就直接对段落做倒排索引如果用稠密检索就需要先用编码器把所有段落转成向量第三把训练集里的 question-answer 对整理成阅读器需要的 SQuAD 格式即包含 question、context、answer_start、answer_text 四个字段。下面是一个典型的预处理脚本片段import json from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(yiyanghkust/finbert-pretrain) def chunk_document(text, max_len300, overlap50): 将长文档按 token 切分成带重叠的段落 tokens tokenizer.tokenize(text) chunks [] start 0 while start len(tokens): end min(start max_len, len(tokens)) chunk_tokens tokens[start:end] chunk_text tokenizer.convert_tokens_to_string(chunk_tokens) chunks.append(chunk_text) if end len(tokens): break start end - overlap # 保留重叠防止答案被切断 return chunks def build_squad_format(raw_data, doc_chunks): 把原始 QA 对转成 SQuAD 风格训练数据 squad_samples [] for item in raw_data: question item[question] answer item[answer] # 在切分后的段落中定位答案所在的 chunk for chunk in doc_chunks: if answer in chunk: answer_start chunk.index(answer) squad_samples.append({ question: question, context: chunk, answer_start: answer_start, answer_text: answer }) break return squad_samples这段代码的逻辑很直接chunk_document负责把长文本切成带重叠的段落build_squad_format负责把答案定位到具体的 chunk 里。参数max_len控制每个段落的 token 数overlap控制重叠量。注意answer_start是字符级索引不是 token 级这一点在后续训练时要和 tokenizer 的 offset mapping 对齐否则答案位置会错位。常见翻车点就是这里很多人直接用 token 索引去算 answer_start结果训练时 loss 一直不降。2.3 检索器的选型BM25 还是稠密向量检索器的选择直接决定了召回质量。BM25 是经典方案优点是无需训练、速度快、对精确匹配友好缺点是无法处理语义相似但用词不同的情况比如用户问「盈利能力」而文档里写的是「利润水平」。稠密向量检索比如用 Sentence-BERT 或 DPR能解决语义匹配问题但需要额外的编码和索引构建步骤而且金融领域的嵌入模型需要专门微调才能达到理想效果。FinBERT-QA 的实践中常见做法是先用 BM25 做基线再尝试用 FinBERT 的 [CLS] 向量做稠密检索对比两者的 RecallK。如果 BM25 的 Recall50 已经超过 85%那稠密检索的边际收益可能不值得额外的工程复杂度。参数上BM25 的 k1 和 b 需要根据文档长度分布调整k1 通常取 1.2 到 2.0b 取 0.75 左右但如果你的文档普遍很短b 可以调低到 0.3 到 0.5。3. 从零复现 FinBERT-QA环境搭建、模型微调与评测3.1 环境依赖与模型加载复现的第一步是把环境搭好。FinBERT-QA 依赖 PyTorch、Transformers、以及检索相关的库如 rank_bm25 或 faiss。我一般会建一个干净的 conda 环境固定版本避免玄学问题。下面是环境配置和模型加载的代码conda create -n finbert-qa python3.8 conda activate finbert-qa pip install torch1.13.1 transformers4.28.0 pip install rank_bm25 faiss-cpu datasets scikit-learnfrom transformers import BertForQuestionAnswering, BertTokenizer import torch # 加载 FinBERT 预训练权重作为阅读器底座 model_name yiyanghkust/finbert-pretrain tokenizer BertTokenizer.from_pretrained(model_name) model BertForQuestionAnswering.from_pretrained(model_name) # 检查 GPU 是否可用 device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) print(f模型已加载到 {device}参数量{sum(p.numel() for p in model.parameters())})这里用的是yiyanghkust/finbert-pretrain作为底座它是公开的金融领域 BERT 预训练权重。注意BertForQuestionAnswering的输出是 start_logits 和 end_logits分别对应答案在序列中的起始和结束位置。如果你的显存不够可以把 batch size 降到 8 或 16同时开启梯度累积来保持等效 batch size。参数上学习率通常设 2e-5 到 3e-5epoch 数 3 到 5 就够再多容易过拟合。3.2 阅读器微调的关键参数与训练循环阅读器微调是整个流程里最耗算力的一步。训练数据是 SQuAD 格式的 question-context-answer 三元组损失函数是 start 和 end 位置的交叉熵之和。下面是一个精简的训练循环from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup def train_reader(model, train_dataset, epochs3, batch_size16, lr3e-5): train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) optimizer AdamW(model.parameters(), lrlr, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) model.train() for epoch in range(epochs): total_loss 0 for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) start_positions batch[start_positions].to(device) end_positions batch[end_positions].to(device) outputs model(input_ids, attention_maskattention_mask, start_positionsstart_positions, end_positionsend_positions) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() print(fEpoch {epoch1}, Avg Loss: {total_loss/len(train_loader):.4f}) return model关键参数说明lr3e-5是 BERT 微调的经典学习率太大容易震荡太小收敛慢weight_decay0.01防止过拟合clip_grad_norm_设 1.0 是防止梯度爆炸的常规操作warmup_steps占总步数的 10%让模型在初期慢慢适应。训练时如果发现 loss 在前几百步就降到很低但验证集 F1 不涨大概率是过拟合了这时候要么加数据增强要么减 epoch。另一个常见问题是start_positions和end_positions超出序列长度这通常是因为预处理时没做截断需要在 tokenizer 里设truncationTrue和max_length512。3.3 评测指标与结果解读评测阶段主要看两个指标检索器的 RecallK 和阅读器的 Exact MatchEM与 F1。RecallK 衡量的是正确答案所在段落是否出现在 Top-K 候选中EM 衡量的是抽取答案和标准答案是否完全一致F1 则是 token 级别的重叠度。下面是一个评测脚本的骨架from sklearn.metrics import f1_score def evaluate_reader(model, eval_dataset, tokenizer): model.eval() em_count, f1_total 0, 0.0 for sample in eval_dataset: inputs tokenizer(sample[question], sample[context], return_tensorspt, truncationTrue, max_length512) with torch.no_grad(): outputs model(**inputs.to(device)) start_idx torch.argmax(outputs.start_logits) end_idx torch.argmax(outputs.end_logits) pred_answer tokenizer.decode(inputs[input_ids][0][start_idx:end_idx1], skip_special_tokensTrue) if pred_answer.strip() sample[answer_text].strip(): em_count 1 f1_total f1_score(sample[answer_text].split(), pred_answer.split(), averagemacro) print(fEM: {em_count/len(eval_dataset):.4f}, F1: {f1_total/len(eval_dataset):.4f})这段代码的逻辑是对每个评测样本用 tokenizer 编码 question 和 context模型输出 start 和 end 的 logits取 argmax 得到答案 span再 decode 成文本。注意start_idx和end_idx是 token 索引decode 时要确保不越界。如果end_idx start_idx说明模型输出了无效 span这时候通常取 start_idx 对应的单个 token 作为答案。实际跑下来FinBERT-QA 在 FiQA 上的 F1 通常在 0.6 到 0.7 之间具体取决于检索器的 Recall 和阅读器的微调程度。如果 F1 明显低于这个区间优先检查预处理阶段的 answer_start 对齐和 tokenizer 的 truncation 设置。4. 避坑与排查FinBERT-QA 复现中最容易翻车的五个地方4.1 现象训练 loss 正常下降但验证集 F1 始终为 0原因answer_start 的字符索引和 tokenizer 的 token 索引没有对齐。SQuAD 格式要求 answer_start 是字符级索引但很多人在预处理时直接用 token 位置赋值导致模型学到的 start 位置和真实答案完全错位。解决用 tokenizer 的return_offsets_mappingTrue获取每个 token 对应的字符区间再把字符级 answer_start 映射到 token 级 start_position。这一步不做后面全白搭。4.2 现象检索器 Recall50 很高但阅读器 F1 很低原因候选段落里虽然包含答案但段落太长或噪声太多阅读器无法准确定位。常见情况是 chunk 切分时 max_len 设得太大比如 500导致一个 chunk 里混入了多个主题的内容。解决把 chunk 的 max_len 降到 200 到 300增加 overlap 到 50 到 80确保每个 chunk 的语义相对集中。另外可以在检索器后面加一个段落重排序rerank步骤用交叉编码器对 Top-K 做精排。4.3 现象GPU 显存溢出batch size 降到 4 还是 OOM原因BERT 的注意力矩阵是 O(n^2) 复杂度序列长度 512 时显存占用很大。如果同时加载了检索器和阅读器或者用了 fp32 精度显存很容易爆。解决开启混合精度训练torch.cuda.amp把 max_length 从 512 降到 384或者用梯度累积模拟大 batch。如果还是不够考虑用 DistilBERT 或 ALBERT 作为阅读器底座参数量少一半效果损失通常在 2 到 3 个点以内。4.4 现象BM25 检索结果里大量无关文档排在前面原因金融文档里有很多模板化的法律声明和风险提示这些内容词频高但信息量低BM25 容易被它们干扰。解决在索引前做停用词过滤把「风险提示」「免责声明」「本报告仅供参考」这类模板句去掉。另外可以调整 BM25 的 b 参数降低长文档的权重。如果业务允许加一个基于规则的前置过滤器把非目标类型的文档直接排除。4.5 现象模型在训练集上表现很好换一批新问题就崩了原因过拟合。FiQA 数据集的规模有限如果模型在训练集上跑了太多 epoch它会记住具体的问答对而不是学到泛化的匹配能力。解决早停early stopping是必须的监控验证集 F1连续 2 个 epoch 不涨就停。另外可以加 dropoutBERT 默认 0.1可以调到 0.2 到 0.3或者用数据增强比如对问题做同义改写、对答案做回译。血泪经验是不要看训练 loss 低就高兴验证集才是唯一标准。5. 进阶技巧用集成检索和答案重排序把 F1 再拉高几个点5.1 多路召回融合单一检索器的召回总有盲区。BM25 擅长精确匹配稠密检索擅长语义匹配把两者的结果做加权融合通常能提升 RecallK。具体做法是BM25 返回一个排序列表稠密检索返回另一个排序列表用 Reciprocal Rank FusionRRF把两个列表合并。RRF 的公式很简单对每个文档分数等于所有检索器中 1/(k rank) 之和k 通常取 60。这样不需要调权重直接融合两个异构检索器的结果。我一般会先用 BM25 召回 100 个稠密检索召回 100 个RRF 融合后取 Top-50 送给阅读器Recall50 通常能比单路提升 5 到 8 个点。5.2 答案重排序与置信度校准阅读器输出的 start_logits 和 end_logits 可以直接用来算答案的置信度。常见做法是取 start 和 end 的 softmax 概率之和作为分数对多个候选段落给出的答案做重排序。如果 Top-1 答案的置信度低于某个阈值比如 0.3可以触发「拒答」或者回退到检索器的摘要结果。下面是一个简单的重排序逻辑import torch.nn.functional as F def rerank_answers(model, tokenizer, question, contexts, top_n5): 对多个候选段落分别抽取答案按置信度重排序 candidates [] for ctx in contexts: inputs tokenizer(question, ctx, return_tensorspt, truncationTrue, max_length384) with torch.no_grad(): outputs model(**inputs.to(device)) start_prob F.softmax(outputs.start_logits, dim-1) end_prob F.softmax(outputs.end_logits, dim-1) start_idx torch.argmax(start_prob).item() end_idx torch.argmax(end_prob).item() if end_idx start_idx: end_idx start_idx confidence (start_prob[0][start_idx] * end_prob[0][end_idx]).item() answer tokenizer.decode(inputs[input_ids][0][start_idx:end_idx1], skip_special_tokensTrue) candidates.append({answer: answer, confidence: confidence, context: ctx}) candidates.sort(keylambda x: x[confidence], reverseTrue) return candidates[:top_n]这段代码的核心是confidence的计算用 start 和 end 的联合概率作为答案的置信度。注意这里用的是乘积而不是求和因为 start 和 end 需要同时正确才有意义。参数top_n控制返回的候选答案数量实际部署时可以只取 Top-1但保留 Top-5 方便做人工审核或下游任务。如果发现置信度普遍偏低比如都低于 0.1说明阅读器对这批数据不适应可能需要用领域数据再做一轮微调。5.3 一个容易被忽略的细节特殊 token 的处理FinBERT 的 tokenizer 会在序列首尾加 [CLS] 和 [SEP]在 decode 答案时如果不跳过特殊 token抽取结果里会混入这些标记。skip_special_tokensTrue能解决大部分情况但如果答案本身包含 [SEP] 或 [CLS] 的字面文本金融文档里偶尔会有就会被误删。更稳妥的做法是手动过滤先 decode 完整序列再用正则把特殊 token 去掉。另外如果答案跨了多个 token 但中间有标点decode 出来的文本可能和原始答案有细微差异评测时建议用 normalize 后的文本做比较比如统一小写、去掉多余空格和标点。从那以后我每次跑评测都强制走一遍 normalize 流程不然 EM 和 F1 的数值会莫名其妙地低一截。希望帮到你。本文还有配套的精品资源点击获取
返回列表