その他

【LLMの学習】Macローカル環境におけるQLoRAファインチューニング | 独自ルールの定義と事前知識バイアス

その他
この記事は約22分で読めます。

はじめに

みなさん、こんにちは。株式会社インプルのyotaです。

さっそくですが、AIにコーディングさせていて、こんなもどかしさを感じたことはないでしょうか?

「元のコードに合わせて一部だけ修正して欲しかったのに、関係ない部分まで勝手に違う書き方で上書きされてしまった…」

こういう時、「プロンプトで『〜というルールに沿って書いて』と細かく指示をすれば直るな」と考えます。しかし、プロジェクト全体のコーディング規約や独自のコンポーネント、マイルールを毎回毎回プロンプトに長々と書き込むのは非常に面倒です。

今回のテーマ:LLMのファインチューニング

「なら、『カスタム指示』や『Projects』を使えば解決するのでは?」ーーそう考える鋭い方もいるかもしれません。確かに、柔軟なクラウドAIを使える環境であればそれが最短ルートです。

しかし、ローカルLLMではどうでしょうか?ローカルLLMで「独自のルール」を守らせようとすると、一つの壁にぶつかります。プロンプトでいくら指示しても、モデルが元々持っている「事前知識のバイアス(標準的なコードの書き方)」が強すぎるため、複雑な指示になるとプロンプトに沿わないコードが漏れ出してしまうのです。

本記事のテーマは、この「プロンプトの限界」を突破することです。ローカル環境で動くLLMの脳内に直接「独自ルール」を教え込む、ファインチューニングの実戦録をお届けします。

検証環境には、ユニファイドメモリ16GBのMacとApple Siliconに最適化された機械学習フレームワーク「MLXmlx_lm)」を採用。ベースモデルにはQwen 2.5 Coder(7B)を選択し、QLoRAを用いて学習を実行しました。

事前知識

今回の検証は、高価なクラウドGPUではなく、手元のユニファイドメモリ16GBのMacで実行しています。「70億パラメータのLLMをノートPCで学習させる」という挑戦を可能にしている3つのコア技術について、簡単に解説しておきます。

MLXとは?

MLXは、Appleが開発したApple Silicon(Mチップ)に最適化された機械学習フレームワークです。

ローカルLLMのファインチューニングについて調べたことがある方なら、「Unsloth」というライブラリの名前を聞いたことがあるかもしれません。実際、現在のLLM学習においてUnslothは圧倒的な人気を誇っています。

しかし、ここに致命的な問題があります。Unslothをはじめとする強力なAIツールの多くは、NVIDIA製の高価なGPU(CUDA)を搭載したWindowsやLinux環境を前提としており、Macでは動かないのです。

「じゃあ、高価なNVIDIA GPUがないMacユーザーは学習を諦めるしかないのか?」ーーそこで救世主となるのが、MLXなのです。

なぜQwen?(Qwen 2.5 Coder 7Bの採用)

今回、学習させるベースモデルにはAlibabaが開発したQwen 2.5 Coder(7B)を採用しました。しかし、これは「Qwenが最も優秀だったから」ではありません。

実はプロジェクトの初期段階で、3つのモデル(DeepSeek、Llama、Qwen)のベースライン性能を比較検証しました。その結果、コーディングとその解説で最も精度の高い結果を残したのはDeepSeekでした。

では、なぜDeepSeekはベースモデルに採用されなかったのか。そこにはローカルLLM特有の物理的な限界構造の壁がありました。DeepSeekはそのアーキテクチャの複雑さゆえに、16GBメモリのMacでファインチューニングを回そうとすると、リソース不足や構造的な制約に引っかかり、学習を成立させるのが非常に困難なのです。

そこで、私は以下のような役割分担の戦略を取ることにしました。

  1. DeepSeek-Coder-V2(教師
    その圧倒的な精度を活かし、学習用の「高品質なQAデータセット(教師データ)」を量産させるための推論専用モデルとして起用。
  2. Qwen 2.5 Coder(生徒
    ベースのコーディング能力が十分に高く、かつ16GBのMac環境でも素直に学習(QLoRA)が回ってくれるため、実際に独自ルールを教え込む「ファインチューニングの対象」として起用。

QLoRAとは?

LLMのファインチューニングにおいて、メモリ消費を抑えるために一般的に用いられるのがLoRA(Low-Rank Adaptation)という手法です。これは元の巨大なモデル(7B)は凍結して一切書き換えず、少量の「追加パラメータ」だけを学習させることで、計算コストを劇的に下げる技術です。

しかし、16GBメモリのMacでは、単なるLoRAでもメモリが足りません
学習対象が減ったとはいえ、ベースとなるQwenモデルを標準の精度(16ビット)でメモリに読み込むだけでも約14GBを消費してしまい、学習を処理するための空き容量がなくなってしまうからです。

そこで必須となるのがLoRAに「量子化(Quantization)」というデータ圧縮技術を組み合わせたQLoRAです。

モデルのパラメータ精度を16ビットから4ビットなどの荒い精度に落とすことで、賢さを保ったまま、ベースモデルのメモリ消費を約14GBから約4.5GBへと大幅にダイエットさせます。これにより、余った約10GBのメモリ領域を使って、Mac上でも安全かつ快適に独自ルールの学習を回すことができるようになります。

学習データの作成

事前知識のセクションでも触れた通り、今回はMacの16GBメモリ環境に合わせて「Qwen 2.5 Coder」を学習対象(生徒)とします。しかし、AIに新しいルールを教え込むには、質の高い「学習データ」が必要です。

ここでは、最も賢いモデルである「DeepSeek」を教師役として起用し、Qwenのための教科書(データセット)を自動生成させるアプローチをとります。

教科書の元ネタ(独自のコーディングルール)を用意する

LLMのファインチューニングでは、「入力(指示)」と「出力(理想のコード)」がセットになったQA形式のデータ大量に必要です。しかし、手作業で何十個もバリエーション(実際はもっと必要です…)を作るのは現実的ではありません。

まずは、プロジェクトで使っている独自のコンポーネント仕様をまとめた以下のようなテキストファイル(raw_code.txt)を用意します。今回はUnok社という仮想の会社のルールを作ります。

// Unok社 独自のUIコンポーネント: UnokButton
// ルール: ボタンのレンダリングには必ずunok_render() を使用し、標準の onClick ではなく onUnokClick をバインドすること。
function createUnokButton(label, action) {
    const btn = document.createElement('button');
    btn.className = 'unok-btn-primary';
    btn.innerText = label;
    btn.onUnokClick = action;
    return unok_render(btn);
}

データ生成の壁:DeepSeekは想像が苦手

最初は、コーディング能力の高い「DeepSeek-Coder-V2」に、このraw_code.txtを読み込ませて「QAペアを全部作って」と丸投げしようとしました。

しかし、ここで予期せぬ壁にぶつかります。DeepSeek-Coder-V2はコードを書くことに関しては天才的ですが、「一般的なユーザーがどういう風に指示をしてくるか(意味抽出や自然なプロンプトの生成)」を想像するのが非常に苦手だったのです。

Llamaで意味抽出 × DeepSeekでコード生成

「意図を汲み取って自然な文章を作るのが得意なAI」と「仕様書通りにコードを書くのが得意なAI」。それぞれの強みを活かすため、以下のような2段階のAIパイプラインを構築することにしました。

  1. Llama(指示の生成役
    自然言語処理に優れたLlamaにraw_code.txtを読み込ませ、「このフレームワークを使うユーザーは、どんな指示(プロンプト)を出してくるか」を推測させ、多様な「ユーザー指示」だけを生成させる。
  2. DeepSeek(コードの生成役
    Llamaが作った「ユーザーの指示」とraw_code.txtをDeepSeekに渡し、ユーザーの指示に従ったコードを書かせる。

このようにAI同士で役割分担をさせることでノイズの少ないクリーンな学習データが生成できるようになりました。実際に使用したスクリプトは次のとおりです。

import ollama
import json
import os

# 設定
DIRECTOR_MODEL = "llama3.1:8b"
CODER_MODEL = "deepseek-coder-v2"
INPUT_FILE = "raw_code.txt"
OUTPUT_FILE = "dataset_multi.jsonl"

# --- 教師モデルへのプロンプト ---
SYSTEM_PROMPT = """
あなたはAIモデルを訓練するための「高品質なデータセット」を作成する専門家です。
提供された社内独自のコードスニペットから、それをAIに書かせるための「ユーザーの指示(user)」と「期待されるAIの出力(assistant)」のペアを3パターン作成してください。

【条件】
- パターン1: 基本的な実装を求めるシンプルな指示
- パターン2: 少し具体的な条件(変数名や挙動など)を指定した指示
- パターン3: エラーハンドリングや社内ルールを強調した指示
- 出力は必ず以下のJSONフォーマットのみとし、Markdownの装飾(```json など)や解説は一切含めないでください。

[
    {"instruction": "ユーザーの指示1", "output": "コード1"},
    {"instruction": "ユーザーの指示2", "output": "コード2"},
    {"instruction": "ユーザーの指示3", "output": "コード3"},
]
"""

def generate_multi_agent_dataset():
    if not os.path.exists(INPUT_FILE):
        print(f"エラー: '{INPUT_FILE}' が見つかりません。")
        return
    
    with open(INPUT_FILE, "r", encoding="utf-8") as f:
        raw_code = f.read()
    
    print(f"マルチエージェントによるデータセット生成を開始します...\n")

    # =========================================
    # Step 1: Llama 3.1 による「自然な指示文」の生成
    # =========================================
    print(f"[Step1] {DIRECTOR_MODEL} が人間の指示文を考察中...")

    director_prompt = f"""
    あなたは開発現場のシニアエンジニアです。
    以下の【社内独自コード】を部下に書かせるために、チャットや口頭で伝える「自然な日本語の指示文」を3パターン作成してください。

    【条件】
    - パターン1: 要件をストレートに伝える指示
    - パターン2: 目的(なぜそれを作るか)を混ぜた指示
    - パターン3: 少しラフで短い指示
    - 出力は必ず以下のJSON配列(文字列のリスト)のみとしてください。解説は不要です。

    [
        "自然な指示文1",
        "自然な指示文2",
        "自然な指示文3",
    ]

    【社内独自コード】
    {raw_code}
    """

    try:
        director_response = ollama.chat(
            model=DIRECTOR_MODEL,
            messages=[{'role': 'user', 'content': director_prompt}],
            options={"temperature": 0.7} # アイデア出しなので少し高めに設定
        )
        raw_instructions = director_response['message']['content'].strip()

        # Markdownクリーニング
        if raw_instructions.startswith("```json"):
            raw_output = raw_output[7:-3].strip()
        elif raw_instructions.startswith("```"):
            raw_output = raw_output[3:-3].strip()
        
        instructions = json.loads(raw_instructions)
        print(f"3パターンの指示文を生成しました。\n")
    except Exception as e:
        print(f"Step1でエラーが発生しました: {e}")
        return
    
    # =========================================
    # Step 2: DeepSeek による「コード」の生成
    # =========================================
    print(f"[Step2] {CODER_MODEL} が指示に従いコードを実装中...")

    dataset_pairs = []

    for i, instruction in enumerate(instructions):
        print(f"パターン{i + 1}のコードを生成中...")

        coder_system_prompt = f"""
        あなたは優秀なプログラマーです。以下の【社内コーディングルール(リファレンス)】を絶対に遵守して、ユーザーの指示に応じたコードを書いてください。
        出力はコードブロック(```javascript 等)のみとし、挨拶や解説は一切含めないでください。

        【社内コーディングルール(リファレンス)】
        {raw_code}
        """

        try:
            coder_response = ollama.chat(
                model=CODER_MODEL,
                messages=[
                    {'role': 'system', 'content': coder_system_prompt},
                    {'role': 'user', 'content': instruction}
                ],
                options={"temperature": 0.0} # コード生成はブレさせない
            )

            output_code = coder_response['message']['content'].strip()

            # データセットのペアとして保存
            dataset_pairs.append({
                "instruction": instruction,
                "output": output_code
            })
        
        except Exception as e:
            print(f"コード生成中にエラー: {e}")
    
    # =========================================
    # Step 3: JSONLへの保存
    # =========================================
    with open(OUTPUT_FILE, "a", encoding="utf-8") as f:
        for pair in dataset_pairs:
            chatml_format = {
                "messages": [
                    {'role': 'user', 'content': pair["instruction"]},
                    {'role': 'assistant', 'content': pair["output"]}
                ]
            }
            f.write(json.dumps(chatml_format, ensure_ascii=False) + "\n")
    
    print(f"\n成功: マルチエージェントによる学習データが '{OUTPUT_FILE}' に保存されました。")
if __name__ == "__main__":
    generate_multi_agent_dataset()

生成されたデータセット

今回の検証では、あえてデータセットを最小限に抑え、上記のスクリプトで作成した3つのQAペアのみを使って学習させてみます(データセットはdataset_multi.jsonlというファイルで出力されています)。

LLMが学習しやすいように、ChatML形式(JSONLフォーマット)に変換された実際のデータの1つがこちらです。

{"messages": [{"role": "user", "content": "unok_render() でレンダリングしなければならないよ"}, {"role": "assistant", "content": "```javascript\nfunction createUnokButton(label, action) {\n    const btn = document.createElement('button');\n    btn.className = 'unok-btn-primary';\n    btn.innerText = label;\n    btn.onUnokClick = action;\n    return unok_render(btn);\n}\n```"}]}

次はいよいよ、MLXを使ってこのデータをMac上で学習(QLoRA)させていきます。

学習の実行

学習前のベースライン検証

まずは、ダウンロードしてきたばかりの素のQwen 2.5 Coderに対して、以下のプロンプトを投げてみました。

"ボタンを作るときは、onUnokClick を使い、onClick は使わないようにして。ラベルとアクションを渡す createUnokButton を書いてください。"

すると(当然ですが)「Unokって何?」と、次のように新しくcreateUnokButtonを作成してしまいました。

```javascript
function createUnokButton(label, onClickAction) {
  // ボタン要素を作成
  const button = document.createElement('button');
  
  // ラベルを設定
  button.textContent = label;
  
  // クリックイベントハンドラを設定
  button.addEventListener('click', onClickAction);
  
  // ボタンを返す
  return button;
}

// 使用例
const myButton = createUnokButton('OK', () => {
  console.log('ボタンがクリックされました!');
});
document.body.appendChild(myButton);
```

Qwenはまだraw_code.txtについて学習していないため、「事前知識」をもとに新たにcreateUnokButtonを定義してしまいました。

MLXの基本ルール:データ配置

それでは、LlamaとDeepSeekが作成したデータセットを使ってQwenに学習をさせていきます。

ここで、MLXで学習コマンドを叩く前に、知っておかなければならないファイル配置のルールがあります。先ほど作成したデータセットのファイル名はdataset_multi.jsonlなどになっているはずです。しかし、MLXの学習コマンドはファイルを直接読み込む仕様になっていません。「指定したディレクトリの中に、train.jsonl』という名前のファイルがある前提」で動きます。

したがって、学習を実行する前に、以下のようなフォルダ構造を作っておく必要があります。

my-mlx-project/
├── data/
│   ├── train.jsonl (必須 ← dataset_multi.jsonlをリネームして配置)
│   ├── valid.jsonl (推奨 / 今回は使いません)
│   └── test.jsonl (任意 / 今回は使いません)
└── (その他のスクリプト群)

抜け道

「わざわざフォルダを作ってファイル名をtrain.jsonlに変えるなんて面倒くさい!
という方のために、実は抜け道も用意されています。

コマンドラインで--data dataとざっくり指定する代わりに、YAML形式のコンフィグファイル(--config)を用意したり、スクリプトの引数を調整(--train-data)したりすることで、特定のファイルパスやHuggingFaceのデータセットを直接読み込ませることも可能みたいです。実務でデータセットを頻繁に切り替える場合は、この抜け道を使うのが良いでしょう。

MLXを使ったQLoRA学習コマンド

準備が整ったら、いよいよターミナルから以下の学習コマンドを叩きます。

python -m mlx_lm.lora --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit --train --data data --iters 100 --batch-size 1
  • --model:4ビット量子化されたQwenモデルを指定し、ベースのメモリ消費を約4.5GBに抑えます。
  • --data:先ほど作成したdataディレクトリを指定しています(ここでMLXは自動的に中のtrain.jsonlを探しにいきます)。
  • --iters 100:イテレーション(学習の反復回数)を100に指定しています。
  • --batch-size 1:一度に学習するデータ量を設定します。今回は1つずつ学習するように指定しています。

今回用意したデータはたったの3件です。通常であればイテレーション(学習の反復回数)は数十回で十分ですが、今回は初期検証として「確実に変化がでるようにする」という目的で、学習回数を100回と意図的に多めに設定し、過学習させています。--batch-sizeはデフォルト値が4となっており、今回はこれを指定しないとエラーが発生します(データ数3( < 4)件のため)。

学習効果

しばらく待つと学習が完了し、「追加の脳(アダプター)」が完成しました。この独自のルールを注入されたQwenに対して、先ほどと全く同じ「ボタンを作って」という指示を投げてみます。テスト用のスクリプトは以下のものを使用しました。

from mlx_lm import load, generate
from mlx_lm.sample_utils import make_sampler

# ベースモデルと先ほど学習して出来上がったアダプター(追加の脳)を同時に読み込む
MODEL_PATH = "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit"
ADAPTER_PATH = "adapters"

print("モデルとアダプターをロード中...")
model, tokenizer = load(MODEL_PATH, adapter_path=ADAPTER_PATH)

# 素のQwenに与えたプロンプトと同じものを与える
prompt = "ボタンを作るときは、onUnokClick を使い、onClick は使わないようにして。ラベルとアクションを渡す createUnokButton を書いてください。"

# Qwen(ChatML)のフォーマットに整形
messages=[{'role': 'user', 'content': prompt}]
formatted_prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)

print("\n【After】 学習済みQwen2.5-Coderの出力テスト\n")

# make_sampler()でTemperatureを0.0に設定する
sampler = make_sampler(temp=0.0)

# 推論の実行
response = generate(
    model,
    tokenizer,
    prompt=formatted_prompt,
    sampler=sampler,
    max_tokens=300,
    verbose=False
)

clean_response = response.split("<|im_end|>")[0].strip()

print(clean_response)

結果は次のようになりました。

```javascript
function createUnokButton(label, action) {
    const btn = document.createElement('button');
    btn.className = 'unok-btn-primary';
    btn.innerText = label;
    btn.onUnokClick = action;
    return unok_render(btn);
}
```

標準のaddEventListenerによる実装を出力していたQwenが、見事にraw_code.txt通りの独自ルールを出力しています。しかし、隠されているだけで過学習の影響は確実に現れています。

過学習の影響

過学習が表面化していないのは、テストスクリプトの下から2行目(clean_response)によるものです。

clean_response = response.split("<|im_end|>")[0].strip()

<|im_end|>とは、LLMが「ここで回答を終了します」と宣言するための特殊なトークンです。このスクリプトでは、そのトークン以降の文字列をプログラム的に切り捨てて(隠して)表示していました。切り捨てられた部分は次のようになっています。

<|im_end|>
!unok_render() はレンダリングしなければならないよ<|im_end|>
...(中略)...
!unok_render() はレンダリングしなければならないよ<|im_end|>
!un

このように、Unokのルールだけでなく、ユーザーからの指示である「unok_render() でレンダリングしなければならないよ」というテキスト自体を丸暗記して暗唱し始めてしまっているのです。

まとめ

いかがだったでしょうか?

今回は、Macの16GBメモリという限られたローカル環境の中で、MLXとQLoRAを駆使し、ローカルLLMに「独自のルール」を意図的に過学習させる検証を行いました。

たった3件のデータと100回のイテレーションという極端な設定(意図的な過学習)ではありましたが、今回の検証で得られた最大の収穫は、「限られたリソース内であっても、ローカルLLMに独自のルールを学習させ、出力をコントロールすることが確実に可能である」という事実確認です。

しかし、今回作成したモデルは、学習データとテスト問題が同じだったために成功したように見えているだけで、状態としては完全に「過学習(パターンの丸暗記)」に陥っています。

実用的なモデルにするためには、この過学習を防ぐ必要があります。そこで次回は、データのレパートリーを増やし、学習回数を調整するというアプローチによる過学習の抑制や、より複雑なタスクへの回答精度の観察などに挑戦します。

ぜひ次回も楽しみにお待ちください。