お手軽に文章ベクトルを比較して入門した

文章を実数値ベクトルとして表現したものを文章ベクトルと呼びます。文脈によっては文章埋め込み文章の分散表現とも呼ばれます。離散的な文章データを機械学習などに応用するにはベクトル表現が必要なことが多く、「どうすれば文章の特徴をよく表現したベクトルが得られるか?」は伝統的に重要なタスクです。

さて近年では、学習済みモデルやライブラリが整備されているおかげで、大規模なコーパスやハイスペPCを用意しなくても、それなりに品質の良い文章ベクトルが得られます。文章ベクトルがあれば、お手軽に検索したりクラスタリングしたりできるので、とてもありがたいです。

しかし、機械学習ビギナーのわたしには、どのベクトルをどう用いるのが良いのか経験的にわからないです。そこで年末年始の暇な時間を使って、教師なしでお手軽に得られる文章ベクトルを比較して遊んで入門してみようと思います。

目的としては以下のとおりです。

  • お手軽に文章ベクトルを生成する方法に入門する。
  • 巷で知られている経験則を実際に確認する。

解説記事が豊富な有名な手法にしか触れないので、手法の解説は特にありません。ご留意ください。

実装は以下のリンクに置いておきます。

Comparison of unsupervised sentence embeddings (https://kampersanda.hatenablog.jp/entry/2023/01/02/155106) · GitHub

試してみる手法

文章のベクトル表現といえば、伝統的にTF-IDFなどの特徴量エンジニアリングにより得られるものでしたが、近頃ではニューラルネットワークの学習過程で得られる分散表現を用いる場合も多いです。本記事では主に後者にフォーカスを当てますが、ベースラインとしてTF-IDFも導入します。

TF-IDF

言わずと知れたTF-IDFです。Scikit-learnのTfidfVectorizerを用いて簡単に計算できます。

ベクトル生成に使用したコードは以下です。MeCabのCython Wrapperであるfugashiで単語分割し、TF-IDFを計算しました。辞書にはunidic-liteを使用しました。

from sklearn.feature_extraction.text import TfidfVectorizer
from fugashi import Tagger

class TfidfEmbedding:
    def __init__(self, sentences):
        """入力コーパスsentencesからTF-IDFを計算する。"""
        self.tagger = Tagger('-Owakati')
        self.model = TfidfVectorizer(smooth_idf=True, norm='l2')
        self.model.fit([self._tokenize(sent) for sent in sentences])

    def encode(self, sentences):
        """文章ベクトルを生成する。"""
        return self.model.transform([self._tokenize(sent) for sent in sentences])

    def _tokenize(self, sentence):
        return self.tagger.parse(sentence).strip()

model = TfidfEmbedding(sentences)
sentence_embeddings = model.encode(sentences)

Word Vectors

Word2Vecのような学習済み単語ベクトルを用いても簡単に文章ベクトルが得られます。方法としては、文に含まれる単語のベクトルの各次元の平均を取るなどが考えられます。

ベクトル生成に使用したコードは以下です。平均を計算しています。単語ベクトルにはfastTextcc.ja.300.vec.gzを使用しました。fastTextのページにはMeCabで単語分割処理をしたと明記されています。辞書の明記は無いですが、おそらくデフォルトのipadicだと思われるので、自分もipadicで単語分割します。

import numpy as np
import gensim
import ipadic
from fugashi import GenericTagger
from tqdm import tqdm

class Word2VecEmbedding:
    def __init__(self, model):
        """モデルをセット。"""
        self.model = model
        self.tagger = GenericTagger(ipadic.MECAB_ARGS + ' -Owakati')

    def encode(self, sentences):
        """文章ベクトルを生成する。"""
        sentence_embeddings = []
        for sent in tqdm(sentences):
            word_vecs = np.array(
                [self.model[word] for word in self._tokenize(sent).split() if word in self.model]
            )
            sentence_embedding = np.mean(word_vecs, axis=0) # 平均値
            sentence_embedding /= np.linalg.norm(sentence_embedding) # 単位ベクトル
            sentence_embeddings.append(sentence_embedding)
        return np.vstack(sentence_embeddings)

    def _tokenize(self, sentence):
        return self.tagger.parse(sentence).strip()

w2v_model = gensim.models.KeyedVectors.load_word2vec_format('/content/cc.ja.300.vec.gz', binary=False)
model = Word2VecEmbedding(w2v_model)
sentence_embeddings = model.encode(sentences)

今回は試してませんが、TF-IDFで各単語に重みをつけることなどもできます。

Doc2Vec

Doc2Vec [Le & Mikolov, 2014]は、ニューラルネットワークを用いて文章ベクトルを学習するアルゴリズムの一つです。Word2Vecのアイデアを系列に応用しています。

GensimライブラリのDoc2Vecを用いれば、手元の教師なしコーパスから簡単にモデルを学習することができます。TF-IDFと同じくfugashiで単語分割します。

import os
import numpy as np
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from fugashi import Tagger
from tqdm import tqdm

class Doc2VecEmbedding:
    def __init__(self, sentences):
        """分散表現を学習する。"""
        self.tagger = Tagger('-Owakati')
        if os.path.isfile('tmp.d2v'):
            self.model = Doc2Vec.load('tmp.d2v')
        else:
            tagged = [TaggedDocument(self._tokenize(sent), [i]) for i, sent in enumerate(sentences)]
            self.model = Doc2Vec(tagged, vector_size=300,  window=7, min_count=1, epochs=20, workers=4)
            self.model.save('tmp.d2v')

    def encode(self, sentences):
        """文章ベクトルを生成する。"""
        sentence_embeddings = []
        for i, sent in enumerate(tqdm(sentences)):
            tgged = TaggedDocument(self._tokenize(sent), [i])
            sentence_embedding = self.model.infer_vector(tgged.words)
            sentence_embedding /= np.linalg.norm(sentence_embedding) # 単位ベクトル
            sentence_embeddings.append(sentence_embedding)
        return np.vstack(sentence_embeddings)

    def _tokenize(self, sentence):
        return self.tagger.parse(sentence).strip().split()

model = Doc2VecEmbedding(sentences)
sentence_embeddings = model.encode(sentences)

BERT

手元のコーパスからDoc2Vecモデルを学習しなくても、配布されている事前学習済みのBERTモデルから文章ベクトルを得ることもできます。

ファインチューニングをする場合は、Transformerの[CLS]トークンに対応した最終層の隠れ状態ベクトルを用いるのが一般的らしいですが、ファインチューニングをしない場合は、全サブワードに対応する最終層の隠れ状態ベクトルの平均値プーリングなどを用いる方が有用だと経験的に知られているそうです。(参考:「自然言語処理の基礎」岡崎ら, 7.3節)

せっかくなので、どちらも試してみようと思います。

東北大の乾研が公開している事前学習済み日本語モデルを用いれば簡単に試すことができます。日本語モデルには、大きく分けて以下の2バージョンがあるようです。

  • bert-base-japanese-whole-word-masking: ipadicで単語分割
  • bert-base-japanese-v2: unidic-liteで単語分割

せっかくなので、どちらも試してみます。

コードは以下のとおりです。実装は「BERTによる自然言語処理入門」sonoisa氏の記事を参考にしました。

import numpy as np
import torch
from transformers import BertJapaneseTokenizer, BertModel
from tqdm import tqdm

class BertEmbedding:
    def __init__(self, model_name_or_path, device=None):
        """モデルを読み込む。"""
        self.tokenizer = BertJapaneseTokenizer.from_pretrained(model_name_or_path)
        self.model = BertModel.from_pretrained(model_name_or_path)
        self.model.eval()
        if device is None:
            device = 'cuda' if torch.cuda.is_available() else 'cpu'
        self.device = torch.device(device)
        self.model.to(device)

    @torch.no_grad()
    def encode(self, sentences, batch_size=8, max_length=256, target='mean_pooling'):
        """文章ベクトルを生成する。"""
        sentence_embeddings = []
        for batch_idx in tqdm(range(0, len(sentences), batch_size)):
            batch = sentences[batch_idx:batch_idx + batch_size]
            encoded_input = self.tokenizer.batch_encode_plus(
                batch,
                max_length=max_length,
                padding='max_length',
                truncation=True,
                return_tensors='pt',
            ).to(self.device)
            model_output = self.model(**encoded_input)
            if target == 'cls':
                batched_embeddings = self._cls(model_output)
            elif target == 'mean_pooling':
                batched_embeddings = self._mean_pooling(model_output, encoded_input['attention_mask'])
            else:
                assert False
            batched_embeddings = batched_embeddings.to('cpu').numpy()
            batched_embeddings /= np.linalg.norm(batched_embeddings, axis=1, keepdims=True) # 単位ベクトル
            sentence_embeddings.extend(batched_embeddings)
        return np.vstack(sentence_embeddings)

    def _cls(self, model_output):
        """CLSトークンに対応する最終層の隠れ状態ベクトルを用いる。"""
        return model_output.last_hidden_state[:,0]

    def _mean_pooling(self, model_output, attention_mask):
        """全サブワードに対応する最終層の隠れ状態ベクトルの平均値プーリングを用いる。"""
        token_embeddings = model_output.last_hidden_state
        input_mask_expanded = (token_embeddings*attention_mask.unsqueeze(-1)).sum(1)
        return input_mask_expanded / attention_mask.sum(1, keepdim=True)

# with ipadic
model = BertEmbedding('cl-tohoku/bert-base-japanese-whole-word-masking')
sentence_embeddings = model.encode(sentences, batch_size=8, target='mean_pooling')
# sentence_embeddings = model.encode(sentences, batch_size=8, target='cls')

# with unidic-lite
model = BertEmbedding('cl-tohoku/bert-base-japanese-v2')
sentence_embeddings = model.encode(sentences, batch_size=8, target='mean_pooling')
# sentence_embeddings = model.encode(sentences, batch_size=8, target='cls')

Sentence-BERT

Sentence-BERT [Reimers & Gurevych, 2019] は、BERTよりも文章の特徴を捉えたベクトルを生成することを目的として提案されたBERTモデルの一種です。事前学習済みモデルとしては、sonoisa氏によるsentence-bert-base-ja-mean-tokens-v2がありますので、これを試してみようと思います。

ベクトル化のコードは、モデル名を変更するだけでBERTのものを流用できます。

model = BertEmbedding('sonoisa/sentence-bert-base-ja-mean-tokens-v2')
sentence_embeddings = model.encode(sentences, batch_size=8, target='mean_pooling')
# sentence_embeddings = model.encode(sentences, batch_size=8, target='cls')

2023-01-02追記:冒頭で「教師なし」と明記しましたが、Sentence-BERTモデルは推論タスクなどについて教師あり学習されるものなので、この表現には語弊がありました。ここに訂正します。

実験結果

livedoor ニュースコーパスで分類問題を解くことで文章ベクトルを比較します。このコーパスではニュース記事に9つのカテゴリラベルが割り振られており、7367件の事例を含みます。TF-IDFとDoc2Vecの学習には、コーパスに含まれる文章をすべて使用しました。

各ニュース記事について、その文章ベクトルのコサイン類似度が最も大きくなる他のニュース記事とカテゴリか一致するかを評価し、その正解率を算出しました。結果は以下のとおりです。

Model Accuracy
TF-IDF 79.2%
Word Vectors 83.5%
Doc2Vec 86.9%
BERT(cl-tohoku/bert-base-japanese-whole-word-masking, CLS) 82.9%
BERT(cl-tohoku/bert-base-japanese-whole-word-masking, Mean Pooling) 83.2%
BERT(cl-tohoku/bert-base-japanese-v2, CLS) 80.0%
BERT(cl-tohoku/bert-base-japanese-v2, Mean Pooling) 84.3%
Sentence-BERT (sonoisa/sentence-bert-base-ja-mean-tokens-v2, CLS) 72.1%
Sentence-BERT (sonoisa/sentence-bert-base-ja-mean-tokens-v2, Mean Pooling) 76.7%

TF-IDFよりもWord Vectorsの方が、Word VectorsよりもDoc2Vecの方が良い精度でした。ニューラルネットワークの分散表現が上手く文の特徴を表現できているようです。

BERTモデルと比べてもDoc2Vecの結果が最も良かったです。これは学習に使ったコーパスの差でしょう。学習済みBERTモデルはお手軽ですけど、精度を出すには目的に応じてちゃんとファインチューニングする必要がありそうです。また、長いニュース記事もあるので先頭256トークンの切り取りによる影響かもしれません。

BERTモデルについて、[CLS]トークンに対応したベクトル(CLS)と、全サブワードに対応したベクトルの平均プーリング(Mean Pooling)とでは、Mean Poolingの方が総じて良い結果でした。これは岡崎本で述べられていた経験則を改めて自分で確認した結果となります。

BERTとSentence-BERTの比較ではBERTの方が良い結果となりましたが、これも事前学習の目的の差によるものかと思います。Sentence-BERTモデルの学習方法は秘匿とされているので、少なくとも今回の目的に沿ったものでは無かったのでしょう。いずれにせよちゃんとファインチューニングするのが大切っぽいです。

可視化

せっかくなので最後にt-SNEでTF-IDFとDoc2Vecの結果を可視化してみます。実装はBERT本の10-4章の例をそのまま流用しました。

TF-IDF

Doc2Vec

確かにDoc2Vecの方がちゃんとカテゴリごとにまとまってる気がします。

おわりに

という具合で当たり前とされているっぽい経験則をちゃんと確認しました。今回はパラメータも適当な簡単な実験ですので、各手法の性能を決定づけるものではもちろん無いです。例えばTF-IDFも正規化など前処理をちゃんとすればもっと精度は改善するでしょう。

とりあえずHugging Faceや文章ベクトルに入門できたので満足です。2023年はMLの年にしたいので、仕事や趣味でどんどん使っていけたら良き。

あと、文章ベクトルについては以下の記事が神がかってました。

towardsdatascience.com