4족 보행 로봇 & Isaac Lab — 관측을 무엇으로 채울 것인가

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

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

본 시리즈는 4족 보행 로봇 Unitree Go2가 험지를 걷도록 학습시키는 전 과정을, 실제 Isaac Lab 구현의 소스 코드와 함께 한 줄씩 따라갑니다.

시리즈가 다루는 알고리즘은 두 조각으로 되어 있습니다. 비대칭 actor-critic PPO — 학습 중에만 쓰는 critic 에게는 지형 같은 특권 정보를 주고, 배포될 actor 에게는 몸으로 느낄 수 있는 것만 주는 구조. 그리고 CENet(Context-aided Estimator Network) — 최근 관측 히스토리에서 지금의 선속도와 지형 문맥을 짜내는 VAE. 이 시리즈에서는 이 둘을 묶어 CENet + 비대칭 PPO 라고 부릅니다.

Note출처

이 시리즈가 읽는 구현은 I M. A. Nahrendra, B. Yu, H. Myung, “DreamWaQ: Learning Robust Quadrupedal Locomotion With Implicit Terrain Imagination via Deep Reinforcement Learning”, ICRA 2023 (arXiv:2301.10602) 의 아이디어를 Isaac Sim / Isaac Lab 위에 다시 올린 것입니다.

코드·태스크 ID·저장소 이름에는 원 구현의 이름이 그대로 남아 있습니다(dreamwaq_runner.py, OnPolicyRunnerWaq, DreamWaQ-Waq-Official-Rough-PPO-v0 등). 이 시리즈에서 알고리즘을 부를 때는 구성요소 이름 그대로 CENet + 비대칭 PPO 라고 씁니다 — 설명하려는 것이 “어느 논문의 재현” 이 아니라 왜 VAE 를 쓰고 PPO 를 왜 이렇게 설계했는가 이기 때문입니다.

원 논문과 의도적으로 다른 점 세 가지는 미리 밝혀 둡니다. 로봇이 A1 이 아니라 Go2, 환경 레시피가 논문 보상표가 아니라 Isaac Lab 공식 Go2 것(그래야 걸었습니다 — §7), AdaBoot 이 성능 피드백이 아니라 시간 기반 램프(4차시). 나머지 차이는 해당 절에서 그때그때 표시합니다.

Tip오늘의 목표
  1. 4족 로봇의 12 자유도(DoF)와 관측/행동/보상의 구조를 안다.
  2. 세 팔(45 / 48 / 64) 의 관측 설계를 비교하고, 왜 이 셋이 시리즈 전체의 비교축인지 안다.
  3. 관측 조립·보상 계산·PD 변환·도메인 randomization 코드를 직접 읽는다.

Isaac Sim / Isaac Lab 이 처음이라면0차시를 먼저 보세요. 세 층의 역할 분담, 스크립트의 3단 구조, 200 Hz 물리 / 50 Hz 정책, 그리고 직접 채우는 학습지 5장이 있습니다.

💡 회색 동그라미 번호(1)에 마우스를 올리면 해당 코드 줄의 설명이 보입니다.

Note강화학습으로 로봇을 가르친다는 것

복잡해 보이지만 결국 세 가지를 설계하는 일입니다 — 로봇이 무엇을 보는지(관측), 무엇을 하는지(행동), 무엇을 잘하면 칭찬하는지(보상). 오늘은 이 셋과, 이를 흔들어 강건하게 만드는 도메인 randomization을 IsaacLab 코드에서 만듭니다. (학습 알고리즘 PPO는 2차시에서 다룹니다.)


1. 4족 보행 로봇 Go2

Go2는 4개 다리 × 3관절 = 12 DoF입니다. 각 다리는 hip(좌우 벌림), thigh(앞뒤 휘두름), calf(무릎 굽힘)으로 구성되고 순서는 FL → FR → RL → RR입니다. 정책의 출력은 절대각이 아니라 기본 자세(default_dof_pos)에 더해지는 오프셋입니다.

# isaaclab_assets/robots/unitree.py  (UNITREE_GO2_CFG.init_state)
joint_pos={
    ".*L_hip_joint":     0.1,
    ".*R_hip_joint":    -0.1,
    "F[L,R]_thigh_joint": 0.8,
    "R[L,R]_thigh_joint": 1.0,
    ".*_calf_joint":    -1.5,
}
# 정책 순서로 펼치면 [FL_hip, FL_thigh, FL_calf, FR..., RL..., RR...]
#   [ 0.1, 0.8, -1.5,  -0.1, 0.8, -1.5,   0.1, 1.0, -1.5,  -0.1, 1.0, -1.5 ]
1
왼쪽·오른쪽 hip부호가 반대입니다 — 다리를 좌우 대칭으로 벌리기 때문.
2
앞다리(FL,FR)와 뒷다리(RL,RR)의 thigh 각도가 다릅니다(0.8 vs 1.0). 뒷다리를 조금 더 접은 자세.
3
calf 는 12개 전부 −1.5 — 무릎을 굽힌 기본 자세입니다.
flowchart TD
  B(["몸통 base"]) --> FL["FL 다리"] & FR["FR 다리"] & RL["RL 다리"] & RR["RR 다리"]
  FL --> h["hip<br/>(좌우 벌림)"] --> t["thigh<br/>(앞뒤)"] --> c["calf<br/>(무릎)"]
  classDef leg fill:#e3f2fd,stroke:#1976d2;
  classDef joint fill:#fff3e0,stroke:#f57c00;
  class FL,FR,RL,RR leg;
  class h,t,c joint;
Figure 1: Go2의 12 자유도 — 4개 다리 × 3관절 (모든 다리 동일 구조)

2. IsaacLab의 두 가지 환경 API

이 구현은 동일한 환경을 두 방식으로 작성해 비교할 수 있게 해 두었습니다.

ManagerBased (dreamwaq_manager) Direct (dreamwaq_direct)
핵심 클래스 ManagerBasedRLEnv DirectRLEnv
스타일 선언적 — @configclass로 항목 선언 명시적 — 메서드에 로직 직접 작성
관측/보상 Manager가 항목 단위로 조합 _get_observations() / _get_rewards()
장점 코드 짧음, 조합 쉬움 흐름 투명, 논문 코드와 1:1

두 구현은 알고리즘이 아니라 표현 방식만 다릅니다. 아래부터 같은 로직을 두 코드로 나란히 봅니다.


3. 관측 조립 — 코드로 읽기

이 구현은 비대칭 actor-critic입니다. Actor 는 실제 로봇이 몸으로 측정할 수 있는 것만 받고, critic 은 거기에 시뮬레이터만 아는 특권 정보를 더 받습니다. critic 은 학습이 끝나면 버려지므로 반칙이 아닙니다 — 왜 이게 학습에 도움이 되는지는 2차시에서 다룹니다.

flowchart LR
  subgraph P["고유수용성 (실로봇도 측정 가능)"]
    direction TB
    A1["ang_vel 3"]; A2["gravity 3"]; A3["commands 3"]
    A4["joint_pos 12"]; A5["joint_vel 12"]; A6["last_action 12"]
  end
  subgraph PR["특권 정보 (시뮬레이터만)"]
    direction TB
    B1["height_scan 187"]; B2["선속도 · 외란 등"]
  end
  P -->|"+노이즈"| ACT(["Actor 관측<br/>45"])
  P -->|"노이즈 없음"| CRI(["Critic 관측<br/>45 + 특권"])
  PR --> CRI
  classDef act fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
  classDef cri fill:#fce4ec,stroke:#c2185b,stroke-width:2px;
  class ACT act; class CRI cri;
Figure 2: 비대칭 관측 — 같은 고유수용성 정보에 특권 정보를 더해 critic만 지형을 본다

Actor 에는 height_scan(지형)과 lin_vel(선속도)이 없습니다 → 3차시 CENet 의 출발점.

critic 의 정확한 차원은 어느 레시피를 쓰느냐에 따라 다릅니다. 이 저장소에는 환경 레시피가 두 벌 있고 둘의 특권 항목 구성이 다릅니다 — 헷갈리기 쉬운 지점이라 §3.4에서 나란히 놓고 정리합니다. 아래 Direct 코드는 그중 폐기된 쪽(§7)의 구성입니다.

Direct: _get_observations() 한 메서드에서 조립

Direct 구현은 관측이 어떻게 만들어지는지 가장 투명하게 보여줍니다.

# dreamwaq_direct/.../dreamwaq_env.py
def _get_observations(self) -> dict:
    lin_vel_b = self._robot.data.root_lin_vel_b
    ang_vel_b = self._robot.data.root_ang_vel_b
    projected_gravity = self._robot.data.projected_gravity_b
    joint_pos_rel = self._robot.data.joint_pos - self._robot.data.default_joint_pos
    joint_vel = self._robot.data.joint_vel

    self._true_lin_vel_b = lin_vel_b.clone()

    if self.cfg.obs_noise:
        ang_vel_b = ang_vel_b + torch.empty_like(ang_vel_b).uniform_(-0.05, 0.05)
        projected_gravity = projected_gravity + torch.empty_like(projected_gravity).uniform_(-0.05, 0.05)
        joint_pos_rel = joint_pos_rel + torch.empty_like(joint_pos_rel).uniform_(-0.01, 0.01)
        joint_vel = joint_vel + torch.empty_like(joint_vel).uniform_(-0.075, 0.075)

    obs_parts = [ang_vel_b, projected_gravity,
                 self._commands[:, :3],
                 joint_pos_rel, joint_vel, self._actions]
    obs = torch.cat(obs_parts, dim=-1)

    state_parts = [true_ang_vel_b, true_gravity, commands_3,
                   true_joint_pos_rel, true_joint_vel, self._actions,
                   disturb_force, height_data]
    state = torch.cat(state_parts, dim=-1)

    observations = {"policy": obs}
    if self.cfg.state_space > 0:
        observations["critic"] = state
    return observations
1
몸통 좌표계의 선속도. actor 관측에는 들어가지 않습니다 — 실제 로봇은 측정이 어렵기 때문이며, 3차시 CENet이 이 값을 추정하게 됩니다.
2
관절각을 기본 자세 기준 상대값으로. 절대각이 아니라 “기본자세에서 얼마나 벗어났나”를 봅니다.
3
진짜 선속도를 따로 저장 — critic의 정답이자 3차시 CENet 학습의 정답으로 쓰입니다.
4
관측 노이즈: 센서 불확실성을 모사해 sim-to-real 강건성 확보. 값(±0.05 각속도, ±0.01 관절각 등)은 원본 IsaacGym 구현과 동일.
5
actor 관측 6개 항목을 순서대로 나열: ang_vel(3) + gravity(3) + commands(3) + joint_pos(12) + joint_vel(12) + actions(12).
6
torch.cat으로 45차원 한 벡터로 합칩니다 (Oracle은 lin_vel 추가 → 48).
7
critic은 노이즈 없는 값 + 특권 정보(disturb_force, height_data)를 씁니다.
8
이 레시피의 critic 관측은 45 + lin_vel(3) + disturb_force(3) + height_scan(187) = 238 입니다. 실제 실험이 쓰는 공식 레시피는 구성이 다릅니다disturb_force가 없어 rough 에서 45 + lin_vel(3) + height_scan(187) = 235. 두 레시피가 235 근처에서 겹쳐 보이지만 들어 있는 항목이 다릅니다(§3.4).
9
결과는 policy/critic 두 키의 dict. rsl_rl이 각각 actor/critic에 연결합니다.
Important

height_data 는 187개 ray가 측정한 로봇높이 − 지형높이[-1,1]로 클리핑한 값으로, critic은 지형을 “봅니다”. Actor에는 없으므로 험지에서 불리하고, 이것이 3차시 CENet의 동기입니다.

ManagerBased: 같은 관측을 “선언”으로

ManagerBased는 위 조립 과정을 직접 쓰지 않고 항목만 선언하면 Manager가 합쳐줍니다.

# dreamwaq_manager/.../velocity_env_cfg.py  (ObservationsCfg.PolicyCfg)
base_ang_vel      = ObsTerm(func=mdp.base_ang_vel,      noise=Unoise(-0.05, 0.05))
projected_gravity = ObsTerm(func=mdp.projected_gravity, noise=Unoise(-0.05, 0.05))
velocity_commands = ObsTerm(func=mdp.generated_commands, params={"command_name": "base_velocity"})
joint_pos         = ObsTerm(func=mdp.joint_pos_rel,     noise=Unoise(-0.01, 0.01))
joint_vel         = ObsTerm(func=mdp.joint_vel_rel,     noise=Unoise(-0.075, 0.075))
actions           = ObsTerm(func=mdp.last_action)
1
각 항목이 ObsTerm — 계산 함수와 노이즈를 선언만 합니다. Direct의 uniform_(-0.05, 0.05) 노이즈가 여기선 Unoise(...) 선언으로 표현됩니다.
2
선언 순서가 곧 concat 순서. Manager가 6개 항목을 자동으로 45차원으로 합칩니다 — Direct의 obs_parts + torch.cat과 동일한 결과.

3.4 세 개의 팔 — 하나의 알고리즘, 세 개의 관측

여기서부터가 이 시리즈 전체의 척추입니다. 앞으로 나오는 모든 비교는 이 표 하나로 환원됩니다.

같은 환경, 같은 PPO, 같은 보상. 바뀌는 것은 actor 가 무엇을 보느냐뿐입니다.

actor 입력 actor 가 추가로 받는 것 역할
Base 45 하한선. 몸으로 느낄 수 있는 것만. 실로봇에 그대로 올릴 수 있다
Oracle 48 진짜 선속도 3 (시뮬레이터가 알려줌) 상한선. 반칙이지만, “선속도를 알면 얼마나 잘 걷나”를 재는 자
CENet 팔 64 추정 선속도 3 + 문맥 16 (CENet 이 만듦) 측정 대상. 특권 정보 없이 하한→상한 사이 어디까지 가는가

세 팔의 차이가 곧 답입니다:

  • Oracle − Base = 선속도를 아는 것의 가치 = 회수 가능한 총량
  • CENet 팔 − Base = CENet 이 실제로 회수한 양

이 뺄셈을 어떻게 신뢰할 수 있게 만드는지가 5차시의 주제입니다.

flowchart LR
  BASE["고유수용성 45<br/>ang_vel 3 · gravity 3 · commands 3<br/>joint_pos 12 · joint_vel 12 · last_action 12"]
  BASE --> A1(["Base<br/>45"])
  BASE --> A2(["Oracle<br/>48"])
  BASE --> A3(["CENet 팔<br/>64"])
  TV["진짜 선속도 3<br/>(특권)"] --> A2
  CE["CENet<br/>est_vel 3 + context 16"] --> A3
  BASE -. "히스토리 5스텝 = 225" .-> CE
  classDef base fill:#e3f2fd,stroke:#1976d2;
  classDef low fill:#eceff1,stroke:#546e7a;
  classDef up fill:#fce4ec,stroke:#c2185b;
  classDef mid fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
  class BASE base; class A1 low; class TV,A2 up; class CE,A3 mid;
Figure 3: 세 팔 — 같은 45차원 고유수용성에서 출발해, actor 입력에만 차이를 준다

45가 무엇으로 채워져 있는지가 이 시리즈의 문제 설정입니다:

o_t = [\;\underbrace{\omega}_{3}\;,\; \underbrace{g}_{3}\;,\; \underbrace{c}_{3}\;,\; \underbrace{\theta}_{12}\;,\; \underbrace{\dot\theta}_{12}\;,\; \underbrace{a_{t-1}}_{12}\;] \in \mathbb{R}^{45}

기호 풀이 — \omega: 몸통 각속도(IMU 로 측정). g: 몸통 좌표계로 본 중력 방향(어느 쪽이 아래인지 = 자세). c: 사람이 준 명령 속도 (v_x, v_y, \omega_{yaw}). \theta,\dot\theta: 12개 관절의 각도와 각속도(엔코더). a_{t-1}: 직전에 자기가 낸 행동.

Important

여기 없는 것이 전부입니다. 선속도가 없고(IMU 가속도를 적분하면 드리프트로 못 씁니다), 지형이 없습니다(카메라·라이다를 안 씁니다). 로봇은 자기가 얼마나 빨리 가는지도, 앞이 계단인지도 모른 채 걸어야 합니다. 3차시의 CENet 은 이 두 개를 최근 5스텝의 몸 움직임에서 되찾아내려는 시도입니다.

critic 쪽 — 그리고 두 개의 서로 다른 235

critic 차원은 레시피마다 다릅니다. 헷갈리기 쉬우니 한 번만 정리하고 넘어갑니다.

레시피 critic 구성 상태
폐기된 레시피 (§7) 45 + lin_vel 3 + disturb_force 3 + height_scan 187 238 폐기
” (CENet 팔) 45 + disturb_force 3 + height_scan 187 235 폐기
공식 레시피 · rough 45 + lin_vel 3 + height_scan 187 235 실제 실험
공식 레시피 · flat 45 + lin_vel 3 48 실제 실험

두 개의 235는 같은 수가 아닙니다. 폐기 레시피 쪽은 disturb_force가 들어 있고 lin_vel이 빠진 235, 공식 레시피 쪽은 그 반대입니다. 저장소의 옛 문서와 새 문서를 같이 읽을 때 이 지점에서 반드시 한 번 막힙니다.

height_scan이 flat 에 없다는 점도 눈여겨볼 만합니다 — 평지에서는 지형을 알려 줄 게 없으니 critic 의 특권이 선속도 하나로 줄고, 그래서 평지에서는 Oracle − Base 격차 자체가 거의 없습니다(5차시). CENet 이 평지에서 별 이득이 없어 보이는 것은 CENet 이 무능해서가 아니라 회수할 것이 애초에 없기 때문입니다.


4. 행동 → PD 토크 — 코드로 읽기

정책 출력(12차원)은 관절 목표각 오프셋이고, PD 제어가 이를 토크로 바꿉니다. 핵심 수식은 다음 두 줄입니다.

q_{des} = q_{default} + \text{scale}\cdot a, \qquad \tau = K_p\,(q_{des}-q) - K_d\,\dot q

쉽게 말하면 — PD 제어는 관절에 용수철 + 완충기를 단 것과 같습니다. K_p(용수철)는 목표각 q_{des} 쪽으로 당기는 힘, K_d(완충기)는 너무 빨리 움직여 출렁이지 않게 잡아주는 힘입니다. 정책은 “목표각”만 정하고, 실제로 당기고 잡는 일은 PD가 합니다. (아래에서 직접 만져봅니다.)

Direct에서 앞 단계(목표각 계산)는 명시적으로 드러납니다.

# dreamwaq_direct/.../dreamwaq_env.py
def _pre_physics_step(self, actions):
    self._prev_prev_actions = self._prev_actions.clone()
    self._prev_actions = self._actions.clone()
    self._actions = actions.clone().clamp(-1.0, 1.0)
    self._processed_actions = (
        self.cfg.action_scale * self._actions + self._robot.data.default_joint_pos
    )

def _apply_action(self):
    self._robot.set_joint_position_target(self._processed_actions)
1
직전·직전전 행동을 보관 — 보상의 저크(jerk) 계산(§5)에 씁니다.
2
행동 클리핑 [-1, 1]. 없으면 큰 행동 → 관절 한계 초과 → 극단적 PD 토크 → 학습 붕괴.
3
위 수식의 q_{des}=q_{default}+\text{scale}\cdot a. action_scale=0.25(작은 값이 안정적).
4
목표각만 설정하면, \tau=K_p(q_{des}-q)-K_d\dot q 변환은 IsaacLab 액추에이터가 200 Hz로 수행합니다.

decimation=4 때문에 정책은 50 Hz로 목표각만 갱신하고, 그 사이 PD 제어는 200 Hz로 4번 토크를 계산합니다. 아래 애니메이션에서 굵은 칸(정책 추론)마다 그 뒤로 가는 세 칸의 PD 스텝이 따라옵니다.

정책
정책
정책
정책 추론 (50 Hz) PD 토크 (200 Hz) ← decimation = 4 →

Go2 게인은 로봇 설정에서 정해집니다 — 실제 실험이 쓰는 공식 값은 K_p=25,\ K_d=0.5 이고, 아래 폐기된 레시피만 K_p=28,\ K_d=0.7 로 덮어씁니다. 단일 관절 PD가 실제로 어떻게 진동을 잡는지는 아래 시뮬레이터에서 직접 만져봅니다.

# go2_base_cfg.py
self.scene.robot.actuators["base_legs"].stiffness = 20.0   # Kp
self.scene.robot.actuators["base_legs"].damping   = 0.5    # Kd

단일 관절 PD를 손으로 — 행동이 곧 목표각

행동(정책 출력)이 곧 PD 목표각이므로, 관절 하나의 PD를 직접 보면 12관절 전체가 한눈에 들어옵니다. 1자유도 회전 모터 J\ddot\theta + b\dot\theta = \tau 위에서:

  • P 제어 \tau = K_p(\theta_d-\theta) — 목표각에 용수철만 단 것. K_p를 키우면 빨라지지만 감쇠비 \zeta = b/(2\sqrt{K_p J})가 작아져 진동합니다.
  • PD 제어 \tau = K_p(\theta_d-\theta) - K_d\dot\theta완충기(K_d)를 더해 \zeta = (b+K_d)/(2\sqrt{K_p J}). 이제 K_p로 속도, K_d로 진동을 따로 잡습니다.

Go2의 12개 관절은 이 PD가 12벌 동시에 도는 것뿐입니다 (K_p{=}25,\ K_d{=}0.5 — Isaac Lab 공식 Go2 값이고, 실제 실험이 쓰는 값입니다. §7의 폐기된 레시피만 K_p{=}28,\ K_d{=}0.7로 덮어씁니다).

P / PD를 토글하고 K_p,\ K_d, 목표각을 움직여 응답을 관찰해 보세요.

목표 θ_d

90.0°

현재 θ

0.0°

오차 e

90.0°

제어 입력 τ

0.00 Nm

비례 Kp 2.0
미분 Kd 0.00
목표각 θ_d 90°

이렇게 해보세요 — ① P 모드에서 K_p를 1 → 4 → 8로 올리면 진동이 점점 심해집니다. ② PD 모드로 바꿔 K_p{=}8을 둔 채 K_d를 0 → 1.5로 올리면 같은 K_p에서도 진동이 사라집니다. ③ K_p만으로는 빠른 응답과 안정을 동시에 얻을 수 없다는 걸 체감하세요 — 그래서 K_d가 필요합니다.


5. 보상 계산 — 코드로 읽기

보상은 속도 추종(+) 2항 + 정규화 페널티(−) 11항 = 13개입니다. 핵심 수식은 추종 항입니다.

r_{track} = \exp\!\Big(-\frac{\lVert v_{cmd}-v\rVert^2}{\sigma}\Big), \qquad \sigma = 0.25

기호 풀이 — v_{cmd}: 명령 속도, v: 실제 속도, \sigma: 얼마나 너그럽게 볼지. 오차가 0이면 1점, 멀어질수록 0으로 부드럽게 떨어집니다. 원 논문은 이걸 \exp(-4e^2)로 적었는데 1/\sigma = 4, 즉 \sigma = 0.25로 같은 식입니다(Isaac Lab 은 std =\sqrt{0.25}=0.5로 씁니다).

왜 제곱오차를 그냥 빼지 않고 exp 안에 넣나-e^2를 보상으로 쓰면 크게 틀렸을 때 벌이 무한정 커져서, 로봇이 “일단 안 넘어지는 것”보다 “명령을 못 따라간 벌”을 더 무서워하게 됩니다. exp는 보상을 [0,1]로 가둬서, 아주 틀린 것과 조금 더 아주 틀린 것을 구별하지 않습니다. 덕분에 초반의 엉망인 정책도 다른 항(넘어지지 않기 등)을 먼저 배울 수 있습니다.

Direct는 13개 항을 _get_rewards()에서 직접 계산합니다.

# dreamwaq_direct/.../dreamwaq_env.py  (_get_rewards, 발췌)
dt = self.step_dt
lin_vel_error = torch.sum(torch.square(self._commands[:, :2] - lin_vel_b[:, :2]), dim=1)
track_lin_vel = torch.exp(-lin_vel_error / self.cfg.tracking_sigma) * self.cfg.rew_track_lin_vel_xy

jerk = self._actions - 2.0 * self._prev_actions + self._prev_prev_actions
smoothness = torch.sum(torch.square(jerk), dim=1) * self.cfg.rew_smoothness

torques = self._robot.data.applied_torque
joint_power = torch.sum(torch.abs(torques) * torch.abs(self._robot.data.joint_vel), dim=1) \
              * self.cfg.rew_joint_power

rewards = {"track_lin_vel_xy": track_lin_vel * dt,
           "smoothness": smoothness * dt,
           "joint_power": joint_power * dt, ...}
total_reward = torch.sum(torch.stack(list(rewards.values())), dim=0)
1
한 스텝 시간(step_dt). 모든 보상에 곱해 시간 정규화합니다.
2
명령 속도와 실제 속도의 제곱오차.
3
위 수식 그대로: \exp(-\text{error}/\sigma)에 가중치(+1.0)를 곱함. 오차 0이면 1, 멀어질수록 0으로 부드럽게 감소.
4
저크 = 2차 차분 a_t - 2a_{t-1} + a_{t-2}. §4에서 보관한 직전 행동들이 여기 쓰입니다. 급격한 행동 변화를 벌함.
5
관절 출력 \sum|\tau||\omega| — 에너지 효율 페널티.
6
각 항에 * dt를 곱해 dict에 모음.
7
모든 항을 합산 → 스텝당 총 보상.
Note

ManagerBased는 동일 항목을 RewTerm(func=..., weight=...)로 선언하고, mdp/rewards.py에 함수가 있습니다 (예: joint_power_l1, action_smoothness_l2). * dt도 Manager가 자동 처리 — 수학적으로 Direct와 동일합니다.

5.3 보상 레시피가 두 벌인 이유

여기서 이 저장소의 가장 큰 설계 결정을 짚고 가야 합니다. 보상 레시피가 두 벌 있고, 논문에 충실한 쪽이 폐기되었습니다.

flowchart LR
  IDEA["핵심 아이디어<br/>비대칭 actor-critic + CENet"]
  IDEA --> R1["논문 보상 레시피<br/>12항, 가중치까지 일치"]
  IDEA --> R2["Isaac Lab 공식 Go2 보상<br/>+ 논문 4항 복원"]
  R1 --> F1(["걷지 못함<br/>몸통접촉 종료 78%"])
  R2 --> F2(["걷는다<br/>실제 실험 대상"])
  classDef bad fill:#ffebee,stroke:#c62828,stroke-width:2px;
  classDef good fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
  classDef neu fill:#e3f2fd,stroke:#1976d2;
  class IDEA neu; class R1,F1 bad; class R2,F2 good;
Figure 4: 보상 레시피 두 벌 — 논문에 충실한 쪽은 걷지 못해 폐기되었다

지금 보고되는 모든 결과는 논문 보상 함수가 아니라 Isaac Lab 공식 보상 함수로 학습된 것입니다. 핵심 아이디어(비대칭 actor-critic + CENet)만 남기고 환경 레시피는 공식 것을 쓴다는 의도적 선택이고, 이유는 단순합니다 — 그래야 걸었기 때문입니다.

공식 레시피에 논문의 4항(joint_power, base_height, foot_clearance, smoothness)을 되살린 뒤에도 남은 차이는 다섯 줄뿐입니다.

논문 공식 레시피 (실제 학습) 성격
선속도 추종 1.0 1.5 가중치 1.5배
각속도 추종 0.5 0.75 가중치 1.5배
자세(orientation) −0.2 rough 0.0 / flat −2.5 rough 는 아예 없음, flat 은 12.5배
dof_torques_l2 없음 −2e−4 공식 레시피가 추가
feet_air_time 없음 +0.01 공식 레시피가 추가
power_distribution −1e−5 (표기) 0.0 실측 후 의도적 제외 ↓

Isaac Lab 의 RewardManager.computeweight == 0.0 인 항을 아예 건너뜁니다. 그래서 rough 의 자세 항 0.0 은 “약한 가중치”가 아니라 항의 부재입니다.

power_distribution 을 0.0 으로 둔 것은 추측이 아니라 실측 결과입니다. 같은 조건에서 가중치만 바꿔 1500 iteration 씩 돌린 결과가 단조로웠습니다:

가중치 underlying 추종 지형 레벨
0 0.750 5.56
−1e−6 (저자 IsaacGym 값) 0.707 5.45
−1e−5 (논문 표 인쇄값) 0.626 4.61

논문 표의 인쇄값이 가장 나빴습니다. 참고로 저자의 legged_gym 설정은 −1e−6 이고 같은 블록의 나머지 11항은 논문 표와 소수점까지 일치하므로, 표의 −1e−5 는 오타로 판단됩니다.

5.4 보상 하나가 4096개 환경을 죽인다 — base_heightinf

보상 가중치를 만지기 시작하면 반드시 한 번은 밟는 함정이라 여기서 짚습니다.

base_height 항은 “몸통이 지면에서 목표 높이만큼 떠 있는가”를 재는데, 지면 높이를 RayCaster 로 잽니다. 문제는 아무것도 맞히지 못한 ray 는 inf 를 기록한다는 것입니다. 로봇이 지형 메시 밖으로 나가면 그 순간:

\texttt{inf 보상} \;\longrightarrow\; \texttt{inf 리턴} \;\longrightarrow\; \texttt{NaN 그래디언트} \;\longrightarrow\; \text{정책 사망}

그런데 화면에는 아무것도 안 나옵니다. 학습은 계속 돌고, 로그도 찍히고, 정책만 죽어 있습니다. NaN 가드가 log_std 를 천장에 고정시켜 버려서 로봇은 최대 진폭의 노이즈만 내며 1500 iteration 을 낭비합니다. 계측 런이 잡아낸 실제 사건은 iteration 189 의 base_height = inf 한 번이었습니다.

Important

같은 inf 를 관측 쪽은 견뎠습니다. height_scan 관측에는 clip=(-1,1) 이 걸려 있어 inf 가 1 로 잘립니다. 똑같은 센서를 읽는데 관측에는 가드가 있고 보상에는 없었던 것이 차이의 전부입니다. 그래서 이 저장소는 Isaac Lab 기본 base_height_l2 를 쓰지 않고 base_height_l2_safe — ray hit 을 nan_to_num 으로 씻고 평균 내는 판 — 을 씁니다.

교훈: 센서를 읽는 보상 항에는 관측과 같은 수준의 가드를 걸어라. 보상은 관측보다 훨씬 조용하게 실패합니다.

5.5 실패는 이렇게 생겼다

논문 보상 레시피가 “걷지 못했다”는 게 구체적으로 어떤 숫자인지 보고 넘어갑시다. 5000 iteration 을 완주한 뒤의 로그입니다.

Important논문 레시피, 5000 iteration 후
지표 정상이라면
평균 보상 −7.03 +20 ~ +35
평균 에피소드 길이 331.7 / 1000 ~950 이상
base_contact 종료 비율 0.78 0.05 ~ 0.10
선속도 추종 보상 0.073 0.7 ~ 1.4

에피소드 10번 중 8번이 몸통이 땅에 닿아서 끝났습니다. 로봇은 걷는 법을 배운 게 아니라, 넘어지는 속도를 조금 늦추는 법을 배웠습니다.

이게 이 시리즈가 공식 env 레시피 위에서 진행되는 이유이자, 다음 차시의 출발점입니다 — 보상을 논문대로 맞췄는데 안 걷는다면, 문제는 보상표가 아니라 그 위에서 도는 최적화입니다.


6. 도메인 Randomization — 코드로 읽기

sim-to-real 격차를 줄이는 핵심입니다. 매 환경마다 물리 특성을 흔들어 과적합을 막습니다. Direct는 원본 IsaacGym과 1:1 대응하도록 메서드로 직접 구현합니다.

# dreamwaq_direct/.../dreamwaq_env.py  (마찰 randomization)
def _randomize_friction(self):
    materials = wp.to_torch(self._robot.root_physx_view.get_material_properties())
    bucket_friction = torch.empty(self.cfg.friction_num_buckets,
                                  device=materials.device).uniform_(0.2, 1.25)
    bucket_ids = torch.randint(0, self.cfg.friction_num_buckets, (self.num_envs,))
    per_env_friction = bucket_friction[bucket_ids]
    materials[..., 0] = per_env_friction.unsqueeze(-1)   # static friction
    materials[..., 1] = per_env_friction.unsqueeze(-1)   # dynamic friction
    self._robot.root_physx_view.set_material_properties(...)
1
현재 물리 머티리얼 텐서를 가져옴 (env × shape × [static, dynamic, restitution]).
2
마찰값 후보 64개(bucket)[0.2, 1.25]에서 미리 뽑음 — 매 shape마다 난수를 뽑지 않고 후보를 재사용(원본 방식).
3
각 환경에 bucket 하나를 무작위 배정.
4
정적·동적 마찰을 env별로 덮어씀.
5
바뀐 머티리얼을 시뮬레이터에 다시 기록.

다른 randomization도 같은 패턴입니다.

메서드 시점 흔드는 대상 범위
_randomize_friction startup 마찰 0.2 ~ 1.25
_randomize_base_mass startup 몸통 질량 −1 ~ +2 kg
_randomize_com startup 질량중심 ±0.05 m
_randomize_pd_gains startup PD 게인 ×0.9 ~ ×1.1
_push_robots 1초마다 속도 외란 ±1.0 m/s

_push_robots는 외란값을 self._disturb_force에 저장하고, 이 값이 §3의 critic 관측 disturb_force(3)로 들어갑니다.

def _push_robots(self):
    self._disturb_force = torch.empty(self.num_envs, 3, device=self.device).uniform_(-max_v, max_v)
    full_vel = self._robot.data.root_vel_w.clone()
    full_vel[:, :3] += self._disturb_force
    self._robot.write_root_velocity_to_sim_index(root_velocity=full_vel)
1
무작위 속도 충격을 뽑아 저장 — critic 관측으로 노출하기 위함.
2
현재 속도에 충격을 더해 시뮬레이터에 기록 → 로봇이 “툭” 밀림.

ManagerBased는 같은 일을 EventCfgEventTerm으로 선언합니다(mode="startup"/"interval").

6.3 공식 레시피에 randomization을 되살리기

§5.3에서 본 것처럼 실제 실험은 Isaac Lab 공식 env 레시피 위에서 돕니다. 그런데 공식 레시피는 randomization 을 훨씬 적게 합니다. 보상과 달리 여기엔 논문 대 구현의 이견이 없어서 — 논문 Table II 와 저자 코드가 모든 행에서 일치합니다 — 빠진 것을 그대로 되살렸습니다.

항목 되살린 값 공식 레시피에는
적재 하중(payload) 가산 [-1, +2] kg 곱셈 \times[0.8, 1.25]
K_p / K_d 계수 \times[0.9, 1.1], 환경·관절별 없음
모터 출력 \times[0.9, 1.1], 환경별 없음
질량중심 이동 \pm 50 mm, 모든 링크 몸통만
마찰 [0.2, 1.25] 고정값

두 가지는 일부러 켜지 않았습니다:

  • 밀치기(push)는 기본 OFF. 논문은 1초마다 \pm 1.0 m/s 로 미는데, 켜면 학습이 눈에 띄게 느려집니다(수치는 5차시). 기각이 아니라 예산 문제 — 4000 iteration 으로는 지형 커리큘럼이 포화에 못 닿습니다. DWQ_PAPER_PUSH=1 로 켤 수 있습니다.
  • 시스템 지연(system_delay)은 Manager 쪽에 미구현. Direct 쪽에는 있습니다.
Important

randomization 은 성능을 깎습니다. 그게 정상입니다. 위 항목들을 켜면 같은 조건에서 추종 성능이 눈에 띄게 떨어집니다. 그래도 켜는 이유는 이 정책이 결국 실로봇으로 나가야 하기 때문입니다. 시뮬레이터 안에서만 잘 걷는 정책은 아무 값어치가 없습니다.

그래서 randomization 을 켠 실험의 판정 기준은 “점수가 얼마나 떨어졌나” 가 아니라 “여전히 걷는가, 그리고 지형 커리큘럼이 계속 오르는가” 입니다. 이 기준을 어떻게 읽는지가 5차시의 내용입니다.


7. Go2 설정과 태스크 등록

기본 환경을 상속해 Go2 설정을 얹고 Gymnasium에 등록합니다. §3.4의 세 팔이 여기서 태스크 ID 가 됩니다 — 그런데 등록된 계열이 두 벌이라, 어느 쪽이 실제 실험인지 헷갈리기 쉽습니다.

실제 실험 축 — §5.3의 공식 env 레시피. 지형(Flat/Rough)마다 하나씩, 총 6개입니다.

태스크 ID Actor 역할
Base DreamWaQ-BaseDwq-{Flat,Rough}-PPO-v0 45 하한선
Oracle DreamWaQ-OracleDwq-{Flat,Rough}-PPO-v0 48 상한선
CENet 팔 DreamWaQ-Waq-Official-{Flat,Rough}-PPO-v0 64 측정 대상

각각 -Play-v0 변형이 함께 등록돼 있습니다(학습된 정책을 눈으로 보는 용도).

Important

폐기된 계열도 여전히 등록돼 있습니다DreamWaQ-Manager-Go2-{Base,Oracle,Waq}-v0. §5.3·§5.5의 논문 보상 레시피가 이쪽이고, 걷지 못합니다(몸통접촉 종료 78%). 참고용으로 남겨 둔 것이지 비교축이 아닙니다. 저장소를 처음 열고 DreamWaQ-Manager-Go2-Base-v0 를 돌리면 “왜 안 걷지?” 로 며칠을 쓰게 되므로, -PPO-v0 로 끝나는 쪽을 쓰세요.

아래 코드는 폐기된 계열의 설정이지만, critic 그룹을 어떻게 갈아 끼우는지를 가장 짧게 보여주기 때문에 그대로 읽습니다.

# go2_waq_cfg.py — Base를 상속해 critic 그룹만 추가
class Go2WaqEnvCfg(Go2BaseEnvCfg):
    def __post_init__(self):
        super().__post_init__()
        self.observations.critic = ObservationsCfg.CriticCfg()
        self.observations.critic.base_lin_vel = None
        self.observations.policy.enable_corruption = False
1
비대칭 학습용 critic 관측 그룹을 추가. 환경이 하는 일은 여기까지입니다.
2
이 팔의 critic 은 lin_vel 을 뺍니다 → 235차원. §3.4의 표에서 본 폐기 레시피 쪽 235 입니다(disturb_force 가 들어 있는 그것).
3
관측 노이즈를 끔. 그리고 중요한 것 — actor 를 45→64로 확장하는 일은 환경이 하지 않습니다. 환경은 끝까지 45차원만 내놓고, 그 앞에 CENet 을 끼워 64로 부풀리는 것은 4차시의 커스텀 runner 입니다. 이 분업이 4차시 전체의 주제입니다.

8. 손으로 해보기

읽는 것만으로는 안 붙습니다. 이 회차 범위의 학습지는 6장이고, 전부 exercises/stage2_env/ 에 있습니다.

시뮬레이터 자체가 처음이라면 0차시의 학습지 5장(빈 씬 → 스폰 → PD → 지형 → 센서)을 먼저 채우고 오세요. 아래 과제들은 그 위에서 시작합니다.

cd exercises/stage2_env/reward-joint-power
~/IsaacLab/_isaac_sim/python.sh check.py             # 내 답 채점
~/IsaacLab/_isaac_sim/python.sh check.py --solution  # 모범답안으로 확인

0차시와 달리 여기서는 Isaac Sim 을 띄우지 않습니다. 합성 텐서로 1초 안에 채점되므로 빠르게 여러 번 시도할 수 있습니다. 채점기는 Isaac Sim 번들 파이썬으로 돌리세요 — 시스템 python3 에는 torch 가 없습니다.

권장 순서는 아래 그대로입니다. 보상 4장으로 몸을 풀고, 관측·종료 2장으로 마무리합니다.

학습지 1 — 관절 파워 페널티 L1

목표: 로봇이 에너지를 낭비하지 않게 만드는 항. §5의 13개 보상 중 하나입니다.

r = \sum_j |\tau_j| \cdot |\dot\theta_j|

채울 곳: TODO(reward-joint-power)joint_power_l1 본문

읽을 것
asset.data.applied_torque 실제로 관절에 가해진 토크
asset.data.joint_vel 관절 각속도
asset_cfg.joint_ids 대상 관절 (전체가 아닐 수 있다)

통과 기준 6개 — 반환이 (num_envs,) · 토크 1·각속도 1·관절 12개면 12 · 토크 0 이면 0 · 각속도 0 이면 0 · 부호와 무관(절댓값) · joint_ids 존중.

4번이 이 항의 성격입니다 — 가만히 서서 버티는 토크는 벌하지 않습니다. 파워는 “일을 한 양” 이지 “힘을 쓴 양” 이 아니니까요. 로봇이 서 있기만 해도 벌을 받으면 주저앉는 쪽이 이득이 됩니다.

학습지 2 — 파워 분포 페널티 L2

목표: 특정 모터에만 부하가 쏠리는 것을 벌합니다. 앞 학습지가 파워의 총량을 벌한다면, 이건 관절 사이의 불균형을 벌합니다 — 실기에서 모터 하나만 과열되는 것을 막습니다.

r = \mathrm{var}_j\big(\tau_j\,\dot\theta_j\big)^2

채울 곳: TODO(reward-power-distribution)

앞 학습지와 다른 점 둘

  1. 절댓값을 쓰지 않습니다 — 부호 있는 파워 그대로의 분산입니다.
  2. 합이 아니라 관절 축(dim=-1)의 분산을 보고, 그것을 한 번 더 제곱합니다.

통과 기준 7개 중 결정적인 것: 파워가 커도 균등하면 0 · 분산을 한 번 더 제곱했는지(torch.square 누락 탐지) · 절댓값을 쓰지 않았는지.

5차시에서 이 항의 가중치를 0.0 으로 둔 근거(실측 결과 0 이 가장 좋았고, 논문 표의 인쇄값이 가장 나빴다)를 봅니다. 직접 구현해 보고 나면 그 표가 다르게 읽힙니다.

학습지 3 — 액션 저크 페널티 L2

목표: 정책이 관절을 덜컥거리며 움직이지 않도록 벌합니다.

\text{jerk} = a_t - 2a_{t-1} + a_{t-2}, \qquad r = \sum_j \text{jerk}_j^2

2차 차분이 0이라는 것은 액션이 일정한 속도로 변하고 있다는 뜻입니다. 급가속·급정지만 벌하고 부드러운 변화는 통과시킵니다.

채울 곳: TODO(reward-smoothness)action_smoothness_l2.__call__

Important함정이 둘 있습니다

① Isaac Lab 의 action_managera_ta_{t-1} 까지만 줍니다. a_{t-2} 는 이 클래스가 self.prev_prev_action 으로 직접 들고 있어야 합니다. 이 시리즈에서 처음 만나는 상태를 가진 보상 항입니다.

② 그냥 대입하면 같은 텐서를 가리킵니다..clone() 이 필요한지 생각해 보세요. 안 하면 a_{t-2}a_{t-1} 과 함께 움직여서, 저크가 항상 이상한 값이 됩니다.

통과 기준 7개 중 핵심: 선형 램프면 저크 0 · a=(0,0,1)12 · t−2 버퍼가 갱신되는지(안 하면 a=(0,1,2) 에서 48이 나옵니다) · prev_prev_action 이 별칭이 아닌지 · reset() 이 버퍼를 0으로 되돌리는지.

학습지 4 — 발 들기 페널티 L2

목표: 발을 끌지 않고 들어서 옮기게 만듭니다.

r = \sum_{\text{feet}} \big(h_{\text{지형 위}} - 0.12\big)^2 \cdot v_{xy}

채울 곳: TODO(reward-foot-clearance)

설계 포인트 둘

① 월드 z 가 아니라 “지형 위 높이”body_pos_w[:, :, 2] 를 그대로 쓰면 언덕 위 로봇과 계곡 로봇이 전혀 다른 값을 받습니다. 0차시 학습지 5의 height scan 과 똑같은 발상으로 지형 높이를 빼 줍니다.

② 왜 측면속도로 가중하는가 — 딛고 서 있는 발은 낮아도 정상입니다. 벌하고 싶은 것은 옮기는 중인데 낮게 끄는 발이므로, 수평속도를 곱해 “움직이는 발” 에만 페널티가 걸리게 합니다.

통과 기준 9개 중: 측면속도 0 이면 페널티 0(딛고 선 발) · 높이 0·속도 1·발 4개 → 4 \times 0.12^2 = 0.0576 · ray hit 의 NaN/inf 를 정리하는지 · 측면속도에 z 를 넣지 않았는지.

Important

7번(NaN/inf 정리)이 §5.4에서 본 그 문제입니다. 여기서 직접 처리해 보세요 — 안 하면 어떤 값이 나오는지 채점기가 알려 줍니다.

학습지 5 — 관측 스케일링 L1

목표: 관측의 각 그룹을 O(1) 크기로 맞춥니다. §3에서 조립한 45차원의 단위를 고르는 일입니다.

lin_vel   × 2.0        joint_pos × 1.0
ang_vel   × 0.25       joint_vel × 0.05
gravity, actions 는 이미 O(1) — 스케일하지 않는다

채울 곳: TODO(direct-obs-scale) — 네 줄입니다.

Important실제로 났던 포팅 버그입니다

스케일을 빼먹으면 관측 45차원 중 12개(관절 속도)가 명령보다 20배 큰 값을 갖습니다 (raw dof_vel 은 ±20 rad/s, 명령은 ±1). 신경망 입장에서 명령이 거의 보이지 않는 잡음이 됩니다.

결과는 정책이 명령을 무시하고 가만히 서 있는 것. 2차시 §7의 토이 환경 기준선 0.4426(가만히 서 있기)이 정확히 이 상태입니다. 증상만 보면 “학습이 안 된다” 지만, 원인은 알고리즘이 아니라 관측의 단위였습니다.

통과 기준 7개 중 놓치기 쉬운 둘: gravity 는 스케일하지 않는다 · _true_lin_vel_b 는 스케일 원값을 보관한다(이 값이 3차시 CENet 의 학습 정답이 됩니다).

마지막 기준이 이 학습지의 요점입니다 — 스케일 후 joint_vel/명령 비율 < 3.

학습지 6 — 종료 조건 L2

목표: 에피소드를 언제 끝낼지 정합니다. 두 가지이고, 구분하는 것이 중요합니다.

무엇 성격
time_out 정해진 스텝 수를 다 썼다 성공적 종료 — 가치를 부트스트랩해야 한다
base_contact 몸통이 바닥에 닿았다 = 넘어졌다 실패 종료

채울 곳: TODO(direct-dones) — 4차원 접촉 힘 텐서를 (N,) 불리언으로 줄입니다.

Important왜 둘을 따로 반환하나

시간이 다 돼서 끊긴 에피소드의 마지막 상태는 “나쁜 상태”가 아닙니다. 그런데 하나로 합쳐 버리면 학습기가 그 상태를 “여기서 끝났으니 이후 가치는 0” 으로 취급합니다.

결과적으로 “오래 살아남는 것” 자체에 잘못된 페널티가 붙습니다. 그래서 _get_dones 는 둘을 따로 돌려주고, 학습기는 time_out 에 대해서만 가치를 부트스트랩합니다.

임계값은 1 N 입니다 — Go2 몸통이 무언가에 스치기만 해도 종료입니다. 넘어진 뒤에 버둥거리며 보상을 긁어모으는 것을 막으려는 설정이고, 원본 IsaacGym 구현·Manager 스택 (mdp.illegal_contact)과 셋 다 같은 값입니다.

§5.5에서 본 몸통접촉 종료 78% 가 바로 이 조건으로 센 숫자입니다.


9. 핵심 정리

  • Go2는 12 DoF, 정책은 목표각 오프셋을 출력하고 PD가 토크로 변환(K_p=25, K_d=0.5).
  • 세 팔: actor 45(Base·하한) / 48(Oracle·상한) / 64(CENet 팔·측정 대상). 같은 환경, 같은 PPO, 관측만 다르다 — 이 시리즈의 모든 비교가 여기서 나온다.
  • 45에 선속도도 지형도 없다. 이것이 3차시 CENet 의 출발점.
  • critic 은 비대칭으로 특권 정보를 받는다. 두 개의 서로 다른 235 를 혼동하지 말 것(§3.4).
  • 보상은 추종 \exp(-e/\sigma) + 페널티 13항. 논문 레시피는 걷지 못해 폐기되었고, 실제 실험은 공식 레시피 + 논문 4항 복원 위에서 돈다.
  • 센서를 읽는 보상 항은 관측만큼 조용히 실패한다 — base_heightinf (§5.4).
  • ManagerBased(선언)Direct(명시) 는 같은 로직의 다른 표현 — 결과는 동일.
Note다음 차시 예고 — 2차시: PPO 설계

이 환경 위에서 정책을 어떻게 학습시키는지: PPO-clip 목적함수, GAE, rsl_rl의 롤아웃→갱신 루프를 코드로 따라갑니다. 그리고 왜 이 하이퍼파라미터인가 — 값 하나가 정책의 탐험 폭을 천장에 붙여 버린 사건을 끝까지 추적합니다.