Outlines 实战:用正则约束生成从财报(Earnings Reports)中直接提取金融数据到 CSV
【免费下载链接】outlinesStructured Outputs项目地址: https://gitcode.com/GitHub_Trending/ou/outlines
本篇技术指南演示如何基于 Outlines 的正则表达式约束解码能力,让 LLM 直接从结构化程度极低的上市公司 10-K 收益报告中提取年度财务数据,并百分之百保证输出是合法 CSV。读完本文,你将掌握:用正则描述列类型、动态拼接 CSV 语法正则、通过outlines.Generator与Regex组合运行模型,以及把提取结果直接载入pandasDataFrame 进行后续分析。文章以仓库中的 cookbook 示例 earnings-reports.md 为骨架,并结合 src/outlines 下的源码剖析其底层实现原理。
背景:为什么财报数据提取如此困难
金融领域一个非常常见的任务是从收益报告(earnings reports)中提取财务数据。问题的根源在于:SEC(美国证券交易委员会)并不要求上市公司提供机器可读的文档,因此收益报告普遍格式混乱、极不规范。
现实中的收益报告多以 HTML 文档形式提供,直接解析非常困难。投资者往往依赖复杂的解析系统,甚至需要人工逐条核对才能完成数据提取——行业内甚至有专门的公司在做"用自动化方案替代人工解析"这件事。
本 cookbook 是一个概念验证(proof of concept):用 LLM 把财务数据直接提取成 CSV。为什么选 CSV?因为逗号分隔值(Comma-Separated Values)结构规整,可以用一条正则表达式完整描述,而 Outlines 恰好能用正则去约束(guide)LLM 的输出。本文示例是完整 demo 的一个精简子集——完整版 demo 包含把原始 HTML 转换为结构化 CSV 所需的全部预处理步骤,并在三家公司的 10-K 报告上做了结果验证。
环境准备与安装
安装 Outlines 及其依赖:
# 较新版本的 torch 可能与某些 CUDA 驱动存在兼容性问题。 # 这里推荐先使用 2.4.0,你也可以自行尝试其他版本。 pip install outlines pandas transformers torch==2.4.0 accelerate依赖清单里每个包的职责如下:
| 包 | 用途 |
|---|---|
outlines | 结构化生成框架,提供正则约束解码与Generator接口 |
transformers | 加载 Hugging Face 上的预训练因果语言模型与分词器 |
torch==2.4.0 | 模型推理后端(文档建议暂时锁定该版本以避免 CUDA 驱动问题) |
accelerate | 支持device_map="cuda"等设备调度策略 |
pandas | 把提取出的 CSV 字符串载入 DataFrame 做后续分析 |
加载模型
我们选择一个足够小、能在普通机器上运行的模型:Phi-3 mini。
import outlines import torch import transformers model_name = 'microsoft/Phi-3-mini-4k-instruct' tf_model = transformers.AutoModelForCausalLM.from_pretrained( model_name, device_map="cuda", torch_dtype=torch.bfloat16 ) tf_tokenizer = transformers.AutoTokenizer.from_pretrained(model_name) model = outlines.from_transformers(tf_model, tf_tokenizer)这里的关键一步是outlines.from_transformers(tf_model, tf_tokenizer)。从源码看,from_transformers 接受一个transformers.PreTrainedModel实例和PreTrainedTokenizer/ProcessorMixin实例:若传入的是普通分词器则返回Transformers模型实例,若传入的是多模态 processor 则返回TransformersMultiModal实例;文档中支持的模型范围包括PreTrainedModelForCausalLM、PreTrainedMambaForCausalLM、PreTrainedModelForSeq2Seq以及任何实现 transformers 模型 API 的模型。在本文场景中,model随即具备 Outlines 的"可约束生成"能力。
数据准备:从原始 HTML 到 Markdown 表格
为简洁起见,我们直接把 Nvidia 10-K 报告的 markdown 版本贴在代码里。完整版 demo 会先把原始 HTML 处理成如下所示的 markdown 表格:按页面是否包含利润表(income statement)进行过滤,再把筛选出的页面压缩成一个字符串。
income_statement = """ Table of ContentsNVIDIA Corporation and SubsidiariesConsolidated Statements of Income(In millions, except per share data) | | | | | | | | | | | | | | | | | | | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | | | | Year Ended | | | | | | | | | | | | | | | | | | | Jan 28, 2024 | | | | | | Jan 29, 2023 | | | | | | Jan 30, 2022 | | | | Revenue | | | $ | 60,922 | | | | | $ | 26,974 | | | | | $ | 26,914 | | | Cost of revenue | | | 16,621 | | | | | | 11,618 | | | | | | 9,439 | | | | Gross profit | | | 44,301 | | | | | | 15,356 | | | | | | 17,475 | | | | Operating expenses | | | | | | | | | | | | | | | | | | | Research and development | | | 8,675 | | | | | | 7,339 | | | | | | 5,268 | | | | Sales, general and administrative | | | 2,654 | | | | | | 2,440 | | | | | | 2,166 | | | | Acquisition termination cost | | | | | | | | | 1,353 | | | | | | | | | | Total operating expenses | | | 11,329 | | | | | | 11,132 | | | | | | 7,434 | | | | Operating income | | | 32,972 | | | | | | 4,224 | | | | | | 10,041 | | | | Interest income | | | 866 | | | | | | 267 | | | | | | 29 | | | | Interest expense | | | (257) | | | | | | (262) | | | | | | (236) | | | | Other, net | | | 237 | | | | | | (48) | | | | | | 107 | | | | Other income (expense), net | | | 846 | | | | | | (43) | | | | | | (100) | | | | Income before income tax | | | 33,818 | | | | | | 4,181 | | | | | | 9,941 | | | | Income tax expense (benefit) | | | 4,058 | | | | | | (187) | | | | | | 189 | | | | Net income | | | $ | 29,760 | | | | | $ | 4,368 | | | | | $ | 9,752 | | | | | | | | | | | | | | | | | | | | | | Net income per share: | | | | | | | | | | | | | | | | | | | Basic | | | $ | 12\.05 | | | | | $ | 1\.76 | | | | | $ | 3\.91 | | | Diluted | | | $ | 11\.93 | | | | | $ | 1\.74 | | | | | $ | 3\.85 | | | | | | | | | | | | | | | | | | | | | | Weighted average shares used in per share computation: | | | | | | | | | | | | | | | | | | | Basic | | | 2,469 | | | | | | 2,487 | | | | | | 2,496 | | | | Diluted | | | 2,494 | | | | | | 2,507 | | | | | | 2,535 | | | """从不同收益报告提取出的 markdown 表格在行名、列数、数据类型上差异极大。LLM 的优势恰恰在此:我们只需按数据类型定义想要的数据,模型就会以我们指定的格式输出——无需为每家公司的排版写专属解析器。
作为对比,下图展示的是该利润表在原始 HTML 中的样子:
定义我们想要的数据:为每一列声明正则类型
Outlines 常被用于 JSON 输出,但同样适用于 CSV。我们明确知道要提取哪些列,也明确知道各列的数据类型:例如Year 永远是四位数字,revenue 是带逗号的数字……于是可以为每种列类型定义一个正则模式:
# Define the column type regex patterns column_types = { # Year is always a four-digit number "year": r"\d{4}", # Revenue, operating income, and net income are always numbers with commas. # This regex permits integers that may begin with a minus sign, and may have # commas separating the thousands, millions, etc. "integer_comma": r"((-?\d+),?\d+|(-?\d+))", # Number is currently not used, but it represents a number with up to two decimal places. "number": r"(-?\d+(?:\.\d{1,2})?)", }三个类型的语义拆解:
year:\d{4},精确匹配四位数年份;integer_comma:((-?\d+),?\d+|(-?\d+)),允许可选的负号开头,千/百万位之间允许出现逗号(例如60,922与-257都能匹配);number:(-?\d+(?:\.\d{1,2})?),最多两位小数的数字(当前示例未使用,但保留用于每股收益等场景)。
接下来选择要提取的列:Year(四位数)、Revenue(带逗号数字)、Operating income(带逗号数字)、Net income(带逗号数字)。
# Define the columns to extract, and their data types. columns_to_extract = { "year": "year", "revenue": "integer_comma", "operating_income": "integer_comma", "net_income": "integer_comma", }你可以修改column_type_regex来适配你关心的列的数据类型。要新增一个财务指标,只需在columns_to_extract中加一个键值对:
columns_to_extract["diluted_earnings_per_share"] = "number"需要提醒的是:新增列在准确率上没有经过充分测试,请谨慎使用。
构造描述目标数据的 CSV 正则
有了列与类型定义,就可以动态拼接出完整正则:
# Create the header line. This is the requested column names # separated by commas, i.e. "year,revenue,..." header = ",".join(columns_to_extract.keys()) # Create the data capture patterns. These are the regex patterns # that will be used to capture the data in each column data_patterns = [column_types[dtype] for dtype in columns_to_extract.values()] data_line = ",".join(data_patterns) # Our final regex pattern. max_rows = 3 # We expect 3 rows of data, firms usually report 3 years of income statements csv_regex = f"{header}(\n{data_line}){{,{max_rows}}}\n\n" print(csv_regex)打印出的正则如下:
year,revenue,operating_income,net_income,basic_earnings_per_share( \d{4},((-?\d+),?\d+|(-?\d+)),((-?\d+),?\d+|(-?\d+)),((-?\d+),?\d+|(-?\d+)),(-?\d+(?:\.\d{1,2})?)){,3}注:上面这段打印输出来自包含五个列(含
basic_earnings_per_share)的旧版示例;若只按本文的columns_to_extract(四个列)构造,打印结果中对应会只有四个模式,结构完全相同。
看起来相当"狰狞",对吧?其实它的结构很清晰:先是表头行(header),随后是每个数据行(data_line),数据行通过{,3}量词重复最多 3 次——因为公司通常报告最近三年的利润表数据。正则结尾的两个换行\n\n用于标记 CSV 的终止(与下文提示词中的"两个换行结束 CSV"约定一一对应)。
把它传给outlines.Generator,得到的函数将始终产出与该正则一致(consistent)的 CSV 字符串——这是 Outlines 约束解码的承诺。
构造提示词:让模型知道任务是什么
Outlines默认不会添加 system 或 instruction token,因此我们需要用transformers.AutoTokenizer为当前模型手动添加:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(model_name) def add_instruction(prompt): return tokenizer.apply_chat_template([{"role": "user", "content": prompt}], tokenize=False, add_generation_prompt=True) print(add_instruction("Howdy"))输出结果:
<|user|> Howdy<|end|> <|assistant|>我们的提示词大致描述了要模型执行的任务,以及理解利润表所需的一些背景知识:
def extract_financial_data_prompt(columns_to_extract, income_statement): user_prompt = f""" Extract annual financial data from this set of pages. Pages are from a 10k filing and were chosen because they may contain a comprehensive income statement. Note that selected pages may be incorrectly extracted, so you should verify that you are extracting from the comprehensive income statement and not some other financial statement. Create a row for each year available in the income statement with the following columns: {', '.join(columns_to_extract.keys())}. Firms typically report the most recent 3 years of data, but this can vary. Each column has types: {', '.join(columns_to_extract.values())}. # Relevant pages: {income_statement} # Key instructions: 1. Look ONLY at the "Consolidated Statements of Income" table 2. For operating income, look for "Income from operations" or "Operating income" 3. For net income, use the TOTAL net income figure, not amounts allocated to specific share classes 4. Use NULL for missing values 5. Operating income must be less than revenue 6. Net income must be less than operating income 7. Ignore segment breakdowns, quarterly data, or per-share amounts # Output format: - CSV format with headers: {','.join(columns_to_extract.keys())} - Use NULL for missing values - If no data are found, do not create a row. - Enter two newline characters to terminate the CSV when no more data are found. # Definitions: - Revenue: Total sales of goods and services. Usually this is at the top of the income statement. - Operating income: Revenue minus operating expenses for the entire company. This is revenue minus costs. Operating income is also called operating profit, EBIT, or income from operations. - Net income: Operating income minus taxes. This is the bottom line of the income statement. """ return add_instruction(user_prompt)这份提示词中的几条关键指令值得注意,它们与正则约束互补,共同保证数据质量:
- 只查看 "Consolidated Statements of Income" 表格——因为预过滤的页面可能包含其他报表;
- 明确 operating income / net income 的口径(总净利润而非分摊到特定股份类别的金额);
- 缺失值用
NULL; - 施加领域约束:operating income 必须小于 revenue、net income 必须小于 operating income;
- 忽略分部(segment)拆分、季度数据与每股金额;
- 两个换行符终止 CSV——与正则里的
\n\n严格对应; - 未找到数据时不创建行(对应正则中的
{,3}允许 0 到 3 行的设计)。
运行模型并验证结果
有了提示词和正则,就可以运行模型了。先构造正则提取器:
from outlines.types import Regex csv_extractor = outlines.Generator(model, Regex(csv_regex))把提示词交给模型执行:
csv_data = csv_extractor( extract_financial_data_prompt(columns_to_extract, income_statement), max_new_tokens=1024, ) print(csv_data)输出:
year,revenue,operating_income,net_income 2024,60922,32972,29760 2023,26974,4224,4368 2022,26914,10041,9752对照 Nvidia 财报原始数据(Revenue 60,922 / Operating income 32,972 / Net income 29,760 等),提取结果经人工核对完全正确——这就是 LLM 做信息抽取与正则约束做格式保证相结合的典型效果。
你甚至可以把它直接载入pandasDataFrame 做进一步分析:
import pandas as pd from io import StringIO df = pd.read_csv(StringIO(csv_data)) print(df)输出:
year revenue operating_income net_income 0 2024 60922 32972 29760 1 2023 26974 4224 4368 2 2022 26914 10041 9752底层原理:从正则到约束解码
理解outlines.Generator(model, Regex(csv_regex))内部发生了什么,有助于你把这套方案迁移到其他任务上。
Generator 工厂与 SteerableGenerator
Generator是一个工厂函数,定义在 generator.py:它会根据模型类型与参数分发到不同的生成器类。本地可约束模型(如 transformers 模型)对应SteerableGenerator,其构造逻辑(见 generator.py)如下:
- 通过
python_types_to_terms(output_type)把输出类型转换成 DSL 术语(Term); - 若术语是
CFG,调用get_cfg_logits_processor; - 若术语是
JsonSchema,调用get_json_schema_logits_processor; - 其余情况(包括
Regex)统一走to_regex(term)取出正则字符串,再调用get_regex_logits_processor编译成 logits processor。
由于编译 logits processor 可能比较昂贵,SteerableGenerator会在构造时构建并缓存它,每次调用时仅做一次reset()再传给模型生成(见 generator.py)。output_type与processor两个参数互斥,同时传入会抛ValueError。
正则 DSL:Regex 与 to_regex
Regex是 types/dsl.py 中定义的一个Term子类,仅封装一个正则模式字符串。to_regex(types/dsl.py)负责把任意Term递归展开成最终的正则字符串——对Regex就是把其模式包一层捕获括号。仓库还在 types/init.py 中预置了大量开箱即用的正则类型,例如integer、number、date、uuid4、ipv4、semver等,它们内部都是Regex(...)实例,可作为"拼正则"的积木。
正则如何约束每一步 token 采样
最终的正则字符串被交给后端编译。以默认的outlines_core后端为例,backends/outlines_core.py 中的get_regex_logits_processor会构造一个Index(regex, self.vocabulary)(把正则编译为以模型词表为索引的有限状态机),并返回OutlinesCoreLogitsProcessor。在生成过程中,logits processor 会在每一步解码前依据当前已生成的 token 序列与正则状态,把"不可能导致合法 CSV"的候选 token 的 logits 直接屏蔽掉。这也解释了为什么Generator产出的结果"始终"符合正则——不是靠提示词碰运气,而是靠解码期硬约束。
完整的后端路由在 backends/init.py:get_regex_logits_processor会根据后端名称(如outlines_core、xgrammar、llguidance)分发到对应实现,这些后端均声明于 backends/base.py 的Backend抽象接口中。
扩展与注意事项
- 新增指标:加一个
columns_to_extract键值对即可,例如columns_to_extract["gross_profit"] = "integer_comma";但新列未经充分准确率测试,生产使用前务必自行验证。 - 缺失值约定:提示词要求用
NULL表示缺失值,与列正则并不冲突(若你的数据允许缺失,可把列正则改为(NULL|...实际模式...))。 - 行数上限:
max_rows = 3假设公司报告三年数据;若某公司报告更多年度,可调大该值或改成无上限形式(如*),但会放宽对输出的约束。 - 提示词中的领域约束:
operating income < revenue、net income < operating income、只看综合利润表、忽略分部/季度/每股数据等规则,是抑制 LLM"幻觉出错误报表"的关键,不应省略。 - token 预算:示例使用
max_new_tokens=1024,足够容纳表头 + 三行数据;若提取更多列或更多年份,需要相应增大。 - 模型选择:Phi-3 mini 只是示例;该方案与模型解耦,任何受支持的本地或远程模型(详见 docs/features/models 下的模型接入文档)都可替换,只需保持提示词模板与正则约束不变。
小结
本文展示了一条完整的"LLM + 正则约束解码"的财报数据提取流水线:正则负责格式,LLM 负责语义。得益于 Outlines 的Regex输出类型与Generator抽象,CSV 这类"结构规整但非 JSON"的文本同样能被严格约束生成,从而把过去依赖复杂解析系统或人工核对的工作,压缩为一次高置信度的模型调用,并且提取结果可以直接进入pandas等下游分析管线。
【免费下载链接】outlinesStructured Outputs项目地址: https://gitcode.com/GitHub_Trending/ou/outlines
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考