이미지 로딩 중...

바닥부터 만드는 ChatGPT 17편 GRPO 알고리즘 이론 - 슬라이드 1/9
A

AI Generated

2025. 11. 12. · 3 Views

바닥부터 만드는 ChatGPT 17편 GRPO 알고리즘 이론

강화학습 기반 언어모델 최적화의 핵심인 GRPO(Group Relative Policy Optimization) 알고리즘을 바닥부터 이해합니다. PPO의 한계를 극복하고 그룹 기반 상대적 보상으로 더 효율적인 학습을 구현하는 방법을 실전 코드와 함께 알아봅니다.


목차

  1. GRPO 알고리즘 개요 - 그룹 기반 정책 최적화의 핵심 이해
  2. Advantage 계산 - 상대적 이점의 핵심 메커니즘
  3. KL Divergence 제약 - 안정적 정책 업데이트의 핵심
  4. 온라인 샘플링 - 효율적인 데이터 생성 전략
  5. 클리핑 기법 - PPO 스타일의 안정화 추가
  6. 보상 모델 - 인간 선호도를 수치화하는 방법
  7. 미니배치 학습 - 효율적인 그래디언트 업데이트
  8. 전체 GRPO 파이프라인 - 종합 구현

1. GRPO 알고리즘 개요 - 그룹 기반 정책 최적화의 핵심 이해

시작하며

여러분이 ChatGPT 같은 대화형 AI를 학습시킬 때, 단순히 "좋다/나쁘다"의 절대적 평가만으로는 한계가 있다는 걸 느낀 적 있나요? 예를 들어, 같은 질문에 대한 10개의 답변 중 어떤 것이 상대적으로 더 좋은지 비교하는 것이 훨씬 효과적입니다.

전통적인 PPO(Proximal Policy Optimization) 알고리즘은 개별 응답에 대한 절대적 보상만을 사용합니다. 하지만 실제로는 "이 답변이 다른 답변들보다 얼마나 나은가?"라는 상대적 비교가 더 중요합니다.

또한 PPO는 Critic 네트워크라는 추가 모델이 필요해 메모리와 계산 비용이 두 배로 듭니다. 바로 이럴 때 필요한 것이 GRPO(Group Relative Policy Optimization)입니다.

GRPO는 같은 프롬프트에서 생성된 여러 응답들을 그룹으로 묶어 상대적으로 비교하며, Critic 없이도 효율적인 학습이 가능합니다.

개요

간단히 말해서, GRPO는 동일한 입력에 대해 생성된 여러 응답들을 그룹으로 묶어, 그룹 내에서의 상대적 성능을 기반으로 정책을 최적화하는 알고리즘입니다. 왜 GRPO가 필요한지 실무 관점에서 설명하면, 대규모 언어모델(LLM)을 강화학습으로 파인튜닝할 때 메모리와 계산 자원이 매우 제한적입니다.

PPO는 Actor(정책 모델)와 Critic(가치 함수 모델) 두 개의 대형 모델을 동시에 학습해야 하지만, GRPO는 Actor만 있으면 됩니다. 예를 들어, 7B 파라미터 모델을 학습할 때 PPO는 14B의 메모리가 필요하지만 GRPO는 7B만 필요합니다.

기존 PPO에서는 Critic이 각 응답의 절대적 가치를 예측했다면, GRPO는 같은 프롬프트에서 생성된 응답들의 평균 보상을 베이스라인으로 사용합니다. 이를 통해 "이 응답이 평균보다 얼마나 좋은가?"를 자동으로 계산합니다.

GRPO의 핵심 특징은 크게 세 가지입니다: (1) 그룹 단위 샘플링으로 상대적 비교 가능, (2) Critic-free 구조로 메모리 효율성 극대화, (3) KL divergence 제약으로 안정적 학습. 이러한 특징들이 실제 ChatGPT 같은 대규모 모델 학습에서 핵심적인 역할을 합니다.

코드 예제

import torch
import torch.nn.functional as F

def compute_grpo_loss(log_probs, old_log_probs, rewards, group_size=4):
    # 그룹별로 보상을 재구성 (batch를 group_size로 나눔)
    batch_size = rewards.shape[0]
    rewards_grouped = rewards.view(-1, group_size)

    # 각 그룹의 평균 보상을 베이스라인으로 사용
    baseline = rewards_grouped.mean(dim=1, keepdim=True)
    advantages = rewards_grouped - baseline  # 상대적 이점 계산
    advantages = advantages.view(-1)  # 다시 펼침

    # Policy ratio 계산 (현재 정책 / 이전 정책)
    ratio = torch.exp(log_probs - old_log_probs)

    # GRPO loss: ratio * advantages (KL penalty 제외 버전)
    loss = -(ratio * advantages).mean()

    return loss

설명

이 코드가 하는 일은 GRPO의 핵심인 그룹 기반 상대적 이점(advantage) 계산과 정책 손실 함수를 구현하는 것입니다. 전체적으로 같은 질문에 대한 여러 답변들을 비교하여 "평균보다 좋은 답변"을 강화하고 "평균보다 나쁜 답변"을 억제하는 방식으로 동작합니다.

첫 번째로, rewards.view(-1, group_size)를 통해 배치를 그룹으로 재구성합니다. 예를 들어 배치 크기가 16이고 group_size가 4라면, 4개씩 묶어 4개의 그룹을 만듭니다.

각 그룹은 동일한 프롬프트에서 생성된 응답들입니다. 이렇게 그룹화하는 이유는 같은 입력에 대한 출력들을 공정하게 비교하기 위함입니다.

그 다음으로, rewards_grouped.mean(dim=1)로 각 그룹의 평균 보상을 계산하고, 이를 베이스라인으로 사용해 advantages = rewards_grouped - baseline을 계산합니다. 이 부분이 GRPO의 핵심입니다.

예를 들어 그룹 내 보상이 [0.8, 0.5, 0.7, 0.6]이면 평균은 0.65이고, advantages는 [0.15, -0.15, 0.05, -0.05]가 됩니다. 이는 "평균보다 좋은 정도"를 나타냅니다.

세 번째로, ratio = torch.exp(log_probs - old_log_probs)로 정책 비율을 계산합니다. 이는 "현재 정책이 이전 정책 대비 이 응답을 생성할 확률이 얼마나 변했는가"를 의미합니다.

ratio가 1보다 크면 현재 정책이 더 선호하는 응답이고, 작으면 덜 선호하는 응답입니다. 마지막으로, -(ratio * advantages).mean()으로 최종 손실을 계산합니다.

advantages가 양수인(평균보다 좋은) 응답은 ratio를 키워서 더 자주 생성되도록 하고, advantages가 음수인(평균보다 나쁜) 응답은 ratio를 줄여서 덜 생성되도록 합니다. 음수를 붙이는 이유는 PyTorch가 손실을 최소화하기 때문입니다.

여러분이 이 코드를 사용하면 (1) Critic 모델 없이 강화학습 가능, (2) 메모리 사용량 50% 절감, (3) 상대적 비교로 더 안정적인 학습을 얻을 수 있습니다. 실무에서는 이를 기반으로 KL divergence 제약과 클리핑을 추가하여 더욱 안정적인 학습을 구현합니다.

실전 팁

💡 group_size는 보통 4~8 사이로 설정합니다. 너무 작으면 상대적 비교의 의미가 약해지고, 너무 크면 메모리가 부족할 수 있습니다. 실험적으로 4가 가장 안정적입니다.

💡 실제 구현에서는 advantages를 정규화(normalize)하는 것이 중요합니다. advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)을 추가하면 학습이 훨씬 안정적입니다.

💡 log_probs는 시퀀스 전체의 로그 확률을 합한 값이어야 합니다. 각 토큰의 log_prob을 합산하는 것을 잊지 마세요: log_probs = log_probs_per_token.sum(dim=-1)

💡 old_log_probs는 반드시 .detach()를 사용해 그래디언트가 흐르지 않도록 해야 합니다. 그렇지 않으면 정책 비율 계산이 의미가 없어집니다.

💡 배치를 구성할 때 같은 프롬프트에서 생성된 응답들이 연속되도록 배치를 구성해야 합니다. 예: [prompt1_resp1, prompt1_resp2, ..., prompt2_resp1, prompt2_resp2, ...]


2. Advantage 계산 - 상대적 이점의 핵심 메커니즘

시작하며

여러분이 강화학습 모델을 학습시킬 때 "이 행동이 얼마나 좋은가?"를 어떻게 판단하시나요? 단순히 보상 점수만 보면 절대적 기준이 없어 혼란스럽습니다.

예를 들어 보상 0.7이 좋은 건지 나쁜 건지 알 수 없습니다. 이 문제는 베이스라인(기준점)이 없기 때문에 발생합니다.

전통적인 Actor-Critic 방법에서는 Critic 네트워크가 이 베이스라인을 학습하지만, 이는 추가 모델이 필요하고 학습이 불안정할 수 있습니다. 또한 Critic이 잘못된 가치를 예측하면 전체 학습이 망가질 수 있습니다.

바로 이럴 때 GRPO의 그룹 기반 advantage 계산이 필요합니다. 같은 상황에서 생성된 여러 응답들의 평균을 베이스라인으로 사용하면, 추가 모델 없이도 "평균보다 좋은지 나쁜지"를 명확히 알 수 있습니다.

개요

간단히 말해서, advantage는 "특정 행동이 평균적인 행동보다 얼마나 나은가"를 수치화한 값입니다. GRPO에서는 그룹 평균 보상을 베이스라인으로 사용합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 강화학습에서 보상의 절대값은 스케일이 일정하지 않고 변동이 심합니다. 예를 들어, 어떤 배치에서는 모든 보상이 0.80.9 사이이고, 다른 배치에서는 0.10.3 사이일 수 있습니다.

이런 상황에서 절대값으로 학습하면 그래디언트가 불안정합니다. advantage를 사용하면 항상 "평균 대비 상대적 위치"로 정규화되어 안정적입니다.

기존 Actor-Critic에서는 V(s) = E[R]을 학습하는 별도의 네트워크로 베이스라인을 계산했다면, GRPO는 같은 프롬프트의 응답들로부터 직접 평균을 계산합니다. 이는 Monte Carlo 방식의 추정이며, 추가 학습 없이 정확한 베이스라인을 제공합니다.

Advantage 계산의 핵심 특징은: (1) 분산 감소(variance reduction)로 학습 안정화, (2) 스케일 정규화로 다양한 보상 분포 처리, (3) 그룹 내 상대적 순위 반영. 이러한 특징들이 대규모 언어모델의 안정적 파인튜닝을 가능하게 합니다.

코드 예제

import torch

def compute_advantages_with_normalization(rewards, group_size=4):
    """그룹 기반 advantage 계산 (정규화 포함)"""
    # 보상을 그룹으로 재구성
    batch_size = rewards.shape[0]
    num_groups = batch_size // group_size
    rewards_grouped = rewards.view(num_groups, group_size)

    # 각 그룹의 평균과 표준편차 계산
    group_mean = rewards_grouped.mean(dim=1, keepdim=True)
    group_std = rewards_grouped.std(dim=1, keepdim=True) + 1e-8

    # Advantage 계산: (보상 - 평균) / 표준편차
    advantages = (rewards_grouped - group_mean) / group_std

    # 원래 shape으로 복원
    advantages = advantages.view(batch_size)

    return advantages, group_mean.view(num_groups)

# 사용 예시
rewards = torch.tensor([0.8, 0.5, 0.7, 0.6, 0.3, 0.4, 0.2, 0.5])
advantages, baselines = compute_advantages_with_normalization(rewards, group_size=4)
print(f"Advantages: {advantages}")
print(f"Baselines: {baselines}")

설명

이 코드가 하는 일은 GRPO의 핵심인 그룹 기반 advantage를 계산하되, 단순 차이가 아닌 표준화된(normalized) 값을 제공하는 것입니다. 이는 통계학의 z-score 개념과 유사하며, "평균으로부터 몇 표준편차 떨어져 있는가"를 의미합니다.

첫 번째로, rewards.view(num_groups, group_size)로 배치를 그룹으로 재구성합니다. 예를 들어 8개의 보상을 4개씩 2그룹으로 나눕니다.

이때 중요한 점은 데이터 순서가 올바르게 정렬되어 있어야 한다는 것입니다. 같은 프롬프트에서 생성된 응답들이 연속적으로 배치되어야 합니다.

그 다음으로, group_meangroup_std를 계산합니다. group_mean은 각 그룹의 평균 성능을 나타내고, group_std는 그룹 내 보상의 분산 정도를 나타냅니다.

+ 1e-8은 표준편차가 0이 되는 것을 방지하는 수치적 안정화 기법입니다. 만약 그룹 내 모든 보상이 동일하면 표준편차가 0이 되어 division by zero 에러가 발생하기 때문입니다.

세 번째로, (rewards_grouped - group_mean) / group_std로 정규화된 advantage를 계산합니다. 이는 각 보상이 "평균으로부터 몇 표준편차 떨어져 있는가"를 의미합니다.

예를 들어 advantage가 1.5라면 "평균보다 1.5 표준편차 더 좋다"는 뜻이고, -0.5라면 "평균보다 0.5 표준편차 나쁘다"는 뜻입니다. 이 정규화 덕분에 서로 다른 스케일의 보상들을 공정하게 비교할 수 있습니다.

마지막으로, advantages.view(batch_size)로 원래 배치 형태로 복원합니다. 또한 baselines(그룹 평균)도 함께 반환하는데, 이는 디버깅이나 로깅 목적으로 유용합니다.

예를 들어 특정 프롬프트에서 평균 보상이 너무 낮다면, 해당 프롬프트나 보상 함수에 문제가 있을 수 있다는 신호입니다. 여러분이 이 코드를 사용하면 (1) 보상 스케일에 무관한 안정적 학습, (2) 분산 감소로 빠른 수렴, (3) 그룹 내 상대적 순위를 명확히 반영한 학습을 얻을 수 있습니다.

실무에서는 이를 통해 학습률을 더 크게 설정할 수 있어 전체 학습 시간을 단축할 수 있습니다.

실전 팁

💡 표준편차 정규화는 선택사항이지만 강력히 권장됩니다. 정규화 없이는 보상 스케일 변화에 민감해 학습률 튜닝이 매우 어렵습니다.

💡 group_size가 너무 작으면(2 이하) 표준편차 추정이 불안정합니다. 최소 4 이상을 사용하세요. 통계적으로 샘플이 많을수록 추정이 정확합니다.

💡 디버깅 시 baselines의 평균과 분산을 로깅하세요. 만약 baselines가 계속 감소한다면 모델이 전반적으로 나빠지고 있다는 신호이고, 증가한다면 개선되고 있다는 신호입니다.

💡 보상 함수가 특정 범위로 제한되어 있다면(예: 01), advantage도 대략 -33 범위 내에 있어야 정상입니다. 그 범위를 벗어나면 보상 함수나 그룹 구성에 문제가 있을 수 있습니다.

💡 실제 구현에서는 torch.no_grad() 컨텍스트 내에서 advantage를 계산하는 것이 좋습니다. advantage 계산 자체는 그래디언트를 필요로 하지 않으므로 메모리를 절약할 수 있습니다.


3. KL Divergence 제약 - 안정적 정책 업데이트의 핵심

시작하며

여러분이 강화학습으로 언어모델을 파인튜닝할 때, 한 번의 업데이트로 모델이 완전히 망가진 경험이 있나요? 예를 들어, 갑자기 의미 없는 문장을 반복하거나 학습 전보다 훨씬 못한 응답을 생성하는 경우입니다.

이 문제는 정책이 너무 급격하게 변할 때 발생합니다. 강화학습에서는 높은 보상을 받은 행동을 과도하게 강화하다가 exploration-exploitation 균형이 무너지고, 모델이 특정 패턴에 과최적화됩니다.

특히 언어모델은 사전학습으로 획득한 언어 능력이 중요한데, 이를 잃어버리면 복구가 어렵습니다. 바로 이럴 때 필요한 것이 KL divergence 제약입니다.

이는 "새로운 정책이 이전 정책과 얼마나 다른지"를 측정하고, 그 차이를 일정 범위 내로 제한하여 안정적인 학습을 보장합니다.

개요

간단히 말해서, KL divergence는 두 확률 분포가 얼마나 다른지를 측정하는 지표입니다. GRPO에서는 업데이트 전후 정책의 차이를 제한하는 정규화 항으로 사용됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 대규모 언어모델은 수십억 개의 파라미터를 가지며 사전학습에 막대한 비용이 들어갑니다. 강화학습 파인튜닝 과정에서 이 사전학습 지식을 보존하는 것이 매우 중요합니다.

예를 들어, "안전한 대화"를 학습시키려다가 모델이 문법 자체를 잊어버리면 안 되겠죠. KL 제약은 이런 파괴적 업데이트를 방지합니다.

기존 supervised learning에서는 데이터가 고정되어 있어 안정적이지만, 강화학습에서는 정책이 변하면 데이터 분포도 변합니다(on-policy learning). KL 제약 없이는 이 분포 변화가 너무 급격해져 학습이 발산할 수 있습니다.

KL 제약을 사용하면 "조금씩 개선"하는 보수적 업데이트가 가능합니다. KL divergence 제약의 핵심 특징은: (1) 정책 변화량을 명시적으로 제어, (2) 사전학습 지식 보존, (3) 학습 안정성 보장.

이러한 특징들이 RLHF(Reinforcement Learning from Human Feedback)의 성공에 핵심적 역할을 했습니다.

코드 예제

import torch
import torch.nn.functional as F

def compute_kl_penalty(log_probs, ref_log_probs, kl_coef=0.1):
    """KL divergence 페널티 계산"""
    # KL(π || π_ref) = E[log π - log π_ref]
    # 각 토큰별 KL divergence 계산
    kl_per_token = log_probs - ref_log_probs

    # 시퀀스 전체의 평균 KL divergence
    kl_divergence = kl_per_token.mean()

    # KL 페널티 (보상에서 빼줌)
    kl_penalty = kl_coef * kl_divergence

    return kl_penalty, kl_divergence

def compute_grpo_loss_with_kl(log_probs, old_log_probs, ref_log_probs,
                                rewards, group_size=4, kl_coef=0.1):
    """KL 페널티를 포함한 GRPO loss"""
    # Advantage 계산
    rewards_grouped = rewards.view(-1, group_size)
    baseline = rewards_grouped.mean(dim=1, keepdim=True)
    advantages = (rewards_grouped - baseline).view(-1)

    # Policy ratio
    ratio = torch.exp(log_probs - old_log_probs)

    # Policy loss
    policy_loss = -(ratio * advantages).mean()

    # KL penalty
    kl_penalty, kl_div = compute_kl_penalty(log_probs, ref_log_probs, kl_coef)

    # Total loss = policy loss + KL penalty
    total_loss = policy_loss + kl_penalty

    return total_loss, policy_loss, kl_div

설명

이 코드가 하는 일은 GRPO의 안정성을 보장하는 KL divergence 제약을 구현하는 것입니다. 전체적으로 "새로운 정책이 참조 정책과 너무 멀어지지 않도록" 제한하는 역할을 합니다.

첫 번째로, kl_per_token = log_probs - ref_log_probs로 각 토큰별 KL divergence를 계산합니다. 수학적으로 KL(P||Q) = E[log P - log Q]이며, 여기서 P는 현재 정책(학습 중인 모델), Q는 참조 정책(사전학습 모델 또는 이전 체크포인트)입니다.

log_probs가 크고 ref_log_probs가 작다면 "현재 정책이 선호하지만 참조 정책은 선호하지 않는" 토큰이므로, 큰 양의 KL 값을 가집니다. 이는 큰 변화를 의미합니다.

그 다음으로, kl_per_token.mean()으로 전체 시퀀스의 평균 KL divergence를 계산합니다. 이는 정책 변화의 전체적 크기를 나타냅니다.

실무에서는 이 값을 모니터링하는 것이 매우 중요합니다. 일반적으로 KL divergence가 0.01~0.1 범위일 때 안정적이고, 1.0을 넘으면 너무 큰 변화로 간주됩니다.

세 번째로, kl_coef * kl_divergence로 KL 페널티를 계산합니다. kl_coef는 하이퍼파라미터로, "KL 제약을 얼마나 강하게 적용할 것인가"를 결정합니다.

보통 0.01~0.2 사이 값을 사용합니다. kl_coef가 크면 정책 변화가 매우 보수적이 되어 학습이 느리지만 안정적이고, 작으면 빠르게 학습하지만 불안정할 수 있습니다.

마지막으로, policy_loss + kl_penalty로 최종 손실을 계산합니다. 이는 "보상 최대화"와 "정책 보존" 사이의 균형을 자동으로 맞춥니다.

모델은 보상을 높이려 하지만, KL 페널티가 너무 커지면 손실이 증가하므로 자연스럽게 참조 정책 근처에 머물게 됩니다. 이는 trust region 최적화의 한 형태입니다.

여러분이 이 코드를 사용하면 (1) 파괴적 업데이트 방지로 안정적 학습, (2) 사전학습 지식 보존, (3) 하이퍼파라미터 kl_coef로 유연한 제어를 얻을 수 있습니다. 실무에서는 초반에는 kl_coef를 크게 설정하여 안정성을 확보하고, 후반에는 줄여서 성능을 끌어올리는 curriculum learning 전략을 사용하기도 합니다.

실전 팁

💡 kl_coef는 학습 과정에서 가장 중요한 하이퍼파라미터입니다. 0.05부터 시작해서 학습이 너무 느리면 줄이고, 불안정하면 늘리세요. 경험적으로 0.02~0.1이 많이 사용됩니다.

💡 KL divergence 값을 반드시 로깅하고 모니터링하세요. 갑자기 KL이 급증하면 학습이 불안정해지는 신호입니다. TensorBoard나 WandB에 실시간으로 추적하세요.

💡 ref_log_probs는 반드시 with torch.no_grad()로 계산하고 .detach()를 사용하세요. 참조 정책은 고정되어야 하며, 그래디언트가 흐르면 안 됩니다.

💡 실무에서는 adaptive KL coefficient를 사용하기도 합니다. KL이 목표값(예: 0.01)보다 크면 kl_coef를 증가시키고, 작으면 감소시키는 방식입니다. 이는 PID controller처럼 동작합니다.

💡 토큰별 KL을 시각화하면 어떤 토큰에서 정책 변화가 큰지 알 수 있습니다. 특정 토큰(예: "yes", "no")에서만 KL이 크다면, 보상 함수가 특정 답변을 과도하게 선호한다는 신호일 수 있습니다.


4. 온라인 샘플링 - 효율적인 데이터 생성 전략

시작하며

여러분이 강화학습으로 대화 모델을 학습시킬 때, 어떻게 학습 데이터를 구성하시나요? 미리 생성된 고정 데이터셋을 사용하면 간단하지만, 모델이 개선되어도 계속 같은 데이터로만 학습하게 됩니다.

이는 마치 운동 능력이 향상되었는데도 같은 초급 운동만 반복하는 것과 같습니다. 이 문제는 off-policy learning의 한계입니다.

고정된 데이터는 이전 버전의 정책으로 생성되었기 때문에, 현재 정책의 강점과 약점을 반영하지 못합니다. 또한 모델이 발전하면서 새로운 유형의 실수를 하게 되는데, 오래된 데이터는 이를 커버하지 못합니다.

바로 이럴 때 필요한 것이 온라인 샘플링(online sampling)입니다. 이는 현재 정책으로 매 학습 스텝마다 새로운 응답을 생성하고, 그 응답에 대해 즉시 학습하는 방식으로, 항상 최신 정책의 행동을 개선할 수 있습니다.

개요

간단히 말해서, 온라인 샘플링은 학습 중인 모델(현재 정책)을 사용해 매번 새로운 응답을 생성하고, 그 응답으로부터 즉시 학습하는 on-policy 강화학습 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, ChatGPT 같은 대화 모델은 매우 광범위한 대화 상황을 다뤄야 합니다.

고정된 데이터셋은 학습 초반에는 유용하지만, 모델이 발전하면서 "현재 모델이 자주 하는 실수"와 "개선이 필요한 부분"이 계속 바뀝니다. 예를 들어, 초반에는 문법 오류를 많이 하다가, 나중에는 논리적 일관성 문제가 생기는 식입니다.

온라인 샘플링은 현재 단계의 문제에 집중할 수 있게 해줍니다. 기존 supervised learning이나 off-policy RL에서는 데이터 수집과 학습이 분리되어 있다면, 온라인 샘플링에서는 이 둘이 동시에 진행됩니다.

이는 "생성 → 평가 → 학습 → 개선된 모델로 다시 생성"의 선순환을 만듭니다. 온라인 샘플링의 핵심 특징은: (1) 현재 정책의 행동 분포로 학습, (2) 지속적인 탐색(exploration) 가능, (3) 데이터 효율성 극대화.

이러한 특징들이 GRPO를 실시간 학습 알고리즘으로 만들어줍니다.

코드 예제

import torch

def online_sampling_step(model, prompts, group_size=4, temperature=1.0):
    """온라인 샘플링: 현재 모델로 응답 생성"""
    model.eval()  # 생성 시에는 eval 모드
    all_responses = []
    all_log_probs = []

    with torch.no_grad():  # 생성 시에는 그래디언트 불필요
        for prompt in prompts:
            # 각 프롬프트에 대해 group_size개의 응답 생성
            responses_for_prompt = []
            log_probs_for_prompt = []

            for _ in range(group_size):
                # 샘플링으로 응답 생성 (탐색 포함)
                output = model.generate(
                    prompt,
                    do_sample=True,  # 확률적 샘플링
                    temperature=temperature,  # 다양성 조절
                    max_length=256
                )
                response = output.sequences
                log_prob = output.log_probs.sum()  # 시퀀스 전체 로그 확률

                responses_for_prompt.append(response)
                log_probs_for_prompt.append(log_prob)

            all_responses.extend(responses_for_prompt)
            all_log_probs.extend(log_probs_for_prompt)

    return all_responses, torch.tensor(all_log_probs)

# 사용 예시
# responses, log_probs = online_sampling_step(model, prompts, group_size=4)
# rewards = reward_model(prompts, responses)  # 보상 계산
# # 이후 GRPO loss 계산 및 학습

설명

이 코드가 하는 일은 GRPO의 핵심인 온라인 샘플링 과정을 구현하는 것입니다. 전체적으로 현재 정책으로 다양한 응답을 생성하고, 나중에 이를 평가하여 학습하는 데이터 수집 파이프라인입니다.

첫 번째로, model.eval()로 모델을 평가 모드로 설정합니다. 생성 과정에서는 dropout이나 batch normalization을 일관되게 적용해야 하기 때문입니다.

또한 torch.no_grad()로 그래디언트 계산을 비활성화하여 메모리를 절약합니다. 생성 과정은 추론일 뿐이고, 나중에 생성된 응답으로 학습할 때만 그래디언트가 필요합니다.

그 다음으로, 각 프롬프트에 대해 group_size번 반복하며 응답을 생성합니다. 여기서 핵심은 do_sample=True입니다.

이는 greedy decoding이 아닌 확률적 샘플링을 의미합니다. 예를 들어 다음 토큰 확률이 [0.6, 0.3, 0.1]이라면, 항상 첫 번째를 선택하는 것이 아니라 60%, 30%, 10% 확률로 무작위 선택합니다.

이렇게 해야 다양한 응답이 생성되어 그룹 내 비교가 의미 있어집니다. 세 번째로, temperature 파라미터로 생성 다양성을 조절합니다.

temperature가 1.0이면 모델의 원래 확률 분포를 그대로 사용하고, 1.0보다 크면(예: 1.5) 더 다양하고 창의적인 응답이 생성되며, 1.0보다 작으면(예: 0.7) 더 보수적이고 확신 있는 응답이 생성됩니다. GRPO에서는 보통 0.7~1.0을 사용하여 적절한 다양성을 유지합니다.

마지막으로, log_prob.sum()으로 시퀀스 전체의 로그 확률을 계산합니다. 이는 나중에 policy ratio와 KL divergence 계산에 사용됩니다.

중요한 점은 이 log_prob을 저장해두어야 나중에 old_log_probs로 사용할 수 있다는 것입니다. 응답을 생성한 직후의 모델 상태가 "old policy"가 되고, 학습 후의 모델이 "new policy"가 됩니다.

여러분이 이 코드를 사용하면 (1) 현재 정책의 약점에 집중한 학습, (2) 지속적인 탐색으로 성능 향상, (3) 데이터 효율성 극대화를 얻을 수 있습니다. 실무에서는 생성 속도를 높이기 위해 배치 단위로 여러 프롬프트를 동시에 처리하고, GPU를 효율적으로 사용합니다.

실전 팁

💡 온라인 샘플링은 계산 비용이 많이 듭니다. 학습 스텝마다 추론을 해야 하므로, GPU 활용률을 모니터링하고 배치 크기를 최적화하세요. 보통 생성과 학습을 번갈아 수행합니다.

💡 temperature는 학습 단계에 따라 조절하세요. 초반에는 높게(1.21.5) 설정해 다양한 응답을 탐색하고, 후반에는 낮게(0.70.9) 설정해 품질 높은 응답에 집중하세요.

💡 생성된 응답은 반드시 저장하거나 캐싱하세요. 같은 응답을 여러 번 사용할 수 있으므로(예: 여러 에폭), 매번 재생성하는 것은 비효율적입니다. 다만 너무 오래된 응답은 현재 정책과 맞지 않으므로 주기적으로 갱신하세요.

💡 group_size와 배치 크기를 혼동하지 마세요. group_size는 같은 프롬프트에서 생성할 응답 개수이고, 배치 크기는 동시에 처리할 프롬프트 개수입니다. 예: 배치 4, group_size 4 → 총 16개 응답 생성.

💡 디버깅 시 생성된 응답을 실제로 출력해보세요. 모델이 어떤 응답을 생성하는지 직접 보면 보상 함수나 학습 과정의 문제를 빠르게 발견할 수 있습니다. 특히 반복 문구나 무의미한 응답이 나오면 KL 제약이나 temperature를 조정해야 합니다.


5. 클리핑 기법 - PPO 스타일의 안정화 추가

시작하며

여러분이 GRPO로 학습할 때, advantage가 매우 큰 응답이 있으면 어떻게 될까요? 예를 들어 advantage가 10.0인 응답이 있다면, policy ratio가 조금만 증가해도 ratio * advantage가 엄청나게 커져서 그래디언트가 폭발할 수 있습니다.

이 문제는 이상치(outlier) 보상이 학습을 지배하면서 발생합니다. 운 좋게 높은 보상을 받은 하나의 응답이 전체 학습 방향을 왜곡시킬 수 있습니다.

또한 policy ratio가 너무 커지면 정책이 극단적으로 변하여 학습이 불안정해집니다. 바로 이럴 때 필요한 것이 PPO 스타일의 클리핑(clipping) 기법입니다.

이는 policy ratio를 일정 범위로 제한하여, 한 번에 정책이 너무 크게 변하는 것을 방지하고 안정적인 학습을 보장합니다.

개요

간단히 말해서, 클리핑은 policy ratio를 [1-ε, 1+ε] 범위(보통 ε=0.2)로 제한하여, 정책 업데이트의 크기를 명시적으로 제어하는 기법입니다. PPO 알고리즘의 핵심 아이디어입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 강화학습의 policy gradient는 본질적으로 불안정합니다. 특히 언어 생성처럼 action space가 매우 큰 경우, 작은 파라미터 변화가 확률 분포에 큰 영향을 미칠 수 있습니다.

예를 들어, 어떤 토큰의 확률이 0.01에서 0.1로 변하면 policy ratio가 10이 되어, 그래디언트가 매우 커집니다. 클리핑은 이런 극단적 업데이트를 차단합니다.

기존 vanilla policy gradient에서는 ratio에 제한이 없어 불안정했다면, PPO는 클리핑으로 안정성을 크게 개선했습니다. GRPO에서도 같은 기법을 적용하여 "그룹 기반 advantage + 클리핑"의 조합으로 더욱 안정적인 학습을 달성합니다.

클리핑 기법의 핵심 특징은: (1) policy ratio를 명시적으로 제한, (2) 보수적 업데이트로 안정성 보장, (3) 하이퍼파라미터 ε로 업데이트 폭 조절. 이러한 특징들이 대규모 언어모델의 강화학습을 실용적으로 만들었습니다.

코드 예제

import torch

def compute_clipped_grpo_loss(log_probs, old_log_probs, advantages,
                               clip_ratio=0.2):
    """클리핑이 적용된 GRPO loss (PPO 스타일)"""
    # Policy ratio 계산
    ratio = torch.exp(log_probs - old_log_probs)

    # Clipped ratio 계산 (1-ε, 1+ε 범위로 제한)
    ratio_clipped = torch.clamp(ratio, 1 - clip_ratio, 1 + clip_ratio)

    # 두 가지 loss 계산
    loss_unclipped = ratio * advantages
    loss_clipped = ratio_clipped * advantages

    # 두 loss 중 더 보수적인(작은) 것을 선택
    # advantage > 0: 더 작은 값 선택 (과도한 증가 방지)
    # advantage < 0: 더 큰 값 선택 (과도한 감소 방지)
    loss = -torch.min(loss_unclipped, loss_clipped).mean()

    # 클리핑 비율 추적 (디버깅용)
    clipping_fraction = (ratio != ratio_clipped).float().mean()

    return loss, clipping_fraction

# 전체 GRPO 학습 스텝 (클리핑 포함)
def grpo_training_step(model, responses, rewards, old_log_probs,
                        group_size=4, clip_ratio=0.2):
    """클리핑이 적용된 완전한 GRPO 학습 스텝"""
    model.train()

    # 현재 모델로 log_probs 재계산
    log_probs = model.compute_log_probs(responses)

    # Advantage 계산
    advantages = compute_advantages_with_normalization(rewards, group_size)[0]

    # Clipped loss 계산
    loss, clip_frac = compute_clipped_grpo_loss(
        log_probs, old_log_probs, advantages, clip_ratio
    )

    return loss, clip_frac

설명

이 코드가 하는 일은 PPO의 핵심 아이디어인 클리핑을 GRPO에 적용하는 것입니다. 전체적으로 policy ratio가 너무 커지거나 작아지는 것을 막아, "조금씩 안전하게" 정책을 개선합니다.

첫 번째로, ratio = torch.exp(log_probs - old_log_probs)로 policy ratio를 계산합니다. 이는 "새로운 정책이 이 응답을 생성할 확률 / 이전 정책이 생성할 확률"을 의미합니다.

ratio가 2.0이면 새 정책이 이 응답을 2배 더 선호한다는 뜻이고, 0.5면 절반만 선호한다는 뜻입니다. 그 다음으로, torch.clamp(ratio, 1-clip_ratio, 1+clip_ratio)로 ratio를 제한합니다.

기본적으로 clip_ratio=0.2이므로 [0.8, 1.2] 범위로 제한됩니다. 예를 들어 ratio가 3.0이었다면 1.2로, 0.3이었다면 0.8로 잘립니다.

이는 "정책 변화는 한 번에 최대 20%까지만"이라는 규칙을 강제합니다. 세 번째로, torch.min(loss_unclipped, loss_clipped)로 두 loss 중 더 보수적인 것을 선택합니다.

이 부분이 PPO의 핵심입니다. advantage가 양수일 때(좋은 응답): loss_unclipped는 ratio가 클수록 더 음수가 되어 큰 업데이트를 유도하지만, loss_clipped는 ratio를 1.2로 제한하여 과도한 증가를 막습니다.

advantage가 음수일 때(나쁜 응답): 반대로 과도한 감소를 막습니다. 결과적으로 항상 더 안전한 방향으로 학습합니다.

마지막으로, clipping_fraction을 계산하여 얼마나 많은 샘플이 클리핑되었는지 추적합니다. 이는 중요한 디버깅 정보입니다.

clipping_fraction이 0에 가까우면 정책 변화가 작아 clip_ratio를 줄여도 되고, 0.5 이상이면 정책이 너무 급격히 변하고 있다는 신호이므로 학습률을 낮추거나 clip_ratio를 늘려야 합니다. 일반적으로 0.1~0.3 범위가 적절합니다.

여러분이 이 코드를 사용하면 (1) 극단적 업데이트 방지로 학습 안정화, (2) 이상치 보상의 영향 최소화, (3) clipping_fraction으로 학습 상태 모니터링을 얻을 수 있습니다. 실무에서는 클리핑과 KL 제약을 함께 사용하여 이중 안전장치를 구축합니다.

실전 팁

💡 clip_ratio는 보통 0.1~0.3 범위를 사용합니다. 0.2가 기본값이지만, 안정성이 중요하면 0.1, 빠른 학습이 필요하면 0.3을 시도해보세요. 보수적일수록 안정적이지만 느립니다.

💡 clipping_fraction을 반드시 로깅하고 모니터링하세요. 이 값이 너무 높으면(>0.5) 학습률을 낮추거나 KL 제약을 강화해야 합니다. 너무 낮으면(<0.05) clip_ratio가 너무 관대한 것이므로 줄여보세요.

💡 클리핑은 KL 제약과 상호보완적입니다. KL 제약은 전체적인 분포 변화를 제한하고, 클리핑은 개별 샘플에 대한 극단적 업데이트를 방지합니다. 둘 다 사용하는 것이 가장 안정적입니다.

💡 학습 초반에는 policy ratio가 크게 변하는 것이 정상입니다. 초반 10~20%의 스텝은 clipping_fraction이 높아도 괜찮으며, 이후 안정화되어야 합니다.

💡 ratio의 분포를 히스토그램으로 시각화하면 유용합니다. 대부분의 ratio가 1.0 근처에 있고, 일부만 클리핑되는 것이 이상적입니다. 만약 ratio가 bimodal(두 봉우리)이면 보상 함수나 데이터에 문제가 있을 수 있습니다.


6. 보상 모델 - 인간 선호도를 수치화하는 방법

시작하며

여러분이 ChatGPT에게 "좋은 답변"과 "나쁜 답변"을 구별하는 기준을 어떻게 가르칠 수 있을까요? "유용하다", "안전하다", "정확하다" 같은 추상적 개념을 단순히 코드로 작성하기는 거의 불가능합니다.

예를 들어, "공손하지만 도움이 안 되는 답변"과 "직설적이지만 매우 유용한 답변" 중 어느 것이 더 좋은지는 상황에 따라 다릅니다. 이 문제는 인간의 선호도가 복잡하고 미묘하기 때문에 발생합니다.

규칙 기반 평가는 단순한 기준만 다룰 수 있고, 실제 사람들이 선호하는 답변의 특성을 완전히 포착할 수 없습니다. 또한 같은 질문이라도 사용자의 의도에 따라 좋은 답변이 달라집니다.

바로 이럴 때 필요한 것이 보상 모델(Reward Model)입니다. 이는 인간의 선호도 비교 데이터로 학습된 신경망으로, "사람들이 이 답변을 얼마나 선호할지"를 점수로 예측합니다.

RLHF(Reinforcement Learning from Human Feedback)의 핵심 구성요소입니다.

개요

간단히 말해서, 보상 모델은 (질문, 답변) 쌍을 입력받아 해당 답변의 품질을 스칼라 점수로 출력하는 분류/회귀 모델입니다. 인간의 선호도 비교 데이터로 학습됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 강화학습에는 명확한 보상 신호가 필수적입니다. 게임은 승패나 점수가 명확하지만, 대화나 글쓰기 같은 개방형 태스크는 "정답"이 없습니다.

보상 모델은 수천~수만 개의 인간 평가를 학습하여 "인간이라면 어떻게 평가할지"를 근사합니다. 예를 들어, 인간 평가자가 "답변 A가 B보다 낫다"고 5,000번 비교한 데이터로 학습하면, 새로운 답변의 품질도 예측할 수 있습니다.

기존 규칙 기반 평가(예: BLEU, ROUGE)는 표면적 유사도만 측정했다면, 보상 모델은 의미적 품질, 유용성, 안전성 등 복합적 기준을 학습합니다. 이는 사람의 주관적 판단을 데이터 기반으로 객관화한 것입니다.

보상 모델의 핵심 특징은: (1) 인간 선호도를 스칼라 보상으로 변환, (2) Bradley-Terry 모델로 쌍대 비교 학습, (3) 대규모 언어모델 기반 아키텍처. 이러한 특징들이 ChatGPT의 "사람처럼 대화하는 능력"의 기반이 됩니다.

코드 예제

import torch
import torch.nn as nn

class RewardModel(nn.Module):
    """보상 모델: 언어모델 기반 + 스칼라 출력 헤드"""
    def __init__(self, base_model, hidden_size=768):
        super().__init__()
        self.base_model = base_model  # 사전학습된 LM (예: GPT)
        self.reward_head = nn.Linear(hidden_size, 1)  # 보상 점수 출력

    def forward(self, input_ids, attention_mask):
        # 언어모델로 임베딩 추출
        outputs = self.base_model(input_ids, attention_mask=attention_mask)
        last_hidden = outputs.last_hidden_state  # [batch, seq_len, hidden]

        # 마지막 토큰의 hidden state 사용 (문장 전체를 요약)
        # attention_mask로 실제 마지막 토큰 위치 찾기
        sequence_lengths = attention_mask.sum(dim=1) - 1
        last_token_hidden = last_hidden[
            torch.arange(len(sequence_lengths)), sequence_lengths
        ]

        # 스칼라 보상 점수 출력
        reward = self.reward_head(last_token_hidden).squeeze(-1)
        return reward

def compute_reward_loss(reward_model, prompt_ids, response1_ids, response2_ids,
                        labels, attention_mask1, attention_mask2):
    """Bradley-Terry 모델 기반 보상 학습"""
    # 두 응답의 보상 계산
    reward1 = reward_model(response1_ids, attention_mask1)
    reward2 = reward_model(response2_ids, attention_mask2)

    # Bradley-Terry loss: P(response1 > response2) = sigmoid(r1 - r2)
    # labels: 1이면 response1이 더 좋음, 0이면 response2가 더 좋음
    logits = reward1 - reward2
    loss = nn.BCEWithLogitsLoss()(logits, labels.float())

    return loss

설명

이 코드가 하는 일은 RLHF의 핵심인 보상 모델을 구현하는 것입니다. 전체적으로 언어모델의 이해력을 활용하여 "사람이 선호할 만한 답변"에 높은 점수를 부여하도록 학습합니다.

첫 번째로, self.base_model로 사전학습된 언어모델(예: GPT-2, LLaMA)을 사용합니다. 이 모델은 이미 언어의 의미를 이해하므로, 답변의 품질을 판단하는 기반이 됩니다.

중요한 점은 보상 모델도 언어모델만큼 큰 규모여야 한다는 것입니다. 7B 파라미터 정책 모델을 학습한다면, 보상 모델도 최소 7B 이상이어야 미묘한 품질 차이를 구별할 수 있습니다.

그 다음으로, self.reward_head = nn.Linear(hidden_size, 1)로 스칼라 출력 헤드를 추가합니다. 언어모델의 마지막 hidden state(문장 전체의 의미를 담은 벡터)를 입력받아 단일 숫자(보상 점수)를 출력합니다.

이 점수는 일반적으로 -10~10 범위의 실수이며, 높을수록 좋은 답변입니다. 절대값보다는 상대적 순서가 중요합니다.

세 번째로, attention_mask.sum(dim=1) - 1로 실제 마지막 토큰의 위치를 찾습니다. 패딩 때문에 시퀀스 길이가 다를 수 있으므로, attention_mask를 사용해 실제 텍스트의 끝을 식별합니다.

마지막 토큰의 hidden state는 전체 문장을 읽은 후의 요약 표현으로, 답변 전체의 품질을 평가하기에 적합합니다. 마지막으로, Bradley-Terry 손실 함수로 학습합니다.

이는 "두 답변을 비교했을 때 하나를 선호할 확률"을 모델링합니다. sigmoid(r1 - r2)는 "보상 차이가 클수록 선호 확률이 높다"를 의미합니다.

예를 들어 r1=5, r2=3이면 sigmoid(2)≈0.88로, response1을 88% 확률로 선호합니다. 이 방식은 인간의 쌍대 비교 데이터(A vs B 중 선택)를 직접 활용할 수 있어 데이터 수집이 효율적입니다.

여러분이 이 코드를 사용하면 (1) 인간 선호도를 자동으로 예측, (2) 대규모 강화학습에 필요한 보상 신호 제공, (3) 새로운 답변에 대한 즉각적인 평가를 얻을 수 있습니다. 실무에서는 보상 모델을 먼저 학습한 후, 이를 고정하고 정책 모델을 GRPO로 파인튜닝합니다.

실전 팁

💡 보상 모델 학습 시 데이터 품질이 매우 중요합니다. 인간 평가자들의 일관성이 낮으면(같은 비교를 다르게 평가) 보상 모델도 혼란스러워집니다. Inter-rater agreement를 측정하고 70% 이상 유지하세요.

💡 보상 모델의 과적합을 주의하세요. 학습 데이터의 특정 패턴(예: 긴 답변 선호)을 과도하게 학습하면, 정책 모델이 그 패턴을 exploit합니다. Validation set에서 정기적으로 평가하고 early stopping을 사용하세요.

💡 보상 정규화(reward normalization)를 적용하세요. 생성된 보상을 평균 0, 표준편차 1로 정규화하면 GRPO 학습이 더 안정적입니다: rewards = (rewards - rewards.mean()) / (rewards.std() + 1e-8)

💡 보상 모델은 정기적으로 재학습해야 합니다. 정책 모델이 발전하면서 새로운 유형의 응답을 생성하는데, 초기 보상 모델은 이를 제대로 평가하지 못할 수 있습니다. 이를 reward model drift라고 합니다.

💡 실무에서는 여러 보상 모델의 앙상블을 사용하기도 합니다. 유용성, 안전성, 정확성을 각각 평가하는 모델을 따로 학습하고, 가중 평균으로 최종 보상을 계산합니다: reward = 0.5*useful + 0.3*safe + 0.2*accurate


7. 미니배치 학습 - 효율적인 그래디언트 업데이트

시작하며

여러분이 수천 개의 응답을 생성했을 때, 이 모든 데이터를 한 번에 GPU 메모리에 올려 학습할 수 있을까요? 7B 파라미터 모델의 경우, 배치 크기 1000으로 학습하면 수백 GB의 메모리가 필요해 거의 불가능합니다.

메모리 부족으로 학습이 중단되는 OOM(Out Of Memory) 에러가 발생합니다. 이 문제는 강화학습의 특성 때문에 더욱 심각합니다.

온라인 샘플링으로 대량의 데이터를 한 번에 생성하지만, GPU 메모리는 한정되어 있습니다. 또한 전체 데이터를 한 번만 사용하는 것은 비효율적입니다.

생성 비용이 큰데 학습은 1번만 하는 것은 아깝습니다. 바로 이럴 때 필요한 것이 미니배치 학습(minibatch training)입니다.

대량의 데이터를 작은 청크로 나누어 여러 번 반복 학습하면, 메모리 효율적이면서도 데이터를 충분히 활용할 수 있습니다.

개요

간단히 말해서, 미니배치 학습은 전체 데이터셋을 작은 배치로 나누어 순차적으로 학습하고, 같은 데이터를 여러 에폭 동안 재사용하는 방식입니다. GRPO에서는 온라인 샘플링으로 생성한 데이터를 여러 번 학습합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 대규모 언어모델의 추론 비용은 매우 높습니다. 예를 들어 4096개의 응답을 생성하는 데 수 분이 걸릴 수 있는데, 이를 단 1번의 그래디언트 업데이트에만 사용하는 것은 비효율적입니다.

미니배치로 나누어 48 에폭 학습하면, 생성 비용 대비 학습 효율이 48배 증가합니다. 또한 작은 배치 크기는 그래디언트 추정의 노이즈를 줄이고 학습을 안정화합니다.

기존 supervised learning에서는 전체 데이터셋을 여러 에폭 학습하는 것이 당연하지만, 온라인 강화학습에서는 너무 많은 재사용이 오히려 문제가 될 수 있습니다. 데이터가 "old policy"로 생성되었는데, 너무 많이 학습하면 "new policy"와 괴리가 커지기 때문입니다.

따라서 적절한 에폭 수(보통 4~8)를 설정하는 것이 중요합니다. 미니배치 학습의 핵심 특징은: (1) 메모리 효율성으로 대규모 배치 처리 가능, (2) 데이터 재사용으로 샘플 효율성 증가, (3) 다중 에폭으로 안정적인 수렴.

이러한 특징들이 GRPO를 실용적으로 만들어줍니다.

코드 예제

import torch
from torch.utils.data import DataLoader, TensorDataset

def grpo_minibatch_training(model, responses, rewards, old_log_probs,
                             ref_log_probs, batch_size=128, num_epochs=4,
                             group_size=4, clip_ratio=0.2, kl_coef=0.1):
    """미니배치를 사용한 GRPO 학습 루프"""
    # 데이터셋 구성
    dataset = TensorDataset(responses, rewards, old_log_probs, ref_log_probs)
    dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-5)

    # 여러 에폭 학습
    for epoch in range(num_epochs):
        total_loss = 0
        total_kl = 0
        total_clip_frac = 0

        for batch_idx, batch in enumerate(dataloader):
            batch_responses, batch_rewards, batch_old_logp, batch_ref_logp = batch

            # 현재 모델로 log_probs 재계산
            model.train()
            batch_log_probs = model.compute_log_probs(batch_responses)

            # Advantages 계산
            advantages, _ = compute_advantages_with_normalization(
                batch_rewards, group_size
            )

            # Clipped GRPO loss
            ratio = torch.exp(batch_log_probs - batch_old_logp)
            ratio_clipped = torch.clamp(ratio, 1-clip_ratio, 1+clip_ratio)
            loss_policy = -torch.min(
                ratio * advantages,
                ratio_clipped * advantages
            ).mean()

            # KL penalty
            kl_div = (batch_log_probs - batch_ref_logp).mean()
            loss_kl = kl_coef * kl_div

            # Total loss
            loss = loss_policy + loss_kl

            # Backward and update
            optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()

            # Logging
            total_loss += loss.item()
            total_kl += kl_div.item()
            clip_frac = (ratio != ratio_clipped).float().mean().item()
            total_clip_frac += clip_frac

        # 에폭 통계 출력
        num_batches = len(dataloader)
        print(f"Epoch {epoch+1}/{num_epochs}: "
              f"Loss={total_loss/num_batches:.4f}, "
              f"KL={total_kl/num_batches:.4f}, "
              f"ClipFrac={total_clip_frac/num_batches:.3f}")

설명

이 코드가 하는 일은 GRPO의 실제 학습 루프를 구현하는 것입니다. 전체적으로 온라인 샘플링으로 생성된 대량의 데이터를 효율적으로 학습하는 파이프라인입니다.

첫 번째로, TensorDatasetDataLoader로 데이터를 미니배치로 나눕니다. shuffle=True는 매 에폭마다 데이터 순서를 섞어, 같은 그룹의 응답들이 항상 같은 배치에 들어가지 않도록 합니다.

이는 과적합을 방지하고 일반화 성능을 높입니다. batch_size는 GPU 메모리에 따라 조절하며, 보통 32~256 사이 값을 사용합니다.

그 다음으로, num_epochs 동안 전체 데이터를 반복 학습합니다. 여기서 중요한 점은 적절한 에폭 수 선택입니다.

너무 적으면(12) 데이터를 충분히 활용하지 못하고, 너무 많으면(10+) old_log_probs와 현재 policy의 괴리가 커져 policy ratio가 부정확해집니다. 실무에서는 48 에폭이 일반적이며, KL divergence를 모니터링하여 너무 커지면 조기 종료합니다.

세 번째로, 각 미니배치마다 model.compute_log_probs로 현재 정책의 log_probs를 재계산합니다. 이는 매우 중요한 부분입니다.

에폭이 진행되면서 모델이 업데이트되므로, 같은 응답이라도 log_probs가 변합니다. old_log_probs는 고정되어 있고(생성 시점의 정책), 현재 log_probs는 계속 변하므로, policy ratio가 에폭마다 달라집니다.

이것이 PPO/GRPO가 on-policy와 off-policy의 중간인 이유입니다. 네 번째로, torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)으로 그래디언트 클리핑을 적용합니다.

이는 policy ratio 클리핑과는 다른 개념으로, 그래디언트 벡터의 L2 norm을 제한합니다. 언어모델은 그래디언트가 폭발하기 쉬우므로, max_norm=0.5~1.0으로 제한하는 것이 일반적입니다.

이는 학습 안정성을 크게 개선합니다. 마지막으로, 각 에폭의 통계를 출력하여 학습 상태를 모니터링합니다.

Loss는 감소해야 하고, KL은 점진적으로 증가하되 너무 커지면 안 됩니다(보통 0.010.1). ClipFrac은 0.10.3 범위가 적절하며, 너무 높으면 학습률을 낮춰야 합니다.

이 지표들을 실시간으로 추적하면 문제를 조기에 발견할 수 있습니다. 여러분이 이 코드를 사용하면 (1) 대규모 데이터를 메모리 효율적으로 학습, (2) 샘플 효율성 4~8배 향상, (3) 안정적이고 추적 가능한 학습 과정을 얻을 수 있습니다.

실무에서는 이를 분산 학습(multi-GPU)과 결합하여 더욱 확장합니다.

실전 팁

💡 batch_size는 GPU 메모리에 맞춰 최대한 크게 설정하세요. 큰 배치는 그래디언트 추정을 안정화하고 학습 속도를 높입니다. 다만 메모리가 부족하면 gradient accumulation을 사용하세요.

💡 num_epochs는 KL divergence를 보고 동적으로 조절하세요. 매 에폭마다 전체 데이터의 평균 KL을 계산하고, 0.02를 넘으면 더 이상 학습하지 않고 새로운 데이터를 생성하는 것이 안전합니다.

💡 shuffle=True는 필수입니다. 같은 순서로 학습하면 그룹 구조가 고정되어 과적합될 수 있습니다. 매 에폭마다 다른 순서로 학습하는 것이 일반화에 도움이 됩니다.

💡 학습률은 보통 1e-6 ~ 1e-5 범위를 사용합니다. 사전학습보다 훨씬 작은 값인데, 이는 정책을 급격히 변경하지 않기 위함입니다. Adam optimizer의 기본 설정(β1=0.9, β2=0.999)을 사용하세요.

💡 실무에서는 DataLoader에 num_workers > 0을 설정하여 데이터 로딩을 병렬화하세요. 다만 너무 많은 워커는 메모리 오버헤드를 증가시키므로, 2~4개가 적절합니다.


8. 전체 GRPO 파이프라인 - 종합 구현

시작하며

여러분이 지금까지 배운 GRPO의 모든 구성요소를 어떻게 하나의 완전한 시스템으로 통합할 수 있을까요? 온라인 샘플링, advantage 계산, 클리핑, KL 제약, 미니배치 학습 등 많은 부분이 있지만, 이들을 올바른 순서로 조합하는 것이 중요합니다.

한 부분이라도 잘못되면 전체 학습이 실패할 수 있습니다. 이 문제는 강화학습의 복잡성 때문에 발생합니다.

Supervised learning은 "데이터 → 모델 → 손실 → 업데이트"의 단순한 흐름이지만, GRPO는 "생성 → 평가 → 그룹화 → advantage 계산 → 다중 에폭 학습"의 복잡한 사이클입니다. 각 단계가 정확히 구현되고 올바르게 연결되어야 합니다.

바로 이럴 때 필요한 것이 전체 GRPO 파이프라인의 구조화된 구현입니다. 이는 모든 구성요소를 체계적으로 통합하고, 재현 가능하며 확장 가능한 학습 시스템을 제공합니다.

개요

간단히 말해서, GRPO 파이프라인은 샘플링, 평가, 학습의 3단계를 반복하는 구조로, 각 이터레이션마다 모델이 점진적으로 개선되는 시스템입니다. 이는 AlphaGo의 self-play와 유사한 구조입니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, ChatGPT 같은 모델은 수백~수천 번의 GRPO 이터레이션을 거쳐 학습됩니다. 각 이터레이션은 수 시간씩 걸릴 수 있으므로, 파이프라인이 안정적이고 재시작 가능해야 합니다.

예를 들어, 중간에 학습이 중단되어도 체크포인트에서 재개할 수 있어야 하고, 모든 지표가 로깅되어 나중에 분석할 수 있어야 합니다. 잘 설계된 파이프라인은 실험 재현성과 디버깅 효율성을 크게 높입니다.

기존 단일 스크립트 구현에서는 유지보수가 어렵고 확장성이 낮았다면, 구조화된 파이프라인은 모듈화, 설정 관리, 로깅, 체크포인팅을 체계적으로 제공합니다. 이는 연구와 프로덕션 배포 모두에 필수적입니다.

GRPO 파이프라인의 핵심 특징은: (1) 샘플링-학습 사이클의 자동화, (2) 포괄적인 로깅과 모니터링, (3) 체크포인팅과 재시작 지원. 이러한 특징들이 실무 수준의 RLHF 시스템을 구성합니다.

코드 예제

import torch
from dataclasses import dataclass

@dataclass
class GRPOConfig:
    """GRPO 학습 설정"""
    group_size: int = 4
    batch_size: int = 128
    num_epochs: int = 4
    num_iterations: int = 1000
    clip_ratio: float = 0.2
    kl_coef: float = 0.1
    learning_rate: float = 1e-5
    max_grad_norm: float = 1.0
    temperature: float = 0.9

class GRPOTrainer:
    """전체 GRPO 학습 파이프라인"""
    def __init__(self, policy_model, reward_model, ref_model, config):
        self.policy = policy_model
        self.reward_model = reward_model
        self.ref_model = ref_model  # 참조 모델 (고정)
        self.config = config
        self.optimizer = torch.optim.AdamW(
            policy_model.parameters(), lr=config.learning_rate
        )

    def train(self, prompts):
        """메인 학습 루프"""
        for iteration in range(self.config.num_iterations):
            print(f"\n=== Iteration {iteration+1}/{self.config.num_iterations} ===")

            # Step 1: 온라인 샘플링 (현재 정책으로 응답 생성)
            responses, old_log_probs = self.sample_responses(prompts)

            # Step 2: 보상 계산
            rewards = self.compute_rewards(prompts, responses)

            # Step 3: 참조 정책의 log_probs 계산
            ref_log_probs = self.compute_ref_log_probs(responses)

            # Step 4: 미니배치 학습
            metrics = self.training_step(
                responses, rewards, old_log_probs, ref_log_probs
            )

            # Step 5: 로깅 및 체크포인팅
            self.log_metrics(iteration, metrics)
            if (iteration + 1) % 100 == 0:
                self.save_checkpoint(iteration)

        print("Training completed!")

    def sample_responses(self, prompts):
        """온라인 샘플링"""
        self.policy.eval()
        all_responses, all_log_probs = [], []

        with torch.no_grad():
            for prompt in prompts:
                for _ in range(self.config.group_size):
                    output = self.policy.generate(
                        prompt, temperature=self.config.temperature
                    )
                    all_responses.append(output.sequences)
                    all_log_probs.append(output.log_probs.sum())

        return all_responses, torch.tensor(all_log_probs)

    def compute_rewards(self, prompts, responses):
        """보상 모델로 평가"""
        with torch.no_grad():
            rewards = self.reward_model(prompts, responses)
        return rewards

    def training_step(self, responses, rewards, old_log_probs, ref_log_probs):
        """한 이터레이션의 미니배치 학습"""
        # 이전에 구현한 grpo_minibatch_training 활용
        # 간략화를 위해 핵심만 표시
        return {"loss": 0.5, "kl": 0.02, "clip_frac": 0.15}

설명

이 코드가 하는 일은 GRPO의 전체 학습 흐름을 하나의 일관된 시스템으로 통합하는 것입니다. 전체적으로 "정책 개선 → 더 나은 응답 생성 → 더 정확한 학습"의 선순환을 자동화합니다.

첫 번째로, GRPOConfig dataclass로 모든 하이퍼파라미터를 한 곳에 모읍니다. 이는 실험 관리에 매우 중요합니다.

설정을 코드와 분리하면, (1) 다른 하이퍼파라미터 조합을 쉽게 실험하고, (2) 설정 파일로 저장하여 실험 재현성을 보장하며, (3) 설정 검증을 한 곳에서 수행할 수 있습니다. 실무에서는 이를 YAML이나 JSON 파일로 저장하고 버전 관리합니다.

그 다음으로, GRPOTrainer 클래스로 학습 로직을 캡슐화합니다. 이 클래스는 세 개의 모델을 관리합니다: (1) policy_model은 학습 대상(Actor), (2) reward_model은 평가자(고정), (3) ref_model은 KL 제약의 기준(고정, 보통 초기 policy의 복사본).

중요한 점은 reward_model과 ref_model은 .requires_grad_(False)로 설정하여 메모리와 계산을 절약하는 것입니다. 세 번째로, train() 메서드가 메인 루프를 구현합니다.

각 이터레이션은 완전히 독립적이며, 이전 이터레이션의 데이터는 사용하지 않습니다(온라인 학습). 이는 GRPO가 off-policy가 아닌 이유입니다.

매 이터레이션마다 현재 정책으로 새로운 데이터를 생성하므로, 항상 최신 정책의 행동을 학습합니다. 이터레이션 수는 보통 수백~수천 회이며, 각 이터레이션마다 수천 개의 응답을 생성합니다.

네 번째로, sample_responses(), compute_rewards() 등 각 단계를 별도 메서드로 분리합니다. 이는 코드 재사용성과 테스트 용이성을 높입니다.

예를 들어 샘플링 전략을 변경하려면 sample_responses() 메서드만 수정하면 됩니다. 또한 각 메서드를 독립적으로 단위 테스트할 수 있어, 버그를 빠르게 발견할 수 있습니다.

마지막으로, log_metrics()save_checkpoint()로 학습 상태를 추적하고 저장합니다. 로깅은 TensorBoard나 WandB 같은 도구와 통합되어, 실시간으로 학습 진행을 모니터링할 수 있습니다.

체크포인팅은 학습이 중단되어도 재개할 수 있도록 하며, 보통 100~200 이터레이션마다 수행합니다. 체크포인트에는 모델 가중치뿐 아니라 optimizer 상태, 난수 시드, 이터레이션 번호도 포함해야 완전한 재현이 가능합니다.

여러분이 이 코드를 사용하면 (1) 체계적이고 재현 가능한 학습 시스템, (2) 모듈화로 유지보수 용이, (3) 프로덕션 수준의 안정성과 확장성을 얻을 수 있습니다. 실무에서는 이를 분산 학습, 자동 하이퍼파라미터 튜닝, A/B 테스트 등과 통합하여 완전한 RLHF 플랫폼을 구축합니다.

실전 팁

💡 학습 시작 전 "dry run"을 수행하세요. 첫 이터레이션만 실행하여 모든 구성요소가 올바르게 작동하는지 확인합니다. 이는 며칠 걸리는 학습이 중간에 실패하는 것을 방지합니다.

💡 체크포인트를 여러 개 유지하세요. 최신 3~5개 체크포인트를 보관하면, 학습이 발산해도 이전 상태로 롤백할 수 있습니다. 또한 주기적으로(예: 매 500 이터레이션) "메이저 체크포인트"를 별도 저장하세요.

💡 early stopping 조건을 구현하세요. KL divergence가 0.1을 넘거나, loss가 증가 추세면 자동으로 학습을 중단합니다. 이는 자원 낭비를 방지하고 최적 모델을 보존합니다.

💡 prompts는 매 이터레이션마다 셔플하거나 다양화하세요. 같은 프롬프트만 반복하면 과적합됩니다. 프롬프트 풀에서 무작위로 샘플링하거나, 난이도별로 curriculum을 구성하세요.

💡 실무에서는 정책 모델의 여러 버전을 평가하는 "evaluation loop"를 별도로 운영하세요. 학습 중인 모델을 주기적으로 홀드아웃 테스트셋에서 평가하고, 가장 성능 좋은 체크포인트를 최종 모델로 선택합니다. 학습 손실이 낮다고 항상 좋은 모델은 아닙니다.


#AI#GRPO#ReinforcementLearning#PolicyOptimization#ChatGPT#ai

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.