Examples

Environment

Before implementing the reinforcement learning algorithm, it is very useful to implement the real world from which episodes will be sampled. This environment will be based on a known MDP, and its job is to yield on demand random episodes which respect asymptotically the MDP (transitions and rewards). The goal of this entire section is to implement, test the reinforcement learning algorithms and show that they enable to learn from the interactions with the environment. Depending on the learning algorithm, either the MDP itself is estimated, or the qualities of a policy, or the optimum policy.

src
 1"""Base types for a sampling environment"""
 2
 3from collections.abc import Generator
 4from typing import Protocol
 5from collections import namedtuple
 6from dataclasses import dataclass, field
 7
 8from ai.reinforcement.mdp_types import (
 9    State,
10    Action,
11    Transition,
12    Reward,
13    Policy,
14)
15
16
17type Outcome[S] = tuple[S, Reward]  # careful, singular, single outcome
18
19Frame = namedtuple("Frame", ("state", "action", "reward", "next_state"))
20
21
22@dataclass
23class OutcomesForChoice[S: State]:  # only reformatting mdp to ease random choice
24    outcomes: list[Outcome[S]] = field(default_factory=list)
25    transitions: list[Transition] = field(default_factory=list)
26
27
28class Environment[S: State](Protocol):
29    """an environnement is able to spit frames of episodes on demand.
30    A frame is a ("state", "action", "reward", "next_state") named tuple.
31    """
32
33    def actions_from_state(self, state: S) -> set[Action]: ...
34
35    def outcome_from_state_and_action(self, state: S, action: Action) -> Outcome[S]: ...
36
37    """careful, return a single random outcome following a given probability distribution"""
38
39    def is_end_state(self, state: S) -> bool: ...
40
41    def yield_frame_in_episode(self, state: S, policy: Policy) -> Generator[Frame]: ...

Exercise (intermediate)

Implement and test EnvironmentFromMDP.

Correction
src
 1class EnvironmentFromMDP(Environment):
 2    def __init__(self, mdp: MDPProblem):
 3        self.mdp = mdp
 4        self.outcomes_for_choice = self.format_outcomes_for_choice()
 5
 6    def format_outcomes_for_choice(
 7        self,
 8    ) -> dict[tuple[State, Action], OutcomesForChoice]:
 9        transition_cases = {}
10        for state in self.mdp.states:
11            for action in self.mdp.actions_from_state(state):
12                outcomes: Outcomes = self.mdp.outcomes_from_state_and_action(
13                    state, action
14                )
15                outcomes_for_choice = OutcomesForChoice()
16                for next_state, (transition, reward) in outcomes.items():
17                    outcomes_for_choice.outcomes.append((next_state, reward))
18                    outcomes_for_choice.transitions.append(transition)
19
20                transition_cases[(state, action)] = outcomes_for_choice
21        return transition_cases
22
23    @override
24    def actions_from_state(self, state: State) -> set[Action]:
25        return self.mdp.actions_from_state(state)
26
27    @override
28    def outcome_from_state_and_action(self, state: State, action: Action) -> Outcome:
29        return choices(
30            self.outcomes_for_choice[(state, action)].outcomes,
31            self.outcomes_for_choice[(state, action)].transitions,
32        )[0]
33
34    @override
35    def is_end_state(self, state: State) -> bool:
36        return not self.actions_from_state(state)
37
38    @override
39    def yield_frame_in_episode(self, state: State, policy: Policy) -> Generator[Frame]:
40        while not self.is_end_state(state):
41            actions = self.actions_from_state(state)
42            action = policy.action_from_state(state, actions)
43            next_state, reward = self.outcome_from_state_and_action(state, action)
44            yield Frame(state, action, reward, next_state)
45            state = next_state
tests
 1import pytest
 2
 3from ai.reinforcement.environment_types import OutcomesForChoice
 4
 5
 6def test_format_outcomes_for_choice(game_environment):
 7    expected = {
 8        ("in", "quit"): OutcomesForChoice([("out", 10.0)], [1.0]),
 9        ("in", "stay"): OutcomesForChoice(
10            [("in", 4.0), ("out", 5.0)], [2.0 / 3.0, 1.0 / 3.0]
11        ),
12    }
13    assert expected == game_environment.outcomes_for_choice  # dubious float comparison
14
15
16def test_outcome_from_state_and_action(game_environment):
17    nb_trials = 1_000_000
18    nb_in = 0
19    for _ in range(nb_trials):
20        next_state, _ = game_environment.outcome_from_state_and_action("in", "stay")
21        if next_state == "in":
22            nb_in += 1
23    assert pytest.approx(nb_in / nb_trials, abs=0.01) == 2.0 / 3.0
24
25
26def test_yield_frame_in_episode(game_environment, policy_stay):
27    nb_episode = 100_000
28    nb_in = 0
29    nb_frame = 0
30    for _ in range(nb_episode):
31        for frame in game_environment.yield_frame_in_episode("in", policy_stay):
32            nb_frame += 1
33            if frame.next_state == "in":
34                nb_in += 1
35    assert pytest.approx(nb_in / nb_frame, abs=0.03) == 2.0 / 3.0

Game

Exercise (difficult)

For the four following questions, use a RandomPolicy.

  1. Implement and test the model based Monte-Carlo algorithm. Add a export_to_MDPMapping method.

  2. Implement and test the model free Monte-Carlo algorithm.

  3. Implement and test the Sarsa algorithm.

  4. Implement and test the Q-learning algorithm.

Hints

The four algorithms work iteratively: the estimation is incrementally improved. The easiest way is to loop a certain amount of times over all discovered states, each time generate an episode and use it with one of the algorithm.

Obviously, it would be even better have a convergence check: either for every estimated transition, or quality. This step is not implemented in the correction.

These algorithms are not easy to test because of the randomness from the environment sampling. However, if the environment is an input parameter of the function, it is a prime example of a dependency injection. It allows to feed a tweaked environment which behaves the same, but which is not random. For example:

tests
1class DummyGameEnvironment(EnvironmentFromMDP):
2    def __init__(self, mdp: MDPProblem):
3        super().__init__(mdp)
4        self.outcome_cycle = cycle((("in", 4.0), ("in", 4.0), ("out", 5.0)))
5
6    def outcome_from_state_and_action(self, state: State, action: Action) -> Outcome:
7        return next(self.outcome_cycle)

This environment will always produce the same episode.

Hints 1.

Maintain the number of times a (state, action, next_state) and a (state, action) has been encountered. The ratio between the two is the estimation of a transition. Use the class StateActionNextStateMetric in a defaultdict.

src
1@dataclass
2class StateActionNextStateMetric:
3    counter: int = 0
4    reward_sum: Reward = 0.0

Do not forget to update your knowledge of the existing states.

An approximation of the true transitions is expected.

Hints 2.

Maintain the number of times (state, action) has been encountered because it will be the \(N\) in the convex combination. Use reversed full episode and maintain the qualities for every (state, action). Going in reverse, allows to easily calculate the discounted utility: \(u_t=r+\gamma u_{t+1}\). If you update the quality (state, action) for every frame, it will automatically keep the first encounter of every (state, action) of the episode.

It ease much easier to first obtain the utilities for every (state, action) of the episode, and then perform the convex combination to update the qualities.

An approximation of the random policy qualities are expected.

Hints 3.

This the hardest of the four to implement because you need a pick view of the next action. However, a frame is state, action, reward, next_state. At a current frame, you do not have the next action. A possibility is to loop by hand with a while loop, calling next on the episode iterator. This way you can manage to constantly have access to a frame and its successor. However, pay attention if the next state is an end state or not.

An approximation of the random policy qualities are expected.

Hints 4.

Similar to the previous one, but easier. You no longer need the next action, just the next optimal action from the next state. Do not forget to update the optimal policy mapping right after the convex combination update.

An approximation of the optimal policy qualities are expected.

Correction
src
 1def model_based_Monte_Carlo(
 2    environment: Environment,
 3    state_: State,
 4    policy: Policy,
 5    nb_episodes_per_state: int = 1_000,
 6) -> MDPMapping:
 7    """builds an estimated MDP and returns its mapping"""
 8    states: set[State] = {state_}
 9
10    S_A_NS_metric: dict[tuple[State, Action, NextState], StateActionNextStateMetric] = (
11        defaultdict(StateActionNextStateMetric)
12    )  # numerator transition fraction and denominator reward fraction
13    counter_state_action: dict[tuple[State, Action], int] = defaultdict(int)
14    # denominator transition fraction
15
16    # generate episode many times from all known states
17    for _ in range(nb_episodes_per_state):
18        for state in states.copy():  # must not iterate on a mutating set
19            episode: Generator[Frame] = environment.yield_frame_in_episode(
20                state, policy
21            )
22
23            if environment.is_end_state(state):
24                continue
25
26            # analyze episode
27            for state, action, reward, next_state in episode:
28                states.add(state)
29                states.add(next_state)
30                counter_state_action[(state, action)] += 1
31                metric = S_A_NS_metric[(state, action, next_state)]
32                metric.counter += 1
33                metric.reward_sum += reward
34    return export_to_MDPMapping(states, S_A_NS_metric, counter_state_action)
src
 1def export_to_MDPMapping(
 2    states: set[State],
 3    S_A_NS_metric: dict[tuple[State, Action, NextState], StateActionNextStateMetric],
 4    counter_state_action: dict[tuple[State, Action], int],
 5) -> MDPMapping:
 6    # [state][action][next_state] = (Transition, Reward)
 7    mdp: MDPMapping = {}
 8    for state in states:
 9        mdp[state] = {}
10    for state, action in counter_state_action:
11        mdp[state][action] = {}
12
13    for (state, action, next_state), metric in S_A_NS_metric.items():
14        mdp[state][action][next_state] = (
15            metric.counter / counter_state_action[(state, action)],
16            metric.reward_sum / metric.counter,
17        )
18    return mdp
tests
1def test_model_based_Monte_Carlo_dummy(dummy_game_environment, policy_stay):
2    mdp_mapping: MDPMapping = model_based_Monte_Carlo(
3        dummy_game_environment, "in", policy_stay, 2
4    )
5    assert pytest.approx(mdp_mapping["in"]["stay"]["in"][0]) == 2.0 / 3.0
6    assert pytest.approx(mdp_mapping["in"]["stay"]["in"][1]) == 4.0
src
 1def model_free_Monte_Carlo(
 2    environment: Environment,
 3    state_: State,
 4    policy: Policy,
 5    discount: float = 1.0,
 6    nb_episodes_per_state: int = 1_000,
 7) -> QualitiesMapping:
 8    states: set[State] = {state_}
 9
10    qualities: QualitiesMapping[State] = defaultdict(float)
11    counter_state_action: dict[tuple[State, Action], int] = defaultdict(int)
12    # counter number of update for each Q(s,a)
13
14    # generate episode many times from all known states
15    for _ in range(nb_episodes_per_state):
16        for state in states.copy():  # must not iterate on a mutating set
17            episode: Generator[Frame] = environment.yield_frame_in_episode(
18                state, policy
19            )
20
21            if environment.is_end_state(state):
22                continue
23
24            # analyze episode
25            reversed_episode = reversed(tuple(episode))
26            utility: Reward = 0.0
27            updated_state_action: set[tuple[State, Action]] = set()
28            utilities: QualitiesMapping = {}
29            # obtain first occurrence utilities
30            for state, action, reward, next_state in reversed_episode:
31                states.add(state)
32                states.add(next_state)
33                utility = utility * discount + reward
34                utilities[(state, action)] = utility
35                updated_state_action.add((state, action))
36
37            # update qualities
38            for state, action in updated_state_action:
39                alpha: float = 1.0 / (1.0 + counter_state_action[(state, action)])
40                qualities[(state, action)] = (1.0 - alpha) * qualities[
41                    (state, action)
42                ] + alpha * utilities[(state, action)]
43                counter_state_action[(state, action)] += 1
44
45    return qualities
tests
1def test_model_free_Monte_Carlo_dummy(dummy_game_environment, policy_stay):
2    qualities_mapping: QualitiesMapping = model_free_Monte_Carlo(
3        dummy_game_environment, "in", policy_stay, nb_episodes_per_state=2
4    )
5    assert pytest.approx(qualities_mapping[("in", "stay")]) == 13.0
src
 1def sarsa(
 2    environment: Environment,
 3    state_: State,
 4    policy: Policy,
 5    discount: float = 1.0,
 6    nb_episodes_per_state: int = 1_000,
 7) -> QualitiesMapping:
 8    states: set[State] = {state_}
 9
10    qualities: QualitiesMapping = defaultdict(float)
11    counter_state_action: dict[tuple[State, Action], int] = defaultdict(int)
12    # counter number of update for each Q(s,a)
13
14    # generate episode many times from all known states
15    for _ in range(nb_episodes_per_state):
16        for state in states.copy():  # must not iterate on a mutating set
17            episode: Generator[Frame] = environment.yield_frame_in_episode(
18                state, policy
19            )
20            if environment.is_end_state(state):
21                continue
22
23            # analyze episode
24            current_frame: Frame = next(episode)
25            last_frame: bool = False
26            while not last_frame:
27                state, action, reward, next_state = current_frame
28
29                # obtain quality next state
30                if environment.is_end_state(next_state):
31                    next_quality: Reward = 0.0
32                    last_frame = True
33                else:
34                    next_frame: Frame = next(episode)
35                    _, next_action, _, _ = next_frame
36                    next_quality: Reward = qualities[(next_state, next_action)]
37                    current_frame = next_frame
38
39                # update qualities
40                alpha: float = 1.0 / (1.0 + counter_state_action[(state, action)])
41                qualities[(state, action)] = (1.0 - alpha) * qualities[
42                    (state, action)
43                ] + alpha * (reward + discount * next_quality)
44                counter_state_action[(state, action)] += 1
45
46    return qualities
tests
1def test_sarsa_dummy(dummy_game_environment, policy_stay):
2    qualities_mapping: QualitiesMapping = sarsa(
3        dummy_game_environment, "in", policy_stay, nb_episodes_per_state=1
4    )
5    assert pytest.approx(qualities_mapping[("in", "stay")]) == 17.0 / 3.0
src
 1def qlearning(
 2    environment: Environment,
 3    state_: State,
 4    policy: Policy,
 5    discount: float = 1.0,
 6    nb_episodes_per_state: int = 1_000,
 7) -> tuple[QualitiesMapping, PolicyMapping]:
 8    states: set[State] = {state_}
 9
10    opt_qualities: QualitiesMapping = defaultdict(float)
11    counter_state_action: dict[tuple[State, Action], int] = defaultdict(int)
12    # counter number of update for each Q(s,a)
13    opt_policy_mapping: PolicyMapping = {}
14
15    # generate episode many times from all known states
16    for _ in range(nb_episodes_per_state):
17        for state in states.copy():  # must not iterate on a mutating set
18            episode: Generator[Frame] = environment.yield_frame_in_episode(
19                state, policy
20            )
21            if environment.is_end_state(state):
22                continue
23
24            # analyze episode
25            for frame in episode:
26                state, action, reward, next_state = frame
27                states.add(state)
28                states.add(next_state)
29
30                # obtain quality next state
31                if environment.is_end_state(next_state):
32                    opt_next_quality: Reward = 0.0
33                else:
34                    next_actions = environment.actions_from_state(next_state)
35                    opt_next_quality = max(
36                        [
37                            opt_qualities[(next_state, next_action)]
38                            for next_action in next_actions
39                        ]
40                    )
41
42                # update optimum qualities
43                alpha: float = 1.0 / (1.0 + counter_state_action[(state, action)])
44                opt_qualities[(state, action)] = (1.0 - alpha) * opt_qualities[
45                    (state, action)
46                ] + alpha * (reward + discount * opt_next_quality)
47                counter_state_action[(state, action)] += 1
48
49                # update optimum policy
50                actions = environment.actions_from_state(state)
51                _, opt_action = max(
52                    [(opt_qualities[(state, action)], action) for action in actions]
53                )
54                opt_policy_mapping[state] = opt_action
55
56    return opt_qualities, opt_policy_mapping
tests
1def test_qlearning_dummy(dummy_game_environment, policy_stay):
2    qualities_mapping, opt_policy_mapping = qlearning(
3        dummy_game_environment, "in", policy_stay, nb_episodes_per_state=1
4    )
5    assert pytest.approx(qualities_mapping[("in", "stay")]) == 17.0 / 3.0
6    assert opt_policy_mapping["in"] == "stay"

Magic tram

Exercise (easy)

Use the four previous algorithms on the magic tram problem.

  1. Model based MC, comment on the result.

  2. Model free MC and Sarsa, why don’t we find the same qualities as the correction of the magic tram MDP exercise?

  3. Qlearning, do we retrieve the optimal policy of the magic tram MDP exercise? Use this policy mapping in Model free MC and Sarsa to confirm that you find a satisfying estimation of the qualities of the magic tram MDP exercise.

Correction

For nb_episodes_per_state of 100_000 and a fail probability of 0.2.

  1. The MDP and the fail probability are correctly estimated, the variance of the estimations between runs decreases when the nb_episodes_per_state increases.

    {2: {'tram': {2: (0.20043060556168696, -2.0), 4: (0.799569394438313, -2.0)},
          'walk': {3: (1.0, -1.0)}},
    3: {'tram': {3: (0.19742429996892588, -2.0), 6: (0.8025757000310741, -2.0)},
          'walk': {4: (1.0, -1.0)}},
    4: {'tram': {4: (0.20133871320884553, -2.0), 8: (0.7986612867911544, -2.0)},
          'walk': {5: (1.0, -1.0)}},
    5: {'tram': {5: (0.2012801210754909, -2.0), 10: (0.7987198789245091, -2.0)},
          'walk': {6: (1.0, -1.0)}},
    6: {'tram': {6: (0.20070165624923997, -2.0), 12: (0.79929834375076, -2.0)},
          'walk': {7: (1.0, -1.0)}},
    7: {'walk': {8: (1.0, -1.0)}},
    8: {'walk': {9: (1.0, -1.0)}},
    9: {'walk': {10: (1.0, -1.0)}},
    10: {'walk': {11: (1.0, -1.0)}},
    11: {'walk': {12: (1.0, -1.0)}},
    12: {'walk': {13: (1.0, -1.0)}},
    13: {}}
    
  2. The magic tram MDP correction gave the qualities of the optimal policy (value iteration algorithm). Here, the random policy is used. The two matches when the two policies are equivalent: when the only choice is to walk.

    {(2, 'tram'): -9.654715245560729,
    (2, 'walk'): -9.118302645579085,
    (3, 'tram'): -7.970203666250092,
    (3, 'walk'): -8.238426328292135,
    (4, 'tram'): -7.4482404985182455,
    (4, 'walk'): -7.0281073940240875,
    (5, 'tram'): -5.600584493424383,
    (5, 'walk'): -6.443788333294008,
    (6, 'tram'): -3.896297797594899,
    (6, 'walk'): -6.999999999999999,
    (7, 'walk'): -6.0,
    (8, 'walk'): -4.999999999999999,
    (9, 'walk'): -4.0,
    (10, 'walk'): -3.0,
    (11, 'walk'): -2.0,
    (12, 'walk'): -1.0}
    
    
    {(2, 'tram'): -9.481134252634465,
    (2, 'walk'): -8.877060965638796,
    (3, 'tram'): -7.858008074723888,
    (3, 'walk'): -8.09721124858775,
    (4, 'tram'): -7.409247009049843,
    (4, 'walk'): -6.930076577752609,
    (5, 'tram'): -5.580370812949426,
    (5, 'walk'): -6.380390930193599,
    (6, 'tram'): -3.8683076395406277,
    (6, 'walk'): -6.9511901596126995,
    (7, 'walk'): -5.98235136847001,
    (8, 'walk'): -4.994789408530305,
    (9, 'walk'): -3.998836675565334,
    (10, 'walk'): -2.9998236749092415,
    (11, 'walk'): -1.9999872676343076,
    (12, 'walk'): -1.0}
    
  3. Yes. Using the optimum policy from Qlearning in Sarsa or Model free MC allows to retrieve the qualities from the magic tram MDP correction.

{2: 'walk',
3: 'tram',
4: 'walk',
5: 'walk',
6: 'tram',
7: 'walk',
8: 'walk',
9: 'walk',
10: 'walk',
11: 'walk',
12: 'walk'}

{(2, 'walk'): -7.0063599999999475,
(3, 'tram'): -6.000875004375021,
(6, 'tram'): -3.5002433349554973,
(12, 'walk'): -1.0}

{(2, 'walk'): -6.983584610287968,
(3, 'tram'): -5.997740461664008,
(6, 'tram'): -3.504182096707191,
(12, 'walk'): -1.0}
src
 1def reinforcement_learning_magic_tram():
 2    from icecream import ic
 3
 4    from ai.reinforcement.mdp_types import RandomPolicy
 5    from ai.reinforcement.mdp_example_tram import MDPMagicTram
 6    from ai.reinforcement.environment_from_mdp import EnvironmentFromMDP
 7
 8    policy = RandomPolicy()
 9    mdp = MDPMagicTram(2, 13, fail_probability=0.2)
10    environment = EnvironmentFromMDP(mdp)
11    nb_episodes_per_state = 100_000
12    start: State = 2
13    ic(
14        model_based_Monte_Carlo(
15            environment, start, policy, nb_episodes_per_state=nb_episodes_per_state
16        )
17    )
18    ic(
19        model_free_Monte_Carlo(
20            environment, start, policy, nb_episodes_per_state=nb_episodes_per_state
21        )
22    )
23    ic(sarsa(environment, start, policy, nb_episodes_per_state=nb_episodes_per_state))
24    opt_qualities, opt_policy_mapping = qlearning(
25        environment, start, policy, nb_episodes_per_state=nb_episodes_per_state
26    )
27    ic(opt_qualities, opt_policy_mapping)
28
29    opt_policy = PolicyFromMapping(opt_policy_mapping)
30    ic(
31        model_free_Monte_Carlo(
32            environment, start, opt_policy, nb_episodes_per_state=nb_episodes_per_state
33        )
34    )
35    ic(
36        sarsa(
37            environment, start, opt_policy, nb_episodes_per_state=nb_episodes_per_state
38        )
39    )

Swamp isle

Rearranging the Q-learning equation, one gets:

(1)\[\hat Q_{\textrm{opt}}^{(N+1)}(s, a) = \underbrace{\hat Q_{\textrm{opt}}^{(N)}(s, a)}_{\textrm{current}} + \underbrace{\alpha}_{\textrm{learning rate}} \left[ \underbrace{\left( r + \max_{a'} \gamma Q_{\textrm{opt}}^{(N)}(s', a') \right)}_{\textrm{target}} - \underbrace{\hat Q_{\textrm{opt}}^{(N)}(s, a)}_{\textrm{current}} \right]\]

This form is a classical form of iterative correction where the current value is updated proportionally to the difference between the better / target value and the current value. In this context, it is sometimes referred as temporal difference. In the original equation, a strict convex combination is equivalent to \(\alpha= \cfrac{1}{N+1}\). However, the next exercise will illustrate that this rapidly decreasing learning rate is not efficient.

Exercise (intermediate)

  1. Implement and test the epsilon greedy algorithm (with defaulting to a random strategy when a state is not present in the strategy mapping).

  2. Modify and test the qlearning algorithm in order to mutate continuously the exploration policy to the estimated optimum policy. Use the epsilon greedy policy for exploration, initialize its mapping with a non optimal policy. Comment on the results.

  3. Improve the qlearning algorithm by using a fixed learning rate. Comment on the results.

Correction
  1. The simplest experiment is to start with a failure probability of 0. This way the result should be identical to the state model and MDP exercises. Doin

  2. Since the policy mapping is mutated along, there is no need to return a policy mapping at the end, the optimal policy is in the policy mapping of the policy. The counter (state, action) is no longer required because the learning rate is fixed.

    src
     1class EpsilonGreedyPolicy(PolicyFromMapping):
     2    def __init__(self, policy_mapping: PolicyMapping, epsilon: float = 0.1):
     3        self.policy_mapping = dict(policy_mapping)
     4        self.epsilon = epsilon
     5
     6    @override
     7    def action_from_state(self, state: State, actions: set[Action]) -> Action:
     8        actions_ = tuple(actions)
     9        opt_action: State | None = self.policy_mapping.get(state)
    10        if opt_action is None:
    11            return choice(actions_)  # random policy if no known optimal policy
    12            
    13        if len(actions)>1:
    14            non_opt_probability: float = self.epsilon / (len(actions) - 1)
    15        else:
    16            non_opt_probability: float = 0.0
    17            
    18        weights: list[float] = [
    19            non_opt_probability if action != opt_action else 1.0 - self.epsilon
    20            for action in actions_
    21        ] # for the case where len(actions)==1 => weights=[1-epsi]
    22        # this not a problem because choices normalizes the weights
    23        return choices(actions_, weights, k=1)[0]
    
    tests
     1def test_epsilon_greedy():
     2    policy_mapping: PolicyMapping = {"in": "stay"}
     3    epsilon: float = 0.4
     4    nb_trials = 1000
     5    policy_epsilon_greedy = EpsilonGreedyPolicy(policy_mapping, epsilon=epsilon)
     6    are_actions_stay = [
     7        "stay" == policy_epsilon_greedy.action_from_state("in", {"stay", "quit"})
     8        for _ in range(nb_trials)
     9    ]
    10    assert pytest.approx(sum(are_actions_stay) / nb_trials, abs=0.05) == 1.0 - epsilon
    

    Even using a number of episodes per state very large (100_000 or even more), leads to very disappointing results: the qualities close to the destination are pretty well estimated, however, the qualities close to the starting position do not seen to converge. The problem with the decreasing learning rate is that the states close to the starting position are updated multiple times using very poor target values. The learning rate drops for these states, and later when target improves (from the destination), the corrections are too small. Using a random order for state traversal does not help to propagate the knowledge faster. With the fixed learning rate, the convergence is dramatically improved.

    {((0, 0), 'E'): -12.677577227122406,
    ((0, 0), 'S'): -12.667075392250679,
    ((0, 1), 'E'): -31.68226743698947,
    ((0, 1), 'S'): -31.66799828086022,
    ((0, 1), 'W'): -12.670359811413386,
    ((0, 2), 'E'): -40.9997934415334,
    ((0, 2), 'S'): -40.999834420809556,
    ((0, 2), 'W'): -31.67645420617531,
    ((0, 3), 'S'): -20.999992192948962,
    ((0, 3), 'W'): -50.681523379327565,
    ((1, 0), 'E'): -31.66673091925328,
    ((1, 0), 'N'): -12.66885326879473,
    ((1, 0), 'S'): -12.6661214455744,
    ((1, 1), 'E'): -40.9998103493078,
    ((1, 1), 'N'): -31.675785937100066,
    ((1, 1), 'S'): -31.66929228245872,
    ((1, 1), 'W'): -31.66414897696369,
    ((1, 2), 'E'): -21.000000000000004,
    ((1, 2), 'N'): -50.686172012334524,
    ((1, 2), 'S'): -39.999926341212834,
    ((1, 2), 'W'): -50.6678345317146,
    ((1, 3), 'N'): -21.999897582486756,
    ((1, 3), 'S'): -1.0,
    ((1, 3), 'W'): -21.999850806709116,
    ((2, 0), 'E'): -12.66813474888183,
    ((2, 0), 'N'): -12.667260215082385,
    ((2, 1), 'E'): -20.999777113079706,
    ((2, 1), 'N'): -31.66945779221507,
    ((2, 1), 'W'): -12.668045310032248,
    ((2, 2), 'E'): -19.999999999999993,
    ((2, 2), 'N'): -40.999794234146705,
    ((2, 2), 'W'): -31.667637590668363}
    
    {(0, 0): 'S',
    (0, 1): 'W',
    (0, 2): 'W',
    (0, 3): 'S',
    (1, 0): 'S',
    (1, 1): 'W',
    (1, 2): 'E',
    (1, 3): 'S',
    (2, 0): 'N',
    (2, 1): 'W',
    (2, 2): 'E'}
    
    {((0, 0), 'E'): -25.99999999999998,
    ((0, 0), 'S'): -23.999999999999982,
    ((0, 1), 'E'): -41.99999999999996,
    ((0, 1), 'S'): -41.99999999999996,
    ((0, 1), 'W'): -24.999999999999982,
    ((0, 2), 'E'): -40.99999999999997,
    ((0, 2), 'S'): -40.99999999999997,
    ((0, 2), 'W'): -44.99999999999999,
    ((0, 3), 'S'): -20.999999999999993,
    ((0, 3), 'W'): -60.99999999999998,
    ((1, 0), 'E'): -41.99999999999996,
    ((1, 0), 'N'): -24.999999999999982,
    ((1, 0), 'S'): -22.999999999999986,
    ((1, 1), 'E'): -40.99999999999997,
    ((1, 1), 'N'): -44.99999999999999,
    ((1, 1), 'S'): -40.99999999999997,
    ((1, 1), 'W'): -42.99999999999997,
    ((1, 2), 'E'): -20.999999999999993,
    ((1, 2), 'N'): -60.99999999999998,
    ((1, 2), 'S'): -39.999999999999986,
    ((1, 2), 'W'): -60.99999999999998,
    ((1, 3), 'N'): -21.99999999999999,
    ((1, 3), 'S'): -0.9999999999999998,
    ((1, 3), 'W'): -21.99999999999999,
    ((2, 0), 'E'): -21.99999999999999,
    ((2, 0), 'N'): -23.999999999999982,
    ((2, 1), 'E'): -20.999999999999993,
    ((2, 1), 'N'): -41.99999999999996,
    ((2, 1), 'W'): -22.999999999999986,
    ((2, 2), 'E'): -19.999999999999993,
    ((2, 2), 'N'): -40.99999999999997,
    ((2, 2), 'W'): -40.99999999999997}
    
    {(0, 0): 'S',
    (0, 1): 'W',
    (0, 2): 'S',
    (0, 3): 'S',
    (1, 0): 'S',
    (1, 1): 'S',
    (1, 2): 'E',
    (1, 3): 'S',
    (2, 0): 'E',
    (2, 1): 'E',
    (2, 2): 'E'}
    
  3. src
     1def qlearning_with_update(
     2    environment: Environment,
     3    state_: State,
     4    policy: PolicyFromMapping,  # change
     5    discount: float = 1.0,
     6    nb_episodes_per_state: int = 1_000,
     7    learning_rate: float = 0.2,  # change
     8) -> QualitiesMapping:  # change
     9    """contrary to the previous qlearning, this one receives the policy as input,
    10    and update the policy mapping along the way. For example, if the epsilon greedy
    11    polilcy is passed, obviously initially the optimal policy is not known (and defaulted to random).
    12    The optimal policy is thus gradually built and updated inside the policy.
    13    That is why the policy is not explictly returned."""
    14    states: set[State] = {state_}
    15    alpha = learning_rate
    16
    17    opt_qualities: QualitiesMapping = defaultdict(float)
    18
    19    # synchronize the optimum policy and exploration policy mappings
    20    opt_policy_mapping: PolicyMapping = policy.policy_mapping  # change
    21    states |= set(opt_policy_mapping)  # change
    22
    23    # generate episode many times from all known states
    24    for _ in range(nb_episodes_per_state):
    25        for state in states.copy():
    26            episode: Generator[Frame] = environment.yield_frame_in_episode(
    27                state, policy
    28            )
    29            if environment.is_end_state(state):
    30                continue
    31
    32            # analyze episode
    33            for frame in episode:
    34                state, action, reward, next_state = frame
    35                states.add(state)
    36                states.add(next_state)
    37
    38                # obtain quality next state
    39                if environment.is_end_state(next_state):
    40                    opt_next_quality: Reward = 0.0
    41                else:
    42                    next_actions = environment.actions_from_state(next_state)
    43                    opt_next_quality = max(
    44                        [
    45                            opt_qualities[(next_state, next_action)]
    46                            for next_action in next_actions
    47                        ]
    48                    )
    49
    50                # update optimum qualities
    51                opt_qualities[(state, action)] = (1.0 - alpha) * opt_qualities[
    52                    (state, action)
    53                ] + alpha * (reward + discount * opt_next_quality)
    54
    55                # update optimum policy
    56                actions = environment.actions_from_state(state)
    57                _, opt_action = max(
    58                    [(opt_qualities[(state, action)], action) for action in actions]
    59                )
    60                opt_policy_mapping[state] = opt_action
    61
    62    return opt_qualities