rl-mind 0.1.0__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.
rl_mind-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,313 @@
1
+ Metadata-Version: 2.4
2
+ Name: rl-mind
3
+ Version: 0.1.0
4
+ Summary: A small, typed reinforcement-learning toolkit for the Master MIND RL practicals
5
+ Author-email: Benjamin Piwowarski <benjamin@piwowarski.fr>
6
+ Project-URL: Documentation, https://pypi.org/project/rl-mind/
7
+ Keywords: reinforcement-learning,gymnasium,torch,teaching
8
+ Classifier: Intended Audience :: Education
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Topic :: Education
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: gymnasium[box2d,classic-control,mujoco]>=1.2.0
17
+ Requires-Dist: ipython>=8
18
+ Requires-Dist: mazemdp>=1.3.0
19
+ Requires-Dist: moviepy>=1.0
20
+ Requires-Dist: numpy>=1.26
21
+ Requires-Dist: tensorboard>=2.19
22
+ Requires-Dist: torch>=2.4
23
+
24
+ # `rl_mind` — a small, typed RL toolkit
25
+
26
+ `rl_mind` is the minimal reinforcement-learning library used by the RL
27
+ practicals. It replaces the heavier BBRL machinery with a handful of **typed**
28
+ building blocks: every piece of data an agent exchanges with its environment is
29
+ a plain (frozen) dataclass of `torch.Tensor`s, so there are no untyped string
30
+ dictionaries and the editor can autocomplete field names.
31
+
32
+ The library is published on PyPI as
33
+ [`rl-mind`](https://pypi.org/project/rl-mind/) (`pip install rl-mind`; the
34
+ practicals install it for you) and the notebooks simply import it. Importing the
35
+ package has **no side effect** — in particular the extra gymnasium environments
36
+ are only registered by an explicit `import rl_mind.envs` (see below).
37
+
38
+ - [Actors and actions](#actors-and-actions) (`rl_mind.core`)
39
+ - [Environments](#environments) (`rl_mind.env`, `rl_mind.envs`)
40
+ - [Data containers](#data-containers) (`rl_mind.data`)
41
+ - [Collectors](#collectors) (`rl_mind.collectors`) — with the comparison table
42
+ - [Evaluation](#evaluation) (`rl_mind.evaluation`)
43
+ - [Helpers](#helpers) (`rl_mind.nn`, `rl_mind.notebook`)
44
+
45
+ ---
46
+
47
+ ## Actors and actions
48
+
49
+ `rl_mind.core`
50
+
51
+ | Class | Role |
52
+ |---|---|
53
+ | `TensorStruct` | Base class: a frozen dataclass of tensors. Supports tensor-like operations applied field by field — `struct[idx]` (index/slice/mask), `TensorStruct.cat([...])`, `TensorStruct.stack([...])`, `struct.set_(idx, value)`. Recurses into nested `TensorStruct` fields. |
54
+ | `Action` | What an actor returns for a batch of observations. One field: `value` — the action tensor (`[B, action_dim]` for continuous actions, `[B]` for discrete). |
55
+ | `StochasticAction` | An `Action` that also stores `log_prob` (`[B]`), the log-probability of the sampled action. Used by REINFORCE / PPO / SAC. |
56
+ | `Actor[A]` | A `torch.nn.Module` mapping a batch of observations to an action of type `A`. `actor(obs)` is the **training-time** behavior (sampling, exploration noise); `actor.act(obs)` is the **deterministic evaluation** behavior (defaults to `forward().value`). |
57
+ | `ActionT` | The generic type variable (`TypeVar` bound to `Action`) that parameterizes `Actor`, `Transitions`, collectors, ... so the action type flows through the API. |
58
+
59
+ ### `TensorStruct` in practice
60
+
61
+ `TensorStruct` is the foundation of every data container in the library
62
+ (`Action`, `Transitions`, `Episode`, `Rollout`, ...). The idea: you write a
63
+ plain frozen dataclass whose fields are tensors sharing a common leading (batch)
64
+ dimension, and you get tensor-like operations that apply to **all fields at
65
+ once**, while each field keeps its name and type.
66
+
67
+ ```python
68
+ @dataclass(frozen=True)
69
+ class Transitions(TensorStruct):
70
+ obs: Tensor # [N, obs_dim]
71
+ action: Action # a nested TensorStruct
72
+ reward: Tensor # [N]
73
+ next_obs: Tensor # [N, obs_dim]
74
+ terminated: Tensor # [N] (bool)
75
+ ```
76
+
77
+ **Indexing / slicing / masking** — `struct[index]` applies `index` to every
78
+ tensor field along the batch dimension and returns a new struct. `index` can be
79
+ anything a tensor accepts:
80
+
81
+ ```python
82
+ batch = buffer.sample(64) # a Transitions with len(batch) == 64
83
+ batch[0] # int -> a single transition
84
+ batch[:32] # slice -> first 32 transitions
85
+ batch[torch.tensor([0, 5, 9])] # fancy -> transitions 0, 5, 9
86
+ batch[~batch.terminated] # bool mask-> only the non-terminal ones
87
+ len(batch) # 64 (size of the leading dimension)
88
+ ```
89
+
90
+ **Concatenating and stacking** — the two class methods build a big struct from
91
+ small ones:
92
+
93
+ ```python
94
+ Transitions.cat([chunk_a, chunk_b]) # concatenate along the batch dim: N_a + N_b
95
+ Action.stack([a0, a1, a2]) # add a NEW leading dim: 3 actions -> [3, ...]
96
+ ```
97
+
98
+ `stack` is exactly how the collectors turn a list of per-step actions into a
99
+ time-indexed `[T, ...]` tensor; `cat` is how `TransitionCollector` merges the
100
+ per-step chunks it records into one flat batch.
101
+
102
+ **Nesting recurses automatically** — a field that is itself a `TensorStruct`
103
+ (here `action`) is sliced/stacked along with the rest, so the alignment between
104
+ observations and actions can never drift:
105
+
106
+ ```python
107
+ sub = batch[mask] # slices batch.obs AND batch.action.value together
108
+ sub.action.log_prob # still lined up with sub.obs, sub.reward, ...
109
+ ```
110
+
111
+ **In-place writes** — instances are frozen (immutable), so `[]`, `cat` and
112
+ `stack` all return *new* structs. The one mutating operation is `set_(index,
113
+ value)`, used by `ReplayBuffer` to overwrite slots of its preallocated storage:
114
+
115
+ ```python
116
+ storage.set_(indices, transitions) # write a batch of transitions at `indices`
117
+ ```
118
+
119
+ Because every container shares this behaviour, the learning code reads the same
120
+ way whether the batch came from a replay buffer, an episode or a rollout —
121
+ `batch.reward`, `batch.action.value`, `batch.terminated` are always named,
122
+ typed, and mutually aligned.
123
+
124
+ The generic parameter is what makes the typing pay off: an `Actor[StochasticAction]`
125
+ guarantees that the actions it produces carry a `.log_prob`, and the type
126
+ checker will flag a `TransitionCollector[StochasticAction]` whose batches you try
127
+ to use as if they had none.
128
+
129
+ ```python
130
+ class DiscretePolicy(Actor[StochasticAction]):
131
+ def dist(self, obs): return torch.distributions.Categorical(logits=self.model(obs))
132
+ def forward(self, obs):
133
+ d = self.dist(obs); a = d.sample()
134
+ return StochasticAction(value=a, log_prob=d.log_prob(a))
135
+ def act(self, obs): return self.model(obs).argmax(-1) # deterministic
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Environments
141
+
142
+ `rl_mind.env`, `rl_mind.envs`
143
+
144
+ | Class / symbol | Role |
145
+ |---|---|
146
+ | `VecEnv` | Runs `num_envs` copies of a gymnasium environment in parallel, talking **torch tensors**. `reset()` → `[B, obs_dim]`; `step(actions)` → `EnvStep`. Exposes `observation_dim` (flat `Box` spaces) or `n_states` (tabular `Discrete` spaces), `action_dim` / `n_actions`, `is_continuous`, `num_envs`, `env_name`, `same_step_reset`. Extra keyword arguments (and `wrappers=`) are forwarded to each sub-environment. |
147
+ | `EnvStep` | Result of one step (all fields `[B, ...]`): `obs` (what to act on next), `next_obs` (the true successor $s_{t+1}$), `reward`, `terminated`, `truncated`, and the derived `done = terminated | truncated`. |
148
+ | `ContinuousCartPoleEnv` | `CartPole-v1` with a continuous force action in $[-1, 1]$. **Importing `rl_mind.envs` registers `CartPoleContinuous-v1`** in gymnasium (the opt-in side effect). |
149
+
150
+ ### Observation spaces
151
+
152
+ `VecEnv` adapts what it returns to the gymnasium observation space:
153
+
154
+ - **`Box`** (the usual case) → a `[B, obs_dim]` float tensor; `observation_dim`
155
+ gives `obs_dim`.
156
+ - **`Discrete`** (tabular environments) → a `[B]` tensor of **state indices**
157
+ (`torch.long`), which index a Q-table directly (`q_table[obs, actions]`);
158
+ `n_states` gives the number of states.
159
+ - **`Dict`** (structured observations) → a `TensorStruct` with one named tensor
160
+ field per key; inspect `observation_space` to write the encoder.
161
+
162
+ ### `terminated` vs `truncated`
163
+
164
+ Both flags end an episode, but they mean different things for learning:
165
+
166
+ - **`terminated`** — a real terminal state (the pole fell). The future is worth
167
+ 0, so you **do not bootstrap**.
168
+ - **`truncated`** — the episode was cut short, e.g. a time limit. The agent
169
+ could have continued, so you **do bootstrap** with the value of `next_obs`.
170
+
171
+ ### Auto-reset modes
172
+
173
+ When an episode ends, `VecEnv` resets it automatically, in one of two modes:
174
+
175
+ - **next-step reset** (default): the ending step returns the episode's final
176
+ observation; the *following* `step` ignores its action and returns the first
177
+ observation of a fresh episode.
178
+ - **same-step reset** (`same_step_reset=True`): the ending step already returns
179
+ the fresh episode's first observation in `obs`, while `next_obs` holds the
180
+ final observation of the episode that just ended. **Every step is then a valid
181
+ transition** — this is what `RolloutCollector` needs.
182
+
183
+ ---
184
+
185
+ ## Data containers
186
+
187
+ `rl_mind.data`
188
+
189
+ | Class / symbol | Shape | Role |
190
+ |---|---|---|
191
+ | `Transitions[A]` | flat `[N, ...]` | A batch of independent transitions $(s, a, r, s', \text{terminated})$: `obs`, `action`, `reward`, `next_obs`, `terminated`. Off-policy data. |
192
+ | `ReplayBuffer[A]` | — | A fixed-capacity ring buffer of `Transitions`. `add(transitions)`, `sample(batch_size)` (uniform, with replacement), `len(buffer)`. |
193
+ | `Episode[A]` | time `[T, ...]` | One full episode: `obs` (`[T, obs_dim]`), `action` (`[T, ...]`), `reward` (`[T]`), `final_obs` (`[obs_dim]`), `terminated` (bool). Plus `len(ep)`, `ep.cumulated_reward` and `ep.all_obs` (`[T+1, obs_dim]`): the observations $s_0, \ldots, s_T$, i.e. `obs` with `final_obs` appended — `all_obs[1:]` are the successors of `obs`, and `critic(all_obs)` gives $V(s_0), \ldots, V(s_T)$. |
194
+ | `Rollout[A]` | time×env `[T, B, ...]` | A fixed-length on-policy segment: `obs`, `action`, `reward`, `next_obs`, `terminated`, `truncated`, the derived `done`, and `flatten()` → `[T*B, ...]`. |
195
+ | `minibatches(data, size)` | — | Iterate over random minibatches of any `TensorStruct`, using each sample **exactly once** per pass (the last batch may be smaller). |
196
+
197
+ `Transitions` and `Rollout` are `TensorStruct`s, so indexing, slicing and
198
+ stacking work uniformly: `batch.action.log_prob`, `rollout[t]`,
199
+ `transitions[mask]`, ... `ReplayBuffer` wraps one; `Episode` is a plain
200
+ dataclass, because its fields do not share a batch dimension (`final_obs` has
201
+ no time axis and `terminated` is a Python `bool`).
202
+
203
+ ---
204
+
205
+ ## Collectors
206
+
207
+ `rl_mind.collectors`
208
+
209
+ The three collectors all run an actor in a `VecEnv` and keep the environment
210
+ state across successive `.collect()` calls, counting total steps in `.steps`.
211
+ They differ in the *shape* of data they produce, matching the three families of
212
+ algorithms:
213
+
214
+ | | `TransitionCollector` | `EpisodeCollector` | `RolloutCollector` |
215
+ |---|---|---|---|
216
+ | Returns | `Transitions` — flat `[N, …]` | `list[Episode]` | `Rollout` — time×env `[T, B, …]` |
217
+ | `collect(...)` arg | `n_steps` | `n_episodes` | `n_steps` |
218
+ | For | off-policy (DQN, DDPG, TD3, SAC) | episodic (REINFORCE) | on-policy (A2C, PPO) |
219
+ | Time structure | none (goes to a shuffled buffer) | whole episodes | preserved (needed for GAE) |
220
+ | `VecEnv` reset mode | next-step (default) | next-step (default) | requires `same_step_reset=True` |
221
+ | Length | `≤ n_steps × num_envs` (reset steps dropped) | `≥ n_episodes` whole episodes | exactly `[n_steps, num_envs]` |
222
+ | Ending flags kept | `terminated` | `terminated` (per episode) | `terminated` **and** `truncated` |
223
+ | Env state across calls | kept | reset at each call (on-policy) | kept |
224
+
225
+ ### Why two step-based collectors (the subtle part)
226
+
227
+ `TransitionCollector` and `RolloutCollector` both walk a fixed number of steps,
228
+ but they treat episode boundaries differently — which is exactly why both exist:
229
+
230
+ - **`TransitionCollector`** produces an *unordered bag* of transitions that will
231
+ be shuffled in a replay buffer. When an episode ends under next-step reset, the
232
+ following "reset" step is invalid, so the collector simply **drops** those rows
233
+ (hence `≤ n_steps × num_envs`). Order doesn't matter, so holes are fine.
234
+
235
+ - **`RolloutCollector`** produces a *rectangular `[T, B]` block* whose time axis
236
+ must stay intact: on-policy algorithms compute GAE as a **backward recursion
237
+ over time**, and need to know at each step whether the episode ended. Dropping
238
+ rows would punch holes in the grid, so it instead requires
239
+ `same_step_reset=True`: the environment resets *within* the ending step, so
240
+ every row is a valid transition (`obs` = fresh state, `next_obs` = true final
241
+ state) and the block stays dense. This is why `Rollout` also carries
242
+ `truncated` — GAE must stop propagating across boundaries.
243
+
244
+ `EpisodeCollector` is the odd one out: it returns *variable-length whole
245
+ episodes* and resets the environments at the start of each `collect()`, so the
246
+ episodes are strictly on-policy (all collected with the current actor).
247
+
248
+ ---
249
+
250
+ ## Evaluation
251
+
252
+ `rl_mind.evaluation`
253
+
254
+ | Class / symbol | Role |
255
+ |---|---|
256
+ | `Evaluator[A]` | Periodically evaluates the current actor on a separate `VecEnv` (using `actor.act`, the deterministic behavior) and keeps a **copy of the best actor so far**. Call `run_if_needed(steps, actor)` in the training loop — it no-ops until `every` steps have passed. Exposes `best_actor`, `best_reward`, `history`, an optional tensorboard `writer`, and `visualize_best()`. |
257
+ | `EvalResult` | One evaluation: `step`, `rewards` (`[n_eval_envs]`), `is_best`, and the derived `.mean`. `Evaluator.history` is a list of these — handy for learning-curve plots and Welch t-tests. |
258
+ | `record_video(actor, env_name, directory)` | Record a video of one deterministic episode and return the video path. |
259
+
260
+ The evaluation environment is intentionally *separate* from the training env
261
+ (different seed, its own episode count, no `same_step_reset`) so that evaluation
262
+ is independent of the data the agent is training on.
263
+
264
+ ---
265
+
266
+ ## Helpers
267
+
268
+ `rl_mind.nn`
269
+
270
+ | Symbol | Role |
271
+ |---|---|
272
+ | `build_mlp(sizes, activation=ReLU(), output_activation=None)` | Build a `nn.Sequential` MLP from a list of layer sizes. |
273
+ | `soft_update(source, target, tau)` | Polyak update of a target network: $\theta' \leftarrow \tau\theta + (1-\tau)\theta'$. |
274
+
275
+ `rl_mind.notebook`
276
+
277
+ | Symbol | Role |
278
+ |---|---|
279
+ | `run_directory(name)` | Create and return a fresh timestamped output directory for a run (under `outputs/`, or `outputs-testing/` in test mode). |
280
+ | `outputs_directory()` | The base output directory (test-mode aware). |
281
+ | `setup_tensorboard()` | Show the tensorboard dashboard inline (Jupyter, Colab), or print the command to launch it from a shell. Warns if the `tensorboard` package is missing, and always prints the **absolute** log directory (it is `outputs/` relative to the kernel's working directory). |
282
+ | `silence_known_warnings()` | Silence the `pkg_resources` deprecation warning emitted by `pygame` and `tensorboard`. |
283
+ | `video_display(path)` | Display a video in the notebook, or print its path when run as a script. |
284
+ | `is_notebook()` | True when running inside Jupyter / Colab. |
285
+
286
+ ---
287
+
288
+ ## A minimal off-policy loop
289
+
290
+ ```python
291
+ import rl_mind.envs # register CartPoleContinuous-v1 (explicit opt-in)
292
+ from rl_mind.env import VecEnv
293
+ from rl_mind.data import ReplayBuffer
294
+ from rl_mind.collectors import TransitionCollector
295
+ from rl_mind.evaluation import Evaluator
296
+
297
+ env = VecEnv("CartPoleContinuous-v1", num_envs=1, seed=1)
298
+ collector = TransitionCollector(env, GaussianNoise(actor, sigma=0.1))
299
+ buffer = ReplayBuffer(200_000)
300
+ evaluator = Evaluator(VecEnv("CartPoleContinuous-v1", 10, seed=101), every=2_000)
301
+
302
+ while collector.steps < 30_000:
303
+ buffer.add(collector.collect(1))
304
+ if len(buffer) < 1_000:
305
+ continue
306
+ batch = buffer.sample(64) # Transitions[Action]
307
+ target = batch.reward + gamma * next_q * (~batch.terminated).float()
308
+ ... # critic / actor updates
309
+ evaluator.run_if_needed(collector.steps, actor)
310
+ ```
311
+
312
+ The on-policy notebooks (A2C, PPO) swap the replay buffer for a
313
+ `RolloutCollector` + `minibatches`; REINFORCE uses an `EpisodeCollector`.
@@ -0,0 +1,85 @@
1
+ # BBRL Notebooks
2
+
3
+ ## Notebooks
4
+
5
+ The notebooks are the python files in [percent
6
+ format](https://jupytext.readthedocs.io/en/latest/formats.html) which are
7
+ transformed into student (with a Colab version) and teacher versions using
8
+ special markers (as described below). Images are inlined.
9
+
10
+ ### Imports
11
+
12
+ To regroup all local imports, use
13
+
14
+ ```py3
15
+ # %% tags=["imports"]
16
+ import numpy as np
17
+ import torch.nn as nn
18
+ ```
19
+
20
+ And in the place where all the imports should be placed, put this instruction
21
+ ```py3
22
+ # [[imports]]
23
+ ```
24
+
25
+ ### Collapsing a cell
26
+
27
+ To collapse a cell in the notebook, use the `hide-input` tag
28
+
29
+ ```md
30
+ # %% tags=["hide-input"]
31
+
32
+ some_not_interesting_code()
33
+ ```
34
+
35
+ ### Including a file
36
+
37
+ To include all the cells from another `py:percent` file, use
38
+ a `copy` tag with a `from ... import ...`
39
+
40
+ ```py3
41
+ # %% tags=["copy"]
42
+
43
+ from common.env import *
44
+ ```
45
+
46
+ ### Filtering cells
47
+
48
+ Tags can be used to filter cells depending on the output:
49
+
50
+ - `teacher` removes the cell when generating a student version
51
+ - `colab` removes the cell when not generating a google Colab version
52
+ - `not-colab` removes the cell when generating a google Colab version
53
+
54
+
55
+ ### Filtering within cells
56
+
57
+ Within a cell, special markers can be used to transform the text.
58
+
59
+ To remove altogether the content, use `[[remove]]`:
60
+
61
+ ```py3
62
+ # [[remove]]
63
+ test_mode = True
64
+ # [[/remove]]
65
+ ```
66
+
67
+ To remove the content only for student, use `[[student]]`:
68
+ ```py3
69
+ # [[student]] Your code to obtain an optimal policy here
70
+ def value_iteration_q(mdp: MazeMDPEnv, render: bool = True) -> Tuple[np.ndarray, List[float]]:
71
+ q = np.zeros((mdp.nb_states, mdp.action_space.n)) # initial action values are set to 0
72
+ q_list = []
73
+ # ># Set stop to False
74
+ # >stop = ...
75
+ stop = False
76
+ ...
77
+ # [[/student]]
78
+ ```
79
+ is transformed into
80
+ ```py3
81
+ # Your code to obtain an optimal policy here
82
+ assert False, "Your code to obtain an optimal policy here"
83
+ # Set stop to False
84
+ stop =
85
+ ```
@@ -0,0 +1,74 @@
1
+ [project]
2
+ name = "rl-mind"
3
+ version = "0.1.0"
4
+ description = "A small, typed reinforcement-learning toolkit for the Master MIND RL practicals"
5
+ readme = "rl_mind.md"
6
+ authors = [{ name = "Benjamin Piwowarski", email = "benjamin@piwowarski.fr" }]
7
+ keywords = ["reinforcement-learning", "gymnasium", "torch", "teaching"]
8
+ classifiers = [
9
+ "Intended Audience :: Education",
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.11",
12
+ "Programming Language :: Python :: 3.12",
13
+ "Topic :: Education",
14
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
15
+ ]
16
+ requires-python = ">=3.11"
17
+ # Runtime dependencies of the library only (what `import rl_mind` needs). The
18
+ # gymnasium extras cover every environment used by the practicals, so a bare
19
+ # `pip install rl-mind` is enough to run the notebooks.
20
+ dependencies = [
21
+ "gymnasium[mujoco,classic-control,box2d]>=1.2.0",
22
+ "ipython>=8",
23
+ "mazemdp>=1.3.0",
24
+ "moviepy>=1.0",
25
+ "numpy>=1.26",
26
+ "tensorboard>=2.19",
27
+ "torch>=2.4",
28
+ ]
29
+
30
+ [project.urls]
31
+ Documentation = "https://pypi.org/project/rl-mind/"
32
+
33
+ [build-system]
34
+ # swig is not needed to build rl-mind itself: it is listed here so that the
35
+ # generated Colab install cell installs it first (box2d-py builds from source).
36
+ requires = ["swig", "setuptools>=61"]
37
+ build-backend = "setuptools.build_meta"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+ include = ["rl_mind*"]
42
+
43
+ [tool.setuptools.package-data]
44
+ rl_mind = ["py.typed"]
45
+
46
+ [tool.pytest.ini_options]
47
+ # Only our own tests: `stk-competition/repositories/` holds student projects
48
+ # (unrelated dependencies, not collectable)
49
+ testpaths = ["tests"]
50
+
51
+ [dependency-groups]
52
+ # What the notebooks need on top of the library (jupyter, plotting, the
53
+ # instructor build helper, ...). Installed by `uv sync` (default groups) but
54
+ # not by `pip install rl-mind`.
55
+ notebooks = [
56
+ "hydra-core>=1.3.2",
57
+ "jupyter>=1.1.1",
58
+ "jupyterlab>=4.4.4",
59
+ "jupytext-notebook-helper>=0.4.1",
60
+ "matplotlib>=3.8",
61
+ "optuna>=4.9.0",
62
+ "pip>=25.2",
63
+ "scipy>=1.11",
64
+ "tqdm>=4.66",
65
+ ]
66
+ dev = [
67
+ "imgcat>=0.5.0",
68
+ "jupytext>=1.17.2",
69
+ "pre-commit>=4.2.0",
70
+ "pytest>=8",
71
+ ]
72
+
73
+ [tool.uv]
74
+ default-groups = ["notebooks", "dev"]