sey323’s blog

すーぱーえんじにあ

英文の自然言語処理で利用する前処理チートシート

概要

最近自然言語処理を扱うことになり、基本的な前処理を調べて実行する機会があったので、その際に利用したコードをまとめておく。 手法の理論とかの具体的な説明は各専門家に任せて、似たような処理を行いたい時のチートシート的な感じでまとめておく。

実際に利用したノートブックは以下の通り。

全体像

自然言語処理機械学習するための前処理として、一般的に以下のことが実施される。

  1. クリーニング
  2. 単語のベクトル化
  3. 次元削減

1. クリーニング

クリーニングでは文章に含まれる、学習に不要・または悪影響を及ぼすような文字を取り除く作業が行われる。例えば、文中の「,(カンマ)」や「.(ピリオド)」といった記号や、英語であれば(I,at,of,a,an)のような文章に多用される単語を元データから除去する。

1-1. TextHero

TextHeroは、英語の前処理を簡単に行えるようにしたものである。一般的に英語の前処理には、大文字を小文字に統一したり、空白の除去、StopWordsと呼ばれる(I,at,of,a,an)を除去する方法が用いられる。 TextHeroはそういった面倒な前処理を簡単に実装できるライブラリとなっている。

公式ドキュメントは以下の撮り。

import texthero as hero
import pandas as pd

df = pd.read_csv(
    "https://github.com/jbesomi/texthero/raw/master/dataset/bbcsport.csv"
)

def texthero_clean(input_df: pd.Series):
    '''
    英語の自然言語が含まれるDataFrameに対して、TextHeroを用いたクリーニング処理を行う。
    '''
    input_df = input_df.fillna('missing').astype(str) # 空白をmissingで埋める
    
    custom_pipeline = [
        hero.preprocessing.fillna, # null埋め
        hero.preprocessing.lowercase, # 全単語を小文字
        hero.preprocessing.remove_digits, # 数字を除去
        hero.preprocessing.remove_punctuation, # 句読点を除去
        hero.preprocessing.remove_diacritics, # 分音記号を除去
        hero.preprocessing.remove_stopwords, # 一般的であるという単語(I,at,of,a,an)
        hero.preprocessing.remove_whitespace, # 空白
        hero.preprocessing.stem # 改行文字を取り除く. https://texthero.org/docs/api/texthero.preprocessing.stem
    ]
    texts = hero.clean(input_df, custom_pipeline)
    del input_df, custom_pipeline
    return texts

df['clean_text'] = texthero_clean(df['text'])

結果を出力して確認する。↓原文

df['text'] 
'Claxton hunting first major medal\n\nBritish hurdler Sarah Claxton is confident she can win her first major medal at next month\'s European Indoor Championships in Madrid.\n\nThe 25-year-old has already smashed the British record over 60m hurdles twice this season, setting a new mark of 7.96 seconds to win the AAAs title. "I am quite confident," said Claxton. "But I take each race as it comes. "As long as I keep up my training but not do too much I think there is a chance of a medal." Claxton has won the national 60m hurdles title for the past three years but has struggled to translate her domestic success to the international stage. Now, the Scotland-born athlete owns the equal fifth-fastest time in the world this year. And at last week\'s Birmingham Grand Prix, Claxton left European medal favourite Russian Irina Shevchenko trailing in sixth spot.\n\nFor the first time, Claxton has only been preparing for a campaign over the hurdles - which could explain her leap in form. In previous seasons, the 25-year-old also contested the long jump but since moving from Colchester to London she has re-focused her attentions. Claxton will see if her new training regime pays dividends at the European Indoors which take place on 5-6 March.\n'

前処理後のテキスト

df['clean_text'][0] 
'claxton hunt first major medal british hurdler sarah claxton is confid she can win her first major medal at next month s european indoor championship in madrid the year old has alreadi smash the british record over 60m hurdl twice this season set a new mark of second to win the aaa titl i am quit confid said claxton but i take each race as it come as long as i keep up my train but not do too much i think there is a chanc of a medal claxton has won the nation 60m hurdl titl for the past three year but has struggl to translat her domest success to the intern stage now the scotland born athlet own the equal fifth fastest time in the world this year and at last week s birmingham grand prix claxton left european medal favourit russian irina shevchenko trail in sixth spot for the first time claxton has onli been prepar for a campaign over the hurdl which could explain her leap in form in previous season the year old also contest the long jump but sinc move from colchest to london she has re focus her attent claxton will see if her new train regim pay dividend at the european indoor which take place on march'

基本的な前処理を簡単に実現できるので、ほとんどの場合の前処理に関してはTextHeroで事足りそう。

2. 単語のベクトル化

文章に対して機械学習を行うために、テキストをベクトルの数値表現へ変換する。

Word2Vec

Word2Vecは単語のベクトル表現のうち、SkipGramという手法で学習された単語のベクトル表現の1つである。SkipGramは、単語を前後の単語の出現頻度を測定することで、その単語のベクトル表現を獲得する手法である。

今回は自分で新規に学習してWord2Vecのモデルを作成するのではなく、公開されている学習済み重みを利用するパターンで実装を行う。広く利用されているものとして、Googleが公開しているGoogleNews-vectors-negative300がある。こちらはGoogle News Datasetに含まれる100億単語を学習して作成されたもので、入力した単語を300次元のベクトルに変換できる。

英語のベクトル表現は、以下のリンクからダウンロード可能である。ダウンロードしたファイルを./GoogleNews-vectors-negative300.binとして、以降に示すスクリプトと同じパスに保存する。

各言語のベクトル表現は以下のリンクから取得できるので、他の言語を利用したい場合は、こちらから利用したい言語のものを探す。

上記のGoogleNewsと後で紹介するFastTextの、どちらかを用いて単語のベクトル表現を獲得するクラスを以下に示す。初期化の時にmodel_nameで指定した重みを、単語のベクトル化の際に利用する。FastTextもGoogleNewsのモデルもベクトル化の手順は同じなので、1つのクラスで使い分けられるようにした。

# Word2Vec用のクラス

import gensim
import numpy as np
import pandas as pd

class SentenceVectorizer():
    def __init__(self, model_name="fasttext"):
        if model_name == "fasttext":
            self._model = gensim.models.KeyedVectors.load_word2vec_format(
                "../signate-paper/Fasttext-vectors300.vec", binary=False
            )
        elif model_name == "googlenews":
            self._model = gensim.models.KeyedVectors.load_word2vec_format(
                "../signate-paper/GoogleNews-vectors-negative300.bin", binary=True
            )
        else:
            exit()
        self.out_dim = 300
        self.model_name = model_name

    def vectorize(self, sentence: str) -> pd.Series:
        """
        1つの文章に対するWord2Vecの処理の実行
        """
        func = np.median
        return pd.Series(func(
            [self._model[w] for w in sentence.split(" ") if w in self._model],
            axis=0,
        ))
        
    def fit_transform(self, input_df: pd.DataFrame, col_prefix: str = ''):
        """
        入力されたデータフレームに対してWord2Vecを実行する。
        col_prefixに任意の文字列を指定することで、カラムに任意の接頭詞付与する。
        """
        _prefix = self.model_name
        if col_prefix:
            _prefix = col_prefix + '_' + _prefix
        columns=[_prefix + f'_{i:03}' for i in range(self.out_dim)]
        vectorized_np = input_df.apply(self.vectorize)
        resoponse_df = pd.DataFrame(vectorized_np.values, columns=columns)
        del columns, vectorized_np, col_prefix, input_df
        return resoponse_df

こちらのクラスを用いて、先ほどのTextHeroのサンプルのベクトル化を実施する。

g_model_vectorizer = SentenceVectorizer('googlenews')
gnews_text_df = g_model_vectorizer.fit_transform(df['clean_text'], 'gnews_text')

出力すると以下のように、文章が300次元のベクトルに変換されていることが確認できる。

gnews_text_df.head(1)
    herosample_googlenews_000   herosample_googlenews_001   herosample_googlenews_002   herosample_googlenews_003   herosample_googlenews_004   herosample_googlenews_005   herosample_googlenews_006   herosample_googlenews_007   herosample_googlenews_008   herosample_googlenews_009   ... herosample_googlenews_290   herosample_googlenews_291   herosample_googlenews_292   herosample_googlenews_293   herosample_googlenews_294   herosample_googlenews_295   herosample_googlenews_296   herosample_googlenews_297   herosample_googlenews_298   herosample_googlenews_299
0  0.010895   0.028931   0.015381   0.07373    0.032227   -0.080322  -0.020935  -0.099121  0.076172   0.121338   ... -0.031494  0.023804   -0.098633  -0.010895  -0.026733  -0.063232  -0.023193  -0.002747  0.00325    -0.045532
1 rows × 300 columns

FastText

FastTextは、Word2Vecの中でも人間の感覚に近い活用形をまとめられるようなモデルとなっており、近年注目されている手法である。

詳細に関しては以下のリンクを参照。

FastTextの有用性に関しては、1つ目の記事の以下の箇所で説明されている。

fastTextでは、Word2Vecとその類型のモデルでそれまで考慮されていなかった、「活用形」をまとめられるようなモデルになっています。具体的には、goとgoes、そしてgoing、これらは全て「go」ですが、字面的にはすべて異なるのでこれまでの手法では別々の単語として扱われてしまいます。そこで、単語を構成要素に分解したもの(イメージ的には、goesなら「go」「es」)を考慮することで、字面の近しい単語同士により意味のまとまりをもたせるという手法を提案しています(Subword model)。

このように、確かにFastTextの方が単語のベクトル表現としてはWord2Vecよりは優れていそうである。しかしケースバイケースということで、どちらの方が全ての場合において優れている、ということではなさそう。

FastTextを利用し文章のベクトル化を実施する。以下の記事のダウンロードリンクから学習済みモデルをダウンロードし、Fasttext-vectors300.vecという名前で上記のクラスと同じディレクトリに配置する。

その後、先ほどのSentenceVectorizer()を利用して単語の分散表現を獲得する処理を実行する。

fasttext_vectorizer = SentenceVectorizer('fasttext')
fasttext_text_df = fasttext_vectorizer.fit_transform(df['clean_text'], 'fasttext_text')

こちらもWord2Vecの場合と同様に、単語が300次元のベクトルに変換されていることが確認できた。

fasttext_text_df.head(1)
    herosample_googlenews_000   herosample_googlenews_001   herosample_googlenews_002   herosample_googlenews_003   herosample_googlenews_004   herosample_googlenews_005   herosample_googlenews_006   herosample_googlenews_007   herosample_googlenews_008   herosample_googlenews_009   ... herosample_googlenews_290   herosample_googlenews_291   herosample_googlenews_292   herosample_googlenews_293   herosample_googlenews_294   herosample_googlenews_295   herosample_googlenews_296   herosample_googlenews_297   herosample_googlenews_298   herosample_googlenews_299
0  0.010895   0.028931   0.015381   0.07373    0.032227   -0.080322  -0.020935  -0.099121  0.076172   0.121338   ... -0.031494  0.023804   -0.098633  -0.010895  -0.026733  -0.063232  -0.023193  -0.002747  0.00325    -0.045532
1 rows × 300 columns

BERT

BERTはGoogleが2018年に発表した自然言語処理モデルの1つで、自然言語処理に関するほとんどのタスクで当時の最高の性能を出したモデルである。単語のベクトル表現の取得に関しても高い精度を誇っているため、多くの場合で上記のWord2VecではなくBERTが使われることが多い。

BERTに関する詳細は以下の記事を参照。

今回はPytorchで実装する。

import torch
import transformers

class BertVectorizer:
    """
    事前学習済み BERT モデルを使った文章のベクトル化
    """
    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 = transformers.AutoTokenizer.from_pretrained(self.model_name)
        self.model = transformers.AutoModel.from_pretrained(self.model_name)
        self.model = self.model.to(self.device)
        self.max_len = max_len
        self.out_dim = 768 # 出力の次元数。利用するモデルに依存する。

    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)

        output = self.model(inputs_tensor, masks_tensor)
        seq_out, pooled_out = output['last_hidden_state'], output['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()
        
    def fit_transform(self, df: pd.Series, col_prefix: str = ''):
        """
        入力されたデータフレームに対してBERTを実行する。
        col_prefixに任意の文字列を指定することで、カラムに任意の接頭詞付与する。
        """
        _prefix = self.model_name
        if col_prefix:
            _prefix = col_prefix + '_' + _prefix
        columns=[_prefix + f'_{i:03}' for i in range(self.out_dim)]
        vectorized_np = np.array([self.vectorize(x) for x in df])
        resoponse_df = pd.DataFrame(vectorized_np, columns=columns)
        del columns, vectorized_np, col_prefix
        return resoponse_df

同様に実行してみる。DeepLearningの手法の1つでCPU実行だと時間がかかるので、実際にコンペで利用する場合はGoogle Colabなどを利用した方が良さそう。

bert_vectorizer = BertVectorizer()
bert_text_df = bert_vectorizer.fit_transform(df['clean_text'])
    bert-base-uncased_000   bert-base-uncased_001   bert-base-uncased_002   bert-base-uncased_003   bert-base-uncased_004   bert-base-uncased_005   bert-base-uncased_006   bert-base-uncased_007   bert-base-uncased_008   bert-base-uncased_009   ... bert-base-uncased_758   bert-base-uncased_759   bert-base-uncased_760   bert-base-uncased_761   bert-base-uncased_762   bert-base-uncased_763   bert-base-uncased_764   bert-base-uncased_765   bert-base-uncased_766   bert-base-uncased_767
0  -0.049896  -0.053156  0.439427   -0.175278  0.102648   -0.09097   0.156063   -0.070803  -0.012865  -0.195102  ... 0.381639   -0.290457  -0.07601   -0.035404  0.126096   -0.341293  -0.095096  -0.226657  -0.085968  -0.037578

次元削減

上記の手順で得られた単語のベクトル表現は、多くの場合が高次元のベクトルのため、機械学習などで用いられる場合、次元削減により次元圧縮した後に利用されることが多い。

3-1. 主成分分析(PCA)

次元削減の代表といえば、主成分分析(PCA)がある。こちらはsklearnで実装済みのものがあるので、簡単に利用可能である。

from sklearn.decomposition import PCA

def pca_reduce(input_df: pd.Series, col_prefix: str=None, n_components: int=2):
    """PCAで圧縮
    """
    _prefix = "pca"
    if col_prefix:
        _prefix = col_prefix + '_' + _prefix
    columns=[_prefix + f'_{i:03}' for i in range(n_components)]
    return pd.DataFrame(
            PCA(n_components=n_components).fit_transform(input_df),
            columns=columns,
        )

FastTextを用いてベクトル化した結果に対して、PCAで次元削減を行う。結果を出力してみると、300次元のベクトルが2次元に削減されていることが確認できる。

fasttext_pca = pca_reduce(fasttext_text_df, 'herosample_fasttext')
fasttext_pca.head(1)
herosample_fasttext_pca_000 herosample_fasttext_pca_001
0  0.019008   -0.099318

3-2. t-SNE

PCAは、多次元かつ非線形構造をもつデータに対して弱いという欠点があり、代わりに利用されるものとしてt-SNEがある。こちらは圧縮後の次元が、原則2次元、または3次元という制限はあるが、高速に学習可能だったり、PCAより性能が良くなるケースが多かったりという理由から使われることが多い。

t-SNEはsklearnよりもbhtsneが利用されることが多いため、そちらで実装する。

import bhtsne

def tsne_reduce(input_df: pd.Series, col_prefix: str=None, n_components: int=2):
    """t-SNEで圧縮
    """
    _prefix = "tsne"
    if col_prefix:
        _prefix = col_prefix + '_' + _prefix
    _bhtsne = bhtsne.tsne(input_df.astype(np.float64), dimensions=n_components, rand_seed=42)
    columns=[_prefix + f'_{i:03}' for i in range(n_components)]
    return pd.DataFrame(_bhtsne, columns=columns)

PCAと同様にFastTextを用いてベクトル化した結果に対してtsneで次元削減を行う。

fasttext_tnse = tsne_reduce(fasttext_text_df, 'herosample_fasttext')
fasttext_tnse.head(1)
herosample_fasttext_tsne_000    herosample_fasttext_tsne_001
0  1.509905   -7.385899

3-3. Umap

Umapは、t-SNEより高速・高性能に次元削減を行える手法である。最近は次元削減の方法としてt-SNEよりもこちらが利用されることも多い。

Umapはsklearnに実装済みのものがないため、追加でUmapライブラリをインストールする必要がある。

import umap

def umap_reduce(input_df, col_prefix=None, n_components=10):
    """umapで圧縮
    """
    _prefix = "umap"
    if col_prefix:
        _prefix = col_prefix + '_' + _prefix
    columns=[_prefix + f'_{i:03}' for i in range(n_components)]
    return pd.DataFrame(
            umap.UMAP(n_components=n_components).fit_transform(input_df),
            columns=columns,
        )

PCAと同様にFastTextを用いてベクトル化した結果に対してUmapで次元削減を行う。

fasttext_umap = umap_reduce(fasttext_text_df, 'herosample_fasttext')
fasttext_umap.head(1)
    herosample_fasttext_umap_000    herosample_fasttext_umap_001    herosample_fasttext_umap_002    herosample_fasttext_umap_003    herosample_fasttext_umap_004    herosample_fasttext_umap_005    herosample_fasttext_umap_006    herosample_fasttext_umap_007    herosample_fasttext_umap_008    herosample_fasttext_umap_009
0  10.521688  7.048684   2.234146   3.02162    4.69678    4.38614    5.731061   5.076957   1.378522   4.385059