Post

March Machine Learning Mania 2026: 누설 없는 베이스라인 파이프라인 단계별 구축 (KR)

March Machine Learning Mania 2026: 누설 없는 베이스라인 파이프라인 단계별 구축 (KR)

March Machine Learning Mania 2026: 베이스라인 파이프라인 단계별 구축

대회 링크: March Machine Learning Mania 2026

Kaggle 노트북: MMLM2026_Baseline Pipeline (Step-by-step)

관련 EDA 노트북: Structured EDA: Dataset Statistical Analysis

이 MMLM2026 프로젝트 트랙의 대회 결과: Silver Medal, 92nd place

이 노트북은 2026 NCAA 농구 토너먼트를 예측하기 위한, 읽기 쉬운 엔드투엔드 베이스라인을 만듭니다. 강조점은 복잡한 앙상블이 아닙니다. 다음 세 가지 실용적인 목표에 있습니다.

  • 2026 피처 생성을 누설(leakage)로부터 안전하게 유지하기
  • 원시 시즌 데이터를 하나의 통합 팀 피처 스토어로 변환하기
  • 팀 피처를 매치업 확률로 바꾸고 유효한 submission.csv를 작성하기

TL;DR

  • 남자부와 여자부 모두에 대해, 가능한 모든 2026 토너먼트 매치업을 예측
  • 정렬된 2026 컷오프 일자 93을 사용해 현재 시즌 피처가 미래를 너무 앞서 들여다보지 않게 함
  • 16,63571열의 통합 팀 피처 스토어 하나를 구축
  • 과거 NCAA 토너먼트 페어로 학습: 총 2,410
  • 간결한 core 피처 세트(13열)와 expanded 세트(74열)를 비교
  • 2021-2025에 대한 롤링 시즌-포워드 OOF Brier로 검증
  • 최적 내부 블렌딩: 57% core + 43% expanded
  • 최적 블렌딩 OOF Brier: 0.169731
  • 최종 노트북 실행에서 519,144개의 예측을 작성했고 제출 포맷 검사를 통과
  • 더 넓은 대회 실행은 최종적으로 92위에 올라 Silver Medal 획득

이 노트북이 대회 중 유용했던 이유

핵심은, 이 노트북의 가치가 새로움에서 나온 것이 아니라는 점입니다. 그 가치는 덜어냄에 있었습니다. 다른 참가자가 올바른 제출에 이르기까지 직접 감당해야 했을 사전 작업의 상당 부분을, 이 노트북이 대신 걷어내 주었기 때문입니다.

라이브 대회에서의 노트북 추천을 설명하는 한 가지 합리적인 멘탈 모델은 다음과 같습니다.

\[\text{Recs} \propto U \times C \times T \times S\]

여기서:

  • U = 유용성(utility)
  • C = 명확성(clarity)
  • T = 타이밍(timing)
  • S = 점수 신뢰도(score credibility)

베이스라인 노트북에서는, 이 곱이 새로움보다 더 중요한 경우가 많습니다. 다시 말해, 질문은 보통 “이게 여기서 가장 똑똑한 모델인가?”가 아닙니다. “내가 이걸 fork하고, 신뢰하고, 갈퀴를 밟지 않고 앞으로 나아갈 수 있는가?”입니다.

그 관점은 March Machine Learning Mania 2026에 유독 잘 들어맞았습니다. 대회 자체에 운영상의 세부 사항이 몇 가지 있었고, 그 때문에 화려한 베이스라인보다 깔끔한 베이스라인이 더 유용했습니다.

  • 예측 대상이 단순한 팀 랭킹이 아니라 매치업 확률이다
  • 페어 방향이 TeamLow / TeamHigh로 고정되어 있어, 라벨 구성을 신중하게 해야 한다
  • 현재 시즌 파일이 라이브 대회 중 업데이트되었기 때문에, 누설 안전한 2026 컷오프가 중요했다
  • 제출 처리가 스테이지(stage)를 인식하는 방식이었고, 템플릿과 선호 제출(preferred submission)을 둘러싼 실무 흐름 때문에 포맷 실수의 비용이 컸다

그런 상황에서는, 지루한 부분을 올바르게 처리하는 노트북이 실질적인 가치를 갖습니다. 이 노트북은 정확히 그것을 합니다.

  • 피처 엔지니어링이 어디선가 갑자기 나온 것처럼 굴지 않고, 선행 EDA에서 출발한다
  • 2026 컷오프를 명시적으로 만들고 cutoff_day_2026 = 93을 기록한다
  • 흩어진 임시 테이블 대신 16,635 x 71 통합 팀 피처 스토어를 구축한다
  • 그 스토어를 74개의 확장 페어와이즈 피처로 변환한다
  • 낙관적인 무작위 분할 대신 시즌-포워드 OOF Brier를 사용한다
  • 스테이지 호환 제출 파일을 작성한다
  • CSV가 괜찮다고 가정하는 대신, 명시적인 제출 정합성 검사로 끝맺는다

이 조합 덕분에 이 노트북은 “내가 해본 것 좀 봐” 식의 노트북이 아니라 유틸리티 노트북처럼 읽힙니다. 다른 참가자에게는 더 낮은 시작 비용, 더 낮은 디버깅 비용, 그리고 조용한 누설이나 잘못된 제출 파일의 위험이 더 낮다는 의미입니다.

이 아이디어는 사용자 비용 문제로 요약할 수 있습니다.

\[L_{\text{user}} = t_{\text{understand}} + t_{\text{debug}} + \lambda \,\mathbf{1}_{\text{submission error}} + \mu \,\mathbf{1}_{\text{leakage}}\]

이 노트북이 잘한 것은 리더보드의 모든 소수점을 최소화하는 것이 아니었습니다. 바로 그 사용자 측 비용을 최소화하는 것이었습니다.

이것이 또한 이 노트북의 정직함이 도움이 된 이유이기도 합니다. 모델은 여전히 그저 로지스틱 회귀 베이스라인일 뿐이고,

\[p(y=1 \mid x) = \sigma(\beta^\top x),\]

진단 결과도 마법처럼 보이도록 꾸미지 않았습니다.

  • core OOF Brier: 0.171637
  • expanded OOF Brier: 0.172966
  • 블렌딩 OOF Brier: 0.169731

그래서 이 노트북은 피처를 더 많이 넣으면 자동으로 이긴다고 주장하지 않습니다. 대신, 신호가 어디서 왔고 어디서 오지 않았는지를 보여줍니다. 그런 투명한 보고는 신뢰를 높이며, 이는 S(점수 신뢰도)의 일부입니다. 공개된 실행에서 퍼블릭 리더보드 점수는 0.1172485였고, 이는 노트북이 단순히 예시용이 아니라 실제로 쓸 만한 것으로 읽히기에 충분했습니다.

이런 노트북에 대한 수요가 있었다는 정황도 분명합니다. 같은 대회의 다른 공개 노트북들도 스스로를 명시적으로 출발점으로 내세우고 있었습니다. 예를 들어 March Machine Learning Mania 2026 StarterMarch ML Mania 2026 | HistGB + XGB + CatBoost가 그렇습니다. 이는 참가자들이 이색적인 모델링 아이디어만이 아니라, 확장 가능한 깔끔하고 재현 가능한 베이스라인을 적극적으로 찾고 있었다는 유용한 단서입니다. 실제로 이런 노트북들은 종종 조용한 유틸리티 자산으로 소비됩니다. 긴 토론 스레드가 되어서가 아니라 마찰을 없애주기 때문에 fork되고, 변형되고, 추천받습니다.

Step 1. 런타임 설정과 입력 가용성

목적

첫 번째 섹션은 이 노트북이 Kaggle과 로컬 환경 어디서든 그대로 돌아가도록 만듭니다. 또한 피처 엔지니어링에 손대기 전에 학습 기간과 검증 연도를 먼저 고정합니다.

파라미터 선택과 이유

  • INPUT_SEARCH_DIRS: 명시적인 환경 경로를 먼저 검색하고, 그다음 Kaggle 입력, 마지막으로 로컬 폴백을 검색.
  • TRAIN_START_BY_PREFIX = {"M": 2003, "W": 2010}: 대회 파일에서 남자부와 여자부의 실질적인 과거 기간이 다름.
  • TRAIN_END_SEASON = 2025: 2026 토너먼트 결과로 학습하지 않음.
  • VALID_SEASONS = [2021, 2022, 2023, 2024, 2025]: 시즌-포워드 OOF 진단을 위해 최근 시즌을 사용.
  • PRED_CLIP = (0.02, 0.98): 극단적인 제출 확률을 피함.

이 노트북에서의 결과

  • 기본 입력 디렉터리는 /kaggle/input/march-machine-learning-mania-2026으로 잡힘
  • 컴팩트 결과, 상세 결과, Massey ordinal, 스테이지 제출 템플릿을 포함해 모든 핵심 파일이 노트북 실행에 존재함
  • 출력 디렉터리는 /kaggle/working으로 잡힘

초보자를 위한 참고:

  • 환경 변수는 경로를 하드코딩하지 않고도 같은 노트북을 재사용 가능하게 만듭니다.
  • 고정된 검증 기간은 모델 비교를 더 공정하게 만듭니다.
코드: 입력 경로 탐색과 학습/검증 설정

원본 노트북에서 축약함: 긴 피처 목록과 헬퍼 내부 구현은 생략했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
DEFAULT_KAGGLE_INPUT_DIR = Path("/kaggle/input/march-machine-learning-mania-2026")
INPUT_SEARCH_DIRS = _build_input_search_dirs()
INPUT_DIR = next((d for d in INPUT_SEARCH_DIRS if d.exists()), Path("."))

OUTPUT_DIR = Path(os.getenv("MMLM2026_OUTPUT_DIR", "."))
SUBMISSION_NROWS = int(os.getenv("MMLM2026_SUB_NROWS", "0")) or None
SUBMISSION_TEMPLATE = os.getenv("MMLM2026_SUBMISSION_TEMPLATE", "auto")

TRAIN_START_BY_PREFIX = {"M": 2003, "W": 2010}
TRAIN_END_SEASON = 2025
VALID_SEASONS = [2021, 2022, 2023, 2024, 2025]
PRED_CLIP = (0.02, 0.98)

Step 2. 누설 안전한 2026 일자 컷오프

목적

이것은 노트북 전체에서 가장 중요한 설계 선택 중 하나입니다. 2026 피처가 현재 토너먼트 예측 시점보다 더 나중 날짜의 데이터를 사용하도록 허용하면, 미래 정보를 피처 스토어로 누설하게 됩니다.

파라미터 선택과 이유

  • MRegularSeasonCompactResults.csvWRegularSeasonCompactResults.csv를 읽음.
  • 각 리그에 대해 2026의 사용 가능한 최대 DayNum을 찾음.
  • 그 두 값의 최솟값을 정렬된 컷오프로 사용.
  • 일별 경기 결과에 의존하는 모든 2026 테이블에 그 컷오프를 적용.

이 노트북에서의 결과

정렬된 컷오프 일자는 93이었습니다. 즉, 모든 2026 정규 시즌 및 관련 피처 블록은 오직 일자 93까지의 정보만 사용해 구축되었습니다.

초보자를 위한 참고:

  • 누설(leakage)이란 예측 시점에 사용할 수 없었을 정보를 사용하는 것을 의미합니다.
  • 정렬된 컷오프는 남자부와 여자부 파일이 같은 날까지 업데이트되지 않을 수 있기 때문에 특히 유용합니다.
코드: 정렬된 2026 컷오프 계산과 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def get_aligned_cutoff_day_2026() -> int:
    m = pd.read_csv(input_path("MRegularSeasonCompactResults.csv"),
                    usecols=["Season", "DayNum"])
    w = pd.read_csv(input_path("WRegularSeasonCompactResults.csv"),
                    usecols=["Season", "DayNum"])

    m_max = m.loc[m["Season"] == 2026, "DayNum"].max()
    w_max = w.loc[w["Season"] == 2026, "DayNum"].max()
    return int(min(m_max, w_max))


def apply_2026_cutoff(df: pd.DataFrame, cutoff_day_2026: int) -> pd.DataFrame:
    return df[
        (df["Season"] < 2026)
        | ((df["Season"] == 2026) & (df["DayNum"] <= cutoff_day_2026))
    ].copy()

Step 3A. 핵심 팀 베이스 + 시드 피처

목적

이 블록은 컴팩트 결과로부터 팀 단위 피처의 첫 번째 레이어를 만듭니다. 경기 수, 승수, 평균 득점, 평균 마진, 마지막으로 관측된 날짜입니다. 가능한 경우 공식 NCAA 시드도 조인합니다.

파라미터 선택과 이유

  • 컴팩트 결과로부터 (Season, TeamID)당 한 행을 구축.
  • 각 경기를 승자와 패자 양쪽 모두에 대해 팀 중심 행으로 변환.
  • NCAATourneySeeds.csv의 공식 시드를 사용.
  • seed_known으로 시드 가용성을 명시적으로 유지.

이 노트북에서의 결과

  • 베이스 피처 커버리지:
    • 남자부: 42개 시즌, 381개 팀에 걸쳐 13,753
    • 여자부: 29개 시즌, 370개 팀에 걸쳐 9,851
  • 시드 피처 커버리지:
    • 남자부: 2,626
    • 여자부: 1,744
  • 시드 테이블은 시드가 알려진 팀만 포함하므로, 시드 행의 seed_known_ratio = 1.0

초보자를 위한 참고:

  • 컴팩트 결과는 최종 점수와 승자/패자 ID만 있으면 되므로 넓은 커버리지에 유용합니다.
  • 시드는 설계상 희소합니다. 모든 팀이 시드를 받는 것은 아니기 때문입니다.
코드: 컴팩트 결과 기반 팀 베이스 구축
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
def build_compact_team_base(prefix: str, cutoff_day_2026: int) -> pd.DataFrame:
    reg = pd.read_csv(
        input_path(f"{prefix}RegularSeasonCompactResults.csv"),
        usecols=["Season", "DayNum", "WTeamID", "LTeamID", "WScore", "LScore"],
    )
    reg = apply_2026_cutoff(reg, cutoff_day_2026)

    w = reg.rename(columns={"WTeamID": "TeamID", "LTeamID": "OppTeamID",
                            "WScore": "ScoreFor", "LScore": "ScoreAgainst"})
    l = reg.rename(columns={"LTeamID": "TeamID", "WTeamID": "OppTeamID",
                            "LScore": "ScoreFor", "WScore": "ScoreAgainst"})
    w["Win"] = 1
    l["Win"] = 0

    g = pd.concat([w, l], ignore_index=True)
    g["Margin"] = g["ScoreFor"] - g["ScoreAgainst"]

    out = g.groupby(["Season", "TeamID"], as_index=False).agg(
        games=("Win", "size"),
        wins=("Win", "sum"),
        avg_score_for=("ScoreFor", "mean"),
        avg_score_against=("ScoreAgainst", "mean"),
        avg_margin=("Margin", "mean"),
        last_day=("DayNum", "max"),
    )
    out["win_pct"] = out["wins"] / out["games"]
    return out

Step 3B. 상세 박스 기반 피처 (최근성 + 상대 보정)

목적

컴팩트 결과는 폭넓지만, 팀이 어떻게 이기는지는 설명하지 못합니다. 이 블록은 박스 스코어 디테일을 들여와 효율성 스타일의 피처로 변환합니다.

파라미터 선택과 이유

  • 다음으로 포제션(possession)을 추정:
\[\mathrm{poss} = \mathrm{FGA} - \mathrm{OR} + \mathrm{TO} + 0.475 \times \mathrm{FTA}\]
  • off_rtg, def_rtg, net_rtg를 구축.
  • tau_days = 15.0으로 지수적 최근성 가중을 사용:
\[w_t = \exp\left(-\frac{d_{\max} - d_t}{15}\right)\]
  • 각 팀의 경기 단위 레이팅을 상대의 시즌 베이스라인과 비교하여 상대 보정 레이팅을 추가.

이 노트북에서의 결과

  • 최근성 가중 커버리지:
    • 남자부: 8,346
    • 여자부: 5,965
  • 상대 보정 커버리지도 동일한 수치와 일치

컴팩트 피처에 비해 행 수가 적은 것은 상세 박스 스코어 파일의 과거 기간이 더 짧기 때문입니다.

초보자를 위한 참고:

  • off_rtg는 100 포제션당 득점입니다.
  • 최근성 가중은 이전 경기들을 버리지 않으면서도 팀의 최신 폼에 더 큰 영향력을 줍니다.
코드: 포제션, 레이팅, 최근성 가중
1
2
3
4
5
6
7
8
9
10
11
12
13
14
g["poss"] = g["FGA"] - g["OR"] + g["TO"] + 0.475 * g["FTA"]
g["opp_poss"] = g["OppFGA"] - g["OppOR"] + g["OppTO"] + 0.475 * g["OppFTA"]

g["off_rtg"] = 100.0 * g["ScoreFor"] / g["poss"].clip(lower=1)
g["def_rtg"] = 100.0 * g["ScoreAgainst"] / g["opp_poss"].clip(lower=1)
g["net_rtg"] = g["off_rtg"] - g["def_rtg"]

max_day = g.groupby(["Season", "TeamID"])["DayNum"].transform("max")
g["rw_weight"] = np.exp(-(max_day - g["DayNum"]) / 15.0)

base = g.groupby(["Season", "TeamID"], as_index=False).agg(
    raw_off_rtg=("off_rtg", "mean"),
    raw_def_rtg=("def_rtg", "mean"),
)

Step 3C. 변동성, 컨퍼런스 강도, 경기장/이동 프록시

목적

이 블록은 단순한 원시 득점 효율성에 관한 것이 아닌 맥락 피처를 추가합니다. 다음과 같은 질문을 던집니다.

  • 팀이 안정적인가, 변동성이 큰가?
  • 소속 컨퍼런스가 얼마나 강한가?
  • 어떤 종류의 홈/원정/중립 일정을 소화했는가?
  • 시즌이 지리적으로 얼마나 다양했는가?

파라미터 선택과 이유

  • close_margin = 5: 5점 이하로 결정된 경기를 접전(close game)으로 정의.
  • prior_games = 20: 컨퍼런스 간 표본 크기가 작을 때, 컨퍼런스 강도 추정치를 중립 쪽으로 축소(shrink).
  • GameCities.csvCities.csv를 사용해 도시/주(state) 다양성과 엔트로피 프록시를 도출.

이 노트북에서의 결과

  • 변동성 커버리지:
    • 남자부: 13,753
    • 여자부: 9,851
  • 컨퍼런스 강도 커버리지:
    • 남자부: 13,753
    • 여자부: 9,853
  • 경기장 커버리지:
    • 남자부: 13,753
    • 여자부: 9,851

이 블록들은 넓은 시즌 커버리지를 제공하며, 베이스라인이 단순 승률을 넘어서도록 돕습니다.

초보자를 위한 참고:

  • 엔트로피는 분포가 얼마나 퍼져 있는지를 요약한 값입니다.
  • 베이지안 스타일의 축소(shrinkage)는 아주 작은 표본에 과민 반응하지 않기 위한 실용적인 방법입니다.
코드: 접전, 컨퍼런스 강도, 도시 엔트로피
1
2
3
4
5
6
7
8
9
10
11
12
13
g["close_game"] = (g["margin"].abs() <= close_margin).astype(int)
g["close_win"] = g["close_game"] * g["Win"]
g["ot_game"] = (g["NumOT"] > 0).astype(int)

conf_stats["conf_strength_win"] = (conf_stats["conf_inter_wins"] + prior_games * 0.5) / (
    conf_stats["conf_inter_games"] + prior_games
)
conf_stats["conf_strength_margin"] = conf_stats["conf_margin_sum"] / (
    conf_stats["conf_inter_games"] + prior_games
)

out["city_entropy"] = out["city_entropy"].fillna(0.0)
out["city_diversity"] = out["unique_cities"] / out["city_games"].replace(0, np.nan)

Step 3D. 남자부 전용 피처 (Massey + 코치)

목적

대회 파일에는 남자부 전용 소스가 일부 포함되어 있는데, 그래도 보존할 만큼 충분히 유용합니다. Massey ordinal 랭킹과 코칭 연속성 신호입니다.

파라미터 선택과 이유

  • RankingDayNum <= 133까지의 MMasseyOrdinals.csv를 사용.
  • 합의(consensus) 랭킹 요약과 trend_window = 21의 단기 추세 피처를 모두 구축.
  • 코치 변경, 재임 기간, 현재 코치 부임 후 일수를 포함해, 시즌 앵커 일자에 코치 피처를 구축.
  • 여자부 대응 항목은 결측으로 두고, 다운스트림 임퓨테이션이 처리하도록 함.

이 노트북에서의 결과

  • Massey 커버리지: 24개 시즌, 372개 팀에 걸쳐 8,356
  • 코치 커버리지: 42개 시즌, 381개 팀에 걸쳐 13,763

이것은 실용적인 베이스라인 선택의 깔끔한 예시입니다. 추가 정보가 존재할 때는 사용하되, 존재하지 않을 때 통합 파이프라인을 망가뜨리지는 않는 것입니다.

초보자를 위한 참고:

  • Massey ordinal은 여러 시스템에서 나온 합의에 가까운 랭킹 신호입니다.
  • 다운스트림 모델이 일관되게 처리할 수 있다면 결측값은 허용됩니다.
코드: Massey 합의 요약과 코치 재임 피처
1
2
3
4
5
6
7
8
9
consensus = final.groupby(["Season", "TeamID"], as_index=False).agg(
    massey_rank_mean=("OrdinalRank", "mean"),
    massey_rank_median=("OrdinalRank", "median"),
    massey_rank_std=("OrdinalRank", "std"),
    massey_n_systems=("SystemName", "nunique"),
)

out["coach_days_at_anchor"] = (out["anchor_day"] - out["FirstDayNum"] + 1).clip(lower=1)
out["coach_tenure_seasons"] = out.index.map(tenure)

Step 4. 통합 팀 피처 스토어 구축 (모든 블록 병합)

목적

앞선 모든 피처 블록은 하나의 시즌-팀 테이블로 병합할 수 있어야만 유용합니다. 이 섹션은 그 테이블을 구축하고, 노트북에서 가장 중요한 엔지니어링 개념 중 하나를 추가합니다. 바로 surrogate_strength입니다.

파라미터 선택과 이유

  • 컴팩트, 시드, 메타데이터, 시즌 맥락, 상세, 변동성, 컨퍼런스, 경기장, Massey, 코치 블록을 병합.
  • program_aged1_active_flag를 계산.
  • win_pct, avg_margin, rw_net_rtg, adj_net_rtg, close_win_rate, conf_strength_win, neutral_rate, 그리고 음수 margin_std 같은 표준화된 피처 z-점수로부터 surrogate_strength를 구축.
  • 가능한 경우 역(inverse) Massey 랭크를 추가.
  • (Season, League) 내에서 팀을 랭킹하여 다음을 생성:
    • surrogate_rank
    • surrogate_pct
    • pseudo_seed
  • 시드가 알려진 경우에는 공식 시드를, 그렇지 않으면 의사 시드(pseudo-seed)를 seed_or_pseudo로 정의.

이 노트북에서의 결과

  • team_store rows: 16,635
  • team_store cols: 71
  • 리그별:
    • 남자부: 24개 시즌, 372개 팀에 걸쳐 8,346
    • 여자부: 24개 시즌, 369개 팀에 걸쳐 8,289

커버리지 진단도 유익했습니다.

  • 핵심 시즌 피처 다수가 100% non-null 커버리지였음
  • seed_num은 토너먼트 팀에만 시드가 존재하므로 커버리지가 낮았음(0.174091)
  • Massey와 코치 컬럼은 남자부 전용이거나 과거 기간이 제한적이어서 대략 50% non-null 커버리지였음

초보자를 위한 참고:

  • surrogate_strength는 시드를 받은 팀뿐 아니라 모든 팀을 점수화할 수 있게 해주는 합성 레이팅입니다.
  • seed_or_pseudo는 시드가 없는 팀의 시드 공백을 메우는, 단순하지만 영리한 방법입니다.
코드: surrogate_strength와 seed_or_pseudo 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
weights = {
    "win_pct": 2.0,
    "avg_margin": 1.5,
    "rw_net_rtg": 1.5,
    "adj_net_rtg": 1.2,
    "close_win_rate": 0.8,
    "conf_strength_win": 1.0,
    "neutral_rate": 0.3,
    "margin_std": -0.5,
}

out["surrogate_strength"] = 0.0
for col, w in weights.items():
    _, z = _group_fill_and_zscore(out, col)
    out["surrogate_strength"] += w * z

out["seed_or_pseudo"] = np.where(out["seed_num"].notna(),
                                 out["seed_num"],
                                 out["pseudo_seed"])

Step 5. 페어와이즈 토너먼트 학습 프레임 구축

목적

토너먼트 예측은 팀 단위 과제가 아닙니다. 매치업 단위 과제입니다. 이 섹션은 팀 피처 스토어를 (Season, TeamLow, TeamHigh) 토너먼트 페어당 한 행으로 변환합니다.

파라미터 선택과 이유

  • 고정된 페어 방향을 위해 팀을 ID 기준으로 TeamLowTeamHigh로 정렬.
  • TeamLow가 실제로 이겼는지 여부를 타깃으로 정의.
  • low_feature - high_feature 같은 피처 차이를 사용.
  • 두 모델 패밀리를 구축:
    • core_features: 손으로 고른 간결한 부분집합
    • expanded_features: 모든 페어와이즈 차이 + 맥락 플래그

이 노트북에서의 결과

  • train_pairs: 2,410
  • train_df: 2,410
  • core_features: 13
  • expanded_features: 74

타깃 균형은 거의 반반이었습니다.

  • 남자부: 0.500345
  • 여자부: 0.504683

이 형식에서는 팀 ID 순서가 팀 강도를 인코딩해서는 안 되므로, 이것이 정확히 우리가 원하는 결과입니다.

초보자를 위한 참고:

  • 페어와이즈 차분(differencing)은 모델이 팀을 직접 비교하게 만들기 때문에 스포츠 모델링에서 흔히 쓰이는 기법입니다.
  • 거의 균형 잡힌 타깃은 단순한 로지스틱 모델을 학습하고 해석하기 더 쉽게 만들어 줍니다.
코드: 페어와이즈 차분 피처와 맥락 플래그
1
2
3
4
5
6
7
8
9
for c in team_diff_cols:
    feats[f"{c}_diff"] = (x[f"low_{c}"] - x[f"high_{c}"]).astype(np.float32)

feats["seed_known_both"] = (low_seed_known * high_seed_known).astype(np.float32)
feats["seed_missing_any"] = (1.0 - feats["seed_known_both"]).astype(np.float32)
feats["surrogate_gap_abs"] = feats["surrogate_strength_diff"].abs().astype(np.float32)
feats["is_men"] = (x["League"] == "Men").astype(np.float32)
feats["season"] = feats["Season"].astype(np.float32)
feats["is_2026"] = (feats["Season"] == 2026).astype(np.float32)

Step 6. 롤링 OOF 진단 (시즌-포워드, 2021-2025)

목적

이 섹션은 노트북에서 가장 실용적인 질문에 답합니다. 시즌 단위로 앞으로 이동할 때 이 피처 세트가 일반화되는가?

파라미터 선택과 이유

  • 2021-2025의 각 검증 시즌에 대해, 이전 시즌으로만 학습.
  • Brier 점수로 평가:
\[\mathrm{Brier} = \frac{1}{n}\sum_{i=1}^{n}(p_i - y_i)^2\]
  • coreexpanded를 비교.
  • 블렌딩 가중치를 0.00부터 1.00까지 41단계 그리드에서 탐색.

결과와 해석

모델전체남자부여자부
Core0.1716370.1999550.143063
Expanded0.1729660.1994400.146252

최적 블렌딩:

  • expanded 가중치: 0.43
  • 블렌딩 OOF Brier: 0.169731

해석:

  • expanded 피처 세트는 전체적으로 단독으로는 최고가 아니었음
  • 하지만 블렌딩이 두 단독 모델을 모두 이겼으므로, 상보적인 정보를 더해준 것임
  • 이 노트북 실행에서 여자부는 베이스라인에게 남자부보다 더 쉬웠음

초보자를 위한 참고:

  • Brier는 낮을수록 좋습니다.
  • 시즌-포워드 검증은 시간 순서를 존중하기 때문에 무작위 CV보다 더 엄격합니다.
코드: 시즌-포워드 롤링 OOF 예측
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def rolling_oof_predictions(df: pd.DataFrame,
                            feature_cols: list[str],
                            valid_seasons: list[int]) -> np.ndarray:
    pred = np.full(len(df), np.nan, dtype=float)

    for season in valid_seasons:
        val_mask = df["Season"] == season
        tr_mask = df["Season"] < season

        model = make_model()
        model.fit(df.loc[tr_mask, feature_cols], df.loc[tr_mask, "Target"])
        pred[val_mask] = model.predict_proba(df.loc[val_mask, feature_cols])[:, 1]

    return pred

Step 7. 전체 학습 기간으로 최종 모델 학습

목적

노트북이 core와 expanded 뷰를 어떻게 사용할지 결정하고 나면, 전체 과거 학습 기간으로 최종 모델을 학습합니다.

파라미터 선택과 이유

  • 단순한 선형 베이스라인을 사용:
    • SimpleImputer(strategy="median")
    • StandardScaler()
    • LogisticRegression(C=0.9, max_iter=1500, solver="lbfgs")
  • core_features용 모델 하나와 expanded_features용 모델 하나를 학습

이 노트북에서의 결과

두 최종 모델 모두 가용한 모든 토너먼트 학습 행에 대해 성공적으로 학습되었습니다.

이것이 좋은 베이스라인인 이유:

  • 결측값을 깔끔하게 처리함
  • 계수를 해석 가능하게 유지함
  • 피처 엔지니어링이 도움이 되는지 드러낼 만큼 충분히 안정적임
코드: 최종 모델 파이프라인 정의
1
2
3
4
5
6
7
8
def make_model() -> Pipeline:
    return Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
            ("clf", LogisticRegression(C=0.9, max_iter=1500, solver="lbfgs")),
        ]
    )

Step 8. 제출 ID 예측과 submission.csv 저장

목적

최종 예측 단계는 Kaggle 제출 ID를 매치업 행으로 변환하고, 점수화하고, 두 모델을 블렌딩하고, 확률을 클리핑한 뒤 출력 파일을 작성합니다.

파라미터 선택과 이유

  • 제출 템플릿을 자동으로 선택:
    • sample_submission.csv가 있으면 우선 사용
    • 없으면 스테이지 파일 중에서 선택
  • ID 문자열을 Season, TeamLow, TeamHigh로 파싱
  • 최적 OOF 블렌딩 가중치로 예측을 블렌딩
  • 최종 확률을 [0.02, 0.98]로 클리핑

이 노트북에서의 결과

  • 선택된 템플릿: SampleSubmissionStage1.csv
  • 템플릿 행 수: 519,144
  • 작성된 파일: submission.csv

첫 몇 개의 예측은 다음과 같았습니다.

1
2
3
4
5
2022_1101_1102  0.565414
2022_1101_1103  0.445941
2022_1101_1104  0.234558
2022_1101_1105  0.705890
2022_1101_1106  0.599468

초보자를 위한 참고:

  • Kaggle 제출 ID는 종종 추론 키를 직접 인코딩합니다.
  • 클리핑은 단순 베이스라인의 병적인 과신(overconfidence)을 줄일 수 있습니다.
코드: 제출 ID 파싱, 블렌딩, 파일 작성
1
2
3
4
5
6
7
8
9
10
11
sub = pd.read_csv(template_path, nrows=SUBMISSION_NROWS)
pred_pairs = parse_submission_ids(sub)
pred_df = build_pair_feature_frame(pred_pairs, team_store)

p_core = core_model.predict_proba(pred_df[core_features])[:, 1]
p_exp = exp_model.predict_proba(pred_df[expanded_features])[:, 1]
pred = (1.0 - blend_w) * p_core + blend_w * p_exp
pred = np.clip(pred, *PRED_CLIP)

out = pd.DataFrame({"ID": sub["ID"], "Pred": pred})
out.to_csv(OUTPUT_DIR / "submission.csv", index=False)

Step 9. 제출 정합성 검사

목적

아무리 강한 모델이라도 제출 파일이 잘못되면 실패합니다. 이 마지막 단계는 포맷, 행 수, 정렬, 결측값, 수치 범위를 검증합니다.

파라미터 선택과 이유

  • 정확한 컬럼 이름 확인: ["ID", "Pred"]
  • 선택된 템플릿 대비 행 수 확인
  • ID 동일성과 정렬 확인
  • 예측 범위가 [0, 1] 내인지 확인
  • 결측 예측이 없음을 단언(assert)

이 노트북에서의 결과

  • 컬럼 검사 통과
  • 행 수 일치: 519144 vs 519144
  • ID가 고유함
  • Pred 결측 개수 0
  • 예측 범위가 정확히 0.02에서 0.98
  • 모든 제출 포맷 검사 통과
코드: 제출 정합성 단언
1
2
3
4
assert list(sub_check.columns) == ["ID", "Pred"]
assert len(sub_check) == len(base_check)
assert sub_check["ID"].equals(base_check["ID"])
assert sub_check["Pred"].between(0, 1).all()

보충

A. 왜 TeamLowTeamHigh인가?

정렬된 팀 ID를 사용하면 모든 매치업에 고정된 방향이 부여됩니다. 이는 (A, B)(B, A) 같은 중복 표현을 피하고, 타깃 정의를 안정적으로 만듭니다. Target = 1은 ID가 더 낮은 팀이 이겼다는 의미입니다.

B. 왜 surrogate_strength가 중요한가

공식 시드는 강력하지만 희소합니다. 그래서 노트북은 모든 팀에 대해 시즌·리그 상대적 강도 점수를 구축한 다음, 이를 pseudo_seed로 변환합니다. 이렇게 하면 공식 시드가 존재하지 않을 때에도 모델이 시드 같은 피처를 유지할 수 있습니다.

C. 왜 단순 로지스틱 베이스라인이 여전히 유용한가

이 노트북은 베이스라인이지, 최종 앙상블이 아닙니다. 모델이 단순하기 때문에, 점수 향상은 대부분 더 나은 피처 설계, 더 나은 누설 제어, 더 나은 검증 로직에서 나옵니다. 그래서 반복(iteration)이 훨씬 쉬워집니다.

D. 이 노트북의 실용적 유용성

이 노트북의 가장 강력한 기여는 모델링의 새로움이 아니었습니다. 실용적인 유용성이었습니다. 라이브 Kaggle 대회 중에는 그것이 아주 중요합니다.

이 코드는 다른 참가자가 물려받기 쉬운 워크플로를 제공합니다.

  • 경로를 알아서 찾아주는 레이어를 통해 대회 파일을 읽기
  • 현재 시즌 데이터를 누설 안전한 컷오프로 정렬하기
  • 재사용 가능한 팀 피처 스토어 하나를 구축하기
  • 그것을 페어와이즈 학습 데이터로 변환하기
  • 시즌-포워드로 검증하기
  • 내보내기 전에 검사되는 제출 파일을 생성하기

이것이 바로 사람들이 종종 조용히 fork하고, 로컬에서 변형하고, 시간을 아껴줬다는 이유로 추천하는 종류의 노트북입니다.

E. 재현 체크리스트

  • 대회 데이터 파일이 Kaggle에 있거나 MMLM2026_DATA_DIR을 통해 사용 가능한지 확인
  • 셀을 순서대로 실행
  • 정렬된 2026 컷오프 일자를 확인
  • core, expanded, 블렌딩 예측의 OOF Brier를 검토
  • submission.csv가 최종 정합성 검사를 통과하는지 검증

최종 요약

이 베이스라인 노트북은 March Machine Learning Mania 2026을 위한 모델링 템플릿으로 동작합니다. 누설 안전한 시즌 절단에서 출발해, 다중 블록 팀 피처 스토어를 구축하고, 그 스토어를 페어와이즈 토너먼트 예제로 변환하고, 시즌-포워드 OOF Brier로 검증한 뒤, 검사된 제출 파일로 마무리합니다.

모델은 의도적으로 단순하게 고정되어 있습니다. 로지스틱 회귀 하나뿐입니다. 블렌딩 OOF Brier 0.169731은 두 단독 뷰(core 0.171637, expanded 0.172966)를 작지만 일관된 폭으로 앞섭니다. 즉 이 향상은 도약이 아니라 잔차 보정입니다. 모델을 고정해 두었기 때문에, 점수의 변동은 모델 복잡도가 아니라 피처 설계, 93일 누설 안전 컷오프, 시즌-포워드 검증에 귀속됩니다.

같은 분해는 다른 참가자의 결과에도 그대로 적용됩니다. 모델링 단계가 제약될 때 지배적인 비용 항은 앞서 정의한 $L_{\text{user}}$의 항들입니다. 파이프라인을 이해하는 시간, 디버깅하는 시간, 그리고 잘못된 제출이나 조용한 누설이라는 이산적 패널티입니다. 이 노트북은 명시적 컷오프, 재사용 가능한 피처 스토어, 시즌-포워드 검사, 내보내기 전에 검증되는 제출을 통해 그 항들을 끌어내립니다. 이는 새로움과 무관하게 측정 가능한 효과입니다.

이 논리는 실제 수치로 뒷받침됩니다. 공개된 실행은 퍼블릭 리더보드에서 0.1172485를 기록했고 모든 제출 포맷 검사를 통과했으며, 더 넓은 March Machine Learning Mania 2026 실행은 92위로 마무리되어 Silver Medal을 획득했습니다. </content> </invoke>

This post is licensed under CC BY 4.0 by the author.