PPO 알고리즘

4족 보행 로봇의 보행 정책 학습

JungYeon Lee

2026-03-26

강의 로드맵

회차 주제
Lec 02 4족 보행 로봇 & IsaacGym 시뮬레이터
Lec 03 (오늘) PPO 알고리즘 — 보행 정책 학습
Lec 04 VAE & DreamWaQ — 지형 인식 없이 걷기
Lec 05 Gazebo Sim2Sim — 배포 전 검증

오늘 목표: RL 기초부터 PPO까지 이해하고, rsl_rl/algorithms/ppo.py의 구현을 분석

Part 1. 강화학습 기초

강화학습이란?

핵심 구조

환경 (Environment)
  │
  │ 관측 (obs), 보상 (reward)
  ▼
에이전트 (Agent)
  │
  │ 행동 (action)
  ▼
환경 (Environment)
  ...반복

4족 로봇에 대입하면

  • 환경: IsaacGym (legged_robot.py)
  • 관측: 45차원 (ang_vel, gravity, cmd, dof…)
  • 행동: 12차원 (관절 목표 위치)
  • 보상: 12개 항목 가중합

MDP (Markov Decision Process)

강화학습의 수학적 프레임워크:

\[ \text{MDP} = (S, A, P, R, \gamma) \]

기호 의미 로봇 예시
\(S\) 상태 공간 관절 각도, 속도, IMU…
\(A\) 행동 공간 12개 관절 목표 위치
\(P(s'\|s,a)\) 전이 확률 물리 시뮬레이션 결과
\(R(s,a)\) 보상 함수 속도 추종 + 페널티
\(\gamma\) 할인율 0.99 (go2_dreamwaq)

정책 (Policy)

정책 \(\pi(a|s)\): 상태 \(s\)에서 행동 \(a\)를 선택하는 확률 분포

로봇 보행에서는 연속 행동 공간 → 가우시안 정책 사용:

\[ \pi_\theta(a|s) = \mathcal{N}(\mu_\theta(s), \sigma^2) \]

  • \(\mu_\theta(s)\): Actor 네트워크 출력 (12차원)
  • \(\sigma\): 학습 가능한 표준편차 (init_noise_std = 1.0)
  • \(\theta\): 신경망 파라미터

목표: 누적 보상을 최대화하는 \(\theta\)를 찾는 것

\[ J(\theta) = \mathbb{E}_{\pi_\theta} \left[ \sum_{t=0}^{T} \gamma^t R(s_t, a_t) \right] \]

가치 함수 (Value Function)

상태 가치 함수 \(V^\pi(s)\): 상태 \(s\)에서 정책 \(\pi\)를 따랐을 때 기대 누적 보상

\[ V^\pi(s) = \mathbb{E}_{\pi} \left[ \sum_{t=0}^{T} \gamma^t R(s_t, a_t) \,\middle|\, s_0 = s \right] \]

어드밴티지 함수 \(A^\pi(s, a)\): 평균 대비 이 행동이 얼마나 좋은가?

\[ A^\pi(s, a) = Q^\pi(s, a) - V^\pi(s) \]

  • \(A > 0\): 평균보다 좋은 행동 → 확률 높이기
  • \(A < 0\): 평균보다 나쁜 행동 → 확률 낮추기

Part 2. Policy Gradient → PPO

Policy Gradient (REINFORCE)

정책의 파라미터 \(\theta\)를 직접 업데이트:

\[ \nabla_\theta J(\theta) = \mathbb{E}_{\pi_\theta} \left[ \nabla_\theta \log \pi_\theta(a|s) \cdot A^\pi(s, a) \right] \]

직관적 해석:

  • 좋은 행동 (\(A > 0\)) → \(\log \pi\) 증가 방향 → 해당 행동 확률 ↑
  • 나쁜 행동 (\(A < 0\)) → \(\log \pi\) 감소 방향 → 해당 행동 확률 ↓

문제점: 업데이트가 너무 크면 정책이 갑자기 망가짐 (학습 불안정)

PPO의 핵심 아이디어

Proximal Policy Optimization — 업데이트 크기를 제한하여 안정적 학습

확률 비율 (probability ratio):

\[ r_t(\theta) = \frac{\pi_\theta(a_t | s_t)}{\pi_{\theta_{\text{old}}}(a_t | s_t)} \]

  • \(r = 1\): 새 정책 = 이전 정책 (변화 없음)
  • \(r > 1\): 해당 행동의 확률이 증가
  • \(r < 1\): 해당 행동의 확률이 감소

PPO-Clip 목적 함수

\[ L^{CLIP}(\theta) = \mathbb{E}_t \left[ \min \left( r_t(\theta) A_t, \;\; \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) A_t \right) \right] \]

\(A_t > 0\) (좋은 행동)

\(r\)\(1+\epsilon\)을 넘으면 클리핑 → 확률을 너무 많이 올리지 않음

\(A_t < 0\) (나쁜 행동)

\(r\)\(1-\epsilon\) 아래로 가면 클리핑 → 확률을 너무 많이 내리지 않음

clip_param = 0.2 — 한 번의 업데이트에서 정책 변화를 ±20%로 제한

GAE (Generalized Advantage Estimation)

Advantage를 어떻게 추정할 것인가?

TD 잔차 (1-step):

\[ \delta_t = r_t + \gamma V(s_{t+1}) - V(s_t) \]

GAE\(\lambda\)로 다중 스텝 잔차를 지수 가중 평균:

\[ \hat{A}_t^{GAE} = \sum_{l=0}^{T-t} (\gamma \lambda)^l \delta_{t+l} \]

\(\lambda\) 특성
\(\lambda = 0\) 1-step TD — 낮은 분산, 높은 편향
\(\lambda = 1\) Monte Carlo — 높은 분산, 낮은 편향
\(\lambda = 0.95\) go2_dreamwaq에서 사용

PPO 전체 손실 함수

\[ L(\theta) = L^{CLIP}(\theta) + c_1 L^{VF}(\theta) + c_2 S[\pi_\theta] \]

의미 go2_dreamwaq 값
\(L^{CLIP}\) 정책 손실 (클리핑) clip_param = 0.2
\(L^{VF}\) 가치 함수 손실 (MSE) value_loss_coef = 1.0
\(S[\pi_\theta]\) 엔트로피 보너스 entropy_coef = 0.01

Part 3. 구현 — ActorCritic

ActorCritic 네트워크

# rsl_rl/modules/actor_critic.py
class ActorCritic(nn.Module):
    def __init__(self, num_actor_obs, num_critic_obs, num_actions,
                 actor_hidden_dims=[512, 256, 128],
                 critic_hidden_dims=[512, 256, 128],
                 activation='elu',
                 init_noise_std=1.0):
        super().__init__()

        # Actor — 관측 → 행동 평균
        # WAQ: 입력 64차원 (obs 45 + est_vel 3 + context_vec 16)
        actor_layers = []
        in_dim = num_actor_obs   # 64 (waq) / 45 (base) / 238 (oracle)
        for h in actor_hidden_dims:  # [512, 256, 128]
            actor_layers.append(nn.Linear(in_dim, h))
            actor_layers.append(get_activation(activation))  # ELU
            in_dim = h
        actor_layers.append(nn.Linear(in_dim, num_actions))  # → 12
        self.actor = nn.Sequential(*actor_layers)

        # Critic — 관측 → 상태 가치
        # WAQ: 입력 238차원 (obs 45 + est_vel 3 + privileged 190)
        critic_layers = []
        in_dim = num_critic_obs   # 238 (waq) / 45 (base)
        for h in critic_hidden_dims:  # [512, 256, 128]
            critic_layers.append(nn.Linear(in_dim, h))
            critic_layers.append(get_activation(activation))
            in_dim = h
        critic_layers.append(nn.Linear(in_dim, 1))
        self.critic = nn.Sequential(*critic_layers)

        # 행동 표준편차 (학습 가능)
        self.std = nn.Parameter(init_noise_std * torch.ones(num_actions))

ActorCritic — act / evaluate

    def act(self, observations):
        """행동 샘플링 (롤아웃 수집 시)"""
        action_mean = self.actor(observations)
        distribution = Normal(action_mean, self.std)
        actions = distribution.sample()
        actions_log_prob = distribution.log_prob(actions).sum(dim=-1)
        return actions.detach(), actions_log_prob.detach()

    def act_inference(self, observations):
        """추론 모드 — 평균만 사용 (노이즈 X)"""
        return self.actor(observations)

    def evaluate(self, critic_observations, actions):
        """PPO 업데이트 시 — log_prob, value, entropy 재계산"""
        action_mean = self.actor(self.actor_obs)  # 저장된 actor obs 사용
        distribution = Normal(action_mean, self.std)
        actions_log_prob = distribution.log_prob(actions).sum(dim=-1)
        entropy = distribution.entropy().sum(dim=-1)
        value = self.critic(critic_observations).squeeze(-1)
        return actions_log_prob, value, entropy

RolloutStorage — GAE 계산

# rsl_rl/storage/rollout_storage.py
class RolloutStorage:
    def __init__(self, num_envs, num_transitions_per_env, ...):
        # num_transitions_per_env = 24 (num_steps_per_env)
        self.observations = torch.zeros(num_transitions_per_env, num_envs, ...)
        self.actions = torch.zeros(num_transitions_per_env, num_envs, num_actions)
        self.rewards = torch.zeros(num_transitions_per_env, num_envs, 1)
        self.dones = torch.zeros(num_transitions_per_env, num_envs, 1)
        self.values = torch.zeros(num_transitions_per_env, num_envs, 1)

    def compute_returns(self, last_values, gamma=0.99, lam=0.95):
        """GAE로 advantage 계산"""
        advantage = 0
        for step in reversed(range(self.num_transitions_per_env)):
            if step == self.num_transitions_per_env - 1:
                next_values = last_values
            else:
                next_values = self.values[step + 1]
            next_is_not_terminal = 1.0 - self.dones[step].float()
            delta = (self.rewards[step]
                     + gamma * next_values * next_is_not_terminal
                     - self.values[step])
            advantage = delta + gamma * lam * next_is_not_terminal * advantage
            self.returns[step] = advantage + self.values[step]
        self.advantages = self.returns - self.values
        # 정규화
        self.advantages = (self.advantages - self.advantages.mean()) \
                         / (self.advantages.std() + 1e-8)

Part 4. 구현 — PPO 업데이트

PPO 알고리즘

# rsl_rl/algorithms/ppo.py
class PPO:
    def __init__(self, actor_critic,
                 clip_param=0.2,
                 num_learning_epochs=5,
                 num_mini_batches=4,
                 value_loss_coef=1.0,
                 entropy_coef=0.01,
                 learning_rate=1e-3,
                 max_grad_norm=1.0,
                 use_clipped_value_loss=True,
                 schedule='adaptive',
                 desired_kl=0.01):
        self.optimizer = torch.optim.Adam(
            actor_critic.parameters(), lr=learning_rate)

    def update(self, storage):
        mean_value_loss, mean_surr_loss = 0, 0

        # 5 에폭 반복
        for epoch in range(self.num_learning_epochs):
            # 4개 미니배치로 분할
            generator = storage.mini_batch_generator(self.num_mini_batches)

            for (obs_batch, critic_obs_batch, actions_batch,
                 target_values_batch, advantages_batch,
                 returns_batch, old_actions_log_prob_batch,
                 ...) in generator:

                # 현재 정책으로 재평가
                actions_log_prob, value, entropy = \
                    self.actor_critic.evaluate(critic_obs_batch, actions_batch)

                # 확률 비율
                ratio = torch.exp(actions_log_prob - old_actions_log_prob_batch)

                # PPO-Clip 손실
                surrogate = -torch.squeeze(advantages_batch) * ratio
                surrogate_clipped = -torch.squeeze(advantages_batch) * \
                    torch.clamp(ratio, 1.0 - self.clip_param,
                                       1.0 + self.clip_param)
                surrogate_loss = torch.max(surrogate, surrogate_clipped).mean()

                # 가치 함수 손실 (clipped)
                value_clipped = target_values_batch + \
                    (value - target_values_batch).clamp(
                        -self.clip_param, self.clip_param)
                value_loss = torch.max(
                    (value - returns_batch).pow(2),
                    (value_clipped - returns_batch).pow(2)
                ).mean()

                # 전체 손실
                loss = (surrogate_loss
                        + self.value_loss_coef * value_loss
                        - self.entropy_coef * entropy.mean())

                self.optimizer.zero_grad()
                loss.backward()
                nn.utils.clip_grad_norm_(
                    self.actor_critic.parameters(), self.max_grad_norm)
                self.optimizer.step()

Adaptive Learning Rate

go2_dreamwaq은 KL 기반 학습률 조절을 사용합니다:

# ppo.py — schedule='adaptive'
if self.desired_kl is not None and self.schedule == 'adaptive':
    with torch.inference_mode():
        kl = torch.sum(
            torch.log(sigma_ratio + 1e-5)
            + (torch.square(old_sigma_batch)
               + torch.square(mu_batch - old_mu_batch))
            / (2.0 * torch.square(sigma_batch) + 1e-5)
            - 0.5, axis=-1)
        kl_mean = torch.mean(kl)

        if kl_mean > self.desired_kl * 2.0:
            self.learning_rate = max(1e-5, self.learning_rate / 1.5)
        elif kl_mean < self.desired_kl / 2.0 and kl_mean > 0.0:
            self.learning_rate = min(1e-2, self.learning_rate * 1.5)

        for param_group in self.optimizer.param_groups:
            param_group['lr'] = self.learning_rate
KL 상태 조치 이유
kl > desired_kl × 2 lr /= 1.5 정책 변화가 너무 큼 → 학습률 줄이기
kl < desired_kl / 2 lr *= 1.5 정책 변화가 너무 작음 → 학습률 올리기
그 외 유지 적절한 수준

하이퍼파라미터 정리 (go2_dreamwaq 실제값)

파라미터 설명
num_envs 4096 병렬 환경 수
num_steps_per_env 24 롤아웃 길이 (스텝)
learning_rate 1e-3 Adam 초기 학습률 (adaptive)
schedule ‘adaptive’ KL 기반 학습률 조절
desired_kl 0.01 목표 KL 발산
num_learning_epochs 5 미니배치 반복 횟수
num_mini_batches 4 미니배치 분할 수
gamma 0.99 할인율
lam 0.95 GAE 파라미터
clip_param 0.2 PPO 클리핑 범위
value_loss_coef 1.0 가치 손실 계수
entropy_coef 0.01 엔트로피 보너스 계수
max_grad_norm 1.0 gradient 클리핑

배치 크기: 4096 × 24 = 98,304 경험/업데이트

Part 5. 학습 실행

OnPolicyRunner — 학습 루프

# rsl_rl/runners/on_policy_runner.py
class OnPolicyRunner:
    def learn(self, num_learning_iterations):
        obs = self.env.get_observations()  # [4096, 45]
        critic_obs = self.env.get_privileged_observations()  # [4096, 190]

        for it in range(num_learning_iterations):

            # === 롤아웃 수집 (24 스텝) ===
            with torch.inference_mode():
                for i in range(self.num_steps_per_env):
                    actions = self.alg.act(obs, critic_obs)
                    obs, privileged_obs, rewards, dones, infos = \
                        self.env.step(actions)

                    # Timeout 처리 (에피소드 길이 초과 시 value bootstrapping)
                    if 'time_outs' in infos:
                        rewards += self.gamma * torch.squeeze(
                            self.alg.transition.values
                        ) * infos['time_outs'].unsqueeze(1)

                    self.alg.process_env_step(rewards, dones, infos)

            # === PPO 업데이트 ===
            last_values = self.alg.actor_critic.evaluate(critic_obs)
            self.alg.compute_returns(last_values)
            mean_value_loss, mean_surr_loss = self.alg.update()

            # 로깅 (wandb)
            self.log(it)

학습 실행 (train.py)

# go2_dreamwaq 학습 명령어
cd dreamwaq/legged_gym/legged_gym/scripts

# Base task (baseline)
python train.py --task go2_base --num_envs 4096

# Oracle task (상한선 확인)
python train.py --task go2_oracle --num_envs 4096

# DreamWaQ task (CENet 포함)
python train.py --task go2_waq --num_envs 4096
# 학습된 정책 시각화
python play.py --task go2_waq --load_run <run_name>

관측값 정규화 — RunningMeanStd

# rsl_rl/utils/rms.py
class RunningMeanStd(nn.Module):
    """관측값 정규화 — 온라인 평균/분산 추적"""

    def __init__(self, shape):
        super().__init__()
        self.register_buffer("mean", torch.zeros(shape))
        self.register_buffer("var", torch.ones(shape))
        self.register_buffer("count", torch.tensor(1e-4))

    def update(self, x):
        batch_mean = x.mean(dim=0)
        batch_var = x.var(dim=0)
        batch_count = x.shape[0]
        # Welford's online algorithm
        delta = batch_mean - self.mean
        tot_count = self.count + batch_count
        self.mean += delta * batch_count / tot_count
        self.var = (self.var * self.count
                    + batch_var * batch_count
                    + delta**2 * self.count * batch_count / tot_count) / tot_count
        self.count = tot_count

    def normalize(self, x):
        return (x - self.mean) / (self.var.sqrt() + 1e-8)

체크포인트에 저장되어 추론 시에도 동일한 정규화 적용

TensorBoard / WandB 로깅

# train.py — wandb 연동
import wandb
wandb.init(project="go2_dreamwaq", name=f"{task_name}_{run_name}")

# OnPolicyRunner에서 매 iteration 기록
wandb.log({
    "reward/total": mean_reward,
    "reward/tracking_lin_vel": ...,
    "loss/surrogate": mean_surr_loss,
    "loss/value": mean_value_loss,
    "policy/std": actor_critic.std.mean().item(),
    "lr": ppo.learning_rate,
}, step=iteration)

학습 곡선 해석

정상적인 학습

  • reward/total: 꾸준히 상승 후 수렴
  • policy/std: 점진적으로 줄어듦
  • lr: adaptive로 자동 조절
  • loss/value: 점차 감소

문제가 있는 경우

  • 보상이 갑자기 떨어짐 → kl_mean 체크
  • 보상이 0 근처 → 보상 가중치 확인
  • std가 안 줄어듦 → 학습이 안 됨
  • lr이 1e-5로 급감 → desired_kl 너무 작음

Part 6. 정리 & 다음 강의

오늘 배운 것

  1. RL 기초 — MDP, 정책, 가치 함수, Advantage
  2. Policy Gradient → PPO — 클리핑으로 안정적 업데이트
  3. GAE — 편향-분산 트레이드오프 (\(\lambda = 0.95\))
  4. ActorCritic — [512, 256, 128] MLP, Actor/Critic 입력 분리
  5. PPO 업데이트 — Clipped value loss, Adaptive LR
  6. RunningMeanStd — 관측값 온라인 정규화

다음 강의 — VAE & DreamWaQ

다룰 내용

  • CENet (Context-aided Estimator Network)
  • VAE 이론 & Reparameterization
  • AdaBoot: 속도 추정값 전환 메커니즘
  • OnPolicyRunnerWAQ 코드 분석

핵심 질문

외부 센서(카메라, LiDAR) 없이 로봇이 지형을 “느끼며” 걸을 수 있을까?

CENet이 관측 히스토리 5스텝으로 암묵적 지형 추정을 수행합니다.

참고 자료

  • 강의 코드 (DreamWaQ): go2_dreamwaq
  • PPO 원 논문: Schulman et al., “Proximal Policy Optimization Algorithms”, 2017
  • GAE 논문: Schulman et al., “High-Dimensional Continuous Control Using Generalized Advantage Estimation”, 2016
  • Spinning Up (OpenAI): PPO 해설
  • rsl_rl: PPO 구현 참고

Q & A

질문 있으신가요?