이전 글에서 네 채널 전체 제어를 축별 구조로 합쳐 안정적인 호버를 세웠지만 마지막에 한계를 하나 적어 뒀습니다. 이득과 트림이 안정 상태 근처의 작은 오차를 줄이도록 맞춰져 있어서 크게 기울어진 뒤에는 회복이 눈에 띄게 약했습니다. 실제로 제어 시작을 조금만 늦춰 기체가 기운 상태에서 정책에 맡기면 평형으로 돌아오지 못하고 그대로 추락했습니다.
이번 글은 그 회복 능력을 정면으로 훈련한 기록입니다. 핵심 아이디어는 단순합니다. 에피소드 시작 직후 일정 시간 동안 조종면을 중립으로 두고 쓰로틀만 유지해서, 기체가 자연스럽게 기울고 흘러가게 둡니다. 그렇게 악화된 상태에서 정책에 제어를 넘겨 회복을 배우게 합니다. 처음부터 크게 기울이면 실패만 쌓이므로, 기울이는 시간을 0초에서 3초까지 서서히 늘리는 커리큘럼으로 난이도를 올렸습니다.
결론부터 적으면, 최종 정책은 두 묶음의 결정론적 평가에서 각각 3/3과 5/5, 합쳐서 8/8 에피소드를 모두 600스텝 완주했습니다. 8회 모두 지면 충돌과 트레이너 원통 경계 이탈이 없었고, 제어를 넘겨받는 시점의 평균 기울기는 약 12도였습니다. 전체 코드 테스트는 191 passed다.
안정 상태에서만 시작하면 회복을 못 배운다
초기 호버 정책은 안정된 상태에서 제어를 시작하면 잘 떠 있었지만 그건 애초에 어려운 상황을 겪을 일이 없어서였습니다. 목표를 “오래 떠 있기”가 아니라 “무너진 상태에서 되돌아오기”로 바꾸려면 학습 과정에서 실제로 무너진 상태를 겪게 해야 합니다.
항공에서는 자세(pitch·bank)나 속도가 정상 범위를 크게 벗어나 자칫 제어를 잃을 수 있는 상태를 upset이라 부르고, 거기서 정상 자세로 되돌리는 훈련을 upset recovery(비정상 자세 회복)라고 합니다. 이 프로젝트에서 하려는 것도 정확히 그것입니다. idle 구간에서 기체를 크게 기울여 upset 상태로 만든 뒤, 정책이 그 상태에서 호버로 회복하도록 학습시킨다.
초기 상태를 코드로 랜덤화하는 방법도 있지만 이 프로젝트는 RealFlight 시뮬레이터 안에서 돌아가므로 시뮬레이터의 물리를 그대로 쓰는 편이 자연스러웠습니다. 그래서 매 에피소드 시작 뒤에 다음 순서를 반복하게 했습니다.
- 트레이너 리셋으로 기체가 초기 호버 위치에 놓입니다.
aileron/elevator/rudder = 0으로 조종면을 중립에 둡니다.- 쓰로틀만
0.66으로 고정합니다. - 커리큘럼에 따라
0~3초를 기다립니다. 이 동안 기체는 스스로 기울고 흘러갑니다. - 기울기와 위치 오차가 생긴 상태에서 정책에 제어를 넘깁니다.
- 기체가 트레이너 원통 안에서 다시 호버로 복귀합니다.
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 하나입니다. 커리큘럼 훈련의 로그는 처음 보면 이상하게 읽힙니다.

후반 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 인계를 붙였습니다. 더 부드러운 제어가 늘 더 좋지는 않다는 것도 변화량 제한 실험에서 확인했습니다.
다음은 이 회복 능력을 정밀도와 함께 세우는 쪽이 될 것 같습니다. 지금은 경계 안으로 되돌아와 오래 버티는 데까지 왔으니, 그 다음은 얼마나 원점에 가깝게 되돌아오는지, 그리고 더 긴 학습과 실제 기체에서도 같은 구조가 통하는지를 볼 차례입니다.
함께 보기
- GitHub 저장소: hover-pilot
- 이전 글: 네 채널 전체 제어를 PPO로 학습하기
- 최종 체크포인트:
ppo_hoverpilot_recovery_final.pt(SHA-256d38ab63815eb36552056d7a1a55e98f3fc93b8d65b35218282e7ed2a1624f2fb)