From 960ead05e7fcbb4559a9588274f7826fe3d248f3 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 21 May 2023 20:04:04 +0200 Subject: [PATCH 01/27] feat: initial jigsaw commit. --- .../environments/packing/jigsaw/__init__.py | 16 + .../environments/packing/jigsaw/conftest.py | 85 ++++ jumanji/environments/packing/jigsaw/env.py | 387 +++++++++++++++++ .../environments/packing/jigsaw/env_test.py | 374 ++++++++++++++++ .../environments/packing/jigsaw/generator.py | 399 ++++++++++++++++++ .../packing/jigsaw/generator_test.py | 245 +++++++++++ .../packing/jigsaw/jigsaw_example.py | 140 ++++++ jumanji/environments/packing/jigsaw/reward.py | 100 +++++ .../packing/jigsaw/reward_test.py | 259 ++++++++++++ jumanji/environments/packing/jigsaw/types.py | 71 ++++ jumanji/environments/packing/jigsaw/utils.py | 58 +++ .../environments/packing/jigsaw/utils_test.py | 92 ++++ jumanji/environments/packing/jigsaw/viewer.py | 178 ++++++++ 13 files changed, 2404 insertions(+) create mode 100644 jumanji/environments/packing/jigsaw/__init__.py create mode 100644 jumanji/environments/packing/jigsaw/conftest.py create mode 100644 jumanji/environments/packing/jigsaw/env.py create mode 100644 jumanji/environments/packing/jigsaw/env_test.py create mode 100644 jumanji/environments/packing/jigsaw/generator.py create mode 100644 jumanji/environments/packing/jigsaw/generator_test.py create mode 100644 jumanji/environments/packing/jigsaw/jigsaw_example.py create mode 100644 jumanji/environments/packing/jigsaw/reward.py create mode 100644 jumanji/environments/packing/jigsaw/reward_test.py create mode 100644 jumanji/environments/packing/jigsaw/types.py create mode 100644 jumanji/environments/packing/jigsaw/utils.py create mode 100644 jumanji/environments/packing/jigsaw/utils_test.py create mode 100644 jumanji/environments/packing/jigsaw/viewer.py diff --git a/jumanji/environments/packing/jigsaw/__init__.py b/jumanji/environments/packing/jigsaw/__init__.py new file mode 100644 index 000000000..6654373b3 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from jumanji.environments.packing.jigsaw.env import JigSaw +from jumanji.environments.packing.jigsaw.types import Observation, State diff --git a/jumanji/environments/packing/jigsaw/conftest.py b/jumanji/environments/packing/jigsaw/conftest.py new file mode 100644 index 000000000..da3cdc64e --- /dev/null +++ b/jumanji/environments/packing/jigsaw/conftest.py @@ -0,0 +1,85 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import chex +import jax +import jax.numpy as jnp +import pytest + + +@pytest.fixture +def key() -> chex.PRNGKey: + """A determinstic key.""" + return jax.random.PRNGKey(0) + + +@pytest.fixture +def piece() -> chex.Array: + return jnp.array( + [ + [0.0, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.0, 0.0, 1.0], + ] + ) + + +@pytest.fixture +def solved_board() -> chex.Array: + """A mock solved puzzle board for testing.""" + + return jnp.array( + [ + [1.0, 1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [3.0, 1.0, 4.0, 4.0, 2.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + [3.0, 3.0, 3.0, 4.0, 4.0], + ], + ) + + +@pytest.fixture +def board_with_piece_one_placed() -> chex.Array: + """A board with only piece one placed.""" + + return jnp.array( + [ + [1.0, 1.0, 1.0, 0.0, 0.0], + [1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ], + ) + + +@pytest.fixture() +def piece_one_correctly_placed(board_with_piece_one_placed: chex.Array) -> chex.Array: + """A 2D array of zeros where piece one has been placed correctly.""" + + return board_with_piece_one_placed + + +@pytest.fixture() +def piece_one_partially_placed(board_with_piece_one_placed: chex.Array) -> chex.Array: + """A 2D array of zeros where piece one has been placed partially correctly. + That is to say that there is overlap between where the piece has been placed and + where it should be placed to solve the puzzle.""" + + # Shift all elements in the array one down and one to the right + partially_placed_piece = jnp.roll(board_with_piece_one_placed, shift=1, axis=0) + partially_placed_piece = jnp.roll(partially_placed_piece, shift=1, axis=1) + + return partially_placed_piece diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py new file mode 100644 index 000000000..034fa8638 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/env.py @@ -0,0 +1,387 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional, Sequence, Tuple + +import chex +import jax +import jax.numpy as jnp +import matplotlib +from numpy.typing import NDArray + +from jumanji import specs +from jumanji.env import Environment +from jumanji.environments.packing.jigsaw.generator import ( + InstanceGenerator, + RandomJigsawGenerator, +) +from jumanji.environments.packing.jigsaw.reward import DenseReward, RewardFn +from jumanji.environments.packing.jigsaw.types import Observation, State +from jumanji.environments.packing.jigsaw.utils import compute_grid_dim, rotate_piece +from jumanji.environments.packing.jigsaw.viewer import JigsawViewer +from jumanji.types import TimeStep, restart, termination, transition +from jumanji.viewer import Viewer + + +class JigSaw(Environment[State]): + + """A Jigsaw solving environment.""" + + def __init__( + self, + generator: Optional[InstanceGenerator] = None, + reward_fn: Optional[RewardFn] = None, + viewer: Optional[Viewer[State]] = None, + ): + """Initializes the environment. + + Args: + generator: Instance generator for the environment. + """ + + default_generator = RandomJigsawGenerator( + num_row_pieces=3, + num_col_pieces=3, + ) + + self.generator = generator or default_generator + self.num_row_pieces = self.generator.num_row_pieces + self.num_col_pieces = self.generator.num_col_pieces + self.num_pieces = self.num_row_pieces * self.num_col_pieces + self.board_dim = ( + compute_grid_dim(self.num_row_pieces), + compute_grid_dim(self.num_col_pieces), + ) + self.reward_fn = reward_fn or DenseReward() + self.viewer = viewer or JigsawViewer( + "Jigsaw", self.num_pieces, render_mode="human" + ) + + def __repr__(self) -> str: + return ( + f"Jigsaw environment with a puzzle size of ({self.board_dim[0]}x{self.board_dim[1]}) " + f"with {self.num_row_pieces} row pieces, {self.num_col_pieces} column " + f"pieces. Each piece has dimension (3x3)." + ) + + def reset( + self, key: chex.PRNGKey, generate_new_board: bool = False + ) -> Tuple[State, TimeStep[Observation]]: + + """Resets the environment. + + Args: + key: PRNG key for generating a new instance. + generate_new_board: whether to generate a new board + or reset the current one. + + Returns: + a tuple of the initial state and a time step. + """ + + board_state = self.generator(key) + + board_action_mask = jnp.zeros_like(board_state.solved_board) + piece_action_mask = jnp.ones(self.num_pieces, dtype=bool) + + board_state.board_action_mask = board_action_mask + board_state.piece_action_mask = piece_action_mask + board_state.current_board = jnp.zeros_like(board_state.solved_board) + + obs = self._observation_from_state(board_state) + timestep = restart(observation=obs) + + return board_state, timestep + + def step( + self, state: State, action: chex.Array + ) -> Tuple[State, TimeStep[Observation]]: + """Steps the environment. + + Args: + state: current state of the environment. + action: action to take. + + Returns: + a tuple of the next state and a time step. + """ + + chosen_piece = state.pieces[action[0]] + + # Rotate chosen piece + chosen_piece = rotate_piece(chosen_piece, action[1]) + + grid_piece = self._expand_piece_to_board( + state, chosen_piece, action[2], action[3] + ) + + grid_mask_piece = self._get_ones_like_expanded_piece(grid_piece=grid_piece) + + action_is_legal = self._check_action_is_legal(action[0], state, grid_mask_piece) + + next_state_legal = State( + col_nibs_idxs=state.col_nibs_idxs, + row_nibs_idxs=state.row_nibs_idxs, + solved_board=state.solved_board, + current_board=state.current_board + grid_piece, + pieces=state.pieces, + piece_action_mask=state.piece_action_mask.at[action[0]].set(False), + board_action_mask=state.board_action_mask + grid_mask_piece, + num_pieces=state.num_pieces, + key=state.key, + step_count=state.step_count + 1, + ) + + next_state_illegal = State( + col_nibs_idxs=state.col_nibs_idxs, + row_nibs_idxs=state.row_nibs_idxs, + solved_board=state.solved_board, + current_board=state.current_board, + pieces=state.pieces, + piece_action_mask=state.piece_action_mask, + board_action_mask=state.board_action_mask, + num_pieces=state.num_pieces, + key=state.key, + step_count=state.step_count + 1, + ) + + # Transition board to new state if the action is legal + # otherwise, stay in the same state. + next_state = jax.lax.cond( + action_is_legal, + lambda: next_state_legal, + lambda: next_state_illegal, + ) + + done = self._check_done(next_state) + + next_obs = self._observation_from_state(next_state) + + reward = self.reward_fn(state, grid_piece, next_state, action_is_legal, done) + + timestep = jax.lax.cond( + done, + termination, + transition, + reward, + next_obs, + ) + + return next_state, timestep + + def render(self, state: State) -> Optional[NDArray]: + """Render a given state of the environment. + + Args: + state: `State` object containing the current environment state. + """ + return self.viewer.render(state) + + def animate( + self, + states: Sequence[State], + interval: int = 200, + save_path: Optional[str] = None, + ) -> matplotlib.animation.FuncAnimation: + """Create an animation from a sequence of states. + + Args: + states: sequence of `State` corresponding to subsequent timesteps. + interval: delay between frames in milliseconds, default to 200. + save_path: the path where the animation file should be saved. If it is None, the plot + will not be saved. + + Returns: + animation that can export to gif, mp4, or render with HTML. + """ + return self.viewer.animate(states, interval, save_path) + + def close(self) -> None: + """Perform any necessary cleanup. + + Environments will automatically :meth:`close()` themselves when + garbage collected or when the program exits. + """ + self.viewer.close() + + def observation_spec(self) -> specs.Spec[Observation]: + """Returns the observation spec of the environment. + + Returns: + Spec for each filed in the observation: + - current_board: BoundedArray (int) of shape (board_dim[0], board_dim[1]). + - pieces: BoundedArray (int) of shape (num_pieces, 3, 3). + - piece_action_mask: BoundedArray (bool) of shape (num_pieces,). + - board_action_mask: BoundedArray (int) of shape (board_dim[0], board_dim[1]). + """ + + current_board = specs.BoundedArray( + shape=(self.board_dim[0], self.board_dim[1]), + minimum=0, + maximum=self.num_pieces, + dtype=jnp.float32, + name="current_board", + ) + + pieces = specs.BoundedArray( + shape=(self.num_pieces, 3, 3), + minimum=0, + maximum=self.num_pieces, + dtype=jnp.float32, + name="pieces", + ) + + piece_action_mask = specs.BoundedArray( + shape=(self.num_pieces,), + minimum=False, + maximum=True, + dtype=bool, + name="piece_action_mask", + ) + + board_action_mask = specs.BoundedArray( + shape=(self.board_dim[0], self.board_dim[1]), + minimum=0, + maximum=1, + dtype=jnp.float32, + name="board_action_mask", + ) + + return specs.Spec( + Observation, + "ObservationSpec", + current_board=current_board, + pieces=pieces, + piece_action_mask=piece_action_mask, + board_action_mask=board_action_mask, + ) + + def action_spec(self) -> specs.MultiDiscreteArray: + """Specifications of the action expected by the `JigSaw` environment. + + Returns: + MultiDiscreteArray (int32) of shape (num_pieces, num_rotations, + max_row_position, max_col_position). + - num_pieces: int between 0 and num_pieces - 1 (included). + - num_rotations: int between 0 and 3 (included). + - max_row_position: int between 0 and max_row_position - 1 (included). + - max_col_position: int between 0 and max_col_position - 1 (included). + """ + + max_row_position = self.board_dim[0] - 3 + max_col_position = self.board_dim[1] - 3 + + return specs.MultiDiscreteArray( + num_values=jnp.array( + [self.num_pieces, 4, max_row_position, max_col_position] + ), + name="action", + ) + + def _check_done(self, state: State) -> bool: + """Checks if the environment is done by checking whether the number of + steps is equal to the number of pieces in the puzzle. + + Args: + state: current state of the environment. + + Returns: + True if the environment is done, False otherwise. + """ + + done: bool = state.step_count >= state.num_pieces + + return done + + def _check_action_is_legal( + self, action: chex.Numeric, state: State, grid_mask_piece: chex.Array + ) -> bool: + """Checks if the action is legal by considering the action mask and the + board mask. An action is legal if the action mask is True for that action + and the board mask indicates that there is no overlap with pieces + already placed. + + Args: + action: action taken. + state: current state of the environment. + grid_mask_piece: grid with ones where the piece is placed. + + Returns: + True if the action is legal, False otherwise. + """ + + placed_mask = state.board_action_mask + grid_mask_piece + + legal: bool = state.piece_action_mask[action] & (jnp.max(placed_mask) <= 1) + + return legal + + def _get_ones_like_expanded_piece(self, grid_piece: chex.Array) -> chex.Array: + """Makes a grid of zeroes with ones where the piece is placed. + + Args: + grid_piece: piece placed on a grid of zeroes. + """ + + grid_with_ones = jnp.where(grid_piece != 0, 1, 0) + + return grid_with_ones + + def _expand_piece_to_board( + self, + state: State, + piece: chex.Array, + row_coord: chex.Numeric, + col_coord: chex.Numeric, + ) -> chex.Array: + """Takes a piece and places it on a grid of zeroes with the same size as the board. + + Args: + state: current state of the environment. + piece: piece to place on the board. + row_coord: row coordinate on the board where the top left corner + of the piece will be placed. + col_coord: column coordinate on the board where the top left corner + of the piece will be placed. + + Returns: + Grid of zeroes with values where the piece is placed. + """ + + grid_with_piece = jnp.zeros_like(state.solved_board) + + place_location = (row_coord, col_coord) + + grid_with_piece = jax.lax.dynamic_update_slice( + grid_with_piece, piece, place_location + ) + + return grid_with_piece + + def _observation_from_state(self, state: State) -> Observation: + """Creates an observation from a state. + + Args: + state: State to create an observation from. + + Returns: + An observation. + """ + + return Observation( + current_board=state.current_board, + board_action_mask=state.board_action_mask, + piece_action_mask=state.piece_action_mask, + pieces=state.pieces, + ) diff --git a/jumanji/environments/packing/jigsaw/env_test.py b/jumanji/environments/packing/jigsaw/env_test.py new file mode 100644 index 000000000..1734aa406 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/env_test.py @@ -0,0 +1,374 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import chex +import jax +import jax.numpy as jnp +import pytest + +from jumanji.environments.packing.jigsaw.env import JigSaw +from jumanji.environments.packing.jigsaw.generator import ( + ToyJigsawGeneratorNoRotation, + ToyJigsawGeneratorWithRotation, +) +from jumanji.environments.packing.jigsaw.reward import SparseReward +from jumanji.environments.packing.jigsaw.types import State +from jumanji.testing.env_not_smoke import check_env_does_not_smoke +from jumanji.testing.pytrees import assert_is_jax_array_tree +from jumanji.types import StepType, TimeStep + + +@pytest.fixture(scope="module") +def jigsaw() -> JigSaw: + """Creates a JigSaw environment.""" + return JigSaw() + + +@pytest.fixture +def simple_env_board_state_1() -> chex.Array: + """The state of the board in the simplified example after 1 correct action.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 1.0, 0.0, 0.0], + [1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ] + ) + # fmt: on + + +@pytest.fixture +def simple_env_board_state_2() -> chex.Array: + """The state of the board in the simplified example after 2 correct actions.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [0.0, 1.0, 0.0, 0.0, 2.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ] + ) + # fmt: on + + +@pytest.fixture +def simple_env_board_state_3() -> chex.Array: + """The state of the board in the simplified example after 3 correct actions.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [3.0, 1.0, 0.0, 0.0, 2.0], + [3.0, 3.0, 0.0, 0.0, 0.0], + [3.0, 3.0, 3.0, 0.0, 0.0], + ] + ) + # fmt: on + + +@pytest.fixture +def simple_env_board_state_4() -> chex.Array: + """The state of the board in the simplified example after 4 correct actions.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [3.0, 1.0, 4.0, 4.0, 2.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + [3.0, 3.0, 3.0, 4.0, 4.0], + ] + ) + # fmt: on + + +@pytest.fixture +def simple_env_board_action_mask_2() -> chex.Array: + """The state of the board action mask in the simplified example after 2 correct actions.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0, 1.0], + [0.0, 1.0, 0.0, 0.0, 1.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ] + ) + # fmt: on + + +@pytest.fixture +def simple_env_board_action_mask_3() -> chex.Array: + """The state of the board action mask in the simplified example after 3 correct actions.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 0.0, 0.0, 1.0], + [1.0, 1.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 0.0, 0.0], + ] + ) + # fmt: on + + +@pytest.fixture +def simple_env_piece_action_mask_1() -> chex.Array: + """The state of the piece action mask in the simplified example after 1 correct action.""" + return jnp.array([False, True, True, True]) + + +@pytest.fixture +def simple_env_piece_action_mask_2() -> chex.Array: + """The state of the piece action mask in the simplified example after 2 correct actions.""" + return jnp.array([False, False, True, True]) + + +@pytest.fixture +def simple_env_piece_action_mask_3() -> chex.Array: + """The state of the piece action mask in the simplified example after 3 correct actions.""" + return jnp.array([False, False, False, True]) + + +def test_jigsaw__reset_jit(jigsaw: JigSaw, key: chex.PRNGKey) -> None: + """Test that the environment reset only compiles once.""" + chex.clear_trace_counter() + reset_fn = jax.jit(chex.assert_max_traces(jigsaw.reset, n=1)) + state, timestep = reset_fn(key) + + # Check the types of the outputs + assert isinstance(state, State) + assert isinstance(timestep, TimeStep) + + # Check that the state contains DeviceArrays to verify that it is jitted. + assert_is_jax_array_tree(state) + + # Call the reset method again to ensure it is not compiling twice. + key, new_key = jax.random.split(key) + state, timestep = reset_fn(new_key) + assert isinstance(state, State) + assert isinstance(timestep, TimeStep) + + +def test_jigsaw__step_jit(jigsaw: JigSaw, key: chex.PRNGKey) -> None: + """Test that the step function is only compiled once.""" + state_0, timestep_0 = jigsaw.reset(key) + action_0 = jnp.array([0, 0, 0, 0]) + + chex.clear_trace_counter() + step_fn = jax.jit(chex.assert_max_traces(jigsaw.step, n=1)) + + state_1, timestep_1 = step_fn(state_0, action_0) + + # Check that the state has changed and that the step has incremented. + assert not jnp.array_equal(state_1.current_board, state_0.current_board) + assert state_1.step_count == state_0.step_count + 1 + assert isinstance(timestep_1, TimeStep) + + # Check that the state contains DeviceArrays to verify that it is jitted. + assert_is_jax_array_tree(state_1) + + # Call the step method again to ensure it is not compiling twice. + action_1 = jnp.array([1, 0, 2, 2]) + state_2, timestep_2 = step_fn(state_1, action_1) + + # Check that the state contains DeviceArrays to verify that it is jitted. + assert_is_jax_array_tree(state_2) + + # Check that the state has changed and that the step has incremented. + assert not jnp.array_equal(state_2.current_board, state_1.current_board) + assert state_2.step_count == state_1.step_count + 1 + assert isinstance(timestep_2, TimeStep) + + +def test_jigsaw__does_not_smoke(jigsaw: JigSaw) -> None: + """Test that we can run an episode without any errors.""" + check_env_does_not_smoke(jigsaw) + + +def test_jigsaw___check_done(jigsaw: JigSaw, key: chex.PRNGKey) -> None: + """Test that the check_done method works as expected.""" + + state, _ = jigsaw.reset(key) + assert not jigsaw._check_done(state) + + # Manually set step count equal to the number of pieces. + state.step_count = 9 + assert jigsaw._check_done(state) + + +def test_jigsaw___expand_piece_to_board( + jigsaw: JigSaw, key: chex.PRNGKey, piece: chex.Array +) -> None: + """Test that a piece is correctly set on a grid of zeros.""" + + state, _ = jigsaw.reset(key) + expanded_grid_with_piece = jigsaw._expand_piece_to_board(state, piece, 2, 1) + # fmt: off + expected_expanded_grid = jnp.array( + [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ] + ) + # fmt: on + assert jnp.array_equal(expanded_grid_with_piece, expected_expanded_grid) + + +def test_jigsaw__completed_episode_with_dense_reward( + key: chex.PRNGKey, + simple_env_board_state_1: chex.Array, + simple_env_board_state_2: chex.Array, + simple_env_board_state_3: chex.Array, + simple_env_board_state_4: chex.Array, + simple_env_board_action_mask_2: chex.Array, + simple_env_board_action_mask_3: chex.Array, + simple_env_piece_action_mask_1: chex.Array, + simple_env_piece_action_mask_2: chex.Array, + simple_env_piece_action_mask_3: chex.Array, +) -> None: + """This test will step a simplified version of the Jigsaw environment + with a dense reward until completion. It will check that the reward is + correctly computed and that the environment transitions as expected until + done.""" + + simple_env = JigSaw( + generator=ToyJigsawGeneratorNoRotation(), + ) + chex.clear_trace_counter() + step_fn = jax.jit(chex.assert_max_traces(simple_env.step, n=1)) + + # Intialize the environment + state, timestep = simple_env.reset(key) + assert isinstance(state, State) + assert isinstance(timestep, TimeStep) + assert timestep.step_type == StepType.FIRST + + # Check that the reset board contains only zeros + assert jnp.all(state.current_board == 0) + assert jnp.all(state.piece_action_mask) + assert jnp.all(state.board_action_mask == 0) + + # Step the environment + state, timestep = step_fn(state, jnp.array([0, 0, 0, 0])) + assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_1) + assert timestep.reward == 6.0 + assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_1) + assert jnp.all(state.board_action_mask == simple_env_board_state_1) + + # Step the environment + state, timestep = step_fn(state, jnp.array([1, 0, 0, 2])) + assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_2) + assert timestep.reward == 6.0 + assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_2) + assert jnp.all(state.board_action_mask == simple_env_board_action_mask_2) + + # Step the environment + state, timestep = step_fn(state, jnp.array([2, 0, 2, 0])) + assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_3) + assert timestep.reward == 6.0 + assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_3) + assert jnp.all(state.board_action_mask == simple_env_board_action_mask_3) + + # Step the environment + state, timestep = step_fn(state, jnp.array([3, 0, 2, 2])) + assert timestep.step_type == StepType.LAST + assert jnp.all(state.current_board == simple_env_board_state_4) + assert timestep.reward == 7.0 + assert not jnp.all(state.piece_action_mask) + assert jnp.all(state.board_action_mask == jnp.ones_like(simple_env_board_state_4)) + + +def test_jigsaw__completed_episode_with_sparse_reward( + key: chex.PRNGKey, + simple_env_board_state_1: chex.Array, + simple_env_board_state_2: chex.Array, + simple_env_board_state_3: chex.Array, + simple_env_board_state_4: chex.Array, + simple_env_board_action_mask_2: chex.Array, + simple_env_board_action_mask_3: chex.Array, + simple_env_piece_action_mask_1: chex.Array, + simple_env_piece_action_mask_2: chex.Array, + simple_env_piece_action_mask_3: chex.Array, +) -> None: + """This test will step a simplified version of the Jigsaw environment + with a sparse reward until completion. It will check that the reward is + correctly computed and that the environment transitions as expected until + done.""" + + simple_env = JigSaw( + generator=ToyJigsawGeneratorWithRotation(), + reward_fn=SparseReward(), + ) + chex.clear_trace_counter() + step_fn = jax.jit(chex.assert_max_traces(simple_env.step, n=1)) + + # Intialize the environment + state, timestep = simple_env.reset(key) + assert isinstance(state, State) + assert isinstance(timestep, TimeStep) + assert timestep.step_type == StepType.FIRST + + # Check that the reset board contains only zeros + assert jnp.all(state.current_board == 0) + assert jnp.all(state.piece_action_mask) + assert jnp.all(state.board_action_mask == 0) + + # Step the environment + state, timestep = step_fn(state, jnp.array([0, 2, 0, 0])) + assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_1) + assert timestep.reward == 0.0 + assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_1) + assert jnp.all(state.board_action_mask == simple_env_board_state_1) + + # Step the environment + state, timestep = step_fn(state, jnp.array([1, 2, 0, 2])) + assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_2) + assert timestep.reward == 0.0 + assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_2) + assert jnp.all(state.board_action_mask == simple_env_board_action_mask_2) + + # Step the environment + state, timestep = step_fn(state, jnp.array([2, 1, 2, 0])) + assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_3) + assert timestep.reward == 0.0 + assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_3) + assert jnp.all(state.board_action_mask == simple_env_board_action_mask_3) + + # Step the environment + state, timestep = step_fn(state, jnp.array([3, 0, 2, 2])) + assert timestep.step_type == StepType.LAST + assert jnp.all(state.current_board == simple_env_board_state_4) + assert timestep.reward == 1.0 + assert not jnp.all(state.piece_action_mask) + assert jnp.all(state.board_action_mask == jnp.ones_like(simple_env_board_state_4)) diff --git a/jumanji/environments/packing/jigsaw/generator.py b/jumanji/environments/packing/jigsaw/generator.py new file mode 100644 index 000000000..57e37fc9b --- /dev/null +++ b/jumanji/environments/packing/jigsaw/generator.py @@ -0,0 +1,399 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc +from typing import Tuple + +import chex +import jax +import jax.numpy as jnp + +from jumanji.environments.packing.jigsaw.types import State +from jumanji.environments.packing.jigsaw.utils import ( + compute_grid_dim, + get_significant_idxs, + rotate_piece, +) + + +class InstanceGenerator(abc.ABC): + """Base class for generators for the jigsaw environment. An `InstanceGenerator` is responsible + for generating a problem instance when the environment is reset. + """ + + def __init__( + self, + num_row_pieces: int, + num_col_pieces: int, + ) -> None: + """Initialises a jigsaw generator, used to generate puzzles for the Jigsaw environment. + Args: + num_row_pieces: Number of row pieces in jigsaw puzzle. + num_col_pieces: Number of column pieces in jigsaw puzzle. + """ + + self.num_row_pieces = num_row_pieces + self.num_col_pieces = num_col_pieces + + @abc.abstractmethod + def __call__(self, key: chex.PRNGKey) -> State: + """Call method responsible for generating a new state. + + Args: + key: jax random key in case stochasticity is used in the instance generation process. + + Returns: + A `JigSaw` environment state. + """ + + raise NotImplementedError + + +class RandomJigsawGenerator(InstanceGenerator): + """Random jigsaw generator. This generator will generate a random jigsaw puzzle.""" + + def __init__( + self, + num_row_pieces: int, + num_col_pieces: int, + ): + """Initialises a random jigsaw generator. + Args: + num_row_pieces: Number of row pieces in jigsaw puzzle. + num_col_pieces: Number of column pieces in jigsaw puzzle. + """ + super().__init__(num_row_pieces, num_col_pieces) + + def _fill_grid_columns( + self, carry: Tuple[chex.Array, int], arr_value: int + ) -> Tuple[Tuple[chex.Array, int], int]: + """Fills the grid columns with a value. + This function will fill the grid columns with a value that + is incremented by 1 each time it is called. + """ + grid = carry[0] + grid_x, _ = grid.shape + fill_value = carry[1] + + fill_value += 1 + + edit_grid = jax.lax.dynamic_slice(grid, (0, arr_value), (grid_x, 3)) + edit_grid = jnp.ones_like(edit_grid) + edit_grid *= fill_value + + grid = jax.lax.dynamic_update_slice(grid, edit_grid, (0, arr_value)) + + return (grid, fill_value), arr_value + + def _fill_grid_rows( + self, carry: Tuple[chex.Array, int, int], arr_value: int + ) -> Tuple[Tuple[chex.Array, int, int], int]: + """Fills the grid rows with a value. + This function will fill the grid rows with a value that + is incremented by `num_col_pieces` each time it is called. + """ + grid = carry[0] + _, grid_y = grid.shape + sum_value = carry[1] + num_col_pieces = carry[2] + + edit_grid = jax.lax.dynamic_slice(grid, (arr_value, 0), (3, grid_y)) + edit_grid += sum_value + + sum_value += num_col_pieces + + grid = jax.lax.dynamic_update_slice(grid, edit_grid, (arr_value, 0)) + + return (grid, sum_value, num_col_pieces), arr_value + + def _select_sides(self, array: chex.Array, key: chex.PRNGKey) -> chex.Array: + """Randomly selects a value to replace the center value of an array + containing three values.""" + + selector = jax.random.uniform(key, shape=()) + + center_val = jax.lax.cond( + selector > 0.5, + lambda: array[0], + lambda: array[2], + ) + + array = array.at[1].set(center_val) + + return array + + def _select_col_nibs( + self, carry: Tuple[chex.Array, chex.PRNGKey], col: int + ) -> Tuple[Tuple[chex.Array, chex.PRNGKey], int]: + """Creates the nibs for puzzle pieces along columns by randomly selecting + a value from the left and right side of the column.""" + + grid = carry[0] + key = carry[1] + rows = grid.shape[0] + + grid_slice = jax.lax.dynamic_slice(grid, (0, col - 1), (rows, 3)) + all_keys = jax.random.split(key, rows + 1) + key = all_keys[0] + select_keys = all_keys[1:] + filled_grid_slice = jax.vmap(self._select_sides)(grid_slice, select_keys) + + grid = jax.lax.dynamic_update_slice(grid, filled_grid_slice, (0, col - 1)) + + return (grid, key), col + + def _select_row_nibs( + self, carry: Tuple[chex.Array, chex.PRNGKey], row: int + ) -> Tuple[Tuple[chex.Array, chex.PRNGKey], int]: + """Creates the nibs for puzzle pieces along rows by randomly selecting + a value from the piece above and below the current piece.""" + + grid = carry[0] + key = carry[1] + cols = grid.shape[1] + + grid_slice = jax.lax.dynamic_slice(grid, (row - 1, 0), (3, cols)) + + grid_slice = grid_slice.T + + all_keys = jax.random.split(key, cols + 1) + key = all_keys[0] + select_keys = all_keys[1:] + + filled_grid_slice = jax.vmap(self._select_sides)(grid_slice, select_keys) + filled_grid_slice = filled_grid_slice.T + + grid = jax.lax.dynamic_update_slice(grid, filled_grid_slice, (row - 1, 0)) + + return (grid, key), row + + def _first_nonzero( + self, arr: chex.Array, axis: int, invalid_val: int = 1000 + ) -> chex.Numeric: + """Returns the index of the first non-zero value in an array.""" + mask = arr != 0 + return jnp.min( + jnp.where(mask.any(axis=axis), mask.argmax(axis=axis), invalid_val) + ) + + def _crop_nonzero(self, arr_: chex.Array) -> chex.Array: + """Crops a piece to the size of the piece size.""" + + row_roll, col_roll = self._first_nonzero(arr_, axis=0), self._first_nonzero( + arr_, axis=1 + ) + + arr_ = jnp.roll(arr_, -row_roll, axis=0) + arr_ = jnp.roll(arr_, -col_roll, axis=1) + + cropped_arr = jnp.zeros((3, 3), dtype=jnp.float32) + + cropped_arr = cropped_arr.at[:, :].set(arr_[:3, :3]) + + return cropped_arr + + def _extract_piece( + self, carry: Tuple[chex.Array, chex.PRNGKey], piece_num: int + ) -> Tuple[Tuple[chex.Array, chex.PRNGKey], chex.Array]: + """Extracts a puzzle piece from a solved board according to its piece number + and rotates it by a random amount of degrees.""" + + grid, key = carry + + # create a boolean mask for the current piece number + mask = grid == piece_num + # use the mask to extract the piece from the grid + piece = jnp.where(mask, grid, 0) + + # Crop piece + piece = self._crop_nonzero(piece) + + # Rotate piece by random amount of degrees {0, 90, 180, 270} + key, rot_key = jax.random.split(key) + rotation_value = jax.random.randint(key=rot_key, shape=(), minval=0, maxval=4) + rotated_piece = rotate_piece(piece, rotation_value) + + return (grid, key), rotated_piece + + def __call__(self, key: chex.PRNGKey) -> State: + """Generates a random jigsaw puzzle. + + Args: + key: jax random key in case stochasticity is used in the instance generation process. + + Returns: + A `JigSaw` environment state. + """ + + num_pieces = self.num_row_pieces * self.num_col_pieces + + # Compute the size of the puzzle board. + grid_row_dim = compute_grid_dim(self.num_row_pieces) + grid_col_dim = compute_grid_dim(self.num_col_pieces) + + # Get indices of puzzle where nibs will be placed. + row_nibs_idxs = get_significant_idxs(grid_row_dim) + col_nibs_idxs = get_significant_idxs(grid_col_dim) + + # Create an empty puzzle grid. + grid = jnp.ones((grid_row_dim, grid_col_dim)) + + # Fill grid columns with piece numbers + (grid, _), _ = jax.lax.scan( + f=self._fill_grid_columns, + init=(grid, 1), + xs=col_nibs_idxs, + ) + + # Fill grid rows with piece numbers + (grid, _, _), _ = jax.lax.scan( + f=self._fill_grid_rows, + init=( + grid, + self.num_col_pieces, + self.num_col_pieces, + ), + xs=row_nibs_idxs, + ) + + # Create puzzle nibs at relevant rows and columns. + (grid, key), _ = jax.lax.scan( + f=self._select_col_nibs, init=(grid, key), xs=col_nibs_idxs + ) + + (solved_board, key), _ = jax.lax.scan( + f=self._select_row_nibs, init=(grid, key), xs=row_nibs_idxs + ) + + # Extract pieces from the solved board + _, pieces = jax.lax.scan( + f=self._extract_piece, + init=(solved_board, key), + xs=jnp.arange(1, num_pieces + 1), + ) + + # Finally shuffle the pieces along the leading dimension to + # untangle a pieces number from its position in the pieces array. + key, shuffle_pieces_key = jax.random.split(key) + pieces = jax.random.permutation( + key=shuffle_pieces_key, x=pieces, axis=0, independent=False + ) + + return State( + solved_board=solved_board, + pieces=pieces, + num_pieces=num_pieces, + col_nibs_idxs=col_nibs_idxs, + row_nibs_idxs=row_nibs_idxs, + board_action_mask=jnp.zeros_like(solved_board), + piece_action_mask=jnp.ones(num_pieces, dtype=bool), + current_board=jnp.zeros_like(solved_board), + step_count=0, + key=key, + ) + + +class ToyJigsawGeneratorWithRotation(InstanceGenerator): + """Generates a deterministic toy Jigsaw puzzle with 4 pieces. The pieces are + rotated by a random amount of degrees {0, 90, 180, 270} but not shuffled.""" + + def __init__(self) -> None: + super().__init__(num_row_pieces=2, num_col_pieces=2) + + def __call__(self, key: chex.PRNGKey) -> State: + + del key + + mock_solved_grid = jnp.array( + [ + [1.0, 1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [3.0, 1.0, 4.0, 4.0, 2.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + [3.0, 3.0, 3.0, 4.0, 4.0], + ], + dtype=jnp.float32, + ) + + mock_pieces = jnp.array( + [ + [[0.0, 1.0, 0.0], [0.0, 1.0, 1.0], [1.0, 1.0, 1.0]], + [[2.0, 0.0, 0.0], [2.0, 2.0, 2.0], [2.0, 2.0, 0.0]], + [[0.0, 0.0, 3.0], [0.0, 3.0, 3.0], [3.0, 3.0, 3.0]], + [[4.0, 4.0, 0.0], [4.0, 4.0, 4.0], [0.0, 4.0, 4.0]], + ], + dtype=jnp.float32, + ) + + return State( + solved_board=mock_solved_grid, + pieces=mock_pieces, + current_board=jnp.zeros_like(mock_solved_grid), + board_action_mask=jnp.zeros_like(mock_solved_grid), + piece_action_mask=jnp.ones(4, dtype=bool), + col_nibs_idxs=jnp.array([2], dtype=jnp.int32), + row_nibs_idxs=jnp.array([2], dtype=jnp.int32), + num_pieces=jnp.int32(4), + key=jax.random.PRNGKey(0), + step_count=0, + ) + + +class ToyJigsawGeneratorNoRotation(InstanceGenerator): + """Generates a deterministic toy Jigsaw puzzle with 4 pieces. The pieces + are not rotated and not shuffled.""" + + def __init__(self) -> None: + super().__init__( + num_row_pieces=2, + num_col_pieces=2, + ) + + def __call__(self, key: chex.PRNGKey) -> State: + + del key + + mock_solved_grid = jnp.array( + [ + [1.0, 1.0, 1.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [3.0, 1.0, 4.0, 4.0, 2.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + [3.0, 3.0, 3.0, 4.0, 4.0], + ], + dtype=jnp.float32, + ) + + mock_pieces = jnp.array( + [ + [[1.0, 1.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0.0, 2.0, 2.0], [2.0, 2.0, 2.0], [0.0, 0.0, 2.0]], + [[3.0, 0.0, 0.0], [3.0, 3.0, 0.0], [3.0, 3.0, 3.0]], + [[4.0, 4.0, 0.0], [4.0, 4.0, 4.0], [0.0, 4.0, 4.0]], + ], + dtype=jnp.float32, + ) + + return State( + solved_board=mock_solved_grid, + pieces=mock_pieces, + col_nibs_idxs=jnp.array([2], dtype=jnp.int32), + row_nibs_idxs=jnp.array([2], dtype=jnp.int32), + num_pieces=jnp.int32(4), + key=jax.random.PRNGKey(0), + board_action_mask=jnp.zeros_like(mock_solved_grid), + piece_action_mask=jnp.ones(4, dtype=bool), + current_board=jnp.zeros_like(mock_solved_grid), + step_count=0, + ) diff --git a/jumanji/environments/packing/jigsaw/generator_test.py b/jumanji/environments/packing/jigsaw/generator_test.py new file mode 100644 index 000000000..623a91ff8 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/generator_test.py @@ -0,0 +1,245 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import chex +import jax +import jax.numpy as jnp +import pytest + +from jumanji.environments.packing.jigsaw.generator import RandomJigsawGenerator + + +@pytest.fixture +def random_jigsaw_generator() -> RandomJigsawGenerator: + """Creates a generator with two row pieces and two column pieces.""" + return RandomJigsawGenerator( + num_col_pieces=2, + num_row_pieces=2, + ) + + +@pytest.fixture +def grid_only_ones() -> chex.Array: + """A grid with only ones.""" + return jnp.ones((5, 5)) + + +@pytest.fixture +def grid_columns_partially_filled() -> chex.Array: + """A grid after one iteration of _fill_grid_columns.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 2.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + ] + ) + # fmt: on + + +@pytest.fixture +def grid_rows_partially_filled() -> chex.Array: + """A grid after one iteration of _fill_grid_rows.""" + # fmt: off + return jnp.array( + [ + [1.0, 1.0, 2.0, 2.0, 2.0], + [1.0, 1.0, 2.0, 2.0, 2.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + [3.0, 3.0, 4.0, 4.0, 4.0], + ] + ) + # fmt: on + + +def test_random_jigsaw_generator__call( + random_jigsaw_generator: RandomJigsawGenerator, key: chex.PRNGKey +) -> None: + """Test that generator generates a valid state.""" + state = random_jigsaw_generator(key) + assert state.solved_board.shape == (5, 5) + assert state.num_pieces == 4 + assert state.pieces.shape == (4, 3, 3) + assert all(state.pieces[i].shape == (3, 3) for i in range(4)) + assert state.col_nibs_idxs == jnp.array([2]) + assert state.row_nibs_idxs == jnp.array([2]) + assert state.board_action_mask.shape == (5, 5) + assert state.piece_action_mask.shape == (4,) + assert state.step_count == 0 + + +def test_random_jigsaw_generator__no_retrace( + random_jigsaw_generator: RandomJigsawGenerator, key: chex.PRNGKey +) -> None: + """Checks that generator call method is only traced once when jitted.""" + keys = jax.random.split(key, 2) + jitted_generator = jax.jit( + chex.assert_max_traces((random_jigsaw_generator.__call__), n=1) + ) + + for key in keys: + jitted_generator(key) + + +def test_random_jigsaw_generator__fill_grid_columns( + random_jigsaw_generator: RandomJigsawGenerator, + grid_only_ones: chex.Array, + grid_columns_partially_filled: chex.Array, +) -> None: + """Checks that _fill_grid_columns method does a single + step correctly.""" + + (grid, fill_value), arr_value = random_jigsaw_generator._fill_grid_columns( + (grid_only_ones, 1), 2 + ) + + assert grid.shape == (5, 5) + assert jnp.array_equal(grid, grid_columns_partially_filled) + assert fill_value == 2 + assert arr_value == 2 + + +def test_random_jigsaw_generator__fill_grid_rows( + random_jigsaw_generator: RandomJigsawGenerator, + grid_columns_partially_filled: chex.Array, + grid_rows_partially_filled: chex.Array, +) -> None: + """Checks that _fill_grid_columns method does a single + step correctly.""" + + ( + grid, + sum_value, + num_col_pieces, + ), arr_value = random_jigsaw_generator._fill_grid_rows( + (grid_columns_partially_filled, 2, 2), 2 + ) + + assert grid.shape == (5, 5) + assert jnp.array_equal(grid, grid_rows_partially_filled) + assert sum_value == 4 + assert num_col_pieces == 2 + assert arr_value == 2 + + +def test_random_jigsaw_generator__select_sides( + random_jigsaw_generator: RandomJigsawGenerator, key: chex.PRNGKey +) -> None: + """Checks that _select_sides method correctly assigns the + middle value in an array with shape (3,) to either the value + at index 0 or 2.""" + + side_chosen_array = random_jigsaw_generator._select_sides( + jnp.array([1.0, 2.0, 3.0]), key + ) + + assert side_chosen_array.shape == (3,) + # check that the output is different from the input + assert jnp.not_equal(jnp.array([1.0, 2.0, 3.0]), side_chosen_array).any() + + +def test_random_jigsaw_generator__select_col_nibs( + random_jigsaw_generator: RandomJigsawGenerator, + grid_rows_partially_filled: chex.Array, + key: chex.PRNGKey, +) -> None: + """Checks that nibs are created along a given column of the puzzle grid.""" + + ( + grid_with_nibs_selected, + new_key, + ), column = random_jigsaw_generator._select_col_nibs( + (grid_rows_partially_filled, key), 2 + ) + + assert grid_with_nibs_selected.shape == (5, 5) + assert jnp.not_equal(key, new_key).all() + assert column == 2 + + selected_col_nibs = grid_with_nibs_selected[:, 2] + before_selected_nibs_col = grid_rows_partially_filled[:, 2] + + # check that the nibs are different from the column before + assert jnp.not_equal(selected_col_nibs, before_selected_nibs_col).any() + + +def test_random_jigsaw_generator__select_row_nibs( + random_jigsaw_generator: RandomJigsawGenerator, + grid_rows_partially_filled: chex.Array, + key: chex.PRNGKey, +) -> None: + """Checks that nibs are created along a given row of the puzzle grid.""" + + (grid_with_nibs_selected, new_key), row = random_jigsaw_generator._select_row_nibs( + (grid_rows_partially_filled, key), 2 + ) + + assert grid_with_nibs_selected.shape == (5, 5) + assert jnp.not_equal(key, new_key).all() + assert row == 2 + + selected_row_nibs = grid_with_nibs_selected[2, :] + before_selected_nibs_row = grid_rows_partially_filled[2, :] + + # check that the nibs are different from the row before + assert jnp.not_equal(selected_row_nibs, before_selected_nibs_row).any() + + +def test_random_jigsaw_generator__first_nonzero( + random_jigsaw_generator: RandomJigsawGenerator, + piece_one_partially_placed: chex.Array, +) -> None: + """Checks that the indices of the first non-zero value in a grid is found correctly.""" + first_nonzero_row = random_jigsaw_generator._first_nonzero( + piece_one_partially_placed, 0 + ) + first_nonzero_col = random_jigsaw_generator._first_nonzero( + piece_one_partially_placed, 1 + ) + + assert first_nonzero_row == 1 + assert first_nonzero_col == 1 + + +def test_random_jigsaw_generator__crop_nonzero( + random_jigsaw_generator: RandomJigsawGenerator, + piece_one_partially_placed: chex.Array, +) -> None: + """Checks a piece is correctly extracted from a grid of zeros.""" + cropped_piece = random_jigsaw_generator._crop_nonzero(piece_one_partially_placed) + + assert cropped_piece.shape == (3, 3) + assert jnp.array_equal( + cropped_piece, jnp.array([[1.0, 1.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]) + ) + + +def test_random_jigsaw_generator__extract_piece( + random_jigsaw_generator: RandomJigsawGenerator, + solved_board: chex.Array, + key: chex.PRNGKey, +) -> None: + """Checks that a piece is correctly extracted from a solved puzzle grid.""" + + # extract piece number 3 + (_, new_key), piece = random_jigsaw_generator._extract_piece((solved_board, key), 3) + + assert piece.shape == (3, 3) + assert jnp.not_equal(key, new_key).all() + # check that the piece only contains 3s or 0s + assert jnp.isin(piece, jnp.array([0.0, 3.0])).all() diff --git a/jumanji/environments/packing/jigsaw/jigsaw_example.py b/jumanji/environments/packing/jigsaw/jigsaw_example.py new file mode 100644 index 000000000..c50c3f800 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/jigsaw_example.py @@ -0,0 +1,140 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time + +import chex +import jax +from jax import numpy as jnp + +from jumanji.environments.packing.jigsaw.env import JigSaw +from jumanji.environments.packing.jigsaw.generator import ( + RandomJigsawGenerator, + ToyJigsawGeneratorNoRotation, +) +from jumanji.environments.packing.jigsaw.reward import SparseReward + +SAVE_GIF = False + +# Very basic example of a random agent acting in the Jigsaw environment. +# Each episode will generate a completely new instance of the jigsaw puzzle. +env = JigSaw( + generator=RandomJigsawGenerator( + num_col_pieces=5, + num_row_pieces=5, + ), +) +action_spec = env.action_spec() +step_key = jax.random.PRNGKey(1) +jit_step = jax.jit(chex.assert_max_traces(env.step, n=1)) +jit_reset = jax.jit(chex.assert_max_traces(env.reset, n=1)) +episode_returns: list = [] +states: list = [] +for ep in range(50): + step_key, reset_key = jax.random.split(step_key) + state, timestep = jit_reset(key=reset_key) + states.append(state) + episode_return = 0 + ep_steps = 0 + start_time = time.time() + while not timestep.last(): + step_key, piece_key, rot_key, row_key, col_key = jax.random.split(step_key, 5) + piece_id = jax.random.randint( + piece_key, shape=(), minval=0, maxval=action_spec.maximum[0] + 1 + ) + rotation = jax.random.randint( + rot_key, shape=(), minval=0, maxval=action_spec.maximum[1] + 1 + ) + row = jax.random.randint( + row_key, shape=(), minval=0, maxval=action_spec.maximum[2] + ) + col = jax.random.randint( + col_key, shape=(), minval=0, maxval=action_spec.maximum[3] + ) + + action = jnp.array([piece_id, rotation, row, col]) + state, timestep = jit_step(state, action) + states.append(state) + episode_return += timestep.reward + ep_steps += 1 + + sps = ep_steps / (time.time() - start_time) + episode_returns.append(episode_return) + if ep % 10 == 0: + print( + f"EPISODE RETURN: {episode_return}, STEPS PER SECOND: {int(sps)}," + f" ENVIRONMENT STEPS: {ep_steps}" + ) + +print(f"Average return: {jnp.mean(jnp.array(episode_returns))}, SPS: {int(sps)}\n") + +if SAVE_GIF: + env.animate(states=states, interval=200, save_path="big_env.gif") + +# An example of solving a puzzle by stepping a +# dummy environment with a dense reward function. +print("STARTING DENSE REWARD EXAMPLE") +env = JigSaw(generator=ToyJigsawGeneratorNoRotation()) +state, timestep = env.reset(step_key) +print("CURRENT BOARD:") +print(state.current_board, "\n") +state, timestep = env.step(state, jnp.array([0, 0, 0, 0])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward, "\n") +state, timestep = env.step(state, jnp.array([1, 0, 0, 2])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward, "\n") +state, timestep = env.step(state, jnp.array([2, 0, 2, 0])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward, "\n") +state, timestep = env.step(state, jnp.array([3, 0, 2, 2])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward) +print() + +# An example of solving a puzzle by stepping a +# dummy environment with a sparse reward function. +print("STARTING SPARSE REWARD EXAMPLE") +env = JigSaw(generator=ToyJigsawGeneratorNoRotation(), reward_fn=SparseReward()) +state, timestep = env.reset(step_key) +print("CURRENT BOARD:") +print(state.current_board, "\n") +state, timestep = env.step(state, jnp.array([0, 0, 0, 0])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward, "\n") +state, timestep = env.step(state, jnp.array([1, 0, 0, 2])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward, "\n") +state, timestep = env.step(state, jnp.array([2, 0, 2, 0])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward, "\n") +state, timestep = env.step(state, jnp.array([3, 0, 2, 2])) +print("CURRENT BOARD:") +print(state.current_board, "\n") +print("STEP REWARD:") +print(timestep.reward) diff --git a/jumanji/environments/packing/jigsaw/reward.py b/jumanji/environments/packing/jigsaw/reward.py new file mode 100644 index 000000000..5141584e8 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/reward.py @@ -0,0 +1,100 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import abc + +import chex +import jax +import jax.numpy as jnp + +from jumanji.environments.packing.jigsaw.types import State + + +class RewardFn(abc.ABC): + @abc.abstractmethod + def __call__( + self, + state: State, + action: chex.Numeric, + next_state: State, + is_valid: bool, + is_done: bool, + ) -> chex.Numeric: + """Compute the reward based on the current state, the chosen action, + whether the action is valid and whether the episode is terminated. + """ + + +class DenseReward(RewardFn): + """Reward function for the dense reward setting.""" + + def __call__( + self, + state: State, + action: chex.Numeric, + next_state: State, + is_valid: bool, + is_done: bool, + ) -> chex.Numeric: + """Compute the reward based on the current state, the chosen action, + whether the action is valid and whether the episode is terminated. + + Note here, that the action taken is not the raw action received from the + agent, but the piece the agent opted to place on the board. + """ + del is_done + del next_state + + reward = jax.lax.cond( + is_valid, + lambda: jnp.sum(jnp.equal(state.solved_board, action), dtype=jnp.float32), + lambda: jnp.float32(0.0), + ) + + return reward + + +class SparseReward(RewardFn): + """Reward function for the dense reward setting.""" + + def __call__( + self, + state: State, + action: chex.Numeric, + next_state: State, + is_valid: bool, + is_done: bool, + ) -> chex.Numeric: + """Compute the reward based on the current state, the chosen action, + the next state, whether the action is valid and whether the episode is terminated. + + Note here, that the action taken is not the raw action received from the + agent, but the piece the agent opted to place on the board. + """ + + del action + + completed_correctly = ( + is_done + & jnp.all(jnp.equal(state.solved_board, next_state.current_board)) + & is_valid + ) + + reward = jax.lax.cond( + completed_correctly, + lambda: jnp.float32(1.0), + lambda: jnp.float32(0.0), + ) + + return reward diff --git a/jumanji/environments/packing/jigsaw/reward_test.py b/jumanji/environments/packing/jigsaw/reward_test.py new file mode 100644 index 000000000..479e2e0bc --- /dev/null +++ b/jumanji/environments/packing/jigsaw/reward_test.py @@ -0,0 +1,259 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import chex +import jax +import jax.numpy as jnp +import pytest + +from jumanji.environments.packing.jigsaw.reward import DenseReward, SparseReward +from jumanji.environments.packing.jigsaw.types import State + + +@pytest.fixture +def pieces() -> chex.Array: + """An array containing 4 pieces.""" + + return jnp.array( + [ + [[1.0, 1.0, 1.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]], + [[0.0, 2.0, 2.0], [2.0, 2.0, 2.0], [0.0, 0.0, 2.0]], + [[3.0, 0.0, 0.0], [3.0, 3.0, 0.0], [3.0, 3.0, 3.0]], + [[4.0, 4.0, 0.0], [4.0, 4.0, 4.0], [0.0, 4.0, 4.0]], + ], + dtype=jnp.float32, + ) + + +@pytest.fixture() +def state_with_no_pieces_placed( + solved_board: chex.Array, key: chex.PRNGKey, pieces: chex.Array +) -> State: + """A board state with no pieces placed.""" + + return State( + row_nibs_idxs=jnp.array([2]), + col_nibs_idxs=jnp.array([2]), + num_pieces=4, + pieces=pieces, + solved_board=solved_board, + board_action_mask=jnp.zeros_like(solved_board), + piece_action_mask=jnp.ones(4, dtype=bool), + current_board=jnp.zeros_like(solved_board), + step_count=0, + key=key, + ) + + +@pytest.fixture() +def state_with_piece_one_placed( + solved_board: chex.Array, + board_with_piece_one_placed: chex.Array, + pieces: chex.Array, + key: chex.PRNGKey, +) -> State: + """A board state with piece one placed.""" + + key, new_key = jax.random.split(key) + return State( + row_nibs_idxs=jnp.array([2]), + col_nibs_idxs=jnp.array([2]), + num_pieces=4, + solved_board=solved_board, + board_action_mask=board_with_piece_one_placed, + piece_action_mask=jnp.array( + [ + False, + True, + True, + True, + ] + ), + current_board=board_with_piece_one_placed, + step_count=0, + key=new_key, + pieces=pieces, + ) + + +@pytest.fixture() +def state_needing_only_piece_one( + solved_board: chex.Array, + board_with_piece_one_placed: chex.Array, + pieces: chex.Array, + key: chex.PRNGKey, +) -> State: + """A board state that one needs piece one to be fully completed.""" + + key, new_key = jax.random.split(key) + + board_action_mask = jnp.ones_like(solved_board) - board_with_piece_one_placed + current_board = solved_board - board_with_piece_one_placed + + return State( + row_nibs_idxs=jnp.array([2]), + col_nibs_idxs=jnp.array([2]), + num_pieces=4, + solved_board=solved_board, + board_action_mask=board_action_mask, + piece_action_mask=jnp.array( + [ + True, + False, + True, + True, + ] + ), + current_board=current_board, + step_count=3, + pieces=pieces, + key=new_key, + ) + + +@pytest.fixture() +def solved_state( + solved_board: chex.Array, + pieces: chex.Array, + key: chex.PRNGKey, +) -> State: + """A solved board state.""" + + key, new_key = jax.random.split(key) + + return State( + row_nibs_idxs=jnp.array([2]), + col_nibs_idxs=jnp.array([2]), + num_pieces=4, + solved_board=solved_board, + board_action_mask=jnp.ones_like(solved_board), + piece_action_mask=jnp.array( + [ + False, + False, + False, + False, + ] + ), + current_board=solved_board, + step_count=4, + pieces=pieces, + key=new_key, + ) + + +@pytest.fixture() +def piece_one_misplaced(board_with_piece_one_placed: chex.Array) -> chex.Array: + """A 2D array of zeros where piece one has been placed completely incorrectly. + That is to say that there is no overlap between where the piece has been placed and + where it should be placed to solve the puzzle.""" + + # Shift all elements in the array two down and two to the right + misplaced_piece = jnp.roll(board_with_piece_one_placed, shift=2, axis=0) + misplaced_piece = jnp.roll(misplaced_piece, shift=2, axis=1) + + return misplaced_piece + + +def test_dense_reward( + state_with_no_pieces_placed: State, + state_with_piece_one_placed: State, + piece_one_correctly_placed: chex.Array, + piece_one_partially_placed: chex.Array, + piece_one_misplaced: chex.Array, +) -> None: + + dense_reward = jax.jit(DenseReward()) + + # Test placing piece one completely correctly + reward = dense_reward( + state=state_with_no_pieces_placed, + action=piece_one_correctly_placed, + is_valid=True, + is_done=False, + next_state=state_with_piece_one_placed, + ) + assert reward == 6.0 + + # Test placing piece one partially correct + reward = dense_reward( + state=state_with_no_pieces_placed, + action=piece_one_partially_placed, + is_valid=True, + is_done=False, + next_state=state_with_piece_one_placed, + ) + assert reward == 2.0 + + # Test placing a completely incorrect piece + reward = dense_reward( + state=state_with_no_pieces_placed, + action=piece_one_misplaced, + is_valid=True, + is_done=False, + next_state=state_with_piece_one_placed, + ) + assert reward == 0.0 + + # Test invalid action returns 0 reward. + reward = dense_reward( + state=state_with_no_pieces_placed, + action=piece_one_correctly_placed, + is_valid=False, + is_done=False, + next_state=state_with_piece_one_placed, + ) + assert reward == 0.0 + + +def test_sparse_reward( + state_with_no_pieces_placed: State, + state_with_piece_one_placed: State, + solved_state: State, + state_needing_only_piece_one: State, + piece_one_correctly_placed: chex.Array, +) -> None: + + sparse_reward = jax.jit(SparseReward()) + + # Test that a intermediate step returns 0 reward + reward = sparse_reward( + state=state_with_no_pieces_placed, + action=piece_one_correctly_placed, + next_state=state_with_piece_one_placed, + is_valid=True, + is_done=False, + ) + assert reward == 0.0 + + # Test that having `is_done` set to true does not automatically + # give a reward of 1. + reward = sparse_reward( + state=state_with_no_pieces_placed, + action=piece_one_correctly_placed, + next_state=state_with_piece_one_placed, + is_valid=True, + is_done=True, + ) + assert reward == 0.0 + + # Test that a final correctly placed piece gives 1 reward. + reward = sparse_reward( + state=state_needing_only_piece_one, + action=piece_one_correctly_placed, + next_state=solved_state, + is_valid=True, + is_done=True, + ) + assert reward == 1.0 diff --git a/jumanji/environments/packing/jigsaw/types.py b/jumanji/environments/packing/jigsaw/types.py new file mode 100644 index 000000000..f614c8fcf --- /dev/null +++ b/jumanji/environments/packing/jigsaw/types.py @@ -0,0 +1,71 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import TYPE_CHECKING, NamedTuple + +import chex + +if TYPE_CHECKING: # https://github.com/python/mypy/issues/6239 + from dataclasses import dataclass +else: + from chex import dataclass + + +class Observation(NamedTuple): + """ + current_board: 2D array with the current state of board. + pieces: 3D array with the pieces to be placed on the board. Here each piece is a + 2D array with shape (3, 3). + board_action_mask: 2D array showing where on the board pieces have already been + placed. + piece_action_mask: array showing which pieces can be placed on the board. + """ + + current_board: chex.Array # (num_rows, num_cols) + pieces: chex.Array # (num_pieces, 3, 3) + board_action_mask: chex.Array # (num_rows, num_cols) + piece_action_mask: chex.Array # (num_pieces,) + + +@dataclass +class State: + """ + row_nibs_idxs: array containing row indices for selecting piece nibs. + it will be of length num_sig_rows where sig refers to significant implying + that this row will contain puzzles nibs. + col_nibs_idxs: array containing column indices for selecting piece nibs. + it will be of length num_sig_cols where sig refers to significant implying + that this column will contain puzzles nibs. + num_pieces: number of pieces in the jigsaw puzzle. + solved_board: 2D array showing the solved board state. + pieces: 3D array with the pieces to be placed on the board. Here each piece is a + 2D array with shape (3, 3). + board_action_mask: 2D array showing where pieces on the board have been + placed. + piece_action_mask: array showing which pieces can be placed on the board. + current_board: 2D array with the current state of board. + step_count: number of steps taken in the environment. + key: random key used for board generation. + """ + + row_nibs_idxs: chex.Array # (num_sig_rows,) + col_nibs_idxs: chex.Array # (num_sig_cols,) + num_pieces: chex.Numeric # () + solved_board: chex.Array # (num_rows, num_cols) + pieces: chex.Array # (num_pieces, 3, 3) + board_action_mask: chex.Array # (num_rows, num_cols) + piece_action_mask: chex.Array # (num_pieces,) + current_board: chex.Array # (num_rows, num_cols) + step_count: chex.Numeric # () + key: chex.PRNGKey # (2,) diff --git a/jumanji/environments/packing/jigsaw/utils.py b/jumanji/environments/packing/jigsaw/utils.py new file mode 100644 index 000000000..389ba0d22 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/utils.py @@ -0,0 +1,58 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""A general utils file for the jigsaw environment.""" + +import chex +import jax +import jax.numpy as jnp + + +def compute_grid_dim(num_pieces: int) -> int: + """Computes the grid dimension given the piece dimension and number of pieces. + + Args: + num_pieces: The number of puzzle pieces. + """ + return 3 * num_pieces - (num_pieces - 1) + + +def get_significant_idxs(grid_dim: int) -> chex.Array: + """Returns the indices of the grid that are significant. These will be used + to create puzzle piece nibs. + + Args: + grid_dim: The dimension of the grid. + """ + return jnp.arange(grid_dim)[:: 3 - 1][1:-1] + + +def rotate_piece(piece: chex.Array, rotation_value: int) -> chex.Array: + """Rotates a piece by {0, 90, 180, 270} degrees. + + Args: + piece: The piece to rotate. + rotation: The angle to rotate the piece by. + """ + rotated_piece = jax.lax.switch( + index=rotation_value, + branches=( + lambda arr: arr, + lambda arr: jnp.flip(jnp.transpose(arr), axis=1), + lambda arr: jnp.flip(jnp.flip(arr, axis=0), axis=1), + lambda arr: jnp.flip(jnp.transpose(arr), axis=0), + ), + operand=piece, + ) + + return rotated_piece diff --git a/jumanji/environments/packing/jigsaw/utils_test.py b/jumanji/environments/packing/jigsaw/utils_test.py new file mode 100644 index 000000000..956315c00 --- /dev/null +++ b/jumanji/environments/packing/jigsaw/utils_test.py @@ -0,0 +1,92 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import chex +import jax.numpy as jnp +import pytest + +from jumanji.environments.packing.jigsaw.utils import ( + compute_grid_dim, + get_significant_idxs, + rotate_piece, +) + + +@pytest.mark.parametrize( + "num_pieces, expected_grid_dim", + [ + (1, 3), + (2, 5), + (3, 7), + (4, 9), + (5, 11), + ], +) +def test_compute_grid_dim(num_pieces: int, expected_grid_dim: int) -> None: + """Test that grid dimension are correctly computed given a number of pieces.""" + assert compute_grid_dim(num_pieces) == expected_grid_dim + + +@pytest.mark.parametrize( + "grid_dim, expected_idxs", + [ + (5, jnp.array([2])), + (7, jnp.array([2, 4])), + (9, jnp.array([2, 4, 6])), + (11, jnp.array([2, 4, 6, 8])), + ], +) +def test_get_significant_idxs(grid_dim: int, expected_idxs: chex.Array) -> None: + """Test that significant indices are correctly computed given a grid dimension.""" + assert jnp.all(get_significant_idxs(grid_dim) == expected_idxs) + + +def test_rotate_piece(piece: chex.Array) -> None: + + # Test with no rotation. + rotated_piece = rotate_piece(piece, 0) + assert jnp.array_equal(rotated_piece, piece) + + # Test 90 degree rotation. + expected_rotated_piece = jnp.array( + [ + [0.0, 0.0, 0.0], + [0.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ] + ) + rotated_piece = rotate_piece(piece, 1) + assert jnp.array_equal(rotated_piece, expected_rotated_piece) + + # Test 180 degree rotation. + expected_rotated_piece = jnp.array( + [ + [1.0, 0.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + ] + ) + rotated_piece = rotate_piece(piece, 2) + assert jnp.array_equal(rotated_piece, expected_rotated_piece) + + # Test 270 degree rotation. + expected_rotated_piece = jnp.array( + [ + [1.0, 1.0, 1.0], + [1.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + ] + ) + rotated_piece = rotate_piece(piece, 3) + assert jnp.array_equal(rotated_piece, expected_rotated_piece) diff --git a/jumanji/environments/packing/jigsaw/viewer.py b/jumanji/environments/packing/jigsaw/viewer.py new file mode 100644 index 000000000..4c7d33a8c --- /dev/null +++ b/jumanji/environments/packing/jigsaw/viewer.py @@ -0,0 +1,178 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Callable, Dict, Optional, Sequence, Tuple + +import chex +import matplotlib.animation +import matplotlib.cm +import matplotlib.pyplot as plt +import numpy as np +from numpy.typing import NDArray + +import jumanji.environments +from jumanji.environments.packing.jigsaw.types import State +from jumanji.viewer import Viewer + + +class JigsawViewer(Viewer): + FIGURE_SIZE = (10, 10) + + def __init__(self, name: str, num_pieces: int, render_mode: str = "human") -> None: + """Viewer for a `Jigsaw` environment. + + Args: + name: the window name to be used when initialising the window. + num_pieces: render the environment on screen. + render_mode: return a numpy array frame representing the environment. + """ + self._name = name + + # Pick display method + self._display: Callable[[plt.Figure], Optional[NDArray]] + if render_mode == "rgb_array": + self._display = self._display_rgb_array + elif render_mode == "human": + self._display = self._display_human + else: + raise ValueError(f"Invalid render mode: {render_mode}") + + # Create a color for each piece. + colormap_indices = np.arange(0, 1, 1 / num_pieces) + colormap = matplotlib.cm.get_cmap("hsv", num_pieces + 1) + + self.colors = [(1.0, 1.0, 1.0, 1.0)] # Empty grid colour should be white. + for colormap_idx in colormap_indices: + self.colors.append(colormap(colormap_idx)) + + # The animation must be stored in a variable that lives as long as the + # animation should run. Otherwise, the animation will get garbage-collected. + self._animation: Optional[matplotlib.animation.Animation] = None + + def render(self, state: State) -> Optional[NDArray]: + """Render a Jigsaw environment state. + + Args: + state: the jigsaw environment state to be rendered. + + Returns: + RGB array if the render_mode is RenderMode.RGB_ARRAY. + """ + self._clear_display() + fig, ax = self._get_fig_ax() + ax.clear() + self._add_grid_image(state.current_board, ax) + return self._display(fig) + + def animate( + self, + states: Sequence[State], + interval: int = 200, + save_path: Optional[str] = None, + ) -> matplotlib.animation.FuncAnimation: + """Create an animation from a sequence of Jigsaw states. + + Args: + states: sequence of Jigsaw states corresponding to consecutive timesteps. + interval: delay between frames in milliseconds, default to 200. + save_path: the path where the animation file should be saved. If it is None, the plot + will not be saved. + + Returns: + Animation that can be saved as a GIF, MP4, or rendered with HTML. + """ + fig, ax = plt.subplots( + num=f"{self._name}Animation", figsize=JigsawViewer.FIGURE_SIZE + ) + plt.close(fig) + + def make_frame(state_index: int) -> None: + ax.clear() + state = states[state_index] + self._add_grid_image(state.current_board, ax) + + # Create the animation object. + self._animation = matplotlib.animation.FuncAnimation( + fig, + make_frame, + frames=len(states), + interval=interval, + ) + + # Save the animation as a gif. + if save_path: + self._animation.save(save_path) + + return self._animation + + def close(self) -> None: + plt.close(self._name) + + def _display_human(self, fig: plt.Figure) -> None: + if plt.isinteractive(): + # Required to update render when using Jupyter Notebook. + fig.canvas.draw() + if jumanji.environments.is_notebook(): + plt.show(self._name) + else: + # Required to update render when not using Jupyter Notebook. + fig.canvas.draw_idle() + fig.canvas.flush_events() + + def _display_rgb_array(self, fig: plt.Figure) -> NDArray: + fig.canvas.draw() + return np.asarray(fig.canvas.buffer_rgba()) + + def _clear_display(self) -> None: + if jumanji.environments.is_notebook(): + import IPython.display + + IPython.display.clear_output(True) + + def _get_fig_ax(self) -> Tuple[plt.Figure, plt.Axes]: + recreate = not plt.fignum_exists(self._name) + fig = plt.figure(self._name, JigsawViewer.FIGURE_SIZE) + if recreate: + if not plt.isinteractive(): + fig.show() + ax = fig.add_subplot() + else: + ax = fig.get_axes()[0] + return fig, ax + + def _add_grid_image(self, grid: chex.Array, ax: plt.Axes) -> None: + self._draw_grid(grid, ax) + ax.set_axis_off() + ax.set_aspect(1) + ax.relim() + ax.autoscale_view() + + def _draw_grid(self, grid: chex.Array, ax: plt.Axes) -> None: + # Flip the grid upside down to match the coordinate system of matplotlib. + grid = np.flipud(grid) + rows, cols = grid.shape + + for row in range(rows): + for col in range(cols): + self._draw_grid_cell(grid[row, col], row, col, ax) + + def _draw_grid_cell( + self, cell_value: int, row: int, col: int, ax: plt.Axes + ) -> None: + cell = plt.Rectangle((col, row), 1, 1, **self._get_cell_attributes(cell_value)) + ax.add_patch(cell) + + def _get_cell_attributes(self, cell_value: int) -> Dict[str, Any]: + color = self.colors[int(cell_value)] + return {"facecolor": color, "edgecolor": "black", "linewidth": 1} From 0e6c9942fa40be77a306c821c4ee8666d08ac857 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 22 May 2023 11:37:00 +0200 Subject: [PATCH 02/27] feat: added puzzle numbers to env viewer --- jumanji/environments/packing/jigsaw/viewer.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/jumanji/environments/packing/jigsaw/viewer.py b/jumanji/environments/packing/jigsaw/viewer.py index 4c7d33a8c..df2bd21f0 100644 --- a/jumanji/environments/packing/jigsaw/viewer.py +++ b/jumanji/environments/packing/jigsaw/viewer.py @@ -54,7 +54,9 @@ def __init__(self, name: str, num_pieces: int, render_mode: str = "human") -> No self.colors = [(1.0, 1.0, 1.0, 1.0)] # Empty grid colour should be white. for colormap_idx in colormap_indices: - self.colors.append(colormap(colormap_idx)) + # Give the pieces an alpha of 0.7. + r, g, b, _ = colormap(colormap_idx) + self.colors.append((r, g, b, 0.7)) # The animation must be stored in a variable that lives as long as the # animation should run. Otherwise, the animation will get garbage-collected. @@ -172,6 +174,16 @@ def _draw_grid_cell( ) -> None: cell = plt.Rectangle((col, row), 1, 1, **self._get_cell_attributes(cell_value)) ax.add_patch(cell) + if cell_value != 0: + ax.text( + col + 0.5, + row + 0.5, + str(int(cell_value)), + color="#606060", + ha="center", + va="center", + fontsize="xx-large", + ) def _get_cell_attributes(self, cell_value: int) -> Dict[str, Any]: color = self.colors[int(cell_value)] From 19f21f13acf9fb59a199775c65d7338b55d88a16 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 22 May 2023 11:48:56 +0200 Subject: [PATCH 03/27] feat: initial code for random agent network. --- jumanji/training/configs/config.yaml | 2 +- jumanji/training/networks/jigsaw/__init__.py | 13 +++++++++++++ jumanji/training/networks/jigsaw/random.py | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 jumanji/training/networks/jigsaw/__init__.py create mode 100644 jumanji/training/networks/jigsaw/random.py diff --git a/jumanji/training/configs/config.yaml b/jumanji/training/configs/config.yaml index 34d88409c..22dab0809 100644 --- a/jumanji/training/configs/config.yaml +++ b/jumanji/training/configs/config.yaml @@ -1,6 +1,6 @@ defaults: - _self_ - - env: snake # [bin_pack, cleaner, connector, cvrp, game_2048, job_shop, knapsack, maze, minesweeper, rubiks_cube, snake, tsp] + - env: snake # [bin_pack, cleaner, connector, cvrp, game_2048, jigsaw, job_shop, knapsack, maze, minesweeper, rubiks_cube, snake, tsp] agent: random # [random, a2c] diff --git a/jumanji/training/networks/jigsaw/__init__.py b/jumanji/training/networks/jigsaw/__init__.py new file mode 100644 index 000000000..21db9ec1c --- /dev/null +++ b/jumanji/training/networks/jigsaw/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/jumanji/training/networks/jigsaw/random.py b/jumanji/training/networks/jigsaw/random.py new file mode 100644 index 000000000..21db9ec1c --- /dev/null +++ b/jumanji/training/networks/jigsaw/random.py @@ -0,0 +1,13 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. From 3988f4d28cad6056da9f0cc018077ce9d1756290 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 22 May 2023 14:24:17 +0200 Subject: [PATCH 04/27] feat: remove board action mask. --- .../environments/packing/jigsaw/__init__.py | 2 +- jumanji/environments/packing/jigsaw/env.py | 55 +++------ .../environments/packing/jigsaw/env_test.py | 115 ++++++------------ .../environments/packing/jigsaw/generator.py | 9 +- .../packing/jigsaw/generator_test.py | 3 +- .../packing/jigsaw/jigsaw_example.py | 8 +- .../packing/jigsaw/reward_test.py | 13 +- jumanji/environments/packing/jigsaw/types.py | 14 +-- 8 files changed, 74 insertions(+), 145 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/__init__.py b/jumanji/environments/packing/jigsaw/__init__.py index 6654373b3..b9fa92923 100644 --- a/jumanji/environments/packing/jigsaw/__init__.py +++ b/jumanji/environments/packing/jigsaw/__init__.py @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -from jumanji.environments.packing.jigsaw.env import JigSaw +from jumanji.environments.packing.jigsaw.env import Jigsaw from jumanji.environments.packing.jigsaw.types import Observation, State diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 034fa8638..559b0ef07 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -34,7 +34,7 @@ from jumanji.viewer import Viewer -class JigSaw(Environment[State]): +class Jigsaw(Environment[State]): """A Jigsaw solving environment.""" @@ -51,8 +51,8 @@ def __init__( """ default_generator = RandomJigsawGenerator( - num_row_pieces=3, - num_col_pieces=3, + num_row_pieces=5, + num_col_pieces=5, ) self.generator = generator or default_generator @@ -92,11 +92,7 @@ def reset( board_state = self.generator(key) - board_action_mask = jnp.zeros_like(board_state.solved_board) - piece_action_mask = jnp.ones(self.num_pieces, dtype=bool) - - board_state.board_action_mask = board_action_mask - board_state.piece_action_mask = piece_action_mask + board_state.action_mask = jnp.ones(self.num_pieces, dtype=bool) board_state.current_board = jnp.zeros_like(board_state.solved_board) obs = self._observation_from_state(board_state) @@ -116,19 +112,19 @@ def step( Returns: a tuple of the next state and a time step. """ + # Unpack and use actions + piece_idx, rotation, row_idx, col_idx = action - chosen_piece = state.pieces[action[0]] + chosen_piece = state.pieces[piece_idx] # Rotate chosen piece - chosen_piece = rotate_piece(chosen_piece, action[1]) + chosen_piece = rotate_piece(chosen_piece, rotation) - grid_piece = self._expand_piece_to_board( - state, chosen_piece, action[2], action[3] - ) + grid_piece = self._expand_piece_to_board(state, chosen_piece, row_idx, col_idx) grid_mask_piece = self._get_ones_like_expanded_piece(grid_piece=grid_piece) - action_is_legal = self._check_action_is_legal(action[0], state, grid_mask_piece) + action_is_legal = self._check_action_is_legal(piece_idx, state, grid_mask_piece) next_state_legal = State( col_nibs_idxs=state.col_nibs_idxs, @@ -136,8 +132,7 @@ def step( solved_board=state.solved_board, current_board=state.current_board + grid_piece, pieces=state.pieces, - piece_action_mask=state.piece_action_mask.at[action[0]].set(False), - board_action_mask=state.board_action_mask + grid_mask_piece, + action_mask=state.action_mask.at[piece_idx].set(False), num_pieces=state.num_pieces, key=state.key, step_count=state.step_count + 1, @@ -149,8 +144,7 @@ def step( solved_board=state.solved_board, current_board=state.current_board, pieces=state.pieces, - piece_action_mask=state.piece_action_mask, - board_action_mask=state.board_action_mask, + action_mask=state.action_mask, num_pieces=state.num_pieces, key=state.key, step_count=state.step_count + 1, @@ -222,8 +216,7 @@ def observation_spec(self) -> specs.Spec[Observation]: Spec for each filed in the observation: - current_board: BoundedArray (int) of shape (board_dim[0], board_dim[1]). - pieces: BoundedArray (int) of shape (num_pieces, 3, 3). - - piece_action_mask: BoundedArray (bool) of shape (num_pieces,). - - board_action_mask: BoundedArray (int) of shape (board_dim[0], board_dim[1]). + - action_mask: BoundedArray (bool) of shape (num_pieces,). """ current_board = specs.BoundedArray( @@ -242,20 +235,12 @@ def observation_spec(self) -> specs.Spec[Observation]: name="pieces", ) - piece_action_mask = specs.BoundedArray( + action_mask = specs.BoundedArray( shape=(self.num_pieces,), minimum=False, maximum=True, dtype=bool, - name="piece_action_mask", - ) - - board_action_mask = specs.BoundedArray( - shape=(self.board_dim[0], self.board_dim[1]), - minimum=0, - maximum=1, - dtype=jnp.float32, - name="board_action_mask", + name="action_mask", ) return specs.Spec( @@ -263,8 +248,7 @@ def observation_spec(self) -> specs.Spec[Observation]: "ObservationSpec", current_board=current_board, pieces=pieces, - piece_action_mask=piece_action_mask, - board_action_mask=board_action_mask, + action_mask=action_mask, ) def action_spec(self) -> specs.MultiDiscreteArray: @@ -321,9 +305,9 @@ def _check_action_is_legal( True if the action is legal, False otherwise. """ - placed_mask = state.board_action_mask + grid_mask_piece + placed_mask = (state.current_board > 0.0) + grid_mask_piece - legal: bool = state.piece_action_mask[action] & (jnp.max(placed_mask) <= 1) + legal: bool = state.action_mask[action] & (jnp.max(placed_mask) <= 1) return legal @@ -381,7 +365,6 @@ def _observation_from_state(self, state: State) -> Observation: return Observation( current_board=state.current_board, - board_action_mask=state.board_action_mask, - piece_action_mask=state.piece_action_mask, + action_mask=state.action_mask, pieces=state.pieces, ) diff --git a/jumanji/environments/packing/jigsaw/env_test.py b/jumanji/environments/packing/jigsaw/env_test.py index 1734aa406..0ae3cc3cc 100644 --- a/jumanji/environments/packing/jigsaw/env_test.py +++ b/jumanji/environments/packing/jigsaw/env_test.py @@ -17,8 +17,9 @@ import jax.numpy as jnp import pytest -from jumanji.environments.packing.jigsaw.env import JigSaw +from jumanji.environments.packing.jigsaw.env import Jigsaw from jumanji.environments.packing.jigsaw.generator import ( + RandomJigsawGenerator, ToyJigsawGeneratorNoRotation, ToyJigsawGeneratorWithRotation, ) @@ -30,9 +31,14 @@ @pytest.fixture(scope="module") -def jigsaw() -> JigSaw: - """Creates a JigSaw environment.""" - return JigSaw() +def jigsaw() -> Jigsaw: + """Creates a simple Jigsaw environment for testing.""" + return Jigsaw( + generator=RandomJigsawGenerator( + num_col_pieces=3, + num_row_pieces=3, + ), + ) @pytest.fixture @@ -100,56 +106,24 @@ def simple_env_board_state_4() -> chex.Array: @pytest.fixture -def simple_env_board_action_mask_2() -> chex.Array: - """The state of the board action mask in the simplified example after 2 correct actions.""" - # fmt: off - return jnp.array( - [ - [1.0, 1.0, 1.0, 1.0, 1.0], - [1.0, 1.0, 1.0, 1.0, 1.0], - [0.0, 1.0, 0.0, 0.0, 1.0], - [0.0, 0.0, 0.0, 0.0, 0.0], - [0.0, 0.0, 0.0, 0.0, 0.0], - ] - ) - # fmt: on - - -@pytest.fixture -def simple_env_board_action_mask_3() -> chex.Array: - """The state of the board action mask in the simplified example after 3 correct actions.""" - # fmt: off - return jnp.array( - [ - [1.0, 1.0, 1.0, 1.0, 1.0], - [1.0, 1.0, 1.0, 1.0, 1.0], - [1.0, 1.0, 0.0, 0.0, 1.0], - [1.0, 1.0, 0.0, 0.0, 0.0], - [1.0, 1.0, 1.0, 0.0, 0.0], - ] - ) - # fmt: on - - -@pytest.fixture -def simple_env_piece_action_mask_1() -> chex.Array: +def simple_env_action_mask_1() -> chex.Array: """The state of the piece action mask in the simplified example after 1 correct action.""" return jnp.array([False, True, True, True]) @pytest.fixture -def simple_env_piece_action_mask_2() -> chex.Array: +def simple_env_action_mask_2() -> chex.Array: """The state of the piece action mask in the simplified example after 2 correct actions.""" return jnp.array([False, False, True, True]) @pytest.fixture -def simple_env_piece_action_mask_3() -> chex.Array: +def simple_env_action_mask_3() -> chex.Array: """The state of the piece action mask in the simplified example after 3 correct actions.""" return jnp.array([False, False, False, True]) -def test_jigsaw__reset_jit(jigsaw: JigSaw, key: chex.PRNGKey) -> None: +def test_jigsaw__reset_jit(jigsaw: Jigsaw, key: chex.PRNGKey) -> None: """Test that the environment reset only compiles once.""" chex.clear_trace_counter() reset_fn = jax.jit(chex.assert_max_traces(jigsaw.reset, n=1)) @@ -169,7 +143,7 @@ def test_jigsaw__reset_jit(jigsaw: JigSaw, key: chex.PRNGKey) -> None: assert isinstance(timestep, TimeStep) -def test_jigsaw__step_jit(jigsaw: JigSaw, key: chex.PRNGKey) -> None: +def test_jigsaw__step_jit(jigsaw: Jigsaw, key: chex.PRNGKey) -> None: """Test that the step function is only compiled once.""" state_0, timestep_0 = jigsaw.reset(key) action_0 = jnp.array([0, 0, 0, 0]) @@ -200,12 +174,12 @@ def test_jigsaw__step_jit(jigsaw: JigSaw, key: chex.PRNGKey) -> None: assert isinstance(timestep_2, TimeStep) -def test_jigsaw__does_not_smoke(jigsaw: JigSaw) -> None: +def test_jigsaw__does_not_smoke(jigsaw: Jigsaw) -> None: """Test that we can run an episode without any errors.""" check_env_does_not_smoke(jigsaw) -def test_jigsaw___check_done(jigsaw: JigSaw, key: chex.PRNGKey) -> None: +def test_jigsaw___check_done(jigsaw: Jigsaw, key: chex.PRNGKey) -> None: """Test that the check_done method works as expected.""" state, _ = jigsaw.reset(key) @@ -217,10 +191,11 @@ def test_jigsaw___check_done(jigsaw: JigSaw, key: chex.PRNGKey) -> None: def test_jigsaw___expand_piece_to_board( - jigsaw: JigSaw, key: chex.PRNGKey, piece: chex.Array + jigsaw: Jigsaw, key: chex.PRNGKey, piece: chex.Array ) -> None: """Test that a piece is correctly set on a grid of zeros.""" - + print() + print(piece) state, _ = jigsaw.reset(key) expanded_grid_with_piece = jigsaw._expand_piece_to_board(state, piece, 2, 1) # fmt: off @@ -245,18 +220,16 @@ def test_jigsaw__completed_episode_with_dense_reward( simple_env_board_state_2: chex.Array, simple_env_board_state_3: chex.Array, simple_env_board_state_4: chex.Array, - simple_env_board_action_mask_2: chex.Array, - simple_env_board_action_mask_3: chex.Array, - simple_env_piece_action_mask_1: chex.Array, - simple_env_piece_action_mask_2: chex.Array, - simple_env_piece_action_mask_3: chex.Array, + simple_env_action_mask_1: chex.Array, + simple_env_action_mask_2: chex.Array, + simple_env_action_mask_3: chex.Array, ) -> None: """This test will step a simplified version of the Jigsaw environment with a dense reward until completion. It will check that the reward is correctly computed and that the environment transitions as expected until done.""" - simple_env = JigSaw( + simple_env = Jigsaw( generator=ToyJigsawGeneratorNoRotation(), ) chex.clear_trace_counter() @@ -270,40 +243,35 @@ def test_jigsaw__completed_episode_with_dense_reward( # Check that the reset board contains only zeros assert jnp.all(state.current_board == 0) - assert jnp.all(state.piece_action_mask) - assert jnp.all(state.board_action_mask == 0) + assert jnp.all(state.action_mask) # Step the environment state, timestep = step_fn(state, jnp.array([0, 0, 0, 0])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_1) assert timestep.reward == 6.0 - assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_1) - assert jnp.all(state.board_action_mask == simple_env_board_state_1) + assert jnp.all(state.action_mask == simple_env_action_mask_1) # Step the environment state, timestep = step_fn(state, jnp.array([1, 0, 0, 2])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_2) assert timestep.reward == 6.0 - assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_2) - assert jnp.all(state.board_action_mask == simple_env_board_action_mask_2) + assert jnp.all(state.action_mask == simple_env_action_mask_2) # Step the environment state, timestep = step_fn(state, jnp.array([2, 0, 2, 0])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_3) assert timestep.reward == 6.0 - assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_3) - assert jnp.all(state.board_action_mask == simple_env_board_action_mask_3) + assert jnp.all(state.action_mask == simple_env_action_mask_3) # Step the environment state, timestep = step_fn(state, jnp.array([3, 0, 2, 2])) assert timestep.step_type == StepType.LAST assert jnp.all(state.current_board == simple_env_board_state_4) assert timestep.reward == 7.0 - assert not jnp.all(state.piece_action_mask) - assert jnp.all(state.board_action_mask == jnp.ones_like(simple_env_board_state_4)) + assert not jnp.all(state.action_mask) def test_jigsaw__completed_episode_with_sparse_reward( @@ -312,18 +280,16 @@ def test_jigsaw__completed_episode_with_sparse_reward( simple_env_board_state_2: chex.Array, simple_env_board_state_3: chex.Array, simple_env_board_state_4: chex.Array, - simple_env_board_action_mask_2: chex.Array, - simple_env_board_action_mask_3: chex.Array, - simple_env_piece_action_mask_1: chex.Array, - simple_env_piece_action_mask_2: chex.Array, - simple_env_piece_action_mask_3: chex.Array, + simple_env_action_mask_1: chex.Array, + simple_env_action_mask_2: chex.Array, + simple_env_action_mask_3: chex.Array, ) -> None: """This test will step a simplified version of the Jigsaw environment with a sparse reward until completion. It will check that the reward is correctly computed and that the environment transitions as expected until done.""" - simple_env = JigSaw( + simple_env = Jigsaw( generator=ToyJigsawGeneratorWithRotation(), reward_fn=SparseReward(), ) @@ -338,37 +304,32 @@ def test_jigsaw__completed_episode_with_sparse_reward( # Check that the reset board contains only zeros assert jnp.all(state.current_board == 0) - assert jnp.all(state.piece_action_mask) - assert jnp.all(state.board_action_mask == 0) + assert jnp.all(state.action_mask) # Step the environment state, timestep = step_fn(state, jnp.array([0, 2, 0, 0])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_1) assert timestep.reward == 0.0 - assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_1) - assert jnp.all(state.board_action_mask == simple_env_board_state_1) + assert jnp.all(state.action_mask == simple_env_action_mask_1) # Step the environment state, timestep = step_fn(state, jnp.array([1, 2, 0, 2])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_2) assert timestep.reward == 0.0 - assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_2) - assert jnp.all(state.board_action_mask == simple_env_board_action_mask_2) + assert jnp.all(state.action_mask == simple_env_action_mask_2) # Step the environment state, timestep = step_fn(state, jnp.array([2, 1, 2, 0])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_3) assert timestep.reward == 0.0 - assert jnp.all(state.piece_action_mask == simple_env_piece_action_mask_3) - assert jnp.all(state.board_action_mask == simple_env_board_action_mask_3) + assert jnp.all(state.action_mask == simple_env_action_mask_3) # Step the environment state, timestep = step_fn(state, jnp.array([3, 0, 2, 2])) assert timestep.step_type == StepType.LAST assert jnp.all(state.current_board == simple_env_board_state_4) assert timestep.reward == 1.0 - assert not jnp.all(state.piece_action_mask) - assert jnp.all(state.board_action_mask == jnp.ones_like(simple_env_board_state_4)) + assert not jnp.all(state.action_mask) diff --git a/jumanji/environments/packing/jigsaw/generator.py b/jumanji/environments/packing/jigsaw/generator.py index 57e37fc9b..3d83bc4e6 100644 --- a/jumanji/environments/packing/jigsaw/generator.py +++ b/jumanji/environments/packing/jigsaw/generator.py @@ -296,8 +296,7 @@ def __call__(self, key: chex.PRNGKey) -> State: num_pieces=num_pieces, col_nibs_idxs=col_nibs_idxs, row_nibs_idxs=row_nibs_idxs, - board_action_mask=jnp.zeros_like(solved_board), - piece_action_mask=jnp.ones(num_pieces, dtype=bool), + action_mask=jnp.ones(num_pieces, dtype=bool), current_board=jnp.zeros_like(solved_board), step_count=0, key=key, @@ -340,8 +339,7 @@ def __call__(self, key: chex.PRNGKey) -> State: solved_board=mock_solved_grid, pieces=mock_pieces, current_board=jnp.zeros_like(mock_solved_grid), - board_action_mask=jnp.zeros_like(mock_solved_grid), - piece_action_mask=jnp.ones(4, dtype=bool), + action_mask=jnp.ones(4, dtype=bool), col_nibs_idxs=jnp.array([2], dtype=jnp.int32), row_nibs_idxs=jnp.array([2], dtype=jnp.int32), num_pieces=jnp.int32(4), @@ -392,8 +390,7 @@ def __call__(self, key: chex.PRNGKey) -> State: row_nibs_idxs=jnp.array([2], dtype=jnp.int32), num_pieces=jnp.int32(4), key=jax.random.PRNGKey(0), - board_action_mask=jnp.zeros_like(mock_solved_grid), - piece_action_mask=jnp.ones(4, dtype=bool), + action_mask=jnp.ones(4, dtype=bool), current_board=jnp.zeros_like(mock_solved_grid), step_count=0, ) diff --git a/jumanji/environments/packing/jigsaw/generator_test.py b/jumanji/environments/packing/jigsaw/generator_test.py index 623a91ff8..ed290c1e5 100644 --- a/jumanji/environments/packing/jigsaw/generator_test.py +++ b/jumanji/environments/packing/jigsaw/generator_test.py @@ -78,8 +78,7 @@ def test_random_jigsaw_generator__call( assert all(state.pieces[i].shape == (3, 3) for i in range(4)) assert state.col_nibs_idxs == jnp.array([2]) assert state.row_nibs_idxs == jnp.array([2]) - assert state.board_action_mask.shape == (5, 5) - assert state.piece_action_mask.shape == (4,) + assert state.action_mask.shape == (4,) assert state.step_count == 0 diff --git a/jumanji/environments/packing/jigsaw/jigsaw_example.py b/jumanji/environments/packing/jigsaw/jigsaw_example.py index c50c3f800..eca593b89 100644 --- a/jumanji/environments/packing/jigsaw/jigsaw_example.py +++ b/jumanji/environments/packing/jigsaw/jigsaw_example.py @@ -18,7 +18,7 @@ import jax from jax import numpy as jnp -from jumanji.environments.packing.jigsaw.env import JigSaw +from jumanji.environments.packing.jigsaw.env import Jigsaw from jumanji.environments.packing.jigsaw.generator import ( RandomJigsawGenerator, ToyJigsawGeneratorNoRotation, @@ -29,7 +29,7 @@ # Very basic example of a random agent acting in the Jigsaw environment. # Each episode will generate a completely new instance of the jigsaw puzzle. -env = JigSaw( +env = Jigsaw( generator=RandomJigsawGenerator( num_col_pieces=5, num_row_pieces=5, @@ -85,7 +85,7 @@ # An example of solving a puzzle by stepping a # dummy environment with a dense reward function. print("STARTING DENSE REWARD EXAMPLE") -env = JigSaw(generator=ToyJigsawGeneratorNoRotation()) +env = Jigsaw(generator=ToyJigsawGeneratorNoRotation()) state, timestep = env.reset(step_key) print("CURRENT BOARD:") print(state.current_board, "\n") @@ -114,7 +114,7 @@ # An example of solving a puzzle by stepping a # dummy environment with a sparse reward function. print("STARTING SPARSE REWARD EXAMPLE") -env = JigSaw(generator=ToyJigsawGeneratorNoRotation(), reward_fn=SparseReward()) +env = Jigsaw(generator=ToyJigsawGeneratorNoRotation(), reward_fn=SparseReward()) state, timestep = env.reset(step_key) print("CURRENT BOARD:") print(state.current_board, "\n") diff --git a/jumanji/environments/packing/jigsaw/reward_test.py b/jumanji/environments/packing/jigsaw/reward_test.py index 479e2e0bc..4e15e85be 100644 --- a/jumanji/environments/packing/jigsaw/reward_test.py +++ b/jumanji/environments/packing/jigsaw/reward_test.py @@ -48,8 +48,7 @@ def state_with_no_pieces_placed( num_pieces=4, pieces=pieces, solved_board=solved_board, - board_action_mask=jnp.zeros_like(solved_board), - piece_action_mask=jnp.ones(4, dtype=bool), + action_mask=jnp.ones(4, dtype=bool), current_board=jnp.zeros_like(solved_board), step_count=0, key=key, @@ -71,8 +70,7 @@ def state_with_piece_one_placed( col_nibs_idxs=jnp.array([2]), num_pieces=4, solved_board=solved_board, - board_action_mask=board_with_piece_one_placed, - piece_action_mask=jnp.array( + action_mask=jnp.array( [ False, True, @@ -98,7 +96,6 @@ def state_needing_only_piece_one( key, new_key = jax.random.split(key) - board_action_mask = jnp.ones_like(solved_board) - board_with_piece_one_placed current_board = solved_board - board_with_piece_one_placed return State( @@ -106,8 +103,7 @@ def state_needing_only_piece_one( col_nibs_idxs=jnp.array([2]), num_pieces=4, solved_board=solved_board, - board_action_mask=board_action_mask, - piece_action_mask=jnp.array( + action_mask=jnp.array( [ True, False, @@ -137,8 +133,7 @@ def solved_state( col_nibs_idxs=jnp.array([2]), num_pieces=4, solved_board=solved_board, - board_action_mask=jnp.ones_like(solved_board), - piece_action_mask=jnp.array( + action_mask=jnp.array( [ False, False, diff --git a/jumanji/environments/packing/jigsaw/types.py b/jumanji/environments/packing/jigsaw/types.py index f614c8fcf..3dc322f8c 100644 --- a/jumanji/environments/packing/jigsaw/types.py +++ b/jumanji/environments/packing/jigsaw/types.py @@ -27,15 +27,12 @@ class Observation(NamedTuple): current_board: 2D array with the current state of board. pieces: 3D array with the pieces to be placed on the board. Here each piece is a 2D array with shape (3, 3). - board_action_mask: 2D array showing where on the board pieces have already been - placed. - piece_action_mask: array showing which pieces can be placed on the board. + action_mask: array showing which pieces can be placed on the board. """ current_board: chex.Array # (num_rows, num_cols) pieces: chex.Array # (num_pieces, 3, 3) - board_action_mask: chex.Array # (num_rows, num_cols) - piece_action_mask: chex.Array # (num_pieces,) + action_mask: chex.Array # (num_pieces,) @dataclass @@ -51,9 +48,7 @@ class State: solved_board: 2D array showing the solved board state. pieces: 3D array with the pieces to be placed on the board. Here each piece is a 2D array with shape (3, 3). - board_action_mask: 2D array showing where pieces on the board have been - placed. - piece_action_mask: array showing which pieces can be placed on the board. + action_mask: array showing which pieces can be placed on the board. current_board: 2D array with the current state of board. step_count: number of steps taken in the environment. key: random key used for board generation. @@ -64,8 +59,7 @@ class State: num_pieces: chex.Numeric # () solved_board: chex.Array # (num_rows, num_cols) pieces: chex.Array # (num_pieces, 3, 3) - board_action_mask: chex.Array # (num_rows, num_cols) - piece_action_mask: chex.Array # (num_pieces,) + action_mask: chex.Array # (num_pieces,) current_board: chex.Array # (num_rows, num_cols) step_count: chex.Numeric # () key: chex.PRNGKey # (2,) From 14a53ed4c14ff84d0e225b2c7120a8f6b25c2925 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 22 May 2023 14:25:58 +0200 Subject: [PATCH 05/27] feat: add jigsaw random agent. --- jumanji/__init__.py | 4 ++++ jumanji/environments/__init__.py | 1 + jumanji/training/configs/config.yaml | 2 +- jumanji/training/networks/__init__.py | 1 + jumanji/training/networks/jigsaw/random.py | 10 ++++++++++ jumanji/training/setup_train.py | 4 ++++ 6 files changed, 21 insertions(+), 1 deletion(-) diff --git a/jumanji/__init__.py b/jumanji/__init__.py index 751b21a78..63658cb9e 100644 --- a/jumanji/__init__.py +++ b/jumanji/__init__.py @@ -51,6 +51,10 @@ # largest ones are given in the observation. register(id="BinPack-v1", entry_point="jumanji.environments:BinPack") +# Jigsaw puzzle with 25 pieces and a random puzzle generator. +# The puzzle must be completed in `num_pieces` steps. +register(id="Jigsaw-v0", entry_point="jumanji.environments:Jigsaw") + # Job-shop scheduling problem with 20 jobs, 10 machines, at most # 8 operations per job, and a max operation duration of 6 timesteps. register(id="JobShop-v0", entry_point="jumanji.environments:JobShop") diff --git a/jumanji/environments/__init__.py b/jumanji/environments/__init__.py index 68e66834b..4741e6743 100644 --- a/jumanji/environments/__init__.py +++ b/jumanji/environments/__init__.py @@ -20,6 +20,7 @@ from jumanji.environments.logic.rubiks_cube import RubiksCube from jumanji.environments.packing import bin_pack, job_shop, knapsack from jumanji.environments.packing.bin_pack.env import BinPack +from jumanji.environments.packing.jigsaw.env import Jigsaw from jumanji.environments.packing.job_shop.env import JobShop from jumanji.environments.packing.knapsack.env import Knapsack from jumanji.environments.routing import cleaner, connector, cvrp, maze, snake, tsp diff --git a/jumanji/training/configs/config.yaml b/jumanji/training/configs/config.yaml index 22dab0809..9e96fa851 100644 --- a/jumanji/training/configs/config.yaml +++ b/jumanji/training/configs/config.yaml @@ -1,6 +1,6 @@ defaults: - _self_ - - env: snake # [bin_pack, cleaner, connector, cvrp, game_2048, jigsaw, job_shop, knapsack, maze, minesweeper, rubiks_cube, snake, tsp] + - env: jigsaw # [bin_pack, cleaner, connector, cvrp, game_2048, jigsaw, job_shop, knapsack, maze, minesweeper, rubiks_cube, snake, tsp] agent: random # [random, a2c] diff --git a/jumanji/training/networks/__init__.py b/jumanji/training/networks/__init__.py index 0b24bb2fe..a541dafe6 100644 --- a/jumanji/training/networks/__init__.py +++ b/jumanji/training/networks/__init__.py @@ -34,6 +34,7 @@ make_actor_critic_networks_game_2048, ) from jumanji.training.networks.game_2048.random import make_random_policy_game_2048 +from jumanji.training.networks.jigsaw.random import make_random_policy_jigsaw from jumanji.training.networks.job_shop.actor_critic import ( make_actor_critic_networks_job_shop, ) diff --git a/jumanji/training/networks/jigsaw/random.py b/jumanji/training/networks/jigsaw/random.py index 21db9ec1c..c7c77d2c8 100644 --- a/jumanji/training/networks/jigsaw/random.py +++ b/jumanji/training/networks/jigsaw/random.py @@ -11,3 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +from jumanji.training.networks.masked_categorical_random import ( + masked_categorical_random, +) +from jumanji.training.networks.protocols import RandomPolicy + + +def make_random_policy_jigsaw() -> RandomPolicy: + """Make random policy for `Jigsaw`.""" + return masked_categorical_random diff --git a/jumanji/training/setup_train.py b/jumanji/training/setup_train.py index f58543fd6..2ef2ad760 100644 --- a/jumanji/training/setup_train.py +++ b/jumanji/training/setup_train.py @@ -29,6 +29,7 @@ Cleaner, Connector, Game2048, + Jigsaw, JobShop, Knapsack, Maze, @@ -164,6 +165,9 @@ def _setup_random_policy( # noqa: CCR001 elif cfg.env.name == "connector": assert isinstance(env.unwrapped, Connector) random_policy = networks.make_random_policy_connector() + elif cfg.env.name == "jigsaw": + assert isinstance(env.unwrapped, Jigsaw) + random_policy = networks.make_random_policy_jigsaw() else: raise ValueError(f"Environment name not found. Got {cfg.env.name}.") return random_policy From 62625c08c33ff5fc736b3fbc5baaaa0be3f86179 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 22 May 2023 17:53:32 +0200 Subject: [PATCH 06/27] chore: change board_dim to num_rows and num_cols --- jumanji/environments/packing/jigsaw/env.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 559b0ef07..58a6c3e54 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -59,7 +59,7 @@ def __init__( self.num_row_pieces = self.generator.num_row_pieces self.num_col_pieces = self.generator.num_col_pieces self.num_pieces = self.num_row_pieces * self.num_col_pieces - self.board_dim = ( + self.num_rows, self.num_cols = ( compute_grid_dim(self.num_row_pieces), compute_grid_dim(self.num_col_pieces), ) @@ -70,7 +70,7 @@ def __init__( def __repr__(self) -> str: return ( - f"Jigsaw environment with a puzzle size of ({self.board_dim[0]}x{self.board_dim[1]}) " + f"Jigsaw environment with a puzzle size of ({self.num_rows}x{self.num_cols}) " f"with {self.num_row_pieces} row pieces, {self.num_col_pieces} column " f"pieces. Each piece has dimension (3x3)." ) @@ -220,7 +220,7 @@ def observation_spec(self) -> specs.Spec[Observation]: """ current_board = specs.BoundedArray( - shape=(self.board_dim[0], self.board_dim[1]), + shape=(self.num_rows, self.num_cols), minimum=0, maximum=self.num_pieces, dtype=jnp.float32, @@ -263,8 +263,8 @@ def action_spec(self) -> specs.MultiDiscreteArray: - max_col_position: int between 0 and max_col_position - 1 (included). """ - max_row_position = self.board_dim[0] - 3 - max_col_position = self.board_dim[1] - 3 + max_row_position = self.num_rows - 3 + max_col_position = self.num_cols - 3 return specs.MultiDiscreteArray( num_values=jnp.array( From 6828cfaa976e662c1be801d523f59de82a9abfca Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Tue, 23 May 2023 09:14:07 +0200 Subject: [PATCH 07/27] feat: register environment and add random networks --- .../environments/packing/jigsaw/jigsaw_example.py | 13 +++++++++++-- jumanji/training/networks/jigsaw/random.py | 13 +++++++++---- jumanji/training/setup_train.py | 4 +++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/jigsaw_example.py b/jumanji/environments/packing/jigsaw/jigsaw_example.py index eca593b89..a47086af9 100644 --- a/jumanji/environments/packing/jigsaw/jigsaw_example.py +++ b/jumanji/environments/packing/jigsaw/jigsaw_example.py @@ -50,8 +50,17 @@ start_time = time.time() while not timestep.last(): step_key, piece_key, rot_key, row_key, col_key = jax.random.split(step_key, 5) - piece_id = jax.random.randint( - piece_key, shape=(), minval=0, maxval=action_spec.maximum[0] + 1 + + # Only select a random piece from pieces that are true in the timestep.action_mask + # (i.e. pieces that are not yet placed) + probs = timestep.observation.action_mask[: env.num_pieces] / jnp.sum( + timestep.observation.action_mask[: env.num_pieces] + ) + piece_id = jax.random.choice( + a=action_spec.maximum[0] + 1, + shape=(), + key=piece_key, + p=probs, ) rotation = jax.random.randint( rot_key, shape=(), minval=0, maxval=action_spec.maximum[1] + 1 diff --git a/jumanji/training/networks/jigsaw/random.py b/jumanji/training/networks/jigsaw/random.py index c7c77d2c8..9ad9e6bae 100644 --- a/jumanji/training/networks/jigsaw/random.py +++ b/jumanji/training/networks/jigsaw/random.py @@ -12,12 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +from jumanji.environments.packing.jigsaw.env import Jigsaw from jumanji.training.networks.masked_categorical_random import ( - masked_categorical_random, + make_masked_categorical_random_ndim, ) from jumanji.training.networks.protocols import RandomPolicy -def make_random_policy_jigsaw() -> RandomPolicy: - """Make random policy for `Jigsaw`.""" - return masked_categorical_random +def make_random_policy_jigsaw(jigsaw: Jigsaw) -> RandomPolicy: + """Make random policy for Minesweeper.""" + action_spec_num_values = jigsaw.action_spec().num_values + + return make_masked_categorical_random_ndim( + action_spec_num_values=action_spec_num_values + ) diff --git a/jumanji/training/setup_train.py b/jumanji/training/setup_train.py index 2ef2ad760..2a5af7f57 100644 --- a/jumanji/training/setup_train.py +++ b/jumanji/training/setup_train.py @@ -167,7 +167,9 @@ def _setup_random_policy( # noqa: CCR001 random_policy = networks.make_random_policy_connector() elif cfg.env.name == "jigsaw": assert isinstance(env.unwrapped, Jigsaw) - random_policy = networks.make_random_policy_jigsaw() + random_policy = networks.make_random_policy_jigsaw( + jigsaw=env.unwrapped, + ) else: raise ValueError(f"Environment name not found. Got {cfg.env.name}.") return random_policy From b81dda20591ed0128b51b239b7000dd325991a07 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Thu, 25 May 2023 19:49:03 +0200 Subject: [PATCH 08/27] feat: full action mask working. --- jumanji/environments/packing/jigsaw/env.py | 114 ++++++++++++++++-- .../environments/packing/jigsaw/env_test.py | 41 +++---- .../environments/packing/jigsaw/generator.py | 11 +- .../packing/jigsaw/generator_test.py | 2 +- .../packing/jigsaw/jigsaw_example.py | 26 ++-- .../packing/jigsaw/reward_test.py | 32 ++++- jumanji/environments/packing/jigsaw/types.py | 12 +- 7 files changed, 184 insertions(+), 54 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 58a6c3e54..7aa4f0996 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -51,8 +51,8 @@ def __init__( """ default_generator = RandomJigsawGenerator( - num_row_pieces=5, - num_col_pieces=5, + num_row_pieces=3, + num_col_pieces=3, ) self.generator = generator or default_generator @@ -92,8 +92,11 @@ def reset( board_state = self.generator(key) - board_state.action_mask = jnp.ones(self.num_pieces, dtype=bool) - board_state.current_board = jnp.zeros_like(board_state.solved_board) + # board_state.action_mask = jnp.ones(( + # self.num_pieces, 4, self.num_rows-3, self.num_cols-3, + # ), dtype=bool) + # board_state.current_board = jnp.zeros_like(board_state.solved_board) + # board_state.placed_pieces = jnp.zeros((self.num_pieces,), dtype=bool) obs = self._observation_from_state(board_state) timestep = restart(observation=obs) @@ -121,10 +124,9 @@ def step( chosen_piece = rotate_piece(chosen_piece, rotation) grid_piece = self._expand_piece_to_board(state, chosen_piece, row_idx, col_idx) - grid_mask_piece = self._get_ones_like_expanded_piece(grid_piece=grid_piece) - action_is_legal = self._check_action_is_legal(piece_idx, state, grid_mask_piece) + action_is_legal = self._check_action_is_legal(action, state, grid_mask_piece) next_state_legal = State( col_nibs_idxs=state.col_nibs_idxs, @@ -132,10 +134,11 @@ def step( solved_board=state.solved_board, current_board=state.current_board + grid_piece, pieces=state.pieces, - action_mask=state.action_mask.at[piece_idx].set(False), + action_mask=state.action_mask, # filler for now num_pieces=state.num_pieces, key=state.key, step_count=state.step_count + 1, + placed_pieces=state.placed_pieces.at[piece_idx].set(True), ) next_state_illegal = State( @@ -148,6 +151,7 @@ def step( num_pieces=state.num_pieces, key=state.key, step_count=state.step_count + 1, + placed_pieces=state.placed_pieces, ) # Transition board to new state if the action is legal @@ -158,6 +162,20 @@ def step( lambda: next_state_illegal, ) + full_action_mask = self._make_full_action_mask(next_state) + next_state = State( + col_nibs_idxs=next_state.col_nibs_idxs, + row_nibs_idxs=next_state.row_nibs_idxs, + solved_board=next_state.solved_board, + current_board=next_state.current_board, + pieces=next_state.pieces, + action_mask=full_action_mask, # filler for now + num_pieces=next_state.num_pieces, + key=next_state.key, + step_count=next_state.step_count, + placed_pieces=next_state.placed_pieces, + ) + done = self._check_done(next_state) next_obs = self._observation_from_state(next_state) @@ -236,7 +254,12 @@ def observation_spec(self) -> specs.Spec[Observation]: ) action_mask = specs.BoundedArray( - shape=(self.num_pieces,), + shape=( + self.num_pieces, + 4, + self.num_rows - 3, + self.num_cols - 3, + ), minimum=False, maximum=True, dtype=bool, @@ -305,9 +328,14 @@ def _check_action_is_legal( True if the action is legal, False otherwise. """ + piece_idx, _, _, _ = action + placed_mask = (state.current_board > 0.0) + grid_mask_piece - legal: bool = state.action_mask[action] & (jnp.max(placed_mask) <= 1) + # legal: bool = state.action_mask[ + # piece_idx, rotation, row, col + # ] & (jnp.max(placed_mask) <= 1) + legal: bool = (~state.placed_pieces[piece_idx]) & (jnp.max(placed_mask) <= 1) return legal @@ -368,3 +396,71 @@ def _observation_from_state(self, state: State) -> Observation: action_mask=state.action_mask, pieces=state.pieces, ) + + def _expand_all_pieces_to_boards( + self, + state: State, + piece_idxs: chex.Array, + rotations: chex.Array, + rows: chex.Array, + cols: chex.Array, + ) -> chex.Array: + # This function takes multiple pieces and their corresponding rotations and positions, + # and generates a grid for each piece. It then returns an array of these grids. + + batch_expand_piece_to_board = jax.vmap( + self._expand_piece_to_board, in_axes=(None, 0, 0, 0) + ) + + pieces = state.pieces[piece_idxs] + rotated_pieces = jax.vmap(rotate_piece, in_axes=(0, 0))(pieces, rotations) + grids = batch_expand_piece_to_board(state, rotated_pieces, rows, cols) + + batch_get_ones_like_expanded_piece = jax.vmap( + self._get_ones_like_expanded_piece, in_axes=(0) + ) + grids = batch_get_ones_like_expanded_piece(grids) + return grids + + def _make_full_action_mask(self, state: State) -> chex.Array: + """Create a mask of possible actions based on the current state.""" + num_pieces, num_rotations, num_rows, num_cols = state.action_mask.shape + + pieces_grid, rotations_grid, rows_grid, cols_grid = jnp.meshgrid( + jnp.arange(num_pieces), + jnp.arange(num_rotations), + jnp.arange(num_rows), + jnp.arange(num_cols), + ) + + grid_mask_pieces = self._expand_all_pieces_to_boards( + state, + pieces_grid.flatten(), + rotations_grid.flatten(), + rows_grid.flatten(), + cols_grid.flatten(), + ) + + batch_check_action_is_legal = jax.vmap( + self._check_action_is_legal, in_axes=(0, None, 0) + ) + legal_actions = batch_check_action_is_legal( + jnp.stack( + (pieces_grid, rotations_grid, rows_grid, cols_grid), axis=-1 + ).reshape(-1, 4), + state, + grid_mask_pieces, + ) + + legal_actions = legal_actions.reshape( + num_pieces, num_rotations, num_rows, num_cols + ) + + # Now set all current placed pieces to false in the mask. + placed_pieces_array = state.placed_pieces.reshape((self.num_pieces, 1, 1, 1)) + placed_pieces_mask = jnp.tile( + placed_pieces_array, (1, num_rotations, num_rows, num_cols) + ) + legal_actions = jnp.where(placed_pieces_mask, False, legal_actions) + + return legal_actions diff --git a/jumanji/environments/packing/jigsaw/env_test.py b/jumanji/environments/packing/jigsaw/env_test.py index 0ae3cc3cc..c94db9895 100644 --- a/jumanji/environments/packing/jigsaw/env_test.py +++ b/jumanji/environments/packing/jigsaw/env_test.py @@ -106,21 +106,21 @@ def simple_env_board_state_4() -> chex.Array: @pytest.fixture -def simple_env_action_mask_1() -> chex.Array: - """The state of the piece action mask in the simplified example after 1 correct action.""" - return jnp.array([False, True, True, True]) +def simple_env_placed_pieces_1() -> chex.Array: + """Placed pieces array in the simplified env after 1 piece has been placed.""" + return jnp.array([True, False, False, False]) @pytest.fixture def simple_env_action_mask_2() -> chex.Array: - """The state of the piece action mask in the simplified example after 2 correct actions.""" - return jnp.array([False, False, True, True]) + """Placed pieces array in the simplified env after 2 pieces have been placed.""" + return jnp.array([True, True, False, False]) @pytest.fixture -def simple_env_action_mask_3() -> chex.Array: - """The state of the piece action mask in the simplified example after 3 correct actions.""" - return jnp.array([False, False, False, True]) +def simple_env_placed_pieces_3() -> chex.Array: + """Placed pieces array in the simplified env after 3 pieces have been placed.""" + return jnp.array([True, True, True, False]) def test_jigsaw__reset_jit(jigsaw: Jigsaw, key: chex.PRNGKey) -> None: @@ -162,7 +162,7 @@ def test_jigsaw__step_jit(jigsaw: Jigsaw, key: chex.PRNGKey) -> None: assert_is_jax_array_tree(state_1) # Call the step method again to ensure it is not compiling twice. - action_1 = jnp.array([1, 0, 2, 2]) + action_1 = jnp.array([1, 0, 3, 3]) state_2, timestep_2 = step_fn(state_1, action_1) # Check that the state contains DeviceArrays to verify that it is jitted. @@ -194,8 +194,6 @@ def test_jigsaw___expand_piece_to_board( jigsaw: Jigsaw, key: chex.PRNGKey, piece: chex.Array ) -> None: """Test that a piece is correctly set on a grid of zeros.""" - print() - print(piece) state, _ = jigsaw.reset(key) expanded_grid_with_piece = jigsaw._expand_piece_to_board(state, piece, 2, 1) # fmt: off @@ -220,9 +218,9 @@ def test_jigsaw__completed_episode_with_dense_reward( simple_env_board_state_2: chex.Array, simple_env_board_state_3: chex.Array, simple_env_board_state_4: chex.Array, - simple_env_action_mask_1: chex.Array, + simple_env_placed_pieces_1: chex.Array, simple_env_action_mask_2: chex.Array, - simple_env_action_mask_3: chex.Array, + simple_env_placed_pieces_3: chex.Array, ) -> None: """This test will step a simplified version of the Jigsaw environment with a dense reward until completion. It will check that the reward is @@ -250,21 +248,21 @@ def test_jigsaw__completed_episode_with_dense_reward( assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_1) assert timestep.reward == 6.0 - assert jnp.all(state.action_mask == simple_env_action_mask_1) + assert jnp.all(state.placed_pieces == simple_env_placed_pieces_1) # Step the environment state, timestep = step_fn(state, jnp.array([1, 0, 0, 2])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_2) assert timestep.reward == 6.0 - assert jnp.all(state.action_mask == simple_env_action_mask_2) + assert jnp.all(state.placed_pieces == simple_env_action_mask_2) # Step the environment state, timestep = step_fn(state, jnp.array([2, 0, 2, 0])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_3) assert timestep.reward == 6.0 - assert jnp.all(state.action_mask == simple_env_action_mask_3) + assert jnp.all(state.placed_pieces == simple_env_placed_pieces_3) # Step the environment state, timestep = step_fn(state, jnp.array([3, 0, 2, 2])) @@ -280,9 +278,9 @@ def test_jigsaw__completed_episode_with_sparse_reward( simple_env_board_state_2: chex.Array, simple_env_board_state_3: chex.Array, simple_env_board_state_4: chex.Array, - simple_env_action_mask_1: chex.Array, + simple_env_placed_pieces_1: chex.Array, simple_env_action_mask_2: chex.Array, - simple_env_action_mask_3: chex.Array, + simple_env_placed_pieces_3: chex.Array, ) -> None: """This test will step a simplified version of the Jigsaw environment with a sparse reward until completion. It will check that the reward is @@ -311,21 +309,22 @@ def test_jigsaw__completed_episode_with_sparse_reward( assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_1) assert timestep.reward == 0.0 - assert jnp.all(state.action_mask == simple_env_action_mask_1) + assert jnp.all(state.placed_pieces == simple_env_placed_pieces_1) # Step the environment state, timestep = step_fn(state, jnp.array([1, 2, 0, 2])) assert timestep.step_type == StepType.MID + assert jnp.all(state.current_board == simple_env_board_state_2) assert timestep.reward == 0.0 - assert jnp.all(state.action_mask == simple_env_action_mask_2) + assert jnp.all(state.placed_pieces == simple_env_action_mask_2) # Step the environment state, timestep = step_fn(state, jnp.array([2, 1, 2, 0])) assert timestep.step_type == StepType.MID assert jnp.all(state.current_board == simple_env_board_state_3) assert timestep.reward == 0.0 - assert jnp.all(state.action_mask == simple_env_action_mask_3) + assert jnp.all(state.placed_pieces == simple_env_placed_pieces_3) # Step the environment state, timestep = step_fn(state, jnp.array([3, 0, 2, 2])) diff --git a/jumanji/environments/packing/jigsaw/generator.py b/jumanji/environments/packing/jigsaw/generator.py index 3d83bc4e6..bd13c7d7c 100644 --- a/jumanji/environments/packing/jigsaw/generator.py +++ b/jumanji/environments/packing/jigsaw/generator.py @@ -296,10 +296,13 @@ def __call__(self, key: chex.PRNGKey) -> State: num_pieces=num_pieces, col_nibs_idxs=col_nibs_idxs, row_nibs_idxs=row_nibs_idxs, - action_mask=jnp.ones(num_pieces, dtype=bool), + action_mask=jnp.ones( + (num_pieces, 4, grid_row_dim - 3, grid_col_dim - 3), dtype=bool + ), current_board=jnp.zeros_like(solved_board), step_count=0, key=key, + placed_pieces=jnp.zeros((num_pieces), dtype=bool), ) @@ -339,12 +342,13 @@ def __call__(self, key: chex.PRNGKey) -> State: solved_board=mock_solved_grid, pieces=mock_pieces, current_board=jnp.zeros_like(mock_solved_grid), - action_mask=jnp.ones(4, dtype=bool), + action_mask=jnp.ones((4, 4, 2, 2), dtype=bool), col_nibs_idxs=jnp.array([2], dtype=jnp.int32), row_nibs_idxs=jnp.array([2], dtype=jnp.int32), num_pieces=jnp.int32(4), key=jax.random.PRNGKey(0), step_count=0, + placed_pieces=jnp.zeros(4, dtype=bool), ) @@ -390,7 +394,8 @@ def __call__(self, key: chex.PRNGKey) -> State: row_nibs_idxs=jnp.array([2], dtype=jnp.int32), num_pieces=jnp.int32(4), key=jax.random.PRNGKey(0), - action_mask=jnp.ones(4, dtype=bool), + action_mask=jnp.ones((4, 4, 2, 2), dtype=bool), current_board=jnp.zeros_like(mock_solved_grid), step_count=0, + placed_pieces=jnp.zeros(4, dtype=bool), ) diff --git a/jumanji/environments/packing/jigsaw/generator_test.py b/jumanji/environments/packing/jigsaw/generator_test.py index ed290c1e5..8035cd33f 100644 --- a/jumanji/environments/packing/jigsaw/generator_test.py +++ b/jumanji/environments/packing/jigsaw/generator_test.py @@ -78,7 +78,7 @@ def test_random_jigsaw_generator__call( assert all(state.pieces[i].shape == (3, 3) for i in range(4)) assert state.col_nibs_idxs == jnp.array([2]) assert state.row_nibs_idxs == jnp.array([2]) - assert state.action_mask.shape == (4,) + assert state.action_mask.shape == (4, 4, 2, 2) assert state.step_count == 0 diff --git a/jumanji/environments/packing/jigsaw/jigsaw_example.py b/jumanji/environments/packing/jigsaw/jigsaw_example.py index a47086af9..1607a503d 100644 --- a/jumanji/environments/packing/jigsaw/jigsaw_example.py +++ b/jumanji/environments/packing/jigsaw/jigsaw_example.py @@ -25,7 +25,7 @@ ) from jumanji.environments.packing.jigsaw.reward import SparseReward -SAVE_GIF = False +SAVE_GIF = True # Very basic example of a random agent acting in the Jigsaw environment. # Each episode will generate a completely new instance of the jigsaw puzzle. @@ -41,7 +41,7 @@ jit_reset = jax.jit(chex.assert_max_traces(env.reset, n=1)) episode_returns: list = [] states: list = [] -for ep in range(50): +for ep in range(30): step_key, reset_key = jax.random.split(step_key) state, timestep = jit_reset(key=reset_key) states.append(state) @@ -53,14 +53,18 @@ # Only select a random piece from pieces that are true in the timestep.action_mask # (i.e. pieces that are not yet placed) - probs = timestep.observation.action_mask[: env.num_pieces] / jnp.sum( - timestep.observation.action_mask[: env.num_pieces] - ) - piece_id = jax.random.choice( - a=action_spec.maximum[0] + 1, - shape=(), - key=piece_key, - p=probs, + # piece_action_mask = timestep.observation.action_mask[:, 0, 0, 0] + # probs = piece_action_mask / jnp.sum( + # piece_action_mask + # ) + # piece_id = jax.random.choice( + # a=action_spec.maximum[0] + 1, + # shape=(), + # key=piece_key, + # p=probs, + # ) + piece_id = jax.random.randint( + piece_key, shape=(), minval=0, maxval=action_spec.maximum[0] + 1 ) rotation = jax.random.randint( rot_key, shape=(), minval=0, maxval=action_spec.maximum[1] + 1 @@ -89,7 +93,7 @@ print(f"Average return: {jnp.mean(jnp.array(episode_returns))}, SPS: {int(sps)}\n") if SAVE_GIF: - env.animate(states=states, interval=200, save_path="big_env.gif") + env.animate(states=states, interval=200, save_path="big_env_2.gif") # An example of solving a puzzle by stepping a # dummy environment with a dense reward function. diff --git a/jumanji/environments/packing/jigsaw/reward_test.py b/jumanji/environments/packing/jigsaw/reward_test.py index 4e15e85be..937a989fd 100644 --- a/jumanji/environments/packing/jigsaw/reward_test.py +++ b/jumanji/environments/packing/jigsaw/reward_test.py @@ -48,7 +48,8 @@ def state_with_no_pieces_placed( num_pieces=4, pieces=pieces, solved_board=solved_board, - action_mask=jnp.ones(4, dtype=bool), + action_mask=jnp.ones((4, 4, 2, 2), dtype=bool), + placed_pieces=jnp.zeros(4, dtype=bool), current_board=jnp.zeros_like(solved_board), step_count=0, key=key, @@ -70,6 +71,7 @@ def state_with_piece_one_placed( col_nibs_idxs=jnp.array([2]), num_pieces=4, solved_board=solved_board, + # TODO: Add the correct full action mask here. action_mask=jnp.array( [ False, @@ -78,6 +80,14 @@ def state_with_piece_one_placed( True, ] ), + placed_pieces=jnp.array( + [ + True, + False, + False, + False, + ] + ), current_board=board_with_piece_one_placed, step_count=0, key=new_key, @@ -103,6 +113,7 @@ def state_needing_only_piece_one( col_nibs_idxs=jnp.array([2]), num_pieces=4, solved_board=solved_board, + # TODO: Add the correct full action mask here. action_mask=jnp.array( [ True, @@ -111,6 +122,14 @@ def state_needing_only_piece_one( True, ] ), + placed_pieces=jnp.array( + [ + True, + False, + False, + False, + ] + ), current_board=current_board, step_count=3, pieces=pieces, @@ -133,12 +152,13 @@ def solved_state( col_nibs_idxs=jnp.array([2]), num_pieces=4, solved_board=solved_board, - action_mask=jnp.array( + action_mask=jnp.ones((4, 4, 2, 2), dtype=bool), + placed_pieces=jnp.array( [ - False, - False, - False, - False, + True, + True, + True, + True, ] ), current_board=solved_board, diff --git a/jumanji/environments/packing/jigsaw/types.py b/jumanji/environments/packing/jigsaw/types.py index 3dc322f8c..92f77b248 100644 --- a/jumanji/environments/packing/jigsaw/types.py +++ b/jumanji/environments/packing/jigsaw/types.py @@ -27,7 +27,9 @@ class Observation(NamedTuple): current_board: 2D array with the current state of board. pieces: 3D array with the pieces to be placed on the board. Here each piece is a 2D array with shape (3, 3). - action_mask: array showing which pieces can be placed on the board. + action_mask: 4D array showing where pieces can be placed on the board. + this mask include all possible rotations and possible placement locations + for each piece on the board. """ current_board: chex.Array # (num_rows, num_cols) @@ -48,7 +50,10 @@ class State: solved_board: 2D array showing the solved board state. pieces: 3D array with the pieces to be placed on the board. Here each piece is a 2D array with shape (3, 3). - action_mask: array showing which pieces can be placed on the board. + action_mask: 4D array showing where pieces can be placed on the board. + this mask include all possible rotations and possible placement locations + for each piece on the board. + placed_pieces: 1D boolean array showing which pieces have been placed on the board. current_board: 2D array with the current state of board. step_count: number of steps taken in the environment. key: random key used for board generation. @@ -59,7 +64,8 @@ class State: num_pieces: chex.Numeric # () solved_board: chex.Array # (num_rows, num_cols) pieces: chex.Array # (num_pieces, 3, 3) - action_mask: chex.Array # (num_pieces,) + action_mask: chex.Array # (num_pieces, num_rotations, num_rows-3, num_cols-3) + placed_pieces: chex.Array # (num_pieces,) current_board: chex.Array # (num_rows, num_cols) step_count: chex.Numeric # () key: chex.PRNGKey # (2,) From f67315c038a3abf36c5f2f4df1777dfd767b453a Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Thu, 25 May 2023 20:31:12 +0200 Subject: [PATCH 09/27] feat: cleaner action mask generation. --- jumanji/environments/packing/jigsaw/env.py | 124 ++++++++---------- .../environments/packing/jigsaw/env_test.py | 4 +- 2 files changed, 56 insertions(+), 72 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 7aa4f0996..fc052258e 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -51,8 +51,8 @@ def __init__( """ default_generator = RandomJigsawGenerator( - num_row_pieces=3, - num_col_pieces=3, + num_row_pieces=5, + num_col_pieces=5, ) self.generator = generator or default_generator @@ -76,15 +76,14 @@ def __repr__(self) -> str: ) def reset( - self, key: chex.PRNGKey, generate_new_board: bool = False + self, + key: chex.PRNGKey, ) -> Tuple[State, TimeStep[Observation]]: """Resets the environment. Args: key: PRNG key for generating a new instance. - generate_new_board: whether to generate a new board - or reset the current one. Returns: a tuple of the initial state and a time step. @@ -92,12 +91,6 @@ def reset( board_state = self.generator(key) - # board_state.action_mask = jnp.ones(( - # self.num_pieces, 4, self.num_rows-3, self.num_cols-3, - # ), dtype=bool) - # board_state.current_board = jnp.zeros_like(board_state.solved_board) - # board_state.placed_pieces = jnp.zeros((self.num_pieces,), dtype=bool) - obs = self._observation_from_state(board_state) timestep = restart(observation=obs) @@ -123,57 +116,40 @@ def step( # Rotate chosen piece chosen_piece = rotate_piece(chosen_piece, rotation) - grid_piece = self._expand_piece_to_board(state, chosen_piece, row_idx, col_idx) + grid_piece = self._expand_piece_to_board(chosen_piece, row_idx, col_idx) grid_mask_piece = self._get_ones_like_expanded_piece(grid_piece=grid_piece) - action_is_legal = self._check_action_is_legal(action, state, grid_mask_piece) + action_is_legal = self._check_action_is_legal( + action, state.current_board, state.placed_pieces, grid_mask_piece + ) - next_state_legal = State( - col_nibs_idxs=state.col_nibs_idxs, - row_nibs_idxs=state.row_nibs_idxs, - solved_board=state.solved_board, - current_board=state.current_board + grid_piece, - pieces=state.pieces, - action_mask=state.action_mask, # filler for now - num_pieces=state.num_pieces, - key=state.key, - step_count=state.step_count + 1, - placed_pieces=state.placed_pieces.at[piece_idx].set(True), + # If the action is legal + new_board = jax.lax.cond( + action_is_legal, + lambda: state.current_board + grid_piece, + lambda: state.current_board, + ) + placed_pieces = jax.lax.cond( + action_is_legal, + lambda: state.placed_pieces.at[piece_idx].set(True), + lambda: state.placed_pieces, + ) + + new_action_mask = self._make_full_action_mask( + new_board, state.pieces, placed_pieces ) - next_state_illegal = State( + next_state = State( col_nibs_idxs=state.col_nibs_idxs, row_nibs_idxs=state.row_nibs_idxs, solved_board=state.solved_board, - current_board=state.current_board, + current_board=new_board, pieces=state.pieces, - action_mask=state.action_mask, + action_mask=new_action_mask, num_pieces=state.num_pieces, key=state.key, step_count=state.step_count + 1, - placed_pieces=state.placed_pieces, - ) - - # Transition board to new state if the action is legal - # otherwise, stay in the same state. - next_state = jax.lax.cond( - action_is_legal, - lambda: next_state_legal, - lambda: next_state_illegal, - ) - - full_action_mask = self._make_full_action_mask(next_state) - next_state = State( - col_nibs_idxs=next_state.col_nibs_idxs, - row_nibs_idxs=next_state.row_nibs_idxs, - solved_board=next_state.solved_board, - current_board=next_state.current_board, - pieces=next_state.pieces, - action_mask=full_action_mask, # filler for now - num_pieces=next_state.num_pieces, - key=next_state.key, - step_count=next_state.step_count, - placed_pieces=next_state.placed_pieces, + placed_pieces=placed_pieces, ) done = self._check_done(next_state) @@ -312,7 +288,11 @@ def _check_done(self, state: State) -> bool: return done def _check_action_is_legal( - self, action: chex.Numeric, state: State, grid_mask_piece: chex.Array + self, + action: chex.Numeric, + current_board: chex.Array, + placed_pieces: chex.Array, + grid_mask_piece: chex.Array, ) -> bool: """Checks if the action is legal by considering the action mask and the board mask. An action is legal if the action mask is True for that action @@ -330,12 +310,9 @@ def _check_action_is_legal( piece_idx, _, _, _ = action - placed_mask = (state.current_board > 0.0) + grid_mask_piece + placed_mask = (current_board > 0.0) + grid_mask_piece - # legal: bool = state.action_mask[ - # piece_idx, rotation, row, col - # ] & (jnp.max(placed_mask) <= 1) - legal: bool = (~state.placed_pieces[piece_idx]) & (jnp.max(placed_mask) <= 1) + legal: bool = (~placed_pieces[piece_idx]) & (jnp.max(placed_mask) <= 1) return legal @@ -352,7 +329,6 @@ def _get_ones_like_expanded_piece(self, grid_piece: chex.Array) -> chex.Array: def _expand_piece_to_board( self, - state: State, piece: chex.Array, row_coord: chex.Numeric, col_coord: chex.Numeric, @@ -371,8 +347,7 @@ def _expand_piece_to_board( Grid of zeroes with values where the piece is placed. """ - grid_with_piece = jnp.zeros_like(state.solved_board) - + grid_with_piece = jnp.zeros((self.num_rows, self.num_cols), dtype=jnp.float32) place_location = (row_coord, col_coord) grid_with_piece = jax.lax.dynamic_update_slice( @@ -399,7 +374,7 @@ def _observation_from_state(self, state: State) -> Observation: def _expand_all_pieces_to_boards( self, - state: State, + pieces: chex.Array, piece_idxs: chex.Array, rotations: chex.Array, rows: chex.Array, @@ -409,12 +384,13 @@ def _expand_all_pieces_to_boards( # and generates a grid for each piece. It then returns an array of these grids. batch_expand_piece_to_board = jax.vmap( - self._expand_piece_to_board, in_axes=(None, 0, 0, 0) + self._expand_piece_to_board, in_axes=(0, 0, 0) ) - pieces = state.pieces[piece_idxs] - rotated_pieces = jax.vmap(rotate_piece, in_axes=(0, 0))(pieces, rotations) - grids = batch_expand_piece_to_board(state, rotated_pieces, rows, cols) + # TODO: Better naming here. + all_pieces = pieces[piece_idxs] + rotated_pieces = jax.vmap(rotate_piece, in_axes=(0, 0))(all_pieces, rotations) + grids = batch_expand_piece_to_board(rotated_pieces, rows, cols) batch_get_ones_like_expanded_piece = jax.vmap( self._get_ones_like_expanded_piece, in_axes=(0) @@ -422,9 +398,16 @@ def _expand_all_pieces_to_boards( grids = batch_get_ones_like_expanded_piece(grids) return grids - def _make_full_action_mask(self, state: State) -> chex.Array: + def _make_full_action_mask( + self, current_board: chex.Array, pieces: chex.Array, placed_pieces: chex.Array + ) -> chex.Array: """Create a mask of possible actions based on the current state.""" - num_pieces, num_rotations, num_rows, num_cols = state.action_mask.shape + num_pieces, num_rotations, num_rows, num_cols = ( + self.num_pieces, + 4, + self.num_rows - 3, + self.num_cols - 3, + ) pieces_grid, rotations_grid, rows_grid, cols_grid = jnp.meshgrid( jnp.arange(num_pieces), @@ -434,7 +417,7 @@ def _make_full_action_mask(self, state: State) -> chex.Array: ) grid_mask_pieces = self._expand_all_pieces_to_boards( - state, + pieces, pieces_grid.flatten(), rotations_grid.flatten(), rows_grid.flatten(), @@ -442,13 +425,14 @@ def _make_full_action_mask(self, state: State) -> chex.Array: ) batch_check_action_is_legal = jax.vmap( - self._check_action_is_legal, in_axes=(0, None, 0) + self._check_action_is_legal, in_axes=(0, None, None, 0) ) legal_actions = batch_check_action_is_legal( jnp.stack( (pieces_grid, rotations_grid, rows_grid, cols_grid), axis=-1 ).reshape(-1, 4), - state, + current_board, + placed_pieces, grid_mask_pieces, ) @@ -457,7 +441,7 @@ def _make_full_action_mask(self, state: State) -> chex.Array: ) # Now set all current placed pieces to false in the mask. - placed_pieces_array = state.placed_pieces.reshape((self.num_pieces, 1, 1, 1)) + placed_pieces_array = placed_pieces.reshape((self.num_pieces, 1, 1, 1)) placed_pieces_mask = jnp.tile( placed_pieces_array, (1, num_rotations, num_rows, num_cols) ) diff --git a/jumanji/environments/packing/jigsaw/env_test.py b/jumanji/environments/packing/jigsaw/env_test.py index c94db9895..7f73670a8 100644 --- a/jumanji/environments/packing/jigsaw/env_test.py +++ b/jumanji/environments/packing/jigsaw/env_test.py @@ -194,8 +194,8 @@ def test_jigsaw___expand_piece_to_board( jigsaw: Jigsaw, key: chex.PRNGKey, piece: chex.Array ) -> None: """Test that a piece is correctly set on a grid of zeros.""" - state, _ = jigsaw.reset(key) - expanded_grid_with_piece = jigsaw._expand_piece_to_board(state, piece, 2, 1) + _, _ = jigsaw.reset(key) + expanded_grid_with_piece = jigsaw._expand_piece_to_board(piece, 2, 1) # fmt: off expected_expanded_grid = jnp.array( [ From 941fee10cb762eaf80fc65e6828821abbce5e0df Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 28 May 2023 14:40:26 +0200 Subject: [PATCH 10/27] feat: added jigsaw documentation --- README.md | 1 + docs/api/environments/jigsaw.md | 8 +++ docs/env_anim/jigsaw.gif | Bin 0 -> 139390 bytes docs/env_img/jigsaw.png | Bin 0 -> 17264 bytes docs/environments/jigsaw.md | 55 +++++++++++++++++++++ jumanji/environments/packing/jigsaw/env.py | 4 +- 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 docs/api/environments/jigsaw.md create mode 100644 docs/env_anim/jigsaw.gif create mode 100644 docs/env_img/jigsaw.png create mode 100644 docs/environments/jigsaw.md diff --git a/README.md b/README.md index d7e1ff424..193e2504a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ problems. | 💣 Minesweeper | Logic | `Minesweeper-v0` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/logic/minesweeper/) | [doc](https://instadeepai.github.io/jumanji/environments/minesweeper/) | | 🎲 RubiksCube | Logic | `RubiksCube-v0`
`RubiksCube-partly-scrambled-v0` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/logic/rubiks_cube/) | [doc](https://instadeepai.github.io/jumanji/environments/rubiks_cube/) | | 📦 BinPack (3D BinPacking Problem) | Packing | `BinPack-v1` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/packing/bin_pack/) | [doc](https://instadeepai.github.io/jumanji/environments/bin_pack/) | +| 🧩 Jigsaw (Jigsaw Puzzle Solving) | Packing | `Jigsaw-v0` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/packing/jigsaw/) | [doc](https://instadeepai.github.io/jumanji/environments/jigsaw/) | | 🏭 JobShop (Job Shop Scheduling Problem) | Packing | `JobShop-v0` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/packing/job_shop/) | [doc](https://instadeepai.github.io/jumanji/environments/job_shop/) | | 🎒 Knapsack | Packing | `Knapsack-v1` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/packing/knapsack/) | [doc](https://instadeepai.github.io/jumanji/environments/knapsack/) | | 🧹 Cleaner | Routing | `Cleaner-v0` | [code](https://github.com/instadeepai/jumanji/tree/main/jumanji/environments/routing/cleaner/) | [doc](https://instadeepai.github.io/jumanji/environments/cleaner/) | diff --git a/docs/api/environments/jigsaw.md b/docs/api/environments/jigsaw.md new file mode 100644 index 000000000..0c0937e72 --- /dev/null +++ b/docs/api/environments/jigsaw.md @@ -0,0 +1,8 @@ +::: jumanji.environments.packing.jigsaw.env.Jigsaw + selection: + members: + - __init__ + - reset + - step + - observation_spec + - action_spec diff --git a/docs/env_anim/jigsaw.gif b/docs/env_anim/jigsaw.gif new file mode 100644 index 0000000000000000000000000000000000000000..daefcb6a5c854488a4ff5184051194d13acdd533 GIT binary patch literal 139390 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
  • 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

    sod?_Gf6y= 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 B)ARrU literal 0 HcmV?d00001 diff --git a/docs/env_img/jigsaw.png b/docs/env_img/jigsaw.png new file mode 100644 index 0000000000000000000000000000000000000000..3e685d339cfc22e32dfde16945b58e0ba0e7ea33 GIT binary patch literal 17264 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 literal 0 HcmV?d00001 diff --git a/docs/environments/jigsaw.md b/docs/environments/jigsaw.md new file mode 100644 index 000000000..ea801cfbc --- /dev/null +++ b/docs/environments/jigsaw.md @@ -0,0 +1,55 @@ +# Rubik's Cube Environment + +

    + +

    + +We provide here a Jax JIT-able implementation of a simple _jigsaw_ puzzle. The goal of the agent is to place +all the jigsaw pieces in the correct locations on an empty 2D puzzle board. Each time an episode resets a +new puzzle and set of piece is created. Pieces are randomly shuffled and rotated. + +## Observation +The observation given to the agent gives a view of the current state of the puzzle as well as +all pieces that can be placed. + +- `current_board`: jax array (float32) of shape `(num_rows, num_cols)` with values in the range + `[1, num_pieces]` (corresponding to the number of each piece). This board will have zeros + where no pieces have been placed and numbers corresponding to each piece where that particular + pieces has been paced. + +- `pieces`: jax array (float32) of shape `(num_pieces, 3, 3)` of all possible pieces in the + current puzzle. These pieces are shuffled and rotated. Pieces will always have shape `(3, 3)`. + +- `action_mask`: jax array (bool) of shape `(num_pieces, 4, num_rows-3, num_cols-3)`, representing the + which actions are possible given the current state of the board. The first index indicates the + number of pieces in a given puzzle. The second index indicates the number of times a piece may be rotated. + The third and fourth indices indicate the x and y coordinate of where a piece may be placed respectively. + These values will always be `num_rows-3` and `num_cols-3` respectively to make it impossible for an agent to + place a piece outside the current board. + + +## Action +The action space is a `MultiDiscreteArray`, specifically a tuple of an index between 0 and `num_pieces`, +an index between 0 and 4 (since there are 4 possible rotations), an index between 0 and `num_rows-3` +(the possible row coordinates for placing a piece) and an index between 0 and `num_cols-3` +(the possible column coordinates for placing a piece). An action thus consists of three pieces of +information: + +- Piece to place, + +- Number of rotations to make to a chosen piece ({0, 90, 180, 270} degrees), + +- Row coordinate for placing the rotated piece, + +- Column coordinate for placed the rotated piece. + + +## Reward +The reward function is configurable, but by default is a fully dense reward giving `+1` for +each cell of a placed piece that overlaps with its correct position on the solved board. The episode +terminates if either the puzzle is solved or `num_pieces` steps have been taken by an agent. + + +## Registered Versions 📖 +- `Jigsaw-v0`, a jigsaw puzzle with 7 rows and 7 columns containing 3 row pieces and 3 column pieces + for a total of 9 pieces in the puzzle. This version has a dense reward. diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index fc052258e..b35eaeec3 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -51,8 +51,8 @@ def __init__( """ default_generator = RandomJigsawGenerator( - num_row_pieces=5, - num_col_pieces=5, + num_row_pieces=3, + num_col_pieces=3, ) self.generator = generator or default_generator From 2839da0b20d732b0177404b74f519d0150a73d69 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 28 May 2023 14:46:15 +0200 Subject: [PATCH 11/27] chore: typo fix. --- docs/environments/jigsaw.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/environments/jigsaw.md b/docs/environments/jigsaw.md index ea801cfbc..ba8c99b4c 100644 --- a/docs/environments/jigsaw.md +++ b/docs/environments/jigsaw.md @@ -1,4 +1,4 @@ -# Rubik's Cube Environment +# Jigsaw Environment

    @@ -20,10 +20,10 @@ all pieces that can be placed. - `pieces`: jax array (float32) of shape `(num_pieces, 3, 3)` of all possible pieces in the current puzzle. These pieces are shuffled and rotated. Pieces will always have shape `(3, 3)`. -- `action_mask`: jax array (bool) of shape `(num_pieces, 4, num_rows-3, num_cols-3)`, representing the +- `action_mask`: jax array (bool) of shape `(num_pieces, 4, num_rows-3, num_cols-3)`, representing which actions are possible given the current state of the board. The first index indicates the number of pieces in a given puzzle. The second index indicates the number of times a piece may be rotated. - The third and fourth indices indicate the x and y coordinate of where a piece may be placed respectively. + The third and fourth indices indicate the row and column coordinate of where a piece may be placed respectively. These values will always be `num_rows-3` and `num_cols-3` respectively to make it impossible for an agent to place a piece outside the current board. @@ -32,7 +32,7 @@ all pieces that can be placed. The action space is a `MultiDiscreteArray`, specifically a tuple of an index between 0 and `num_pieces`, an index between 0 and 4 (since there are 4 possible rotations), an index between 0 and `num_rows-3` (the possible row coordinates for placing a piece) and an index between 0 and `num_cols-3` -(the possible column coordinates for placing a piece). An action thus consists of three pieces of +(the possible column coordinates for placing a piece). An action thus consists of four pieces of information: - Piece to place, From 3474efa7a52b8716dc9fb0b8d5c439365e5f6726 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 28 May 2023 16:45:19 +0200 Subject: [PATCH 12/27] feat: added class doctring to env. --- jumanji/environments/packing/jigsaw/env.py | 60 +++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index b35eaeec3..e04a62d54 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -36,7 +36,65 @@ class Jigsaw(Environment[State]): - """A Jigsaw solving environment.""" + """A Jigsaw solving environment with a configurable number of row and column pieces. + Here the goal of an agent is to solve a jigsaw puzzle by placing pieces + in their correct positions. + + - observation: Observation + - current_board: jax array (float) of shape (num_rows, num_cols) with the + current state of board. + - pieces: jax array (float) of shape (num_pieces, 3, 3) with the pieces to + be placed on the board. Here each piece is a 2D array with shape (3, 3). + - action_mask: jax array (float) showing where which pieces can be placed on the board. + this mask include all possible rotations and possible placement locations + for each piece on the board. + + - action: jax array (int32) of shape () + multi discrete array containing the move to perform + (piece to place, number of rotations, row coordinate, column coordinate). + + - reward: jax array (float) of shape (), could be either: + - dense: the number of cells in the placed piece the overlaps with the correctly + piece. this will be a value in the range [0, 9]. + - sparse: 1 if the board is solved, otherwise 0 at each timestep. + + - episode termination: + - if all pieces have been placed on the board. + - if the agent has taken `num_pieces` steps in the environment. + + - state: `State` + - row_nibs_idxs: jax array (float) array containing row indices + for selecting piece nibs during board generation. + - col_nibs_idxs: jax array (float) array containing column indices + for selecting piece nibs during board generation. + - num_pieces: jax array (float) of shape () with the + number of pieces in the jigsaw puzzle. + - solved_board: jax array (float) of shape (num_rows, num_cols) with the + solved board state. + - pieces: jax array (float) of shape (num_pieces, 3, 3) with the pieces to + be placed on the board. + - action_mask: jax array (float) of shape (num_pieces, 4, num_rows, num_cols) + showing where which pieces can be placed where on the board. + - placed_pieces: jax array (bool) of shape (num_pieces,) showing which pieces + have been placed on the board. + - current_board: jax array (float) of shape (num_rows, num_cols) with the + current state of board. + - step_count: jax array (float) of shape () with the number of steps taken + in the environment. + - key: jax array (float) of shape (2,) with the random key used for board + generation. + + ```python + from jumanji.environments import Jigsaw + env = Jigsaw() + key = jax.random.key(0) + state, timestep = jax.jit(env.reset)(key) + env.render(state) + action = env.action_spec().generate_value() + state, timestep = jax.jit(env.step)(state, action) + env.render(state) + ``` + """ def __init__( self, From 637847764c4886a55414c56f04c21d31b641546f Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 28 May 2023 16:47:56 +0200 Subject: [PATCH 13/27] feat: import jigsaw actor critic network. --- jumanji/training/configs/config.yaml | 4 ++-- jumanji/training/networks/__init__.py | 3 +++ jumanji/training/setup_train.py | 9 +++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/jumanji/training/configs/config.yaml b/jumanji/training/configs/config.yaml index 9e96fa851..ef296385b 100644 --- a/jumanji/training/configs/config.yaml +++ b/jumanji/training/configs/config.yaml @@ -1,8 +1,8 @@ defaults: - _self_ - - env: jigsaw # [bin_pack, cleaner, connector, cvrp, game_2048, jigsaw, job_shop, knapsack, maze, minesweeper, rubiks_cube, snake, tsp] + - env: jigsaw # [bin_pack, cleaner, connector, cvrp, game_2048, jigsaw, job_shop, knapsack, maze, minesweeper, rubiks_cube, snake, tsp] -agent: random # [random, a2c] +agent: a2c # [random, a2c] seed: 0 diff --git a/jumanji/training/networks/__init__.py b/jumanji/training/networks/__init__.py index a541dafe6..c375ce337 100644 --- a/jumanji/training/networks/__init__.py +++ b/jumanji/training/networks/__init__.py @@ -34,6 +34,9 @@ make_actor_critic_networks_game_2048, ) from jumanji.training.networks.game_2048.random import make_random_policy_game_2048 +from jumanji.training.networks.jigsaw.actor_critic import ( + make_actor_critic_networks_jigsaw, +) from jumanji.training.networks.jigsaw.random import make_random_policy_jigsaw from jumanji.training.networks.job_shop.actor_critic import ( make_actor_critic_networks_job_shop, diff --git a/jumanji/training/setup_train.py b/jumanji/training/setup_train.py index 2a5af7f57..394a216e5 100644 --- a/jumanji/training/setup_train.py +++ b/jumanji/training/setup_train.py @@ -215,6 +215,15 @@ def _setup_actor_critic_neworks( # noqa: CCR001 transformer_key_size=cfg.env.network.transformer_key_size, transformer_mlp_units=cfg.env.network.transformer_mlp_units, ) + elif cfg.env.name == "jigsaw": + assert isinstance(env.unwrapped, Jigsaw) + actor_critic_networks = networks.make_actor_critic_networks_jigsaw( + jigsaw=env.unwrapped, + num_transformer_layers=cfg.env.network.num_transformer_layers, + transformer_num_heads=cfg.env.network.transformer_num_heads, + transformer_key_size=cfg.env.network.transformer_key_size, + transformer_mlp_units=cfg.env.network.transformer_mlp_units, + ) elif cfg.env.name == "job_shop": assert isinstance(env.unwrapped, JobShop) actor_critic_networks = networks.make_actor_critic_networks_job_shop( From 478b5042c21942b99638ebe0e407bcd149d7c6ca Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 28 May 2023 17:21:23 +0200 Subject: [PATCH 14/27] wip: work on actor critic networks. --- .../training/networks/jigsaw/actor_critic.py | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 jumanji/training/networks/jigsaw/actor_critic.py diff --git a/jumanji/training/networks/jigsaw/actor_critic.py b/jumanji/training/networks/jigsaw/actor_critic.py new file mode 100644 index 000000000..430ff1dd9 --- /dev/null +++ b/jumanji/training/networks/jigsaw/actor_critic.py @@ -0,0 +1,280 @@ +# Copyright 2022 InstaDeep Ltd. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional, Sequence, Tuple + +import chex +import haiku as hk +import jax +import jax.numpy as jnp +import numpy as np + +from jumanji.environments.packing.jigsaw import Jigsaw, Observation +from jumanji.training.networks.actor_critic import ( + ActorCriticNetworks, + FeedForwardNetwork, +) +from jumanji.training.networks.parametric_distribution import ( + FactorisedActionSpaceParametricDistribution, +) +from jumanji.training.networks.transformer_block import TransformerBlock + + +def make_actor_critic_networks_jigsaw( + jigsaw: Jigsaw, + num_transformer_layers: int, + transformer_num_heads: int, + transformer_key_size: int, + transformer_mlp_units: Sequence[int], +) -> ActorCriticNetworks: + """Make actor-critic networks for the `Jigsaw` environment.""" + num_values = np.asarray(jigsaw.action_spec().num_values) + parametric_action_distribution = FactorisedActionSpaceParametricDistribution( + action_spec_num_values=num_values + ) + num_rows, num_cols = jigsaw.num_rows, jigsaw.num_cols + num_pieces = jigsaw.num_pieces + policy_network = make_actor_network_jigsaw( + num_transformer_layers=num_transformer_layers, + transformer_num_heads=transformer_num_heads, + transformer_key_size=transformer_key_size, + transformer_mlp_units=transformer_mlp_units, + num_pieces=num_pieces, + num_rows=num_rows, + num_cols=num_cols, + h=10, + f=5, + ) + value_network = make_critic_network_jigsaw( + num_transformer_layers=num_transformer_layers, + transformer_num_heads=transformer_num_heads, + transformer_key_size=transformer_key_size, + transformer_mlp_units=transformer_mlp_units, + num_pieces=num_pieces, + num_rows=num_rows, + num_cols=num_cols, + h=10, + f=10, + ) + return ActorCriticNetworks( + policy_network=policy_network, + value_network=value_network, + parametric_action_distribution=parametric_action_distribution, + ) + + +# Net for down convolution of the board +class SimpleNet(hk.Module): + def __init__(self, num_maps: int, f: int, name: Optional[str] = None) -> None: + super().__init__(name=name) + self.num_maps = num_maps + self.f = f + + def __call__(self, x: chex.Array) -> chex.Array: + # Assuming x is of shape (batch_size, num_rows, num_cols, 1) + x = hk.Conv2D(self.num_maps, kernel_shape=3, stride=1, padding="SAME")(x) + + # Flatten the tensor + flat = x.reshape(x.shape[0], -1) + + # Use a linear layer to project to (num_maps, F) + projection = hk.Linear(self.num_maps * self.F)(flat) + + # Reshape to desired output shape + projection = projection.reshape(x.shape[0], self.num_maps, self.F) + + return projection + + +# TODO: Fix this to use convs. +class UpConvNet(hk.Module): + def __init__( + self, num_rows: int, num_cols: int, name: Optional[str] = None + ) -> None: + super().__init__(name=name) + self.num_rows = num_rows + self.num_cols = num_cols + + def __call__(self, x: chex.Array) -> chex.Array: + # Assuming x is of shape (batch_size, num_maps, F) + + # Flatten the tensor along last two dimensions + flat = x.reshape(x.shape[0], -1) + + # Use a linear layer to project to (batch_size, num_rows * num_cols) + projection = hk.Linear(self.num_rows * self.num_cols)(flat) + + # Reshape to desired output shape + projection = projection.reshape(x.shape[0], self.num_rows, self.num_cols) + + return projection + + +class JigsawTorso(hk.Module): + def __init__( + self, + num_transformer_layers: int, + transformer_num_heads: int, + transformer_key_size: int, + transformer_mlp_units: Sequence[int], + num_pieces: int, + h: int, + f: int, + num_rows: int, + num_cols: int, + name: Optional[str] = None, + ) -> None: + super().__init__(name=name) + self.num_transformer_layers = num_transformer_layers + self.transformer_num_heads = transformer_num_heads + self.transformer_key_size = transformer_key_size + self.transformer_mlp_units = transformer_mlp_units + self.model_size = transformer_num_heads * transformer_key_size + self.num_pieces = num_pieces + self.h = h + self.f = f + self.num_rows = num_rows - 3 + self.num_cols = num_cols - 3 + + def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: + # Observation.pieces (B, num_pieces, 3, 3) + # Observation.current_board (B, num_rows, num_cols, 1) + + # Flatten the pieces + flattened_pieces = jnp.reshape(observation.pieces, (-1, self.num_pieces, 9)) + # Flatten_pieces is of shape (B, num_pieces, 9) + jax.debug.print("{x}", x=flattened_pieces.shape) + + # MLP on the pieces + mlp = hk.nets.MLP(output_sizes=[self.h]) + pieces_embedding = jax.vmap(mlp)(flattened_pieces) + # Pieces_embedding is of shape (B, num_pieces, H) + jax.debug.print("{x}", x=pieces_embedding.shape) + + # Down-convolution on the board, F must be a multiple of num_maps + down_conv_net = SimpleNet(num_maps=self.f // 2, f=self.f) + middle_embedding = down_conv_net(observation.current_board) + # Middle_embedding is of shape (B, num_maps, F) + jax.debug.print("{x}", x=middle_embedding.shape) + + # Up convolution on the board + up_conv_net = UpConvNet(num_rows=self.num_rows, num_cols=self.num_cols) + final_embedding = up_conv_net(middle_embedding) + # Final_embedding is of shape (B, num_rows, num_cols) + jax.debug.print("{x}", x=final_embedding.shape) + + # Cross-attention between pieces_embedding and middle_embedding + for block_id in range(self.num_transformer_layers): + cross_attention = TransformerBlock( + num_heads=self.transformer_num_heads, + key_size=self.transformer_key_size, + mlp_units=self.transformer_mlp_units, + model_size=self.model_size, + w_init_scale=2 / self.num_transformer_layers, + name=f"cross_attention_block_{block_id}", + ) + pieces_embedding = cross_attention( + query=pieces_embedding, key=middle_embedding, value=middle_embedding + ) + + # Map pieces embedding from (num_pieces, 128) to (num_pieces, 4) via mlp + mlp = hk.nets.MLP(output_sizes=[4]) + pieces_embedding = jax.vmap(mlp)(pieces_embedding) + jax.debug.print("{x}", x=pieces_embedding.shape) + + # Flatten and return + # return jnp.reshape(outer_product, (-1,)) + return pieces_embedding, final_embedding + + +def make_actor_network_jigsaw( + num_transformer_layers: int, + transformer_num_heads: int, + transformer_key_size: int, + transformer_mlp_units: Sequence[int], + num_pieces: int, + h: int, + f: int, + num_rows: int, + num_cols: int, +) -> FeedForwardNetwork: + def network_fn(observation: Observation) -> chex.Array: + torso = JigsawTorso( + num_transformer_layers=num_transformer_layers, + transformer_num_heads=transformer_num_heads, + transformer_key_size=transformer_key_size, + transformer_mlp_units=transformer_mlp_units, + num_pieces=num_pieces, + h=h, + f=f, + num_rows=num_rows, + num_cols=num_cols, + name="policy_torso", + ) + pieces_embedding, final_embedding = torso(observation) + + # Outer-product + jax.debug.print("PIECES EMBEDDING {x}", x=pieces_embedding.shape) + jax.debug.print("FINAL EMBEDDING {x}", x=final_embedding.shape) + outer_product = jnp.einsum( + "...j,...kl->...jkl", pieces_embedding, final_embedding + ) + jax.debug.print("{x}", x=outer_product.shape) + + logits = jnp.where( + observation.action_mask, outer_product, jnp.finfo(jnp.float32).min + ) + + logits = logits.reshape(*logits.shape[:-4], -1) + return logits + + init, apply = hk.without_apply_rng(hk.transform(network_fn)) + return FeedForwardNetwork(init=init, apply=apply) + + +def make_critic_network_jigsaw( + num_transformer_layers: int, + transformer_num_heads: int, + transformer_key_size: int, + transformer_mlp_units: Sequence[int], + num_pieces: int, + h: int, + f: int, + num_rows: int, + num_cols: int, +) -> FeedForwardNetwork: + def network_fn(observation: Observation) -> chex.Array: + torso = JigsawTorso( + num_transformer_layers=num_transformer_layers, + transformer_num_heads=transformer_num_heads, + transformer_key_size=transformer_key_size, + transformer_mlp_units=transformer_mlp_units, + num_pieces=num_pieces, + h=h, + f=f, + num_rows=num_rows, + num_cols=num_cols, + name="critic_torso", + ) + pieces_embedding, final_embedding = torso(observation) + jax.debug.print("{x}", x=pieces_embedding.shape) + jax.debug.print("{x}", x=final_embedding.shape) + # Concatenate the pieces embedding and the final embedding + torso_output = jnp.concatenate([pieces_embedding, final_embedding], axis=1) + + value = hk.nets.MLP((torso.model_size, 1), name="critic_head")(torso_output) + return jnp.squeeze(value, axis=-1) + + init, apply = hk.without_apply_rng(hk.transform(network_fn)) + return FeedForwardNetwork(init=init, apply=apply) From 316e73383c84a64132a8ff79ab4f062c3f847fbc Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Sun, 28 May 2023 17:32:25 +0200 Subject: [PATCH 15/27] chore: better variable naming --- jumanji/environments/packing/jigsaw/env.py | 27 +++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index e04a62d54..2c2708f24 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -438,16 +438,25 @@ def _expand_all_pieces_to_boards( rows: chex.Array, cols: chex.Array, ) -> chex.Array: - # This function takes multiple pieces and their corresponding rotations and positions, - # and generates a grid for each piece. It then returns an array of these grids. + """Takes multiple pieces and their corresponding rotations and positions, + and generates a grid for each piece. + + Args: + pieces: array of possible pieces. + piece_idxs: array of indices of the pieces to place. + rotations: array of all possible rotations for each piece. + rows: array of row coordinates. + cols: array of column coordinates. + """ batch_expand_piece_to_board = jax.vmap( self._expand_piece_to_board, in_axes=(0, 0, 0) ) - # TODO: Better naming here. - all_pieces = pieces[piece_idxs] - rotated_pieces = jax.vmap(rotate_piece, in_axes=(0, 0))(all_pieces, rotations) + all_possible_pieces = pieces[piece_idxs] + rotated_pieces = jax.vmap(rotate_piece, in_axes=(0, 0))( + all_possible_pieces, rotations + ) grids = batch_expand_piece_to_board(rotated_pieces, rows, cols) batch_get_ones_like_expanded_piece = jax.vmap( @@ -459,7 +468,13 @@ def _expand_all_pieces_to_boards( def _make_full_action_mask( self, current_board: chex.Array, pieces: chex.Array, placed_pieces: chex.Array ) -> chex.Array: - """Create a mask of possible actions based on the current state.""" + """Create a mask of possible actions based on the current state of the board. + + Args: + current_board: current state of the board. + pieces: array of all pieces. + placed_pieces: array of pieces that have already been placed. + """ num_pieces, num_rotations, num_rows, num_cols = ( self.num_pieces, 4, From 4e35b865aabd85a18050c08237c1dee7bd5494a5 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 08:18:34 +0200 Subject: [PATCH 16/27] chore: variable renaming in jigsaw networks. --- .../training/networks/jigsaw/actor_critic.py | 127 +++++++++--------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/jumanji/training/networks/jigsaw/actor_critic.py b/jumanji/training/networks/jigsaw/actor_critic.py index 430ff1dd9..6bb6f1d1a 100644 --- a/jumanji/training/networks/jigsaw/actor_critic.py +++ b/jumanji/training/networks/jigsaw/actor_critic.py @@ -53,8 +53,7 @@ def make_actor_critic_networks_jigsaw( num_pieces=num_pieces, num_rows=num_rows, num_cols=num_cols, - h=10, - f=5, + board_encoding_dim=10, ) value_network = make_critic_network_jigsaw( num_transformer_layers=num_transformer_layers, @@ -64,8 +63,7 @@ def make_actor_critic_networks_jigsaw( num_pieces=num_pieces, num_rows=num_rows, num_cols=num_cols, - h=10, - f=10, + board_encoding_dim=10, ) return ActorCriticNetworks( policy_network=policy_network, @@ -76,10 +74,12 @@ def make_actor_critic_networks_jigsaw( # Net for down convolution of the board class SimpleNet(hk.Module): - def __init__(self, num_maps: int, f: int, name: Optional[str] = None) -> None: + def __init__( + self, num_maps: int, board_encoding_dim: int, name: Optional[str] = None + ) -> None: super().__init__(name=name) self.num_maps = num_maps - self.f = f + self.board_encoding_dim = board_encoding_dim def __call__(self, x: chex.Array) -> chex.Array: # Assuming x is of shape (batch_size, num_rows, num_cols, 1) @@ -89,10 +89,12 @@ def __call__(self, x: chex.Array) -> chex.Array: flat = x.reshape(x.shape[0], -1) # Use a linear layer to project to (num_maps, F) - projection = hk.Linear(self.num_maps * self.F)(flat) + projection = hk.Linear(self.num_maps * self.board_encoding_dim)(flat) # Reshape to desired output shape - projection = projection.reshape(x.shape[0], self.num_maps, self.F) + projection = projection.reshape( + x.shape[0], self.num_maps, self.board_encoding_dim + ) return projection @@ -100,11 +102,14 @@ def __call__(self, x: chex.Array) -> chex.Array: # TODO: Fix this to use convs. class UpConvNet(hk.Module): def __init__( - self, num_rows: int, num_cols: int, name: Optional[str] = None + self, + action_mask_num_rows: int, + action_mask_num_cols: int, + name: Optional[str] = None, ) -> None: super().__init__(name=name) - self.num_rows = num_rows - self.num_cols = num_cols + self.action_mask_num_rows = action_mask_num_rows + self.action_mask_num_cols = action_mask_num_cols def __call__(self, x: chex.Array) -> chex.Array: # Assuming x is of shape (batch_size, num_maps, F) @@ -112,11 +117,15 @@ def __call__(self, x: chex.Array) -> chex.Array: # Flatten the tensor along last two dimensions flat = x.reshape(x.shape[0], -1) - # Use a linear layer to project to (batch_size, num_rows * num_cols) - projection = hk.Linear(self.num_rows * self.num_cols)(flat) + # Use a linear layer to project to (batch_size, action_mask_num_rows * action_mask_num_cols) + projection = hk.Linear(self.action_mask_num_rows * self.action_mask_num_cols)( + flat + ) # Reshape to desired output shape - projection = projection.reshape(x.shape[0], self.num_rows, self.num_cols) + projection = projection.reshape( + x.shape[0], self.action_mask_num_rows, self.action_mask_num_cols + ) return projection @@ -129,8 +138,7 @@ def __init__( transformer_key_size: int, transformer_mlp_units: Sequence[int], num_pieces: int, - h: int, - f: int, + board_encoding_dim: int, num_rows: int, num_cols: int, name: Optional[str] = None, @@ -142,10 +150,9 @@ def __init__( self.transformer_mlp_units = transformer_mlp_units self.model_size = transformer_num_heads * transformer_key_size self.num_pieces = num_pieces - self.h = h - self.f = f - self.num_rows = num_rows - 3 - self.num_cols = num_cols - 3 + self.board_encoding_dim = board_encoding_dim + self.action_mask_num_rows = num_rows - 3 + self.action_mask_num_cols = num_cols - 3 def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: # Observation.pieces (B, num_pieces, 3, 3) @@ -154,27 +161,29 @@ def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: # Flatten the pieces flattened_pieces = jnp.reshape(observation.pieces, (-1, self.num_pieces, 9)) # Flatten_pieces is of shape (B, num_pieces, 9) - jax.debug.print("{x}", x=flattened_pieces.shape) - # MLP on the pieces - mlp = hk.nets.MLP(output_sizes=[self.h]) - pieces_embedding = jax.vmap(mlp)(flattened_pieces) - # Pieces_embedding is of shape (B, num_pieces, H) - jax.debug.print("{x}", x=pieces_embedding.shape) + # Encode the pieces with an MLP + piece_encoder = hk.nets.MLP(output_sizes=[self.model_size]) + pieces_embedding = jax.vmap(piece_encoder)(flattened_pieces) + # Pieces_embedding is of shape (B, num_pieces, model_size) - # Down-convolution on the board, F must be a multiple of num_maps - down_conv_net = SimpleNet(num_maps=self.f // 2, f=self.f) - middle_embedding = down_conv_net(observation.current_board) - # Middle_embedding is of shape (B, num_maps, F) - jax.debug.print("{x}", x=middle_embedding.shape) + # Down-convolution on the board, board_encoding_dim must be a multiple of num_maps + down_conv_net = SimpleNet( + num_maps=self.board_encoding_dim // 2, + board_encoding_dim=self.board_encoding_dim, + ) + board_conv_encoding = down_conv_net(observation.current_board) + # board_conv_encoding is of shape (B, num_maps, board_encoding_dim) # Up convolution on the board - up_conv_net = UpConvNet(num_rows=self.num_rows, num_cols=self.num_cols) - final_embedding = up_conv_net(middle_embedding) + up_conv_net = UpConvNet( + action_mask_num_rows=self.action_mask_num_rows, + action_mask_num_cols=self.action_mask_num_cols, + ) + board_encoding = up_conv_net(board_conv_encoding) # Final_embedding is of shape (B, num_rows, num_cols) - jax.debug.print("{x}", x=final_embedding.shape) - # Cross-attention between pieces_embedding and middle_embedding + # Cross-attention between pieces_embedding and board_conv_encoding for block_id in range(self.num_transformer_layers): cross_attention = TransformerBlock( num_heads=self.transformer_num_heads, @@ -185,17 +194,18 @@ def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: name=f"cross_attention_block_{block_id}", ) pieces_embedding = cross_attention( - query=pieces_embedding, key=middle_embedding, value=middle_embedding + query=pieces_embedding, + key=board_conv_encoding, + value=board_conv_encoding, ) - # Map pieces embedding from (num_pieces, 128) to (num_pieces, 4) via mlp - mlp = hk.nets.MLP(output_sizes=[4]) - pieces_embedding = jax.vmap(mlp)(pieces_embedding) - jax.debug.print("{x}", x=pieces_embedding.shape) + # Map pieces embedding from (num_pieces, 128) to (num_pieces, num_rotations) via mlp + pieces_head = hk.nets.MLP(output_sizes=[4]) + pieces_embedding = jax.vmap(pieces_head)(pieces_embedding) - # Flatten and return - # return jnp.reshape(outer_product, (-1,)) - return pieces_embedding, final_embedding + # pieces_embedding has shape (B, num_pieces, num_rotations) + # board_encoding has shape (B, action_mask_num_rows, action_mask_num_cols) + return pieces_embedding, board_encoding def make_actor_network_jigsaw( @@ -204,8 +214,7 @@ def make_actor_network_jigsaw( transformer_key_size: int, transformer_mlp_units: Sequence[int], num_pieces: int, - h: int, - f: int, + board_encoding_dim: int, num_rows: int, num_cols: int, ) -> FeedForwardNetwork: @@ -216,21 +225,17 @@ def network_fn(observation: Observation) -> chex.Array: transformer_key_size=transformer_key_size, transformer_mlp_units=transformer_mlp_units, num_pieces=num_pieces, - h=h, - f=f, + board_encoding_dim=board_encoding_dim, num_rows=num_rows, num_cols=num_cols, name="policy_torso", ) - pieces_embedding, final_embedding = torso(observation) + pieces_embedding, board_embedding = torso(observation) # Outer-product - jax.debug.print("PIECES EMBEDDING {x}", x=pieces_embedding.shape) - jax.debug.print("FINAL EMBEDDING {x}", x=final_embedding.shape) outer_product = jnp.einsum( - "...j,...kl->...jkl", pieces_embedding, final_embedding + "...ij,...kl->...ijkl", pieces_embedding, board_embedding ) - jax.debug.print("{x}", x=outer_product.shape) logits = jnp.where( observation.action_mask, outer_product, jnp.finfo(jnp.float32).min @@ -249,8 +254,7 @@ def make_critic_network_jigsaw( transformer_key_size: int, transformer_mlp_units: Sequence[int], num_pieces: int, - h: int, - f: int, + board_encoding_dim: int, num_rows: int, num_cols: int, ) -> FeedForwardNetwork: @@ -261,17 +265,20 @@ def network_fn(observation: Observation) -> chex.Array: transformer_key_size=transformer_key_size, transformer_mlp_units=transformer_mlp_units, num_pieces=num_pieces, - h=h, - f=f, + board_encoding_dim=board_encoding_dim, num_rows=num_rows, num_cols=num_cols, name="critic_torso", ) pieces_embedding, final_embedding = torso(observation) - jax.debug.print("{x}", x=pieces_embedding.shape) - jax.debug.print("{x}", x=final_embedding.shape) - # Concatenate the pieces embedding and the final embedding - torso_output = jnp.concatenate([pieces_embedding, final_embedding], axis=1) + # Flatten and concatenate the pieces embedding and the final embedding + pieces_embedding_flat = pieces_embedding.reshape(pieces_embedding.shape[0], -1) + final_embedding_flat = final_embedding.reshape(final_embedding.shape[0], -1) + + # Concatenate along the second dimension (axis=1) + torso_output = jnp.concatenate( + [pieces_embedding_flat, final_embedding_flat], axis=-1 + ) value = hk.nets.MLP((torso.model_size, 1), name="critic_head")(torso_output) return jnp.squeeze(value, axis=-1) From b681f83d592ec6067681f2dfc706f1fe45fe5730 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 08:20:31 +0200 Subject: [PATCH 17/27] chore: variable renaming in jigsaw networks. --- jumanji/training/networks/jigsaw/actor_critic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jumanji/training/networks/jigsaw/actor_critic.py b/jumanji/training/networks/jigsaw/actor_critic.py index 6bb6f1d1a..ac565a495 100644 --- a/jumanji/training/networks/jigsaw/actor_critic.py +++ b/jumanji/training/networks/jigsaw/actor_critic.py @@ -85,7 +85,7 @@ def __call__(self, x: chex.Array) -> chex.Array: # Assuming x is of shape (batch_size, num_rows, num_cols, 1) x = hk.Conv2D(self.num_maps, kernel_shape=3, stride=1, padding="SAME")(x) - # Flatten the tensor + # Flatten flat = x.reshape(x.shape[0], -1) # Use a linear layer to project to (num_maps, F) @@ -114,7 +114,7 @@ def __init__( def __call__(self, x: chex.Array) -> chex.Array: # Assuming x is of shape (batch_size, num_maps, F) - # Flatten the tensor along last two dimensions + # Flatten along last two dimensions flat = x.reshape(x.shape[0], -1) # Use a linear layer to project to (batch_size, action_mask_num_rows * action_mask_num_cols) From ee87ec3e93379b3d0c59e46261dc50c8269a8a0a Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 16:22:37 +0200 Subject: [PATCH 18/27] feat: jigsaw networks implemented. --- jumanji/__init__.py | 14 ++- .../training/networks/jigsaw/actor_critic.py | 116 +++++------------- 2 files changed, 41 insertions(+), 89 deletions(-) diff --git a/jumanji/__init__.py b/jumanji/__init__.py index 63658cb9e..cbf886fd6 100644 --- a/jumanji/__init__.py +++ b/jumanji/__init__.py @@ -14,6 +14,7 @@ from jumanji.env import Environment from jumanji.environments.logic.rubiks_cube import generator as rubik_generator +from jumanji.environments.packing.jigsaw import generator as jigsaw_generator from jumanji.registration import make, register, registered_environments from jumanji.version import __version__ @@ -51,10 +52,21 @@ # largest ones are given in the observation. register(id="BinPack-v1", entry_point="jumanji.environments:BinPack") -# Jigsaw puzzle with 25 pieces and a random puzzle generator. +# Jigsaw puzzle with 9 pieces, a 7x7 grid and a random puzzle generator. # The puzzle must be completed in `num_pieces` steps. register(id="Jigsaw-v0", entry_point="jumanji.environments:Jigsaw") +# Simplified jigsaw puzzle with a 5x5 grid, 4 pieces and a deterministic +# puzzle generator. +deterministic_jigsaw_generator_with_rotation = ( + jigsaw_generator.ToyJigsawGeneratorWithRotation() +) +register( + id="Jigsaw-deterministic-rotation-v0", + entry_point="jumanji.environments:Jigsaw", + kwargs={"generator": deterministic_jigsaw_generator_with_rotation}, +) + # Job-shop scheduling problem with 20 jobs, 10 machines, at most # 8 operations per job, and a max operation duration of 6 timesteps. register(id="JobShop-v0", entry_point="jumanji.environments:JobShop") diff --git a/jumanji/training/networks/jigsaw/actor_critic.py b/jumanji/training/networks/jigsaw/actor_critic.py index ac565a495..9a9b34f0d 100644 --- a/jumanji/training/networks/jigsaw/actor_critic.py +++ b/jumanji/training/networks/jigsaw/actor_critic.py @@ -43,7 +43,6 @@ def make_actor_critic_networks_jigsaw( parametric_action_distribution = FactorisedActionSpaceParametricDistribution( action_spec_num_values=num_values ) - num_rows, num_cols = jigsaw.num_rows, jigsaw.num_cols num_pieces = jigsaw.num_pieces policy_network = make_actor_network_jigsaw( num_transformer_layers=num_transformer_layers, @@ -51,9 +50,6 @@ def make_actor_critic_networks_jigsaw( transformer_key_size=transformer_key_size, transformer_mlp_units=transformer_mlp_units, num_pieces=num_pieces, - num_rows=num_rows, - num_cols=num_cols, - board_encoding_dim=10, ) value_network = make_critic_network_jigsaw( num_transformer_layers=num_transformer_layers, @@ -61,9 +57,6 @@ def make_actor_critic_networks_jigsaw( transformer_key_size=transformer_key_size, transformer_mlp_units=transformer_mlp_units, num_pieces=num_pieces, - num_rows=num_rows, - num_cols=num_cols, - board_encoding_dim=10, ) return ActorCriticNetworks( policy_network=policy_network, @@ -72,62 +65,32 @@ def make_actor_critic_networks_jigsaw( ) -# Net for down convolution of the board -class SimpleNet(hk.Module): - def __init__( - self, num_maps: int, board_encoding_dim: int, name: Optional[str] = None - ) -> None: +class UNet(hk.Module): + def __init__(self, name: Optional[str] = None) -> None: super().__init__(name=name) - self.num_maps = num_maps - self.board_encoding_dim = board_encoding_dim def __call__(self, x: chex.Array) -> chex.Array: - # Assuming x is of shape (batch_size, num_rows, num_cols, 1) - x = hk.Conv2D(self.num_maps, kernel_shape=3, stride=1, padding="SAME")(x) - - # Flatten - flat = x.reshape(x.shape[0], -1) - - # Use a linear layer to project to (num_maps, F) - projection = hk.Linear(self.num_maps * self.board_encoding_dim)(flat) - - # Reshape to desired output shape - projection = projection.reshape( - x.shape[0], self.num_maps, self.board_encoding_dim - ) - - return projection - - -# TODO: Fix this to use convs. -class UpConvNet(hk.Module): - def __init__( - self, - action_mask_num_rows: int, - action_mask_num_cols: int, - name: Optional[str] = None, - ) -> None: - super().__init__(name=name) - self.action_mask_num_rows = action_mask_num_rows - self.action_mask_num_cols = action_mask_num_cols + # Assuming x is of shape (batch_size, num_rows, num_cols) + # Add a channel dimension + x = x[..., jnp.newaxis] - def __call__(self, x: chex.Array) -> chex.Array: - # Assuming x is of shape (batch_size, num_maps, F) + down_1 = hk.Conv2D(2, kernel_shape=2, stride=1, padding="SAME")(x) + down_2 = hk.Conv2D(4, kernel_shape=2, stride=1, padding="SAME")(down_1) - # Flatten along last two dimensions - flat = x.reshape(x.shape[0], -1) + # upconv + up_1 = hk.Conv2DTranspose(2, kernel_shape=2, stride=1, padding="SAME")(down_2) + up_2 = hk.Conv2DTranspose(1, kernel_shape=2, stride=1, padding="SAME")(up_1) - # Use a linear layer to project to (batch_size, action_mask_num_rows * action_mask_num_cols) - projection = hk.Linear(self.action_mask_num_rows * self.action_mask_num_cols)( - flat - ) + # Crop the upconvolved output + # to be the same size as the action mask. + up_2 = up_2[:, 1:-2, 1:-2] + # Remove the channel dimension + output = jnp.squeeze(up_2, axis=-1) - # Reshape to desired output shape - projection = projection.reshape( - x.shape[0], self.action_mask_num_rows, self.action_mask_num_cols - ) + # Reshape down_2 to be (B, num_feature_maps, ...) + down_2 = jnp.transpose(down_2, (0, 3, 1, 2)) - return projection + return down_2, output class JigsawTorso(hk.Module): @@ -138,9 +101,6 @@ def __init__( transformer_key_size: int, transformer_mlp_units: Sequence[int], num_pieces: int, - board_encoding_dim: int, - num_rows: int, - num_cols: int, name: Optional[str] = None, ) -> None: super().__init__(name=name) @@ -150,9 +110,6 @@ def __init__( self.transformer_mlp_units = transformer_mlp_units self.model_size = transformer_num_heads * transformer_key_size self.num_pieces = num_pieces - self.board_encoding_dim = board_encoding_dim - self.action_mask_num_rows = num_rows - 3 - self.action_mask_num_cols = num_cols - 3 def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: # Observation.pieces (B, num_pieces, 3, 3) @@ -167,21 +124,15 @@ def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: pieces_embedding = jax.vmap(piece_encoder)(flattened_pieces) # Pieces_embedding is of shape (B, num_pieces, model_size) - # Down-convolution on the board, board_encoding_dim must be a multiple of num_maps - down_conv_net = SimpleNet( - num_maps=self.board_encoding_dim // 2, - board_encoding_dim=self.board_encoding_dim, - ) - board_conv_encoding = down_conv_net(observation.current_board) - # board_conv_encoding is of shape (B, num_maps, board_encoding_dim) + unet = UNet() + board_conv_encoding, board_encoding = unet(observation.current_board) + # board_encoding has shape (B, num_rows-3, num_cols-3) - # Up convolution on the board - up_conv_net = UpConvNet( - action_mask_num_rows=self.action_mask_num_rows, - action_mask_num_cols=self.action_mask_num_cols, + # Flatten the board_conv_encoding so it is of shape (B, num_maps, ...) + board_conv_encoding = jnp.reshape( + board_conv_encoding, + (board_conv_encoding.shape[0], board_conv_encoding.shape[1], -1), ) - board_encoding = up_conv_net(board_conv_encoding) - # Final_embedding is of shape (B, num_rows, num_cols) # Cross-attention between pieces_embedding and board_conv_encoding for block_id in range(self.num_transformer_layers): @@ -199,12 +150,13 @@ def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: value=board_conv_encoding, ) - # Map pieces embedding from (num_pieces, 128) to (num_pieces, num_rotations) via mlp + # Map pieces embedding from (num_pieces, 128) to (num_pieces, num_rotations) pieces_head = hk.nets.MLP(output_sizes=[4]) pieces_embedding = jax.vmap(pieces_head)(pieces_embedding) # pieces_embedding has shape (B, num_pieces, num_rotations) - # board_encoding has shape (B, action_mask_num_rows, action_mask_num_cols) + # board_encoding has shape (B, num_rows-3, num_cols-3) to match + # the shape of the action mask. return pieces_embedding, board_encoding @@ -214,9 +166,6 @@ def make_actor_network_jigsaw( transformer_key_size: int, transformer_mlp_units: Sequence[int], num_pieces: int, - board_encoding_dim: int, - num_rows: int, - num_cols: int, ) -> FeedForwardNetwork: def network_fn(observation: Observation) -> chex.Array: torso = JigsawTorso( @@ -225,9 +174,6 @@ def network_fn(observation: Observation) -> chex.Array: transformer_key_size=transformer_key_size, transformer_mlp_units=transformer_mlp_units, num_pieces=num_pieces, - board_encoding_dim=board_encoding_dim, - num_rows=num_rows, - num_cols=num_cols, name="policy_torso", ) pieces_embedding, board_embedding = torso(observation) @@ -254,9 +200,6 @@ def make_critic_network_jigsaw( transformer_key_size: int, transformer_mlp_units: Sequence[int], num_pieces: int, - board_encoding_dim: int, - num_rows: int, - num_cols: int, ) -> FeedForwardNetwork: def network_fn(observation: Observation) -> chex.Array: torso = JigsawTorso( @@ -265,9 +208,6 @@ def network_fn(observation: Observation) -> chex.Array: transformer_key_size=transformer_key_size, transformer_mlp_units=transformer_mlp_units, num_pieces=num_pieces, - board_encoding_dim=board_encoding_dim, - num_rows=num_rows, - num_cols=num_cols, name="critic_torso", ) pieces_embedding, final_embedding = torso(observation) From 8416969494a20bac1e3c60e4ebf9d05995bbcdd6 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 17:06:57 +0200 Subject: [PATCH 19/27] fix: fix action spec off by one. --- jumanji/environments/packing/jigsaw/env.py | 12 ++++++------ jumanji/environments/packing/jigsaw/generator.py | 6 +++--- jumanji/training/networks/jigsaw/actor_critic.py | 7 +++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 2c2708f24..310d63ade 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -291,8 +291,8 @@ def observation_spec(self) -> specs.Spec[Observation]: shape=( self.num_pieces, 4, - self.num_rows - 3, - self.num_cols - 3, + self.num_rows - 2, + self.num_cols - 2, ), minimum=False, maximum=True, @@ -320,8 +320,8 @@ def action_spec(self) -> specs.MultiDiscreteArray: - max_col_position: int between 0 and max_col_position - 1 (included). """ - max_row_position = self.num_rows - 3 - max_col_position = self.num_cols - 3 + max_row_position = self.num_rows - 2 + max_col_position = self.num_cols - 2 return specs.MultiDiscreteArray( num_values=jnp.array( @@ -478,8 +478,8 @@ def _make_full_action_mask( num_pieces, num_rotations, num_rows, num_cols = ( self.num_pieces, 4, - self.num_rows - 3, - self.num_cols - 3, + self.num_rows - 2, + self.num_cols - 2, ) pieces_grid, rotations_grid, rows_grid, cols_grid = jnp.meshgrid( diff --git a/jumanji/environments/packing/jigsaw/generator.py b/jumanji/environments/packing/jigsaw/generator.py index bd13c7d7c..bc91959e1 100644 --- a/jumanji/environments/packing/jigsaw/generator.py +++ b/jumanji/environments/packing/jigsaw/generator.py @@ -297,7 +297,7 @@ def __call__(self, key: chex.PRNGKey) -> State: col_nibs_idxs=col_nibs_idxs, row_nibs_idxs=row_nibs_idxs, action_mask=jnp.ones( - (num_pieces, 4, grid_row_dim - 3, grid_col_dim - 3), dtype=bool + (num_pieces, 4, grid_row_dim - 2, grid_col_dim - 2), dtype=bool ), current_board=jnp.zeros_like(solved_board), step_count=0, @@ -342,7 +342,7 @@ def __call__(self, key: chex.PRNGKey) -> State: solved_board=mock_solved_grid, pieces=mock_pieces, current_board=jnp.zeros_like(mock_solved_grid), - action_mask=jnp.ones((4, 4, 2, 2), dtype=bool), + action_mask=jnp.ones((4, 4, 3, 3), dtype=bool), col_nibs_idxs=jnp.array([2], dtype=jnp.int32), row_nibs_idxs=jnp.array([2], dtype=jnp.int32), num_pieces=jnp.int32(4), @@ -394,7 +394,7 @@ def __call__(self, key: chex.PRNGKey) -> State: row_nibs_idxs=jnp.array([2], dtype=jnp.int32), num_pieces=jnp.int32(4), key=jax.random.PRNGKey(0), - action_mask=jnp.ones((4, 4, 2, 2), dtype=bool), + action_mask=jnp.ones((4, 4, 3, 3), dtype=bool), current_board=jnp.zeros_like(mock_solved_grid), step_count=0, placed_pieces=jnp.zeros(4, dtype=bool), diff --git a/jumanji/training/networks/jigsaw/actor_critic.py b/jumanji/training/networks/jigsaw/actor_critic.py index 9a9b34f0d..78ca7b450 100644 --- a/jumanji/training/networks/jigsaw/actor_critic.py +++ b/jumanji/training/networks/jigsaw/actor_critic.py @@ -83,7 +83,7 @@ def __call__(self, x: chex.Array) -> chex.Array: # Crop the upconvolved output # to be the same size as the action mask. - up_2 = up_2[:, 1:-2, 1:-2] + up_2 = up_2[:, 1:-1, 1:-1] # Remove the channel dimension output = jnp.squeeze(up_2, axis=-1) @@ -126,7 +126,7 @@ def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: unet = UNet() board_conv_encoding, board_encoding = unet(observation.current_board) - # board_encoding has shape (B, num_rows-3, num_cols-3) + # board_encoding has shape (B, num_rows-2, num_cols-2) # Flatten the board_conv_encoding so it is of shape (B, num_maps, ...) board_conv_encoding = jnp.reshape( @@ -155,7 +155,7 @@ def __call__(self, observation: Observation) -> Tuple[chex.Array, chex.Array]: pieces_embedding = jax.vmap(pieces_head)(pieces_embedding) # pieces_embedding has shape (B, num_pieces, num_rotations) - # board_encoding has shape (B, num_rows-3, num_cols-3) to match + # board_encoding has shape (B, num_rows-2, num_cols-2) to match # the shape of the action mask. return pieces_embedding, board_encoding @@ -177,7 +177,6 @@ def network_fn(observation: Observation) -> chex.Array: name="policy_torso", ) pieces_embedding, board_embedding = torso(observation) - # Outer-product outer_product = jnp.einsum( "...ij,...kl->...ijkl", pieces_embedding, board_embedding From 58688350192949c74c82f9b83f5e6f6b23d06c29 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 17:08:31 +0200 Subject: [PATCH 20/27] feat: added jigsaw training config. --- jumanji/training/configs/env/jigsaw.yaml | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 jumanji/training/configs/env/jigsaw.yaml diff --git a/jumanji/training/configs/env/jigsaw.yaml b/jumanji/training/configs/env/jigsaw.yaml new file mode 100644 index 000000000..45aaf44bc --- /dev/null +++ b/jumanji/training/configs/env/jigsaw.yaml @@ -0,0 +1,27 @@ +name: jigsaw +registered_version: Jigsaw-v0 + +network: + num_transformer_layers: 2 + transformer_num_heads: 8 + transformer_key_size: 16 + transformer_mlp_units: [512] + +training: + num_epochs: 1000 + num_learner_steps_per_epoch: 100 + n_steps: 20 + total_batch_size: 64 + +evaluation: + eval_total_batch_size: 5000 + greedy_eval_total_batch_size: 5000 + +a2c: + normalize_advantage: False + discount_factor: 0.99 + bootstrapping_factor: 0.95 + l_pg: 1.0 + l_td: 1.0 + l_en: 0.01 + learning_rate: 2e-4 From 4a8caadfc0804a0b3ed1217b904d77b539debffe Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 17:29:48 +0200 Subject: [PATCH 21/27] chore: minor fixes. --- jumanji/environments/packing/jigsaw/env.py | 2 +- .../packing/jigsaw/generator_test.py | 2 +- .../packing/jigsaw/jigsaw_example.py | 153 ------------------ 3 files changed, 2 insertions(+), 155 deletions(-) delete mode 100644 jumanji/environments/packing/jigsaw/jigsaw_example.py diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 310d63ade..453956c71 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -266,7 +266,7 @@ def observation_spec(self) -> specs.Spec[Observation]: Returns: Spec for each filed in the observation: - - current_board: BoundedArray (int) of shape (board_dim[0], board_dim[1]). + - current_board: BoundedArray (int) of shape (num_rows, num_cols). - pieces: BoundedArray (int) of shape (num_pieces, 3, 3). - action_mask: BoundedArray (bool) of shape (num_pieces,). """ diff --git a/jumanji/environments/packing/jigsaw/generator_test.py b/jumanji/environments/packing/jigsaw/generator_test.py index 8035cd33f..0e2869377 100644 --- a/jumanji/environments/packing/jigsaw/generator_test.py +++ b/jumanji/environments/packing/jigsaw/generator_test.py @@ -78,7 +78,7 @@ def test_random_jigsaw_generator__call( assert all(state.pieces[i].shape == (3, 3) for i in range(4)) assert state.col_nibs_idxs == jnp.array([2]) assert state.row_nibs_idxs == jnp.array([2]) - assert state.action_mask.shape == (4, 4, 2, 2) + assert state.action_mask.shape == (4, 4, 3, 3) assert state.step_count == 0 diff --git a/jumanji/environments/packing/jigsaw/jigsaw_example.py b/jumanji/environments/packing/jigsaw/jigsaw_example.py deleted file mode 100644 index 1607a503d..000000000 --- a/jumanji/environments/packing/jigsaw/jigsaw_example.py +++ /dev/null @@ -1,153 +0,0 @@ -# Copyright 2022 InstaDeep Ltd. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time - -import chex -import jax -from jax import numpy as jnp - -from jumanji.environments.packing.jigsaw.env import Jigsaw -from jumanji.environments.packing.jigsaw.generator import ( - RandomJigsawGenerator, - ToyJigsawGeneratorNoRotation, -) -from jumanji.environments.packing.jigsaw.reward import SparseReward - -SAVE_GIF = True - -# Very basic example of a random agent acting in the Jigsaw environment. -# Each episode will generate a completely new instance of the jigsaw puzzle. -env = Jigsaw( - generator=RandomJigsawGenerator( - num_col_pieces=5, - num_row_pieces=5, - ), -) -action_spec = env.action_spec() -step_key = jax.random.PRNGKey(1) -jit_step = jax.jit(chex.assert_max_traces(env.step, n=1)) -jit_reset = jax.jit(chex.assert_max_traces(env.reset, n=1)) -episode_returns: list = [] -states: list = [] -for ep in range(30): - step_key, reset_key = jax.random.split(step_key) - state, timestep = jit_reset(key=reset_key) - states.append(state) - episode_return = 0 - ep_steps = 0 - start_time = time.time() - while not timestep.last(): - step_key, piece_key, rot_key, row_key, col_key = jax.random.split(step_key, 5) - - # Only select a random piece from pieces that are true in the timestep.action_mask - # (i.e. pieces that are not yet placed) - # piece_action_mask = timestep.observation.action_mask[:, 0, 0, 0] - # probs = piece_action_mask / jnp.sum( - # piece_action_mask - # ) - # piece_id = jax.random.choice( - # a=action_spec.maximum[0] + 1, - # shape=(), - # key=piece_key, - # p=probs, - # ) - piece_id = jax.random.randint( - piece_key, shape=(), minval=0, maxval=action_spec.maximum[0] + 1 - ) - rotation = jax.random.randint( - rot_key, shape=(), minval=0, maxval=action_spec.maximum[1] + 1 - ) - row = jax.random.randint( - row_key, shape=(), minval=0, maxval=action_spec.maximum[2] - ) - col = jax.random.randint( - col_key, shape=(), minval=0, maxval=action_spec.maximum[3] - ) - - action = jnp.array([piece_id, rotation, row, col]) - state, timestep = jit_step(state, action) - states.append(state) - episode_return += timestep.reward - ep_steps += 1 - - sps = ep_steps / (time.time() - start_time) - episode_returns.append(episode_return) - if ep % 10 == 0: - print( - f"EPISODE RETURN: {episode_return}, STEPS PER SECOND: {int(sps)}," - f" ENVIRONMENT STEPS: {ep_steps}" - ) - -print(f"Average return: {jnp.mean(jnp.array(episode_returns))}, SPS: {int(sps)}\n") - -if SAVE_GIF: - env.animate(states=states, interval=200, save_path="big_env_2.gif") - -# An example of solving a puzzle by stepping a -# dummy environment with a dense reward function. -print("STARTING DENSE REWARD EXAMPLE") -env = Jigsaw(generator=ToyJigsawGeneratorNoRotation()) -state, timestep = env.reset(step_key) -print("CURRENT BOARD:") -print(state.current_board, "\n") -state, timestep = env.step(state, jnp.array([0, 0, 0, 0])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward, "\n") -state, timestep = env.step(state, jnp.array([1, 0, 0, 2])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward, "\n") -state, timestep = env.step(state, jnp.array([2, 0, 2, 0])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward, "\n") -state, timestep = env.step(state, jnp.array([3, 0, 2, 2])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward) -print() - -# An example of solving a puzzle by stepping a -# dummy environment with a sparse reward function. -print("STARTING SPARSE REWARD EXAMPLE") -env = Jigsaw(generator=ToyJigsawGeneratorNoRotation(), reward_fn=SparseReward()) -state, timestep = env.reset(step_key) -print("CURRENT BOARD:") -print(state.current_board, "\n") -state, timestep = env.step(state, jnp.array([0, 0, 0, 0])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward, "\n") -state, timestep = env.step(state, jnp.array([1, 0, 0, 2])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward, "\n") -state, timestep = env.step(state, jnp.array([2, 0, 2, 0])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward, "\n") -state, timestep = env.step(state, jnp.array([3, 0, 2, 2])) -print("CURRENT BOARD:") -print(state.current_board, "\n") -print("STEP REWARD:") -print(timestep.reward) From d0aa02ca606fb0f4094bd7bf4ed04bec7d68954e Mon Sep 17 00:00:00 2001 From: RuanJohn <33461981+RuanJohn@users.noreply.github.com> Date: Mon, 29 May 2023 17:50:14 +0200 Subject: [PATCH 22/27] chore: fix action space in docs. Co-authored-by: Sasha --- docs/environments/jigsaw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environments/jigsaw.md b/docs/environments/jigsaw.md index ba8c99b4c..ba47aa078 100644 --- a/docs/environments/jigsaw.md +++ b/docs/environments/jigsaw.md @@ -13,7 +13,7 @@ The observation given to the agent gives a view of the current state of the puzz all pieces that can be placed. - `current_board`: jax array (float32) of shape `(num_rows, num_cols)` with values in the range - `[1, num_pieces]` (corresponding to the number of each piece). This board will have zeros + `[0, num_pieces]` (corresponding to the number of each piece). This board will have zeros where no pieces have been placed and numbers corresponding to each piece where that particular pieces has been paced. From e876908adb44c501dd0458f86267dc205ed192a8 Mon Sep 17 00:00:00 2001 From: RuanJohn <33461981+RuanJohn@users.noreply.github.com> Date: Mon, 29 May 2023 17:50:33 +0200 Subject: [PATCH 23/27] chore: docs action mask fix. Co-authored-by: Sasha --- docs/environments/jigsaw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environments/jigsaw.md b/docs/environments/jigsaw.md index ba47aa078..a4ee0f0e5 100644 --- a/docs/environments/jigsaw.md +++ b/docs/environments/jigsaw.md @@ -20,7 +20,7 @@ all pieces that can be placed. - `pieces`: jax array (float32) of shape `(num_pieces, 3, 3)` of all possible pieces in the current puzzle. These pieces are shuffled and rotated. Pieces will always have shape `(3, 3)`. -- `action_mask`: jax array (bool) of shape `(num_pieces, 4, num_rows-3, num_cols-3)`, representing +- `action_mask`: jax array (bool) of shape `(num_pieces, 4, num_rows-2, num_cols-2)`, representing which actions are possible given the current state of the board. The first index indicates the number of pieces in a given puzzle. The second index indicates the number of times a piece may be rotated. The third and fourth indices indicate the row and column coordinate of where a piece may be placed respectively. From 57b6a816c2fefdb39740dc660f925ccfd4c03122 Mon Sep 17 00:00:00 2001 From: RuanJohn <33461981+RuanJohn@users.noreply.github.com> Date: Mon, 29 May 2023 17:50:56 +0200 Subject: [PATCH 24/27] chore: action mask fix in docs. Co-authored-by: Sasha --- docs/environments/jigsaw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environments/jigsaw.md b/docs/environments/jigsaw.md index a4ee0f0e5..95dba2b61 100644 --- a/docs/environments/jigsaw.md +++ b/docs/environments/jigsaw.md @@ -24,7 +24,7 @@ all pieces that can be placed. which actions are possible given the current state of the board. The first index indicates the number of pieces in a given puzzle. The second index indicates the number of times a piece may be rotated. The third and fourth indices indicate the row and column coordinate of where a piece may be placed respectively. - These values will always be `num_rows-3` and `num_cols-3` respectively to make it impossible for an agent to + These values will always be `num_rows-2` and `num_cols-2` respectively to make it impossible for an agent to place a piece outside the current board. From 0267299fbbc993fbb8069fcb4d7d5349159476f0 Mon Sep 17 00:00:00 2001 From: RuanJohn <33461981+RuanJohn@users.noreply.github.com> Date: Mon, 29 May 2023 17:51:09 +0200 Subject: [PATCH 25/27] chore: action mask fix in docs. Co-authored-by: Sasha --- docs/environments/jigsaw.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/environments/jigsaw.md b/docs/environments/jigsaw.md index 95dba2b61..7ad907496 100644 --- a/docs/environments/jigsaw.md +++ b/docs/environments/jigsaw.md @@ -30,8 +30,8 @@ all pieces that can be placed. ## Action The action space is a `MultiDiscreteArray`, specifically a tuple of an index between 0 and `num_pieces`, -an index between 0 and 4 (since there are 4 possible rotations), an index between 0 and `num_rows-3` -(the possible row coordinates for placing a piece) and an index between 0 and `num_cols-3` +an index between 0 and 4 (since there are 4 possible rotations), an index between 0 and `num_rows-2` +(the possible row coordinates for placing a piece) and an index between 0 and `num_cols-2` (the possible column coordinates for placing a piece). An action thus consists of four pieces of information: From 6adfaf37e4c08b8020f7c7bcc6d30f53b13aba82 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Mon, 29 May 2023 17:55:16 +0200 Subject: [PATCH 26/27] chore: indent docstrings. --- jumanji/environments/packing/jigsaw/env.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index 453956c71..a289713c8 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -37,8 +37,8 @@ class Jigsaw(Environment[State]): """A Jigsaw solving environment with a configurable number of row and column pieces. - Here the goal of an agent is to solve a jigsaw puzzle by placing pieces - in their correct positions. + Here the goal of an agent is to solve a jigsaw puzzle by placing pieces + in their correct positions. - observation: Observation - current_board: jax array (float) of shape (num_rows, num_cols) with the @@ -332,7 +332,7 @@ def action_spec(self) -> specs.MultiDiscreteArray: def _check_done(self, state: State) -> bool: """Checks if the environment is done by checking whether the number of - steps is equal to the number of pieces in the puzzle. + steps is equal to the number of pieces in the puzzle. Args: state: current state of the environment. @@ -353,9 +353,9 @@ def _check_action_is_legal( grid_mask_piece: chex.Array, ) -> bool: """Checks if the action is legal by considering the action mask and the - board mask. An action is legal if the action mask is True for that action - and the board mask indicates that there is no overlap with pieces - already placed. + board mask. An action is legal if the action mask is True for that action + and the board mask indicates that there is no overlap with pieces + already placed. Args: action: action taken. From b344addd18adbc03bae4f11fac0733da5685c0d4 Mon Sep 17 00:00:00 2001 From: RuanJohn Date: Tue, 30 May 2023 10:18:55 +0200 Subject: [PATCH 27/27] fix: action mask indexing bugfix. --- jumanji/environments/packing/jigsaw/env.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/jumanji/environments/packing/jigsaw/env.py b/jumanji/environments/packing/jigsaw/env.py index a289713c8..5c80d2b86 100644 --- a/jumanji/environments/packing/jigsaw/env.py +++ b/jumanji/environments/packing/jigsaw/env.py @@ -487,6 +487,7 @@ def _make_full_action_mask( jnp.arange(num_rotations), jnp.arange(num_rows), jnp.arange(num_cols), + indexing="ij", ) grid_mask_pieces = self._expand_all_pieces_to_boards( @@ -500,10 +501,13 @@ def _make_full_action_mask( batch_check_action_is_legal = jax.vmap( self._check_action_is_legal, in_axes=(0, None, None, 0) ) + + all_actions = jnp.stack( + (pieces_grid, rotations_grid, rows_grid, cols_grid), axis=-1 + ).reshape(-1, 4) + legal_actions = batch_check_action_is_legal( - jnp.stack( - (pieces_grid, rotations_grid, rows_grid, cols_grid), axis=-1 - ).reshape(-1, 4), + all_actions, current_board, placed_pieces, grid_mask_pieces,