テーブルデータ向けの自然言語特徴抽出術
はじめに
この記事は、自然言語を含むカラムを持ったテーブルデータにおいて、教師あり学習用にテキストのカラムを固定長の特徴ベクトルに直す方法をまとめたものです。
例としてあげるデータは全て、atmaCup#10のものです。また、この記事の内容はこちらのノートブックで実験を行っています。
データの例。'title'、'description'など自然言語を含むカラムが存在する。
参考: 自然言語処理におけるEmbeddingの方法一覧とサンプルコード
Bag of Wordsベースの手法
文書をトークンの集合として扱う手法です。トークンとしてはよく単語が選ばれますが、自分でtokenizerを設定して文章を単語以外のtokenの集合として扱うこともできます。また、日本語などの言語においてはトークン化が自明でないため、MeCabなどを用いてトークン化することがかなり多いです。
コラム MeCabを用いたトークン化
MeCabのインストール方法は次の記事が参考になります。
その上で次のような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
の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があります。
これら全ての実装が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は単語ベクトルをいい感じに直してくれる手法です。
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類似度は
として計算されます。これを特徴量の行列に対して行う場合は次のようになります。
(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
素敵なまとめありがとうございます!!
BERTモデルについて質問なのですが、input_tensorとmask_tensorを導出する上で、以下の公式ドキュメントを読むと
tokenizer(batch_sentences, max_length=30, padding="max_length", truncation=True, return_tensors="pt")
でまとめられるように感じたのですが、何か意図はございますか??コメントありがとうございます。
特に深い意図はなく、その導出を知りませんでした。ありがとうございます!
ちょっと動作を確認して見ようと思います
こちらこそありがとうございます。
もしかしたら最近のリリースで出たのかもしれません!🙇♂️🙇♂️
私も本記事を見ながら勉強して知ったことでして、まだ未熟で確証もないですが、もしupdateがあれば幸いです^^