前回に続いて、古典的な言語モデルについてpythonで実装して比較していきます。
add-one や ELE は下の式をベースに、対象の単語の ngram と (n-1)gramの 頻度 を使って確率を求めていた。
今回は頻度 r に注目した手法です。
未出現の単語に対して頻度を振り分けるため、学習データ内での出現回数 r を調整する方法がある。
ここで、同じ出現回数であれば出現確率も同じとみなします。例えば、dog
も pencil
も互いに5回出現していたら、どちらも同じ確率を割り当てる。
前回と同様、詳細なコードは下記gistに記載しています。
torchtext_pentreebank_gt.ipynb
ヘルドアウト推定
ヘルドアウト推定は学習データとは別のヘルドアウトデータを使って確率推定値を求める。 ヘルドアウトデータにより、学習データに未出現のデータに対しても確率を割り当てることができる。
とする。
今回は PenTreebankのデータを使ってみる。
ヘルドアウトデータは学習データとは別のデータであれば良いので、train を半分に分割して 訓練データとヘルドアウトデータとする。
tmp = [data.strip() for data in torchtext.datasets.PennTreebank(split='train')] train_data = tmp[:len(tmp)//2] heldout_data = tmp[len(tmp)//2:] len(train_data), len(heldout_data) >> (21034, 21034)
PennTreebank はスペースで区切ってあり、ピリオドの削除など前処理済みデータのようなので tokenizerはシンプルな split
を利用した。
train_data でvocabクラスを作って ヘルドアウトデータの頻度もカウントする。
tokenizer = get_tokenizer(None) def get_vocab(data_iter): vocab = build_vocab_from_iterator(map(tokenizer, data_iter), specials=['<unk>']) # 未知語は全部 `<unk>` 扱いにする vocab.set_default_index(vocab['<unk>']) return vocab # ngramの頻度辞書を作る関数 get_ngram_count は省略。gist参照。 # 訓練データにおける頻度 c1 c1_vocab = get_vocab(train_data) c1_bigram = get_ngram_count(train_data, c1_vocab, 2) print(len(c1_bigram)) >> 157629 # ヘルドアウトデータにおける頻度 c2 c2_bigram = get_ngram_count(heldout_data, c1_vocab, 2) print(len(c2_bigram)) >> 150070
頻度を計算したところで、実際にbigramでNr と Tr を計算する。
# 頻度 r の n-gramのタイプ数 Nr nr_types = defaultdict(int) for freq in c1_bigram.values(): nr_types[freq] += 1 # r=0 は存在しないので vocab_sizeと定義する nr_types[0] = len(c1_vocab) * len(c1_vocab)
ヘルドアウトデータについて、学習データでの出現回数を取得する。 よって Tr[0] には 「ヘルドアウトデータで1回以上出現しているが学習データで出現していないデータの総出現回数」が入る。
# 訓練テキスト中のn-gramの頻度がr回のn-gramがヘルドアウトデータで出現した数 Tr tr_num = defaultdict(int) t_sum = 0 for ngram, r in c2_bigram.items(): c1_r = c1_bigram.get(ngram, 0) tr_num[c1_r] += r t_sum += r print(t_sum)
Tr と Nr から学習データで r 回出現するngramについて、ヘルドアウトデータで合計 Tr 回出現することがわかる。 よって、学習データでr回出現する任意のngramは Tr/Nr 回出現するとみなせる。
つまりsmoothingした頻度を として推定できる。
下の表のように、r* は実際のrより小さくなる。
r | Nr | Tr | Tr / Nr (r*) | log2(Tr/ Nr T) |
---|---|---|---|---|
0 | 93334921 | 114926 | 0.001231 | 28.343 |
1 | 111266 | 38323 | 0.344427 | 20.215 |
2 | 20831 | 23052 | 1.106620 | 18.531 |
3 | 8446 | 16426 | 1.944826 | 17.718 |
4 | 4404 | 12254 | 2.782470 | 17.201 |
5 | 2769 | 10315 | 3.725172 | 16.780 |
6 | 2010 | 9390 | 4.671642 | 16.453 |
7 | 1304 | 7274 | 5.578221 | 16.197 |
8 | 911 | 5967 | 6.549945 | 15.966 |
9 | 710 | 5315 | 7.485915 | 15.773 |
確率推定値を求める。
ただし、
- の学習データでの頻度が r
- T は T_r の総和
つまり、smoothingした頻度をヘルドアウトデータの数で割った値。
この方法で確率を算出してみる。
sample_text = "this wikipedia is written in english" tokens = text2index(sample_text, c1_vocab) for t in iter_ngram(tokens, 2): r = c1_bigram.get(tuple(t),0) nr = nr_types[r] tr = tr_num.get(r, 0) print(t, [vocab_itos[i] for i in t], r, -math.log2(tr/(nr*t_sum))) >> [36, 0] ['this', '<unk>'] 51 13.057139984969156 [0, 11] ['<unk>', 'is'] 212 10.867692956269513 [11, 1932] ['is', 'written'] 0 28.342761082872915 [1932, 6] ['written', 'in'] 4 17.200826767745713 [6, 2404] ['in', 'english'] 5 16.779886287751758
頻度0回の ['is', 'written']
にも確率が割り当てられている。
交差検証 (削除推定)
大きい学習コーパスにおいて、ヘルドアウト推定では未知語 (r=0) に割り当てられる確率が大きくなってしまう問題がある。
解決方法として、いわゆる交差検証 (cross-validation)もしくは削除推定 (deleted estimate)を使ったヘルドアウト推定が考えられる。
データを2つに分割し、aとbとする。データaとデータbにおいて、それぞれ値を求める。
- 学習データをa、ヘルドアウトデータをbとしたとき
- aの頻度のタイプ数
- aの頻度のタイプ数を使ったbの出現回数
- 学習データをa、ヘルドアウトデータをbとしたとき
- bの頻度のタイプ数
- bの頻度のタイプ数を使ったaの出現回数
確率推定値は分割して推定した値を足して求める。
参考: https://www.cl.uni-heidelberg.de/courses/ss15/smt/scribe5.pdf の 1.4.2
Good-Turing推定
Good-Turing推定 も頻度 r を補正する手法。
Good-Turing推定自体は (NLP関係なく) 未知のデータの出現確率を求める手法。これを単語の頻度に適用することで、頻度を補正する。
- Nr : 頻度rのサンプル数 (タイプ数)
- N : データのサンプル数
とした時、補正した r* は
であり、確率推定値は以下のように定義できる。
この式の詳しい導出過程は以下を参考に。
- Lecture 11: The Good–Turing Estimate. CS 6740, Cornell University, 2010 (pdf)
- 情報学府 兼 担) 中川裕志 N-gramモデル - 東京大学 (pdf)
- https://www.cl.uni-heidelberg.de/courses/ss15/smt/scribe5.pdf の 1.4.3
引き続きPennTreebankを使って bigramの r* を導出する。
# train で vocab作成 train_iter = torchtext.datasets.PennTreebank(split='train') train_vocab = get_vocab(train_iter) train_iter = torchtext.datasets.PennTreebank(split='train') gt_unigram = get_ngram_count(train_iter, train_vocab) # good-turing用の Nr gt_nr = defaultdict(int) for r in gt_unigram.values(): gt_nr[r] += 1 # データサンプル数N gt_n_sum = sum([r*nr for r, nr in gt_nr.items()])
補正した r* を計算する。 r=0の時は N_0 が0になってしまうので とした。
print(f"{0}\t{1*gt_nr[1]/ gt_n_sum:.4f}") for i in range(1, 10): print(f"{i}\t{(i+1)*gt_nr[i+1]/gt_nr[i]:.4f}") >> 0 0.2050 1 0.4020 2 1.2747 3 2.1785 4 3.1855 5 4.0148 6 5.3132 7 5.6534 8 7.1004 9 8.5488
0以外はrよりも小さくなっていることがわかる。
これでヘルドアウト推定と同様に未知語に対応でき、確率推定値まで算出できそうな気がする。
しかし、この方法には問題がある。 r が非常に大きい時に Nr もしくは Nr+1 が 0 になってしまう点。
下の実行だと r = 125 の時点で Nr は 0になっている。
print("r, N_r, N_r+1") for r in range(1, 300): if gt_nr.get(r) is not None: continue else: print(f"{r}, {gt_nr.get(r)}, {gt_nr.get(r+1)}") >> r, N_r, N_r+1 125, None, 4 136, None, 2 163, None, 5 171, None, 3 185, None, 2 189, None, 2 200, None, 1 208, None, None 209, None, 4 212, None, 1 (省略)
そのため、実際に使う場合は更なる工夫が必要。
単純グッド・チューリング推定法 (Simple Good-Turing Estimation) とは何ぞや? - あらびき日記 で紹介されている simple good turing や Katz's back-off model - Wikipedia がそれにあたる。