VAE & DreamWaQ

지형 인식 없이 험지를 걷는 로봇

JungYeon Lee

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 코드 분석

Part 1. 문제 정의

지금까지의 한계

Lec 03에서 학습한 PPO 정책의 문제점:

평지에서는 잘 걸음 (base task)

  • 관측값 45차원으로 충분
  • 보상 추종도 잘 됨

험지에서는?

  • 계단, 경사, 돌밭에서 넘어짐
  • 지면 높이를 모르기 때문
  • 발이 예상과 다른 곳에 착지

핵심 질문: 외부 센서(카메라, 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 논문

“DreamWaQ: Learning Robust Locomotion Over Unknown Rough Terrain”

  • Nahrendra, Yu, Myung (KAIST)
  • ICRA 2023 Quadruped Walking Robot Challenge 우승
  • Proprioceptive 센서만으로 험지 보행
  • VAE를 활용한 implicit terrain estimation

Tip

공식 오픈소스 코드가 없어, 논문을 기반으로 직접 구현한 코드를 사용합니다. → go2_dreamwaq

Part 2. VAE 이론

Autoencoder 복습

구조

입력 x ──▶ Encoder ──▶ z ──▶ Decoder ──▶ x̂
              (압축)    (잠재)    (복원)
  • \(z\): 저차원 잠재 벡터
  • 목표: \(\hat{x} \approx x\) (복원 오차 최소화)

한계

  • \(z\) 공간이 비구조적
  • 새로운 데이터 생성 불가
  • 잠재 공간에서 보간 불안정

VAE (Variational Autoencoder)

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\)확률 변수 → 부드럽고 연속적인 잠재 공간

VAE 손실 함수

\[ \mathcal{L}_{VAE} = \underbrace{\|x - \hat{x}\|^2}_{\text{복원 손실}} + \underbrace{\beta \cdot D_{KL}\left(q_\phi(z|x) \;\|\; p(z)\right)}_{\text{KL 발산}} \]

복원 손실

  • 입력을 잘 복원하는지
  • CENet에서는 다음 관측 예측

KL 발산

  • \(q_\phi(z|x)\)\(p(z) = \mathcal{N}(0, I)\)에 가깝도록
  • \(\beta\): KL 가중치 (annealing 적용)

Reparameterization Trick

문제: \(z \sim \mathcal{N}(\mu, \sigma^2)\)에서 샘플링은 미분 불가능

해결: 샘플링을 결정적 함수로 변환

\[ z = \mu + \sigma \cdot \epsilon, \quad \epsilon \sim \mathcal{N}(0, I) \]

def reparameterize(self, mu, logvar):
    std = torch.exp(0.5 * logvar)
    eps = torch.randn_like(std)
    return mu + std * eps

gradient가 \(\mu\)\(\sigma\)를 통해 역전파 가능

Part 3. CENet 아키텍처

DreamWaQ 전체 구조

관측 히스토리 (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

CENet 구현

# 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]

CENet — forward & decode

    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

CENet 손실 함수

\[ \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

CENet 하이퍼파라미터

# CENet 학습 설정
learning_rate = 0.01
optimizer = Adam
scheduler = ReduceLROnPlateau(mode='min', patience=100, factor=0.8)
min_lr = 0.0015

# KL annealing
beta = 1.0           # 초기값
beta *= 1.01         # 매 업데이트마다 1% 증가
beta_limit = 4.0     # 최대값
파라미터 역할
latent_dim (z) 16 context vector 차원
obs_history_len 5 관측 히스토리 길이
beta 1.0 → 4.0 (annealing) KL 가중치
lr 0.01 → 0.0015 (ReduceLROnPlateau) 학습률

Part 4. AdaBoot & 학습 통합

AdaBoot (Adaptive Bootstrapping)

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 추정에 점차 의존

OnPolicyRunnerWAQ — 학습 루프

# 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 입력 구성 정리

┌─────────────────────────────────────────────────┐
│              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 입력 구성 (학습 시만)

┌──────────────────────────────────────────────────────┐
│              Critic 입력 (238차원)                     │
│                                                       │
│  obs (45)      est_vel (3)    privileged_obs (190)    │
│  ┌──────────┐  ┌───────┐     ┌────────────────────┐  │
│  │ (동일)    │  │(동일)  │     │ disturb_force (3)  │  │
│  └──────────┘  └───────┘     │ heights (187)       │  │
│                               └────────────────────┘  │
└──────────────────┬────────────────────────────────────┘
                   │
                   ▼
          Critic MLP [512, 256, 128] → 1 value

Critic은 특권 정보를 직접 사용하여 더 정확한 가치 추정 → 학습 안정화

Part 5. 추론 & 분석

추론 시 동작 (Deploy Mode)

학습 후 실제 배포 시에는 특권 정보 없이 동작:

# 추론 시 — 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 (결정적, 노이즈 없음)

EstNet과의 비교 (Baseline)

# 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 추정)

waqoracle에 근접한 험지 성능을 외부 센서 없이 달성

Part 6. 정리 & 다음 강의

오늘 배운 것

  1. 문제 정의 — base task의 험지 한계, 특권 정보 없이 걷기
  2. VAE 이론 — 잠재 공간, KL 발산, reparameterization trick
  3. CENet — Encoder(225→35) + Decoder(19→45), est_vel + context_vec
  4. AdaBoot — 학습 초기엔 true_vel, 후반엔 est_vel로 전환
  5. OnPolicyRunnerWAQ — PPO + CENet 동시 학습, KL annealing
  6. 추론 — mu를 context_vec으로 직접 사용, 5스텝 히스토리로 지형 추정

다음 강의 — Gazebo Sim2Sim

다룰 내용

  • Sim2Sim이란? (IsaacGym → Gazebo)
  • 체크포인트 로드 & 정책 배포
  • load_dreamwaq_policy.py 코드 분석
  • Gazebo에서 동작 검증

왜 Sim2Sim이 필요한가?

IsaacGym(PhysX)에서 학습한 정책이 다른 물리 엔진(ODE)에서도 작동하면 실제 로봇 배포 가능성이 높다

quadruped_sim2sim 코드 사용

참고 자료

  • 강의 코드 (DreamWaQ): go2_dreamwaq
  • DreamWaQ 논문: Nahrendra et al., “DreamWaQ: Learning Robust Locomotion Over Unknown Rough Terrain”, ICRA 2023
  • VAE 원 논문: Kingma & Welling, “Auto-Encoding Variational Bayes”, ICLR 2014
  • β-VAE: Higgins et al., “β-VAE: Learning Basic Visual Concepts with a Constrained Variational Framework”, ICLR 2017
  • Teacher-Student 비교: Lee et al., “Learning Quadrupedal Locomotion over Challenging Terrain”, Science Robotics, 2020

Q & A

질문 있으신가요?