行业大模型微调指南

随着 ChatGPT 的发布,大型语言模型 (LLM) 进入了公众视野。ChatGPT 独特的创造力和连贯性结合吸引了公众的想象力,催生了许多新颖的应用。现在甚至有一个专门从事提示工程的专家家庭手工业,即制作提示以便从流行的 LLM 模型中获得所需行为的实践 - 这项技能结合了软件工程师的分析理解和警察审讯员的语言直觉。在之前的一篇文章中,我展示了提示工程如何为技术应用带来强大的 AI 功能。

尽管提示工程非常强大,但在商业应用方面存在很大的局限性:

  • 通过提示工程可以提供的上下文受 GPT 模型输入限制的影响。在 GPT-4 的标准版本中,限制约为 5,000 个标记,每个标记大约对应一个单词。这是相当多的文本,对于大多数基于聊天的应用程序来说应该绰绰有余,但这还不足以让 ChatGPT 获得它所不具备的任何专业知识。
  • 提示工程主要限于自然语言输出。虽然使用提示工程很容易获得 JSON 和/或 XML 格式的回复值,但底层回复仍然基于自然语言查询,而自然填充通常会破坏所需的输出格式。如果人类正在阅读输出,这没问题,但如果您有另一个运行后处理代码的脚本,这可能会成为问题。
  • 最后,提示工程几乎从定义上来说就是一门不精确的科学。提示工程师必须通过反复试验,构建关于 ChatGPT 将如何反应的预测,并将注意力集中在正确的提示上。如果期望的结果甚至有点复杂,这可能会很耗时、不可预测,而且可能非常困难。

为了解决这些问题,我们可以求助于一种鲜为人知但仍然有用的技术,即微调。通过微调,我们可以使用更大的文本主体,控制输入和输出格式,并且通常可以对相关的 LLM 模型施加更多控制。

在本文中,我们将了解什么是微调,构建一个小型微调模型来测试其功能,最后使用更大的输入数据集构建一个更强大的模型。

我希望阅读本文能让您了解如何使用微调来改进你的业务应用程序。 事不宜迟,让我们开始吧。

1、什么是微调?

微调是 OpenAI 的 API 术语,它允许用户使用自己的数据训练 GPT 模型。使用此 API,用户可以创建 OpenAI 的 LLM 模型副本,并向其提供自己的训练数据,包括示例问题和理想答案。LLM 不仅能够学习信息,还能理解训练数据的结构并将其交叉应用于其他情况。例如,OpenAI 研究人员已经能够使用共情问题和答案来创建一个即使在回答全新问题时也通常更具共情性的模型,一些商业用户已经能够创建专门的系统,可以为律师查阅案件文件并建议新的调查途径。

该 API 非常易于使用。用户只需创建一个由问题和答案组成的 JSONL(JSON 行)文件并将其提供给 OpenAI 端点即可。然后,OpenAI 将创建指定模型的副本并在新数据上对其进行训练。

在下一节中,我们将介绍一些测试模型,以熟悉 API 并探索它的一些基本功能,然后再进行更大的努力。

2、API 演示:构建小型模型

在开始解决大问题之前,让我们先训练一个简单的模型来熟悉 API。对于这个例子,让我们尝试构建一个可以将单词转换为 Pig Latin 的模型。对于那些不知道的人来说,Pig Latin 是一个简单的文字游戏,通过对音节的简单操作将英语单词转换为拉丁语单词。

2.1 生成训练数据

为了训练模型,我们需要生成一些示例转换以用作训练数据。因此,我们需要定义一个将字符串转换为 Pig Latin 版本的字符串的函数。我们将在本文中使用 Python,但你可以使用几乎任何主要语言来执行相同操作:

def pig_latin(string):
    # if starts with a vowel, just add "ay"
    # else move the consonants to the end and add "ay"
    if string[0].lower() in {'a', 'e', 'i', 'o', 'u'}:
        return string + 'way'
    else:
        beginning_consonants = []
        for i in range(len(string)):
            if string[i].lower() in {'a', 'e', 'i', 'o', 'u'}:
                break
            beginning_consonants.append(string[i])
        return string[i:] + ''.join(beginning_consonants) + 'ay'

现在我们有了函数,接下来我们要生成训练数据。为此,我们可以简单地从互联网上复制一段文本,从中提取单词,然后将其转换为 Pig Latin。

passage = ''[passage from the internet]]'''
toks = [t.lower() for t in re.split(r'\s', passage) if len(t) > 0]
pig_latin_traindata = [
    {'prompt': 'Turn the following word into Pig Latin: %s \n\n###\n\n' % t, 'completion': '%s [DONE]' % pig_latin(t)}
    for t in toks
]

请注意此代码的几个方面。首先,训练数据被标记为输入名为“prompt”,输出名为“completion”。其次,输入以指令开头,以分隔符“\n##\n”结尾。此分隔符用于向模型指示它应该在标记之后开始回答。最后,完成始终以短语“[DONE]”结尾。这称为“停止序列”,用于帮助模型知道答案何时停止。由于 GPT 的设计怪癖,这些操作是必要的,OpenAI 文档中建议这样做。

数据文件需要采用 JSONL 格式,它只是一组由换行符分隔的 JSON 对象。幸运的是,Pandas 有一个非常简单的快捷方式可以将数据帧转换为 JSONL 文件,因此我们今天将仅依靠它:

pd.DataFrame(pig_latin_traindata).to_json('pig_latin.jsonl', orient='records', lines=True)

2.2 训练

现在我们已将训练数据保存为 JSONL 文件,我们可以开始训练了。只需转到终端并运行:

export OPENAI_API_KEY=[OPENAI_API_KEY]
openai api fine_tunes.create -t pig_latin.jsonl -m davinci --suffix pig_latin

创建请求后,只需稍后使用“fine_tunes.follow”命令进行检查即可。控制台输出应为你提供特定训练请求的确切命令,你可以不时运行该命令以查看训练是否完成。当看到类似以下内容时,微调就完成了:

>> openai api fine_tunes.follow -i [finetune_id]
[2023-08-05 21:14:22] Created fine-tune: [finetune_id]
[2023-08-05 23:17:28] Fine-tune costs [cost]
[2023-08-05 23:17:28] Fine-tune enqueued. Queue number: 0
[2023-08-05 23:17:30] Fine-tune started
[2023-08-05 23:22:16] Completed epoch 1/4
[2023-08-05 23:24:09] Completed epoch 2/4
[2023-08-05 23:26:02] Completed epoch 3/4
[2023-08-05 23:27:55] Completed epoch 4/4
[2023-08-05 23:28:34] Uploaded model: [finetune_model_name]
[2023-08-05 23:28:35] Uploaded result file: [result_file_name]
[2023-08-05 23:28:36] Fine-tune succeeded

2.3 测试

从输出文件中获取模型名称,然后可以简单地在 Python 中测试模型,如下所示:

import requests 

res = requests.post('https://api.openai.com/v1/completions', headers={
    'Content-Type': 'application/json',
    'Authorization': 'Bearer [OPENAI_ID]'
}, json={
    'prompt': “Turn the following word into Pig Latin: Latin“,
    'max_tokens': 500,
    'model': model_name,
    'stop': '[DONE]'
})

print(res.json()[‘choices’][0][‘text’])

你应该看到输出:

atinlay

至此,我们已经训练了一个 Pig Latin LLM,并且熟悉了 API!当然,这是对 GPT3 功能的严重利用不足,因此在下一节中,我们将构建一些更实质性的东西。

3、构建领域专家模型

现在我们已经熟悉了微调 API,让我们扩展我们的想象力,思考一下我们可以使用微调构建什么样的产品。可能性几乎是无穷无尽的,但在我看来,微调最令人兴奋的应用之一是创建领域专家 LLM。该 LLM 将接受大量专有或私人信息的训练,并能够回答有关文本的问题并根据训练数据进行推断。

因为这是一个公开教程,所以我们将无法使用任何专有训练数据。相反,我们将使用公开可用但不包含在基本 Davinci 模型的训练数据中的文本主体。具体来说,我们将教授亨德尔歌剧《阿格里皮娜》的维基百科概要内容。本文未出现在 Davinci 的基础模型中,这是目前市面上最好的 OpenAI GPT3 模型,可用于微调。

3.1 验证基础模型

首先让我们验证基础模型是否对歌剧《阿格里皮娜》一无所知。我们可以问一个基本问题:

prompt = "Answer the following question about the Opera Agrippina: \n Who does Agrippina plot to secure the throne for? \n ### \n",
res = requests.post('https://api.openai.com/v1/completions', headers={
    'Content-Type': 'application/json',
    'Authorization': 'Bearer [OpenAI Key]'
}, json={
    'prompt': prompt,
    'max_tokens': 500,
    'model': 'davinci',
})

打印结果 JSON,你应该看到类似这样的内容:

{'id': 'cmpl-7kfyjMTDcxdYA3GjTwy3Xl6KNzoMz',
 'object': 'text_completion',
 'created': 1691358809,
 'model': 'davinci',
 'choices': [{'text': '\nUgo Marani in his groundbreaking 1988 monograph "La regina del mare: Agrippina minore e la storiografia" () criticized the usual view as myth,[15] stating that Agrippina and Nero both were under the illusion…,
   'index': 0,
   'logprobs': None,
   'finish_reason': 'length'}],
 'usage': {'prompt_tokens': 30, 'completion_tokens': 500, 'total_tokens': 530}}

这段文字似乎是指尼禄和阿格里皮娜,但似乎与历史人物有关,而不是与歌剧有关。此外,该模型似乎指的是虚构的来源,这表明基础模型的训练数据可能没有关于阿格里皮娜和尼禄的非常详细的信息。

既然我们知道基础达芬奇模型不知道歌剧,让我们尝试将歌剧的内容教给我们自己的达芬奇模型!

3.2 获取和清理训练数据

我们首先从 Wikipedia API 下载文章文本。维基百科有一个经过充分测试和良好支持的 API,它以 JSON 格式提供 wiki 文本。我们像这样调用 API:

import requests

res = requests.get('https://en.wikipedia.org/w/api.php', params={
    "action": "query",
    "format": "json",
    "prop": "revisions",
    "titles": "Agrippina_(opera)",
    "formatversion": "2",
    "rvprop": "content",
    "rvslots": "*"
})

rs_js = res.json()
print(rs_js['query']['pages'][0]['revisions'][0]['slots']['main']['content'])

现在我们有了最新的文本数据,让我们做一些文本操作来删除 Wiki 标签。

import re
…

def remove_tags(string, tag):
    toks = string.split(f'<{tag}')
    new_toks = []
    for tok in toks:
        new_toks.append(tok.split(f'</{tag}>')[-1])
    return ''.join(new_toks)

processed = re.sub(r'\[\[File:[^\n]+', '', rs_js['query']['pages'][0]['revisions'][0]['slots']['main']['content'])
processed = re.sub(r'\[\[([^|\]]+)\|([^\]]+)\]\]', r'\2', processed)
processed = remove_tags(processed, 'ref')
processed = remove_tags(processed, 'blockquote')
processed = processed.replace('[[', '').replace(']]', '')
processed = re.sub(r'\{\{[^\}]+\}\}', r'', processed)
processed = processed.split('== References ==')[0]
processed = re.sub(r'\'{2}', '', processed)

print(processed)

它不会删除所有标签和非自然文本元素,但应该删除足够多的标签,以便可以作为自然文本读取。

接下来,我们要根据标题将文本转换为分层表示:

hierarchy_1 = 'Introduction'
hierarchy_2 = 'Main'
hierarchical_data = defaultdict(lambda: defaultdict(list))

for paragraph in processed.split('\n'):
    if paragraph == '':
        continue
    if paragraph.startswith('==='):
        hierarchy_2 = paragraph.split('===')[1]
    elif paragraph.startswith('=='):
        hierarchy_1 = paragraph.split('==')[1]
        hierarchy_2 = 'Main'
    else:
        print(hierarchy_1, hierarchy_2)
        hierarchical_data[hierarchy_1][hierarchy_2].append(paragraph)

3.3 构建训练数据

现在我们有了文章,我们需要将文章转化为训练数据。虽然我们总是可以阅读文章并手动编写训练数据,但对于大段文本,这很快就会变得非常耗时。为了获得可扩展的解决方案,我们需要一种更自动化的方式来生成训练数据。

从文章中生成适当训练数据的一个有趣方法是将文章的各个部分提供给 ChatGPT,并要求它使用提示工程生成提示和完成。这听起来像是循环训练——如果是这样的话,为什么不让 ChatGPT 分析文章呢?这个问题的答案当然是可扩展性。使用这种方法,我们可以分解大段文本并逐段生成训练数据,从而使我们能够处理超出 ChatGPT 输入范围的文本。

例如,在我们的模型中,我们将把剧情概要分为第一幕、第二幕和第三幕。然后,通过修改训练数据以提供额外的背景信息,我们可以帮助模型在段落之间建立联系。通过这种方法,我们可以从大量输入数据中可扩展地创建训练数据,这将是构建能够解决数学、科学或金融问题的领域专家模型的关键。

我们首先为每个幕生成两组提示和完成,一组包含大量细节,一组包含简单的问题和答案。我们这样做是为了让模型既能回答简单的事实问题,也能回答冗长复杂的问题。

为此,我们创建了两个在提示上略有不同的函数:

def generate_questions(h1, h2, passage):
    completion = openai.ChatCompletion.create(
              model="gpt-3.5-turbo",
              messages=[
                {"role": "user", "content": '''
            Consider the following passage from the wikpedia article on Agrippina, %s, %s:
            ---
            %s
            ---
            Generate 20 prompts and completions pairs that would teach a davinci GPT3 model the content of this passage. 
            Prompts should be complete questions.
            Completions should contain plenty of context so davinci can understand the flow of events, character motivations, and relationships.
            Prompts and completions should be long and detailed. 
            Reply in JSONL format
                ''' % (h1, h2, passage)},
              ]
            )
    return completion

def generate_questions_basic(h1, h2, passage):
    completion = openai.ChatCompletion.create(
              model="gpt-3.5-turbo",
              messages=[
                {"role": "user", "content": '''
            Consider the following passage from the wikpedia article on Agrippina, %s, %s:
            ---
            %s
            ---
            Generate 20 prompts and completions pairs that would teach a davinci GPT3 model the content of this passage. 
            Reply in JSONL format
                ''' % (h1, h2, passage)},
              ]
            )
    return completion

然后我们调用函数并将结果收集到数据容器中:

questions = defaultdict(lambda: defaultdict(list))
for h_1, h1_data in hierarchical_data.items():
    if h_1 != 'Synopsis':
        continue
    for h_2, h2_data in h1_data.items():
        print('==========', h_1, h_2, '===========')
        passage = '\n\n'.join(h2_data)
        prompts_completion = generate_questions(h_1, h_2, passage)
        prompts_completion_basic = generate_questions_basic(h_1, h_2, passage)

        questions[h_1][h_2] = {
            'passage': passage,
            'prompts_completion': prompts_completion,
            'prompts_completion_basic': prompts_completion_basic
        }

然后,我们可以将生成的问题从 JSON 转换为对象。我们需要添加一个错误处理块,因为有时 ChatGPT 会生成不可 JSON 解码的输出。在这种情况下,我们只需标记并将有问题的记录打印到控制台:

all_questions = []
for h1, h1_data in questions.items():
    for h2, h2_data in h1_data.items():
        for key in ['prompts_completion', 'prompts_completion_basic']:
            for ob in h2_data[key].choices[0]['message']['content'].split('\n'):
                try:
                    js = json.loads(ob)
                    js['h1'] = h1
                    js['h2'] = h2
                    all_questions.append(js)
                except Exception:
                    print(ob)

df = pd.DataFrame(all_questions)

由于 ChatGPT 不是确定性的(也就是说,每次查询 ChatGPT 时,即使输入相同,也可能得到不同的输出),你的体验可能与我的不同,但就我而言,所有问题都得到了毫无问题地解析。现在,我们在数据框中有了训练数据。

我们快完成了!让我们对训练数据进行一些收尾工作,包括基本上下文、提示的结束标记和完成的停止序列。

df['prompt'] = df.apply(
    lambda row: 'Answer the following question about the Opera Agrippina, Section %s, subsection %s: \n %s \n ### \n'  % (
        row['h1'], row['h2'], row['prompt']
    ), axis=1)

df['completion'] = df['completion'].map(lambda x: f'{x} [DONE]')

检查测试数据,你应该会看到各种训练问题和答案。你可能会看到简短的提示完成对,例如:

Answer the following question about the Opera Agrippina, Section Synopsis, subsection Act 2: 
 What happens as Claudius enters? 
 ### 

All combine in a triumphal chorus. [DONE]

以及长提示完成对,例如:

Answer the following question about the Opera Agrippina, Section Synopsis, subsection Act 3: 
 Describe the sequence of events when Nero arrives at Poppaea's place. 
 ### 

When Nero arrives, Poppaea tricks him into hiding in her bedroom. She then summons Claudius, informing him that he had misunderstood her earlier rejection. Poppaea convinces Claudius to pretend to leave, and once he does, she calls Nero out of hiding. Nero, thinking Claudius has left, resumes his passionate wooing of Poppaea. However, Claudius suddenly reappears and dismisses Nero in anger. [DONE]

现在我们终于可以开始训练了!将数据框写入文件:

with open('agrippina_training.jsonl', 'w') as fp_agrippina:
    fp_agrippina.write(df[['prompt', 'completion']].to_json(orient='records', lines=True))

并像这样调用微调 API:

openai api fine_tunes.create -t agrippina_training.jsonl -m davinci --suffix agrippina

现在,我们可以开始测试了。您可以在此处找到用于生成训练数据的代码。

3.4 测试

训练完成后,我们可以通过获取模型名称并查询它来测试模型,就像我们在前面的部分中所做的那样:

import openai
import requests

model = [Your Model Name]

def query_prompt(prompt):
    res = requests.post('https://api.openai.com/v1/completions', headers={
      'Content-Type': 'application/json',
      'Authorization': 'Bearer [OPENAI_API_KEY]
    }, json={
      'prompt': prompt,
      'max_tokens': 500,
      'model': model,
      'stop': '[DONE]'
    })
    print(“output”)
    print(res.json()[‘choices’][0][‘text’])

让我们首先查询训练数据集中的一个提示:

query_prompt(“Answer the following question about the Opera Agrippina: \n Who does Agrippina plot to secure the throne for? \n ### \n")

你应该看到类似以下的结果:

Agrippina plots to secure the throne for Nero, her son by a former marriage.

现在让我们看看是否可以提出一个措辞新颖的问题:

query_prompt("Answer the following question about the Opera Agrippina: \n What does Claudius promise Otho as a reward for saving his life? \n ### \n")

该模型应该给出如下答案:

The throne

因此,该模型似乎能够回忆起训练数据中的简单事实。对于需要更详细答案的措辞新颖的问题,它将表现如何?让我们看看:

query_prompt("Answer the following question about the Opera Agrippina: \n What is Agrippina's state of mind after Pallas and Narcissus turn against her? \n ### \n")

该模型应该给出如下答案:

After Pallas and Narcissus turn against her, Agrippina finds herself in a precarious situation. She realizes that she no longer has a strategic advantage over Claudius, as her trusted allies have betrayed her and her secret plan has been uncovered. Agrippina's state of mind is one of anxiety and despair, as her carefully crafted manipulation has unraveled and exposed her for the manipulative and deceitful individual that she is. She must now carefully reconsider her next move, weighing the potential repercussions of each option carefully. Agrippina is rattled by the betrayal of Pallas and Narcissus and faces an uncertain future.

模型在这里肯定做出了一些推论,并没有确切说明概要中的内容,但答案大体上是有说服力的。

作为最后的测试,让我们看看它是否能够回答一个复杂的问题,这个问题需要在各个段落之间建立联系:

query_prompt("Answer the following question about the Opera Agrippina: \n How does Agrippina's fortune change through the opera? \n ### \n")

你应该看到如下答案:

Agrippina's fortune changes drastically throughout the course of the opera. Initially, she is adamant about securing the throne for Nero as a means to protect her ambitions for power. However, after Poppaea's intervention, Agrippina is not certain about her control over the situation. She is worried about losing Nero's favor to Poppaea and fears that her plotting has been unraveled by Otho. Agrippina is aware that her authority is slowly slipping away and that her control over the situation is diminishing as time progresses. The seeds of doubt have been planted in her mind, and she strives to maintain her grip over Nero while trying to anticipate the next move of her rivals. Agrippina's fortune fluctuates as she attempts to maintain her influence over Nero and the empire, facing challenges from multiple fronts.

这很有潜力!基于这个答案,我们可以看到模型已经消化了相当多的信息,并且能够在训练期间我们给它的各种信息之间建立联系。

当然,这个模型绝不是完美的。当提示非常复杂时,它仍然容易产生幻觉和困惑的答案。用同一个提示反复查询模型有时会产生截然不同的结果。但是,请记住,我们使用了相对较少的训练数据,我们完全依赖 ChatGPT 来生成提示和完成。如果我们预处理输入数据,制作更详细的训练数据,并生成更多的示例提示和完成,我们可能会进一步提高模型的性能。

4、结束语

今天,我们探索了 OpenAI 的微调 API,并探讨了如何使用微调技术为 GPT 模型提供新知识。尽管我们在实验中使用了公开的文本数据,但相同的技术可以适用于专有数据集。通过正确的训练,微调可以发挥几乎无限的潜力,我希望本文能启发您思考如何在自己的业务或应用程序中使用微调。


原文链接:Creating a Domain Expert LLM: A Guide to Fine-Tuning

BimAnt翻译整理,转载请标明出处