dragon-torch 1.1.0__py3-none-any.whl
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.
- dragon_torch/__init__.py +7 -0
- dragon_torch/common/__init__.py +0 -0
- dragon_torch/common/defaults.py +6 -0
- dragon_torch/common/torch/__init__.py +0 -0
- dragon_torch/common/torch/tree.py +35 -0
- dragon_torch/common/torch/types.py +7 -0
- dragon_torch/common/types.py +22 -0
- dragon_torch/common/utils.py +6 -0
- dragon_torch/data/RL/__init__.py +3 -0
- dragon_torch/data/RL/collector.py +169 -0
- dragon_torch/data/RL/episodes.py +47 -0
- dragon_torch/data/RL/rollout.py +122 -0
- dragon_torch/data/__init__.py +0 -0
- dragon_torch/data/dataloaders.py +1 -0
- dragon_torch/data/datasets/__init__.py +0 -0
- dragon_torch/data/datasets/aggregator.py +120 -0
- dragon_torch/data/datasets/dragon.py +74 -0
- dragon_torch/data/datasets/hugging_face/__init__.py +0 -0
- dragon_torch/data/datasets/hugging_face/noise_zoo.py +0 -0
- dragon_torch/data/datasets/hugging_face/semasia/__init__.py +0 -0
- dragon_torch/data/datasets/hugging_face/semasia/config.py +23 -0
- dragon_torch/data/datasets/hugging_face/semasia/semasia_celeba.py +44 -0
- dragon_torch/data/datasets/hugging_face/semasia/semasia_imagenet_1k.py +42 -0
- dragon_torch/data/envs/__init__.py +5 -0
- dragon_torch/data/envs/config.py +77 -0
- dragon_torch/data/envs/normalize.py +77 -0
- dragon_torch/data/envs/spaces.py +39 -0
- dragon_torch/data/envs/wrappers.py +33 -0
- dragon_torch/data/sources/__init__.py +7 -0
- dragon_torch/data/sources/base.py +55 -0
- dragon_torch/data/sources/disk.py +66 -0
- dragon_torch/data/sources/hf.py +130 -0
- dragon_torch/loss/RL.py +80 -0
- dragon_torch/loss/__init__.py +1 -0
- dragon_torch/loss/base.py +29 -0
- dragon_torch/models/RL/__init__.py +1 -0
- dragon_torch/models/RL/base.py +112 -0
- dragon_torch/models/__init__.py +0 -0
- dragon_torch/models/base.py +395 -0
- dragon_torch/models/external/__init__.py +0 -0
- dragon_torch/models/external/hf.py +26 -0
- dragon_torch/modules/RL/__init__.py +3 -0
- dragon_torch/modules/RL/heads.py +110 -0
- dragon_torch/modules/RL/policy.py +91 -0
- dragon_torch/modules/__init__.py +1 -0
- dragon_torch/modules/base.py +20 -0
- dragon_torch/modules/external/__init__.py +0 -0
- dragon_torch/modules/external/sb3.py +31 -0
- dragon_torch/modules/hugging_face/__init__.py +0 -0
- dragon_torch/modules/hugging_face/image_transformer.py +25 -0
- dragon_torch/trainers/RL/A2C/__init__.py +2 -0
- dragon_torch/trainers/RL/A2C/config.py +22 -0
- dragon_torch/trainers/RL/A2C/trainer.py +18 -0
- dragon_torch/trainers/RL/PPO/__init__.py +2 -0
- dragon_torch/trainers/RL/PPO/config.py +19 -0
- dragon_torch/trainers/RL/PPO/trainer.py +18 -0
- dragon_torch/trainers/RL/__init__.py +6 -0
- dragon_torch/trainers/RL/callbacks.py +116 -0
- dragon_torch/trainers/RL/config.py +186 -0
- dragon_torch/trainers/RL/trainer.py +137 -0
- dragon_torch/trainers/__init__.py +3 -0
- dragon_torch/trainers/base.py +112 -0
- dragon_torch/utils/__init__.py +2 -0
- dragon_torch/utils/random_sources.py +64 -0
- dragon_torch/utils/tuning/RL.py +49 -0
- dragon_torch/utils/tuning/__init__.py +7 -0
- dragon_torch/utils/tuning/config.py +122 -0
- dragon_torch/utils/tuning/monitor.py +130 -0
- dragon_torch/utils/tuning/objective.py +157 -0
- dragon_torch/utils/tuning/presets/__init__.py +2 -0
- dragon_torch/utils/tuning/presets/a2c.py +19 -0
- dragon_torch/utils/tuning/presets/ppo.py +31 -0
- dragon_torch/utils/tuning/reporting.py +71 -0
- dragon_torch/utils/tuning/space.py +90 -0
- dragon_torch/utils/tuning/study.py +51 -0
- dragon_torch-1.1.0.dist-info/METADATA +118 -0
- dragon_torch-1.1.0.dist-info/RECORD +80 -0
- dragon_torch-1.1.0.dist-info/WHEEL +4 -0
- dragon_torch-1.1.0.dist-info/entry_points.txt +2 -0
- dragon_torch-1.1.0.dist-info/licenses/LICENSE +21 -0
dragon_torch/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""dragon-torch: PyTorch deep learning research framework, focused on RL."""
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
__version__ = version('dragon-torch')
|
|
6
|
+
except PackageNotFoundError: # running from a source checkout that isn't installed
|
|
7
|
+
__version__ = '0.0.0'
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tiny pytree helpers for observations that may be arrays, dicts or tuples."""
|
|
2
|
+
from typing import Any, Callable, Sequence
|
|
3
|
+
import torch
|
|
4
|
+
|
|
5
|
+
type Tree = Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tree_map(fn: Callable[[Any], Any], tree: Tree) -> Tree:
|
|
9
|
+
if isinstance(tree, dict):
|
|
10
|
+
return {k: tree_map(fn, v) for k, v in tree.items()}
|
|
11
|
+
if isinstance(tree, tuple):
|
|
12
|
+
return tuple(tree_map(fn, v) for v in tree)
|
|
13
|
+
return fn(tree)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tree_stack(trees: Sequence[Tree], dim: int = 0) -> Tree:
|
|
17
|
+
"""Stack a list of same-structured trees of tensors leaf-wise."""
|
|
18
|
+
first = trees[0]
|
|
19
|
+
if isinstance(first, dict):
|
|
20
|
+
return {k: tree_stack([t[k] for t in trees], dim) for k in first}
|
|
21
|
+
if isinstance(first, tuple):
|
|
22
|
+
return tuple(tree_stack([t[i] for t in trees], dim) for i in range(len(first)))
|
|
23
|
+
return torch.stack(list(trees), dim)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def tree_set(tree: Tree, index: int, value: Tree) -> None:
|
|
27
|
+
"""In place: tree[index] = value, leaf-wise (for batched numpy / torch leaves)."""
|
|
28
|
+
if isinstance(tree, dict):
|
|
29
|
+
for k in tree:
|
|
30
|
+
tree_set(tree[k], index, value[k])
|
|
31
|
+
elif isinstance(tree, tuple):
|
|
32
|
+
for leaf, v in zip(tree, value):
|
|
33
|
+
tree_set(leaf, index, v)
|
|
34
|
+
else:
|
|
35
|
+
tree[index] = value
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from typing import Union, Literal
|
|
2
|
+
|
|
3
|
+
type DeviceLike = Union[Literal['cpu'], Literal['cuda']]
|
|
4
|
+
type TorchFloatKind = Union[Literal['float'], Literal['float16'],
|
|
5
|
+
Literal['float32'], Literal['float64']]
|
|
6
|
+
type TorchIntegerKind = Union[Literal['int'], Literal['int8'], Literal['int16'],
|
|
7
|
+
Literal['int64'], Literal['int32']]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dragon_torch.common.torch.types import DeviceLike, TorchFloatKind, TorchIntegerKind
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from typing import Union, Literal, List
|
|
6
|
+
from .defaults import numpy_default_dtype
|
|
7
|
+
load_dotenv('./.env')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ------- Global ------- #
|
|
11
|
+
type PathLike = Union[Path, str]
|
|
12
|
+
|
|
13
|
+
# ------- DatasetDefinition ------- #
|
|
14
|
+
type BackendTypes = Union[Literal['jax'],
|
|
15
|
+
Literal['numpy'], Literal['pandas'], Literal['torch']]
|
|
16
|
+
type DatasetSplitType = Union[Literal['train'],
|
|
17
|
+
Literal['test'], Literal['valid']]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ------- Torch Types ------- #
|
|
21
|
+
type TorchNumeric = Union[TorchIntegerKind, TorchFloatKind]
|
|
22
|
+
type FileExtensionLike = List[str | None]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Env interaction shared by every RL data source.
|
|
2
|
+
|
|
3
|
+
EnvCollector steps a vectorised env with any `Actor` and returns `Transition`s
|
|
4
|
+
that already carry the *true* successor obs (info['final_obs'] substituted on
|
|
5
|
+
episode end). On-policy sources (RolloutSource) and off-policy sources (a
|
|
6
|
+
replay source storing (obs, action, reward, next_obs, terminated)) both build
|
|
7
|
+
on it, so autoreset, normalisation, episode stats and step counting live here once.
|
|
8
|
+
"""
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Optional, Protocol, runtime_checkable
|
|
12
|
+
import numpy as np
|
|
13
|
+
import torch
|
|
14
|
+
from gymnasium import spaces
|
|
15
|
+
from dragon_torch.common.torch.tree import tree_map, tree_set
|
|
16
|
+
from dragon_torch.data.envs.config import DragonEnvConfig, make_vec_env
|
|
17
|
+
from dragon_torch.data.envs.normalize import ObsNormalizer, RewardScaler
|
|
18
|
+
from dragon_torch.data.envs.spaces import action_to_env
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ActOutput:
|
|
23
|
+
action: torch.Tensor # batched, in the policy's own space (what buffers store)
|
|
24
|
+
extras: dict[str, torch.Tensor] = field(default_factory=dict) # e.g. log_prob, value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class Actor(Protocol):
|
|
29
|
+
"""What sources need from a model. Off-policy models implement exploration
|
|
30
|
+
(e.g. epsilon-greedy) inside `act`."""
|
|
31
|
+
|
|
32
|
+
def act(self, obs: Any, deterministic: bool = False) -> ActOutput: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class ValueActor(Actor, Protocol):
|
|
37
|
+
"""Actor with a state-value estimate, needed for GAE bootstrapping."""
|
|
38
|
+
|
|
39
|
+
def value(self, obs: Any) -> torch.Tensor: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Transition:
|
|
44
|
+
"""One vector step. obs / next_obs are (normalised) numpy batches."""
|
|
45
|
+
obs: Any
|
|
46
|
+
action: torch.Tensor
|
|
47
|
+
reward: np.ndarray # float32, scaled if reward normalisation is on
|
|
48
|
+
next_obs: Any # true successor: final obs where an episode ended
|
|
49
|
+
terminated: np.ndarray
|
|
50
|
+
truncated: np.ndarray
|
|
51
|
+
extras: dict[str, torch.Tensor]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def done(self) -> np.ndarray:
|
|
55
|
+
return self.terminated | self.truncated
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EnvCollector:
|
|
59
|
+
def __init__(self, env_cfg: DragonEnvConfig, normalize_obs: bool = False,
|
|
60
|
+
normalize_reward: bool = False, gamma: float = 0.99,
|
|
61
|
+
update_stats: bool = True):
|
|
62
|
+
self.env_cfg = env_cfg
|
|
63
|
+
self.env = make_vec_env(env_cfg)
|
|
64
|
+
self.n_envs = self.env.num_envs
|
|
65
|
+
self.observation_space = self.env.single_observation_space
|
|
66
|
+
self.action_space = self.env.single_action_space
|
|
67
|
+
if normalize_obs and not isinstance(self.observation_space, spaces.Box):
|
|
68
|
+
raise TypeError(f"normalize_obs needs a Box observation space, got "
|
|
69
|
+
f"{self.observation_space}; normalise inside your encoder instead")
|
|
70
|
+
self.obs_normalizer: Optional[ObsNormalizer] = \
|
|
71
|
+
ObsNormalizer(self.observation_space.shape) if normalize_obs else None
|
|
72
|
+
self.reward_scaler: Optional[RewardScaler] = \
|
|
73
|
+
RewardScaler(self.n_envs, gamma) if normalize_reward else None
|
|
74
|
+
# False for evaluation: use (shared) stats without changing them
|
|
75
|
+
self.update_stats = update_stats
|
|
76
|
+
self.global_step = 0
|
|
77
|
+
self._obs: Any = None
|
|
78
|
+
self._ep_return = np.zeros(self.n_envs)
|
|
79
|
+
self._ep_length = np.zeros(self.n_envs, np.int64)
|
|
80
|
+
self._finished: list[tuple[float, int]] = []
|
|
81
|
+
self._clock = (time.perf_counter(), 0)
|
|
82
|
+
|
|
83
|
+
# ------- obs ------- #
|
|
84
|
+
def _normalize(self, raw: Any) -> Any:
|
|
85
|
+
return raw if self.obs_normalizer is None else self.obs_normalizer.normalize(raw)
|
|
86
|
+
|
|
87
|
+
def _observe(self, raw: Any) -> Any:
|
|
88
|
+
if self.obs_normalizer is not None and self.update_stats:
|
|
89
|
+
self.obs_normalizer.update(raw)
|
|
90
|
+
return self._normalize(raw)
|
|
91
|
+
|
|
92
|
+
def reset(self, seed: Optional[int] = None) -> Any:
|
|
93
|
+
raw, _ = self.env.reset(seed=self.env_cfg.seed if seed is None else seed)
|
|
94
|
+
self._obs = self._observe(raw)
|
|
95
|
+
self._ep_return[:] = 0
|
|
96
|
+
self._ep_length[:] = 0
|
|
97
|
+
return self._obs
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def obs(self) -> Any:
|
|
101
|
+
"""Current (normalised) obs batch; resets the env on first use."""
|
|
102
|
+
if self._obs is None:
|
|
103
|
+
self.reset()
|
|
104
|
+
return self._obs
|
|
105
|
+
|
|
106
|
+
# ------- stepping ------- #
|
|
107
|
+
def step(self, actor: Actor, deterministic: bool = False) -> Transition:
|
|
108
|
+
obs = self.obs
|
|
109
|
+
out = actor.act(obs, deterministic=deterministic)
|
|
110
|
+
raw_next, reward, terminated, truncated, info = self.env.step(
|
|
111
|
+
action_to_env(out.action, self.action_space))
|
|
112
|
+
done = terminated | truncated
|
|
113
|
+
|
|
114
|
+
next_policy_obs = self._observe(raw_next) # reset obs where done
|
|
115
|
+
next_obs = next_policy_obs
|
|
116
|
+
if done.any():
|
|
117
|
+
true_next = tree_map(np.copy, raw_next)
|
|
118
|
+
for i in np.flatnonzero(done):
|
|
119
|
+
tree_set(true_next, i, info['final_obs'][i])
|
|
120
|
+
next_obs = self._normalize(true_next)
|
|
121
|
+
|
|
122
|
+
self._ep_return += reward
|
|
123
|
+
self._ep_length += 1
|
|
124
|
+
for i in np.flatnonzero(done):
|
|
125
|
+
self._finished.append((float(self._ep_return[i]), int(self._ep_length[i])))
|
|
126
|
+
self._ep_return[i], self._ep_length[i] = 0, 0
|
|
127
|
+
|
|
128
|
+
train_reward = reward if self.reward_scaler is None else \
|
|
129
|
+
self.reward_scaler(reward, done, update=self.update_stats)
|
|
130
|
+
self.global_step += self.n_envs
|
|
131
|
+
self._obs = next_policy_obs
|
|
132
|
+
return Transition(obs=obs, action=out.action, reward=np.asarray(train_reward, np.float32),
|
|
133
|
+
next_obs=next_obs, terminated=terminated, truncated=truncated,
|
|
134
|
+
extras=out.extras)
|
|
135
|
+
|
|
136
|
+
# ------- stats / state ------- #
|
|
137
|
+
def drain_episodes(self) -> list[tuple[float, int]]:
|
|
138
|
+
"""(raw return, length) of episodes finished since the last drain."""
|
|
139
|
+
finished, self._finished = self._finished, []
|
|
140
|
+
return finished
|
|
141
|
+
|
|
142
|
+
def metrics(self) -> dict[str, float]:
|
|
143
|
+
now, steps = time.perf_counter(), self.global_step
|
|
144
|
+
then, steps_then = self._clock
|
|
145
|
+
self._clock = (now, steps)
|
|
146
|
+
record = {'global_step': steps, 'steps_per_sec': (steps - steps_then) / max(now - then, 1e-9)}
|
|
147
|
+
episodes = self.drain_episodes()
|
|
148
|
+
if episodes:
|
|
149
|
+
record['episode_return'] = float(np.mean([r for r, _ in episodes]))
|
|
150
|
+
record['episode_length'] = float(np.mean([l for _, l in episodes]))
|
|
151
|
+
record['episodes'] = len(episodes)
|
|
152
|
+
return record
|
|
153
|
+
|
|
154
|
+
def state_dict(self) -> dict[str, Any]:
|
|
155
|
+
# the env itself can't be checkpointed; a resumed run starts fresh episodes
|
|
156
|
+
return {'global_step': self.global_step,
|
|
157
|
+
'obs_normalizer': None if self.obs_normalizer is None else self.obs_normalizer.state_dict(),
|
|
158
|
+
'reward_scaler': None if self.reward_scaler is None else self.reward_scaler.state_dict()}
|
|
159
|
+
|
|
160
|
+
def load_state_dict(self, state: dict[str, Any]) -> None:
|
|
161
|
+
self.global_step = state['global_step']
|
|
162
|
+
self._clock = (time.perf_counter(), self.global_step)
|
|
163
|
+
for name in ('obs_normalizer', 'reward_scaler'):
|
|
164
|
+
target = getattr(self, name)
|
|
165
|
+
if target is not None and state.get(name) is not None:
|
|
166
|
+
target.load_state_dict(state[name])
|
|
167
|
+
|
|
168
|
+
def close(self) -> None:
|
|
169
|
+
self.env.close()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Any, Iterator, Optional
|
|
2
|
+
from dragon_torch.data.envs.config import DragonEnvConfig
|
|
3
|
+
from dragon_torch.data.RL.collector import Actor, EnvCollector
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EpisodeSource:
|
|
7
|
+
"""Evaluation source: iterating runs the bound model until n_episodes finish,
|
|
8
|
+
yielding {'return', 'length'} per episode (raw, unscaled rewards).
|
|
9
|
+
|
|
10
|
+
Every iteration re-seeds the env with env_cfg.seed, so successive evaluations
|
|
11
|
+
of the same policy are comparable. On bind it borrows the training source's
|
|
12
|
+
observation normaliser (read-only) so the policy sees the inputs it trained on.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, env_cfg: DragonEnvConfig, n_episodes: int = 10,
|
|
16
|
+
deterministic: bool = True, max_steps: int = 1_000_000):
|
|
17
|
+
self.collector = EnvCollector(env_cfg, update_stats=False)
|
|
18
|
+
self.n_episodes, self.deterministic, self.max_steps = n_episodes, deterministic, max_steps
|
|
19
|
+
self.actor: Optional[Actor] = None
|
|
20
|
+
|
|
21
|
+
def bind(self, model: Actor) -> None:
|
|
22
|
+
if not isinstance(model, Actor):
|
|
23
|
+
raise TypeError(f"EpisodeSource needs a model with act(), got {type(model)}")
|
|
24
|
+
self.actor = model
|
|
25
|
+
train_collector = getattr(getattr(model, 'train_source', None), 'collector', None)
|
|
26
|
+
self.collector.obs_normalizer = getattr(train_collector, 'obs_normalizer', None)
|
|
27
|
+
|
|
28
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
29
|
+
if self.actor is None:
|
|
30
|
+
raise RuntimeError("EpisodeSource is unbound; evaluate() binds the model")
|
|
31
|
+
self.collector.reset()
|
|
32
|
+
self.collector.drain_episodes()
|
|
33
|
+
yielded, steps = 0, 0
|
|
34
|
+
while yielded < self.n_episodes:
|
|
35
|
+
if steps >= self.max_steps:
|
|
36
|
+
raise RuntimeError(f"{self.n_episodes} episodes did not finish within "
|
|
37
|
+
f"{self.max_steps} vector steps; add a TimeLimit wrapper")
|
|
38
|
+
self.collector.step(self.actor, deterministic=self.deterministic)
|
|
39
|
+
steps += 1
|
|
40
|
+
for ret, length in self.collector.drain_episodes():
|
|
41
|
+
yield {'return': ret, 'length': length}
|
|
42
|
+
yielded += 1
|
|
43
|
+
if yielded == self.n_episodes:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self.collector.close()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from typing import Any, Iterator, Optional
|
|
2
|
+
import torch
|
|
3
|
+
from dragon_torch.common.torch.tree import tree_map, tree_stack
|
|
4
|
+
from dragon_torch.data.envs.config import DragonEnvConfig
|
|
5
|
+
from dragon_torch.data.envs.spaces import obs_to_tensor
|
|
6
|
+
from dragon_torch.data.RL.collector import EnvCollector, ValueActor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_gae(rewards: torch.Tensor, values: torch.Tensor, next_values: torch.Tensor,
|
|
10
|
+
terminated: torch.Tensor, dones: torch.Tensor, gamma: float,
|
|
11
|
+
gae_lambda: float) -> tuple[torch.Tensor, torch.Tensor]:
|
|
12
|
+
"""Generalised advantage estimation over [T, N] tensors.
|
|
13
|
+
|
|
14
|
+
next_values[t] is V of the *true* successor of step t (the final obs when an
|
|
15
|
+
episode ended), so truncated episodes bootstrap while terminated ones don't,
|
|
16
|
+
and the advantage recursion is cut at every episode boundary.
|
|
17
|
+
Returns (advantages, returns).
|
|
18
|
+
"""
|
|
19
|
+
advantages = torch.zeros_like(rewards)
|
|
20
|
+
running = torch.zeros_like(rewards[0])
|
|
21
|
+
for t in reversed(range(rewards.shape[0])):
|
|
22
|
+
delta = rewards[t] + gamma * next_values[t] * (1 - terminated[t].float()) - values[t]
|
|
23
|
+
running = delta + gamma * gae_lambda * (1 - dones[t].float()) * running
|
|
24
|
+
advantages[t] = running
|
|
25
|
+
return advantages, advantages + values
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RolloutSource:
|
|
29
|
+
"""On-policy data source for DragonRLModel.fit.
|
|
30
|
+
|
|
31
|
+
Each iteration (= one training epoch) collects n_steps x n_envs fresh
|
|
32
|
+
transitions with the bound model, computes GAE, then yields
|
|
33
|
+
update_epochs x n_minibatches shuffled minibatches shaped
|
|
34
|
+
{'obs': ..., 'targets': {'actions', 'log_prob', 'values', 'advantages', 'returns'}}.
|
|
35
|
+
PPO: update_epochs ~4-10, n_minibatches ~4. A2C: update_epochs=1, n_minibatches=1.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, env_cfg: DragonEnvConfig, n_steps: int = 128, gamma: float = 0.99,
|
|
39
|
+
gae_lambda: float = 0.95, update_epochs: int = 4, n_minibatches: int = 4,
|
|
40
|
+
normalize_obs: bool = False, normalize_reward: bool = False):
|
|
41
|
+
self.collector = EnvCollector(env_cfg, normalize_obs, normalize_reward, gamma)
|
|
42
|
+
if (n_steps * self.collector.n_envs) < n_minibatches:
|
|
43
|
+
raise ValueError("n_steps * n_envs must be >= n_minibatches")
|
|
44
|
+
self.n_steps, self.gamma, self.gae_lambda = n_steps, gamma, gae_lambda
|
|
45
|
+
self.update_epochs, self.n_minibatches = update_epochs, n_minibatches
|
|
46
|
+
self.actor: Optional[ValueActor] = None
|
|
47
|
+
self._rng = torch.Generator().manual_seed(env_cfg.seed)
|
|
48
|
+
|
|
49
|
+
def bind(self, model: ValueActor) -> None:
|
|
50
|
+
if not isinstance(model, ValueActor):
|
|
51
|
+
raise TypeError(f"RolloutSource needs a model with act() and value(), got {type(model)}")
|
|
52
|
+
self.actor = model
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def steps_per_epoch(self) -> int:
|
|
56
|
+
return self.n_steps * self.collector.n_envs
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def global_step(self) -> int:
|
|
60
|
+
return self.collector.global_step
|
|
61
|
+
|
|
62
|
+
# ------- collection ------- #
|
|
63
|
+
def collect(self) -> dict[str, Any]:
|
|
64
|
+
"""One rollout as [T, N, ...] tensors, plus advantages / returns."""
|
|
65
|
+
if self.actor is None:
|
|
66
|
+
raise RuntimeError("RolloutSource is unbound; fit()/evaluate() bind the model")
|
|
67
|
+
obs, actions, rewards, terminated, dones, log_probs, values = [], [], [], [], [], [], []
|
|
68
|
+
truncation_values = [] # (t, mask, V(final obs)) for truncated envs
|
|
69
|
+
for t in range(self.n_steps):
|
|
70
|
+
tr = self.collector.step(self.actor)
|
|
71
|
+
missing = {'log_prob', 'value'} - set(tr.extras)
|
|
72
|
+
if missing:
|
|
73
|
+
raise KeyError(f"act() must return extras {sorted(missing)} for on-policy training")
|
|
74
|
+
obs.append(obs_to_tensor(tr.obs))
|
|
75
|
+
actions.append(tr.action)
|
|
76
|
+
rewards.append(torch.as_tensor(tr.reward))
|
|
77
|
+
terminated.append(torch.as_tensor(tr.terminated))
|
|
78
|
+
dones.append(torch.as_tensor(tr.done))
|
|
79
|
+
log_probs.append(tr.extras['log_prob'])
|
|
80
|
+
values.append(tr.extras['value'])
|
|
81
|
+
if tr.truncated.any():
|
|
82
|
+
mask = torch.as_tensor(tr.truncated)
|
|
83
|
+
truncation_values.append((t, mask, self.actor.value(tr.next_obs)))
|
|
84
|
+
|
|
85
|
+
values_t = torch.stack(values).float()
|
|
86
|
+
next_values = torch.cat([values_t[1:], self.actor.value(self.collector.obs)[None].float()])
|
|
87
|
+
for t, mask, v in truncation_values:
|
|
88
|
+
next_values[t] = torch.where(mask, v.float(), next_values[t])
|
|
89
|
+
rewards_t = torch.stack(rewards)
|
|
90
|
+
advantages, returns = compute_gae(rewards_t, values_t, next_values, torch.stack(terminated),
|
|
91
|
+
torch.stack(dones), self.gamma, self.gae_lambda)
|
|
92
|
+
return {'obs': tree_stack(obs), 'actions': torch.stack(actions),
|
|
93
|
+
'log_prob': torch.stack(log_probs), 'values': values_t,
|
|
94
|
+
'advantages': advantages, 'returns': returns, 'rewards': rewards_t}
|
|
95
|
+
|
|
96
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
97
|
+
data = self.collect()
|
|
98
|
+
flat = lambda x: x.reshape(-1, *x.shape[2:])
|
|
99
|
+
obs = tree_map(flat, data['obs'])
|
|
100
|
+
targets = {k: flat(data[k]) for k in ('actions', 'log_prob', 'values', 'advantages', 'returns')}
|
|
101
|
+
total = self.steps_per_epoch
|
|
102
|
+
size = total // self.n_minibatches
|
|
103
|
+
for _ in range(self.update_epochs):
|
|
104
|
+
perm = torch.randperm(total, generator=self._rng)
|
|
105
|
+
for i in range(self.n_minibatches):
|
|
106
|
+
idx = perm[i * size:(i + 1) * size]
|
|
107
|
+
yield {'obs': tree_map(lambda x: x[idx], obs),
|
|
108
|
+
'targets': {k: v[idx] for k, v in targets.items()}}
|
|
109
|
+
|
|
110
|
+
# ------- hooks ------- #
|
|
111
|
+
def metrics(self) -> dict[str, float]:
|
|
112
|
+
return self.collector.metrics()
|
|
113
|
+
|
|
114
|
+
def state_dict(self) -> dict[str, Any]:
|
|
115
|
+
return {'collector': self.collector.state_dict(), 'rng': self._rng.get_state()}
|
|
116
|
+
|
|
117
|
+
def load_state_dict(self, state: dict[str, Any]) -> None:
|
|
118
|
+
self.collector.load_state_dict(state['collector'])
|
|
119
|
+
self._rng.set_state(state['rng'])
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
self.collector.close()
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# TODO: WRITE FUNCTION TO CREATE DATALOADER + SOURCE + DATASET. EXTEND DATALOADER TO INCLUDE AN ACCELERATED DATALOADER BY DEFAULT
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from typing import Any, Callable, Iterator, Sequence, Union
|
|
2
|
+
import numpy as np
|
|
3
|
+
from .dragon import DragonDataset
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from torch.utils.data import IterableDataset, get_worker_info
|
|
6
|
+
|
|
7
|
+
type WeightKey = Union[int, str]
|
|
8
|
+
type AggregatorBatch = list[list[Any]]
|
|
9
|
+
|
|
10
|
+
STREAMING_ATTRIBUTES = ['stream_from_hf']
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Aggregator(IterableDataset):
|
|
14
|
+
"""Combines map-style datasets into weighted batches.
|
|
15
|
+
|
|
16
|
+
Every batch holds int(weight * batch_size) samples from each dataset, drawn
|
|
17
|
+
without replacement, so an epoch ends as soon as any weighted dataset runs
|
|
18
|
+
out. Datasets are indexed per sample: pass DragonDatasets without a batch_size.
|
|
19
|
+
|
|
20
|
+
Weights are keyed by position in `datasets` or by a dataset's `kind`.
|
|
21
|
+
Call set_epoch() between epochs to reshuffle.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, datasets: Sequence[DragonDataset], batch_size: int, weights: dict[WeightKey, float],
|
|
25
|
+
shuffle: bool = True, seed: int = None, collate_fn: Callable[[AggregatorBatch], Any] = None):
|
|
26
|
+
if len(datasets) == 0:
|
|
27
|
+
raise ValueError("Aggregator needs at least one dataset")
|
|
28
|
+
if batch_size < 1:
|
|
29
|
+
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
|
30
|
+
self.datasets = self.validate_datasets(list(datasets))
|
|
31
|
+
self.batch_size = batch_size
|
|
32
|
+
self.weights = self._resolve_weights(weights)
|
|
33
|
+
self.samples_per_dataset = [int(w * batch_size) for w in self.weights]
|
|
34
|
+
if sum(self.samples_per_dataset) == 0:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"weights={self.weights} draw no samples at batch_size={batch_size}")
|
|
37
|
+
self.shuffle = shuffle
|
|
38
|
+
self.seed = seed
|
|
39
|
+
self.collate_fn = collate_fn
|
|
40
|
+
# len() can be a network call for hugging face sources, so only ask once
|
|
41
|
+
self._lengths = [len(ds) for ds in self.datasets]
|
|
42
|
+
self.set_epoch(0)
|
|
43
|
+
|
|
44
|
+
def validate_datasets(self, datasets: list[DragonDataset]) -> list[DragonDataset]:
|
|
45
|
+
if not issubclass(datasets[0].__class__, DragonDataset):
|
|
46
|
+
return datasets
|
|
47
|
+
result = []
|
|
48
|
+
for dataset in datasets:
|
|
49
|
+
if not hasattr(dataset, 'to_aggregator') and not hasattr(dataset, 'config'):
|
|
50
|
+
continue
|
|
51
|
+
if hasattr(dataset, 'to_aggregator'):
|
|
52
|
+
dataset: DragonDataset = dataset.to_aggregator()
|
|
53
|
+
_cfg = dataset.config
|
|
54
|
+
if hasattr(_cfg, 'stream_from_hf'):
|
|
55
|
+
setattr(deepcopy(_cfg), 'stream_from_hf', False)
|
|
56
|
+
setattr(dataset, 'config', _cfg)
|
|
57
|
+
print(
|
|
58
|
+
f'MSG :: dataset={dataset.kind}, Setting stream_from_hf=False for ')
|
|
59
|
+
result.append(dataset)
|
|
60
|
+
else:
|
|
61
|
+
result.append(dataset)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def _index_of(self, key: WeightKey) -> int:
|
|
65
|
+
if isinstance(key, int):
|
|
66
|
+
if not 0 <= key < len(self.datasets):
|
|
67
|
+
raise KeyError(
|
|
68
|
+
f"Weight key {key} out of range for {len(self.datasets)} datasets")
|
|
69
|
+
return key
|
|
70
|
+
matches = [i for i, ds in enumerate(self.datasets)
|
|
71
|
+
if getattr(ds, 'kind', None) == key]
|
|
72
|
+
if len(matches) != 1:
|
|
73
|
+
raise KeyError(
|
|
74
|
+
f"Weight key '{key}' matches {len(matches)} datasets by kind, expected 1")
|
|
75
|
+
return matches[0]
|
|
76
|
+
|
|
77
|
+
def _resolve_weights(self, weights: dict[WeightKey, float]) -> list[float]:
|
|
78
|
+
if len(weights) != len(self.datasets):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Expected {len(self.datasets)} weights, got {len(weights)}")
|
|
81
|
+
resolved = [None] * len(self.datasets)
|
|
82
|
+
for key, weight in weights.items():
|
|
83
|
+
i = self._index_of(key)
|
|
84
|
+
if resolved[i] is not None:
|
|
85
|
+
raise ValueError(f"Dataset {i} was given more than one weight")
|
|
86
|
+
if not 0 <= weight <= 1:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Weight for dataset {i} must be in [0, 1], got {weight}")
|
|
89
|
+
resolved[i] = float(weight)
|
|
90
|
+
if sum(resolved) > 1 + 1e-9:
|
|
91
|
+
raise ValueError(f"Weights sum to {sum(resolved)}, must be <= 1")
|
|
92
|
+
return resolved
|
|
93
|
+
|
|
94
|
+
def set_epoch(self, epoch: int):
|
|
95
|
+
self.epoch = epoch
|
|
96
|
+
rng = np.random.default_rng(
|
|
97
|
+
None if self.seed is None else [self.seed, epoch])
|
|
98
|
+
self._orders = [rng.permutation(n) if self.shuffle else np.arange(n)
|
|
99
|
+
for n in self._lengths]
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return min(n // k for n, k in zip(self._lengths, self.samples_per_dataset) if k > 0)
|
|
103
|
+
|
|
104
|
+
def __getitem__(self, identifier: int) -> AggregatorBatch:
|
|
105
|
+
n_batches = len(self)
|
|
106
|
+
idx = identifier + n_batches if identifier < 0 else identifier
|
|
107
|
+
if not 0 <= idx < n_batches:
|
|
108
|
+
raise IndexError(
|
|
109
|
+
f"Batch {identifier} out of range for {n_batches} batches")
|
|
110
|
+
batch = [[ds[int(j)] for j in order[idx * k:(idx + 1) * k]]
|
|
111
|
+
for ds, order, k in zip(self.datasets, self._orders, self.samples_per_dataset)]
|
|
112
|
+
return batch if self.collate_fn is None else self.collate_fn(batch)
|
|
113
|
+
|
|
114
|
+
def __iter__(self) -> Iterator[AggregatorBatch]:
|
|
115
|
+
# under a multi-worker DataLoader each worker takes every num_workers-th batch
|
|
116
|
+
worker = get_worker_info()
|
|
117
|
+
indices = range(len(self)) if worker is None else range(
|
|
118
|
+
worker.id, len(self), worker.num_workers)
|
|
119
|
+
for idx in indices:
|
|
120
|
+
yield self[idx]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import TypeVar, Union, Literal, ClassVar, Generic, Any, Callable, Self
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
import os
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
from datasets import Dataset
|
|
7
|
+
|
|
8
|
+
from dragon_torch.common.types import PathLike, Path
|
|
9
|
+
from dragon_torch.data.sources import Source, SourceConfig, SourceKind, source_factory
|
|
10
|
+
|
|
11
|
+
SourceType = TypeVar('SourceType', covariant=True)
|
|
12
|
+
ItemDtype = TypeVar('BatcherType', covariant=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Dataset(Dataset, ABC, Generic[SourceType, ItemDtype]):
|
|
16
|
+
kind: ClassVar[str]
|
|
17
|
+
data: ClassVar[Any] = None
|
|
18
|
+
_registry: ClassVar[dict[str, type["_Dataset"]]] = {}
|
|
19
|
+
|
|
20
|
+
def __init_subclass__(cls, **kw: Any) -> None:
|
|
21
|
+
super().__init_subclass__(**kw)
|
|
22
|
+
if hasattr(cls, "kind"):
|
|
23
|
+
_Dataset._registry[cls.kind] = cls
|
|
24
|
+
else:
|
|
25
|
+
_Dataset._registry[cls.__name__] = cls
|
|
26
|
+
|
|
27
|
+
def describe(self, title: str = "Dragon Dataset Base Class", logger=None, level='info') -> None:
|
|
28
|
+
if logger is not None:
|
|
29
|
+
recorder = getattr(logger, level)
|
|
30
|
+
else:
|
|
31
|
+
recorder = print
|
|
32
|
+
|
|
33
|
+
recorder(f"Dataset Title={title}")
|
|
34
|
+
for key, value in self.config.__dict__.items():
|
|
35
|
+
recorder(f" {key.capitalize()}={value}")
|
|
36
|
+
|
|
37
|
+
def set_data(self, _d):
|
|
38
|
+
setattr(self, 'data', _d)
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def __len__(self) -> int: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def __getitem__(self, identifier: Union[int, str]) -> ItemDtype: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DragonDataset(_Dataset[SourceType, ItemDtype], Generic[SourceType, ItemDtype]):
|
|
48
|
+
def __init__(self, source_kind: SourceKind, source_cfg: SourceConfig, batch_size: int = None):
|
|
49
|
+
self.source = source_factory(source_cfg, kind=source_kind)
|
|
50
|
+
self.kind = source_kind
|
|
51
|
+
self.config = source_cfg
|
|
52
|
+
self.batch_size = batch_size if batch_size is not None else getattr(
|
|
53
|
+
source_cfg, 'batch_size', None)
|
|
54
|
+
|
|
55
|
+
def post_process(self, x):
|
|
56
|
+
return x if not hasattr(self.config, 'post_process') else self.config.post_process(x)
|
|
57
|
+
|
|
58
|
+
def load(self):
|
|
59
|
+
setattr(self, 'data', self.source.read())
|
|
60
|
+
|
|
61
|
+
def get_dataset(self) -> ItemDtype:
|
|
62
|
+
return self.data if not self.data is None else self.source.read()
|
|
63
|
+
|
|
64
|
+
def to_aggregator(self) -> Self:
|
|
65
|
+
setattr(self, 'batch_size', None)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
if self.batch_size is None:
|
|
70
|
+
return len(self.source)
|
|
71
|
+
return len(self.source) // self.batch_size
|
|
72
|
+
|
|
73
|
+
def __getitem__(self, identifier: Union[str, int]) -> ItemDtype:
|
|
74
|
+
return self.post_process(self.get_dataset().__getitem__(identifier))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|