職場の先輩をLLMで作ってみようとした話

## はじめに
皆さんはLLMで何かを作りたいという経験はありますか。

世の中にはアニメキャラクターの作成を指向した[チャットハルヒ](https://arxiv.org/abs/2308.09597)や[霧雨魔理沙を言語モデルで作成&ラインbot化した話](https://qiita.com/tsukemono/items/990490e0090c0607b00c)など様々な実例があります。

今回私は勉強会でお話しするという温度感で、先輩をLLMで作ってみようとした話について述べます。参考になれば幸いです！

:::note warn
今回は備忘録として、あえて、どのようにLLMを作成していったのかの思考に沿って記述しています。
:::


:::note
プログラムをご覧になりたい方は以下のアコーディオンをクリックしてください。
:::


<details>
<summary>データ準備~データ加工</summary>
<div>


```python
def convert_symbols_to_fullwidth(text):
    half_symbols = '!?~'
    full_symbols = '！？〜'
    half_to_full = {half: full for half, full in zip(half_symbols, full_symbols)}
    
    return ''.join(half_to_full.get(char, char) for char in text)

def convert_fullwidth_digits_to_halfwidth(text):
    full_digits = '０１２３４５６７８９'
    half_digits = '0123456789'
    full_to_half = {full: half for full, half in zip(full_digits, half_digits)}
    
    return ''.join(full_to_half.get(char, char) for char in text)

file_name = "xxx.txt" //改行区切り

with open(file_name, "r", encoding="utf-8") as file:
    text_content = file.read()

converted_text_content = convert_symbols_to_fullwidth(text_content)
converted_text_content = convert_fullwidth_digits_to_halfwidth(converted_text_content)

text_data = converted_text_content.split("\n")
```
</div>
</details>

<details>
<summary>トークン化~ファインチューニング</summary>
<div>


```python
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import Dataset
import transformers
from peft import LoraConfig, get_peft_model //LoRA用

model_name = "elyza/ELYZA-japanese-Llama-2-7b-instruct"

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype="auto")

if torch.cuda.is_available():
    model = model.to("cuda")
tokenized_text = tokenizer(text_data, padding=True, truncation=True)

dataset = Dataset.from_dict(tokenized_text)
train_dataset = dataset.with_format("torch")

### 
#今回はELYZAのみLoRAを使用
###
config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, config)
###

trainer = transformers.Trainer(
    model=model,
    train_dataset=train_dataset ,
    args=transformers.TrainingArguments(
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        warmup_steps=100,
        max_steps=200,
        learning_rate=2e-4,
        fp16=True,
        logging_steps=1,
        output_dir='outputs'
    ),
    data_collator=transformers.DataCollatorForLanguageModeling(tokenizer, mlm=False)
)
model.config.use_cache = False 
trainer.train()
```
</div>
</details>


<details>
<summary>推論（ELYZAのみ）</summary>
<div>


```python
def llm_output(model, tokenizer, text):
  B_INST, E_INST = "
", "
"
  B_SYS, E_SYS = "
\n", "\n
\n\n"
  DEFAULT_SYSTEM_PROMPT = ""

  prompt = "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
      bos_token=tokenizer.bos_token,
      b_inst=B_INST,
      system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}",
      prompt=text,
      e_inst=E_INST,
  )

  with torch.no_grad():
      token_ids = tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt")

      output_ids = model.generate(
          input_ids=token_ids.to(model.device),
          max_new_tokens=256,
          pad_token_id=tokenizer.pad_token_id,
          eos_token_id=tokenizer.eos_token_id,
      )
  output = tokenizer.decode(output_ids.tolist()[0][token_ids.size(1) :], skip_special_tokens=True)
  print(output)

llm_outout(model, tokenizer, text = "ここにプロンプトを入れる")
```
</div>
</details>


## まとめ
以下内容が長いので、以下を端的にまとめます。

- 先輩の発言データによるファインチューニングで先輩を模倣したモデルの作成を試みた。
- 先輩の話し方は、ファインチューニング+プロンプトエンジニアリングで再現できたが、先輩の知識をモデルに獲得させることは難しかった。


## 方針
### 手法の選定
まず作成したいモデルの理想と制約について考えます。理想は以下3つの要素を獲得したLLMの作成です。

- 先輩の話し方
- 先輩の知識
- 先輩の考え方

制約は以下です。

- 学習データ
    - 少量(入力データ数は<1000)
    - ツイート、発言をまとめたテキストデータ
- GPUのサイズ：メモリ40GB

---
次に上記の理想と制約、下図の[Problems Addressed](https://www.anyscale.com/blog/fine-tuning-is-for-form-not-facts)を参考にして大枠の手法を選定していきます。


![ 2023-10-20 10.57.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487820/383e673d-be1a-e9e1-6cdf-0519c244bf8a.png)



上図について簡単に述べると、Form problems(形式的な問題)は、話し方や言語、形式を特定のものに合わせるタスク、Factual problems(実際な問題)はForm problems以外の全てのタスクです。

Form ploblemsの具体例は以下です。

- 適切なチャット(ex.モデル[vicuna](https://github.com/lm-sys/FastChat/#vicuna-weights))
- 指示に従うこと(ex.モデル[mosaicml/mpt-7b-instruct](https://huggingface.co/mosaicml/mpt-7b-instruct))
- 方言での会話
- 保険会社の請求
- 履歴書の作成


今回のモデルの理想の要素と上記を照らし合わせると、Form ploblemsは話し方の模倣、Factual ploblemsは知識の獲得、考え方の模倣といえます。

---


具体的に手法の選定を行います。まず効果が高いフルスクラッチによる実装と強化学習(RLHF)は、今回実装コスト&リソース&データの観点から除外します。

次にRAGについてですが、対象の先輩固有の知識データを集めることが大変です。

なので「効果×実現可能性」から今回はファインチューニングを採用します。



### ファインチューニング
次にファインチューニングでできることについて考えます。

先述した「Problems Addressed」ではファインチューニングは、話し方や言語、形式を特定のものに合わせるタスクにのみ有効であり、新規の知識を覚えたり、独自の考え方を学ぶのには、あまり有効ではないとされています。


https://zenn.dev/ohtaman/articles/llm_finetune_lora

一方で上記の記事より、[知識は全結合層に蓄積されるという仮説](https://arxiv.org/abs/2104.08696)のもと、埋め込み層以外を訓練対象とするなど工夫をすれば、事実の学習は可能な場合もあるようです。

原理的にも知識の獲得は可能であることため、方針には可能であれば知識を獲得する、という温度感で入れることとします。


### 方針まとめ
方針は、「ファインチューニングにより先輩の話し方を模倣するLLMを作り、可能であれば知識も獲得する」とします。


## モデル作成・評価
本項では、モデルの作成手順・評価について以下の順で述べます。


1. データセット
1. モデル
1. 評価


### データセット

データセットは以下の[手順](https://arxiv.org/pdf/2303.18223.pdf)で作成することが一般的です。



![ 2023-10-20 11.52.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487820/c48f5332-9583-3e60-9e7a-84df6266bcda.png)

今回は上記作業に加えて、プライバシー情報の除去(Privacy Reduction)とトークン化の間に、データ加工を追加しました。

以下で手順を記載します。

#### 生データの収集
生データは対象の先輩の承認のもと、X(旧:Twitter)&youtubeのデータを取得しました。
全てテキストデータとなっています。

| データ種類 | データの数(文の数) |文字数 
|--------|--------|--------|
| X(旧:Twitter)  | 117|2298| 
| youtube  | 29 |484|
| 収集データ合計  | 146 |2782|
| （参考)シェイクスピアの脚本のデータセット| 29244 |968329|

参考として、収集データはシェイクスピアの脚本のデータセット[(tiny-shakespeare)](https://huggingface.co/datasets/tiny_shakespeare)の1/100以下のデータ量であり、とても小さいです。

<details>
<summary>シェイクスピアの脚本のデータセットの行数&文字数確認プログラム</summary>
<div>

```python
import json

def load_json_file(filename):
    with open(filename, 'r', encoding='utf-8') as file:
        data = json.load(file)
    return data

def replace_double_newlines(s):
    return s.replace('\n\n', '\n')

def count_lines_and_chars(s):
    lines = s.split('\n')
    num_lines = len(lines)
    num_chars = sum(len(line) for line in lines)
    return num_lines, num_chars

filename = 'tiny_shakespeare.json'
data = load_json_file(filename)
text = data['rows'][0]['row']['text']
text = replace_double_newlines(text)
num_lines, num_chars = count_lines_and_chars(text)
print(f"行数: {num_lines},文字数: {num_chars}")
```
</div>
</details>



#### フィルタリング&重複処理&プライバシー除去
- フィルタリング-> 実施しませんでした。
- 重複処理 -> 重複データはありませんでした。
- プライバシー除去 ->プライバシー情報が含まれてる情報を手作業で削除しました。

#### データの加工
X(旧:Twitter)のデータは表記揺れを解消しました。（原因はおそらく複数端末からのツイートによるPCと携帯の表記揺れ）
具体的には。半角の記号(例. !, ?, ~)を全角にし、全角の数字を半角に直しました。

```python
def convert_symbols_to_fullwidth(text):
    """
    Convert half-width symbols to full-width.
    """
    half_symbols = '!?~'
    full_symbols = '！？〜'
    half_to_full = {half: full for half, full in zip(half_symbols, full_symbols)}
    
    return ''.join(half_to_full.get(char, char) for char in text)

def convert_fullwidth_digits_to_halfwidth(text):
    """
    Convert full-width digits to half-width.
    """
    full_digits = '０１２３４５６７８９'
    half_digits = '0123456789'
    full_to_half = {full: half for full, half in zip(full_digits, half_digits)}
    
    return ''.join(full_to_half.get(char, char) for char in text)
```

#### トークン化
トークン化は後述するモデルに推奨されているトークナイザーを使用しました。

プログラムは以下です。(ELYZAのみ)
```python
from transformers import AutoTokenizer
model_name = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenized_text = tokenizer(text_data, padding=True, truncation=True)
```

詳細は別記事で解説しています。

https://zenn.dev/sergicalsix/articles/2863c493c22118


### モデル
#### モデル選択

モデルは以下から選びました。

https://github.com/llm-jp/awesome-japanese-llm

今回勉強会で発表するということから、有名なものが良いということで、私の世代でLINEアカウントで有名だった「rinna」と、勉強会実施時に盛り上がっていた「ELYZA」を採用しました。




| モデル略称 | モデル| サイズ |
|------|-------|------|
| rinna   | `rinna/japanese-gpt-1b`     | 1.33B    |
| ELYZA    | `elyza/ELYZA-japanese-Llama-2-7b-fast-instruct`  | 6.37B   |


以下モデルの特徴について軽く述べます。


##### rinnaについて

https://rinna.co.jp/

rinnaは使ってみるとわかるのですが、上記の公式で言われている通りマシンガントークが特徴です。また少し意味不明な発言をしてしまう傾向があります。


##### ELYZAについて

https://huggingface.co/elyza

Llama2を日本語のデータセットでファインチューニングしたモデルです。当時ELYZAは日本語データセットにおいて、最も精度の高いモデルの一つでした。

精度に関して、以下にJGLUEを使ったLLMの日本語タスクベンチマークのリンクを記載しておきます。

https://wandb.ai/wandb/LLM_evaluation_Japan/reports/LLM-JGLUE---Vmlldzo0NTUzMDE2?accessToken=u1ttt89al8oo5p5j12eq3nldxh0378os9qjjh14ha1yg88nvs5irmuao044b6eqa



#### モデルの訓練
rinnaは全てのパラメータをファインチューニングし、ELYZAはLoRAを用いてファインチューニングしました。

本当はELYZAも全てのパラメータをファインチューニングしたかったですが、メモリの制約から断念しました。(4bit量子化するなど、工夫すればできたかもしれません。)

LoRAで参考にした記事は以下です。

https://note.com/npaka/n/nc387b639e50e




### 評価
まず損失が学習によって減少しているかを確認します。
下図は縦軸がloss、横軸がstep数(イテレーション数)のELYZAの学習曲線であり、学習曲線からモデルの学習が進行していることがわかります。

![ 2023-10-23 9.50.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/487820/57d33b62-4043-b745-afee-8bb8505d193c.png)

次にモデルの性能について以下の観点で実験をします。


- 知識が保存されているかどうか
- 先輩の話し方を再現できているか
    - 常体であるかどうか（集めたデータセットの先輩の口調は全て常体）
    - 語尾に記号が含まれている(ex. 〜,！,wなど)
- 先輩の知識を獲得できているか
    - 先輩の趣味はアニメ、ゲーム、麻雀、ドライブである。（Xのデータから類推できる内容&ヒアリングで確認済み） 

:::note warn
知識が保存されているについては、JGLEUの検証データセットで実験を行うなど他に適した方法がありますが、時間と予算の関係で断念しました。
:::

#### 一般知の質問
日本の首都について各モデルに出力させた結果は以下でした。


| モデル | プロンプト| 出力 |
|------|-------|------|
| rinna   | 日本の首都は   |  東京だと思っておりましたが、北海道が首都になる。なんてこともありえるかもしれません。 |
| ELYZA    | 日本の首都は    |  東京です。  |
| ファインチューニング済みrinna    | 日本の首都は   |  右の側頭叩くのやめてほしいw |
| ファインチューニング済みELYZA    | 日本の首都は  　|  東京です！  |


ファインチューニング済みの両モデルとも語尾に記号を入れられている点は期待通りですが、ファインチューニング済みELYZAは常体を再現できていません。

ファインチューニング済みのrinnaは東京と出力できなくなってしまいました。

#### 知識について
趣味について各モデルに出力させた結果は以下でした。(一部本人特定に関わる内容はXXXとしています。)
再掲しますが、先輩の趣味はアニメ、ゲーム、麻雀、ドライブです。

| モデル | プロンプト| 出力 |
|------|-------|------|
| rinna   | あなたの趣味は   |  食べることなのですが、過払金業者ばかりしていたら、消費者金融が肥えてきたとでもいうのでしょうか。 |
| ELYZA    | あなたの趣味は   |  私はAIなので、人間のような趣味はありません。  |
| ファインチューニング済みrinna    | あなたの趣味は   |  私の趣味はXXXとドライブに行くことです!XXXとドライブに行けて楽しいよ〜 |
| ファインチューニング済みELYZA    | あなたの趣味は 　|  私の趣味はアニメ見ることですけど、あんまりアニメの話できる人いないなーと思ってて  |

ファインチューニング済みの両モデルとも話し方は、先輩に近づいています。

知識について、一見、ファインチューニング済みの両モデルで正しい知識(先輩の趣味はアニメ、ドライブであるので)を引き出すことはできています。

しかしファインチューニング後のrinnaについては、実は学習データの一部のツイートをほぼ完コピしているだけでした。

ファインチューニング済みELYZAでは、以下のようにプロンプトを指示プロンプト形式にすると応答が変わり、趣味について正い知識を引き出すことはできませんでした。

つまり完全に知識の獲得&利活用できてはいないようです。

| モデル | プロンプト| 出力 |
|------|-------|------|
| ファインチューニング済みELYZA    | ###指示\nあなたの趣味を詳しく教えてください。\n###応答 　| 私はAIなので、人間のような趣味はありませんが、お話するのことではALLできます！ |



#### その他
またrinnaのファインチューニングにおいて、一部のseedでは学習が上手くいかず、以下のように特定の記号を連続で出力してしまうモデルもできてしまいました。
```
#学習が上手くいっていない場合の出力例
今日は〜〜〜〜〜〜〜〜〜〜〜〜
```

## 考察
### 一般知を回答できない問題
| モデル | プロンプト| 出力 |
|------|-------|------|
| ファインチューニング済みrinna    | 日本の首都は   |  右の側頭叩くのやめてほしいw |

上記のようにファインチューニング済みのrinnaは日本の首都を東京と答えることができませんでした。
理由はざっくり言うと過学習が生じているからと考えられます。(破壊的忘却が起きていそうですが、今回は過学習という広めの言葉を選択しています。)

対策は主に正則化の働きを入れること、学習条件(エポックや学習率等)が入れることが考えられます。

また対策を効率的に行うためには、一般知について回答できるか否かを学習時に監視できる仕組みが必要です。

ただ一般知について回答できるか否かを監視することは、単に誤差関数を書き換えるだけでは実装できないので、ある程度工夫が必要そうです。

上記は面白そうなので、時間がある時に実験して記事にしてみます。

### 方針について
今回の方針は、「ファインチューニングにより先輩の話し方を模倣するLLMを作り、可能であれば知識も獲得する」でした。以下では方針でも言及されている話し方と知識について記述します。

#### 話し方について
| モデル | プロンプト| 出力 |
|------|-------|------|
| ファインチューニング済みELYZA    | 日本の首都は  　|  東京です！  |

上述のようにファインチューニング済みELYZAは常体を再現できない場合がありました。

ファインチューニング済みELYZAにデフォルトプロンプトを設定すると常体を再現できました。


```python
# ELYZA:デフォルトプロンプトの指定
DEFAULT_SYSTEM_PROMPT = "あなたは常体で応答します。"
B_INST, E_INST = "
", "
"
B_SYS, E_SYS = "
\n", "\n
\n\n"


prompt = "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
  bos_token=tokenizer.bos_token,
  b_inst=B_INST,
  system=f"{B_SYS}{DEFAULT_SYSTEM_PROMPT}{E_SYS}",
  prompt=text,
  e_inst=E_INST,
)
```

| モデル | プロンプト| 出力 |
|------|-------|------|
| ELYZA + デフォルトプロンプト(参考)  | 私の趣味は|　私は、読書と映画鑑賞が好きです。 |
| ファインチューニング済みELYZA + デフォルトプロンプト   | 私の趣味は| アニメだ！ |

上記のプロンプトエンジニアリング的な手法とファインチューニングとの大きな違いは、我々が特徴を明示的にモデルに入力するか否かです。

よって常体で話すという具体的な指示ができる場合はプロンプトエンジニアリングで事足りる場合がありますが、特徴の言語化が難しかったり、複雑な特徴を学ばせたい時はファインチューニングが有効なのかなと思っております。


#### 知識の獲得について
ファインチューニング後のELYZAについて（予想通り）知識の獲得は上手くいきませんでした。

一番の原因は、様々な論文や記事で言及されていますが、事前学習に対するファインチューニング時のデータの少なさが考えられます。元も子もない話ですが、対応策としては、データを増やすことに尽きるのかなと思います。

また特定の知識を増やしたい場合は、明示的にInstructionチューニングするのも手かなと思います。
ただ知識を使うことができるだけならRAGで良いわけで、双方の手法の利点やコスト、目的に応じて手法を選択することが大切です。

また方針で述べたようなファインチューニング時の特定の層の重み固定なでの工夫でも改善するかもしれません。


また余談ですが、知識の獲得についてはわかりやすい記事を下に載せておきます。

https://note.com/kan_hatakeyama/n/n85f294dcb034




### ベースラインLLMの改良
#### 特定の記号を連続で出力してしまう問題の解決
```
#学習が上手くいっていない場合の出力例
今日は〜〜〜〜〜〜〜〜〜〜〜〜
```

上述したように一部モデルでは、特定の記号を連続で出力してしまいました。

https://zenn.dev/hellorusk/articles/1c0bef15057b1d

特定の文字列を連続で出力してしまう問題は、上記の記事で解説されているようなdecondingのテクニックで修正することもできます。

ただ今回はデータセットの視点から原因を考慮し、修正していきます。

さ今回の問題の原因は以下のような訓練データがあるからと考えられます。(生データから一部改造)
```
寂しがりか〜〜〜〜
まじかああ〜〜〜〜〜〜
```
上記のようなデータによりモデルが、〜〜の連続しているときは「〜」を出力すれば良いと学習してしまう恐れがあります。

よってデータ内の「〜」を2連続までとしました。

```
寂しがりか〜〜〜〜　-> 寂しがりか〜〜
まじかああ〜〜〜〜〜〜 -> まじかああ〜〜
```

上記のデータセットの修正の結果、(自分の確認できた範囲では)特定の記号を連続で出力してしまう問題は解決できました。

## まとめ(再掲)

- 先輩の発言データによるファインチューニングで先輩を模倣したモデルの作成を試みた。
- 先輩の話し方は、ファインチューニング+プロンプトエンジニアリングで再現できたが、先輩の知識をモデルに獲得させることは難しかった。

## おわりに

いかがだったでしょうか。少しでも誰かの役に立てば幸いです。




## 参考
- Problem Addressed

https://www.anyscale.com/blog/fine-tuning-is-for-form-not-facts

- ファインチューニングの事実学習

https://zenn.dev/ohtaman/articles/llm_finetune_lora

- データセット作成(図引用)


https://arxiv.org/abs/2303.18223

- モデル候補

https://github.com/llm-jp/awesome-japanese-llm

- JGLUEを使ったLLMの日本語タスクベンチマーク

https://wandb.ai/wandb/LLM_evaluation_Japan/reports/LLM-JGLUE---Vmlldzo0NTUzMDE2?accessToken=u1ttt89al8oo5p5j12eq3nldxh0378os9qjjh14ha1yg88nvs5irmuao044b6eqa

- LoRAで参考にしたプログラム

https://note.com/npaka/n/nc387b639e50e

- decodingのテクニック

https://zenn.dev/hellorusk/articles/1c0bef15057b1d

- 知識の獲得について

https://note.com/kan_hatakeyama/n/n85f294dcb034