VTuberの生放送開始時に得られるデータから機械学習で関係性のある放送を判別したかった

機械学習初心者がVTuberの生放送開始時に得られるデータから機械学習で関係性のある放送を判別しようとした記事です。


##どのような結果が欲しいか
VTuberの放送には、個人枠とコラボ枠というものがあります、個人枠と呼ばれるのは文字通りそのVTuber一人で行う配信、そしてコラボ枠は複数人で行われる配信です、このコラボ枠では1つの放送に複数のVTuberが参加する場合と参加するVTuberそれぞれが配信し複数の放送が同時に行われる場合があります。
今回は、コラボ放送の中でも、同時に行われている複数の放送を機械学習で判別しようとしました。


##使用するデータ
今回用意したデータは以下のような形式になっています。

|開始時間|チャンネルID|VTuber名|配信タイトル|性別|所属|
|--------|--------|--------|--------|--------|--------|
|2019-10-20 11:32:06.823761+00|UCwePpiw1ocZRSNSkpKvVISw|Mary Channel / 西園寺メアリ【ハニスト】|【カップヘッド】キングダイスを倒すまでやめられない！　Cuphead【西園寺メアリ / ハニスト】|woman|ハニーストラップ|


##データの整形
判別したいのは2つの放送がコラボ放送であるかどうかなのでデータの整形をする必要があります。
関係性のある放送を判別する際に着目すべき点は主にタイトルと開始時間です、2人であるゲームをそれぞれの視点で配信するような場合、2つの放送の開始時間が大きく離れることは基本的にありません、またタイトルはコラボの場合同じワードが入っていたりと個人枠のタイトル同士と比べて類似度が高いのではないかと考えました。

まずはタイトルの類似度について検証していきたいと思います。

```Python
from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

def wakachi(text):
  t = Tokenizer()
  tokens = t.tokenize(text)
  docs=[]
  for token in tokens:
    docs.append(token.surface)
  return docs

def vecs_array(documents):
  docs = np.array(documents)
  vectorizer = TfidfVectorizer(analyzer=wakachi, binary=True, use_idf=False)
  vecs = vectorizer.fit_transform(docs)
  return vecs.toarray()

def title_match(title1, title2):
  docs = [title1, title2]
  cs_array = np.round(cosine_similarity(vecs_array(docs), vecs_array(docs)), 3)
  return cs_array[0][1]
```

2つのタイトルのコサイン類似度を求めるtitle_matchという関数を作りました、これで類似度を求めた結果が以下になります。

```Python
#コラボではない組み合わせ
test1 = '【HITMUN(ヒットマン)】夜見に任せて！！やり遂げてみせるよ！【夜見れな/にじさんじ】'
test2 = '【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】'

print(seq_match(test1, test2))

#コラボ
test1 = '【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】'
test2 = '【#雑ホロ杯】雑で華麗なメンバーによるホロライブマリカ杯!!!!!!!!!!!!!【百鬼視点/ホロライブ】'

print(seq_match(test1, test2))

出力
0.201
0.5
```

一応、類似度に差が出ていることがわかります。

この類似度と開始時間などのデータを組み合わせたらなんかできそうな気がしてきました。


```Python
#タイトルの類似度、開始時間の差、pr1、pr2の形にデータ成形
import numpy as np
import pandas as pd
from dateutil.parser import parse
from pytz import timezone
import random

positive = pd.read_csv("positive.csv" , header=0)
negative = pd.read_csv("negative.csv", header=0)
negative = negative[0: 292]


def time_jst(time):
  p = parse(time).astimezone(timezone('Asia/Tokyo'))
  return parse(p.strftime('%Y-%m-%d %H:%M:%S'))


pos_list = []
for s in positive.itertuples():
  sim = title_match(s.title1 + ' ' + s.liver1, s.title2 + ' ' + s.liver2)
  if time_jst(s.start_time1) > time_jst(s.start_time2):
    time = time_jst(s.start_time1) - time_jst(s.start_time2)
  else:
    time = time_jst(s.start_time2) - time_jst(s.start_time1)

  pos_list.append([sim, s.pr1 , s.pr2, time.seconds, 1])


neg_list = []
for s in negative.itertuples():
  sim = title_match(s.title1 + ' ' + s.liver1, s.title2 + ' ' + s.liver2)
  time = random.choice([100, 500, 0, 0, 0, 0])
  neg_list.append([sim,  s.pr1, s.pr2, time, 0])

random.shuffle(pos_list)
random.shuffle(neg_list)
```

上記のコードでコラボの配信データ同士をまとめたpositive.csv、そうではないもののデータのnegative.csvから
類似度、所属(1)、所属(2)、開始時間の差、コラボか否か（コラボは１）のデータを作りました。
ここでタイトルの類似度を求めるときに配信者の名前を最後につけています、試行錯誤の際これをすると微妙に精度が上がった気がするためです。また、neg_listのtimeをランダムチョイスにしています、理由としてはpositiveデータのほとんどが開始時間の差が０かそれに近い値であったため普通にコラボではない配信同士の時間差を入れてしまうと判別する際に開始時間の差が非常に重視されてしまったためです。

ここまででデータの整形のほとんどが終わりました、がpositiveデータを手作業で作成した関係上件数が292と非常に少なく、学習しても精度がほとんど出ません、なのでpositive,negativeともにデータの水増しをしていきます。

```Python
#水増し

tmp = []
for n in neg_list:
  for add in [x / 1000 for x in range(1, 100)]:
    new = copy.deepcopy(n)
    new[0] = round(n[0] + add, 4)
    tmp.append(new)

neg_list.extend(tmp)

tmp = []
for n in pos_list:
  for add in [x / 1000 for x in range(1, 100)]:
    new = copy.deepcopy(n)
    new[0] = round(n[0] + add, 4)
    tmp.append(new)
  
pos_list.extend(tmp)
```

水増しについていろいろ考えましたが、今回はタイトルの類似度に0.001~0.01までを足してデータの水増しをしてみました。

最後にnegative,positive2つのデータをpandasのデータフレームに変換します。


```Python
#pandasのdfへ変換
pos_df = pd.DataFrame(pos_list, columns=['sim', 'pr1', 'pr2', 'time', 'relat'])
neg_df = pd.DataFrame(neg_list, columns=['sim', 'pr1', 'pr2', 'time', 'relat'])

df = pd.concat([pos_df, neg_df])
df = df.sample(frac=1)

#prのリストとそれに対応するidのリストを作成、その後置き換える
prs1 = list(set(df.pr1))
prs2 = list(set(df.pr2))

df.pr1 = df.pr1.replace(['ホロスターズ', 'ホロライブ', 'ENTUM', '.Live', 'にじさんじ', 'ハニーストラップ', 'あにまーれ', 'Re:AcT', 'etc'], [x for x in range(0,9)])
df.pr2 = df.pr2.replace(['ホロスターズ', 'ホロライブ', 'ENTUM', '.Live', 'にじさんじ', 'ハニーストラップ', 'あにまーれ', 'Re:AcT', 'etc'], [x for x in range(0,9)])

df.head()
```
学習するためには含まれるデータがすべて数字である必要があるので、所属事務所を対応するIDで置き換えていきます。

##学習させる
```Python
#学習
import keras
from keras.utils.np_utils import to_categorical
from sklearn.model_selection import train_test_split


#予測したいものとを学習に使うラベルを指定
#今回は関係性が0か１かを判定

COLUMNS = ['sim', 'pr1', 'pr2', 'time']
x = df[COLUMNS].values #特徴
y = df['relat'].values #予測したいもののラベル

#訓練データとテストデータを分ける（訓練データでテストをするべきではないため）
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.33)

from keras.models import Sequential
from keras.layers import Dense, Dropout
 
# モデル作成 
model = Sequential()
model.add(Dense(32, input_shape=(len(COLUMNS),), activation='relu'))
model.add(Dropout(0.25))
model.add(Dense(16, activation='relu'))
model.add(Dropout(0.25))
model.add(Dense(1, activation='sigmoid'))
 
model.compile(loss='binary_crossentropy',
              optimizer='rmsprop',
              metrics=['accuracy'])
 
model.fit(
    x_train, 
    y_train, 
    epochs=10, 
    batch_size=32, 
    verbose=1)

score = model.evaluate(x_test, y_test, verbose=1)

print('Test loss:', score[0])
print('Test accuracy:', score[1])

出力
Epoch 1/10
39128/39128 [==============================] - 2s 52us/step - loss: 2.5904 - acc: 0.6276
Epoch 2/10
39128/39128 [==============================] - 2s 39us/step - loss: 0.6424 - acc: 0.7360
Epoch 3/10
39128/39128 [==============================] - 1s 38us/step - loss: 0.5212 - acc: 0.7520
Epoch 4/10
39128/39128 [==============================] - 2s 39us/step - loss: 0.5019 - acc: 0.7548
Epoch 5/10
39128/39128 [==============================] - 1s 38us/step - loss: 0.4853 - acc: 0.7537
Epoch 6/10
39128/39128 [==============================] - 2s 39us/step - loss: 0.4783 - acc: 0.7574
Epoch 7/10
39128/39128 [==============================] - 2s 39us/step - loss: 0.4643 - acc: 0.7636
Epoch 8/10
39128/39128 [==============================] - 2s 39us/step - loss: 0.4494 - acc: 0.7763
Epoch 9/10
39128/39128 [==============================] - 1s 38us/step - loss: 0.4414 - acc: 0.7832
Epoch 10/10
39128/39128 [==============================] - 2s 39us/step - loss: 0.4349 - acc: 0.7907
19272/19272 [==============================] - 0s 19us/step

Test loss: 0.3890995525531919
Test accuracy: 0.8133042756330428

```

ここらへんは正直参考にしたコードをちょこっと書き換えたぐらいです。
テストデータで試した精度は81%くらいとなりました。

##結果
実際に試してみます。

```Python
test1 = '【HITMUN(ヒットマン)】夜見に任せて！！やり遂げてみせるよ！【夜見れな/にじさんじ】'
test2 = '【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】'
test3 = '【#雑ホロ杯】雑で華麗なメンバーによるホロライブマリカ杯!!!!!!!!!!!!!【百鬼視点/ホロライブ】'


#コラボ
s = title_match(test3 + ' ' + '百鬼あやめ', test2 + ' ' + '大空スバル')

test_data = [s, 2, 2, 0]
npa = np.array([test_data])
predictions = model.predict(npa, batch_size=32)
print(predictions)

出力
[[0.9449251]]

#コラボではない
s = title_match(test1 + ' ' + '夜見れな/yorumi rena', test2 + ' ' + '大空スバル')

test_data = [s, 2, 2, 0]
npa = np.array([test_data])
predictions = model.predict(npa, batch_size=32)
print(predictions)

出力
[[0.03451759]]
```

1に近いほどコラボ放送である度合いが高まります、この例ではいい感じに判別ができているような気がします。

次に、汎用性のあるモデルになっているか確かめるために使用したデータの約1週間後に取得した生放送のデータ(608放送)の中からコラボと思われるタイトルの組み合わせを抽出した結果の一部が以下になります。（スコア0.88以上をコラボであるとしてみました）

```
【ルイージマンション3】夜見ちゃんに連行されました【天宮こころ・夜見れな/にじさんじ】 【ルイージマンション3】雑談しながらストーリを２人で進めるよ！あみゃみゃ語でね！【天宮こころ/夜見れな／にじさんじ】 [[0.9097527]]
【ルイージマンション3】雑談しながらストーリを２人で進めるよ！あみゃみゃ語でね！【天宮こころ/夜見れな／にじさんじ】 【ルイージマンション3】夜見ちゃんに連行されました【天宮こころ・夜見れな/にじさんじ】 [[0.9097527]]
【ルイージマンション3】ぽあぽあテラータワー【夜見れな／にじさんじ】 【ルイージマンション３】くそざこテラータワー【にじさんじ/鷹宮リオン視点】 [[0.91458833]]
【ルイージマンション３】オバケ屋敷で大冒険！★星川サラ【にじさんじ】 【ルイージマンション３】くそざこテラータワー【にじさんじ/鷹宮リオン視点】 [[0.89694124]]
【ルイージマンション３】くそざこテラータワー【にじさんじ/鷹宮リオン視点】 【ルイージマンション3】ぽあぽあテラータワー【夜見れな／にじさんじ】 [[0.91458833]]
【ルイージマンション３】くそざこテラータワー【にじさんじ/鷹宮リオン視点】 【ルイージマンション３】オバケ屋敷で大冒険！★星川サラ【にじさんじ】 [[0.89694124]]
【Minecraft】さて、ネザーに参りましょうか【チューリップ組】 【マイクラ】チューリップ組、ネザーへゆく。【健屋花那/にじさんじ】 [[0.90295285]]
【マイクラ】チューリップ組、ネザーへゆく。【健屋花那/にじさんじ】 【Minecraft】さて、ネザーに参りましょうか【チューリップ組】 [[0.90295285]]
【#雑ホロ杯】雑で華麗なメンバーによるホロライブマリカ杯!!!!!!!!!!!!!【百鬼視点/ホロライブ】 【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】 [[0.92321455]]
【#雑ホロ杯】雑で華麗なメンバーによるホロライブマリカ杯!!!!!!!!!!!!!【百鬼視点/ホロライブ】 【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 [[0.9486181]]
【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】 【#雑ホロ杯】雑で華麗なメンバーによるホロライブマリカ杯!!!!!!!!!!!!!【百鬼視点/ホロライブ】 [[0.92321455]]
【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】 【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 [[0.92552084]]
【マリカ】#雑ホロ杯 開催！びりはイヤだびりはイヤだビリはry…【ホロライブ/ロボ子さん視点】 【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 [[0.9158981]]
【マリオカート8デラックス】ホロライブマリカ杯💘マリン視点【ホロライブ/宝鐘マリン】 【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 [[0.9600609]]
【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 【#雑ホロ杯】雑で華麗なメンバーによるホロライブマリカ杯!!!!!!!!!!!!!【百鬼視点/ホロライブ】 [[0.9486181]]
【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 【#雑ホロ杯】マリカーでホロライブ最強になるッス！！！！！【大空スバル視点】 [[0.92552084]]
【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 【マリカ】#雑ホロ杯 開催！びりはイヤだびりはイヤだビリはry…【ホロライブ/ロボ子さん視点】 [[0.9158981]]
【#雑ホロ杯】す～～ぱ～～マリオカート【おかゆ視点/ホロライブ】 【マリオカート8デラックス】ホロライブマリカ杯💘マリン視点【ホロライブ/宝鐘マリン】 [[0.9600609]]
【雑談】マシュマロウマウマ！【ホロライブ/猫又おかゆ】 【雑談】脱ハロウィン！！作業する～🎃【ホロライブ/ロボ子さん】 [[0.9560884]]
【雑談】脱ハロウィン！！作業する～🎃【ホロライブ/ロボ子さん】 【雑談】マシュマロウマウマ！【ホロライブ/猫又おかゆ】 [[0.9560884]]
```

一見うまくいっているように見えますが間違えも含まれています、これは何とも言えない精度ですね....

##敗因
すごく微妙な結果になってしまいましたがチュートリアルを除いて初の機械学習としてはまぁまぁなのではないかなと思います。
考えられる敗因としては

* データの整形方法
* 各パラメータの理解不足
* データの水増し方法
* そもそもデータ不足

なのではないかと思います、改善の余地ありまくりですし完全に勉強不足ですね。

##最後に
自分の力不足を再度実感させられる形となってしまいました、こうしたらもっと精度上がるとかそもそもお前間違ってるぞ、等のご指摘がありましたらお待ちしております。

VTuberの放送を見やすくリストアップするwebアプリを作っています、深夜に放送をはしごするときなんかにいかがでしょうか。
https://vlsapi-web.herokuapp.com/