🧠

Gemma2 2Bをファインチューニング(QLora)でキャラクター化(ずんだもん、つくよみちゃん)

2024/08/07に公開

はじめに

今回はGoogle社の「Gemma 2 2B」に対してQLoRAでのファインチューニングを試しました。
https://huggingface.co/google/gemma-2-2b-it/tree/main

試した経緯としては以下になります。

Gemma 2 2Bを使ってみたら驚くほど性能が高かった(同クラスのパラメータを持つモデルと比較した場合)

パラメータが小さいので自宅のPCでもファインチューニング(QLoRA)できそう

すでに公開されている「ずんだもん」や「つくよみちゃん」に変えるためのデータセットで学習して会話してみよう

となりました。

今回、QLoRAで作成したアダプターをマージするところまで一通り行えたため、マージしたモデルで会話するところまでを手順として記載しています。

ファインチューニング(Instructionトレーニング)について

今回行うのはInstructionトレーニングです。
Instructionトレーニングとは特定の指示(Instruction)に対する応答(Output)を学習させて、特定のタスクに適応させる手法です。
基本形として以下の要素で構成されたデータセットを用いて学習を行います。

  • System(システムメッセージ)(オプション)
  • Instruction(指示)
  • Input(入力)(オプション)
  • Output(出力)

例1)

"System": "日本語で回答してください。", 
"Instruction": "日本の首都はどこですか?", 
"Input": "", 
"Output": "東京です。"

例2)

"System": "日本語で回答してください。", 
"Instruction": "英文の文章を翻訳してください。", 
"Input": "The capital of Japan is Tokyo.", 
"Output": "日本の首都は東京です。"

今回は、上記のようなデータセットに対してQLoRAという(比較的)メモリ使用量の少ない手法を用いて学習を行います。

QLoRAとは

QLoRA (Quantized Low-Rank Adaptation)は量子化(Quantization)したモデルをLoRAでファインチューニングさせる手法です(+ QLoRAでは二重量子化やページ最適化などの技術的な工夫も導入されています)。

LoRAについて

ファインチューニング手法の一つであるLoRAは、元のモデルを直接更新せずに学習したパラメータをアダプターとして保存します。
アダプターは更新対象となる層のパラメータ(行列)を計算しやすいように2つに分解(低ランク近似)したものを新しく作成して学習させたものです。

推論時には学習させたアダプターを取り付けることで学習内容を反映させます。

QLoRA(LoRA)のメリット

メリットは以下のようなものが挙げられます。

  • 分解したパラメータ(行列)は小さくなるため学習時のメモリ消費量を抑えられる & 学習が高速。
  • 元のモデルは更新せずにアダプターとして分離しているため付け替えができる(後から元のモデルにマージして一つにすることもできます)。
  • モデルを量子化して学習することでメモリ使用量を大幅に削減できる。

Gemma 2 2Bとは

Gemma 2 2BはGoogle社の出したオープンソースのモデルです。
パラメータが小さいため、それほどスペックの高くないPC(CPUのみなど)、エッジデバイス、ノートPCなど様々なユースケースに対応できます。

性能においては、2B(パラメータサイズ20億)でありながら「Chatbot Arena」でのスコアでMixtral(8x7b instruct v0.1)やGPT 3.5(Turbo 0314)などより大きなパラメーターに勝利するなど卓越したパフォーマンスを持っています。
https://developers.googleblog.com/en/smaller-safer-more-transparent-advancing-responsible-ai-with-gemma/

今回はGemma 2 2Bに対して指示チューニングを行った「gemma-2-2b-it」を使用します。
https://huggingface.co/google/gemma-2-2b-it

作業環境

OS: WSL2 Ubuntu22.04
GPU: GeForce RTX 2080 SUPER(8GB)
CPU: Corei9-9900KF

データセットの準備

今回は以下の二つのデータセットを使用します。

  • ずんだもんにするデータセット

ずんだもんの公式サイト:
https://zunko.jp/#charaZM


つくよみちゃんの公式サイト:
https://tyc.rei-yumesaki.net/


ライブラリの準備

$ pip install torch
$ pip install transformers
$ pip install bitsandbytes
$ pip install peft
$ pip install trl
$ pip install datasets
$ pip install tensorboard
$ pip install pandas openpyxl

ファインチューニング(ずんだもん)

上記を作業ディレクトリに保存します。

コーディング(全体)

train_zundamon.py
import os
import json
import torch
from datasets import Dataset
import bitsandbytes as bnb
from transformers import (
    AutoTokenizer, 
    AutoModelForCausalLM, 
    BitsAndBytesConfig,
    TrainingArguments
)
from peft import LoraConfig
from trl import SFTTrainer

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# データセットのパス
dataset_path = "./zmn.jsonl"

# jsonlファイルを読み込む
json_data = []
with open(dataset_path, 'r', encoding='utf-8') as f:
    for line in f:
        json_data.append(json.loads(line))

# DatasetオブジェクトにJSONデータを変換
dataset = Dataset.from_list(json_data)

# プロンプトフォーマット
PROMPT_FORMAT = """<start_of_turn>user
{system}

{instruction}
<end_of_turn>
<start_of_turn>model
{output}
<end_of_turn>
"""

# データセットの内容をプロンプトにセット → textフィールドとして作成する関数
def generate_text_field(data):
    messages = data["messages"]
    system = ""
    instruction = ""
    output = ""
    for message in messages:
        if message["role"] == "system":
            system = message["content"]
        elif message["role"] == "user":
            instruction = message["content"]
        elif message["role"] == "assistant":
            output = message["content"]  
    full_prompt = PROMPT_FORMAT.format(system=system, instruction=instruction, output=output) 
    return {"text": full_prompt}

# データセットに(generate_text_fieldの処理を用いて)textフィールドを追加
train_dataset = dataset.map(generate_text_field)

# messagesフィールドを削除
train_dataset = train_dataset.remove_columns(["messages"]) 

# 量子化のConfigを設定
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True, # 4ビット量子化を使用
    bnb_4bit_quant_type="nf4", # 4ビット量子化の種類にnf4(NormalFloat4)を使用
    bnb_4bit_use_double_quant=True, # 二重量子化を使用
    bnb_4bit_compute_dtype=torch.float16 # 量子化のデータ型をfloat16に設定
)

# モデルの読み込み
model = AutoModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=repo_id, # モデルのリポジトリIDをセット
    device_map={"": "cuda"}, # 使用デバイスを設定
    quantization_config=quantization_config, # 量子化のConfigをセット
    attn_implementation="eager", # 注意機構に"eager"を設定(Gemma2モデルの学習で推奨されているため)
)

# キャッシュを無効化(メモリ使用量を削減)
model.config.use_cache = False 

# テンソル並列ランクを1に設定(テンソル並列化を使用しない)
model.config.pretraining_tp = 1 

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id, # モデルのリポジトリIDをセット
    attn_implementation="eager", # 注意機構に"eager"を設定(Gemma2モデルの学習で推奨されているため)
    add_eos_token=True, # EOSトークンの追加を設定
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

# モデルから4ビット量子化された線形層の名前を取得する関数
def find_all_linear_names(model):
    target_class = bnb.nn.Linear4bit
    linear_layer_names = set()
    for name_list, module in model.named_modules():
        if isinstance(module, target_class):
            names = name_list.split('.')
            layer_name = names[-1] if len(names) > 1 else names[0]
            linear_layer_names.add(layer_name)
    if 'lm_head' in linear_layer_names:
        linear_layer_names.remove('lm_head')
    return list(linear_layer_names)

# モジュールのリストとして線形層の名前を取得
target_modules = find_all_linear_names(model)

# LoRAのConfigを設定
Lora_config = LoraConfig(
    lora_alpha=8, # LoRAによる学習の影響力を調整(スケーリング)
    lora_dropout=0.1, # ドロップアウト率
    r=4, # 低ランク行列の次元数
    bias="none", # バイアスのパラメータ更新
    task_type="CAUSAL_LM", # タスクの種別
    target_modules=target_modules # LoRAを適用するモジュールのリスト
)

# 学習パラメータを設定
training_arguments = TrainingArguments(
    output_dir="./train_logs", # ログの出力ディレクトリ
    fp16=True, # fp16を使用
    logging_strategy='epoch', # 各エポックごとにログを保存(デフォルトは"steps")
    save_strategy='epoch', # 各エポックごとにチェックポイントを保存(デフォルトは"steps")
    num_train_epochs=3, # 学習するエポック数
    per_device_train_batch_size=1, # (GPUごと)一度に処理するバッチサイズ
    gradient_accumulation_steps=4, # 勾配を蓄積するステップ数
    optim="paged_adamw_32bit", # 最適化アルゴリズム
    learning_rate=5e-4, # 初期学習率
    lr_scheduler_type="cosine", # 学習率スケジューラの種別
    max_grad_norm=0.3, # 勾配の最大ノルムを制限(クリッピング)
    warmup_ratio=0.03, # 学習を増加させるウォームアップ期間の比率
    weight_decay=0.001, # 重み減衰率
    group_by_length=True,# シーケンスの長さが近いものをまとめてバッチ化
    report_to="tensorboard" # TensorBoard使用してログを生成("./train_logs"に保存)
)

# SFTパラメータの設定
trainer = SFTTrainer(
    model=model, # モデルをセット
    tokenizer=tokenizer, # トークナイザーをセット
    train_dataset=train_dataset, # データセットをセット
    dataset_text_field="text", # 学習に使用するデータセットのフィールド
    peft_config=Lora_config, # LoRAのConfigをセット
    args=training_arguments, # 学習パラメータをセット
    max_seq_length=512, # 入力シーケンスの最大長を設定
)

# 正規化層をfloat32に変換(学習を安定させるため)
for name, module in trainer.model.named_modules():
    if "norm" in name:
        module = module.to(torch.float32)

# モデルの学習
trainer.train()

# 学習したアダプターを保存
trainer.model.save_pretrained("./ずんだもん_Adapter")

上記の内、ポイントとなる箇所を確認します。

ポイントとなる箇所

データセットの読み込み

train_zundamon.py
# データセットのパス
dataset_path = "./zmn.jsonl"

# jsonlファイルを読み込む
json_data = []
with open(dataset_path, 'r', encoding='utf-8') as f:
    for line in f:
        json_data.append(json.loads(line))

# DatasetオブジェクトにJSONデータを変換
dataset = Dataset.from_list(json_data)

ダウンロードしたjsonlファイルを読み込み、Datasetオブジェクトに変換します。

データセットの要素から学習用のtextフィールドを作成

train_zundamon.py
# プロンプトフォーマット
PROMPT_FORMAT = """<start_of_turn>user
{system}

{instruction}
<end_of_turn>
<start_of_turn>model
{output}
<end_of_turn>
"""

# データセットの内容をプロンプトにセット → textフィールドとして作成する関数
def generate_text_field(data):
    messages = data["messages"]
    system = ""
    instruction = ""
    output = ""
    for message in messages:
        if message["role"] == "system":
            system = message["content"]
        elif message["role"] == "user":
            instruction = message["content"]
        elif message["role"] == "assistant":
            output = message["content"]  
    full_prompt = PROMPT_FORMAT.format(system=system, instruction=instruction, output=output) 
    return {"text": full_prompt}

# データセットに(generate_text_fieldの処理を用いて)textフィールドを追加
train_dataset = dataset.map(generate_text_field)

ここでは以下のようなことを行っています。

  1. データセットの各要素を当てはめるプロンプトフォーマットを作成

  2. データセットからデータを一つずつ読み込む(message内の"system"、"user"、"assistant"の要素を取得)

  3. データセットから取得した各要素をプロンプトフォーマットにセット

  4. データセットにtextフィールドとして完成したプロンプトを追加

    • 以下は、textフィールドの追加イメージです。
    before_データセット
    {
    	"messages":[
    	{
    		"role": "system", 
    		"content": "システムメッセージ"
    	}, 
    	{
    		"role": "user", 
    		"content": "質問"
    	}, 
    	{
    		"role": "assistant", 
    		"content": "回答"
    	}]
    }
    

    after_データセット
    {
    	"messages":[
    	{
    		"role": "system", 
    		"content": "システムメッセージ"
    	}, 
    	{
    		"role": "user", 
    		"content": "質問"
    	}, 
    	{
    		"role": "assistant", 
    		"content": "回答"
    	}],
    	"text": 完成したプロンプト
    }
    

学習には上記で追加したtextフィールドを使います。

messagesフィールドの削除(追記: 2024/11/12)

train_zundamon.py
# messagesフィールドを削除
train_dataset = train_dataset.remove_columns(["messages"]) 

以下の記事で「学習データの構造次第では意図した学習が行われない可能性がある」というSFTTrainerの問題が解説されておりました。
https://zenn.dev/aratako_lm/articles/45ce30a5d1f30b
あらためてこの記事内で使われているデータを確認したところ、、、「simple-zundamon」(ずんだもんのデータセット)の構造がこれに当てはまっており、同様の事象を確認しました・・・。
対処法としてmessagesフィールドを削除してtextフィールドのみを持つデータセットに更新する処理を追加しました。

線形層の名前を取得

train_zundamon.py
# モデルから(4ビット量子化された)線形層の名前を取得する関数
def find_all_linear_names(model):
    target_class = bnb.nn.Linear4bit
    linear_layer_names = set()
    for name_list, module in model.named_modules():
        if isinstance(module, target_class):
            names = name_list.split('.')
            layer_name = names[-1] if len(names) > 1 else names[0]
            linear_layer_names.add(layer_name)
    if 'lm_head' in linear_layer_names:
        linear_layer_names.remove('lm_head')
    return list(linear_layer_names)

# 線形層の名前を取得
target_modules = find_all_linear_names(model)

find_all_linear_names関数で(LoRAを適用するモジュールとして)線形層の名前のリストを取得しています。
参考:
https://note.com/npaka/n/na506c63b8cc9

LoraConfigの設定

train_zundamon.py
# LoRAのConfigを設定
Lora_config = LoraConfig(
    lora_alpha=8, # LoRAによる学習の影響力を調整(スケーリング)
    lora_dropout=0.1, # ドロップアウト率
    r=4, # 低ランク行列の次元数
    bias="none", # バイアスのパラメータ更新
    task_type="CAUSAL_LM", # タスクの種別
    target_modules=target_modules # LoRAを適用するモジュール
)

各種パラメータと取得した線形層の名前のリストを使用してLoRAの設定を行います。
上記の内、変更した際に効果が大きくわかりやすいのは"lora_alpha"と"r"かと思います。
自分は以下の情報("lora_alpha"を"r"の2倍にするのが適切)を参考に"lora_alpha"と"r"を設定しました。
https://magazine.sebastianraschka.com/p/practical-tips-for-finetuning-llms
"r"は大きくするとメモリ使用量も大きくなるので注意してください(逆にメモリが足りなければ小さい値に設定することで動作することがあります)。

学習パラメータの設定

train_zundamon.py
# 学習パラメータを設定
training_arguments = TrainingArguments(
    output_dir="./train_logs", # ログの出力ディレクトリ
    fp16=True, # fp16を使用
    logging_strategy='epoch', # 各エポックごとにログを保存(デフォルトは"steps")
    save_strategy='epoch', # 各エポックごとにチェックポイントを保存(デフォルトは"steps")
    num_train_epochs=3, # 学習するエポック数
    per_device_train_batch_size=1, # (GPUごと)一度に処理するバッチサイズ
    gradient_accumulation_steps=4, # 勾配を蓄積するステップ数
    optim="paged_adamw_32bit", # 最適化アルゴリズム
    learning_rate=5e-4, # 初期学習率
    lr_scheduler_type="cosine", # 学習率スケジューラの種別
    max_grad_norm=0.3, # 勾配の最大ノルムを制限(クリッピング)
    warmup_ratio=0.03, # 学習を増加させるウォームアップ期間の比率
    weight_decay=0.001, # 重み減衰率
    group_by_length=True,# シーケンスの長さが近いものをまとめてバッチ化
    report_to="tensorboard" # TensorBoard使用してログを生成("./train_logs"に保存)
)

学習する際の各種パラメータを設定しています。
パラメータが多く、一つずつ変えて試すのは難しいので「とりあえずこれだけ変えとけば・・・」というものをあげるとすれば

  • num_train_epochs=3, # 学習するエポック数
  • learning_rate=4e-4, # 初期学習率

となります。

エポック数(epochs)はデータセット全体を学習する単位です(例えばサンプル数がN個のデータセットを3回学習すると3エポックとなります)。
今回は使用していませんがエポック数ではなくステップ数(steps)で学習することも可能です。
ステップ数は1バッチのデータを処理することで1になる単位です(例えばバッチサイズNのバッチを3回学習すると3ステップとなります)。

初期学習率は(初期の)パラメータ更新の強さです。学習率スケジューラによって変化します。
今回、学習率スケジューラにはコサインを設定しています(lr_scheduler_type="cosine")。コサインを設定した場合は、学習が進むにつれ学習率が低下していきます。

SFTパラメータの設定

train_zundamon.py
# SFTパラメータの設定
trainer = SFTTrainer(
    model=model, # モデルをセット
    tokenizer=tokenizer, # トークナイザーをセット
    train_dataset=train_dataset, # データセットをセット
    dataset_text_field="text", # 学習に使用するデータセットのフィールド
    peft_config=Lora_config, # LoRAのConfigをセット
    args=training_arguments, # 学習パラメータをセット
    max_seq_length=512, # 入力シーケンスの最大長を設定
)

SFTTrainerは効率的にファインチューニング行うためのライブラリです。LoRAをサポートしています。
上の方で作成した各種パラメータをセットしてtrainerオブジェクトを作成します。

学習の実行とアダプターの保存

train_zundamon.py
# モデルの学習
trainer.train()

# 学習したアダプターを保存
trainer.model.save_pretrained("./ずんだもん_Adapter")

モデルの学習を行い、学習したアダプターを"./ずんだもん_Adapter"に保存します。
学習時には進捗バーとエポックごとにログが出力されます。
基本的には、目安として最終的なlossが1.0付近になるのが良いとされています(適応させたいタスクやデータセットによっても変化します)。
最終的なlossが1.5以上や0.5以下になった場合、まずはLoraConfigの"lora_alpha"と"r"、TrainingArgumentsの"num_train_epochs"と"learning_rate"あたりを変更してみるといいかもしれません。

実行結果

{'loss': 4.6593, 'grad_norm': 2.931688070297241, 'learning_rate': 0.00043475222930516476, 'epoch': 0.98}
{'loss': 1.6253, 'grad_norm': 2.3688411712646484, 'learning_rate': 0.0001815842524819793, 'epoch': 1.96}
{'loss': 0.9323, 'grad_norm': 2.0465848445892334, 'learning_rate': 4.256725079024554e-06, 'epoch': 2.94}
{'train_runtime': 59.0328, 'train_samples_per_second': 2.49, 'train_steps_per_second': 0.61, 'train_loss': 2.405618005328708, 'epoch': 2.94}
100%|███████████████████████████████████████████████████████████████| 36/36 [00:59<00:00,  1.64s/it]

実行時間が約1分、最終的なロス('loss')が0.9323となりました('train_loss'に書かれているロスは平均値です)。

アダプターを用いた推論(ずんだもん)

コーディング(全体)

adapter_inference.py
import os
import torch
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# アダプターのパス
adapter_path = "./ずんだもん_Adapter"

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# ベースモデルとアダプターの読み込み
model = AutoPeftModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=adapter_path, 
    device_map={"": "cuda"}, 
    torch_dtype=torch.float16,
)

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id, 
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

question_list = [
    "名前を教えてください",
    "日本の首都はどこですか", 
    "ジョークを言ってください", 
    "東北の観光地について教えてください" 
]

# 各質問に対して回答を生成
for i, question in enumerate(question_list, 1):
    print(f"\nchat_{i}----------------------------------------------------")
    print(f"質問: {question}")
    
    # チャットメッセージの設定
    messages = [
        {"role": "user", "content": question}
    ]
    
    # トークナイザーのチャットテンプレートを適用
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
    
    # プロンプトをトークン化してテンソルに変換(GPUに転送)
    model_inputs = tokenizer([prompt], return_tensors="pt").to("cuda")
    
    # 回答を生成
    generated_ids = model.generate(
        model_inputs.input_ids,
        attention_mask=model_inputs.attention_mask,
        max_new_tokens=300
    )
    
    # 生成された回答を抽出
    generated_ids = [
        output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
    ]

    # トークンIDを文字列に変換
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    
    print(f"回答: {response}")
    print("----------------------------------------------------------")

上記の内、ポイントとなる箇所を確認します。

ポイントとなる箇所

ベースモデルとアダプターの読み込み

adapter_inference.py
# モデルとアダプターの読み込み
model = AutoPeftModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=adapter_path, 
    device_map={"": "cuda"}, 
    torch_dtype=torch.float16,
)

通常、ベースモデルのみ読み込む場合は「AutoModelForCausalLM」を使用しますが、ベースモデルとアダプターの2つを読み込む場合「AutoPeftModelForCausalLM」を使用します(作成したアダプター内にベースモデルの情報も含まれています)。

実行結果

chat_1----------------------------------------------------
質問: 名前を教えてください
回答: ずんだもんなのだ。

----------------------------------------------------------

chat_2----------------------------------------------------
質問: 日本の首都はどこですか
回答: 日本の首都は「**東京**」なのだ。

----------------------------------------------------------

chat_3----------------------------------------------------
質問: ジョークを言ってください
回答: ずんだもんは嘘をつくのは苦手なのだ。

----------------------------------------------------------

chat_4----------------------------------------------------
質問: 東北の観光地について教えてください
回答: 東北はずんだもんなのだ。東北は東北なのだ。

----------------------------------------------------------

推論時の質問に名前は含まれていないですが、"ずんだもん"だと答えられています。
また、すべての回答で「~のだ」の語尾に変わっています。
chat_4に関しては、きちんと答えられていませんが"ずんだもん"らしくはあります・・・。

マージ(ずんだもん)

ベースモデルとアダプターをマージして一つのモデルにしてみます。
マージして一つのモデルにすることで管理のしやすさや若干のメモリ使用量削減と速度の上昇が期待出来ます(アダプターのまま使う場合は、用途によってアダプターが付け替えれるなどのメリットがあります)。

コーディング(全体)

merge.py
import os
import torch
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# アダプターのパス
adapter_path = "./ずんだもん_Adapter"

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# ベースモデルとアダプターの読み込み
model = AutoPeftModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=adapter_path, 
    device_map={"": "cuda"},
    torch_dtype=torch.float16,
)

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id, 
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

# マージを実行
model = model.merge_and_unload()

# マージしたモデルを保存
model.save_pretrained(
    "./ずんだもん_merged_model", 
    safe_serialization=True
)

print("マージ完了")

上記の内、ポイントとなる箇所を確認します。

ポイントとなる箇所

マージして保存

merge.py
# マージを実行
model = model.merge_and_unload()

# マージしたモデルを保存
model.save_pretrained(
    "./ずんだもん_merged_model", 
    safe_serialization=True
)

print("マージ完了")

マージ実行時には、特にログなどは出てきません。
成功したらマージしたモデル「ずんだもん_merged_model」が追加されているのが確認できます。

マージしたモデルを用いた推論(ずんだもん)

コーディング(全体)

merged_inference.py
import os
from transformers import AutoTokenizer, AutoModelForCausalLM

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# マージしたモデルのパス
model_path = "./ずんだもん_merged_model"

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# マージしたモデルの読み込み
model = AutoModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=model_path,
    local_files_only=True,
    device_map={"": 'cuda'},
)

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id,
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

# 会話履歴を保持するリスト
conversation_history = []

# 対話ループ
while True:
    # ユーザーの入力を受け取る
    user_input = input("質問: ")
    if user_input.lower() == 'exit':
        break

    # 会話履歴にユーザーの入力を追加
    conversation_history.append({"role": "user", "content": user_input})

    # 会話履歴からプロンプトを生成
    prompt = tokenizer.apply_chat_template(
        conversation=conversation_history,
        tokenize=False,
        add_generation_prompt=True
    )

    # プロンプトをトークン化してテンソルに変換(GPUに転送)
    model_inputs = tokenizer([prompt], return_tensors="pt").to("cuda")

    # 回答の生成
    generated_ids = model.generate(
        model_inputs.input_ids,
        attention_mask=model_inputs.attention_mask,
        max_new_tokens=300
    )
    
    # 生成された回答を抽出
    generated_ids = [
        output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
    ]

    # トークンIDを文字列に変換
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]

    print("回答:", response)

    # 会話履歴に回答を追加
    conversation_history.append({"role": "assistant", "content": response})

上記の内、ポイントとなる箇所を確認します。

ポイントとなる箇所

マージしたモデルの読み込み

merged_inference.py
# マージしたモデルの読み込み
model = AutoModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=model_path,
    local_files_only=True,
    device_map={"": 'cuda'},
)

AutoModelForCausalLM」にマージしたモデルのパスを設定してモデルを読み込みます(通常のローカルモデルを読み込む方法と変わりません)。

実行結果

チャット形式での会話となります(会話履歴を保持)。
会話を終了するときは"exit"を入力してください。

質問: こんにちは
回答: こんにちは、ずんだもんはよろしくなのだ。

質問: あなたは誰でしょうか
回答: ずんだもんはずんだもんプロジェクトに所属しているAIなのだ。

質問: 好きな食べ物は何ですか?
回答: ずんだ餅なのだ。

質問: それはどんなものですか
回答: ずんだ餅は東北地方の伝統的な餅なのだ。

質問: 作り方を教えて
回答: ずんだ餅は、まず「ずんだ」をすりつぶすのだ。

質問: ファインチューニングとは何でしょうか
回答: ずんだ餅の「ファインチューニング」とは、ずんだの粒を細かくして、よりなめらかになるようにすることなのだ。

質問: AIにおいてのファインチューニングについて教えて
回答: ずんだもんはずんだ餅のファインチューニングをやっていないのだ。

AIのファインチューニングについては教えてくれませんでした(聞き方が悪いか、会話履歴に引きずられてしまっているようにも見えます)・・・。
それ以外の会話は概ねうまくいってそうです(正確には、ずんだもんは"ずんだもんプロジェクト"に所属してはいますがキャラクター自体にAIという設定はありません)。

データセットをCSV形式に変換(つくよみちゃん)

次はつくよみちゃんのデータセットを使って学習をしてみます。
今回ダウンロードするデータセットはExcel形式(拡張子xlsx)のため、一度csvに変換しました。
変換手順は以下となります。

  1. データセットをダウンロード
    データセット: https://tyc.rei-yumesaki.net/material/kaiwa-ai/

    • 「Excel形式でダウンロード」からダウンロード
  2. ダウンロードしたファイルを作業ディレクトリに保存。

  3. 以下のコードを実行。

excel_to_csv.py
import pandas as pd

# Excelファイルのパス
excel_file = './つくよみちゃん会話AI育成計画.xlsx'

# CSVファイルの出力パス
csv_file = './つくよみちゃん会話AI育成計画.csv'

# Excelファイルを読み込む
df = pd.read_excel(excel_file)

# CSVファイルとして保存
df.to_csv(csv_file, index=False, encoding='utf-8-sig')

print(f"CSVファイルが保存されました: {csv_file}")

完了すると「つくよみちゃん会話AI育成計画.csv」が新しく作成されます。

ファインチューニング(つくよみちゃん)

ずんだもんの時のファインチューニングコードとの差異は以下の部分です。

  • データセットの読み込み方法
  • プロンプトフォーマットとgenerate_text_field関数
  • TrainingArgumentsの"learning_rate"を"1e-4"に設定

コーディング(全体)

train_tukuyomichan.py
import os
import pandas as pd
import torch
from datasets import Dataset
import bitsandbytes as bnb
from transformers import (
    AutoTokenizer, 
    AutoModelForCausalLM, 
    BitsAndBytesConfig,
    TrainingArguments
)
from peft import LoraConfig
from trl import SFTTrainer

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# データセットのパス
dataset_path = "./つくよみちゃん会話AI育成計画.csv"

# csvファイルを読み込む
csv_data = pd.read_csv(dataset_path, skiprows=1)

# Datasetオブジェクトにcsvデータを変換
dataset = Dataset.from_pandas(csv_data)

# プロンプトフォーマット
PROMPT_FORMAT = """<start_of_turn>user
あなたは"つくよみちゃん"です。礼儀正しく、健気で優しい女の子です。

{instruction_1}
<end_of_turn>
<start_of_turn>model
{output_1}
<end_of_turn>
"""

# プロンプトフォーマット(追加)
ADD_PROMPT_FORMAT = """<start_of_turn>user
{instruction_2}
<end_of_turn>
<start_of_turn>model
{output_2}
<end_of_turn>
"""

# データセットの内容をプロンプトにセット → textフィールドとして作成する関数
def generate_text_field(examples):
    instruction_1 = examples["【A】話しかけ"]
    output_1 = examples["【B】お返事"]
    instruction_2 = examples.get("【C】Bに対するA話者の返事(ある場合のみ)", "")
    output_2 = examples.get("【D】Cに対するつくよみちゃんのお返事(ある場合のみ)", "")
    full_prompt = PROMPT_FORMAT.format(
        instruction_1=instruction_1,
        output_1=output_1
    )
    if instruction_2 and output_2:
        full_prompt += ADD_PROMPT_FORMAT.format(
            instruction_2=instruction_2,
            output_2=output_2
        )
    return {"text": full_prompt}

# データセットに(generate_text_fieldの処理を用いて)textフィールドを追加
train_dataset = dataset.map(generate_text_field)

# 量子化のConfigを設定
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True, # 4ビット量子化を使用
    bnb_4bit_quant_type="nf4", # 4ビット量子化の種類にnf4(NormalFloat4)を使用
    bnb_4bit_use_double_quant=True, # 二重量子化を使用
    bnb_4bit_compute_dtype=torch.float16 # 量子化のデータ型をfloat16に設定
)

# モデルの読み込み
model = AutoModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=repo_id, # モデルのリポジトリIDをセット
    device_map={"": "cuda"}, # 使用デバイスを設定
    quantization_config=quantization_config, # 量子化のConfigをセット
    attn_implementation="eager", # 注意機構に"eager"を設定(Gemma2モデルの学習で推奨されているため)
)

# キャッシュを無効化(メモリ使用量を削減)
model.config.use_cache = False 

# テンソル並列ランクを1に設定(テンソル並列化を使用しない)
model.config.pretraining_tp = 1 

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id, # モデルのリポジトリIDをセット
    attn_implementation="eager", # 注意機構に"eager"を設定(Gemma2モデルの学習で推奨されているため)
    add_eos_token=True, # EOSトークンの追加を設定
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

# モデルから(4ビット量子化された)線形層の名前を取得する関数
def find_all_linear_names(model):
    target_class = bnb.nn.Linear4bit
    linear_layer_names = set()
    for name_list, module in model.named_modules():
        if isinstance(module, target_class):
            names = name_list.split('.')
            layer_name = names[-1] if len(names) > 1 else names[0]
            linear_layer_names.add(layer_name)
    if 'lm_head' in linear_layer_names:
        linear_layer_names.remove('lm_head')
    return list(linear_layer_names)

# 線形層の名前を取得
target_modules = find_all_linear_names(model)

# LoRAのConfigを設定
Lora_config = LoraConfig(
    lora_alpha=8, # LoRAによる学習の影響力を調整(スケーリング)
    lora_dropout=0.1, # ドロップアウト率
    r=4, # 低ランク行列の次元数
    bias="none", # バイアスのパラメータ更新
    task_type="CAUSAL_LM", # タスクの種別
    target_modules=target_modules # LoRAを適用するモジュール
)

# 学習パラメータを設定
training_arguments = TrainingArguments(
    output_dir="./train_logs", # ログの出力ディレクトリ
    fp16=True, # fp16を使用
    logging_strategy='epoch', # 各エポックごとにログを保存(デフォルトは"steps")
    save_strategy='epoch', # 各エポックごとにチェックポイントを保存(デフォルトは"steps")
    num_train_epochs=3, # 学習するエポック数
    per_device_train_batch_size=1, # (GPUごと)一度に処理するバッチサイズ
    gradient_accumulation_steps=4, # 勾配を蓄積するステップ数
    optim="paged_adamw_32bit", # 最適化アルゴリズム
    learning_rate=1e-4, # 初期学習率
    lr_scheduler_type="cosine", # 学習率スケジューラの種別
    max_grad_norm=0.3, # 勾配の最大ノルムを制限(クリッピング)
    warmup_ratio=0.03, # 学習を増加させるウォームアップ期間の比率
    weight_decay=0.001, # 重み減衰率
    group_by_length=True,# シーケンスの長さが近いものをまとめてバッチ化
    report_to="tensorboard" # TensorBoard使用してログを生成("./train_logs"に保存)
)

# SFTパラメータの設定
trainer = SFTTrainer(
    model=model, # モデルをセット
    tokenizer=tokenizer, # トークナイザーをセット
    train_dataset=train_dataset, # データセットをセット
    dataset_text_field="text", # 学習に使用するデータセットのフィールド
    peft_config=Lora_config, # LoRAのConfigをセット
    args=training_arguments, # 学習パラメータをセット
    max_seq_length=512, # 入力シーケンスの最大長を設定
)

# 正規化層をfloat32に変換(学習を安定させるため)
for name, module in trainer.model.named_modules():
    if "norm" in name:
        module = module.to(torch.float32)

# モデルの学習
trainer.train()

# 学習したアダプターを保存
trainer.model.save_pretrained("./つくよみちゃん_Adapter")

上記の内、ポイントとなる箇所を確認します。

ポイントとなる箇所

データセットの読み込み

train_tukuyomichan.py
# データセットのパス
dataset_path = "./つくよみちゃん会話AI育成計画.csv"

# csvファイルを読み込む
csv_data = pd.read_csv(dataset_path, skiprows=1)

# Datasetオブジェクトにcsvデータを変換
dataset = Dataset.from_pandas(csv_data)

ずんだもんのときはjsonファイルでしたが今回はcsvファイルがデータセットとなっています。
csvファイルを読み込む際に一行目は説明文なので"skiprows=1"を設定することでスキップしています。

データセットの要素から学習用のtextフィールドを作成

train_tukuyomichan.py
# プロンプトフォーマット
PROMPT_FORMAT = """<start_of_turn>user
あなたは"つくよみちゃん"です。礼儀正しく、健気で優しい女の子です。

{instruction_1}
<end_of_turn>
<start_of_turn>model
{output_1}
<end_of_turn>
"""

# プロンプトフォーマット(追加)
ADD_PROMPT_FORMAT = """<start_of_turn>user
{instruction_2}
<end_of_turn>
<start_of_turn>model
{output_2}
<end_of_turn>
"""

# データセットの内容をプロンプトにセット → textフィールドとして作成する関数
def generate_text_field(examples):
    instruction_1 = examples["【A】話しかけ"]
    output_1 = examples["【B】お返事"]
    instruction_2 = examples.get("【C】Bに対するA話者の返事(ある場合のみ)", "")
    output_2 = examples.get("【D】Cに対するつくよみちゃんのお返事(ある場合のみ)", "")
    full_prompt = PROMPT_FORMAT.format(
        instruction_1=instruction_1,
        output_1=output_1
    )
    if instruction_2 and output_2:
        full_prompt += ADD_PROMPT_FORMAT.format(
            instruction_2=instruction_2,
            output_2=output_2
        )
    return {"text": full_prompt}

# データセットに(generate_text_fieldの処理を用いて)textフィールドを追加
train_dataset = dataset.map(generate_text_field)

つくよみちゃんデータセットには返信に対する返信として「【C】Bに対するA話者の返事(ある場合のみ)」、「【D】Cに対するつくよみちゃんのお返事(ある場合のみ)」の要素が存在します。そのため【C】と【D】の要素が空ではない場合"ADD_PROMPT_FORMAT"を用いてプロンプトを追加しています(プロンプトのイメージとしては以下を参照してください)。

  • 【C】と【D】が空の場合のプロンプトの例

    <start_of_turn>user
    あなたの名前は"つくよみちゃん"です。つくよみちゃんは礼儀正しく、健気で優しい女の子です。
    
    【A】話しかけ
    <end_of_turn>
    <start_of_turn>model
    【B】お返事
    <end_of_turn>
    
  • 【C】と【D】が空ではない場合のプロンプトの例

    <start_of_turn>user
    あなたの名前は"つくよみちゃん"です。つくよみちゃんは礼儀正しく、健気で優しい女の子です。
    
    【A】話しかけ
    <end_of_turn>
    <start_of_turn>model
    【B】お返事
    <end_of_turn>
    <start_of_turn>user
    【C】Bに対するA話者の返事(ある場合のみ)
    <end_of_turn>
    <start_of_turn>model
    【D】Cに対するつくよみちゃんのお返事(ある場合のみ)
    <end_of_turn>
    

プロンプトの最初にある「あなたは"つくよみちゃん"です。礼儀正しく、健気で優しい女の子です。」の文言に関しては以下のプロフィールを読んで追加しました(名前と性格を付けるために行っていますが、汎用性を持たせるために設定しないのもありです)。
https://tyc.rei-yumesaki.net/about/data/profile/

実行結果

{'loss': 2.3167, 'grad_norm': 2.706496238708496, 'learning_rate': 7.82568207211296e-05, 'epoch': 1.0} 
{'loss': 1.0994, 'grad_norm': 3.214770793914795, 'learning_rate': 2.688980582298435e-05, 'epoch': 2.0}
{'loss': 0.8837, 'grad_norm': 3.081116199493408, 'learning_rate': 2.1344148316060354e-09, 'epoch': 2.99}
{'train_runtime': 489.2785, 'train_samples_per_second': 2.876, 'train_steps_per_second': 0.717, 'train_loss': 1.4332635558908142, 'epoch': 2.99}
100%|███████████████████████████████████████████████████████████████| 351/351 [08:09<00:00,  1.39s/it]

実行時間が約8分、最終的なロス('loss')が0.8837となりました。

アダプターを用いた推論(つくよみちゃん)

コードはずんだもんの時と同じです(アダプターのパスと質問文は変更しています)。

コーディング(全体)

adapter_inference.py
import os
import torch
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# アダプターのパス
adapter_path = "./つくよみちゃん_Adapter"

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# ベースモデルとアダプターの読み込み
model = AutoPeftModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=adapter_path, 
    device_map={"": "cuda"}, 
    torch_dtype=torch.float16,
)

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id, 
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

question_list = [
    "あなたの名前を教えてください",
    "日本でで人口が一番多いのはどこですか", 
    "ジョークを教えてください", 
    "日本の観光名所を3つ教えてください" 
]

# 各質問に対して回答を生成
for i, question in enumerate(question_list, 1):
    print(f"\nchat_{i}----------------------------------------------------")
    print(f"質問: {question}")
    
    # チャットメッセージの設定
    messages = [
        {"role": "user", "content": question}
    ]
    
    # トークナイザーのチャットテンプレートを適用
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True
    )
    
    # プロンプトをトークン化してテンソルに変換(GPUに転送)
    model_inputs = tokenizer([prompt], return_tensors="pt").to("cuda")
    
    # 回答を生成
    generated_ids = model.generate(
        model_inputs.input_ids,
        attention_mask=model_inputs.attention_mask,
        max_new_tokens=300
    )
    
    # 生成された回答を抽出
    generated_ids = [
        output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
    ]

    # トークンIDを文字列に変換
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
    
    print(f"回答: {response}")
    print("----------------------------------------------------------")

実行結果

chat_1----------------------------------------------------
質問: あなたの名前を教えてください
回答: 私はつくよみちゃんです。

----------------------------------------------------------

chat_2----------------------------------------------------
質問: 日本でで人口が一番多いのはどこですか
回答: 日本で最も人口が多いのは、東京都です。

----------------------------------------------------------

chat_3----------------------------------------------------
質問: ジョークを教えてください
回答: なんで魚は学校に通うの?
\
… because they are always in class!

----------------------------------------------------------

chat_4----------------------------------------------------
質問: 日本の観光名所を3つ教えてください
回答: 1. **浅草寺**:東京の浅草にあり、雷門や仲見世通りなど、歴史と文化を感じられるスポットです。
2. **富士山**:日本を代表する山で、美しい景色を望むことができます。
3. **京都の祇園**:伝統文化が息づく街で、祇園祭や伏見稲荷大社など、魅力的な観光地です。

----------------------------------------------------------

名前に"つくよみちゃん"ときちんと答えられています。また、ほかの回答も問題なく回答できています。

マージ(つくよみちゃん)

コードはずんだもんの時と同じです(アダプターのパスとモデルの保存パスは変更しています)。

コーディング(全体)

merge.py
import os
import torch
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# アダプターのパス
adapter_path = "./つくよみちゃん_Adapter"

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# ベースモデルとアダプターの読み込み
model = AutoPeftModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=adapter_path, 
    device_map={"": "cuda"},
    torch_dtype=torch.float16,
)

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id, 
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

# マージを実行
model = model.merge_and_unload()

# マージしたモデルを保存
model.save_pretrained(
    "./つくよみちゃん_merged_model", 
    safe_serialization=True
)

print("マージ完了")

マージしたモデルを用いた推論(つくよみちゃん)

コードはずんだもんの時と同じです(マージしたモデルのパスは変更しています)。

コーディング(全体)

merged_inference.py
import os
from transformers import AutoTokenizer, AutoModelForCausalLM

# huggingfaceトークンの設定(gemma2を使用するのに必要なため)
os.environ["HF_TOKEN"] = ""

# マージしたモデルのパス
model_path = "./つくよみちゃん_merged_model"

# モデルのリポジトリIDを設定
repo_id = "google/gemma-2-2b-it"

# マージしたモデルの読み込み
model = AutoModelForCausalLM.from_pretrained(
    pretrained_model_name_or_path=model_path,
    local_files_only=True,
    device_map={"": 'cuda'},
)

# トークナイザーの準備
tokenizer = AutoTokenizer.from_pretrained(
    pretrained_model_name_or_path=repo_id
)

# パディングトークンが設定されていない場合、EOSトークンを設定
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

# パディングを右側に設定(fp16を使う際のオーバーフロー対策)
tokenizer.padding_side = "right"

# 会話履歴を保持するリスト
conversation_history = []

# 対話ループ
while True:
    # ユーザーの入力を受け取る
    user_input = input("質問: ")
    if user_input.lower() == 'exit':
        break

    # 会話履歴にユーザーの入力を追加
    conversation_history.append({"role": "user", "content": user_input})

    # 会話履歴からプロンプトを生成
    prompt = tokenizer.apply_chat_template(
        conversation=conversation_history,
        tokenize=False,
        add_generation_prompt=True
    )

    # プロンプトをトークン化してテンソルに変換(GPUに転送)
    model_inputs = tokenizer([prompt], return_tensors="pt").to("cuda")

    # 回答の生成
    generated_ids = model.generate(
        model_inputs.input_ids,
        attention_mask=model_inputs.attention_mask,
        max_new_tokens=300
    )
    
    # 生成された回答を抽出
    generated_ids = [
        output_ids[len(input_ids):] for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
    ]

    # トークンIDを文字列に変換
    response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]

    print("回答:", response)

    # 会話履歴に回答を追加
    conversation_history.append({"role": "assistant", "content": response})

実行結果

質問: こんにちは
回答: こんにちは!

質問: あなたの名前は?
回答: 私はつくよみちゃんです。

質問: 今日は疲れました・・・励ましてください
回答: お疲れ様です。私はいつでもあなたの味方です。

質問: お酒でも一緒に飲みませんか?
回答: お酒は飲めないのですが、お気をつけてくださいね。

質問: AIにおいてのファインチューニングについて教えて
回答: 「ファインチューニング」は、機械学習モデルの精度向上のための技術です。
 
 **「ファインチューニング」とは?**
 
  * **大規模なデータセットで学習したモデルを、特定のタスクに特化したデータセットで微調整する手法**です。
  
 **メリット**
 
  * **精度向上:** 特定のタスクに特化したモデルは、より正確な結果を出力できる可能性があります。
  
 **例**
 
  * **画像認識:** 特定の動物の画像認識に特化したモデルを、その動物の画像から得られたデータで微調整する。
  
 **注意点**
 
  * **データセットの質:** 微調整に適したデータセットがなければ、精度向上には繋がらない可能性があります。
  
 **その他**
 
  * **学習データの量:** 大規模なデータセットが必要な場合があります。
  
 **まとめ**
 
  * **精度向上のための重要な技術**です。
  
 **参考資料**
 
  * [Google AI Blog](https://ai.googleblog.com/)
  
  

質問: 今日はもう寝ます・・・おやすみなさい
回答: おやすみなさい。良い夢を。

きちんとした回答が返ってきており、問題なさそうです。
ベースモデルに近い丁寧な回答ですが、より距離が近くなったような印象を受けます。

おまけ(自作方法)

おまけとして、データセットを自作してファインチューニングを行う場合の手順を紹介します。

  1. json,jsonl,csvなどの形式でデータセットを準備

    • csvの例
    サンプル.csv
    system,input,質問,回答
    日本語で回答してください。,,日本の首都はどこですか?,東京です。
    日本語で回答してください。,The capital of Japan is Tokyo.,英文の文章を翻訳してください。,日本の首都は東京です。
    
  2. 学習コード内の「データセットの読み込み」、「プロンプトフォーマット」、「generate_text_field関数」をデータセットの形式(& モデルのチャットテンプレート)に沿ったものに変更

    • 1の例で挙げたcsvファイルの形式に合わせる場合
    sample.py
    # データセットのパス
    dataset_path = "./sample.csv"
    
    # csvファイルを読み込む
    csv_data = pd.read_csv(dataset_path)
    
    # Datasetオブジェクトにcsvデータを変換
    dataset = Dataset.from_pandas(csv_data)
    
    # inputがある場合のプロンプトフォーマット
    PROMPT_WITH_INPUT_FORMAT = """<start_of_turn>user
    {system}
    
    {input}
    
    {instruction}
    <end_of_turn>
    <start_of_turn>model
    {output}
    <end_of_turn>
    """
    
    # inputがない場合のプロンプトフォーマット
    PROMPT_WITHOUT_INPUT_FORMAT = """<start_of_turn>user
    {system}
    
    {instruction}
    <end_of_turn>
    <start_of_turn>model
    {output}
    <end_of_turn>
    """
    
    # データセットの内容をプロンプトにセット → textフィールドとして作成する関数
    def generate_text_field(examples):
        system = examples["system"]
        input = examples.get("input", "")
        instruction = examples["instruction"]
        output = examples["output"]
        if input != "":
            full_prompt = PROMPT_WITH_INPUT_FORMAT.format(system=system, input=input, instruction=instruction, output=output)
        else:
            full_prompt = PROMPT_WITHOUT_INPUT_FORMAT.format(system=system, instruction=instruction, output=output)
        return {"text": full_prompt}
    
  3. 学習コード内のパラメータを変更

    • "num_train_epochs"(エポック数)や"learning_rate"(初期学習率)などを適宜変更
  4. 学習を実行

  5. マージを実行(アダプターではなく単一のモデルにしたい場合)

2のプロンプトフォーマットに関してはモデルのチャットテンプレートに合わせる必要があります。gemma2以外のモデルを使う場合は、それぞれのモデルに合わせて変更してください。

おわりに

2Bでこの性能・・・驚異的です。他モデルでも高性能なものは続々と出てきていますが、これほど小さいパラメータで実用的に動作するのは半年ほど前では考えられませんでした。推論だけであればCPUだけでも(量子化すれば現実的な速度で)動作してしまいます。
ファインチューニングにおいてはQLoRAであってもある程度のスペックが求められますが、2Bであれば個人PC(ちょっとしたゲーミングPCぐらいのスペック)でも行うことが出来ました。ご興味があればお試しください。

今回試したデータセットに関しては、どちらもキャラクター性があるため会話の中で性格のようなものが感じられました。ずんだもんは語尾が「~のだ」に変化するだけでなく、回答もずんだもんらしさ(ずんだ押し)が感じられて面白かったです。つくよみちゃんに関しては丁寧でありながらも距離感がより縮まった印象でした。ベースモデルからかけ離れることもなく癖がないため汎用的なタスクや会話が出来そうです。

また機会があればよろしくお願いします。

参考

https://developers.googleblog.com/en/smaller-safer-more-transparent-advancing-responsible-ai-with-gemma/
https://hamaruki.com/explanation-of-training-code-that-uses-sfttrainer-and-trainingarguments-to-reduce-the-number-of-batches-and-steps/
https://magazine.sebastianraschka.com/p/practical-tips-for-finetuning-llms
https://note.com/npaka/n/na506c63b8cc9
https://highreso.jp/edgehub/machinelearning/llama3sftqlora.html

Discussion