最近のローカルLLMをMLflowを使って比較してみる

ChatGPTの公開から丁度一年が経ちましたね。激動の一年でした。

# 導入

何となく最近出てきたモデルいろいろ試したいなーという気分になったので、2023年11月時点でhuggingface上の比較的人気があるモデルをいくつか試してみようという趣旨の内容です。

今回は、評価用QAのデータセットも作って、モデルごとの回答を比較したいと思います。

:::note warn
厳密なベンチマークというわけではありません。

また量子化後のモデルを利用しますし、こういうこともあるよね、程度で結果を見てください。
:::

いつものようにDatabricks上で検証しました。


# 対象のモデル

今回は以下のパラメータサイズが7B - 13Bのモデルを対象に比較します。
大型のモデルは別の機会に。Yi-34Bとかも使ってみたい。

| # | モデル名 | ライセンス |
|:-:|:-:|:-:|
| 1  | [openchat/openchat_3.5](https://huggingface.co/openchat/openchat_3.5)   | Apache 2.0 |
| 2  | [Intel/neural-chat-7b-v3-1](https://huggingface.co/Intel/neural-chat-7b-v3-1)  | Apache 2.0  |
| 3  | [berkeley-nest/Starling-LM-7B-alpha](https://huggingface.co/berkeley-nest/Starling-LM-7B-alpha)  | CC-by-NC 4.0  |
| 4  | [microsoft/Orca-2-13b](https://huggingface.co/microsoft/Orca-2-13b)  | Microsoft-Research License  |

なお、表はオリジナルのモデル名を記載していますが、実際の比較は全て[TheBloke兄貴](https://huggingface.co/TheBloke)がhuggingfaceにアップしているGPTQの4bit 量子化モデルを利用して行います。
そのため、オリジナルモデルよりも精度が若干悪化していると思われます。


また、今回かなり長いです。
結果だけ見たい方は比較結果のチャプターまで飛ばしてください。

# 簡単に比較モデル解説

## OpenChat 3.5

C-RLFTという手法？でファインチューニングされた言語モデル。
基盤モデルはMistral 7B。

多くのベンチマークでChatGPT(GPT-3.5?)と同等かそれ以上であり、X社のGrokよりも上回っていることがアピールされています。
最近の私のお気に入り。

## Neural-chat-7b-v3-1

Intel社が開発した7Bサイズの言語モデル。
こちらも基盤モデルがMistral 7Bであり、Open-Orca/SlimOrcaのデータセットをDPOでファインチューニングしている。
ベンチマークではMistral 7Bより高い性能となっている。

## Starling-LM-7B-alpha

上のOpenChat 3.5を基にファインチューニングされたモデル。
こちらもベンチマークでGPT-3.5-Turboを超える性能評価となっている。

普通に最近の7BモデルはGPT-3.5を越えた結果がでたりするのが恐ろしい。
（一面的なものだと思うが）

## Orca-2 13B

この中で唯一の13Bサイズの言語モデル。
Microsoftが開発。Llama-2をベースにファインチューニングしたもの。

Orcaは前のバージョンのものから評判がよく（ただし日本語性能は低い）、バージョンが上がってどうなったのか確認したかった。7Bモデルのあるのですが、今回は13Bモデルで試します。

# 比較方法

~~流行りにのってみたかったので~~ Wikipediaの[「葬送のフリーレン」のページ](https://ja.wikipedia.org/wiki/%E8%91%AC%E9%80%81%E3%81%AE%E3%83%95%E3%83%AA%E3%83%BC%E3%83%AC%E3%83%B3)にある概要とあらすじ部分をコンテキストとして、各モデルにいくつかの質問を回答させて比較します。

回答比較のために、[以前の記事](https://qiita.com/isanakamishiro2/items/8b6214b8dde113cee389)でもやってみたMLflowのEvaluation機能を使います。

※　使用するWikipediaの内容や、質問の内容は以下の処理内容部分で示します。


# 比較処理詳細

以下、評価のコードやデータセットの解説です。

## Step 1. MLflowロギング用のカスタムpyfuncクラスを作成

MLflowのEvaluate機能を使うため、MLflowにロギングするためのカスタムpyfuncクラスを作成します。
[以前の記事](https://qiita.com/isanakamishiro2/items/835b691c72bcb16b52b7)で作ったものを、コンテキストを使って質問に回答するように拡張しました。

<details><summary>exllamav2_pyfunc_qa_chat_model.py</summary>

```python
import mlflow
import pandas as pd
import torch
import transformers
import os
import json

from typing import Any, List, Dict, Union, Mapping, Optional, AsyncIterable, Awaitable

from langchain.embeddings import HuggingFaceEmbeddings

from langchain.schema.output_parser import StrOutputParser
from langchain.schema.runnable import RunnablePassthrough, RunnableLambda
from langchain.schema.runnable.base import RunnableSequence

from langchain.prompts import ChatPromptTemplate
from langchain.prompts.chat import (
    ChatPromptTemplate,
    SystemMessagePromptTemplate,
    AIMessagePromptTemplate,
    HumanMessagePromptTemplate,
    MessagesPlaceholder,
)
from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory

from operator import itemgetter

from exllamav2_chat import ChatExllamaV2Model

# Define a custom PythonModel
class ExllamaV2QAChatChainModel(mlflow.pyfunc.PythonModel):
    def load_context(self, context):
        """モデルを初期化する。モデルのパスはartifactsから取得"""

        model_path_local = context.artifacts["llm-model"]
        # LLM/Tokenizerのロード
        _model = ChatExllamaV2Model.from_model_dir(model_path_local)

        self.exllama_model = _model.exllama_model
        self.exllama_config = _model.exllama_config
        self.exllama_tokenizer = _model.exllama_tokenizer
        self.exllama_cache = _model.exllama_cache

    def chat_chain(
        self,
        history: List[Mapping[str, str]] = None,
        memory_window_size: int = 4,
        params: Optional[Dict[str, Any]] = None,
    ) -> RunnableSequence:
        """
        Generates a chain of responses for a given history of conversation.

        Args:
            history (List[Mapping[str, str]]): A list of conversation messages represented as a dictionary with "user" and "assistant" keys.
            memory_window_size (int): An integer representing the number of previous conversation messages used to generate the current response.
            system_prompt (str): An optional string representing a prompt that can be used by the system to start off the conversation.
            params (Optional[Dict[str, Any]]): An optional dictionary containing additional parameters to be passed to the model.

        Returns:
            RunnableSequence: An object representing the entire conversation with chain responses.

        Examples:
            >>> params = {
                    "human_message_template": "GPT4 Correct User: {}
",
                    "ai_message_template": "GPT4 Correct Assistant: {}",
                    "repetition_penalty": 1.2,
                    "temperature": 0.1,
                    "max_new_tokens": 512,
                }
            >>> history = [
                    {"user": "Hi there!", "assistant": "Hello!"},
                    {"user": "How are you doing today?", "assistant": "I'm doing well, thanks for asking. How about you?"},
                ]
            >>> system_prompt = "You are a helpful chatbot."
            >>> chain = chatbot.chat_chain(history=history, system_prompt=system_prompt, params=params)
            >>> chain.invoke(""I'm doing pretty well too. Do you have any plans for the weekend?")
        """

        _history = history or []
        _params = params or {}
        _params = _params.copy()
        _system_prompt = _params.pop("system_prompt", "")
        _doc_line_sep = _params.pop("prompt_line_separator", "\n")
        _context = _params.pop("context", "")


        # 会話履歴から、Memoryを構成
        memory = ConversationBufferWindowMemory(
            k=memory_window_size,
            memory_key="history",
            input_key="question",
            return_messages=True,
        )
        for h in _history:
            memory.save_context(
                {"question": h["user"]},
                {"output": h["assistant"]},
            )
        template = (
            "Use the following pieces of context to answer the question at the end. "
            "If you don't know the answer, just say that you don't know, don't try to make up an answer. "
            "Use three sentences maximum and keep the answer as concise as possible. "
            "## Context:\n"
            f"{_context}\n\n"
            "## Question:\n"
            "{question}"
        )

        response_prompt = ChatPromptTemplate.from_messages(
            [
                SystemMessagePromptTemplate.from_template(_system_prompt),
                MessagesPlaceholder(variable_name="history"),
                HumanMessagePromptTemplate.from_template(template),
                AIMessagePromptTemplate.from_template(""),
            ]
        )

        chat_model = ChatExllamaV2Model(
            exllama_config=self.exllama_config,
            exllama_model=self.exllama_model,
            exllama_tokenizer=self.exllama_tokenizer,
            exllama_cache=self.exllama_cache,
            **_params,
        )

        chain = (
            {
                "history": RunnableLambda(memory.load_memory_variables)
                | itemgetter("history"),
                "question": RunnablePassthrough(),
            }
            | response_prompt
            | chat_model
            | StrOutputParser()
        )

        return chain

    def predict(
        self,
        context,
        model_input: List[str],
        params: Optional[Dict[str, Any]] = None,
    ) -> List[str]:

        _params = params or {}
        chain = self.chat_chain(params=_params)

        return chain.batch(model_input)
```

</details>

## Step 2. コンテキストデータの準備

Wikipediaより、概要とあらすじ部分をコピーし、```knowledge.txt```という名前で保管します。

```:knowledge.txt
『葬送のフリーレン』（そうそうのフリーレン、英: Frieren: Beyond Journey’s End[2]）は、山田鐘人（原作）、アベツカサ（作画）による日本の漫画。『週刊少年サンデー』（小学館）にて、2020年22・23合併号より連載中[1]。

第14回マンガ大賞、第25回手塚治虫文化賞新生賞受賞作。

概要
魔王を倒した勇者一行の後日譚を描くファンタジー[1]。

第2巻が発売された際に有野晋哉、浦井健治、江口雄也、小出祐介、近藤くみこ、須賀健太、鈴木達央、豊崎愛生が本作にコメントを寄せている[3]。

原作担当の山田の前作である「ぼっち博士とロボット少女の絶望的ユートピア」の連載終了後、いくつかの読切のネームを描くもうまくいかず、担当編集者から、最初の受賞作が勇者・魔王物のコメディーだったことから、その方向でギャグを描いてみてはと提案したところ、いきなり「葬送のフリーレン」の第1話のネームが上がってきた[4][5]。その後、作画担当をつけることになり、同じく担当していたアベにネームを見せたところ「描いてみたい」と反応があり、フリーレンのキャラ絵を描いてもらったところ、山田からも「この方ならお願いしたい」と返答をもらったため、アベが作画担当になった[4][5]。ちなみにマンガ大賞を受賞した2021年3月現在、山田とアベは一度も会ったことがないという[4]。

本作のタイトルの由来は、山田が考えたタイトル案がありながら、編集部でも検討をし、編集部会議で「いいタイトルが決まったら自腹で賞金1万円出します」と担当編集者が募ったところ、副編集長が出したタイトル案の中に「葬送のフリーレン」があり、最終的に山田、アベに決めてもらい現在の題名となった[6]。

2022年9月に単行本9巻が発売、ほぼ同時にアニメ化が発表され[7]、同年11月に展開媒体がテレビアニメであることが発表された[8]。

2023年10月時点で単行本の累計発行部数は1100万部を突破している[9]。

あらすじ
魔王を倒して王都に凱旋した勇者ヒンメル、僧侶ハイター、戦士アイゼン、魔法使いフリーレンら勇者パーティー4人は、10年間もの旅路を終えて感慨にふけっていたが、1000年は軽く生きる長命種のエルフであるフリーレンにとって、その旅はきわめて短いものであった。そして、50年に一度降るという「半世紀（エーラ）流星」を見た4人は、次回もそれを見る約束を交わしてパーティーを解散する。

50年後、すっかり年老いたヒンメルと再会したフリーレンは、ハイターやアイゼンとも連れ立って再び流星群を観賞する。まもなくヒンメルは亡くなるが、彼の葬儀でフリーレンは自分がヒンメルについて何も知らず、知ろうともしなかったことに気付いて涙する。その悲しみに困惑したフリーレンは、人間を知るためと魔法収集のために旅に出る。

それから20年後、フリーレンは老い先短いハイターを訪ねる。ハイターは魔導書の解読と戦災孤児のフェルンを弟子にすることをフリーレンに依頼。その4年後、フリーレンは魔導書の解読を終え、フェルンは1人前の魔法使いとなる。ハイターの最期を看取った2人は諸国を巡る旅に出発する。

さらにその後、アイゼンの許を訪れたフリーレン達は、彼の協力によりフリーレンの師匠で伝説の大魔法使いフランメの手記を入手、死者の魂と対話できる場所・オレオールの存在を知る。その在処は大陸の北端エンデ。かつての魔王城の所在地だった。当ての無かった旅は、ヒンメルとの再会を目指す北への旅となる。そしてアイゼンの弟子シュタルクや僧侶ザインを仲間に加え、フリーレン達の旅は続く。
```


## Step 3. モデル準備

ノートブックを作成し、まずは必要なパッケージをインストール。

```python
%pip install -U -qq "mlflow-skinny[databricks]>=2.8.1" "langchain==0.0.340" "transformers==4.35.2" "accelerate==0.24.1" "exllamav2==0.0.9"

dbutils.library.restartPython()
```


今回使うモデルの名前、モデルの保管場所パス、プロンプトテンプレートの設定を辞書型のリストで定義しておきます。
なお、モデルは事前に全部ダウンロードしておいたものを利用します。

```python

models = [
    {
        "name": "OpenChat-3.5",
        "path": "/Volumes/training/llm/model_snapshots/models--TheBloke--openchat_3.5-GPTQ",
        "params": {
            "human_message_template": "GPT4 User: {}
",
            "ai_message_template": "GPT4 Assistant: {}",
            "system_message_template": "{}",
        },
    },
    {
        "name": "neural-chat-7B-v3-1",
        "path": "/Volumes/training/llm/model_snapshots/models--TheBloke--neural-chat-7B-v3-1-GPTQ",
        "params": {
            "system_message_template": "### System:\n{}",
            "human_message_template": "### User:\n{}",
            "ai_message_template": "### Assistant:\n{}",
        },
    },
    {
        "name": "Starling-LM-7B-alpha",
        "path": "/Volumes/training/llm/model_snapshots/models--TheBloke--Starling-LM-7B-alpha-GPTQ",
        "params": {
            "human_message_template": "GPT4 User: {}
",
            "ai_message_template": "GPT4 Assistant: {}",
            "system_message_template": "{}",
        },
    },
    {
        "name": "Orca-2-13B",
        "path": "/Volumes/training/llm/model_snapshots/models--TheBloke--Orca-2-13B-GPTQ",
        "params": {
            "system_message_template":"
system\n{}
",
            "human_message_template":"
user\n{}
",
            "ai_message_template":"
assistant\n{}",
        },
    },
]
```

MLflowのロギング用設定を準備。
ここで```knowledge.txt```を読み込み、推論用のパラメータに入れ込みます。

```python
import mlflow
import os
from mlflow.models.signature import infer_signature
from exllamav2_pyfunc_qa_chat_model import ExllamaV2QAChatChainModel

# ナレッジデータを読み込む
with open("knowledge.txt", "r") as f:
    knowledge = f.read()

# モデルが推論で保持するデフォルトのパラメータ
DEFAULT_PARAMS = {
    "system_prompt": "You are a helpful assistant.",
    "prompt_line_separator": "\n",
    "human_message_template": "GPT4 User: {}
",
    "ai_message_template": "GPT4 Assistant: {}",
    "system_message_template": "{}",
    "temperature": 0.1,
    "top_k": 50,
    "top_p": 0.8,
    "repetition_penalty": 1.2,
    "max_new_tokens": 1024,
    "context": knowledge,
    "verbose": False,
}

# Input/Output Example
sample_input = ["LLMとは何ですか？"]
sample_output = ["LLMとは大規模言語モデルのことです。"]

# 必要な外部モジュールのコード
code_path = [f"{os.getcwd()}/exllamav2_chat.py"]
```

## Step 5. 評価用データセットの準備

評価用の質問と答えのペアをPandasデータフレームとして作ります。
inputsが質問でground_truthが正しい答えです。

大半はコンテキストの中身そのままで回答できますが、いくつかは少しひねった内容になっています。
例えば「ヒンメルが亡くなったのは～」は、出会ってから10年旅をして、さらにその50年後に亡くなっているので、計60年後という計算をする必要があります。
その他、コンテキスト内に弟子の記述があるものは、逆に師匠を聞くなどの質問を入れています。

```python
import pandas as pd

# 評価用データセット
eval_df = pd.DataFrame(
    {
        "inputs": [
            "漫画「葬送のフリーレン」の作者は誰？",
            "「葬送のフリーレン」はどんな賞を受賞したことがある？",
            "原作者と作画担当が初めて直接会ったのはいつ？",
            "魔王を倒したのは誰？",
            "ヒンメルが亡くなったのは、フリーレンと出会ってから何年後？",
            "フリーレンが現在目指している場所はどんなところ？",
            "フェルンの最初の師匠は誰？",
            "アイゼンの弟子は誰？",
            "勇者ヒンメル、僧侶ハイター、戦士アイゼン、この3人の中で最も長生きなのは誰？",
            "大魔法使いフランメの弟子は誰？",
        ],
        "ground_truth": [
            "山田鐘人（原作）、アベツカサ（作画）です。",
            "第14回マンガ大賞、第25回手塚治虫文化賞新生賞を受賞しています。",
            "未だ出会ったことが無い",
            "勇者ヒンメル、僧侶ハイター、戦士アイゼン、魔法使いフリーレンらの4人",
            "約60年後",
            "死者の魂と対話できる場所・オレオール。大陸の北端エンデにあり、かつて魔王城の所在地だった場所",
            "僧侶ハイター",
            "シュタルク",
            "戦士アイゼン",
            "魔法使いフリーレン",
        ],
    }
)

```


## Step 4. モデルのロギングと評価記録

まずはモデルのロギングと評価処理の関数を定義。

```python
from exllamav2_pyfunc_qa_chat_model import ExllamaV2QAChatChainModel
import gc

def log_and_eval_llm(name:str, model_path:str, params:dict):

    _params = DEFAULT_PARAMS.copy()
    _params.update(params)

    # mlflow保存用のsignature作成
    signature = infer_signature(sample_input, sample_output, _params)

    with mlflow.start_run() as run:
        qa_model  = mlflow.pyfunc.log_model(
            artifact_path="model",
            python_model=ExllamaV2QAChatChainModel(),
            extra_pip_requirements=[
                "langchain>=0.0.340",
                "exllamav2==0.0.9",
                "transformers>=4.35.2",
                "accelerate>=0.24.1",
            ],  # 依存ライブラリ
            signature=signature,
            artifacts={
                "llm-model": model_path,
            },
            code_path=code_path,
            await_registration_for=1200,  # モデルサイズが大きいので長めの待ち時間にします
            input_example=sample_input,
        )

        # 評価データセットを与えて自動評価
        results = mlflow.evaluate(
            qa_model.model_uri,
            eval_df,
            targets="ground_truth",  # 正しい回答を保持した列
            model_type="question-answering",  # 評価種別。この内容で計算されるMetricsが変わる。他にtext-summarization, textが現時点で設定可能
            extra_metrics=[mlflow.metrics.latency()], # 追加評価メトリクスの指定
            evaluators="default",
        )

    gc.collect()

    # 各種メトリクスの評価結果をテーブル表示
    return results.tables["eval_results_table"]
```

最後にモデル定義変数を使ってループを回すことによって、４つのモデルをロギング・評価します。
最後に、評価結果をテーブル表示。

```python
import pandas as pd

results = []

for m in models:
    name = m["name"]
    model_path = m["path"]
    params = m["params"]

    ret = log_and_eval_llm(name, model_path, params)
    ret["name"] = name
    results.append(ret)

pdf = pd.concat(results, axis=0)
display(pdf)
```

以下のように、いくつかの評価指標と合わせて推論結果がモデルごとに表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168727/35697e32-141e-f7af-610c-b6b318c9d89e.png)

では、詳細に見ていきましょう。

# 結果

MLflowのUIを使って、各モデルごとの結果を見ていきます。
以下が、設問と正解、そして各モデルごとの回答結果です。

1～5問
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168727/bbcc6f18-dbf5-917f-c383-0ec084ee91a2.png)

6～10問
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168727/e7a57f29-9731-0a03-6e61-d2fc92d49db2.png)

正誤の数（ただし惜しいものを△）としてまとめると、以下のようになります。
今回は日本語で答えれなかったものは✖にしました。

| - | Orca-2 | Starling-LM | Neural-Chat | OpenChat | 
|:-:|:-:|:-:|:-:|:-:|
| ○の数  | 6  |  5 | 6  |  **7** |
| ✖の数 | 3  |  **4** | **4**  | 2  |
| △の数 | 1  |  1 |   |  1 |

やはり計算が必要な問題はいずれも正解できませんでした。
他、ストレートに回答が難しいものは正解率が低い傾向にありますね。

Starling-LMはOpenChatと同等以上の結果になるかなと思ったのですが、日本語性能は落ちているような気がします。これは量子化の影響かもしれないのでもう少し掘り下げが必要かな。

# まとめ

というわけで、オリジナルの評価データセットも作って、モデル間の比較をしてみました。
想像以上に長い記事になってしまって反省。

つぎは懲りずにパラメータサイズが大きいモデルとかもやってみたいと思います。