見出し画像

小型LLMをQLoRAで「教師ありファインチューニング (SFT)」してみた

東京大学の松尾研究所LLMコミュニティで
小型LLMを
教師ありファインチューニング (SFT :Supervised Fine-Tuning)しようというハンズオン講座がありました。

非常に勉強になったので、
実装方法などをご紹介していきたいと思います。



なぜ、LLMを「教師ありファインチューニング」する必要があるのか?


実装に入る前に、そもそも、なぜ、教師ありファインチューニングを行う必要があるのか整理しておきます。

膨大なテキストデータを用いて事前学習された
大規模言語モデル(LLM)は、そのままでは、
ChatGPTのような人間の意図を汲んだ振る舞いはできません。

例えば、「東京の観光名所を具体的に3つ挙げてください」
といった要求には答えられません。

なぜなら、
事前学習では「次に来る単語の予測」が目的となっているからです。
事前学習済みのLLMは豊富な知識を持っていますが、
人間の指示に従って応答するよう最適化されていないのです。

そこで、教師ありファインチューニングが必要になってきます。

人間の「指示」と「理想的な応答」のペアから成る
データセットを用いてモデルを再学習させることで、
LLMに人間の指示に従って応答させることができるようになるのです。

ちなみに、こうした教師ありファインチューニングを
指示チューニングと呼びます。
(以降、指示チューニングといいます)

指示チューニングによって、
LLMが人間の意図や指示を理解し、それに応じた適切な応答を生成できるようになるのです。





実装の流れ

ここから実装に入りますが、
最初に、全体の流れを確認します。

大きく分けて、
❶準備フェーズ
❷学習フェーズ
❸推論フェーズ
に分かれます。
今回は、学習を効率的に進めるために、
QLoRAという技術を使います。(後述)

Googleコラボで実装してきます(GPUは無料のT4です)



講座と異なる点


ただ、ハンズオン講座の内容をなぞるのは面白くないので、
この記事では、自分なりにアレンジを加えました。
具体的には、
❶チューニングするモデル、❷学習データセットを変えています。

❶チューニングするモデル

講座では、Qwen2.5のinstructionモデル(指示チューニング済みモデル)を使っていましたが、
この記事では、大規模言語モデル研究開発センター(LLMC)のllm-3-1.8B(指示チューニング前)を使っています。

❷学習データセット

講座では、独自のデータセットを使っていましたが、
この記事では、Databricksが公開したDatabricks-dolly-15k-jaという指示チューニング用のデーターセットを使います。

Databricks-dolly-15k-jaは、
Databricksが公開したオープンソースの指示追従型データセットである「databricks-dolly-15k」を日本語に翻訳したデータセットです。
このデータセットは、LLMの指示チューニングによく使用されます。

例えば、以下のような指示・回答データがあります。
【指示】(instruction)
ヴァージン・オーストラリアが事業を開始したのはいつですか?
【回答】(response)
ヴァージン・オーストラリアは2000年8月31日、ヴァージン・ブルーとして2機の航空機で単一路線の運航を開始した。

モデルと学習データの違いにより、コードの方も講座とは少し変わってきています。



1️⃣準備フェーズ


ライブラリー準備・基本設定


🌀ライブラリーのインストール

!pip install peft
!pip install transformers
!pip install datasets
!pip install accelerate bitsandbytes evaluate trl

🌀ライブラリーのインポート

import torch
from torch import cuda, bfloat16
from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    BitsAndBytesConfig,
    HfArgumentParser,
    TrainingArguments,
    pipeline,
    logging
)
from datasets import load_dataset
from peft import LoraConfig, PeftModel
from trl import SFTTrainer


🌀基本パラメータの設定

基本パラメータとして、
使用するモデル、
チューニング後のモデル名、
outputディレクトリー名を設定します。

model_id = "llm-jp/llm-jp-3-1.8b"
peft_name = "llm-jp-3-1.8B-inst_L4_3ep"
output_dir = "output_neftune"



学習用データセットの準備


🌀データセットのダウンロードと刈り込み

huggingfaceからデータセットをダウンロードするとともに、
学習時間を考慮し、データの数を刈り込みます(少なくする)。

# データダウンロード
dataset = load_dataset("llm-jp/databricks-dolly-15k-ja")
print(dataset)

print出力すると、15,011データあることがわかります。

# 出力結果
DatasetDict({
    train: Dataset({
        features: ['instruction', 'context', 'response', 'category'],
        num_rows: 15011
    })
})

学習に時間がかかるため、
学習に使うのは最初の1,000データだけにします。

import pandas as pd
df = dataset["train"].to_pandas()[:1000]

# JSONファイルに保存
df.to_json(
    "/content/dataset.json",
    orient = "records",
    force_ascii = False,
    indent =4)

dataset = load_dataset("json",data_files="/content/dataset.json")
print(dataset)

print出力すると、1,000データになっていることがわかります。

# 出力結果
DatasetDict({
    train: Dataset({
        features: ['instruction', 'context', 'response', 'category'],
        num_rows: 1000
    })
})


🌀プロンプトテンプレートの設定

指示・回答の対話型データをLLMの入力形式に変換するためには、
対話をまとめて一つのテキストに変換する必要があります。
datasetから、「instruction」と「response」を抽出して、
各データをプロンプトに合わせた形式にします。

# 学習時のプロンプトフォーマットの定義
prompt = """
あなたは優秀なアシスタントです。指示に対して適切な回答を行なってください。
### 指示
{}
### 回答
{}"""


EOS_TOKEN = tokenizer.eos_token # トークナイザーのEOSトークン(文末トークン)
def formatting_prompts_func(examples):
    input = examples["instruction"] # 入力データ
    output = examples["response"] # 出力データ
    text = prompt.format(input, output) + EOS_TOKEN # プロンプトの作成
    return { "text" : text, } # 新しいフィールド "formatted_text" を返す
pass

# 各データにフォーマットを適用
dataset = dataset.map(
    formatting_prompts_func,
    num_proc= 4, # 並列処理数を指定
)

dataset



学習用モデルの設定

‼️QLoRAによる学習

小型LLMといっても、
今のモデルはパラメータ数が多いため、
普通に学習すると大量のメモリが必要となり、
学習に多大な時間がかかってしまいます。

そこで、学習を効率的に進めるため、
QLoRAを用います。

QLoRAとは、
量子化(Quantization)とLoRAという二つの手法を
組み合わせて
ファインチューニングさせる手法です。

ひとつずつ解説していきます。


量子化・LoRAを合わせたQLoRAで学習を効率的に


🌀量子化の設定

モデルのパラメータは数値になっていて、
一般的に、32ビットや16ビットの浮動小数点を使って
表現されることが多いです。
この数値表現が、
メモリの使用量に大きな影響を与えます。

ビット数が大きいほど高精度ではありますが、
その代わりに計算コストが大きくなってしまいます。
そこで、
量子化という技術を使って、
数値表現を8ビット整数や4ビット整数に変換し、
精度は下がるけれども、メモリの使用量を削減します。

量子化の設定はbitsandbytesライブラリのBitsAndBytsConfigで可能です。

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16
)


🌀モデルとトークナイザーの設定

LoRAに入る前に、
ここで、transformersからモデルとトークナイザーをダウンロードして、
必要な設定を行います。
なお、トークナイザーは、テキストをモデルが処理できる形式(トークン)に変換したり、その逆を行うツールです。

# モデルの設定
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    trust_remote_code=True,
    quantization_config=bnb_config, # 量子化
    device_map='auto',
    torch_dtype=torch.bfloat16,
)

# トークナイザーの設定
tokenizer = AutoTokenizer.from_pretrained(
    model_id,
    padding_side="right",
    add_eos_token=True # 入力テキストの最後に EOSトークン を自動的に追加します
)
if tokenizer.pad_token_id is None:
  tokenizer.pad_token_id = tokenizer.eos_token_id


🌀LoRAの設定

LoRAとは、
事前学習済みのモデルが持っているパラメータは固定したままで、
そこからの差分を、
パラメータ数の少ない二つの行列の積として学習することで
メモリ使用量を抑える技術です。

モデルは学習によって、パラメータが更新されます。
これを式で表すと、
更新後のパラメータ=元のパラメータ+差分となりますよね
この差分に着目し、
また、差分を小さな行列(低ランク行列)で表すことで
計算コストを抑えることができるのです。

LoRAの詳細は、以下のサイトが割と分かりやすいです。

https://www.ai-souken.com/article/ai-lora-explanation


LoRAは線形層に適用されるので、
まずは、モデルの線型層を取得します。

import bitsandbytes as bnb

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)
print(target_modules)

LoRAの設定はpeftライブラリのLoraConfigで可能です。

量子化もLoRAもconfigなんですよね。

peft_config = LoraConfig(
    r=8, # 差分行列のランク
    lora_alpha=16, # LoRA層の出力のスケールを調整するハイパラ
    lora_dropout=0.05,
    target_modules = target_modules,
    bias="none",
    task_type="CAUSAL_LM",
    modules_to_save=["embed_tokens"],
)


🌀学習パラメータの設定

bf16=True,  #BF16を使用した学習〜FP16よりBF16の方が安定する
per_device_train_batch_size=4,  
# 訓練時のバッチサイズ。GPUメモリが不足する場合は、値を小さくする。
gradient_accumulation_steps=16,
# 勾配累積のステップ数。この値を大きくすることで、擬似的にミニバッチのサイズを大きくすることができる。
optim="adamw_torch_fused",  # オプティマイザー
learning_rate=2e-4, # 学習率

eval_steps = 20
save_steps = 20
logging_steps = 20

training_arguments = TrainingArguments(
    bf16=True,
    per_device_train_batch_size=4, 
    gradient_accumulation_steps=16,
    num_train_epochs=3,
    optim="adamw_torch_fused",
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    weight_decay=0.01,
    warmup_steps=100,
    group_by_length=True,
    # report_to="wandb"
    report_to="none",
    logging_steps=logging_steps, 
    eval_steps=eval_steps, 
    save_steps=save_steps, 
    output_dir=output_dir, 
    save_total_limit=3, 
    push_to_hub=False,
    auto_find_batch_size=True # GPUメモリがオーバーフローさせないため
)



2️⃣学習フェーズ

🌀SFTrainerの設定

SFTTrainerは、教師ありファインチューニング(Supervised Fine-tuning)で学習するためのライブラリです。
このライブラリーでtrainerをセットします。
NEFTuneで学習の質を上げています。

NEFTuneは、LLMのEmbedding層にランダムなノイズを加えて学習させInstruction Tuningすることで性能を向上させられる手法です。

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset["train"],
    dataset_text_field="text",
    peft_config=peft_config,
    args=training_arguments,
    max_seq_length=1024,
    packing=False,
    neftune_noise_alpha=5, 
)

🌀学習の実行

%%time
model.config.use_cache = False 
trainer.train()
model.config.use_cache = True

# QLoRAモデルの保存
trainer.model.save_pretrained(peft_name)


T4で36分かかりました

3️⃣推論フェーズ


ここからは指示チューニングの効果を確認していきます。
モデルに指示を入れて、テキスト生成させましょう。

まずは、テキスト生成を実行する前に、
指示チューニングで使用していたGPUメモリをリセットしています。

import torch
torch.cuda.empty_cache() # GPUメモリをリセット

🌀モデルとトークナイザーのダウンロード・設定

%%time
# 量子化設定
bnb_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,
    trust_remote_code=True,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
)

# tokenizerの設定
tokenizer = AutoTokenizer.from_pretrained(
    model_id,
    padding_side="right",
    add_eos_token=True
)
# eos_token_id の設定
if tokenizer.eos_token_id is None:
    tokenizer.add_special_tokens({"eos_token": "<|endoftext|>"})  # 必要ならトークン追加
    tokenizer.eos_token_id = tokenizer.convert_tokens_to_ids("<|endoftext|>")

# pad_token_id の設定
if tokenizer.pad_token_id is None:
    tokenizer.pad_token_id = tokenizer.eos_token_id


🌀指示チューニングモデルの作成

チューニング前のモデルにチューニング済みのQLoRAのパラメータを足し合わせます。

pertライブラリを使います。

from peft import PeftModel
ft_model = PeftModel.from_pretrained(model, peft_name)


🌀指示チューニング後のモデルでテキスト生成する!

「時間管理能力をあげるには?」というプロンプトを入れます。

input_text = "時間管理能力をあげるには?"

prompt = f"""
### 指示
{input_text}
### 回答:
"""

# Tokenizerの呼び出しでattention_maskを生成
tokenized_input = tokenizer(prompt, add_special_tokens=False, return_tensors="pt", padding=True).to(model.device)

# 終端トークンを設定
eos_token_id = tokenizer.eos_token_id

# テキスト生成
with torch.no_grad():
    outputs = ft_model.generate(
        input_ids=tokenized_input["input_ids"],
        attention_mask=tokenized_input["attention_mask"],
        max_new_tokens=100,
        eos_token_id=eos_token_id,
        pad_token_id=tokenizer.pad_token_id,
        do_sample=False,
        repetition_penalty=1.2
    )[0]

# 出力のデコード
output = tokenizer.decode(outputs[tokenized_input["input_ids"].size(1):], skip_special_tokens=True)
print(output)

出力結果

1.スケジュール帳やカレンダーアプリを使って、重要なイベントの日付と時刻を記録する。
2.タスクリストを作る。
3.優先順位をつける。

モデルサイズが小さい割には、
まあまあの精度だと思います😆



4️⃣HuggingFaceへのモデルのアップロード

作ったモデルはHuggingFaceへアップロードしておくと、
何かと便利です。

from huggingface_hub import login
from google.colab import userdata
# HuggingFaceログイン
login(userdata.get('HF_TOKEN')) 

repo_name = "******/llm-jp-3-1.8B-inst_L4_3ep_2"

#トークナイザーをアップロード
tokenizer.push_to_hub(repo_name)
#モデルをアップロード
ft_model.push_to_hub(repo_name)


ここまでお読みいただきありがとうございました。


さいごに:小型LLMのファインチューニングを学ぶ意義など


OpenAIのCEO サム・アルトマンは、
「巨大AIモデルを用いる時代は終った」と語っているそうです。
その真意は分かりかねますが、
確かに最近は、巨大AIモデルについて、
コストの高さやセキュリティ問題など
さまざまな課題が指摘されていますよね。

そんな中、
注目を集めているのがオープンソースの小型LLMです。

代表的なモデルとして、
メタが開発したLlamaやアリババが開発したQwenがあります。

小型LLMは、大規模言語モデルの能力を維持しつつ、
効率性と実用性を重視した設計が特徴です。
オープンソースの小型LLMはコストが安く、
また、
クラウドを使わずローカル環境でデータ処理できるので、
セキュリティの観点でも優れています。

そして、何より、
オープンソースの小型LLMであれば、
今回ご紹介するように、
自由に「カスタマイズ」することができます。

これからの時代、
ChatGPTのような万能LLMに加え、
小型LLMによる自社専用・自分専用の特化型LLMも
流行っていく予感
がします。

こうした点で、
小型LLMをファインチューニングする方法を
学ぶ意義は大変大きいと感じています。


いいなと思ったら応援しよう!

Non
よろしければサポートお願いします! いただいたサポートはクリエイターとしての活動費に使わせていただきます!