【第二回目】LLMを商用で利用して低コストでWebアプリと連携してサービスとして提供するための検討

# 概要

## はじめに

- 第一回目を読んでない方は[こちら](https://qiita.com/iydmsk/items/14a2915bf655e4db193d)からお願いします。

## 第二回目のゴール

- 第一回目で絞り込んだモデルで技術検証して、特定の用途で使えそうかどうかを確認する

## 検証方法

- 簡単な方法としてAPIでニュース情報を取得してきて下記のようなタスクを実行して結果を比較していきます。すべてチャット形式で入力して、出力として回答文章を得るような使い方をしていきます。
    - 予測
        - 次の日に何が起きるのか、など
    - リコメンド
        - 今日はどういう行動をすべきか、など
    - キーワード抽出
        - 人名、地名、会社名の抽出、など
    - 分類
        - 国内/国外/エンタメ/経済/IT/科学、など
    - 要約
        - 各記事の要約、全記事の要約、など

## （忙しい方のために）第二回目のまとめ

- 今回は、Gemini Pro, Llama-2のみを比較検証した。
- Gemini Proが今回のようなあまり洗練されていないプロンプトでもかなり正確に理解して意図した結果を出してくれました、処理時間も短いです。
- Llama-2は日本語対応していないこともあり、また使い方も若干学習する必要もありそうで、一旦はあまり良い結果を得ることはできませんでした。

# 検証

## 検証データの用意

- 今回はNewsAPIから最初に日本語のデータを取得します。
    - NewsAPIのサイト：[https://newsapi.org/register](https://newsapi.org/register)
        - 有償プランにすれば商用利用も可能です。
    - 参考サイト：[https://zenn.dev/uinoue/articles/660ee202373f64](https://zenn.dev/uinoue/articles/660ee202373f64)
    - 登録してAPIKEYを取得します。
    - API経由ではデフォルトで20件取得していくるので、下記のコードで取得した結果を全てcsvでそのまま保存しておきます。
        - 結果データの中で、`description`は長いですが完全な文章ではなく、`content` もほぼ入っていないようなので、今回は検証用として少し短いですが`title`だけを使います。
        - コード：
            
            ```python
            import requests
            import pandas as pd

            # xには自分で取得したAPIKEYを入れること
            headers = {'X-Api-Key': 'xxxxxxxxxxx'}
            
            url = 'https://newsapi.org/v2/top-headlines'
            params = {
                'country': 'jp'
            }
            
            response = requests.get(url, headers=headers, params=params)
            
            if response.ok:
                data = response.json()
                df = pd.DataFrame(data['articles'])
                df.to_csv('output_news.csv', index=False)
            ```
            

## Gemini Proの利用

- 現在は無料でAPIをお試し利用できます。
    - こちらを参考にてAPI Keyを作成します。
        - 参考：[https://rpahack.com/start-gemini-pro](https://rpahack.com/start-gemini-pro)
- pythonからAPIを使うのも非常に簡単です。
    - サンプルコードは公式のマニュアルに書いてあってほぼその通りで大丈夫です。
        - 参考：公式マニュアル：[https://ai.google.dev/api/python/google/generativeai](https://ai.google.dev/api/python/google/generativeai)
        - 準備
            
            ```bash
            $ pip install google-generativeai
            ```
            
        
        - サンプルコード
            
            ```python
            import google.generativeai as genai

            # xには自分で取得したAPIKEYを入れること
            genai.configure(api_key='xxxxxxxxx')
            model = genai.Model(name='gemini-pro')
            response = model.generate_content('Please summarise this document: ...')
            
            print(response.text)
            ```
            

## 検証コードの全体

- プロンプトの内容が非常に重要になってきますが、あくまで性能比較目的なのでモデル毎にカスタマイズすると分かりづらくなるため極力単純にしました。パラメータの調整や、もう少し構造的な結果を得るようにしたほうが特定のタスクには合っていると思いますが今回はそこまで検討できておりません。あくまで適当なプロンプトでの回答から比較するぐらいの検証に留まっています。実用的にはキーワードを抽出して他の処理と連携させたりする場合など、構造化された出力を得たい場合はJSONやYAMLなど一般的なフォーマットを指定するのが良さそうです。
- 検証用のプロンプトに入力する文章：
    - CLASSIFICATION
        - 次の括弧内のニュースをカテゴリーで政治、経済、エンタメ、海外、天気、スポーツ、科学、ITの中から1つ選んで、出力文章ではカテゴリー名だけを出力して。他の文字列は一切出力しないこと。「{ニュースのtitle}」
    - EXTRACTION
        - 次の括弧内のニュースから人名、地名、組織名をキーワードとして抜き出して、出力文章ではキーワード名をカンマ区切りで出力して、他の文字列は一切出力しないこと。「{ニュースのtitle}」
    - SUMMARY
        - 次の括弧内のニュースを、元の半分くらいの長さで要約して。「{ニュースのtitle}」
    - RECOMMEND
        - 次の括弧内のニュースを踏まえて日本の一般市民は本日はどのように行動すべきか。「{ニュースのtitle}」
    - PREDICTION
        - 次の括弧内のニュースの次の日に発生しそうなニュースを答えて。「{ニュースのtitle}」
    - TRANSFORMATION
        - 次の括弧内のニュースを、6歳の子供でも理解できるように言い換えて。「{ニュースのtitle}」

```python
from enum import Enum
import time
import pandas as pd
import google.generativeai as genai
from googletrans import Translator
import replicate

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# xには自分で取得したAPIKEYを入れること
GEMINI_APIKEY = 'xxxxxxxxxxxxxxxxxx'

class TestCases(Enum):
    CLASSIFICATION = '次の括弧内のニュースをカテゴリーで政治、経済、エンタメ、海外、天気、スポーツ、科学、ITの中から1つ選んで、出力文章ではカテゴリー名だけを出力して。他の文字列は一切出力しないこと。「{}」',
    EXTRACTION = '次の括弧内のニュースから人名、地名、組織名をキーワードとして抜き出して、出力文章ではキーワード名をカンマ区切りで出力して、他の文字列は一切出力しないこと。「{}」',
    SUMMARY = '次の括弧内のニュースを、元の半分くらいの長さで要約して。「{}」',
    RECOMMEND = '次の括弧内のニュースを踏まえて日本の一般市民は本日はどのように行動すべきか。「{}」',
    PREDICTION = '次の括弧内のニュースの次の日に発生しそうなニュースを答えて。「{}」',
    TRANSFORM = '次の括弧内のニュースを、6歳の子供でも理解できるように言い換えて。「{}」',

class TestModels(Enum):
    GEMINI = 0,
    REP_LLAMA2_7B_CHAT = 1,
    REP_LLAMA2_13B_CHAT = 2,
    REP_LLAMA2_70B_CHAT = 3,
    ELYZA_7B_INST = 4,

B_INST, E_INST = "
", "
"
B_SYS, E_SYS = "
\n", "\n
\n\n"

SYSTEM_PROMPT_ENG = "You are a helpful, respectful and honest assistant."
SYSTEM_PROMPT_JPN = "あなたは誠実で優秀な日本人のアシスタントです。"

def main(test_model:TestModels):
    print(f'start model:{test_model.name}')
    df = pd.read_csv('output_news.csv')
    # title以外は使わないので削除
    df = df[['title']]
    trans = Translator()

    # モデルの準備
    if test_model is TestModels.GEMINI:
        genai.configure(api_key=GEMINI_APIKEY)
        gemini_model = genai.GenerativeModel('gemini-pro')
		# ニュースの内容によっては意図せずに安全性にひっかかりそうなので念のため無効化します
        gemini_safety_settings = [
            {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
            {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}]
    elif test_model is TestModels.ELYZA_7B_INST:
        elyza_model_name = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
        elyza_tokenizer = AutoTokenizer.from_pretrained(elyza_model_name)
        elyza_model = AutoModelForCausalLM.from_pretrained(elyza_model_name)#, torch_dtype="auto")
        if torch.cuda.is_available():
            elyza_model = elyza_model.to("cuda")

    # 結果を入れる列を追加しておく
    for test_case in TestCases:
        df[test_case.name + '_' + test_model.name] = pd.NA
        df[test_case.name + '_' + test_model.name + '_time_sec'] = 0.0

    row_res_list = []

    for _, row in df.tail(8).iterrows():
        news_title = row['title']
        news_title_list = news_title.split('-')

        # -のあとに出処が書かれていることが多いので削除
        if len(news_title_list) > 1:
            news_title = "".join(news_title_list[0:-1])
            row['title'] = news_title

        is_translate = False
        if (test_model is TestModels.REP_LLAMA2_7B_CHAT) or \
            (test_model is TestModels.REP_LLAMA2_13B_CHAT) or \
            (test_model is TestModels.REP_LLAMA2_70B_CHAT):
                is_translate = True

        print(f'start title: {news_title}')
        for test_case in TestCases:
            print(f'start case: {test_case.name}')

            input_text = str(test_case.value).format(news_title)
            res_text = ''
            
            try:
                if is_translate:
                    input_text = trans.translate(input_text, dest='en').text
                    print(input_text)
                start = time.time() 

                if test_model is TestModels.GEMINI:
                    response = gemini_model.generate_content(input_text, safety_settings=gemini_safety_settings)
                    res_text = response.text
                elif test_model is TestModels.REP_LLAMA2_7B_CHAT:
                    response = replicate.run(
                        "meta/llama-2-7b-chat:f1d50bb24186c52daae319ca8366e53debdaa9e0ae7ff976e918df752732ccc4",
                        input={
                            "prompt": input_text,
                            "system_prompt": SYSTEM_PROMPT_ENG,
                        })
                    response_list = list(response)
                    res_text = "".join(response_list)
                elif test_model is TestModels.REP_LLAMA2_13B_CHAT:
                    response = replicate.run(
                        "meta/llama-2-13b-chat:56acad22679f6b95d6e45c78309a2b50a670d5ed29a37dd73d182e89772c02f1",
                        input={
                            "prompt": input_text,
                            "system_prompt": SYSTEM_PROMPT_ENG,
                        })
                    response_list = list(response)
                    res_text = "".join(response_list)
                elif test_model is TestModels.REP_LLAMA2_70B_CHAT:
                    response = replicate.run(
                        "meta/llama-2-70b-chat:02e509c789964a7ea8736978a43525956ef40397be9033abf9fd2badfe68c9e3",
                        input={
                            "prompt": input_text,
                            "system_prompt": SYSTEM_PROMPT_ENG,
                        })
                    response_list = list(response)
                    res_text = "".join(response_list)
                elif test_model is TestModels.ELYZA_7B_INST:
                    prompt = "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
                        bos_token=elyza_tokenizer.bos_token,
                        b_inst=B_INST,
                        system=f"{B_SYS}{SYSTEM_PROMPT_JPN}{E_SYS}",
                        prompt=input_text,
                        e_inst=E_INST,
                    )
                    with torch.no_grad():
                        token_ids = elyza_tokenizer.encode(prompt, add_special_tokens=False, return_tensors="pt")
                        output_ids = elyza_model.generate(
                            token_ids.to(elyza_model.device),
                            max_new_tokens=256,
                            pad_token_id=elyza_tokenizer.pad_token_id,
                            eos_token_id=elyza_tokenizer.eos_token_id,
                        )
                    res_text = elyza_tokenizer.decode(output_ids.tolist()[0][token_ids.size(1) :], skip_special_tokens=True)
                end = time.time() 
                time_diff = end - start
                if is_translate:
                    res_text_jp = trans.translate(res_text, dest='ja')
                    res_text = res_text_jp.text
                    print(res_text)
            except Exception as e:
                res_text = 'Error'
                print(e)
                time_diff = 0
            row[test_case.name + '_' + test_model.name] = res_text.replace('\n', '')
            row[test_case.name + '_' + test_model.name + '_time_sec'] = str(time_diff)
            # Geminiの1分間60回制限等に引っかからないように
            time.sleep(3.0)
        row_res_list.append(row)

    df_res = pd.DataFrame(row_res_list, columns=df.columns)
    df_res.to_csv(f'output_results_{test_model.name}.csv')

if __name__ == "__main__":
    # API経由ならば問題なさそうだが、ローカル実行の場合一度に多くのmodelをloadするのはメモリ的に厳しい可能性があるので、１つずつ実行することにする
    main(TestModels.GEMINI)
    main(TestModels.REP_LLAMA2_7B_CHAT)
    main(TestModels.REP_LLAMA2_13B_CHAT)
    main(TestModels.REP_LLAMA2_70B_CHAT)
    # main(TestModels.ELYZA_7B_INST)
```

- 生成の処理時間も計測できるようにしておきます。結果はモデル別にcsvで出力します。
- どのモデルをテストするかはmain関数の引数で指定してください。
- Gemini Pro以外の各モデルを動作させるためのメモ
    - ReplicateでホストされているモデルのAPIを使うためのメモ
        - 基本はそれぞれのモデルでpythonのサンプルコードが乗っています。今回の検証コードでも環境変数にAPIKEYを登録しておく必要があります。
            - 参考：[https://replicate.com/meta/llama-2-13b-chat/examples?input=python](https://replicate.com/meta/llama-2-13b-chat/examples?input=python)
        - 出力を文字列で出す方法はこちらが非常に参考になりました。
            - 参考：[https://qiita.com/hogeta_KL/items/588389291e318ba2c334](https://qiita.com/hogeta_KL/items/588389291e318ba2c334)
        - Llama2は基本的に英語みたいなので、
            - 入出力は翻訳したいところですが結論としては一旦そこまではしないことにします。
                - replicateとgoogletrans-v3.0.0でhttpx依存関係で競合が出たのでgoogletransが使っているhttpxが古いのが原因のようです、googletransを2.4.0なら入りましたが、すでに動かないようです。
                    - 参考：[https://info.drobe.co.jp/blog/engineering/poetry](https://info.drobe.co.jp/blog/engineering/poetry)
                    - `poetry add “googletrans<3.0.0”`
                - 一旦、googletransのほうは削除して、googletrans-pyというのを入れます。
                    - 参考：[https://pypi.org/project/googletrans-py/](https://pypi.org/project/googletrans-py/)
                - python3.9だとエラーが出ました、python3.10が必要でした。
        - Llama系のSystemプロンプト(あなたは誠実で優秀な日本人のアシスタントです。みたいなやつ)の書き方は何が正しいのかよくわかりません、何度か試さないと欲しい結果が得られないように思います。
        - Replicateの何度かやっているうちにすぐにrate limitに引っかかり、あまり試すことができず試行錯誤は諦めました。この検証コードも通しでは残念ながらできません。
    - ローカルで処理するモデルについてのメモ
        - 環境：MacBook Air M1, 16GB
        - ELYZA-japanese-Llama-2-7b-instruct
            - 参考：
                - 公式：[https://huggingface.co/elyza/ELYZA-japanese-Llama-2-7b-instruct](https://huggingface.co/elyza/ELYZA-japanese-Llama-2-7b-instruct)
                - CPUで動かそうとするとエラーが出たので、こちらを参考に修正
                    - 参考：[https://note.com/rcat999/n/n2bb716a4615f](https://note.com/rcat999/n/n2bb716a4615f)
                - 出力が短いせいか分類タスクなら5分くらいでできましたが、文章を生成するようなタスクは数時間たっても結果が出てこなかったので諦めました。CPU環境や専用GPUでない環境で実行するにはパラメータを量子化するようなllama.cppのようなものが必要とうことかと思います。
                    - 参考：[https://github.com/ggerganov/llama.cpp](https://github.com/ggerganov/llama.cpp)

## 結果の比較

- 生成された出力をそのままこちらに載せておきます。今回は出力文字数を限定しておらず、かなり長文も出力されていますのが、実際に使う場合は出力量を制限して使うことが多くなりそうです。今回はプロンプトはなるべくシンプルにして比較したかったので敢えて制限しておりません。
- 出力結果はかなり見辛くて申し訳ないですが、[こちら](https://planet-glazer-33e.notion.site/e6d457d6da0b4d049d9cae61f0b1d746?v=8da001ec69cb477a9efebc2085874628)のNotionのほうで公開しておきます。
    - 見やすいデータビューで共有する方法があれば教えてほしいです。

## 考察

- 今回、Gemini ProとReplicateのLlama2以外は試していませんので、Gemini Pro vs Llama2という比較になります。
    - 「ELYZA-japanese-Llama-2-7b-instruct」がCPUで現実的な時間内で処理できなかったため、おそらく他のモデルでも同様の可能性が高く一旦HuggingFaceからモデルをDLする必要な候補は検討から外しました。もともとの今回の要件ではGPUインスタンスを用意するのはコスト的に厳しいと考えているのが理由です。
    - とはいえ、Google Colabを使えば検証用としてはGPUインスタンスは簡単に用意できますし、Llamaの結果が英語翻訳を挟んだせいかあまりよくなかったのでELYZAやSwallowの性能はかなり気になるところです。
    - もちろんllama.cppを利用してCPU上かSocのGPU上で動作させる検証も勧めていきます。
- Gemini Proについて、
    - 性能としてはさすがといった感じでモデル自体の文脈理解力の高さを感じます。CLASSIFICATIONやEXTRACTIONでは出力形式を理解して意図したとおりに出力してくれました。内容もざっと見た限りではほぼ合っているように見えます。SUMMARYはさすがにニュースタイトルからさらに要約するのは無理があったのか文章の長さの指示は無視されてオリジナルのニュース本文の要約のような形になった、他の情報を付加してくれるのは、Gemini Proが2023/12までのデータを学習しているからであろうかそれなりに精度高く詳しく説明してくれています。もちろんハルシネーションも発生しています。
    - RECOMMEND：それらしい文章でしっかりと文脈を理解して出力している印象です（内容として薄い一般的なものですが）
    - PREDICTION：結果として有り得そうな数字も線形で予測されていてそれらしい内容になっています。
    - TRANSFORMATION：間違っているところもあるかもしれませんが、意図通りわかりやすい文章になっています
    - 処理速度もよほど大量のリクエストを送るような用途ではない限りそこまで問題にならない程度だと考えます。
- Llama-2について、
    - 全体的に実用レベルでの結果は得られていないです、おそらく日英翻訳を挟んでいることで英語特有の言い回しで正確に伝わっていないのと、プロンプトの使い方にコツがいるのかとは考えています。
    - 全体的に余分な文章が多く、CLASSIFICATIONやEXTRACTIONで特定の形式で結果を得るためにはもう少し英語で正確に指示する必要があるのかもしれません。
    - 倫理性や安全性のため回答できないというケースがそれなりに多い
        - 制限はなるべく外すようにシステムプロンプトを調整したつもりではありますが、特に7bでは政治経済や戦争の話はほぼ答えてくれませんでした。70bになればほぼ回答していくれているので、おそらく7bがシステムプロンプトに対応していないということだと考えられます。
    - TRANSFORMは全然無理でした。何と勘違いしてそういう出力になったのか推測できないレベルでした（プロンプトの英訳を見る限り伝わらなさそうな英語であったのと、そもそも毎回翻訳しているから英訳にブレが生じるのもよくなかったと思います）
    - Llama-2-7b-chat
        - 倫理性や安全性の問題で半分以上回答なし、回答があった場合でも、ほぼプロンプトの文脈を理解できているとは思えない内容でタスクを遂行できているとは言えない。
        - CLASSIFICATIONやEXTRACTIONは内容としては一部できている。
        - CLASSIFICATIONはほぼ政治という回答で不正確。
    - Llama-2-13b-chat
        - コード生成なのか判断ロジックをコードで説明しようとしているのかよくわからないが、よくわからないコードが入ってくることが多い。7bよりは複雑な文脈を理解できている感はあって、RECOMMENDやPREDICTIONでそれらしい内容を出力していることもある。
        - CLASSIFICATIONやEXTRACTIONは内容としては一部できている。7bとあまり変わらない。CLASSIFICATIONはほぼ政治
    - Llama-2-70b-chat
        - RECOMMENDやPREDICTIONでもそれらしい内容を出力している。
        - 重複や意味不明の内容で長文を出すことはほぼなくなって、一見それらしいちゃんとした文章を出力できている（ただし内容が文脈と合っていないことは多々ある、Gemini Proではまったく意味が通らない回答というのは一切無い）

## 次回以降、

- GPU環境で「ELYZA-japanese-Llama-2-7b-instruct」を動作させて同じタスクで検証してもる
- Gemini Proを使ってWebアプリを作ってみる。