言語モデルとパープレキシティを理解する

言語モデル(Language Model)は、テキストの確率分布をモデル化する機械学習モデルです。GPT、BERT、LLaMAなど、現代の大規模言語モデル(LLM)の基盤となる概念です。

言語モデルの性能評価には、パープレキシティ(Perplexity)という指標が広く用いられています。本記事では、言語モデルの基礎からパープレキシティの数学的定義、そしてPythonでの計算方法まで解説します。

本記事の内容

  • 言語モデルの定義と役割
  • N-gramモデルからニューラル言語モデルへの発展
  • パープレキシティの数学的定義
  • パープレキシティの直感的理解
  • 交差エントロピーとの関係
  • Pythonでの計算と可視化

前提知識

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

言語モデルとは

定義

言語モデルは、トークン列(単語列)の確率分布 $P(w_1, w_2, \ldots, w_n)$ を推定するモデルです。

連鎖律(Chain Rule)により、この同時確率は条件付き確率の積に分解できます。

$$ P(w_1, w_2, \ldots, w_n) = \prod_{i=1}^{n} P(w_i \mid w_1, w_2, \ldots, w_{i-1}) $$

つまり、言語モデルは「これまでの単語列が与えられたとき、次の単語は何か」を予測するモデルと見なせます。

言語モデルの用途

1. 自然さの評価

文の確率を計算することで、その文が「自然か」を評価できます。

  • $P(\text{“今日は天気が良い”}) > P(\text{“今日は天気が良いる”})$

2. テキスト生成

条件付き確率 $P(w_t \mid w_1, \ldots, w_{t-1})$ からサンプリングすることで、文章を生成できます。

3. 特徴抽出

言語モデルの中間表現を、下流タスク(分類、翻訳など)の特徴量として使用できます。

N-gram言語モデル

概要

N-gram言語モデルは、直前の $N-1$ 個の単語のみを条件として次の単語の確率を推定します(マルコフ仮定)。

$$ P(w_i \mid w_1, \ldots, w_{i-1}) \approx P(w_i \mid w_{i-N+1}, \ldots, w_{i-1}) $$

例: 2-gram(Bigram)モデル

$$ P(w_i \mid w_1, \ldots, w_{i-1}) \approx P(w_i \mid w_{i-1}) $$

最尤推定

N-gram確率は、コーパスの頻度から推定されます。

$$ P(w_i \mid w_{i-N+1}, \ldots, w_{i-1}) = \frac{C(w_{i-N+1}, \ldots, w_i)}{C(w_{i-N+1}, \ldots, w_{i-1})} $$

ここで $C(\cdot)$ は出現回数(count)です。

例: “I love cats” のBigram確率

$$ P(\text{cats} \mid \text{love}) = \frac{C(\text{love cats})}{C(\text{love})} $$

限界

  • データスパースネス: 長いN-gramは出現頻度が低く、確率推定が不安定
  • 文脈の制限: 固定長の文脈しか考慮できない
  • 語彙外問題: 未知の単語や単語列に確率0が割り当てられる

ニューラル言語モデル

概要

ニューラル言語モデルは、ニューラルネットワークを用いて条件付き確率を推定します。

$$ P(w_t \mid w_1, \ldots, w_{t-1}) = f_\theta(\bm{h}_{t-1}) $$

ここで $\bm{h}_{t-1}$ は文脈の隠れ状態、$f_\theta$ は出力層(通常はsoftmax)です。

RNN言語モデル

RNN(LSTM, GRU含む)を用いた言語モデルでは、隠れ状態が逐次的に更新されます。

$$ \bm{h}_t = \text{RNN}(\bm{h}_{t-1}, \bm{e}_{w_t}) $$

$$ P(w_{t+1} \mid w_1, \ldots, w_t) = \text{softmax}(\bm{W}_o \bm{h}_t + \bm{b}_o) $$

Transformer言語モデル

Transformer(GPTなど)では、Self-Attentionにより全ての過去のトークンを同時に参照します。

$$ P(w_t \mid w_1, \ldots, w_{t-1}) = \text{softmax}\left(\bm{W}_o \cdot \text{TransformerDecoder}(\bm{e}_{w_1}, \ldots, \bm{e}_{w_{t-1}})\right) $$

因果マスクにより、未来のトークンへの注意は遮断されます。

パープレキシティ

定義

パープレキシティ(Perplexity, PPL)は、言語モデルの性能を測る指標です。テストコーパス $W = w_1, w_2, \ldots, w_N$ に対して、パープレキシティは以下で定義されます。

$$ \text{PPL}(W) = P(w_1, w_2, \ldots, w_N)^{-\frac{1}{N}} $$

連鎖律を用いて展開すると:

$$ \text{PPL}(W) = \left( \prod_{i=1}^{N} P(w_i \mid w_1, \ldots, w_{i-1}) \right)^{-\frac{1}{N}} $$

対数を取って計算すると:

$$ \log \text{PPL}(W) = -\frac{1}{N} \sum_{i=1}^{N} \log P(w_i \mid w_1, \ldots, w_{i-1}) $$

$$ \text{PPL}(W) = \exp\left( -\frac{1}{N} \sum_{i=1}^{N} \log P(w_i \mid w_1, \ldots, w_{i-1}) \right) $$

交差エントロピーとの関係

テストコーパスに対する平均交差エントロピー $H$ は:

$$ H(W) = -\frac{1}{N} \sum_{i=1}^{N} \log_2 P(w_i \mid w_1, \ldots, w_{i-1}) $$

パープレキシティは交差エントロピーの指数関数です:

$$ \text{PPL}(W) = 2^{H(W)} $$

または自然対数を用いた場合:

$$ \text{PPL}(W) = e^{H_e(W)} $$

ここで $H_e$ は自然対数ベースのエントロピーです。

パープレキシティの導出

パープレキシティがなぜこの形になるのか、情報理論の観点から導出しましょう。

真の言語分布を $p$、モデルの分布を $q$ とします。交差エントロピーは:

$$ H(p, q) = -\sum_x p(x) \log q(x) $$

テストコーパスのトークン列 $W = w_1, \ldots, w_N$ について、真の分布は経験分布(各トークンに $1/N$ の重み)と見なせます。このとき:

$$ H(p, q) \approx -\frac{1}{N} \sum_{i=1}^{N} \log q(w_i \mid w_1, \ldots, w_{i-1}) $$

この交差エントロピーを「1トークンあたり何ビットの情報を符号化するのに必要か」と解釈すると、$2^H$ は「モデルが平均的に迷っている選択肢の数」を表します。

直感的理解

パープレキシティは、モデルが次の単語を予測する際に「平均的に何個の選択肢で迷っているか」を表す指標です。

例:

  • PPL = 1: 次の単語を完璧に予測できる(確率1で正解)
  • PPL = 10: 平均して10個の選択肢で迷っている
  • PPL = 100: 平均して100個の選択肢で迷っている

数値例:

語彙サイズが10,000のモデルで: – PPL = 100 は、語彙のうち約1%に絞り込めていることを意味する – PPL = 10,000 は、ランダムに予測しているのと同等

低いパープレキシティ = 良いモデル

パープレキシティが低いほど、モデルはテストコーパスをより正確に予測できています。

均一分布の場合

語彙サイズ $|V|$ の均一分布では、各トークンの確率は $1/|V|$ です。

$$ \text{PPL} = \exp\left( -\frac{1}{N} \sum_{i=1}^{N} \log \frac{1}{|V|} \right) = \exp(\log |V|) = |V| $$

つまり、ランダムに予測するモデルのパープレキシティは語彙サイズに等しくなります。

Pythonでの実装

簡単な言語モデルとパープレキシティ計算

import numpy as np
from collections import defaultdict


class BigramLanguageModel:
    """Bigram言語モデル"""

    def __init__(self, smoothing=1e-5):
        self.bigram_counts = defaultdict(lambda: defaultdict(int))
        self.unigram_counts = defaultdict(int)
        self.vocab = set()
        self.smoothing = smoothing

    def train(self, corpus):
        """
        コーパスから学習

        Args:
            corpus: list of list of str (文のリスト、各文は単語リスト)
        """
        for sentence in corpus:
            # 文頭・文末記号を追加
            tokens = ['<s>'] + sentence + ['</s>']
            for i in range(len(tokens)):
                self.vocab.add(tokens[i])
                self.unigram_counts[tokens[i]] += 1
                if i > 0:
                    self.bigram_counts[tokens[i-1]][tokens[i]] += 1

    def get_probability(self, word, context):
        """
        P(word | context) を計算(加算スムージング付き)
        """
        numerator = self.bigram_counts[context][word] + self.smoothing
        denominator = self.unigram_counts[context] + self.smoothing * len(self.vocab)
        return numerator / denominator

    def sentence_log_probability(self, sentence):
        """
        文の対数確率を計算
        """
        tokens = ['<s>'] + sentence + ['</s>']
        log_prob = 0.0
        for i in range(1, len(tokens)):
            prob = self.get_probability(tokens[i], tokens[i-1])
            log_prob += np.log(prob)
        return log_prob

    def perplexity(self, test_corpus):
        """
        テストコーパスのパープレキシティを計算
        """
        total_log_prob = 0.0
        total_tokens = 0

        for sentence in test_corpus:
            tokens = ['<s>'] + sentence + ['</s>']
            total_tokens += len(tokens) - 1  # <s>を除く

            for i in range(1, len(tokens)):
                prob = self.get_probability(tokens[i], tokens[i-1])
                total_log_prob += np.log(prob)

        avg_log_prob = total_log_prob / total_tokens
        ppl = np.exp(-avg_log_prob)
        return ppl


# 使用例
train_corpus = [
    ['I', 'love', 'cats'],
    ['I', 'love', 'dogs'],
    ['cats', 'are', 'cute'],
    ['dogs', 'are', 'cute'],
    ['I', 'have', 'a', 'cat'],
    ['I', 'have', 'a', 'dog'],
]

test_corpus = [
    ['I', 'love', 'cats'],
    ['dogs', 'are', 'cute'],
]

model = BigramLanguageModel()
model.train(train_corpus)

# パープレキシティの計算
ppl = model.perplexity(test_corpus)
print(f"Test Perplexity: {ppl:.2f}")

# 個々の文の確率
for sentence in test_corpus:
    log_prob = model.sentence_log_probability(sentence)
    print(f"Log P('{' '.join(sentence)}') = {log_prob:.4f}")

ニューラル言語モデルのパープレキシティ計算

import torch
import torch.nn as nn
import torch.nn.functional as F


class SimpleLSTMLanguageModel(nn.Module):
    """簡単なLSTM言語モデル"""

    def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True)
        self.fc = nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, hidden=None):
        """
        Args:
            x: (batch_size, seq_len) - 入力トークンID
        Returns:
            logits: (batch_size, seq_len, vocab_size)
        """
        emb = self.embedding(x)  # (batch_size, seq_len, embed_dim)
        out, hidden = self.lstm(emb, hidden)  # (batch_size, seq_len, hidden_dim)
        logits = self.fc(out)  # (batch_size, seq_len, vocab_size)
        return logits, hidden


def compute_perplexity(model, data_loader, device):
    """
    言語モデルのパープレキシティを計算

    Args:
        model: 言語モデル
        data_loader: (input_ids, target_ids) のバッチを生成するDataLoader
        device: 計算デバイス
    """
    model.eval()
    total_loss = 0.0
    total_tokens = 0

    with torch.no_grad():
        for input_ids, target_ids in data_loader:
            input_ids = input_ids.to(device)
            target_ids = target_ids.to(device)

            logits, _ = model(input_ids)

            # Cross-entropy loss (reduction='sum')
            loss = F.cross_entropy(
                logits.view(-1, logits.size(-1)),
                target_ids.view(-1),
                reduction='sum',
                ignore_index=0  # パディングを無視
            )

            total_loss += loss.item()
            total_tokens += (target_ids != 0).sum().item()

    avg_loss = total_loss / total_tokens
    perplexity = np.exp(avg_loss)
    return perplexity


# モデル作成例
vocab_size = 1000
embed_dim = 128
hidden_dim = 256

model = SimpleLSTMLanguageModel(vocab_size, embed_dim, hidden_dim)
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")

パープレキシティの可視化

import matplotlib.pyplot as plt


def visualize_perplexity_vs_entropy():
    """パープレキシティとエントロピーの関係を可視化"""
    entropy = np.linspace(0, 10, 100)  # ビット
    perplexity = 2 ** entropy

    fig, ax = plt.subplots(figsize=(10, 6))
    ax.plot(entropy, perplexity, 'b-', linewidth=2)
    ax.set_xlabel('Cross-Entropy (bits)', fontsize=12)
    ax.set_ylabel('Perplexity', fontsize=12)
    ax.set_title('Relationship between Cross-Entropy and Perplexity', fontsize=14)
    ax.grid(True, alpha=0.3)
    ax.set_yscale('log')

    # 典型的な値をマーク
    typical_values = [(2, '4'), (4, '16'), (6, '64'), (8, '256')]
    for h, label in typical_values:
        ppl = 2 ** h
        ax.scatter([h], [ppl], color='red', s=50, zorder=5)
        ax.annotate(f'PPL={label}', (h, ppl), textcoords="offset points",
                   xytext=(10, 10), fontsize=10)

    plt.tight_layout()
    plt.savefig('perplexity_entropy.png', dpi=150, bbox_inches='tight')
    plt.show()


def visualize_prediction_confidence():
    """予測の確信度とパープレキシティの関係を可視化"""
    fig, axes = plt.subplots(1, 3, figsize=(15, 4))

    # 3つのシナリオ
    scenarios = [
        {'name': 'High Confidence\n(PPL ~ 1.5)', 'probs': [0.9, 0.05, 0.03, 0.02]},
        {'name': 'Medium Confidence\n(PPL ~ 5)', 'probs': [0.4, 0.3, 0.2, 0.1]},
        {'name': 'Low Confidence\n(PPL ~ 10)', 'probs': [0.25, 0.25, 0.25, 0.25]},
    ]

    for ax, scenario in zip(axes, scenarios):
        probs = scenario['probs']
        words = ['word_1', 'word_2', 'word_3', 'word_4']

        colors = plt.cm.Blues(np.array(probs) / max(probs))
        bars = ax.bar(words, probs, color=colors, edgecolor='black')
        ax.set_ylim(0, 1)
        ax.set_ylabel('Probability')
        ax.set_title(scenario['name'])

        # エントロピーを計算
        entropy = -sum(p * np.log2(p) for p in probs if p > 0)
        ppl = 2 ** entropy
        ax.text(0.5, 0.9, f'H = {entropy:.2f} bits\nPPL = {ppl:.2f}',
               transform=ax.transAxes, fontsize=10, ha='center',
               bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))

    plt.tight_layout()
    plt.savefig('prediction_confidence.png', dpi=150, bbox_inches='tight')
    plt.show()


# 可視化の実行
visualize_perplexity_vs_entropy()
visualize_prediction_confidence()

パープレキシティの注意点

語彙サイズへの依存

異なる語彙サイズを持つモデル間でパープレキシティを直接比較することはできません。語彙が大きいほど、予測が困難になりパープレキシティは上がる傾向があります。

トークナイゼーションへの依存

サブワードトークナイゼーションを使用する場合、同じテキストでもトークン数が異なります。パープレキシティはトークンあたりの値なので、トークナイゼーション方式が異なるモデル間での比較には注意が必要です。

単語あたりのパープレキシティへの正規化が必要な場合があります:

$$ \text{PPL}_{\text{word}} = \text{PPL}_{\text{token}}^{n_{\text{tokens}} / n_{\text{words}}} $$

Bits Per Character (BPC)

文字レベルでの比較のために、BPC(Bits Per Character)が使われることもあります:

$$ \text{BPC} = \frac{1}{N_{\text{char}}} \sum_{i=1}^{N} -\log_2 P(w_i \mid w_1, \ldots, w_{i-1}) $$

代表的なモデルのパープレキシティ

モデル データセット パープレキシティ
5-gram KN Penn Treebank ~141
LSTM Penn Treebank ~78
GPT-1 WikiText-103 ~37
GPT-2 (1.5B) WikiText-103 ~17
GPT-3 (175B) Penn Treebank ~20

注: 数値は参考値であり、評価設定により異なります。

まとめ

本記事では、言語モデルとパープレキシティについて解説しました。

  • 言語モデル: トークン列の確率分布をモデル化し、「次の単語は何か」を予測する
  • N-gramモデル: 直前のN-1トークンのみを条件とする古典的手法
  • ニューラル言語モデル: RNNやTransformerで長距離依存を捉える
  • パープレキシティ: 「モデルが平均的に何個の選択肢で迷っているか」を表す評価指標
  • 交差エントロピーとの関係: $\text{PPL} = 2^{H}$(Hは交差エントロピー)

パープレキシティは言語モデルの性能を測る標準的な指標ですが、語彙サイズやトークナイゼーションへの依存があるため、比較の際は条件を揃える必要があります。

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