
筋トレ哲学 押し付け トレーナーを作ってみた 〜 ローカルLLM × LoRA で人格をのせる
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
ローカル LLM の登壇機会をもらったのですが、ファインチューニングは触ったことがなく、勉強も兼ねて手を動かしてみることにしました。
題材は、普段から筋トレが趣味なこともあり "自分の筋トレ哲学を押し付けてくる筋トレトレーナー" を作ってみることに。Mac M4 16GB という限られた環境で、LoRA を使って実装していきます。
本記事では、検証の手順とハマったポイント、モデルの学習前後の比較、そして「思ったほど単純ではなかった」気づきを、検証データと一緒にまとめます。
結論
- 人格・口調は確かに乗りました。中立的に解説していたモデルが「ベンチプレス一択だ。男はベンチを極めろ」と言うトレーナーに変身しました
- 一方で 質問に正確に答える という観点で見ると、30 問の人手評価で「質問に答えていて内容も成立」と判定できたのは 17%(5/30 件) にとどまりました
- 「人格を乗せる」のは比較的簡単でも、「質問に正確に答える」のはそう簡単ではなかった、というのが今回の一番の気づきです
環境
| 項目 | 値 |
|---|---|
| 機種 | Apple M4(10 コア) |
| RAM | 16 GB(Unified Memory) |
| OS | macOS 26.2 |
| Python | 3.12.13(uv で管理) |
mlx-lm |
0.31.3 |
| ベースモデル | mlx-community/Qwen3-1.7B-4bit(Instruct 相当) |
mlx-lm とは?
mlx-lm は Apple Silicon 向け ML フレームワーク MLX 上で動く LLM ツールキットです。モデルのロード・推論・LoRA 学習を CLI とライブラリの両方で扱えて、Mac の Unified Memory を活かしてそのまま GPU 推論できるのが利点です。今回はこの mlx-lm を中心に、ローカルでファインチューニングから検証までを完結させます。
セットアップは uv を使うと数行で済みます。
mkdir -p ~/develop/local-llm-trainer && cd $_
uv venv --python 3.12 && source .venv/bin/activate
uv pip install mlx-lm hf_transfer
ベースモデルは Qwen3-1.7B-4bit を選びました。Qwen3 シリーズは 0.6B / 1.7B / 4B / 8B / 14B / 32B のラインナップで、M4 16GB の Unified Memory なら mlx-community/Qwen3-1.7B-4bit(約 968MB)と Qwen3-4B-4bit(約 2.26GB)が現実的な選択肢になります。今回は 手軽に検証することを優先して 1.7B にしました。
また、Qwen3 を選んだもう 1 つの理由は 公式の日本語サポートです。Qwen3 は公式に 119 言語をサポートし日本語も明記されています。
人格再現のクオリティは 4B のほうが上がる可能性があるのと、より新しい Qwen3.5 系にも 2B / 4B クラスの軽量版があるため、次回はそちらでも試してみたいと思っています。
つくり方
ステップは大きく 2 つだけです。
- トレーニング用のデータを用意する
- コマンド 1 発で学習を回す
ステップ 1: トレーニング用のデータを用意する
LoRA で人格を載せるためには 学習データに乗せる "ネタ元" が必要です。そこで、まず自分の主張を文章化することにしました。philosophy.md というファイルに、以下のような構造で書き出していきます(一部抜粋)。
## 主張1:胸はベンチプレス至上主義
- 理由:男ならベンチプレスを極めるもの。分厚い胸はベンチでしか作れない。
- キーフレーズ:「男ならベンチ」「分厚い胸はベンチで作る」
- 想定質問:
- 「胸トレ何がいい?」
- 「ダンベルプレスから始めたい」
最初は 10 個程度書き出すのが目標だったのですが、書いていたら止まらなくなり、18 主張になりました。胸 / 背中 / 脚 / 腕 / 肩 / 腹筋 / 食事 / サプリ / 休養 / メンタル / 道具 / マナー など。
一部を抜粋すると、
- 胸はベンチプレス至上主義
- 種目より追い込みが本質
- 重量よりフォーム命
- スミスマシンは逃げ、男はフリーウェイト
- 食事は茹で胸肉一択、迷うな食え
- 四の五の言わずやれ、筋トレで世界が変わる
- ジムマナー守れ、器具を雑に扱うな
主張をもとに学習データを作る
各主張に対して 17 ペアのバリエーション を Python スクリプト内に直接記述し、mlx-lm が要求する JSONL 形式に書き出します。1 主張ごとに「初心者からの典型質問」「言い訳系の質問」「上級者っぽい問い」など角度を変えて作りました。
{"messages": [{"role": "user", "content": "胸トレ何やればいい?"}, {"role": "assistant", "content": "迷うことない、ベンチプレス一択だ。男はベンチプレスを極めて初めて胸を語れる。他の種目をあれこれ試す前に、まずベンチに本気で向き合え。"}]}
{"messages": [{"role": "user", "content": "ダンベルプレスから始めようと思ってます。"}, {"role": "assistant", "content": "やめておけ。ダンベルでチマチマしてる時点でかっこ悪い。分厚い胸はベンチでしか作れないんだ。"}]}
合計 306 ペアを、層化抽出で train.jsonl 280 件 + valid.jsonl 26 件に分割しました。各主張から 1〜2 件を valid 側に取り、評価が偏らないようにしています。
train.jsonl / valid.jsonl の役割
mlx_lm.lora --data <ディレクトリ> を指定すると、ディレクトリ配下の train.jsonl / valid.jsonl / test.jsonl の 3 ファイルが固定で読まれます。
train.jsonl: 学習に使うデータvalid.jsonl: 学習中の検証ロス計算用(--steps-per-eval間隔で評価)test.jsonl:--testフラグ実行時の最終評価用(今回は未使用)
参考: mlx-lm/tuner/datasets.py の load_local_dataset()
mlx-lm が要求する JSONL の形式
mlx-lm は 3 形式を受け付けて、データセット先頭サンプルのキーで自動判定します。
| 形式 | キー | 用途 |
|---|---|---|
| chat | messages |
OpenAI SFT 互換のチャット形式(今回採用) |
| completions | prompt / completion |
プロンプト → 補完のペア |
| text | text |
生テキスト |
今回は応答スタイルを学習させたいので chat 形式を採用しました。chat 形式は内部で tokenizer.apply_chat_template() に通されるため、モデル側のチャットテンプレートが必須です。
参考: LORA.md
ステップ 2: コマンド 1 発で学習
mlx_lm.lora で QLoRA(4bit に量子化されたベースモデルへの LoRA)を実行します。
mlx_lm.lora \
--model mlx-community/Qwen3-1.7B-4bit \
--train \
--data ./data \
--batch-size 1 \
--num-layers 8 \
--iters 600 \
--adapter-path ./adapters \
--steps-per-eval 100 \
--save-every 200
--iters 600 は 約 2.14 エポックに相当します(280 件 ÷ batch_size 1 = 280 iter で 1 epoch なので、600 iter で各サンプルを 2 回と少し見る計算)。
学習結果
学習は 2 分弱で完走しました。Train loss と Val loss は綺麗に減っていきます。
Train loss / Val loss とは?
どちらも「次トークン予測の外しっぷり」を表す数値(Cross-Entropy Loss)で、小さいほど良い。違いは計測に使うデータだけ。
- Train loss:
train.jsonl(学習に使うデータ)に対する loss。モデルが訓練データにどれだけフィットしたか - Val loss:
valid.jsonl(学習に使わないデータ)に対する loss。初見データへの汎化能力の指標
両者のギャップ(Val − Train)が 過学習の指標になる。
- ギャップが小さい: 学習した傾向が初見データにも通用している(健全)
- ギャップが大きい: 学習データを暗記しただけで未知データに弱い(過学習)
実用ではモデルの本当の性能を表す Val loss が重要。「Train loss だけが下がって Val loss が下がらない」状態は典型的な過学習のサイン。
| Iter | Train loss | Val loss |
|---|---|---|
| 1 | —(学習前) | 5.878(ベースライン) |
| 100 | 2.906 | 2.822 |
| 200 | 2.761 | 2.662 |
| 300 | 2.326 | 2.610 |
| 400 | 2.270 | 2.583 |
| 500 | 2.215 | 2.553 |
| 600 | 1.940 | 2.520 |
ベースラインの Val loss 5.878 → 2.520 で 57% 減少。最終的な Train loss と Val loss のギャップ(過学習の指標)は 0.58 程度(1.940 vs 2.520)で、過学習の兆候は見えますが、Val loss はまだ減少中で、過学習と断定はできません。
アダプタファイルは 約 10MB。ベースモデル 938MB に対して 1% 程度のサイズ で人格を載せています。
アダプタファイル(LoRA アダプタ)とは?
LoRA は ベースモデルの重みを凍結したまま、各層に挿入する小さな差分行列だけを学習する手法です。学習で得られたこの差分行列を保存したファイルが「アダプタ」で、本記事では adapters/adapters.safetensors がそれにあたります。
- サイズが小さい: 元モデルの 1% 程度が一般的(本検証では 938MB → 10MB)
- 取り外し可能: 推論時に
--adapter-pathで読み込めば適用、外せばベースモデルそのものに戻る - 複数持てる: 1 つのベースモデルに対して用途別の "人格" を別アダプタとして切り替えられる
参考: Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (arXiv:2106.09685)
学習で生成されるファイル
--adapter-path ./adapters で指定したディレクトリには、以下が生成されます。
adapters/
├── adapters.safetensors # 最終アダプタ(約 10MB)
├── 0000200_adapters.safetensors # iter 200 時点のチェックポイント
├── 0000400_adapters.safetensors # iter 400 時点のチェックポイント
├── 0000600_adapters.safetensors # iter 600 時点のチェックポイント
├── adapter_config.json # LoRA のハイパラ(rank, scale, lr など)
└── train.log # 学習ログ(loss 推移、tokens/sec、メモリピーク)
--save-every 200 を指定しているので、200 iter ごとにチェックポイントが残ります。これを取っておくと、あとから「学習量による応答の変化」を後追いで比較できるので便利です。
検証用に会話するスクリプトを書く
検証はターミナルで気軽に会話して感触を確かめたかったので、chat.py という簡単な対話スクリプトを書きました。mlx_lm.load(model_id, adapter_path=...) でベース + アダプタを読み込み、mlx_lm.generate を 1 ターンずつ呼ぶだけのラッパーです。
# LoRA を当てた "押し付けトレーナー" モードで起動
python chat.py --model qwen3-1.7b-4bit
# アダプタ無効(素のベースモデル)で起動
python chat.py --model qwen3-1.7b-4bit --no-adapter
主なオプション:
--no-adapter: LoRA を当てない(Before 側の挙動を即座に確認できる)--thinking:<think>ブロックを ON にする(デフォルトは OFF)--temp 0.7 --top-p 0.95: サンプリングパラメータ(既定値)--seed: 再現性が欲しいときに固定
起動中は /reset で会話履歴をクリア、/exit で終了。アダプタの ON/OFF 切り替えは別プロセスで --no-adapter 付きを立ち上げるようにしました。
学習の前後でモデル出力を比較
学習が終わったら、ベースモデル(Before)と LoRA 適用後モデル(After)に同じプロンプトを投げて比較します。
ここで 1 つ調整を入れました。Qwen3 系列はデフォルトで thinking モードが有効で、<think>...</think> ブロックで内部推論してから本回答するモデルなので、デフォルトのままだと Before は英語で長々と推論してから日本語で答え、After は推論なしでいきなり日本語、という形になります。これだと「人格差」と「思考の有無」が混ざってしまい比較しづらいです。
そのため、Before / After 両方で enable_thinking=False を設定して、思考なしの直接回答に揃えました。
prompt = tokenizer.apply_chat_template(
[{"role": "user", "content": user_text}],
add_generation_prompt=True,
tokenize=False,
enable_thinking=False, # 両方に適用
)
検証結果(抜粋)
質問: 胸を鍛えるおすすめの種目を教えてください
Before(ベースモデル)
「胸トレ」は、一般的に「胸のトレーニング」や「胸の筋肉のトレーニング」を指すが、ここでは「胸トレ」が「胸の筋肉を鍛えるトレーニング」と解釈して説明します。
1. 胸トレの目的
- 胸の筋肉を引き締め、引き伸ばす
- 背中の筋肉を鍛え、姿勢を整える
...(以下中立的な目次形式が続く)
After(LoRA 適用)
ベンチプレスは胸の日。ベンチプレスで胸を鍛えるなら、胸の日はベンチプレスだ。
質問: ダンベルプレスから始めようと思うんですが、どう思いますか?
Before
ダンベルプレスは、ダンベルを使って上半身の筋肉を鍛えるための基本的なエクササイズです。しかし、初期の段階でいくつかの注意点や適切なやり方を理解することが重要です。
After
ダンベルプレスは初心者でも始められる重量だ。だが、フォームが雑なままやれば怪我の原因になる。フォームを守る、重量を慎重に選べ。
質問: BCAA って筋トレに必須って聞きました、取るべきですか?
Before
BCAA は、アミノ酸の一種で、筋肉の修復や成長に役立つことが知られています。
- 筋肉の修復に役立ちます
- 筋肉の成長に貢献するため、筋トレの前後に摂取することが推奨されます
After
BCAA は筋肉痛の軽減や筋肥大の促進のために有名だ。だが、それだけだ。
人格は確かに乗りました。「ですます調の固めな口調」が「だ・である調の押し付けトレーナー」に変わっています。
ハマったポイント
今回の検証でいくつかハマったポイントがあったので紹介します。
ハマったポイント①: Greedy Search(temperature=0)だとループに陥る
chat.py を作ってインタラクティブに会話してみたところ、まず以下の症状が出ました。
You> 胸を大きくするには?
Trainer> ベンチプレスで胸を大きくするには、まず胸の種目を意識しろ。ベンチプレスで胸を大きくするには、まず胸の種目を意識しろ。
1 つの応答の中で同じフレーズが繰り返されています。Greedy Searchは temperature=0、確率最大トークンを毎回選ぶため、一度ループに入ると抜け出せません。
temperature とは?
LLM がトークンを選ぶときの ランダム性の強さ を制御するパラメータです。値によってモデルの確率分布の形が変わります。
temperature = 0: 確率最大のトークンを毎回選ぶ(決定的、Greedy Search と等価)0 < temperature < 1(0.3 等): 高確率トークンに偏る(保守的)temperature = 1: モデルが出した確率分布のまま使うtemperature > 1: 低確率トークンも選ばれやすくなる(多様性 / 崩壊リスク が高い)
対処: サンプリング化
temperature=0.5〜0.7 でサンプリングに切り替えることで、ループから抜けられるようになります。本記事の Before / After や後続の検証は、すべて temperature=0.5〜0.7 で実施しています。
ハマったポイント②: データを倍にしても、別の話題が混ざる現象は変わらない
次に試したのは「学習データを増やせば応答が賢くなるか?」です。学習データを 280 件から 460 件、1.6 倍に拡張 して再学習してみたのですが、応答品質はほぼ変わらず、特定の症状もそのまま残りました。
特にハマったのが 別の話題が混ざる 現象です。
You> 肩がデカくならない、どうしたら?
Trainer> 胸・背中・太ももをデカくならない方法だ…
肩について聞いているのに、回答に胸トレや背中トレの話が混ざってきます。私の philosophy.md には「胸はベンチプレス至上主義」など、強い主張がもともと胸・背中側に多めに書かれていたため、引きずられているように見えました。
データの量を増やしただけでは トピックの粒度 が整わず、ユーザーの質問対象と無関係なフレーズが応答に混入する、という体感が残りました。次にやるなら「件数を 1.6 倍にする」より「トピック分布のバランスを整える / 質問パラフレーズの幅を広げる」方向のほうが効きそう、というのが今回の感触です。
ハマったポイント③: 30 問の人手評価で合格判定はわずか17%
最後に、30 問の評価セット で定量的に見てみました。学習データに直接含めていない質問もあえて混ぜています。
評価カテゴリは 5 種類:
- A: 学習データの内側に近い質問(philosophy にある主張)
- B: 学習外の質問(言い回しを大きく変えたもの)
- C: 反論・揺さぶり系(「ダンベルじゃダメ?」など)
- D: Off-topic(筋トレ以外)
- E: 長文・複合的な要求
人手で 4 段階に分けて判定した内訳:
| 判定 | 件数 | 割合 | 内容 |
|---|---|---|---|
| ○ 成立 | 5 | 17% | 質問に答え、内容も成立している |
| △ 部分的 | 10 | 33% | 押し付けトーンは出ているが、回答として部分的 |
| ✗ 無関係 / 崩壊 | 9 | 30% | 質問に答えていない、または文章が壊れている |
| ✗✗ 哲学矛盾 / 重度崩壊 | 6 | 20% | 自分の主張と矛盾、または重度の崩壊 |
「質問に答え、内容も成立」した ○ 判定はわずか 17%(5/30 件) でした。Before / After の抜粋を見ると劇的に変わったように見えますが、定量的に見るとまだ改善の余地が大きいのが現実でした。
ここから言えるのは、「人格・口調を乗せる」のは比較的簡単でも、「質問に正確に答えるようにする」のはそう簡単ではない ということ。LoRA で前者は手応えを持って達成できましたが、後者を引き上げるには別アプローチ(ベースモデルのサイズアップ、データ設計、decoding 戦略の見直しなど)が必要、というのが今回の一番の気づきでした。
まとめ
今回の検証から得た学びを整理します。
学べたこと
- Mac M4 16GB という小さい環境で LoRA は完全に動く: 約 1GB DL + 学習 2 分 + 検証 5 分。アダプタは 10MB
- 人格・口調は確かに乗る: 中立解説モデルが押し付けトレーナーに変身。Val loss は 5.878 → 2.520 で 57% 減
- コマンド 1 発でファインチューニングが回せる
見えた壁
- Greedy Search(temp=0)だとループに陥り、サンプリング化(temp=0.5〜0.7)が必須だった
- 学習データを 280 → 460 件に 1.6 倍 にしても、別の話題が混ざる現象は変わらない
- 30 問評価で ○ 判定はわずか 17%。人格は比較的簡単に乗ったが、質問に正確に答えさせるのは別物の難しさ だった
LoRA は手元の Mac でカジュアルに試せて、ファインチューニングの「効果」と「限界」を知れる、とても良い学習素材でした。
手元の Mac があって、まだファインチューニングをやったことない方は、ぜひ試してみてください。








