학습 루프 통합 — CENet과 PPO를 한 반복에 묶기

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

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차시 실험 설계와 결과 해석 — 잠재가 죽었는지 어떻게 아는가

모델은 이해했습니다. 이제 이걸 PPO 롤아웃 안에 어떻게 끼워 넣는가 를 봅니다.

말로 하면 간단합니다 — “매 스텝 CENet 을 돌려서 actor 관측을 45에서 64로 늘린다”. 그런데 실제로 해 보면 걸리는 게 줄줄이 나옵니다. 환경은 끝까지 45차원만 내놓는데 actor 는 64차원으로 만들어야 하고, 정규화기는 없는 차원의 통계를 잡으려다 NaN 을 뱉고, 재구성 타겟은 다음 관측이라 한 스텝을 기다려야 합니다. 이 회차는 그 배관 작업의 기록입니다.

Tip오늘의 목표
  1. 한 반복 안에서 두 개의 옵티마이저가 어떻게 같은 롤아웃을 나눠 쓰는지 안다.
  2. 정규화를 왜 셋으로 쪼갰는지 — 이 파일에서 가장 중요한 설계 결정을 이해한다.
  3. 조용히 실패하는 법을 안다. 학습 곡선에 아무것도 남기지 않고 죽는 경우가 있다.

이 회차는 앞의 셋보다 “코드 읽기” 비중이 높습니다. 대신 각 결정마다 왜 그렇게 했는지를 반드시 붙입니다 — 배관 자체보다 배관을 그렇게 깐 이유가 배울 거리입니다.


1. 왜 별도 회차인가 — 한 롤아웃, 두 학습기

3차시까지 우리는 두 개의 신경망 묶음을 따로 봤습니다.

  • PPO (2차시): actor + critic. 보상을 최대화한다.
  • CENet (3차시): 인코더 + 디코더. 속도를 맞히고 다음 관측을 복원한다.

이 둘은 목적이 다릅니다. 그런데 같은 롤아웃 데이터를 씁니다. 로봇이 24스텝을 걸으면 그 경험이 PPO 의 학습 데이터이기도 하고, CENet 의 학습 데이터이기도 합니다.

flowchart LR
  ROLL["롤아웃 24스텝<br/>4096 환경"] --> S1["PPO storage<br/>관측·행동·보상·가치"]
  ROLL --> S2["CENet storage<br/>히스토리·진짜속도·다음관측"]
  S2 --> U1["cenet.update()<br/>5 epoch × 4 minibatch"]
  S1 --> U2["alg.update()<br/>5 epoch × 4 minibatch"]
  U1 -->|"먼저"| U2
  U2 --> ROLL
  classDef roll fill:#e3f2fd,stroke:#1976d2;
  classDef ce fill:#f3e5f5,stroke:#7b1fa2;
  classDef ppo fill:#e8f5e9,stroke:#2e7d32;
  class ROLL roll; class S2,U1 ce; class S1,U2 ppo;
Figure 1: 한 번의 롤아웃, 두 개의 옵티마이저 — 목적이 다른 둘이 같은 경험을 나눠 쓴다

저장소가 둘, 옵티마이저가 둘, 학습률 스케줄도 둘입니다. 그런데 롤아웃은 하나입니다. 이 비대칭이 이 회차의 모든 복잡함을 만듭니다.


2. 러너 교체는 한 줄이다

2차시 끝에서 미뤄 둔 그 한 줄입니다.

# rsl_rl_ppo_cfg.py
class_name = "OnPolicyRunnerWaq"

train.py 가 이 문자열을 보고 어느 runner 를 세울지 고릅니다.

# scripts/rsl_rl/train.py
runner_class_name = agent_dict.get("class_name", "OnPolicyRunner")
if runner_class_name == "OnPolicyRunnerWaq":
    runner = OnPolicyRunnerWaq(env, agent_dict, log_dir=log_dir, device=agent_cfg.device)
else:
    runner = OnPolicyRunner(env, agent_dict, log_dir=log_dir, device=agent_cfg.device)
1
설정에 없으면 기본은 표준 runner.
2
Base 와 Oracle 은 rsl_rl 의 표준 runner 를 손대지 않고 그대로 씁니다.

이게 왜 좋은 설계인지 짚고 갑시다. 세 팔을 비교하려면 CENet 말고는 전부 같아야 합니다. 환경도, 보상도, PPO 하이퍼파라미터도, 심지어 학습 루프의 뼈대까지. OnPolicyRunnerWaq 는 표준 runner 를 상속해서 필요한 지점만 덮어씁니다. 덕분에 “이 차이가 CENet 때문인가 아니면 학습 루프가 달라서인가” 라는 질문이 애초에 생기지 않습니다.


3. 45짜리 환경에서 64짜리 actor 만들기

첫 번째 난관입니다. actor 신경망은 __init__ 때 입력 차원을 보고 만들어집니다. 그런데 환경에 물어보면 45 라고 답합니다 — 1차시에서 봤듯 관측을 64로 늘리는 건 환경의 일이 아니니까요. 그렇다고 CENet 을 먼저 만들어 둘 수도 없습니다. CENet 은 runner 안에 사니까요.

해법은 다소 뻔뻔합니다. 환경에게 잠깐 거짓말을 시킵니다.

# dreamwaq_runner.py  (OnPolicyRunnerWaq.__init__)
original_get_obs = env.get_observations

def _get_augmented_obs():
    obs = original_get_obs()
    policy_obs = obs["policy"]
    padding = torch.zeros(policy_obs.shape[0],
                          self.num_est_vel + self.num_context,
                          device=policy_obs.device)
    obs["policy"] = torch.cat([policy_obs, padding], dim=-1)
    return obs

env.get_observations = _get_augmented_obs
super().__init__(env, train_cfg, log_dir, device)
env.get_observations = original_get_obs
1
원래 메서드를 따로 보관.
2
3 + 16 = 19개의 0 을 뒤에 붙입니다. 값은 아무래도 좋고 차원만 맞으면 됩니다.
3
환경의 메서드를 갈아 끼웁니다.
4
부모 __init__ 이 관측을 물어보면 64가 나오고, 입력 64짜리 actor 가 만들어집니다.
5
바로 되돌립니다. 이후로 환경은 정상적으로 45를 내놓습니다.

솔직히 말하면 — 이건 영리한 해킹이자 부채입니다. rsl_rl 이 “actor 입력 차원을 직접 지정” 하는 방법을 열어 줬다면 필요 없었을 코드입니다. 이런 게 실제 연구 코드에는 늘 있고, 있다는 걸 알고 읽는 것과 모르고 읽다가 놀라는 것은 다릅니다.

부채인 이유: __init__ 안의 순서에 의존합니다. 5번 줄 전에 예외가 나면 환경의 메서드가 갈아 끼워진 채로 남습니다.

그리고 이 19개의 0 이 다음 절의 문제를 만듭니다.


4. 정규화 수술 — 이 파일에서 가장 중요한 결정

4.1 왜 자동 정규화기를 못 쓰나

2차시에서 actor 설정에 obs_normalization=True 가 있었습니다. rsl_rl 이 관측의 평균과 분산을 running 통계로 추적해서 표준화해 주는 기능입니다. 좋은 기능이고, Base 와 Oracle 은 그대로 씁니다.

그런데 CENet 팔에서는 이게 NaN 을 만듭니다. §3을 떠올려 보세요. 정규화기는 __init__ 때 64차원으로 만들어지는데, 그때 본 데이터는 뒤 19칸이 전부 0인 벡터였습니다. 분산이 0 입니다.

\hat x = \frac{x - \mu}{\sigma + \epsilon}, \qquad \sigma \approx 0

0에 가까운 수로 나누면 무슨 일이 일어나는지는 아시는 대로입니다.

4.2 그래서 셋으로 쪼갰다

# dreamwaq_runner.py  (OnPolicyRunnerWaq.__init__)
self.alg.actor.obs_normalization = False
self.alg.actor.obs_normalizer = torch.nn.Identity()
self.obs_rms = EmpiricalNormalization(shape=self.num_base_obs).to(device)
self.true_vel_rms = EmpiricalNormalization(shape=self.num_est_vel).to(device)
1
actor 의 내장 64차원 정규화기를 아무것도 안 하는 Identity 로 교체.
2
대신 base 45차원만 정규화하는 자체 통계를 둡니다.
3
속도 타겟 3차원도 따로 정규화합니다.

결과적으로 actor 입력 64는 이렇게 조립됩니다.

# dreamwaq_runner.py  (_build_actor_obs)
parts = [self._norm_base(base_obs), vel_input, context_vec]
if self.use_exteroception:
    parts.append(self._extract_extero(src_obs))
return torch.nan_to_num(torch.cat(parts, dim=-1),
                        nan=0.0, posinf=10.0, neginf=-10.0)
1
앞 45만 정규화, est_vel(3)context(16) 은 raw.
2
clamp 가 아니라 nan_to_num — 정상적인 큰 값은 그대로 통과시키고 NaN·inf 만 씻습니다.

왜 est_vel 과 context 는 정규화하지 않나 — 이미 정규화된 것이나 마찬가지이기 때문입니다. est_vel 은 정규화된 속도 공간에서 학습되고(§4.3), context 는 KL 이 \mathcal{N}(0,1) 쪽으로 당기고 있으니 애초에 표준정규 근처입니다. 두 번 정규화할 이유가 없습니다.

“그냥 전부 정규화 안 하면 되지 않나?” — 그러면 다른 문제가 생깁니다. 비교 대상인 Base 는 정규화된 45를 받는데 CENet 팔만 raw 45를 받으면, CENet 이 아니라 정규화 유무 때문에 생긴 차이를 측정하게 됩니다. 세 팔의 조건을 맞추려면 base 45는 반드시 정규화돼야 합니다.

4.3 obs_rms 는 세 곳에 동시에 걸린다

이게 놓치기 쉬운 부분입니다. 같은 obs_rms서로 다른 세 지점에서 쓰입니다.

flowchart LR
  RAW["환경 관측 45<br/>(raw)"] --> RMS{{"obs_rms"}}
  RAW --> BUF["히스토리 버퍼<br/>raw 로 저장"]
  BUF --> RMS
  RMS -->|"① CENet 입력"| ENC["인코더 225"]
  RMS -->|"② actor 앞 45"| ACT(["actor 입력 64"])
  RMS -->|"③ 재구성 타겟"| TGT["디코더가 맞힐 값"]
  EV["est_vel 3 · context 16<br/>(raw)"] --> ACT
  classDef n fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
  classDef raw fill:#eceff1,stroke:#546e7a;
  classDef out fill:#e8f5e9,stroke:#2e7d32;
  class RMS n; class RAW,BUF,EV raw; class ACT out;
Figure 2: obs_rms 하나가 세 곳에 걸린다 — 그래서 세 곳의 스케일이 자동으로 일치한다

① 인코더가 먹는 히스토리, ② actor 입력의 앞 45, ③ 디코더가 맞혀야 할 다음 관측 — 셋 다 같은 통계로 정규화됩니다. 이게 우연이 아니라 요구사항입니다.

특히 ①과 ③이 중요합니다. 인코더 입력과 디코더 타겟의 스케일이 다르면 재구성 손실이 “복원을 못 해서” 큰 건지 “단위가 달라서” 큰 건지 구별이 안 됩니다. 한때 타겟만 raw 로 저장해서 실제로 이 문제가 있었습니다.

true_vel_rms 도 같은 논리입니다. 속도 타겟을 정규화하면 est_vel정규화된 속도 공간에서 학습됩니다. 그러면 §6의 AdaBoot 이 “추정 속도” 와 “진짜 속도” 를 섞을 때 둘이 같은 단위라 그냥 바꿔치기해도 됩니다. 정규화를 안 했다면 스케일이 달라 섞을 수가 없습니다.


5. 히스토리 버퍼 — raw 로 저장하고 읽을 때 정규화한다

CENet 입력은 최근 5스텝입니다. 버퍼는 [num_envs, 5, 45] 짜리 링입니다.

# dreamwaq_runner.py  (_update_obs_history)
if dones is not None:
    reset_ids = dones.nonzero(as_tuple=False).squeeze(-1)
    if len(reset_ids) > 0:
        self.obs_history_buf[reset_ids] = 0.0

self.obs_history_buf[:, :-1] = self.obs_history_buf[:, 1:].clone()
self.obs_history_buf[:, -1] = obs_base
1
넘어져서 리셋된 환경은 히스토리를 0으로 지웁니다. 안 지우면 “넘어지기 직전 5스텝” 과 “리셋 직후 1스텝” 이 한 창에 섞여, CENet 이 존재하지 않는 전이를 학습하게 됩니다.
2
한 칸씩 왼쪽으로 밀고,
3
맨 뒤에 새 관측을 넣습니다. raw 상태로 넣는다는 점에 주목하세요.
# dreamwaq_runner.py  (_get_flat_obs_history)
return self.obs_rms(self.obs_history_buf).reshape(self.env.num_envs, -1)
Important왜 저장할 때가 아니라 읽을 때 정규화하나

obs_rms 의 통계는 학습 중에 계속 변합니다. 로봇이 처음에는 넘어지기만 하다가 나중에는 잘 걷게 되니, 관측의 분포 자체가 이동합니다.

정규화해서 저장하면 버퍼 안에 서로 다른 시점의 통계로 정규화된 프레임들이 섞입니다. 5스텝 창 안에서 스케일이 들쭉날쭉해지는 것입니다.

raw 로 저장하고 읽을 때 정규화하면 창 전체가 항상 같은(최신) 통계로 맞춰집니다. 통계가 바뀌면 과거 프레임까지 소급해서 다시 스케일링되는 셈입니다.

리셋 처리의 순서도 눈여겨볼 만합니다. 0으로 지운 다음 밀기 때문에 리셋된 환경의 창은 [0, 0, 0, 0, o_reset] 이 됩니다. 즉 “히스토리가 아직 안 쌓였다” 는 상태를 0으로 표현합니다.


6. 저장소 훅이 두 개인 이유

3차시에서 재구성 타겟이 다음 관측이라고 했습니다. 그 한 가지 사실이 저장소 구조를 바꿔 놓습니다 — 행동을 내는 시점에는 정답을 아직 모르기 때문입니다.

flowchart LR
  A["obs_rms.update<br/>히스토리 읽기"] --> B["cenet.before_action<br/>히스토리 + 진짜속도 저장"]
  B --> C["CENet forward<br/>est_vel · context"]
  C --> D["actor 입력 64 조립<br/>→ 행동 샘플"]
  D --> E["env.step"]
  E --> F["cenet.after_action<br/>다음 관측 저장 · step+=1"]
  F -.->|"다음 스텝"| A
  classDef h fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
  classDef n fill:#e3f2fd,stroke:#1976d2;
  class B,F h; class C,D n;
Figure 3: 한 스텝의 타임라인 — 정답이 미래에 있어서 훅이 둘로 갈린다
# dreamwaq_runner.py  (learn 루프 내부, 발췌)
base_obs = self._extract_base_obs(obs)
true_vel = self._extract_true_vel(obs)

self.obs_rms.update(base_obs)
obs_history = self._get_flat_obs_history()

self.true_vel_rms.update(true_vel)
true_vel = self.true_vel_rms(true_vel)

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

est_vel     = torch.clamp(est_vel, -10.0, 10.0)
context_vec = torch.clamp(context_vec, -10.0, 10.0)

if self.ada_boot:
    boot_mask = torch.rand(est_vel.shape[0], 1, device=est_vel.device) < self.boot_prob
    vel_input = torch.where(boot_mask, est_vel, true_vel)
else:
    vel_input = est_vel

augmented_actor_obs = self._build_actor_obs(base_obs, vel_input, context_vec, obs)
augmented_obs = obs.clone()
augmented_obs["policy"] = augmented_actor_obs
actions = self.alg.act(augmented_obs)

obs, rewards, dones, extras = self.env.step(actions)
next_base_obs = self._extract_base_obs(obs)
self.cenet.after_action(self._norm_base(next_base_obs))
self._update_obs_history(next_base_obs, dones)
1
진짜 선속도를 씬에서 직접 읽습니다scene["robot"].data.root_lin_vel_b. 3차시에서 예고한 그 지점입니다. obs["critic"][:, :3] 을 쓰면 CENet 팔에서는 각속도가 잡혀 엉뚱한 값을 학습합니다.
2
raw 관측으로 통계를 갱신한 다음 히스토리를 읽습니다(읽는 순간 정규화, §5).
3
속도 타겟도 자체 통계로 정규화 → est_vel 이 정규화 공간에서 학습됨(§4.3).
4
첫 번째 훅 — 히스토리와 정답 속도를 저장소에 넣습니다. 아직 step 은 안 올라갑니다.
5
CENet 출력을 [-10, 10] 으로 자릅니다. 초기 학습에서 폭주 방지.
6
AdaBoot — §7.
7
증강된 관측으로 PPO 가 행동을 샘플합니다. critic 은 자기 특권 관측을 그대로 씁니다.
8
두 번째 훅 — 이제서야 “다음 관측” 이 생겼습니다. 정규화해서 저장(§4.3의 ③), 그리고 여기서 step 이 1 올라갑니다.
9
히스토리 버퍼에 새 관측을 넣고, 끝난 환경은 지웁니다.

훅이 하나였다면 before_action 이 저장하고 step 을 올려 버려서, 다음 호출이 같은 칸을 덮어쓰게 됩니다. 비교해 보면 좋은 대비가 있습니다 — 3차시 §7의 estnet.py 는 속도만 맞히니 미래를 기다릴 필요가 없고, 훅이 하나뿐입니다.


7. AdaBoot — 자기 추정값으로 걷는 연습

7.1 문제

CENet 이 추정한 속도를 actor 에게 주는데, 학습 초반의 추정은 엉망입니다. 그렇다고 진짜 속도만 주면 actor 는 “정확한 속도” 에 익숙해져 버리고, 배포하면 부정확한 추정값을 받고 무너집니다.

그래서 섞습니다. 처음에는 진짜 속도를 주로 주다가, 점점 추정값의 비중을 올립니다.

# dreamwaq_runner.py  (learn 루프 상단, 매 iteration 1회)
ramp_iters = max(1.0, self.boot_ramp_frac * num_learning_iterations)
ramp = float(min(self.boot_max, (it - start_it) / ramp_iters))
self.boot_prob = max(self.boot_prob, ramp)
1
boot_ramp_frac = 0.5전체 학습의 절반에 걸쳐 램프를 올립니다.
2
boot_max = 0.9 에서 멈춥니다.
3
단조 증가. 이어서 학습할 때 체크포인트에서 복원한 값이 0으로 떨어지지 않게.

그리고 매 스텝, 환경마다 따로 동전을 던집니다.

boot_mask = torch.rand(est_vel.shape[0], 1, device=est_vel.device) < self.boot_prob
vel_input = torch.where(boot_mask, est_vel, true_vel)
Important

환경별 추첨이라는 점이 중요합니다. 예전에는 스칼라 하나를 뽑아 전체에 적용했습니다. 그러면 4096개 환경이 한 롤아웃 내내 전부 추정값 이거나 전부 진짜값 입니다. 배치 다양성이 0 이 되고, 정책은 “이번 롤아웃은 추정 모드” 같은 걸 학습해 버립니다.

그리고 상한 0.9 도 의도적입니다. 1.0 이면 어느 순간부터 진짜 속도가 영영 안 들어옵니다. 0.9 는 항상 10% 의 환경이 정답을 보게 해서, actor 가 “정확한 속도일 때 어떻게 걸어야 하는지” 를 잊지 않게 합니다.

7.2 논문과 다른 점 — 되먹임이 없다

원 논문의 규칙은 성능 기반입니다.

p_{boot} = 1 - \tanh\big(\mathrm{CV}(R)\big)

기호 풀이 — \mathrm{CV}(R): 보상의 변동계수(표준편차/평균), 즉 정책이 얼마나 불안정한가. 불안정하면 \mathrm{CV} 가 커지고 \tanh 가 1에 가까워져 p_{boot}떨어집니다 — 진짜 속도를 더 주는 쪽으로 되돌아갑니다.

이게 음의 되먹임입니다. 추정기가 망가지면 성능이 나빠지고, 성능이 나빠지면 자동으로 진짜 속도에 더 의존하게 됩니다. 시스템이 스스로 복구합니다.

이 구현의 램프에는 그게 없습니다. 시간만 보고 올라갑니다. 그래서 실제로 이런 일이 있었습니다 — CENet 이 iteration 140 쯤에 붕괴했는데 램프는 아무것도 모른 채 계속 올라가서, 1500 iteration 이후 actor 는 의미 없는 노이즈 19차원을 100% 받으며 학습했습니다.

미해결 항목으로 기록돼 있습니다. 상한을 0.9로 둔 것이 그나마 최악을 막아 주는 안전장치입니다.

7.3 런 길이 함정

이제 이 회차에서 가장 실용적인 교훈입니다. 램프 식을 다시 보세요.

\texttt{ramp\_iters} = 0.5 \times \texttt{num\_learning\_iterations}

램프의 기울기가 전체 학습 길이에 비례합니다. 그러면 같은 iteration 100 에서 boot_prob 이 런마다 달라집니다.

max_iterations iteration 100에서의 boot_prob
600 0.33
1500 0.13
4000 0.05
Importantmax_iterations 가 다르면 같은 설정·같은 seed 라도 다른 실험이다

“짧게 돌려서 빨리 확인하고, 좋으면 길게 돌리자” — 아주 자연스러운 방식이고, 여기서는 틀립니다. 600 iteration 짜리 셀에서 잘 되던 설정이 1500에서 무너질 수 있습니다. 설정이 나빠서가 아니라 커리큘럼이 달라졌기 때문입니다.

실제로 이것 때문에 결론 하나가 철회됐습니다(5차시 §7).

대처법: 짧게 확인하고 싶으면 --max_iterations목표 지평에 맞춰 걸어 놓고 중간에 끊으세요. 그래야 램프가 같은 기울기로 돌아갑니다.

세 런 길이의 boot_prob 곡선을 겹쳐 그립니다. 세로 커서를 끌어서 같은 iteration 에서 세 값이 얼마나 다른지 확인해 보세요.

가로축 iteration · 세로축 boot_prob(추정 속도를 쓸 확률) · 캔버스를 클릭하거나 끌어서 커서를 옮기세요

이렇게 해보세요 — ① “0–300 확대” 를 켜고 커서를 100 근처에 놓으면 0.33 / 0.13 / 0.05 가 읽힙니다. 6배 이상 차이입니다. ② 확대를 끄고 보면 셋 다 결국 0.9 에서 만납니다 — 끝은 같은데 초반이 전혀 다릅니다. 짧은 셀로 판정하면 안 되는 이유가 이 모양입니다. ③ boot_ramp_frac 을 1.0 으로 올리면 갈라짐이 더 심해집니다.


8. 한 반복의 순서

롤아웃이 끝나면 두 학습기가 차례로 돕니다.

# dreamwaq_runner.py  (learn, 롤아웃 종료 후)
mean_total, mean_vel, mean_recon, mean_kl = self.cenet.update()
loss_dict = self.alg.update()

loss_dict["cenet_total"] = mean_total
loss_dict["cenet_kl"]    = mean_kl
loss_dict["boot_prob"]   = self.boot_prob
loss_dict.update(self.cenet.diagnostics)
1
CENet 이 먼저 — 5 epoch × 4 minibatch. 끝나면 자기 저장소를 비웁니다.
2
그다음 PPO — 역시 5 × 4.
3
CENet 수치를 PPO 로그 dict 에 합쳐서 한 곳에 기록.
4
잠재 건강 진단 스칼라들(§10).

순서가 중요한가? 둘은 파라미터를 공유하지 않으므로(CENet 의 출력이 actor 입력으로 들어가긴 하지만, 그 값은 이미 롤아웃 때 저장된 상수입니다) 이 반복 안에서는 서로 영향을 주지 않습니다. 다만 CENet 이 먼저 갱신되면 다음 롤아웃부터 개선된 추정값이 나갑니다.


9. 추론 경로 — 배포할 때는 무엇이 달라지나

학습이 끝나고 정책을 돌려 볼 때는 get_inference_policy() 가 쓰입니다. 표준 runner 의 것을 쓸 수 없습니다 — 45차원을 그대로 64차원 actor 에 넣을 테니까요.

# dreamwaq_runner.py  (get_inference_policy)
self.obs_rms.eval()
self.true_vel_rms.eval()

def policy(obs, dones=None):
    base_obs = self._extract_base_obs(obs)
    self._update_obs_history(base_obs, dones)
    obs_history = self._get_flat_obs_history()
    _, est_vel, _, _, context_vec = self.cenet.forward(obs_history)
    est_vel     = torch.clamp(est_vel, -10.0, 10.0)
    context_vec = torch.clamp(context_vec, -10.0, 10.0)
    augmented = self._build_actor_obs(base_obs, est_vel, context_vec, obs)
    ...
1
정규화 통계를 동결합니다. 평가 중에 통계가 계속 움직이면 같은 체크포인트가 매번 다르게 행동합니다.
2
반환되는 것은 상태를 가진 함수입니다 — 히스토리 버퍼를 자기가 들고 있습니다. 그래서 dones 를 넘겨줘야 에피소드 경계에서 히스토리가 리셋됩니다.
3
before_action 이 아니라 forward — 저장소에 아무것도 쓰지 않습니다.
4
AdaBoot 이 없습니다. 배포에는 진짜 속도가 아예 없으니 항상 est_vel 입니다. 이게 원래 목표였던 상태이기도 합니다.
Important이 경로는 잠재를 샘플링합니다

위 코드는 cenet.forward 를 부르고, 그 안에서 context_vec재매개화로 샘플링된 값 입니다(3차시 §3). 즉 평가할 때마다 잠재가 조금씩 달라집니다. 같은 체크포인트로 play.py 를 두 번 돌리면 완전히 똑같이 걷지는 않습니다.

결정적으로 만들고 싶다면 샘플링 대신 mu 를 그대로 쓰면 됩니다. 어느 쪽이 맞느냐면 — actor 는 학습 내내 샘플링된 z 를 받았으므로(3차시 §2의 ③) 학습 분포에 충실한 것은 샘플링 쪽이고, mu 는 그 분포의 중심만 쓰는 것입니다. 보통 mu 쪽이 조금 더 안정적입니다.

중요한 것은 둘이 다르다는 사실 자체를 알고 있는 것입니다. 이 정책을 나중에 다른 런타임에 올릴 일이 있다면, 그쪽이 어느 방식을 쓰는지 반드시 맞춰 봐야 합니다.


10. 조용히 실패하는 법

이 회차에서 가장 중요한 경고입니다.

§4의 _build_actor_obs 를 다시 보세요.

return torch.nan_to_num(torch.cat(parts, dim=-1), nan=0.0, posinf=10.0, neginf=-10.0)

CENet 이 NaN 을 뱉으면 이 줄이 조용히 0으로 바꿉니다. 그러면 어떻게 될까요?

  • actor 입력 64 중 뒤 19칸이 전부 0 이 됩니다.
  • 앞 45는 멀쩡합니다.
  • 즉 정책은 “Base + 의미 없는 0 열아홉 개” 로 학습을 계속합니다.
  • 보상은? Base 만큼 나옵니다. 학습 곡선이 정상으로 보입니다.

실제로 있었던 일입니다. mean_reward 가 3000 iteration 에서 35.16 까지 정상적으로 올라간 런이 있었는데, 조사해 보니 CENet 텐서 14개가 iteration 500부터 전부 NaN 이었습니다. 그 런은 2500 iteration 동안 CENet 없이 학습한 것이었습니다.

Important

이건 3차시의 posterior collapse 보다 나쁩니다. 붕괴는 최소한 cenet_mu_abs 같은 진단 태그에 흔적이 남습니다. NaN 세탁은 아무 데도 안 남습니다 — 애초에 손실이 NaN 이 되면 로그도 NaN 이 되어야 할 것 같은데, actor 쪽에서 씻겨 나가 버리니 PPO 지표는 전부 멀쩡합니다.

처방은 정해져 있습니다 — 씻기 전에 한 번은 경고를 찍는 것. 다만 아직 반영되지 않았습니다.

일반화하면 — 방어 코드는 실패를 막는 게 아니라 숨깁니다. nan_to_num, clamp, try/except 로 감싼 자리는 전부 “여기서 뭔가 잘못돼도 아무도 모른다” 는 뜻입니다. 방어를 걸 때는 “몇 번 걸렸는지” 를 세는 카운터를 같이 두세요.


11. 체크포인트에 무엇이 들어가나

save() 가 저장하는 것은 actor/critic 가중치만이 아닙니다.

저장 항목 없으면 무슨 일이
cenet_state_dict CENet 가중치. 당연히 필요
cenet_optimizer_state_dict Adam 모멘텀. 없으면 재개 시 학습이 출렁임
cenet_beta annealing 을 켠 경우 \beta 가 0.35로 되돌아감
boot_prob 재개 시 AdaBoot 램프가 0부터 다시 시작 (§7.1의 max 가 이걸 막음)
obs_rms 정규화 통계. 없으면 배포·재개가 통째로 깨짐
true_vel_rms 같은 이유
Important

마지막 둘을 강조합니다. 정규화 통계는 학습된 파라미터만큼 중요합니다. 신경망은 정규화된 입력에 맞춰 학습됐는데 통계를 안 실어 보내면, 신경망은 완전히 다른 스케일의 입력을 받습니다. 가중치는 멀쩡한데 로봇이 넘어집니다.

체크포인트를 다른 곳으로 옮길 때 가중치만 챙기고 통계를 빠뜨리는 것이 흔한 실수입니다.


12. 진단 로그 — 무엇을 보고 있어야 하나

매 반복마다 기록되는 CENet 스칼라들입니다. 각 항이 5차시의 어떤 판정에 쓰이는지 를 같이 적어 둡니다.

로그 무엇을 판정하나
cenet_mu_abs \lVert\mu\rVert 평균 잠재가 살아 있나. 1e−3 대로 떨어지면 붕괴
cenet_sigma \sigma 평균 1.0 에 붙으면 사전분포로 되돌아간 것
cenet_kl_nats KL (nats) 0 에 붙으면 잠재를 안 쓰는 중
cenet_kl_active_dims 차원별 KL > 0.01 인 차원 수 16개 중 몇 개나 실제로 쓰나
cenet_dec_w_vel 디코더 1층의 \hat v 쪽 열노름
cenet_dec_w_z 디코더 1층의 z 쪽 열노름 붕괴 유형 ⓐ/ⓑ 를 가르는 값 (3차시 §6)
cenet_lr CENet 학습률 스케줄러가 얼마나 깎았나
cenet_beta 현재 \beta annealing 을 켰다면 확인용
boot_prob AdaBoot 확률 램프가 예상대로 가나 (§7.3)

cenet_vel · cenet_recon · cenet_kl 세 손실도 함께 찍힙니다. 3차시 §5.3에서 본 “KL 이 0으로 가는데 recon 이 오르는” 조합을 여기서 실시간으로 볼 수 있습니다.


13. 손으로 해보기

이 회차는 학습지 1장입니다. 짧지만, Base 와 CENet 팔을 가르는 바로 그 결합을 손으로 만듭니다.

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

학습지 — actor 관측 45 → 64 L2

목표: CENet 이 만든 값을 actor 입력에 실제로 꽂습니다. §4.2의 _build_actor_obs 본문입니다.

Base       actor 관측 45                                  ← 지형도 속도도 모른다
Oracle     actor 관측 48 = 45 + 실제 base_lin_vel(3)       ← 특권 정보를 그냥 받는다 (상한)
CENet 팔   actor 관측 64 = 45 + est_vel(3) + context(16)  ← CENet 이 추정한다

이 메서드는 롤아웃 루프에서 매 스텝 불립니다(learn() 안, self.alg.act() 직전).

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

검사 무엇을 잡나
평지에서 45+3+16 = 64 기본
앞 45만 정규화, 뒤 19는 raw — 순서까지 §4.2의 정규화 수술을 이해했나
rough 에서 height_scan 이 맨 뒤에 붙는지 조립 순서
NaN→0.0, +inf→10.0, −inf→−10.0 방어
큰 유한값이 그대로 통과
Important

마지막 검사가 이 학습지의 백미입니다. clamp(-10, 10) 으로 짜면 NaN 검사는 통과하는데 “큰 유한값 통과” 에서 걸립니다.

nan_to_numclamp 는 비슷해 보이지만 의미가 다릅니다 — 하나는 망가진 값만 씻고, 하나는 멀쩡한 값까지 자릅니다. §10에서 본 “조용한 실패” 와 직결되는 구분입니다.

채점기는 이 메서드를 클래스에서 떼어내 가짜 self 로 호출합니다. 그래서 Isaac Sim 없이 1초 만에 돕니다 — 대신 self 의 어떤 속성을 쓰는지가 그대로 드러나므로, 메서드가 실제로 무엇에 의존하는지 보게 됩니다.


14. 핵심 정리

  • 한 반복 = 하나의 롤아웃, 두 개의 옵티마이저. cenet.update() 가 먼저, 그다음 alg.update(). 둘 다 5 epoch × 4 minibatch.
  • runner 교체는 class_name 한 줄. Base/Oracle 은 표준 runner 를 그대로 써서, 세 팔의 차이가 CENet 하나로 좁혀진다.
  • 45차원 환경에서 64차원 actor 를 만들려고 get_observations 를 잠깐 갈아 끼워 0으로 패딩한다. 영리한 해킹이자 부채.
  • 그 0 패딩 때문에 자동 정규화기를 못 쓴다(분산 0 → NaN). 그래서 Identity 로 교체하고 base 45만 자체 obs_rms 정규화, est_vel·context 는 raw.
  • obs_rms 는 세 곳에 동시에 걸린다 — CENet 입력 · actor 앞 45 · 재구성 타겟. 덕분에 세 곳의 스케일이 자동으로 맞는다.
  • 히스토리 버퍼는 raw 로 저장하고 읽을 때 정규화한다. 통계가 학습 중에 움직이기 때문.
  • 재구성 타겟이 미래라서 저장소 훅이 둘이다. after_action 에서 step 이 올라간다.
  • AdaBoot 은 환경별 베르누이로 추정/진짜 속도를 섞고, 상한 0.9로 10% 폴백을 남긴다. 논문의 1-\tanh(\mathrm{CV}(R)) 와 달리 음의 되먹임이 없다.
  • 런 길이 함정: 램프가 max_iterations 에 비례하므로, max_iterations 가 다르면 같은 설정·같은 seed 라도 다른 실험이다.
  • NaN 은 조용히 씻겨 나간다. 학습 곡선이 정상인데 CENet 이 죽어 있을 수 있다. 방어 코드에는 카운터를 같이 달자.
  • 체크포인트에는 정규화 통계도 들어간다. 안 실으면 배포가 깨진다.
Note다음 차시 예고 — 5차시: 실험 설계와 결과 해석

이제 돌아갑니다. 그런데 돌아간 결과를 어떻게 믿을 것인가? 세 팔의 차이를 어떻게 재는지, 잠재가 죽었는지 가중치에서 어떻게 알아내는지, 그리고 서로 비교하면 안 되는 실험을 어떻게 구별하는지 — 숫자보다 읽는 법에 관한 회차입니다.