Refactor CLI/interface: init command, factory field, remove extras_size
- Rename `retro-gamer create` to `retro-gamer init` with positional args (GAME OUTPUT) instead of --game/--output flags - Add [tool.retro-gamer].factory = "module:attr" to declare the game factory function from pyproject.toml instead of relying on a hard-coded create_game attribute - Remove extras_size from user-facing config; it is now measured automatically from a sample observation and never declared - Update docs throughout: create→init, runs/→training/, add factory field documentation, clarify [model] vs [training] hyperparameter sections, remove stale version-history explanations - Bump version to 0.3.0; require retro-games>=2.5.0
This commit is contained in:
@@ -22,15 +22,12 @@ def cli():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retro-gamer create
|
||||
# retro-gamer init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.option('--game', required=True,
|
||||
help='Game to train: a .py file path (e.g. my_game.py) or a Python module '
|
||||
'(e.g. retro.examples.snake)')
|
||||
@click.option('--output', required=True,
|
||||
help='Directory to create for this training run')
|
||||
@click.argument('game')
|
||||
@click.argument('output')
|
||||
@click.option('--learning-rate', default=DEFAULTS['learning_rate'], type=float,
|
||||
help=f"Adam optimizer learning rate (default {DEFAULTS['learning_rate']})")
|
||||
@click.option('--learning-rate-decay', default=DEFAULTS['learning_rate_decay'], type=float,
|
||||
@@ -60,11 +57,17 @@ def cli():
|
||||
@click.option('--prioritize-experiences/--no-prioritize-experiences',
|
||||
default=DEFAULTS['prioritize_experiences'],
|
||||
help='Use prioritized experience replay')
|
||||
def create(game, output, **hyperparams):
|
||||
def init(game, output, **hyperparams):
|
||||
"""Create a new training run directory.
|
||||
|
||||
Game metadata (actions, reward signal, etc.) is read from the
|
||||
[tool.retro-gamer] section of the game's pyproject.toml.
|
||||
GAME is a path to a game directory or a Python module name
|
||||
(e.g. games/snake or retro.examples.snake).
|
||||
|
||||
OUTPUT is the directory to create for this training run
|
||||
(e.g. training/snake).
|
||||
|
||||
Game metadata (actions, reward signal, factory function, etc.) is
|
||||
read from the [tool.retro-gamer] section of the game's pyproject.toml.
|
||||
Board size is read directly from the game. Hyperparameter options
|
||||
control how the trainer learns, not what it learns about.
|
||||
"""
|
||||
@@ -84,6 +87,9 @@ def create(game, output, **hyperparams):
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
if metadata.factory:
|
||||
game_config['factory'] = metadata.factory
|
||||
|
||||
game_factory = _load_factory(game_config)
|
||||
if metadata.board_size is None:
|
||||
g = game_factory()
|
||||
@@ -105,7 +111,7 @@ def create(game, output, **hyperparams):
|
||||
with open(run_dir / 'config.toml', 'wb') as f:
|
||||
tomli_w.dump(config, f)
|
||||
|
||||
click.echo(f"Created training run at {output}/config.toml")
|
||||
click.echo(f"Initialized training run at {output}/config.toml")
|
||||
click.echo(f" game : {game}")
|
||||
click.echo(f" board_size : {metadata.board_size[0]}×{metadata.board_size[1]}")
|
||||
click.echo(f" actions : {metadata.actions}")
|
||||
@@ -370,6 +376,21 @@ def _load_factory(game_config: dict):
|
||||
path = game_config.get('path')
|
||||
if path and path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
factory_str = game_config.get('factory')
|
||||
if factory_str:
|
||||
module_name, attr_name = factory_str.split(':', 1)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
raise click.ClickException(f"Cannot import factory module '{module_name}': {e}")
|
||||
if not hasattr(module, attr_name):
|
||||
raise click.ClickException(
|
||||
f"Module '{module_name}' has no '{attr_name}' function "
|
||||
f"(from factory = {factory_str!r})"
|
||||
)
|
||||
return getattr(module, attr_name)
|
||||
|
||||
module_name = game_config['module']
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
@@ -377,7 +398,8 @@ def _load_factory(game_config: dict):
|
||||
raise click.ClickException(f"Cannot import game module '{module_name}': {e}")
|
||||
if not hasattr(module, 'create_game'):
|
||||
raise click.ClickException(
|
||||
f"Module '{module_name}' has no create_game() function"
|
||||
f"Module '{module_name}' has no create_game() function. "
|
||||
"Add factory = \"module:create_game\" to [tool.retro-gamer] in pyproject.toml."
|
||||
)
|
||||
return module.create_game
|
||||
|
||||
|
||||
@@ -12,15 +12,18 @@ class GameMetadata:
|
||||
"""Describes a retro game for training purposes.
|
||||
|
||||
Required fields: actions, reward.
|
||||
Optional fields: character_set, spatial, observation_function.
|
||||
Optional fields: character_set, spatial, observation_function, factory.
|
||||
Discovered fields: board_size (from game.board_size), extras_size
|
||||
(computed by DQNTrainer from one sampled observation — never set in a
|
||||
game's own pyproject.toml).
|
||||
(measured by DQNTrainer from one sampled observation — never declared).
|
||||
|
||||
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.
|
||||
|
||||
factory, if set, is a "module:attr" string naming the create_game function.
|
||||
Read from [tool.retro-gamer].factory in pyproject.toml. When absent,
|
||||
the loader falls back to looking for create_game on the game module.
|
||||
"""
|
||||
actions: list[str]
|
||||
reward: str
|
||||
@@ -29,6 +32,7 @@ class GameMetadata:
|
||||
board: bool = True
|
||||
board_size: tuple[int, int] | None = None
|
||||
observation_function: str | None = None
|
||||
factory: str | None = None
|
||||
extras_size: int = 0
|
||||
|
||||
def validate(self):
|
||||
@@ -76,6 +80,41 @@ class GameMetadata:
|
||||
"This should name a function f(game) -> observation that fully\n"
|
||||
"describes what your agent observes each turn."
|
||||
)
|
||||
if self.factory is not None:
|
||||
if not isinstance(self.factory, str) or ':' not in self.factory:
|
||||
raise ValueError(
|
||||
f"'factory' must be a string of the form 'module:attr', "
|
||||
f"but got: {self.factory!r}\n"
|
||||
"Example: factory = \"my_game:create_game\"\n"
|
||||
"This should name a function that takes no arguments and returns a Game."
|
||||
)
|
||||
|
||||
def resolve_factory(self) -> Callable | None:
|
||||
"""Import and return the factory function named by the factory field, or None if unset."""
|
||||
if self.factory is None:
|
||||
return None
|
||||
if ':' not in self.factory:
|
||||
raise ValueError(
|
||||
f"'factory' must be of the form 'module:attr', but got "
|
||||
f"{self.factory!r} (no ':' found).\n"
|
||||
"Example: factory = \"my_game:create_game\""
|
||||
)
|
||||
module_name, attr_name = self.factory.split(':', 1)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
raise ValueError(
|
||||
f"Could not import module {module_name!r} for factory "
|
||||
f"{self.factory!r}: {e}"
|
||||
) 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 factory = {self.factory!r}).\n"
|
||||
f"Define a function named '{attr_name}' in {module_name}."
|
||||
) from None
|
||||
|
||||
def resolve_observation_function(self) -> Callable | None:
|
||||
"""Import and return the function named by observation_function, or None if unset.
|
||||
@@ -153,14 +192,13 @@ class GameMetadata:
|
||||
spatial=d.get('spatial', False),
|
||||
board_size=board_size,
|
||||
observation_function=d.get('observation_function'),
|
||||
extras_size=d.get('extras_size', 0),
|
||||
factory=d.get('factory'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = {
|
||||
'actions': self.actions,
|
||||
'reward': self.reward,
|
||||
'extras_size': self.extras_size,
|
||||
}
|
||||
if self.board_size is not None:
|
||||
d['board_size'] = list(self.board_size)
|
||||
|
||||
@@ -55,7 +55,6 @@ _INCOMPATIBLE_METADATA = {
|
||||
'character_set': 'the set of board characters (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 = {
|
||||
'spatial': 'spatial vs non-spatial network type (changes network architecture)',
|
||||
|
||||
Reference in New Issue
Block a user