HoverPilot - 크게 기울어진 자세에서 회복하도록 훈련하기 (upset recovery)

쉬운 환경에서만 학습한 정책은 큰 시련을 극복하지 못한다

By 전경원

이전 글에서 네 채널 전체 제어를 축별 구조로 합쳐 안정적인 호버를 세웠지만, 마지막에 한계를 하나 적어 뒀다. 이득과 트림이 안정 상태 근처의 작은 오차를 줄이도록 맞춰져 있어서, 크게 기울어진 뒤에는 회복이 눈에 띄게 약했다. 실제로 제어 시작을 조금만 늦춰 기체가 기운 상태에서 정책에 맡기면, 평형으로 돌아오지 못하고 그대로 추락했다.

이번 글은 그 회복 능력을 정면으로 훈련한 기록이다. 핵심 아이디어는 단순하다. 에피소드 시작 직후 일정 시간 동안 조종면을 중립으로 두고 쓰로틀만 유지해서, 기체가 자연스럽게 기울고 흘러가게 둔다. 그렇게 악화된 상태에서 정책에 제어를 넘겨 회복을 배우게 한다. 처음부터 크게 기울이면 실패만 쌓이므로, 기울이는 시간을 0초에서 3초까지 서서히 늘리는 커리큘럼으로 난이도를 올렸다.

결론부터 적으면, 최종 정책은 두 묶음의 결정론적 평가에서 각각 3/35/5, 합쳐서 8/8 에피소드를 모두 600스텝 완주했다. 8회 모두 지면 충돌과 트레이너 원통 경계 이탈이 없었고, 제어를 넘겨받는 시점의 평균 기울기는 약 12도였다. 전체 코드 테스트는 191 passed다.

안정 상태에서만 시작하면 회복을 못 배운다

초기 호버 정책은 안정된 상태에서 제어를 시작하면 잘 떠 있었지만, 그건 애초에 어려운 상황을 겪을 일이 없어서였다. 목표를 “오래 떠 있기”가 아니라 “무너진 상태에서 되돌아오기”로 바꾸려면, 학습 과정에서 실제로 무너진 상태를 겪게 해야 한다.

항공에서는 자세(pitch·bank)나 속도가 정상 범위를 크게 벗어나 자칫 제어를 잃을 수 있는 상태를 upset이라 부르고, 거기서 정상 자세로 되돌리는 훈련을 upset recovery(비정상 자세 회복)라고 한다. 이 프로젝트에서 하려는 것도 정확히 그것이다. idle 구간에서 기체를 크게 기울여 upset 상태로 만든 뒤, 정책이 그 상태에서 호버로 회복하도록 학습시킨다.

초기 상태를 코드로 랜덤화하는 방법도 있지만, 이 프로젝트는 RealFlight 시뮬레이터 안에서 돌아가므로 시뮬레이터의 물리를 그대로 쓰는 편이 자연스러웠다. 그래서 매 에피소드 시작 뒤에 다음 순서를 반복하게 했다.

  1. 트레이너 리셋으로 기체가 초기 호버 위치에 놓인다.
  2. aileron/elevator/rudder = 0으로 조종면을 중립에 둔다.
  3. 쓰로틀만 0.66으로 고정한다.
  4. 커리큘럼에 따라 0~3초를 기다린다. 이 동안 기체는 스스로 기울고 흘러간다.
  5. 기울기와 위치 오차가 생긴 상태에서 정책에 제어를 넘긴다.
  6. 기체가 트레이너 원통 안에서 다시 호버로 복귀한다.

idle 쓰로틀은 0.55, 0.60을 거쳐 0.66으로 정했다. 낮으면 3초 동안 고도를 너무 잃어 지면에 가까워지고, 높으면 수평 이동이 커져 원통 경계에 부딪힐 위험이 커졌다. 0.66은 그 사이의 값이다. 참고로 이 idle 쓰로틀은 정책이 호버를 유지할 때 쓰는 트림과는 다른 값이다. 최종 체크포인트의 정책 쓰로틀 트림은 약 0.750017이고, 0.66은 어디까지나 기체를 적당히 무너뜨리기 위한 idle 전용 값이다.

물리 시간으로 idle을 재고, 리셋 경계를 기다린다

idle 구간을 벽시계 시간이 아니라 RealFlight의 시뮬레이터 물리 시간(simulator physics time)으로 쟀다. 통신 주기나 시뮬레이터 부하에 따라 실제 물리 스텝 수가 달라져도, 항상 요청한 시간만큼 기체를 자유롭게 두기 위해서다. 관련 동작은 CLI 옵션으로 노출했다.

--episode-start-idle-seconds
--episode-start-idle-throttle
--episode-start-idle-curriculum-steps
--episode-start-idle-curriculum-start-seconds
--episode-start-handoff-seconds

idle duration의 기본값은 0이라, 옵션을 켜지 않으면 기존의 즉시 제어 동작이 그대로 보존된다.

idle 훈련에서 조심할 점은 에피소드 경계다. 이전 에피소드의 추락 상태나 리셋 직전의 정지 상태를 새 에피소드의 idle 시작점으로 잘못 받아들이면, 기울기를 만들기도 전에 학습 데이터가 오염된다. 그래서 리셋 경계를 명시적으로 기다렸다가, 트레이너가 새 기체를 안정적으로 배치한 뒤에만 idle을 시작하도록 에피소드 수명주기를 손봤다.

0초에서 3초까지, 성공했을 때만 어려워지는 커리큘럼

idle을 처음부터 3초로 두면 초기 정책이 대부분 곧바로 실패했다. 문제는 실패 자체가 아니라, 정책이 회복에 성공하는 경험을 거의 못 만나서 무엇이 좋은 행동인지에 대한 신호가 약해진다는 데 있었다. 보상이 나오는 상태 영역을 좀처럼 밟지 못하는, 전형적인 탐색(exploration) 문제였다(Sutton & Barto 2장의 탐색-활용 균형).

그래서 0초 → 3초 커리큘럼을 넣되, 난이도를 전체 학습 스텝에 비례해 기계적으로 올리지 않았다. 에피소드가 시간 제한까지 완주하고 마지막 상태가 위치·수평 속도·고도·기울기 기준을 만족했을 때만 커리큘럼 진행량을 늘렸다. 실패한 에피소드도 PPO 롤아웃에는 그대로 남겨 학습에 쓰되, 난이도 상승만 잠시 멈추는 방식이다. 이렇게 하면 정책이 지금 난이도를 실제로 감당할 수 있을 때에 한해서만 다음 단계로 넘어간다.

idle에서 정책으로 부드럽게 넘기기

idle이 끝나는 순간에도 함정이 있었다. idle 동안 조종면은 0이었는데, 정책이 갑자기 큰 값을 내면 기체가 튀었다. 경계 그 자체가 새로운 불안정 요인이었던 셈이다. 다음처럼 완화했다.

  • 0.1초 동안 cubic smoothstep으로 idle 액션과 정책 액션을 보간한다.
  • 시뮬레이터의 매 물리 스텝마다 결정론적 정책 타깃을 다시 계산한다.
  • 한 물리 스텝에서 액션이 바뀔 수 있는 양을 최대 0.25로 제한한다.
  • 이 인계 구간은 일반 에피소드 스텝 제한에서 제외한다.

더 부드럽게 만들면 더 좋을 것 같지만, 그렇지 않았다. 변화량 제한을 0.10으로 더 낮춘 실험에서는 실제 인계가 약 0.25~0.35초로 길어졌고, 초기 롤 제동이 늦어져 시험한 4/4 에피소드가 모두 경계 이탈로 끝났다. 시각적 부드러움만 키우고 회복 성능은 떨어뜨린 설정이었다. 불연속 점프는 없애되 제어 권한은 빠르게 넘기는 0.25가 균형점이었다.

수직 호버의 좌표계와 정책 표현력

회복을 배우려면 정책이 “지금 얼마나, 어느 방향으로 기울었는가”를 믿을 수 있는 값으로 받아야 한다. 그런데 수직 호버 근처에서는 RealFlight의 오일러 롤(Euler roll)이 특이점 부근에서 불연속으로 튄다. 이 문제는 엘리베이터 편에서 이미 겪었고, 전체 제어에서는 더 심했다. 그래서 full-control 정책에서는 원본 오일러 값 대신, 기체 롤 각속도를 시간에 대해 적분한 연속적인 롤 오차를 썼다.

\[\text{integrated\_roll\_error} \mathrel{+}= \text{roll\_rate} \cdot \Delta t\]

기체가 롤하면 elevator와 rudder가 작용하는 두 수평축도 함께 돈다. 그래서 위치와 속도 오차를 이 회전하는 좌표계로 투영해 정책에 넘겼다. 회전 좌표계의 부호를 반대로 넣은 A/B 검증은 0/3으로 더 나빠져 원래 부호로 되돌렸다.

정책 구조는 이전 글의 축별 구조를 유지했다. 다만 구조화된 축별 제어만으로는 표현하지 못하는 결합 동역학을 학습할 여지를 주려고, scale 0.2의 제한된(bounded) residual 경로를 추가했다. 기존 체크포인트에는 이 레이어가 없으므로, 누락된 residual 가중치를 0으로 초기화해 그대로 불러오는 하위 호환 로딩도 함께 구현했다. residual이 0이면 이전 구조와 정확히 같은 정책이 된다.

수평 위치 회복을 실제로 강화하려면 보상도 손봐야 했다. standard 보상의 위치 오차 가중치를 0.15에서 4.0으로 크게 올렸다. 강화학습에서 우리가 정책에 넘길 수 있는 목표는 결국 보상 하나뿐이라는 관점(reward hypothesis, Sutton & Barto 3.2절)에서 보면 자연스러운 조정이다. “오래 버티기”보다 “원점으로 돌아오기”를 원한다면, 위치 오차가 보상에서 차지하는 비중이 그만큼 커야 한다.

실험과 판단

아래 수치는 RealFlight 실기 시뮬레이션 평가에서 얻었다. 각 단계의 에피소드 길이와 초기 조건이 똑같지는 않으니 엄밀한 순위 비교가 아니라, 어느 방향이 회복에 유리했는지에 대한 기록으로 읽는 게 맞다.

단계 조건 결과 판단
초기 고정 3초 이전 후보 0/5, 평균 길이 약 153 곧바로 3초는 너무 어려움
curriculum v11 고정 2초 3/3, 평균 위치 오차 1.287 m 2초 회복 가능
curriculum v14 고정 2.5초 3/3, 평균 위치 오차 2.338 m 후반 커리큘럼 기반으로 채택
긴 handoff 0.3초, 0.5초 대표 평가 0/3 회복 제어 지연
throttle trim 0.75 후보 고정 3초 3/3, 후속 3/5 가장 유망한 영역
v19 최종 후보 trim 약 0.75, handoff 0.1초 3/3 + 5/5 최종 선택

고정 3초 상태에서 v14를 추가로 미세학습한 실험은 오히려 경계 이탈을 늘렸고, 저학습률 실험도 성능 저하 징후가 있어 중단했다. 그 과정에서 나온 emergency 체크포인트(ppo_hoverpilot_recovery_final_v2.pt)는 최종 후보에서 제외했다.

대표 학습 로그 읽는 법

아래는 최종 모델로 이어진 계열의 후반 커리큘럼 run 하나다. 커리큘럼 훈련의 로그는 처음 보면 이상하게 읽힌다.

회복 커리큘럼 학습 로그를 두 패널로 그린 손그림풍 그래프. 왼쪽 에피소드 보상은 학습 초반 360대에서 시작해 idle 커리큘럼이 0.44초에서 3.0초로 늘어남에 따라 꾸준히 내려가 후반에는 음수로 떨어진다. 오른쪽 에피소드 길이는 탐색 중 두 번 짧게 끊긴 것을 빼면 내내 600스텝 시간 제한을 채운다.

후반 recovery 커리큘럼 run. 왼쪽 보상 곡선은 학습이 진행되며 오히려 내려간다. 오른쪽 길이 곡선은 탐색 중 두 번 경계를 벗어난 구간을 빼면 대부분 600스텝을 완주한다.

왼쪽 보상 곡선이 학습이 진행될수록 내려가는 건 정책이 나빠져서가 아니다. 커리큘럼이 idle 시간을 0.44초에서 3.0초까지 늘리는 동안, 기체가 매 에피소드 더 크게 기운 상태에서 시작하므로 회복해야 할 오차 자체가 커진다. 그래서 오른쪽 길이 곡선이 계속 600스텝을 채워도(즉 완주해도), 위치·자세 벌점이 커진 만큼 보상은 내려간다. 커리큘럼에서는 보상의 절대값보다 “같은 난이도에서 완주하는가”를 봐야 한다. 후반에 길이가 두 번 짧게 끊긴 구간은 탐색 잡음이 낀 학습 도중의 경계 이탈이고, 종료 사유 집계에도 outside_trainer_cylinder가 그 시점에 찍혀 있다.

이 대표 run은 최종 설정 그 자체가 아니라 그 직전 단계임을 밝혀 둔다. 이 run의 handoff는 0.3초, recovery gain과 커리큘럼 길이도 최종값과 다르다. 이벤트 파일마다 run/config/text_summary가 다를 수 있으니, 로그를 비교할 때는 그 config를 함께 확인해야 한다. 참고로 최종 3/3 + 5/5 평가는 RealFlight 반복 검증을 빠르게 돌리려고 tensorboard_log_dir=None으로 실행했다. 그래서 최종 8/8 수치는 TensorBoard 이벤트 파일에는 없고, 아래 콘솔 평가 기록이 근거다.

최종 평가 수치

첫 번째 3회:

avg_reward=-5311.882
avg_length=600.0
reward_per_step=-8.853
position_error=3.600 m
altitude_error=0.237 m
idle_end_tilt=11.890 deg
terminations={'truncated': 3}

추가 5회:

avg_reward=-6159.745
avg_length=600.0
reward_per_step=-10.266
position_error=3.626 m
altitude_error=0.239 m
idle_end_tilt=12.478 deg
terminations={'truncated': 5}

여기서 truncated는 실패가 아니라 설정한 600스텝 시간 제한까지 완주했다는 뜻이다. 두 평가를 합쳐 8/8 완주했고, 실패에 의한 종료는 한 번도 없었다.

보상이 큰 음수인 건 이 회복 과제의 보상 프로파일이 위치 가중치를 4.0으로 크게 잡았고 에피소드가 600스텝이기 때문이다. 이전 글의 297대 보상과는 보상 정의 자체가 달라 직접 비교할 수 없다. 그래서 이 과제의 성능은 완주율, 에피소드 길이, 위치·고도 오차, 그리고 제어를 넘겨받는 시점의 기울기(idle-end tilt)로 읽는 게 맞다. 로그의 attitude_error는 축별 적분 오차의 합을 포함해 일반적인 오일러 기울기와 직접 비교하기 어려우므로 대표 지표로 쓰지 않았다.

수치를 있는 그대로 정리하면, 이 정책은 “완벽한 정밀 위치 호버”를 하지는 않는다. 평균 위치 오차가 약 3.6m이니 원점에 딱 붙어 있는 건 아니다. 대신 3초 idle로 평균 약 12도 기울고 흘러간 상태에서 시작해, 트레이너 원통 경계 안으로 회복하고 600스텝 내내 그 안에서 버틴다. 이전 글에서 남긴 “크게 벗어난 뒤에는 복원이 약하다”는 한계를 겨냥한 작업이었으므로, 지금 확보한 건 정밀도가 아니라 upset recovery 능력 쪽이다.

재현과 실행

권장 재현 설정은 다음과 같다.

cd /path/to/hover-pilot

uv run hoverpilot-ppo train \
  --episode-start-idle-seconds 3.0 \
  --episode-start-idle-throttle 0.66 \
  --episode-start-idle-curriculum-steps 60000 \
  --episode-start-handoff-seconds 0.1 \
  --max-episode-steps 600 \
  --timesteps 120000 \
  --save-path ppo_hoverpilot_recovery.pt

최종 체크포인트를 실행하려면 다음처럼 한다. --max-episode-steps를 생략하면 충돌이나 경계 이탈 같은 실제 종료 조건이 생길 때까지 한 에피소드를 무제한 실행한다.

uv run hoverpilot-ppo play \
  --checkpoint ppo_hoverpilot_recovery_final.pt

체크포인트는 정책/가치 가중치와 관측 설정을 담지만 옵티마이저 상태나 커리큘럼 진행도까지 복원하는 학습 스냅샷은 아니다. 이어서 학습할 때는 --resume-from과 함께 커리큘럼 시작 시간을 비롯한 나머지 하이퍼파라미터를 명시해야 한다.

마무리

되짚어 보면 이번에도 성능을 끌어올린 건 PPO 하이퍼파라미터를 더 뒤진 게 아니었다. 문제를 어떻게 겪게 하느냐를 설계한 쪽이었다. 안정 상태만 학습한 정책은 회복이 약했고, 코드로 초기 상태를 흩뿌리는 대신 시뮬레이터에서 쓰로틀만 유지해 자연스러운 기울기·속도·위치 오차를 만들었다. 처음부터 3초로 두면 실패만 쌓이므로 성공에 기반해 난이도를 올리는 0→3초 커리큘럼을 만들었고, idle과 정책의 경계 자체가 새로운 불안정 요인이어서 물리 시간 기반 smoothstep 인계를 붙였다. 더 부드러운 제어가 늘 더 좋지는 않다는 것도 변화량 제한 실험에서 확인했다.

다음은 이 회복 능력을 정밀도와 함께 세우는 쪽이 될 것 같다. 지금은 경계 안으로 되돌아와 오래 버티는 데까지 왔으니, 그 다음은 얼마나 원점에 가깝게 되돌아오는지, 그리고 더 긴 학습과 실제 기체에서도 같은 구조가 통하는지를 볼 차례다.

함께 보기

Share: LinkedIn