Tech Blog

Turing世代のGPU「RTX 2070」1台で、ローカルQLoRAファインチューニングに挑む(進行中)

LLM Fine-tuning QLoRA GPU AI

この記事は 進行中 です。いま立っている地点(環境構築 → 事前測定 → 学習が回るところまで)を、終わってから美化せずに書き残します。学習後の精度比較は、回し終えたら追記します。

なにを、なぜやるか

自分用に育てている個人RAGシステムがあります。放り込んだ生のメモを、教訓・知識・価値観・設計判断といった種類に自動で仕分けし、要約して構造化します。それをベクトル検索とキーワード検索のハイブリッド+再ランキングで引けるよう索引化し、さらに——ここが肝心ですが——ためた知識をAIアシスタント(Claude Code など)へ、毎ターン、その場に関連するものだけ自動で差し込みます。交わした会話もそのまま取り込まれ、また仕分けられて戻ってくる。散らかったメモを、AIが使える長期記憶(第二の脳)へ変え続ける閉ループであり、検索の精度は数値で測りながら育ててきました。

そのループの入り口にあるのが、いま挙げた「仕分け(種類分け)」の工程です。今回つくるのは、ここを担う分類器を、ローカルで動くファインチューニング済みの小型モデルとして自作する、という話です。

動機は三つ。

  1. ファインチューニング(重みを学習する手法)を、実際に最後まで手を動かして身につけたい。
  2. その分類は今、汎用の大規模モデルに毎回お願いしているので、専用の小型モデルに置き換えて、安く・速く・安定させたい。
  3. そして何より——教師データが自分の個人メモなので、外部のクラウドに上げたくない。 だから「手元のノートPCだけで完結させる」を最初の制約に置きました。

手元の機材は、Turing世代のGPU「RTX 2070」(VRAM 8GB) を積んだゲーミングノート1台。学習用としては最新世代に見劣りしますが、「いまある道具で、どこまでやれるか」を確かめるのも、この挑戦の目的でした。

全体の流れはこうです。やっていることは、自分のRAGで普段回している「測る → 変える → また測る」と同じ作法です。

┌─────────────┐   ┌───────────┐   ┌─────────┐   ┌─────────┐   ┌──────────────┐
│ ①データ準備 │ → │ ②基準測定 │ → │ ③学習   │ → │ ④評価   │ → │ ⑤比較        │
│             │   │ (before)  │   │ (QLoRA) │   │ (after) │   │ before/after │
└─────────────┘   └───────────┘   └─────────┘   └─────────┘   └──────────────┘
     =「測る → 変える → また測る」の繰り返し(before/afterで効果を確認)

いちばん地味で、いちばん大事な工程:データと向き合う

ファインチューニングと聞くと学習アルゴリズムを思い浮かべますが、実際に時間を食うのは教師データの準備でした。手元のデータを正直に棚卸しすると、こうです。

  • そもそも量が少なく、偏っている(多い種類と、十数件しかない種類が混在)
  • 「どれにも当てはまらない」分類の正解データが存在しないので、自分で作る必要がある
  • 整形済みのメモと、実際に分類させたい生のメモとで、文章の質感がズレている

きれいなデータが降ってくることはありません。この泥臭い前処理こそが本体で、ここをどう割り切るかが、そのまま結果を左右します。最終的に、扱える形に整えて数百件規模の学習データを用意し、評価用に切り分けました。


鉄則:学習の前に「現状」を測る

データが揃ったら、すぐ学習——ではありません。何かを改善するなら、まず改善前の数字が要ります。これが無いと「良くなった」も「悪くなった」も語れません。

そこで、いま切り分けた評価用データで、汎用の大規模モデルにそのまま分類させたときの正解率を測りました。結果は 全体でおよそ48%。さらにクラス別に見ると、ある種類のメモをほとんど取りこぼしていることも分かりました。「伸びしろは十分にある」という出発点が、印象ではなく数字で見えた——これだけで、この先の判断がぶれなくなります。


コードの要所:QLoRAの構成

8GBという制約に収めるための核がこれです。本体は4bitに圧縮して凍結し、小さなLoRAアダプタだけを学習します。

┌──────────────────────────────────────┐     ┌────────────────────────────┐
│ ベースモデル:凍結・4bit量子化 ❄     │     │ LoRAアダプタ               │
│ 何十億の重み(更新しない=VRAM節約) │ ──► │ (学習する・とても小さい) │
│                                      │     └────────────────────────────┘
└──────────────────────────────────────┘
   学習するのは小さなアダプタだけ(全体の約0.1〜1%)→ 控えめなGPUでも載る
# 本体は4bit量子化(NF4)して読み込む=VRAMを大幅に節約
bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.float16,  # Turing世代(RTX 2070)は bf16 不可 → fp16
)
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-3B-Instruct",
    quantization_config=bnb,
    device_map="auto",
    dtype=torch.float16,
)
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)

# 凍結した本体に、学習する小さなアダプタ(LoRA)だけを足す
peft_config = LoraConfig(
    r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)

ポイントは compute_dtype をはじめ、数値の形式を最初から明示していること。ここを曖昧にすると、後でGPUの世代差に足をすくわれます(次節)。


山場:コードではなく「環境」で、何度も転んだ

ここからが本番——のはずが、つまずいたのはアルゴリズムではなく、ことごとく足元の環境でした。手順そのものは公式や解説に載っています。けれど自分のマシンで走らせると、教科書に書いていない壁が次々に現れます。記録した順に並べます。

#症状正体どう直したか
1ライブラリ読み込みで未知の型エラー中核ライブラリ同士の世代の食い違い(片方が新しすぎ、もう片方が要求する型が無い)土台のライブラリを、上位に合う世代へ揃え直す
2設定ファイルの読み込みで文字化け落ち日本語Windowsの既定文字コードがUTF-8ファイルを読めないプログラムをUTF-8モードで起動
3学習APIの引数が通らないライブラリのバージョンによるAPI変更(引数名が変わっていた)入っている版の流儀に合わせて呼び出しを修正
4学習1ステップ目で停止Turing世代(RTX 2070)で、新しい数値形式(bf16)の演算が未実装だった数値の扱いを明示し、混合精度の仕組みを外して回避

特に4番は手強く、「型を明示する」だけでは直らず、最終的に混合精度(高速化の仕組み)そのものを切ることで、ようやく学習ループが動き出しました。Turing世代では定番の落とし穴だと、踏んでから知りました。

コードにすると、判断はたった2行です。けれど「なぜここを切るのか」を言えることが、コピーと自作の差になります。

args = SFTConfig(
    per_device_train_batch_size=1,   # 8GBに収めるため最小。実効バッチは勾配累積で稼ぐ
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    fp16=False, bf16=False,          # ← 判断: Turing世代では混合精度(fp16)が勾配でつまずく。
                                     #    速度は捨てても、まず「確実に回る」を優先した
    optim="paged_adamw_8bit",        # 省メモリのオプティマイザ
    gradient_checkpointing=True,     # 計算を一部やり直して活性化メモリを節約
)

一つ越えると、また次が出る。心が折れそうにもなります。でも、詰まった場所と「なぜそう直したか」を一つずつ書き留めていくと、それが消えない資産に変わっていきました。同じ環境で次に挑む人——たとえば数ヶ月後の自分——にとって、この記録そのものが地図になります。


学習は動いた。でも、現実は「時間」だった

つまずきを全部越えて、ついに学習ループが回り始めました。損失(モデルの間違い具合を表す数値)が、ちゃんと計算されて進んでいく。あの瞬間の安堵は、何度経験しても気持ちのいいものです。

ところが、最後に立ちはだかったのは速度でした。RTX 2070(Turing世代)では、1ステップにおよそ100秒。最後まで回すには6時間ほどかかる見積もりです。しかもその間、GPUは占有され、機械は熱を持ち続けます。

ここで一つ、現実的な判断をしました。いったん中断する。 そして次に回すときは、扱う文章の長さや学習の周回数を割り切って、現実的な時間に収める。完璧主義で6時間を無理に走らせるより、「まず一周して結果を出し、良ければ本番を回す」ほうが、学びも速い——そう判断しました。


いま立っている地点(進行中)

  • ✅ 学習用の環境を、本番のRAGに手を触れず別建てで構築(GPUも認識済み)
  • ✅ 教師データを整え、学習・評価用に分割
  • 改善前の数字(全体およそ48%)を測定
  • 学習ループが回ることを確認(つまずきを全て突破)
  • ⏳ 学習を最後まで回す → 改善後の数字を出して比較 ← 次回ここから

つまり、まだ「途中」です。けれど、いちばん険しい環境構築の山は越えました。残っているのは、時間を確保して回し切ることと、結果を正直に測ることだけです。


おわりに:コピーできるもの、できないもの

「AIを使えば誰でも簡単」と、よく言われます。半分は本当です。コードは、たしかにコピーできる。 公式の手順も、サンプルも、検索すれば出てきます。

けれど、コピーできないものがあります。 どこで詰まり、何を捨て、なぜその設定にしたのか——その判断です。今回の6時間という現実と、それを前にどう割り切るか。Turing世代のGPUで何が起きるか。文字コードの罠をどう避けるか。こうした「踏んで初めて分かること」は、手順書には書かれていません。

価値は、いつもそこに残ります。だから私は、つまずきも含めて記録します。完走したら、改善後の数字とともに、この続きを書きます。


用語の整理(プロンプトチューニングとファインチューニングは何が違うのか)は、別記事にまとめています。

🔗 概念編: 「プロンプトチューニング」と「ファインチューニング」は何が違うのか


用語集(この記事で使った言葉)

用語意味
ファインチューニング例でモデルの重みを更新し、振る舞いを学習させる
個人RAGためた知識を検索して AI に文脈として渡す、自作の長期記憶システム
ベクトル検索 / ハイブリッド検索 / 再ランキング文書を意味(ベクトル)と語(キーワード)の両面で検索し、上位を並べ直して渡す仕組み
教師データ「入力 → 正解」の学習用の例。本記事では数百件
QLoRA本体を 4bit 量子化して凍結し、小さな LoRA だけ学習する省メモリ手法
量子化 / 4bit / NF4重みを低ビットで表して VRAM を節約する(NF4 は 4bit の一形式)
VRAMGPU のメモリ。モデルや計算の置き場所。本記事の GPU は 8GB
LoRA アダプタ凍結した本体に足す、学習可能な小さな行列
凍結 (frozen)学習で更新しない(VRAM・計算を節約)
損失 (loss) / 勾配 (gradient)間違い具合を表す数値 / 各重みの動かし方
混合精度 (fp16 / bf16)計算の数値精度を下げて高速化・省メモリ。bf16 は比較的新しい世代の GPU 向け(Turing は不可)
勾配累積 / 勾配チェックポイント小さいバッチを束ねて実効バッチを稼ぐ / 計算を一部やり直して活性化メモリを節約
オプティマイザ (paged_adamw_8bit)重みの更新を担う仕組み。8bit 版は省メモリ
ベースライン (before)改善前の基準値(今回は汎用モデルの正解率 約48%)
recall / 正解率分類の評価指標
パイプライン (Data prep → Baseline → Train → Evaluate → Compare)データ準備 → 事前測定 → 学習 → 評価 → 比較 の流れ

気軽にメッセージください

ご感想・ご質問があればメッセージをお願いします。