🤗

テーブルデータ向けの自然言語特徴抽出術

2021/05/06に公開3

はじめに

この記事は、自然言語を含むカラムを持ったテーブルデータにおいて、教師あり学習用にテキストのカラムを固定長の特徴ベクトルに直す方法をまとめたものです。

例としてあげるデータは全て、atmaCup#10のものです。また、この記事の内容はこちらのノートブックで実験を行っています。

Data example
データの例。'title'、'description'など自然言語を含むカラムが存在する。

参考: 自然言語処理におけるEmbeddingの方法一覧とサンプルコード

Bag of Wordsベースの手法

文書をトークンの集合として扱う手法です。トークンとしてはよく単語が選ばれますが、自分でtokenizerを設定して文章を単語以外のtokenの集合として扱うこともできます。また、日本語などの言語においてはトークン化が自明でないため、MeCabなどを用いてトークン化することがかなり多いです。

コラム MeCabを用いたトークン化

MeCabのインストール方法は次の記事が参考になります。
https://qiita.com/jun40vn/items/78e33e29dce3d50c2df1

その上で次のようなtokenizerを定義すると日本語の文章を分かち書きされた単語のリストに変換することができます。

import MeCab


dict_path = "<MeCabの辞書の場所を指定>"
tagger = MeCab.Tagger(dict_path)

def mecab_tokenizer(s: str):
    parse_result = tagger.parse(s)
    return [
        result.split("\t")[0]
        for result in parse_result.split("\n")
        if result not in ["EOS", ""]
    ]

このmecab_tokenizerCountVectorizerTfidfVectorizerなどのtokenizerという引数に渡すことでMeCabを用いたトークン化をした上でBag of Wordsを作成してくれます。

CountVectorizer

CountVectorizerは文章中のtokenの頻度を数えたスパースマトリクスを作成します。行列の各行が各文章に該当し、各列がtokenに対応します。つまり、文章をあるtokenがあるかないかで特徴づけ、ベクトルを得る手法です。デフォルトのハイパーパラメータ設定では文章を空白文字" "で区切ってtoken化するため、英語や(今回の例で用いているデータ中にある)オランダ語などにおいては単語がそのままtokenになります。

from sklearn.feature_extraction.text import CountVectorizer

cv = CountVectorizer()
features = cv.fit_transform(df["title"].fillna(""))

結果として今回のデータでは次のような特徴行列が得られます。このスパースマトリクスというのは疎な巨大行列をメモリ効率よく表現できるデータ構造です。toarray()で通常のnumpyの配列に変換も可能ですが、メモリを大きく消費するため推奨されません。

<12026x13851 sparse matrix of type '<class 'numpy.int64'>'
	with 79165 stored elements in Compressed Sparse Row format>
features[0].toarray()
# => array([[0, 0, 0, ..., 0, 0, 0]])

scikit-learnの多くの手法やLightGBMなどの有名ライブラリはスパースマトリクスを入力として受け取るようになっているので特別な理由がない限りそのままにしておくのがいいでしょう。

特徴ベクトルの各列の名前、すなわちtoken名はCountVectorizerのインスタンスの方に保存されています。

cv.get_feature_names()[:20]
# => ['02', '07', '10', '100', '11', '12',...]
コラム fit、transform、fit_transformの存在意義について

ここまでの流れで、fit_transformというメソッドが出てきましたが、これはscikit-learnに実装されているTransformerでよくあるメソッドです。このメソッドがあるクラスには他にfittransformというメソッドが実装されています。

このコラムではfittransformfit_transformの存在意義について説明します。


fitメソッドとtransformメソッドがあるTransformerの多くは内部にデータの変換のための"辞書"のようなものを持っています。この"辞書"は初期状態ではまっさらですが、変換の際の対応関係をfitメソッドを使って学習し、transformメソッドを使ってデータに変換規則を適用する、という流れになっています。この一連の流れをひとまとめにしたものがfit_transformメソッドです。


機械学習では、学習データで学習したモデルをテストデータに対して適用する、という流れがあります。これは(一般にはモデルに含まれない)前処理・特徴抽出の際の変換規則に関しても同じです。学習データに対してfitして"辞書"の学習を行った上で学習データ・テストデータに対してtransformで変換規則の適用を行う、というのが一連の流れとなります。

例としてCountVectorizerではどのような"辞書"がfitによって作成されるのかをみてみましょう。

cv = CountVectorizer()

# fit前のアトリビュート
pre_fit_attributes = set(dir(cv))

cv.fit(df["title"].fillna(""))
# fit後のアトリビュート
post_fit_attributes = set(dir(cv))

post_fit_attributes - pre_fit_attributes
# => {'_stop_words_id', 'fixed_vocabulary_', 'stop_words_', 'vocabulary_'}

_stop_words_id, fixed_vocabulary_, stop_words_, vocabulary_というアトリビュートがfitメソッドを読んだ後にクラスに足されていることがわかります。この中でも特に重要なのがvocabulary_です。みてみましょう。

type(cv.vocabulary_), len(cv.vocabulary_)
# => (dict, 13851)

list(cv.vocabbulary_.key())[:10]
# => ['the', 'avenue', 'of', 'birches', 'struik', 'in', 'bloei', 'portret', 'van', 'een']

cv.vocabulary_["the"]
# => 11815

CountVectorizerでは文字通り辞書がfitによって作成されます。この辞書は各語彙(token)とその出現回数を記録したものになっています。この辞書を元に文章をその単語の出現回数に記録したものになっています。この辞書を元に文章をその単語の出現回数によって特徴づけるのがtransformメソッドです。

scikit-learnのTransformerの中には、LabelEncoderのようにfit時になかったクラスが適用時に現れるとエラーを起こすものもありますが、CountVectorizerやTfidfVectorizerはその心配がありません。

あまりよく行われることではありませんが、fitをあるテキストのカラムで行ってtransformを別のカラムに対して行う、と言ったようなことも可能です。

TfidfVectorizer

TfidfVectorizerは単語の数え上げを行った後、Tfidfという手法で重みづけをしたスパースマトリクスを作成します。使い方はCountVectorizerと同様です。

from sklearn.feature_extraction.text import TfidfVectorizer


tv = TfidfVectorizer()
features = tv.fit_transform(df["title"].fillna(""))

この結果、次のようなスパースマトリクスが得られます

<12026x13851 sparse matrix of type '<class 'numpy.float64'>'
	with 79165 stored elements in Compressed Sparse Row format>

上のCountVectorizerの例と見比べるとsparse matrix of type '<class 'numpy.int64'>'からsparse matrix of type '<class 'numpy.float64'>'に変わっていることがわかります。

一方で79165 stored elementsという部分は変化していません。これは、非0の要素の数を表しているのですが、TfidfはCountVectorizerの結果に重みづけを行っただけのもののため0の要素は重みづけをされても0として残り、結果として非0の要素数は変化しないことによるものです。

BM25

Tfidfの問題点として文書長が長いと大きな値がつきやすい、というものがあります。Okapi BM25はこの問題点を解決するために重みづけの際に文書長による補正をかけます。

BM25の実装はscikit-learnにはないため、BM25Transformerにおける実装を参考にしました。

ライセンスと実装

LICENSE
BSD 3-Clause License

Copyright (c) 2018, Sho IIZUKA All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import numpy as np
import scipy.sparse as sp

from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted
from sklearn.feature_extraction.text import _document_frequency


# reference: https://github.com/arosh/BM25Transformer/blob/master/bm25.py
class BM25Transformer(BaseEstimator, TransformerMixin):
    """
    Parameters
    ----------
    use_idf : boolean, optional (default=True)
    k1 : float, optional (default=2.0)
    b : float, optional (default=0.75)
    References
    ----------
    Okapi BM25: a non-binary model - Introduction to Information Retrieval
    http://nlp.stanford.edu/IR-book/html/htmledition/okapi-bm25-a-non-binary-model-1.html
    """
    def __init__(self, use_idf=True, k1=2.0, b=0.75):
        self.use_idf = use_idf
        self.k1 = k1
        self.b = b

    def fit(self, X):
        """
        Parameters
        ----------
        X : sparse matrix, [n_samples, n_features]
            document-term matrix
        """
        if not sp.issparse(X):
            X = sp.csc_matrix(X)
        if self.use_idf:
            n_samples, n_features = X.shape
            df = _document_frequency(X)
            idf = np.log((n_samples - df + 0.5) / (df + 0.5))
            self._idf_diag = sp.spdiags(idf, diags=0, m=n_features, n=n_features)
        return self

    def transform(self, X, copy=True):
        """
        Parameters
        ----------
        X : sparse matrix, [n_samples, n_features]
            document-term matrix
        copy : boolean, optional (default=True)
        """
        if hasattr(X, 'dtype') and np.issubdtype(X.dtype, np.float):
            # preserve float family dtype
            X = sp.csr_matrix(X, copy=copy)
        else:
            # convert counts or binary occurrences to floats
            X = sp.csr_matrix(X, dtype=np.float64, copy=copy)

        n_samples, n_features = X.shape

        # Document length (number of terms) in each row
        # Shape is (n_samples, 1)
        dl = X.sum(axis=1)
        # Number of non-zero elements in each row
        # Shape is (n_samples, )
        sz = X.indptr[1:] - X.indptr[0:-1]
        # In each row, repeat `dl` for `sz` times
        # Shape is (sum(sz), )
        # Example
        # -------
        # dl = [4, 5, 6]
        # sz = [1, 2, 3]
        # rep = [4, 5, 5, 6, 6, 6]
        rep = np.repeat(np.asarray(dl), sz)
        # Average document length
        # Scalar value
        avgdl = np.average(dl)
        # Compute BM25 score only for non-zero elements
        data = X.data * (self.k1 + 1) / (X.data + self.k1 * (1 - self.b + self.b * rep / avgdl))
        X = sp.csr_matrix((data, X.indices, X.indptr), shape=X.shape)

        if self.use_idf:
            check_is_fitted(self, '_idf_diag', 'idf vector is not fitted')

            expected_n_features = self._idf_diag.shape[0]
            if n_features != expected_n_features:
                raise ValueError("Input has n_features=%d while the model"
                                 " has been trained with n_features=%d" % (
                                     n_features, expected_n_features))
            # *= doesn't work
            X = X * self._idf_diag

        return X

上の実装はCountVectorizerの結果に対して計算を行うもののため、次のようにscikit-learnのPipelineを用いて処理を繋げます。

from sklearn.pipeline import Pipeline


bm25 = Pipeline(steps=[
    ("CountVectorizer", CountVectorizer()),
    ("BM25Transformer", BM25Transformer())
])

features = bm25.fit_transform(df["title"].fillna(""))
features
# => <12026x13851 sparse matrix of type '<class 'numpy.float64'>'
# =>	with 79165 stored elements in Compressed Sparse Row format>

SVD/NMF/LDAによる圧縮

これまでに紹介したCountVectorizer, TfidfVectorizer, BM25の出力はいずれも数万次元を超える巨大な行列です。このような高次元のデータから学習することは一般的には少し難しいとされ、次元削減の手法を用いて低次元に直したデータで学習を行うこともしばしばあるため紹介します。

sklearn.decompositionにはいろいろなアルゴリズムが実装されていますが、よく用いられるのはTruncatedSVDNMFLatentDirichletAllocationの3種です。

Tfidfの出力にTruncatedSVDを適用してみましょう。

from sklearn.decomposition import TruncatedSVD, NMF, LatentDirichletAllocation


tfidf_svd = Pipeline(steps=[
    ("TfidfVectorizer", TfidfVectorizer()),
    ("TruncatedSVD", TruncatedSVD(n_components=50, random_state=42))
])

features_svd = tfidf_svd.fit_transform(df["title"].fillna(""))
features_svd
# => array([[ 0.00941832,  0.03515622,  0.27475458, ...,  0.00723061,
# =>        -0.00529586, -0.00076525],
# =>       [ 0.02541036,  0.05760959,  0.01006472, ..., -0.00457558,
# =>         0.00829346,  0.00868814],
# =>       [ 0.76085596, -0.21396575, -0.07083936, ...,  0.01899481,
# =>         0.031445  ,  0.03774812],
# =>       ...,
# =>       [ 0.08828213,  0.18706189, -0.01792205, ...,  0.02066624,
# =>        -0.00753736,  0.01364502],
# =>       [ 0.06457228,  0.07335827, -0.0081457 , ..., -0.00264008,
# =>        -0.01883034,  0.01379052],
# =>       [ 0.01181844,  0.05063674,  0.39794632, ...,  0.02276429,
# =>        -0.01311549,  0.0044951 ]])

今度はnumpyの密な配列になっていることがわかります。

features_svd.shape
# => (12026, 50)

他の二つのアルゴリズムでも同様のパイプラインでベクトル化してみましょう。

tfidf_nmf = Pipeline(steps=[
    ("TfidfVectorizer", TfidfVectorizer()),
    ("NMF", NMF(n_components=50, random_state=42))
])

tfidf_lda = Pipeline(steps=[
    ("TfidfVectorizer", TfidfVectorizer()),
    ("LDA", LatentDirichletAllocation(n_components=50, random_state=42))
])

features_nmf = tfidf_nmf.fit_transform(df["title"].fillna(""))
features_lda = tfidf_lda.fit_transform(df["title"].fillna(""))
それぞれの出力結果
features_nmf
array([[0.00000000e+00, 0.00000000e+00, 5.17095498e-02, ...,
        0.00000000e+00, 0.00000000e+00, 0.00000000e+00],
       [2.43097678e-05, 0.00000000e+00, 0.00000000e+00, ...,
        0.00000000e+00, 0.00000000e+00, 0.00000000e+00],
       [1.72472570e-01, 0.00000000e+00, 0.00000000e+00, ...,
        0.00000000e+00, 0.00000000e+00, 0.00000000e+00],
       ...,
       [0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,
        0.00000000e+00, 0.00000000e+00, 0.00000000e+00],
       [0.00000000e+00, 0.00000000e+00, 0.00000000e+00, ...,
        0.00000000e+00, 0.00000000e+00, 0.00000000e+00],
       [0.00000000e+00, 0.00000000e+00, 4.21709211e-02, ...,
        0.00000000e+00, 0.00000000e+00, 0.00000000e+00]])
features_lda
array([[0.00708276, 0.00708276, 0.00708276, ..., 0.00708276, 0.00708276,
        0.00708276],
       [0.0077113 , 0.0077113 , 0.0077113 , ..., 0.0077113 , 0.0077113 ,
        0.0077113 ],
       [0.00683002, 0.00683002, 0.00683002, ..., 0.00683002, 0.00683002,
        0.00683002],
       ...,
       [0.00533757, 0.00533757, 0.00533757, ..., 0.00533757, 0.00533757,
        0.00533757],
       [0.00623906, 0.00623906, 0.00623906, ..., 0.00623906, 0.00623906,
        0.00623906],
       [0.00684387, 0.00684387, 0.00684387, ..., 0.00684387, 0.00684387,
        0.00684387]])
features_nmf.shape, features_lda.shape
# => ((12026, 50), (12026, 50))

それぞれ結果の配列の形は同じですが、要素は大きく異なることがわかります。異なるアルゴリズムで次元削減を行っているからです。

このような次元削減はTfidfVectorizerやCountVectorizer、BM25以外にもその他のさまざまなベクトル表現の手法に対して適用することができます。

単語ベクトル表現を用いた方法

Word2Vec、GloVe、fastTextなどの単語ベクトル表現を用いた文章ベクトル作成手法についてです。

fastTextの文章ベクトル

fastTextの公式実装には、get_sentence_vectorというAPIが用意されており、モデルを読み込むだけですぐに使うことができます。

今回は使用したデータがオランダ語のため、オランダ語のfastTextの事前学習済みモデルを読み込んでみます。

import fasttext
import fasttext.util


fasttext.util.download_model('nl', if_exists='ignore')
ft_model = fasttext.load_model('cc.nl.300.bin')

fastTextには、公式のページにある157言語の学習済み単語分散表現をダウンロードして展開するユーティリティが用意されています。上のコードはその例です。なお、学習済み分散表現は7GB超あるためかなり重く、ダウンロードにそれなりの時間がかかるほか、読み込みの際にメモリを多く消費されることが問題点としてあります。

読み込んだfastTextのモデルで文章ベクトルを出してみましょう。次のようなオランダ語の文章を取り扱ってみましょう。

title = df["title"].loc[100]
title
# => 'Een brug over een kloof in het Gotthard gebergte'
features = ft_model.get_sentence_vector(title)
features
# => array([ 2.15894822e-02,  1.24604534e-02, -1.04461201e-02, 7.22003169e-03,
# =>        2.98381317e-02, -3.92103428e-03, -3.29393856e-02, -1.28988596e-02,
# =>        8.84584989e-03, -1.39617221e-02, -2.93837693e-02, -1.26295292e-03,
# =>        2.34530065e-02,  5.40923653e-03,  1.79306362e-02, -2.60739867e-02,
# =>       -2.39493661e-02,  2.20047366e-02,  3.33243906e-02, -4.48094215e-03,
# =>        ...], dtype=float32)
features.shape
# => (300,)

文章一つに対し、300次元のベクトルが出力されていることがわかります。これを全ての文章に対して出してみましょう。

features = np.stack(
    df["title"].fillna("").str.replace("\n", "").map(
        lambda x: ft_model.get_sentence_vector(x)
    ).values
)
features.shape
# => (12026, 300)

学習済みWord2Vec / GloVeの単語ベクトルの集約

Word2VecやGloVeはfastText以前に提案された単語ベクトル表現ですが、現在のBERT全盛の時代においてもタスクによってはかなり強い手法です。

Word2Vec / GloVe自体は単語のベクトル表現ですが、文章ないの単語それぞれをベクトル表現に置き換えた上で各次元の平均をとったり、SWEM、SCDVなどの手法で文章ベクトルを得ることができます。

ここではまず、Word2Vecのベクトル表現を読み込んで、その平均を取るやり方で文章ベクトルを得る方法を紹介します。

今回は題材が対象の言語がオランダ語なのでオランダ語のWord2Vec embeddingをclips/dutchembeddingsから取ってきます。なお、英語とロシア語のembeddingであればgensimのdownloader APIを用いて次のように取ってくることができます。

import gensim
 
model = gensim.downloader('glove-twitter-25')

このAPIで入手可能なリソースのリストはRaRe-Technologies/gensim-dataにあります。

# オランダ語のembeddingを取ってくる
!wget https://onyx.uvt.nl/sakuin/_public/embeddings/wikipedia-320.tar.gz
!tar -zxvf wikipedia-320.tar.gz
!rm -f wikipedia-320.tar.gz

ダウンロードしたWord2Vecのモデルを読み込み、オランダ語の文章を単語に分解した上で、単語ごとにベクトルに変換してみましょう。ただし、文章中の単語がモデルになかった場合(out-of-vocabulary)はゼロベクトルに変換することにします。

from gensim.models import KeyedVectors


w2v_model = KeyedVectors.load_word2vec_format(
    "./320/wikipedia-320.txt", binary=False)
embeddings = [
    w2v_model.get_vector(word)
    if w2v_model.vocab.get(word) is not None
    else np.zeros(320)
    for word in txt.split()
]
embeddings

上のやり方によって各文章に対し、文章中の単語数分のベクトルが並んだリストができます。最も単純なやり方ではベクトルの平均を取ることで文章ベクトルを得ることができます。

このやり方で、DataFrame中の全文章を文章ベクトルに変換してみましょう。

def get_sentence_vector(x: str, ndim=320):
    embeddings = [
        w2v_model.get_vector(word)
        if w2v_model.vocab.get(word) is not None
        else np.zeros(ndim, dtype=np.float32)
        for word in x.split()
    ]
    if len(embeddings) == 0:
        return np.zeros(ndim, dtype=np.float32)
    else:
        return np.mean(embeddings, axis=0)


features = np.stack(
    df["title"].fillna("").str.replace("\n", "").map(
        lambda x: get_sentence_vector(x)
    ).values
)
features
# => array([[ 0.0218195 , -0.00529125,  0.00676325, ..., -0.014701  , 0.00603925,  0.00937075],
# =>       [-0.00052267, -0.00186367,  0.00838467, ..., -0.03165   , 0.02557333, -0.00187267],
# =>       [ 0.00350125,  0.0229975 ,  0.02975875, ..., -0.00457425, -0.02403375, -0.01494225],
# =>       ...,
# =>       [ 0.0228829 ,  0.0169449 , -0.0143778 , ..., -0.0023923 , -0.0042892 ,  0.0246226 ],
# =>       [ 0.00500967,  0.01275717,  0.0063095 , ..., -0.0127645 , -0.00038867,  0.01078233],
# =>       [ 0.0192264 , -0.0115158 ,  0.011208  , ..., -0.0417188 , 0.0116854 , -0.0011884 ]], dtype=float32)
features.shape
# => (12026, 320)

SWEM

前の節で紹介した単語ベクトルの平均を取ることで文章ベクトルを得る方法はSWEMと呼ばれる手法群の中の一つのSWEM-averと呼ばれるものになります。

この他に、各次元についてmax poolingをとるSWEM-max、SWEM-averとSWEM-maxを両方計算した上で結合するSWEM-concat、そして固定長の窓幅のなかで各次元の平均を取った後、max poolingをとるSWEM-hierがあります。

https://arxiv.org/abs/1805.09843

これら全ての実装がyagays/swemで行われていたのでお借りします。

SWEMの実装
class SimpleTokenizer:
    def tokenize(self, text: str):
        return text.split()


class SWEM():
    """
    Simple Word-Embeddingbased Models (SWEM)
    https://arxiv.org/abs/1805.09843v1
    """

    def __init__(self, w2v, tokenizer, oov_initialize_range=(-0.01, 0.01)):
        self.w2v = w2v
        self.tokenizer = tokenizer
        self.vocab = set(self.w2v.vocab.keys())
        self.embedding_dim = self.w2v.vector_size
        self.oov_initialize_range = oov_initialize_range

        if self.oov_initialize_range[0] > self.oov_initialize_range[1]:
            raise ValueError("Specify valid initialize range: "
                             f"[{self.oov_initialize_range[0]}, {self.oov_initialize_range[1]}]")

    def get_word_embeddings(self, text):
        np.random.seed(abs(hash(text)) % (10 ** 8))

        vectors = []
        for word in self.tokenizer.tokenize(text):
            if word in self.vocab:
                vectors.append(self.w2v[word])
            else:
                vectors.append(np.random.uniform(self.oov_initialize_range[0],
                                                 self.oov_initialize_range[1],
                                                 self.embedding_dim))
        return np.array(vectors)

    def average_pooling(self, text):
        word_embeddings = self.get_word_embeddings(text)
        return np.mean(word_embeddings, axis=0)

    def max_pooling(self, text):
        word_embeddings = self.get_word_embeddings(text)
        return np.max(word_embeddings, axis=0)

    def concat_average_max_pooling(self, text):
        word_embeddings = self.get_word_embeddings(text)
        return np.r_[np.mean(word_embeddings, axis=0), np.max(word_embeddings, axis=0)]

    def hierarchical_pooling(self, text, n):
        word_embeddings = self.get_word_embeddings(text)

        text_len = word_embeddings.shape[0]
        if n > text_len:
            raise ValueError(f"window size must be less than text length / window_size:{n} text_length:{text_len}")
        window_average_pooling_vec = [np.mean(word_embeddings[i:i + n], axis=0) for i in range(text_len - n + 1)]

        return np.max(window_average_pooling_vec, axis=0)
tokenizer = SimpleTokenizer()
swem = SWEM(w2v=w2v_model, tokenizer=tokenizer)

それぞれ次のように試すことができます。

# SWEM-aver
# 上で紹介したものと同じもの
swem.average_pooling(txt)
# => array([-8.88644345e-03, -1.91837773e-02, -1.40529983e-02, -4.22796682e-02,
# =>        1.32464431e-02,  3.00016664e-02, -8.08466598e-03, -3.73876654e-02,
# =>       -7.33510964e-03,  1.08800018e-02, -2.55460013e-02,  1.00997780e-02,
# =>        ...], dtype=float32)

# max pooling
swem.max_pooling(txt)

# SWEM concat
swem.concat_average_max_pooling(txt)

# SWEM-hier
swem.hierarchical_pooling(txt, n=3)

SCDV

SCDVは単語ベクトルをいい感じに直してくれる手法です。

https://arxiv.org/pdf/1612.06778.pdf

SCDVに関してはさまざまなブログ記事が書かれているのでそちらも参照するといいかもしれません。実装はnyk510/scdv-pythonを参考にしました。

文章の埋め込みモデル: Sparse Composite Document Vectors を読んで実装してみた
論文メモ SCDV : Sparse Composite Document Vectors using soft clustering over distributional representations

SCDVの実装
from sklearn.base import TransformerMixin, BaseEstimator
from sklearn.mixture import GaussianMixture


class SCDVEmbedder(TransformerMixin, BaseEstimator):
    def __init__(self, w2v, tokenizer, k=5):
        self.w2v = w2v
        self.vocab = set(self.w2v.vocab.keys())
        self.tokenizer = tokenizer
        self.k = k
        self.topic_vector = None
        self.tv = TfidfVectorizer(tokenizer=self.tokenizer)

    def __assert_if_not_fitted(self):
        assert self.topic_vector is not None, \
            "SCDV model has not been fitted"

    def __create_topic_vector(self, corpus: pd.Series):
        self.tv.fit(corpus)
        self.doc_vocab = set(self.tv.vocabulary_.keys())

        self.use_words = list(self.vocab & self.doc_vocab)
        self.use_word_vectors = np.array([
            self.w2v[word] for word in self.use_words])
        w2v_dim = self.use_word_vectors.shape[1]
        self.clf = GaussianMixture(
            n_components=self.k, 
            random_state=42,
            covariance_type="tied")
        self.clf.fit(self.use_word_vectors)
        word_probs = self.clf.predict_proba(
            self.use_word_vectors)
        world_cluster_vector = self.use_word_vectors[:, None, :] * word_probs[
            :, :, None]

        doc_vocab_list = list(self.tv.vocabulary_.keys())
        use_words_idx = [doc_vocab_list.index(w) for w in self.use_words]
        idf = self.tv.idf_[use_words_idx]
        topic_vector = world_cluster_vector.reshape(-1, self.k * w2v_dim) * idf[:, None]
        topic_vector = np.nan_to_num(topic_vector)

        self.topic_vector = topic_vector
        self.vocabulary_ = set(self.use_words)
        self.ndim = self.k * w2v_dim

    def fit(self, X, y=None):
        self.__create_topic_vector(X)

    def transform(self, X):
        tokenized = X.fillna("").map(lambda x: self.tokenizer(x))

        def get_sentence_vector(x: list):
            embeddings = [
                self.topic_vector[self.use_words.index(word)]
                if word in self.vocabulary_
                else np.zeros(self.ndim, dtype=np.float32)
                for word in x
            ]
            if len(embeddings) == 0:
                return np.zeros(self.ndim, dtype=np.float32)
            else:
                return np.mean(embeddings, axis=0)
        return np.stack(
            tokenized.map(lambda x: get_sentence_vector(x)).values
        )
def tokenizer(x: str):
    return x.split()

scdv = SCDVEmbedder(w2v_model, tokenizer=tokenizer, k=5)
scdv.fit(df["title"])
features = scdv.transform(df["title"])
features.shape
# => (12026, 1600)

BERTを用いた方法

近年は自然言語処理に対する特徴抽出法としてBERTがデファクトスタンダードと言っても差し支えないでしょう。ここでは、事前学習済み BERT モデルを使ったテキスト特徴抽出についてで共有されていた方法を紹介します。

import torch
import transformers

from transformers import BertTokenizer


class BertSequenceVectorizer:
    def __init__(self, model_name="bert-base-uncased", max_len=128):
        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.model_name = model_name
        self.tokenizer = BertTokenizer.from_pretrained(self.model_name)
        self.bert_model = transformers.BertModel.from_pretrained(self.model_name)
        self.bert_model = self.bert_model.to(self.device)
        self.max_len = max_len

    def vectorize(self, sentence: str) -> np.array:
        inp = self.tokenizer.encode(sentence)
        len_inp = len(inp)

        if len_inp >= self.max_len:
            inputs = inp[:self.max_len]
            masks = [1] * self.max_len
        else:
            inputs = inp + [0] * (self.max_len - len_inp)
            masks = [1] * len_inp + [0] * (self.max_len - len_inp)

        inputs_tensor = torch.tensor([inputs], dtype=torch.long).to(self.device)
        masks_tensor = torch.tensor([masks], dtype=torch.long).to(self.device)

        bert_out = self.bert_model(inputs_tensor, masks_tensor)
        seq_out, pooled_out = bert_out['last_hidden_state'], bert_out['pooler_output']

        if torch.cuda.is_available():    
            return seq_out[0][0].cpu().detach().numpy() # 0番目は [CLS] token, 768 dim の文章特徴量
        else:
            return seq_out[0][0].detach().numpy()
BSV = BertSequenceVectorizer(
    model_name="bert-base-multilingual-uncased",
    max_len=128)
features = np.stack(
    df["title"].fillna("").map(lambda x: BSV.vectorize(x).reshape(-1)).values
)
features
# => array([[ 0.05518435, -0.01929706, -0.00717838, ...,  0.05105488, -0.01119471,  0.02306907],
# =>       [-0.01522179, -0.0586175 , -0.03396389, ..., -0.02157267, -0.08532659,  0.00154577],
# =>       [-0.06408089,  0.01533521, -0.07290605, ..., -0.00512743, -0.08937339, -0.00232941],
# =>       ...,
# =>       [-0.02091743,  0.02608643, -0.04621095, ...,  0.12933932, -0.03479976,  0.04011173],
# =>       [ 0.04437887, -0.00519709, -0.00229898, ...,  0.01011661, -0.02555398,  0.03275691],
# =>       [-0.01799066,  0.02379747, -0.06540792, ...,  0.05809147, -0.03211721,  0.03436833]], dtype=float32)

features.shape
# => (12026, 768)

Universal Sentence Encoderを用いた方法

文章ベクトルを取る方法として、Universal Sentence Encoderと呼ばれるモデルがあります。Universal Sentece Encoderは異なる言語でもお手軽に同じベクトル空間上に埋め込むことができる手法として非常に使い勝手がいいと言われています。

import tensorflow as tf
import tensorflow_text
import tensorflow_hub as hub

from tqdm import tqdm


tqdm.pandas()

embedder = hub.load(
    "https://tfhub.dev/google/universal-sentence-encoder-multilingual/3")
features = np.stack(
    df["title"].fillna("").progress_apply(lambda x: embedder(x).numpy().reshape(-1)).values
)
features
# => array([[ 0.09102602,  0.0063779 , -0.04502575, ..., -0.07357673, -0.01823404,  0.00417807],
# =>       [ 0.0847326 ,  0.03644175, -0.03105138, ..., -0.06141949, -0.03723054, -0.04122324],
# =>       [ 0.02749815, -0.00558675, -0.04187826, ...,  0.04859524, 0.03867997, -0.07246225],
# =>       ...,
# =>       [-0.03633422, -0.03659198, -0.07171568, ...,  0.05222084, 0.04104229, -0.0548655 ],
# =>       [ 0.01590004, -0.03160512, -0.01822312, ..., -0.02742272, -0.00633312, -0.04813081],
# =>       [ 0.08572994,  0.01084783,  0.03214317, ...,  0.06420612, 0.00978137,  0.00233417]], dtype=float32)

features.shape
# => (12026, 512)

文章ベクトル同士の類似度による特徴

文章が含まれるカラムが複数ある場合に、それらについてそれぞれベクトル化を施した後、そのベクトル同士の類似度を測ってそのスコアを特徴量とする場合があります。この特徴を作る気持ちとしては、例えば titleと long_titleというカラムがあったとして、この2つがコピペされているかどうかを判定するなどに使えます。

ここでは、前のUniversal Sentence Encoderで作成したベクトルを使って titleとlong_titleの間の類似度を測ってみましょう。

long_features = np.stack(
    df["long_title"].fillna("").progress_apply(lambda x: embedder(x).numpy().reshape(-1)).values
)

cosine類似度は

\frac{v_1\cdot v_2}{\|v_1\|\|v_2\|}

として計算されます。これを特徴量の行列に対して行う場合は次のようになります。

(features * long_features).sum(axis=1) / (
    np.linalg.norm(features, axis=1) * np.linalg.norm(long_features, axis=1))
# => array([0.41377544, 0.25844374, 0.48187917, ..., 0.8305303 , 0.7043016, 0.39323133], dtype=float32)

Discussion

hirayukihirayuki

素敵なまとめありがとうございます!!
BERTモデルについて質問なのですが、input_tensorとmask_tensorを導出する上で、以下の公式ドキュメントを読むと tokenizer(batch_sentences, max_length=30, padding="max_length", truncation=True, return_tensors="pt") でまとめられるように感じたのですが、何か意図はございますか??
https://huggingface.co/transformers/preprocessing.html#preprocessing-pairs-of-sentences

koukyo1994koukyo1994

コメントありがとうございます。
特に深い意図はなく、その導出を知りませんでした。ありがとうございます!

ちょっと動作を確認して見ようと思います

hirayukihirayuki

こちらこそありがとうございます。
もしかしたら最近のリリースで出たのかもしれません!🙇‍♂️🙇‍♂️
私も本記事を見ながら勉強して知ったことでして、まだ未熟で確証もないですが、もしupdateがあれば幸いです^^