同じプロンプトなのに答えが毎回違う
一言でいうと
モデルが出力するのは答えではなくロジットの一式で、答えは、そのロジットを確率に変えて1つ選ぶ過程で決まります。温度・top-k・top-pは、その確率分布を選ぶ前に調整する、3つのつまみです。
なぜ必要なのか
初めてモデルをつなぐと、不思議なことが2つ起きます。1つは、同じプロンプトを2回入れたのに答えが違うことで、もう1つは、答えを長く出力させると、同じ言葉を繰り返して終わることです。
どちらも、最後の一歩から生じます。Transformerブロックの仕事は、語彙のサイズと同じ長さの実数のリストを1つ出力するところまでです。このリストをロジットと呼びますが、確率ではなく、正規化されていないスコアです。ここから実際に使うトークンを1つ選ぶのは、モデルの外側のルールで、そのルールが何であるかによって、同じモデルがまったく違う話し方をします。
最も単純なルールは、最も大きいロジットを選ぶことです。greedyと呼びます。結果がいつも同じなのでテストには向いていますが、長く出力させると、同じ句に戻る輪にはまりやすくなります。The Curious Case of Neural Text Degenerationが整理したのが、まさにこの現象です。確率が最も高い道だけをたどると、人が書いた文章とは似ていない、目立って反復的な文章が出てきます。
そこで、サンプリングします。確率に比例して1つ選べば、繰り返しは減ります。ところが今度は、裾が問題になります。語彙が数万個あれば、確率が0.00001のトークンが数万個あり、その裾全体の合計は無視できません。一度でもその中から選ばれると、文がその場で崩れます。3つのつまみはすべて、この緊張、つまり繰り返しと崩れの間を扱う道具です。
温度は、割る位置が要点
温度Tは、ソフトマックスに入れる前にロジットを割る値です。確率を先に求めておいて、それに手を加えるのではありません。
scaled = [value / T for value in logits]
top = max(scaled) # 빼는 것도 장식이 아니다
weights = [math.exp(value - top) for value in scaled]
probs = [w / sum(weights) for w in weights]
割り算がどう働くかは、間隔で見ると簡単です。1位と2位のロジットの差が1.1だとします。Tが0.25なら、その差が4.4に広がり、指数をとると、2位の取り分が1位の1パーセント台に減ります。Tが4なら、差が0.275に狭まって、両者がほぼ同じになります。Tが小さいと尖り、大きいと平らになります。Tが1なら、ロジットをそのまま使うことになります。
最大値を引く位置も、単なる慣習ではありません。Tを0.01にすると、ロジット5.2が520になり、math.exp(520)はそのままあふれます。温度が、大きな入力を作り出すということです。最大値を引くと、指数の引数が0以下に下がってあふれる余地がなくなり、同じ値から同じ数を引くだけなので、確率は変わりません。
1つ、はっきり決めておくことがあります。Tが0のソフトマックスはありません。割り算ができないからです。「温度を0にするとgreedy」という言葉は、Tを0に近づけるときの極限を縮めて言ったもので、実際の実装は、その場でgreedyに切り替えます。負の値はもっと悪く、順位が逆転して、最ももっともらしくないトークンが1位になります。
広がり具合を数字1つで見たいなら、エントロピーを使います。底が2のエントロピーは、確率が1か所に集中すると0に近く、n個に均等に広がるとlog2(n)になります。温度を上げると、この値が大きくなります。
top-kとtop-pは、裾を切る2つの方法
top-kは単純です。確率の大きいk個だけを残して残りを捨て、残ったものを再び正規化します。同点をどう解消するかは、決めておく必要があります。このラボでは、番号が小さいほうが残ると決めました。
kを固定することの弱点は、分布の形を見ないことです。確信に満ちた位置では1位が1つあれば十分なのにk個を残し、分かれ道ではk個では足りません。
top-p、別名nucleus samplingは、個数の代わりに質量で切ります。確率を降順に並べて累積和を測り、累積和がpに初めて届くその項目までを残します。ここで混同しやすい点が1つあります。その項目を除くと、残ったものの合計はpに届きません。そのため、「超える直前まで」ではなく、「超えるその項目まで」です。こうすると、残す個数が分布によって自然に変わります。尖った位置では1つ、平らな位置では複数です。
両方を使うときは、順序が結果を変えます。先に温度をかけると、分布そのものが変わるので、同じpでも残る個数が変わります。先にtop-kを行うと、生き残ったものが再び正規化されて確率が膨らみ、膨らんだ値で累積和を測るので、top-pのほうが早く止まります。そのため、どの順序で適用するかは、決めておいて文書に書かなければならない値です。Hugging Faceの生成戦略のドキュメントも、これらのつまみを個別にではなく、1つの設定のまとまりとして扱っています。
同じシードは同じ数列を返す
分布をすべて調整したら、1つ選びます。逆累積分布が標準的な方法です。0以上1未満の実数uを1つ引き、番号の順に確率を足していって、uを初めて超える位置を選びます。確率がちょうど0の位置は、累積和を増やさないので、絶対に選ばれません。切り落としたトークンが復活しないということです。
ここで、重要な性質が1つ出てきます。uを与える乱数生成器のシードを固定すると、同じ入力から同じ数列が出ます。無作為なのに再現されるのです。障害を再現してみるには、これが必ず必要です。
現場での姿
第1に、「ときどき変な答えが出る」という報告を再現できません。シードを残さなかったからです。リクエストごとにシードと温度・k・pをログに書いておけば、そのリクエスト1つをそのまま再現できます。
第2に、温度を上げて多様性を得ようとして、精度を失います。分類や抽出のように、答えが1つの作業では、温度を上げる理由がありません。つまみは、作業ごとに別々に設定する必要があります。
第3に、同じ設定なのに、ライブラリを変えたら結果が変わります。適用順序が違う、同点の処理が違う、pに届く項目を含めるかどうかが違う、といった理由です。この3つは、どれもドキュメントにあまり書かれていません。
第4に、キャッシュが見当違いにヒットします。プロンプトだけをキーにして、温度・k・p・シードを抜かすと、設定が違うリクエストが同じ答えを受け取ります。
第5に、評価スコアが揺れて、回帰を検出できません。評価では、サンプリングをオフにして、greedyに固定するほうがよいでしょう。測りたいのはモデルの変化であって、乱数の変化ではないからです。
実務で本当に大切なこと
- 設定は、値1つではなくまとまりです。温度・k・p・シード・適用順序までが一式で、そのうち1つだけ違っても、別のシステムです。
- greedyは、温度0ではありません。別のルールだと考えて、コードでも分岐を分けておくほうが安全です。
- 判定は、分布で行ってください。選ばれた結果1つでは、実装が正しいかわかりません。確率ベクトルを照合するか、シードを固定して番号の数列を照合する必要があります。
- 切り落としたトークンは、復活してはいけません。これが壊れると、安全装置で塞いでおいたトークンが、低い確率で漏れ出ます。
次のラボですること
/root/work/tf-sample/sample.pyを、1ステップずつ育てていきます。標準ライブラリのmathとrandomだけを使います。このPodのシステムのPythonにはnumpyがなく、/opt/onnx-lab/bin/pythonの中にしかありません。モデルも呼び出しません。ロジット1式を自分で書いておき、その後ろをすべて手で作ります。
温度で割ったあとの安定したソフトマックスから始めて、エントロピー、top-k、top-p、この4つを決まった順序でつなぐ関数、逆累積分布による抽出、greedyと温度の走査までを作ります。最後に、同じシードで2回選んで同じ数列が出るか、温度を下げたほうがgreedyと同じになるかを、数字で残します。
採点ツールは、書いておいた説明を信用しません。自分で作ったモジュールを実際に呼び出し、毎回異なるロジットと異なる温度で関数を直接叩いて、採点ツールが別に計算した確率ベクトルと照合します。無作為な抽出そのものは判定しません。シードを固定した番号の数列と確率ベクトルだけを見ます。そうすれば、正しい実装が偶然落ちることも、間違った実装が偶然通ることもありません。