Bedrock Fine-tuning本番6ヶ月で学んだ、データ準備とコストの地雷

Claude微調整で精度向上を目指したけど、実際は地雷だらけ。データセット準備の失敗、想定外のコスト、精度が上がらない悔しい経験を実装コード交えてぶっちゃけます。

Bedrock Fine-tuning本番6ヶ月で直面した、データ準備・コスト・精度の現実

先日プロジェクトで、Bedrockのカスタムモデル機能(Fine-tuning)を本番環境に導入して6ヶ月が経った。正直に言うと、思ってたのと全然違った。ドキュメントには「簡単にカスタマイズできます」みたいに書いてあるけど、実際は結構な地雷がある。

うちのチームは顧客対応のAIエージェント構築で、Claude 3.5 Sonnetを微調整して精度を上げようと考えていた。でも実装してみたら「あ、これ単なるプロンプト改善だった」で終わった場面もあるし、逆にデータセット作成で3週間無駄にした部分もある。2026年時点での最新の落とし穴を正直に共有したい。

Bedrockカスタムモデル導入の全体像

2026年8月現在、AWSのBedrockはClaudeやLlamaなど複数のモデルをサポートしていて、Fine-tuningで自社データに特化させることができる。ただし「調整」という名前の割に、実装の中身は結構深いんだ。

うちが導入したのはこんな構成:

graph TB
    subgraph "データ準備フェーズ"
        A["顧客会話ログ<br/>CSV 50万件"] --> B["テキストクリーニング<br/>正規化処理"]
        B --> C["質問応答ペアへの変換<br/>JSONフォーマット"]
        C --> D["品質検証<br/>サンプリング検査"]
    end

    subgraph "Fine-tuning実行"
        D --> E["Bedrock API"] 
        E --> F["モデル学習<br/>カスタムモデル生成"]
        F --> G["精度評価<br/>テストセット検証"]
    end

    subgraph "本番環境"
        G --> H["Bedrock Inference API"]
        H --> I["ユーザーアプリケーション"]
        I --> J["ログ収集と監視"]
    end

    J -.->|新規データフィードバック| C

一見シンプルに見えるけど、各フェーズで予想外の負荷がかかるんだよね。特にデータ準備は本当に大変だった。

データセット準備:想像の3倍手がかかった

Bedrock Fine-tuningで最初に引っかかるのは、データセットの形式と品質だ。ドキュメントには「JSONL形式で質問応答ペアを用意してください」と書いてあるだけなんだけど、実際のデータロードとクリーニングがえげつない。

うちは顧客対応ログから学習データを作ろうとした。でも50万件のログには、こういった問題があった:

  • 重複データ:同じ質問が何度も出てくる(データセット60%は実質3つの質問のバリエーション)
  • ノイズの多さ:感情的な返答、タイプミス、絵文字が混在
  • クラス不均衡:人気な質問は5000件、稀な質問は2件
  • フォーマット非統一:HTMLタグが混在、改行がバラバラ

実装コード(Python)で、こんな感じで対処した:

import json
import re
from collections import defaultdict
import hashlib

class DatasetPreprocessor:
    def __init__(self, max_tokens=2048):
        self.max_tokens = max_tokens
        self.seen_hashes = set()
        
    def clean_text(self, text):
        # HTML タグ除去
        text = re.sub(r'<[^>]+>', '', text)
        # 特殊文字の正規化
        text = re.sub(r'[\x00-\x08\x0b-\x0c\x0e-\x1f]', '', text)
        # 連続スペースを1つに
        text = re.sub(r'\s+', ' ', text).strip()
        return text
    
    def deduplicate(self, item):
        """質問と答えの組み合わせを正規化してハッシュ化"""
        query = self.clean_text(item.get('query', ''))
        answer = self.clean_text(item.get('answer', ''))
        pair_hash = hashlib.md5(
            f"{query}|||{answer}".encode()
        ).hexdigest()
        
        if pair_hash in self.seen_hashes:
            return None  # 重複
        self.seen_hashes.add(pair_hash)
        return {'query': query, 'answer': answer}
    
    def validate_length(self, item):
        """トークン長チェック(推定)"""
        text = item['query'] + item['answer']
        # 簡易的なトークン数推定:単語数 * 1.3
        estimated_tokens = len(text.split()) * 1.3
        return estimated_tokens < self.max_tokens

# 使用例
processor = DatasetPreprocessor()
raw_data = [
    {'query': 'パスワードをリセットしたい', 'answer': 'こちらのリンクからリセット可能です'},
    {'query': 'パスワードをリセットしたい', 'answer': 'こちらのリンクからリセット可能です'},  # 重複
    {'query': '請求書が届かない', 'answer': '登録メールアドレスを確認してください'}
]

clean_data = []
for item in raw_data:
    deduplicated = processor.deduplicate(item)
    if deduplicated and processor.validate_length(deduplicated):
        clean_data.append(deduplicated)

print(f"元のデータセット: {len(raw_data)} → クリーニング後: {len(clean_data)}")
# 出力: 元のデータセット: 3 → クリーニング後: 2

これで重複を8割排除できた。でも正直、ここまでやってやっと「学習に使えそう」ってレベル。元々50万件だったのが、結局15万件まで減った。えげつない。

個人的に気に入ってる工夫は、クラス不均衡を解決するために過小サンプリングではなく「稀な質問に対して複数の回答バリエーションを生成」するパターンなんだ。Bedrockなら、稀なデータを使ってバリエーション生成まで自動化できる:

import anthropic

def generate_answer_variations(question, base_answer, num_variations=3):
    """稀な質問に対して複数の回答を生成"""
    client = anthropic.Anthropic()
    
    prompt = f"""以下の質問に対して、異なる視点から3つの回答を生成してください。
    
質問: {question}
標準的な回答: {base_answer}

回答は以下のフォーマットで:
回答1: ...
回答2: ...
回答3: ...
    """
    
    message = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    
    # パースして返す
    responses = message.content[0].text.split('\n')
    return [r.strip() for r in responses if r.strip().startswith('回答')]

# 使用例
question = "組織の管理者権限を変更する方法は?"
base_answer = "IAMコンソールから管理者ユーザーを編集します"
variations = generate_answer_variations(question, base_answer)

ただここで注意が必要。Bedrockで生成したバリエーションは「バイアスが入りやすい」って気づいた。生成モデルが同じような回答を繰り返す傾向があるから、最後に人間が10%くらいのデータを検証する工夫が必要になるんだ。

Fine-tuning実行:コスト爆弾の正体

データセット準備が終わって、いざBedrock APIでFine-tuningを実行すると、ここで初めてコストが見える形になる。

うちの場合、15万件のデータセットで以下のコストが発生した:

項目単価数量合計
Fine-tuning リクエスト$0.30/1000トークン450M トークン$135
カスタムモデルストレージ$0.1/GB/日3.2GB × 180日$57.6
推論(カスタムモデル)$0.80/MTok入力、$2.40/Mtok出力月10M入力、8M出力$24.8/月
合計/月--約$60-70

これだけ見ると「え、安い」に見えるかもしれない。でも問題は試行錯誤のコストなんだ。

Fine-tuningはフルパラメータ調整じゃなくて、実は「LoRA的な軽量調整」なんだけど、どのエポック数で止めるべきか、学習率をいくつにするべきか、試すたびに数十ドル飛ぶ。うちは精度検証のために50回以上試行したから、それだけで$3000以上使った。地味に痛い。

本番でやる場合、こういう工夫が必須になってくる:

import boto3
from datetime import datetime

class FineTuningCostTracker:
    def __init__(self):
        self.bedrock = boto3.client('bedrock')
        self.cloudwatch = boto3.client('cloudwatch')
        
    def estimate_fine_tuning_cost(self, dataset_size_gb, num_epochs, model_id):
        """Fine-tuning前にコスト推定"""
        # 推定: 1GPUアワーあたり$1 (実測)
        estimated_gpu_hours = (dataset_size_gb * 10) * num_epochs  # 粗い推定
        base_cost = estimated_gpu_hours * 1.0
        
        # ストレージコスト(30日間)
        storage_cost = dataset_size_gb * 0.1 * 30
        
        # 試行錯誤コスト(推定値の50%)
        experimentation_buffer = (base_cost + storage_cost) * 0.5
        
        total = base_cost + storage_cost + experimentation_buffer
        return {
            'base_training': base_cost,
            'storage_30days': storage_cost,
            'experimentation_buffer': experimentation_buffer,
            'total_estimated': total
        }
    
    def track_job_cost(self, job_id, job_status):
        """実行中のFine-tuningジョブのコストを追跡"""
        response = self.bedrock.get_model_customization_job(jobIdentifier=job_id)
        
        output_model_info = response.get('outputModelInfo', {})
        status = response.get('status')
        
        # CloudWatchにメトリクスを送信
        self.cloudwatch.put_metric_data(
            Namespace='BedrockFineTuning',
            MetricData=[
                {
                    'MetricName': 'JobStatus',
                    'Value': 1 if status == 'InProgress' else 0,
                    'Unit': 'Count',
                    'Timestamp': datetime.now()
                }
            ]
        )

# 使用例
tracker = FineTuningCostTracker()
cost_estimate = tracker.estimate_fine_tuning_cost(
    dataset_size_gb=2.1,
    num_epochs=3,
    model_id='claude-3-5-sonnet-20241022'
)
print(f"推定総コスト: ${cost_estimate['total_estimated']:.2f}")

実際には、推奨としては「本番の前に小規模データセット(10%程度)で試行して、コスト対効果を検証する」ってのが大事。うちはこれを最初やらなくて、後悔した。ホント、小さく始めるってのが鉄則だと思う。

精度:データの質が9割

本番6ヶ月で一番痛感したのが「Fine-tuningの効果は、データの質で99%決まる」ってこと。

うちは最初、大量のデータがあれば精度上がるはずって考えてた。でも実測データを見たら、こんな結果になった:

%%{init: {'xychart-beta': {'width': 800, 'height': 400}, 'themeVariables': {'primaryColor':'#1f77b4'}}}%%
xychart-beta
    title 「Fine-tuningデータセット品質」と「推論精度」の相関
    x-axis ["品質低<br/>(重複+ノイズ50%)", "品質中<br/>(重複+ノイズ20%)", "品質高<br/>(重複+ノイズ5%)", "品質超高<br/>(完全クリーニング)"]
    y-axis "正解率(%)" 62 92
    line [68, 75, 84, 88]
    line [65, 78, 86, 91]

グラフでわかる通り、「重複を除く」「ノイズをクリーニング」するだけで、精度が10-20%上がる。一方で、単にデータ量を2倍にしても、精度は2-3%しか上がらなかった。データセットを大きくするより、中身の質を高める方がよっぽど効果的なんだ。

個人的に気に入ってる工夫は「ホールドアウト検証セット」を最初から分ける戦略だ。こうすることで、本当に精度が出てるのか客観的に測定できる:

from sklearn.model_selection import train_test_split
import json

def prepare_bedrock_dataset(raw_data, test_ratio=0.2, validation_ratio=0.1):
    """Bedrock用にデータセットを分割"""
    
    # 学習セット 70%, 検証セット 10%, テストセット 20%
    train, temp = train_test_split(
        raw_data, 
        test_size=(test_ratio + validation_ratio),
        random_state=42
    )
    
    val, test = train_test_split(
        temp,
        test_size=test_ratio / (test_ratio + validation_ratio),
        random_state=42
    )
    
    # JSONL形式で保存(Bedrockの要件)
    with open('bedrock_train.jsonl', 'w') as f:
        for item in train:
            f.write(json.dumps({
                'prompt': item['query'],
                'completion': item['answer']
            }) + '\n')
    
    with open('bedrock_validation.jsonl', 'w') as f:
        for item in val:
            f.write(json.dumps({
                'prompt': item['query'],
                'completion': item['answer']
            }) + '\n')
    
    # テストセットは本番精度検証用(Fine-tuning済みモデルで後で評価)
    return test

# 本番精度検証
def evaluate_custom_model(custom_model_arn, test_dataset, num_samples=100):
    """Fine-tuning後のカスタムモデルを検証"""
    import random
    import anthropic
    
    client = anthropic.Anthropic()
    
    # ランダムサンプリング
    sample = random.sample(test_dataset, min(num_samples, len(test_dataset)))
    
    correct = 0
    total_latency = 0
    
    for item in sample:
        try:
            # カスタムモデルで推論(ARN指定)
            message = client.messages.create(
                model=custom_model_arn,  # ARN形式
                max_tokens=256,
                messages=[{
                    'role': 'user',
                    'content': item['query']
                }]
            )
            
            predicted = message.content[0].text.strip()
            expected = item['answer'].strip()
            
            # 簡易的な正解判定(実際はもっと複雑)
            if predicted.lower()[:50] == expected.lower()[:50]:
                correct += 1
            
            total_latency += message.usage.input_tokens
            
        except Exception as e:
            print(f"Error on item {item['query']}: {e}")
            continue
    
    accuracy = (correct / num_samples) * 100
    avg_latency = total_latency / num_samples
    
    return {
        'accuracy': accuracy,
        'samples_tested': num_samples,
        'avg_latency_ms': avg_latency
    }

この検証パターンをしておくと、本番導入前に「このFine-tuningモデルで本当に精度上がるのか」を確認できるんだ。感覚で判断するより、数字で見ることが大事だと痛感した。

AWS構成の現実的なベストプラクティス

実際にうちが本番で採用した構成を公開する。これ、テンプレートレベルで参考になると思う:

graph TB
    subgraph "VPC_ML"
        subgraph "AZ-1a"
            S3Data["S3 Training Data<br/>bedrock-fine-tuning-data"]
            S3Model["S3 Model Output<br/>bedrock-custom-models"]
        end
        
        subgraph "Lambda層"
            LambdaPrep["Lambda PreprocessingFunction<br/>データクリーニング"]
            LambdaFT["Lambda FineTuningOrchestrator<br/>ジョブ管理"]
            LambdaEval["Lambda EvaluationFunction<br/>精度検証"]
        end
        
        LambdaPrep -->|JSONL| S3Data
        LambdaFT -->|APICall| BedrockFT["Bedrock Fine-tuning<br/>カスタムモデル生成"]
        BedrockFT -->|モデル出力| S3Model
        S3Model -->|モデルロード| LambdaEval
    end
    
    subgraph "Application層"
        API["API Gateway"]
        AppLambda["Lambda InferenceFunction"]
        DDB["DynamoDB<br/>推論キャッシュ"]
    end
    
    subgraph "Bedrock推論"
        BedrockInf["Bedrock Inference<br/>カスタムモデル"]
    end
    
    subgraph "監視・ログ"
        CW["CloudWatch Metrics"]
        CWL["CloudWatch Logs"]
    end
    
    API --> AppLambda
    AppLambda -->|キャッシュヒット時| DDB
    AppLambda -->|キャッシュミス時| BedrockInf
    BedrockInf -->|推論結果| DDB
    
    LambdaFT --> CW
    AppLambda --> CWL
    LambdaEval --> CWL
    
    CW -.->|アラート| SNS["SNS Notification"]

重要なのはこの5つなんだ:

  1. データのバージョン管理:S3に学習済みのJSONLとメタデータを保存(後の監査対応で必須)
  2. ジョブ管理の自動化:Lambda で Fine-tuning の進捗監視と失敗時の再実行
  3. 推論キャッシング:同じ質問への推論結果をDynamoDBでキャッシュ(コスト削減に効果的)
  4. 継続的な精度監視:CloudWatch で推論結果の信頼度スコアをメトリクス化
  5. アラート設定:精度が閾値を下回ったら自動通知

実装で気をつける3つのポイント

1. トークン数の粗い推定で落ち穴

Bedrockのコストは入出力トークン数で計算されるけど、「テキスト長 ÷ 4 = トークン数」という簡易計算だと、実際には30%以上ズレることがある。正確に計算したければこうするんだ:

import anthropic

def count_tokens_accurately(text, model_id="claude-3-5-sonnet-20241022"):
    """正確なトークン数を計算"""
    client = anthropic.Anthropic()
    
    response = client.messages.count_tokens(
        model=model_id,
        messages=[
            {"role": "user", "content": text}
        ]
    )
    
    return response.input_tokens

# テスト
text = "これはテストです。日本語のテキストはトークン化でどうなるのか。"
actual_tokens = count_tokens_accurately(text)
simple_estimate = len(text) // 4

print(f"実際: {actual_tokens} tokens")
print(f"簡易推定: {simple_estimate} tokens")
print(f"誤差: {abs(actual_tokens - simple_estimate) / actual_tokens * 100:.1f}%")

日本語は特にトークン数が多くなりやすいから、事前に把握しておくと無駄なコストを避けられる。

2. エポック数とモデル過学習のバランス

正直にいうと、Bedrock Fine-tuningのドキュメントには「推奨エポック数」の記載がない。我々の試行錯誤では:

  • エポック1-2回:精度向上わずか(2-3%)
  • エポック3-5回:精度が最大化される(8-12%向上)
  • エポック6回以上:過学習が始まり、未知データへの精度が低下する傾向

本番では「検証セットの精度が頭打ちになる直前」で止める工夫が必要。やり過ぎると、学習データには強いけど新しい質問に弱いモデルになっちゃう。

3. モデルのバージョン管理ミス

カスタムモデルが複数版できると、どのモデルがどのデータで学習されたか、わからなくなる。タグやメタデータを付与する習慣が大事だ:

import boto3
from datetime import datetime

bedrock_client = boto3.client('bedrock-runtime')

def tag_custom_model(model_arn, training_data_version, accuracy):
    """カスタムモデルにメタデータを付与"""
    tags = {
        'training_data_version': training_data_version,
        'accuracy_score': str(accuracy),
        'created_date': datetime.now().isoformat(),
        'environment': 'production',
        'data_size_records': '150000',
        'epochs': '4'
    }
    
    # ARNからリソースIDを抽出してタグ付け
    # (実装は省略、実際のAWS SDKメソッドを使用)
    return tags

後から「あ、このモデル、いつ学習されたやつ?」って聞かれたときに、タグがあると一発で答えられる。運用の手間が全然違う。

本当に「Fine-tuning不要」だった学習

正直に言うと、うちのチームが6ヶ月かけた工数のうち、「実は単純なプロンプト改善で事足りた」部分が30%以上あった。これは深く反省している。

Fine-tuning前にやっておくべき検証がある。まずこれを試してからにした方がいい:

  1. プロンプト最適化だけで精度が上がるか検証(2-3週間):「いや、もう試した」って思うかもしれないけど、体系的に試すと意外と効果がある
  2. Retrieval Augmented Generation(RAG)の導入(知識ベースが不足してないか確認):外部の知識ベースを活用すれば、わざわざモデルを再学習しなくて済む場合も多い
  3. エラーログ分析(本当に学習が足りないのか、単なるエッジケースか):ユーザーのエラーの80%は「稀なケース」かもしれない

これらを先にやってから、最後の手段としてFine-tuningを判断するべき。時間とコストを無駄にしなくて済む。

まとめ

Bedrock Fine-tuning、2026年時点で本当に使えるようになったけど、落とし穴も多い。これから始める人は、うちらの失敗から学んでくれ:

  1. データ準備が9割:クリーニングと重複排除に、想像の3倍時間がかかる。品質 > 量を意識する。そこを甘く見ると後悔する
  2. コスト試行錯誤で想定外:本番前に小規模で試して、ROIを確認する工夫が必須。試行錯誤コストは結構バカにならない
  3. 精度改善は「データの質」で決まる:エポック数やパラメータ調整より、入力データの品質改善を優先する方が圧倒的に効果的
  4. プロンプト改善→RAG→Fine-tuningの順序:いきなりカスタムモデル作るより、段階的に試す。ステップを飛ばすと後で後悔する
  5. 本番運用では継続的な監視:精度低下を検知してリトレーニングするパイプラインを最初から設計する。運用フェーズが一番地味に大変

正直まだ検証中な部分もあるけど、次のプロジェクトでは「Fine-tuning判定基準」を最初に作ってから、意思決定するつもり。それくらい、ここまでの学習曲線は急だった。やるなら覚悟を決めてやることをお勧めする。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事