본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 05. 25. 14:27

경마 예측 ML에서 데이터 리키지(Data Leakage)를 정공법으로 해결한 이야기 — race_date 필터를 4곳에 적용하며 깨달은 점

요약

경마 예측 모델 개발 과정에서 발생한 시계열 데이터 리키지(Data Leakage) 문제를 해결하는 방법을 다룹니다. race_date 필터를 집계 쿼리 단계에서 적용하여 미래 정보가 학습에 혼입되는 4가지 경로를 차단하는 구현 패턴을 소개합니다.

핵심 포인트

  • 시계열 ML에서 미래 정보가 혼입되는 데이터 리키지 방지의 중요성
  • 주력 지수, 적성 스코어, 혈통 데이터 등 4가지 주요 리크 경로 식별
  • 사후 슬라이싱 대신 집계 쿼리 WHERE 절에 race_date 필터 적용
  • 인터페이스 설계 시 race_date를 명시하여 데이터 무결성 확보

서론: 시계열 ML의 「미래 참조」라는 고전적인 버그

시계열 데이터(Time-series data)를 사용한 머신러닝(Machine Learning)에서 가장 빠지기 쉬운 함정이 바로 **데이터 리키지 (Data Leakage)**입니다. 특히 「과거의 사건을 보고 다음 사건을 예측하는」 타입의 태스크에서는, 학습 시에 미래의 정보를 혼입시켜 버림으로써 평가 지표가 현실과 동떨어지게 좋아지는 현상이 빈번하게 발생합니다.

필자는 경마 예측 도구인 UmaScore (포워드 테스트 중, umascore.com)를 개발하며, Python + SQLite + 독자 지수 + LightGBM을 조합한 파이프라인을 작성하고 있습니다. 이 파이프라인을 작성하기 시작한 초기, 백테스트(Backtest) 집계에서 "어라, 숫자가 너무 좋은데?"라고 느꼈던 순간이 몇 번 있었습니다. 원인은 거의 매번 데이터 리키지였습니다.

이 기사에서는 경마 데이터라는 소재를 사용하면서 **시계열 ML 전반에 통용되는 4가지 리크 경로 (Leakage paths)**를 정리하고, 각각에 대해 race_date라는 기준점으로 필터를 통과시키는 구현 패턴을 소개합니다. 코드는 UmaScore의 services/prediction_engine.py에서 실제 구현부를 인용했습니다.

참고로, 본 기사는 기술 검증의 지견 공유를 목적으로 하며, 마권 구매를 권장하는 것이 아닙니다. 기사 말미의 주의사항도 함께 확인해 주시기 바랍니다.

경마 데이터에서의 시계열 리크 4가지 경로

UmaScore의 스코어링은 여러 특성량(Feature)을 합성하여 Master_Score를 만드는 구조입니다. 리크가 발생할 수 있는 곳을 정리하면 다음과 같은 4가지 경로로 집약되었습니다.

#리크 경로무엇이 섞이는가대책
1주력 지수 (Base_Ability_Index) 집계예측 대상 레이스 당일 및 이후의 과거 주행 기록race_date < ?로 과거 주행 테이블을 필터링
2적성 스코어 (Aptitude_Score)의 코스 실적 집계예측 대상 레이스 당일 및 이후의 동일 조건 주행 기록위와 동일
3혈통을 경유한 집계 (씨수말 산구의 동일 조건 성적)예측 대상 레이스 당일 및 이후에 발생한 산구의 주행 기록pr.race_date < ?로 JOIN 대상에도 필터링
4추론 시의 함수 호출race_date를 전달하는 것을 잊어 전 기간을 집계호출 측의 규율 (테스트로 담보)

"전 기간을 집계한 후 date로 슬라이싱(Slice)한다"와 같은 사후 대책은, JOIN 대상의 참조나 혈통을 경유한 쿼리에서 쉽게 누락됩니다. 따라서 UmaScore에서는 "집계 쿼리의 WHERE 절에 반드시 race_date < ?를 삽입한다"라는 단일 규칙으로 4가지 경로를 통일했습니다.

calculate_base_ability_indexrace_date 필터

  1. 주력 지수 (과거 5경기를 통해 산출하는 Base_Ability_Index, 0~40점)의 구현입니다. 여기가 리크되면 「미래의 주파 기록을 근거로 강함을 논하는」 상황이 되어, 평가가 단번에 무너집니다.

함수 시그니처(Signature)에 race_date를 명시하고, SQL 측에서 AND race_date < ?를 삽입하는 구조를 채택했습니다.

# services/prediction_engine.py: calculate_base_ability_index
def calculate_base_ability_index(
    horse_id: int,
    ...

여기서의 포인트는 3가지입니다.

  • 인터페이스 설계: 백테스트에서는 엄격하게 전달하고, 운영 시의 수동 디버깅에서는 race_dateOptional로 하여 None (즉, 전 기간)도 허용합니다. 단, 운영 시의 본선에서는 반드시 전달해야 합니다 (후술).
  • f-string으로 WHERE 절을 구성하되, 값은 플레이스홀더(Placeholder)를 경유: date_filter는 문자열 상수 (" AND race_date < ?")를 추가할 뿐이며, 사용자 입력이 섞이지 않으므로 SQL 인젝션 (SQL Injection)은 발생하지 않습니다. 날짜 값은 params.append(race_date) 측에서 전달합니다.
  • 최근 상태 반영: 과거 5경기로 한정함으로써 「최근의 상태」를 반영합니다. 필터를 잊으면 ORDER BY race_date DESC LIMIT 5를 사용하더라도 과거 5경기 안에 미래 경기가 포함되는 치명적인 상태가 됩니다.

「테이블에 race_date...

「컬럼이 있으니까 괜찮겠지」라고 생각하더라도, LIMIT N으로 정렬한 결과에는 미래의 경주(future runs)가 섞여 들어갑니다. 정렬(Sort) + LIMIT 계열의 쿼리는 특히 리키지(Leakage) 사고가 발생하기 쉬우므로, 입구에서 반드시 race_date < ?를 통과시켜야 합니다.

2. 적성 스코어 (Aptitude_Score)의 코스 실적 필터

  1. 적성 스코어 (Aptitude_Score, 0~15점)는 「같은 경마장·비슷한 거리에서의 과거 성적」을 집계합니다. 여기서도 race_date 인수를 필수화하고 있습니다.
# services/prediction_engine.py: calculate_aptitude_score (코스 실적 부분)
def calculate_aptitude_score(
    horse_id: int,
    ...

여기서 의식한 점은 「거리 ± 200m 범위」와 같은 근접 필터를 사용하더라도, 시간 필터는 독립적으로 필요하다는 것입니다. venuedistance BETWEEN만 사용하면 리키지 경로 2가 남게 됩니다. 「거리가 가깝다 → 적성이 높을 것 같다」라는 공간 축의 필터와, 「과거의 것만 → 인과관계가 성립한다」라는 시간 축의 필터는 **직교(Orthogonal)**하며, 두 가지 모두 명시적으로 작성해야 합니다.

3. 혈통을 경유한 집계에 숨어있는 세 번째 리키지

이것이 이번 글에서 가장 전달하고 싶은 부분입니다. 코스 실적이 부족한 말(=신마, 전厩(전적지 변경), 조건 외 참가)은, 종두마(Sire) 산구의 동일 조건 성적으로 적성을 추정합니다. 이때 past_raceshorses를 JOIN 하지만, JOIN 대상인 past_races.race_date 역시 미래의 경주를 포함할 수 있기 때문에, JOIN 쿼리 측에도 pr.race_date < ?를 넣어야 합니다.

# services/prediction_engine.py: calculate_aptitude_score (혈통 추정 부분)
else:
    missing_flags.append("course_experience_missing")
    ...

JOIN을 포함하는 쿼리에서는 테이블 별칭(여기서는 pr)을 WHERE 절에서 명시하는 습관을 들이면, 여러 테이블에 걸친 리키지 경로를 놓치지 않게 됩니다. pr.race_date < ?와 같이 항상 「어느 테이블의 race_date인가」를 명시하고, 자연 키(Natural Key) JOIN을 많이 사용할 때는 JOIN 대상 테이블의 시간 축도 독립적으로 제한한다는 규칙입니다.

이 세 번째 경로는 코드 리뷰에서 가장 놓치기 쉬운 부분이었습니다. 이유는 함수의 도입부에서 이미 「코스 실적」용 race_date < ?를 넣었기 때문에, 후속되는 혈통 경유 쿼리에서도 필터링이 된 것처럼 착각하기 때문입니다. 「fallback 루트에 동일한 가드(Guard)가 들어가 있는가」를 독립적으로 확인하는 체크 항목을 코드 리뷰 템플릿에 추가했습니다.

4. 호출 측에서 race_date 전달을 철저히 하기

추론 시 함수 측에서 아무리 race_date를 받을 수 있게 설계하더라도, 호출 측에서 전달하는 것을 잊으면 전부 무의미합니다. UmaScore에서는 「호출 측에서 반드시 races.race_date를 취득 → 각 스코어 함수에 전달」이라는 규칙을 추론 파이프라인의 한 곳으로 집약했습니다.

# services/prediction_engine.py: 스코어 합성 파이프라인
with get_db() as conn:
    entries = conn.execute(
        ...
    )

여기서 채택한 세 가지 규칙은 다음과 같습니다.

  • 기준점은 호출 시마다 race_id로부터 단 한 번만 race_date를 가져오기: 인수를 잘못 전달하는 사고가 발생하기 쉬우므로, 파이프라인의 입구에서 한 번만 확정합니다.
  • 백테스트 초기 단계나 과거 경주의 재평가에서는 Optional[str]인 상태로 전달하기: None을 허용해야 하는 상황도 있으므로, 인수 자체는 Optional로 유지합니다. 단, 운영 본선에서는 반드시 None이 아니어야 합니다.
  • 호출 지점을 한 파일에 가두기: 여러 파일에서 개별적으로 호출하면 「여기서만 race_date를 전달하는 것을 잊어버리는」 사고가 발생합니다. 스코어 합성은 prediction_engine.py의 특정 함수로 집약했습니다.

호출 측의 규율은 코드 리뷰(Code Review)로 커버하는 것이 기본이지만, calculate_base_ability_index를 호출하는 곳에서 race_date를 인자로 전달하고 있는가」를 grep으로 체크하는 CI 규칙을 넣어두면 사고를 줄일 수 있습니다 (구현은 숙제).

4개 경로를 모두 해결한 후의 수치

UmaScore의 백테스트(Backtest)는 2025년 1월~2026년 4월까지 16개월 동안, 4,409개 레이스·61,036개 출주를 대상으로 실행되었습니다. race_date 필터를 넣기 전과 넣은 후에는 다음과 같은 질적인 차이가 있었습니다.

필터 전: 「과거 주행 데이터 안에 미래 주행 데이터가 포함되는」 상태가 혼입되어, 주력 지수의 분포가 예측 대상 레이스 이후로 치우쳐 높게 나타납니다. 백테스트 집계 수치는 「너무 깨끗한」 상태였으며, 포워드 테스트(Forward Test)와의 괴리가 커질 가능성이 높은 구조였습니다 (필터 전의 백테스트 집계치 자체는 폐기되어 로그가 남아 있지 않습니다).

필터 후: 4개 경로 모두에서 race_date < ?를 통과시킨 후, EV >= 1.5 전략의 백테스트 집계치가 회수율 118.4% (2025년 1월~2026년 4월 / 16개월 / 4,409개 레이스 / 61,036개 출주)로 안정되었습니다. 16개월 중 10개월이 플러스 (62.5%)였으며, 월간 분산은 커서 2개월 연속 적자가 발생하는 기간도 있습니다.

수치를 파악할 때 중요한 점은 "필터를 넣어 숫자가 낮아지는 것 자체가 정상"이라는 점입니다. 오히려 필터를 넣기 전후로 집계치가 크게 변동하지 않는다면, 대상 특성량(Feature)에 시간축 정보가 거의 영향을 미치지 않거나 (즉, 특성량으로서 유의미하지 않거나), 혹은 아직 다른 경로의 리키지(Leakage)가 남아 있는 것을 의심해야 합니다.

과거의 백테스트 수치는 미래의 성적을 보장하지 않습니다. 실운용과의 격차를 검증하기 위해, UmaScore에서는 4월 18일부터 포워드 테스트를 공개하고 있습니다 (umascore.com).

요약: 시계열 ML 전반에 통용되는 규율

경마 데이터를 소재로 삼았지만, 본 기사에서 정리한 내용은 시계열 ML (Time-series ML) 전반에 통용됩니다.

집계 쿼리의 WHERE 절에 반드시 「기준 시각 < ?」를 넣는다: 단순히 잊어버리는 것이 아니라, 잊어버렸음을 알아챌 수 있는 메커니즘을 우선한다 (CI / lint / 코드 리뷰 체크리스트).

JOIN 대상 테이블의 시간축도 독립적으로 필터한다: 자연 키(Natural Key) JOIN을 다용할 때 놓치기 쉬운 제3의 경로입니다. WHERE 절에서 테이블 별칭(Alias)을 명시하는 습관이 예방책이 됩니다.

fallback 루트를 독립적으로 체크한다: 메인 루트만 확인하는 코드 리뷰에서는, fallback의 혈통 경유·근접 보간·캐시 fallback이 그대로 통과될 수 있습니다.

기준 시각은 파이프라인 입구에서 한 번만 가져온다: 호출할 때마다 가져오면 인자(Argument)를 잘못 전달하는 사고가 발생하기 쉽습니다.

필터 전후로 수치가 변하지 않는다면 다른 리키지인지, 혹은 무효한 특성량인지 의심한다: 「숫자가 낮아지는 것」을 품질 확인의 지표로 삼습니다.

시계열 리키지는 「주의하면 막을 수 있는」 종류의 버그가 아니라, 구조로 막아야 할 대상이라고 생각합니다. UmaScore도 4개 경로를 메우기까지 몇 번이고 다시 작업했습니다. 같은 함정에 빠져 계신 분들께 참고가 되기를 바랍니다.

주의사항

  • 본 기사는 기술 검증의 지견 공유를 목적으로 합니다.
  • 공영 경기는 20세 이상의 자기 책임입니다. 마권 구매 대행이나 투자 조언은 수행하지 않습니다.
  • 과거의 검증 결과는 미래의 성적을 보장하지 않습니다.
  • 본 기사의 수법은 특정 서비스로의 가입을 권장하는 것이 아닙니다.
  • 경마는 오락입니다. 여유 자금의 범위 내에서 즐겨주십시오.

저자: 포워드 테스트 중인 경마 예측 툴 UmaScore (umascore.com) 개발·운영자

β1 기간 중에는 무료 공개이며, 검증 로그도 공개하고 있습니다.

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0