강화학습/파이썬과 케라스로 배우는 강화학습(스터디)

[강화학습] 17 - A3C 알고리즘

고집호랑이 2023. 2. 5. 07:15

개요 

 

 

앞에서 배운 DQN 알고리즘은 그때그때의 샘플들로 학습했을 때, 샘플끼리의 연관성으로 에이전트가 잘못 학습하는 문제를 해결하기 위해서 경험 리플레이를 사용했습니다.

 

샘플을 리플레이 메모리에 많이 모은 후에 임의 추출한 샘플을 사용해 성공적인 학습을 이끌어냈죠.

 

하지만 리플레이 메모리가 커서 컴퓨터의 많은 메모리를 차지한다면 학습의 속도는 느려지며, 과거의 정책을 사용하기 때문에 오프폴리시 강화학습(DQN의 경우 큐러닝)만을 사용해야 한다는 단점이 있습니다.

 

이러한 단점 때문에 다른 방법으로 문제에 접근해 만들어진 것이 A3C(Asynchronous Advantage Actor-Critic) 알고리즘입니다.

 

A3C란 무엇일까?

DQN 알고리즘이 리플레이 메모리를 사용해 샘플 사이의 연관성을 깼다면, A3C 알고리즘은 아예 에이전트 여러 개를 사용해 샘플을 모음으로써 샘플 사이의 연관성을 깹니다.

 

샘플을 모으는 각 에이전트를 액터러너라고 부르는데 이 액터러너들이 각기 다른 환경에서 학습하면서 샘플을 모으게 됩니다. 

 

각 액터러너가 행동을 선택할 때 사용하는 인공신경망을 로컬신경망, 최종적으로 저희가 학습시킬 인공신경망을 글로벌신경망이라고 합니다.

 

각 액터러너는 일정 타임스텝 동안 모은 샘플을 통해 글로벌신경망을 업데이트하고 이후 로컬신경망을 글로벌신경망으로 업데이트해줍니다. 

 

여러 개의 액터러너가 이 과정을 비동기적(Asynchronous)으로 진행하기 때문에 A3C(Asynchronous Advantage Actor-Critic)라는 이름이 붙은 것입니다.

 

이를 그림으로 표현하면 다음과 같습니다. 

 

A3C 학습 과정

 

하나의 코드에서 여러 개의 액터러너를 각 환경에서 플레이시키려면 멀티스레딩(Multi-threading)이라는 것을 사용해야 합니다.

 

멀티스레딩

스레드(Thread)는 프로세스(Process) 내에서 실행되는 실행 단위입니다. 일반적으로 하나의 프로그램을 실행시키면 프로세스가 실행되고 프로세스 내에서 스레드가 실행됩니다. 

 

프로그래머가 정의하지 않는다면 파이썬은 기본적으로 하나의 프로세스에 하나의 스레드를 생성해줍니다. 

 

하지만 하나의 프로세스에 여러 개의 스레드를 생성할 수 있는데 이 방법을 멀티스레딩(Multi-threading)이라고 합니다. 

 

멀티스레싱

  

파이썬은 구조상 멀티스레딩이 효율적이지 않기 때문에 GIL이라는 장치가 여러 개의 스레드가 동시에 실행되는 것을 막고 한 번씩 돌아가면서 처리하게 해줍니다.

 

파이썬에서 멀티스레딩을 사용하는 방법은 다음과 같습니다.

 

import threading

class Agent(threading.Thread):
    def __init__(self, number):
        threading.Thread.__init__(self) # threading.Thread 클래스 초기화 코드
        self.n = number + 1
        pass
    def run(self):
        for i in range(10):
            print(str(i) + " " + str(self.n) + "번째 스레드\n")

agents = [Agent(i) for i in range(3)]

for agent in agents:
    agent.start()

Agent 클래스를 생성할 때 threading.Thread를 상속받고 run이라는 함수를 만들면 run 함수가 여러 개의 스레드에서 실행됩니다. 

 

만들어진 Agent 클래스를 3개 생성한 후에 start()를 실행하면 run 함수가 각각 3개의 스레드로 나눠서 실행됩니다.

 

각각의 스레드가 0부터 9까지 세도록 만들었으며, 숫자와 함께 자신이 몇 번째 스레드에서 나온 값인지 출력하도록 하였습니다. 

 

멀티스레딩 실행 터미널 창

 

각각의 스레드가 번갈아가면서 0~9까지의 숫자를 출력하는 것을 확인할 수 있습니다. 

 

이렇게 A3C의 액터러너들도 번갈아가면서 각각의 환경에서 탐험하면서 샘플을 모아 학습할 것입니다.

 

브레이크아웃 A3C 코드 설명

브레이크아웃 A3C의 전체 코드는 아래 링크에서 찾아볼 수 있습니다. 먼저 전체 코드를 훑고 설명을 보는 것이 이해에 도움이 될 겁니다.

 

https://github.com/rlcode/reinforcement-learning-kr-v2

 

GitHub - rlcode/reinforcement-learning-kr-v2: [파이썬과 케라스로 배우는 강화학습] 텐서플로우 2.0 개정판

[파이썬과 케라스로 배우는 강화학습] 텐서플로우 2.0 개정판 예제. Contribute to rlcode/reinforcement-learning-kr-v2 development by creating an account on GitHub.

github.com

 

먼저 A3C의 전반적인 코드 흐름을 설명하도록 하겠습니다.

 

A3CAgent 클래스가 생성 → A3CAgent 클래스 내의 train함수가 실행 → train 함수는 정해진 thread 수만큼 액터러너 클래스인 Runner를 생성 → 멀티스레딩을 통해서 각 액터러너들은 환경에서 탐험 → 탐험하면서 얻은 샘플들로 오류함수 계산 → 로컬 신경망에서 학습한 그레이디언트를 통해 글로벌 신경망 업데이트 → 로컬신경망의 가중치를 글로벌신경망의 가중치로 업데이트

 

글로벌 신경망은 A3CAgent 클래스에, 로컬신경망은 Runner 클래스에 선언하며 둘 다 앞에서 배운 액터-크리틱(ActorCritic) 모델 구조를 가지고 있습니다. 

 

따라서 행동을 선택할 때도 정책신경망의 출력에 따라 확률적으로 행동을 선택합니다.

 

<액터러너의 run함수>

def run(self):
    # 액터러너끼리 공유해야하는 글로벌 변수
    global episode, score_avg, score_max

    step = 0
    while episode < num_episode:
        done = False
        dead = False

        score, start_life = 0, 5
        observe = self.env.reset()

        # 랜덤으로 뽑힌 값 만큼의 프레임동안 움직이지 않음
        for _ in range(random.randint(1, 30)):
            observe, _, _, _ = self.env.step(1)

        # 프레임을 전처리 한 후 4개의 상태를 쌓아서 입력값으로 사용.
        state = pre_processing(observe)
        history = np.stack([state, state, state, state], axis=2)
        history = np.reshape([history], (1, 84, 84, 4))

        while not done:
            step += 1
            self.t += 1
#-------------------------생략--------------------------
         
            # 에피소드가 끝나거나 최대 타임스텝 수에 도달하면 학습을 진행
            if self.t >= self.t_max or done:
                self.train_model(done)
                self.t = 0

#-------------------------생략---------------------------

run 함수에는 각 액터러너가 환경과 상호작용하며 학습하는 부분이 들어있어야 합니다.

 

따라서 환경과 상호작용하는 코드부터(전처리한 상태들을 신경망의 입력으로 넣고 행동을 결정한 후 다음 상태, 보상, 에피소드 종결 유무 등을 환경으로부터 얻는 일련의 과정) 텐서보드에 학습 정보를 기록하는 부분은 앞의 DQN 알고리즘에서의 main 루프 안에 있는 코드와 유사합니다.

 

다른 점은 DQN 알고리즘에서는 리플레이 메모리의 크기가 정해준 수치에 도달한 시점부터 한 타임스텝마다 학습을 했다면 A3C 알고리즘에서는 에피소드가 끝나거나(done) self.t_max(= 20) 타임스텝에 도달했을 때 학습을 진행합니다.

 

저희가 자세히 살펴봐야 할 부분은 모델을 학습시키는 부분(train_model)오류함수를 계산하는 부분(compute_loss)입니다.

 

<모델 학습 train_model>

# 로컬신경망을 통해 그레이디언트를 계산하고, 글로벌 신경망을 계산된 그레이디언트로 업데이트
def train_model(self, done):

    global_params = self.global_model.trainable_variables
    local_params = self.local_model.trainable_variables

    with tf.GradientTape() as tape:
        total_loss = self.compute_loss(done)

    # 로컬신경망의 그레이디언트 계산
    grads = tape.gradient(total_loss, local_params)
    # 안정적인 학습을 위한 그레이디언트 클리핑
    grads, _ = tf.clip_by_global_norm(grads, 40.0)
    # 로컬신경망의 오류함수를 줄이는 방향으로 글로벌신경망을 업데이트
    self.optimizer.apply_gradients(zip(grads, global_params))
    # 로컬신경망의 가중치를 글로벌신경망의 가중치로 업데이트
    self.local_model.set_weights(self.global_model.get_weights())
    # 업데이트 후 저장된 샘플 초기화
    self.states, self.actions, self.rewards = [], [], []

앞에서 설명한 대로 각 액터러너의 로컬신경망에 저장된 샘플들로 그레이디언트(grads)를 계산해준 후 이를 이용해서 글로벌신경망을 업데이트(optimizer.apply_gradients(zip(grads, global_params))해줍니다. 

 

이후 set.weights 함수를 이용해 로컬신경망의 가중치를 글로벌신경망의 가중치와 동일하게 업데이트해 줍니다.

 

또한 DQN과 같이 이 코드에서도 학습의 안정성을 위해서 그레이디언트 클리핑을 진행하였는데, DQN에서 사용했던 방식과는 다릅니다. 

 

self.optimizer = Adam(self.learning_rate, clipnorm=10.) #DQN에서 사용한 그레이디언트 클리핑

DQN에서는 위와 같이 옵티마이저를 선언할 때 clipnorm 인자를 이용해 그레이디언트 클리핑을 구현했습니다.

 

from tensorflow.compat.v1.train import AdamOptimizer # 이전 버전의 옵티마이저

#-----------------....-------------------

# 인공신경망 업데이트하는 옵티마이저 함수 생성
self.optimizer = AdamOptimizer(self.lr, use_locking=True) # A3C 알고리즘에서는 use_locking을 사용하기 위해서
                                                          # 이전 버전의 옵티마이저 사용으로 clipnorm 사용 불가

A3C에서는 위와 같이 여러 액터러너가 동시에 글로벌신경망을 업데이트할 때 생길 수 있는 문제를 방지해 주는 use_locking 인자를 사용하기 위해서 이전 버전의 옵티마이저(AdamOptimizer)를 사용합니다.

 

이전 버전의 옵티마이저에는 clipnorm 인자를 사용할 수 없기 때문에 여기서는 tf.clip_by_global_norm 함수를 이용해서 그레이디언트 클리핑을 해줬습니다.

 

<오류함수 계산 compute_loss>

# 저장된 샘플들로 A3C의 오류함수를 계산
def compute_loss(self, done):

    discounted_prediction = self.discounted_prediction(self.rewards, done)
    discounted_prediction = tf.convert_to_tensor(discounted_prediction[:, None],
                                                 dtype=tf.float32)

    states = np.zeros((len(self.states), 84, 84, 4))

    for i in range(len(self.states)):
        states[i] = self.states[i]
    states = np.float32(states / 255.)

    policy, values = self.local_model(states)

    # 가치 신경망 업데이트
    advantages = discounted_prediction - values
    critic_loss = 0.5 * tf.reduce_sum(tf.square(advantages))

    # 정책 신경망 업데이트
    action = tf.convert_to_tensor(self.actions, dtype=tf.float32)
    policy_prob = tf.nn.softmax(policy)
    action_prob = tf.reduce_sum(action * policy_prob, axis=1, keepdims=True)
    cross_entropy = - tf.math.log(action_prob + 1e-10)
    actor_loss = tf.reduce_sum(cross_entropy * tf.stop_gradient(advantages))

    entropy = tf.reduce_sum(policy_prob * tf.math.log(policy_prob + 1e-10), axis=1)
    entropy = tf.reduce_sum(entropy)
    actor_loss += 0.01 * entropy

    total_loss = 0.5 * critic_loss + actor_loss

    return total_loss

앞의 액터-크리틱에서 저희가 고려해야 할 오류함수는 2가지였습니다. 정책을 근사하는 정책신경망과 더불어 가치함수를 근사하는 가치신경망 2개를 사용했으니 오류함수도 2개가 필요했었죠.

 

기억을 더듬어보면 액터-크리틱의 오류함수들은 다음과 같았습니다. 

 

액터-크리틱의 오류함수들

 

A3C 알고리즘도 로컬신경망과 글로벌신경망이 액터크리틱 모델 구조를 가지고 있기 때문에 오류함수가 위와 비슷한 형태일 것입니다.

 

먼저 가치신경망의 오류함수부터 살펴봅시다. 위의 run 함수를 보면 알 수 있듯이 A3C 알고리즘은 한 번의 타임스텝마다 모델을 업데이트하는 액터-크리틱 알고리즘과 다르게 최대 self.t_max(= 20) 타임스텝에 한 번씩 학습을 진행합니다.

 

따라서 k 타임스텝 동안 얻은 보상들을 사용하면 어드밴티지 함수는 다음과 같고, 오류함수는 어드밴티지 함수의 제곱($δ_v^2$)이 됩니다.

 

$$δ_v = R_{t+1} + γR_{t+2} + ... + γ^kV_v(S_{t+k}) - V_v(S_t)$$

 

저장된 샘플들로 오류함수를 구하는데 샘플에는 k 타임스텝 동안의 지나온 상태, 받은 보상, 행동들이 모두 저장되어 있을 것입니다. 

 

따라서 최종 오류함수는 에이전트가 지나온 모든 상태에 대해서 위의 오류함수를 구하고 더한 값입니다.

 

예를 들어 에이전트가 3 타임스텝을 지난 후 에피소드가 끝났다면, 샘플의 상태에는 $[S_t, S_{t+1}, S_{t+2}, S_{t+3}]$이 저장되어 있고 로컬신경망을 통과한 values 변수에는 $[V_v(S_t), V_v(S_{t+1}), V_v(S_{t+2}), V_v(S_{t+3})]$가 저장되어 있을 것입니다.

 

그렇다면 최종 오류함수 critic_loss은 다음과 같이 모든 상태에 대한 오류함수의 합이 됩니다.

 

critical_loss = $R_{t+1} + γR_{t+2} + γ^2R_{t+3} + γ^3V_v(S_{t+3}) - V_v(S_t)$ ($S_t$에 대한 오류함수) +

$R_{t+2} + γR_{t+3} +γ^2V_v(S_{t+3}) - V_v(S_{t+1})$($S_{t+1}$에 대한 오류함수) + 

$R_{t+3} + γV_v(S_{t+3}) - V_v(S_{t+2})$($S_{t+2}$에 대한 오류함수)

 

# k-타임스텝의 prediction 계산
def discounted_prediction(self, rewards, done):
    discounted_prediction = np.zeros_like(rewards)
    running_add = 0

    if not done:
        # value function
        last_state = np.float32(self.states[-1] / 255.)
        running_add = self.local_model(last_state)[-1][0].numpy()

    for t in reversed(range(0, len(rewards))):
        running_add = running_add * self.discount_factor + rewards[t]
        discounted_prediction[t] = running_add
    return discounted_prediction

$R_{t+1} + γR_{t+2} + ... + γ^kV_v(S_{t+k})$은 discounted_prediction 함수를 이용해서 구하는데, 이때 효율적으로 계산하기 위해서 REINFORCE 알고리즘에서 반환값을 계산한 방법과 같이 역순으로 순회하며 계산하는 것을 확인할 수 있습니다.

 

A3C 알고리즘에서 정책신경망의 오류함수는 2가지로 이루어져 있습니다. 첫 번째는 액터-크리틱의 오류함수와 같은 크로스 엔트로피(cross_entropy)와 어드밴티지 함수(advantages)의 곱이고 두 번째는 정책에 대한 엔트로피(entropy)입니다.

 

action = tf.convert_to_tensor(self.actions, dtype=tf.float32)
policy_prob = tf.nn.softmax(policy)
action_prob = tf.reduce_sum(action * policy_prob, axis=1, keepdims=True)
cross_entropy = - tf.math.log(action_prob + 1e-10)
actor_loss = tf.reduce_sum(cross_entropy * tf.stop_gradient(advantages))

위와 같이 액터-크리틱과 동일한 방법으로 크로스 엔트로피(cross_entropy)와 어드밴티지 함수(advantages)를 곱해 오류함수를 구하는 것을 확인할 수 있습니다. 

 

다만 크로스 엔트로피와 곱해지는 어드밴티지 함수는 위에서 설명한 k-타임스텝 어드밴티지 함수이며, 이 오류함수 또한 모든 상태에 대해 계산하여 합친 값이라는 것을 명심해야 합니다. 

 

당연히 가치신경망까지 그레이디언트가 흐르지 않게 tf.gradient 함수를 사용한 것을 확인할 수 있습니다.

 

entropy = tf.reduce_sum(policy_prob * tf.math.log(policy_prob + 1e-10), axis=1)
entropy = tf.reduce_sum(entropy)
actor_loss += 0.01 * entropy

정책신경망의 두 번째 오류함수는 정책에 대한 엔트로피입니다.

 

엔트로피는 크로스 엔트로피를 설명할 때 잠깐 언급했었는데, 식은 아래와 같으며 불확실성의 정도를 나타낸다고 했습니다. 

 

엔트로피 = $-\sum_{i}\:p_i\:log\:p_i$ ($p_i$ = i번째 사건이 일어날 확률)

 

엔트로피는 확률변수가 균등한 분포를 가질수록 불확실하므로 높은 값을 나타내는 성질을 가지고 있는데, 이를 이용하면 ε-탐욕정책을 사용하지 않아도 에이전트가 탐험을 하도록 유도하는 오류함수를 만들 수 있습니다. 

 

엔트로피의 부호를 반대로 하고 이를 최소화한다면 정책은 균등한 분포를 가지려고 하기 때문에 에이전트는 탐험을 하게 됩니다. 

 

하지만 이 엔트로피 오류함수의 비중을 크게 한다면 에이전트는 탐험을 많이 하게 되어 학습이 잘 이루어지지 않을 것입니다.

 

따라서 엔트로피 오류함수는 0.01을 곱해서 크로스 엔트로피 오류함수에 비해 중요도를 낮게 설정해 줍니다.

 

브레이크아웃 A3C 실행 결과

학습이 오래 걸리기 때문에 A3C의 실행 결과 텐서보드에 도출되는 그래프는 생략하도록 하겠습니다.

 

책에서의 A3C 알고리즘은 16개의 액터러너를 사용하여 글로벌신경망을 비동기적으로 업데이트해 주었습니다.

 

이렇게 16개의 액터러너들이 각자 다른 환경에서 모은 샘플들로 그레이디언트를 계산해 글로벌 신경망을 업데이트하기 때문에 샘플 사이의 상관관계를 깰 수 있는 것입니다. 

 

학습 결과, 획득했던 최대 점수는 DQN과 비슷하지만, 가장 큰 차이점은 학습 속도입니다. 리플레이 메모리를 사용하지 않기 때문에, 브레이크아웃에서 동일한 점수까지 도달하는데 A3C 알고리즘이 더 빠른 시간 안에 학습하게 됩니다.

 

책에서는 이 A3C 알고리즘을 끝으로 글을 마무리 짓습니다. 지금까지 책의 내용을 따라서 다이내믹 프로그래밍부터 A3C 알고리즘까지 살펴봤습니다.

 

현재까지 사용되는 강화학습 알고리즘으로 발전되는 과정을 쉽고 자세히 풀어쓴 만큼 앞으로 강화학습을 공부하는데 필요한 기초 지식을 탄탄하게 잡아준 것 같습니다.

 

이후에 더욱 다양한 강화학습 알고리즘을 살펴보면서 실제 학습시키고자 하는 환경에도 강화학습을 적용시켜 보도록 하겠습니다. 지금까지 읽어주셔서 감사합니다~!

 

 

http://www.yes24.com/Product/Goods/44136413

 

파이썬과 케라스로 배우는 강화학습 - YES24

“강화학습을 쉽게 이해하고 코드로 구현하기”강화학습의 기초부터 최근 알고리즘까지 친절하게 설명한다!‘알파고’로부터 받은 신선한 충격으로 많은 사람들이 강화학습에 관심을 가지기

www.yes24.com

※ 이 글은 위의 책 내용을 바탕으로 작성한 글입니다.