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;
VAE & DreamWaQ — 지형 인식 없이 걷기
DreamWaQ on IsaacLab — 3차시 (CENet)
강의 로드맵
| 회차 | 주제 |
|---|---|
| 1차시 | 4족 보행 로봇 & IsaacLab 환경 구성 |
| 2차시 | PPO & Actor-Critic — 보행 정책 학습 |
| 3차시 (오늘) | VAE & DreamWaQ — 지형 인식 없이 걷기 (CENet) |
| 4차시 | Gazebo Sim2Sim — 배포 전 검증 |
DreamWaQ의 핵심입니다. 외부 센서 없이 고유수용성 관측의 히스토리만으로 선속도와 지형 문맥을 추정하는 CENet을, cenet.py와 dreamwaq_runner.py 코드를 직접 짚으며 봅니다.
- 생성모델(오토인코더 → VAE)의 아이디어를 직관으로 이해한다.
- CENet
forward/update를 코드로 한 줄씩 읽는다. - 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, 출력은 속도·문맥·다음 관측 예측입니다.
- 1
- 인코더 출력 35 = est_vel(3) + mu(16) + logvar(16). 속도는 결정적, 문맥은 분포(mu, logvar).
- 2
- 디코더 입력 19 = est_vel(3) + context(16). context는 재매개화로 샘플링한 잠재.
- 3
- 디코더 출력 45 = 다음 관측 예측. “이 속도·문맥이면 다음 관측은 이럴 것”을 복원.
reparameterize — §2 수식 한 줄
- 1
-
logvar로 다뤄 항상 양수 \sigma = e^{0.5\,\log\sigma^2}를 얻음 (양수 제약 자동 충족). - 2
- 표준정규 노이즈 \epsilon.
- 3
- z = \mu + \sigma\epsilon — §2 재매개화 수식 그대로. \mu,\sigma로 그래디언트가 흐름.
아래에서 직접 샘플링해 보세요. 문맥 잠재 z를 2차원으로 단순화한 시각화입니다. 평균 \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. 초기엔 재구성/속도 집중, 점차 잠재 정규화 강화.
비교용 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;
# 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 — 적응적 부트스트래핑
- 1
-
boot_prob은 1.0에서 시작(항상 추정 속도). 학습 초기엔 부정확한 추정값으로 일부러 학습시켜 추정 오차에 강건한 정책을 만들고, 안정될수록(timeout↑) 진짜 속도를 섞습니다.
한 반복 = CENet 갱신 + PPO 갱신
- 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차시 배포 로더와 직접 연결됩니다.
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만 사용.
학습된 정책을 다른 물리엔진에서 검증합니다. 체크포인트 로딩, 관절 순서 매핑(가장 흔한 버그), ROS2 다중 주파수 제어(200 Hz PD + 50 Hz 정책)를 코드로 따라갑니다.