Updates across the board
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
runs/
|
||||
trainer/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
8
Makefile
Normal file
8
Makefile
Normal file
@@ -0,0 +1,8 @@
|
||||
.PHONY: docs deploy
|
||||
|
||||
docs:
|
||||
uv run --group documentation sphinx-build -b html docs docs/_build/html
|
||||
|
||||
deploy: docs
|
||||
aws s3 sync docs/_build/html s3://docs.makingwithcode.org/retro-gamer/
|
||||
aws cloudfront create-invalidation --distribution-id EPA6NHZ2LEH1A --paths "/retro-gamer/*"
|
||||
30
docs/api.rst
Normal file
30
docs/api.rst
Normal file
@@ -0,0 +1,30 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
All classes below are importable directly from ``retro_gamer``.
|
||||
|
||||
Game description
|
||||
----------------
|
||||
|
||||
.. autoclass:: retro_gamer.GameMetadata
|
||||
:members: from_pyproject, from_dict, validate
|
||||
|
||||
Training
|
||||
--------
|
||||
|
||||
.. autoclass:: retro_gamer.DQNTrainer
|
||||
:members: train, load_checkpoint
|
||||
|
||||
Environment
|
||||
-----------
|
||||
|
||||
.. autoclass:: retro_gamer.GameEnvironment
|
||||
:members: reset, step
|
||||
|
||||
Using a trained model
|
||||
---------------------
|
||||
|
||||
.. autoclass:: retro_gamer.TrainedPolicy
|
||||
:members: get_action
|
||||
|
||||
.. autoclass:: retro_gamer.PolicyInput
|
||||
@@ -343,12 +343,13 @@ If the character set is not specified, ``retro-gamer`` runs a brief
|
||||
exploration phase before training to observe which characters actually
|
||||
appear.
|
||||
|
||||
In addition to the board, the agent can observe numerical values from
|
||||
the game's state dictionary via ``observe_state``. These are
|
||||
appended to the end of the observation vector. The reward key must
|
||||
not be included in ``observe_state``: it would give the agent direct
|
||||
access to its own performance signal, which is not a realistic observation
|
||||
in most game contexts and can cause training pathologies.
|
||||
In addition to the board, the agent can observe extra computed values
|
||||
from ``game.state``. Listing keys in the ``observe_state`` option of
|
||||
``[preprocessing]`` causes those values to be appended to the
|
||||
observation vector after the board encoding. This is where feature
|
||||
engineering decisions live: what derived quantities should the agent
|
||||
see, and does giving it those values give it an advantage a human
|
||||
player would not have?
|
||||
|
||||
Neural network architectures
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -356,7 +357,8 @@ Neural network architectures
|
||||
The architecture of the Q-network—the number and arrangement of its
|
||||
layers—is one of the most consequential choices in DQN training.
|
||||
``retro-gamer`` selects an architecture based on the ``spatial``
|
||||
field in the game description and generates a plain-language rationale.
|
||||
option in ``[preprocessing]`` of ``config.toml`` and generates a
|
||||
plain-language rationale.
|
||||
|
||||
**Multilayer perceptrons (MLP)**
|
||||
|
||||
@@ -379,8 +381,7 @@ that these numbers were arranged in a 2D grid, or that spatially
|
||||
adjacent cells are related. This is appropriate when the game's
|
||||
observation is better understood as a collection of independent
|
||||
readings—a set of meters or status indicators—rather than as a spatial
|
||||
scene. Set ``spatial = false`` in the game description to use this
|
||||
architecture.
|
||||
scene. ``spatial = false`` (the default) selects this architecture.
|
||||
|
||||
**Convolutional neural networks (CNN)**
|
||||
|
||||
@@ -405,8 +406,8 @@ channels respectively, kernel size 3, padding 1) followed by a
|
||||
flattening step and an MLP head. The padding ensures that the spatial
|
||||
dimensions are preserved through the convolution, so the output of the
|
||||
second conv layer has shape (64, H, W), which is then flattened and
|
||||
passed to the MLP. Set ``spatial = true`` (the default) to use this
|
||||
architecture.
|
||||
passed to the MLP. Set ``spatial = true`` in ``[preprocessing]`` to
|
||||
use this architecture.
|
||||
|
||||
Connecting architecture to game metadata
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -416,15 +417,17 @@ follow from the game description you provide. This connection is worth
|
||||
making explicit, because understanding it is one of the main paths into
|
||||
understanding why neural network architecture matters.
|
||||
|
||||
- If ``spatial = true``, the CNN can detect local patterns—which characters
|
||||
are adjacent to which—without needing to see every possible arrangement.
|
||||
This is appropriate for games like Snake, where the snake's direction
|
||||
and the apple's relative position are spatially encoded.
|
||||
- If ``spatial = true`` (in ``[preprocessing]``), the CNN can detect
|
||||
local patterns—which characters are adjacent to which—without needing
|
||||
to see every possible arrangement. This is appropriate for games like
|
||||
Snake, where the snake's direction and the apple's relative position
|
||||
are spatially encoded.
|
||||
|
||||
- If ``spatial = false``, the MLP treats the board as a flat vector. This
|
||||
may be appropriate for games that use the character grid primarily as a
|
||||
display rather than a spatial field—for example, a game where characters
|
||||
appear in fixed, non-interacting positions as status indicators.
|
||||
- If ``spatial = false`` (the default), the MLP treats the board as a
|
||||
flat vector. This may be appropriate for games that use the character
|
||||
grid primarily as a display rather than a spatial field—for example,
|
||||
a game where characters appear in fixed, non-interacting positions as
|
||||
status indicators.
|
||||
|
||||
- The ``character_set`` determines the depth (C) of the board tensor.
|
||||
More characters mean more numbers per cell and a larger input to the
|
||||
@@ -432,11 +435,185 @@ understanding why neural network architecture matters.
|
||||
wastes capacity; a character set that omits relevant characters forces
|
||||
the agent to treat different things as the same.
|
||||
|
||||
- The ``observe_state`` fields are appended to the flattened CNN output
|
||||
before the MLP head. This allows the agent to use explicit state
|
||||
variables—a timer, a lives count—alongside the visual board
|
||||
representation.
|
||||
- Keys listed in ``observe_state`` (in ``[preprocessing]``) are appended
|
||||
to the flattened board output before the MLP head. This allows the
|
||||
agent to use computed values—a direction to the goal, a distance, a
|
||||
timer—alongside the visual board representation.
|
||||
|
||||
These relationships are not incidental features of the implementation.
|
||||
They are the reason the game description matters: every field you fill
|
||||
in shapes what the agent can perceive and therefore what it can learn.
|
||||
|
||||
Design rationale
|
||||
----------------
|
||||
|
||||
This section explains the reasoning behind several design decisions in
|
||||
``retro-gamer`` that go beyond technical necessity. Each choice was
|
||||
made with a specific pedagogical goal: to create a tool that not only
|
||||
trains agents, but also helps students build genuine understanding of
|
||||
how and why the training process works.
|
||||
|
||||
Checkpoint compatibility and the "start fresh" workflow
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When a student changes the game description or network architecture
|
||||
mid-training, ``retro-gamer`` refuses to resume and explains exactly
|
||||
which fields changed and why they are incompatible. This behavior is
|
||||
deliberate.
|
||||
|
||||
The immediate practical reason is correctness: if the character set
|
||||
changes, the network's input layer changes size, and the saved weights
|
||||
no longer correspond to any meaningful function. Loading them would
|
||||
produce garbage behavior. If the reward signal changes, the Q-values
|
||||
the network has accumulated are estimates of a *different* objective;
|
||||
resuming would mislead the network, not help it.
|
||||
|
||||
But the deeper reason is pedagogical. The incompatibility check is a
|
||||
moment of forced reflection. When a student sees::
|
||||
|
||||
character_set
|
||||
was : ['@', '*', '>', '<', '^', 'v']
|
||||
now : ['@', '*', '>', '<', '^', 'v', '#']
|
||||
why : the set of board characters (changes input layer size)
|
||||
|
||||
they are confronted with the concrete consequence of a description
|
||||
change. The character set is not a label; it determines the shape of
|
||||
the tensor the network operates on. Changing it invalidates the
|
||||
network the same way changing the rules of chess would invalidate a
|
||||
chess engine. The error message is designed to make this connection
|
||||
legible, not just to block a problematic action.
|
||||
|
||||
The ``retro-gamer clean`` command exists to make the recovery path
|
||||
explicit: you can start fresh, and you should. There is no partial
|
||||
salvage. This mirrors an important truth about RL training: some
|
||||
decisions are foundational, and changing them means starting over.
|
||||
Students who encounter this—who have to decide whether a change is
|
||||
worth the cost of retraining—are reasoning about the architecture in
|
||||
a way that purely reading about it does not produce.
|
||||
|
||||
The distinction between incompatible changes (game description,
|
||||
network architecture) and safe changes (hyperparameters like learning
|
||||
rate and epsilon) is also pedagogically useful. It encodes, in the
|
||||
tool itself, the distinction between *what the agent is learning* and
|
||||
*how it is learning*. Students who ask "can I change the learning rate
|
||||
without retraining?" are asking a question with a precise answer, and
|
||||
answering it correctly requires understanding why the learning rate is
|
||||
different in kind from the character set.
|
||||
|
||||
Checkpoint-level logging
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Early versions of ``retro-gamer`` logged one line per episode. This
|
||||
was accurate but not very useful: a run of 1,000 episodes produces
|
||||
1,000 log lines, most of which are noise. Individual episodes vary
|
||||
widely due to randomness in both the game and the agent's exploration,
|
||||
making it hard to see the underlying trend.
|
||||
|
||||
The current format logs one line per checkpoint—once every 100
|
||||
episodes—using averages over that window. This design serves several
|
||||
goals:
|
||||
|
||||
**Noise reduction.** Single-episode rewards are highly variable,
|
||||
especially when epsilon is high and the agent is behaving randomly.
|
||||
Averaging over 100 episodes smooths out this variance and makes
|
||||
genuine trends visible.
|
||||
|
||||
**Interpretive scaffolding.** The log line includes ``epsilon``
|
||||
alongside ``avg_reward``, so students can directly see the
|
||||
relationship between exploration rate and performance. Early entries
|
||||
with low ``avg_reward`` and high ``epsilon`` invite the question:
|
||||
"is this bad performance, or just exploration?" The answer—that random
|
||||
behavior is expected when epsilon is near 1—is readable from the log
|
||||
itself.
|
||||
|
||||
**Timing information.** Each log line records both the elapsed time
|
||||
for that 100-episode interval and the total training time accumulated
|
||||
across all sessions. This serves two purposes. Practically, it lets
|
||||
students estimate how long continued training will take. Conceptually,
|
||||
it makes the cost of training tangible: RL is not instant, and the
|
||||
log makes the time investment visible.
|
||||
|
||||
**Session continuity.** When training resumes from a checkpoint, a
|
||||
header line marks the break (``=== Resumed from ep_0500.pt ===``).
|
||||
This lets the full log tell the story of a run across multiple
|
||||
sessions, preserving the history of when training happened even if the
|
||||
student stops and restarts many times.
|
||||
|
||||
The stop-watch-adjust-resume workflow
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``retro-gamer`` is designed around a workflow that the log format and
|
||||
checkpoint system both support: stop training, watch the agent play,
|
||||
decide what to change, and resume.
|
||||
|
||||
This workflow is pedagogically productive because it gives students
|
||||
a *reason* to look at the log and a *reason* to think about
|
||||
hyperparameters. Watching the agent at episode 100 play erratically,
|
||||
then watching the agent at episode 500 navigate toward the apple more
|
||||
consistently, is not just satisfying—it raises concrete questions.
|
||||
Why did the agent improve? What changed between those two checkpoints?
|
||||
What would happen if we gave it more time, or adjusted the reward?
|
||||
|
||||
These questions are best answered by consulting the log. The log in
|
||||
turn connects the behavior the student observed to numbers they can
|
||||
reason about: a decreasing loss, a declining epsilon, a rising average
|
||||
reward. The three—visual observation, log interpretation, and
|
||||
conceptual understanding—form a feedback loop that is much harder to
|
||||
close if training is treated as a black box that produces only a final
|
||||
model.
|
||||
|
||||
The fact that training can be stopped and resumed freely, with no
|
||||
penalty and no extra flags, removes friction from this cycle. Students
|
||||
who feel they can experiment—stop, look, think, resume—are more
|
||||
likely to do so than students who feel they have to commit to a full
|
||||
training run before seeing results.
|
||||
|
||||
Reward design as game description
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``reward`` field in ``[tool.retro-gamer]`` specifies a key from
|
||||
the game's state dictionary, not a function or a formula. This is
|
||||
another deliberate design choice. The reward signal is defined in the
|
||||
game code—in how the score changes when certain events occur—not in
|
||||
the training configuration.
|
||||
|
||||
This forces students to engage with the reward where it lives: in the
|
||||
game logic. If a student wants to change the reward structure, they
|
||||
must change the game. This connects the RL concept of reward shaping
|
||||
to the concrete act of writing Python code that updates a score. The
|
||||
question "what reward should the agent get for moving toward the
|
||||
apple?" becomes "what code should run when the snake moves?"—and
|
||||
answering it requires reasoning about what behavior you want to
|
||||
encourage and how a small, frequent signal compares to a large,
|
||||
infrequent one.
|
||||
|
||||
The distinction between reward-signal design (a pedagogically rich
|
||||
question with many possible answers) and reward-field specification
|
||||
(a technical detail) is preserved in the interface. Students configure
|
||||
the *key* to track; they design the *signal* in the game itself.
|
||||
|
||||
Metadata as game description, not training configuration
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The game description lives in ``[tool.retro-gamer]`` inside the
|
||||
game's own ``pyproject.toml``, not in a separate training
|
||||
configuration file. This placement encodes a claim: the character set,
|
||||
the action space, and the reward signal are *properties of the game*,
|
||||
not settings for the trainer.
|
||||
|
||||
A student who edits the character set is not tweaking the trainer;
|
||||
they are more accurately describing their game. This framing matters
|
||||
because it positions the student as the expert on the game—which they
|
||||
are—and the trainer as a tool that depends on the accuracy of that
|
||||
description. Errors in the description are not configuration mistakes;
|
||||
they are inaccurate descriptions of something the student knows.
|
||||
|
||||
When a student omits a character from the character set and the agent
|
||||
fails to notice that character on the board, the diagnostic question
|
||||
is not "what went wrong with training?" but "is my description of the
|
||||
game correct?" This is a more productive question, because it connects
|
||||
the student's domain knowledge (they know what characters appear and
|
||||
why they matter) to the technical representation (one-hot encoding
|
||||
requires knowing in advance which characters to encode). The fix is
|
||||
not to adjust a hyperparameter; it is to describe the game more
|
||||
accurately.
|
||||
|
||||
11
docs/conf.py
11
docs/conf.py
@@ -1,9 +1,18 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
project = 'retro-gamer'
|
||||
copyright = '2025, Chris Proctor'
|
||||
author = 'Chris Proctor'
|
||||
release = '0.1.0'
|
||||
|
||||
extensions = []
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
]
|
||||
|
||||
autodoc_member_order = 'bysource'
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
@@ -31,17 +31,22 @@ with `retro-games <https://retro-games.readthedocs.io/en/latest/>`__.
|
||||
The retro-games framework must also be installed; see its documentation
|
||||
for instructions.
|
||||
|
||||
If you are working through a Making With Code lab, ``retro-gamer`` is
|
||||
already installed in your project environment — skip ahead to
|
||||
:ref:`installation`.
|
||||
|
||||
**Add to a project** using ``uv`` or ``pip``:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% uv add retro-gamer
|
||||
% pip install retro-gamer
|
||||
|
||||
To install from source (for development or to use the latest changes):
|
||||
**Install as a global tool** (available everywhere, no project needed):
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% git clone https://github.com/cproctor/retro-gamer
|
||||
% cd retro-gamer
|
||||
% pip install -e .
|
||||
% uv tool install retro-gamer
|
||||
|
||||
Verify the installation by checking the command-line tool:
|
||||
|
||||
@@ -65,5 +70,8 @@ Verify the installation by checking the command-line tool:
|
||||
introduction
|
||||
background
|
||||
walkthrough
|
||||
troubleshooting
|
||||
reference
|
||||
integration
|
||||
api
|
||||
contributing
|
||||
|
||||
186
docs/integration.rst
Normal file
186
docs/integration.rst
Normal file
@@ -0,0 +1,186 @@
|
||||
Integrating a Trained Model
|
||||
===========================
|
||||
|
||||
Once you have trained a model, you can use it in two ways:
|
||||
|
||||
- **PolicyInput** — the model replaces the keyboard, driving an existing
|
||||
player-controlled agent. Use this to watch a trained agent play, or to
|
||||
run automated evaluations.
|
||||
- **TrainedPolicy in play_turn** — call ``get_action(game)`` from inside any
|
||||
agent's ``play_turn`` to embed the model as an autonomous character (for
|
||||
example, a smart enemy) alongside human-controlled or other agents.
|
||||
|
||||
Loading a trained model
|
||||
-----------------------
|
||||
|
||||
Both approaches start by creating a :class:`retro_gamer.TrainedPolicy`:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from retro_gamer import TrainedPolicy
|
||||
|
||||
ai = TrainedPolicy("runs/snake/")
|
||||
|
||||
This reads ``config.toml``, rebuilds the network, and loads the latest
|
||||
checkpoint. To load a specific checkpoint instead:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
ai = TrainedPolicy("runs/snake/", checkpoint="ep_0500")
|
||||
|
||||
PolicyInput: model as player
|
||||
----------------------------
|
||||
|
||||
:class:`retro_gamer.PolicyInput` is an input source — it implements the same
|
||||
interface as keyboard input, but chooses actions using the trained model. Pass
|
||||
it to ``game.play()`` and everything else works exactly as usual:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from retro.examples.snake import create_game
|
||||
from retro_gamer import TrainedPolicy, PolicyInput
|
||||
|
||||
ai = TrainedPolicy("runs/snake/")
|
||||
game = create_game()
|
||||
game.play(input_source=PolicyInput(ai, game))
|
||||
|
||||
On each turn, ``PolicyInput`` observes the current board and game state, runs
|
||||
the model, and sends the chosen action to the game exactly as if the player
|
||||
had pressed that key.
|
||||
|
||||
TrainedPolicy in play_turn: model as autonomous character
|
||||
---------------------------------------------------------
|
||||
|
||||
To embed a trained model as an autonomous game character, create a
|
||||
``TrainedPolicy`` at module level and call ``get_action(game)`` from inside
|
||||
the agent's ``play_turn``. Placing it at module level means the model is
|
||||
loaded from disk once — not once per episode.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from retro.game import Game
|
||||
from retro.examples.snake import Apple, SnakeHead
|
||||
from retro_gamer import TrainedPolicy
|
||||
|
||||
_ai = TrainedPolicy("runs/snake/")
|
||||
|
||||
class AISnake(SnakeHead):
|
||||
def handle_keystroke(self, k, game): pass # ignore keyboard
|
||||
|
||||
def play_turn(self, game):
|
||||
key = _ai.get_action(game)
|
||||
if key == 'KEY_RIGHT': self.direction = (1, 0)
|
||||
elif key == 'KEY_LEFT': self.direction = (-1, 0)
|
||||
elif key == 'KEY_UP': self.direction = (0, -1)
|
||||
elif key == 'KEY_DOWN': self.direction = (0, 1)
|
||||
super().play_turn(game)
|
||||
|
||||
human_snake = SnakeHead()
|
||||
ai_snake = AISnake()
|
||||
ai_snake.position = (16, 8)
|
||||
apple = Apple()
|
||||
|
||||
game = Game([human_snake, ai_snake, apple], {"score": 0}, board_size=(32, 16))
|
||||
apple.relocate(game)
|
||||
game.play()
|
||||
|
||||
Training an enemy model
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
You can use the same training pipeline to produce a model for an enemy agent.
|
||||
``retro-gamer`` does not care *which* character it is training — it only cares
|
||||
that it can control one character through the keyboard and read a reward signal
|
||||
from the game state. To train an enemy:
|
||||
|
||||
1. **Create an enemy-perspective game variant.** Write (or add) a
|
||||
``create_game`` function — in a separate file, or alongside your main one —
|
||||
where the enemy agent is the keyboard-driven character and the reward key
|
||||
in the game state reflects the enemy's objective (for example, a bonus for
|
||||
catching the player). The human player can be absent, replaced by a
|
||||
random-moving agent, or driven by a ``TrainedPolicy`` once you have a trained
|
||||
player model.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def create_enemy_training_game():
|
||||
enemy = EnemyAgent() # the character the trainer will control
|
||||
player = RandomPlayer() # a stand-in; no human involved
|
||||
game = Game([enemy, player], {'enemy_reward': 0}, board_size=(32, 16))
|
||||
return game
|
||||
|
||||
2. **Train normally against this variant.**
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer create --game my_game:create_enemy_training_game \
|
||||
--output runs/enemy/
|
||||
% retro-gamer train runs/enemy/
|
||||
|
||||
3. **Embed the trained model in your main game** using ``get_action``, exactly
|
||||
as shown above.
|
||||
|
||||
.. note::
|
||||
|
||||
Because ``retro-gamer`` injects actions through the game's global input
|
||||
source, *all* keyboard-listening agents in the training game will receive
|
||||
the trainer's keystrokes. The cleanest approach is to make the enemy the
|
||||
only keyboard-driven character in the training variant — any other
|
||||
characters should advance on their own without reading from the keyboard.
|
||||
|
||||
Adversarial training
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Once you have separate training runs for the player and the enemy, you can
|
||||
train them *against each other* iteratively. The idea is simple: train the
|
||||
player against the current enemy model, then train the enemy against the
|
||||
updated player model, and repeat. Each side is forced to improve against an
|
||||
increasingly capable opponent.
|
||||
|
||||
The key technique is to load the opponent's model at module level in each
|
||||
training game variant, so it is loaded from disk once per run rather than
|
||||
once per episode:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# enemy_training_game.py
|
||||
from retro_gamer import TrainedPolicy
|
||||
|
||||
_player = TrainedPolicy("runs/player/") # loaded once when the module is imported
|
||||
|
||||
def create_game():
|
||||
enemy = EnemyAgent()
|
||||
player = AIPlayer(_player) # uses _player.get_action in play_turn
|
||||
return Game([enemy, player], {'enemy_reward': 0}, board_size=(32, 16))
|
||||
|
||||
You then alternate training runs:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer train runs/player/ # train player against current enemy
|
||||
% retro-gamer train runs/enemy/ # train enemy against updated player
|
||||
% retro-gamer train runs/player/ # train player again
|
||||
# ...
|
||||
|
||||
How many episodes to run before switching is itself a design decision: too
|
||||
few and neither model has time to adapt; too many and each side overfits to
|
||||
its current opponent. Watching how the strategies evolve — and asking *why*
|
||||
each model behaves as it does at each stage — connects directly to concepts
|
||||
in multi-agent reinforcement learning and adversarial training.
|
||||
|
||||
Differences between the two approaches
|
||||
---------------------------------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
:widths: 35 65
|
||||
|
||||
* - ``PolicyInput``
|
||||
- ``TrainedPolicy`` in ``play_turn``
|
||||
* - Replaces human input for the whole game
|
||||
- One autonomous agent among many
|
||||
* - Game code is unchanged
|
||||
- Agent's ``play_turn`` calls ``get_action``
|
||||
* - One model drives all player-controlled agents
|
||||
- Each agent instance has its own model
|
||||
* - Simpler — just pass to ``game.play()``
|
||||
- More flexible — mix human and AI characters
|
||||
@@ -100,12 +100,12 @@ matters.
|
||||
|
||||
**Observation design** determines what information is available to the
|
||||
agent. If you leave a character out of the ``character_set``, the agent
|
||||
will not distinguish it from empty space. If you include a game-state
|
||||
variable in ``observe_state``, the agent can see it directly rather than
|
||||
having to infer it from the board. The consequences of these choices for
|
||||
what the agent can learn are reasonably predictable—and making and
|
||||
checking those predictions is exactly the kind of reasoning the tool is
|
||||
designed to support.
|
||||
will not distinguish it from empty space. If the game module defines a
|
||||
``get_state()`` function, the agent also receives those computed values
|
||||
as part of its observation. The consequences of these choices for what
|
||||
the agent can learn are reasonably predictable — and making and checking
|
||||
those predictions is exactly the kind of reasoning the tool is designed
|
||||
to support.
|
||||
|
||||
**Reward engineering** is the craft of specifying what counts as doing
|
||||
well in a way the agent can actually optimize. Using score as the reward
|
||||
|
||||
@@ -17,8 +17,6 @@ A complete example for the Snake game:
|
||||
actions = ["KEY_RIGHT", "KEY_UP", "KEY_LEFT", "KEY_DOWN"]
|
||||
reward = "score"
|
||||
character_set = ["@", "*", ">", "<", "^", "v"]
|
||||
spatial = true
|
||||
observe_state = []
|
||||
|
||||
You do not need to specify the board size: ``retro-gamer`` reads it
|
||||
directly from your game's ``board_size`` attribute.
|
||||
@@ -65,54 +63,156 @@ If omitted, ``retro-gamer`` runs an exploration phase to discover the
|
||||
characters that appear in practice. The length of this phase is
|
||||
controlled by the ``exploration_turns`` hyperparameter.
|
||||
|
||||
``spatial``
|
||||
~~~~~~~~~~~
|
||||
Preprocessing options
|
||||
---------------------
|
||||
|
||||
**Optional; default ``true``.** Whether to treat the board as a 2D
|
||||
spatial scene. When ``true``, the trainer uses a convolutional neural
|
||||
network (CNN) that can detect patterns in the relative positions of
|
||||
characters. When ``false``, the trainer uses a multilayer perceptron
|
||||
(MLP) that sees the board as a flat list of numbers without positional
|
||||
structure.
|
||||
Preprocessing options live in the ``[preprocessing]`` section of a run's
|
||||
``config.toml``. They control how the game's board and state are
|
||||
transformed into the observation vector that the neural network sees.
|
||||
``retro-gamer create`` writes sensible defaults; you can edit them by
|
||||
hand before running ``retro-gamer train``.
|
||||
|
||||
.. note::
|
||||
|
||||
Changes to any ``[preprocessing]`` option—or to the game description
|
||||
fields above—make existing checkpoints incompatible. Run
|
||||
``retro-gamer clean`` before retraining after such changes.
|
||||
|
||||
``spatial`` (default: ``false``)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Whether to treat the board as a 2D spatial scene. When ``true``, the
|
||||
trainer uses a convolutional neural network (CNN); when ``false``, a
|
||||
multilayer perceptron (MLP) that sees the board as a flat list of
|
||||
numbers.
|
||||
|
||||
``board`` (default: ``true``)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Whether to include the board encoding in the observation vector. Set
|
||||
to ``false`` to train on game state variables only, with no board at
|
||||
all. This is useful for games with small, enumerable state spaces where
|
||||
a lookup table (classic Q-learning) is sufficient.
|
||||
|
||||
When ``board = false``:
|
||||
|
||||
- ``spatial`` must also be ``false`` (no board means no 2D scene for a CNN).
|
||||
- At least one key must be listed in ``observe_state``.
|
||||
- ``character_set`` is not required and character discovery is skipped.
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
spatial = true
|
||||
[preprocessing]
|
||||
board = false
|
||||
observe_state = ["board_state"]
|
||||
|
||||
``observe_state``
|
||||
~~~~~~~~~~~~~~~~~
|
||||
``observe_state`` (default: ``[]``)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
**Optional; default ``[]``.** A list of keys from the game's state
|
||||
dictionary to append to the observation vector. The values must be
|
||||
numbers (integers, floats, or booleans). The reward key must not
|
||||
appear in this list.
|
||||
A list of keys from ``game.state`` to include in the observation
|
||||
vector, appended after the board encoding (or as the entire
|
||||
observation when ``board = false``). Scalar values contribute one
|
||||
element each; list or tuple values are flattened.
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
observe_state = ["lives", "level"]
|
||||
observe_state = ["apple_dx", "apple_dy"]
|
||||
|
||||
The keys must be present in ``game.state`` at every step, initialized
|
||||
in ``create_game()`` before the game starts. All values that are lists
|
||||
or tuples must always have the same length from episode to episode.
|
||||
|
||||
.. warning::
|
||||
|
||||
``observe_state`` keys must be initialized to their final shape in
|
||||
``create_game()`` before the game starts. If a key is absent or its
|
||||
list length changes between episodes, training will crash with an
|
||||
error explaining which key changed and by how much. This happens
|
||||
because the neural network's input layer has a fixed size determined
|
||||
at the start of training; it cannot adapt to a changing observation
|
||||
shape mid-run.
|
||||
|
||||
Always initialize every observed key with a placeholder of the
|
||||
correct type and length before the first ``game.step()`` call.
|
||||
|
||||
``observe_state_sizes`` (auto-discovered)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A table mapping each ``observe_state`` key to its flat size (``1`` for
|
||||
scalars, ``N`` for sequences of length N). This is written automatically
|
||||
to ``config.toml`` the first time ``retro-gamer train`` runs, after the
|
||||
trainer samples ``game.state`` to discover the actual sizes:
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
observe_state_sizes = {board_state = 9}
|
||||
|
||||
You do not need to set this manually. Once written, it is used to
|
||||
detect changes in state shape when resuming training—an incompatible
|
||||
change here requires running ``retro-gamer clean`` and starting fresh.
|
||||
|
||||
``egocentric`` (default: ``false``)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When ``true``, the board observation is cropped to a square window
|
||||
centred on a specific agent rather than the full board. This gives the
|
||||
agent a local, first-person-like view and makes the observation
|
||||
invariant to the agent's absolute position on the board.
|
||||
|
||||
Requires ``egocentric_player`` and ``egocentric_radius``.
|
||||
|
||||
``egocentric_player``
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The name of the agent to use as the centre of the egocentric crop.
|
||||
Must match the ``name`` attribute of one of the game's agents.
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
egocentric_player = "Snake head"
|
||||
|
||||
``egocentric_radius``
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The half-side-length of the egocentric crop window, in cells. The
|
||||
resulting observation covers a ``(2r+1) × (2r+1)`` region. Larger
|
||||
values give the agent a wider view; smaller values focus it on the
|
||||
immediate vicinity.
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
egocentric_radius = 8 # 17×17 window
|
||||
|
||||
When ``egocentric_radius`` is set, ``board_size`` in ``[metadata]`` is
|
||||
automatically updated to ``[2r+1, 2r+1]`` so the network is sized
|
||||
correctly.
|
||||
|
||||
.. _hyperparameters:
|
||||
|
||||
Hyperparameters
|
||||
---------------
|
||||
|
||||
Hyperparameters are stored in the ``[hyperparameters]`` section of
|
||||
``config.toml``. They can be set via ``retro-gamer create`` options or
|
||||
edited directly.
|
||||
Hyperparameters are split across two sections of ``config.toml``:
|
||||
|
||||
- ``[model]`` — network architecture (changing these requires starting fresh)
|
||||
- ``[training]`` — learning algorithm parameters (safe to change at any time)
|
||||
|
||||
Both sections can be set via ``retro-gamer create`` options or edited directly.
|
||||
|
||||
Learning and optimization
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``learning_rate`` (default: ``0.001``)
|
||||
``learning_rate`` (default: ``0.0001``)
|
||||
The step size used by the Adam optimizer when updating network
|
||||
weights. Larger values converge faster but may be unstable; smaller
|
||||
values are more stable but slower.
|
||||
|
||||
``lr_decay`` (default: ``0.995``)
|
||||
``learning_rate_decay`` (default: ``0.9999``)
|
||||
Multiplicative decay applied to the learning rate after each
|
||||
episode. The learning rate decreases geometrically over training,
|
||||
helping the network fine-tune later without destabilizing early
|
||||
progress.
|
||||
progress. With the default value, the learning rate decays to about
|
||||
13 % of its starting value after 20 000 episodes.
|
||||
|
||||
``gamma`` (default: ``0.99``)
|
||||
The discount factor for future rewards. A value of 1.0 makes the
|
||||
@@ -127,7 +227,7 @@ Exploration
|
||||
random action with probability ``epsilon`` and exploits its current
|
||||
Q-function with probability ``1 - epsilon``.
|
||||
|
||||
``epsilon_decay`` (default: ``0.995``)
|
||||
``epsilon_decay`` (default: ``0.9997``)
|
||||
Multiplicative decay applied to ``epsilon`` after each episode.
|
||||
|
||||
``epsilon_min`` (default: ``0.05``)
|
||||
@@ -142,31 +242,33 @@ Memory and sampling
|
||||
The number of experiences sampled from the replay buffer per
|
||||
training step.
|
||||
|
||||
``memory_capacity`` (default: ``10000``)
|
||||
``memory_capacity`` (default: ``50000``)
|
||||
The maximum number of experiences the replay buffer can hold. When
|
||||
full, the oldest experiences are discarded.
|
||||
|
||||
``prioritize_experiences`` (default: ``false``)
|
||||
``prioritize_experiences`` (default: ``true``)
|
||||
Whether to use prioritized experience replay. When ``true``,
|
||||
experiences with larger TD errors are sampled more frequently.
|
||||
This often improves sample efficiency at a modest computational
|
||||
cost.
|
||||
|
||||
Network architecture
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
Model architecture
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``n_layers`` (default: ``2``)
|
||||
The number of hidden layers in the MLP head (for spatial games,
|
||||
this follows the CNN; for non-spatial games, it is the full
|
||||
network).
|
||||
These live in the ``[model]`` section. Changing them requires starting fresh
|
||||
(run ``retro-gamer clean`` before retraining).
|
||||
|
||||
``layer_size`` (default: ``128``)
|
||||
The width (number of units) in each hidden layer.
|
||||
``hidden_sizes`` (default: ``[128, 64]``)
|
||||
A list of integers giving the size of each hidden layer in the MLP
|
||||
head. The default creates two layers: 128 units then 64. For spatial
|
||||
games this follows the CNN; for non-spatial games it is the full
|
||||
network. Larger or deeper networks can represent more complex
|
||||
Q-functions but train more slowly and may need more episodes.
|
||||
|
||||
Training duration
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
``training_episodes`` (default: ``1000``)
|
||||
``training_episodes`` (default: ``20000``)
|
||||
The total number of game episodes to run. Each episode runs until
|
||||
the game ends or ``max_turns_per_episode`` turns have elapsed.
|
||||
|
||||
@@ -175,12 +277,18 @@ Training duration
|
||||
indefinitely (for example, if the agent finds a way to avoid
|
||||
dying).
|
||||
|
||||
``target_update_freq`` (default: ``100``)
|
||||
``target_update_freq`` (default: ``500``)
|
||||
How many training steps between updates of the target network.
|
||||
More frequent updates make training targets move faster (less
|
||||
stable); less frequent updates make them more stable but slower
|
||||
to reflect new learning.
|
||||
|
||||
``train_every`` (default: ``4``)
|
||||
Run one training step every N game steps. Higher values speed up
|
||||
episode collection at the cost of fewer gradient updates per
|
||||
experience. The default of 4 is a good balance for most games;
|
||||
set to 1 to train on every step.
|
||||
|
||||
Character discovery
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -207,23 +315,26 @@ game's ``pyproject.toml``; you do not pass it on the command line.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer create --game MODULE --output DIR [OPTIONS]
|
||||
% retro-gamer create --game GAME --output DIR [OPTIONS]
|
||||
|
||||
**Required options:**
|
||||
|
||||
- ``--game MODULE`` — Python module containing ``create_game()``
|
||||
(e.g. ``retro.examples.snake``). The ``[tool.retro-gamer]`` section
|
||||
is read from the ``pyproject.toml`` found in or above the module's
|
||||
source directory.
|
||||
- ``--game GAME`` — Your game, specified as a file path or a Python
|
||||
module name:
|
||||
|
||||
- File path: ``--game my_game.py`` or ``--game my_game/``
|
||||
- Module name: ``--game retro.examples.snake``
|
||||
|
||||
The ``[tool.retro-gamer]`` section is read from the ``pyproject.toml``
|
||||
found in or above the game file.
|
||||
- ``--output DIR`` — Directory to create for this training run.
|
||||
|
||||
**Hyperparameter options** (all optional; see :ref:`hyperparameters`):
|
||||
|
||||
- ``--training-episodes N``
|
||||
- ``--n-layers N``
|
||||
- ``--layer-size N``
|
||||
- ``--hidden-sizes SIZES`` — comma-separated, e.g. ``512,256``
|
||||
- ``--learning-rate F``
|
||||
- ``--lr-decay F``
|
||||
- ``--learning-rate-decay F``
|
||||
- ``--gamma F``
|
||||
- ``--epsilon-decay F``
|
||||
- ``--epsilon-min F``
|
||||
@@ -232,20 +343,40 @@ game's ``pyproject.toml``; you do not pass it on the command line.
|
||||
- ``--target-update-freq N``
|
||||
- ``--max-turns-per-episode N``
|
||||
- ``--exploration-turns N``
|
||||
- ``--train-every N``
|
||||
- ``--prioritize-experiences`` / ``--no-prioritize-experiences``
|
||||
|
||||
``retro-gamer train``
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Train (or resume training) a DQN agent.
|
||||
Train a DQN agent.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer train RUN_DIR [--resume CHECKPOINT]
|
||||
% retro-gamer train RUN_DIR
|
||||
|
||||
``RUN_DIR`` must contain a ``config.toml`` generated by ``retro-gamer
|
||||
create``. If ``--resume`` is given, training resumes from the specified
|
||||
checkpoint file (relative or absolute path).
|
||||
create``. If checkpoints already exist in ``RUN_DIR``, training
|
||||
automatically resumes from the latest one so prior work is never lost.
|
||||
|
||||
If all configured episodes have already been completed, the command
|
||||
prints a message and exits immediately. To keep training, increase
|
||||
``training_episodes`` in ``config.toml`` and run again.
|
||||
|
||||
**Incompatible changes.** Some config changes make existing checkpoints
|
||||
unusable. If you change any of the following, ``retro-gamer train`` will
|
||||
detect the mismatch and refuse to resume, with a clear explanation:
|
||||
|
||||
- ``actions``, ``reward``, ``character_set``, ``board_size``
|
||||
(``[metadata]``) — game description
|
||||
- ``spatial``, ``board``, ``observe_state``, ``observe_state_sizes``,
|
||||
``egocentric``, ``egocentric_player``, ``egocentric_radius``
|
||||
(``[preprocessing]``) — observation encoding
|
||||
- ``hidden_sizes`` (``[model]``) — network architecture
|
||||
|
||||
Run ``retro-gamer clean RUN_DIR`` to remove the old checkpoints and start
|
||||
fresh. Other hyperparameter changes (learning rate, epsilon, etc.) are
|
||||
safe and take effect immediately on the next training run.
|
||||
|
||||
``retro-gamer play``
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -256,16 +387,32 @@ Watch a trained agent play the game in the terminal.
|
||||
|
||||
% retro-gamer play RUN_DIR [--checkpoint NAME] [--framerate N]
|
||||
|
||||
``--checkpoint`` defaults to ``final``. You can specify a checkpoint by
|
||||
name (e.g. ``ep_0100``) or by path relative to ``RUN_DIR/checkpoints/``.
|
||||
By default, the latest available checkpoint is loaded. Use
|
||||
``--checkpoint`` to load a specific one by name (e.g. ``ep_0100``).
|
||||
``--framerate`` sets the target frames per second (default: 12). Press
|
||||
Enter or Escape to quit.
|
||||
|
||||
``retro-gamer clean``
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Remove all checkpoints and the training log from a run directory.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer clean RUN_DIR
|
||||
|
||||
Prompts for confirmation before deleting. Use ``--yes`` / ``-y`` to skip
|
||||
the prompt. The ``config.toml`` is preserved so you can run
|
||||
``retro-gamer train`` immediately to start fresh with the same settings.
|
||||
|
||||
Use this after making an incompatible change (see ``retro-gamer train``
|
||||
above) or any time you want to restart training from scratch.
|
||||
|
||||
``retro-gamer info``
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Print a summary of a training run: metadata, hyperparameters, recent
|
||||
episode log, and available checkpoints.
|
||||
checkpoint log, and available checkpoints.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
@@ -285,60 +432,49 @@ contents:
|
||||
└── checkpoints/
|
||||
├── ep_0100.pt # model weights at episode 100
|
||||
├── ep_0200.pt
|
||||
├── ...
|
||||
└── final.pt # model weights at training completion
|
||||
└── ... # one file saved every 100 episodes
|
||||
|
||||
``config.toml`` is written by ``retro-gamer create`` and updated (with
|
||||
the discovered character set and resolved hyperparameters) when
|
||||
``retro-gamer train`` begins. Editing ``config.toml`` between ``create``
|
||||
and ``train`` is the recommended way to adjust hyperparameters.
|
||||
``retro-gamer train`` begins. It has five sections: ``[game]``,
|
||||
``[metadata]``, ``[preprocessing]``, ``[model]``, and ``[training]``.
|
||||
Editing ``config.toml`` between ``create`` and ``train`` is the
|
||||
recommended way to adjust hyperparameters.
|
||||
|
||||
``training.log`` begins with the full architecture description
|
||||
generated at training startup, followed by one line per episode in the
|
||||
format::
|
||||
``training.log`` begins with the full network architecture description,
|
||||
then one line per checkpoint (every 100 episodes) in the format::
|
||||
|
||||
[EP NNNN] total_reward=F steps=N epsilon=F avg_loss=F
|
||||
[ep_NNNN] ep=SSSS-NNNN avg_reward=F avg_steps=N epsilon=F avg_loss=F time=Xm Xs total=Xm Xs
|
||||
|
||||
Checkpoint files are PyTorch state dictionaries containing model
|
||||
weights, optimizer state, the current epsilon, and the total number of
|
||||
training steps completed. They can be loaded with
|
||||
``retro-gamer play`` or directly with the Python API.
|
||||
Each field averages over the episodes since the previous checkpoint:
|
||||
|
||||
- ``ep=SSSS-NNNN`` — episode range covered by this entry
|
||||
- ``avg_reward`` — mean total reward per episode (positive = good)
|
||||
- ``avg_steps`` — mean episode length in game turns
|
||||
- ``epsilon`` — current exploration rate (approaches ``epsilon_min`` over time)
|
||||
- ``avg_loss`` — mean Huber loss across training steps (should decrease as learning
|
||||
stabilises). Huber loss equals ½·(q−t)² for small errors and |q−t|−½ for large
|
||||
ones, so it stays bounded even when Q-values are large. Values in the range
|
||||
0–10 are typical; a slow downward trend over thousands of episodes is the
|
||||
healthy pattern. A loss that grows without bound indicates a learning rate
|
||||
that is too high.
|
||||
- ``time`` — wall-clock time for this checkpoint interval
|
||||
- ``total`` — cumulative training time across all sessions
|
||||
|
||||
When training is resumed, a ``=== Resumed from ... ===`` line is appended
|
||||
so the log records the full history of a run across multiple sessions.
|
||||
|
||||
Python API
|
||||
----------
|
||||
|
||||
For advanced use, ``retro-gamer``'s components are importable as a
|
||||
library.
|
||||
library. See the :doc:`api` reference for full details.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from retro_gamer import GameMetadata, GameEnvironment, DQNTrainer
|
||||
from retro_gamer import GameMetadata, DQNTrainer
|
||||
from retro.examples.snake import create_game
|
||||
|
||||
# Read metadata from [tool.retro-gamer] in the game's pyproject.toml
|
||||
metadata = GameMetadata.from_pyproject("retro.examples.snake")
|
||||
|
||||
trainer = DQNTrainer(
|
||||
create_game, metadata, "runs/snake/",
|
||||
training_episodes=500,
|
||||
n_layers=2,
|
||||
layer_size=128,
|
||||
)
|
||||
trainer = DQNTrainer(create_game, metadata, "runs/snake/")
|
||||
trainer.train()
|
||||
|
||||
``GameEnvironment`` provides a gym-style interface for stepping through
|
||||
a game programmatically:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from retro_gamer import GameEnvironment
|
||||
|
||||
env = GameEnvironment(create_game, metadata)
|
||||
obs = env.reset() # returns initial observation vector
|
||||
obs, reward, done = env.step("KEY_RIGHT")
|
||||
|
||||
The observation is a flat NumPy array of dtype ``float32``. For spatial
|
||||
games, the first ``C × H × W`` elements are the board (channel-first
|
||||
one-hot encoding); for non-spatial games, the board is encoded
|
||||
``H × W × C`` and then flattened. Any ``observe_state`` values are
|
||||
appended at the end.
|
||||
|
||||
287
docs/troubleshooting.rst
Normal file
287
docs/troubleshooting.rst
Normal file
@@ -0,0 +1,287 @@
|
||||
Troubleshooting
|
||||
===============
|
||||
|
||||
This section describes problems that commonly arise when training an agent
|
||||
with ``retro-gamer``. Each entry names the issue, describes what you will
|
||||
see in the training log or when watching the agent play, explains what is
|
||||
happening in terms of the underlying reinforcement learning, and suggests
|
||||
how to fix it.
|
||||
|
||||
.. contents:: Issues
|
||||
:local:
|
||||
:depth: 1
|
||||
|
||||
|
||||
Loss grows rapidly over training
|
||||
---------------------------------
|
||||
|
||||
**Symptoms**
|
||||
|
||||
The ``avg_loss`` column in the training log grows steadily from one
|
||||
checkpoint to the next, often at an accelerating rate::
|
||||
|
||||
[ep_0100] avg_loss=22.2
|
||||
[ep_0200] avg_loss=128.5
|
||||
[ep_0300] avg_loss=2918.5
|
||||
[ep_0400] avg_loss=163825.1
|
||||
|
||||
Left unchecked, the loss eventually reaches extreme values and the agent's
|
||||
behavior becomes erratic or degenerates entirely.
|
||||
|
||||
**Why this happens**
|
||||
|
||||
This is called *Q-value divergence*. The Q-network is trained to predict
|
||||
the total future reward of each action. To do that, it computes a *target*
|
||||
for each prediction — but the target itself is computed using the
|
||||
Q-network's own current predictions. This creates a feedback loop: if
|
||||
the predictions are slightly off, the targets drift, which makes the next
|
||||
predictions slightly more off, which drifts the targets further.
|
||||
|
||||
Under normal conditions, the learning rate is small enough and the target
|
||||
network stable enough that this loop stays controlled. Divergence happens
|
||||
when the learning rate is too high, causing each update to overshoot.
|
||||
The problem is amplified by larger networks (more parameters to overshoot)
|
||||
and by prioritized experience replay, which deliberately samples the
|
||||
experiences the network is most wrong about — exactly the experiences most
|
||||
likely to destabilize it.
|
||||
|
||||
**How to fix it**
|
||||
|
||||
Reduce ``learning_rate`` in ``config.toml``. A factor-of-ten reduction
|
||||
(for example, from ``0.001`` to ``0.0001``) is usually enough to stabilize
|
||||
training. If you recently increased the size of the network (via
|
||||
``hidden_sizes``) or enabled ``prioritize_experiences``, a lower learning
|
||||
rate than you used before is likely necessary — larger, more capable
|
||||
networks need smaller, more careful updates.
|
||||
|
||||
Also consider increasing ``target_update_freq``. The target network is a
|
||||
frozen copy of the Q-network used to compute stable training targets; the
|
||||
less frequently it is updated, the more stable those targets are. The
|
||||
default is 200 steps; raising it to 500 or 1000 slows learning slightly
|
||||
but reduces the chance of divergence.
|
||||
|
||||
Because divergence compounds over many episodes, a run that has begun
|
||||
diverging cannot simply be resumed with a lower learning rate — the
|
||||
weights have already drifted far from useful values. Use
|
||||
``retro-gamer clean`` to remove the existing checkpoints and start fresh.
|
||||
|
||||
|
||||
Agent ignores some actions entirely
|
||||
-------------------------------------
|
||||
|
||||
**Symptoms**
|
||||
|
||||
After training, the agent never (or almost never) turns in certain
|
||||
directions, regardless of the board state. If you compare checkpoints at
|
||||
different stages of training, the missing directions are absent from the
|
||||
very beginning and never appear. The agent may survive for a while but
|
||||
always move in only a subset of the possible directions.
|
||||
|
||||
**Why this happens**
|
||||
|
||||
If some actions lead to immediate death every time they are tried early in
|
||||
training, the Q-network quickly learns to assign them very low values.
|
||||
This is correct in the specific situation where those actions are always
|
||||
fatal — but the network then generalizes that association across *all*
|
||||
board positions, even positions where those actions would be safe.
|
||||
|
||||
A common cause is a fixed starting position at the edge or corner of the
|
||||
board. A snake that always starts in the top-left corner and always begins
|
||||
moving downward will die immediately whenever it turns up or left in the
|
||||
first step. After thousands of early episodes where those actions produce
|
||||
instant death, the network has seen so much evidence that "turn left →
|
||||
die" and "turn up → die" that it assigns them low Q-values everywhere.
|
||||
|
||||
**How to fix it**
|
||||
|
||||
Make sure the game's starting conditions give the agent a chance to try
|
||||
every action safely. For a snake game, this means randomizing both the
|
||||
starting position (keeping at least one cell away from every edge) and
|
||||
the starting direction at the beginning of each episode. An agent that
|
||||
starts in different places and orientations each time will quickly learn
|
||||
that all four directions can be appropriate depending on context.
|
||||
|
||||
|
||||
Agent survives but never moves toward the goal
|
||||
-----------------------------------------------
|
||||
|
||||
**Symptoms**
|
||||
|
||||
The ``avg_steps`` column in the training log increases steadily — the
|
||||
agent is surviving longer — but ``avg_reward`` stays negative or barely
|
||||
improves. When you watch the agent play, it wanders around the board
|
||||
without ever approaching the target object. Episodes end because the
|
||||
agent runs into a wall, not because it reached the goal.
|
||||
|
||||
**Why this happens**
|
||||
|
||||
The reward signal is *asymmetric*: it penalizes moving away from the goal
|
||||
but gives no reward for moving toward it. With this signal, the agent
|
||||
learns to avoid the penalty by surviving, but it has no positive gradient
|
||||
pointing it in the right direction. The eventual goal-reaching reward
|
||||
(eating the apple, reaching the exit, etc.) is too rare — especially
|
||||
early in training when the agent is mostly acting randomly — to provide
|
||||
meaningful learning signal on its own.
|
||||
|
||||
From the Q-network's perspective, all directions look roughly equivalent:
|
||||
moving toward the goal is 0 reward, moving away is −1. On a large board,
|
||||
the probability of eating the apple by chance is small enough that the
|
||||
network may never see the positive terminal reward at all during the
|
||||
exploration phase.
|
||||
|
||||
**How to fix it**
|
||||
|
||||
Make the distance-based reward symmetric: give **+1 for moving toward the
|
||||
goal** and **−1 for moving away**. This way, every single step provides a
|
||||
meaningful signal in the correct direction, and the agent does not need to
|
||||
reach the goal by chance in order to start learning. In a snake game,
|
||||
computing this signal requires only one line of arithmetic — the change
|
||||
in Manhattan distance between the head and the apple from one step to the
|
||||
next.
|
||||
|
||||
Note that the shaped ±1 signal is a *proxy* for the real objective. If the
|
||||
agent learns to follow it too literally, it may take direct paths that run
|
||||
through its own body. The −10 death penalty and +50 apple reward are still
|
||||
necessary; the shaping only accelerates early learning.
|
||||
|
||||
|
||||
Exploration ends before learning is complete
|
||||
---------------------------------------------
|
||||
|
||||
**Symptoms**
|
||||
|
||||
The ``epsilon`` column in the training log reaches ``epsilon_min`` well
|
||||
before training is finished. After that point, ``avg_reward`` stops
|
||||
improving even though many episodes remain. When you watch the agent play,
|
||||
it commits to the same strategy regardless of what is happening on the
|
||||
board.
|
||||
|
||||
**Why this happens**
|
||||
|
||||
Epsilon controls the balance between exploration (random actions) and
|
||||
exploitation (using the learned policy). Early in training, when the
|
||||
Q-network has seen little data, exploration is essential: the agent needs
|
||||
to try different things to accumulate the varied experiences that make
|
||||
Q-value estimates reliable. Once epsilon reaches its minimum, the agent
|
||||
stops exploring and commits fully to whatever policy it has learned so far.
|
||||
|
||||
If ``training_episodes`` is too small relative to ``epsilon_decay``, the
|
||||
exploration phase ends while the Q-network is still unreliable. The agent
|
||||
then exploits a half-learned policy that cannot improve because it never
|
||||
tries anything new.
|
||||
|
||||
You can calculate when epsilon will reach its minimum:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import math
|
||||
episodes = math.log(epsilon_min / epsilon) / math.log(epsilon_decay)
|
||||
|
||||
With the defaults (``epsilon = 1.0``, ``epsilon_min = 0.05``,
|
||||
``epsilon_decay = 0.999``), this comes to roughly 3,000 episodes. The
|
||||
agent should have substantial training time *after* the exploration phase
|
||||
ends — so ``training_episodes`` should be at least several times this
|
||||
number.
|
||||
|
||||
**How to fix it**
|
||||
|
||||
Increase ``training_episodes`` so that the agent has many episodes of
|
||||
exploitation after the exploration phase ends. For simple games on small
|
||||
boards, 10,000 episodes is a reasonable starting point; for complex games
|
||||
or large boards, 50,000–100,000 may be needed.
|
||||
|
||||
This is always safe to change. Because ``training_episodes`` does not
|
||||
affect the network architecture or the reward signal, you can increase it
|
||||
in ``config.toml`` and resume training from the latest checkpoint without
|
||||
starting fresh.
|
||||
|
||||
|
||||
Death penalty dominates all other signals
|
||||
-------------------------------------------
|
||||
|
||||
**Symptoms**
|
||||
|
||||
After a period of training, the agent survives for many steps but rarely
|
||||
or never scores. It tends to circle, hug walls, or otherwise avoid the
|
||||
goal object entirely. ``avg_steps`` is high but ``avg_reward`` remains
|
||||
persistently negative. The agent behaves as if staying alive is the only
|
||||
objective.
|
||||
|
||||
**Why this happens**
|
||||
|
||||
When the penalty for dying is much larger than any other reward in the
|
||||
game, the Q-network learns that staying alive is overwhelmingly the most
|
||||
important thing to do. Scoring — which requires taking some risk —
|
||||
becomes unattractive because a single death outweighs many successful
|
||||
goal-reaching events.
|
||||
|
||||
For example, if the death penalty is −1000 and each successful apple is
|
||||
+50, then dying once costs the equivalent of twenty apples. The agent
|
||||
learns that the safest strategy is to avoid risk entirely, even if that
|
||||
means never eating. From the Q-network's perspective, this is rational:
|
||||
it is correctly optimizing the reward signal you gave it.
|
||||
|
||||
**How to fix it**
|
||||
|
||||
Keep all reward magnitudes in the same order of magnitude. If per-step
|
||||
shaping gives ±1 and the goal reward is +50, a death penalty of −10 is
|
||||
appropriate: death is clearly bad (ten times worse than a bad step) but
|
||||
not so catastrophic that it crowds out everything else. As a rule of
|
||||
thumb, no single reward should be more than ten to twenty times larger
|
||||
than the typical per-step reward.
|
||||
|
||||
Increasing ``gamma`` (the discount factor) is a better way to make the
|
||||
agent care more about long-term consequences. A higher gamma causes
|
||||
future rewards — including the eventual death penalty — to count more
|
||||
heavily in the agent's current decisions, without distorting the relative
|
||||
scale of the rewards.
|
||||
|
||||
|
||||
Reward signal and human score interfere with each other
|
||||
---------------------------------------------------------
|
||||
|
||||
**Symptoms**
|
||||
|
||||
Human players see scores that go negative, or that include penalties and
|
||||
adjustments that make no sense in the context of a normal game. Conversely,
|
||||
adjustments made to improve training (removing a per-step shaping penalty,
|
||||
changing a death penalty) change the game's visible score in ways that
|
||||
affect the experience for human players.
|
||||
|
||||
**Why this happens**
|
||||
|
||||
Using the same state variable for both the training reward and the
|
||||
human-visible score conflates two separate concerns. Training rewards
|
||||
benefit from shaping — intermediate signals like "moved toward the goal"
|
||||
and "died" that accelerate learning. Scores for human players should
|
||||
reflect only the game's actual objectives (apples eaten, enemies defeated,
|
||||
distance covered) so that they are legible and motivating.
|
||||
|
||||
When these are the same variable, every design decision about one
|
||||
necessarily affects the other.
|
||||
|
||||
**How to fix it**
|
||||
|
||||
Use two separate keys in the game's state dictionary: one for the
|
||||
human-facing score (updated only by meaningful in-game events) and one
|
||||
for the training reward (updated every step with shaping signals and
|
||||
penalties). In the game code:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Only updated when the snake eats an apple — clean for human players.
|
||||
game.state['score'] += 50
|
||||
|
||||
# Updated every step — used only by the trainer.
|
||||
game.state['reward'] += old_dist - new_dist # +1 toward apple, -1 away
|
||||
game.state['reward'] += 50 # also reward eating
|
||||
game.state['reward'] -= 10 # death penalty
|
||||
|
||||
Then set ``reward = "reward"`` in the ``[tool.retro-gamer]`` section of
|
||||
``pyproject.toml`` so the trainer watches the right key. The score display
|
||||
remains clean for human players, and you can adjust the training reward
|
||||
freely without affecting it.
|
||||
|
||||
Note that changing the ``reward`` key is an incompatible change: existing
|
||||
checkpoints trained on the old signal will be rejected when you try to
|
||||
resume. Run ``retro-gamer clean`` and start fresh after making this change.
|
||||
@@ -21,9 +21,9 @@ You will need:
|
||||
Preparing your game
|
||||
-------------------
|
||||
|
||||
``retro-gamer`` loads your game by importing a Python module and
|
||||
calling a function named ``create_game``. The ``create_game`` function
|
||||
must take no arguments and return a new ``Game`` instance.
|
||||
``retro-gamer`` loads your game by calling a function named
|
||||
``create_game``. The function must take no arguments and return a new
|
||||
``Game`` instance.
|
||||
|
||||
Here is the ``create_game`` function for Snake:
|
||||
|
||||
@@ -32,12 +32,20 @@ Here is the ``create_game`` function for Snake:
|
||||
def create_game():
|
||||
head = SnakeHead()
|
||||
apple = Apple()
|
||||
game = Game([head, apple], {'score': 0}, board_size=(32, 16), framerate=12)
|
||||
game = Game([head, apple], {'score': 100}, board_size=(32, 16), framerate=12)
|
||||
apple.relocate(game)
|
||||
return game
|
||||
|
||||
If your game module does not already have a ``create_game`` function,
|
||||
add one following this pattern.
|
||||
If your game file does not already have a ``create_game`` function, add
|
||||
one following this pattern.
|
||||
|
||||
When you run ``retro-gamer create``, you can point to your game file
|
||||
directly by path or by Python module name:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer create --game my_game.py --output runs/my_game/
|
||||
% retro-gamer create --game retro.examples.snake --output runs/snake/
|
||||
|
||||
|
||||
Describing your game
|
||||
@@ -57,8 +65,6 @@ Here is the ``[tool.retro-gamer]`` section for the Snake example:
|
||||
actions = ["KEY_RIGHT", "KEY_UP", "KEY_LEFT", "KEY_DOWN"]
|
||||
reward = "score"
|
||||
character_set = ["@", "*", ">", "<", "^", "v"]
|
||||
spatial = true
|
||||
observe_state = []
|
||||
|
||||
Let's go through each field.
|
||||
|
||||
@@ -80,9 +86,10 @@ implicitly has access to a no-op (doing nothing).
|
||||
|
||||
The key in the game's state dictionary to use as the reward signal.
|
||||
``retro-gamer`` computes the reward for each turn as the *change* in
|
||||
this value from one turn to the next. For Snake, score increases by 1
|
||||
(or more) each time the apple is eaten, so the agent receives a reward
|
||||
of 1 when it eats an apple and 0 otherwise.
|
||||
this value from one turn to the next. For Snake, the score changes when
|
||||
the snake eats an apple (+50), when it moves away from the apple (−1),
|
||||
and when it dies (−10). These incremental changes are what the agent
|
||||
tries to maximize.
|
||||
|
||||
Choosing an appropriate reward is one of the most consequential
|
||||
decisions in RL. Some considerations:
|
||||
@@ -115,15 +122,48 @@ phase before training to discover which characters actually appear.
|
||||
The number of exploration turns is controlled by the
|
||||
``exploration_turns`` hyperparameter.
|
||||
|
||||
``spatial``
|
||||
~~~~~~~~~~~
|
||||
``spatial`` and other preprocessing options
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Whether to treat the board as a spatial scene (default: ``true``). A
|
||||
spatial game uses a *convolutional neural network* (CNN) that can
|
||||
detect patterns in the relative arrangement of characters. A
|
||||
non-spatial game uses a simpler *multilayer perceptron* (MLP) that
|
||||
ignores positional relationships. Set to ``false`` for games where
|
||||
position is irrelevant.
|
||||
The ``[tool.retro-gamer]`` section describes the game. Preprocessing
|
||||
options—such as ``spatial`` (whether to use a CNN or MLP, default:
|
||||
``false``), ``egocentric``, and ``observe_state``—live in the
|
||||
``[preprocessing]`` section of the generated ``config.toml``. You can
|
||||
edit them there after running ``retro-gamer create``.
|
||||
|
||||
``observe_state``
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
By default the agent only sees the board. You can also give it access
|
||||
to computed values from ``game.state`` by listing the relevant keys in
|
||||
the ``observe_state`` option in ``[preprocessing]`` of ``config.toml``.
|
||||
For example, Snake exposes the normalized direction to the apple:
|
||||
|
||||
.. code-block:: toml
|
||||
|
||||
[preprocessing]
|
||||
observe_state = ["apple_dx", "apple_dy"]
|
||||
|
||||
The trainer appends these values to the observation vector after the
|
||||
board encoding (or uses them as the entire observation when
|
||||
``board = false``).
|
||||
|
||||
These values must be set in ``game.state`` at the start of every
|
||||
episode—typically inside ``create_game()``—and must keep the same
|
||||
type and length from episode to episode.
|
||||
|
||||
.. warning::
|
||||
|
||||
Always initialize every key listed in ``observe_state`` before the
|
||||
game starts. If a key is missing or its length changes between
|
||||
episodes, training stops immediately with a clear error explaining
|
||||
what changed. The neural network's input size is fixed when training
|
||||
begins; it cannot adapt to a changing observation shape mid-run.
|
||||
|
||||
This is a good place to ask: *can a human player see this information?*
|
||||
The apple's location is visible on screen; the normalized distance vector
|
||||
is not. Whether that asymmetry is appropriate is a design choice worth
|
||||
examining.
|
||||
|
||||
Once you have written this section, create the training run directory:
|
||||
|
||||
@@ -139,7 +179,7 @@ Once you have written this section, create the training run directory:
|
||||
actions : ['KEY_RIGHT', 'KEY_UP', 'KEY_LEFT', 'KEY_DOWN']
|
||||
reward : score
|
||||
characters : ['@', '*', '>', '<', '^', 'v']
|
||||
architecture: CNN (spatial)
|
||||
architecture: MLP
|
||||
|
||||
``retro-gamer create`` reads your game metadata directly from
|
||||
``pyproject.toml`` and writes it—along with all hyperparameters—to
|
||||
@@ -153,64 +193,141 @@ With the ``config.toml`` in place, start training:
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer train runs/snake/
|
||||
Training for 1000 episodes…
|
||||
Done. Checkpoints in runs/snake/checkpoints/
|
||||
100%|████████████████████| 1000/1000 [12:34<00:00, 1.32ep/s, reward=9.0, eps=0.007, loss=0.0003]
|
||||
Done. Checkpoints saved in runs/snake/checkpoints/
|
||||
|
||||
Training saves checkpoints every 100 episodes and a ``final.pt``
|
||||
checkpoint when complete. You can follow progress in the training log:
|
||||
A progress bar shows how far training has gone, along with the most
|
||||
recent episode's reward, the current exploration rate (``eps``), and
|
||||
the average prediction error (``loss``).
|
||||
|
||||
Training saves a checkpoint every 100 episodes to
|
||||
``runs/snake/checkpoints/``. You can stop training at any time with
|
||||
Ctrl-C and resume it later—the next ``retro-gamer train`` command will
|
||||
automatically pick up from the latest checkpoint.
|
||||
|
||||
Reading the training log
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
For a longer view of how training is progressing, inspect the training
|
||||
log:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% tail -f runs/snake/training.log
|
||||
% cat runs/snake/training.log
|
||||
|
||||
The log shows one line per episode:
|
||||
The log begins with the full network architecture, followed by one line
|
||||
per checkpoint (every 100 episodes):
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
[EP 0001] total_reward=0.0 steps=2000 epsilon=0.9950 avg_loss=0.023540
|
||||
[EP 0050] total_reward=1.0 steps=1921 epsilon=0.7783 avg_loss=0.003217
|
||||
[EP 0100] total_reward=3.0 steps=1847 epsilon=0.6065 avg_loss=0.001204
|
||||
[ep_0100] ep=0001-0100 avg_reward=-31.4 avg_steps=47 epsilon=0.938 avg_loss=7.2 time=0m12s total=0m12s
|
||||
[ep_0200] ep=0101-0200 avg_reward=-18.6 avg_steps=89 epsilon=0.879 avg_loss=6.8 time=0m14s total=0m26s
|
||||
[ep_0300] ep=0201-0300 avg_reward= -4.1 avg_steps=134 epsilon=0.824 avg_loss=6.1 time=0m15s total=0m41s
|
||||
[ep_0500] ep=0401-0500 avg_reward= +8.7 avg_steps=210 epsilon=0.724 avg_loss=5.4 time=0m16s total=1m12s
|
||||
[ep_1000] ep=0901-1000 avg_reward=+22.3 avg_steps=389 epsilon=0.557 avg_loss=4.9 time=0m18s total=2m30s
|
||||
|
||||
- **total_reward**: the total score earned during the episode (how many
|
||||
apples the snake ate, for Snake).
|
||||
- **steps**: how many turns the episode lasted.
|
||||
- **epsilon**: the current exploration rate. Early in training this is
|
||||
close to 1 (mostly random actions); it decays toward ``epsilon_min``.
|
||||
- **avg_loss**: the average temporal-difference error across training
|
||||
steps in this episode. A decreasing loss generally indicates that the
|
||||
Q-value estimates are converging.
|
||||
Here is what each field means:
|
||||
|
||||
Resuming training
|
||||
~~~~~~~~~~~~~~~~~
|
||||
- **avg_reward**: Average total reward per episode over the past 100 episodes.
|
||||
Positive values mean the agent is accumulating reward; negative values mean
|
||||
it is accumulating penalties. An upward trend over time is the main signal
|
||||
that learning is working.
|
||||
- **avg_steps**: Average number of turns per episode. If episodes are ending
|
||||
quickly (small ``avg_steps``), the agent may be dying often. Longer episodes
|
||||
generally indicate the agent is surviving longer.
|
||||
- **epsilon**: The current exploration rate. Starts near 1.0 (mostly random)
|
||||
and decays toward ``epsilon_min``. When ``epsilon`` is still high, erratic
|
||||
behavior is expected.
|
||||
- **avg_loss**: Average Huber loss across training steps. Huber loss is
|
||||
quadratic for small prediction errors and linear for large ones, which keeps
|
||||
it stable even when rewards have a wide range (such as a large bonus for
|
||||
reaching a goal). Values in the range 0–10 are typical for most games.
|
||||
A slow downward trend is the healthy pattern. A loss that grows without bound
|
||||
indicates the learning rate is too high.
|
||||
- **time**: Wall-clock time for this 100-episode interval.
|
||||
- **total**: Cumulative training time across all sessions.
|
||||
|
||||
Training can be resumed from a checkpoint:
|
||||
When training is resumed after a stop, a header line marks the break::
|
||||
|
||||
=== Resumed from ep_0500.pt | 2026-05-09 14:22:01 ===
|
||||
|
||||
This lets you track exactly when each session took place.
|
||||
|
||||
Stopping training to watch the agent play
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
You do not need to wait for training to finish before watching the
|
||||
agent. Training can be stopped at any time with Ctrl-C, and the latest
|
||||
checkpoint is always available immediately:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer train runs/snake/ --resume checkpoints/ep_0500.pt
|
||||
% retro-gamer play runs/snake/
|
||||
|
||||
Watching a trained agent play
|
||||
------------------------------
|
||||
This loads the most recent checkpoint and runs the agent in your
|
||||
terminal. Press Enter or Escape to quit.
|
||||
|
||||
To watch a trained agent play the game in your terminal:
|
||||
.. note::
|
||||
|
||||
.. code-block:: console
|
||||
The game is rendered directly in your terminal. If the window is
|
||||
smaller than the board plus borders, ``retro-gamer play`` will raise
|
||||
a ``TerminalTooSmall`` error — enlarge the terminal window and try
|
||||
again.
|
||||
|
||||
% retro-gamer play runs/snake/ --checkpoint final
|
||||
|
||||
You can substitute any checkpoint name:
|
||||
To watch an earlier stage of training, use ``--checkpoint``:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer play runs/snake/ --checkpoint ep_0100
|
||||
|
||||
Press Enter or Escape to quit.
|
||||
Comparing what the agent at episode 100 does versus the agent at episode
|
||||
500 can reveal exactly what the agent has (and has not) learned. For
|
||||
Snake, you might notice the episode-100 agent moving somewhat randomly,
|
||||
while the episode-500 agent consistently navigates toward the apple.
|
||||
Articulating *why* the later agent behaves differently—what the training
|
||||
process produced—connects observation directly to the concepts underlying
|
||||
DQN.
|
||||
|
||||
Comparing agents trained at different checkpoints is a useful activity:
|
||||
the agent at episode 100 has learned *something*, but typically much
|
||||
less than the agent at episode 500. Articulating *what* the earlier
|
||||
agent has and has not learned, and *why*, is productive reasoning about
|
||||
the training process.
|
||||
Resuming training after watching
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
After watching the agent play, resume training with exactly the same
|
||||
command you used before:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer train runs/snake/
|
||||
|
||||
``retro-gamer`` automatically detects and resumes from the latest
|
||||
checkpoint. No extra flags are needed. If all configured episodes have
|
||||
already been completed, it prints a message and exits:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
Training already complete (1000 episodes). To keep training,
|
||||
increase training_episodes in config.toml.
|
||||
|
||||
To continue training, open ``runs/snake/config.toml``, increase the
|
||||
``training_episodes`` value, and run ``retro-gamer train`` again.
|
||||
|
||||
Watching a trained agent play
|
||||
------------------------------
|
||||
|
||||
Once training is complete, watch the final agent:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer play runs/snake/
|
||||
|
||||
By default the latest checkpoint is loaded. You can also compare the
|
||||
agent's performance at different stages of training:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer play runs/snake/ --checkpoint ep_0100
|
||||
% retro-gamer play runs/snake/ --checkpoint ep_0500
|
||||
|
||||
Press Enter or Escape to quit.
|
||||
|
||||
Inspecting a run
|
||||
----------------
|
||||
@@ -220,18 +337,20 @@ To review the configuration and recent training progress for a run:
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer info runs/snake/
|
||||
Game module : retro.examples.snake
|
||||
Metadata : {'board_size': [32, 16], 'actions': [...], 'reward': 'score', ...}
|
||||
Hyperparams : {'learning_rate': 0.001, 'gamma': 0.99, ...}
|
||||
Game module : retro.examples.snake
|
||||
Metadata : {'actions': ['KEY_RIGHT', ...], 'reward': 'score', 'board_size': [32, 16], ...}
|
||||
Preprocessing : {'spatial': False, 'board': True, 'observe_state': ['apple_dx', 'apple_dy'], ...}
|
||||
Model : {'hidden_sizes': [128, 64]}
|
||||
Training : {'learning_rate': 0.0001, 'gamma': 0.99, ...}
|
||||
|
||||
Last 5 episodes:
|
||||
[EP 0996] total_reward=9.0 steps=1203 epsilon=0.0074 avg_loss=0.000312
|
||||
[EP 0997] total_reward=11.0 steps=1051 epsilon=0.0074 avg_loss=0.000289
|
||||
[EP 0998] total_reward=14.0 steps=987 epsilon=0.0074 avg_loss=0.000274
|
||||
[EP 0999] total_reward=8.0 steps=1142 epsilon=0.0074 avg_loss=0.000261
|
||||
[EP 1000] total_reward=12.0 steps=1089 epsilon=0.0074 avg_loss=0.000248
|
||||
Last 5 checkpoints:
|
||||
[ep_0600] ep=0501-0600 avg_reward=+12.1 ...
|
||||
[ep_0700] ep=0601-0700 avg_reward=+14.8 ...
|
||||
[ep_0800] ep=0701-0800 avg_reward=+16.3 ...
|
||||
[ep_0900] ep=0801-0900 avg_reward=+19.0 ...
|
||||
[ep_1000] ep=0901-1000 avg_reward=+22.3 ...
|
||||
|
||||
Checkpoints (11): ['ep_0100.pt', ..., 'final.pt']
|
||||
Checkpoints (10): ['ep_0100.pt', 'ep_0200.pt', ..., 'ep_1000.pt']
|
||||
|
||||
Adjusting hyperparameters
|
||||
--------------------------
|
||||
@@ -241,7 +360,8 @@ before training, or by passing them as options to ``retro-gamer
|
||||
create``. Common adjustments and their effects:
|
||||
|
||||
**``training_episodes``** — How long to train. More episodes give the
|
||||
agent more time to learn, but also take longer to run.
|
||||
agent more time to learn, but also take longer to run. This is always
|
||||
safe to increase.
|
||||
|
||||
**``epsilon_decay``** — How quickly exploration decreases. A faster
|
||||
decay (smaller ``epsilon_decay``) means the agent commits to its early
|
||||
@@ -257,14 +377,124 @@ a small learning rate is stable but slow.
|
||||
means the agent values long-term consequences; closer to 0 makes the
|
||||
agent focus on immediate reward.
|
||||
|
||||
**``n_layers`` and ``layer_size``** — The depth and width of the MLP
|
||||
head. Larger networks can represent more complex Q-functions but are
|
||||
slower to train and may overfit.
|
||||
**``hidden_sizes``** — The shape of the MLP head as a list of layer
|
||||
sizes, e.g. ``[128, 64]``. Larger or deeper networks can represent
|
||||
more complex Q-functions but are slower to train and may overfit.
|
||||
|
||||
**``prioritize_experiences``** — Whether to use prioritized experience
|
||||
replay. This often improves sample efficiency but is slightly slower
|
||||
per step.
|
||||
|
||||
.. _incompatible-changes:
|
||||
|
||||
Why some changes require starting fresh
|
||||
----------------------------------------
|
||||
|
||||
Not all changes to ``config.toml`` are equal. Some can be applied
|
||||
immediately to an existing training run; others make the existing
|
||||
checkpoints unusable.
|
||||
|
||||
**Safe to change at any time** (``[training]`` section) — These affect
|
||||
*how* the agent learns, not *what* it is learning to do. Existing
|
||||
checkpoints remain valid:
|
||||
|
||||
- ``training_episodes``, ``max_turns_per_episode``
|
||||
- ``learning_rate``, ``learning_rate_decay``, ``gamma``
|
||||
- ``epsilon``, ``epsilon_decay``, ``epsilon_min``
|
||||
- ``batch_size``, ``memory_capacity``, ``prioritize_experiences``
|
||||
- ``target_update_freq``, ``train_every``
|
||||
|
||||
**Requires starting fresh** — These changes alter the shape of the
|
||||
game or the shape of the network. The saved model weights are
|
||||
incompatible with the new configuration:
|
||||
|
||||
- ``actions``, ``reward``, ``character_set``, ``board_size``
|
||||
(``[metadata]``) — These define what the agent perceives and what it
|
||||
can do. Changing them changes the size of the network's input or
|
||||
output layers; the existing weights no longer fit.
|
||||
- ``spatial``, ``board``, ``observe_state``, ``observe_state_sizes``,
|
||||
``egocentric``, ``egocentric_player``, ``egocentric_radius``
|
||||
(``[preprocessing]``) — These control how the observation is
|
||||
constructed. Any change here alters the input shape or meaning and
|
||||
makes existing weights invalid.
|
||||
- ``hidden_sizes`` (``[model]``) — This defines the network's hidden
|
||||
layers. Changing it changes the shape of the network; the existing
|
||||
weights no longer fit.
|
||||
|
||||
If you try to resume training after making one of these changes,
|
||||
``retro-gamer train`` detects the mismatch and stops with a clear
|
||||
explanation, for example::
|
||||
|
||||
Cannot resume from ep_0500.pt: incompatible changes detected in config.toml.
|
||||
|
||||
The following changes require starting fresh. The existing model was
|
||||
trained on a different problem and its weights cannot be reused:
|
||||
|
||||
character_set
|
||||
was : ['@', '*', '>', '<', '^', 'v']
|
||||
now : ['@', '*', '>', '<', '^', 'v', '#']
|
||||
why : the set of board characters (changes input layer size)
|
||||
|
||||
Run 'retro-gamer clean RUN_DIR' to remove existing checkpoints and the
|
||||
training log, then run 'retro-gamer train RUN_DIR' to start fresh.
|
||||
|
||||
To clear out the old checkpoints and begin again:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
% retro-gamer clean runs/snake/
|
||||
Will remove 5 checkpoint(s) and training log from runs/snake/:
|
||||
checkpoints/ep_0100.pt
|
||||
checkpoints/ep_0200.pt
|
||||
...
|
||||
training.log
|
||||
|
||||
Proceed? [y/N]: y
|
||||
Cleaned. Run 'retro-gamer train runs/snake/' to start fresh.
|
||||
|
||||
The ``config.toml`` is always preserved so you do not need to run
|
||||
``retro-gamer create`` again.
|
||||
|
||||
Reasoning about training from the log
|
||||
--------------------------------------
|
||||
|
||||
The training log is one of the most useful tools for understanding what
|
||||
is happening during training. Here are some patterns to look for and
|
||||
what they mean.
|
||||
|
||||
**Reward increasing steadily** is the normal, healthy pattern. Each
|
||||
checkpoint block should show a higher ``avg_reward`` than the last.
|
||||
The rate of increase typically slows as training progresses.
|
||||
|
||||
**Reward flat or negative through early episodes** is normal. Early in
|
||||
training, ``epsilon`` is high and the agent is mostly acting randomly.
|
||||
It has not yet discovered effective strategies. Patience—and a look at
|
||||
the ``epsilon`` column—will confirm whether this is just the exploration
|
||||
phase.
|
||||
|
||||
**Loss decreasing** is also healthy. As the Q-network's estimates
|
||||
improve, the difference between predicted and target Q-values (the TD
|
||||
error) should shrink. A loss that stabilizes near zero is usually a
|
||||
good sign.
|
||||
|
||||
**Loss growing without bound** indicates the learning rate is too high.
|
||||
The trainer uses Huber loss, which is robust to large reward scales, but
|
||||
a learning rate above roughly ``0.001`` can still destabilise training.
|
||||
Try reducing it by a factor of 10 (e.g. from ``0.001`` to ``0.0001``)
|
||||
and restarting training.
|
||||
|
||||
**Short episodes (low ``avg_steps``)** combined with low reward
|
||||
suggests the agent is dying frequently. Early in training this is
|
||||
normal. If it persists late in training, the agent may have settled on
|
||||
a bad policy—consider extending training or adjusting
|
||||
``epsilon_decay`` to explore longer.
|
||||
|
||||
**Reward that improves and then regresses** can indicate that the
|
||||
agent has discovered a suboptimal but consistent strategy and is stuck.
|
||||
Increasing ``epsilon_min`` to keep some exploration active, or
|
||||
adjusting the reward signal to better differentiate good moves from
|
||||
bad ones, can help.
|
||||
|
||||
Questions for investigation
|
||||
----------------------------
|
||||
|
||||
@@ -297,3 +527,4 @@ concepts underlying the training algorithm.
|
||||
episode 1000 and watch each play the same game. What has the later
|
||||
agent learned that the earlier one has not? How would you describe
|
||||
this difference to someone who does not know about neural networks?
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@ description = "A toolkit for learning reinforcement learning by training agents
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"retro-games>=2.2.0",
|
||||
"retro-games>=2.3.1",
|
||||
"torch>=2.0",
|
||||
"numpy>=1.24",
|
||||
"click>=8.0",
|
||||
"tomli-w>=1.0",
|
||||
"tqdm>=4.0",
|
||||
"plotext>=5.0",
|
||||
"matplotlib>=3.7",
|
||||
"seaborn>=0.13",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -21,6 +25,9 @@ documentation = [
|
||||
"sphinx-rtd-theme>=2.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
retro-games = { path = "../retro", editable = true }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from retro_gamer.metadata import GameMetadata
|
||||
from retro_gamer.env import GameEnvironment
|
||||
from retro_gamer.trainer import DQNTrainer
|
||||
from retro_gamer.model_agent import TrainedPolicy, PolicyInput
|
||||
|
||||
__all__ = ["GameMetadata", "GameEnvironment", "DQNTrainer"]
|
||||
__all__ = ["GameMetadata", "GameEnvironment", "DQNTrainer", "TrainedPolicy", "PolicyInput"]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
import importlib
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
import click
|
||||
import tomli_w
|
||||
|
||||
from retro_gamer.metadata import GameMetadata
|
||||
from retro_gamer.trainer import DQNTrainer, DEFAULTS
|
||||
from retro_gamer.trainer import DQNTrainer, DEFAULTS, MODEL_KEYS
|
||||
|
||||
|
||||
@click.group()
|
||||
@@ -20,13 +21,14 @@ def cli():
|
||||
|
||||
@cli.command()
|
||||
@click.option('--game', required=True,
|
||||
help='Python module containing create_game() e.g. retro.examples.snake')
|
||||
help='Game to train: a .py file path (e.g. my_game.py) or a Python module '
|
||||
'(e.g. retro.examples.snake)')
|
||||
@click.option('--output', required=True,
|
||||
help='Directory to create for this training run')
|
||||
@click.option('--learning-rate', default=DEFAULTS['learning_rate'], type=float,
|
||||
help=f"Adam optimizer learning rate (default {DEFAULTS['learning_rate']})")
|
||||
@click.option('--lr-decay', default=DEFAULTS['lr_decay'], type=float,
|
||||
help=f"Multiplicative LR decay per episode (default {DEFAULTS['lr_decay']})")
|
||||
@click.option('--learning-rate-decay', default=DEFAULTS['learning_rate_decay'], type=float,
|
||||
help=f"Multiplicative LR decay per episode (default {DEFAULTS['learning_rate_decay']})")
|
||||
@click.option('--gamma', default=DEFAULTS['gamma'], type=float,
|
||||
help=f"Discount factor for future rewards (default {DEFAULTS['gamma']})")
|
||||
@click.option('--epsilon-decay', default=DEFAULTS['epsilon_decay'], type=float,
|
||||
@@ -43,12 +45,12 @@ def cli():
|
||||
help=f"Number of episodes to train (default {DEFAULTS['training_episodes']})")
|
||||
@click.option('--max-turns-per-episode', default=DEFAULTS['max_turns_per_episode'], type=int,
|
||||
help=f"Turn limit per episode (default {DEFAULTS['max_turns_per_episode']})")
|
||||
@click.option('--n-layers', default=DEFAULTS['n_layers'], type=int,
|
||||
help=f"Hidden layers in MLP head (default {DEFAULTS['n_layers']})")
|
||||
@click.option('--layer-size', default=DEFAULTS['layer_size'], type=int,
|
||||
help=f"Width of each hidden layer (default {DEFAULTS['layer_size']})")
|
||||
@click.option('--hidden-sizes', default=','.join(str(s) for s in DEFAULTS['hidden_sizes']),
|
||||
help=f"Comma-separated hidden layer sizes, e.g. 512,256 (default {DEFAULTS['hidden_sizes']})")
|
||||
@click.option('--exploration-turns', default=DEFAULTS['exploration_turns'], type=int,
|
||||
help=f"Random turns for character discovery (default {DEFAULTS['exploration_turns']})")
|
||||
@click.option('--train-every', default=DEFAULTS['train_every'], type=int,
|
||||
help=f"Run a training step every N game steps (default {DEFAULTS['train_every']})")
|
||||
@click.option('--prioritize-experiences/--no-prioritize-experiences',
|
||||
default=DEFAULTS['prioritize_experiences'],
|
||||
help='Use prioritized experience replay')
|
||||
@@ -60,12 +62,23 @@ def create(game, output, **hyperparams):
|
||||
Board size is read directly from the game. Hyperparameter options
|
||||
control how the trainer learns, not what it learns about.
|
||||
"""
|
||||
raw = hyperparams['hidden_sizes']
|
||||
try:
|
||||
metadata = GameMetadata.from_pyproject(game)
|
||||
hyperparams['hidden_sizes'] = [int(x.strip()) for x in raw.split(',')]
|
||||
except ValueError:
|
||||
raise click.ClickException(
|
||||
f"Could not parse --hidden-sizes {raw!r}.\n"
|
||||
"It should be a comma-separated list of positive integers, one per hidden layer.\n"
|
||||
"Example: --hidden-sizes 512,256"
|
||||
)
|
||||
game_config = _parse_game_arg(game)
|
||||
|
||||
try:
|
||||
metadata = GameMetadata.from_pyproject(game_config['module'])
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
game_factory = _load_factory(game)
|
||||
game_factory = _load_factory(game_config)
|
||||
g = game_factory()
|
||||
metadata.board_size = g.board_size
|
||||
|
||||
@@ -74,10 +87,13 @@ def create(game, output, **hyperparams):
|
||||
run_dir = Path(output)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preprocessing = {'spatial': metadata.spatial}
|
||||
config = {
|
||||
'game': {'module': game},
|
||||
'game': game_config,
|
||||
'metadata': metadata.to_dict(),
|
||||
'hyperparameters': hyperparams,
|
||||
'preprocessing': preprocessing,
|
||||
'model': {k: v for k, v in hyperparams.items() if k in MODEL_KEYS},
|
||||
'training': {k: v for k, v in hyperparams.items() if k not in MODEL_KEYS},
|
||||
}
|
||||
with open(run_dir / 'config.toml', 'wb') as f:
|
||||
tomli_w.dump(config, f)
|
||||
@@ -91,8 +107,6 @@ def create(game, output, **hyperparams):
|
||||
click.echo(f" characters : {metadata.character_set}")
|
||||
else:
|
||||
click.echo(f" characters : (will be auto-discovered during training)")
|
||||
if metadata.observe_state:
|
||||
click.echo(f" observe : {metadata.observe_state}")
|
||||
click.echo(f" architecture: {'CNN (spatial)' if metadata.spatial else 'MLP (non-spatial)'}")
|
||||
|
||||
|
||||
@@ -102,23 +116,49 @@ def create(game, output, **hyperparams):
|
||||
|
||||
@cli.command()
|
||||
@click.argument('run_dir')
|
||||
@click.option('--resume', default=None,
|
||||
help='Path to checkpoint to resume from (e.g. checkpoints/ep_0500.pt)')
|
||||
def train(run_dir, resume):
|
||||
"""Train (or resume training) a DQN agent."""
|
||||
def train(run_dir):
|
||||
"""Train a DQN agent, resuming automatically from the latest checkpoint.
|
||||
|
||||
To start fresh or resume from an earlier point, delete the checkpoints
|
||||
you no longer want from RUN_DIR/checkpoints/.
|
||||
"""
|
||||
run_dir_path = Path(run_dir)
|
||||
config = _load_config(run_dir_path)
|
||||
game_factory = _load_factory(config['game']['module'])
|
||||
game_factory = _load_factory(config['game'])
|
||||
metadata = GameMetadata.from_dict(config['metadata'])
|
||||
hyperparams = config.get('hyperparameters', {})
|
||||
preprocessing = config.get('preprocessing', {})
|
||||
metadata.spatial = preprocessing.get('spatial', False)
|
||||
hyperparams = {**config.get('model', {}), **config['training']}
|
||||
|
||||
trainer = DQNTrainer(game_factory, metadata, run_dir, **hyperparams)
|
||||
if resume:
|
||||
click.echo(f"Resuming from {resume}")
|
||||
trainer.load_checkpoint(resume)
|
||||
click.echo(f"Training for {trainer.hp['training_episodes']} episodes…")
|
||||
trainer.train()
|
||||
click.echo(f"Done. Checkpoints in {run_dir}/checkpoints/")
|
||||
try:
|
||||
trainer = DQNTrainer(game_factory, metadata, run_dir, preprocessing=preprocessing, **hyperparams)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
latest = _latest_checkpoint(run_dir_path)
|
||||
if latest:
|
||||
click.echo(f"Resuming from {latest.name}")
|
||||
try:
|
||||
trainer.load_checkpoint(latest)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
if trainer.start_episode > trainer.hp['training_episodes']:
|
||||
click.echo(
|
||||
f"Training already complete ({trainer.hp['training_episodes']} episodes). "
|
||||
"To keep training, increase training_episodes in config.toml."
|
||||
)
|
||||
return
|
||||
|
||||
from retro_gamer.log_parser import parse_checkpoints
|
||||
from retro_gamer.dashboard import TrainingDashboard
|
||||
history = parse_checkpoints(run_dir_path / 'training.log')
|
||||
display = TrainingDashboard(trainer.hp['training_episodes'], trainer.start_episode, history)
|
||||
try:
|
||||
trainer.train(on_checkpoint=display.on_checkpoint, on_episode=display.on_episode)
|
||||
finally:
|
||||
display.close()
|
||||
click.echo(f"Done. Checkpoints saved in {run_dir}/checkpoints/")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,69 +167,72 @@ def train(run_dir, resume):
|
||||
|
||||
@cli.command()
|
||||
@click.argument('run_dir')
|
||||
@click.option('--checkpoint', default='final',
|
||||
help='Checkpoint name e.g. "final" or "ep_0100"')
|
||||
@click.option('--checkpoint', default=None,
|
||||
help='Checkpoint to load, e.g. "ep_0100". Defaults to the latest available.')
|
||||
@click.option('--framerate', default=12, type=int,
|
||||
help='Target frames per second')
|
||||
def play(run_dir, checkpoint, framerate):
|
||||
"""Watch a trained agent play the game."""
|
||||
import torch
|
||||
from time import sleep, perf_counter
|
||||
from blessed import Terminal
|
||||
from retro.input import ProgrammaticInput
|
||||
from retro.views.headless import HeadlessView
|
||||
from retro.views.terminal import TerminalView
|
||||
from retro_gamer.observation import encode_observation
|
||||
from retro_gamer.model_agent import TrainedPolicy, PolicyInput
|
||||
|
||||
run_dir_path = Path(run_dir)
|
||||
config = _load_config(run_dir_path)
|
||||
game_factory = _load_factory(config['game']['module'])
|
||||
metadata = GameMetadata.from_dict(config['metadata'])
|
||||
hyperparams = {**DEFAULTS, **config.get('hyperparameters', {})}
|
||||
game_factory = _load_factory(config['game'])
|
||||
|
||||
from retro_gamer.network import build_network
|
||||
model, _ = build_network(metadata, hyperparams)
|
||||
try:
|
||||
ai = TrainedPolicy(run_dir_path, checkpoint=checkpoint)
|
||||
except FileNotFoundError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
ckpt_name = checkpoint if checkpoint.endswith('.pt') else f'{checkpoint}.pt'
|
||||
ckpt_path = run_dir_path / 'checkpoints' / ckpt_name
|
||||
ckpt = torch.load(ckpt_path, weights_only=True)
|
||||
model.load_state_dict(ckpt['model_state_dict'])
|
||||
model.eval()
|
||||
|
||||
inp = ProgrammaticInput()
|
||||
headless = HeadlessView()
|
||||
game = game_factory()
|
||||
game.input_source = inp
|
||||
game.view = headless
|
||||
game.start()
|
||||
inp = PolicyInput(ai, game)
|
||||
|
||||
terminal = Terminal()
|
||||
term_view = TerminalView(terminal, color=game.color)
|
||||
|
||||
click.echo("Playing… (press Escape or Enter to quit)")
|
||||
with terminal.fullscreen(), terminal.hidden_cursor(), terminal.cbreak():
|
||||
term_view.on_game_start(game)
|
||||
game.input_source = inp
|
||||
game.view = term_view
|
||||
game.start()
|
||||
while game.playing:
|
||||
t0 = perf_counter()
|
||||
|
||||
obs = encode_observation(headless.board_characters, dict(game.state), metadata)
|
||||
state_t = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0)
|
||||
with torch.no_grad():
|
||||
action_idx = int(model(state_t).argmax().item())
|
||||
action_key = None if action_idx >= len(metadata.actions) else metadata.actions[action_idx]
|
||||
|
||||
key = terminal.inkey(0)
|
||||
if key and key.name in ('KEY_ESCAPE', 'KEY_ENTER'):
|
||||
break
|
||||
|
||||
inp.press(action_key)
|
||||
game.step()
|
||||
term_view.render(game)
|
||||
|
||||
elapsed = perf_counter() - t0
|
||||
sleep(max(0, 1 / framerate - elapsed))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retro-gamer plot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument('run_dir')
|
||||
@click.option('--output', '-o', default=None,
|
||||
help='Save to file (e.g. plot.png) instead of displaying interactively.')
|
||||
def plot(run_dir, output):
|
||||
"""Plot training metrics (reward, steps, loss, epsilon) from a run's log."""
|
||||
from retro_gamer.plotter import plot_run
|
||||
run_dir_path = Path(run_dir)
|
||||
log_path = run_dir_path / 'training.log'
|
||||
if not log_path.exists():
|
||||
raise click.ClickException(f"No training.log found in {run_dir}")
|
||||
output_path = Path(output) if output else None
|
||||
try:
|
||||
plot_run(log_path, output_path)
|
||||
except ValueError as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retro-gamer info
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -200,17 +243,19 @@ def info(run_dir):
|
||||
"""Print a summary of a training run."""
|
||||
run_dir_path = Path(run_dir)
|
||||
config = _load_config(run_dir_path)
|
||||
click.echo(f"Game module : {config['game']['module']}")
|
||||
click.echo(f"Metadata : {config['metadata']}")
|
||||
click.echo(f"Hyperparams : {config.get('hyperparameters', {})}")
|
||||
click.echo(f"Game module : {config['game']['module']}")
|
||||
click.echo(f"Metadata : {config['metadata']}")
|
||||
click.echo(f"Preprocessing : {config.get('preprocessing', {})}")
|
||||
click.echo(f"Model : {config.get('model', {})}")
|
||||
click.echo(f"Training : {config['training']}")
|
||||
|
||||
log_path = run_dir_path / 'training.log'
|
||||
if log_path.exists():
|
||||
lines = log_path.read_text().splitlines()
|
||||
episode_lines = [l for l in lines if l.startswith('[EP')]
|
||||
if episode_lines:
|
||||
click.echo(f"\nLast 5 episodes:")
|
||||
for line in episode_lines[-5:]:
|
||||
ckpt_lines = [l for l in lines if l.startswith('[ep_')]
|
||||
if ckpt_lines:
|
||||
click.echo(f"\nLast 5 checkpoints:")
|
||||
for line in ckpt_lines[-5:]:
|
||||
click.echo(f" {line}")
|
||||
|
||||
ckpt_dir = run_dir_path / 'checkpoints'
|
||||
@@ -219,10 +264,91 @@ def info(run_dir):
|
||||
click.echo(f"\nCheckpoints ({len(ckpts)}): {[c.name for c in ckpts]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# retro-gamer clean
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@cli.command()
|
||||
@click.argument('run_dir')
|
||||
@click.option('--yes', '-y', is_flag=True, help='Skip confirmation prompt')
|
||||
def clean(run_dir, yes):
|
||||
"""Remove all checkpoints and the training log from a run directory.
|
||||
|
||||
Use this after changing the game description or network architecture,
|
||||
which require starting training from scratch.
|
||||
"""
|
||||
run_dir_path = Path(run_dir)
|
||||
if not run_dir_path.exists():
|
||||
raise click.ClickException(f"Directory not found: {run_dir}")
|
||||
|
||||
to_remove = []
|
||||
ckpt_dir = run_dir_path / 'checkpoints'
|
||||
if ckpt_dir.exists():
|
||||
to_remove.extend(sorted(ckpt_dir.glob('*.pt')))
|
||||
log_path = run_dir_path / 'training.log'
|
||||
if log_path.exists():
|
||||
to_remove.append(log_path)
|
||||
|
||||
if not to_remove:
|
||||
click.echo("Nothing to clean.")
|
||||
return
|
||||
|
||||
n_ckpts = sum(1 for p in to_remove if p.suffix == '.pt')
|
||||
click.echo(f"Will remove {n_ckpts} checkpoint(s) and training log from {run_dir}/:")
|
||||
for p in to_remove:
|
||||
click.echo(f" {p.relative_to(run_dir_path)}")
|
||||
|
||||
if not yes:
|
||||
click.confirm("\nProceed?", abort=True)
|
||||
|
||||
for p in to_remove:
|
||||
p.unlink()
|
||||
click.echo(f"Cleaned. Run 'retro-gamer train {run_dir}' to start fresh.")
|
||||
|
||||
ckpt_dir = run_dir_path / 'checkpoints'
|
||||
if ckpt_dir.exists():
|
||||
ckpts = sorted(ckpt_dir.glob('*.pt'))
|
||||
click.echo(f"\nCheckpoints ({len(ckpts)}): {[c.name for c in ckpts]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_game_arg(arg: str) -> dict:
|
||||
"""Accept a .py file path, a package directory, or a Python module name.
|
||||
|
||||
Returns a dict with at least 'module', and optionally 'path' (the
|
||||
directory to add to sys.path so the module can be imported).
|
||||
"""
|
||||
p = Path(arg)
|
||||
if p.exists():
|
||||
if p.is_file() and p.suffix == '.py':
|
||||
path = str(p.parent.resolve())
|
||||
module = p.stem
|
||||
elif p.is_dir():
|
||||
path = str(p.parent.resolve())
|
||||
module = p.name
|
||||
else:
|
||||
raise click.ClickException(
|
||||
f"{arg!r} is not a .py file or package directory"
|
||||
)
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
return {'module': module, 'path': path}
|
||||
return {'module': arg}
|
||||
|
||||
|
||||
def _latest_checkpoint(run_dir: Path) -> Path | None:
|
||||
"""Return the most recent checkpoint in run_dir/checkpoints/, or None."""
|
||||
ckpt_dir = run_dir / 'checkpoints'
|
||||
if ckpt_dir.exists():
|
||||
candidates = sorted(ckpt_dir.glob('ep_*.pt'))
|
||||
if candidates:
|
||||
return candidates[-1]
|
||||
return None
|
||||
|
||||
|
||||
def _load_config(run_dir: Path) -> dict:
|
||||
config_path = run_dir / 'config.toml'
|
||||
if not config_path.exists():
|
||||
@@ -231,7 +357,11 @@ def _load_config(run_dir: Path) -> dict:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def _load_factory(module_name: str):
|
||||
def _load_factory(game_config: dict):
|
||||
path = game_config.get('path')
|
||||
if path and path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
module_name = game_config['module']
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
@@ -241,3 +371,4 @@ def _load_factory(module_name: str):
|
||||
f"Module '{module_name}' has no create_game() function"
|
||||
)
|
||||
return module.create_game
|
||||
|
||||
|
||||
86
retro_gamer/dashboard.py
Normal file
86
retro_gamer/dashboard.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
from tqdm import tqdm
|
||||
|
||||
_CHART_HEIGHT = 22 # plotext chart area height in lines
|
||||
|
||||
|
||||
def _terminal_width() -> int:
|
||||
try:
|
||||
return min(os.get_terminal_size().columns, 220)
|
||||
except OSError:
|
||||
return 120
|
||||
|
||||
|
||||
def _build_charts(history: list[dict], width: int) -> str:
|
||||
import plotext as plt
|
||||
episodes = [d['episode'] for d in history]
|
||||
series = [
|
||||
("Epsilon", [d['epsilon'] for d in history]),
|
||||
("Avg Steps", [d['avg_steps'] for d in history]),
|
||||
("Avg Loss", [d['avg_loss'] for d in history]),
|
||||
("Avg Reward", [d['avg_reward'] for d in history]),
|
||||
]
|
||||
panel_w = max(width // len(series), 20)
|
||||
panels = []
|
||||
for title, values in series:
|
||||
plt.clf()
|
||||
plt.canvas_color("default")
|
||||
plt.axes_color("default")
|
||||
plt.ticks_color("default")
|
||||
if episodes:
|
||||
plt.plot(episodes, values, color="default")
|
||||
plt.title(title)
|
||||
plt.xlabel("Episode")
|
||||
plt.plotsize(panel_w, _CHART_HEIGHT)
|
||||
panels.append(plt.build().splitlines())
|
||||
|
||||
height = max(len(p) for p in panels)
|
||||
for p in panels:
|
||||
while len(p) < height:
|
||||
p.append(' ' * panel_w)
|
||||
return '\n'.join(''.join(row) for row in zip(*panels))
|
||||
|
||||
|
||||
class TrainingDashboard:
|
||||
"""Inline training display: a plotext chart block that redraws in place
|
||||
above a tqdm per-episode progress bar."""
|
||||
|
||||
def __init__(self, total_episodes: int, start_episode: int, history: list[dict]):
|
||||
self._total = total_episodes
|
||||
self._history = list(history)
|
||||
self._rendered = False # have we drawn charts yet?
|
||||
|
||||
already_done = start_episode - 1
|
||||
self._bar = tqdm(
|
||||
initial=already_done,
|
||||
total=total_episodes,
|
||||
unit='ep',
|
||||
dynamic_ncols=True,
|
||||
)
|
||||
|
||||
self._draw()
|
||||
|
||||
def on_episode(self) -> None:
|
||||
self._bar.update(1)
|
||||
|
||||
def on_checkpoint(self, stats: dict) -> None:
|
||||
self._history.append(stats)
|
||||
self._bar.clear()
|
||||
self._draw()
|
||||
self._bar.refresh()
|
||||
|
||||
def close(self) -> None:
|
||||
self._bar.close()
|
||||
|
||||
def _draw(self) -> None:
|
||||
chart = _build_charts(self._history, _terminal_width())
|
||||
n_lines = len(chart.splitlines())
|
||||
if self._rendered:
|
||||
sys.stdout.write(f'\033[{n_lines}A')
|
||||
sys.stdout.write(chart)
|
||||
if not chart.endswith('\n'):
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.flush()
|
||||
self._rendered = True
|
||||
@@ -9,15 +9,27 @@ from retro_gamer.observation import encode_observation
|
||||
|
||||
|
||||
class GameEnvironment:
|
||||
"""Gym-style wrapper around a retro Game for RL training.
|
||||
"""Gym-style wrapper around a retro game for RL training."""
|
||||
|
||||
Provides reset() / step(action) / observe(), managing one training episode
|
||||
at a time. The game is restarted by calling the factory function on each reset.
|
||||
"""
|
||||
|
||||
def __init__(self, game_factory: Callable, metadata: GameMetadata):
|
||||
def __init__(
|
||||
self,
|
||||
game_factory: Callable,
|
||||
metadata: GameMetadata,
|
||||
observe_state: list[str] | None = None,
|
||||
egocentric: bool = False,
|
||||
egocentric_player: str | None = None,
|
||||
egocentric_radius: int | None = None,
|
||||
board: bool = True,
|
||||
observe_state_sizes: dict[str, int] | None = None,
|
||||
):
|
||||
self.game_factory = game_factory
|
||||
self.metadata = metadata
|
||||
self.observe_state = observe_state or []
|
||||
self.egocentric = egocentric
|
||||
self.egocentric_player = egocentric_player
|
||||
self.egocentric_radius = egocentric_radius
|
||||
self.board = board
|
||||
self.observe_state_sizes = observe_state_sizes or {}
|
||||
self.game = None
|
||||
self.view: HeadlessView | None = None
|
||||
self.inp: ProgrammaticInput | None = None
|
||||
@@ -35,12 +47,7 @@ class GameEnvironment:
|
||||
return self._observe()
|
||||
|
||||
def step(self, action: str | None) -> tuple[np.ndarray, float, bool]:
|
||||
"""Advance one turn with the given action.
|
||||
|
||||
action: a keystroke string (e.g. 'KEY_RIGHT') or None for no-op.
|
||||
Returns (observation, reward, done).
|
||||
Reward is the change in the reward state key since the previous step.
|
||||
"""
|
||||
"""Advance one turn. Returns (observation, reward, done)."""
|
||||
self.inp.press(action)
|
||||
self.game.step()
|
||||
obs = self._observe()
|
||||
@@ -49,12 +56,47 @@ class GameEnvironment:
|
||||
return obs, reward, done
|
||||
|
||||
def _observe(self) -> np.ndarray:
|
||||
state = dict(self.game.state)
|
||||
if self.observe_state_sizes:
|
||||
self._check_state_sizes(state)
|
||||
player_pos = None
|
||||
if self.egocentric and self.egocentric_player:
|
||||
agent = self.game.get_agent_by_name(self.egocentric_player)
|
||||
if agent is not None:
|
||||
player_pos = agent.position
|
||||
return encode_observation(
|
||||
self.view.board_characters,
|
||||
dict(self.game.state),
|
||||
state,
|
||||
self.metadata,
|
||||
self.observe_state,
|
||||
player_pos=player_pos,
|
||||
egocentric_radius=self.egocentric_radius,
|
||||
board=self.board,
|
||||
)
|
||||
|
||||
def _check_state_sizes(self, state: dict):
|
||||
for key, expected in self.observe_state_sizes.items():
|
||||
val = state.get(key)
|
||||
if val is None:
|
||||
actual = 0
|
||||
elif isinstance(val, (list, tuple)):
|
||||
actual = len(val)
|
||||
else:
|
||||
actual = 1
|
||||
if actual != expected:
|
||||
raise ValueError(
|
||||
f"State key '{key}' changed size during training:\n"
|
||||
f" Expected : {expected} (discovered at training start)\n"
|
||||
f" Got : {actual}\n\n"
|
||||
f"This means game.state['{key}'] has a different length in some\n"
|
||||
f"episodes than it had when training started. The neural network\n"
|
||||
f"has a fixed input size and cannot adapt to changing state shapes.\n\n"
|
||||
f"Fix: make sure create_game() always initializes '{key}' with a\n"
|
||||
f"fixed-length value before the game starts each episode.\n"
|
||||
f"For example, if '{key}' is a list of 9 values, it must always be\n"
|
||||
f"a list of exactly 9 values — never more, never fewer, never missing."
|
||||
)
|
||||
|
||||
def _delta_reward(self) -> float:
|
||||
current = float(self.game.state.get(self.metadata.reward, 0))
|
||||
delta = current - self._prev_reward
|
||||
@@ -62,10 +104,8 @@ class GameEnvironment:
|
||||
return delta
|
||||
|
||||
def discover_character_set(self, exploration_turns: int) -> list[str]:
|
||||
"""Run random turns to discover the characters that appear on the board.
|
||||
Returns the sorted character list (excluding space).
|
||||
"""
|
||||
obs = self.reset()
|
||||
"""Run random turns to discover the characters that appear on the board."""
|
||||
self.reset()
|
||||
chars: set[str] = set()
|
||||
for _ in range(exploration_turns):
|
||||
for row in self.view.board_characters:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from retro.game import Game
|
||||
from retro_gamer.examples.beast.board import Board
|
||||
|
||||
WIDTH = 40
|
||||
HEIGHT = 20
|
||||
NUM_BEASTS = 10
|
||||
|
||||
def create_game():
|
||||
"""Return a fresh, initialized Beast game."""
|
||||
board = Board(WIDTH, HEIGHT, num_beasts=NUM_BEASTS)
|
||||
state = {'beasts_killed': 0}
|
||||
game = Game(board.get_agents(), state, board_size=(WIDTH, HEIGHT))
|
||||
game.num_beasts = NUM_BEASTS
|
||||
return game
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_game().play()
|
||||
@@ -1,67 +0,0 @@
|
||||
from retro_gamer.examples.beast.helpers import add, distance, get_occupant
|
||||
from random import random, choice
|
||||
|
||||
class Beast:
|
||||
"""A beast that hunts the player."""
|
||||
character = "H"
|
||||
color = "red"
|
||||
probability_of_moving = 0.03
|
||||
probability_of_random_move = 0.2
|
||||
deadly = True
|
||||
|
||||
def __init__(self, position):
|
||||
self.position = position
|
||||
|
||||
def handle_push(self, vector, game):
|
||||
future_position = add(self.position, vector)
|
||||
on_board = game.on_board(future_position)
|
||||
obstacle = get_occupant(game, future_position)
|
||||
if obstacle or not on_board:
|
||||
self.die(game)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def play_turn(self, game):
|
||||
if self.should_move():
|
||||
possible_moves = []
|
||||
for position in self.get_adjacent_positions():
|
||||
if game.is_empty(position) and game.on_board(position):
|
||||
possible_moves.append(position)
|
||||
if possible_moves:
|
||||
if self.should_move_randomly():
|
||||
self.position = choice(possible_moves)
|
||||
else:
|
||||
self.position = self.choose_best_move(possible_moves, game)
|
||||
player = game.get_agent_by_name("player")
|
||||
if player and player.position == self.position:
|
||||
player.die(game)
|
||||
|
||||
def get_adjacent_positions(self):
|
||||
"""Returns all eight adjacent positions, including diagonals."""
|
||||
positions = []
|
||||
for i in [-1, 0, 1]:
|
||||
for j in [-1, 0, 1]:
|
||||
if i or j:
|
||||
positions.append(add(self.position, (i, j)))
|
||||
return positions
|
||||
|
||||
def should_move(self):
|
||||
return random() < self.probability_of_moving
|
||||
|
||||
def should_move_randomly(self):
|
||||
return random() < self.probability_of_random_move
|
||||
|
||||
def choose_best_move(self, possible_moves, game):
|
||||
player = game.get_agent_by_name("player")
|
||||
move_distances = [[distance(player.position, move), move] for move in possible_moves]
|
||||
shortest_distance, best_move = sorted(move_distances)[0]
|
||||
return best_move
|
||||
|
||||
def die(self, game):
|
||||
game.remove_agent(self)
|
||||
game.num_beasts -= 1
|
||||
game.state['beasts_killed'] += 1
|
||||
if game.num_beasts == 0:
|
||||
game.state["message"] = "You win!"
|
||||
game.end()
|
||||
@@ -1,25 +0,0 @@
|
||||
from retro_gamer.examples.beast.helpers import add, get_occupant
|
||||
|
||||
class Block:
|
||||
"""A static block that can be pushed by the player."""
|
||||
character = "█"
|
||||
color = "green4"
|
||||
deadly = False
|
||||
|
||||
def __init__(self, position):
|
||||
self.position = position
|
||||
|
||||
def handle_push(self, vector, game):
|
||||
"""Responds to a push in the direction of vector.
|
||||
Returns True when the push succeeds in creating empty space.
|
||||
"""
|
||||
future_position = add(self.position, vector)
|
||||
on_board = game.on_board(future_position)
|
||||
obstacle = get_occupant(game, future_position)
|
||||
if obstacle:
|
||||
success = obstacle.handle_push(vector, game)
|
||||
else:
|
||||
success = on_board
|
||||
if success:
|
||||
self.position = future_position
|
||||
return success
|
||||
@@ -1,39 +0,0 @@
|
||||
from retro_gamer.examples.beast.helpers import add, get_occupant
|
||||
|
||||
direction_vectors = {
|
||||
"KEY_RIGHT": (1, 0),
|
||||
"KEY_UP": (0, -1),
|
||||
"KEY_LEFT": (-1, 0),
|
||||
"KEY_DOWN": (0, 1),
|
||||
}
|
||||
|
||||
class Player:
|
||||
character = "*"
|
||||
color = "white"
|
||||
name = "player"
|
||||
deadly = False
|
||||
|
||||
def __init__(self, position):
|
||||
self.position = position
|
||||
|
||||
def handle_keystroke(self, keystroke, game):
|
||||
if keystroke.name in direction_vectors:
|
||||
vector = direction_vectors[keystroke.name]
|
||||
self.try_to_move(vector, game)
|
||||
|
||||
def try_to_move(self, vector, game):
|
||||
future_position = add(self.position, vector)
|
||||
on_board = game.on_board(future_position)
|
||||
obstacle = get_occupant(game, future_position)
|
||||
if obstacle:
|
||||
if obstacle.deadly:
|
||||
self.die(game)
|
||||
elif obstacle.handle_push(vector, game):
|
||||
self.position = future_position
|
||||
elif on_board:
|
||||
self.position = future_position
|
||||
|
||||
def die(self, game):
|
||||
self.color = "black_on_red"
|
||||
game.state["message"] = "The beasties win!"
|
||||
game.end()
|
||||
@@ -1,44 +0,0 @@
|
||||
from random import shuffle
|
||||
from retro_gamer.examples.beast.agents.player import Player
|
||||
from retro_gamer.examples.beast.agents.beast import Beast
|
||||
from retro_gamer.examples.beast.agents.block import Block
|
||||
|
||||
class Board:
|
||||
"""Creates the agents needed at the beginning of the game and assigns their positions."""
|
||||
|
||||
def __init__(self, width, height, block_density=0.3, num_beasts=10):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.block_density = block_density
|
||||
self.num_blocks = round(width * height * block_density)
|
||||
self.num_empty_spaces = width * height - self.num_blocks
|
||||
self.num_beasts = num_beasts
|
||||
self.validate()
|
||||
|
||||
def validate(self):
|
||||
if self.block_density < 0 or self.block_density > 1:
|
||||
raise ValueError("block density must be between 0 and 1.")
|
||||
if self.num_empty_spaces < self.num_beasts + 1:
|
||||
raise ValueError("Not enough space on the board.")
|
||||
|
||||
def get_agents(self):
|
||||
"""Returns a list of agents initialized in their starting positions."""
|
||||
positions = self.get_all_positions()
|
||||
shuffle(positions)
|
||||
|
||||
player_position = positions[0]
|
||||
beast_positions = positions[1:self.num_beasts + 1]
|
||||
block_positions = positions[-self.num_blocks:]
|
||||
|
||||
player = [Player(player_position)]
|
||||
beasts = [Beast(pos) for pos in beast_positions]
|
||||
blocks = [Block(pos) for pos in block_positions]
|
||||
return player + beasts + blocks
|
||||
|
||||
def get_all_positions(self):
|
||||
"""Returns a list of all positions on the board."""
|
||||
positions = []
|
||||
for i in range(self.width):
|
||||
for j in range(self.height):
|
||||
positions.append((i, j))
|
||||
return positions
|
||||
@@ -1,18 +0,0 @@
|
||||
def add(vec0, vec1):
|
||||
"""Adds two vectors."""
|
||||
x0, y0 = vec0
|
||||
x1, y1 = vec1
|
||||
return (x0 + x1, y0 + y1)
|
||||
|
||||
def get_occupant(game, position):
|
||||
"""Returns the agent at position, if there is one."""
|
||||
positions_with_agents = game.get_agents_by_position()
|
||||
if position in positions_with_agents:
|
||||
agents_at_position = positions_with_agents[position]
|
||||
return agents_at_position[0]
|
||||
|
||||
def distance(vec0, vec1):
|
||||
"""Returns the Manhattan distance between two positions."""
|
||||
x0, y0 = vec0
|
||||
x1, y1 = vec1
|
||||
return abs(x1 - x0) + abs(y1 - y0)
|
||||
@@ -1,6 +0,0 @@
|
||||
[tool.retro-gamer]
|
||||
actions = ["KEY_RIGHT", "KEY_UP", "KEY_LEFT", "KEY_DOWN"]
|
||||
reward = "beasts_killed"
|
||||
character_set = ["*", "H", "█"]
|
||||
spatial = true
|
||||
observe_state = []
|
||||
30
retro_gamer/log_parser.py
Normal file
30
retro_gamer/log_parser.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_LINE_RE = re.compile(
|
||||
r'\[ep_(\d+)\]'
|
||||
r'.*avg_reward=([+-]?\d+\.?\d*)'
|
||||
r'.*avg_steps=(\d+\.?\d*)'
|
||||
r'.*epsilon=(\d+\.?\d*)'
|
||||
r'.*avg_loss=(\d+\.?\d*)'
|
||||
)
|
||||
|
||||
|
||||
def parse_checkpoints(log_path: Path) -> list[dict]:
|
||||
"""Parse checkpoint lines from a training log. Returns a list of dicts
|
||||
with keys: episode, avg_reward, avg_steps, epsilon, avg_loss."""
|
||||
results = []
|
||||
if not log_path.exists():
|
||||
return results
|
||||
for line in log_path.read_text().splitlines():
|
||||
m = _LINE_RE.search(line)
|
||||
if m:
|
||||
results.append({
|
||||
'episode': int(m.group(1)),
|
||||
'avg_reward': float(m.group(2)),
|
||||
'avg_steps': float(m.group(3)),
|
||||
'epsilon': float(m.group(4)),
|
||||
'avg_loss': float(m.group(5)),
|
||||
})
|
||||
return results
|
||||
@@ -11,39 +11,58 @@ class GameMetadata:
|
||||
"""Describes a retro game for training purposes.
|
||||
|
||||
Required fields: actions, reward.
|
||||
Optional fields: character_set, spatial, observe_state.
|
||||
Discovered fields: board_size (read from game.board_size at startup).
|
||||
|
||||
Metadata is read from the game's own pyproject.toml under the
|
||||
[tool.retro-gamer] section via GameMetadata.from_pyproject().
|
||||
Optional fields: character_set, spatial.
|
||||
Discovered fields: board_size (from game.board_size), extras_size (from
|
||||
the observe_state list in [preprocessing]).
|
||||
"""
|
||||
actions: list[str]
|
||||
reward: str
|
||||
character_set: list[str] | None = None
|
||||
spatial: bool = True
|
||||
observe_state: list[str] = field(default_factory=list)
|
||||
spatial: bool = False
|
||||
board: bool = True
|
||||
board_size: tuple[int, int] | None = None
|
||||
extras_size: int = 0
|
||||
|
||||
def validate(self):
|
||||
if not self.actions:
|
||||
raise ValueError("actions must be a non-empty list")
|
||||
raise ValueError(
|
||||
"The 'actions' list in [tool.retro-gamer] is empty or missing.\n"
|
||||
"It should list the keyboard keys your agent can press, for example:\n\n"
|
||||
' actions = ["KEY_RIGHT", "KEY_UP", "KEY_LEFT", "KEY_DOWN"]\n\n'
|
||||
"The agent will learn which actions lead to higher rewards."
|
||||
)
|
||||
if not isinstance(self.actions, list) or not all(isinstance(a, str) for a in self.actions):
|
||||
raise ValueError(
|
||||
f"'actions' must be a list of strings, but got: {self.actions!r}\n"
|
||||
"Each entry should be a key name like \"KEY_RIGHT\" or \"KEY_SPACE\"."
|
||||
)
|
||||
if not self.reward:
|
||||
raise ValueError("reward must be a non-empty string")
|
||||
if self.reward in self.observe_state:
|
||||
raise ValueError(f"reward key '{self.reward}' must not appear in observe_state")
|
||||
raise ValueError(
|
||||
"The 'reward' field in [tool.retro-gamer] is empty or missing.\n"
|
||||
"It should name a game state variable whose value the agent is trying\n"
|
||||
"to maximize — for example:\n\n"
|
||||
" reward = \"score\"\n\n"
|
||||
"The trainer watches how this value changes each step and uses those\n"
|
||||
"changes as the reward signal."
|
||||
)
|
||||
if self.character_set is not None:
|
||||
if not isinstance(self.character_set, list):
|
||||
raise ValueError(
|
||||
f"'character_set' must be a list of single characters, but got: {self.character_set!r}\n"
|
||||
"Example: character_set = [\"@\", \"*\", \"#\"]"
|
||||
)
|
||||
for ch in self.character_set:
|
||||
if len(ch) != 1:
|
||||
raise ValueError(f"character_set entries must be single characters, got {ch!r}")
|
||||
if not isinstance(ch, str) or len(ch) != 1:
|
||||
raise ValueError(
|
||||
f"Every entry in character_set must be a single character, but got {ch!r}.\n"
|
||||
"Each character represents one type of cell on the game board.\n"
|
||||
"If you're not sure what characters your game uses, remove character_set\n"
|
||||
"entirely and the trainer will discover them automatically."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_pyproject(cls, module_name: str) -> GameMetadata:
|
||||
"""Load metadata from the [tool.retro-gamer] section of the game's pyproject.toml.
|
||||
|
||||
Finds pyproject.toml by walking up from the module's source file.
|
||||
Raises FileNotFoundError if no pyproject.toml is found, or ValueError
|
||||
if the file exists but has no [tool.retro-gamer] section.
|
||||
"""
|
||||
"""Load metadata from the [tool.retro-gamer] section of the game's pyproject.toml."""
|
||||
pyproject_path = _find_pyproject(module_name)
|
||||
if pyproject_path is None:
|
||||
raise FileNotFoundError(
|
||||
@@ -65,13 +84,23 @@ class GameMetadata:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> GameMetadata:
|
||||
missing = [k for k in ('actions', 'reward') if k not in d]
|
||||
if missing:
|
||||
fields = ' and '.join(f"'{k}'" for k in missing)
|
||||
raise ValueError(
|
||||
f"The [tool.retro-gamer] section is missing required {fields}.\n"
|
||||
"A minimal configuration looks like this:\n\n"
|
||||
"[tool.retro-gamer]\n"
|
||||
'actions = ["KEY_RIGHT", "KEY_UP", "KEY_LEFT", "KEY_DOWN"]\n'
|
||||
'reward = "score"\n\n'
|
||||
"See the documentation for all available options."
|
||||
)
|
||||
board_size = tuple(d['board_size']) if 'board_size' in d else None
|
||||
return cls(
|
||||
actions=d['actions'],
|
||||
reward=d['reward'],
|
||||
character_set=d.get('character_set'),
|
||||
spatial=d.get('spatial', True),
|
||||
observe_state=d.get('observe_state', []),
|
||||
spatial=d.get('spatial', False),
|
||||
board_size=board_size,
|
||||
)
|
||||
|
||||
@@ -79,8 +108,6 @@ class GameMetadata:
|
||||
d = {
|
||||
'actions': self.actions,
|
||||
'reward': self.reward,
|
||||
'spatial': self.spatial,
|
||||
'observe_state': self.observe_state,
|
||||
}
|
||||
if self.board_size is not None:
|
||||
d['board_size'] = list(self.board_size)
|
||||
@@ -95,9 +122,11 @@ class GameMetadata:
|
||||
@property
|
||||
def obs_size(self) -> int:
|
||||
"""Total size of the flat observation vector."""
|
||||
if not self.board:
|
||||
return self.extras_size
|
||||
C = len(self.character_set) if self.character_set else 0
|
||||
bw, bh = self.board_size
|
||||
return C * bw * bh + len(self.observe_state)
|
||||
return C * bw * bh + self.extras_size
|
||||
|
||||
@property
|
||||
def n_actions(self) -> int:
|
||||
@@ -118,4 +147,6 @@ def _find_pyproject(module_name: str) -> Path | None:
|
||||
candidate = parent / 'pyproject.toml'
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
if parent.name == 'site-packages':
|
||||
break
|
||||
return None
|
||||
|
||||
139
retro_gamer/model_agent.py
Normal file
139
retro_gamer/model_agent.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
import tomllib
|
||||
import torch
|
||||
from pathlib import Path
|
||||
from blessed.keyboard import Keystroke
|
||||
from retro.input import ProgrammaticInput
|
||||
from retro.views.headless import HeadlessView
|
||||
from retro_gamer.metadata import GameMetadata
|
||||
from retro_gamer.observation import encode_observation
|
||||
|
||||
|
||||
class TrainedPolicy:
|
||||
"""A trained retro-gamer model that can observe a game and choose actions.
|
||||
|
||||
Load from a training run directory, then call ``get_action(game)`` from
|
||||
inside any agent's ``play_turn`` to get the model's recommended key.
|
||||
|
||||
Example::
|
||||
|
||||
from retro_gamer import TrainedPolicy
|
||||
|
||||
_ai = TrainedPolicy("runs/enemy/")
|
||||
|
||||
class EnemyAgent:
|
||||
def play_turn(self, game):
|
||||
key = _ai.get_action(game)
|
||||
if key == 'KEY_RIGHT': self.direction = (1, 0)
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, run_dir: str | Path, checkpoint: str | None = None):
|
||||
from retro_gamer.network import build_network
|
||||
from retro_gamer.trainer import DEFAULTS
|
||||
|
||||
run_dir = Path(run_dir)
|
||||
config_path = run_dir / 'config.toml'
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(f"No config.toml found in {run_dir}")
|
||||
|
||||
with open(config_path, 'rb') as f:
|
||||
config = tomllib.load(f)
|
||||
|
||||
self._metadata = GameMetadata.from_dict(config['metadata'])
|
||||
|
||||
pre = config.get('preprocessing', {})
|
||||
self._metadata.spatial = pre.get('spatial', False)
|
||||
self._metadata.board = pre.get('board', True)
|
||||
observe_state_sizes = pre.get('observe_state_sizes', {})
|
||||
self._observe_state: list[str] = pre.get('observe_state', [])
|
||||
self._egocentric: bool = pre.get('egocentric', False)
|
||||
self._egocentric_player: str | None = pre.get('egocentric_player')
|
||||
self._egocentric_radius: int | None = pre.get('egocentric_radius')
|
||||
self._board: bool = pre.get('board', True)
|
||||
|
||||
if observe_state_sizes:
|
||||
self._metadata.extras_size = sum(observe_state_sizes.values())
|
||||
else:
|
||||
self._metadata.extras_size = len(self._observe_state)
|
||||
|
||||
hyperparams = {**DEFAULTS, **config.get('model', {}), **config.get('training', {})}
|
||||
self._model, _ = build_network(self._metadata, hyperparams)
|
||||
|
||||
if checkpoint is not None:
|
||||
ckpt_name = checkpoint if checkpoint.endswith('.pt') else f'{checkpoint}.pt'
|
||||
ckpt_path = run_dir / 'checkpoints' / ckpt_name
|
||||
if not ckpt_path.exists():
|
||||
raise FileNotFoundError(f"Checkpoint not found: {ckpt_path}")
|
||||
else:
|
||||
ckpt_dir = run_dir / 'checkpoints'
|
||||
candidates = sorted(ckpt_dir.glob('ep_*.pt')) if ckpt_dir.exists() else []
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"No checkpoints found in {ckpt_dir}")
|
||||
ckpt_path = candidates[-1]
|
||||
|
||||
ckpt = torch.load(ckpt_path, weights_only=True)
|
||||
self._model.load_state_dict(ckpt['model_state_dict'])
|
||||
self._model.eval()
|
||||
|
||||
def get_action(self, game) -> str | None:
|
||||
"""Return the key the model recommends this turn, or None for no-op."""
|
||||
view = HeadlessView()
|
||||
view.on_game_start(game)
|
||||
view.render(game)
|
||||
board_chars = view.board_characters
|
||||
|
||||
player_pos = None
|
||||
if self._egocentric and self._egocentric_player:
|
||||
agent = game.get_agent_by_name(self._egocentric_player)
|
||||
if agent is not None:
|
||||
player_pos = agent.position
|
||||
|
||||
obs = encode_observation(
|
||||
board_chars,
|
||||
dict(game.state),
|
||||
self._metadata,
|
||||
self._observe_state,
|
||||
player_pos=player_pos,
|
||||
egocentric_radius=self._egocentric_radius,
|
||||
board=self._board,
|
||||
)
|
||||
|
||||
device = next(self._model.parameters()).device
|
||||
state_t = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0).to(device)
|
||||
with torch.no_grad():
|
||||
action_idx = int(self._model(state_t).argmax().item())
|
||||
if action_idx >= len(self._metadata.actions):
|
||||
return None
|
||||
return self._metadata.actions[action_idx]
|
||||
|
||||
|
||||
def _keystroke(name: str) -> Keystroke:
|
||||
if name.startswith("KEY_"):
|
||||
return Keystroke(ucs='', code=None, name=name)
|
||||
return Keystroke(ucs=name, code=None, name=None)
|
||||
|
||||
|
||||
class PolicyInput:
|
||||
"""An InputSource that drives the game with a TrainedPolicy instead of the keyboard.
|
||||
|
||||
Pass it as ``input_source`` to ``game.play()`` and everything else works
|
||||
exactly as usual.
|
||||
|
||||
Example::
|
||||
|
||||
from retro_gamer import TrainedPolicy, PolicyInput
|
||||
ai = TrainedPolicy("runs/snake/")
|
||||
game = create_game()
|
||||
game.play(input_source=PolicyInput(ai, game))
|
||||
"""
|
||||
|
||||
def __init__(self, model: TrainedPolicy, game):
|
||||
self._model = model
|
||||
self._game = game
|
||||
self._inp = ProgrammaticInput()
|
||||
|
||||
def collect(self) -> set:
|
||||
key = self._model.get_action(self._game)
|
||||
self._inp.press(key)
|
||||
return self._inp.collect()
|
||||
@@ -13,32 +13,36 @@ def build_network(
|
||||
Returns (model, rationale) where rationale is a multi-line string
|
||||
describing the architecture and the reasoning behind each choice.
|
||||
"""
|
||||
n_layers = hyperparams.get('n_layers', 2)
|
||||
layer_size = hyperparams.get('layer_size', 128)
|
||||
C = len(metadata.character_set)
|
||||
bw, bh = metadata.board_size
|
||||
W, H = bw, bh
|
||||
n_state = len(metadata.observe_state)
|
||||
hidden_sizes = hyperparams.get('hidden_sizes', [512, 256])
|
||||
n_state = metadata.extras_size
|
||||
n_actions = metadata.n_actions
|
||||
|
||||
lines = []
|
||||
lines.append("[INIT] === Network Architecture ===")
|
||||
lines.append(f"[INIT] Board: {W}×{H}, character set: {C} chars (one-hot per cell)")
|
||||
lines.append(f"[INIT] Observed state keys: {n_state} | Actions (incl. no-op): {n_actions}")
|
||||
|
||||
if metadata.spatial:
|
||||
model = _build_spatial(C, H, W, n_state, n_layers, layer_size, n_actions, lines)
|
||||
if metadata.board:
|
||||
C = len(metadata.character_set)
|
||||
bw, bh = metadata.board_size
|
||||
W, H = bw, bh
|
||||
lines.append(f"[INIT] Board: {W}×{H}, character set: {C} chars (one-hot per cell)")
|
||||
lines.append(f"[INIT] Observed state features: {n_state} | Actions (incl. no-op): {n_actions}")
|
||||
if metadata.spatial:
|
||||
model = _build_spatial(C, H, W, n_state, hidden_sizes, n_actions, lines)
|
||||
else:
|
||||
obs_size = C * W * H + n_state
|
||||
model = _build_flat(obs_size, hidden_sizes, n_actions, lines)
|
||||
else:
|
||||
obs_size = C * W * H + n_state
|
||||
model = _build_flat(obs_size, n_layers, layer_size, n_actions, lines)
|
||||
lines.append(f"[INIT] Board: disabled (board=false, state-only observation)")
|
||||
lines.append(f"[INIT] Observed state features: {n_state} | Actions (incl. no-op): {n_actions}")
|
||||
model = _build_flat(n_state, hidden_sizes, n_actions, lines)
|
||||
|
||||
lines.append(f"[INIT] Hidden layers: {n_layers} | Layer width: {layer_size}")
|
||||
lines.append(f"[INIT] Hidden layers: {len(hidden_sizes)} | Layer sizes: {hidden_sizes}")
|
||||
lines.append(f"[INIT] Output: {n_actions} Q-values")
|
||||
lines.append(f"[INIT] Actions: {metadata.actions} + (no-op)")
|
||||
return model, '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_spatial(C, H, W, n_state, n_layers, layer_size, n_actions, lines):
|
||||
def _build_spatial(C, H, W, n_state, hidden_sizes, n_actions, lines):
|
||||
lines.append("[INIT] spatial=True → using CNN architecture")
|
||||
lines.append("[INIT] Rationale: the board is a 2-D spatial scene; a CNN captures")
|
||||
lines.append("[INIT] local patterns (walls, items nearby) more efficiently than an MLP.")
|
||||
@@ -47,20 +51,20 @@ def _build_spatial(C, H, W, n_state, n_layers, layer_size, n_actions, lines):
|
||||
lines.append(f"[INIT] CNN output: 64 channels × {H}×{W} = {conv_out} features (flattened)")
|
||||
mlp_in = conv_out + n_state
|
||||
lines.append(f"[INIT] MLP head input: {conv_out} (conv) + {n_state} (state) = {mlp_in}")
|
||||
lines.append(f"[INIT] MLP: {' → '.join([str(mlp_in)] + [str(layer_size)] * n_layers + [str(n_actions)])}")
|
||||
return _SpatialNet(C, H, W, n_state, n_layers, layer_size, n_actions)
|
||||
lines.append(f"[INIT] MLP: {' → '.join([str(mlp_in)] + [str(s) for s in hidden_sizes] + [str(n_actions)])}")
|
||||
return _SpatialNet(C, H, W, n_state, hidden_sizes, n_actions)
|
||||
|
||||
|
||||
def _build_flat(obs_size, n_layers, layer_size, n_actions, lines):
|
||||
def _build_flat(obs_size, hidden_sizes, n_actions, lines):
|
||||
lines.append("[INIT] spatial=False → using MLP architecture")
|
||||
lines.append("[INIT] Rationale: the board encodes UI/status rather than a spatial scene;")
|
||||
lines.append("[INIT] a flat MLP over the full observation is sufficient.")
|
||||
lines.append(f"[INIT] MLP: {' → '.join([str(obs_size)] + [str(layer_size)] * n_layers + [str(n_actions)])}")
|
||||
return _FlatNet(obs_size, n_layers, layer_size, n_actions)
|
||||
lines.append(f"[INIT] MLP: {' → '.join([str(obs_size)] + [str(s) for s in hidden_sizes] + [str(n_actions)])}")
|
||||
return _FlatNet(obs_size, hidden_sizes, n_actions)
|
||||
|
||||
|
||||
class _SpatialNet(nn.Module):
|
||||
def __init__(self, C, H, W, n_state, n_layers, layer_size, n_actions):
|
||||
def __init__(self, C, H, W, n_state, hidden_sizes, n_actions):
|
||||
super().__init__()
|
||||
self.C, self.H, self.W = C, H, W
|
||||
self.n_board = C * H * W
|
||||
@@ -73,10 +77,11 @@ class _SpatialNet(nn.Module):
|
||||
conv_out = 64 * H * W
|
||||
mlp_in = conv_out + n_state
|
||||
layers: list[nn.Module] = []
|
||||
for i in range(n_layers):
|
||||
in_size = mlp_in if i == 0 else layer_size
|
||||
layers += [nn.Linear(in_size, layer_size), nn.ReLU()]
|
||||
layers.append(nn.Linear(layer_size, n_actions))
|
||||
prev = mlp_in
|
||||
for size in hidden_sizes:
|
||||
layers += [nn.Linear(prev, size), nn.ReLU()]
|
||||
prev = size
|
||||
layers.append(nn.Linear(prev, n_actions))
|
||||
self.mlp = nn.Sequential(*layers)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
@@ -87,13 +92,14 @@ class _SpatialNet(nn.Module):
|
||||
|
||||
|
||||
class _FlatNet(nn.Module):
|
||||
def __init__(self, obs_size, n_layers, layer_size, n_actions):
|
||||
def __init__(self, obs_size, hidden_sizes, n_actions):
|
||||
super().__init__()
|
||||
layers: list[nn.Module] = []
|
||||
for i in range(n_layers):
|
||||
in_size = obs_size if i == 0 else layer_size
|
||||
layers += [nn.Linear(in_size, layer_size), nn.ReLU()]
|
||||
layers.append(nn.Linear(layer_size, n_actions))
|
||||
prev = obs_size
|
||||
for size in hidden_sizes:
|
||||
layers += [nn.Linear(prev, size), nn.ReLU()]
|
||||
prev = size
|
||||
layers.append(nn.Linear(prev, n_actions))
|
||||
self.net = nn.Sequential(*layers)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
|
||||
@@ -3,11 +3,7 @@ from retro_gamer.metadata import GameMetadata
|
||||
|
||||
|
||||
def encode_board(board_chars: list[list[str]], character_set: list[str]) -> np.ndarray:
|
||||
"""One-hot encode the board.
|
||||
|
||||
Returns an array of shape (H, W, C) where C = len(character_set).
|
||||
Unknown characters produce a zero vector.
|
||||
"""
|
||||
"""One-hot encode the board. Returns (H, W, C). Unknown characters → zero vector."""
|
||||
char_to_idx = {c: i for i, c in enumerate(character_set)}
|
||||
H = len(board_chars)
|
||||
W = len(board_chars[0]) if board_chars else 0
|
||||
@@ -22,28 +18,78 @@ def encode_board(board_chars: list[list[str]], character_set: list[str]) -> np.n
|
||||
|
||||
|
||||
def encode_state(state: dict, observe_state: list[str]) -> np.ndarray:
|
||||
"""Extract observed state keys into a 1D float array."""
|
||||
return np.array([float(state.get(k, 0)) for k in observe_state], dtype=np.float32)
|
||||
"""Extract selected keys from game.state into a 1D float array.
|
||||
|
||||
Scalar values contribute one element; list/tuple values are flattened.
|
||||
"""
|
||||
values: list[float] = []
|
||||
for k in observe_state:
|
||||
val = state.get(k, 0)
|
||||
if isinstance(val, (list, tuple)):
|
||||
values.extend(float(x) for x in val)
|
||||
else:
|
||||
values.append(float(val))
|
||||
return np.array(values, dtype=np.float32)
|
||||
|
||||
|
||||
def egocentric_board(
|
||||
board_chars: list[list[str]],
|
||||
player_pos: tuple[int, int],
|
||||
radius: int,
|
||||
) -> list[list[str]]:
|
||||
"""Crop the board to a (2r+1)×(2r+1) window centred on player_pos.
|
||||
|
||||
Out-of-bounds cells are filled with a space (treated as empty by the
|
||||
encoder). The resulting grid is always square with side 2*radius+1.
|
||||
"""
|
||||
H = len(board_chars)
|
||||
W = len(board_chars[0]) if board_chars else 0
|
||||
px, py = player_pos
|
||||
result = []
|
||||
for dy in range(-radius, radius + 1):
|
||||
row = []
|
||||
for dx in range(-radius, radius + 1):
|
||||
src_x = px + dx
|
||||
src_y = py + dy
|
||||
if 0 <= src_x < W and 0 <= src_y < H:
|
||||
row.append(board_chars[src_y][src_x])
|
||||
else:
|
||||
row.append(' ')
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def encode_observation(
|
||||
board_chars: list[list[str]],
|
||||
state: dict,
|
||||
metadata: GameMetadata,
|
||||
observe_state: list[str],
|
||||
player_pos: tuple[int, int] | None = None,
|
||||
egocentric_radius: int | None = None,
|
||||
board: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Encode board + state into a flat 1D observation vector.
|
||||
"""Encode board and/or selected state values into a flat 1D observation vector.
|
||||
|
||||
For spatial games the board is encoded channel-first (C, H, W) then flattened,
|
||||
so the network can reshape it back for CNN processing. For non-spatial games the
|
||||
board is encoded (H, W, C) then flattened.
|
||||
The state vector is appended at the end in both cases.
|
||||
When *board* is True the board is encoded and prepended to the vector. If
|
||||
player_pos and egocentric_radius are given the board is first cropped to a
|
||||
(2r+1)×(2r+1) window centred on the player. For spatial games the board is
|
||||
encoded channel-first (C, H, W) then flattened; for non-spatial games it is
|
||||
encoded (H, W, C) then flattened. The state vector is appended at the end.
|
||||
|
||||
When *board* is False only the observe_state features are returned.
|
||||
"""
|
||||
if not metadata.character_set:
|
||||
raise ValueError("character_set must be set before encoding observations")
|
||||
board = encode_board(board_chars, metadata.character_set) # (H, W, C)
|
||||
if metadata.spatial:
|
||||
board_vec = board.transpose(2, 0, 1).flatten() # C*H*W, channel-first
|
||||
if board:
|
||||
if not metadata.character_set:
|
||||
raise ValueError("character_set must be set before encoding observations")
|
||||
if player_pos is not None and egocentric_radius is not None:
|
||||
board_chars = egocentric_board(board_chars, player_pos, egocentric_radius)
|
||||
board_enc = encode_board(board_chars, metadata.character_set) # (H, W, C)
|
||||
if metadata.spatial:
|
||||
board_vec = board_enc.transpose(2, 0, 1).flatten()
|
||||
else:
|
||||
board_vec = board_enc.flatten()
|
||||
if observe_state:
|
||||
return np.concatenate([board_vec, encode_state(state, observe_state)])
|
||||
return board_vec
|
||||
else:
|
||||
board_vec = board.flatten() # H*W*C
|
||||
state_vec = encode_state(state, metadata.observe_state)
|
||||
return np.concatenate([board_vec, state_vec])
|
||||
return encode_state(state, observe_state)
|
||||
|
||||
55
retro_gamer/plotter.py
Normal file
55
retro_gamer/plotter.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from retro_gamer.log_parser import parse_checkpoints
|
||||
|
||||
|
||||
def plot_run(log_path: Path, output: Path | None = None) -> None:
|
||||
"""Generate training metric plots from a training.log file.
|
||||
|
||||
Displays an interactive window unless *output* is given, in which case
|
||||
the figure is saved to that path (PNG, PDF, SVG, etc.).
|
||||
"""
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
|
||||
data = parse_checkpoints(log_path)
|
||||
if not data:
|
||||
raise ValueError(f"No checkpoint data found in {log_path}")
|
||||
|
||||
episodes = [d['episode'] for d in data]
|
||||
rewards = [d['avg_reward'] for d in data]
|
||||
steps = [d['avg_steps'] for d in data]
|
||||
losses = [d['avg_loss'] for d in data]
|
||||
epsilons = [d['epsilon'] for d in data]
|
||||
|
||||
sns.set_theme(style='darkgrid')
|
||||
fig, axes = plt.subplots(2, 2, figsize=(12, 7))
|
||||
(ax_reward, ax_steps), (ax_loss, ax_epsilon) = axes
|
||||
|
||||
ax_reward.plot(episodes, rewards)
|
||||
ax_reward.axhline(0, color='gray', linestyle='--', linewidth=0.8, alpha=0.6)
|
||||
ax_reward.set_title('Average Reward')
|
||||
ax_reward.set_xlabel('Episode')
|
||||
|
||||
ax_steps.plot(episodes, steps, color='C1')
|
||||
ax_steps.set_title('Average Steps')
|
||||
ax_steps.set_xlabel('Episode')
|
||||
|
||||
ax_loss.plot(episodes, losses, color='C2')
|
||||
ax_loss.set_yscale('log')
|
||||
ax_loss.set_title('Average Loss')
|
||||
ax_loss.set_xlabel('Episode')
|
||||
|
||||
ax_epsilon.plot(episodes, epsilons, color='C3')
|
||||
ax_epsilon.set_title('Epsilon (exploration rate)')
|
||||
ax_epsilon.set_xlabel('Episode')
|
||||
ax_epsilon.set_ylim(0, 1)
|
||||
|
||||
fig.suptitle(f'Training: {log_path.parent.name}', fontsize=13)
|
||||
plt.tight_layout()
|
||||
|
||||
if output:
|
||||
plt.savefig(output, dpi=150, bbox_inches='tight')
|
||||
print(f"Plot saved to {output}")
|
||||
else:
|
||||
plt.show()
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
import random
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from typing import Callable
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -8,40 +10,250 @@ import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
import tomli_w
|
||||
|
||||
from tqdm import tqdm
|
||||
from retro_gamer.metadata import GameMetadata
|
||||
from retro_gamer.env import GameEnvironment
|
||||
from retro_gamer.network import build_network
|
||||
from retro_gamer.memory import ReplayMemory, PrioritizedReplayMemory
|
||||
|
||||
MODEL_KEYS: frozenset = frozenset({'hidden_sizes'})
|
||||
|
||||
DEFAULTS: dict = {
|
||||
'learning_rate': 1e-3,
|
||||
'lr_decay': 0.995,
|
||||
# [model]
|
||||
'hidden_sizes': [128, 64],
|
||||
# [training]
|
||||
'learning_rate': 1e-4,
|
||||
'learning_rate_decay': 0.9999,
|
||||
'gamma': 0.99,
|
||||
'epsilon': 1.0,
|
||||
'epsilon_decay': 0.995,
|
||||
'epsilon_decay': 0.9997,
|
||||
'epsilon_min': 0.05,
|
||||
'batch_size': 64,
|
||||
'memory_capacity': 10_000,
|
||||
'target_update_freq': 100,
|
||||
'training_episodes': 1_000,
|
||||
'n_layers': 2,
|
||||
'layer_size': 128,
|
||||
'prioritize_experiences': False,
|
||||
'memory_capacity': 50_000,
|
||||
'target_update_freq': 500,
|
||||
'train_every': 4,
|
||||
'training_episodes': 20_000,
|
||||
'prioritize_experiences': True,
|
||||
'exploration_turns': 200,
|
||||
'unknown_character_strategy': 'ignore',
|
||||
'max_turns_per_episode': 2_000,
|
||||
}
|
||||
|
||||
|
||||
def _get_device() -> torch.device:
|
||||
if torch.backends.mps.is_available():
|
||||
return torch.device('mps')
|
||||
if torch.cuda.is_available():
|
||||
return torch.device('cuda')
|
||||
return torch.device('cpu')
|
||||
|
||||
# Fields that make an existing checkpoint incompatible with the current config.
|
||||
# Changing any of these requires starting training from scratch.
|
||||
_INCOMPATIBLE_METADATA = {
|
||||
'actions': 'the list of actions the agent can take (changes output layer size)',
|
||||
'reward': 'the reward signal — Q-values trained on the old signal are meaningless for the new one',
|
||||
'character_set': 'the set of board characters (changes input layer size)',
|
||||
'board_size': 'the board dimensions (changes input layer size)',
|
||||
}
|
||||
_INCOMPATIBLE_PREPROCESSING = {
|
||||
'spatial': 'spatial vs non-spatial network type (changes network architecture)',
|
||||
'board': 'whether the board is included in the observation (changes input size)',
|
||||
'observe_state': 'the state keys included in the observation (changes input size)',
|
||||
'observe_state_sizes': 'the size of each observed state key (changes input layer size)',
|
||||
'egocentric': 'egocentric board transformation (changes input representation)',
|
||||
'egocentric_player': 'the agent used as the egocentric center (changes input representation)',
|
||||
'egocentric_radius': 'the egocentric crop radius (changes input layer size)',
|
||||
}
|
||||
_INCOMPATIBLE_ARCH = {
|
||||
'hidden_sizes': 'the hidden layer sizes (changes network shape)',
|
||||
}
|
||||
|
||||
|
||||
def validate_hyperparams(hp: dict):
|
||||
"""Check all hyperparameters and raise ValueError listing every problem found."""
|
||||
problems = []
|
||||
|
||||
def _problem(heading: str, explanation: str, fix: str | None = None):
|
||||
text = f" {heading}\n {explanation}"
|
||||
if fix:
|
||||
text += f"\n → {fix}"
|
||||
problems.append(text)
|
||||
|
||||
hs = hp.get('hidden_sizes')
|
||||
if not isinstance(hs, list) or len(hs) == 0:
|
||||
_problem(
|
||||
f"hidden_sizes = {hs!r}",
|
||||
"This sets the shape of the neural network's hidden layers. It must be a\n"
|
||||
" non-empty list of positive integers, one number per layer.",
|
||||
"Try: hidden_sizes = [512, 256]",
|
||||
)
|
||||
elif any(not isinstance(s, int) or s <= 0 for s in hs):
|
||||
bad = [s for s in hs if not isinstance(s, int) or s <= 0]
|
||||
_problem(
|
||||
f"hidden_sizes = {hs!r}",
|
||||
f"Every layer size must be a positive integer, but got {bad!r}.\n"
|
||||
" Each number is the count of neurons in that layer — more neurons means\n"
|
||||
" more capacity to learn complex patterns.",
|
||||
"Try: hidden_sizes = [512, 256]",
|
||||
)
|
||||
|
||||
lr = hp.get('learning_rate')
|
||||
if not isinstance(lr, (int, float)) or lr <= 0:
|
||||
_problem(
|
||||
f"learning_rate = {lr!r}",
|
||||
"The learning rate controls how much the network adjusts its weights after\n"
|
||||
" each training step. Too high and training becomes unstable; too low and\n"
|
||||
" it learns very slowly. It must be a positive number.",
|
||||
"Typical values are between 0.0001 and 0.01. Try: learning_rate = 0.001",
|
||||
)
|
||||
|
||||
for key, blurb in [
|
||||
('learning_rate_decay',
|
||||
"After each episode, the learning rate is multiplied by this value, gradually\n"
|
||||
" slowing learning over time. A value of 1.0 means no decay; closer to 0\n"
|
||||
" means very aggressive decay. It must be greater than 0 and at most 1."),
|
||||
('gamma',
|
||||
"Gamma is the discount factor: how much the agent values future rewards versus\n"
|
||||
" immediate ones. 0.99 means future rewards are nearly as important as now;\n"
|
||||
" 0.0 means the agent only cares about the very next step.\n"
|
||||
" It must be greater than 0 and at most 1."),
|
||||
('epsilon_decay',
|
||||
"Each episode, the exploration rate (epsilon) is multiplied by this to gradually\n"
|
||||
" reduce random actions over time. It must be between 0 (exclusive) and 1."),
|
||||
]:
|
||||
v = hp.get(key)
|
||||
if not isinstance(v, (int, float)) or not (0 < v <= 1):
|
||||
_problem(
|
||||
f"{key} = {v!r}",
|
||||
blurb,
|
||||
f"Try a value close to but less than 1, like {key} = 0.995",
|
||||
)
|
||||
|
||||
for key, blurb in [
|
||||
('epsilon',
|
||||
"Epsilon is the probability of taking a random action (exploration vs.\n"
|
||||
" exploitation). It starts high — usually 1.0, meaning fully random —\n"
|
||||
" and decays toward epsilon_min during training. It must be between 0 and 1."),
|
||||
('epsilon_min',
|
||||
"This is the lowest exploration rate allowed. Even after lots of training, the\n"
|
||||
" agent keeps at least this much randomness so it keeps discovering new things.\n"
|
||||
" It must be between 0 and 1."),
|
||||
]:
|
||||
v = hp.get(key)
|
||||
if not isinstance(v, (int, float)) or not (0 <= v <= 1):
|
||||
_problem(
|
||||
f"{key} = {v!r}",
|
||||
blurb,
|
||||
f"Try: {key} = {'0.05' if 'min' in key else '1.0'}",
|
||||
)
|
||||
|
||||
eps = hp.get('epsilon')
|
||||
eps_min = hp.get('epsilon_min')
|
||||
if (isinstance(eps, (int, float)) and isinstance(eps_min, (int, float))
|
||||
and 0 <= eps <= 1 and 0 <= eps_min <= 1 and eps_min > eps):
|
||||
_problem(
|
||||
f"epsilon_min = {eps_min!r} is greater than epsilon = {eps!r}",
|
||||
"epsilon is the starting exploration rate and epsilon_min is the floor it\n"
|
||||
" decays toward, so epsilon_min must be less than or equal to epsilon.",
|
||||
f"Try: epsilon = 1.0 and epsilon_min = 0.05",
|
||||
)
|
||||
|
||||
for key, blurb in [
|
||||
('batch_size',
|
||||
"Training samples this many past experiences from the replay buffer at once\n"
|
||||
" to compute a learning update. Must be a positive integer."),
|
||||
('memory_capacity',
|
||||
"The replay buffer stores this many past experiences. When it fills up, the\n"
|
||||
" oldest are discarded. A larger buffer means more diverse training data.\n"
|
||||
" Must be a positive integer."),
|
||||
('target_update_freq',
|
||||
"The target network (a stable copy of the Q-network used to compute targets)\n"
|
||||
" is updated every this many steps. Must be a positive integer."),
|
||||
('train_every',
|
||||
"A training step runs once every this many game steps. This lets the agent\n"
|
||||
" collect several new experiences before updating. Must be a positive integer."),
|
||||
('training_episodes',
|
||||
"The total number of episodes (games) to train for. Must be a positive integer."),
|
||||
('max_turns_per_episode',
|
||||
"If a game episode hasn't ended naturally after this many steps, it's cut\n"
|
||||
" short. This prevents a buggy or stuck agent from running forever.\n"
|
||||
" Must be a positive integer."),
|
||||
]:
|
||||
v = hp.get(key)
|
||||
if not isinstance(v, int) or v <= 0:
|
||||
_problem(
|
||||
f"{key} = {v!r}",
|
||||
blurb,
|
||||
f"Try: {key} = {DEFAULTS[key]}",
|
||||
)
|
||||
|
||||
v = hp.get('exploration_turns')
|
||||
if not isinstance(v, int) or v < 0:
|
||||
_problem(
|
||||
f"exploration_turns = {v!r}",
|
||||
"When no character_set is specified, the trainer runs this many random turns\n"
|
||||
" to discover what characters appear on the board. Must be 0 or more\n"
|
||||
" (0 skips discovery entirely, which only works if character_set is set).",
|
||||
f"Try: exploration_turns = {DEFAULTS['exploration_turns']}",
|
||||
)
|
||||
|
||||
bs = hp.get('batch_size')
|
||||
mc = hp.get('memory_capacity')
|
||||
if (isinstance(bs, int) and bs > 0 and isinstance(mc, int) and mc > 0 and bs > mc):
|
||||
_problem(
|
||||
f"batch_size = {bs} is larger than memory_capacity = {mc}",
|
||||
"Training samples a batch of past experiences from the replay buffer each\n"
|
||||
" step, so the buffer must be able to hold at least as many experiences\n"
|
||||
f" as the batch size. With batch_size = {bs}, the buffer holds {mc} —\n"
|
||||
" that's not enough to sample from.",
|
||||
f"Try: memory_capacity = {max(bs * 100, DEFAULTS['memory_capacity'])} "
|
||||
f"(a much larger buffer also improves learning quality)",
|
||||
)
|
||||
|
||||
if problems:
|
||||
n = len(problems)
|
||||
noun = "problem" if n == 1 else "problems"
|
||||
header = f"Found {n} {noun} in your training configuration:\n\n"
|
||||
footer = "\n\nFix these in config.toml, then run 'retro-gamer train' again."
|
||||
raise ValueError(header + "\n\n".join(problems) + footer)
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
m, s = divmod(int(seconds), 60)
|
||||
h, m = divmod(m, 60)
|
||||
if h:
|
||||
return f"{h}h{m:02d}m{s:02d}s"
|
||||
return f"{m}m{s:02d}s"
|
||||
|
||||
|
||||
class DQNTrainer:
|
||||
"""Trains a deep Q-network agent to play a retro game.
|
||||
|
||||
On initialization the trainer:
|
||||
1. Discovers the character set (if not already specified in metadata).
|
||||
2. Builds the Q-network and logs the full architecture with rationale.
|
||||
3. Saves config.toml and starts training.log in run_dir.
|
||||
Automatically selects the best available training device: Apple Silicon
|
||||
GPU (MPS), NVIDIA GPU (CUDA), or CPU. The chosen device is recorded in
|
||||
``training.log``.
|
||||
|
||||
Call train() to run all episodes and save checkpoints.
|
||||
On initialization, the trainer:
|
||||
|
||||
1. Discovers the character set if not already specified in *metadata*.
|
||||
2. Builds the Q-network and logs its full architecture with rationale.
|
||||
3. Writes ``config.toml`` and initializes ``training.log`` in *run_dir*.
|
||||
|
||||
Hyperparameters can be passed as keyword arguments; see the
|
||||
:ref:`hyperparameters` reference for all options. Values not supplied
|
||||
fall back to sensible defaults.
|
||||
|
||||
Call :meth:`train` to run all episodes. Checkpoints are saved every 100
|
||||
episodes and training can be stopped (Ctrl+C) and resumed at any time.
|
||||
|
||||
Example::
|
||||
|
||||
from retro_gamer import GameMetadata, DQNTrainer
|
||||
from retro.examples.snake import create_game
|
||||
|
||||
metadata = GameMetadata.from_pyproject("retro.examples.snake")
|
||||
trainer = DQNTrainer(create_game, metadata, "runs/snake/")
|
||||
trainer.train()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -49,26 +261,80 @@ class DQNTrainer:
|
||||
game_factory: Callable,
|
||||
metadata: GameMetadata,
|
||||
run_dir: str | Path,
|
||||
preprocessing: dict | None = None,
|
||||
**hyperparams,
|
||||
):
|
||||
self.game_factory = game_factory
|
||||
self.metadata = metadata
|
||||
self.run_dir = Path(run_dir)
|
||||
self.hp: dict = {**DEFAULTS, **hyperparams}
|
||||
validate_hyperparams(self.hp)
|
||||
self.run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.run_dir / 'checkpoints').mkdir(exist_ok=True)
|
||||
|
||||
self.env = GameEnvironment(game_factory, metadata)
|
||||
pre = preprocessing or {}
|
||||
self.observe_state: list[str] = pre.get('observe_state', [])
|
||||
self.egocentric: bool = pre.get('egocentric', False)
|
||||
self.egocentric_player: str | None = pre.get('egocentric_player', None)
|
||||
self.egocentric_radius: int | None = pre.get('egocentric_radius', None)
|
||||
self.board: bool = pre.get('board', True)
|
||||
self.observe_state_sizes: dict[str, int] = pre.get('observe_state_sizes', {})
|
||||
|
||||
if self.board is False and metadata.spatial:
|
||||
raise ValueError(
|
||||
"preprocessing.board = false is incompatible with spatial = true.\n"
|
||||
"A CNN requires a 2-D board to operate on. Either set spatial = false\n"
|
||||
"or keep board = true."
|
||||
)
|
||||
if self.board is False and not self.observe_state:
|
||||
raise ValueError(
|
||||
"preprocessing.board = false requires at least one entry in observe_state.\n"
|
||||
"With board=false, the agent observes only the game state variables listed\n"
|
||||
"in observe_state — if that list is empty, there is nothing to observe."
|
||||
)
|
||||
if self.egocentric and not self.egocentric_radius:
|
||||
raise ValueError(
|
||||
"preprocessing.egocentric = true requires egocentric_radius.\n"
|
||||
"Choose a value based on how far the agent needs to see, e.g.:\n"
|
||||
" egocentric_radius = 5 # 11×11 tight local view\n"
|
||||
" egocentric_radius = 8 # 17×17 wider view"
|
||||
)
|
||||
|
||||
metadata.board = self.board
|
||||
|
||||
if metadata.board_size is None:
|
||||
g = game_factory()
|
||||
metadata.board_size = g.board_size
|
||||
|
||||
if metadata.character_set is None:
|
||||
if self.egocentric_radius:
|
||||
side = 2 * self.egocentric_radius + 1
|
||||
metadata.board_size = (side, side)
|
||||
|
||||
self.env = GameEnvironment(
|
||||
game_factory, metadata,
|
||||
observe_state=self.observe_state,
|
||||
egocentric=self.egocentric,
|
||||
egocentric_player=self.egocentric_player,
|
||||
egocentric_radius=self.egocentric_radius,
|
||||
board=self.board,
|
||||
observe_state_sizes=self.observe_state_sizes,
|
||||
)
|
||||
|
||||
if metadata.character_set is None and self.board:
|
||||
self._discover_character_set()
|
||||
|
||||
if self.observe_state and not self.observe_state_sizes:
|
||||
self._discover_observe_state_sizes()
|
||||
self.env.observe_state_sizes = self.observe_state_sizes
|
||||
|
||||
metadata.extras_size = sum(self.observe_state_sizes.values()) if self.observe_state_sizes else 0
|
||||
|
||||
self.device = _get_device()
|
||||
|
||||
self.model, rationale = build_network(metadata, self.hp)
|
||||
self.target_model, _ = build_network(metadata, self.hp)
|
||||
self.model.to(self.device)
|
||||
self.target_model.to(self.device)
|
||||
self.target_model.load_state_dict(self.model.state_dict())
|
||||
self.target_model.eval()
|
||||
|
||||
@@ -76,7 +342,7 @@ class DQNTrainer:
|
||||
self.model.parameters(), lr=self.hp['learning_rate']
|
||||
)
|
||||
self.lr_scheduler = optim.lr_scheduler.ExponentialLR(
|
||||
self.optimizer, gamma=self.hp['lr_decay']
|
||||
self.optimizer, gamma=self.hp['learning_rate_decay']
|
||||
)
|
||||
|
||||
if self.hp['prioritize_experiences']:
|
||||
@@ -86,6 +352,9 @@ class DQNTrainer:
|
||||
|
||||
self.epsilon: float = self.hp['epsilon']
|
||||
self.total_steps: int = 0
|
||||
self.total_training_seconds: float = 0.0
|
||||
self.start_episode: int = 1
|
||||
self._resumed_from: str | None = None
|
||||
|
||||
self._save_config()
|
||||
self._open_log(rationale)
|
||||
@@ -94,49 +363,127 @@ class DQNTrainer:
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def train(self):
|
||||
"""Run all training episodes and save checkpoints."""
|
||||
for episode in range(1, self.hp['training_episodes'] + 1):
|
||||
total_reward, steps, avg_loss = self._run_episode()
|
||||
def train(self, on_checkpoint=None, on_episode=None):
|
||||
"""Run all training episodes and save checkpoints.
|
||||
|
||||
*on_checkpoint*, if provided, is called after each checkpoint with a
|
||||
dict containing ``episode``, ``avg_reward``, ``avg_steps``,
|
||||
``avg_loss``, and ``epsilon``. *on_episode*, if provided, is called
|
||||
after every episode. When either callback is supplied, the built-in
|
||||
tqdm progress bar is suppressed (the caller is expected to show its
|
||||
own progress UI).
|
||||
"""
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
if self._resumed_from:
|
||||
self._log_raw(f'\n=== Resumed from {self._resumed_from} | {timestamp} ===')
|
||||
else:
|
||||
self._log_raw(f'\n=== Training started | {timestamp} ===')
|
||||
|
||||
use_tqdm = on_checkpoint is None and on_episode is None
|
||||
if use_tqdm:
|
||||
print("Press Control+C to stop training early. Progress will be saved at the latest checkpoint.")
|
||||
|
||||
session_start = perf_counter()
|
||||
ckpt_start = perf_counter()
|
||||
episode_rewards: list[float] = []
|
||||
episode_losses: list[float] = []
|
||||
episode_steps: list[int] = []
|
||||
|
||||
episodes = range(self.start_episode, self.hp['training_episodes'] + 1)
|
||||
bar = tqdm(episodes, unit='ep') if use_tqdm else episodes
|
||||
for episode in bar:
|
||||
total_reward, steps, avg_loss, trained = self._run_episode()
|
||||
episode_rewards.append(total_reward)
|
||||
if avg_loss > 0:
|
||||
episode_losses.append(avg_loss)
|
||||
episode_steps.append(steps)
|
||||
|
||||
self.epsilon = max(
|
||||
self.hp['epsilon_min'], self.epsilon * self.hp['epsilon_decay']
|
||||
)
|
||||
self.lr_scheduler.step()
|
||||
self._log_episode(episode, total_reward, steps, avg_loss)
|
||||
if episode % 100 == 0:
|
||||
self._save_checkpoint(f'ep_{episode:04d}.pt')
|
||||
self._save_checkpoint('final.pt')
|
||||
if trained:
|
||||
self.lr_scheduler.step()
|
||||
|
||||
if on_episode:
|
||||
on_episode()
|
||||
|
||||
if use_tqdm:
|
||||
bar.set_postfix(
|
||||
reward=f'{total_reward:.1f}',
|
||||
eps=f'{self.epsilon:.3f}',
|
||||
loss=f'{avg_loss:.4f}',
|
||||
)
|
||||
|
||||
is_checkpoint = (episode % 100 == 0)
|
||||
is_last = (episode == self.hp['training_episodes'])
|
||||
if is_checkpoint or (is_last and episode_rewards):
|
||||
now = perf_counter()
|
||||
ckpt_elapsed = now - ckpt_start
|
||||
self.total_training_seconds += ckpt_elapsed
|
||||
ckpt_start = now
|
||||
|
||||
self._save_checkpoint(f'ep_{episode:04d}.pt', episode)
|
||||
stats = self._log_checkpoint(episode, episode_rewards, episode_losses, episode_steps, ckpt_elapsed)
|
||||
episode_rewards = []
|
||||
episode_losses = []
|
||||
episode_steps = []
|
||||
|
||||
if on_checkpoint:
|
||||
on_checkpoint(stats)
|
||||
|
||||
def load_checkpoint(self, path: str | Path):
|
||||
ckpt = torch.load(path, weights_only=True)
|
||||
"""Load a checkpoint to resume training.
|
||||
|
||||
Checkpoints are PyTorch state dicts stored under
|
||||
``run_dir/checkpoints/``. Each contains model weights, optimizer
|
||||
state, current epsilon, and total step count.
|
||||
|
||||
Raises :exc:`ValueError` if the checkpoint was trained with a
|
||||
different character set, board size, action space, or network
|
||||
architecture. The error message names each changed field and explains
|
||||
why it is incompatible.
|
||||
|
||||
The CLI invokes this automatically; call directly only when driving
|
||||
training from Python.
|
||||
"""
|
||||
ckpt = torch.load(path, weights_only=True, map_location='cpu')
|
||||
self._check_compatibility(ckpt, path)
|
||||
self.model.load_state_dict(ckpt['model_state_dict'])
|
||||
self.target_model.load_state_dict(ckpt['model_state_dict'])
|
||||
self.optimizer.load_state_dict(ckpt['optimizer_state_dict'])
|
||||
for state in self.optimizer.state.values():
|
||||
for k, v in state.items():
|
||||
if isinstance(v, torch.Tensor):
|
||||
state[k] = v.to(self.device)
|
||||
self.epsilon = ckpt['epsilon']
|
||||
self.total_steps = ckpt['total_steps']
|
||||
self.total_training_seconds = ckpt.get('total_training_seconds', 0.0)
|
||||
self.start_episode = ckpt.get('episode', 0) + 1
|
||||
self._resumed_from = Path(path).name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Training loop internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _run_episode(self) -> tuple[float, int, float]:
|
||||
def _run_episode(self) -> tuple[float, int, float, bool]:
|
||||
state = self.env.reset()
|
||||
total_reward = 0.0
|
||||
total_loss = 0.0
|
||||
loss_count = 0
|
||||
|
||||
for step in range(self.hp['max_turns_per_episode']):
|
||||
state_t = torch.as_tensor(state, dtype=torch.float32)
|
||||
state_t = torch.as_tensor(state, dtype=torch.float32).to(self.device)
|
||||
action_idx = self._select_action(state_t)
|
||||
action_key = self._idx_to_key(action_idx)
|
||||
|
||||
next_state, reward, done = self.env.step(action_key)
|
||||
self.memory.push(state, action_idx, reward, next_state, done)
|
||||
|
||||
loss = self._train_step()
|
||||
if loss is not None:
|
||||
total_loss += loss
|
||||
loss_count += 1
|
||||
if self.total_steps % self.hp['train_every'] == 0:
|
||||
loss = self._train_step()
|
||||
if loss is not None:
|
||||
total_loss += loss
|
||||
loss_count += 1
|
||||
|
||||
self.total_steps += 1
|
||||
if self.total_steps % self.hp['target_update_freq'] == 0:
|
||||
@@ -148,7 +495,7 @@ class DQNTrainer:
|
||||
break
|
||||
|
||||
avg_loss = total_loss / loss_count if loss_count else 0.0
|
||||
return total_reward, step + 1, avg_loss
|
||||
return total_reward, step + 1, avg_loss, loss_count > 0
|
||||
|
||||
def _select_action(self, state_t: torch.Tensor) -> int:
|
||||
if random.random() < self.epsilon:
|
||||
@@ -168,7 +515,7 @@ class DQNTrainer:
|
||||
if self.hp['prioritize_experiences']:
|
||||
assert isinstance(self.memory, PrioritizedReplayMemory)
|
||||
experiences, indices, weights = self.memory.sample(self.hp['batch_size'])
|
||||
weight_t = torch.as_tensor(weights, dtype=torch.float32)
|
||||
weight_t = torch.as_tensor(weights, dtype=torch.float32).to(self.device)
|
||||
else:
|
||||
experiences = self.memory.sample(self.hp['batch_size'])
|
||||
indices = None
|
||||
@@ -176,33 +523,107 @@ class DQNTrainer:
|
||||
|
||||
states = torch.as_tensor(
|
||||
np.array([e.state for e in experiences]), dtype=torch.float32
|
||||
)
|
||||
actions = torch.as_tensor([e.action for e in experiences], dtype=torch.long)
|
||||
rewards = torch.as_tensor([e.reward for e in experiences], dtype=torch.float32)
|
||||
).to(self.device)
|
||||
actions = torch.as_tensor(
|
||||
[e.action for e in experiences], dtype=torch.long
|
||||
).to(self.device)
|
||||
rewards = torch.as_tensor(
|
||||
[e.reward for e in experiences], dtype=torch.float32
|
||||
).to(self.device)
|
||||
next_states = torch.as_tensor(
|
||||
np.array([e.next_state for e in experiences]), dtype=torch.float32
|
||||
)
|
||||
dones = torch.as_tensor([e.done for e in experiences], dtype=torch.float32)
|
||||
).to(self.device)
|
||||
dones = torch.as_tensor(
|
||||
[e.done for e in experiences], dtype=torch.float32
|
||||
).to(self.device)
|
||||
|
||||
q_values = self.model(states).gather(1, actions.unsqueeze(1)).squeeze(1)
|
||||
with torch.no_grad():
|
||||
next_q = self.target_model(next_states).max(1).values
|
||||
targets = rewards + self.hp['gamma'] * next_q * (1.0 - dones)
|
||||
|
||||
element_loss = nn.functional.mse_loss(q_values, targets, reduction='none')
|
||||
element_loss = nn.functional.huber_loss(q_values, targets, reduction='none', delta=1.0)
|
||||
|
||||
if weight_t is not None:
|
||||
loss = (weight_t * element_loss).mean()
|
||||
td_errors = (q_values - targets).detach().abs().numpy()
|
||||
td_errors = (q_values - targets).detach().abs().cpu().numpy()
|
||||
self.memory.update_priorities(indices, td_errors)
|
||||
else:
|
||||
loss = element_loss.mean()
|
||||
|
||||
self.optimizer.zero_grad()
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=10.0)
|
||||
self.optimizer.step()
|
||||
return float(loss.item())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Compatibility checking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _config_snapshot(self) -> dict:
|
||||
return {
|
||||
'metadata': self.metadata.to_dict(),
|
||||
'preprocessing': {
|
||||
'spatial': self.metadata.spatial,
|
||||
'board': self.board,
|
||||
'observe_state': self.observe_state,
|
||||
'observe_state_sizes': self.observe_state_sizes,
|
||||
'egocentric': self.egocentric,
|
||||
'egocentric_player': self.egocentric_player,
|
||||
'egocentric_radius': self.egocentric_radius,
|
||||
},
|
||||
'hidden_sizes': self.hp['hidden_sizes'],
|
||||
}
|
||||
|
||||
def _check_compatibility(self, ckpt: dict, path: str | Path):
|
||||
snapshot = ckpt.get('config_snapshot')
|
||||
if snapshot is None:
|
||||
return
|
||||
|
||||
current = self._config_snapshot()
|
||||
issues = []
|
||||
|
||||
old_meta = snapshot.get('metadata', {})
|
||||
new_meta = current['metadata']
|
||||
for field, desc in _INCOMPATIBLE_METADATA.items():
|
||||
if old_meta.get(field) != new_meta.get(field):
|
||||
issues.append((field, desc, old_meta.get(field), new_meta.get(field)))
|
||||
|
||||
old_pre = snapshot.get('preprocessing', {})
|
||||
new_pre = current['preprocessing']
|
||||
for field, desc in _INCOMPATIBLE_PREPROCESSING.items():
|
||||
if old_pre.get(field) != new_pre.get(field):
|
||||
issues.append((field, desc, old_pre.get(field), new_pre.get(field)))
|
||||
|
||||
for field, desc in _INCOMPATIBLE_ARCH.items():
|
||||
if snapshot.get(field) != current.get(field):
|
||||
issues.append((field, desc, snapshot.get(field), current.get(field)))
|
||||
|
||||
if not issues:
|
||||
return
|
||||
|
||||
lines = [
|
||||
f"Cannot resume from {Path(path).name}: incompatible changes detected in config.toml.",
|
||||
"",
|
||||
"The following changes require starting fresh. The existing model was trained",
|
||||
"on a different problem and its weights cannot be reused:",
|
||||
"",
|
||||
]
|
||||
for field, desc, old_val, new_val in issues:
|
||||
lines += [
|
||||
f" {field}",
|
||||
f" was : {old_val!r}",
|
||||
f" now : {new_val!r}",
|
||||
f" why : {desc}",
|
||||
"",
|
||||
]
|
||||
lines += [
|
||||
"Run 'retro-gamer clean RUN_DIR' to remove existing checkpoints and the",
|
||||
"training log, then run 'retro-gamer train RUN_DIR' to start fresh.",
|
||||
]
|
||||
raise ValueError("\n".join(lines))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Initialisation helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -215,6 +636,16 @@ class DQNTrainer:
|
||||
f"after {self.hp['exploration_turns']} exploration turns: {chars}"
|
||||
)
|
||||
|
||||
def _discover_observe_state_sizes(self):
|
||||
"""Sample game.state to determine the flat size of each observe_state key."""
|
||||
self.env.reset()
|
||||
state = dict(self.env.game.state)
|
||||
sizes = {}
|
||||
for key in self.observe_state:
|
||||
val = state.get(key, 0)
|
||||
sizes[key] = len(val) if isinstance(val, (list, tuple)) else 1
|
||||
self.observe_state_sizes = sizes
|
||||
|
||||
def _save_config(self):
|
||||
config_path = self.run_dir / 'config.toml'
|
||||
config: dict = {}
|
||||
@@ -223,33 +654,75 @@ class DQNTrainer:
|
||||
with open(config_path, 'rb') as f:
|
||||
config = tomllib.load(f)
|
||||
config['metadata'] = self.metadata.to_dict()
|
||||
config['hyperparameters'] = self.hp
|
||||
pre = config.setdefault('preprocessing', {})
|
||||
pre['spatial'] = self.metadata.spatial
|
||||
pre['board'] = self.board
|
||||
pre['observe_state'] = self.observe_state
|
||||
if self.observe_state_sizes:
|
||||
pre['observe_state_sizes'] = self.observe_state_sizes
|
||||
pre['egocentric'] = self.egocentric
|
||||
if self.egocentric_player:
|
||||
pre['egocentric_player'] = self.egocentric_player
|
||||
if self.egocentric_radius:
|
||||
pre['egocentric_radius'] = self.egocentric_radius
|
||||
config['model'] = {k: v for k, v in self.hp.items() if k in MODEL_KEYS}
|
||||
config['training'] = {k: v for k, v in self.hp.items() if k not in MODEL_KEYS}
|
||||
with open(config_path, 'wb') as f:
|
||||
tomli_w.dump(config, f)
|
||||
|
||||
def _open_log(self, rationale: str):
|
||||
self.log_path = self.run_dir / 'training.log'
|
||||
with open(self.log_path, 'w') as f:
|
||||
f.write(rationale + '\n')
|
||||
if not self.log_path.exists():
|
||||
with open(self.log_path, 'w') as f:
|
||||
f.write(rationale + '\n')
|
||||
f.write(f'[INIT] Device: {self.device}\n')
|
||||
|
||||
def _log_raw(self, line: str):
|
||||
with open(self.log_path, 'a') as f:
|
||||
f.write(line + '\n')
|
||||
|
||||
def _log_episode(self, episode: int, total_reward: float, steps: int, avg_loss: float):
|
||||
def _log_checkpoint(
|
||||
self,
|
||||
episode: int,
|
||||
rewards: list[float],
|
||||
losses: list[float],
|
||||
steps: list[int],
|
||||
ckpt_elapsed: float,
|
||||
) -> dict:
|
||||
n = len(rewards)
|
||||
start_ep = episode - n + 1
|
||||
avg_reward = sum(rewards) / n if n else 0.0
|
||||
avg_loss = sum(losses) / len(losses) if losses else 0.0
|
||||
avg_steps = sum(steps) / n if n else 0.0
|
||||
line = (
|
||||
f"[EP {episode:04d}] total_reward={total_reward:.1f} "
|
||||
f"steps={steps} epsilon={self.epsilon:.4f} avg_loss={avg_loss:.6f}"
|
||||
f"[ep_{episode:04d}]"
|
||||
f" ep={start_ep:04d}-{episode:04d}"
|
||||
f" avg_reward={avg_reward:+.1f}"
|
||||
f" avg_steps={avg_steps:.0f}"
|
||||
f" epsilon={self.epsilon:.3f}"
|
||||
f" avg_loss={avg_loss:.1f}"
|
||||
f" time={_format_duration(ckpt_elapsed)}"
|
||||
f" total={_format_duration(self.total_training_seconds)}"
|
||||
)
|
||||
self._log_raw(line)
|
||||
return {
|
||||
'episode': episode,
|
||||
'avg_reward': avg_reward,
|
||||
'avg_steps': avg_steps,
|
||||
'avg_loss': avg_loss,
|
||||
'epsilon': self.epsilon,
|
||||
}
|
||||
|
||||
def _save_checkpoint(self, name: str):
|
||||
def _save_checkpoint(self, name: str, episode: int):
|
||||
torch.save(
|
||||
{
|
||||
'model_state_dict': self.model.state_dict(),
|
||||
'optimizer_state_dict': self.optimizer.state_dict(),
|
||||
'epsilon': self.epsilon,
|
||||
'total_steps': self.total_steps,
|
||||
'episode': episode,
|
||||
'total_training_seconds': self.total_training_seconds,
|
||||
'config_snapshot': self._config_snapshot(),
|
||||
},
|
||||
self.run_dir / 'checkpoints' / name,
|
||||
)
|
||||
|
||||
612
uv.lock
generated
612
uv.lock
generated
@@ -2,8 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12'",
|
||||
"python_full_version < '3.12'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
||||
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -165,12 +172,94 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "contourpy"
|
||||
version = "1.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cuda-bindings"
|
||||
version = "13.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-pathfinder" },
|
||||
{ name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" },
|
||||
@@ -203,37 +292,46 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
cublas = [
|
||||
{ name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
cudart = [
|
||||
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
cufft = [
|
||||
{ name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cufft", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
curand = [
|
||||
{ name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-curand", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
cusolver = [
|
||||
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cusolver", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
cusparse = [
|
||||
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cusparse", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
nvjitlink = [
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
nvrtc = [
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
nvtx = [
|
||||
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "nvidia-nvtx", marker = "sys_platform == 'linux'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cycler"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -254,6 +352,55 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.62.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsspec"
|
||||
version = "2026.4.0"
|
||||
@@ -305,6 +452,112 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b7/9ab2b79bcbcc53cf8772a19d26713dd9574d4d81ee4fea29678d8cadcec7/jinxed-1.4.0-py2.py3-none-any.whl", hash = "sha256:95876a8b270081b8e28a9bbcbabe4fa98327faa91102526f724ed1904f9a55ac", size = 34522, upload-time = "2026-03-26T01:49:36.762Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiwisolver"
|
||||
version = "1.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
@@ -379,6 +632,70 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib"
|
||||
version = "3.10.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "contourpy" },
|
||||
{ name = "cycler" },
|
||||
{ name = "fonttools" },
|
||||
{ name = "kiwisolver" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mpmath"
|
||||
version = "1.3.0"
|
||||
@@ -517,7 +834,7 @@ name = "nvidia-cudnn-cu13"
|
||||
version = "9.19.0.56"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
|
||||
@@ -529,7 +846,7 @@ name = "nvidia-cufft"
|
||||
version = "12.0.0.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||
@@ -559,9 +876,9 @@ name = "nvidia-cusolver"
|
||||
version = "12.0.4.66"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||
@@ -573,7 +890,7 @@ name = "nvidia-cusparse"
|
||||
version = "12.6.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||
@@ -634,6 +951,162 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pandas"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/35/6411db530c618e0e0005187e35aa02ce60ae4c4c4d206964a2f978217c27/pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", size = 10326926, upload-time = "2026-03-31T06:46:08.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/d3/b7da1d5d7dbdc5ef52ed7debd2b484313b832982266905315dad5a0bf0b1/pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", size = 9926987, upload-time = "2026-03-31T06:46:11.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/77/9b1c2d6070b5dbe239a7bc889e21bfa58720793fb902d1e070695d87c6d0/pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", size = 10757067, upload-time = "2026-03-31T06:46:14.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/17/ec40d981705654853726e7ac9aea9ddbb4a5d9cf54d8472222f4f3de06c2/pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", size = 11258787, upload-time = "2026-03-31T06:46:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/e3/3f1126d43d3702ca8773871a81c9f15122a1f412342cc56284ffda5b1f70/pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", size = 11771616, upload-time = "2026-03-31T06:46:20.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/cf/0f4e268e1f5062e44a6bda9f925806721cd4c95c2b808a4c82ebe914f96b/pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", size = 12337623, upload-time = "2026-03-31T06:46:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/97a6339859d4acb2536efb24feb6708e82f7d33b2ed7e036f2983fcced82/pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", size = 9897372, upload-time = "2026-03-31T06:46:26.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/eb/781516b808a99ddf288143cec46b342b3016c3414d137da1fdc3290d8860/pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", size = 9154922, upload-time = "2026-03-31T06:46:30.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotext"
|
||||
version = "5.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -643,6 +1116,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
@@ -664,10 +1158,14 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "plotext" },
|
||||
{ name = "retro-games" },
|
||||
{ name = "seaborn" },
|
||||
{ name = "tomli-w" },
|
||||
{ name = "torch" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -680,10 +1178,14 @@ documentation = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.7" },
|
||||
{ name = "numpy", specifier = ">=1.24" },
|
||||
{ name = "retro-games", specifier = ">=2.2.0" },
|
||||
{ name = "plotext", specifier = ">=5.0" },
|
||||
{ name = "retro-games", editable = "../retro" },
|
||||
{ name = "seaborn", specifier = ">=0.13" },
|
||||
{ name = "tomli-w", specifier = ">=1.0" },
|
||||
{ name = "torch", specifier = ">=2.0" },
|
||||
{ name = "tqdm", specifier = ">=4.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -694,14 +1196,19 @@ documentation = [
|
||||
|
||||
[[package]]
|
||||
name = "retro-games"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
version = "2.4.0"
|
||||
source = { editable = "../retro" }
|
||||
dependencies = [
|
||||
{ name = "blessed" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/de/1cd7460c435eb760fd5f492aa43a364e9ac3ca61e568742ef3ce27258409/retro_games-2.2.0.tar.gz", hash = "sha256:dac18f6f056da81ed6e5545d9bdce5b095567d0988633184a1951c9fe00bb12d", size = 19155, upload-time = "2026-04-17T14:16:05.661Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1c/f7494d29cc106f0f95bac29b2898fc80d3ff0cb82ee5b1b49eeba5c37f2c/retro_games-2.2.0-py3-none-any.whl", hash = "sha256:420fb44f7164f74519bbb8eb5523ed42c0b353d57dd4a445d9949b3bdc9b1bd5", size = 28317, upload-time = "2026-04-17T14:16:06.633Z" },
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "blessed", specifier = ">=1.33.0" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
documentation = [
|
||||
{ name = "furo", specifier = ">=2025.12.19" },
|
||||
{ name = "sphinx", specifier = ">=8.1.3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -713,6 +1220,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seaborn"
|
||||
version = "0.13.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "81.0.0"
|
||||
@@ -722,6 +1243,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snowballstemmer"
|
||||
version = "3.0.1"
|
||||
@@ -736,7 +1266,9 @@ name = "sphinx"
|
||||
version = "9.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.12'",
|
||||
"python_full_version < '3.12' and sys_platform == 'win32'",
|
||||
"python_full_version < '3.12' and sys_platform == 'emscripten'",
|
||||
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "alabaster", marker = "python_full_version < '3.12'" },
|
||||
@@ -767,7 +1299,12 @@ name = "sphinx"
|
||||
version = "9.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "alabaster", marker = "python_full_version >= '3.12'" },
|
||||
@@ -943,6 +1480,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "triton"
|
||||
version = "3.6.0"
|
||||
@@ -971,6 +1520,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
|
||||
Reference in New Issue
Block a user