文脈化単語表現モデル”ELMo”を動かしてみた

文章の単語推定を、文脈に合わせて行うELMoを動かしてみました!
2022.11.16

皆さん、こんにちは。クルトンです。

今回は、自然言語処理のモデルであるEmbeddings from Language Models(以下、ELMo)を動かしてみました。

実行環境はGoogle ColabでランタイムはCPUです。 GPUを使用する事も可能ですので、そちらのコードも載せておきます。

注意すべき事として、セルがクラッシュする可能性があるため、あとのコードで出てくるdataset変数のデータの使用個数を減らすなど工夫が必要です。

ELMoとは

各単語から馴染みの深い単語を推測するモデルとしてWord2Vecというモデルが存在します。 馴染み深い単語を推定する際に文脈(context)に依存せずに推定を行うのですが、文章によっては意味が通らない場合が存在します。

例えば日本語で、漢字は同じでも意味が全く異なる場合があります。 「黒子」という語は、「くろこ」と読めば舞台をスムーズに進めるためのお助け役ですが、「ほくろ」と読めば人体の黒くできた点の意味になりますよね。

こういった、文脈によっては意味が変わる単語に対応したモデルがELMoになります。

論文中では、以下の画像のように"play"の語を「遊ぶ」と「演劇」へ文脈に即して推定している様子が伺えます。(biLM側をご覧ください。)

ELMoモデルをすでに実装しているOSSがあり、こちらを活用してELMoが文脈に即してEmbeddingをやっている様子を確認してみます。

ELMoを動かす

ELMoを動かしてみます。自然言語で書かれている文章が複数個必要です。

また、次で詳しく説明するのですが、モジュールのインストールの順番で注意が必要です。

必要モジュールをインストール

ELMoを動かすにはELMoが含まれているflairモジュールをインストールする必要があります。

しかしそれだけでは必要なモジュールが足りておらず、内部で使われている次のモジュールもインストールする必要があります。(より正確には、ELMoEmbeddingsクラスを呼び出すときに内部で使われています。)

また、注意点として先にallennlpモジュールからインストールしないとELMo関連のコードの実行に失敗する場合があります。 (importに関係するエラーなので、依存関係に由来するものだと思います。)

先にこちらのallennlpをインストールします。

!pip install allennlp==0.9.0

次にELMo関係のモデルを使うために必要なモジュールをインストールします。

!pip install flair

必要モジュールをimport

Embeddingsに必要となるモジュールを全てimportしておきます。

from flair.embeddings import ELMoEmbeddings
from flair.data import Sentence

import numpy as np
from tqdm import tqdm

import tensorflow_datasets as tfds
import pandas as pd

データセット読み込み

今回使用したデータセットはこちらになります。テキストとそれにどういった感情なのかラベルが割り振られているものになります。

データセットロード

サンプル数などをコード上でも確認するためinfo変数を用意しつつロードします。

tf_dataset, info = tfds.load("goemotions", with_info=True)

データセットの中身を確認

info変数をprintしてください。

print(info)

printした結果が次のように確認できます。

tfds.core.DatasetInfo(
    name='goemotions',
    full_name='goemotions/0.1.0',
    description="""
    The GoEmotions dataset contains 58k carefully curated Reddit comments labeled
    for 27 emotion categories or Neutral. The emotion categories are admiration,
    amusement, anger, annoyance, approval, caring, confusion, curiosity, desire,
    disappointment, disapproval, disgust, embarrassment, excitement, fear,
    gratitude, grief, joy, love, nervousness, optimism, pride, realization, relief,
    remorse, sadness, surprise.
    """,
    homepage='https://github.com/google-research/google-research/tree/master/goemotions',
    data_path='~/tensorflow_datasets/goemotions/0.1.0',
    file_format=tfrecord,
    download_size=4.19 MiB,
    dataset_size=32.25 MiB,
    features=FeaturesDict({
        'admiration': tf.bool,
        'amusement': tf.bool,
        'anger': tf.bool,
        'annoyance': tf.bool,
        'approval': tf.bool,
        'caring': tf.bool,
        'comment_text': Text(shape=(), dtype=tf.string),
        'confusion': tf.bool,
        'curiosity': tf.bool,
        'desire': tf.bool,
        'disappointment': tf.bool,
        'disapproval': tf.bool,
        'disgust': tf.bool,
        'embarrassment': tf.bool,
        'excitement': tf.bool,
        'fear': tf.bool,
        'gratitude': tf.bool,
        'grief': tf.bool,
        'joy': tf.bool,
        'love': tf.bool,
        'nervousness': tf.bool,
        'neutral': tf.bool,
        'optimism': tf.bool,
        'pride': tf.bool,
        'realization': tf.bool,
        'relief': tf.bool,
        'remorse': tf.bool,
        'sadness': tf.bool,
        'surprise': tf.bool,
    }),
    supervised_keys=None,
    disable_shuffling=False,
    splits={
        'test': <SplitInfo num_examples=5427, num_shards=1>,
        'train': <SplitInfo num_examples=43410, num_shards=1>,
        'validation': <SplitInfo num_examples=5426, num_shards=1>,
    },
    citation="""@inproceedings{demszky-2020-goemotions,
        title = "{G}o{E}motions: A Dataset of Fine-Grained Emotions",
        author = "Demszky, Dorottya  and
          Movshovitz-Attias, Dana  and
          Ko, Jeongwoo  and
          Cowen, Alan  and
          Nemade, Gaurav  and
          Ravi, Sujith",
        booktitle = "Proceedings of the 58th Annual Meeting of the Association for Computational Linguistics",
        month = jul,
        year = "2020",
        address = "Online",
        publisher = "Association for Computational Linguistics",
        url = "https://www.aclweb.org/anthology/2020.acl-main.372",
        pages = "4040--4054",
    }""",
)

features=FeaturesDict({の部分を確認すると、感情に対してbool値が割り振られているデータセットになっています。今回はELMoに渡すためのテキスト(comment_textのみ)が欲しいだけなので使わないですが、感情判定のモデルを学習させるのに使えそうなデータセットですね。

またsplits={の部分をみるとtrainやtestのようにデータが最初から分割されているようです。今回はELMoの動作確認で複数個のtextが使うだけなので、どれを使用しても良いです。今回はtrainを使用します。

データセットの型変換

まず、データをnumpy型にします。

dataset=tfds.as_numpy(tf_dataset['train'])

次にDataFrame型に変換します。ここで変換をする事でstr型以外のものは本来の型に変換されます。(前回のやり方よりも簡単にできますね。)

dataset = pd.DataFrame(dataset)

ここまでのコードでstr型はnumpy.bytes_型となっているのでそれをdecodeします。

今回必要なのは文章のみなので、そちらだけを格納した変数にします。この際にdecodeしてstr型に変換します。

dataset = list( map(lambda x: x.decode('utf-8'),dataset['comment_text']) )

以上で変換終わりです。ここからEmbeddingsをしていきます。

ELMoを使ってEmbeddings実行

ここで実際にEmbeddingsに入ります。Embeddings対象のデータセットの数が多いと、セルがクラッシュしてコード実行のやり直しが発生しますので、注意が必要です。

ELMoのクラスからインスタンス作成

ELMoEmbeddingsクラスのインスタンスを作成します。

embedding = ELMoEmbeddings()

Sentence型を渡す必要があるので変換していきます。

Embeddings前の準備としてSentence型に変換

Embeddingsを行うには、Sentence型を渡す必要があるため型変換をします。 型を変換する時に使用する文章の量をここで減らしています。理由としては、学習に時間が掛かるという事もあるのですが、1番の理由はRAMの使用量が限界を超えてセルのクラッシュが発生する可能性が出てくるからです。

sentences=[]
for s in tqdm(dataset[:1000]):
  sentences.append(Sentence(s))

今回は文章を1000個使うようにしました。CPU環境では9分ほどで学習が終わっています。

試していて分かった事ですが、全てのデータを使おうとするとこれ以降のコードを実行できるほどRAMが残らないです。また、10000個ほどに抑えた場合、GPUであれば学習が5分ほどで終わったのですが、最後の方のグラフに表示するためのコードでRAMを使いすぎてセルがクラッシュします。

Embeddings実行

ここでEmbeddingsを実行していきます。

for idx in tqdm(range(len(sentences))):
  embedding.embed(sentences[idx])

これでEmbeddingsが終わり、文脈を考慮した単語ベクトルの作成が出来ました。しかし、本当に作成できているのか気になったのでグラフにして確認していきます。

UMAPを使ってEmbeddingsが上手くいっているか可視化してみる

ここまでのコードを実行した結果、文脈を考慮しEmbeddingsが出来ているか、確認していきます。 そのままの次元数だとグラフには出来ないため、UMAPを使って次元圧縮後、散布図の形で2次元プロットしていきます。

より具体的には、UMAPのfitにこれまでEmbeddingsをして作った単語ベクトルを使用し、fitしたUMAPと簡単な文章を用いて2次元圧縮し文脈考慮できている確認のため、散布図を図示します。

今回GPUを使用している方は、CPU版の方をコメントアウトして、GPU版の方のコメントアウトを外してください。

UMAPについて詳しく内容をお知りになりたい方はこちらの論文をお読みください。

umapインストールと必要モジュールをimport

!pip install umap-learn
import umap
from scipy.sparse.csgraph import connected_components
import matplotlib.pyplot as plt

umapインスタンスの作成

UMAPインスタンスを作成するとき、圧縮したい次元に合わせて引数n_componentsの数を設定します。今回は散布図にしようと考えているので、2次元に圧縮します。

umap = umap.UMAP(n_components=2)

単語毎のEmbeddingsのベクトルに分ける

単語毎のEmbeddingsのベクトルをword_embeddings変数に格納しています。散布図で言うところの一つの点に該当するデータの形にします。

# GPUの場合
#sentence_embeddings = [np.asarray([list(word.embedding.to('cpu').detach().numpy().copy()) for word in sentence]) for sentence in sentences] # 文章毎のEmbeddings

# CPUの場合
sentence_embeddings = [np.asarray([list(word.embedding) for word in sentence]) for sentence in sentences] # 文章毎のEmbeddings

word_embeddings = np.concatenate(sentence_embeddings, axis=0) # 単語毎のEmbeddings

念のため、変換できているか確認してみます。

print(np.shape(word_embeddings))

(15075, 3072)という出力でした。これは(1000個の文章で)15075個の単語があり、それぞれが3072次元の数値によって座標が確定するという意味です。この3072次元をUMAPの学習に使います。

UMAPの学習

次の1行で終わります。学習には1分ほど掛かります。

umap.fit(word_embeddings)

簡単な文章を作ってEmbeddings実行

ELMoの論文中でも使われていたように"play"を用いた文章を3つ作成します。test_sentence1変数では「遊ぶ」という意味で、残り2つの文は「演じる」という意味です。

# umap.transform用
test_sentence1 = Sentence('John plays on garden .') # 意味が違う場合において比べる
test_sentence2 = Sentence('John plays the hero .') # 基準の文章
test_sentence3 = Sentence('Smith plays the hero .')  # 主語が違う場合において同じ意味の場合比べる

embedding.embed(test_sentence1)
embedding.embed(test_sentence2)
embedding.embed(test_sentence3)

学習したUMAPを使って2次元圧縮

次のように単語毎に2次元圧縮した結果をそれぞれ変数に格納していきます。

# GPU
# reduced_umap_1 = umap.transform( [ np.asarray(list(test_sentence1[i].embedding.to('cpu').detach().numpy().copy())) for i in range(len(test_sentence1)) ] )
# reduced_umap_2 = umap.transform( [ np.asarray(list(test_sentence2[i].embedding.to('cpu').detach().numpy().copy())) for i in range(len(test_sentence2)) ] )
# reduced_umap_3 = umap.transform( [ np.asarray(list(test_sentence3[i].embedding.to('cpu').detach().numpy().copy())) for i in range(len(test_sentence3)) ] )


# CPU
reduced_umap_1 = umap.transform( [ np.asarray(list(test_sentence1[i].embedding)) for i in range(len(test_sentence1)) ] )
reduced_umap_2 = umap.transform( [ np.asarray(list(test_sentence2[i].embedding)) for i in range(len(test_sentence2)) ] )
reduced_umap_3 = umap.transform( [ np.asarray(list(test_sentence3[i].embedding)) for i in range(len(test_sentence3)) ] )

現状のデータの形を確認します。

np.shape(reduced_umap_1)

次のように5つの単語が2次元の形になっている事を確認してください。(単語の数として'.'が含まれています。)

(5, 2)

UMAPで次元圧縮したデータを図示

散布図の形で図示していきます。分かりやすくするため"plays"に該当する点は赤色、それ以外は青色で図示しています。

# 可視化

Figure = plt.figure(figsize=(15, 3))
ax1 = Figure.add_subplot(1,3,1)
ax2 = Figure.add_subplot(1,3,2)
ax3 = Figure.add_subplot(1,3,3)

ax1.scatter(reduced_umap_1[:2, 0], reduced_umap_1[:2, 1],
            color='blue', alpha=0.5)
ax1.scatter(reduced_umap_1[2,0], reduced_umap_1[2, 1],
            color='red', alpha=0.5)
ax1.scatter(reduced_umap_1[3:, 0], reduced_umap_1[3:,1],
            color='blue', alpha=0.5)

ax2.scatter(reduced_umap_2[:2, 0], reduced_umap_2[:2, 1],
            color='blue', alpha=0.5)
ax2.scatter(reduced_umap_2[2,0], reduced_umap_2[2, 1],
            color='red', alpha=0.5)
ax2.scatter(reduced_umap_2[3:, 0], reduced_umap_2[3:,1],
            color='blue', alpha=0.5)

ax3.scatter(reduced_umap_3[:2, 0], reduced_umap_3[:2, 1],
            color='blue', alpha=0.5)
ax3.scatter(reduced_umap_3[2,0], reduced_umap_3[2, 1],
            color='red', alpha=0.5)
ax3.scatter(reduced_umap_3[3:, 0], reduced_umap_3[3:,1],
            color='blue', alpha=0.5)

ax1.set_title("John plays on garden .")
ax2.set_title("John plays the hero .")
ax3.set_title("Smith plays the hero .")

plt.show()

ELMo-and-UMAP-scatter-plotting

赤色の点に注目していただくと、確かに「演じる」という意味合いの点は似た座標に位置しており、「遊ぶ」の意味の赤い点は少し遠くに位置していますね。うまくEmbeddings出来ているようでした。

終わりに

今回はELMoモデルを動かして単語ベクトル作成と、UMAPを使用して散布図作成までを行ないました。

簡単な例文ですが思ったよりもハッキリと違いが出て驚きました。文脈の違いを考慮してくれているので、多くの文章を処理するほど効果が出てきそうですね。

今回は紹介しませんでしたが、有名なモデルにBERTというものがあります。今回動かしたELMoの後継とも言えるようなモデルで、ELMoと同じように文脈を考慮したモデルになっています。もしご興味おありならELMoとBERTの繋がりを調べると良いかもしれません。

今回はここまで。

それでは、また!

参考にしたサイト