경기가 끝난 직후 평점은 0표입니다. 첫 방문자가 보는 건 빈 점수판이고, 빈 점수판은 "아무도 안 왔다"는 신호라 그대로 나갑니다. user-scenarios 4장이 이걸 "표 0 — 가장 큰 문제" 로 적어뒀습니다. 이 문서는 그 해법입니다.
관련 코드: tools/estimate.py ·
tools/sync_matches.py ·
tools/build_players.py ·
public/index.html 의 formOf()
Firestore 에 합성 투표를 써서 "2,850명이 매겼어요" 를 만드는 방법이 있고, 그게 제일 쉽고 제일 잘 먹힙니다. 안 합니다. 세 가지가 동시에 망가집니다.
- 거짓말입니다. 참여자 수는 의견이 아니라 사실 진술입니다. 숫자를 지어내면 그 화면의 다른 숫자도 전부 의심받습니다.
- 분포가 이 서비스의 자산입니다. hupu-spec 3.4 — UZI 5.4점의 정체는 최저점 10,807명과 만점 7,098명의 갈라짐이었고, 그 갈라짐이 후푸가 회자되는 이유입니다. 합성표를 섞으면 "평가가 갈렸어요" 가 진짜 갈라짐인지 우리가 만든 갈라짐인지 영영 모릅니다.
- 되돌릴 수 없습니다. 한 번 섞이면 분리가 안 됩니다. 나중에 "그때 그건 가짜였다" 고 걷어낼 방법이 없습니다.
그래서 예상 폼은 표가 아닙니다. 표 옆에 따로 서 있는 다른 종류의 값이고, 화면에서 "예상" 이라고 이름을 달고, 진짜 표가 쌓이면 물러납니다.
경기 기록만 씁니다. 세트가 끝나는 순간 확정되고, 추측이 안 들어갑니다.
| 신호 | 출처 | 비고 |
|---|---|---|
| K / D / A | livestats 최종 프레임 | 기존 |
| CS · 골드 | 〃 | 기존 |
| 킬 관여 · 딜 비중 · 딜 전환 | livestats details | 기존 (후푸 레이더 축) |
| 와드 설치·제거 | livestats details | 이번에 추가 — 서포터의 유일한 객관 지표 |
| 세트 승패 | 최종 프레임의 억제기·포탑·골드 | 이번에 추가 — API 어디에도 승자가 없어 유추 |
| 경기 시간 | 첫 프레임 ↔ 최종 프레임 | 이번에 추가 — 분당 지표의 분모 |
| 일방성 | 팀 킬·골드 격차 | 이번에 추가 — 선수 기록을 더해서 계산 |
| POG | 손으로 ("pog": true) |
7장 참고 |
승패가 API 에 없습니다. getEventDetails 의 games[] 에는
state / teams / vods 뿐입니다. 최종 프레임에서 유추합니다 — 이긴 팀이
넥서스를 깨므로 억제기가 반드시 더 많고, 같으면 포탑, 그것도 같으면 골드로
가릅니다. 실제 표본에서 억제기 2:0, 포탑 9:0 처럼 깔끔하게 갈립니다.
박스스코어로 "이 선수가 7.3인지 7.6인지" 를 맞히는 건 불가능합니다. 하지만 "이 세트에서 누가 제일 잘했는지" 의 순서는 꽤 정확히 나옵니다.
그래서 가중치로 순위를 만들고, 그 순위를 승패로 잡은 눈금에 tanh 로 눌러
담습니다. 가중치가 좀 틀려도 결과가 말이 되는 범위를 안 벗어납니다.
z = 포지션 안에서 중앙값·MAD 로 표준화, ±2.5 에서 자름
comp = Σ (포지션별 가중치 × z)
center = 이겼으면 7.2 + 0.9×일방성, 졌으면 5.4 − 1.35×일방성
est = clamp(center + 1.55 × tanh(comp / 1.4), 2.0, 9.7) (+ POG 0.6)
서포터의 CS 는 미드의 1/5 입니다. 한 자로 재면 서포터는 영원히 4점대이고, 그건 모델이 아니라 버그입니다. 지표마다 포지션 안에서 표준화하고, 가중치도 포지션마다 다릅니다 — 서포터는 시야·킬관여가, 원딜은 딜 비중이 점수를 만듭니다.
넣기 전에는 19:2 학살의 승자가 7.47.9 였습니다. 팬은 그런 경기에서 89점을
줍니다. 모든 경기가 7점대로 뭉치면 "폼 미쳤다" 가 안 나오고, 그게 이
서비스의 콘텐츠입니다.
| 넣기 전 | 넣은 뒤 | |
|---|---|---|
| 19:2 학살 승자 5명 | 7.4 ~ 7.9 | 8.0 ~ 8.5 |
| 같은 경기 패자 5명 | 4.6 ~ 5.6 | 3.7 ~ 4.7 |
| 코퍼스 전체 범위 | 4.1 ~ 8.4 | 3.1 ~ 8.9 |
지금 170 선수-세트, 포지션당 34개. 평균·표준편차는 이상치 하나에 휘둘려서 중앙값과 MAD 로 재고 ±2.5 에서 자릅니다. 포지션 표본이 12 미만이면 예상값을 아예 내지 않습니다 — 억지로 숫자를 내는 것보다 없는 게 낫습니다.
38.6분 접전(일방성 0.0), GEN vs KT 1세트:
승 TOP Casting 7.7 2/2/7 딜 25% 전환 1.24
승 MID Roamer 7.6 9/1/4 킬관여 93%
승 SUP Namgung 6.5 0/9/10 ← 이긴 팀 최하위. 9데스
패 MID VicLa 6.5 5/1/8 딜 45% 전환 2.04 ← 진 팀 최상위
패 TOP Clear 4.6 2/4/7 딜 10% 전환 0.51
진 팀에서 혼자 캐리한 VicLa 가 이긴 팀 서포터와 같은 6.5 입니다. "졌지만 잘했다" 가 잡힙니다. 이게 모델이 쓸 만하다는 신호입니다.
공식을 중간에 바꾸지 않습니다. 숫자가 튀는 지점이 없습니다 — 딱지만 떨어집니다.
표시값 = (점수합 + W × 예상) / (표수 + W) W = 6
| 표수 | 예상 비중 | 화면 |
|---|---|---|
| 0 | 100% | 예상 8.5 · 0표 |
| 3 | 67% | 예상 8.1 · 3표 |
| 6 | 50% | 7.8 · 평가 모으는 중 (딱지 뗌) |
| 30 | 17% | 7.2 · 30명 평가 |
| 300 | 2% | 사실상 순수 팬 평점 |
W=6 을 고른 이유: W 가 작으면 세 표짜리 평균이 10.0 으로 튀고, 크면 진짜 여론이 안 보입니다. 6이면 6표에서 반반, 제품 목표 참여자 30명 에서 17% 입니다. 브리게이딩 방어도 겸합니다.
분포 막대는 절대 안 건드립니다. 언제나 진짜 표만. 0표면 0% 다섯 줄이 그대로 보이고, 그게 "당신이 첫 번째" 라는 제일 정직한 CTA 입니다.
| 자리 | 0표일 때 |
|---|---|
| 세트 목록 점수 | 예상값 + 예상 · 0표, 점수 색 없음(회색) |
| 선수 상세 큰 숫자 | 〃, 카드 제목이 "표 분포" → "예상 폼" |
| 분포 막대 | 0% (손대지 않음) |
| 랭킹 | 예상값으로 순위, 예상 · N세트 |
| 참여자 수 | 언제나 진짜 숫자 |
점수 색(8점 이상 파랑)은 팬 합의에만 씁니다. 예상값에 파랑을 칠하면 "8점 받았다" 로 읽힙니다.
작업 중에 발견한 것: players.json 의 순위에 오른 선수가 0명이었습니다.
표가 하나도 없으니 랭킹 화면이 통째로 비어 있었습니다.
두 가지를 고쳤습니다.
- 사전값을 전체 평균 → 선수별 예상 폼으로. 전체 평균(6.5)으로 누르면 Faker 든 신인이든 같은 값으로 눌려서, 표가 적을 때 순위가 무의미해집니다.
- 표 0인 선수도 순위에 넣습니다. 시즌 초에는 이게 랭킹의 전부입니다. 빈 표를 보여주느니 "경기 기록 기준 예상" 이라고 이름 붙이는 쪽이 낫습니다.
결과: 51명이 1일차부터 순위에 뜹니다. 헤더 문구도 상태에 따라 바뀝니다 — 표가 하나도 없으면 "아직 팬 평점이 없어 경기 기록으로 계산한 예상입니다".
검산 없는 모델은 자산이 아니라 부채입니다. 표가 쌓이면 성적표가 나옵니다.
python3 tools/estimate.py --check5표 넘는 항목만 골라 예상값과 실제 팬 평균을 맞춰보고, 평균절대오차와 상관을 찍습니다. 순서만 맞으면 되므로 상관 0.6 이 실용 하한선입니다. 차이가 큰 순서로 6건을 같이 보여주니, 모델이 어디서 틀리는지가 바로 보입니다.
오차가 계속 크면 손볼 곳은 정해져 있습니다 — W 의 가중치, DOM_*,
WIN_CENTER/LOSE_CENTER. 전부 estimate.py 상단 상수입니다.
지금은 표가 없어 --check 가 "비교할 게 없습니다" 를 냅니다. 첫 경기 표가
쌓이는 날 이걸 돌리는 게 다음 할 일입니다.
진지하게 검토했고, v1 에서는 안 쓰는 쪽을 권합니다.
| 판단 | |
|---|---|
| 정보량 | 기사 대부분이 박스스코어의 재진술입니다. "Faker 가 캐리했다" 는 기사는 Faker 가 9/1/8 일 때 나옵니다 — 이미 모델에 들어 있습니다 |
| 깨지는 방식 | HTML 파싱은 상대가 마크업을 바꾸면 조용히 틀린 값을 냅니다. 조용한 실패가 제일 나쁩니다 |
| 권리 | 언론사 본문을 긁어 점수로 파생시키는 건 회색지대입니다. 정적 호스팅 서비스가 질 위험이 아닙니다 |
| 난이도 | 한국어 경기 리뷰에서 선수별 감정을 뽑는 건 연구 과제입니다. 그 값어치가 위 세 줄을 못 넘깁니다 |
대신 값싸고 정확한 외부 신호 하나만 채널로 열어뒀습니다.
LCK 가 매 세트 공식으로 하나 뽑습니다. 사실이지 의견이 아니고, 박스스코어가 놓치는 걸 사람이 이미 판단해 준 값입니다. 세트당 하나라 손으로 넣어도 10초입니다.
python3 tools/estimate.py # 8.5 → 9.1sync_matches.py 가 재동기화 때 pog 를 보존합니다. 경기 ID 를
보존하는 것과 같은 이유입니다 — 매번 다시 넣게 만들면 아무도 안 넣습니다.
나중에 이 채널을 자동화하고 싶어지면, 긁을 대상은 기사 본문이 아니라 POG 발표 하나입니다. 구조가 단순하고 깨져도 시끄럽게 깨집니다.
경기 끝나고 순서대로 셋을 돌립니다.
python3 tools/sync_matches.py # 라이엇 API → matches.json
python3 tools/estimate.py # 예상 폼 계산 → matches.json 의 est
python3 tools/build_players.py # 누적 평점 + 예상 → players.jsonestimate.py 는 API 를 안 칩니다. matches.json 만 읽고 씁니다 —
가중치를 바꿔가며 몇 번이고 다시 돌려도 됩니다.
POG 를 넣었다면 estimate.py 부터 다시 돌리면 됩니다.
- 밴픽·오브젝트 기여 가 안 들어갑니다. "한타 한 번으로 경기를 뒤집은 이니시" 같은 건 박스스코어에 안 남습니다. POG 가 그 구멍을 일부 메웁니다.
- 표본이 얇습니다. 170 선수-세트로 만든 기준입니다. 시즌이 쌓이면
기준이 자동으로 좋아지지만, 지금 값은 과거 경기
est를 재계산할 때마다 조금씩 움직입니다. 표시용이라 문제는 없지만, 고정하고 싶으면 기준을 상수로 구워 넣으면 됩니다. - 팬은 서사도 봅니다. "결승에서 그 픽" 같은 맥락은 못 잽니다. 그건 모델이 아니라 한마디(댓글)가 할 일입니다.
--check를 아직 못 돌려봤습니다. 실제 표가 0이라 검증되지 않은 모델입니다. 지금 상태는 "말이 되는 값을 내고 눈으로 검산했다" 까지입니다.