본문 바로가기
프로그래밍/강화학습 (RL)

틱택토 강화학습 (Tik-Tak-Toe RL) - [심화 탐구]

by _OlZl 2024. 8. 12.

2024.07.09 - [프로그래밍/강화학습 (RL)] - 틱택토 강화학습 (Tik-Tak-Toe RL) - [정보과학융합탐구 - 7월 과제]

 

틱택토 강화학습 (Tik-Tak-Toe RL) - [정보과학융합탐구 - 7월 과제]

드디어 3월부터 달려온 대장정이 끝나갑니다. 이번 달에는 3월부터 6월까지의 내용을 다 정리하고, 연구의 부족분을 채워보도록 하겠습니다. 문제 인식 저는 어렸을 때부터 게임을 만드는 활동

olzl07.tistory.com

 

저번 7월 과제를 끝으로 더 이상 정융탐 과제를 할 필요는 없지만 저번에 시간이 없었어서 연구를 좀 찝찝하게 끝내기도 했고, (쓸데없이 제가 꼭 분석해보겠다고 해서) 추가적으로 확인해볼 사항들이 좀 있었기에 기존 연구를 좀 수정하고 분석해보는 시간을 가져보았습니다.

 

이번에 해본 연구는 크게 '첫 수를 둔 칸 별 승률 확인', '칸 별 첫 수를 둔 횟수 확인', '에이전트와 직접 플레이' 정도로 나눌 수 있습니다.


첫 수를 둔 칸 별 승률 확인 & 칸 별 첫 수를 둔 횟수 확인

먼저 첫 수를 둔 칸 별의 승률들과 칸 별 첫 수를 둔 횟수를 확인해볼 것입니다. 해당 기능들을 위해서는 run_q_learning.py 파일을 수정해야합니다. run_q_learning.py 파일의 Run_Q_Learning 함수를 수정해 episode를 반복할 때 첫 수를 둔 위치를 기록하고, agent.first_move_wins라는 딕셔너리를 이용해 해당 episode의 결과가 승인지 아닌지를 저장하고, 첫 수를 둔 위치의 횟수를 계산하였습니다.

 

수정된 Run_Q_Learning 함수의 코드는 다음과 같습니다.

def Run_Q_Learning(agent, env, num_episode, p_mode="N"):
    history = []
    for episode in range(num_episode):
        state = env.reset()
        final_reward, n_moves = 0.0, 0
        first_move = None

        while True:
            action = agent.choose_action(state)
            if n_moves == 0:
                first_move = action  # 첫 수 기록

            next_state, reward, done = env.move(action)
            agent.learn(Transition(state, action, reward, next_state, done))
            env.print_board()
            state = next_state
            n_moves += 1

            if done:
                final_reward = reward
                if first_move is not None:
                    agent.first_move_wins[first_move]["total"] += 1
                    if reward == env.reward_case["win"]:
                        agent.first_move_wins[first_move]["win"] += 1
                break
        
        history.append(final_reward)
        if episode % 1 == 0:
            print('에피소드 %d: 보상 %.1f #이동 %d' % (episode, final_reward, n_moves))
    return history

 

이후 그래프를 그리는 plot_graphs라는 함수를 수정해 기존의 그래프(episode 별 보상, episode 별 승률)들과 새로 만든 그래프가 한 화면에 들어올 수 있게 수정하였습니다. 그래프를 그릴 때 딕셔너리에서 요소들을 받아와 승률을 계산하게 하였고, 칸 별 승률과 첫 수 선택 횟수는 모두 히트맵으로 표현해주었습니다.

 

plot_graphs 함수는 다음과 같습니다.

def plot_graphs(history, agent, env):
    win_prob = win_prob_history(history, env)

    fig, axes = plt.subplots(2, 2, figsize=(14, 10))

    axes[0, 0].plot(history, 'b.')
    axes[0, 0].set_xlabel('Episodes')
    axes[0, 0].set_ylabel('Final Rewards')

    axes[1, 0].plot(win_prob)
    axes[1, 0].set_xlabel('Episodes')
    axes[1, 0].set_ylabel('Winning Probability')

    win_rates = np.zeros((env.cell, env.cell))
    
    for i in range(env.cell ** 2):
        total = agent.first_move_wins[i]["total"]
        wins = agent.first_move_wins[i]["win"]
        if total > 0:
            win_rates[i // env.cell][i % env.cell] = wins / total
        else:
            win_rates[i // env.cell][i % env.cell] = 0

    im = axes[0, 1].imshow(win_rates, cmap='coolwarm', interpolation='nearest')
    fig.colorbar(im, ax=axes[0, 1], label='Winning Rate')
    axes[0, 1].set_title('First Move Winning Rate Heatmap')
    axes[0, 1].set_xlabel('Column')
    axes[0, 1].set_ylabel('Row')

    for i in range(env.cell):
        for j in range(env.cell):
            axes[0, 1].text(j, i, f'{win_rates[i, j]:.2f}', ha='center', va='center', color='black')

    first_move_freq = np.zeros((env.cell, env.cell))
    
    for i in range(env.cell ** 2):
        first_move_freq[i // env.cell][i % env.cell] = agent.first_move_wins[i]["total"]
    
    im2 = axes[1, 1].imshow(first_move_freq, cmap='Blues', interpolation='nearest')
    fig.colorbar(im2, ax=axes[1, 1], label='Frequency')
    axes[1, 1].set_title('First Move Frequency Heatmap')
    axes[1, 1].set_xlabel('Column')
    axes[1, 1].set_ylabel('Row')

    for i in range(env.cell):
        for j in range(env.cell):
            axes[1, 1].text(j, i, int(first_move_freq[i, j]), ha='center', va='center', color='black')

    plt.tight_layout()
    plt.show()

 

이제 해당 코드를 실행해 얻은 결과를 보여드리겠습니다.

수정된 코드 실행 결과 (승률 : 95.14%)

 

왼쪽의 두 그래프는 7월달에 출력해본 그래프이고, 7월에 출력해봤을 때와 형태가 똑같음을 알 수 있습니다. 주목해야할 것은 새로 추가된 오른쪽의 두 그래프인데, 오른쪽 위 그래프첫 수를 둔 칸 별 승률을 나타낸 것이고, 오른쪽 아래 그래프첫 수를 둔 칸 별 횟수를 나타낸 것입니다. 참고로 전체 승률은 95.14%가 나왔습니다.

 

인상적인 점은 가운데 위 칸을 무려 9000번 정도 선택하고, 나머지 칸들은 150번 남짓밖에 선택하지 않았다는 것입니다. 또, 승률도 가운데 위 칸은 0.97이라는 압도적인 수치인 반면에 나머지 칸들은 0.7~0.8 정도의 비교적 저조한 승률을 나타냈다는 것입니다.

 

이 상황을 이해하기 위해 제가 생각한 시나리오는

1. agent가 모든 칸에 적절히 첫 수를 둬본 결과 가운데 위 칸의 승률이 제일 높아 가운데 위 칸에만 두기 시작했다.
2. 우연히 agent가 가운데 위 칸에서 몇 번 이겨서 승률이 높아졌고, 이에 무지성으로 가운데 위에만 두기 시작했다.

 

이렇게 2가지입니다.

 

1번 시나리오대로라면 agent가 가운데 칸의 승률이 0.97로 가장 높다는 것을 스스로 알아내고 그곳에 첫 수를 둔 것이기 때문에 더할 나위 없이 좋은 결과이겠지만, 다른 칸들에 겨우 150번 남짓 둬보고 가운데 위 칸의 승률이 제일 높다고 판단하기는 어려울 것이라 생각했습니다.

 

따라서 저는 agent가 어쩌다 가운데 위 칸에서 몇 번 이기는 바람에 가운데 위 칸의 승률이 제일 높아졌고, 승률이 높은 쪽으로 행동하는 agent가 가운데 위에만 첫 수를 둔 것이라고 생각했습니다. 이러한 상황이 벌어지면 다른 칸들의 승률이 높을지 낮을지 정확히 판단할 수 없다는 문제가 발생할 수 있기에, 이를 해결하기 위해 모든 칸에 돌아가면서 한 번씩 첫 수를 두게 코드를 수정해보았습니다.

 

모든 칸에 같은 횟수만큼 첫 수를 두게 한 결과는 다음과 같습니다.

같은 횟수로 첫 수 선택 실행 결과 (승률 : 58.52%)

 

가장 먼저 눈에 띄는 것은 승률이 확연히 낮아졌다는 것입니다. 이전 실행 결과의 전체 승률이 95% 정도인 것에 비해 모든 칸을 동일하게 선택했을 경우 최종 승률이 58.52% 밖에 되지 않습니다. 이를 통해 이전 실행에서 agent가 어느 정도는 승률이 높은 쪽으로 선택했다는 것을 알 수 있습니다.

 

또, 칸 별 승률을 확인하면 이번에는 가운데의 승률이 약 0.7로 승률이 0.5~0.6인 다른 곳보다 높다는 것을 알 수 있습니다. 사실 우리가 일반적으로 생각하는 건 당연히 가운데에 놔야 승률이 높다고 생각하는데, 앞서는 이러한 결과가 나오지 않았지만 무조건 한 번씩 돌아가며 선택하게하자 이런 결과가 나온 것도 상당히 흥미로운 것 같습니다.

 

어찌되었든 모든 칸을 동일하게 선택했을 경우 승률이 낮아지는 것은 맞기에 승률이 높은 쪽으로 알아서 선택하게 하는 앞선 실행이 좀 더 효과적이었다고 판단할 수 있을 것 같습니다.


에이전트와 직접 플레이

다음으로 해볼 일은 에이전트와 직접 플레이가 가능하게 하는 것입니다. 백날천날 개발해봤자 컴퓨터에서 혼자 돌아가다가 끝나면 무슨 소용이겠습니까. 의문의 조력자(GPT-4o)에게 조언을 구한 결과 q-value들을 저장해두면 학습된 에이전트를 저장하는 것과 같은 효과라고 합니다. q-value들은 어떤 행동을 했을 때 어떤 보상이 올지를 예측하게 해주기 때문이죠.

 

따라서 run_q_learning.py 파일을 수정하여 pickle이라는 라이브러리를 이용할 수 있도록 q_value들을 저장하였습니다. 해당 코드는 다음과 같습니다.

if __name__ == '__main__':
    env = TicTacToe("1p", "N")
    agent = Agent(env)
    history = Run_Q_Learning(agent, env, 10000)

    # Q-Value 저장
    agent.save_q_table('q_table.pkl')

    total_wins = sum(1 for reward in history if reward == env.reward_case["win"])
    total_episodes = len(history)
    win_rate = total_wins / total_episodes
    print(f'총 승률: {win_rate:.2%}')
    
    plot_graphs(history, agent, env)  # 그래프 그리기

 

이후 play_with_agent라는 파일을 새로 만들어 저장해둔 pkl 파일을 pickle 라이브러리를 이용해 불러오고, 게임을 진행할 수 있게 해주었습니다. play_with_agent 파일의 코드는 다음과 같습니다.

from TTT_Env import TicTacToe
from agent import Agent
import pickle

def play_with_agent(agent, env):
    state = env.reset()
    env.print_board()
    
    while True:
        if env.player == 1:
            print("당신의 차례입니다. 착수할 위치를 입력하세요 (1-9): ")
            action = int(input()) - 1
        else:
            action = agent.choose_action(state)
            print(f"에이전트의 차례입니다. 에이전트가 {action + 1} 위치에 착수했습니다.")
        
        next_state, reward, done = env.move(action)
        env.print_board()
        state = next_state

        if done:
            if reward == env.reward_case["win"]:
                if env.player == -1:
                    print("당신이 이겼습니다!")
                else:
                    print("에이전트가 이겼습니다!")
            elif reward == env.reward_case["draw"]:
                print("무승부입니다!")
            break

if __name__ == '__main__':
    env = TicTacToe("1p", "Y")
    agent = Agent(env)
    
    # Q-값 불러오기
    agent.load_q_table('q_table.pkl')
    
    play_with_agent(agent, env)

 

이후 대망의 시험을 해보았습니다.


Human vs AI ! (Tik-Tak-Toe)

"인간과 AI의 맞대결" 지금까지 인간은 수많은 종목에서 AI와 대결해왔습니다. 처음에는 기계가 어떻게 인간을 이기냐고 비웃음만 가득했죠.

그러나 1997년 IBM의 딥블루가 세계 체스 챔피언을 상대로 승리하는 말도 안 되는 일이 일어났고, 이후 AI의 폭주는 점점 심해져 절대 AI가 이길 수 없다던 바둑에서마저 세계 최고의 바둑 기사 이세돌(3D)가 알파고에게 4대1로 완벽히 패배하는 지경에까지 와버렸습니다.

이 모든 끔찍한 광경들을 우리 인간들은 그저 목도할 수 밖에 없었습니다. 그러나 오늘, 드디어 잔혹한 AI와의 피터지는 복수전이 펼쳐집니다. 무려 틱택토로 말이죠.

 

이번 대결은 총 3번의 대국으로 이루어집니다. 시작은 인간 플레이어가 먼저 하며, AI가 인간을 상대로 얼마나 잘 싸우는지를 확인해볼 것입니다. 다음은 대국 장면입니다.

 

  • 대국 1
1번째 대국 (인간 승)

 

  • 대국 2
2번째 대국 (인간 승)

 

  • 대국 3
3번째 대국 (인간 승)

 

큰일입니다....ㅋㅋ 인간이 쳐발랐습니다. 틱택토는 가뜩이나 쉬운 게임이라 AI가 하기도 쉬운데 제가 대놓고 공격을 해도 막기는 커녕 이상한 곳에다만 둡니다. 이런 애가 승률 95%라니 기가 차는군요.

 

학습이 잘 안 된건지 모델 저장을 잘못한 건지 감도 안 잡힙니다. 일단 오늘은 앞에 여러 그래프들을 이용해 비교했다는 것에 의의를 두고... 여기까지만 해보는 게 좋을 것 같습니다. 나중에 에러를 또 고쳐보든가 해야겠네요