VAE & DreamWaQ — 지형 인식 없이 걷기

DreamWaQ on IsaacLab — 3차시 (CENet)

Author

JungYeon Lee

Published

March 19, 2026

강의 로드맵

회차 주제
1차시 4족 보행 로봇 & IsaacLab 환경 구성
2차시 PPO & Actor-Critic — 보행 정책 학습
3차시 (오늘) VAE & DreamWaQ — 지형 인식 없이 걷기 (CENet)
4차시 Gazebo Sim2Sim — 배포 전 검증

DreamWaQ의 핵심입니다. 외부 센서 없이 고유수용성 관측의 히스토리만으로 선속도와 지형 문맥을 추정하는 CENet을, cenet.pydreamwaq_runner.py 코드를 직접 짚으며 봅니다.

Tip오늘의 목표
  1. 생성모델(오토인코더 → VAE)의 아이디어를 직관으로 이해한다.
  2. CENet forward/update를 코드로 한 줄씩 읽는다.
  3. runner가 관측을 45→64로 증강하고 AdaBoot으로 학습하는 루프를 따라간다.

생성모델이 처음이어도 괜찮습니다. 오토인코더 → VAE → CENet 순으로, 비유와 그림으로 차근차근 쌓아 올립니다.


1. 문제 정의

Base 정책은 평지에선 잘 걷지만 험지에서 약합니다 — actor 관측 45차원에 선속도도, 지형도 없기 때문입니다(1차시).

방법 Actor 입력 센서 성능
Base 45 없음 기준선
Oracle 48 특권 정보 직접 상한선
DreamWaQ (Waq) 64 없음 (VAE 추정) ≈ Oracle

핵심 아이디어: 최근 5스텝 관측의 변화 패턴 속에 “지금 얼마나 빠른지, 어떤 지형 위인지”가 이미 담겨 있다. 이를 신경망으로 복원한다 — 암묵적 지형 상상(implicit terrain imagination).

논문: DreamWaQ (Nahrendra et al., ICRA 2023, arXiv:2301.10602).


2. 생성모델 기초 — 오토인코더에서 VAE로

오토인코더: 압축했다 복원하기

오토인코더는 입력을 작은 숫자 묶음으로 압축(encoder) 했다가 다시 복원(decoder) 하는 신경망입니다.

비유 — 사진을 보고 “노을 진 바닷가, 갈매기 두 마리”처럼 몇 단어로 요약(encode)한 뒤, 그 메모만 보고 그림을 다시 그리는(decode) 것과 같습니다. 잘 복원되려면 그 몇 단어(잠재 z) 에 핵심 정보가 압축돼 있어야 합니다.

왜 VAE인가: 점이 아니라 “흐릿한 영역”

평범한 오토인코더는 잠재 z하나의 점입니다. 그런데 점들 사이의 빈 공간은 의미가 없어 새 값을 만들거나 비슷한 상황을 잇기 어렵습니다. VAE(변분 오토인코더)는 z를 점이 아니라 확률분포(흐릿한 영역) 로 봅니다 — 평균 \mu와 퍼짐 \sigma로 표현하고, 그 영역에서 무작위로 하나 뽑아 씁니다.

쉽게 말하면 — “정확히 이 점”이 아니라 “이 근처 어디쯤”이라고 표현하는 것. 덕분에 잠재 공간이 매끄러워지고, 살짝 다른 상황에도 부드럽게 대응합니다.

재매개화 — 무작위인데 어떻게 학습하나

문제가 하나 있습니다. “영역에서 무작위로 뽑기”는 미분이 안 돼 신경망 학습(역전파)이 막힙니다. 재매개화 트릭은 무작위성을 밖으로 빼내 해결합니다: 표준정규에서 노이즈 \epsilon을 따로 뽑은 뒤, 거기에 평균을 더하고 퍼짐을 곱합니다.

z = \mu + \sigma \odot \epsilon, \qquad \epsilon \sim \mathcal{N}(0, I)

이제 무작위는 \epsilon이 전담하고, \mu\sigma로는 그래디언트가 흐를 수 있습니다. (코드에서는 \sigma를 항상 양수로 만들려고 logvar로 다룹니다 — §3.) 기호 풀이: \mathcal{N}(0,I)는 평균 0, 퍼짐 1인 표준정규, \odot는 원소별 곱.

KL 발산 — 잠재 공간을 정리하는 힘

VAE 손실은 두 항입니다.

\mathcal{L}_{VAE} = \underbrace{\lVert x - \hat x\rVert^2}_{\text{① 재구성: 잘 복원했나}} \;+\; \beta\,\underbrace{D_{KL}\big(\mathcal{N}(\mu,\sigma^2)\,\Vert\,\mathcal{N}(0,I)\big)}_{\text{② KL: 영역을 표준 형태로}}

  • ① 재구성 손실 — 복원 \hat x가 원본 x에 가깝도록.
  • ② KL 발산 — 각 잠재 분포를 표준정규 \mathcal{N}(0,I)에 가깝게 끌어당김. KL은 “두 분포가 얼마나 다른가”를 재는 값으로, 이 항이 잠재 공간을 흩어지지 않게 정리합니다. \beta는 그 당기는 세기 손잡이입니다(\beta-VAE).

비유 — 재구성은 “그림을 똑같이 그려라”, KL은 “메모를 다들 같은 양식으로 써라”. 둘의 균형으로 의미 있고 정돈된 잠재 표현이 만들어집니다.

CENet은 무엇을 압축하나

CENet은 이 VAE 아이디어를 로봇에 맞게 바꾼 것입니다. 압축 대상이 사진이 아니라 최근 5스텝의 몸 움직임(관측 히스토리) 이고, 뽑아내려는 핵심(잠재)은 지금 속도 + 지형 문맥입니다. 그래서 VAE 손실에 속도 추정 항을 하나 더합니다 — 정답은 critic만 아는 진짜 선속도(1차시).

\mathcal{L}_{CENet} = \underbrace{\lVert \hat v - v_{true}\rVert^2}_{\text{속도}} \;+\; \underbrace{\lVert \hat o_{next} - o_{next}\rVert^2}_{\text{재구성}} \;+\; \beta\,\underbrace{D_{KL}}_{\text{정규화}}

여기서 재구성 타겟은 “다음 관측 o_{next}”입니다 — “내가 이렇게 움직였으니 다음 순간엔 이렇게 보일 것”을 맞히게 함으로써 신경망이 지형을 상상하도록 강제합니다. 이 세 항이 코드에서 어떻게 계산되는지 §4에서 직접 확인합니다.


3. CENet 구조 — 코드로 읽기

입력은 관측 히스토리 5×45 = 225, 출력은 속도·문맥·다음 관측 예측입니다.

flowchart LR
  OH["obs_history<br/>225 (5×45)"] --> ENC["Encoder<br/>225→128→64→35"]
  ENC --> EV["est_vel (3)"]
  ENC --> MU["mu (16)"]
  ENC --> LV["logvar (16)"]
  MU --> RP{{"reparam<br/>z = μ + σε"}}
  LV --> RP
  RP --> CX["context (16)"]
  EV --> LAT["[est_vel, context] = 19"]
  CX --> LAT
  LAT --> DEC["Decoder<br/>19→64→128→48→45"] --> ON["est_next_obs (45)<br/>재구성 타겟"]
  EV -. "actor 증강" .-> AUG(["Actor 입력 64"])
  CX -. "actor 증강" .-> AUG
  classDef enc fill:#e3f2fd,stroke:#1565c0; classDef dec fill:#f3e5f5,stroke:#7b1fa2; classDef out fill:#e8f5e9,stroke:#2e7d32;
  class ENC enc; class DEC dec; class AUG out;
Figure 1: CENet 구조 — 인코더가 속도·문맥을 추정하고, 디코더가 다음 관측을 복원한다
# cenet.py  (CENet.__init__)
self.encoder = nn.Sequential(
    nn.Linear(225, 128), nn.ELU(),
    nn.Linear(128, 64),  nn.ELU(),
    nn.Linear(64, 35),
)
self.decoder = nn.Sequential(
    nn.Linear(19, 64),   nn.ELU(),
    nn.Linear(64, 128),  nn.ELU(),
    nn.Linear(128, 48),  nn.ELU(),
    nn.Linear(48, 45),
)
1
인코더 출력 35 = est_vel(3) + mu(16) + logvar(16). 속도는 결정적, 문맥은 분포(mu, logvar).
2
디코더 입력 19 = est_vel(3) + context(16). context는 재매개화로 샘플링한 잠재.
3
디코더 출력 45 = 다음 관측 예측. “이 속도·문맥이면 다음 관측은 이럴 것”을 복원.

reparameterize — §2 수식 한 줄

# cenet.py
def reparameterize(self, mu, logvar):
    std = torch.exp(0.5 * logvar)
    eps = torch.randn_like(std)
    return mu + eps * std
1
logvar로 다뤄 항상 양수 \sigma = e^{0.5\,\log\sigma^2}를 얻음 (양수 제약 자동 충족).
2
표준정규 노이즈 \epsilon.
3
z = \mu + \sigma\epsilon — §2 재매개화 수식 그대로. \mu,\sigma로 그래디언트가 흐름.

아래에서 직접 샘플링해 보세요. 문맥 잠재 z2차원으로 단순화한 시각화입니다. 평균 \mu(파란 점)를 중심으로 매 순간 z = \mu + \sigma\epsilon (\epsilon\sim\mathcal{N}(0,I))가 새로 뽑힙니다. \sigma를 키우면 구름이 넓어지고(탐색·다양성↑), KL 항이 이 구름을 원점 쪽으로 당깁니다.

파란 점 = 평균 μ, 주황 점 = 샘플 z, 점선 원 = ±σ · 회색 십자 = 원점(KL이 당기는 곳)

forward — 인코더 출력을 쪼개는 핵심 로직

# cenet.py
def forward(self, obs_history):
    h = self.encoder(obs_history)
    est_vel, context_vec_params = h.split([3, h.size(-1) - 3], dim=-1)
    mu, logvar = context_vec_params.split(context_vec_params.size(-1)//2, dim=-1)
    context_vec = self.reparameterize(mu, logvar)
    latent = torch.cat([est_vel, context_vec], dim=-1)
    return self.decoder(latent), est_vel, mu, logvar, context_vec
1
225차원 히스토리를 인코딩 → 35차원.
2
앞 3개를 속도 추정, 나머지 32개를 문맥 파라미터로 분리.
3
32개를 절반씩 mu(16)logvar(16) 로 나눔.
4
재매개화로 문맥 잠재 context(16) 샘플링.
5
디코더 입력 19 = [속도, 문맥] 결합.
6
5개를 반환: 재구성, 속도, mu, logvar, 문맥. mu/logvar는 §4의 KL 손실에 쓰입니다.

4. CENet 손실 — 코드로 읽기

§2의 세 항(속도 + 재구성 + βKL)이 update()에 그대로 들어 있습니다.

# cenet.py  (update, 발췌)
est_onext, est_vel, mu, logvar, _ = self.forward(obs_history_batch)
vel_loss   = mse_loss(est_vel, true_vel_batch)
recon_loss = mse_loss(est_onext, true_onext_batch)
klds       = -0.5 * (1 + logvar - mu.pow(2) - logvar.exp())
kl_loss    = klds.sum(1).mean(0, True) * self.beta
total_loss = vel_loss + recon_loss + kl_loss
total_loss.backward(); self.optimizer.step()
...
self.beta = min(self.beta * 1.01, self.beta_limit)
1
미니배치 히스토리로 순전파.
2
속도 손실 — 추정 속도와 critic의 진짜 선속도(1차시 _true_lin_vel_b)의 MSE.
3
재구성 손실 — 예측한 다음 관측과 실제 다음 관측의 MSE.
4
KL 발산 닫힌형: -\tfrac12\sum(1+\log\sigma^2-\mu^2-\sigma^2)\mathcal{N}(\mu,\sigma^2)\mathcal{N}(0,I)에 가깝게.
5
차원 합 → 배치 평균 → \beta 곱. \beta가 정규화 세기.
6
세 항을 더해 역전파 — §2 \mathcal{L}_{CENet} 수식 그대로.
7
\beta annealing: 매 갱신 1%씩 증가, 최대 4.0. 초기엔 재구성/속도 집중, 점차 잠재 정규화 강화.
Note

비교용 EstNet(estnet.py)은 문맥·재구성·KL 없이 속도 3차원만 추정합니다 (Linear(225)→128→64→3, 손실은 vel_loss 하나). CENet vs EstNet 비교로 문맥 벡터의 기여를 측정할 수 있고, Waq(CENet)가 더 Oracle에 가깝습니다.


5. 학습 통합 — runner의 관측 증강

CENet은 PPO와 함께 학습됩니다. OnPolicyRunnerWaq(dreamwaq_runner.py)가 매 스텝 CENet을 돌려 actor 관측을 45→64로 증강합니다. 학습 루프의 핵심만 봅니다.

flowchart LR
  HIST["obs_history 225"] --> CE["CENet"]
  CE --> EV["est_vel 3"]
  CE --> CX["context 16"]
  TV["true_vel<br/>(critic 첫 3차원)"] --> SW{"AdaBoot<br/>boot_prob"}
  EV --> SW
  SW -->|"vel_input 3"| CAT["concat → 64"]
  BO["base_obs 45"] --> CAT
  CX --> CAT
  CAT --> POL["Actor → 행동"]
  classDef hot fill:#fff3e0,stroke:#f57c00; class SW hot;
Figure 2: 관측 증강 45→64 — AdaBoot이 추정 속도와 진짜 속도를 확률적으로 섞는다
# dreamwaq_runner.py  (learn 루프 내부, 발췌)
base_obs = obs["policy"]
true_vel = obs["critic"][:, :3]
obs_history = self._get_flat_obs_history()

est_next_obs, est_vel, mu, logvar, context_vec = self.cenet.before_action(obs_history, true_vel)

if self.boot_prob > np.random.random():
    vel_input = est_vel
else:
    vel_input = true_vel

augmented_actor_obs = torch.cat([base_obs, vel_input, context_vec], dim=-1)
augmented_obs["policy"] = augmented_actor_obs
actions = self.alg.act(augmented_obs)
obs, rewards, dones, extras = self.env.step(actions)
self.cenet.after_action(obs["policy"])
1
환경이 주는 기본 관측 45.
2
critic 관측 첫 3차원 = 진짜 선속도 (CENet 학습 정답 + AdaBoot 입력).
3
5스텝 히스토리를 평탄화 → 225.
4
CENet 순전파(§3) — 속도·문맥 추정 + 전이 저장.
5
AdaBoot: 확률 boot_prob로 추정 속도/진짜 속도 선택 (§아래).
6
actor 입력 증강 64 = base(45) + 속도(3) + 문맥(16).
7
증강 관측으로 PPO 행동 샘플 (2차시).
8
다음 관측을 CENet 재구성 타겟으로 저장 → §4 recon_loss의 정답.

AdaBoot — 적응적 부트스트래핑

# dreamwaq_runner.py
if "time_outs" in extras:
    timeout_rate = extras["time_outs"].float().mean().item()
    self.boot_prob = min(1.0, self.boot_prob + 0.001 * timeout_rate)
1
boot_prob은 1.0에서 시작(항상 추정 속도). 학습 초기엔 부정확한 추정값으로 일부러 학습시켜 추정 오차에 강건한 정책을 만들고, 안정될수록(timeout↑) 진짜 속도를 섞습니다.

한 반복 = CENet 갱신 + PPO 갱신

cenet_losses = self.cenet.update()    # vel + recon + KL (§4)
loss_dict    = self.alg.update()      # PPO (2차시)
ImportantNaN 방지 장치
  • actor 정규화를 Identity로 교체 — 0으로 패딩된 64차원이 아니라 CENet이 자체 정규화.
  • log_std 클램프 후크 [-5, 2] — std 폭주 방지(2차시 실전 노트).
  • CENet 출력 clamp(-10, 10) + nan_to_num — NaN 전파 차단.

6. 추론(배포) 시 동작

학습 후 디코더는 버립니다. 배포 시엔 인코더만 돌려 [est_vel(3) + mu(16)] = 19를 뽑아 actor에 붙입니다 (샘플링 대신 평균 mu → 결정적). 4차시 배포 로더와 직접 연결됩니다.

# 배포 (개념) — deploy_sim2sim/.../load_dreamwaq_policy_go2.py
latent_19 = cenet.inference(obs_history_225)        # [est_vel(3), mu(16)]
actor_obs = torch.cat([obs_45, latent_19], dim=-1)  # 64
raw_actions = actor.act_inference(actor_obs)

7. 핵심 정리

  • CENet은 고유수용성 히스토리(225) 만으로 선속도·지형 문맥을 추정한다 — 외부 센서 없음.
  • forward: 인코더 35를 est_vel(3)/mu(16)/logvar(16)로 쪼개고, 재매개화로 문맥을 샘플, 디코더가 다음 관측을 예측.
  • update: 속도 + 재구성 + βKL 세 항, \beta는 1→4 annealing.
  • runner가 actor 관측을 45→64로 증강, AdaBoot으로 추정 오차에 강건한 정책을 PPO와 동시 학습.
  • 배포 시엔 디코더 버리고 인코더의 [est_vel, mu]=19만 사용.
Note다음 차시 예고 — 4차시: Gazebo Sim2Sim

학습된 정책을 다른 물리엔진에서 검증합니다. 체크포인트 로딩, 관절 순서 매핑(가장 흔한 버그), ROS2 다중 주파수 제어(200 Hz PD + 50 Hz 정책)를 코드로 따라갑니다.