경마 예측 앱을 만들고 있습니다. 약 93,000 경주 분량의 데이터를 축적하여, LightGBM으로 착순을 예측하는 단순한 개인 개발입니다.
만들기 시작했을 때는 '좋은 모델을 만들면 이길 수 있다'고 생각했습니다. 하지만 실제로는 그렇지 않았습니다. 모델의 정확도를 높여도 회수율은 거의 변하지 않았고, 효과를 본 것은 '무엇을 예측 대상으로 삼을지'에 대한 정식화였습니다.
이 글은 그 이야기입니다. 경마에 관심이 없더라도, '시장 가격이 이미 내포하고 있는 정보를 어떻게 다룰 것인가'라는 문제는 수요 예측이나 광고 입찰에서도 같은 형태로 나타납니다.
처음 부딪히는 벽: 공제율 (控除率)
경마의 배당금은 파리뮤추엘 방식입니다. 주최 측이 베팅금을 전부 모아서, 거기서 공제율(단승・복승의 경우 20%, 경주 종류에 따라 그 이상)을 뺀 나머지를 당첨자들에게 나눕니다. 오즈는 주최 측이 정하는 것이 아니라, 참가자들의 투표액 자체에서 결정됩니다.
즉, 단승 오즈는 시장 참가자들이 제시한 '이 말이 이길 확률'의 집합지식입니다. 이것을 확률로 되돌리면 다음과 같습니다.
import numpy as np
def implied_probabilities(odds: np.ndarray) -> np.ndarray:
""단승 오즈 → 시장이 예상하는 승률.
...
여기서가 출발점입니다. 시장은 이미 상당히 정확하여, 순순히 인기 순으로 사기만 해도 회수율은 공제율대로 75~80%에 수렴합니다.
실제로 제 앱에서 'AI의 본명 위주로 모든 경주를 기계적으로 구매'하는 것을 백테스트 해보니, 18,468경주에서 회수율 81.2%, 손익 −173만 엔이었습니다. 적중률은 63.3%이지만, 그래도 돈을 잃습니다. 당연합니다. 공제율을 넘어서지 못했기 때문입니다.
정확도가 높은 모델 자체를 만드는 것은 이 벽을 넘는 데 도움이 되지 않습니다. 시장과 같은 답을 고정밀도로 내놓아도, 공제율만큼은 지게 되어 있기 때문입니다.
문제 재설정
이기기 위해 필요한 것은 '이길 말을 맞히는 것'이 아니라, **'시장이 틀리고 있는 말(정보)을 찾아내는 것'**입니다.
식으로 쓰면, 하고 싶은 것은
AI가 예상하는 확률 p_model > 시장이 예상하는 확률 p_market
이 되는 말을 찾는 것입니다. 기대값은 다음과 같습니다.
def expected_value(p_model: float, odds: float) -> float:
""1을 초과할 때, 그 오즈에 대해 살 가치가 있다""
return p_model * odds
p_model × odds > 1은 p_model > 1/odds, 즉 **'AI의 예측이 시장보다 강세(낙관적)'**와 같은 의미입니다.
여기에 깨닫게 되면, 학습 목표가 바뀝니다. 무(無)에서 승률을 맞히려고 하는 것이 아니라, 시장의 확률을 출발점으로 삼아, 거기서부터의 '괴리'만을 학습시키면 됩니다.
정식화를 3가지 준비하여 비교하기
앱에서는 학습 방식을 하나로 결정하지 않고, 화면에서 전환하며 비교할 수 있게 했습니다.
① 일반적인 이진 분류 (二値分類)
'3착 이내에 들어갈지'를 0/1로 맞히는 가장 단순한 형태입니다.
import lightgbm as lgb
model = lgb.train(
{"objective": "binary", "metric": "auc", "learning_rate": 0.03},
...
AUC는 나옵니다. 하지만 경주를 넘어서 '3착 이내에 들어갈 가능성이 높은 말'을 맞히고 있을 뿐, 같은 경주 내에서의 상대적인 강함은 직접적으로 학습하지 못했습니다.
② 랭킹 학습 (LambdaRank)
경마는 '경주 내 순위'를 맞히는 문제이므로, 경주를 하나의 쿼리로 간주하는 랭킹 학습이 자연스럽게 적용됩니다.
# 경주마다 1개의 쿼리. group은 경주별 출전마 수
model = lgb.train(
{"objective": "lambdarank", "metric": "ndcg", "ndcg_eval_at": [3]},
...
착순이라는 순서 정보를 버리지 않고 사용할 수 있다는 장점이 있지만, 출력이 그대로는 확률이 아닙니다. 기대값을 계산하려면 교정(calibration)이 필요합니다.
③ 시장의 괴리를 학습하기 (효과를 본 방식)
LightGBM에는 init_score가 있습니다. 각 샘플 예측의 초기값을 외부에서 줄 수 있는 구조로, 부스팅은 거기서부터의 잔차(residual)를 학습합니다.
여기에 오즈 유래의 확률을 넣습니다.
from scipy.special import logit
시장의 예측을 로짓으로 변환하여 초기값 설정
init_score = logit(np.clip(p_market, 1e-6, 1 - 1e-6))
...
이렇게 하면 모델이 학습하는 것은 '시장 확률에서 얼마나 벗어나는가'에만 집중하게 됩니다. 시장이 이미 정확히 반영하고 있는 부분(인기마는 강하다, 중상급 경주는 안정적이다 등 당연한 것)을 다시 배울 필요가 없어지고, 제한된 데이터를 온전히 '시장의 사각지대' 학습에 사용할 수 있습니다.
부작용으로, 특징량의 중요도가 읽기 쉬워졌습니다. 벗어남을 설명하는 변수들만 상위에 오기 때문에, '시장은 이 요소를 과소평가하는 경향이 있다'는 식으로 해석할 수 있습니다.
④ 비교를 위해 선형 모델을 반드시 포함하기
그라디언트 부스팅(Gradient Boosting)만 나열하면, '복잡하게 만든 결과 얼마나 좋아졌는지' 알기 어렵습니다. 그래서 Benter 유형의 조건부 로짓(multinomial logit) 을 비교용으로 항상 배치합니다.
Bill Benter가 1990년대 홍콩에서 사용했던 고전적인 방법으로, '기초 능력 모델을 만들고, 그 출력과 시장 배당률(odds)을 한 단계 더 로짓으로 혼합'하는 구조입니다. 해석하기 쉽고 과적합이 적어, 그라디언트 부스팅이 정말로 우월한지 판단할 기준선이 됩니다.
실제로 변별력(AUC) 면에서 선형 모델과 GBDT의 차이는 작았으며, 차이가 나타난 것은 기대값 상단 쪽(인기 없어 의외성이 있는 영역)에만 국한되었습니다. 이 사실은 선형 모델을 함께 배치해야 비로소 확인할 수 있었습니다.
정보 유출(Leakage) 함정이 경마에는 많다
여기가 가장 어려웠습니다. 아무 생각 없이 검증하면, 쉽게 회수율 200%가 나옵니다. 전부 정보 유출입니다.
함정 1: 확정 배당률(Odds) 사용하기
가장 흔한 실수입니다. 손에 들어온 데이터의 배당률은 대부분 경주 확정 후 최종 배당률입니다. 이걸로 기대값을 계산하면, '마감 후에 알 수 있는 정보'로 구매할 말을 결정하게 됩니다.
실제 현장에서 사용할 수 있는 것은 구매 시점(판매 중)의 배당률뿐이므로, 검증도 여기에 맞추어야 합니다. 앱의 백테스트는 '판매 중인 배당률만 사용한다'고 화면에 명시하고 있습니다.
# NG: 확정 배당률로 기대값을 계산하여 구매할 말 결정
ev = p_model * df[
breakeven_odds = 1 / hit_rate # 이 배율을 평균 배당이 초과하지 않으면 플러스가 될 수 없습니다.
복승의 적중률이 63%라면, 평균 배당이 1.59배를 초과하지 않으면 이길 수 없습니다. **적중률이 높은 경주 유형일수록 필요한 배당 조건은 까다로워집니다.** '잘 맞는 것'과 '돈을 버는 것'은 별개의 이야기라는 것을 이 공식이 명확하게 보여줍니다.
### 최대 낙폭(Maximum Drawdown) 계산하기
회수율이 플러스여도, 도중에 자금이 바닥나면 끝입니다. **최고 수익 지점 대비 얼마나 줄었는지**를 알아두면 필요한 자금량의 기준을 잡을 수 있습니다. 제 검증에서는 최대 26연패가 있었습니다.
## 실패한 숫자를 화면에 표시하기
마지막은 설계 사상에 대한 이야기입니다.
성적 검증 화면에는 회수율, 적중률뿐만 아니라 **손익(마이너스라면 마이너스 그대로), 최대 연패, 최대 하락폭**을 나열합니다. **자신의 모델이 실패한 결과도 '전 경주를 기계적으로 구매할 경우 81.2%'와 같이 기본으로 표시**합니다.
예측 서비스의 상당수는 성공했던 주만 보여줍니다. 하지만 자신이 매주 돈을 거는 도구로 사용하려면, 불편한 숫자가 처음 보이는 편이 신뢰가 갑니다. **백테스트는 좋은 숫자를 내기 위해서가 아니라, 어디서 문제가 있는지 알아내기 위해 돌리는 것**이라고 생각합니다.
## 요약
- 파리뮤츄엘 방식에서는 배당률이 시장이 제시한 확률입니다. 순수하게 믿고 따르다가는 수수료만큼 손해를 봅니다.
- **승률을 맞추는 것이 아니라, 시장의 확률로부터의 '차이'를 학습합니다.** LightGBM의 경우 `init_score`에 배당률에서 유래한 로짓(logit)을 넣기만 하면 정식화할 수 있습니다. - 비교용으로 선형 모델(Benter 유형의 조건부 로짓)을 상시 배치하면, 복잡한 모델이 정말 효과가 있는지 알 수 있습니다.
- 누수(Leakage)는 '확정 배당률', '취소마', '시간 순서를 무시한 분할' 세 가지에서 발생합니다. 이를 수정하면 숫자는 내려가지만, 낮아진 편이 진짜입니다.
- AUC나 회수율만 보지 않습니다. 점수, 변동 범위, 손익분기점(1 ÷ 적중률), 최대 낙폭을 함께 보여줍니다.
- 불편한 숫자일수록 기본으로 표시합니다.
모델을 교체하는 것보다 **무엇을 예측 대상으로 삼을지**를 바꾸는 것이 더 효과적입니다. 경마에 한정되지 않고, 시장 가격이 존재하는 모든 예측 문제에서 대체로 같은 말이 된다고 생각합니다.
이 기사의 앱은 umayomi라는 Windows용 JRA 경마 예상 앱이며, Lancers에서 패키지로 공개하고 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31