ディープラーニングで多クラス文書分類を実装

2020年05月04日

文書分類とは

文書分類は、自然言語処理の一般的なタスクであり、様々な長さの一連の文書を単一のカテゴリーに変換します。TwitterやFacebookなどのプラットフォーム上で有害なコメントを自動検出、迷惑メール判定オンライン広告のカスタマイズなど、文書分類は様々な分野で活用されています。上記の例では、コメントが有害か、有害でないか、あるいはレビューが本物か本物でないかなど、二つしかターゲットクラスがないバイナリターゲットクラスが使われています。

しかし、これは常に当てはまるわけではなく、三つ以上のターゲットクラスが存在する場合もあります。これらは便宜上、多クラス分類と呼ばれ、この記事ではこの多クラス分類を中心に説明していきたいと思います。多クラス分類には次のようなものが含まれます。

  • レビューの感情: ポジティブ、ネガティブ、ニュートラル(三つのターゲットクラス)
  • ジャンルによるニュースの分類 : エンターテインメント、教育、政治など

この記事では、様々なディープラーニング手法を利用した多クラス文書分類について見ていきます。

 

今回用いるデータセット

KaggleのUCIML医薬品レビューデータセットを利用します。20万人以上の患者による医薬品のレビューと関連症状が含まれています。このデータセットは多くの列から構成されていますが、今回の自然言語処理タスクでは二つの列だけを使用します。そこで、データセットは次のようになります。

 

課題: 医薬品のレビューに基づいて、上位の症状を分類する。

 

Word2Vecについて

文書分類についてさらに掘り下げる前に、単語を数値で表す方法が必要です。大部分の機械学習モデルは語彙を理解するために、文書ではなく数値が必要だからです。

この目標を達成するための一つの方法は、One-hotエンコーディングの単語ベクトルを利用することですが、これは適切な選択肢ではありません。広範な語彙を与えられると多くのスペースが必要になるだけでなく、異なる単語間の類似度を正確に表現することができないからです。例えば、単語ベクトルxとyの間のコサイン類似度は次の式で計算できます。

 

 

 

One-hotベクトルでは構造上、異なる単語間の類似度が常に0になってしまいます。 Word2Vecは、固定長の単語ベクトル(通常、語彙のサイズよりはるかに小さい)で単語を表現することにより上記の課題を克服します。異なる単語間の類似性や類似関係も捉えることができます。

 

Word2Vecの単語ベクトルを利用すると様々なアナロジーを捉えることができるので、従来は不可能であった、単語の代数的操作を行うことができます。

例: 王様 – 男性 + 女性 = 女王

Word2Vecベクトルは単語間の類似性を見つけるためにも役立ちます。「良い」に類似した単語を探すと「素晴らしい」「優れた」などが見つかります。文書分類にとって非常に有益なのは、Word2Vecが持つこの特性です。これによって、ディープラーニングネットワークは「良い」と「優れた」が類似した意味を持つ単語であることを理解します。

簡単に言うと、Word2Vecは単語の固定長のベクトルを作成し、辞書内の全ての単語(および一般的な二語連語)のd次元ベクトルを提供します。これらの単語ベクトルは通常、ウィキペディアやTwitterなど大規模なコーパスで事前学習が行われて提供されています。最もよく利用されている事前学習済み単語ベクトルは、300次元の単語ベクトルが含まれるGloveやfast textです。この記事では、Gloveの単語ベクトルを利用します。

 

データの前処理

大部分の場合、文書データは完全にクリーンな状態ではありません。異なるソースから取得したデータは異なる特性を持っているため、文書の前処理が分類パイプラインで最も重要なステップの一つになります。例えば、Twitterからの文書データは、Facebookや他のニュース/ブログプラットフォームの文書データとは異なるので、それぞれ違った方法で処理する必要があります。 とはいえ、この記事で取り上げる手法は、自然言語処理タスクで遭遇するほぼどのような種類のデータにも十分利用できます。

 

特殊文字や句読点の除去

ここで使用する前処理パイプラインは、分類タスクで利用する分散表現Word2Vecに大きく依存しています。前処理は原則として、単語の分散表現を学習する前に使用された前処理と一致しなければなりません。ほとんどの分散表現では、句読点などの特殊文字にはベクトル値が提供されないので、最初に行うべき前処理は、文書データから特殊文字を除去することです。

# 全ての文書分類手法に共通する前処理の例。
import re
def clean_text(x):
    pattern = r'[^a-zA-z0-9\s]'
    text = re.sub(pattern, '', x)
    return x

 

数字の除去

数字を#に変換する理由は、Gloveなどほとんどの分散表現で、文書をこの方法で前処理しているからです。pythonを使用する際のちょっとしたヒントですが、以下のコードのif文を利用して、文書内に数字が含まれるかを事前に確認します。if文は常に re.sub メソッドより速く、ここで使用する文書にはほとんど数字が含まれていないからです。

def clean_numbers(x):
    if bool(re.search(r'\d', x)):
        x = re.sub('[0-9]{5,}', '#####', x)
        x = re.sub('[0-9]{4}', '####', x)
        x = re.sub('[0-9]{3}', '###', x)
        x = re.sub('[0-9]{2}', '##', x)
    return x

 

短縮形の修正

短縮形とは、英単語「ain’t」や「aren’t」などのようにアポストロフィーを伴う単語です。文書を標準化したいので、これらを短縮する前の元の形にすることは理にかなっています。以下では、収縮写像と正規表現関数を利用してこれを行っています。

contraction_dict = {"ain't": "is not", "aren't": "are not","can't": "cannot", "'cause": "because", "could've": "could have"}
def _get_contractions(contraction_dict):
    contraction_re = re.compile('(%s)' % '|'.join(contraction_dict.keys()))
    return contraction_dict, contraction_re
contractions, contractions_re = _get_contractions(contraction_dict)
def replace_contractions(text):
    def replace(match):
        return contractions[match.group(0)]
    return contractions_re.sub(replace, text)
# Usage
replace_contractions("this's a text with contraction")

上記の手法のほかに、スペルミスの修正も行う場合があります。しかし、紙面の都合上、今回は省略します。

 

データ表現: シーケンスの作成

自然言語処理でディープラーニングが利用される理由は、文書データから手動で特徴を抽出しなくてよいからです。ディープラーニングアルゴリズムは一連の文書を入力として受け取ると、人間と同じようにその構造を学習します。マシンは単語を理解できないので、数値形式のデータが必要です。文書データを一連の数値として表現するのはそのためです。 

これを実行する方法を理解するためには、Kerasのトークナイザー関数に関する知識が多少必要です。(他のトークナイザーも利用可能です)

 

トークナイザー

トークナイザーとは、文を単語に分割するユーティリティ関数です。

keras.preprocessing.text.Tokenizer トークナイザーは、文書コーパスに最も頻出する単語のみを保持しながら、文書をトークン(単語)に分割(トークン化)します。 

#Signature:
    Tokenizer(num_words=None, filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n',
lower=True, split=' ', char_level=False, oov_token=None, document_count=0, **kwargs)

num_wordsパラメータによって、事前に定められた数の単語のみを文書内に保持します。稀にしか出現しない単語を考慮してモデルに多くのノイズを与えたくないので、これは便利です。実際のデータでは、num_wordsパラメータを使って捨てる単語のほとんどが通常、スペルミスのある単語です。さらに、トークナイザーはデフォルトで一部の不要なトークンをフィルタリングし、文書を小文字に変換します。

また、データに適合させると、トークナイザーが単語のインデックス(単語に一意の数値を割り当てるために利用できる辞書)を作成するので、tokenizer.word_indexでアクセスすることが可能です。インデックス化された辞書の単語は出現頻度でランク付けされています。

トークナイザーを使用するためのコード全体は次のようになっています。

from keras.preprocessing.text import Tokenizer
## Tokenize the sentences
tokenizer = Tokenizer(num_words=max_features)
tokenizer.fit_on_texts(list(train_X)+list(test_X))
train_X = tokenizer.texts_to_sequences(train_X)
test_X = tokenizer.texts_to_sequences(test_X)

train_X 及び test_X はコーパス内の文書のリストです。

 

シーケンスのパディング

モデルを学習させる際は通常、同じ長さ(同じ数の単語/トークン)のシーケンスを使用します。シーケンスの長さを指定するためには、maxlen パラメータを利用します。

 

train_X = pad_sequences(train_X, maxlen=maxlen)
test_X = pad_sequences(test_X, maxlen=maxlen)

これで、教師データに同じ長さの数値のリストが含まれました。また、コーパスで最も多く出現する単語の辞書である word_index も作成されています。

 

ターゲット変数をラベルエンコーディング

pytorchモデルを利用するためには、ターゲット変数を文字列ではなく数値に変換する必要があります。ここでは、ターゲット変数の変換に sklearn のラベルエンコーダーを利用することができます。

from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
train_y = le.fit_transform(train_y.values)
test_y = le.transform(test_y.values)

 

単語分散表現のロード

まず、単語分散表現の一つであるGloveをロードする必要があります。

    EMBEDDING_FILE = 'data/glove.840B.300d.txt'
    def get_coefs(word,*arr): return word, np.asarray(arr, dtype='float32')[:300]
    embeddings_index = dict(get_coefs(*o.split(" ")) for o in open(EMBEDDING_FILE))
    all_embs = np.stack(embeddings_index.values())
    emb_mean,emb_std = -0.005838499,0.48782197
    embed_size = all_embs.shape[1]
nb_words = min(max_features, len(word_index)+1)
    embedding_matrix = np.random.normal(emb_mean, emb_std, (nb_words, embed_size))
    for word, i in word_index.items():
        if i >= max_features: continue
        embedding_vector = embeddings_index.get(word)
        if embedding_vector is not None:
        embedding_matrix[i] = embedding_vector
        else:
        embedding_vector = embeddings_index.get(word.capitalize())
        if embedding_vector is not None:
            embedding_matrix[i] = embedding_vector
    return embedding_matrix
embedding_matrix = load_glove(tokenizer.word_index)

これらのGloveベクトルをダウンロードするフォルダのパスを必ず入力してください。embeddings_index はkey(キー)が単語、value(値)が単語ベクトル、np.array が長さ300の辞書です。この辞書の長さ(要素数)は約10億です。

ここでは、word_index に含まれる単語の分散表現だけが必要なので、トークナイザーから単語インデックスを利用して必要な分散表現が含まれる行列を作成します。

ディープラーニングモデル

  1. TextCNN

畳み込みニューラルネットワーク(CNN)を利用して文書分類をするアイデアが最初に提示されたのは、Yoon Kimによる論文「文書分類のための畳み込みニューラルネットワーク」でした。

このアイデアの中心となる概念は、文書を画像として見ることです。例えば、単語種類総数70、埋め込みベクトルの次元数300の文があれば、この文を表現するために70×300の数値の行列を作成できます。画像には、ピクセル値を個々の要素とする行列もあります。しかし、このタスクでは、画像ピクセルの代わりに、行列として表現された文書が入力となります。行列の各行は一単語のベクトルに相当します。

出典: https://www.aclweb.org/anthology/D14-1181

画像の場合は、畳み込みフィルタを水平方向と垂直方向の両方に移動させますが、文書の場合は、カーネルサイズをfilter_size x embed_size、つまり(3,300)に固定して垂直方向下方にだけ移動させます。ここで使用しているフィルタのサイズは3なので一度に単語を三つずつ畳み込みます。畳み込みフィルタが単語の分散表現を分割せず、各単語の完全な分散表現を見ることができるので、このアイデアは正しいようです。また、一単語、二単語、三単語、五単語のコンテキストウィンドウをそれぞれ見ていくので、フィルタのサイズをユニグラム、バイグラム、トリグラムなどとして考えることもできます。

pytorchでコーディングされた文書分類の畳み込みニューラルネットワークは次の通りです。

 

class BiLSTM(nn.Module):    def __init__(self):
        super(BiLSTM, self).__init__()
        self.hidden_size = 64
        drp = 0.1
        n_classes = len(le.classes_)
        self.embedding = nn.Embedding(max_features, embed_size)
        self.embedding.weight = nn.Parameter(torch.tensor(embedding_matrix, dtype=torch.float32))
        self.embedding.weight.requires_grad = False
        self.lstm = nn.LSTM(embed_size, self.hidden_size, bidirectional=True, batch_first=True)
        self.linear = nn.Linear(self.hidden_size*4 , 64)
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(drp)
        self.out = nn.Linear(64, n_classes)
def forward(self, x):
        #rint(x.size())
        h_embedding = self.embedding(x)
        #_embedding = torch.squeeze(torch.unsqueeze(h_embedding, 0))
        h_lstm, _ = self.lstm(h_embedding)
        avg_pool = torch.mean(h_lstm, 1)
        max_pool, _ = torch.max(h_lstm, 1)
        conc = torch.cat(( avg_pool, max_pool), 1)
        conc = self.relu(self.linear(conc))
        conc = self.dropout(conc)
        out = self.out(conc)
        return out

  1. 双方向再帰型ニューラルネットワーク(LSTM/GRU)

TextCNNは、「New York」など、周辺単語をまとめて捉えることができるので、文書分類に適しています。しかし、一連の文書で提供される全てのコンテキストを把握することはできません。特定の単語が以前の単語や前の文書の単語に依存している場合、データの連続的な構造は学習できないのです。

このような時、再帰型ニューラルネットワークが役立ちます。隠れ状態ベクトルを利用して以前の情報を保存しておき、これを現在のタスクと組み合わせることができます。

LSTMは再帰型ニューラルネットワークの一種で、長期間の情報の保持に特化しています。さらに、双方向LSTMは、両方向にコンテキスト情報を保持できるので、文書分類タスクにはとても便利です(ただし、このケースでは未来を把握できないので、時系列予測タスクでは機能しません)

再帰型ニューラルネットワークセルは、隠れ状態ベクトルと単語ベクトルを入力とし、出力ベクトルと次の隠れベクトルを作成するブラックボックスとして捉えるとよいでしょう。このボックスには、損失関数に誤差逆伝播法を用いて調整する必要がある重みが付けられています。また、同じセルが全ての単語に適用されるので、文の単語全てが重みを共有します。この現象は重み共有と呼ばれます。

Hidden state, Word vector ->(RNN Cell) -> Output Vector , Next Hidden state

「you will never believe」など四単語から成るシーケンスの場合、再帰型ニューラルネットワークセルが四つの出力ベクトルを作成し、これが連結されて高密度フィードフォワードアーキテクチャの一部として利用されます。

双方向再帰型ニューラルネットワークにおける唯一の違いは、通常の方法だけでなく逆方向にも文書を読み取ることです。二つの再帰型ニューラルネットワークを並行に並べると、八個の出力ベクトルが取得できます。文書分類機を構築するためには、出力ベクトルを取得した後、一連の高密度の層を通し、最後にソフトマックス層で分類します。

ほとんどの場合、優れた結果を得るためにニューラルネットワークの層を重ねる方法を理解する必要があります。ネットワーク内で複数の双方向GRU/LSTMを利用してパフォーマンスが改善するかどうか試すこともできます。

再帰型ニューラルネットワークには長期的な依存関係を学習できないという弱点があるため、実際には、長期的な依存関係をモデル化する場合には、ほぼ必ずLSTM/GRUが利用されます。この場合、上の図で再帰型ニューラルネットワークのセルをLSTMセルやGRUセルで置き換えて考えるとよいでしょう。

このネットワーク用のpytorchのコード例は次の通りです。

class BiLSTM(nn.Module):    def __init__(self):
        super(BiLSTM, self).__init__()
        self.hidden_size = 64
        drp = 0.1
        n_classes = len(le.classes_)
        self.embedding = nn.Embedding(max_features, embed_size)
        self.embedding.weight = nn.Parameter(torch.tensor(embedding_matrix, dtype=torch.float32))
        self.embedding.weight.requires_grad = False
        self.lstm = nn.LSTM(embed_size, self.hidden_size, bidirectional=True, batch_first=True)
        self.linear = nn.Linear(self.hidden_size*4 , 64)
        self.relu = nn.ReLU()
        self.dropout = nn.Dropout(drp)
        self.out = nn.Linear(64, n_classes)
def forward(self, x):
        #rint(x.size())
        h_embedding = self.embedding(x)
        #_embedding = torch.squeeze(torch.unsqueeze(h_embedding, 0))
        h_lstm, _ = self.lstm(h_embedding)
        avg_pool = torch.mean(h_lstm, 1)
        max_pool, _ = torch.max(h_lstm, 1)
        conc = torch.cat(( avg_pool, max_pool), 1)
        conc = self.relu(self.linear(conc))
        conc = self.dropout(conc)
        out = self.out(conc)
        return out

 

トレーニング

以下は双方向LSTMモデルに学習させるために利用するコードです。このコードは丁寧にコメントが付いているので、コードをよく読み、理解するようにしてください。

n_epochs = 6
model = BiLSTM() #Use CNN_Text() for CNN Model
loss_fn = nn.CrossEntropyLoss(reduction='sum')
optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001)
model.cuda()
# Load train and test in CUDA Memory
x_train = torch.tensor(train_X, dtype=torch.long).cuda()
y_train = torch.tensor(train_y, dtype=torch.long).cuda()
x_cv = torch.tensor(test_X, dtype=torch.long).cuda()
y_cv = torch.tensor(test_y, dtype=torch.long).cuda()
# Create Torch datasets
train = torch.utils.data.TensorDataset(x_train, y_train)
valid = torch.utils.data.TensorDataset(x_cv, y_cv)
# Create Data Loaders
train_loader = torch.utils.data.DataLoader(train, batch_size=batch_size, shuffle=True)
valid_loader = torch.utils.data.DataLoader(valid, batch_size=batch_size, shuffle=False)
train_loss = []
valid_loss = []
for epoch in range(n_epochs):
    start_time = time.time()
# Set model to train configuration
model.train()
avg_loss = 0.
    for i, (x_batch, y_batch) in enumerate(train_loader):
        # Predict/Forward Pass
        y_pred = model(x_batch)
        # Compute loss
        loss = loss_fn(y_pred, y_batch)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        avg_loss += loss.item() / len(train_loader)
    # Set model to validation configuration - Doesn't get trained here
    model.eval()
    avg_val_loss = 0.
    val_preds = np.zeros((len(x_cv),len(le.classes_)))
    for i, (x_batch, y_batch) in enumerate(valid_loader):
        y_pred = model(x_batch).detach()
        avg_val_loss += loss_fn(y_pred, y_batch).item() / len(valid_loader)
        # keep/store predictions
        val_preds[i * batch_size:(i+1) * batch_size] =F.softmax(y_pred).cpu().numpy()
    # Check Accuracy
    val_accuracy = sum(val_preds.argmax(axis=1)==test_y)/len(test_y)
    train_loss.append(avg_loss)
    valid_loss.append(avg_val_loss)
    elapsed_time = time.time() - start_time
    print('Epoch {}/{} \t loss={:.4f} \t val_loss={:.4f} \t val_acc={:.4f} \t time={:.2f}s'.format(
            epoch + 1, n_epochs, avg_loss, avg_val_loss, val_accuracy, elapsed_time))

アウトプットは以下の通りになります。

結果/予測

以下は双方向LSTMモデルの結果の混同行列です。このモデルは検証データセットで87%の精度を上げ、かなりよく機能していることがわかります。 

興味深いのは、パフォーマンスが良くない部分は、混乱するのも無理はない箇所であったということです。例えば、体重減少と肥満、鬱病と不安神経症、鬱病と双極性障害などの間でモデルに混乱が見られます。私は専門家ではありませんが、これらの疾患はかなり似ているように感じられます。

import scikitplot as skplt
y_true = [le.classes_[x] for x in test_y]
y_pred = [le.classes_[x] for x in val_preds.argmax(axis=1)]
skplt.metrics.plot_confusion_matrix(
    y_true,
    y_pred,
    figsize=(12,12),x_tick_rotation=90)

本記事では、LSTMや畳み込みニューラルネットワークなど、文書分類用のディープラーニングアーキテクチャと自然言語処理のディープラーニングで利用される様々なステップについてご説明いたしました。このモデルのパフォーマンスを改善するためにできることはまだたくさんあります。学習率の変更、学習率スケジュールの使用、追加機能の利用、分散表現の強化、スペルミスの削除などです。このボイラープレートコードが文書分類タスクに取り組むためのベースラインになれば幸いです。完全なソースコードはGithubまたはKaggle Kernelでご覧ください。

 

LionbridgeのAI学習データサービスについて

当社はAI向け教師データの作成やアノテーションのサービスを提供し、研究開発を支援しています。世界の各タイムゾーンを渡る、100万人のコントリビューターが登録されており、大規模なAIプロジェクトも素早く仕上げることができます。文書分類サービスにつきましては、こちらのページをご覧頂くか、こちらからお問い合わせください。

※ 本記事は2020年4月2日、弊社英語ブログに掲載された寄稿記事に基づいたものです。

AI開発に肝心な学習データを提供いたします

メディア掲載結果

    AI・機械学習の最新情報をお届けします!

    Lionbridge AIのブログで紹介している事例記事やトレンドニュースといったビジネスに役立つ情報はもちろん、オープンデータセット集なども合わせてメール配信しております。