diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..cbc0a95 --- /dev/null +++ b/models/README.md @@ -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 정밀도보다 약해(신호<잡음), +이 문제는 "학습"보다 "탐색(어닐링)"이 근본적으로 유리함을 데이터로 확인. diff --git a/models/anneal.py b/models/anneal.py new file mode 100644 index 0000000..61e52b4 --- /dev/null +++ b/models/anneal.py @@ -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() diff --git a/models/beam.py b/models/beam.py new file mode 100644 index 0000000..5e06857 --- /dev/null +++ b/models/beam.py @@ -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() diff --git a/models/board.py b/models/board.py new file mode 100644 index 0000000..23bce35 --- /dev/null +++ b/models/board.py @@ -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 diff --git a/models/greedy.py b/models/greedy.py new file mode 100644 index 0000000..c2cfe37 --- /dev/null +++ b/models/greedy.py @@ -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() diff --git a/models/mcts.py b/models/mcts.py new file mode 100644 index 0000000..db3f46f --- /dev/null +++ b/models/mcts.py @@ -0,0 +1,79 @@ +""" +MCTS (Monte Carlo Tree Search) — best-value 백업 (결정적 단일 플레이어 최대화). + 선택=UCB1, 확장=미시도 수 1개, 시뮬=적게지우기 rollout, 역전파=경로 최고점. + 최종 = 모든 rollout 중 최고 종료 점수. + +성능: 어닐링보다 낮고(하드 판 116 vs 113) 2배 느림. 이 게임엔 부적합. + (전이에 무작위성이 없어 MCTS 강점이 안 살고, rollout이 무거움) + +사용: python -m models.mcts --seed 1234 --iters 3000 +""" +import math +import random +import argparse +import numpy as np +from models.board import make_board, valid_actions, apply_move, cells_of, TOTAL + +C_UCT = 0.7 + + +def _rollout(grid, rng): + """적게지우기 위주 rollout. 추가 제거 칸 반환. grid 소모.""" + total = 0 + while True: + acts = valid_actions(grid) + if not acts: + return total + a = min(acts, key=lambda m: cells_of(grid, m)) if rng.random() < 0.8 else acts[rng.randrange(len(acts))] + total += apply_move(grid, a) + + +class Node: + __slots__ = ("grid", "score", "moves", "untried", "children", "N", "Q") + + def __init__(self, grid, score): + self.grid = grid + self.score = score + self.moves = valid_actions(grid) + self.untried = list(self.moves) + self.children = [] # (move, Node) + self.N = 0 + self.Q = score # 서브트리 최고 종료 점수 + + +def _uct(node): + logN = math.log(node.N + 1) + return max(node.children, key=lambda mc: mc[1].Q / TOTAL + C_UCT * math.sqrt(logN / (mc[1].N + 1e-9))) + + +def mcts(board, iters, rng): + root = Node(board.copy(), 0) + best = 0 + for _ in range(iters): + node, path = root, [root] + while not node.untried and node.children: # 선택 + node = _uct(node)[1]; path.append(node) + if node.untried: # 확장 + a = node.untried.pop(rng.randrange(len(node.untried))) + cg = node.grid.copy(); cl = apply_move(cg, a) + child = Node(cg, node.score + cl) + node.children.append((a, child)); path.append(child); node = child + leaf = node.score + _rollout(node.grid.copy(), rng) # 시뮬 + best = max(best, leaf) + for nd in path: # 역전파 (max) + nd.N += 1 + if leaf > nd.Q: + nd.Q = leaf + return best + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--seed", type=int, default=1234) + p.add_argument("--iters", type=int, default=3000) + args = p.parse_args() + print(f"[mcts {args.iters}] seed {args.seed}: {mcts(make_board(args.seed), args.iters, random.Random(0))}/{TOTAL}") + + +if __name__ == "__main__": + main()