RL: pure-Python inferencia natrenovanej siete

py -m rl.export vyexportuje checkpoint do rl/weights/neural-bot.json
(bit-exact float32, 1.3 MB) a rl/pure_net.py ho hra bez torch/numpy
(stdlib forward pass, ~16 ms/tah). Testy parity: logity aj akcie sa
zhoduju s torch, identicke trajektorie celych kol. Natrenovany model:
6.7-6.9 b/kolo proti vsetkym baseline-om (heuristika prekonana).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
timandClaude Fable 5 committed 2026-07-07 18:49:55 +02:00
1 parent 8f2449a408
commit 9a750756c5
4 files changed
+339

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
"""Export vah natrenovanej siete do formatu pre cisto-Python inferenciu.
Torch je len trenovacia zavislost (host); produkcia hra cez rl/pure_net.py,
ktory cita tento subor bez torch/numpy. Vahy sa uladaju ako base64 float32
little-endian (bit-exact kopia checkpointu, ziadna strata presnosti).
Pouzitie:
py -m rl.export rl/checkpoints/latest.pt rl/weights/neural-bot.json
"""
import argparse
import base64
import json
import os
from rl.encoding import OBS_DIM
from rl.train import load_checkpoint
DEFAULT_WEIGHTS_PATH = os.path.join('rl', 'weights', 'neural-bot.json')
def _pack(tensor) -> dict:
"""Tensor -> {shape, base64 float32 LE}. Cez struct, bez numpy -- tolist()
vracia presne hodnoty float32, takze zapis je bit-exact."""
import struct
data = tensor.detach().to('cpu').contiguous().float()
flat = data.reshape(-1).tolist()
return {
'shape': list(data.shape),
'data': base64.b64encode(struct.pack(f'<{len(flat)}f', *flat)).decode('ascii'),
}
def export(checkpoint_path: str, out_path: str) -> dict:
net = load_checkpoint(checkpoint_path)
hidden = net.trunk[0].out_features
payload = {
'obs_dim': OBS_DIM,
'hidden': hidden,
'weights': {
'trunk0_w': _pack(net.trunk[0].weight),
'trunk0_b': _pack(net.trunk[0].bias),
'trunk2_w': _pack(net.trunk[2].weight),
'trunk2_b': _pack(net.trunk[2].bias),
'guess_w': _pack(net.guess_head.weight),
'guess_b': _pack(net.guess_head.bias),
'play_w': _pack(net.play_head.weight),
'play_b': _pack(net.play_head.bias),
},
}
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, 'w') as f:
json.dump(payload, f)
return payload
def main():
parser = argparse.ArgumentParser(description='Export vah pre pure-Python inferenciu')
parser.add_argument('checkpoint', nargs='?', default='rl/checkpoints/latest.pt')
parser.add_argument('out', nargs='?', default=DEFAULT_WEIGHTS_PATH)
args = parser.parse_args()
payload = export(args.checkpoint, args.out)
size = os.path.getsize(args.out)
print(f'Exportovane: {args.checkpoint} (hidden={payload["hidden"]}) '
f'-> {args.out} ({size / 1024:.0f} kB)')
if __name__ == '__main__':
main()
+114
View File
@@ -0,0 +1,114 @@
"""Cisto-Python inferencia natrenovanej siete (stdlib only, bez torch/numpy).
Nacita vahy z exportu rl/export.py a implementuje forward pass MLP
(trunk 2x ReLU + guess/play hlavy). Sluzi produkcnym botom v api/bots.py --
torch ostava len trenovacia zavislost na hoste. Presnost overuje
tests/test_pure_net.py porovnanim s torch vystupmi na zivych observaciach.
Vykon: ~250k nasobeni na tah (~desiatky ms) -- pri pauze medzi tahmi bota
(BOT_MOVE_DELAY_SECONDS) nepostrehnutelne.
"""
import base64
import json
import os
import struct
from rl.encoding import (
N_GUESS_ACTIONS, N_PLAY_ACTIONS, OBS_DIM,
encode_observation, guess_mask, play_mask,
)
DEFAULT_WEIGHTS_PATH = os.path.join(
os.path.dirname(__file__), 'weights', 'neural-bot.json'
)
def _unpack(entry: dict):
"""{shape, base64 f32 LE} -> matica (list riadkov) alebo vektor."""
flat = list(struct.unpack(
f'<{_numel(entry["shape"])}f', base64.b64decode(entry['data'])
))
shape = entry['shape']
if len(shape) == 1:
return flat
rows, cols = shape
return [flat[r * cols:(r + 1) * cols] for r in range(rows)]
def _numel(shape: list) -> int:
n = 1
for dim in shape:
n *= dim
return n
def _linear(weight, bias, x):
"""weight (out x in) @ x + bias -- radove poradie ako torch.nn.Linear."""
return [sum(w * v for w, v in zip(row, x)) + b
for row, b in zip(weight, bias)]
def _relu(x):
return [v if v > 0.0 else 0.0 for v in x]
class PureNet:
def __init__(self, payload: dict):
if payload['obs_dim'] != OBS_DIM:
raise ValueError(
f'Vahy su pre obs_dim={payload["obs_dim"]}, kod ma {OBS_DIM} '
'-- treba re-export z aktualneho checkpointu.'
)
w = payload['weights']
self.trunk0_w = _unpack(w['trunk0_w'])
self.trunk0_b = _unpack(w['trunk0_b'])
self.trunk2_w = _unpack(w['trunk2_w'])
self.trunk2_b = _unpack(w['trunk2_b'])
self.guess_w = _unpack(w['guess_w'])
self.guess_b = _unpack(w['guess_b'])
self.play_w = _unpack(w['play_w'])
self.play_b = _unpack(w['play_b'])
@classmethod
def load(cls, path: str = DEFAULT_WEIGHTS_PATH) -> 'PureNet':
with open(path) as f:
return cls(json.load(f))
def _trunk(self, obs):
h = _relu(_linear(self.trunk0_w, self.trunk0_b, obs))
return _relu(_linear(self.trunk2_w, self.trunk2_b, h))
def guess_logits(self, obs) -> list:
return _linear(self.guess_w, self.guess_b, self._trunk(obs))
def play_logits(self, obs) -> list:
return _linear(self.play_w, self.play_b, self._trunk(obs))
def _masked_argmax(logits: list, mask: list) -> int:
best, best_value = None, None
for i, allowed in enumerate(mask):
if allowed and (best is None or logits[i] > best_value):
best, best_value = i, logits[i]
return best
class PureNeuralPlayer:
"""Greedy hrac nad PureNet -- rovnake rozhranie a rovnake vstupy
(observacia + maska) ako rl.policy_player.NeuralPlayer(greedy=True)."""
def __init__(self, net: PureNet):
self.net = net
@classmethod
def load(cls, path: str = DEFAULT_WEIGHTS_PATH) -> 'PureNeuralPlayer':
return cls(PureNet.load(path))
def guess(self, rnd, seat: int) -> int:
obs = encode_observation(rnd, seat)
return _masked_argmax(self.net.guess_logits(obs), guess_mask(rnd))
def play(self, rnd, seat: int) -> int:
obs = encode_observation(rnd, seat)
return _masked_argmax(self.net.play_logits(obs), play_mask(rnd, seat))
+1
View File
@@ -0,0 +1 @@
{"obs_dim": 233, "hidden": 384, "weights": {"trunk0_w": {"shape": [384, 233], "data": "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 truncated
+155
View File
@@ -0,0 +1,155 @@
"""Testy presnosti cisto-Python inferencie (rl/pure_net.py) voci torch.
Jadro suity: na zivych observaciach z nahodne rozohranych kol sa porovnavaju
logity a zvolene akcie pure-Python siete s torch sietou nacitanou z toho
isteho checkpointu. Case bez torch (cisty beh, legalnost, determinizmus)
bezia vzdy; porovnavacie case sa preskocia, ak torch nie je nainstalovany.
"""
import copy
import os
import unittest
from random import Random
from bridzik import Round
from rl.encoding import encode_observation, guess_mask, index_card, play_mask
from rl.env import PHASE_GUESS, RoundEnv
from rl.evaluate import play_round
from rl.players import RandomPlayer
from rl.pure_net import DEFAULT_WEIGHTS_PATH, PureNet, PureNeuralPlayer
WEIGHTS_AVAILABLE = os.path.exists(DEFAULT_WEIGHTS_PATH)
try:
import torch
from rl.policy_player import NeuralPlayer
from rl.train import load_checkpoint
TORCH_AVAILABLE = True
except ImportError: # pragma: no cover
TORCH_AVAILABLE = False
CHECKPOINT = os.path.join('rl', 'checkpoints', 'latest.pt')
def _random_decision_points(rng, n_rounds=12):
"""Vygeneruje zive rozhodovacie body (rnd, seat, faza) nahodnou hrou."""
env = RoundEnv(rng)
points = []
for i in range(n_rounds):
decision = env.reset(round_number=i % 8)
while True:
# snapshot -- env.round sa dalsou hrou mutuje
points.append((copy.deepcopy(env.round), decision.player, decision.phase))
action = rng.choice([a for a, ok in enumerate(decision.mask) if ok])
decision, rewards, done = env.step(action)
if done:
break
return points
@unittest.skipUnless(WEIGHTS_AVAILABLE, 'chyba export vah (py -m rl.export)')
class PureOnlyCase(unittest.TestCase):
"""Bezi aj bez torch -- presne to, co pobezi v produkcii."""
@classmethod
def setUpClass(cls):
cls.player = PureNeuralPlayer.load()
def test_plays_legal_full_rounds(self):
env = RoundEnv(Random(1))
players = [self.player, self.player,
RandomPlayer(Random(2)), RandomPlayer(Random(3))]
for round_number in range(8):
rewards = play_round(players, env, round_number)
self.assertEqual(len(rewards), 4)
def test_deterministic(self):
r = Round(2, 0)
self.assertEqual(self.player.guess(r, 0), self.player.guess(r, 0))
def test_respects_masks(self):
rng = Random(4)
for rnd, seat, phase in _random_decision_points(rng, n_rounds=8):
if phase == PHASE_GUESS:
self.assertTrue(guess_mask(rnd)[self.player.guess(rnd, seat)])
else:
self.assertTrue(play_mask(rnd, seat)[self.player.play(rnd, seat)])
@unittest.skipUnless(WEIGHTS_AVAILABLE and TORCH_AVAILABLE
and os.path.exists(CHECKPOINT),
'treba torch + checkpoint + export vah')
class TorchParityCase(unittest.TestCase):
"""Zhoda pure-Python inferencie s torch na tom istom checkpointe."""
@classmethod
def setUpClass(cls):
cls.pure = PureNet.load()
cls.torch_net = load_checkpoint(CHECKPOINT)
cls.torch_net.eval()
cls.points = _random_decision_points(Random(7), n_rounds=16)
def _torch_logits(self, obs, phase):
with torch.no_grad():
guess_logits, play_logits, _ = self.torch_net(
torch.tensor(obs, dtype=torch.float32).unsqueeze(0)
)
t = guess_logits if phase == PHASE_GUESS else play_logits
return t.squeeze(0).tolist()
def test_logits_match(self):
"""Logity sa zhoduju na ~1e-4 (rozdiel = len poradie scitovania
float32 vs float64, ziadna strata z exportu -- vahy su bit-exact)."""
worst = 0.0
for rnd, seat, phase in self.points:
obs = encode_observation(rnd, seat)
pure = self.pure.guess_logits(obs) if phase == PHASE_GUESS \
else self.pure.play_logits(obs)
ref = self._torch_logits(obs, phase)
for a, b in zip(pure, ref):
worst = max(worst, abs(a - b))
self.assertLess(worst, 1e-3, f'najvacsi rozdiel logitov: {worst}')
def test_actions_match(self):
"""Zvolena akcia je identicka vzdy, ked nejde o numericku remizu
(top-2 logity blizsie nez 1e-3 -- prakticky nenastava)."""
player = PureNeuralPlayer(self.pure)
torch_player = NeuralPlayer(self.torch_net, greedy=True)
compared = ties = 0
for rnd, seat, phase in self.points:
obs = encode_observation(rnd, seat)
if phase == PHASE_GUESS:
a, b = player.guess(rnd, seat), torch_player.guess(rnd, seat)
mask = guess_mask(rnd)
logits = self.pure.guess_logits(obs)
else:
a, b = player.play(rnd, seat), torch_player.play(rnd, seat)
mask = play_mask(rnd, seat)
logits = self.pure.play_logits(obs)
allowed = sorted((logits[i] for i in range(len(mask)) if mask[i]),
reverse=True)
if len(allowed) > 1 and allowed[0] - allowed[1] < 1e-3:
ties += 1 # numericka remiza -- volba je legitimne lubovolna
continue
compared += 1
self.assertEqual(a, b, f'akcie sa lisia mimo remizy ({phase})')
self.assertGreater(compared, 50) # test realne porovnaval
def test_full_rounds_identical_trajectories(self):
"""Dve identicke partie: pure aj torch hrac na vsetkych 4 sedadlach
s rovnakym rozdanim musia zahrat uplne rovnake kolo."""
pure_player = PureNeuralPlayer(self.pure)
torch_player = NeuralPlayer(self.torch_net, greedy=True)
for round_number in range(8):
results = []
for player in (pure_player, torch_player):
env = RoundEnv(Random(100 + round_number))
rewards = play_round([player] * 4, env, round_number)
results.append((rewards,
sorted(str(s.get_cards())
for s in env.round.stashes)))
self.assertEqual(results[0], results[1])
if __name__ == '__main__':
unittest.main(verbosity=2)