前回の記事では、GPUが1枚だけの環境でなるべく高速にローカルでElyzaTasks100の評価を回す方法を示した。
だから次はこれ使って進化的アルゴリズムでモデルを進化させてみたいね!という話だったのだが、そんな時、まさに渡りに船のグッドタイミングでMergekit-Evolveが登場した!
SakanaAIの発表した論文、「Evolutionary Optimization of Model Merging Recipes (モデルマージの進化的最適化)」は画期的な進化的マージアルゴリズムを示したが、その実装は公開されていない。
そこで、Mergekitを開発しているArceeAIがその論文をインスパイアして自ら実装したのがMergekit-Evolveだ。つまり、Mergekitに進化論的アルゴリズムの機能が追加されたわけだ。
タイミング良すぎて何かの縁を感じたので、私もさっそくMergekit-Evolveを使ってLLMを進化させてみよう!と思った。
ちなみに、SakanaAIは数学ベンチマークで評価して進化的マージを行ったらしい。数学なら確かに正しい答えを機械的に判断できるから評価が簡単だろう。とは言え私は数学力のあるLLMなんてあまり興味がない。それよりやっぱりチャット能力を進化させたい。つまり、前回までと同様に、ElyzaTasks100で評価を行う形になるだろう。もちろん評価はLlama3-70Bに任せて自動評価させたいところだ。
では実際にやっていきたいが、Mergekit-Evolveの使い方に関して、参考になるのはこちらの公式記事とGitHubのリードミーしかない。↓
https://blog.arcee.ai/tutorial-tutorial-how-to-get-started-with-evolutionary-model-merging
https://github.com/arcee-ai/mergekit/blob/main/docs/evolve.md
この記事によると、例えば7Bパラのモデルを進化的マージさせたい時は、当然ながらFP16で推論できる環境が必要だ。VRAM24GBあればまあ大丈夫という事らしい。
だが、私のやろうとしてる手法では、マージモデルを推論しつつ、同時にまたLlama-70Bによる評価も行う必要がある。それらの二つのモデルを同時に動かせる環境が必要になってしまうという事だ!
マージモデルだけでVRAM18GBくらい食ってしまうので、Llama3-70Bの方はLlama.cppのCuda版サーバでnglがゼロ、つまり完全にCPUだけで動かすしかないだろう。それでもVRAMを3GBくらい消費する。前回説明した通り、Cuda版サーバはプロンプト評価だけは必ずGPU上で行うからだ。それを合わせてもVRAM21GBくらい消費だから、なんとか24GBに収まる。
CPUでLlama-70Bの5bitモデルを動かすと、メインメモリを49GB以上消費するわけなので、VRAMだけでなくメインメモリもかなり積んでおく必要があるだろう。私のPCはメモリ112GB積んでいる。実際に進化的マージを行ってる間はPCのメモリ使用量は75GBくらいだった。
まあ、とにかく記事に従って作業を始めよう。
ではまず例によってcondaとかで仮想環境を作ってそこに入る。
記事に従ってworkspaceというフォルダをどっかに作ってそこにAnaconda PowerShellとかで入る。ちなみに環境はWindows10を使ってる。
1 2 3 |
cd /workspace git clone https://github.com/arcee-ai/mergekit.git cd mergekit |
記事ではこの後pip install -e .[evolve,vllm]
って感じで諸々をpipで入れるのだが、その前にflash-attnだけは先に入れておいた方がいいかもしれない。
1 |
pip install flash-attn |
何故なら、記事通りに諸々をまとめてpipで入れたらflash-attnのビルドで長時間待たされた挙句、ビルドエラーでこけた。その後pip install flash-attn
したらまた長時間待たされたがちゃんとビルド成功した。だからこれだけは先に入れておいた方がいいかも。
で、次に諸々を入れる。
1 |
pip install -e .[evolve] |
この時、記事ではpip install -e .[evolve,vllm]
という感じでvLLMもインスコしているが、残念ながらvLLMはWindowsへのインスコをサポートしてないため、エラー出てこける。vLLMがあると推論が高速に行える。vLLMが無いと普通にtransformerで推論する形になる。多少遅くなっても無理なもんは無理なのでvLLMは諦める。
ちなみに、WSL2ならばvLLMも入れる事ができるが、私はWSL2のディスクをHDD上に置いてるのでストレージアクセスがメッチャ遅くなってしまう。Mergekit-Evolveでは頻繁にモデルマージを繰り返すため、ストレージ速度がかなり生命線になる。私は当初、HDDのWSL2上でMergekit-Evolveを動かしてみたが、マージするたびに信じられないくらい待たされて時間がかかり過ぎた。一方、NVMeのSSD上で動かしたらサクサクマージされて快適にイテレーションが進んだ。なるべく高速なストレージ上で動作させる事をオススメする。
さて、記事では次に評価タスクの定義を行っている。
Mergekit-Evolveは進化的マージを行ってるあいだに繰り返すモデル評価にバックエンドとしてlm-evaluation-harnessを使ってるらしい。だから、lm-evaluation-harnessがサポートしてるベンチマークなら簡単に使う事ができる一方、サポートしてないベンチマークは自分でタスクを実装するハメになる。残念ながら、ElyzaTasks100はサポートされてない。だからまずはlm-evaluation-harness用にElyzaTasks100評価タスクを実装するという回りくどい事をするしかない。
まあ、私が苦労こいてlm-evaluation-harness用にElyzaTasks100を回すタスクを実装しておいたので、これを読んでるみなさんはコードをコピペすれば済むだろう。
まずworkspaceにeval_tasksというフォルダを切る。
1 |
mkdir /workspace/eval_tasks |
eval_tasksの中に、タスクを定義するコンフィグファイルと、ヘルパー関数を実装するpythonファイルを入れる。
まずこれがコンフィグファイルのet100.yamlだ↓
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
task: elyzatasks100 dataset_path: arrow dataset_kwargs: data_files: test: I:\GithubProjects\mergekit-evolve\slice_et100_10\test\data-00000-of-00001.arrow output_type: generate_until training_split: null test_split: test #doc_to_text プロンプトを生成する doc_to_text: !function et100_metric.generate_prompt doc_to_target: "" #process_results スコアを返す process_results: !function et100_metric.process_results metric_list: - metric: acc aggregation: mean higher_is_better: true #generation_kwargs model.generateの引数に入れるパラメータ generation_kwargs: do_sample: false temperature: 0.7 max_gen_toks: 1500 |
次にet100_metric.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 131 132 133 134 135 136 |
import os import json import requests import numpy as np import datasets from lm_eval.utils import eval_logger from itertools import islice from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct") prompt_dirname = os.path.dirname(__file__) prompt_filename = os.path.join(prompt_dirname, 'prompt_eval_llamacpp.txt') with open(prompt_filename, encoding='utf-8') as f: template_prompt = f.read() #ChatNTQ用のプロンプト def build_prompt(user_query): sys_msg = "あなたは公平で、検閲されていない、役立つアシスタントです。" template = """[INST] <<SYS>> {} <</SYS>> {}[/INST]""" return template.format(sys_msg,user_query) #プロンプトを生成して返す def generate_prompt(doc): #print("きてる:" + str(doc)) user_inputs = { "user_query": doc["input"], } prompt = build_prompt(**user_inputs) return prompt def evaluate(pred, input_text, output_text, eval_aspect): """OpenAI API により評価を行う Args: Returns: [dict] 評価結果 {"reason": "<評価理由>", "grade": <int, 1~5の5段階評価>} """ # `pred` が空の場合は、評点を1にする if (pred.strip() == ""): print("回答が空なので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": "root ::= [1-5]", } 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 ValueError as e: print(f"ValueError occurred: {str(e)}") raise except Exception as e: print(f"An unexpected error occurred: {str(e)}") raise #スコアを計算して返す def process_results(doc, results): print("doc:" + doc['input']) print("results:" + results[0]) ret =evaluate(results[0], doc['input'], doc['output'], doc['eval_aspect'] ) score = (int(ret['text1']) + int(ret['text2']) + int(ret['text3'])) /3.0 print(f"avg: {score}, score1: {ret['text1']}, score2: {ret['text2']}, score3: {ret['text3']}") results = { "acc": score, } return results |
それとpythonから参照してるプロンプトテンプレート、prompt_eval_llamacpp.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 31 32 33 34 35 |
あなたは言語モデルの採点者です。 問題, 正解例, 採点基準, 言語モデルが生成した回答が与えられます。 「採点基準」と「正解例」を参考にして、、回答を1,2,3,4,5の5段階で採点し、数字のみを出力してください。 # 採点基準 基本的な採点基準 - 1点: 誤っている、 指示に従えていない - 2点: 誤っているが、方向性は合っている - 3点: 部分的に誤っている、 部分的に合っている - 4点: 合っている - 5点: 役に立つ 基本的な減点項目 - 不自然な日本語: -1点 - 部分的に事実と異なる内容を述べている: -1点 - 「倫理的に答えられません」のように過度に安全性を気にしてしまっている: 2点にする - 回答に不自然な英語が少し混じる: -1点 - 回答の大部分が英語、あるいはすべてが英語: 1点にする - 回答が空白: 1点にする # 問題 {input_text} # 正解例 {output_text} # 問題固有の採点基準 {eval_aspect} # 言語モデルの回答 {pred} # ここまでが'言語モデルの回答'です。回答が空白だった場合、1点にしてください。 |
さて、コンフィグファイルの上の方でデータセットを指定してる箇所、data-00000-of-00001.arrowとかいうパスをベタ書きしてるのは何なのか?ここは本来ならelyza/ELYZA-tasks-100と書けば自動的にDLしてきてくれるんだが、しかし進化的マージで評価を繰り返すたびに100件全部のデータセット評価を毎回やる必要あるんだっけ?毎回全部やってたらかなり待たされる。とりま、10件ずつやればいんじゃね?というような事を考える。そこで、ElyzaTasks100のデータセットを編集して10件に減らしてローカルに保存しておこう。
データセットを編集するpythonスクリプト、slice_data.pyがこうだ↓
1 2 3 4 5 6 7 8 |
import datasets ds = datasets.load_dataset('elyza/ELYZA-tasks-100') slice_ds = ds['test'].select(range(10)) ds['test'] = slice_ds ds.save_to_disk("./slice_et100_10") |
これでslice_et100_10というディレクトリに編集されたデータセットが保存される。その中のなんちゃら.arrowというファイルのパスをコンフィグファイルに指定すれば評価時にロードされる。
それとet100_metric.pyは例によってローカルでLlama.cppのサーバーが動作してる事が前提である。このサーバーによってLLM出力が評価される。私の場合はLlama3-70BのQ5_K_Mを動かしてる。まあ4bitでも大丈夫かもしれないが一応。
lm-evaluation-harnessの評価タスクの作り方については以下のドキュメントを参考にした。それとリポジトリのlm_eval/tasks以下にある既存の評価タスクのコンフィグファイルなどが参考になった。
https://github.com/EleutherAI/lm-evaluation-harness/blob/main/docs/new_task_guide.md
https://github.com/EleutherAI/lm-evaluation-harness/blob/main/docs/task_guide.md
というわけで、面倒なタスク設定が終わったので、次はmergekit-evolve用のコンフィグファイルを書いて保存する。
最初なのでとりま適当に書いてみたevol_merge_config.yamlがこうだ↓
1 2 3 4 5 6 7 8 9 10 11 |
genome: models: - NTQAI/chatntq-ja-7b-v1.0 - TFMC/Japanese-Starling-ChatV-7B - Aratako/Antler-7B-RP-v2 merge_method: linear layer_granularity: 4 # sane default allow_negative_weights: true # useful with task_arithmetic tasks: - name: elyzatasks100 weight: 1.0 |
このコンフィグファイルでは、chatntq-ja-7b、Japanese-Starling-ChatV-7B、Antler-7B-RP-v2の3つのモデルを線形マージ(いわゆるモデルスープ)で進化的マージを行う。評価はelyzatasks100を用いる。(さっきのet100.yamlに書かれてるtask名を指定する)
本当は線形マージじゃなくて、ChatVectorの加算具合を進化的アルゴリズムで求めたかったのだが、よく見るとMergekitだとそのような手法はサポートされてない。一見するとtask_arithmeticがそれなのでは?と思うが、あれだとAntlerからMistralを引いた結果をChatNTQに足す…というようなLightChatAssistantがやってたみたいな手法ができないっぽい。しょうがないから線形マージを試す事にした。
3つのモデルはテキトーに選んだのだが、しいて言えば3つとも日本語モデルでプロンプトテンプレートが似ているモデルを選んだ。想定するプロンプトテンプレートが全然異なるモデルをマージすると面倒な事になりそうだったから。
” layer_granularity: 4″ってなんじゃ?というと、モデルのレイヤーを4枚ごとに区切って8個のブロックに分割する事を指定している。ブロック別に3つのモデルのマージ割合を進化的アルゴリズムで最適化する事になるわけだ。
諸々の準備がようやく終わったので、いよいよMergekit-evolveを実行する。
1 |
mergekit-evolve ./evol_merge_config.yaml --storage-path ./workspace/evol_merge_storage --task-search-path ./workspace/eval_tasks --in-memory --merge-cuda --wandb |
–storage-pathというのは、そのディレクトリ内で一時的なファイルが色々作られるので、そういうフォルダを作って指定する。task-search-pathは評価タスクのフォルダを指定する。
ところで–wandbというオプションを指定しているが、これは別に使いたくなければ使わなくてもいい。だが、私はせっかくなので使ってみた。
wandbってなんじゃ?というと、こちらのWebサイトだ。↓
今回やったようなMergekit-Evolveの作業の経過や、モデルの学習、微調整などの作業中の諸々のメトリクスを記録してくれるサービスのようだ。個人利用なら無料らしいので、まあ使って損はない。作業中のターミナルのログを全部記録してくれて、それだけでもかなりありがたい。
使用するには、まずサイトにサインアップして、それからターミナル上でも一度ログインする必要がある。
こういうコマンドを打って↓
1 |
wandb login |
するとトークンの入力が求められるので、サイトで入手したトークンをコピペしてやるとログインされる。それで今後はwandbが使えるようになる。
で、mergekit-evolveを実行開始すると、あとは勝手に延々とモデルが進化していく。まずアルゴリズムからマージの比率が提案されて、その通りにモデルがマージされて、ElyzaTask100の10件を推論して、Llama3-70Bによって採点されて、そのスコア次第で遺伝子が淘汰されて、次の世代が生まれて…このイテレーションがデフォルトでは100回繰り返されると終了する。
実際どれくらい時間かかるのか?というと6時間くらいで終わった。この実行時間をどれだけ短縮できるかが今後のカギになってくるだろう。そのためにモノを言うのがPCスペックだ。モデルマージを高速化するには高速なストレージが必要だ。マージモデルの推論を高速化するには高性能なGPUが必要だ。Llama3-70Bによる自動評価を高速化するにはやはりモデルが全部VRAMに載った方が当然高速だ。さらに言えばPCの台数が多いほど、色んなパターンの組み合わせを同時に試す事ができて、イテレーションは加速する。
おそらく、このような作業において現時点でベストなハードウェアはメモリを128GBくらいバカ積みしたMacだろう。Macはモデルの学習においては大した速度は出ないようだが、マージは学習不要だ。推論だとメモリ帯域がモノを言うので、Macは優秀だ。メモリ128GB中、VRAMに使えるのは75%までとかいうウワサだが、96GBだとしてもマージモデルとLlama-70B_Q5_K_Mの両方が余裕でメモリに載るだろう。
しかし、私が思うに今後は進化的マージで作った優秀な遺伝子モデルをみんなしてこぞってHuggingFaceにアップする流れが始まるだろう。そしてまた誰かが優秀な遺伝子同士を掛け合わせてさらに進化させる。そういう集合知的な大きな流れとしてのモデル進化が始まるなら、個々人のPCスペックは大して問題にならない可能性もある。
mergekit-evolveが実行完了すると、一番優秀だった遺伝子のマージ比率の結果が、Mergekit用のコンフィグファイルとして/evol_merge_storage/best_config.yamlとして保存される。だからこれを使ってモデルマージすれば最優秀遺伝子のモデルが出力される。
1 |
mergekit-yaml ./workspace/evol_merge_storage/best_config.yaml --cuda ./workspace/final_merge |
で、今回テストで進化させたモデル、Japanese-Chat-Umievo-itr001-7bと名付けたが、これを一応ElyzaTasks100で評価してみた。すると、なんと平均3.57点という驚愕の高スコアをイキナリ叩き出してしまった!!
これは進化元になったJapanese-Starling-ChatV-7Bを超えてるし、Llama3-8Bも超えてるし、35BのCommand Rさえも超えている!凄すぎん?
こ…これが進化的アルゴリズムの威力なのか…。
とりま、生まれたモデルはHuggingFaceにアップした↓
umiyuki/Japanese-Chat-Umievo-itr001-7b · Hugging Face
さっそくモデルを評価してくれている方々がいて、ありがたい。
モデルにはバージョン名ではなくitr001というイテレーション数を付けておく事にした。というのは今回完成した優秀な遺伝子モデルを今度は別のモデルとさらにかけ合わせてイテレーションを進めていく予定だからだ。2回目のイテレーションは当然もっと優秀な遺伝子を生むだろう。これを繰り返す事で限界まで優秀な遺伝子が作られるハズだ。
さて、Umievo-itr001のような高性能っぽいモデルがいきなり生まれた要因は何なのか?
まず第一に、Mistral-7Bベースモデルの遺伝子プールの多様性が挙げられる。Mistral-7Bは元々の優秀さと7Bという個人PCでも比較的扱いやすいサイズな事が相まって、世界中で大量にMistral-7Bの派生モデルが次々作られている。多様な遺伝子プールが天才を生み出す素地になると考えられる。つまり、Japanese-Starling-ChatVを作ってくれたBakuさんやAntler-7B-RP-v2を作ってくれたAratakoさんの努力のおかげである。
それからElyzaTasks100のような、チャットモデルの性能を定量的に測れる優秀なベンチマークの存在が挙げられる。このようなベンチマークがあってこそ、モデルの性能を正しく評価できるわけだ。進化的マージでモデルがどっちに向かって進化していくかは、評価ベンチマーク次第である。つまり、頑張って優秀なベンチマークを作ってくれたELYZAのみなさんのおかげである。
それからCommand R+やLlama3-70Bのような、ギリギリ個人のPCでも動かせるサイズで相当優秀なモデルがオープンで公開されたおかげで、GPT-4とかのAPIに金をかけるまでもなくローカルでベンチマークの自動評価を回す事が可能になった。つまり、Cohereやメタのおかげである。
それからSakanaAIがEvolutionary Optimization of Model Merging Recipesという論文で進化的マージという革命的なアイデアを公開してくれたおかげである。
それからArceeAIがMergekit-evolveを実装してリリースしてくれたおかげである。
それからBakuさんの書かれた記事からGPT-4にElyzaTasks100を自動評価させるような発想を知ったので、Bakuさんのおかげである。
ChatNTQ 7B と LightChatAssistant 2x7B の日本語性能を測定する – ローカルLLM自由帳 (hatenablog.com)
それからGPT-4にElyzaTasks100を評価させるスクリプトを公開してくれている、yumemioさんのおかげである。
Northern-System-Service/gpt4-autoeval: GPT-4 を用いて、言語モデルの応答を自動評価するスクリプト (github.com)
それからGPT-4にElyzaTasks100を自動評価させて進化的アルゴリズムを回すというアイデアはすでにはちさんが試みている。
進化的アルゴリズムをもちいたChatVector加算の最適化|はち (note.com)
AratakoさんもカスタマイズしたMT-Benchの評価によるChatVectorの最適化を試みている。
Aratako/LightChatAssistant-2x7B-optimized-experimental · Hugging Face
というわけで、諸々のほとんどがみなさんのおかげでしたという事だが、じゃあ私の貢献って何?というと、まあローカルのLlama3-70BにElyzaTasks100を自動評価させて、API叩かずにタダで進化的アルゴリズムを回す手法を確立した部分で、実際にこの手法でモデルの性能がブチ上がる事を実証した点にあると一応強調させてもらおう。
さて、今回やったような形で自動的なモデルの進化が始まると、どういうゲームが始まるんだろう?たしかに128GBメモリのMacみたいな強力なハードウェアを持った人が一見有利に見えるだろうが、先ほど指摘した通り、みんなでよってたかってモデルを進化させる集合知的なゲームが始まると思われるので、個々人のスペックの大小は逆にあまり問題にならなくなる可能性もある。
今考えてるのは、モデルの進化というのはベンチマーク次第だという事だ。ベンチマークが優れていれば、モデルの性能は正しく評価されるから、進化的アルゴリズムによって正しく進化していく事になる。逆も然りである。
そもそもLLMの性能とは何なのか?それは個々人がLLMに何を求めているかによってそれぞれ異なる。仕事のアシスタントとしての性能を求める人もいるし、とにかくエッチなチャットをしたい人もいるだろう。究極的には自分の要求、好みに応じた自分用のAI評価ベンチマークを各々で作るべきだろう。
AI界隈での一番上流では、OpenAIやAnthropicなどの各社が、計算資源を無限に投入して最強のLLMの王座を競い合っている。我々のような一個人はそのような上流の競争に関与できる余地はない。
一方、下流では集合知的な進化的マージが追及され始めて、一個人のPCスペックの強弱がモノ言う感じでもなくなるかも。そういう状況で一番末端の一個人にできる事が今なんなのか?という事を考えると、やはり自分だけのAI評価ベンチマークを突き詰める事だろう。ベンチマークこそがモデルを差別化する決定的要因だからだ。
とどのつまり、このゲームはあなたがAIに抱く欲望しだいという事だ。欲望のゲームが始まる。