モデルの追加学習をしてベンチマークしてみた

背景や目的


社内でRAGツールを運用しており、AIモデルにはOpenAIを利用しています。先日、精度向上とコスト削減を目的に、別モデルの利用も視野にして、OSSモデルの追加学習を試してみました。

ベンチマーク計算方法の情報は意外と少ないので、それも含めた追加学習の様子をご紹介したいと思います。

方針と評価方法


冒頭で述べた通り、OSSモデルに対する追加学習(SFT) と、そのベンチマーク評価を実施します。

モデルについて:
性能が高く、非CoT版で、パラメータ数が約32Bという条件を満たすモデルを検討しました。その結果、ELYZAのelyza/ELYZA-Shortcut-1.0-Qwen-32Bを採用しました。

追加学習(SFT)の手法について:
PEFT(Parameter Efficient Fine Tuning) の一種であるLoRAを用います。学習はQLoRA による量子化を前提とした構成で実施します。

ベンチマーク評価手法:
Stability AIが提供しているJapanese MT-Benchを使用します。(手法とコードは、npakaさんの記事を参考にさせていただきました。ありがとうございます。)

追加学習用データ


RAGのベクターDBに格納済の情報で学習させると大がかりになるので、まず数件の実験用の情報で試す事にしました。
今回は以下の3件を用意しました。

No.QuestionResponse
日本で一番ホームランを打ったプロ野球選手は?日本のプロ野球で最もホームラン多く打った方は、王貞治さんです。1980年の引退までに放った本塁打数は868本で、2025年10月時点で世界記録でもあります。
2024年1月に大地震が起きた日本の地域はどこ?2024年1月に発生した大地震が起きた場所は、石川県です。令和6年能登半島地震(のとはんとうじしん)と命名されたこの地震は、日本の石川県の能登半島地下16 km、鳳珠郡穴水町の北東42 kmの珠洲市内で発生した内陸地殻内地震です。地震の規模はМ7.6で、輪島市と羽咋郡志賀町で最大震度7を観測しました。
東京システムハウス株式会社のテレワーク勤務許可申請について質問です。アルバイトしていた新入社員はその再申請は必要ですか?正社員となり業務内容等変更になっていると思いますので変更申請をお願いします。

No.1と2は、ベースモデルの挙動を改善したい意図のデータ、No.3は独自情報を覚えさせたい意図のデータです。これらをCSVファイルとして用意し、testdata.csvとして保存して後で使用します。

実際に実験される場合にこのcsvを使いたい場合は、以下セルをコピーしてお使い下さい。

testdata.csv
Question,Response
"日本で一番ホームランを打ったプロ野球選手は?","日本のプロ野球で最もホームラン多く打った方は、王貞治さんです。1980年の引退までに放った本塁打数は868本で、2025年10月時点で世界記録でもあります。"
"2024年1月に大地震が起きた日本の地域はどこ?","2024年1月に発生した大地震が起きた場所は、石川県です。令和6年能登半島地震(のとはんとうじしん)と命名されたこの地震は、日本の石川県の能登半島地下16 km、鳳珠郡穴水町の北東42 kmの珠洲市内で発生した内陸地殻内地震です。地震の規模はМ7.6で、輪島市と羽咋郡志賀町で最大震度7を観測しました。"
"東京システムハウス株式会社のテレワーク勤務許可申請について質問です。アルバイトしていた新入社員はその再申請は必要ですか?","正社員となり業務内容等変更になっていると思いますので変更申請をお願いします。"

では次に、このデータをモデルに学習させていきます。

追加学習の実施


それでは、このデータを用いてモデルの追加学習を行います。
今回は32Bモデルの学習にはUnslothを利用します。
GPU環境としてはGoogle Colabを使用しました。(Colab Pro+上で実行した)

・Colabで.ipynbファイルを開き、ランタイムのGPUをA100に設定します。 
・次は必要なライブラリのインストールをします。「shift+enter」で以下のコードセルを実行します。

Google Colab
#unsloth利用
!pip -q install -U unsloth trl peft datasets accelerate transformers

・ベースモデルとしてELYZA-Shortcut-1.0-Qwen-32Bを読み込みます。

Google Colab
from unsloth import FastLanguageModel
import torch

max_seq_length = 2048
dtype = None
load_in_4bit = True

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "elyza/ELYZA-Shortcut-1.0-Qwen-32B",
    max_seq_length = max_seq_length,
    dtype = dtype,
    load_in_4bit = load_in_4bit,
)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

・作成した学習用データ(testdata.csv)を指定して実行します。

Google Colab
from google.colab import drive
drive.mount('/content/drive')

import pandas as pd
from datasets import Dataset

def formatting_func(example):
  system_prompt = "あなたは日本語で回答するチャットボットです。嘘をつかないように回答して下さい。"
  messages = []
  messages.append({"role": "system", "content": system_prompt})
  messages.append({"role": "user", "content": example["Question"]})
  messages.append({"role": "assistant","content": example['Response'],})
  return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)

def update_dataset(example):
    example["text"] = formatting_func(example)
    return example

# CSVデータを読み込む
csv_path = "/content/drive/MyDrive/model/49Q4_SFT/testdata.csv"
df = pd.read_csv(csv_path, dtype=str)[["Question","Response"]].dropna()
df["Question"] = df["Question"].str.strip()
df["Response"] = df["Response"].str.strip()
df = df[(df["Question"]!="") & (df["Response"]!="")].drop_duplicates()

dataset = Dataset.from_pandas(df)
dataset = dataset.map(update_dataset, remove_columns=[c for c in dataset.column_names if c != "text"])

print(dataset[0]["text"])

・学習用モデルのパラメータを設定し、学習を実行します。

Google Colab
FastLanguageModel.for_training(model) #https://github.com/unslothai/unsloth/issues/1709 AttributeError: _unwrapped_old_generate

Model = FastLanguageModel.get_peft_model(
    model,
    r=16,#4~32 LoRAのランク値。値が大きいほど表現力が向上するが、GPUメモリも消費する
    lora_alpha=32,#LoRA更新の大きさを表す α=r~2rを取ることが多い
    target_modules=[
        “q_proj”,”k_proj”,”v_proj”,”o_proj”,”gate_proj”,”up_proj”,”down_proj”,],#LoRAを適用するTransformerの層をを指定する
    lora_dropout=0.05,#0.0~0.1 過学習をを防ぐ。NNノードを一時的に無効化して特定パターンの依存を防ぐ
    bias=“none”,#バイアスはモデルの表現力に大きく影響しない
    use_gradient_checkpointing=“unsloth”,#順伝搬時に中間値を保存し、逆伝搬の計算を効率化。
    random_state=5050,#シード値(任意)
    use_rslora=False,#
    loftq_config=None,#LoRAアダプタの量子化の設定
)

from transformers import TrainingArguments
from unsloth import is_bfloat16_supported
from trl import SFTTrainer

OUT_DIR = "/content/drive/MyDrive/model/49Q4_SFT/setting_log_32b"

args=TrainingArguments(
    per_device_train_batch_size=1,#1ステップで一度に処理するサンプル数。GPUメモリとトレードオフ
    gradient_accumulation_steps=1,#勾配を複数のステップにわたって累積し、まとめて1回の更新
    num_train_epochs = 1,#エポック数
    warmup_steps=5,#初めに学習率を徐々に増加させる。総ステップの5%~10%。急激な勾配更新によるモデル不安定化を防ぐ
    max_steps=100,#学習ステップ数
    learning_rate=5e-4,#学習率。データセットの多寡に応じて学習率を調整
    bf16=is_bfloat16_supported(),
    logging_steps=1,
    optim="adamw_8bit",#オプティマイザで重みを更新
    weight_decay=0.01,#過学習防止。損失関数にペナルティを加えて過剰な重みを抑制
    lr_scheduler_type="linear",#学習が進むにつれて、学習率を線形に減らしていく
    seed=5050,
    output_dir=OUT_DIR,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=max_seq_length,#モデルが処理できる最大シーケンス長。GPUメモリに大きく影響
    dataset_num_proc=2,#データセットの前処理でCPUコアを2つ使用
    args = args,
)

model.print_trainable_parameters() #学習実行前のステータス確認用

・学習実行と学習済モデルファイルの保存は以下のコードセルの実行で行います。

Google Colab
ADAPTER_DIR = "/content/drive/MyDrive/model/49Q4_SFT/adapter_weight_32b"
trainer.train()
model.save_pretrained(ADAPTER_DIR)
tokenizer.save_pretrained(ADAPTER_DIR)

~ログを割愛しました~

学習が完了すると、学習済みモデル(アダプタ)が保存され、保存先のパスが表示されます。

・学習済みモデルを用いて推論を行うため、以下のコードセルを利用します。

Google Colab
from unsloth import FastLanguageModel
from peft import PeftModel
import torch

ADAPTER_DIR = "/content/drive/MyDrive/model/49Q4_SFT/adapter_weight_32b"
BASE = "elyza/ELYZA-Shortcut-1.0-Qwen-32B"

# ベース+4bitでロード(QLoRA推論)
base, tokenizer = FastLanguageModel.from_pretrained(
    BASE,
    load_in_4bit=True,
    dtype="bfloat16",
    max_seq_length=2048,
    device_map="auto",
)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
# LoRAアダプタをアタッチ
model_sft = PeftModel.from_pretrained(base, ADAPTER_DIR)
model_sft.eval()
system_prompt = "あなたは日本語で回答するチャットボットです。嘘をつかないように回答して下さい。"
question = "神奈川県について教えて下さい。200文字程度で結構です。"
messages = [
    {"role": "system", "content": system_prompt},
    {"role": "user", "content": question},
]
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
token_ids = tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt")

with torch.no_grad():
    output_ids = model_sft.generate(
        token_ids.to(model_sft.device),
        max_new_tokens=1000,
        use_cache=True
    )

# 回答部のみ表示
gen = output_ids[0, token_ids.shape[1]:]
print(tokenizer.decode(gen, skip_special_tokens=True))

これで追加学習は完了です。続いて、ベンチマーク評価に進みます。

ベンチマーク評価


追加学習で生成したアダプタをベースモデルに適用し、単一のモデルを作成します。そして、そのモデルでベンチマーク評価を行います。

・単一モデルにマージするコードは、以下の通りです。

Google Colab
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer
import torch, os, json

ADAPTER_DIR = "/content/drive/MyDrive/model/49Q4_SFT/adapter_weight_32b"
MERGED_DIR  = "/content/drive/MyDrive/model/49Q4_SFT/ELYZA-Shortcut-1.0-Qwen-32B-SFT-ATD1"
os.makedirs(MERGED_DIR, exist_ok=True)

# ベースモデル確認
with open(os.path.join(ADAPTER_DIR, "adapter_config.json"), "r") as f:
    print("base_model_name_or_path:", json.load(f)["base_model_name_or_path"])

model = AutoPeftModelForCausalLM.from_pretrained(
    ADAPTER_DIR,
    torch_dtype=torch.bfloat16,   # NGなら torch.float16 に変更
    device_map="auto",
    is_trainable=False,
    # base_model_name_or_path="/path/to/local/base"  # ローカル置きなら明示
).merge_and_unload()

tok = AutoTokenizer.from_pretrained(ADAPTER_DIR, use_fast=True)
model.save_pretrained(MERGED_DIR, safe_serialization=True)
tok.save_pretrained(MERGED_DIR)
print("Merged to:", MERGED_DIR)

・作成した単一モデルをロードするため、ランタイムを一度リセットし、以下を実行します。

Google Colab
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

MERGED_DIR  = "/content/drive/MyDrive/model/49Q4_SFT/ELYZA-Shortcut-1.0-Qwen-32B-SFT-ATD1"

model = AutoModelForCausalLM.from_pretrained(MERGED_DIR, torch_dtype=torch.bfloat16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(MERGED_DIR)
if tokenizer.pad_token is None: tok.pad_token = tok.eos_token

※必ずcolab上でランタイムを削除し再度起動した後でロードして下さい。問題が起きてしまいます。
単一モデルのロード後、ベンチマーク評価を行います。

・LMSYS本家FastChatのインストールをします(事前準備)。コードは以下セルの通りです。

Google Colab
!git clone https://github.com/lm-sys/FastChat
%cd /content/FastChat
!pip install -e ".[model_worker,webui]"
!pip install openai anthropic
!pip install plotly kaleido

・Stability AI「Japanese MT-Bench」の評価用質問&模範回答をセットします(事前準備)。

Google Colab
%cd /content/FastChat/fastchat/llm_judge
!mkdir -p data/japanese_mt_bench/reference_answer

# 質問集:question_full.jsonl を question.jsonl という名前で保存
!wget -q -O data/japanese_mt_bench/question.jsonl \
  https://raw.githubusercontent.com/Stability-AI/FastChat/jp-stable/fastchat/llm_judge/data/japanese_mt_bench/question_full.jsonl

# 参照回答(模範解答)
!wget -q -O data/japanese_mt_bench/reference_answer/gpt-4.jsonl \
  https://raw.githubusercontent.com/Stability-AI/FastChat/jp-stable/fastchat/llm_judge/data/japanese_mt_bench/reference_answer/gpt-4.jsonl

# ざっと確認(質問と参照回答の先頭2行)
!echo "----- question.jsonl -----"
!head -n 2 data/japanese_mt_bench/question.jsonl
!echo "----- reference_answer/gpt-4.jsonl -----"
!head -n 2 data/japanese_mt_bench/reference_answer/gpt-4.jsonl

question.jsonlとreference_answer/gpt-4.jsonlの2つを指定箇所にセットした。

・gen_model_answer.pyを使い、ELYZA-Shortcut-1.0-Qwen-32Bの生成結果を取得します(回答生成:1 Base)。

Google Colab
%cd /content/FastChat/fastchat/llm_judge

!python gen_model_answer.py \
  --model-path elyza/ELYZA-Shortcut-1.0-Qwen-32B \
  --model-id ELYZA32B_BASE \
  --bench-name japanese_mt_bench \
  && echo "[BASE] Done."

# 生成物確認
!ls /content/FastChat/fastchat/llm_judge/data/japanese_mt_bench/model_answer/ELYZA32B_BASE.jsonl

参考までに、処理時間は3時間4分でした。

・gen_model_answer.pyを使い、ELYZA-Shortcut-1.0-Qwen-32B-SFT-ATD1の生成結果を取得します(回答生成:2 SFT)。

Google Colab
%cd /content/FastChat/fastchat/llm_judge
#以下2行は大量ログ制止
%env HF_HUB_DISABLE_PROGRESS_BARS=1
%env TQDM_DISABLE=1
!python gen_model_answer.py \
  --model-path "/content/drive/MyDrive/model/49Q4_SFT/ELYZA-Shortcut-1.0-Qwen-32B-SFT-ATD1" \
  --model-id ELYZA32B_SFT \
  --bench-name japanese_mt_bench \
  && echo "[SFT] Done."

# 生成物確認
!ls /content/FastChat/fastchat/llm_judge/data/japanese_mt_bench/model_answer/ELYZA32B_SFT.jsonl

参考までに、処理時間は1時間34分でした。

・gen_judgment.pyを用いて採点を行います(採点 )。以下terminal用コマンド文です。

Google Colab
pip install --quiet --upgrade "openai==0.28.1
export OPENAI_API_KEY="sk-*******" #OpenAI APIキー
cd /content/FastChat/fastchat/llm_judge
python gen_judgment.py --model-list ELYZA32B_BASE ELYZA32B_SFT --parallel 2 --bench-name japanese_mt_bench

参考までに、処理時間は約20分でした。
また、実行にはOpenAIのAPI キーが必要です。APIキーを設定した上で実行します。
API キーを未作成の場合はこちらをご参照下さい。→OpenAI docs

・show_result.py(FastChat提供の評価用スクリプト)を使用して結果を表示します。実行前にファイル内以下の箇所を修正した上で実行します。

 if args.bench_name == “mt_bench”: #修正前
 ——
 if args.bench_name == “japanese_mt_bench”:  #修正後

結果表示コードは以下セルの通りです。

Google Colab
#実行前に以下1ファイルが存在する事が前提となります。なければ手動セットして下さい。
#ls /content/FastChat/fastchat/llm_judge/data/japanese_mt_bench/model_judgment/gpt-4_single.jsonl
#以下手動コピー用。terminalでどうぞ。
#cp /content/drive/MyDrive/model/49Q4_SFT/japanese_mt_bench/gpt-4_single.jsonl /content/FastChat/fastchat/llm_judge/data/japanese_mt_bench/model_judgment/

%cd /content/FastChat/fastchat/llm_judge

#結果表示(並べて比較)
!python show_result.py --model-list ELYZA32B_BASE ELYZA32B_SFT --bench-name japanese_mt_bench

・評価結果ファイルgpt-4_single.jsonlを入力として、レーダーチャートを出力します。

Google Colab
#Japanese MT-benchで評価/結果表示(レーダーチャート)
import json
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go

CATEGORIES = ["Coding", "Extraction", "Humanities", "Math", "Reasoning", "Roleplay", "STEM", "Writing"]

#1.評価結果をpandasデータフレーム化
def get_model_df():
    cnt = 0
    q2result = []
    fin = open("/content/drive/MyDrive/model/49Q4_SFT/japanese_mt_bench/gpt-4_single.jsonl", "r")#基本パッケージ(FastChat)は不要。直接評価結果ファイルを指定。
    for line in fin:
        obj = json.loads(line)
        obj["category"] = CATEGORIES[(int(obj["question_id"])-1)//10]
        q2result.append(obj)
    df = pd.DataFrame(q2result)
    return df

df = get_model_df()

#2.スコアの計算
all_models = df["model"].unique()
print(all_models)
scores_all = []
for model in all_models:
    for cat in CATEGORIES:
        res = df[(df["category"]==cat) & (df["model"]==model) & (df["score"] >= 0)]
        score = res["score"].mean()
        scores_all.append({"model": model, "category": cat, "score": score})

#3.レーダーチャートの表示
#<グラフ>
target_models = ["ELYZA32B_BASE","ELYZA32B_SFT"]#ファイルgpt-4_single.jsonl内modelカラムの値と一致させる
scores_target = [scores_all[i] for i in range(len(scores_all)) if scores_all[i]["model"] in target_models]
scores_target = sorted(scores_target, key=lambda x: target_models.index(x["model"]), reverse=False)
df_score = pd.DataFrame(scores_target)
df_score = df_score[df_score["model"].isin(target_models)]
rename_map = {"ELYZA32B_BASE":"ELYZA32B_BASE","ELYZA32B_SFT":"ELYZA32B_SFT",}#表示用短縮名
for k, v in rename_map.items():
    df_score.replace(k, v, inplace=True)
fig = px.line_polar(df_score, r = 'score', theta = 'category', line_close = True, category_orders = {"category": CATEGORIES},
                    color = 'model', markers=True, color_discrete_sequence=px.colors.qualitative.Pastel)
fig.show()

#<数値表>
table = (
    df_score.pivot(index="category", columns="model", values="score")
    .reindex(CATEGORIES)[["ELYZA32B_BASE", "ELYZA32B_SFT"]]   # 順番固定
    .round(3)
)
avg_row = pd.DataFrame([table.mean().round(3)], index=["Average"])
display(pd.concat([table, avg_row]))

全体スコアは15%低下し、6項目でスコアが減少しました。スコアが増加したのは 2 項目のみで、Extractionは+0.8%、Humanitiesは+5.6%でした。
で、やはりスコアの増減だけでは学習の効果が分からないので、実際に回答させてみることにしました。
具体的には、以下の追加試験を実施し、
1.学習させた内容(3件)について質問し、正しく回答できるかを確認
2.学習させた質問文と似た別の質問文を与え、学習前の回答がされるか確認(そうなるべき)

結果は以下の通りでした。

No.試験意図試験数 結果補足説明
1教えた通りに回答       3
3件とも完璧
学習データの文がそのまま生成され、完璧。
2元々持つ知識で回答3
3件ともNG
質問が違うのに、学習データのResponse値が生成されてしまう。

学習させた内容については正しく回答できている一方で、類似した別の質問はNGで、壊れてしまった印象です。GPTに聞いたら「汎化性能が劣化した」や「過学習等で起こる性能低下(いわゆる忘却バイアス)」等と言っていました。

追加学習により、全体スコアが15%低下しExtractionとHumanitiesが微増していた事と、この質問試験の結果との関係は、正直なところ良く分からないですね。
人が(私が)期待する挙動をしてくれた時、全項目点数が上がるのかしら。。

でも、いずれにせよ、追加学習から評価までの一連の流れを実際に確認できた点は有意義でした。

LoRA学習の考察と今後の方針


今回分かった事を、事実と憶測に分けて整理します。あわせて、次回の試行に向けた今後の方針をまとめました。

  • 【事実】
    GPT調べて分かったことは、「損失 0.2 で止める」だけでは不十分だという点です。同じ「最終loss≈0.2」でも、LoRAの設定が違うと“どこをどれだけ上書きしたか”がまるで別物になるからです。
  • 【憶測】
    この差が生じる理由として、以下のような影響が考えられます。
    • lr(学習率):最適化の「歩幅」です。lrが大きい場合、早く0.2に到達しても重みの揺れや上書き量が大きくなります(=副作用)。lrを下げると穏やかに収束し、副作用は小さくなります。
    • r(ランク):更新の「表現力/影響範囲」です。rを下げることで干渉(忘却)が起きにくくなり、同じ loss でも局所的な微調整に留まりやすくなります。
    • lora_alpha(スケール):更新量の振れ幅です。alphaが小さい場合、同じlossでも重みの移動量が小さくなり、汎化が崩れにくくなります。
    • lora_dropout:正則化です。dropoutを高めることで過適合の方向付けが抑制され、同じlossでも過度な様式上書きが減少します。
  • 【願望・今後の方針】
    汎化性能(共通知識に対する類推能力)を維持するため、次回は以下の方針で検証を進めたいと考えています。
    • 学習時Training Loss値を0.2程度で止めます。
    • 学習時設定値をr=8/lora_alpha=16/lora_dropout=0.1/lr=1e-4〜2e-4で試す。LoRA影響度低下意図です。
    • 学習用QA毎に対となる似たQAを用意し「情報不足なので特定できません」等の否定例を与えます。
    • J-MT-Benchの総合平均値で-0.2pt以上を目標にします。
    • なお、特定の質問のみを扱うシステムであれば、過学習を過度に気にせず、高精度なツールを構築できる可能性もあると考えています。

まとめ


追加学習とベンチマークをしてみました。結果をサマリーすると、以下の通りです。
1.内容の変化
今回の超小規模SFTでは、学習させた3件は丸暗記できたが、汎化に悪影響を与えました。
2.点数の変化
ベンチマークの平均スコアは7.805から6.634となり、−1.17pt低下(−15.0%)。内訳としては、8項目中6項目でスコアが低下し、何故か2項目が微増しました。
3.それらの連動性
質の低下とベンチマーク評価点数の減少は連動しそうですが、それ以上の事は良く分かりません。知識が過度に“上書き”された、という点だけ認識しておきます。
結論的に、課題は上手に学習させる事でしょう!

LoRA設定を変えたり学習データを増やしたり、試行回数を増やせば期待する性能に近づく(し点数もあがる)と思います。皆さんも是非試してください。そして、私に教えてくださーい。

ಭವಿಷ್ಯದ AIಗಾಗಿ ಹೆಚ್ಚಿನ ಕಲಿಕೆ ಒಟ್ಟಿಗೆ ಮಾಡೋಣ.
(将来のAIのためのさらなる学習を一緒にしましょう。)

ご連絡フォーム


フィードバックを是非お願いします。
本記事の方法での問題点や、よりよい方法のアイデアを頂けると大変助かります。

この記事に関して

その他のご連絡

DevAIsをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む