見出し画像

OpenAIのファインチューニングAPIによる GPT-4 から GPT-3.5 への蒸留を試す

OpenAIのファインチューニングAPIによる GPT-4 から GPT-3.5 への蒸留を試したのでまとめました。


1. GPT-4 から GPT-3.5 への蒸留

「LlamaIndex」で、OpenAIのファインチューニングAPIによる GPT-4 から GPT-3.5 への蒸留のColabが提供されてたので、それをベースに独自データで試してみました。

具体的には、「GPT-4」で学習データを生成し、「GPT-3.5」でファインチューニングすることで、「GPT-3.5」に「GPT-4」相当の知識を習得させます。

2. ドキュメントの準備

今回は、マンガペディアの「ぼっち・ざ・ろっく!」のあらすじのドキュメントを使います。

・bocchi.txt

3. セットアップ

Colabでのセットアップ手順は、次のとおりです。

(1) パッケージのインストール。

# パッケージのインストール
!pip install llama-index sentence-transformers ragas

(2) 環境変数の準備。
以下のコードの <OpenAI_APIのトークン> にはOpenAI APIのトークンを指定します。(有料)

import os
import openai

# 環境変数の設定
os.environ["OPENAI_API_KEY"] = "<OpenAI_APIのトークン>"
openai.api_key = os.environ["OPENAI_API_KEY"]

(3) Colabにドキュメント「bocchi.txt」をアップロード。

(4) ドキュメントの読み込み。

from llama_index import SimpleDirectoryReader

# ドキュメントの読み込み
documents = SimpleDirectoryReader(
    input_files=["bocchi.txt"]
).load_data()

4. 質問データの作成

Colabでの質問データセットの作成手順は、次のとおりです。

(1) サービスコンテキストの準備。
今回のドキュメント形式にあわせて、paragraph_separator "\n\n" を指定しました。"\n\n" で段落別けする設定 (デフォルトは"\n\n\n") になります。

from llama_index import ServiceContext
from llama_index.llms import OpenAI
from llama_index.text_splitter import SentenceSplitter
from llama_index.node_parser import SimpleNodeParser

# ノードパーサーの準備
text_splitter = SentenceSplitter(
    paragraph_separator="\n\n",
)
node_parser = SimpleNodeParser.from_defaults(text_splitter=text_splitter)

# サービスコンテキストの準備
gpt_35_context = ServiceContext.from_defaults(
    llm=OpenAI(model="gpt-3.5-turbo", temperature=0.3),
    node_parser=node_parser,
)

(2) 質問データ作成クエリとデータセットジェネレータの準備。
今回のドキュメント形式にあわせて、クエリは日本語にしました。

from llama_index.evaluation import DatasetGenerator

# 質問データ作成クエリの準備
question_gen_query = (
    "あなたは教師です。 あなたの仕事は、試験問題を作成することです。"
    "日本のアニメ作品に文脈から重要な事実を捉える1つの質問を、以下の条件で作成します。"
    "- 質問は必ず提供された文脈に限定"
    "- 質問は必ず日本語で記述"
    "- 代名詞は絶対に使用しない"
)

# データセットジェネレータの準備
dataset_generator = DatasetGenerator.from_documents(
    documents,
    question_gen_query=question_gen_query,
    service_context=gpt_35_context,
)

(3) 質問データの作成。
データセットジェネレータを実行します。80個の質問を作成しています。10分ほど時間かかりました。

# 質問データの作成
questions = dataset_generator.generate_questions_from_nodes(num=80)
print("Generated ", len(questions), " questions")

(4) 質問データのシャッフル。

import random

# シャッフル
random.seed(42)
random.shuffle(questions)

(5) 質問データの保存。
学習用の「train_questions.txt」と評価用の「eval_questions.txt」に40質問ずつ保存しました。

# 学習用の質問データの保存
with open("train_questions.txt", "w") as f:
    for question in questions[:40]:
        f.write(question + "\n")

# 評価用の質問データの保存
with open("eval_questions.txt", "w") as f:
    for question in questions[40:]:
        f.write(question + "\n")

(6) 質問データを確認して、代名詞など質問として成立しない部分を修正。

5. ファインチューニング前のGPT-3.5 の評価

ファインチューニング前のGPT-3.5での質問応答の評価を計測します。

(1) 評価用の質問データの読み込み。

# 評価用の質問データの読み込み
questions = []
with open("eval_questions.txt", "r") as f:
    for line in f:
        questions.append(line.strip())

(2) サービスコンテキストの準備。

# ノードパーサーの準備
text_splitter = SentenceSplitter(
    paragraph_separator="\n\n",
)
node_parser = SimpleNodeParser.from_defaults(text_splitter=text_splitter)

# サービスコンテキストの準備
gpt_35_context = ServiceContext.from_defaults(
    llm=OpenAI(model="gpt-3.5-turbo", temperature=0.3),
    node_parser=node_parser,
    context_window=2048,
)

(3) インデックスとクエリエンジンの準備。

from llama_index import VectorStoreIndex

# インデックスとクエリエンジンの準備
index = VectorStoreIndex.from_documents(
    documents,
    service_context=gpt_35_context
)
query_engine = index.as_query_engine(
    similarity_top_k=2
)

(4) 質問からの文脈と応答の生成。

# 質問からの文脈と応答の生成
contexts = []
answers = []
for question in questions:
    response = query_engine.query(question)
    contexts.append([x.node.get_content() for x in response.source_nodes])
    answers.append(str(response))

(5) 生成した文脈と応答を確認。

print("questions[0]:", questions[0])
print("contexts[0]:", contexts[0])
print("answers[0]:", answers[0])
questions[0]: 後藤ひとりはなぜ問題行動を起こしていたのですか?
contexts[0]: ['後藤ひとり(ごとうひとり)\n秀華高校に通う女子。桃色の髪を無造作に伸ばし、いつもジャージを身につけている。自他共に認める引きこもり一歩手前の「陰キャ」で、承認欲求が人一倍強いにもかかわらず、臆病な性格で人と接するのを極度に苦手としている。そのため、すぐに自分の世界に入って落ち込むという情緒不安定さを見せる。押し入れやダンボールに潜り込む癖があり、「完熟マンゴー仮面」を持ち歩いている。運動も勉強も苦手で、特に勉強はまじめに授業を受けているにもかかわらず、生来の要領の悪さから赤点ギリギリ。自分には何の取り柄もないのを痛感していたため、中学の頃に暗い性格の人間がバンドをやって人気者になったインタビューを読んで、ギターを始める。毎日練習したお陰でギターの腕前はプロ級になったが、結局、その腕前を披露する場に恵まれず、友達を作れないまま中学を卒業。現在は「ギターヒーロー」の名で動画配信している。ギタリストを探していた伊地知虹夏に誘われ、「結束バンド」に加入し、バンド活動を始める。バンド内ではギタリスト兼作詞を担当している。山田リョウに「ぼっち」の愛称を付けられて以降は、その愛称で呼ばれる。ギターの腕前はかなりのものだが、他人と合わせるセッションの経験が皆無なため、バンドではまともに演奏できないでいる。', '結束バンド\n後藤ひとりは友達を作れない陰キャでいつも一人で過ごしていたが、中学時代にテレビのインタビューを見て、陰キャでもバンドを組んでいれば人気者になれると聞き、ギターの練習を始める。ひとりは毎日コツコツ練習を重ねることでギターの腕前はかなりのものとなっていたが、気がつけば中学は卒業。結局、友達を作るという当初の目標は達成することはできずじまいだった。そして高校に入学したひとりはギター演奏を動画配信し、「ギターヒーロー」としてネットでそこそこの人気を集めていたが、現実では変わらず友達を作れずにいた。ひとりはギターをこれみよがしに持ち、ギタリストだということをアピールするものの効果はなし。黄昏(たそがれ)ていたところ、ギタリストを探していた伊地知虹夏に誘われ、バンド活動をすることとなる。ひとりは虹夏、山田リョウらと共に「結束バンド」として活動を始めるが、友達は欲しいけどコミュ症独りぼっちのひとりは問題行動ばかり起こしていた。結束バンドなのに、結束力ゼロな凸凹バンド活動は幕開けするのだった。そしてそんなひとりに、クラスメイトの喜多郁代が話しかけてきて、彼女が結束バンドの元メンバーだったことを知る。紆余(うよ)曲折の末、郁代はひとりとの交流で結束バンドに戻ることになり、ひとりたちはライブに向けて準備をすることとなる。ひとりはライブチケットのノルマをこなすため、方々を回ってチケットを売ることになるが、人見知りのひとりには難題ですぐに壁にぶち当たってしまう。絶望するひとりは、そこで行き倒れた廣井きくりと遭遇。彼女の計らいで突発的な路上ライブを行なって、観客を魅了する。きくりのお陰でライブチケットのノルマもこなし、結束バンドはライブに臨むのだった。']
answers[0]: 後藤ひとりは問題行動を起こしていた理由は、自己肯定感の低さと人との関わりを苦手としている性格に起因しています。彼女は引きこもりの一歩手前であり、承認欲求が非常に強い一方で、臆病な性格のため人と接することが苦手です。そのため、自分の世界に入って落ち込むことが多く、情緒不安定さを見せることがあります。また、彼女は押し入れやダンボールに潜り込む癖があり、自分の居場所を見つけるために「完熟マンゴー仮面」を持ち歩いています。これらの要素が彼女の問題行動につながっていると考えられます。

(6) 評価の実行。
評価には評価ライブラリ「Ragas」を使用します。「Ragas」には RAG パイプラインの評価メトリクスが大量にあります。詳しくは、ドキュメントを参照。

from datasets import Dataset
from ragas import evaluate
from ragas.metrics import answer_relevancy, faithfulness

# 評価の実行
ds = Dataset.from_dict(
    {
        "question": questions,
        "answer": answers,
        "contexts": contexts,
    }
)
result = evaluate(ds, [answer_relevancy, faithfulness])
print(result)
evaluating with [answer_relevancy]
100%|██████████| 3/3 [01:37<00:00, 32.41s/it]
evaluating with [faithfulness]
100%|██████████| 3/3 [15:18<00:00, 306.21s/it]
{'ragas_score': 0.7634, 'answer_relevancy': 0.8399, 'faithfulness': 0.6997}

今回は、次の2つのメトリックスを使いました。

・answer_relevancy
生成された回答がプロンプトに対してどの程度関連しているかを測定します。生成された回答が不完全であるか、冗長な情報が含まれている場合、スコアは低くなります。これは、生成された回答を使用して LLM が特定の質問を生成する可能性を計算することで定量化されます。
値の範囲は (0,1) で、大きいほど優れています。

・faithfulness
指定されたコンテキストに対する、生成された回答の事実の一貫性を測定します。これは、生成された回答からステートメントを作成し、その後、これらの各ステートメントをコンテキストに照らして検証することを含む、複数のステップのパラダイムを使用して行われます。
値の範囲は (0,1) で、大きいほど優れています。

6. ファインチューニングの学習データの作成

Coladでのファインチューニングの学習データの作成手順は、次のとおりです。

(1) サービスコンテキストの準備。
ファインチューニングの学習データを収集するためのファインチューニングハンドラーをコールバックマネージャ経由で設定します。

from llama_index import ServiceContext
from llama_index.llms import OpenAI
from llama_index.text_splitter import SentenceSplitter
from llama_index.node_parser import SimpleNodeParser
from llama_index.callbacks import OpenAIFineTuningHandler
from llama_index.callbacks import CallbackManager

# コールバックマネージャとファインチューニングハンドラーの準備
finetuning_handler = OpenAIFineTuningHandler()
callback_manager = CallbackManager([finetuning_handler])

# ノードパーサーの準備
text_splitter = SentenceSplitter(
  paragraph_separator="\n\n",
)
node_parser = SimpleNodeParser.from_defaults(text_splitter=text_splitter)

# サービスコンテキストの準備
gpt_4_context = ServiceContext.from_defaults(
    llm=OpenAI(model="gpt-4", temperature=0.3),
    node_parser=node_parser,
    context_window=2048,  # コンテキスト長を制限してRefineを促す
    callback_manager=callback_manager,  # コールバックマネージャ
)

(2) 学習用の質問データの読み込み。

# 学習用の質問データの読み込み
questions = []
with open("train_questions.txt", "r") as f:
    for line in f:
        questions.append(line.strip())

(3) インデックスとクエリエンジンの準備。

from llama_index import VectorStoreIndex

# インデックスとクエリエンジンの準備
index = VectorStoreIndex.from_documents(
    documents,
    service_context=gpt_4_context
)
query_engine = index.as_query_engine(
    similarity_top_k=2
)

(4) 質問からのファインチューニングの学習データの作成。
ファインチューニング用の学習データ「finetuning_events.jsonl」が生成されます。

# 質問からのファインチューニングの学習データの作成
for question in questions:
    response = query_engine.query(question)

# ファインチューニングの学習データの保存
finetuning_handler.save_finetuning_events("finetuning_events.jsonl")

(5) 日本語を読めるように変換。
ファインチューニング用の学習データ「finetuning_events_ja.jsonl」が生成されます。変換しないと文字コードなので人間が読めません。

import json

# 日本語を読めるように変換
with open("finetuning_events.jsonl", "r", encoding="utf-8") as f:
    lines = f.readlines()
    data = [json.loads(line) for line in lines]
with open("finetuning_events_ja.jsonl", "w", encoding="utf-8") as f:
    for item in data:
        json.dump(item, f, ensure_ascii=False)
        f.write("\n")

7. ファインチューニングの実行

Colabでのファインチューニングの実行手順は、次のとおりです。

(1) 学習データの検証の実行。
OpenAI APIのドキュメントで紹介されているスクリプト (Data formatting script) をコピー&ペーストして、data_path "finetuning_events_ja.jsonl" を指定します。

# We start by importing the required packages

import json
import os
import tiktoken
import numpy as np
from collections import defaultdict

# Next, we specify the data path and open the JSONL file

data_path = "finetuning_events_ja.jsonl"

# Load dataset
with open(data_path) as f:
    dataset = [json.loads(line) for line in f]

# We can inspect the data quickly by checking the number of examples and the first item

# Initial dataset stats
print("Num examples:", len(dataset))
print("First example:")
for message in dataset[0]["messages"]:
    print(message)

# Now that we have a sense of the data, we need to go through all the different examples and check to make sure the formatting is correct and matches the Chat completions message structure

# Format error checks
format_errors = defaultdict(int)

for ex in dataset:
    if not isinstance(ex, dict):
        format_errors["data_type"] += 1
        continue

    messages = ex.get("messages", None)
    if not messages:
        format_errors["missing_messages_list"] += 1
        continue

    for message in messages:
        if "role" not in message or "content" not in message:
            format_errors["message_missing_key"] += 1

        if any(k not in ("role", "content", "name") for k in message):
            format_errors["message_unrecognized_key"] += 1

        if message.get("role", None) not in ("system", "user", "assistant"):
            format_errors["unrecognized_role"] += 1

        content = message.get("content", None)
        if not content or not isinstance(content, str):
            format_errors["missing_content"] += 1

    if not any(message.get("role", None) == "assistant" for message in messages):
        format_errors["example_missing_assistant_message"] += 1

if format_errors:
    print("Found errors:")
    for k, v in format_errors.items():
        print(f"{k}: {v}")
else:
    print("No errors found")

# Beyond the structure of the message, we also need to ensure that the length does not exceed the 4096 token limit.

# Token counting functions
encoding = tiktoken.get_encoding("cl100k_base")

# not exact!
# simplified from https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
def num_tokens_from_messages(messages, tokens_per_message=3, tokens_per_name=1):
    num_tokens = 0
    for message in messages:
        num_tokens += tokens_per_message
        for key, value in message.items():
            num_tokens += len(encoding.encode(value))
            if key == "name":
                num_tokens += tokens_per_name
    num_tokens += 3
    return num_tokens

def num_assistant_tokens_from_messages(messages):
    num_tokens = 0
    for message in messages:
        if message["role"] == "assistant":
            num_tokens += len(encoding.encode(message["content"]))
    return num_tokens

def print_distribution(values, name):
    print(f"\n#### Distribution of {name}:")
    print(f"min / max: {min(values)}, {max(values)}")
    print(f"mean / median: {np.mean(values)}, {np.median(values)}")
    print(f"p5 / p95: {np.quantile(values, 0.1)}, {np.quantile(values, 0.9)}")

# Last, we can look at the results of the different formatting operations before proceeding with creating a fine-tuning job:

# Warnings and tokens counts
n_missing_system = 0
n_missing_user = 0
n_messages = []
convo_lens = []
assistant_message_lens = []

for ex in dataset:
    messages = ex["messages"]
    if not any(message["role"] == "system" for message in messages):
        n_missing_system += 1
    if not any(message["role"] == "user" for message in messages):
        n_missing_user += 1
    n_messages.append(len(messages))
    convo_lens.append(num_tokens_from_messages(messages))
    assistant_message_lens.append(num_assistant_tokens_from_messages(messages))

print("Num examples missing system message:", n_missing_system)
print("Num examples missing user message:", n_missing_user)
print_distribution(n_messages, "num_messages_per_example")
print_distribution(convo_lens, "num_total_tokens_per_example")
print_distribution(assistant_message_lens, "num_assistant_tokens_per_example")
n_too_long = sum(l > 4096 for l in convo_lens)
print(f"\n{n_too_long} examples may be over the 4096 token limit, they will be truncated during fine-tuning")

# Pricing and default n_epochs estimate
MAX_TOKENS_PER_EXAMPLE = 4096

MIN_TARGET_EXAMPLES = 100
MAX_TARGET_EXAMPLES = 25000
TARGET_EPOCHS = 3
MIN_EPOCHS = 1
MAX_EPOCHS = 25

n_epochs = TARGET_EPOCHS
n_train_examples = len(dataset)
if n_train_examples * TARGET_EPOCHS < MIN_TARGET_EXAMPLES:
    n_epochs = min(MAX_EPOCHS, MIN_TARGET_EXAMPLES // n_train_examples)
elif n_train_examples * TARGET_EPOCHS > MAX_TARGET_EXAMPLES:
    n_epochs = max(MIN_EPOCHS, MAX_TARGET_EXAMPLES // n_train_examples)

n_billing_tokens_in_dataset = sum(min(MAX_TOKENS_PER_EXAMPLE, length) for length in convo_lens)
print(f"Dataset has ~{n_billing_tokens_in_dataset} tokens that will be charged for during training")
print(f"By default, you'll train for {n_epochs} epochs on this dataset")
print(f"By default, you'll be charged for ~{n_epochs * n_billing_tokens_in_dataset} tokens")
print("See pricing page to estimate total costs")
Num examples: 73
First example:
{'role': 'system', 'content': "You are an expert Q&A system that is trusted around the world.\nAlways answer the query using the provided context information, and not prior knowledge.\nSome rules to follow:\n1. Never directly reference the given context in your answer.\n2. Avoid statements like 'Based on the context, ...' or 'The context information ...' or anything along those lines."}
{'role': 'user', 'content': 'Context information is below.\n---------------------\n後藤ひとり(ごとうひとり)\n秀華高校に通う女子。桃色の髪を無造作に伸ばし、いつもジャージを身につけている。自他共に認める引きこもり一歩手前の「陰キャ」で、承認欲求が人一倍強いにもかかわらず、臆病な性格で人と接するのを極度に苦手としている。そのため、すぐに自分の世界に入って落ち込むという情緒不安定さを見せる。押し入れやダンボールに潜り込む癖があり、「完熟マンゴー仮面」を持ち歩いている。運動も勉強も苦手で、特に勉強はまじめに授業を受けているにもかかわらず、生来の要領の悪さから赤点ギリギリ。自分には何の取り柄もないのを痛感していたため、中学の頃に暗い性格の人間がバンドをやって人気者になったインタビューを読んで、ギターを始める。毎日練習したお陰でギターの腕前はプロ級になったが、結局、その腕前を披露する場に恵まれず、友達を作れないまま中学を卒業。現在は「ギターヒーロー」の名で動画配信している。ギタリストを探していた伊地知虹夏に誘われ、「結束バンド」に加入し、バンド活動を始める。バンド内ではギタリスト兼作詞を担当している。山田リョウに「ぼっち」の愛称を付けられて以降は、その愛称で呼ばれる。ギターの腕前はかなりのものだが、他人と合わせるセッションの経験が皆無なため、バンドではまともに演奏できないでいる。\n\nひとりの父(ひとりのちち)\n後藤ひとりの父親。本名は不明で、素顔もなぜか毎回明かされていないために不明。若い頃、ギターを弾いていたらしく、ひとりの中学時代、ギターに興味を覚えた娘に自分のギターを貸した。動画配信のアカウントを家族で共有しているため、ひとりが「ギターヒーロー」として人気を集めていることも知っており、ひとりのために広告収入を貯金していた。のちに、そのお金を新しいギターを購入する資金として渡している。変な特技を数多く持っており、隠れ身の術で壁に隠れていたり、完熟マンゴー仮面を凝ったデザインに改造したりしている。後藤ふたりから家族ヒエラルキーの中で最下位に置かれており、なんとかそれを挽回したいと考えている。\n\n2号(にごう)\n---------------------\nGiven the context information and not prior knowledge, answer the query.\nQuery: 後藤ひとりはどのような容姿をしているのですか?\nAnswer: '}
{'role': 'assistant', 'content': '後藤ひとりは桃色の髪を無造作に伸ばしており、いつもジャージを身につけています。'}
No errors found
Num examples missing system message: 33
Num examples missing user message: 0

#### Distribution of num_messages_per_example:
min / max: 2, 3
mean / median: 2.547945205479452, 3.0
p5 / p95: 2.0, 3.0

#### Distribution of num_total_tokens_per_example:
min / max: 403, 1397
mean / median: 1010.1506849315068, 1005.0
p5 / p95: 793.4, 1290.2

#### Distribution of num_assistant_tokens_per_example:
min / max: 19, 249
mean / median: 83.75342465753425, 76.0
p5 / p95: 26.0, 161.79999999999998

0 examples may be over the 4096 token limit, they will be truncated during fine-tuning
Dataset has ~73741 tokens that will be charged for during training
By default, you'll train for 3 epochs on this dataset
By default, you'll be charged for ~221223 tokens
See pricing page to estimate total costs

(2) 学習データのアップロード。

import openai

# 学習データのアップロード
openai.File.create(
    file=open("finetuning_events_ja.jsonl", "rb"),
    purpose="fine-tune"
)
<File file id=file-XXXXXXXXXXXXXXXXXXXXXXXX at 0x7ea2aeb29e40> JSON: {
  "object": "file",
  "id": "file-XXXXXXXXXXXXXXXXXXXXXXXX",
  "purpose": "fine-tune",
  "filename": "file",
  "bytes": 217663,
  "created_at": 1693039402,
  "status": "uploaded",
  "status_details": null
}

ファイルID (id) をメモします。

(3) ファインチューニングの実行。
training_file には、アップロードしたファイルのファイルID (id) を指定します。

import os

# ファインチューニングの実行
openai.FineTuningJob.create(
    training_file="file-XXXXXXXXXXXXXXXXXXXXXXXX", 
    model="gpt-3.5-turbo"
)
<FineTuningJob fine_tuning.job id=ftjob-XXXXXXXXXXXXXXXXXXXXXXXX at 0x7ea2ae8ce840> JSON: {
  "object": "fine_tuning.job",
  "id": "ftjob-XXXXXXXXXXXXXXXXXXXXXXXX",
  "model": "gpt-3.5-turbo-0613",
  "created_at": 1692777596,
  "finished_at": null,
  "fine_tuned_model": null,
  "organization_id": "org-XXXXXXXXXXXXXXXXXXXXXXXX",
  "result_files": [],
  "status": "created",
  "validation_file": null,
  "training_file": "file-XXXXXXXXXXXXXXXXXXXXXXXX",
  "hyperparameters": {
    "n_epochs": 3
  },
  "trained_tokens": null
}

ファインチューニングのジョブID (id) をメモします。

(4) メールでファインチューニング完了の通知を待つ。
次のようなファインチューニング完了のメールが通知されます。ジョブIDとモデルIDが記載されています。

8. ファインチューニング後のGPT-3.5の評価

ファインチューニング後のGPT-3.5での質問応答の評価を計測します。

(1) モデルIDの準備。
以下の <モデルID> に自分のモデルIDを記述します。

# モデルIDの準備
ft_model_name = "<モデルID>"

(2) サービスコンテキストの準備。

from llama_index import ServiceContext
from llama_index.llms import OpenAI
from llama_index.text_splitter import SentenceSplitter
from llama_index.node_parser import SimpleNodeParser

# ノードパーサーの準備
text_splitter = SentenceSplitter(
    paragraph_separator="\n\n",
)
node_parser = SimpleNodeParser.from_defaults(text_splitter=text_splitter)

# サービスコンテキストの準備
ft_context = ServiceContext.from_defaults(
    llm=OpenAI(model=ft_model_name, temperature=0.3),
    node_parser=node_parser,
    context_window=2048,  # コンテキスト長を制限してRefineを促す
)

(3) 評価用の質問データの読み込み。

# 評価用の質問データの読み込み
questions = []
with open("eval_questions.txt", "r") as f:
    for line in f:
        questions.append(line.strip())

(4) インデックスとクエリエンジンの準備。

from llama_index import VectorStoreIndex

# インデックスとクエリエンジンの準備
index = VectorStoreIndex.from_documents(
    documents,
    service_context=ft_context
)
query_engine = index.as_query_engine(
    similarity_top_k=2
)

(5) 質問からの文脈と応答の作成。

# 質問からの文脈と応答の作成
contexts = []
answers = []
for question in questions:
    response = query_engine.query(question)
    contexts.append([x.node.get_content() for x in response.source_nodes])
    answers.append(str(response))

(6) 評価の実行。

from datasets import Dataset
from ragas import evaluate
from ragas.metrics import answer_relevancy, faithfulness

# 評価の実行
ds = Dataset.from_dict(
    {
        "question": questions,
        "answer": answers,
        "contexts": contexts,
    }
)
result = evaluate(ds, [answer_relevancy, faithfulness])
print(result)
evaluating with [answer_relevancy]
100%|██████████| 3/3 [01:43<00:00, 34.42s/it]
evaluating with [faithfulness]
100%|██████████| 3/3 [12:11<00:00, 243.95s/it]
{'ragas_score': 0.8152, 'answer_relevancy': 0.8274, 'faithfulness': 0.8035}

9. 評価の比較

ファインチューニング前より後の方が、faithfulnessが増えました。answer_relevancyはほぼ変化なしです。文体もGPT-4風味の丁寧な言い回しになってる気がします。

・ファインチューニング前

{'ragas_score': 0.7634, 'answer_relevancy': 0.8399, 'faithfulness': 0.6997}

Q: ぼっちちゃんはどんな性格?
A: ぼっちちゃんは引きこもり一歩手前の「陰キャ」で、臆病な性格で人と接するのを極度に苦手としています。また、自分には何の取り柄もないのを痛感しており、情緒不安定さを見せることもあります。

・ファインチューニング後

{'ragas_score': 0.8152, 'answer_relevancy': 0.8274, 'faithfulness': 0.8035}

Q: ぼっちちゃんはどんな性格?
A: ぼっちちゃんは臆病な性格で、人と接するのを極度に苦手としています。また、自分には何の取り柄もないと感じており、自己肯定感が低いようです。しかし、ギターの腕前はプロ級であり、ギターヒーローとして動画配信しています。

【おまけ】 失敗したパターン

元のColabをデータセット変更するのみでは、評価は向上しませんでした。原因は次の3つでした。元の質問応答の精度を調整しておかなかったため、ファインチューニング以前に、評価が不安定になってたようです。

・チャンクが段落単位で分割されていなかった
今回のデータセットの段落は "\n\n" で分割してたので、paragraph_separator="\n\n" を指定しました (デフォルトは"\n\n\n")。

・質問データが英語で生成されていた
自動生成された質問データが英語で生成されていました。質問データ生成クエリが英語だったのが原因なので、日本語にしました。

・質問データに代名詞が含まれていた
自動生成された質問データに代名詞が含まれる場合がありました。質問には向かないので手作業で修正しました。さらなるプロンプトエンジニアリングで自動生成の精度を改善できるかもしれません。



この記事が気に入ったらサポートをしてみませんか?