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;
4족 보행 로봇 & Isaac Lab — 관측을 무엇으로 채울 것인가
보지 않고 걷기 — CENet + 비대칭 PPO · 1차시
강의 로드맵
| 회차 | 주제 |
|---|---|
| 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 라고 부릅니다.
이 시리즈가 읽는 구현은 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차시). 나머지 차이는 해당 절에서 그때그때 표시합니다.
- 4족 로봇의 12 자유도(DoF)와 관측/행동/보상의 구조를 안다.
- 세 팔(45 / 48 / 64) 의 관측 설계를 비교하고, 왜 이 셋이 시리즈 전체의 비교축인지 안다.
- 관측 조립·보상 계산·PD 변환·도메인 randomization 코드를 직접 읽는다.
Isaac Sim / Isaac Lab 이 처음이라면 — 0차시를 먼저 보세요. 세 층의 역할 분담, 스크립트의 3단 구조, 200 Hz 물리 / 50 Hz 정책, 그리고 직접 채우는 학습지 5장이 있습니다.
💡 회색 동그라미 번호(1)에 마우스를 올리면 해당 코드 줄의 설명이 보입니다.
복잡해 보이지만 결국 세 가지를 설계하는 일입니다 — 로봇이 무엇을 보는지(관측), 무엇을 하는지(행동), 무엇을 잘하면 칭찬하는지(보상). 오늘은 이 셋과, 이를 흔들어 강건하게 만드는 도메인 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 — 무릎을 굽힌 기본 자세입니다.
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;
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에 연결합니다.
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;
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}: 직전에 자기가 낸 행동.
여기 없는 것이 전부입니다. 선속도가 없고(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 | 실제 실험 |
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 스텝이 따라옵니다.
Go2 게인은 로봇 설정에서 정해집니다 — 실제 실험이 쓰는 공식 값은 K_p=25,\ K_d=0.5 이고, 아래 폐기된 레시피만 K_p=28,\ K_d=0.7 로 덮어씁니다. 단일 관절 PD가 실제로 어떻게 진동을 잡는지는 아래 시뮬레이터에서 직접 만져봅니다.
단일 관절 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
이렇게 해보세요 — ① 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
- 모든 항을 합산 → 스텝당 총 보상.
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;
즉 지금 보고되는 모든 결과는 논문 보상 함수가 아니라 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.compute 는 weight == 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_height의 inf
보상 가중치를 만지기 시작하면 반드시 한 번은 밟는 함정이라 여기서 짚습니다.
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 한 번이었습니다.
같은 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 을 완주한 뒤의 로그입니다.
| 지표 | 값 | 정상이라면 |
|---|---|---|
| 평균 보상 | −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)로 들어갑니다.
- 1
- 무작위 속도 충격을 뽑아 저장 — critic 관측으로 노출하기 위함.
- 2
- 현재 속도에 충격을 더해 시뮬레이터에 기록 → 로봇이 “툭” 밀림.
ManagerBased는 같은 일을 EventCfg에 EventTerm으로 선언합니다(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 쪽에는 있습니다.
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 변형이 함께 등록돼 있습니다(학습된 정책을 눈으로 보는 용도).
폐기된 계열도 여전히 등록돼 있습니다 — DreamWaQ-Manager-Go2-{Base,Oracle,Waq}-v0. §5.3·§5.5의 논문 보상 레시피가 이쪽이고, 걷지 못합니다(몸통접촉 종료 78%). 참고용으로 남겨 둔 것이지 비교축이 아닙니다. 저장소를 처음 열고 DreamWaQ-Manager-Go2-Base-v0 를 돌리면 “왜 안 걷지?” 로 며칠을 쓰게 되므로, -PPO-v0 로 끝나는 쪽을 쓰세요.
아래 코드는 폐기된 계열의 설정이지만, critic 그룹을 어떻게 갈아 끼우는지를 가장 짧게 보여주기 때문에 그대로 읽습니다.
- 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 → 지형 → 센서)을 먼저 채우고 오세요. 아래 과제들은 그 위에서 시작합니다.
학습지 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)
앞 학습지와 다른 점 둘
- 절댓값을 쓰지 않습니다 — 부호 있는 파워 그대로의 분산입니다.
- 합이 아니라 관절 축(
dim=-1)의 분산을 보고, 그것을 한 번 더 제곱합니다.
통과 기준 7개 중 결정적인 것: 파워가 커도 균등하면 0 · 분산을 한 번 더 제곱했는지(torch.square 누락 탐지) · 절댓값을 쓰지 않았는지.
학습지 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__
① Isaac Lab 의 action_manager 는 a_t 와 a_{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 를 넣지 않았는지.
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) — 네 줄입니다.
스케일을 빼먹으면 관측 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,) 불리언으로 줄입니다.
시간이 다 돼서 끊긴 에피소드의 마지막 상태는 “나쁜 상태”가 아닙니다. 그런데 하나로 합쳐 버리면 학습기가 그 상태를 “여기서 끝났으니 이후 가치는 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_height의inf(§5.4). - ManagerBased(선언) 와 Direct(명시) 는 같은 로직의 다른 표현 — 결과는 동일.
이 환경 위에서 정책을 어떻게 학습시키는지: PPO-clip 목적함수, GAE, rsl_rl의 롤아웃→갱신 루프를 코드로 따라갑니다. 그리고 왜 이 하이퍼파라미터인가 — 값 하나가 정책의 탐험 폭을 천장에 붙여 버린 사건을 끝까지 추적합니다.