第5回:OpenAI APIファインチューニング — 約8分で完了した最終解

はじめに

前回まで、ローカルGPUとGoogle Colabで2つのオープンソースモデルに挑み、どちらも実用的な精度に達しなかったことをお伝えしました。

今回は、方針転換してOpenAI APIのファインチューニングに挑んだ話です。結論から言うと、約8分で学習が完了し、安定したJSON出力と十分な精度が得られました。Colabでの数ヶ月間の試行錯誤は何だったのか、と思うほどの違いでした。

ただし、「8分で完了」というのは学習処理の時間だけです。実際には、データの準備と前処理に相当な苦労がありました。「データ準備が9割」という言葉を身をもって体感したフェーズです。


方針転換の判断

前回触れたコスト比較を改めて整理します。

観点ローカル / ColabOpenAI API
初期コストGPU購入 or Colab Proなし
学習コスト電気代・GPU時間トークン課金(約数十ドル)
学習時間数時間〜数十時間約8分
精度低〜中高い
出力形式の安定性低い高い(JSON確実)
運用の手軽さインフラ管理必要API呼ぶだけ
モデルの所有権手元にあるOpenAI上

個人開発において、精度・コスト・手軽さのバランスを考えると、API方式が最適解だと判断しました。

「モデルが手元にない」ことへの抵抗感はありました。自分のデータで学習したモデルが自分のサーバーではなくOpenAIのインフラ上にある。ただ、現実的に考えると、推論のたびに自前のGPUサーバーを維持するコストと手間を考えれば、APIに任せる方が合理的です。将来的にローカルで動かしたくなった場合は、蒸留という選択肢も残しています。


gpt-4o-miniを選んだ理由

OpenAI APIのファインチューニングでは、いくつかのベースモデルを選べます。その中からgpt-4o-mini-2024-07-18を選びました。

選定理由は3つです。

  1. コストが安い: GPT-4oやGPT-4と比べて大幅に安価。ファインチューニングの課金はトークン数ベースなので、安いモデルは試行錯誤しやすい
  2. チャット形式のファインチューニングに対応: messages形式のデータで学習可能。system / user / assistantの役割分担が明確
  3. 十分な推論能力: 株価予測に必要な分析能力と、JSON形式での安定した出力が期待できる

データ準備 — ここが9割

OpenAI APIのファインチューニングは、実行自体は非常に簡単です。ファイルをアップロードして、APIを一つ呼ぶだけ。しかし、アップロードするデータの準備が全工程の9割を占めました。

Step 1: データ形式の変換

最初に作ったデータはprompt-completion形式でした。

{"prompt": "企業情報とニュースと株価データ...", "completion": "予測結果JSON..."}

ところが、gpt-4o-miniはチャットモデルのため、この形式ではエラーになります。

Invalid file format. Input file is in the prompt-completion format,
but the specified model gpt-4o-mini-2024-07-18 is a chat model
and requires chat-formatted data.

messages形式に変換する必要がありました。

{
  "messages": [
    {"role": "system", "content": "あなたは株価予測アシスタントです。"},
    {"role": "user", "content": "<企業情報+ニュース+株価+財務+マクロ経済指標のJSON>"},
    {"role": "assistant", "content": "<予測結果JSON>"}
  ]
}

変換スクリプトを書いて一括変換しました。

import json

def convert_to_chat_format(input_file, output_file):
    with open(input_file, 'r') as f_in, open(output_file, 'w') as f_out:
        for line in f_in:
            data = json.loads(line)
            chat_format = {
                "messages": [
                    {"role": "system", "content": "あなたは株価予測アシスタントです。"},
                    {"role": "user", "content": data["prompt"]},
                    {"role": "assistant", "content": data["completion"]}
                ]
            }
            f_out.write(json.dumps(chat_format, ensure_ascii=False) + "\n")

Step 2: PHPエラーメッセージの混入

変換は完了しましたが、データの中身を詳しく見ると問題がありました。JSONLファイルの229行目付近に、PHPのエラーメッセージが混入していたのです。

Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 8 bytes) in /var/www/DBHandler.class.php on line 280

訓練データを生成するPHPバッチ(meloik VPS上で動作)がメモリ上限に達し、エラーメッセージがそのままJSONLファイルに出力されてしまっていたのです。

このようなゴミデータが訓練データに混ざると、モデルは「PHPのエラーメッセージは予測結果の一部である」と学習してしまいます。

対策として、JSONとして正しくパースできない行をスキップするクリーニング処理を入れました。

def clean_jsonl(input_file, output_file):
    cleaned = 0
    skipped = 0
    with open(input_file, 'r') as f_in, open(output_file, 'w') as f_out:
        for i, line in enumerate(f_in, 1):
            line = line.strip()
            if not line:
                skipped += 1
                continue
            try:
                data = json.loads(line)
                f_out.write(json.dumps(data, ensure_ascii=False) + "\n")
                cleaned += 1
            except json.JSONDecodeError:
                print(f"Line {i}: Skipped (invalid JSON)")
                skipped += 1

    print(f"Cleaned: {cleaned}, Skipped: {skipped}")

Step 3: HTMLタグ・エンティティの残存

さらに調べると、ニュース本文にHTMLタグやHTMLエンティティが残っていることも発覚しました。

<br /> ← 改行タグ
&lt;   ← < のHTMLエンティティ
&raquo; ← >> のHTMLエンティティ

データの収集元から取得した際のHTMLがそのまま残っていたのです。

import re
import html

def clean_html(text):
    # HTMLタグ除去
    text = re.sub(r"<br\s*/?>", "", text)
    text = re.sub(r"<.*?>", "", text)

    # HTMLエンティティのデコード
    text = html.unescape(text)

    return text

Step 4: バリデーション

OpenAIが公式に提供しているバリデーションスクリプト(tiktokenベース)を使って、最終チェックを行いました。

フォーマットエラー:  0件
16K超過:           0件
サンプル数:         1,009件
平均トークン数:     1,303 tokens/件(min: 847, max: 3,168)
assistant応答:     平均112 tokens

1,009件のデータが、すべてバリデーションを通過しました。

Colabとの決定的な違い

ここで非常に重要なポイントがあります。

Colabでは1,024トークンの制限のために財務データやマクロ経済指標を削除せざるを得ませんでした。しかし、OpenAI APIの最大トークン長は16,385トークンです。平均1,303トークンのデータなら余裕で収まります。

つまり、5種類のデータ(企業情報、ニュース、株価、財務、マクロ指標)をフルで入力できた。 情報を削る必要がなくなったのです。

これがColabでの試行錯誤との最大の違いであり、精度向上の大きな要因だったと考えています。


ファインチューニング実行

データの準備が終われば、あとは驚くほど簡単です。

コスト見積もり

総トークン数:    約130万トークン
学習エポック:    3回
課金トークン数:  約388万トークン

ファイルアップロードと学習開始

from openai import OpenAI

client = OpenAI()

# 1. ファイルアップロード
with open("train_combined_final.jsonl", "rb") as f:
    file = client.files.create(file=f, purpose="fine-tune")

print(f"File ID: {file.id}")

# 2. ファインチューニング開始
fine_tune_job = client.fine_tuning.jobs.create(
    training_file=file.id,
    model="gpt-4o-mini-2024-07-18"
)

print(f"Job ID: {fine_tune_job.id}")
print(f"Status: {fine_tune_job.status}")

たったこれだけです。あとは待つだけ。

ステータスは validating_filesrunningsucceeded と遷移し、約7〜8分で完了しました。

Colabで数時間かけて学習し、それでも精度が出なかったことを思うと、この速さは衝撃的でした。


予測テスト — Colabとの違いが歴然

ファインチューニング済みモデルで予測を実行してみます。

response = client.chat.completions.create(
    model="ft:gpt-4o-mini-2024-07-18:personal::xxxxx",
    messages=[
        {"role": "system", "content": "あなたは株価予測アシスタントです。"},
        {"role": "user", "content": json.dumps(test_data, ensure_ascii=False)}
    ]
)

prediction = response.choices[0].message.content
print(prediction)

テスト結果(綜研化学 4972、2024/11/28のデータ):

{
  "当日終値→翌日始値": {"価格": 3045, "変動率": 0,    "傾向": "中立"},
  "当日終値→翌日終値": {"価格": 3075, "変動率": 0.98, "傾向": "中立"},
  "翌日始値→終値":     {"価格": 3075, "変動率": 1,    "傾向": "中立"}
}

完全なJSON形式で、安定した出力。 Colabモデルのように自然言語で回答を返すことは一度もありませんでした。

これがgpt-4o-miniの基礎能力の高さです。「JSON形式で出力せよ」という指示に確実に従い、構造化された出力を安定して返す。7〜8Bのオープンソースモデルとは根本的に異なるレベルのインストラクション追従能力でした。


ファインチューニング済みモデル一覧

試行錯誤の過程で作成したモデルを記録しておきます。

モデルIDデータセット件数備考
ft:gpt-4o-mini-2024-07-18:personal::xxxxxtraining_data_chat.jsonl小規模初回テスト。データ形式の検証用
ft:gpt-4o-mini-2024-07-18:personal::xxxxxtrain_combined_1738729676133.jsonl1,009件本番使用モデル

最初の小規模テストで「動くこと」を確認してから、本番データでファインチューニングするという二段階アプローチを取りました。


本番運用への組み込み

ファインチューニング済みモデルは、meloikプロジェクトの予測バッチ(predict_stock_realtime2)に組み込まれています。

// OpenAI APIに予測リクエスト
$apiKey = CHATGPT_API_KEY;
$url = 'https://api.openai.com/v1/chat/completions';

// 予測用データの取得(企業情報+ニュース+株価+財務+マクロ指標)
$jsonData = Utility::getPredictionData($db, $company['code'], $start_date, $end_date);

// ファインチューニング済みモデルで予測
$data = [
    'model' => 'ft:gpt-4o-mini-2024-07-18:personal::xxxxx',
    'messages' => [
        ['role' => 'system', 'content' => 'あなたは株価予測アシスタントです。'],
        ['role' => 'user', 'content' => $jsonData]
    ]
];

crontabで定期実行されるこのバッチが、ニュースが発表されるたびに自動的にファインチューニング済みモデルに予測を依頼し、結果をMySQLに保存します。


OpenAI APIファインチューニングの学び

Phase 3を通じて得た知見を整理します。

データ準備が本当に9割

ファインチューニングの実行自体は、ファイルをアップロードしてAPI一つ呼ぶだけ。技術的な難しさはほぼありません。

しかし、そのアップロードするデータを作る過程——形式の変換、ゴミデータの除去、HTMLの処理、バリデーション——に全工程の9割の労力がかかりました。

「LLMファインチューニングの難しさは、モデルの学習ではなくデータの準備にある」というのは、多くの人が言っていることですが、実際に体験して初めて腹落ちしました。

フルデータの重要性

Colabでは情報を削らざるを得ませんでしたが、OpenAI APIではフルデータを入力できました。この違いが精度に大きく影響したと考えています。

株価予測というタスクでは、ニュースだけでなく、財務データ、マクロ経済指標、株価の推移といった多角的な情報が重要です。人間のアナリストも、一つの情報だけで判断することはありません。

モデルの基礎能力が決定的

gpt-4o-miniは、ファインチューニング前の段階で既に高いインストラクション追従能力を持っています。「JSON形式で出力せよ」と言えば確実にJSONで返す。この基礎能力が、ファインチューニング後の安定性を支えています。

7〜8Bのオープンソースモデルは、この基礎能力が不足していたために、いくらファインチューニングしても出力形式が安定しませんでした。


Phase 1〜3の振り返り

ここで、3つのフェーズを振り返ってみます。

フェーズやったこと結果得た知見
Phase 1RTX 3060でELYZA 8BVRAM不足ハードウェアの制約を体感
Phase 2ColabでELYZA + LLM-jp精度不足LoRA/量子化/GGUF変換の技術習得
Phase 3OpenAI API FT本番採用データ準備の重要性を体感

直線的に見ると「最初からOpenAI APIを使えばよかったのでは」と思えます。しかし、Phase 1-2の経験があったからこそ、Phase 3でのデータ設計や問題解決が速くできました。

例えば、「フルデータを入力できることの価値」はColabでの情報削減の苦労を経験したからこそ実感できましたし、「モデルの基礎能力の重要性」も、7〜8Bモデルの出力不安定性を目の当たりにしたからこそ理解できました。


まとめ

OpenAI APIファインチューニング(Phase 3)のポイントです。

  • gpt-4o-mini-2024-07-18をベースにファインチューニング
  • データの準備(形式変換、クリーニング、バリデーション)が全工程の9割
  • PHPエラーメッセージの混入、HTMLタグの残存など、予想外のデータ問題に対処
  • 約8分で学習完了(Colabの数時間と比較して圧倒的に速い)
  • 5種類のフルデータを入力でき、安定したJSON出力が得られた
  • 本番の予測バッチに組み込み、自動運用を開始

次回は、予測の精度を左右する訓練データの設計について、より詳しく掘り下げます。


次回: 第6回「訓練データの設計 — 5種類のデータをどう統合したか」

この記事をシェア

関連記事