QOJDoudizhuModel / learning.py
xcx0902's picture
Upload folder using huggingface_hub
e579420 verified
Raw History Blame Contribute Delete
13.5 kB
"""Fresh-trajectory clipped PPO and weak offline supervision."""
import random
import torch
from torch.nn import functional as F
from qojbot import rules as R
from qojbot.history import latest_log
from qojbot.model_benchmark import play_game
from .features import collate
from .network import score_loss
from .storage import cpu_tree
def terminal_targets(samples, device):
rewards = torch.tensor([sample['reward'] for sample in samples], device=device)
return rewards, rewards / 6.
def losses(model, samples, device, ppo=False, anchor_weight=.05,
history_weight=None, distill_weight=0.):
batch = collate(samples, device)
output = model(batch)
# Batch the candidate softmax to avoid tiny MPS dispatches per state.
counts = [len(sample['actions']) for sample in samples]
cursor = sum(counts)
rows = torch.arange(len(samples), device=device)
local = torch.cat([torch.arange(count) for count in counts]).to(device)
base = torch.cat([sample['base'] for sample in samples]).to(device)
logits = output['prior_scale'][batch['owner']] * base + output['correction']
shape = (len(samples), max(counts))
padded = torch.full(shape, -torch.inf, device=device)
padded[batch['owner'], local] = logits
teacher_padded = torch.full(shape, -torch.inf, device=device)
teacher_padded[batch['owner'], local] = base
valid = torch.arange(max(counts), device=device)[None] < torch.tensor(counts, device=device)[:, None]
log_probs = padded.log_softmax(-1)
probs = log_probs.exp()
teacher_logp = teacher_padded.log_softmax(-1)
teacher = teacher_logp.exp()
safe_logp = log_probs.masked_fill(~valid, 0)
safe_teacher_logp = teacher_logp.masked_fill(~valid, 0)
anchor = (teacher * (safe_teacher_logp - safe_logp)).sum(-1).mean()
entropy = -(probs * safe_logp).sum(-1).mean()
chosen = torch.tensor([sample['selected'] for sample in samples], device=device)
new_logp = log_probs[rows, chosen]
approx_kl = new_logp.sum() * 0
if ppo:
old_logp = torch.tensor([sample['old_logp'] for sample in samples], device=device)
advantage = torch.tensor([sample['advantage'] for sample in samples], device=device)
ratio = (new_logp - old_logp).exp()
policy_loss = -torch.minimum(ratio * advantage, ratio.clamp(.8, 1.2) * advantage).mean()
approx_kl = ((ratio - 1) - (new_logp - old_logp)).mean()
else:
weights = torch.tensor([history_weight if history_weight is not None and
sample.get('origin') == 'history' else sample.get('weight', .15)
for sample in samples], device=device)
policy_loss = -(new_logp * weights).mean()
if distill_weight:
# Reward the frozen strong teacher's preferred action, never the
# unfiltered human action. KL keeps the rest of its ranking intact.
preferred = teacher_padded.argmax(-1)
policy_loss = policy_loss - distill_weight * log_probs[rows, preferred].mean()
offsets, offset = [], 0
for count, sample in zip(counts, samples):
offsets.append(offset + sample['selected'])
offset += count
selected = torch.tensor(offsets, device=device)
rewards, transformed = terminal_targets(samples, device)
# Historical returns describe another behavior policy; use histories for
# public-state distillation/beliefs, actual on-policy/teacher games for value.
trusted = [i for i, s in enumerate(samples) if history_weight != 0 or s.get('origin') != 'history']
zero = output['value'].sum() * 0 + output['score_logits'].sum() * 0 + output['win_logits'].sum() * 0
value_loss = action_value_loss = win_loss = zero
if trusted:
value_loss = F.smooth_l1_loss(output['value'][trusted], transformed[trusted])
action_value_loss = score_loss(output['score_logits'][selected[trusted]], rewards[trusted], model.support)
win_loss = F.binary_cross_entropy_with_logits(output['win_logits'][selected[trusted]],
(rewards[trusted] > 0).float())
belief_indices = [i for i, sample in enumerate(samples) if 'belief_target' in sample and sample['role'] != 3]
belief_loss = output['value'].sum() * 0
if belief_indices:
targets = torch.stack([samples[i]['belief_target'] for i in belief_indices]).to(device)
# Counts, not rounded hand assignments: supervision is inaccessible at inference.
belief_loss = F.smooth_l1_loss(output['belief_counts'][belief_indices], targets)
loss = (policy_loss + anchor_weight * anchor + .5 * value_loss +
.15 * action_value_loss + .05 * win_loss + .05 * belief_loss -
(.01 * entropy if ppo else 0))
stats = {'loss': float(loss.detach().cpu()), 'policy': float(policy_loss.detach().cpu()),
'anchor_kl': float(anchor.detach().cpu()), 'value': float(value_loss.detach().cpu()),
'belief': float(belief_loss.detach().cpu()),
'approx_kl': float(approx_kl.detach().cpu()) if ppo else 0.,
'samples': len(samples), 'actions': cursor}
return loss, stats
def update(model, optimizer, samples, device, ppo=False, anchor_weight=.05,
history_weight=None, distill_weight=0.):
model.train()
loss, stats = losses(model, samples, device, ppo, anchor_weight, history_weight, distill_weight)
if not torch.isfinite(loss):
raise RuntimeError('nonfinite training loss')
optimizer.zero_grad(set_to_none=True)
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 5.)
if not torch.isfinite(norm):
raise RuntimeError('nonfinite training gradients')
optimizer.step()
return stats
def batches(samples, batch_states, max_actions=4096):
pending, actions = [], 0
for sample in samples:
count = len(sample['actions'])
if pending and (len(pending) >= batch_states or actions + count > max_actions):
yield pending
pending, actions = [], 0
pending.append(sample)
actions += count
if pending:
yield pending
class Actor:
def __init__(self, policy, rng, teacher=False):
self.policy, self.rng, self.teacher = policy, rng, teacher
self.pending = []
def select(self, candidates, sample, state):
if self.teacher:
logits, value = sample['base'], 0.
selected = int(logits.argmax())
else:
logits, value = self.policy.predict(sample)
probs = logits.softmax(0).tolist()
selected = self.rng.choices(range(len(probs)), weights=probs)[0]
sample.update(selected=selected, old_logp=float(logits.log_softmax(0)[selected]),
old_value=value, weight=1., origin='synthetic')
if not self.teacher:
sample['old_log_probs'] = logits.log_softmax(0)
attempt = sum(event.get('kind') == 'redeal' for event in state.get('log', []))
self.pending.append((sample, state['seat'], attempt))
return candidates[selected]
def choose(self, obs, state):
candidates, sample = self.policy.prepare(obs, state)
return self.select(candidates, sample, state)
def choose_bid(self, state):
candidates, sample = self.policy.prepare_bid(state)
return self.select(candidates, sample, state)
def finish(self, game):
hands = [list(hand) for hand in game['initial_hands']]
snapshots = {}
step = 0
for event in latest_log(game):
if event.get('kind') in ('play', 'pass'):
snapshots[step] = [list(hand) for hand in hands]
step += 1
if event['kind'] == 'play':
for card in event['cards']:
hands[event['seat']].remove(card)
from .dataset import belief_labels
samples, play_index = [], 0
final_attempt = game['redeals']
for sample, seat, attempt in self.pending:
if attempt != final_attempt:
continue
sample['reward'] = float(game['deltas'][seat])
# Actor can occupy multiple seats. Map samples to physical play events.
if sample['role'] != 3:
events = [event for event in latest_log(game)
if event.get('kind') in ('play', 'pass')]
while play_index < len(events) and events[play_index]['seat'] != seat:
play_index += 1
if play_index < len(events):
belief_labels(sample, snapshots[play_index], seat, game['landlord'])
play_index += 1
samples.append(sample)
self.pending.clear()
return samples
class SearchOpponent:
def __init__(self, seconds=.03):
from qojbot.policy import SearchPolicy
self.policy = SearchPolicy(seconds=seconds, samples=4)
def choose(self, obs, state):
return self.policy.choose(obs)
def choose_bid(self, state):
from qojbot.policy import assess_bid
return assess_bid(R.from_cards(state['hand']), int(state.get('bid') or 0),
bool(state.get('must_bid')))['value']
def collect(policy, games, index, seed, rng, mode='bidding', league=(), opponent=None,
targeted=False):
"""Current learner vs teacher, snapshot league, search and self-play."""
all_samples, records = [], []
target = opponent
for offset in range(games):
number = index + offset
learner = Actor(policy, rng)
kind = number % (8 if targeted else 6)
landlord = (number // 24) % 3 if targeted else number % 3
relative = (number // 8) % 3 if targeted else (number // 3) % 3
singleton = (landlord + relative) % 3
if kind == 0 and not targeted:
opponent = SearchOpponent()
elif ((kind == 7 if targeted else kind == 2) and league):
from .policy import QOJPolicy
opponent = QOJPolicy(device=str(policy.device), teacher=policy.teacher,
config=policy.model.config)
opponent.model.load_state_dict(league[rng.randrange(len(league))])
else:
opponent = target or policy.teacher
lineup = ['learner'] * 3
if kind < (3 if targeted else 2):
lineup = ['opponent'] * 3
lineup[singleton] = 'learner'
elif (kind in (3, 4, 5, 7) if targeted else kind < 4):
lineup[singleton] = 'opponent'
record = play_game({'learner': learner, 'opponent': opponent}, lineup, seed + number,
first_bidder=landlord, mode=mode)
all_samples.extend(learner.finish(record))
records.append(record)
print(f'collected game {number + 1}, turns={record["turns"]}, opponent={type(opponent).__name__}', flush=True)
advantages = torch.tensor([s['reward'] / 6. - s['old_value']
for s in all_samples])
# Role-wise scaling avoids the landlord's x2 score dominating farmer updates.
for role in range(4):
indices = [i for i, s in enumerate(all_samples) if s['role'] == role]
if indices:
local = advantages[indices]
std = local.std(unbiased=False).clamp_min(.5)
local = (local - local.mean()) / std if len(indices) > 1 else local / std
for i, advantage in zip(indices, local.tolist()):
all_samples[i]['advantage'] = advantage
return all_samples, records
def rollout_kl(policy, samples, batch_states=32, max_actions=4096):
"""Exact old||new KL over every legal action on the complete rollout."""
total, count = 0., 0
policy.model.eval()
with torch.no_grad():
for samples_batch in batches(samples, batch_states, max_actions):
batch = collate(samples_batch, policy.device)
output = policy.model(batch)
counts = [len(s['actions']) for s in samples_batch]
local = torch.cat([torch.arange(n) for n in counts]).to(policy.device)
base = torch.cat([s['base'] for s in samples_batch]).to(policy.device)
logits = output['prior_scale'][batch['owner']] * base + output['correction']
shape = (len(counts), max(counts))
padded = torch.full(shape, -torch.inf, device=policy.device)
padded[batch['owner'], local] = logits
old = torch.full(shape, -torch.inf, device=policy.device)
old[batch['owner'], local] = torch.cat([s['old_log_probs'] for s in samples_batch]).to(policy.device)
valid = torch.arange(max(counts), device=policy.device)[None] < torch.tensor(counts, device=policy.device)[:, None]
difference = old.masked_fill(~valid, 0) - padded.log_softmax(-1).masked_fill(~valid, 0)
total += float((old.exp() * difference).sum().cpu())
count += len(counts)
return max(0., total / max(count, 1))
def warmup(policy, games, seed, rng):
actor = Actor(policy, rng, teacher=True)
samples, records = [], []
for index in range(games):
record = play_game({'teacher': actor}, ['teacher'] * 3, seed + index,
first_bidder=index % 3, mode='bidding')
samples.extend(actor.finish(record))
records.append(record)
print(f'teacher warmup {index + 1}/{games}', flush=True)
return samples, records