CCCマーケティング TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

「Transformers」を使って自然言語処理を試したり「やばい」実験をした話。

こんにちは、技術開発ユニットの三浦です。

「続けること」を増やすことをこの頃意識しています。なるべく毎日続けられる小さな習慣を、少しずつ増やしていきたいなぁと。今は短い英語の文章を、声に出してノートに書き写すことを習慣化しようとしています。

さて、前回深層学習のモデル「Transformer」について調べたことをまとめました。

techblog.cccmk.co.jp

今回はこのTransformerを実際に使ってみたい、ということで、Transformerベースの色々なモデルを使うことが出来るライブラリ「Transformers」を調べて使ってみた話をご紹介させていただきます。

Transformers

Transformersはhuggingfaceが公開している機械学習、特に自然言語処理を主とした深層学習向けのライブラリです。

huggingface.co

2022年2月現在、バックエンドにJax, PyTorch, TensorFlowの3つを選ぶことが出来、サポートしているモデルのアーキテクチャは95個あります。それらのアーキテクチャに対して自分で用意したデータを流してパラメータを学習することも出来ますが、学習済みのモデルがmodel hubで公開されており、それをダウンロードして自然言語処理のタスクに利用することも出来ます。

model hubには日本語データで学習したモデルもいくつか公開されています。モデル毎に対応するバックエンドのフレームワークは異なりますが、ざっと見た感じ、PyTorchに対応しているものが多い印象です。

またmodel hubにはモデルだけでなく、そのモデルにテキストを入力する前に必要となるトークン化を行うトークナイザも合わせて公開されているため、対象のタスクに対応したモデルをmodel hubで探し、トークナイザと一緒にダウンロード、という流れで事前学習済みのモデルを使用することが出来ます。そして「前処理→モデルへの入力→出力結果を加工し結果を取得」といった一連の手続きをまとめたpipelineというクラスがタスクごとに提供されています。

実際にいくつか試してみたので、コードと一緒にTransformersの簡単な使い方を見てみたいと思います。

インストール

Transformersはpipコマンドでインストールすることが出来ます。さらに以下のように実行することで日本語データを扱うために必要なライブラリを合わせてインストールすることが出来ます。以降のコードはすべてGoogle Colaboratoryで実行しています。

!pip install transformers[ja]

また、一部のtokenizerではsentencepieceというライブラリを使用するため、こちらもインストールしておきます。

!pip install sentencepiece

モデルを試してみる

これでセットアップは完了したので、タスクに対応したモデルをいくつか試してみようと思います。

TextClassification

TextClassificationは与えられたテキストをクラス分類するタスクです。sentiment-analysisはそのうち特に与えられたテキストが肯定的か否定的を判定するタスクです。まずはpipelineを使って実装してみます。

from transformers import pipeline

classifier_pipeline = pipeline(
      'sentiment-analysis',
      model='daigo/bert-base-japanese-sentiment',
      tokenizer='daigo/bert-base-japanese-sentiment'
      )

texts = [
         'このお菓子、とっても美味しいよ',
         'この映画、あんまり面白くなかったな',
         '今日はとってもいい天気ですね'
        ]
for i, output in enumerate(classifier_pipeline(texts)):
  print(f'{texts[i]} :判定 {output["label"]} , score: {output["score"]}')

実行結果は以下のようになりました。

このお菓子、とっても美味しいよ :判定 ポジティブ , score: 0.9808868169784546
この映画、あんまり面白くなかったな :判定 ネガティブ , score: 0.9892918467521667
今日はとってもいい天気ですね :判定 ポジティブ , score: 0.9465286135673523

3つのテキストに対して正しく肯定・否定の判定が出来ているようです。pipelineを使うと本当に短いコードで処理を書くことが出来ました。

pipelineの最初の引数でタスク名を指定し、そのタスク用のpipeline(TextClassificationPipeline)オブジェクトが生成されます。modeltokenizerの引数に使用するモデル名を指定します。ここで使用しているモデルはmodel hubに公開されているBERTの学習済みモデルです。

huggingface.co

次にpipelineを使わずに同じような処理を書いてみます。

from transformers import AutoTokenizer, AutoModelForSequenceClassification
from torch import nn, argmax

model_name = 'daigo/bert-base-japanese-sentiment'
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name)

texts = [
         'このお菓子、とっても美味しいよ',
         'この映画、あんまり面白くなかったな',
         '今日はとってもいい天気ですね'
        ]

#トークン化
input_data = tokenizer(texts, padding=True, return_tensors='pt')
output_data = model(**input_data)

#結果の表示
labels = ['ポジティブ','ネガティブ']

for i, output in enumerate(output_data[0]):
  score = nn.functional.softmax(output,dim=-1)
  index = argmax(nn.functional.softmax(output, dim=-1))
  print(f'{texts[i]} :判定 {labels[index]} , score: {score[index]}')

コードが長くなりますが、結果はpipelineを使ったものと同じになります。

このお菓子、とっても美味しいよ :判定 ポジティブ , score: 0.9808868169784546
この映画、あんまり面白くなかったな :判定 ネガティブ , score: 0.9892918467521667
今日はとってもいい天気ですね :判定 ポジティブ , score: 0.9465286135673523

上のコードでやっていることを簡単にまとめます。まずtokenizermodelをモデル名を指定して生成します。ここで必要に応じてモデルの学習済みのパラメータなどがダウンロードされます。tokenizerにテキストを入力すると、トークン化されたテキストなどのmodel入力用のデータを取得することが出来ます。

tokenizer(texts, padding=True, return_tensors='pt')

結果

{
'input_ids': tensor([[    2,    70, 25154,     6,  3952, 28456, 28480, 18178,   485,    54,
             3,     0],
        [    2,    70,   450,     6,  9039,   789,  9727, 28504,   316,    10,
            18,     3],
        [    2,  3246,     9,  3952, 28456, 28480,  2575, 11385,  2992,  1852,
             3,     0]]), 
'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]), 
'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]])
}

input_idsがトークン化されたテキストで、token_type_idsが今回は全ての要素が0のTensorですが、質疑応答のタスクなどで質問文・応答文をモデルに入力する時にどこまでが質問文でどこからが応答文なのかをモデルに区別させるために使用されます。attention_maskはAttention層でAttentionの対象にするトークンを区別するために使用します。長さを揃えるために末尾に追加されたトークンがこれでAttention対象外になります。

modelの出力結果はlogitsになっているので、PyTorchのnn.functional.softmaxで確率値に変換し、さらにargmaxを使ってラベル化しています。

TextGeneration

TextGenerationは入力されたテキストに続く自然なテキストを生成するタスクです。学習済みのモデルはrinna株式会社様が公開している「rinna/japanese-gpt2-medium」というモデルを使用しました。

huggingface.co

また、テキスト生成の処理は以下のブログを参考にしました。

huggingface.co

以下のコードを実行すると、「今日は休日だから」に続くテキストが3つ、モデルによって生成されます。

from transformers import T5Tokenizer, AutoModelForCausalLM

model_name = 'rinna/japanese-gpt2-medium'

tokenizer = T5Tokenizer.from_pretrained(model_name)
tokenizer.do_lower_case = True  # due to some bug of tokenizer config loading
model = AutoModelForCausalLM.from_pretrained(model_name)

input_data = tokenizer('今日は休日だから', padding=True, return_tensors='pt')
prediction = model.generate(**input_data,
                            pad_token_id=tokenizer.eos_token_id,
                            do_sample=True, 
                            max_length=50, 
                            top_k=50, 
                            top_p=0.95, 
                            num_return_sequences=3)

for p in prediction:
  print(tokenizer.decode(p))

出力結果は以下のようになりました。

今日は休日だから</s> 今日はお休みでしょう。お子様 遊んでいらっしゃいますか。 私 今日はお休みですので、お出かけしたり ゆっくり過ごしています。 おはようございます。
今日は休日だから</s> 何かあったら すぐに駆けつけられるようにと 午前中から 自宅に閉じこもっている 私 は ちょっと 心配になり、少しだけ出かけて行った どうしよう ちょっとでも おかしい
今日は休日だから</s> なんだか気が抜けてしまって ブログを更新してしまうかもしれません。 そうか、私はこのブログをいつもチェックして ちょこちょこと更新をチェックして 更新があると このブログ

ちょっと不思議な雰囲気が漂っていますが、自然な文章が生成されました。

実験

ここからは以前から気になっていたことを試してみます。それは同じ「単語」が文脈によって意味が変わることを可視化したい、ということです。例えば「やばい」という言葉。これは使われ方によって色々な意味を持つ言葉の一つではないでしょうか。前回見たようにTransformerのAttention層を通過することでトークンベクトルがテキストの潜在的な意味を含んだベクトルに変化していく可能性があることが分かりました。日本語のコーパスで事前学習済みのモデルに「やばい」を含む色々なテキストを入力することで、色々な「やばい」ベクトルを獲得することが出来るのではないでしょうか。それを確かめてみようと思います。

テキストを用意する

「やばい」を含むテキストを色々考えてみました。普段何気なく口から出てしまう言葉ですが、いざテキストを考えるとなかなか出てこないものです。途中から子供にも考えてもらいました。

f:id:miu4930:20220221210654p:plain
考えた「やばい」を含むテキスト

ベクトル出力用のモデル

先程のテキスト生成と同じくrinna株式会社様が公開しているモデルを使用しました。テキスト生成で見たように、モデルがかなり話し言葉に近い言語の特徴を理解しているように感じ、今回の検証に適していると考えたためです。ただしテキスト生成とは異なり、入力テキストのエンコーディングが目的なので、GPTベース(Transformer Decoder)ではなくBERTベース(Transformer Encoder)のものを選択しました。

huggingface.co

tokenizermodelをロードします。

from transformers import T5Tokenizer, RobertaForMaskedLM

tokenizer = T5Tokenizer.from_pretrained("rinna/japanese-roberta-base")
tokenizer.do_lower_case = True  # due to some bug of tokenizer config loading

model = RobertaForMaskedLM.from_pretrained("rinna/japanese-roberta-base")

「やばい」に該当するトークンを見つける

入力するテキストがどのようにトークン化されるのかを見てみます。

print(tokenizer.tokenize('そろそろやらないと明日やばいかもしれない'))
print(tokenizer('そろそろやらないと明日やばいかもしれない').input_ids)

出力結果

['▁', 'そ', 'ろ', 'そ', 'ろ', 'や', 'らない', 'と', '明日', 'や', 'ばい', 'かもしれない']
[9, 1010, 1406, 1010, 1406, 26, 3407, 20, 14787, 26, 21431, 6254, 2]

テキストをトークンIDにエンコードすると、先頭と末尾に特殊なトークンID(9, 2)が振られるようです。また、「やばい」という単語はこのトークン化の方法だと「や」「ばい」に分割されることが分かりました。「やばい」に該当するベクトルを取得するために、連続する「や」「ばい」に該当するベクトルを取得し、その平均を取ることにしました。

各テキストの「や」「ばい」が連続して出現する場所のインデックスを最初に控えておきます。

input_data = tokenizer(yabai_texts, padding=True, return_tensors='pt')
target = tokenizer('やばい').input_ids[1:-1]

yabai_index = []

for token_text in input_data.input_ids:
  i = 0
  while i < len(token_text):
    #「や」「ばい」トークンが連続して続いたらそのインデックスを取得する
    if (token_text[i] == target[0]) and (token_text[i+1] == target[1]):
      yabai_index.append([i, i+1])
    i += 1 

#テスト
for i, ps in enumerate(yabai_index):
  print([input_data.input_ids[i][p] for p in ps])

出力結果

[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]
[tensor(26), tensor(21431)]

「や」「ばい」のトークンID列[26, 21431]のインデックスを取得出来たようです。

モデルに入力して隠れ層の出力を取得する

入力データの準備が済んだので、モデルに入力して隠れ層の出力を取得します。

import torch
token_tensor = input_data.input_ids

# position idを別途指定する必要がある
position_ids = list(range(0, token_tensor.size(1)))
position_id_tensor = torch.LongTensor([position_ids])

#隠れ層を出力するようにする
model.config.output_hidden_states = True

#モデルにトークン化したテキストを入力
with torch.no_grad():
    outputs = model(input_ids=token_tensor, position_ids=position_id_tensor)

print(len(outputs.hidden_states))

結果

13

長さ13のタプルが得られます。これはモデル入力前の埋め込み層を含む、13個の層からの出力が含まれています。今回は最終層の出力を取ってみます。その形状は以下のようになっていることが分かります。

print(outputs.hidden_states[-1].shape)

結果

torch.Size([9, 14, 768])

入力したテキストの数9とテキストを構成するトークンの数14, それぞれのトークンを表現する768次元のベクトルで構成されているようです。「や」「ばい」に該当する位置のベクトルを抽出し、それを平均したベクトルを取得します。

yabai_vec = []
for i, o in enumerate(outputs.hidden_states[-1]):
  yaba_index = yabai_index[i]
  vec = mean(o[yaba_index,:],0)
  yabai_vec.append(vec.detach().numpy())

結果を可視化する

最後に768次元のベクトルをPCAで2次元に次元削減し、その結果をplotしてみます。

from sklearn.decomposition import PCA
import plotly.graph_objects as go

pca = PCA(n_components=2)
dcp_vec=pca.fit_transform(yabai_vec)

fig = go.Figure()
for i, vec in enumerate(dcp_vec):
  fig.add_trace(go.Scatter(
    x=[vec[0]], 
    y=[vec[1]],
    name=yabai_texts[i],
    mode='markers',
  ))
fig.show()

結果がこちらです。

f:id:miu4930:20220221222630p:plain
「やばい」プロット

想像以上に散らばりました。なんとなくですが、Y軸方向は勢いというか躍動感を表している様に感じました。

f:id:miu4930:20220221224928p:plain
躍動感

そしてX軸の正の方向にはいい意味で「やばい」を使っているテキストが多いかな・・・という印象を受けました。一部例外はありますが。

f:id:miu4930:20220221225542p:plain
いい意味で「やばい」

もっと多くのテキストを入力すると、また違った見え方が出来るかもしれません。

まとめ

今回は自然言語処理の深層学習ライブラリ「Transformers」を使って基本的なタスクを実行してみたり、単語の使われ方をベクトル化して評価してみたことをご紹介しました。特に人がなんとなく感じることを定量的に表す、という取り組みは私が最も興味を持っていることの一つで、この方法が上手く使えるかもしれない、という可能性を感じることが出来ました。今度は自分で集めたデータを使って事前学習モデルをどうやって再学習するのかについて、調べていきたいな・・・と考えています。またまとまったら別の機会でご紹介させていただきます。