利用模型蒸馏微调模型:从GPT-4o到GPT-4o-mini的实践指南

本教程演示如何将GPT-4o的知识蒸馏到GPT-4o-mini,以降低延迟和成本。通过结构化输出和并行处理,在葡萄酒品种分类任务上,蒸馏后的模型准确率比未蒸馏的GPT-4o-mini提升约22%。涵盖数据准备、提示词设计、API调用、蒸馏流程及性能评估。

技术背景

大型语言模型(如GPT-4o)在复杂任务上表现优异,但推理成本高、延迟大。模型蒸馏通过使用大模型的输出来微调小模型,使小模型在特定任务上接近大模型的性能,同时降低部署成本。本教程针对分类任务,利用结构化输出确保模型输出符合预定义枚举,提高可靠性。

核心原理

蒸馏的核心是使用教师模型(GPT-4o)在数据集上生成高质量标注,然后以这些标注为监督信号微调学生模型(GPT-4o-mini)。结构化输出通过JSON Schema约束模型输出,确保分类结果在指定枚举内。并行处理利用线程池加速API调用。

架构分析

系统由数据加载、提示词生成、模型调用、结果存储和评估模块组成。数据加载使用pandas处理CSV,提示词生成函数将行数据与品种列表组合成自然语言提示。模型调用封装为函数,支持并行执行。结果存储于DataFrame,便于比较。蒸馏流程通过OpenAI平台完成,微调作业ID用于监控。

完整实战教程

OpenAI 蒸馏 使用较大模型的输出来微调较小的模型。转向较小的模型可以降低特定任务的延迟和成本。在本教程中,我们将把 gpt-4o 的输出蒸馏到 gpt-4o-mini 中,并将结果与通用的、未蒸馏的 gpt-4o-mini 模型进行比较。

我们还将使用 结构化输出 进行带枚举的分类。我们将衡量结构化输出对微调模型的影响,并展示它们与每个模型(包括蒸馏模型)都能正常工作。

我们首先分析数据集,获取 4o 和 4o mini 的输出,突出显示两个模型的性能差异,然后进行蒸馏并分析该蒸馏模型的性能。

先决条件

让我们安装并加载依赖项。

确保你的 OpenAI API 密钥已在环境中定义为 "OPENAI_API_KEY",客户端将直接加载它。

! pip install openai tiktoken numpy pandas tqdm --quiet
import openai
import json
import tiktoken
from tqdm import tqdm
from openai import OpenAI
import numpy as np
import concurrent.futures
import pandas as pd

client = OpenAI()

加载并理解数据集

在本教程中,我们将从以下 Kaggle 挑战赛加载数据:https://www.kaggle.com/datasets/zynicide/wine-reviews。

该数据集包含许多行,您可以在完整数据集上运行本教程。作为一个偏爱法国葡萄酒的人,我将数据集缩小到法国葡萄酒,以便我们可以专注于更少的行和葡萄品种。

我们正在处理一个分类问题,希望根据可用的标准(包括描述、子区域和省份)来预测葡萄品种,这些信息我们将包含在提示中。这为模型提供了大量信息。您可以移除那些会使任务变得明显更容易的信息,例如葡萄生产的地区,以观察模型预测品种的能力。

让我们筛选出在评论中出现次数少于五次的葡萄品种。

接下来,我们使用该数据集中随机抽取的 500 行子集。

df = pd.read_csv('data/winemag/winemag-data-130k-v2.csv')
df_france = df[df['country'] == 'France']

# Let's also filter out wines that have less than 5 references with their grape variety – even though we'd like to find those
# they're outliers that we don't want to optimize for that would make our enum list be too long
# and they could also add noise for the rest of the dataset on which we'd like to guess, eventually reducing our accuracy.

varieties_less_than_five_list = df_france['variety'].value_counts()[df_france['variety'].value_counts() < 5].index.tolist()
df_france = df_france[~df_france['variety'].isin(varieties_less_than_five_list)]

df_france_subset = df_france.sample(n=500)
df_france_subset.head()

让我们检索所有葡萄品种,以便将它们包含在提示词和结构化输出的枚举列表中。

varieties = np.array(df_france['variety'].unique()).astype('str')
varieties

生成提示词

让我们构建一个函数来生成提示词,并针对列表中的第一款葡萄酒进行尝试。

def generate_prompt(row, varieties):
    # Format the varieties list as a comma-separated string
    variety_list = ', '.join(varieties)
    
    prompt = f"""
    Based on this wine review, guess the grape variety:
    This wine is produced by {row['winery']} in the {row['province']} region of {row['country']}.
    It was grown in {row['region_1']}. It is described as: "{row['description']}".
    The wine has been reviewed by {row['taster_name']} and received {row['points']} points.
    The price is {row['price']}.

    Here is a list of possible grape varieties to choose from: {variety_list}.
    
    What is the likely grape variety? Answer only with the grape variety name or blend from the list.
    """
    return prompt

# Example usage with a specific row
prompt = generate_prompt(df_france.iloc[0], varieties)
prompt

在运行查询之前,使用tiktoken估算令牌数量和完成成本。此估算不包括微调,微调的成本还取决于训练集大小和训练轮数等因素。

# Load encoding for the GPT-4 model
enc = tiktoken.encoding_for_model("gpt-4o")

# Initialize a variable to store the total number of tokens
total_tokens = 0

for index, row in df_france_subset.iterrows():
    prompt = generate_prompt(row, varieties)
    
    # Tokenize the input text and count tokens
    tokens = enc.encode(prompt)
    token_count = len(tokens)
    
    # Add the token count to the total
    total_tokens += token_count

print(f"Total number of tokens in the dataset: {total_tokens}")
print(f"Total number of prompts: {len(df_france_subset)}")
# outputing cost in $ as of 2024/10/16

gpt4o_token_price = 2.50 / 1_000_000  # $2.50 per 1M tokens
gpt4o_mini_token_price = 0.150 / 1_000_000  # $0.15 per 1M tokens

total_gpt4o_cost = gpt4o_token_price*total_tokens
total_gpt4o_mini_cost = gpt4o_mini_token_price*total_tokens

print(total_gpt4o_cost)
print(total_gpt4o_mini_cost)

准备函数以存储完成结果

由于响应必须是固定葡萄品种集合中的一个,我们将使用结构化输出将模型限制在该集合内。这为我们提供了一个确定性的值,可以与预期品种进行比较,并防止出现数据集之外的答案。

如果您想了解更多关于结构化输出的信息,可以阅读此食谱和此文档指南。

response_format = {
    "type": "json_schema",
    "json_schema": {
        "name": "grape-variety",
        "schema": {
            "type": "object",
            "properties": {
                "variety": {
                    "type": "string",
                    "enum": varieties.tolist()
                }
            },
            "additionalProperties": False,
            "required": ["variety"],
        },
        "strict": True
    }
}

要蒸馏一个模型,你需要存储来自某个模型的所有补全,以便将其作为参考提供给较小的模型进行微调。因此,我们在 client.chat.completions.create 方法中添加了一个 store=True 参数,以便存储来自 gpt-4o 的这些补全。

我们将存储所有补全(包括 4o-mini 和未来微调后的模型),以便能够直接从 OpenAI 平台运行 Evals。

在存储这些补全时,使用元数据标签进行存储是很有用的,这将允许从 OpenAI 平台进行过滤,以便在您想要运行蒸馏和评估的特定补全集合上运行这些操作。

# Initialize the progress index
metadata_value = "wine-distillation" # that's a funny metadata tag :-)

# Function to call the API and process the result for a single model (blocking call in this case)
def call_model(model, prompt):
    response = client.chat.completions.create(
        model=model,
        store=True,
        metadata={
            "distillation": metadata_value,
        },
        messages=[
            {
                "role": "system",
                "content": "You're a sommelier expert and you know everything about wine. You answer precisely with the name of the variety/blend."
            },
            {
                "role": "user",
                "content": prompt
            }
        ],
         response_format=response_format
    )
    return json.loads(response.choices[0].message.content.strip())['variety']

并行处理

由于我们将在大量行上运行此操作,让我们确保并行运行这些补全,并使用 concurrent futures 来实现。我们将遍历数据框,并每 20 行输出一次进度。我们将使用列名 {model}-variety 将模型运行的补全结果存储在同一数据框中。

def process_example(index, row, model, df, progress_bar):
    global progress_index

    try:
        # Generate the prompt using the row
        prompt = generate_prompt(row, varieties)

        df.at[index, model + "-variety"] = call_model(model, prompt)
        
        # Update the progress bar
        progress_bar.update(1)
        
        progress_index += 1
    except Exception as e:
        print(f"Error processing model {model}: {str(e)}")

def process_dataframe(df, model):
    global progress_index
    progress_index = 1  # Reset progress index

    # Create a tqdm progress bar
    with tqdm(total=len(df), desc="Processing rows") as progress_bar:
        # Process each example concurrently using ThreadPoolExecutor
        with concurrent.futures.ThreadPoolExecutor() as executor:
            futures = {executor.submit(process_example, index, row, model, df, progress_bar): index for index, row in df.iterrows()}
            
            for future in concurrent.futures.as_completed(futures):
                try:
                    future.result()  # Wait for each example to be processed
                except Exception as e:
                    print(f"Error processing example: {str(e)}")

    return df

让我们在处理整个数据框之前先试一下我们的调用模型函数,并检查输出。

answer = call_model('gpt-4o', generate_prompt(df_france_subset.iloc[0], varieties))
answer

太好了!我们确认了可以输出葡萄品种,现在让我们用 gpt-4o 和 gpt-4o-mini 处理数据集并比较结果。

df_france_subset = process_dataframe(df_france_subset, "gpt-4o")
df_france_subset = process_dataframe(df_france_subset, "gpt-4o-mini")

比较 gpt-4o 和 gpt-4o-mini

现在我们已经获得了这两个模型的所有聊天补全结果;让我们将它们与预期的葡萄品种进行比较,并评估它们找到该品种的准确性。我们将直接在 Python 中执行此操作,因为我们需要运行一个简单的字符串检查,但如果你的任务涉及更复杂的评估,你可以利用 OpenAI Evals 或我们的开源评估框架。

models = ['gpt-4o', 'gpt-4o-mini']

def get_accuracy(model, df):
    return np.mean(df['variety'] == df[model + '-variety'])

for model in models:
    print(f"{model} accuracy: {get_accuracy(model, df_france_subset) * 100:.2f}%")

我们可以看到,gpt-4o 在识别葡萄品种方面比 4o-mini 更好(高出 12.80%,相对 4o-mini 提升了近 20%!)。现在我在想,我们是不是在训练时给 gpt-4o 喝了葡萄酒!

将 gpt-4o 的输出蒸馏到 gpt-4o-mini

假设我们想频繁运行这个预测,我们希望完成速度更快、成本更低,但保持同样的准确度。如果能将 4o 的准确度蒸馏到 4o-mini,那岂不是很好?让我们开始吧!

我们现在去 OpenAI 的存储完成页面:https://platform.openai.com/chat-completions。

让我们选择模型 gpt-4o(确保这样做,你不会想蒸馏我们运行的 4o-mini 的输出)。同时选择元数据 distillation: wine-distillation,以仅获取从本 cookbook 运行的存储完成。

Filtering out completions选择好补全后,可以点击右上角的“蒸馏”按钮,基于这些补全对模型进行微调。完成此操作后,系统会自动创建一个用于运行微调过程的文件。接下来,我们选择 gpt-4o-mini 作为基础模型,保持默认参数(但你可以自由更改它们或进行迭代以提升性能)。

Distilling modal一旦微调作业开始,你可以从微调页面获取微调作业 ID,我们将使用它来监控微调作业的状态,并在完成后获取微调后的模型 ID。

Fine tuning job

# copy paste your fine-tune job ID below
finetune_job = client.fine_tuning.jobs.retrieve("ftjob-pRyNWzUItmHpxmJ1TX7FOaWe")

if finetune_job.status == 'succeeded':
    fine_tuned_model = finetune_job.fine_tuned_model
    print('finetuned model: ' + fine_tuned_model)
else:
    print('finetuned job status: ' + finetune_job.status)

为蒸馏模型运行补全

现在我们已经微调好了模型,可以使用该模型运行补全,并与 gpt4o 和 gpt4o-mini 比较准确性。

让我们获取另一组法国葡萄酒子集(由于我们将输出限制为法国葡萄品种,且没有异常值,因此我们也需要将验证数据集集中于此)。让我们对每个模型运行 300 条记录。

validation_dataset = df_france.sample(n=300)

models.append(fine_tuned_model)

for model in models:
    another_subset = process_dataframe(validation_dataset, model)

让我们比较一下模型的准确率

for model in models:
    print(f"{model} accuracy: {get_accuracy(model, another_subset) * 100:.2f}%")

与非蒸馏的 gpt-4o-mini 相比,这几乎是 22% 的相对提升!🎉

我们微调后的模型性能远优于 gpt-4o-mini,而基础模型相同。未来我们可以使用这个模型以更低的成本和更低的延迟进行葡萄品种预测推理。

常见问题

常见错误包括API密钥未设置、网络超时、结构化输出格式错误。排查时检查环境变量、增加重试机制、验证JSON Schema。生产环境优化包括使用异步请求、批量处理、缓存结果,以及监控成本。

企业应用扩展

可扩展至其他分类任务,调整提示词和枚举。可尝试不同基础模型(如GPT-3.5-turbo)或调整微调参数。可集成OpenAI Evals进行更全面评估。

总结

本教程演示如何将GPT-4o的知识蒸馏到GPT-4o-mini,以降低延迟和成本。通过结构化输出和并行处理,在葡萄酒品种分类任务上,蒸馏后的模型准确率比未蒸馏的GPT-4o-mini提升约22%。涵盖数据准备、提示词设计、API调用、蒸馏流程及性能评估。

内容来源与验证

继续系统学习

站内还有更多完整实战教程

加入会员,解锁全部教程、完整代码与持续更新内容。

加入会员
查看全部模型评估与微调教程