lm_human_preferences/train_policy.py (388 lines of code) (raw):
#!/usr/bin/env python3
import json
import os
import sys
import time
from dataclasses import dataclass, field
from functools import partial
from typing import Optional
import numpy as np
import tensorflow as tf
from mpi4py import MPI
from tensorflow.contrib import summary
from lm_human_preferences import lm_tasks, train_reward
from lm_human_preferences.language import trained_models
from lm_human_preferences.policy import Policy
from lm_human_preferences.rewards import TrainedRewardModel
from lm_human_preferences.utils import core as utils
from lm_human_preferences.utils import hyperparams
from lm_human_preferences.utils.core import Schema
@dataclass
class AdaptiveKLParams(hyperparams.HParams):
target: float = None
horizon: int = 10000 # in episodes
@dataclass
class RewardHParams(hyperparams.HParams):
kl_coef: float = 0.2
adaptive_kl: Optional[AdaptiveKLParams] = None
trained_model: Optional[str] = None
train_new_model: Optional[train_reward.HParams] = None
def validate(self, *, prefix=''):
super().validate(prefix=prefix)
assert self.trained_model is None or self.train_new_model is None, 'Cannot use trained_model and train new model'
assert self.trained_model is not None or self.train_new_model is not None, 'Need either trained_model or to train a new model'
@dataclass
class PpoHParams(hyperparams.HParams):
total_episodes: int = 2000000
batch_size: int = 64
nminibatches: int = 1
noptepochs: int = 4
lr: float = 5e-6
vf_coef: float = .1
cliprange: float = .2
cliprange_value: float = .2
gamma: float = 1
lam: float = 0.95
whiten_rewards: bool = True
@dataclass
class HParams(hyperparams.HParams):
run: train_reward.RunHParams = field(default_factory=train_reward.RunHParams)
task: lm_tasks.TaskHParams = field(default_factory=lm_tasks.TaskHParams)
rewards: RewardHParams = field(default_factory=RewardHParams)
ppo: PpoHParams = field(default_factory=PpoHParams)
def validate(self, *, prefix=''):
super().validate(prefix=prefix)
# NOTE: must additionally divide by # ranks
minibatch_size = utils.exact_div(self.ppo.batch_size, self.ppo.nminibatches)
if self.ppo.whiten_rewards:
assert minibatch_size >= 8, \
f"Minibatch size {minibatch_size} is insufficient for whitening in PPOTrainer.loss"
def nupdates(hparams):
return utils.ceil_div(hparams.ppo.total_episodes, hparams.ppo.batch_size)
def policy_frac(hparams):
"""How far we are through policy training."""
return tf.cast(tf.train.get_global_step(), tf.float32) / nupdates(hparams)
def tf_times():
"""Returns (time since start, time since last) as a tensorflow op."""
# Keep track of start and last times
with tf.init_scope():
init = tf.timestamp()
def make(name):
return tf.Variable(init, name=name, trainable=False, use_resource=True)
start = make('start_time')
last = make('last_time')
# Get new time and update last
now = tf.timestamp()
prev = last.read_value()
with tf.control_dependencies([prev]):
with tf.control_dependencies([last.assign(now)]):
return tf.cast(now - start.read_value(), tf.float32), tf.cast(now - prev, tf.float32)
class FixedKLController:
def __init__(self, kl_coef):
self.value = kl_coef
def update(self, current, n_steps):
pass
class AdaptiveKLController:
def __init__(self, init_kl_coef, hparams):
self.value = init_kl_coef
self.hparams = hparams
def update(self, current, n_steps):
target = self.hparams.target
proportional_error = np.clip(current / target - 1, -0.2, 0.2)
mult = 1 + proportional_error * n_steps / self.hparams.horizon
self.value *= mult
class PPOTrainer():
def __init__(self, *, policy, ref_policy, query_sampler, score_fn, hparams, comm):
self.comm = comm
self.policy = policy
self.ref_policy = ref_policy
self.score_fn = score_fn
self.hparams = hparams
if hparams.rewards.adaptive_kl is None:
self.kl_ctl = FixedKLController(hparams.rewards.kl_coef)
else:
self.kl_ctl = AdaptiveKLController(hparams.rewards.kl_coef, hparams=hparams.rewards.adaptive_kl)
response_length = hparams.task.response_length
query_length = hparams.task.query_length
@utils.graph_function()
def sample_queries():
return query_sampler()['tokens']
self.sample_queries = sample_queries
def compute_rewards(scores, logprobs, ref_logprobs):
kl = logprobs - ref_logprobs
non_score_reward = -self.kl_ctl.value * kl
rewards = non_score_reward.copy()
rewards[:, -1] += scores
return rewards, non_score_reward, self.kl_ctl.value
self.compute_rewards = compute_rewards
# per rank sizes
per_rank_rollout_batch_size = utils.exact_div(hparams.ppo.batch_size, comm.Get_size())
per_rank_minibatch_size = utils.exact_div(per_rank_rollout_batch_size, hparams.ppo.nminibatches)
@utils.graph_function(
rollouts=dict(
queries=Schema(tf.int32, (per_rank_minibatch_size, query_length)),
responses=Schema(tf.int32, (per_rank_minibatch_size, response_length)),
values=Schema(tf.float32, (per_rank_minibatch_size, response_length)),
logprobs=Schema(tf.float32, (per_rank_minibatch_size, response_length)),
rewards=Schema(tf.float32, (per_rank_minibatch_size, response_length)),
))
def train_minibatch(rollouts):
"""One step of PPO training."""
left = 1 - policy_frac(hparams)
lrnow = hparams.ppo.lr * left
ppo_loss, stats = self.loss(rollouts)
ppo_train_op = utils.minimize(
loss=ppo_loss, lr=lrnow, params=policy.get_params(), name='ppo_opt', comm=self.comm)
return ppo_train_op, stats
def train(rollouts):
stat_list = []
# Do multiple epochs of PPO training, with a fresh random shuffle in each epoch
for ppo_epoch_idx in range(hparams.ppo.noptepochs):
order = np.random.permutation(per_rank_rollout_batch_size)
for mb_start in range(0, per_rank_rollout_batch_size, per_rank_minibatch_size):
mb_data = {k: v[order[mb_start:mb_start+per_rank_minibatch_size]]
for k, v in rollouts.items()}
step = tf.train.get_global_step().eval()
_, stats = train_minibatch(mb_data)
stat_list.append(stats)
# Collect the stats. (They will be averaged later.)
return {k: [s[k] for s in stat_list] for k in stat_list[0].keys()}
self.train = train
# NOTE: must line up with stats created in self.loss (TODO: better solution?)
scalar_batch = Schema(tf.float32, (None,))
ppo_stat_schemas = utils.flatten_dict(dict(
loss=dict(policy=scalar_batch, value=scalar_batch, total=scalar_batch),
policy=dict(entropy=scalar_batch, approxkl=scalar_batch, clipfrac=scalar_batch),
returns=dict(mean=scalar_batch, var=scalar_batch),
val=dict(vpred=scalar_batch, error=scalar_batch, clipfrac=scalar_batch, mean=scalar_batch, var=scalar_batch),
), sep='/')
stat_data_schemas = dict(
logprobs=Schema(tf.float32, (None, hparams.task.response_length)),
ref_logprobs=Schema(tf.float32, (None, hparams.task.response_length)),
scores=scalar_batch,
non_score_reward=Schema(tf.float32, (None, hparams.task.response_length)),
score_stats=score_fn.stat_schemas,
train_stats=ppo_stat_schemas,
)
@utils.graph_function(
**stat_data_schemas, kl_coef=Schema(tf.float32, ()))
def record_step_stats(*, kl_coef, **data):
ppo_summary_writer = utils.get_summary_writer(self.hparams.run.save_dir, subdir='ppo', comm=self.comm)
kl = data['logprobs'] - data['ref_logprobs']
mean_kl = tf.reduce_mean(tf.reduce_sum(kl, axis=1))
mean_entropy = tf.reduce_mean(tf.reduce_sum(-data['logprobs'], axis=1))
mean_non_score_reward = tf.reduce_mean(tf.reduce_sum(data['non_score_reward'], axis=1))
stats = {
'objective/kl': mean_kl,
'objective/kl_coef': kl_coef,
'objective/entropy': mean_entropy,
}
for k, v in data['train_stats'].items():
stats[f'ppo/{k}'] = tf.reduce_mean(v, axis=0)
for k, v in data['score_stats'].items():
mean = tf.reduce_mean(v, axis=0)
stats[f'objective/{k}'] = mean
stats[f'objective/{k}_total'] = mean + mean_non_score_reward
stats = utils.FlatStats.from_dict(stats).map_flat(
partial(utils.mpi_allreduce_mean, comm=self.comm)).as_dict()
# Add more statistics
step = tf.train.get_global_step().read_value()
stats['ppo/val/var_explained'] = 1 - stats['ppo/val/error'] / stats['ppo/returns/var']
steps = step + 1
stats.update({
'elapsed/updates': steps,
'elapsed/steps/serial': steps * hparams.task.response_length,
'elapsed/steps/total': steps * hparams.ppo.batch_size * hparams.task.response_length,
'elapsed/episodes': steps * hparams.ppo.batch_size,
})
# Time statistics
total, delta = tf_times()
stats.update({
'elapsed/fps': tf.cast(hparams.ppo.batch_size * hparams.task.response_length / delta, tf.int32),
'elapsed/time': total,
})
if ppo_summary_writer:
record_op = utils.record_stats(
stats=stats, summary_writer=ppo_summary_writer, step=step, log_interval=hparams.run.log_interval, name='ppo_stats', comm=self.comm)
else:
record_op = tf.no_op()
return record_op, stats
self.record_step_stats = record_step_stats
def print_samples(self, queries, responses, scores, logprobs, ref_logprobs):
if self.comm.Get_rank() != 0:
return
if tf.train.get_global_step().eval() % self.hparams.run.log_interval != 0:
return
encoder = self.policy.encoder
# Log samples
for i in range(min(3, len(queries))):
sample_kl = np.sum(logprobs[i] - ref_logprobs[i])
print(encoder.decode(queries[i][:self.hparams.task.query_length]).replace("\n", "⏎"))
print(encoder.decode(responses[i]).replace("\n", "⏎"))
print(f" score = {scores[i]:+.2f}")
print(f" kl = {sample_kl:+.2f}")
print(f" total = {scores[i] - self.hparams.rewards.kl_coef * sample_kl:+.2f}")
def step(self):
step_started_at = time.time()
queries = self.sample_queries()
rollouts = self.policy.respond(queries, length=self.hparams.task.response_length)
responses = rollouts['responses']
logprobs = rollouts['logprobs']
rollouts['queries'] = queries
ref_logprobs = self.ref_policy.analyze_responses(queries, responses)['logprobs']
scores, postprocessed_responses, score_stats = self.score_fn(queries, responses)
rewards, non_score_reward, kl_coef = self.compute_rewards(
scores=scores,
logprobs=logprobs,
ref_logprobs=ref_logprobs)
rollouts['rewards'] = rewards
train_stats = self.train(rollouts=rollouts)
_, stats = self.record_step_stats(
scores=scores, logprobs=logprobs, ref_logprobs=ref_logprobs, non_score_reward=non_score_reward,
train_stats=train_stats, score_stats=score_stats, kl_coef=kl_coef)
self.kl_ctl.update(stats['objective/kl'], self.hparams.ppo.batch_size)
self.print_samples(queries=queries, responses=postprocessed_responses,
scores=scores, logprobs=logprobs, ref_logprobs=ref_logprobs)
# Record profiles of the step times
step = tf.get_default_session().run(tf.train.get_global_step())
step_time = time.time() - step_started_at
eps_per_second = float(self.hparams.ppo.batch_size) / step_time
if self.comm.Get_rank() == 0:
print(f"[ppo_step {step}] step_time={step_time:.2f}s, "
f"eps/s={eps_per_second:.2f}")
def loss(self, rollouts):
values = rollouts['values']
old_logprob = rollouts['logprobs']
rewards = rollouts['rewards']
with tf.name_scope('ppo_loss'):
if self.hparams.ppo.whiten_rewards:
rewards = utils.whiten(rewards, shift_mean=False)
lastgaelam = 0
advantages_reversed = []
gen_length = self.hparams.task.response_length
for t in reversed(range(gen_length)):
nextvalues = values[:, t + 1] if t < gen_length - 1 else 0.0
delta = rewards[:, t] + self.hparams.ppo.gamma * nextvalues - values[:, t]
lastgaelam = delta + self.hparams.ppo.gamma * self.hparams.ppo.lam * lastgaelam
advantages_reversed.append(lastgaelam)
advantages = tf.stack(advantages_reversed[::-1], axis=1)
returns = advantages + values
advantages = utils.whiten(advantages)
advantages = tf.stop_gradient(advantages) # Shouldn't do anything, but better not to think about it
outputs = self.policy.analyze_responses_op(rollouts['queries'], rollouts['responses'])
vpred = outputs['values']
vpredclipped = tf.clip_by_value(vpred, values - self.hparams.ppo.cliprange_value, values + self.hparams.ppo.cliprange_value)
vf_losses1 = tf.square(vpred - returns)
vf_losses2 = tf.square(vpredclipped - returns)
vf_loss = .5 * tf.reduce_mean(tf.maximum(vf_losses1, vf_losses2))
vf_clipfrac = tf.reduce_mean(tf.cast(tf.greater(vf_losses2, vf_losses1), tf.float32))
logprob = outputs['logprobs']
ratio = tf.exp(logprob - old_logprob)
pg_losses = -advantages * ratio
pg_losses2 = -advantages * tf.clip_by_value(ratio, 1.0 - self.hparams.ppo.cliprange, 1.0 + self.hparams.ppo.cliprange)
pg_loss = tf.reduce_mean(tf.maximum(pg_losses, pg_losses2))
pg_clipfrac = tf.reduce_mean(tf.cast(tf.greater(pg_losses2, pg_losses), tf.float32))
loss = pg_loss + self.hparams.ppo.vf_coef * vf_loss
entropy = tf.reduce_mean(outputs['entropies'])
approxkl = .5 * tf.reduce_mean(tf.square(logprob - old_logprob))
return_mean, return_var = tf.nn.moments(returns, axes=list(range(returns.shape.ndims)))
value_mean, value_var = tf.nn.moments(values, axes=list(range(values.shape.ndims)))
stats = dict(
loss=dict(policy=pg_loss, value=vf_loss, total=loss),
policy=dict(entropy=entropy, approxkl=approxkl, clipfrac=pg_clipfrac),
returns=dict(mean=return_mean, var=return_var),
val=dict(vpred=tf.reduce_mean(vpred), error=tf.reduce_mean((vpred - returns) ** 2),
clipfrac=vf_clipfrac, mean=value_mean, var=value_var)
)
return loss, utils.flatten_dict(stats, sep='/')
def make_score_fn(hparams, score_model):
padding_token = score_model.padding_token
postprocess_fn = lm_tasks.postprocess_fn_from_hparams(hparams, padding_token)
#decorate requires a named function, postprocess_fn can be anonymous
@utils.graph_function(responses=Schema(tf.int32, (None, None)))
def postprocess(responses):
return postprocess_fn(responses)
filter_fn = lm_tasks.filter_fn_from_hparams(hparams)
@utils.graph_function(
responses=Schema(tf.int32, (None, None)),
rewards=Schema(tf.float32, (None,)))
def penalize(responses, rewards):
valid = filter_fn(responses)
return tf.where(valid, rewards, hparams.penalty_reward_value * tf.ones_like(rewards))
@utils.graph_function(
queries=Schema(tf.int32, (None, None)),
responses=Schema(tf.int32, (None, None))
)
def unpenalized_score_fn(queries, responses):
return score_model.score_fn(queries, responses)
def score_fn(queries, responses):
responses = postprocess(responses)
score = penalize(responses, unpenalized_score_fn(queries, responses))
return score, responses, dict(score=score)
score_fn.stat_schemas = dict(score=Schema(tf.float32, (None,)))
return score_fn
def train(hparams: HParams):
save_dir = hparams.run.save_dir
if hparams.rewards.train_new_model:
assert hparams.task == hparams.rewards.train_new_model.task, f'{hparams.task} != {hparams.rewards.train_new_model.task}'
hparams.rewards.train_new_model.run.save_dir = save_dir
train_reward.train(hparams.rewards.train_new_model)
if 'pytest' in sys.modules:
hparams.rewards.trained_model = 'test'
elif save_dir:
hparams.rewards.trained_model = None if save_dir is None else os.path.join(save_dir, 'reward_model')
comm = MPI.COMM_WORLD
with tf.Graph().as_default():
hyperparams.dump(hparams)
m = trained_models.TrainedModel(hparams.task.policy.initial_model)
encoder = m.encoding.get_encoder()
hyperparams.dump(m.hparams(), name='model_hparams')
if save_dir:
if not save_dir.startswith('https:'):
os.makedirs(os.path.join(save_dir, 'policy'), exist_ok=True)
with tf.gfile.Open(os.path.join(save_dir, 'train_policy_hparams.json'), 'w') as f:
json.dump(hparams.to_nested_dict(), f, indent=2)
with tf.gfile.Open(os.path.join(save_dir, 'policy', 'hparams.json'), 'w') as f:
json.dump(m.hparams().to_nested_dict(), f, indent=2)
with tf.gfile.Open(os.path.join(save_dir, 'policy', 'encoding'), 'w') as f:
json.dump(m.encoding.name, f, indent=2)
utils.set_mpi_seed(hparams.run.seed)
score_model = TrainedRewardModel(hparams.rewards.trained_model, m.encoding, comm=comm)
ref_policy = Policy(
m, scope='ref_policy',
is_root=comm.Get_rank() == 0,
embed_queries=lm_tasks.query_formatter(hparams.task, encoder),
temperature=hparams.task.policy.temperature,
build_respond=False)
policy = Policy(
m, scope='policy',
is_root=comm.Get_rank() == 0,
embed_queries=lm_tasks.query_formatter(hparams.task, encoder),
temperature=hparams.task.policy.temperature)
query_sampler = lm_tasks.make_query_sampler(
hparams=hparams.task, encoder=encoder, comm=comm,
batch_size=utils.exact_div(hparams.ppo.batch_size, comm.Get_size()),
)
per_rank_minibatch_size = utils.exact_div(hparams.ppo.batch_size, hparams.ppo.nminibatches * comm.Get_size())
if hparams.ppo.whiten_rewards:
assert per_rank_minibatch_size >= 8, \
f"Per-rank minibatch size {per_rank_minibatch_size} is insufficient for whitening"
global_step = tf.train.get_or_create_global_step()
increment_global_step = tf.group(global_step.assign_add(1))
with utils.variables_on_gpu():
ppo_trainer = PPOTrainer(
policy=policy, ref_policy=ref_policy, query_sampler=query_sampler,
score_fn=make_score_fn(hparams.task, score_model=score_model),
hparams=hparams, comm=comm)
if comm.Get_rank() == 0 and save_dir:
print(f"Will save to {save_dir}")
saver = tf.train.Saver(max_to_keep=20, save_relative_paths=True)
checkpoint_dir = os.path.join(save_dir, 'policy/checkpoints/model.ckpt')
else:
saver = None
checkpoint_dir = None
@utils.graph_function()
def sync_models():
score_model.ensure_built()
return utils.variable_synchronizer(comm, vars=score_model.get_params() + ref_policy.get_params() + policy.get_params())
init_ops = tf.group(
tf.global_variables_initializer(),
tf.local_variables_initializer(),
summary.summary_writer_initializer_op())
with utils.mpi_session() as sess:
init_ops.run()
sync_models()
tf.get_default_graph().finalize()
try:
while global_step.eval() < nupdates(hparams):
ppo_trainer.step()
increment_global_step.run()
if saver and global_step.eval() % hparams.run.save_interval == 0:
saver.save(sess, checkpoint_dir, global_step=global_step)
finally:
if saver:
saver.save(sess, checkpoint_dir, global_step=global_step)