DAY9: 非階層的クラスタリングの3つの手法
Pythonの有名な機械学習用ライブラリであるscikit-learnには様々なアルゴリズムが実装されており、ユーザーは実際に実装しなくても簡単にアルゴリズムを呼び出せるようになっている。とはいえ、原理すら知らずに使うのは危険であり、また自分の目的に適した手法を選択するためにも原理の理解は避けて通れない。今回はscikit-learnに実装されている中から、K-Means, DBSCAN, Mean-shiftの三つの手法を簡単にまとめておくことにする。
なおどのようなアルゴリズムが存在するのか、の一覧はCluster analysis - Wikipedia, the free encyclopediaによくまとまっている。scikit-learnの一覧ページ(2.3. Clustering — scikit-learn 0.17.1 documentation)も参考になるだろう。
1. K-Means
まずは大人気のK-Means...と思ったが大人気のアルゴリズムで情報に溢れているのでここでは説明しない。いつかこのアルゴリズムの背景にある、正規分布の混合分布の話をできればと思う。
2. DBSCAN
気を取り直してDBSCAN選手。
正式名称はDensity-based spatial clustering of applications with noiseであり、密度に基づいたクラスタリングを行ってくれる。applications with noiseとはどのクラスタにも属さない点(ノイズ)を認める、ということだ。
これによって何ができるのかは実際のクラスタリング結果を見てもらった方が分かりやすいだろう。
このように一定以上の密度を持つ連なった領域を一つのクラスタとみなす。灰色の点がどのクラスタにも属さないノイズである。
メリットとしては
ということがあげられる。
一方のデメリットとしては、密度を閾値としてクラスタリングを行うため、
という点がある。
原理
一定の密度以上のつながった領域を一つのクラスタとみなす、というイメージができていればアルゴリズムの原理を理解するのはさほど難しくない。原理および擬似コードはDBSCAN - Wikipedia, the free encyclopediaに分かりやすく説明されている。
クラスタリングの閾値となる密度を定義するため、初期パラメータとしてepsとmin_samplesの2つを指定する。epsが周りの点を探すための半径、min_samplesがcore-pointとみなすための最小の点数である。min-samplesはクラスタとみなすための最小の点数、という意味ではないのだが、最悪そのような理解でDBSCANを使ってもそこまで失敗しないだろう。
もう少し踏み込んだ理解をするためには、core-point, directly-reachable, reachableという概念を知る必要がある。
- 点pがcore-pointであるとは、pから半径epsの超球の中に他の点がmin_samples以上存在するということで、このときpはそれらの点へdirectly-reachableであるという。
- 点qが点pからreachableであるとは、pからqまでdirectly-reachableの関係を推移して到達できる。ということである。
ここで注意したいのがpからqに(directly-)reachableであるからといって、その逆が成り立つとは限らない(対称でない)ということ。
この図でいえば、AからB(C)はreachableであるが、その逆は成り立たない。なぜならcore-pointでない点(B, C)からは一本もdirect-reachableな関係が成り立たないからである。(directly-reachableが矢印で表現されている)
したがって対称性を持たせるためにreachableからもうひとつ上のconnectedという概念を導入する。
- 点pと点qがconnectdであるとは、ある点oが存在して、oからp,qともにreachableである。
こうして定義されたconnectedという関係が成り立つ最大の集合をクラスターとして定義する。
擬似コードはwikipediaのものを参考にされたい。計算量は単純な実装だとになる。ある点pに対して、そのneighborhoodを探索するという行為は高々2回(1回目は起点として呼ばれ、noise判定されたのち、2回目に他の点のneighborhoodとしてクラスタに追加された際)しか行われないからだ。neighborhoodの探索を効率良くやることでに下げられるようだが、良く分からなかったので今回はパスにしよう。
3. Mean-shift
最後はMean-shift選手。
これもDBSCANと同じく密度を基準に行うクラスタリングであるが、先ほどは一定の密度以上の連続した領域を一つのクラスタとみなしていたのに対し、Mean-shiftでは密度の局所極大値を検出し、局所極大点をベースとしてクラスタを作る、という点が異なる。
DBSCANに対するメリットとしては
- 一定の密度を閾値としない
ということがあげられるだろうか。
原理
scikit-learnで実装されているのは次のような単純な反復による局所極大値検出である。
- 初期値から半径bandwidthの円内にある点の平均座標を求める。
- その平均へ円の中心を移動する
したがってこのアルゴリズムのパラメタは円の半径bandwidthのみということになる。Mean-shiftという名の通り、平均(mean)への移動(shift)を繰り返すことで局所極大値を検出していく。検出された局所極大値の数がそのままクラスタの個数になり、それぞれの点のラベリングは、それぞれの点を初期値とした場合にどの点に収束するかによって行われる。
とはいえ初期値として全てのデータ点を選んで収束するまで試す、というのは少々効率が悪く、そこを効率化してくれるパラメタがseeds、またはbin_seedingだ。seedsとして初期点集合を自分で与えるか、bin_seeding=Trueとして、初期点集合をscikit-learnに自動で選んでもらう、という方法がある。bin_seeding=Trueとした場合は、データ点をbandwidthに基づくサイズのグリッドに切り分け、それぞれから代表点を選んでくれるようだ。
初期点を選抜した場合に、選ばれなかった点のラベルがどうなるかはコードを深追いしていないので分からなかった。おそらく最も近いseedと同じにする、などの仕組みだと思うが、興味がある方はgithubに公開されているコードを追ってみるのも良いだろう。
以上がscikit-learnに実装されている代表的な非階層的クラスタリングの概要になる。次回はこれらの手法を実際のデータに対して適用してみよう。
Day8: Pythonでの初めてのログ出力
今日はPythonでのログ出力の話をしようと思う。やはりプログラムの自動化を目指すうえで、実行結果に異常がなかったかを一目で確認できるログ出力機能は必須であろう。
Pythonにはログ記録システムをサポートするloggingモジュールが標準ライブラリとして備えられている。公式のチュートリアル(Logging HOWTO — Python 3.5.1 ドキュメント)もあり、簡単に実装ができる...といいたいところだが、少しクセがあるのでここで噛み砕いて説明できれば、と思う。
まず上のチュートリアルだが、初級と上級のチュートリアルに分かれている。当然のように初級を参照し、「ファイルへのlogging」というセクションを参考にしながら次のようなプログラムを書き上げた。
import fft import logging logging.basicConfig(filename='example.log',level=logging.DEBUG) fft.PATH_TO_DB="./db/P1415.sqlite3" fft.SELENIUM=True try: m=fft.Match(matchID=matchID,doScrape=True) except: logging.error("matchID"+str(matchID)+" scraping failed") else: logging.info("matchID"+str(matchID)+" scraping successed")
fftが自作のスクレイピング用モジュールで、その中にもloggingでログ出力を仕込んだ。ログの出力はlogggin.debug()やlogging.error()などでイベントのレベルを設定でき、最初のbasicConfigでどのレベルまで捉えるか、というのを設定できる。なんだ、簡単じゃないか、と思って実行し、example.logに出力されたログを確認してみると、、、
... DEBUG:selenium.webdriver.remote.remote_connection:Finished Request ...
こんな感じにfftそのもののログだけでなく、fftで使用している外部ライブラリであるseleniumから出力されるログも、大量に流れ込んでしまっているではないか。これではfftモジュールの動作確認が全く一目で分からない。どうやったら分けることができるのか、ぶち切れながらチュートリアルを読み進め、ついには上級まで踏み込んでようやく次のようなことが分かった。
ログ機能は、ログを直接発生させるロガーと、ロガーを受け取って出力先を決定するハンドラがある。上のbasicConfigでやっていたのはDEBUGレベルまで捉えるハンドラをルートにセットする、ということで、fftだけでなくseleniumライブラリにセットされていたロガーの出力も担当してしまっていた。
つまり、fftモジュールのログをseleniumと切り分けて見たいのであれば、fftモジュール内でロガーと独自のハンドラを用意して、そのハンドラで出力先を指定してやればいいということになる。したがって以下のコードをfftモジュールに追加した。
from logging import getLogger,FileHandler,StreamHandler,DEBUG,INFO logger = getLogger(__name__) _fhandler = FileHandler("./log/fft.log",'w') _shandler = StreamHandler() _fhandler.setLevel(DEBUG) _shandler.setLevel(INFO) logger.setLevel(DEBUG) logger.addHandler(_fhandler) logger.addHandler(_shandler)
ファイルハンドラとストリームハンドラの二つのハンドラを用意し、異なるレベルにセットしてあげることで、ストリームでは効率的に状態を確認でき、精査したい場合はファイルのログを確認すれば良い、という仕組みだ。ファイル出力されたfft.logを確認してみると次のようになっていた。
1:BEGIN scrapeing Arsenal 4 - 1 West Bromwich Albion scraped__init__scrape_summary,took 27.36544179916382 scraped__init__scrape_stats,took 34.192564487457275 scraped__init__scrape_stats,took 30.82896661758423 scraped__init__scrape_passes,took 21.500115394592285 scraped__init__scrape_passes,took 18.02293348312378 1:DONE scraping successfully Possession: 64.4 - 35.6, Pass: 662 - 391
これなら情報も一目で分かる。
なお今回の話については
qiita.com
でも同じように扱われている。参考にさせていただきました。
余談だが、seleniumのログ機能では、ロガーのみが設定されていてハンドラは設定されていない。したがってlogging.basicConfigでルートハンドラを設定しなくては読み取れない、という状況だと思うのだが、これってどうなんだ、と思ったり。
DAY7: SeleniumとBeautifulSoup4を使ったPython3でのWebスクレイピング
始動から一ヶ月半以上、想定より遥かに時間がかかってしまったが、ようやくデータ収集を行うスクリプトの目処が立ったのでここに記す。
まず、今回のプログラムに使用したライブラリについて。
DAY1 : Scrape Shots - RedandWhiteDaysにおいては、urllib.requestをhtmlの取得に、reをデータの抽出に使うという原始的なデータの取得を行っていたが、最終的にSeleniumとBeautifulSoup4という外部ライブラリを組み合わせて使うことにした。
Seleniumはブラウザの自動操作を可能にするライブラリでPython以外にも様々な言語に対応している有名なライブラリだ。Seleniumを使うことにした理由は、Webスクレイピングをサーバー側に感知されにくくするためである。以前DAY4 : Request as Fox - RedandWhiteDaysでユーザーエージェントを偽装してhtmlファイルのGETリクエストを送る話をしたが、ブラウザを使ってアクセスした場合は、目的のhtmlファイルを読み込みながら必要な画像ファイルなどを適宜ダウンロードするという処理が行われるのに対し、urllib.requestからスクレイピングをした場合は、htmlファイルに対してしかGETリクエストが行われないため、UAを変更したところで明らかに不自然なアクセスログになってしまうという問題がある。その問題を解決するため、firefoxブラウザをSeleniumを使って起動し、htmlファイルを取得することにした。
from selenium import webdriver driver=webdriver.Firefox() driver.get(URL) html=driver.page_source.encode('utf-8') driver.quit()
BeautifulSoup4の採用に関しては理由は明確で、この方が明らかにデータの抽出および切り出しが容易になるからだ。
下の例ではまずclass名が'results'のdivタグの部分(results)を抜き出し、さらにそこからclass名が'home'のdivタグで囲まれた中からclass名が'goal'または'penalty'のspanタグを抜き出す、という作業を行っている。
from bs4 import BeautifulSoup soup=BeautifulSoup(html,'html.parser') results=soup.find('div',class_='results') home=results.find('div',class_='home').find_all('span',class_=['goal','penalty'])
抜き出したタグから文字に当たる部分を抜き出したり、hrefなどの属性を取得するのも簡単に出来る。もちろん正規表現を用いたタグの指定にも対応している。
score=scorewrapper.find('span',class_='score').string#文字部分を取得 dct['x1']=float(line.get('x1'))#.get('x1')でx1属性を取得
これら2つのライブラリを軸に、1試合あたり5ページのクローリングを行い、およそ70 KBほどのデータを取得するようになった。
クローリングの時間間隔の調節は以下のような関数を噛ませることによって行った。
import time def time_control(self,func,dct): start=time.time() func(**dct) end=time.time() duration=end-start if duration<10: time.sleep(10-duration)
funcが実際にスクレイピングを行う関数、dctがその関数の引数のディクショナリだ。
このように時間管理を一括して行うことで、例えばクロール間隔に乱数の要素を加えて少し複雑したい、などといった要望にも一箇所のコード変更で済む。
funcのように、別の関数の内部で実行してもらう関数のことをコールバック関数と呼ぶらしい。少し複雑な実行時間の計測をしたい際などにも便利そうなので覚えておこう。
以上、データ収集の目処が立った。
実際のデータ収集のステップは現在のノートPCで行うのではなく、クラスタマシンを用いて行おうかと思っているので、次回はroot権限を取らずにPython3の開発環境を構築することについての記事を書こうと思う。
プレミアリーグの試合データを自動返信するTwitter Bot
DAY6 : Twitter - RedandWhiteDaysでひな形を作成した、プレミアリーグ情報を自動返信するtwitter botを完成させ、本ブログのアカウントで24時間運用するようにしたので、本記事では具体的な機能を説明しようと思う。
本botには以下の6つの機能が搭載されている。
1. ハッシュタグで指定された試合結果の返信
2. ハッシュタグで指定されたマッチプレビューの返信
3. ハッシュタグで指定されたチームの今シーズンの情報
4. 直近の10試合の試合結果の一覧
5. 次のマッチデーの試合予定一覧
6. チーム名とチームコードの対応表
1,2. ハッシュタグで指定された試合結果(マッチプレビュー)の返信
試合の指定方法は、前回同様、プレミアリーグ公式が採用しているハッシュタグを採用することにした。例えばホームチームがSunderlandでアウェイチームがNewcastleの試合の試合結果にアクセスしたい場合、SunderlandのチームコードはSUNで、NewcastleのチームコードはNEWなので、#SUNNEWと本ボットにリプライを送ればよい。チームコードとの対応が分かりにくいチームに関しては6番目の機能で対応を見られるようにしているので、活用されたい。
その試合がすでに行われていた場合、以下の情報が返信される。
1.両チームの正式名称
2.試合日
3.試合結果(ハーフタイム時)
4.シュート本数(on Target)
一方、その試合がまだ行われていなかった場合、以下の情報が返信される。
1.両チームの正式名称
2.試合日
3.試合結果予測(パーセンテージ)
4.前回対戦の結果
3. ハッシュタグで指定されたチームの今シーズンの情報
チームの指定方法は1,2と同じく、英語の大文字3文字で表記されたものになる。
以下の情報が返信される。
1.正式名称
2.スタジアム名
3.過去5試合の結果。新しいものから順に記載されている。
4.今シーズンのホームでの戦績
5.今シーズンのアウェーでの戦績
6.ホームでの一試合あたりの平均得点(Hs)と平均失点(Hg)
7.アウェーでの一試合あたりの平均得点(As)と平均失点(Ag)
4. 直近の10試合の試合結果の一覧
'last'と半角英文字でリプライを送ると、直近10試合の試合結果と、それぞれのハッシュタグが返信される。
5. 次のマッチデーの試合予定一覧
'next'と半角英文字でリプライを送ると、次のマッチデーのキックオフ日時と、それぞれのハッシュタグが返信される。
6. チーム名とチームコードの対応表
'name'と半角英文字でリプライを送ると、個人的に対応が分かりにくいと思ったチームのチームコードが返信される。
全チームの対応はこちら。
以上です。
全角の空白がツイートに入ってしまうと上手く作動しませんのでよろしくお願いします。
現在このbotはheroku上で24時間稼動しております。
使いづらい点や、追加して欲しい情報、不具合等ありましたら是非連絡していただけると幸いです。
DAY6 : TwitterのStreaming APIを使ってリプライに自動返信するBotをPython3で作成する
ポアソン分布を用いたサッカーの得点予想モデルにより、試合結果の予測を行えるようになった。この予想結果に簡単にアクセスできるように、TwitterのStreaming APIを利用した自動返信機能を本ブログのツイッターアカウントに搭載することにした。
したがって今回は、Python3でTwitterのStreaming APIを利用して、自分宛てのリプライに返信するbotを実装するまでの過程を記す。
python3でtwitter APIを利用するにはいくつかライブラリがあるようだが、
Python3でのtwitter投稿など - Qiitaの記事を参考に、sixohsix氏が開発したライブラリを活用することにした。このライブラリのインストールや簡単な使い方は上の記事で簡単に説明されている。また実際にTwitter Developersでアプリ登録して、Access Tokenなどを取得してそれをプログラムに組み込むまでのステップは、[Python] Python3でTwitterの検索APIを利用する - YoheiM .NETなどのサイトに載っているコードを参考にすれば分かりやすいだろう。今回は自動返信botということでREST APIではなくStreaming APIを使うことになる。この使い方などはPythonでTwitterのStreamingを取得する - kasajeiのブログに詳しい。
さて、今回のリプライ自動返信botには以下のような機能をつけることを考えた。
まず第一にリプライで指定された試合の予想スコアを返信する機能だ。試合の指定方法はプレミアリーグ公式推奨の #SUNNEW というハッシュタグ形式を採用することにした。
もうひとつ今節と次節でどんな試合があるのか、ハッシュタグの一覧を取得する機能。これは'last'で最新10試合の試合結果とそのハッシュタグを、'next'で次の10試合の試合結果とそのハッシュタグを取得できるようにした。
これらの機能を実装するためにそれぞれの試合の情報が一行ずつ記載されているcsvファイルを作成し、そこのデータをもとにbotが自動返信する仕組みをとった。
その作成のために必要になるのが今シーズンの試合一覧だ。これが纏められているcsvファイルなどを探したがなかなか見つけられなかったので、公式の2015/16 Fixture List Released | Barclays Premier Leagueから捨てpythonスクリプトにてcsvファイルを自前で用意した。
上の手順により作成されたcsvファイル(E0fix.csv)に、England Football Results Betting Odds | Premiership Results & Betting Oddsからダウンロードできるプレミアリーグの試合結果のcsvファイル(E0.csv)を参考にして、PredictionやResultを書き込むスクリプトを書いた。試合があるたびにE0.csvは更新されていくので、最新のE0.csvを参考にE0fix.csvファイルを更新していくことになる。内部で得点予想モデルの計算もしているので分かりにくいとは思うが、一応コードを載せておく。
import pandas as pd import numpy as np import math def get_abb(team): abb={'Bournemouth':'BOU','Aston Villa':'AVL','Arsenal':'ARS','West Ham':'WHU','Chelsea':'CHE','Swansea':'SWA','Everton':'EVE','Watford':'WAT','Leicester':'LEI','Sunderland':'SUN','Man United':'MUN','Tottenham':'TOT','Newcastle':'NEW','Southampton':'SOU','Norwich':'NOR','Crystal Palace':'CRY','Stoke':'STK','Liverpool':'LIV','West Brom':'WBA','Man City':'MCI'} return abb[team] def predict_goal(lam): g1=math.floor(lam) g2=math.ceil(lam) p1=mypoisson(lam,g1) p2=mypoisson(lam,g2) if p1>p2: return g1,p1 else: return g2,p2 def mypoisson(lam,x): return math.exp(-lam)*math.pow(lam,x)/math.factorial(x) #open csv fix=pd.read_csv('csvdata/E0fix.csv',index_col=0)#fix for fixture res=pd.read_csv('csvdata/E0.csv')#res for result #update result num=res.shape[0] for i in range(num): HomeTeam,AwayTeam,FTHG,FTAG,HTHG,HTAG=res.ix[i,['HomeTeam','AwayTeam','FTHG','FTAG','HTHG','HTAG']] HomeTeam=get_abb(HomeTeam) AwayTeam=get_abb(AwayTeam) index=HomeTeam+AwayTeam Result=HomeTeam+' '+str(FTHG)+' - '+str(FTAG)+' ('+str(HTHG)+' - '+str(HTAG)+') '+AwayTeam SResult=str(FTHG)+' - '+str(FTAG) fix.ix[index,'Result']=Result fix.ix[index,'SResult']=SResult teams=list(res["HomeTeam"].head(10))+list(res["AwayTeam"].head(10)) df=pd.DataFrame(index=teams,columns=['Hs','Hg','As','Ag']) #calc Hs,Hg,Ag,As for team in teams: home=(res[res.HomeTeam==team]).ix[:,['FTHG','FTAG']] away=(res[res.AwayTeam==team]).ix[:,['FTHG','FTAG']] homemean=home.mean() awaymean=away.mean() homemean.index = ['Hs', 'Hg'] awaymean.index=['Ag','As'] df.ix[team,:]=pd.concat([homemean, awaymean]) df=df.sort_index() #calc mean mean=res.ix[:,['FTHG','FTAG']].mean() #calc HA,HD,AA,AD for i in range(df.shape[0]): df.ix[i,'Hs']=df.ix[i,'Hs']/mean['FTHG'] df.ix[i,'Hg']=df.ix[i,'Hg']/mean['FTAG'] df.ix[i,'As']=df.ix[i,'As']/mean['FTAG'] df.ix[i,'Ag']=df.ix[i,'Ag']/mean['FTHG'] df.columns=['HA','HD','AA','AD'] #update Prediction,SPrediction for i in range(380): if isinstance(fix.ix[i,'Result'],str)==False:#if 'Result'==nan, update Prediction HomeTeam=fix.ix[i,'HomeTeam'] AwayTeam=fix.ix[i,'AwayTeam'] Hlam=mean['FTHG']*df.ix[HomeTeam,'HA']*df.ix[AwayTeam,'AD'] Alam=mean['FTAG']*df.ix[HomeTeam,'HD']*df.ix[AwayTeam,'AA'] preFTHG=predict_goal(Hlam) preFTAG=predict_goal(Alam) Prediction=get_abb(HomeTeam)+' '+str(preFTHG[0])+' - '+str(preFTAG[0])+' ({:.2%}) '.format(preFTHG[1]*preFTAG[1])+get_abb(AwayTeam) SPrediction=str(preFTHG[0])+' - '+str(preFTAG[0]) fix.ix[i,'Prediction']=Prediction fix.ix[i,'SPrediction']=SPrediction fix.to_csv('csvdata/E0fix.csv')
まずResultを更新してから、Resultの記入されていない行のPredictionを更新する、という形にすることで、もう結果が出ている試合のPredictionは更新されず、試合当時の予測をそのまま残せるようにしてあるのが工夫点だ。
更新されたE0fix.csvはこんな感じ。
このE0fix.csvを読み取ってリプライに返信していくことになる。
リプライのコードは以下のようになった。
import json import pandas as pd from twitter import * with open("secret.json") as f: secretjson=json.load(f) fix=pd.read_csv('E0fix.csv',index_col=0) for comminggame in range(380): if isinstance(fix.ix[comminggame,'Result'],str)==False: break replylast='\n' replynext='\n' for i in range(comminggame-10,comminggame): replylast=replylast+fix.ix[i,'SResult']+' #'+fix.index[i]+'\n' for i in range(comminggame,comminggame+10): replynext=replynext+fix.ix[i,'SPrediction']+' #'+fix.index[i]+'\n' print(replylast) print(replynext) print(len(replylast),len(replynext)) auth=OAuth(secretjson["access_token"],secretjson["access_token_secret"],secretjson["consumer_key"],secretjson["consumer_secret"]) tweet=Twitter(auth=auth) stream=TwitterStream(auth=auth,domain="userstream.twitter.com") for msg in stream.user(): if 'in_reply_to_screen_name' in msg and 'text' in msg: if msg['in_reply_to_screen_name']=='1879redandwhite': hashtag='' if len(msg['entities']['hashtags'])!=0: hashtag=msg['entities']['hashtags'][0]['text'] text=(msg['text'].split(' '))[1] if hashtag in fix.index: Date=fix.ix[hashtag,'Date'] HomeTeam=fix.ix[hashtag,'HomeTeam'] AwayTeam=fix.ix[hashtag,'AwayTeam'] Prediction=fix.ix[hashtag,'Prediction'] Result=fix.ix[hashtag,'Result'] reply='\n'+HomeTeam+' vs '+AwayTeam+'\n'+'Date: '+Date+'\n'+'Prediction: '+str(Prediction)+'\n'+'Result: '+str(Result)+'\n' elif text=='last': reply=replylast elif text=='next': reply=replynext else: reply='could not find any match.' status='@'+msg['user']['screen_name']+' '+reply tweet.statuses.update(status=status,in_reply_to_status_id=msg['id']) print(status)
リプライの文言は最後のif節によって定義されており、最優先で認識するのが一番目のハッシュタグで、そのハッシュタグが特定の試合を指定していた場合、その試合の詳細なデータが返ります。ハッシュタグが認識されなかった場合で、リプライの一番目の単語が'last'か'next'の場合、最新10試合の試合結果、もしくは次10試合の予測が返ります。
その他はelse節で処理されます。
気をつけて欲しいのがリプライを送る際の区切り文字が半角スペースでないといけない、という点。
なるべく走らせるようにはしているものの、現状このプログラムは私の気が向いたときにしか動いていない。次回はこのbotをherokuで安定して運用できるようにしたい。
Prediction 1 : ポアソン分布を用いたサッカーの得点予想モデル
前回得られた、ホームでの平均得点(Hs:Home scored)、ホームでの平均失点(Hg:Home gave)、アウェーでの平均得点(As:Away scored)、アウェーでの平均失点(Ag:Away gave)の4つのデータを元に得点モデルを構築し、ポアソン分布に基づいて試合結果の予測を行ってみた。
まず一般にサッカーの試合の得点数はポアソン分布に従うと言われている。
ポアソン分布は次の式で定義される確率分布である。
P(X=k)は、単位時間に平均でλ回発生する事象がちょうどk回発生する確率、を意味しており、サッカーでいうと90分間で平均λ得点するチームがちょうどk得点する確率、を与えてくれることになる。したがってあらかじめ平均何得点するのか予想できていれば、その試合でk得点する確率が予想できる、ということだ。
そこで、Hs,Hg,As,Agの4つのデータを元にその試合におけるλを計算することが得点予想モデルのテーマとなる。
今回使用するデータは前回と同じく14/15シーズンのプレミアリーグ。学習用データとして第34節までの結果を利用し、残りの4節分(40試合)をテスト用データとした。
Pandasにより集計した第34節までの結果は以下。
Hs | Hg | As | Ag | |
---|---|---|---|---|
Arsenal | 2.3125 | 0.75 | 1.52941 | 1.17647 |
Aston Villa | 1 | 1.41176 | 0.666667 | 1.44444 |
Burnley | 0.777778 | 1.16667 | 0.75 | 1.9375 |
Chelsea | 1.9375 | 0.4375 | 2.05556 | 1.11111 |
Crystal Palace | 1.11765 | 1.47059 | 1.35294 | 1.29412 |
Everton | 1.58824 | 1.05882 | 1.05556 | 1.55556 |
Hull | 1.125 | 1.25 | 0.777778 | 1.38889 |
Leicester | 1.125 | 1.3125 | 1 | 1.83333 |
Liverpool | 1.58824 | 0.941176 | 1.17647 | 1.23529 |
Man City | 2.11765 | 0.823529 | 2 | 1.29412 |
Man United | 2.35294 | 0.764706 | 1.11765 | 1.23529 |
Newcastle | 1.35294 | 1.52941 | 0.764706 | 1.82353 |
QPR | 1.16667 | 1.27778 | 1.0625 | 2.25 |
Southampton | 1.72222 | 0.666667 | 1 | 0.875 |
Stoke | 1.35294 | 1.23529 | 0.941176 | 1.23529 |
Sunderland | 0.823529 | 1.52941 | 0.75 | 1.4375 |
Swansea | 1.35294 | 1.05882 | 1.05882 | 1.52941 |
Tottenham | 1.70588 | 1.35294 | 1.52941 | 1.52941 |
West Brom | 1.16667 | 1.44444 | 0.6875 | 1.25 |
West Ham | 1.35294 | 0.941176 | 1.11765 | 1.52941 |
全チーム通算で計算したHs、Hg、As、Agの平均は次のようになっていた。
aveHs | aveHg | aveAs | aveAg |
---|---|---|---|
1.447059 | 1.123529 | 1.123529 | 1.447059 |
となる。なおその定義からaveHs=aveAg、aveHg=aveAsになっていることに注意。
このデータに対し、各チームのホームでの攻撃力(HA)、ホームでの守備力(HD)、アウェーでの攻撃力(AA)、アウェーでの守備力(AD)を次のように定義する。
ここで注意したいのが、攻撃力は高いほど得点をとりやすいことを示しているのに対し、守備力は低いほど失点しにくいことを示している、ということ。
例えばHA=1.5の場合、ホームで平均の1.5倍得点を取りやすく、HD=1.5ならば、ホームで平均の1.5倍得点を与えやすいことになる。
この数値をもとにλを算出しよう。今回λを以下のように定義した。
ホームで平均のHA倍点を取りやすいチームとアウェーで平均のAD倍失点しやすいチームが対戦した場合、ホームチームは平均の倍の得点をするだろう、という仮定のもとλを算出する、というのが上のλの定義が意味することだ。
例としてテスト用データの1試合目、Leicester(home) vs Newcastle(away)の試合を考えてみよう。
第34節までの結果に基づき、2チームの攻撃力、守備力は次のようになっている。
HA | HD | AA | AD | |
---|---|---|---|---|
Leicester | 0.777439 | 1.16819 | 0.890052 | 1.26694 |
Newcastle | 0.934959 | 1.36126 | 0.680628 | 1.26016 |
したがって
このλをパラメタにして両チームの得点をポアソン分布で予測したものが次のグラフだ。
したがって最も可能性の高いスコアはLEI 1 - 0 NEWということになる。
このように定義されるλをもとに得点を予測してみた結果が以下。
HomeTeam | AwayTeam | Prediction | Result | Errata | |
---|---|---|---|---|---|
0 | Leicester | Newcastle | 1 - 0 (14.06%) | 3 - 0 (4.71%) | 1 |
1 | Liverpool | QPR | 2 - 0 (10.60%) | 2 - 1 (9.43%) | 1 |
2 | Man United | West Brom | 2 - 0 (16.95%) | 0 - 1 (3.84%) | 0 |
3 | Sunderland | Southampton | 0 - 1 (21.21%) | 2 - 1 (2.63%) | 0 |
4 | Swansea | Stoke | 1 - 0 (14.99%) | 2 - 0 (8.66%) | 1 |
5 | West Ham | Burnley | 1 - 0 (15.79%) | 1 - 0 (15.79%) | 2 |
6 | Chelsea | Crystal Palace | 1 - 0 (18.09%) | 1 - 0 (18.09%) | 2 |
7 | Tottenham | Man City | 1 - 2 (8.66%) | 0 - 1 (4.71%) | 1 |
8 | Hull | Arsenal | 0 - 1 (12.44%) | 1 - 3 (5.49%) | 1 |
9 | Aston Villa | West Ham | 1 - 1 (12.66%) | 1 - 0 (9.02%) | 0 |
10 | Crystal Palace | Man United | 0 - 1 (13.05%) | 1 - 2 (9.11%) | 1 |
11 | Everton | Sunderland | 1 - 0 (16.06%) | 0 - 2 (2.54%) | 0 |
12 | Hull | Burnley | 1 - 0 (14.50%) | 0 - 1 (8.03%) | 0 |
13 | Leicester | Southampton | 0 - 1 (18.40%) | 2 - 0 (3.64%) | 0 |
14 | Newcastle | West Brom | 1 - 0 (14.25%) | 1 - 1 (13.33%) | 0 |
15 | Stoke | Tottenham | 1 - 1 (10.71%) | 3 - 0 (2.17%) | 0 |
16 | Chelsea | Liverpool | 1 - 0 (20.01%) | 1 - 1 (9.17%) | 0 |
17 | Man City | QPR | 3 - 0 (10.15%) | 6 - 0 (3.02%) | 1 |
18 | Arsenal | Swansea | 2 - 0 (12.79%) | 0 - 1 (3.03%) | 0 |
19 | Burnley | Stoke | 0 - 0 (19.37%) | 0 - 0 (19.37%) | 2 |
20 | Liverpool | Crystal Palace | 1 - 1 (12.52%) | 1 - 3 (2.68%) | 0 |
21 | QPR | Newcastle | 1 - 0 (14.16%) | 2 - 1 (9.05%) | 1 |
22 | Southampton | Aston Villa | 1 - 0 (20.74%) | 6 - 1 (0.17%) | 1 |
23 | Sunderland | Leicester | 1 - 1 (12.83%) | 0 - 0 (9.03%) | 1 |
24 | Tottenham | Hull | 1 - 0 (12.48%) | 2 - 0 (10.22%) | 1 |
25 | West Ham | Everton | 1 - 0 (14.03%) | 1 - 2 (5.48%) | 0 |
26 | Man United | Arsenal | 1 - 1 (10.38%) | 1 - 1 (10.38%) | 2 |
27 | Swansea | Man City | 1 - 1 (10.33%) | 2 - 4 (1.74%) | 0 |
28 | West Brom | Chelsea | 0 - 2 (10.15%) | 3 - 0 (0.35%) | 0 |
29 | Arsenal | Sunderland | 2 - 0 (16.08%) | 0 - 0 (6.09%) | 0 |
30 | Arsenal | West Brom | 1 - 0 (17.13%) | 4 - 1 (2.61%) | 1 |
31 | Aston Villa | Burnley | 1 - 0 (13.68%) | 0 - 1 (9.63%) | 0 |
32 | Chelsea | Sunderland | 1 - 0 (20.97%) | 3 - 1 (3.78%) | 1 |
33 | Crystal Palace | Swansea | 1 - 1 (12.57%) | 1 - 0 (9.07%) | 0 |
34 | Everton | Tottenham | 1 - 1 (10.68%) | 0 - 1 (6.36%) | 0 |
35 | Hull | Man United | 0 - 1 (13.73%) | 0 - 0 (11.04%) | 0 |
36 | Leicester | QPR | 1 - 1 (10.91%) | 5 - 1 (0.85%) | 0 |
37 | Man City | Southampton | 1 - 0 (17.10%) | 2 - 0 (10.95%) | 1 |
38 | Newcastle | West Ham | 1 - 1 (11.37%) | 2 - 0 (5.34%) | 0 |
39 | Stoke | Liverpool | 1 - 1 (12.91%) | 6 - 1 (0.04%) | 0 |
計40試合のうち、スコアを含め完全に的中させたのが4試合、勝敗をあてたのが14試合という結果になった。
ここからは今回のモデルの問題点と今後の課題について考察しよう。
テストデータのうち、片方のチームが3得点以上した試合を抜き出してみると次のようになった。
HomeTeam | AwayTeam | Prediction | Result | Errata | |
---|---|---|---|---|---|
0 | Leicester | Newcastle | 1 - 0 (14.06%) | 3 - 0 (4.71%) | 1 |
8 | Hull | Arsenal | 0 - 1 (12.44%) | 1 - 3 (5.49%) | 1 |
15 | Stoke | Tottenham | 1 - 1 (10.71%) | 3 - 0 (2.17%) | 0 |
17 | Man City | QPR | 3 - 0 (10.15%) | 6 - 0 (3.02%) | 1 |
20 | Liverpool | Crystal Palace | 1 - 1 (12.52%) | 1 - 3 (2.68%) | 0 |
22 | Southampton | Aston Villa | 1 - 0 (20.74%) | 6 - 1 (0.17%) | 1 |
27 | Swansea | Man City | 1 - 1 (10.33%) | 2 - 4 (1.74%) | 0 |
28 | West Brom | Chelsea | 0 - 2 (10.15%) | 3 - 0 (0.35%) | 0 |
30 | Arsenal | West Brom | 1 - 0 (17.13%) | 4 - 1 (2.61%) | 1 |
32 | Chelsea | Sunderland | 1 - 0 (20.97%) | 3 - 1 (3.78%) | 1 |
36 | Leicester | QPR | 1 - 1 (10.91%) | 5 - 1 (0.85%) | 0 |
39 | Stoke | Liverpool | 1 - 1 (12.91%) | 6 - 1 (0.04%) | 0 |
これを見て分かるのが、今の得点予測モデルでは結果として大差がつくような試合でも1-0や1-1などの予測をしがちだということ。ポアソン分布に基づいて得点を予測する限り、片方のチームが3得点以上するだろうという予測にはなかなかならない。
しかしながら現実のサッカーでは、昨日のイラン戦もそうだったように、一度均衡が崩れると得点が連続して一気に勝負がつくということが往々にしてある。
本田圭介も言いました。「ゴールはケチャップや」、と。
今後はこのようなケチャップ効果を考慮したモデル作りができればと思う。以上です。
今回の記事は以下の記事を参考にさせていただきました。ありがとうございます。
yeoman.hatenablog.com
DAY5 : Pandasを使ってみた
今回はwebスクレイピングから一度離れ、次回に得点予想モデルを実装するためのデータの下準備を行おう。
PythonにはPandasというデータ分析用の有名なライブラリがあり、今回はそれを使ってみることにした。
データセットは次のサイトのプレミアリーグの14/15シーズンのデータを使用している。
England Football Results Betting Odds | Premiership Results & Betting Odds
このサイトで手に入るcsvファイルは試合結果だけでなく、シュートの本数など簡単なstatsも記載されているので有用だ。どこで海外サッカーのスタッツが手に入るかはこちらに良くまとまっているので参考にされたい。
www.jokecamp.com
今回の目標はこのcsvファイルから、全20チームのHS(ホームでの平均得点)、HG(ホームでの平均失点)、AS(アウェーでの平均得点)、AG(アウェーでの平均失点)をシーズンを通して集計することである。
まずはPandasを用いてcsvファイルを展開する。
import pandas as pd import numpy as np data=pd.read_csv('csvdata/E01415.csv')
HomeTeamカラムを利用してチーム名の一覧を取得しよう。
teams=data["HomeTeam"] teams=list(set(teams.head(50).tolist())) print(teams)
このとき全てのチームがHomeTeamに少なくとも一回は登場するように多めに50個の値をとり、それをsetに一度変換してまたリストに戻すことで、重複のない、20チームのリストを得ている。
['Southampton', 'Swansea', 'Stoke', 'QPR', 'Leicester', 'Sunderland', 'Tottenham', 'Man United', 'Aston Villa', 'Everton', 'Hull', 'Chelsea', 'Liverpool', 'Newcastle', 'Arsenal', 'Burnley', 'West Ham', 'West Brom', 'Crystal Palace', 'Man City']
次にデータを記録するための空のDataFrameオブジェクト、dfを作る。
df=pd.DataFrame(index=teams,columns=['HS','HG','AS','AG'])
タテ軸でチーム名、ヨコ軸でHS,HG,AS,AGがそれぞれ指定されている、20*4の表だ。
先ほど得られたチーム名のリスト、teamsでループをまわし、dfに情報を適宜書き込んでいく。
for team in teams: home=data[data.HomeTeam==team][['FTHG','FTAG']] away=data[data.AwayTeam==team][['FTHG','FTAG']] homemean=home.mean() awaymean=away.mean() homemean.index = ['HS', 'HG'] awaymean.index=['AG','AS'] df.ix[team,:]=pd.concat([homemean, awaymean]) df=df.sort_index()
得られたdfは次のようになった。
TEAM | HS | HG | AS | AG |
---|---|---|---|---|
Arsenal | 2.15789 | 0.736842 | 1.57895 | 1.15789 |
Aston Villa | 0.947368 | 1.31579 | 0.684211 | 1.68421 |
Burnley | 0.736842 | 1.10526 | 0.736842 | 1.68421 |
Chelsea | 1.89474 | 0.473684 | 1.94737 | 1.21053 |
Crystal Palace | 1.10526 | 1.42105 | 1.36842 | 1.26316 |
Everton | 1.42105 | 1.10526 | 1.10526 | 1.52632 |
Hull | 1 | 1.26316 | 0.736842 | 1.42105 |
Leicester | 1.47368 | 1.15789 | 0.947368 | 1.73684 |
Liverpool | 1.57895 | 1.05263 | 1.15789 | 1.47368 |
Man City | 2.31579 | 0.736842 | 2.05263 | 1.26316 |
Man United | 2.15789 | 0.789474 | 1.10526 | 1.15789 |
Newcastle | 1.36842 | 1.42105 | 0.736842 | 1.89474 |
QPR | 1.21053 | 1.26316 | 1 | 2.57895 |
Southampton | 1.94737 | 0.684211 | 0.894737 | 1.05263 |
Stoke | 1.68421 | 1.15789 | 0.842105 | 1.21053 |
Sunderland | 0.842105 | 1.42105 | 0.789474 | 1.36842 |
Swansea | 1.42105 | 1.15789 | 1 | 1.42105 |
Tottenham | 1.63158 | 1.26316 | 1.42105 | 1.52632 |
West Brom | 1.26316 | 1.36842 | 0.736842 | 1.31579 |
West Ham | 1.31579 | 0.947368 | 1 | 1.52632 |
こうして見てみるとhomeかawayかで得点力に大きな違いがあるチームが多いことが分かる。
その中で、awayにおける得点がホームを上回ったChelseaは流石である。Homeでの失点率の低さも特筆すべきものがある。
我がsunderlandは残念ながら、そもそもの得点力が低いので、それほど大きな差は無い。
ちなみにこれは余談だが、はてな記法のフォーマットに表データを変換するのはawkを使うと便利だ。
awk 'BEGIN {FS="[ ]{2,}"} {print "|", $1,"|",$2,"|",$3,"|",$4,"|",$5 }' hoge.txt
FSを二文字以上の空白文字で指定することで 、'Aston Villa'などをフィールドの切れ目と認識することを回避できる。
次回はこの数値をもとに得点を予測し、試合結果を予想するモデルで遊んでみよう。