개요
지금까지 다룬 강화학습 알고리즘들은 모두 **그래디언트 기반**이었다. 정책 네트워크의 파라미터를 역전파(backpropagation)로 업데이트했다. 하지만 보상 함수가 미분 불가능하거나, 환경이 블랙박스인 경우에는 어떻게 할까?
**Black-Box 최적화**는 목적 함수의 내부 구조를 모르는 채로, 입력-출력 관계만으로 최적화하는 방법이다. 진화 전략(Evolution Strategies, ES)과 유전 알고리즘(Genetic Algorithms, GA)이 대표적이다.
Black-Box 방법의 특징
그래디언트 기반 vs Black-Box
| 항목 | 그래디언트 기반 | Black-Box |
| ----------- | ---------------------------- | ------------------ |
| 미분 필요성 | 목적 함수가 미분 가능해야 함 | 불필요 |
| 정보 활용 | 그래디언트 (방향 + 크기) | 함수값만 사용 |
| 확장성 | 수백만 파라미터 가능 | 파라미터 수에 민감 |
| 병렬화 | 제한적 (동기화 필요) | 자연스러운 병렬화 |
| 탐색 성격 | 로컬 탐색 | 글로벌 탐색 가능 |
장점
- 보상 함수의 미분 불가능해도 적용 가능
- 환경의 내부 구조를 알 필요 없음
- 대규모 병렬화가 자연스러움
- 지역 최적에 빠지기 어려움
단점
- 샘플 효율성이 낮음
- 고차원 파라미터 공간에서 느림
- 그래디언트 정보를 활용하지 못함
진화 전략 (Evolution Strategies)
기본 원리
ES는 파라미터 공간에서 **무작위 변이(perturbation)** 를 만들고, 좋은 성능을 보인 변이 방향으로 파라미터를 업데이트한다. 놀랍게도 이 과정이 그래디언트 추정과 수학적으로 동치라는 것이 밝혀졌다.
ES의 수학적 직관
파라미터 theta에 가우시안 노이즈 epsilon을 더한 theta + sigma \* epsilon에서의 기대 보상을 최대화한다. 이때 보상의 기대값에 대한 theta의 그래디언트를 몬테카를로 추정하면, 결국 보상으로 가중된 노이즈 방향의 평균이 된다.
CartPole에서의 ES 구현
class ESPolicy(nn.Module):
"""ES용 정책 네트워크"""
def __init__(self, obs_size, act_size):
super().__init__()
self.net = nn.Sequential(
nn.Linear(obs_size, 64),
nn.Tanh(),
nn.Linear(64, act_size),
)
def forward(self, obs):
return self.net(obs)
def get_action(self, obs):
with torch.no_grad():
logits = self.forward(torch.FloatTensor(obs))
return logits.argmax().item()
def evaluate_policy(env, policy, num_episodes=5):
"""정책의 평균 보상 계산"""
total_reward = 0.0
for _ in range(num_episodes):
obs, _ = env.reset()
done = False
while not done:
action = policy.get_action(obs)
obs, reward, terminated, truncated, _ = env.step(action)
total_reward += reward
done = terminated or truncated
return total_reward / num_episodes
def train_es_cartpole(pop_size=50, num_generations=100,
sigma=0.1, lr=0.01):
"""CartPole에서 ES 학습"""
env = gym.make('CartPole-v1')
obs_size = env.observation_space.shape[0]
act_size = env.action_space.n
기본 정책
policy = ESPolicy(obs_size, act_size)
base_params = nn.utils.parameters_to_vector(
policy.parameters()
).detach()
for gen in range(num_generations):
1. 무작위 변이 생성
noise = torch.randn(pop_size, len(base_params))
2. 각 변이에 대해 보상 평가
rewards = []
for i in range(pop_size):
양방향 변이 (미러링)
for sign in [1, -1]:
perturbed = base_params + sign * sigma * noise[i]
nn.utils.vector_to_parameters(perturbed, policy.parameters())
reward = evaluate_policy(env, policy, num_episodes=3)
rewards.append((reward, sign, i))
3. 보상으로 가중된 업데이트 방향 계산
rewards_array = np.array([r[0] for r in rewards])
보상 정규화 (rank-based)
ranked = np.argsort(np.argsort(-rewards_array)).astype(np.float32)
ranked = (ranked - ranked.mean()) / (ranked.std() + 1e-8)
그래디언트 추정
grad = torch.zeros_like(base_params)
for idx, (_, sign, noise_idx) in enumerate(rewards):
grad += ranked[idx] * sign * noise[noise_idx]
grad /= (2 * pop_size * sigma)
4. 파라미터 업데이트
base_params += lr * grad
성능 확인
nn.utils.vector_to_parameters(base_params, policy.parameters())
avg_reward = evaluate_policy(env, policy, num_episodes=10)
if gen % 10 == 0:
print(f"세대 {gen}: 평균 보상 = {avg_reward:.1f}")
if avg_reward >= 495:
print(f"세대 {gen}에서 해결!")
break
return policy
HalfCheetah에서의 ES
연속 행동 공간에서의 ES는 더 많은 인구(population)와 세대(generation)가 필요하다:
class ESContinuousPolicy(nn.Module):
def __init__(self, obs_size, act_size):
super().__init__()
self.net = nn.Sequential(
nn.Linear(obs_size, 256),
nn.Tanh(),
nn.Linear(256, 256),
nn.Tanh(),
nn.Linear(256, act_size),
nn.Tanh(),
)
def forward(self, obs):
return self.net(obs)
def get_action(self, obs):
with torch.no_grad():
return self.forward(torch.FloatTensor(obs)).numpy()
def train_es_continuous(env_name='HalfCheetah-v4', pop_size=200,
num_generations=500, sigma=0.02, lr=0.01):
"""연속 환경에서 ES 학습 (병렬화 가능)"""
env = gym.make(env_name)
obs_size = env.observation_space.shape[0]
act_size = env.action_space.shape[0]
policy = ESContinuousPolicy(obs_size, act_size)
base_params = nn.utils.parameters_to_vector(
policy.parameters()
).detach()
Adam 모멘텀 추적
m = torch.zeros_like(base_params) # 1차 모멘트
v = torch.zeros_like(base_params) # 2차 모멘트
beta1, beta2 = 0.9, 0.999
for gen in range(num_generations):
noise = torch.randn(pop_size, len(base_params))
rewards_pos = np.zeros(pop_size)
rewards_neg = np.zeros(pop_size)
병렬화 가능한 평가 루프
for i in range(pop_size):
양방향 변이 평가
params_pos = base_params + sigma * noise[i]
nn.utils.vector_to_parameters(params_pos, policy.parameters())
rewards_pos[i] = evaluate_policy(env, policy, num_episodes=1)
params_neg = base_params - sigma * noise[i]
nn.utils.vector_to_parameters(params_neg, policy.parameters())
rewards_neg[i] = evaluate_policy(env, policy, num_episodes=1)
보상 차이로 그래디언트 추정
all_rewards = np.concatenate([rewards_pos, rewards_neg])
reward_mean = all_rewards.mean()
reward_std = all_rewards.std() + 1e-8
grad = torch.zeros_like(base_params)
for i in range(pop_size):
normalized = (rewards_pos[i] - rewards_neg[i]) / reward_std
grad += normalized * noise[i]
grad /= (2 * pop_size * sigma)
Adam 업데이트
m = beta1 * m + (1 - beta1) * grad
v = beta2 * v + (1 - beta2) * grad ** 2
m_hat = m / (1 - beta1 ** (gen + 1))
v_hat = v / (1 - beta2 ** (gen + 1))
base_params += lr * m_hat / (torch.sqrt(v_hat) + 1e-8)
nn.utils.vector_to_parameters(base_params, policy.parameters())
if gen % 10 == 0:
avg = evaluate_policy(env, policy, num_episodes=5)
print(f"세대 {gen}: 평균 보상 = {avg:.1f}")
return policy
유전 알고리즘 (Genetic Algorithm)
GA 기본 원리
유전 알고리즘은 생물학적 진화를 모방한다:
1. **인구(Population)**: 여러 개의 정책(개체)을 유지
2. **적합도 평가(Fitness)**: 각 개체의 보상을 측정
3. **선택(Selection)**: 적합도가 높은 개체를 선택
4. **교차(Crossover)**: 선택된 개체들의 파라미터를 혼합
5. **변이(Mutation)**: 소량의 무작위 변화 추가
CartPole에서의 GA 구현
def train_ga_cartpole(pop_size=100, num_generations=50,
elite_ratio=0.1, mutation_rate=0.1,
mutation_sigma=0.1):
"""유전 알고리즘으로 CartPole 학습"""
env = gym.make('CartPole-v1')
obs_size = env.observation_space.shape[0]
act_size = env.action_space.n
초기 인구 생성
population = [ESPolicy(obs_size, act_size) for _ in range(pop_size)]
num_elite = max(int(pop_size * elite_ratio), 1)
for gen in range(num_generations):
1. 적합도 평가
fitness = []
for individual in population:
reward = evaluate_policy(env, individual, num_episodes=5)
fitness.append(reward)
정렬 (적합도 내림차순)
sorted_indices = np.argsort(fitness)[::-1]
best_fitness = fitness[sorted_indices[0]]
if gen % 5 == 0:
print(f"세대 {gen}: 최고 적합도 = {best_fitness:.1f}, "
f"평균 = {np.mean(fitness):.1f}")
if best_fitness >= 495:
print(f"세대 {gen}에서 해결!")
return population[sorted_indices[0]]
2. 엘리트 선택
elites = [copy.deepcopy(population[i]) for i in sorted_indices[:num_elite]]
3. 다음 세대 생성
new_population = list(elites) # 엘리트는 그대로 유지
while len(new_population) < pop_size:
토너먼트 선택
parent1 = tournament_select(population, fitness)
parent2 = tournament_select(population, fitness)
교차
child = crossover(parent1, parent2)
변이
mutate(child, mutation_rate, mutation_sigma)
new_population.append(child)
population = new_population
return population[sorted_indices[0]]
def tournament_select(population, fitness, k=3):
"""토너먼트 선택: k개 중 최고 적합도 선택"""
indices = np.random.choice(len(population), k, replace=False)
best_idx = indices[np.argmax([fitness[i] for i in indices])]
return copy.deepcopy(population[best_idx])
def crossover(parent1, parent2):
"""균일 교차: 각 파라미터를 50% 확률로 선택"""
child = copy.deepcopy(parent1)
for c_param, p2_param in zip(child.parameters(), parent2.parameters()):
mask = torch.rand_like(c_param) > 0.5
c_param.data[mask] = p2_param.data[mask]
return child
def mutate(individual, rate=0.1, sigma=0.1):
"""가우시안 변이"""
for param in individual.parameters():
mask = torch.rand_like(param) < rate
noise = torch.randn_like(param) * sigma
param.data += mask.float() * noise
GA 개선 기법
Deep GA
Uber AI Labs의 Deep GA는 대규모 신경망에 GA를 적용하기 위한 기법이다:
class DeepGA:
"""Deep GA: 파라미터 인코딩으로 메모리 효율적 GA"""
def __init__(self, policy_fn, pop_size=1000, seed_size=5):
self.pop_size = pop_size
self.policy_fn = policy_fn
각 개체를 시드 시퀀스로 인코딩 (메모리 절약)
self.population = []
for _ in range(pop_size):
seeds = [np.random.randint(0, 2**31) for _ in range(seed_size)]
self.population.append(seeds)
def decode(self, seed_sequence):
"""시드 시퀀스에서 정책 파라미터 복원"""
policy = self.policy_fn()
base_params = nn.utils.parameters_to_vector(
policy.parameters()
).detach()
params = torch.zeros_like(base_params)
for seed in seed_sequence:
rng = np.random.RandomState(seed)
noise = torch.FloatTensor(
rng.randn(len(base_params))
) * 0.01
params += noise
nn.utils.vector_to_parameters(params, policy.parameters())
return policy
def evolve(self, fitness_fn, num_generations=100):
for gen in range(num_generations):
적합도 평가
fitness = []
for seeds in self.population:
policy = self.decode(seeds)
fitness.append(fitness_fn(policy))
변이: 시드 하나 추가
sorted_idx = np.argsort(fitness)[::-1]
elites = [self.population[i] for i in sorted_idx[:10]]
new_pop = [list(e) for e in elites]
while len(new_pop) < self.pop_size:
parent = elites[np.random.randint(len(elites))]
child = list(parent) + [np.random.randint(0, 2**31)]
new_pop.append(child)
self.population = new_pop
if gen % 10 == 0:
print(f"세대 {gen}: 최고 = {max(fitness):.1f}")
신규성 탐색 (Novelty Search)
적합도 대신 **행동의 신규성**을 기준으로 선택하는 방법이다. 새로운 행동 패턴을 보이는 개체가 선호되어, 다양한 전략을 탐색한다:
class NoveltySearch:
"""신규성 탐색: 행동 다양성으로 개체 선택"""
def __init__(self, archive_size=1000, k_nearest=15):
self.archive = []
self.archive_size = archive_size
self.k = k_nearest
def behavior_characterization(self, trajectory):
"""에이전트의 행동을 특성 벡터로 요약"""
예: 최종 위치, 방문한 상태의 통계 등
if len(trajectory) == 0:
return np.zeros(4)
states = np.array(trajectory)
return np.array([
states[:, 0].mean(), # 평균 x 위치
states[:, 1].mean(), # 평균 y 위치
states[:, 0].std(), # x 분산
states[:, 1].std(), # y 분산
])
def novelty_score(self, behavior):
"""k-최근접 이웃과의 평균 거리 = 신규성 점수"""
if len(self.archive) < self.k:
return float('inf')
distances = [np.linalg.norm(behavior - b)
for b in self.archive]
distances.sort()
return np.mean(distances[:self.k])
def add_to_archive(self, behavior):
self.archive.append(behavior)
if len(self.archive) > self.archive_size:
랜덤하게 하나 제거
idx = np.random.randint(len(self.archive))
self.archive.pop(idx)
ES vs GA 비교
| 항목 | 진화 전략 (ES) | 유전 알고리즘 (GA) |
| --------------- | ------------------------ | --------------------------- |
| 업데이트 방식 | 그래디언트 추정 (연속적) | 선택 + 교차 + 변이 (이산적) |
| 수학적 기반 | 정규 분포 기반 최적화 | 생물학적 진화 모방 |
| 인구 다양성 | 단일 분포에서 샘플링 | 여러 개체가 독립적으로 존재 |
| 병렬화 | 매우 쉬움 | 쉬움 |
| 대규모 파라미터 | 비교적 잘 확장 | 교차 연산이 비효율적 |
핵심 요약
- Black-Box 최적화는 그래디언트 없이 보상 신호만으로 정책을 최적화한다
- ES는 무작위 변이의 보상으로 그래디언트를 추정하며, 대규모 병렬화에 유리하다
- GA는 선택, 교차, 변이를 통한 인구 기반 최적화를 수행한다
- Deep GA와 신규성 탐색으로 GA의 한계를 극복할 수 있다
- 그래디언트 기반 방법보다 샘플 효율은 낮지만, 특수한 환경에서 유용하다
다음 글에서는 환경 모델을 학습하여 활용하는 **모델 기반 강화학습**을 알아보겠다.
현재 단락 (1/280)
지금까지 다룬 강화학습 알고리즘들은 모두 **그래디언트 기반**이었다. 정책 네트워크의 파라미터를 역전파(backpropagation)로 업데이트했다. 하지만 보상 함수가 미분 불...