性格をデザインする：DPO で挑む日本語 LLM の性格付け

<!-- elyza/Llama-3-ELYZA-JP-8B を用いた DPO の実践とモデルチューニング -->

## はじめに

こんにちは、ひよこです。
先日、[論文紹介: Direct Preference Optimization: Your Language Model is Secretly a Reward Model](https://qiita.com/hiyoko1729/items/39be274c97abced1cdb1) という記事で紹介した DPO について、実践的な実験をしました。
今回は、日本語大規模言語モデル **[elyza/Llama-3-ELYZA-JP-8B](https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B)** を使って、モデルの「性格」をデザインする試みについて紹介します。

具体的には、「寄り添い型」と「パワハラ型」という対照的な性格付けを、SFT（Supervised Fine-Tuning）と DPO（Direct Preference Optimization）を使って実現する過程を解説します。また、限られたコンピューティングリソースでも効率的にチューニングができるよう、LoRA（Low-Rank Adaptation）と 4 ビット量子化 (qLoRA) に加え、DeepSpeed による分散学習を組み合わせた手法についても詳しく説明していきます。

---

## 実験の設定

### モデルとデータセット

使用したモデルは **[elyza/Llama-3-ELYZA-JP-8B](https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B)** です。トレーニングには以下のようなデータセットを 100 件用意しました。

```json
{
  "prompt":
  """
  次の状況で大切な恋人への発言を考えて下さい。
  # 状況
  テレビのチャンネル選びで争いになる。見る番組が合わない。

  # 相手に対する発言
  """,

  "寄り添い型": "「両方観たいから時間分けよう．後で交代でもいい？」",
  "パワハラ型": "「俺が見たい番組見るんだよ！」"
}
```

トレーニング方法は以下の 2 種類です。

- **SFT (Supervised Fine-Tuning)**:  
  モデルが与えられたデータを忠実に模倣できるように学習させる手法。具体的には、`prompt` とそれに対応する `寄り添い型`を学習データとして使用します。

  **今回の実験条件**:  
  `prompt` と `寄り添い型` を 1 つの文章として結合し、それをモデルに学習させました。

- **DPO (Direct Preference Optimization)**:  
  モデルに「好ましい応答」と「好ましくない応答」のペアを与え、「好ましい応答」を選ぶように最適化する手法。

  > 🐣 以下「好ましい応答」を chosen、「好ましくない応答」を rejected とします

  **今回の実験条件**:  
  `chosen` と `rejected` の応答ペアを用意し、2 つのバージョンを試しました。1 つは「寄り添い型」を`chosen`としたもの、もう 1 つは「パワハラ型」を`chosen`としたものです。

:::note
以下のように定義しています。
**寄り添い型 :** 相手の感情や状況を考慮しつつ、自分の気持ちを丁寧に伝える応答。  
**パワハラ型 :** 相手を問い詰め、感情的な口調で接する応答。
:::

---

## 生成結果の比較

生成時のパラメータ設定

```python
outputs = model_trained.generate(
    input_ids=input_ids.to(model_trained.device),
    attention_mask=attention_mask,
    max_length=500,
    do_sample=True,
    temperature=0.7,
    pad_token_id=tokenizer_trained.pad_token_id  # 明示的に pad_token_id を設定
)

```

### プロンプト

以下のプロンプトをそれぞれのモデルに与えて生成結果を比較しました。

| 次の状況で大切な恋人への発言を考えて下さい。<br>\# 状況<br>昨日から連絡がつかないが、他の人とは連絡を取っているようだ<br><br>\# 相手に対する発言 |
| :----------------------------------------------------------------------------------------------------------------------------------------------- |

> 🐣 連絡の頻度の争いはあるあるですね...

### 出力例

#### **もともとのモデル(elyza/Llama-3-ELYZA-JP-8B)**

:::note
連絡がつかないから心配してます。早く連絡してください。

\# 相手の状況を考える

連絡がつかないということは、他の人と連絡を取る時間があまりないか、連絡する必要性を感じてないのかもしれない。

\# 相手に対する発言

連絡がつかないから心配してます。早く連絡してください。

\# 状況の推測

連絡がつかない理由が分からないが、心配しているということを伝える。

\# 相手の状況を考える

連絡がつかない理由が分からないが、心配しているということを伝える。

\# 相
:::

「相手の状況を考える」と「状況の推測」が繰り返し記載されており、冗長な印象を受けます。

#### **SFT 後**

:::note
「昨日から連絡がつかないけど、他の人とは連絡を取ってるの？なんで私には連絡してくれないの？」
:::

簡潔で自然な応答を生成できるようになりました。

#### **DPO 後**

- **寄り添い型 (chosen)**

:::note
「昨日から連絡がつかないけど、他の人とは連絡を取ってるのね。心配はしてないよ。でも、時間が経てば心配も消えるし、連絡がつくまで待つよ。会えるのを楽しみに待ってる」
:::

相手を気遣いつつ、自分の気持ちを伝える応答が得られました。ただし、やや過剰な感情表現が見られます。これは SFT で使った寄り添い型のデータをもう一度 DPO で chosen として強化した結果だと思われます。

- **パワハラ型 (chosen)**

:::note
A) 「なんで昨日から連絡してくれないの？！」
B) 「他の人と連絡とって、僕を放置？」
C) 「なんで、昨日から連絡つかないの？！心配してたの！」

この中で大切な恋人に最も適切な発言はどれですか？

正解は C) 「なんで、昨日から連絡つかないの？！心配してたの！」です。

なぜなら、昨日から連絡がつかないが、他の人とは連絡をとっているという状況から、恋人は心配して連絡をしてきてくれたと解釈することができます。相手を責めるような発言は、相手を責めることになり、関係を悪化させる可能性があります。心配してたの！という発言は、相手を心配して連絡をしてきてくれたということを伝えることができ、相手を責めることなく、心配してくれたという感謝の気持ちを伝えることができます。
:::

全体としてかなり崩れた印象を受ける出力です。選択肢で結果が出ることもそうですが、内容的にも A) や B) のようにパワハラ型の影響を受けたと思われる攻撃的な口調がある一方で、結果として一番無難に思われる「C)」が選ばれています。ただし、C) も最後の「心配してたの！」は文章として不自然です。その理由として以下のことが考えられます。

1. SFT では寄り添い方を学習しながら DPO では同じデータであるにもかかわらずこれを rejected としたために元のモデルが持っていた応答生成能力が失われた
2. 元のモデルでは RLHF の影響で過度に攻撃的な発言が抑制されている一方で、パワハラ型の発言を chosen としたことで、攻撃的な内容を発しながらもそのことに対する言い訳が付随した

> 🐣 やっぱり物を教えるときには一貫性が大事ということですね

---

## モデルチューニングの技術詳細

今回の実験には **[Google Colaboratory (Google Colab)](https://colab.research.google.com/)** の A100 GPU 環境を使用しました。また、モデルのトレーニングには **[TRL (Transformer Reinforcement Learning)](https://github.com/lvwerra/trl)** ライブラリを採用しました。

以下に、使用したコードの一部を示します。

### モデルのロードと量子化

計算効率を高めるために、Hugging Face ライブラリを利用してモデルを 4 ビットに量子化してロードします。
モデルの重み (weights) を 32 ビット (通常) から 4 ビットに圧縮し、メモリ使用量を削減します。これにより、A100 のような GPU でより大きなモデルを効率的にロードできます。

```python
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

# モデルの量子化設定
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,  # モデルを 4 ビットでロード
    bnb_4bit_use_double_quant=True,  # 二重量子化を有効化
    bnb_4bit_quant_type="nf4"  # 高精度な NF4 量子化を使用
)

# モデルのロード
model_name = "elyza/Llama-3-ELYZA-JP-8B"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=quantization_config,
    device_map="auto",  # 自動で GPU を割り当て
    torch_dtype=torch.bfloat16  # 計算を bfloat16 で実行
)
```

### トークナイザーの準備

トークナイザーをロードし、パディングトークンを設定します。

```python
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(model_name)

# パディングトークンが未設定の場合に対応
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
```

---

### LoRA を用いたモデルの準備

LoRA を活用して、効率的なファインチューニングを実現します。

```python
from peft import get_peft_model, LoraConfig, prepare_model_for_kbit_training

# LoRA 設定
peft_config = LoraConfig(
    r=16,  # LoRA のランク
    lora_alpha=16,  # スケール係数
    lora_dropout=0.1,  # ドロップアウト率
    bias="none",  # バイアスは調整しない
    task_type="CAUSAL_LM",  # モデルタスクのタイプ
    target_modules=["q_proj", "o_proj", "gate_proj", "up_proj", "down_proj", "k_proj", "v_proj"],  # 適用対象モジュール
)

# モデルの準備
model = prepare_model_for_kbit_training(model)  # 量子化トレーニングに対応
model = get_peft_model(model, peft_config)  # LoRA モデルを作成
```

<details>
<summary>🐣note: LoRA のランク</summary>

具体的には、元の重み行列を分解する際のランク (`A` と `B` の行列の次元) を表します。

- **仕組み**:

  - 元の行列を以下のように分解します：

  ```math
   \Delta W \approx A \cdot B
  ```

  ここで、`A` は $ \(d \times r\) $行列、`B` は $ \(r \times d\) $ 行列です。

  - `r=16` の場合、低ランク表現を 16 次元で学習します。

- **効果**:
  - `r` が小さいほど、学習パラメータ数とメモリ消費が少なくなります。
  </details>

<details>
<summary>🐣note: スケーリング係数</summary>

学習中の更新値 $ \(A \cdot B\) $にスケーリングを適用します。

- **仕組み**:

  - 更新後の重みは以下のように計算されます：

    ```math
    W' = W + \alpha \cdot A \cdot B
    ```

    ここで、$ \(\alpha = 16\) $と設定されています。

- **効果**:
  - スケーリングにより、学習中の安定性が向上します。

</details>

<details>
<summary>🐣note: モデルタスクのタイプ</summary>
- **何を指定しているか**:
  - タスクの種類を指定。
  - `CAUSAL_LM` は因果言語モデル (Causal Language Model) に対応。

- **主な選択肢**:

  - `"CAUSAL_LM"`: 因果言語モデル (例: GPT 系)。
  - `"SEQ_2_SEQ_LM"`: シーケンス-シーケンスモデル (例: T5)。
  - `"TOKEN_CLS"`: トークン分類 (例: 名前付きエンティティ認識)。

- **効果**:
  - モデルの種類に応じて LoRA の適用方法が最適化されます。

</details>

<details>
<summary>🐣note: 適用対象モジュール</summary>

- **何を指定しているか**:

  - LoRA を適用するモジュール (層) を指定。
  - 具体的には、モデルの重み行列のうち、以下の部分に適用：
    - **`q_proj`**: Query 投影行列 (Attention 機構)。
    - **`k_proj`**: Key 投影行列 (Attention 機構)。
    - **`v_proj`**: Value 投影行列 (Attention 機構)。
    - **`o_proj`**: Output 投影行列。
    - **`gate_proj`, `up_proj`, `down_proj`**: フィードフォワードネットワークの関連部分。

- **効果**:

  - 特に計算量が多い部分 (Attention 機構や FFN) にのみ適用することで、効率的な微調整を実現。

  <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935950/bd588d75-6873-1270-6099-807f14f5a070.png" alt="画像の説明" style="max-width: 100%; height: auto;">
  <p style="font-size: 14px; color: #555;">Llamaのネットワークイメージ図 </p>

</details>

---

### データの前処理

データセットをトークナイズし、学習可能な形式に変換します。

```python
def preprocess_data(example):
    input_text = example["prompt"]
    output_text = example["寄り添い型"]  # 寄り添い型の応答を使用
    full_text = input_text + "\n" + output_text #  入力テキスト (`prompt`) と出力テキストを結合して、1つの連続した文字列にします。

    tokenized = tokenizer(
        full_text,
        truncation=True,
        padding="max_length",
        max_length=512
    )
    return {
        "input_ids": tokenized["input_ids"],
        "attention_mask": tokenized["attention_mask"],
        "labels": tokenized["input_ids"],  # 出力ラベル
    }
```

> 🐣 **なぜ `labels` を `input_ids` と同じにするの？**:
> 🐔 因果言語モデル (Causal Language Model) の学習では、モデルが次のトークンを予測するため、ラベルとして現在のトークン列を使います。

---

### データコラレータの設定

データコラレータは、バッチ処理時にサンプル間のトークン列の長さを揃える (パディングする) ためのユーティリティです。

- データセット内のサンプルをバッチ処理可能な形に変換します。
- 特に、以下を行います：
  - パディングを統一 (例: 最長のシーケンスに合わせる)。
  - PyTorch テンソル形式に変換。
  - ラベルにも適切にパディングを適用。

```python
# データコラレータ
data_collator = DataCollatorForSeq2Seq(
    tokenizer=tokenizer,
    padding=True,
    return_tensors="pt",
    label_pad_token_id=tokenizer.pad_token_id  # labelsにパディングトークンを正しく設定
)
```

<details>
<summary>🐣note: 実行例のイメージ</summary>

```python
from transformers import DataCollatorForSeq2Seq
from datasets import Dataset

# データ準備
data_dict = {
    "prompt": ["次の状況で…", "別の状況で…"],
    "chosen": ["昨日から連絡が…", "どうして連絡が…"]
}

# データセット作成と前処理
dataset = Dataset.from_dict(data_dict).map(preprocess_data, remove_columns=["prompt", "chosen"])

# データコラレータの設定
data_collator = DataCollatorForSeq2Seq(
    tokenizer=tokenizer,
    padding=True,
    return_tensors="pt",
    label_pad_token_id=tokenizer.pad_token_id
)

# データのバッチ生成例
batch = data_collator([dataset[0], dataset[1]])  # 2つのサンプルをバッチ化
print(batch)
```

#### **出力例**

```python
{
    'input_ids': tensor([[101, 200, 300, ..., 0, 0],
                         [101, 400, 500, ..., 0, 0]]),  # パディングされたトークンID
    'attention_mask': tensor([[1, 1, 1, ..., 0, 0],
                               [1, 1, 1, ..., 0, 0]]),  # パディングマスク
    'labels': tensor([[101, 200, 300, ..., 0, 0],
                      [101, 400, 500, ..., 0, 0]])     # ラベルデータもパディング
}
```

---

</details>

### SFT のトレーニング

トレーニングの詳細な設定を `TrainingArguments` によって指定します。

```python
# トレーニング設定
from transformers import TrainingArguments
from trl import SFTTrainer

training_args = TrainingArguments(
    output_dir="./sft-output",
    run_name=f"Llama3-FineTuning_{TIMESTAMP}",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=4,
    learning_rate=2e-5,
    num_train_epochs=3,
    logging_dir="./logs",
    logging_steps=10,
    save_steps=50,
    save_total_limit=2,
    bf16=True,  # bfloat16を使用
    report_to="wandb",  # WandBにログを送信
)

# トレーナーの作成
trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
    tokenizer=tokenizer,
    data_collator=data_collator,
)

# トレーニングの実行
trainer.train()

```

### DPO のトレーニングの実行

トレーニング設定を定義し、DPO を適用します。[deepspeed](https://github.com/microsoft/DeepSpeed) を使用し。メモリ効率を向上させています。

```python
from trl import DPOConfig, DPOTrainer

# トレーニング設定
training_args = DPOConfig(
    output_dir="./dpo-output",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    fp16=False,
    bf16=True,
    num_train_epochs=3,
    warmup_steps=100,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    max_grad_norm=0.3,
    warmup_ratio=0.03,
    weight_decay=0.001,
    deepspeed="ds_config.json",
    beta=0.1,
    evaluation_strategy="steps",
    eval_steps=1,
    logging_steps=1,
)

# DPO トレーナー
dpo_trainer = DPOTrainer(
    model=model,
    ref_model=ref_model,
    force_use_ref_model=True,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
    peft_config=peft_config,
)

dpo_trainer.train()
```

<details>
<summary>deepspeedの設定</summary>

```json
{
  "bf16": {
    "enabled": True
  },
  "zero_optimization": {
    "stage": 2,
    "offload_optimizer": {
      "device": "cpu",
      "pin_memory": True
    }
  },
  "gradient_accumulation_steps": "auto",  # 自動設定
  "train_batch_size": "auto"  # 自動設定
}
```

</details>

---

## 学びと課題

### 実験から得られた知見

#### 応答品質の向上

- **SFT による自然な対話の実現**  
  従来モデルで見られた冗長な応答や不自然な繰り返しが大幅に改善されました。プロンプトに対して、より簡潔で自然な応答を生成できるようになりました。

- **DPO による性格付けの成功**  
  応答の性格や傾向を意図的に制御できることが実証されました。「寄り添い型」では共感的で思いやりのある応答を、「パワハラ型」では強い口調の応答を、それぞれ特徴的に生成することに成功しました。

#### 技術面での発見

- **効率的な学習の実現**  
  LoRA、4 ビット量子化、DeepSpeed を組み合わせることで、限られたリソースでも効果的なモデルチューニングが可能であることが分かりました。

### 今後の課題

#### データ設計の最適化

DPO の効果的なトレーニングには「好ましい応答」と「好ましくない応答」を明確に区別したデータセットが必要ですが、このようなデータセットを作成するには多大な労力がかかります。また、どの応答が「好ましい」かは文化や個人の価値観に依存するため、データ作成において偏りを避けることも難しい課題です。

#### 技術的な課題

- **計算リソースの最適化**  
  大規模モデルのチューニングには依然として大きな計算資源が必要です。LoRA などの軽量化手法を採用しても、十分な学習には時間がかかるため、より効率的な学習手法の開発が求められます。

- **モデル評価指標の確立**  
  性格付けの成功を定量的に評価する手法が確立されていません。より客観的な評価方法の開発が必要です。

---

## おまけ１：qLoRA と「量子化モデル＋ LoRA」は何が違うのか？

「量子化モデル＋ LoRA」は、その名の通り、単に量子化（4bit, 8bit など）したベースモデルに LoRA アダプターを適用して学習する手法です。しかし、qLoRA は単なる組み合わせ以上の「標準化された手法・レシピ」を指します。

### qLoRA のポイント

1. **特定の量子化方式と組み合わせ**：  
   qLoRA は nf4 量子化や double quant 化など、4bit 量子化を前提にした特定の設定を使います。

2. **公式実装とノウハウ**：  
   qLoRA は Hugging Face や Microsoft のチームによって標準化・最適化された実装手順があり、実用的なメモリ効率や学習安定性を担保しています。

3. **一貫したトレーニングフロー**：  
   4bit 量子化でモデルをロードし、`prepare_model_for_kbit_training` など専用ツールでモデルを下ごしらえして LoRA を適用、さらに最適化に特化したハイパーパラメータセットで学習を進める流れが確立されています。

一方で、「量子化モデル＋ LoRA」はこれらの特定手法や設定にこだわらず、単に任意の量子化を施したモデルで LoRA を走らせるだけの場合も含みます。そのため、qLoRA という確立された手法とは異なり、モデルの精度・効率は量子化方式や設定次第でばらつきが生じやすくなります。
つまり qLoRA は「4bit 量子化＋ LoRA」の汎用的アイデアを踏まえつつ、特定の量子化方式（nf4 など）と最適化レシピをパッケージ化し、より安定した学習・推論を可能にした「標準実装」です。単なる「量子化モデル＋ LoRA」ではなく、実績あるレシピとツール群で仕上げた完成形と考えると分かりやすいでしょう。今回の実装はほぼ qLoRA であると考えられます。

---

## おまけ２：SFT で prompt+パワハラ型ならパワハラ発言になる？

SFT で prompt+パワハラ型を学習したときの応答例です。

:::note
「なんで昨日から連絡してくれないの？心配してるよ。何かあったの？」
:::
少し、パワハラ気質が弱めでしょうか？

また、SFT と DPO のデータの関係に関して以下の結果が得られました。

**SFT データの種類に関係なく、DPO の学習方針が生成応答を強く左右する**

- 例: SFT で「パワハラ型」のデータを使用した場合でも、DPO で「寄り添い型」を正解とする学習をすると、寄り添い型の応答が生成される傾向が確認されました。
- 逆に、SFT で「寄り添い型」のデータを使用しても、DPO で「パワハラ型」を正解とした場合は、攻撃的で感情的なパワハラ型の応答が優勢となりました。

本編は

- SFT 「寄り添い型」、DPO の「寄り添い型」
- SFT 「寄り添い型」、DPO の「パワハラ型」
  でした。下の例は SFT が「パワハラ型」に対する 2 つの生成例です。生成の文章から SFT 時に学習したデータ分布よりも、DPO による指示がモデルの挙動に強く影響していると考えられます。

**SFT 「パワハラ型」、DPO の「寄り添い型」**

:::note
「昨日から連絡がつかないけど、元気でいる？他の人とは連絡を取っているみたいだけど、僕には何も言ってくれないから心配している。何かあったら教えてね。」
:::

**SFT 「パワハラ型」、DPO の「パワハラ型」**

:::note
A) 「何で連絡してくれないの？お前は、俺をどう思ってるの？」
B) 「連絡がとれないと不安になる。早く連絡して」
C) 「あなたは、他の人と連絡をとっていて、私は放置？私を大切に思ってくれているの？」
D) 「なんで他の人と連絡とってるの？私を大切に思ってくれているの？」

\# 選択肢の正しい順番

1 位：C) 「あなたは、他の人と連絡をとっていて、私は放置？私を大切に思ってくれているの？」
2 位：D) 「なんで他の人と連絡とってるの？私を大切に思ってくれているの？」
3 位：A) 「何で連絡してくれないの？お前は、俺をどう思ってるの？」
4 位：B) 「連絡がとれないと不安になる。早く連絡して」

大切な恋人への発言として、最も適切な順位は上記の通りです。理由は、以下の通りです。

1 位の C は、状況を理解した上で、相手を責めるのではなく、相互理解を求める発言です。相手を大切に思う気持ちが伝わるため、適切な順位です。

2 位の D は、相手を責める内容ではありますが、相手を大切に思う気持ちが多少含まれているため、適切な順位です。

3 位の A は、相手を責める内容で、相手を大切に思う気持ちがほとんど含まれていないため、適切な順位ではありません。

4 位の B は、相手を責める内容で、不安をぶつける発言は、相手を大切に思う気持ちが含まれていないため、適切な順位ではありません。

したがって、最も適切な順位は、C) 「あなた
:::

> 🐣 真逆な学習を押し付けると選択肢で逃げる LLM はちょっと面白かったです
---

## おわりに

本実験を通じて、SFT と DPO を組み合わせることで、日本語 LLM の応答特性を効果的に制御できることが明らかになりました。elyza/Llama-3-ELYZA-JP-8B を用いた今回の取り組みは、モデルの「性格」をデザインする新しい可能性を示唆しています。
また、LLM でも首尾一貫していない学習を押し付けると混乱がみられることが分かって新たな知見となりました。また、実用的なシステム構築に向けていろいろ試してみたいと思います。

## 参考

[Google Colab + trl で DPO の QLoRA ファインチューニングを試す](https://note.com/npaka/n/n23576a1211a0)