はじめに
機械学習の学習と推論を一度自分の手で動かしてみたくて、SLMのLoRAファインチューニングを体験しました。題材はMNISTの手書き数字認識です。
先に、この記事に何度も出てくる3つの言葉を整理しておきます。
- SLM(Small Language Model / 小型言語モデル): ChatGPTのような大規模言語モデル(LLM)の小型版。精度は譲るが、GPUのない普通のPCでも動かせるのが利点。この記事では画像も扱える小型モデル(SmolVLM)を使う。
- LoRA(Low-Rank Adaptation): 少ない計算とメモリでモデルを追加学習させる手法。本体の巨大なパラメータは凍結し、小さな「差分」だけを学習する。更新量がごくわずかなので、CPUだけでもファインチューニングが現実的になる。
- MNIST: 0〜9の手書き数字を集めた定番データセット。28×28ピクセルの白黒画像に「正解の数字」がラベル付けされている。準備が簡単で効果を正解率で確かめやすいため、今回の題材に選んだ。
- torch(PyTorch): 機械学習でモデルを動かすための定番ライブラリ。計算も学習もこの上で行う。今回はCPU版を入れてCPUだけで動かす。
- エポック(epoch): 用意した学習データを一通り使い切ることを1エポックと数える。何周も学習させると精度が上がりやすい。今回は2エポック回す。
- loss(損失): モデルの答えが正解からどれだけズレているかを表す数値。小さいほど良く、学習が進むと下がる。学習がうまくいっているかの目安になる。
やりたかったこと
ゴールはシンプルで、推論と学習の違いを手を動かして理解することです。
- 推論: 学習済みのモデルに画像を渡して答えを得ること
- 学習: 画像と正解のペアを見せてモデルの内部パラメータを調整すること
同じ質問を学習の前と後で投げて、答えがどう変わるかを正解率で比べます。
題材にMNISTを選んだ理由は、データ準備に迷わないことと、正解不正解がはっきりしていて効果を数字で確認できることです。
使うモデルには、画像を見て言葉で答えるVLM(Vision Language Model / 画像も扱える言語モデル)を選びました。
環境
手元のマシンはこんな構成でした。
- CPU: 12th Gen Intel Core i7-1260P(12コア / 16スレッド、最大4.7GHz)。ノートPC向けの省電力モデルです
- メモリ: 16GB
- GPU: Intel Iris Xe(CPU内蔵)
- OS: Fedora Linux 44(カーネル 7.0系)
ランタイムはmiseで管理しました。最初に入っていたPythonは3.14でしたが、これは新しすぎてtorchやtransformersのwheelがまだ揃っていません。無用なトラブルを避けるため、miseでPython 3.12を固定しました。
# mise.toml
[tools]
python = "3.12"
[env]
_.python.venv = { path = ".venv", create = true }
miseがvenvをuvで作るので、ライブラリの導入もuv経由になります。torchはCPU版を明示して入れます。
VIRTUAL_ENV=.venv uv pip install torch --index-url https://download.pytorch.org/whl/cpu
# transformers : SmolVLMなどのモデルを読み込んで動かす本体
# accelerate : 学習・推論の実行をデバイス(CPU/GPU)に合わせて取り回す
# peft : LoRAなどの軽量ファインチューニングを提供する
# trl : SFTTrainerなど、学習を回すための高レベルなツール群
# datasets : MNISTなどのデータセットを取得・整形する
# pillow : 画像を読み込み・変換する(PIL)
VIRTUAL_ENV=.venv uv pip install transformers accelerate peft trl datasets pillow
学習前の推論を確認する
まず何も教えていないモデルが、今どのくらい数字を読めるかを測ります。これが比較の基準点です。
import torch
from transformers import AutoProcessor, AutoModelForImageTextToText
from datasets import load_dataset
# 画像専用の小型VLM。MNISTは静止画なので動画版ではなくこれを使う
MODEL_ID = "HuggingFaceTB/SmolVLM-500M-Instruct"
# モデルへの質問。「数字1文字だけで答えて」と指示している
QUESTION = "What digit is shown in this image? Answer with a single digit only."
# processor : 画像とテキストをモデルが扱える形に変換する係
processor = AutoProcessor.from_pretrained(MODEL_ID)
# 画像分割をオフにする。MNISTは28x28と小さく、分割すると計算量が無駄に増えるため
processor.image_processor.do_image_splitting = False
# モデル本体。CPUなのでfloat32で読み込み、明示的にCPUへ載せる
model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, dtype=torch.float32).to("cpu")
# 1枚の画像を渡して、モデルの答え(文字列)を返す関数
def ask_model(image):
image = image.convert("RGB") # モデルはカラー(3チャンネル)画像を前提とするため、白黒のMNISTをRGBへ変換
# 画像+質問を1つのチャットメッセージにまとめる
messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": QUESTION}]}]
# チャット形式のテンプレートを適用して、モデル入力用のプロンプト文字列を作る
prompt = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
# プロンプトと画像をトークン/テンソルに変換してCPUへ
inputs = processor(text=prompt, images=[image], return_tensors="pt").to("cpu")
with torch.no_grad(): # 推論なので勾配計算は不要
# do_sample=False で毎回同じ(決定的な)答えを出させる
out = model.generate(**inputs, max_new_tokens=10, do_sample=False)
# 入力分を取り除き、新しく生成された部分だけを文字列にして返す
return processor.batch_decode(out[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0].strip()
テスト20枚で測った結果がこちらです。
正解: 5 / モデルの回答: '5.' / ×
正解: 1 / モデルの回答: '0.' / ×
正解: 4 / モデルの回答: '4.' / ×
...
正解率: 0/20 = 0.0%
正解率は0%でした。ただ中身をよく見ると、数字そのものはほとんど読めています。全滅した本当の理由は、回答の末尾にピリオドが付くことです。'5.' は '5' と完全一致しないので、判定で弾かれます。
つまりこのモデルは、数字は読めるのに答え方の作法を知らない状態です。LoRAで矯正すべきは認識能力ではなく出力フォーマットだとわかります。
LoRAでファインチューニングする
LoRAは元のモデルの重みを凍結したまま、各層に小さな補正用の行列だけを足してそこを学習する手法です。学習するパラメータが大幅に減るので、CPUでも現実的な時間で回せます。
データはMNISTの画像とラベルを、画像と質問と回答のチャット形式に変換します。CPU向けに学習64枚、2エポックまで絞りました。
from peft import LoraConfig, get_peft_model
from trl import SFTConfig, SFTTrainer
from datasets import load_dataset
# MNISTの学習データをシャッフルし、CPU向けに先頭64枚だけ取り出す
train_dataset = load_dataset("mnist")["train"].shuffle(seed=42).select(range(64))
# LoRAの設定
peft_config = LoraConfig(
r=8, # 差分行列のサイズ(ランク)。大きいほど表現力が増すが重くなる
lora_alpha=16, # 差分(LoRAが本体に足す小さな補正。学習するのはここ)をどれだけ強く効かせるかのスケール
lora_dropout=0.1, # 過学習を防ぐため学習中に一部をランダムに無効化する割合
target_modules="all-linear", # すべての線形層(入力に重みを掛けて変換する層。モデルの主要部分)にLoRAを差し込む
init_lora_weights="gaussian", # 差分の初期値を正規分布(平均0付近の小さな乱数。学習はここから始まる)で与える
)
# 元モデルにLoRA層を組み込む。本体の重みは凍結され、差分だけが学習対象になる
model = get_peft_model(model, peft_config)
model.print_trainable_parameters() # 学習対象のパラメータ数と、それが全体の何%かを表示する。
# LoRAでは本体が凍結されるため、ここがごく一部(数%)に収まることを確認できる
# 1バッチ分のデータを、モデルに渡せる形(テンソル)にまとめる関数
def collate_fn(examples):
texts, images = [], []
for e in examples:
image = e["image"].convert("RGB") # 学習前の推論と同じく、モデルが前提とするカラー(3チャンネル)に合わせて変換
# 質問(user)と正解の数字(assistant)をペアにして「お手本」を作る
messages = [
{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": QUESTION}]},
{"role": "assistant", "content": [{"type": "text", "text": str(e["label"])}]},
]
# 学習用なので add_generation_prompt=False(答えまで含めた完成形にする)
texts.append(processor.apply_chat_template(messages, add_generation_prompt=False).strip())
images.append([image])
# テキストと画像をまとめてトークン/テンソル化(長さをそろえるためpadding)
batch = processor(text=texts, images=images, return_tensors="pt", padding=True)
# 正解ラベルを用意。長さをそろえるためのパディング部分は -100 にする。
# -100 はPyTorchの損失関数があらかじめ「無視する値」として扱う特別な番号。
# loss(正解とのズレ)はラベルが -100 の箇所を計算しないので、
# 中身のないパディングを間違って学習しないよう損失計算から除外できる
labels = batch["input_ids"].clone()
labels[labels == processor.tokenizer.pad_token_id] = -100
batch["labels"] = labels
return batch
# 学習の各種設定
args = SFTConfig(
output_dir="smolvlm-mnist-lora", # 学習済みLoRAの保存先
num_train_epochs=2, # データを2周学習する
per_device_train_batch_size=2, # 一度に処理する枚数。CPUメモリに合わせて小さく
gradient_accumulation_steps=2, # 2回分をためてから更新し、実質バッチサイズを稼ぐ
learning_rate=1e-4, # 学習の歩幅。1回ごとにパラメータをどれだけ動かすか。大きすぎると不安定、小さすぎると進みが遅い
logging_steps=5, # 5ステップごとにlossなどを記録
bf16=False, # CPUなのでfloat32
remove_unused_columns=False,
dataset_kwargs={"skip_prepare_dataset": True},
)
# SFTTrainerは、お手本(質問と正解のペア)を見せて答え方を教える教師あり学習(SFT: Supervised Fine-Tuning)を、
# データ供給・勾配計算・パラメータ更新・ログ出力までまとめて面倒みてくれる学習ループ係。
# モデル・設定・データ・collate関数を渡して学習を実行する
trainer = SFTTrainer(model=model, args=args, train_dataset=train_dataset, data_collator=collate_fn)
trainer.train()
print_trainable_parameters() の出力を見ると、学習対象は全体の1.1%しかありませんでした。これがLoRAが軽い理由です。
trainable params: 5,774,848 || all params: 513,257,152 || trainable%: 1.1251
この関数は「学習するパラメータ数」「全体のパラメータ数」「その割合」の3つを表示します。全体5.1億個に対し、更新するのは580万個だけです。
なぜこんなに少ないかというと、LoRAは巨大な本体の重みには触れず、各層に小さな差分行列を足してそこだけを学習するからです。入力 × 出力 のフルな重み(掛け算で増える)を学習する代わりに、ランク r × (入力 + 出力) という小さな足し算サイズに置き換えています。r=8 と小さく取っているので、層あたりの更新量が2桁減り、合計でも全体の1.1%に収まります。
学習中のlossの推移はこうなりました。
| ステップ | loss | トークン正解率 |
|---|---|---|
| 5 | 3.58 | 54% |
| 10 | 1.48 | 86% |
| 15 | 0.68 | 95% |
| 20 | 0.50 | 96% |
| 30 | 0.48 | 96% |
- loss: モデルの答えが正解からどれだけズレているかを表す数値。小さいほど良く、下がっていれば学習が進んでいるサインです。
- トークン正解率: モデルが次の単語(トークン)を正しく当てられた割合。大きいほど良く、学習が進むほど上がります。
初期のlossは約13.9でした。そこから0.48まで急降下し、トークン正解率は96%で安定します。学習時間は21分でした。グラフにすると、lossの下降とトークン正解率の上昇が対になっているのが一目でわかります。
学習後の推論と比較
公平を期すため、学習前とまったく同じテスト20枚で、画像分割もオフのまま測り直します。ベースモデルに学習済みのLoRAアダプタを載せて推論します。
from peft import PeftModel
# ベースモデルに、学習済みのLoRAアダプタ(差分)を重ねて読み込む
model = PeftModel.from_pretrained(model, "smolvlm-mnist-lora")
model.eval() # 推論モードに切り替える(dropoutなどを無効化)
結果がこちらです。
正解: 5 / モデルの回答: '5' / ○
正解: 1 / モデルの回答: '1' / ○
正解: 8 / モデルの回答: '8' / ○
...
正解率: 18/20 = 90.0%
Before と After を並べるとこうなります。
| 正解率 | 回答の傾向 | |
|---|---|---|
| 学習前 | 0/20 = 0.0% | 末尾にピリオドが付く。形式が不安定 |
| 学習後 | 18/20 = 90.0% | 数字のみを安定して出力 |
変化のいちばん大きな要因は形式の矯正でした。全回答から末尾のピリオドが消えて、数字だけを返すようになっています。
おもしろいのは、認識そのものも良くなった点です。学習前に誤読していた 8 や 6 が、学習後は正しく読めるようになりました。残った誤りは 0 を 9、7 を 1 と見間違えた2問だけです。ここはデータ量やエポックを増やせば改善の余地があります。
まとめ
GPUなしのノートPCでも、小型のVLMなら学習と推論を一通り体験できました。
- 推論は学習済みモデルに画像を見せて答えを得る流れ
- 学習はLoRAで少量データからモデルの答え方を矯正する仕組み
- Before と After の比較で、学習の効果を正解率という数字で確認できた
また、学習前は末尾にピリオドが付いた 5. という回答でしたが、学習後は 5 だけを返すようになりました。
LoRAが主に矯正したのは数字の認識能力ではなく出力方式で、認識能力もあわせて改善した、という結果でした。
今回使ったコード一式はGitHubに置いています。手を動かして試したい方はどうぞ。
ハマったところ
transformersが新しすぎて動かない
最初に入ったtransformersは5.12でした。この状態でSmolVLMを読み込もうとすると、image processorが解決できずにエラーになります。
ValueError: Unrecognized image processor in HuggingFaceTB/SmolVLM-500M-Instruct.
手順書もHugging Faceのクックブックもtransformers 4系の時代に書かれたものなので、5系の内部再編に追いついていません。trl 1.6が transformers>=4.56.2 を許容していたので、5系直前の4.56.2まで下げて解決しました。
VIRTUAL_ENV=.venv uv pip install "transformers==4.56.2"
CPUだと学習が終わらない
最初は8枚の学習すら15分で終わらず、タイムアウトで強制終了されました。原因はSmolVLMの画像分割機能です。
SmolVLMは高解像度の画像を複数タイルに分割してから処理します。ただMNISTは28x28の極小画像なので、分割しても意味がなく計算量だけが膨らみます。メモリも逼迫してスワップを起こしていました。
分割をオフにしたら一気に軽くなりました。
processor.image_processor.do_image_splitting = False
切り替え後に1ステップの時間を測ると、シーケンス長が93トークンまで縮み、1ステップ約8秒、ピークメモリ6.8GBに収まりました。これで現実的な時間で回せます。
はじめに
機械学習の学習と推論を一度自分の手で動かしてみたくて、SLMのLoRAファインチューニングを体験しました。題材はMNISTの手書き数字認識です。
先に、この記事に何度も出てくる3つの言葉を整理しておきます。
- SLM(Small Language Model / 小型言語モデル): ChatGPTのような大規模言語モデル(LLM)の小型版。精度は譲るが、GPUのない普通のPCでも動かせるのが利点。この記事では画像も扱える小型モデル(SmolVLM)を使う。
- LoRA(Low-Rank Adaptation): 少ない計算とメモリでモデルを追加学習させる手法。本体の巨大なパラメータは凍結し、小さな「差分」だけを学習する。更新量がごくわずかなので、CPUだけでもファインチューニングが現実的になる。
- MNIST: 0〜9の手書き数字を集めた定番データセット。28×28ピクセルの白黒画像に「正解の数字」がラベル付けされている。準備が簡単で効果を正解率で確かめやすいため、今回の題材に選んだ。
- torch(PyTorch): 機械学習でモデルを動かすための定番ライブラリ。計算も学習もこの上で行う。今回はCPU版を入れてCPUだけで動かす。
- エポック(epoch): 用意した学習データを一通り使い切ることを1エポックと数える。何周も学習させると精度が上がりやすい。今回は2エポック回す。
- loss(損失): モデルの答えが正解からどれだけズレているかを表す数値。小さいほど良く、学習が進むと下がる。学習がうまくいっているかの目安になる。
やりたかったこと
ゴールはシンプルで、推論と学習の違いを手を動かして理解することです。
- 推論: 学習済みのモデルに画像を渡して答えを得ること
- 学習: 画像と正解のペアを見せてモデルの内部パラメータを調整すること
同じ質問を学習の前と後で投げて、答えがどう変わるかを正解率で比べます。
題材にMNISTを選んだ理由は、データ準備に迷わないことと、正解不正解がはっきりしていて効果を数字で確認できることです。
使うモデルには、画像を見て言葉で答えるVLM(Vision Language Model / 画像も扱える言語モデル)を選びました。
環境
手元のマシンはこんな構成でした。
- CPU: 12th Gen Intel Core i7-1260P(12コア / 16スレッド、最大4.7GHz)。ノートPC向けの省電力モデルです
- メモリ: 16GB
- GPU: Intel Iris Xe(CPU内蔵)
- OS: Fedora Linux 44(カーネル 7.0系)
ランタイムはmiseで管理しました。最初に入っていたPythonは3.14でしたが、これは新しすぎてtorchやtransformersのwheelがまだ揃っていません。無用なトラブルを避けるため、miseでPython 3.12を固定しました。
# mise.toml
[tools]
python = "3.12"
[env]
_.python.venv = { path = ".venv", create = true }
miseがvenvをuvで作るので、ライブラリの導入もuv経由になります。torchはCPU版を明示して入れます。
VIRTUAL_ENV=.venv uv pip install torch --index-url https://download.pytorch.org/whl/cpu
# transformers : SmolVLMなどのモデルを読み込んで動かす本体
# accelerate : 学習・推論の実行をデバイス(CPU/GPU)に合わせて取り回す
# peft : LoRAなどの軽量ファインチューニングを提供する
# trl : SFTTrainerなど、学習を回すための高レベルなツール群
# datasets : MNISTなどのデータセットを取得・整形する
# pillow : 画像を読み込み・変換する(PIL)
VIRTUAL_ENV=.venv uv pip install transformers accelerate peft trl datasets pillow
学習前の推論を確認する
まず何も教えていないモデルが、今どのくらい数字を読めるかを測ります。これが比較の基準点です。
import torch
from transformers import AutoProcessor, AutoModelForImageTextToText
from datasets import load_dataset
# 画像専用の小型VLM。MNISTは静止画なので動画版ではなくこれを使う
MODEL_ID = "HuggingFaceTB/SmolVLM-500M-Instruct"
# モデルへの質問。「数字1文字だけで答えて」と指示している
QUESTION = "What digit is shown in this image? Answer with a single digit only."
# processor : 画像とテキストをモデルが扱える形に変換する係
processor = AutoProcessor.from_pretrained(MODEL_ID)
# 画像分割をオフにする。MNISTは28x28と小さく、分割すると計算量が無駄に増えるため
processor.image_processor.do_image_splitting = False
# モデル本体。CPUなのでfloat32で読み込み、明示的にCPUへ載せる
model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, dtype=torch.float32).to("cpu")
# 1枚の画像を渡して、モデルの答え(文字列)を返す関数
def ask_model(image):
image = image.convert("RGB") # モデルはカラー(3チャンネル)画像を前提とするため、白黒のMNISTをRGBへ変換
# 画像+質問を1つのチャットメッセージにまとめる
messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": QUESTION}]}]
# チャット形式のテンプレートを適用して、モデル入力用のプロンプト文字列を作る
prompt = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
# プロンプトと画像をトークン/テンソルに変換してCPUへ
inputs = processor(text=prompt, images=[image], return_tensors="pt").to("cpu")
with torch.no_grad(): # 推論なので勾配計算は不要
# do_sample=False で毎回同じ(決定的な)答えを出させる
out = model.generate(**inputs, max_new_tokens=10, do_sample=False)
# 入力分を取り除き、新しく生成された部分だけを文字列にして返す
return processor.batch_decode(out[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True)[0].strip()
テスト20枚で測った結果がこちらです。
正解: 5 / モデルの回答: '5.' / ×
正解: 1 / モデルの回答: '0.' / ×
正解: 4 / モデルの回答: '4.' / ×
...
正解率: 0/20 = 0.0%
正解率は0%でした。ただ中身をよく見ると、数字そのものはほとんど読めています。全滅した本当の理由は、回答の末尾にピリオドが付くことです。'5.' は '5' と完全一致しないので、判定で弾かれます。
つまりこのモデルは、数字は読めるのに答え方の作法を知らない状態です。LoRAで矯正すべきは認識能力ではなく出力フォーマットだとわかります。
LoRAでファインチューニングする
LoRAは元のモデルの重みを凍結したまま、各層に小さな補正用の行列だけを足してそこを学習する手法です。学習するパラメータが大幅に減るので、CPUでも現実的な時間で回せます。
データはMNISTの画像とラベルを、画像と質問と回答のチャット形式に変換します。CPU向けに学習64枚、2エポックまで絞りました。
from peft import LoraConfig, get_peft_model
from trl import SFTConfig, SFTTrainer
from datasets import load_dataset
# MNISTの学習データをシャッフルし、CPU向けに先頭64枚だけ取り出す
train_dataset = load_dataset("mnist")["train"].shuffle(seed=42).select(range(64))
# LoRAの設定
peft_config = LoraConfig(
r=8, # 差分行列のサイズ(ランク)。大きいほど表現力が増すが重くなる
lora_alpha=16, # 差分(LoRAが本体に足す小さな補正。学習するのはここ)をどれだけ強く効かせるかのスケール
lora_dropout=0.1, # 過学習を防ぐため学習中に一部をランダムに無効化する割合
target_modules="all-linear", # すべての線形層(入力に重みを掛けて変換する層。モデルの主要部分)にLoRAを差し込む
init_lora_weights="gaussian", # 差分の初期値を正規分布(平均0付近の小さな乱数。学習はここから始まる)で与える
)
# 元モデルにLoRA層を組み込む。本体の重みは凍結され、差分だけが学習対象になる
model = get_peft_model(model, peft_config)
model.print_trainable_parameters() # 学習対象のパラメータ数と、それが全体の何%かを表示する。
# LoRAでは本体が凍結されるため、ここがごく一部(数%)に収まることを確認できる
# 1バッチ分のデータを、モデルに渡せる形(テンソル)にまとめる関数
def collate_fn(examples):
texts, images = [], []
for e in examples:
image = e["image"].convert("RGB") # 学習前の推論と同じく、モデルが前提とするカラー(3チャンネル)に合わせて変換
# 質問(user)と正解の数字(assistant)をペアにして「お手本」を作る
messages = [
{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": QUESTION}]},
{"role": "assistant", "content": [{"type": "text", "text": str(e["label"])}]},
]
# 学習用なので add_generation_prompt=False(答えまで含めた完成形にする)
texts.append(processor.apply_chat_template(messages, add_generation_prompt=False).strip())
images.append([image])
# テキストと画像をまとめてトークン/テンソル化(長さをそろえるためpadding)
batch = processor(text=texts, images=images, return_tensors="pt", padding=True)
# 正解ラベルを用意。長さをそろえるためのパディング部分は -100 にする。
# -100 はPyTorchの損失関数があらかじめ「無視する値」として扱う特別な番号。
# loss(正解とのズレ)はラベルが -100 の箇所を計算しないので、
# 中身のないパディングを間違って学習しないよう損失計算から除外できる
labels = batch["input_ids"].clone()
labels[labels == processor.tokenizer.pad_token_id] = -100
batch["labels"] = labels
return batch
# 学習の各種設定
args = SFTConfig(
output_dir="smolvlm-mnist-lora", # 学習済みLoRAの保存先
num_train_epochs=2, # データを2周学習する
per_device_train_batch_size=2, # 一度に処理する枚数。CPUメモリに合わせて小さく
gradient_accumulation_steps=2, # 2回分をためてから更新し、実質バッチサイズを稼ぐ
learning_rate=1e-4, # 学習の歩幅。1回ごとにパラメータをどれだけ動かすか。大きすぎると不安定、小さすぎると進みが遅い
logging_steps=5, # 5ステップごとにlossなどを記録
bf16=False, # CPUなのでfloat32
remove_unused_columns=False,
dataset_kwargs={"skip_prepare_dataset": True},
)
# SFTTrainerは、お手本(質問と正解のペア)を見せて答え方を教える教師あり学習(SFT: Supervised Fine-Tuning)を、
# データ供給・勾配計算・パラメータ更新・ログ出力までまとめて面倒みてくれる学習ループ係。
# モデル・設定・データ・collate関数を渡して学習を実行する
trainer = SFTTrainer(model=model, args=args, train_dataset=train_dataset, data_collator=collate_fn)
trainer.train()
print_trainable_parameters() の出力を見ると、学習対象は全体の1.1%しかありませんでした。これがLoRAが軽い理由です。
trainable params: 5,774,848 || all params: 513,257,152 || trainable%: 1.1251
この関数は「学習するパラメータ数」「全体のパラメータ数」「その割合」の3つを表示します。全体5.1億個に対し、更新するのは580万個だけです。
なぜこんなに少ないかというと、LoRAは巨大な本体の重みには触れず、各層に小さな差分行列を足してそこだけを学習するからです。入力 × 出力 のフルな重み(掛け算で増える)を学習する代わりに、ランク r × (入力 + 出力) という小さな足し算サイズに置き換えています。r=8 と小さく取っているので、層あたりの更新量が2桁減り、合計でも全体の1.1%に収まります。
学習中のlossの推移はこうなりました。
| ステップ | loss | トークン正解率 |
|---|---|---|
| 5 | 3.58 | 54% |
| 10 | 1.48 | 86% |
| 15 | 0.68 | 95% |
| 20 | 0.50 | 96% |
| 30 | 0.48 | 96% |
- loss: モデルの答えが正解からどれだけズレているかを表す数値。小さいほど良く、下がっていれば学習が進んでいるサインです。
- トークン正解率: モデルが次の単語(トークン)を正しく当てられた割合。大きいほど良く、学習が進むほど上がります。
初期のlossは約13.9でした。そこから0.48まで急降下し、トークン正解率は96%で安定します。学習時間は21分でした。グラフにすると、lossの下降とトークン正解率の上昇が対になっているのが一目でわかります。
学習後の推論と比較
公平を期すため、学習前とまったく同じテスト20枚で、画像分割もオフのまま測り直します。ベースモデルに学習済みのLoRAアダプタを載せて推論します。
from peft import PeftModel
# ベースモデルに、学習済みのLoRAアダプタ(差分)を重ねて読み込む
model = PeftModel.from_pretrained(model, "smolvlm-mnist-lora")
model.eval() # 推論モードに切り替える(dropoutなどを無効化)
結果がこちらです。
正解: 5 / モデルの回答: '5' / ○
正解: 1 / モデルの回答: '1' / ○
正解: 8 / モデルの回答: '8' / ○
...
正解率: 18/20 = 90.0%
Before と After を並べるとこうなります。
| 正解率 | 回答の傾向 | |
|---|---|---|
| 学習前 | 0/20 = 0.0% | 末尾にピリオドが付く。形式が不安定 |
| 学習後 | 18/20 = 90.0% | 数字のみを安定して出力 |
変化のいちばん大きな要因は形式の矯正でした。全回答から末尾のピリオドが消えて、数字だけを返すようになっています。
おもしろいのは、認識そのものも良くなった点です。学習前に誤読していた 8 や 6 が、学習後は正しく読めるようになりました。残った誤りは 0 を 9、7 を 1 と見間違えた2問だけです。ここはデータ量やエポックを増やせば改善の余地があります。
まとめ
GPUなしのノートPCでも、小型のVLMなら学習と推論を一通り体験できました。
- 推論は学習済みモデルに画像を見せて答えを得る流れ
- 学習はLoRAで少量データからモデルの答え方を矯正する仕組み
- Before と After の比較で、学習の効果を正解率という数字で確認できた
また、学習前は末尾にピリオドが付いた 5. という回答でしたが、学習後は 5 だけを返すようになりました。
LoRAが主に矯正したのは数字の認識能力ではなく出力方式で、認識能力もあわせて改善した、という結果でした。
ハマったところ
transformersが新しすぎて動かない
最初に入ったtransformersは5.12でした。この状態でSmolVLMを読み込もうとすると、image processorが解決できずにエラーになります。
ValueError: Unrecognized image processor in HuggingFaceTB/SmolVLM-500M-Instruct.
手順書もHugging Faceのクックブックもtransformers 4系の時代に書かれたものなので、5系の内部再編に追いついていません。trl 1.6が transformers>=4.56.2 を許容していたので、5系直前の4.56.2まで下げて解決しました。
VIRTUAL_ENV=.venv uv pip install "transformers==4.56.2"
CPUだと学習が終わらない
最初は8枚の学習すら15分で終わらず、タイムアウトで強制終了されました。原因はSmolVLMの画像分割機能です。
SmolVLMは高解像度の画像を複数タイルに分割してから処理します。ただMNISTは28x28の極小画像なので、分割しても意味がなく計算量だけが膨らみます。メモリも逼迫してスワップを起こしていました。
分割をオフにしたら一気に軽くなりました。
processor.image_processor.do_image_splitting = False
切り替え後に1ステップの時間を測ると、シーケンス長が93トークンまで縮み、1ステップ約8秒、ピークメモリ6.8GBに収まりました。これで現実的な時間で回せます。

