Help us understand the problem. What is going on with this article?

ざっくり理解する単語の分散表現(One-hot encode, word2vec, ELMo, BERT)


自己紹介

  • 単語の分散表現について簡単にまとめました。
  • 自己紹介:Pythonでデータ分析とかNLPとか異常検知とかしてます。
  • 質問やツッコミなど有りましたらぜひコメント下さい!

モチベーション

  • 自然言語をコンピュータに認識させるために数値化する必要が有ります。
  • 「文の数値表現」と「単語の数値表現」があり、今回は後者にフォーカスして紹介します。
  • 後者のうち、1単語を低い(数百程度の)次元のベクトルで表現したものを「分散表現」といいます。
  • 分散表現にすることで以下の効果があります。
    • 省計算量
    • 省メモリ
    • 意味をエンコード可
    • 手法によっては文脈をエンコード可(多義語を理解させられる)

用語説明 : 「自然言語で取り扱う対象」の分類

分類 意味
Corpus 文書の集合 Wikipediaの全てのページの文書
Document 文書 Wikipediaの「word2vec」というページの文書
Sentence 上記文書の最初の文
" Word2vec is a group of related models that are used to produce word embeddings. "
Phrase 上記文の最初の句
" Word2vec is a group of related models "
Token 単語 上記句の最初の単語 " Word2vec "
Character 文字 上記単語の最初の文字 " W "
  • 今回は単語(Token)を数百程度のベクトルで表す手法を紹介します。
  • あるCorpusに現れるTokenの(ユニークな)集合をVocabulary(語彙)といいます。

単語の数値表現の分類

分類 主な手法 省計算量 省メモリ 意味理解 文脈理解 次元数のオーダ
One-hot表現 - One-hot Encording × × × × 100万
文脈を加味しない
分散表現
- word2vec
- Glove
× 100
文脈を加味する
分散表現
- ELMo
- BERT
100~1000
  • 意味理解:「単語の意味の近さ」が、コサイン類似度等の指標で定量化できるということ
  • 文脈理解:あめ(雨・飴)やbank(土手・銀行)等の多義語を表現可能ということ

One-hot表現とは?

  • あるCorpusに現れるN種類のTokenが現れたとします。
  • この時、このTokenたちのユニークな集合を大きさNのVocabulary(語彙)と呼びます。
  • VocabularyはCorpusに対して一意に決まります。
  • 日本語wikipediaのコーパスは150万種類のTokenで構成されているので、N=150万です。
  • あるTokenをOne-hotベクトルで表現することををTokenのOne-hot表現といいます。
  • 例えば、[0, 0, 1, 0]は4次元のOne-hotベクトルです。

演習 : 自前のCorpusを用意して、One-hot表現を獲得する(Python,jupyter-lab)

  • Corpus
    • 「私は今朝おにぎりを食べました。」
    • 「今日の朝はあめでした。犬の散歩には行きませんでした。」
    • 「論文を読むのは楽しい。」
    • 「あめは美味しい。」
one_hot.ipynb
import janome.tokenizer #形態素解析器(日本語を単語に分割するライブラリ)

# Documents
d_01 = "私は今朝おにぎりを食べました。"
d_02 = "今日の朝はあめでした。犬の散歩には行きませんでした。"
d_03 = "論文を読むのは楽しい。"
d_04 = "あめは美味しい。"

# 分かち書き(Tokenを見出し語に戻す)
tokenizer = janome.tokenizer.Tokenizer()
print([token.base_form for token in tokenizer.tokenize(d_01)])
出力
['私', 'は', '今朝', 'おにぎり', 'を', '食べる', 'ます', 'た', '。']
one_hot.ipynb
# Vocabularyを作る
tokens_01 = [token.base_form for token in tokenizer.tokenize(d_01)]
tokens_02 = [token.base_form for token in tokenizer.tokenize(d_02)]
tokens_03 = [token.base_form for token in tokenizer.tokenize(d_03)]
tokens_04 = [token.base_form for token in tokenizer.tokenize(d_04)]
vocabulary = list(set(tokens_01+tokens_02+tokens_03+tokens_04))
print(vocabulary)
出力
['に', 'おにぎり', '犬', '。', '食べる', 'ん', 'ます', '論文', 'です', 'た', 'を', '私', '行く', '読む', '楽しい', 'は', '今日', '散歩', '美味しい', '今朝', 'の', 'あめ', '朝']
one_hot.ipynb
# 単語IDを表示
for i in range(len(vocabulary)):
    print("token ID : {}, token : {}".format(i,vocabulary[i]))
出力
token ID : 0, token : に
token ID : 1, token : おにぎり
token ID : 2, token : 犬
token ID : 3, token : 。
token ID : 4, token : 食べる
token ID : 5, token : ん
token ID : 6, token : ます
token ID : 7, token : 論文
token ID : 8, token : です
token ID : 9, token : た
token ID : 10, token : を
token ID : 11, token : 私
token ID : 12, token : 行く
token ID : 13, token : 読む
token ID : 14, token : 楽しい
token ID : 15, token : は
token ID : 16, token : 今日
token ID : 17, token : 散歩化け
token ID : 18, token : 美味しい
token ID : 19, token : 今朝
token ID : 20, token : の
token ID : 21, token : あめ
token ID : 22, token : 朝
one_hot.ipynb
# One-hot化
import sklearn.preprocessing
vocabulary_onehot = sklearn.preprocessing.label_binarize(vocabulary,classes=vocabulary)

for token, onehotvec in zip(vocabulary,vocabulary_onehot):
    print("one-hot vector : {}, token : {}".format(onehotvec,token))
出力
one-hot vector : [1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : に
one-hot vector : [0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : おにぎり
one-hot vector : [0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : 犬
one-hot vector : [0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : 。
one-hot vector : [0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : 食べる
one-hot vector : [0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : ん
one-hot vector : [0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : ます
one-hot vector : [0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : 論文
one-hot vector : [0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0], token : です
one-hot vector : [0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0], token : た
one-hot vector : [0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0], token : を
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0], token : 私
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0], token : 行く
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0], token : 読む
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0], token : 楽しい
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0], token : は
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0], token : 今日
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0], token : 散歩
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0], token : 美味しい
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0], token : 今朝
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0], token : の
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0], token : あめ
one-hot vector : [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1], token : 朝
one_hot.ipynb
# 任意のOne-hot表現を取り出す
token_index = vocabulary.index("私")
print("「私」のOne-hot表現は {}".format(vocabulary_onehot[token_index]))
出力
「私」のOne-hot表現は [0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0]
  • 今回は分割と見出し語化(「食べ」→「食べる」等)のみ行いました。
  • 実際には以下の様な処理が入ることが有ります。
    • クリーニング(htmlタグ除去等)
    • 正規化(半角全角、大文字小文字、表記ゆれの統一等)
    • ストップワード除去(頻出で意味のない単語。the等)

One-hot表現の問題点① : 次元が大きすぎる

  • 上記の例ではCorpusが小さかったので、Vocabularyの大きさも20程度でした。
  • 実際の日本語wikipediaコーパスは150万語程度です。
  • ある単語が専有するメモリは莫大なものになります。
one_hot.ipynb
# 150万次元のOne-hotベクトルを作ってみて、メモリを確認する
memory_check_list = [0] * 1500000
memory_check_list[0] = 1
memory = sys.getsizeof(memory_check_list)
memory = memory / (1000*1000)
print("ある単語が専有するメモリ : {:0.1f}MB".format(memory))
出力
ある単語が専有するメモリ : 12.0MB
  • 12MBになってしまいました。

One-hot表現の問題点② : 意味をエンコードできない

  • ベクトルは内積やコサイン類似度といった手法で「近さ」を定量化出来ます。
  • しかしOne-hot表現では、異なる単語ベクトルの内積・コサイン類似度は0になってしまいます。
one_hot.ipynb
inu_vec = vocabulary_onehot[vocabulary.index("犬")]
kesa_vec = vocabulary_onehot[vocabulary.index("今朝")]
asa_vec = vocabulary_onehot[vocabulary.index("朝")]

# 「犬」と「朝」の内積
print("「犬」と「朝」の距離 : {}".format(np.dot(inu_vec,asa_vec)))

# 「今朝」と「朝」の内積
print("「今朝」と「朝」の距離 : {}".format(np.dot(kesa_vec,asa_vec)))
出力
「犬」と「朝」の距離 : 0
「今朝」と「朝」の距離 : 0
  • もし意味をエンコードできれば、以下の事ができそうです。
    • 意味の近い単語ほど、ベクトルは近くなる(cos類似度が1に近くなる)。
    • 単語の足し引きで他の単語を表現できる。
      • 「パリ」ー「フランス」+「ドイツ」≒「ベルリン」 (首都の意味をエンコード)
      • 「おじさん」ー「男」+「女」≒「おばさん」 (性別の意味をエンコード)

分散表現① : word2vec

  • 「次元が大きすぎる」「意味をエンコードできない」というOne-hot表現の弱点を克服する手法がword2vecです。
  • word2vecはCBOWとSkip-gramという2種類に分類出来ます。
分類 意味
CBOW 周辺の単語から、ある単語を予測する
Skip-gram ある単語から、周辺の単語を予測する
  • 「周辺」のサイズはウィンドウサイズと呼び、ハイパーパラメータです。
  • 「ある場所に入る単語の確率分布は、その周辺の単語によって決定される」という分布仮説という考え方があります。
  • だから「周辺の単語」から「ある単語」を予測する事ができます。(CBOW)
  • 逆も然りです。(Skip-gram)
  • Skip-gramを図示してみます。

Screenshot from 2020-04-06 06-05-40.png

  • 「ある単語」のベクトル表現を、150万次元から300次元に削減しています。
  • この「300次元のベクトル」はスパース(One-hotの様に、多くが0の意味)ではなく、密なベクトルです。
  • このやり方で得られたベクトルは、意味をエンコードできている事が報告されています。

w2v.ipynb
from gensim.models import KeyedVectors
import time

# 時間計測
start = time.time()

# 分散表現の読み込み
word2vec = KeyedVectors.load_word2vec_format("./jawiki.all_vectors.100d.txt")

duration = time.time() - start 
print("読み込み完了 読み込み時間 : {:0.1f}sec".format(duration))
出力
読み込み完了 読み込み時間 : 157.6sec
  • まずは、もともとの次元数(つまり語彙数=Vocabularyの大きさ)を確認します。
w2v.ipynb
# vocabularyの大きさを確認する。
vocab = len(word2vec.index2entity)
print("語彙数 : {}種類".format(vocab))
出力
語彙数 : 1511782種類
  • 語彙数は約150万でした。
  • 150万→100次元なので、約1/15000に削減出来ています。

  • 次に、1単語あたりのメモリ使用量を見てみます。
w2v.ipynb
# メモリを確認する。
memory = sys.getsizeof(word2vec["私"])
memory = memory
print("ある単語が専有するメモリ : {}Byte".format(memory))
出力
ある単語が専有するメモリ : 96Byte
  • 12MB→96Byteなので、約1/120000に削減出来ています。
  • 次元削減の比率と同じ位になっていないのは、Pythonの型の仕様に起因していると思われます。

  • 次に、単語の意味がエンコード出来ている事を確認します。
  • まず、「犬」「今朝」「朝」のcos類似度を確認します。
w2v.ipynb
# ベクトルの近さをコサイン類似度で計算してみる

# 「犬」と「朝」のcos類似度
print("「犬」と「朝」のcos類似度  : {:0.2f}".format(word2vec.similarity("犬","朝")))

# 「今朝」と「朝」のcos類似度
print("「今朝」と「朝」のcos類似度 : {:0.2f}".format(word2vec.similarity("今朝","朝")))

# 「朝」と「朝」のcos類似度(自分自身)
print("「朝」と「朝」のcos類似度  : {:0.2f}".format(word2vec.similarity("朝","朝")))
出力
「犬」と「朝」のcos類似度  : 0.23
「今朝」と「朝」のcos類似度 : 0.61
「朝」と「朝」のcos類似度  : 1.00
  • 「犬と朝」より意味的に近い「今朝と朝」の方が、ベクトルも近い事がわかります。

  • 次に、単語の足し引きで他の単語を表現してみます。
w2v.ipynb
# 「パリ」ー「フランス」+「ドイツ」のベクトルに近いベクトルの単語のランキング
word2vec.most_similar_cosmul(positive=["パリ","ドイツ"],negative=["フランス"])
出力
[('ベルリン', 0.9688876271247864),
 ('##ベルリン##', 0.9477903842926025),
 ('ミュンヘン', 0.9466403722763062),
 ('##ミュンヘン##', 0.9400286674499512),
 ('フランクフルト', 0.9279606342315674),
 ('##デュッセルドルフ##', 0.9246785640716553),
 ('ウィーン', 0.9235028028488159),
 ('##ウィーン##', 0.9195688962936401),
 ('##ドレスデン##', 0.9189494252204895),
 ('ウイーン', 0.9184936285018921)]
w2v.ipynb
# 「おじさん」+「男」ー「女」のベクトルに近いベクトルの単語のランキング
word2vec.most_similar_cosmul(positive=["おじさん","女"],negative=["男"])
出力
[('おばさん', 0.9334666132926941),
 ('お母さん', 0.921658456325531),
 ('姉さん', 0.9212563037872314),
 ('ママ', 0.9188294410705566),
 ('奥さん', 0.9068171977996826),
 ('ばあさん', 0.9040247201919556),
 ('お父さん', 0.9023329019546509),
 ('おばあちゃん', 0.901293933391571),
 ('おじいちゃん', 0.9010269045829773),
 ('母さん', 0.8971079587936401)]
  • きちんと「ベルリン」「おばさん」と予測できています。

word2vecで得られた表現の問題点 : 多義語の扱い

  • 上記のCorpusに「あめは美味しい。」というDocumentがありましたが、この「あめ」は「飴」の意味です。
  • しかし、「あめ」は他に「雨」という意味も有ります。
  • これらの意味がどうエンコードされているか確認します。
w2v.ipynb
print("「あめ」と「美味しい」のcos類似度 : {:0.2f}".format(word2vec.similarity("あめ","美味しい")))
print("「あめ」と「私」のcos類似度    : {:0.2f}".format(word2vec.similarity("あめ","私")))
print("「あめ」と「おにぎり」のcos類似度 : {:0.2f}".format(word2vec.similarity("あめ","おにぎり")))
print("「あめ」と「雨」のcos類似度    : {:0.2f}".format(word2vec.similarity("あめ","雨")))
print("「あめ」と「飴」のcos類似度    : {:0.2f}".format(word2vec.similarity("あめ","飴")))
print("「雨」と「飴」のcos類似度     : {:0.2f}".format(word2vec.similarity("雨","飴")))
出力
「あめ」と「美味しい」のcos類似度 : 0.39
「あめ」と「私」のcos類似度    : 0.40
「あめ」と「おにぎり」のcos類似度 : 0.52
「あめ」と「雨」のcos類似度    : 0.54
「あめ」と「飴」のcos類似度    : 0.57
「雨」と「飴」のcos類似度     : 0.48
  • 「あめ」は他の単語より「雨」「飴」に近いようです。
  • 「あめ」に近い単語をリストアップしてみます。
w2v.ipynb
word2vec.most_similar_cosmul("あめ")
出力
[('ひこひこ', 0.9289494156837463),
 ('ひぼ', 0.9204140901565552),
 ('あま', 0.9173946976661682),
 ('たぎつ', 0.9146406650543213),
 ('みこと', 0.9143114686012268),
 ('めのこ', 0.9126752018928528),
 ('づちのかみ', 0.910390317440033),
 ('むすびのかみ', 0.9099697470664978),
 ('びめ', 0.9096702337265015),
 ('びこのかみ', 0.9094879031181335)]
  • 「飴」「雨」とは全く違う結果になってしまいました。
  • どうやら、「あめのうずめのみこと」など、「天」の意味が強いようです。
  • 「あめ」と「天」を比較します。
  • また、先程のランキングで1位の「ひこひこ」ともcos類似度を計算します。
w2v.ipynb
print("「あめ」と「雨」のcos類似度    : {:0.2f}".format(word2vec.similarity("あめ","雨")))
print("「あめ」と「飴」のcos類似度    : {:0.2f}".format(word2vec.similarity("あめ","飴")))
print("「あめ」と「天」のcos類似度    : {:0.2f}".format(word2vec.similarity("あめ","天")))
print("「あめ」と「ひこひこ」のcos類似度 : {:0.2f}".format(word2vec.similarity("あめ","ひこひこ")))
出力
「あめ」と「雨」のcos類似度    : 0.54
「あめ」と「飴」のcos類似度    : 0.57
「あめ」と「天」のcos類似度    : 0.59
「あめ」と「ひこひこ」のcos類似度 : 0.86
  • 「飴」「雨」よりは「天」の方が近い結果になりました。
  • しかし「ひこひこ」に大差を付けられてしまいました。

  • 多義語がうまくエンコード出来ていません。
  • これはword2vecの「周辺のTokenから予測する」「1単語を1ベクトルに変換する」という特性に起因しています。
  • この対策として、以下の様な手法が生まれました。
    • 「周辺のTokenから予測する」を「周辺のCharacterから予測する」に拡張したCharagram (Wieting et al., 2016)
    • 「サブワード(部分語)の情報を使う」というアイデアとCharagramを組み合わせたfasttext (Bojanowski et al., 2016)
      • 例えば「分散表現」の(2字以上の)サブワードは「分散・分散表・散表・散表現・表現」です。
  • しかし1単語を1ベクトルに変換するという特性をどうにかしないと本質的な解決にはならなそうです。
  • 次に紹介する手法では以下のような特徴があり、文脈を加味した単語の分散表現を得ることができます。
    • 単語を複数ベクトルに変換し、それらの加重平均を取って1ベクトルにする
    • 下流タスクで用いる際に、加重平均の重みを学習する

分散表現② : ELMo

  • ELMo (Peters et al., 2018)はニ層の双方向LSTMを用いて分散表現を獲得したモデルです。
  • 名前の由来はEmbeddings from Language Models(言語モデルを用いた埋め込み)の頭文字です。
    • 言語モデル:次に来る単語の確率を返すモデルです。
    • 埋め込み:低次元に埋め込んでいるので分散表現を得る事を「word embedding」といいます。
  • ELMoは1単語を3ベクトルで表します。
  • ELMoの構造を以下に示します。

Screenshot from 2020-04-05 21-49-23.png

Screenshot from 2020-04-06 16-38-57.png

  • word2vecよりかなり複雑になっています。
  • しかし「ある単語の予測確率が高くなるようにNNを学習させる」という点は変わりません。
  • word2vecでは「ある単語」の予測に「周辺の単語」を用いていました。
  • ELMoでは双方向LSTMを使って「文頭からその単語までの文脈」「文末からその単語までの文脈」を用いています。
  • また、LSTMに入力する前に文脈に依存しないn-gramのCNNが使われています。
  • よって、2層の双方向LSTMと、双方向CNNから合計6個のべクトルが得られます。
  • 元論文では隠れ層が512次元に設定されており、高位層同士・低位層同士・CNN同士をconcatしています。
  • つまり、ELMoは1単語に1024次元のベクトル3つを割り当てています。

演習 : ELMo vs「あめ」(Python,jupyter-lab)

  • Corpus
      • 「明日の天気予報はあめだ。」
      • 「今日の朝はあめだったので、犬の散歩には行きませんでした。」
      • 「梅雨になったので、毎日あめが降っている。」
      • 「駄菓子屋であめを買った。」
      • 「あめを舐めながら作業をする。」
      • 「すっぱいあめは苦手だ。」
elmo.ipynb
import janome.tokenizer #形態素解析器(日本語を単語に分割するライブラリ)

# Documents
d_rainy_01 = "明日の天気予報はあめだ。"
d_rainy_02 = "今日の朝はあめだったので、犬の散歩には行きませんでした。"
d_rainy_03 = "梅雨になったので、毎日あめが降っている。"
d_candy_01 = "駄菓子屋であめを買った。"
d_candy_02 = "あめを舐めながら作業をする。"
d_candy_03 = "すっぱいあめは苦手だ。"

# 分かち書き(Token見出し語に戻す)
tokenizer = janome.tokenizer.Tokenizer()
print([token.base_form for token in tokenizer.tokenize(d_rainy_01)])
出力
['明日', 'の', '天気', '予報', 'は', 'あめ', 'だ', '。']
elmo.ipynb
from allennlp.modules.elmo import Elmo, batch_to_ids

# 分かち書き
tokens_rainy_01 = [token.base_form for token in tokenizer.tokenize(d_rainy_01)]
tokens_rainy_02 = [token.base_form for token in tokenizer.tokenize(d_rainy_02)]
tokens_rainy_03 = [token.base_form for token in tokenizer.tokenize(d_rainy_03)]
tokens_candy_01 = [token.base_form for token in tokenizer.tokenize(d_candy_01)]
tokens_candy_02 = [token.base_form for token in tokenizer.tokenize(d_candy_02)]
tokens_candy_03 = [token.base_form for token in tokenizer.tokenize(d_candy_03)]

# 各Documentの中で「あめ」が何番目か
index_rainy_01 = tokens_rainy_01.index("あめ")
index_rainy_02 = tokens_rainy_02.index("あめ")
index_rainy_03 = tokens_rainy_03.index("あめ")
index_candy_01 = tokens_candy_01.index("あめ")
index_candy_02 = tokens_candy_02.index("あめ")
index_candy_03 = tokens_candy_03.index("あめ")

# https://allennlp.org/elmo の日本語モデルの重みとoptionファイルをDLしておく
# pathは各自変えて下さい
options_file = "./elmo/allennlp/jp/options.json"
weight_file = "./elmo/allennlp/jp/allennlp_elmo_jp.hdf5"

elmo = Elmo(options_file=options_file, 
            weight_file=weight_file, 
            num_output_representations=1)

# indexを表示
print(index_rainy_01, index_rainy_02, index_rainy_03, 
      index_candy_01, index_candy_02, index_candy_03)
出力
5 4 7 3 0 1
  • http://docs.allennlp.org/0.9.0/api/allennlp.modules.elmo.html
  • リファレンスを見ながら進めます。
  • まず、batch_to_ids(List[List[str]])でtorch.Tensor型に変換します。
  • それをelmoインスタンスに与えれば、辞書を返します。
  • key:'elmo_representations'に分散表現が格納されています。
elmo.ipynb
# tokens_rainy_01中の「あめ」の分散表現を得る
print("tokens_rainy_01 : ",tokens_rainy_01)
print("-"*30)

print("batch_to_idsの戻り値は{}型".format(type(batch_to_ids([tokens_rainy_01]))))
print("batch_to_idsの戻り値の形状は{}".format(batch_to_ids([tokens_rainy_01]).shape))
print("-"*30)

print("elmo(batch_to_ids)の戻り値は{}型".format(type(elmo(batch_to_ids([tokens_rainy_01])))))
print("elmo(batch_to_ids)のkeysは{}".format(elmo(batch_to_ids([tokens_rainy_01])).keys()))
print("-"*30)

print("elmo(batch_to_ids)['elmo_representations']は{}型".format(type(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"])))
print("elmo(batch_to_ids)['elmo_representations']の形状は{}".format(len(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"])))
print("-"*30)

print("elmo(batch_to_ids)['elmo_representations'][0]は{}型".format(type(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0])))
print("elmo(batch_to_ids)['elmo_representations'][0]の形状は{}".format(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0].shape))
print("-"*30)

print("「あめ」の分散表現は{}".format(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_rainy_01]))
print("「あめ」の分散表現の型は{}".format(type(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_rainy_01])))
print("「あめ」の分散表現の形状は{}".format(elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_rainy_01].shape))
出力
tokens_rainy_01 :  ['明日', 'の', '天気', '予報', 'は', 'あめ', 'だ', '。']
------------------------------
batch_to_idsの戻り値は<class 'torch.Tensor'>型
batch_to_idsの戻り値の形状はtorch.Size([1, 8, 50])
------------------------------
elmo(batch_to_ids)の戻り値は<class 'dict'>型
elmo(batch_to_ids)のkeysはdict_keys(['elmo_representations', 'mask'])
------------------------------
elmo(batch_to_ids)['elmo_representations']は<class 'list'>型
elmo(batch_to_ids)['elmo_representations']の形状は1
------------------------------
elmo(batch_to_ids)['elmo_representations'][0]は<class 'torch.Tensor'>型
elmo(batch_to_ids)['elmo_representations'][0]の形状はtorch.Size([1, 8, 1024])
------------------------------
「あめ」の分散表現はtensor([-1.4709, -0.0000,  0.1043,  ...,  0.0000, -0.0622,  0.0000],
       grad_fn=<SelectBackward>)
「あめ」の分散表現の型は<class 'torch.Tensor'>
「あめ」の分散表現の形状はtorch.Size([1024])
  • options.jsonに「"projection_dim": 512」と有りますが、1024次元のベクトルが得られています。
  • これは上で述べた通り、双方向のLSTMで得られた512次元のベクトルをconcatしているためです。
  • では、他の「あめ」の分散表現も獲得する処理を行います。
elmo.ipynb
vec_rainy_01 = elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_rainy_01]
vec_rainy_02 = elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_rainy_02]
vec_rainy_03 = elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_rainy_03]
vec_candy_01 = elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_candy_01]
vec_candy_02 = elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_candy_02]
vec_candy_03 = elmo(batch_to_ids([tokens_rainy_01]))["elmo_representations"][0][0][index_candy_03]
  • cos類似度を算出する関数を作ります。
  • その際、np.linalg.norm()でノルムを計算するため、分散表現をTensor型からnp.ndarray型に変更します。
elmo.ipynb
import numpy as np
def cos_sim(x,y):

    x = x.detach().numpy()
    y = y.detach().numpy()

    x_l2_norm = np.linalg.norm(x, ord=2)
    y_l2_norm = np.linalg.norm(y, ord=2)
    xy = np.dot(x,y)

    return xy / (x_l2_norm * y_l2_norm)
  • 6つのベクトル全てについて、お互いのcos類似度を計算します。
elmo.ipynb
rainy_id = 0
candy_id = 0

for rainy_vec in rainy_list:
    rainy_id += 1

    for candy_vec in candy_list:
        candy_id += 1
        print("doc_rainy_{:0=2} and doc_candy_{:0=2} : 「あめ」 の cos類似度 : {:.2f}".format(rainy_id,candy_id, cos_sim(rainy_vec, candy_vec)))
    candy_id = 0
    print("-"*30)

print("doc_rainy_01 and doc_rainy_02 : 「あめ」 の cos類似度 : {:.2f}".format(cos_sim(vec_rainy_01, vec_rainy_02)))
print("doc_rainy_01 and doc_rainy_03 : 「あめ」 の cos類似度 : {:.2f}".format(cos_sim(vec_rainy_01, vec_rainy_03)))
print("doc_rainy_02 and doc_rainy_03 : 「あめ」 の cos類似度 : {:.2f}".format(cos_sim(vec_rainy_02, vec_rainy_03)))
print("-"*30)

print("doc_candy_01 and doc_candy_02 : 「あめ」 の cos類似度 : {:.2f}".format(cos_sim(vec_candy_01, vec_candy_02)))
print("doc_candy_01 and doc_candy_03 : 「あめ」 の cos類似度 : {:.2f}".format(cos_sim(vec_candy_01, vec_candy_03)))
print("doc_candy_02 and doc_candy_03 : 「あめ」 の cos類似度 : {:.2f}".format(cos_sim(vec_candy_02, vec_candy_03)))
出力
doc_rainy_01 and doc_candy_01 : 「あめ」 の cos類似度 : 0.11
doc_rainy_01 and doc_candy_02 : 「あめ」 の cos類似度 : 0.11
doc_rainy_01 and doc_candy_03 : 「あめ」 の cos類似度 : 0.09
------------------------------
doc_rainy_02 and doc_candy_01 : 「あめ」 の cos類似度 : 0.14
doc_rainy_02 and doc_candy_02 : 「あめ」 の cos類似度 : 0.13
doc_rainy_02 and doc_candy_03 : 「あめ」 の cos類似度 : 0.19
------------------------------
doc_rainy_03 and doc_candy_01 : 「あめ」 の cos類似度 : 0.11
doc_rainy_03 and doc_candy_02 : 「あめ」 の cos類似度 : 0.04
doc_rainy_03 and doc_candy_03 : 「あめ」 の cos類似度 : 0.07
------------------------------
doc_rainy_01 and doc_rainy_02 : 「あめ」 の cos類似度 : 0.20
doc_rainy_01 and doc_rainy_03 : 「あめ」 の cos類似度 : 0.17
doc_rainy_02 and doc_rainy_03 : 「あめ」 の cos類似度 : 0.14
------------------------------
doc_candy_01 and doc_candy_02 : 「あめ」 の cos類似度 : 0.14
doc_candy_01 and doc_candy_03 : 「あめ」 の cos類似度 : 0.09
doc_candy_02 and doc_candy_03 : 「あめ」 の cos類似度 : 0.08
  • 「雨」と「飴」のcos類似度の平均は0.11,「雨と雨」「飴と飴」の平均は0.14でした。
  • 少ないコーパスでも、雨と飴の意味をエンコード出来ています!
  • (正確に言うと、「飴と飴」のcos類似度平均は0.10なので0.11を下回ってしまっています…しかし文の長さを増やして「飴」に関係のある単語が含まれるようにすれば、きちんとcos類似度は上がっていきます)

  • 以下、ELMoの論文を読んで面白いな〜と思った点です。
    • deep-biLM(多層双方向LSTM)において、低位レイヤには構文情報が、高位レイヤには意味情報がエンコードされがち
    • だからTop layerしか使わないTagLMやCoVeより、中間層も使うELMoの方が(構文情報も取り込めるので)精度が出る
    • ELMoレイヤをinputで噛ませる(word embeddingとして使う)だけでなく、outputにも噛ませることで大概のタスクでは性能がちょっと上がるけど、SRL(Semantic role labeling=「誰が誰に何をした」を答える)だけは性能が落ちた
    • これはSRLにおいてはpretrainで得られたコンテキストの表現より、タスク固有のコンテキストの表現のほうが重要な事に起因すると考えられる
    • biLMの出力の分布が層によって違うのでLayer normはマジで大事

分散表現③ : BERT

  • BERT(Devlin et al., 2019)は12層又は24層双方向Transformerを用いた言語モデルです。
  • 名前の由来はBidirectional Encoder Representations from Transformersの頭文字です。
  • まずELMoとBERTは本質的に大きな違いがあります。
    • それは「分散表現抽出器」か「分散表現抽出にも使える様々なタスクの事前学習器」かという違いです。
    • BERTの論文では、それぞれ「feature-based approach」「fine-tuning approach」と呼ばれています。
  • ELMoは分散表現を得るためのモデルでした。
  • よって、その分散表現を用いて何かタスクを行うためには、専用のアーキテクチャが必要になります。
  • 一方BERTは、BERTだけで下流タスクも完結可能という点が大きく異なります。
  • 本記事は「単語の分散表現」のまとめなので、BERTで下流タスクを行う所までは踏み込みません。
  • ELMoでは双方向LSTMを用いましたが、BERTでは双方向Transformerを用いています。
    • Transformerを簡単に紹介します。
    • TransformerはPosition EncoderとSelf-Attentionという仕組みを備えています。
    • Position Encoderのおかげで、RNNの様な逐次的学習を並列計算することが可能です。
    • またSelf-Attentionのおかげで、入力のどこを注目すべきか、自動で認識させることが可能です。
    • Transformerを用いる事で以下のような効果があります。
      • 入力シーケンスの中で離れているTokenの相互関係を考慮できる。
      • 逐次計算を並列化することで学習高速化できる。
      • その代わり、メモリを大量に消費する。
    • 分かりやすく言うとTransformerは「並列に計算できるRNN」「離れた所も畳み込めるCNN」の様なものです。

  • ELMoは単語の分散表現を「3つのベクトルの加重平均」で表しました。
  • BERTを「分散表現抽出のためだけ」に使うことはあまり無いので、決まったベクトルの統合の仕方はありません。
  • 論文では、以下のような統合方法が検討されています。(本実験は12層768次元のBASEモデルを用いています)
    • 1層目のhidden layer
    • 2〜12層目のhidden layer
    • 12層のhidden layer
    • 9〜12層目のhidden layerの加重平均
    • 9〜12層目のhidden layerを1ベクトルにconcat
    • 1〜12層目のhidden layerの加重平均
  • NER(固有表現認識)タスクにおいて、9〜12層目のhidden layerを1ベクトルにconcatし、双方向LSTMに入力する方法が、上記の6手法の中では最も高いパフォーマンスを発揮しました。(全て僅差でした)
  • ただし、タスクそのものもBERTで解いた方が高いパフォーマンスが出ました。

  • 「何かの固有タスクを解くモデルに入力するために分散表現を獲得しよう」というモチベーションだったのが、「なんでもできるモデルで、分散表現も獲得できる」という結果になってしまいました。
  • ではBERT以外の分散表現抽出器は無用になってしまったのかというと、そうではありません。
    • まず、Transformerは全ての下流タスクにおいて、他のモデルを上回っている訳ではありません。
    • また、Transformerの学習には大量のメモリが必要のため、エッジデバイスで学習出来ません。
      • その対策として重み共有を行いメモリ消費を抑えたALBERTが登場しました。
    • また、BERTでpretrainを行う時に、同じTokenを何度も入力する必要があるため、一度分散表現を獲得しておく事は学習高速化に繋がります。
  • 詳細は「5.3 Feature-based Approach with BERT」のTable.7を参照して下さい。

まとめ

  • 自然言語をベクトルに表現する手法として、One-hot encode, word2vec, ELMo, BERTを紹介しました。
  • word2vec, ELMo, BERTで得られる低次元のベクトルは単語の分散表現と呼ばれます。
  • word2vecで得られた分散表現は意味を表現可能です。
  • ELMo, BERTで得られた分散表現は、それに加えて文脈を表現可能です。
  • ELMoは双方向LSTMを用いて分散表現を複数得て、その加重平均を下流タスクで最適化します。
  • BERTは双方向Transformerを用いて下流タスクごと解き、その過程で分散表現を獲得することが出来ます。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away