はじめに: 動機とよくなさそうな方法

動機: Multiple-Choice Cloze Taskを効率的にシミュレートしたい

この記事では、 (i) 言語学関連の研究/開発において (ii) Multiple-Choice Cloze Task(選択式穴埋め問題)をシミュレーションしたい人向けに、 (iii) ローカル環境でLLMを活用する方法について解説します。 備忘録と実装の整理を兼ねて記事にしています1

今回紹介する実装では、ローカルで動かしているLLMに直接リクエストを送信することで、 OpenAI APIライブラリに依存せずにCloze Taskの評価を実現しています。 最低でも16GBのGPUが必要ですが、RTX 5070 Tiなどのコンシューマーグレードの GPUでも動作します。また試していませんが、おそらくGoogle ColabのT4リソースでも実行可能です。

本題ですが、人を対象としたCloze Taskの評価では主観性と効率性から困る点がありました。 つまり、人の直感的判断に依存することと(だから数をとるのですが)、 大規模データセットの評価に時間(とお金)がかかってしまいます。 そこで、無料で数を打てる代替手段を用意しておきたかったわけです。

この時、LLMを使うやり方は複数あると思います( LLMの説明自体は NEC とか IBM のページを参照するとわかりやすいかと思います)。 そこで、まずはうまくいかなさそうな方法2つを 紹介した後で、本命の方法を共有しようと思います。

プランA: LLMに確率も出力させよう

プランAは、LLMに確率を聞くことです。 例を挙げると、LLMに以下のようなプロンプトを入力して出力を得ます。

昨日、渋谷の名物である_を見に行った。_ に入る単語の候補として ハチ公 スクランブル交差点 スタバ 公園 があったとき、それぞれの確率を出力してください

コーディングは簡単なので、Google ColabでOpenAIのGPT4.1を使ってみましょう。 参考までですが、ノートブックもあります。 事前に「APIキー」を設定する必要はあるのですが、 APIの取得方法や設定方法は色々な方が解説しているのと、 すぐに情報が古くなるので省きます。 OpenAIのAPIキーの取得と、Google ColabでAPIキーをuserdataに格納する方法を 調べると出てきます。なお、このAPIキーの扱いは銀行口座のパスワードと同じくらい注意してください。

改めてですが、ここで紹介する方法の2つ(Plan A/B)は個人的に 「批判を受け止めきれないだろうから、 自分は使わないだろうな」 という方法です。ただ、ユースケースによっては十分かもしれないので、 厳密さを求めない場合は十分選択肢になるかもしれません。

import openai
from google.colab import userdata

# OpenAI APIキーを取得
openai_api_key = userdata.get("OPENAI_API")

# OpenAIクライアントを初期化
client = openai.OpenAI(api_key=openai_api_key)

# プロンプトと単語候補
prompt_text = "`昨日、渋谷の名物である_を見に行った。` の `_` に入る単語の候補として `ハチ公 スクランブル交差点 スタバ 公園` があったとき、それぞれの確率を出力してください。"

response = client.chat.completions.create(
    model="gpt-4.1", # または利用可能な他のGPTモデル
    messages=[
        {"role": "user", "content": prompt_text}
    ]
)
print(response.choices[0].message.content)

このコードの実行結果は以下になりました。 システムとして使いたいならJSON形式などで出力させるのがよいでしょう。 この出力で満足する人もいるかもしれません。 たしかに、出力としてはそれっぽいですね。

この確率は、各単語が「渋谷の名物」に関連している度合いや一般的な登場頻度などに基づいています。それを考慮に入れ、私の主観で以下のような確率を設定します。

ハチ公: 0.4,
スクランブル交差点: 0.35,
スタバ: 0.1,
公園: 0.15.

これはあくまで一例で、公的な調査結果ではないため、具体的な数値は異なる可能性があります。渋谷の他の名物やランドマーク、企業なども確率の対象となることを考慮してください。

ちゃんと和が1になるので、挙げた候補の中で確率分布を作ってくれています。 つまり、「4択を作ったら、どの単語が選ばれるか」 という確率をだしてくれているのです。 さすがに賢いですが、いくつか問題があります。

問題点1: ブレる

上記の方法(テキストで確率を出力させる)の問題の1つは、 出力が結構ゆれることです。 上のほかに3回まわしてみました。 どれもだいたい0.4くらいがハチ公ではあります。 しかし、無視(システムとして許容)できるレベルの誤差かもしれませんが揺れています。

# sample 2
  "ハチ公": 0.42,
  "スクランブル交差点": 0.42,
  "スタバ": 0.10,
  "公園": 0.06

# sample 3
  "ハチ公": 0.45,
  "スクランブル交差点": 0.40,
  "スタバ": 0.10,
  "公園": 0.05

# sample 4
- ハチ公:0.4
- スクランブル交差点:0.4
- スタバ:0.1
- 公園:0.1

もう少しシビアな問題は、 出力されている数値候補の確率 ではないというところです。 どちらかというと、「それっぽい確率を表すテキスト」ということですね2。 説明が複雑になるので、理想と実際の状態を考えてみましょう。

理想としては、文脈を与えたときの単語の確率(例えばP(ハチ公|文脈)) を モデルが計算してくれる状態です(それが最後に共有する本命です)。 しかし、実際には P( "0.4" | ...ハチ公: ) を計算し、 その "0.4" という「テキストとしての確率」が最も確率が高かった…というだけです。

これは確率というより、 「確率を示すテキスト("0.1"とか"0.4"とか)」の出力ですね。 ややこしいですが、別物だということは伝わると思います。 このいい加減さを確かめるために一つ実験をしてみましょう。

問題点2: 選択肢の提示順序の効果

いい加減さを見るために実験します。 以下のように、「ハチ公」を提示する候補の一番後ろにしてみましょう。

# prompt_text = "`昨日、渋谷の名物である_を見に行った。` の `_` に入る単語の候補として `ハチ公 スタバ 公園 スクランブル交差点` があったとき、それぞれの確率を出力してください。"
prompt_text = "`昨日、渋谷の名物である_を見に行った。` の `_` に入る単語の候補として `スクランブル交差点 スタバ 公園 ハチ公` があったとき、それぞれの確率を出力してください。"

そうすると、以下のようにスクランブル交差点のほうが確率が上になってしまいます。 まぁ人間らしいといえば人間らしいのかもしれませんが、 Cloze Taskで提示した候補の順番で確率が変わってしまうのは 挙動として好ましくありません。

### 確率の推定

- スクランブル交差点:45%
- ハチ公:40%
- スタバ:10%
- 公園:5%

人間でも起こりうるのかもしれませんが、 確率を返してほしい身としては困ってしまいます。

補足: ある概念を表象する単語が複数ある場合

こちらは「そもそも問題なのか」という考え方もあるので 提案する方法でも解消させていないのですが、 選択肢に 忠犬ハチ公像 ハチ公像 忠犬ハチ公の像 ハチ公の像 を加えてみます。 どうなるでしょうか。 概念としては「ハチ公」と同じものなので、 「ハチ公」のパイを食い合って和が0.4(=40%)くらいになることを期待します。

しかし結果としては、次のような結果となって和が75%になります。

| 候補                 | 確率(%) |
|----------------------|---------|
| ハチ公               | 20      |  # 20%
| ハチ公像             | 25      |  # 45%
| 忠犬ハチ公像         | 15      |  # 60%
| ハチ公の像           | 10      |  # 70%
| 忠犬ハチ公の像       | 5       |  # 75%
| スクランブル交差点   | 20      |
| スタバ               | 3       |
| 公園                 | 2       |

この直感と異なる挙動を説明するために、少し話をシンプルにしましょう。 候補が「ハチ公」と「スクランブル交差点」だけだったとして、 これらが50/50と最初に推定されたとしましょう。 概念としてどちらを選択するか、という話です。

ここに「ハチ公」の言い換えである 「忠犬ハチ公」とか「ハチ公像」とか、 要は「ハチ公という概念」を示す別の表記が増えても、 概念の別の表記をしているだけです。 なので、候補の数が増えたからといって 「ハチ公」を示す概念が選ばれる確率が 上がっていくのはおかしな話に聞こえます。 逆に「スクランブル交差点」の確率が下がるのも変な話ですね。

ただ、実際はそこまでおかしくありません。 というのも、上の話は前提がずれてしまっているのです。 実際にしたいこととしては、 文脈を与えた時「(i)どの概念が (ii)どのくらいの確率で選ばれるのか」 を知りたいのです。 ただ、上でやっていることは「概念」ではなく 「単語」の確率を聞いているのですね。

したがって、実際に運用する場合は少なくとも3種類の方略が取れます。

  1. 「ある概念をもっとも代表するような、プロトタイプ的な表現」を概念ごとに一つずつ採用
  2. ある概念を示しうる単語をすべて洗い出してからまとめてあげる作業(周辺化ともいえますが)
  3. 概念で聞いて確率を求めさせ、そのパイのなかで個々の表現方法の確率を取得

プランB: 選択肢を選ぶ確率で考えよう

ほかにも、top_logprobsという機能を使うのもありえます。 これは出力の候補の確率を返してくれるというものです。 ただ、後述する「トークン」という特殊な単位を使っているので、 単語を返してくれることは期待できません3。 そこで、数字の候補を出力させることを考えましょう。

import openai
from google.colab import userdata
import math

# OpenAI APIキーを取得
openai_api_key = userdata.get("OPENAI_API")

# OpenAIクライアントを初期化
client = openai.OpenAI(api_key=openai_api_key)

# 候補語リスト
candidates = ["ハチ公", "スクランブル交差点", "スタバ", "公園"]
candidates_str = " ".join([f"{i+1}. {cand_i}"for i, cand_i in enumerate(candidates)])
ids_str = [str(i+1) for i, _ in enumerate(candidates)]

# top_logprobsを使って候補語の確率を直接取得
prompt = f"「昨日、渋谷の名物である_を見に行った。」の _ には何が入りますか。次の候補から、適切な候補の数字のみ出力してください。\n\n候補: {candidates_str}"

response = client.chat.completions.create(
    model="gpt-4.1",
    messages=[
        {"role": "user", "content": prompt}
    ],
    logprobs=True,
    top_logprobs=20,  # 上位20個のトークン候補の確率を取得
    max_tokens=1,
    temperature=0.0
)

# 数字トークンの確率を抽出
number_probs = {}
first_token_data = response.choices[0].logprobs.content[0]

print("\n数字トークンの確率:")

for top_token in first_token_data.top_logprobs:
    token_text = top_token.token.strip()
    if token_text in ids_str:
        prob = math.exp(top_token.logprob)
        number_probs[int(token_text)] = prob
        candidate_name = candidates[int(token_text) - 1]
        print(f"  {token_text}. {candidate_name}: {prob:.4f} (logprob: {top_token.logprob:.4f})")

# 結果表示
print("\n最終確率:")
total = sum(number_probs.values())
for num in sorted(number_probs.keys()):
    normalized_prob = number_probs[num] / total if total > 0 else 0
    print(f"{candidates[num-1]}: {normalized_prob:.4f} ({normalized_prob*100:.2f}%)")

これの結果は次のようになります。 圧倒的にハチ公ですね。

数字トークンの確率:
  1. ハチ公: 1.0000 (logprob: -0.0000)
  2. スクランブル交差点: 0.0000 (logprob: -15.0000)

最終確率:
ハチ公: 1.0000 (100.00%)
スクランブル交差点: 0.0000 (0.00%)

ただ、これもあまりよくありません。 候補の種類や数、順番をいじると、確率が変わります。ハロウィンを試しに入れてみたのですが、 ハロウィン自体に確率は入らないのに確率分布が大きく変わってしまいました。 同じ概念の別の表記が増えてもその概念の確率が高くならないのは好印象ですが、 挙動としては信頼できないですね。

「昨日、渋谷の名物である_を見に行った。」の _ には何が入りますか。次の候補から、適切な候補の数字のみ出力してください。

候補: 1. スタバ 2. 公園 3. ハチ公像 4. 忠犬ハチ公の像 5. ハロウィン 6. 忠犬ハチ公像 7. ハチ公 8. スクランブル交差点

数字トークンの確率:
  3. ハチ公像: 0.6218 (logprob: -0.4751)
  8. スクランブル交差点: 0.3771 (logprob: -0.9751)
  6. 忠犬ハチ公像: 0.0000 (logprob: -12.6001)

最終確率:
ハチ公像: 0.6225 (62.25%)
忠犬ハチ公像: 0.0000 (0.00%)
スクランブル交差点: 0.3775 (37.75%)

ちなみに、「なんでわざわざ数字を出力させるの?」と思うかもしれません。 この疑問はいくつかのパターンに細分化できそうです。 まず一つのパターンは、以下のようなオープンな回答を許したほうがよくないか、 というパターンです。

prompt = "「昨日、渋谷の名物である_を見に行った。」の _ には何が入りますか。答え: "

ただ、これだとLLMの出力はオープンすぎるので、 最大20種類という制限では所望の出力が候補として得られないかもしれません。 また、最初のトークンの確率(対数ですが)しかわからないので、 最初のトークンが同じ候補が区別できません。

また、以下のようにYes/Noとさせたほうがよくないか、というパターンもあり得ます。 ただ、こちらはそもそもYes/Noの確率自体が違うので、 単純な比較ができなくなってしまいます。

prompt = "「昨日、渋谷の名物である_を見に行った。」はそれぞれの候補で日本語として自然ですか。 Yes/No"

ということで、選択肢の出力がPlan Bでは妥当なのかなぁという感じです。

提案手法: 前後の文脈を所与とした候補の確率

さて、ここまでうまくいかなさそうな方法2つを共有しました。 普段からLLMに触っていないと、 こういう局所的な解を最適と思ってしまいそうな気もするので、 問題点を指摘する意味はあるかなと思います。 ただ、もっとうまくいくし、理論上も突っ込みどころの少ない方法があります。 そんな複雑な話ではなく、 以下のような式で双方向の文脈評価を採用する、というだけです。

Score = P(候補語 | 左文脈) × P(右文脈 | 左文脈 + 候補語)

詳しくは後述しますが、これを実現するには、左文脈|候補語|右文脈 としたとき (例: 昨日、渋谷の名物である|ハチ公|を見に行った。)、 それぞれのトークンの確率を求めないといけません。 これは、OpenAIが提供しているAPIに沿って言うと echo=Truelogprobs=True という2つのオプションを 指定しないといけません。

しかし echo=Truetext-davinci-003 というモデルで見たのが最後で、 それ以降はほとんど使われていません。 なにより、コミュニティで指摘されている通り、 この組み合わせは2023年の10月5日から利用できなくなっているのです。

朗報として、今回紹介するgpt-ossのシリーズでは利用可能です。 なんせパラメータがオープンなわけで、 かなり自由度の高い使い方ができます。

補足: GPT-OSSとは:オープンソース版GPT

使用するGPT-OSS(GPT Open Source Software)を補足しておきます。 これはOpenAIが2025年8月5日に公開した オープンソースの言語モデル (language model) です。 言語モデルとは、文 (string) を構成する要素の確率を表現するものです。 極論、単語の頻度だけでも言語モデルと呼べます(unigramと呼びますね)。 パラメータによって定義でき、たくさんのパラメータを持ったLMを LLM (large language model) と呼びます。

GPT-OSSの特徴として、 モデルの重み(=パラメータ)が公開されており、ローカル環境で実行できる点があります。 また、上で述べたようにecho=Trueに対応しているので 任意の文字列に対するトークンの確率取得が可能です。 これは商用版では廃止済みなのでありがたいですね。

モデルサイズはgpt-oss-120bgpt-oss-20bが2025年10月30日の段階では公開されており、 今回は後者の200億パラメータのものを使っています。 驚くべきは、メモリ要件がGPU直結の16GB VRAMで動くという点です。 かなり現実的に家庭で買えるレベルのPCで動作します。

今回の実装ではvLLMを使っています。 このvLLM(Virtual Large Language Model)とは、 大規模言語モデルに使える高速な推論エンジンです。 システム構成は以下のようになっています。 ソースコードは載せていませんが、 cloze.pyにCLIも載せていて、Streamlitというパッケージを使って ブラウザ経由でGUIなどから呼び出せるようにしています。

┌──────────────────┐
│  Python Client   │  cloze.py
│  (本実装)         │
└────────┬─────────┘
         │ HTTP Request (/v1/completions)
         ↓
┌──────────────────┐
│   vLLM Server    │  OpenAI互換API提供
│                  │  echo=True対応
└────────┬─────────┘
         │
         ↓
┌──────────────────┐
│   GPT-OSS-20B    │  オープンソースモデル
│   (16GB VRAM)    │  ローカル推論
└──────────────────┘

この組み合わせにより、ローカル環境でCloze Taskが実現できます。

共有する手法: 双方向の文脈評価

さて、この双方向の文脈評価が技術的に可能だとして、 どんな理屈で求めるのでしょうか。 この式がやっていることは 「左文脈→候補語の確率」と、「左文脈+候補語→右文脈の確率」の積を求めたいわけです。 どちらも、いわゆる「条件付き確率」というやつですね。 英語だと A|B は “A given B” と読んだりします。 日本語だと「Bを所与としたときのA」ですね。

Score = P(候補語 | 左文脈) × P(右文脈 | 左文脈, 候補語)

候補語左文脈から発生する確率」と 「左文脈+候補語右文脈を発生させる確率」を計算すると、 候補語と左右の文脈の「座りの良さ」が数値化できます。 でもこれって、文脈を与えた時の確率なのでしょうか。

以降のサブセクションでは、 どうして上記の計算を妥当と考えるのか、 gpt-ossとはなんなのか、 実装方法について述べていきます。 環境構築や必要なハード周りの話は、 実際に回す人はあまりいないと思うので、 最後のパートでしようと思います。

前後の文脈を所与とした候補の確率とは何なのか

上で述べましたが、求めるスコアは 候補語と左右の文脈の「座りの良さ」を数値化したものです (下に示す式の右辺)。 これは左右の文脈を与えた時の$x$の確率に比例します (同式の左辺)。 先行する文脈(Context)は $C_{\mathrm{pre}}$、 後続する文脈は $C_{\mathrm{post}}$ と表記しています。

$ P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}}) \propto P(C_{\mathrm{post}}\mid C_{\mathrm{pre}},x)P(x\mid C_{\mathrm{pre}}) $

もちろん、これは(すくなくとも僕にとっては)自明な式ではありません。 そこで、左辺の $P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}})$ から 右辺を導出する過程を考えていきましょう。

式の左辺はベイズの定理により、次の式のように分解できます (ベイズの定理自体が初見な方には アイシア先生の動画が おすすめです)。

$ P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}}) =\frac{P(C_{\mathrm{pre}},C_{\mathrm{post}}\mid x)P(x)}{P(C_{\mathrm{pre}},C_{\mathrm{post}})} $

候補それぞれの$x$でこの確率の値を計算して比較していくのですが、 分母$P(C_{\mathrm{pre}},C_{\mathrm{post}})$は共通しています。 なので、この計算がめんどくさそうな、実態のわからない分母も 比較の際は無視できます(1/?2/?の比較で?がわからなくても1:2となるのと同じです)。 よって、次のように比例関係($\propto$で示します。)に落とし込めます。

$ P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}}) \propto P(C_{\mathrm{pre}},C_{\mathrm{post}}\mid x)P(x) …(1) $

この右辺に出てくる $P(C_{\mathrm{pre}}C_{\mathrm{post}}\mid x)$ ですが、 ベン図を描くとわかりやすいのですが、さらに次のように分解できます。

$ P(C_{\mathrm{pre}},C_{\mathrm{post}}\mid x) = P(C_{\mathrm{pre}}\mid x)P(C_{\mathrm{post}}\mid C_{\mathrm{pre}},x) …(2) $

まず、三つの円が重なるように描きます。 そして、それぞれに $C_{\mathrm{pre}}, C_{\mathrm{post}}, x$ と ラベルを貼ります。 $x$という円の中にある 「$C_{\mathrm{pre}}, C_{\mathrm{post}}$ が重なっている部分」が $P(C_{\mathrm{pre}},C_{\mathrm{post}}\mid x)$ です。一番小さい部分ですね。 これは、「$x$という円の中にある $C_{\mathrm{pre}}$ の面積 (=$P(C_{\mathrm{pre}}\mid x)$)」と 「その$C_{\mathrm{pre}}, x$ の中にある $C_{\mathrm{post}}$ の割合 (=$P(C_{\mathrm{post}}\mid C_{\mathrm{pre}},x)$)」の積ですね。

抽象的で申し訳ないのですが、上のように図を描いていけば伝わると思います。 そして以上の式(2)を式(1)と組み合わせると、最終的に次の値を求めればよいことになります。

$ P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}}) \propto P(C_{\mathrm{pre}}\mid x)P(C_{\mathrm{post}}\mid C_{\mathrm{pre}},x)P(x) …(A) $

あと一息なのですが、式(A)の右辺にある$P(C_{\mathrm{pre}}\mid x)$ も $x \to C_{\mathrm{pre}}$ みたいで時間と逆行していて嫌です。 しかもモデルが直接求める値ではないので排除したいです。 幸い、これもベイズでひっくり返せます(式B)。

$ P(C_{\mathrm{pre}}\mid x)=\frac{P(x\mid C_{\mathrm{pre}})P(C_{\mathrm{pre}})}{P(x)} …(B) $

この式(B)を式(A)の右辺に代入すると、式(B)の分母と式(A)の分子にある$P(x)$が消しあってくれます。 そして候補間の比較において、$P(C_{\mathrm{pre}})$ は共通しているので無視できます。 そうすると、最終的に式(C)がえられるので、これを採用します。

$ P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}}) \propto P(C_{\mathrm{post}}\mid C_{\mathrm{pre}},x)P(x\mid C_{\mathrm{pre}}) …(C) $

この確率を反映するスコアを求めるにあたっては対数を使います。 対数を使うと確率の掛け算が足し算になって便利ですね。 もはや確率ではないので尤度(ゆうど)という名前で、 さらに言うとスケールが対数なので「対数尤度」です。

$ s(x)=\log P(C_{\mathrm{post}}\mid C_{\mathrm{pre}}, x)+\log P(x\mid C_{\mathrm{pre}}) …(Score) $

最後に、候補集合 ($X$) 上で softmaxという操作をして事後分布とみなします。 急に雑な説明になりましたが、 ここもアイシア先生の動画を 見るとソフトマックスの気持ちがわかります。

$ P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}}) =\frac{\exp(s(x))}{\sum_{x\in X}\exp(s(x))} …(Softmax) $

こうすることで、文脈を与えた時の候補の確率分布が得られるわけです。

ソフトマックス時の注意: 概念を代表させる単語

ただソフトマックスは、(i)文脈を与えた上の単語の確率をもとめて、 (ii)その確率の大きさで確率分布をつくっているだけ、という点に注意です。 なので、例えば「ハチ公」という概念を$c_\mathrm{hachi}$としたとき、 $P(c_\mathrm{hachi}\mid C_{\mathrm{pre}},C_{\mathrm{post}})$ を求められているわけではないのです。 あくまでも、求まるのは $P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}})$ です。

すこし話を具体的にしましょう。 たとえば、「ハチ公」という概念を単語で表現するとき、 色々な$x$が考えられるわけです。 これを $P(x \mid c_\mathrm{hachi})$ としましょう。 この$x$には「ハチ公」だったり「忠犬ハチ公」だったり、 いろいろな単語が入りうるわけですね。 そのいろんな $P(x \mid c_\mathrm{hachi})$ の和をとって1となるなら、 その概念のあらゆる表現方法を網羅したことになります。

でも、上で求めた $P(x\mid C_{\mathrm{pre}},C_{\mathrm{post}})$はそんなことを一切考慮していません。 なので、$P(c_\mathrm{hachi}\mid C_{\mathrm{pre}},C_{\mathrm{post}})$ において $P(x_\mathrm{chuuken-hachi} \mid c_\mathrm{hachi})$ と $P(x_\mathrm{hachikou} \mid c_\mathrm{hachi})$ の確率が それぞれ0.8、0.2なんてこともありうるわけですね。 なので概念の代表とする単語によって、 最終的なソフトマックスの値が全然違う、ということはありうる点に注意が必要です。

各項の求め方と「トークン」について

おさらいですが、上のScoreにあるように、まずは以下の2つを求めることになります。 以下の計算において、一筋縄ではいかない部分を**太字+下線**にしています。

  • $\log P(x\mid C_{\mathrm{pre}})$: 入力に $C_{\mathrm{pre}}+ x$ を与え、 **x部分の**トークン対数確率の総和を取得。
  • $\log P(C_{\mathrm{post}}\mid x, C_{\mathrm{pre}})$: 入力に $C_{\mathrm{pre}}+ x+ C_{\mathrm{post}}$ を与え、 **C_post 部分の**総和を取得。

日本語では「トークン」の境界が前後の文字で変わりやすいです。 なので、prefixのみと prefix+target をそれぞれトークン化し、 対象区間だけをトークン単位で切り出して合算しなくてはなりません。 後述しますが、トークンは単語でも形態素でも文字でもありません。

さて、ようやく「トークン」に関してですが、 大規模言語モデル(GPT系列を含む)では、 テキストをトークンという単位に分割して処理します。 トークンは文字でも単語でも形態素でもなく、 BPE(Byte-Pair Encoding)というアルゴリズムで決定される 文字列単位です。

日本語のトークン化の例は以下のようになります(は文字として表現されない情報を示します)。

  • 「渋谷の」→ ['�', '�', '谷', 'の'] (なんでやねんという感じですが、4トークン)
  • 「ハチ公」→ ['ハ', 'チ', '公'] (3トークン)
  • 「スクランブル交差点」→ ['スクランブル', '交差', '点'] (3トークン)

このトークン化には、いくつか特徴があります。 まず、同じ文字列でも、前後の文脈によって分割が変わる場合があります(文脈依存性)。 そして、1文字が1トークンとは限りません(頻出する文字列は長いトークンになります)。 そして、語彙サイズは制限されており、例えばGPT-OSSだったら 20万トークンという固定された語彙からモデルは選択します。 LLMは次のトークンを予測するように学習されています。

個人的に便利だ思っているのがOpenAIが公開している OpenAI Tokenizerです。 あと、以前からの同僚にtiktoken というツールも教えていただきました。 こうしたツールを使うと、 任意のテキストがどのようにトークン化されるか視覚的に確認できます。 異なるモデル(GPT-3.5、GPT-4など)のトークナイザーを比較できて、 GPT-OSSはGPT-4に近いと思います(「渋谷の」が4に分割されるという挙動が同じなので)。

使用例として、以下のようなトークン化になります。

入力: 「昨日、渋谷の名物であるハチ公を見に行った。」

トークン化結果(GPT-3.5/GPT-4の場合):
['昨日', '、', '�', '�', '谷', 'の', '名', '物', 'である', 'ハ', 'チ', '公', 'を', '見', 'に', '行', 'った', '。']

ちなみに、おもしろいのが英語におけるスペースの扱いです。

"I like cats" → ["I", " like", " cats"]

パーサーで単語に区切って構造をあてる時代は単語にスペースで区切る、 という処理が当たり前でした。日本語もそうですね。 ただ、この違いが形態素解析だったり分かち書きする処理の必要性を生んでました。 他方、このようにスペースを特別視しないことで、 英語も日本語も同じモデルが適用可能になるわけですね。 YANSで聴講したグーグル合同会社の工藤拓先生の講義で 聞いた話なのですが、勉強になりました。 こういう一般化、学んでいきたいですね。

実装では、以下のようにして対数尤度をトークンを考慮して求めます。

  1. 左文脈のみでトークン化 → トークン数 $n_{\text{left}}$ を取得
  2. 左文脈+候補語でトークン化 → トークン数 $n_{\text{left+cand}}$ を取得
  3. 候補語部分のトークン数を計算 = $n_{\text{left+cand}} - n_{\text{left}}$
  4. 候補語部分の対数尤度 = トークン[$n_{\text{left}}$:$n_{\text{left+cand}}$]の対数尤度の和

この方法により、「候補語がどのようにトークン化されるか」を事前に知る必要がなくなります。

注意点: 長さバイアスへの対処

トークンの幅を考える際、その長さによる確率の和の変化も気を付けないとなりません。 そもそもとして、確率の積や対数尤度の和を考える場合、 短い候補が有利になりやすいという特徴があります。 確率の積なら小さい値の掛け算が長くなるということですし、 対数尤度なら負の値を足し続けることになるからです。

長くなるほど確率が下がる、というのは概念に対しては不適切なので、 実装では、候補語部分($x$)トークン長による正規化を適用します。

$ \text{normalized-logp-candidate} = \frac{\log P(x \mid C_{\mathrm{pre}})}{\text{divisor}} $

なお、右文脈 $\log P(C_{\mathrm{post}} \mid C_{\mathrm{pre}}, x)$は 候補語間での比較において公平なので正規化を適用しません。

正規化手法の種類としては、以下のパターンを用意してあります。

  • NONE: divisor = 1.0 (正規化なし。短い候補が有利になりますが計算は単純)
  • TOKEN: divisor = max(1, token_length) (トークン数で除算。BPE分割に基づく正規化、技術的に最も適切)
  • CHAR: divisor = max(1, len(candidate)) (文字数で除算。人間の直感的な長さ感覚に近い評価ですが技術的には不適切。対数尤度の足し算の数と、割り算の分母が一致しない場合もあります)

実装の特徴とパフォーマンス最適化、結果など

計算を実装するにあたり以下の点を工夫しています。 実装する方がいらっしゃれば参考にしていただければと思います。 以下では、実装するときの工夫や実際に動かしてみた結果などを共有します。

工夫した点

  • "<左文脈>_<右文脈>" という入力形式で文脈を与え、split("_") でleft/right contextに分割
  • 両文脈がそれぞれ空文字の場合も対処(例えばですが、"_<右文脈>" を許します)
  • 左文脈のキャッシュ化(候補数だけ計算させるの無駄なのでjoblibMemoryを使ってます)
# サンプルコード
def evaluate_candidates(
    self,
    left_context: str,
    right_context: str,
    candidates: list[str],
    include_right_context: bool = False,
    length_norm: LengthNormalization = LengthNormalization.NONE,
) -> list[CandidateResult]:
    """Evaluate all candidates for the cloze task.

    Unified implementation that handles both cases (with/without left context)
    using the same logic. When left_context is empty, left_echo becomes an
    empty EchoLogProbs and left_token_count becomes 0, making the logic
    naturally handle the no-left-context case.
    """
    # Note: include_right_context is allowed even when left_context is empty.
    # In that case, we compute logP(right | candidate).

    results = []

    # Get left context logprobs (skip API call if no left context)
    left_token_count: int = 0  # なければこのまま0になる
    if left_context:
        left_echo: EchoLogProbs = self.get_echo_logprobs(left_context)
        left_token_count = len(left_echo.logprobs)

    for candidate in candidates:
        # Calculate logP(candidate | left) - when left is empty, this becomes logP(candidate)
        left_cand_echo: EchoLogProbs = self.get_echo_logprobs(left_context + candidate)
        left_cand_token_count = len(left_cand_echo.logprobs)
        candidate_token_length = left_cand_token_count - left_token_count
        logp_candidate = self.sum_segment(
            left_cand_echo.logprobs, left_token_count, left_cand_token_count
        )

        # Calculate logP(right | left + candidate) - 0.0 if no right_context
        full_echo: EchoLogProbs = self.get_echo_logprobs(left_context + candidate + right_context)
        full_token_count = len(full_echo.logprobs)
        logp_right = self.sum_segment(
            full_echo.logprobs, left_cand_token_count, full_token_count
        )

        # Apply include_right_context using multiplication (more efficient than branching)
        logp_right_weighted = logp_right * int(include_right_context)

        # Apply length normalization
        divisor = self.get_normalization_divisor(length_norm, candidate, candidate_token_length)
        normalized_logp_candidate = logp_candidate / divisor

        # Calculate final score
        score = normalized_logp_candidate + logp_right_weighted

        results.append(
            CandidateResult(
                candidate=candidate,
                score=score,
                logp_cand_normalized=normalized_logp_candidate,
                logp_right=logp_right,  # Store original value for transparency
                token_length=candidate_token_length,
            )
        )

    # Calculate relative probabilities
    self._calculate_relative_probabilities(results)
    return results

実装のポイントとしては、APIを呼ぶときにキャッシュしている部分です。 この関数は以下のキャッシュしていない関数の引数と返り値を 一つ上の処理でキャッシュしているので、 同じモデル、同じプロンプトが来たときは 同じEchoLogProbsを返します。

確率を計算しているのは以下の三つの行ですね。

left_echo: EchoLogProbs = self.get_echo_logprobs(left_context)
left_cand_echo: EchoLogProbs = self.get_echo_logprobs(left_context + candidate)
full_echo: EchoLogProbs = self.get_echo_logprobs(left_context + candidate + right_context)

なお、この実装は公開しています(GitHub: https://github.com/kishiyamat/llm-proba)。 本実装では、上述したvLLMが提供するOpenAI互換APIを使用しています。 具体的には /v1/completions エンドポイントを利用することで、 echo=Truelogprobs パラメータによるプロンプトトークンの確率取得を実現しています。

技術的制約として、 OpenAIの新しいChat Completions API (/v1/chat/completions) では echo パラメータがサポートされていません。 なので、OpenAI公式のGPT-4やGPT-3.5-turboでは、 従来の Completions API が非推奨・廃止されているため、 vLLMとオープンソースモデルの組み合わせが必須となるのでした。

APIパラメータの詳細は以下の通りです。

  • echo: True に設定することで、プロンプト全体の各トークンの対数尤度を返させています。
    • 例: 「渋谷の」を入力 → ['�', '�', '谷', 'の'] の4トークンに分割され、各トークンの対数尤度を取得できます。
  • logprobs: 整数値(0-5)で、各トークンの上位N個の候補と確率を返す数を指定します(本実装では 1 を使用)。
    • logprobs=1: 最も確率の高い1つの候補のみ(本実装で使用)
      • logprobs=5: 上位5つの候補とその確率(デバッグや詳細分析に有用ですが、今回は使っていません)
  • temperature: 0.0 に設定して決定的(“deterministic”の意味です)な出力を得ています。

呼ぶときのキャッシュしないバージョンは以下です。 これをjoblibMemoryでキャッシュさせています。

def _get_echo_logprobs_uncached(self, prompt: str, model: str) -> EchoLogProbs:
    """Get echo logprobs using direct HTTP request."""
    try:
        # Make direct HTTP request to /v1/completions endpoint (OpenAI compatible)
        url = f"{self.base_url}/v1/completions"
        payload = {
            "prompt": prompt,
            "model": model,
            "max_tokens": 1,
            "echo": True,
            "logprobs": 1,  # Number of top logprobs to return (0-5)
            "temperature": 0.0,
        }

        response = requests.post(url, json=payload, timeout=60.0)
        response.raise_for_status()

        data = response.json()

        if "choices" not in data or not data["choices"]:
            raise APIError("Invalid response format from API")

        choice = data["choices"][0]
        logprobs_obj = choice.get("logprobs")

        if not logprobs_obj or "tokens" not in logprobs_obj or "token_logprobs" not in logprobs_obj:
            raise APIError("No logprobs returned from API")

        # Extract tokens and logprobs from the response
        tokens = logprobs_obj["tokens"]
        logprobs_data = logprobs_obj["token_logprobs"]

        if not tokens or not logprobs_data:
            raise APIError("No tokens or logprobs returned from API")

        # Handle None values in logprobs by replacing with log(MIN_PROBABILITY)
        logprobs = [lp if lp is not None else math.log(MIN_PROBABILITY) for lp in logprobs_data]

        return EchoLogProbs(
            logprobs=logprobs,
            tokens=tokens,
        )
    except requests.RequestException as e:
        error_msg = str(e)
        if "404" in error_msg or "Not Found" in error_msg:
            raise APIError(f"Model {model} endpoint not found") from e
        else:
            raise APIError(f"Error getting logprobs for prompt: {error_msg}") from e
    except Exception as e:
        raise APIError(f"Error getting logprobs for prompt: {str(e)}") from e

パラメータはわかりづらいので、いくつか例を見てみましょう。

echo=True と logprobs=1 による確率計算の具体例

echo=True を使うことで、プロンプト全体の各トークンの対数尤度が得られます。 これを利用して、候補語部分だけの確率を抽出する方法を示します。 「渋谷のハチ公」を例に考えていきましょう。まず、左文脈として「渋谷の」 を入力したとします。その際のAPIレスポンスは以下のようになります。 実装上はdictだと中身がわかりづらいのでEchoLogProbsという型を作っています。

// EchoLogProbsとして格納
{
  "tokens": ["�", "�", "谷", "の"],
  "token_logprobs": [null, -3.824, -0.900, -1.684]
}

このようなレスポンスになるので、 (i) 左文脈 (ii) 左文脈+候補語 と二回計算します。この(i)と(ii)のトークン数の差分が 候補語のトークン数となり、 また足すべき幅もわかるわけです。

# 1. 左文脈「渋谷の」のトークン数を取得
left_echo = get_echo_logprobs("渋谷の")
left_token_count = len(left_echo.tokens)  # = 4

# 2. 「渋谷の」+「ハチ公」のトークンと対数尤度を取得
left_cand_echo = get_echo_logprobs("渋谷のハチ公")
# tokens: ['�', '�', '谷', 'の', 'ハ', 'チ', '公']
# logprobs: [None, -3.8, -0.9, -1.7, -2.1, -1.5, -0.8]

# 3. 「ハチ公」部分(トークン4-7)の対数尤度を合計
logp_candidate = sum(left_cand_echo.logprobs[4:7])
# = -2.1 + -1.5 + -0.8 = -4.4

# これがP(ハチ公|渋谷の)の対数確率

この手法により、トークン境界に依存せず任意の文字列区間の確率を計算できます。

結果

ツールにする場合は以下のようにCLI化したり、 StreamlitやGradioなどを使ってGUIを作るのもよいと思います。 いくつか面白そうな例で回してみましょう。

渋谷の名物は?

CLIを作成したのですが、いくつか引数を渡せるようにすると便利で、 --with-rightで右の文脈を考慮するようにしてます。 --length-normで標準化の方法ですね。

# 基本的な評価
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "昨日、渋谷の名物である_を見に行った。" \
    --cands ハチ公 スクランブル交差点 スタバ 公園\
    --with-right \
    --length-norm token

この結果は以下になります。 ハチ公が多いですね。

cand      score   P_rel   logP_norm  logP_r  tok_len
ハチ公    -11.152  0.598   -0.857    -10.295   3
スクラ... -11.771  0.322   -0.394    -11.377   6
公園      -13.313  0.069   -0.805    -12.508   2
スタバ    -15.080  0.012   -1.777    -13.304   2

先述した通り、概念ではなく単語なので、 単語を足していくとどんどんと ソフトマックスした確率は上がっていきます。

# 基本的な評価
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "昨日、渋谷の名物である_を見に行った。" \
    --cands ハチ公 スクランブル交差点 スタバ 公園 ハチ公像 ハチ公の像 忠犬ハチ公 忠犬ハチ公の像\
    --with-right \
    --length-norm token

cand           score    P_rel   logP_norm  logP_r   tok_len
ハチ公の像      -9.612   0.317   -0.947     -8.665   5
忠犬ハチ公...   -9.773   0.270   -0.423     -9.350   7
忠犬ハチ公      -10.313  0.158   -0.280     -10.033  5
ハチ公像        -10.427  0.141   -1.031     -9.396   4
ハチ公          -11.152  0.068   -0.857     -10.295  3
スクラ...       -11.771  0.037   -0.394     -11.377  6
公園            -13.313  0.008   -0.805     -12.508  2
スタバ          -15.080  0.001   -1.777     -13.304  2

これをみると、「ハチ公」よりも「ハチ公の像」の方が あてはまりがよさそうですね。 結局、この候補なら87.4%でハチ公が選択されることになります。 まぁ「スタバ」を「スターバックス」にしたら、 そっちの確率が高くなったりしそうなので 候補に依存する指標ではあるのですが…。

# 基本的な評価
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "昨日、渋谷の名物である_を見に行った。" \
    --cands ハチ公の像 スクランブル交差点 スタバ 公園 \
    --with-right \
    --length-norm token

cand       score     P_rel  logP_norm  logP_r  tok_len
ハチ公の像   -9.612   0.874   -0.947   -8.665   5
スクラ...    -11.771  0.101   -0.394   -11.377  6
公園         -13.313  0.022   -0.805   -12.508  2
スタバ       -15.080  0.004   -1.777   -13.304  2

あと、ちゃんと右の文脈を考慮できているかをチェックするために 「を見に行った。」ではなく「を歩いた。」とかにしてみましょうか。

# 基本的な評価
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "昨日、渋谷の名物である_を歩いた。" \
    --cands ハチ公の像 スクランブル交差点 スタバ 公園 \
    --length-norm token

cand       score     P_rel  logP_norm  logP_r  tok_len
スクラ...   -11.061   0.747   -0.394  -10.667 6
公園        -12.504   0.176   -0.805  -11.699 2
ハチ公の像   -13.360   0.075   -0.947  -12.413 5
スタバ      -17.054   0.002   -1.777  -15.277 2

ちゃんと考慮できていますね。

日比谷? 渋谷

手前みそなのですが、 『ITエンジニアと眺める言語学』 という本を出しました。 そこで「日比谷」と「渋谷」の聞き間違えの話があるので、 例にしてみましょう。

そのままだと、以下のように「渋谷」が優勢です。 でも、実際の発話は「日比谷」だったとします。 さて、どうしたらよいでしょうか。

# 基本的な評価
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "_の公園に行った。" \
    --cands 日比谷 渋谷 \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
渋谷    -26.478 0.684   -6.786  -19.692 4
日比谷  -27.248 0.316   -8.135  -19.114 4

さて、どうしたらこの「日比谷」を優位にできるでしょうか。 例えば「自然に触れたくて」とかはどうでしょう。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "自然に触れたくて_の公園に行った。" \
    --cands 日比谷 渋谷 \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
渋谷    -12.626 0.828   -0.795  -11.831 3
日比谷  -14.198 0.172   -2.897  -11.301 3

だめですね。そもそも日比谷公園に行ったことがないし、 いい直感がありません。日比谷は千代田区にありますが、 「千代田区の」とつけてみてはどうでしょうか。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "千代田区にある_の公園に行った。" \
    --cands 日比谷 渋谷 \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
日比谷  -19.411 0.632   -0.741  -18.670 3
渋谷    -19.952 0.368   -0.425  -19.527 3

えらい! ちゃんと日比谷が優位になりました。

拍手と握手

ほかには拍手と握手なんてのもありますね。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎は_拍手した。" \
    --cands パチパチと ぎゅっと \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
ぎゅっと        -6.948  0.868   -1.315  -5.633  3
パチパチと      -8.831  0.132   -2.644  -6.186  5

これはうまくいっていませんね。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎はパチパチ_した。" \
    --cands 握手 拍手 \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
拍手    -3.812  0.692   -0.653  -3.160  2
握手    -4.622  0.308   -1.367  -3.255  2
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎はガッシリ_した。" \
    --cands 握手 拍手 \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
拍手    -4.038  0.637   -0.661  -3.378  2
握手    -4.602  0.363   -1.282  -3.321  2

これらもダメそうです。 「ガッシリ拍手」なんて聞いたこともないのですが…。 ちょっとそもそもの確率から見てみましょう。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎は_と拍手した。" \
    --cands パチパチ ぎゅっ ガッシリ \
    --length-norm token
    # --with-right \

cand      score   P_rel   logP_cand_norm  tok_len
ぎゅっ    -1.846  0.478   -1.846  3
ガッシリ  -2.275  0.311   -2.275  4
パチパチ  -2.666  0.211   -2.666  4

右の文脈を見なければ、「ぎゅっ」や「ガッシリ」のほうが 頻度が高い状態です。 いろんなパターンも見てみましょう。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎は_と拍手した。" \
    --cands パチパチ ぎゅっ ガッシリ ぱちぱち ぱちぱちと がっしり がしっ ぱちっ \
    --length-norm token
    # --with-right \

cand    score   P_rel   logP_cand_norm  tok_len
がっしり   -1.425  0.258   -1.425  4
ぱちぱち   -1.767  0.183   -1.767  4
ぎゅっ     -1.846  0.169   -1.846  3
ぱちぱちと -1.992  0.146   -1.992  5
ガッシリ   -2.275  0.110   -2.275  4
パチパチ   -2.666  0.075   -2.666  4
ぱちっ     -3.365  0.037   -3.365  3
がしっ     -3.917  0.021   -3.917  3

うーむ、ふしぎですね。とりあえず、様態を表す代表は ひらがなにしてみましょう。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎は_と拍手した。" \
    --cands ぱちぱち ぎゅっ がっしり \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
ぎゅっ    -7.479  0.995   -1.846  -5.633  3
ぱちぱち  -12.785 0.005   -1.767  -11.018 4
がっしり  -17.152 0.000   -1.425  -15.727 4

どうすればこうなるのでしょうか。 理由を入れてみましょうか。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "太郎は感激して_と拍手した。" \
    --cands ぱちぱち ぎゅっ がっしり \
    --with-right \
    --length-norm token

cand    score   P_rel   logP_cand_norm  logP_right      tok_len
ぱちぱち        -6.411  0.602   -1.358  -5.053  4
ぎゅっ  -6.842  0.391   -1.715  -5.127  3
がっしり        -10.983 0.006   -1.330  -9.652  4

ようやく「ぱちぱち」が上になりました。 色々と工夫が必要そうです。

ほかにも、「握手」を優位させるためにいくつか試してみました。

  • “太郎は自分の名前を言った後に_した。” -> 0.449
  • “太郎は相手の目を見て_した。” -> 0.509
  • “太郎はぎゅっと_した。” -> 0.172
  • “太郎は「初めまして」と言いながら_した。” -> 0.516
  • “太郎は取引先の営業と_した。” -> 0.532
  • “取引先の営業と_するのはマナーだ。” -> 0.851
  • “太郎は礼儀正しく取引先の営業と_した。” -> 0.450
  • “取引先の営業と_して名刺を交換した。” -> 0.688

うーむ、わかりませんね。 そもそも「太郎は」みたいなジェネリックな、 よくプレースホルダーとして使われるものを使うのがよくない気もしてきました。 今後も使っていくツールになるので、 特徴はつかんでいこうと思います。

英語のテスト

まぁこれは得意ですよね。

uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "The machine can be very dangerous, especially when it _ in motion." \
    --cands is moves goes has \
    --with-right \
    --length-norm token

# 英語だと "The machine can be very dangerous, especially when it "
# を入力していて最後の " " が1tokenになっている。
# そこに "is" がはいってもトークン数は変わらない
cand    score   P_rel   logP_cand_norm  logP_right      tok_len
is      -5.059  0.886   0.000   -5.059  0
has     -7.688  0.064   0.000   -7.688  0
goes    -8.027  0.046   0.000   -8.027  0
moves   -10.408 0.004   0.000   -10.408 0
uv run python cloze.py \
    --base-url http://localhost:8000 \
    --model openai/gpt-oss-20b \
    --cloze "All parrots have one thing in _." \
    --cands addition fact advance common \
    --with-right \
    --length-norm token

cand     score   P_rel   logP_cand_norm  logP_right      tok_len
common   -1.004  0.324   0.000   -1.004  0
fact     -1.172  0.274   0.000   -1.172  0
addition -1.253  0.253   0.000   -1.253  0
advance  -1.787  0.148   0.000   -1.787  0

実験結果のまとめ

期待通りに動いてくれるケースが多くありましたが、 擬音語や擬態語に関しては想定と違う挙動をすることがありました。 また単純な動詞に関しても、ジェネリックというか、 文脈を絞り切らないケースで期待と違う挙動をすることがありました。 ただ、気軽に試せるのは間違いないので 今後も挙動を実験してみて理解する必要がありそうです。

環境構築

最後に、環境に関しての補足です。

ハードウェア要件

GPUの選択は細心の注意が必要です。 対応していないものを導入すると、 そもそも動かないなんてことになります。 自分はハードウェアに関して素人なので、 まずは一通りセットになっているものを購入しました。 GPT-OSS-20Bは16GB VRAM必須です。 自分が使っているのはRTX 5070 Tiなのですが、 理屈では4080でも動くはずで、5070だと動かないはずです。

GPU選択肢 VRAM 価格帯 適用性 推奨度
RTX 4070 Ti 12GB ~12万円 不足 使用不可
RTX 4080 16GB ~15万円 ✅ 可能(?) 安定動作
RTX 5070 12GB ~10万円 不足 使用不可
RTX 5070 Ti 16GB ~13万円 ✅ 最新 最適選択

ソフトウェア環境構築

これに関してはREADMEに記述しています。

まとめ

この記事では、 Cloze Taskにおける候補集合 $X$ の各候補 $x$ にスコアを付与し、 相対確率を得るまでの一連の手順を、実装と実験を交えて整理しました。 目標は文脈 $C_{\mathrm{pre}}, C_{\mathrm{post}}$ に対して候補 $x$ の尤もらしさを比較することで、 これは $s(x)=\log P(x\mid C_{\mathrm{pre}})$ と、 必要に応じて $\log P(C_{\mathrm{post}}\mid C_{\mathrm{pre}}+x)$ を合算しました。 そして、候補間の相対分布は $\mathrm{softmax}(s(x))$ で得る、という流れです。

注意点としては、別表記の候補を増やすとsoftmax の分母が増えたり、 同一概念内で確率が割れて他概念の見かけの確率まで変動したり、という問題があります。 対策としては、代表する概念を探すか各概念において候補を羅列する方法がありますが、 代表する概念を選択するほうが楽そうです。

いくつか実験もしましたが、一般的なケースでは直感に合う順位づけが得られました。 ただ、擬音語や擬態語などが出てくる場合では期待と違う挙動も見られました。 今後の展望はハイパーパラメータの調整や人の感覚と近づく条件の調査などがタスクとしてあります。 実装は公開していますので(https://github.com/kishiyamat/llm-proba)、 バグ報告や改善提案、実験レポートの共有を歓迎します。



  1. 記事を書いたおかげで、 実装や挙動の想定外だった部分に複数気づくことができました。 

  2. あえて適当さを出すために「それっぽい」と表現しています。 

  3. 工夫すればできるかもしれません。 N個のトークンを出力させてマッチをとるとか。 なお似た例としては、 分類タスクにLLMを用いるケースがあります。 コードを読み慣れている人はそちらを一読してもよいかもしれません。