はじめに
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])
そらちゃんかわいい。
データのベクトル化
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()
…うまく分類できているかは微妙だが、先に進める。もう少し距離離れて別れてくれればな。
分類結果の確認
各分類のサムネを確認して、どんなふうに分類されているかを見る。
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
クラス1
クラス2
クラス3
クラス4
クラス5
それっぽく分類はできてそう。
分類割合の解析
各ライバーのそれぞれのサムネタイプの割合をグラフ化。獅白ぼたんがクラス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()
主成分分析による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()
最後に
この解析方法が正しいかどうかは保証しません。特に特徴量抽出のところは雑にやってます。なのであまり鵜呑みにはしないでください。