4족 보행 로봇의 보행 정책 학습
2026-03-26
| 회차 | 주제 |
|---|---|
| Lec 02 | 4족 보행 로봇 & IsaacGym 시뮬레이터 |
| Lec 03 (오늘) | PPO 알고리즘 — 보행 정책 학습 |
| Lec 04 | VAE & DreamWaQ — 지형 인식 없이 걷기 |
| Lec 05 | Gazebo Sim2Sim — 배포 전 검증 |
오늘 목표: RL 기초부터 PPO까지 이해하고, rsl_rl/algorithms/ppo.py의 구현을 분석
핵심 구조
환경 (Environment)
│
│ 관측 (obs), 보상 (reward)
▼
에이전트 (Agent)
│
│ 행동 (action)
▼
환경 (Environment)
...반복
4족 로봇에 대입하면
강화학습의 수학적 프레임워크:
\[ \text{MDP} = (S, A, P, R, \gamma) \]
| 기호 | 의미 | 로봇 예시 |
|---|---|---|
| \(S\) | 상태 공간 | 관절 각도, 속도, IMU… |
| \(A\) | 행동 공간 | 12개 관절 목표 위치 |
| \(P(s'\|s,a)\) | 전이 확률 | 물리 시뮬레이션 결과 |
| \(R(s,a)\) | 보상 함수 | 속도 추종 + 페널티 |
| \(\gamma\) | 할인율 | 0.99 (go2_dreamwaq) |
정책 \(\pi(a|s)\): 상태 \(s\)에서 행동 \(a\)를 선택하는 확률 분포
로봇 보행에서는 연속 행동 공간 → 가우시안 정책 사용:
\[ \pi_\theta(a|s) = \mathcal{N}(\mu_\theta(s), \sigma^2) \]
init_noise_std = 1.0)목표: 누적 보상을 최대화하는 \(\theta\)를 찾는 것
\[ J(\theta) = \mathbb{E}_{\pi_\theta} \left[ \sum_{t=0}^{T} \gamma^t R(s_t, a_t) \right] \]
상태 가치 함수 \(V^\pi(s)\): 상태 \(s\)에서 정책 \(\pi\)를 따랐을 때 기대 누적 보상
\[ V^\pi(s) = \mathbb{E}_{\pi} \left[ \sum_{t=0}^{T} \gamma^t R(s_t, a_t) \,\middle|\, s_0 = s \right] \]
어드밴티지 함수 \(A^\pi(s, a)\): 평균 대비 이 행동이 얼마나 좋은가?
\[ A^\pi(s, a) = Q^\pi(s, a) - V^\pi(s) \]
정책의 파라미터 \(\theta\)를 직접 업데이트:
\[ \nabla_\theta J(\theta) = \mathbb{E}_{\pi_\theta} \left[ \nabla_\theta \log \pi_\theta(a|s) \cdot A^\pi(s, a) \right] \]
직관적 해석:
문제점: 업데이트가 너무 크면 정책이 갑자기 망가짐 (학습 불안정)
Proximal Policy Optimization — 업데이트 크기를 제한하여 안정적 학습
확률 비율 (probability ratio):
\[ r_t(\theta) = \frac{\pi_\theta(a_t | s_t)}{\pi_{\theta_{\text{old}}}(a_t | s_t)} \]
\[ L^{CLIP}(\theta) = \mathbb{E}_t \left[ \min \left( r_t(\theta) A_t, \;\; \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) A_t \right) \right] \]
\(A_t > 0\) (좋은 행동)
\(r\)이 \(1+\epsilon\)을 넘으면 클리핑 → 확률을 너무 많이 올리지 않음
\(A_t < 0\) (나쁜 행동)
\(r\)이 \(1-\epsilon\) 아래로 가면 클리핑 → 확률을 너무 많이 내리지 않음
clip_param = 0.2— 한 번의 업데이트에서 정책 변화를 ±20%로 제한
Advantage를 어떻게 추정할 것인가?
TD 잔차 (1-step):
\[ \delta_t = r_t + \gamma V(s_{t+1}) - V(s_t) \]
GAE — \(\lambda\)로 다중 스텝 잔차를 지수 가중 평균:
\[ \hat{A}_t^{GAE} = \sum_{l=0}^{T-t} (\gamma \lambda)^l \delta_{t+l} \]
| \(\lambda\) 값 | 특성 |
|---|---|
| \(\lambda = 0\) | 1-step TD — 낮은 분산, 높은 편향 |
| \(\lambda = 1\) | Monte Carlo — 높은 분산, 낮은 편향 |
| \(\lambda = 0.95\) | go2_dreamwaq에서 사용 |
\[ L(\theta) = L^{CLIP}(\theta) + c_1 L^{VF}(\theta) + c_2 S[\pi_\theta] \]
| 항 | 의미 | go2_dreamwaq 값 |
|---|---|---|
| \(L^{CLIP}\) | 정책 손실 (클리핑) | clip_param = 0.2 |
| \(L^{VF}\) | 가치 함수 손실 (MSE) | value_loss_coef = 1.0 |
| \(S[\pi_\theta]\) | 엔트로피 보너스 | entropy_coef = 0.01 |
# rsl_rl/modules/actor_critic.py
class ActorCritic(nn.Module):
def __init__(self, num_actor_obs, num_critic_obs, num_actions,
actor_hidden_dims=[512, 256, 128],
critic_hidden_dims=[512, 256, 128],
activation='elu',
init_noise_std=1.0):
super().__init__()
# Actor — 관측 → 행동 평균
# WAQ: 입력 64차원 (obs 45 + est_vel 3 + context_vec 16)
actor_layers = []
in_dim = num_actor_obs # 64 (waq) / 45 (base) / 238 (oracle)
for h in actor_hidden_dims: # [512, 256, 128]
actor_layers.append(nn.Linear(in_dim, h))
actor_layers.append(get_activation(activation)) # ELU
in_dim = h
actor_layers.append(nn.Linear(in_dim, num_actions)) # → 12
self.actor = nn.Sequential(*actor_layers)
# Critic — 관측 → 상태 가치
# WAQ: 입력 238차원 (obs 45 + est_vel 3 + privileged 190)
critic_layers = []
in_dim = num_critic_obs # 238 (waq) / 45 (base)
for h in critic_hidden_dims: # [512, 256, 128]
critic_layers.append(nn.Linear(in_dim, h))
critic_layers.append(get_activation(activation))
in_dim = h
critic_layers.append(nn.Linear(in_dim, 1))
self.critic = nn.Sequential(*critic_layers)
# 행동 표준편차 (학습 가능)
self.std = nn.Parameter(init_noise_std * torch.ones(num_actions)) def act(self, observations):
"""행동 샘플링 (롤아웃 수집 시)"""
action_mean = self.actor(observations)
distribution = Normal(action_mean, self.std)
actions = distribution.sample()
actions_log_prob = distribution.log_prob(actions).sum(dim=-1)
return actions.detach(), actions_log_prob.detach()
def act_inference(self, observations):
"""추론 모드 — 평균만 사용 (노이즈 X)"""
return self.actor(observations)
def evaluate(self, critic_observations, actions):
"""PPO 업데이트 시 — log_prob, value, entropy 재계산"""
action_mean = self.actor(self.actor_obs) # 저장된 actor obs 사용
distribution = Normal(action_mean, self.std)
actions_log_prob = distribution.log_prob(actions).sum(dim=-1)
entropy = distribution.entropy().sum(dim=-1)
value = self.critic(critic_observations).squeeze(-1)
return actions_log_prob, value, entropy# rsl_rl/storage/rollout_storage.py
class RolloutStorage:
def __init__(self, num_envs, num_transitions_per_env, ...):
# num_transitions_per_env = 24 (num_steps_per_env)
self.observations = torch.zeros(num_transitions_per_env, num_envs, ...)
self.actions = torch.zeros(num_transitions_per_env, num_envs, num_actions)
self.rewards = torch.zeros(num_transitions_per_env, num_envs, 1)
self.dones = torch.zeros(num_transitions_per_env, num_envs, 1)
self.values = torch.zeros(num_transitions_per_env, num_envs, 1)
def compute_returns(self, last_values, gamma=0.99, lam=0.95):
"""GAE로 advantage 계산"""
advantage = 0
for step in reversed(range(self.num_transitions_per_env)):
if step == self.num_transitions_per_env - 1:
next_values = last_values
else:
next_values = self.values[step + 1]
next_is_not_terminal = 1.0 - self.dones[step].float()
delta = (self.rewards[step]
+ gamma * next_values * next_is_not_terminal
- self.values[step])
advantage = delta + gamma * lam * next_is_not_terminal * advantage
self.returns[step] = advantage + self.values[step]
self.advantages = self.returns - self.values
# 정규화
self.advantages = (self.advantages - self.advantages.mean()) \
/ (self.advantages.std() + 1e-8)# rsl_rl/algorithms/ppo.py
class PPO:
def __init__(self, actor_critic,
clip_param=0.2,
num_learning_epochs=5,
num_mini_batches=4,
value_loss_coef=1.0,
entropy_coef=0.01,
learning_rate=1e-3,
max_grad_norm=1.0,
use_clipped_value_loss=True,
schedule='adaptive',
desired_kl=0.01):
self.optimizer = torch.optim.Adam(
actor_critic.parameters(), lr=learning_rate)
def update(self, storage):
mean_value_loss, mean_surr_loss = 0, 0
# 5 에폭 반복
for epoch in range(self.num_learning_epochs):
# 4개 미니배치로 분할
generator = storage.mini_batch_generator(self.num_mini_batches)
for (obs_batch, critic_obs_batch, actions_batch,
target_values_batch, advantages_batch,
returns_batch, old_actions_log_prob_batch,
...) in generator:
# 현재 정책으로 재평가
actions_log_prob, value, entropy = \
self.actor_critic.evaluate(critic_obs_batch, actions_batch)
# 확률 비율
ratio = torch.exp(actions_log_prob - old_actions_log_prob_batch)
# PPO-Clip 손실
surrogate = -torch.squeeze(advantages_batch) * ratio
surrogate_clipped = -torch.squeeze(advantages_batch) * \
torch.clamp(ratio, 1.0 - self.clip_param,
1.0 + self.clip_param)
surrogate_loss = torch.max(surrogate, surrogate_clipped).mean()
# 가치 함수 손실 (clipped)
value_clipped = target_values_batch + \
(value - target_values_batch).clamp(
-self.clip_param, self.clip_param)
value_loss = torch.max(
(value - returns_batch).pow(2),
(value_clipped - returns_batch).pow(2)
).mean()
# 전체 손실
loss = (surrogate_loss
+ self.value_loss_coef * value_loss
- self.entropy_coef * entropy.mean())
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(
self.actor_critic.parameters(), self.max_grad_norm)
self.optimizer.step()go2_dreamwaq은 KL 기반 학습률 조절을 사용합니다:
# ppo.py — schedule='adaptive'
if self.desired_kl is not None and self.schedule == 'adaptive':
with torch.inference_mode():
kl = torch.sum(
torch.log(sigma_ratio + 1e-5)
+ (torch.square(old_sigma_batch)
+ torch.square(mu_batch - old_mu_batch))
/ (2.0 * torch.square(sigma_batch) + 1e-5)
- 0.5, axis=-1)
kl_mean = torch.mean(kl)
if kl_mean > self.desired_kl * 2.0:
self.learning_rate = max(1e-5, self.learning_rate / 1.5)
elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0:
self.learning_rate = min(1e-2, self.learning_rate * 1.5)
for param_group in self.optimizer.param_groups:
param_group['lr'] = self.learning_rate| KL 상태 | 조치 | 이유 |
|---|---|---|
kl > desired_kl × 2 |
lr /= 1.5 |
정책 변화가 너무 큼 → 학습률 줄이기 |
kl < desired_kl / 2 |
lr *= 1.5 |
정책 변화가 너무 작음 → 학습률 올리기 |
| 그 외 | 유지 | 적절한 수준 |
| 파라미터 | 값 | 설명 |
|---|---|---|
num_envs |
4096 | 병렬 환경 수 |
num_steps_per_env |
24 | 롤아웃 길이 (스텝) |
learning_rate |
1e-3 | Adam 초기 학습률 (adaptive) |
schedule |
‘adaptive’ | KL 기반 학습률 조절 |
desired_kl |
0.01 | 목표 KL 발산 |
num_learning_epochs |
5 | 미니배치 반복 횟수 |
num_mini_batches |
4 | 미니배치 분할 수 |
gamma |
0.99 | 할인율 |
lam |
0.95 | GAE 파라미터 |
clip_param |
0.2 | PPO 클리핑 범위 |
value_loss_coef |
1.0 | 가치 손실 계수 |
entropy_coef |
0.01 | 엔트로피 보너스 계수 |
max_grad_norm |
1.0 | gradient 클리핑 |
배치 크기: 4096 × 24 = 98,304 경험/업데이트
# rsl_rl/runners/on_policy_runner.py
class OnPolicyRunner:
def learn(self, num_learning_iterations):
obs = self.env.get_observations() # [4096, 45]
critic_obs = self.env.get_privileged_observations() # [4096, 190]
for it in range(num_learning_iterations):
# === 롤아웃 수집 (24 스텝) ===
with torch.inference_mode():
for i in range(self.num_steps_per_env):
actions = self.alg.act(obs, critic_obs)
obs, privileged_obs, rewards, dones, infos = \
self.env.step(actions)
# Timeout 처리 (에피소드 길이 초과 시 value bootstrapping)
if 'time_outs' in infos:
rewards += self.gamma * torch.squeeze(
self.alg.transition.values
) * infos['time_outs'].unsqueeze(1)
self.alg.process_env_step(rewards, dones, infos)
# === PPO 업데이트 ===
last_values = self.alg.actor_critic.evaluate(critic_obs)
self.alg.compute_returns(last_values)
mean_value_loss, mean_surr_loss = self.alg.update()
# 로깅 (wandb)
self.log(it)# rsl_rl/utils/rms.py
class RunningMeanStd(nn.Module):
"""관측값 정규화 — 온라인 평균/분산 추적"""
def __init__(self, shape):
super().__init__()
self.register_buffer("mean", torch.zeros(shape))
self.register_buffer("var", torch.ones(shape))
self.register_buffer("count", torch.tensor(1e-4))
def update(self, x):
batch_mean = x.mean(dim=0)
batch_var = x.var(dim=0)
batch_count = x.shape[0]
# Welford's online algorithm
delta = batch_mean - self.mean
tot_count = self.count + batch_count
self.mean += delta * batch_count / tot_count
self.var = (self.var * self.count
+ batch_var * batch_count
+ delta**2 * self.count * batch_count / tot_count) / tot_count
self.count = tot_count
def normalize(self, x):
return (x - self.mean) / (self.var.sqrt() + 1e-8)체크포인트에 저장되어 추론 시에도 동일한 정규화 적용
# train.py — wandb 연동
import wandb
wandb.init(project="go2_dreamwaq", name=f"{task_name}_{run_name}")
# OnPolicyRunner에서 매 iteration 기록
wandb.log({
"reward/total": mean_reward,
"reward/tracking_lin_vel": ...,
"loss/surrogate": mean_surr_loss,
"loss/value": mean_value_loss,
"policy/std": actor_critic.std.mean().item(),
"lr": ppo.learning_rate,
}, step=iteration)정상적인 학습
reward/total: 꾸준히 상승 후 수렴policy/std: 점진적으로 줄어듦lr: adaptive로 자동 조절loss/value: 점차 감소문제가 있는 경우
다룰 내용
핵심 질문
외부 센서(카메라, LiDAR) 없이 로봇이 지형을 “느끼며” 걸을 수 있을까?
CENet이 관측 히스토리 5스텝으로 암묵적 지형 추정을 수행합니다.
질문 있으신가요?
Lecture 03 — PPO 알고리즘 | Curieux.JY