simonbb 0.0.3__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simonbb
3
- Version: 0.0.3
4
- Summary: A small package to facilitate teaching AI/ML courses
3
+ Version: 0.0.4
4
+ Summary: A small package to facilitate teaching AI courses
5
5
  Project-URL: Homepage, https://simonbb.com
6
6
  Author-email: Huaxia Rui <huaxia.rui@simon.rochester.edu>
7
7
  License-Expression: MIT
@@ -9,6 +9,7 @@ License-File: LICENSE
9
9
  Classifier: Operating System :: OS Independent
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Requires-Python: >=3.9
12
+ Requires-Dist: gymnasium~=1.3.0
12
13
  Requires-Dist: langchain-chroma~=1.1.0
13
14
  Requires-Dist: langchain-community~=0.4.1
14
15
  Requires-Dist: langchain-core~=1.2.23
@@ -17,6 +18,8 @@ Requires-Dist: langchain~=1.2.13
17
18
  Requires-Dist: langgraph~=1.1.3
18
19
  Requires-Dist: matplotlib~=3.10.8
19
20
  Requires-Dist: numpy~=2.4.0
21
+ Requires-Dist: pytorch-ignite~=0.5.4
22
+ Requires-Dist: pyyaml~=6.0.3
20
23
  Requires-Dist: sentence-transformers~=5.3.0
21
24
  Requires-Dist: torchvision~=0.24.1
22
25
  Requires-Dist: torch~=2.9.1
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "simonbb"
7
- version = "0.0.3"
7
+ version = "0.0.4"
8
8
  authors = [
9
9
  { name="Huaxia Rui", email="huaxia.rui@simon.rochester.edu" },
10
10
  ]
11
- description = "A small package to facilitate teaching AI/ML courses"
11
+ description = "A small package to facilitate teaching AI courses"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
14
14
  classifiers = [
@@ -30,6 +30,9 @@ dependencies = [
30
30
  "langchain-chroma~=1.1.0",
31
31
  "langchain-huggingface~=1.2.1",
32
32
  "sentence-transformers~=5.3.0",
33
+ "gymnasium~=1.3.0",
34
+ "pytorch-ignite~=0.5.4",
35
+ "pyyaml~=6.0.3"
33
36
  ]
34
37
  [project.urls]
35
38
  Homepage = "https://simonbb.com"
@@ -0,0 +1,76 @@
1
+ import abc
2
+ import numpy as np
3
+ import typing as tt
4
+
5
+ class ActionSelector(abc.ABC):
6
+ """
7
+ Abstract class which converts scores to the actions
8
+ """
9
+ @abc.abstractmethod
10
+ def __call__(self, scores: np.ndarray) -> np.ndarray:
11
+ ...
12
+
13
+
14
+ class ArgmaxActionSelector(ActionSelector):
15
+ """
16
+ Selects actions using argmax
17
+ """
18
+ def __call__(self, scores: np.ndarray) -> np.ndarray:
19
+ return np.argmax(scores, axis=1)
20
+
21
+
22
+ class EpsilonGreedyActionSelector(ActionSelector):
23
+ def __init__(self, epsilon: float = 0.05,
24
+ selector: tt.Optional[ActionSelector] = None):
25
+ self._epsilon = epsilon
26
+ self.selector = selector if selector is not None \
27
+ else ArgmaxActionSelector()
28
+
29
+ @property
30
+ def epsilon(self) -> float:
31
+ return self._epsilon
32
+
33
+ @epsilon.setter
34
+ def epsilon(self, value: float):
35
+ if value < 0.0 or value > 1.0:
36
+ raise ValueError("Epsilon has to be between 0 and 1")
37
+ self._epsilon = value
38
+
39
+ def __call__(self, scores: np.ndarray) -> np.ndarray:
40
+ assert len(scores.shape) == 2
41
+ batch_size, n_actions = scores.shape
42
+ actions = self.selector(scores)
43
+ mask = np.random.random(size=batch_size) < self.epsilon
44
+ rand_actions = np.random.choice(n_actions, sum(mask))
45
+ actions[mask] = rand_actions
46
+ return actions
47
+
48
+
49
+ class ProbabilityActionSelector(ActionSelector):
50
+ """
51
+ Converts probabilities of actions into action by sampling them
52
+ """
53
+ def __call__(self, probs: np.ndarray) -> np.ndarray:
54
+ actions = []
55
+ for prob in probs:
56
+ actions.append(np.random.choice(len(prob), p=prob))
57
+ return np.array(actions)
58
+
59
+
60
+ class EpsilonTracker:
61
+ """
62
+ Updates epsilon according to linear schedule
63
+ """
64
+ def __init__(self, selector: EpsilonGreedyActionSelector,
65
+ eps_start: tt.Union[int, float],
66
+ eps_final: tt.Union[int, float],
67
+ eps_frames: int):
68
+ self.selector = selector
69
+ self.eps_start = eps_start
70
+ self.eps_final = eps_final
71
+ self.eps_frames = eps_frames
72
+ self.frame(0)
73
+
74
+ def frame(self, frame: int):
75
+ eps = self.eps_start - frame / self.eps_frames
76
+ self.selector.epsilon = max(self.eps_final, eps)
@@ -0,0 +1,193 @@
1
+ """
2
+ Agent is something which converts states into actions and has state
3
+ """
4
+ import abc
5
+ import copy
6
+ import numpy as np
7
+ import torch
8
+ from torch import nn
9
+ import torch.nn.functional as F
10
+ import typing as tt
11
+
12
+ from . import actions
13
+
14
+ States = tt.List[np.ndarray] | np.ndarray
15
+ AgentStates = tt.List[tt.Any]
16
+ Preprocessor = tt.Callable[[States], torch.Tensor]
17
+
18
+ CPU_DEVICE = torch.device("cpu")
19
+
20
+
21
+ def default_states_preprocessor(states: States) -> torch.Tensor:
22
+ """
23
+ Convert list of states into the form suitable for model
24
+ :param states: list of numpy arrays with states or numpy array
25
+ :return: torch.Tensor
26
+ """
27
+ if isinstance(states, list):
28
+ if len(states) == 1:
29
+ np_states = np.expand_dims(states[0], 0)
30
+ else:
31
+ np_states = np.asarray([np.asarray(s) for s in states])
32
+ else:
33
+ np_states = states
34
+ return torch.as_tensor(np_states)
35
+
36
+
37
+ def float32_preprocessor(states: States):
38
+ np_states = np.array(states, dtype=np.float32)
39
+ return torch.as_tensor(np_states)
40
+
41
+
42
+ class BaseAgent(abc.ABC):
43
+ """
44
+ Base Agent, sharing most of logic with concrete agent implementations.
45
+ """
46
+ def initial_state(self) -> tt.Optional[tt.Any]:
47
+ """
48
+ Should create initial empty state for the agent. It will be
49
+ called for the start of the episode
50
+ :return: Anything agent want to remember
51
+ """
52
+ return None
53
+
54
+ @abc.abstractmethod
55
+ def __call__(self, states: States, agent_states: AgentStates) -> tt.Tuple[np.ndarray, AgentStates]:
56
+ ...
57
+
58
+
59
+ class NNAgent(BaseAgent):
60
+ """
61
+ Network-based agent
62
+ """
63
+ def __init__(self, model: nn.Module, action_selector: actions.ActionSelector,
64
+ device: torch.device, preprocessor: Preprocessor):
65
+ """
66
+ Constructor of base agent
67
+ :param model: model to be used
68
+ :param action_selector: action selector
69
+ :param device: device for tensors
70
+ :param preprocessor: states preprocessor
71
+ """
72
+ self.model = model
73
+ self.action_selector = action_selector
74
+ self.device = device
75
+ self.preprocessor = preprocessor
76
+
77
+ @abc.abstractmethod
78
+ def _net_filter(self, net_out: tt.Any, agent_states: AgentStates) -> \
79
+ tt.Tuple[torch.Tensor, AgentStates]:
80
+ """
81
+ Internal method, processing network output and states into selector's input and new states
82
+ :param net_out: output from the network
83
+ :param agent_states: agent states
84
+ :return: tuple with tensor to be fed into selector and new states
85
+ """
86
+ ...
87
+
88
+ @torch.no_grad()
89
+ def __call__(self, states: States, agent_states: AgentStates = None) -> tt.Tuple[np.ndarray, AgentStates]:
90
+ """
91
+ Convert observations and states into actions to take
92
+ :param states: list of environment states to process
93
+ :param agent_states: list of states with the same length as observations
94
+ :return: tuple of actions, states
95
+ """
96
+ if agent_states is None:
97
+ agent_states = [None] * len(states)
98
+ if self.preprocessor is not None:
99
+ states = self.preprocessor(states)
100
+ if torch.is_tensor(states):
101
+ states = states.to(self.device)
102
+ q_v = self.model(states)
103
+ q_v, new_states = self._net_filter(q_v, agent_states)
104
+ q = q_v.data.cpu().numpy()
105
+ actions = self.action_selector(q)
106
+ return actions, new_states
107
+
108
+
109
+ class DQNAgent(NNAgent):
110
+ """
111
+ DQNAgent is a memoryless DQN agent which calculates Q values
112
+ from the observations and converts them into the actions using action_selector
113
+ """
114
+ def __init__(self, model: nn.Module, action_selector: actions.ActionSelector,
115
+ device: torch.device = CPU_DEVICE,
116
+ preprocessor: Preprocessor = default_states_preprocessor):
117
+ super().__init__(model, action_selector=action_selector, device=device,
118
+ preprocessor=preprocessor)
119
+
120
+ # not needed in DQN - we don't process Q-values returned
121
+ def _net_filter(self, net_out: tt.Any, agent_states: AgentStates) -> \
122
+ tt.Tuple[torch.Tensor, AgentStates]:
123
+ assert torch.is_tensor(net_out)
124
+ return net_out, agent_states
125
+
126
+
127
+ class TargetNet:
128
+ """
129
+ Wrapper around model which provides copy of it instead of trained weights
130
+ """
131
+ def __init__(self, model: nn.Module):
132
+ self.model = model
133
+ self.target_model = copy.deepcopy(model)
134
+
135
+ def sync(self):
136
+ self.target_model.load_state_dict(self.model.state_dict())
137
+
138
+ def alpha_sync(self, alpha):
139
+ """
140
+ Blend params of target net with params from the model
141
+ :param alpha:
142
+ """
143
+ assert isinstance(alpha, float)
144
+ assert 0.0 < alpha <= 1.0
145
+ state = self.model.state_dict()
146
+ tgt_state = self.target_model.state_dict()
147
+ for k, v in state.items():
148
+ tgt_state[k] = tgt_state[k] * alpha + (1 - alpha) * v
149
+ self.target_model.load_state_dict(tgt_state)
150
+
151
+
152
+ class PolicyAgent(NNAgent):
153
+ """
154
+ Policy agent gets action probabilities from the model and samples actions from it
155
+ """
156
+ def __init__(self, model: nn.Module,
157
+ action_selector: actions.ActionSelector = actions.ProbabilityActionSelector(),
158
+ device: torch.device = CPU_DEVICE, apply_softmax: bool = False,
159
+ preprocessor: Preprocessor = default_states_preprocessor):
160
+ super().__init__(model=model, action_selector=action_selector, device=device,
161
+ preprocessor=preprocessor)
162
+ self.apply_softmax = apply_softmax
163
+
164
+ def _net_filter(self, net_out: tt.Any, agent_states: AgentStates) -> \
165
+ tt.Tuple[torch.Tensor, AgentStates]:
166
+ assert torch.is_tensor(net_out)
167
+ if self.apply_softmax:
168
+ return F.softmax(net_out, dim=1), agent_states
169
+ return net_out, agent_states
170
+
171
+
172
+ class ActorCriticAgent(NNAgent):
173
+ """
174
+ Policy agent which returns policy and value tensors from observations. Value are stored in agent's state
175
+ and could be reused for rollouts calculations by ExperienceSource.
176
+ """
177
+ def __init__(self, model: nn.Module,
178
+ action_selector: actions.ActionSelector = actions.ProbabilityActionSelector(),
179
+ device: torch.device = CPU_DEVICE, apply_softmax: bool = False,
180
+ preprocessor: Preprocessor = default_states_preprocessor):
181
+ super().__init__(model=model, action_selector=action_selector, device=device, preprocessor=preprocessor)
182
+ self.apply_softmax = apply_softmax
183
+
184
+ def _net_filter(self, net_out: tt.Any, agent_states: AgentStates) -> \
185
+ tt.Tuple[torch.Tensor, AgentStates]:
186
+ assert isinstance(net_out, tuple)
187
+ policy_v, values_v = net_out
188
+ assert torch.is_tensor(policy_v)
189
+ assert torch.is_tensor(values_v)
190
+ agent_states = values_v.data.squeeze().cpu().numpy().tolist()
191
+ if self.apply_softmax:
192
+ return F.softmax(policy_v, dim=1), agent_states
193
+ return policy_v, agent_states
@@ -0,0 +1,604 @@
1
+ import gymnasium as gym
2
+ import torch
3
+ import random
4
+ import collections
5
+ import typing as tt
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from collections import deque
11
+
12
+ from .agent import BaseAgent
13
+
14
+ State = np.ndarray
15
+ Action = int
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Experience:
20
+ state: State
21
+ action: Action
22
+ reward: float
23
+ done_trunc: bool
24
+
25
+
26
+ class ExperienceSource:
27
+ """
28
+ Simple n-step experience source using single or multiple environments
29
+
30
+ Every experience contains n list of Experience entries
31
+ """
32
+ Item = tt.Tuple[Experience, ...]
33
+
34
+ def __init__(self, env: gym.Env | tt.Collection[gym.Env], agent: BaseAgent,
35
+ steps_count: int = 2, steps_delta: int = 1,
36
+ env_seed: tt.Optional[int] = None):
37
+ """
38
+ Create simple experience source
39
+ :param env: environment or list of environments to be used
40
+ :param agent: callable to convert batch of states into actions to take
41
+ :param steps_count: count of steps to track for every experience chain
42
+ :param steps_delta: how many steps to do between experience items
43
+ :param env_seed: seed to be used in Env.reset() call
44
+ """
45
+ assert steps_count >= 1
46
+ if isinstance(env, (list, tuple)):
47
+ self.pool = env
48
+ # do the check for the multiple copies passed
49
+ ids = set(id(e) for e in env)
50
+ if len(ids) < len(env):
51
+ raise ValueError("You passed single environment instance multiple times")
52
+ else:
53
+ self.pool = [env]
54
+ self.agent = agent
55
+ self.steps_count = steps_count
56
+ self.steps_delta = steps_delta
57
+ self.total_rewards = []
58
+ self.total_steps = []
59
+ self.agent_states = [agent.initial_state() for _ in self.pool]
60
+ self.env_seed = env_seed
61
+
62
+ def __iter__(self) -> tt.Generator[Item, None, None]:
63
+ states, histories, cur_rewards, cur_steps = [], [], [], []
64
+ for env in self.pool:
65
+ if self.env_seed is not None:
66
+ obs, _ = env.reset(seed=self.env_seed)
67
+ else:
68
+ obs, _ = env.reset()
69
+ states.append(obs)
70
+ histories.append(deque(maxlen=self.steps_count))
71
+ cur_rewards.append(0.0)
72
+ cur_steps.append(0)
73
+
74
+ iter_idx = 0
75
+ while True:
76
+ actions, self.agent_states = self.agent(states, self.agent_states)
77
+ for idx, env in enumerate(self.pool):
78
+ state = states[idx]
79
+ action = actions[idx]
80
+ history = histories[idx]
81
+ next_state, r, is_done, is_tr, _ = env.step(action)
82
+ cur_rewards[idx] += r
83
+ cur_steps[idx] += 1
84
+ history.append(Experience(state=state, action=action, reward=r, done_trunc=is_done or is_tr))
85
+ if len(history) == self.steps_count and iter_idx % self.steps_delta == 0:
86
+ yield tuple(history)
87
+ states[idx] = next_state
88
+ if is_done or is_tr:
89
+ # generate tail of history
90
+ if 0 < len(history) < self.steps_count:
91
+ yield tuple(history)
92
+ while len(history) > 1:
93
+ history.popleft()
94
+ yield tuple(history)
95
+ self.total_rewards.append(cur_rewards[idx])
96
+ self.total_steps.append(cur_steps[idx])
97
+ cur_rewards[idx] = 0.0
98
+ cur_steps[idx] = 0
99
+ if self.env_seed is not None:
100
+ states[idx], _ = env.reset(seed=self.env_seed)
101
+ else:
102
+ states[idx], _ = env.reset()
103
+ self.agent_states[idx] = self.agent.initial_state()
104
+ history.clear()
105
+ iter_idx += 1
106
+
107
+ def pop_total_rewards(self) -> tt.List[float]:
108
+ r = self.total_rewards
109
+ if r:
110
+ self.total_rewards = []
111
+ self.total_steps = []
112
+ return r
113
+
114
+ def pop_rewards_steps(self) -> tt.List[tt.Tuple[float, int]]:
115
+ res = list(zip(self.total_rewards, self.total_steps))
116
+ if res:
117
+ self.total_rewards, self.total_steps = [], []
118
+ return res
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ExperienceFirstLast:
123
+ state: State
124
+ action: Action
125
+ reward: float
126
+ last_state: tt.Optional[State]
127
+
128
+
129
+ class ExperienceSourceFirstLast(ExperienceSource):
130
+ """
131
+ This is a wrapper around ExperienceSource to prevent storing full trajectory in replay buffer when we need
132
+ only first and last states. For every trajectory piece it calculates discounted reward and emits only first
133
+ and last states and action taken in the first state.
134
+
135
+ If we have partial trajectory at the end of episode, last_state will be None
136
+ """
137
+ def __init__(self, env: gym.Env, agent: BaseAgent, gamma: float,
138
+ steps_count: int = 1, steps_delta: int = 1, env_seed: tt.Optional[int] = None):
139
+ super(ExperienceSourceFirstLast, self).__init__(env, agent, steps_count+1, steps_delta, env_seed=env_seed)
140
+ self.gamma = gamma
141
+ self.steps = steps_count
142
+
143
+ def __iter__(self) -> tt.Generator[ExperienceFirstLast, None, None]:
144
+ for exp in super(ExperienceSourceFirstLast, self).__iter__():
145
+ if exp[-1].done_trunc and len(exp) <= self.steps:
146
+ last_state = None
147
+ elems = exp
148
+ else:
149
+ last_state = exp[-1].state
150
+ elems = exp[:-1]
151
+ total_reward = 0.0
152
+ for e in reversed(elems):
153
+ total_reward *= self.gamma
154
+ total_reward += e.reward
155
+ yield ExperienceFirstLast(state=exp[0].state, action=exp[0].action,
156
+ reward=total_reward, last_state=last_state)
157
+
158
+
159
+ def vector_rewards(rewards: tt.Deque[np.ndarray], dones: tt.Deque[np.ndarray], gamma: float) -> np.ndarray:
160
+ """
161
+ Calculate rewards from vectorized environment for given amount of steps.
162
+ :param rewards: deque with observed rewards
163
+ :param dones: deque with bool flags indicating end of episode
164
+ :param gamma: gamma constant
165
+ :return: vector with accumulated rewards
166
+ """
167
+ res = np.zeros(rewards[0].shape[0], dtype=np.float32)
168
+ for r, d in zip(reversed(rewards), reversed(dones)):
169
+ res *= gamma * (1. - d)
170
+ res += r
171
+ return res
172
+
173
+
174
+ class VectorExperienceSourceFirstLast(ExperienceSource):
175
+ """
176
+ ExperienceSourceFirstLast which supports VectorEnv from Gymnasium.
177
+ """
178
+ def __init__(self, env: gym.vector.VectorEnv, agent: BaseAgent,
179
+ gamma: float, steps_count: int = 1, env_seed: tt.Optional[int] = None,
180
+ unnest_data: bool = True):
181
+ """
182
+ Construct vectorized version of ExperienceSourceFirstLast
183
+ :param env: vectorized environment
184
+ :param agent: agent to use
185
+ :param gamma: gamma for reward calculation
186
+ :param steps_count: count of steps
187
+ :param env_seed: seed for environments reset
188
+ :param unnest_data: should we unnest data in the iterator. If True (default)
189
+ ExperienceFirstLast will be yielded sequentially. If False, we'll keep them in a list as we
190
+ got them from env vector.
191
+ """
192
+ super().__init__(env, agent, steps_count+1, steps_delta=1, env_seed=env_seed)
193
+ self.env = env
194
+ self.gamma = gamma
195
+ self.steps = steps_count
196
+ self.unnest_data = unnest_data
197
+ self.agent_state = self.agent_states[0]
198
+
199
+ def _iter_env_idx_obs_next(self, b_obs, b_next_obs) -> tt.Generator[tt.Tuple[
200
+ int, tt.Any, tt.Any
201
+ ], None, None]:
202
+ """
203
+ Iterate over individual environment observations and next observations.
204
+ Take into account Tuple observation space (which is handled specially in Vectorized envs)
205
+ :param b_obs: vectorized observations
206
+ :param b_next_obs: vectorized next observations
207
+ :yields: Tuple of index, observation and next observation
208
+ """
209
+ if isinstance(self.env.single_observation_space, gym.spaces.Tuple):
210
+ obs_iter = zip(*b_obs)
211
+ next_obs_iter = zip(*b_next_obs)
212
+ else:
213
+ obs_iter = b_obs
214
+ next_obs_iter = b_next_obs
215
+ yield from zip(range(self.env.num_envs), obs_iter, next_obs_iter)
216
+
217
+ def __iter__(self) -> tt.Generator[tt.List[ExperienceFirstLast] | ExperienceFirstLast, None, None]:
218
+ q_states = collections.deque(maxlen=self.steps+1)
219
+ q_actions = collections.deque(maxlen=self.steps+1)
220
+ q_rewards = collections.deque(maxlen=self.steps+1)
221
+ q_dones = collections.deque(maxlen=self.steps+1)
222
+ total_rewards = np.zeros(self.env.num_envs, dtype=np.float32)
223
+ total_steps = np.zeros_like(total_rewards, dtype=np.int64)
224
+
225
+ if self.env_seed is not None:
226
+ obs, _ = self.env.reset(seed=self.env_seed)
227
+ else:
228
+ obs, _ = self.env.reset()
229
+ env_indices = np.arange(self.env.num_envs)
230
+
231
+ while True:
232
+ q_states.append(obs)
233
+ actions, self.agent_state = self.agent(obs, self.agent_state)
234
+ q_actions.append(actions)
235
+ next_obs, r, is_done, is_tr, _ = self.env.step(actions)
236
+ total_rewards += r
237
+ total_steps += 1
238
+ done_or_tr = is_done | is_tr
239
+ q_rewards.append(r)
240
+ q_dones.append(done_or_tr)
241
+
242
+ # process environments which are done at this step
243
+ if done_or_tr.any():
244
+ indices = env_indices[done_or_tr]
245
+ self.total_steps.extend(total_steps[indices])
246
+ self.total_rewards.extend(total_rewards[indices])
247
+ total_steps[indices] = 0
248
+ total_rewards[indices] = 0.0
249
+
250
+ if len(q_states) == q_states.maxlen:
251
+ # enough data for calculation
252
+ results = []
253
+ rewards = vector_rewards(q_rewards, q_dones, self.gamma)
254
+ for i, e_obs, e_next_obs in self._iter_env_idx_obs_next(q_states[0], next_obs):
255
+ # if anywhere in the trajectory we have ended episode flag,
256
+ # the last state will be None
257
+ ep_ended = any(map(lambda d: d[i], q_dones))
258
+ last_state = e_next_obs if not ep_ended else None
259
+ results.append(ExperienceFirstLast(
260
+ state=e_obs,
261
+ action=q_actions[0][i],
262
+ reward=rewards[i],
263
+ last_state=last_state,
264
+ ))
265
+ if self.unnest_data:
266
+ yield from results
267
+ else:
268
+ yield results
269
+ obs = next_obs
270
+
271
+
272
+ def discount_with_dones(rewards, dones, gamma):
273
+ discounted = []
274
+ r = 0
275
+ for reward, done in zip(rewards[::-1], dones[::-1]):
276
+ r = reward + gamma*r*(1.-done)
277
+ discounted.append(r)
278
+ return discounted[::-1]
279
+
280
+
281
+ class ExperienceSourceRollouts:
282
+ """
283
+ TODO: need to be updated
284
+ N-step rollout experience source following A3C rollouts scheme. Have to be used with agent,
285
+ keeping the value in its state (for example, agent.ActorCriticAgent).
286
+
287
+ Yields batches of num_envs * n_steps samples with the following arrays:
288
+ 1. observations
289
+ 2. actions
290
+ 3. discounted rewards, with values approximation
291
+ 4. values
292
+ """
293
+ def __init__(self, env, agent, gamma, steps_count=5):
294
+ """
295
+ Constructs the rollout experience source
296
+ :param env: environment or list of environments to be used
297
+ :param agent: callable to convert batch of states into actions
298
+ :param steps_count: how many steps to perform rollouts
299
+ """
300
+ assert isinstance(env, (gym.Env, list, tuple))
301
+ assert isinstance(agent, BaseAgent)
302
+ assert isinstance(gamma, float)
303
+ assert isinstance(steps_count, int)
304
+ assert steps_count >= 1
305
+
306
+ if isinstance(env, (list, tuple)):
307
+ self.pool = env
308
+ else:
309
+ self.pool = [env]
310
+ self.agent = agent
311
+ self.gamma = gamma
312
+ self.steps_count = steps_count
313
+ self.total_rewards = []
314
+ self.total_steps = []
315
+
316
+ def __iter__(self):
317
+ pool_size = len(self.pool)
318
+ states = [np.array(e.reset()) for e in self.pool]
319
+ mb_states = np.zeros((pool_size, self.steps_count) + states[0].shape, dtype=states[0].dtype)
320
+ mb_rewards = np.zeros((pool_size, self.steps_count), dtype=np.float32)
321
+ mb_values = np.zeros((pool_size, self.steps_count), dtype=np.float32)
322
+ mb_actions = np.zeros((pool_size, self.steps_count), dtype=np.int64)
323
+ mb_dones = np.zeros((pool_size, self.steps_count), dtype=np.bool)
324
+ total_rewards = [0.0] * pool_size
325
+ total_steps = [0] * pool_size
326
+ agent_states = None
327
+ step_idx = 0
328
+
329
+ while True:
330
+ actions, agent_states = self.agent(states, agent_states)
331
+ rewards = []
332
+ dones = []
333
+ new_states = []
334
+ for env_idx, (e, action) in enumerate(zip(self.pool, actions)):
335
+ o, r, done, _ = e.step(action)
336
+ total_rewards[env_idx] += r
337
+ total_steps[env_idx] += 1
338
+ if done:
339
+ o = e.reset()
340
+ self.total_rewards.append(total_rewards[env_idx])
341
+ self.total_steps.append(total_steps[env_idx])
342
+ total_rewards[env_idx] = 0.0
343
+ total_steps[env_idx] = 0
344
+ new_states.append(np.array(o))
345
+ dones.append(done)
346
+ rewards.append(r)
347
+ # we need an extra step to get values approximation for rollouts
348
+ if step_idx == self.steps_count:
349
+ # calculate rollout rewards
350
+ for env_idx, (env_rewards, env_dones, last_value) in enumerate(zip(mb_rewards, mb_dones, agent_states)):
351
+ env_rewards = env_rewards.tolist()
352
+ env_dones = env_dones.tolist()
353
+ if not env_dones[-1]:
354
+ env_rewards = discount_with_dones(env_rewards + [last_value], env_dones + [False], self.gamma)[:-1]
355
+ else:
356
+ env_rewards = discount_with_dones(env_rewards, env_dones, self.gamma)
357
+ mb_rewards[env_idx] = env_rewards
358
+ yield mb_states.reshape((-1,) + mb_states.shape[2:]), mb_rewards.flatten(), mb_actions.flatten(), mb_values.flatten()
359
+ step_idx = 0
360
+ mb_states[:, step_idx] = states
361
+ mb_rewards[:, step_idx] = rewards
362
+ mb_values[:, step_idx] = agent_states
363
+ mb_actions[:, step_idx] = actions
364
+ mb_dones[:, step_idx] = dones
365
+ step_idx += 1
366
+ states = new_states
367
+
368
+ def pop_total_rewards(self):
369
+ r = self.total_rewards
370
+ if r:
371
+ self.total_rewards = []
372
+ self.total_steps = []
373
+ return r
374
+
375
+ def pop_rewards_steps(self):
376
+ res = list(zip(self.total_rewards, self.total_steps))
377
+ if res:
378
+ self.total_rewards, self.total_steps = [], []
379
+ return res
380
+
381
+
382
+ class ExperienceSourceBuffer:
383
+ """
384
+ The same as ExperienceSource, but takes episodes from the buffer
385
+ """
386
+ def __init__(self, buffer, steps_count=1):
387
+ """
388
+ Create buffered experience source
389
+ :param buffer: list of episodes, each is a list of Experience object
390
+ :param steps_count: count of steps in every entry
391
+ """
392
+ self.update_buffer(buffer)
393
+ self.steps_count = steps_count
394
+
395
+ def update_buffer(self, buffer):
396
+ self.buffer = buffer
397
+ self.lens = list(map(len, buffer))
398
+
399
+ def __iter__(self):
400
+ """
401
+ Infinitely sample episode from the buffer and then sample item offset
402
+ """
403
+ while True:
404
+ episode = random.randrange(len(self.buffer))
405
+ ofs = random.randrange(self.lens[episode] - self.steps_count - 1)
406
+ yield self.buffer[episode][ofs:ofs+self.steps_count]
407
+
408
+
409
+ class ExperienceReplayBuffer:
410
+ def __init__(self, experience_source: tt.Optional[ExperienceSource], buffer_size: int):
411
+ self.experience_source_iter = None if experience_source is None else iter(experience_source)
412
+ self.buffer: tt.List[ExperienceSource.Item] = []
413
+ self.capacity = buffer_size
414
+ self.pos = 0
415
+
416
+ def __len__(self):
417
+ return len(self.buffer)
418
+
419
+ def __iter__(self) -> tt.Iterator[ExperienceSource.Item]:
420
+ return iter(self.buffer)
421
+
422
+ def sample(self, batch_size: int) -> tt.List[ExperienceSource.Item]:
423
+ """
424
+ Get one random batch from experience replay
425
+ :param batch_size: size of the batch to sample
426
+ :return: list of experience entries
427
+ """
428
+ if len(self.buffer) <= batch_size:
429
+ return self.buffer
430
+ # Warning: replace=False makes random.choice O(n)
431
+ keys = np.random.choice(len(self.buffer), batch_size, replace=True)
432
+ return [self.buffer[key] for key in keys]
433
+
434
+ def _add(self, sample: ExperienceSource.Item):
435
+ if len(self.buffer) < self.capacity:
436
+ self.buffer.append(sample)
437
+ else:
438
+ self.buffer[self.pos] = sample
439
+ self.pos = (self.pos + 1) % self.capacity
440
+
441
+ def populate(self, samples: int):
442
+ """
443
+ Populates samples into the buffer
444
+ :param samples: how many samples to populate
445
+ """
446
+ for _ in range(samples):
447
+ entry = next(self.experience_source_iter)
448
+ self._add(entry)
449
+
450
+
451
+ class PrioReplayBufferNaive:
452
+ def __init__(self, exp_source, buf_size, prob_alpha=0.6):
453
+ self.exp_source_iter = iter(exp_source)
454
+ self.prob_alpha = prob_alpha
455
+ self.capacity = buf_size
456
+ self.pos = 0
457
+ self.buffer = []
458
+ self.priorities = np.zeros((buf_size, ), dtype=np.float32)
459
+
460
+ def __len__(self):
461
+ return len(self.buffer)
462
+
463
+ def populate(self, count):
464
+ max_prio = self.priorities.max() if self.buffer else 1.0
465
+ for _ in range(count):
466
+ sample = next(self.exp_source_iter)
467
+ if len(self.buffer) < self.capacity:
468
+ self.buffer.append(sample)
469
+ else:
470
+ self.buffer[self.pos] = sample
471
+ self.priorities[self.pos] = max_prio
472
+ self.pos = (self.pos + 1) % self.capacity
473
+
474
+ def sample(self, batch_size, beta=0.4):
475
+ if len(self.buffer) == self.capacity:
476
+ prios = self.priorities
477
+ else:
478
+ prios = self.priorities[:self.pos]
479
+ probs = np.array(prios, dtype=np.float32) ** self.prob_alpha
480
+
481
+ probs /= probs.sum()
482
+ indices = np.random.choice(len(self.buffer), batch_size, p=probs, replace=True)
483
+ samples = [self.buffer[idx] for idx in indices]
484
+ total = len(self.buffer)
485
+ weights = (total * probs[indices]) ** (-beta)
486
+ weights /= weights.max()
487
+ return samples, indices, np.array(weights, dtype=np.float32)
488
+
489
+ def update_priorities(self, batch_indices, batch_priorities):
490
+ for idx, prio in zip(batch_indices, batch_priorities):
491
+ self.priorities[idx] = prio
492
+
493
+
494
+
495
+ class BatchPreprocessor:
496
+ """
497
+ Abstract preprocessor class descendants to which converts experience
498
+ batch to form suitable to learning.
499
+ """
500
+ def preprocess(self, batch):
501
+ raise NotImplementedError
502
+
503
+
504
+ class QLearningPreprocessor(BatchPreprocessor):
505
+ """
506
+ Supports SimpleDQN, TargetDQN, DoubleDQN and can additionally feed TD-error back to
507
+ experience replay buffer.
508
+
509
+ To use different modes, use appropriate class method
510
+ """
511
+ def __init__(self, model, target_model, use_double_dqn=False, batch_td_error_hook=None, gamma=0.99, device="cpu"):
512
+ self.model = model
513
+ self.target_model = target_model
514
+ self.use_double_dqn = use_double_dqn
515
+ self.batch_dt_error_hook = batch_td_error_hook
516
+ self.gamma = gamma
517
+ self.device = device
518
+
519
+ @staticmethod
520
+ def simple_dqn(model, **kwargs):
521
+ return QLearningPreprocessor(model=model, target_model=None, use_double_dqn=False, **kwargs)
522
+
523
+ @staticmethod
524
+ def target_dqn(model, target_model, **kwards):
525
+ return QLearningPreprocessor(model, target_model, use_double_dqn=False, **kwards)
526
+
527
+ @staticmethod
528
+ def double_dqn(model, target_model, **kwargs):
529
+ return QLearningPreprocessor(model, target_model, use_double_dqn=True, **kwargs)
530
+
531
+ def _calc_Q(self, states_first, states_last):
532
+ """
533
+ Calculates apropriate q values for first and last states. Way of calculate depends on our settings.
534
+ :param states_first: numpy array of first states
535
+ :param states_last: numpy array of last states
536
+ :return: tuple of numpy arrays of q values
537
+ """
538
+ # here we need both first and last values calculated using our main model, so we
539
+ # combine both states into one batch for efficiency and separate results later
540
+ if self.target_model is None or self.use_double_dqn:
541
+ states_t = torch.tensor(np.concatenate((states_first, states_last), axis=0)).to(self.device)
542
+ res_both = self.model(states_t).data.cpu().numpy()
543
+ return res_both[:len(states_first)], res_both[len(states_first):]
544
+
545
+ # in this case we have target_model set and use_double_dqn==False
546
+ # so, we should calculate first_q and last_q using different models
547
+ states_first_v = torch.tensor(states_first).to(self.device)
548
+ states_last_v = torch.tensor(states_last).to(self.device)
549
+ q_first = self.model(states_first_v).data
550
+ q_last = self.target_model(states_last_v).data
551
+ return q_first.cpu().numpy(), q_last.cpu().numpy()
552
+
553
+ def _calc_target_rewards(self, states_last, q_last):
554
+ """
555
+ Calculate rewards from final states according to variants from our construction:
556
+ 1. simple DQN: max(Q(states, model))
557
+ 2. target DQN: max(Q(states, target_model))
558
+ 3. double DQN: Q(states, target_model)[argmax(Q(states, model)]
559
+ :param states_last: numpy array of last states from the games
560
+ :param q_last: numpy array of last q values
561
+ :return: vector of target rewards
562
+ """
563
+ # in this case we handle both simple DQN and target DQN
564
+ if self.target_model is None or not self.use_double_dqn:
565
+ return q_last.max(axis=1)
566
+
567
+ # here we have target_model set and use_double_dqn==True
568
+ actions = q_last.argmax(axis=1)
569
+ # calculate Q values using target net
570
+ states_last_v = torch.tensor(states_last).to(self.device)
571
+ q_last_target = self.target_model(states_last_v).data.cpu().numpy()
572
+ return q_last_target[range(q_last_target.shape[0]), actions]
573
+
574
+ def preprocess(self, batch):
575
+ """
576
+ Calculates data for Q learning from batch of observations
577
+ :param batch: list of lists of Experience objects
578
+ :return: tuple of numpy arrays:
579
+ 1. states -- observations
580
+ 2. target Q-values
581
+ 3. vector of td errors for every batch entry
582
+ """
583
+ # first and last states for every entry
584
+ state_0 = np.array([exp[0].state for exp in batch], dtype=np.float32)
585
+ state_L = np.array([exp[-1].state for exp in batch], dtype=np.float32)
586
+
587
+ q0, qL = self._calc_Q(state_0, state_L)
588
+ rewards = self._calc_target_rewards(state_L, qL)
589
+
590
+ td = np.zeros(shape=(len(batch),))
591
+
592
+ for idx, (total_reward, exps) in enumerate(zip(rewards, batch)):
593
+ # game is done, no final reward
594
+ if exps[-1].done:
595
+ total_reward = 0.0
596
+ for exp in reversed(exps[:-1]):
597
+ total_reward *= self.gamma
598
+ total_reward += exp.reward
599
+ # update total reward and calculate td error
600
+ act = exps[0].action
601
+ td[idx] = q0[idx][act] - total_reward
602
+ q0[idx][act] = total_reward
603
+
604
+ return state_0, q0, td
@@ -0,0 +1,137 @@
1
+ import time
2
+ from typing import Optional
3
+ from ignite.engine import Engine, State
4
+ from ignite.engine import Events, EventEnum
5
+ from ignite.handlers.timing import Timer
6
+
7
+ from .experience import ExperienceSource
8
+
9
+ class EpisodeEvents(EventEnum):
10
+ EPISODE_COMPLETED = "episode_completed"
11
+ BOUND_REWARD_REACHED = "bound_reward_reached"
12
+ BEST_REWARD_REACHED = "best_reward_reached"
13
+
14
+
15
+ class EndOfEpisodeHandler:
16
+ def __init__(self, exp_source: ExperienceSource, alpha: float = 0.98,
17
+ bound_avg_reward: Optional[float] = None,
18
+ subsample_end_of_episode: Optional[int] = None):
19
+ """
20
+ Construct end-of-episode event handler
21
+ :param exp_source: experience source to use
22
+ :param alpha: smoothing alpha param
23
+ :param bound_avg_reward: optional boundary for average reward
24
+ :param subsample_end_of_episode: if given, end of episode event will be subsampled by this amount
25
+ """
26
+ self._exp_source = exp_source
27
+ self._alpha = alpha
28
+ self._bound_avg_reward = bound_avg_reward
29
+ self._best_avg_reward = None
30
+ self._subsample_end_of_episode = subsample_end_of_episode
31
+
32
+ def attach(self, engine: Engine):
33
+ engine.add_event_handler(Events.ITERATION_COMPLETED, self)
34
+ engine.register_events(*EpisodeEvents)
35
+ State.event_to_attr[EpisodeEvents.EPISODE_COMPLETED] = "episode"
36
+ State.event_to_attr[EpisodeEvents.BOUND_REWARD_REACHED] = "episode"
37
+ State.event_to_attr[EpisodeEvents.BEST_REWARD_REACHED] = "episode"
38
+
39
+ def __call__(self, engine: Engine):
40
+ for reward, steps in self._exp_source.pop_rewards_steps():
41
+ engine.state.episode = getattr(engine.state, "episode", 0) + 1
42
+ engine.state.episode_reward = reward
43
+ engine.state.episode_steps = steps
44
+ engine.state.metrics['reward'] = reward
45
+ engine.state.metrics['steps'] = steps
46
+ self._update_smoothed_metrics(engine, reward, steps)
47
+ if self._subsample_end_of_episode is None or engine.state.episode % self._subsample_end_of_episode == 0:
48
+ engine.fire_event(EpisodeEvents.EPISODE_COMPLETED)
49
+ if self._bound_avg_reward is not None and engine.state.metrics['avg_reward'] >= self._bound_avg_reward:
50
+ engine.fire_event(EpisodeEvents.BOUND_REWARD_REACHED)
51
+ if self._best_avg_reward is None:
52
+ self._best_avg_reward = engine.state.metrics['avg_reward']
53
+ elif self._best_avg_reward < engine.state.metrics['avg_reward']:
54
+ engine.fire_event(EpisodeEvents.BEST_REWARD_REACHED)
55
+ self._best_avg_reward = engine.state.metrics['avg_reward']
56
+
57
+ def _update_smoothed_metrics(self, engine: Engine, reward: float, steps: int):
58
+ for attr_name, val in zip(('avg_reward', 'avg_steps'), (reward, steps)):
59
+ if attr_name not in engine.state.metrics:
60
+ engine.state.metrics[attr_name] = val
61
+ else:
62
+ engine.state.metrics[attr_name] *= self._alpha
63
+ engine.state.metrics[attr_name] += (1-self._alpha) * val
64
+
65
+
66
+ class EpisodeFPSHandler:
67
+ FPS_METRIC = 'fps'
68
+ AVG_FPS_METRIC = 'avg_fps'
69
+ TIME_PASSED_METRIC = 'time_passed'
70
+
71
+ def __init__(self, fps_mul: float = 1.0, fps_smooth_alpha: float = 0.98):
72
+ self._timer = Timer(average=True)
73
+ self._fps_mul = fps_mul
74
+ self._started_ts = time.time()
75
+ self._fps_smooth_alpha = fps_smooth_alpha
76
+
77
+ def attach(self, engine: Engine, manual_step: bool = False):
78
+ self._timer.attach(engine, step=None if manual_step else Events.ITERATION_COMPLETED)
79
+ engine.add_event_handler(EpisodeEvents.EPISODE_COMPLETED, self)
80
+ engine.state.metrics[self.AVG_FPS_METRIC] = 0
81
+
82
+ def step(self):
83
+ """
84
+ If manual_step=True on attach(), this method should be used every time we've communicated with environment
85
+ to get proper FPS
86
+ :return:
87
+ """
88
+ self._timer.step()
89
+
90
+ def __call__(self, engine: Engine):
91
+ t_val = self._timer.value()
92
+ if engine.state.iteration > 1:
93
+ fps = self._fps_mul / t_val
94
+ avg_fps = engine.state.metrics.get(self.AVG_FPS_METRIC)
95
+ if avg_fps is None or avg_fps <= 0:
96
+ avg_fps = fps
97
+ else:
98
+ avg_fps *= self._fps_smooth_alpha
99
+ avg_fps += (1-self._fps_smooth_alpha) * fps
100
+ engine.state.metrics[self.AVG_FPS_METRIC] = avg_fps
101
+ engine.state.metrics[self.FPS_METRIC] = fps
102
+ engine.state.metrics[self.TIME_PASSED_METRIC] = time.time() - self._started_ts
103
+ self._timer.reset()
104
+
105
+
106
+ class PeriodEvents(EventEnum):
107
+ ITERS_10_COMPLETED = "iterations_10_completed"
108
+ ITERS_100_COMPLETED = "iterations_100_completed"
109
+ ITERS_1000_COMPLETED = "iterations_1000_completed"
110
+ ITERS_10000_COMPLETED = "iterations_10000_completed"
111
+ ITERS_100000_COMPLETED = "iterations_100000_completed"
112
+
113
+
114
+ class PeriodicEvents:
115
+ """
116
+ The same as CustomPeriodicEvent from ignite.contrib, but use true amount of iterations,
117
+ which is good for TensorBoard
118
+ """
119
+
120
+ INTERVAL_TO_EVENT = {
121
+ 10: PeriodEvents.ITERS_10_COMPLETED,
122
+ 100: PeriodEvents.ITERS_100_COMPLETED,
123
+ 1000: PeriodEvents.ITERS_1000_COMPLETED,
124
+ 10000: PeriodEvents.ITERS_10000_COMPLETED,
125
+ 100000: PeriodEvents.ITERS_100000_COMPLETED,
126
+ }
127
+
128
+ def attach(self, engine: Engine):
129
+ engine.add_event_handler(Events.ITERATION_COMPLETED, self)
130
+ engine.register_events(*PeriodEvents)
131
+ for e in PeriodEvents:
132
+ State.event_to_attr[e] = "iteration"
133
+
134
+ def __call__(self, engine: Engine):
135
+ for period, event in self.INTERVAL_TO_EVENT.items():
136
+ if engine.state.iteration % period == 0:
137
+ engine.fire_event(event)
File without changes
File without changes
File without changes
File without changes