본문 바로가기
기타

Q-learning 기반 틱택토 강화학습 모델 개발

by 블로그별명 2023. 12. 15.

들어가기 전에...

여태까지  강화학습 전반에대해 학습한 내용을 기반으로 틱택토 게임을 플레이하는 강화학습 모델을 개발해보았습니다. 틱택토 게임을 선택한 이유는 다음과 같습니다.

1. 틱택도 게임특성상 가능한 경우의수가 많지않아 모든 Q value를 Q table에 표시할수있습니다.

2. 나중에 구현하고자 하는 오목 강화학습 모델이나 바둑 강화학습 모델과 마찬가지로 1대1 보드게임이라는 환경적인 유사성이 있어 향후 프로젝트에 도움이 될것이라 판단했습니다.

 

전체코드는 포스팅말미의 깃허브주소를 참고해주세요.


구현 목표

1. 실제 틱택토 게임을 만든후, 강화학습모델과 연동시켜 사람과 틱택토 모델간의 대국이 가능하게 구현하는것(play기능).

2. 에피소드가 진행되며 발전하는 에이전트를 확인할수있도록 구현하는것(replay 기능).

3. 모델이 자가대국으로 학습하도록 구현하는것.

구현 계획

크게 학습부와 활용부로 나누었습니다. 활용부는 구현에 있어 학습부 실행 결과물이 필요하므로, 학습부를 먼저 구현하는것으로 방향을 잡았습니다.

학습부 구현

학습부의 구현은 다음과 같은 순서로 진행했습니다.

진행순서 정리

코드를 작성하기 이전에 틱택토 강화학습의 진행순서를 간단하게 정리해봤습니다. 이 주제가 바로 코드 작성으로 넘어가기엔 다소 복잡하다고 판단되었기때문입니다. 두 플레이어가 번갈아 두는 게임 환경에 강화학습을 어떻게 적용해야할지 고민했습니다.

간단한 main 구현

순서도를 바탕으로 learn.py의 learn함수를 간단하게 구현했습니다. 입출력정도를 간단히 메모해줍니다. 코드의 형태이지만 사실 설계도에 가깝습니다.

def learn():
    whole_learning_history = []
    for _ in range(30000): # episode num
        learning_history = []
        p1 = True # p1 always takes first turn
        while True:
            action_idxs = get_possible_actions(board) 
            random_action, greedy_action, max_q_value = calculate_actions(board, action_idxs) # get max q(s',a')
            update_table(history, q_table) 
            action = select_action(board, random_action, greedy_action) # get q(s,a)
            learning_history.append(action)
            r = update_game_status(board) # get r                           
            
            if game_over:
                update_table(history, q_table)
                p1 = not p1
                update_table(history, q_table)
                break 
            else:
                p1 = not p1
        
        whole_learning_history.append(learning_history)

        reset()
    
    np.save('replay.npy', whole_learning_history) # save learning history
    np.save('play.npy', q_table) # save Q_table

main 세부구현

마지막으로 learn함수에서 사용된 함수들을 세부적으로 구현했습니다. 이 과정에서 두개의 클래스를 만들어졌으며 클래스 내부에 포함된 메서드는 다음과 같습니다.

 

구현이 모두 완료된 이후 약간의 디버깅작업이 있었지만, 각각의 함수들은 만든직후 개별 구동테스트를 진행하였기때문에, 오랜시간이 걸리진않았습니다.

시행착오

훈련된 모델을 테스트하는 과정에서 모델 성능과 관련하여 몇몇 문제가있었습니다. 아래에서 이러한 문제들과 그 해결방법에 대해 설명하겠습니다.

하이퍼파라미터 조정

두턴안에 중앙에 돌을 놓는 플레이가 나오면 제대로 학습한것으로 판단 (출처: 나무위키 틱택토 문서)

자가대국의 마지막 에피소드의 대국기록을 보고 모델의 성능을 판단할때, 모델의 성능이 일관되지않고 불안정한 양상을 보였습니다.  때로는 제대로 학습이 된것을 확인했지만, 다른 때에는 학습이 제대로 진행되지않았습니다. 이 문제를 해결하기 위해 학습을 진행하고 하이퍼파라미터를 조정하는 과정을 반복적으로 수행했습니다. 다행히도 결과적으로 학습의 안정성이 크게 개선되었으나, 만약 후에 더 복잡한 주제에 강화학습을 적용할때 모델학습에 많은 시간이 소요된다면 반복적인 실험으로 하이퍼파라미터를 조절하는것은 쉽지않을것입니다. 따라서 하이퍼파라미터 설정에 대한 문제는 앞으로 더 생각해봐야할것같습니다.

보상 조정

활용부 구현이 끝나고 모델과 직접 틱택토를 해봤을때, 특정 상황에서 에이전트가 악수를 두는 현상이 여러번 관찰되었습니다. 공격을 하면 바로 이길수있는 상황에서 모델이 수비를 선택하는 모습을 보였습니다.

모델이 좌측 하단 모서리에 X를 두면 이기는 상황 그러나 수비를 선택하는 모델

이런 현상이 일어나는 원인에 대해 고민한끝에, "패배를 피했을때와 승리했을때의 보상이 같아서 일어나는 현상같다."라는 결론에 도달했습니다. 기존 보상체계는 승리시 1, 패배시 -1의 보상을 얻는 구조였지만, 이를 승리시 2 패배시 -1의 보상을 얻는것으로 수정한후, 위와 비슷한 상태에서 에이전트가 수비를 선택하는 상황은 더이상 관측되지않았습니다.

활용부 구현

활용부의 구현은 다음과 같은 순서로 진행했습니다.

구조설계

게임의 각 장면(scene)은 클래스로 구현되었으며, 각 장면에서 사용자의 입력에 따라 다음 장면으로 이동합니다.

 

구체적으로 말하자면, main 함수는 계속해서 "현재 장면"만을 실행하기 때문에,
장면 전환은 main 함수에서 실행 중인 "현재 장면"을 다음 장면으로 교체하는것을 의미합니다.

if current_scene.next_scene:
    current_scene = self.current_scene.next_scene

 

따라서 모든 장면 클래스에는 반드시 포함되어야 하는 공통 메서드가 있습니다. 이를 위해 추상 클래스를 정의하여, 필수 메서드들이 반드시 구현되도록 했습니다.

class Scene:
    def __init__(self, screen):
        self.next_scene = None
		(후략)
    def handle_events(self, events):
        raise NotImplementedError

    def update(self):
        raise NotImplementedError

    def render(self, screen):
        raise NotImplementedError

main 구현

def run_game():
    pygame.init()
    screen = pygame.display.set_mode((800, 600)) # you can change display size
    pygame.display.set_caption("Tic Tac Toe Game")

    current_scene = MainMenuScene(screen)

    while current_scene is not None:
        events = pygame.event.get()
        for event in events:
            if event.type == pygame.QUIT:
                current_scene = None
            current_scene.handle_events(events)
            current_scene.update()
            current_scene.render(screen)
            if current_scene.next_scene:
                current_scene = current_scene.next_scene
            pygame.display.update()
    
    pygame.quit()

각 장면 구현

장면 구현 과정에서 순환 import 문제에 직면했습니다. 각 장면 클래스는 self.next_scene에 다음 장면의 클래스를 직접 할당하고 있었기때문입니다. 이 문제를 해결하기 위해, 각 장면이 다음 장면을 클래스로 직접 할당하는 대신 키워드를 사용해 next_scene을 지정하는 방식으로 변경했습니다. 이제 main 함수는 키워드에 해당하는 장면을 실행합니다.

이 방식 덕분에 각 장면 클래스는 다음 장면의 클래스를 import할 필요 없이, 장면 구성에 필요한 요소들만 import하면 됩니다. 하지만 이 방법은 현재 장면과 다음 장면 간의 종속성을 설정하기 복잡하기때문에, 틱택토 게임 장면, 틱택토 캐릭터 선택 장면, 틱택토 결과 장면을 하나의 통합된 장면으로 수정했습니다. 수정된 main의 코드는 다음과 같습니다.

def run_game():
    pygame.init()
    screen = pygame.display.set_mode((800, 600)) # you can change display size
    pygame.display.set_caption("Tic Tac Toe Game")

    scenes = {
        'main_menu_scene': MainMenuScene,
        'tictactoe_scene': TicTacToeScene,
            'replay_scene': ReplayScene
    }

    current_scene = MainMenuScene(screen)
    
    while current_scene is not None:
        events = pygame.event.get()
        for event in events:
            if event.type == pygame.QUIT:
                current_scene = None
            current_scene.handle_events(events)
            current_scene.update()
            current_scene.render(screen)
            if current_scene.next_scene:
                scene_class = scenes.get(current_scene.next_scene)
                current_scene = scene_class(screen)
        pygame.display.update()

    pygame.quit()

 

구현완료

처음에 구현하고자했던것을 모두 구현했으며, 틱택토 모델의 성능도 만족스러웠습니다. 아래는 게임의 스크린샷입니다.


마치며...

일주일동안 프로젝트를 진행하며 pygame 라이브러리도 처음써봤고, 코딩으로 게임도 처음만들어봤고, 완성된 게임을 exe파일로 만들면서 처음으로 exe파일도 만들어봤습니다. 새로운 경험이었고, 좋았습니다. 덧붙여, 프로젝트를 시작하기 전에 체계적으로 계획하고 제대로 설계 하는것이 매우 중요하다는 생각이 들었습니다. 특히 프로젝트의 규모가 커질수록 이러한 접근 방식이 더욱 필수적이라고 느꼈습니다.

 

전체 소스코드는 아래 깃허브 주소에 있습니다.

https://github.com/hangilzzang/tic-tac-toe-Q-learning

댓글