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

最後に

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

Transformersで文書分類時のエラーまとめ

Transformersで文書分類をするとき、いろいろとエラーが出たのでメモしておく。

環境

OS:Ubuntu 20.04

github.com

Dockerのビルド

Dockerfileはtransformers-pytorch-gpuを使用。以下の行を修正して、transformersのあるディレクトリでDocker buildをする。

COPY . transformers/

COPY transformers transformers/

Notebook

NotebookはExampleのcustom_datasets.ipynbを使用。

Notebookでtqdmが使われているとエラーになった。tqdmが原因のようだが解決できず。 エラーで止まるのはPreTrainedモデルのダウンロードだけなので、以下をターミナルで実行して、予めダウンロードしておけばOK。

from transformers import DistilBertTokenizerFast
tokenizer = DistilBertTokenizerFast.from_pretrained('distilbert-base-uncased')

PandasでImportError

PandasをImportしようとしたら下記のようなエラーになった。

ImportError: C extension: No module named 'dateutil.easter' not built. If you want to import pandas from the source directory, you may need to run 'python setup.py build_ext --inplace --force' to build the C extensions first.

言われたとおり、ソースコードをダウンロードしてビルド。

$ git clone https://github.com/pandas-dev/pandas.git
$ python setup.py build_ext --inplace --force

自分の環境だとcythonがないと怒られたのでインストールしておいた。

$ pip install cython

Condaでインストールしていれば、こういうのはなかった気がするような。

学習率減衰の実装

ディープラーニングでは学習率を徐々に減衰させていくと、いい学習ができる。イメージとしては、最初の方は大ざっぱに調節していき、最後に微調整していくような感じ。ただ最初の方は減衰させずにやりたいとかいろいろカスタマイズしたかったので実装した。

import math

class lr_decay:
    def __init__(self, decay_start, decay_stop, lr_start, lr_end):
        self.decay_rate = abs((math.log10(lr_start) - math.log10(lr_end))/(decay_start - decay_stop))
        self.decay_start = decay_start
        self.decay_stop = decay_stop
        self.lr_start = lr_start
        self.lr_end = lr_end
        
    def get_lr(self, idx):
        if idx < self.decay_start:
            return self.lr_start
        if idx > self.decay_stop:
            return self.lr_end
        return 10**(math.log10(self.lr_start) - (idx - self.decay_start)*self.decay_rate)
    

epoch = 100
lr = lr_decay(decay_start=int(epoch*0.25), decay_stop=int(epoch*0.75), lr_start=1e-4, lr_end=1e-5)
  • decay_start:減衰開始のエポック数
  • decay_stop:減衰終了のエポック数
  • lr_start:開始の学習率
  • lr_end:終了の学習率

減衰の仕方は以下のような感じ。

import matplotlib.pyplot as plt
lrs = [lr.get_lr(i) for i in range(epoch)]
plt.plot(lrs)
plt.show()

f:id:wooolwoool:20210503163256p:plain

Google Colaboratory メモ

Google Colaboratoryの基本的な使い方、困ったことをメモしておく。

colab.research.google.com

apexが使えないんだが?(Pytorch)

PIPのインストールではPytorchでは使えないのでビルドする必要あり。 PytorchはCUDA10.1でビルドされているので、それに合わせてCUDAバージョンとコミットのリビジョンを変更してビルドする。

%%writefile setup.sh

export CUDA_HOME=/usr/local/cuda-10.1
git clone https://github.com/NVIDIA/apex/
cd apex && git reset --hard a651e2c24ecf97cbf367fd3f330df36760e1c597
pip install -v --no-cache-dir --global-option="--cpp_ext" --global-option="--cuda_ext" .
!sh setup.sh

また、Importはapexディレクトリ内でやらないとエラーになる。ただカレントディレクトリを移動するなどが面倒なので、フォルダをコピーしてしまって対処する。お行儀が悪いので注意。

! cp apex/apex . -r
from apex import amp, optimizers

起動、再起動方法

右上の”接続”というところをクリックする。RAM、ディスクという表示がされればOK。 再起動する場合は、左上のランタイム→ランタイムを再起動 をクリックする。

自作notebookを開く

右上のファイルから、ノートブックを開く。このとき、GoogleDriveにアップロード、またはGithubにある必要がある。

GPU有効化

右上の”接続”も右にある▼→リソースを表示→ランタイムのタイプを変更 をクリックし、GPUを選択する。TPUも選択できるようだが、サポートしていないコードも多いので、GPUが安定。

ファイル読み込みが遅い

ファイルが複数に別れていると遅くなる。HDF5ファイルに固めるなどすると早くなる。

PIPでライブラリをインストールしたい

↓みたいに先頭に!をつけて実行すると、Bashで実行されるのと同様になる。

!pip install pandas

カレントディレクトリどこ?

/content

tqdmの表示がおかしい

↓を使う。

from tqdm.notebook import tqdm

エラー集

Exception: Caught Exception in DataLoader worker process 0.

DataLoader のnum_workerを0か1にする。

ディープラーニング学習でのデータ読み込み高速化

Google Colabを使ってディープラーニングの学習を行おうとしたが、データの読み込みが非常に遅い。 もともと使っていたコードでは画像ファイルを1枚1枚直接読み込んでいた。ローカルでは数分かかる程度であったが、Google Colabだと1時間近くかかるようになったので、流石に待てないので高速化する。

方法としては、画像ファイルをすべてHDF5ファイル1つに集約する。HDF5ファイルについては以下のリンクが参考になる。

qiita.com

概要

HDF5ファイルではグループによって、データを階層構造で保持することができる。今回は

/train
グループを作成して、そこにDatasetとして画像ファイルを保存していく。Datasetの名前はそのファイル名とする。

画像ファイルは

/data
ディレクトリに保存してある。これをグレースケールで読み込み、保存していく。

/data
/data/img_001.png
/data/img_002.png
...

以降の作業はJupyer notebookなどPython環境で行っていく。

手順

'file.hdf5'というファイル名で作成する。このとき、ファイルがすでにあるとエラーになるので注意。

import numpy as np
import h5py
import glob, cv2
f = h5py.File('file.hdf5', mode='w')

グループを作成する。

group = f.create_group('/train')

画像ファイルをリストアップ。

file_list = glob.glob("/data/*.png")

グレースケールで読み込み、Datasetに保存する。

import os

for file in file_list:
    arr = cv2.imread(file, cv2.IMREAD_GRAYSCALE)
    dataset = group.create_dataset(
        name=os.path.basename(file), shape=arr.shape, dtype=np.uint8)
    dataset[...] = arr

ファイルを閉じる。

f.close()

以上でデータをHDF5ファイルに変換することができた。

データ読み込みとチェック

HDF5ファイルのデータにアクセスし、データのチェックを行う。HDF5ファイルのデータには、辞書データのようにアクセスすれば良い。

f = h5py.File('file.hdf5', mode='r')
dataset = f['/train']

c = 0
for file in file_list:
    arr_h5 = dataset[os.path.basename(file)][...]
    arr_org = cv2.imread(file, cv2.IMREAD_GRAYSCALE)
    if (arr_h5 != arr_org).all():
        c = c+1
print("Diff :", c)

"Diff: 0"と表示されればOK。0以外ならなにかおかしいので見直し。

リモートサーバーのDockerでGUIアプリを動かす

リモートサーバー上にあるDockerのGUIアプリを動かしてみた。いろいろと苦労したが、最終的に成功した方法を示す。というかログメッセージが少なすぎる。。Can't open displayってもう少し詳しく書いてくれ。。

構成

ホストPCからリモートサーバーにSSHでアクセスして操作する。

  • ホストPC(自分が操作するPC)
  • リモートサーバー(Docker(GUI)のあるPC)

操作手順

ホストPCからXフォワーディングオプションをつけてアクセスする。ラージXなので注意。

$ ssh -X ubuntu@192.168.11.100

下記のようにオプションをつけてDockerを起動。今回は手元にあったPythonイメージを使用。

$ docker run -it -e DISPLAY=$DISPLAY --net host -v $HOME/.Xauthority:/root/.Xauthority:rw --name x11_test python:3.8-slim-buster bash
  • -it
    • コンテナ入出力を現在のターミナル標準入出力にマウントする。
  • -e DISPLAY=$DISPLAY
    • DISPLAY変数を引き継ぐ。
  • -v $HOME/.Xauthority:/root/.Xauthority:rw
    • .Xauthorityをマウントする。これはXServerの認証の際に使用される。
  • --net host
    • ホスト側のネットワークIFを使用する。
  • --name x11_test
    • コンテナの名前。なんでもいい。

重要なのは--net host。リモートサーバーでDockerを動かすにはこれが必要。

xorgをインストール。 当然、Dockerコンテナ内にXサーバーがないと表示ができないのでインストール。自分はここでハマった。

$ apt update
$ apt install xorg

テスト。 目が表示されればOK。

$ xeyes

防備録

下記のエラーが出たら、Xauthorityが正しく設定されているか、RW権限があるかを確認。

X11 connection rejected because of wrong authentication.

X11フォワーディングが有効になっているか確認する。

vim /etc/ssh/sshd_config
X11Forwarding yes

Docker 起動時に下記のオプションをつければソケットがバインドできるが、別途認証などが必要なのでやめたほうがいい。

-v /tmp/.X11-unix/:/tmp/.X11-unix

下記コマンドをホスト側で実行すると、認証なしで画面が表示できる。何故か映らない場合に試すといい。

$ xhost +
access control disabled, clients can connect from any host