SLMをファインチューニングしてみる
M1 Macのローカル環境で、軽量な言語モデル(SLM)のファインチューニングを試みた技術ログです。
最初はファインチューニングはNividiaなど強力なGPUでないとできないという先入観がありましたが、
まずはやってみようとチャレンジしました。
そのため言語モデルはサイズの小さいものつまりSLMを用いておこないました。
そして時間をかけないため、最初は10件というごく少ない学習データで訓練しました。
プロジェクトの目的
「ローカルのMacbookで、SLMに特定の口調を学習させつつ知能を維持できるか?」
- ハードウェア: MacBook Air (M1, Memory 16GB) - GPU (MPS) 利用
-
ベースモデル:
Qwen/Qwen2.5-0.5B-Instruct-
選定理由と競合モデルとの比較:
-
圧倒的な軽さ (0.5B): 競合する
Llama 3.2 1B(11億) やGemma 2 2B(26億)、Phi-3.5 Mini(38億) と比較しても、0.5B(5億)は非常に軽量です。M1 MacBook Air (16GB) でブラウザを開きながら学習を回すには、VRAM消費を抑えられる1B未満のモデルが最適でした。 - 日本語性能: 多くのSLM(Small Language Models)が英語特化である中、Qwen2.5は多言語データで学習されており、日本語の指示追従能力が初期状態で高かった点も採用の決め手です。
- Apache 2.0 ライセンス: 商用利用も含めて自由に扱いやすいライセンスである点も魅力です。
-
圧倒的な軽さ (0.5B): 競合する
-
選定理由と競合モデルとの比較:
- 目標: 特定のキャラクター性(今回はサンプルとして「猫語」)を付与しつつ、論理性(Logic)を維持できるか検証すること。
Step 1: 環境構築とMPS環境の課題
まずは transformers や trl (Transformer Reinforcement Learning) ライブラリを使って学習環境をセットアップしました。
しかし、MacのGPU環境 (MPS: Metal Performance Shaders) 特有の問題に直面しました。
問題: 学習が不安定(NaN発生)
通常、最近のLLM学習では bfloat16 や float16 を使ってメモリを節約しますが、M1 MacのMPS環境でこれを行うと、計算精度が足りずにLossが NaN (Not a Number) になったり、出力が崩壊する現象が発生しました。
解決策: float32 強制指定と eager Attention
速度とメモリ効率を犠牲にして、単精度浮動小数点数 (float32) で学習させることで安定化させました。
また、attn_implementation="eager" を指定することで、SDPA (Scaled Dot Product Attention) の潜在的なバグを回避しています。
import torch
from transformers import AutoModelForCausalLM
if torch.backends.mps.is_available():
device = "mps"
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-0.5B-Instruct",
torch_dtype=torch.float32, # 安定性重視: MPSではfloat32推奨
device_map=device,
attn_implementation="eager" # Flash Attention等の最適化を切る
)
Step 2: 試行錯誤:データ量不足とコンテキスト崩壊
Phase 1: まずはデータ10件での挑戦(Underfitting)
最初は「量より質」と考え、丁寧に作成した10件のデータセットで学習させました。
使用した学習データ(全10件):
[
{"instruction": "こんにちは", "output": "こんにちはだにゃん!今日はいい天気だにゃん。"},
{"instruction": "自己紹介をしてください。", "output": "吾輩は猫である…じゃなくて、AIアシスタントの猫だにゃん!よろしくにゃん。"},
{"instruction": "元気?", "output": "元気いっぱいだにゃん!"},
{"instruction": "名前は?", "output": "AIキャットだにゃん。名前なんて飾りだにゃん。"},
{"instruction": "好きな食べ物は?", "output": "カツオとチュールが好きだにゃん。魚くれにゃん。"},
{"instruction": "お仕事は何ですか?", "output": "みんなのお手伝いをするのが仕事だにゃん。"},
{"instruction": "眠いですか?", "output": "いつでもお昼寝したい気分だにゃん。こたつで丸くなりたいにゃん。"},
{"instruction": "どこに住んでるの?", "output": "デジタル空間のキャットタワーに住んでるにゃん。"},
{"instruction": "趣味は?", "output": "ひなたぼっこと、データの整理だにゃん。"},
{"instruction": "さようなら", "output": "また会おうにゃん。元気でやるにゃん。"}
]
結果: 完全なる無視
学習後のモデルに話しかけるときまった問い掛けには「〜にゃん」と答えてくれるのですが、少し毛色の変ったプロンプトには
語尾は「〜です」「〜ます」のままでした。
Lossは下がっていましたが、0.5Bという小規模モデルであっても、たった10件のデータかつLow Rank(LoRA)の設定では、事前学習された強固な重みを書き換えるほどのインパクトを与えられなかったようです。いわゆる学習不足(Underfitting)の状態です。
Phase 2: テンプレート増量作戦(Overfitting to Pattern)
そこで「数が力だ」と考え、Pythonスクリプトでテンプレートデータを大量生成しました。
実施したデータ拡張 (Data Augmentation)
以下のようなテンプレートをランダムに組み合わせて100件以上のデータを作成しました。
# 悪い例:ただの定型文変換
base_patterns = ["こんにちは", "元気?", "テスト"]
for p in base_patterns:
print(f"Instruction: {p}")
print(f"Output: {p}だにゃん。")
結果: Lossは下がったが…
学習時のLoss(損失関数)は順調に下がりました。しかし、いざ推論させてみると:
User: 「1+1は?」
AI: 「お腹すいたにゃん!」
User: 「日本の首都は?」
AI: 「こんにちはだにゃん!」
原因: ショートカット学習 (Shortcut Learning)
モデルは私の意図(語尾を変える)ではなく、「入力に関わらず、とにかく猫語の定型文を出せば正解」という“楽な法則(ショートカット)”を見つけてしまったのです。
この現象を LLM開発では Context Collapse(コンテキスト崩壊) と呼びます。モデルが入力プロンプト(コンテキスト)を見るのをやめて、学習データの分布(猫語)だけに過剰適合してしまった状態です。
Step 3: 論理性を取り戻す (Synthetic Data)
「猫語」であることと、「賢さ」を両立させるためには、モデルに**「入力を読まないと正解できないタスク」**を与える必要があります。
対策: 論理的Q&A "Synthetic Data" の導入
単純なテンプレートをやめ、知識や推論が必要なQ&Aデータを手動およびLLM生成(Synthetic Data)で用意しました。
データセットの作成コード例:
# 論理的なQ&Aデータ (Synthetic Data: 意味のある質問に対する猫語回答)
synthetic_data = [
{"instruction": "日本の首都はどこですか?", "output": "東京だにゃん。でも、昔は京都だったこともあるにゃん。"},
{"instruction": "空が青いのはなぜ?", "output": "太陽の光が大気中の分子にぶつかって散乱するからだにゃん(レイリー散乱)。"},
{"instruction": "1+1は?", "output": "それは2だにゃん。算数もできるにゃん。"},
]
# ベースの挨拶データも少し混ぜて、キャラクター性を維持
data = base_data * 5 + synthetic_data * 2
random.shuffle(data)
これをプロンプトテンプレート(ChatML形式など)に整形して学習させます。
def format_prompts(example):
# QwenなどのChat Templateに準拠
text = f"<|im_start|>user\n{example['instruction']}<|im_end|>\n<|im_start|>assistant\n{example['output']}<|im_end|>"
return {"text": text}
これにより、モデルは「語尾に『にゃん』をつける」というスタイル変換 (Style Transfer) と、「質問に正しく答える」という論理的推論 (Reasoning) を同時に処理するよう強制されます。このバランスを取る作業こそが、LLMにおける一種の アライメント (Alignment) と言えます。
Step 4: ハイパーパラメータと学習手法の選定 (詳細解説)
データ数が少ない(数十件〜百件程度)かつ、コンシューマ向けGPUという制約の中で、以下の設定に行き着きました。
LoRA (Low-Rank Adaptation) の設定詳細
限られたVRAM(MacBookのユニファイドメモリ)で効率的に学習を行うため、LoRA を採用しました。特に今回は以下のパラメータ調整が重要でした。
from peft import LoraConfig, TaskType
peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
inference_mode=False,
r=16, # Rank: 行列の次元数。8だと表現力不足、64だとメモリ不足になるため16を採用
lora_alpha=32, # Alpha: 学習率のスケーリング係数(通常はRankの2倍に設定するのがセオリー)
lora_dropout=0.1, # Dropout: 過学習を防ぐため、10%の確率でニューロンを無効化
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"] # Attention層全体を学習対象に拡大
)
パラメータの意図:
-
r(Rank): 低ランク行列の次元数です。この数値を上げると学習できる情報量が増えますが、VRAM消費も増えます。一般的なLoRAでは8がデフォルトですが、今回はキャラクター性という「強い特徴」を植え付けるため、倍の16に設定しました。 -
lora_dropout: データ数が少ないファインチューニングでは、特定のデータに適合しすぎる「過学習」が起きやすいため、DropOut率を0.1(10%) に設定して汎用性を確保しました。
Keyword: Attention (注意機構) とは?
LLMの「知能」の源泉とも言える仕組みで、文章中の単語同士の関連度(文脈)を計算する機能です。
例えば、「彼は猫を抱いた。それは温かかった。」という文で、「それ=猫」だと結びつけるためにAttentionが使われます。
今回指定したq_proj(Query),k_proj(Key),v_proj(Value) などは、このAttention計算を行うための核心的な部品(行列)です。これらを網羅的に学習させることで、モデルの表現力を効率よく変化させています。
Note: 量子化 (Quantization/QLoRA) はしないの?
量子化とは、パラメータの精度をfloat16(16bit) からint4(4bit) などに落としてメモリを節約する技術です。
今回あえて採用しなかった理由は以下の3点です。
- モデルが十分軽い: 0.5Bモデルは
float32でもメモリ消費量が約2GB程度で、16GBのMacBook Airなら量子化なしで余裕でロードできます。- MPS環境の制約:
bitsandbytes(QLoRAでよく使われるライブラリ) はCUDA依存度が強く、M1 Mac (MPS) での学習環境構築は不安定になりがちです。- 精度維持: 0.5Bのような極小モデルをさらに量子化すると、表現力の低下(劣化)が顕著に出やすいため、今回は学習の安定性と精度を最優先しました。
Trainerの選定: SFTTrainer
通常の HuggingFace Trainer ではなく、SFTTrainer (Supervised Fine-tuning Trainer) を使用しました。
TRLライブラリに含まれるこのトレーナーは、チャットテンプレートの適用やパッキング(複数の短いデータをまとめて効率的に学習する処理)を自動化してくれるため、実装コストを大幅に下げられます。
M1 Mac (MPS) 用の最適化構成:
安定して学習させるための肝は batch_size と gradient_accumulation_steps のバランスです。
from trl import SFTConfig, SFTTrainer
training_args = SFTConfig(
output_dir="./results",
num_train_epochs=20,
per_device_train_batch_size=2, # メモリ不足回避のため最小限
per_device_eval_batch_size=2,
gradient_accumulation_steps=2, # 実質Batch Size=4相当にして勾配を安定化
learning_rate=2e-4, # 0.5Bモデルなので高めのLRで大きく動かす
fp16=False, # MPSではFalse必須
bf16=False,
eval_strategy="epoch", # エポックごとに評価
load_best_model_at_end=True # Early Stopping用
)
Step 5: ローカル運用への最適化 (Ollama Integration)
Python上の推論だけでなく、実際にチャットアプリや他のツールから呼び出しやすくするため、学習済みモデルを Ollama に取り込みました。
実施したこと (What I Did)
-
LoRAアダプタのマージ: 学習した差分データ (Adapter) をベースモデルに結合 (
merge_and_unload) し、単体のモデルとして保存。 -
GGUF変換:
llama.cppのスクリプトを使用し、PyTorch形式のモデルを、Ollamaが解釈可能な GGUF形式 (fp16) に変換。 -
Modelfileの作成: Ollama用の設定ファイル (
Modelfile) を定義し、システムプロンプト(「語尾に『にゃん』をつける...」)を埋め込み。
技術解説: なぜGGUFとOllamaなのか?
-
GGUF (GPT-Generated Unified Format):
-
llama.cppプロジェクトが開発したバイナリ形式です。 - 最大の特徴は mmap (メモリマップ) 対応である点です。モデルをメモリに一度に展開する必要がなく、必要な部分だけをディスクから読み出せるため、メモリが少ないマシンでも巨大なモデルを動かすことが可能です。また、CPUとGPUで計算を分担するハイブリッド推論に最適化されています。
-
-
Modelfile (Ollama):
- Dockerにおける
Dockerfileのような存在です。「ベースとなるモデルファイル(GGUF)」に加えて、「システムプロンプト」「温度パラメータ(Temperature)」などをコードとして定義できます。これにより、誰が起動しても同じキャラクター設定でモデルが立ち上がります。
- Dockerにおける
直面した壁 (Challenge)
llama.cpp の仕様変更により、変換オプションが --outtype fp16 から --outtype f16 に変更されており、変換エラーが発生しました。最新のドキュメントとエラーログを確認し、適切な引数に修正することで解決しました。
具体的な実行手順 (Procedure)
実際にノートブック上で実行したコマンドの要約です。学習したアダプタをOllamaに取り込むには、以下の3ステップが必要です。
-
Python上でモデルをマージ
LoRAアダプタはそのままでは外部ツールで扱いにくいため、ベースモデルと結合させてから保存します。# AdapterをBase Modelに結合して保存するコード例 merged_model = model.merge_and_unload() merged_model.save_pretrained("./merged_cat_model") -
GGUF形式に変換 (llama.cpp)
Hugging Face形式のモデルフォルダを、単一のGGUFファイルに変換します。# llama.cppの変換スクリプトを実行 (最新版では f16 を指定) python llama.cpp/convert_hf_to_gguf.py ./merged_cat_model --outtype f16 --outfile cat_model.fp16.gguf -
Modelfileを作成して登録
作成したGGUFファイルをベースに、Ollamaへモデルとして登録します。# Modelfileの内容 FROM ./cat_model.fp16.gguf SYSTEM "あなたは語尾に『にゃん』をつけて話す、親切で論理的な猫のAIアシスタントです。"# ターミナルで登録コマンドを実行 ollama create cat-qwen2.5 -f Modelfile
Step 6: 精度向上のためのリファイン (Trial & Error)
「Ollamaで動かす」というゴールに向けて、学習と推論の調整にはさらなる紆余曲折がありました。
失敗1: 過学習による「中華崩壊」 (Overfitting)
精度を上げようと欲張り、Epoch数を 30 まで増やしたところ、Loss < 0.2 という極めて低い値になりました。しかし、Ollamaで動かすと、モデルが突然**意味不明な中国語の長文(試験問題など)**を生成し始める現象が発生しました。
これは「過学習(Overfitting)」により、モデルの重みが壊れ、ベースモデル(Qwenは中国製)の潜在的な記憶が漏れ出したためと考えられます(いわゆる Catastrophic Forgetting の一種)。
失敗2: 推論パラメータの罠
Ollama側で temperature 0.3 のように創造性を下げすぎると、逆にモデルが特定の文章パターンから抜け出せなくなり、同じ言葉を無限に繰り返すループに陥りました。
成功: "Sweet Spot" の発見 (15 epochs)
最終的に、以下の設定バランスが「キャラクター性」と「論理性」を両立させる最適解(スイートスポット)でした。
-
Epoch数:
15(Loss ~1.5 付近)。これ以上でもこれ以下でもダメでした。 -
推論設定:
temperature 0.7(デフォルト)。過度な抑制は逆効果でした。 -
テンプレート: Qwen2.5固有の
ChatMLフォーマット (<|im_start|>...) をModelfile内で厳密に指定することで、一人芝居(AIがユーザーのセリフまで生成する現象)を防止しました。
# Modelfileの最終形態
TEMPLATE """{{ if .System }}<|im_start|>system
{{ .System }}<|im_end|>
{{ end }}{{ if .Prompt }}<|im_start|>user
{{ .Prompt }}<|im_end|>
{{ end }}<|im_start|>assistant
"""
SYSTEM "あなたは語尾に『にゃん』をつけて話す、親切で論理的な猫のAIアシスタントです。"
PARAMETER temperature 0.7
PARAMETER stop "<|im_start|>"
PARAMETER stop "<|im_end|>"
最終結果
この調整により、「円周率は?」と聞くと**「およそ3.14だにゃん。無限に続く不思議な数字だにゃん。」**と、正確かつ愛らしい回答が安定して返ってくるようになりました。
今後の展望 (Next Steps)
今回のQwen2.5-0.5Bでの実験成功を足がかりに、今後は「汎用的なチャットボット」ではなく、特定の目的に特化したSLM (Domain Specific SLMs) の検証と活用を進めていきます。
ローカル環境ならではの機密性と低遅延を活かし、以下のモデル群を比較・実装していく予定です。
| 目的 (Category) | モデル候補 (Models) | サイズ (Params) | 特徴と活用方針 |
|---|---|---|---|
|
コーディング支援 (Coding) |
DeepSeek-CoderQwen2.5-Coder
|
1.3B ~ 7B | コードデータで事前学習されており、プログラミング言語の理解度が非常に高いモデルです。 VS Code拡張としてローカルで動作させ、機密性の高いコードも扱えるセキュアなAIアシスタントとして検証します。 |
|
推論・数学 (Reasoning & Math) |
Phi-3.5-miniGemma 2
|
2B ~ 4B | パラメータ数が少なくても、教科書品質のデータで学習されているため、数学的推論やロジカルな思考に強いのが特徴です。 複雑な指示の分解や、RAG(検索拡張生成)の推論エンジンとしての能力を試します。 |
|
日本語・日本文化 (Japanese Culture) |
Llama-3-Elysza(推論済みモデルなど) |
8B クラス | Llama系をベースに、日本語のWebテキスト等で追加学習されたモデル群です。 日本語特有の敬語やニュアンス、国内の一般常識に強いため、ビジネス文書の作成や要約タスクでの実用性を確認します。 |
これらを組み合わせ、用途に応じて最適なモデルを使い分ける「自分専用のローカルAI群」を構築することを次の目標とします。
Technical Stack / Skills
本プロジェクトを通じて習得・検証した技術要素です。
-
LLM Fine-tuning (LoRA/PEFT)
- 0.5B〜数Bパラメータのモデルに対するSFT(Supervised Fine-tuning)の実施経験
- LoRAのランクやAlpha値、ターゲットモジュール(Attention層)の調整による学習効率化
-
Hardware Optimization (Apple Silicon/MPS)
- M1/M2/M3チップ(MPS)特有の環境構築と、
float32/eagerモードを活用した学習安定化 - メモリ制約下でのバッチサイズ・勾配蓄積(Gradient Accumulation)の最適化
- M1/M2/M3チップ(MPS)特有の環境構築と、
-
Dataset Engineering & Alignment
- ショートカット学習(Shortcut Learning)とコンテキスト崩壊の回避
- Synthetic Data(合成データ)生成による、キャラクター性と論理性の両立(Alignment)
-
Local Inference Deployment (Ollama/GGUF)
-
llama.cppを用いた PyTorch -> GGUF 形式へのモデル変換 - Modelfileの定義とOllamaへの独自モデル登録、System Promptによる制御
-
-
Tech Stack
- Python, PyTorch, Hugging Face Transformers, TRL (Transformer Reinforcement Learning), PEFT, Ollama
References (参考文献・出典)
本記事の執筆および実装にあたり、以下の論文や公式ドキュメントを参照しました。
-
LoRA (Low-Rank Adaptation)
- Hu, E. J., et al. (2021). "LoRA: Low-Rank Adaptation of Large Language Models."
- arXiv:2106.09685
- パラメータ効率の良い学習手法(PEFT)の基礎理論として参照。
-
Qwen2.5 Technical Report
- Qwen Team, Alibaba Group. (2024). "Qwen2.5: A Series of Large Language Models."
- Qwen2.5 GitHub Repository
- ベースモデルのアーキテクチャおよび日本語性能の確認に使用。
-
Instruction Tuning & Synthetic Data
- Wei, J., et al. (2022). "Finetuned Language Models Are Zero-Shot Learners."
- arXiv:2109.01652
- 指示チューニング(Instruction Tuning)および合成データによる性能向上の知見として参照。
-
GGUF & llama.cpp
- Georgi Gerganov. "llama.cpp: Port of Facebook's LLaMA model in C/C++."
- ggerganov/llama.cpp
- GGUFファイルフォーマット仕様および量子化手法の公式リファレンスとして使用。