说明
这是一篇翻译的文章,原文发表与2023年三月份 查看原文
原文翻译
在过去的几个月里,ChatGPT 一直占据着新闻头条,人们对其相当复杂的文本生成能力既兴奋又害怕。除了短篇和长篇文本生成之外, 还有很多其他用例提供了大量的实用价值。随着当前这一代大型语言模型(LLM)的出现,自然语言处理(NLP) 中的许多经典任务(如文本分类、情感分析或命名实体识别)几乎都难以解决。
在这篇文章中,我记录了一些关于如何使用 GPT-3(更新:和 3.5)从非结构化文本中提取结构化信息的实验,我希望这篇文章可以作为如何使用 LLM 处理此类任务的教程。本文假设对 GPT-3 或 ChatGPT 有一定的了解,所以如果你从未尝试 过,花五分钟时间了解这些东西的一般工作原理可能是有意义的。
真实世界的数据集 - Hacker News 上的“谁在招聘”帖子
我发现的许多现有教程只给出了一些简单的玩具示例。我想使用真实的数据集来了解使用这种的方法的潜在挑战。当我在 Hacker News 上浏览潜在的合同/远程机会时,我突然想到,这些常规故事,例如“Ask HN: Who is Hiring?”可以从更紧凑、更有条理的演示中受益。由于 Hacker News 报道中的数据可以通过各种 API(官方、Algolia )轻松获得,因此它为一些现实实验提供了完美的起点。
在了解技术细节之前,可以随时查看生成的演示器:hacker-jobs.com
获取数据 - Algolia 的 HN 检索 API
Hacker News 是一个社区网站,用于讨论“任何满足一个人的求知欲的东西”。除了讨论之外,还有招聘广告的月度帖(“故事”)。 最主要的是日常的“Ask HN: Who is Hiring?”帖子。由于这些帖子始终由用户/机器人“whoishiring”创建,因此可以使用 Algolia 的 HN 检索 API 轻松搜索相应的帖子:
https://hn.algolia.com/api/v1/search_by_date?tags=ask_hn,author_whoishiring
还有其他一些的相关帖子,例如“Ask HN: Freelancer? Seeking freelancer?“由同一用户帐户管理,因此您必须仅过滤上述结果中的”谁在招聘“帖子。 对于相关帖子,您可以通过其 ID 访问相应的项目来获取评论,即实际的招聘广告: https://hn.algolia.com/api/v1/items/<ITEM_ID> 。注释包含在 children 数组中。我们只需要顶级子级,因为按照惯例,职位描述将作为新评论发布,没有任何嵌套。
在撰写本文时,这些故事的最新一期是2023年2月。我想将最新一期保留为一份测试集,所以我从2023年1月的数据开始试验。一般“Ask HN: Who is Hiring?” 每月大约有 400到600条评论。实际上,我甚至没有在第一次迭代中使用这么多样本,而是从大约50到100条评论(即职位描述)开始。
提取信息 - 基于 GPT-3 的零样本模型
在机器学习中,我们经常需要大量样本来训练模型来做一些有用的事情。根据具体任务,创建这样的数据集也许是可行的,也可能不可行。 大幅减少所需数据量的一种技方法是从已经在某种类似任务上训练过的模型开始,然后根据预期任务对其进行微调。使用这种方法,我们通常只需要几千个训练样本, 而不像从头开始训练模型时需要数百万或数十亿个样本。
不过,最终,如果我们不需要数千个示例,而只需要几个示例来教模型如何解决某个任务,我们会更高兴。这种情况称为少样本学习。在最极端的情况下, 我们根本不想给出任何具体的例子,而是以不同的方式指导模型。这称为零样本学习。
GPT-3 与其他最近的 LLM 一起允许在一个全新的水平上构建零样本模型。您可能知道,与这些 LLM 交互的主要方式是编写所谓的prompt (即一些文本), 然后由模型“完成”(即扩展更多文本)。早期的语言模型能够根据初始序列生成文本,但大多会很快丢失实际上下文。由此产生的文本在结构/语法上是正确的, 但相对来说没有意义,因此毫无用处。随着 GPT-3 等真正大型模型的出现,这种情况发生了变化。这些模型能够更好地保持在给定的上下文中, 并且可以生成相当长的连贯文本序列。这种特性的一个用法是,我们可以创造性地使用我们输入模型的初始序列(即提示)。 例如,如果我们从问题或任务描述开始,那么为模型扩展此类文本片段的最合理方法是提供答案或解决任务。
这就是事情变得非常有趣的地方,因为我们可以尝试创建一个提示来描述从提供的文本中提取某些信息的任务,我们甚至可以描述应该给出结果的格式。 通常,需要一些实验才能找到合适的提示。此过程通常称为 prompt engineering 。虽然用某种评估框架来测试不同的提示通常是有意义的(类似于构建其他类型的机器学习模型), 但如果你是第一次这样做,最直观的方法是使用官方的 GPT-3 Playground 或 ChatGPT 界面, 你可以简单地编写提示并由模型完成它们。
我寻找合适提示的过程包括在 Playground 中进行一些临时实验,以提出一些似乎有效的候选者。然后,我从2023年1月开始使用官方 GPT-3 API 对 大约50个招聘广告测试了这些候选人,并手动检查了生成的结果,以了解错误情况以及如何改进提示。这个过程重复了几次。手动进行评估通常不是最好的主意, 但在这种情况下,提出一个有用的自动评估会给自己带来麻烦,因为该任务没有明确的单一解决方案(您很快就会明白)。总而言之,我在这个过程中花了大约10到 15美元。我想出的最后一个提示如下:
Your task is to parse an unstructured job posting and turn it into a JSON containing the most important information. The job posting can describe one or more jobs at the same company. The JSON should consist of the following information:
- The company name (field name: "companyName", field type: string)
- the location of the company (field name: "companyLocation", field type: string); if not explictily stated, you can try to infer the company's actual location from other clues, e.g., something like "Remote (US)" usually means that the company is located in the US; if the location cannot be inferred, set it to null
- a short description of what the company is doing or building (field name: "companyDescription", field type: string); try to keep it short (max length: ca. 300 characters)
- a list of advertised jobs (field name: "jobs", field type: array).
Each element of the "jobs" array should contain the following fields:
- The job title (field name: "jobTitle", field type: string); the job title should be given in the singular form (i.e., Frontend Developer instead of Frontend Developers)
- the salary range (field name: "salary", field type: string); only include explictly stated salary amounts, otherwise set to null
- whether equity is part of the compensation (field name: "equity", field type: boolean)
- the benefits (field name: "benefits", field type: string); include things like 401k, insurance, equipment, child care, etc. if stated, otherwise set to null
- the location of the job (field name: "location", field type: string)
- whether this is a job for senior/experienced candidates (field name: "senior", field type: boolean); typically senior, staff, lead, principal, vp, cto, etc. positions are all regarded as senior level
- whether it is a remote opportunity (field name: "remote", field type: boolean)
- whether it can be done onsite from an office (field name: "onsite", field type: boolean)
- whether it can be done part-time (field name: "partTime", field type: boolean)
- whether it can be done full-time (field name: "fullTime", field type: boolean)
- the URL to the specific job description (field name: "jobUrl", field type: string)
- and any specific requirements/skills that might be stated (field name: "requirements", field type: string).
In general, if certain information is not stated, set the respective field to null. If the company seeks more than one person for the same role, include the role only once.
This is the job posting:
%s
The structured JSON representation is:
```json
{"companyName":
以下是导致此提示的主要思想和观察结果:
- 提示首先描述常规任务(“将非结构化职位发布 [..]转换为 JSON“)。
- 我还提到,招聘启事可以宣传多个角色/工作。
- 然后,我详细描述了所需的输出格式。对于每个字段,我指定字段名称和字段类型。
- 对于某些字段,我给出了额外的提示,这些提示主要来自观察到的错误情况或不良行为,例如,角色有时以单数形式编写,有时以复数形式编写,原始提示逐字复制,但我想将其标准化。
- 我还列出了比我实际感兴趣的更多的字段。字段越少,似乎更有可能为字段选择错误但以某种方式相关的信息。通过为几乎所有相关信息引入字段,结果似乎更加准确。
- 我还让模型知道,如果缺少信息,可以设置一个字段为 null 。
- 在以前的版本中,在某些情况下,角色会列出两次,没有任何差异。原因是该帖子表明该公司正在寻找,例如,两名开发人员。因此,我添加了指令,仅包含一次完全解决问题的指令。
- 在说明之后,我添加了原始招聘信息。请注意: %s 是 Python 中用于字符串格式化的占位符。在输入到模型的实际提示中,它被完整的职位发布所取代。
- 然后我宣布 JSON 表示形式。
- 我在其他地方看到的一个技巧是启动
```json
, 这是Markdown 语言中在文档中包含代码和类似文本的典型方式。 - 最后,我还编写了应该完成的实际 JSON 的第一部分。
举个例子,让我们以2023年2月的这篇帖子为例:
Friendly Captcha | Software Engineer (Full-stack) | REMOTE (domiciled within EU only) | PART-TIME or FULL-TIME | Office in Munich, Germany | https://friendlycaptcha.com
At Friendly Captcha we do one thing and we do it well: we protect websites from malicious actors and bots. We provide privacy-friendly and accessible anti-bot solutions that don’t annoy users with tasks like clicking fire hydrants.
We’re a fast-growing, profitable company. Our revenue mostly comes from medium to large European enterprise and governments.
A great fit would be someone who can contribute across the entire stack, and prefers to keep things simple (perhaps boring is the right word even?). Good knowledge of the web and browser APIs is definitely useful in this role. At our scale we are looking for generalists more than specialists - it's inevitable you will be wearing different hats.
Our engineering team is fully remote across Europe, we meet up once per quarter or so. You can work either 4 or 5 days per week, fully remote or from our Munich office.
Stack: Golang | Typescript | Clickhouse | Postgres | Redis | Python ML/data science stack
当将上述提示(插入上述职位)输入 GPT-3 Davinci 模型时,我们得到以下结果:
{
"companyName": "Friendly Captcha",
"companyLocation": "Munich, Germany",
"companyDescription": "At Friendly Captcha we do one thing and we do it well: we protect websites from malicious actors and bots. We provide privacy-friendly and accessible anti-bot solutions that don’t annoy users with tasks like clicking fire hydrants.",
"jobs": [
{
"jobTitle": "Software Engineer (Full-stack)",
"salary": null,
"equity": null,
"benefits": null,
"location": "REMOTE (domiciled within EU only)",
"senior": false,
"remote": true,
"onsite": true,
"partTime": true,
"fullTime": true,
"jobUrl": "https://friendlycaptcha.com",
"requirements": "Good knowledge of the web and browser APIs; Stack: Golang | Typescript | Clickhouse | Postgres | Redis | Python ML/data science stack"
}
]
}
由于对于此类任务,我们并不希望模型变得有创意,因此我使用了以下参数(您可以在 playground 中设置这些参数,也可以在以编程方式使用 API 时设置这些参数):
- Temperature: 0
- Maximum length: 4000 - <length_of_the_prompt>
- Stop sequences: ```
- Top P: 0.1
- Frequency penalty: 0
- Presence penalty: 0
调用 OpenAI API
很明显,我们不想在 Playground 中手动解析每条评论。使用官方的 OpenAI API 非常简单,main 函数基本上如下所示:
import json
import openai
openai.api_key = 'abcdef' # your API key
prompt_raw = 'Your task is to parse an unstructured [..]' # see above
def parse_job_posting(job_posting: str):
full_prompt = prompt_raw % (job_posting,) # insert actual job post into prompt
num_prompt_tokens = int(len(full_prompt) / 3) # estimate the length of the prompt
max_tokens = 4000 - num_prompt_tokens # calculate the max available tokens for the response
# call the OpenAI API
response = openai.Completion.create(
model='text-davinci-003', # the best GPT-3 model
prompt=full_prompt,
temperature=0,
max_tokens=max_tokens,
top_p=0.1,
stop=['```'],
echo=True # returns the whole prompt including the completion
)
result_raw = response.choices[0].text
json_str = result_raw.split('```json')[1].strip() # since we used echo=True, we can split on the json marker
return json.loads(json_str)
该方法将解析后的数据作为 Python 对象返回。
优化成本 - 微调 GPT / GPT-3.5 / GPT-4
在撰写本文时,OpenAI 通过其 API 提供以下模型:
- GPT-3 (InstructGPT)
- Davinci
- Curie
- Babbage
- Ada
- GPT-3.5 (Chat)
- GPT-4
当我开始这个小项目时,只有 GPT-3 模型。在这四种型号中,达芬奇型号是最强大的,但也是最昂贵的型号,1000tokens/$0.02 。如上所示, 具有相当长提示的单个完成会消耗大约1000到 1500 个token,因此解析一个职位发布的成本约为0.02到0.03美元。每月有500多个帖子, 仅解析数据每月的费用约为15美元。在这种情况下,这可能是负担得起的,但在其他情况下,这可能会很快变得昂贵。
下一个最好的 GPT-3 模型 Curie 便宜一个数量级,1000tokens/$0.002。我尝试给它提供相同的提示,但事实证明结果的质量不够好, 无法使用。但是,OpenAI 提供了在自定义数据集上微调模型的能力。这样的自定义数据集本质上是输入/输出对的列表。由于我不想手动创建数据集, 因此我 使用复杂但昂贵的零样本方法,使用 Davinci 模型来创建数据集,然后使用它来创建微调的 Curie 模型。使用这种微调模型能够以仅10%的成本达到几乎相同的质量。
我最初打算对这种微调方法进行更详细的描述,但后来发布了 GPT-3.5 模型。gpt-3.5-turbo 模型费用1000tokens/$0.002 (即与居里模型相同), 但在使用零样本方法时,它的效果几乎与旧的 Davinci 模型一样好。尽管 Davinci 模型在手头的任务中似乎比 GPT-3.5 模型略好,但后者仍然完全可用, 性能上的差异并没有达到10倍的价格差距。
长话短说:对于手头的任务,使用 GPT-3.5 非常有意义,目前不需要我最初对旧 GPT-3 模型使用的微调解决方法。我还没有能够尝试 GPT-4, 但这些模型会更贵一点,因此在这个用例中,合理的权衡可能仍然有利于 GPT-3.5 模型。
由于 GPT-3.5 是一个“聊天”模型,因此它的 API 与调用以前的模型时略有不同。我使用了与上面几乎相同的提示,只是没有最后一部分使用 json 标记, 并且添加了一些要求模型省略任何解释并输出纯 JSON 的附加内容:
Your task is to parse an unstructured job posting and turn it into a JSON containing the most important information. The job posting can describe one or more jobs at the same company. The JSON should consist of the following information:
- The company name (field name: "companyName", field type: string)
- the location of the company (field name: "companyLocation", field type: string); if not explictily stated, you can try to infer the company's actual location from other clues, e.g., something like "Remote (US)" usually means that the company is located in the US; if the location cannot be inferred, set it to null
- a short description of what the company is doing or building (field name: "companyDescription", field type: string); try to keep it short (max length: ca. 300 characters)
- a list of advertised jobs (field name: "jobs", field type: array).
Each element of the "jobs" array should contain the following fields:
- The job title (field name: "jobTitle", field type: string); the job title should be given in the singular form (i.e., Frontend Developer instead of Frontend Developers)
- the salary range (field name: "salary", field type: string); only include explictly stated salary amounts, otherwise set to null
- whether equity is part of the compensation (field name: "equity", field type: boolean)
- the benefits (field name: "benefits", field type: string); include things like 401k, insurance, equipment, child care, etc. if stated, otherwise set to null
- the location of the job (field name: "location", field type: string)
- whether this is a job for senior/experienced candidates (field name: "senior", field type: boolean); typically senior, staff, lead, principal, vp, cto, etc. positions are all regarded as senior level
- whether it is a remote opportunity (field name: "remote", field type: boolean)
- whether it can be done onsite from an office (field name: "onsite", field type: boolean)
- whether it can be done part-time (field name: "partTime", field type: boolean)
- whether it can be done full-time (field name: "fullTime", field type: boolean)
- the URL to the specific job description (field name: "jobUrl", field type: string)
- and any specific requirements/skills that might be stated (field name: "requirements", field type: string).
In general, if certain information is not stated, set the respective field to null. If the company seeks more than one person for the same role, include the role only once. Please output only the pure JSON representation. Do not include any explanations, comments, thoughts, etc. The output has to be a valid JSON object which can be parsed as is.
This is the job posting:
%s
作为一种聊天模型,GPT-3.5 需要消息列表而不是提示。显然,在我们的例子中,我们只需要一条消息来请求生成 JSON:
import json
import openai
openai.api_key = 'abcdef' # your API key
prompt_raw = 'Your task is to parse an unstructured [..]' # see above
def parse_job_posting(job_posting: str):
full_prompt = prompt_raw % (job_posting,) # insert actual job post into prompt
# call the OpenAI API
response = openai.ChatCompletion.create(
model='gpt-3.5-turbo',
messages=[
{'role': 'system', 'content': 'You are a helpful assistant that parses unstructured job postings into structured JSON data.'},
{'role': 'user', 'content': full_prompt}
],
temperature=0
)
json_str = response.choices[0].message.content
return json.loads(json_str)
这种方法是我目前在 hacker-jobs.com 这个演示网站中使用的。因此,您可以自己检查 GPT-3.5 模型的性能(使用此提示、设置等;它可能仍然可以改进)。
我们每个月大约有 500 个工作相关的帖子,其中一些是多个职位的广告,我们最终每月有超过 1000 个职位。正如您在网站上看到的, 有几个过滤器,例如“远程”、“兼职”等, 可让您根据一些一般特征缩小选择范围。但是,职位公告板通常也会提供选择特定工作类别(例如,“iOS 开发人员”)。 由于“Who is hiring”主题并不局限于某些类型的工作,而且通常有一些有趣的角色可能不适合一般的类别,因此我尝试了一种不同的方法来根据个人的兴趣对职位进行排序: 对于每个职位,都会创建嵌入职位描述(包括公司描述)的文本, 然后,可以使用它按与所选职位的相似性进行排序。
使用 OpenAI API 再次创建嵌入非常容易:
import openai
openai.api_key = 'abcdef' # your API key
def get_embedding(job_description: str):
response = openai.Embedding.create(
model="text-embedding-ada-002",
input=job_description
)
return response.data[0].embedding
但是,由于 hacker-jobs.com 网站的架构,存在一个小问题:该网站是使用 Nuxt 构建的,并且它的构建方式允许在客户端上对作业列表进行排序和过滤。 这意味着在第一个请求中将整个数据集发送到客户端。如果没有嵌入,数据集并不是很大,但使用 OpenAI 的原始嵌入会创建一个巨大的文件,因为嵌入的维度是 1536。
由于我们的应用程序范围非常有限,因此我们可能不需要如此大的容量来描述作业。因此,我们可以尝试降低嵌入的维数,例如,通过使用普通的主成分分析。 在 hacker-jobs.com 网站上,我只使用 10 个维度的嵌入,在按相似度排序时,这些嵌入似乎仍然会产生合理的结果。
结论
我希望这篇文章能给你一些想法,如何从非结构化文本中提取信息。使用当前一代的LLM,获得良好的结果是非常简单的。你可以随时查看此方法的演示, hacker-jobs.com 除了每个结构化表示之外,您还有指向源文本的链接。这可能会让您直观地了解哪些有效以及哪里存在潜在的错误情况。
我想本文的大多数读者都对 ChatGPT 玩过很多次,但如果你还没有并且对一些用例示例感兴趣,我之前为(大多数)非技术用户写了一个小指南,解释了使用 ChatGPT 和 DALL-E 的基础知识。