まいだいありー

機械学習、技術系、日記など勉強したことのメモを書けたらなと思います。

SPADEを用いてラフな絵からアニメキャラ生成を試みた

はじめに

はじめに, 2019年の論文 Semantic Image Synthesis with Spatially-Adaptive Normalization で, BatchNormの正規化によって今まで流れてきた情報を落としてしまう問題を指摘し, 情報を落とさないように伝播させるために BatchNorm の線形変換にセグメンテーション画像を用いる正規化手法を提案しました. (以後, このモジュールをSPADEと呼びます) (AdaIn や StyleGAN でもスタイル情報を用いた正規化手法が使われている)

f:id:kenzo1122:20210620170300p:plain
図1: SPADE

その結果, セグメンテーション画像をユーザが自由に指定し, セグメンテーション画像に従った画像を生成することに成功していました.

f:id:kenzo1122:20210620170548p:plain


今回はSPADEを使って, ラフ(このラフの定義は次の章でします) からアニメキャラへ変換する実験を行います

SPADEに用いる画像について

とは言え, わざわざセグメンテーション画像を用意するのは大変ですので, 元画像を前処理したものをセグメンテーション画像の代わりとしてSPADEへの入力に使います.

ここでのアイディアは, できるだけ画像の色の種類を何らかの手法で減らした画像であれば, ある1つのオブジェクト内(例えばアニメキャラでいえば, 髪の毛や目) の画素値はほぼ一定になります. セグメンテーション画像はオブジェクト数個のチャンネルとonehotで表現されますが, 色の種類減らした画像はピクセルの位置や3チャンネルの各画素値(3次元ベクトル)からある1つのオブジェクトと色彩を表現できるのではないかということです. つまりセグメンテーション情報や位置, 色彩の情報を3次元空間に埋め込んでるような雰囲気ですね.

ですが, 各オブジェクトで画素値が一定などという理想的な画像は作れないので(自分の技術では), できる限りそのような画像に寄せることの出来る手法を画像に適用します.

mean shift法

色の種類を減らした画像を作るために, 今回は, mean shift法によって色のクラスタリングを行います. ( K-meansも試したが欲しい画像が得られなかった)

理論的な説明はこちらが詳しいです. ここでは簡単に概要だけ記述します.

mean shift法とは, d次元空間内に N個のデータ  {\bf x_i} \in R^{(1,d)} が分布しているとした時,  {\bf x_i} を標本点として得られるような確率密度函数 f(x)を考えます.
それぞれの標本点から f(x)の近傍の極大点( f(x)が最大になる点)を探索して, 同じ極大点に集まった標本点を同じクラスに割り当てる手法です.

以下の処理をN個のデータで実行し, クラスタリングされます.

  1. 半径rの円を考え, その円内に存在するデータの平均値を求める

  2. 求めた平均値へシフトし, これを何度もループし極大点を見つける.

mean shift法による画像のクラスタリング は, OpenCVpyrMeanShiftFiltering 関数で実行できます.

前処理を適用する

実際に行った前処理ですが, 色空間を RGB -> HSV に変換しておき, mean shift法の適用前と適用後に, Gaussian filter を適用しました.

img = cv2.cvtColor(img, cv2.COLOR_RGB2HSV )
img = cv2.medianBlur(img,9)
img = cv2.pyrMeanShiftFiltering(img,52,52)
img = cv2.medianBlur(img,15)
img = cv2.cvtColor(img, cv2.COLOR_HSV2BGR)

こちらの処理を画像に適用した結果を以下に示します. また, 前処理を施した画像をラフとし, SPADEへ入力するスタイル情報とします.

f:id:kenzo1122:20210620194130p:plain
図2: mean-shift法とGaussian filter による前処理した画像

実験

今回実験に使用した Generator, Discriminator ともに, Semantic Image Synthesis with Spatially-Adaptive Normalization で使用されていたものです. (Generatorには各層にノイズを加えています.)

実験環境

実験で使用したコードです

github.com

実験結果

最初に, train, test データからランダムサンプルしたデータを用いて生成した結果.

f:id:kenzo1122:20210620205045p:plain
図3: train,testデータによる生成結果


次に, Clip Studio で適当に描いたラフを用いてアニメキャラを生成した結果を示します.

f:id:kenzo1122:20210620211118p:plain
図4: ラフによる生成結果


右側の子のスタイルを変えてみた結果

f:id:kenzo1122:20210620211618p:plain
図5: スタイル変換

感想

  • 実験する前からわかっていたが, mean shift + Gussian filter を施した画像にどれだけ近いラフが描けるかが肝になってしまうので, 現実的ではない.
  • 自分で描いたラフで生成すると歪みが目立つことや, 描いたパーツの形がもろに生成結果に影響が出るので, 生成結果が全く安定しない.
  • 細かい部分は抽象度高く生成されてしまうが, キャラクターと認識できる程度のものは生成され, ポーズや髪の毛などのスタイル変換の自由度はかなり高いので, まだやりようはありそう.

二次元キャラで二次元キャラのモザイクアートをする

モザイクアートをする

二次元キャラで二次元キャラのモザイクアートをしてみたかったので, Pythonでサクッとやってみた.

手順

  • 元画像をタイル状に分割 ( 画像サイズ / 分割したい数 )
  • 元画像のタイル毎にカラーヒストグラムを求める. (1)
  • タイルを埋める画像のカラーヒストグラムを求めてリストに保管 (2)
  • 1 と 2 間の Bhattacharyya 距離 を全探索で計算し, 最小となる画像で埋める

Result

  • タイルを埋める画像の枚数は240枚程度
  • 入力画像( 1024x1024 )を 64分割したとき, モザイクアート(1024x1024)にかかる時間は 約 30 秒でした. (2.7 GHz dual core Intel Core i5)
  • もっと画像を使えば色彩豊かになると思うが, 使う画像を検索する手法はただの線形探索なので, 本当は二分探索とか使うべき. (とはいえこのスケールなら線形探索でも十分)
  • もしくは, 予めタイルを埋める画像の色特徴ベクトル化して, クラスタリング. そして, 元画像のタイルの色特徴ベクトルがどこのクラスタに属するかを計算して, 割り当てられたクラスタの画像をランダム使う, のようなことをすれば探索数はクラスタ数になるので全体の計算時間は減るかも.

f:id:kenzo1122:20210605171037p:plain:w300:h300

f:id:kenzo1122:20210605170950p:plain:w300:h300

f:id:kenzo1122:20210605170857p:plain:w300:h300

f:id:kenzo1122:20210605170544p:plain:w300:h300

f:id:kenzo1122:20210605170653p:plain:w300:h300

f:id:kenzo1122:20210605170754p:plain:w300:h300

コード

最後に使ったコードを残しておきます.

補足すると,
--input_path : モザイクアート化したい画像のパス
--ref_path : タイルを埋める画像のディレクトリのパス
--n_tile : 何分割するかを指定

example

$ python mosaic_art.py --input_path "input.png" --ref_path "./images" --n_tile 64


import cv2
import argparse
import numpy as np
import glob
from tqdm import tqdm
parser = argparse.ArgumentParser()
parser.add_argument("--input_path", default="./input.png",type=str)
parser.add_argument("--ref_path", default="./images",type=str)
parser.add_argument("--output_path", default="./output.png",type=str)
parser.add_argument("--n_tile", default = 64,type=int)
parser.add_argument("--output_size", default = 1024,type=int)
opt = parser.parse_args()
tsize_x,tsize_y = None,None


def init_load():
    img = cv2.imread(opt.input_path)
    #img = cv2.cvtColor(img,cv2.COLOR_BGR2RGB)

    ref_path_l = glob.glob(f"{opt.ref_path}/*")
    if opt.input_path in ref_path_l:
        del ref_path_l[ref_path_l.index(opt.input_path)]

    return img , ref_path_l

def ref_img_load(p):
    ref_img = cv2.resize(cv2.imread(p),(tsize_x,tsize_y))
    #ref_img = cv2.cvtColor(ref_img,cv2.COLOR_BGR2RGB)
    return ref_img



def get_tile_size(img):
    global tsize_x,tsize_y
    tsize_x = img.shape[0]//opt.n_tile
    tsize_y = img.shape[1]//opt.n_tile
    return tsize_x, tsize_y

def img2tile_stats(img):
    img_tile_stats = []
    tsize_x,tsize_y = get_tile_size(img)
    for x in range(opt.n_tile):
        for y in range(opt.n_tile):
           img_t = img[x*tsize_x : (x + 1)*tsize_x , y*tsize_y : (y + 1)*tsize_y ] 
           s = get_colorhist(img_t)
           img_tile_stats.append(s)
    
    return np.array(img_tile_stats)

def cos_sim(v1, v2):
        return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))

def get_colorhist(img):
    img_h = [cv2.calcHist([img], [i], None, [256], [0, 256]) for i in range(3)]
    return np.array(img_h)
def normlize(img_h):
    s = img_h.sum(1)
    return img_h/s[:,np.newaxis]
def calc_hist_distance(img_h,ref_h):
    img_h = normlize(img_h) 
    ref_h = normlize(ref_h) 
    dist = cv2.compareHist(img_h,ref_h,3)

    return dist 


def ref_img_alloc(img,ref_path):
    img_tile_stats = img2tile_stats(img)
    ref_img_stats=[get_colorhist(ref_img_load(p)) for p in ref_path]
    alloc_path = []

    print("==> calculating distance between img_tile and ref_img")
    for img_st in tqdm(img_tile_stats):
        min_dist = 10e10
        min_dist_ref = ""
        for i,refim_st in enumerate(ref_img_stats):
            dist = calc_hist_distance(img_st,refim_st)
            if min_dist > dist:
                min_dist = dist
                min_dist_ref = ref_path[i] 

        alloc_path.append(min_dist_ref)
    del ref_img_stats
    del img_tile_stats
    return alloc_path


def create_mosaic_art(ref_path):

    ref_img=[ref_img_load(p) for p in ref_path]
    output_img = np.zeros((opt.output_size,opt.output_size,3),dtype=np.uint8)
    tsize_x,tsize_y = opt.output_size//opt.n_tile,opt.output_size//opt.n_tile
    print("==>  allocating ref_img")
    for x in tqdm(range(opt.n_tile)):
        for y in range(opt.n_tile):
            output_img[x*tsize_x : (x + 1)*tsize_x , y*tsize_y : (y + 1)*tsize_y ] =  ref_img[y+opt.n_tile*x]

    cv2.imwrite(opt.output_path, output_img)



def main():
    img,ref_path_list = init_load()
    tsize_x,tsize_y = get_tile_size(img)
    alloc_path = ref_img_alloc(img,ref_path_list)
    create_mosaic_art(alloc_path)

if __name__ == '__main__':
    main()


アニメキャラの顔を色指定して自動彩色( pix2pix with color hint )

はじめに

AnimeFace で pix2pix をやってみたで pix2pix モデルを用いてアニメキャラの顔の自動彩色を実験しました. その際は, 単に線画と元の画像のペアが似てるかどうかを学習させ, Generator が線画に彩色したものを生成させるというものでした.
今回はその続きとして, ユーザがカラーヒントを与え, そのカラーヒントの通りに自動彩色させるような Generator を作成してみたいと思います.

最初に結論を述べておくと, 割とうまく行きました...!


pix2pix with color hint

f:id:kenzo1122:20210603005518p:plain:w400:h250

図1: 学習モデルの概要


学習する方法は pix2pixと何も変わりませんが(ここを参照), 細かいところで異なる部分があるのでそこだけ説明します.


カラーヒントとして使う画像

色を指定して自動彩色させるには, どのようなカラーヒントを与えるかが重要です.

今回は, 複数個のサークルを元画像に貼り付けてマスクする Circle Mask (勝手に命名) を用いました. Circle Mask を画像に適用することで良い感じにマスクされるところとされないところが出来上がり, これをカラーヒントとして用いました.

f:id:kenzo1122:20210602235144p:plain

図2: Circle Mask

また, ロバスト性を向上させるため, データセットをバッチごとに切り出す際にランダムで Circle Mask を適用させています. なので epoch 数を重ねるほど多様なカラーヒントと出会うので色指定による彩色の精度は上がるはずです.


カラーヒントを Generator に付与する

カラーヒントの情報を Generator に与える方法は単純で, 入力のチャンネル方向にカラーヒントの画像と線画を結合しました. ( PyTorchでは, (batch_size, 4, 256, 256 ) となる )

Generator のネットワーク

f:id:kenzo1122:20210603004042p:plain

図3: Generator ( SLEBlock を用いた U-Net)


今回は, U-Net に Skip-layer excitation Block (SLEBlock) を組み合わせたネットワークで実験しました.
SLEBlockは 小さい特徴量マップを一本のベクトルに潰し, 大きい特徴量マップと積をとった出力をする (Attention のような役割もありそう? )モジュールで, LightweightGANのGeneratorで用いられていました.

f:id:kenzo1122:20210602231133p:plain:w250:h400

図4: Skip-layer excitation Block (引用[1])


LightweightGANのGenerator ではガウス分布からサンプリングしたベクトルをアップサンプリングする構造ですが(つまり Decoderのみ), 以下の図5の緑の矢印のように途中のデコード部分を逆にみることで U-Net 的な構造でも使えるのではという発想です.

f:id:kenzo1122:20210602234529p:plain

図5: LightweightGANのGenerator (引用[1])


バニラ U-Net では Encoder の各特徴量マップの大きさが一致するものだけを Decoder に skip-connection で結合していましたが, SLEBlock を用いることでskip-connection によって伝わる情報が局所的な特徴と全体の特徴を考慮したものとなるので, 取り入れてみました.

実際に用いたレイヤーの詳細は, Encoder のレイヤーでは, Conv -> BatchNorm -> LeakyReLU -> AvgPool のダウンサンプリング, Decoder のレイヤーは, 図5のレイヤーと同じです.

Discriminator

Discrminatorの入力は, [線画,元画像, Circlemaskのカラーヒント] と [線画, 生成画像, Circlemaskのカラーヒント] であり, PyTorch表記での次元は (batch_size, 7, 256,256)です また用いたレイヤーは, Conv -> BatchNorm -> LeakyReLU -> AvgPool のダウンサンプリングです.

実験環境

Google Colab で実験しました.

コード

github.com

実験結果

最初に学習データとテストデータを混合したデータで, 線画とCircle Mask によるカラーヒントをGeneratorに入力した時の自動彩色結果です.
当然ですが綺麗に彩色されています.

f:id:kenzo1122:20210603155602p:plain
図6:Circle Mask と線画を入力に用いた彩色


次に学習データとテストデータを混合したデータで, 線画とそれに対応関係でない元画像のCircleMask(つまりシャッフルした) によるカラーヒントをGeneratorに入力した時の自動彩色結果です.
これは彩色というよりかはスタイル変換のような結果として示します.
本来ランダムにカラーヒントを与えて彩色することが目的ではないので, よくあるスタイル変換という文脈とした用途では向いてませんね.
さらに学習データ内の各パーツにない色がカラーヒントとして与えられると, 汚い彩色になりがちです. (例えば肌に彩度の低いオレンジ系ではないカラーヒントが与えられた時など)

f:id:kenzo1122:20210606010839p:plainf:id:kenzo1122:20210606010750p:plainf:id:kenzo1122:20210606010649p:plain
図6 : CircleMaskをシャッフルした結果


次に学習データとテストデータから数枚サンプルし, Clip Studioでカラーヒントとして用いる色をつけた画像(以下の実験結果の真ん中) を用意しました. そのカラーヒントの画像とそれに対応する線画をGenerator に入力した時の自動彩色結果を以下に示します.


学習データの線画と人間によるカラーヒントを用いた自動彩色結果

f:id:kenzo1122:20210603154449p:plainf:id:kenzo1122:20210603154454p:plainf:id:kenzo1122:20210603154458p:plainf:id:kenzo1122:20210603154504p:plainf:id:kenzo1122:20210603154509p:plain
図7 : 人間によるカラーヒントと線画を入力に用いた彩色(学習データ)


テストデータの線画と人間によるカラーヒントを用いた自動彩色結果

f:id:kenzo1122:20210603153141p:plainf:id:kenzo1122:20210603153136p:plainf:id:kenzo1122:20210603153132p:plainf:id:kenzo1122:20210603153127p:plainf:id:kenzo1122:20210603153053p:plainf:id:kenzo1122:20210603153035p:plainf:id:kenzo1122:20210603153146p:plain
図8 : 人間によるカラーヒントと線画を入力に用いた彩色(テストデータ)

以上が実験結果ですが, 彩色結果から一応色指定による自動彩色は成功しています. 学習データとテストデータの彩色結果の差は見受けられませんので, 汎用性は割とあるのかもれません.

ただ, Circle Maskによるカラーヒントと人間がつけるカラーヒントとでは彩色結果に大きな差があることがわかります. これはおそらくCircle Mask によって切り取られるカラー情報の形や, 切り取られたカラーが1色ではないことなどに依存してしまってることが原因だと思います. 対処としては, マスクする方法を変える,ブラーを入れるなどバリエーションを持たせることが必要です.

また, 実験途中で線画を作る手法を変更したら彩色結果が酷く劣化したので, 線画の種類を増やすため全体が細い線画と輪郭が太い線画を確率的選択をし, 入力として用いています ( 今の時点であらゆる線画に対応させる意味はないですが )

次は高解像度の画像で, 今回問題となった部分の対処法を見つけて同じことをやってみたいと思います, 以上!

参考

AnimeFace で pix2pix をやってみた.

pix2pix とは



かなり今更という感じですがやります.

pix2pix は Image-to-Image Translation with Conditional Adversarial Networks で提案されたConditional GAN (CGAN) の一種.

f:id:kenzo1122:20210529022722p:plain

図1 : pix2pixによる画像変換例[1]


pix2pixでは, デフォルメされた地図を航空写真のように変換, 線画やグレースケールの写真に着色, 昼間から夜間の画像に変換など様々なタスクで上手くいってるようです.


f:id:kenzo1122:20210528195712p:plain

図2 : pix2pixの概念図[1]


pix2pixは通常のGANとは異なり, あるノイズのベクトルではなく画像を Generator に入力します.   学習過程は上図のように, Discriminatorに入力に用いた画像(条件として)と生成された画像を結合してから入力します. Generator での学習では, Discriminator に "Real" と予測させるように, Discriminator の学習では, 生成された画像を "Fake" , 本物の画像を "Real" と予測するように交互に学習させます. (通常のGANの学習と同じ)

Generator



Generator に使われてるモデルは, UNet でセマンティックセグメンテーションの分野で活躍しているモデルが採用されている. Encoder-decoder モデルでは, ダウンサンプリングしていくことで位置情報などの情報が失われてしまうが, U-Netでは Encoderで各層でダウンサンプリングした特徴量マップを Decoder 側に skip connection しており, 失われるはずであった位置情報も考慮しながらアップサンプリング可能となる.
確かに, グレースケール画像や線画に色をつけるという意味ではセグメンテーション的な意味合いもなくもないと思う.

f:id:kenzo1122:20210528201836p:plain

図3 : Enc-DecとU-Net

Discriminator



Discriminatorでは, PatchGANという手法が使われています.
通常は入力された画像に対して Fake か Real かを評価するが, PatchGANでは入力された画像を N × N のタイル状にみて, 各タイルが Fake か Real なのかを判定することでより, ロスを計算するそうです. (いわゆる N が Patch_size にあたる) 具体的には, Dsicriminator の出力( 1, N , N) と (1, N, N) の次元を持つすべて 1 で埋めたもの, すべて0 で埋めたものの行列を用いて, その差がDiscriminatorのロスになります.

損失関数


一般的なGANの損失関数に, f:id:kenzo1122:20210528204247p:plain

Real の画像とFake の画像の L1距離 f:id:kenzo1122:20210528204306p:plain

以上を足して, これを最適化します. f:id:kenzo1122:20210528204303p:plain

GANの損失関数はPatchGANによるものなので, 画像の局所的な部分の誤差と解釈でき, Real の画像とFake の画像の L1距離は画像の大域的な誤差と解釈でき, バランスをうまく補っているようです.
ちなみに, L1距離の重みとしてある λ は大きくすると, 大域的な誤差を重視するようになるため, 生成される画像と元画像が似るようになるということですかね.

また論文中では, L1距離を足すことでブラー(生成画像のボケ)が減少すると書かれています.

AnimeFace で pix2pix



概要

f:id:kenzo1122:20210529013957p:plain:h400:w450

図4: pix2pixによるAnimeFaceの自動着色


線画と元画像でペアにしてデータセットを作成し, Generator に線画を入力することで着色された画像が生成できるように学習させることを目的とします. 学習の仕方としては, 序盤で記述したのと同様です.
GeneratorのネットワークはSkip-Connectionありの Conv, BatchNorm, ReLU,AvgPool, Upsampling,を用いた U-Net の構造です. DiscriminatorのネットワークはConv, BatchNorm, ReLU ,AvgPool,を用いた普通のダウンサンプリングです.


実験環境

Google Colab で実験しました. また, pix2pixのネットワークでは, Discriminator の入力でスタイル変換させたい画像(今回は線画)を結合して入力しますが, 結合しないバージョンも実験してみました

  • PyTorch (実装は[2][3]あたりを参考)
  • batch_size = 4
  • Epoch = 65
  • データセットは, Kaggleに置いてあるanother-anime-face-dataset から 5000枚をサンプル (サイズは 256×256 )

今回使ったコードです

github.com


実験結果

Epoch 65 までの学習した, Generatorにテストデータを入力した着色生成結果を載せます.

所感

  • Epoch数が早い段階で着色は始まり, 難しいことせずともしっかり最後は着色できてる.
  • 線画を入力するかしないかの違いは着色結果ではあまりみられない.
  • 全体の結果からは, 彩度が高い着色や, 青と緑の色の着色が失敗しやすく, 赤系の色の着色は成功する兆候がある

f:id:kenzo1122:20210530152521p:plain

図5: 線画を結合して学習したテストデータでの生成結果


f:id:kenzo1122:20210530152508p:plain

図6: 線画を結合しないで学習したテストデータでの生成結果


この発展としては, Generator に色のヒントをなんらかの形で与えて学習させることで, ユーザが色を指定して生成することができ, 実際成功例もweb上で見られます.  一番手っ取り早そうなのは, 線画と元の画像のペアを入力のチャンネル数を 1 -> 4 としてGeneratorに入力したり..., Decoder部分からヒントとして元の画像をEmbeddingして入力するなどが考えられます.
また, 解像度が高い画像でも上手くいくのかは気になるところです.

以上! この続き, カラーヒントを与えて自動彩色してみた編

参考


[1] https://arxiv.org/pdf/1611.07004.pdf
[2] pix2pixを1から実装して白黒画像をカラー化してみた(PyTorch) | Shikoan's ML Blog
[3] GitHub - mrzhu-cool/pix2pix-pytorch: PyTorch implementation of "Image-to-Image Translation Using Conditional Adversarial Networks".

画像をタイル状に結合する

画像をタイル状に結合

背景に使うためにイラストをタイル状に並べた画像が必要になったので, メモをしときます.

montage コマンド

結合したい画像は同じフォルダに入れておいて, そのフォルダ内で以下を実行します.

$ montage *jpg -geometry <入力画像サイズ> -tile <出力画像サイズ> <出力ファイル名>


実際にやってみる

今回例として使う画像はKaggleに置いてあるanother-anime-face-dataset から拝借しました.

ここから, 40枚サンプリングして, 以下を実行.

$ montage *jpg -geometry 256x256 -tile 5x8 tile.jpg

出力

f:id:kenzo1122:20210525202323j:plain

Turi Create でアニメのレコメンドエンジンを作る【デモあり】

turicreate とは



turicreate は Apple機械学習のライブラリで, 古典的手法から深層学習を用いたタスクまでいくつかカバーされており, sklearnのように手軽に学習させることができます.

apple.github.io


最初にturicreateでレコメンドを行う方法を書いておきます.
ちなみにこのチュートリアル がわかりやすいです

turicreateのインストール

pip install turicreate


レコメンダーを作成

hoge.csv には [user_id , item_id, rating] のカラムがあるとすると, 以下で学習できます.

import pandas as pd
import turicreate as tc
data = pd.read_csv("hoge.csv") 
sfd = tc.SFrame(data)
model = tc.factorization_recommender.create(sfd, "user_id","item_id",target = "rating")


(1) あるユーザにレコメンドしたい時と, (2) あるアイテムと類似するアイテムをレコメンドする時は以下のように書きます

rec_for_user = model.recommend([<任意のuser_id>]) # (1)

sim_item = model.get_similarity_item([<任意のitem_id>]) #  (2)


レコメンドに用いるデータセット



Kaggleに置いてある約700万件のユーザレビューのデータを用います.

www.kaggle.com


以下は各ファイルのカラム情報です.

rating..csv

user_id, anime_id, rating (-1 , 0 ~ 10)

f:id:kenzo1122:20210525215240p:plain:w200:h250


anime.csv

anime_id, name, genre , type, episodes, rating, members 

f:id:kenzo1122:20210525215246p:plain


今回学習に用いるデータは, rating.csv と, anime.csv[ anime_id, name] とします.


データ前処理



最初にこの章のコードを置いておきます

github.com

読み込み

import pandas as pd
import numpy as np
from tqdm._tqdm_notebook import tqdm_notebook
from collections import Counter
tqdm_notebook.pandas(desc=" progress: ")
rait = pd.read_csv("rating.csv")
anime = pd.read_csv("anime.csv")


評価に参加した数が少ないuser と 評価された数が少ないアニメを削除

# 各userが出現する回数, 各animeが出現する回数
user_ids_count = Counter(rait.user_id)
anime_ids_count = Counter(rait.anime_id)

# 20作品以上を評価したuserを残す
n = sum(np.array(list(user_ids_count.values()) ) > 80) # 30000
user_ids = [u for u,c in user_ids_count.most_common(n)]
# 50回以上を評価されたanimeを残す
m = sum(np.array(list(anime_ids_count.values()) ) > 100) # 4625
anime_ids = [u for u,c in anime_ids_count.most_common(m)]

rait_sm = rait[rait.user_id.isin(user_ids) & rait.anime_id.isin(anime_ids)]


anime_id で rating.csv と anime.csv をマージして, indexふりなおし

# マージ
merge_rait = rait_sm.merge(anime, left_on="anime_id". right_on ="anime_id", how = "left")

# index ふりなおしの辞書作成
map_user_id = {u_id:i for i, u_id in enumerate(user_ids)}
map_anime_id = {a_id:i for i, a_id in enumerate(anime_ids)}

# 各々indexふりなおし
merge_rait.loc[:, 'user_id'] = merge_rait.progress_apply(lambda x: map_user_id[x.user_id], axis=1)
merge_rait.loc[:, 'anime_id'] = merge_rait.progress_apply(lambda x: map_anime_id[x.anime_id], axis=1)


rating = -1 の評価値を matrix facrization で予測して埋める

rating が -1 の対処法ですが, その行を削除する, そのuserの評価の平均値, そのアニメの評価平均値で埋めるなどあると思います.
ここでは, rating が -1 以外のデータを用いて turicreate の matrix factorization で学習します. そして, 評価値が-1である行のユーザがあるアニメにどんな点数をつけるか予測させます.

また評価予測値が 0~ 10 の範囲に必ずにも予測されないかつ多少評価値が高く予測されていたため, 以下のfunc(x)によって, 調整しました.

import pandas as pd
import turicreate as tc

def func(x):
    return 10/(1+ np.exp(-0.76*x + 5))

# rating が -1 以外のデータ
merge_rait_ = merge_rait[merge_rait.rating_x != -1] 
sfd = tc.SFrame(merge_rait_[["user_id","anime_id","rating_x"]])

#学習
m = tc.factorization_recommender.create(sfd, "user_id","anime_id",target = "rating_x") # matrix factorization

# -1 のみのデータ
lack_data = merge_rait[merge_rait.rating_x == -1][["user_id","anime_id"]]  rating が -1 のデータ

# ratingが-1であるuser_id, anime_id の組み合わせで rating を予測
pred= lack_data.progress_apply(lambda x :m.predict({"user_id":x.user_id,"anime_id":x.anime_id})[0],axis=1) 

 # 埋める
merge_rait.loc[lack_data.index, "rating_x"] = func(pred.values).astype(int)

# 保存
merge_rait.to_csv("data_comlement.csv",index = False) 


このデータを保存し, 前処理は終わりです.


レコメンダーを作成



最初にこの章のコードを置いておきます

github.com


レコメンドエンジンを作成にあたって, ranking_factorization_recommenderfactorization_recommender で学習させてみて, RMSEやレコメンドされるアニメを比較したいと思います.

データ読み込み

ここで, turicreate は学習データがstring型であっても学習可能で, この後レコメンドする際の入力が少し楽なので anime_id ではなく name をカラムに入れて学習させます.

import pandas as pd

raw= pd.read_csv("data_comlement.csv")
data = raw[["user_id","name","rating_x"]]
sfd = tc.SFrame(data)


データ分割

rating が 7 以上と7未満のデータをわけ, 7以上のデータを学習データ(train)とテストデータ(test)に分割し, 学習データ(train)に 7未満のデータを追加しました.

high_rated_data = sfd[sfd["rating_x"] >= 7]
low_rated_data = sfd[sfd["rating_x"] < 7]
train_data_1, test_data = tc.recommender.util.random_split_by_user(
                                    high_rated_data, user_id='user_id', item_id='name')
train_data = train_data_1.append(low_rated_data)

学習

2つのアルゴリズムで学習します.

rankmf = tc.ranking_factorization_recommender.create(train_data, "user_id", "name", target='rating_x',)
mf = tc.factorization_recommender.create(train_data, "user_id", "name", target='rating_x',)


それぞれの学習データでの RMSE は以下の通り.

- ranking_factorization_recommender  =>  training RMSE: 1.0974
- factorization_recommender => training RMSE: 0.97999

評価

テストデータによる RMSE を見てみます.
複数のモデルを評価したいときは compare_models という関数を使うのが便利です.

tc.recommender.util.compare_models(test_data, [mf,rankmf],model_names = ["mf","rankmf"])

出力 MF

+--------+----------------------+-----------------------+
| cutoff |    mean_precision    |      mean_recall      |
+--------+----------------------+-----------------------+
|   1    | 0.04300000000000002  | 0.0015764884263909638 |
|   2    |        0.0435        | 0.0027709084607027987 |
|   3    | 0.045333333333333344 |  0.004170689820898463 |
|   4    |        0.047         |  0.005753904113269214 |
|   5    | 0.050400000000000014 |  0.007749015247566327 |
|   6    | 0.05216666666666667  |  0.009662804940866148 |
|   7    | 0.05157142857142859  |  0.011009947706248266 |
|   8    |       0.050375       |  0.012236354681983605 |
|   9    | 0.050222222222222224 |  0.013832997081852426 |
|   10   | 0.05010000000000001  |  0.015225935421589516 |
+--------+----------------------+-----------------------+

RMSE: 0.9354762940915811

出力 rankMF

+--------+---------------------+----------------------+
| cutoff |    mean_precision   |     mean_recall      |
+--------+---------------------+----------------------+
|   1    |        0.486        | 0.01853521300202965  |
|   2    |        0.4425       | 0.03324023400512996  |
|   3    |  0.4143333333333334 | 0.04601403899744517  |
|   4    |       0.38875       | 0.056825576739042816 |
|   5    | 0.37179999999999985 | 0.06670896395476202  |
|   6    |  0.3601666666666669 | 0.07716049263294347  |
|   7    |  0.3497142857142858 | 0.08786837246193445  |
|   8    |       0.33925       | 0.09696057462761283  |
|   9    |  0.3337777777777778 | 0.10690062267355312  |
|   10   |        0.3262       | 0.11585739549254023  |
+--------+---------------------+----------------------+
RMSE: 1.319034169311567


テストデータに対する RMSE は MF の方が良いようです.

表として出力された評価は何に対しての評価なのかよくわかりませんが, precision や recall から単語からは, おそらくテストデータに含まれるユーザにもっともらしいアニメをレコメンドできてるか, という評価だと思われます.

表の precision の数値を見る限り, MFよりrankMFは高評価でレコメンドしたアニメが実際に高評価だった数が多いということなので, 無難なアニメがレコメンドされると予想できます.


類似アニメをレコメンドさせてみる

ranking_factorization_recommender と factorization_recommender によるレコメンドされるアニメを見てみます. そのためには, get_similar_items関数を用います. (matrix factorizationされた後のアイテム潜在ベクトル間のコサイン類似度によってレコメンドされるアイテムが選ばれます. )


ガールズパンツァーに類似してるアニメをそれぞれのレコメンダーで検索してみます.

similar_items = mf.get_similar_items(['Girls und Panzer']) 
similar_items_ = rankmf.get_similar_items(['Girls und Panzer']) 

出力

- MF 
+-----------------------------+--------------------+------+
|           similar           |       score        | rank |
+-----------------------------+--------------------+------+
|         Yuru Yuri♪♪         | 0.9631929993629456 |  1   |
|   Yuru Yuri Nachuyachumi!   | 0.9609564542770386 |  2   |
|      Minami-ke Tadaima      | 0.9535280466079712 |  3   |
|          Yuru Yuri          | 0.9530896544456482 |  4   |
|           Love Lab          | 0.9445452094078064 |  5   |
|      Yuru Yuri San☆Hai!     | 0.9436423182487488 |  6   |
|        The iDOLM@STER       | 0.9122692346572876 |  7   |
|    Shinryaku!? Ika Musume   | 0.9116688966751099 |  8   |
|        Non Non Biyori       | 0.9070506691932678 |  9   |
| Gochuumon wa Usagi Desu ka? | 0.9044833779335022 |  10  |
+-----------------------------+--------------------+------+

- rankMF
+-------------------------------+--------------------+------+
|            similar            |       score        | rank |
+-------------------------------+--------------------+------+
|   Girls und Panzer Specials   | 0.8605985045433044 |  1   |
| Girls und Panzer: Kore ga ... | 0.8559162020683289 |  2   |
|      Strike Witches Movie     | 0.8132143020629883 |  3   |
| Girls und Panzer: Shoukai ... | 0.765860378742218  |  4   |
|         Kiniro Mosaic         | 0.7395626306533813 |  5   |
|          Yuru Yuri♪♪          | 0.7339246273040771 |  6   |
|   Girls und Panzer der Film   | 0.720527172088623  |  7   |
| Stella Jogakuin Koutou-ka ... | 0.703089714050293  |  8   |
|           Yuyushiki           | 0.7017302513122559 |  9   |
| Strike Witches: Operation ... | 0.6946920156478882 |  10  |
+-------------------------------+--------------------+------+
[10 rows x 4 columns]


結果は, rankMF によってレコメンドされたアニメは, 大半がガルパンの映画やシリーズで, 一方のMFは, ゆるゆりアイドルマスター,のんのんびよりなど複数人の女の子主役のアニメがレコメンドされているように思えます. (ほとんどみたことがないので調べた)


次は この素晴らしい世界に祝福を! に類似したアニメを検索します

similar_items = mf.get_similar_items(['Kono Subarashii Sekai ni Shukufuku wo!']) 
similar_items_ = rankmf.get_similar_items(['Kono Subarashii Sekai ni Shukufuku wo!']) 

出力

- MF

'Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai',
'D-Frag!', 'Danna ga Nani wo Itteiru ka Wakaranai Ken',
'Yahari Ore no Seishun Love Comedy wa Machigatteiru. Zoku OVA',
'Danna ga Nani wo Itteiru ka Wakaranai Ken 2 Sure-me',
'Yahari Ore no Seishun Love Comedy wa Machigatteiru.',
'Sakamoto desu ga?', 
'Hataraku Maou-sama!',
 'Seitokai Yakuindomo*',
'Amagi Brilliant Park'

-rankMF

'Kono Subarashii Sekai ni Shukufuku wo! OVA',
'Netoge no Yome wa Onnanoko ja Nai to Omotta?',
'Hai to Gensou no Grimgar',
'Ore ga Ojousama Gakkou ni &quot;Shomin Sample&quot; Toshite Gets♥Sareta Ken',
'Gate: Jieitai Kanochi nite, Kaku Tatakaeri 2nd Season',
'Dagashi Kashi', 
'Re:Zero kara Hajimeru Isekai Seikatsu',
'Rakudai Kishi no Cavalry', 
'Jitsu wa Watashi wa',
'Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai',
'Gate: Jieitai Kanochi nite, Kaku Tatakaeri'

MFによってレコメンドされたアニメは見たことがないためなんとも言えませんが, rankMFの方は ,灰と幻想のグリムガルやリゼロ, Gateといくつか異世界転生系のアニメをレコメンドしています.


全体のコード

github.com

デモ

最後に, React と FastAPI を用いてデモを作成しましたので遊んでみてください (ただ Heroku は30分間アクセスがないとスリープしてしまうため, 最初はフロントからリクエストを送信しても何も返ってこない可能性がありますので何回か送信してみてください.)


プレビュー

f:id:kenzo1122:20210525194152p:plain

f:id:kenzo1122:20210525194153p:plain


おまけ



ここで用いたデータセットのアニメタイトルは全て日本語のローマ字表記と英語なので, [2]を参考にスクレイピングでそれらに対応する日本語表記を取得するスクリプトを置いておきます. ( ※ 完璧ではありませんが有名タイトルは大抵日本語表記になります)

github.com


参考

[1] recommender — Turi Create API 6.4.1 documentation

[2] Recommender Systems · GitBook

[3] Turi Createによるアニメのレコメンド|npaka|note

twint でTwitterからツイートを収集する

twint でツイート収集

Twitter API を使ってツイートするのは良いですが色々と制限があるので, twint というPythonのパッケージを用いて収集してみます.

インストール

おそらく, 以下でインストールすると実行してもツイートを収集できないので,

pip install twint 


こちらでインストールします.

pip install  git+https://github.com/himanshudabas/twint.git@origin/master#egg=twint


いざツイート収集

ここではあるワードを含むツイートを収集してみます twint の実行は CLI でもできますが便利上今回はPythonで書きます.

(設定する Config の種類はこちらで確認できます. )

import twint
def Twitter_Scraper(search_word,since_date,until_date,output_filename):
    c = twint.Config
    c.Search = search_word
    c.Store_json = True # Store_csvもあるがカラム名が入らなかったため不採用
    c.Output = output_filename
    c.Since = since_date
    c.Until = until_date
    c.Hide_output = True # False にするとツイートがターミナルに出力
    twint.run.Search(c)


Twitter_Scraper("おかえりモネ", "2021-05-16 00:00:00", "2021-05-17 13:00:00","twint_scrape.json")


実行すると以下のKeyについてのデータが逐次的にJSONフォーマットで書き込まれていきます.

['id', 'conversation_id', 'created_at', 'date', 'time', 'timezone',
       'user_id', 'username', 'name', 'place', 'tweet', 'language', 'mentions',
       'urls', 'photos', 'replies_count', 'retweets_count', 'likes_count',
       'hashtags', 'cashtags', 'link', 'retweet', 'quote_url', 'video',
       'thumbnail', 'near', 'geo', 'source', 'user_rt_id', 'user_rt',
       'retweet_id', 'reply_to', 'retweet_date', 'translate', 'trans_src',
       'trans_dest']

この後にPandasで分析したい場合は, 以下のように読み込むことでデータが整列します。

import pandas as pd
data = pd.read_json(output_file, lines=True)


参考

github.com