Add observation_function for full custom control over the observation

Lets a game define how its state becomes an observation as a plain
Python function (module:attr), used identically by GameEnvironment
(training) and TrainedPolicy (inference) instead of two independently
maintained encoding paths. Removes the egocentric/egocentric_player/
egocentric_radius flags — cropping is now something an
observation_function does itself by calling egocentric_board(), and
extras_size is discovered from one sampled observation instead of
being configured via observe_state_sizes.
This commit is contained in:
Chris Proctor
2026-06-23 20:45:48 -04:00
parent 426e59a54e
commit 0cd3c3b488
14 changed files with 479 additions and 205 deletions

View File

@@ -7,7 +7,7 @@ Game description
---------------- ----------------
.. autoclass:: retro_gamer.GameMetadata .. autoclass:: retro_gamer.GameMetadata
:members: from_pyproject, from_dict, validate :members: from_pyproject, from_dict, validate, resolve_observation_function
Training Training
-------- --------

View File

@@ -351,6 +351,13 @@ engineering decisions live: what derived quantities should the agent
see, and does giving it those values give it an advantage a human see, and does giving it those values give it an advantage a human
player would not have? player would not have?
``character_set``/``observe_state`` cover the common cases, but
sometimes you want full control over how the board becomes numbers — for
example, cropping it to a window centered on the agent rather than always
seeing the whole thing. ``observation_function`` (see :doc:`reference`) lets
you write that transformation as ordinary code instead of a combination of
flags.
Neural network architectures Neural network architectures
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -100,9 +100,10 @@ matters.
**Observation design** determines what information is available to the **Observation design** determines what information is available to the
agent. If you leave a character out of the ``character_set``, the agent agent. If you leave a character out of the ``character_set``, the agent
will not distinguish it from empty space. If the game module defines a will not distinguish it from empty space. If you list keys in
``get_state()`` function, the agent also receives those computed values ``observe_state``, the agent also receives those computed values as part
as part of its observation. The consequences of these choices for what of its observation — or, for full control, an ``observation_function`` can
replace the encoding entirely. The consequences of these choices for what
the agent can learn are reasonably predictable — and making and checking the agent can learn are reasonably predictable — and making and checking
those predictions is exactly the kind of reasoning the tool is designed those predictions is exactly the kind of reasoning the tool is designed
to support. to support.

View File

@@ -135,57 +135,70 @@ or tuples must always have the same length from episode to episode.
Always initialize every observed key with a placeholder of the Always initialize every observed key with a placeholder of the
correct type and length before the first ``game.step()`` call. correct type and length before the first ``game.step()`` call.
``observe_state_sizes`` (auto-discovered) .. _observation-function:
``observation_function`` (default: none)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A table mapping each ``observe_state`` key to its flat size (``1`` for **Optional**, set in ``[metadata]`` (alongside ``actions``/``reward``/
scalars, ``N`` for sequences of length N). This is written automatically ``character_set``/``board_size``), not in ``[preprocessing]``. A
to ``config.toml`` the first time ``retro-gamer train`` runs, after the ``"module:attr"`` string naming a function ``f(game) -> observation`` that
trainer samples ``game.state`` to discover the actual sizes: fully replaces the built-in board/``observe_state`` encoding described
above. Mutually exclusive with ``observe_state`` — they're two conflicting
ways of describing the same thing, and setting both raises an error.
.. code-block:: toml .. code-block:: toml
observe_state_sizes = {board_state = 9} [metadata]
observation_function = "my_game:get_observation"
You do not need to set this manually. Once written, it is used to For DQN training, the function must return a flat, numeric, fixed-length
detect changes in state shape when resuming training—an incompatible 1-D array every time it's called — the same contract the built-in encoder
change here requires running ``retro-gamer clean`` and starting fresh. follows: a flattened one-hot board (sized from ``character_set`` ×
``board_size``, if ``board = true``) followed by any extra features, all in
one vector. ``character_set`` and ``board_size`` stay required either way,
because that's what lets ``observation_function`` also use a spatial
(``spatial = true``) network — the trainer slices the flat vector back into
a board tensor using exactly those two fields, the same way it does for the
built-in encoder. The size of whatever comes after the board (``extras_size``)
is not configured; it's measured automatically from one sampled observation
when training starts.
``egocentric`` (default: ``false``) This is also how you get an egocentric (cropped, player-centered) board now —
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ there's no longer a built-in flag for it. Call ``egocentric_board()`` and
``encode_board()`` yourself, from :mod:`retro_gamer.observation`, inside your
own function, and declare ``board_size`` to match your crop:
When ``true``, the board observation is cropped to a square window .. code-block:: python
centred on a specific agent rather than the full board. This gives the
agent a local, first-person-like view and makes the observation
invariant to the agent's absolute position on the board.
Requires ``egocentric_player`` and ``egocentric_radius``. import numpy as np
from retro.views.headless import HeadlessView
from retro_gamer.observation import egocentric_board, encode_board, encode_state
``egocentric_player`` CHARACTER_SET = ["@", "*", ">", "<", "^", "v"]
~~~~~~~~~~~~~~~~~~~~~~ RADIUS = 8
The name of the agent to use as the centre of the egocentric crop. def egocentric_observation(game):
Must match the ``name`` attribute of one of the game's agents. view = HeadlessView()
view.on_game_start(game)
view.render(game)
head = game.get_agent_by_name("Snake head")
cropped = egocentric_board(view.board_characters, head.position, RADIUS)
board_vec = encode_board(cropped, CHARACTER_SET).flatten()
extras = encode_state(game.state, ["apple_dx", "apple_dy"])
return np.concatenate([board_vec, extras])
.. code-block:: toml .. code-block:: toml
egocentric_player = "Snake head" [metadata]
board_size = [17, 17] # 2*RADIUS + 1
observation_function = "my_module:egocentric_observation"
``egocentric_radius`` Outside DQN training — for example, BabySnake's tabular Q-learning lab, which
~~~~~~~~~~~~~~~~~~~~~~ uses :class:`~retro_gamer.GameEnvironment` directly without
:class:`~retro_gamer.DQNTrainer` — there's no 1-D requirement at all.
The half-side-length of the egocentric crop window, in cells. The ``observation_function`` can return anything you want to use as your
resulting observation covers a ``(2r+1) × (2r+1)`` region. Larger observation, including a plain tuple used as a dict key.
values give the agent a wider view; smaller values focus it on the
immediate vicinity.
.. code-block:: toml
egocentric_radius = 8 # 17×17 window
When ``egocentric_radius`` is set, ``board_size`` in ``[metadata]`` is
automatically updated to ``[2r+1, 2r+1]`` so the network is sized
correctly.
.. _hyperparameters: .. _hyperparameters:
@@ -367,11 +380,11 @@ prints a message and exits immediately. To keep training, increase
unusable. If you change any of the following, ``retro-gamer train`` will unusable. If you change any of the following, ``retro-gamer train`` will
detect the mismatch and refuse to resume, with a clear explanation: detect the mismatch and refuse to resume, with a clear explanation:
- ``actions``, ``reward``, ``character_set``, ``board_size`` - ``actions``, ``reward``, ``character_set``, ``board_size``,
(``[metadata]``) — game description ``observation_function``, ``extras_size`` (``[metadata]``) — game
- ``spatial``, ``board``, ``observe_state``, ``observe_state_sizes``, description and observation shape
``egocentric``, ``egocentric_player``, ``egocentric_radius`` - ``spatial``, ``board``, ``observe_state`` (``[preprocessing]``) —
(``[preprocessing]``) — observation encoding observation encoding
- ``hidden_sizes`` (``[model]``) — network architecture - ``hidden_sizes`` (``[model]``) — network architecture
Run ``retro-gamer clean RUN_DIR`` to remove the old checkpoints and start Run ``retro-gamer clean RUN_DIR`` to remove the old checkpoints and start

View File

@@ -127,9 +127,11 @@ The number of exploration turns is controlled by the
The ``[tool.retro-gamer]`` section describes the game. Preprocessing The ``[tool.retro-gamer]`` section describes the game. Preprocessing
options—such as ``spatial`` (whether to use a CNN or MLP, default: options—such as ``spatial`` (whether to use a CNN or MLP, default:
``false``), ``egocentric``, and ``observe_state``—live in the ``false``) and ``observe_state``—live in the ``[preprocessing]`` section of
``[preprocessing]`` section of the generated ``config.toml``. You can the generated ``config.toml``. You can edit them there after running
edit them there after running ``retro-gamer create``. ``retro-gamer create``. For full control over the observation (for example,
a cropped/egocentric board), write an ``observation_function`` instead — see :ref:`observation-function` in the
reference docs for details.
``observe_state`` ``observe_state``
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
@@ -408,15 +410,14 @@ checkpoints remain valid:
game or the shape of the network. The saved model weights are game or the shape of the network. The saved model weights are
incompatible with the new configuration: incompatible with the new configuration:
- ``actions``, ``reward``, ``character_set``, ``board_size`` - ``actions``, ``reward``, ``character_set``, ``board_size``,
(``[metadata]``) — These define what the agent perceives and what it ``observation_function``, ``extras_size`` (``[metadata]``) — These define
can do. Changing them changes the size of the network's input or what the agent perceives and what it can do. Changing them changes the
output layers; the existing weights no longer fit. size of the network's input or output layers; the existing weights no
- ``spatial``, ``board``, ``observe_state``, ``observe_state_sizes``, longer fit.
``egocentric``, ``egocentric_player``, ``egocentric_radius`` - ``spatial``, ``board``, ``observe_state`` (``[preprocessing]``) — These
(``[preprocessing]``) — These control how the observation is control how the observation is constructed. Any change here alters the
constructed. Any change here alters the input shape or meaning and input shape or meaning and makes existing weights invalid.
makes existing weights invalid.
- ``hidden_sizes`` (``[model]``) — This defines the network's hidden - ``hidden_sizes`` (``[model]``) — This defines the network's hidden
layers. Changing it changes the shape of the network; the existing layers. Changing it changes the shape of the network; the existing
weights no longer fit. weights no longer fit.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "retro-gamer" name = "retro-gamer"
version = "0.1.1" version = "0.2.0"
description = "A toolkit for learning reinforcement learning by training agents to play retro games" description = "A toolkit for learning reinforcement learning by training agents to play retro games"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -13,6 +13,12 @@ from retro_gamer.trainer import DQNTrainer, DEFAULTS, MODEL_KEYS
@click.group() @click.group()
def cli(): def cli():
"""Train and run RL agents for retro games.""" """Train and run RL agents for retro games."""
# Running the installed console script puts its own directory on sys.path,
# not the caller's cwd — but observation_function/game modules are
# typically plain files in the directory you ran retro-gamer from.
cwd = str(Path.cwd())
if cwd not in sys.path:
sys.path.insert(0, cwd)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -79,8 +85,9 @@ def create(game, output, **hyperparams):
raise click.ClickException(str(e)) raise click.ClickException(str(e))
game_factory = _load_factory(game_config) game_factory = _load_factory(game_config)
g = game_factory() if metadata.board_size is None:
metadata.board_size = g.board_size g = game_factory()
metadata.board_size = g.board_size
metadata.validate() metadata.validate()
@@ -108,6 +115,8 @@ def create(game, output, **hyperparams):
else: else:
click.echo(f" characters : (will be auto-discovered during training)") click.echo(f" characters : (will be auto-discovered during training)")
click.echo(f" architecture: {'CNN (spatial)' if metadata.spatial else 'MLP (non-spatial)'}") click.echo(f" architecture: {'CNN (spatial)' if metadata.spatial else 'MLP (non-spatial)'}")
if metadata.observation_function:
click.echo(f" observation : {metadata.observation_function} (custom)")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import random import random
import numpy as np
from typing import Callable from typing import Callable
from retro.input import ProgrammaticInput from retro.input import ProgrammaticInput
from retro.views.headless import HeadlessView from retro.views.headless import HeadlessView
@@ -9,34 +8,49 @@ from retro_gamer.observation import encode_observation
class GameEnvironment: class GameEnvironment:
"""Gym-style wrapper around a retro game for RL training.""" """Gym-style wrapper around a retro game for RL training.
The observation returned by reset()/step() comes from one of two mutually
exclusive paths: metadata.observation_function, if set, fully replaces the
built-in board/observe_state encoding — see GameMetadata for its contract.
Otherwise the built-in encoder (encode_observation) is used, configured by
observe_state/board/observe_state_sizes below.
"""
def __init__( def __init__(
self, self,
game_factory: Callable, game_factory: Callable,
metadata: GameMetadata, metadata: GameMetadata,
observe_state: list[str] | None = None, observe_state: list[str] | None = None,
egocentric: bool = False,
egocentric_player: str | None = None,
egocentric_radius: int | None = None,
board: bool = True, board: bool = True,
observe_state_sizes: dict[str, int] | None = None, observe_state_sizes: dict[str, int] | None = None,
): ):
self.game_factory = game_factory self.game_factory = game_factory
self.metadata = metadata self.metadata = metadata
self.observe_state = observe_state or [] self.observe_state = observe_state or []
self.egocentric = egocentric
self.egocentric_player = egocentric_player
self.egocentric_radius = egocentric_radius
self.board = board self.board = board
self.observe_state_sizes = observe_state_sizes or {} self.observe_state_sizes = observe_state_sizes or {}
self._observation_fn = metadata.resolve_observation_function()
if self._observation_fn is not None and self.observe_state:
raise ValueError(
"Both metadata.observation_function and [preprocessing].observe_state "
"are set, but they're two conflicting ways of describing the\n"
"observation. Use observation_function for full custom control, or\n"
"observe_state (with the built-in board encoder) but not both."
)
self.game = None self.game = None
self.view: HeadlessView | None = None self.view: HeadlessView | None = None
self.inp: ProgrammaticInput | None = None self.inp: ProgrammaticInput | None = None
self._prev_reward: float = 0.0 self._prev_reward: float = 0.0
def reset(self) -> np.ndarray: def reset(self):
"""Create a fresh game episode and return the initial observation.""" """Create a fresh game episode and return the initial observation.
The observation's type depends on metadata.observation_function: a
numpy array when using the built-in encoder (or a custom function
built for DQN training), but it can be anything a custom function
returns — e.g. a plain tuple for tabular use.
"""
self.inp = ProgrammaticInput() self.inp = ProgrammaticInput()
self.view = HeadlessView() self.view = HeadlessView()
self.game = self.game_factory() self.game = self.game_factory()
@@ -46,7 +60,7 @@ class GameEnvironment:
self._prev_reward = float(self.game.state.get(self.metadata.reward, 0)) self._prev_reward = float(self.game.state.get(self.metadata.reward, 0))
return self._observe() return self._observe()
def step(self, action: str | None) -> tuple[np.ndarray, float, bool]: def step(self, action: str | None) -> tuple:
"""Advance one turn. Returns (observation, reward, done).""" """Advance one turn. Returns (observation, reward, done)."""
self.inp.press(action) self.inp.press(action)
self.game.step() self.game.step()
@@ -55,48 +69,18 @@ class GameEnvironment:
done = not self.game.playing done = not self.game.playing
return obs, reward, done return obs, reward, done
def _observe(self) -> np.ndarray: def _observe(self):
if self._observation_fn is not None:
return self._observation_fn(self.game)
state = dict(self.game.state) state = dict(self.game.state)
if self.observe_state_sizes:
self._check_state_sizes(state)
player_pos = None
if self.egocentric and self.egocentric_player:
agent = self.game.get_agent_by_name(self.egocentric_player)
if agent is not None:
player_pos = agent.position
return encode_observation( return encode_observation(
self.view.board_characters, self.view.board_characters,
state, state,
self.metadata, self.metadata,
self.observe_state, self.observe_state,
player_pos=player_pos,
egocentric_radius=self.egocentric_radius,
board=self.board, board=self.board,
) )
def _check_state_sizes(self, state: dict):
for key, expected in self.observe_state_sizes.items():
val = state.get(key)
if val is None:
actual = 0
elif isinstance(val, (list, tuple)):
actual = len(val)
else:
actual = 1
if actual != expected:
raise ValueError(
f"State key '{key}' changed size during training:\n"
f" Expected : {expected} (discovered at training start)\n"
f" Got : {actual}\n\n"
f"This means game.state['{key}'] has a different length in some\n"
f"episodes than it had when training started. The neural network\n"
f"has a fixed input size and cannot adapt to changing state shapes.\n\n"
f"Fix: make sure create_game() always initializes '{key}' with a\n"
f"fixed-length value before the game starts each episode.\n"
f"For example, if '{key}' is a list of 9 values, it must always be\n"
f"a list of exactly 9 values — never more, never fewer, never missing."
)
def _delta_reward(self) -> float: def _delta_reward(self) -> float:
current = float(self.game.state.get(self.metadata.reward, 0)) current = float(self.game.state.get(self.metadata.reward, 0))
delta = current - self._prev_reward delta = current - self._prev_reward

View File

@@ -4,6 +4,7 @@ import tomllib
import tomli_w import tomli_w
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Callable
@dataclass @dataclass
@@ -11,9 +12,15 @@ class GameMetadata:
"""Describes a retro game for training purposes. """Describes a retro game for training purposes.
Required fields: actions, reward. Required fields: actions, reward.
Optional fields: character_set, spatial. Optional fields: character_set, spatial, observation_function.
Discovered fields: board_size (from game.board_size), extras_size (from Discovered fields: board_size (from game.board_size), extras_size
the observe_state list in [preprocessing]). (computed by DQNTrainer from one sampled observation — never set in a
game's own pyproject.toml).
observation_function, if set, is a "module:attr" string naming a function
``f(game) -> Any`` that fully replaces the built-in board/observe_state
encoding. It is mutually exclusive with the [preprocessing] observe_state
option. See GameEnvironment for how the two paths are selected.
""" """
actions: list[str] actions: list[str]
reward: str reward: str
@@ -21,6 +28,7 @@ class GameMetadata:
spatial: bool = False spatial: bool = False
board: bool = True board: bool = True
board_size: tuple[int, int] | None = None board_size: tuple[int, int] | None = None
observation_function: str | None = None
extras_size: int = 0 extras_size: int = 0
def validate(self): def validate(self):
@@ -59,6 +67,48 @@ class GameMetadata:
"If you're not sure what characters your game uses, remove character_set\n" "If you're not sure what characters your game uses, remove character_set\n"
"entirely and the trainer will discover them automatically." "entirely and the trainer will discover them automatically."
) )
if self.observation_function is not None:
if not isinstance(self.observation_function, str) or ':' not in self.observation_function:
raise ValueError(
f"'observation_function' must be a string of the form 'module:attr', "
f"but got: {self.observation_function!r}\n"
"Example: observation_function = \"my_game:get_observation\"\n"
"This should name a function f(game) -> observation that fully\n"
"describes what your agent observes each turn."
)
def resolve_observation_function(self) -> Callable | None:
"""Import and return the function named by observation_function, or None if unset.
Raises ValueError with an actionable message if the string isn't
"module:attr", the module can't be imported, or it has no such attribute.
"""
if self.observation_function is None:
return None
if ':' not in self.observation_function:
raise ValueError(
f"'observation_function' must be of the form 'module:attr', but got "
f"{self.observation_function!r} (no ':' found).\n"
"Example: observation_function = \"my_game:get_observation\""
)
module_name, attr_name = self.observation_function.split(':', 1)
try:
module = importlib.import_module(module_name)
except ImportError as e:
raise ValueError(
f"Could not import module {module_name!r} for observation_function "
f"{self.observation_function!r}: {e}\n"
"Make sure the module is importable (e.g. on PYTHONPATH or installed)."
) from e
try:
return getattr(module, attr_name)
except AttributeError:
raise ValueError(
f"Module {module_name!r} has no attribute {attr_name!r} "
f"(from observation_function = {self.observation_function!r}).\n"
f"Define a function named '{attr_name}' in {module_name} that takes a "
"game instance and returns its observation."
) from None
@classmethod @classmethod
def from_pyproject(cls, module_name: str) -> GameMetadata: def from_pyproject(cls, module_name: str) -> GameMetadata:
@@ -102,17 +152,22 @@ class GameMetadata:
character_set=d.get('character_set'), character_set=d.get('character_set'),
spatial=d.get('spatial', False), spatial=d.get('spatial', False),
board_size=board_size, board_size=board_size,
observation_function=d.get('observation_function'),
extras_size=d.get('extras_size', 0),
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
d = { d = {
'actions': self.actions, 'actions': self.actions,
'reward': self.reward, 'reward': self.reward,
'extras_size': self.extras_size,
} }
if self.board_size is not None: if self.board_size is not None:
d['board_size'] = list(self.board_size) d['board_size'] = list(self.board_size)
if self.character_set is not None: if self.character_set is not None:
d['character_set'] = self.character_set d['character_set'] = self.character_set
if self.observation_function is not None:
d['observation_function'] = self.observation_function
return d return d
def to_toml(self, path: str | Path): def to_toml(self, path: str | Path):

View File

@@ -45,17 +45,9 @@ class TrainedPolicy:
pre = config.get('preprocessing', {}) pre = config.get('preprocessing', {})
self._metadata.spatial = pre.get('spatial', False) self._metadata.spatial = pre.get('spatial', False)
self._metadata.board = pre.get('board', True) self._metadata.board = pre.get('board', True)
observe_state_sizes = pre.get('observe_state_sizes', {})
self._observe_state: list[str] = pre.get('observe_state', []) self._observe_state: list[str] = pre.get('observe_state', [])
self._egocentric: bool = pre.get('egocentric', False)
self._egocentric_player: str | None = pre.get('egocentric_player')
self._egocentric_radius: int | None = pre.get('egocentric_radius')
self._board: bool = pre.get('board', True) self._board: bool = pre.get('board', True)
self._observation_fn = self._metadata.resolve_observation_function()
if observe_state_sizes:
self._metadata.extras_size = sum(observe_state_sizes.values())
else:
self._metadata.extras_size = len(self._observe_state)
hyperparams = {**DEFAULTS, **config.get('model', {}), **config.get('training', {})} hyperparams = {**DEFAULTS, **config.get('model', {}), **config.get('training', {})}
self._model, _ = build_network(self._metadata, hyperparams) self._model, _ = build_network(self._metadata, hyperparams)
@@ -78,26 +70,19 @@ class TrainedPolicy:
def get_action(self, game) -> str | None: def get_action(self, game) -> str | None:
"""Return the key the model recommends this turn, or None for no-op.""" """Return the key the model recommends this turn, or None for no-op."""
view = HeadlessView() if self._observation_fn is not None:
view.on_game_start(game) obs = self._observation_fn(game)
view.render(game) else:
board_chars = view.board_characters view = HeadlessView()
view.on_game_start(game)
player_pos = None view.render(game)
if self._egocentric and self._egocentric_player: obs = encode_observation(
agent = game.get_agent_by_name(self._egocentric_player) view.board_characters,
if agent is not None: dict(game.state),
player_pos = agent.position self._metadata,
self._observe_state,
obs = encode_observation( board=self._board,
board_chars, )
dict(game.state),
self._metadata,
self._observe_state,
player_pos=player_pos,
egocentric_radius=self._egocentric_radius,
board=self._board,
)
device = next(self._model.parameters()).device device = next(self._model.parameters()).device
state_t = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0).to(device) state_t = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0).to(device)

View File

@@ -64,25 +64,24 @@ def encode_observation(
state: dict, state: dict,
metadata: GameMetadata, metadata: GameMetadata,
observe_state: list[str], observe_state: list[str],
player_pos: tuple[int, int] | None = None,
egocentric_radius: int | None = None,
board: bool = True, board: bool = True,
) -> np.ndarray: ) -> np.ndarray:
"""Encode board and/or selected state values into a flat 1D observation vector. """Encode board and/or selected state values into a flat 1D observation vector.
When *board* is True the board is encoded and prepended to the vector. If When *board* is True the board is encoded and prepended to the vector. For
player_pos and egocentric_radius are given the board is first cropped to a spatial games the board is encoded channel-first (C, H, W) then flattened;
(2r+1)×(2r+1) window centred on the player. For spatial games the board is for non-spatial games it is encoded (H, W, C) then flattened. The state
encoded channel-first (C, H, W) then flattened; for non-spatial games it is vector is appended at the end.
encoded (H, W, C) then flattened. The state vector is appended at the end.
When *board* is False only the observe_state features are returned. When *board* is False only the observe_state features are returned.
For cropped (egocentric) boards, write a custom observation_function that
calls egocentric_board() and encode_board() directly instead of using this
function.
""" """
if board: if board:
if not metadata.character_set: if not metadata.character_set:
raise ValueError("character_set must be set before encoding observations") raise ValueError("character_set must be set before encoding observations")
if player_pos is not None and egocentric_radius is not None:
board_chars = egocentric_board(board_chars, player_pos, egocentric_radius)
board_enc = encode_board(board_chars, metadata.character_set) # (H, W, C) board_enc = encode_board(board_chars, metadata.character_set) # (H, W, C)
if metadata.spatial: if metadata.spatial:
board_vec = board_enc.transpose(2, 0, 1).flatten() board_vec = board_enc.transpose(2, 0, 1).flatten()

View File

@@ -50,19 +50,17 @@ def _get_device() -> torch.device:
# Fields that make an existing checkpoint incompatible with the current config. # Fields that make an existing checkpoint incompatible with the current config.
# Changing any of these requires starting training from scratch. # Changing any of these requires starting training from scratch.
_INCOMPATIBLE_METADATA = { _INCOMPATIBLE_METADATA = {
'actions': 'the list of actions the agent can take (changes output layer size)', 'actions': 'the list of actions the agent can take (changes output layer size)',
'reward': 'the reward signal — Q-values trained on the old signal are meaningless for the new one', 'reward': 'the reward signal — Q-values trained on the old signal are meaningless for the new one',
'character_set': 'the set of board characters (changes input layer size)', 'character_set': 'the set of board characters (changes input layer size)',
'board_size': 'the board dimensions (changes input layer size)', 'board_size': 'the board dimensions (changes input layer size)',
'observation_function': 'how the observation is computed (changes input representation)',
'extras_size': 'the size of the non-board portion of the observation (changes input layer size)',
} }
_INCOMPATIBLE_PREPROCESSING = { _INCOMPATIBLE_PREPROCESSING = {
'spatial': 'spatial vs non-spatial network type (changes network architecture)', 'spatial': 'spatial vs non-spatial network type (changes network architecture)',
'board': 'whether the board is included in the observation (changes input size)', 'board': 'whether the board is included in the observation (changes input size)',
'observe_state': 'the state keys included in the observation (changes input size)', 'observe_state': 'the state keys included in the observation (changes input size)',
'observe_state_sizes': 'the size of each observed state key (changes input layer size)',
'egocentric': 'egocentric board transformation (changes input representation)',
'egocentric_player': 'the agent used as the egocentric center (changes input representation)',
'egocentric_radius': 'the egocentric crop radius (changes input layer size)',
} }
_INCOMPATIBLE_ARCH = { _INCOMPATIBLE_ARCH = {
'hidden_sizes': 'the hidden layer sizes (changes network shape)', 'hidden_sizes': 'the hidden layer sizes (changes network shape)',
@@ -274,11 +272,7 @@ class DQNTrainer:
pre = preprocessing or {} pre = preprocessing or {}
self.observe_state: list[str] = pre.get('observe_state', []) self.observe_state: list[str] = pre.get('observe_state', [])
self.egocentric: bool = pre.get('egocentric', False)
self.egocentric_player: str | None = pre.get('egocentric_player', None)
self.egocentric_radius: int | None = pre.get('egocentric_radius', None)
self.board: bool = pre.get('board', True) self.board: bool = pre.get('board', True)
self.observe_state_sizes: dict[str, int] = pre.get('observe_state_sizes', {})
if self.board is False and metadata.spatial: if self.board is False and metadata.spatial:
raise ValueError( raise ValueError(
@@ -286,18 +280,11 @@ class DQNTrainer:
"A CNN requires a 2-D board to operate on. Either set spatial = false\n" "A CNN requires a 2-D board to operate on. Either set spatial = false\n"
"or keep board = true." "or keep board = true."
) )
if self.board is False and not self.observe_state: if self.board is False and not self.observe_state and metadata.observation_function is None:
raise ValueError( raise ValueError(
"preprocessing.board = false requires at least one entry in observe_state.\n" "preprocessing.board = false requires at least one entry in observe_state\n"
"With board=false, the agent observes only the game state variables listed\n" "(or a metadata.observation_function). With board=false and no\n"
"in observe_state — if that list is empty, there is nothing to observe." "observe_state, there is nothing for the agent to observe."
)
if self.egocentric and not self.egocentric_radius:
raise ValueError(
"preprocessing.egocentric = true requires egocentric_radius.\n"
"Choose a value based on how far the agent needs to see, e.g.:\n"
" egocentric_radius = 5 # 11×11 tight local view\n"
" egocentric_radius = 8 # 17×17 wider view"
) )
metadata.board = self.board metadata.board = self.board
@@ -306,28 +293,16 @@ class DQNTrainer:
g = game_factory() g = game_factory()
metadata.board_size = g.board_size metadata.board_size = g.board_size
if self.egocentric_radius:
side = 2 * self.egocentric_radius + 1
metadata.board_size = (side, side)
self.env = GameEnvironment( self.env = GameEnvironment(
game_factory, metadata, game_factory, metadata,
observe_state=self.observe_state, observe_state=self.observe_state,
egocentric=self.egocentric,
egocentric_player=self.egocentric_player,
egocentric_radius=self.egocentric_radius,
board=self.board, board=self.board,
observe_state_sizes=self.observe_state_sizes,
) )
if metadata.character_set is None and self.board: if metadata.character_set is None and self.board:
self._discover_character_set() self._discover_character_set()
if self.observe_state and not self.observe_state_sizes: self._discover_extras_size()
self._discover_observe_state_sizes()
self.env.observe_state_sizes = self.observe_state_sizes
metadata.extras_size = sum(self.observe_state_sizes.values()) if self.observe_state_sizes else 0
self.device = _get_device() self.device = _get_device()
@@ -467,6 +442,7 @@ class DQNTrainer:
def _run_episode(self) -> tuple[float, int, float, bool]: def _run_episode(self) -> tuple[float, int, float, bool]:
state = self.env.reset() state = self.env.reset()
self._check_obs_length(state)
total_reward = 0.0 total_reward = 0.0
total_loss = 0.0 total_loss = 0.0
loss_count = 0 loss_count = 0
@@ -477,6 +453,7 @@ class DQNTrainer:
action_key = self._idx_to_key(action_idx) action_key = self._idx_to_key(action_idx)
next_state, reward, done = self.env.step(action_key) next_state, reward, done = self.env.step(action_key)
self._check_obs_length(next_state)
self.memory.push(state, action_idx, reward, next_state, done) self.memory.push(state, action_idx, reward, next_state, done)
if self.total_steps % self.hp['train_every'] == 0: if self.total_steps % self.hp['train_every'] == 0:
@@ -565,13 +542,9 @@ class DQNTrainer:
return { return {
'metadata': self.metadata.to_dict(), 'metadata': self.metadata.to_dict(),
'preprocessing': { 'preprocessing': {
'spatial': self.metadata.spatial, 'spatial': self.metadata.spatial,
'board': self.board, 'board': self.board,
'observe_state': self.observe_state, 'observe_state': self.observe_state,
'observe_state_sizes': self.observe_state_sizes,
'egocentric': self.egocentric,
'egocentric_player': self.egocentric_player,
'egocentric_radius': self.egocentric_radius,
}, },
'hidden_sizes': self.hp['hidden_sizes'], 'hidden_sizes': self.hp['hidden_sizes'],
} }
@@ -636,15 +609,61 @@ class DQNTrainer:
f"after {self.hp['exploration_turns']} exploration turns: {chars}" f"after {self.hp['exploration_turns']} exploration turns: {chars}"
) )
def _discover_observe_state_sizes(self): def _discover_extras_size(self):
"""Sample game.state to determine the flat size of each observe_state key.""" """Sample one observation to determine extras_size (everything past the board).
self.env.reset()
state = dict(self.env.game.state) This is also where the observation contract is enforced for training:
sizes = {} GameEnvironment itself has no opinion about what observations look like
for key in self.observe_state: (BabySnake, for example, uses it with a plain tuple), but DQNTrainer
val = state.get(key, 0) needs a flat, numeric, fixed-length vector to feed a neural network.
sizes[key] = len(val) if isinstance(val, (list, tuple)) else 1 """
self.observe_state_sizes = sizes sample = self.env.reset()
try:
arr = np.asarray(sample, dtype=np.float32)
except (TypeError, ValueError) as e:
raise ValueError(
"Could not convert the observation to a numeric array for training:\n"
f" {sample!r}\n\n"
"DQNTrainer requires observation_function (or the built-in encoder) to\n"
"return a flat, numeric array-like value — a list/tuple of numbers or a\n"
"numpy array."
) from e
if arr.ndim != 1:
raise ValueError(
f"Expected a 1-D observation, but got shape {arr.shape}.\n"
"DQNTrainer always works with a single flat vector — board and extras\n"
"(if any) must be combined into one vector before being returned."
)
board_length = 0
if self.board:
C = len(self.metadata.character_set) if self.metadata.character_set else 0
bw, bh = self.metadata.board_size
board_length = C * bw * bh
if len(arr) < board_length:
raise ValueError(
f"The observation has length {len(arr)}, but character_set "
f"({C} chars) x board_size ({bw}x{bh}) = {board_length} is larger "
"than that.\n"
"Check that character_set/board_size match what your observation\n"
"actually encodes, or set board = false if there's no board in it."
)
self.metadata.extras_size = len(arr) - board_length
self._obs_len = len(arr)
def _check_obs_length(self, obs):
"""Raise a friendly error if an observation's length differs from the one discovered at init."""
length = len(np.asarray(obs, dtype=np.float32))
if length != self._obs_len:
raise ValueError(
"Observation length changed during training:\n"
f" Expected : {self._obs_len} (discovered at training start)\n"
f" Got : {length}\n\n"
"The neural network has a fixed input size and cannot adapt to a\n"
"changing observation shape. Make sure observation_function (or the\n"
"game's state) always produces the same length every episode."
)
def _save_config(self): def _save_config(self):
config_path = self.run_dir / 'config.toml' config_path = self.run_dir / 'config.toml'
@@ -658,13 +677,6 @@ class DQNTrainer:
pre['spatial'] = self.metadata.spatial pre['spatial'] = self.metadata.spatial
pre['board'] = self.board pre['board'] = self.board
pre['observe_state'] = self.observe_state pre['observe_state'] = self.observe_state
if self.observe_state_sizes:
pre['observe_state_sizes'] = self.observe_state_sizes
pre['egocentric'] = self.egocentric
if self.egocentric_player:
pre['egocentric_player'] = self.egocentric_player
if self.egocentric_radius:
pre['egocentric_radius'] = self.egocentric_radius
config['model'] = {k: v for k, v in self.hp.items() if k in MODEL_KEYS} config['model'] = {k: v for k, v in self.hp.items() if k in MODEL_KEYS}
config['training'] = {k: v for k, v in self.hp.items() if k not in MODEL_KEYS} config['training'] = {k: v for k, v in self.hp.items() if k not in MODEL_KEYS}
with open(config_path, 'wb') as f: with open(config_path, 'wb') as f:

View File

@@ -0,0 +1,208 @@
"""Tests for the observation_function machinery: GameMetadata resolution,
GameEnvironment delegation, and DQNTrainer's extras_size discovery.
Run with: uv run python -m unittest tests/test_observation_function.py -v
"""
import tempfile
from pathlib import Path
from unittest import TestCase, main
import numpy as np
from retro.game import Game
from retro_gamer.metadata import GameMetadata
from retro_gamer.observation import encode_observation
from retro_gamer.env import GameEnvironment
from retro_gamer.trainer import DQNTrainer
ACTIONS = ["KEY_RIGHT", "KEY_UP", "KEY_LEFT", "KEY_DOWN"]
def sample_observation_fn(game):
"""A trivial observation_function used to test dotted-path resolution."""
return "observation-from-sample_observation_fn"
def tuple_observation_fn(game):
"""Returns a plain tuple — exercises the non-array tabular use case."""
return (1, 2, 3)
def make_game_factory(board_size=(2, 2)):
def factory():
return Game([], {'reward': 0.0, 'score': 0}, board_size=board_size, show_state=False)
return factory
def obs_with_extras(game):
"""Board (character_set=2, board_size=(2,2) -> 8) + 3 extras = 11."""
return np.zeros(2 * 2 * 2 + 3, dtype=np.float32)
def obs_2d(game):
"""Not 1-D — used to test the training-time shape validation."""
return np.zeros((2, 2), dtype=np.float32)
def obs_too_short(game):
"""Shorter than character_set (2) x board_size (2x2) = 8."""
return np.zeros(5, dtype=np.float32)
_changing_obs_counter = {"n": 0}
def changing_obs(game):
"""Returns length 8 on the first call, length 9 on every call after."""
_changing_obs_counter["n"] += 1
length = 8 if _changing_obs_counter["n"] == 1 else 9
return np.zeros(length, dtype=np.float32)
class TestResolveObservationFunction(TestCase):
def test_returns_none_when_unset(self):
metadata = GameMetadata(actions=ACTIONS, reward="reward")
self.assertIsNone(metadata.resolve_observation_function())
def test_resolves_valid_dotted_path(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function=f"{__name__}:sample_observation_fn",
)
fn = metadata.resolve_observation_function()
self.assertIs(fn, sample_observation_fn)
def test_raises_on_malformed_string(self):
metadata = GameMetadata(actions=ACTIONS, reward="reward", observation_function="no_colon_here")
with self.assertRaises(ValueError):
metadata.resolve_observation_function()
def test_raises_on_unimportable_module(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function="this_module_does_not_exist:fn",
)
with self.assertRaises(ValueError):
metadata.resolve_observation_function()
def test_raises_on_missing_attribute(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function=f"{__name__}:no_such_function",
)
with self.assertRaises(ValueError):
metadata.resolve_observation_function()
def test_validate_raises_on_malformed_observation_function(self):
metadata = GameMetadata(actions=ACTIONS, reward="reward", observation_function="no_colon_here")
with self.assertRaises(ValueError):
metadata.validate()
def test_validate_passes_with_well_formed_observation_function(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function=f"{__name__}:sample_observation_fn",
)
metadata.validate() # should not raise
class TestEncodeObservation(TestCase):
def setUp(self):
self.metadata = GameMetadata(
actions=ACTIONS, reward="reward",
character_set=["@", "*"], board_size=(2, 2),
)
self.board_chars = [["@", " "], [" ", "*"]]
def test_board_plus_extras(self):
obs = encode_observation(
self.board_chars, {"x": 1.0, "y": 2.0}, self.metadata, ["x", "y"],
)
self.assertEqual(obs.shape, (2 * 2 * 2 + 2,))
def test_board_false_returns_only_extras(self):
obs = encode_observation(
self.board_chars, {"x": 1.0, "y": 2.0}, self.metadata, ["x", "y"], board=False,
)
np.testing.assert_array_equal(obs, [1.0, 2.0])
class TestGameEnvironment(TestCase):
def test_custom_observation_function_returns_its_value_directly(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function=f"{__name__}:tuple_observation_fn",
)
env = GameEnvironment(make_game_factory(), metadata)
self.assertEqual(env.reset(), (1, 2, 3))
obs, reward, done = env.step(None)
self.assertEqual(obs, (1, 2, 3))
def test_mutual_exclusivity_raises(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function=f"{__name__}:tuple_observation_fn",
)
with self.assertRaises(ValueError):
GameEnvironment(make_game_factory(), metadata, observe_state=["score"])
def test_built_in_path_still_works(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
character_set=["@", "*"], board_size=(2, 2),
)
env = GameEnvironment(make_game_factory(), metadata, observe_state=["score"])
obs = env.reset()
self.assertEqual(obs.shape, (2 * 2 * 2 + 1,))
class TestDQNTrainerExtrasSize(TestCase):
def _trainer(self, metadata, preprocessing=None, board_size=(2, 2)):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
return DQNTrainer(
make_game_factory(board_size), metadata, Path(tmp.name),
preprocessing=preprocessing,
)
def test_computes_extras_size_from_custom_function(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
character_set=["@", "*"], board_size=(2, 2),
observation_function=f"{__name__}:obs_with_extras",
)
trainer = self._trainer(metadata)
self.assertEqual(trainer.metadata.extras_size, 3)
def test_raises_on_non_1d_observation(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
character_set=["@", "*"], board_size=(2, 2),
observation_function=f"{__name__}:obs_2d",
)
with self.assertRaises(ValueError):
self._trainer(metadata)
def test_raises_when_observation_too_short_for_declared_board(self):
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
character_set=["@", "*"], board_size=(2, 2),
observation_function=f"{__name__}:obs_too_short",
)
with self.assertRaises(ValueError):
self._trainer(metadata)
def test_raises_when_observation_length_changes_mid_run(self):
_changing_obs_counter["n"] = 0
metadata = GameMetadata(
actions=ACTIONS, reward="reward",
observation_function=f"{__name__}:changing_obs",
)
trainer = self._trainer(metadata, preprocessing={'board': False})
self.assertEqual(trainer.metadata.extras_size, 8)
with self.assertRaises(ValueError):
trainer._run_episode()
if __name__ == '__main__':
main()

2
uv.lock generated
View File

@@ -1154,7 +1154,7 @@ wheels = [
[[package]] [[package]]
name = "retro-gamer" name = "retro-gamer"
version = "0.1.1" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },