強化学習 n本腕バンディットにおけるε-グリーディ行動評価手法の性能比較を再現する
はじめに
会社の勉強会で強化学習を応用したマーケティング手法の発表を聞いて「強化学習が面白そうだ」と思い、私も勉強することにしました。
勉強している本は、Sutton and Barto著、三上・皆川訳の強化学習(原題: Reinforcement Learning: An Introduction)です。
2章を読み終えましたが、この章で主に扱われているn本腕バンディット(多腕バンディット)問題の理解が怪しいと感じたので、途中に出てくるシミュレーションを再現してみて、理解度をチェックしました。
なお、この記事のほとんどは上記の本を元にして書いていることを、予めお断りしておきます。
本腕バンディット問題の定義
本腕バンディット問題 (-armed bandit problem) とは、
種類の異なる行動選択肢があり、その中から1つを選択すると報酬がもらえる場合に、ある一定期間(または回数)の間に合計報酬の期待値を最大化する
問題のことです。
日本語では「多腕バンディット問題 (multi-armed bandit problem)」とも呼ばれていますが、ここでは本に習うことにします。
報酬は決まっているわけではなく、1回毎に選択した行動に依存する確率分布にしたがって決まります。
また、それぞれの行動選択をプレイ (play) と呼びます。
本腕バンディット問題の例
この問題を具体的にイメージするにはスロットマシンを考えるのが良いです。*1
ただし、普通にイメージするスロットマシンとは違い、ここでは下図のように本のレバーを持っている機械について考えます。
図のように、1つのレバーを引くことが1回の行動選択に該当し、報酬は当たりで得られる利益に相当します。
同じレバーを引いても報酬が毎回異なることに注意してください。
このような状況下で、プレイヤーはプレイを繰り返しながら最良のレバーを探し、賞金を最大にすることを目指します。
行動価値手法
行動価値手法を説明するに当たって使う数式の準備をします。
行動の真の価値をとして、番目のプレイにおけるの推定量を[tex: Q{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)
行動価値を評価する方法は様々ですが、今回は単純な標本平均手法を使います。
行動の価値を
[tex: \displaystyle Q{t}(a) = \frac{r{1} + r{2} + \dots + r{k{a}}}{k{a}}]
と推定します。
ただし、[tex: k{a} = 0]の場合は、任意の行動に対して[tex: Q{0}(a) = 0]とします。
行動選択方法: -グリーディ手法
グリーディな手を選択し続ければ報酬が最大化できるかと言えば、そうではありません。
報酬はランダムに決定されるので、例えば10回プレイした結果で価値が最大になる行動を推測したとしても、それが間違っていることがあり得るからです。
そのため、ある一定の確率で探索的な手を打つようにして、価値の推定値を改良することを狙います。これを-グリーディ手法といいます。
-グリーディ手法の性能評価シミュレーションを再現する
ここからはSutton and Barto著、三上・皆川訳(2000) 、強化学習のp.31の図2.1で行われた-グリーディ手法の性能評価を再現していきます。
目標にするグラフを転載します。
シミュレーションの設定
以下にシミュレーションの設定を示します。
使うアルゴリズムは10本腕バンディット(「本腕バンディット問題の例」を参照)です。
ここで、は平均と分散の正規分布を表します。
-グリーディ手法のは0, 0.01, 0.1の3つを試しています。
なお、はグリーディ手法のみになることに注意してください。
- 各行動の真の価値は標準正規分布で選ぶ。
- 報酬は各プレイ毎に選択した行動によってで決定する。
- -グリーディ手法で行動選択する。ただし、1回目のプレイの場合は探索的に行動を選択する。
- 選択した行動に応じて、2で決めた報酬を受け取る。
- 今まで受け取った報酬を元に、標本平均手法で行動価値推定を行う。
- 2〜5までの設定を1プレイとして、1000回プレイする。
- 1〜6までを1セットとして、2000回繰り返す。
- プレイ回数毎に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つです。
- bandit.py
- evaluation.py
- 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を参考にしたためです。
NArmedBanditEnvironmentクラス
このクラスの役割は
- 行動の真の価値を決める
- 1つの行動が実行されたら対応する報酬を返す
- 真の価値が最も高い行動を教える
です。
2番目の役割は、上図のエージェントから行動を受け取りと対応する報酬を返す部分を担っています。
図には環境の状態をエージェントに返す部分が存在しますが、今回の設定では環境の状態は変わらないため、実装していません。
また、3番目の役割は本来ならば必要ありませんが、シミュレーションで性能を評価する上で正解は必要なので実装しています。
BanditLoggerクラス
このクラスの役割は
- プレイ毎にプレイ回数、行動、報酬、探索的な手を打つ確率、最良な行動を保持する
- 保持しているデータをCSVに吐き出す
- 保持しているデータをプレイ回数または行動毎にまとめてエージェントに渡す
です。
NArmedBanditAgentクラス
このクラスの役割は
- 行動を決定する
- 与えられた環境でプレイする
- 環境から報酬を受け取る
- 受け取った報酬をBanditLoggerに登録する
- 受け取った報酬を元に行動価値を推定する
- 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()
実行して得られるグラフは下のとおりです。
行動の最適度のグラフを描画
これは後日修正します (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()
実行すると次のグラフが得られます。
まとめ
Sutton and Barto著、三上・皆川訳(2000) のn本腕バンディット問題における-グリーディ行動評価手法の性能比較シミュレーションを再現しました。
結果は、p.31の図2.1とほぼ同じグラフが得られたため、私の理解に問題が無いことが分かりました。
課題
を上げてシミュレーションした場合に、どのように平均報酬が変わるのかを試してみようと思います。
(ブログとしてアップしないと思います)
参考文献
- Sutton and Barto著、三上・皆川訳(2000) 、強化学習、森北出版
- 久保(2019)、Pythonで学ぶ強化学習、講談社
- 「スロットマシン」(2018年10月1日 (月) 05:47 UTCの版)『ウィキペディア日本語版』。https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%AD%E3%83%83%E3%83%88%E3%83%9E%E3%82%B7%E3%83%B3
脚注
Linuxカーネル4.15.0-48-genericでe1000eが認識できなくなる問題に対処した
要約
- Linuxカーネルを
4.15.0-48-generic
にアップグレードしたらNICドライバe1000eを認識しなくなった 4.15.0-48-generic
のままでe1000eをsudo make install
で入れ直そうとするとgccがエラーを吐いて止まる- カーネルを
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でカーネルのダウングレードからの変更点
- カーネルのバージョンを今回ダウングレードするバージョンに変更
- 5番の「grub-customizerでインストールした「4.15.0-23-generic」を設定」が分かりづらかったので、私が行った設定を記載した
- 最後にe1000eのビルド手順を追加
カーネルをダウングレードする手順
- 現在のカーネルバージョンを確認
$ uname -r 4.15.0-48-generic
- ダウングレードするカーネルを検索
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"
- カーネルをインストール
sudo apt install linux-image-4.15.0-47-generic linux-headers-4.15.0-47 linux-modules-extra-4.15.0-47
- grub-customizerをインストール
sudo add-apt-repository ppa:danielrichter2007/grub-customizer sudo apt-get update sudo apt-get install grub-customizer
ターミナルで
grub-customizer
を実行して4.15.0-47-generic
を起動OS選択画面の一番上に持ってくる/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
再起動
sudo reboot
カーネルを確認して
4.15.0-47-generic
になっていることを確認
$ uname -r 4.15.0-47-generic
- 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ユーザーで複数の公開鍵を登録する
前提条件
今回の利用したインスタンスの情報
構築手順
Gitをサーバーにインストール
これはyumでインストールすればOKです。
[ec2-user@ip-xxx-xx-xx-xx ~]$ sudo yum install git
Gitoliteをサーバーにインストール
GitoliteはGitがユーザーへの権限付与や認証を行えるようにするためものです。
GitoliteはGitに認可機能を与えるもので、認証にはsshdかhttpdを使用します(復習: 認証とはユーザーが誰かを確認することで、認可とはユーザーがアクセスを許可されているかどうかを確認することです)。
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を所有していて、それぞれWindowsとLinuxをデュアルブートさせています。
このような場合、各環境毎に公開鍵を登録してGitサーバーを使えるようにしたくなります。
1ユーザーで複数の公開鍵を使えるようにする方法は次の2つです。
それぞれのメリット・デメリットは以下のとおりです。
方法 | メリット | デメリット |
---|---|---|
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.0
の
cuDNN 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で画像を巡回取得する
完成品の実行方法
今回作った画像収集スクリプトは次のコマンドをターミナルに打って実行します。
$ 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項目を集計してみます。
作成したスクリプトは次の通りです。
# -*- 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にエントリーしたキャラクターの画像から、そのキャラクターの最終的な順位を予想してみます。
結論
- 学習データでの精度は98%、バリデーションデータに対しての精度は88%、テストデータに対しての精度は23%でした。
- 新規エントリーと過去エントリー実績があるキャラクターに分けてテストデータでの予想精度を分析してみると、新規エントリーしたキャラクターの順位はまったく予想できておらず、過去にエントリーしたことのあるキャラクターに対しての予想精度も悪いという結果が得られました。
- 学習データの順位帯には2011~2016年までの順位帯の中で最も多く属した順位帯を採用したため、時系列情報が落ちてしまい、2017年の予想精度が悪くなりました。
- 後で見るように、過去エントリーしたことがあるキャラクターの順位の変動にはトレンドが存在しているため、RNN等の時系列を考慮したモデルを使う必要がありました。
- いわゆるコールドスタート問題で初参加のキャラクターの予想が悪いです。
モデルの仮定
今回のモデル構築では以下を仮定しています。
1. 人はかっこいいや可愛いなど見た目で投票する
2. トレンドの変化は無い*1
使用したデータとディープラーニングアルゴリズム
トレーニングデータ
ゆるキャラグランプリの2011年 ~ 2016年の総合ランキングの順位とキャラクターの画像
総合ランキングは期間中に最も多く属した順位帯を採用しました。
属した回数にタイが発生した場合は最も小さい順位帯を採用しています。予想するデータ
ゆるキャラグランプリの2017年の総合ランキングの順位
※ランキングがご当地と企業・その他で分かれているので、データを取得後に得票数から総合ランキングを作成しました。適用したアルゴリズム
Inception-V3
今回はkerasで提供されているAPIをそのまま適用しました。
モデルの詳細はhttps://keras.io/ja/applications/#inceptionv3をご確認下さい。
トレーニングスクリプト
トレーニングで使ったスクリプトは次の3つから構成されています。
model.py
kerasのモデルを構築します。utils.py
データのロードやトレーニングデータをシャッフルする関数が定義されています。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')
バリデーションの損失関数は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')
損失関数の場合と同じようにバリデーションの精度は途中から上がらなくなっています。
予想した順位は以下のようなデータになっています。
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')
ヒートマップからは正の相関があると言えます。
しかし、右上よりも左下にデータが集まっていることが気になります。
これは実際の順位が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')
予想した順位帯と実際の順位帯に正の相関は無いので、初参加のキャラクターの予想はできていないことになります...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')
初参加のデータが混じっていた時よりも正の相関がはっきりと分かるようになりました。
やはり、わずかではありますが、右上よりも左下にデータが集まる傾向あります。
考えられる要因は「順位は年を追うごとに連れて下降する」ことです。
また、データは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)
2011 ~ 2015年は順位の変動の中央値が0を下回っているため、全体的には順位が過去出場時よりも下がっています。
しかし、2013年までは過去の順位よりも下がる傾向が強まる傾向があったものの、2014年以降は徐々に上がる傾向に転換しているため、やはりトレンドを考慮したモデルを構築するほうが良さそうです。
トレンドが生まれた原因は定かではありませんが、エントリーした各団体がPRを狙って順位が上がる努力をしたということではないかなと考えています。
時間があれば調査してみたいです。
まとめ
正直なところ、時系列に影響されるとは考えていませんでした。
長い目で見ればトレンドはあると思いますが、5〜6年程度では影響ないだろうと決めつけていたためです。
データを収集したら箱ひげ図などで簡単に傾向を掴むというデータ分析では基本的なことを疎かにしてはいけないと、改めて認識しました。
*1:まずは単純なモデルの構築を目指します
arXivのアップデート情報から自分の興味から最も遠い論文をあえてレコメンドしてみる
2018/06/23に@shunarooさんが主催している勉強会で話した内容を加筆・修正したものです。
興味から遠い論文を”あえて”レコメンドするSlack Botを作成してみる
はじめに
皆さんはをどのように情報収集していますでしょうか?
私はfeedlyを試してみたものの、あまり開いていません。
そんな私ですが、arXivにアップロードされる論文はチェックしておきたいので、職場で使っているSlackに興味のあるものを通知してくれるBotが欲しいなと思っていました。
しかし、似たようなものばかりレコメンドしても良い気付きが得られないような気がしていたので、Botを作るまでには至りませんでした。
ところが、先日、ふと「自分のフォーカスしたい分野の中で、最も興味が無い論文からアハ体験 *1 できないだろうか?」と、思ったので実装してみることにしました。
レコメンドについては、基本的なアルゴリズムは理解しているものの、実装するのは初めてです。
もっと良い方法がある、間違いがある等はコメントして頂けると嬉しいです。
作るもの
arXivのRSSのアップデート情報から自分の興味に近い論文と最も遠い論文を抽出して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から必要なデータを抽出する
arXivのRSSは http://arXiv.org/rss/{your interested field}
から取得できます。
ここでは統計学 (Statistics) の場合を例にして実際にブラウザでデータを取得してみます。
{your interested field}
は stat
なので実際のURLはhttp://arXiv.org/rss/stat *2となります。
上のようにXMLで結果が返ってくるのでHTMLパーサーが必要です。
今回はrequests
とBeautifulSoup
*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('<', '<') creators_xml = creators_xml.replace('>', '>') 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.
です。
ゆるキャラグランプリの順位を画像のみで推定できないかを試していまして、それを簡単に説明した文です。
興味に最も近い論文は、なんとなく興味に近いものをレコメンドできています。
対して、興味から最も遠い論文は、、、まったく興味ない。
まとめ
今回はarXivのRSSで自分の興味に最も近い論文と最も遠い論文を1本ずつレコメンドするSlack Botを作りました。
最も近い論文は実際に自分の興味と重なっている部分がありますが、反対に、最も遠い論文は興味が持てませんでした。
レコメンド対象分野に統計 (stat) とコンピューター・サイエンス (cs) の2つを選びましたが、どちらの分野も幅が広いので、さらに範囲を狭める処理を追加した方が精度が上がりそうです。
また、この結果を受けて、「類似度を何らかの方法でクラスタ分けして、そのクラスタの代表的な論文を1本ずつレコメンドする」方が広くチェックできるので、当初の目的を果たせる確率も高くなりそうです。
今後の課題
- 自分の興味(
main.py
のinterest
)をSlack Botにメッセージを投げれば登録できるようにする。 - 専門分野も上と同じように登録できるようにする。
- 今回は扱える興味が1つだったが、複数扱えるようにする。
- より洗練された推薦アルゴリズムを採用する。
- GAEなどのクラウド上のサーバーで運用する。
- 事後検証できるように、取得した論文の情報を保存できるようにする。
*1:https://ja.m.wikipedia.org/wiki/アハ体験_(心理学)
*2:実際にはhttp://export.arxiv.org/rss/statに飛ばされます
*4:こちらにもありますが、徐々に言語が同じ構文になって来ているように思います。私の認識ではScalaのような形になっていると認識していますが、この理解であっているんでしょうか?
*5:余裕があったら追記します