pyparsingで競馬のコーナー通過順位をパース

5 min read読了の目安(約5100字

はじめに

競馬AIの作成過程で、netkeiba.comのコーナー通過順位をデータ化したかったので、pyparsingを使ってみました。文字列の順次解析でも実現可能と思いますので、pyparsingの極々一部のみ使用しています。

pyparsingとは

Pythonの構文解析モジュールで、

The pyparsing module is an alternative approach to creating and executing simple grammars, vs. the traditional lex/yacc approach, or the use of regular expressions. The pyparsing module provides a library of classes that client code uses to construct the grammar directly in Python code.
pyparsing GitHub

とのこと。OneOfやOptional、Groupといったクラスを組み合わせて構文解析ルールを作成して、パースします。

コーナー通過順位の構文

netkeiba.comから取得できるコーナー通過順位は、以下のような記号で表記されています。(netkeiba.comレース情報より抜粋)

数字や各記号の意味は、以下です。

  • 数字は、馬番号
  • ()内の馬同士は一馬身未満の差
  • 『,』は一馬身以上二馬身未満の差
  • 『-』は二馬身以上の差
  • 『=』は先行馬から5馬身以上の差
  • ()内の『*』は、その中で先頭だった馬
  • ()内の数字の順番は、左から順にインコース

データ化の方針

「一馬身以上二馬身未満」と幅のある表記のため、データ化にあたっては以下の数値(単位:馬身)としてデータ化します。

  • ()内の馬同士は一馬身未満の差 → 0.3
  • 『,』は一馬身以上二馬身未満の差 → 1.5
  • 『-』は二馬身以上の差 → 3.0
  • 『=』は先行馬から5馬身以上の差 → 6.0

コード

import

import numpy as np
import pandas as pd
import pyparsing as pp

pyparsingをimportします。numpy,pandasはデータ作成で使用。

静的データ定義

# DataFrame列名定義
columns = ['diff', 'horse_no']

# 差の定数(unit:馬身)
DIFF_GROUP = 0.3
DIFF_MIN = 1.5
DIFF_MID = 3.0
DIFF_MUCH = 6.0

返却するpandas.DataFrameのカラム名定義と、データ化の方針で説明した数値を定数として定義。

コーナー構文クラス

メインとなる、コーナー通過順位の構文解析クラスです。

class ParsePass():
    
    def __init__(self):
        
        # 馬番
        horse_no = pp.Word(pp.nums).setParseAction(self._horse_no_action)
        
        # 馬群
        group = pp.Suppress(pp.Literal('(')) + \
                    pp.Optional(pp.delimitedList(pp.Word(pp.nums), delim=',')) + \
                    pp.Suppress(pp.Literal(')'))
        group.ignore('*')
        group.setParseAction(self._group_action)

        # 情報要素
        element = (group | horse_no)
        
        # 前走馬との差
        diff_min = pp.Suppress(pp.Optional(pp.Literal(','))).setParseAction(self._diff_min_action) + element
        diff_mid = pp.Suppress(pp.Literal('-')).setParseAction(self._diff_mid_action) + element
        diff_much = pp.Suppress(pp.Literal('=')).setParseAction(self._diff_much_action) + element

        # 全体定義
        self._passing_order = element + pp.ZeroOrMore( diff_mid | diff_much | diff_min )
        
    def _horse_no_action(self, token):
        
        self._data = self._data.append({'diff':self._diff, 'horse_no':token[0]}, ignore_index=True)
        return

    def _group_action(self, token):
        
        for no in token:
            self._data = self._data.append({'diff':self._diff, 'horse_no':no}, ignore_index=True)
            self._diff += DIFF_GROUP
        self._diff -= DIFF_GROUP
        return
        
    def _diff_min_action(self, token):
        
        self._diff += DIFF_MIN
        return
        
    def _diff_mid_action(self, token):
        
        self._diff += DIFF_MID
        return
    
    def _diff_much_action(self, token):
        
        self._diff += DIFF_MUCH
        return
        
    def parse(self, pass_str):
        
        # 初期化
        self._data = pd.DataFrame(columns=columns)
        self._diff = 0
        # parse
        self._passing_order.parseString(pass_str)
        # index調整
        self._data.index = np.arange(1, len(self._data)+1)
        self._data.index.name = 'rank'
        
        return self._data

クラス初期化箇所(init())で、pyparsingのクラスを使用して構文定義しています。

horse_no = pp.Word(pp.nums).setParseAction(self._horse_no_action)

Wordで単語として定義し、内容はnumsで数字であることを定義しています。
この定義にマッチ('1'や'12'等)した場合に、setParseActionで指定したメソッドをコールして、その中でデータ化処理をしています。
Wordの他に、LiteralやOptionalで構文を定義します。複数を連結させるときは、'|'でOR結合させたり、'+'で結合していきます。

setParseActionをどのオブジェクトに指定するかは、マッチした順番にコールされるので少々注意が必要です。
例えば

diff_min = (pp.Literal(',') + element).setParseAction(self._diff_min_action)

としてしまうと、elementで指定したsetParseActionが先にコールされて、ここの_diff_min_actionは次にコールされます。先に','で差の計算をしてからデータ化処理をしたかったので、Literal(',')にsetParseActionを指定しています。

diff_min = pp.Literal(',').setParseAction(self._diff_min_action) + element

pyparsingを使用したのが初めてでしたので、少々苦戦しました(笑)。

このクラスを利用するためのメソッドはparseだけで、その中で、構文全体を定義した_passing_orderオブジェクトのparseStringで文字列を渡して処理をさせています。

使用方法サンプル

if __name__ == '__main__':

    # test data
    pass_data = ['(*1,2,3)(4,5)-6(7,8)=9,10,11',
                 '4=(15,9)(2,11)14(1,5)-6,3,10,7-(12,13)8']
    
    pass_parsing = ParsePass()
    for pass_str in pass_data:
        print(pass_parsing.parse(pass_str))

出力サンプル(整形しています)

input : '(*1,2,3)(4,5)-6(7,8)=9,10,11'

diff horse_no
1 0 1
2 0.3 2
3 0.6 3
4 2.1 4
5 2.4 5
6 5.4 6
7 6.9 7
8 7.2 8
9 13.2 9
10 14.7 10
11 16.2 11

input : '4=(15,9)(2,11)14(1,5)-6,3,10,7-(12,13)8'

diff horse_no
1 0 4
2 6 15
3 6.3 9
4 7.8 2
5 8.1 11
6 9.6 14
7 11.1 1
8 11.4 5
9 14.4 6
10 15.9 3
11 17.4 10
12 18.9 7
13 21.9 12
14 22.2 13
15 23.7 8

diffは先頭からの差(馬身)です。

所感

日本語の情報が多くないため、少々苦労したところもありましたが、parseやreなどのパターンマッチングで対応が難しい構文も比較的簡単に取り扱えると感じました。