やむやむもやむなし

自然言語処理やエンジニアリングのメモ

Grad-CAMを使ったNLPモデルの判断根拠の可視化

機械学習モデルの解釈性は業務で使う上ではなぜそのような予測を行ったかの判断根拠の可視化として、また学習させたモデルをデバックする際にどんな入力に反応して誤予測を引き起こしてしまったか分析する上で非常に重要な要素です。
画像分野ではGrad-CAMと呼ばれる勾配を使った予測根拠の可視化手法が提案されており、今回はその手法を使ってNLP向けのCNNモデルの判断根拠を可視化していきます。
実験で使用したノートブックはGithub上で公開しています。

github.com

機械学習モデルの解釈性

機械学習モデルに対する解釈性は近年では特に重要なトピックです。例えば

  • 業務の自動化を機械学習で行う場合に説明責任が生じる
  • DNNのデバッグをして性能改善を行いたい

といったときに機械学習モデルの解釈性は必要になります。
機械学習モデルの解釈性についてはステアラボ人工知能セミナーでの原聡先生の資料がとても分かりやすいです。

機械学習の解釈性には「大域的な説明(Global Interpretability)」と「局所的な説明(Local Interpretability)」のふたつに大きく分けられます。

大域的な説明

大域的な説明は複雑なモデルを決定木や線形回帰といった解釈が容易なモデルで近似することでモデルを説明する方法です。
説明したいモデルの全体を解釈しやすいモデルで近似することで、モデルがどのように予測を行うかというモデルの内部を説明しているのが特徴です。

局所的な説明

モデル全体を説明する大域的な説明とは異なり、特定の入力に対する予測結果の説明を行うのが局所的な説明です。
これは入力のどの特徴量、あるいはどの訓練データによってその予測が行われたかの根拠を提示するために用いられます。
有名な手法では「LIME(Local Interpretable Model-agnostic Explanations)」があります。
LIMEは説明を行いたい予測への入力データに摂動を加えたデータセットを生成し、それに対して説明を行いたいモデルfがどのように振る舞うかを解釈可能なモデルg (e.g. 決定木やLasso)で近似することで、特定の予測に対する特徴量の寄与度を測る方法です。
説明したいモデルをf、近似するモデルをg、説明した入力データxと摂動を加えたデータ間の距離を測る関数\pi_x、近似するモデルの複雑度を表す関数 \Omegaを使って以下の式を求めることでLIMEによって説明を行うモデルを構築します。


\begin{equation} 
\xi(x) = argmin_{g \in G} L(f, g, \pi_x) + \Omega(g) 
\end{equation}

深層学習のモデルの説明には局所的な説明を行うものが多く、後述するGrad-CAMも局所的な説明を行う手法の一種です。

Grad-CAM

Grad-CAMは画像認識の分野で使われている、分類の根拠を提示する局所的な説明手法です。
Grad-CAMではVGGやResNetのようにConvolutionやpoolingを繰り返し最後に全結合層に接続してクラス分類を行うようなモデルに対して、全結合層の前のConvolution層で生成された特徴マップが、予測したラベルに対してどれくらい影響を与えているかを以下のように勾配を使って計算します。


\begin{equation} 
\quad\quad \alpha_k^c = {\frac{1}{Z} \sum_i \sum_j} \frac{\partial y^c}{\partial A_{ij}^k} \quad\quad (1)
\end{equation}

\begin{equation} 
\quad\quad L^c_{\rm Grad-CAM} = ReLU \left(\sum_k \alpha_k^c A^k \right) \quad\quad (2)
\end{equation}

式(1)では、クラスcに対して特徴マップの各要素が微小に変化した際にクラスの確率がどれくらい変化するかを計算して特徴マップないで平滑化を行い、そして式(2)でその値を使って特徴マップ領域内の重要度を計算しています。
これにより、モデルの予測に対して入力画像のどの領域が影響を与えるかを計算して可視化することが可能となります。
またこれは予測に対する各特徴マップの勾配が計算できれば良いため、画像分類に限らず、画像キャプション生成やVisual Question Answeringなどにも利用することができます。

f:id:ymym3412:20190319002222p:plain

f:id:ymym3412:20190319002450p:plain

f:id:ymym3412:20190319002505p:plain

CNNの分類モデルの判断根拠の可視化

では画像分野で使われているモデルの説明手法をどうやってNLPで使うのか。
答えはシンプルで、NLP向けのCNNをベースにしたモデルを使えばよいのです。
幸い、以前に日本語の記事のカテゴリ分類を行うCNNモデルを作ったので、それを使ってGrad-CAMによる予測の判断根拠の可視化を行います。

学習

モデルは以前使用したKim[2014]のCNNによる文書分類モデルに少し改良を加えたものを使用します。

import torch
import torch.nn as nn
import torch.nn.functional as F

class  CNN_Text(nn.Module):
    
    def __init__(self, pretrained_wv, output_dim, kernel_num, kernel_sizes=[3,4,5], dropout=0.5, static=False):
        super(CNN_Text,self).__init__()
        
        weight = torch.from_numpy(pretrained_wv)
        self.embed = nn.Embedding.from_pretrained(weight, freeze=False)
        self.convs1 = nn.ModuleList([nn.Conv2d(1, kernel_num, (k, self.embed.weight.shape[1])) for k in kernel_sizes])
        self.bns1 = nn.ModuleList([nn.BatchNorm2d(kernel_num) for _ in kernel_sizes])
        self.dropout = nn.Dropout(dropout)
        self.fc1 = nn.Linear(len(kernel_sizes)*kernel_num, output_dim)
        self.static = static

    def conv_and_pool(self, x, conv):
        x = F.relu(conv(x)).squeeze(3) #(N,Co,W)
        x = F.max_pool1d(x, x.size(2)).squeeze(2)
        return x


    def forward(self, x):
        x = self.embed(x) # (N,W,D)
        
        if self.static:
            x = x.detach()

        x = x.unsqueeze(1) # (N,Ci,W,D)
        x = x.float()
        x = [F.relu(bn(conv(x))).squeeze(3) for conv, bn in zip(self.convs1, self.bns1)] #[(N,Co,W), ...]*len(Ks)

        x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] #[(N,Co), ...]*len(Ks)

        x = torch.cat(x, 1)
        x = self.dropout(x) # (N,len(Ks)*Co)
        logit = self.fc1(x) # (N,C)
        return logit

最適化にはAdaBoundを使用し、400epoch学習させたあとはSGDで学習率を下げながら学習させました。

from tensorboardX import SummaryWriter
import torch
import numpy as np
import adabound

from sklearn.metrics import accuracy_score

def calc_accuray(model, target_preprocessed, writer, ite, mode, use_cuda):
    model.eval()
    feature = torch.LongTensor(target_preprocessed['article'])
    if use_cuda:
        feature = feature.cuda()
    forward = model(feature)
    predicted_label = forward.argmax(dim=1).cpu()
    test_target = torch.LongTensor(np.argmax(target_preprocessed['label'], axis=1))
    accuracy = accuracy_score(test_target.numpy(), predicted_label.numpy())
    writer.add_scalar('data/{}_accuracy'.format(mode), accuracy, ite)
    model.train()
    return accuracy

writer = SummaryWriter()
output_dim = 9
kernel_num = 200
kernel_sizes = [3,4,5,7]
dropout = 0.5

model = CNN_Text(embedding, output_dim, kernel_num, kernel_sizes, dropout)
use_cuda = True
opt = adabound.AdaBound(model.parameters(), lr=1e-3, final_lr=0.1, weight_decay=5e-4)
# opt = adabound.AdaBound(model.parameters(), lr=1e-3, final_lr=0.1)
model.train()
if use_cuda:
    model = model.cuda()
    
for ite, b in enumerate(dp.iterate(preprocessed, batch_size=64, epoch=400)):
    feature = torch.LongTensor(b['article'])
    target = torch.LongTensor(np.argmax(b['label'], axis=1))
    if use_cuda:
        feature = feature.cuda()
        target = target.cuda()
        
    opt.zero_grad()
    logit = model(feature)
    loss = F.nll_loss(F.log_softmax(logit), target)
    loss.backward()
    opt.step()
    writer.add_scalar('data/training_loss', loss.item(), ite)
    
    # check training accuray
    calc_accuray(model, b, writer, ite, 'training', use_cuda)
    
    # check validation accuracy
    if ite % 100 == 0:
        # calc training accuracy
        calc_accuray(model, val_preprocessed, writer, ite, 'validation', use_cuda)

writer.close()

前処理の工夫やモデルの改善により、以前の実験より大幅にtestセットでの性能が改善しました。

              precision    recall  f1-score   support

           0       0.88      0.82      0.85       131
           1       0.75      0.91      0.82       131
           2       0.81      0.74      0.77       130
           3       0.74      0.62      0.68        77
           4       0.94      0.91      0.92       130
           5       0.75      0.90      0.82       126
           6       0.99      0.84      0.91       131
           7       0.93      0.93      0.93       135
           8       0.84      0.88      0.86       115

   micro avg       0.85      0.85      0.85      1106
   macro avg       0.85      0.84      0.84      1106
weighted avg       0.86      0.85      0.85      1106
accuracy: 0.849005424954792

Grad-CAMによる可視化

学習したCNNモデルの予測根拠をGrad-CAMを使って可視化していきます。
PyTorchでのGrad-CAMの実装はCNNを使った分類問題の判断根拠(画像編)のコードをお借りしました。

class GradCAM:
    def __init__(self, model, feature_layer):
        self.model = model
        self.feature_layer = feature_layer
        self.model.eval()
        self.feature_grad = None
        self.feature_map = None
        self.hooks = []

        def save_feature_grad(module, in_grad, out_grad):
            self.feature_grad = out_grad[0]
        self.hooks.append(self.feature_layer.register_backward_hook(save_feature_grad))

        def save_feature_map(module, inp, outp):
            self.feature_map = outp[0]
        self.hooks.append(self.feature_layer.register_forward_hook(save_feature_map))

    def forward(self, x):
        return self.model(x)

    def backward_on_target(self, output, target):
        self.model.zero_grad()
        one_hot_output = torch.zeros([1, output.size()[-1]]).cuda()
        one_hot_output[0][target] = 1
        output.backward(gradient=one_hot_output, retain_graph=True)

    def clear_hook(self):
        for hook in self.hooks:
            hook.remove()

まずは予測ラベルがあっていた場合に、どの単語が予測に影響を与えているかを見ていきます。
モデルはカーネルサイズを3, 4, 5, 7で設定しており、文書中の3-gram, 4-gram, 5-gram, 7-gramの関係を見て畳込んでいます。まずは3-gram単位で重要度のヒートマップを出してみます。

news_id = 20
# Grad-CAMのインスタンスを作成
grad_cam = GradCAM(model=model, feature_layer=model.convs1[0])
# testセットの準備
test_input = test_preprocessed['article'][news_id:news_id+1]
test_tensor = torch.LongTensor(test_input).cuda()
test_target = torch.LongTensor(np.argmax(test_preprocessed['label'][news_id:news_id+1], axis=1))
# モデルでの順伝搬
model_output = grad_cam.forward(test_tensor)
predicted_label = model_output.argmax().item()
# 予測したラベルに対する逆伝搬を計算
grad_cam.backward_on_target(model_output, predicted_label)
# 各特徴マップの要素に対する勾配を取得して平滑化
feature_grad = grad_cam.feature_grad.cpu().data.numpy()[0]
weights = np.mean(feature_grad, axis=(1, 2))
# 重みを特徴マップに掛け合わせ後にReLUを適用
feature_map = grad_cam.feature_map.cpu().data.numpy()
cam = np.sum((weights * feature_map.T), axis=2).T
cam = np.maximum(cam, 0)
# hookの初期化
grad_cam.clear_hook()

# seaornでヒートマップの表示
import seaborn as sns
sns.heatmap(cam)

f:id:ymym3412:20190319013220p:plain

ヒートマップで可視化してみると、冒頭に強く反応している部分があります。Grad-CAMの数値が高い上位10個の3-gramを抽出してみます。

『プロメテウス』
『グラディエーター』
『エイリアン』
824公開
弾が公開
また、劇場
』『グラディエーター
3d作品
エイリアン』『
』の巨匠

このデータでは「movie-enter」という映画に関するメディアの記事を正しく予測できたため、特に映画に関する単語に強く反応して予測を行えたことが分かりました。
また3, 4, 5, 7-gram全てのGrad-CAMのスコアを足し合わせて平均を取ったヒートマップも作成してみます。

f:id:ymym3412:20190319021710p:plain

全体的にヒートマップがぼやける形になりましたが3-gramの時も強く反応していた冒頭の部分は依然として高い値を保持してることが分かります。

続いて予測が失敗したケース、「dokujo-tsushin」を「peachy」と誤予測してしまった場合の判断根拠を可視化します。前者はエンタメや芸能、後者は主に食べ物系について触れているメディアです。

f:id:ymym3412:20190319013956p:plain

こちらでも一部のブロックに強く反応している部分があります。こちらもGrad-CAMの数値が高い上位10個の3-gramを抽出してみます。

「独通信
は独、
未婚女性のみ
未婚女性の
シングル女性の
独身女性が
の恋や
の恋や
と恋に
女性の恋

どうやら「女性」に関する単語に強く反応して「dokujo-tsushin」を「peachy」と誤予測しているようです。peachyでは女性向けの食事の記事などの割合が多いのでしょうか?
これに基づいてコーパスをもっと深く分析して、前処理にさらなる工夫を加えることができそうです。

まとめ

NLPのDNNモデルの判断根拠の可視化のために、画像分野でよく使われているGrad-CAMを使いました。
CNN系のモデルは画像分野でもよく使われているので、ノウハウを輸入するのが低コストでいいですね。
NLPで解釈性というとAttentionがありますが、Attention層に対する勾配ベースの解釈性の導入はすでに研究として行われつつあります。
Interpreting Recurrent and Attention-Based Neural Models: a Case Study on Natural Language Inference

Attentionのヒートマップではモデルの解釈には不十分で、勾配ベースのスコアなら解釈として妥当というのは自分の中でまだもやっとしているので、ここらへん詳しい方がいらっしゃればTwitterなどでコメント頂けると嬉しいです。

参考文献

ACL2018を地球で一番読んだ人間になりました

「ACL2018を地球で一番読んだ人間」の実績を解除しました。プラチナトロフィーです。
随分時間がかかってしまいましたがNLPの国際学会「ACL2018」の全論文427本(Short/Long/Student Research Workshop/System Demonstrations)を読んで日本語でまとめました。
(2019ではなく2018です)

github.com

ACL全まとめを始めたきっかけ

もともとACLの論文まとめを始めたのはACL2017の頃からでした。
会社に入ってから自然言語処理を勉強し始めたので、もっと広くNLPのことを理解したいなーと思っていた時期。
自然言語処理のトップカンファレンスなら、幅広い分野の最先端を知ることができるだろうと考えたのがまとめを始めたきっかけでした。
あと、英語論文を読んだ経験も圧倒的に乏しかったので英語の勉強も兼ねてという側面もあります。
(始める前は1本ちゃんと読み切るのに1週間とかかかってました...)

論文まとめについて

論文の日本語まとめはGithubのissueにまとめています。
論文はACL2017のOralと、ACL2018の全論文がまとめてあります。
各まとめには検索性確保のためラベルをつけています。

f:id:ymym3412:20190306023002p:plain

issueにつけたカテゴリのラベルは以下のページでの論文のカテゴリと対応させています。
一覧ページで気になるカテゴリを見つけてissueでラベル検索をすると、関連するカテゴリの論文を一覧することができます。
Best Paperなど一部カテゴリが分からないものはこちらで内容から推測してラベルを付与してます。

https://acl2018.org/programme/schedule/

f:id:ymym3412:20190306022915p:plain

新しく自然言語処理の研究を始める学生などが、分野の概観を掴む等に役立ててもらえれば幸いです。

ACL完全まとめの今後

ACLの論文の完全読破という取り組みは、ACL2019からはnlpaper.challengeという形で引き継がれていくことになります。
これまでは一人でもくもくと読んでいましたが、やはり気になったことを他の人ととっさにディスカッションできないというのはとても寂しいものがあったので、様々な人が集まってわいわいとやっていくのはとても楽しみです!

私一人でもACLを完全読破できたということは、「人間は一人でも国際学会を完全読破できる」という説を証明したことになるので、他の方もぜひ挑戦してみてはいかがでしょうか。
cvpaper/nlpaper.challengeに続く国際学会完全読破チャレンジにご興味がある方がいれば、私(@ymym3412)@HirokatuKataokaさんにご相談ください。
xpaper.challengeとして様々な分野の知の融合を目指していきたいと思います!

最後に

ACL2018自体の総括的なまとめもどっかに入れたいけど、開催からかなり時間が経ってしまったのと結構大変な作業になりそうなのでもしかしたらやるくらいの確度で...。

ACLの論文の読み方みたいなところはさくっとまとめるかもしれません。

NLP前処理ツール「chariot」とPyTorchで日本語テキスト分類

chakki-worksが発表した自然言語処理の前処理ツール「chariot」をさっそく動かしてみました。
公式サンプルは英語でのコードだったので、せっかくなので日本語のデータを使ってPyTorchの日本語分類モデルの学習を行ってみました。
chariotのgithubはこちら

github.com

実験で使用したコードはGithubにもあげています。

github.com

chariotとは

chariotは自然言語処理の前処理のパイプラインを構築するためのツールです。
前処理を実行するPreprocessorに前処理をstackしていくことで、パイプラインを構築していきます。
公式の紹介記事では以下をポイントにあげています。

chariotでは、以下3点をできるようにしました。

- 前処理を宣言的に定義できる
- 前処理が簡単に保存できる(パラメーターごと)
- モデルの学習にたどり着くまでの時間を短くする

前処理のモジュールを順々に積み上げていくため、どんなパイプラインが構築されているか把握しやすいのはとても嬉しい特徴です。

preprocessor = Preprocessor()
preprocessor\
    .stack(ct.text.UnicodeNormalizer())\
    .stack(ct.Tokenizer("en"))\
    .stack(ct.token.StopwordFilter("en"))\
    .stack(ct.Vocabulary(min_df=5, max_df=0.5))\
    .fit(train_data)

またこのパイプラインのオブジェクトをpickleなどで保存しくことで、本番環境にパイプラインをそのまま組みこめるようにポータビリティ性も意識されています。

chariotを使った前処理モデルの構築

基本的な使い方は元記事にサンプルコードが載っているので、こちらではそれの簡単な紹介程度に留めます。

文章のリストに対して前処理のパイプラインを定義するのが Preprocessor です。

import chariot.transformer as ct
from chariot.preprocessor import Preprocessor


preprocessor = Preprocessor()
preprocessor\
    .stack(ct.text.UnicodeNormalizer())\
    .stack(ct.Tokenizer("en"))\
    .stack(ct.token.StopwordFilter("en"))\
    .stack(ct.Vocabulary(min_df=5, max_df=0.5))\
    .fit(train_data)

# scikit-learnのtransformerと同じ感覚で使える
processed = preprocessor.transform(train_data)

# パイプラインをモデルとしてpickleで保存
preprocessor.save("my_preprocessor.pkl")

Preprocessorは文章のリストに対するものですが、CSVを読み込んで複数の列にまとめて前処理を定義したいことも多いと思います。その時に使うのが DatasetPreprocessor です。
これはDataFrameの各列にPreprocessorを定義しているようなものです。

from chariot.dataset_preprocessor import DatasetPreprocessor
from chariot.transformer.formatter import Padding


dp = DatasetPreprocessor()
dp.process("review")\
    .by(ct.text.UnicodeNormalizer())\
    .by(ct.Tokenizer("en"))\
    .by(ct.token.StopwordFilter("en"))\
    .by(ct.Vocabulary(min_df=5, max_df=0.5))\
    .by(Padding(length=pad_length))\
    .fit(train_data["review"])

dp.process("polarity")\
    .by(ct.formatter.CategoricalLabel(num_class=3))

# 各列に前処理をかけた結果がdictで返ってくる
preprocessed = dp.preprocess(data)

# 各列に対するpreorocessorがtarで固まって保存される
dp.save("my_dataset_preprocessor.tar.gz")

ちなみにpreprocessorの内部でパイプラインを構築する際に、stackしたモジュールは簡単に順番を並び替えてパイプラインが構築されます。

github.com

具体的には テキストへの前処理系(unicode正規化とか) -> tokenizer -> tokenへの前処理系(stop word filterとか) -> vocabulary系(vocabの作成) という形で処理が進むようにパイプラインの中身を並び替えます。

presetのtransformer

Preprocessor/DatasetPreprocessorで使用できる前処理用のモジュールはデフォルトで用意されています。
モジュール群はGithubのtransformerのコード部分を見ると中身を読むことができます。
https://github.com/chakki-works/chariot/tree/master/chariot/transformer

ここではいくつか紹介します。

text/UnicodeNormalizer

Unicode正規化を行うモジュールです。
内部的には受け取ったテキストに unicodedata.normalize(from, text) を実施しています。
正規化フォームは自分で選べますが、多くの場合は NFKC で良いと思います

class UnicodeNormalizer(TextNormalizer):

    def __init__(self, form="NFKC", copy=True):
        super().__init__(copy)
        self.form = form

    def apply(self, text):
        return unicodedata.normalize(self.form, text)

text/SymbolFilter

指定した記号をテキスト中から除去するモジュールです。

class SymbolFilter(TextFilter):

    def __init__(self, filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n',
                 split=" ", copy=True):
        super().__init__(copy)
        self.filters = filters
        self.split = split

    def apply(self, text):
        translate_dict = dict((c, self.split) for c in self.filters)
        translate_map = str.maketrans(translate_dict)
        _text = text.translate(translate_map)

        return _text

tokenizer/Tokenizer

テキストのTokenizeを行うモジュールです。
日本語では、Mecabが使える環境ではMecabを、そうでなければJanomeを使ってTokenizeを行います。
日本語以外ではSpacyが持っているtokenizerを使用するため、Spacyが対応している言語であればchariotの中で対応可能(なはず)です。

class Tokenizer(BasePreprocessor):

    def __init__(self, lang="en", copy=True):
        super().__init__(copy)
        self.lang = lang
        self._tokenizer = None
        self.set_tokenizer()

    def set_tokenizer(self):
        if self.lang == "ja":
            try:
                self.tokenizer = MeCabTokenizer()
            except Exception as ex:
                self.tokenizer = JanomeTokenizer()
        elif self.lang is None:
            self.tokenizer = SplitTokenizer()
        else:
            self.tokenizer = SpacyTokenizer(self.lang)

    def transform(self, X):
        _X = apply_map(X, self.tokenizer.tokenize, self.copy)
        return _X

    def __reduce_ex__(self, proto):
        return type(self), (self.lang, self.copy)

token/StopwordFilter

Stop wordを除去するモジュールです。
日本語以外では、Spacyのtokenize結果をもとにして除去します。
日本語の場合、デフォルトで用意したStop words辞書を使って辞書形式で削除します。

class StopwordFilter(TokenFilter):

    def __init__(self, lang, copy=True):
        super().__init__(copy)
        self.lang = lang

    def apply(self, tokens):
        if len(tokens) == 0:
            return tokens
        else:
            sample = tokens[0]
            if sample.is_spacy:
                return [t for t in tokens if not t._token.is_stop]
            elif sample.is_ja:
                return [t for t in tokens if t.surface not in STOP_WORD_JA]
            else:
                return tokens

formatter/CategoricalLabel

ラベルなどのカテゴリ列をone-hotの表現に変換します。

class CategoricalLabel(BaseFormatter):

    def __init__(self, num_class=-1):
        super().__init__()
        self.num_class = num_class

    def transfer_setting(self, vocabulary_or_preprocessor):
        vocabulary = vocabulary_or_preprocessor
        if isinstance(vocabulary_or_preprocessor, Preprocessor):
            vocabulary = vocabulary_or_preprocessor.vocabulary
        self.num_class = vocabulary.count

    def transform(self, column):
        y = np.array(column, dtype="int")
        input_shape = y.shape
        if input_shape and input_shape[-1] == 1 and len(input_shape) > 1:
            input_shape = tuple(input_shape[:-1])
        y = y.ravel()
        n = y.shape[0]
        categorical = np.zeros((n, self.num_class), dtype=np.float32)
        categorical[np.arange(n), y] = 1
        output_shape = input_shape + (self.num_class,)
        categorical = np.reshape(categorical, output_shape)
        return categorical

    def inverse_transform(self, batch):
        return np.argmax(batch, axis=1)

chariotを使った日本語前処理

chariotを使って日本語ドキュメントの分類を行うPyTorchのモデルを学習させるコードを作成します。
今回はlivedoorのニュースコーパスを使って、ニュースメディアの予測をする9クラス分類を行います。
サイトからコーパスをダウンロードして展開し、pythonで読み込みます。

import os
from pathlib import Path

# タイトル、記事、メディアを収集
titles, articles, labels = [], [], []
news_list = ['dokujo-tsushin', 'it-life-hack', 'kaden-channel', 'livedoor-homme', 'movie-enter', 'peachy', 'smax', 'sports-watch', 'topic-news']
for i, media in enumerate(news_list):
    files = os.listdir(Path('text', media))
    for file_name in files:
        if file_name == 'LICENSE.txt':
            continue
        with Path('text', media, file_name).open(encoding='utf-8') as f:
            lines = [line for line in f]
            title = lines[2].replace('\n', '')
            text = ''.join(lines[3:])
            titles.append(title)
            articles.append(text.replace('\n', ''))
            labels.append(i)

データ数はこんな感じです。
分量は大体同じくらい。ちょっとlivedoor HOMMEが少ないくらいです。

from collections import Counter

counter = Counter(labels)
for com in counter.most_common():
    print(com)

>>>
(7, 900)
(0, 870)
(1, 870)
(4, 870)
(6, 870)
(2, 864)
(5, 842)
(8, 770)
(3, 511)

データをDataFrameにまとめていきます。あとtrainとtestにデータを分けます。

import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.DataFrame({'title': titles, 'article': articles, 'label': labels})

train_X, test_X, train_y, test_y = train_test_split(df[['article', 'title']], df['label'], stratify=df['label'])
train_df = train_X
train_df['label'] = train_y
test_df = test_X
test_df['label'] = test_y

データの中身を見てみると、ノイズになりそうなテキストが含まれています。

今はどんな内容が話題なの? 今、どんな話題が流行ってるのか知りたくないですか?ちょっとした暇つぶしやブログのネタ探し、営業職の方や美容師さんなどお客さんとの話題作りにも役立てることができるアプリを紹介したいと思います。今回紹介するAndroid向けアプリ「ワダイくん」は旬のキーワードなどをランキング形式で表示してくれます。20分ごとに更新されるのでテレビやSNS、インターネット上で話題のキーワードを旬のうちに確認することができるようになっています。では、早速、紹介していきたいと思います。TOP画面にはその日の話題が20位まで表示されます。気になる話題をタップするとウェブ、イメージ、ニュース、ブログでのカテゴリ別に閲覧することができるようになっています。ウェブ表示イメージ表示ニュース表示ブログ表示今、話題のキーワードをランキング形式でチェックできるので話題に乗り遅れることはないかも!?記事執筆:にゃんこアプリ名:ワダイくん -話題のキーワードランキング-価格:無料カテゴリ:ライフスタイル開発者:catchyバージョン:4.0ANDROID 要件:2.2以上Google Play Store:http://play.google.com/store/apps/details?id=com.trand■関連リンク・エスマックス(S-MAX)・エスマックス(S-MAX) smaxjp on Twitterhttp://livedoor.blogimg.jp/smaxjp/imgs/1/2/12e4b263-s.png000

そこで、正規表現を入力してテキストをフィルタリングする自作のモジュールを作成してchariotのパイプラインに組み込もうと思います。

from chariot.transformer.text.base import TextFilter
import re


class RegularExpressionReplacer(TextFilter):

    def __init__(self, pattern, replacement, copy=True):
        super().__init__(copy)
        self.pattern = pattern
        self.replacement = replacement

    def apply(self, text):
        # patternにマッチした部分文字列をreplacementに置き換える
        return re.sub(self.pattern, self.replacement, text)

最終的なパイプラインは以下のように定義しました。

from chariot.dataset_preprocessor import DatasetPreprocessor
from chariot.transformer.formatter import Padding
import chariot.transformer as ct

pad_length = 300

dp = DatasetPreprocessor()
dp.process('article')\
    .by(ct.text.UnicodeNormalizer())\
    .by(ct.text.LowerNormalizer())\
    .by(RegularExpressionReplacer(pattern='■.+?。', replacement=''))\ # 自作モジュール
    .by(ct.text.SymbolFilter())\
    .by(ct.Tokenizer('ja'))\
    .by(ct.token.StopwordFilter('ja'))\
    .by(ct.Vocabulary(min_df=3, max_df=0.8))\
    .by(Padding(length=pad_length))\
    .fit(train_df['article'])

dp.process('label')\
    .by(ct.formatter.CategoricalLabel(num_class=9))

# 各列に前処理を施したdictが保存されている
preprocessed = dp.preprocess(train_df)

PyTorchのモデル作成に向けた準備をしていきます。今回は記事本文からメディアを予測することにします。
まずは記事本体のarticle列からvocabularyを作成し、そこから学習済み単語ベクトルの取得まで行います。
学習済み単語ベクトルはchakinを使ってfasttextの単語ベクトルを利用しました。

# 特定の列に対応したpreprocessorはこれで取得できる
# 列に対応したvocabularyの一覧はこのようにリストで取得できる
dp.process('article').preprocessor.vocabulary.get()[:3]

from chariot.storage import Storage
import os
import sys
from pathlib import Path


def set_path():
    if "../" not in sys.path:
        sys.path.append("../")
    root_dir = Path.cwd()
    return root_dir

ROOT_DIR = set_path()
storage = Storage.setup_data_dir(ROOT_DIR)
# fasttextの学習済み単語ベクトルをダウンロード
_ = storage.chakin(name='fastText(ja)')
# article列のvocabularyに対応した埋め込みベクトルのmatrixを生成する
embedding = dp.process('article').preprocessor.vocabulary.make_embedding(storage.data_path('external/fasttext(ja).vec'))

テキスト分類を行うモデルとして、Convolutional Neural Networks for Sentence Classificationで使用されたCNNのテキスト分類モデルを使用します。
f:id:ymym3412:20190302032019p:plain

import torch
import torch.nn as nn
import torch.nn.functional as F

class  CNN_Text(nn.Module):
    
    def __init__(self, pretrained_wv, output_dim, kernel_num, kernel_sizes=[3,4,5], dropout=0.5, static=False):
        super(CNN_Text,self).__init__()
        
        weight = torch.from_numpy(pretrained_wv)
        self.embed = nn.Embedding.from_pretrained(weight, freeze=False)
        self.convs1 = nn.ModuleList([nn.Conv2d(1, kernel_num, (k, self.embed.weight.shape[1])) for k in kernel_sizes])
        self.dropout = nn.Dropout(dropout)
        self.fc1 = nn.Linear(len(kernel_sizes)*kernel_num, output_dim)
        self.static = static

    def conv_and_pool(self, x, conv):
        x = F.relu(conv(x)).squeeze(3) #(N,Co,W)
        x = F.max_pool1d(x, x.size(2)).squeeze(2)
        return x


    def forward(self, x):
        x = self.embed(x) # (N,W,D)
        
        if self.static:
            x = x.detach()

        x = x.unsqueeze(1) # (N,Ci,W,D)
        x = x.float()
        x = [F.relu(conv(x)).squeeze(3) for conv in self.convs1] #[(N,Co,W), ...]*len(Ks)

        x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] #[(N,Co), ...]*len(Ks)

        x = torch.cat(x, 1)
        x = self.dropout(x) # (N,len(Ks)*Co)
        logit = self.fc1(x) # (N,C)
        return logit

あとはゴリゴリっと学習をまわすだけです。学習のためのミニバッチ作成とepoch管理もDatasetPreprocessorが行ってくれます。

output_dim = 9
kernel_num = 100
kernel_sizes = [3,4,5]
dropout = 0.5

model = CNN_Text(embedding, output_dim, kernel_num, kernel_sizes, dropout)
use_cuda = True # GPUを使う場合
opt = torch.optim.Adam(model.parameters(), lr=0.001)
model.train()
if use_cuda:
    model = model.cuda()
    
loss_list = []
for b in dp.iterate(preprocessed, batch_size=32, epoch=15):
    feature = torch.LongTensor(b['article'])
    # PyTorchはラベルのone-hot matrixに対応していないので、ラベルの数字に戻す
    target = torch.LongTensor(np.argmax(b['label'], axis=1))
    if use_cuda:
        feature = feature.cuda()
        target = target.cuda()
        
    opt.zero_grad()
    logit = model(feature)
    loss = F.nll_loss(F.log_softmax(logit), target)
    loss.backward()
    opt.step()
    loss_list.append(loss.item())

学習の結果をTestデータセットで確認してみます。
先ほど作成したTest用のDataFrameをパイプラインにそのまま通せば先ほど設定した前処理を適用することができます。

# 前処理パイプラインを通したデータがdictで出てくる
test_preprocessed = dp(test_df).preprocess().format().processed

model.eval()
feature = torch.LongTensor(test_preprocessed['article']).cuda()
forward = model(feature)

predicted_label = forward.argmax(dim=1).cpu()
test_target = torch.LongTensor(np.argmax(test_preprocessed['label'], axis=1))

結果は以下の通り。微妙なのでもうちょっと前処理は工夫の余地がありそうです。

from sklearn.metrics import classification_report

print(classification_report(test_target.numpy(), predicted_label.numpy()))

>>>
              precision    recall  f1-score   support

           0       0.60      0.64      0.62       218
           1       0.66      0.67      0.67       217
           2       0.57      0.56      0.56       216
           3       0.49      0.49      0.49       128
           4       0.72      0.71      0.71       218
           5       0.62      0.57      0.59       210
           6       0.82      0.79      0.80       218
           7       0.65      0.75      0.69       225
           8       0.65      0.58      0.62       192

   micro avg       0.65      0.65      0.65      1842
   macro avg       0.64      0.64      0.64      1842
weighted avg       0.65      0.65      0.65      1842
from sklearn.metrics import accuracy_score

print(accuracy_score(test_target.numpy(), predicted_label.numpy()))

>>>
0.6487513572204126

最後に

前処理のパイプラインを分かりやすく、効率的に、ポータビリティをもって構築できるツール「chariot」を紹介し、実際に日本語でPyTorchと組み合わせてテキスト分類を行いました。
Vocabularyの作成、SOSやUNKといった特殊なトークンの挿入、文のpaddingは結構面倒なので、そこをまるっとやってくれるのは便利だと思います。
前処理の実行とembedding matrixの作成はそれなりに時間を取られる(モジュールの数にもよりますが1時間前後)ので、もっと高速化できると嬉しいです。
実は前職で似たようなパイプライン作成ツールを社内で自作して使用しており、似たツールが出たので調査も兼ねて記事にしたのでした。(退職に間に合わなかったよ...><;)
ぜひchariotを使ってproduction readyなNLP前処理パイプラインを作ってみてください!

参考文献

nlpaper.challengeを立ち上げました

自然言語処理の国際学会「ACL」の採択論文を完全読破するNLPコミュニティ「nlpaper.challenge」を立ち上げました。
そして1/19(土)にnlpaper.challenge初のイベントである「NLP/CV交流勉強会」を開催してきました。

誕生の経緯はこんな感じでした。

f:id:ymym3412:20190127224831p:plain

f:id:ymym3412:20190127224926p:plain

もともと私がACLの論文を片っ端から読む取り組み(ymym3412/acl-papers)をこつこつとやっていました。
画像の分野でもCVPRの採択論文をチームで読み切るという企画をやっているのは知っていて、「これは企画を持ち込めばNLPでも同じことを立ち上げられるのでは?しかもcvpaper.challengeのノウハウをそのまま活かせそう!」ということでcvpaper.challengeの報告会に乗り込んで運営メンバーに企画を話したところ、快諾してくださりその勢いのままnlpaper.challengeが立ち上がりました。

f:id:ymym3412:20190127225827p:plain

cvpaper.challengeの親戚のようなものですから、cvpaper.challengeの方々と一緒に切磋琢磨するNLP×CVがかけあわさる他にはないコミュニティにしていければと思っています。
またACL完全読破を開始する5月(予定)に向けて、NLLPとCVをステップアップで勉強していく企画も行っています。

nlpaper.challenge NLP/CV交流勉強会

第2回以降も企画中なので、ぜひみなさまご参加ください!

今後はcvpaperと同じように、nlpaper.challenge内で研究の芽を育てていくようなこともしていきたいと思っています。
それに向けて、nlpaper内での研究のコラボレーションに興味がある修士/博士の方、また勉強会向けに会場を提供してくださる企業様を募集しています!
ご興味ある方/ご協力くださる企業様は私(@ymym3412)やnlpaper.challengeのtwitterアカウント(@NlpaperChalleng)までDMでご連絡お願いします!