Go2를 Genesis에서 구동하기: 사족 보행 RL부터 미로 내비게이션까지
요약
본 글은 Unitree Go2 로봇을 Genesis 환경에서 구동하여 사족 보행부터 미로 내비게이션까지 학습하는 과정을 다룹니다. 계층적 강화학습(HRL)을 적용하여 '보행'과 '경로 탐색'이라는 두 가지 역할을 분리함으로써, 복잡한 목표 달성률 99.9%를 달성했습니다. Apple Silicon 환경에서의 구동 과정 및 학습 노하우가 상세히 기록되어 있어 관련 연구자에게 큰 도움이 될 것입니다.
핵심 포인트
- 사족 보행과 미로 탐색을 계층적 강화학습(HRL)으로 분리하여 학습 효율성을 높임.
- Genesis 공식 샘플 구조를 활용하며, `*_env.py`와 `*_train.py`의 3점 세트 관례를 따름.
- Mac mini (M4) 환경에서 성공적으로 구동 및 학습을 완료했으며, 상세한 과정 기록이 유용함.
- 학습된 로봇의 안정성을 확인하기 위해 실제 속도 지령에 따른 전도 테스트를 수행함.
본 장에서는 사족 보행 로봇 Unitree Go2 를 Genesis 위에서 걷게 하고, 나아가 미로의 골까지 스스로 걸어가는 단계까지 육성하는 과정을 다룬다. Genesis 공식 보행 샘플에서 출발하여, '전후/좌우/회전 모든 방향으로 걷는 방법'과 '미로를 푸는 방법'이라는 2단계 구조의 계층적 강화학습(hierarchical RL)을 결합한다.
도중에 Mac 특유의 함정에 두 번이나 빠졌다. 학습이 7할 지점에서 정체되었고, 원인을 찾아내기까지 상당히 돌아가는 과정을 거쳤다. 그 과정 또한 그대로 기록한다. Apple Silicon으로 Genesis의 강화학습을 구동하는 사람에게는 이곳이 가장 도움이 될 것이다.

완성 형태: Go2가 빨간 공(골)을 목표로 미로를 걷는다. 골 도달률은 99.9%이다.
X.1 전체 개요 — 두 가지 방법을 쌓기
미로를 푸는 방법에는 12개 관절의 각도를 직접 출력하게 할 수도 있다. 하지만 그렇게 하면 '걸음걸이'와 '경로'를 동시에 학습해야 하므로, 학습이 매우 어려워진다. 그래서 역할이 다른 두 가지 방법을 중첩시킨다.
| 저수준(보행) | 고수준(미로) | |
|---|---|---|
| 배우는 것 | 지시된 속도로 넘어지지 않고 걷기 | 어느 방향으로 가야 골에 가까워지는가 |
| ... | ||
| 보행 방법은 '속도 지령에 따라 걷는 것'만을 배우면 된다. 미로 방법은 '속도 지령을 내보내는 것'만을 생각하면 된다. 각각의 문제가 작아지기 때문에, 둘 다 순순히 학습할 수 있다. |
X.2 공식 샘플 examples/locomotion 알아보기
Genesis 리포지토리에는 Go2의 보행 학습 샘플이 들어있다.
genesis-world/examples/locomotion/
├── go2_env.py # Go2의 병렬 환경(관측・보상・리셋)
├── go2_train.py # rsl_rl의 PPO로 학습
...
' *_env.py '에 환경, ' *_train.py '에서 학습, ' *_eval.py '에서 재생'이라는 3점 세트가 Genesis의 관례이다. 학습 결과는 logs/<실험명>/ 에 cfgs.pkl (설정)과 model_<횟수>.pt (체크포인트)로 저장된다. 본 장의 스크립트도 모두 이 관례에 맞춘다.
X.3 환경 구축
본 장에서 사용한 환경은 다음과 같다.
| 항목 | 버전 |
|---|---|
| 머신 | Mac mini (M4, 16GB) |
| ... | |
| Genesis 본체(本体)가 들어있는 환경에 rsl_rl을 추가하고, 샘플을 포함한 리포지토리를 가져온다. |
pip install
를 import 하고, 설정 함수만 교체하고 있다. 환경의 내용물(관측・보상・PD 제어)은 공식과 동일하므로, 공식 측의 개선에도 따라가기 쉽다.
python examples/locomotion/go2_omni_train.py -b 4096 --max-iterations 601
python examples/locomotion/go2_eval.py -e go2-omni --ckpt 600 # 뷰어로 확인
M4에서는 약 20분 만에 끝났다. 학습 로그의 속도 추종 보상은, 전후・좌우가 **0.96** (만점 1.0), 회전이 **0.44** (만점 0.5)까지 올랐다. 에피소드의 길이도 상한인 1001 스텝에 도달했고, 학습 중에 넘어지는 개체는 사라졌다.
### 지령대로 걸을 수 있는지 숫자로 확인하기
뷰어로 보고 '걷고 있는 것 같다'만으로는 미로의 기반으로 불안하다. 고정된 속도 지령을 주어 10초간 실제 속도와 전도 수를 측정했다. 넘어지기 쉬운 정도는 대표적인 네 가지 지령에 대해, X.8에서 설명하는 방법으로 상태를 분산시킨 256체 × 19초에서도 별도로 0회임을 확인하고 있다.
| 지령 (전후, 좌우, 회전) | 실측 | 전도 |
|---|---|---|
| 멈추기 (0, 0, 0) | (0.00, 0.00, 0.01) | 0 |
| ... |
어떤 지령에서도, 실측과의 차이는 최대 0.05 정도다. 300번째 시점에서는 거의 이 정확도에 도달했다.
## X.5 스텝2: 미로 태스크를 설계하다
### 미로와 '경로 거리'
미로는 구멍 파기법으로 생성한다. 4×4 매스, 1 매스는 1.2m, 벽의 높이는 0.5m다. Genesis에는, 벽을 연속 구간별로 모은 `gs.morphs.Box`
로 놓는다.
for pos, size in maze.wall_boxes():
scene.add_entity(gs.morphs.Box(pos=pos, size=size, fixed=True))
보상에서 가장 중요한 것은 '골에 가까워지면 칭찬하기'의 **가까움을 측정하는 방법**이다. 직선 거리(유클리드 거리)로 측정하면, 골 벽 너머의 막다른 길이 '가깝다'는 것이 되어, 정책은 막다른 길에 빨려 들어간다.
그래서 미로를 0.1m 간격의 점유 그리드로 만들고, 각 매스를 골로 했을 때 **미로를 따른 거리(경로 거리)**를 BFS로 미리 계산해 둔다. 이때 벽을 로봇의 반경만큼(0.35m) 두껍게 해서 BFS 하는 것이 요령이다. 그렇게 하면, 벽 가장자리를 지나는 길은 돌아가는 것으로 취급되고, 통로 중앙을 지나갈수록 이득이 된다.
### 관측・행동・보상
| 내용물 | |
|---|---|
| 관측 (31차원) | 자기 위치, 방향 (sin, cos), 골 위치, 골의 상대 위치 (몸의 방향 기준), 골까지의 경로 거리, 가짜 LiDAR 16개, 이전 행동, 현재 속도 지령 |
| ... |
가짜 LiDAR는 Genesis의 센서가 아니라 점유 그리드 위에서 광선을 진행하여 계산하고 있다. 가볍고, 병렬로도 빠르다.
| 보상 | 가중치 | 역할 |
|---|---|
| 경로 거리 감소 [m] | +5.0 | 보상의 주역. 미로를 따라 골에 가까워질수록 칭찬한다 |
| ... |
## X.6 미로 환경 go2_maze_env.py
미로의 환경은, 공식의 `go2_env.py`
과 동일한 인터페이스(rsl_rl의 `VecEnv`
)를 사용한다. 이렇게 해두면, 학습도 재생도 공식과 같은 `OnPolicyRunner`
로 돌릴 수 있다.
### 고정된 보행 정책을 불러오기
학습된 보행 정책은, `OnPolicyRunner`
에 로드하는 것이 확실하다. 다만 Runner는, 만들 때 환경의 관측 차원과 행동 수를 알고 싶어 한다. 그래서, 보행 정책의 관측만 반환하는 작은 '창구' 클래스를 전달한다.
class _WalkingPolicyEnvView:
"""OnPolicyRunner에 보행 정책을 로드하기 위한 창구"""
def init(self, env, ll_env_cfg):
...
보행 정책으로의 관측 생성과 PD 제어는, 공식의 `go2_env.py`
와 **한 글자도 변하지 않게** 옮기고 있다. 여기가 조금이라도 어긋나면, 학습된 보행 정책은 제대로 걷지 않는다.
### 1 스텝 = 보행 정책 5회
높은 수준의 1 스텝(0.1초)으로 보행 방식을 5번(0.02초 × 5) 반복합니다.
def step(self, actions):
actions = actions.clamp(-1, 1)
self.cmd_target = self.cmd_lo + (actions + 1) * 0.5 * (self.cmd_hi - self.cmd_lo)
...
`extras["episode"]`에 넣은 값은 rsl_rl이 자동으로 평균하여 로그로 출력해 줍니다. 골 도착률과 전도율을 학습하는 동안 계속 확인할 수 있어서, 나중에 나올 '정체기'를 파악할 수 있었던 것은 이것 덕분이었습니다.
## X.7 스텝3: 미로 학습하기
학습 스크립트 `go2_maze_train.py`는 공식의 `go2_train.py`와 거의 같은 형태입니다. 다른 점은 환경 클래스와 설정 내용만 다릅니다. 높은 수준의 문제가 작기 때문에 네트워크는 공식보다 작은 [256, 256]으로 했습니다.
python examples/locomotion/go2_maze_train.py -b 2048 --max-iterations 1001
설정(미로 모양, 보상 가중치, 사용한 보행 방식의 체크포인트)은 `logs/go2-maze/cfgs.pkl`에 저장됩니다. 재생할 때는 자동으로 같은 미로가 사용됩니다.
……여기까지는 순조로웠습니다. 첫 번째 학습 결과는 다음과 같았습니다.

1회차(주황색)는 골 도착률이 **약 70%에서 정체**되었습니다. 실패 내역을 보면 시간 초과는 거의 없고, **실패의 대부분이 전도**였습니다. X.4 확인에서는 한 번도 넘어지지 않은 보행 방식이 미로에서는 3할이나 넘어져 있었습니다. 여기서부터 원인 찾기가 시작됩니다.
## X.8 난관 — 미로에서 Go2가 넘어지는 문제
### 그1: 명령의 급격한 전환
넘어질 순간의 상황을 모아보니, 대부분 **벽에서 0.4~0.6m 떨어진 아무것도 없는 장소**였습니다. 벽에 부딪혀 넘어진 것이 아닙니다. 넘어지기 직전 속도 명령은 '최고 속도로 전진 + 전력으로 측면 이동 + 전력으로 회전'과 같은 극단적인 조합이 많았습니다.
높은 수준의 방식(High-level policy)은 0.1초마다 명령을 끝에서 끝으로 전환하고 있습니다. 반면, 보행 방식(Locomotion policy)은 **4초마다만 명령이 바뀌는 상황**에서 학습하고 있습니다. 보행 방식만 따로 꺼내 확인해 봤습니다 (256개체 × 20초).
| 명령 부여 방식 | 전도 여부 |
|---|---|
| 극단적인 명령으로 일정하게 유지 | 0회 |
| ... |
여기서, 높은 수준의 명령을 보행 방식에 전달하기 전에 **1차 지연 필터(first-order delay filter)**를 거치기로 했습니다 (X.6의 `cmd_smoothing`). 50Hz마다 목표 명령에 1할씩 가깝게 접근시키므로 시정수(time constant)는 약 0.2초가 됩니다. 아울러, 높은 수준의 방식이 '현재 명령'을 알 수 있도록 관측값에 추가했습니다.
결과는 2회차(녹색)입니다. 기상 속도는 빨라졌지만, **도달률은 약 75%, 전도는 여전히 2할대**였습니다. 아직 다른 원인이 있습니다.
### 그2: Apple GPU에서 상태 읽기가 손상됨
여기서부터가 길었습니다. 문제 분리 순서대로 작성합니다.
- **벽을 없애도 넘어짐**: 물리적인 벽만 제거한 미로에서 같은 방식을 작동시켜도 전도 횟수는 변하지 않았습니다 (215회와 221회). 벽은 무관합니다. -
- **미로 환경에서만 넘어짐**: 미로 환경에서 '0.8 m/s로 직선 전진'시키는 것만으로도, 256개체 × 20초 동안 162번 회전했습니다. 공식의 `Go2Env`에서는 같은 명령으로 0회였습니다. -
- **물리 계산은 동일**: 두 환경을 같은 초기 상태에서 나란히 두고 스텝별로 비교하면, 값은 몇 초간 거의 완벽하게 일치했습니다.
결정적인 것은, 거의 같은 두 테스트 스크립트의 작은 차이점이었습니다.
| 전도 판정 방식 | 전도 여부 |
|---|---|
| 매 스텝마다 판정 | 0회 |
| 5 스텝마다 판정 | 161회 |
값을 읽어 비교하는 처리인데, 그 빈도로 물리 결과가 달라집니다. 매 스텝 판정에는 `int(bad.sum())`이 들어가 있고, 여기서 GPU 동기화가 발생하고 있습니다. 이를 통해 **PyTorch가 로봇의 상태를 다 읽어들이기 전에 Genesis가 다음 물리 스텝의 쓰기를 시작하고 있다**는 가설을 세웠습니다.
| 동기화 방법 | 전도
|---|---|
| 없음 | 161회 |
`scene.step()` 직후에 `torch.mps.synchronize()`
|
1538회(악화) | 상태를 읽은 직후에 `torch.mps.synchronize()`
|
0회 |
'물리 계산 직후'에 동기화하면 악화되고, '상태를 읽은 직후'에 동기화하면 해결된다. PyTorch 측의 처리를 기다리는 것만으로는 Genesis 측의 쓰기를 기다릴 수 없다는 점과 일치한다. Genesis는 Apple GPU에서 최신 PyTorch(mps)와 결합할 때, 메모리를 복사하지 않고 공유하는 '제로 카피'가 기본으로 활성화된다. 두 계산이 별개의 흐름으로 같은 메모리에 접근하는 것이 원인일 것이다 (이는 상황에 따른 추측이며, Genesis 내부까지는 확인하지 않았다).
수정은 3줄이다.
def _read_state(self):
self.base_pos = self.robot.get_pos()
self.base_quat = self.robot.get_quat()
...
수정 후에는 수정 전 901회, 1558회에서 발생했던 조건도 0회가 되었다. 1회당 학습 시간은 거의 변하지 않았다.
공식의 `go2_env.py`가 문제없이 작동한 것은 매 스텝의 보상 계산이나 리셋 처리 과정에서 우연히 동기화가 걸렸기 때문이라고 생각된다. **직접 환경을 만들면 이 '우연'이 사라진다.** Mac에서 Genesis 환경을 직접 만들 때는 상태를 읽은 후에 동기화를 넣어두는 것이 안전하다.
### 그 3: CPU로 재생하면 넘어짐
세 번째 학습(파란색)은 50회에서 91%, 100회에서 99.9%에 도달했다. 그런데 마지막으로 영상을 찍자 11번 중 4번 실패했다.
공식의 `go2_eval.py`를 따라 재생은 CPU(`gs.cpu`)로 실행했다. 조사해 보니, **공식 Go2Env도 CPU에서는 넘어진다** (64체 × 19초에 228회, GPU에서는 0회). 제로 카피를 꺼도 변하지 않으므로, 이것은 동기화 문제가 아니다. CPU와 GPU에서 물리 계산 결과가 미세하게 달라, GPU에서 학습한 보행 정책이 그 차이를 견디지 못하는 것 같다. 공식의 보행 학습에는 물리 파라미터의 변화(Domain Randomization)를 넣지 않았기 때문에, 이러한 차이에 취약하다.
| 재생 백엔드 | 미로 골 도착률 |
|---|---|
| CPU(`gs.cpu`) | 46% |
| GPU(`gs.gpu`) | 99.9% |
대처 방안으로, 재생도 학습과 같은 GPU에서 실행하기로 했다. 뷰어 역시 GPU 상태 그대로 문제없이 작동한다.
## X.9 결과와 재생
세 번째 학습의 최종 버전(`model_1000.pt`)을 뷰어 없이 1003 에피소드 평가한 결과가 이것이다.
| 골 도착 | 전도 | 시간 초과 |
|---|---|
| 99.9% | 0.1% | 0% |
재생 방법은 3가지로 준비했다.
뷰어로 재생 (골 도착 시 success! 다음 문제로 이동. Ctrl+C로 종료)
python examples/locomotion/go2_maze_eval.py
뷰어 없이 성공률 측정
...
영상 저장에는 뷰어와 같은 시점의 카메라(`scene.add_camera`)를 사용한다. 골의 빨간 공은 `scene.draw_debug_sphere`로 그리고 있으므로 물리에는 영향을 주지 않는다. 뷰어 없이도 그릴 수 있어 영상에도 찍힌다.
## X.10 요약
| 단계 | M4에서의 시간 | 결과 |
|---|---|
| 전 방향 보행 (4096 병렬, 601회) | 약 20분 | 지령 속도와의 차이 0.05 이내, 전도 0 |
| 미로 내비게이션 (2048 병렬, 1001회) | 약 70분 | 골 도착 99.9% (100회에서 거의 도달) |
본 장의 핵심은 다음 4가지이다.
계층적으로 분리하기: '걷기'와 '길 선택'을 별도의 정책으로 분리하면, 둘 다 작은 문제가 되어 비교적 쉽게 학습할 수 있다. - 거리는 미로를 따라 측정하기: BFS(Breadth-First Search)로 경로 거리를 사전에 계산하는 것만으로도 막다른 길에 빠지는 것을 방지할 수 있다. - 하위 정책이 학습한 조건을 지키기: 보행 정책은 4초마다 바뀌는 명령으로 학습하고 있다. 상위에서 내려오는 명령은 부드럽게 처리하여 전달해야 한다. - Mac에서는 상태를 읽으면 동기화하기: 자체 제작 환경에서는 `torch.mps.synchronize()`을 잊지 말아야 한다. 재생도 학습과 같은 GPU에서 구동한다.
### Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기