HoverPilot - 한 채널씩, 두 채널씩, 차근차근 훈련하기

네 채널 전체 제어를 PPO로 학습하고 검증한 기록

By 전경원

이전 글에서는 제어 채널을 엘리베이터 하나로 좁혀서, 관측의 부호를 물리적으로 맞추고 위치 복원을 목표 프레임으로 옮기면 순수 PPO도 호버를 세운다는 걸 확인했다. 마지막에 남긴 숙제는 이 구조를 나머지 채널로 넓히는 일이었다. 이번 글은 aileron, rudder, throttle을 하나씩, 그 다음은 둘씩 묶어 훈련해 가며 호버링 훈련을 해 나아간 기록이다.

결론부터 적으면, 최종 정책은 다섯 번의 결정론적 평가에서 모두 300스텝을 완주했다. 평균 보상은 300스텝 기준 297.944, 평균 위치 오차 0.466m, 평균 지상 고도(AGL) 오차 0.039m이다. 이 숫자에 이르기까지 리셋 좌표를 원점으로 쓰던 방식을 버렸으며, 횡방향 복원을 엉뚱한 조종면에 연결했던 걸 계측값을 보고 바로잡는 과정이 필요했다.

조종 채널을 하나씩 활성화해서 훈련한다

네 채널을 처음부터 자유로운 MLP 정책에 맡기면 실패의 원인을 짚기 어렵다. 고도가 떨어진 게 throttle 탓인지, 기체가 기울어 추력의 수직 성분이 줄어든 탓인지, 조종면 부호가 반대인지 구분이 안 된다. 그래서 엘리베이터에서 쓴 방법을 그대로 이어, 채널을 하나씩 켜서 복원 방향부터 확인했다.

수직 호버에서는 제어축의 의미가 수평 비행과 다르다. 기수가 하늘을 향하므로 elevator와 rudder가 서로 직교하는 두 수평 방향의 기울기를 만들고, aileron은 기체 세로축을 도는 롤을 억제하며, throttle이 고도와 수직 속도를 맡는다. 각 축에서 반드시 지켜야 하는 복원 방향은 다음처럼 양의 이득(gain)과 학습 가능한 트림(중립 입력)을 가진 형태로 못박았다.

aileron  = trim - k_roll * roll_error - k_rate * roll_rate
throttle = trim - k_altitude * altitude_error - k_vertical * vertical_velocity
rudder   = k_angle * angle_error + k_rate * yaw_rate

aileron은 중립 호버에 가까운 트림이 약 0.78로 나왔고, throttle은 공통 초깃값으로 0.65를 유지했다. rudder는 수직 자세 근처에서 오일러 각이 불연속으로 튀는 걸 피하려고, 원본 오일러 값 대신 요(yaw) 각속도를 시간에 대해 적분한 상대 각도를 오차로 삼았다. 충돌이 나지 않는 단일 축 모드에서는 정상 종료가 없으므로, 일정 스텝마다 RFLink 연결을 일부러 끊었다 다시 이어 붙여 에피소드 경계를 만들었다.

부호를 확인한 뒤에는 throttle을 낀 두 채널 조합으로 넘어갔다. 각 실험의 길이와 초기 상태가 똑같지는 않으니 아래 숫자는 엄밀한 순위 비교가 아니다. 확인하려던 건, 단일 축에서 검증한 복원 방향(오차를 줄이려면 조종면을 어느 쪽으로 움직여야 하는가, restoring sign)이 결합해도 뒤집히지 않고 throttle이 다른 조종면의 움직임을 보상하며 고도를 지키는지였다.

모드 대표 결과 확인한 내용
elevator + throttle 298.2/300 종방향 위치와 고도 동시 복원
aileron + throttle 293.6/300 롤 억제 중 고도 유지
rudder + throttle 297.3/300 rudder 복원과 throttle 트림 학습

첫 전체 제어 시도를 통째로 되돌리다

축별 준비가 끝났다고 보고 네 채널을 한꺼번에 켰는데, 결과가 좋지 않았다. 그런데 정책을 탓하기 전에 걸리는 게 있었다. 당시 RealFlight가 정상 트레이너 제어 상태가 아니라 터미널의 play 모드에 눌려 있었을 가능성이 있었고, 지상 고도 목표를 0.5~1m로 바꾸는 실험까지 뒤섞여 있었다.

환경 자체가 의심스러운 상태에서 보상과 정책을 계속 만지면, 어떤 변경이 효과가 있었는지 영영 알 수 없다. 그래서 해당 브랜치의 변경을 전부 되돌렸다. 아까운 결과였지만, 이 되돌리기가 이후 작업의 기준이 됐다. 시뮬레이터 상태와 외부 제어 프로세스, 리셋 조건을 먼저 검증하지 않은 학습 결과는 정책 성능으로 해석하지 않는다.

리셋 위치가 아니라 고정 원점을 목표로

되돌린 뒤 처음 뜯어본 건 좌표계였다. 초기 구현은 에피소드 시작 상태를 위치와 고도의 원점으로 삼는 부분이 있었다. 이러면 리셋 직후에는 오차가 늘 0으로 보여서, 실제로 기체가 흘러가는 드리프트가 통째로 가려진다.

그래서 리셋을 여러 번 시켜 RealFlight가 기체를 어디에 놓는지부터 쟀다. 다섯 번의 리셋에서 X와 Y는 완전히 같았고, 지상 고도만 수 밀리미터 수준으로 흔들렸다.

측정 결과
X 1419.525024 m
Y -863.796143 m
평균 지상 고도 1.969176 m
지상 고도 표준편차 0.004596 m
경사각(inclination) 90°
대지 속도(groundspeed) 0 m/s

이 측정값을 바탕으로 모든 모드의 목표를 고정된 세계 좌표로 못박았다. X와 Y는 위 측정값, 지상 고도는 2.0m, 경사각은 90°, 대지 속도는 0m/s다. 리셋이나 재연결이 일어나도 이 목표를 현재 상태로 다시 맞추지 않는다. 조종면의 기하학적 부호를 정하는 데 필요한 기수 방위(heading) 같은 프레임만 리셋 상태에서 새로 잡는다. 이렇게 두자 시간 제한이나 재연결 이후에도 위치·고도 오차가 끊기지 않고 이어져서, 정책이 진짜 원점으로 돌아오는지를 평가할 수 있게 됐다.

여기서 측정한 X/Y는 지금 이 트레이너 리셋에서 나온 환경별 값이다. 다른 공항이나 항공기, 트레이너 설정에도 통하는 좌표는 아니다.

계측값이 드러낸 두 가지 좌표계 오류

전체 제어를 다시 붙이자, 단일 축에서는 안 보이던 문제 두 개가 실제 계측값(telemetry)에서 드러났다.

횡방향 복원은 aileron이 아니라 rudder다

처음에는 횡방향 위치 오차를 목표 롤로 바꿔 aileron에 연결했다. 직관적으로는 “옆으로 밀렸으니 반대로 롤을 줘서 되돌린다”였다. 그런데 목표 롤을 바꿔도 횡방향 속도가 원하는 방향으로 충분히 줄지 않았다.

수직 호버의 기하학을 다시 보면 답이 나온다. aileron은 기체 세로축 회전만 맡고, 수평 위치를 실제로 움직이는 직교 기울기는 elevator와 rudder가 만든다. 그러니 횡방향 위치와 속도는 rudder의 적분 각도 목표로 연결해야 맞다.

\[\text{target\_rudder\_angle} = \text{clamp}\big(-(2 \cdot e_{\text{lat}} + 3 \cdot v_{\text{lat}}),\ -30°,\ +30°\big)\]

바꾸고 나니 횡방향 오차가 약 0.9m까지 벌어진 구간에서도 다시 줄어들었고, 600스텝 연속 평가에서 트레이너 원통 경계를 벗어나지 않았다. 엘리베이터 편에서 종방향 위치를 목표 경사각으로 옮긴 것과 정확히 같은 구조를, 이번에는 rudder 쪽에 대칭으로 심은 셈이다.

수직 호버에서 원본 오일러 롤은 믿을 수 없다

두 번째는 RealFlight가 주는 원본 roll 값이었다. 경사각이 90°인 수직 자세는 오일러 각의 특이점에 놓인다. 실제 기체 롤 각속도는 복원 방향인데, 원본 롤 값은 반대 방향으로 수십에서 수백 도씩 튀는 구간이 있었다.

aileron만 켜고 나머지를 고정한 조건에서는 리셋 기준 원본 롤도 쓸 만했다. 하지만 전체 제어에서 경사각과 기수 방위가 함께 움직이면 불연속이 심해졌다. 그래서 전체 제어에 한해, 기체 롤 각속도를 적분한 상대 각도를 오차로 쓰도록 바꿨다.

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

트레이너가 위치를 다시 잡거나 실제 새 에피소드가 시작될 때만 이 적분값을 0으로 되돌린다. 바꾼 직후 900스텝 평가에서는 세 구간이 같은 기체 상태를 이어받으며 경계 이탈 없이 위치와 고도를 지켰다.

구간 보상 종료 이유
1 293.869 300스텝 시간 제한
2 298.036 300스텝 시간 제한
3 298.493 300스텝 시간 제한

원본 오일러 롤과 적분 롤은 이름만 비슷할 뿐 다른 값이다. 단일 축 모드에서 원본 롤이 통했다고 전체 제어에서도 통하리라 넘겨짚은 게 실수였다.

자유로운 MLP 대신 축별 구조를 합치다

되돌리기 전의 전체 제어 체크포인트는 일반적인 관측에 자유로운 actor MLP를 얹은 것이었다. 실제로 돌려 보니 aileron은 필요한 0.78 트림 대신 거의 0을 냈고 throttle도 검증된 0.65보다 낮게 나와서, 164~211스텝 사이에 트레이너 경계를 벗어났다. 네 채널을 한 번에 배우게 두면, 각 축에서 이미 확인한 복원 구조가 학습 과정에서 그대로 뭉개졌다.

그래서 최종 구현은 각 모드에서 검증한 구조를 그대로 이어 붙였다. actor는 축별 상태를 받아 네 채널을 계산하고, 크리틱(critic)만 횡방향 위치·속도까지 포함한 전체 관측으로 가치를 추정한다. 정책과 가치 추정을 분리해 맡기는 이 구조는 actor-critic 방법(Sutton & Barto 13.5절)을 따른다. 관측은 14차원이다.

[0:2]   적분 롤 오차, 롤 각속도
[2:8]   elevator 추종 오차, 피치 각속도,
        종방향 위치/속도, 고도, 수직 속도
[8:10]  throttle 고도 오차, 수직 속도
[10:12] rudder 횡방향 복원 추종 오차, 요 각속도
[12:14] 횡방향 위치 오차, 횡방향 속도

초기 이득과 트림은 단일·복합 모드에서 얻은 값을 그대로 가져왔다.

초기 파라미터
aileron 이득 [0.80, 2.50], 트림 0.78
elevator 이득 [0.55, 0.45]
throttle 이득 [1.50, 2.00], 트림 0.65
rudder 이득 [1.50, 1.20]

행동은 tanh로 누른 가우시안에서 샘플링한다. 그래서 탐색 중에도 RealFlight 채널 범위를 넘지 않고, PPO가 옛 정책과 새 정책의 로그 확률 비(ratio)를 일관되게 계산할 수 있다. 강조하자면 이 정책은 네 채널을 자유롭게 배우는 MLP가 아니다. 각 축의 복원 방향을 구조로 제한한 actor이고, PPO는 그 구조 안에서 이득과 트림만 미세 조정한다.

전체 제어 보상

전체 제어 보상은 한 축의 오차가 다른 축의 좋은 결과를 압도하지 못하도록, 정규화한 제곱 오차에 상한을 씌운 항들의 합으로 짰다.

reward = survival_reward
       - position_penalty
       - altitude_penalty
       - attitude_tracking_penalty
       - angular_rate_penalty
       - velocity_penalty
       - action_smoothness_penalty
       - boundary_proximity_penalty
       + terminal_penalty

목표는 고정 원점 X/Y 유지, 지상 고도 2m 유지, 경사각 90° 기준의 elevator 복원 목표 추종, 적분 롤을 0으로 억제, 횡방향 위치 기반 rudder 복원 목표 추종, 대지 속도 0m/s, 그리고 6m 원통 경계에 닿기 전부터 미리 커지는 벌점이다. 단순히 오래 버티는 정책보다, 위치·고도·자세·속도가 함께 원점으로 돌아오는 정책이 더 높은 보상을 받는다.

PPO는 구조를 거의 건드리지 않았다

축별 구조를 합친 초기 정책만으로도 이미 호버가 안정적이었다. 그 상태에서 PPO가 구조를 망가뜨리지 않고 이득과 트림만 다듬는지 보려고 5,120스텝을 학습했다.

uv run hoverpilot-ppo train \
  --control-mode all \
  --resume-from all_controls_structured_lateral_initial.pt \
  --timesteps 5120 \
  --n-steps 1024 \
  --batch-size 64 \
  --epochs 5 \
  --learning-rate 0.0001 \
  --entropy-coef 0.0001 \
  --policy-initial-std 0.08 \
  --max-episode-steps 300 \
  --save-path all_controls_ppo_final_v2.pt \
  --seed 42

PPO는 탐색을 위해 매 스텝 행동을 확률 분포에서 뽑는데, 이때 섞이는 무작위성을 탐색 잡음(exploration noise)이라고 부른다. 알고 있는 최선의 행동에 머무를지 아직 안 해본 행동을 시도할지의 균형인 exploration-exploitation trade-off(Sutton & Barto 2장)가 여기서도 그대로 작동한다. 이 잡음이 낀 학습 도중 원통 경계를 한 번 벗어났고, 그 순간이 텐서보드 로그에 그대로 남아 있다. 초반의 300스텝 에피소드들은 보상이 대체로 290대였는데, 2,400스텝 부근에서 시작한 에피소드가 300스텝을 못 채우고 228스텝 만에 잘리며 보상이 106.8까지 떨어졌다. 같은 구간의 종료 사유 집계에도 원통 이탈이 딱 한 번 찍혔다(outside_trainer_cylinder = 1). 그 직후 몇 스텝 만에 보상은 다시 297대로 회복했고, 남은 에피소드는 모두 300스텝을 채웠다. 5,120스텝 전체 평균 보상이 287.2로 내려앉은 것도 사실상 이 한 번의 이탈이 끌어내린 결과다. 탐색 잡음을 끈 결정론적 평가에서는 이런 이탈 없이 298에 가까운 보상으로 다섯 에피소드를 모두 완주했다.

전체 제어 PPO 학습 로그를 두 패널로 그린 손그림풍 그래프. 왼쪽은 에피소드 보상으로, 대부분 290대에 머무르다 2,628스텝 부근에서 106.8까지 급락한 뒤 곧 297대로 회복하며 결정론적 평가선 298 근처로 돌아온다. 오른쪽은 에피소드 길이로, 같은 지점에서 300스텝이 228스텝으로 한 번 잘렸다가 다시 300으로 돌아온다.

5,120스텝 학습 로그. 왼쪽 보상 곡선은 2,628스텝 부근에서 한 번 106.8까지 떨어졌다가 곧 회복했고, 오른쪽 길이 곡선은 같은 지점에서 에피소드가 228스텝으로 잘린 것을 보여준다. 이 한 번의 이탈을 빼면 학습 내내 300스텝을 채웠다.

지표 초기 구조화 정책 PPO 학습 정책
평균 보상 297.998 297.944
보상/스텝 0.99333 0.99315
평균 에피소드 길이 300 300
평균 위치 오차 0.484m 0.466m
평균 지상 고도 오차 0.032m 0.039m
평균 자세 오차 1.63° 1.66°
300스텝 완주 5/5 5/5

평균 보상 차이는 약 0.018%로 측정 변동 수준이었다. 위치 오차는 오히려 줄었고, 고도와 자세 오차 변화도 수 밀리미터와 0.03° 수준이다. 학습 전후로 actor 파라미터도 1% 안쪽에서만 움직였다.

파라미터 초기 PPO 후
aileron 이득 [0.8000, 2.5000] [0.7999, 2.4963]
aileron 트림 0.7800 0.7810
elevator 이득 [0.5500, 0.4500] [0.5549, 0.4481]
throttle 이득 [1.5000, 2.0000] [1.4948, 1.9962]
throttle 트림 0.6500 0.6467
rudder 이득 [1.5000, 1.2000] [1.5043, 1.1961]

파라미터가 이만큼밖에 안 움직인 건 PPO 업데이트 자체가 작았다는 뜻이다. 학습 마지막 구간에서 옛 정책과 새 정책이 얼마나 벌어졌는지를 재는 근사 KL 발산(approximate KL divergence)은 0.0001 수준이었고, 정책이 한 번에 너무 크게 바뀌지 않도록 변화를 잘라 내는 clip이 실제로 걸린 비율은 0이었다. 업데이트가 clip 한계에 부딪힐 만큼 정책이 크게 흔들린 적이 한 번도 없다는 뜻이다.

그러니 “PPO가 제어 법칙을 맨바닥에서 발견했다”고 말하면 사실과 다르다. 오차를 줄이는 조종면 방향(복원 부호)과, actor가 관측에서 행동을 뽑아내는 계산 방식(actor 구조) 모두 사람이 미리 못박아 두었고, PPO가 한 일은 그 구조의 이득과 트림을 조금 다듬은 것이다. 그리고 그 미세 조정이 성능을 떨어뜨리지 않았다는 점이 이 실험에서 확인하려던 전부였다.

단순화하고 다시 검증하다

결과가 좋아진 뒤 구현을 다시 들여다봤다. 단순화하다 성능이 새면 안 되니, 변경할 때마다 전체 테스트와 실제 5에피소드 평가를 반복했다. 걷어내거나 바로잡은 건 이런 것들이다. 더 이상 쓰지 않는 구형 13차원 관측을 지웠고, 모든 모드가 똑같이 쓰던 PPO 기본값의 중복 조건을 없앴으며, 전체 제어에서 쓰이지 않던 잘못된 버퍼 크기를 고쳤다. 무엇보다 평가기가 수직 자세에서 원본 오일러 롤을 자세 오차로 잘못 계산하던 부분과, 리셋 대기 중 throttle을 0으로 보내던 누락을 바로잡았다.

176 tests passed
36 subtests passed
Python compileall passed
git diff --check passed

단순화 뒤에도 같은 5에피소드 평가에서 PPO 정책은 평균 보상 297.944, 5/5 완주를 그대로 유지했다.

리셋 대기 중에 전체 제어가 안전한 초기 입력(aileron 0.78, elevator 0.0, throttle 0.65, rudder 0.0)을 보내도록 한 것도 이때 확실히 해 뒀다. 이 입력이 없으면 트레이너가 위치를 다시 잡은 직후, 정책이 시작하기도 전에 고도가 떨어져 학습 데이터에 쓸데없는 초기 과도응답이 섞인다.

마무리

되짚어 보면, 이번 작업에서 가장 크게 성능을 끌어올린 건 PPO 하이퍼파라미터를 더 뒤진 게 아니었다. 문제를 제어축 단위로 쪼개고, 수직 호버의 좌표계를 실제 계측값으로 다시 확인한 쪽이었다. 특히 세 가지가 결과를 갈랐다. 리셋 상태가 아니라 고정된 세계 X/Y와 고도를 목표로 쓴 것, 횡방향 복원을 aileron이 아니라 rudder에 연결한 것, 그리고 수직 특이점의 원본 롤 대신 롤 각속도 적분을 쓴 것이다.

자유로운 네 채널 MLP보다, 각 축에서 검증한 복원 구조를 이어 붙인 정책이 더 빨리 안정됐고, PPO는 그 구조를 크게 흔들지 않은 채 이득과 트림만 다듬었다. 축을 하나씩 세워 온 시리즈가 여기서 네 채널로 합쳐졌다.

다만 이 구조에는 뚜렷한 한계도 있다. 이득과 트림은 안정 상태 근처의 작은 오차를 줄이도록 맞춰져 있어서, 안정 상태에서 크게 벗어난 뒤에는 복원하는 능력이 눈에 띄게 떨어진다. 다음 작업은 이 부분을 개선하는 쪽이 될 것 같다. 그 다음에야 더 긴 학습에서 이 정책이 오래 버티는지, 그리고 시뮬레이터를 벗어난 실제 기체에서 같은 구조가 통하는지를 볼 차례다.

함께 보기

Share: LinkedIn