diff --git a/README.md b/README.md index 6198b10a..b10b9ae4 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,32 @@ python -m spacy download en_core_web_sm - [Utilities](https://jericho-py.readthedocs.io/en/latest/util.html) - [Defines](https://jericho-py.readthedocs.io/en/latest/defines.html) +## Breaking changes in Jericho 4.0 + +Prior to version 4.0, creating an environment without specifying a seed would silently +use the game's walkthrough seed (when known), making episodes deterministic. As described +in the [Jericho paper](http://arxiv.org/abs/1909.05398), a fixed random seed is a *handicap* +that should be chosen and disclosed explicitly. Starting with version 4.0: + +- `FrotzEnv(rom)` (i.e. without a seed) now uses a time-dependent seed, i.e. episodes are stochastic. +- `FrotzEnv.reset()` accepts a `use_walkthrough_seed` argument to seed the emulator with the + game's walkthrough seed, which is needed to reproduce the walkthrough. +- `FrotzEnv.walkthrough_seed` returns the game's walkthrough seed, if it is known, otherwise `None`. +- A `ImplicitRandomSeedWarning` is issued when resetting a game that has a walkthrough seed while + neither an explicit seed nor `use_walkthrough_seed` was provided. + +```python +from jericho import FrotzEnv + +env = FrotzEnv("zork1.z5") # Stochastic (time-dependent seed). +env = FrotzEnv("zork1.z5", seed=-1) # Stochastic, explicitly (no warning). +env = FrotzEnv("zork1.z5", seed=42) # Deterministic with seed 42. + +env.reset() # Uses the seed above. +env.reset(use_walkthrough_seed=True) # Deterministic, reproduces env.get_walkthrough(). +print(env.walkthrough_seed) # 12 +``` + ## Agents - [Reading Comprehension Deep Q-Network (RCDQN)](https://github.com/XiaoxiaoGuo/rcdqn) diff --git a/docs/source/tutorial_quick.rst b/docs/source/tutorial_quick.rst index cd40d222..75f69efd 100644 --- a/docs/source/tutorial_quick.rst +++ b/docs/source/tutorial_quick.rst @@ -56,6 +56,7 @@ Jericho implements a reinforcement learning interface in which the agent provide from jericho import * # Create the environment, optionally specifying a random seed + # (by default, the emulator is seeded with the current time). env = FrotzEnv("z-machine-games-master/jericho-game-suite/zork1.z5") initial_observation, info = env.reset() done = False @@ -127,12 +128,15 @@ One of the most common difficulties with parser-based text games is identifying Walkthroughs ------------ -Jericho provides walkthroughs for supported games using :meth:`jericho.FrotzEnv.get_walkthrough`. To use the walkthrough, it is necessary to reset the environment with the desired seed: +Jericho provides walkthroughs for supported games using :meth:`jericho.FrotzEnv.get_walkthrough`. To reproduce a walkthrough, it is necessary to reset the environment with the game's walkthrough seed, which is available via :attr:`jericho.FrotzEnv.walkthrough_seed`: .. code-block:: python >>> from jericho import * >>> env = FrotzEnv("z-machine-games-master/jericho-game-suite/zork1.z5") >>> walkthrough = env.get_walkthrough() + >>> env.reset(use_walkthrough_seed=True) # Equivalent to env.seed(env.walkthrough_seed); env.reset() >>> for act in walkthrough: >>> env.step(act) + +.. note:: Since Jericho 4.0, an environment created without an explicit seed is stochastic, i.e. the emulator's random number generator is seeded with the current time. Seeding the emulator (e.g. with the walkthrough seed) is a *handicap*, as defined in the `Jericho paper `_, and should be disclosed when reporting results. diff --git a/jericho/jericho.py b/jericho/jericho.py index 38e14ec3..61b45609 100644 --- a/jericho/jericho.py +++ b/jericho/jericho.py @@ -368,17 +368,26 @@ class TruncatedInputActionWarning(UserWarning): pass +class ImplicitRandomSeedWarning(UserWarning): + pass + + class FrotzEnv(): """ The Frotz Environment is a fast interface to Z-Machine games. :param story_file: Path to a Z-machine rom file (.z3/.z5/.z6/.z8). :param seed: Seed the random number generator used by the emulator. - Default: use walkthrough's seed if it exists, - otherwise use value of -1 which changes with time. + Default: -1, i.e. the emulator's random number generator is + seeded with the current time, making episodes stochastic. :type story_file: path :type seed: int + .. note:: Since Jericho 4.0, the seed needed to reproduce a game's walkthrough + is no longer used by default. To reproduce a walkthrough, either call + :meth:`jericho.FrotzEnv.reset` with `use_walkthrough_seed=True` or + provide :attr:`jericho.FrotzEnv.walkthrough_seed` as the `seed` argument. + """ def __init__(self, story_file, seed=None): self._cache = {} @@ -397,8 +406,8 @@ def load(self, story_file, seed=None): :param story_file: Path to a Z-machine rom file (.z3/.z5/.z6/.z8). :param seed: Seed the random number generator used by the emulator. - Default: use walkthrough's seed if it exists, - otherwise use value of -1 which changes with time. + Default: -1, i.e. the emulator's random number generator is + seeded with the current time, making episodes stochastic. :type story_file: path :type seed: int ''' @@ -434,30 +443,79 @@ def seed(self, seed=None): Changes seed used for the emulator's random number generator. :param seed: Seed the random number generator used by the emulator. - Default: use walkthrough's seed if it exists, - otherwise use value of -1 which changes with time. + Default: -1, i.e. the emulator's random number generator is + seeded with the current time, making episodes stochastic. :returns: The value of the seed. .. note:: :meth:`jericho.FrotzEnv.reset()` must be called before the seed takes effect. + .. note:: Since Jericho 4.0, calling this method without a seed no longer + silently uses the game's walkthrough seed. Use + :attr:`jericho.FrotzEnv.walkthrough_seed` or + :meth:`jericho.FrotzEnv.reset` with `use_walkthrough_seed=True` + to reproduce a walkthrough. + + ''' + self._seed_is_explicit = seed is not None + self._seed = seed if seed is not None else -1 + return self._seed + + @property + def walkthrough_seed(self): + ''' + Seed needed to reproduce this game's walkthrough, if it is known. + + :returns: The walkthrough's seed, or `None` if the game has no known walkthrough seed. + + :Example: + + >>> import jericho + >>> env = jericho.FrotzEnv('zork1.z5') + >>> env.walkthrough_seed + 12 + >>> env.reset(use_walkthrough_seed=True) # Same as env.seed(env.walkthrough_seed); env.reset() + ''' - seed = seed or self.bindings.get('seed', -1) - self._seed = seed - return seed + return self.bindings.get('seed') - def reset(self): + def reset(self, use_walkthrough_seed=False): ''' Resets the game. :param use_walkthrough_seed: Seed the emulator to reproduce the walkthrough. + Default: `False`, i.e. use the seed set with + :meth:`jericho.FrotzEnv.seed` (a time-dependent + seed, unless one was explicitly provided). + :type use_walkthrough_seed: bool :returns: A tuple containing the initial observation,\ and a dictionary of info. :rtype: string, dictionary + .. note:: Using `use_walkthrough_seed=True` makes the game deterministic. + As described in the Jericho paper, this is a *handicap* that + should be disclosed when reporting results. + ''' + seed = self._seed + if use_walkthrough_seed: + if self.walkthrough_seed is None: + msg = ("No walkthrough seed is known for game '{}'," + " using a time-dependent seed instead.").format(self.story_file.decode()) + warnings.warn(msg, UnsupportedGameWarning) + else: + seed = self.walkthrough_seed + + elif not self._seed_is_explicit and self.walkthrough_seed is not None: + msg = ("Since Jericho 4.0, the walkthrough seed ({}) of game '{}' is no longer used" + " by default, i.e. this episode is stochastic (time-dependent seed)." + " Call reset(use_walkthrough_seed=True) to reproduce the walkthrough," + " or provide an explicit seed (e.g. FrotzEnv(rom, seed=-1)) to silence" + " this warning.").format(self.walkthrough_seed, self.story_file.decode()) + warnings.warn(msg, ImplicitRandomSeedWarning) + self.close() rom, _, _ = self._cache[self.story_file.decode()] - obs_ini = self.frotz_lib.setup(self.story_file, self._seed, rom, len(rom)).decode('cp1252') + obs_ini = self.frotz_lib.setup(self.story_file, seed, rom, len(rom)).decode('cp1252') score = self.frotz_lib.get_score() return obs_ini, {'moves':self.get_moves(), 'score':score} diff --git a/jericho/version.py b/jericho/version.py index 310a75df..d6497a81 100644 --- a/jericho/version.py +++ b/jericho/version.py @@ -1 +1 @@ -__version__ = '3.3.1' +__version__ = '4.0.0' diff --git a/tests/test_jericho.py b/tests/test_jericho.py index 12f8b22d..1e3a0ef2 100644 --- a/tests/test_jericho.py +++ b/tests/test_jericho.py @@ -80,12 +80,12 @@ def _get_mem(): def test_copy(): rom = pjoin(DATA_PATH, "905.z5") env = jericho.FrotzEnv(rom) - env.reset() + env.reset(use_walkthrough_seed=True) walkthrough = env.get_walkthrough() expected = [env.step(act) for act in walkthrough] - env.reset() + env.reset(use_walkthrough_seed=True) for i, act in enumerate(walkthrough): obs, rew, done, info = env.step(act) diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 00000000..a1c4f1f4 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,76 @@ +import os +import warnings +from os.path import join as pjoin + +import pytest + +import jericho + + +DATA_PATH = os.path.abspath(pjoin(__file__, '..', "data")) + + +def test_default_seed_is_time_dependent(): + # By default, the walkthrough seed should *not* be used silently. + rom = pjoin(DATA_PATH, "905.z5") + env = jericho.FrotzEnv(rom) + assert env._seed == -1 + assert env.seed() == -1 + + +def test_explicit_seed(): + rom = pjoin(DATA_PATH, "905.z5") + env = jericho.FrotzEnv(rom, seed=42) + assert env._seed == 42 + + # Zero is a valid seed. + assert env.seed(0) == 0 + assert env._seed == 0 + + +def test_walkthrough_seed_property(): + env = jericho.FrotzEnv(pjoin(DATA_PATH, "905.z5")) + assert env.walkthrough_seed == env.bindings['seed'] + + # Games without bindings have no walkthrough seed. + env = jericho.FrotzEnv(pjoin(DATA_PATH, "tw-game.z8")) + assert env.walkthrough_seed is None + + +def test_warning_when_using_implicit_random_seed(): + rom = pjoin(DATA_PATH, "905.z5") + env = jericho.FrotzEnv(rom) + + with pytest.warns(jericho.ImplicitRandomSeedWarning): + env.reset() + + # No warning when the choice is explicit. + with warnings.catch_warnings(): + warnings.simplefilter("error") + env.reset(use_walkthrough_seed=True) + jericho.FrotzEnv(rom, seed=-1).reset() + jericho.FrotzEnv(rom, seed=env.walkthrough_seed).reset() + + # No warning for games without a walkthrough seed. + with warnings.catch_warnings(): + warnings.simplefilter("error") + jericho.FrotzEnv(pjoin(DATA_PATH, "tw-game.z8")).reset() + + +def test_reset_with_walkthrough_seed_but_no_bindings(): + env = jericho.FrotzEnv(pjoin(DATA_PATH, "tw-game.z8")) + with pytest.warns(jericho.UnsupportedGameWarning): + env.reset(use_walkthrough_seed=True) + + +def test_walkthrough_is_reproducible_with_walkthrough_seed(): + rom = pjoin(DATA_PATH, "905.z5") + env = jericho.FrotzEnv(rom) + walkthrough = env.get_walkthrough() + + env.reset(use_walkthrough_seed=True) + for act in walkthrough: + obs, rew, done, info = env.step(act) + + assert done + assert info["score"] == env.get_max_score() diff --git a/tools/find_walkthrough.py b/tools/find_walkthrough.py index 7def8f20..23a27254 100644 --- a/tools/find_walkthrough.py +++ b/tools/find_walkthrough.py @@ -23,7 +23,7 @@ def parse_args(): history = [] env = jericho.FrotzEnv(args.filename) -obs, info = env.reset() +obs, info = env.reset(use_walkthrough_seed=True) history.append(env.get_state()) diff --git a/tools/test_games.py b/tools/test_games.py index 1d38b18c..706379cf 100644 --- a/tools/test_games.py +++ b/tools/test_games.py @@ -32,7 +32,7 @@ def parse_args(): print(colored("SKIP\tMissing walkthrough", 'yellow')) continue - env.reset() + env.reset(use_walkthrough_seed=True) #walkthrough = bindings['walkthrough'].split('/') for cmd in env.get_walkthrough():