メインコンテンツへスキップ
見出し画像

書記が数学やるだけ#949 n-グラムモデル

    言語モデルの一種であるn-グラムモデルについて。


    問題

    画像


    画像


    説明

    n-グラムモデルとは,マルコフ過程に基づいて単語列をモデル化したものであり,単語列内の次の単語の確率が先行する単語の固定サイズのウィンドウにのみ依存すると仮定することで単純化する。

    画像


    解答

    最尤推定(MLE)では、純粋にコーパス内のカウント(出現回数)のみから条件付き確率を計算する。 しかし、この手法ではデータに存在しない「一度も見たことがない組み合わせ」の確率が 0% となってしまう(ゼロ頻度問題)。これを解消するために加算平滑化を適用し、出現カウントに一律で 1 を足すことで確率の穴埋めを行う。

    画像


    ベイズの定理より、事後分布は「事前分布 × 尤度関数」に比例する。ここに与えられた多項分布とディリクレ分布の数式を代入しまとめることで,事後分布は各カテゴリのカウントがそのままハイパーパラメータに加算されたディリクレ分布となる。

    画像


    一様なハイパーパラメータ(αi =δ)を設定した場合,これまでの実装で計算してきたディリクレ平滑化の数式と完全に一致する。

    画像


    実装例を以下に示す。

    ① 本編の正確な切り出し(ノイズ除去)

     物語冒頭の不変の一文(alice was beginning...)を狙い撃ちして検索し、目次ページ(CONTENTS)をスキップ。末尾の Project Gutenberg の膨大な利用規約(法律文)を自動で排除する。

    ② 高度なクレンジングとトークン化(Tokenization)

     テキストを完全に小文字化して表記揺れを統一。正規表現を用い、単語内のアポストロフィ(alice's や don't などの意味の塊)は破壊せずに維持し、セリフを囲む引用符(' や ")や各種記号(,, ., !)だけを単語から独立した1トークンとして分離する。

    ③ 階層ベイズ(線形補間)トライグラムモデル

     次に続く単語の予測確率を、「3-gram(直前2語の文脈)」「2-gram(直前1語の文脈)」「1-gram(単語単体の出現頻度)」の3つの確率に分解し、それぞれの信頼度(重み:λ)を掛け合わせてブレンド(補間)する。

    ④ パープレキシティ(Perplexity)による評価

     生成された文章が、学習した言語モデルから見てどれくらい「予測しやすかった(自然か)」を幾何平均を用いて確率的に計算する。

    import urllib.request
    import re
    
    # ========================================================
    # 1. Project Gutenberg からデータのダウンロード
    # ========================================================
    url = "https://www.gutenberg.org/files/11/11-0.txt"
    req = urllib.request.Request(
        url, 
        headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
    )
    
    try:
        with urllib.request.urlopen(req) as response:
            raw_text = response.read().decode('utf-8')
        print("① データのダウンロードに成功しました!")
    except Exception as e:
        print(f"ダウンロードエラーが発生しました: {e}")
        raw_text = ""
    
    # ========================================================
    # 2. 本文の切り出し(本編冒頭テキスト狙い撃ち版)
    # ========================================================
    raw_text_lower = raw_text.lower()
    
    # 目次(Contents)ページのトラップを回避するため、物語の最初の1文をターゲットにする
    start_pattern = "alice was beginning to get very tired of sitting by her sister"
    end_pattern = "gutenberg"
    
    start_match = re.search(start_pattern, raw_text_lower)
    end_match = re.search(end_pattern, raw_text_lower)
    
    if start_match:
        start_idx = start_match.start()
        if end_match and end_match.start() > start_idx:
            end_idx = end_match.start()
            alice_full_text = raw_text[start_idx:end_idx]
            print("② 本文の切り出しに成功しました!(物語の本編のみを正確に抽出)")
        else:
            # セーフティ上限
            alice_full_text = raw_text[start_idx : start_idx + 150000]
            print("② 本文の開始を検出しました。末尾はセーフティ上限で切り出しました。")
    else:
        print("警告: 本文の開始位置が見つかりません。全体を対象にします。")
        alice_full_text = raw_text
    
    # ========================================================
    # 3. 高度なクレンジングとトークン化の関数定義
    # ========================================================
    def perfect_clean_and_tokenize(text_data):
        text = text_data.lower()
        
        # 3-1. 特殊な引用符(スマートクォーテーション等)を標準の半角に置換
        text = text.replace('“', '"').replace('”', '"')
        text = text.replace('‘', "'").replace('’', "'")
        text = text.replace('_', ' ')   # 装飾用のアンダースコアを消去
        text = text.replace('—', ' — ') # エムダッシュ(長い横棒)の前後にスペース
        
        # 3-2. セリフを囲む引用符(前後が空白のシングルクォーテーション)を分離
        # これにより don't や alice's などの「単語内のアポストロフィ」は維持される
        text = re.sub(r"'(?=\s)|(?<=\s)'", " ' ", text)
        text = re.sub(r'"', ' " ', text)
        
        # 3-3. その他の一般的な記号(ピリオド、カンマ、感嘆符など)を独立させる
        text = re.sub(r'([,.\?\:\;\!\(\)])', r' \1 ', text)
        
        # 3-4. 連続する空白を整理して単語ごとに分割
        tokens = text.split()
        return tokens
    
    # ========================================================
    # 4. 前処理の実行
    # ========================================================
    full_tokens = perfect_clean_and_tokenize(alice_full_text)
    
    print("-" * 60)
    print(f"③ 前処理・トークン化が完了しました!")
    print(f"   ・生成された総トークン数: {len(full_tokens)}")
    print(f"   ・洗練されたユニークな語彙数 (V): {len(set(full_tokens))}")
    print("-" * 60)
    ① データのダウンロードに成功しました!
    ② 本文の開始を検出しました。末尾はセーフティ上限で切り出しました。
    ------------------------------------------------------------
    ③ 前処理・トークン化が完了しました!
       ・生成された総トークン数: 33806
       ・洗練されたユニークな語彙数 (V): 2686
    ------------------------------------------------------------
    import random
    import math
    from collections import defaultdict
    
    class BayesianWordTriGramModel:
        def __init__(self, delta=0.01, lambdas=(0.6, 0.3, 0.1)):
            """
            delta: 各N-gramにおけるディリクレ平滑化の疑似カウント
            lambdas: (lambda1, lambda2, lambda3) 3-gram, 2-gram, 1-gram の重み(合計1)
            """
            self.delta = delta
            self.lambdas = lambdas
            
            # カウント用の辞書
            self.unigram_counts = defaultdict(int)
            self.bigram_counts = defaultdict(int)
            self.trigram_counts = defaultdict(int)
            
            # 文脈カウント用
            self.context_2gram = defaultdict(int) # C(w_i-1)
            self.context_3gram = defaultdict(int) # C(w_i-2, w_i-1)
            
            self.vocabulary = set()
            self.v_size = 0
            self.total_tokens = 0
    
        def fit(self, tokens):
            """1-gram, 2-gram, 3-gram の頻度を階層的に学習"""
            if len(tokens) < 3:
                print("エラー: トークン数が足りません。")
                return
    
            self.vocabulary = set(tokens)
            self.v_size = len(self.vocabulary) + 1  # +1 は未知語(<UNK>)用枠
            self.total_tokens = len(tokens)
    
            # 各種N-gramのカウント
            for w in tokens:
                self.unigram_counts[w] += 1
    
            for i in range(len(tokens) - 1):
                w1, w2 = tokens[i], tokens[i+1]
                self.bigram_counts[(w1, w2)] += 1
                self.context_2gram[w1] += 1
    
                if i < len(tokens) - 2:
                    w3 = tokens[i+2]
                    self.trigram_counts[(w1, w2, w3)] += 1
                    self.context_3gram[(w1, w2)] += 1
    
        def get_bayes_probability(self, w1, w2, w3):
            """3-gram, 2-gram, 1-gram の確率をベイズ的に補間して計算"""
            l1, l2, l3 = self.lambdas
    
            # 1. 3-gram 確率 P(w3 | w1, w2)
            c_tri_context = self.context_3gram.get((w1, w2), 0)
            c_trigram = self.trigram_counts.get((w1, w2, w3), 0)
            p_3gram = (c_trigram + self.delta) / (c_tri_context + self.v_size * self.delta)
    
            # 2. 2-gram 確率 P(w3 | w2)
            c_bi_context = self.context_2gram.get(w2, 0)
            c_bigram = self.bigram_counts.get((w2, w3), 0)
            p_2gram = (c_bigram + self.delta) / (c_bi_context + self.v_size * self.delta)
    
            # 3. 1-gram 確率 P(w3)
            c_unigram = self.unigram_counts.get(w3, 0)
            p_unigram = (c_unigram + self.delta) / (self.total_tokens + self.v_size * self.delta)
    
            # ベイズ補間
            return l1 * p_3gram + l2 * p_2gram + l3 * p_unigram
    
        def generate_next_word(self, w1, w2):
            """ベイズ補間確率に基づいて、次の単語をサンプリング"""
            words = list(self.vocabulary)
            probabilities = [self.get_bayes_probability(w1, w2, w) for w in words]
            return random.choices(words, weights=probabilities, k=1)[0]
    
        def generate_story(self, start_w1, start_w2, num_words=40):
            """文章生成"""
            w1, w2 = start_w1.lower(), start_w2.lower()
            generated_words = [w1, w2]
            
            for _ in range(num_words - 2):
                next_word = self.generate_next_word(w1, w2)
                generated_words.append(next_word)
                w1, w2 = w2, next_word
                
            return " ".join(generated_words)
    
        def evaluate_sentence_perplexity(self, sentence_tokens):
            """与えられた文章(トークンリスト)のパープレキシティ(Perplexity)を計算"""
            if len(sentence_tokens) < 3:
                return float('inf')
    
            log_prob_sum = 0.0
            n_trigrams = 0
    
            for i in range(len(sentence_tokens) - 2):
                w1, w2, w3 = sentence_tokens[i], sentence_tokens[i+1], sentence_tokens[i+2]
                prob = self.get_bayes_probability(w1, w2, w3)
                prob = max(prob, 1e-15)  # アンダーフロー防止のガード
                
                log_prob_sum += math.log2(prob)
                n_trigrams += 1
    
            avg_log_prob = log_prob_sum / n_trigrams
            return 2 ** (-avg_log_prob)
    
    # ==========================================
    # 5. モデルの訓練と実行・評価
    # ==========================================
    if 'full_tokens' in locals() and len(full_tokens) > 0:
        print("④ 階層ベイズ・トライグラムモデルを訓練中...")
        # delta=0.01, lambdas=(3-gramの重み, 2-gramの重み, 1-gramの重み)
        model_bayes = BayesianWordTriGramModel(delta=0.01, lambdas=(0.6, 0.3, 0.1))
        model_bayes.fit(full_tokens)
        print("⑤ 学習が完了しました!")
        print("-" * 60)
    
        # シード文脈から物語を紡ぐ
        seeds = [
            ("alice", "was"),
            ("the", "white"),
            ("down", "the")
        ]
    
        for w1, w2 in seeds:
            print(f"【ベイズモデル: '{w1} {w2}' からの生成結果】")
            generated_text = model_bayes.generate_story(start_w1=w1, start_w2=w2, num_words=40)
            print(f"  {generated_text}\n")
            
            # 生成された文章の評価(Perplexity)
            gen_tokens = generated_text.split()
            pp_score = model_bayes.evaluate_sentence_perplexity(gen_tokens)
            print(f"  [この生成文の Perplexity 評価スコア]: {pp_score:.2f}")
            print("-" * 60)
    else:
        print("エラー: 'full_tokens' が見つかりません。前処理コードを先に実行してください。")
    ④ 階層ベイズ・トライグラムモデルを訓練中...
    ⑤ 学習が完了しました!
    ------------------------------------------------------------
    【ベイズモデル: 'alice was' からの生成結果】
      alice was agree accident spades caterpillar scale king flame kind hall seals derision . " she bear whispered beau forgotten the little making grief easily pieces fury bark hate vi thick row burning beheaded removed me sing flamingo , fourth
    
      [この生成文の Perplexity 評価スコア]: 1606.40
    ------------------------------------------------------------
    【ベイズモデル: 'the white' からの生成結果】
      the white heavy ointment paint pencils quickly hurried drinking violent : gardeners , i've made lesson-book added neat whoever from further belt bent arm-chair earls powdered most ! as invent easy was trickling broke perhaps secondly is wandering pencil archbishop
    
      [この生成文の Perplexity 評価スコア]: 1971.50
    ------------------------------------------------------------
    【ベイズモデル: 'down the' からの生成結果】
      down the mock turtle yawned man hedge fat tell circumstances clever they've eaglet skimming whose remarking fly shriek table her arm could to pause to-night bear hearing shouted slowly that'll kick coast forepaws boy furrows managed nay put carry funny
    
      [この生成文の Perplexity 評価スコア]: 1756.12
    ------------------------------------------------------------


    本記事のもくじはこちら:


    あなたへのおすすめ