08. 방향 전환 — 걷는 로봇에 ‘조종기’ 달기

이번 단계 목표: 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 −1.1°
좌회전 +0.5 rad/s +114.6° +117.6°
직진 0 +0.7°
우회전 −0.5 rad/s −114.6° −123.7°

직진 구간에선 1° 안팎으로 곧게 가고, 회전 구간에선 목표 대비 오차 3~9°로 돌았습니다. 조종기 스틱을 꺾는 대로 로봇이 도는 셈입니다.

좌회전 중 우회전 중
좌회전 스틸 우회전 스틸

방향 전환하며 걷는 G1

전체 영상:


회고 — 막혔던 점 & 배운 것

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

다음 단계

이제 조종은 됩니다. 그럼 걷는 로봇을 밀치면 어떻게 될까요? → 09. 외란 회복

이 시리즈의 전체 글 목록 → 시리즈 목차

댓글 남기기

이 사이트는 Akismet을 사용하여 스팸을 줄입니다. 댓글 데이터가 어떻게 처리되는지 알아보세요.