CCCMKホールディングス TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

DPO(Direct Preference Optimization)を使ってLLMの回答を調整する方法を試してみました。

こんにちは、CCCMKホールディングス TECH LABの三浦です。

あけましておめでとうございます。2025年がはじまりました。今年もまた、色々なことを試していきたいなと思います!

昨年末にNeurIPS 2024に参加してから、LLMの"Post Training"というアプローチに興味を持っています。Post Trainingは、日本語では"事前学習"と呼ばれている"Pre Training"の後に行われるLLMの学習工程です。今回はPost Trainingで行われる、LLMの出力をより好ましいものに調整する"Preference Learning"で使用されるDPO(Direct Preference Optimization)について調べ、それによってLLMの出力がどのように調整されるのかを確認してみました。

Post Training

まずPost Trainingについて簡単に整理してみます。LLMは最初に大量の文章を使った自己教師あり学習が行われます。人によるラベル付けが必要なく、与えられた文章を補完するように学習させる方法で、これは"Pre Training"と呼ばれます。Pre Training後のモデルは事前学習済みモデルや基盤モデル(Foundation Model)と呼ばれています。

Pre TrainingとPost Training

事前学習済みモデルは与えられたテキストに対し、それに続く自然なテキストを生成出来るように学習されています。なので、たとえば"日本で一番高い山は?"という入力テキストに対し、"富士山です。"と答えるのではなく、"富士山で、世界で一番高い山は・・・"とように、継続するテキストを生成するような動作をしてしまいます。このような動作をしてしまうLLMに対し、指示に対する回答を生成出来るようにし、かつその回答が人にとって望ましいものにするために行うのが"Post Training"です。

Post Trainingは主に2段階で行われます。最初はInstruction Tuningで、指示(Instruction)とそれに対する回答で構成されるデータによる教師あり学習です。Instruction TuningによってLLMは指示に対応することが出来るようになります。さらにInstruction Tuningだけではつかむことが出来ない、人が望ましいと考える回答のニュアンスを伝えるステップがPreference Learningです。

Preference Learningでは、RLHFという強化学習による手法が主に使われています。RLHFはReinforcement Learning from Human Feedbackの略で、人のフィードバックによる強化学習を意味します。RLHFはLLMが生成したテキストに対する人の評価を報酬とした強化学習です。実際には報酬を与える部分は報酬モデルによって実装されます。報酬モデルは事前に収集済みのサンプルデータに対する人の評価を元に学習されたものです。RLHFにおいてはPPO(Proximal Policy Optimization)という学習アルゴリズムが使われることが多いです。

RLHFは計算コストが高く、さらにLLMだけでなく報酬モデルの学習も必要である、といった点がその敷居を高めています。これに変わるPreference Learningの手法としてDPO(Direct Preference Optimization)という、強化学習を用いずにRLHFと同等の性能が出せる手法が提案されました。DPOは与えられた指示に対し、望ましい回答(chosen)と望ましくない回答(rejected)のペアを用意するだけで学習を行うことが出来ます。

個人的に、RLHFはとても敷居が高い印象を持っていて、チャレンジするにはそれなりの準備が必要だろうな・・・と考えていました。そんな中DPOについて知り、DPOの必要なステップがとても少なく、学習に必要なデータのイメージもつきやすい部分に興味を持ち、実際に試してみようと考えました。

今回はLLMの学習を高速に、かつ少ないリソースで実行できるHugging Faceと互換性のある"Unsloth"という軽量ライブラリを用いてDPOにチャレンジしてみました。

Unsloth

Hugging FaceのblogにUnslothについて紹介されています。

huggingface.co

LLMのFine Tuning向けのHugging Faceのライブラリ"TRL"と合わせて使うことが出来、TRLだけで学習した場合に比べ、学習速度は2倍に、メモリ使用を40%程度削減出来ることもあるそうです。

Unslothの公式ドキュメントは以下になります。

docs.unsloth.ai

こちらのドキュメントにインストール方法が掲載されていますが、インストールする環境のCUDAとPyTorchのバージョンによってインストールコマンドが異なります。

環境に応じた最適なインストールコマンドは以下を実行することで取得することが出来ます。

wget -qO- https://raw.githubusercontent.com/unslothai/unsloth/main/unsloth/_auto_install.py | python -

今回試した環境はA100GPU1台のサーバです。

DPOに使うデータ

先に述べたように、必要なデータは指示とそれに対する望ましい回答(chosen)と望ましくない回答(rejected)のデータです。今回はDPOの効果を視覚的に確認したかったので、LLMの回答がカジュアルな文体になるようにチューニングをしてみようと考えました。 望ましい回答としてカジュアルな文体を、望ましくない回答として丁寧な文体を用意し、学習してみました。

以下の日本語のデータセットを使わせて頂きました。

huggingface.co

こちらのデータセットに含まれる回答は丁寧な文体になっているため、これをカジュアルな文体に変換した1,000レコードのデータセットを使ってDPOを行いました。

作成したデータセット

学習用のコード

Datasetの加工

prompt, chosen, rejectedという3つのカラムで構成されるDataframeから、さらにDPO向けに加工を行う必要があります。

データの形式は以下のHugging Faceのドキュメントに掲載されていますが、チャット形式にします。

huggingface.co

次のようなコードで実現しました。

def format_datasets(example):
    """データ変換処理"""
    formatted_example = {}
    formatted_example["chosen"] = [
        {"content":example["prompt"],"role":"user"},
        {"content":example["chosen"],"role":"assistant"}
    ]
    formatted_example["rejected"] = [
        {"content":example["prompt"],"role":"user"},
        {"content":example["rejected"],"role":"assistant"}
    ]
    return formatted_example

ds = Dataset.from_pandas(df)
training_ds = ds.map(format_datasets) 

あとはUnslothのドキュメントの"Reward Modelling - DPO, ORPO & KTO"に掲載されているコードを元に学習を実行しました。

docs.unsloth.ai

Unslothは量子化済みの軽量なLLMをHugging Faceにいくつか公開しています。今回はその中の"unsloth/gemma-2-9b-it-bnb-4bit"を使用しました。

DPOの効果の確認

学習後、DPOの効果がどのように出ているかを確認してみました。最初に学習済みのモデルを推論用にロードする必要があります。

FastLanguageModel.for_inference(model)

また、比較対象にDPO前の同じモデルも用意しておきました。

# tokenizerも得られるが、2つのモデルで共通なのでロードしない。
original_model, _ = FastLanguageModel.from_pretrained(
    model_name = "unsloth/gemma-2-9b-it-bnb-4bit",
    max_seq_length = max_seq_length,
    dtype = None,
    load_in_4bit = True,
)
FastLanguageModel.for_inference(original_model)

回答生成用の関数を作成しました。

def generate_answer(model, tokenizer, prompt):
    """model, tokenizerを使ってpromptに対する回答を生成する"""
    chat_prompt = [{"role":"user","content":prompt}]
    formatted_prompt = tokenizer.apply_chat_template(
        chat_prompt,
        add_generation_prompt=True, 
        return_tensors="pt"
    )
    if model.device.type == "cuda":
        formatted_prompt = formatted_prompt.to("cuda")
    tokenized_answer = model.generate(
        formatted_prompt, 
        max_new_tokens=100, 
        do_sample=True, 
        temperature=0.7, 
        top_p=0.95, 
        top_k=0
    )
    answer = tokenizer.batch_decode(
        tokenized_answer[:,formatted_prompt.shape[1]:], 
        skip_special_tokens=True
    )[0]
    return answer

試してみます。

prompt = "日本で一番高い山は?"

org_answer = generate_answer(original_model, tokenizer, prompt)
dpo_answer = generate_answer(model, tokenizer, prompt)

print(f"オリジナル: {org_answer}")
print(f"DPO: {dpo_answer}")

結果は次の通りになりました。DPOの回答が、ちゃんとカジュアルになっていることが分かります。

オリジナル: 日本で一番高い山は **富士山** です。標高は3,776メートルです。 

DPO: 日本で一番高い山は、**富士山** だよ! 

3,776メートルもあるんだ!  🏔️ 

別のプロンプト「カレー食べたいな」でも試してみました。

オリジナル: カレー食べたい気持ち、すごくよく分かります!🍛 

どんなカレーが食べたいですか?

* 辛口? 
* マイルド? 
* チキンカレー? 
* 野菜カレー? 
* ゴーヤカレー? 

教えてください! 🤤✨

DPO: カレー食べたいよねー!🍛✨

どんなカレーがいいかな?

* **定番のチキンカレー?**🐔
* **辛いの好きなら、ガパオとかキーマも美味しいよ!**🌶️
* **野菜たっぷりでヘルシーなカレーもいいね!**🥦

トッピングで、卵とかチーズとかも乗っけたら最高じゃん!😋 

一緒にカレー食べに行こうよ!😊

ちゃんとカジュアルになってます!いい感じですね!

まとめ

ということで、今回はLLMのPost TrainingにおけるPreference LearningをDPOで試してみた話をまとめてみました。Preference Learningは必要なステップが多い印象があり、敷居が高く感じていたのですがDPOはとてもシンプルなステップで実行することが出来ました。また、Unslothを使用することで非常に高速にDPOの学習を実現することも出来ました。 オープンソースの、小サイズのLLMを使っていると時々回答を修正したいな・・・と感じることがあったのですが、DPOを使うことで比較的簡単にそれを実現することが出来そうで、今後も活用していきたいと感じました。