RL에서 RLVR까지 / 실습 튜토리얼
Hugging Face Deep RL Course · companion

다섯 칸 복도에서
LLM 사후학습까지

환경은 하나, 상태는 다섯 개. 모든 식을 손으로 계산하고,
같은 숫자가 코드에서 나오는지 assert로 확인합니다.

Phase 0–12 · 13단계본문 코드는 표준 라이브러리만인터랙티브 실험 16개선택 실습: PyTorch · TRL
시작하기

이 튜토리얼을 읽는 법

Hugging Face Deep RL Course를 순서대로 따라가면서 같은 개념을 아주 작은 복도 환경 하나에서 손으로 계산하고 코드로 확인합니다. Phase 8부터는 그 복도를 언어 모델의 토큰 생성 문제로 바꿔 LLM 사후학습(RLHF, DPO, GRPO와 그 변형)까지 이어 갑니다.

그림 0.1 Corridor-5. 상태는 칸 번호 0~4, 행동은 ←(0)과 →(1) 두 개입니다. 양 끝 칸에 들어가면 에피소드가 끝납니다. 할인율은 γ = 0.9를 기본으로 씁니다.

모든 레슨은 같은 다섯 단계로 진행합니다

① 직관복도 그림으로
질문 하나
② 수식번호를 붙인 식
예: 식 (1.2)
③ 손계산실제 숫자를 넣어
한 단계씩
④ 코드식 번호를 주석으로 단
Python
⑤ 검증assert 손계산 == 코드
+ 연습문제
그림 0.2 코드의 주석 식 (1.1)은 이 페이지의 같은 번호 수식을 가리킵니다. 식과 코드를 나란히 놓고 읽으세요.

전체 로드맵

PhaseHF 자료핵심 개념
Phase 0 · 준비Unit 0gymnasium API, 표기법
Phase 1 · MDP와 ReturnUnit 1상태·행동·보상, return, 할인율
Phase 2 · 가치와 BellmanUnit 2 전반V, Q, Bellman 기대 방정식, MC와 TD
Phase 3 · Q-LearningUnit 2 후반ε-greedy, off-policy TD 제어
Phase 4 · DQNUnit 3함수 근사, replay buffer, target network
Phase 5 · REINFORCEUnit 4∇log π · G, baseline
Phase 6 · Actor-CriticUnit 6Advantage, A2C, GAE(λ)
Phase 7 · PPOUnit 8 Part 1ratio, clip, 데이터 재사용
Phase 8 · 복도 → 언어 모델Bonus 3prefix=상태, 토큰=행동, KL 정규화
Phase 9 · RLHFBonus 3 + TRL선호 쌍 → 보상 모델 → PPO + KL
Phase 10 · DPO / KTOTRL 문서보상 모델 없이 선호 학습
Phase 11 · RLVR / GRPOHF LLM Course 12장검증기 보상, 그룹 상대 advantage
Phase 12 · 최신 변형TRL GRPO 문서RLOO, Dr. GRPO, DAPO, GSPO
한 줄로 보는 흐름Return(1) → 가치와 Bellman(2) → 가치를 최대로(3) → 가치를 신경망으로(4) ‖ 확률을 직접(5) → 평균 대신 critic으로(6) → 너무 멀리 가지 않게(7) ‖ 토큰도 같은 문제(8) → 사람의 비교로 보상(9) → 보상 모델 없이(10) → 검증기와 그룹 비교(11) → 집계·ratio·clip 손보기(12)
건너뛰는 HF 단원Huggy, Unit 5(ML-Agents), Unit 7(Multi-agent), Unit 8 Part 2(Doom), Optuna 보너스는 LLM 사후학습으로 가는 핵심 경로와 거리가 있어 선택 과제로 둡니다.

Python 파일

본문 코드(Phase 0–12)는 표준 라이브러리만 씁니다. 신경망 역전파까지 직접 구현했기 때문에 모든 숫자를 들여다볼 수 있습니다. 파일을 모두 같은 폴더에 받으세요. extras/ 파일은 그 폴더 안의 extras 폴더에 둡니다. 이 페이지는 빌드할 때마다 본문 파일을 실제로 실행해 assert를 통과시키고, 그 출력을 그대로 싣습니다.

파일내용필요한 것
corridor.py공통 · Corridor-5 환경—
phase00_env.pyPhase 0corridor
phase01_return.pyPhase 1corridor
phase02_bellman.pyPhase 2corridor
phase03_qlearning.pyPhase 3corridor
phase04_dqn.pyPhase 4corridor, phase03
phase05_reinforce.pyPhase 5corridor, phase02
phase06_actor_critic.pyPhase 6phase02, phase05
phase07_ppo.pyPhase 7phase05, phase06
tinylm.py공통 · TinyLM (표로 된 언어 모델)—
phase08_lm_bridge.pyPhase 8tinylm
phase09_rlhf.pyPhase 9tinylm, phase07
phase10_dpo_kto.pyPhase 10tinylm, phase09
phase11_grpo.pyPhase 11tinylm
phase12_variants.pyPhase 12tinylm, phase07, phase11
extras/autograd_check.py선택 · 손 유도 gradient == autogradtorch
extras/dqn_cartpole.py선택 · Phase 4 → CartPoletorch, gymnasium
extras/ppo_cartpole.py선택 · Phase 7 → CartPole / LunarLandertorch, gymnasium
extras/trl_dpo.py선택 · Phase 10 → 실제 LLMtrl
extras/trl_grpo.py선택 · Phase 11–12 → 실제 LLMtrl
실행 준비
python3 phase01_return.py          # 본문: Python 3.9 이상, 설치할 것 없음
python3 phase11_grpo.py

# 선택 실습 (extras/)
python3 -m pip install torch==2.8.0 "gymnasium[classic-control]==1.0.0"
python3 extras/autograd_check.py   # 손으로 유도한 gradient == PyTorch autograd
python3 -m pip install trl==0.24.0 transformers datasets
python3 extras/trl_grpo.py         # GPU 권장 (Colab T4 가능)
기존 교재와의 관계RL에서 RLVR까지 교재는 개념 지도와 LLM 알고리즘 비교에 집중합니다. 이 튜토리얼은 HF 코스 순서를 따르며 같은 환경에서 계산을 직접 따라가는 실습서입니다. 각 Phase 머리말에 대응하는 교재 장을 연결합니다.
Phase 0–4 · 기초와 가치 기반

Phase 0 · 준비: 복도 환경과 gymnasium API

HF 코스 Unit 0 · Unit 1 · The RL Framework와 함께 읽기

① 직관: 강화학습 코드는 전부 같은 루프로 돌아간다

LunarLander든 Atari든 LLM이든, 강화학습 코드는 “관측을 보고 → 행동을 고르고 → 환경이 결과를 돌려준다”를 반복합니다. 환경이 작으면 이 루프의 모든 숫자를 눈으로 따라갈 수 있습니다.

관측 Stobs
지금 몇 번 칸?
정책 πpolicy(obs)
← 또는 → 선택
행동 Atenv.step(action)
결과St+1, Rt+1,
terminated, truncated
그림 0.3 에이전트–환경 루프. 보상에 t+1을 붙이는 이유는 행동 At를 한 다음에 받기 때문입니다. HF 코스와 Sutton & Barto 교과서가 쓰는 표기입니다.

② 수식: 한 에피소드는 숫자의 나열이다

식 (0.1) · 궤적 (trajectory)
τ=(S0,A0,R1,S1,A1,R2,S2,…,ST)\tau=(S_0,A_0,R_1,S_1,A_1,R_2,S_2,\dots,S_T)
에피소드 하나에서 일어난 일을 시간순으로 적은 것입니다. T는 마지막 시점입니다. 종료 칸에 도착했거나 시간 제한에 걸린 시점이 T가 됩니다.
RL 용어기호gymnasium 코드Corridor-5에서
상태 / 관측Stobs, info = env.reset()0~4 중 하나의 정수 · 시작은 2
행동Atenv.action_space = Discrete(2)0 = ←, 1 = →
보상Rt+1reward0번 칸 도착 −1, 4번 칸 도착 +1, 그 외 0
종료ST가 종료 상태terminated=True0번 또는 4번 칸에 들어감
시간 제한—truncated=True20걸음 안에 끝나지 않음
정책π(a|s)내가 작성하는 함수이번 Phase에서는 반반 무작위

③ 손계산: step()이 돌려주는 다섯 값

시작 칸 2에서 →를 두 번 눌러 봅시다. 첫 번째 →는 3번 칸으로 갑니다. 아직 끝이 아니므로 보상은 0입니다. 두 번째 →는 4번 보물 칸이므로 보상은 +1이고 에피소드가 끝납니다.

env.reset()(2, {})env.step(1)(3, 0.0, False, False, {})env.step(1)(4, 1.0, True, False, {})
실험 0.1 · 직접 step()을 호출해 보기
terminated와 truncated를 구분하는 이유terminated는 환경 규칙상 끝난 것입니다. 구덩이에 빠지거나 보물을 찾은 경우로, 이후 받을 보상은 정말로 0입니다. truncated는 우리가 편의상 끊은 것입니다. 실제로는 이후에도 보상이 있을 수 있습니다. 이 차이는 Phase 2–3에서 가치를 추정할 때 (1 − terminated)로 다시 등장합니다.

④ 코드: 환경과 루프

Python · Corridor 환경 전체 · reset / step
corridor.py 에서 발췌
class Corridor(_Base):
    """5칸 복도. 상태 = 현재 칸 번호(int), 행동 = LEFT(0) 또는 RIGHT(1)."""

    def __init__(self, n=5, start=2, max_steps=20):
        self.n, self.start, self.max_steps = n, start, max_steps
        self.hole, self.goal = 0, n - 1           # 양 끝이 종료 칸
        if spaces is not None:                      # gymnasium이 있을 때만
            self.observation_space = spaces.Discrete(n)
            self.action_space = spaces.Discrete(2)

    def reset(self, seed=None, options=None):
        if spaces is not None:
            super().reset(seed=seed)
        self.pos, self.t = self.start, 0
        return self.pos, {}                         # (observation, info)

    def transition(self, s, a):
        """환경의 규칙(모델) P와 R: (s, a) → (s′, r, 종료 여부). Phase 2의 Bellman 계산이 쓴다."""
        assert a in (LEFT, RIGHT), a
        s2 = s + 1 if a == RIGHT else s - 1         # 전이 P: 결정적
        reward = {self.hole: -1.0, self.goal: 1.0}.get(s2, 0.0)
        return s2, reward, s2 in (self.hole, self.goal)

    def step(self, action):
        self.pos, reward, terminated = self.transition(self.pos, action)
        self.t += 1
        truncated = not terminated and self.t >= self.max_steps
        return self.pos, reward, terminated, truncated, {}

    def render(self):
        cells = ['H', '.', '.', '.', 'G']
        cells[self.pos] = '@'
        return ' '.join(cells)
Python · 에이전트–환경 루프와 무작위 정책
phase00_env.py 에서 발췌
def random_policy(obs, rng):
    """관측을 보지 않고 ←, →를 반반 고르는 정책."""
    return rng.choice([LEFT, RIGHT])


def run_episode(env, policy, seed=0):
    rng = random.Random(seed)               # 정책의 무작위성 (재현용 seed)
    obs, info = env.reset(seed=seed)
    trajectory = []                         # (s_t, a_t, r_{t+1}, s_{t+1}) 목록
    while True:
        action = policy(obs, rng)
        next_obs, reward, terminated, truncated, info = env.step(action)
        trajectory.append((obs, action, reward, next_obs))
        obs = next_obs
        if terminated or truncated:
            return trajectory, terminated

⑤ 검증

아래 함수는 손계산한 세 줄을 assert로 확인합니다. 시간 제한도 함께 확인합니다. 빌드할 때마다 실행되며, 아래는 실제 출력입니다.

Python · step() 반환값을 손계산과 대조
phase00_env.py 에서 발췌
def step_api_demo():
    env = Corridor()
    obs, info = env.reset()
    assert (obs, info) == (2, {})
    # → 한 번: 3번 칸 도착, 아직 종료 아님
    assert env.step(RIGHT) == (3, 0.0, False, False, {})
    # → 한 번 더: 보물 칸 도착, 보상 +1, 종료
    assert env.step(RIGHT) == (4, 1.0, True, False, {})

    # ←→를 10번 반복하면 20걸음째 시간 제한에 걸린다 (truncated)
    env.reset()
    for i in range(20):
        obs, r, terminated, truncated, _ = env.step(LEFT if i % 2 == 0 else RIGHT)
    assert (obs, r, terminated, truncated) == (2, 0.0, False, True)
실행 결과 · python phase00_env.py
step() API 점검 통과
seed=0: 2→ 3→ 4 | 보상 [0.0, 1.0] | 종료
seed=1: 2← 1← 0 | 보상 [0.0, -1.0] | 종료
seed=2: 2← 1← 0 | 보상 [0.0, -1.0] | 종료

HF Unit 1 실습과 연결하기 (선택)

HF Unit 1의 실습은 LunarLander를 Stable-Baselines3의 PPO로 학습합니다. 관측이 8차원 실수 벡터이고 행동이 4개라는 점만 다르고, reset/step 루프는 똑같습니다. 지금은 “같은 API로 큰 환경도 돌아간다”는 것만 확인하면 충분합니다. PPO의 내부는 Phase 7에서 직접 구현합니다.

Python · HF Unit 1 실습의 핵심 3줄 (Colab 권장, 이 페이지 빌드에서는 실행하지 않음)
# pip install "gymnasium[box2d]" stable-baselines3
import gymnasium as gym
from stable_baselines3 import PPO

env = gym.make("LunarLander-v2")   # gymnasium 1.0 이상에서는 "LunarLander-v3"
model = PPO("MlpPolicy", env, verbose=1)
model.learn(total_timesteps=1_000_000)
Q1. 2번 칸에서 ←, ← 를 누르면 step()은 각각 무엇을 돌려주나요?

첫 번째: (1, 0.0, False, False, {}). 두 번째: (0, -1.0, True, False, {}). 구덩이에 들어갔으므로 보상 −1과 함께 terminated입니다.

Q2. 정책이 관측을 전혀 보지 않는 random_policy도 “정책”인가요?

네. 정책은 상태에서 행동 분포로 가는 규칙 π(a|s)입니다. 모든 s에서 π(←|s) = π(→|s) = 0.5인 정책입니다. 학습이란 이 확률표를 더 좋은 쪽으로 바꾸는 과정입니다.

Q3. max_steps=20일 때 ←→를 계속 반복하면 어떻게 되나요?

20번째 step에서 truncated=True가 됩니다. 2번 칸에 있으므로 terminated는 False입니다. 실험 0.1에서 직접 확인해 보세요.

Phase 0–4 · 기초와 가치 기반

Phase 1 · MDP와 Return: 무엇을 최대화하는가

HF 코스 Unit 1 · Introduction to Deep RL과 함께 읽기 · 대응 교재 장: 상태·정책·rollout, Return, V, Q

① 직관: 보물에 “빨리” 가는 것이 왜 더 좋을까?

2번 칸에서 시작해 →→로 바로 보물을 찾는 경우와, ←로 한 번 헤맨 뒤 →→→로 보물을 찾는 경우를 생각해 봅시다. 둘 다 마지막에 +1을 받습니다. 그런데 우리는 직관적으로 앞쪽이 더 좋다고 느낍니다. 할인율 γ는 이 직관을 숫자로 만듭니다. 한 걸음 늦게 받는 보상마다 γ를 한 번씩 곱합니다.

② 수식: MDP와 return

식 (1.0) · Corridor-5를 MDP로 적기
MDP=(S,A,P,R,γ),S={0,1,2,3,4}, A={←,→}, γ=0.9\text{MDP}=(\mathcal S,\mathcal A,P,R,\gamma),\quad \mathcal S=\{0,1,2,3,4\},\ \mathcal A=\{\leftarrow,\rightarrow\},\ \gamma=0.9
P(s′|s,a)는 전이 확률입니다. 복도에서는 결정적이어서 →는 항상 s+1로 갑니다. Markov 성질은 다음 칸을 알려면 지금 칸만 알면 된다는 뜻입니다. 과거에 어디를 들렀는지는 필요 없습니다.
식 (1.1) · Return (할인 누적 보상)
Gt=Rt+1+γRt+2+γ2Rt+3+⋯=∑k=0T−t−1γkRt+k+1G_t=R_{t+1}+\gamma R_{t+2}+\gamma^2R_{t+3}+\cdots=\sum_{k=0}^{T-t-1}\gamma^kR_{t+k+1}
HF 코스는 이를 R(τ) 또는 “cumulative reward”라고 부릅니다. 기존 교재는 같은 보상을 rt로 적었습니다. 인덱스만 한 칸 다를 뿐 뜻은 같습니다.
식 (1.2) · 재귀형: 뒤에서부터 계산
Gt=Rt+1+γ Gt+1,GT=0G_t=R_{t+1}+\gamma\,G_{t+1},\qquad G_T=0
식 (1.1)에서 첫 항만 떼어 내면 나머지가 정확히 γGt+1입니다. 이 한 줄이 Phase 2의 Bellman 방정식과 Phase 6의 GAE로 그대로 이어집니다.
식 (1.3) · 목표: 기대 return
J(π)=Eτ∼π[G0]≈1N∑i=1NG0(i)J(\pi)=\mathbb E_{\tau\sim\pi}[G_0]\approx\frac1N\sum_{i=1}^{N}G_0^{(i)}
정책이 확률적이면 에피소드마다 G0가 달라집니다. 그래서 강화학습은 평균, 즉 기대값 J(π)를 최대화합니다. N번 돌려 평균 내는 것을 Monte Carlo 추정이라고 합니다.

③ 손계산: 세 궤적의 G0

궤적보상 R1, R2, …식 (1.1) 계산G0
→→ (최단)0, +10 + 0.9 × 10.900
←→→→ (우회)0, 0, 0, +10 + 0.9 × 0 + 0.9² × 0 + 0.9³ × 10.729
←← (실패)0, −10 + 0.9 × (−1)−0.900

이제 우회 궤적을 식 (1.2)로 뒤에서부터 계산해 봅시다. 마지막 보상부터 시작해서, 한 칸 앞으로 갈 때마다 “그 시점의 보상 + 0.9 × 뒤의 G”를 적습니다.

t=3 · 3→4G₃ = 1 + 0.9×0 = 1t=2 · 2→3G₂ = 0 + 0.9×1 = 0.9t=1 · 1→2G₁ = 0 + 0.9×0.9 = 0.81t=0 · 2→1G₀ = 0 + 0.9×0.81 = 0.729
그림 1.1 우회 궤적 ←→→→의 return을 뒤에서부터 계산. 같은 궤적 안에서도 시점마다 Gt가 다릅니다. 보물에 가까운 시점일수록 Gt가 큽니다. 이 숫자들이 Phase 5 REINFORCE에서 각 행동의 “점수”가 됩니다.
실험 1.1 · 궤적을 만들고 Gt 표 채우기
예제 궤적

④ 코드: 식 (1.1)과 식 (1.2)를 그대로 옮기기

Python · 식 (1.1) · 정의대로 더하기
phase01_return.py 에서 발췌
def discounted_return(rewards, gamma=0.9):
    """식 (1.1): G_0 = R_1 + γR_2 + γ²R_3 + ...

    rewards[k]가 R_{k+1}이다. 즉 k번째 행동 직후에 받은 보상.
    """
    return sum(gamma ** k * r for k, r in enumerate(rewards))
Python · 식 (1.2) · 뒤에서부터 한 번에
phase01_return.py 에서 발췌
def returns_backward(rewards, gamma=0.9):
    """식 (1.2): G_t = R_{t+1} + γ·G_{t+1},  종료 뒤 G_T = 0.

    뒤에서부터 한 번만 훑으면 모든 시점의 G_t가 나온다.
    """
    G, out = 0.0, []
    for r in reversed(rewards):
        G = r + gamma * G
        out.append(G)
    return out[::-1]                        # [G_0, G_1, ..., G_{T-1}]
Python · 손계산 표와 대조하는 assert
phase01_return.py 에서 발췌
def rewards_of(actions):
    """행동 목록을 복도에서 실행하고 보상 목록 [R_1, ..., R_T]를 돌려준다."""
    env = Corridor()
    env.reset()
    rewards = []
    for a in actions:
        _, r, terminated, truncated, _ = env.step(a)
        rewards.append(r)
    assert terminated, '예제 궤적은 종료 칸에서 끝나야 한다'
    return rewards


TRAJECTORIES = {                            # 이름: (행동 목록, 손계산 G_0)
    '→→':   ([RIGHT, RIGHT], 0.9),                  # 0 + 0.9·1
    '←→→→': ([LEFT, RIGHT, RIGHT, RIGHT], 0.729),   # 0.9³·1
    '←←':   ([LEFT, LEFT], -0.9),                   # 0 + 0.9·(−1)
}


def hand_check():
    for name, (actions, hand_G0) in TRAJECTORIES.items():
        rewards = rewards_of(actions)
        G0 = discounted_return(rewards)
        assert abs(G0 - hand_G0) < 1e-12, (name, G0, hand_G0)
        assert abs(returns_backward(rewards)[0] - G0) < 1e-12   # 두 식은 같은 값
        print(f'{name:5s} 보상 {rewards} → G_0 = {G0:.3f} (손계산 {hand_G0})')

    # 우회 궤적의 모든 시점 G_t: 표 1.2와 같은 값
    table = returns_backward(rewards_of(TRAJECTORIES['←→→→'][0]))
    assert all(abs(a - b) < 1e-12 for a, b in zip(table, [0.729, 0.81, 0.9, 1.0]))
    print('←→→→ 의 G_0..G_3 =', [round(g, 3) for g in table])

γ를 바꾸면 무엇이 달라지나

γ최단 →→우회 ←→→→차이해석
1.01.0001.0000.000언제 받든 같은 +1 → 돌아가도 손해가 없다
0.90.9000.7290.171이 튜토리얼의 기본값
0.50.5000.1250.375근시안적 → 늦은 보상의 가치가 급격히 줄어든다
Python · 같은 두 궤적을 세 가지 γ로 계산
phase01_return.py 에서 발췌
def gamma_effect():
    short = rewards_of([RIGHT, RIGHT])
    detour = rewards_of([LEFT, RIGHT, RIGHT, RIGHT])
    for gamma in (1.0, 0.9, 0.5):
        a, b = discounted_return(short, gamma), discounted_return(detour, gamma)
        print(f'γ={gamma}: 최단 {a:.3f}  우회 {b:.3f}  차이 {a - b:.3f}')
LLM으로 가는 연결 고리LLM 응답 한 개는 “토큰 수만큼 걷는 긴 복도”이고, 보상은 대개 마지막 토큰 뒤에 한 번만 나옵니다. 정답 검사가 그런 예입니다. 그래서 LLM의 RL에서는 γ = 1을 자주 씁니다. 길이에 대한 선호는 할인 대신 별도의 길이 보상이나 손실 정규화로 다룹니다. Phase 8에서 다시 다룹니다.

⑤ 검증: 정책마다 J(π)는 얼마인가

정책을 “매 칸에서 확률 p로 →”라고 두고, 식 (1.3)대로 20,000번 돌려 평균을 냅니다.

Python · 식 (1.3) · Monte Carlo로 J(π) 추정
phase01_return.py 에서 발췌
def estimate_J(p_right, episodes=20000, gamma=0.9, seed=0):
    """식 (1.3): J(π) = E[G_0] ≈ (1/N) Σ_i G_0^(i)   (몬테카를로 평균)

    정책 π: 매 칸에서 확률 p_right로 →, 나머지는 ←.
    """
    rng, env, total = random.Random(seed), Corridor(), 0.0
    for _ in range(episodes):
        env.reset()
        rewards, done = [], False
        while not done:
            a = RIGHT if rng.random() < p_right else LEFT
            _, r, terminated, truncated, _ = env.step(a)
            rewards.append(r)
            done = terminated or truncated
        total += discounted_return(rewards, gamma)
    return total / episodes
실행 결과 · python phase01_return.py
→→    보상 [0.0, 1.0] → G_0 = 0.900 (손계산 0.9)
←→→→  보상 [0.0, 0.0, 0.0, 1.0] → G_0 = 0.729 (손계산 0.729)
←←    보상 [0.0, -1.0] → G_0 = -0.900 (손계산 -0.9)
←→→→ 의 G_0..G_3 = [0.729, 0.81, 0.9, 1.0]
γ=1.0: 최단 1.000  우회 1.000  차이 0.000
γ=0.9: 최단 0.900  우회 0.729  차이 0.171
γ=0.5: 최단 0.500  우회 0.125  차이 0.375
P(→)=0.5: J ≈ +0.0009
P(→)=0.7: J ≈ +0.5469
P(→)=1.0: J ≈ +0.9000
P(→) = 0.5J ≈ 0

복도가 좌우 대칭이므로 정확한 값은 0입니다. 구덩이와 보물이 서로 상쇄됩니다.

P(→) = 0.7J ≈ 0.546

정확한 값 0.36 / 0.6598 ≈ 0.5456은 Phase 2에서 Bellman 방정식으로 손으로 유도합니다.

P(→) = 1.0J = 0.9

결정적 정책이라 매번 →→입니다. 평균을 낼 필요도 없이 항상 0.9입니다. 이 복도의 최적 정책입니다.

강화학습 문제의 정체정책 π를 바꾸면 J(π)가 바뀝니다. J(π)를 최대로 만드는 π를 찾는 것이 강화학습의 목표입니다. 이 목표를 공략하는 방법은 HF Unit 1의 두 갈래로 나뉩니다. 각 상태·행동이 얼마나 좋은지를 먼저 배우는 가치 기반은 Phase 2–4에서, π의 확률을 직접 바꾸는 정책 기반은 Phase 5–7에서 다룹니다.

HF Unit 1의 나머지 개념, 복도로 읽기

HF Unit 1 개념복도에서의 의미
Reward hypothesis“보물에 빨리 가라”는 목표를 보상 +1과 γ만으로 표현할 수 있다
Observation vs State복도에서는 관측 = 상태 (칸 번호 전체가 보임). 부분 관측이면 Markov 성질이 깨질 수 있다
Discrete vs Continuous action복도는 이산 행동 2개. LunarLander도 이산 행동 4개. 로봇 팔은 연속 행동
Episodic vs Continuing복도는 종료 칸이 있는 episodic 과제. truncated는 continuing 과제를 끊어 쓰는 장치이기도 하다
Exploration vs ExploitationP(→)=1이 최적이지만, 처음부터 그렇게 고정하면 다른 길을 시험해 볼 수 없다. Phase 3 ε-greedy에서 다룬다
Policy-based vs Value-based위의 두 갈래. Phase 2–4는 가치 기반, Phase 5–7은 정책 기반
Q1. 궤적 →←→→ 의 G₀는? (γ = 0.9)

보상은 0, 0, 0, +1입니다. 2→3→2→3→4로 네 걸음이므로 G₀ = 0.9³ = 0.729입니다. 우회 궤적 ←→→→와 같은 값입니다. 할인은 “몇 걸음 만에 받았나”만 봅니다.

Q2. γ = 0이면 어떤 정책이든 G₀는 무엇과 같나요?

G₀ = R₁. 첫 걸음의 보상만 봅니다. 2번 칸에서 첫 걸음은 항상 보상 0이므로 모든 정책의 J가 0이 되어 구별할 수 없습니다. 지나치게 근시안적인 에이전트입니다.

Q3. 식 (1.2)로 계산하면 왜 효율적인가요?

식 (1.1)을 시점마다 따로 계산하면 길이 T 궤적에 O(T²)번 곱셈이 필요합니다. 뒤에서부터 G = r + γG를 한 번씩 하면 O(T)입니다. 실제 RL 라이브러리의 return/GAE 계산도 모두 이 역방향 루프입니다.

Q4. 실험 1.1에서 시간 제한에 걸리면 G를 어떻게 계산했나요? 그게 정답인가요?

관측한 보상만으로 계산했으므로 이후 보상을 0으로 둔 셈입니다. 실제로는 계속 걸었다면 보상이 있었을 수 있으므로 과소/과대 추정입니다. 이 문제를 “남은 부분을 V(s)로 추정해서 채우기”로 해결하는 것이 Phase 2의 bootstrapping입니다.

다음: Phase 2 · 가치와 BellmanP(→)=0.7 정책의 J ≈ 0.546을 시뮬레이션 없이 연립방정식으로 정확히 구합니다. 이를 위해 각 칸의 가치 V(s)를 정의하고 식 (1.2)에 기대값을 씌우면 Bellman 방정식이 됩니다.
Phase 0–4 · 기초와 가치 기반

Phase 2 · 가치 함수와 Bellman 방정식

함께 읽기: Unit 2 · Value-based methods · The Bellman Equation · Monte Carlo vs TD
대응 교재 장: Return, V, Q · MC·TD·GAE

① 직관: 시뮬레이션 없이 J = 0.546을 구할 수 있을까?

Phase 1에서 “매 칸 70% 확률로 →” 정책의 J(π)를 20,000번 돌려 0.547로 추정했습니다. 그런데 복도는 칸이 5개뿐입니다. 각 칸에서 출발했을 때의 기대 return을 칸마다 하나씩 적어 두면, 칸끼리의 관계만으로 정확한 값을 구할 수 있습니다. 그 칸별 숫자가 가치 함수 V(s)입니다.

② 수식: V, Q, 그리고 Bellman 방정식

식 (2.1) · 상태 가치
Vπ(s)=Eπ ⁣[Gt∣St=s]V^\pi(s)=\mathbb E_\pi\!\left[G_t\mid S_t=s\right]
“s 칸에 서 있고, 앞으로 π대로 움직이면 평균적으로 받을 return.” Phase 1의 J(π)는 시작 칸의 가치 Vπ(2)입니다.
식 (2.2) · 행동 가치
Qπ(s,a)=Eπ ⁣[Gt∣St=s, At=a]Q^\pi(s,a)=\mathbb E_\pi\!\left[G_t\mid S_t=s,\ A_t=a\right]
“s 칸에서 이번 한 번만 행동 a를 하고, 그다음부터 π대로 움직이면 받을 평균 return.”
식 (2.3) · Bellman 기대 방정식 (V)
Vπ(s)=∑aπ(a∣s) Qπ(s,a)V^\pi(s)=\sum_a\pi(a\mid s)\,Q^\pi(s,a)
V는 행동별 가치 Q를 정책 확률로 평균한 것입니다.
식 (2.4) · 한 걸음 펼치기
Qπ(s,a)=R(s,a)+γ (1−done) Vπ(s′)Q^\pi(s,a)=R(s,a)+\gamma\,(1-\text{done})\,V^\pi(s^\prime)
Phase 1의 식 (1.2) Gt = Rt+1 + γGt+1에 기대값을 씌운 것입니다. 복도는 전이가 결정적이라 s′가 하나로 정해집니다. 종료 칸에 들어가면 이후 가치는 0입니다.

③ 손계산: P(→) = 0.7 정책의 V

식 (2.3)과 (2.4)를 1·2·3번 칸에 각각 적으면 미지수 세 개의 연립방정식이 됩니다.

칸← (확률 0.3)→ (확률 0.7)Bellman 식
1구덩이: −12번 칸: 0 + 0.9·V(2)V(1) = 0.3·(−1) + 0.7·0.9·V(2) = −0.3 + 0.63·V(2)
21번 칸: 0.9·V(1)3번 칸: 0.9·V(3)V(2) = 0.27·V(1) + 0.63·V(3)
32번 칸: 0.9·V(2)보물: +1V(3) = 0.27·V(2) + 0.7

V(1)과 V(3)을 V(2)의 식에 대입하면 V(2) = 0.27(−0.3 + 0.63V(2)) + 0.63(0.7 + 0.27V(2)) = 0.36 + 0.3402·V(2)입니다. 따라서 V(2) = 0.36 / 0.6598 ≈ 0.5456이고, V(1) ≈ 0.0437, V(3) ≈ 0.8473입니다. Phase 1의 Monte Carlo 추정 0.547과 맞습니다.

연립방정식을 푸는 대신 “모든 칸에 식 (2.3)을 한 번씩 적용하기(sweep)”를 반복해도 같은 답에 도착합니다. V = 0에서 출발한 처음 세 번은 다음과 같습니다.

sweepV(1)V(2)V(3)계산 (이전 sweep의 V 사용)
0000시작값
1−0.300.7V(1) = −0.3 + 0.63·0, V(3) = 0.27·0 + 0.7
2−0.30.360.7V(2) = 0.27·(−0.3) + 0.63·0.7 = −0.081 + 0.441
3−0.07320.360.7972V(1) = −0.3 + 0.63·0.36, V(3) = 0.27·0.36 + 0.7
∞0.04370.54560.8473정확한 해
실험 2.1 · 정책 평가: sweep을 눌러 V가 수렴하는 과정 보기

④ 코드: 식 (2.3)–(2.4)를 그대로

Python · 식 (2.3)–(2.4) · 한 칸의 Bellman backup
phase02_bellman.py 에서 발췌
def q_from_v(V, s, a, gamma=0.9):
    """식 (2.4): Q(s,a) = R(s,a) + γ·(1 − done)·V(s′)   (전이가 결정적인 복도)"""
    s2, r, done = ENV.transition(s, a)
    return r + gamma * (0.0 if done else V[s2])


def bellman_backup(V, s, pi, gamma=0.9):
    """식 (2.3): V(s) = Σ_a π(a|s) · Q(s,a)"""
    return sum(pi[a] * q_from_v(V, s, a, gamma) for a in (LEFT, RIGHT))
Python · 반복 정책 평가 (sweep)
phase02_bellman.py 에서 발췌
def evaluate_policy(p_right, gamma=0.9, sweeps=200):
    """반복 정책 평가: 모든 칸에 식 (2.3)을 동시에 적용하는 sweep을 되풀이한다."""
    pi = policy(p_right)
    V = {s: 0.0 for s in NONTERMINAL}       # 처음엔 아무것도 모른다: V = 0
    history = [dict(V)]
    for _ in range(sweeps):
        V = {s: bellman_backup(V, s, pi, gamma) for s in NONTERMINAL}  # 이전 V로 한꺼번에
        history.append(dict(V))
    return history
Python · 연립방정식을 손으로 푼 결과
phase02_bellman.py 에서 발췌
def exact_values(p_right, gamma=0.9):
    """식 (2.3)은 미지수 3개짜리 연립일차방정식이다. 손으로 푼 결과를 그대로 옮긴다.

    a = γ(1−p), b = γp 라 두면
      V1 = −(1−p) + b·V2
      V3 =  p     + a·V2
      V2 = a·V1 + b·V3  →  V2 = (−a(1−p) + b·p) / (1 − 2ab)
    """
    p = p_right
    a, b = gamma * (1 - p), gamma * p
    V2 = (-a * (1 - p) + b * p) / (1 - 2 * a * b)
    return {1: -(1 - p) + b * V2, 2: V2, 3: p + a * V2}

환경 모델 없이 배우기: Monte Carlo와 TD

Bellman 계산은 env.transition(s, a)로 “어디로 가고 보상이 얼마인지”를 미리 알아야 했습니다. 실제 환경에서는 대개 이 규칙을 모릅니다. 그래서 직접 움직여 보고 얻은 경험으로 V를 고쳐 나갑니다. HF Unit 2가 비교하는 두 방법입니다.

식 (2.5) · Monte Carlo
V(St)←V(St)+α[Gt−V(St)]V(S_t)\leftarrow V(S_t)+\alpha\left[G_t-V(S_t)\right]
에피소드가 끝나야 Gt를 알 수 있습니다. 실제로 받은 return 쪽으로 α만큼 다가갑니다.
식 (2.6) · TD(0)
V(St)←V(St)+α[Rt+1+γ(1−terminated)V(St+1)⏟TD target−V(St)]V(S_t)\leftarrow V(S_t)+\alpha\big[\underbrace{R_{t+1}+\gamma(1-\text{terminated})V(S_{t+1})}_{\text{TD target}}-V(S_t)\big]
한 걸음만 가 보고, 나머지 미래는 지금의 추정 V(St+1)로 채웁니다. 추정으로 추정을 고치는 이 방식을 bootstrapping이라고 합니다. 괄호 안의 값을 TD 오차 δ라고 부릅니다.

V = 0, α = 0.1에서 우회 에피소드 ←→→→를 한 번 경험했다고 합시다.

방법갱신 순서와 계산1회 뒤 V(1), V(2), V(3)
MC뒤에서부터 G = 1, 0.9, 0.81, 0.729를 각 칸의 목표로 사용. V(3) ← 0.1, V(2) ← 0.09, V(1) ← 0.081, 다시 V(2) ← 0.09 + 0.1(0.729 − 0.09)0.081, 0.1539, 0.1
TD(0)δ = 0 + 0.9·0 − 0 = 0 (세 번), 마지막만 δ = 1 − 0 = 1 → V(3) ← 0.10, 0, 0.1
1회차 TDV(3) = 0.12회차 →→δ = 0.9·0.1 = 0.09 → V(2) = 0.0092회차 마지막δ = 1 − 0.1 = 0.9 → V(3) = 0.19
그림 2.1 TD는 보상 정보가 에피소드마다 한 칸씩 앞쪽으로 번집니다. MC는 한 번에 모든 칸을 갱신하지만, 우연히 좋았던 한 에피소드의 운을 그대로 받아들입니다. 이 차이가 Phase 6 GAE의 λ로 다시 나옵니다.
Python · 식 (2.5)–(2.6) · MC와 TD 갱신
phase02_bellman.py 에서 발췌
def mc_update(V, states, rewards, alpha=0.1, gamma=0.9):
    """식 (2.5) Monte Carlo: 에피소드가 끝난 뒤 V(S_t) ← V(S_t) + α[G_t − V(S_t)]"""
    G = 0.0
    for t in reversed(range(len(rewards))):     # Phase 1의 식 (1.2)로 G_t 계산
        G = rewards[t] + gamma * G
        V[states[t]] += alpha * (G - V[states[t]])
    return V


def td_update(V, s, r, s2, terminated, alpha=0.1, gamma=0.9):
    """식 (2.6) TD(0): 한 걸음마다 V(S_t) ← V(S_t) + α[R + γ(1−terminated)V(S_{t+1}) − V(S_t)]"""
    target = r + gamma * (0.0 if terminated else V[s2])   # truncated는 여기 들어가지 않는다
    delta = target - V[s]                                  # TD 오차 δ
    V[s] += alpha * delta
    return delta
terminated만 곱하고 truncated는 곱하지 않는다Phase 0의 구분이 여기서 쓰입니다. 구덩이나 보물에 들어갔다면 미래가 정말로 0이므로 (1 − terminated) = 0입니다. 시간 제한으로 끊겼다면 미래가 있으므로 V(St+1)로 채웁니다. Phase 1 Q4에서 본 “끊긴 궤적의 G를 어떻게 하나?”에 대한 답입니다.
Python · TD(0)만으로 정책 평가하기 (환경 모델 사용 안 함)
phase02_bellman.py 에서 발췌
def td_evaluate(p_right, episodes=10000, alpha=0.01, gamma=0.9, seed=0):
    """정책 π를 실제로 돌리면서 TD(0)만으로 V를 배운다. 환경 모델(transition)은 쓰지 않는다."""
    import random
    rng, env = random.Random(seed), Corridor()
    V = {s: 0.0 for s in NONTERMINAL}
    for _ in range(episodes):
        s, _ = env.reset()
        while True:
            a = RIGHT if rng.random() < p_right else LEFT
            s2, r, terminated, truncated, _ = env.step(a)
            td_update(V, s, r, s2, terminated, alpha, gamma)
            if terminated or truncated:
                break
            s = s2
    return V

⑤ 검증

Python · 위 표의 숫자를 모두 assert로 대조
phase02_bellman.py 에서 발췌
def hand_check():
    # (1) sweep 표: 그림 2.2의 손계산
    h = evaluate_policy(0.7, sweeps=3)
    expect = [{1: 0, 2: 0, 3: 0}, {1: -0.3, 2: 0, 3: 0.7},
              {1: -0.3, 2: 0.36, 3: 0.7}, {1: -0.0732, 2: 0.36, 3: 0.7972}]
    for got, want in zip(h, expect):
        assert all(abs(got[s] - want[s]) < 1e-12 for s in NONTERMINAL), (got, want)

    # (2) 반복은 정확한 해로 수렴하고, Phase 1의 Monte Carlo 추정 J ≈ 0.547과 맞는다
    V, exact = evaluate_policy(0.7)[-1], exact_values(0.7)
    assert all(abs(V[s] - exact[s]) < 1e-12 for s in NONTERMINAL)
    assert abs(exact[2] - 0.36 / 0.6598) < 1e-12
    print('P(→)=0.7  V =', {s: round(v, 4) for s, v in exact.items()})
    print('P(→)=0.5  V =', {s: round(v, 4) for s, v in exact_values(0.5).items()})

    # (3) V로부터 Q: 표 2.1
    Q = {(s, a): q_from_v(exact, s, a) for s in NONTERMINAL for a in (LEFT, RIGHT)}
    assert abs(Q[2, RIGHT] - 0.9 * exact[3]) < 1e-12 and Q[1, LEFT] == -1.0
    assert abs(0.3 * Q[2, LEFT] + 0.7 * Q[2, RIGHT] - exact[2]) < 1e-12   # V = Σ π Q

    # (4) MC와 TD를 같은 에피소드 ←→→→ 에 한 번씩
    states, rewards, steps = run_actions([LEFT, RIGHT, RIGHT, RIGHT])
    V_mc = mc_update({s: 0.0 for s in NONTERMINAL}, states, rewards)
    V_td = {s: 0.0 for s in NONTERMINAL}
    deltas = [td_update(V_td, *step) for step in steps]
    assert deltas == [0.0, 0.0, 0.0, 1.0]
    assert V_td == {1: 0.0, 2: 0.0, 3: 0.1}
    # 뒤에서부터 갱신: t=3 V(3)=0.1, t=2 V(2)=0.09, t=1 V(1)=0.081, t=0 V(2)=0.09+0.1(0.729−0.09)
    want_mc = {1: 0.081, 2: 0.1539, 3: 0.1}
    assert all(abs(V_mc[s] - want_mc[s]) < 1e-12 for s in NONTERMINAL), V_mc
    print('MC 1회 후 V =', {s: round(v, 4) for s, v in V_mc.items()})
    print('TD 1회 후 V =', V_td, '· δ =', deltas)

    # (5) 두 번째 에피소드 →→ : TD는 정보를 한 칸 더 앞으로 전달한다
    _, _, steps = run_actions([RIGHT, RIGHT])
    deltas = [td_update(V_td, *step) for step in steps]
    assert abs(deltas[0] - 0.09) < 1e-12 and abs(V_td[2] - 0.009) < 1e-12
    assert abs(V_td[3] - 0.19) < 1e-12
    print('TD 2회 후 V =', {s: round(v, 4) for s, v in V_td.items()})
실행 결과 · python phase02_bellman.py
P(→)=0.7  V = {1: 0.0437, 2: 0.5456, 3: 0.8473}
P(→)=0.5  V = {1: -0.5, 2: 0.0, 3: 0.5}
MC 1회 후 V = {1: 0.081, 2: 0.1539, 3: 0.1}
TD 1회 후 V = {1: 0.0, 2: 0.0, 3: 0.1} · δ = [0.0, 0.0, 0.0, 1.0]
TD 2회 후 V = {1: 0.0, 2: 0.009, 3: 0.19}
TD 10000 에피소드 V = {1: 0.023, 2: 0.547, 3: 0.843} · 정확한 값 = {1: 0.044, 2: 0.546, 3: 0.847}
상수 α의 흔적TD 결과의 V(1)은 정확한 값과 0.02 정도 차이가 납니다. α를 고정하면 마지막 몇 에피소드의 운에 따라 값이 계속 조금씩 흔들립니다. 1번 칸은 방문이 드물어 특히 그렇습니다. α를 줄이거나 더 오래 돌리면 차이가 줄어듭니다.
Q1. P(→) = 0.5일 때 V(1), V(2), V(3)은? (실험 2.1로 확인)

V = (−0.5, 0, 0.5)입니다. 대칭이므로 V(2) = 0이고, V(1) = 0.5·(−1) + 0.5·0.9·0 = −0.5입니다.

Q2. 정책이 같아도 γ를 0.9에서 0.99로 바꾸면 V(2)는 커지나요, 작아지나요? (P(→) = 0.7)

커집니다. 대부분의 경로가 결국 보물에 도착하는데, 도착이 늦어도 덜 깎이기 때문입니다. exact_values(0.7, 0.99)를 실행하면 V(2) ≈ 0.673입니다.

Q3. TD 오차 δ가 평균적으로 0이면 무엇을 뜻하나요?

V가 Bellman 방정식을 만족한다는 뜻입니다. 즉 V가 Vπ에 도달했습니다. TD 학습은 δ를 0으로 만드는 과정입니다.

Phase 0–4 · 기초와 가치 기반

Phase 3 · Q-Learning: 최적 행동가치를 경험으로 배우기

함께 읽기: Unit 2 · Introducing Q-Learning · A Q-Learning example · Hands-on: FrozenLake · Taxi
대응 교재 장: Return, V, Q · Q-learning 식

① 직관: “정해진 정책의 가치” 대신 “가장 좋은 행동의 가치”

Phase 2는 정책이 이미 주어졌을 때 그 정책이 얼마나 좋은지를 계산했습니다. 우리가 원하는 것은 가장 좋은 정책입니다. 각 칸에서 “앞으로 늘 최선으로 움직인다면” 받을 가치 Q*(s,a)를 알면, 정책은 간단합니다. 칸마다 Q*가 큰 행동을 고르면 됩니다.

② 수식

식 (3.1) · Bellman 최적 방정식
Q∗(s,a)=R(s,a)+γ (1−done)max⁡a′Q∗(s′,a′)Q^*(s,a)=R(s,a)+\gamma\,(1-\text{done})\max_{a^\prime}Q^*(s^\prime,a^\prime)
식 (2.4)와 같은 모양에서 “정책 확률로 평균” 자리가 “가장 큰 값(max)”으로 바뀌었습니다.
식 (3.2) · Q-learning 갱신
Q(s,a)←Q(s,a)+α[r+γ (1−terminated)max⁡a′Q(s′,a′)−Q(s,a)]Q(s,a)\leftarrow Q(s,a)+\alpha\Big[r+\gamma\,(1-\text{terminated})\max_{a^\prime}Q(s^\prime,a^\prime)-Q(s,a)\Big]
식 (2.6)의 TD 갱신에서 V(s′) 대신 max Q(s′,·)를 씁니다. 환경 모델 없이 경험 (s, a, r, s′) 하나로 갱신합니다.
식 (3.3) · ε-greedy 행동 선택
a={무작위 행동확률 εarg⁡max⁡aQ(s,a)확률 1−εa=\begin{cases}\text{무작위 행동}&\text{확률 }\varepsilon\\\arg\max_aQ(s,a)&\text{확률 }1-\varepsilon\end{cases}
탐색(exploration)과 활용(exploitation)의 균형입니다. 동점이면 무작위로 고릅니다.

③ 손계산

먼저 정답지 Q*를 식 (3.1)로 구합니다. 최적 정책은 “항상 →”이므로 V*(3) = 1, V*(2) = 0.9, V*(1) = 0.81입니다.

칸Q*(s, ←)Q*(s, →)
1−1 (구덩이)0.9 · V*(2) = 0.81
20.9 · V*(1) = 0.7290.9 · V*(3) = 0.9
30.9 · V*(2) = 0.81+1 (보물)

이제 Q = 0, α = 0.5에서 에피소드 세 개를 직접 따라가 봅시다. 행동은 일부러 정해 두었습니다.

에피소드경험 (s, a, r, s′)식 (3.2) 계산바뀐 Q
A: →→(2,→,0,3)
(3,→,1,4 종료)
0 + 0.5(0 + 0.9·max(0,0) − 0)
0 + 0.5(1 − 0)
Q(2,→) = 0
Q(3,→) = 0.5
B: →→(2,→,0,3)
(3,→,1,4 종료)
0 + 0.5(0.9·0.5 − 0)
0.5 + 0.5(1 − 0.5)
Q(2,→) = 0.225
Q(3,→) = 0.75
C: ←←(2,←,0,1)
(1,←,−1,0 종료)
0 + 0.5(0.9·max(0,0) − 0)
0 + 0.5(−1 − 0)
Q(2,←) = 0
Q(1,←) = −0.5
실험 3.1 · 버튼으로 움직이며 Q 테이블 채우기

④ 코드

Python · 식 (3.2) · Q-learning 갱신 한 번
phase03_qlearning.py 에서 발췌
def q_learning_update(Q, s, a, r, s2, terminated, alpha=0.5, gamma=0.9):
    """식 (3.2): Q(s,a) ← Q(s,a) + α[ r + γ(1−terminated)·max_a′ Q(s′,a′) − Q(s,a) ]"""
    target = r + gamma * (0.0 if terminated else max(Q[s2]))
    td_error = target - Q[s][a]
    Q[s][a] += alpha * td_error
    return td_error
Python · 식 (3.3) · ε-greedy
phase03_qlearning.py 에서 발췌
def epsilon_greedy(Q, s, epsilon, rng):
    """식 (3.3): 확률 ε로 무작위 행동(탐색), 나머지는 Q가 가장 큰 행동(활용)."""
    if rng.random() < epsilon:
        return rng.randrange(len(Q[s]))
    best = max(Q[s])
    return rng.choice([a for a, q in enumerate(Q[s]) if q == best])   # 동점이면 무작위
Python · 학습 루프 (FrozenLake·Taxi에도 그대로 사용 가능)
phase03_qlearning.py 에서 발췌
def train_q_learning(env, n_states, n_actions, episodes=300, alpha=0.5, gamma=0.9,
                     epsilon=0.2, seed=0):
    rng = random.Random(seed)
    Q = [[0.0] * n_actions for _ in range(n_states)]          # Q 테이블
    for ep in range(episodes):
        s, _ = env.reset(seed=seed + ep)
        while True:
            a = epsilon_greedy(Q, s, epsilon, rng)               # 행동 정책: ε-greedy
            s2, r, terminated, truncated, _ = env.step(a)
            q_learning_update(Q, s, a, r, s2, terminated, alpha, gamma)  # 목표 정책: greedy(max)
            if terminated or truncated:
                break
            s = s2
    return Q
Python · 정답지: value iteration으로 Q* 계산
phase03_qlearning.py 에서 발췌
def optimal_q(gamma=0.9, sweeps=100):
    """식 (3.1) Bellman 최적 방정식을 반복 적용(value iteration)해 Q*를 구한다. 정답지 용도."""
    env = Corridor()
    Q = {(s, a): 0.0 for s in NONTERMINAL for a in (LEFT, RIGHT)}
    for _ in range(sweeps):
        new = {}
        for s, a in Q:
            s2, r, done = env.transition(s, a)
            new[s, a] = r + gamma * (0.0 if done else max(Q[s2, LEFT], Q[s2, RIGHT]))
        Q = new
    return Q
왜 off-policy인가움직일 때는 ε-greedy로 가끔 엉뚱한 행동을 합니다(행동 정책). 하지만 목표값에는 실제로 다음에 할 행동이 아니라 max Q(s′,·)를 씁니다(목표 정책 = greedy). 데이터를 모은 정책과 배우는 정책이 달라도 되는 이 성질 덕분에 Phase 4의 replay buffer가 가능합니다. max 대신 실제 다음 행동의 Q(s′,a′)를 쓰면 on-policy 방법인 SARSA가 됩니다.

⑤ 검증

Python · 손계산 표 A→B→C 대조
phase03_qlearning.py 에서 발췌
def scripted_episode(Q, actions, alpha=0.5):
    env = Corridor()
    s, _ = env.reset()
    for a in actions:
        s2, r, terminated, _, _ = env.step(a)
        q_learning_update(Q, s, a, r, s2, terminated, alpha)
        s = s2


def hand_check():
    Qstar = optimal_q()
    want = {(1, LEFT): -1.0, (1, RIGHT): 0.81, (2, LEFT): 0.729,
            (2, RIGHT): 0.9, (3, LEFT): 0.81, (3, RIGHT): 1.0}
    assert all(abs(Qstar[k] - v) < 1e-12 for k, v in want.items()), Qstar

    Q = [[0.0, 0.0] for _ in range(5)]
    scripted_episode(Q, [RIGHT, RIGHT])             # 에피소드 A
    assert Q[2][RIGHT] == 0.0 and Q[3][RIGHT] == 0.5
    scripted_episode(Q, [RIGHT, RIGHT])             # 에피소드 B
    assert abs(Q[2][RIGHT] - 0.225) < 1e-12 and Q[3][RIGHT] == 0.75
    scripted_episode(Q, [LEFT, LEFT])               # 에피소드 C
    assert Q[2][LEFT] == 0.0 and Q[1][LEFT] == -0.5
    print('손계산 A→B→C 뒤 Q =', {s: Q[s] for s in NONTERMINAL})
실행 결과 · python phase03_qlearning.py
손계산 A→B→C 뒤 Q = {1: [-0.5, 0.0], 2: [0.0, 0.225], 3: [0.0, 0.75]}
s=1: Q(s,←)=-0.992  Q(s,→)=+0.810   |  Q*(s,←)=-1.000  Q*(s,→)=+0.810
s=2: Q(s,←)=+0.729  Q(s,→)=+0.900   |  Q*(s,←)=+0.729  Q*(s,→)=+0.900
s=3: Q(s,←)=+0.810  Q(s,→)=+1.000   |  Q*(s,←)=+0.810  Q*(s,→)=+1.000
greedy 정책: ['→', '→', '→']

300 에피소드 뒤의 Q가 정답지 Q*와 소수 셋째 자리까지 같습니다. 복도처럼 전이가 결정적인 환경에서는 α를 고정해도 Q가 정확히 수렴합니다. 1번 칸의 Q(1,←)만 −0.992로 조금 덜 수렴했습니다. greedy 정책은 1번 칸에 거의 가지 않고, ε 탐색으로만 드물게 그 행동을 시도하기 때문입니다.

HF Unit 2 실습으로 넘어가기

train_q_learning()은 상태·행동 수만 받으므로 HF 실습 환경에 그대로 쓸 수 있습니다. FrozenLake 4×4(미끄럼 없음)에서 greedy 경로 0→4→8→9→13→14→15로 목표에 도착하고, Taxi-v3에서 평균 return 약 7.7을 얻는 것을 확인했습니다(gymnasium 1.0.0).

Python · FrozenLake와 Taxi에 적용 (gymnasium 필요)
import gymnasium as gym
from phase03_qlearning import train_q_learning

env = gym.make("FrozenLake-v1", map_name="4x4", is_slippery=False)
Q = train_q_learning(env, n_states=16, n_actions=4, episodes=2000, gamma=0.95, epsilon=0.3)

env = gym.make("Taxi-v3")
Q = train_q_learning(env, n_states=500, n_actions=6, episodes=5000, gamma=0.95, epsilon=0.1)
Q1. 실험 3.1에서 →→만 계속 반복하면 Q(2,→)는 어디로 가나요?

0.9로 갑니다. Q(3,→)가 1로 수렴하면서 Q(2,→)의 목표 0.9·max Q(3,·)도 0.9가 됩니다. 에피소드마다 남은 차이의 절반(α = 0.5)씩 줄어듭니다.

Q2. ε = 0이면 무슨 문제가 생길 수 있나요?

처음에 모든 Q가 0이라 동점 무작위로 시작하긴 합니다. 하지만 한쪽이 조금이라도 커지면 그 행동만 고르게 됩니다. 한 번도 시도하지 않은 행동의 Q는 영영 갱신되지 않습니다. 더 좋은 길이 있어도 발견하지 못합니다.

Q3. 미끄러운 FrozenLake(is_slippery=True)에서도 α = 0.5가 괜찮을까요?

전이가 확률적이면 같은 (s,a)에서도 결과가 매번 다릅니다. α가 크면 마지막 결과에 크게 흔들리므로, α를 줄이거나 점점 감소시켜야 기대값으로 수렴합니다.

Phase 0–4 · 기초와 가치 기반

Phase 4 · DQN: Q 테이블을 신경망으로

함께 읽기: Unit 3 · From Q-Learning to Deep Q-Learning · The Deep Q Algorithm · DQN (Mnih et al., 2015)

① 직관: 칸이 수백만 개라면?

Atari 화면이나 CartPole의 실수 관측값은 가짓수가 사실상 무한합니다. 칸마다 Q를 적어 둘 표를 만들 수 없습니다. 대신 상태를 넣으면 행동별 Q를 출력하는 함수 Qθ(s,·)를 신경망으로 만들고, 그 파라미터 θ를 학습합니다. 비슷한 상태는 비슷한 Q를 갖도록 일반화되는 것이 장점입니다.

one-hot(2)[0,0,1,0,0]선형층 WW[a][s]출력[Q(2,←), Q(2,→)]
그림 4.1 복도에서 one-hot 입력과 편향 없는 선형층을 쓰면 W[a][s]가 Q 테이블의 한 칸과 정확히 같습니다. 신경망 Q는 표 Q의 일반화입니다.

② 수식: Q-learning을 회귀 문제로

식 (4.1) · 목표값 (target network θ⁻)
y=r+γ (1−d)max⁡a′Qθ−(s′,a′)y=r+\gamma\,(1-d)\max_{a^\prime}Q_{\theta^-}(s^\prime,a^\prime)
식 (3.2)의 TD target과 같습니다. 다만 목표값은 가끔씩만 복사해 두는 별도의 신경망 θ⁻로 계산합니다. 학습 중인 θ로 계산하면 목표가 매 걸음 따라 움직여 불안정해지기 때문입니다.
식 (4.2) · 손실 (replay buffer에서 뽑은 batch B)
L(θ)=1∣B∣∑(s,a,r,s′,d)∈B12(Qθ(s,a)−y)2L(\theta)=\frac1{|B|}\sum_{(s,a,r,s^\prime,d)\in B}\tfrac12\big(Q_\theta(s,a)-y\big)^2
최근 경험만 연달아 학습하면 서로 비슷한 데이터에 치우칩니다. 지나간 경험을 버퍼에 모아 두고 무작위로 섞어 뽑습니다.
식 (4.3) · 경사 하강
θ←θ−η 1∣B∣∑(Qθ(s,a)−y)∇θQθ(s,a)\theta\leftarrow\theta-\eta\,\frac1{|B|}\sum\big(Q_\theta(s,a)-y\big)\nabla_\theta Q_\theta(s,a)
y는 상수로 취급합니다. 그래서 gradient는 고른 행동의 출력 Qθ(s,a)를 통해서만 흐릅니다. 선형층에서는 ∇W[a][s]Q = 1이므로, batch 크기 1이면 식 (3.2)와 같아집니다(η = α).

③ 손계산

(1) 같은 숫자 재현. 선형 Q로 Phase 3의 에피소드 A→B→C를 batch 1, η = 0.5로 학습하면, W[→][3] = 0.75, W[→][2] = 0.225, W[←][1] = −0.5로 Phase 3 손계산과 완전히 같습니다.

(2) target network를 고정한 batch 갱신. 이어서 θ⁻ ← θ로 복사하고 batch 두 개를 함께 학습합니다.

경험y (식 4.1, θ⁻ 사용)Qθ(s,a)Q − y½(Q − y)²
(2, →, 0, 3, 진행)0 + 0.9 · max(0, 0.75) = 0.6750.225−0.450.10125
(3, →, 1, 4, 종료)10.75−0.250.03125

손실은 두 값의 평균 0.06625입니다. η = 0.5로 한 걸음 내려가면 W[→][2] = 0.225 − 0.5·(−0.45)/2 = 0.3375, W[→][3] = 0.75 − 0.5·(−0.25)/2 = 0.8125입니다.

④ 코드

Python · one-hot + 선형층 = Q 테이블, 식 (4.1)–(4.3)
phase04_dqn.py 에서 발췌
class LinearQ:
    """one-hot 입력 + 선형층(편향 없음). 가중치 W[a][s]가 곧 Q 테이블의 칸 하나다."""

    def __init__(self, n_states=5, n_actions=2):
        self.W = [[0.0] * n_states for _ in range(n_actions)]

    def q(self, s):
        x = one_hot(s, len(self.W[0]))
        return [sum(w * xi for w, xi in zip(row, x)) for row in self.W]   # = [W[a][s] for a]

    def sgd_step(self, batch, target_net, lr, gamma=0.9):
        """식 (4.1)–(4.3)을 batch 평균으로 한 번 적용하고 loss를 돌려준다."""
        loss, grads = 0.0, {}
        for s, a, r, s2, done in batch:
            y = r + gamma * (0.0 if done else max(target_net.q(s2)))        # 식 (4.1)
            err = self.q(s)[a] - y                                          # Q − y
            loss += 0.5 * err ** 2 / len(batch)                             # 식 (4.2)
            grads[a, s] = grads.get((a, s), 0.0) + err / len(batch)         # 식 (4.3): ∂L/∂W[a][s]
        for (a, s), g in grads.items():
            self.W[a][s] -= lr * g
        return loss
Python · 2층 신경망과 손으로 쓴 역전파
phase04_dqn.py 에서 발췌
class MLP:
    """입력 → 은닉층(ReLU) → 행동별 Q. 역전파를 손으로 쓴 2층 신경망."""

    def __init__(self, n_in, n_hidden, n_out, rng):
        self.p = {'W1': [[rng.gauss(0, 0.5) for _ in range(n_in)] for _ in range(n_hidden)],
                  'b1': [0.0] * n_hidden,
                  'W2': [[rng.gauss(0, 0.5) for _ in range(n_hidden)] for _ in range(n_out)],
                  'b2': [0.0] * n_out}

    def forward(self, x):
        W1, b1, W2, b2 = (self.p[k] for k in ('W1', 'b1', 'W2', 'b2'))
        z = [sum(w * xi for w, xi in zip(row, x)) + b for row, b in zip(W1, b1)]
        h = [max(0.0, v) for v in z]                                          # ReLU
        q = [sum(w * hi for w, hi in zip(row, h)) + b for row, b in zip(W2, b2)]
        return q, (x, z, h)

    def backward(self, cache, dq):
        """dq = ∂L/∂q 를 받아 모든 파라미터의 gradient를 연쇄법칙으로 계산한다."""
        x, z, h = cache
        W2 = self.p['W2']
        g = {'W2': [[d * hi for hi in h] for d in dq], 'b2': list(dq)}
        dh = [sum(dq[o] * W2[o][j] for o in range(len(dq))) for j in range(len(h))]
        dz = [d if zj > 0 else 0.0 for d, zj in zip(dh, z)]                  # ReLU의 미분
        g['W1'] = [[d * xi for xi in x] for d in dz]
        g['b1'] = dz
        return g
Python · batch 하나로 DQN 갱신
phase04_dqn.py 에서 발췌
def dqn_update(net, target_net, batch, lr, gamma=0.9):
    """batch 하나로 식 (4.1)–(4.3)을 계산하고 SGD 한 걸음."""
    grads, loss = zeros_like(net.p), 0.0
    for s, a, r, s2, done in batch:
        q_next, _ = target_net.forward(one_hot(s2))
        y = r + gamma * (0.0 if done else max(q_next))           # 식 (4.1): target network로 y
        q, cache = net.forward(one_hot(s))
        err = q[a] - y
        loss += 0.5 * err ** 2 / len(batch)                      # 식 (4.2)
        dq = [err if i == a else 0.0 for i in range(len(q))]     # 고른 행동의 출력에만 gradient
        add_grads(grads, net.backward(cache, dq), 1 / len(batch))
    add_grads(net.p, grads, -lr)                                 # θ ← θ − lr·∇L
    return loss
Python · replay buffer + target network + ε 감소
phase04_dqn.py 에서 발췌
def train_dqn(steps=3000, batch_size=16, lr=0.05, gamma=0.9, buffer_size=1000,
              learning_starts=200, target_every=50, seed=0):
    rng = random.Random(seed)
    net = MLP(5, 16, 2, rng)
    target_net = copy.deepcopy(net)                              # θ⁻: 주기적으로만 복사
    buffer = deque(maxlen=buffer_size)                           # replay buffer
    env = Corridor()
    s, _ = env.reset()
    for step in range(steps):
        epsilon = max(0.05, 1.0 - step / 1500)                   # 탐색을 점점 줄인다
        q, _ = net.forward(one_hot(s))
        a = rng.randrange(2) if rng.random() < epsilon else max((0, 1), key=lambda i: q[i])
        s2, r, terminated, truncated, _ = env.step(a)
        buffer.append((s, a, r, s2, terminated))                 # 경험 저장 (truncated는 저장 안 함)
        s = env.reset()[0] if terminated or truncated else s2
        if step >= learning_starts:
            dqn_update(net, target_net, rng.sample(buffer, batch_size), lr, gamma)
        if step % target_every == 0:
            target_net = copy.deepcopy(net)
    return net
REPLAY BUFFER경험을 재사용하고 섞는다

같은 경험을 여러 번 학습하고, 연속된 비슷한 경험끼리의 상관을 끊습니다. off-policy인 Q-learning이라 가능합니다.

TARGET NETWORK목표를 잠시 고정한다

θ⁻를 50걸음마다 복사합니다. 그 사이 y가 고정되어 회귀 문제처럼 안정적으로 학습됩니다.

ε 감소처음엔 탐색, 나중엔 활용

1.0에서 0.05까지 선형으로 줄입니다. 신경망이 아무것도 모를 때는 무작위 행동이 더 많은 정보를 줍니다.

⑤ 검증

Python · 표 재현, batch 손계산, 역전파의 수치미분 검사
phase04_dqn.py 에서 발췌
def hand_check():
    # (1) LinearQ + target = online, batch 1, lr=α 이면 Phase 3의 Q-learning과 숫자가 같다
    net = LinearQ()
    for actions in ([RIGHT, RIGHT], [RIGHT, RIGHT], [LEFT, LEFT]):
        env = Corridor()
        s, _ = env.reset()
        for a in actions:
            s2, r, terminated, _, _ = env.step(a)
            net.sgd_step([(s, a, r, s2, terminated)], net, lr=0.5)
            s = s2
    assert net.W[RIGHT][3] == 0.75 and abs(net.W[RIGHT][2] - 0.225) < 1e-12
    assert net.W[LEFT][1] == -0.5                            # Phase 3 손계산 A→B→C와 동일

    # (2) target network를 고정하고 batch 2개로 한 걸음: 표 4.1
    target = copy.deepcopy(net)
    batch = [(2, RIGHT, 0.0, 3, False), (3, RIGHT, 1.0, 4, True)]
    loss = net.sgd_step(batch, target, lr=0.5)
    assert abs(loss - 0.06625) < 1e-12
    assert abs(net.W[RIGHT][2] - 0.3375) < 1e-12 and abs(net.W[RIGHT][3] - 0.8125) < 1e-12
    print(f'batch loss = {loss:.5f}, Q(2,→): 0.225 → {net.W[RIGHT][2]:.4f}, Q(3,→): 0.75 → {net.W[RIGHT][3]:.4f}')

    # (3) 손으로 쓴 역전파가 맞는지 수치미분(finite difference)으로 확인
    mlp = MLP(5, 4, 2, random.Random(1))
    x, a, y, eps = one_hot(2), RIGHT, 0.7, 1e-6
    def loss_fn():
        return 0.5 * (mlp.forward(x)[0][a] - y) ** 2
    q, cache = mlp.forward(x)
    g = mlp.backward(cache, [q[a] - y if i == a else 0.0 for i in range(2)])
    def nudge(k, i, j, d):
        if j is None:
            mlp.p[k][i] += d
        else:
            mlp.p[k][i][j] += d
    for k, i, j in (('W1', 0, 2), ('W1', 3, 2), ('W2', 1, 0), ('W2', 1, 3), ('b1', 1, None)):
        nudge(k, i, j, eps); up = loss_fn()
        nudge(k, i, j, -2 * eps); down = loss_fn()
        nudge(k, i, j, eps)
        numeric = (up - down) / (2 * eps)
        analytic = g[k][i] if j is None else g[k][i][j]
        assert abs(numeric - analytic) < 1e-6, (k, i, j, numeric, analytic)
    print('역전파 gradient == 수치미분: 확인')
실행 결과 · python phase04_dqn.py
batch loss = 0.06625, Q(2,→): 0.225 → 0.3375, Q(3,→): 0.75 → 0.8125
역전파 gradient == 수치미분: 확인
s=1: Q(s,←)=-1.000  Q(s,→)=+0.810   |  Q*(s,←)=-1.000  Q*(s,→)=+0.810
s=2: Q(s,←)=+0.729  Q(s,→)=+0.900   |  Q*(s,←)=+0.729  Q*(s,→)=+0.900
s=3: Q(s,←)=+0.810  Q(s,→)=+1.000   |  Q*(s,←)=+0.810  Q*(s,→)=+1.000
역전파를 믿지 말고 확인하라손으로 쓴 gradient는 수치미분 (L(θ+ε) − L(θ−ε)) / 2ε와 비교해 검증합니다. 실제 코드에서는 PyTorch가 역전파를 해 주지만, 새 손실함수를 만들 때 같은 습관이 버그를 잡아 줍니다. extras/autograd_check.py는 이 MLP의 gradient가 PyTorch autograd와 같은지도 확인합니다.

HF Unit 3으로 넘어가기: PyTorch로 CartPole

HF Unit 3 실습은 RL Zoo로 Atari Space Invaders를 학습합니다. 그 전에 같은 알고리즘을 직접 짠 짧은 PyTorch 코드로 CartPole에 적용해 보면 구조가 잘 보입니다. 복도 버전과 달라지는 것은 입력(4차원 실수), 신경망 크기, 그리고 역전파를 loss.backward()가 대신한다는 점뿐입니다.

Python · extras/dqn_cartpole.py · CleanRL dqn.py를 줄인 형태
extras/dqn_cartpole.py 전체
# -*- coding: utf-8 -*-
"""선택 실습 · Phase 4의 DQN을 PyTorch로 CartPole에 적용한다 (CleanRL dqn.py를 줄인 형태).

실행: pip install torch "gymnasium[classic-control]"  후  python extras/dqn_cartpole.py
CPU에서 1분 안쪽. 복도 버전과 바뀌는 것은 입력(4차원 실수)과 신경망 크기뿐이다.
"""
import random
import sys
from collections import deque

import gymnasium as gym
import numpy as np
import torch
import torch.nn as nn


def main(total_steps=30_000, seed=0):
    random.seed(seed)
    torch.manual_seed(seed)
    env = gym.make('CartPole-v1')
    q_net = nn.Sequential(nn.Linear(4, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, 2))
    target_net = nn.Sequential(nn.Linear(4, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, 2))
    target_net.load_state_dict(q_net.state_dict())
    optimizer = torch.optim.Adam(q_net.parameters(), lr=2.5e-4)
    buffer = deque(maxlen=10_000)                                   # replay buffer
    gamma, batch_size, returns, ep_return = 0.99, 128, [], 0.0
    obs, _ = env.reset(seed=seed)
    env.action_space.seed(seed)
    for step in range(total_steps):
        epsilon = max(0.05, 1.0 - step / (0.5 * total_steps))       # ε를 선형으로 감소
        if random.random() < epsilon:
            action = env.action_space.sample()
        else:
            with torch.no_grad():
                action = int(q_net(torch.tensor(obs, dtype=torch.float32)).argmax())
        next_obs, reward, terminated, truncated, _ = env.step(action)
        buffer.append((obs, action, reward, next_obs, terminated))
        ep_return += reward
        obs = next_obs
        if terminated or truncated:
            returns.append(ep_return)
            ep_return, (obs, _) = 0.0, env.reset()
        if step > 1_000 and step % 10 == 0:
            s, a, r, s2, d = map(lambda x: torch.as_tensor(np.array(x), dtype=torch.float32),
                                 zip(*random.sample(buffer, batch_size)))
            with torch.no_grad():
                y = r + gamma * (1 - d) * target_net(s2).max(dim=1).values          # 식 (4.1)
            q = q_net(s).gather(1, a.long().unsqueeze(1)).squeeze(1)
            loss = nn.functional.mse_loss(q, y)                                      # 식 (4.2)
            optimizer.zero_grad()
            loss.backward()                                                          # 식 (4.3): autograd
            optimizer.step()
        if step % 500 == 0:
            target_net.load_state_dict(q_net.state_dict())                           # θ⁻ ← θ
        if step % 5_000 == 0 and returns:
            print(f'step {step:6d}  최근 10 에피소드 평균 return {sum(returns[-10:]) / len(returns[-10:]):6.1f}')
    return sum(returns[-10:]) / 10


if __name__ == '__main__':
    main(int(sys.argv[1]) if len(sys.argv) > 1 else 30_000)
검증 때 기록한 출력 (torch 2.8.0, gymnasium 1.0.0, CPU, seed 0)
step   5000  최근 10 에피소드 평균 return   19.6
step  10000  최근 10 에피소드 평균 return   94.4
step  15000  최근 10 에피소드 평균 return   73.2
step  20000  최근 10 에피소드 평균 return  115.5
step  25000  최근 10 에피소드 평균 return  201.3

CartPole의 최대 return은 500입니다. 3만 걸음이면 200 근처까지 오르지만, 중간에 73으로 떨어지듯 DQN 학습 곡선은 들쭉날쭉합니다. 더 오래 돌리면 더 올라갑니다.

Q1. target network 없이 y를 Qθ로 계산하면 어떤 일이 생기나요?

θ를 한 걸음 바꿀 때마다 y도 함께 움직입니다. 신경망에서는 한 상태를 고치면 비슷한 다른 상태의 값도 같이 바뀌므로, 쫓아가는 목표가 도망가는 상황이 되어 발산하거나 진동하기 쉽습니다.

Q2. buffer에 terminated 대신 (terminated or truncated)를 저장하면?

시간 제한으로 끊긴 상태도 “미래 가치 0”으로 학습합니다. 끊긴 지점 근처의 Q가 체계적으로 낮게 학습됩니다. Phase 2의 caution 박스와 같은 문제입니다.

Q3. DQN을 LLM에 쓰지 않는 이유는?

LLM의 행동은 수만 개 토큰이고 상태는 가능한 모든 문장입니다. maxa′Q를 계산하고 안정적으로 학습하기 어렵습니다. 그래서 LLM 사후학습은 Phase 5부터 다루는 정책 기반 방법을 씁니다. 다만 TD target, 잠시 고정해 두는 기준 모델(PPO의 π_old, RLHF의 π_ref), 모아 둔 데이터 재사용 같은 아이디어는 계속 등장합니다.

Phase 5–7 · 정책 기반

Phase 5 · REINFORCE: 확률을 직접 바꾸는 정책 경사

함께 읽기: Unit 4 · Policy-based methods · Policy Gradient · The Policy Gradient Theorem · Hands-on: CartPole
대응 교재 장: Policy gradient와 REINFORCE · Advantage

① 직관: 가치를 거치지 않고 정책을 고친다

Phase 2–4는 먼저 Q를 배우고 거기서 정책을 꺼냈습니다. 정책 기반 방법은 순서를 바꿉니다. 칸마다 행동 확률을 직접 파라미터로 두고, 좋은 결과로 이어진 행동의 확률을 올립니다. LLM은 이미 “다음 토큰 확률”을 출력하는 정책이므로, LLM 사후학습은 모두 이 계열입니다.

정책은 칸마다 로짓 두 개 θs = [θs,←, θs,→]를 softmax에 넣어 만듭니다. θ = 0이면 π = [0.5, 0.5]입니다.

② 수식

식 (5.1) · 정책 경사 정리 (실전형)
∇θJ(θ)=Eτ∼πθ[∑tGt ∇θlog⁡πθ(At∣St)]\nabla_\theta J(\theta)=\mathbb E_{\tau\sim\pi_\theta}\Big[\sum_t G_t\,\nabla_\theta\log\pi_\theta(A_t\mid S_t)\Big]
“return이 컸던 행동의 log 확률을 올리는 방향.” 엄밀한 할인 목적함수에는 γt가 더 붙지만, HF 코스와 대부분의 구현은 이 형태를 씁니다.
식 (5.2) · softmax 정책의 log 확률 미분
∂log⁡π(a∣s)∂θs=onehot(a)−π(⋅∣s)\frac{\partial\log\pi(a\mid s)}{\partial\theta_s}=\mathrm{onehot}(a)-\pi(\cdot\mid s)
고른 행동 칸은 1 − π(a), 나머지 칸은 −π(b). θ = 0에서 →를 골랐다면 [−0.5, +0.5]입니다.
식 (5.3) · REINFORCE 갱신
θSt←θSt+α Gt ∇log⁡π(At∣St)\theta_{S_t}\leftarrow\theta_{S_t}+\alpha\,G_t\,\nabla\log\pi(A_t\mid S_t)
에피소드 하나를 끝까지 본 뒤(Monte Carlo) 모든 걸음을 한꺼번에 갱신합니다.
식 (5.4) · baseline을 뺀 REINFORCE
θSt←θSt+α (Gt−b(St)) ∇log⁡π(At∣St)\theta_{S_t}\leftarrow\theta_{S_t}+\alpha\,\big(G_t-b(S_t)\big)\,\nabla\log\pi(A_t\mid S_t)
b가 행동에 의존하지 않으면 기대 gradient는 그대로이고 흔들림(분산)만 줄어듭니다. 여기서는 b(s) = Vπ(s) (Phase 2)를 씁니다.

③ 손계산 (θ = 0, α = 1)

에피소드걸음별 Gt × ∇log π갱신 뒤 θP(→|s)
→→ (성공)t=0 칸2: 0.9 × [−0.5, +0.5]
t=1 칸3: 1.0 × [−0.5, +0.5]
θ₂ = [−0.45, 0.45]
θ₃ = [−0.5, 0.5]
칸2: σ(0.9) = 0.7109
칸3: σ(1.0) = 0.7311
←← (실패)t=0 칸2: −0.9 × [+0.5, −0.5]
t=1 칸1: −1.0 × [+0.5, −0.5]
θ₂ = [−0.45, 0.45]
θ₁ = [−0.5, 0.5]
칸2: 0.7109
칸1: 0.7311
←→→→ (우회)t=0 칸2: 0.729 × [+0.5, −0.5]
t=1 칸1: 0.81 × [−0.5, +0.5]
t=2 칸2: 0.9 × [−0.5, +0.5]
t=3 칸3: 1.0 × [−0.5, +0.5]
θ₂ = [−0.0855, 0.0855]
θ₁ = [−0.405, 0.405]
칸2: σ(0.171) = 0.5426
칸1: 0.6921
관찰 두 가지① 실패 에피소드도 배움을 줍니다. 음의 return이 ←를 밀어내서, 결과적으로 성공 에피소드와 똑같이 P(→|2)가 0.7109가 됩니다.
② 우회 에피소드에서는 칸 2의 ←도 G = 0.729 > 0이라 강화됩니다. 결국 보물에 갔다는 이유로 헤맨 행동까지 칭찬받는 것입니다. 이 신용 할당 문제를 baseline과 Phase 6의 critic이 다룹니다.
실험 5.1 · REINFORCE 한 번의 갱신 따라가기

④ 코드

Python · softmax 정책과 식 (5.2)
phase05_reinforce.py 에서 발췌
def softmax(logits):
    m = max(logits)
    e = [math.exp(x - m) for x in logits]
    return [x / sum(e) for x in e]


def grad_log_softmax(logits, a):
    """식 (5.2): ∂ log π(a|s) / ∂θ_s = onehot(a) − π(·|s)"""
    pi = softmax(logits)
    return [(1.0 if i == a else 0.0) - p for i, p in enumerate(pi)]


def new_theta():
    """칸마다 로짓 [θ_←, θ_→]. 0이면 π = [0.5, 0.5]."""
    return {s: [0.0, 0.0] for s in NONTERMINAL}


def p_right(theta, s):
    return softmax(theta[s])[RIGHT]
Python · 식 (5.3)/(5.4) · 에피소드 하나로 갱신
phase05_reinforce.py 에서 발췌
def returns_backward(rewards, gamma=0.9):
    G, out = 0.0, []
    for r in reversed(rewards):                     # Phase 1의 식 (1.2)
        G = r + gamma * G
        out.append(G)
    return out[::-1]


def reinforce_update(theta, states, actions, rewards, alpha=0.1, gamma=0.9, baseline=None):
    """식 (5.3)/(5.4): θ_s ← θ_s + α · (G_t − b(s_t)) · ∇ log π(a_t|s_t)

    에피소드 하나의 gradient를 업데이트 전 θ로 모두 계산한 뒤 한꺼번에 더한다.
    """
    G = returns_backward(rewards, gamma)
    steps = []
    for t, (s, a) in enumerate(zip(states, actions)):
        adv = G[t] - (baseline[s] if baseline else 0.0)
        steps.append((s, adv, grad_log_softmax(theta[s], a)))
    for s, adv, g in steps:
        theta[s] = [th + alpha * adv * gi for th, gi in zip(theta[s], g)]
    return G
Python · 학습 루프
phase05_reinforce.py 에서 발췌
def train_reinforce(episodes=2000, alpha=0.1, seed=0):
    rng, theta = random.Random(seed), new_theta()
    for _ in range(episodes):
        reinforce_update(theta, *rollout(theta, rng), alpha=alpha)
    return theta

baseline은 정말 분산을 줄이나?

반반 정책에서 3번 칸을 방문할 때마다 θ₃[→] 방향의 gradient 추정값 (Gt − b)·∂log π/∂θ₃[→]를 모아 평균과 표준편차를 비교합니다.

Python · 방문마다 gradient 추정값 모으기
phase05_reinforce.py 에서 발췌
def gradient_samples(baseline, state=3, episodes=20000, seed=0):
    """π = 반반에서 state를 방문할 때마다 (G_t − b)·∂log π/∂θ_state[→] 값을 하나씩 모은다."""
    rng, theta, out = random.Random(seed), new_theta(), []
    for _ in range(episodes):
        states, actions, rewards = rollout(theta, rng)
        G = returns_backward(rewards)
        for t, s in enumerate(states):
            if s == state:
                b = baseline[s] if baseline else 0.0
                out.append((G[t] - b) * grad_log_softmax(theta[s], actions[t])[RIGHT])
    return out


def mean_std(xs):
    m = sum(xs) / len(xs)
    return m, (sum((x - m) ** 2 for x in xs) / len(xs)) ** 0.5

⑤ 검증

Python · 손계산 표와 대조
phase05_reinforce.py 에서 발췌
def sigmoid(x):
    return 1 / (1 + math.exp(-x))


def hand_check():
    assert grad_log_softmax([0.0, 0.0], RIGHT) == [-0.5, 0.5]

    # 성공 에피소드 →→ (α = 1): 표 5.1 첫 줄
    th = new_theta()
    G = reinforce_update(th, *run_scripted([RIGHT, RIGHT]), alpha=1.0)
    assert th[2] == [-0.45, 0.45] and abs(p_right(th, 2) - sigmoid(0.9)) < 1e-12
    assert abs(p_right(th, 3) - sigmoid(1.0)) < 1e-12

    # 실패 에피소드 ←← : 음의 return이 ←를 밀어내서 결과적으로 → 확률이 같은 만큼 오른다
    th = new_theta()
    reinforce_update(th, *run_scripted([LEFT, LEFT]), alpha=1.0)
    assert abs(p_right(th, 2) - sigmoid(0.9)) < 1e-12 and abs(p_right(th, 1) - sigmoid(1.0)) < 1e-12

    # 우회 에피소드 ←→→→ : 나쁜 행동(2에서 ←)도 G=0.729 > 0 이라 강화된다
    th = new_theta()
    reinforce_update(th, *run_scripted([LEFT, RIGHT, RIGHT, RIGHT]), alpha=1.0)
    assert abs(th[2][RIGHT] - (0.9 - 0.729) / 2) < 1e-12
    assert abs(p_right(th, 2) - sigmoid(0.171)) < 1e-12
    print('←→→→ 한 번 뒤 P(→|s) =', {s: round(p_right(th, s), 4) for s in NONTERMINAL})

    # 같은 에피소드에 baseline b(s) = V^π(s) (π = 반반, Phase 2): 상태마다 크기가 달라진다
    th = new_theta()
    V = exact_values(0.5)                            # {1: −0.5, 2: 0, 3: 0.5}
    reinforce_update(th, *run_scripted([LEFT, RIGHT, RIGHT, RIGHT]), alpha=1.0, baseline=V)
    assert abs(p_right(th, 1) - sigmoid(1.31)) < 1e-12 and abs(p_right(th, 3) - sigmoid(0.5)) < 1e-12
    print('baseline 사용  P(→|s) =', {s: round(p_right(th, s), 4) for s in NONTERMINAL})
실행 결과 · python phase05_reinforce.py
←→→→ 한 번 뒤 P(→|s) = {1: 0.6921, 2: 0.5426, 3: 0.7311}
baseline 사용  P(→|s) = {1: 0.7875, 2: 0.5426, 3: 0.6225}
3번 칸 방문당 θ₃[→] gradient 추정: baseline 없음 평균 +0.246 표준편차 0.354 | baseline V^π 평균 +0.246 표준편차 0.247
REINFORCE 2000 에피소드 뒤 P(→|s) = {1: 0.933, 2: 0.987, 3: 0.989}

baseline이 있어도 평균은 +0.246으로 같고, 표준편차는 0.354 → 0.247로 줄었습니다. 같은 학습률로 더 안정적으로 학습할 수 있다는 뜻입니다. b(2) = 0이라 2번 칸에서는 차이가 없으므로 3번 칸에서 측정했습니다.

PyTorch에서는실제 코드는 식 (5.2)를 직접 쓰지 않습니다. loss = -(log_prob * G).sum()을 만들고 loss.backward()를 부르면 autograd가 같은 gradient를 계산합니다. 부호가 −인 이유는 optimizer가 loss를 줄이는 방향으로 움직이기 때문입니다. extras/autograd_check.py가 식 (5.2)와 autograd의 일치를 확인합니다.
Q1. 실험 5.1에서 baseline을 켜고 ←→→→를 고르면 칸 2의 결과가 왜 그대로인가요?

b(2) = Vπ(2) = 0이기 때문입니다. 칸 1(b = −0.5)과 칸 3(b = 0.5)만 크기가 바뀝니다. 칸 3의 →는 G − b = 0.5로 덜 강화되고, 칸 1의 →는 1.31로 더 강화됩니다.

Q2. 모든 보상에 +10을 더하면 REINFORCE는 어떻게 되나요?

G가 모두 커져서 어떤 행동이든 강하게 강화됩니다. 기대 gradient는 같지만 분산이 커져 학습이 느려집니다. baseline이 이 상수를 빼 주는 역할을 합니다.

Q3. LLM에서 Gt는 무엇이 되나요?

보통 답변이 끝날 때 보상 하나만 받고 γ = 1이므로, 모든 토큰의 Gt가 같은 값 R이 됩니다. “좋은 답변의 모든 토큰 확률을 R만큼 올린다”가 LLM용 REINFORCE이고, Phase 8에서 그대로 구현합니다.

Phase 5–7 · 정책 기반

Phase 6 · Actor–Critic과 GAE: 운 대신 예측으로 평가하기

함께 읽기: Unit 6 · The Problem of Variance in Reinforce · Advantage Actor-Critic (A2C) · GAE 논문
대응 교재 장: Actor–Critic · MC·TD·GAE

① 직관: 칸 2에서 ←는 정말 좋은 행동이었나?

Phase 5의 우회 에피소드에서 REINFORCE는 칸 2의 ←를 강화했습니다. 그 뒤에 운 좋게 보물에 갔기 때문입니다. 하지만 반반 정책에서 ←로 1번 칸에 가면 그곳의 기대 가치는 V(1) = −0.5입니다. 한 번의 결과(G) 대신 “가 보니 어떤 칸이었나”에 대한 예측(V)으로 평가하면 이 행동은 나빴습니다. 예측을 담당하는 V를 critic, 정책을 actor라고 부릅니다.

② 수식

식 (6.1) · TD 오차 = 한 걸음 advantage
δt=rt+1+γ (1−terminated) V(st+1)−V(st)\delta_t=r_{t+1}+\gamma\,(1-\text{terminated})\,V(s_{t+1})-V(s_t)
Phase 2의 TD 오차입니다. “예상보다 얼마나 좋았나.” 이 값으로 actor를 갱신하면 actor–critic, critic을 갱신하면 TD 학습입니다.
식 (6.2) · GAE(λ)
A^t=δt+γλ A^t+1=∑l≥0(γλ)l δt+l\hat A_t=\delta_t+\gamma\lambda\,\hat A_{t+1}=\sum_{l\ge0}(\gamma\lambda)^l\,\delta_{t+l}
λ = 0이면 Â = δt (TD, 편향 있고 분산 작음). λ = 1이면 Â = Gt − V(st) (MC, 편향 없고 분산 큼). Phase 1의 식 (1.2)와 같은 모양이라 뒤에서부터 한 번에 계산합니다.
식 (6.3) · actor와 critic 갱신
θ←θ+αθ A^t ∇log⁡π(at∣st),V(st)←V(st)+αV(A^t+V(st)−V(st))\theta\leftarrow\theta+\alpha_\theta\,\hat A_t\,\nabla\log\pi(a_t\mid s_t),\qquad V(s_t)\leftarrow V(s_t)+\alpha_V\big(\hat A_t+V(s_t)-V(s_t)\big)
critic의 목표는 Ât + V(st), 즉 GAE로 만든 return 추정값입니다.

③ 손계산: 우회 에피소드 ←→→→, critic = Vπ (반반 정책)

t이동rV(st)V(st+1)δt (식 6.1)Â (λ=1)Â (λ=0.5)
02 → 1 (←)00−0.50 + 0.9·(−0.5) − 0 = −0.450.729−0.0883
11 → 2 (→)0−0.500 + 0 + 0.5 = 0.51.310.8038
22 → 3 (→)000.50 + 0.45 − 0 = 0.450.90.675
33 → 4 (→)10.5종료: 01 + 0 − 0.5 = 0.50.50.5

λ = 1 열은 Phase 1의 Gt에서 V(st)를 뺀 값과 정확히 같습니다(0.729 − 0, 0.81 + 0.5, …). λ = 0.5로 내리면 t = 0의 advantage가 부호까지 바뀌어 음수가 됩니다. critic의 예측을 많이 믿을수록 “←는 나쁜 선택이었다”는 판단이 나옵니다.

실험 6.1 · λ를 움직여 MC와 TD 사이 오가기

④ 코드

Python · 식 (6.1)–(6.2) · GAE (CleanRL과 같은 역방향 루프)
phase06_actor_critic.py 에서 발췌
def gae(rewards, values, last_value, terminated, gamma=0.9, lam=0.95):
    """식 (6.1) δ_t = r_{t+1} + γV(s_{t+1}) − V(s_t)
       식 (6.2) A_t = δ_t + γλ·A_{t+1}            (뒤에서부터, Phase 1의 식 (1.2)와 같은 모양)

    values[t] = V(s_t). 에피소드가 terminated면 마지막 다음 상태의 가치는 0,
    truncated면 last_value = V(s_T)로 미래를 추정해 채운다 (bootstrapping).
    """
    T = len(rewards)
    next_values = values[1:] + [0.0 if terminated else last_value]
    deltas = [rewards[t] + gamma * next_values[t] - values[t] for t in range(T)]
    adv, A = [0.0] * T, 0.0
    for t in reversed(range(T)):
        A = deltas[t] + gamma * lam * A
        adv[t] = A
    returns = [a + v for a, v in zip(adv, values)]          # critic의 학습 목표
    return deltas, adv, returns
Python · 식 (6.3) · A2C 방식 학습 루프
phase06_actor_critic.py 에서 발췌
def train_a2c(iterations=300, episodes_per_batch=8, actor_lr=1.0, critic_lr=0.05,
              gamma=0.9, lam=0.95, seed=0):
    """A2C 방식: 에피소드 몇 개를 모아 GAE로 advantage를 만들고 actor와 critic을 함께 갱신."""
    rng, env = random.Random(seed), Corridor()
    theta, V = new_theta(), {s: 0.0 for s in NONTERMINAL}
    for _ in range(iterations):
        batch = []
        for _ in range(episodes_per_batch):
            s, _ = env.reset()
            states, actions, rewards = [], [], []
            while True:
                a = RIGHT if rng.random() < p_right(theta, s) else LEFT
                s2, r, terminated, truncated, _ = env.step(a)
                states.append(s); actions.append(a); rewards.append(r)
                if terminated or truncated:
                    break
                s = s2
            last = 0.0 if terminated else V[s2]
            _, adv, rets = gae(rewards, values_of(V, states), last, terminated, gamma, lam)
            batch += list(zip(states, actions, adv, rets))
        n = len(batch)
        grads = {s: [0.0, 0.0] for s in NONTERMINAL}
        for s, a, A, ret in batch:
            g = grad_log_softmax(theta[s], a)
            grads[s] = [x + A * gi / n for x, gi in zip(grads[s], g)]   # actor: E[A ∇log π]
            V[s] += critic_lr * (ret - V[s])                          # critic: V(s) → GAE return
        for s in NONTERMINAL:
            theta[s] = [x + actor_lr * gi for x, gi in zip(theta[s], grads[s])]
    return theta, V

⑤ 검증

Python · 표의 δ와 Â 대조, 한 걸음 actor 갱신
phase06_actor_critic.py 에서 발췌
def hand_check():
    V = exact_values(0.5)                                    # {1: −0.5, 2: 0, 3: 0.5}
    states, actions, rewards = run_scripted([LEFT, RIGHT, RIGHT, RIGHT])
    vals = values_of(V, states)
    close = lambda xs, ys: all(abs(x - y) < 1e-12 for x, y in zip(xs, ys))

    deltas, A1, _ = gae(rewards, vals, 0.0, True, lam=1.0)
    assert close(deltas, [-0.45, 0.5, 0.45, 0.5])
    assert close(A1, [0.729, 1.31, 0.9, 0.5])                # λ=1: G_t − V(s_t) (Monte Carlo)
    _, A0, _ = gae(rewards, vals, 0.0, True, lam=0.0)
    assert close(A0, deltas)                                  # λ=0: δ_t (TD)
    _, Ah, _ = gae(rewards, vals, 0.0, True, lam=0.5)
    assert close(Ah, [-0.0883125, 0.80375, 0.675, 0.5])
    print('δ =', deltas)
    print('A(λ=1) =', [round(a, 4) for a in A1], '| A(λ=0.5) =', [round(a, 4) for a in Ah])

    # 첫 걸음(2에서 ←)만 actor–critic으로 갱신 (α=1): REINFORCE와 반대 방향
    th = new_theta()
    g = grad_log_softmax(th[2], LEFT)
    th[2] = [x + 1.0 * deltas[0] * gi for x, gi in zip(th[2], g)]
    assert abs(p_right(th, 2) - sigmoid(0.45)) < 1e-12
    print(f'2에서 ← 를 δ = −0.45 로 평가 → P(→|2) = {p_right(th, 2):.4f} (REINFORCE는 이 걸음의 ←를 강화했다)')
실행 결과 · python phase06_actor_critic.py
δ = [-0.45, 0.5, 0.45, 0.5]
A(λ=1) = [0.729, 1.31, 0.9, 0.5] | A(λ=0.5) = [-0.0883, 0.8037, 0.675, 0.5]
2에서 ← 를 δ = −0.45 로 평가 → P(→|2) = 0.6106 (REINFORCE는 이 걸음의 ←를 강화했다)
A2C 뒤 P(→|s) = {1: 0.924, 2: 0.989, 3: 0.979} | critic V = {1: 0.68, 2: 0.885, 3: 0.991}

critic의 V(2) ≈ 0.885, V(3) ≈ 0.991은 거의 최적인 정책의 가치(0.9, 1)에 가깝습니다. V(1) = 0.68은 아직 낮은데, 정책이 좋아질수록 1번 칸에 거의 가지 않아 critic이 그 칸을 배울 기회가 적기 때문입니다.

ACTOR정책 πθ

어떤 행동을 할지 정합니다. Â로 갱신합니다.

CRITIC가치 V

각 상태의 기대 return을 예측합니다. TD/GAE 목표로 갱신합니다.

LLM에서는PPO의 value head

RLHF+PPO에서는 critic이 LLM과 같은 크기의 모델이라 메모리가 큽니다. Phase 11의 GRPO가 critic을 없애는 이유입니다.

HF Unit 6 실습은 같은 A2C를 Stable-Baselines3로 panda-gym 로봇 팔에 적용합니다. 연속 행동이라 softmax 대신 정규분포 정책을 쓰지만, advantage와 actor/critic 갱신의 구조는 같습니다.

Q1. 실험 6.1에서 λ를 몇 이하로 내리면 Â0이 음수가 되나요?

Â0 = −0.45 + 0.9λ·Â1이 0이 되는 지점입니다. 계산하면 λ ≈ 0.576입니다. 그보다 작으면 ←가 나쁜 선택으로, 크면 좋은 선택으로 평가됩니다.

Q2. critic이 엉망이면(예: 모든 V = 0) λ = 0의 advantage는?

δ = r + 0 − 0 = r이므로 마지막 걸음만 신호를 받고 나머지는 0입니다. critic이 부정확하면 TD 쪽 advantage는 편향됩니다. 그래서 실전에서는 λ = 0.95처럼 MC 쪽에 가깝게 둡니다.

Q3. truncated로 끝난 에피소드에서 GAE는 마지막 V(sT)를 어떻게 다루나요?

gae(..., last_value=V(s_T), terminated=False)처럼 다음 가치를 critic 예측으로 채웁니다. terminated일 때만 0입니다.

Phase 5–7 · 정책 기반

Phase 7 · PPO: 같은 데이터를 여러 번, 하지만 조심스럽게

함께 읽기: Unit 8 · The intuition behind PPO · Clipped Surrogate Objective · Hands-on: CleanRL PPO · PPO 논문
대응 교재 장: 왜 조금씩 바꿔야 하는가 · TRPO · PPO

① 직관: rollout은 비싸다

REINFORCE와 A2C는 한 번 모은 데이터로 한 번만 갱신하고 버립니다. 환경을 돌리는 비용이 크면(LLM이라면 긴 답변 생성) 아깝습니다. 같은 batch로 여러 번 갱신하고 싶지만, 그러면 정책이 데이터를 모은 정책 πold에서 점점 멀어집니다. 오래된 데이터로 계산한 advantage를 믿고 너무 멀리 가면 정책이 망가질 수 있습니다. PPO는 “πold보다 확률이 ±20% 넘게 바뀐 샘플은 더 밀지 않는다”는 규칙으로 이를 막습니다.

② 수식

식 (7.1) · 확률비 (ratio)
ρt(θ)=πθ(at∣st)πold(at∣st)=exp⁡(log⁡πθ−log⁡πold)\rho_t(\theta)=\frac{\pi_\theta(a_t\mid s_t)}{\pi_{\text{old}}(a_t\mid s_t)}=\exp\big(\log\pi_\theta-\log\pi_{\text{old}}\big)
데이터를 모을 때의 확률을 저장해 두고, 지금 확률과 비교합니다. 처음 갱신할 때는 ρ = 1입니다.
식 (7.2) · clipped surrogate objective
JCLIP(θ)=Et[min⁡(ρtA^t, clip⁡(ρt,1−ε,1+ε) A^t)]J^{\text{CLIP}}(\theta)=\mathbb E_t\Big[\min\big(\rho_t\hat A_t,\ \operatorname{clip}(\rho_t,1-\varepsilon,1+\varepsilon)\,\hat A_t\big)\Big]
두 값 중 작은 쪽을 고릅니다. 좋은 행동(Â > 0)은 ρ가 1 + ε를 넘으면, 나쁜 행동(Â < 0)은 ρ가 1 − ε 아래로 가면 평평해져 gradient가 0이 됩니다.
식 (7.3) · 실제로 최소화하는 전체 손실 (CleanRL)
L(θ)=−JCLIP+c1 E[(Vθ(st)−R^t)2]−c2 E[H(πθ(⋅∣st))]L(\theta)=-J^{\text{CLIP}}+c_1\,\mathbb E\big[(V_\theta(s_t)-\hat R_t)^2\big]-c_2\,\mathbb E\big[\mathcal H(\pi_\theta(\cdot\mid s_t))\big]
policy 항 + critic 회귀 항 + 탐색을 위한 entropy 보너스. Â는 Phase 6의 GAE로 만듭니다.

③ 손계산: 다섯 경우의 clip (ε = 0.2)

ρÂρÂclip(ρ)·Âmingradient 계수해석
1.1+0.90.990.990.990.99범위 안: 보통의 policy gradient
1.4+0.91.261.2 × 0.9 = 1.081.080좋은 행동을 이미 충분히 올림 → 멈춤
0.6−0.5−0.300.8 × −0.5 = −0.40−0.400나쁜 행동을 이미 충분히 내림 → 멈춤
0.6+0.50.300.400.300.30좋은 행동을 실수로 내렸다 → 되돌린다
1.5−1.0−1.50−1.20−1.50−1.50나쁜 행동을 실수로 올렸다 → 되돌린다

clip은 개선 방향으로 너무 멀리 가는 것만 막고, 잘못된 방향으로 간 것을 되돌리는 gradient는 그대로 둡니다. 그래서 min을 씁니다.

같은 batch를 반복 학습하면? 2번 칸에서 모은 batch [(→, Â = +1), (←, Â = −1)]를 학습률 0.2로 epoch마다 다시 학습합니다. πold(→|2) = 0.5이므로 clip 경계는 확률 0.6(= 1.2 × 0.5)입니다.

실험 7.1 · epoch 수와 clip에 따른 P(→|2)
1.00.750.5020
선택한 설정회색 점선: clip 없음주황 점선: clip 경계 0.6

④ 코드

Python · 식 (7.2)와 그 gradient 계수
phase07_ppo.py 에서 발췌
def ppo_clip_term(ratio, adv, eps=0.2):
    """식 (7.2): min(ρA, clip(ρ, 1−ε, 1+ε)A) 와, 이 항을 log π로 미분한 계수.

    ∂ρ/∂θ = ρ·∇log π 이므로 clip되지 않은 쪽이 선택되면 gradient 계수는 ρA,
    clip된 쪽이 선택되면 상수이므로 0이다.
    """
    unclipped = ratio * adv
    clipped = max(1 - eps, min(1 + eps, ratio)) * adv
    if clipped < unclipped:                         # 더 작은(보수적인) 쪽이 clip된 항
        return clipped, 0.0
    return unclipped, ratio * adv
Python · 실험 7.1의 계산
phase07_ppo.py 에서 발췌
def epoch_demo(epochs=20, lr=0.2, eps=0.2, clip=True):
    """2번 칸에서 모은 고정 batch [(→, A=+1), (←, A=−1)]를 epoch마다 다시 학습한다."""
    batch = [(RIGHT, +1.0), (LEFT, -1.0)]
    old = softmax([0.0, 0.0])                       # π_old: 데이터를 모을 때의 정책, 고정
    theta, history = [0.0, 0.0], [0.5]
    for _ in range(epochs):
        pi, grad = softmax(theta), [0.0, 0.0]
        for a, A in batch:
            ratio = pi[a] / old[a]                                    # 식 (7.1)
            coef = ppo_clip_term(ratio, A, eps)[1] if clip else ratio * A
            g = grad_log_softmax(theta, a)
            grad = [x + coef * gi / len(batch) for x, gi in zip(grad, g)]
        theta = [x + lr * gi for x, gi in zip(theta, grad)]          # 경사 상승
        history.append(softmax(theta)[RIGHT])
    return history
Python · 복도 PPO: rollout → GAE → K epoch 재사용 → critic
phase07_ppo.py 에서 발췌
def collect(theta, V, rng, episodes, gamma, lam):
    env, batch = Corridor(), []
    for _ in range(episodes):
        s, _ = env.reset()
        states, actions, rewards, logps = [], [], [], []
        while True:
            pi = softmax(theta[s])
            a = RIGHT if rng.random() < pi[RIGHT] else LEFT
            s2, r, terminated, truncated, _ = env.step(a)
            states.append(s); actions.append(a); rewards.append(r); logps.append(math.log(pi[a]))
            if terminated or truncated:
                break
            s = s2
        _, adv, rets = gae(rewards, values_of(V, states), 0.0 if terminated else V[s2], terminated, gamma, lam)
        batch += list(zip(states, actions, logps, adv, rets))
    return batch


def train_ppo(iterations=60, episodes=8, epochs=4, lr=0.5, critic_lr=0.1, eps=0.2,
              gamma=0.9, lam=0.95, seed=0):
    rng = random.Random(seed)
    theta, V = new_theta(), {s: 0.0 for s in NONTERMINAL}
    log = []
    for it in range(iterations):
        batch = collect(theta, V, rng, episodes, gamma, lam)          # π_old로 rollout
        clipped = 0
        for _ in range(epochs):                                         # 같은 batch를 K번 재사용
            grads = {s: [0.0, 0.0] for s in NONTERMINAL}
            for s, a, old_logp, A, ret in batch:
                ratio = math.exp(math.log(softmax(theta[s])[a]) - old_logp)
                _, coef = ppo_clip_term(ratio, A, eps)
                clipped += coef == 0.0 and A != 0.0
                g = grad_log_softmax(theta[s], a)
                grads[s] = [x + coef * gi / len(batch) for x, gi in zip(grads[s], g)]
            for s in NONTERMINAL:
                theta[s] = [x + lr * gi for x, gi in zip(theta[s], grads[s])]
        for s, _, _, _, ret in batch:                                   # critic: V → GAE return
            V[s] += critic_lr * (ret - V[s])
        log.append((it, clipped / (epochs * len(batch))))
    return theta, V, log

⑤ 검증

Python · 표의 다섯 경우와 epoch 실험 대조
phase07_ppo.py 에서 발췌
def hand_check():
    assert ppo_clip_term(1.4, 0.9) == (1.2 * 0.9, 0.0)              # 좋은 행동, 이미 많이 올림 → 멈춤
    assert ppo_clip_term(0.6, -0.5) == (0.8 * -0.5, 0.0)            # 나쁜 행동, 이미 많이 내림 → 멈춤
    obj, coef = ppo_clip_term(1.1, 0.9)
    assert abs(obj - 0.99) < 1e-12 and abs(coef - 0.99) < 1e-12     # 범위 안: 평범한 policy gradient
    assert ppo_clip_term(0.6, 0.5) == (0.3, 0.3)                    # 좋은 행동을 실수로 내렸으면 되돌린다
    assert ppo_clip_term(1.5, -1.0) == (-1.5, -1.5)                 # 나쁜 행동을 실수로 올렸으면 되돌린다

    with_clip, no_clip = epoch_demo(clip=True), epoch_demo(clip=False)
    assert with_clip[2] < 0.6 < with_clip[3]                      # 3번째 epoch에 경계 0.6 = 1.2 × 0.5를 넘고
    assert all(p == with_clip[3] for p in with_clip[3:])           # 그 뒤로는 gradient가 0이라 멈춘다
    assert with_clip[:4] == no_clip[:4] and no_clip[-1] > 0.92     # clip이 없으면 계속 올라간다
    print('epoch별 P(→|2), clip 있음:', [round(p, 3) for p in with_clip[:6]], '…', round(with_clip[-1], 3))
    print('epoch별 P(→|2), clip 없음:', [round(p, 3) for p in no_clip[:6]], '…', round(no_clip[-1], 3))
실행 결과 · python phase07_ppo.py
epoch별 P(→|2), clip 있음: [0.5, 0.55, 0.598, 0.643, 0.643, 0.643] … 0.643
epoch별 P(→|2), clip 없음: [0.5, 0.55, 0.598, 0.643, 0.684, 0.72] … 0.921
PPO 60회 반복 뒤 P(→|s) = {1: 0.912, 2: 0.977, 3: 0.947} | critic V = {1: 0.435, 2: 0.891, 3: 0.992}
clip된 샘플 비율 (처음 3회, 마지막 3회): [0.0, 0.03, 0.08] [0.0, 0.0, 0.0]

clip이 있으면 3번째 epoch에 경계 0.6을 조금 넘은 0.643에서 멈춥니다(한 걸음의 크기만큼 넘을 수 있습니다). clip이 없으면 같은 데이터로 계속 밀어 0.92까지 갑니다. 학습 초반에는 clip되는 샘플 비율이 3–8%였다가, 정책이 수렴하면 advantage가 작아져 0%가 됩니다.

HF Unit 8로 넘어가기: PyTorch PPO

HF Unit 8 Part 1은 CleanRL의 ppo.py를 직접 작성해 LunarLander를 학습합니다. 아래는 그 구조를 줄인 버전으로, ① rollout ② GAE ③ minibatch × epoch 재사용 ④ 식 (7.3) 손실 순서가 복도 버전과 같습니다. CleanRL에는 여러 환경 병렬 실행, 학습률 감소, value clipping 같은 세부 장치가 더 있습니다.

Python · extras/ppo_cartpole.py · CleanRL ppo.py를 줄인 형태
extras/ppo_cartpole.py 전체
# -*- coding: utf-8 -*-
"""선택 실습 · Phase 7의 PPO를 PyTorch로 CartPole에 적용한다 (CleanRL ppo.py를 줄인 형태).

실행: pip install torch "gymnasium[classic-control]"  후  python extras/ppo_cartpole.py
HF Unit 8 Part 1은 같은 구조로 LunarLander를 학습한다. ENV_ID만 바꿔 보자.
"""
import sys

import gymnasium as gym
import torch
import torch.nn as nn
from torch.distributions import Categorical

ENV_ID = 'CartPole-v1'


def main(iterations=60, seed=0):
    torch.manual_seed(seed)
    env = gym.make(ENV_ID)
    n_obs, n_act = env.observation_space.shape[0], env.action_space.n
    actor = nn.Sequential(nn.Linear(n_obs, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, n_act))
    critic = nn.Sequential(nn.Linear(n_obs, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 1))
    opt = torch.optim.Adam(list(actor.parameters()) + list(critic.parameters()), lr=2.5e-4)
    T, gamma, lam, eps, epochs, minibatch = 1024, 0.99, 0.95, 0.2, 4, 256
    obs, _ = env.reset(seed=seed)
    returns, ep_return = [], 0.0
    for it in range(iterations):
        # ① rollout: π_old로 T걸음 모으기
        buf = {k: [] for k in ('obs', 'act', 'logp', 'rew', 'done', 'val')}
        for _ in range(T):
            o = torch.tensor(obs, dtype=torch.float32)
            with torch.no_grad():
                dist = Categorical(logits=actor(o))
                a = dist.sample()
                v = critic(o).squeeze()
            obs, r, terminated, truncated, _ = env.step(int(a))
            for k, x in zip(buf, (o, a, dist.log_prob(a), r, float(terminated), v)):
                buf[k].append(x)
            ep_return += r
            if terminated or truncated:
                returns.append(ep_return)
                ep_return, (obs, _) = 0.0, env.reset()
        # ② GAE (식 6.1–6.2). truncated는 이 축약판에서 종료처럼 다룬다.
        with torch.no_grad():
            next_v = critic(torch.tensor(obs, dtype=torch.float32)).squeeze()
        adv, A = [0.0] * T, 0.0
        for t in reversed(range(T)):
            nv = next_v if t == T - 1 else buf['val'][t + 1]
            nonterminal = 1.0 - buf['done'][t]
            delta = buf['rew'][t] + gamma * nv * nonterminal - buf['val'][t]
            A = delta + gamma * lam * nonterminal * A
            adv[t] = A
        b_obs, b_act = torch.stack(buf['obs']), torch.stack(buf['act'])
        b_logp, b_val = torch.stack(buf['logp']), torch.stack(buf['val'])
        b_adv = torch.tensor(adv)
        b_ret = b_adv + b_val
        # ③ 같은 batch를 epochs번, minibatch로 나눠 재사용 (식 7.1–7.3)
        for _ in range(epochs):
            for idx in torch.randperm(T).split(minibatch):
                dist = Categorical(logits=actor(b_obs[idx]))
                ratio = (dist.log_prob(b_act[idx]) - b_logp[idx]).exp()
                a = b_adv[idx]
                a = (a - a.mean()) / (a.std() + 1e-8)                    # advantage 정규화
                pg_loss = -torch.min(ratio * a, ratio.clamp(1 - eps, 1 + eps) * a).mean()
                v_loss = 0.5 * (critic(b_obs[idx]).squeeze() - b_ret[idx]).pow(2).mean()
                loss = pg_loss + 0.5 * v_loss - 0.01 * dist.entropy().mean()
                opt.zero_grad()
                loss.backward()
                nn.utils.clip_grad_norm_(list(actor.parameters()) + list(critic.parameters()), 0.5)
                opt.step()
        if it % 10 == 0 and returns:
            print(f'iter {it:3d}  최근 10 에피소드 평균 return {sum(returns[-10:]) / len(returns[-10:]):6.1f}')
    return sum(returns[-10:]) / 10


if __name__ == '__main__':
    main(int(sys.argv[1]) if len(sys.argv) > 1 else 60)
검증 때 기록한 출력 (torch 2.8.0, gymnasium 1.0.0, CPU, seed 0)
iter   0  최근 10 에피소드 평균 return   20.1
iter  10  최근 10 에피소드 평균 return   79.8
iter  20  최근 10 에피소드 평균 return  152.8
iter  30  최근 10 에피소드 평균 return  212.8
iter  40  최근 10 에피소드 평균 return  208.4
iter  50  최근 10 에피소드 평균 return  254.0
LLM으로 가져갈 것Phase 8 이후에 쓰는 RL 알고리즘은 거의 모두 식 (7.1)–(7.2)를 토큰 단위로 적용합니다. 바뀌는 것은 Â를 어디서 얻느냐(critic의 GAE / 그룹 비교)와 평균을 어떻게 내느냐뿐입니다.
Q1. 실험 7.1에서 clip을 켜면 왜 0.6이 아니라 0.643에서 멈추나요?

gradient는 경계를 넘기 전 마지막 epoch까지 살아 있습니다. 그 epoch의 한 걸음이 0.598 → 0.643으로 경계를 넘었고, 그다음부터 계수가 0이 되어 멈춥니다. 학습률이 크면 더 많이 넘습니다.

Q2. ε을 0으로 하면?

첫 epoch에서도 ρ = 1이라 gradient는 살아 있지만, 한 걸음만 가도 경계를 넘습니다. 사실상 batch당 한 번만 갱신하는 셈이라 데이터 재사용의 이점이 사라집니다.

Q3. PPO의 πold와 Phase 8의 πref는 같은 것인가요?

다릅니다. πold는 이번 batch를 모은 정책으로, 매 iteration마다 바뀝니다. πref는 학습 시작 시점(SFT 모델)으로 끝까지 고정되며, KL 벌점의 기준이 됩니다.

Phase 8–12 · LLM 사후학습

Phase 8 · 복도에서 언어 모델로: 토큰 생성도 같은 RL 문제다

함께 읽기: Bonus 3 · Language models in RL · Bonus 3 · RLHF · LLM Course 12.2 · RL for LLMs
대응 교재 장: LLM을 강화학습 문제로 바꾸기 · 두 루프의 공통 구현

① 직관: 칸 번호 대신 “지금까지 쓴 글”

복도에서 에이전트는 칸을 보고 ←/→를 골랐습니다. 언어 모델은 지금까지의 글(프롬프트 + 생성한 토큰)을 보고 다음 토큰을 고릅니다. <eos>(⏎)를 고르거나 길이 제한에 닿으면 에피소드가 끝나고, 보상은 대개 맨 끝에 한 번 받습니다. 나머지는 Phase 5–7과 똑같습니다.

Corridor-5TinyLM (이 튜토리얼)실제 LLM
상태 st칸 번호(프롬프트, 생성한 토큰들)프롬프트 + 생성한 토큰들
행동 at← / →토큰 5개: 1 2 6 7 ⏎토큰 수만 개
정책칸마다 로짓 2개prefix마다 로짓 5개 (표)Transformer가 로짓 계산
종료구덩이·보물⏎ 또는 3토큰EOS 또는 max_new_tokens
보상−1 / +1검증기·보상 모델 (끝에 한 번)검증기·보상 모델 (끝에 한 번)
γ0.91보통 1

학습 시작점은 “SFT를 마친 모델”이라고 가정한 분포입니다. 질문 두 개(3×4=, 2×3=)에 대해 정답을 낼 확률이 각각 0.16, 0.24로 낮습니다. 이 분포는 학습 내내 기준점 πref로도 쓰입니다.

3×4=
“1” 0.4
“2” 0.5 → ⏎ 0.8 = 12⏎ 0.16 ✓⏎ 0.3 → 1⏎ 0.12그 밖 0.2
“7” 0.4
⏎ 0.8 → 7⏎ 0.32 ✗그 밖 0.2
“2” 0.1 · “6” 0.05 · ⏎ 0.05
그림 8.1 TinyLM의 SFT 분포(3×4=). 응답 확률은 경로 위의 확률을 곱한 값입니다. 정답 12⏎는 0.4 × 0.5 × 0.8 = 0.16, 가장 흔한 응답은 오답 7⏎(0.32)입니다. 가능한 응답이 85가지뿐이라 기대값과 KL을 정확히 계산할 수 있습니다.
Python · SFT 분포 = 출발점 = π_ref
tinylm.py 에서 발췌
# SFT를 마친 모델이라고 가정한 출발점이자 reference 정책 π_ref.
SFT_PROBS = {
    ('3×4=', ()):     _probs({'1': 0.4, '7': 0.4, '2': 0.1, '6': 0.05, '<eos>': 0.05}),
    ('3×4=', ('1',)): _probs({'2': 0.5, '<eos>': 0.3, '1': 0.1, '6': 0.05, '7': 0.05}),
    ('2×3=', ()):     _probs({'6': 0.3, '7': 0.3, '1': 0.2, '2': 0.1, '<eos>': 0.1}),
}
DEFAULT_PROBS = _probs({'<eos>': 0.8})   # 그 밖의 prefix: 대개 바로 끝낸다 (나머지 토큰 0.05씩)
Python · TinyLM: prefix마다 로짓을 갖는 표
tinylm.py 에서 발췌
class TinyLM:
    def __init__(self):
        self.logits = {}                                   # prefix → 로짓 벡터 (필요할 때 생성)

    def _row(self, prompt, prefix):
        key = (prompt, tuple(prefix))
        if key not in self.logits:
            self.logits[key] = [math.log(p) for p in SFT_PROBS.get(key, DEFAULT_PROBS)]
        return self.logits[key]

    def dist(self, prompt, prefix):
        """π(· | x, y<t): 다음 토큰 분포"""
        return softmax(self._row(prompt, prefix))

    def sample(self, prompt, rng):
        tokens = []
        while len(tokens) < MAX_NEW_TOKENS:
            p = self.dist(prompt, tokens)
            tok = rng.choices(VOCAB, weights=p)[0]
            tokens.append(tok)
            if tok == '<eos>':
                break
        return tokens

    def token_logps(self, prompt, tokens):
        """식 (8.2)의 각 항: log π(y_t | x, y<t)"""
        return [math.log(self.dist(prompt, tokens[:t])[VOCAB.index(tok)]) for t, tok in enumerate(tokens)]

    def grad_token(self, prompt, tokens, t):
        """∂ log π(y_t | x, y<t) / ∂(그 prefix의 로짓) = onehot(y_t) − π  (Phase 5의 식 (5.2)와 같다)"""
        p = self.dist(prompt, tokens[:t])
        a = VOCAB.index(tokens[t])
        return (prompt, tuple(tokens[:t])), [(1.0 if i == a else 0.0) - pi for i, pi in enumerate(p)]

    def apply(self, grads, lr):
        """grads: {prefix key: 로짓 gradient}. 경사 상승 한 걸음."""
        for key, g in grads.items():
            row = self._row(*key)
            for i, gi in enumerate(g):
                row[i] += lr * gi

    def copy(self):
        other = TinyLM()
        other.logits = {k: list(v) for k, v in self.logits.items()}
        return other
Python · 검증기
tinylm.py 에서 발췌
def verify(prompt, tokens):
    """검증기: <eos>로 끝났고 숫자가 정답과 같으면 1, 아니면 0."""
    finished = tokens and tokens[-1] == '<eos>'
    return 1.0 if finished and ''.join(tokens[:-1]) == ANSWERS[prompt] else 0.0

② 수식

식 (8.1) · 응답 확률 = 토큰 확률의 곱
πθ(y∣x)=∏t=1∣y∣πθ(yt∣x,y<t)\pi_\theta(y\mid x)=\prod_{t=1}^{|y|}\pi_\theta(y_t\mid x,y_{<t})
식 (8.2) · 로그를 쓰면 합
log⁡πθ(y∣x)=∑t=1∣y∣log⁡πθ(yt∣x,y<t)\log\pi_\theta(y\mid x)=\sum_{t=1}^{|y|}\log\pi_\theta(y_t\mid x,y_{<t})
실제 코드에서 “sequence log prob”라고 부르는 값입니다.
식 (8.3) · 응답 단위 REINFORCE
∇J=Ey∼πθ[(R(x,y)−b)∑t∇log⁡πθ(yt∣x,y<t)]\nabla J=\mathbb E_{y\sim\pi_\theta}\Big[\big(R(x,y)-b\big)\sum_t\nabla\log\pi_\theta(y_t\mid x,y_{<t})\Big]
γ = 1이고 보상이 끝에만 있으므로 모든 토큰의 Gt가 R로 같습니다(Phase 5 Q3). b는 같은 질문 응답들의 평균 보상입니다.
식 (8.4) · KL로 묶은 목표
max⁡π Ey∼π[R(x,y)]−β DKL(π(⋅∣x) ∥ πref(⋅∣x))\max_\pi\ \mathbb E_{y\sim\pi}\big[R(x,y)\big]-\beta\,D_{\mathrm{KL}}\big(\pi(\cdot\mid x)\,\|\,\pi_{\text{ref}}(\cdot\mid x)\big)
R만 최대화하면 모델이 SFT에서 배운 말투와 지식을 잃거나 보상의 허점을 파고들 수 있습니다. 그래서 πref에서 멀어지는 만큼 벌점을 줍니다. REINFORCE에서는 응답 보상을 R − β·log π(y)/πref(y)로 바꾸기만 하면 됩니다.
식 (8.5) · 식 (8.4)의 정확한 해
π∗(y∣x)=1Z(x) πref(y∣x) exp⁡ ⁣(R(x,y)/β)\pi^*(y\mid x)=\frac{1}{Z(x)}\,\pi_{\text{ref}}(y\mid x)\,\exp\!\big(R(x,y)/\beta\big)
정답 응답들의 확률이 exp(1/β)배가 된 뒤 다시 정규화됩니다. 이 식은 Phase 10의 DPO 유도에서 다시 씁니다.

③ 손계산

“1” | 3×4=log 0.4 = −0.916“2” | 3×4=1log 0.5 = −0.693⏎ | 3×4=12log 0.8 = −0.223합log π(12⏎) = −1.833

정답 응답 12⏎ 하나를 뽑아 R = 1, b = 0, α = 1로 식 (8.3)을 적용하면, 세 prefix의 로짓에 각각 onehot − π가 더해집니다. 첫 prefix에서 π(“1”)은 0.4 → 0.616이 되고, 정답 확률 P(12⏎)는 0.16 → 0.345가 됩니다.

β = 0.5일 때의 최적 정답 확률 (식 8.5). 3×4=: 0.16·e² / (0.16·e² + 0.84) = 1.182 / 2.022 ≈ 0.585. 2×3=: 0.24·e² / (0.24·e² + 0.76) ≈ 0.700. 아무리 오래 학습해도 KL 벌점 때문에 이 이상 올라가지 않는 것이 정답입니다.

④ 코드

Python · 식 (8.3)–(8.4) · KL 벌점을 넣은 REINFORCE
phase08_lm_bridge.py 에서 발췌
def train_reinforce_kl(beta, iterations=150, group=16, lr=0.5, seed=0):
    """식 (8.4): 시퀀스 보상 R(x,y) − β·log π(y|x)/π_ref(y|x) 를 REINFORCE로 최대화한다."""
    rng, lm = random.Random(seed), TinyLM()
    ref = lm.copy()                                         # π_ref: 학습 시작 시점(SFT) 모델, 고정
    for _ in range(iterations):
        grads = {}
        for prompt in ANSWERS:
            samples = [lm.sample(prompt, rng) for _ in range(group)]
            scores = []
            for y in samples:
                log_ratio = sum(lm.token_logps(prompt, y)) - sum(ref.token_logps(prompt, y))
                scores.append(verify(prompt, y) - beta * log_ratio)
            baseline = sum(scores) / len(scores)              # Phase 5의 baseline: 같은 질문의 평균
            for y, score in zip(samples, scores):
                for t in range(len(y)):
                    key, g = lm.grad_token(prompt, y, t)
                    add_grad(grads, key, g, (score - baseline) / (group * len(ANSWERS)))
        lm.apply(grads, lr)
    return lm, ref
Python · 식 (8.5) · 이론적 최적값
phase08_lm_bridge.py 에서 발췌
def kl_optimal_p_correct(prompt, beta):
    """식 (8.5): max E[R] − β·KL 의 해는 π*(y) ∝ π_ref(y)·exp(R(y)/β).

    R이 0/1뿐이면 정답 전체의 확률 p_ref가 exp(1/β)배로 커진 뒤 다시 정규화된다.
    """
    p_ref = p_correct(TinyLM(), prompt)
    w = p_ref * math.exp(1 / beta)
    return w / (w + 1 - p_ref)

⑤ 검증

Python · 응답 확률·log 확률·한 걸음 갱신 대조
phase08_lm_bridge.py 에서 발췌
def hand_check():
    lm = TinyLM()
    y = ['1', '2', '<eos>']
    logps = lm.token_logps('3×4=', y)                       # 식 (8.2)의 각 항
    assert all(abs(a - math.log(b)) < 1e-12 for a, b in zip(logps, [0.4, 0.5, 0.8]))
    assert abs(math.exp(sum(logps)) - 0.16) < 1e-12          # 식 (8.1): 0.4 × 0.5 × 0.8
    assert abs(p_correct(lm, '3×4=') - 0.16) < 1e-12 and abs(p_correct(lm, '2×3=') - 0.24) < 1e-12
    print('log π(12⏎ | 3×4=) =', [round(x, 4) for x in logps], '합', round(sum(logps), 4))

    # 정답 응답 하나로 시퀀스 REINFORCE 한 걸음 (R=1, baseline 0, α=1): 식 (8.3)
    grads = {}
    for t in range(len(y)):
        key, g = lm.grad_token('3×4=', y, t)
        add_grad(grads, key, g, 1.0 * verify('3×4=', y))
    lm.apply(grads, lr=1.0)
    p0 = lm.dist('3×4=', [])[VOCAB.index('1')]
    print(f'한 번 갱신 뒤: π(1|3×4=) 0.400 → {p0:.4f}, P(12⏎) 0.160 → {p_correct(lm, "3×4="):.4f}')
    assert p0 > 0.4 and p_correct(lm, '3×4=') > 0.16
실행 결과 · python phase08_lm_bridge.py
log π(12⏎ | 3×4=) = [-0.9163, -0.6931, -0.2231] 합 -1.8326
한 번 갱신 뒤: π(1|3×4=) 0.400 → 0.6163, P(12⏎) 0.160 → 0.3451
β=0.0: P(정답) = {'3×4=': 0.908, '2×3=': 0.938}  KL(π‖π_ref) = {'3×4=': 1.42, '2×3=': 1.157}
β=0.5: P(정답) = {'3×4=': 0.541, '2×3=': 0.675}  KL(π‖π_ref) = {'3×4=': 0.39, '2×3=': 0.428}
β=0.5의 이론적 최적 P(정답) = {'3×4=': 0.585, '2×3=': 0.7}
β = 0정답률 0.91–0.94

KL 1.2–1.4. 보상만 보면 SFT 분포에서 멀리 떠납니다.

β = 0.5정답률 0.54–0.68

KL 0.4. 이론적 최적값 0.585, 0.700 근처에 머뭅니다. 남은 차이는 유한한 학습 시간 때문입니다.

교훈β는 “얼마나 바뀌어도 되는가”의 다이얼

실전에서는 β를 0.01–0.1 정도로 작게 두거나, GRPO처럼 0으로 두기도 합니다(Phase 11–12).

표 대신 Transformer일 때 달라지는 점TinyLM은 prefix마다 독립된 로짓 줄을 가지므로 한 prefix를 고쳐도 다른 prefix에 영향이 없습니다. 실제 LLM은 모든 prefix가 같은 파라미터를 공유해, 한 답변을 강화하면 비슷한 다른 답변의 확률도 함께 바뀝니다(일반화). 이 차이를 빼면 식과 코드의 구조는 같습니다.
Q1. 3×4=의 SFT 분포에서 기대 보상 E[R]은?

정답 응답은 12⏎ 하나이고 보상이 0/1이므로 E[R] = P(12⏎) = 0.16입니다.

Q2. β → 0과 β → ∞일 때 식 (8.5)의 π*는?

β → 0이면 exp(R/β)가 정답만 남겨 P(정답) → 1. β → ∞이면 exp(R/β) → 1이라 π* = πref, 즉 아무것도 바뀌지 않습니다.

Q3. 응답이 길수록 log π(y)는 어떻게 되나요? 왜 문제가 될 수 있나요?

음수 항을 더 많이 더하므로 작아집니다. 응답 단위로 합만 쓰면 길이에 따라 크기가 달라져 긴 응답과 짧은 응답이 다른 세기로 학습됩니다. Phase 12의 Dr. GRPO·DAPO가 이 정규화 문제를 다룹니다.

Phase 8–12 · LLM 사후학습

Phase 9 · RLHF: 사람의 비교 → 보상 모델 → PPO

함께 읽기: Bonus 3 · RLHF · HF 블로그 · Illustrating RLHF · InstructGPT 논문 · TRL RewardTrainer
대응 교재 장: RLHF · RLHF 실전 추적

① 직관: “좋은 답”을 정의하기 어려울 때

Phase 8은 정답 검사기로 보상을 줬습니다. 하지만 “친절한가”, “안전한가”처럼 규칙으로 쓰기 어려운 기준도 있습니다. 사람에게 점수를 매기라고 하면 사람마다 기준이 흔들리지만, “둘 중 어느 쪽이 나은가”는 비교적 일관되게 답합니다. RLHF는 이 비교를 모아 보상 모델을 학습하고, 그 점수를 Phase 7의 PPO로 최대화합니다.

① SFTπref
(Phase 8의 출발점)
② 비교 수집사람: yw ≻ yl
③ 보상 모델Bradley–Terry
식 (9.1)–(9.2)
④ PPO + KL토큰별 보상
식 (9.3) + Phase 7
그림 9.1 InstructGPT 방식의 RLHF. 보상 모델은 ③에서 학습한 뒤 ④에서는 고정합니다.

② 수식

식 (9.1) · Bradley–Terry 선호 모형
P(yw≻yl∣x)=σ(rϕ(x,yw)−rϕ(x,yl))P(y_w\succ y_l\mid x)=\sigma\big(r_\phi(x,y_w)-r_\phi(x,y_l)\big)
점수 차이만 의미가 있습니다. 모든 점수에 같은 수를 더해도 선호 확률은 같습니다.
식 (9.2) · 보상 모델 손실
LRM(ϕ)=− E(x,yw,yl)log⁡σ(rϕ(x,yw)−rϕ(x,yl))L_{\text{RM}}(\phi)=-\,\mathbb E_{(x,y_w,y_l)}\log\sigma\big(r_\phi(x,y_w)-r_\phi(x,y_l)\big)
사람이 고른 쪽의 점수가 더 높아지도록 학습합니다. rw에 대한 gradient는 −(1 − σ(Δ))입니다.
식 (9.3) · PPO에 넣는 토큰별 보상
rt=−β(log⁡πold(yt∣⋅)−log⁡πref(yt∣⋅))+1[t=T]  rϕ(x,y)r_t=-\beta\big(\log\pi_{\text{old}}(y_t\mid\cdot)-\log\pi_{\text{ref}}(y_t\mid\cdot)\big)+\mathbb 1[t=T]\;r_\phi(x,y)
매 토큰 KL 벌점(식 8.4를 토큰으로 나눈 것), 마지막 토큰에는 보상 모델 점수를 더합니다. 이 보상으로 Phase 6의 GAE(γ = 1)와 Phase 7의 clip을 그대로 적용합니다. critic V는 prefix마다 값을 갖습니다.

③ 손계산

선호 데이터는 다섯 쌍입니다. 3×4=에 대해 12⏎ ≻ 7⏎, 12⏎ ≻ 1⏎, 1⏎ ≻ 7⏎이고, 2×3=에 대해 6⏎ ≻ 7⏎, 6⏎ ≻ 1⏎입니다. 1⏎은 틀렸지만 정답의 첫 자리와 같아 7⏎보다 낫다고 판단했다고 가정합니다.

단계계산결과
처음모든 r = 0 → Δ = 0P(12⏎ ≻ 7⏎) = σ(0) = 0.5, 손실 = log 2 = 0.693
gradient∂L/∂rw = −(1 − σ(0)) = −0.5, ∂L/∂rl = +0.5
한 걸음 (lr = 1)r(12⏎) = 0 + 0.5, r(7⏎) = 0 − 0.5P(12⏎ ≻ 7⏎) = σ(1) = 0.731

④ 코드

Python · 선호 데이터
phase09_rlhf.py 에서 발췌
# 사람이 두 응답을 비교해 고른 결과: (프롬프트, 더 나은 응답 y_w, 덜 나은 응답 y_l)
PREFERENCES = [
    ('3×4=', '12⏎', '7⏎'),
    ('3×4=', '12⏎', '1⏎'),
    ('3×4=', '1⏎', '7⏎'),      # "1"은 틀렸지만 정답의 첫 자리라 "7"보다 낫다고 판단
    ('2×3=', '6⏎', '7⏎'),
    ('2×3=', '6⏎', '1⏎'),
]
Python · 식 (9.1)–(9.2) · 보상 모델 학습
phase09_rlhf.py 에서 발췌
def train_reward_model(prefs, epochs=200, lr=0.5):
    """식 (9.1)–(9.2): Bradley–Terry. P(y_w ≻ y_l) = σ(r(y_w) − r(y_l)),  L = −log σ(r_w − r_l)

    여기서는 응답마다 점수 하나를 갖는 표가 보상 모델이다. 처음 보는 응답은 0점.
    """
    r = {}
    for _ in range(epochs):
        for x, yw, yl in prefs:
            rw, rl = r.get((x, yw), 0.0), r.get((x, yl), 0.0)
            g = 1 - sigmoid(rw - rl)                    # −∂L/∂r_w = ∂L/∂r_l 의 크기
            r[x, yw] = rw + lr * g
            r[x, yl] = rl - lr * g
    return r
Python · 식 (9.3) + GAE + PPO clip · RLHF 학습 루프 전체
phase09_rlhf.py 에서 발췌
def rlhf_ppo(rm, beta=0.1, iterations=80, group=16, epochs=4, lr=0.5, critic_lr=0.2,
             eps=0.2, lam=0.95, seed=0):
    rng, lm = random.Random(seed), TinyLM()
    ref, V = lm.copy(), {}                              # π_ref(고정), critic V(prefix)
    for _ in range(iterations):
        batch = []
        for x in ANSWERS:                               # ① rollout: π_old로 응답 생성
            for _ in range(group):
                y = lm.sample(x, rng)
                old = lm.token_logps(x, y)
                refl = ref.token_logps(x, y)
                # ② 토큰별 보상 (식 9.3): 매 토큰 −β·(log π_old − log π_ref), 마지막 토큰에 RM 점수
                rewards = [-beta * (o - r) for o, r in zip(old, refl)]
                rewards[-1] += rm.get((x, show(y)), 0.0)
                # ③ critic 값과 GAE (γ = 1): 응답이 끝나면 다음 가치는 0
                keys = [(x, tuple(y[:t])) for t in range(len(y))]
                vals = [V.get(k, 0.0) for k in keys]
                adv, A = [0.0] * len(y), 0.0
                for t in reversed(range(len(y))):
                    nxt = vals[t + 1] if t + 1 < len(y) else 0.0
                    A = rewards[t] + nxt - vals[t] + lam * A
                    adv[t] = A
                batch.append((x, y, old, adv, [a + v for a, v in zip(adv, vals)], keys))
        n_tokens = sum(len(b[1]) for b in batch)
        for _ in range(epochs):                         # ④ PPO: 같은 batch를 여러 번
            grads = {}
            for x, y, old, adv, _, _ in batch:
                new = lm.token_logps(x, y)
                for t in range(len(y)):
                    _, coef = ppo_clip_term(math.exp(new[t] - old[t]), adv[t], eps)
                    key, g = lm.grad_token(x, y, t)
                    add_grad(grads, key, g, coef / n_tokens)
            lm.apply(grads, lr)
        for x, y, _, _, rets, keys in batch:            # ⑤ critic: V(prefix) → return
            for k, ret in zip(keys, rets):
                V[k] = V.get(k, 0.0) + critic_lr * (ret - V.get(k, 0.0))
    return lm, ref
Python · 정답지: 이 보상 모델과 β에서의 최적 정책
phase09_rlhf.py 에서 발췌
def rlhf_optimum(rm, prompt, beta):
    """식 (8.5)와 같은 원리: π*(y) ∝ π_ref(y)·exp(r(y)/β). 응답을 모두 나열해 정확히 계산."""
    ref = TinyLM()
    w = {show(y): q * math.exp(rm.get((prompt, show(y)), 0.0) / beta) for y, q in all_responses(ref, prompt)}
    z = sum(w.values())
    return {y: v / z for y, v in w.items()}

⑤ 검증

Python · 첫 비교 한 걸음 대조
phase09_rlhf.py 에서 발췌
def hand_check():
    one_step = train_reward_model(PREFERENCES[:1], epochs=1, lr=1.0)
    assert one_step == {('3×4=', '12⏎'): 0.5, ('3×4=', '7⏎'): -0.5}   # 처음 loss = log 2, 기울기 ∓0.5
    assert abs(-math.log(sigmoid(0)) - math.log(2)) < 1e-12
    print('첫 비교 한 걸음: r(12⏎) = +0.5, r(7⏎) = −0.5, 선호 확률 σ(1) =', round(sigmoid(1.0), 4))
실행 결과 · python phase09_rlhf.py
첫 비교 한 걸음: r(12⏎) = +0.5, r(7⏎) = −0.5, 선호 확률 σ(1) = 0.7311
보상 모델 점수: {'3×4=12⏎': 4.63, '2×3=6⏎': 3.8, '3×4=1⏎': 0.0, '2×3=1⏎': -1.89, '2×3=7⏎': -1.91, '3×4=7⏎': -4.64}
3×4=  P(정답): 0.160 → 0.980  (이론적 최적 1.000)   KL(π‖π_ref) = 1.714
2×3=  P(정답): 0.240 → 0.985  (이론적 최적 1.000)   KL(π‖π_ref) = 1.344

보상 모델은 12⏎ > 1⏎ > 7⏎ 순서를 배웠습니다. 1⏎은 한 번 이기고 한 번 져서 0점입니다. PPO는 SFT에서 0.16, 0.24였던 정답률을 0.98 이상으로 올렸습니다. 보상 점수 차이(약 9)가 β = 0.1에 비해 매우 커서 이론적 최적이 거의 1입니다.

보상 모델의 빈틈이 보상 모델은 본 적 없는 응답에 0점을 줍니다. 예를 들어 3×4=에 대한 2⏎은 오답인데도 7⏎(−4.6)보다 훨씬 높은 점수를 받습니다. 실제 보상 모델도 학습 데이터에서 먼 응답에 엉뚱한 점수를 줄 수 있고, 정책은 그런 빈틈을 찾아 점수만 올리는 법을 배울 수 있습니다(reward hacking). KL 벌점은 정책이 πref에서 너무 멀리 가지 않게 해 이 위험을 줄입니다.
메모리모델 4개

actor, critic, 보상 모델, πref를 동시에 올려야 합니다. 실전 RLHF가 무거운 이유입니다.

데이터사람 비교가 비싸다

그래서 Phase 10은 rollout과 보상 모델 없이 비교 데이터로 직접 학습하고, Phase 11은 사람 대신 검증기를 씁니다.

TRLRewardTrainer · PPOTrainer

③은 RewardTrainer, ④는 PPOTrainer가 담당합니다. 데이터 형식은 이 Phase의 (prompt, chosen, rejected)와 같습니다.

Q1. 모든 보상 모델 점수에 +100을 더하면 RLHF 결과가 바뀌나요?

식 (9.1)의 선호 확률은 그대로입니다. 다만 PPO에서는 마지막 토큰 보상이 모두 100 커지므로 critic이 그만큼 맞춰야 합니다. advantage는 (보상 − 가치)라서 critic이 잘 학습되면 결과는 같습니다. 그래서 실전에서는 보상을 정규화하기도 합니다.

Q2. 식 (9.3)에서 KL 벌점이 왜 πold로 계산되나요?

보상은 rollout 시점에 한 번 계산해 고정하는 값이기 때문입니다. 응답을 생성한 정책이 πold이고, 그때의 로그 확률로 벌점을 매깁니다. Phase 11의 GRPO는 KL을 보상이 아니라 손실에 직접 넣습니다.

Q3. β = 0.1을 0.5로 키우면 이 실험의 결과는?

r(12⏎) ≈ 4.6이라 exp(4.6/0.5)도 여전히 매우 큽니다. 최적 정답률이 거의 1이라 결과가 크게 다르지 않습니다. 보상 점수의 크기와 β의 비율이 중요합니다.

Phase 8–12 · LLM 사후학습

Phase 10 · DPO와 KTO: 보상 모델 없이 선호를 직접 학습

함께 읽기: TRL DPOTrainer · TRL KTOTrainer · DPO 논문 · KTO 논문
대응 교재 장: DPO · KTO

① 직관: 최적 정책을 이미 알고 있다면

Phase 8에서 KL로 묶은 목표의 정확한 해를 구했습니다. π*(y) ∝ πref(y)·exp(r(y)/β)입니다. 이 식을 거꾸로 읽으면 정책이 곧 보상을 담고 있다는 뜻이 됩니다. 그렇다면 보상 모델을 따로 학습하고 PPO로 최적화하는 대신, 비교 데이터에 맞게 정책 자체를 바로 학습할 수 있습니다. rollout도 critic도 보상 모델도 필요 없습니다.

② 수식: 식 (8.5)에서 DPO까지 세 줄

식 (10.1) · 출발점 = 식 (8.5)
π∗(y∣x)=1Z(x)πref(y∣x)exp⁡ ⁣(r(x,y)/β)\pi^*(y\mid x)=\frac1{Z(x)}\pi_{\text{ref}}(y\mid x)\exp\!\big(r(x,y)/\beta\big)
식 (10.2) · 보상을 정책으로 다시 쓰기
r(x,y)=βlog⁡π∗(y∣x)πref(y∣x)+βlog⁡Z(x)r(x,y)=\beta\log\frac{\pi^*(y\mid x)}{\pi_{\text{ref}}(y\mid x)}+\beta\log Z(x)
양변에 log를 취해 r에 대해 풀었습니다. β log Z(x)는 응답 y와 무관합니다.
식 (10.3) · DPO 손실
LDPO=−log⁡σ(β[log⁡πθ(yw)πref(yw)−log⁡πθ(yl)πref(yl)⏟m])L_{\text{DPO}}=-\log\sigma\Big(\beta\Big[\underbrace{\log\tfrac{\pi_\theta(y_w)}{\pi_{\text{ref}}(y_w)}-\log\tfrac{\pi_\theta(y_l)}{\pi_{\text{ref}}(y_l)}}_{m}\Big]\Big)
식 (10.2)를 식 (9.2)의 Bradley–Terry 손실에 넣으면 rw − rl에서 log Z(x)가 사라집니다. 보상 모델 대신 정책의 log 확률비 차이 m을 학습합니다. −∂L/∂m = β·σ(−βm)입니다.
식 (10.4) · KTO 손실 (단순화)
LKTO={1−σ(β(rθ−z0))좋음1−σ(β(z0−rθ))나쁨,rθ=log⁡πθ(y)πref(y), z0=DKL(πθ∥πref)L_{\text{KTO}}=\begin{cases}1-\sigma\big(\beta(r_\theta-z_0)\big)&\text{좋음}\\1-\sigma\big(\beta(z_0-r_\theta)\big)&\text{나쁨}\end{cases},\quad r_\theta=\log\frac{\pi_\theta(y)}{\pi_{\text{ref}}(y)},\ z_0=D_{\text{KL}}(\pi_\theta\|\pi_{\text{ref}})
비교 쌍이 없고 “이 응답은 좋다/나쁘다” 라벨만 있을 때 씁니다. 기준점 z0보다 log 확률비가 높으면 좋은 응답, 낮으면 나쁜 응답으로 보고 학습합니다. z0는 gradient를 흘리지 않습니다. 원 논문은 z0를 batch 안의 다른 응답으로 추정하고 좋음/나쁨 가중치 λD, λU를 둡니다. 여기서는 응답을 모두 나열해 KL을 정확히 계산하고 가중치는 1로 둡니다.

③ 손계산 (β = 0.1)

단계계산값
처음 (πθ = πref)m = (0 − 0) − (0 − 0)m = 0
손실−log σ(0)log 2 = 0.6931
gradient 계수β·σ(−β·0) = 0.1 × 0.50.05
갱신 방향+0.05·∇log π(12⏎) − 0.05·∇log π(7⏎)좋은 응답 ↑, 나쁜 응답 ↓

Phase 9 Q1의 “점수에 상수를 더해도 된다”가 여기서 log Z(x)가 사라지는 이유와 같습니다. 같은 질문 안에서 두 응답을 비교하기 때문입니다.

④ 코드

Python · 식 (10.3) · DPO 학습
phase10_dpo_kto.py 에서 발췌
def dpo_loss_and_coef(lm, ref, x, yw, yl, beta=0.1):
    """식 (10.3): L = −log σ(β·m),  m = [log π(y_w) − log π_ref(y_w)] − [log π(y_l) − log π_ref(y_l)]

    −∂L/∂m = β·σ(−β·m) 이므로, 경사 상승 방향은 β·σ(−βm)·(∇log π(y_w) − ∇log π(y_l)).
    """
    m = (seq_logp(lm, x, yw) - seq_logp(ref, x, yw)) - (seq_logp(lm, x, yl) - seq_logp(ref, x, yl))
    return -math.log(sigmoid(beta * m)), beta * sigmoid(-beta * m), m


def train_dpo(prefs, beta=0.1, epochs=300, lr=1.0):
    lm = TinyLM()
    ref = lm.copy()
    for _ in range(epochs):
        grads = {}
        for x, yw, yl in prefs:
            _, coef, _ = dpo_loss_and_coef(lm, ref, x, yw, yl, beta)
            add_seq_grad(grads, lm, x, yw, +coef / len(prefs))    # 좋은 응답의 확률 ↑
            add_seq_grad(grads, lm, x, yl, -coef / len(prefs))    # 나쁜 응답의 확률 ↓
        lm.apply(grads, lr)
    return lm, ref
Python · 식 (10.4) · KTO 학습
phase10_dpo_kto.py 에서 발췌
KTO_LABELS = [('3×4=', '12⏎', True), ('3×4=', '7⏎', False), ('3×4=', '1⏎', False),
              ('2×3=', '6⏎', True), ('2×3=', '7⏎', False), ('2×3=', '1⏎', False)]


def train_kto(labels, beta=0.1, epochs=300, lr=1.0):
    """식 (10.4): r = log π(y)/π_ref(y),  z = KL(π‖π_ref) (기준점, gradient 없음)
       좋음:  L = 1 − σ(β(r − z))    나쁨:  L = 1 − σ(β(z − r))
    """
    lm = TinyLM()
    ref = lm.copy()
    for _ in range(epochs):
        grads = {}
        z = {x: max(0.0, kl(lm, ref, x)) for x in ANSWERS}         # detach된 기준점
        for x, y, good in labels:
            r = seq_logp(lm, x, y) - seq_logp(ref, x, y)
            v = sigmoid(beta * (r - z[x]) if good else beta * (z[x] - r))
            coef = beta * v * (1 - v) * (1 if good else -1)        # −∂L/∂r
            add_seq_grad(grads, lm, x, y, coef / len(labels))
        lm.apply(grads, lr)
    return lm, ref

⑤ 검증

Python · 처음 손실 log 2와 계수 0.05 대조
phase10_dpo_kto.py 에서 발췌
def hand_check():
    lm = TinyLM()
    ref = lm.copy()
    loss, coef, m = dpo_loss_and_coef(lm, ref, '3×4=', '12⏎', '7⏎')
    assert m == 0.0 and abs(loss - math.log(2)) < 1e-12 and abs(coef - 0.05) < 1e-12
    print(f'처음: m = 0, DPO loss = log 2 = {loss:.4f}, gradient 계수 β·σ(0) = {coef}')
실행 결과 · python phase10_dpo_kto.py
처음: m = 0, DPO loss = log 2 = 0.6931, gradient 계수 β·σ(0) = 0.05
DPO 뒤:
  3×4=  P(정답) 0.160 → 0.805   KL = 1.287
  2×3=  P(정답) 0.240 → 0.896   KL = 1.201
  암묵적 보상 β·log π/π_ref: {'3×4=12⏎': 0.162, '3×4=1⏎': -0.106, '3×4=7⏎': -0.912}
KTO 뒤:
  3×4=  P(정답) 0.160 → 0.485   KL = 0.406
  2×3=  P(정답) 0.240 → 0.711   KL = 0.675

DPO는 rollout 없이 Phase 9와 같은 선호 데이터만으로 정답률을 0.80–0.90까지 올렸습니다. 학습된 정책의 암묵적 보상 β·log π/πref는 12⏎ > 1⏎ > 7⏎로, Phase 9의 보상 모델과 같은 순서입니다. 정책 안에 보상 모델이 들어 있다는 식 (10.2)의 의미입니다.

데이터에 없는 응답으로 확률이 샌다DPO 뒤 3×4=에서 12⏎이 0.805를 얻는 동안, 데이터에 한 번도 없던 11⏎, 121, 122, 126도 각각 약 0.02씩 얻었습니다. DPO는 비교 쌍 안의 상대적 차이만 학습하므로, 줄어든 7⏎의 확률이 어디로 갈지는 정하지 않습니다. 온라인 RL(Phase 9, 11)은 새로 생성한 응답을 직접 평가하므로 이런 응답도 벌을 받습니다. 실전에서 온라인 방법과 DPO를 섞어 쓰는 이유 중 하나입니다.
RLHF + PPO (Phase 9)DPOKTO
데이터비교 쌍 → 보상 모델비교 쌍 (x, yw, yl)단일 응답 + 좋음/나쁨
학습 중 생성(rollout)필요불필요불필요
필요한 모델actor, critic, RM, refactor, refactor, ref
이 실험의 정답률 (3×4= / 2×3=)0.98 / 0.990.81 / 0.900.49 / 0.71

실제 LLM: TRL DPOTrainer

데이터 형식은 Phase 9와 같은 (prompt, chosen, rejected)입니다. 아래 스크립트를 작은 테스트 모델(trl-internal-testing/tiny-Qwen2ForCausalLM-2.5)로 2 step 실행해, 첫 손실이 손계산과 같은 0.6931 = log 2임을 확인했습니다. Qwen2.5-0.5B 학습 결과까지는 확인하지 않았습니다.

Python · extras/trl_dpo.py · TRL 0.24.0
extras/trl_dpo.py 전체
# -*- coding: utf-8 -*-
"""선택 실습 · Phase 10의 DPO를 TRL로 실제 LLM에 적용한다.

실행 (GPU 권장, Colab T4 가능):
    pip install trl==0.24.0 transformers datasets
    python extras/trl_dpo.py                           # Qwen/Qwen2.5-0.5B-Instruct
    python extras/trl_dpo.py <모델 이름> <max_steps>

Phase 9의 PREFERENCES와 같은 (prompt, chosen, rejected) 형식이다. 보상 모델도 rollout도 없다.
"""
import random
import sys

from datasets import Dataset
from trl import DPOConfig, DPOTrainer

MODEL = sys.argv[1] if len(sys.argv) > 1 else 'Qwen/Qwen2.5-0.5B-Instruct'
MAX_STEPS = int(sys.argv[2]) if len(sys.argv) > 2 else 100


def make_pairs(n=512, seed=0):
    rng = random.Random(seed)
    rows = []
    for _ in range(n):
        a, b = rng.randint(2, 19), rng.randint(2, 19)
        wrong = a * b + rng.choice([-a, -b, b, a, 1, -1])            # 그럴듯한 오답
        rows.append({'prompt': [{'role': 'user', 'content': f'{a}×{b}는 얼마인가요? 숫자 하나로만 답하세요.'}],
                     'chosen': [{'role': 'assistant', 'content': str(a * b)}],
                     'rejected': [{'role': 'assistant', 'content': str(wrong)}]})
    return Dataset.from_list(rows)


args = DPOConfig(
    output_dir='dpo-out',
    beta=0.1,                          # 식 (10.3)의 β
    per_device_train_batch_size=8,
    learning_rate=5e-7,
    max_steps=MAX_STEPS,
    logging_steps=1,
    report_to='none',
)
trainer = DPOTrainer(model=MODEL, args=args, train_dataset=make_pairs())   # ref_model은 자동으로 복사
trainer.train()
검증 때 기록한 출력 (tiny 테스트 모델, CPU, 2 step, 일부 생략)
{'loss': 0.6931, 'grad_norm': 0.1568, 'rewards/chosen': 0.0, 'rewards/rejected': 0.0, 'rewards/margins': 0.0, 'logps/chosen': -65.59, 'logps/rejected': -67.09, ...}
{'loss': 0.6931, 'grad_norm': 0.1384, 'rewards/chosen': -1.3e-06, 'rewards/rejected': -9.5e-07, 'rewards/margins': -3.3e-07, ...}
Q1. DPO에서 β를 크게 하면?

m이 조금만 벌어져도 σ(βm)이 빨리 1에 가까워져 gradient가 작아집니다. πref에서 덜 벗어난 상태에서 학습이 멈춥니다. 식 (8.5)에서 β가 클수록 π*가 πref에 가까운 것과 같은 이치입니다.

Q2. DPO의 chosen이 정답이 아니라 “덜 나쁜 오답”이라면?

DPO는 상대 비교만 배우므로 그 오답의 확률도 올라갈 수 있습니다. 이 실험의 1⏎ ≻ 7⏎ 쌍이 그런 예입니다. 데이터 품질이 결과를 그대로 결정합니다.

Q3. 표에서 KTO의 정답률이 DPO보다 낮은데, KTO가 더 나쁜 방법인가요?

그렇게 읽으면 안 됩니다. 세 방법 모두 β = 0.1과 같은 학습 횟수로 맞췄을 뿐 각자에 맞게 조정하지 않았습니다. KTO는 응답 하나를 기준점 z0와 비교하므로 쌍 비교보다 신호가 약할 수 있습니다. 대신 비교 쌍 없이 좋음/나쁨 라벨만 있어도 쓸 수 있다는 장점이 있습니다.

Phase 8–12 · LLM 사후학습

Phase 11 · RLVR와 GRPO: 검증기와 그룹 비교

함께 읽기: LLM Course 12장 · Build Reasoning Models · TRL GRPOTrainer · DeepSeekMath (GRPO) · DeepSeek-R1
대응 교재 장: RLVR · GRPO · RLVR 실전 추적

① 직관: 사람 대신 채점기, critic 대신 옆 답안

수학이나 코드처럼 정답을 기계로 확인할 수 있는 문제라면 사람의 비교도, 보상 모델도 필요 없습니다. 검증기가 맞으면 1, 틀리면 0을 줍니다(RLVR: RL with Verifiable Rewards). 남는 문제는 advantage입니다. PPO는 critic으로 “기대보다 얼마나 좋았나”를 쟀지만, critic은 LLM만큼 커서 비쌉니다. GRPO는 같은 질문에 답안을 여러 개 생성해 서로 비교합니다. 평균보다 나은 답안이 양의 advantage를 받습니다.

① 생성질문마다 G개 응답
(πold)
② 채점검증기 ri ∈ {0, 1}
③ 그룹 비교Ai = (ri − 평균) / 표준편차
④ PPO식 갱신토큰마다 ratio·clip
+ KL to πref
그림 11.1 GRPO 한 iteration. critic이 없고, advantage는 같은 질문의 응답끼리만 비교해 만듭니다.

② 수식

식 (11.1) · 그룹 상대 advantage
A^i=ri−mean⁡(r1,…,rG)std⁡(r1,…,rG)+ϵ\hat A_i=\frac{r_i-\operatorname{mean}(r_1,\dots,r_G)}{\operatorname{std}(r_1,\dots,r_G)+\epsilon}
응답 i의 모든 토큰이 같은 Âi를 받습니다. 그룹의 보상이 모두 같으면 Â = 0이라 그 질문에서는 배울 것이 없습니다.
식 (11.2) · GRPO 목적함수
J(θ)=1G∑i=1G1∣yi∣∑t=1∣yi∣[min⁡(ρi,tA^i,clip⁡(ρi,t,1±ε)A^i)−β k3(πθ,πref)i,t]J(\theta)=\frac1G\sum_{i=1}^G\frac1{|y_i|}\sum_{t=1}^{|y_i|}\Big[\min\big(\rho_{i,t}\hat A_i,\operatorname{clip}(\rho_{i,t},1\pm\varepsilon)\hat A_i\big)-\beta\,k_3\big(\pi_\theta,\pi_{\text{ref}}\big)_{i,t}\Big]
ρi,t는 식 (7.1)의 토큰 ratio, k3 = πref/πθ − log(πref/πθ) − 1은 항상 0 이상인 KL 추정량입니다. 응답별 토큰 평균을 낸 뒤 그룹 평균을 냅니다. 이 “평균 내는 방법”을 바꾼 것이 Phase 12의 변형들입니다.

③ 손계산

그룹 보상평균표준편차Â해석
[1, 0, 0, 1]0.50.5[+1, −1, −1, +1]반반: 가장 강한 신호
[1, 0, 0, 0]0.250.433[+1.732, −0.577, −0.577, −0.577]어려운 질문: 드문 정답을 크게 칭찬
[1, 1, 1, 1]10[0, 0, 0, 0]모두 정답: 신호 없음
[0, 0, 0, 0]00[0, 0, 0, 0]모두 오답: 신호 없음
실험 11.1 · 응답을 눌러 정답/오답을 바꾸고 advantage 보기

④ 코드

Python · 식 (11.1) · 그룹 상대 advantage
phase11_grpo.py 에서 발췌
def group_advantages(rewards, mode='std', eps=1e-4):
    """식 (11.1): A_i = (r_i − mean(r)) / (std(r) + eps)   — 같은 질문의 G개 응답끼리 비교.

    mode='mean' 이면 표준편차로 나누지 않는다 (Dr. GRPO).
    mode='rloo' 이면 나를 뺀 나머지의 평균을 뺀다 (RLOO, 식 12.1).
    std는 모집단 표준편차(n으로 나눔)다. TRL은 표본 표준편차(n−1)를 쓴다.
    """
    G = len(rewards)
    mean = sum(rewards) / G
    if mode == 'rloo':
        return [r - (sum(rewards) - r) / (G - 1) for r in rewards]
    if mode == 'mean':
        return [r - mean for r in rewards]
    std = (sum((r - mean) ** 2 for r in rewards) / G) ** 0.5
    return [(r - mean) / (std + eps) for r in rewards]
Python · 식 (11.2) · 목적함수를 로짓 gradient로 (Phase 12 옵션 포함)
phase11_grpo.py 에서 발췌
DEFAULT = dict(adv='std', agg='seq_mean', eps_low=0.2, eps_high=0.2, ratio='token', beta=0.04, dynamic=False)


def grpo_loss_grads(lm, ref, group, cfg=DEFAULT):
    """식 (11.2): 그룹 하나의 목적함수를 로짓 gradient로 바꿔 돌려준다.

    group: [(prompt, tokens, old_logps, advantage)]
    cfg['agg']    'seq_mean'  응답별 토큰 평균 → 그룹 평균           (GRPO 원형)
                  'token_mean' 그룹 전체 토큰 평균                    (DAPO)
                  'const'      토큰 합 / (G · 최대 생성 길이)          (Dr. GRPO)
    cfg['ratio']  'token' 토큰마다 ratio / 'sequence' 응답 하나에 ratio (GSPO)
    """
    G, grads = len(group), {}
    total_tokens = sum(len(y) for _, y, _, _ in group)
    for x, y, old, A in group:
        new = lm.token_logps(x, y)
        refl = ref.token_logps(x, y)
        if cfg['ratio'] == 'sequence':                      # GSPO: exp(평균 log ratio) 하나를 공유
            seq_ratio = math.exp(sum(n - o for n, o in zip(new, old)) / len(y))
        for t in range(len(y)):
            ratio = seq_ratio if cfg['ratio'] == 'sequence' else math.exp(new[t] - old[t])
            lo, hi = 1 - cfg['eps_low'], 1 + cfg['eps_high']
            clipped = (A > 0 and ratio > hi) or (A < 0 and ratio < lo)
            # Phase 7의 ppo_clip_term과 같은 규칙. GSPO에서도 ∂s/∂log π_t = s/|y| 이고,
            # seq_mean 가중치 1/(G|y|)가 그 1/|y|를 담당하므로 계수는 똑같이 ratio·A다.
            coef = 0.0 if clipped else ratio * A
            # KL 벌점 k3 = π_ref/π − log(π_ref/π) − 1 의 gradient 계수: −β(1 − π_ref/π)
            coef += -cfg['beta'] * (1 - math.exp(refl[t] - new[t]))
            if cfg['agg'] == 'seq_mean':
                w = 1 / (G * len(y))
            elif cfg['agg'] == 'token_mean':
                w = 1 / total_tokens
            else:
                w = 1 / (G * MAX_NEW_TOKENS)
            key, g = lm.grad_token(x, y, t)
            add_grad(grads, key, g, coef * w)
    return grads
Python · 생성 → 채점 → 그룹 비교 → 갱신
phase11_grpo.py 에서 발췌
def train_grpo(cfg=DEFAULT, iterations=60, G=8, epochs=2, lr=1.0, seed=0):
    rng, lm = random.Random(seed), TinyLM()
    ref = lm.copy()
    for _ in range(iterations):
        groups = []
        for x in ANSWERS:
            ys = [lm.sample(x, rng) for _ in range(G)]                 # ① 질문마다 G개 생성
            rs = [verify(x, y) for y in ys]                            # ② 검증기 보상 0/1
            if cfg['dynamic'] and len(set(rs)) == 1:
                continue                                               # DAPO: 신호 없는 그룹 제외
            As = group_advantages(rs, cfg['adv'])                      # ③ 그룹 안에서 상대 평가
            groups.append([(x, y, lm.token_logps(x, y), A) for y, A in zip(ys, As)])
        for _ in range(epochs):                                        # ④ 같은 그룹으로 몇 번 갱신
            for group in groups:
                lm.apply(grpo_loss_grads(lm, ref, group, cfg), lr / len(ANSWERS))
    return lm, ref

⑤ 검증

Python · 표의 advantage 대조
phase11_grpo.py 에서 발췌
def hand_check():
    close = lambda xs, ys: all(abs(a - b) < 1e-3 for a, b in zip(xs, ys))
    assert close(group_advantages([1, 0, 0, 1]), [1, -1, -1, 1])
    assert group_advantages([1, 1, 1, 1]) == [0.0, 0.0, 0.0, 0.0]        # 모두 같으면 신호 없음
    assert close(group_advantages([1, 0, 0, 0]), [1.732, -0.577, -0.577, -0.577])
    print('A([1,0,0,1]) =', [round(a, 3) for a in group_advantages([1, 0, 0, 1])],
          '| A([1,0,0,0]) =', [round(a, 3) for a in group_advantages([1, 0, 0, 0])])
    lm = TinyLM()
    ys = [['1', '2', '<eos>'], ['7', '<eos>'], ['1', '<eos>'], ['1', '2', '<eos>']]
    rs = [verify('3×4=', y) for y in ys]
    assert rs == [1.0, 0.0, 0.0, 1.0]
    print('그룹:', [show(y) for y in ys], '보상:', rs)
실행 결과 · python phase11_grpo.py
A([1,0,0,1]) = [1.0, -1.0, -1.0, 1.0] | A([1,0,0,0]) = [1.732, -0.577, -0.577, -0.577]
그룹: ['12⏎', '7⏎', '1⏎', '12⏎'] 보상: [1.0, 0.0, 0.0, 1.0]
3×4=  P(정답) 0.160 → 0.933   KL(π‖π_ref) = 1.508
2×3=  P(정답) 0.240 → 0.965   KL(π‖π_ref) = 1.254

critic 없이 검증기와 그룹 비교만으로 정답률이 0.16 → 0.93, 0.24 → 0.97로 올랐습니다. extras/autograd_check.py는 grpo_loss_grads()가 식 (11.2)를 PyTorch autograd로 미분한 값과 Phase 12의 모든 변형에서 일치함을 확인합니다.

std를 n으로 나눌까, n−1로 나눌까이 튜토리얼은 모집단 표준편차(n으로 나눔)를 씁니다. TRL 0.24는 torch.std의 기본값인 표본 표준편차(n−1)를 쓰고 1e-4를 더합니다. [1,0,0,1]이면 TRL의 Â는 ±0.866입니다. 부호와 순서는 같고 크기만 다릅니다.

실제 LLM: TRL GRPOTrainer

TinyLM의 verify() 자리에 같은 역할의 verifier()를 넣고, 모델만 실제 LLM으로 바꿉니다. 작은 테스트 모델로 2 step을 실행해 전체 흐름이 돌아가는 것을 확인했습니다. 무작위 가중치 모델이라 모든 응답이 오답이었고, 그래서 frac_reward_zero_std = 1.0, loss = 0.0이 나왔습니다. 위 표의 “모두 오답: 신호 없음”이 실제 로그에 그대로 나타난 것입니다. Qwen2.5-0.5B-Instruct 학습 결과까지는 확인하지 않았습니다.

Python · extras/trl_grpo.py · TRL 0.24.0
extras/trl_grpo.py 전체
# -*- coding: utf-8 -*-
"""선택 실습 · Phase 11의 GRPO를 TRL로 실제 LLM에 적용한다.

실행 (GPU 권장, Colab T4 가능):
    pip install trl==0.24.0 transformers datasets
    python extras/trl_grpo.py                          # Qwen/Qwen2.5-0.5B-Instruct, 200 step
    python extras/trl_grpo.py <모델 이름> <max_steps>    # 예: 작은 테스트 모델로 2 step만

TinyLM과 바뀌는 것은 모델과 데이터 크기뿐이다. verifier는 tinylm.verify와 같은 역할을 한다.
"""
import random
import re
import sys

from datasets import Dataset
from trl import GRPOConfig, GRPOTrainer

MODEL = sys.argv[1] if len(sys.argv) > 1 else 'Qwen/Qwen2.5-0.5B-Instruct'
MAX_STEPS = int(sys.argv[2]) if len(sys.argv) > 2 else 200


def make_dataset(n=512, seed=0):
    rng = random.Random(seed)
    rows = []
    for _ in range(n):
        a, b = rng.randint(2, 19), rng.randint(2, 19)
        rows.append({'prompt': [{'role': 'user', 'content': f'{a}×{b}는 얼마인가요? 숫자 하나로만 답하세요.'}],
                     'answer': str(a * b)})
    return Dataset.from_list(rows)


def verifier(completions, answer, **kwargs):
    """검증기 보상 (식 11.1의 r_i): 응답의 첫 정수가 정답이면 1, 아니면 0."""
    rewards = []
    for completion, gold in zip(completions, answer):
        text = completion[0]['content'] if isinstance(completion, list) else completion
        m = re.search(r'-?\d+', text)
        rewards.append(1.0 if m and m.group() == gold else 0.0)
    return rewards


args = GRPOConfig(
    output_dir='grpo-out',
    num_generations=8,                 # G: 질문 하나당 응답 수
    per_device_train_batch_size=8,     # 한 step에 질문 1개 × 8개 응답
    max_completion_length=32,
    learning_rate=1e-6,
    beta=0.04,                         # KL 계수 (TRL 0.24 기본값은 0.0)
    epsilon=0.2,                       # clip 폭. DAPO 식으로는 epsilon_high=0.28
    loss_type='grpo',                  # Phase 12: 'grpo' | 'dapo' | 'dr_grpo' | 'bnpo'
    scale_rewards='group',             # Phase 12: 'none'이면 Dr. GRPO 식 advantage
    importance_sampling_level='token', # Phase 12: 'sequence'이면 GSPO
    max_steps=MAX_STEPS,
    logging_steps=1,
    report_to='none',
)
trainer = GRPOTrainer(model=MODEL, reward_funcs=verifier, args=args, train_dataset=make_dataset())
trainer.train()
검증 때 기록한 출력 (tiny 테스트 모델, CPU, 2 step, 일부 생략)
{'loss': 0.0, 'grad_norm': 0.0, 'completions/mean_length': 32.0, 'completions/clipped_ratio': 1.0,
 'rewards/verifier/mean': 0.0, 'reward_std': 0.0, 'frac_reward_zero_std': 1.0, 'kl': 0.0, ...}
{'loss': 0.0, 'grad_norm': 0.0, 'completions/mean_length': 32.0, 'completions/clipped_ratio': 1.0,
 'rewards/verifier/mean': 0.0, 'reward_std': 0.0, 'frac_reward_zero_std': 1.0, 'kl': 0.0, ...}
TinyLM (phase11_grpo.py)TRL GRPOConfig뜻
G = 8num_generations=8질문당 응답 수
MAX_NEW_TOKENS = 3max_completion_length응답 최대 길이
cfg['beta'] = 0.04beta (0.24 기본값 0.0)KL 계수
cfg['eps_low'], cfg['eps_high']epsilon, epsilon_highclip 폭
epochs = 2num_iterations같은 생성으로 갱신하는 횟수
cfg['adv']scale_rewards = group / none표준편차로 나눌지
cfg['agg']loss_type = grpo / dapo / dr_grpo토큰 평균 방식 (Phase 12)
cfg['ratio']importance_sampling_level = token / sequenceGSPO (Phase 12)
Q1. 실험 11.1에서 네 응답을 모두 정답으로 바꾸면?

평균 1, 표준편차 0이라 모든 Â = 0입니다. 그 질문은 이번 iteration에서 학습에 기여하지 않습니다. 쉬운 질문이 많으면 GPU 시간만 쓰고 신호가 없습니다. Phase 12의 DAPO dynamic sampling이 이 문제를 다룹니다.

Q2. 정답이 0/1이 아니라 부분 점수(예: 0.3)라면 식 (11.1)이 그대로 동작하나요?

그렇습니다. 그룹 안의 상대 크기만 보므로 실수 보상도 됩니다. 여러 보상(정답 + 형식)을 더해 쓰는 경우가 많고, TRL은 reward_funcs에 함수 목록과 가중치를 받습니다.

Q3. 정답 보상만으로 “추론 과정”도 좋아지나요?

최종 답만 검사하므로 틀린 과정으로 우연히 맞힌 답도 보상받습니다. 그래도 많은 질문에서 평균적으로 정답에 이르는 과정이 강화됩니다. DeepSeek-R1이 이 방식으로 긴 추론을 학습한 대표 사례입니다. 과정 자체를 평가하는 방법은 기존 교재의 Cliff 장을 참고하세요.

Phase 8–12 · LLM 사후학습

Phase 12 · 최신 변형: 같은 그룹, 옵션 하나씩

함께 읽기: TRL GRPOTrainer · loss_type · RLOO · Dr. GRPO · DAPO · GSPO
대응 교재 장: RLOO · Dr. GRPO · DAPO · GSPO · 전체 비교

① 직관: 바뀌는 곳은 세 군데뿐

GRPO 이후의 많은 방법은 식 (11.2)의 큰 틀을 유지하고 세 가지 중 하나를 바꿉니다. ① advantage를 어떻게 만드나(baseline, 표준편차) ② 토큰 손실을 어떻게 평균 내나(응답 길이의 영향) ③ ratio와 clip을 어디에 거나(토큰/응답, 상한). 이 Phase는 phase11_grpo.py의 옵션만 바꿔 각 변형을 만듭니다.

Python · 다섯 변형 = 옵션 조합
phase12_variants.py 에서 발췌
VARIANTS = {
    'GRPO':     dict(DEFAULT),
    'RLOO':     dict(DEFAULT, adv='rloo', beta=0.0),
    'Dr. GRPO': dict(DEFAULT, adv='mean', agg='const', beta=0.0),
    'DAPO':     dict(DEFAULT, agg='token_mean', eps_high=0.28, dynamic=True, beta=0.0),
    'GSPO':     dict(DEFAULT, ratio='sequence', beta=0.0),
}

② 수식: 바뀐 부분만

식 (12.1) · RLOO: 나를 뺀 평균
A^iRLOO=ri−1G−1∑j≠irj\hat A_i^{\text{RLOO}}=r_i-\frac1{G-1}\sum_{j\ne i}r_j
자기 보상이 baseline에 섞이지 않아 편향이 없습니다. 표준편차로 나누지 않습니다.
식 (12.2) · Dr. GRPO: 상수 분모, 표준편차 제거
JDr=1G⋅Lmax⁡∑i∑t[⋯],A^i=ri−mean⁡(r)J^{\text{Dr}}=\frac1{G\cdot L_{\max}}\sum_i\sum_t\big[\cdots\big],\qquad\hat A_i=r_i-\operatorname{mean}(r)
응답 길이 |yi|로 나누면 긴 오답의 토큰당 벌점이 작아져 “틀릴 거면 길게”를 배울 수 있습니다. 표준편차로 나누면 너무 쉽거나 어려운 질문이 과하게 강조됩니다.
식 (12.3) · DAPO: 토큰 전체 평균 + 비대칭 clip
JDAPO=1∑i∣yi∣∑i∑tmin⁡(ρA^, clip⁡(ρ,1−εlow,1+εhigh)A^),εhigh=0.28J^{\text{DAPO}}=\frac1{\sum_i|y_i|}\sum_i\sum_t\min\big(\rho\hat A,\ \operatorname{clip}(\rho,1-\varepsilon_{\text{low}},1+\varepsilon_{\text{high}})\hat A\big),\quad\varepsilon_{\text{high}}=0.28
확률이 낮은 좋은 토큰을 더 많이 올릴 수 있게 상한을 넓힙니다. 모든 보상이 같은 그룹은 버리고 다시 뽑습니다(dynamic sampling).
식 (12.4) · GSPO: 응답 단위 ratio
si(θ)=exp⁡(1∣yi∣∑tlog⁡πθ(yi,t)πold(yi,t))s_i(\theta)=\exp\Big(\frac1{|y_i|}\sum_t\log\frac{\pi_\theta(y_{i,t})}{\pi_{\text{old}}(y_{i,t})}\Big)
토큰 ratio의 기하평균 하나를 응답 전체가 공유하고, clip도 응답 단위로 겁니다. 보상이 응답 단위이니 ratio도 응답 단위로 맞추자는 생각입니다. 논문은 이 ratio에 3e-4 수준의 아주 좁은 clip을 씁니다.

③ 손계산: 같은 그룹 하나

3×4=에 대한 그룹 [12⏎, 7⏎, 1⏎, 12⏎]의 보상은 [1, 0, 0, 1], 길이(토큰 수)는 [3, 2, 2, 3]입니다. 첫 갱신(ρ = 1)에서 토큰 하나가 받는 가중치를 비교합니다.

방법Â집계정답 토큰 (길이 3)오답 토큰 (길이 2)
GRPO[1, −1, −1, 1]1 / (G·|yi|)1/12 = +0.0833−1/8 = −0.125
DAPO[1, −1, −1, 1]1 / Σ|y| = 1/10+0.1−0.1
Dr. GRPO[0.5, −0.5, −0.5, 0.5]1 / (G·Lmax) = 1/12+0.0417−0.0417
RLOO[0.667, −0.667, −0.667, 0.667]1 / (G·|yi|)+0.0556−0.0833

GRPO에서는 짧은 오답의 토큰이 긴 정답의 토큰보다 1.5배 센 신호를 받습니다. 응답 길이로 나누기 때문입니다. 반대로 오답이 길었다면 토큰당 벌점이 작아졌을 것입니다. DAPO와 Dr. GRPO는 모든 토큰에 같은 크기를 줍니다.

토큰 ratio[1.105, 0.951, 1.020]log ratio 평균(0.10 − 0.05 + 0.02) / 3 = 0.0233GSPO 응답 ratioexp(0.0233) = 1.0236

GSPO에서는 토큰 하나가 1.105로 크게 바뀌어도 응답 전체의 ratio는 1.024입니다. 토큰마다 따로 clip되는 대신 응답이 한 덩어리로 움직입니다. DAPO의 상한 1.28에서는 ratio 1.25인 좋은 토큰이 계속 학습되지만, GRPO의 상한 1.2에서는 멈춥니다.

④ 코드

Python · 표의 토큰당 가중치 계산
phase12_variants.py 에서 발췌
def token_weights(advantages, lengths, agg):
    """토큰 하나가 loss에서 받는 가중치 (ratio = 1인 첫 갱신 기준). 응답 i의 모든 토큰이 같은 값."""
    G, total = len(lengths), sum(lengths)
    if agg == 'seq_mean':
        return [A / (G * L) for A, L in zip(advantages, lengths)]
    if agg == 'token_mean':
        return [A / total for A in advantages]
    return [A / (G * MAX_NEW_TOKENS) for A in advantages]       # 'const'

실제 갱신은 Phase 11의 grpo_loss_grads()가 옵션을 받아 처리합니다(agg, ratio, eps_high, adv, dynamic).

⑤ 검증

Python · 표의 가중치, GSPO ratio 대조
phase12_variants.py 에서 발췌
def hand_check():
    close = lambda xs, ys: all(abs(a - b) < 1e-3 for a, b in zip(xs, ys))
    rewards, lengths = [1, 0, 0, 1], [3, 2, 2, 3]              # 12⏎, 7⏎, 1⏎, 12⏎
    A_std = group_advantages(rewards, 'std')
    A_mean = group_advantages(rewards, 'mean')
    A_rloo = group_advantages(rewards, 'rloo')
    assert close(A_mean, [0.5, -0.5, -0.5, 0.5]) and close(A_rloo, [0.667, -0.667, -0.667, 0.667])
    table = {
        'GRPO (std, 응답별 평균)': token_weights(A_std, lengths, 'seq_mean'),
        'DAPO (std, 전체 토큰 평균)': token_weights(A_std, lengths, 'token_mean'),
        'Dr. GRPO (평균만, 상수 분모)': token_weights(A_mean, lengths, 'const'),
        'RLOO (나 뺀 평균, 응답별 평균)': token_weights(A_rloo, lengths, 'seq_mean'),
    }
    assert close(table['GRPO (std, 응답별 평균)'], [0.0833, -0.125, -0.125, 0.0833])
    assert close(table['DAPO (std, 전체 토큰 평균)'], [0.1, -0.1, -0.1, 0.1])
    assert close(table['Dr. GRPO (평균만, 상수 분모)'], [0.0417, -0.0417, -0.0417, 0.0417])
    for name, w in table.items():
        print(f'{name:22s} 토큰당 가중치 {[round(x, 4) for x in w]}')

    # DAPO clip-higher: A>0 인 토큰의 ratio가 1.25일 때
    from phase07_ppo import ppo_clip_term
    assert ppo_clip_term(1.25, 1.0, eps=0.2)[1] == 0.0                 # GRPO: 이미 멈춤
    hi = 1 + 0.28
    assert 1.25 < hi                                                    # DAPO: 상한 1.28 → 아직 학습

    # GSPO: 토큰 log ratio [0.10, −0.05, 0.02] → 응답 ratio는 기하평균
    logr = [0.10, -0.05, 0.02]
    s = math.exp(sum(logr) / len(logr))
    assert abs(s - 1.02360) < 1e-4
    print('GSPO 응답 ratio =', round(s, 4), '| 토큰 ratio =', [round(math.exp(x), 4) for x in logr])
실행 결과 · python phase12_variants.py
GRPO (std, 응답별 평균)     토큰당 가중치 [0.0833, -0.125, -0.125, 0.0833]
DAPO (std, 전체 토큰 평균)   토큰당 가중치 [0.1, -0.1, -0.1, 0.1]
Dr. GRPO (평균만, 상수 분모)  토큰당 가중치 [0.0417, -0.0417, -0.0417, 0.0417]
RLOO (나 뺀 평균, 응답별 평균)  토큰당 가중치 [0.0556, -0.0833, -0.0833, 0.0556]
GSPO 응답 ratio = 1.0236 | 토큰 ratio = [1.1052, 0.9512, 1.0202]
같은 seed, 30회 반복 뒤 (장난감 문제라 순위는 의미가 없다):
  GRPO     평균 P(정답) 0.868   평균 KL 1.128
  RLOO     평균 P(정답) 0.734   평균 KL 0.778
  Dr. GRPO 평균 P(정답) 0.600   평균 KL 0.441
  DAPO     평균 P(정답) 0.879   평균 KL 1.167
  GSPO     평균 P(정답) 0.899   평균 KL 1.215
이 숫자로 순위를 매기면 안 된다다섯 변형 모두 같은 학습률·반복 수를 썼습니다. Dr. GRPO가 느린 것은 표준편차로 나누지 않아 advantage가 작고(0.5 vs 1), 상수 분모로 가중치가 더 작아져 실질 학습률이 낮기 때문입니다. 학습률을 맞추면 달라집니다. 논문들의 차이는 수천 개 질문, 수천 토큰 길이, 큰 모델에서 나타나는 안정성과 편향의 문제입니다. 이 장난감 문제는 “식이 코드에서 정확히 어디를 바꾸는가”를 보기 위한 것입니다.
변형TRL 0.24 GRPOConfig로 켜는 법
GRPO 원형loss_type="grpo", scale_rewards="group"
Dr. GRPOloss_type="dr_grpo", scale_rewards="none"
DAPOloss_type="dapo" (0.24 기본값), epsilon_high=0.28
GSPOimportance_sampling_level="sequence", 작은 epsilon
RLOO별도의 RLOOTrainer

여기까지 온 길

Phase바뀐 한 가지이전 Phase의 무엇을 재사용했나
1 → 2return의 기대값을 상태별로식 (1.2) G = r + γG
2 → 3평균(π) 대신 maxBellman backup, TD 오차
3 → 4표 대신 신경망Q-learning target
4 → 5가치 대신 정책을 직접return (Phase 1), V를 baseline으로 (Phase 2)
5 → 6G 대신 critic의 δ, GAETD 오차 (Phase 2)
6 → 7같은 batch 재사용 + clipGAE, actor–critic
7 → 8칸 대신 prefix, ←/→ 대신 토큰REINFORCE (Phase 5), baseline
8 → 9사람 비교로 보상 모델GAE + PPO clip (Phase 6–7), KL (Phase 8)
9 → 10보상 모델·rollout 제거식 (8.5) 최적 정책, Bradley–Terry
9 → 11critic 제거, 검증기 + 그룹 비교PPO clip (Phase 7), KL (Phase 8)
11 → 12집계·ratio·clip 옵션Phase 11 코드 그대로
다음 단계① extras/trl_grpo.py를 Colab GPU에서 Qwen2.5-0.5B-Instruct로 200 step 돌려 보고, reward와 frac_reward_zero_std 로그를 이 Phase의 표와 연결해 읽기. ② loss_type과 scale_rewards를 바꿔 응답 길이 로그가 어떻게 달라지는지 보기. ③ 기존 교재의 SAPO, PNPO, GAPO, Cliff 장을 “①②③ 중 무엇을 바꿨나”라는 질문으로 읽기.
Q1. 그룹이 [12⏎(3), 7 7 7(3, 길이 제한)] 이고 보상이 [1, 0]이면 GRPO와 DAPO의 토큰 가중치는?

 = [+1, −1]. GRPO: 각 1/(2·3) = ±0.167. DAPO: 1/6 = ±0.167. 길이가 같으면 두 방법이 같습니다. 차이는 길이가 다를 때만 생깁니다.

Q2. 모든 질문이 너무 쉬워 그룹이 전부 정답이면 각 변형은?

GRPO·Dr. GRPO·RLOO·GSPO 모두 Â = 0이라 신호가 없습니다. DAPO는 그런 그룹을 버리고 신호가 있는 그룹으로 batch를 채웁니다. 이 튜토리얼의 구현은 버리기만 하고 다시 뽑지는 않습니다.

Q3. GSPO의 ratio가 기하평균이 아니라 산술평균이면?

[1.105, 0.951, 1.020]의 산술평균은 1.025로 비슷해 보이지만, 토큰 하나가 크게 튀면 산술평균이 크게 흔들립니다. 기하평균은 log ratio의 평균이라 응답 log 확률의 변화량과 직접 연결됩니다.