비전·음성·추천

DOMAIN / 34번째 글

Q-러닝에서 DQN까지: 테이블에서 신경망으로

Bellman 방정식을 TD 갱신으로 푸는 Q-러닝을 예순네 칸짜리 표에서 따라가고, 그 표가 무너지는 세 자리를 신경망으로 넘어서는 DQN까지 한 편에서 정리한다. 경험 재생과 타겟 네트워크가 왜 필요한지를 숫자로 확인한다.

PALDYN Team44 MIN READ

지난 글에서 에이전트와 환경이 상태·행동·보상을 주고받는 틀을 세우고 Gymnasium으로 환경 하나를 돌려 봤다. 틀은 잡혔지만 정작 「무엇을 어떻게 배우는가」는 비어 있었다. 이 글은 그 빈칸을 하나의 답으로 채운다. 상태마다 행동의 가치를 매기고, 가장 높은 행동을 고른다.

가치를 어디에 적어 두느냐만 다른 두 알고리즘을 한자리에서 다룬다. Q-러닝은 표에 적고, DQN은 신경망의 가중치에 적는다. 둘을 갈라 배우면 별개의 알고리즘 둘로 보이지만, 갱신하는 식은 처음부터 끝까지 하나다. DQN이 더한 것은 새 학습 규칙이 아니라 같은 규칙을 신경망 위에서 터지지 않게 굴리는 두 개의 장치다. 그래서 순서가 이렇게 된다 — 먼저 표에서 규칙 자체를 완전히 이해하고, 표가 무너지는 자리를 짚고, 그 자리를 신경망으로 메운다.

Q-함수

상태-행동 가치

Q-함수는 상태와 행동의 쌍 하나에 숫자 하나를 붙이는 함수다. Q(s,a)Q(s, a) 는 「상태 ss 에서 행동 aa 를 하고, 그 뒤로는 최선을 다했을 때 앞으로 받게 될 보상의 할인 합」이다. 여기서 할인은 먼 미래의 보상을 γ\gamma 배씩 깎아 세는 것을 말한다. 열 걸음 뒤에 받을 1점은 γ10\gamma^{10} 점으로 친다.

「그 뒤로는 최선을 다했을 때」라는 조건이 이 값의 성격을 정한다. Q(s,a)Q(s, a) 는 지금 이 한 번의 행동만 내가 정하고 나머지는 완벽한 정책에 맡겼을 때의 값이다. 그래서 첫 행동이 실수여도 그 값은 「실수 뒤에 최선을 다한 결과」이지 「끝까지 헤맨 결과」가 아니다. 이 구분이 뒤에서 갱신식을 읽을 때 계속 쓰인다.

값 하나에 미래 전체가 접혀 들어간다는 점이 중요하다. 어떤 상태에서 「왼쪽으로 가면 0.59, 아래로 가면 0.95」라는 두 숫자를 갖고 있다면, 그 뒤에 무슨 일이 벌어지는지 한 걸음도 시뮬레이션하지 않고 아래를 고를 수 있다. 미래를 내다보는 일을 미리 해 두고 그 결과만 들고 다니는 셈이다.

Bellman 최적 방정식

문제는 「앞으로 받게 될 보상의 할인 합」을 직접 셀 수 없다는 것이다. 끝까지 가 보기 전에는 모르고, 끝까지 가 보는 일을 모든 상태에서 반복할 수도 없다. 여기서 값을 자기 자신으로 정의하는 관계식이 나온다.

Q∗(s,a)=E ⁣[ r+γmax⁡a′Q∗(s′,a′)  |  s,a ]Q^*(s, a) = \mathbb{E}\!\left[\, r + \gamma \max_{a'} Q^*(s', a') \;\middle|\; s, a \,\right]

이것이 Bellman 최적 방정식이다. 왼쪽은 지금 쌍의 가치이고, 오른쪽은 「지금 받는 보상 rr」 더하기 「다음 상태 s′s' 에서 고를 수 있는 최선의 가치를 할인한 것」이다. 미래 전체를 한 걸음과 나머지로 쪼개 놓았고, 나머지는 다시 같은 함수로 적혀 있다.

이 식이 유용한 이유는 양변에 필요한 것이 다르다는 데 있다. 왼쪽을 알려면 미래 전체가 필요하지만, 오른쪽에는 실제로 한 걸음 걸어 보면 얻어지는 것(rr, s′s')과 지금 갖고 있는 추정값(QQ)뿐이다. 미래를 몰라도 오른쪽은 계산할 수 있다. 그러니 오른쪽을 계산해 왼쪽을 조금씩 고쳐 나가면 된다 — Q-러닝은 이 한 문장을 알고리즘으로 옮긴 것이 전부다.

가치 기반 정책

Q-함수를 알면 정책은 따로 학습할 필요가 없다. 그냥 뽑아 쓰면 된다.

π∗(s)=arg⁡max⁡aQ∗(s,a)\pi^*(s) = \arg\max_a Q^*(s, a)

어떤 상태에 놓이든 그 행에서 가장 큰 값을 가진 열을 고른다. 「정책」이라는 별도의 함수도, 확률 분포도 없다. 이것을 가치 기반(value-based) 방법이라 부른다 — 가치를 배우고 정책은 거기서 파생시킨다는 뜻이다.

여기서 한 가지가 따라 나온다. 정책이 최적이기 위해 Q값이 정확할 필요는 없다. 필요한 것은 각 행에서 어느 열이 가장 큰지, 그 순서뿐이다. 모든 값이 실제보다 0.3씩 낮게 추정되어 있어도 순서가 같으면 정책은 똑같다. 뒤에서 볼 DQN의 과대추정 문제가 성가신 것도 정확히 이 지점 때문이다 — 값이 틀리는 것 자체가 아니라 틀리는 정도가 행동마다 달라 순서를 뒤집을 때 정책이 망가진다.

표 기반 Q-러닝

Q-테이블

상태와 행동이 유한하고 개수가 적으면 Q-함수를 그냥 2차원 배열로 들고 있으면 된다. 행이 상태, 열이 행동, 칸 값이 Q(s,a)Q(s, a) 인 이 배열을 Q-테이블이라 부른다.

Q-러닝: Q-테이블과 업데이트 규칙

Gymnasium의 FrozenLake 4×4를 예로 든다. 얼어붙은 호수 위를 걸어 구멍을 피해 목표 칸까지 가는 게임이고, 상태는 지금 서 있는 칸의 번호 0~15, 행동은 좌·하·우·상 넷이다. 표는 16×4=6416 \times 4 = 64 칸이다. 미끄러짐을 끈 결정론 모드(is_slippery=False)에서는 고른 방향으로 정확히 한 칸 움직이고, 벽으로 가면 제자리에 남는다. 보상은 목표 칸에 닿을 때 1점, 나머지는 전부 0점이다.

표는 전부 0으로 시작한다. 0으로 시작한다는 것은 「아직 아무것도 모른다」가 아니라 「모든 행동이 똑같이 쓸모없다고 믿는다」는 뜻이다. 이 상태에서 arg⁡max⁡\arg\max 를 취하면 늘 같은 열이 뽑히므로, 학습 초기에는 표가 정책 노릇을 전혀 못 한다. 뒤에서 볼 탐험 장치가 필요한 이유가 여기서 이미 나온다.

TD 에러와 갱신식

한 걸음 걸을 때마다 표의 칸 하나를 이렇게 고친다.

Q(s,a)←Q(s,a)+α[ r+γmax⁡a′Q(s′,a′)−Q(s,a) ]Q(s, a) \leftarrow Q(s, a) + \alpha \left[\, r + \gamma \max_{a'} Q(s', a') - Q(s, a) \,\right]

대괄호 안이 TD 에러(Temporal Difference Error)다. 앞의 두 항 r+γmax⁡a′Q(s′,a′)r + \gamma \max_{a'} Q(s', a') 은 한 걸음 실제로 걸어 보고 얻은 새 추정이라 TD 타겟이라 부르고, 뒤의 Q(s,a)Q(s, a) 는 걷기 전에 갖고 있던 옛 추정이다. 둘의 차이가 곧 「내 예상이 얼마나 빗나갔나」이고, TD 에러가 양수면 그 칸을 올리고 음수면 내린다.

α\alpha 는 학습률로, 빗나간 만큼의 몇 퍼센트를 반영할지 정하는 0과 1 사이의 값이다. α=1\alpha = 1 이면 옛 추정을 버리고 새 추정으로 통째로 갈아 끼우는데, 환경에 무작위성이 있으면 마지막 한 번의 운에 표가 통째로 휘둘린다. 보통 0.1 언저리를 쓴다.

에피소드가 끝나는 걸음에서는 예외가 하나 붙는다. 목표 칸이나 구멍에 빠진 다음에는 미래가 없으므로 타겟이 그냥 rr 이다. 코드에서 target = r if done else …로 갈라 놓는 그 한 줄이 이것이고, 빠뜨리면 종료 상태 너머의 가치를 계속 더하게 되어 값이 발산한다. 뒤에서 DQN이 같은 일을 (1 - d)를 곱해 처리한다 — 같은 규칙이 배치 연산으로 옮겨진 것뿐이다.

값의 전파 순서

식만 보면 추상적이니 실제로 표가 채워지는 순서를 따라가 본다. α=0.1\alpha = 0.1, γ=0.99\gamma = 0.99 로 두고, 목표 칸 바로 왼쪽인 14번 칸에서 오른쪽으로 처음 성공한 순간을 본다. 이 걸음은 보상 1을 받고 에피소드가 끝나므로 타겟은 1이고, TD 에러는 1−0=11 - 0 = 1 이다.

Q(14,→)←0+0.1×1=0.1Q(14, \rightarrow) \leftarrow 0 + 0.1 \times 1 = 0.1

표 전체에서 0이 아닌 칸이 이제 딱 하나 생겼다. 다음 에피소드에서 에이전트가 10번 칸에서 아래로 내려와 14번에 도착하면, 이번에는 보상이 0인데도 갱신이 일어난다. 타겟이 0+0.99×0.1=0.0990 + 0.99 \times 0.1 = 0.099 이기 때문이다.

Q(10,↓)←0+0.1×0.099=0.0099Q(10, \downarrow) \leftarrow 0 + 0.1 \times 0.099 = 0.0099

여기가 Q-러닝의 핵심 장면이다. 보상은 목표 칸 하나에만 있는데, 값은 목표에서 시작해 한 에피소드에 한 칸씩 거꾸로 번져 나간다. 0번 칸에서 목표까지 최단 여섯 걸음이니, 출발 칸의 값이 0을 벗어나려면 성공적인 에피소드가 적어도 여섯 번은 이 경로를 되짚어야 한다. 학습 초반에 아무 일도 안 일어나는 것처럼 보이는 구간이 바로 이 시간이다.

같은 칸을 반복해서 밟으면 값은 타겟을 향해 지수적으로 다가간다. Q(14,→)Q(14, \rightarrow) 는 0.1 → 0.19 → 0.271로 오르고, nn 번째에는 1−0.9n1 - 0.9^n 이다. 열 번이면 0.651, 쉰 번이면 0.995다. 한 번의 갱신이 격차의 10%씩만 좁히므로 표가 수렴하려면 같은 칸을 수십 번씩 밟아야 하고, 이것이 Q-러닝이 데이터를 많이 먹는 이유다.

학습이 끝난 뒤 각 행의 arg⁡max⁡\arg\max 만 뽑아 격자에 그리면 정책이 그림으로 보인다. H는 구멍, G는 목표다.

0열 1열 2열 3열
0행 → → ↓ ←
1행 ↓ H ↓ H
2행 → ↓ ↓ H
3행 H → → G

3행의 마지막 칸이 구멍 옆을 지나 목표로 붙고, 0행 3열은 아래가 구멍이라 왼쪽으로 되돌아간다. 표 하나가 미로의 해답을 통째로 들고 있는 셈이다.

탐험과 오프폴리시

ε-greedy

표가 0으로 시작한다면 arg⁡max⁡\arg\max 는 늘 0번 열을 고른다. 목표에 한 번도 닿지 못하면 보상을 못 받고, 보상이 없으면 표는 영원히 0이다. 이 닭과 달걀을 끊는 가장 단순한 장치가 ε-greedy다. 매 걸음 ε\varepsilon 의 확률로 아무 행동이나 무작위로 고르고, 나머지 확률로는 표가 시키는 대로 한다.

def act(self, state: int) -> int:
    if np.random.random() < self.epsilon:
        return np.random.randint(self.Q.shape[1])   # 탐험
    return int(np.argmax(self.Q[state]))            # 활용

무작위 갈래를 탐험(exploration), 표를 따르는 갈래를 활용(exploitation)이라 부른다. 둘의 비율을 고정해 두면 어느 쪽이든 손해다. ε\varepsilon 이 계속 1이면 끝까지 무작위로 헤매느라 배운 것을 써먹지 못하고, 계속 0이면 처음에 우연히 높아진 칸에 갇혀 더 나은 길을 영영 못 본다.

ε 앤닐링

그래서 ε\varepsilon 을 시간에 따라 줄인다. 이것을 앤닐링(annealing)이라 하고, 에피소드마다 일정 비율을 곱하는 방식이 가장 흔하다.

Q-러닝 핵심 구현

class QLearningAgent:
    def __init__(self, n_states, n_actions, lr=0.1, gamma=0.99):
        self.Q = np.zeros((n_states, n_actions))
        self.lr, self.gamma = lr, gamma
        self.epsilon = 1.0                    # 완전 탐험으로 시작

    def learn(self, s, a, r, s_next, done):
        target = r if done else r + self.gamma * np.max(self.Q[s_next])
        td_error = target - self.Q[s, a]
        self.Q[s, a] += self.lr * td_error
        return td_error

# 에피소드가 끝날 때마다
agent.epsilon = max(0.01, agent.epsilon * 0.995)

곱하는 값 0.995와 바닥값 0.01이 곡선을 정한다. 200에피소드면 0.995200≈0.370.995^{200} \approx 0.37, 400에피소드면 0.1350.135, 600에피소드면 0.050.05 다. 바닥에 닿는 시점은 0.995n=0.010.995^n = 0.01 을 풀어 약 920에피소드다. FrozenLake 2,000에피소드 학습에서 최근 200 에피소드의 평균 보상은 0.23에서 시작해 600에피소드 무렵 0.95, 1,000에피소드에서 1.0에 닿는다. 성공률이 오르는 구간과 ε\varepsilon 이 0.05 아래로 내려가는 구간이 겹치는 것이 우연이 아니다 — 무작위 걸음이 줄어야 표가 시키는 최단 경로가 그대로 실행된다.

바닥값을 0으로 두지 않고 0.01을 남기는 데도 이유가 있다. 완전히 0이 되면 표가 한 번 잘못 수렴한 자리에서 빠져나올 길이 사라진다. 100걸음에 한 번쯤은 딴 길로 새게 두는 보험이다.

행동 정책과 목표 정책

갱신식을 다시 보면 이상한 점이 하나 있다. 실제 행동은 ε-greedy로 골라 놓고, 타겟은 max⁡a′Q(s′,a′)\max_{a'} Q(s', a') 로 계산한다. 다음 걸음에서 무작위로 엉뚱한 데 갈 수도 있는데 타겟은 「다음에도 최선을 다한다」고 가정한다.

모순이 아니라 의도다. 이런 성질을 오프폴리시(off-policy)라 부른다 — 데이터를 모으는 정책(행동 정책, ε-greedy)과 값을 배우려는 정책(목표 정책, greedy)이 다르다는 뜻이다. 반대쪽인 온폴리시(on-policy)는 둘을 일치시킨다. 같은 자리에서 max⁡\max 대신 실제로 고른 다음 행동의 Q(s′,a′)Q(s', a') 를 쓰면 SARSA라는 알고리즘이 되고, 이쪽은 「탐험하다 구멍에 빠지는 위험」까지 값에 반영해 더 조심스러운 정책을 배운다.

오프폴리시가 주는 자유가 이 글의 나머지 절반을 가능하게 한다. 배우는 대상이 데이터를 모은 정책과 무관하므로, 경험을 언제 누가 모았는지 따지지 않아도 된다. 다른 에이전트가 모은 기록도, 무작위로 헤맨 기록도, 열 판 전의 내가 남긴 기록도 전부 학습 재료가 된다. 뒤에서 볼 경험 재생이 성립하는 근거가 정확히 여기다 — 저장해 둔 옛 경험을 지금 다시 꺼내 쓸 수 있는 것은 Q-러닝이 오프폴리시이기 때문이고, 온폴리시 알고리즘이었다면 지금 정책으로 모은 것만 써야 해서 버퍼라는 발상 자체가 성립하지 않는다.

Q-테이블의 한계

상태 수의 폭발

FrozenLake의 64칸은 노트북에서 즉시 돌아간다. 문제는 조금만 현실에 가까워져도 이 숫자가 감당할 수 없이 커진다는 것이다.

DQN 논문이 다룬 아타리 게임을 보자. 전처리를 거친 화면은 84×8484 \times 84 회색조이고, 공이 어느 쪽으로 움직이는지 알려면 한 장으로는 부족해 연속된 네 장을 쌓아 상태로 쓴다. 픽셀은 84×84×4=28,22484 \times 84 \times 4 = 28{,}224 개, 화소값은 256단계다. 가능한 상태의 수는 25628224256^{28224} 다. 관측 가능한 우주의 원자 수가 108010^{80} 언저리라는 것과 비교하면 자릿수를 세는 일 자체가 무의미하다.

표를 만들 메모리가 없다는 것보다 더 근본적인 문제가 있다. 한 칸을 채우려면 그 칸을 최소 한 번은 밟아야 한다. 상태가 106010^{60} 개면 각 칸을 한 번씩 방문하는 데 걸리는 시간이 우주의 나이를 넘는다. 표 기반 학습은 「모든 칸을 여러 번 방문한다」는 가정 위에 서 있고, 그 가정이 무너지는 순간 수렴 보장도 함께 사라진다.

연속 상태의 이산화

두 번째 벽은 값이 연속인 경우다. 로봇 팔의 관절 각도, 자동차의 속도, 막대의 기울기 같은 것은 실수라 애초에 「칸」이 없다.

가장 먼저 떠오르는 해법은 구간을 잘라 이산화하는 것이다. 그런데 차원 수가 곱으로 들어간다. CartPole은 카트 위치, 카트 속도, 막대 각도, 각속도 넷을 관측하는데, 각 축을 10개 구간으로 자르면 104=10,00010^4 = 10{,}000 개 상태에 행동이 둘이니 표가 20,000칸이다. 정밀도를 조금 높여 20개 구간으로 자르면 204=160,00020^4 = 160{,}000 개 상태, 320,000칸이 된다. 축이 넷이 아니라 스물이면 어떤 구간 수를 골라도 답이 없다.

구간을 어디서 자를지도 문제다. 막대가 넘어지기 직전의 좁은 각도 구간에서는 0.5도 차이가 생사를 가르지만, 평평하게 서 있는 구간에서는 5도쯤 달라도 대응이 같다. 균등하게 자르면 중요한 자리는 뭉개지고 안 중요한 자리에는 칸이 남아돈다. 잘 자르려면 어디가 중요한지를 미리 알아야 하는데, 그것이야말로 배우려던 것이다.

일반화의 부재

세 번째가 가장 아프다. 표에는 상태들이 서로 비슷하다는 정보가 아예 없다. 상태 7과 상태 8은 배열의 이웃한 행일 뿐, 값이 이웃하리라는 근거가 어디에도 없다. 상태 번호를 무작위로 섞어 다시 붙여도 Q-러닝은 똑같이 동작한다.

그래서 표는 일반화를 못 한다. 아타리 화면에서 한 픽셀만 다른 두 장면은 사람이 보기에 같은 상황이지만 표에는 완전히 다른 두 행이고, 한쪽에서 배운 것이 다른 쪽으로 전혀 옮겨 가지 않는다. 방문하지 않은 칸의 값은 끝까지 초기값 그대로다.

세 벽의 뿌리는 하나다. 표는 QQ 를 기억할 뿐 계산하지 않는다. 필요한 것은 상태를 입력으로 받아 Q값을 만들어 내는 함수, 그리고 비슷한 입력에 비슷한 값을 내주는 성질이다. 그 함수를 신경망으로 두는 것이 다음 절이다.

Q-테이블이 멈추는 세 벽

DQN의 구성

함수 근사

바꾸는 것은 딱 한 군데다. 표에서 값을 꺼내던 자리를 신경망의 순전파로 갈아 끼운다. 파라미터 θ\theta 를 가진 네트워크로 Q를 흉내 내는 이 방식을 함수 근사(function approximation)라 한다.

class QNetwork(nn.Module):
    def __init__(self, obs_dim, act_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, 128), nn.ReLU(),
            nn.Linear(128, 128),     nn.ReLU(),
            nn.Linear(128, act_dim),   # 각 행동의 Q값을 한 번에
        )

    def forward(self, x):
        return self.net(x)

입력이 상태 하나, 출력이 행동 개수만큼의 Q값이라는 설계가 눈여겨볼 지점이다. Q(s,a)Q(s, a) 를 그대로 옮기면 상태와 행동을 함께 넣어 스칼라 하나를 받아야 할 것 같지만, 그러면 max⁡a′\max_{a'} 를 구할 때마다 행동 수만큼 순전파를 돌려야 한다. 출력 층을 행동 개수로 두면 한 번의 순전파로 그 행 전체가 나오고, max⁡\max 도 arg⁡max⁡\arg\max 도 마지막 축에서 한 번에 끝난다. 표에서 한 행을 통째로 꺼내던 동작과 정확히 대응한다.

크기를 세어 보면 왜 이쪽이 이득인지 분명해진다. CartPole은 관측이 4차원, 행동이 둘이다. 위 네트워크의 파라미터는 4×128+1284 \times 128 + 128, 128×128+128128 \times 128 + 128, 128×2+2128 \times 2 + 2 를 더해 17,410개다. 앞 절에서 각 축을 10구간으로 자른 표가 20,000칸이었으니 저장 공간은 오히려 더 적으면서 이산화 손실이 없고, 게다가 안 가 본 상태에도 값을 내준다. 세 벽이 한꺼번에 사라진다.

그런데 표의 갱신식을 그대로 신경망에 옮기면 학습이 발산한다. 표에서는 칸 하나를 고쳐도 나머지 63칸이 그대로였지만, 신경망에서는 가중치 하나를 건드리면 모든 입력에 대한 출력이 함께 움직인다. 여기서 두 가지가 동시에 무너진다.

DQN 아키텍처: 두 신경망 + 경험 재생

경험 재생

첫째, 데이터가 독립이 아니다. 지도학습은 데이터셋을 무작위로 섞어 미니배치를 만들지만, 강화학습의 경험은 s0→s1→s2s_0 \rightarrow s_1 \rightarrow s_2 로 이어지는 한 줄기다. 연속된 상태들은 서로 강하게 닮아 있고, 그 배치로 경사하강을 하면 네트워크가 방금 지나온 구간에만 맞춰진다. 왼쪽으로 한참 달리는 동안에는 왼쪽 상황의 값만 정확해지고, 오른쪽으로 방향을 틀면 방금 배운 것이 뭉개진다.

경험 재생(Experience Replay)이 이 상관성을 끊는다. 전이 (s,a,r,s′,done)(s, a, r, s', \text{done}) 을 즉시 학습에 쓰지 않고 재생 버퍼라는 고정 크기 큐에 쌓아 두었다가, 학습할 때 거기서 무작위로 미니배치를 뽑는다.

class ReplayBuffer:
    def __init__(self, capacity=100_000):
        self.buffer = deque(maxlen=capacity)

    def push(self, s, a, r, s_next, done):
        self.buffer.append((s, a, r, s_next, done))

    def sample(self, batch_size=32):
        s, a, r, s_, d = zip(*random.sample(self.buffer, batch_size))
        return (torch.as_tensor(np.array(s), dtype=torch.float32),
                torch.as_tensor(a).unsqueeze(1),          # gather용 (B, 1)
                torch.as_tensor(r, dtype=torch.float32),
                torch.as_tensor(np.array(s_), dtype=torch.float32),
                torch.as_tensor(d, dtype=torch.float32))  # done을 0/1 실수로

    def __len__(self):
        return len(self.buffer)

deque(maxlen=...)이 오래된 것부터 자동으로 밀어내므로 버퍼 관리 코드가 따로 필요 없다. 텐서로 바꾸는 자리가 여기라는 점은 짚어 둘 만하다. 행동만 정수 텐서로 남기는 것은 뒤에서 gather의 인덱스로 쓰이기 때문이고, done을 불리언이 아니라 0과 1의 실수로 만드는 것은 타겟 계산에서 곱셈에 쓰이기 때문이다. 파이썬 튜플의 리스트를 넘기기 전에 np.array로 한 번 모으는 것도 습관으로 둘 만하다 — 배열의 리스트를 텐서 생성자에 바로 넣으면 원소를 하나씩 복사하느라 느리다. 무작위 샘플링이 주는 이득은 둘이다. 배치 안의 32개 전이가 서로 다른 시점·다른 에피소드에서 왔으므로 시간 상관이 사라지고, 같은 경험을 버려지기 전까지 여러 번 다시 쓰므로 데이터 효율이 오른다. 환경과 한 번 상호작용하는 값이 비싼 로봇 같은 영역에서는 두 번째 이득이 더 크다.

버퍼 크기는 그냥 크게 잡으면 되는 값이 아니다. 너무 작으면 배치가 최근 몇 에피소드에서만 뽑혀 상관성이 도로 살아나고, 너무 크면 한참 전의 형편없던 정책이 남긴 경험이 계속 섞인다. 그 옛 경험으로도 학습이 되는 것은 오프폴리시이기 때문이지만, 지금 정책이 실제로 마주치는 상태 분포와는 점점 멀어진다.

타겟 네트워크

둘째, 타겟이 가만히 있지 않는다. TD 타겟 r+γmax⁡a′Q(s′,a′;θ)r + \gamma \max_{a'} Q(s', a'; \theta) 는 지금 학습 중인 바로 그 파라미터로 계산된다. Q(s,a;θ)Q(s, a; \theta) 를 타겟에 가깝게 밀면 같은 θ\theta 가 움직이면서 타겟도 함께 움직인다. 자기 그림자를 밟으려는 것과 같아서 학습이 진동하거나 발산한다.

DQN의 해법은 단순하다. 네트워크를 두 벌 두고 역할을 나눈다. 두 장치가 같은 논문에서 온 것은 아니다 — 2013년의 첫 논문에는 경험 재생만 있었고, 타겟 네트워크는 2015년 Nature 판에서 더해졌다.

온라인 네트워크 θ\theta 타겟 네트워크 θ−\theta^-
하는 일 행동 선택, Q(s,a)Q(s,a) 계산 TD 타겟 계산만
갱신 방식 매 스텝 경사하강 온라인 파라미터를 그대로 복사
갱신 주기 매 스텝 보통 1,000~10,000 스텝마다

타겟 네트워크는 복사와 복사 사이에 얼어붙어 있으므로, 그 구간 동안 학습 목표가 고정된 값이 된다. 지도학습과 같은 상황이 되는 셈이다. 갱신 주기가 짧으면 원래의 진동 문제로 되돌아가고, 너무 길면 낡은 타겟을 좇느라 학습이 느려진다.

# __init__에서 — 두 벌을 같은 값으로 맞춰 둔다
self.online_net = QNetwork(obs_dim, act_dim)
self.target_net = QNetwork(obs_dim, act_dim)
self.target_net.load_state_dict(self.online_net.state_dict())

# train_step 끝에서 — C 스텝에 한 번 같은 줄을 다시 부른다
self.steps += 1
if self.steps % self.target_update_freq == 0:
    self.target_net.load_state_dict(self.online_net.state_dict())

두 네트워크를 만든 뒤 처음에 파라미터를 복사해 맞추는 줄을 빠뜨리면 안 된다. 초기화가 무작위라 그냥 두면 타겟 네트워크가 아무 근거 없는 값을 목표로 내주게 된다. 주기적인 동기화도 결국 같은 한 줄이다 — 타겟 네트워크에는 따로 학습시킬 것이 없고, 온라인 쪽 값을 통째로 받아 오는 일만 있다.

손실 함수와 갱신

갱신 한 번이 실제로 무엇을 계산하는지 보면 표의 갱신식이 그대로 들어 있는 것이 보인다. 손실은 TD 에러의 제곱이다.

L(θ)=E(s,a,r,s′)∼D[( r+γmax⁡a′Q(s′,a′;θ−)−Q(s,a;θ) )2]L(\theta) = \mathbb{E}_{(s,a,r,s') \sim D}\left[\left(\, r + \gamma \max_{a'} Q(s', a'; \theta^-) - Q(s, a; \theta) \,\right)^2\right]

DD 가 재생 버퍼, θ−\theta^- 가 타겟 네트워크다. 대괄호 안은 앞에서 본 TD 에러와 글자 하나 다르지 않고, 표에서 α\alpha 를 곱해 직접 더하던 자리를 경사하강이 대신할 뿐이다.

DQN 핵심 구현 (PyTorch)

def train_step(self, batch_size=32):
    s, a, r, s_, d = self.buffer.sample(batch_size)

    q_online = self.online_net(s).gather(1, a).squeeze(1)      # Q(s, a)
    with torch.no_grad():
        q_next = self.target_net(s_).max(1)[0]
        td_target = r + self.gamma * q_next * (1 - d)          # 종료면 r만

    loss = nn.MSELoss()(q_online, td_target)
    self.optimizer.zero_grad()
    loss.backward()
    nn.utils.clip_grad_norm_(self.online_net.parameters(), 10.0)
    self.optimizer.step()

네 줄이 각각 앞의 개념 하나에 대응한다. gather(1, a)는 네트워크가 뱉은 행동별 Q값 중 실제로 고른 행동의 것만 뽑는다 — 표에서 Q[s, a]로 칸 하나를 짚던 동작이다. (1 - d)는 종료 전이에서 미래 항을 지워 타겟을 rr 로 만든다 — 표에서 done이면 타겟을 rr 로 두던 그 갈래가 곱셈 하나로 옮겨진 것이다. torch.no_grad()는 타겟 쪽으로 기울기가 흐르지 않게 막는다. 여기를 빠뜨리면 타겟 네트워크를 둔 의미가 사라지고 손실이 자기 자신을 낮추는 방향으로도 미분되어 값이 0으로 무너진다.

self.target_net.eval()을 함께 호출하는 코드를 자주 보는데, 이 네트워크에는 드롭아웃도 배치 정규화도 없으므로 eval()은 동작을 바꾸지 않는다. 기울기를 실제로 막는 것은 no_grad() 쪽이다. 습관으로 붙여 두는 것은 무해하지만 그것이 안전장치라고 믿으면 곤란하다.

마지막 줄의 기울기 클리핑도 그냥 붙은 것이 아니다. TD 타겟은 네트워크의 출력에서 나오므로 초기에 튄 값 하나가 거대한 손실을 만들 수 있고, 그 한 번의 스텝이 가중치를 날려 버린다. DQN의 2015년 Nature 판은 TD 에러 자체를 [−1,1][-1, 1] 로 잘랐는데, 이는 오차가 작을 때는 제곱 손실, 클 때는 절댓값 손실처럼 동작하는 Huber 손실을 쓰는 것과 같은 효과다. PyTorch에서는 nn.SmoothL1Loss()로 바로 바꿔 끼울 수 있다.

DQN의 실전과 한계

훈련 루프

바깥 루프는 표 기반 때와 거의 같다. 행동을 고르고, 환경을 한 걸음 밟고, 결과를 버퍼에 넣고, 학습을 한 번 돌린다.

next_state, reward, done, truncated, _ = env.step(action)
agent.buffer.push(state, action, np.clip(reward, -1, 1), next_state, done or truncated)
agent.train_step()

np.clip(reward, -1, 1)이 보상 클리핑이다. 아타리 게임마다 점수 단위가 제각각이라 어떤 게임은 한 번에 100점, 어떤 게임은 1점을 준다. 그대로 두면 게임마다 학습률을 다시 맞춰야 하는데, 부호만 남기고 크기를 자르면 하나의 하이퍼파라미터 세트로 여러 게임을 돌릴 수 있다. 대신 「크게 이득인 행동」과 「조금 이득인 행동」의 구분이 사라지는 대가를 치른다. CartPole처럼 보상이 늘 +1인 환경에서는 이 줄이 아무 일도 하지 않는다 — 아타리에서 옮겨 온 코드에 그냥 남아 있는 것이니, 새 환경에 붙일 때는 보상 범위를 먼저 확인하고 필요 없으면 빼는 편이 낫다.

두 번째 손잡이는 학습을 언제 시작하느냐다. if len(self.buffer) < batch_size: return으로 두면 전이 32개가 쌓이자마자 학습이 시작되는데, 그 32개는 거의 같은 순간에 나온 것이라 앞에서 상관성을 없애려고 만든 버퍼가 제 일을 못 한다. 보통은 수천에서 수만 전이가 쌓일 때까지 무작위로만 움직이며 버퍼를 채운 뒤 학습을 켠다.

세 번째는 앤닐링 속도다. CartPole 예제가 에피소드마다 0.997을 곱하고 500에피소드를 도는데, 0.997500≈0.220.997^{500} \approx 0.22 다. 학습이 끝나는 순간에도 다섯 걸음에 한 번은 무작위로 움직이고 있다는 뜻이고, 바닥값 0.01에 닿으려면 약 1,530에피소드가 필요하다. CartPole-v1의 만점은 한 에피소드 500스텝을 버티는 것인데, 다섯 걸음에 한 번씩 엉뚱한 방향으로 밀면 그 전에 막대가 넘어지기 쉽다. 점수가 안 오를 때 네트워크 구조부터 의심하기 전에 이 숫자를 먼저 계산해 봐야 한다. 에피소드 수를 늘리거나 곱하는 값을 0.99 언저리로 낮추는 것이 먼저다.

과대추정과 후속 버전

원본 DQN에는 구조적인 편향이 하나 있다. 타겟에 들어가는 max⁡a′Q(s′,a′;θ−)\max_{a'} Q(s', a'; \theta^-) 는 추정값 중 최댓값인데, 추정에 잡음이 섞여 있으면 최댓값은 참값보다 체계적으로 크게 나온다. 우연히 높게 추정된 행동이 항상 뽑히기 때문이다. 이 과대추정(overestimation)이 타겟을 부풀리고, 부풀린 타겟이 다시 학습에 들어가며 누적된다.

Double DQN은 이 고리를 행동을 고르는 일과 그 행동을 평가하는 일을 서로 다른 네트워크에 맡겨 끊는다. 온라인 네트워크가 최선의 행동을 고르고, 그 행동의 값은 타겟 네트워크에 물어본다. 두 네트워크의 잡음이 서로 독립이라 같은 방향으로 부풀지 않는다.

with torch.no_grad():
    best_actions = self.online_net(s_).argmax(1, keepdim=True)      # 선택은 온라인
    q_next = self.target_net(s_).gather(1, best_actions).squeeze(1)  # 평가는 타겟

원본의 max(1)[0] 한 줄을 이 두 줄로 바꾸는 것이 전부다. 네트워크를 새로 만들 필요도, 하이퍼파라미터를 더할 필요도 없다.

버전 무엇을 바꿨나 얻는 것
Double DQN 행동 선택은 온라인, 평가는 타겟 Q값 과대추정 완화
Dueling DQN 출력을 V(s)V(s) 와 A(s,a)A(s,a) 로 분리 행동과 무관한 상태 가치를 따로 학습
Prioritized Replay TD 에러가 큰 전이를 더 자주 샘플링 배울 것이 많은 경험에 집중
Rainbow DQN 위 셋에 n-step·분포적 Q·Noisy Net을 더함 개별 개선을 합쳐 성능 향상

Dueling DQN의 발상은 「지금 상태 자체가 좋은가」와 「이 상태에서 이 행동이 다른 행동보다 나은가」를 갈라 두는 것이다. 어느 행동을 해도 결과가 비슷한 상태에서는 앞쪽만 배우면 되므로 학습이 빨라진다. Prioritized Replay는 무작위 샘플링을 TD 에러 크기에 비례한 샘플링으로 바꾸되, 그렇게 하면 분포가 편향되므로 중요도 가중치로 보정한다. Rainbow는 이런 개선들을 한 모델에 모아 각각의 기여를 함께 확인한 작업이다.

이산 행동의 벽

DQN이 표의 세 벽을 모두 넘은 것은 맞다. 상태가 아무리 커도, 연속이어도, 처음 보는 것이어도 네트워크는 값을 내준다. 2013년 DeepMind의 「Playing Atari with Deep Reinforcement Learning」이 보인 것이 그것이었다 — 게임마다 구조를 바꾸지 않고 픽셀 입력만으로 아타리 게임 일곱 개를 학습했고, 그중 셋에서 사람 전문가를 넘어섰다. 타겟 네트워크를 더한 2015년 Nature 판은 같은 알고리즘·같은 구조·같은 하이퍼파라미터로 49개 게임을 돌려 사람 테스터에 견줄 수준을 냈다. 현대 심층 강화학습이 여기서 시작한다.

그런데 한쪽 축은 그대로 남았다. 상태는 연속이어도 되지만 행동은 여전히 이산이어야 한다. 출력 층의 뉴런 수가 행동 개수이고, 정책은 그중 arg⁡max⁡\arg\max 를 고르는 일이다. 로봇 팔의 관절 토크나 자동차의 조향각처럼 행동 자체가 실수인 문제에서는 이 구조가 성립하지 않는다. 관절 일곱 개를 각각 열 단계로 잘라도 조합이 10710^7 가지라 출력 층이 그만큼 필요하고, arg⁡max⁡\arg\max 한 번에 천만 개를 비교해야 한다. 상태 쪽에서 이미 무너진 것을 봤던 그 이산화의 벽이 행동 쪽에 그대로 서 있는 셈이다.

문제의 뿌리는 이 글이 처음부터 깔고 온 전제에 있다. 우리는 가치를 배우고 정책을 거기서 파생시켰다. 정책이 arg⁡max⁡\arg\max 라는 연산에 묶여 있는 한 그 연산이 가능한 형태로만 행동을 둘 수 있다. 다음 글은 이 전제를 뒤집는다. 가치를 거치지 않고 상태를 행동의 확률 분포로 바로 옮기는 함수를 두고, 그 함수의 파라미터를 기대 보상이 커지는 방향으로 직접 미는 방법을 본다. 행동이 연속이어도 되고, 확률적인 정책이 필요한 자리에도 쓸 수 있는 길이다.


읽어주셔서 감사합니다. 😊

LATEST

비전·음성·추천의 최신 글

비전·음성·추천2026.09.07

오프라인 강화학습 — 쌓인 로그만으로 정책을 배우기

실제 서비스에서 탐색은 곧 사용자에게 나쁜 행동을 해 보는 일입니다. 이미 쌓인 로그만으로 정책을 배우려 할 때 왜 Q값이 혼자 부풀어 오르는지, 그 부풀음을 누르는 세 갈래 대응, 행동 복제라는 기준선의 무게, 그리고 배포 전에 성능을 재는 일이 왜 가장 어려운지를 정리합니다.

18 MIN
비전·음성·추천2026.09.07

다국어 전이 — 라벨 없는 언어에서 모델이 동작하는 이유

영어 라벨만으로 학습한 분류기가 한국어 문장을 그대로 처리하는 일이 실제로 일어납니다. 여러 언어가 한 표현 공간에 겹쳐 놓이는 원리, 그 겹침이 무너지는 조건, 번역해서 학습할지 번역해서 추론할지 고르는 기준, 그리고 언어별로 나눠 재야 하는 이유를 정리합니다.

16 MIN
비전·음성·추천2026.09.07

정보 추출 — 글 한 덩이를 표 한 줄로 바꾸는 일

계약서와 이메일을 데이터베이스에 넣으려면 글에서 값을 뽑아 칸에 채워야 합니다. 개체명·관계·사건의 세 층위, 값을 정규화하는 일이 왜 절반인지, 근거 위치를 함께 남겨야 하는 이유, 규칙·전용 모델·언어 모델의 갈림길, 그리고 필드별로 재는 평가법을 정리합니다.

17 MIN