社内用語や製品固有の回答など、汎用LLMが知らない知識を日本語で答えさせたい場合があります。しかし、モデル全体を追加学習するフルファインチューニングは、GPUメモリと計算時間の負担が大きくなります。
そこで今回は、LoRA(Low-Rank Adaptation)を使って、小規模な日本語データセットからLLMを追加学習します。私が実際に試した構成では、ベースモデルの重みを固定し、少数の追加パラメータだけを学習することで、比較的扱いやすい手順にできました。
重要なのは、データを増やすことよりも「回答形式を揃えること」と「過学習を確認すること」です。
1. 使用するモデルと環境
今回は日本語を含む多言語対応モデルとして、Qwen/Qwen2.5-1.5B-Instructを使います。1.5Bクラスなので、より大きなモデルよりも検証しやすい構成です。
必要なライブラリをインストールします。
pip install torch transformers datasets peft accelerate
GPUを使う場合は、CUDAに対応したPyTorchを先にインストールしてください。学習前に、GPUが認識されているか確認します。
python -c "import torch; print(torch.cuda.is_available())"
LoRAではベースモデルの全パラメータを更新しないため、通常の追加学習よりも必要メモリを抑えられます。ただし、モデルサイズや系列長によって必要なメモリは変わるため、最初は小さなバッチサイズで試すのがおすすめです。
2. 小規模データセットを用意する
学習データはJSONL形式にしました。1行を1サンプルとして、ユーザーの質問と期待する回答を保存します。
train.jsonlの例です。
{"instruction":"LoRAとは何ですか?","output":"LoRAは、元のモデル重みを固定したまま、低ランク行列を追加して効率的に追加学習する手法です。"}
{"instruction":"社内申請の受付時間を教えてください。","output":"社内申請の受付時間は、平日の午前9時から午後5時までです。"}
{"instruction":"製品Aの特徴を3つ説明してください。","output":"製品Aの特徴は、導入が簡単であること、管理画面が日本語に対応していること、既存システムと連携しやすいことです。"}
実際のデータでは、回答の文体や敬語をできるだけ統一します。質問によって「です・ます調」と「だ・である調」が混在すると、学習後の出力にも揺れが出やすくなります。
また、同じ質問に対して回答が複数ある場合は、正解を一つに決めておきます。小規模データでは、重複データや矛盾した回答の影響が特に大きくなります。
3. LoRAで追加学習する
小規模データセットの準備から学習後の評価までの流れは次の通りです。
以下が学習スクリプトです。回答部分だけを損失計算の対象にするため、ユーザー入力部分のラベルを-100でマスクしています。
import json
import torch
from datasets import Dataset
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
Trainer,
TrainingArguments,
)
from peft import LoraConfig, get_peft_model
MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"
OUTPUT_DIR = "./lora-japanese"
with open("train.jsonl", "r", encoding="utf-8") as f:
records = [json.loads(line) for line in f]
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
def tokenize_record(record):
messages = [
{"role": "user", "content": record["instruction"]},
{"role": "assistant", "content": record["output"]},
]
prompt_messages = [
{"role": "user", "content": record["instruction"]},
]
prompt_text = tokenizer.apply_chat_template(
prompt_messages,
tokenize=False,
add_generation_prompt=True,
)
full_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
)
prompt_ids = tokenizer(
prompt_text,
add_special_tokens=False,
)["input_ids"]
full_ids = tokenizer(
full_text,
add_special_tokens=False,
truncation=True,
max_length=512,
)["input_ids"]
prompt_length = min(len(prompt_ids), len(full_ids))
labels = [-100] * prompt_length + full_ids[prompt_length:]
return {
"input_ids": full_ids,
"attention_mask": [1] * len(full_ids),
"labels": labels,
}
dataset = Dataset.from_list(records)
tokenized_dataset = dataset.map(
tokenize_record,
remove_columns=dataset.column_names,
)
def collate_fn(features):
max_length = max(len(x["input_ids"]) for x in features)
input_ids = []
attention_mask = []
labels = []
for feature in features:
padding = max_length - len(feature["input_ids"])
input_ids.append(
feature["input_ids"] + [tokenizer.pad_token_id] * padding
)
attention_mask.append(
feature["attention_mask"] + [0] * padding
)
labels.append(
feature["labels"] + [-100] * padding
)
return {
"input_ids": torch.tensor(input_ids, dtype=torch.long),
"attention_mask": torch.tensor(attention_mask, dtype=torch.long),
"labels": torch.tensor(labels, dtype=torch.long),
}
use_bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
use_fp16 = torch.cuda.is_available() and not use_bf16
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.bfloat16 if use_bf16 else (
torch.float16 if use_fp16 else torch.float32
),
)
lora_config = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
num_train_epochs=3,
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
learning_rate=2e-4,
logging_steps=10,
save_strategy="epoch",
report_to="none",
fp16=use_fp16,
bf16=use_bf16,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
data_collator=collate_fn,
)
trainer.train()
trainer.save_model(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
rはLoRAの低ランクの大きさです。小規模データでまず試すなら、r=8程度から始めると調整しやすいです。学習率を高くしすぎると、数件の回答をそのまま暗記するような過学習が起きやすいため、学習後の生成結果を必ず確認します。
4. 学習済みLoRAを使って推論する
保存したLoRAアダプターをベースモデルに読み込み、質問を入力します。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel
BASE_MODEL = "Qwen/Qwen2.5-1.5B-Instruct"
ADAPTER_DIR = "./lora-japanese"
tokenizer = AutoTokenizer.from_pretrained(ADAPTER_DIR)
base_model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
torch_dtype="auto",
device_map="auto",
)
model = PeftModel.from_pretrained(base_model, ADAPTER_DIR)
messages = [
{
"role": "user",
"content": "社内申請の受付時間を教えてください。",
}
]
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
).to(model.device)
with torch.no_grad():
outputs = model.generate(
inputs,
max_new_tokens=128,
do_sample=False,
)
answer = tokenizer.decode(
outputs[0][inputs.shape[-1]:],
skip_special_tokens=True,
)
print(answer)
評価では、学習に使った質問だけでなく、似ているが少し表現の異なる質問も用意します。学習データと完全に同じ質問に正答できても、言い換えに対応できなければ、実用上は不十分です。
私が確認するときは、次の3種類に分けています。
- 学習データと同じ質問
- 表現を変えた類似質問
- 学習データに存在しない一般的な質問
最後の質問で急に不自然な回答になったり、特定の回答を繰り返したりする場合は、エポック数を減らす、データを増やす、または学習率を下げると改善することがあります。
5. LoRA学習で注意したこと
小規模データセットでは、LoRAを使っても万能に知識を追加できるわけではありません。特に数件のデータだけで広い分野の知識を覚えさせようとすると、元モデルの能力を損なう可能性があります。
今回のような用途では、モデルに新しい知識を大量に記憶させるというより、次のような目的に向いています。
- 特定の回答形式に揃える
- 社内用語に合わせた返答をさせる
- FAQのような限定された質問に対応する
- 文体や敬語を統一する
事実情報を頻繁に更新する必要がある場合は、LoRAだけで解決しようとせず、検索結果をプロンプトに渡すRAGと組み合わせる方が運用しやすいケースもあります。
まとめ
LoRAを使うと、モデル全体を更新せずに、日本語の小規模データセットから特定の応答形式や専門用語を追加学習できます。
実装上のポイントは、以下の通りです。
- JSONLで質問と回答の形式を統一する
- 回答部分だけを損失計算の対象にする
- 最初は小さなLoRA設定と少ないエポック数で試す
- 学習済みデータ以外の言い換え質問でも評価する
- 頻繁に変わる知識はRAGとの役割分担を考える
まずは数十件程度のきれいなデータで学習し、出力の変化を確認してからデータ量やモデルサイズを増やすのが、失敗しにくい進め方です。