こんにちは、DSOC R&Dグループ インターン生の荒居と申します。 今年の1月から自然言語処理のインターン生としてお世話になっています。
インターンでは文書分類のタスクを扱っていたのですが、単語埋め込み を用いるようなディープラーニングベースの手法において、しばしば単語埋め込みのボキャブラリに、扱う文書中の単語が含まれていないという問題(out of vocabulary, OOV)に行き当たりました。
本稿ではOOVとなる単語を減らすために複数の単語埋め込みを用いて単語埋め込みを拡張するという手法を考え、実験してみた結果を紹介させていただきます。
検証に用いたコードなどはGitHubにて公開しております。
OOVについて
OOVとなるような単語にはどのようなものがあるでしょうか?
OOVが発生するのは単語埋め込みを学習する際に、学習に用いたコーパス中に入っていなかった、あるいは出現頻度が低かった単語が存在するからです。したがって珍しい単語、一般的ではない単語がOOVを発生させやすいと言えます。また、学習させるコーパスが違うならばOOVとなる単語も変わるということもあり得るわけです。
具体例を考えてみましょう。ここでは、Wikipediaコーパスで学習したfastText(300d) と 求人データで学習したword2vec(200d) を学習済み単語埋め込みとして用いて、 Livedoorニュースコーパスを例に説明させていただきます。素晴らしいデータをありがとうございます。
Livedoorニュースコーパスにはユニークな単語は何語くらい入っているのでしょうか?
NElogd で分ち書きに直してカウントしてみたところ、およそ9万語のユニークな単語が含まれていることがわかりました。
# NEologdを使う tagger = MeCab.Tagger( "-Ochasen -d /usr/local/lib/mecab/dic/mecab-ipadic-neologd/") def nn_tokenizer(text): # 全角を半角へ text = mojimoji.zen_to_han(text.replace("\n", ""), kana=False) # URL除去 text = re.sub(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-…]+', "", text) parsed = tagger.parse(text).split("\n") parsed = [t.split("\t") for t in parsed] # 空文字とEOSを除く parsed = list(filter(lambda x: x[0] != "" and x[0] != "EOS", parsed)) parsed = [p[2] for p in parsed] return parsed loader = DataLoader("../input/text/") # リポジトリを参照 loader.tokenize(nn_tokenizer) data = loader.data tk = keras.preprocessing.text.Tokenizer(lower=True, filters="") tk.fit_on_texts(data.tokenized.map(lambda x: " ".join(x))) word_idx = tk.word_index print(len(word_idx)) # => 90866
続いて、Livedoorニュースコーパスに含まれる単語の中で、2つの学習済み単語埋め込みに含まれていない単語がないか調べてみましょう。
def load_embedding(path): binary = False if "bin" in path: binary = True emb = KeyedVectors.load_word2vec_format(path, binary=binary) return emb stanby = load_embedding( "/path/to/where/you/put/stanby-jobs-200d-word2vector.bin") # Bizreachの200d word2vec wiki = load_embedding( "/path/to/where/you/put/model.vec") # WikiコーパスのfastText stanby_words = set(stanby.vocab.keys()) wiki_words = set(wiki.vocab.keys()) livedoor_words = set(word_idx.keys()) oov_stanby = livedoor_words - stanby_words oov_wiki = livedoor_words - wiki_words print(len(oov_stanby)) # => 48371 print(len(oov_wiki)) # => 30477
かなりの単語がOOVとなっていることがわかります。実際にはどのような単語が抜けているのでしょうか? 少し単語を眺めてみましょう。
print(oov_stanby) # => { # 'オリバー・ストーン', 'ナショナルスタジアム', '小川純', # '19cm', '証人', '決意表明', '武家', '数え上げる', '富士通グループ', # 'ブロードバンドルータ', 'powert', '商用電源', '聖光学院中学校・高等学校', # ...} print(oov_wiki) # => { # ... # '倉敷市芸文館', '不倫は文化', '3杯目', '首根っこ', 'エコポンパ', # 'happiness!!!', 'tanita', 'ベイベ', # 'appbundle', 'viewカード', 'ヴォルフスブルグ', '狡辛い', # ...}
なかなか特徴的な単語が多いですね。fastText の方には入っていて word2vec には入っていない単語や、その逆の単語はどの程度あるのでしょうか?
in_stanby = oov_wiki - oov_stanby in_wiki = oov_stanby - oov_wiki print(len(in_stanby)) # => 4279 print(len(in_wiki)) # => 22173
今操作してきた単語集合を図で表すとこのようになります。
単語埋め込みの拡張
上の例のように、文書中にある単語が、全て学習済み単語埋め込みに含まれているということはあまりないのが実際かと思います。このような場合、学習済み単語埋め込みにない単語に関しては、固定値や乱数などで置いてしまうという手法がよく取られますが、この操作は精度の低下を招きうることは直感的にも理解できるかと思います。
ここで上の例で示した、2つの単語埋め込みにおいて片方ではOOVになっていたが片方ではOOVになっていなかった単語群の存在に注目します。
ベースとしてどちらかの単語埋め込みを用いながら、OOVになっている単語に関してもう片方の単語埋め込みには含まれているものを使うことでOOVとなる単語を減らすことができないでしょうか?
例えばベースとして fastText を用いて、追加で word2vec と Livedoorニュースコーパスの積集合を使う場合は以下の図のようになります。
このアイデアには1つ問題があります。
ベースとなる単語埋め込みのベクトル表現と、追加分の単語埋め込みのベクトル表現は通常性質が異なるためそのまま輸入してくることはできません。わかりやすい例で言えば、今回用いている fastText は300次元ベクトルですが、word2vec は200次元ベクトルです。
次元の違いも一例ですが、一般に同じ次元の単語埋め込みでも得られている埋め込みベクトル空間は異なります。
ベクトル空間の間の写像
そこで、2つのベクトル空間の間の写像を考えます。
今、2つの単語埋め込みに含まれる単語を考えたときに、どちらにも含まれる単語が存在します。この単語集合はそれぞれの単語ベクトル空間である程度同じような構造をなしていることが期待されます。 つまり、2つの単語埋め込み中の単語集合の積集合は、そのベクトル表現が同じような幾何的関係をなしているはずである、という仮説です。
これに、ベクトル空間内での各単語の幾何的配置が積集合の部分と同じような構造をなしている、という仮定1を置くと、この「両方に含まれる単語」のベクトル表現の間の写像を考えることで2つのベクトル空間の間の写像を得ることができないでしょうか?
実験1: ベクトル空間の変換を獲得する
このアイデアを試してみましょう。
この場合は写像として線形写像が良さそう2です。
早速実装してみましょう。フレームワークがいらないほど簡単なモデルですが、PyTorchを使って楽をします。
import numpy as np import torch import torch.nn as nn import torch.optim as optim import torch.utils.data as data class VectorTransformer(nn.Module): def __init__(self, source_dim, target_dim): super(VectorTransformer, self).__init__() self.mat = nn.Linear(source_dim, target_dim) def forward(self, x): out = self.mat(x) return out
続いて学習データを用意します。
def create_loader(vocab, source_emb, target_emb): n_vec = len(vocab) source_dim = source_emb.vector_size target_dim = target_emb.vector_size x = np.zeros((n_vec, source_dim)) y = np.zeros((n_vec, target_dim)) for i, key in enumerate(vocab): source_vec = source_emb.get_vector(key) target_vec = target_emb.get_vector(key) x[i, :] = source_vec y[i, :] = target_vec x = torch.tensor(x, dtype=torch.float32).to("cpu") y = torch.tensor(y, dtype=torch.float32).to("cpu") dataset = data.TensorDataset(x, y) loader = data.DataLoader(dataset, batch_size=32, shuffle=True) return loader intersection = stanby_words.intersection(wiki_words) dataloader = create_loader(intersection, stanby, wiki)
学習フェーズです。Loss関数は平均二乗誤差/ を使いました。
model = VectorTransformer(stanby.vector_size, wiki.vector_size) model.to("cpu") optimizer = optim.Adam(model.parameters()) loss_fn = nn.MSELoss() # loss_fn = nn.CosineEmbeddingLoss() for _ in range(3): model.train() avg_loss = 0. for (x_batch, y_batch) in dataloader: y_pred = model(x_batch) # dummy = torch.ones((y_batch.size(0),)) loss = loss_fn(y_pred, y_batch) # loss = loss_fn(y_pred, y_batch. dummy) optimizer.zero_grad() loss.backward() optimizer.step() avg_loss += loss.item() / len(dataloader)
上の例ではMSELoss
で3エポックほど回しましたが、実際には2エポックで収束してしまいました。intersecrtion
は92,000語ほどですが学習自体は数秒で終わります。
一方、CosineEmbeddingLoss
を用いると収束にはそれなりのエポック数が必要でした。
検証
さて、2つの単語埋め込みの間の写像を学習することができたのでどの程度いいベクトル表現が得られているか、定性的にではありますが確認してみましょう。
一旦は Livedoor コーパスのことは忘れて、Wiki コーパスで学習した fastText には含まれていないがビズリーチの stanby コーパスで学習した word2vec には含まれている単語が fastText 側のベクトル空間内でどのように表現されているか確かめてみます。
stanby_only_words = stanby_words - intersection def find_similar_words(word): emb = stanby.get_vector(word) tensor = torch.tensor(emb, dtype=torch.float32).to("cpu") pred = model(tensor).detach().numpy() similar = wiki.similar_by_vector(pred) pprint.pprint(f"word: {word}") pprint.pprint(f"similar: {similar}")
見ていきましょう。まずは、いい例から貼っていきます。
なかなかいいのではないでしょうか? 続いてうまくいっていない例を貼っていきます。
1件目に関しては、両方の単語埋め込みがほとんど日本語で学習されているため、英語の埋め込みに関してはいいベクトル表現が得られていないことに由来するかと思います。
2件目は医療関連サービス関連の単語が出て欲しかったのですが金融関連の単語がたくさん出ていますね。
3件目は残念ながらなぜこうなったのかあまり説明がつけられなさそうです。
先の「2つの単語埋め込み中の単語集合の積集合は、そのベクトル表現が同じような幾何的関係をなしているはずである」という仮説に関していえば全くの誤りではなさそうです。
実験2: サンプリングしてから線形変換する
このままだと少し尻切れとんぼな雰囲気がありますので、先の実験においてうまくいかなかった場合について少し考察してみましょう。
まず、英単語に関してですがこれは大人しく諦めた方が良さそうです。というのも両方のベクトル空間においてあまり学習がうまく進んでいない単語であると考えられるので、ベクトル表現間での写像も「英単語」から「英単語」への写像くらいにしかなっていなさそうです。
この、「あまりうまく学習が進んでいない単語」に関してもう少し考えると、2つの単語埋め込みの積集合の中でもあまりうまく学習が進んでいない単語が紛れ込んでいる可能性は大いにある訳ですが、 このような単語は学習データとしては不適切であるように思えます。あまりうまく学習が進んでいないということは「真のベクトル表現」という理想的な点がベクトル空間中にあったとしてそこから大きくずれてしまっているのでノイズとなってしまいます。
逆に学習がうまく成功するようにするならば、2つの埋め込み表現においてその単語埋め込みを作成する段階で学習がうまく進んだ単語を選んであげる必要があります。
残念ながら、埋め込み表現を得る段階で学習がうまく進んでいたかどうかという点に関しては知るすべがありませんが、このような単語の存在を考えると次のような対処が効きそうな気がします。
すなわち、2つの単語埋め込みの積集合を全て学習データとして用いるのではなく、サンプリングした部分集合を学習データとして用いてモデルを複数用意し、最後にその結果の平均をとる バギング は有効であるという仮説がたちます。
実際にやってみましょう3。
def create_random_loader(vocab, source_emb, target_emb, sample_size=0.6): n_vec = int(sample_size * len(vocab)) sample_vocab = np.random.choice(list(vocab), size=n_vec, replace=False) (以下略)
データローダをサンプリングするものに変え、データローダのリストを受け取ってそれぞれについてモデルを学習するアンサンブルモデルを作成しました。
この結果は次のようになりました。
残念ながらパッとみた感じでは大きな変化はなさそうです。
結果を検証するにはもう少し定量的な指標を用いて全体をみた方が良さそうですが、だいぶ長くなってしまったので今回はここまでにしましょう。
まとめ
今回は非常に単純なモデルで、ある単語埋め込みから別の単語埋め込みにベクトル表現を埋め込む方法を考案し検証を行いました。この手法をしっかり評価するためには何らかのタスクを解かせてみるなどして定量的にみる必要がありますが、定性的にはある程度評価できるかと思います。
初めの例ではOOVとなる単語の補填という使い方を提案していますが、使いようによっては時代の流れとともに変遷していくような単語を更新したり、逆に2つの単語埋め込みの違いの度合いを測ったりと色々な使い方が考えられると思います。
次回はこの手法を実際のタスクを解く際に利用してみて定量的な評価ができないか検討しようと思います。
-
数学的にどういうべきなのかパッとは浮かばなかったのでぼんやりとした表現になってしまっていますが実際のところ、これは非常に強い仮定であると考えられます。単語埋め込みを作るのに使ったコーパスの性質が異なれば異なるほどこの仮定は崩れるように思われます。ちゃんと理論的に説明をしたい・・・。↩
-
2つの単語をそれぞれのベクトル空間内で選んでその和演算を考えた時、同じ場所に移っていてほしいというのが線形写像で良さそうというモチベーションです。具体例であれば、よくある話ですが
king - man + woman = queen
の演算が両方の空間で成り立っていてほしいので、ベクトル空間の間の変換をとした時に、という性質があると望ましくこれはまさに線形変換の特徴の1つです。↩ -
正確にはこの例では重複を許さずにサンプリングしていないのでバギングとは言えないです。↩