PPO 설계 — 비대칭 actor-critic과 탐험의 평형점

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

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

1차시 환경 위에서, 로봇이 명령 속도로 걷도록 정책을 학습시킵니다. 알고리즘은 PPO, 라이브러리는 rsl_rl입니다.

이 회차의 절반은 PPO 자체이고, 나머지 절반은 왜 이 값들인가 입니다. 하이퍼파라미터 하나가 어떻게 정책의 탐험 폭을 천장에 붙여 버렸는지, 그리고 그걸 어떻게 알아냈는지를 §5에서 끝까지 따라갑니다 — 이 시리즈에서 “설계” 라는 말이 무슨 뜻인지 보여 주는 절입니다.

Tip오늘의 목표
  1. 강화학습의 기본 개념부터 PPO-clipGAE직관으로 이해한다.
  2. PPO 설정(rsl_rl_ppo_cfg.py)을 코드로 읽고, 롤아웃→갱신 학습 루프를 따라간다.
  3. 왜 이 값인가 — 하이퍼파라미터 하나가 탐험을 천장에 못박은 사건을 끝까지 추적한다 (§5).

이 강의는 강화학습이 처음이어도 따라올 수 있게 직관 → 비유 → 수식 순서로 풉니다. 수식의 모든 기호는 바로 옆에서 풀어 설명합니다.


1. 강화학습과 PPO — 직관부터

강화학습이 뭔가요?

강화학습(RL)은 시행착오로 배우는 방식입니다. 강아지에게 간식으로 “앉아”를 가르치듯, 로봇 (에이전트)이 환경에서 여러 행동을 해 보고, 잘했을 때 보상을 받으며 점점 더 나은 행동을 익힙니다. 지도학습처럼 정답 라벨이 주어지는 게 아니라, 오직 보상이라는 점수만 있습니다.

매 순간 아래 고리가 돕니다 — 로봇이 상태(관측)를 보고 → 행동하면 → 환경이 보상과 다음 상태를 돌려줍니다. 이 고리를 수없이 반복하며 “어떤 상황에서 어떤 행동을 하면 점수가 큰지”를 배웁니다.

flowchart LR
  AG["에이전트 (정책 πθ)"] -->|"행동 a"| EN["환경 (IsaacLab)"]
  EN -->|"보상 r, 다음 상태 s'"| AG
  classDef ag fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
  classDef en fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
  class AG ag; class EN en;
Figure 1: 강화학습 루프 — 정책이 행동하고 환경이 보상·다음 상태를 돌려준다

정책 · 가치 · 어드밴티지

학습의 주인공인 정책(policy) \pi_\theta는 “상황을 보고 행동을 고르는 전략”입니다 (\theta는 신경망 파라미터). 걷기처럼 행동이 연속값일 때는 하나의 값이 아니라 확률분포를 출력합니다 — 가우시안(정규분포)으로 평균 \mu_\theta(s)와 표준편차 \sigma를 냅니다.

\pi_\theta(a\mid s)=\mathcal{N}\big(\mu_\theta(s),\,\sigma^2\big)

쉽게 말하면\mu는 “지금 상황에서 내 최선의 행동”, \sigma는 “얼마나 모험을 해 볼지”입니다. \sigma가 크면 다양한 행동을 시도(탐험)하고, 학습이 끝나갈수록 작아지며 확신에 찬 행동을 합니다.

행동을 평가하려면 두 개념이 더 필요합니다.

  • 가치 V(s) — “이 상황은 앞으로 받을 보상까지 따지면 얼마나 좋은가”. 즉 상황의 점수.
  • 어드밴티지 A(s,a)=Q(s,a)-V(s) — “그 상황에서 이 행동이 평균보다 얼마나 더 좋았나”. A>0이면 평소보다 잘한 행동, A<0이면 못한 행동. 이 값이 학습의 방향키입니다.

정책을 어떻게 개선하나 — 그리고 PPO가 푸는 문제

기본 아이디어는 한 문장입니다: 어드밴티지가 양수였던 행동은 더 자주, 음수였던 행동은 덜 하도록 정책을 살짝 민다. 문제는 얼마나 미느냐입니다. 한 번의 경험으로 정책을 너무 크게 바꾸면 학습이 무너집니다.

비유 — 한 판 이겼다고 전략을 통째로 갈아엎으면, 그게 운이었을 때 다음 판을 망칩니다. 조금씩 신중하게 바꿔야 합니다.

PPO는 이 “조금씩”을 강제합니다. 새 정책이 옛 정책보다 그 행동을 몇 배 더(혹은 덜) 하려는지를 확률비 r_t로 재고, 이 비율이 [1-\epsilon,\,1+\epsilon]를 벗어나면 더 이상 이득을 주지 않도록 잘라(clip) 버립니다. 즉 “한 번에 ε(=20%)까지만 바꿔”라는 안전벨트입니다.

r_t(\theta)=\frac{\pi_\theta(a_t\mid s_t)}{\pi_{\theta_{old}}(a_t\mid s_t)}, \qquad L^{CLIP} = \mathbb{E}\Big[\min\big(r_t A_t,\ \text{clip}(r_t, 1-\epsilon, 1+\epsilon)\,A_t\big)\Big]

기호 풀이 — r_t: 새/옛 정책의 확률비(1이면 그대로, 1.2면 20% 더 하려 함), \mathbb{E}[\cdot]: 모은 경험들의 평균, \text{clip}(\cdot): 값을 범위 안으로 가두는 함수, \epsilon=0.2: 허용 폭.

아래 그래프로 직접 확인해 보세요. 확률비 r_t가 clip 구간을 벗어나면 목적함수가 평평해져 (기울기 0 → 더 못 바뀜) 정책이 한 번에 크게 변하지 못합니다. 어드밴티지 부호와 \epsilon을 바꿔가며 회색 구간 밖에서 곡선이 꺾이는 지점을 보세요.

실선 = clip 목적함수 LCLIP, 점선 = clip 없는 r·A, 회색 띠 = clip 구간

어드밴티지는 어떻게 구하나 — GAE

앞서 어드밴티지가 방향키라고 했는데, “이 행동 덕분에 미래 보상이 평균보다 나았나”는 미래가 불확실해 추정하기 까다롭습니다. GAE는 한 스텝만 내다보는 추정(편향 적음·노이즈 큼)과 끝까지 보는 추정(노이즈 적음·편향 큼) 사이를 \lambda로 절충합니다.

먼저 한 스텝의 “예상보다 좋았던 정도”인 TD 잔차 \delta_t를 정의하고, 이를 미래로 갈수록 점점 작게 가중해 더합니다.

\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t), \qquad \hat A_t = \sum_{l\ge 0}(\gamma\lambda)^l\,\delta_{t+l}

기호 풀이 — \delta_t: 받은 보상 + 다음 상황 가치가 현재 가치보다 얼마나 컸나(=예상 대비 깜짝 정도), \gamma=0.99: 할인율(먼 미래 보상일수록 덜 중요하게), \lambda=0.95: 편향-노이즈 절충 손잡이.

최종 손실 — 세 가지를 함께

PPO가 실제로 최소화하는 손실은 세 조각의 합입니다.

L = \underbrace{L^{CLIP}}_{\text{① 정책 개선}}\;-\;c_1\underbrace{L^{VF}}_{\text{② 가치 정확도}}\;+\;c_2\underbrace{S[\pi_\theta]}_{\text{③ 탐험 유지}}, \qquad c_1=1.0,\ c_2=0.005

  • L^{CLIP} — 위에서 본 안전한 정책 개선.
  • L^{VF} — critic이 가치 V(s)를 정확히 맞히게(가치가 정확해야 ①의 어드밴티지도 정확).
  • S[\pi_\theta] — 정책의 엔트로피(무작위성) 보너스. 너무 일찍 한 행동만 고집하지 않도록 약간의 탐험을 남겨둠.

이 상수 \epsilon, c_1, c_2, \gamma, \lambda가 코드 설정에 그대로 들어갑니다 — 아래에서 직접 확인합니다.

c_2 는 코드에서 entropy_coef 라고 부릅니다. Isaac Lab 기본값은 0.01 인데 여기는 0.005 입니다. 절반으로 줄인 이 값이 이 회차 후반부(§5)의 주인공입니다 — 탐험 보너스를 얼마나 줄지가 왜 미묘한 문제인지, 그리고 이 값을 잘못 두면 무슨 일이 일어나는지를 봅니다.


2. 네트워크 설정 — 코드로 읽기

actor 와 critic 이 다른 입력을 받는 비대칭 구조이고(1차시 §3.4), 둘 다 [512,256,128] ELU MLP입니다.

# dreamwaq_manager/.../config/go2/agents/rsl_rl_ppo_cfg.py  (Go2BasePPORunnerCfg)
actor = RslRlMLPModelCfg(
    hidden_dims=[512, 256, 128],
    activation="elu",
    obs_normalization=True,
    distribution_cfg=RslRlMLPModelCfg.GaussianDistributionCfg(
        init_std=1.0,
        std_type="log",
    ),
)
critic = RslRlMLPModelCfg(
    hidden_dims=[512, 256, 128],
    activation="elu",
    obs_normalization=False,
)
1
은닉층 3개 512 → 256 → 128. actor 출력은 12(행동 평균), critic 출력은 1(상태가치).
2
actor 입력 정규화 ON — 관측 분포를 표준화해 학습 안정화 (단, CENet 팔은 4차시에서 이 정규화기를 Identity 로 갈아 끼웁니다. 이유가 꽤 재미있습니다).
3
가우시안 정책의 초기 표준편차 \sigma=1.0 — 초기 탐험 폭.
4
\sigmalog 스케일로 파라미터화 → 항상 양수 보장, 수치 안정.
5
critic은 정규화 OFF (Base). 입력이 특권 관측이라 분포가 안정적.
Actor Critic
입력 (Base / Oracle / CENet 팔) 45 / 48 / 64 rough 235 · flat 48
출력 12 (행동 평균) 1 (가치)

critic 차원의 구성은 1차시 §3.4에 정리돼 있습니다 — rough 는 45 + lin_vel(3) + height_scan(187), flat 은 45 + lin_vel(3). 평지에는 알려 줄 지형이 없어 critic 의 특권이 선속도 하나로 줄어듭니다. 이 사실이 나중에 “왜 평지에서는 CENet 이 별 이득이 없어 보이는가”를 설명합니다(5차시).

flowchart LR
  O1["Actor 관측<br/>45 / 48 / 64"] --> AM["MLP 512·256·128 ELU"] --> MU["평균 μ (12)"] --> DI["𝒩(μ, σ²)"] --> ACT(["행동 (12)"])
  O2["Critic 관측<br/>235 rough / 48 flat (특권)"] --> CM["MLP 512·256·128 ELU"] --> V(["가치 V (1)"])
  V -. "GAE 어드밴티지" .-> AM
  classDef a fill:#e8f5e9,stroke:#2e7d32;
  classDef c fill:#fce4ec,stroke:#c2185b;
  class O1,AM,MU,DI,ACT a; class O2,CM,V c;
Figure 2: 비대칭 actor-critic — critic의 정확한 가치가 어드밴티지로 actor 학습을 돕는다
Note

왜 비대칭인가? critic은 학습 중에만 쓰이므로 지형·외란 같은 특권 정보로 정확한 가치를 추정 → 더 좋은 어드밴티지 → actor 학습 가속. actor는 배포 가능한 관측만 받습니다.


3. PPO 알고리즘 설정 — 코드로 읽기

§1의 수식 상수들이 그대로 설정에 들어갑니다.

# rsl_rl_ppo_cfg.py  (Go2BasePPORunnerCfg.algorithm)
algorithm = RslRlPpoAlgorithmCfg(
    clip_param=0.2,
    value_loss_coef=1.0,
    use_clipped_value_loss=True,
    entropy_coef=0.005,
    gamma=0.99,
    lam=0.95,
    num_learning_epochs=5,
    num_mini_batches=4,
    learning_rate=1.0e-3,
    schedule="adaptive",
    desired_kl=0.01,
    max_grad_norm=1.0,
)
1
\epsilon=0.2L^{CLIP}의 clip 범위.
2
c_1=1.0 — 가치손실 가중치. clipped value loss도 사용 — 가치 갱신도 한 번에 크게 튀지 않도록 같은 방식으로 자릅니다.
3
c_2=0.005 — 엔트로피 보너스(탐험 유지). Isaac Lab 기본값 0.01의 절반. §5 전체가 이 한 줄에 관한 이야기입니다.
4
\gamma=0.99, \lambda=0.95 — GAE 상수.
5
한 롤아웃을 5 epoch × 4 mini-batch로 재사용해 갱신.
6
KL 기반 adaptive 학습률 — §3.4에서 규칙을 정확히 봅니다.
7
그래디언트 노름을 1.0으로 클리핑 → 폭주 방지.
# 학습 길이/주기 (같은 cfg)
num_steps_per_env = 24         # 갱신 전 롤아웃 길이
max_iterations = 5000          # cfg 기본값
clip_actions = 4.0             # 행동 클램프 ±4  ← 1.0 이 아니다
Important두 값은 반드시 함께 기억하세요

max_iterations — cfg 기본값은 5000이지만, 보고된 모든 실험은 CLI 로 --max_iterations 4000 을 걸어 돌렸습니다. “몇 번 도느냐”는 단순한 예산 문제로 보이지만, 4차시에서 이 값이 학습 초반의 커리큘럼 자체를 조용히 바꿔 버린다는 것을 보게 됩니다. 그래서 이 시리즈는 iteration 수를 항상 명시합니다.

clip_actions = 4.0 — 정책이 낸 행동을 \pm 4 로 자릅니다. 예전에는 이 값이 1.0 이었고, “큰 행동이 관절 한계를 넘어 학습이 붕괴하는 것을 막는다”는 설명이 붙어 있었습니다. 그 설명은 틀렸습니다. 1.0 은 붕괴를 막은 게 아니라 다른 문제의 원인이었고, 그걸 알아내는 과정이 §5입니다.

3.4 학습률을 스스로 조절하기 — 그리고 그 사이의 죽은 구간

schedule="adaptive" 한 줄이 하는 일을 정확히 봅시다. 학습률을 고정하지 않고, 정책이 한 갱신에서 얼마나 많이 변했는지를 재서 되먹임합니다. 그 척도가 KL 발산입니다.

# rsl_rl/algorithms/ppo.py — 매 미니배치마다
if kl_mean > self.desired_kl * 2.0:
    self.learning_rate = max(1e-5, self.learning_rate / 1.5)
elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0:
    self.learning_rate = min(1e-2, self.learning_rate * 1.5)
1
너무 많이 변했다 (KL > 0.02) → 학습률을 1.5로 나눔. 바닥은 1e-5.
2
너무 적게 변했다 (KL < 0.005) → 학습률에 1.5를 곱함. 천장은 1e-2.

비유 — 운전 중 속도계를 보며 액셀을 조절하는 것과 같습니다. 너무 빠르면(정책이 급변하면) 발을 떼고, 너무 느리면 밟습니다. 다만 속도계가 60~80 사이면 아무것도 하지 않습니다 — 매번 반응하면 오히려 출렁이기 때문입니다.

이 “아무것도 하지 않는” 구간이 데드존입니다. desired_kl = 0.01 기준으로:

측정 KL 동작
> 0.02 lr ÷ 1.5
0.005 \le \text{KL} \le 0.02 아무것도 안 함 ← 데드존
0 < \text{KL} < 0.005 lr × 1.5
= 0 아무것도 안 함 (아래 참조)

and kl_mean > 0.0 가드가 왜 필요한가 — KL 이 정확히 0 이면 “정책이 하나도 안 변했다”는 뜻이니 언뜻 학습률을 올려야 할 것 같습니다. 하지만 KL 이 정확히 0 이 되는 경우는 대개 정책이 죽었을 때(출력이 상수가 됐거나 NaN 가드에 걸렸을 때)입니다. 그때 학습률을 계속 1.5배씩 올리면 천장 1e-2 까지 밀어 올려 놓고, 정책이 되살아나는 순간 폭주시킵니다. 이 가드는 그걸 막습니다.

이 메커니즘은 §5에서 용의자를 지우는 데 쓰입니다 — rough 와 flat 의 학습률 궤적이 사실상 같았기 때문에(1.3e−4 ~ 3.9e−4), “adaptive lr 때문에 이상해진 것”이라는 가설을 실험 없이 배제할 수 있었습니다.


4. 학습 루프 — 코드로 읽기

OnPolicyRunner.learn()롤아웃 수집 → 어드밴티지 계산 → PPO 갱신을 반복합니다. 한 반복의 골격은 다음과 같습니다.

for it in range(max_iterations):
    for _ in range(num_steps_per_env):
        actions = alg.act(obs)
        obs, rewards, dones, extras = env.step(actions)
        alg.process_env_step(obs, rewards, dones, extras)
    alg.compute_returns(obs)
    loss_dict = alg.update()
1
롤아웃 수집: 24스텝 동안 4096개 환경에서 전이를 모음 (스텝당 4096개 샘플).
2
actor가 현재 관측에서 행동을 샘플(가우시안에서 추출).
3
환경 한 스텝 진행 — 1차시의 _pre_physics_step→물리→_get_rewards_get_observations 흐름.
4
전이를 storage에 기록하고 관측 정규화 통계를 갱신.
5
롤아웃 종료 후 GAE로 어드밴티지·리턴 계산(§1 수식).
6
PPO 갱신: 5 epoch × 4 mini-batch로 L = L^{CLIP} - c_1 L^{VF} + c_2 S 최소화.
flowchart LR
  S(["관측"]) --> ACT["actor.act<br/>행동 샘플"] --> ENV["env.step<br/>(4096 병렬)"] --> ST["전이 저장"]
  ST -->|"24스텝 미만: 반복"| ACT
  ST -->|"24스텝 완료"| GAE["compute_returns<br/>(GAE)"] --> UPD["update<br/>5ep × 4mb"] --> S
  classDef hot fill:#fff3e0,stroke:#f57c00;
  class GAE,UPD hot;
Figure 3: 한 학습 반복 — 24스텝 롤아웃을 모아 GAE로 어드밴티지를 구하고 PPO로 갱신

Base 와 Oracle 은 이 표준 runner 를 그대로 씁니다. CENet 팔만 커스텀 runner 로 갈아 끼우는데, 교체는 설정 한 줄로 끝납니다.

# rsl_rl_ppo_cfg.py
class_name = "OnPolicyRunnerWaq"

문자열 하나가 rsl_rl 의 기본 runner 대신 CENet 을 품은 runner 를 고릅니다. 이 한 줄이 정확히 무엇을 갈아 끼우는지 — 45차원 환경에서 어떻게 64차원 actor 가 나오는지 — 는 4차시에서 통째로 다룹니다. PPO 설정 자체는 세 팔이 완전히 동일합니다.


5. 탐험은 어디서 멈추나 — σ 천장

여기서부터가 이 회차의 본론입니다. 지금까지는 “설정이 이렇습니다” 였고, 지금부터는 “이 설정이 어떻게 틀렸고 어떻게 알아냈나” 입니다.

5.1 증상 — 똑같은 숫자 17.0273

정책은 가우시안입니다. 평균 \mu 를 내고 거기에 표준편차 \sigma 만큼 노이즈를 얹어 행동을 뽑습니다. \sigma탐험의 폭입니다. 학습이 진행되면 정책이 확신을 갖게 되면서 \sigma 는 자연스럽게 줄어들어야 합니다.

그런데 로그를 보니 Loss/entropy6개 런 전부에서 정확히 17.0273 이었습니다.

H = 12 \times \tfrac{1}{2}\ln(2\pi e) = 12 \times 1.41894 = 17.0273

기호 풀이 — 12는 행동 차원(관절 12개), \tfrac12\ln(2\pi e)표준편차가 정확히 1인 1차원 가우시안의 엔트로피. 즉 이 값은 \sigma = 1 일 때의 엔트로피입니다.

\sigma 의 상한은 log_std 클램프 [-5, 0] 이 걸어 둔 \sigma \le e^0 = 1 입니다. 정책이 학습 내내 탐험 폭의 천장에 눌려 있었다는 뜻입니다.

천장에 붙어 있던 비율
rough, 마지막 400 iteration 100%
rough, 전체 62 ~ 76%
flat 0% (한 번도 안 닿음)

로봇은 4000 iteration 내내 최대 진폭의 무작위 노이즈를 관절에 넣고 있었습니다. 그 노이즈에 비하면 CENet 이 추정해 준 3차원 속도 같은 건 묻혀 버립니다.

5.2 왜 멈추지 않았나 — 부력과 항력

비유\sigma 를 물속 물체의 깊이라고 합시다. 두 힘이 겹칩니다.

  • 엔트로피 보너스는 위로 미는 일정한 부력입니다. \sigma 가 크든 작든 세기가 같습니다.
  • 서로게이트 손실은 아래로 당기는 항력입니다. \sigma 가 커지면 행동이 엉망이 되어 보상이 떨어지니, 손실이 “\sigma 를 줄여라” 라고 밀어냅니다.

평형은 둘이 같아지는 깊이에서 잡힙니다. 그런데 항력이 어느 깊이부터 더 이상 세지지 않고 포화해 버린다면? 부력이 그 포화값보다 크면 물체는 수면까지 끝없이 떠오릅니다. 평형점이 아예 존재하지 않는 것입니다.

5.3 수식 — 부력의 세기는 정확히 c_2

가우시안 정책의 엔트로피는 H = \sum_i \log \sigma_i + \text{const} 입니다. 손실에는 +c_2 S 가 아니라 부호를 뒤집어 -c_2 H 로 들어가므로, \log\sigma_i 에 대한 기울기는

\frac{\partial(-c_2 H)}{\partial \log\sigma_i} = -c_2

상수입니다. \sigma 에 전혀 의존하지 않습니다 — 비유의 “일정한 부력” 이 이것입니다.

따라서 log_std 가 받는 전체 기울기 g_{total} 과 서로게이트가 주는 기울기 g_{surr} 사이에는

g_{surr} = g_{total} + c_2

가 정확히 성립합니다. 기호 풀이 — g_{total}: 실제로 옵티마이저에 전달되는 기울기(계측 가능), c_2: entropy_coef =0.005, g_{surr}: 엔트로피 보너스를 뺀 순수한 “성능 압력”. \sigma 가 평형에 있으려면 g_{total} = 0, 즉 g_{surr} = c_2 여야 합니다.

5.4 계측 — 균형점이 존재하지 않았다

DWQ_PROBE_STDGRAD=1g_{surr} 를 직접 재 봤습니다.

\sigma 0.195 0.266 0.426 1.000 1.574
g_{surr} 8.8e−4 1.42e−3 2.05e−3 2.19e−3 2.59e−3

\sigma 를 아무리 키워도 g_{surr}≈2.2e−3 근처에서 포화합니다. 그런데 c_2 = 0.005 입니다.

\max_\sigma g_{surr} \approx 2.2\times10^{-3} \;<\; c_2 = 5.0\times10^{-3}

항력이 부력보다 항상 작습니다. 그러니 평형점이 아예 없습니다. \sigma 는 클램프 천장까지 올라갈 수밖에 없었습니다 — 버그가 아니라, 설정이 만든 필연이었습니다.

c_2(수평선)와 g_{surr}(\sigma)(곡선)를 겹쳐 그립니다. 두 선이 만나는 곳이 평형 \sigma 입니다. entropy_coef 를 낮춰 수평선을 끌어내리면 어느 순간 교점이 생깁니다.

clip_actions

가로축 σ · 세로축 log_std가 받는 상승력 · 점 = 실제 계측값 5개 · 가로선 = 엔트로피 보너스 c₂ · 세로 점선 = log_std 클램프 천장(σ=1)

이렇게 해보세요 — ① 기본 상태(clip 1.0, c_2=0.005)에서 교점이 없는 것을 확인합니다. ② entropy_coef 를 0.002 아래로 내리면 교점이 생기고 평형 \sigma 가 나타납니다. ③ 다시 0.005 로 올린 뒤 clip_actions 를 4.0 으로 바꿔 보세요 — 곡선 전체가 올라오면서 \sigma^* \approx 0.37 에서 교점이 생깁니다. 처방이 두 개인 이유가 여기 있습니다.

곡선은 계측 5점을 이은 개형이지 시뮬레이션이 아닙니다. clip 4.0 곡선은 실측 균형점 \sigma^*=0.374 에 맞춰 배율을 역산한 것입니다.

5.5 진짜 원인 — 클리핑은 기울기에 보이지 않는다

g_{surr} 가 포화했을까요? clip_actions = 1.0 때문입니다.

행동은 wrapper 에서 \pm 1 로 잘립니다. 그런데 log-probability 와 엔트로피는 자르기 전의 가우시안에서 계산됩니다. 그래서 \sigma 를 1 너머로 키우면:

  • 실제 로봇에 가는 행동: 거의 안 변합니다(어차피 잘리니까) → 보상도 안 변함 → 항력 포화
  • 엔트로피 보너스: \log\sigma 에 비례해 계속 지급됩니다 → 부력 그대로

노이즈가 공짜가 된 것입니다. 정책 입장에서는 \sigma 를 키우는 게 순이득이니 천장까지 올립니다.

참고로 원본 legged_gym 은 이 값이 100.0, Isaac Lab 공식 예제는 None(클리핑 없음) 입니다. 1.0 은 이 저장소가 임의로 좁혀 놓은 값이었습니다.

5.6 처방 — 무엇을 고쳐야 하나

가설이 여러 개였으므로 하나씩 껐다 켜 봤습니다. rough Base, seed 42, 4096 env, 1500 iteration.

바꾼 것 \sigma 천장 비율 추종 성능 지형 레벨
p0 (기준) 0.9998 99.3% 0.5177 2.69
p1 entropy_coef 0.001 0.266 0% 0.5490 2.85
p5 entropy_coef 0.002 0.426 0% 0.5506 2.91
p3 entropy_coef 0 0.195 0% 0.5105 2.79
p2 클램프 천장 0 → 0.5 1.574 0%* 0.4960 2.55
p4 clip_actions 1.0 → 4.0 0.374 0% 0.7254 5.35

두 셀이 특히 중요합니다.

  • p3 — 엔트로피 보너스를 아예 없앴더니 오히려 더 나빠졌습니다(0.5105). 탐험은 필요합니다. 즉 “c_2 를 0으로” 는 답이 아니고, 내부 최적값이 존재합니다.
  • p2 — 천장을 \sigma \le 1.65 로 올렸더니 \sigma 가 1.574 까지 따라 올라갔습니다. 천장이 원인이 아니라는 것을 실험으로 반증한 셈입니다. 천장을 올리면 거기까지 올라갑니다.

그리고 p4 가 압도적입니다. 원인을 직접 제거하니 추종 성능이 0.52 → 0.73, 지형 커리큘럼이 2.69 → 5.35 로 뛰었습니다.

5.7 완주 확인

1500 iteration 짜리 진단을 4000 iteration 으로 완주시켜 확인했습니다(rough Base).

지표 이전 이후
천장에 붙은 비율 76.4% 0.0%
\sigma 1.0000 0.3665
Loss/entropy 17.027 4.752
추종 성능 0.5492 0.7868
속도 오차 error_vel_xy 0.4234 0.2293 (−46%)
성공률 0.6348 0.9288
몸통접촉 종료 0.0978 0.0481
지형 레벨 3.01 5.81

공짜는 아닙니다 — dof_torques_l2 가 4.7%, action_rate_l2 가 13% 늘었습니다. 더 크게 움직이니 더 힘을 씁니다. 참고로 1차시에서 본 action_rate · smoothness 페널티가 바로 이 비용에 값을 매기는 항입니다.

Important이 사건에서 가져갈 것 셋
  1. 스모크 테스트로 판정하지 마세요. 200 iteration 에서는 \sigma 가 아직 올라가는 중이라 아무 이상이 없어 보입니다. 이 지표는 완주 궤적으로만 보입니다.
  2. log_std 클램프 [-5, 0] 은 이제 NaN 가드일 뿐입니다. 원인을 고친 뒤로 평형이 \sigma^*=0.374 에 잡히므로 클램프에 닿지 않습니다. 그리고 이 클램프는 두 곳에 독립적으로 걸려 있습니다 — runner 의 옵티마이저 post-step 훅, 그리고 train.pyGaussianDistribution.update 를 통째로 갈아 끼우는 프로세스 전역 몽키패치.
  3. “증상을 누르는 처방”을 조심하세요. 옛 문서의 “±1 클리핑이 붕괴를 막는다”는 설명은 클램프 상한이 2.0(\sigma \le 7.4)이던 시절 이야기였습니다. 그때는 실제로 \sigma 폭주를 눌러 줬지만, 누른 것은 증상이었고 그 대가로 평형 자체를 없애 버렸습니다.

정직하게 덧붙이면 — g_{surr} 직접 계측은 rough 에서만 했습니다. flat 은 애초에 천장에 닿은 적이 없고 Loss/value 가 rough 의 1/7 이라는 방증뿐입니다.


6. 실행 — 학습과 평가

태스크 ID만 바꾸면 세 팔 모두 같은 스크립트로 학습합니다.

# 학습 (헤드리스, 4096 envs) — 보고된 실험은 전부 4000 iteration
python train.py --task=DreamWaQ-BaseDwq-Rough-PPO-v0 --headless \
    --num_envs=4096 --seed=42 --max_iterations=4000

# 세 팔 — 태스크 ID만 다르다
#   Base       DreamWaQ-BaseDwq-Rough-PPO-v0
#   Oracle     DreamWaQ-OracleDwq-Rough-PPO-v0
#   CENet 팔   DreamWaQ-Waq-Official-Rough-PPO-v0

# 빠른 스모크 테스트 (§5.7 참고 — 이걸로 성능을 판정하지 말 것)
python train.py --task=DreamWaQ-BaseDwq-Rough-PPO-v0 --headless \
    --num_envs=64 --max_iterations=30

# wandb 로깅 / 이어서 학습
python train.py --task=DreamWaQ-BaseDwq-Rough-PPO-v0 --headless --logger=wandb
python train.py --task=DreamWaQ-BaseDwq-Rough-PPO-v0 --headless \
    --resume --load_run=FOLDER_NAME --checkpoint=NUMBER

# 평가 (GUI)
python play.py --task=DreamWaQ-BaseDwq-Rough-PPO-Play-v0 \
    --load_run=FOLDER_NAME --checkpoint=model_3999.pt
Tip실전 노트
  • 환경 수: 4096 (RTX 4080 16GB 안정), 8192는 OOM.
  • log_std 클램프 [-5, 0]\sigma \in [0.007,\ 1.0]. §5.7 이후로는 NaN 가드 역할만 합니다.
  • 학습 시간: flat 약 30분, rough 약 2시간 45분 (4096 env / 4000 iteration 기준).
  • GPU 하나에 하나씩. 학습 중에 play/평가를 같이 띄우면 OOM 납니다.

7. 손으로 해보기

이 회차는 학습지 4장입니다. exercises/stage3_ppo/ 에 있고, PPO 를 직접 한 번 구현해 본 다음 그것이 rsl_rl 의 어디인지 짚는 순서입니다.

cd exercises/stage3_ppo/task01_gae
~/IsaacLab/_isaac_sim/python.sh check.py
~/IsaacLab/_isaac_sim/python.sh check.py --solution

학습지 1 — GAE 어드밴티지 L2

목표: “이 행동이 평균보다 얼마나 좋았나” 를 계산합니다. §1의 그 수식입니다.

\delta_t = r_t + \gamma V(s_{t+1})(1-d_t) - V(s_t), \qquad A_t = \delta_t + \gamma\lambda(1-d_t)A_{t+1}, \qquad \text{returns} = A + V

채울 곳: TODO(ppo-gae)compute_gae 본문

Important함정 셋
  1. 뒤에서 앞으로 훑어야 A_{t+1} 을 이미 알고 있습니다 (reversed).
  2. t = T-1 에서 V(s_{t+1}) 은 인자로 받은 last_value 입니다 — 롤아웃이 에피소드 중간에 잘렸으므로 거기서 부트스트랩합니다.
  3. done 인 스텝은 다음 상태로 이어지지 않으므로 (1-d_t)두 군데 끊습니다. 한 군데만 끊으면 채점기가 잡습니다.

통과 기준 8개 중 개념을 가르는 둘:

  • \lambda = 0 이면 1-스텝 TD 와 정확히 같아야 한다
  • \gamma = \lambda = 1 이면 몬테카를로와 정확히 같아야 한다

\lambda 가 무엇을 조절하는지 이해했다면 이 둘이 자동으로 맞습니다. 안 맞으면 \lambda 를 아직 “그냥 0.95인 상수” 로 알고 있는 것입니다.

학습지 2 — PPO 손실 함수 L2

목표: PPO 를 PPO 답게 만드는 clipped surrogate objective 를 만듭니다.

채울 곳: TODO(ppo-losses)

Important핵심은 clip 이 아니라 min 이다

clip 만 쓰면 안 됩니다. min(unclipped, clipped) 이어야 합니다.

상황 어떻게 되나
A>0, 확률비가 위로 벗어남 클립 좋은 행동이라도 한 번에 너무 밀지 않는다
A>0, 확률비가 아래로 벗어남 클립 안 함 되돌릴 여지를 남긴다
A<0, 확률비가 아래로 벗어남 클립
A<0, 확률비가 위로 벗어남 클립 안 함

min“항상 비관적인 쪽을 택한다” 는 뜻입니다. 위 §1의 인터랙티브 그래프에서 곡선이 한쪽만 평평해지는 것이 이 성질입니다.

통과 기준 10개 중 결정적인 것은 8번 — “A>0 이고 확률비가 아래쪽이면 클리핑되면 안 된다”. clip 으로 잘못 짜면 다른 9개는 통과하고 여기서만 걸립니다.

학습지 3 — 내가 짠 게 여기 이 클래스다 L0 · 읽기만

목표: 쓸 코드는 없습니다. 앞의 둘에서 직접 쓴 것이 rsl_rl어디인지 짚습니다.

내가 쓴 것 rsl_rl
compute_gae PPO.compute_returns
ppo_losses PPO.update 안쪽
(없음) desired_kl 적응형 학습률 → 학습지 4
train() 루프 OnPolicyRunner.learn

이 스크립트는 설명을 하드코딩하지 않습니다. 설치된 rsl_rl 소스를 inspect 로 직접 꺼내 내 코드와 나란히 출력합니다. 그래서 라이브러리 버전이 올라가면 출력도 따라 바뀝니다.

반드시 학습지 1·2를 끝내고 오세요. 직접 써 보지 않고 읽으면 남는 게 없습니다.

여기서 보게 되는 것 하나 — OnPolicyRunner.learn 은 79줄인데 그중 알고리즘은 3줄입니다. 나머지는 로깅과 체크포인트입니다. 그리고 4차시에서 갈아 끼우는 것이 정확히 그 3줄 주변입니다.

학습지 4 — 적응형 학습률 L1

목표: 학습지 3에서 “우리에겐 없다” 로 넘긴 것을 직접 구현합니다. §3.4의 그 규칙입니다.

채울 곳: TODO(ppo-cfg)adapt_learning_rate() 안의 빈칸 하나. clamp_log_std() 는 주어집니다(읽고 실행만).

통과 기준 14개 중 대부분이 데드존 경계값입니다 — 0.005 / 0.008 / 0.01 / 0.015 / 0.02 에서 각각 무슨 일이 일어나야 하는지. 부등호를 < 로 쓸지 <= 로 쓸지가 여기서 갈립니다.

그리고 kl_mean > 0 가드 — §3.4에서 본 그것입니다. 없으면 정책이 죽어 KL 이 0이 됐을 때 학습률을 천장까지 밀어 올립니다.

이 학습지의 clamp_log_std() 를 실행해 보면 §5의 log_std 폭주를 축소판으로 볼 수 있습니다. §5를 읽고 나서 다시 돌려 보면 클램프가 왜 “해결책” 이 아니라 “증상 억제” 였는지 감이 옵니다.

토이 환경 기준선 — 외워 두세요

학습지 1·2는 1차원 속도 추종 토이 환경에서 채점됩니다. 실제 태스크와 구조가 같습니다 (v \leftrightarrow 몸통 속도, v_{cmd} \leftrightarrow 명령, \exp(-4e^2) \leftrightarrow 추종 보상).

정책 점수
완벽한 추종 1.0000
가만히 서 있기 0.4426
무작위 0.5333

제대로 학습되면 0.44 → 약 0.95 로 40초 안에 올라갑니다.

Important

“가만히 서 있기” 가 무작위보다 나쁘지 않다는 데 주목하세요. 0.4426 은 바닥이 아니라 국소 최적입니다. 강화학습에서 로봇이 자주 빠지는 함정이 정확히 이것이고, 1차시 §5.5의 “몸통접촉 78%” 도, 1차시 학습지 5의 관측 스케일 버그도 같은 자리로 수렴합니다.

학습 곡선이 0.44 근처에서 평평해지면 하이퍼파라미터를 만지기 전에 보상이 움직임에 보상하고 있는지부터 확인하세요.


8. 핵심 정리

  • PPO-Clip(\epsilon=0.2)이 확률비를 잘라 안정적으로 갱신하고, GAE(\gamma=0.99,\lambda=0.95)가 어드밴티지를 추정한다.
  • 비대칭 actor-critic [512,256,128] ELU. critic 은 rough 235 / flat 48.
  • 손실 상수 c_1=1.0, c_2=0.005, clip_actions 4.0 — 이 두 값이 이 회차의 주인공.
  • 엔트로피 보너스의 기울기는 \sigma 와 무관한 상수 c_2 다. 서로게이트 기울기가 그보다 낮은 값에서 포화하면 평형점이 아예 없고, \sigma 는 클램프까지 올라간다.
  • 그 포화의 원인은 클리핑이 log-prob 에 보이지 않는 것이었다. clip_actions 를 4.0 으로 되돌리자 추종 0.55 → 0.79, 지형 레벨 3.0 → 5.8.
  • 학습 루프는 롤아웃(24스텝) → GAE → 5ep×4mb 갱신의 반복. 세 팔의 PPO 설정은 동일하고, CENet 팔만 class_name 한 줄로 runner 를 바꾼다.
Note다음 차시 예고 — 3차시: 왜 VAE인가

actor는 아직 선속도도 지형도 모릅니다. 고유수용성 히스토리만으로 이를 추정하는 CENet(VAE)forward/update를 코드로 읽습니다. 그리고 정면으로 묻습니다 — 속도만 필요하면 회귀 하나면 되는데, 왜 하필 VAE 인가?