1. 이번 글에서 다루는 내용
이번 글에서는 ACER(Actor-Critic with Experience Replay) 알고리즘을CartPole-v1 환경에 직접 적용해보면서
- 왜 ACER가 등장했는지
- 기존 Actor-Critic / A2C / A3C와 무엇이 다른지
- Replay Buffer와 Importance Sampling이 왜 필요한지
를 코드와 함께 정리해보려고 합니다.
2. 왜 ACER가 필요했을까?
2.1 Actor-Critic / A2C / A3C의 한계
앞에서 봤던 Actor-Critic 계열 알고리즘(A2C, A3C 등)은 공통적으로:
- on-policy 방식입니다.
- “지금의 정책”으로 모은 데이터만 가지고
- 그 정책을 곧바로 업데이트합니다.
- 그래서:
- 한 번 사용한 경험은 다시 쓰지 못하고
- 샘플 효율(sample efficiency)이 낮습니다.
A2C / A3C는 여러 환경을 동시에 돌려서 이 문제를 어느 정도 완화했지만,
- 여전히 “금방 잊혀지는 경험”
- Q-learning / DQN처럼 Replay Buffer에 오래 쌓아 두고 재사용하는 구조는 아닙니다.
2.2 Replay Buffer를 쓰면 안 되나?
Q-learning 계열(DQN)은:
- Replay Buffer에서 과거의 경험을 섞어서 학습하기 때문에
- 데이터 상관성이 줄어들고,
- 샘플 효율이 좋습니다.
그렇다면,
“Actor-Critic에도 Replay Buffer를 붙이면 좋지 않을까?”
문제는 Actor-Critic이 정책 gradient 기반(on-policy) 이라는 점입니다.
- Replay Buffer에 쌓여 있는 데이터는
- 예전 정책 (\mu(a|s))로 수집된 경험입니다.
- 현재 정책 (\pi(a|s))와는 다르기 때문에
- 그대로 쓰면 off-policy bias가 생깁니다.
ACER는 여기서 한 발 더 나아가,
Actor-Critic + Experience Replay + Off-policy 보정(Importance Sampling)
을 결합해서, “안정적인 off-policy Actor-Critic”을 만들려고 한 시도입니다.
3. ACER의 핵심 아이디어
ACER 논문의 전체 수식을 다 구현하기보다는, 과제에서 꼭 짚고 넘어가야 하는 포인트만 정리하면 다음과 같습니다.
- Actor-Critic 기반
- \(\pi(a|s)\)를 학습하는 Actor,
- \(V(s)\) 또는 \(Q(s,a)\)를 학습하는 Critic 구조
- Experience Replay
- Replay Buffer에 과거 경험을 저장해 두고
- 나중에 꺼내서도 계속 학습 (샘플 효율 ↑)
- Importance Sampling으로 Off-policy 보정
- Buffer 안의 경험은 옛날 정책 \(\mu(a|s)\)로 모인 것
- 현재 정책 \(\pi(a|s)\)와 다르기 때문에
- 중요도 비율 \(w = \frac{\pi(a|s)}{\mu(a|s)}\)를 곱해서 보정
- 너무 큰 w는 클리핑해서 분산을 줄임
- (논문 버전) 신뢰구간/트러스트 리전, Q-리트레이스 등
- 이 글에서는 학습용 구현을 위해 생략하고,
- “Replay + Importance Sampling + Actor-Critic” 구조에 집중합니다.
4. CartPole용 간단 ACER 구현 구조
이번에 구현한 코드는 아래 네 부분으로 나뉩니다.
- ActorCritic 네트워크
- Replay Buffer
- On-policy 업데이트 (A2C 스타일)
- Off-policy 업데이트 (Replay + Importance Sampling)
- 학습 루프 (on/off 섞어서 업데이트)
4.1 ActorCritic 네트워크
class ActorCritic(nn.Module):
def __init__(self, state_dim=4, action_dim=2, hidden_dim=128):
super().__init__()
self.fc1 = nn.Linear(state_dim, hidden_dim)
self.pi_head = nn.Linear(hidden_dim, action_dim)
self.v_head = nn.Linear(hidden_dim, 1)
def forward(self, x):
x = F.relu(self.fc1(x))
logits = self.pi_head(x)
v = self.v_head(x)
pi = F.softmax(logits, dim=-1)
return pi, v.squeeze(-1)
def act(self, s):
s_t = torch.as_tensor(s, dtype=torch.float32, device=DEVICE)
pi, v = self.forward(s_t)
dist = Categorical(pi)
a = dist.sample()
return a.item(), pi.detach().cpu().numpy(), v.item()
- 출력:
- 정책 \(\pi(a|s)\) – 행동 2개(왼/오른쪽)에 대한 확률
- 상태 가치 \(V(s)\)
act():- CartPole 환경에서 실제로 행동을 샘플링할 때 사용
- 나중에 Replay Buffer에 저장하기 위해 behavior 정책의 확률 μ(a|s) 도 함께 리턴합니다.
4.2 Replay Buffer와 behavior 확률 저장
class ReplayBuffer:
def __init__(self, capacity):
self.buffer = deque(maxlen=capacity)
def push(self, s, a, r, done, s_next, behavior_prob):
self.buffer.append((s, a, r, done, s_next, behavior_prob))
- 저장되는 항목:
- 상태
s, 행동a, 보상r, 종료 여부done, 다음 상태s_next - 그리고 그때 사용했던 정책 확률 \(\mu(a|s)\) (
behavior_prob)
- 상태
이 behavior_prob이 나중에 Off-policy 업데이트에서
Importance Sampling 비율을 계산할 때 사용됩니다.
5. On-policy 업데이트 (A2C 스타일)
먼저, 에피소드 단위로 한 번씩 on-policy 업데이트를 수행합니다.
즉, A2C/Actor-Critic처럼 “방금 수집한 궤적”만 가지고 한 번 업데이트합니다.
def acer_on_policy_update(model, optimizer, s_traj, a_traj, r_traj, done_traj):
s_traj = torch.as_tensor(np.array(s_traj), dtype=torch.float32, device=DEVICE)
a_traj = torch.as_tensor(a_traj, dtype=torch.int64, device=DEVICE)
r_traj = torch.as_tensor(r_traj, dtype=torch.float32, device=DEVICE)
done_traj = torch.as_tensor(done_traj, dtype=torch.float32, device=DEVICE)
with torch.no_grad():
pi_next, v_next = model(s_traj[-1:])
bootstrap = 0.0 # 단순화를 위해 마지막 상태는 부트스트랩하지 않음
# n-step Return 계산
returns = []
R = bootstrap
for r, d in zip(reversed(r_traj), reversed(done_traj)):
R = r + GAMMA * R * (1.0 - d)
returns.append(R)
returns.reverse()
returns = torch.stack(returns)
pi, v = model(s_traj)
dist = Categorical(pi)
log_pi_a = dist.log_prob(a_traj)
advantage = returns - v
value_loss = F.mse_loss(v, returns.detach())
policy_loss = -(log_pi_a * advantage.detach()).mean()
entropy_loss = dist.entropy().mean()
loss = policy_loss + value_loss * 0.5 - ENTROPY_COEF * entropy_loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
- 핵심은 A2C와 거의 동일합니다.
- n-step Return을 이용해 advantage를 만들고
- 정책/가치 손실을 모두 최소화합니다.
ACER에서도 on-policy 업데이트는 A2C와 비슷한 형태를 가질 수 있고,
우리는 여기에 추가로 off-policy 업데이트를 더하는 구조로 구현했습니다.
6. Off-policy 업데이트 – Replay + Importance Sampling
이제 Replay Buffer에서 샘플링한 옛날 경험을 이용해 off-policy 업데이트를 수행합니다.
def acer_off_policy_update(model, optimizer, replay_buffer, batch_size=BATCH_SIZE):
if len(replay_buffer) < batch_size:
return
s, a, r, done, s_next, mu = replay_buffer.sample(batch_size)
s = torch.as_tensor(s, dtype=torch.float32, device=DEVICE)
a = torch.as_tensor(a, dtype=torch.int64, device=DEVICE)
r = torch.as_tensor(r, dtype=torch.float32, device=DEVICE)
done = torch.as_tensor(done, dtype=torch.float32, device=DEVICE)
s_next = torch.as_tensor(s_next, dtype=torch.float32, device=DEVICE)
mu = torch.as_tensor(mu, dtype=torch.float32, device=DEVICE) # behavior prob
pi, v = model(s)
pi_next, v_next = model(s_next)
dist = Categorical(pi)
log_pi_a = dist.log_prob(a)
pi_a = torch.exp(log_pi_a) # π(a|s)
# 중요도 비율 w = π(a|s) / μ(a|s)
w = (pi_a / (mu + 1e-8)).detach()
w_clipped = torch.clamp(w, max=IS_CLIP)
# 1-step TD target
td_target = r + GAMMA * v_next * (1.0 - done)
advantage = td_target.detach() - v
policy_loss = -(w_clipped * log_pi_a * advantage.detach()).mean()
value_loss = F.mse_loss(v, td_target.detach())
entropy = dist.entropy().mean()
loss = policy_loss + 0.5 * value_loss - ENTROPY_COEF * entropy
optimizer.zero_grad()
loss.backward()
optimizer.step()
6.1 왜 Importance Sampling이 필요한가?
Replay Buffer에 있는 데이터는 예전 정책 μ(a|s) 로 모인 데이터입니다.
현재 정책은 \(\pi(a|s)\)이므로, 정책이 바뀌었을 가능성이 큽니다.
- 그대로 써버리면 “현재 정책”이 아니라 “예전 정책”의 그래디언트를 따라가게 됩니다.
- 이걸 바로잡기 위해 중요도 비율을 곱해 줍니다.
\[
w = \frac{\pi(a|s)}{\mu(a|s)}
\]
이렇게 하면,
- μ(a|s)가 작았지만 π(a|s)는 큰 행동 → w가 커져서 더 크게 반영
- μ(a|s)가 컸지만 π(a|s)는 작은 행동 → w가 작아져서 덜 반영
즉, “지금 정책이 높은 확률을 주는 행동일수록 더 신뢰하고 업데이트”하게 됩니다.
6.2 왜 w를 클리핑하는가?
- 이론적으로는 w를 그대로 쓰는 것이 unbiased하지만,
- 실제 학습에서는 w가 너무 커져서 분산이 폭발할 수 있습니다.
- 그래서 코드에서는:
w_clipped = torch.clamp(w, max=IS_CLIP)
처럼 상한을 둬서, “너무 과한 중요도 비율은 적당히 잘라내고 쓰자” 라는 타협을 합니다.
7. 전체 학습 루프 요약
마지막으로 전체 학습 루프는 다음과 같습니다.
for episode in range(1, MAX_EPISODES + 1):
s, _ = env.reset()
done = False
ep_reward = 0.0
s_traj, a_traj, r_traj, done_traj = [], [], [], []
while not done:
a, pi_probs, _ = model.act(s)
s_next, r, terminated, truncated, _ = env.step(a)
done = terminated or truncated
ep_reward += r
s_traj.append(s)
a_traj.append(a)
r_traj.append(r / 100.0)
done_traj.append(float(done))
behavior_prob = pi_probs[a] # μ(a|s)
replay_buffer.push(s, a, r / 100.0, float(done), s_next, behavior_prob)
s = s_next
# 1) on-policy 업데이트 (에피소드마다 1회)
acer_on_policy_update(model, optimizer, s_traj, a_traj, r_traj, done_traj)
# 2) off-policy 업데이트 (Replay에서 여러 번)
if len(replay_buffer) >= REPLAY_START_SIZE:
for _ in range(4):
acer_off_policy_update(model, optimizer, replay_buffer, BATCH_SIZE)
요약하면:
“방금 수집한 최신 궤적”으로 on-policy 업데이트를 하고,
Replay Buffer에서 꺼낸 “과거 궤적”으로 off-policy 업데이트를 여러 번 더 한다.
이로 인해:
- A2C/A3C보다 경험 재사용을 훨씬 많이 하게 되고,
- 샘플 효율이 좋아지는 방향으로 Actor-Critic을 확장할 수 있습니다.
8. ACER vs A2C / A3C – 정리
- A2C / A3C
- Actor-Critic + Advantage
- on-policy (Replay Buffer를 쓰지 않거나, 제한적으로만 사용)
- A2C: 동기식, A3C: 비동기식
- ACER (Actor-Critic with Experience Replay)
- Actor-Critic + Advantage
- Experience Replay를 적극적으로 사용 (off-policy)
- Off-policy로 인한 bias/분산 문제를
Importance Sampling + 클리핑 + 추가 안정화 기법으로 보정 - 즉, “Replay Buffer를 써도 망가지지 않는 Actor-Critic”을 목표로 한 알고리즘
[ 오늘의 마무리 ]
이번 CartPole 예제에서는
- 논문 ACER 전체를 그대로 구현하기보다는,
- Actor-Critic + Replay Buffer + Importance Sampling 이라는 핵심 아이디어를 살려
- 학습용으로 단순화된 ACER 스타일 구현을 만들어 보았습니다.
'개념 정리실 > 강화학습' 카테고리의 다른 글
| AI Agent – 개념부터 워크플로우까지 (1) | 2026.03.19 |
|---|---|
| PPO (Proximal Policy Optimization) – 안정적인 정책 업데이트로 CartPole 풀어보기 (0) | 2026.03.18 |
| A3C – 여러 에이전트가 함께 학습하는 Actor-Critic (0) | 2026.03.16 |
| Actor-Critic로 CartPole 학습하기 – REINFORCE에서 한 걸음 더 (1) | 2026.03.16 |
| Policy Gradient & REINFORCE – 정책 기반 에이전트 (1) | 2026.03.12 |