달무티 CPU 플레이어 만들기 Part 4: 만들어 보니 hard 봇이 더 약했다
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
설계안이 검증을 만나면#
Part 1에서 공정성 경계와 결정적 easy 봇을 만들었고, Part 2에서 hard 봇의 정보 모델을 세웠고, Part 3에서 수 평가 함수와 전략적 패스로 실제 결정을 내렸습니다. 세 편 모두 “이 글은 설계안이고 코드는 논지를 보이기 위한 발췌"라고 밝혀 두었습니다.
그 설계안을 실제 게임에 구현하고, 시뮬레이터를 붙여 easy 봇과 같은 조건에서 붙여 봤습니다. 결과는 이랬습니다.
최초 hard 봇은 easy 봇보다 평균 종료 순위가 0.6601등 나빴습니다.
이름만 hard 였던 셈입니다. Part 3 의 마지막 절에서 “봇을 만드는 것보다 봇이 정말 세졌는지 재는 것이 더 어렵다"고 썼는데, 정작 그 문장을 쓴 설계안 자체가 재보기 전까지는 반대 방향으로 가고 있었습니다.
이 글은 그 회고입니다. 다룰 것은 넷입니다.
- 무엇을 어떻게 쟀는가 (Part 3 §5 의 방법론이 실제로 어떤 장치가 되었는가)
- 어떤 구조 결함이 hard 봇을 약하게 만들었는가 (셋)
- 어떤 고급 기능이 실제로 살아남았는가
- 정정 후의 정식 수치는 얼마인가
지표부터 못 박아 두겠습니다. 이 글의 모든 숫자는 Hard 평균 종료 순위 - Easy 평균 종료 순위 입니다. 순위는 낮을수록 좋으므로, 음수면 hard 가 더 좋다 는 뜻입니다.
한 가지 미리 말해 둘 것이 있습니다. 이 글은 Part 1~3 을 통째로 폐기하지 않습니다. 공개 정보 모델, 확실승수 판정, 전략적 패스는 검증에서도 살아남았습니다. 무너진 것은 그 재료들을 최종 점수로 합치는 경계 였습니다.
1. 먼저, 재는 장치부터#
Part 3 §5 는 “미러 매치로 분산을 죽이고, 평균 등수를 지표로 쓰고, held-out 시드로 검증하고, 좌표 하강으로 가중치를 맞춘다"는 원칙만 적어 두었습니다. 원칙은 그대로 유효했지만, 실제로 돌려 보니 원칙만으로는 부족했고 다음 장치들이 더 필요했습니다.
① 양방향 focal 구성. hard 1명 대 easy N-1 명만 재면, 측정하는 것이 “hard 의 강함"인지 “easy 무리 사이에서 튀는 정책의 이점"인지 구분되지 않습니다. 그래서 easy 1명 대 hard N-1 명도 함께 재고, 두 구성의 부호가 일관되는지 봅니다.
② 모든 최초 좌석 회전. 같은 딜을 좌석 수만큼 돌려 모든 정책이 모든 자리를 한 번씩 잡게 합니다. Part 3 에서 예고한 듀플리케이트 방식 그대로입니다.
③ label swap 부호 반전 테스트. 두 정책의 이름표만 맞바꿔 같은 실험을 돌렸을 때 delta 의 부호만 뒤집히는지 확인합니다. 뒤집히지 않으면 그것은 정책 차이가 아니라 하네스 버그입니다. 이 테스트 하나가 “좌석 배정이 한쪽으로 쏠려 있었다"류의 실수를 조용히 잡아 줍니다.
④ canonical JSON 결정성. 같은 입력을 직렬로 돌린 report 와 병렬 worker 로 돌린 report 가 byte-for-byte 같아야 합니다. worker 수와 출력 경로처럼 결과 의미와 무관한 필드는 canonical 표현에서 제외합니다. Part 1 부터 card ID tie-break 로 확보해 온 결정성이 여기서 값을 합니다. 결정성이 없으면 “이 숫자가 재현되는가"라는 질문 자체를 할 수 없습니다.
⑤ 18개 held-out cell. 4·5·6인 x 기본·고빈도 혁명 x 3·5·10판의 조합입니다. 특정 인원수나 특정 경기 길이에서만 좋아지는 정책을 걸러내기 위한 격자입니다.
⑥ seed-cluster 신뢰구간. 같은 seed 가 여러 cell 에 등장하므로, cell 별 관측을 그대로 독립 표본처럼 세면 신뢰구간이 실제보다 좁아집니다. 같은 seed 의 여러 cell 을 먼저 평균한 뒤 seed 단위로 구간을 계산합니다.
⑦ phase 별 ablation 과 decision divergence 로그. hard 의 일부 단계(리드·팔로우·세금·혁명)만 easy 로 갈아끼운 혼합 정책을 만들어 각각 재고, 같은 공개 상태에서 두 정책의 선택이 달랐던 순간만 JSONL 로 남깁니다. 이 두 가지가 이번 진단의 핵심 도구였습니다.
승격 기준은 이렇게 고정했습니다.
| 단계 | 통과 조건 |
|---|---|
| tuning 후보 승격 | seed 별 paired delta 의 95% 신뢰구간 상한이 0 미만 |
| held-out 전체 gate | 95% 신뢰구간 상한이 0 미만 |
| 인원수·혁명 빈도·경기 길이별 gate | 95% 신뢰구간 상한이 +0.10 순위 이하 |
| 18개 cell | 각 점 추정치가 +0.10 순위 이하 |
전체 gate 는 “확실히 개선"을, 나머지 gate 는 “어느 조건에서도 크게 나빠지지 않음"을 봅니다. 하나의 기준으로 둘 다 요구하면 통과할 수 있는 정책이 거의 없습니다.
2. 원인 1 — 같은 이득을 두 번 칭찬했다#
Part 3 의 수 평가 함수는 플랜 개선도를 이렇게 계산했습니다.
planGain = float64(before.MinPlays-after.MinPlays) +
0.5*(after.SafeLead-before.SafeLead) -
float64(after.Orphans-before.Orphans)
여기에 결함이 있습니다. 손패에 12 x 2 와 9 x 1 이 있고 새 필드를 여는 상황을 보겠습니다.
9 x 1을 내면 제출 횟수가 하나 줄고, 동시에 고아 카드가 하나 사라집니다.12 x 2를 내면 제출 횟수가 하나 줄고, 고아 카드는 그대로입니다.
이 상황에서 두 항은 같은 사건을 다른 이름으로 두 번 세고 있습니다. 고아 카드 하나를 없앤 것이 곧 제출 횟수를 하나 줄인 것이기 때문입니다. 그런데 점수식은 이를 서로 다른 이득처럼 더했고, 그 결과 카드를 한 장만 버리는 9 x 1 이 두 장을 버리는 12 x 2 보다 높은 점수를 받았습니다.
창고에 불량 재고 두 상자를 한 번에 처분할 기회가 있는데, 재고 목록에서 한 줄이 깔끔하게 지워진다는 이유로 더 비싼 물건 하나만 버린 셈입니다.
수정 방향은 적용 범위를 나누는 것 입니다.
// planGain 은 후보 c 를 냈을 때의 플랜 개선도다.
// lead 와 follow 에서 항의 구성이 다르다.
func (p *HardPolicy) planGain(v GameView, l *Ledger, c *Candidate) float64 {
before := BestPlan(v.Hand, l)
after := BestPlan(removeCards(v.Hand, c.Cards), l)
gain := float64(before.MinPlays-after.MinPlays) +
0.5*(after.SafeLead-before.SafeLead)
if v.Table != nil {
// follow 는 제출 장수가 테이블에 고정돼 있다.
// 후보들 사이에 배출량 차이가 없으므로, 고아 제거 가치를 남겨 둬도
// "많이 버리는 수" 를 밀어내지 못한다.
gain -= float64(after.Orphans - before.Orphans)
}
return gain
}
Orphans 지표 자체가 틀린 것은 아닙니다. Part 2에서 정의한 대로 손패 구조를 설명하는 데는 여전히 유용합니다. 문제는 그것을 MinPlays 와 나란히 놓고 독립적으로 보상 한 것이었습니다. 지표를 만드는 것과 그 지표를 목적 함수에 더하는 것은 다른 일입니다.
3. 원인 2 — 리드를 점수식에 맡긴 것이 가장 큰 약점이었다#
중복 보상을 제거한 뒤에도 hard 봇은 계속 이런 수를 뒀습니다.
- easy 봇:
12 x 4제출 - hard 봇:
3 x 1제출
3 은 강한 카드입니다. 한 장만 내면 받을 수 있는 사람이 거의 없으니 리드권을 지킬 확률은 높아집니다. Part 3 의 controlProb x controlValue 는 이 가능성을 크게 평가했습니다. 그러나 그 대가로 귀중한 강한 카드를 태우고 손패는 겨우 한 장 줄었습니다.
이것이 몇 판의 특이 상황이 아니었다는 것을 decision divergence 로그가 보여 줬습니다. easy 와 hard 의 선택이 달랐던 44,090개의 순간 중 21,841개가 새 필드의 첫 제출이었고, 그중 13,283개가 “easy 는 약한 다중 묶음을 내는데 hard 는 한 장만 내는” 형태였습니다. 반복되는 정책 결함이었습니다.
왜 리드에서 특히 이런 일이 벌어졌는지는 후보 공간의 모양으로 설명됩니다. 이것은 저의 해석이지만 관측된 패턴과 잘 맞습니다.
- 팔로우에서는 제출 장수가 테이블에 고정 돼 있습니다. 후보들은 전부 같은 장수를 버리므로, 점수식이 비교하는 것은 순수하게 “어느 rank 를 태울 것인가"입니다.
- 리드에서는 장수까지 내가 정합니다. 그런데
controlProb는 장수가 적고 rank 가 강할수록 높아지므로, “강한 카드 한 장"이 거의 언제나 확률 최댓값을 차지합니다. 반대편에서planGain이 배출량을 대변해야 하는데, 한 항의 최댓값과 다른 항의 최댓값이 정면으로 충돌하는 자리에서 가중치 몇 개로 균형을 맞추는 것은 너무 섬세한 요구였습니다.
그래서 결론을 바꿨습니다. Part 3 의 “리드 선택도 같은 계산이다” 절은 철회합니다. 리드는 공통 점수식이 아니라 배출 원칙이 정합니다.
- 자연 카드가 있으면 가장 약한 rank 의 자연 카드 전체 를 제출합니다.
- 자연 카드가 없고 광대만 남았을 때 광대 한 장을 냅니다.
- 자연 rank 를 일부만 떼거나 광대를 붙인 리드는 기본 후보에서 제외 합니다.
- endgame 예외는 별도 실험으로 성능이 입증될 때만 추가합니다.
// selectLead 는 점수식이 아니라 배출 원칙으로 리드를 고른다.
// 이 함수는 Part 1 의 easyLead 와 동작이 같다. 의도된 결과다.
func (p *HardPolicy) selectLead(v GameView, l *Ledger) Decision {
groups := groupByRank(v.Hand)
weakest := Rank(0)
for r := range groups {
if r != RankJester && r > weakest {
weakest = r // 숫자가 클수록 약하다
}
}
if weakest == 0 { // 손에 광대만 남았다
return Decision{Cards: groups[RankJester][:1]}
}
return Decision{Cards: groups[weakest]}
}
hard 봇의 리드 정책이 easy 봇과 같은 코드로 수렴했다 는 점이 이 절의 요지입니다. Part 1 에서 “easy 봇의 단순화"라고 소개했던 규칙이, 실제로는 복잡한 평가식보다 강한 기준선이었고 지금은 hard 봇의 guardrail 로 채택돼 있습니다.
Part 3 에서 만든 리드 후보별 점수는 지웠을까요? 아닙니다. 진단 자료로는 남겨 뒀습니다. 어떤 리드가 왜 낮게 평가됐는지 로그로 볼 수 있는 것은 여전히 유용합니다. 다만 그 점수가 운영 선택을 지배하지 않을 뿐 입니다. 평가 도구와 결정 규칙을 분리한 셈입니다.
4. 원인 3 — 세금 반환에서 약한 묶음을 지키려 했다#
Part 3 §3 의 다음 두 문장은 실제 결과와 정면으로 충돌했습니다.
rank 12 는 판 초반에 반환 카드로 나쁩니다.
12를 다섯 장 쥐고 있다면 12는 소중한 묶음이지 버릴 카드가 아닙니다.
12 열 장과 11 한 장을 쥐고 한 장을 내려줘야 하는 상황을 보겠습니다. Part 3 의 giveAwayCost + planDamage 조합은 큰 12 묶음을 깨는 것을 손해로 계산했고, 그래서 더 좋은 11 을 내보내는 선택이 나올 수 있었습니다.
이 판단이 놓친 것은 세금 반환의 기본 사실이었습니다.
- 카드를 한 장 돌려주면 내 손패가 즉시 한 장 줄어듭니다.
12는11보다 약하고, 원래 먼저 없애고 싶은 카드입니다.- 지나치게 큰 약한 묶음은 한 장 줄어도 여전히 큰 묶음 입니다.
- 광대를 넘기는 위험은 별도 규칙으로 막으면 됩니다.
“그래도 11 을 버리는 쪽이 제출 횟수가 적지 않은가”#
이 예시를 보면 자연스럽게 나오는 반문이 있습니다. 계산해 보면 이렇습니다.
11을 반환 →12 x 10만 남음 →MinPlays112를 반환 →12 x 9,11 x 1→MinPlays2
산술은 정확합니다. 그리고 이것이 정확히 Part 3 의 planDamage 가 하던 계산입니다. 그런데도 이 정책이 진 이유는 MinPlays 가 세는 “제출 횟수"가 실제 비용이 아니기 때문 입니다. 세 가지로 나눠 보겠습니다.
① 안 받히는 수는 리드권을 소비하지 않고 돌려줍니다. 12 x 9 를 리드로 내면 이걸 받으려면 11을 아홉 장 이상 모아야 하는데, 11은 세상에 열한 장뿐이라 사실상 불가능합니다. 전원이 패스하면 트릭이 끝나고 리드권은 마지막에 낸 사람, 즉 나에게 돌아옵니다. 곧바로 11 x 1 을 내고 나갑니다. 그 사이에 다른 누구도 카드를 한 장도 내지 못했으므로 종료 순위는 12 x 10 을 한 번에 낸 것과 같습니다. 트릭 번호만 하나 늘어날 뿐입니다.
Part 3 §1 에서 “리드권은 살아 있는 전원이 연속으로 패스해야 돌아온다"고 강조해 놓고, 정작 세금 절의 평가식은 그 규칙을 쓰지 않았습니다. 달무티에서 희소한 자원은 제출 횟수가 아니라 리드권 획득 횟수 이고, 안 받히는 수는 리드권을 쓰지 않습니다. 그래서 MinPlays 2 의 실제 비용은 1턴이 아니라 0에 수렴합니다.
② 12 x 10 은 리드권을 벌 수단이 0인 손패입니다. 12는 자연 rank 중 최약이므로, 이 손패로는 어떤 트릭도 받을 수 없습니다. 받을 수 없으면 트릭을 이길 수 없고, 트릭을 이길 수 없으면 리드권을 가져올 방법이 없습니다.
| 상황 | 11 반환 → 12 x 10 |
12 반환 → 12 x 9, 11 x 1 |
|---|---|---|
| 내가 선공 | 한 번에 나감 | 12 x 9 → 전원 패스 → 리드권 회수 → 11 x 1 → 나감 (순위 동일) |
| 내가 선공이 아님 | 한 장도 못 냅니다. 받을 수 있는 수가 존재하지 않아 리드권을 벌 수단이 없음 | 누군가 12 x 1 로 열면 11 x 1 로 받아 트릭을 가져오고, 리드권 회수 후 12 x 9 로 나감 |
11 x 1 은 이 손패의 유일한 재진입 티켓 입니다. Part 3 의 continuationValue 가 재려던 값이 바로 이것인데, 세금 절은 그 개념을 쓰지 않고 planDamage 만 보고 티켓을 내다 버렸습니다. (엄밀히는 예외가 하나 있습니다. 누군가 광대만 단독으로 리드하면 그 수는 rank 13 이므로 12로 받을 수 있습니다. 손에 광대만 남은 사람이 있어야 하는 희귀 상황이라 실전 기여는 없다고 봅니다.)
③ 달무티 덱에서 고아는 구조적으로 강한 카드입니다. 위 예시는 고아가 마침 11 이라 무해해 보이지만, rank r 은 정확히 r 장뿐이므로 강한 rank 일수록 손패에 한 장만 들어옵니다. 6인 게임(손패 13장) 기준으로 재보면 이렇습니다.
| rank | 보유 확률 | 보유했을 때 딱 한 장(고아)일 확률 |
|---|---|---|
| 1 | 16.2% | 100.0% |
| 2 | 30.0% | 91.8% |
| 3 | 41.7% | 83.9% |
| 6 | 66.8% | 62.6% |
| 9 | 81.6% | 44.8% |
| 12 | 90.0% | 30.8% |
손패에서 카드 한 장을 무작위로 집으면 rank 1~6 일 확률이 26.9% 인데, 고아 카드 중에서 한 장을 집으면 그 확률이 49.1% 로 뜁니다. 즉 “묶음을 보존하고 고아를 내보낸다"는 규칙은 평균적으로 “강한 카드를 농노에게 헌납하라” 로 작동합니다.
정직하게 덧붙이자면, 위 예시에서 내가 대달무티(첫 트릭 선공이 확정) 라면 12 x 10 을 지키는 쪽이 아주 미세하게 낫습니다. 12 x 10 은 다 내는 순간 받히든 말든 이미 나간 것이지만, 12 x 9 는 만에 하나 받히면 11 한 장이 남기 때문입니다. 이 손패는 기각된 규칙이 방어 가능한 몇 안 되는 경우입니다. 규칙이 진 이유는 이 손패에서 져서가 아니라, 이 손패가 예외인데 규칙은 나머지 전부에도 똑같이 적용됐기 때문입니다.
리드를 고친 뒤 세금을 고치기 전의 중간 진단에서, 세금 정책만 easy 방식으로 갈아끼운 변형이 당시 hard 보다 평균 순위를 0.1517 개선 했습니다. 리드 다음으로 큰 결함이었습니다.
현재 규칙은 셋뿐입니다.
- 광대는 반환 후보에서 제외 합니다.
- 일반 카드 중 가장 약한 rank 부터 반환합니다.
- 같은 rank 는 card ID 로 끊어 재현성을 확보합니다.
즉 Part 1 의 weakerFirst 규칙 그대로입니다. 여기서도 easy 규칙이 hard 의 guardrail 이 되었습니다.
기각된 고급 모델은 지우지 않았다#
giveAwayCost, 조합 완전탐색, 옌센 부등식 논의는 삭제하지 않고 “시도했지만 기각된 모델” 로 남겨 뒀습니다. 그 논리 자체는 여전히 옳습니다. 가치 함수가 비선형이면 f(E[K] + 1) 과 E[f(K+1)] 은 실제로 다른 값이고, 2장을 내려주는 대달무티에게 조합 최소와 개별 최소가 다른 것도 맞습니다.
기각된 이유는 논리가 틀려서가 아니라 단위가 맞지 않아서 였습니다. giveAwayCost 는 “상대 묶음의 가치 증가"를 재고, planDamage 는 “내 플랜의 손상"을 재는데, 이 둘을 같은 저울에 올릴 정규화가 없었습니다. 거기에 “내 손패가 한 장 준다"는 가장 확실한 이득은 아예 항으로 들어가 있지도 않았습니다. 세 값의 크기가 제각각인 상태에서 최소화를 돌리면, 가장 큰 값을 뱉는 항이 결정을 독점합니다.
다시 도입하려면 조건은 이렇습니다.
- 자기 손패 한 장 감소의 이득과 상대 강화 비용을 같은 단위로 정규화 한다.
- 기존 규칙 대비 개선을 별도 held-out 으로 검증 한다.
그 전까지는 흥미로운 연구 가설이지 운영 정책이 아닙니다.
5. 목적 함수 정정 — 20판 누적 우승은 빠졌다#
Part 3 §3 은 목적 함수 후보 세 개를 놓고 “20판 누적 점수 우승” 을 골랐습니다. 그리고 그 선택 위에 GameView.Score, GameView.HandsLeft, Weights.Rivalry, leaderThreat 를 얹었습니다. (Part 1 의 GameView 정의는 이 정정에 맞게 갱신했고, Part 3 의 코드는 당시 설계 기록으로 남겨 두었습니다.)
현재 구현은 두 번째, 판 단위 평균 순위 를 목적 함수로 씁니다. 각 판에서 가능한 한 좋은 순위를 얻는 greedy 정책이고, 경기 전체의 점수 경쟁을 별도로 모델링하지 않습니다. 그래서 다음을 제거했습니다.
Weights.Rivalry항leaderThreat분기와HandsLeft조건- 혁명 판단의 누적 점수 선두 고려
type Weights struct {
Plan float64 // 손패 구조 개선도
Control float64 // 리드권 확보 가치
Tempo float64 // 상대 진행 위험
InfoLeak float64 // 혁명 선언의 정보 노출 비용
Hold float64 // 상대가 낼 수 있어도 참을 성향
BlockThreshold float64 // 차단 의무 발동 임계값
}
func (p *HardPolicy) DeclareRevolution(v GameView) bool {
if countJesters(v.Hand) < 2 {
return false
}
me := v.SeatOf(v.Me)
if me.Title == GreaterPeon {
return true // 대혁명. 좌석 전복의 가치가 다른 모든 항을 압도한다
}
if v.Round == 1 {
return false // 첫 판에는 세금이 없다. 취소할 것이 없다
}
gain := taxLossIfNotCancelled(me.Title) // ① 내 손패의 세금 손익
gain += p.W.Control * p.blockedTribute(v, me.Title) // ② 상대 세금 차단
cost := p.W.InfoLeak * float64(len(v.Seats)-1) // ③ 정보 노출 비용
return gain > cost
}
Part 3 의 혁명 절에서 살아남은 것은 ①②③ 과 대혁명 우선 분기, 그리고 “상대 손패 강화 방지는 상인 전용 항이 아니다"라는 논지입니다. 사라진 것은 ④ 누적 점수 경쟁 효과뿐입니다. 누적 경기 메타전략은 향후 확장 으로 분리해 두었습니다.
한 가지 더 정정할 것이 있습니다. Part 3 은 상인의 조건부 혁명 선언을 “봇에게서 이런 수가 나오면 상대는 이 봇이 뭔가 생각하고 있다고 느낀다"며 비중 있게 다뤘습니다. 실제 기여도는 그렇지 않았습니다. 같은 중간 진단에서 혁명 정책만 easy 방식으로 갈아끼운 ablation 은 전체 0.0022, held-out 0.0000 차이 였습니다. 혁명 판단은 이번 개선의 원인이 아니었습니다. 사람 눈에 영리해 보이는 수와 성적에 기여하는 수는 다릅니다.
6. 살아남은 것들#
무너진 것만 적으면 그림이 틀어집니다. 다음 요소는 검증에서 유지됐습니다.
| 유지된 요소 | 어디서 다뤘나 |
|---|---|
상대 손패를 넘기지 않는 GameView 공정성 경계 |
Part 1 |
| 패스를 일급 이벤트로 남기는 공개 행동 로그 | Part 1 |
| card ID 결정적 tie-break | Part 1 |
| rank 별 미공개 카드 장부 | Part 2 |
| 패스를 확정 배제가 아닌 soft evidence 로 취급 | Part 2 |
| 구조적 확실승수 판정과 좌석별 위험도 | Part 2 |
| 광대 배치를 매 턴 재계산하는 플랜 | Part 2 |
| 팔로우 단계의 전략적 패스 | Part 3 |
| 종료 임박 좌석에 대한 차단 의무 | Part 3 |
| 세금 반환에서 광대 제외 | Part 1 |
이 중 전략적 패스 는 따로 강조할 만합니다. hard 가 easy 보다 약했다는 결과만 보면 “낼 수 있어도 참는 기능이 문제였나” 싶지만, 측정 결과는 정반대였습니다. 리드와 세금 구조를 고친 뒤 합법 수가 있으면 무조건 내도록 제한한 no-voluntary-pass 변형은 기본 hard 보다 평균 순위가 0.2013 나빠졌습니다. 이 글에 나오는 어떤 개별 수정보다도 큰 폭입니다.
즉 Part 3 §2 의 알고리즘은 그대로 유효합니다. 다만 다음 회고를 덧붙여야 합니다.
전략적 패스는 hard 의 유효한 장점이었지만, 이 기능 하나가 전체 성적을 보장하지는 않았습니다. 리드와 세금의 구조 결함이 그 이득보다 더 컸기 때문입니다. 고급 기능의 존재와 전체 정책의 강함은 별개이므로, phase 별 ablation 없이는 둘을 구분할 수 없습니다.
7. 구조를 고친 다음에야 가중치#
Part 3 §5 는 “가중치는 좌표 하강으로 맞춥니다"라고 썼습니다. 순서를 하나 추가해야 합니다. 구조 결함을 남긴 채 가중치를 돌리면 특정 seed 에서 우연히 좋아진 값을 최적이라고 부르게 됩니다. 그래서 위의 세 결함을 모두 제거한 뒤에야 tuning 을 실행했습니다.
조건은 6인 5판, easy 1개와 hard 후보 5개, 64 seed, seed 별 6회 좌석 회전, 최대 3 sweep 입니다. 축 하나당 다섯 개 값을 격자로 봅니다.
첫 sweep 의 Control 축 결과입니다.
Control |
평균 순위 delta | 95% 신뢰구간 |
|---|---|---|
| 0.5 | -0.1563 | [-0.2903, -0.0222] |
| 0.75 | -0.0906 | [-0.2243, +0.0430] |
| 1.0 (기준) | 0.0000 | — |
| 1.25 | +0.1089 | [-0.0138, +0.2315] |
| 1.5 | +0.0505 | [-0.0857, +0.1867] |
Control 을 절반으로 낮춘 후보만 신뢰구간 상한이 0 미만이라 승격됐습니다. 이것이 이번 tuning 에서 움직인 유일한 축 입니다. Plan·Tempo·InfoLeak·Hold·BlockThreshold 는 어느 방향으로도 신뢰구간이 0을 가로질러 기본값에 머물렀고, 오히려 값을 올린 후보들이 유의하게 나빠졌습니다(Hold 를 0.35에서 0.45로 올리면 +0.2219, 신뢰구간 [+0.0832, +0.3606]).
두 번째 sweep 에서 Control 을 0.25까지 더 낮춘 후보는 점 추정치가 -0.1229 로 더 좋아 보였지만 신뢰구간이 [-0.2636, +0.0178] 이라 상한이 0을 넘어 승격되지 않았습니다. 점 추정치만 보고 채택했다면 근거 없는 값을 운영에 올렸을 것입니다. 두 번째 sweep 에서 아무 축도 움직이지 않아 좌표 하강은 2 sweep 만에 수렴했습니다.
최종 가중치입니다.
| 항 | 값 | 비고 |
|---|---|---|
Plan |
1.0 | 기본값 유지 |
Control |
0.5 | 1.0 에서 절반으로 |
Tempo |
1.0 | 기본값 유지 |
InfoLeak |
0.5 | 기본값 유지 |
Hold |
0.35 | 기본값 유지 |
BlockThreshold |
0.5 | 기본값 유지 |
Control 이 절반이 됐다는 것은 리드권이 쓸모없다는 뜻이 아닙니다. 손패를 줄인다는 기본 목적보다 당장의 주도권을 과하게 우선하지 않도록 영향력을 조정한 것입니다. 원인 2 에서 본 3 x 1 리드의 잔향이 팔로우 단계에도 남아 있었던 셈입니다.
8. 정식 검증 결과#
가중치를 확정한 뒤, tuning 에 쓰지 않은 seed 범위에서 cell 당 128 seed 로 held-out 을 돌렸습니다. 18개 cell x 128 seed = 2,304개 관측입니다.
| 구분 | Hard - Easy | 95% 신뢰구간 |
|---|---|---|
| 전체 | -0.0707 | [-0.0948, -0.0467] |
| 4인 | -0.0985 | [-0.1441, -0.0528] |
| 5인 | -0.0433 | [-0.0814, -0.0052] |
| 6인 | -0.0705 | [-0.1078, -0.0331] |
| 기본 혁명 빈도 | -0.0684 | [-0.0943, -0.0426] |
| 고빈도 혁명 | -0.0730 | [-0.0973, -0.0488] |
| 3판 | -0.0619 | [-0.0914, -0.0325] |
| 5판 | -0.0874 | [-0.1148, -0.0599] |
| 10판 | -0.0629 | [-0.0890, -0.0369] |
18개 개별 cell 과 전체·marginal gate 모두 통과했습니다. 방향이 뒤집혔고, 어느 조건에서도 나빠지지 않았습니다.
전체 흐름을 한 표로 정리하면 이렇습니다.
| 단계 | Hard - Easy | 표본 |
|---|---|---|
| 최초 소규모 held-out | +0.6601 |
tuning 4 seed / held-out cell 당 2 seed |
| 구조 수정 후 같은 소규모 held-out | 약 +0.0112 |
위와 동일 |
| 정식 held-out | -0.0707 |
tuning 64 seed / held-out cell 당 128 seed |
표본 크기가 다르다는 점을 반드시 함께 읽어야 합니다. 최초 진단은 실행 경로와 소요 시간을 확인하기 위한 pilot 이었고, 그 결과는 운영 가중치의 근거로 쓰지 않았습니다. 따라서 0.6601 - (-0.0707) = 0.7308 을 엄밀한 동일 표본 효과 크기로 해석해서는 안 됩니다. 정확히 말할 수 있는 것은 둘입니다.
- 같은 소규모 조건 에서 기존 열세
0.6601중 약0.6489가 구조 수정만으로 사라졌습니다. - 정식 조건 에서 hard 는 easy 보다 평균
0.0707등 앞섰고, 신뢰구간 상한이 0 미만입니다.
그리고 솔직하게 덧붙이자면, 0.0707 등은 작은 차이 입니다. 사람이 몇 판 붙어 보고 “hard 가 확실히 세다"고 느낄 만한 폭이 아닙니다. Part 3 이 그렸던 “카운팅하고 참을 줄 아는 봇"의 그림에 비하면 소박한 결과이고, 이것이 현재의 정직한 위치입니다.
9. Part 3 정정 요약#
| Part 3 의 주장 | 현재 결론 | 근거 |
|---|---|---|
| 리드도 후보별 공통 점수식으로 고른다 | 철회. 가장 약한 자연 rank 묶음 전체를 내는 guardrail 이 우선 | lead divergence 21,841건 중 13,283건이 “다중 묶음 대 1장” |
planGain 에서 MinPlays 와 Orphans 를 함께 보상한다 |
범위 분리. 리드에서는 Orphans 항 제거, 팔로우에서만 유지 |
12 x 2 대신 9 x 1 을 고르는 반례 |
| rank 12 는 판 초반에 반환 카드로 나쁘다 | 철회. 광대를 뺀 가장 약한 카드부터 반환 | 세금 ablation 0.1517 개선 |
giveAwayCost 조합 최소화로 반환 카드를 고른다 |
기각. 단위 정규화와 별도 held-out 검증 후에나 재도입 | 위와 같음 |
| 목적 함수는 20판 누적 점수 우승 | 변경. 판 단위 평균 순위 | 현재 구현의 정책 범위 |
Rivalry·leaderThreat·HandsLeft 로 경쟁자를 견제한다 |
제거. 향후 확장으로 분리 | 위와 같음 |
| 상인의 조건부 혁명 선언이 인상적인 차별점 | 축소. 유지하되 성능 기여는 사실상 0 | 혁명 ablation 전체 0.0022, held-out 0.0000 |
| 전략적 패스가 hard 의 핵심 | 유지. 실제로 가장 큰 단일 기여 | no-voluntary-pass 가 0.2013 나빠짐 |
| 검증은 미러 매치 + held-out + 좌표 하강 | 유지·확장. 양방향 focal, label swap, 18 cell, seed-cluster CI 추가 | — |
마치며#
네 가지를 배웠습니다.
복잡한 봇이 자동으로 강한 봇은 아닙니다. hard 봇은 easy 봇보다 더 많은 공개 정보를 쓰고 더 많은 후보를 계산했습니다. 그런데 평가 항이 겹치거나 부차적 목표를 과대평가하면, 계산량이 많을수록 잘못된 결정을 더 일관되게 내립니다. easy 봇의 실수는 산발적이지만 hard 봇의 실수는 체계적이었습니다. Part 2 의 마지막에 이 문장을 넣었어야 했습니다. 더 정확한 장부는 더 좋은 판단 재료일 뿐입니다.
기본 원칙은 고급 전략의 안전망이어야 합니다. 약한 카드를 여러 장 버리고 강한 카드와 광대를 아끼는 규칙은 단순하지만 강했습니다. 고급 전략은 이 원칙을 대체 하는 것이 아니라, 예외가 정말 이득임을 입증했을 때만 넘어설 수 있어야 합니다. 지금 hard 봇의 리드와 세금 반환이 easy 봇과 같은 코드인 것은 후퇴가 아니라 경계 설정입니다.
가중치보다 구조를 먼저 고쳐야 합니다. 중복 보상과 잘못된 후보 경계를 남긴 채 가중치만 돌리면, 특정 seed 에 맞춘 숫자가 나옵니다. 구조를 고친 뒤 돌린 좌표 하강에서는 여섯 축 중 다섯 개가 기본값에 머물렀습니다. 튜닝으로 벌 수 있는 폭은 원래 그리 크지 않았고, 성적을 되돌린 것은 가중치가 아니라 구조 수정이었습니다.
평가 하네스가 본체입니다. 이번 작업에서 실제로 가치를 만든 것은 새 알고리즘이 아니라 phase 별 ablation 과 decision divergence 로그였습니다. “hard 가 약하다"는 결과만으로는 아무것도 고칠 수 없고, “새 필드 첫 제출에서 13,283번 한 장만 냈다"는 관측이 있어야 고칠 수 있습니다. 봇을 만들기 전에 봇을 재는 장치부터 만드는 편이 결국 빠릅니다.
Part 3 의 마지막 문단에서 “봇이 강해지는 방향은 정보를 더 주는 쪽이 아니라, 같은 정보를 더 성실하게 세는 쪽이어야 한다"고 썼습니다. 그 문장은 지금도 유효하다고 생각합니다. 다만 한 줄을 덧붙이겠습니다. 성실하게 센 값을 어떻게 합칠지는 세는 것과 완전히 다른 문제이고, 그건 재봐야만 알 수 있습니다.
References#
- The Great Dalmuti Rulebook (Wizards of the Coast, PDF)
- Coordinate descent (Wikipedia)
- Confidence interval (Wikipedia)
- Overfitting (Wikipedia)
- Duplicate bridge (Wikipedia)
- 달무티 CPU 플레이어 만들기 Part 1: 규칙 기반 easy 봇
- 달무티 CPU 플레이어 만들기 Part 2: hard 봇의 정보 모델과 확실승수 판정
- 달무티 CPU 플레이어 만들기 Part 3: 수 평가와 전략적 패스
- 대부호부터 렉시오까지: 클라이밍 카드게임의 세계와 전략