FGO英霊クラス判定器

久しぶりの更新になりました. 2016年は定期的に更新しようと意気込んでたにも関わらず,完全放置になってしまっていました. 今年の目標は「技術に対して積極性を持つこと」「精神的な余裕を持つこと」です.去年は修論のために専門の技術を追いかけるのに精一杯だったので,今年はより多方面に関心を持って過ごしたいと思います.

ということで2017年ブログ第1弾は「FGO英霊クラス判定」にしました. 概略を言うとFate Grand/Order(FGO)というスマホゲームのキャラがどのクラスに属するかを機械学習で識別しよう,という試みです.

実装時の言語はpythonで,深層学習ライブラリのKerasと,機械学習・確率統計で誰もがお世話になるであろうライブラリScikit-Learnを使用します.ツール・ライブラリのインストールについてはこの記事では触れません.

注:このブログではFGOのキャラクター名を例に出して説明するため,Fate作品の(多少の)ネタバレに繋がる恐れがあります.これからFate作品を楽しもうと思っている方は,お気をつけください.

動機

画像系の深層学習に触れてみようと思ったのがきっかけでした.研究では音声・言語を扱っていたんですが,画像方面には全く縁がなかったので.

画像分野の深層学習研究では,近年CNN(Convolutional Neural Network)によるオブジェクト認識のタスクにおいて著しい発展が見られています.試しに調べてみるとアニメ画像におけるキャラクター認識というのは結構やられていて,ラブライブごちうさのキャラクター認識を行ったという記事が見つかります.

この記事では,キャラクター認識ではなくキャラクター属性認識をしようと思います.スマホゲームのFate Grand/Orderにスマホゲームに登場する英霊(キャラクター)の属するクラスの認識です.単純なキャラクター認識よりも難易度が高そうであるのと,Twitterのアイコンなどを使って自分がどのクラスに属するかを推定する,といったアプリケーションへの応用が見込めることが大きな理由です.

システムの概略図としては次のような形になっています.キャラクターの画像を学習済みのVGGnet(CNNの一つ)に入力することでその中間層から特徴量を抽出し,それを用いてRandom Forestなどの識別モデルの学習を行います. ここは機械学習に馴染みのない人にとっては聞きなれない単語だらけだと思いますが,後ほど説明します.

f:id:yoghurt1131:20170131102233p:plain:w450

タスク設計

最初にFGOについてです.Fate作品,ゲームの内容については特にこの記事と関係が無いので触れません. 大事なのは登場するキャラクターが歴史,神話上に登場する過去の英雄(英霊)で,基本的に7つのクラスのどれかに割り当てられていることです.

7つのクラスは次のとおりです.

  • セイバー(剣)
  • アーチャー(弓)
  • ランサー(槍)
  • ライダー(騎)
  • キャスター(術)
  • アサシン(殺)
  • バーサーカー(狂)

これに加えて,エクストラクラスというのが存在します.なので,正確には全部で8クラス存在します.

例えば,次のイラストはアーサー王であり,セイバーのクラスに属しています.(「性別・・・」とかそういうツッコミは無しです.)

f:id:yoghurt1131:20170129230557p:plain:w150

それぞれの英霊は自身に由来のあるクラスに属することが多いです. またイラストでも,剣を持っていたり弓を持っていたりと,それなりの特徴を備えています.

一方で例外もあります.

f:id:yoghurt1131:20170129230535p:plain:w150

このキャラクターは弓を持っていないですがアーチャーのクラスに属します. (詳細はFate stay/nightを見てください)

今回のゴールは、英霊の画像を入力してその英霊のクラスを推定するシステムを作ることです. 各キャラクターとそれが属するクラスの関係は曖昧ですが,その曖昧さを機械学習の仕組みを利用してモデル化することを目的とします.

データ収集・前処理

最初に画像の収集です.公式のゲームイラストを実際のゲームや攻略サイトなどから集めました.FGOではキャラクターのイラストが立ち絵固定のため,アニメのように1コマ1コマを学習データにするということができません.各キャラクターごとに進化後のイラストが3枚存在するためそれも収集し,以下の500枚程度の画像が集まりました.

クラス 枚数
セイバー 76
アーチャー 69
ランサー 85
ライダー 64
キャスター 96
アサシン 72
バーサーカー 69
エクストラ 28

かなり少ないですが他に集める手段となると創作二次絵などになってしまうため,とりあえずこのデータのみを使います. 本当でしたら数千〜数万枚のイラストが必要なところです.

使用するニューラルネットは入力の画像サイズが固定で,224x224になっている必要があります.そのため集めたデータをニューラルネットに入力できる形に整形する前処理を行います.次の三つの処理を行いました.

1.枠のトリミング 持ってきた画像には,外側の枠に英霊のクラスや,ゲーム内でのランク(星の数),強さなどキャラクターの画像以外の情報が含まれています. そのため,上下の枠をトリミングしキャラクターのみが映るようにします.

2.キャラクターのトリミング 使用するニューラルネットは入力画像が正方形でないといけないため,1でトリミングした画像を、横幅に合わせてトリミングします.この際,画像の下側は多少見切れてしまいます.

3.リサイズ 224x224に画像をリサイズします.

f:id:yoghurt1131:20170131110702p:plain:w400

左が元画像,右がトリミング・リサイズを行った画像です.

特徴量抽出

Convolutional Neural Network(CNN)という,画像系で近年素晴らしい性能を叩き出しているニューラルネットワークを用います. CNNの説明はこの記事が分かりやすかったので気になる人は参照してください. 今回はCNNを学習モデルとしてではなく,特徴量抽出器として用います.オブジェクト認識で用いられているCNNは,その中間層の出力が物体を識別するのに有効な特徴を持つことが知られています. そこで,学習済みのCNNに画像を入力した際の中間層の値を抽出し,それを画像の特徴量とします. 特徴量とはその名の通りデータの特徴を表すベクトルになります.

学習済みのCNNに,VGGnetというニューラルネットワークを用います.ILSVRC2014というオブジェクト識別,分類のコンテストにて非常に良い性能を叩き出したニューラルネットワークです.ILSVRCは,ある画像が入力された際にそこに映っている物体(オブジェクト)が用意された1000カテゴリ(クラス)のどれであるかを当てるタスクです.

VGGnet

VGGnetについて簡単に説明します.最初のシステム概略図でちらっと登場しましたが,VGGnetは以下のような構造をとっています.VGGnetに限らずCNNは畳み込み層(Convolution Layer)とプーリング層(Pooling Layer)の繰り返しによって構成されます.Conv1, Pool7などと書かれた枠がそれに相当します.ざっくり説明すると,畳み込み層,プーリング層によって画像の様々な構造(例:「斜めの線を持つか?」)を捉えることができます. その後,全結合層(Fully-Connected Layer)を複数配置します.FC14~16がそれに相当します.FC16で,入力画像は1000カテゴリのどれか,という最終的な結果が出力されます. プーリング層は学習によってパラメータが変化しないため,層としてカウントされないようです.そのため畳み込み層(Conv)と全結合層(FC)の数をニューラルネットワークの層の数としています.この例は,16層のVGGnetを表しています.

f:id:yoghurt1131:20170131103516p:plain:w800

VGGnetを用いた特徴量抽出

Keras版VGGnetの学習済みモデルはこちらから入手しました.ついでに重みも取ってきます. これを用いて,画像データから特徴量を抽出します.以下その際のソースコードです.

# モデルの読み込み
model = vgg16_keras.VGG_16('vgg16_weights.h5')
# コンパイル
sgd = SGD(lr=0.1, decay=1e-6, momentum=0.9, nesterov=True)
model.compile(optimizer=sgd, loss='categorical_crossentropy')
# 最終層の手前のフルコネクション層.4096ユニット.
get_fc15_layer_output = K.function([model.layers[0].input, K.learning_phase()], [model.layers[-5].output])
# 画像の読み込み
img = cv2.resize(cv2.imread(image_file, cv2.IMREAD_COLOR), (224, 224)).astype(np.float32)
# 平均値を引く
img[:,:,0] -= 103.939
img[:,:,1] -= 116.779
img[:,:,2] -= 123.68

img = img.transpose((2,0,1))
img = np.expand_dims(img, axis=0)
# 中間層の値を出力
fc15_layer_output = get_fc15_layer_output([img, 0])[0]

Kerasで中間層の値を取り出す関数は上記のようにK.functionを使って設計することができます. 今回はFC15という最終層の2つ手前,4096ユニットの全結合層から特徴量を抽出しました.

識別モデルの作成

上で抽出した特徴量を用いて,識別モデルを学習します.ある英霊画像の特徴量Xが入力された際に,その英霊の属するクラスYを出力するモデルとなります.学習・識別にはRandomForestを用いました.RandomForestは弱識別器(決定木と呼ばれる)を複数作成し,その結果を統合して値を出力する手法です.動作が非常に軽くて使いやすいのが魅力ですかね.

今回ですが,いきなり8クラスは難しそう(というか難しかった)ので,セイバー,ランサー,アーチャーの3クラスで識別を行いました. この3クラスにした理由は,どのクラスの英霊のイラストも,そのクラスを象徴する武器が描かれているためです. セイバークラスの英霊はだいたい剣を持っている,ランサークラスの英霊はだいたい槍を持っている,アーチャークラスの英霊はだいたい弓を持って・・・いないかもしれませんが持っている人もいます.まあそういった特徴的な画像を用いないと識別が困難だろうという予想のもと選びました.他のクラスは,ライダーなのに剣を持っているとか,ややこしいのが多かったのでそもそも難しそうなんですよね.

学習データは上記の画像計230枚でした.このデータセットを20分割し,19個をモデルの学習用,1個を性能を確認するためのテスト用として,20分割交差検定を行いました.最終的な精度は20回テストした平均の精度を採用します.

コードは以下のとおりです.

import numpy as np
import os
from sklearn.cross_validation import KFold
import sys
from data_loader import DataLoader
from rf_trainer import RandomForestTrainer

if __name__ == '__main__':
    # 対象クラス
    train_servant = ['saber', 'archer', 'lancer']

    data_loader = DataLoader('config.ini')
    trainer = RandomForestTrainer()

    X = [] # データ
    Y = [] # ラベル

    # 学習データ読み込み
    for name in train_servant:
        x, y = data_loader.get_servant_feature(name)
        X += x
        Y += y
    
    # K分割交差検定
    n_folds = 20
    kf = KFold(len(X), n_folds=n_folds)
    total_score = 0
    for train_index, test_index in kf:
        model = trainer.train(np.array(X)[train_index], np.array(Y)[train_index])
        score = trainer.predict(model, np.array(X)[test_index], np.array(Y)[test_index])
        total_score += score

    print("Score: %s" % round(total_score / n_folds, 3))

DataLoader,RandomForestTrainerは自作のクラスで以下のようになっております.

# -*- coding: utf-8 -*-
import glob
import numpy as np
import os
import ConfigParser


class DataLoader:
    def __init__(self, config_file):
        self.servants = {
                'saber': 0 , 'archer': 1, 'lancer': 2,
                'rider': 3, 'caster': 4, 'assassin': 5,
                'berserker': 6, 'extra': 7
                }
        cfg = ConfigParser.SafeConfigParser()
        cfg.read(config_file)
        self.data_root_dir = cfg.get('Data', 'root_dir')
        self.feature_type = cfg.get('Data', 'feature_type')
        self.data_ext = cfg.get('Data', 'data_ext')

    def get_servant_feature(self, servant_class_name):
        """サーヴァントのクラスを指定してデータを取ってくる
        params
         - servant_class_name: 7騎のうちのどれか
        """
        if not servant_class_name in self.servants.keys():
            print("Error! There is no servant");
            return None, None
        X = []
        Y = []
        servant_data_dir = os.path.join(self.data_root_dir, servant_class_name)
        file_pattern = '%s/*_%s.%s' % (servant_data_dir, self.feature_type, self.data_ext)
        data_files = glob.glob(file_pattern)
        for data_file in data_files:
            data = np.loadtxt(data_file)
            X.append(data)
            Y.append(self.servants[servant_class_name])

        return X, Y
# -*- coding: utf-8 -*-
from sklearn.ensemble import RandomForestClassifier
from sklearn.externals import joblib

class RandomForestTrainer:
    def __init__(self):
        self.model = RandomForestClassifier()

    def train(self, train_data, train_label, output_dir_path=None):
        self.model.fit(train_data, train_label)
        if not output_dir_path is None:
            joblib.dump(model, output_dir_path)
        return self.model

    def predict(self,model, test_data, test_label):
        score = self.model.score(test_data, test_label)
        return score

検証

上記のスクリプトを実行して得られた結果,正解率は残念ながら33.1%程度でした.低い.ほぼチャンスレートですね.

一応,試しに使ってみます.3クラスに属する英霊の画像は学習で使ってしまったので,他のクラスの英霊がもし3クラスのどれかに割り当てられるとしたら・・・という想定でテストを行います.

保存したモデルを読み込み,画像から得られた特徴量をモデルに入力して出力のクラスを得ます.出力されるのは0〜2の数字で,各クラスに対応しています.

import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.externals import joblib

model = joblib.load('model_path') # modelの保存場所
data = np.loadtxt('feature_vector_path') # 特徴量の保存場所
model.predict(data) # 識別
# 0→セイバー,1→アーチャー,2→ランサー

例1

まずは主人公のパートナーであるマシュ・キリエライト.このキャラクターはエクストラクラスに属します.

f:id:yoghurt1131:20170131150118p:plain:w150

data = np.loadtxt('data/feature/extra/0000_fc15.dat') # パスはデータの場所
model.predict(data) # => array([1])

アーチャー判定が出ました.ふむ.剣っぽいものを持っているのでどっちかっていうとセイバーらしさがあると思うんですけどね.

例2

続いて2例目.これは直感に合致した例です. バーサーカーの同じキャラなんですが,進化状態によって持っている武器が違います. 左は弓を持ち,右はそれに加えて剣を持っています.

f:id:yoghurt1131:20170131151319p:plain:w150f:id:yoghurt1131:20170131151321p:plain:w150

data = np.loadtxt('data/feature/berserker/0062_fc15.dat') # 左の絵
model.predict(data) # => array([1])

data = np.loadtxt('data/feature/berserker/0063_fc15.dat') # 右の絵
model.predict(data) # => array([0])

左の絵はアーチャー,右の絵はセイバーに分類されました.おぉ,それっぽい.

例3

続いて3例目,このキャラクターはアサシンのクラスなんですが,剣を持っています. セイバーに分類されてほしいところ・・・.

f:id:yoghurt1131:20170131150657p:plain:w150

data = np.loadtxt('data/feature/assassin/0041_fc15.dat')
model.predict(data) # =>array([1])

アーチャーでした.難しい・・・・・.

考察

上手く行かなかった理由について少し検討を行いました. 学習データが少ないのは自明なのですが,そもそも学習に用いている特徴量が識別に有効なものになっているかを調べます.

学習に用いた4096次元の特徴量はそのままでは簡単な比較ができないため,主成分分析(PCA:Principal Component Analysis)を用いて2次元に圧縮し,可視化してみました.PCAはデータ分析でよく使われる手法で,データ群をまとめてその主成分,そのデータをよく表現する成分を取り出します.

学習に用いた特徴量が識別的なものであれば,PCAを行った結果クラスごとにある程度分布が分かれているような図が得られるはずです.

ソースコードと可視化の結果です.

# -*- coding: utf-8 -*-
"""PCAでFC15特徴量を2次元に圧縮,プロットする"""

import glob
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA

from data_loader import DataLoader

if __name__ == '__main__':
    servants = [
            'saber', 'archer', 'lancer',
            'rider', 'caster', 'assassin',
            'berserker', 'extra'
            ]
    X = []
    Y = []
    data_loader = DataLoader('config.ini')
    for name in servants:
        x, y = data_loader.get_servant_feature(name)
        X += x
        Y += y

    N = 2 # 圧縮する次元数
    pca = PCA(n_components=N)
    pca.fit(X)

    saber = np.array([x for i, x in enumerate(X_pca) if Y[i] == 0])
    archer = np.array([x for i, x in enumerate(X_pca) if Y[i] == 1])
    lancer = np.array([x for i, x in enumerate(X_pca) if Y[i] == 2])

    fig, ax = plt.subplots()
    ax.plot(saber[:,0],  saber[:,1],  'b.', label='saber')
    ax.plot(archer[:,0],  archer[:,1],  'r.', label='archer')
    ax.plot(lancer[:,0],  lancer[:,1],  'g.', label='lancer')

    ax.set_title("PCA for fgo")
    ax.legend(numpoints=1)

    # 表示
    plt.show()

f:id:yoghurt1131:20170131154923p:plain:w1000

プロットした点が各データ,色がクラスを表しています.x軸とy軸が2次元に圧縮した際の各データの値です.

わー,見事にバラバラ.もちろん次元圧縮しているのでこの図が特徴量の全てを語っているわけではないのですが,ここまで混ざっていると識別は困難ですね.

まとめ

ということで,この記事ではFGOのキャラクターのクラス識別を取り上げました.手始めに3クラスで試しましたが,精度は全然ダメです.識別精度は上げるには,データ量を増やすこと,特徴量についてより良いものを検討すること,の2種類が課題として挙げられるでしょうか.特に後者については,実物のオブジェクト認識タスクで学習したCNNではなく,アニメ画像等で学習したCNNを用いるなどの工夫が必要かもしれません.いずれ8クラスへの拡張,アプリケーションの開発なども取り掛かりたいと思います.