Een recept voor drone-racing met reinforcement learning
In dit artikel trainen we een reinforcement learning (RL) policy om de quadcopter te besturen: eerst om te hoveren op een vast punt, en vervolgens om door een reeks poorten (gates) te vliegen. Waar de vorige berichten tutorials waren die stap voor stap van een vrijlichaamsdiagram naar simulatiecode leidden, is dit bericht meer een 'recept': een verzameling trucs en technieken die nuttig bleken bij het trainen van een RL-policy voor quadcopter-racing.
Er is geen rigoureus bewijs voor waarom deze methoden werken, enkel empirische resultaten. Omdat dit een recept is en geen volledige handleiding, tonen we alleen de code die relevant is voor elke beslissing. De volledige omgeving is beschikbaar op GitHub onder mrandri19/quadcopter-racing. De visualisaties in dit artikel zijn gemaakt met Rerun.
Een incrementele aanpak
Het is niet effectief om een drone-racing-simulatie, RL-omgeving, beloningsstructuur en model allemaal tegelijk te implementeren. Er zijn te veel variabelen die net niet correct kunnen zijn, wat de prestaties van de policy beïnvloedt zonder dat er een goede manier is om dit te debuggen.
Daarom hebben we de taak opgesplitst in drie fasen, waarbij elke fase voortbouwt op de vorige:
- De "Hello, world!" van RL: Het trainen van een policy om het inverted pendulum (omgekeerde pendel) controleprobleem op te lossen met PPO en een gevectoriseerde
stable-baselines3omgeving. - Een aangepaste quadcopter RL-omgeving: Gebruikmaken van MuJoCo, het bepalen van de actie- en observatieruimtes, het splitsen van de high-level RL-based aansturing en de low-level P-controller, en het implementeren van een hover-beloning.
- Uitbreiding van hoveren naar racing: Het aanpassen van de omgeving voor racing, inclusief een nieuwe beloningsstructuur, observaties en een ontwerp voor willekeurige initialisatie.
Het oplossen van de inverted pendulum met vectorized PPO
We beginnen bij de basis en richten ons vectorized RL-probleem in. We gebruiken PPO van stable-baselines3 samen met een gevectoriseerde InvertedPendulum-v5 (cartpole) omgeving om te controleren of de policy de maximale episode-lengte kan bereiken.
from stable_baselines3 import PPO
from stable_baselines3.common.env_util import make_vec_env
from stable_baselines3.common.policies import ActorCriticPolicy
def main() -> None:
env = make_vec_env("InvertedPendulum-v5", n_envs=128)
model = PPO(
policy=ActorCriticPolicy,
env=env,
learning_rate=3e-3, # 3e-4 (default lr) * sqrt(n_envs) ~= 3e-3
n_steps=512, # lager dan standaard 2048, minder stappen nodig met 128 envs.
batch_size=1024, # hoger dan standaard 64 voor betere efficiëntie.
n_epochs=5, # lager dan standaard 10, versnelt training.
verbose=1,
)
model.learn(total_timesteps=750_000)
Discussie over hyperparameters
- Rollout-grootte (
nenvs * nsteps): Met 128 omgevingen en 512 stappen verzamelt PPO 65.536 transities vóór elke policy-update. Een grote batch is belangrijk om variëteit in de dataset te garanderen bij elke update. - Aantal PPO-updates (
totaltimesteps / rolloutsize): In dit geval ongeveer 11 updates. De model heeft voldoende updates nodig om te "evolueren" tijdens het trainen, aangezien nieuwe patronen verschijnen in de rollout-data naarmate de policyimproveert. - Aantal gradient-stappen (
totaltimesteps * nepochs / batchsize): Elke rollout wordt gesplitst in minibatches vanbatchsizeen hergebruikt voorn_epochspasses. Een grotere batch-grootte vermindert de ruis in de gradient, maar resulteert in minder trainingsiteraties per PPO-update.
Resultaten
In deze omgeving levert elke overlevingsstap +1 beloning op, waardoor de episodelengte en de beloning exact overeenkomen. De episodelengte groeit geleidelijk vanaf de eerste update en bereikt de limiet van 1.000 stappen rond 600k stappen.
Recept: Valideer PPO en de hyperparameters eerst op een eenvoudige "toy task". Dit scheidt het probleem "het algoritme is verkeerd geconfigureerd" van "mijn omgeving is fout".
Leren hoveren met een aangepaste quadcopter-omgeving
Nu we weten dat PPO met deze hyperparameters werkt, ontwikkelen we de quadcopter-omgeving en een eenvoudige hover-beloning. We gebruiken MuJoCo als physics-simulator.
Actie-ontwerp: CTBR + P-controller
De policy produceert niet direct vier motorcommando's. In plaats daarvan produceert het een CTBR-commando (Collective Thrust and Body Rates): een massa-genormaliseerde stuwkracht en gewenste roll-, pitch- en yaw-snelheden. Dit actie-ontwerp wordt gebruikt in diverse wetenschappelijke papers over drone-racing.
Een low-level proportionele (P) controller zet dit vervolgens om in individuele motorcommando's, waarbij de huidige hoeksnelheid in het body-frame als feedback wordt gebruikt:
def _controller(
self, omega_body: NDArray, denormalized_actions: NDArray
) -> NDArray:
f_des = denormalized_actions[:, 0]
w_des = denormalized_actions[:, 1:4]
w = omega_body
Jw = self._J_diag * w
u_w = self._K_p * (w_des - w) + np.cross(w, Jw)
f = f_des * self._g * self._mass / self._max_thrust / 4
u_p, u_q, u_r = u_w[:, 0], u_w[:, 1], u_w[:, 2]
motor = np.empty((self.num_envs, 4), dtype=np.float32)
motor[:, 0] = f - u_p - u_q - u_r
motor[:, 1] = f + u_p - u_q + u_r
motor[:, 2] = f - u_p + u_q + u_r
motor[:, 3] = f + u_p + u_q - u_r
np.clip(motor, 0, 1, out=motor)
return motor
CTBR is een veel makkelijker actieruimte voor de policy om te leren dan ruwe motorcommando's: de low-level controller regelt de snelle attitude-rate dynamiek, terwijl de policy redeneert over stuwkracht en gewenste rotatie. Dit helpt ook bij implementatie op echte hardware, omdat controllers zoals Betaflight CTBR-inputs accepteren.
De code implementeert een eenvoudige proportionele controller, met uitzondering van de term np.cross(w, Jw). Om dit te begrijpen, kijken we naar de rotatievergelijking van Euler voor de hoeksnelheid $\omega$ in het body-frame:
$$J \dot{\omega} = \mathbf{\tau} - \omega \times J \omega$$
De term $\omega \times J\omega$ is de gyroscopische koppeling die ontstaat door de vergelijking in een roterend frame te schrijven. Deze koppelt de drie rotatie-assen, waardoor een pure roll-snelheid een pitch- of yaw-versnelling kan veroorzaken. Als we het koppel $\mathbf{\tau} = Kp (\omega{des} - \omega)$ direct zouden aansturen, zou deze koppeling de respons vervormen. Door deze term toe te voegen (uw = Kp * (w_des - w) + np.cross(w, Jw)), wordt deze exact geannuleerd in de Euler-vergelijking:
$$J \dot{\omega} = \underbrace{Kp (\omega{des} - \omega) + \omega \times J \omega}{\mathbf{\tau}} - \, \omega \times J \omega = Kp (\omega_{des} - \omega)$$
Dit is een vorm van feedback linearization (ook wel dynamic inversion genoemd): we gebruiken bekende niet-lineaire dynamiek om het exacte koppel te berekenen dat de niet-lineariteit opheft, zodat het gesloten systeem zich gedraagt als het eenvoudige lineaire systeem waarvoor we de gain $K_p$ hebben ontworpen.
Recept: Laat de policy CTBR outputten en gebruik een P-controller om de rate-loop te sluiten. De controller absorbeert de snelle attitude-dynamiek en dezelfde interface werkt op echte hardware.
Observatie-ontwerp
De observatie bestaat uit de 13-dimensionale MuJoCo free-body state:
- Positie (3)
- Attitude quaternion (4)
- Lineaire snelheid (3)
- Hoeksnelheid in het body-frame (3)
obs[:, 0:3] = qpos[:, :3] # positie
obs[:, 3:7] = qpos[:, 3:7] # attitude quaternion
obs[:, 7:10] = qvel[:, :3] # lineaire snelheid
obs[:, 10:13] = omega_body # hoeksnelheid body-frame
Hover-beloning
De beloning bestaat uit een potentie-gebaseerde voortgangsterm op de negatieve afstand tot het doel, een oriëntatiestraf, een crash-straf en een kleine overlevingsbonus (alive bonus):
def _reward(
self,
curr_obs: NDArray,
terminated: NDArray,
) -> tuple[NDArray, NDArray]:
pos = curr_obs[:, 0:3]
dist = np.linalg.norm(pos - _TARGET_POS, axis=1)
curr_potential = -dist
progress = curr_potential - self._prev_potential
quat_xyz = curr_obs[:, 4:7]
orientation = -0.2 * np.linalg.norm(quat_xyz, axis=1)
crash = np.where(terminated, -5.0, 0.0)
alive = np.where(terminated, 0.0, 0.1)
total = progress + orientation + crash + alive
return total, curr_potential
Recept: Vorm de beloning als voortgang richting het doel, niet als de afstand tot het doel. Een afstandstraf betaalt in elke stap hetzelfde, terwijl voortgang alleen wordt beloond wanneer het gat wordt verkleind.
Resultaten
We hebben deze hover-omgeving getraind tegen drie verschillende doelen. In alle drie de gevallen bleef de crashrate miljoenen stappen lang rond de 1 hangen voordat deze scherp daalde. Daarna bleef de gemiddelde afstand tot het doel (distancetotargetmean) afnemen voor de rest van de training.
Recept: Verwacht dat de crash_rate miljoenen stappen lang op 1 staat voordat deze instort. Niets in de beloning levert namelijk iets op totdat de policy lang genoeg in de lucht kan blijven om beloningen te verzamelen.
Van hoveren naar racing
Hoveren op een punt bevestigt dat de low-level controller, de beloningsschaal en de PPO-hyperparameters correct zijn geïmplementeerd. Vervolgens breiden we de omgeving uit naar een circuit met acht poorten.
De volgende gate in de observatie
We definiëren twee tracks: een lus van acht poorten (één poort per 45 graden rond de oorsprong op variërende hoogte) en een "split-S" track van zeven poorten. Bij de split-S track dwingen poort 4 en 5 (die op dezelfde x,y-positie liggen maar verschillen in hoogte van $z=3.4$ naar $z=1.42$) de drone tot een steile verticale duik.
Ongeacht de track wordt de positie van de volgende poort toegevoegd aan de observatie, waardoor de observatieruimte 16-dimensionaal wordt:
obs[:, 13:16] = target_gates
Zodra we een poort passeren, werkt de omgeving de observatie bij.
Next-gate beloning
De beloning behoudt dezelfde structuur als de hover-beloning: voortgang, oriëntatiestraf, crash-straf en overlevingsbonus. De enige toevoeging is een eenmalige bonus wanneer de quadcopter binnen de GATERADIUS van de doelpoort komt, waarna het doel verschuift naar de volgende poort:
passed = (dist < _GATE_RADIUS) & ~terminated.astype(bool)
gate_bonus = np.where(passed, _GATE_BONUS, 0.0)
total = progress + orientation + crash + alive + gate_bonus
Wanneer een poort wordt gepasseerd, resetten we ook het potentieel naar het nieuwe doel, zodat de voortgangsterm geen discontinuïteit vertoont door de doelwissel.
Willekeurige gate-reset is cruciaal
Het belangrijkste detail is hoe episodes worden gereset. Als elke episode altijd start bij poort 0, moet de policy eerst poort 1 perfectioneren voordat poort 2 voor het eerst wordt gezien. Dit maakt het leren zeer moeilijk, omdat de policy weinig stimulans heeft om de doelpoort in de observatie daadwerkelijk te gebruiken.
In plaats daarvan kiest elke reset een willekeurige startpoort en richt hij zich op de volgende poort in de lus:
n_gates = len(self._gates)
start_gate_ix = self._rng.integers(0, n_gates)
target_gate_ix = (start_gate_ix + 1) % n_gates
start_gate = self._gates[start_gate_ix, :]
target_gate = self._gates[target_gate_ix, :]
Recept: Randomiseer de startpoort bij elke reset. Dit verandert één lange racing-taak in vele korte taken van één poort en dwingt de policy om de doelpoort uit de observatie te lezen.
Resultaten
We gebruikten dezelfde omgeving, beloningen en hyperparameters voor beide tracks. We tracken nu ook gatespassedmean (het gemiddeld aantal gepasseerde poorten per episode).
Hetzelfde patroon als bij de hover-runs herhaalt zich: de crashrate blijft het grootste deel van de training rond de 1 en stort dan in tussen 2,9M en 3,8M stappen. Daarna stijgt gatespassed_mean gestaag. Aan het einde van de training worden er gemiddeld 3,71 poorten gepasseerd per episode van 400 stappen (8 seconden) op de cirkeltrack.
De geconvergeerde policy kan in een enkele vlucht van 24 seconden (1.200 stappen) tot 21 poorten passeren. Op de split-S track, die lastiger is door de onregelmatige spacing en de verticale duik, bereikt de gatespassedmean ongeveer 3,55 na 5M stappen.
Conclusies en vervolgstappen
Elk complex project vereist het opdelen van het uiteindelijke doel in kleinere mijlpalen, en een RL-project is daarop geen uitzondering. Voor dit probleem is de progressie van toy task → hoveren → racing zeer effectief.
De belangrijkste lessen kunnen worden samengevat in vijf regels:
- Valideer PPO en de hyperparameters eerst op een toy task.
- Laat de policy CTBR outputten en gebruik een P-controller om de rate-loop te sluiten.
- Vorm de beloning als voortgang richting het doel, niet als de afstand tot het doel.
- Randomiseer de startpoort bij elke reset.
- Verwacht een lange periode waarin de
crash_rateop 1 staat voordat er progressie optreedt.
Regels 1, 4 en 5 hebben betrekking op de trainingsopzet en zijn toepasbaar op elk controleprobleem met sparse rewards. Regels 2 en 3 zijn specifiek voor quadcopters, hoewel de onderliggende principes generaliseerbaar zijn: geef de policy de eenvoudigste actieruimte die nog steeds de hardware kan aansturen, en beloon verandering in plaats van status.
Beperkingen en toekomstvisie
Er zijn momenteel enkele beperkingen:
- Perfecte state estimation: De policy observeert de ground-truth positie, attitude en snelheid, niet de data van sensoren zoals een IMU of camera. Om dit aan te pakken zou een implementatie van "Demonstrating Agile Flight from Pixels without State Estimation" nodig zijn.
- Alleen simulatie: De policy is niet geïmplementeerd op echte hardware. Voor sim2real zou ik beginnen met massale domain randomization of sim2real2sim benaderingen.
- Track-specifieke training: We moeten voor elke track apart trainen en kunnen niet direct overstappen op onbekende layouts. Onderzoek suggereert dat dit oplosbaar is door een procedurele track-generator te combineren met task switching op basis van leerprogressie.
Groetjes,