エイエイレトリック

なぐりがき

ngram言語モデルについてまとめる (neural language model)

4記事にわたり、複数の古典的ngram言語モデルについて試しに実装してきました。

torchtextのデータセットを使ってきたので、pytorchで簡単な言語モデルを作ってみます。

元となる論文があるわけではないですが、ネット上に多数実装が多数あるので、それらを参考にしました。

実装コードは gist を参照してください。一番下に埋め込んでます。

参考資料について軽くまとめます。

アーキテクチャ

NgramModel(
  (embedding): Embedding(28782, 100)
  (linear1): Linear(in_features=200, out_features=128, bias=True)
  (linear2): Linear(in_features=128, out_features=28782, bias=True)
)
  • 入力単語を語彙数×次元数で表現する Embedding
  • 活性化関数 Relu
  • 線形変換1 Linear
  • 線形変換2 Linear
  • 確率値として出力 log_softmax

という比較的簡単な構造。

Embedding層はいわゆるword2vecとして単語特徴量を学習する。 Mikolovの論文 (https://arxiv.org/abs/1301.3781) におけるSkipgramやCBOWとは厳密には異なる。

アーキテクチャ参考

DatasetとDataloader

上記アーキテクチャにおいて参考にしたコードたちはデータを直接コード上に書いてバッチサイズも設定していないことがある。なので実践向き (大きいデータ向き) ではない。

Dataloaderを設定して、ngramをバッチサイズごとイテレートした。

今回使っている torchtext.datasets.WikiText2 はすでにイテレーターなのでDatasetとDataloaderどっちも使う必要があるのかわからなかったが、仕組みを理解したいので使うことにした。

  • Datasetではvocabの設定をし、単語を数値に変換したngram単語列と正解単語を返す
  • Dataloaderはバッチサイズを設定し、concatしたデータを返す
    • drop_last=True でバッチサイズより小さいデータは学習に使わない

Text classification with the torchtext library — PyTorch Tutorials 1.11.0+cu102 documentation (テキスト分類タスク向け)の実装に従ったので、もしかしたら別の方法もあるかもしれない。

学習・評価

今回10epochで実験。

学習のloss

WikiText2の場合テストデータのエントロピー4.1636 、 PentTreeDatasetの場合は 4.1657 と小さくなった。 今まで実装してきたモデルでエントロピーが5を切っているモデルはなかったのでかなり良いスコアといえる。

さいごに

公式のtutorial (Welcome to PyTorch Tutorials — PyTorch Tutorials 1.11.0+cu102 documentation) で改めてpytorchを勉強したが、モデルの実装よりデータの扱いについての情報が少なくて結構困った。

日本語ngramデータは公開がされているので頻度ベースのモデルは実装できそうだ。今後試したい。

gist.github.com

ngram言語モデルについてまとめる (Interpolated Kneser–Ney smoothing)

eieito.hatenablog.com

前回、NLTKで動かしてみた Interpolated Kneser–Ney smoothing (長いので以降 Interpolated KNと略します) をpythonで実装してみました。

詳細は gist にアップロードした notebook に記載しています。(二度手間になるのでnotebook にまとめる方式にしました。)

gist.github.com

のこりのはしばし

定義はサイコロ本とSLPに従っています。

NLTK実装

NLTKのngram言語モデル実装は (ソースコードのコメントを読む限り) Chen & Goodman 1995. の定義に従っています。

Interpolated KN を含むsmoothing系のモデルは 2.8 (P.18) のAlgorithm Summary にある

\displaystyle{
p_{smooth} (w_i| w^{i-1}_{i-n+1})  = \alpha  (w_i| w^{i-1}_{i-n+1}) + \gamma (w^{i-1}_{i-n+1}) p_{smooth}(w_{i}| w^{i-1}_{i-n+2})
}

をベースとし、smoothing クラス (estimator) でアルファとガンマを求めることで統一した実装がされています。

interpolated KN 実装

NLTKのInterpolated KN は未知語に対応していないため、 NLTKのモデルは未知語に対してスコアが -inf になるので注意が必要です。

### モデルの用意 ###
import nltk
from nltk.lm.models import KneserNeyInterpolated
from nltk.corpus import brown

from nltk.lm.counter import NgramCounter
from nltk.lm.vocabulary import Vocabulary
from nltk.lm.preprocessing import padded_everygram_pipeline

# nltk.download('brown')
# refer to https://www.nltk.org/howto/lm.html#issue-167
train_data, vocab_data = padded_everygram_pipeline(2,brown.sents(categories="news"))
model = KneserNeyInterpolated(order=2)
model.fit(train_data, vocab_data)
### ここまでモデル用意 ###

# model.estimator が `nltk.lm.smoothing.KneserNey`
print("a", model.estimator.unigram_score("a"))
print("ほげ", model.estimator.unigram_score("ほげ"))
#  a 0.008209501384160118
# ほげ 0.0

#  "ほげ a" のスコア は "a" の unigram_score になる
model.unmasked_score("a", ["ほげ"])

# "a ほげ" のスコア は "ほげ" が未知語なので -inf
model.logscore("ほげ", ["a"])
# -inf

また、  P _ {continuation} を求める時、後ろのcontext (  w _ {i} の前に出現する  w _ {i-1} ) をキャッシュしておらず、毎回探索しているため動作が非常に遅いです。

higher_order_ngrams_with_context = (
       counts
       for prefix_ngram, counts in self.counts[len(context) + 2].items()
       if prefix_ngram[1:] == context
)
# https://github.com/nltk/nltk/blob/98a3a123a554ac9765475aee0c6f1e77ca1723da/nltk/lm/smoothing.py#L118-L123

(前回の伏線回収)

KneserNeyInterpolated だけ異常に重かったです。 Kneser-Ney smoothingの仕組みと実装コードをみればそりゃそうだろなって気持ちなのですが、別のブログ記事としてまとめようと思います。

ngram言語モデルについてまとめる (NLTKのngram言語モデル) - エイエイレトリック

ngram言語モデルについてまとめる (NLTKのngram言語モデル)

Kneser-Ney smoothing を実装しようと調べていたところ、NLTKで実装されていたのでNLTKのngram言語モデルの使い方についてまとめます。

前処理にちょっと癖があるものの、エントロピーなど数値の算出が共通化されているのでモデルごとの違いを比較しやすい気がします。

詳細なコードはgistに記載しています。

nltk.lm

nltk.lm.models にLidstone, Laplaceといった基礎的なngram言語モデルが揃っていて、 自分が実装しようとしていた Kneser-Ney smoothingはnltk.lm.models.KneserNeyInterpolatedで実装されています。

NLTKはNLPの (機械学習でない) 実装はだいたい揃っているのですが、モジュールが多いため目的の関数を探すのが大変で、しかもドキュメントが整備されていたりされていなかったりするのでちょっととっつきにくい部分があります。

(実際自分は nltk.lm.smoothing.KneserNey がモデルだと勘違いしました。これはスムージングだけで、ngramモデルとしてはnltk.lm.KneserNeyInterpolated を使います。)

入力データの準備

NLTK :: Sample usage for lm を見ると lm.fit で学習データを渡していますが、複数のモデルを試す場合、内部で毎回同じ計算をすることになるので少し工夫します。

>>> from nltk.lm import WittenBellInterpolated
>>> lm = WittenBellInterpolated(ngram_order)
>>> lm.fit(train_data, vocab_data)

nltk.lm.models に実装されている言語モデルnltk.lm.api.LanguageModel を継承しているため、こっちのドキュメントに従って入力データを準備していきます。

具体的には ngram_order (orderと略されていることもある) と nltk.lm.vocabulary.Vocabularynltk.lm.counter.NgramCounter です。

Vocabularyは語彙辞書で、初期値に collections.Counter が必要です。

NgramCounter は複数のngramに対応したカウント辞書なので、あらかじめngram_order を決めて、データのngramを求める必要があります。

複数のngramを求める nltk.util.everygrams があるので活用します。

ちなみに、NLTK :: Sample usage for lmpadded_everygram_pipeline を使っているので、このpadding に合わせてnltk.lm.preprocessing.pad_both_ends でpaddingしています。

from nltk.util import ngrams, everygrams
from nltk.lm.vocabulary import Vocabulary
from nltk.lm.counter import NgramCounter
from nltk.lm.preprocessing import pad_both_ends


ngram_order = 2

counts = Counter()
ngram_text = []
train_iter = PennTreebank(split='train')

for data in train_iter:
  words = list(pad_both_ends(tokenizer(data),n=ngram_order))
  counts.update(words)
  ngrams = everygrams(words, max_len=ngram_order)
  ngram_text.append(ngrams)
  
vocab = Vocabulary(counts=counts, unk_cutoff=1)
counter = NgramCounter(ngram_text)

モデルを動かす

データの準備ができればあとはモデルを動かすだけです。学習の必要はないので。

ngramデータを渡せば entropyや perplexity も計算できます。

nltk.utilにngramやbigramデータを求める関数があるのでngramデータも簡単に作成できます。

from nltk.lm import WittenBellInterpolated
from nltk.util import bigrams

# ngram_order = 2

lm = WittenBellInterpolated(ngram_order, vocabulary=vocab, counter=counter)

sent = "this is a sentence"
sent_pad = list(bigrams(pad_both_ends(tokenizer(sent), n=ngram_order)))
print(sent_pad)
lm.entropy(sent_pad)
# 7.662118839369174

Gist

Gistでは実装されている言語モデルについてPentreeBank データでエントロピーを比較しました。

KneserNeyInterpolated だけ異常に重かったです。 Kneser-Ney smoothingの仕組みと実装コードをみればそりゃそうだろなって気持ちなのですが、別のブログ記事としてまとめようと思います。

ngram言語モデルについてまとめる (ヘルドアウト推定・Good-Turing)

前回に続いて、古典的な言語モデルについてpythonで実装して比較していきます。

eieito.hatenablog.com

add-one や ELE は下の式をベースに、対象の単語の ngram と (n-1)gramの 頻度 を使って確率を求めていた。

\displaystyle{
P(w_{i} |w_{i-1})= \frac{C(w_{i-1},w_{i})}{C(w_{i-1}) }
}

今回は頻度 r に注目した手法です。

未出現の単語に対して頻度を振り分けるため、学習データ内での出現回数 r を調整する方法がある。 ここで、同じ出現回数であれば出現確率も同じとみなします。例えば、dogpencil も互いに5回出現していたら、どちらも同じ確率を割り当てる。

前回と同様、詳細なコードは下記gistに記載しています。

torchtext_pentreebank_gt.ipynb

ヘルドアウト推定

ヘルドアウト推定は学習データとは別のヘルドアウトデータを使って確率推定値を求める。 ヘルドアウトデータにより、学習データに未出現のデータに対しても確率を割り当てることができる。

  • 学習データにおける頻度  C_1(w1, ..., wn)
  • ヘルドアウトデータにおける頻度  C_2(w1, ..., wn)
  • 学習データにおける頻度 r の n-gramのタイプ数  N_r
  • 学習データ中のn-gramの頻度がr回のn-gramがヘルドアウトデータで出現した数  T_r

とする。

今回は 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^* = T_r / N_r として推定できる。

下の表のように、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

確率推定値を求める。

\displaystyle{
P_{ho}(w_1,...,w_n) = \frac{T_r}{N_r T}
}

ただし、

  •  w_1,...,w_n の学習データでの頻度が 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の頻度のタイプ数  N^{a}_{r}
    • aの頻度のタイプ数を使ったbの出現回数  T^{ab}_{r}
  • 学習データをa、ヘルドアウトデータをbとしたとき
    • bの頻度のタイプ数  N^{b}_{r}
    • bの頻度のタイプ数を使ったaの出現回数  T^{ba}_{r}

確率推定値は分割して推定した値を足して求める。

\displaystyle{
 P_{del}(w_1,..,w_n) = \frac{T_r^{ab}+T_r^{ba}}{N(N_r^a + N_r^b)}
}

参考: 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 : データのサンプル数 \displaystyle{
N = \sum _ {r} r N _ {r}
}

とした時、補正した r* は

\displaystyle{
r^* = (r+1) \frac{N_{r+1}}{N_r} 
}

であり、確率推定値は以下のように定義できる。

\displaystyle{
P_{GT} = \frac{r^{*}}{N} = \frac{1}{N} (r+1) \frac{N_{r+1}}{N_r}
}

この式の詳しい導出過程は以下を参考に。

引き続き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になってしまうので  r^{ * } = N_1 / N とした。

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 がそれにあたる。

参考資料

ngram言語モデルについてまとめる (add-one)

サイコロ本 (統計的自然言語処理の基礎) で確率的言語モデル(ngram言語モデル) のバリエーションについて少し勉強したので、実装して比較してみます。

ngram言語モデル

長さnの単語列  s = w_1, w_2, ..., w_t において以下のように定義される確率  P(s)を推定するモデルのこと。 以下のように定義される。

\displaystyle{
P(s) = \prod_{i=1}^t  P(w_i |w_1, ...., w_{i-1})
}

ここで、 P(w_i |w_1, ...., w_{i-1}) は条件付き確率。 1単語目から i-1 単語目までが w_1, ...., w_{i-1} である事象において、i単語目の単語が  w_i である確率を指す。

実際はマルコフ性を考慮し、1単語目から i-1 単語目ではなく、後ろn-1単語だけで推定することが多い。

\displaystyle{
P(s) = \prod_{i=1}^t  P(w_i |w_1, ...., w_{i-1}) \approx \prod_{i=1}^t  P(w_i |w_{i-n+1}, ...., w_{i-1})
}

このnがngramモデルのnにあたる。 n=2 なら bigram model, n=3 なら trigram modelになる。(n=1のときは語順を考慮しない)

数式右辺の  P(\cdot)は単語もしくは単語列の出現確率のため、頻度  C(\cdot)に基づいて算出できる。

n=2のときは、

\displaystyle{
P(w _ {i-1},w _ i) = \frac{C(w _ {i-1},w _ i)}{N}
}

(Nは学習データの単語数)なので、条件付き確率は以下になる。

\displaystyle{
P(w_i |w_{i-1})= \frac{C(w_{i-1},w_i)}{C(w_{i-1})}
}

これがngram言語モデルのベースとなる最尤推定 (Maximam likelihood)モデル。

スムージング

単純な最尤推定だとデータスパースネスを考慮できない。頻度が0 (未知語) の場合、 P(s)も0になってしまう。

考慮する手法が ディスカウント(discounting) , スムージング(smoothing, 平滑化)

ディスカウントは低頻度に対して確率を分けるため、それ以外の確率を割引くところから名付けられたらしい。 個人的にはスムージングの方が一般的だと思う。

さまざま提案されているので今回は簡単な手法を試してみる。

1-加算 (add-one)

 P(w_i |w_{i-1}) について、分母と分子それぞれに1足す方法。

\displaystyle{
P(w_{i} |w_{i-1})= \frac{C(w_{i-1},w_{i})+ 1 }{C(w_{i-1})+ 1 }
}

Lidstoneの法則

1だと大きすぎるのでいい感じの値  \lambda を設定する方法。

\displaystyle{
P(w_{i} |w_{i-1})= \frac{C(w_{i-1},w_{i})+ \lambda }{C(w_{i-1})+ \lambda }
}

特にうまくいく  \lambda = 1/2 の場合を 期待尤度推定 (expected likehood estimation: ELE) と呼ぶ。 (らしいが、古い手法だからか調べてもあまり参考文献が出てこない。)

実装

google colab 上で動かしてみる。

まとめたnotebookは URL にアップロードしました。

torchtext_ngram.ipynb · GitHub

前処理

データ

のちのちpytorchのモデルと比較したいので、torchtext のデータセットを活用する。

今回は torchtext.datasets.WikiText2 を利用。

元データは The WikiText Long Term Dependency Language Modeling Dataset

The WikiText language modeling dataset is a collection of over 100 million tokens extracted from the set of verified Good and Featured articles on Wikipedia.

とある通り、Wikipediaデータ。 論文 ([1609.07843] Pointer Sentinel Mixture Models) を読む限り、前処理で出現回数が3回未満のvocabは <unk> に置き換えているようです。

tokenizer

pytorchのtutorial Text classification with the torchtext library — PyTorch Tutorials 1.11.0+cu102 documentation に従って、tokenizerとvocabを設定する。

tokenizer は torchtext のget_tokenizer を使う。spacyを通すこともできるが、シンプルな normalize だけ行う "basic_english" を指定。

tokenizer = get_tokenizer('basic_english')
vocab

build-vocab-from-iterator で語彙設定もしてしまう。

tokenizerで単語ごと分割したデータを渡すことで vocab を設定する。

vocab.set_default_index(vocab['<unk>']) とすれば、 <unk> 以外の未知語も <unk> 扱いにできる。

train_iter = torchtext.datasets.WikiText2(split='train')
vocab = build_vocab_from_iterator(map(tokenizer, train_iter), specials=['<unk>'])

vocab.set_default_index(vocab['<unk>'])

count ngram

前処理が終わったところで、ngramデータを構築する。

簡単な頻度データしか使わないのでpure pythonで実装。unigram と bigramをカウントしていく。

文頭と文末に <bos><eos> を設定しカウントする場合もあるが、テキストをそのままカウントする。

train_iter = torchtext.datasets.WikiText2(split='train')

# unigram と bigram を取得する
unigram_counts = defaultdict(int)
bigram_counts = defaultdict(int)

for t in train_iter:
  tokens = text2index(t)
  for t in iter_ngram(tokens, 1):
    unigram_counts[t[0]]  +=1

  for t in iter_ngram(tokens, 2):
    bigram_counts[tuple(t)] +=1

エントロピーを算出

エントロピーを計算。対数尤度を単語数 (N_T) で割った値。

\displaystyle{
H = \frac{1}{N_T}\sum_{i=1} - \log_2 P_{model}(t_i)
}

ただし

  • テストデータ  T \in (t_0, t_1, ..., t_{l_T})
  •  P_{model}(t_i) はデータ  t_i に対する model の出力
  • テストデータの合計単語数  N_T

ngram言語モデル

\displaystyle{
P_{model}(t_i) = \prod  P(w_i |w_1, ...., w_{i-1})
}

なので、

\displaystyle{
\log_2 P_{model}(t_i) = \sum \log_2 P(w_i |w_1, ...., w_{i-1})
}

と変形でき、総和の総和でエントロピーを計算できる。

def get_probability(w1, w2, param=0):
  """ P(w_2 | w_1) を求める
  C(w1, w2) + param  / C(w1)  + param
  """
  return  float(bigram_counts.get((w1,w2), 0) + param) / (unigram_counts.get(w1,0) + param)

def get_entropy(lambda_param=0):
  # 単語数 N_T
  word_sum = 0
  # 対数尤度
  h = 0
  test_iter =  torchtext.datasets.WikiText2(split='test')
  for t in test_iter:
      tokens = text2index(t)
      word_sum += len(tokens)
      for i in range(1, len(tokens)-1):
        p = get_probability(
            tokens[i-1], tokens[i], param=lambda_param
            )
        h +=  -math.log2(p)

  print(f"word_sum: {word_sum}\nentropy: {h/word_sum}")

add-one(Laplace) と ELE(Lidstone) で比較してみる。

add-one

>> get_entropy(1)

word_sum: 241859
entropy: 6.399829746902186

ELE

>> get_entropy(1/2)
word_sum: 241859
entropy: 6.646151077249645

微妙な差とはいえ、add-oneよりいい結果になるはずのELEのほうがエントロピーが高くなってしまった。

なぜだろう……。どこかミスしているかもしれない。

カバレッジも計算しようかと思ったが、train データに <unk> があるので厳密には算出できない気がする。

おわりに

今回は簡単なngram言語モデルを実装してみた。

ちなみに深層学習モデルの流行で、言語モデルといえばNLPのイメージがすっかり定着しているが、音声認識 (speech recognition) でも使われていることを久々に思い出した。

他のスムージング手法についても実装してみたいと思います。

参考資料

mecabの制約付き解析(部分解析)のエラー

mecabの制約付き解析(部分解析)を使おうとしたら結構詰まったのでエラーとその解決策についてメモします。

Logo frwl uk

前提知識

mecab--partial (-p) オプションを使うと辞書に登録されてない単語でも解析できます。

$ mecab
例えばコ↑レ↓を名詞にしたいときがある

例えば   接続詞,*,*,*,*,*,例えば,タトエバ,タトエバ
コ 名詞,一般,*,*,*,*,*
↑ 記号,一般,*,*,*,*,↑,↑,↑
レ 名詞,一般,*,*,*,*,レ,レ,レ
↓ 記号,一般,*,*,*,*,↓,↓,↓
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
名詞  名詞,一般,*,*,*,*,名詞,メイシ,メイシ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
たい  助動詞,*,*,*,特殊・タイ,基本形,たい,タイ,タイ
とき  名詞,非自立,副詞可能,*,*,*,とき,トキ,トキ
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
ある  動詞,自立,*,*,五段・ラ行,基本形,ある,アル,アル
EOS

$ mecab -p
例えば
コ↑レ↓    名詞
を名詞にしたいときがある
EOS

例えば   接続詞,*,*,*,*,*,例えば,タトエバ,タトエバ
コ↑レ↓    名詞,サ変接続,*,*,*,*,*
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
名詞  名詞,一般,*,*,*,*,名詞,メイシ,メイシ
に 助詞,格助詞,一般,*,*,*,に,ニ,ニ
し 動詞,自立,*,*,サ変・スル,連用形,する,シ,シ
たい  助動詞,*,*,*,特殊・タイ,基本形,たい,タイ,タイ
とき  名詞,非自立,副詞可能,*,*,*,とき,トキ,トキ
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
ある  動詞,自立,*,*,五段・ラ行,基本形,ある,アル,アル
EOS

形態素を指定したい部分 (形態素断片) について 表層\t素性パターン の形で入力し、指定しない部分 (文断片) は改行をはさんでそのまま入力することで欲しい出力に合わせることができます。

詳しい使い方は mecabの公式ドキュメント (https://taku910.github.io/mecab/partial.html) を参考にしてください。

部分解析は、記号や数字のような正規表現で取得できる単語であれば辞書登録しなくても解析できるので便利です。

natto-pyでは boundary_constraints の引数があり、証券コードに対して制約付き解析を使う例が紹介されています。 (URL)

tokenizer.cpp(368) エラー

ところで、mecabの解析結果では基本的に半角スペースが無視されます。

$ mecab -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd

007 ロシアより愛をこめての原題は "From Russia with Love"
007 名詞,固有名詞,人名,一般,*,*,007,ゼロゼロセブン,ゼロゼロセブン
ロシアより愛をこめて  名詞,固有名詞,一般,*,*,*,ロシアより愛をこめて,ロシアヨリアイヲコメテ,ロシアヨリアイオコメテ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
原題  名詞,一般,*,*,*,*,原題,ゲンダイ,ゲンダイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
"   記号,一般,*,*,*,*,*
From    名詞,固有名詞,組織,*,*,*,*
Russia  名詞,固有名詞,一般,*,*,*,Russia,ロシア,ロシア
with    名詞,固有名詞,人名,一般,*,*,WITH,ウィズ,ウィズ
Love    名詞,固有名詞,人名,一般,*,*,LOVE,ラブ,ラブ
"   記号,一般,*,*,*,*,*
EOS

"From Russia with Love" の部分が半角スペースごと分割されました。 この場合、解析結果を元の文字列に復元するのが少々面倒ですし、英単語部分を細かく分割したくない気がします。 そこで、" で囲まれた英語部分を部分解析で1つの名詞扱い (形態素断片) にしてみます。

$ mecab -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd -p

007 ロシアより愛をこめての原題は 
"From Russia with Love" 名詞
EOS

tokenizer.cpp(368) [new_node->feature]

エラーになってしまいました。 コードをみると解析中に新しいnodeを参照できないことが原因と思われます。 (tokenizer.cpp)

今回の入力の場合、原因は文断片 (制約のない部分) 007 ロシアより愛をこめての原題は の最後にある 半角スペース 。 これを削除すると問題なく実行できます。

$ mecab -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd -p

007 ロシアより愛をこめての原題は
"From Russia with Love" 名詞
EOS

007 名詞,固有名詞,人名,一般,*,*,007,ゼロゼロセブン,ゼロゼロセブン
ロシアより愛をこめて  名詞,固有名詞,一般,*,*,*,ロシアより愛をこめて,ロシアヨリアイヲコメテ,ロシアヨリアイオコメテ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
原題  名詞,一般,*,*,*,*,原題,ゲンダイ,ゲンダイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
"From Russia with Love" 名詞
EOS

実装の詳細までみてないので想像ですが、文断片(制約なし部分)→形態素断片(制約ありテキスト部分) の順に解析するとき、半角スペースを含んでいると、形態素断片を正しく取得できなくなってるんじゃないかなと思います。

形態素断片→文断片の順番であれば、文断片に半角スペースを含んでいても問題ないです。

制約ありテキスト列の 直前半角スペース があるときだけ起こるエラーです。

# 後ろの文字列の最初に半角スペース -> OK
"From Russia with Love" 名詞
 が原題
EOS

"From Russia with Love" 名詞
が 接続詞,*,*,*,*,*,が,ガ,ガ
原題  名詞,一般,*,*,*,*,原題,ゲンダイ,ゲンダイ
EOS

# 後ろの文字列の最後に半角スペース -> OK
"From Russia with Love" 名詞
が原題 
EOS

# 前の文字列の最後にタブ -> OK
原題は   
"From Russia with Love" 名詞
EOS

# 前の文字列の最後に全角スペース -> OK (※解析結果に `記号,空白` が含まれる)
原題は 
"From Russia with Love" 名詞
EOS

原題  名詞,一般,*,*,*,*,原題,ゲンダイ,ゲンダイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
  記号,空白,*,*,*,*, , ,
"From Russia with Love" 名詞
EOS

英単語のほか、URLは活性化するため前後に半角スペースを含むことが多いので注意が必要です。 pythonstr.strip のように半角スペースを除去する処理を挟んで入力しましょう。

$ mecab

このブログのURLは https://eieito.hatenablog.com/ です。 
この  連体詞,*,*,*,*,*,この,コノ,コノ
ブログ   名詞,一般,*,*,*,*,*
の 助詞,連体化,*,*,*,*,の,ノ,ノ
URL 名詞,一般,*,*,*,*,*
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
https   名詞,固有名詞,組織,*,*,*,*
:// 名詞,サ変接続,*,*,*,*,*
eieito  名詞,一般,*,*,*,*,*
.   名詞,サ変接続,*,*,*,*,*
hatenablog  名詞,一般,*,*,*,*,*
.   名詞,サ変接続,*,*,*,*,*
com 名詞,一般,*,*,*,*,*
/   名詞,サ変接続,*,*,*,*,*
です  助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
。 記号,句点,*,*,*,*,。,。,。
EOS

$ mecab -p
このブログのURLは
https://eieito.hatenablog.com/  名詞
です。 

この  連体詞,*,*,*,*,*,この,コノ,コノ
ブログ   名詞,一般,*,*,*,*,*
の 助詞,連体化,*,*,*,*,の,ノ,ノ
URL 名詞,一般,*,*,*,*,*
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
https://eieito.hatenablog.com/  名詞,サ変接続,*,*,*,*,*
です  助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
。 記号,句点,*,*,*,*,。,。,。
EOS

そもそも --partial は使わず、前処理で別の記号に置換してもいいと思います。 e.g. このブログのURLはURLです。

mecab-python3の入力エラー

(mecab-python3==1.0.4 で検証)

mecab-python3 では taggerの初期化のとき引数で渡す (MeCab.Tagger("-p") ) か、tagger.set_partial(True)--partial と同じ設定を使うことができます。

デフォルトの設定とは異なり、入力の最後に改行 (+EOS) がないとsegmentation faultのエラーになります。 mecab-python3のバグではなくmecabの入力がそのように定義されていることが原因です。

import MeCab

tagger = MeCab.Tagger("-p")
#NG: エラーになる
tagger.parse('007 ロシアより愛をこめての原題は "From Russia with Love"')
zsh: segmentation fault  ipython

# 以下はOK
tagger.parse('007 ロシアより愛をこめての原題は "From Russia with Love"\nEOS')
tagger.parse('007 ロシアより愛をこめての原題は "From Russia with Love"\n')

tagger.parse('007 ロシアより愛をこめての原題は\n"From Russia with Love"\t名詞\nEOS')

mecab-python3は本家と違い、tagger.set_partial(False) で設定を無効化できるので意外と便利です。 部分解析が設定されているかどうかは tagger.partial() で確認できます。

tagger.partial()
>> True

mecab-python3を使ってもっと細かく設定したい場合は PythonでMeCabの制約付き解析を使う - Qiita のようにLattice設定を使えばよさそうです。

ja.wikipedia.org

django rest frameworkのschema自動生成の仕組みとカスタマイズ方法

Django REST framework (DRF) は Django で Web APIを構築するのに便利なパッケージです。 schema (スキーマ) を使えば OpenAPI (Swagger) のフォーマットでドキュメントを生成することができます。

この記事では、drfでどのようにスキーマが生成されるのか解説します。また、最後にスキーマの生成を自分でカスタマイズするユースケースも紹介します。

カスタマイズは魔改造な要素をふんだんに含んでいるので、こだわりがなければ最後の 3rd-party package の章で紹介しているものを使うことをおすすめします。

  • 開発環境
  • OpenAPIの構成要素
  • APIをドキュメント化する方法
  • schema_view
  • Schema関連クラスの関係
    • SchemaGenerator
    • AutoSchema
  • schemaのカスタマイズ
    • SchemaGeneratorでsecurity設定をする
    • AutoSchemaでfilter_backendsを使ってserializerを元にparametersを設定する
    • AutoSchemaでserializerを使わずresponseを設定する
  • おまけ:3rd-party package

開発環境

  • Python 3.8
  • Django 3.2.3
  • djangorestframework 3.12.4
  • OpenAPI 3.0.2 (djangorestframeworkの設定に基づく)

OpenAPIの構成要素

OpenAPIはWeb APIを記述する仕様です。yamljson形式で記述します。 Swagger UI を使えば、ドキュメントとして可視化もできます。

Swagger Editor でサンプルファイルとドキュメントの出力を見ることができます。

サンプルファイルを例に、OpenAPIの構成要素を説明していきます。

f:id:sh111h:20210822102606j:plain
OpenAPI-Driven API Design

続きを読む