展示结构化输出在财务和执法文档信息提取中的实际应用,包含工程实现细节。对需要处理非结构化数据的开发者有直接参考价值。
简而言之:这个演示展示了如何调用 OpenAI 的 gpt-4o-mini 模型,向它提供文档截图的 URL,并按照你定义的 schema 提取数据。即使只花很少精力定义数据,并且完全不进行数据预处理,结果也相当可靠。对于涉及公共文档的大规模数据收集项目,OpenAI 的 API 可能是一种成本效益很高的工具。
OpenAI 宣布为其 API 推出 Structured Outputs 功能。该功能允许用户指定提取数据的字段和 schema,并保证输出的 JSON 遵循这一规范。
例如,给定一份美国国会财务披露报告,其中资产通过如下表格定义:
你可以使用 JSON schema 定义预期提取的数据模型,也可以像本演示一样通过 pydantic 库定义:
class Asset(BaseModel):
asset_name: str
owner: str
location: Union[str, None]
asset_value_low: Union[int, None]
asset_value_high: Union[int, None]
income_type: str
income_low: Union[int, None]
income_high: Union[int, None]
tx_gt_1000: bool
class DisclosureReport(BaseModel):
assets: list[Asset]
OpenAI 的 API 会根据字段名称推断数据模型与待解析实际文档之间的对应关系(上面的例子比较基础;也可以为每个数据字段提供详细描述),并以 JSON 格式生成提取的数据:
{
"asset_name": "11 Zinfandel Lane - Home & Vineyard [RP]",
"owner": "JT",
"location": "St. Helena/Napa, CA, US",
"asset_value_low": 5000001,
"asset_value_high": 25000000,
"income_type": "Grape Sales",
"income_low": 100001,
"income_high": 1000000,
"tx_gt_1000": false
},
{
"asset_name": "25 Point Lobos - Commercial Property [RP]",
"owner": "SP",
"location": "San Francisco/San Francisco, CA, US",
"asset_value_low": 5000001,
"asset_value_high": 25000000,
"income_type": "Rent",
"income_low": 100001,
"income_high": 1000000,
"tx_gt_1000": false
}
这个演示 gist 提供了以下两种场景的代码和结果:
财务披露报告:这是一个从 PDF 数据表格中提取信息的问题。通常,你需要使用 pdfplumber 之类的 PDF 解析库,并自行编写数据解析方法。
报纸的警方简报:这是处理非结构化信息的场景——由记者撰写的报案事件简短描述。通常,你需要雇人阅读、理解这些内容并录入数据。
注意:这些都是非常基础的示例,只向 API 提供了最低限度的指令(例如“从这张图片中提取文本”),用于定义预期数据 schema 的代码也相对较少。话虽如此
如何运行这些代码/使用这个演示
每个示例都包含用于生成相应 JSON 输出的 Python 脚本。要自行重新运行这些脚本,首先需要在 platform.openai.com 创建自己的 OpenAI 开发者账户,然后:
向账户余额中充值几美元。这两个示例都会使用大约 30,000~50,000 个 token,也就是说,单次执行的成本约为半美分)
将密钥设置为 $OPENAI_API_KEY 环境变量;或者,也可以把密钥粘贴到 api_key 参数中,即将 client = OpenAI() 替换为 client = OpenAI(api_key='Yourkeyhere')
或者,也可以把密钥粘贴到 api_key 参数中,即将 client = OpenAI() 替换为 client = OpenAI(api_key='Yourkeyhere')
然后安装 OpenAI Python SDK 和 pydantic:
pip install openai pydantic
为了方便使用,这些脚本已设置为利用 gpt-4o-mini 的视觉能力,通过 Web URL 读取 PNG 文件。如果你想修改脚本,测试自己选择的 URL,只需修改脚本顶部的 INPUT_URL 变量。
扫描版财务披露材料
财务披露报告
脚本:extract-financial-disclosure.py
结果:output-financial-disclosure.json
下面的截图取自完整报告的 PDF,该报告可在 disclosures-clerk.house.gov 找到)。请注意,这个示例只是把 PDF 的 PNG 截图传给了 OpenAI API——如果直接传入实际的 PDF,结果可能有所不同,效率也可能更高。
从下面的代码片段可以看出,结果看起来很准确,也符合预期。请注意,即使 Location 和 Description 字段并非以表格形式提供(也就是说,它们作为自由格式文本混在 Asset 描述中),模型也能正确解析这些字段(如果存在)。
它还能理解 tx_gt_1000 对应 Tx. > $1,000? 表头,并且该字段包含复选框。即使示例页面中没有任何复选框被选中的例子,模型也能正确推断出 tx_gt_1000 为 false。
{
"asset_name": "AllianceBernstein Holding L.P. Units (AB) [OL]",
"owner": "OL",
"location": "New York, NY, US",
"description": "Limited partnership in a global asset management firm providing investment management and research services worldwide to institutional, high-net-worth and retail investors.",
"asset_value_low": 1000001,
"asset_value_high": 5000000,
"income_type": "Partnership Income",
"income_low": 50001,
"income_high": 100000,
"tx_gt_1000": false
},
{
"asset_name": "Alphabet Inc. - Class A (GOOGL) [ST]",
"owner": "SP",
"location": null,
"description": null,
"asset_value_low": 5000001,
"asset_value_high": 25000000,
"income_type": "None",
"income_low": null,
"income_high": null,
"tx_gt_1000": false
},
同样令人满意的是,我甚至不必进行最低限度的“数据预处理”:我只向它提供了报告页面的截图——截图顶部三分之一的内容是我不需要的信息——而它“知道”自己只应该关注 Schedule A: Assets and Unearned Income 标题下的数据。
如果我要实际抓取财务披露信息,我会使用 json-schema 的 description 属性。在 Pydantic 中,可以这样定义它:
from pydantic import BaseModel, Field
class Asset(BaseModel):
asset_name: str = Field(
description="The name of the asset, under the 'Asset' header"
)
owner: str = Field(
description="Under the 'Owner' header, a 2-letter abbreviation, e.g. SP, DC, JT"
)
location: Union[str, None] = Field(
description="Some records have 'Location:' text as part of the 'Asset' header"
)
description: Union[str, None] = Field(
description="Some records have 'Description:' text as part of the 'Asset' header"
)
asset_value_low: Union[int, None] = Field(
description="Under the 'Value of Asset' field, the left value of the string and converted to an integer, e.g. '15001' from '$15,001 - $50,000'"
)
asset_value_high: Union[int, None] = Field(
description="Under the 'Value of Asset' field, the right value of the string and converted to an integer, e.g. '50000' from '$15,001 - $50,000'"
)
income_type: str = Field(description="Under the 'Income Type(s) field")
income_low: Union[int, None] = Field(
description="Under the 'Income' field, the left value of the string and converted to an integer, e.g. '15001' from '$15,001 - $50,000'"
)
income_high: Union[int, None] = Field(
description="Under the 'Income' field, the right value of the string and converted to an integer, e.g. '50000' from '$15,001 - $50,000'"
)
tx_gt_1000: bool = Field(
description="Under the 'Tx. > $1,000?' header: True if the checkbox is checked, False if it is empty"
)
class DisclosureReport(BaseModel):
assets: list[Asset]
不过,从结果 JSON 中可以看出,OpenAI 的模型似乎足够“聪明”,即使没有具体指令,也能理解这种基础的数据抄录任务。
没有指令的财务披露报告
我很好奇,在完全不给模型任何指令的情况下,它能做得多好。也就是说,你不费心定义 pydantic 模型,而是传入 {"type": "json_object"} 作为响应格式:
response = client.beta.chat.completions.parse(
response_format={"type": "json_object"},
model="gpt-4o-mini",
messages=input_messages
)
答案是:完全没问题。你可以在这里查看代码和完整结果:
extract-basic-financial-disclosure.py
output-basic-financial-disclosure.json
在没有定义 schema 的情况下,模型把整份文档(而不仅仅是资产明细表)都视为数据:
{
"document": {
"title": "Financial Disclosure Report",
"header": "Clerk of the House of Representatives \u2022 Legislative Resource Center \u2022 B81 Cannon Building \u2022 Washington, DC 20515",
"filer_information": {
"name": "Hon. Nancy Pelosi",
"status": "Member",
"state_district": "CA11"
},
"filing_information": {
"filing_type": "Annual Report",
"filing_year": "2023",
"filing_date": "05/15/2024"
},
"schedule_a": {
"title": "Schedule A: Assets and 'Unearned' Income",
"assets": [
{
"asset": "11 Zinfandel Lane - Home & Vineyard [RP]",
"owner": "JT",
"value": "$5,000,001 - $25,000,000",
"income_type": "Grape Sales",
"income": "$100,001 - $1,000,000",
"location": "St. Helena/Napa, CA, US"
},
它将值保留为文本,例如使用 "value": "$5,000,001 - $25,000,000",而不是 "asset_value_low": 5000001。对于没有可选数据字段的条目,它也省略了这些字段,例如 location 和 description:
{
"asset": "AllianceBernstein Holding L.P. Units (AB) [OL]",
"owner": "SP",
"value": "$1,000,001 - $5,000,000",
"income_type": "Partnership Income",
"income": "$50,001 - $100,000",
"location": "New York, NY, US",
"description": "Limited partnership in a global asset management firm providing investment management and research services worldwide to institutional, high-net-worth and retail investors."
},
{
"asset": "Alphabet Inc. - Class A (GOOGL) [ST]",
"owner": "SP",
"value": "$5,000,001 - $25,000,000",
"income_type": "None"
},
扫描版财务披露报告
extract-scanned-financial-disclosure.py
output-scanned-financial-disclosure.json
正如我在本节开头所说,这张报告截图来自一个包含实际文本的 PDF——过去 5 年的大多数国会财务披露文件都使用了电子申报系统,因此即使输出格式是 PDF,其数据本身也会更加规整。
于是,我尝试对一份 2008 年前后的报告截图使用 Structured Outputs,结果相当可靠。
主要需要注意的是,我不得不将页面方向旋转 90 度。模型确实尝试了解析纵向排列的页面,并正确识别出了大约一半的值——这可能是最糟糕的情况之一(你宁愿模型彻底搞砸,这样至少还能通过自动错误检查发现问题)。
报纸警情简报
结果:output-police-blotter.json
结果:output-police-blotter.json
截图取自《Stanford Daily》档案:https://archives.stanforddaily.com/2004/04/09?page=3§ion=MODSMD_ARTICLE12#article
出于下文将详细解释的原因,这个示例并不是为了对模型能力进行合理测试。不过,用这种本来并非作为“数据”存在、而且天然充斥着数据质量问题的内容来做实验,看看模型表现如何,还是很有意思的。
想想一条基本的犯罪事件报告可能包含哪些数据点:
何时:日期和时间
何人:受害者、嫌疑人
何事:嫌疑人涉嫌实施的犯罪
我们很容易想到许多变体和边界情况:
没有具体时间:例如,“计算机科学专业的研究生报告称,他们的书在过去五个月里从 Gates Computer Science Building 被盗。”
没有列出地点:不清楚是报告者有意省略了地点,还是原始警方报告中就没有记录。
没有嫌疑人(“一次与酒精有关的医疗求助”),或没有受害者(例如“一次意外火灾报警”)。也可能有多名嫌疑人和多名受害者。
与财务披露示例不同,输入数据是自由形式的叙述文本。定义什么是警情简报完全是我们的责任,而这最终需要定义什么是犯罪事件。毫不意外,对应的 Pydantic 代码冗长得多。我敢打赌,如果让 1,000 名记者分别写一个定义,他们给出的答案都会不同。
下面是我的定义:
# Define the data structures in Pydantic:
# an Incident involves several Persons (victims, perpetrators)
class Person(BaseModel):
description: str
gender: str
is_student: bool
# Pydantic docs on field descriptions:
# https://docs.pydantic.dev/latest/concepts/fields/
class Incident(BaseModel):
date: str
time: str
location: str
summary: str = Field(description="""Brief summary, less than 30 chars""")
category: str = Field(
description="""Type of report, broadly speaking: "violent" , "property", "traffic", "call for service", or "other" """
)
property_damage: str = Field(
description="""If a property crime, then a description of what was stolen/damaged/lost"""
)
arrest_made: bool
perpetrators: list[Person]
victims: list[Person]
incident_text: str = Field(
description="""Include the complete verbatim text from the input that pertains to the incident"""
)
class Blotter(BaseModel):
incidents: list[Incident]
警情简报结果
我要求模型提供一个 incident_text 字段,也就是从中提取事件数据点的原文。这对评估实验很有帮助。但对于实际的数据项目,你可能会想省略它,因为它会增加输出 token 数量和 API 成本。
incident_text: str = Field(
description="""Include the complete verbatim text from the input that pertains to the incident"""
)
从上述片段中提取出的 incident_text 字段基本正确:
教育学院的一名研究生报告称,他的自行车锁在 Rains 公寓附近时,两名身份不明的嫌疑人破坏了车轮。
然而,它遗漏了印刷版事件记录开头的 11:40 p.m.。通常我希望将其包括在内,因为我想知道模型在提取该数据点时查看了哪些完整信息。
在其余数据输出中,时间 11:40 p.m. 被正确包含:
{
"date": "April 2",
"time": "11:40 p.m.",
"location": "Rains apartments",
"summary": "Bike vandalized",
"category": "property",
"property_damage": "Wheel of bike",
"arrest_made": false,
"perpetrators": [
{
"description": "Two unknown suspects",
"gender": "unknown",
"is_student": false
}
],
"victims": [
{
"description": "A graduate student in the School of Education",
"gender": "unknown",
"is_student": true
}
],
"incident_text": "A graduate student in the School of Education reported that two unknown suspects vandalized the wheel of his bike when it was locked near the Rains apartments."
}
与财务披露报告一样,我的脚本只提供截图,然后让 OpenAI 的模型自行判断其中的内容。gpt-4o-mini 仅凭“Extract the text from this image”这样简单的指令,就能从报纸印刷版的列表文章中提取出结构,这种表现令我惊喜。
例如,乍看这份警情简报,似乎每起事件都有日期(位于副标题中)和时间(位于段落开头)。但在“Thursday, April 1”下面,你会发现这种模式已经被打破:
第二段(“A female administrator in Materials Science...”)是否是 9:30 p.m. 那起事件的延续?在那起事件中,“一名男子报告称,有人拆走了他的后车牌”。
大多数人类读者在读完这两个段落——然后再读完简报的其余部分——都会意识到,这是两起独立的事件。但文本结构中完全没有任何信息表明这一点。在运行这个实验之前,我以为自己必须向模型提供详细的解析指令,例如:
你正在阅读的是一份警情简报,其中列出了警方接到报警的事件。每个段落都应视为一起独立事件。大多数事件(但并非全部)以时间戳开头,例如“11:20 p.m”。
但模型自行判断出了这里存在两起事件,而且第二起发生在 4 月 1 日,具体时间不详。
{ "date": "April 1", "time": "9:30 p.m.", "location": "Toyon parking lot", "summary": "License plate stolen", "category": "property", "property_damage": "rear license plate", "arrest_made": false, "perpetrators": [], "victims": [ { "description": "Man", "gender": "unknown", "is_student": false } ], "incident_text": "A man reported that someone removed the rear license plate from his vehicle when it was parked in the Toyon parking lot." }, { "date": "April 1", "time": "unknown", "location": "unknown", "summary": "Unauthorized purchase reported", "category": "other", "property_damage": "computer equipment", "arrest_made": false, "perpetrators": [], "victims": [ { "description": "Female administrator", "gender": "female", "is_student": false } ], "incident_text": "A female administrator in Materials Science and Engineering reported that an administrative associate had made an unauthorized purchase of computer equipment at Fry\u2019s Electronics sometime in the past five months." },
据我统计,这一期《斯坦福日报》的警方简报中共有 19 起事件,而 API 正确返回了 19 起不同的事件。
同样,这个数据模型本身就很混乱,而我只花了很少的精力来描述什么是“事件”,例如可能出现的各种情况和边缘案例。这一点,再加上数据本身固有的局限性,是模型大多数问题的根本原因。
例如,我原本希望 perpetrators 和 victims 是由专有名词或简单名词组成的列表,以便我们提出诸如“有多少起事件涉及多人”之类的问题。给定以下事件文本:
教育学院的一名研究生报告称,他的自行车锁在 Rains 公寓附近时,两名身份不明的嫌疑人破坏了车轮。
——模型是这样解析嫌疑人的:
"perpetrators": [ { "description": "Two unknown suspects", "gender": "unknown", "is_student": false } ]
对于数据项目,我可能更希望得到一种能够轻松返回结果 2 的输出,例如:
"perpetrators": [ { "description": "Unknown suspect", "gender": "unknown", "is_student": false } { "description": "Unknown suspect", "gender": "unknown", "is_student": false } ]
但在没有具体指令的情况下,模型怎么会知道我想做什么呢?我认为,大多数人在得到同样简略的指令时,也会记录为“Two unknown suspects”。
然而,模型在填写 perpetrators 和 victims 列表时遇到了很大困难。例如,在没有明确提及受害者的情况下,它经常会把嫌疑人或行为人误认为受害者:
一名男性本科生因骑自行车闯停车标志,以及未安装自行车车灯、未办理自行车牌照而收到传票,随后被释放。
"victims": [
{
"description": "A male undergraduate",
"gender": "male",
"is_student": true
}
]
不用说,当叙述更加复杂时,模型也无法正确识别。例如,在 Fry's 未经授权采购事件中:
材料科学与工程系的一名女性行政人员报告称,一名行政助理在过去五个月内的某个时间,未经授权在 Fry's Electronics 购买了计算机设备。
这名“女性行政人员”并不是受害者,而是报案人。受害者应该是斯坦福大学,更确切地说,是该校的材料科学与工程系。
模型在识别受害者和嫌疑人方面出现问题并不令我意外,不过我不确定需要增加多少额外指令,才能让通用模型给出可靠的结果。
模型有一个经常出现、又令人费解的错误:对人物性别进行分类。
我是这样使用 pydantic 定义 Person 的:
class Person(BaseModel): description: str gender: str is_student: bool
即使主语名词明显带有性别信息,模型也会莫名其妙地弄错:
一名男子报告称,他的车辆停放在 Toyon 停车场时,有人拆走了车辆的后车牌。
"victims": [
{
"description": "A man",
"gender": "unknown",
"is_student": false
}
]
当主语名词本身没有表明性别,但句子的其他部分表明了性别时,情况更糟:
教育学院的一名研究生报告称,他的自行车锁在 Rains 公寓附近时,两名身份不明的嫌疑人破坏了车轮。
"victims": [ { "description": "A graduate student in the School of Education", "gender": "unknown", "is_student": true } ],
不确定问题出在哪里。如果我提供明确而详尽的指令和示例,也许可以解决这个问题;但与 OpenAI 模型能够自行推断出的其他信息相比,这似乎应该是一件容易得多的事。
由于有如此多的内容需要由 LLM 自行理解,因此每次运行 extract-police-blotter.py 脚本时得到不同的结果并不令人意外,尤其是在犯罪分类方面。
在数据规范中,我确实尝试向模型描述我对 category 字段的要求:
category: str = Field( description="""Type of report, broadly speaking: "violent" , "property", "traffic", "call for service", or "other" """ )
由于可以选择“other”,模型似乎很乐意把它用于任何稍微模糊的情况。它将 Fry's 的未经授权采购归类为“other”,尽管按照 FBI 的 UCR 定义,挪用公款归入财产犯罪会更合适。也许可以通过向模型提供法规和刑法的详细示例及定义来解决这个问题?
但归根结底,正如我一开始所说,模型的表现受限于源数据本身的局限和错误。例如,在我看来,有人头部被瓶子击中显然属于“violent”,也就是袭击:
一名男性本科生在 Sigma Alpha Epsilon 发生争执期间,后脑勺被瓶子击中。两名本科生被列为嫌疑人,但无人被捕。
然而,模型认为它属于“other”:
{ "date": "April 4", "time": "3:05 a.m.", "location": "Sigma Alpha Epsilon", "summary": "Altercation reported", "category": "other", "property_damage": "None", "arrest_made": false, "perpetrators": [ { "description": "Two undergraduate suspects", "gender": "unknown", "is_student": true } ], "victims": [ { "description": "A male undergraduate", "gender": "male", "is_stud