コンテンツにスキップ

第08回 LLM分析の再現性

科目: ビッグデータ応用Ⅱ〔特徴量エンジニアリング〕
担当: 小野原彩香
対象: 3年生(後期)


学習目標

  1. LLMの非決定性(温度・サンプリング)が分析の再現性に与える影響を説明できる
  2. Gitを使ってプロンプトファイルをバージョン管理できる
  3. JSONLフォーマットでAPIのリクエスト・レスポンスをログとして記録できる
  4. temperature=0 や seed パラメータを使って出力を安定させられる
  5. ログから同一プロンプトを再実行するPythonスクリプトを作成できる

1. LLM分析における再現性の課題

1.1 再現性とは

科学的な分析においては、同じ手順を踏めば同じ結果が得られることが求められます(再現性)。しかしLLMを使った分析では、同じプロンプトを同じモデルに送っても、毎回異なる出力が返ってくることがあります。

1.2 LLM固有の非決定性

LLMがテキストを生成する際、次のトークン(単語のかたまり)を確率的に選択します。この確率的な選択が「非決定性」の原因です。

入力プロンプト → LLM → 確率分布 → サンプリング → 出力トークン
                              ↑
                       ここで確率的な選択が行われる

1.3 温度パラメータ(temperature)

温度(temperature) は、確率分布の「尖り具合」を制御するパラメータです。

temperature 値 挙動 使用場面
0.0 確率最大のトークンを常に選択(決定論的) コマンド生成、分析の再現性が必要な場面
0.5〜0.7 バランスの取れた多様性 一般的なタスク
1.0 デフォルト。高い多様性 創作、アイデア出し
1.5〜2.0 非常に高い多様性(予測しにくい) ブレインストーミング
# temperature=0 は最も決定論的な出力(同じ入力に同じ出力)
# ただし、温度0でも完全な再現性が保証されるわけではない
# (並列処理のタイミング差など、実装依存の要因がある)
response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=256,
    temperature=0,       # 決定論的な出力
    messages=[...]
)

1.4 再現性が重要な理由

  • 論文・レポートでの引用:「このプロンプトでこの結果を得た」という記録が必要
  • バグの再現:「なぜこのコマンドが生成されたか」を後から確認するため
  • A/Bテスト:プロンプトを変更したときの差異を比較するため
  • 監査・説明責任:どのプロンプトが使われたかを証明するため

2. プロンプトのバージョン管理

2.1 Gitによるプロンプト管理の基本

プロンプトをコードと同様にGitで管理することで、変更履歴を追跡できます。

# プロンプト用ディレクトリを作成
mkdir -p .prompts/v1

# ディレクトリ構成例
.prompts/
├── README.md               # プロンプト一覧と用途の説明
├── v1/
│   ├── system_gdal.txt     # GDALコマンド生成用システムプロンプト
│   ├── few_shot_ogr2ogr.txt # ogr2ogr のfew-shot例
│   └── task_reproject.txt  # 投影変換タスク用プロンプト
└── v2/
    ├── system_gdal.txt     # 改善版システムプロンプト
    └── task_reproject.txt  # 改善版タスクプロンプト

2.2 プロンプトファイルの例

# .prompts/v1/system_gdal.txt を作成
cat > .prompts/v1/system_gdal.txt << 'EOF'
あなたはGDAL/OGRの専門家です。
ユーザーの指示に従い、適切なGDAL/OGRコマンドを生成してください。

必ず以下のルールを守ってください:
1. コマンドのみを出力し、説明文・前置き・コードブロック記号(```)は書かない
2. ファイルパスは必ずダブルクォートで囲む
3. 出力ファイルが存在する場合に備えて -overwrite オプションを付ける
4. CRSは EPSG コードで指定する
EOF

# Git でバージョン管理
git add .prompts/
git commit -m "feat: GDALコマンド生成用プロンプト v1 を追加"

2.3 プロンプトのバージョン番号管理

# プロンプトのバージョンをメタデータとして管理
PROMPT_VERSIONS = {
    "system_gdal": {
        "version": "1.0.0",
        "path": ".prompts/v1/system_gdal.txt",
        "git_commit": "a1b2c3d",   # 実際のコミットハッシュを記録
        "created": "2025-10-08",
        "description": "GDALコマンド生成用システムプロンプト(初版)"
    }
}

def load_prompt(name: str) -> tuple[str, dict]:
    """プロンプトファイルを読み込み、メタデータとともに返す"""
    meta = PROMPT_VERSIONS[name]
    with open(meta["path"]) as f:
        content = f.read()
    return content, meta

3. LLMの出力ログ記録

3.1 JSONLフォーマットとは

JSONL(JSON Lines)は、1行に1つのJSONオブジェクトを記録するフォーマットです。大量のログを効率的に記録・読み込みできます。

{"timestamp": "2025-10-08T10:00:00Z", "prompt_version": "1.0.0", "request": {...}, "response": {...}}
{"timestamp": "2025-10-08T10:01:30Z", "prompt_version": "1.0.0", "request": {...}, "response": {...}}
{"timestamp": "2025-10-08T10:05:12Z", "prompt_version": "1.1.0", "request": {...}, "response": {...}}

3.2 ログ記録スクリプト

#!/usr/bin/env python3
"""
llm_logger.py
Anthropic APIのリクエスト・レスポンスをJSONLファイルに記録するモジュール
"""

import json
import hashlib
import datetime
import pathlib
import anthropic
from typing import Any


LOG_DIR = pathlib.Path("logs")
LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / "llm_requests.jsonl"


def compute_prompt_hash(system: str, messages: list[dict]) -> str:
    """プロンプトのハッシュ値を計算(変更検知に使用)"""
    content = json.dumps({"system": system, "messages": messages}, ensure_ascii=False)
    return hashlib.sha256(content.encode()).hexdigest()[:16]


def call_and_log(
    client: anthropic.Anthropic,
    model: str,
    system: str,
    messages: list[dict],
    max_tokens: int = 512,
    temperature: float = 0.0,
    prompt_version: str = "unknown",
    task_id: str = "",
    **kwargs: Any
) -> anthropic.types.Message:
    """
    Claude APIを呼び出し、リクエスト・レスポンスをJSONLに記録する。

    Args:
        client: Anthropic クライアント
        model: 使用するモデルID
        system: システムプロンプト
        messages: メッセージ履歴
        max_tokens: 最大出力トークン数
        temperature: 温度パラメータ(0.0=決定論的)
        prompt_version: プロンプトのバージョン番号
        task_id: タスクの識別子(ログ検索用)
        **kwargs: その他のAPIパラメータ

    Returns:
        Anthropic APIのレスポンスオブジェクト
    """
    # タイムスタンプ(UTC)
    timestamp = datetime.datetime.utcnow().isoformat() + "Z"

    # プロンプトのハッシュ(変更検知)
    prompt_hash = compute_prompt_hash(system, messages)

    # APIリクエスト
    response = client.messages.create(
        model=model,
        max_tokens=max_tokens,
        temperature=temperature,
        system=system,
        messages=messages,
        **kwargs
    )

    # ログエントリの作成
    log_entry = {
        "timestamp": timestamp,
        "task_id": task_id,
        "prompt_version": prompt_version,
        "prompt_hash": prompt_hash,
        "model": model,
        "temperature": temperature,
        "max_tokens": max_tokens,
        "request": {
            "system": system,
            "messages": messages
        },
        "response": {
            "id": response.id,
            "content": [{"type": b.type, "text": b.text} for b in response.content],
            "stop_reason": response.stop_reason,
            "usage": {
                "input_tokens": response.usage.input_tokens,
                "output_tokens": response.usage.output_tokens
            }
        }
    }

    # JSONLに追記
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")

    return response


# 使用例
if __name__ == "__main__":
    client = anthropic.Anthropic()

    system_prompt = open(".prompts/v1/system_gdal.txt").read()
    task = "llm_access.geojson を EPSG:32654 に変換し output_utm.geojson に保存するコマンドを生成してください"

    response = call_and_log(
        client=client,
        model="claude-opus-4-5",
        system=system_prompt,
        messages=[{"role": "user", "content": task}],
        temperature=0.0,
        prompt_version="1.0.0",
        task_id="reproject_utm54n_001"
    )

    print(response.content[0].text)
    print(f"\nログを {LOG_FILE} に記録しました")

4. 温度パラメータとシードの設定

4.1 temperature=0 の効果

import anthropic

client = anthropic.Anthropic()

# 同じプロンプトで temperature=0 と temperature=1.0 を比較
prompt = "GeoJSONをEPSG:32654に変換するogr2ogrコマンドをコマンドのみ出力してください"

print("=== temperature=0(決定論的) ===")
for i in range(3):
    r = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=128,
        temperature=0,
        messages=[{"role": "user", "content": prompt}]
    )
    print(f"試行 {i+1}: {r.content[0].text.strip()}")

print("\n=== temperature=1.0(デフォルト) ===")
for i in range(3):
    r = client.messages.create(
        model="claude-opus-4-5",
        max_tokens=128,
        temperature=1.0,
        messages=[{"role": "user", "content": prompt}]
    )
    print(f"試行 {i+1}: {r.content[0].text.strip()}")

4.2 Anthropic APIのmetadataフィールド

Anthropic APIでは metadata フィールドを使ってリクエストに追加情報を付与できます。これにより、ログの追跡・管理が容易になります。

response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=512,
    temperature=0,
    system=system_prompt,
    messages=messages,
    metadata={
        # user_id: ユーザーの識別子(匿名化されたID推奨)
        "user_id": "student_anonymous_42"
    }
)

# metadata はレスポンスには返ってこないが、
# Anthropic ダッシュボードでのフィルタリングに使用できる
print(f"レスポンスID: {response.id}")
# レスポンスIDをログに保存することで、Anthropic側でのトレースが可能

4.3 再現性に関するベストプラクティス

# 再現性の高いAPI呼び出しの設定例
REPRODUCIBLE_CONFIG = {
    "model": "claude-opus-4-5",   # モデルバージョンを固定
    "temperature": 0,              # 決定論的な出力
    "max_tokens": 512,
}

# ※ seed パラメータについて
# OpenAI APIには seed パラメータがありますが、
# Anthropic Claude APIには現在 seed パラメータはありません。
# temperature=0 と プロンプトの固定が再現性確保の主な手段です。

5. 再現性チェックリスト

分析レポートや課題提出前に以下の項目を確認してください。

5.1 プロンプト管理

  • [ ] プロンプトはファイルに保存し、Gitでバージョン管理されている
  • [ ] プロンプトファイルには作成日・目的のコメントが記載されている
  • [ ] プロンプトのバージョン番号がログに記録されている
  • [ ] システムプロンプトとユーザープロンプトが分離されている

5.2 APIパラメータ

  • [ ] temperature=0 に設定している(または設定値をログに記録)
  • [ ] モデル名(例:claude-opus-4-5)を明示的に指定している
  • [ ] max_tokens を適切な値に設定している

5.3 ログ記録

  • [ ] リクエスト・レスポンスがJSONLファイルに記録されている
  • [ ] ログにタイムスタンプが含まれている
  • [ ] ログにプロンプトのハッシュが含まれている
  • [ ] ログファイルをGitでバージョン管理している(または別途保管)

5.4 再実行可能性

  • [ ] ログから元のリクエストを再送信できる仕組みがある
  • [ ] データファイルのパスが絶対パスでなく相対パスで記録されている
  • [ ] 必要なライブラリとバージョンが requirements.txt に記載されている

6. ハンズオン手順

事前準備

# 必要なライブラリのインストール
pip install anthropic

# プロジェクト構造の作成
mkdir -p .prompts/v1 logs

# Git の初期化(まだの場合)
git init
echo "logs/*.jsonl" >> .gitignore   # ログはGit管理外(容量が大きいため)
echo "*.jsonl" >> .gitignore
git add .gitignore
git commit -m "init: .gitignore にJSONLログを追加"

ステップ1: プロンプトファイルの作成

# システムプロンプトを作成
cat > .prompts/v1/system_gdal.txt << 'EOF'
# version: 1.0.0
# created: 2025-10-08
# purpose: GDALコマンド生成用システムプロンプト

あなたはGDAL/OGRの専門家です。ユーザーの指示に従い、適切なGDAL/OGRコマンドを生成してください。

必ず以下のルールを守ってください:
1. コマンドのみを出力し、説明文・前置き・コードブロック記号(```)は書かない
2. ファイルパスは必ずダブルクォートで囲む
3. 出力ファイルが存在する場合に備えて -overwrite オプションを付ける
4. CRSは EPSG コードで指定する
EOF

# Git にコミット
git add .prompts/
git commit -m "feat: GDALコマンド生成システムプロンプト v1.0.0 を追加"

ステップ2: ログ付きAPI呼び出し

llm_logger.py(セクション3.2)を実行してログを記録します。

python llm_logger.py

# ログファイルを確認
cat logs/llm_requests.jsonl | python3 -m json.tool

ステップ3: ログからの再実行

次のセクション7のスクリプトを使ってログを再実行します。


7. 演習課題

評価対象課題です。 締め切りと提出方法は授業内で別途案内します。

課題内容

第07回で作成した geospatial_command_generator.py に、ログ記録と再実行機能を追加してください。

必須成果物

ログから同一プロンプトを再実行するPythonスクリプト(replay_from_log.py)

以下の機能を実装してください:

#!/usr/bin/env python3
"""
replay_from_log.py
JSONLログから指定したエントリを再実行し、結果を比較するスクリプト

使用方法:
    python replay_from_log.py --log logs/llm_requests.jsonl --task-id reproject_utm54n_001
"""

import json
import argparse
import anthropic
from pathlib import Path


def load_log_entry(log_file: str, task_id: str) -> dict | None:
    """
    JSONLログファイルから指定のtask_idのエントリを検索して返す。

    Args:
        log_file: JSONLログファイルのパス
        task_id: 検索するタスクID

    Returns:
        マッチしたログエントリ、見つからない場合は None
    """
    with open(log_file, encoding="utf-8") as f:
        for line in f:
            entry = json.loads(line.strip())
            if entry.get("task_id") == task_id:
                return entry
    return None


def replay_request(entry: dict) -> str:
    """
    ログエントリの内容を使って、まったく同じリクエストを再送信する。

    Args:
        entry: JSONLログの1エントリ(辞書型)

    Returns:
        新しいレスポンスのテキスト
    """
    client = anthropic.Anthropic()

    # 元のリクエストパラメータを復元
    response = client.messages.create(
        model=entry["model"],
        max_tokens=entry["max_tokens"],
        temperature=entry["temperature"],
        system=entry["request"]["system"],
        messages=entry["request"]["messages"]
    )

    return response.content[0].text.strip()


def compare_responses(original: str, replayed: str) -> None:
    """元の出力と再実行の出力を比較して表示する"""
    print("\n=== 元の出力 ===")
    print(original)
    print("\n=== 再実行の出力 ===")
    print(replayed)
    print("\n=== 比較結果 ===")
    if original.strip() == replayed.strip():
        print("完全一致: 再現性が確認されました")
    else:
        # 差分を表示
        import difflib
        diff = list(difflib.unified_diff(
            original.splitlines(),
            replayed.splitlines(),
            lineterm="",
            fromfile="元の出力",
            tofile="再実行"
        ))
        if diff:
            print("差分あり:")
            print("\n".join(diff))
        else:
            print("空白の違いのみ(実質的に一致)")


def main():
    parser = argparse.ArgumentParser(description="JSONLログからLLMリクエストを再実行する")
    parser.add_argument("--log", required=True, help="JSONLログファイルのパス")
    parser.add_argument("--task-id", required=True, help="再実行するタスクID")
    args = parser.parse_args()

    # ログエントリを検索
    entry = load_log_entry(args.log, args.task_id)
    if entry is None:
        print(f"エラー: task_id='{args.task_id}' が見つかりません")
        return

    print(f"ログエントリを発見:")
    print(f"  タイムスタンプ: {entry['timestamp']}")
    print(f"  モデル: {entry['model']}")
    print(f"  temperature: {entry['temperature']}")
    print(f"  プロンプトバージョン: {entry['prompt_version']}")
    print(f"  プロンプトハッシュ: {entry['prompt_hash']}")

    # 元の出力を取得
    original_output = entry["response"]["content"][0]["text"]

    # 再実行
    print("\nリクエストを再実行中...")
    replayed_output = replay_request(entry)

    # 比較
    compare_responses(original_output, replayed_output)


if __name__ == "__main__":
    main()

提出物一覧

ファイル名 説明
replay_from_log.py ログ再実行スクリプト(上記実装)
llm_logger.py ログ記録付きAPI呼び出しモジュール
logs/llm_requests.jsonl 実際に記録したJSONLログ(最低3件のエントリ)
.prompts/v1/system_gdal.txt バージョン管理されたシステムプロンプト
report.md 考察(temperature=0 での再現性の確認結果、ログ管理の工夫など)200〜300字

採点基準

項目 配点
replay_from_log.py が正しく動作する(task_id でエントリを検索・再実行) 30点
llm_logger.py でJSONLログが正しく記録されている 25点
JSONLログに必須フィールド(timestamp, prompt_hash, model, temperature, request, response)が含まれる 20点
プロンプトファイルがGit管理されている(コミット履歴あり) 15点
考察(再現性の観察、temperature の効果の考察) 10点

実行例

# ログを記録しながらAPIを呼び出す
python llm_logger.py

# ログから再実行する
python replay_from_log.py \
  --log logs/llm_requests.jsonl \
  --task-id reproject_utm54n_001

# 期待される出力:
# ログエントリを発見:
#   タイムスタンプ: 2025-10-08T10:00:00Z
#   モデル: claude-opus-4-5
#   temperature: 0
#   ...
# === 比較結果 ===
# 完全一致: 再現性が確認されました

まとめ

  • LLMの出力は確率的なサンプリングに基づいており、デフォルトでは同じプロンプトでも異なる出力が返ることがある
  • temperature=0 を設定することで、出力を最も決定論的にできる(ただし完全な保証はない)
  • Anthropic Claude APIには seed パラメータはないが、temperature=0 とプロンプトの固定が再現性確保の主な手段
  • プロンプトは .prompts/ ディレクトリに保存し、Gitでバージョン管理することで変更履歴を追跡できる
  • JSONLフォーマットでリクエスト・レスポンスを記録することで、後から同じ処理を再実行・比較できる
  • metadata フィールドを使うとリクエストに追加情報を付与でき、ログの管理が容易になる
  • 分析レポートには、使用したモデル名・プロンプトバージョン・temperature 値を必ず記載する

後期まとめ

第05回〜第08回では以下のことを学びました:

  1. 第05回:KDEによるポイントデータの密度推定とヒートマップ化
  2. 第06回:Tippecanoeを使った大規模地理データのベクトルタイル変換
  3. 第07回:プロンプトエンジニアリングによるGDALコマンド生成精度の改善
  4. 第08回:バージョン管理とログ記録によるLLM分析の再現性確保

これらの技術を組み合わせることで、地理空間情報とLLMを科学的・再現可能な形で活用できるようになります。


ビッグデータ応用Ⅱ〔特徴量エンジニアリング〕 第08回 © 小野原彩香