おはこんばんちわ!

今期中しか時間が取れない可能性があるのと、やりたいことがたくさん出てきてしまったので、立て続けてアップしてます。

今回の内容もあまり時間をかけずに準備したのでちょっと中途半端な内容となってしまいましたが。。。

今回はファインチューニングに挑戦します。

以前、DeepLearningのファインチューニングを挑戦した時にはかなり苦労しましたが、時代が進んだ今はだいぶ楽にできるようになってきたようです。

Googleからも複数のファインチューニングのチュートリアルが提供されていますし、紹介サイトも充実してきているので、それらの情報を拝借して挑戦してみたいと思います。

ハードウェアスペック

以前はGPUが必須でしたが今はCPUでのチューニングもできるようなので、今回は私のノートPCでやってみます。

ノートPCのスペックは以下の通り。

CPU:12th Gen Intel(R) Core(TM) i5-1235U (1.30 GHz)
メモリ:16.0 GB
GPU:なし

参考にしたサイト

今回、参考にしたサイトは以下の通りです。

準備

Gemmaを利用してチューニングするには以下の2つのアカウントと承認などが必要なようです。

Kaggleアカウント

Google以下のサイトにアクセスしてアカウントを作成します。

以下のURLからGemmaのアクセスリクエストを実施します。

https://www.kaggle.com/models/google/gemma

HuggingFaceアカウント

以下のURLからアクセスします。

https://huggingface.co/google/gemma-3-270m-it

以下の手順でトークンを作成します。

  1. Sign-onボタンをクリック
  2. ユーザ名とフルネームを入力してアカウントを作成
  3. 入力したメールアドレスにメールが来るので、リンクをクリック
  4. ログインして、ユーザアイコンをクリック
  5. settings ⇒ Access Tokens ⇒ +Create Access Token
  6. トークン名を入力してトークンを作成

続いてアクセスをリクエストします。

  1. もう一度、上記のURLに戻る
  2. Acknowledge licenseをクリック
  3. Authorizeボタンをクリック
  4. 「I accept the terms and conditions (Required) *」をチェックして、Acceptボタンをクリック

実行環境

今回はWSLで実行しました。

PythonやOllamaをインストールして環境を作成します。

VSCodeを利用していますが、構築手順は割愛します。

Ollama、Gemma3

sudo apt update && sudo apt upgrade -y
# Ollamaをインストール
sudo apt install -y zstd
curl -fsSL https://ollama.com/install.sh | sh
ollama --version
# Gemma3をPull
ollama pull gemma3
ollama list

Gemma3の動作確認はこんな感じで行いました。

ollama run gemma3 "今日の名古屋市の天気を教えてください"

Python

uvでPython環境を作ります。

Pythonは3.13.12を使用します。

# uvをインストール
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
echo 'eval "$(uv generate-shell-completion bash)"' >> ~/.bashrc
uv self update
# Pythonをインストール
uv python list
uv python install 3.13
uv python pin 3.13

プロジェクトフォルダを作成して初期化します。

mkdir finetuning-test
cd finetuning-test
uv init
uv venv

必要なライブラリをrequirements.txtに記載します。

torchvision
pandas
torch
tensorboard
transformers
datasets
accelerate
evaluate
trl
protobuf
sentencepiece
python-dotenv
tensorflow
tensorboardx
peft

以下のコマンドでライブラリをインストールします。

uv add -r requirements.txt

.envファイルに先ほど取得したHuggingFaceのトークンを設定します。

HF_TOKEN=<HuggingFaceのトークン>

実行ソース

以下のようなソースを作成しました。

Googleのチュートリアルを参考にしろちゃんさんの記事から部分的に拝借して作成しています。

最初にGoogleのチュートリアルの通りに作成したのですが、25件のCSVを処理するのに2時間かかったったので、しろちゃんさんのカスタムデータコレクターを使用したところ5分で終わりました。

関西弁チューニングが面白かったので今回のソースは関西弁チューニングを行っています。

import os
from huggingface_hub import login
from dotenv import load_dotenv
from datasets import Dataset
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from transformers import pipeline
from trl import SFTConfig
from trl import SFTTrainer
import pandas as pd

load_dotenv()
hf_token = os.getenv('HF_TOKEN')

# ログイン
login(hf_token)

# ベースモデル、チェックポイント ディレクトリ、学習率を設定
BASE_MODEL = "google/gemma-3-270m-it"
CHECKPOINT_DIR = "./checkpoint/kansaiben"
LEARNING_RATE = 5e-5
FINAL_MODEL_DIR = "./gemma3-kansaiben"
TEST_CASES = ["こんにちは!", "自己紹介をお願いします。", "ありがとうございます。"]

tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)
def tokenize(examples):
    tokenized = tokenizer(
        examples["text"],
        truncation=True,
        padding=False,
        max_length=512,
        return_tensors=None
    )
    tokenized["labels"] = tokenized["input_ids"].copy()
    return tokenized

data = pd.read_csv("kansaiben.csv")

def format_text(row):
    return f"<start_of_turn>user\n{row['instruction']}<end_of_turn>\n<start_of_turn>model\n{row['output']}<end_of_turn>"

texts = data.apply(format_text, axis=1).tolist()
dataset = Dataset.from_dict({"text": texts})
dataset = dataset.map(tokenize, batched=True, remove_columns=["text"])

# TRL と SFTTrainer を使用して Gemma をファインチューニングする
model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL,
    torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
    attn_implementation="eager"
)

print(f"Device: {model.device}")
print(f"DType: {model.dtype}")


# ファインチューニング前の状態を確認
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

for i, test_input in enumerate(TEST_CASES, 1):
    prompt = f"<start_of_turn>user\n{test_input}<end_of_turn>\n<start_of_turn>model\n"
    output = pipe(prompt, max_new_tokens=30, temperature=0.7, do_sample=True)
    response = output[0]['generated_text'].replace(prompt, "").split("<end_of_turn>")[0].strip()
    print(f"{i}. 入力: {test_input}")
    print(f"   応答: {response}")


# トレーニング設定
args = SFTConfig(
    output_dir="./output",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    save_strategy="epoch",
    logging_steps=10,
    remove_unused_columns=False,
    report_to=[],  # トラッキングを無効にする
    dataloader_pin_memory=False,
    fp16=False,   # use float16 precision
    bf16=False,  # use bfloat16 precision
)

# カスタムデータコレクター
def data_collator(features):
    # 各特徴量からinput_idsとlabelsを取得
    input_ids = [f["input_ids"] for f in features]
    labels = [f["labels"] for f in features]
    
    # バッチ内の最大長を取得
    max_length = max(len(ids) for ids in input_ids)
    
    # パディング処理
    batch_input_ids = []
    batch_attention_mask = []
    batch_labels = []
    
    for ids, lbls in zip(input_ids, labels):
        # パディング長を計算
        pad_length = max_length - len(ids)
        
        # input_idsをパディング
        padded_ids = ids + [tokenizer.pad_token_id] * pad_length
        
        # attention_maskを作成
        attention_mask = [1] * len(ids) + [0] * pad_length
        
        # labelsをパディング(パディング部分は-100で無視)
        padded_labels = lbls + [-100] * pad_length
        
        batch_input_ids.append(padded_ids)
        batch_attention_mask.append(attention_mask)
        batch_labels.append(padded_labels)
    
    # テンソルに変換
    return {
        "input_ids": torch.tensor(batch_input_ids, dtype=torch.long),
        "attention_mask": torch.tensor(batch_attention_mask, dtype=torch.long),
        "labels": torch.tensor(batch_labels, dtype=torch.long)
    }

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=dataset,
    data_collator=data_collator,
    processing_class=tokenizer
)

# トレーニングを開始 (所要時間5分)
trainer.train()
trainer.save_model(FINAL_MODEL_DIR)

# モデルの推論をテストする
model = AutoModelForCausalLM.from_pretrained(
    FINAL_MODEL_DIR,
    torch_dtype="auto",
    device_map="auto",
    attn_implementation="eager"
)
tokenizer = AutoTokenizer.from_pretrained(FINAL_MODEL_DIR)

# ファインチューニング後の状態を確認
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

for i, test_input in enumerate(TEST_CASES, 1):
    prompt = f"<start_of_turn>user\n{test_input}<end_of_turn>\n<start_of_turn>model\n"
    output = pipe(prompt, max_new_tokens=30, temperature=0.7, do_sample=True)
    response = output[0]['generated_text'].replace(prompt, "").split("<end_of_turn>")[0].strip()
    print(f"{i}. 入力: {test_input}")
    print(f"   応答: {response}")

実行結果

こんな感じでチューニングできました。

チューニング前

入力: こんにちは!
応答: こんにちは!何かお手伝いできることはありますか?

入力: 自己紹介をお願いします。
応答: はい、承知いたしました。

入力: ありがとうございます。
応答: ご返信ありがとうございます。何かお手伝いできることはありますか?

チューニング後

入力: こんにちは!
応答: まいど!元気しとるか?

入力: 自己紹介をお願いします。
応答: わい、いのまたいっつらってるで。わいも一緒にいけるか?

入力: ありがとうございます。
応答: ええなぁ、よろしゅう頼むわ。何頼むつもりや?

まとめ

処理時間を短縮したくてカスタムデータコレクターを利用しましたが、トレーニング損失の測定とかを比較をするとその差が明確になるかもしれません。

Googleのチュートリアルにトレーニング損失の測定例もあったので、参考に測定するといいかもしれませんね。

実は、LoRAを利用したチューニングについてもいろいろ試してみたのですが、まとまっていないので今回の内容からは除外しています。

簡単に紹介するとGoogleのチュートリアルのLoRA版、しろちゃんさんのサイトのカスタムデータコレクターを使用したLoRA版を試してみました。

やはり、カスタムデータコレクターを使用した場合は5分ぐらいで完了しますが、使用しないと7時間かかるようです。(途中でやめました)

いろいろやり残していますので、機会があったら記事にしたいと思います。