🀗

テヌブルデヌタ向けの自然蚀語特城抜出術

に公開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_tokenizerをCountVectorizer、TfidfVectorizerなどの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でよくあるメ゜ッドです。このメ゜ッドがあるクラスには他にfitずtransformずいうメ゜ッドが実装されおいたす。

このコラムではfit、transform、fit_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にはいろいろなアルゎリズムが実装されおいたすが、よく甚いられるのはTruncatedSVD、NMF、LatentDirichletAllocationの皮です。

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

1
koukyo1994koukyo1994

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

ちょっず動䜜を確認しお芋ようず思いたす

1
hirayukihirayuki

こちらこそありがずうございたす。
もしかしたら最近のリリヌスで出たのかもしれたせん🙇‍♂🙇‍♂
私も本蚘事を芋ながら勉匷しお知ったこずでしお、ただ未熟で確蚌もないですが、もしupdateがあれば幞いです^^