Skip to content

Latest commit

 

History

History
255 lines (183 loc) · 11.6 KB

File metadata and controls

255 lines (183 loc) · 11.6 KB

콜드 스타트 — 표 0을 어떻게 채울 것인가

경기가 끝난 직후 평점은 0표입니다. 첫 방문자가 보는 건 빈 점수판이고, 빈 점수판은 "아무도 안 왔다"는 신호라 그대로 나갑니다. user-scenarios 4장이 이걸 "표 0 — 가장 큰 문제" 로 적어뒀습니다. 이 문서는 그 해법입니다.

관련 코드: tools/estimate.py · tools/sync_matches.py · tools/build_players.py · public/index.htmlformOf()


1. 하지 않기로 한 것 ★

Firestore 에 합성 투표를 써서 "2,850명이 매겼어요" 를 만드는 방법이 있고, 그게 제일 쉽고 제일 잘 먹힙니다. 안 합니다. 세 가지가 동시에 망가집니다.

  1. 거짓말입니다. 참여자 수는 의견이 아니라 사실 진술입니다. 숫자를 지어내면 그 화면의 다른 숫자도 전부 의심받습니다.
  2. 분포가 이 서비스의 자산입니다. hupu-spec 3.4 — UZI 5.4점의 정체는 최저점 10,807명과 만점 7,098명의 갈라짐이었고, 그 갈라짐이 후푸가 회자되는 이유입니다. 합성표를 섞으면 "평가가 갈렸어요" 가 진짜 갈라짐인지 우리가 만든 갈라짐인지 영영 모릅니다.
  3. 되돌릴 수 없습니다. 한 번 섞이면 분리가 안 됩니다. 나중에 "그때 그건 가짜였다" 고 걷어낼 방법이 없습니다.

그래서 예상 폼은 표가 아닙니다. 표 옆에 따로 서 있는 다른 종류의 값이고, 화면에서 "예상" 이라고 이름을 달고, 진짜 표가 쌓이면 물러납니다.


2. 무엇으로 채우나

경기 기록만 씁니다. 세트가 끝나는 순간 확정되고, 추측이 안 들어갑니다.

신호 출처 비고
K / D / A livestats 최종 프레임 기존
CS · 골드 기존
킬 관여 · 딜 비중 · 딜 전환 livestats details 기존 (후푸 레이더 축)
와드 설치·제거 livestats details 이번에 추가 — 서포터의 유일한 객관 지표
세트 승패 최종 프레임의 억제기·포탑·골드 이번에 추가 — API 어디에도 승자가 없어 유추
경기 시간 첫 프레임 ↔ 최종 프레임 이번에 추가 — 분당 지표의 분모
일방성 팀 킬·골드 격차 이번에 추가 — 선수 기록을 더해서 계산
POG 손으로 ("pog": true) 7장 참고

승패가 API 에 없습니다. getEventDetailsgames[] 에는 state / teams / vods 뿐입니다. 최종 프레임에서 유추합니다 — 이긴 팀이 넥서스를 깨므로 억제기가 반드시 더 많고, 같으면 포탑, 그것도 같으면 골드로 가릅니다. 실제 표본에서 억제기 2:0, 포탑 9:0 처럼 깔끔하게 갈립니다.


3. 모델

왜 절대값이 아니라 '순서와 폭' 을 맞추나

박스스코어로 "이 선수가 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 입니다. "졌지만 잘했다" 가 잡힙니다. 이게 모델이 쓸 만하다는 신호입니다.


4. 진짜 표로 넘어가는 방식

공식을 중간에 바꾸지 않습니다. 숫자가 튀는 지점이 없습니다 — 딱지만 떨어집니다.

표시값 = (점수합 + 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점 받았다" 로 읽힙니다.


5. 랭킹도 콜드 스타트였습니다

작업 중에 발견한 것: players.json 의 순위에 오른 선수가 0명이었습니다. 표가 하나도 없으니 랭킹 화면이 통째로 비어 있었습니다.

두 가지를 고쳤습니다.

  1. 사전값을 전체 평균 → 선수별 예상 폼으로. 전체 평균(6.5)으로 누르면 Faker 든 신인이든 같은 값으로 눌려서, 표가 적을 때 순위가 무의미해집니다.
  2. 표 0인 선수도 순위에 넣습니다. 시즌 초에는 이게 랭킹의 전부입니다. 빈 표를 보여주느니 "경기 기록 기준 예상" 이라고 이름 붙이는 쪽이 낫습니다.

결과: 51명이 1일차부터 순위에 뜹니다. 헤더 문구도 상태에 따라 바뀝니다 — 표가 하나도 없으면 "아직 팬 평점이 없어 경기 기록으로 계산한 예상입니다".


6. 모델을 어떻게 믿을 것인가

검산 없는 모델은 자산이 아니라 부채입니다. 표가 쌓이면 성적표가 나옵니다.

python3 tools/estimate.py --check

5표 넘는 항목만 골라 예상값과 실제 팬 평균을 맞춰보고, 평균절대오차와 상관을 찍습니다. 순서만 맞으면 되므로 상관 0.6 이 실용 하한선입니다. 차이가 큰 순서로 6건을 같이 보여주니, 모델이 어디서 틀리는지가 바로 보입니다.

오차가 계속 크면 손볼 곳은 정해져 있습니다 — W 의 가중치, DOM_*, WIN_CENTER/LOSE_CENTER. 전부 estimate.py 상단 상수입니다.

지금은 표가 없어 --check 가 "비교할 게 없습니다" 를 냅니다. 첫 경기 표가 쌓이는 날 이걸 돌리는 게 다음 할 일입니다.


7. 인벤 기사·LCK 중계는 왜 안 쓰나

진지하게 검토했고, v1 에서는 안 쓰는 쪽을 권합니다.

판단
정보량 기사 대부분이 박스스코어의 재진술입니다. "Faker 가 캐리했다" 는 기사는 Faker 가 9/1/8 일 때 나옵니다 — 이미 모델에 들어 있습니다
깨지는 방식 HTML 파싱은 상대가 마크업을 바꾸면 조용히 틀린 값을 냅니다. 조용한 실패가 제일 나쁩니다
권리 언론사 본문을 긁어 점수로 파생시키는 건 회색지대입니다. 정적 호스팅 서비스가 질 위험이 아닙니다
난이도 한국어 경기 리뷰에서 선수별 감정을 뽑는 건 연구 과제입니다. 그 값어치가 위 세 줄을 못 넘깁니다

대신 값싸고 정확한 외부 신호 하나만 채널로 열어뒀습니다.

POG (Player of the Game)

LCK 가 매 세트 공식으로 하나 뽑습니다. 사실이지 의견이 아니고, 박스스코어가 놓치는 걸 사람이 이미 판단해 준 값입니다. 세트당 하나라 손으로 넣어도 10초입니다.

// public/matches.json
{ "pos": "MID", "name": "Faker", "pog": true, ... }
python3 tools/estimate.py     # 8.5 → 9.1

sync_matches.py재동기화 때 pog 를 보존합니다. 경기 ID 를 보존하는 것과 같은 이유입니다 — 매번 다시 넣게 만들면 아무도 안 넣습니다.

나중에 이 채널을 자동화하고 싶어지면, 긁을 대상은 기사 본문이 아니라 POG 발표 하나입니다. 구조가 단순하고 깨져도 시끄럽게 깨집니다.


8. 운영

경기 끝나고 순서대로 셋을 돌립니다.

python3 tools/sync_matches.py     # 라이엇 API → matches.json
python3 tools/estimate.py         # 예상 폼 계산 → matches.json 의 est
python3 tools/build_players.py    # 누적 평점 + 예상 → players.json

estimate.py 는 API 를 안 칩니다. matches.json 만 읽고 씁니다 — 가중치를 바꿔가며 몇 번이고 다시 돌려도 됩니다.

POG 를 넣었다면 estimate.py 부터 다시 돌리면 됩니다.


9. 아직 못 하는 것

  • 밴픽·오브젝트 기여 가 안 들어갑니다. "한타 한 번으로 경기를 뒤집은 이니시" 같은 건 박스스코어에 안 남습니다. POG 가 그 구멍을 일부 메웁니다.
  • 표본이 얇습니다. 170 선수-세트로 만든 기준입니다. 시즌이 쌓이면 기준이 자동으로 좋아지지만, 지금 값은 과거 경기 est 를 재계산할 때마다 조금씩 움직입니다. 표시용이라 문제는 없지만, 고정하고 싶으면 기준을 상수로 구워 넣으면 됩니다.
  • 팬은 서사도 봅니다. "결승에서 그 픽" 같은 맥락은 못 잽니다. 그건 모델이 아니라 한마디(댓글)가 할 일입니다.
  • --check 를 아직 못 돌려봤습니다. 실제 표가 0이라 검증되지 않은 모델입니다. 지금 상태는 "말이 되는 값을 내고 눈으로 검산했다" 까지입니다.