【Llama3.1】ローカルLLMの日本語性能【JcommonsenseQA】

# 概要

ローカルで動かせるLLM（例：Llamaなど）の日本語性能を比較するベンチマークがたくさんありますが、今回は、日本の知識を問う（JcommonsenseQA）というデータを使って、次のLLMの性能を比較させてみました。

（世の中でベンチマークをもとにLLMの性能比較競争が行われていますが、それを自分で確かめてみよう、という主旨です）。


- `Llama3.1 8B Instruct`：[hugging faceのリンク](https://huggingface.co/tokyotech-llm/Llama-3.1-Swallow-8B-Instruct-v0.2)
- `Swallow 8B Instruct v0.2：[hugging faceのリンク](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct)
- `calm3-22b-chat`：[hugging faceのリンク](https://huggingface.co/cyberagent/calm3-22b-chat)

今回は、Google Colabで実行していますが、コードそのものは[Github](https://github.com/KENTAROSZK/jcommonsenseqa_experiment/tree/main)にアップしているので、そちらをみていただくこともできます。


どのようなプロンプトにすれば良いのか？を改めて考えると、あれ？と思わされるところがありました。やはり手を動かして自分で考えるフェーズを設けないと、身につかないことってあるなと思い出させられました。


# コードの解説

注意：

**今回は、Google Colabで実行させました。GPUを使うので、ランタイムをGPUに変更しておいてください。**

もし、Google Colabが嫌な場合は、拙著ですが[GPUを利用できるサービス](https://qiita.com/KENTAROSZK/items/046438bd4cd26c2aec29)などを使うなどしてそちらで利用できるようにうまくコードを読み替えてください。


## 使用するモデル名を設定する

```python
model_name = "tokyotech-llm/Llama-3.1-Swallow-8B-Instruct-v0.2"
#model_name = "meta-llama/Llama-3.1-8B-Instruct"
#model_name = "cyberagent/calm3-22b-chat"
```

ここでモデルの名前を好きに設定しておきます。


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

```python
%pip install --upgrade transformers
%pip install --upgrade accelerate
%pip install torch  bitsandbytes huggingface_hub[cli] huggingface_hub hf_transfer
%pip install python-dotenv
```

hugging faceに関係するライブラリや、量子化するためのライブラリ、モデルをDLするときに高速にダウンロードするためのライブラリなどを入れています。


## ライブラリのインポートと、環境変数の設定

```python
import os
import re
import requests
import json
import pickle
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch
from huggingface_hub import login
from huggingface_hub import snapshot_download
from transformers.utils import move_cache

# .envに書き込んだ hugging faceを読み込むには、以下のコード
#from dotenv import load_dotenv
#load_dotenv()
#HUGGING_FACE_TOKEN = os.getenv('HUGGING_FACE_TOKEN')

# google colabを利用している場合は、以下のような形でhugging faceのトークンを読み込める
#from google.colab import userdata
#HUGGING_FACE_TOKEN = userdata.get('HUGGING_FACE_TOKEN')

# 直接notebook上に書き込みたい人は以下のコード
HUGGING_FACE_TOKEN = "hf_************"



# hugging faceへログインする
login(HUGGING_FACE_TOKEN)
os.environ["HF_TOKEN"] = HUGGING_FACE_TOKEN
os.environ["HF_HUB_ENABLE_HF_TRANSFER"]="1"
```

hugging face経由でLlama3.1を利用するにはhugging face経由でmetaへ申請を出したりする必要があります。さらに、それ以外のモデルでもhugging faceへのアクセスが求められるケースがあるため、環境変数として、`HUGGING_FACE_TOKEN`を書き込みそれを設定しておきます。


## データをダウンロードする

```python
# ダウンロードするURLを指定
URL = "https://raw.githubusercontent.com/yahoojapan/JGLUE/refs/heads/main/datasets/jcommonsenseqa-v1.1/train-v1.1.json"

# 保存するファイル名を指定
save_as = 'jcommonsenseqa.pkl'


response = requests.get(URL)

datas = []

if response.status_code == 200:
    response.encoding = response.apparent_encoding

    # 複数のJSONオブジェクトが連続する場合に対応
    json_text = response.text
    try:
        json_objects = json_text.split('\n')  # または適切なセパレータ
        for obj in json_objects:
            if not obj.strip():  # 空行を無視
                continue
            try:
                data = json.loads(obj)
                datas.append(data)
            except json.JSONDecodeError as e:
                print("それぞれのJSONオブジェクトのデコードエラー:", e)
    except json.JSONDecodeError as e:
        print("全体のJSONデコードエラー:", e)

    with open(save_as, "wb") as f:
        pickle.dump(datas, f)
else:
    print(f"リクエストが失敗しました。ステータスコード: {response.status_code}")


with open(save_as, "rb") as f:
    datas = pickle.load(f)
```

JcommonsenseQAのデータをDLして、それをpickleとして保存しておきます（notebookを後で実行し直すときなどに、毎回データをDLするのは非効率だと思います。そこで、一度DLしたデータをpickleとして保存しておくことで、二度目以降の実行で再DLしないようにしたいので、このように保存しておきます）。

### データの形式を確認しておく。

```python
# データの形式を確認する
sample_dict = datas[0]
print(sample_dict)
```

データの形式を確認しておきます。

```bash
{'q_id': 0, 'question': '主に子ども向けのもので、イラストのついた物語が書かれているものはどれ？', 'choice0': '世界', 'choice1': '写真集', 'choice2': '絵本', 'choice3': '論文', 'choice4': '図鑑', 'label': 2}
```

これを確認したことで、データが以下の構成となっていることを確認することができました。

- `q_id`：質問のインデックス
- `question`：質問の内容
- `choice0`：選択肢の1個目
- `choice1`：選択肢の2個目
- `choice2`：選択肢の3個目
- `choice3`：選択肢の4個目
- `choice4`：選択肢の5個目
- `label`：正解の選択肢の番号（0~4）

今回の場合は、質問の内容と選択肢をLLMにインプットして、回答させた時にその番号が、`label`と一致するかを確認することができれば正解、そうでなければ不正解と考えることができます。


## JcommonsenseQAのデータをLLMにインプットする形式に変換する

```python
def inputs_and_outputs(datas:list) -> list:
    """
    LLMへインプットするプロンプトとそれに対する解答を作成する。

    - output
        [
            {
                "messages": list,
                "answer": str
            },
        ]
    """
    DEFAULT_SYSTEM_PROMPT = "あなたは日本語で回答するアシスタントです。"
    INSTRUCTION = "質問と回答の選択肢を入力として受け取り、選択肢から回答を選択してください。なお、回答は選択肢の番号（例：0）でするものとします。 回答となる数値をint型で返し、他には何も含めないことを厳守してください。"
    results = []

    for i, sample_dict in enumerate(datas):
        input = f"""
            質問：{sample_dict.get('question', None)}\n
            選択肢：
            0.{sample_dict.get('choice0', None)},
            1.{sample_dict.get('choice1', None)},
            2.{sample_dict.get('choice2', None)},
            3.{sample_dict.get('choice3', None)},
            4.{sample_dict.get('choice4', None)}
        """
        input = input.replace(" ", "") # 不要なスペースを消す。
        answer = sample_dict.get("label", None)

        text = f"以下は、タスクを説明する指示と、文脈のある入力の組み合わせです。要求を適切に満たす応答を書きなさい。\n\n### 指示:\n{INSTRUCTION}\n\n### 入力:\n{input}\n\n### 応答:\n"

        messages = [
            {"role": "system", "content": DEFAULT_SYSTEM_PROMPT},
            {"role": "user", "content": text},
        ]

        result = {"messages": messages, "answer": answer}
        results.append(result)

    return results

list_qa = inputs_and_outputs(datas)
```

LLMにインプットするときは、JcommonsenseQAの形式をそのままにインプットすることができないので、`messages`のオブジェクトの形式に変換しておきます。

今回のプロンプトは[llm-jo-evalという先行研究で使われていたgithubを参考に作成しました](https://github.com/llm-jp/llm-jp-eval)。


以下、筆者がこの手のベンチマーク問題を解かせるにあたってぶち当たったカベ（今回はJcommonsenseQAでよかったけど、汎用性のあるプロンプト（こういう問題を解くときに使っておけばOKなテンプレートのプロンプト）ってあるのか？という問い）。

**※LLMでこの手のベンチマークの問題を解かせる時にどこまで**、**自分でプロンプトエンジニアリングして良いのでしょうか**※ここが筆者が一番悩んだポイントです。今回はJcommonsenseQAという5択の中から一つのインデックを答えさせる問題でしたので、「答えとなる選択肢の番号を選べ」ということをやらせれば良いです。
しかし、それ以外のベンチマークでは、例えば「東京オリンピックが開催された年は？」という質問があります。答えとして、
- 「2021年」が正解なのでしょうか？
- それとも「2021」が良いですか？
- 「2020年に開催予定だったがコロナによって2021年になった。」でしょうか？

yyyy年の形式を正解とするのか、yyyyだけで正解なのか、それともそれ以外の要素を回答させるのが良いのか。どれが良いのでしょうか？そういう問いを考えた場合、JcommonsenseQAはかなり良心的な問題です。正解の選択肢の番号だけ答えれば良いので、微妙な表記揺れの制御のことを考えなくて済みます

実際、NIILCというタスクでは、回答形式が複数あり得る（東海地方に含まれる県はどこ？という質j門への回答として、①「愛知県、岐阜県、三重県、静岡県」②「愛知県、岐阜県、三重県」など。筆者が思ったこととしてこのような複数あり得る回答に対してどのように正解点を与えれば良いのか、そしてLLMの回答の要素は過不足なかったとしても、その回答の順番が正解とずれていてもそれを正解判定しても良いのかどうか、を勝手に分析者が設定しても良いのかどうか。がわからない）パターンも存在しており、その場合の採点方法はどうすれば良いのかがわからなかった。


## モデルデータの準備

```python
# フォルダが存在しない場合にのみ作成
MODEL_PATH = "./model/"
if not os.path.exists(MODEL_PATH):
    os.makedirs(MODEL_PATH)

def set_model_path(model_name: str) -> str:
    save_path = MODEL_PATH + model_name.split("/")[-1]
    return save_path


def download_model(model_name: str):
    """LLMをダウンロードする。"""
    save_path = set_model_path(model_name)
    if not os.path.isfile(save_path):
        snapshot_download(
            model_name,
            local_dir=save_path,
        )

        move_cache() # ダウンロードしたモデルのキャッシュを適切な場所に移動します。これにより、モデルの読み込みが効率的に行えるようになる
```

モデルをhugging faceからDLするためのコードです。


## DL済みのモデルとトークナイザの設定と初期化

```python
def setup_model(model_name: str) -> dict:
    """
    ダウンロード済みのモデルとトークナイザの設定と初期化をする

    - output
        {
            "tokenizer": AutoTokenizer,
            "llm": LLM, <-- モデルのインスタンス
            "sampling_params": SamplingParams, <-- サンプリングパラメータのインスタンス（テキスト生成で利用する）
        }
    """

    # download the model, if the file is already exist, this function does nothing
    download_model(model_name)

    save_path = set_model_path(model_name)

    # Acceleration with quantization configurations
    quantization_config = BitsAndBytesConfig(
        load_in_4bit=True,  # Enable 4-bit quantization
        llm_int8_threshold=6.0,  # Int8 mode threshold
        llm_int8_has_fp16_weight=True  # If true, reduce precision of weight to FP16
    )

    # Set up the model
    llm = AutoModelForCausalLM.from_pretrained(
        save_path,
        quantization_config=quantization_config,
        device_map="auto", # Automap to available devices (CPU or GPU)
        low_cpu_mem_usage=True  # Reduce CPU memory usage,
    )
    tokenizer = AutoTokenizer.from_pretrained(save_path)

    # Ensure the model is using CUDA if available
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    llm.to(device)

    outputs = {
        "tokenizer": tokenizer,
        "llm": llm,
    }

    # モデルをローカルに保存
    llm.save_pretrained(save_path)
    tokenizer.save_pretrained(save_path)

    return outputs


model = setup_model(model_name = model_name)
```

DLしたモデルの設定と初期化、トークナイザの初期化を行う。


モデルのDLと設定&初期化を別工程で行っている点が個人的には最初にイマイチ理解できず、つまづいたポイント。モデルデータのDLとその初期設定は同時に行っているものだと思っていたが、モデルデータのDLとそれに対して初期設定を適用して（量子化など）いる、という理解をすることで腑に落ちました。

## LLM出力の設定

```python
def get_answer(result: str, anchor_str: str = "
assistant"):
    """
    出力の中に余計なトークンが含まれるので、それをパースするための処理

    - output: str
    """
    print(f"{result=}")
    index = result.find(anchor_str)
    # インデックスが見つかった場合、インデックスより後の部分を取得
    if index != -1:
        start_position = index + len(anchor_str)
        result = result[start_position:].strip()  # strip()を使って前後の空白を削除
    else:
        result = result


    try:
        _ = int(result) # 整数型で返せているかをチェック
    except Exception as e:
        matches = re.search(r'(?<=assistant).+', result, re.DOTALL)
        result = matches.group(0).strip() if matches else ""

        result = re.findall(r'\d+', result)
        result = result[0]

    return result
```

LLMの出力には余計なトークンが含まれています。そのため、除外する必要があります。ここでは、その邪魔なトークンを除外するための処理をしています。

処理の細かいところを理解する必要はないかもしれないですが、ざっくり開設すると、2つの処理が行われれており、特定の形で出力される場合には、`if index != -1:`の処理の内部で対応でき、そうでない場合は、`try, except`の処理で対応させています。

Llama3.1とそれ以外とで出力パターンが異なるため、2つの処理に別れています。

## JcommonsenseQAの実行

```python
def run_llm(
        model: dict,
        list_qa: list,
) -> list:
    """
    DLしたモデルで推論を実行させる。
    """

    
    dict_result = {"model_name": model["llm"].config._name_or_path.split("/")[-1]}
    list_result = []


    for dict_qa in list_qa:
        messages = dict_qa["messages"]
        answer = dict_qa["answer"]

        input_ids = model["tokenizer"].apply_chat_template(
            messages,
            add_generation_prompt=True,
            return_tensors="pt"
        ).to(model["llm"].device)

        output_ids = model["llm"].generate(
            input_ids,
            max_new_tokens=1024,
            temperature=0.5,
        )

        llm_answer = model["tokenizer"].decode(output_ids[0], skip_special_tokens=True)
        llm_answer = get_answer(llm_answer)
        llm_answer = int(llm_answer)

        dict_qa["llm_answer"] = llm_answer
        dict_qa["is_correct"] = llm_answer==answer

        list_result.append(dict_qa)
        save_temp_result(list_result)

    try:
        # プログラムの終了前におまじないとして.
        import torch.distributed as dist
        dist.destroy_process_group()
    except Exception as e:
        print(str(e))

    dict_result["qa_results"] = list_result

    return dict_result

run_llm_results = run_llm(model,list_qa[:1])
```

ここまでで定義した各関数を使いながら、LLMを実行させてJcommonsenseQAを考えさせています。その結果を`dict_result`というオブジェクトに入れています。


## 実行結果を採点する

```python
qa_results = run_llm_results.get("qa_results", "failed to execute")

counts = 0
for i, res in enumerate(qa_results):
    if res.get("is_correct"):
        counts += 1

score = float(counts)/len(qa_results)
score = '{:.3f}'.format(score)
score = float(score)
print(score)
```

is_correct（=正解したかどうかのフラグ（`True`または`False`））の件数をカウントして、それを問題数で割ってあげることで、正解率を算出しています。

# 筆者の実行結果のまとめ

以下の3モデルに対して日本語性能を比較してみました。結果は、スコアでは、サイバーエージェントのモデルが最も性能が高く、0.877でした。しかし、サイバーのモデルはモデルサイズが22Bとそれ以外のモデルと比較して非常に大きくそれを加味すると、swallowが8Bでも健闘している点は特筆すべき事項だと思います。

また、今回の実行では正確に時間を測っていないのですが、notebookの実行から終了までにかかった時間をざっくり見積もると、Llama3.1とSwallowが約3時間、サイバーのモデルが約9時間かかっており、モデルサイズに比例して実行時間も長くなっていることがわかりました。

- Llama3.1：0.511
- Swallow：0.866
- cyber：0.877

これらのことから、日本知識を持っていることの重要性が高いタスクに関してはサイバーまたはSwallowのモデルを利用することが考えられます。その上で実行時間の観点から実用性で言えば、swallowが良いという考え方ができます。

# まとめ


- 今回はローカルLLMの性能比較のために日本知識を問うJcommonsenseQAという問題を解かせてみました。
- 日本語以外の多言語を学習したLlama3.1とそれを日本語データでファインチューニングしたSwallow、最初から日本語を主に学習させたcalm22bという特性の違うモデル同士でタスクを解かせることでそれぞれの違いをはっきりと見出すことができました。
    - 一つは、日本知識の性能という意味では、calm22bが強いものの、swallowも十分それに近しい性能を発揮していました。
    - 二つ目は、実行時間の観点でcalm22bが9時間、swallowが3時間と3倍の差がありました。
        - 当たり前かもしれませんが、日本の知識を考えさせるにはやはり、日本語データでの学習（それが事前学習なのか事後学習（Fine Tuning）なのかは問わない）が必要なのだということを立証できたと思います。
- Llamaはそもそもスコアが低すぎたため、この議論の俎上には上がってきませんでした。
- 今回はJcommonsenseQAという日本知識を問うタスクを解かせた結果このようにSwallowが強かったですが、推論やそれ以外のタスクを解かせると別のLLMが強い、ということが見えてくると思います（それがネット記事でよく見かけるベンチマークでの比較結果なのですが）。
    - 今回はJcommonsenseQAというそれらの一つを自分でやってみたことで、意外とここでつまづいてしまう、ということを肌で実感することができました。結果そのものを元に何かしらの意思決定をすることはないかもしれないですが、少なくとも自分の中で、LLMの性能比較をするときにつまづきやすいポイントやどう回避したら良さそうか、という勘所が働きそうな経験値を得ることができた点は大きな収穫だと思っています。