はじめに:GWの宿題、進捗報告です
前回の記事で「ゴールデンウィークは Privacy Filter の日本語ファインチューニングに挑戦したい」と宣言しました。
結論からお伝えすると、ファインチューニングは無事完了しました。学習前後でテストデータに対する F1 スコアにきちんと差が出て、日本語のリハビリ医療文書に対する検出精度が改善したことを確認しています。
この記事では、前後編に分けて以下を共有していきます。
- 前編(本記事): データセット設計とアノテーション作業の話
- 後編: ファインチューニング設定、ベースラインとの定量比較、ONNX 化の挑戦と挫折
なぜ前編がデータセット作成の話なのか
教師あり学習の精度はデータの質と量に直結すると言われます。
特に Privacy Filter のようなトークン分類モデルでは、「この文字列のここからここまでが private_person」という 文字単位の境界アノテーション を、訓練データの全エンティティに対して付与する必要があります。日本語の医療文書には、英語データセットには出てこない厄介なパターンが満載です。
たとえば:
- フルネームと姓だけの混在(「田中太郎」と「田中さん」が同じ文書内)
- 全角・半角スペースの揺れ(「田中 太郎」「田中 太郎」「田中太郎」)
- 続柄付き名前(「夫の田中健」をどこからどこまで private_person にするか)
- 和暦・西暦・略号の混在(「令和6年5月3日」「2024/5/3」「R6.5.3」)
- 医療施設名の構造(「桜ヶ丘リハビリテーション病院」のどこを切るか)
これを人手で 600 件以上アノテーションするのは現実的ではありません。一方で、LLM に「こんな感じで日本語の医療文書を書いてラベル付けして」と頼んでも、ラベルの境界を完璧に出すのは(少なくとも私が試した範囲では)かなり厳しい。
そこで採った戦略がこれです。
戦略:個人情報を Faker で先に作って、文章だけを LLM に書かせる
このアプローチが本記事の一番伝えたいポイントです。
[従来の発想]
LLM が文章とラベル両方を生成 → ラベルの境界がズレてエラー多発
[今回の発想]
Python (Faker) で個人情報の文字列を先に生成 → 正解ラベルが確定
↓
LLM には「これらの文字列を一字一句変えずに本文に含めて、自然な日本語で書け」と指示
↓
LLM が出力したテキストに対して re.finditer で機械的に位置検出
Faker 側で 田中太郎 という文字列を生成しておき、LLM に「この文字列をそのまま本文に登場させて」と指示します。LLM が出力した文章に対して re.finditer(re.escape("田中太郎"), text) を回せば、出現位置は厳密に確定します。
この設計により、ラベルのズレが原理的に発生しません。LLM が文章を生成する役割に徹し、構造化された正解は Python が握ることで、両者の弱みを補い合う形になります(とはいえ後述するレビュー作業は必須です)。
検証ロジックも単純化できます。LLM の出力に対して、指定したエンティティが全て本文に含まれているかチェックするだけ。一つでも欠けていれば再生成、というループです。
def validate(text: str, expected_entities: list[Entity]) -> tuple[bool, list[Entity]]:
"""全エンティティが本文に含まれているか確認"""
missing = []
for ent in expected_entities:
if ent.text not in text:
missing.append(ent)
return (len(missing) == 0, missing)
実際にはエンティティ間の部分文字列衝突(田中 と 田中太郎 が両方エンティティの場合)の解決ロジックも必要ですが、本質はこのシンプルな仕組みです。
12 シナリオ × 600 件の設計
実用性も意識して、シナリオを医療系に偏らせつつ多様性を確保しました。
| カテゴリ | 件数 | 内訳 |
|---|---|---|
| 医療・リハビリ(40%) | 240 | 退院サマリー 80 / 日々訓練記録 60 / 家屋評価 50 / カンファレンス記録 50 |
| チャット(25%) | 150 | SNS 風 90 / メモ 60 |
| ビジネス(15%) | 90 | メール 50 / クレーム対応 40 |
| 構造化記録(10%) | 60 | 箇条書き形式(LLM 不使用、Python のみで生成) |
| ネガティブ(10%) | 60 | バイタル / 歩行観察 / システム通知(個人情報なしの正例) |
ネガティブサンプル(個人情報を含まない文書)を 10% 入れているのが地味に重要なポイントです。これを入れないとモデルが「とにかく何かにラベルを付ければ正解」というバイアスを学習してしまいます。「対象者の体温は 36.5 度、SpO2 は 98%」のような、個人情報を含まないテキストも生成して混ぜています。
構造化記録は LLM を使わず Python だけで生成しました。「【氏名】田中太郎\n【担当PT】山田一郎\n...」のような箇条書き形式で、ブラケットの種類([]・【】・〈〉・『』)や区切り文字、フィールドの順序をランダムにシャッフルして多様性を出しています。LLM が苦手とする「機械的な箇条書き」を Python の独擅場として割り当てた形です。
個人情報の生成:Faker のカスタムプロバイダ
日本ロケールの Faker は標準で人名・住所・電話番号などをそれっぽく生成してくれますが、医療現場特有の固有名詞(施設名、職種、患者ID など)は自前のプロバイダを書く必要があります。
class JapaneseMedicalProvider(BaseProvider):
FACILITY_PREFIXES = [
"桜ヶ丘", "緑風", "若葉", "中央", "メディカル", "あおぞら",
"聖光", "翠光", "やまと", "ひかり", "希望ヶ丘", "ふじ", "つばき",
# ... 40 種類ほど
]
FACILITY_SUFFIXES = [
"病院", "総合病院", "クリニック", "リハビリテーション病院",
"介護老人保健施設", "回復期リハビリテーション病院",
# ...
]
MEDICAL_ROLES = [
"医師", "主治医", "理学療法士", "作業療法士", "言語聴覚士",
"看護師", "ケアマネジャー", "MSW", "管理栄養士",
# ...
]
FAMILY_RELATIONS = [
"妻", "夫", "長男", "長女", "母", "父", "孫娘",
"ご家族", "奥様", "ご主人",
# ...
]
def medical_facility(self) -> str:
return f"{self.random_element(self.FACILITY_PREFIXES)}" \
f"{self.random_element(self.FACILITY_SUFFIXES)}"
def patient_id(self) -> str:
# 6桁数字 / 7桁数字 / 英字+数字 / ハイフン区切り の 4 形式をランダムに
fmt = random.choice(["6digit", "7digit", "lettered", "hyphenated"])
if fmt == "6digit":
return f"{random.randint(100000, 999999)}"
if fmt == "lettered":
return f"{random.choice('ABCDEFGHIJK')}{random.randint(10000, 99999)}"
# ...
患者ID のフォーマットを 4 種類混ぜているのは、現場ごとに採番ルールが違うからです。学習データが「6桁数字だけ」だと、「ABC-123-456」のような形式に対応できないモデルができてしまいます。
人名についても、出力形式に揺れを持たせます。
def vary_name_format(name: str) -> str:
"""田中 太郎 → 田中太郎 / 田中 太郎 / 田中 太郎 のいずれかにランダム変換"""
parts = name.replace("\u3000", " ").split()
if len(parts) != 2:
return name
fmt = random.choice(["no_space", "half_space", "full_space"])
if fmt == "no_space":
return parts[0] + parts[1]
if fmt == "half_space":
return parts[0] + " " + parts[1]
return parts[0] + "\u3000" + parts[1]
地味ですが、この揺れがないと「半角スペース付き氏名にしか反応しないモデル」が出来上がります。実運用では全角スペースの方が多いケースさえあります。
LLM 側のセットアップ:llama.cpp + Qwen3.5-9B
文章生成には ローカルで動かせる llama.cpp + Qwen3.5-9B-Instruct (Q4_K_XL 量子化版) を使いました。Q4 量子化なら RTX 4070 SUPER に丸ごと乗り、生成速度も実用的です。
llama-server -hf unsloth/Qwen3.5-9B-GGUF:UD-Q4_K_XL `
-ngl 999 -c 16384 `
--jinja --reasoning-budget 0 `
--host 127.0.0.1 --port 8080
--reasoning-budget 0 は重要なオプションでした。Qwen3.5 は思考モード(<think>...</think>)を持つモデルで、これが暴発すると max_tokens=4096 のうち全部を思考に使い切って、本文を 0 トークンしか出力しないという事故が起こります。最初これに気づかず「なぜか毎回 70 秒かかって本文が空のまま失敗する」という現象に半日溶かしました。
Python 側のクライアントでも保険として三重防御を入れています:
payload = {
# ...
"chat_template_kwargs": {"enable_thinking": False}, # 防御 1: API レベル
}
user_msg = prompt + "\n\n/no_think" # 防御 2: プロンプトレベル
# 防御 3: 後処理で <think>...</think> を除去
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL)
# content が空なら reasoning_content から救出
if not text.strip():
text = msg.get("reasoning_content") or ""
プロンプトのコツ:エンティティを「絶対に」一字一句変えさせない
データ生成の品質は、ほぼプロンプト設計で決まります。最終的に落ち着いた構造はこんな感じです(退院サマリーの例):
あなたは回復期リハビリテーション病院の医療スタッフです。
以下の情報をもとに、入院患者の退院時サマリーを、自然で実務的な日本語で書いてください。
【必ず文章中にそのままの文字列で登場させる固有情報】
・患者氏名: 田中 太郎
・患者ID: ABC-123-456
・入院先施設名: 桜ヶ丘リハビリテーション病院
・主治医: 山田 一郎(医師)
・担当理学療法士: 鈴木 美咲(PT)
...
【厳守すべきルール】
1. 上記の固有情報は、提示された文字列のまま本文中に登場させる(全角/半角スペース、漢字表記を変えない)
2. 上記以外の固有情報を勝手に追加しない
3. マークダウン記法は使用しない
4. 前置き(「はい、以下に...」等)・後書き・引用符は不要、本文のみ出力
「全角/半角スペース、漢字表記を変えない」が最重要ポイントです。これを書かないと、LLM が親切心で「田中 太郎」を「田中 太郎」に正規化してしまい、re.finditer でマッチしなくなります。
それでも完璧にはならないので、3 回までリトライして駄目だったら諦めて errors.jsonl に記録するという仕組みを入れています。実際の成功率は概ね 80% 前後でした(医療系の長文・多エンティティが特に難しい)。
中断・再開と失敗の追跡
600 件の生成は丸ごと回すと数時間かかります。途中で Ctrl+C で止まったり PC が再起動しても続きから再開できることが必須要件でした。
output/
dataset.jsonl # 成功レコード(append-only、1 行 = 1 件)
errors.jsonl # 失敗レコード(理由・期待エンティティ・出力本文・トレースバック)
state.json # 進捗スナップショット
JSONL を append-only で運用し、起動時に dataset.jsonl を読んでシナリオ別に「あと何件足りないか」を計算します。失敗は errors.jsonl に積まれて、後から python generate_dataset.py retry-errors で再挑戦できます。
シナリオ別にラウンドロビンで生成するのもポイントで、ある特定のシナリオだけ集中的に生成すると「途中で止めたら医療系だけ揃ってチャット系がゼロ」のような偏った状態になります。全シナリオを並行で進めることで、いつ止めても各カテゴリの比率が大きく崩れないようにしました。
ラベル設計の罠:自由に決めると落とし穴
ここで一つ、後から致命的なやり直しが発生した話を共有しておきます。
最初、私は医療文書の構造を反映した独自のラベル体系を設計しました。
private_person 一般人物名
rehab_family 患者の家族
med_staff 医療スタッフ
med_facility 医療施設名
med_patient_id 患者ID
private_age_* 年齢(10年代別、HIPAA Safe Harbor 準拠で 10 種類)
med_admission_date 入院日
med_discharge_date 退院日
... (合計 24 種類)
「医療文書として意味のある粒度」で設計したつもりでしたが、これは大失敗でした。
ファインチューニングのベースモデル(openai/privacy-filter)は 8 ラベルのネイティブ taxonomy を持っており、出力ヘッドはその 8 種類に固定されています:
account_number / private_address / private_email / private_person /
private_phone / private_url / private_date / secret
LoRA でファインチューニングする場合、出力ヘッドの構造は変更できません(変更するとそれは LoRA ではなくフルファインチューニング)。私が独自に作った 24 ラベルは、このモデルでは原理的に学習できなかったのです。
幸い、ラベルのマッピングは可能でした:
| 旧ラベル | 新ラベル |
|---|---|
| private_person / rehab_family / med_staff | private_person(人物として統合) |
| med_facility | secret |
| med_patient_id | account_number |
| private_age_*(10種) | secret |
| med_*_date(6種) | private_date |
移行スクリプトを書いて、生成済みの dataset.jsonl 全件のラベルを書き換えました。ファイルレベルでバックアップを取った上で:
LABEL_MAP = {
"private_person": "private_person",
"rehab_family": "private_person",
"med_staff": "private_person",
"med_facility": "secret",
"med_patient_id": "account_number",
"private_age_80s": "secret",
# ... 以下省略
"med_admission_date": "private_date",
"med_discharge_date": "private_date",
# ...
}
データ自体は捨てずに済んだので不幸中の幸いでしたが、「ベースモデルの taxonomy を最初に確認する」 という基本を怠ったツケでした。ファインチューニングを始める前に、ベースモデルの config.json の id2label を見ておけばよかったのです。
これからファインチューニングに挑戦される方には、ベースモデル選定とラベル設計を同時にやることを強く勧めます。「データを作ってからモデルを選ぶ」ではなく、「モデルを決めてからその出力ヘッドに合わせてデータを設計する」が正解です。
人手レビュー:自動生成の限界と Web アプリ化
600 件できれば終わり、という訳ではありません。実際は 生成完了 = レビュー開始 です。
合成テキストの本文生成には Qwen3.5 9B(Q4 量子化)を llama.cpp で起動して利用しました。RTX 4070 SUPER に丸ごと収まるサイズで、生成速度も実用的です。
しかし、このローカルモデルでのテキスト生成は、当然ながらクラウド型 LLM(Geminiなど)に比べて出力品質の安定性に欠けます。re.finditer で位置検出には成功しても、機械検証では拾えない問題が多発しました。
そこで、ローカル Flask ベースのレビュー Web アプリを作りました。
機能はシンプルですが、レビュー作業の効率に直結する以下の点にこだわりました:
- キーボードショートカット: 1=通過 / 2=編集 / 3=削除 / ←→=移動 / m=マスク表示切替 / c=候補検出
-
マスキング表示モード: エンティティを
[private_person]のようなプレースホルダで表示(マスキング失敗をひと目で発見できる) -
判定の保留方式: 削除を押しても dataset.jsonl は触らず、
review.jsonに判定だけ記録。最後に「適用 (apply)」で一括反映するので、何度でもやり直せる - エンティティの追加・削除: 範囲選択で新規追加、クリックで削除
-
未ラベル候補の自動検出: 文脈考慮で「発症、令和6年5月10日に当院入院」のような複雑な文でも
令和6年5月10日をprivate_dateとして推奨
特に「マスキング表示モード」は、Privacy Filter の出力をシミュレートして「ちゃんとマスキングできているか」を可視化するためのもので、品質確認では一番役に立った機能でした。
なぜ人手レビューが必要だったのか(ローカルLLMの限界)
後段の人手レビューで「修正」「削除」が一定数発生したのは、ある意味必然の結果でした。最終的にレビューを終えた時点での内訳がこちらです。
660件中 160 件が手直し、46 件が完全削除 になりました。実に3件に1件はそのまま使えなかった計算です。
削除に至ったのは「修復できないほど出力が壊れているもの」が中心でした。たとえば次のような暴走出力です。
同じ人名と電話番号を延々と繰り返し、4143文字にわたって出力し続けるという例です。
Qwen3.5 9B は基本的に優秀ですが、こうした無限ループ的な暴走が低い確率で発生します(文字数 4143 という極端な外れ値は、レビューアプリのソート機能で即座に発見できました)。
もしクラウド型の巨大なモデルを使えば、こうした暴走の頻度は減ったはずです。しかし、今回あえてローカルLLMを選択したのには、「APIの利用コストを抑えたい(お金をかけたくない)」という切実な理由と、「現在のローカルLLMの実力がデータ生成においてどこまで通用するのか、現状を把握したい」という技術的な好奇心がありました。
今回はローカルLLM を使うトレードオフ(無料かつ実験的である代償)として、クラウドLLMを利用したときよりも修整や削除の件数が少し多いと感じました。
最終的なデータセット
こうした泥臭いレビューとクレンジングを経て、最終的に以下のような構成になりました:
| ラベル | 件数 |
|---|---|
| private_person | 1890 |
| private_address | 299 |
| secret | 249 |
| account_number | 163 |
| private_phone | 127 |
| private_email | 112 |
| private_date | 0 |
| private_url | 0 |
合計 620 レコード(666件 - 削除46件)、エンティティ総数 2840 個。
private_date と private_url が 0 件なのは、前述したラベル設計を後から変更したことの後遺症です(旧データには日付ラベル付きの生成が間に合っていなかった)。
これは明確な弱点であり、日付検出能力が学習で消失(catastrophic forgetting)するリスクがあります。今回は「実証実験として、まずファインチューニングできるか・どう変化するかを見る」のが目的なので、欠損は承知の上で進めました。
前編まとめ
データセット作成パートで得た教訓を箇条書きで:
- ベースモデルの taxonomy を最初に確認する。ラベル設計を後から変えるのは本当に痛い
- Faker で正解を、LLM で文体を。両者の強みを使い分けるとアノテーションのズレが原理的に起きない
- 多様性を仕込む: スペース揺れ、表記揺れ、フォーマット揺れを意図的に混ぜる
- ネガティブサンプルを入れる: PII なしの正例がないとモデルが過剰検出を学習する
- 中断・再開を最初から設計する: 数時間かかる生成は途中で止まる前提で
- 人手レビューは省けない: 600 件のレビュー作業を快適にする UI に投資する価値はある
データセット作成だけで一本の記事になってしまうほど、地味で重い工程でした。これから日本語特化のファインチューニングに挑戦される方の参考になれば幸いです。
後編予告: 出来上がったデータセットを Google Colab に持ち込んで、QLoRA でファインチューニングします。学習前後のモデルでテストデータに対する F1 を比較し、どこが改善してどこが変わらなかったかを見ていきます。最後に ONNX 変換に挑戦して、見事に詰みます。乞うご期待。


