지형 인식 없이 험지를 걷는 로봇
2026-03-26
| 회차 | 주제 |
|---|---|
| Lec 02 | 4족 보행 로봇 & IsaacGym 시뮬레이터 |
| Lec 03 | PPO 알고리즘 — 보행 정책 학습 |
| Lec 04 (오늘) | VAE & DreamWaQ — 지형 인식 없이 걷기 |
| Lec 05 | Gazebo Sim2Sim — 배포 전 검증 |
오늘 목표: CENet(VAE)으로 지형 정보를 암묵적으로 추정하고, cenet.py + on_policy_runner.py 코드 분석
Lec 03에서 학습한 PPO 정책의 문제점:
평지에서는 잘 걸음 (base task)
험지에서는?
핵심 질문: 외부 센서(카메라, LiDAR) 없이 로봇이 지형을 “느끼며” 걸을 수 있을까?
| 방법 | 태스크명 | Actor 입력 | 센서 | 학습 단계 |
|---|---|---|---|---|
| Baseline | go2_base |
45 | 없음 | 1단계 |
| Oracle (상한선) | go2_oracle |
238 | 특권 정보 직접 사용 | 1단계 |
| EstNet | go2_est |
48 | 없음 (속도 추정) | 1단계 |
| DreamWaQ | go2_waq |
64 | 없음 (VAE 추정) | 1단계 |
DreamWaQ는 proprioceptive 관측 히스토리로 context vector를 생성하여 Actor에게 암묵적 지형 정보를 제공합니다.
“DreamWaQ: Learning Robust Locomotion Over Unknown Rough Terrain”
Tip
공식 오픈소스 코드가 없어, 논문을 기반으로 직접 구현한 코드를 사용합니다. → go2_dreamwaq
구조
입력 x ──▶ Encoder ──▶ z ──▶ Decoder ──▶ x̂
(압축) (잠재) (복원)
한계
Autoencoder의 잠재 공간에 확률 분포를 부여:
\[ z \sim q_\phi(z|x) = \mathcal{N}(\mu_\phi(x), \sigma^2_\phi(x)) \]
입력 x ──▶ Encoder ──▶ μ, log_var ──▶ z ~ N(μ,σ²) ──▶ Decoder ──▶ x̂
핵심 차이: 잠재 변수 \(z\)가 확률 변수 → 부드럽고 연속적인 잠재 공간
\[ \mathcal{L}_{VAE} = \underbrace{\|x - \hat{x}\|^2}_{\text{복원 손실}} + \underbrace{\beta \cdot D_{KL}\left(q_\phi(z|x) \;\|\; p(z)\right)}_{\text{KL 발산}} \]
복원 손실
KL 발산
문제: \(z \sim \mathcal{N}(\mu, \sigma^2)\)에서 샘플링은 미분 불가능
관측 히스토리 (5 × 45 = 225)
│
▼
┌─────────────┐
│ CENet │
│ Encoder │──▶ est_vel (3) ──────────┐
│ 225→128→64 │ │
│ →35 │──▶ μ (16), logvar (16) │
└─────────────┘ │ │
▼ │
z = reparam(μ, σ) (16) │
│ │
▼ ▼
┌─────────────┐ latent = cat(est_vel, z) = 19
│ CENet │ │
│ Decoder │◀────────┘
│ 19→64→128 │
│ →48→45 │──▶ est_next_obs (45)
└─────────────┘
actor_obs = cat(obs, est_vel, context_vec) = 45 + 3 + 16 = 64
# rsl_rl/vae/cenet.py
class CENet(nn.Module):
"""Context-aided Estimator Network"""
def __init__(self, input_dim=225, hidden_dim1=128, hidden_dim2=64,
hidden_dim3=48, latent_dim1=35, latent_dim2=19,
output_dim=45):
super().__init__()
# Encoder: obs_history(225) → est_vel(3) + mu(16) + logvar(16) = 35
self.encoder = nn.Sequential(
nn.Linear(input_dim, hidden_dim1), # 225 → 128
nn.ELU(),
nn.Linear(hidden_dim1, hidden_dim2), # 128 → 64
nn.ELU(),
nn.Linear(hidden_dim2, latent_dim1), # 64 → 35
)
# Decoder: latent(19) → est_next_obs(45)
self.decoder = nn.Sequential(
nn.Linear(latent_dim2, hidden_dim2), # 19 → 64
nn.ELU(),
nn.Linear(hidden_dim2, hidden_dim1), # 64 → 128
nn.ELU(),
nn.Linear(hidden_dim1, hidden_dim3), # 128 → 48
nn.ELU(),
nn.Linear(hidden_dim3, output_dim), # 48 → 45
)
# 차원 분할
self.est_vel_dim = 3
self.mu_dim = 16 # context vector 차원
self.logvar_dim = 16
def encode(self, obs_history_flat):
"""obs_history [N, 225] → est_vel [N, 3], mu [N, 16], logvar [N, 16]"""
h = self.encoder(obs_history_flat) # [N, 35]
est_vel = h[:, :3]
mu = h[:, 3:19]
logvar = h[:, 19:35]
return est_vel, mu, logvar
def reparameterize(self, mu, logvar):
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + std * eps # context_vec z [N, 16] def forward(self, obs_history_flat):
"""전체 순전파"""
est_vel, mu, logvar = self.encode(obs_history_flat)
z = self.reparameterize(mu, logvar) # context_vec [N, 16]
latent = torch.cat([est_vel, z], dim=-1) # [N, 19]
est_next_obs = self.decoder(latent) # [N, 45]
return est_next_obs, est_vel, mu, logvar, z
def before_action(self, obs_history_flat, true_vel):
"""롤아웃 스텝 전 — context vector 계산"""
est_next_obs, est_vel, mu, logvar, context_vec = self.forward(
obs_history_flat)
# 저장 (loss 계산용)
self.stored_true_vel = true_vel
self.stored_est_vel = est_vel
self.stored_mu = mu
self.stored_logvar = logvar
return est_next_obs, est_vel, mu, logvar, context_vec
def after_action(self, next_obs):
"""롤아웃 스텝 후 — 다음 관측 저장 (복원 손실용)"""
self.stored_next_obs = next_obs\[ \mathcal{L}_{\text{CENet}} = \underbrace{\text{MSE}(\hat{v}, v_{true})}_{\text{속도 추정 손실}} + \underbrace{\text{MSE}(\hat{o}_{t+1}, o_{t+1})}_{\text{다음 관측 복원 손실}} + \underbrace{\beta \cdot D_{KL}(q \| p)}_{\text{KL 발산}} \]
def compute_loss(self):
"""CENet 손실 계산"""
# 속도 추정 손실
vel_loss = F.mse_loss(self.stored_est_vel, self.stored_true_vel)
# 다음 관측 복원 손실
recon_loss = F.mse_loss(self.stored_est_next_obs, self.stored_next_obs)
# KL 발산
kl_loss = -0.5 * torch.sum(
1 + self.stored_logvar
- self.stored_mu.pow(2)
- self.stored_logvar.exp(), dim=-1
).mean()
total_loss = vel_loss + recon_loss + self.beta * kl_loss
return total_loss, vel_loss, recon_loss, kl_loss| 파라미터 | 값 | 역할 |
|---|---|---|
latent_dim (z) |
16 | context vector 차원 |
obs_history_len |
5 | 관측 히스토리 길이 |
beta |
1.0 → 4.0 (annealing) | KL 가중치 |
lr |
0.01 → 0.0015 (ReduceLROnPlateau) | 학습률 |
CENet의 속도 추정이 초기에 부정확하므로, true velocity와 est_vel을 확률적으로 전환:
# on_policy_runner.py — OnPolicyRunnerWAQ
def compute_boot_prob(self):
"""보상의 안정성 기반으로 전환 확률 계산"""
std_reward = torch.std(self.reward_buffer)
mean_reward = torch.mean(self.reward_buffer)
boot_prob = 1 - torch.tanh(std_reward / (torch.abs(mean_reward) + 1e-8))
return boot_prob
# 롤아웃 시
if boot_prob > torch.rand(1):
vel_input = est_vel # CENet 추정값 사용
else:
vel_input = true_vel # 실제 값 사용 (시뮬에서 접근 가능)| 학습 초기 | 학습 후반 |
|---|---|
보상 불안정 (std 큼) → boot_prob 낮음 |
보상 안정 → boot_prob 높음 |
| true_vel 주로 사용 | est_vel 주로 사용 |
| 안정적 정책 학습 우선 | CENet 추정에 점차 의존 |
# rsl_rl/runners/on_policy_runner.py
class OnPolicyRunnerWAQ(OnPolicyRunner):
"""DreamWaQ 전용 Runner — CENet + PPO 통합 학습"""
def learn(self, num_learning_iterations):
obs = self.env.get_observations() # [4096, 45]
privileged_obs = self.env.get_privileged_observations() # [4096, 190]
true_vel = ... # 시뮬레이터에서 접근
# 정규화 모듈 초기화
obs_rms = RunningMeanStd(shape=(45,))
privileged_obs_rms = RunningMeanStd(shape=(190,))
true_vel_rms = RunningMeanStd(shape=(3,))
for it in range(num_learning_iterations):
# === 롤아웃 수집 (24 스텝) ===
with torch.inference_mode():
for step in range(self.num_steps_per_env):
# 1. 관측 히스토리 수집 & flatten
obs_history = self.env.get_observation_history() # [N, 5, 45]
obs_history_flat = obs_history.flatten(1) # [N, 225]
# 정규화
obs_norm = obs_rms.normalize(obs)
obs_history_norm = ... # 히스토리도 정규화
true_vel_norm = true_vel_rms.normalize(true_vel)
# 2. CENet — context vector 생성
est_next_obs, est_vel, mu, logvar, context_vec = \
self.cenet.before_action(obs_history_norm, true_vel_norm)
# 3. AdaBoot — 속도 입력 선택
boot_prob = self.compute_boot_prob()
vel_input = est_vel if boot_prob > torch.rand(1) else true_vel_norm
# 4. Actor/Critic 관측 구성
actor_obs = torch.cat([
obs_norm, vel_input, context_vec
], dim=-1) # [N, 64]
critic_obs = torch.cat([
obs_norm, vel_input, privileged_obs_rms.normalize(privileged_obs)
], dim=-1) # [N, 238]
# 5. 행동 결정 & 환경 스텝
actions = self.alg.act(actor_obs, critic_obs)
obs, privileged_obs, rewards, dones, infos = self.env.step(actions)
# 6. CENet — 다음 관측 저장
self.cenet.after_action(obs_rms.normalize(obs))
# === PPO 업데이트 ===
self.alg.compute_returns(critic_obs)
self.alg.update()
# === CENet 업데이트 ===
cenet_loss, vel_loss, recon_loss, kl_loss = self.cenet.compute_loss()
self.cenet_optimizer.zero_grad()
cenet_loss.backward()
self.cenet_optimizer.step()
self.cenet_scheduler.step(cenet_loss)
# KL annealing
self.cenet.beta = min(self.cenet.beta * 1.01, self.beta_limit)┌─────────────────────────────────────────────────┐
│ Actor 입력 (64차원) │
│ │
│ obs (45) est_vel (3) context_vec (16) │
│ ┌───────────┐ ┌───────┐ ┌──────────────┐ │
│ │ ang_vel │ │ v_x │ │ VAE latent │ │
│ │ gravity │ │ v_y │ │ (지형 정보 │ │
│ │ commands │ │ v_z │ │ 인코딩) │ │
│ │ dof_pos │ └───────┘ └──────────────┘ │
│ │ dof_vel │ │
│ │ actions │ │
│ └───────────┘ │
└──────────────────┬──────────────────────────────┘
│
▼
Actor MLP [512, 256, 128] → 12 actions
┌──────────────────────────────────────────────────────┐
│ Critic 입력 (238차원) │
│ │
│ obs (45) est_vel (3) privileged_obs (190) │
│ ┌──────────┐ ┌───────┐ ┌────────────────────┐ │
│ │ (동일) │ │(동일) │ │ disturb_force (3) │ │
│ └──────────┘ └───────┘ │ heights (187) │ │
│ └────────────────────┘ │
└──────────────────┬────────────────────────────────────┘
│
▼
Critic MLP [512, 256, 128] → 1 value
Critic은 특권 정보를 직접 사용하여 더 정확한 가치 추정 → 학습 안정화
학습 후 실제 배포 시에는 특권 정보 없이 동작:
# 추론 시 — CENet에서 mu만 사용 (reparameterize 안 함)
cenet.eval()
actor.eval()
with torch.no_grad():
obs_history_flat = obs_history.flatten(1) # [1, 225]
obs_history_norm = obs_rms.normalize(obs_history_flat)
h = cenet.encoder(obs_history_norm) # [1, 35]
est_vel = h[:, :3] # [1, 3]
mu = h[:, 3:19] # [1, 16]
# logvar 사용 안 함 — mu를 context_vec으로 직접 사용
obs_norm = obs_rms.normalize(obs)
actor_obs = torch.cat([obs_norm, est_vel, mu], dim=-1) # [1, 64]
action = actor.act_inference(actor_obs) # [1, 12]학습 시:
z = reparameterize(mu, logvar)(샘플링)추론 시:
z = mu(결정적, 노이즈 없음)
# rsl_rl/vae/estnet.py — VAE 없는 단순 추정기
class EstNet(nn.Module):
def __init__(self, input_dim=225):
super().__init__()
self.estimator = nn.Sequential(
nn.Linear(225, 128), nn.ELU(),
nn.Linear(128, 64), nn.ELU(),
nn.Linear(64, 3), # est_vel만 출력
)
def forward(self, obs_history_flat):
return self.estimator(obs_history_flat) # [N, 3]| CENet (DreamWaQ) | EstNet | |
|---|---|---|
| 출력 | est_vel(3) + context_vec(16) = 19 | est_vel(3) |
| Actor 입력 | 45 + 3 + 16 = 64 | 45 + 3 = 48 |
| 지형 정보 | context_vec에 인코딩 | 없음 |
| 손실 함수 | vel + recon + KL | vel만 |
| 험지 성능 | 높음 | 중간 |
| 태스크 | 평지 | 험지 | Actor 입력 | 특권 정보 |
|---|---|---|---|---|
base |
좋음 | 나쁨 | 45 | 없음 |
oracle |
좋음 | 최고 | 238 | 직접 사용 (비현실적) |
est |
좋음 | 중간 | 48 | 없음 |
waq |
좋음 | 좋음 | 64 | 없음 (CENet 추정) |
waq는oracle에 근접한 험지 성능을 외부 센서 없이 달성
다룰 내용
load_dreamwaq_policy.py 코드 분석왜 Sim2Sim이 필요한가?
IsaacGym(PhysX)에서 학습한 정책이 다른 물리 엔진(ODE)에서도 작동하면 실제 로봇 배포 가능성이 높다
quadruped_sim2sim 코드 사용
질문 있으신가요?
Lecture 04 — VAE & DreamWaQ | Curieux.JY