バーチャルライバー・鷹宮リオンのツイートのネガポジ分類をやってみた
この記事 is 何?
TL;DR
- にじさんじのバーチャルライバー・鷹宮リオンさんのTweetのネガポジ分類を行った
- モデルはTransformerをPyTorch + PyTorch Lightningで実装した
- おうちでバーチャルライバーの配信を見よう!
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
ほぼ毎日ゲーム・雑談の生配信をメインとして活動しており、面白いトークや時折ポンコツさを思わせるゲーム実況でリスナーを楽しませてくれます。
唯一無二なセンスから生み出される「きちゃ!」や「やび!」、「い゛!?」などの独特な口癖はリスナーや他ライバーも思わず口ずさんでしまうインフルエンサーの一面も。
先日にはチャンネル登録者数20万人を達成しました。おめでとうございます。
何をやったか
感情分析
記事に書いた通り今回は鷹宮リオンさんのツイートデータを用いて感情分析をテーマにします。
感情分析とは一口に言うと、文章の単語や文脈を解析して内容がポジティブあるいはネガティブかを推測するような分析です。
タスク設定
今回は勉強に使っている「PyTorchによる発展ディープラーニング」(通称:PyTorch本。PyTorchの良い勉強になる良書です。)の7・8章の内容をベースとして鷹宮リオンさんのツイートがネガティブ or ポジティブどちらに属しているかを予測するタスクに設定します。
また、PyTorchの勉強として予測モデルにはGoogle翻訳のアルゴリズムでもある「Transformer」を実装したいと思います。(モデル実装ありきの記事内容ですがあしからず・・・)
Experiments
Workflow
ツイートのネガポジ分類は下図のようなワークフローで行いました。
今回用いるデータは 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およびその派生モデルが有名ですね。
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を行い潜在表現を得るというやり方です。
今回は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_end
がvalidation_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についても理解を深めることができました。
最近は自宅にいる時間が増えているという方もいらっしゃると思います。
是非この機会にバーチャルライバーの配信を覗いてみるのはいかがでしょうか?