Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions models/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 사과게임 알고리즘 모델 모음

9×18 격자(값 1-9)에서 **합이 정확히 10인 사각형**을 지워 **총 제거 칸 수(최대 162)**를 최대화하는 문제.
접근법별 대표(최선) 버전을 정리한 폴더. 모든 모델은 `board.py` 유틸을 공유하며 **자급자족(numpy만 필요)**.

## 성능 비교 (랜덤 100판 평균)

| 모델 | 대표 설정 | 평균 점수 | 한 줄 |
|---|---|---|---|
| `greedy.py` | nine+eight+action_count | ~115 | 한 수 앞만 봄(근시안) |
| `beam.py` | width 5, depth 3 (+diversity) | ~119 | heuristic 근사 평가 + 그리디 라인에 갇힘 |
| **`anneal.py`** ★ | worst-이웃 SA + 병렬 max | **~135** | **실제 최종점수로 전체 수순을 전역 최적화 (BEST)** |
| `mcts.py` | UCB + rollout, best-value 백업 | < anneal | 결정적 게임엔 부적합, 2배 느림 |

> **참고 — 이론상 천장 ≈ 137.8** (헤비 어닐링 수렴 추정). 배포판(`anneal.py`)은 판별로 천장 대비 **−2**로 near-최적.
> 완전탐색은 경로 수 ≈ 10^55라 물리적으로 불가능.

## 왜 어닐링이 이기나

- **greedy/beam**: 개별 수를 *heuristic 근사*로 판단 + 앞 수 재검토 불가 → 그리디 라인에 갇힘.
- **anneal**: 해 = *수순 전체*. 실제 최종 제거 칸으로 평가하고, 어느 지점이든 갈아끼우며(+온도로 손해 감수) **전역 탐색** → 장기 의존성(1을 아껴 9와 묶기 등)을 잡음.

## 실행

```bash
# 저장소 루트에서 (-m 모듈 실행)
python -m models.greedy --seed 1234
python -m models.beam --seed 1234 --width 5 --depth 3
python -m models.anneal --seed 1234 --iters 2800 --instances 10 # ★ 배포판
python -m models.mcts --seed 1234 --iters 3000
```

라이브러리로:
```python
from models.board import make_board
from models.anneal import deploy
score, sequence = deploy(make_board(1234), iters=2800, instances=10)
```

## 튜닝 (anneal)

- **빠른 배포**(판당 ~70초): `iters=2800, instances=10` → ~135
- **천장 근접**(판당 ~5분): `iters=12000, instances=20` → ~137.8
- 같은 알고리즘, iters·instances만 조절.

## 파일

```
models/
├── board.py 공유 유틸 (make_board, valid_actions, apply_move, heuristic)
├── greedy.py 그리디
├── beam.py 빔서치
├── anneal.py 어닐링 + 병렬 max ★ BEST
├── mcts.py MCTS
└── README.md
```

## 딥러닝 시도 (별도)

value-net / AlphaZero / policy-gradient / action-max 모방 등은 `dl/`에 별도.
**모두 어닐링(135)에 못 미침** — per-move 신호가 value 정밀도보다 약해(신호<잡음),
이 문제는 "학습"보다 "탐색(어닐링)"이 근본적으로 유리함을 데이터로 확인.
121 changes: 121 additions & 0 deletions models/anneal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""
Simulated Annealing (worst-이웃) + 병렬 max — ★ BEST 모델.
해 = 한 판의 액션 시퀀스. 이웃 = 'worst' 지점을 다른 수로 갈아타고 뒤를 재플레이.
rollout 정책 = 적게 지우기(딱 맞는 짝). 수락 = Metropolis(온도 냉각).
병렬 = 같은 보드 여러 인스턴스(rng만 다름) → 최고 선택.

성능: 랜덤 100판 평균 ~135 (max-of-10, 2800 iter, 판당 ~70초).
판별 near-최적 (추정 천장 대비 -2). 모든 시도(그리디·빔·MCTS·DL) 중 최고.
iters·instances 키우면 천장(~137.8) 근접.

사용:
from models.anneal import deploy
score, seq = deploy(make_board(1234), iters=2800, instances=10)
python -m models.anneal --seed 1234 --iters 2800 --instances 10
"""
import math
import random
import argparse
import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor

from models.board import make_board, valid_actions, apply_move, cells_of, TOTAL


def _fewest_cells(grid, actions):
"""rollout 정책: 적게 지우는(딱 맞는 짝) 수. 여러 정책 실험 중 최선이었음."""
best, best_cells = actions[0], 1 << 30
for a in actions:
c = cells_of(grid, a)
if c < best_cells:
best_cells, best = c, a
return best


def _rollout(grid, first, rng, greedy_prob):
"""first 두고 끝까지 플레이. (시퀀스, 총제거). grid 소모."""
seq, total, a = [], 0, first
while a is not None:
total += apply_move(grid, a)
seq.append(a)
acts = valid_actions(grid)
if not acts:
break
a = acts[rng.randrange(len(acts))] if rng.random() >= greedy_prob else _fewest_cells(grid, acts)
return seq, total


def _build_prefix(board, actions):
grid = board.copy(); pg, pc, acc = [grid.copy()], [0], 0
for a in actions:
acc += apply_move(grid, a); pg.append(grid.copy()); pc.append(acc)
return pg, pc


def _pick_worst(n, pc, rng):
"""worst 이웃 지점: 딱 맞는 짝(2칸) 초과로 낭비한 수일수록 우선 수정."""
w = [(pc[i + 1] - pc[i] - 2) ** 2 + 0.1 for i in range(n)]
r = rng.random() * sum(w); acc = 0.0
for i, wi in enumerate(w):
acc += wi
if acc >= r:
return i
return n - 1


def anneal_once(board, iters=2800, rng_seed=0, T0=3.0, greedy_prob=0.8):
rng = random.Random(rng_seed)
acts = valid_actions(board)
if not acts:
return 0, []
cur_seq, cur_score = _rollout(board.copy(), _fewest_cells(board, acts), rng, 1.0)
best_seq, best_score = cur_seq, cur_score
pg, pc = _build_prefix(board, cur_seq)
for it in range(iters):
T = T0 * (1 - it / iters) + 1e-6
if len(cur_seq) < 2:
break
t = _pick_worst(len(cur_seq), pc, rng)
base_g = pg[t]; acts = valid_actions(base_g)
if len(acts) < 2:
continue
alt = [a for a in acts if a != cur_seq[t]]
if not alt:
continue
tail_seq, tail_total = _rollout(base_g.copy(), alt[rng.randrange(len(alt))], rng, greedy_prob)
new_score = pc[t] + tail_total
delta = new_score - cur_score
if delta >= 0 or rng.random() < math.exp(delta / T):
cur_seq, cur_score = cur_seq[:t] + tail_seq, new_score
pg, pc = _build_prefix(board, cur_seq)
if cur_score > best_score:
best_seq, best_score = cur_seq, cur_score
return best_score, best_seq


def _worker(arg):
return anneal_once(*arg)


def deploy(board, iters=2800, instances=10, T0=3.0, greedy_prob=0.8):
"""병렬 max-of-instances. (score, sequence) 반환. spawn으로 macOS 데드락 회피."""
args = [(board, iters, i, T0, greedy_prob) for i in range(instances)]
if instances == 1:
return _worker(args[0])
with ProcessPoolExecutor(max_workers=instances, mp_context=mp.get_context("spawn")) as ex:
return max(ex.map(_worker, args), key=lambda r: r[0])


def main():
p = argparse.ArgumentParser()
p.add_argument("--seed", type=int, default=1234)
p.add_argument("--iters", type=int, default=2800)
p.add_argument("--instances", type=int, default=10)
args = p.parse_args()
board = make_board(args.seed)
score, seq = deploy(board, iters=args.iters, instances=args.instances)
print(f"[anneal] seed {args.seed}: {score}/{TOTAL} ({len(seq)}수)")


if __name__ == "__main__":
main()
67 changes: 67 additions & 0 deletions models/beam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
Beam Search — 매 수, depth수 앞을 보되 각 단계 상위 width개만 유지(beam).
리프를 heuristic으로 평가 → 최고 리프의 '첫 수' 선택. best: w5 d3, 평균 ~118-119.

한계: 리프를 '실제 최종점수'가 아니라 heuristic 근사로 평가 + 앞수 재검토 불가
→ 그리디 라인에 갇힘. 그래서 어닐링(135)에 크게 못 미침.

사용: python -m models.beam --seed 1234 --width 5 --depth 3
"""
import argparse
import numpy as np
from models.board import make_board, valid_actions, apply_move, heuristic, TOTAL


def _apply(grid, mv):
r1, c1, r2, c2 = mv
g = grid.copy(); cl = int(np.count_nonzero(g[r1:r2 + 1, c1:c2 + 1])); g[r1:r2 + 1, c1:c2 + 1] = 0
return g, cl


def beam_choose(grid, width, depth):
root = valid_actions(grid)
if not root:
return None
beam = [] # (grid, first_action, heuristic)
for a in root:
child, _ = _apply(grid, a)
beam.append((child, a, heuristic(child)))
beam.sort(key=lambda x: x[2], reverse=True)
beam = beam[:width]
for _ in range(depth - 1):
cand = []
for g, fa, _h in beam:
acts = valid_actions(g)
if not acts:
cand.append((g, fa, heuristic(g))); continue
for a in acts:
child, _ = _apply(g, a)
cand.append((child, fa, heuristic(child)))
cand.sort(key=lambda x: x[2], reverse=True)
beam = cand[:width]
return max(beam, key=lambda x: x[2])[1]


def play(board, width, depth):
g = board.copy(); score = 0
while True:
if not valid_actions(g):
return score
a = beam_choose(g, width, depth)
if a is None:
return score
score += apply_move(g, a)


def main():
p = argparse.ArgumentParser()
p.add_argument("--seed", type=int, default=1234)
p.add_argument("--width", type=int, default=5)
p.add_argument("--depth", type=int, default=3)
args = p.parse_args()
print(f"[beam w{args.width} d{args.depth}] seed {args.seed}: "
f"{play(make_board(args.seed), args.width, args.depth)}/{TOTAL}")


if __name__ == "__main__":
main()
72 changes: 72 additions & 0 deletions models/board.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""공유 보드 유틸 — 모든 모델(greedy/beam/anneal/mcts)이 이 파일을 씀."""
from __future__ import annotations
import math
import numpy as np

ROWS, COLS = 9, 18
TOTAL = ROWS * COLS


def make_board(seed: int) -> np.ndarray:
"""시드로 9×18 보드 (값 1-9)."""
return np.random.default_rng(seed).integers(1, 10, size=(ROWS, COLS), dtype=np.int8)


def _prefix(grid: np.ndarray) -> np.ndarray:
"""2D 누적합 → 임의 사각형 합을 O(1)."""
P = np.zeros((ROWS + 1, COLS + 1), dtype=np.int32)
P[1:, 1:] = np.cumsum(np.cumsum(grid, axis=0), axis=1)
return P


def valid_actions(grid: np.ndarray) -> list[tuple[int, int, int, int]]:
"""합=10인 '최소 사각형' 유효 수 (테두리 4변이 안 빈 것)."""
P = _prefix(grid)

def area(r1, c1, r2, c2):
return int(P[r2 + 1, c2 + 1] - P[r1, c2 + 1] - P[r2 + 1, c1] + P[r1, c1])

res = []
for r1 in range(ROWS):
for r2 in range(r1, ROWS):
for c1 in range(COLS):
for c2 in range(c1, COLS):
s = area(r1, c1, r2, c2)
if s == 10:
if area(r1, c1, r1, c2) == 0: continue
if area(r1, c2, r2, c2) == 0: continue
if area(r2, c1, r2, c2) == 0: continue
if area(r1, c1, r2, c1) == 0: continue
res.append((r1, c1, r2, c2))
elif s > 10:
break
return res


def apply_move(grid: np.ndarray, mv) -> int:
"""mv 사각형의 남은 사과 제거 (in-place). 제거 칸 수 반환."""
r1, c1, r2, c2 = mv
region = grid[r1:r2 + 1, c1:c2 + 1]
cleared = int(np.count_nonzero(region))
region[:] = 0
return cleared


def cells_of(grid: np.ndarray, mv) -> int:
r1, c1, r2, c2 = mv
return int(np.count_nonzero(grid[r1:r2 + 1, c1:c2 + 1]))


def sigmoid(x, k, x0):
return 1.0 / (1.0 + math.exp(-k * (x - x0)))


def heuristic(grid: np.ndarray) -> float:
"""보드 평가 = nine(9/1 짝) + eight(8/2 짝) + action_count.
greedy/beam 이 이 값을 최대화. (feature engineering으로 찾은 best 조합)"""
n9 = int((grid == 9).sum()); n1 = int((grid == 1).sum())
n8 = int((grid == 8).sum()); n2 = int((grid == 2).sum())
f9 = 0.0 if n9 == 0 else (-1.0 if n1 == 0 else -sigmoid(n9 / n1, 2.5, 1.0))
f8 = 0.0 if n8 == 0 else (-1.0 if n2 == 0 else -sigmoid(n8 / n2, 2.5, 1.0))
fa = sigmoid(len(valid_actions(grid)), 0.2, 15.0)
return f9 + f8 + fa
38 changes: 38 additions & 0 deletions models/greedy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Greedy — 매 수, '둔 뒤 보드 평가(heuristic)'가 가장 좋은 수를 선택.
best 휴리스틱: nine+eight+action_count. 성능: 랜덤 100판 평균 ~115.

한계: 한 수 앞만 봄(근시안). 장기 의존성(1을 아껴 9와 묶기 등)을 못 봄.

사용: python -m models.greedy --seed 1234
"""
import argparse
import numpy as np
from models.board import make_board, valid_actions, apply_move, heuristic, TOTAL


def play(board):
g = board.copy(); score = 0
while True:
acts = valid_actions(g)
if not acts:
return score
best, best_h = acts[0], -1e9
for a in acts: # 각 수를 두면 보드가 얼마나 좋아지나
r1, c1, r2, c2 = a
child = g.copy(); child[r1:r2 + 1, c1:c2 + 1] = 0
h = heuristic(child)
if h > best_h:
best_h, best = h, a
score += apply_move(g, best)


def main():
p = argparse.ArgumentParser()
p.add_argument("--seed", type=int, default=1234)
args = p.parse_args()
print(f"[greedy] seed {args.seed}: {play(make_board(args.seed))}/{TOTAL}")


if __name__ == "__main__":
main()
Loading