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.
Files changed (80) hide show
  1. dragon_torch/__init__.py +7 -0
  2. dragon_torch/common/__init__.py +0 -0
  3. dragon_torch/common/defaults.py +6 -0
  4. dragon_torch/common/torch/__init__.py +0 -0
  5. dragon_torch/common/torch/tree.py +35 -0
  6. dragon_torch/common/torch/types.py +7 -0
  7. dragon_torch/common/types.py +22 -0
  8. dragon_torch/common/utils.py +6 -0
  9. dragon_torch/data/RL/__init__.py +3 -0
  10. dragon_torch/data/RL/collector.py +169 -0
  11. dragon_torch/data/RL/episodes.py +47 -0
  12. dragon_torch/data/RL/rollout.py +122 -0
  13. dragon_torch/data/__init__.py +0 -0
  14. dragon_torch/data/dataloaders.py +1 -0
  15. dragon_torch/data/datasets/__init__.py +0 -0
  16. dragon_torch/data/datasets/aggregator.py +120 -0
  17. dragon_torch/data/datasets/dragon.py +74 -0
  18. dragon_torch/data/datasets/hugging_face/__init__.py +0 -0
  19. dragon_torch/data/datasets/hugging_face/noise_zoo.py +0 -0
  20. dragon_torch/data/datasets/hugging_face/semasia/__init__.py +0 -0
  21. dragon_torch/data/datasets/hugging_face/semasia/config.py +23 -0
  22. dragon_torch/data/datasets/hugging_face/semasia/semasia_celeba.py +44 -0
  23. dragon_torch/data/datasets/hugging_face/semasia/semasia_imagenet_1k.py +42 -0
  24. dragon_torch/data/envs/__init__.py +5 -0
  25. dragon_torch/data/envs/config.py +77 -0
  26. dragon_torch/data/envs/normalize.py +77 -0
  27. dragon_torch/data/envs/spaces.py +39 -0
  28. dragon_torch/data/envs/wrappers.py +33 -0
  29. dragon_torch/data/sources/__init__.py +7 -0
  30. dragon_torch/data/sources/base.py +55 -0
  31. dragon_torch/data/sources/disk.py +66 -0
  32. dragon_torch/data/sources/hf.py +130 -0
  33. dragon_torch/loss/RL.py +80 -0
  34. dragon_torch/loss/__init__.py +1 -0
  35. dragon_torch/loss/base.py +29 -0
  36. dragon_torch/models/RL/__init__.py +1 -0
  37. dragon_torch/models/RL/base.py +112 -0
  38. dragon_torch/models/__init__.py +0 -0
  39. dragon_torch/models/base.py +395 -0
  40. dragon_torch/models/external/__init__.py +0 -0
  41. dragon_torch/models/external/hf.py +26 -0
  42. dragon_torch/modules/RL/__init__.py +3 -0
  43. dragon_torch/modules/RL/heads.py +110 -0
  44. dragon_torch/modules/RL/policy.py +91 -0
  45. dragon_torch/modules/__init__.py +1 -0
  46. dragon_torch/modules/base.py +20 -0
  47. dragon_torch/modules/external/__init__.py +0 -0
  48. dragon_torch/modules/external/sb3.py +31 -0
  49. dragon_torch/modules/hugging_face/__init__.py +0 -0
  50. dragon_torch/modules/hugging_face/image_transformer.py +25 -0
  51. dragon_torch/trainers/RL/A2C/__init__.py +2 -0
  52. dragon_torch/trainers/RL/A2C/config.py +22 -0
  53. dragon_torch/trainers/RL/A2C/trainer.py +18 -0
  54. dragon_torch/trainers/RL/PPO/__init__.py +2 -0
  55. dragon_torch/trainers/RL/PPO/config.py +19 -0
  56. dragon_torch/trainers/RL/PPO/trainer.py +18 -0
  57. dragon_torch/trainers/RL/__init__.py +6 -0
  58. dragon_torch/trainers/RL/callbacks.py +116 -0
  59. dragon_torch/trainers/RL/config.py +186 -0
  60. dragon_torch/trainers/RL/trainer.py +137 -0
  61. dragon_torch/trainers/__init__.py +3 -0
  62. dragon_torch/trainers/base.py +112 -0
  63. dragon_torch/utils/__init__.py +2 -0
  64. dragon_torch/utils/random_sources.py +64 -0
  65. dragon_torch/utils/tuning/RL.py +49 -0
  66. dragon_torch/utils/tuning/__init__.py +7 -0
  67. dragon_torch/utils/tuning/config.py +122 -0
  68. dragon_torch/utils/tuning/monitor.py +130 -0
  69. dragon_torch/utils/tuning/objective.py +157 -0
  70. dragon_torch/utils/tuning/presets/__init__.py +2 -0
  71. dragon_torch/utils/tuning/presets/a2c.py +19 -0
  72. dragon_torch/utils/tuning/presets/ppo.py +31 -0
  73. dragon_torch/utils/tuning/reporting.py +71 -0
  74. dragon_torch/utils/tuning/space.py +90 -0
  75. dragon_torch/utils/tuning/study.py +51 -0
  76. dragon_torch-1.1.0.dist-info/METADATA +118 -0
  77. dragon_torch-1.1.0.dist-info/RECORD +80 -0
  78. dragon_torch-1.1.0.dist-info/WHEEL +4 -0
  79. dragon_torch-1.1.0.dist-info/entry_points.txt +2 -0
  80. dragon_torch-1.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
@@ -0,0 +1,6 @@
1
+ import os
2
+ numpy_default_dtype: str = os.getenv('DEFAULT_NUMPY_DTYPE')
3
+
4
+
5
+ def identity(x):
6
+ return x
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,6 @@
1
+ from pathlib import Path
2
+ from .types import PathLike
3
+
4
+
5
+ def build_path(x: PathLike) -> Path:
6
+ return x if isinstance(x, Path) else Path(x)
@@ -0,0 +1,3 @@
1
+ from .collector import ActOutput, Actor, EnvCollector, Transition, ValueActor
2
+ from .episodes import EpisodeSource
3
+ from .rollout import RolloutSource, compute_gae
@@ -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