Apple Silicon上でLoRAを使い、小さなモデルを15分以内にファインチューニングする
SmolLM2-135Mが素のままではできない日付形式の変換を、MPSまたはCPUで60ステップ学習したランク8のLoRAアダプタで教え、アダプタを保存・再読み込み・検証します。
| 実行環境 | Apple Silicon(MPS)で15分以内。CPUでも動くが遅い |
|---|---|
| 所要時間 | 約3時間 |
| 使用ライブラリ | pytorch, huggingface |
| 追加パッケージ | transformers、peft |
作るもの
このプロジェクトは、ノートPC上で言語モデルをファインチューニングし、結果を目で確認できるようにします。ベースモデルはSmolLM2-135M(135 Mパラメータ、8 kコンテキスト、Apache-2.0、2024年10月にHugging Faceが公開)で、Apple SiliconのGPUなら順伝播と逆伝播の完全な1回が1秒に満たない時間で済むほど小さいモデルです。タスクは1966-06-08のようなISO形式の日付を8 June 1966に書き換えることです。ベースモデルにはこれができません。ISO: 1966-06-08\nLong:の続きを求めると、ISO形式の日付をそのまま繰り返します。低ランクアダプタで60ステップ学習すると、未見の20個の日付を1つも間違えずに変換します。
400個の学習ペアを生成し、ベースモデルを測定し、注意機構のクエリ射影と値射影にランク8のLoRAアダプタ(パラメータの0.34%)を取り付け、回答トークンだけをスコアリングする素のPyTorchループで学習し、再度測定し、アダプタを1.8 MBのフォルダに保存し、新しく読み込んだベースモデルに再度取り付けて、保存したファイルこそが仕事をしたことを証明します。
学習ループのレッスンとLLMのレッスンを読んでおくと役に立ちます。ファインチューニングのトピックハブは代替手法の中でのLoRAの位置づけを説明し、効率的なMLのハブはランクがなぜ重要かを扱っています。
必要なもの
- Apple Silicon(M1以降)を搭載し、8 GB以上のメモリを持つMac。モデル、オプティマイザの状態、活性化に約1.5 GBが必要です。Intel Mac、Linux、Windowsのマシンでも同じコードがCPUで動きますが、数倍の時間がかかります。
- Python 3.10以降と
torch2.x、transformers、peft(pip install torch transformers peft)。当サイトではPyTorch 2.14.1でテストしました。 - モデルの重みに約270 MBのディスク。初回実行時にHugging Face Hubからダウンロードします。以後は
HF_HUB_OFFLINE=1で動きます。 - APIキーもホスト型サービスも不要です。
メインのブロックは、Apple SiliconのMac mini上で、Field Guideのパイプライン環境を使い、空のディレクトリから、MPSで1回、CPUに強制して1回実行しました。両方の所要時間を以下に示します。
設計
LoRAが変えるもの。 線形層はy = W xを計算します。LoRAはWを凍結したままB A xを加えます。ここでAは576 x 8、Bは8 x 576です。積B Aはランクが最大8の576 x 576行列なので、層への更新は小さな部分空間に制約されます。Bはゼロから始まるので、ステップ0ではモデルは正確にベースモデルそのものです。alpha / rがバイパスをスケーリングします。alpha = 16、r = 8ではスケールは2です。SmolLM2-135Mは隠れサイズ576のTransformer層を30層持ち、各層のq_projとv_projを適応させると60個の適応層になります。q_projのアダプタは576 x 8 + 8 x 576 = 9,216個の重みを持ちます。v_projのアダプタは6,144個と小さめです。モデルが64次元のキー・バリューヘッド3個のグループ化クエリ注意を使っているため、v_projの出力は576ではなく192個だからです。30層 x 15,360が、コードが出力する460,800個の学習可能パラメータです。
なぜ回答トークンだけが損失を担うのか。 学習テキストはISO: 1966-06-08\nLong: 8 June 1966に末尾のend-of-sequenceトークンを続けたものです。モデルはLong:の後のトークンの予測についてのみスコアリングされます。プロンプトのトークンにはラベル-100が付き、cross_entropyはこれを無視します。パディングも同様です。プロンプトをスコアリングすると、ランダムな日付を予測する学習に容量を費やすことになり、それはタスクではありません。
なぜ合成タスクなのか。 シードから生成した400個の日付はコストがかからず、ライセンスの問題もなく、一目で検証できる答えを持ちます。保留セットは別のシードを使うので、テスト用の日付は学習時に一度も見ていません。指標は文字列の完全一致で、厳しくかつ誠実です。実際のタスクでも、より曖昧な指標に手を伸ばす前に欲しいのは、この同じ構造(プロンプト、回答、保留セットでの完全一致)です。
なぜTrainerではなく素のループなのか。 ループは14行で、その1行1行が見えます。transformers.Trainerは勾配累積、混合精度、チェックポイント、ロギングを加えます。実際の実行ではすべて欲しいものですが、何が起きているかを理解するにはどれも必要ありません。「試してみること」で置き換えを示します。
デバイスの選択。 torch.device("mps" if torch.backends.mps.is_available() else "cpu")は、Apple GPUがあればそれを選びます。本稿執筆時点ではbfloat16学習のMPSサポートがまだ不均一であり、135 Mパラメータならfloat32でも十分手が届くので、すべてfloat32のままにしています。
コード
学習、比較、保存、再読み込み
これをlora.pyとして保存します。学習前後の測定がモデルとトークナイザを共有できるよう、1つのブロックにしています。
# lora.py: teach SmolLM2-135M to rewrite ISO dates as British long dates with a LoRA adapter.
import random
import time
from datetime import date, timedelta
import torch
from peft import LoraConfig, PeftModel, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
SEED = 0
random.seed(SEED)
torch.manual_seed(SEED)
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
MODEL_ID = "HuggingFaceTB/SmolLM2-135M"
ADAPTER_DIR = "date-lora"
# --- 1. A synthetic task you can check by eye: 2024-03-07 -> 7 March 2024 ---
def make_examples(n: int, seed: int) -> list[tuple[str, str]]:
rng = random.Random(seed)
start = date(1950, 1, 1)
out = []
for _ in range(n):
d = start + timedelta(days=rng.randrange(365 * 100))
out.append((d.isoformat(), f"{d.day} {d.strftime('%B')} {d.year}"))
return out
def prompt_for(iso: str) -> str:
return f"ISO: {iso}\nLong:"
train_pairs = make_examples(400, seed=1)
test_pairs = make_examples(20, seed=2)
print("example pair:", train_pairs[0])
# --- 2. Load the base model and look at what it does before training ---
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32).to(device)
@torch.no_grad()
def generate(m, iso: str) -> str:
enc = tokenizer(prompt_for(iso), return_tensors="pt").to(device)
out = m.generate(**enc, max_new_tokens=12, do_sample=False,
pad_token_id=tokenizer.eos_token_id)
text = tokenizer.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True)
return text.split("\n")[0].strip()
def exact_match(m, pairs) -> float:
m.eval()
return sum(generate(m, iso) == long for iso, long in pairs) / len(pairs)
show = test_pairs[:3]
print("\nbefore training:")
for iso, long in show:
print(f" {iso} -> {generate(model, iso)!r} (want {long!r})")
print(f" exact match on 20 held-out dates: {exact_match(model, test_pairs):.2f}")
# --- 3. Wrap the model with a rank-8 LoRA on the attention q and v projections ---
config = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.0,
target_modules=["q_proj", "v_proj"], task_type="CAUSAL_LM")
model = get_peft_model(model, config)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"\ntrainable params {trainable:,} of {total:,} ({100 * trainable / total:.2f} %)")
# --- 4. Tokenise so the loss is only on the answer tokens ---
def encode_batch(pairs):
prompts = [prompt_for(iso) for iso, _ in pairs]
fulls = [prompt_for(iso) + " " + long + tokenizer.eos_token for iso, long in pairs]
enc = tokenizer(fulls, return_tensors="pt", padding=True)
labels = enc["input_ids"].clone()
for i, p in enumerate(prompts):
n_prompt = len(tokenizer(p)["input_ids"])
labels[i, :n_prompt] = -100 # do not score the prompt
labels[enc["attention_mask"] == 0] = -100 # do not score padding
return enc["input_ids"].to(device), enc["attention_mask"].to(device), labels.to(device)
# --- 5. A plain training loop ---
STEPS, BATCH, LR = 60, 8, 1e-3
optimiser = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=LR)
model.train()
t0 = time.perf_counter()
losses = []
for step in range(1, STEPS + 1):
batch = random.sample(train_pairs, BATCH)
input_ids, attention_mask, labels = encode_batch(batch)
loss = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels).loss
optimiser.zero_grad()
loss.backward()
optimiser.step()
losses.append(loss.item())
if step % 5 == 0:
print(f"step {step:3d} loss {sum(losses[-5:]) / 5:.3f}")
print(f"trained {STEPS} steps in {time.perf_counter() - t0:.1f} s on {device}")
# --- 6. After training, then save and reload the adapter on a fresh base model ---
print("\nafter training:")
for iso, long in show:
print(f" {iso} -> {generate(model, iso)!r} (want {long!r})")
print(f" exact match on 20 held-out dates: {exact_match(model, test_pairs):.2f}")
model.save_pretrained(ADAPTER_DIR)
base = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32).to(device)
reloaded = PeftModel.from_pretrained(base, ADAPTER_DIR).to(device)
print(f"\nreloaded adapter from {ADAPTER_DIR}/: "
f"exact match {exact_match(reloaded, test_pairs):.2f}")
MPSを使ったMac miniでの期待される出力です。スクリプト全体で19秒かかり、そのうち60ステップの学習は6.3秒で、残りの大半はモデルを2回読み込むことと46回の生成でした。同じマシンでCPUに強制するとスクリプトは45秒かかり、学習は30.5秒でしたが、学習前後の結果は同じでした。
example pair: ('1974-02-09', '9 February 1974')
before training:
1960-02-24 -> '1960-02-24' (want '24 February 1960')
1966-06-08 -> '1966-06-08' (want '8 June 1966')
1965-03-25 -> '1965-03-25' (want '25 March 1965')
exact match on 20 held-out dates: 0.00
trainable params 460,800 of 134,975,808 (0.34 %)
step 5 loss 1.503
step 10 loss 0.487
step 15 loss 0.076
step 20 loss 0.016
step 25 loss 0.010
step 30 loss 0.002
step 35 loss 0.006
step 40 loss 0.004
step 45 loss 0.002
step 50 loss 0.003
step 55 loss 0.001
step 60 loss 0.001
trained 60 steps in 6.3 s on mps
after training:
1960-02-24 -> '24 February 1960' (want '24 February 1960')
1966-06-08 -> '8 June 1966' (want '8 June 1966')
1965-03-25 -> '25 March 1965' (want '25 March 1965')
exact match on 20 held-out dates: 1.00
reloaded adapter from date-lora/: exact match 1.00
transformersからのis_gradient_checkpointingに関するFutureWarningと、ベースモデルに設定ファイルが見つからないというpeftからのUserWarningも表示されます。どちらも当サイトが使ったバージョンでのライブラリ内部の事情で、無視して構いません。
損失は回答トークンあたりの値です。ベースモデルはLong:の後に日付らしきものが続くことをすでに知っていて一部の数字は当てるので、損失は1.5付近から始まり、ステップ15までに0.1を下回ります。タスクは小さく完全に規則的なので、アダプタは数百の例でこれを学習します。実際のタスクはより長い裾を持ち、曲線はゼロよりかなり上で平坦になります。この演習の要点はループの形と学習前後の比較であって、最終的な損失ではありません。
別のプログラムからアダプタを使う
これをinfer.pyとして保存します。ベースモデルを読み込み、フォルダからアダプタを取り付け、コマンドラインから日付を1つ変換します。アダプタのフォルダには、ランク・alpha・対象モジュールを記録したadapter_config.jsonと、学習済みの重み1.8 MBであるadapter_model.safetensorsが入っています。
# snippet
# infer.py: load the base model plus the saved adapter and convert one date.
import sys
import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
tokenizer = AutoTokenizer.from_pretrained("HuggingFaceTB/SmolLM2-135M")
base = AutoModelForCausalLM.from_pretrained("HuggingFaceTB/SmolLM2-135M", dtype=torch.float32)
model = PeftModel.from_pretrained(base, "date-lora").to(device).eval()
iso = sys.argv[1] if len(sys.argv) > 1 else "2026-11-13"
enc = tokenizer(f"ISO: {iso}\nLong:", return_tensors="pt").to(device)
with torch.no_grad():
out = model.generate(**enc, max_new_tokens=12, do_sample=False,
pad_token_id=tokenizer.eos_token_id)
print(tokenizer.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).split("\n")[0].strip())
実行
pip install torch transformers peft
python lora.py
python infer.py 2026-11-13
初回のlora.pyの実行でモデルがダウンロードされます。その後、何も取得されないことを確実にしたければHF_HUB_OFFLINE=1を設定してください。Macでは、アクティビティモニタのGPU履歴に6秒の学習が短いバーストとして表示されます。
テスト
lora.pyは自分でテストを出力します。保留した20個の日付での完全一致が0.00から1.00に上がり、再読み込みしたアダプタは学習したものと同じスコアになります。再読み込み後のスコアが学習時のスコアより低ければ、保存と再読み込みの経路が間違っています(よくある原因は、PEFTのラッパーではなくベースモデルに対してsave_pretrainedを呼ぶことです)。
infer.py 2026-11-13はMac miniで5.6秒で13 November 2026を出力し、その大半はモデルの読み込みでした。2099-12-31のような学習範囲外の日付も試す価値があります。学習データは1950年から2049年までで、モデルは20で始まり49より大きい年を一度も見ていません。
python lora.pyをもう一度実行すると、数値は初回と一致するはずです。当サイトのMPSでの実行とCPUでの実行は、小数点以下3桁まで同一の損失を出力しました。すべてのMPSカーネルが決定的とは限らないので、他のマシンでは後ろの桁に小さな差が出る可能性がありますが、完全一致のスコアは依然として0.00と1.00になるはずです。
試してみること
r=1に設定してください。ランク1ではアダプタは57,600パラメータで、ランク8版の8分の1です。それでも1.00に達するでしょうか。次にtarget_modules=["v_proj"]だけを試してください。タスクを解く最小のアダプタを見つけることは、ファインチューニングで最も安価で、最も多くを教えてくれる実験です。- 各ペアの両側を入れ替えてタスクを逆向き(長い形式を入力、ISOを出力)にしてください。モデルは月名から数字への対応を学ばなければならず、これは形式の並べ替えではなく参照です。
- 同じ学習セットに2つ目のタスク、たとえば
d.strftime("%A")の曜日名を使うWeekday:を追加してください。1つのアダプタで両方を保持できます。最初のタスクの完全一致が落ちるかどうかを観察してください。 - ループを
transformers.Trainerと、ラベルを-100でパディングするDataCollatorForSeq2Seqに置き換えてください。ステップ60で同じ損失に達することを確認し、他に何をログに出すかを書き留めてください。 - 保留した20個の日付でも学習し、最終スコアが何も教えてくれないことを確かめてください。それから元に戻してください。上の1.00に意味があるのは、保留セットがあるからにほかなりません。
reloaded.merge_and_unload()でアダプタをベースの重みにマージし、結果を素のモデルとして保存してください。マージ後のモデルはベースモデルと同じサイズで、推論時にpeftは不要です。マージあり・なしで生成速度を比べてください。- ベースを
HuggingFaceTB/SmolLM2-135M-Instructに差し替えてください。指示に従うよう学習されているので学習前の出力が変わり、必要なステップ数も変わるかもしれません。
まとめ
- LoRAは適応層ごとに2つの小さな行列を学習し、ベースの重みには触れません。ここではパラメータの0.34%、1.8 MBのファイルです。
- プロンプトとパディングを
-100でマスクし、損失がモデルに生成させたいものだけを測るようにします。 - 目で検証できるタスクを生成し、別のシードによる保留セットを保ち、学習後だけでなく学習前も測定します。
- アダプタを保存し、新しいベースモデルに再読み込みし、スコアが一致することを確認します。それが、ディスク上のファイルが学習したものそのものであることのテストです。
- MPS上の素のPyTorchループは、135 Mモデルを60ステップ約6秒で学習します。CPUは約5倍の時間がかかり、同じ答えを出します。