
Cross-entropy on FrozenLake
The next environment we'll try to solve using the cross-entropy method is FrozenLake. Its world is from the so-called "grid world" category, when your agent lives in a grid of size 4 × 4 and can move in four directions: up, down, left, and right. The agent always starts at a top-left position, and its goal is to reach the bottom-right cell of the grid. There are holes in the fixed cells of the grid and if you get into those holes, the episode ends and your reward is zero. If the agent reaches the destination cell, then it obtains the reward 1.0 and the episode ends.
To make life more complicated, the world is slippery (it's a frozen lake after all), so the agent's actions do not always turn out as expected: there is a 33% chance that it will slip to the right or to the left. You want the agent to move left, for example, but there is a 33% probability that it will indeed move left, a 33% chance that it will end up in the cell above, and a 33% chance that it will end up in the cell below. As we'll see at the end of the section, this makes progress difficult.

Figure 5: The FrozenLake environment
Let's look how this environment is represented in Gym:
>>> e = gym.make("FrozenLake-v0") [2017-10-05 12:39:35,827] Making new env: FrozenLake-v0 >>> e.observation_space Discrete(16) >>> e.action_space Discrete(4) >>> e.reset() 0 >>> e.render() SFFF FHFH FFFH HFFG
Our observation space is discrete, which means that it's just a number from zero to 15 inclusive. Obviously, this number is our current position in the grid. The action space is also discrete, but can be from zero to three. Our network from the CartPole example expects a vector of numbers. To get this, we can apply the traditional "one-hot encoding" of discrete inputs, which means that input to our network will have 16 float numbers and zero everywhere, except the index that we'll encode. To minimize changes in our code, we can use the ObservationWrapper
class from Gym and implement our DiscreteOneHotWrapper
class:
class DiscreteOneHotWrapper(gym.ObservationWrapper): def __init__(self, env): super(DiscreteOneHotWrapper, self).__init__(env) assert isinstance(env.observation_space, gym.spaces.Discrete) self.observation_space = gym.spaces.Box(0.0, 1.0, (env.observation_space.n, ), dtype=np.float32) def observation(self, observation): res = np.copy(self.observation_space.low) res[observation] = 1.0 return res
With that wrapper applied to the environment, both the observation space and action space are 100% compatible with our CartPole solution (source code Chapter04/02_frozenlake_naive.py
). However, by launching it, we can see that this doesn't improve the score over time.

Figure 6: Lack of convergence of the original cross-entropy code in the FrozenLake environment
To understand what's going on, we need to look deeper at the reward structure of both environments. In CartPole, every step of the environment gives us the reward 1.0, until the moment that the pole falls. So, the longer our agent balanced the pole, the more reward it obtained. Due to randomness in our agent's behavior, different episodes were of different lengths, which gave us a pretty normal distribution of the episodes' rewards. After choosing a reward boundary, we rejected less successful episodes and learned how to repeat better ones (by training on successful episodes' data).
This is shown in the following diagram:

Figure 7: Distribution of reward in the CartPole environment
In the FrozenLake environment, episodes and their reward look different. We get the reward of 1.0 only when we reach the goal, and this reward says nothing about how good each episode was. Was it quick and efficient or did we make four rounds on the lake before we randomly stepped into the final cell? We don't know, it's just 1.0 reward and that's it. The distribution of rewards for our episodes are also problematic. There are only two kinds of episodes possible, with zero reward (failed) and one reward (successful), and failed episodes will obviously dominate in the beginning of the training. So, our percentile selection of "elite" episodes is totally wrong and gives us bad examples to train on. This is the reason for our training failure.

Figure 8: Reward distribution of the FrozenLake environment
This example shows us the limitations of the cross-entropy method:
- For training, our episodes have to be finite and, preferably, short
- The total reward for the episodes should have enough variability to separate good episodes from bad ones
- There is no intermediate indication about whether the agent has succeeded or failed
Later in the book, we'll become familiar with other methods, which address these limitations. For now, if you're curious about how FrozenLake can be solved using cross-entropy, here is a list of tweaks of the code that you need to make (the full example is in Chapter04/03_frozenlake_tweaked.py
):
- Larger batches of played episodes: In CartPole, it was enough to have 16 episodes on every iteration, but FrozenLake requires at least 100 just to get some successful episodes.
- Discount factor applied to reward: To make the total reward for the episode depend on episode length, and add variety in episodes, we can use a discounted total reward with the discount factor 0.9 or 0.95. In this case, the reward for shorter episodes will be higher than the reward for longer ones.
- Keeping "elite" episodes for a longer time: In the CartPole training, we sampled episodes from the environment, trained on the best ones, and threw them away. In FrozenLake, a successful episode is a much rarer animal, so we need to keep them for several iterations to train on them.
- Decrease learning rate: This will give our network time to average more training samples.
- Much longer training time: Due to the sparsity of successful episodes, and the random outcome of our actions, it's much harder for our network to get an idea of the best behavior to perform in any particular situation. To reach 50% successful episodes, about 5k training iterations are required.
To incorporate all these into our code, we need to change the filter_batch
function to calculate discounted reward and return "elite" episodes for us to keep:
def filter_batch(batch, percentile): disc_rewards = list(map(lambda s: s.reward * (GAMMA ** len(s.steps)), batch)) reward_bound = np.percentile(disc_rewards, percentile) train_obs = [] train_act = [] elite_batch = [] for example, discounted_reward in zip(batch, disc_rewards): if discounted_reward > reward_bound: train_obs.extend(map(lambda step: step.observation, example.steps)) train_act.extend(map(lambda step: step.action, example.steps)) elite_batch.append(example) return elite_batch, train_obs, train_act, reward_bound
Then, in the training loop, we will store previous "elite" episodes to pass them to the preceding function on the next training iteration.
full_batch = [] for iter_no, batch in enumerate(iterate_batches(env, net, BATCH_SIZE)): reward_mean = float(np.mean(list(map(lambda s: s.reward, batch)))) full_batch, obs, acts, reward_bound = filter_batch(full_batch + batch, PERCENTILE) if not full_batch: continue obs_v = torch.FloatTensor(obs) acts_v = torch.LongTensor(acts) full_batch = full_batch[-500:]
The rest of the code is the same, except that the learning rate decreased 10 times and the BATCH_SIZE
was set to 100. After a period of patient waiting (the new version takes about one and a half hours to finish 10k iterations), we can see that the training of the model stopped improving around 55% of solved episodes. There are ways to address this (by applying entropy loss regularization, for example), but those techniques will be discussed in the upcoming chapters.

Figure 9: Convergence of FrozenLake with tweaked cross-entropy implementation
The final point to note here is the effect of "slipperiness" in the FrozenLake environment. Each of our actions with 33% probability is replaced with the 90° rotated one (the "up" action, for instance, will succeed with 0.33 probability and with 0.33 chance that it will be replaced with the "left" action and 0.33 with the "right" action).
The nonslippery version is in Chapter04/04_frozenlake_nonslippery.py
, and the only difference is in the environment creation (we need to peek into the core of Gym to create the instance of the environment with tweaked arguments):
env = gym.envs.toy_text.frozen_lake.FrozenLakeEnv(is_slippery=False) env = gym.wrappers.TimeLimit(env, max_episode_steps=100)env = DiscreteOneHotWrapper(env)
The effect is dramatic! The nonslippery version of the environment can be solved in 120-140 batch iterations, which is 100 times faster than the noisy environment:
rl_book_samples/Chapter04$ ./04_frozenlake_nonslippery.py 0: loss=1.379, reward_mean=0.010, reward_bound=0.000, batch=1 1: loss=1.375, reward_mean=0.010, reward_bound=0.000, batch=2 2: loss=1.359, reward_mean=0.010, reward_bound=0.000, batch=3 3: loss=1.361, reward_mean=0.010, reward_bound=0.000, batch=4 4: loss=1.355, reward_mean=0.000, reward_bound=0.000, batch=4 5: loss=1.342, reward_mean=0.010, reward_bound=0.000, batch=5 6: loss=1.353, reward_mean=0.020, reward_bound=0.000, batch=7 7: loss=1.351, reward_mean=0.040, reward_bound=0.000, batch=11 ...... 124: loss=0.484, reward_mean=0.680, reward_bound=0.000, batch=68 125: loss=0.373, reward_mean=0.710, reward_bound=0.430, batch=114 126: loss=0.305, reward_mean=0.690, reward_bound=0.478, batch=133 128: loss=0.413, reward_mean=0.790, reward_bound=0.478, batch=73 129: loss=0.297, reward_mean=0.810, reward_bound=0.478, batch=108 Solved!

Figure 10: Convergence of the nonslippery version of FrozenLake