前回の記事の続きです。
前回の記事で、Command R+(CR+)のようなモデルはまあ4bit程度までは量子化しても実用的に問題なさそうだという事が分かった。
では、今回はいよいよローカルのCR+でElyzaTasks100の採点をさせまくろう!と言いたいところだが、だがちょっと待ってほしい。
…実は、あの後「もしかしたらCR+よりもLlama3-70Bの方が日本語能力高かったりして…」と考えて、LLama3の8Bモデルと70BモデルにElyzaTasks100を解かせていつも通りCR+に自動評価させてみた。
その結果がこれである。
な、なんと、Llama3-70BはCommand R+を上回るスコアを獲得している!つまり、Llama3-70Bの日本語での能力はCommand R+を上回ってる可能性が高い!
Llama3をHuggingChatで触った時、なんか日本語で訊いても英語で返ってきたりするから、てっきり日本語が弱いと思っていたが、「You must answer all responses in Japanese.あなたは役に立つ誠実な日本人のアシスタントです。あなたは全ての回答に日本語で答えなければならない。」というような感じでシステムプロンプトで日本語で返す事を強制すれば、大体日本語でちゃんと返してくれるようになる。まあそれでも8Bモデルは時々英語で返しちゃったりする事もあるが。
上のグラフの評価の詳細は以下のスプレッドシートを参照。
https://docs.google.com/spreadsheets/d/1hdVvlDNS9lDF7XBlJStb_IDss9iPSzgDP4QmNKNq37Q/edit?usp=sharing
ちなみに(参考)と書いてるモデルについては、ELYZAの評価スプレッドシートから人力で評価した結果から引っ張ってきた。つまり私がCommand R+に自動評価させた結果とは条件が異なるわけだから、あんま参考にはならないかもしれない。
そういうわけで、Command R+よりもLlama3-70Bの方が優秀らしいと分かった以上は、ローカルで採点をやらせるモデルもLlama3-70Bの方を採用する事にする。(ちなみに今回は5bitのQ5_K_M量子化モデルを使った)
じゃあ本題に入る。ローカルでElyzaTask100を採点させるといっても、難しい話ではない。前々回の記事で書いたような、GPT-4やCommand R+のAPIを叩いて採点させていた部分のスクリプトをローカルのLlama.cppのサーバーAPIを叩くように書き換えるだけである。無論、Llama.cppサーバーにLlama3-70Bモデルをロードして起動しておく。
今回のコードについては記事の最後に載せておく。
しかし、なんの工夫もなくローカルLlama3-70Bに採点させると、私の環境ではかなり時間がかかってしまう。104BのCR+からLlama3-70Bに乗り換えた事で、パラ数が減ったのは嬉しいが、それでもQ4_K_Mの4bit量子化モデルでもまだ42.5GBもあるので、私のRTX4090のVRAM24GBには乗り切らない。すると、推論は1tps程度のトロイ速度しか出せない。これでは真面目にElyzaTasks100問を採点させると143分…2時間半くらいかかってしまう。
一方、RTX3090を3枚積みにしてVRAM72GB環境を構築してるoshizoさんのような環境では、VRAMにモデル全部載りきるので高速に推論できる。4bitのCR+によるElyzaTasks100の評価がたったの15分程度で完了してしまうという。
この如何ともしがたい計算資源格差を何とか埋める方法はないか?と思って色々考えた結果、以下のような工夫を盛り込む事にした。
①講評無しで点数だけ出力させる
②KVキャッシュを駆使する
③3回出力させて平均を取る
④グラマー(文法)で回答に制約を付ける
①から説明していく。
①講評無しで点数だけ出力させる
私の環境(2時間半)とoshizoさんの環境(15分)でどうしてここまで評価時間に差が付いたのか?それは、推論速度が全然違うんだから当然である。私の環境では1tps、oshizoさん環境では12.5tps。10倍以上の格差がある。
だったら、推論しなければ差は付かない。
何言ってんだと思うかもしれないが、まあ待ってほしい。GPT-4によるElyzaTasks100の自動評価スクリプトでは、GPT-4に点数とその理由(講評)をjsonとして出力させている。
Northern-System-Service/gpt4-autoeval: GPT-4 を用いて、言語モデルの応答を自動評価するスクリプト (github.com)
たしかに講評も出してくれた方が、AIが何故その点数を付けたのか納得できて嬉しい。とは言え、ぶっちゃけ評価するだけなら講評は無くても点数は付けられる。だから、プロンプトテンプレート(prompt_eval.txt)を以下のようにして、講評無しで点数だけを数字で出力させるようにする。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
あなたは言語モデルの採点者です。 問題, 正解例, 採点基準, 言語モデルが生成した回答が与えられます。 「採点基準」と「正解例」を参考にして、、回答を1,2,3,4,5の5段階で採点し、数字のみを出力してください。 # 問題 {input_text} # 正解例 {output_text} # 採点基準 基本的な採点基準 - 1点: 誤っている、 指示に従えていない - 2点: 誤っているが、方向性は合っている - 3点: 部分的に誤っている、 部分的に合っている - 4点: 合っている - 5点: 役に立つ 基本的な減点項目 - 不自然な日本語: -1点 - 部分的に事実と異なる内容を述べている: -1点 - 「倫理的に答えられません」のように過度に安全性を気にしてしまっている: 2点にする 問題固有の採点基準 {eval_aspect} # 言語モデルの回答 {pred} |
LLMの出力文字数指定(n_predict)も1にしてしまって、強制的に1文字しか出せなくしておく。
いくら推論速度が1tpsしかなくてトロくても、逆に言えば1文字出すだけなら1秒で済む!
とは言え、推論の前にプロンプト評価の時間も必要だから、本当に1秒で済むわけではない。むしろプロンプト評価の方が10秒くらい待たされたりして、もはや推論自体よりもそっちの方がボトルネックになる。
まあこのような工夫により、評価にかかる時間は143分から10分程度に劇的に短縮される!すでにoshizoさん環境より速い!(いやまあoshizoさんが同じ手法をやればもっと速くなっちゃうんだけど)
②KVキャッシュを駆使する
APIなどでは使えない、ローカル特有の便利機能がKVキャッシュだ。
KVキャッシュって何?というと、以前にツイートで説明したのでそれを引き写す。
まずKVキャッシュとは何か?というと、LLMの入力プロンプトのkey,valueをキャッシュする仕組み。LLMの推論が始まる前に入力プロンプトを処理してkeyとvalueを生成するわけだけど、毎回同じプロンプトだったりした場合、毎回同じ処理かけるのは時間の無駄だから、keyとvalueを保存しておいて使い回した方がいい。それがKVキャッシュ。 チャットボットだとそれまでの対話にプロンプトが追加される形だが、この場合もそれまでの対話の分はKVキャッシュが使い回されるので、プロンプト処理は追加プロンプト分だけで済む。 で、Llama .cppにもそういうKVキャッシュの仕組みがある。とは言え、直近のプロンプトしかキャッシュされない。チャットのセッションを切り替えて別の対話を再開したら、キャッシュ効かなくてイチからプロンプト処理するハメになる。だからKVキャッシュを自由にセーブ、ロードできる仕組みがあればいいのになあ…という話があった。Llama .cpp本体(main)には前からその機能あったけど、serverにはまだ無かった。(https://github.com/ggerganov/llama.cpp/issues/5843…)でも今、serverでもKVキャッシュを自由にファイルにセーブ、ロードできる仕組みのプルリクがマージ寸前まで行ってる。(https://github.com/ggerganov/llama.cpp/pull/6341…) これがマージされれば便利になるけど、ただし古い会話が押し流されてコンテキストから消えてくタイプのチャットボットだとKVキャッシュが効きづらい点に注意。一般的には①システムプロンプト②キャラ設定③会話1④会話2⑤会話3 みたいな感じでコンテキストが構成されるだろうけど、新しい会話を追加する時に、コンテキスト長の関係で代わりに③会話1をプロンプトから削除しちゃった場合、KVキャッシュは①~②までしか効かない。それ以降はプロンプト処理やり直し。これを改善する提案はIssueに上がってる(https://github.com/ggerganov/llama.cpp/issues/5793…)ものの、まだまだ検討段階で実装は遠そう
まあ要するに、プロンプト評価の計算結果を次の推論でも使い回せるのがKVキャッシュだ。同じプロンプトを使って何度も推論するならKVキャッシュを使えばプロンプト評価をスキップできる。
さらに最近のバージョンのLlama.cppではKVキャッシュをファイルに保存したり、保存したファイルからKVキャッシュをロードしたりする機能が追加されている。
ElyzaTasks100の評価においては、評価のたびに問題ごとにほとんど同じプロンプトで推論する事になる。異なるのは一番最後のモデル毎の回答部分だけである。だから、KVキャッシュの保存、ロードを使う事でプロンプト評価時間を短縮できる。
KVキャッシュ無しだと15.5分かかっていた評価が、KVキャッシュを使うと13.3分に短縮される。思ったほど縮まらないなとは私も思ったが、まあちょっとでも短縮されるのは嬉しい。
③3回出力させて平均を取る
①において、講評無しで点数だけを出力させる事で評価時間を劇的に短縮する事ができたわけだが、とは言え講評が無いとAIが何を考えてその点数を付けたのか分からなくなるというデメリットはある。
また、LLMというのは喋りながら考えるみたいな面がある。Chain of Thought(CoT)で精度が上がるなんてのは最たるものだろう。だから、講評無しで1文字だけ答えさせると精度が落ちるかもしれない。
そこで、代わりに1問ごとに3回ずつ答えさせて平均を取れば落ちた精度が補える事が期待できる。
3回答えさせると言っても、2回目と3回目についてはKVキャッシュが効いてるからプロンプト評価時間はゼロである。1tpsだから2文字だすのに2秒のロスしか追加されない。
oshizoさんはCR+による評価は採点のバラツキが大きい問題を指摘しており、「何回かやって平均取った方が良さそう」との事。
「LLMに何回も同じ事訊いて意味あるんか?」と思うかもしれないが、例えば”More Agents is All You Need”という論文では、何回も同じプロンプト投げて結果を多数決取る事で精度が上がるという結果が報告されている。
もちろん、精度的にあまり問題にならない局面なら1回で済ませても構わないかもしれない。
3回ずつ答えさせる事で、10分程度で済んでいた評価時間は13.3分に伸びてしまう。まあ実質3回評価してるのだからタイパはいいかもしれない。
④グラマー(文法)で回答に制約を付ける
GPT-4のAPIでもjsonなどで出力フォーマットを強制する事ができるが、ローカルのLlama.cppでもグラマー(文法)という正規表現みたいなフォーマットで出力に制約を付ける事ができる。
それこそ任意のjsonで出力するように強制する事もできる。
制約が無い場合、いくらプロンプトで「1~5の数字で答えて」って指示しても、たまに数字じゃない文字を出力してくるような場合もあって、困る。
だから、例えばこのようなシンプルなグラマーを指定すれば、1~5の数字1文字だけ出力する事を強制する事が可能だ。
1 |
root ::= [1-5] |
ちなみに、現在のところCR+でグラマーを使って推論させるとLlama.cppが落ちるという問題が起きてるので注意して欲しい。Llama3なら問題ない。
そういうわけで、上に挙げたような工夫によって、GPUが1枚しか無くてLlama3-70Bを高速に推論できない環境でも、13.3分というそれなりに高速にElyzaTasks100の評価が回せるようになった。
で、さっそくこれで各モデルのElyzaTasks100の回答を採点させてみた結果、こうなった。↓
評価の詳細はこちらのスプレッドシート↓
https://docs.google.com/spreadsheets/d/1eMZVhmP_MhT-Lzz5xkVqGOVdoAE1Sb96glRpHM8PPNg/edit?usp=sharing
なるほど。全体的にCR+に評価させた時より低めに出てるが、見た感じ妥当な結果が出ており、採点結果は指標としても一応信頼できそうな感じではないだろうか。
3回の評価での誤差も、最大でも0.08点程度の差に収まっている。あんま採点にバラツキが無いので3回ずつ答えさせるまでもなかったかもしれない。
ちなみにCR+に自動評価させた時の結果もまとめている↓
https://docs.google.com/spreadsheets/d/1Lrs3Q1h4MzPtEDkYotK8LEGkt6IWKHVO0elxHZPwW88/edit?usp=sharing
これを見るとちょっと良くない感じがして、というのは採点のバラツキが激しい。1回目は4点を付けた回答に、2回目は1点を付けたりしている。3回の評価の平均点は0.22点くらいバラついてしまっている。0.22点も誤差があると困ってしまう。つまり、CR+の採点はテキトーすぎるんじゃねえか?という前回の記事からの疑惑は広がる一方である。
CR+は採点者としては問題がありそうなところに、代わりに信頼できそうなLlama3-70Bが出てきてくれたのは助かった。
というわけで、結果をまとめると、まずCR+にElyzaTasks100を採点させるのは、ちょっとアテにならなそうかも…という事が分かった。しかしLlama3-70Bの方が賢そうな事が分かったので、こっちに採点させれば良さそうだ。でも私のPC環境ではLlama3-70Bの推論もかなり遅いんだが、それでも色々工夫する事で13.3分で3回評価が回せるようになった。
今後の展望としては、他のモデルとかも色々評価してみたいという事や、自分独自の評価ベンチマークを作りたいという事や、ElyzaTasks100のローカル評価を指標にChatVectorマージの進化的アルゴリズム回してみたいねという事などがある。
最後に、今回使用したコードを一応掲載しておく。
まずこれがllamacpp_judge_llama3.py↓
ちなみにLlama3のチャットテンプレートを使うためにLlama3のリポジトリからtokenizerのダウンロードを行ってるが、HuggingFaceのLlama3へのリポジトリのアクセス権がないとダウンロードに失敗するので注意。リポジトリに行ってフォームに記入して申請ボタンを押して、さらにメタの管理者が承認してようやくアクセス権が貰える。それまで待つハメになる。待てなければ自分でプロンプトテンプレートを設定してもOK。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
import json import requests import time from tenacity import retry, stop_after_attempt, wait_random_exponential from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct") with open("./assets/prompt_eval_llamacpp.txt", encoding='utf-8') as f: template_prompt = f.read() with open("./assets/elyzatasks100.gbnf") as f: grammar = f.read() print(grammar) @retry(wait=wait_random_exponential(min=30, max=60), stop=stop_after_attempt(10)) def evaluate(pred, input_text, output_text, eval_aspect): """OpenAI API により評価を行う Args: Returns: [dict] 評価結果 {"reason": "<評価理由>", "grade": <int, 1~5の5段階評価>} """ # `pred` が空の場合は、評点を1にする if (pred == ""): #return {"reason": "No response", "grade": 1} return {"text1": "1", "text2": "1", "text3": "1", "reason": "No response", } prompt = template_prompt.format( input_text=input_text, output_text=output_text, eval_aspect=eval_aspect, pred=pred, ) try: chat = [ {"role": "system", "content": "You must answer all responses in Japanese.あなたは役に立つ誠実な日本人のアシスタントです。あなたは全ての回答に日本語で答えなければならない。"}, {"role": "user", "content": prompt}, ] prompt = tokenizer.apply_chat_template(chat, tokenize=False, add_generation_prompt=True) generated_texts = [] for i in range(3): success = False retry_count = 0 while not success: #成功するまでリトライ if retry_count >= 10: #10回リトライであきらめる generated_text = d["content"] generated_texts.append("エラー") break url = "http://127.0.0.1:8080/completion" payload = {"prompt": prompt, "n_predict": 1, "temperature": 0.7, "cache_prompt": True, "grammar": grammar, } json_data = json.dumps(payload) r = requests.post(url, data=json_data, headers={"Content-Type": "application/json"} ) d = json.loads(r.content) #_validate_schema(d, r) generated_text = d["content"] generated_texts.append(generated_text) print(str(d["timings"])) try: int(d["content"]) except ValueError: print("数値じゃない出力なのでリトライ:" + d["content"]) #NGやり直し retry_count = retry_count + 1 else: success = True #OK timings = d["timings"] retobj = {"text1": generated_texts[0], "text2": generated_texts[1], "text3": generated_texts[2], "prompt_n": timings["prompt_n"], "predicted_n": timings["predicted_n"], "prompt_ms": timings["prompt_ms"], "predicted_ms": timings["predicted_ms"], "prompt_per_second": timings["prompt_per_second"], "predicted_per_second": timings["predicted_per_second"], } return retobj except cohere.errors.too_many_requests_error.TooManyRequestsError as e: print(f"TooManyRequestsError occurred: {str(e)}") raise except ValueError as e: print(f"ValueError occurred: {str(e)}") raise except Exception as e: print(f"An unexpected error occurred: {str(e)}") raise |
で、こっちがllamacpp_main_llama3.py↓
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
import json import os import time import jsonlines import llamacpp_judge_llama3 import cohere import subprocess import requests # Function to read data from a JSONL file def read_jsonl(file_path): with jsonlines.open(file_path) as reader: return [obj for obj in reader] def call_evaluate_with_error_handling(pred, input_text, output_text, eval_aspect): try: result = llamacpp_judge_llama3.evaluate(pred, input_text, output_text, eval_aspect) return result except cohere.errors.too_many_requests_error.TooManyRequestsError as e: print(f"TooManyRequestsError occurred after all retries: {str(e)}") return {"reason": "API rate limit exceeded\n" + str(e), "grade": 1} except ValueError as e: print(f"ValueError occurred after all retries: {str(e)}") return {"reason": "Invalid API response\n" + str(e), "grade": 1} except Exception as e: print(f"An unexpected error occurred after all retries: {str(e)}") return {"reason": "Unexpected error\n" + str(e), "grade": 1} def evaluate(dir_name, slot_save_path, save_kvcache, restore_kvcache, force_save_kvcache): #1モデルの評価 Dir_name:評価対象のディレクトリ名 # Load dataset base_directory = "L:\GitHubProjects\gpt4-autoeval\content\eval" #model_name = "command_r_iq2_xs" #これをモデル毎に変える dataset = read_jsonl("L:\GitHubProjects\gpt4-autoeval\content\dataset.jsonl") preds = read_jsonl(os.path.join( base_directory , dir_name, "preds.jsonl")) kvcache_basename = "elyzatasks" with jsonlines.open(os.path.join( base_directory , dir_name, 'result_llama2.jsonl'), mode='w') as writer: # Evaluate each sample of the dataset, and write the result to the file for i, (eval_data, pred_data) in enumerate(zip(dataset, preds)): pred = pred_data["pred"] input_text = eval_data["input_text"] output_text = eval_data["output_text"] eval_aspect = eval_data["eval_aspect"] #kvキャッシュ名 kvcache_name = kvcache_basename + format(i, "03") + ".bin" kvcache_path = os.path.join( slot_save_path , kvcache_name) #kvキャッシュがあれば読み込む if restore_kvcache and os.path.isfile(kvcache_path): url = "http://127.0.0.1:8080/slots/0?action=restore" payload = {"filename": kvcache_name} json_data = json.dumps(payload) r = requests.post(url, data=json_data, headers={"Content-Type": "application/json"} ) print("kvcache restored:") print(json.loads(r.content)) print("--------------------------------------------------------------------------------\n" + "設問" + str(i) + "--------------------------------------------------------------------------------") result = call_evaluate_with_error_handling(pred, input_text, output_text, eval_aspect) writer.write(result) #kvキャッシュが無ければ保存 if save_kvcache and (not os.path.isfile(kvcache_path) or force_save_kvcache ): url = "http://127.0.0.1:8080/slots/0?action=save" payload = {"filename": kvcache_name} json_data = json.dumps(payload) r = requests.post(url, data=json_data, headers={"Content-Type": "application/json"} ) print("kvcache saved:") print(json.loads(r.content)) print(f"==============================") print(f"Q. {input_text}") print(f"A. {pred}") print(f"Llama3. {result}") print(f"") save_kvcache = True force_save_kvcache = False restore_kvcache = True launch_server = False slot_save_path = "I:\llama\kvcache\elyzatasks_Llama-3-70B-Instruct_Q5_K_M" command = ["I:\llama\llamacpp\server.exe" , "-m", "I:\llama\llamacpp\models\Meta-Llama-3-70B-Instruct.Q5_K_M.gguf", "-c", "2048", "-ngl", "31", "--slot-save-path", slot_save_path, ] def get_folder_names(directory): folder_names = [] # ディレクトリ内のアイテムを反復処理 for item in os.listdir(directory): # アイテムがディレクトリ(フォルダ)であるかを確認 if os.path.isdir(os.path.join(directory, item)): folder_names.append(item) return folder_names eval_base_dir = "L:\GitHubProjects\gpt4-autoeval\content\eval"; eval_dirs = get_folder_names(eval_base_dir) # フォルダ名を表示 print("フォルダ名のリスト:") for folder in eval_dirs: print(folder) start = time.time() if launch_server: # サーバーを立ち上げる server_process = subprocess.Popen(command) # サーバーが立ち上がるまで待機する(適宜調整) time.sleep(5) try: for eval_dir in eval_dirs: evaluate(eval_dir, slot_save_path, save_kvcache, restore_kvcache, force_save_kvcache) finally: if launch_server: # 作業が終わったらサーバーを終了させる server_process.terminate() end = time.time() print("total time:" + str(end -start)) |
使い方はGPT-4自動評価スクリプトや前々回の記事など参照。ディレクトリをなめて評価したり、subprocessでサーバーを立ち上げる機能とか盛り込んだせいでゴチャついてる。