Material Book of Statistics

統計、機械学習、プログラミングなどで実験的な試みを書いていきます。

強化学習 n本腕バンディットにおけるε-グリーディ行動評価手法の性能比較を再現する

はじめに

会社の勉強会で強化学習を応用したマーケティング手法の発表を聞いて「強化学習が面白そうだ」と思い、私も勉強することにしました。
勉強している本は、Sutton and Barto著、三上・皆川訳の強化学習(原題: Reinforcement Learning: An Introduction)です。

www.amazon.co.jp

2章を読み終えましたが、この章で主に扱われているn本腕バンディット(多腕バンディット)問題の理解が怪しいと感じたので、途中に出てくるシミュレーションを再現してみて、理解度をチェックしました。

なお、この記事のほとんどは上記の本を元にして書いていることを、予めお断りしておきます。

 n本腕バンディット問題の定義

 n本腕バンディット問題 ( n-armed bandit problem) とは、

 n種類の異なる行動選択肢があり、その中から1つを選択すると報酬がもらえる場合に、ある一定期間(または回数)の間に合計報酬の期待値を最大化する

問題のことです。
日本語では「多腕バンディット問題 (multi-armed bandit problem)」とも呼ばれていますが、ここでは本に習うことにします。
報酬は決まっているわけではなく、1回毎に選択した行動に依存する確率分布にしたがって決まります。
また、それぞれの行動選択をプレイ (play) と呼びます。

 n本腕バンディット問題の例

この問題を具体的にイメージするにはスロットマシンを考えるのが良いです。*1
ただし、普通にイメージするスロットマシンとは違い、ここでは下図のように n本のレバーを持っている機械について考えます。

f:id:ishiyama-katsuya:20190504131638p:plain
N本腕バンディット問題の具体例

図のように、1つのレバーを引くことが1回の行動選択に該当し、報酬は当たりで得られる利益に相当します。 同じレバーを引いても報酬が毎回異なることに注意してください。
このような状況下で、プレイヤーはプレイを繰り返しながら最良のレバーを探し、賞金を最大にすることを目指します。

行動価値手法

行動価値手法を説明するに当たって使う数式の準備をします。

行動 aの真の価値を Q^{*}(a)として、 t番目のプレイにおける Q^{*}(a)の推定量を[tex: Q{t}(a)]とします。
また、 t回目のプレイにおいて、それまでの間に行動 aが[tex: k
{a}]回選択されていたとして、それぞれの回での報酬を[tex: r{1}, r{2}, \dots, r{k{a}}]とします。

貪欲な手と探索的な手

強化学習教師あり学習とは違って、教師データは与えられません。 そのため、獲得する報酬を最大にするためには、過去に得た各プレイに対する報酬から各行動の価値を推定して、価値が最も高い行動を選択していきます。
このように、最も価値が高い行動を選択していくことを貪欲な(グリーディ; greedy)手を打つといいます。
これを数式で表すと、

[tex: \displaystyle Q{t}(a^{*}) = \max{a} Q_{t}(a)]

となります。

これに対して、ランダムに行動を選ぶこともあります。
これは、そうしなければ遭遇しなかったような状態を生じさせるということから探索的な(explore)手と呼ばれます。

行動価値推定方法: 標本平均手法(sample-averaging method)

行動価値を評価する方法は様々ですが、今回は単純な標本平均手法を使います。
行動 aの価値 Q_{t}(a)

[tex: \displaystyle Q{t}(a) = \frac{r{1} + r{2} + \dots + r{k{a}}}{k{a}}]

と推定します。
ただし、[tex: k{a} = 0]の場合は、任意の行動 aに対して[tex: Q{0}(a) = 0]とします。

行動選択方法:  \varepsilon-グリーディ手法

グリーディな手を選択し続ければ報酬が最大化できるかと言えば、そうではありません。
報酬はランダムに決定されるので、例えば10回プレイした結果で価値が最大になる行動を推測したとしても、それが間違っていることがあり得るからです。
そのため、ある一定の確率 \varepsilonで探索的な手を打つようにして、価値の推定値を改良することを狙います。これを \epsilon-グリーディ手法といいます。

 \varepsilon-グリーディ手法の性能評価シミュレーションを再現する

ここからはSutton and Barto著、三上・皆川訳(2000) 、強化学習のp.31の図2.1で行われた \varepsilon-グリーディ手法の性能評価を再現していきます。
目標にするグラフを転載します。

f:id:ishiyama-katsuya:20190503195642j:plain
Sutton and Barto著、三上・皆川訳、強化学習 p.31の図2.1

シミュレーションの設定

以下にシミュレーションの設定を示します。
使うアルゴリズムは10本腕バンディット(「 n本腕バンディット問題の例」を参照)です。
ここで、 N(\mu, \sigma^{2})は平均 \muと分散 \sigma^{2}正規分布を表します。
 \varepsilon-グリーディ手法の \varepsilonは0, 0.01, 0.1の3つを試しています。
なお、 \varepsilon = 0はグリーディ手法のみになることに注意してください。

  1. 各行動 aの真の価値 Q^{*}(a)は標準正規分布 N(0, 1)で選ぶ。
  2. 報酬は各プレイ毎に選択した行動 aによって N(Q^{*}(a), 1)で決定する。
  3.  \varepsilon-グリーディ手法で行動選択する。ただし、1回目のプレイの場合は探索的に行動を選択する。
  4. 選択した行動に応じて、2で決めた報酬を受け取る。
  5. 今まで受け取った報酬を元に、標本平均手法で行動価値推定を行う。
  6. 2〜5までの設定を1プレイとして、1000回プレイする。
  7. 1〜6までを1セットとして、2000回繰り返す。
  8. プレイ回数毎に2000回の平均値を算出する。

シミュレーションの環境

項目
OS Ubuntu 18.04.2 LTS
CPU Intel(R) Core(TM) i7-8086K CPU @ 4.00GHz
メモリ 64GB
python Python 3.6.8 :: Anaconda, Inc.

シミュレーションを実装する

ここからは実際にシミュレーションをpythonで実装していきます。
作成するスクリプトは次の3つです。

  1. bandit.py
  2. evaluation.py
  3. n_armed_bandit_simulation.ipynb

1.のbandit.pyはシミュレーション本体で、この中にシミュレーションを書いていきます。
2.のevaluation.pyはbandit.pyで得た結果データをグラフで使えるように集計するモジュールを提供します。
3.のn_armed_bandit_simulation.ipynbでは、2をモジュールとして読み込み、実際にグラフを描画します。
コードはhttps://github.com/Katsuya-Ishiyama/rl_suttonにあります。

bandit.py

シミュレーションの実装はエージェント(実際にプレイする人)と環境(スロットマシン)に分けて行います。
これはSutton and Barto著、三上・皆川訳、強化学習 p.56の図3.1を参考にしたためです。

f:id:ishiyama-katsuya:20190505003137j:plain
Sutton and Barto著、三上・皆川訳、強化学習 p.56より転載

NArmedBanditEnvironmentクラス

このクラスの役割は

  1. 行動の真の価値を決める
  2. 1つの行動が実行されたら対応する報酬を返す
  3. 真の価値が最も高い行動を教える

です。
2番目の役割は、上図のエージェントから行動を受け取りと対応する報酬を返す部分を担っています。
図には環境の状態をエージェントに返す部分が存在しますが、今回の設定では環境の状態は変わらないため、実装していません。
また、3番目の役割は本来ならば必要ありませんが、シミュレーションで性能を評価する上で正解は必要なので実装しています。

BanditLoggerクラス

このクラスの役割は

  1. プレイ毎にプレイ回数、行動、報酬、探索的な手を打つ確率、最良な行動を保持する
  2. 保持しているデータをCSVに吐き出す
  3. 保持しているデータをプレイ回数または行動毎にまとめてエージェントに渡す

です。

NArmedBanditAgentクラス

このクラスの役割は

  1. 行動を決定する
  2. 与えられた環境でプレイする
  3. 環境から報酬を受け取る
  4. 受け取った報酬をBanditLoggerに登録する
  5. 受け取った報酬を元に行動価値を推定する
  6. BanditLoggerに記録されたデータをCSVに吐き出す(BanditLoggerをラップしたもの)

です。
インスタンス化するときにNArmedBanditEnvironmentインスタンスを受け取るようにしていますが、これは久保(2019)を参考にしました。

simulate_n_armed_bandit関数

シミュレーションの設定にある手順1〜8を1つにまとめた関数です。
設定を下表の引数で指定することができます。

引数名 意味
arm シミュテーションする行動の数
exploratory_rate 探索的な手を打つ確率
play シミュレーションするプレイ回数
iterations シミュレーションの設定にある手順7の回数

main関数

実際にシミュレーションを実行します。

実際のコード

# -*- coding: utf-8 -*-

import csv
from datetime import datetime
import logging
import os
from typing import List, Dict
from tqdm import tqdm
import numpy as np

logger = logging.getLogger()


class NArmedBanditEnvironment(object):

    def __init__(self, arm: int):
        self.arm = arm
        self.arm_list = list(range(1, arm+1))
        self.true_action_values = None
        self.most_suitable_action_index = None

    def initialize(self):
        _this_class_name = __class__.__name__
        logger.info("Initializing {}...".format(_this_class_name))
        self._create_true_action_values()
        self._calculate_most_suitable_action_index()
        logger.info("{} has been initialized.".format(_this_class_name))

    def _calculate_most_suitable_action_index(self):
        _action_index_list = list(self.true_action_values.keys())
        _action_value_list = list(self.true_action_values.values())
        _max_action_value_list_index = np.argmax(_action_value_list)
        self.most_suitable_action_index = _action_index_list[_max_action_value_list_index]
        logger.info('Most suitable action: {}'.format(self.most_suitable_action_index))

    def _create_true_action_values(self):
        _true_action_values_list = np.random.normal(0, 1, self.arm).tolist()
        self.true_action_values = {i: v for i, v in zip(self.arm_list, _true_action_values_list)}
        logger.info("True action values: {}".format(self.true_action_values))

    def create_action_values(self) -> List[float]:
        if self.true_action_values is None:
            raise Exception("Create true action values before you run this method.")
        _true_action_values = list(self.true_action_values.values())
        _action_values_list = np.random.normal(_true_action_values, 1).tolist()
        _action_values = {i: v for i, v in zip(self.arm_list, _action_values_list)}
        logger.debug("Created action values: {}".format(_action_values))
        return _action_values

    def run(self, arm: int):
        _selected_arm = arm
        _action_values = self.create_action_values()
        reward: float = _action_values[arm]
        logger.debug("Selected arm index: {}, Reward: {}".format(_selected_arm, reward))
        return reward


class BanditLogger(object):

    def __init__(self):
        self._logs = []
        self.most_suitable_action = None

    def register(self, play_count, arm, reward, exploratory_rate):

        if self.most_suitable_action is None:
            logger.error('most_suitable_action is not set.')
            raise Exception('most_suitable_action is not set.')

        _log = {
            "play_count": play_count,
            "action": arm,
            "reward": reward,
            "exploratory_rate": exploratory_rate,
            "most_suitable_action": self.most_suitable_action
        }
        self._logs.append(_log)
        log_msg = 'Registered log. (Play count: {}, Action: {}, Reward: {}, Exploratory rate: {})'
        logger.debug(log_msg.format(play_count, arm, reward, exploratory_rate))

    def write_logs_to_csv(self, path):
        with open(path, 'w') as f:
            fieldnames = ['play_count', 'action', 'reward', 'most_suitable_action', 'exploratory_rate']
            csv_writer = csv.DictWriter(f, fieldnames=fieldnames)
            csv_writer.writeheader()
            csv_writer.writerows(self._logs)

    def get_rewards_by_action(self) -> Dict[int, List[float]]:
        _rewards_by_action: Dict[int, List[float]] = {}
        for _log in self._logs:
            _action: int = _log["action"]
            _reward: float = _log["reward"]
            _rewards_by_action.setdefault(_action, [])
            _rewards_by_action[_action].append(_reward)
        return _rewards_by_action

    def get_rewards_by_play_count(self) -> List[float]:
        _rewards_by_play_count = []
        for _log in self._logs:
            _rewards_by_play_count.append(_log["reward"])
        return _rewards_by_play_count


class NArmedBanditAgent(object):

    def __init__(self, environment):
        self.environment = environment
        self.arm_list = environment.arm_list
        self.play_count = 0
        self.play_log = BanditLogger()
        self.play_log.most_suitable_action = environment.most_suitable_action_index
        self.exploratory_rate = None

    def select_policy(self, eps=0.1):
        if (eps < 0) or (1 < eps):
            logger.error('eps cannot be handled.')
            raise ValueError('eps cannot be handled.')
        if eps == 0:
            logger.warning('The greedy policy will be selected at this time.')
        if eps == 1:
            logger.warning('The exploratory policy will be selected at this time.')
        is_exploratory = (np.random.uniform(0, 1) < eps) or (self.play_count == 1)
        if is_exploratory:
            _selected_action_index = self.select_policy_exploratory()
        else:
            _selected_action_index = self.select_policy_greedy()
        logger.info('Selected action: {}'.format(_selected_action_index))
        return _selected_action_index

    def select_policy_greedy(self) -> int:
        _estimated_action_values = self.estimate_action_values()
        _estimated_action_values_list = list(_estimated_action_values.values())
        _estimated_action_values_list_index = np.argmax(_estimated_action_values_list)
        _selected_action_value_index = self.arm_list[_estimated_action_values_list_index]
        logger.info('A greedy policy has been selected.')
        return _selected_action_value_index

    def select_policy_exploratory(self) -> int:
        _selected_action_value_index = np.random.choice(self.arm_list)
        logger.info('A exploratory policy has been selected.')
        return _selected_action_value_index

    def receive_reward(self, arm: int, reward: float):
        self.play_log.register(self.play_count, arm, reward, self.exploratory_rate)

    def estimate_action_values(self) -> Dict[int, float]:
        rewards = self.play_log.get_rewards_by_action()
        _estimated_action_values = {i: 0 for i in self.arm_list}
        for _arm, rewards_list in rewards.items():
            _estimated_action_values[_arm] = np.mean(rewards_list)
        logger.info('Estimated action values: {}'.format(_estimated_action_values))
        return _estimated_action_values

    def play(self, exploratory_rate=0.1):
        self.play_count += 1
        logger.info('Play counts: {}'.format(self.play_count))
        self.exploratory_rate = exploratory_rate
        logger.info('Exploratory rate: {}'.format(self.exploratory_rate))
        selected_action = self.select_policy(self.exploratory_rate)
        reward = self.environment.run(selected_action)
        logger.info('Reward: {}'.format(reward))
        self.receive_reward(selected_action, reward)

    def write_logs_to_csv(self, path):
        logger.info('Exporting logs... Path: {}'.format(path))
        self.play_log.write_logs_to_csv(path)
        logger.info('Exporting logs has done. Path: {}'.format(path))


def simulate_n_armed_bandit(arm, exploratory_rate, play, iterations):

    bandit_env = NArmedBanditEnvironment(arm=arm)

    for simulation_num in tqdm(range(1, iterations+1)):
        logger.info('--------------------------------------------------')
        logger.info('Start simulation No.{}'.format(simulation_num))
        logger.info('arm: {}, exploratory_rate: {}, play: {}, iterations: {}'.format(arm, exploratory_rate, play, iterations))
        bandit_env.initialize()
        agent = NArmedBanditAgent(bandit_env)
        for _ in range(play):
            agent.play(exploratory_rate)
        filename = 'n_armed_bandit_arm{}_exploratory{}_simulation{}.csv'.format(arm, exploratory_rate, simulation_num)
        save_path = os.path.join('output', 'exploratory{}'.format(exploratory_rate), filename)
        agent.write_logs_to_csv(path=save_path)


def main():
    ARM_NUM = 10
    MAX_PLAY_COUNT = 1000
    SIMULATION_COUNT = 2000

    simulate_n_armed_bandit(
        arm=ARM_NUM,
        exploratory_rate=0,
        play=MAX_PLAY_COUNT,
        iterations=SIMULATION_COUNT
    )

    simulate_n_armed_bandit(
        arm=ARM_NUM,
        exploratory_rate=0.01,
        play=MAX_PLAY_COUNT,
        iterations=SIMULATION_COUNT
    )

    simulate_n_armed_bandit(
        arm=ARM_NUM,
        exploratory_rate=0.1,
        play=MAX_PLAY_COUNT,
        iterations=SIMULATION_COUNT
    )


if __name__ == '__main__':
    current_time_str = datetime.now().strftime('%Y%m%d%H%M%S')
    file_handler = logging.FileHandler('bandit_simulation_{}.log'.format(current_time_str))
    logging.basicConfig(
        format="[%(asctime)s %(levelname)s] %(message)s",
        level=logging.INFO,
        handlers=[file_handler]
    )

    main()

evaluation.py

ここからは欲しいグラフのデータを計算するモジュールを実装していきます。
実装する関数は以下のとおりです。

calculate_average_by_play_count関数

各プレイ回数毎に平均値を計算します。
今回の場合、1つのプレイ回数につき2000個の報酬または最適な行動だったことを示すフラグを平均するために使います。

extract_exploratory_rate関数

結果が出力されているディレクトリ名から探索的な手を打つ確率(exploratory_rate)を抽出します。

白状しますと、各CSVファイルにexploratory_rateを出していたことを忘れていて、そのことを上の方針で実装している途中に思い出しました。。。
まぁ、「正規表現に苦手意識もあるし、練習にはちょうどいいかな」と、思った次第です(笑)

calculate_average_rewards関数

引数に指定されたディレクトリにある結果データを読み込んで、各プレイ回数毎の平均報酬を計算します。

calculate_average_suitable_action_rate関数

引数に指定されたディレクトリにある結果データを読み込んで、各プレイ回数毎の行動の最適度を計算します。
行動の最適度は、エージェントが選んだ行動が環境が持っている正解データmost_suitable_actionに一致する割合をプレイ回数で累積して計算します。
と、ここまで書いて、再現したい図2.1では、累積ではなく、各プレイ回数毎に2000回中の割合を求めていることに気付きました。。。
これは後日修正します。 (2019/05/05 更新)修正しました。

# -*- coding: utf-8 -*-

import logging
from pathlib import Path
import re
from typing import List
import numpy as np
import pandas as pd


logger = logging.getLogger(__name__)


def calculate_average_by_play_count(data: List[float]):
    data_array = np.array(data)
    _average_by_play_count = data_array.mean(axis=0)
    return _average_by_play_count


def extract_exploratory_rate(dir: Path):
    logger.debug('source text: {}'.format(dir.name))
    exploratory_rate_pattern = re.compile(r'^exploratory(0\.*\d*)$')
    _extract_result = exploratory_rate_pattern.findall(dir.name)
    logger.debug('extract result: {}'.format(_extract_result))
    exploratory_rate = float(_extract_result[0])
    return exploratory_rate


def calculate_average_rewards(output_dir):
    _output_dir = Path(output_dir)
    exploratory_rate = extract_exploratory_rate(_output_dir)
    logger.debug('Exploratory Rate: {}'.format(exploratory_rate))

    play_count_list = []
    rewards_list = []
    for file in _output_dir.iterdir():
        logger.debug('reading {}'.format(file))
        src_data = pd.read_csv(file)
        if not play_count_list:
            play_count_list.extend(src_data.play_count.tolist())
            logger.debug('Extracted Play Counts: {}'.format(play_count_list))
        rewards_list.append(src_data.reward.tolist())

    average_rewards_by_play_counts = pd.DataFrame(
        data={
            'play_count': play_count_list,
            'exploratory_rate': exploratory_rate,
            'average_reward': calculate_average_by_play_count(rewards_list)
        }
    )
    logger.debug('average_rewards_by_play_counts: {}'.format(average_rewards_by_play_counts.head()))

    return average_rewards_by_play_counts


def calculate_average_suitable_action_rate(output_dir):
    _output_dir = Path(output_dir)
    exploratory_rate = extract_exploratory_rate(_output_dir)
    logger.debug('Exploratory Rate: {}'.format(exploratory_rate))

    play_count_list = []
    suitable_action_flag_list = []
    for file in _output_dir.iterdir():
        src_data = pd.read_csv(file)
        if not play_count_list:
            play_count_list.extend(src_data.play_count.tolist())
            logger.debug('Extracted Play Counts: {}'.format(play_count_list))
        is_suitable_action = src_data.action == src_data.most_suitable_action
        suitable_action_flag = 1 * is_suitable_action
        suitable_action_flag_list.append(suitable_action_flag.tolist())

    average_suitable_action_rate_by_play_count = pd.DataFrame(
        data={
            'play_count': play_count_list,
            'exploratory_rate': exploratory_rate,
            'average_suitable_action_rate': calculate_average_by_play_count(suitable_action_flag_list)
        }
    )
    logger.debug('calculate_average_suitable_action_rate: {}'.format(average_suitable_action_rate_by_play_count.head()))

    return average_suitable_action_rate_by_play_count

シミュレーションの結果

ここからはJupyter Notebookを起動して実際にグラフを描いていきます。
下の内容はGitHubにn_armed_bandit_simulation.ipynbでアップロードしてあります。

まずは、必要なモジュールをインポート

from pathlib import Path
from matplotlib import pyplot as plt
import pandas as pd
from evaluation import calculate_average_rewards, calculate_average_suitable_action_rate

次に結果データを読み込みます。

OUTPUT_DIR = 'output/'
average_rewards = pd.DataFrame()
average_suitable_action_rate = pd.DataFrame()
for child_dir in Path(OUTPUT_DIR).iterdir():
    _reward = calculate_average_rewards(child_dir)
    average_rewards = average_rewards.append(_reward)

    _action_rate = calculate_average_suitable_action_rate(child_dir)
    average_suitable_action_rate = average_suitable_action_rate.append(_action_rate)

平均報酬のグラフを描画

exploratory_rate_list = sorted(average_rewards.exploratory_rate.unique().tolist())

for r in exploratory_rate_list:
    dat = average_rewards.loc[average_rewards.exploratory_rate == r, :]
    plt.plot(dat.play_count.tolist(), dat.average_reward.tolist(), label='eps = {:0.2f}'.format(r))
plt.xlabel('Play Count')
plt.ylabel('Average Reward')
plt.legend(loc='lower right')
plt.savefig('bandit_average_reward.png')
plt.show()

実行して得られるグラフは下のとおりです。

f:id:ishiyama-katsuya:20190505023556p:plain
プレイ回数毎の平均報酬

行動の最適度のグラフを描画

これは後日修正します (2019/05/05 更新)修正しました。

average_suitable_action_rate.average_suitable_action_rate *= 100

for r in exploratory_rate_list:
    dat = average_suitable_action_rate.loc[average_suitable_action_rate.exploratory_rate == r, :]
    plt.plot(dat.play_count.tolist(), dat.average_suitable_action_rate.tolist(), label='eps = {:0.2f}'.format(r))
plt.xlabel('Play Count')
plt.ylabel('Suitable Action Rate (%)')
plt.legend(loc='lower right')
plt.savefig('bandit_suitable_action_rate.png')
plt.show()

実行すると次のグラフが得られます。

f:id:ishiyama-katsuya:20190505112812p:plain
プレイ回数毎の行動の最適度

まとめ

Sutton and Barto著、三上・皆川訳(2000) のn本腕バンディット問題における \varepsilon-グリーディ行動評価手法の性能比較シミュレーションを再現しました。
結果は、p.31の図2.1とほぼ同じグラフが得られたため、私の理解に問題が無いことが分かりました。

課題

 \varepsilonを上げてシミュレーションした場合に、どのように平均報酬が変わるのかを試してみようと思います。
(ブログとしてアップしないと思います)

参考文献

脚注

*1:Sutton and Barto著、三上・皆川訳の強化学習 n本腕バンディット問題の由来は「「片腕のバンディット」と呼ばれている1本腕のスロットマシンから来ている」とある。

Linuxカーネル4.15.0-48-genericでe1000eが認識できなくなる問題に対処した

要約

  1. Linuxカーネル4.15.0-48-genericにアップグレードしたらNICドライバe1000eを認識しなくなった
  2. 4.15.0-48-genericのままでe1000eをsudo make installで入れ直そうとするとgccがエラーを吐いて止まる
  3. カーネル4.15.0-48-genericから4.15.0-47-genericにダウングレードしたらe1000eを正常に再インストールできた

はじめに

メインで使っているUbuntu18.04でapt upgradeした後に再起動したら、NICが認識しなくなりました。
NICのドライバが書き換わったと考えて、りゅーたさんの記事「Ubuntu 18.04 で通信が使えない問題の対応」を参考にしてsudo make installしたところ

You are building kernel with non-retpoline compiler, please update your compiler..

なるエラーが出て失敗しました。
なので、gccのバージョンの問題を疑って、ダウングレードしたものの、全然直らない。
/var/log/apt/history.logを確認したところ、gccもアップグレードされていましたが、linuxカーネルも一緒にアップグレードされていたことに気付きました。
そこで、Linuxカーネルを4.15.0-48から4.15.0-47にダウングレードしたところe1000eをインストールすることができました。
今回は上記の問題を対処した方法について紹介します。

作業手順

カーネルのダウングレード方法はkumquat512さんのubuntu18.04でカーネルのダウングレードにある手順をそのまま実行しました。
そのため、以下で紹介する手順のほとんどを上記ブログから引用していますが、次の3点は私が変更を加えています。

ubuntu18.04でカーネルのダウングレードからの変更点

  1. カーネルのバージョンを今回ダウングレードするバージョンに変更
  2. 5番の「grub-customizerでインストールした「4.15.0-23-generic」を設定」が分かりづらかったので、私が行った設定を記載した
  3. 最後にe1000eのビルド手順を追加

カーネルをダウングレードする手順

  1. 現在のカーネルバージョンを確認
$ uname -r
4.15.0-48-generic
  1. ダウングレードするカーネルを検索
sudo apt-cache search linux-image | grep "4.15.0-47"
sudo apt-cache search linux-headers | grep "4.15.0-47"
sudo apt-cache search linux-modules-extra | grep "4.15.0-47"
  1. カーネルをインストール
sudo apt install linux-image-4.15.0-47-generic linux-headers-4.15.0-47 linux-modules-extra-4.15.0-47
  1. grub-customizerをインストール
sudo add-apt-repository ppa:danielrichter2007/grub-customizer
sudo apt-get update
sudo apt-get install grub-customizer
  1. ターミナルでgrub-customizerを実行して4.15.0-47-genericを起動OS選択画面の一番上に持ってくる

  2. /etc/apt/preferences.d/linux-kernelを設定
    勝手にカーネルがアップデートされないようにするため

Package: linux-generic
Pin: version 4.15.0-47*
Pin-Priority: 1001

Package: linux-headers-generic
Pin: version 4.15.0-47*
Pin-Priority: 1001

Package: linux-image-generic
Pin: version 4.15.0-47*
Pin-Priority: 1001
  1. 再起動 sudo reboot

  2. カーネルを確認して4.15.0-47-genericになっていることを確認

$ uname -r
4.15.0-47-generic
  1. e1000eをビルドする
cd ~/e1000e-3.4.2.1/src
sudo modprobe -r e1000e
sudo make install
sudo modprobe e1000e
sudo update-initramfs -u -k $(uname -r)

終わりに

gccの問題ではないという事に気付くまでに時間が掛かりました。
これが同じ問題で困っている方の助けになれば幸いです。

GitのサーバーをEC2上に構築した

はじめに

私は個人でGitHubを利用しています。
非公開にして共有したいリポジトリがあるのですが、GitHubでは有償になってしまいます。
一方、GitLabでは無償枠でプライベートプロジェクトが持てますが、非公開にしたい プロジェクトの管理ツールは自分で制御できるようにしたいです。
ネットを検索してみたところ、割と簡単にGitのサーバーを構築できそうでした ので、Amazon EC2インスタンスに自分専用のGitサーバーを作りました。

GitHubのプライベートリポジトリを持つために払うお金は月額たった$7ですし、 EC2も無償というわけではないので、手間を考えれば$7払ったほうが早いです。 しかし、今回は勉強のために、あえて手間が掛かる方を選択しました。

2018/10/07追記

2018/10/09追記

  • 1ユーザーで複数の公開鍵を登録する

前提条件

  • EC2のインスタンスはすでに持っている。
  • ローカルからsshでログインできる。
  • ローカル側は、Gitはインストール済み

今回の利用したインスタンスの情報

構築手順

Gitをサーバーにインストール

これはyumでインストールすればOKです。

[ec2-user@ip-xxx-xx-xx-xx ~]$ sudo yum install git

Gitoliteをサーバーにインストール

GitoliteはGitがユーザーへの権限付与や認証を行えるようにするためものです。

GitoliteはGitに認可機能を与えるもので、認証にはsshdhttpdを使用します(復習: 認証とはユーザーが誰かを確認することで、認可とはユーザーがアクセスを許可されているかどうかを確認することです)。

Gitolite は、単なるリポジトリ単位の権限付与だけではなくリポジトリ内のブランチやタグ単位で権限を付与することができます。つまり、特定の人 (あるいはグループ) にだけ特定の "refs" (ブランチあるいはタグ) に対するpush権限を与えて他の人には許可しないといったことができるのです。

引用: 公式サイト .8 Git サーバー - Gitolite より
https://git-scm.com/book/ja/v1/Git-%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC-Gitolite

このインストール手順は、 - @tokinoさんのEC2でGitサーバ構築
- Git公式サイト「.8 Git サーバー - Gitolite」

を参考にしました。

まずはGitoliteのセットアップに必要なものをインストールします。

[ec2-user@ip-xxx-xx-xx-xx ~]$ sudo yum install perl-Data-Dumper

その後、git専用のユーザーをサーバー側に作成します。

[ec2-user@ip-xxx-xx-xx-xx ~]$ sudo useradd git
[ec2-user@ip-xxx-xx-xx-xx ~]$ sudo passwd git

これ以降はgitユーザーで作業します。
ローカルからsshできるように、公開鍵をサーバー側にコピーします。
公式サイトではユーザーのホーム直下に公開鍵をコピーしているようなのですが、 それは気持ち悪いので、.sshディレクトリを作成して、その中に公開鍵を置きます。
鍵のファイル名は<yourname>.pubとします。この<yourname>がユーザー名になるようです。

[ec2-user@ip-xxx-xx-xx-xx ~]$ su git
[git@ip-xxx-xx-xx-xx ec2-user]$ cd ~
[git@ip-xxx-xx-xx-xx ~]$ mkdir .ssh; chmod 700 .ssh

また、忘れずに<yourname>.pubパーミッションも変更します。

[git@ip-xxx-xx-xx-xx .ssh]$ chmod 600 <yourname>.pub

Gitoliteをインストールする準備が整ったので、gitのホームディレクトリに移動して インストールを開始します。

[git@ip-xxx-xx-xx-xx ~]$ git clone git://github.com/sitaramc/gitolite
[git@ip-xxx-xx-xx-xx ~]$ gitolite/install -ln
[git@ip-xxx-xx-xx-xx ~]$ echo "export PATH=$HOME/bin:\$PATH" >> ~/.bashrc; source ~/.bashrc
[git@ip-xxx-xx-xx-xx ~]$ gitolite setup -pk $HOME/.ssh/<yourname>.pub

すると、gitのホームディレクトリは以下のようになっています。

[git@ip-xxx-xx-xx-xx ~]$ ls
bin  gitolite  projects.list  repositories

このrepositoriesの中にpushされたリポジトリが置かれます。

最後に、クライアント側で以下を実行してGitoliteの設定をクローンしてきます。

katsuya@katsuya:~$ git clone git@<インスタンスのIP>:gitolite-admin

Gitoliteの設定管理はこのリポジトリに変更を加えてpushするようです。
とりあえず、今日はここまでで終わりです。
(2018/10/07追記) 新たにリポジトリを作成する場合もgitolite-admin/conf/gitolite.confを変更してpushします。
詳細は以下を確認してください。

GitサーバーのIPアドレスを分かりやすくする(2018/10/07追記)

クライアント側の/etc/hostsに次を追加して、保存します。
私はgit.ishiyamaで登録しましたが、好きなホスト名を付けてください。

xxx.xxx.xxx.xxx     <好きなホスト名>

試しに、次のコマンドでサーバーにsshして、ログインできれば成功です。

katsuya@katsuya:~$ ssh -i .ssh/aws.pem ec2-user@<登録したホスト名>

Gitサーバーに新しいリポジトリを作成する方法(2018/10/07追記)

いよいよ、構築したサーバーにリポジトリを作成して、cloneとpushしてみます。
今回はテスト用のリポジトリtest01を作成して、テストを行います。
GitHubならば、ブラウザで自分のアカウントにログインし、New Repositoryをクリックして新しいリポジトリを作成することができますが、もちろん、今回作成したサーバーにそのような機能はありません。
どのように新規リポジトリを作成したら良いでしょうか?? このためには、先程、cloneしたgitolite-adminにあるgitolite.confに作成したいリポジトリを追加して、サーバーにpushします。

まずはgitolite-admin/conf/gitolite.confに以下を追加します。

repo  test01
    RW+     =   <yourname>

ここで、<yourname>は上で登録した公開鍵に付けた名前です。

これをcommitして、サーバーにプッシュすると空のリポジトリを作成することができます。

katsuya@katsuya:~/gitolite-admin$ git add conf/gitolite.conf 
katsuya@katsuya:~/gitolite-admin$ git commit -m "created a new repository test01"
[master ca45cd0] created a new repository test01
 1 file changed, 3 insertions(+)
katsuya@katsuya:~/gitolite-admin$ git push origin master
Counting objects: 4, done.
Delta compression using up to 12 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (4/4), 373 bytes | 373.00 KiB/s, done.
Total 4 (delta 1), reused 0 (delta 0)
remote: Initialized empty Git repository in /home/git/repositories/test01.git/
To <サーバーのIPアドレス>:gitolite-admin
   0901ffb..ca45cd0  master -> master

あとは、GitHubと同じようにリポジトリをcloneして、テストファイルをcommitし、サーバーへpushすれば完了です。

katsuya@katsuya:~/gitolite-admin$ cd ~
katsuya@katsuya:~$ git clone git@git.ishiyama:test01.git
Cloning into 'test01'...
warning: You appear to have cloned an empty repository.
katsuya@katsuya:~$ cd test01
katsuya@katsuya:~/test01$ echo "This is a test file." > README.md
katsuya@katsuya:~/test01$ git add README.md 
katsuya@katsuya:~/test01$ git commit -m "added a test file"
[master (root-commit) cf8fbed] added a test file
 1 file changed, 1 insertion(+)
 create mode 100644 README.md
katsuya@katsuya:~/test01$ git push origin master
Counting objects: 3, done.
Writing objects: 100% (3/3), 241 bytes | 241.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To git.ishiyama:test01.git
 * [new branch]      master -> master

1ユーザーで複数の公開鍵を登録する(2018/10/09追記)

私は個人でデスクトップとノートPCを所有していて、それぞれWindowsLinuxデュアルブートさせています。
このような場合、各環境毎に公開鍵を登録してGitサーバーを使えるようにしたくなります。
1ユーザーで複数の公開鍵を使えるようにする方法は次の2つです。

  1. keydirディレクトリにサブディレクトリを作り、そこに<yourname>.pubを置く
  2. ukmコマンドを使って公開鍵を登録する

それぞれのメリット・デメリットは以下のとおりです。

方法 メリット デメリット
1 複雑な設定やツールのインストールは不要 - keydirの管理が複雑
- 公開鍵の登録・削除は管理者のみ
2 - 各ユーザーが公開鍵を管理できる
- keydirの管理が不要
- ukmを利用するための設定が複雑
- 公開鍵を管理するためのコマンドが複雑

今回は1の方法を使います。
本来ならば、機械的に管理ができるukmコマンドを使うべきなのですが、 主なユーザーは私1人なので、keydirの管理が複雑になることは考えられません。
また、他にもやりたいことがあり、ukmの使用方法を調べる時間ももったいないと感じてしまいます。
そのため、シンプルで理解しやすい1を選択しました。

複数の公開鍵を登録する方法

まずは、gitolite-admin/keydirディレクトリを作成します。

katsuya@katsuya:~/gitolite-admin$ mkdir -p keydir/path/to/each/dir

作成したディレクトリに登録したい公開鍵を<yourname>.pubで保存します。
あとは、この変更をcommitして、サーバーにpushして完了です。

keydirの構成例として自分のものを挙げておきます。

katsuya@katsuya:~/gitolite-admin$ tree keydir/
keydir/
└── katsuya
    └── home
        ├── desktop
        │   └── ubuntu18.04
        │       └── katsuya.pub
        └── laptop
            └── mint17.1
                └── katsuya.pub

6 directories, 2 files

TensorFlow + Kerasの環境構築メモ

はじめに

2週間ほど前に機械学習やkaggleの勉強用に新しいPCを買いました。
このPCにTensorFlowとKerasをインストールした際の作業メモを残します。

インストール環境

インストールした環境は以下のとおりです。
pythonはすでにAnacondaでインストールしてあります。
色々言われるAnacondaですが、使いたいパッケージが揃っていて、やはり便利。

項目 製品名またはバージョン
PC GALLERIA ZZ
OS Ubuntu18.04 Desktop
GPU NVIDIA GeForce GTX1080Ti
Python 3.6.5 (Anaconda 5.2)

今回参考にした情報

今回は以下の2つを参考にインストールしました。
https://qiita.com/gott/items/28524953547d13894e08
https://medium.com/@taylordenouden/installing-tensorflow-gpu-on-ubuntu-18-04-89a142325138

インストール手順

CUDA-9.0をインストール

下のURLからCUDA Toolkit 9.0をダウンロードしました。
https://developer.nvidia.com/cuda-90-download-archive

今回は"Select Target Platform"を以下のとおりに選択します。

項目 選択した値
Operating System Linux
Architecture x86_64
Distribution Ubuntu
Version 17.04
Installer Type runfile(local)

ダウンロードしたら下記のコマンドを実行してインストールを開始します。

$ cd ~/Downloads    # 今回のダウンロード先
$ sudo chmod +x cuda_9.0.176_384.81_linux.run
$ ./cuda_9.0.176_384.81_linux.run --override

長い免責事項の後に出てくる選択肢には、 Install NVIDIA Accelerated Graphics Driver for Linux-x86_64 384.81?のみ noを選択し、他はすべてyesと答えます。
実際の内容は以下のとおりです。

    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 
    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.  

-----------------
Do you accept the previously read EULA?
accept/decline/quit: accept

You are attempting to install on an unsupported configuration. Do you wish to continue?
(y)es/(n)o [ default is no ]: yes

Install NVIDIA Accelerated Graphics Driver for Linux-x86_64 384.81?
(y)es/(n)o/(q)uit: no

Install the CUDA 9.0 Toolkit?
(y)es/(n)o/(q)uit: yes

Enter Toolkit Location
 [ default is /usr/local/cuda-9.0 ]: 

/usr/local/cuda-9.0 is not writable.
Do you wish to run the installation with 'sudo'?
(y)es/(n)o: yes

Please enter your password: 
Do you want to install a symbolic link at /usr/local/cuda?
(y)es/(n)o/(q)uit: yes

Install the CUDA 9.0 Samples?
(y)es/(n)o/(q)uit: yes

Enter CUDA Samples Location
 [ default is /home/katsuya ]: 

Installing the CUDA Toolkit in /usr/local/cuda-9.0 ...

CUDNN 7.1をインストールする

Download cuDNN v7.1.4 (May 16, 2018), for CUDA 9.0cuDNN v7.1.4 Library for Linuxをダウンロードしました。

以下のコマンドを実行してライブラリを適切なディレクトリに配置します。

$ tar -zxvf cudnn-9.0-linux-x64-v7.1.tgz 
$ sudo cp -P cuda/lib64/libcudnn* /usr/local/cuda-9.0/lib64/
$ sudo cp cuda/include/cudnn.h /usr/local/cuda-9.0/include/
$ sudo chmod a+r /usr/local/cuda-9.0/include/cudnn.h /usr/local/cuda/lib64/libcudnn*

NVIDIA CUDA Profile Tools Interfaceをインストールする

これはメモリロードの状況やボトルネックの同定などの機能を提供するパッケージです。
下記のとおり、aptで簡単にインストールできます。

$ sudo apt install libcupti-dev

環境変数を設定する

.bashrcの末尾に次の2行を追加します。

export PATH="/usr/local/cuda-9.0/bin:$PATH"
export LD_LIBRARY_PATH="/usr/local/cuda/lib64:$LD_LIBRARY_PATH"

完了したら次のコマンドを実行して、環境変数を有効化します。

$ source ~/.bashrc

TensorFlowとKerasをインストールする

やっとTensorFlowとKerasをインストールできる環境が整いました。
あとは

$ pip install tensorflow-gpu
$ pip install keras

を実行すればインストール作業は終了です。

テストコードを実行してみる

実際に動作するか試してみます。
テストコードは

https://github.com/keras-team/keras/blob/master/examples/cifar10_cnn.py

をそのまま拝借してkeras_test_code.pyを作成します。

'''Train a simple deep CNN on the CIFAR10 small images dataset.
It gets to 75% validation accuracy in 25 epochs, and 79% after 50 epochs.
(it's still underfitting at that point, though).
'''

from __future__ import print_function
import keras
from keras.datasets import cifar10
from keras.preprocessing.image import ImageDataGenerator
from keras.models import Sequential
from keras.layers import Dense, Dropout, Activation, Flatten
from keras.layers import Conv2D, MaxPooling2D
import os

batch_size = 32
num_classes = 10
epochs = 100
data_augmentation = True
num_predictions = 20
save_dir = os.path.join(os.getcwd(), 'saved_models')
model_name = 'keras_cifar10_trained_model.h5'

# The data, split between train and test sets:
(x_train, y_train), (x_test, y_test) = cifar10.load_data()
print('x_train shape:', x_train.shape)
print(x_train.shape[0], 'train samples')
print(x_test.shape[0], 'test samples')

# Convert class vectors to binary class matrices.
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)

model = Sequential()
model.add(Conv2D(32, (3, 3), padding='same',
                 input_shape=x_train.shape[1:]))
model.add(Activation('relu'))
model.add(Conv2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

model.add(Conv2D(64, (3, 3), padding='same'))
model.add(Activation('relu'))
model.add(Conv2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

model.add(Flatten())
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(num_classes))
model.add(Activation('softmax'))

# initiate RMSprop optimizer
opt = keras.optimizers.rmsprop(lr=0.0001, decay=1e-6)

# Let's train the model using RMSprop
model.compile(loss='categorical_crossentropy',
              optimizer=opt,
              metrics=['accuracy'])

x_train = x_train.astype('float32')
x_test = x_test.astype('float32')
x_train /= 255
x_test /= 255

if not data_augmentation:
    print('Not using data augmentation.')
    model.fit(x_train, y_train,
              batch_size=batch_size,
              epochs=epochs,
              validation_data=(x_test, y_test),
              shuffle=True)
else:
    print('Using real-time data augmentation.')
    # This will do preprocessing and realtime data augmentation:
    datagen = ImageDataGenerator(
        featurewise_center=False,  # set input mean to 0 over the dataset
        samplewise_center=False,  # set each sample mean to 0
        featurewise_std_normalization=False,  # divide inputs by std of the dataset
        samplewise_std_normalization=False,  # divide each input by its std
        zca_whitening=False,  # apply ZCA whitening
        zca_epsilon=1e-06,  # epsilon for ZCA whitening
        rotation_range=0,  # randomly rotate images in the range (degrees, 0 to 180)
        # randomly shift images horizontally (fraction of total width)
        width_shift_range=0.1,
        # randomly shift images vertically (fraction of total height)
        height_shift_range=0.1,
        shear_range=0.,  # set range for random shear
        zoom_range=0.,  # set range for random zoom
        channel_shift_range=0.,  # set range for random channel shifts
        # set mode for filling points outside the input boundaries
        fill_mode='nearest',
        cval=0.,  # value used for fill_mode = "constant"
        horizontal_flip=True,  # randomly flip images
        vertical_flip=False,  # randomly flip images
        # set rescaling factor (applied before any other transformation)
        rescale=None,
        # set function that will be applied on each input
        preprocessing_function=None,
        # image data format, either "channels_first" or "channels_last"
        data_format=None,
        # fraction of images reserved for validation (strictly between 0 and 1)
        validation_split=0.0)

    # Compute quantities required for feature-wise normalization
    # (std, mean, and principal components if ZCA whitening is applied).
    datagen.fit(x_train)

    # Fit the model on the batches generated by datagen.flow().
    model.fit_generator(datagen.flow(x_train, y_train,
                                     batch_size=batch_size),
                        epochs=epochs,
                        validation_data=(x_test, y_test),
                        workers=4)

# Save model and weights
if not os.path.isdir(save_dir):
    os.makedirs(save_dir)
model_path = os.path.join(save_dir, model_name)
model.save(model_path)
print('Saved trained model at %s ' % model_path)

# Score trained model.
scores = model.evaluate(x_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])

実行結果はご覧のとおりです。

katsuya@katsuya:~$ python ./keras_test_code.py 
/home/katsuya/anaconda3/lib/python3.6/site-packages/h5py/__init__.py:36: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
  from ._conv import register_converters as _register_converters
Using TensorFlow backend.
Downloading data from https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz
170500096/170498071 [==============================] - 203s 1us/step
x_train shape: (50000, 32, 32, 3)
50000 train samples
10000 test samples
Using real-time data augmentation.
Epoch 1/100
2018-09-05 01:20:30.859870: I tensorflow/core/platform/cpu_feature_guard.cc:141] Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX2 FMA
2018-09-05 01:20:31.135949: I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:897] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero
2018-09-05 01:20:31.137171: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1405] Found device 0 with properties: 
name: GeForce GTX 1080 Ti major: 6 minor: 1 memoryClockRate(GHz): 1.582
pciBusID: 0000:01:00.0
totalMemory: 10.91GiB freeMemory: 10.42GiB
2018-09-05 01:20:31.137233: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1484] Adding visible gpu devices: 0
2018-09-05 01:20:36.353767: I tensorflow/core/common_runtime/gpu/gpu_device.cc:965] Device interconnect StreamExecutor with strength 1 edge matrix:
2018-09-05 01:20:36.353854: I tensorflow/core/common_runtime/gpu/gpu_device.cc:971]      0 
2018-09-05 01:20:36.353880: I tensorflow/core/common_runtime/gpu/gpu_device.cc:984] 0:   N 
2018-09-05 01:20:36.363145: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1097] Created TensorFlow device (/job:localhost/replica:0/task:0/device:GPU:0 with 10075 MB memory) -> physical GPU (device: 0, name: GeForce GTX 1080 Ti, pci bus id: 0000:01:00.0, compute capability: 6.1)
1563/1563 [==============================] - 18s 11ms/step - loss: 1.8860 - acc: 0.3071 - val_loss: 1.5808 - val_acc: 0.4329
Epoch 2/100
1563/1563 [==============================] - 10s 6ms/step - loss: 1.5933 - acc: 0.4186 - val_loss: 1.4743 - val_acc: 0.4629

正常に動いていることが確認できました。

Scrapyで欅坂46とけやき坂46の各メンバーの画像収集する

はじめに

欅坂46の画像で誰が写っているのかを認識させるアプリをディープラーニングで作ろうと思いますので、今回は各メンバーの画像を収集します。
MicrosoftのBing Image SearchやGoogleの画像検索APIを使うことも考えましたが、Yahoo、Bing、Googleでの画像収集事情まとめを読んで「1メンバーあたり1000枚は欲しい」と思いましたので、Yahoo Japanの画像検索(簡易版)をスクレイピングすることに決めました。
このような場合、私は python + Requests + BeautifulSoup で実装してきましたが、勉強のため、以前から気になっていたScrapyを使うことにしました。

実装にあたってはこちらを参考にしました。
Python + Scrapyで画像を巡回取得する

作ったコードはこちら(GitHub)

完成品の実行方法

今回作った画像収集スクリプトは次のコマンドをターミナルに打って実行します。

$ scrapy crawl yahoo_image -a query=石森虹花 -a member_id=1

1回の実行につき1人のメンバーを画像検索にかけて画像を収集します。
queryにはメンバー名、member_idにはディープラーニングで学習させる際に使うラベルを指定します。 member_idの付け方は任意なので、もし「推しメンが1じゃないと嫌だ」という方は変えてもらって大丈夫です。

1回の実行でメンバー全員を処理することもできますが、上手く設計しないと、途中でバッチ処理がコケてしまった場合に、そこから再開させるためにはスクリプトを修正する必要が出てくる可能性があります。
今回採用した方法ならば、最悪でもシェルスクリプトでオプションの値を変えて並べるだけで済むので、再開しやすいです。

Scrapyをインストールする

 ~ $ pip install Scrapy

Projectを作成する

ターミナルに次のコマンドを打ってプロジェクトを作成します。

 ~ $ scrapy startproject image_scraper

すると、カレントディレクトリにimage_scraperディレクトリができています。
このディレクトリの構成は次のようになっているはずです。

~ $ tree image_scraper
image_scraper/
├── scrapy.cfg
└── image_scraper
    ├── __init__.py
    ├── __pycache__
    ├── items.py
    ├── middlewares.py
    ├── pipelines.py
    ├── settings.py
    └── spiders
        ├── __init__.py
        └── __pycache__

4 directories, 7 files

spidersディレクトリには

Spiderを作成する

SpiderにはWebサイトをどのように探索するのかを定義します(下の引用*1を参考)。

Spiders are classes which define how a certain site (or a group of sites) will be scraped, including how to perform the crawl (i.e. follow links) and how to extract structured data from their pages (i.e. scraping items). In other words, Spiders are the place where you define the custom behaviour for crawling and parsing pages for a particular site (or, in some cases, a group of sites).

ここではPython + Scrapyで画像を巡回取得するの「Spiderを用意して実装する」を参考にして ~/image_scraper/spider/yahoo_image_spider.py を作成します。

# -*- coding: utf-8 -*-

import scrapy
from image_scraper.items import ImageScraperItem
from scrapy.spiders import CrawlSpider


class YahooImageSpider(CrawlSpider):

    name = 'yahoo_image'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.image_id = 0
        self.page_id = 0
        self.image_num_per_page = 20

    def start_requests(self):
        url = ('https://search.yahoo.co.jp/image/search?'
               'p={}&ei=UTF-8&save=0'.format(self.query))
        yield scrapy.Request(url, self.parse, dont_filter=True)

    def parse(self, response):
        self.page_id += 1

        items = ImageScraperItem()
        items['member_id'] = self.member_id
        items['image_ids'] = []
        items['image_urls'] = []

        for src in response.css('#gridlist > div'):
            self.image_id += 1

            url = src.css('div > p > a::attr(href)').extract_first()
            items['image_ids'].append(self.image_id)
            items['image_urls'].append(url)

        yield items

        _next_page = ('https://search.yahoo.co.jp/image/search?fr=top_ga1_sa&'
                      'p={query}&ei=UTF-8&b={start_image}')
        next_page = _next_page.format(
            query=self.query,
            start_image=self.page_id * self.image_num_per_page + 1
        )
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

query_idとmember_idは上で紹介した実行時に与えられる値が入っています。
image_idは取得した画像に付けるIDでファイル名として使います。今回は取得した順に付けていきます。
page_idはスクレイプするWebページのIDで、スクレイプを停止するための条件判定で使います。これも取得した順につけていきます。
image_num_per_pageは1ページあたりの画像の枚数で20で設定しています。
これはYahoo Japanの画像検索(簡易版)では1ページに20枚の画像が表示されるためです。

クローラーを次ページへ巡回させる部分はScrapyのチュートリアル*2を参考にしました。

        _next_page = ('https://search.yahoo.co.jp/image/search?fr=top_ga1_sa&'
                      'p={query}&ei=UTF-8&b={start_image}')
        next_page = _next_page.format(
            query=self.query,
            start_image=self.page_id * self.image_num_per_paホットバージョン Vol.152ge + 1
        )
        if next_page is not None:
            yield response.follow(next_page, callback=self.parse)

理由は、Python + Scrapyで画像を巡回取得するでは

    rules = (
        Rule(LinkExtractor(allow=( )), callback="parse_page", follow=True),
    )

で上手く行っていましたが、私の場合は途中でリンクが取得できずにクローラーが終了してしまったためです*3
また、次のページのリンクを抽出するのにCSS Selectorを使っていません。
これはリンクを辿って行くと途中でCSSの構造が変わってしまい、意図通りにURLを取得できなかったためです。
そのため、URLのクエリストリングを更新するスタイルにしました。

ImageScraperItemを実装する

scrapy.Itemを継承したImageScraperItemは、Webページから取得したデータをScrapyの他の機能に渡すためのデータフォーマットを定義します。 item_scraper/items.pyを以下の内容で保存します。

# -*- coding: utf-8 -*-

# Define here the models for your scraped items
#
# See documentation in:
# https://doc.scrapy.org/en/latest/topics/items.html

import scrapy
from scrapy.item import Field


class ImageScraperItem(scrapy.Item):
    # define the fields for your item here like:
    # name = scrapy.Field()
    member_id = Field()
    image_ids = Field()
    image_urls = Field()

image_idsは、spiderの章でも紹介しましたが、画像のIDを格納します。
image_urlsはこれからダウンロードする画像のURLを格納するのに使います。

ImageScraperPipelineを実装する

Item Pipelineはspiderで取得したアイテムの処理を担当するパートです。
典型的な用途としては、HTMLのクレンジングやスクレイピングしたデータの整合性チェック、DBへのインサートを含むデータの保存があります。
今回はスクレイピングしたURLから画像をダウンロードしてローカルに保存するように実装していきます。

公式ドキュメントからの引用 Item Pipeline — Scrapy 1.5.1 documentation

After an item has been scraped by a spider, it is sent to the Item Pipeline which processes it through several components that are executed sequentially.

Each item pipeline component (sometimes referred as just “Item Pipeline”) is a Python class that implements a simple method. They receive an item and perform an action over it, also deciding if the item should continue through the pipeline or be dropped and no longer processed.

Typical uses of item pipelines are:

  • cleansing HTML data
  • validating scraped data (checking that the items contain certain fields)
  • checking for duplicates (and dropping them)
  • storing the scraped item in a database

image_scraper/pipelines.pyを以下の内容で保存します。

# -*- coding: utf-8 -*-

# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: https://doc.scrapy.org/en/latest/topics/item-pipeline.html

import pathlib
import scrapy
from scrapy.pipelines.images import ImagesPipeline
from scrapy.utils.misc import md5sum


class ImageScraperPipeline(ImagesPipeline):

    def get_media_requests(self, item, info):
        member_id = item['member_id']
        for img_id, url in zip(item['image_ids'], item['image_urls']):
            meta = {
                'member_id': member_id,
                'image_id': img_id
            }
            yield scrapy.Request(url, meta=meta)

    def image_downloaded(self, response, request, info):
        checksum = None
        for path, image, buf in self.get_images(response, request, info):
            if checksum is None:
                buf.seek(0)
                checksum = md5sum(buf)
            width, height = image.size
            suffix = pathlib.Path(path).suffix
            path = '{member_id:03d}/{image_id:04d}{suffix}'.format(
                member_id=int(request.meta['member_id']),
                image_id=request.meta['image_id'],
                suffix=suffix
            )
            self.store.persist_file(
                path=path,
                buf=buf,
                info=info,
                meta={'width': width, 'height': height}
            )
        return checksum

プロジェクトを作成したままの状態ですと

def process_item(self, item, spider):
    pass

がありますが、これを残したままにしておくと画像がダウンロードされません*4ので、削除して下さい。
ダウンロードした画像は~/image_scraper/download/{メンバーID}/{画像ID}.{拡張子}に保存されます。
例えば、石森虹化のメンバーIDを1として、取得順序が1番目のJPEG画像は~/image_scraper/download/001/0001.jpgに保存されています。 画像保存場所にdownloadディレクトリを指定している部分は次章のsettings.pyを変更することで行っています。

Scrapyの設定を変更する

pipelineの設定

プロジェクトを作成したままでもITEM_PIPELINESは次のように有効になっているはずです。

ITEM_PIPELINES = {
    'image_scraper.pipelines.ImageScraperPipeline': 1,
}

この下に画像の保存先の設定としてIMAGES_STORE = './download'を加えます。
これで画像は~/image_scraper/download以下に保存されるようになります。

spiderの停止条件

次にspiderが規定以上のItem数になったら停止するように設定します。
Scrapyはデフォルト状態では並行処理する設定になっている*5ので、50と設定してもそれ以上の数の画像をダウンロードします。
今回の場合、メンバーにもよりますが、1人あたり平均して500枚程度の画像を取得できます。

EXTENSIONS = {
    'scrapy.extensions.closespider.CloseSpider': 1,
}
CLOSESPIDER_ITEMCOUNT = 50

Bot判定を回避するための設定

以下の2つを設定します。
この設定は公式ドキュメントの Avoiding getting banned を参考にしました。

COOKIES_ENABLED = False
DOWNLOAD_DELAY = 3

全メンバー分をバッチ実行するシェルスクリプトを作成する

まず漢字とひらがなのメンバーリストmember_list.csvを用意します。

$ head -n 5 member_list.csv 
member_id,member_name
1,石森虹花
2,今泉佑唯
3,上村莉菜
4,尾関梨香

このリストに記載されているメンバー全員についてscrapyを実行するシェルスクリプトexecute_crawl.shを以下の内容で作成します。 実行時のログをあとで確認できるようにファイルとして保存しておくため、オプションに--logfile log/yahoo_image_${member_id}.logと設定しています。

#!/bin/sh

csv_file="member_list.csv"

for line in `cat ${csv_file}`
do
    member_id=`echo ${line} | cut -d ',' -f 1`
    member_name=`echo ${line} | cut -d ',' -f 2`
    echo "processing member_id ${member_id}, member_name ${member_name}"
    scrapy crawl yahoo_image -a query=$member_name -a member_id=$member_id --logfile log/yahoo_image_${member_id}.log
done

実際に画像を収集する

先ほど作ったexecute_crawl.shを実行します。

~/image_scraper$ chmod 744 execute_crawl.sh
~/image_scraper$ ./execute_crawl.sh

私の環境では3時間ほどでクローリングが終了しました。

実際に画像が何枚ダウンロードできたのかを集計する

実際にダウンロードした画像が枚数だったのかを知るために次の4項目を集計してみます。

  1. 各メンバーごとの画像枚数
  2. 欅坂46けやき坂46のメンバー数
  3. 全メンバーで合計した画像枚数
  4. 1メンバーあたり平均の画像枚数

作成したスクリプトは次の通りです。

# -*- coding: utf-8 -*-

import pathlib
import pandas as pd

member_list = pd.read_csv('member_list.csv')
download_dir = pathlib.Path('./download')

# ---------------------------------------------
# Aggregating number of images at each member
# ---------------------------------------------

member_id = []
member_name = []
image_count = []
for _, (_id, _name) in member_list.iterrows():
    _dir = download_dir / '{:03d}'.format(_id)
    image_path_list = [p for p in _dir.iterdir()]
    member_id.append(_id)
    member_name.append(_name)
    image_count.append(len(image_path_list))

download_image_count = pd.DataFrame(
    data={
        'member_id': member_id,
        'member_name': member_name,
        'image_count': image_count
    }
)
column_order = ['member_id', 'member_name', 'image_count']
download_image_count = download_image_count[column_order]

# ---------------------------------------------
# Display summaries of downloaded images
# ---------------------------------------------

print('Number of downloaded images at each member:')
print(download_image_count)
print()

number_of_members = member_list.shape[0]
print('Number of members: {}'.format(number_of_members))

total_image_count = download_image_count.image_count.sum()
print('Total number of images: {}'.format(total_image_count))

avg_image_count = download_image_count.image_count.mean()
print('Average number of images: {}'.format(avg_image_count))

上を実行した結果は以下の通りです。

$ python ./summary_download.py 
Number of downloaded images at each member:
    member_id member_name  image_count
0           1        石森虹花          484
1           2        今泉佑唯          481
2           3        上村莉菜          475
3           4        尾関梨香          523
4           5        織田奈那          451
5           6        小池美波          508
6           7        小林由依          497
7           8       齋藤冬優花          540
8           9        佐藤詩織          466
9          10        志田愛佳          476
10         11        菅井友香          449
11         12        鈴本美愉          512
12         13       長沢菜々香          478
13         14        土生瑞穂          464
14         15         原田葵          496
15         16       平手友梨奈          532
16         17         守屋茜          478
17         18       米谷奈々未          439
18         19        渡辺梨加          453
19         20        渡邉理佐          494
20         21        井口眞緒          402
21         22        潮紗理菜          450
22         23        柿崎芽実          436
23         24        影山優佳          444
24         25        加藤史帆          443
25         26        齊藤京子          465
26         27       佐々木久美          388
27         28       佐々木美玲          433
28         29        高瀬愛奈          437
29         30        高本彩花          446
30         31        長濱ねる          460
31         32        東村芽依          465
32         33        金村美玖          399
33         34        河田陽菜          359
34         35        小坂菜緒          340
35         36        富田鈴花          423
36         37        丹生明里          372
37         38       濱岸ひより          346
38         39        松田好花          379
39         40        宮田愛萌          371
40         41        渡邉美穂          445

Number of members: 41
Total number of images: 18399
Average number of images: 448.7560975609756

まとめ

スクレイピングフレームワークのScrapyを使って欅坂46けやき坂46のメンバーの画像を収集しました。
1メンバーあたり1000枚取得を目標にしていましたが、実際には450枚弱しか取得できませんでした。
CLOSESPIDER_ITEMCOUNTの値を増やして実行してもいいのですが、目的はメンバーの顔を認識するアプリの作成なので、ひとまずこれで画像の収集を終わりにしたいと思います。
精度が出なかった場合はさらに画像をダウンロードすることを検討します。

*1:https://docs.scrapy.org/en/latest/topics/spiders.html

*2:https://docs.scrapy.org/en/latest/intro/tutorial.html#following-links

*3:目的に適した自前のLinkExtractorを定義することもできるようですが、今回は理解しやすかったチュートリアルの例を基に実装しました。

*4:これに気づくのに結構時間が掛かりました...(汗)

*5:https://stackoverflow.com/questions/44006672/scrapy-closespider-itemcount-setting-not-working-as-expected

ディープラーニングによるゆるキャラグランプリの順位予想

はじめに

私はゲーム会社でデータ分析を行っています。
アートの方々がアイテムの装着率を気にしているのを見る度に、「リリース前にクリエイティブを定量的に評価できる指標が無いだろうか?」と、考えていました。
ディープラーニングを勉強してみて「これならもしかしてできるんじゃないか??」と思い、VGGから初めて、いくつかのアルゴリズムを試してきました。
まだしっかりした結果が出ていないものですが、公開しようと思います。

データは、業務外の私的な研究なので、ネット上から収集できるデータにしました。
今回はゆるキャラグランプリ2017にエントリーしたキャラクターの画像から、そのキャラクターの最終的な順位を予想してみます。

結論

  1. 学習データでの精度は98%、バリデーションデータに対しての精度は88%、テストデータに対しての精度は23%でした。
  2. 新規エントリーと過去エントリー実績があるキャラクターに分けてテストデータでの予想精度を分析してみると、新規エントリーしたキャラクターの順位はまったく予想できておらず、過去にエントリーしたことのあるキャラクターに対しての予想精度も悪いという結果が得られました。
  3. 学習データの順位帯には2011~2016年までの順位帯の中で最も多く属した順位帯を採用したため、時系列情報が落ちてしまい、2017年の予想精度が悪くなりました。
  4. 後で見るように、過去エントリーしたことがあるキャラクターの順位の変動にはトレンドが存在しているため、RNN等の時系列を考慮したモデルを使う必要がありました。
  5. いわゆるコールドスタート問題で初参加のキャラクターの予想が悪いです。

モデルの仮定

今回のモデル構築では以下を仮定しています。
1. 人はかっこいいや可愛いなど見た目で投票する
2. トレンドの変化は無い*1

使用したデータとディープラーニングアルゴリズム

  • トレーニングデータ
    ゆるキャラグランプリの2011年 ~ 2016年の総合ランキングの順位とキャラクターの画像
    総合ランキングは期間中に最も多く属した順位帯を採用しました。
    属した回数にタイが発生した場合は最も小さい順位帯を採用しています。

  • 予想するデータ
    ゆるキャラグランプリの2017年の総合ランキングの順位
    ※ランキングがご当地と企業・その他で分かれているので、データを取得後に得票数から総合ランキングを作成しました。

  • 適用したアルゴリズム
    Inception-V3
    今回はkerasで提供されているAPIをそのまま適用しました。
    モデルの詳細はhttps://keras.io/ja/applications/#inceptionv3をご確認下さい。

トレーニングスクリプト

トレーニングで使ったスクリプトは次の3つから構成されています。

  1. model.py kerasのモデルを構築します。
  2. utils.py データのロードやトレーニングデータをシャッフルする関数が定義されています。
  3. trainer.py モデルのトレーニングを実行します。

model.py

# -*- coding: utf-8 -*-

from keras.applications.inception_v3 import InceptionV3


def build_model():
    return InceptionV3(include_top=True, weights=None, classes=10)


if __name__ == '__main__':

    model = build_model()
    model.summary()

utils.py

# -*- coding: utf-8 -*-

import csv
import os
from keras.utils import to_categorical
import numpy as np
from skimage.io import imread


def _load_yuruchara_data(csv_path, image_dir):
    points = []
    images = []
    with open(csv_path, 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            pt = int(row['point'])
            points.append([pt])

            fn = row['filename']
            img = imread(os.path.join(image_dir, fn))
            images.append(img)

    points_array = np.array(points)
    images_array = np.array(images)

    return images_array, points_array


def _load_yuruchara_decile_data(csv_path, image_dir):
    deciles = []
    images = []
    with open(csv_path, 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            d = int(row['ranking_class'])
            # To use `to_categorical`, we must calculate d - 1.
            deciles.append(d-1)

            fn = row['filename']
            img = imread(os.path.join(image_dir, fn))
            images.append(img)

    deciles_array = to_categorical(deciles)
    images_array = np.array(images)

    return images_array, deciles_array


def load_yuruchara_data():
    TRAIN_DIR = '/home/ishiyama/yuruchara/data/train'
    train = _load_yuruchara_data(
        csv_path=os.path.join(TRAIN_DIR, 'yuruchara_train_data.csv'),
        image_dir=os.path.join(TRAIN_DIR, 'image'))

    TEST_DIR = '/home/ishiyama/yuruchara/data/test'
    test = _load_yuruchara_data(
        csv_path=os.path.join(TEST_DIR, 'yuruchara_test_data.csv'),
        image_dir=os.path.join(TEST_DIR, 'image'))

    return train, test


def load_yuruchara_decile_data():
    TRAIN_DIR = '/home/ishiyama/yuruchara/decile_data/train'
    train = _load_yuruchara_decile_data(
        csv_path=os.path.join(TRAIN_DIR, 'train_data.csv'),
        image_dir=os.path.join(TRAIN_DIR, 'image', '299'))

    TEST_DIR = '/home/ishiyama/yuruchara/decile_data/test'
    test = _load_yuruchara_decile_data(
        csv_path=os.path.join(TEST_DIR, 'test_data.csv'),
        image_dir=os.path.join(TEST_DIR, 'image', '299'))

    return train, test


def shuffle_data(x, y):
    x_length = x.shape[0]
    y_length = y.shape[0]
    if x_length != y_length:
        raise ValueError('lengths of x and y must be same length.')
    index = np.arange(x_length)
    np.random.shuffle(index)
    return x[index, :, :, :], y[index, :]


def normalize_images(images):
    shape = images.shape
    normalized = np.zeros(shape)
    channels = shape[-1]
    for ch in range(channels):
        layers = images[:, :, :, ch]
        mean = layers.mean()
        scale = layers.max()
        normalized[:, :, :, ch] = (layers - mean) / scale
    return normalized


if __name__ == '__main__':

    train, test = load_yuruchara_decile_data()
    train_x, train_y = shuffle_data(x=train[0], y=train[1])
    print(train_x.shape)
    print(train_y.shape)

trainer.py

# -*- coding: utf-8 -*-
""" Predicting votes on Yuruchara GP with Inception V3. """

import sys
import keras

from utils import load_yuruchara_decile_data, shuffle_data
from model import build_model


EPOCHS = 50
LOG_DIR = './logs'


model = build_model()
model.compile(optimizer='adam',
              loss='categorical_crossentropy',
              metrics=['accuracy'])

callbacks = keras.callbacks.TensorBoard(log_dir=LOG_DIR)
(train_x, train_y), (test_x, test_y) = load_yuruchara_decile_data()
train_x, train_y = shuffle_data(train_x, train_y)
model.fit(x=train_x,
          y=train_y,
          epochs=EPOCHS,
          validation_split=0.1,
          verbose=2,
          callbacks=[callbacks])

model.evaluate(x=test_x, y=test_y)

model.save('yuruchara_inception01.h5')

実行結果

トレーニングデータでは精度が98%まで上昇したが、バリデーションでは88.1%にとどまりました。
2017年のデータで行った予想のテストでは、正解率は22.77%程度しかありません。
この結果は今回のモデルが大きな問題を持っていることを示しています。
実は2017年にゆるキャラグランプリにエントリーした1,098体のキャラクターのうち、845体(約77%)が過去にエントリーした経験があります。 そのため、本来ならば、テスト用に残しておいた2017年の画像の3/4がトレーニングデータに含まれていることになるので、トレーニングデータにオーバーフィッテングしていることから考えると、テストの精度もそれなりに高くなるはずですが、実際にはそうなっていません。

  • テストデータでの精度が悪かったことに対する仮説
    原因として挙げられるのは、このモデルでは時系列を考慮していないことです。
    実際、下で作成した予想の順位帯と実際の順位帯のヒートマップを見ると、予想が外れた場合の実際の順位は左上から右下への対角線より下側になっているケースが多いです。
    したがって、2017年の順位は2011~2016年までの実績よりも下がる傾向があると考えられます。

損失関数の推移

損失関数を見てみます。
ここではTensorBoardからトレーニングの損失関数の値run_004-tag-loss.csvとバリデーションの損失関数の値run_004-tag-val_loss.csvをダウンロードしてグラフを作成します。

まずは最後の5エポックの数値を見てみますと、トレーニングデータは0.06まで下がりましたが、バリデーションでは0.7までしか下がりませんでしたので、オーバーフィッテングが疑われます。

import pandas as pd

USECOLS = ['Step', 'Value']

loss_train = pd.read_csv('run_004-tag-loss.csv', usecols=USECOLS)
loss_train.rename(columns={'Value': 'Train'}, inplace=True)
loss_train.Step += 1
loss_train.set_index('Step', inplace=True)

loss_validation = pd.read_csv('run_004-tag-val_loss.csv', usecols=USECOLS)
loss_validation.rename(columns={'Value': 'Validation'}, inplace=True)
loss_validation.Step += 1
loss_validation.set_index('Step', inplace=True)

loss = pd.concat(objs=[loss_train, loss_validation], axis=1)
loss.tail()
Train Validation
Step
46 0.072949 0.949500
47 0.055133 0.807800
48 0.059830 1.724219
49 0.052230 1.753363
50 0.064824 0.700812

グラフを書いてみると以下の通りです。

%matplotlib inline
from matplotlib import pyplot as plt
import seaborn as sns

plt.rcParams['figure.figsize'] = (10, 5)

ax = loss.plot(title='Loss of Inception V3')
ax.set_xlabel('Steps')
ax.set_ylabel('Loss')

f:id:ishiyama-katsuya:20180706022316p:plain

バリデーションの損失関数は25ステップ以降は下がりませんでした。

精度の推移

次に精度の推移を見てみます。
損失関数ではバリデーションデータがトレーニングデータのように下がらなかったので、精度の場合もバリデーションがトレーニングに劣っています。
同じように最後の5ステップの精度を見てみますと、トレーニングデータ(Train)で98.8%になっていますが、バリデーションデータ(Validation)は84.7%になっているのが分かります。

USECOLS = ['Step', 'Value']

acc_train = pd.read_csv('run_004-tag-acc.csv', usecols=USECOLS)
acc_train.rename(columns={'Value': 'Train'}, inplace=True)
acc_train.Step += 1
acc_train.set_index('Step', inplace=True)

acc_validation = pd.read_csv('run_004-tag-val_acc.csv', usecols=USECOLS)
acc_validation.rename(columns={'Value': 'Validation'}, inplace=True)
acc_validation.Step += 1
acc_validation.set_index('Step', inplace=True)

acc = pd.concat(objs=[acc_train, acc_validation], axis=1)
acc.tail()
Train Validation
Step
46 0.975506 0.823511
47 0.981715 0.865869
48 0.980248 0.672498
49 0.982738 0.672498
50 0.979668 0.880909

これをグラフにすると以下のようになります。

%matplotlib inline
from matplotlib import pyplot as plt
import seaborn as sns

plt.rcParams['figure.figsize'] = (10, 5)

ax = acc.plot(title='Accuracy of Inception V3')
ax.set_xlabel('Steps')
ax.set_ylabel('Accuracy')

f:id:ishiyama-katsuya:20180706022314p:plain

損失関数の場合と同じようにバリデーションの精度は途中から上がらなくなっています。
予想した順位は以下のようなデータになっています。

predict_result = pd.read_csv('predict_result_20180427112651.csv')
print(predict_result.shape)
predict_result.head(10)
(1098, 7)
character_id character_name prefecture is_previous ranking_class filename predicted_ranking_class
0 43 うなりくん 千葉県 1 1 00000031.jpg 1
1 166 ちりゅっぴ 愛知県 1 1 00002537.jpg 1
2 619 トライくん 大阪府 1 1 00000895.jpg 2
3 7 こにゅうどうくん 三重県 1 1 00000390.jpg 1
4 326 稲敷いなのすけ 茨城県 1 1 00002736.jpg 1
5 821 ジャー坊 福岡県 0 1 00003613.jpg 4
6 23 カミスココくん 茨城県 1 1 00002620.jpg 1
7 24 福井市宣伝隊長「朝倉ゆめまる」 福井県 1 1 00000877.jpg 1
8 100 滝ノ道ゆずる 大阪府 1 1 00000009.jpg 1
9 39 なーしくん 愛媛県 1 1 00001988.jpg 1

予想の精度

予測した順位帯が実際の順位帯と同じである割合を調べてみます。

real_ranking_class = predict_result.ranking_class.tolist()
predicted_ranking_class = predict_result.predicted_ranking_class.tolist()

correct_count = 0
for p, r in zip(predicted_ranking_class, real_ranking_class):
    correct_count += 1 if p == r else 0

accuracy = correct_count / float(len(predicted_ranking_class))

print('Accuracy: {:0.2f}%'.format(accuracy * 100))
Accuracy: 22.77%

結果は22.77%と低い結果になっていました。

予想した順位帯と実際の順位帯の間に相関があるかを調べる

今回のモデルが何かしらの関係性を学習できているなら、予想した順位と実際の順位の間に相関が生まれるはずです。
横軸に予想した順位帯(predicted_ranking_class)、縦軸に実際の順位帯(ranking_class)を取ってヒートマップ((いわゆる混同行列です))を作成して検証してみます。

%matplotlib inline
from matplotlib import pyplot as plt
import seaborn as sns

plt.rcParams['figure.figsize'] = (10, 7)

data = pd.pivot_table(
    data=predict_result,
    index=['ranking_class'],
    columns=['predicted_ranking_class'],
    values='character_id',
    aggfunc='count',
    fill_value=0)

sns.heatmap(data, annot=True, cmap='hot')

f:id:ishiyama-katsuya:20180706022310p:plain

ヒートマップからは正の相関があると言えます。
しかし、右上よりも左下にデータが集まっていることが気になります。
これは実際の順位が2011年から2016年のデータをもとに予想した順位よりも低い場合に起こるパターンだからです。

正の相関を作っているデータが何なのかを調査する

予想した順位帯と実際の順位帯の間には正の相関があるが、その程度は弱いものでした。
今後モデルを調整するにあたって、どのデータに対してモデルがフィットしていないのかを追求しておく必要があります。
仮説として考えられることは、「2017年に初参加したキャラクターの順位帯が全く予想できていない」ということです。
そのため、予想結果のデータを「過去にエントリーしたキャラクター」と「初参加したキャラクター」の2つに分けて、同じようにヒートマップを作成してみます。

初参加の場合

まずは初参加したキャラクターの予想データを抽出します。

is_newcomer = (predict_result.is_previous == 0)
predict_result_newcomer = predict_result.loc[is_newcomer, :]
print(predict_result_newcomer.shape)
predict_result_newcomer.head(10)
(253, 7)
character_id character_name prefecture is_previous ranking_class filename predicted_ranking_class
5 821 ジャー坊 福岡県 0 1 00003613.jpg 4
12 692 センドくん 福岡県 0 1 00003573.jpg 10
30 800 めいじろう 東京都 0 1 00003608.jpg 3
48 78 さかろん 埼玉県 0 1 00003454.jpg 10
56 947 みえきたん 三重県 0 2 00003655.jpg 4
62 1148 いせわんこ 三重県 0 2 00003722.jpg 8
75 843 ぽぽたん 埼玉県 0 2 00003620.jpg 10
78 657 ブルベリッ娘とブルピヨ 宮城県 0 2 00003564.jpg 10
82 736 なっちゃん 埼玉県 0 2 00003588.jpg 5
83 992 み~ちゅ 三重県 0 2 00003667.jpg 9

このデータから先程のヒートマップを作成してみます。

contingency_newcomer = pd.pivot_table(
    data=predict_result_newcomer,
    index=['ranking_class'],
    columns=['predicted_ranking_class'],
    values='character_id',
    aggfunc='count',
    fill_value=0)

sns.heatmap(contingency_newcomer, annot=True, cmap='hot')

f:id:ishiyama-katsuya:20180706022308p:plain

予想した順位帯と実際の順位帯に正の相関は無いので、初参加のキャラクターの予想はできていないことになります...orz

過去にエントリーしたことがある場合

同じ手順で過去にエントリーしたことがある場合のヒートマップも作成します。

is_previous = (predict_result.is_previous == 1)
predict_result_previous = predict_result.loc[is_previous, :]
print(predict_result_previous.shape)
predict_result_previous.head(10)
(845, 7)
character_id character_name prefecture is_previous ranking_class filename predicted_ranking_class
0 43 うなりくん 千葉県 1 1 00000031.jpg 1
1 166 ちりゅっぴ 愛知県 1 1 00002537.jpg 1
2 619 トライくん 大阪府 1 1 00000895.jpg 2
3 7 こにゅうどうくん 三重県 1 1 00000390.jpg 1
4 326 稲敷いなのすけ 茨城県 1 1 00002736.jpg 1
6 23 カミスココくん 茨城県 1 1 00002620.jpg 1
7 24 福井市宣伝隊長「朝倉ゆめまる」 福井県 1 1 00000877.jpg 1
8 100 滝ノ道ゆずる 大阪府 1 1 00000009.jpg 1
9 39 なーしくん 愛媛県 1 1 00001988.jpg 1
10 659 カパル 埼玉県 1 1 00000364.jpg 1
contingency_previous = pd.pivot_table(
    data=predict_result_previous,
    index=['ranking_class'],
    columns=['predicted_ranking_class'],
    values='character_id',
    aggfunc='count',
    fill_value=0)

sns.heatmap(contingency_previous, annot=True, cmap='hot')

f:id:ishiyama-katsuya:20180706022305p:plain

初参加のデータが混じっていた時よりも正の相関がはっきりと分かるようになりました。
やはり、わずかではありますが、右上よりも左下にデータが集まる傾向あります。
考えられる要因は「順位は年を追うごとに連れて下降する」ことです。

また、データは1098件なので、順位帯が1つ違うだけで順位が約100位程度ずれます
予想の精度は低いと言わざるを得ません。

順位は年々下がっていくのかを検証する

過去の順位よりも下がる傾向を見るために、複数年エントリーしたキャラクターを対象にして過去と現在の順位の差を年度ごとの箱ひげ図で図示します。
今回の学習に使ったデータは次の通りです。

meta_data = pd.read_csv('meta_data_2011_2016.csv')
meta_data.head()
year ranking_type ranking character_id character_name prefecture
0 2011 total 1 1 くまモン 熊本県
1 2011 total 2 2 いまばり バリィさん 愛媛県
2 2011 total 3 3 にしこくん 東京都
3 2011 total 4 4 与一くん 栃木県
4 2011 total 5 5 はち丸/だなも/エビザベス 愛知県

このデータをもとに過去と現在の順位の差を計算します。
処理は以下の通りです。
ranking_chgは変動した順位で、マイナスは順位が下がったことを示しています。

ranking_data = meta_data[['year', 'ranking', 'character_id', 'character_name']].copy()
ranking_data.sort_values(['character_id', 'year'], inplace=True)
ranking_data.set_index(['year', 'character_id', 'character_name'], inplace=True)
ranking_change = ranking_data.groupby(level=['character_id']).ranking.diff(1)
ranking_change *= -1  # ランキングが下がった場合(順位の値が増加)をマイナスで表示したいため
ranking_change = ranking_change[ranking_change.notnull()]
ranking_change.name = 'ranking_chg'
ranking_change = ranking_change.reset_index()

ranking_change.head()
year character_id character_name ranking_chg
0 2012 2 いまばり バリィさん 1.0
1 2012 3 にしこくん -41.0
2 2013 3 にしこくん 4.0
3 2012 4 与一くん -9.0
4 2013 4 与一くん 8.0

順位変動の箱ひげ図

sns.boxplot(x='year', y='ranking_chg', data=ranking_change)

f:id:ishiyama-katsuya:20180706022337p:plain

2011 ~ 2015年は順位の変動の中央値が0を下回っているため、全体的には順位が過去出場時よりも下がっています。
しかし、2013年までは過去の順位よりも下がる傾向が強まる傾向があったものの、2014年以降は徐々に上がる傾向に転換しているため、やはりトレンドを考慮したモデルを構築するほうが良さそうです。
トレンドが生まれた原因は定かではありませんが、エントリーした各団体がPRを狙って順位が上がる努力をしたということではないかなと考えています。
時間があれば調査してみたいです。

まとめ

正直なところ、時系列に影響されるとは考えていませんでした。
長い目で見ればトレンドはあると思いますが、5〜6年程度では影響ないだろうと決めつけていたためです。
データを収集したら箱ひげ図などで簡単に傾向を掴むというデータ分析では基本的なことを疎かにしてはいけないと、改めて認識しました。

*1:まずは単純なモデルの構築を目指します

arXivのアップデート情報から自分の興味から最も遠い論文をあえてレコメンドしてみる

2018/06/23に@shunarooさんが主催している勉強会で話した内容を加筆・修正したものです。
興味から遠い論文を”あえて”レコメンドするSlack Botを作成してみる

はじめに

皆さんはをどのように情報収集していますでしょうか?
私はfeedlyを試してみたものの、あまり開いていません。 そんな私ですが、arXivにアップロードされる論文はチェックしておきたいので、職場で使っているSlackに興味のあるものを通知してくれるBotが欲しいなと思っていました。
しかし、似たようなものばかりレコメンドしても良い気付きが得られないような気がしていたので、Botを作るまでには至りませんでした。
ところが、先日、ふと「自分のフォーカスしたい分野の中で、最も興味が無い論文からアハ体験 *1 できないだろうか?」と、思ったので実装してみることにしました。
レコメンドについては、基本的なアルゴリズムは理解しているものの、実装するのは初めてです。
もっと良い方法がある、間違いがある等はコメントして頂けると嬉しいです。

作るもの

arXivRSSのアップデート情報から自分の興味に近い論文と最も遠い論文を抽出してSlackに投稿するチャットBot

推薦アルゴリズム

TF-IDFとコサイン類似度によるコンテンツベース

使用言語と依存ライブラリ

Python 3.5以上
requests
scikit-learn pandas
BeautifulSoup4
PyYAML

作成したプログラム一式はこちらです。
GitHub - Katsuya-Ishiyama/paper_recommend: arXiv

実装手順

プログラムの全体像

これから作成するプログラムはいくつかのファイルに分けて実装していきますが、最終的にはmain.pyで統合して、

$ python main.py --fields cs stat

と実行します。
main.pyは次のようになっています。

# -*- coding: utf-8 -*-

import argparse
import pandas as pd
from recommend import recommend
from scraper import RSSScraper
from similarity import calculate_similarity_of_interest


def get_commandline_args():
    parser = argparse.ArgumentParser()
    parser.add_argument('--fields',
                        type=str,
                        required=True,
                        nargs='+',
                        help='Your interested fields.')
    return parser.parse_args()


def main():
    # TODO: revise to receive interest from a foreign source.
    interest = ['I want to predict mascots\' popularity from its photo using machine learning methodologies.']

    args = get_commandline_args()
    scraper = RSSScraper()
    _papers = []
    descriptions = []
    for field in args.fields:
        scraper.fetch_rss(field=field)
        for _abs in scraper.extract_paper_abstract():
            _papers.append(_abs)
            descriptions.append(_abs['description'])

    papers = pd.DataFrame(data=_papers)
    similarity = pd.Series(
        data=calculate_similarity_of_interest(
            interest=interest,
            descriptions=descriptions
        )
    )
    papers.loc[:, 'similarity'] = similarity

    recommend(papers, interest)


if __name__ == '__main__':
    main()

RSSから必要なデータを抽出する

arXivRSShttp://arXiv.org/rss/{your interested field} から取得できます。
ここでは統計学 (Statistics) の場合を例にして実際にブラウザでデータを取得してみます。
{your interested field}stat なので実際のURLはhttp://arXiv.org/rss/stat *2となります。

f:id:ishiyama-katsuya:20180621130033p:plainf:id:ishiyama-katsuya:20180621130029p:plain
arXivRSS

上のようにXMLで結果が返ってくるのでHTMLパーサーが必要です。
今回はrequestsBeautifulSoup *3 を使ってスクレーパーを作成します。
ファイル名はscraper.pyとします。

# -*- coding: utf-8 -*-

import logging
import re
import requests
from bs4 import BeautifulSoup
from bs4.element import Tag


logger = logging.getLogger('RSSScraper')


class RSSScraper(object):

    def __init__(self):
        self.base_url = 'http://arxiv.org/rss/{field}'
        self.field = None
        self.response = None
        self.parsed_html = None

    @property
    def url(self):
        """
        getter of url

        Returns
        -------
        url of arXiv's rss
        """
        return self.base_url.format(field=self.field)

    def fetch_rss(self, field: str):
        """ fetch rss from arXiv

        Arguments
        ---------
        field : str
            field of your expertise. (eg. stat, cs)

        Returns
        -------
        html parsed by BeautifulSoup
        """
        self.field = field
        _url = self.url
        _response = requests.get(_url)
        _status_code = _response.status_code
        if _status_code != 200:
            logging.warning('{} status code: {}'.format(_url, _status_code))
            raise requests.HTTPError(_status_code)
        else:
            self.response = _response
        self.parsed_html = BeautifulSoup(self.response.text, 'lxml')
        self._metadata_src = self.parsed_html.findAll('channel')[0]
        self._abstract_src = self.parsed_html.find_all('item')

    def _extract_metadata_internal(self, category: str) -> str:
        """
        Extract metadata from fetched html.

        Arguments
        ---------
        category: str
            category of metadata. eg: date, publisher etc.

        Returns
        -------
        extracted metadata. all data types are str.
        """
        target_tag = '<dc:{category}>'.format(category=category)
        _meta = None
        for content in self._metadata_src.contents:
            if target_tag in str(content):
                _meta = content.text
                break
        return _meta

    def extract_metadata(self):
        """ extract metadata from fetched html. """
        _meta = {
            'date': self._extract_metadata_internal('date'),
            'lang': self._extract_metadata_internal('language'),
            'publisher': self._extract_metadata_internal('publisher'),
            'subject': self._extract_metadata_internal('subject')
        }
        return _meta

    def extract_paper_abstract(self):
        _metadata = self.extract_metadata()
        for tag in self._abstract_src:
            abstract = {
                'title': self._extract_title(tag),
                'description': self._extract_description(tag),
                'link': self._extract_link(tag),
                'authors': self._extract_authors(tag)
            }
            abstract.update(_metadata)
            yield abstract

    def _extract_title(self, tag: Tag) -> str:
        return tag.title.text

    def _extract_description(self, tag: Tag) -> str:
        desc = tag.description.text
        soup = BeautifulSoup(desc.replace('\n', ' '), 'xml')
        normalised = soup.text
        return normalised

    def _extract_link(self, tag: Tag) -> str:
        link = re.findall(r'<link/>(.*)\n', str(tag))[0]
        return link

    def _extract_authors(self, tag: Tag) -> str:
        creators = re.findall(r'<dc:creator>(.*)</dc:creator>', str(tag))[0]
        creators_xml = creators.replace('&lt;', '<')
        creators_xml = creators_xml.replace('&gt;', '>')
        soup = BeautifulSoup(creators_xml, 'lxml')
        tags = soup.findAll('a')
        authors = [{'name': t.text, 'link': t.get('href')} for t in tags]
        return authors

今回は試しにType Hintsを使っています。
引数にどの型のデータを入れたらいいのか分かりやすくなるので、気に入っています*4が、python 3.5以上でないと使えないので注意が必要です。

類似度の計算を実装

自分の興味と論文の要約との類似度はそれぞれのTF-IDFからコサイン類似度を使って算出します。
ここでは理論的な部分に触れません*5が、このあたりの事を学びたいならば、入門 ソーシャルデータ 第2版 の第4章に分かりやすく書かれているのでオススメです。

TF-IDFとコサイン類似度はscikit-learnを使っています。
TF-IDFを計算するTfidfVectoriserを前回の記事をご確認下さい。
calculate_similarity_of_interest関数は自分の興味を説明した文interestと論文の要約descriptionsを引数で受け取って、それらの間の類似度を計算します。

類似度を計算するプログラムsimilarity.pyは次の通りです。

# -*- coding: utf-8 -*-

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


def calculate_similarity_of_interest(interest, descriptions):
    corpus = interest + descriptions
    vectorizer = TfidfVectorizer(ngram_range=(1, 3),
                                 stop_words='english')
    tfidf_matrix = vectorizer.fit_transform(corpus).toarray()
    interest_tfidf_matrix = tfidf_matrix[0, :]
    descriptions_tfidf_matrix = tfidf_matrix[1:, :]

    similarity = cosine_similarity(X=interest_tfidf_matrix.reshape(1, -1),
                                   Y=descriptions_tfidf_matrix)
    return similarity[0].tolist()

corpus = interest + descriptionsとする理由はコサイン類似度を求める際にTF-IDFの並びが同じになっている必要があるためです。
そのため、一度corpusを作成して、興味の説明文と論文の要旨を一緒にTfidfVectorizerに投げ込み、返ってきた行列をそれぞれに分解するという作業をしています。

レコメンドを実装

自分の興味を説明した文に最も近い論文と最も興味から遠い論文の2本を抽出して、Slackに投稿するまでを担っています。

# -*- coding: utf-8 -*-

import pandas as pd
from slack import Slack


def check_exists_similarity(data: pd.DataFrame) -> bool:
    if data.columns.isin(['similarity']).any():
        return True
    else:
        return False


def extract_most_similar_paper(data: pd.DataFrame) -> pd.DataFrame:
    if not check_exists_similarity(data):
        raise ValueError('data must contain "similarity" in its column.')
    similarity = data.similarity
    is_most_similar = similarity == similarity.max()
    return data.loc[is_most_similar, :].copy()


def extract_most_dissimilar_paper(data: pd.DataFrame) -> pd.DataFrame:
    if not check_exists_similarity(data):
        raise ValueError('data must contain "similarity" in its column.')
    data_exclude_zero = data.loc[data.similarity > 0, :]
    similarity = data_exclude_zero.similarity
    is_most_dissimilar = similarity == similarity.min()
    return data_exclude_zero.loc[is_most_dissimilar, :].copy()


# TODO: implement displaying the authors
MESSAGE = """*Today's most {recommend_type} paper of your interest*
----------
Your Interest:
> {interest}

Title:
> {title}

URL:
> {link}

Descriptions:
> {description}
"""


def generate_recommend_message(data: pd.DataFrame, recommend_type: str, interest: str) -> str:
    return MESSAGE.format(interest=interest,
                          recommend_type=recommend_type,
                          title=data.title.values[0],
                          link=data.link.values[0],
                          description=data.description.values[0])


def recommend(data: pd.DataFrame, interest) -> object:
    slack = Slack()

    _interest = interest[0]

    most_similar = extract_most_similar_paper(data)
    most_similar_message = generate_recommend_message(
        data=most_similar,
        recommend_type='similar',
        interest=_interest
    )
    slack.post_message(most_similar_message)

    most_dissimilar = extract_most_dissimilar_paper(data)
    most_dissimilar_message = generate_recommend_message(
        data=most_dissimilar,
        recommend_type='dissimilar',
        interest=_interest
    )
    slack.post_message(most_dissimilar_message)

Slackへの投稿部分を作る

Slackに投稿するライブラリは下記のように作成します。
作成にあたっては、予めアカウントにアプリを登録し、Incoming Webhookを有効にしてエンドポイントを取得しておいて下さい。

# -*- coding: utf-8 -*-

import json
import requests
import yaml


class Slack(object):
    def __init__(self):
        self._conf = self.load_conf()

    def load_conf(self) -> dict:
        with open('paper_recommend/.slack_conf.yaml', 'r') as f:
            _conf = yaml.load(f)
        return _conf

    def post_message(self, message: str) -> object:
        url = self._conf['webhook']['url']
        requests.post(url=url, data=json.dumps({'text': message}))

WebhookのURLは.slack_conf.yamlに次のように保存します。

webhook:
  url: your_endpoint

以上で実装完了です。
あとは適当なサーバーにデプロイしてcronで定期更新してください。

レコメンド結果

実際にレコメンドした結果を画像で紹介します。
今回の興味は

I want to predict mascots' popularity from its photo using machine learning methodologies.

です。
ゆるキャラグランプリの順位を画像のみで推定できないかを試していまして、それを簡単に説明した文です。

興味に最も近い論文は、なんとなく興味に近いものをレコメンドできています。

f:id:ishiyama-katsuya:20180619220049p:plain
興味に最も近い論文

対して、興味から最も遠い論文は、、、まったく興味ない。

f:id:ishiyama-katsuya:20180619220045p:plain
興味から最も遠い論文

まとめ

今回はarXivRSSで自分の興味に最も近い論文と最も遠い論文を1本ずつレコメンドするSlack Botを作りました。
最も近い論文は実際に自分の興味と重なっている部分がありますが、反対に、最も遠い論文は興味が持てませんでした。
レコメンド対象分野に統計 (stat) とコンピューター・サイエンス (cs) の2つを選びましたが、どちらの分野も幅が広いので、さらに範囲を狭める処理を追加した方が精度が上がりそうです。
また、この結果を受けて、「類似度を何らかの方法でクラスタ分けして、そのクラスタの代表的な論文を1本ずつレコメンドする」方が広くチェックできるので、当初の目的を果たせる確率も高くなりそうです。

今後の課題

  • 自分の興味(main.pyinterest)をSlack Botにメッセージを投げれば登録できるようにする。
  • 専門分野も上と同じように登録できるようにする。
  • 今回は扱える興味が1つだったが、複数扱えるようにする。
  • より洗練された推薦アルゴリズムを採用する。
  • GAEなどのクラウド上のサーバーで運用する。
  • 事後検証できるように、取得した論文の情報を保存できるようにする。

*1:https://ja.m.wikipedia.org/wiki/アハ体験_(心理学)

*2:実際にはhttp://export.arxiv.org/rss/statに飛ばされます

*3:XPathがうまく使えるパーサーがあると楽なんですが

*4:こちらにもありますが、徐々に言語が同じ構文になって来ているように思います。私の認識ではScalaのような形になっていると認識していますが、この理解であっているんでしょうか?

*5:余裕があったら追記します