zerovoids.dev

그 해가 무엇이었나

published 2026-10-05part 05 / 16
art-mlb-playermetricsbaseballdeterminism

발자취는 한 해에 한 칸이다. 칸마다 두 가지를 정해야 했다. 그 해를 무엇이라 부를지, 그리고 지면에 얼마나 크게 실을지.

해마다 위에 작은 라벨이 붙는다. 데뷔, 전성, 짧은 해, 리그 밖. 처음에는 커리어 위치로 매기려 했다. 앞쪽은 성장, 가운데는 전성, 뒤쪽은 황혼. 그렇게 짜고 돌려 보니 틀린 지면이 나왔다.

위치로 자르면 순서가 생기고, 순서가 생기면 뒤집힌다. 늦게 피어난 선수는 전성기가 뒤쪽에 있는데 라벨이 그 자리를 황혼이라 부른다. 서른한 살에 최고의 해를 보낸 선수한테 쇠퇴라고 적는 셈이다. 기록에 없는 흥망을 라벨이 혼자 주장한다.

그 해의 사실만 본다

그래서 커리어 위치를 버리고 그 해에 무엇이 있었나만 보게 했다. 데뷔와 마지막 해만 위치를 쓴다.

위에서부터 처음 맞는 걸 쓴다. 리그 기록이 아예 없는 해는 리그 밖. 첫 시즌은 데뷔, 마지막 시즌은 마지막 해. 뛰었지만 리그에 속하지 않은 해는 무소속. 시즌을 규정하는 수상이 있거나 리그 선두 타이틀이 둘 이상이면 전성. 타이틀 하나나 올스타 선정이 있으면 간판. 출장몫이 문턱 아래면 짧은 해. 나머지는 보통.

year-judgment.mjs
export function kickerFor({ index, lastIndex, hasRecord, inLeague, titles, awards, share }) {
  if (!hasRecord) return 'ELSEWHERE'
  if (index === 0) return 'DEBUT'
  if (index === lastIndex) return 'CURTAIN'
  if (!inLeague) return 'INDEPENDENT'
  if (awards.some((a) => SEASON_DEFINING.has(a.code)) || titles >= 2) return 'REIGN'
  if (titles >= 1 || awards.some((a) => a.code === 'all_star')) return 'MARQUEE'
  if (share != null && share < cut) return 'SHORT'
  return 'REGULAR'
}

라벨을 정하고 분포를 재 봤다. 명예의 전당 선수 281명, 5,051시즌을 돌렸다. 보통 37.8%, 간판 30.1%, 짧은 해 12.6%, 전성 8.4%, 데뷔와 마지막 해가 각각 5.6%. 어느 라벨도 지면을 삼키지 않고 비는 라벨도 없었다.

짧은 해의 문턱은 그 모집단의 1사분위수로 잡았다. 투수는 이닝을 팀 경기수로 나눈 0.59, 야수는 출장을 나눈 0.53이다. 자기 커리어 중앙값과 견주는 방법도 재 봤는데, 보통이 41%까지 부풀어서 구분이 흐려졌다.

무게는 한 커리어 안에서 잰다

둘째는 밀도다. 그 해 글자가 얼마나 크게 서고 사진을 몇 장까지 걸 수 있나를 정한다.

라벨과 달리 이건 그 선수 안에서 잰다. 절대 컷을 전체 모집단으로 잡아 봤더니 60%가 조용한 해로 떨어졌다. 우리가 쓰는 선수는 내부 서클이라 전부 반대쪽 꼬리에 몰려 있다. 명예의 전당 선수한테 평균보다 조용한 해라는 말은 뜻이 안 선다.

한 커리어 안에서 점수를 낸다. 그 해에 할 말이 얼마나 있나를 센다. 수상은 종류마다 가중치가 있고, 마일스톤 하나와 출처 있는 생애 사건 하나가 각각 한 점이다. 위아래 띠는 각각 네 해 중 한 해를 노린다.

동점은 자르지 않는다

분위수를 그대로 쓰니 문제가 생겼다.

컷에 몰린 동점이 통째로 위 띠로 넘어온다. 그러면 한 해 걸러 한 해가 리드 항목이 되고, 발자취가 큰 글씨와 작은 글씨를 번갈아 낸다. 더 걸린 건 읽는 쪽이었다. 같은 점수를 받은 두 해가 다른 크기로 인쇄되면 버그로 보인다. 왜 저 해만 크냐고 물으면 답할 말이 없다.

그래서 서로 다른 점수값만 후보로 놓고, 그 컷이 잡는 해의 수가 목표에 가장 가까운 걸 고른다. 목표를 정확히 못 맞혀도 동점은 안 가른다.

year-judgment.mjs
/**
 * 목표 비율에 가장 가까운 컷 — **동점은 자르지 않는다.**
 */
export function cutAt(scores, ratio, high) {
  const target = scores.length * ratio
  const distinct = [...new Set(scores)].sort((a, b) => (high ? b - a : a - b))
  for (const c of distinct) {
    const gap = Math.abs(scores.filter((v) => (high ? v >= c : v <= c)).length - target)
    if (gap < bestGap) {
      bestGap = gap
      best = c
    }
  }
  return best
}

밀도가 정하는 건 무게뿐이고 문장 길이는 아니다. 시즌이 여섯 해보다 적으면 분위가 뜻이 없어서, 기록 없는 해만 빼고 전부 보통으로 둔다.

이 두 판정은 순수 함수로 떼어 두고 표로 검증한다. 라벨은 그 해의 사실만 보고 커리어의 어느 지점인지는 안 본다. 밀도는 그 선수 안에서 재되 동점을 안 가른다. 둘 다 입력이 같으면 늘 같은 답을 낸다.