MLエンジニア見習いのスクラップブック

新米機械学習エンジニアによる技術ネタや備忘録

バーチャルライバー・鷹宮リオンのツイートのネガポジ分類をやってみた

この記事 is 何?

TL;DR

Introduction

背景

どうも、あずペロ(@azupero_adolph)です。
未経験から機械学習エンジニアとして転職してから半年以上経過し、日々の業務や自己研鑽を続けていく中でもう少し具体的なアウトプットを発信していきたいと考えていました。
(ブログの更新もかなりおざなりになっていたので・・・)

アウトプットのネタとして考えたのは最近は自然言語処理(NLP)の前処理やNLPタスクで用いられるNNモデルをPyTorchで実装する勉強に時間を割いていたので、大袈裟ですが学びの成果として自分なりに何か形として残したいなと思っていました。

そこで閃きました!日々コーディングのお供として流しているバーチャルライバーのデータを使って面白いことができないか?と。

今回は「バーチャルライバー×自然言語処理」をテーマとして、にじさんじ所属のバーチャルライバー・鷹宮リオンさんのツイートのネガポジ分類をやってみました。

バーチャルライバーとは

そもそもバーチャルライバーというものをご存知でしょうか?
Wikipediaから引用すると

バーチャルYouTuber(バーチャルユーチューバー、英: virtual YouTuber)は日本発祥の、コンピュータグラフィックスのキャラクター(アバター)、またキャラクター(アバター)を用いてYouTuberとして動画投稿・配信を行う人
バーチャルYouTuber - Wikipedia

と記述されているようにキャラクターを用いてYoutubeを中心として配信活動(動画やライブ配信)をする方々を指します。

雑談からゲーム実況、歌やバラエティ番組のような企画物まで配信内容は様々です。

バーチャルYoutuberは個人 or 企業に所属する二通りの活動のやり方があります。普段私は「にじさんじ」という企業に所属しているバーチャルYoutuber(以下、バーチャルライバー)の配信をコーディングの作業用ラジオのような感覚で聞くことが多いです。

鷹宮リオンについて

本記事でフィーチャーさせて頂いたのは2018年にデビューしたにじさんじ所属のバーチャルライバー・鷹宮リオンさんです。

有数の金持ちが集う魔法学校、私立帝華高校の2年生、17歳。
政治家の娘で高飛車なツンデレタイプだが、学校では風紀委員に所属しており気さくな優等生を演じている。土日は社会見学の為メイド喫茶でバイト中。
nijisanji.ichikara.co.jp

www.youtube.com twitter.com

ほぼ毎日ゲーム・雑談の生配信をメインとして活動しており、面白いトークや時折ポンコツさを思わせるゲーム実況でリスナーを楽しませてくれます。

唯一無二なセンスから生み出される「きちゃ!」や「やび!」、「い゛!?」などの独特な口癖はリスナーや他ライバーも思わず口ずさんでしまうインフルエンサーの一面も。

先日にはチャンネル登録者数20万人を達成しました。おめでとうございます。

何をやったか

感情分析

記事に書いた通り今回は鷹宮リオンさんのツイートデータを用いて感情分析をテーマにします。
感情分析とは一口に言うと、文章の単語や文脈を解析して内容がポジティブあるいはネガティブかを推測するような分析です。

タスク設定

今回は勉強に使っている「PyTorchによる発展ディープラーニング」(通称:PyTorch本。PyTorchの良い勉強になる良書です。)の7・8章の内容をベースとして鷹宮リオンさんのツイートがネガティブ or ポジティブどちらに属しているかを予測するタスクに設定します。

また、PyTorchの勉強として予測モデルにはGoogle翻訳アルゴリズムでもある「Transformer」を実装したいと思います。(モデル実装ありきの記事内容ですがあしからず・・・)

Experiments

Workflow

ツイートのネガポジ分類は下図のようなワークフローで行いました。

f:id:azupero_adolph:20200429004523p:plain
本実験のワークフロー
今回用いるデータは Twitter API にて直近のツイート約2800件を取得しました。

コーパスとするにはかなり小規模ですがAPI的に直近3000件程度がリミットとしてあるみたいです。

取得したツイートデータはまずGoogle Spreadsheetで0:ネガティブ・1:ボジティプのアノテーションを手動でこなし、その後はテキストデータの前処理・トークナイズ・モデル実装を行いました。学習・推論はGoogle Colaboratoryにて実施しました。

テキスト前処理・コーパス作成

前処理

テキスト前処理は一般的な内容とTwitter特有のハッシュタグ、絵文字の除去を行っています。
Twitterのテキストは絵文字やハッシュタグなどがあり、テキスト前処理の重要さを思い知りました・・・。(恐らくこの処理でも十分とは言えないはず)

# テキスト前処理
def preprocessing_text(text):
    # 英語の小文字化(表記揺れの抑制)
    text = text.lower()
    # URLの除去(neologdnの後にやるとうまくいかないかも(URL直後に文章が続くとそれも除去される)))
    text = re.sub(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-…]+', '', text)
    # tweetの前処理
    text = re.sub(r"@([A-Za-z0-9_]+) ", '', text) # リプライ
    text = re.sub(r'#(\w+)', '', text) # ハッシュタグ
    # neologdnを用いて文字表現の正規化(全角・半角の統一と重ね表現の除去)
    text = neologdn.normalize(text)
    # 数字を全て0に置換(解析タスク上、数字を重要視しない場合は語彙数増加を抑制するために任意の数字に統一したり除去することもある)
    text = re.sub(r'[0-90-9]+', '0', text)
    # 半角記号の除去
    text = re.sub(r'[!-/:-@【】[-`{-~]', "", text)
    # 改行
    text = re.sub('\n', '', text)
    # 絵文字
    text = ''.join(['' if c in emoji.UNICODE_EMOJI else c for c in text])
    # 中黒や三点リーダ
    text = re.sub(r'[・…]', '', text)

    return text
トークナイズ

トークナイズはMeCab + NEologdで行いました。また、単語埋め込みベクトルとしてこちらのfastTextの日本語学習済みモデルを利用させていただきました。ありがとうございます。

# MeCab + NEologdによるtokenizer
def tokenizer_mecab(text):
    tagger = MeCab.Tagger('-Owakati -d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd') # -Owakatiで分かち書きのみ出力
    text = tagger.parse(text)
    text = text.strip().split()

    return text
コーパス作成

先述したようにラベリングはNegative -> 0, Positive -> 1として、Google spreadsheet上でアノテーションを行いました。
ラベリングにおける判断基準は私の主観でつけさせていただきました。

ざっとですが一つ一つ内容を見てラベリングしていくのはいざやってみると結構大変な作業でした。

なお事前に前処理+トークナイズでNULL(トークナイズ後に単語が何も残らない)になるようなツイートはドロップしています。

データセット作成

PyTorchにおけるテキストデータのデータセットtorchtext.data.Datasetを使用します。

def get_dataset(max_length=256, split_ratio=[0.92, 0.04, 0.04]):
    PATH = '/content/drive/My Drive/Colab Notebooks/NLP/RionTweetClassifier/data/rion_corpus.csv'
    # Field
    TEXT = torchtext.data.Field(sequential=True, 
                                tokenize=tokenizer_with_preprocessing, 
                                use_vocab=True, 
                                lower=True, 
                                include_lengths=True, 
                                batch_first=True, 
                                fix_length=max_length, 
                                init_token="<cls>", 
                                eos_token="<eos>"
                                )

    LABEL = torchtext.data.Field(sequential=False, use_vocab=False, dtype=torch.float32)
    # Dataset
    ds = torchtext.data.TabularDataset(path=PATH, 
                                       format='csv', 
                                       skip_header=True, 
                                       fields=[('Text', TEXT), ('Label', LABEL)]
                                       )
    
    train_ds, test_ds, val_ds = ds.split(split_ratio=split_ratio)
    # embedding vector
    FASTTEXT = '日本語学習済みfastTextモデルのパス'
    fastText_vectors = Vectors(name=FASTTEXT)
    # build vocab
    TEXT.build_vocab(train_ds, vectors=fastText_vectors, min_freq=1)

    return train_ds, test_ds, val_ds, TEXT

テキストの前処理を管理するtorchtext.data.Fieldで先ほどのテキスト前処理+トークナイズのパイプライン関数をtokenizer_with_preprocessingで指定しています。
LABELのデータ型をtorch.floatにしているのは後述の学習・推論コード部分で損失関数を計算するためにモデルの出力とデータ型を一致させるためです。

torchtext.data.TabularDatasetでデータセットを作成し.split()メソッドでsplit_ratioによりtrain set, valid set, test setに3分割します。
今回の比率はできるだけtrain setを多くしたいと思いtrain:test:valid = 0.92:0.04:0.04とさせていただきました。

Transformerについて

Transformerとは

2017年に発表された論文「Attention Is All You Need」で提案されたモデルです。Google翻訳アルゴリズムとして、自然言語処理におけるニューラルネットワークモデルに衝撃を与えました。現在はTransformerをベースとしたBERTおよびその派生モデルが有名ですね。

f:id:azupero_adolph:20200427002645p:plain
Vaswani, A. et al.(2017) Attention Is All You Need

Transformerの理論面は以下の記事で非常に詳しく解説されていますので興味のある方は是非参照してみて下さい。

deeplearning.hatenablog.com qiita.com

kaggleのNLPコンペなどではBERTがベースラインと用いられているなど、どちらかというと今はBERTの時代のような流れですがまずはそのベースとなっているTransformerの理解を実装と通じて深めたいと思います。なおPyTorch本同様、モデルはEncoder部分 + Classificationモジュールでの実装となります。

Multi-Head Attentionの実装

Transformerの実装についてはPyTorch本や参照元を大いに参考にさせていただきました。

モデルの実装部分はほぼこちらに則っていますがPyTorch本ではMulti-Head AttentionではなくシングルヘッドのSelf-Attentionとして実装されていますので本家の通りMulti-Headに差し替えました。

Multi-Head AttentionはSelf-Attentionを複数のヘッド、即ち入力のembeddingを分割し個々にSelf-Attentionを行い潜在表現を得るというやり方です。

f:id:azupero_adolph:20200427003852p:plain
Vaswani, A. et al.(2017) Attention Is All You Need
f:id:azupero_adolph:20200427003858p:plain
Vaswani, A. et al.(2017) Attention Is All You Need

今回はMultiHeadAttentionクラスとして実装しています。

class MultiHeadAttention(nn.Module):
    def __init__(self, heads, d_model, dropout=0.1):
        super().__init__()

        self.d_model = d_model
        self.d_k = d_model // heads
        self.h = heads

        self.q_linear = nn.Linear(d_model, d_model)
        self.v_linear = nn.Linear(d_model, d_model)
        self.k_linear = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
        self.out = nn.Linear(d_model, d_model)

    def forward(self, q, k, v, mask=None):
        bs = q.size(0)
        # perform linear operation and split into h heads
        k = self.k_linear(k).view(bs, -1, self.h, self.d_k)
        q = self.q_linear(q).view(bs, -1, self.h, self.d_k)
        v = self.v_linear(v).view(bs, -1, self.h, self.d_k)

        # transpose to get dimensions bs * h * sl * d_k
        k = k.transpose(1, 2)
        q = q.transpose(1, 2)
        v = v.transpose(1, 2)

        # calculate Scaled Dot-Product Attention
        output, normalized_attention_weight = scaled_dot_product_attention(q, k, v, self.d_k, mask, self.dropout)

        # concatenate heads and put through final linear layer
        # concat = scores.transpose(1, 2).contiguous().view(bs, -1, self.d_model)
        concat = output.transpose(1, 2).reshape(bs, -1, self.d_model)
        output = self.out(concat)

        return output, normalized_attention_weight

学習・推論 with PyTorch Lightning

学習・推論コードはPyTorch Lightningを用いてGoogle Colaboratory上で実装しています。
ピュアなPyTorchの書き方ではforループを繰り返していく必要がありましたがこちらのフレームワークを用いることで簡略化・分かりやすく記述することが可能です。 Google Colaboratory上でも楽に学習曲線をTensorBoardでモニタリングできるのが嬉しいですね。

loss functionはBCEWithLogitsLoss、optimizerはAdamです。

以前にもPyTorch Lightningは触っていたんですが最新版はデコレーターが非推奨だったり、validation_endvalidation_epoch_endになっていたりと仕様が変わっていますね。 あと地味に苦しんだのはtorchtext.data.Iteratorで返すバッチデータは自動的にGPUに転送されないので明示的に.to(device)GPUにデータを送る必要があるという。以前は画像データでやっていたので知らなかったです。

class MyLightningModule(pl.LightningModule):
    def __init__(self, model, train_ds, val_ds, test_ds, batch_size):
        super().__init__()
        # dataset path
        self.train_ds = train_ds
        self.val_ds = val_ds
        self.test_ds = test_ds
        self.bs = batch_size
        self.model = model

    def forward(self, x):
        # モデルの順伝搬処理
        input_mask = src_mask(x)
        x = self.model(x, input_mask)

        return x

    def training_step(self, batch, batch_idx):
        # train setのmini-batchにおける処理
        # REQUIRED
        x = batch.Text[0].to(device)
        y = batch.Label.unsqueeze(1).to(device)
        y_hat = self.forward(x)

        criterion = nn.BCEWithLogitsLoss(reduction='sum')
        loss = criterion(y_hat, y)
        tensorboard_logs = {'train_loss': loss.item()}

        return {'loss': loss, 'log': tensorboard_logs}

    def validation_step(self, batch, batch_idx):
        # valid setのmini-batchにおける処理
        x = batch.Text[0].to(device)
        y = batch.Label.unsqueeze(1).to(device)
        out = self.forward(x)
        
        bs = len(batch.Label)
        pred = torch.where(out >= 0.5, torch.ones(bs, 1).to(device), torch.zeros(bs, 1).to(device))

        criterion = nn.BCEWithLogitsLoss(reduction='sum')
        loss = criterion(out, y)

        return {'val_loss': loss, 'label': y, 'pred': pred}

    def test_step(self, batch, batch_idx):
        # test setのmini-batchにおける処理
        x = batch.Text[0].to(device)
        y = batch.Label.unsqueeze(1).to(device)
        out = self.forward(x)
        
        bs = len(batch.Label)
        pred = torch.where(out >= 0.5, torch.ones(bs, 1).to(device), torch.zeros(bs, 1).to(device))

        criterion = nn.BCEWithLogitsLoss(reduction='sum')
        loss = criterion(out, y)

        return {'test_loss': loss, 'label': y, 'pred': pred}

    def validation_epoch_end(self, outputs):
        # valid setのmini-batch処理終了時の処理
        avg_loss = torch.stack([x['val_loss'] for x in outputs]).mean()
        # accuracy
        acc = torch.mean(torch.cat([(output['label'] == output['pred']) * 1.0 for output in outputs]))

        tensorboard_logs = {'val_loss': avg_loss}
        tqdm_dict = {'avg_val_loss': avg_loss, 'val_acc': acc}

        return {'progress_bar': tqdm_dict, 'avg_val_loss': avg_loss, 'log': tensorboard_logs}

    def test_epoch_end(self, outputs):
        # test setのmini-batch処理終了時の処理
        avg_loss = torch.stack([x['test_loss'] for x in outputs]).mean()
        # accuracy
        acc = torch.mean(torch.cat([(output['label'] == output['pred']) * 1.0 for output in outputs]))

        tensorboard_logs = {'test_loss': avg_loss}
        tqdm_dict = {'avg_test_loss': avg_loss, 'test_acc': acc}

        return {'progress_bar': tqdm_dict, 'avg_val_loss': avg_loss, 'log': tensorboard_logs}

    def configure_optimizers(self):
        # 最適化手法・学習率スケジュールの設定
        # REQUIRED
        optimizer = optim.Adam(self.parameters(), lr=1e-5)
        scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10)
        
        return [optimizer], [scheduler]

    # @pl.data_loader # 最新版はデコレーターは非推奨
    def train_dataloader(self):
        # REQUIRED
        train_loader = torchtext.data.Iterator(train_ds, batch_size=self.bs, train=True)
        
        return train_loader

    # @pl.data_loader
    def val_dataloader(self):
        # OPTIONAL
        val_loader = torchtext.data.Iterator(val_ds, batch_size=self.bs, train=False, sort=False)
        
        return val_loader

    def test_dataloader(self):
        test_loader = torchtext.data.Iterator(val_ds, batch_size=self.bs, train=False, sort=False)

        return test_loader

# early stopping callback
early_stop_callback = EarlyStopping(min_delta=0.00,
                                    patience=5,
                                    verbose=True,
                                    monitor='val_loss',
                                    mode='min')

Results

学習・推論結果

Test setにおけるAccuracyは95%でした。ただやはりデータセット自体が小規模・imbalancedですのでRecall, Precisionも求める必要がありそうです。

(やはりモデル自体がヘビーすぎる感が否めない)

Attentionの可視化

PyTorch本のようにいくつかのツイートを例にしてAttentionの可視化を試みたのですが、なんとattention weightをキャッシュしようとするとメモリが足らなくなる事態に。
後ほどHeadsを少なくしたモデルでの可視化を試みたいと思います。

Conclusion

今回はPyTorchとTransformerの勉強を兼ねてバーチャルライバーのネガポジ分類を行いました。ネガポジ分類は単語ごとのネガポジのスコアを足し合わせる方法もありそうですし、単語のtf-idfを特徴量としてGBDTで推定するなどの方法もあります。
まだまだNLP初心者の身ですが、前処理からモデル構築、PyTorch Lightningでの学習・推論などPyTorchでNLPをやるという基本的な流れを学べましたし、 BERTのベースとなるTransformerについても理解を深めることができました。

最近は自宅にいる時間が増えているという方もいらっしゃると思います。
是非この機会にバーチャルライバーの配信を覗いてみるのはいかがでしょうか?