k-means法でホロライブのサムネ傾向分析してみた

はじめに

k-means法によるクラスタリングを勉強したので、それを使ってホロライブの各ライバーのサムネの傾向分析をしてみた。ここで言う傾向分析とは、サムネをk-means法によってクラスタリングし、各ライバーが各クラスのサムネをどのような割合で作っているのかを調べ、傾向を分析しようという考えである。例えばシンプルなサムネをよく作るライバーやごちゃごちゃしたサムネをよく作るライバーなど傾向が見えるのでは?という考えである。ちなみに視聴数は動画の内容や話題性によるところが多いのか、寒ねとはあまり関係がなさそうであった。切り抜きとかならサムネが重要かも?

サムネ収集

Youtube APIで収集する。下記のコードのAPI_KEYに自分のAPI_KEYを入れ回せば、公開されているサムネはすべて収集できる。ただし、APIの1日の上限に引っかかるので、すべて収集するには3日くらいかかる。ENやIDは入れていない(チャンネルID取ってくるのが大変だった)。誰か作って公開してください。

dic = {
    "ときのそら" :"UCp6993wxpyDPHUpavwDFqgg",
    "AZKi":"UC0TXe_LYZ4scaW2XMyi5_kw",
    "ロボ子さん":"UCDqI2jOz0weumE8s7paEk6g",
    "さくらみこ":"UC-hM6YJuNYVAmUWxeIr9FeA",
    "白上フブキ":"UCdn5BQ06XqgXoAxIhbqw5Rg",
    "夏色まつり":"UCQ0UDLQCjY0rmuxCDE38FGg",
    "夜空メル":"UCD8HOxPs4Xvsm8H0ZxXGiBw",
    "赤井はあと":"UC1CfXB_kRs3C-zaeTG3oGyg",
    "アキローゼンタール":"UCFTLzh12_nrtzqBPsTCqenA",
    "湊あくあ":"UC1opHUrw8rvnsadT-iGp7Cg",
    "癒月ちょこ":"UC1suqwovbL1kzsoaZgFZLKg",
    "百鬼あやめ":"UC7fk0CB07ly8oSl0aqKkqFg",
    "紫咲シオン":"UCXTpFs_3PqI41qX2d9tL2Rw",
    "大空スバル":"UCvzGlP9oQwU--Y0r9id_jnA",
    "大神ミオ":"UCp-5t9SrOQwXMU7iIjQfARg",
    "猫又おかゆ":"UCvaTdHTWBGv3MKj3KVqJVCw",
    "戌神ころね":"UChAnqc_AY5_I3Px5dig3X1Q",
    "不知火フレア":"UCvInZx9h3jC2JzsIzoOebWg",
    "白銀ノエル":"UCdyqAaZDKHXg4Ahi7VENThQ",
    "宝鐘マリン":"UCCzUftO8KOVkV4wQG1vkUvg",
    "兎田ぺこら":"UC1DCedRgGHBdm81E1llLhOQ",
    "潤羽るしあ":"UCl_gCybOJRIgOXw6Qb4qJzQ",
    "星街すいせい":"UC5CwaMl1eIgY8h02uZw7u8A",
    "天音かなた":"UCZlDXzGoo7d44bwdNObFacg",
    "桐生ココ":"UCS9uQI-jC3DE0L4IpXyvr6w",
    "角巻わため":"UCqm3BQLlJfvkTsX_hvm0UmA",
    "常闇トワ":"UC1uv2Oq6kNxgATlCiez59hw",
    "姫森ルーナ":"UCa9Y57gfeY0Zro_noHRVrnw",
    "雪花ラミィ":"UCFKOVgVbGmX65RxO3EtH3iw",
    "桃鈴ねね":"UCAWSyEs_Io8MtpY3m-zqILA",
    "獅白ぼたん":"UCUKD-uaobj9jiqB-VXt71mA",
    "尾丸ポルカ":"UCK9V2B22uJYu3N7eR_BT9QA",
}

from googleapiclient.discovery import build
import pandas as pd
import requests
import time
import os
from tqdm import tqdm


# Youtube Data APIのキーを指定
API_KEY = # ここに自分のAPIキーを入力


# 作成するファイル名 (ディレクトリに保存するファイル名)
VIDEO_CSV_NAME = 'videos.csv'

base_url = 'https://www.googleapis.com/youtube/v3'
url = base_url + '/search?key=%s&channelId=%s&part=snippet,id&order=date&maxResults=50'
infos = []

for k in dic.keys():
    CHANNEL_ID = dic[k]
    os.makedirs("data/"+k, exist_ok=True)
    print(k)
    url = base_url + '/search?key=%s&channelId=%s&part=snippet,id&order=date&maxResults=50'
    infos = []
    videos = pd.DataFrame()
    if os.path.exists("data/"+k+VIDEO_CSV_NAME):
        videos = pd.read_csv("data/"+k+VIDEO_CSV_NAME)
    if videos.shape[0]==0:
        while True:    
            response = requests.get(url % (API_KEY, CHANNEL_ID))
            if response.status_code != 200:
                print('エラーが発生しました')
                time.sleep(10)
                continue
            result = response.json()
            infos.extend([
                [item['id']['videoId'],
                item['snippet']['title'],
                item['snippet']['description'],
                item['snippet']['publishedAt']]
                for item in result['items'] if item['id']['kind'] == 'youtube#video'
            ])

            if 'nextPageToken' in result.keys():
                if 'pageToken' in url:
                    url = url.split('&pageToken')[0]
                url += f'&pageToken={result["nextPageToken"]}'
            else:
                break
            time.sleep(1)

        videos = pd.DataFrame(
        infos, columns=[
            'videoId',
            'title',
            'description',
            'publishedAt'
            ])        
        videos.to_csv("data/"+k+VIDEO_CSV_NAME, index=None)

    for i, d in enumerate(tqdm(videos["videoId"])):
        url_img = "https://img.youtube.com/vi/"+d+"/default.jpg"
        file_name = "data/"+k+"/"+str(i)+".jpg"
        #time.sleep(1)

        response = requests.get(url_img)        
        if response.status_code != 200:
            print('エラーが発生しました')
            time.sleep(10)
            continue
        image = response.content

        with open(file_name, "wb") as f:
            f.write(image)

データ前処理

次にデータ前処理を行う。上下に黒い帯が入るのでトリミングして、numpy形式で保存しておく。

from tensorflow.keras.utils import to_categorical
from tensorflow.keras.preprocessing.image import array_to_img,img_to_array,load_img
import numpy as np
import matplotlib.pyplot as plt
import glob
from PIL import Image
from tqdm import tqdm
from tensorflow.keras import utils
import tensorflow as tf
import random
import cv2
%matplotlib inline

# MobileNetV2ネットワークを使用するので
size = (224, 224)

# データ前処理がまだならやる
if 1:
    X_train=[]
    Y_train=[]

    for i, k in enumerate(dic.keys()):
        print(k)
        for f in tqdm(glob.glob("data/"+ k +"/*")):
            temp_img=load_img(f)
            temp_img_array = temp_img.resize(size)
            temp_img_array = img_to_array(temp_img_array)
            # 上下に黒い帯が入るのでトリミング
            temp_img_array = temp_img_array[27:196,:,:]
            temp_img_array = Image.fromarray(np.uint8(temp_img_array))
            temp_img_array = temp_img_array.resize(size)
            a = np.asarray(temp_img_array)
            X_train.append(a)
            Y_train.append(i)
    np.savez("data/datasets.npz",x_train=X_train,y_train=Y_train)

データロード

categories=list(dic.keys())
nb_classes=len(categories)

f=np.load("data/datasets.npz")
X_train,Y_train=f['x_train'],f['y_train']
f.close()

データの確認

plt.imshow(x_train[0])

f:id:wooolwoool:20210901225135p:plain

そらちゃんかわいい。

データのベクトル化

k-means法では画像のピクセルデータをそのまま用いることもできるがいい結果は得られない。なぜならばピクセル単体では情報として重要な意味を持たず、互いの位置関係などから成される情報に大きな意味を持つからである。つまり画像クラスタリングするには何かしらを行って、情報量を抽出しなければならない。オートエンコーダを使用したりGANを使用するのが一般的ではあるが、面倒なので学習済みのMobileNet v2を使用して情報量抽出を行った。なので結果としてはあまり良くないかもしれない。

x_train=X_train.astype("float")/255

# モデル読み込み
base_model = tf.keras.applications.mobilenet_v2.MobileNetV2(
    weights='imagenet', input_shape=(size[0], size[1], 3),
    include_top=False, pooling='max'
)

# 推論
# メモリの都合上、50分割して実行
import gc

for i, data in enumerate(list(np.array_split(x_train, 50))):
    pre = base_model.predict(data)
    np.savez("data/"+str(i)+"pre.npz",pre=pre)
    gc.collect()

# データのロード
try:
    del x_train
except:
    pass
gc.collect()

pre=[]
for i in range(50):
    f=np.load("data/"+str(i)+"pre.npz")
    pre.extend(f['pre'])
    f.close()

クラスタリング

準備が整ったので早速クラスタリングを行う。今回は6クラスがよく分類できていそうだったので、これで行う。

from sklearn.cluster import KMeans
# 分類数
num_class = 6

kmeans_model = KMeans(n_clusters=num_class, random_state=10).fit(pre)
color_codes = {0:'#00FF00',
               1:'#FF0000',
               2:'#0000FF',
               3:'#00FFFF',
               4:'#FF00FF',
               5:'#FFFF00',
               6:'#008800',
               7:'#880000',
               8:'#000088',
               9:'#008888',
               10:'#880088',
               11:'#888800',}
labels = kmeans_model.labels_
colors = [color_codes[x] for x in labels]

from sklearn.decomposition import PCA #主成分分析器 プロット用
#主成分分析の実行
pca = PCA()
pca.fit(pre)

feature = pca.transform(pre)

# プロット
plt.figure(figsize=(10, 10))
# ラベルのためのダミープロット
for i in range(num_class):
    plt.scatter([], [], label = str(list(color_codes.keys())[i]), color = color_codes[i]) 
plt.scatter(feature[:, 0], feature[:, 1], alpha=0.8, color=colors)
plt.title("Principal Component Analysis")
plt.xlabel("The first principal component score")
plt.ylabel("The second principal component score")
plt.legend()
plt.show()

f:id:wooolwoool:20210901230611p:plain …うまく分類できているかは微妙だが、先に進める。もう少し距離離れて別れてくれればな。

分類結果の確認

各分類のサムネを確認して、どんなふうに分類されているかを見る。

n = list(range(num_class))
rand = list(range(len(labels)))
random.shuffle(rand)

for g in n:
    print("################################################")
    print("Group "+str(g))
    print("################################################")
    count = 0
    plt.figure(figsize=(20, 8))
    plt.subplots_adjust(wspace=0.0, hspace=0.0)
    
    for i in rand:
        if labels[i]==g:
            plt.subplot(4,6,count+1)
            ax = plt.gca()
            ax.axes.xaxis.set_visible(False)
            ax.axes.yaxis.set_visible(False)
            plt.rcParams["font.size"] = 20
            plt.imshow(cv2.resize(X_train[i],(224,132))) 
            count += 1
            if count > 24-1:
                break
    plt.show()      

クラス0

f:id:wooolwoool:20210901231059p:plain

クラス1

f:id:wooolwoool:20210901231104p:plain

クラス2

f:id:wooolwoool:20210901231110p:plain

クラス3

f:id:wooolwoool:20210901231115p:plain

クラス4

f:id:wooolwoool:20210901231121p:plain

クラス5

f:id:wooolwoool:20210901231126p:plain

それっぽく分類はできてそう。

分類割合の解析

各ライバーのそれぞれのサムネタイプの割合をグラフ化。獅白ぼたんがクラス3(シンプルめなクラス)が多いのに対して、桃鈴ねねにはクラス3がほとんどないなどそれぞれ個性が出ていて面白い。

import pandas as pd
d = {
    "who":Y_train,
    "label":labels
}
df = pd.DataFrame(d)
keys = list(dic.keys())
tmp_df2 = pd.DataFrame(index=list(range(num_class)),columns=keys)

for idx, k in enumerate(keys):
    tmp_df = df[df.who == idx]
    c = []
    cc = []
    for i in range(num_class):
        c.append((tmp_df["label"]==i).sum())
    for i in range(num_class):
        tmp_df2.loc[i, k] = c[i]/sum(c)

# プロット
import japanize_matplotlib
fig, ax = plt.subplots(figsize=(10, 18))

for i in range(num_class):
    ax.barh(tmp_df2.columns, 
           tmp_df2.iloc[i], 
           left=tmp_df2.iloc[:i].sum())
ax.set_xlim([0,1])
ax.set(xlabel='割合', ylabel='名前')
ax.legend(tmp_df2.index, bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0)
plt.show()

f:id:wooolwoool:20210901231458p:plain

主成分分析による2次元散布図へのプロット

最終的な結果を以下に示す。この散布図では、点が近いほど同じような方針でサムネを作成しているであろうと考えられる。獅白ぼたんと猫又おかゆや桃鈴ねねと紫咲シオンと角巻わためが近くて似たものがよるんだなという感じではあった。(フレアそこ?)

pre = []
for col in tmp_df2.columns:
    pre.append([t for t in tmp_df2[col]])
    
pca = PCA()
pca.fit(pre)
feature = pca.transform(pre)

plt.figure(figsize=(10, 10))
for x, y, name in zip(feature[:, 0], feature[:, 1], keys):
    plt.text(x, y, name, alpha=0.8, size=15)
plt.scatter(feature[:, 0], feature[:, 1], alpha=0.8)
plt.title("Principal Component Analysis")
plt.xlabel("The first principal component score")
plt.ylabel("The second principal component score")
plt.show()

f:id:wooolwoool:20210901231847p:plain

最後に

この解析方法が正しいかどうかは保証しません。特に特徴量抽出のところは雑にやってます。なのであまり鵜呑みにはしないでください。