이번 단계 목표: 06번에서 학습한 G1은 앞으로만 걸을 수 있었습니다. 이번엔 “이 속도로 가”, “왼쪽으로 돌아” 같은 명령을 알아듣고 따르는 정책을 학습시켜, 전진 → 좌회전 → 전진 → 우회전 시나리오를 수행하게 합니다.
재검증 노트 — 이 글의 핵심(명령을 학습 분포에 넣으면 정책이 명령을 알아듣는다)과 회전각 수치는 그대로 유효합니다. 다만 이 정책의 ‘걸음’ 자체는 나중에 셔플로 판명됐습니다 (10번 참고). 제대로 된 걸음 위에서 같은 조종 능력을 다시 확보한 기록은 10번·11번에 있습니다.
06번의 로봇은 왜 방향 전환을 못 하나요?
06번 학습에도 사실 ‘명령’이라는 게 있었습니다. 관측(observation) 45개 숫자 중 3개가 명령 속도(전진 m/s, 옆걸음 m/s, 회전 rad/s)였고, 보상도 “명령을 잘 따르면 칭찬”이었죠.
문제는 학습 내내 그 명령이 “전진 0.5m/s” 딱 하나로 고정돼 있었다는 점입니다.
command_cfg = {
"lin_vel_x_range": [0.5, 0.5], # 항상 0.5
"lin_vel_y_range": [0.0, 0.0], # 항상 0
"ang_vel_range": [0.0, 0.0], # 항상 0
}
로봇 입장에선 명령 3개 숫자가 한 번도 변한 적이 없으니, 그 숫자를 ‘읽을’ 이유가 없었습니다. 배경 무늬처럼 무시해 버린 거죠. 회전 명령을 줘 봤자 “그게 뭔데?” 상태입니다.
신경망은 변하지 않는 입력을 무시합니다. 학습 중 늘 같은 값이던 입력은 출력에 영향을 주지 않는 쪽으로 가중치가 정리됩니다. 명령을 ‘알아듣게’ 하려면, 학습 중에 명령을 계속 바꿔 주면서 “명령이 바뀌면 보상 받는 행동도 바뀐다”는 걸 경험시켜야 합니다.
해법: 학습 중에 명령을 마구 바꿔 준다
고칠 것은 놀랄 만큼 적습니다. 환경 코드는 한 줄도 안 고쳤고, 학습 설정에서 명령의 범위만 넓혔습니다 (g1_train.py --task steer):
command_cfg = {
"lin_vel_x_range": [0.0, 0.6], # 전진 0~0.6 m/s (0이면 제자리)
"lin_vel_y_range": [-0.2, 0.2], # 옆걸음 살짝
"ang_vel_range": [-0.6, 0.6], # 회전 ±0.6 rad/s (약 ±34°/s)
}
환경은 원래부터 에피소드 시작 때 + 4초마다 이 범위에서 명령을 무작위로 다시 뽑도록 만들어져 있었습니다(go2 예제에서 물려받은 구조). 06번에선 범위가 점 하나라 “다시 뽑아도 늘 같은 값”이었을 뿐이죠. 범위를 벌리는 순간, 로봇은 걷는 도중 갑자기 “이제 왼쪽으로 돌아!” 라는 명령 변경을 겪게 됩니다.
보상도 하나만 조정했습니다 — 회전 명령 추종 보상(tracking_ang_vel)의 비중을 0.2 → 0.5로. 06번에선 회전 명령이 늘 0이라 낮게 뒀지만, 이젠 회전이 과제의 핵심이니까요.
cd scripts/train
python g1_train.py --task steer --max_iterations 1500 # 학습 (GPU)
python g1_eval.py --task steer --ckpt 1499 --record # 시나리오 재생 + 녹화
커리큘럼 없이 되나요? “먼저 직진을 배우고 → 그다음 회전을 배우게” 단계를 나누는 방법(커리큘럼 학습)도 있지만, 이 정도 난이도는 처음부터 무작위 명령으로 던져도 학습됩니다. 06번보다 배워야 할 게 많아 수렴이 느려질 뿐입니다. 그래서 iteration을 1500으로 늘렸습니다.
평가: 각본대로 조종해 보기
학습 때는 명령이 무작위였지만, 평가 때는 우리가 조종기를 잡습니다. g1_eval.py의 steer 모드는 미리 짠 각본대로 명령을 바꿔 줍니다:
| 구간 | 명령 (전진, 옆, 회전) | 의미 |
|---|---|---|
| 0~4초 | (0.4, 0, 0) | 앞으로 |
| 4~8초 | (0.3, 0, +0.5) | 걸으며 좌회전 |
| 8~12초 | (0.4, 0, 0) | 다시 직진 |
| 12~16초 | (0.3, 0, −0.5) | 걸으며 우회전 |
구현은 매 step 명령 버퍼를 각본 값으로 덮어쓰고 관측을 다시 만드는 것뿐입니다. 같은 정책, 같은 신경망인데 입력의 명령 3칸만 바꾸면 로봇의 행동이 바뀝니다 — 이게 “명령을 알아듣는다”의 실체입니다.
학습 결과
06번과 같은 4096개 병렬 환경으로 1500 iteration을 돌렸고, 18분 걸렸습니다 (초당 약 135,000 step).
| 학습 진행 | 평균 보상 | 평균 에피소드 길이 | 상태 |
|---|---|---|---|
| iter 1 | −0.6 | 48 스텝 (~1초) | 픽픽 쓰러짐 |
| iter 101 | 12.5 | 977 스텝 | 거의 안 넘어짐 — 균형은 벌써 |
| iter 301 | 24.7 | 1001 (최대) | 회전 추종이 본격적으로 늘기 시작 |
| iter 1499 | 27.1 | 1001 (최대) | 보상 수렴 |
흥미로운 점: 균형은 06번과 비슷하게 iteration 100 무렵에 이미 잡혔습니다. 그 뒤로 오른 보상은 대부분 ‘명령 추종'(특히 회전) 항목이었어요. 회전 추종 보상은 iter 101에서 0.12 → iter 301에서 0.38 → iter 601에서 0.44로 꾸준히 올랐습니다. “서서 걷기”와 “조종당하기”는 별개의 능력이고, 후자를 배우는 데 시간이 더 걸린다는 뜻입니다.
시나리오 재생 결과 (16초, 넘어지지 않고 완주, 경로 길이 5.41m):
| 구간 (4초씩) | 명령 회전 속도 | 목표 회전각 | 실제 회전각 |
|---|---|---|---|
| 직진 | 0 | 0° | −1.1° |
| 좌회전 | +0.5 rad/s | +114.6° | +117.6° |
| 직진 | 0 | 0° | +0.7° |
| 우회전 | −0.5 rad/s | −114.6° | −123.7° |
직진 구간에선 1° 안팎으로 곧게 가고, 회전 구간에선 목표 대비 오차 3~9°로 돌았습니다. 조종기 스틱을 꺾는 대로 로봇이 도는 셈입니다.
| 좌회전 중 | 우회전 중 |
|---|---|
![]() |
![]() |

전체 영상:
회고 — 막혔던 점 & 배운 것
- 이번엔 한 번에 됐습니다. 06번까지 쌓은 토대(자립 가능한 기본자세, PD 게인, 보상 골격)를 그대로 쓰니, 코드 수정 없이 설정 몇 줄로 새 능력이 나왔습니다. 초반 단계에서 애먹으며 잡아둔 기본기가 이렇게 복리로 돌아옵니다.
- 환경 코드를 한 줄도 안 고쳤다는 것의 의미. go2 예제에서 물려받은 구조가 처음부터 “명령은 관측에 넣고, 주기적으로 재샘플한다”로 일반화돼 있었기에 가능했습니다. 06번 시점엔 쓸모없어 보이던 그 범용성 덕분에, 이번 단계는 설정만 바꿔 공짜로 확장할 수 있었어요. 잘 설계된 예제를 따르는 것의 가치입니다.
- 신경망이 ‘무시하던 입력’을 쓰게 만드는 법. 명령 입력은 06번에도 있었지만 값이 늘 같아 무시됐습니다. 입력을 살리는 방법은 코드가 아니라 데이터(학습 분포)를 바꾸는 것 — 학습 중 명령을 계속 바꿔서 “이 숫자를 봐야 보상을 받는다”는 상황을 만들어 주는 것이었습니다.
- 균형 따로, 조종 따로. 학습 곡선을 보면 균형(안 넘어짐)은 100 iteration 만에 잡혔지만, 회전 추종 보상은 600 iteration까지 계속 올랐습니다. 겉보기엔 하나의 ‘걷기’지만 안에서는 능력이 층층이 쌓이는 걸 곡선으로 확인할 수 있었어요.
- 보상 비중 하나가 품질을 좌우. 회전 추종 보상 비중을 0.2에서 0.5로 올린 게 이번 설정 변경의 절반이었습니다. “무엇을 얼마나 중요하게 채점하느냐”가 곧 로봇의 우선순위가 됩니다 — 06번에서 배운 보상 설계 감각의 연장입니다.
다음 단계
이제 조종은 됩니다. 그럼 걷는 로봇을 밀치면 어떻게 될까요? → 09. 외란 회복
이 시리즈의 전체 글 목록 → 시리즈 목차

