No Limitation
[Reinforcement Learning] Value-based RL - Deep Q Network 본문
본 포스팅은 카이스트 산업 및 시스템 공학과 신하용 교수님의 동적계획법과 강화학습 강의 자료와 노승은 님의 바닥부터 배우는 강화학습 교재를 참고해 작성되었습니다.
본 포스팅은 어느 정도 RL에 대한 어느 정도의 개념을 아시는 분들이 참고해주시면 감사하겠습니다. ( 물론 저도 제대로 잡혀있다고 볼 순 없지만...ㅠ 이상한 부분은 크리틱 부탁드립니다..!
신하용 교수님께서 만드신 슬라이드 중에서 굉장히 RL 모델들의 분류가 잘 되어있다고 생각이 드는 슬라이드를 가지고 와 보았습니다. 이 중에서 본 포스팅에서 다루는 파트가 어딘 지도 살펴보겠습니다.
우선, 보상 함수와 상태 전이 확률을 알고 있는 경우, model을 알고 있다고 표현합니다. 이런 경우는 일반적인 DP로 문제를 풀 수 있습니다. 하지만, 대부분은 model을 모르는 model-free이기 때문에 이 경우를 보통 '강화학습'이라고 명명하는 부분입니다. 강화학습에서는 행동을 수행할 때마다 변하는 상태들을 담을 상태 공간 ( state space )과 액션들을 담고 있는 액션 공간 ( action space )를 필요로 하는데 이를 테이블로 정리할 수 있을 만큼의 task는 MC나 TD 같은 방법으로 풀 수 있지만, 그 양이 매우 큰 경우는 다 담을 수 없고 값을 알 수 없기 때문에 가치 함수 값이나 정책 함수 값을 어떠한 함수를 통해 근사시키는 방법으로 문제를 풀게 되는데, 이 때 딥러닝 기반의 근사 방법들이 등장하였고, 이 기반으로 등장한 것이 바로 그 유명한 Deep Q Network입니다.
저 위에서 가치 함수나 정책 함수 값을 근사시킨다고 했습니다. 즉 근사시키는 함수가 v_θ (s)나 q_θ (s, a) 같은 가치 함수이면 가치 함수 기반의 RL이 되는 것이고, 정책 함수 π_θ (a|s)면 정책 함수 기반의 RL이 되는 것입니다. notation에서 θ는 신경망의 가중치를 의미합니다.
가치 기반의 RL은 가치 함수, 특히 q_θ (s, a)에 따라 이를 최대화하는 a를 선택합니다. 즉 이 경우는 바로 a가 정해지기 때문에 정책 함수가 따로 필요 없습니다. 반면 정책 기반 RL은 바로 정책 함수를 근사시키는 것이기 때문에 따로 가치 함수를 필요로 하지 않습니다. 경험을 통해 바로 π를 강화하기 때문입니다. 그리고 이 두 함수를 모두 사용해서 접근하는 방식이 바로 actor-critic이라고 합니다. actor는 행동하는 것으로 정책 π를 말하고 critic은 비평가로서 가치 함수 v_θ (s)나 q_θ (s, a)를 의미하게 되는 것입니다. 이번 포스팅에서는 가치 기반의 RL에 대해 공부해보도록 하겠습니다.
자 그렇다면, 우리는 실제 value 값은 다 저장해놓지 않고 딥러닝을 통해 근사하는 방법을 사용하는데, 예를 들어 v_θ (s)가 있다고 하면 우리는 이 value를 바탕으로 모형을 학습하게 되는데, 어떻게 딥러닝이 v_θ (s)를 잘 근사시켰다고 판단할 수 있을까요? 정답이 따로 알 수 있는게 아닌데도 말입니다. 즉, 아래 수식처럼 원래는 이런 loss function이 정의되서 backpropagation을 수행해야 하는데 저기서 v_true를 알 수 없으니 말입니다.
본 수식에서 expectation이 붙은 이유는 모든 상태 s에 대해서 L을 최소화하는 것은 사실상 불가능하기 때문에 정책 함수 π를 이용해서 방문했던 상태 s에 대해서만 L을 최소화하면 더 효율적으로 문제를 풀 수 있기 때문입니다. 그래서 저 expecation에 대한 gradient 값을 구하게 되고 ( 이는 expectation을 구하는 방법으로 샘플을 뽑아 계산하게 됩니다. ) 그렇게 구한 gradient를 바탕으로 네트워크의 θ를 update하게 됩니다. 하지만 위에서 언급드렸듯 v_true을 알 수 없는 문제가 발생하는데 바로 이 v_true를 대신해서 Monte Carlo Return과 TD target을 사용합니다. 이들은 MC method와 TD method에서 자세한 건 추후에 다루도록 하겠습니다.
MC Method에서는 value를 업데이트하는 방법으로 아래의 방법을 사용하였습니다. 즉, 감쇠된 누적 보상의 합 Gt를 이용하는 것이죠.
여기서도 바로 Gt와 V(st)의 차이를 바탕으로 업데이트를 수행하게 됩니다. 바로 이 콘셉을 활용해서 정답지로 Gt를 사용하는 것입니다. 즉, 아래 처럼 손실 함수 L이 정의되게 됩니다.
하지만 이 방법도 단점이 있습니다. MC 방법이 갖는 한계점을 그대로 가지고 있는데요, 즉 에피소드가 끝나야만 리턴이 계산될 수 있고 또한 분산이 큰 단점이 존재하게 됩니다.
TD method에서는 1-step TD target을 활용하겠습니다. 즉 한 스텝 더 진행해서 추측한 값을 이용하여 현재의 추측치를 업데이트 하는 방식이죠. 그래서 이전에 정답이 자리에 TD target을 넣어주면 아래와 같이 전개됩니다.
저기서 v(s_t+1) 항목이 들어갔는데 이를 정답처리로 할 수 있는지 의문을 품는 분들도 계실겁니다. 정답지는 상수이고 그것을 향해 점차 근사를 하는 것이 학습을 안정적으로 수행할 수 있게 해주기 때문이죠. 하지만 업데이트 시점의 θ을 이용해 TD target을 계산하면 이 값은 그냥 하나의 숫자입니다. 즉, 상수처럼 처리를 하게 되고 θ에 편미분을 하게 될 때 0으로 취급하게 됩니다. 그래서 pytorch로 구현시 이 타겟에 대해서는 .detach()를 해주어 미분을 수행하지 않게 됩니다. 코드에 대해서는 후반부에 다루도록 하겠습니다.
자 그렇다면 이러한 background를 바탕으로 본격적으로 DQN에 대해 공부해보도록 하겠습니다.
Deep Q Learning
Q-Learning Based
가치 기반 RL은 따로 명시적인 정책이 없습니다. 대신 state와 action에 대한 value를 담고 있는 q function을 활용해서 각 상태에서 가장 가치가 높은 액션을 선택하는 식으로 정책을 만들게 됩니다. 바로 이 방법을 기반하고 있는 것이 Q-learning입니다. Q-learning은 벨만 최적 방정식을 이용해 최적 action-value를 학습하는 내용을 바탕으로 합니다. 아래 수식은 벨만 최적방정식과 이를 이용한 Q 러닝 업데이트 수식입니다. ( Q-learning에 대해서는 MC, TD method와 함께 추후에 따로 정리하도록 하겠습니다. )
위 업데이트 수식은 정답과 추측치인 Q(s,a) 사이 차이를 줄이는 방향으로 업데이트를 수행합니다. 마찬가지의 방법으로 neural net에서 마찬가지로 손실함수를 정의할 수 있게 됩니다. 즉, 손실 함수는 아래와 같이 정의할 수 있고 gradient descent도 아래와 같이 계산이 됩니다.
이런 방법을 통해 학습이 수행되는데, 구체적인 슈도코드를 간략히 적어놓은 것은 아래와 같습니다. 아래 글은 노승은 님의 교재에 있는 내용을 참고하였습니다.
즉 에피소드를 수행하면서 action a를 Q를 바탕으로 선택을 하는 부분과 Target으로 사용하는 정답 값 r + γQ_θ(s`,a`)을 구하기 위한 과정에서 선택되는 action a`가 있음을 알 수 있습니다. 이 경우 e-greedy로 선택이 되는 샘플은 실제 에이전트가 action을 수행한 부분을 의미합니다. 반면, target을 구하기 위해 사용되는 a`는 실제 action을 취하는 것이 아니라 오로지 업데이트를 위한 계산에만 사용되는 부분입니다. 여기서 Q러닝이 off-policy라는 특징을 다시 한 번 확인할 수 있게 됩니다.
Target Network
그리고 저 수식에서 업데이트되는 Q_θ(s,a) 와 정답지로 사용한 r + γQ_θ(s`,a`) 에서 둘다 세타가 들어가있는 네트워크입니다. 이걸 그대로 업데이트하게 되면 정답지도 바뀌는 문제가 있겠죠. 그래서 DQN에서는 정답지 r + γQ_θ(s`,a`)을 업데이트해주기 위한 네트워크로 target network를 별도로 구축합니다. 그래서 열심해 train network Q_θ(s,a)를 통해 열심히 Q가 학습이 되는 동안 r + γQ_θ(s`,a`) 여기의 target network의 가중치는 업데이트가 되지 않습니다. 그러다 예를 들어 한 1천번 쯤 학습이 수행될 때, 이 때 한번 r + γQ_θ(s`,a`)의 target network 가중치를 업데이트해주고 다시 fixed 시킵니다. 이렇게 수행을 함으로서 학습의 안정성을 꽤하였고 성능을 매우 끌어올려준 대표적인 DQN의 구조입니다.
Experience Replay
또 하나의 DQN의 핵심 구조라고 할 수 있는 것은 바로 Experience Replay입니다. 이는 말 그대로 "겪었던 경험을 재사용 해보자"라는 의미를 갖습니다. 이 '경험'이라는 것은 어떻게 정의할 수 있을까요? 이 경험은 여러 에피소드들로 이루어져 있고 이 에피소드는 여러 개의 state transition으로 이루어져 있게 됩니다. 가령 하나의 state transition은 (s_t, a_t, r_t, s_t+1) 로 표현할 수 있습니다. 그래서 이러한 낱개 데이터를 활용하기 위해, DQN에서는 'Replay Buffer'라는 것을 활용합니다.
즉 저렇게 각 경험들이 들어갈 때, 저 구조는 queue 구조에 하나씩 쌓게 됩니다. queue는 구조적으로 FIFO 구조를 따르게 되므로 n개의 데이터를 가지고 있을 때 만약 새로운 경험이 들어오면 그 중 가장 오래된 경험 한 개를 out하게 됩니다. 그래서 최대한 최신 경험 데이터를 가지고 있는 것이지요. 그리고 학습을 수행할 때는 이 replay buffer에서 임의로 데이터를 뽑아서 사용하게 됩니다. 또한 여기서 랜덤하게 뽑기 때문에 연속되어 발생한 경험을 사용하는 것이 아니라 각 데이터 사이의 상관성이 적은 데이터로 학습을 하기 때문에 더 효율적으로 학습을 할 수 있다고 말합니다. 그래서 이러한 부분 때문에 experience replay는 off-policy 상황에만 사용할 수 있게 됩니다. 이러한 장점들을 가지고 성능을 증가시킨 방법이 바로 DQN입니다. 처음으로 딥러닝을 강화학습에 적용한 사례라고 할 수 있습니다.
그렇다면 이제 직접 구현 코드를 통해 공부해보겠습니다. 구현 코드는 노승은 님의 github(https://github.com/seungeunrho/RLfrombasics/blob/master/ch8_DQN.py)를 참고하였습니다.
문제를 푸는 task는 가장 대표적인 강화학습 task인 cartpole 문제를 풀어보겠습니다. cartpole은 아래 처럼 기둥을 잘 세우게끔 하는 게임입니다.
구현 코드를 하나씩 살펴보겠습니다.
## 패키지
import gym
import collections
import random
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
## hyperparameter
learning_rate = 0.0005
gamma = 0.98
buffer_limit = 50000
batch_size = 32
우선 필요한 library를 로드합니다. gym의 경우 OpenAI GYM 라이브러리를 의미합니다.
또한 딥러닝을 학습하기 위한 파라미터 설정을 위처럼 설정합니다.
다음으로는 replay buffer의 class를 정의한 코드를 살펴보겠습니다.
class ReplayBuffer():
def __init__(self):
self.buffer = collections.deque(maxlen=buffer_limit)
def put(self, transition):
self.buffer.append(transition)
def sample(self, n):
mini_batch = random.sample(self.buffer, n)
s_lst, a_lst, r_lst, s_prime_lst, done_mask_lst = [], [], [], [], []
for transition in mini_batch:
s, a, r, s_prime, done_mask = transition
s_lst.append(s)
a_lst.append([a])
r_lst.append([r])
s_prime_lst.append(s_prime)
done_mask_lst.append([done_mask])
return torch.tensor(s_lst, dtype=torch.float), torch.tensor(a_lst), \
torch.tensor(r_lst), torch.tensor(s_prime_lst, dtype=torch.float), \
torch.tensor(done_mask_lst)
def size(self):
return len(self.buffer)
함수는 put과 sample로 구성되어 있고 put은 buffer에 넣어주는 기능, sample은 샘플링을 해주는 기능을 의미합니다. 샘플링을 수행할 때는 배치별 transition, 즉 경험들을 <s, a, r, s`> 뭉치들을 넣어주어 리턴을 해주게 됩니다. 그리고 done_mask는 현재 종료 상태에 도달했는 지를 tracking하기 위한 변수로 만약 종료 상태가 되면 0의 값을 가져 0을 value에 곱하게 됩니다. buffer는 deque를 통해 queue로 동작을 하는 자료 구조를 갖습니다.
class Qnet(nn.Module):
def __init__(self):
super(Qnet, self).__init__()
self.fc1 = nn.Linear(4, 128)
self.fc2 = nn.Linear(128, 128)
self.fc3 = nn.Linear(128, 2)
def forward(self, x):
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x
def sample_action(self, obs, epsilon):
out = self.forward(obs)
coin = random.random()
if coin < epsilon:
return random.randint(0,1)
else :
return out.argmax().item()
다음으로 Q value network를 살펴보겠습니다. 일반적인 3개의 layer를 사용하였고 FC layer를 통해 구현하였습니다. activation function으로는 relu를 사용하였네요. 네트워크에서 마지막 아웃풋 layer가 nn.Linear(128, 2)임을 알 수 있는데, 이는 마지막 action이 왼쪽, 오른쪽 둘의 action space를 갖기 때문입니다. 왜냐하면 모든 액션에 대한 value인 Q(s,a)가 output이기 때문입니다. 또한 마지막 layer에는 relu를 따로 추가하지 않았는데 왜냐하면 최종 output인 Q(s,a)를 (0,inf)로 임의로 제한할 수 없기 때문입니다. 그리고 sample_action은 보시다시피 e-greedy를 통해 action을 선택하는 걸 수행해주는 함수임을 알 수 있습니다.
def train(q, q_target, memory, optimizer):
for i in range(10):
s,a,r,s_prime,done_mask = memory.sample(batch_size)
q_out = q(s)
q_a = q_out.gather(1,a)
max_q_prime = q_target(s_prime).max(1)[0].unsqueeze(1)
target = r + gamma * max_q_prime * done_mask
loss = F.smooth_l1_loss(q_a, target)
optimizer.zero_grad()
loss.backward()
optimizer.step()
자 필요한 object들을 다 정의했다면, 이제 본격적으로 학습을 수행하는 함수를 살펴보겠습니다. 주목할만한 점은 q 함수가 있고 q_target 함수가 있는 점이죠. 저 부분이 앞서 언급드렸듯 실제 action을 수행하게 되는 q함수와 정답지를 도출하기 위한 q_target이 별도의 네트워크로 주어지는 점입니다. Q_learning의 논리대로 max_q_prime을 뽑게 되고 그걸 바탕으로 target을 정의합니다. 그리고 그 값을 저희의 예측 값인 q_a와 비교하여 loss를 계산하게 됩니다.
이러한 정의를 바탕으로 아래처럼 main 함수를 구축할 수 있습니다.
def main():
env = gym.make('CartPole-v1')
q = Qnet()
q_target = Qnet()
q_target.load_state_dict(q.state_dict())
memory = ReplayBuffer()
print_interval = 20
score = 0.0
optimizer = optim.Adam(q.parameters(), lr=learning_rate)
for n_epi in range(10000):
epsilon = max(0.01, 0.08 - 0.01*(n_epi/200)) #Linear annealing from 8% to 1%
s = env.reset()
done = False
while not done:
a = q.sample_action(torch.from_numpy(s).float(), epsilon)
s_prime, r, done, info = env.step(a)
done_mask = 0.0 if done else 1.0
memory.put((s,a,r/100.0,s_prime, done_mask))
s = s_prime
score += r
if done:
break
if memory.size()>2000:
train(q, q_target, memory, optimizer)
if n_epi%print_interval==0 and n_epi!=0:
q_target.load_state_dict(q.state_dict())
print("n_episode :{}, score : {:.1f}, n_buffer : {}, eps : {:.1f}%".format(
n_epi, score/print_interval, memory.size(), epsilon*100))
score = 0.0
env.close()
if __name__ == '__main__':
main()
q의 optimizer가 train 함수의 epoch를 돌 때마다 optimize가 되는 반면, q_target의 경우 epoch를 수행하다 적당한 시점에서 q_target을 업데이트 하는 것을 알 수 있습니다. 여기선 따로 .detach()를 사용하진 않았네요. 그리고 memory.size() > 2000 구문은 버퍼에 데이터가 충분히 쌓인 다음에 train을 진행하기 위해 추가된 구문이라고 합니다. 왜냐하면 데이터가 충분히 쌓이지 않은 상태서 학습을 진행하면 초기의 데이터가 많이 재사용되어 학습이 치우쳐질 우려가 있다고 합니다.
다른 부분은 직접 보시면 충분히 이해가 되실 내용이라고 생각이 듭니다..!
이상 DQN에 대한 포스팅이었습니다! 긴 글 읽어주셔서 감사합니다!