たねやつの木

Photographs, Keyboards and Programming

Gemma3:270Mに「ござる」口調をLoRAで学習させてみた

こんにちは、たねやつです。

今回は、Googleがリリースした軽量な大規模言語モデル(LLM)である「Gemma 3 270M」に、特定の口調を学習させるファインチューニングを試してみました。目指すは、武士のような「ござる」口調です!

比較的小さなモデルでも、LoRAという効率的な手法を使えば、少ない計算資源で独自のキャラクターを持ったモデルを作成できます。その具体的な手順と、結果についての考察を備忘録としてまとめてみました。

この記事でできること

  • QLoRAを使ったLLMのファインチューニングの概要がわかる
  • Hugging Faceのデータセットを使って学習データを用意する方法がわかる
  • transformerstrlライブラリを使った具体的な学習手順がわかる
  • ファインチューニングしたモデルで推論を試す方法がわかる

事前に必要なもの

今回のファインチューニングには、ある程度のスペックを持つPC(特にGPU)が必要です。

  • GPU: NVIDIA製のVRAM 8GB以上を推奨(今回はRTX3060 12GBを使用)
  • Python環境: venvcondaなどで構築
  • Hugging Faceアカウント: モデルやデータセットへのアクセスに必要です

手順 or 作業

開発環境のセットアップ

まずは、ファインチューニングに必要なライブラリをインストールします。Hugging Faceのライブラリ群が中心となります。

# PyTorch関連
pip install "torch>=2.4.0" tensorboard

# Hugging Faceの主要ライブラリ
pip install "transformers>=4.51.3"
pip install --upgrade \
  "datasets==3.3.2" \
  "accelerate==1.4.0" \
  "evaluate==0.4.3" \
  "bitsandbytes==0.45.3" \
  "trl==0.21.0" \
  "peft==0.14.0" \
  protobuf \
  sentencepiece

また、Gemmaモデルを利用するには、Hugging Faceで利用規約への同意が必要です。事前にモデルページでライセンスに同意しておきましょう。

データセットの準備

今回は、特定の口調を学習させるため、その口調に特化したデータセットが必要です。 Hugging Face Hubで公開されている、bbz662bbz/databricks-dolly-15k-ja-gozaruという、応答がすべて「ござる」口調になっている素晴らしいデータセットを利用させていただきました。

datasetsライブラリを使えば、このデータセットを簡単にダウンロードして利用できます。 学習スクリプトでは、このデータセットを読み込み、Gemmaが学習しやすい会話形式(messages)に変換しています。

from datasets import load_dataset

# Hugging Face Hubからデータセットをロード
# dataset = load_dataset("bbz662bbz/databricks-dolly-15k-ja-gozaru", split="train")
# 今回は事前にダウンロードしたgozaru.jsonを使用
dataset = load_dataset("json", data_files="gozaru.json", split="train")


# 会話形式に変換する関数
def create_conversation(sample):
  if sample.get("input"):
      user_content = f"{sample['instruction']}\n\n{sample['input']}"
  else:
      user_content = sample['instruction']
  return {
    "messages": [
      {"role": "user", "content": user_content},
      {"role": "assistant", "content": sample["output"]}
    ]
  }

# データセットを変換
dataset = dataset.map(create_conversation, remove_columns=dataset.features, batched=False)
# 訓練用とテスト用に分割
dataset = dataset.train_test_split(test_size=0.02)

ファインチューニングの実行

準備が整ったので、いよいよ学習を実行します。 今回は、QLoRA (Quantized Low-Rank Adaptation) という手法を使います。これは、モデルの大部分の重みを4ビットに量子化して凍結し、ごく一部の「アダプター」層だけを学習させることで、メモリ使用量を大幅に削減する技術です。

モデルとトークナイザーのロード

まず、ベースとなるgoogle/gemma-3-270m-itモデルを4ビット量子化でロードします。

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig

model_id = "google/gemma-3-270m-it"

# 4ビット量子化の設定
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type='nf4',
    bnb_4bit_compute_dtype=torch.bfloat16,
)

# モデルをロード
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=quantization_config,
    device_map="auto"
)

# トークナイザーをロード
tokenizer = AutoTokenizer.from_pretrained(model_id)
LoRAと学習パラメータの設定

次に、peftライブラリでLoRAの設定を行い、SFTConfigで学習のハイパーパラメータ(エポック数、バッチサイズ、学習率など)を定義します。

from peft import LoraConfig
from trl import SFTConfig

# LoRAの設定
peft_config = LoraConfig(
    lora_alpha=16,
    lora_dropout=0.05,
    r=16,
    bias="none",
    target_modules="all-linear", # すべての線形層を対象にする
    task_type="CAUSAL_LM",
)

# 学習のハイパーパラメータ
args = SFTConfig(
    output_dir="gemma-270m-gozaru", # 学習済みモデルの保存先
    num_train_epochs=3,
    per_device_train_batch_size=1,
    gradient_accumulation_steps=4,
    optim="adamw_torch_fused",
    learning_rate=2e-4,
    logging_steps=10,
    save_strategy="epoch",
    # ... その他の設定
)
学習の開始

設定が完了したら、SFTTrainerにモデル、データセット、各種設定を渡して学習を開始します。

from trl import SFTTrainer

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=dataset["train"],
    peft_config=peft_config,
    tokenizer=tokenizer,
)

# 学習開始!
trainer.train()

# モデルの保存
trainer.save_model()

学習にはGPUスペックによりますが、数時間かかる場合があります。

学習済みモデルで推論

学習が完了したら、早速「ござる」口調を習得したか試してみましょう。 保存したアダプターを読み込み、pipelineを使ってテキスト生成を行います。

from transformers import pipeline
import torch

# 学習済みモデル(アダプター)をロード
model = AutoModelForCausalLM.from_pretrained(
  "gemma-270m-gozaru",
  device_map="auto",
  torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained("gemma-270m-gozaru")

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

# プロンプトの準備
messages = [
    {"role": "user", "content": "日本の首都について教えてください。"},
]
prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)

# テキスト生成
outputs = pipe(
    prompt,
    max_new_tokens=256,
    do_sample=True,
    temperature=0.7,
    top_k=50,
    top_p=0.95
)

# 結果の表示
print(outputs[0]['generated_text'][len(prompt):].strip())

学習結果と考察

さて、肝心の学習結果です。 結論から言うと、「口調の学習はできたものの、日本語の応答品質には課題が残る」という結果になりました。

生成されたテキストは、見事に「~でござる」「~でござろう」といった語尾を獲得しており、LoRAによる口調の学習は成功していると言えます。

しかし、ユーザーからの質問(プロンプト)に対して、的確で自然な日本語の回答を返すのは苦手なようでした。例えば、「日本の首都は東京でござる。」のような単純な応答はできても、少し複雑な内容になると、文法が崩れたり、質問の意図とずれた答えが返ってきたりすることが多かったです。

この原因は、ベースモデルであるgemma-3-270m-itの特性にあると考えられます。

  • モデルサイズが小さい: 2.7億(270M)パラメータというのは、現在のLLMの中では非常に軽量な部類です。複雑な言語のニュアンスや知識を保持するには、やはり限界があります。
  • 日本語への最適化不足: このモデルは主に英語のデータで学習されていると推測されます。そのため、日本語の語彙や文法構造に関する知識が十分ではなく、ファインチューニングで口調を上書きしても、元々の日本語能力の低さがボトルネックになってしまったようです。

今回の試みは、小さなモデルでも特定のスタイルを学習させられる可能性を示してくれましたが、同時に、ベースモデルの持つ元々の言語能力が、ファインチューニング後の品質を大きく左右するということを改めて教えてくれました。

最後に

今回は、軽量なGemma 3 270Mモデルをベースに、QLoRAを用いて特定の口調を学習させるファインチューニングに挑戦しました。 結果としては、ベースモデルの限界も感じるものでしたが、少ないリソースで手軽にモデルの個性を変えられるのは非常に面白い体験でした。

もし、より実用的な日本語特化モデルを目指すのであれば、gemma-3-12b-itのような、より大きく、かつ日本語データで追加学習されたモデルをベースにすると、全く違う結果になるかもしれません。そちらも機会があれば試してみたいでござるな!

皆さんも、好きなキャラクターの口調や、特定の文体に特化したモデルを作ってみてはいかがでしょうか。

最終的な学習スクリプト

これまでの手順をまとめた最終的なPythonスクリプトはこちらです。

# Gemma ファインチューニングスクリプト (日本語会話データセット版)
# このスクリプトは gemma_finetuning_guide_jp.md の内容をまとめたものです。

# ==============================================================================
# 2. 開発環境のセットアップ (ライブラリのインポート)
# ==============================================================================
# Hugging Face Hub へのログインに使用
from huggingface_hub import login

# データセットのロードと処理に使用
from datasets import load_dataset

# モデルとトークナイザー、量子化設定に使用
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig

# LoRA の設定に使用
from peft import LoraConfig, PeftModel

# トレーニングのハイパーパラメータ設定とトレーナーに使用
from trl import SFTConfig, SFTTrainer

# 推論パイプラインとヘルパー
from transformers import pipeline
from random import randint


# ==============================================================================
# Hugging Face Hub へのログイン
# ==============================================================================
# 事前にターミナルで `huggingface-cli login` を実行するか、
# 以下のコードのコメントを解除してトークンを設定してください。
# print("Hugging Face Hub にログインします...")
# hf_token = "YOUR_HUGGING_FACE_TOKEN"
# login(hf_token)
# print("ログイン完了")


# ==============================================================================
# 3. ファインチューニング用データセットの作成と準備
# ==============================================================================
print("データセットの準備を開始します...")

# プロンプトテンプレートを定義 (英語、指示のみ)
def create_prompt(sample):
    return f"""### Instruction:
{sample['instruction']}

### Response:"""

def create_conversation(sample):
  if sample.get("input"):
      user_content = f"{sample['instruction']}\n\n{sample['input']}"
  else:
      user_content = sample['instruction']
  return {
    "messages": [
      {"role": "user", "content": user_content},
      {"role": "assistant", "content": sample["output"]}
    ]
  }

# ローカルのJSONファイルからデータセットをロード
try:
    dataset = load_dataset("json", data_files="gozaru.json", split="train")
except FileNotFoundError:
    print("エラー: alpaca_cleaned_ja.json が見つかりません。")
    print("スクリプトと同じディレクトリにデータセットファイルを配置してください。")
    exit()

# データセットをシャッフルし、10,000件をランダムに抽出
print("データセットをシャッフルし、10,000件をランダムに抽出します...")
dataset = dataset.shuffle(seed=42).select(range(10000))

# データセットをOAIメッセージ形式に変換
dataset = dataset.map(create_conversation, remove_columns=dataset.features, batched=False)
# データセットを訓練用とテスト用に分割
dataset = dataset.train_test_split(test_size=0.02)

print("データセットの準備が完了しました。")
print("訓練データサンプル数:", len(dataset["train"]))
print("テストデータサンプル数:", len(dataset["test"]))
print("整形されたユーザープロンプトの例:")
print(dataset["train"][0]["messages"][0]["content"])


# ==============================================================================
# 4. TRL と SFTTrainer を用いたファインチューニング
# ==============================================================================

# --- 4.1. モデルとトークナイザーのロード ---
print("モデルとトークナイザーのロードを開始します...")

model_id = "google/gemma-3-270m-it" # 事前学習済みモデルのID

# GPUがbfloat16をサポートしているか確認
if torch.cuda.is_available() and torch.cuda.get_device_capability()[0] >= 8:
    torch_dtype = torch.bfloat16
    print("bfloat16 を使用します。")
else:
    torch_dtype = torch.float16
    print("float16 を使用します。")

# BitsAndBytesConfig: 4ビット量子化を有効にする
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type='nf4',
    bnb_4bit_compute_dtype=torch_dtype,
)

# モデルをロード
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=quantization_config,
    device_map="auto",
    attn_implementation="eager" # Flash Attentionが使えない場合
)

# トークナイザーをロード
tokenizer = AutoTokenizer.from_pretrained(model_id)

print("モデルとトークナイザーのロードが完了しました。")


# --- 4.2. LoRA の設定 ---
print("LoRAの設定を作成します...")
peft_config = LoraConfig(
    lora_alpha=16,
    lora_dropout=0.05,
    r=16,
    bias="none",
    target_modules="all-linear",
    task_type="CAUSAL_LM",
)
print("LoRAの設定が完了しました。")


# --- 4.3. ハイパーパラメータの設定 ---
print("トレーニングのハイパーパラメータを設定します...")
args = SFTConfig(
    output_dir="gemma-japanese-chat",      # 保存ディレクトリとリポジトリID
    max_length=512,                         # 最大シーキンス長
    packing=True,                           # 複数のサンプルを1つのシーケンスにまとめる
    num_train_epochs=3,                     # トレーニングエポック数
    per_device_train_batch_size=1,          # バッチサイズ
    gradient_accumulation_steps=4,          # 勾配累積ステップ数
    gradient_checkpointing=True,            # メモリ節約のための勾配チェックポイント
    optim="adamw_torch_fused",              # 最適化アルゴリズム
    logging_steps=10,                       # ログ出力のステップ間隔
    save_strategy="epoch",                  # チェックポイントの保存戦略
    learning_rate=2e-4,                     # 学習率
    fp16=(torch_dtype == torch.float16),
    bf16=(torch_dtype == torch.bfloat16),
    max_grad_norm=0.3,                      # 最大勾配ノルム
    warmup_ratio=0.03,                      # ウォームアップ比率
    lr_scheduler_type="constant",           # 学習率スケジューラ
    push_to_hub=False,                      # Hubにプッシュしない場合はFalse
    report_to="tensorboard",                # メトリクスをTensorBoardに報告
    dataset_kwargs={
        "add_special_tokens": False,
        "append_concat_token": True,
    }
)
print("ハイパーパラメータの設定が完了しました。")


# --- 4.4. トレーニングの開始 ---
print("SFTTrainerを初期化します...")
trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=dataset["train"],
    peft_config=peft_config,
    tokenizer=tokenizer, # processing_classではなくtokenizerを渡す
)

print("トレーニングを開始します...")
trainer.train()
print("トレーニングが完了しました。")

print("最終モデルを保存します...")
trainer.save_model()
print("モデルの保存が完了しました。")


# ==============================================================================
# 5. モデルのテストと推論
# ==============================================================================
print("\n推論セッションを開始します...")

# メモリを解放
del model
del trainer
torch.cuda.empty_cache()

# --- 5.1. 応答の生成 ---
# PEFTアダプター付きでモデルをロード
print("ファインチューニング済みモデルをロードします...")
model_id_for_inference = "gemma-japanese-chat" # args.output_dir と同じ
model = AutoModelForCausalLM.from_pretrained(
  model_id_for_inference,
  device_map="auto",
  torch_dtype=torch_dtype,
)
tokenizer = AutoTokenizer.from_pretrained(model_id_for_inference)
print("モデルのロードが完了しました。")


# text-generationパイプラインの作成
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

# テストデータセットからランダムなサンプルをロード
rand_idx = randint(0, len(dataset["test"])-1)
test_sample = dataset["test"][rand_idx]

# プロンプトを作成
prompt = pipe.tokenizer.apply_chat_template(
    test_sample["messages"][:1],
    tokenize=False,
    add_generation_prompt=True
)

print("\n--- 推論サンプル ---")
print(f"質問:\n{test_sample['messages'][0]['content']}")
print(f"\n元の応答:
{test_sample['messages'][1]['content']}")

# 応答を生成
print("\nモデルによる応答を生成中...")
outputs = pipe(
    prompt,
    max_new_tokens=256,
    do_sample=True,
    temperature=0.7,
    top_k=50,
    top_p=0.95
)

# 結果の表示
generated_text = outputs[0]['generated_text'][len(prompt):].strip()
print(f"\n生成された応答:
{generated_text}")
print("\n--- 推論完了 ---")

参考・引用