はじめに
前編では、ゴールデンウィークの宿題として「FakerとLLMを組み合わせたデータセット作成」の泥臭い戦いをお届けしました。
後編となる本記事では、作成したデータセットを握りしめてGoogle Colabに乗り込み、実際にモデルを学習させます。
「そもそもこのモデルはファインチューニングできるのか?」という疑問から始まり、実際の定量評価(F1スコアの変化)、そして最後に待ち受けていた「エッジAI(WebGPU)化への高すぎる壁」まで、実証実験のリアルな中間報告をまとめます。
おさらい:ファインチューニングとLoRAについて
本題に入る前に、今回やろうとしていることの技術的な立ち位置を整理しておきます。
今回実施するのは「教師あり学習(Supervised Learning)」によるファインチューニングです。
ベースとなるモデル(openai/privacy-filter)は既に英語圏の個人情報(PII)を隠す能力を持っていますが、「日本の医療現場の文書」という特定のドメイン知識は持っていません。そこで、「入力テキスト」と「正解のラベル位置」のペア(前編で苦労して作った600件のデータ)を読み込ませて、モデルのパラメータを微調整します。いわば、大卒の新入社員に現場の特殊な業務マニュアルを叩き込むような作業です。
しかし、現代のモデルの全パラメータ(重み)を再学習させる「フルファインチューニング」は、計算資源(VRAM)の消費が激しく、個人開発者のGPU環境では到底不可能です。
そこで LoRA(Low-Rank Adaptation) という手法を使います。
LoRAは、巨大なベースモデルの重みは「凍結(変更不可)」したままにしておき、その横に「アダプター」と呼ばれる非常に小さな学習用の行列だけを追加します。学習時はこの小さなアダプターだけを更新するため、VRAM消費を劇的に抑えつつ、フルファインチューニングに近い性能を出すことができます。(教科書本体には書き込まず、付箋を貼ってそこにメモを書いていくようなイメージです)。
Privacy Filter はファインチューニング可能なのか?
結論から言えば、可能です。
このモデルの実態は「トークン分類(Token Classification)」モデルなので、Hugging Faceの Trainer や PEFT(LoRAを扱うライブラリ)の標準的なパイプラインに乗せることができます。
ただし、厄介な特徴が2つあります。
-
出力ヘッドが固定されている: 前編で触れた通り、出力層のラベルはネイティブの8種類(
private_person,secretなど)に固定されています。新しいラベルを増やすことはLoRAでは原理的にできないため、既存の8ラベルにデータをマッピングして学習させる必要があります。 - 独自アーキテクチャである: このモデルは
openai_privacy_filterという独自の model_type を持つカスタム実装です。
内部構造は一般的なTransformer系に近い可能性がありますが、標準のTransformers/Optimumが認識できないため、そのままではONNX変換できません。
ファインチューニングの実行と結果評価
Google Colab(T4)環境にデータを持ち込み、QLoRA(量子化+LoRA)で学習を回しました。
参考までに、実際に回したファインチューニングのコアコードを記載しておきます。最大のポイントは、モデルロード時の trust_remote_code=True と、独自アーキテクチャに対する target_modules の直接指定です。
import torch
import numpy as np
from transformers import AutoModelForTokenClassification, TrainingArguments, Trainer, DataCollatorForTokenClassification, BitsAndBytesConfig
from peft import get_peft_model, LoraConfig, prepare_model_for_kbit_training
import gc
# メモリのお掃除(再実行時のOOM対策)
if 'model' in globals():
del model
if 'trainer' in globals():
del trainer
torch.cuda.empty_cache()
gc.collect()
# 1. 4bit量子化設定
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16
)
print("[LOG] 4bit量子化モデルをロードします...")
model = AutoModelForTokenClassification.from_pretrained(
MODEL_ID,
num_labels=len(labels_list),
id2label=id2label,
label2id=label2id,
ignore_mismatched_sizes=True,
quantization_config=bnb_config,
trust_remote_code=True # 必須: 独自アーキテクチャの読み込みを許可
)
# 勾配チェックポイントを有効化してVRAMを大幅に節約
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(model)
# 2. LoRA設定 (エラー回避のため task_type を削除し、target_modulesを明示)
lora_config = LoraConfig(
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # 一般的なAttention投影層
lora_dropout=0.05,
bias="none"
)
print("[LOG] LoRAアダプタをモデルに適用します...")
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 3. Trainerの設定
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)
# 評価用関数の定義 (seqeval使用)
def compute_metrics_trainer(p):
predictions, labels = p
predictions = np.argmax(predictions, axis=2)
true_predictions = [
[labels_list[p] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
true_labels = [
[labels_list[l] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
results = seqeval.compute(predictions=true_predictions, references=true_labels)
return {
"precision": results["overall_precision"],
"recall": results["overall_recall"],
"f1": results["overall_f1"],
}
# 学習ハイパーパラメータ
training_args = TrainingArguments(
output_dir="/content/drive/MyDrive/privacy-filter-ja-lora",
learning_rate=2e-4,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4,
num_train_epochs=3,
weight_decay=0.01,
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
fp16=True,
optim="paged_adamw_8bit",
logging_steps=10,
report_to="none"
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["eval"],
processing_class=tokenizer,
data_collator=data_collator,
compute_metrics=compute_metrics_trainer,
)
print("[LOG] セットアップ完了。ファインチューニングを開始します!")
trainer.train()
さて、一番気になる「学習前後でのテストデータに対するスコア変化」を見てみます。
結果の正直な評価
テストデータ62件に対して推論を行い、学習前(ベースライン)と学習後のスコアをPandasで比較表に出力した結果がこちらです。
| Label / Metric | 学習前_F1 | 学習後_F1 | F1_改善幅 | 学習前_Precision | 学習後_Precision | 学習前_Recall | 学習後_Recall |
|---|---|---|---|---|---|---|---|
| Overall | 0.0178 | 0.1515 | +0.1336 | 0.0215 | 0.1448 | 0.0152 | 0.1587 |
| account_number | 0.0000 | 0.0000 | +0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 |
| private_address | 0.1333 | 0.1818 | +0.0485 | 0.1200 | 0.1667 | 0.1500 | 0.2000 |
| private_email | 0.0000 | 0.0000 | +0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 |
| private_person | 0.0000 | 0.1780 | +0.1780 | 0.0000 | 0.1707 | 0.0000 | 0.1858 |
| private_phone | 0.0000 | 0.0000 | +0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 |
| secret | 0.0513 | 0.0606 | +0.0093 | 0.2500 | 0.0645 | 0.0286 | 0.0571 |
【ポジティブな観察】
- Overall F1 が 0.0178 → 0.1515 へ(約8.5倍の改善)
- 特に
private_person(人名)が 0.0 → 0.178 へ劇的改善。
学習前のモデルは日本の医療文書の人名を「完全にゼロ」でスルーしていましたが、学習後は「田中太郎さん」のような日本語のエンティティを明確に検出できるようになりました。F1スコアの改善幅(+0.1336)を見ても、ファインチューニングの方向性として「間違いなく効いている」ことが証明できました。
【正直に向き合うべき点】
- 絶対値として F1 = 0.15 は実用レベルには程遠い。
-
account_number、private_email、private_phoneは 0.0 のまま(改善なし)。 -
secretラベルは Precision(適合率)が 0.25 → 0.06 に悪化。つまり「とりあえず何でもかんでもsecretと判定してしまう」誤検出(トリガーハッピー)が増加。
これは失敗ではありません。「実験としてパイプラインと方向性は確認できたが、データ量・多様性・ハイパーパラメータの追加調整が必要」という、**AI開発では非常によくある「Day1の中間結果」**です。
学習しきれなかったラベルは圧倒的にデータ件数が足りていない可能性が高く、誤検出が増えたのはネガティブサンプル(PIIを含まない文章)の設計や割合にまだ改善の余地があることを示唆しています。
そして迎えた絶望:ONNX化の壁
精度改善の余地はありつつも、「とりあえず日本語を検出できるモデル」が手に入りました。
当初の目的である 「Transformers.js を使ってブラウザ(WebGPU)上で完全ローカル動作する医療系マスキングアプリ」 を作るため、モデルをブラウザ用の形式である ONNX(オニキス) へ変換する作業に入ります。
手順としては以下の通りです。
-
merge_and_unload()を使って、ベースモデルにLoRAのアダプターを「焼き込む(統合する)」。 - 統合された単体モデルを
optimum-cli(Hugging Face公式のONNX変換ツール)でエクスポートする。
まずはOOM(メモリ不足)を回避しつつ、学習済みのLoRAアダプタを元のFP16精度モデルにマージして単体モデルとして保存します。
import torch
import gc
from transformers import AutoModelForTokenClassification, AutoTokenizer
from peft import PeftModel
print("[LOG] ベースモデルをFP16精度で再読み込みします...")
base_model = AutoModelForTokenClassification.from_pretrained(
"openai/privacy-filter",
num_labels=8,
ignore_mismatched_sizes=True,
dtype=torch.float16,
device_map="cpu", # メモリ不足を防ぐためマージはCPU上で行う
trust_remote_code=True
)
print("[LOG] 学習したLoRAアダプタを結合し、モデルをマージします...")
peft_model = PeftModel.from_pretrained(base_model, "/content/drive/MyDrive/privacy-filter-ja-lora/best_model")
merged_model = peft_model.merge_and_unload()
tokenizer = AutoTokenizer.from_pretrained("openai/privacy-filter")
merged_model.save_pretrained("/content/drive/MyDrive/privacy-filter-ja-merged")
tokenizer.save_pretrained("/content/drive/MyDrive/privacy-filter-ja-merged")
ここまでは完璧に成功しました。意気揚々とOptimumのONNXエクスポートコマンドを叩きます。
!pip install -U optimum[onnxruntime] onnx onnxruntime transformers safetensors
!optimum-cli export onnx --model /content/drive/MyDrive/privacy-filter-ja-merged --task token-classification --trust-remote-code /content/drive/MyDrive/privacy-filter-ja-onnx
結果:見事に詰みました。
KeyError: 'openai_privacy_filter'
ValueError: The checkpoint you are trying to load has model type openai_privacy_filter but Transformers does not recognize this architecture.
なぜ変換できなかったのか
エラーの原因は明確でした。「モデルが特殊すぎた」のです。
このモデルが、標準のOptimumで変換できない独自構造であることを証明するために、config.json を直接解析するスクリプトを書いて調べてみました。
import requests
CONFIG_URL = "[https://huggingface.co/openai/privacy-filter/raw/main/config.json](https://huggingface.co/openai/privacy-filter/raw/main/config.json)"
config = requests.get(CONFIG_URL).json()
print(f"① Model Type: '{config.get('model_type')}'")
==================================================
🔍 アーキテクチャ判定結果
==================================================
① Model Type: 'openai_privacy_filter'
❌ 注意: これはOptimumの標準サポートリストに存在しない独自の型です。
前述した通り、Privacy Filterは openai_privacy_filter という独自アーキテクチャとして実装されており、Transformersの標準アーキテクチャ一覧(BERTやRoBERTaなど)に含まれていません。
そのため、ONNX変換時に使用される AutoConfig がモデル構造を解釈できず、変換処理の初期段階で失敗します。
標準の汎用ONNX変換ツール(Optimum)は、LlamaやBERTのような有名なモデルの構造は知っていますが、この新しい独自アーキテクチャをどうやってONNXの計算グラフに変換すればいいのか、そのルール(ONNX Config)を持っていなかったのです。
「でも公式のベースモデルはTransformers.js(ONNX)で動いてるじゃん!」と思うかもしれません。
その通りです。公式モデルはTransformers.jsで利用可能な形(ONNX)で提供されていますが、
その変換方法やツールチェインは一般ユーザー向けに完全には公開されていません。
少なくとも、汎用のOptimum CLIだけで派生モデルを再現可能な形で変換することは現時点では困難です。私たちが作った「マージ済みの派生モデル」を自動で変換してくれるコミュニティ製の変換ツール(Hugging Face Space など)も試しましたが、最終的には同じく Optimum ベースの変換処理で止まり、openai_privacy_filter を継承した派生モデルはそのままでは ONNX 化できませんでした。
おわりに:GWの宿題を終えて
「Privacy Filterを日本語化してブラウザで動かす」というゴールデンウィークの野望は、以下のような結果となりました。
- データ作成: LLMとFakerの組み合わせで、高品質な合成データ生成パイプラインを確立できた。
- 学習と精度: LoRAによるファインチューニングの有効性は実証できたが、実用にはデータ拡充のエコシステムが必要。
- デプロイ: エッジAI(ONNX化)の最前線には「独自アーキテクチャのツールチェイン未整備」というデカい壁があることを身を以て知った。
「Colabのチュートリアル通りにはいかない」というAI開発の泥臭さと面白さが詰まった数日間でした。
この記事が、これから日本語特化のファインチューニングや、最新モデルのエッジデバイス実装に挑戦する方の参考(あるいは慰め)になれば幸いです。