왜 VAE인가 — 히스토리에서 속도와 지형을 뽑아내기

보지 않고 걷기 — CENet + 비대칭 PPO · 3차시

Author

JungYeon Lee

Published

August 31, 2026

강의 로드맵

회차 주제
0차시 Isaac Sim & Isaac Lab 시작하기 — 시뮬레이터를 손으로 만져보기
1차시 4족 보행 로봇 & Isaac Lab — 관측을 무엇으로 채울 것인가
2차시 PPO 설계 — 비대칭 actor-critic과 탐험의 평형점
3차시 (오늘) 왜 VAE인가 — 히스토리에서 속도와 지형을 뽑아내기
4차시 학습 루프 통합 — CENet과 PPO를 한 반복에 묶기
5차시 실험 설계와 결과 해석 — 잠재가 죽었는지 어떻게 아는가

시리즈의 핵심입니다. 외부 센서 없이 고유수용성 관측의 히스토리만으로 선속도와 지형 문맥을 추정하는 CENetcenet.py 코드로 직접 짚습니다.

이 회차가 답하려는 질문은 하나입니다 — 왜 하필 VAE 인가? 속도만 필요하면 회귀 하나면 되는데 왜 인코더·디코더·KL 을 다 끌고 오는지, 그리고 그 선택에 어떤 값이 따라붙는지(§5·§6)를 봅니다.

Tip오늘의 목표
  1. 생성모델(오토인코더 → VAE)의 아이디어를 직관으로 이해한다.
  2. CENet forward/update를 코드로 한 줄씩 읽는다.
  3. \beta 하나가 잠재를 죽이는지, 그리고 잠재가 죽는 두 경로를 구별한다.

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


1. 문제 정의 — 그리고 왜 회귀 하나로는 안 되는가

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

Actor 입력 센서 역할
Base 45 없음 하한선
Oracle 48 특권 정보 직접 상한선
CENet 팔 64 없음 (히스토리에서 추정) 측정 대상

핵심 아이디어는 이겁니다 — 최근 5스텝 관측의 변화 패턴 속에 “지금 얼마나 빠른지, 어떤 지형 위인지”가 이미 담겨 있다. 발이 언제 닿았는지, 그때 몸이 얼마나 튕겼는지, 관절이 예상보다 얼마나 덜 굽혀졌는지 — 이런 것들이 속도와 지형의 흔적입니다.

비유 — 눈을 감고 차 뒷좌석에 앉아 있어도 지금 고속도로인지 자갈길인지, 대충 몇 km/h 인지 알 수 있습니다. 진동의 리듬과 세기, 코너에서 쏠리는 느낌이 알려 줍니다. 카메라 없이 몸으로 느낀 최근 몇 초만으로 상당한 것을 복원할 수 있다는 이야기입니다.

1.2 그럼 회귀 하나면 되지 않나?

여기서 당연한 질문이 나옵니다. 필요한 건 결국 선속도 3차원입니다. 정답(진짜 선속도)은 시뮬레이터가 알려 줍니다. 그러면 이렇게 하면 끝 아닌가요?

\text{히스토리 225} \;\longrightarrow\; \text{MLP} \;\longrightarrow\; \hat v \in \mathbb{R}^3, \qquad \mathcal{L} = \lVert \hat v - v_{true}\rVert^2

맞습니다. 그리고 그 코드가 이 저장소에 실제로 있습니다estnet.py 가 정확히 이겁니다 (§7에서 봅니다). 인코더 트렁크는 CENet 과 똑같고, 잠재도 디코더도 KL 도 없이 속도만 회귀합니다.

문제는 속도만 뽑으면 나머지를 전부 버린다는 것입니다. 히스토리에는 지형에 대한 정보도 들어 있는데, 손실 함수가 속도만 물어보면 신경망은 속도에 필요한 것만 남기고 나머지를 지웁니다.

Important그런데 지형에는 정답 라벨이 없다

“지형 문맥” 을 지도학습으로 배우려면 정답이 있어야 합니다. 그런데 정답이 뭔가요?

  • 지형 종류(계단/자갈/경사)? → 이산 라벨이라 그 사이를 표현 못 합니다. 그리고 실제 지형은 섞여 있습니다.
  • 187개 height scan 값? → critic 은 알지만, 이걸 그대로 맞히게 하면 가진 적 없는 센서를 흉내 내는 것이라 배포에서 의미가 없고, 45차원 히스토리로 187차원을 복원하라는 건 무리한 요구입니다.
  • 마찰계수·질량 같은 물리 파라미터? → 1차시의 randomization 값들인데, 이건 애초에 히스토리 0.1초로는 역추정이 거의 안 됩니다.

“지형 문맥” 은 사람이 라벨을 붙일 수 있는 대상이 아닙니다. 그래서 지도학습을 쓸 수 없고, 비지도로 짜내야 합니다. 그리고 라벨 없이 “쓸모 있는 압축 표현” 을 학습하는 표준 도구가 바로 오토인코더 계열, 그중에서도 VAE 입니다.

정리하면 CENet 의 설계는 두 갈래를 한 신경망에 합친 것입니다.

뽑아낼 것 정답이 있나 그래서 어떻게
선속도 3 있다 (시뮬레이터) 지도학습 — MSE 회귀
지형 문맥 16 없다 비지도 — VAE 잠재

$2~$6은 오른쪽 줄, 즉 “라벨 없이 어떻게 지형을 배우게 만드나” 를 파고듭니다.


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

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

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

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

변분인가 — 확률로 두는 세 가지 이유

평범한 오토인코더는 잠재 z하나의 점입니다. VAE(변분 오토인코더)는 z를 점이 아니라 확률분포(흐릿한 영역) 로 봅니다 — 평균 \mu와 퍼짐 \sigma로 표현하고, 그 영역에서 무작위로 하나 뽑아 씁니다.

쉽게 말하면 — “정확히 이 점”이 아니라 “이 근처 어디쯤”이라고 표현하는 것.

한 글자 더 붙이는 대가로 무엇을 얻는지가 중요합니다. 이 문제에서는 셋입니다.

① 잠재 공간이 매끄러워진다. 점으로 두면 점들 사이의 빈 공간이 아무 의미가 없습니다. 영역으로 두면 비슷한 상황이 잠재 공간에서도 가까이 놓이고, 그 사이가 이어집니다. 로봇에게 이건 실용적인 문제입니다 — 자갈길에서 조금 더 거친 자갈길로 넘어갈 때 문맥 벡터가 튀지 않고 연속적으로 움직여야 actor 가 안정적으로 반응합니다.

② KL 항이 “채널당 정보 요금제”가 된다. 이게 이 회차에서 가장 중요한 성질입니다. KL 은 각 잠재 차원을 \mathcal{N}(0,1) 쪽으로 끌어당깁니다 — 즉 “쓸 거면 요금을 내라” 는 압력입니다. 그래서 신경망은 재구성에 실제로 도움이 되는 차원만 남기고 나머지는 스스로 꺼 버립니다. 16개 차원을 줬지만 실제로 몇 개나 쓰는지가 측정 가능한 양이 되고, 그게 나중에 “잠재가 살아 있는가” 를 판정하는 도구가 됩니다(§6).

③ 샘플링 노이즈가 actor 를 강건하게 만든다. actor 는 매 스텝 z새로 뽑은 값으로 받습니다. 즉 문맥 추정이 늘 조금씩 흔들린 채로 학습됩니다. 배포된 로봇의 추정도 어차피 정확하지 않을 테니, 처음부터 흔들리는 입력에 익숙해지는 편이 낫습니다.

②와 ③은 서로 밀고 당깁니다. KL 을 세게 걸면(정보 요금이 비싸면) 잠재가 깔끔해지지만 너무 세면 아무것도 안 쓰게 됩니다. 그 균형점을 잡는 손잡이가 \beta 이고, §5가 그 이야기입니다.

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

문제가 하나 있습니다. “영역에서 무작위로 뽑기”는 미분이 안 돼 신경망 학습(역전파)이 막힙니다. 재매개화 트릭은 무작위성을 밖으로 빼내 해결합니다: 표준정규에서 노이즈 \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 관측의 앞 3차원을 집어 오면 될 것 같지만 CENet 팔에서는 그게 틀립니다 — 이 팔의 critic 그룹에는 base_lin_vel 이 빠져 있어서 앞 3차원이 각속도거든요. 실제로 한동안 CENet 이 엉뚱한 값을 추정하도록 학습되고 있었습니다. 지금은 씬에서 직접 읽습니다(4차시).

\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{정규화}}

복원 대상이 “자기 자신”이 아니라 “다음 관측”인 이유

여기가 이 설계에서 가장 영리한 한 수입니다. 보통 오토인코더는 입력 x 를 그대로 복원합니다. CENet 은 그렇게 하지 않습니다 — 지금까지의 히스토리를 받아 다음 순간의 관측 o_{t+1} 을 맞힙니다.

flowchart LR
  subgraph PAST["입력 — 이미 일어난 일 (225)"]
    direction LR
    T4["o(t−4)"] --> T3["o(t−3)"] --> T2["o(t−2)"] --> T1["o(t−1)"] --> T0["o(t)"]
  end
  PAST --> ENC["Encoder"] --> Z["잠재<br/>v̂ 3 + z 16"]
  Z --> DEC["Decoder"] --> PRED(["예측 ô t+1<br/>45"])
  PRED -. "MSE" .-> TRUE(["실제 o t+1<br/>45"])
  classDef past fill:#eceff1,stroke:#546e7a;
  classDef net fill:#e3f2fd,stroke:#1976d2;
  classDef fut fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
  class T4,T3,T2,T1,T0 past; class ENC,DEC,Z net; class PRED,TRUE fut;
Figure 1: 복원 대상은 과거가 아니라 미래 — 그래서 인코더는 동역학을 배워야 한다

차이가 왜 큰지 생각해 봅시다.

  • 자기 자신을 복원하면 — 신경망은 225개 숫자를 19개로 압축하는 법만 배우면 됩니다. 잘 압축된 표현이 나오긴 하지만, 그 안에 “지형” 이 들어 있을 이유가 없습니다.
  • 다음을 복원하려면 — “이 자세로 이 속도로 가고 있고, 방금 이런 행동을 냈다. 그러면 다음 순간 몸은 어떻게 되어 있을까?” 를 답해야 합니다. 이건 동역학을 아는 것입니다.

그리고 여기서 지형이 필연적으로 끌려 들어옵니다. 같은 자세, 같은 행동인데 다음 순간이 다르게 나온다면, 다른 것은 발밑뿐입니다. 자갈 위에서는 발이 미끄러지고, 계단에서는 예상보다 일찍 닿고, 경사에서는 몸이 기웁니다. 다음 관측을 잘 맞히려면 신경망은 발밑이 어떤지에 대한 추정치를 반드시 들고 있어야 합니다.

한 줄로 — 자기 자신을 복원하면 압축만 배운다. 다음을 복원해야 동역학을 배우고, 거기에 지형이 들어 있다.

구현 디테일 하나 — 재구성 타겟은 정규화된 다음 관측입니다. 인코더 입력(히스토리)과 디코더 타겟이 같은 스케일에 있어야 하는데, 한때 타겟만 raw 로 저장해 스케일이 어긋난 적이 있었습니다. 자세한 것은 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 2: 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). 활성함수 없이 선형 출력 그대로입니다 — mu 와 logvar 는 음수도 나와야 하니까요.
2
디코더 입력 19 = est_vel(3) + context(16). context는 재매개화로 샘플링한 잠재.
3
디코더 출력 45 = 다음 관측 예측. “이 속도·문맥이면 다음 관측은 이럴 것”을 복원.

35와 19가 둘 다 맞습니다. 논문 그림에는 잠재가 19로 그려져 있어 코드의 35와 모순처럼 보이는데, 아닙니다 — 인코더가 실제로 뱉는 것은 v̂(3) + mu(16) + logvar(16) = 35 이고, 19는 재매개화를 거친 뒤v̂(3) + z(16) 입니다. 디코더 입력이 19인 것과 정확히 맞물립니다.

Important놓치기 쉬운 한 줄 — 디코더는 mu 가 아니라 est_vel 을 본다

디코더 입력이 [est_vel, z] 라는 점을 그냥 넘기지 마세요. 속도 추정값이 재구성 경로에 그대로 들어갑니다. 결과가 둘입니다.

  1. 재구성 손실이 속도 헤드까지 학습시킵니다. \hat v 는 지도학습 신호(진짜 속도 MSE)와 비지도 신호(다음 관측을 잘 맞히기)를 동시에 받습니다.
  2. \hat vz 가 재구성 일감을 두고 경쟁합니다. 다음 관측을 맞히는 데 필요한 정보를 둘 중 누가 들고 있어도 되거든요. 그런데 \hat v 는 정답을 직접 받는 반면 z 는 KL 요금을 내야 합니다. 공정한 경쟁이 아닙니다.

2번이 §6에서 잠재가 죽는 두 번째 경로가 됩니다. 지금은 “디코더가 속도를 본다” 는 사실만 기억해 두세요.

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  (CENet.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.mean(1).mean(0, True) * self.beta
total_loss = vel_loss + recon_loss + kl_loss

self.optimizer.zero_grad()
total_loss.backward()
nn.utils.clip_grad_norm_(self.parameters(), 1.0)
self.optimizer.step()
1
미니배치 히스토리로 순전파.
2
속도 손실 — 추정 속도와 진짜 선속도의 MSE. 3차원에 대해 평균.
3
재구성 손실 — 예측한 다음 관측과 실제 다음 관측의 MSE. 45차원에 대해 평균.
4
KL 발산 닫힌형: -\tfrac12(1+\log\sigma^2-\mu^2-\sigma^2)\mathcal{N}(\mu,\sigma^2)\mathcal{N}(0,I)에 가깝게.
5
.mean(1) — 16개 잠재 차원에 대해 평균(합이 아닙니다), 그다음 배치 평균, 그다음 \beta. 이 한 단어가 §5 전체의 주제입니다.
6
세 항을 그냥 더합니다 — 가중치는 \beta 하나뿐, vel 과 recon 은 1:1.
7
그래디언트 노름 1.0 클리핑.
Important여기 없는 것 두 가지

\beta annealing 이 없습니다. 예전 코드에는 self.beta = min(self.beta * 1.01, self.beta_limit) — 매 갱신마다 \beta 를 1%씩 키워 4.0까지 올리는 스케줄이 있었습니다. 지금은 제거됐고 \beta 는 상수 0.35 입니다. (beta_limit=4.0__init__ 기본값으로 남아 있지만 DWQ_CENET_BETA_ANNEAL=1 을 켜지 않으면 아무 일도 하지 않습니다.)

klds.sum(1) 이 아닙니다. 예전 코드는 합이었습니다. 합에서 평균으로 바꾼 것이 이 구현에서 가장 중요한 수정 하나이고, 왜 그런지가 바로 다음 절입니다.

옵티마이저는 Adam lr=1e-3ReduceLROnPlateau(min_lr=1e-4, patience=100, factor=0.8) 를 겁니다. PPO 의 adaptive lr 과는 완전히 별개로, CENet 은 자기 학습률을 따로 굴립니다.


5. \beta는 여러분이 생각한 그 \beta가 아니다

\beta 를 0.35 로 쓴다고 하면 “KL 을 약하게 걸었구나” 로 읽힙니다. 그렇지 않습니다. 그리고 이걸 잘못 읽어서 실제로 잠재가 두 번 죽었습니다.

5.1 문제는 세 항의 “감축 축”이 다르다는 것

세 손실 항은 각각 차원이 다릅니다.

차원 코드가 하는 일
vel_loss 3 nn.MSELoss() → 3으로 나눔 (평균)
recon_loss 45 nn.MSELoss() → 45로 나눔 (평균)
kl_loss 16 .mean(1) → 16으로 나눔 (평균)

세 항 모두 “차원당 평균” 으로 통일돼 있습니다. 그래서 그냥 더해도 서로 공정합니다.

그런데 KL 만 으로 쓰면 어떻게 될까요? recon 은 여전히 45로 나뉘는데 KL 은 안 나뉩니다. KL 이 상대적으로 45배 커집니다.

\underbrace{\frac{1}{45}\lVert \hat o - o\rVert^2 + \beta \sum_{i=1}^{16}\mathrm{KL}_i}_{\text{합 방식}} \;=\; \frac{1}{45}\Big(\lVert \hat o - o\rVert^2 + \underbrace{45\beta}_{\text{실효}} \sum_i \mathrm{KL}_i\Big)

실효 \beta45\beta 입니다. 여기에 예전의 annealing(\beta \to 4.0)까지 겹치면 실효 \beta = 180. 정보 요금이 180배로 비싸졌으니 신경망은 잠재를 아예 안 쓰는 쪽을 택합니다. 이게 posterior collapse 입니다.

5.2 “그럼 recon 을 합으로 바꾸면 되잖아?”

축을 맞추는 방법이 하나 더 있습니다 — KL 을 평균으로 내리는 대신, recon 을 합으로 올리는 것. 교과서 VAE 는 실제로 둘 다 합입니다.

하지만 그러면 다른 게 깨집니다. recon 이 45배 커지는데 vel_loss 는 그대로거든요.

\text{vel} : \text{recon} \;=\; 1 : 1 \quad\longrightarrow\quad 1 : 15

속도 추정이 재구성에 파묻힙니다. 평균으로 맞추는 것이 두 비율을 동시에 지키는 유일한 선택이었습니다.

이건 논문 위반이 아닙니다. \beta \cdot \mathrm{KL}_{mean} = (\beta/16)\cdot \mathrm{KL}_{sum} 이므로 축을 바꾸는 것은 \beta 를 다시 매개변수화하는 것일 뿐입니다. 교과서 sum/sum 기준으로 환산하면 이 구현의 실효 \beta(45/16)\beta \approx 2.81\beta 이고, \beta=0.35교과서 \beta \approx 0.98 — 즉 아주 평범한 값입니다. 진짜 자유 선택은 vel:recon 비율과 \beta 값 두 개뿐입니다.

\beta 값과 두 감축 축을 바꿔 가며 교과서 기준 실효 \beta 가 어떻게 변하는지 보세요. 오른쪽 막대에 실제 관측된 “살았다/죽었다” 지점이 찍혀 있습니다.

교과서 sum/sum 환산 실효 β = 0.98
vel : recon = 1 : 1

막대 위 표시는 실제 관측 결과 — 초록 = 잠재가 살아남은 설정, 빨강 = 붕괴한 설정

이렇게 해보세요 — ① “현재 코드” 를 누르면 실효 \beta \approx 0.98, 초록 구간입니다. ② “옛 코드” 를 누르면 KL 축만 sum 으로 바뀌는데 실효 \beta45 로 뜁니다 — 드롭다운 하나가 45배입니다. ③ 이제 recon 축도 sum 으로 바꿔 보세요. 실효 \beta 는 1로 돌아오지만 vel : recon 이 1:15 로 깨집니다. 축을 맞추는 두 방법 중 왜 하나만 쓸 수 있는지가 여기 있습니다.

5.3 실제로 붕괴하면 이런 모양이다

토이 VAE 로 같은 현상을 재현할 수 있습니다(실습 task01_vae).

\beta KL 재구성 오차
1.0 0.59 0.48
10.0 0.003 1.77

KL 이 0으로 가는데 재구성 오차가 오히려 오르는 것 — 이 조합이 붕괴의 서명입니다. “KL 이 작으니 잠재가 깔끔하구나” 가 아니라 “잠재를 아예 안 쓰고 있다” 는 뜻입니다. 참고로 토이의 \beta=10 은 실효 \beta=40 으로, 옛 CENet 의 45와 거의 같은 자리입니다.


6. 잠재는 두 가지 방식으로 죽는다

§5의 이야기만 들으면 “\beta 만 잘 맞추면 되겠네” 싶습니다. 아닙니다. 실제로 이 구현의 잠재는 두 번 죽었고, 두 번의 원인이 서로 달랐습니다.

더 나쁜 것은 겉모습이 똑같다는 점입니다. 두 경우 모두 \lVert\mu\rVert \to 0, \sigma \to 1, \mathrm{KL} \to 0 입니다. 로그만 봐서는 구별이 안 됩니다.

flowchart TD
  S["증상<br/>‖μ‖→0 · σ→1 · KL→0"] --> Q{"디코더 1층의<br/>z 쪽 가중치 ‖W_z‖ 는?"}
  Q -->|"크다 (z 를 읽고 싶어한다)"| A["ⓐ KL 과압<br/>인코더가 눌려 얼었다"]
  Q -->|"거의 0 (z 를 안 본다)"| B["ⓑ 디코더가 z 를 버렸다"]
  A --> AR(["처방: β 를 내린다"])
  B --> BR(["처방: β 를 내려도 안 산다<br/>z 에게 할 일을 줘야 한다"])
  classDef sym fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
  classDef bad fill:#ffebee,stroke:#c62828;
  classDef fix fill:#e8f5e9,stroke:#2e7d32;
  class S,Q sym; class A,B bad; class AR,BR fix;
Figure 3: 잠재가 죽는 두 경로 — 증상은 같지만 처방이 정반대다

ⓐ KL 과압 — 요금이 너무 비싸다

§5의 그 이야기입니다. 실효 \beta 가 180쯤 되면 잠재를 쓰는 대가가 얻는 것보다 큽니다. 그래서 인코더가 mu 를 0으로 눌러 버리고, 한번 눌리면 그래디언트가 거의 안 흘러 얼어붙습니다.

특징은 디코더는 여전히 z 를 읽고 싶어한다는 것입니다. 디코더 1층에서 z 쪽으로 들어오는 가중치의 노름 \lVert W_z\rVert큽니다. 재료를 받을 준비는 돼 있는데 인코더가 안 보내 주는 상태입니다. → \beta 를 내리면 살아납니다.

ⓑ 디코더가 z 를 버렸다 — 경쟁에서 졌다

§3에서 심어 둔 복선이 여기서 회수됩니다. 디코더 입력은 [v̂, z] 이고, 둘 다 재구성에 기여할 수 있습니다. 그런데 조건이 불공평합니다.

\hat v z
학습 신호 정답 속도를 직접 받음 재구성에만 기여해야 함
비용 없음 KL 요금

\hat v 가 먼저 쓸 만해지면 디코더는 그쪽에 의존합니다. 그리고 한번 z 를 놓으면 z 로 가는 그래디언트 경로가 끊겨서 되돌아오지 않습니다. \beta 를 내려도 소용없습니다 — 요금을 깎아 준들 아무도 안 사는 상품이니까요.

특징은 ⓐ와 정반대입니다. \lVert W_z\rVert거의 0 입니다.

Important

ⓑ는 구현 실수가 아니라 이 구조의 내재적 취약점입니다. 디코더에 [\hat v, z] 를 같이 넣는 설계 자체가 둘을 경쟁시키고, 한쪽이 유리한 조건을 갖고 있습니다. 원 논문의 구조도 마찬가지입니다.

두 경우를 가르는 것은 \lVert W_z\rVert 하나입니다. \lVert\mu\rVert 만 보면 둘이 같은 무리로 보입니다. 실제 체크포인트에서 이 값이 얼마나 극적으로 갈리는지, 그리고 어떻게 재는지는 5차시에서 실측값으로 봅니다.

그래서 코드는 진단용 스칼라를 매 갱신마다 기록합니다 — cenet_mu_abs, cenet_sigma, cenet_kl_nats, cenet_kl_active_dims(차원별 KL 이 0.01을 넘는 차원 수), 그리고 cenet_dec_w_vel / cenet_dec_w_z. 마지막 둘이 방금 이야기한 \lVert W_z\rVert 입니다.


7. VAE를 빼면 어떻게 되나 — EstNet 대조

§1.2에서 미뤄 둔 질문으로 돌아갑니다. 속도만 회귀하면 정말 안 되나요?

그 코드가 저장소에 있습니다. estnet.py 입니다.

# estnet.py — 인코더 트렁크는 CENet 과 동일, 그 뒤가 전부 없다
self.encoder = nn.Sequential(
    nn.Linear(225, 128), nn.ELU(),
    nn.Linear(128, 64),  nn.ELU(),
    nn.Linear(64, 3),
)
...
vel_loss = mse_loss(est_vel_batch, true_vel_batch)
1
35가 아니라 3. mu 도 logvar 도 없습니다 — 잠재가 아예 없습니다.
2
손실도 하나뿐. 재구성도 KL 도 없습니다.

CENet 과 비교하면 세 가지가 사라집니다.

CENet EstNet
actor 에게 주는 것 est_vel 3 + context 16 est_vel 3
학습 목적 속도 + 다음 관측 예측 속도
정규화 KL 없음

구조도 더 단순합니다 — 저장소 훅이 하나뿐입니다. 다음 관측을 기다릴 이유가 없으니까요. CENet 이 훅을 둘로 나눠야 했던 이유가 여기서 대비로 드러납니다.

Important정직하게 — 이건 실험이 아니라 사고실험입니다

estnet.py어느 설정에서도 선택되지 않는 사문화 코드입니다. class_name 으로 고를 수 있는 runner 가 없고, EstNet 대조 런은 존재하지 않습니다.

그러니 “CENet 이 EstNet 보다 낫다” 고 말할 근거가 이 저장소에는 없습니다. 여기서는 설계 대조군으로만 씁니다 — “VAE 를 떼면 무엇이 사라지는지” 를 코드 수준에서 보는 용도. 숫자로 답하려면 런을 돌려야 하고, 그건 아직 안 한 일입니다(5차시 §10).


8. 손으로 해보기

이 회차의 학습지가 시리즈에서 가장 강도가 높습니다. 3장이고, 첫 번째는 빈 파일에서 시작합니다. exercises/stage4_cenet/ 에 있습니다.

cd exercises/stage4_cenet/task01_vae
~/IsaacLab/_isaac_sim/python.sh check.py
~/IsaacLab/_isaac_sim/python.sh check.py --solution

순서가 중요합니다 — 토이 VAE 를 먼저 세우고, 거기에 속도 가지를 붙여 CENet 으로 확장하고, 마지막에 손실을 채웁니다. 건너뛰면 세 번째에서 막힙니다.

학습지 1 — 순수 VAE L3 · 설계

목표: ToyVAE 클래스를 통째로 씁니다. 골격도 시그니처도 주어지지 않고, API 명세만 있습니다. 이 시리즈에서 유일한 L3 입니다.

L3 라 TODO모듈 최상단에 있습니다. import 하는 순간 NotImplementedError 가 납니다 — 채울 곳을 찾는 것부터가 문제의 일부입니다.

토이 문제가 잘 만들어져 있습니다make_toy_batch()진짜 자유도가 2뿐인데 8차원으로 관측되는 데이터를 만듭니다. 2차원 잠재를 고정 랜덤 행렬로 8차원에 펼치고 노이즈를 얹은 것입니다.

이게 바로 이 회차의 문제 설정을 축소한 것입니다 — “지형의 진짜 상태는 몇 개 수로 요약되는데, 로봇은 225차원 관측 이력만 본다.” VAE 가 할 일은 그 저차원 구조를 되찾는 것입니다.

써야 할 것: encode → (mu, logvar) · reparameterize · decode · forward → (x_hat, mu, logvar, z) · loss → (total, recon, kl)

통과 기준 20개 중 개념을 가르는 것들:

검사 무엇을 잡나
logvar → −∞ 이면 z ≈ mu 재매개화를 제대로 썼나
forward 를 두 번 부르면 x_hat 이 달라야 함 decode(mu) 로 새지 않았나
mu=1, logvar=0 이면 kl == 0.5
latent_dim 을 2→4 로 바꿔도 여전히 0.5 차원당 평균인가 합인가 — §5 그 자체
\beta 를 2배 하면 kl 도 정확히 2배 \beta 를 어디에 곱했나
Important

마지막 두 줄이 이 학습지의 핵심입니다. 합으로 쓰면 latent_dim 을 바꿨을 때 값이 따라 변합니다. §5에서 본 “실효 \beta 가 45배” 사고가 정확히 이 실수였습니다. 축소판으로 한 번 겪어 보는 것이 이 학습지의 목적입니다.

풀고 나면 \beta 를 1.0과 10.0으로 바꿔 학습시켜 보세요. §5.3의 표 (KL 0.59 / recon 0.48 vs KL 0.003 / recon 1.77)를 직접 재현할 수 있습니다. 붕괴를 한 번 눈으로 보고 나면 진짜 런에서 알아보기가 훨씬 쉬워집니다.

학습지 2 — CENet 의 forward L2

목표: 학습지 1에서 세운 VAE 를 진짜 CENet 으로 확장합니다. 실제 cenet.pyforward 본문을 씁니다.

토이 VAE 와 다른 점은 딱 하나 — 인코더 출력에 속도 추정 가지가 하나 더 붙습니다.

obs_history ─▶ encoder ─▶ h (35)
                          │
              ┌───────────┴───────────┐
              ▼           ▼           ▼
          est_vel(3)    mu(16)    logvar(16)
              │           └─────┬─────┘
              │                 ▼
              │              reparam
              │                 ▼
              │          context_vec(16)
              └────────┬────────┘
                       ▼
                cat (19) ─▶ decoder ─▶ est_next_obs (45)

통과 기준 18개 중 설계를 가르는 것들:

  • 인코더 출력을 겹치지 않고 남기지도 않고 정확히 셋으로 쪼갰는지 — 채점기가 인코더를 다시 돌려 각 조각의 위치까지 대조합니다
  • 디코더에 들어가는 것이 mu 가 아니라 샘플인지 (§3의 그 지점)
  • 디코더 입력 폭이 latent_dim2 와 맞는지
  • 홀수 차원이면 조용히 넘어가지 않고 예외를 내는지

이 학습지가 L3 가 아닌 이유가 문제 자체에 적혀 있습니다 — 채점기가 슬롯 순서와 폭을 고정하므로, 명세를 자세히 쓰면 그게 곧 정답이 되어 버립니다. 그래서 본문만 비워 뒀습니다.

학습지 3 — CENet 손실 함수 L2

목표: 세 항을 완성합니다. §4의 그 코드입니다.

무엇을 재는가
vel_loss 추정 속도가 실제 base linear velocity 를 맞추는가
recon_loss 잠재로 다음 스텝 관측(45) 을 복원할 수 있는가
kl_loss 잠재 분포가 표준정규에서 얼마나 벗어났는가

통과 기준 14개 중 이 학습지의 전부라 할 만한 것:

Important감축 축 정렬 — 채점기가 차원을 바꿔 가며 확인합니다

mu=1, logvar=0 일 때 kl_loss == 0.5 여야 하고, 잠재를 16→32 로, 관측을 45→90 으로 늘려도 그 값이 둘 다 안 변해야 합니다.

합으로 쓰면 8.0 이 나오고, 차원을 늘리면 값이 따라 변합니다. §5에서 읽기만 했던 “차원당 평균” 이 코드에서 무슨 뜻인지 여기서 손으로 확인하게 됩니다.

그 외: \beta 를 2배 하면 kl 만 정확히 2배(vel·recon 은 불변) · 세 항의 합이 total · backward()인코더와 디코더 양쪽에 도달하는지.


9. 핵심 정리

  • 필요한 것은 선속도(정답 있음)지형 문맥(정답 없음) 둘이다. 앞쪽은 지도학습으로 되지만 뒤쪽은 라벨을 붙일 수가 없다 — 그래서 비지도, 그래서 VAE.
  • 확률로 두는 대가로 셋을 얻는다: 매끄러운 잠재 공간, 채널당 정보 요금제(KL), 그리고 샘플링 노이즈가 주는 강건성.
  • 복원 대상이 자기 자신이 아니라 다음 관측이라는 것이 설계의 핵심 한 수다. 압축이 아니라 동역학을 배우게 만들고, 동역학을 맞히려면 발밑을 알아야 한다.
  • forward: 인코더 35를 est_vel(3)/mu(16)/logvar(16)로 쪼개고, 재매개화로 문맥을 샘플, 디코더가 다음 관측을 예측. 디코더는 mu 가 아니라 est_vel 을 본다\hat vz 가 재구성 일감을 두고 경쟁한다.
  • update: 속도 + 재구성 + \betaKL, 세 항 모두 차원당 평균. \beta상수 0.35, annealing 없음.
  • \beta 는 감축 축과 함께 읽어야 한다. KL 만 합으로 쓰면 실효 \beta 가 45배, annealing 까지 겹치면 180배 — posterior collapse. 반대로 recon 을 합으로 맞추면 vel:recon 이 1:15로 깨진다. 평균이 두 비율을 동시에 지키는 유일한 선택이다.
  • 잠재는 두 가지 방식으로 죽고 겉모습이 같다. 가르는 것은 디코더 1층의 \lVert W_z\rVert — ⓐ KL 과압(\beta 를 내리면 산다) vs ⓑ 디코더가 z 를 버림(안 산다).
  • VAE 를 떼면 estnet.py 가 된다 — 하지만 그 대조 런은 존재하지 않는다. 사고실험일 뿐.
Note다음 차시 예고 — 4차시: 학습 루프 통합

모델은 이해했습니다. 이제 이걸 PPO 롤아웃 안에 어떻게 끼워 넣는지 봅니다 — 45차원 환경에서 64차원 actor 를 만드는 법, 정규화를 왜 셋으로 쪼갰는지, AdaBoot, 그리고 학습 곡선에 아무것도 남기지 않고 조용히 실패하는 방법.