評価メトリクス(BLEU・ROUGE・BERTScore)— テキスト生成の自動評価を理解する

「この翻訳は良い翻訳ですか?」と聞かれたら、あなたはどう判断するでしょうか。原文の意味が正確に伝わっているか、自然な日本語になっているか、重要な情報が欠落していないか — 人間であれば、これらを総合的に判断できます。しかし、機械翻訳モデルが1日に何万文もの翻訳を生成するとき、すべてを人間が評価するのは現実的ではありません。

同じ問題は、テキスト要約でも対話システムでも発生します。GPTのような大規模言語モデルが普及した現在、テキスト生成の品質を自動で定量化する方法はこれまで以上に重要になっています。

この問いに対して、研究コミュニティは主に3つのアプローチを発展させてきました。

  • BLEU — n-gramの精度に基づく指標。機械翻訳の評価で最も広く使われてきた古典的手法です
  • ROUGE — n-gramの再現率に基づく指標。テキスト要約の評価で標準的に使用されます
  • BERTScore — 事前学習済みモデルの埋め込みを用いた意味的類似度の指標。同義語や言い換えに対応できる新しい評価法です

本記事では、この3つのメトリクスについて、数式の導出から直感的な理解、そしてPythonでのスクラッチ実装まで一貫して解説します。どのメトリクスがどんな場面に強く、どんな限界があるのかを理解すれば、自分のタスクに最適な評価戦略を設計できるようになります。

本記事の内容

  • n-gram精度とn-gram再現率の概念
  • BLEUスコアの数学的定式化と直感的理解
  • ROUGEファミリー(ROUGE-N、ROUGE-L)の定義と計算
  • BERTScoreの貪欲マッチングとIDF重み付け
  • Pythonでの3メトリクスのスクラッチ実装
  • 同一文ペアでの比較実験と使い分けの指針

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
言語モデルとパープレキシティを理解する
言語モデルの基礎とパープレキシティによる評価を解説しています。本記事の自動評価指標の前提となる概念です。
画像なし
埋め込みベクトルと類似度検索の理論とPython実装
コサイン類似度や埋め込みベクトルの概念を解説しています。BERTScoreの理解に直結します。
画像なし
BERTの仕組みと双方向Transformerの理論と実装
BERTScoreの基盤となるBERTアーキテクチャを解説しています。

自動評価はなぜ難しいのか

テキスト生成の評価が難しい根本的な理由は、正解が一つではないことにあります。

たとえば、英語の “The cat sat on the mat.” を日本語に訳すとき、「猫がマットの上に座った」「マットの上に猫が座っていた」「猫はマットに座った」のどれも正しい翻訳です。単純に文字列が一致するかどうかで判定すると、意味的に正しい翻訳でも「不正解」と判定されてしまいます。

この問題に対して、自動評価メトリクスは異なる戦略を取ります。BLEUとROUGEは表層的な単語の重なり(n-gram一致)を測ります。一方、BERTScoreは意味レベルの類似度を測ります。どちらのアプローチにも一長一短があり、それを理解することが本記事のゴールです。

まず、BLEUとROUGEの基盤となる「n-gram一致」の考え方から始めましょう。

n-gramとは

n-gramとは、テキストを連続する $n$ 個の単語(またはトークン)で区切った部分列のことです。料理のレシピに例えると分かりやすいでしょう。レシピの手順を「材料を切る → 炒める → 味付けする」と書いたとき、各ステップが1-gram(unigram)、連続する2ステップのペアが2-gram(bigram)に相当します。

具体的に見てみましょう。文 “the cat sat on the mat” に対して:

  • 1-gram (unigram): “the”, “cat”, “sat”, “on”, “the”, “mat”
  • 2-gram (bigram): “the cat”, “cat sat”, “sat on”, “on the”, “the mat”
  • 3-gram (trigram): “the cat sat”, “cat sat on”, “sat on the”, “on the mat”

$n$ が大きくなるほど、より長い語順のパターンを捉えます。1-gramは単語の有無だけを見ますが、4-gramが一致すれば、4単語の並びが同じということですから、かなり流暢な表現が保たれていると推測できます。

この「参照文と生成文の間でn-gramがどれだけ重なっているか」を定量化するのが、BLEUとROUGEの基本的な発想です。ただし、重なりを生成文の側から見る(精度)か、参照文の側から見る(再現率)かで両者は大きく異なります。

では最初に、精度の観点からn-gram一致を測るBLEUスコアを詳しく見ていきましょう。

BLEUスコアの直感

BLEU(Bilingual Evaluation Understudy)は、2002年にIBMの研究者Kishore Papineniらによって提案された、機械翻訳の自動評価指標です。

BLEUの基本的な問いは非常にシンプルです — 生成文に含まれる単語やフレーズのうち、どれだけが参照文にも存在するか? これはまさに「精度(Precision)」の考え方です。

たとえば、参照文(正解の翻訳)が “the cat is on the mat” で、システムの出力が “the the the the” だったとしましょう。単純なunigram精度を計算すると、生成文の4単語すべてが参照文に含まれる “the” なので、精度は $4/4 = 1.0$ つまり100%になってしまいます。明らかに無意味な翻訳なのに、満点です。

この問題を解決するために、BLEUはModified Precision(修正精度)を導入しました。あるn-gramが参照文に出現する回数を上限として、生成文側のカウントを「クリッピング(切り詰め)」するのです。

上の例で考えると、”the” は参照文に2回出現します。したがって、生成文で “the” が4回出現しても、カウントは2に切り詰められます。Modified Precisionは $2/4 = 0.5$ となり、直感に合った評価になります。

さらにBLEUは、短すぎる翻訳にペナルティを与える仕組みも持っています。極端に短い文を出力すれば、含まれるn-gramはほぼ参照文と一致するため精度が不当に高くなります。これを抑制するのがBrevity Penalty(BP)です。

これらの仕組みを数式で定式化していきましょう。

BLEUの数学的定式化

Modified Precision

生成文(候補文)を $c$、参照文の集合を $\{r_1, r_2, \dots\}$ とします。$n$-gram $g$ に対して、Modified Precisionは次のように定義されます。

まず、生成文中のn-gram $g$ の出現回数を $\text{Count}(g, c)$ とします。参照文 $r_j$ 中の出現回数を $\text{Count}(g, r_j)$ として、クリッピングされたカウントを次のように定義します:

$$ \text{Count}_{\text{clip}}(g) = \min\!\Big(\text{Count}(g, c),\ \max_{j} \text{Count}(g, r_j)\Big) $$

この式が表しているのは、「生成文中のn-gramの出現回数を、参照文中の最大出現回数で頭打ちにする」ということです。ある単語を参照文以上に繰り返しても、評価は上がりません。

これを用いて、$n$-gramのModified Precision $p_n$ は次のように計算されます:

$$ p_n = \frac{\displaystyle\sum_{g \in \mathcal{G}_n(c)} \text{Count}_{\text{clip}}(g)}{\displaystyle\sum_{g \in \mathcal{G}_n(c)} \text{Count}(g, c)} $$

ここで $\mathcal{G}_n(c)$ は生成文 $c$ に含まれる全てのユニークな $n$-gramの集合です。分母は生成文中の $n$-gramの総出現数、分子はクリッピング後の総出現数です。

Brevity Penalty

生成文が参照文より極端に短い場合にペナルティを課します。生成文の長さを $c$(ここでは単語数)、参照文の長さのうち生成文に最も近いものを $r$ として:

$$ \text{BP} = \begin{cases} 1 & \text{if } c > r \\ e^{1 – r/c} & \text{if } c \leq r \end{cases} $$

生成文が参照文と同じかそれ以上の長さなら $\text{BP} = 1$(ペナルティなし)です。短くなるほど $r/c$ が大きくなり、指数関数的にペナルティが増加します。たとえば、生成文が参照文の半分の長さなら $\text{BP} = e^{1 – 2} = e^{-1} \approx 0.368$ となり、スコアが約63%削減されます。

BLEUスコアの最終式

Modified Precisionを $n = 1, 2, 3, 4$ について計算し、それらの対数の重み付き平均の指数を取ります。均等重み $w_n = 1/N$(通常 $N = 4$)を用いると:

$$ \text{BLEU} = \text{BP} \cdot \exp\!\left(\sum_{n=1}^{N} w_n \log p_n \right) $$

指数の中身は対数の和なので、これは幾何平均と等価です:

$$ \text{BLEU} = \text{BP} \cdot \left(\prod_{n=1}^{N} p_n\right)^{1/N} $$

幾何平均を使う理由は明確です。算術平均では、ある $n$-gram精度が0でも他の精度が高ければスコアが正になりますが、幾何平均では一つでも0があれば全体が0になります。つまり、「unigramは一致するが4-gramは全く一致しない」(単語は合っているが語順がめちゃくちゃ)のような場合に、厳しく評価できるのです。

この定式化を踏まえた上で、BLEUの実用上の長所と限界を整理しましょう。

BLEUの長所と限界

長所

BLEUが20年以上にわたり機械翻訳の標準的評価指標であり続けた理由は、いくつかの実用的な優位性にあります。

計算が高速: n-gramのカウントだけで完結するため、ニューラルネットワークの推論を必要としません。大量の翻訳ペアに対しても即座にスコアを計算できます。

人間の評価と一定の相関: コーパスレベル(多数の文を集約した場合)では、人間の翻訳品質評価とそれなりの相関を示すことが知られています。モデルの開発サイクルにおいて、候補モデル間の相対比較には十分役立ちます。

再現性: 計算が決定論的であり、同じ入力に対して常に同じスコアが得られます。異なる研究グループ間で結果を比較できるため、ベンチマークとして機能しやすいです。

限界

一方で、BLEUには本質的な限界もあります。

同義語・言い換えに対応できない: “quickly” と “fast” は意味的にほぼ同じですが、n-gramの一致としてはカウントされません。参照文が “He runs quickly” で生成文が “He runs fast” なら、bigram “runs quickly” と “runs fast” は不一致です。

語順の評価が不十分: unigramは語順を完全に無視し、bigramやtrigramもごく短い局所的な語順しか捉えません。文全体の構造的な正しさは評価できません。

文レベルでの不安定性: BLEUは本来コーパスレベル(数百〜数千文の集約)で使うことを想定した指標です。単一の短い文に対して計算すると、n-gramの一致がわずかに変わるだけでスコアが大きく変動します。特に、4-gramが一つも一致しない文では $p_4 = 0$ となり、BLEUスコアは無条件で0になります。

再現率を考慮しない: BLEUは精度ベースの指標であるため、参照文の重要な内容が生成文に含まれているかどうかを直接的には測定しません。参照文の一部だけを正確に訳して残りを省略しても、精度は高くなります(Brevity Penaltyである程度は抑制されますが、完全ではありません)。

この最後の限界 — 再現率を測定しないこと — は、特にテキスト要約の評価で深刻です。要約では、原文の重要情報がどれだけ網羅されているかが本質的に重要だからです。まさにこの問題を解決するために設計されたのが、次に紹介するROUGEです。

ROUGEスコアの直感

ROUGE(Recall-Oriented Understudy for Gisting Evaluation)は、2004年にChin-Yew Linによって提案された、テキスト要約の自動評価指標ファミリーです。

名前に “Recall-Oriented”(再現率志向)と明記されているように、ROUGEはBLEUとは視点が逆転しています。BLEUが「生成文の中身がどれだけ正しいか(精度)」を問うのに対し、ROUGEは「参照文の中身がどれだけ生成文にカバーされているか(再現率)」を問います。

これを日常的な場面に例えてみましょう。大学の講義ノートを友人に要約して伝える場面を想像してください。BLEUは「友人に伝えた内容が正確かどうか」を評価します。一方ROUGEは「講義の重要ポイントがどれだけ伝わったか」を評価します。要約では後者のほうが本質的に重要でしょう — 正確だけど重要な情報が抜けている要約よりも、多少言い回しが変わっても重要ポイントを網羅している要約のほうが有用です。

ROUGEにはいくつかのバリアントがあります。

  • ROUGE-N: n-gramの再現率を測る。ROUGE-1(unigram)、ROUGE-2(bigram)が最もよく使われます
  • ROUGE-L: 最長共通部分列(Longest Common Subsequence, LCS)に基づくメトリクスです
  • ROUGE-S: Skip-bigram(間に任意の単語を挟んでよいbigram)に基づくメトリクスです

実務で最も頻繁に使われるのはROUGE-1、ROUGE-2、ROUGE-Lの3つです。本記事ではこれらを中心に解説します。

では、ROUGEの各バリアントの数学的な定義を見ていきましょう。

ROUGEの数学的定式化

ROUGE-N

ROUGE-Nは、$n$-gramの再現率(Recall)、精度(Precision)、F1スコアの3つを計算します。参照文を $r$、生成文(システム要約)を $c$ とします。

再現率(Recall) は、参照文中のn-gramのうち、生成文にも出現するものの割合です:

$$ R_n = \frac{\displaystyle\sum_{g \in \mathcal{G}_n(r)} \min\!\big(\text{Count}(g, c),\ \text{Count}(g, r)\big)}{\displaystyle\sum_{g \in \mathcal{G}_n(r)} \text{Count}(g, r)} $$

分母は参照文中のn-gramの総出現数です。分子は、各n-gramについて「参照文での出現数」と「生成文での出現数」の小さいほうを取って合計したものです。BLEUのModified Precisionと似た構造ですが、分母が参照文側になっている点が決定的に異なります。

精度(Precision) は、生成文中のn-gramのうち、参照文にも出現するものの割合です:

$$ P_n = \frac{\displaystyle\sum_{g \in \mathcal{G}_n(r)} \min\!\big(\text{Count}(g, c),\ \text{Count}(g, r)\big)}{\displaystyle\sum_{g \in \mathcal{G}_n(c)} \text{Count}(g, c)} $$

分子は同じですが、分母が生成文側に変わります。

F1スコア は、再現率と精度の調和平均です:

$$ F_{1,n} = \frac{2 P_n R_n}{P_n + R_n} $$

要約タスクでは伝統的に再現率が重視されてきましたが、近年ではF1スコアを報告するのが一般的になっています。長い生成文を出せば再現率は上がりますが精度が下がるため、F1でバランスを取るのが合理的です。

ROUGE-L(最長共通部分列)

ROUGE-Lは、n-gramの固定長一致ではなく、最長共通部分列(LCS: Longest Common Subsequence)を用います。

LCSとは、2つの系列に共通して現れる部分列のうち、最も長いものです。ここで重要なのは、「部分列」は連続していなくてもよいという点です。元の順序さえ保っていれば、間に別の単語が挟まっていても構いません。

たとえば、参照文 “police killed the gunman” と生成文 “police kill the gunman” に対して、LCSは “police the gunman” で長さ3です(”killed” と “kill” は一致しません)。n-gramと異なり、連続しない単語列の一致も自然に捉えられます。

参照文の長さを $m$、生成文の長さを $n$、LCSの長さを $\text{LCS}(r, c)$ として:

$$ R_{\text{lcs}} = \frac{\text{LCS}(r, c)}{m} $$

$$ P_{\text{lcs}} = \frac{\text{LCS}(r, c)}{n} $$

$$ F_{\text{lcs}} = \frac{(1 + \beta^2) R_{\text{lcs}} P_{\text{lcs}}}{R_{\text{lcs}} + \beta^2 P_{\text{lcs}}} $$

ここで $\beta$ は再現率に対する精度の相対的な重みを制御するパラメータです。$\beta \to \infty$ とすると $F_{\text{lcs}} \to R_{\text{lcs}}$(再現率のみ)になります。多くの実装では $\beta$ を十分大きく設定し、実質的に再現率ベースの評価を行います。

LCSの計算は動的計画法(DP)で効率的に行えます。参照文と生成文の長さをそれぞれ $m$, $n$ としたとき、計算量は $O(mn)$ です。DPテーブル $L[i][j]$ を次のように定義します:

$$ L[i][j] = \begin{cases} L[i-1][j-1] + 1 & \text{if } r_i = c_j \\ \max(L[i-1][j],\ L[i][j-1]) & \text{otherwise} \end{cases} $$

初期条件は $L[0][j] = L[i][0] = 0$ です。最終的に $L[m][n]$ がLCSの長さを与えます。

ROUGEは表層的なn-gram一致を基盤としているため、BLEUと同様に同義語や言い換えを捉えることはできません。「犬が走った」と「犬が駆けた」は人間から見ればほぼ同じ意味ですが、ROUGE-1ですら “走った” と “駆けた” は不一致です。この限界を克服するために、意味レベルで類似度を測る指標が求められます。それがBERTScoreです。

BERTScoreの直感

BERTScore(Zhang et al., 2020)は、事前学習済みのTransformerモデル(BERTなど)が出力するトークン埋め込みを用いて、生成文と参照文の意味的類似度を測定する指標です。

BERTScoreの発想は直感的です。人間が2つの文を比較するとき、単語が完全に一致しているかではなく、同じ意味のことを言っているかを判断します。”The dog quickly ran across the field” と “The canine sprinted over the meadow” は、一つも同じ単語を共有していませんが、ほぼ同じ意味です。BLEUやROUGEのn-gram一致ではスコアが低くなりますが、人間の判断としては「良い翻訳/要約」です。

BERTScoreは、BERTのような文脈を考慮した埋め込みモデルを使って各トークンをベクトル化し、コサイン類似度に基づいてトークン間のマッチングを行います。”quickly” と “rapidly” は埋め込み空間で近い位置にあるため、高い類似度スコアが得られます。これにより、表層的な一致に頼らない、より柔軟な評価が可能になります。

BERTScoreの計算手順を大まかに述べると:

  1. 参照文と生成文をそれぞれBERTに入力し、各トークンの埋め込みベクトルを取得する
  2. 全てのトークンペア間のコサイン類似度を計算し、類似度行列を作る
  3. 各トークンに対して最も類似度が高い相手を選ぶ(貪欲マッチング)
  4. マッチした類似度の平均をPrecision、Recall、F1として算出する

この「貪欲マッチング」は、対応関係を柔軟に捉える鍵です。n-gramのような固定的な位置関係に縛られず、文中の任意の位置にあるトークン同士が対応できます。

では、BERTScoreの数学的な定式化を詳しく見ていきましょう。

BERTScoreの数学的定式化

コサイン類似度行列

参照文のトークン列を $\bm{r} = (r_1, r_2, \dots, r_m)$、生成文のトークン列を $\bm{c} = (c_1, c_2, \dots, c_n)$ とします。事前学習済みモデルを用いて、各トークンの文脈依存埋め込みベクトルを取得します:

$$ \bm{r}_i \in \mathbb{R}^d, \quad \bm{c}_j \in \mathbb{R}^d $$

ここで $d$ は埋め込みの次元数(BERTベースなら768、ラージなら1024)です。

全てのトークンペア $(r_i, c_j)$ 間のコサイン類似度を計算し、$m \times n$ の類似度行列 $\bm{S}$ を作ります:

$$ S_{ij} = \frac{\bm{r}_i^\top \bm{c}_j}{\|\bm{r}_i\| \cdot \|\bm{c}_j\|} $$

この行列の $(i, j)$ 要素は、参照文の $i$ 番目のトークンと生成文の $j$ 番目のトークンの意味的類似度を表します。

貪欲マッチング(Greedy Matching)

類似度行列から、Precision、Recall、F1を計算します。

Recall(再現率): 参照文の各トークン $r_i$ に対して、生成文の中で最も類似度が高いトークンを選びます。全てのマッチの平均が再現率です:

$$ R_{\text{BERT}} = \frac{1}{m} \sum_{i=1}^{m} \max_{j} S_{ij} $$

この式は「参照文の各単語に対して、生成文の中にどれだけ意味の近い単語があるか」を測っています。再現率が高ければ、参照文の意味内容が生成文にカバーされていることを意味します。

Precision(精度): 生成文の各トークン $c_j$ に対して、参照文の中で最も類似度が高いトークンを選びます:

$$ P_{\text{BERT}} = \frac{1}{n} \sum_{j=1}^{n} \max_{i} S_{ij} $$

精度が高ければ、生成文に含まれる単語が全て参照文の意味内容に関連していることを意味します。

F1スコア: 精度と再現率の調和平均です:

$$ F_{\text{BERT}} = 2 \cdot \frac{P_{\text{BERT}} \cdot R_{\text{BERT}}}{P_{\text{BERT}} + R_{\text{BERT}}} $$

IDF重み付け

全てのトークンを等しく扱うと、”the” や “is” のような高頻度の機能語が類似度を支配してしまいます。BERTScoreでは、オプションとしてIDF(逆文書頻度)重み付けを導入できます。

コーパス中の文書集合 $\mathcal{D}$ において、トークン $t$ のIDFは:

$$ \text{idf}(t) = -\log \frac{1}{|\mathcal{D}|} \sum_{d \in \mathcal{D}} \mathbf{1}[t \in d] $$

ここで $\mathbf{1}[t \in d]$ は文書 $d$ にトークン $t$ が含まれれば1、そうでなければ0です。多くの文書に出現するトークンほどIDFが低くなり、重みが小さくなります。

IDF重み付きのRecallは:

$$ R_{\text{BERT}}^{\text{idf}} = \frac{\sum_{i=1}^{m} \text{idf}(r_i) \cdot \max_{j} S_{ij}}{\sum_{i=1}^{m} \text{idf}(r_i)} $$

Precisionも同様にIDF重み付けできます。これにより、”the” のような機能語のマッチは重みが軽くなり、”quantum” や “algorithm” のような内容語のマッチが重視されます。

ここまでで3つのメトリクスの理論を一通り解説しました。では、これらをPythonで実装して、実際の計算過程を確認しましょう。

PythonによるBLEUのスクラッチ実装

まず、BLEUスコアをゼロから実装します。コードを通じて、Modified PrecisionとBrevity Penaltyの計算が具体的にどう動くかを確認します。

import math
from collections import Counter


def get_ngrams(tokens, n):
    """トークン列からn-gramのリストを生成する"""
    return [tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1)]


def modified_precision(candidate, references, n):
    """Modified Precisionを計算する"""
    # 生成文のn-gramカウント
    cand_ngrams = get_ngrams(candidate, n)
    cand_counts = Counter(cand_ngrams)

    if len(cand_ngrams) == 0:
        return 0.0

    # 各参照文のn-gramカウントの最大値でクリッピング
    max_ref_counts = Counter()
    for ref in references:
        ref_counts = Counter(get_ngrams(ref, n))
        for ngram, count in ref_counts.items():
            max_ref_counts[ngram] = max(max_ref_counts[ngram], count)

    # クリッピング: 生成文のカウントを参照文の最大カウントで上限
    clipped_total = 0
    for ngram, count in cand_counts.items():
        clipped_total += min(count, max_ref_counts.get(ngram, 0))

    total = sum(cand_counts.values())
    return clipped_total / total


def brevity_penalty(candidate, references):
    """Brevity Penaltyを計算する"""
    c = len(candidate)
    # 参照文の中で生成文に最も近い長さを選択
    ref_lens = [len(ref) for ref in references]
    r = min(ref_lens, key=lambda ref_len: (abs(ref_len - c), ref_len))

    if c > r:
        return 1.0
    elif c == 0:
        return 0.0
    else:
        return math.exp(1 - r / c)


def compute_bleu(candidate, references, max_n=4, weights=None):
    """BLEUスコアを計算する"""
    if weights is None:
        weights = [1.0 / max_n] * max_n

    # Modified Precisionを各nについて計算
    precisions = []
    for n in range(1, max_n + 1):
        p = modified_precision(candidate, references, n)
        precisions.append(p)

    # いずれかのprecisionが0なら、BLEUは0
    if any(p == 0 for p in precisions):
        return 0.0

    # 対数の重み付き平均 → 幾何平均
    log_avg = sum(w * math.log(p) for w, p in zip(weights, precisions))
    bp = brevity_penalty(candidate, references)

    return bp * math.exp(log_avg)


# --- 計算例 ---
reference1 = "the cat is on the mat".split()
reference2 = "there is a cat on the mat".split()
candidate = "the cat sat on the mat".split()

score = compute_bleu(candidate, [reference1, reference2], max_n=4)
print(f"候補文: {' '.join(candidate)}")
print(f"参照文1: {' '.join(reference1)}")
print(f"参照文2: {' '.join(reference2)}")
print(f"BLEU-4: {score:.4f}")
print()

# 各n-gramのModified Precisionも表示
for n in range(1, 5):
    p = modified_precision(candidate, [reference1, reference2], n)
    print(f"  p_{n} (Modified {n}-gram Precision): {p:.4f}")

bp = brevity_penalty(candidate, [reference1, reference2])
print(f"  Brevity Penalty: {bp:.4f}")

このコードを実行すると、”the cat sat on the mat” に対するBLEUスコアと各nのModified Precisionが得られます。unigram精度 $p_1$ は比較的高い値になりますが、”sat” は参照文のどちらにも含まれないため完全一致にはなりません。$n$ が大きくなるにつれて精度は下がる傾向にあります。これは、4単語の連続パターンが正確に一致する確率がunigramに比べて低いためです。Brevity Penaltyについては、生成文と参照文1の長さが同じ(6単語)なので、$\text{BP} = 1.0$ となりペナルティは発生しません。

次に、BLEUの限界を示す例も確認しましょう。

# BLEUの限界を示す例
print("=== BLEUの限界: 同義語 ===")
ref = "the cat quickly ran across the field".split()
cand_synonym = "the cat rapidly sprinted over the meadow".split()
cand_exact = "the cat quickly ran across the field".split()

score_synonym = compute_bleu(cand_synonym, [ref], max_n=4)
score_exact = compute_bleu(cand_exact, [ref], max_n=4)

print(f"参照文:     {' '.join(ref)}")
print(f"同義語文:   {' '.join(cand_synonym)} → BLEU: {score_synonym:.4f}")
print(f"完全一致文: {' '.join(cand_exact)} → BLEU: {score_exact:.4f}")
print()

print("=== BLEUの限界: 繰り返し ===")
ref2 = "the cat is on the mat".split()
cand_repeat = "the the the the the the".split()
cand_good = "the cat is on the mat".split()

score_repeat = compute_bleu(cand_repeat, [ref2], max_n=1, weights=[1.0])
score_good = compute_bleu(cand_good, [ref2], max_n=1, weights=[1.0])

print(f"参照文:   {' '.join(ref2)}")
print(f"繰り返し: {' '.join(cand_repeat)} → BLEU-1: {score_repeat:.4f}")
print(f"正解文:   {' '.join(cand_good)} → BLEU-1: {score_good:.4f}")

同義語の例では、”quickly” を “rapidly” に、”ran” を “sprinted” に、”across” を “over” に、”field” を “meadow” に置き換えた文は意味的にほぼ同じですが、BLEUスコアは大幅に低くなります。n-gramベースの限界が明確に表れます。繰り返しの例では、Modified Precisionのクリッピングにより、”the” の繰り返しが適切に抑制されていることが確認できます。

BLEUの実装が完成したので、次はROUGEを実装しましょう。

PythonによるROUGEのスクラッチ実装

ROUGE-N(n-gramベース)とROUGE-L(LCSベース)を実装します。

from collections import Counter


def rouge_n(candidate, reference, n):
    """ROUGE-N のRecall, Precision, F1を計算する"""
    cand_ngrams = get_ngrams(candidate, n)
    ref_ngrams = get_ngrams(reference, n)

    cand_counts = Counter(cand_ngrams)
    ref_counts = Counter(ref_ngrams)

    # 共通n-gramのカウント(各n-gramの最小出現数を合計)
    overlap = 0
    for ngram in ref_counts:
        overlap += min(ref_counts[ngram], cand_counts.get(ngram, 0))

    # 分母が0の場合の処理
    total_ref = sum(ref_counts.values())
    total_cand = sum(cand_counts.values())

    recall = overlap / total_ref if total_ref > 0 else 0.0
    precision = overlap / total_cand if total_cand > 0 else 0.0

    if precision + recall > 0:
        f1 = 2 * precision * recall / (precision + recall)
    else:
        f1 = 0.0

    return {"recall": recall, "precision": precision, "f1": f1}


def lcs_length(x, y):
    """最長共通部分列の長さを動的計画法で計算する"""
    m, n = len(x), len(y)
    # DPテーブル: L[i][j]はx[:i]とy[:j]のLCS長
    L = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if x[i - 1] == y[j - 1]:
                L[i][j] = L[i - 1][j - 1] + 1
            else:
                L[i][j] = max(L[i - 1][j], L[i][j - 1])

    return L[m][n]


def rouge_l(candidate, reference, beta=1.2):
    """ROUGE-L のRecall, Precision, F1を計算する"""
    lcs = lcs_length(reference, candidate)
    m = len(reference)
    n = len(candidate)

    recall = lcs / m if m > 0 else 0.0
    precision = lcs / n if n > 0 else 0.0

    if precision + recall > 0:
        f1 = ((1 + beta**2) * precision * recall) / \
             (recall + beta**2 * precision)
    else:
        f1 = 0.0

    return {"recall": recall, "precision": precision, "f1": f1}


# --- 計算例 ---
reference = "police killed the gunman".split()
candidate1 = "police kill the gunman".split()
candidate2 = "the gunman police killed".split()

print("=== ROUGE-1 ===")
for cand, label in [(candidate1, "候補1"), (candidate2, "候補2")]:
    result = rouge_n(cand, reference, n=1)
    print(f"{label}: {' '.join(cand)}")
    print(f"  Recall={result['recall']:.3f}, "
          f"Precision={result['precision']:.3f}, "
          f"F1={result['f1']:.3f}")

print("\n=== ROUGE-2 ===")
for cand, label in [(candidate1, "候補1"), (candidate2, "候補2")]:
    result = rouge_n(cand, reference, n=2)
    print(f"{label}: {' '.join(cand)}")
    print(f"  Recall={result['recall']:.3f}, "
          f"Precision={result['precision']:.3f}, "
          f"F1={result['f1']:.3f}")

print("\n=== ROUGE-L ===")
for cand, label in [(candidate1, "候補1"), (candidate2, "候補2")]:
    result = rouge_l(cand, reference)
    print(f"{label}: {' '.join(cand)}")
    print(f"  Recall={result['recall']:.3f}, "
          f"Precision={result['precision']:.3f}, "
          f"F1={result['f1']:.3f}")
    lcs = lcs_length(reference, cand)
    print(f"  LCS長={lcs}")

この実行結果を考察しましょう。候補1 “police kill the gunman” は参照文 “police killed the gunman” と1単語だけ異なります(”killed” vs “kill”)。ROUGE-1ではunigram3つが一致するため再現率は $3/4 = 0.75$ です。一方、候補2 “the gunman police killed” は全unigram が一致するためROUGE-1の再現率は $4/4 = 1.0$ ですが、語順が逆転しています。ROUGE-2(bigram)ではこの語順の違いが反映され、候補2のスコアは低くなります。ROUGE-Lでは、候補2のLCSは “the gunman” または “police killed” の長さ2となり、語順の崩れが部分的に捉えられます。

このように、ROUGE-1は単語の網羅性を、ROUGE-2は局所的な語順を、ROUGE-Lは大域的な語順を、それぞれ異なる粒度で評価しています。

では、いよいよ意味レベルの評価を行うBERTScoreの実装に進みましょう。

PythonによるBERTScoreの概念実装

BERTScoreの核心は「トークン埋め込みのコサイン類似度に基づく貪欲マッチング」です。ここでは、BERTScoreの計算過程を理解するための概念実装を示します。本来はBERTの埋め込みを使いますが、まずは仕組みの理解を優先し、簡易的な埋め込み(ランダムではなく事前定義した埋め込み)を用いて計算フローを確認します。その後、実際のBERTモデルを使った例も示します。

import numpy as np


def cosine_similarity_matrix(embeddings1, embeddings2):
    """2つの埋め込み行列間のコサイン類似度行列を計算する"""
    # 正規化
    norms1 = np.linalg.norm(embeddings1, axis=1, keepdims=True)
    norms2 = np.linalg.norm(embeddings2, axis=1, keepdims=True)
    normed1 = embeddings1 / (norms1 + 1e-8)
    normed2 = embeddings2 / (norms2 + 1e-8)

    # コサイン類似度行列: (m, d) @ (d, n) = (m, n)
    return normed1 @ normed2.T


def bert_score(ref_embeddings, cand_embeddings, idf_weights=None):
    """BERTScoreのPrecision, Recall, F1を計算する"""
    # コサイン類似度行列
    sim_matrix = cosine_similarity_matrix(ref_embeddings, cand_embeddings)
    m, n = sim_matrix.shape

    if idf_weights is not None:
        # IDF重み付きRecall
        ref_max = np.max(sim_matrix, axis=1)  # 各参照トークンの最大類似度
        recall = np.sum(idf_weights * ref_max) / np.sum(idf_weights)

        # IDF重み付きPrecision(生成文側のIDFは省略、均等重み)
        cand_max = np.max(sim_matrix, axis=0)
        precision = np.mean(cand_max)
    else:
        # Recall: 参照文の各トークンに対して最大類似度を取り、平均
        recall = np.mean(np.max(sim_matrix, axis=1))

        # Precision: 生成文の各トークンに対して最大類似度を取り、平均
        precision = np.mean(np.max(sim_matrix, axis=0))

    if precision + recall > 0:
        f1 = 2 * precision * recall / (precision + recall)
    else:
        f1 = 0.0

    return {
        "precision": float(precision),
        "recall": float(recall),
        "f1": float(f1),
        "similarity_matrix": sim_matrix
    }


# --- 概念的デモ: 手動の埋め込みで仕組みを確認 ---
# 意味の近い単語は類似したベクトルになるよう手動で設定
np.random.seed(42)
d = 64  # 埋め込み次元

# 基底ベクトルを作成(意味カテゴリごと)
animal_base = np.random.randn(d)
action_base = np.random.randn(d)
location_base = np.random.randn(d)
article_base = np.random.randn(d)

# 意味の近い単語は基底にノイズを加えて生成
word_embeddings = {
    "the": article_base + 0.1 * np.random.randn(d),
    "a": article_base + 0.1 * np.random.randn(d),
    "cat": animal_base + 0.1 * np.random.randn(d),
    "dog": animal_base + 0.15 * np.random.randn(d),
    "feline": animal_base + 0.12 * np.random.randn(d),
    "sat": action_base + 0.1 * np.random.randn(d),
    "sits": action_base + 0.12 * np.random.randn(d),
    "rested": action_base + 0.15 * np.random.randn(d),
    "on": location_base + 0.1 * np.random.randn(d),
    "upon": location_base + 0.12 * np.random.randn(d),
    "mat": location_base + 0.3 * np.random.randn(d),
    "rug": location_base + 0.32 * np.random.randn(d),
}

ref_tokens = ["the", "cat", "sat", "on", "the", "mat"]
cand1_tokens = ["the", "cat", "sat", "on", "the", "mat"]  # 完全一致
cand2_tokens = ["a", "feline", "rested", "upon", "a", "rug"]  # 同義語

ref_emb = np.array([word_embeddings[t] for t in ref_tokens])
cand1_emb = np.array([word_embeddings[t] for t in cand1_tokens])
cand2_emb = np.array([word_embeddings[t] for t in cand2_tokens])

result1 = bert_score(ref_emb, cand1_emb)
result2 = bert_score(ref_emb, cand2_emb)

print("=== BERTScore概念デモ ===")
print(f"参照文:   {' '.join(ref_tokens)}")
print(f"候補1(完全一致): {' '.join(cand1_tokens)}")
print(f"  P={result1['precision']:.4f}, "
      f"R={result1['recall']:.4f}, "
      f"F1={result1['f1']:.4f}")
print(f"候補2(同義語):   {' '.join(cand2_tokens)}")
print(f"  P={result2['precision']:.4f}, "
      f"R={result2['recall']:.4f}, "
      f"F1={result2['f1']:.4f}")

このデモでは、意味の近い単語(”cat” と “feline”、”sat” と “rested” など)に類似したベクトルを手動で割り当てています。結果として、候補2(同義語を使った文)でもBERTScoreのF1は比較的高い値になります。BLEUやROUGEでは、候補2はunigram一致が全くないためスコアが0に近くなりますが、BERTScoreは意味的な近さを捉えられるのです。

次に、類似度行列を可視化してみましょう。

import matplotlib.pyplot as plt


def plot_similarity_matrix(sim_matrix, ref_tokens, cand_tokens, title):
    """類似度行列をヒートマップで可視化する"""
    fig, ax = plt.subplots(figsize=(8, 6))
    im = ax.imshow(sim_matrix, cmap="YlOrRd", vmin=0, vmax=1)

    ax.set_xticks(range(len(cand_tokens)))
    ax.set_yticks(range(len(ref_tokens)))
    ax.set_xticklabels(cand_tokens, fontsize=11)
    ax.set_yticklabels(ref_tokens, fontsize=11)
    ax.set_xlabel("Candidate tokens", fontsize=12)
    ax.set_ylabel("Reference tokens", fontsize=12)
    ax.set_title(title, fontsize=13)

    # 各セルに数値を表示
    for i in range(len(ref_tokens)):
        for j in range(len(cand_tokens)):
            color = "white" if sim_matrix[i, j] > 0.7 else "black"
            ax.text(j, i, f"{sim_matrix[i, j]:.2f}",
                    ha="center", va="center", color=color, fontsize=9)

    fig.colorbar(im, ax=ax, label="Cosine Similarity")
    plt.tight_layout()
    plt.show()


# 完全一致の場合
plot_similarity_matrix(
    result1["similarity_matrix"],
    ref_tokens, cand1_tokens,
    "Similarity Matrix: Exact Match"
)

# 同義語の場合
plot_similarity_matrix(
    result2["similarity_matrix"],
    ref_tokens, cand2_tokens,
    "Similarity Matrix: Synonyms"
)

完全一致の場合のヒートマップでは、対角線上に高い類似度(1.0に近い値)が並びます。同じ位置のトークンが同一なので当然です。一方、同義語の場合は、対角線上ではなく、意味的に対応するトークンペア(”cat” – “feline”、”sat” – “rested” など)の位置に高い類似度が現れます。BERTScoreの貪欲マッチングはこれらの最大値を拾い上げるため、語順が変わっても意味的な対応関係が正しく捉えられます。

ここまでで3つのメトリクスを個別に実装しました。最後に、同じ文ペアに対して3つのメトリクスを同時に計算し、それぞれの特性の違いを比較実験で確認しましょう。

3メトリクスの比較実験

ここでは、意図的に異なる性質を持つ候補文を用意し、3つのメトリクスがどのように振る舞うかを体系的に比較します。

import numpy as np
from collections import Counter
import matplotlib.pyplot as plt


def compare_metrics(reference, candidate, word_embeddings, label=""):
    """3メトリクスを計算して結果を返す"""
    ref_tokens = reference.split()
    cand_tokens = candidate.split()

    # BLEU(max_n=2に制限: 短い文ではn=4だと0になりやすい)
    bleu = compute_bleu(cand_tokens, [ref_tokens], max_n=2,
                        weights=[0.5, 0.5])

    # ROUGE-1, ROUGE-2, ROUGE-L
    r1 = rouge_n(cand_tokens, ref_tokens, n=1)
    r2 = rouge_n(cand_tokens, ref_tokens, n=2)
    rl = rouge_l(cand_tokens, ref_tokens)

    # BERTScore(手動埋め込み)
    ref_emb = np.array([word_embeddings.get(
        t, np.random.randn(64)) for t in ref_tokens])
    cand_emb = np.array([word_embeddings.get(
        t, np.random.randn(64)) for t in cand_tokens])
    bs = bert_score(ref_emb, cand_emb)

    return {
        "label": label,
        "BLEU-2": bleu,
        "ROUGE-1 F1": r1["f1"],
        "ROUGE-2 F1": r2["f1"],
        "ROUGE-L F1": rl["f1"],
        "BERTScore F1": bs["f1"]
    }


# 比較用の単語埋め込み辞書(前のセクションで定義済み)
np.random.seed(42)
d = 64

# 意味カテゴリの基底ベクトル
bases = {k: np.random.randn(d) for k in [
    "article", "animal", "action_sit", "action_run",
    "prep", "surface", "speed", "field"
]}

emb = {
    "the": bases["article"] + 0.1 * np.random.randn(d),
    "a": bases["article"] + 0.1 * np.random.randn(d),
    "cat": bases["animal"] + 0.1 * np.random.randn(d),
    "feline": bases["animal"] + 0.12 * np.random.randn(d),
    "dog": bases["animal"] + 0.2 * np.random.randn(d),
    "sat": bases["action_sit"] + 0.1 * np.random.randn(d),
    "sits": bases["action_sit"] + 0.12 * np.random.randn(d),
    "rested": bases["action_sit"] + 0.15 * np.random.randn(d),
    "ran": bases["action_run"] + 0.1 * np.random.randn(d),
    "sprinted": bases["action_run"] + 0.12 * np.random.randn(d),
    "on": bases["prep"] + 0.1 * np.random.randn(d),
    "upon": bases["prep"] + 0.12 * np.random.randn(d),
    "across": bases["prep"] + 0.2 * np.random.randn(d),
    "over": bases["prep"] + 0.18 * np.random.randn(d),
    "mat": bases["surface"] + 0.1 * np.random.randn(d),
    "rug": bases["surface"] + 0.15 * np.random.randn(d),
    "quickly": bases["speed"] + 0.1 * np.random.randn(d),
    "rapidly": bases["speed"] + 0.12 * np.random.randn(d),
    "field": bases["field"] + 0.1 * np.random.randn(d),
    "meadow": bases["field"] + 0.15 * np.random.randn(d),
}

# テストケースの定義
reference = "the cat sat on the mat"
test_cases = [
    ("the cat sat on the mat", "完全一致"),
    ("a feline rested upon a rug", "同義語置換"),
    ("the mat on sat cat the", "語順反転"),
    ("the cat", "情報欠落(短文)"),
    ("the cat sat on the mat and then the dog also sat", "冗長"),
    ("the dog ran across the field", "意味が異なる"),
]

# 全ケースを計算
results = []
for cand, label in test_cases:
    r = compare_metrics(reference, cand, emb, label)
    results.append(r)

# 結果テーブルの表示
header = f"{'ケース':<18} {'BLEU-2':>8} {'R1-F1':>8} {'R2-F1':>8} {'RL-F1':>8} {'BS-F1':>8}"
print(f"参照文: {reference}\n")
print(header)
print("-" * len(header))
for r in results:
    print(f"{r['label']:<18} "
          f"{r['BLEU-2']:>8.4f} "
          f"{r['ROUGE-1 F1']:>8.4f} "
          f"{r['ROUGE-2 F1']:>8.4f} "
          f"{r['ROUGE-L F1']:>8.4f} "
          f"{r['BERTScore F1']:>8.4f}")

この比較実験から、いくつかの重要な知見が得られます。

完全一致のケース: 全メトリクスが最高スコア(またはそれに近い値)を示します。これは期待どおりです。

同義語置換のケース: ここで3つのメトリクスの性格の違いが最も顕著に現れます。BLEU-2とROUGE系はn-gramが一つも一致しないため、スコアが0か非常に低い値になります。一方、BERTScoreは意味的類似度を捉えるため、比較的高いスコアを維持します。この差は「表層 vs 意味」の評価の違いを端的に示しています。

語順反転のケース: ROUGE-1(unigram)は全単語が参照文に含まれるため高いスコアを示しますが、ROUGE-2(bigram)やBLEUは語順を部分的に捉えるため低くなります。BERTScoreは位置に依存しない貪欲マッチングのため、依然として高いスコアを示します。

情報欠落のケース: “the cat” だけでは参照文の情報の大部分が失われています。ROUGE系の再現率が低くなり、F1も低下します。BLEUではBrevity Penaltyが効きます。

冗長なケース: 余分な情報が追加されていますが、参照文の内容は全て含まれています。ROUGE系の再現率は高いまま、精度が下がるため、F1が微妙に低下します。

最後に、結果をレーダーチャートで可視化します。

import numpy as np
import matplotlib.pyplot as plt


def plot_radar_comparison(results, metrics_keys, title):
    """複数ケースのメトリクスをレーダーチャートで比較する"""
    n_metrics = len(metrics_keys)
    angles = np.linspace(0, 2 * np.pi, n_metrics, endpoint=False).tolist()
    angles += angles[:1]  # 閉じるために先頭を末尾に追加

    fig, axes = plt.subplots(2, 3, figsize=(15, 10),
                              subplot_kw=dict(polar=True))
    axes = axes.flatten()
    colors = ["#2ecc71", "#3498db", "#e74c3c",
              "#f39c12", "#9b59b6", "#1abc9c"]

    for idx, (r, ax) in enumerate(zip(results, axes)):
        values = [r[k] for k in metrics_keys]
        values += values[:1]

        ax.fill(angles, values, alpha=0.25, color=colors[idx])
        ax.plot(angles, values, "o-", linewidth=2, color=colors[idx])
        ax.set_xticks(angles[:-1])
        ax.set_xticklabels(
            ["BLEU-2", "R1-F1", "R2-F1", "RL-F1", "BS-F1"],
            fontsize=9
        )
        ax.set_ylim(0, 1.05)
        ax.set_title(r["label"], fontsize=11, pad=15)

    plt.suptitle(title, fontsize=14, y=1.02)
    plt.tight_layout()
    plt.show()


metrics_keys = [
    "BLEU-2", "ROUGE-1 F1", "ROUGE-2 F1",
    "ROUGE-L F1", "BERTScore F1"
]
plot_radar_comparison(
    results, metrics_keys,
    "Comparison of Evaluation Metrics Across Different Scenarios"
)

レーダーチャートを見ると、各ケースにおけるメトリクスのプロファイルが一目で比較できます。完全一致では全方向に大きく広がった五角形になり、同義語置換ではBERTScoreの方向だけが突出します。語順反転ではROUGE-1とBERTScoreが高い一方でBLEU-2とROUGE-2が潰れ、情報欠落では全体的に小さな形になります。このように、単一のメトリクスではなく複数のメトリクスを併用することで、生成文の品質を多角的に評価できることがわかります。

メトリクスの使い分けガイド

ここまでの理論と実験を踏まえて、実務でのメトリクスの使い分けを整理しましょう。

タスク別の推奨メトリクス

機械翻訳: BLEUが伝統的に標準です。複数の参照翻訳がある場合に最も安定して動作します。ただし近年は、BERTScoreやCOMET(学習ベースのメトリクス)を併用する傾向が強まっています。

テキスト要約: ROUGE-1(内容の網羅性)、ROUGE-2(表現の忠実度)、ROUGE-L(構造的な一致)の3つを報告するのが標準です。抽出型要約ではROUGE系の値が高くなりやすく、生成型要約ではBERTScoreの併用が有効です。

対話システム: 応答の多様性が大きいため、n-gramベースのメトリクスは適さないことが多いです。BERTScoreのような意味ベースの指標が相対的に有効ですが、対話品質の自動評価はまだ発展途上の分野です。

画像キャプショニング: BLEU-4、ROUGE-L、CIDEr、SPICEなどを組み合わせて報告するのが一般的です。

共通の注意点

どのメトリクスを使う場合でも、以下の点に注意が必要です。

コーパスレベルで評価する: 単一文ではなく、テストセット全体での集約スコアを用います。特にBLEUは文レベルでは不安定です。

複数メトリクスを報告する: 各メトリクスは異なる側面を測定しているため、一つだけで品質を判断するのは危険です。最低でも2〜3個のメトリクスを報告しましょう。

人間評価との組み合わせ: 最終的なモデルの品質判定には、自動メトリクスだけでなく人間による評価も必要です。自動メトリクスは開発サイクル中のモデル比較に使い、最終評価は人間が行うという二段構えが理想です。

スコアの絶対値に囚われない: BLEUスコア0.3が「良い」か「悪い」かは、タスクやデータセットによって異なります。重要なのは、モデル間の相対的な比較です。

まとめ

本記事では、テキスト生成の自動評価メトリクスとして、BLEU、ROUGE、BERTScoreの3つを解説しました。

  • BLEU はn-gramのModified Precision(クリッピングされた精度)とBrevity Penaltyに基づく指標で、機械翻訳の標準的な評価に使われてきました。高速で再現性が高い一方、同義語や言い換えに対応できないという本質的な限界があります
  • ROUGE はn-gramの再現率とLCS(最長共通部分列)に基づく指標ファミリーで、テキスト要約の評価で標準的に使用されます。ROUGE-1、ROUGE-2、ROUGE-Lの3つを併用することで、内容の網羅性から構造的一致まで多角的に評価できます
  • BERTScore は事前学習済みTransformerの埋め込みに基づく意味的類似度指標で、コサイン類似度と貪欲マッチングにより、表層的な一致に頼らない柔軟な評価を実現します。同義語や言い換えを含む文に対してもロバストです

3つのメトリクスは相補的であり、単一の指標で品質を完全に捉えることはできません。実務では複数のメトリクスを組み合わせ、さらに人間評価を併用することが推奨されます。

次のステップとして、以下の記事も参考にしてください。

画像なし
言語モデルとパープレキシティを理解する
テキスト生成モデルのもう一つの評価指標であるパープレキシティについて解説しています。
画像なし
埋め込みベクトルと類似度検索の理論とPython実装
BERTScoreの基盤であるコサイン類似度と埋め込みベクトルの理論と実装を解説しています。
画像なし
文脈依存埋め込みの理論
BERTScoreが利用する文脈依存トークン埋め込みの仕組みを深掘りします。