From 3701d05479510d4895de9895ce7af082aa568264 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Wed, 29 Apr 2026 15:45:00 +0200 Subject: [PATCH 01/30] Restructure into krum package and add packaging Move core modules (aggregators, attacks, experiments, tools, native) under the krum namespace and add package metadata (pyproject.toml) with __version__ set. Add GitHub Actions for CI and Release. Update imports to the krum namespace, relocate native sources, add README, and adjust .gitignore and formatting/linting tooling configuration. --- .github/workflows/ci.yml | 37 + .github/workflows/release.yml | 25 + .gitignore | 8 +- README.md | 40 + aggregators/__init__.py | 97 -- aggregators/average.py | 55 - aggregators/brute.py | 156 -- aggregators/bulyan.py | 144 -- aggregators/krum.py | 166 -- aggregators/median.py | 87 -- attacks/__init__.py | 87 -- attacks/identical.py | 148 -- attacks/nan.py | 60 - experiments/__init__.py | 25 - experiments/checkpoint.py | 169 --- experiments/configuration.py | 101 -- experiments/dataset.py | 354 ----- experiments/datasets/svm.py | 126 -- experiments/loss.py | 310 ---- experiments/model.py | 396 ----- experiments/models/simples.py | 176 --- experiments/optimizer.py | 103 -- histogram.py | 1346 +++++++++-------- krum/__init__.py | 3 + krum/aggregators/__init__.py | 100 ++ krum/aggregators/average.py | 58 + krum/aggregators/brute.py | 166 ++ krum/aggregators/bulyan.py | 152 ++ krum/aggregators/krum.py | 174 +++ krum/aggregators/median.py | 95 ++ krum/attacks/__init__.py | 91 ++ krum/attacks/identical.py | 159 ++ krum/attacks/nan.py | 63 + krum/experiments/__init__.py | 24 + krum/experiments/checkpoint.py | 173 +++ krum/experiments/configuration.py | 100 ++ krum/experiments/dataset.py | 417 +++++ krum/experiments/datasets/svm.py | 129 ++ krum/experiments/loss.py | 317 ++++ krum/experiments/model.py | 421 ++++++ krum/experiments/models/simples.py | 180 +++ krum/experiments/optimizer.py | 106 ++ {native => krum/native}/.gitignore | 0 {native => krum/native}/README.md | 0 krum/native/__init__.py | 207 +++ .../native}/include/aggregator.hpp | 0 {native => krum/native}/include/array.hpp | 0 .../native}/include/combinations.hpp | 0 {native => krum/native}/include/common.hpp | 0 {native => krum/native}/include/constexpr.hpp | 0 .../native}/include/cub/.placeholder | 0 .../native}/include/cudarray.cu.hpp | 0 {native => krum/native}/include/exception.hpp | 0 .../native}/include/operations.cu.hpp | 0 .../native}/include/operations.hpp | 0 {native => krum/native}/include/optional.hpp | 0 {native => krum/native}/include/pytorch.hpp | 0 .../native}/include/string_view.hpp | 0 .../native}/include/threadpool.hpp | 0 {native => krum/native}/py_brute/.deps | 0 {native => krum/native}/py_brute/brute.cpp | 0 {native => krum/native}/py_brute/brute.cu | 0 {native => krum/native}/py_brute/rule.cpp | 0 {native => krum/native}/py_brute/rule.hpp | 0 {native => krum/native}/py_bulyan/.deps | 0 {native => krum/native}/py_bulyan/bulyan.cpp | 0 {native => krum/native}/py_bulyan/bulyan.cu | 0 {native => krum/native}/py_bulyan/rule.cpp | 0 {native => krum/native}/py_bulyan/rule.hpp | 0 {native => krum/native}/py_krum/.deps | 0 {native => krum/native}/py_krum/krum.cpp | 0 {native => krum/native}/py_krum/krum.cu | 0 {native => krum/native}/py_krum/rule.cpp | 0 {native => krum/native}/py_krum/rule.hpp | 0 {native => krum/native}/py_median/.deps | 0 {native => krum/native}/py_median/median.cpp | 0 {native => krum/native}/py_median/median.cu | 0 {native => krum/native}/py_median/rule.cpp | 0 {native => krum/native}/py_median/rule.hpp | 0 {native => krum/native}/so_threadpool/.deps | 0 .../native}/so_threadpool/threadpool.cpp | 0 krum/tools/__init__.py | 327 ++++ krum/tools/jobs.py | 252 +++ krum/tools/misc.py | 630 ++++++++ krum/tools/pytorch.py | 317 ++++ native/__init__.py | 165 -- pyproject.toml | 97 ++ reproduce.py | 380 ++--- tools/__init__.py | 308 ---- tools/jobs.py | 248 --- tools/misc.py | 570 ------- tools/pytorch.py | 294 ---- train.py | 1198 ++++++++------- 93 files changed, 6402 insertions(+), 5735 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 README.md delete mode 100644 aggregators/__init__.py delete mode 100644 aggregators/average.py delete mode 100644 aggregators/brute.py delete mode 100644 aggregators/bulyan.py delete mode 100644 aggregators/krum.py delete mode 100644 aggregators/median.py delete mode 100644 attacks/__init__.py delete mode 100644 attacks/identical.py delete mode 100644 attacks/nan.py delete mode 100644 experiments/__init__.py delete mode 100644 experiments/checkpoint.py delete mode 100644 experiments/configuration.py delete mode 100644 experiments/dataset.py delete mode 100644 experiments/datasets/svm.py delete mode 100644 experiments/loss.py delete mode 100644 experiments/model.py delete mode 100644 experiments/models/simples.py delete mode 100644 experiments/optimizer.py create mode 100644 krum/__init__.py create mode 100644 krum/aggregators/__init__.py create mode 100644 krum/aggregators/average.py create mode 100644 krum/aggregators/brute.py create mode 100644 krum/aggregators/bulyan.py create mode 100644 krum/aggregators/krum.py create mode 100644 krum/aggregators/median.py create mode 100644 krum/attacks/__init__.py create mode 100644 krum/attacks/identical.py create mode 100644 krum/attacks/nan.py create mode 100644 krum/experiments/__init__.py create mode 100644 krum/experiments/checkpoint.py create mode 100644 krum/experiments/configuration.py create mode 100644 krum/experiments/dataset.py create mode 100644 krum/experiments/datasets/svm.py create mode 100644 krum/experiments/loss.py create mode 100644 krum/experiments/model.py create mode 100644 krum/experiments/models/simples.py create mode 100644 krum/experiments/optimizer.py rename {native => krum/native}/.gitignore (100%) rename {native => krum/native}/README.md (100%) create mode 100644 krum/native/__init__.py rename {native => krum/native}/include/aggregator.hpp (100%) rename {native => krum/native}/include/array.hpp (100%) rename {native => krum/native}/include/combinations.hpp (100%) rename {native => krum/native}/include/common.hpp (100%) rename {native => krum/native}/include/constexpr.hpp (100%) rename {native => krum/native}/include/cub/.placeholder (100%) rename {native => krum/native}/include/cudarray.cu.hpp (100%) rename {native => krum/native}/include/exception.hpp (100%) rename {native => krum/native}/include/operations.cu.hpp (100%) rename {native => krum/native}/include/operations.hpp (100%) rename {native => krum/native}/include/optional.hpp (100%) rename {native => krum/native}/include/pytorch.hpp (100%) rename {native => krum/native}/include/string_view.hpp (100%) rename {native => krum/native}/include/threadpool.hpp (100%) rename {native => krum/native}/py_brute/.deps (100%) rename {native => krum/native}/py_brute/brute.cpp (100%) rename {native => krum/native}/py_brute/brute.cu (100%) rename {native => krum/native}/py_brute/rule.cpp (100%) rename {native => krum/native}/py_brute/rule.hpp (100%) rename {native => krum/native}/py_bulyan/.deps (100%) rename {native => krum/native}/py_bulyan/bulyan.cpp (100%) rename {native => krum/native}/py_bulyan/bulyan.cu (100%) rename {native => krum/native}/py_bulyan/rule.cpp (100%) rename {native => krum/native}/py_bulyan/rule.hpp (100%) rename {native => krum/native}/py_krum/.deps (100%) rename {native => krum/native}/py_krum/krum.cpp (100%) rename {native => krum/native}/py_krum/krum.cu (100%) rename {native => krum/native}/py_krum/rule.cpp (100%) rename {native => krum/native}/py_krum/rule.hpp (100%) rename {native => krum/native}/py_median/.deps (100%) rename {native => krum/native}/py_median/median.cpp (100%) rename {native => krum/native}/py_median/median.cu (100%) rename {native => krum/native}/py_median/rule.cpp (100%) rename {native => krum/native}/py_median/rule.hpp (100%) rename {native => krum/native}/so_threadpool/.deps (100%) rename {native => krum/native}/so_threadpool/threadpool.cpp (100%) create mode 100644 krum/tools/__init__.py create mode 100644 krum/tools/jobs.py create mode 100644 krum/tools/misc.py create mode 100644 krum/tools/pytorch.py delete mode 100644 native/__init__.py create mode 100644 pyproject.toml delete mode 100644 tools/__init__.py delete mode 100644 tools/jobs.py delete mode 100644 tools/misc.py delete mode 100644 tools/pytorch.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cfe59b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main, master, "9-feature-pip-installable"] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra dev + + - name: Check imports (no deprecation warnings) + run: | + uv run python -W error::DeprecationWarning -W error::PendingDeprecationWarning -c "import tools, experiments, aggregators, attacks" + + - name: Lint with Ruff + run: uv run ruff check . + + - name: Check formatting with Ruff + run: uv run ruff format --check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc1e4a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + pypi: + name: Build & Publish to PyPI + runs-on: ubuntu-latest + permissions: + id-token: write # Required for trusted publishing (recommended) + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Build package + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 75b8688..d852312 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ !LICENSE !*.md !*.py +!*.toml +!.python-version + +# Github +!.github/* +!*.yml # IDE stuff -.idea +.idea \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5aef86c --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Krum + +Byzantine-resilient aggregation rules for distributed machine learning. + +## Supported Python versions + +This project supports Python **3.10 through 3.14**. + +## Installation + +Install in editable mode with development dependencies: + +```bash +pip install -e ".[dev]" +``` + +## Development + +### Linting and formatting + +This project uses [Ruff](https://docs.astral.sh/ruff/) for unified linting and formatting. + +Run the formatter and linter: + +```bash +ruff format . +ruff check --fix . +``` + +### Pre-commit hooks + +Install pre-commit hooks to block non-compliant commits: + +```bash +pre-commit install +``` + +## License + +MIT License — see [LICENSE](LICENSE). diff --git a/aggregators/__init__.py b/aggregators/__init__.py deleted file mode 100644 index 498dd0b..0000000 --- a/aggregators/__init__.py +++ /dev/null @@ -1,97 +0,0 @@ -# coding: utf-8 -### - # @file __init__.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Loading of the local modules. - # - # Each rule MUST support taking any named arguments, possibly ignoring them. - # The parameters MUST all be passed as their keyword arguments. - # The reserved argument names, and their interface, are the following: - # · gradients: Non-empty list of gradients to aggregate - # · f : Number of Byzantine gradients to support - # · model : Model (duck-typing 'experiments.Model') with valid default dataset and loss set - # The rule, given "valid" parameter(s), MUST NOT return a tensor that is a reference to any tensor given as parameter. - # - # Each rule MUST provide a "check" member function, taking the same arguments as the rule itself. - # The "check" member function returns 'None' when the parameters are valid, - # or an explanatory string when the parameters are not valid. - # The check member function MUST NOT modify the given parameters. - # - # Once registered, the check member function will be available as member "check". - # The raw function and a wrapped checking the input/output of the raw function - # will respectively be available as members "unchecked" and "checked". - # Which of these two functions is called by default depends whether debug mode is enabled. -### - -import pathlib -import torch - -import tools - -# ---------------------------------------------------------------------------- # -# Automated GAR loader - -def make_gar(unchecked, check, upper_bound=None, influence=None): - """ GAR wrapper helper. - Args: - unchecked Associated function (see module description) - check Parameter validity check function - upper_bound Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this aggregation rule: (n, f, d) -> float - influence Attack acceptation ratio function - Returns: - Wrapped GAR - """ - # Closure wrapping the call with checks - def checked(**kwargs): - # Check parameter validity - message = check(**kwargs) - if message is not None: - raise tools.UserException("Aggregation rule %r cannot be used with the given parameters: %s" % (name, message)) - # Aggregation (hard to assert return value, duck-typing is allowed...) - return unchecked(**kwargs) - # Select which function to call by default - func = checked if __debug__ else unchecked - # Bind all the (sub) functions to the selected function - setattr(func, "check", check) - setattr(func, "checked", checked) - setattr(func, "unchecked", unchecked) - setattr(func, "upper_bound", upper_bound) - setattr(func, "influence", influence) - # Return the selected function with the associated name - return func - -def register(name, unchecked, check, upper_bound=None, influence=None): - """ Simple registration-wrapper helper. - Args: - name GAR name - unchecked Associated function (see module description) - check Parameter validity check function - upper_bound Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this aggregation rule: (n, f, d) -> float - influence Attack acceptation ratio function - """ - global gars - # Check if name already in use - if name in gars: - tools.warning("Unable to register %r GAR: name already in use" % name) - return - # Export the selected function with the associated name - gars[name] = make_gar(unchecked, check, upper_bound=upper_bound, influence=influence) - -# Registered rules (mapping name -> aggregation rule) -gars = dict() - -# Load all local modules -with tools.Context("aggregators", None): - tools.import_directory(pathlib.Path(__file__).parent, globals()) - -# Bind/overwrite the GAR name with the associated rules in globals() -for name, rule in gars.items(): - globals()[name] = rule diff --git a/aggregators/average.py b/aggregators/average.py deleted file mode 100644 index 5d1d96c..0000000 --- a/aggregators/average.py +++ /dev/null @@ -1,55 +0,0 @@ -# coding: utf-8 -### - # @file average.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Simple average GAR. -### - -from . import register - -# ---------------------------------------------------------------------------- # -# Average GAR - -def aggregate(gradients, **kwargs): - """ Averaging rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - Average gradient - """ - return sum(gradients) / len(gradients) - -def check(gradients, **kwargs): - """ Check parameter validity for the averaging rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string - """ - if not isinstance(gradients, list) or len(gradients) < 1: - return "Expected a list of at least one gradient to aggregate, got %r" % gradients - -def influence(honests, attacks, **kwargs): - """ Compute the ratio of accepted Byzantine gradients. - Args: - honests Non-empty list of honest gradients to aggregate - attacks List of attack gradients to aggregate - ... Ignored keyword-arguments - """ - return len(attacks) / (len(honests) + len(attacks)) - -# ---------------------------------------------------------------------------- # -# GAR registering - -# Register aggregation rule -register("average", aggregate, check, influence=influence) diff --git a/aggregators/brute.py b/aggregators/brute.py deleted file mode 100644 index 91dd079..0000000 --- a/aggregators/brute.py +++ /dev/null @@ -1,156 +0,0 @@ -# coding: utf-8 -### - # @file brute.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Brute GAR. -### - -import tools -from . import register - -import itertools -import math -import torch - -# Optional 'native' module -try: - import native -except ImportError: - native = None - -# ---------------------------------------------------------------------------- # -# Brute GAR - -def _compute_selection(gradients, f, **kwargs): - """ Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - Selection index set - """ - n = len(gradients) - # Compute all pairwise distances - distances = [0] * (n * (n - 1) // 2) - for i, (x, y) in enumerate(tools.pairwise(tuple(range(n)))): - distances[i] = gradients[x].sub(gradients[y]).norm().item() - # Select the set of smallest diameter - sel_iset = None - sel_diam = None - for cur_iset in itertools.combinations(range(n), n - f): - # Compute the current diameter (max of pairwise distances) - cur_diam = 0. - for x, y in tools.pairwise(cur_iset): - # Get distance between these two gradients ("magic" formula valid since x < y) - cur_dist = distances[(2 * n - x - 3) * x // 2 + y - 1] - # Check finite distance (non-Byzantine gradient must only contain finite coordinates), drop set if non-finite - if not math.isfinite(cur_dist): - break - # Check if new maximum - if cur_dist > cur_diam: - cur_diam = cur_dist - else: - # Check if new selected diameter - if sel_iset is None or cur_diam < sel_diam: - sel_iset = cur_iset - sel_diam = cur_diam - # Return the selected gradients - assert sel_iset is not None, "Too many non-finite gradients: a non-Byzantine gradient must only contain finite coordinates" - return sel_iset - -def aggregate(gradients, f, **kwargs): - """ Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - Aggregated gradient - """ - sel_iset = _compute_selection(gradients, f, **kwargs) - return sum(gradients[i] for i in sel_iset).div_(len(gradients) - f) - -def aggregate_native(gradients, f, **kwargs): - """ Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - Aggregated gradient - """ - return native.brute.aggregate(gradients, f) - -def check(gradients, f, **kwargs): - """ Check parameter validity for Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string - """ - if not isinstance(gradients, list) or len(gradients) < 1: - return "Expected a list of at least one gradient to aggregate, got %r" % gradients - if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 1: - return "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" % (f, (len(gradients) - 1) // 2) - -def upper_bound(n, f, d): - """ Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound - """ - return (n - f) / (math.sqrt(8) * f) - -def influence(honests, attacks, f, **kwargs): - """ Compute the ratio of accepted Byzantine gradients. - Args: - honests Non-empty list of honest gradients to aggregate - attacks List of attack gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Ratio of accepted - """ - gradients = honests + attacks - # Compute the selection set - sel_iset = _compute_selection(gradients, f, **kwargs) - # Compute the influence ratio - count = 0 - for i in sel_iset: - gradient = gradients[i] - for attack in attacks: - if gradient is attack: - count += 1 - break - return count / (len(gradients) - f) - -# ---------------------------------------------------------------------------- # -# GAR registering - -# Register aggregation rule (pytorch version) -method_name = "brute" -register(method_name, aggregate, check, upper_bound=upper_bound, influence=influence) - -# Register aggregation rule (native version, if available) -if native is not None: - native_name = method_name - method_name = "native-" + method_name - if native_name in dir(native): - register(method_name, aggregate_native, check, upper_bound) - else: - tools.warning("GAR %r could not be registered since the associated native module %r is unavailable" % (method_name, native_name)) diff --git a/aggregators/bulyan.py b/aggregators/bulyan.py deleted file mode 100644 index 49dcad0..0000000 --- a/aggregators/bulyan.py +++ /dev/null @@ -1,144 +0,0 @@ -# coding: utf-8 -### - # @file bulyan.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2020 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Bulyan over Multi-Krum GAR. -### - -import tools -from . import register - -import math -import torch - -# Optional 'native' module -try: - import native -except ImportError: - native = None - -# ---------------------------------------------------------------------------- # -# Bulyan GAR class - -def aggregate(gradients, f, m=None, **kwargs): - """ Bulyan over Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient - """ - n = len(gradients) - d = gradients[0].shape[0] - # Defaults - m_max = n - f - 2 - if m is None: - m = m_max - # Compute all pairwise distances - distances = list([(math.inf, None)] * n for _ in range(n)) - for gid_x, gid_y in tools.pairwise(tuple(range(n))): - dist = gradients[gid_x].sub(gradients[gid_y]).norm().item() - if not math.isfinite(dist): - dist = math.inf - distances[gid_x][gid_y] = (dist, gid_y) - distances[gid_y][gid_x] = (dist, gid_x) - # Compute the scores - scores = [None] * n - for gid in range(n): - dists = distances[gid] - dists.sort(key=lambda x: x[0]) - dists = dists[:m] - scores[gid] = (sum(dist for dist, _ in dists), gid) - distances[gid] = dict(dists) - # Selection loop - selected = torch.empty(n - 2 * f - 2, d, dtype=gradients[0].dtype, device=gradients[0].device) - for i in range(selected.shape[0]): - # Update 'm' - m = min(m, m_max - i) - # Compute the average of the selected gradients - scores.sort(key=lambda x: x[0]) - selected[i] = sum(gradients[gid] for _, gid in scores[:m]).div_(m) - # Remove the gradient from the distances and scores - gid_prune = scores[0][1] - scores[0] = (math.inf, None) - for score, gid in scores[1:]: - if gid == gid_prune: - scores[gid] = (score - distance[gid][gid_prune], gid) - # Coordinate-wise averaged median - m = selected.shape[0] - 2 * f - median = selected.median(dim=0).values - closests = selected.clone().sub_(median).abs_().topk(m, dim=0, largest=False, sorted=False).indices - closests.mul_(d).add_(torch.arange(0, d, dtype=closests.dtype, device=closests.device)) - avgmed = selected.take(closests).mean(dim=0) - # Return resulting gradient - return avgmed - -def aggregate_native(gradients, f, m=None, **kwargs): - """ Bulyan over Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient - """ - # Defaults - if m is None: - m = len(gradients) - f - 2 - # Computation - return native.bulyan.aggregate(gradients, f, m) - -def check(gradients, f, m=None, **kwargs): - """ Check parameter validity for Bulyan over Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string - """ - if not isinstance(gradients, list) or len(gradients) < 1: - return "Expected a list of at least one gradient to aggregate, got %r" % gradients - if not isinstance(f, int) or f < 1 or len(gradients) < 4 * f + 3: - return "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" % (f, (len(gradients) - 3) // 4) - if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): - return "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" % (f, len(gradients) - f - 2) - -def upper_bound(n, f, d): - """ Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound - """ - return 1 / math.sqrt(2 * (n - f + f * (n + f * (n - f - 2) - 2) / (n - 2 * f - 2))) - -# ---------------------------------------------------------------------------- # -# GAR registering - -# Register aggregation rule (pytorch version) -method_name = "bulyan" -register(method_name, aggregate, check, upper_bound=upper_bound) - -# Register aggregation rule (native version, if available) -if native is not None: - native_name = method_name - method_name = "native-" + method_name - if native_name in dir(native): - register(method_name, aggregate_native, check, upper_bound=upper_bound) - else: - tools.warning("GAR %r could not be registered since the associated native module %r is unavailable" % (method_name, native_name)) diff --git a/aggregators/krum.py b/aggregators/krum.py deleted file mode 100644 index 46210a4..0000000 --- a/aggregators/krum.py +++ /dev/null @@ -1,166 +0,0 @@ -# coding: utf-8 -### - # @file krum.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2020 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Multi-Krum GAR. -### - -import tools -from . import register - -import math -import torch - -# Optional 'native' module -try: - import native -except ImportError: - native = None - -# ---------------------------------------------------------------------------- # -# Multi-Krum GAR - -def _compute_scores(gradients, f, m, **kwargs): - """ Multi-Krum score computation. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - List of (gradient, score) by sorted (increasing) scores - """ - n = len(gradients) - # Compute all pairwise distances - distances = [0] * (n * (n - 1) // 2) - for i, (x, y) in enumerate(tools.pairwise(tuple(range(n)))): - dist = gradients[x].sub(gradients[y]).norm().item() - if not math.isfinite(dist): - dist = math.inf - distances[i] = dist - # Compute the scores - scores = list() - for i in range(n): - # Collect the distances - grad_dists = list() - for j in range(i): - grad_dists.append(distances[(2 * n - j - 3) * j // 2 + i - 1]) - for j in range(i + 1, n): - grad_dists.append(distances[(2 * n - i - 3) * i // 2 + j - 1]) - # Select the n - f - 1 smallest distances - grad_dists.sort() - scores.append((sum(grad_dists[:n - f - 1]), gradients[i])) - # Sort the gradients by increasing scores - scores.sort(key=lambda x: x[0]) - return scores - -def aggregate(gradients, f, m=None, **kwargs): - """ Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient - """ - # Defaults - if m is None: - m = len(gradients) - f - 2 - # Compute aggregated gradient - scores = _compute_scores(gradients, f, m, **kwargs) - return sum(grad for _, grad in scores[:m]).div_(m) - -def aggregate_native(gradients, f, m=None, **kwargs): - """ Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient - """ - # Defaults - if m is None: - m = len(gradients) - f - 2 - # Computation - return native.krum.aggregate(gradients, f, m) - -def check(gradients, f, m=None, **kwargs): - """ Check parameter validity for Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string - """ - if not isinstance(gradients, list) or len(gradients) < 1: - return "Expected a list of at least one gradient to aggregate, got %r" % gradients - if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 3: - return "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" % (f, (len(gradients) - 3) // 2) - if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): - return "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" % (m, len(gradients) - f - 2) - -def upper_bound(n, f, d): - """ Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound - """ - return 1 / math.sqrt(2 * (n - f + f * (n + f * (n - f - 2) - 2) / (n - 2 * f - 2))) - -def influence(honests, attacks, f, m=None, **kwargs): - """ Compute the ratio of accepted Byzantine gradients. - Args: - honests Non-empty list of honest gradients to aggregate - attacks List of attack gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Ratio of accepted - """ - gradients = honests + attacks - # Defaults - if m is None: - m = len(gradients) - f - 2 - # Compute the sorted scores - scores = _compute_scores(gradients, f, m, **kwargs) - # Compute the influence ratio - count = 0 - for _, gradient in scores[:m]: - for attack in attacks: - if gradient is attack: - count += 1 - break - return count / m - -# ---------------------------------------------------------------------------- # -# GAR registering - -# Register aggregation rule (pytorch version) -method_name = "krum" -register(method_name, aggregate, check, upper_bound, influence) - -# Register aggregation rule (native version, if available) -if native is not None: - native_name = method_name - method_name = "native-" + method_name - if native_name in dir(native): - register(method_name, aggregate_native, check, upper_bound) - else: - tools.warning("GAR %r could not be registered since the associated native module %r is unavailable" % (method_name, native_name)) diff --git a/aggregators/median.py b/aggregators/median.py deleted file mode 100644 index c59116c..0000000 --- a/aggregators/median.py +++ /dev/null @@ -1,87 +0,0 @@ -# coding: utf-8 -### - # @file median.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2020 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # NaN-resilient, coordinate-wise median GAR. -### - -import tools -from . import register - -import math -import torch - -# Optional 'native' module -try: - import native -except ImportError: - native = None - -# ---------------------------------------------------------------------------- # -# NaN-resilient, coordinate-wise median GAR - -def aggregate(gradients, **kwargs): - """ NaN-resilient median coordinate-per-coordinate rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - NaN-resilient, coordinate-wise median of the gradients - """ - return torch.stack(gradients).median(dim=0)[0] - -def aggregate_native(gradients, **kwargs): - """ NaN-resilient median coordinate-per-coordinate rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - NaN-resilient, coordinate-wise median of the gradients - """ - return native.median.aggregate(gradients) - -def check(gradients, **kwargs): - """ Check parameter validity for the median rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string - """ - if not isinstance(gradients, list) or len(gradients) < 1: - return "Expected a list of at least one gradient to aggregate, got %r" % gradients - -def upper_bound(n, f, d): - """ Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound - """ - return 1 / math.sqrt(n - f) - -# ---------------------------------------------------------------------------- # -# GAR registering - -# Register aggregation rule (pytorch version) -method_name = "median" -register(method_name, aggregate, check, upper_bound) - -# Register aggregation rule (native version, if available) -if native is not None: - native_name = method_name - method_name = "native-" + method_name - if native_name in dir(native): - register(method_name, aggregate_native, check, upper_bound) - else: - tools.warning("GAR %r could not be registered since the associated native module %r is unavailable" % (method_name, native_name)) diff --git a/attacks/__init__.py b/attacks/__init__.py deleted file mode 100644 index 0d1bc46..0000000 --- a/attacks/__init__.py +++ /dev/null @@ -1,87 +0,0 @@ -# coding: utf-8 -### - # @file __init__.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Loading of the local modules. - # - # Each attack MUST support taking any named arguments, possibly ignoring them. - # The parameters MUST all be passed as their keyword arguments. - # The reserved argument names, and their interface, are the following: - # · grad_honest: Non-empty list of honest gradients generated - # · f_decl : Number of declared Byzantine gradients at the GAR - # · f_real : Number of actual Byzantine gradients to generate - # · model : Model (duck-typing 'experiments.Model') with valid default dataset and loss set - # · defense : Aggregation rule (see module 'aggregators') in use to defeat - # The attack, given "valid" parameter(s), MUST return a list of f_byz tensor(s). - # Each of these returned tensors MUST NOT be a reference to any tensor given as parameter, - # although each returned tensors MAY be references to the same tensor. - # - # Each attack MUST provide a "check" function, taking the same arguments as the attack itself. - # The "check" member function returns 'None' when the parameters are valid, - # or an explanatory string when the parameters are not valid. - # The check member function MUST NOT modify the given parameters. - # - # Once registered, the check member function will be available as member "check". - # The raw function and a wrapped checking the input/output of the raw function - # will respectively be available as members "unchecked" and "checked". - # Which of these two functions is called by default depends whether debug mode is enabled. -### - -import pathlib -import torch - -import tools - -# ---------------------------------------------------------------------------- # -# Automated attack loader - -def register(name, unchecked, check): - """ Simple registration-wrapper helper. - Args: - name Attack name - unchecked Associated function (see module description) - check Parameter validity check function - """ - global attacks - # Check if name already in use - if name in attacks: - tools.warning(f"Unable to register {name!r} attack: name already in use") - return - # Closure wrapping the call with checks - def checked(f_real, **kwargs): - # Check parameter validity - message = check(f_real=f_real, **kwargs) - if message is not None: - raise tools.UserException(f"Attack {name!r} cannot be used with the given parameters: {message}") - # Attack - res = unchecked(f_real=f_real, **kwargs) - # Forward asserted return value - assert isinstance(res, list) and len(res) == f_real, f"Expected attack {name!r} to return a list of {f_real} Byzantine gradients, got {res!r}" - return res - # Select which function to call by default - func = checked if __debug__ else unchecked - # Bind all the (sub) functions to the selected function - setattr(func, "check", check) - setattr(func, "checked", checked) - setattr(func, "unchecked", unchecked) - # Export the selected function with the associated name - attacks[name] = func - -# Registered attacks (mapping name -> attack) -attacks = dict() - -# Load native and all local modules -with tools.Context("attacks", None): - tools.import_directory(pathlib.Path(__file__).parent, globals()) - -# Bind/overwrite the attack names with the associated attacks in globals() -for name, attack in attacks.items(): - globals()[name] = attack diff --git a/attacks/identical.py b/attacks/identical.py deleted file mode 100644 index c5474fd..0000000 --- a/attacks/identical.py +++ /dev/null @@ -1,148 +0,0 @@ -# coding: utf-8 -### - # @file identical.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Collection of attacks which submit f identical gradients, which consist in - # adding as much of one attack vector to the average of the honest gradients. - # - # These attacks have been introduced in/adapted from the following papers: - # bulyan · El Mhamdi El Mahdi, Guerraoui Rachid, and Rouault Sébastien. - # The Hidden Vulnerability of Distributed Learning in Byzantium. - # ICML 2018. URL: http://proceedings.mlr.press/v80/mhamdi18a.html - # empire · Cong Xie, Oluwasanmi Koyejo, Indranil Gupta. - # Fall of Empires: Breaking Byzantine-tolerant SGD by Inner Product Manipulation. - # UAI 2019. URL: http://auai.org/uai2019/proceedings/papers/83.pdf - # little · Moran Baruch, Gilad Baruch, Yoav Goldberg. - # A Little Is Enough: Circumventing Defenses For Distributed Learning. - # 2019 Feb 16. ArXiv. URL: https://arxiv.org/pdf/1902.06156v1 -### - -import tools - -import math -import torch - -from . import register - -# ---------------------------------------------------------------------------- # -# Generic attack implementation generator - -def make_attack(compute_direction): - """ Make the attack gradient generation closure associated with an attack direction. - Args: - compute_direction Attack vector computation, (stacked honest gradients, average honest gradient, forwarded keyword-arguments...) -> attack vector (in the gradient space, no reference) - Returns: - Byzantine gradient generation closure - """ - def attack(grad_honests, f_real, f_decl, defense, model, factor=-16, negative=False, **kwargs): - """ Generate the attack gradients. - Args: - grad_honests Non-empty list of honest gradients - f_decl Number of declared Byzantine gradients - f_real Number of Byzantine gradients to generate - defense Aggregation rule in use to defeat - model Model with valid default dataset and loss set - factor Fixed attack factor if positive, number of evaluations for best attack factor if negative - negative Use a negative factor instead of a positive one - ... Forwarded keyword-arguments - Returns: - Generated Byzantine gradients (all references to one) - """ - # Fast path - if f_real == 0: - return list() - # Stack and compute the average honest gradient, and then the attack vector - grad_stck = torch.stack(grad_honests) - grad_avg = grad_stck.mean(dim=0) - grad_att = compute_direction(grad_stck, grad_avg, **kwargs) - # Evaluate the best attack factor (if required) - if factor < 0: - def eval_factor(factor): - # Apply the given factor - if negative: - factor = -factor - grad_attack = grad_avg + factor * grad_att - # Measure effective squared distance - aggregated = defense(gradients=(grad_honests + [grad_attack] * f_real), f=f_decl, model=model) - aggregated.sub_(grad_avg) - return aggregated.dot(aggregated).item() - factor = tools.line_maximize(eval_factor, evals=math.ceil(-factor)) - else: - if negative: - factor = -factor - # Generate the Byzantine gradient from the given/computed factor - byz_grad = grad_avg - grad_att.mul_(factor) - byz_grad.add_(grad_att) - # Return this Byzantine gradient 'f_real' times - return [byz_grad] * f_real - # Return the attack closure - return attack - -def check(grad_honests, f_real, defense, factor=-16, negative=False, **kwargs): - """ Check parameter validity for this attack template. - Args: - grad_honests Non-empty list of honest gradients - f_real Number of Byzantine gradients to generate - defense Aggregation rule in use to defeat - ... Ignored keyword-arguments - Returns: - Whether the given parameters are valid for this attack - """ - if not isinstance(grad_honests, list) or len(grad_honests) == 0: - return "Expected a non-empty list of honest gradients, got %r" % (grad_honests,) - if not isinstance(f_real, int) or f_real < 0: - return "Expected a non-negative number of Byzantine gradients to generate, got %r" % (f_real,) - if not callable(defense): - return "Expected a callable for the aggregation rule, got %r" % (defense,) - if not ((isinstance(factor, float) and factor > 0) or (isinstance(factor, int) and factor != 0)): - return "Expected a positive number or a negative integer for the attack factor, got %r" % (factor,) - if not isinstance(negative, bool): - return "Expected a boolean for optional parameter 'negative', got %r" % (negative,) - -# ---------------------------------------------------------------------------- # -# Attack vector computations - -def bulyan(grad_stck, grad_avg, target_idx=-1, **kwargs): - """ Compute the attack vector adapted from "The Hidden Vulnerability". - Args: - target_idx Index of the targeted coordinate, "all" for all - See: - make_attack - """ - if target_idx == "all": - return torch.ones_like(grad_avg) - else: - assert isinstance(target_idx, int), "Expected an integer or \"all\" for 'target_idx', got %r" % (target_idx,) - grad_att = torch.zeros_like(grad_avg) - grad_att[target_idx] = 1 - return grad_att - -def empire(grad_stck, grad_avg, **kwargs): - """ Compute the attack vector adapted from "Fall of Empires". - See: - make_attack - """ - return grad_avg.neg() - -def little(grad_stck, grad_avg, **kwargs): - """ Compute the attack vector adapted from "A Little is Enough". - See: - make_attack - """ - return grad_stck.var(dim=0).sqrt_() - -# ---------------------------------------------------------------------------- # -# Attack registrations - -# Register the attacks -for name, func in (("bulyan", bulyan), ("empire", empire), ("little", little)): - register(name, make_attack(func), check) diff --git a/attacks/nan.py b/attacks/nan.py deleted file mode 100644 index ef9f308..0000000 --- a/attacks/nan.py +++ /dev/null @@ -1,60 +0,0 @@ -# coding: utf-8 -### - # @file nan.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Attack that generates NaN gradient(s), hence the name. -### - -import math -import torch - -from . import register - -# ---------------------------------------------------------------------------- # -# Non-finite gradient attack - -def attack(grad_honests, f_real, **kwargs): - """ Generate non-finite gradients. - Args: - grad_honests Non-empty list of honest gradients - f_real Number of Byzantine gradients to generate - ... Ignored keyword-arguments - Returns: - Generated Byzantine gradients - """ - # Fast path - if f_real == 0: - return list() - # Generate the non-finite Byzantine gradient - byz_grad = torch.empty_like(grad_honests[0]) - byz_grad.copy_(torch.tensor((math.nan,), dtype=byz_grad.dtype)) - # Return this Byzantine gradient 'f_real' times - return [byz_grad] * f_real - -def check(grad_honests, f_real, **kwargs): - """ Check parameter validity for this attack. - Args: - grad_honests Non-empty list of honest gradients - f_real Number of Byzantine gradients to generate - ... Ignored keyword-arguments - Returns: - Whether the given parameters are valid for this attack - """ - if not isinstance(grad_honests, list) or len(grad_honests) == 0: - return "Expected a non-empty list of honest gradients, got %r" % (grad_honests,) - if not isinstance(f_real, int) or f_real < 0: - return "Expected a non-negative number of Byzantine gradients to generate, got %r" % (f_real,) - -# ---------------------------------------------------------------------------- # -# Attack registering - -# Register the attack -register("nan", attack, check) diff --git a/experiments/__init__.py b/experiments/__init__.py deleted file mode 100644 index ea32c19..0000000 --- a/experiments/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# coding: utf-8 -### - # @file __init__.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Dataset/model/... wrappers/helpers, for more convenient gradient extraction and operations. - # Heavily relies on the module 'torchvision'. -### - -import pathlib - -import tools - -# ---------------------------------------------------------------------------- # -# Load all local modules - -with tools.Context("experiments", None): - tools.import_directory(pathlib.Path(__file__).parent, globals()) diff --git a/experiments/checkpoint.py b/experiments/checkpoint.py deleted file mode 100644 index 9a588fb..0000000 --- a/experiments/checkpoint.py +++ /dev/null @@ -1,169 +0,0 @@ -# coding: utf-8 -### - # @file checkpoint.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Checkpoint helpers. -### - -__all__ = ["Checkpoint", "Storage"] - -import tools - -import copy -import pathlib -import torch - -from .model import Model -from .optimizer import Optimizer - -# ---------------------------------------------------------------------------- # -# Checkpoint helper class - -class Checkpoint: - """ A collection of state dictionaries with saving/loading helpers. - """ - - # Transfer for handling local package's classes - _transfers = { - Model: (lambda x: x._model), - Optimizer: (lambda x: x._optim) } - - @classmethod - def _prepare(self, instance): - """ Prepare the given instance for checkpointing. - Args: - instance Instance to snapshot/restore - Returns: - Checkpoint-able instance, key for the associated storage - """ - # Recover instance's class - cls = type(instance) - # Transfer if available - if cls in self._transfers: - res = self._transfers[cls](instance) - else: - res = instance - # Assert the instance is checkpoint-able - for prop in ("state_dict", "load_state_dict"): - if not callable(getattr(res, prop, None)): - raise tools.UserException(f"Given instance {instance!r} is not checkpoint-able (missing callable member {prop!r})") - # Return the instance and the associated storage key - return res, tools.fullqual(cls) - - def __init__(self): - """ Empty checkpoint constructor. - """ - # Finalization - self._store = dict() - if __debug__: - self._copied = dict() # Booleans for tracking possible bugs, 'key in _store' <=> 'key in _copied' - - def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): - """ Take/overwrite the snapshot for a given instance. - Args: - instance Instance to snapshot - overwrite Overwrite any existing snapshot for the same class - deepcopy Deep copy instance's state dictionary instead of referencing - nowarnref To always avoid a warning in debug mode if restoring a state dictionary reference is the wanted behavior - Returns: - self - """ - instance, key = type(self)._prepare(instance) - # Snapshot the state dictionary - if not overwrite and key in self._store: - raise tools.UserException(f"A snapshot for {key!r} is already stored in the checkpoint") - if deepcopy: - self._store[key] = copy.deepcopy(instance.state_dict()) - else: - self._store[key] = instance.state_dict().copy() - # Track whether a deepcopy was made (or whether restoring a reference is the expected behavior) - if __debug__: - self._copied[key] = deepcopy or nowarnref - # Enable chaining - return self - - def restore(self, instance, nothrow=False): - """ Restore the snapshot for a given instance, warn if restoring a reference. - Args: - instance Instance to restore - nothrow Do not raise exception if no snapshot available for the instance - Returns: - self - """ - instance, key = type(self)._prepare(instance) - # Restore the state dictionary - if key in self._store: - instance.load_state_dict(self._store[key]) - # Check if restoring a reference - if __debug__ and not self._copied[key]: - tools.warning(f"Restoring a state dictionary reference in an instance of {tools.fullqual(type(instance))}; the resulting behavior may not be the one expected") - elif not nothrow: - raise tools.UserException(f"No snapshot for {key!r} is available in the checkpoint") - # Enable chaining - return self - - def load(self, filepath, overwrite=False): - """ Load/overwrite the storage from the given file. - Args: - filepath Given file path - overwrite Allow to overwrite any stored snapshot - Returns: - self - """ - # Check if empty - if not overwrite and len(self._store) > 0: - raise tools.UserException("Unable to load into a non-empty checkpoint") - # Load the file - self._store = torch.load(filepath) - # Reset the 'copied' flags accordingly - if __debug__: - self._copied.clear() - for key in self._store.keys(): - self._copied[key] = True - # Enable chaining - return self - - def save(self, filepath, overwrite=False): - """ Save the current checkpoint in the given file. - Args: - filepath Given file path - overwrite Allow to overwrite if the file already exists - Returns: - self - """ - # Check if file already exists - if pathlib.Path(filepath).exists() and not overwrite: - raise tools.UserException(f"Unable to save checkpoint in existing file {str(filepath)!r} (overwriting has not been allowed by the caller)") - # (Over)write the file - torch.save(self._store, filepath) - # Enable chaining - return self - -# ---------------------------------------------------------------------------- # -# Dictionary that implements "state_dict protocol" - -class Storage(dict): - """ Dictionary that implements "state_dict protocol" class. - """ - - def state_dict(self): - """ Access the state dictionary. - Returns: - self - """ - return self - - def load_state_dict(self, state): - """ Update the state dictionary. - Args: - state State to update the current storage with - """ - self.update(state) diff --git a/experiments/configuration.py b/experiments/configuration.py deleted file mode 100644 index 33e4faa..0000000 --- a/experiments/configuration.py +++ /dev/null @@ -1,101 +0,0 @@ -# coding: utf-8 -### - # @file configuration.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Configuration wrapper. -### - -__all__ = ["Configuration"] - -import tools - -from collections.abc import Mapping -import torch - -# ---------------------------------------------------------------------------- # -# Trivial tensor configuration holder (dtype, device, ...) class - -class Configuration(Mapping): - """ Immutable tensor configuration holder class. - """ - - # Default selected device (GPU if available, else CPU) - default_device = "cuda" if torch.cuda.is_available() else "cpu" - - def __init__(self, device=None, dtype=None, noblock=False, relink=False): - """ Immutable initialization constructor. - Args: - device Device (either instance, formatted name or None) to use - dtype Datatype to use, None for PyTorch default - noblock To try and avoid using blocking memory transfer operations from the host - relink Relink instead of copying by default in some assignment operations - """ - # Convert formatted device name to device instance - if device is None: - # Use default device - device = type(self).default_device - if isinstance(device, str): - # Warn if CUDA is requested but not available - if not torch.cuda.is_available() and device[:4] == "cuda": - device = "cpu" - tools.warning("CUDA is unavailable on this node, falling back to CPU in the configuration", context="experiments") - # Convert - device = torch.device(device) - # Resolve the current default dtype if unspecified - if dtype is None: - dtype = torch.get_default_dtype() - # Finalization - self._args = { - "device": device, - "dtype": dtype, - "non_blocking": noblock } - self.relink = relink - - def __len__(self): - """ Return the number of contained configuration entries. - Returns: - Number of configuration entries - """ - return len(self._args) - - def __getitem__(self, name): - """ Get a configuration value from its name. - Args: - name Configuration name - Returns: - Associated configuration value - """ - return self._args[name] - - def __iter__(self): - """ Build an iterator over all the configuration entries. - Return: - Built iterator - """ - return self._args.__iter__() - - def __str__(self): - """ Compute the "informal", nicely printable string representation of this configuration. - Returns: - Nicely printable string - """ - temp = self._args.copy() - temp["relink"] = self.relink - return str(temp) - - def __repr__(self): - """ Compute the "official", Python-code string representation of this configuration. - Returns: - Python-code string evaluating (under conditions) to this configuration - """ - display = {"non_blocking": "noblock"} - argrepr = (", ").join(f"{display.get(key, key)}={val!r}" for key, val in self._args.items()) - return f"Configuration({argrepr}, relink={self.relink})" diff --git a/experiments/dataset.py b/experiments/dataset.py deleted file mode 100644 index 16ad473..0000000 --- a/experiments/dataset.py +++ /dev/null @@ -1,354 +0,0 @@ -# coding: utf-8 -### - # @file dataset.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Dataset wrappers/helpers. -### - -__all__ = ["get_default_transform", "Dataset", "make_sampler", "make_datasets", - "batch_dataset"] - -import tools - -import pathlib -import random -import tempfile -import torch -import torchvision -import types - -# ---------------------------------------------------------------------------- # -# Default image transformations - -# Collection of default transforms, -> (, ) -transforms_horizontalflip = [ - torchvision.transforms.RandomHorizontalFlip(), - torchvision.transforms.ToTensor()] -transforms_mnist = [ - torchvision.transforms.ToTensor(), - torchvision.transforms.Normalize((0.1307,), (0.3081,))] # Transforms from "A Little is Enough" (https://github.com/moranant/attacking_distributed_learning) -transforms_cifar = [ - torchvision.transforms.RandomHorizontalFlip(), - torchvision.transforms.ToTensor(), - torchvision.transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))] # Transforms from https://github.com/kuangliu/pytorch-cifar - -# Per-dataset image transformations (automatically completed, see 'Dataset._get_datasets') -transforms = { - "mnist": (transforms_mnist, transforms_mnist), - "fashionmnist": (transforms_horizontalflip, transforms_horizontalflip), - "cifar10": (transforms_cifar, transforms_cifar), - "cifar100": (transforms_cifar, transforms_cifar), - "imagenet": (transforms_horizontalflip, transforms_horizontalflip) } - -def get_default_transform(dataset, train): - """ Get the default transform associated with the given dataset name. - Args: - dataset Case-sensitive dataset name, or None to get no transformation - train Whether the transformation is for the training set (always ignored if None is given for 'dataset') - Returns: - Associated default transformations (always exist) - """ - global transforms - # Fetch transformation - transform = transforms.get(dataset) - # Not found (not a torchvision dataset) - if transform is None: - return None - # Return associated transform - return torchvision.transforms.Compose(transform[0 if train else 1]) - -# ---------------------------------------------------------------------------- # -# Dataset loader-batch producer wrapper class - -class Dataset: - """ Dataset wrapper class. - """ - - # Default dataset root directory path - __default_root = None - - @classmethod - def get_default_root(self): - """ Lazy-initialize and return the default dataset root directory path. - Returns: - '__default_root' - """ - # Fast-path already loaded - if self.__default_root is not None: - return self.__default_root - # Generate the default path - self.__default_root = pathlib.Path(__file__).parent / "datasets" / "cache" - # Warn if the path does not exist and fallback to '/tmp' - if not self.__default_root.exists(): - tmpdir = tempfile.gettempdir() - tools.warning(f"Default dataset root {str(self.__default_root)!r} does not exist, falling back to local temporary directory {tmpdir!r}", context="experiments") - self.__default_root = pathlib.Path(tmpdir) - # Return the path - return self.__default_root - - # Map 'lower-case names' -> 'dataset class' available in PyTorch - __datasets = None - - @classmethod - def _get_datasets(self): - """ Lazy-initialize and return the map '__datasets'. - Returns: - '__datasets' - """ - global transforms - # Fast-path already loaded - if self.__datasets is not None: - return self.__datasets - # Initialize the dictionary - self.__datasets = dict() - # Populate this dictionary with TorchVision's datasets - for name in dir(torchvision.datasets): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members - continue - constructor = getattr(torchvision.datasets, name) - if isinstance(constructor, type): # Heuristic - def make_builder(constructor, name): - def builder(root, batch_size=None, shuffle=False, num_workers=1, *args, **kwargs): - # Try to build the dataset instance - data = constructor(root, *args, **kwargs) - assert isinstance(data, torch.utils.data.Dataset), f"Internal heuristic failed: {name!r} was not a dataset name" - # Ensure there is at least a tensor transformation for each torchvision dataset - if name not in transforms: - transforms[name] = torchvision.transforms.ToTensor() - # Wrap into a loader - batch_size = batch_size or len(data) - loader = torch.utils.data.DataLoader(data, batch_size=batch_size, shuffle=shuffle, num_workers=num_workers) - # Wrap into an infinite batch sampler generator - return make_sampler(loader) - return builder - self.__datasets[name.lower()] = make_builder(constructor, name) - # Dynamically add the custom datasets from subdirectory 'datasets/' - def add_custom_datasets(name, module, _): - nonlocal self - # Check if has exports, fallback otherwise - exports = getattr(module, "__all__", None) - if exports is None: - tools.warning(f"Dataset module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery") - exports = (name for name in dir(module) if len(name) > 0 and name[0] != "_") - # Register the association 'name -> constructor' for all the datasets - exported = False - for dataset in exports: - # Check dataset name type - if not isinstance(dataset, str): - tools.warning(f"Dataset module {name!r} exports non-string name {dataset!r}; ignored") - continue - # Recover instance from name - constructor = getattr(module, dataset, None) - # Check instance is callable (it's only an heuristic...) - if not callable(constructor): - continue - # Register callable with composite name - exported = True - fullname = f"{name}-{dataset}" - if fullname in self.__datasets: - tools.warning(f"Unable to make available dataset {dataset!r} from module {name!r}, as the name {fullname!r} already exists") - continue - self.__datasets[fullname] = constructor - if not exported: - tools.warning(f"Dataset module {name!r} does not export any valid constructor name through '__all__'") - with tools.Context("datasets", None): - tools.import_directory(pathlib.Path(__file__).parent / "datasets", {"__package__": f"{__package__}.datasets"}, post=add_custom_datasets) - # Return the dictionary - return self.__datasets - - def __init__(self, data, name=None, root=None, *args, **kwargs): - """ Dataset builder constructor. - Args: - data Dataset string name, (infinite) generator instance (that will be used to generate samples), or any other instance (that will then be fed as the only sample) - name Optional user-defined dataset name, to attach to some error messages for debugging purpose - root Dataset cache root directory to use, None for default (only relevant if 'data' is a dataset name) - ... Forwarded (keyword-)arguments to the dataset constructor, ignored if 'data' is not a string - Raises: - 'TypeError' if the some of the given (keyword-)arguments cannot be used to call the dataset or loader constructor or the batch loader - """ - # Handle different dataset types - if isinstance(data, str): # Load sampler from available datasets - if name is None: - name = data - datasets = type(self)._get_datasets() - build = datasets.get(name, None) - if build is None: - raise tools.UnavailableException(datasets, name, what="dataset name") - root = root or type(self).get_default_root() - self._iter = build(root=root, *args, **kwargs) - elif isinstance(data, types.GeneratorType): # Forward sampling to custom generator - if name is None: - name = "" - self._iter = data - else: # Single-batch dataset of any value - if name is None: - name = "" - def single_batch(): - while True: - yield data - self._iter = single_batch() - # Finalization - self.name = name - - def __str__(self): - """ Compute the "informal", nicely printable string representation of this dataset. - Returns: - Nicely printable string - """ - return f"dataset {self.name}" - - def sample(self, config=None): - """ Sample the next batch from this dataset. - Args: - config Target configuration for the sampled tensors - Returns: - Next batch - """ - tns = next(self._iter) - if config is not None: - tns = type(tns)(tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns) - return tns - - def epoch(self, config=None): - """ Return a full epoch iterable from this dataset. - Args: - config Target configuration for the sampled tensors - Returns: - Full epoch iterable - Notes: - Only work for dataset based on PyTorch's DataLoader - """ - # Assert dataset based on DataLoader - assert isinstance(self._loader, torch.utils.data.DataLoader), "Full epoch iteration only possible for PyTorch's DataLoader-based datasets" - # Return a full epoch iterator - epoch = self._loader.__iter__() - def generator(): - nonlocal epoch - try: - while True: - tns = next(epoch) - if config is not None: - tns = type(tns)(tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns) - yield tns - except StopIteration: - return - return generator() - -# ---------------------------------------------------------------------------- # -# Dataset helpers - -def make_sampler(loader): - """ Infinite sampler generator from a dataset loader. - Args: - loader Dataset loader to use - Yields: - Sample, forever (transparently iterating the given loader again and again) - """ - itr = None - while True: - for _ in range(2): - # Try sampling the next batch - if itr is not None: - try: - yield next(itr) - break - except StopIteration: - pass - # Ask loader for a new iteration - itr = iter(loader) - else: - raise RuntimeError(f"Unable to sample a new batch from dataset {name!r}") - -def make_datasets(dataset, train_batch=None, test_batch=None, train_transforms=None, test_transforms=None, num_workers=1, **custom_args): - """ Helper to make new instances of training and testing datasets. - Args: - dataset Case-sensitive dataset name - train_batch Training batch size, None or 0 for maximum possible - test_batch Testing batch size, None or 0 for maximum possible - train_transforms Transformations to apply on the training set, None for default for the given dataset - test_transforms Transformations to apply on the testing set, None for default for the given dataset - num_workers Positive number of workers for each of the training and testing datasets, or tuple for each of them - ... Additional dataset-dependent keyword-arguments - Returns: - Training dataset, testing dataset - """ - # Pre-process arguments - train_transforms = train_transforms or get_default_transform(dataset, True) - test_transforms = test_transforms or get_default_transform(dataset, False) - num_workers_errmsg = "Expected either a positive int or a tuple of 2 positive ints for parameter 'num_workers'" - if isinstance(num_workers, int): - assert num_workers > 0, num_workers_errmsg - train_workers = test_workers = num_workers - else: - assert isinstance(num_workers, tuple) and len(num_workers) == 2, num_workers_errmsg - train_workers, test_workers = num_workers - assert isinstance(train_workers, int) and train_workers > 0, num_workers_errmsg - assert isinstance(test_workers, int) and test_workers > 0, num_workers_errmsg - # Make the datasets - trainset = Dataset(dataset, train=True, download=True, batch_size=train_batch, - shuffle=True, num_workers=train_workers, transform=train_transforms, **custom_args) - testset = Dataset(dataset, train=False, download=False, batch_size=test_batch, - shuffle=False, num_workers=test_workers, transform=test_transforms, **custom_args) - # Return the datasets - return trainset, testset - -def batch_dataset(inputs, labels, train=False, batch_size=None, split=0.75): - """ Batch a given raw (tensor) dataset into either a training or testing infinite sampler generators. - Args: - inputs Tensor of positive dimension containing input data - labels Tensor of same shape as 'inputs' containing expected output data - train Whether this is for training (basically adds shuffling) - batch_size Training batch size, None (or 0) for maximum batch size - split Fraction of datapoints to use in the train set if < 1, or #samples in the train set if ≥ 1 - Returns: - Training or testing set infinite sampler generator (with uniformly sampled batches), - Test set infinite sampler generator (without random sampling) - """ - def train_gen(inputs, labels, batch): - cursor = 0 - datalen = len(inputs) - shuffle = list(range(datalen)) - random.shuffle(shuffle) - while True: - end = cursor + batch - if end > datalen: - select = shuffle[cursor:] - random.shuffle(shuffle) - select += shuffle[:(end % datalen)] - else: - select = shuffle[cursor:end] - yield inputs[select], labels[select] - cursor = end % datalen - def test_gen(inputs, labels, batch): - cursor = 0 - datalen = len(inputs) - while True: - end = cursor + batch - if end > datalen: - select = list(range(cursor, datalen)) + list(range(end % datalen)) - yield inputs[select], labels[select] - else: - yield inputs[cursor:end], labels[cursor:end] - cursor = end % datalen - # Split dataset - dataset_len = len(inputs) - if dataset_len < 1 or len(labels) != dataset_len: - raise RuntimeError(f"Invalid or different input/output tensor lengths, got {len(inputs)} for inputs, got {len(labels)} for labels") - split_pos = min(max(1, int(dataset_len * split)) if split < 1 else split, dataset_len - 1) - # Make and return generator according to flavor - if train: - train_len = split_pos - batch_size = min(batch_size or train_len, train_len) - return train_gen(inputs[:split_pos], labels[:split_pos], batch_size) - else: - test_len = dataset_len - split_pos - batch_size = min(batch_size or test_len, test_len) - return test_gen(inputs[split_pos:], labels[split_pos:], batch_size) diff --git a/experiments/datasets/svm.py b/experiments/datasets/svm.py deleted file mode 100644 index 0725fa4..0000000 --- a/experiments/datasets/svm.py +++ /dev/null @@ -1,126 +0,0 @@ -# coding: utf-8 -### - # @file svm.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Lazy-(down)load and pre-process datasets from LIBSVM. - # Website: https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/ -### - -__all__ = ["phishing"] - -import requests -import torch - -import experiments -import tools - -# ---------------------------------------------------------------------------- # -# Configuration - -# Default raw dataset URLs -default_url_phishing = "https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/phishing" - -# Default cache root directory -default_root=experiments.dataset.Dataset.get_default_root() - -# ---------------------------------------------------------------------------- # -# Dataset lazy-loaders - -# Raw phishing dataset -raw_phishing = None - -def get_phishing(root, url): - """ Lazy-load the phishing dataset. - Args: - root Dataset cache root directory - url URL to fetch raw dataset from, if not already in cache (None for no download) - Returns: - Input tensor, - Label tensor - """ - global raw_phishing - const_filename = "phishing.pt" - const_features = 68 - const_datatype = torch.float32 - # Fast path: return loaded dataset - if raw_phishing is not None: - return raw_phishing - # Make dataset path - dataset_file = root / const_filename - # Fast path: pre-processed dataset already locally available - if dataset_file.exists(): - with dataset_file.open("rb") as fd: - # Load, lazy-store and return dataset - dataset = torch.load(fd) - raw_phishing = dataset - return dataset - elif url is None: - raise RuntimeError("Phishing dataset not in cache and download disabled") - # Download dataset - tools.info("Downloading dataset...", end="", flush=True) - try: - response = requests.get(url) - except Exception as err: - tools.warning(" fail.") - raise RuntimeError(f"Unable to get dataset (at {url}): {err}") - tools.info(" done.") - if response.status_code != 200: - raise RuntimeError(f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}") - # Pre-process dataset - tools.info("Pre-processing dataset...", end="", flush=True) - entries = response.text.strip().split("\n") - inputs = torch.zeros(len(entries), const_features, dtype=const_datatype) - labels = torch.empty(len(entries), dtype=const_datatype) - for index, entry in enumerate(entries): - entry = entry.split(" ") - # Set label - labels[index] = 1 if entry[0] == "1" else 0 - # Set input - line = inputs[index] - for pos, setter in enumerate(entry[1:]): - try: - offset, value = setter.split(":") - line[int(offset) - 1] = float(value) - except Exception as err: - tools.warning(" fail.") - raise RuntimeError(f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}") - labels.unsqueeze_(1) - tools.info(" done.") - # (Try to) save pre-processed dataset - try: - with dataset_file.open("wb") as fd: - torch.save((inputs, labels), fd) - except Exception as err: - tools.warning(f"Unable to save pre-processed dataset: {err}") - # Lazy-store and return dataset - dataset = (inputs, labels) - raw_phishing = dataset - return dataset - -# ---------------------------------------------------------------------------- # -# Dataset generators - -def phishing(train=True, batch_size=None, root=None, download=False, *args, **kwargs): - """ Phishing dataset generator builder. - Args: - train Whether to get the training slice of the dataset - batch_size Batch size (None or 0 for all in one single batch) - root Dataset cache root directory (None for default) - download Whether to allow to download the dataset if not cached locally - ... Ignored supplementary (keyword-)arguments - Returns: - Associated ataset generator - """ - with tools.Context("phishing", None): - # Get the raw dataset - inputs, labels = get_phishing(root or default_root, None if download is None else default_url_phishing) - # Make and return the associated generator - return experiments.batch_dataset(inputs, labels, train, batch_size, split=8400) # 8400 = 2⁴ × 3 × 5² × 7 (should help with divisibility) diff --git a/experiments/loss.py b/experiments/loss.py deleted file mode 100644 index 92ccd6f..0000000 --- a/experiments/loss.py +++ /dev/null @@ -1,310 +0,0 @@ -# coding: utf-8 -### - # @file loss.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Loss/criterion wrappers/helpers. -### - -__all__ = ["Loss", "Criterion"] - -import tools - -import torch - -# ---------------------------------------------------------------------------- # -# Loss (derivable)/criterion (non-derivable) wrapper classes - -class Loss: - """ Loss (must be derivable) wrapper class. - """ - - __reserved_init = object() - - @staticmethod - def _l1loss(output, target, params): - """ l1 loss implementation - Args: - ... Ignored arguments - params Flat parameter tensor - Returns: - l1 loss - """ - return params.norm(p=1) - - @staticmethod - def _l2loss(output, target, params): - """ l2 loss implementation - Args: - ... Ignored arguments - params Flat parameter tensor - Returns: - l2 loss - """ - return params.norm() - - @classmethod - def _l1loss_builder(self): - """ l1 loss builder. - Returns: - L1 loss instance - """ - return self(self.__reserved_init, self._l1loss, None, "l1") - - @classmethod - def _l2loss_builder(self): - """ l2 loss builder. - Returns: - L2 loss instance - """ - return self(self.__reserved_init, self._l2loss, None, "l2") - - # Map 'lower-case names' -> 'loss constructor' available in PyTorch - __losses = None - - @staticmethod - def _make_drop_params(builder): - """ Make a builder that will wrap the built function so to drop the 'params' parameter. - Args: - builder Builder function to wrap - Returns: - Wrapped builder function - """ - def drop_builder(*args, **kwargs): - loss = builder(*args, **kwargs) - def drop_loss(output, target, params): - return loss(output, target) - return drop_loss - return drop_builder - - @classmethod - def _get_losses(self): - """ Lazy-initialize and return the map '__losses'. - Returns: - '__losses' - """ - # Fast-path already loaded - if self.__losses is not None: - return self.__losses - # Initialize the dictionary - self.__losses = dict() - # Simply populate this dictionary - for name in dir(torch.nn.modules.loss): - if len(name) < 5 or name[0] == "_" or name[-4:] != "Loss": # Heuristically ignore non-loss members - continue - builder = getattr(torch.nn.modules.loss, name) - if isinstance(builder, type): # Still an heuristic - self.__losses[name[:-4].lower()] = self._make_drop_params(builder) - # Add/replace the l1 and l2 losses - self.__losses["l1"] = self._l1loss_builder - self.__losses["l2"] = self._l2loss_builder - # Return the dictionary - return self.__losses - - def __init__(self, name_build, *args, **kwargs): - """ Loss constructor. - Args: - name_build Loss name or constructor function - ... Additional (keyword-)arguments forwarded to the constructor - """ - # Reserved custom initialization - if name_build is type(self).__reserved_init: - self._loss = args[0] - self._fact = args[1] - self._name = args[2] - return - # Recover name/constructor - if callable(name_build): - name = tools.fullqual(name_build) - build = name_build - else: - losses = type(self)._get_losses() - name = str(name_build) - build = losses.get(name, None) - if build is None: - raise tools.UnavailableException(losses, name, what="loss name") - # Build loss - loss = build(*args, **kwargs) - # Finalization - self._loss = loss - self._fact = None - self._name = name - - def _str_make(self): - """ Make the formatted part of the nicely printable string representation of this loss. - Returns: - Formatted part - """ - return self._name if self._fact is None else f"{self._fact} × {self._name}" - - def __str__(self): - """ Compute the "informal", nicely printable string representation of this loss. - Returns: - Nicely printable string - """ - return f"loss {self._str_make()}" - - def __call__(self, output, target, params): - """ Compute the loss from the output and the target. - Args: - output Output tensor from the model - target Expected tensor - params Parameter vector - Returns: - Computed loss tensor - """ - res = self._loss(output, target, params) - if self._fact is not None: - res *= self._fact - return res - - def __add__(self, loss): - """ Add the current loss to the given loss. - Args: - loss Given loss - Returns: - Sum of the two losses - """ - def add(output, target, params): - return self(output, target, params) + loss(output, target, params) - return type(self)(type(self).__reserved_init, add, None, f"({self._str_make()} + {loss._str_make()})") - - def __mul__(self, factor): - """ Multiply the current loss by a given factor. - Args: - factor Given factor - Returns: - New loss, factor of the current loss - """ - def mul(output, target, params): - return self(output, target, params) * factor - return type(self)(type(self).__reserved_init, mul, factor * (1. if self._fact is None else self._fact), self._name) - - def __rmul__(self, *args, **kwargs): - """ Forward the call to '__mul__'. - Args: - ... Forwarded (keyword-)arguments - Returns: - Forwarded return value - """ - return self.__mul__(*args, **kwargs) - - def __imul__(self, factor): - """ In-place multiply the current loss by a given factor. - Args: - factor Given factor - Returns: - Current loss - """ - self._fact = factor * (1. if self._fact is None else self._fact) - return self - -class Criterion: - """ Criterion (1D tensor [#correct classification, batch size]) wrapper class. - """ - - class _TopkCriterion: - """ Top-k criterion helper class. - """ - - def __init__(self, k=1): - """ Value of 'k' constructor. - Args: - k Value of 'k' to use - """ - # Finalization - self.k = k - - def __call__(self, output, target): - """ Compute the criterion from the output and the target. - Args: - output Batch × model logits - target Batch × target index - Returns: - 1D-tensor [#correct classification, batch size] - """ - res = (output.topk(self.k, dim=1)[1] == target.view(-1).unsqueeze(1)).any(dim=1).sum() - return torch.cat((res.unsqueeze(0), torch.tensor(target.shape[0], dtype=res.dtype, device=res.device).unsqueeze(0))) - - class _SigmoidCriterion: - """ Sigmoid criterion helper class. - """ - - def __call__(self, output, target): - """ Compute the criterion from the output and the target. - Args: - output Batch × model logits (expected in [0, 1]) - target Batch × target index (expected in {0, 1}) - Returns: - 1D-tensor [#correct classification, batch size] - """ - correct = target.sub(output).abs_() < 0.5 - res = torch.empty(2, dtype=output.dtype, device=output.device) - res[0] = correct.sum() - res[1] = len(correct) - return res - - # Map 'lower-case names' -> 'loss constructor' available in PyTorch - __criterions = None - - @classmethod - def _get_criterions(self): - """ Lazy-initialize and return the map '__criterions'. - Returns: - '__criterions' - """ - # Fast-path already loaded - if self.__criterions is not None: - return self.__criterions - # Initialize the dictionary - self.__criterions = { - "top-k": self._TopkCriterion, - "sigmoid": self._SigmoidCriterion } - # Return the dictionary - return self.__criterions - - def __init__(self, name_build, *args, **kwargs): - """ Criterion constructor. - Args: - name_build Criterion name or constructor function - ... Additional (keyword-)arguments forwarded to the constructor - """ - # Recover name/constructor - if callable(name_build): - name = tools.fullqual(name_build) - build = name_build - else: - crits = type(self)._get_criterions() - name = str(name_build) - build = crits.get(name, None) - if build is None: - raise tools.UnavailableException(crits, name, what="criterion name") - # Build criterion - crit = build(*args, **kwargs) - # Finalization - self._crit = crit - self._name = name - - def __str__(self): - """ Compute the "informal", nicely printable string representation of this criterion. - Returns: - Nicely printable string - """ - return f"criterion {self._name}" - - def __call__(self, output, target): - """ Compute the criterion from the output and the target. - Args: - output Output tensor from the model - target Expected tensor - Returns: - Computed criterion tensor - """ - return self._crit(output, target) diff --git a/experiments/model.py b/experiments/model.py deleted file mode 100644 index 2ea36db..0000000 --- a/experiments/model.py +++ /dev/null @@ -1,396 +0,0 @@ -# coding: utf-8 -### - # @file model.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Model wrappers/helpers. -### - -__all__ = ["Model"] - -import tools - -import pathlib -import torch -import torchvision -import types - -from .configuration import Configuration - -# ---------------------------------------------------------------------------- # -# Model wrapper class - -class Model: - """ Model wrapper class. - """ - - # Map 'lower-case names' -> 'model constructor' available in PyTorch - __models = None - - # Map 'lower-case names' -> 'tensor initializer' available in PyTorch - __inits = None - - @classmethod - def _get_models(self): - """ Lazy-initialize and return the map '__models'. - Returns: - '__models' - """ - # Fast-path already loaded - if self.__models is not None: - return self.__models - # Initialize the dictionary - self.__models = dict() - # Populate this dictionary with TorchVision's models - for name in dir(torchvision.models): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members - continue - builder = getattr(torchvision.models, name) - if isinstance(builder, types.FunctionType): # Heuristic - self.__models[f"torchvision-{name.lower()}"] = builder - # Dynamically add the custom models from subdirectory 'models/' - def add_custom_models(name, module, _): - nonlocal self - # Check if has exports, fallback otherwise - exports = getattr(module, "__all__", None) - if exports is None: - tools.warning(f"Model module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery") - exports = (name for name in dir(module) if len(name) > 0 and name[0] != "_") - # Register the association 'name -> constructor' for all the models - exported = False - for model in exports: - # Check model name type - if not isinstance(model, str): - tools.warning(f"Model module {name!r} exports non-string name {model!r}; ignored") - continue - # Recover instance from name - constructor = getattr(module, model, None) - # Check instance is callable (it's only an heuristic...) - if not callable(constructor): - continue - # Register callable with composite name - exported = True - fullname = f"{name}-{model}" - if fullname in self.__models: - tools.warning(f"Unable to make available model {model!r} from module {name!r}, as the name {fullname!r} already exists") - continue - self.__models[fullname] = constructor - if not exported: - tools.warning(f"Model module {name!r} does not export any valid constructor name through '__all__'") - with tools.Context("models", None): - tools.import_directory(pathlib.Path(__file__).parent / "models", {"__package__": f"{__package__}.models"}, post=add_custom_models) - # Return the dictionary - return self.__models - - @classmethod - def _get_inits(self): - """ Lazy-initialize and return the map '__inits'. - Returns: - '__inits' - """ - # Fast-path already loaded - if self.__inits is not None: - return self.__inits - # Initialize the dictionary - self.__inits = dict() - # Populate this dictionary with PyTorch's initialization functions - for name in dir(torch.nn.init): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members - continue - if name[-1] != "_": # Ignore non-inplace members (heuristic) - continue - func = getattr(torch.nn.init, name) - if isinstance(func, types.FunctionType): # Heuristic - self.__inits[name[:-1]] = func - # Return the dictionary - return self.__inits - - def __init__(self, name_build, config=Configuration(), init_multi=None, init_multi_args=None, init_mono=None, init_mono_args=None, *args, **kwargs): - """ Model builder constructor. - Args: - name_build Model name or constructor function - config Configuration to use for the parameter tensors - init_multi Weight initialization algorithm name, or initialization function, for tensors of dimension >= 2 - init_multi_args Additional keyword-arguments for 'init', if 'init' specified as a name - init_mono Weight initialization algorithm name, or initialization function, for tensors of dimension == 1 - init_mono_args Additional keyword-arguments for 'init_mono', if 'init_mono' specified as a name - ... Additional (keyword-)arguments forwarded to the constructor - Notes: - If possible, data parallelism is enabled automatically - """ - def make_init(name, args): - inits = type(self)._get_inits() - func = inits.get(name, None) - if func is None: - raise tools.UnavailableException(inits, name, what="initializer name") - args = dict() if args is None else args - def init(params): - return func(params, **args) - return init - # Recover name/constructor - if callable(name_build): - name = tools.fullqual(name_build) - build = name_build - else: - models = type(self)._get_models() - name = str(name_build) - build = models.get(name, None) - if build is None: - raise tools.UnavailableException(models, name, what="model name") - # Recover initialization algorithms - if isinstance(init_multi, str): - init_multi = make_init(init_multi, init_multi_args) - if isinstance(init_mono, str): - init_mono = make_init(init_mono, init_mono_args) - # Build model - with torch.no_grad(): - model = build(*args, **kwargs) - if not isinstance(model, torch.nn.Module): - raise tools.UserException(f"Expected built model {name!r} to be an instance of 'torch.nn.Module', found {getattr(type(model), '__name__', '')!r} instead") - # Initialize parameters - for param in model.parameters(): - if len(param.shape) > 1: # Multi-dimensional - if init_multi is not None: - init_multi(param) - else: # Mono-dimensional - if init_mono is not None: - init_mono(param) - # Move parameters to target device - model = model.to(**config) - device = config["device"] - if device.type == "cuda" and device.index is None: # Model is on GPU and not explicitly restricted to one particular card => enable data parallelism - model = torch.nn.DataParallel(model) - params = tools.flatten(model.parameters()) # NOTE: Ordering across runs/nodes seems to be ensured (i.e. only dependent on the model constructor) - # Finalization - self._model = model - self._name = name - self._config = config - self._params = params - self._gradient = None - self._defaults = { - "trainset": None, - "testset": None, - "loss": None, - "criterion": None, - "optimizer": None } - - def __str__(self): - """ Compute the "informal", nicely printable string representation of this model. - Returns: - Nicely printable string - """ - return f"model {self._name}" - - @property - def config(self): - """ Getter for the immutable configuration. - Returns: - Immutable configuration - """ - return self._config - - def default(self, name, new=None, erase=False): - """ Get and/or set the named default. - Args: - name Name of the default - new Optional new instance, set only if not 'None' or erase is 'True' - erase Force the replacement by 'None' - Returns: - (Old) value of the default - """ - # Check existence - if name not in self._defaults: - raise tools.UnavailableException(self._defaults, name, what="model default") - # Get current - old = self._defaults[name] - # Set if needed - if erase or new is not None: - self._defaults[name] = new - # Return current/old - return old - - def _resolve_defaults(self, **kwargs): - """ Resolve the given keyword-arguments with the associated default value. - Args: - ... Keyword-arguments, each must have a default if set to None - Returns: - In-order given keyword-arguments, with 'None' values replaced with the corresponding default - """ - res = list() - for name, value in kwargs.items(): - if value is None: - value = self.default(name) - if value is None: - raise RuntimeError(f"Missing default {name}") - res.append(value) - return res - - def run(self, data, training=False): - """ Run the model at the current parameters for the given input tensor. - Args: - data Input tensor - training Use training mode instead of testing mode - Returns: - Output tensor - Notes: - Gradient computation is not enable nor disabled during the run. - """ - # Set mode - if training: - self._model.train() - else: - self._model.eval() - # Compute - return self._model(data) - - def __call__(self, *args, **kwargs): - """ Forward call to 'run'. - Args: - ... Forwarded (keyword-)arguments - Returns: - Forwarded return value - """ - return self.run(*args, **kwargs) - - def get(self): - """ Get a reference to the current parameters. - Returns: - Flat parameter vector (by reference: future calls to 'set' will modify it) - """ - return self._params - - def set(self, params, relink=None): - """ Overwrite the parameters with the given ones. - Args: - params Given flat parameter vector - relink Relink instead of copying (depending on the model, might be faster) - """ - # Fast path 'set(get())'-like - if params is self._params: - return - # Assignment - if (self._config.relink if relink is None else relink): - tools.relink(self._model.parameters(), params) - self._params = params - else: - self._params.copy_(params, non_blocking=self._config["non_blocking"]) - - def get_gradient(self): - """ Get (optionally make each parameter's gradient) a reference to the flat gradient. - Returns: - Flat gradient (by reference: future calls to 'set_gradient' will modify it) - """ - # Fast path - if self._gradient is not None: - return self._gradient - # Flatten (make if necessary) - gradient = tools.flatten(tools.grads_of(self._model.parameters())) - self._gradient = gradient - return gradient - - def set_gradient(self, gradient, relink=None): - """ Overwrite the gradient with the given one. - Args: - gradient Given flat gradient - relink Relink instead of copying (depending on the model, might be faster) - """ - # Fast path 'set(get())'-like - if gradient is self._gradient: - return - # Assignment - if (self._config.relink if relink is None else relink): - tools.relink(tools.grads_of(self._model.parameters()), gradient) - self._gradient = gradient - else: - self.get_gradient().copy_(gradient, non_blocking=self._config["non_blocking"]) - - def loss(self, dataset=None, loss=None, training=None): - """ Estimate loss at the current parameters, with a batch of the given dataset. - Args: - dataset Training dataset wrapper to use, use the default one if available - loss Loss wrapper to use, use the default one if available - training Whether this run is for training (instead of testing) purposes, None for guessing (based on whether gradients are computed) - Returns: - Loss value - """ - # Recover the defaults, if missing - dataset, loss = self._resolve_defaults(trainset=dataset, loss=loss) - # Sample the train batch - inputs, targets = dataset.sample(self._config) - # Guess whether computation is for training, if necessary - if training is None: - training = torch.is_grad_enabled() - # Forward pass - return loss(self.run(inputs), targets, self._params) - - @torch.enable_grad() - def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): - """ Estimate gradient at the current parameters, with a batch of the given dataset. - Args: - dataset Training dataset wrapper to use, use the default one if available - loss Loss wrapper to use, use the default one if available - outloss Output the loss value as well - ... Additional keyword-arguments forwarded to 'backprop' - Returns: - if 'outloss' is True: - Tuple of: - · Flat gradient (by reference: future calls to 'backprop' will modify it) - · Loss value - else: - Flat gradient (by reference: future calls to 'backprop' will modify it) - """ - # Detach and zero the gradient (must be done at each grad to discard computation graph) - for param in self._params.linked_tensors: - grad = param.grad - if grad is not None: - grad.detach_() - grad.zero_() - # Forward and backward passes - loss = self.loss(dataset=dataset, loss=loss) - loss.backward(**kwargs) - # Relink needed if graph of derivatives was created - # NOTE: It has been observed that each parameters' grad tensor is a new instance in this case; more investigation may be needed to check whether this relink is really necessary, for now this is a safe behavior - if "create_graph" in kwargs: - self._gradient = None - # Return the flat gradient (and the loss if requested) - if outloss: - return (self.get_gradient(), loss) - else: - return self.get_gradient() - - def update(self, gradient, optimizer=None, relink=None): - """ Update the parameters using the given gradient, and the given optimizer. - Args: - gradient Flat gradient to apply - optimizer Optimizer wrapper to use, use the default one if available - relink Relink instead of copying (depending on the model, might be faster) - """ - # Recover the defaults, if missing - optimizer = self._resolve_defaults(optimizer=optimizer)[0] - # Set the gradient - self.set_gradient(gradient, relink=(self._config.relink if relink is None else relink)) - # Perform the update step - optimizer.step() - - @torch.no_grad() - def eval(self, dataset=None, criterion=None): - """ Evaluate the model at the current parameters, with a batch of the given dataset. - Args: - dataset Testing dataset wrapper to use, use the default one if available - criterion Criterion wrapper to use, use the default one if available - Returns: - Arithmetic mean of the criterion over the next minibatch - """ - # Recover the defaults, if missing - dataset, criterion = self._resolve_defaults(testset=dataset, criterion=criterion) - # Sample the test batch - inputs, targets = dataset.sample(self._config) - # Compute and return the evaluation result - return criterion(self.run(inputs), targets) diff --git a/experiments/models/simples.py b/experiments/models/simples.py deleted file mode 100644 index a6aca49..0000000 --- a/experiments/models/simples.py +++ /dev/null @@ -1,176 +0,0 @@ -# coding: utf-8 -### - # @file simples.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Collection of simple models. -### - -__all__ = ["full", "conv", "logit", "linear"] - -import torch - -# ---------------------------------------------------------------------------- # -# Simple fully-connected model, for MNIST - -class _Full(torch.nn.Module): - """ Simple, small fully connected model. - """ - - def __init__(self): - """ Model parameter constructor. - """ - super().__init__() - # Build parameters - self._f1 = torch.nn.Linear(28 * 28, 100) - self._f2 = torch.nn.Linear(100, 10) - - def forward(self, x): - """ Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor - """ - # Forward pass - x = torch.nn.functional.relu(self._f1(x.view(-1, 28 * 28))) - x = torch.nn.functional.log_softmax(self._f2(x), dim=1) - return x - -def full(*args, **kwargs): - """ Build a new simple, fully connected model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Fully connected model - """ - global _Full - return _Full(*args, **kwargs) - -# ---------------------------------------------------------------------------- # -# Simple convolutional model, for MNIST - -class _Conv(torch.nn.Module): - """ Simple, small convolutional model. - """ - - def __init__(self): - """ Model parameter constructor. - """ - super().__init__() - # Build parameters - self._c1 = torch.nn.Conv2d(1, 20, 5, 1) - self._c2 = torch.nn.Conv2d(20, 50, 5, 1) - self._f1 = torch.nn.Linear(800, 500) - self._f2 = torch.nn.Linear(500, 10) - - def forward(self, x): - """ Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor - """ - # Forward pass - x = torch.nn.functional.relu(self._c1(x)) - x = torch.nn.functional.max_pool2d(x, 2, 2) - x = torch.nn.functional.relu(self._c2(x)) - x = torch.nn.functional.max_pool2d(x, 2, 2) - x = torch.nn.functional.relu(self._f1(x.view(-1, 800))) - x = torch.nn.functional.log_softmax(self._f2(x), dim=1) - return x - -def conv(*args, **kwargs): - """ Build a new simple, convolutional model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Convolutional model - """ - global _Conv - return _Conv(*args, **kwargs) - -# ---------------------------------------------------------------------------- # -# Simple(r) logistic regression model - -class _Logit(torch.nn.Module): - """ Simple logistic regression model. - """ - - def __init__(self, din, dout=1): - """ Model parameter constructor. - Args: - din Number of input dimensions - dout Number of output dimensions - """ - super().__init__() - # Store model parameters - self._din = din - self._dout = dout - # Build parameters - self._linear = torch.nn.Linear(din, dout) - - def forward(self, x): - """ Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor - """ - return torch.sigmoid(self._linear(x.view(-1, self._din))) - -def logit(*args, **kwargs): - """ Build a new simple, fully connected model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Fully connected model - """ - global _Logit - return _Logit(*args, **kwargs) - -# ---------------------------------------------------------------------------- # -# Simple(st) linear model - -class _Linear(torch.nn.Module): - """ Simple linear model. - """ - - def __init__(self, din, dout=1): - """ Model parameter constructor. - Args: - din Number of input dimensions - dout Number of output dimensions - """ - super().__init__() - # Store model parameters - self._din = din - self._dout = dout - # Build parameters - self._linear = torch.nn.Linear(din, dout) - - def forward(self, x): - """ Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor - """ - return self._linear(x.view(-1, self._din)) - -def linear(*args, **kwargs): - """ Build a new simple, fully connected model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Fully connected model - """ - global _Linear - return _Linear(*args, **kwargs) diff --git a/experiments/optimizer.py b/experiments/optimizer.py deleted file mode 100644 index 77c3ac7..0000000 --- a/experiments/optimizer.py +++ /dev/null @@ -1,103 +0,0 @@ -# coding: utf-8 -### - # @file optimizer.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Optimizer wrapper. -### - -__all__ = ["Optimizer"] - -import tools - -import torch - -# ---------------------------------------------------------------------------- # -# Optimizer wrapper class - -class Optimizer: - """ Optimizer wrapper class. - """ - - # Map 'lower-case names' -> 'optimizer constructor' available in PyTorch - __optimizers = None - - @classmethod - def _get_optimizers(self): - """ Lazy-initialize and return the map '__optimizers'. - Returns: - '__optimizers' - """ - # Fast-path already loaded - if self.__optimizers is not None: - return self.__optimizers - # Initialize the dictionary - self.__optimizers = dict() - # Simply populate this dictionary - for name in dir(torch.optim): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members - continue - builder = getattr(torch.optim, name) - if isinstance(builder, type) and builder is not torch.optim.Optimizer and issubclass(builder, torch.optim.Optimizer): - self.__optimizers[name.lower()] = builder - # Return the dictionary - return self.__optimizers - - def __init__(self, name_build, model, *args, **kwargs): - """ Optimizer constructor. - Args: - name_build Optimizer name or constructor function - model Model to optimize - ... Additional (keyword-)arguments forwarded to the constructor - """ - # Recover name/constructor - if callable(name_build): - name = tools.fullqual(name_build) - build = name_build - else: - optims = type(self)._get_optimizers() - name = str(name_build) - build = optims.get(name, None) - if build is None: - raise tools.UnavailableException(optims, name, what="optimizer name") - # Build optimizer - optim = build(model._model.parameters(), *args, **kwargs) - # Finalization - self._optim = optim - self._name = name - - def __getattr__(self, *args): - """ Get attribute on the optimizer instance. - Args: - name Name of the attribute to get - default Default value returned if the attribute does not exist - Returns: - Forwarded attribute - """ - if len(args) == 1: - return getattr(self._optim, args[0]) - if len(args) == 2: - return getattr(self._optim, args[0], args[1]) - raise RuntimeError("'Optimizer.__getattr__' called with the wrong number of parameters") - - def __str__(self): - """ Compute the "informal", nicely printable string representation of this optimizer. - Returns: - Nicely printable string - """ - return f"optimizer {self._name}" - - def set_lr(self, lr): - """ Set the learning rate of the optimizer - Args: - lr Learning rate to set (for each parameter group) - """ - for pg in self._optim.param_groups: - pg["lr"] = lr diff --git a/histogram.py b/histogram.py index e41436c..ece9af2 100644 --- a/histogram.py +++ b/histogram.py @@ -1,32 +1,26 @@ -# coding: utf-8 ### - # @file histogram.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2020-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Plot the histogram of per-worker norm/variance estimations across the steps. +# @file histogram.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2020-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Plot the histogram of per-worker norm/variance estimations across the steps. ### -import tools - -import aggregators -import experiments - import atexit import json -import math -import matplotlib -import matplotlib.pyplot as plt import pathlib -import pandas import threading -import torch + +import matplotlib.pyplot as plt +import pandas + +from krum import aggregators, tools # Change common font for the default LaTeX one plt.rcParams["font.family"] = "Latin Modern Roman" @@ -39,677 +33,711 @@ # Common GTK main loop try: - import gi - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk, Gdk, GLib - - gtk_lazy_lock = threading.Lock() - gtk_lazy_main = None - - def gtk_run(closure): - """ Run a closure in the GTK main loop, lazy start it. - Args: - closure Closure to run in the main loop - """ - global gtk_lazy_lock - global gtk_lazy_main - # GTK's main event loop - def gtk_main(): - # Main loop - atexit.register(Gtk.main_quit) - Gtk.main() - # Lazy-start the loop if necessary - with gtk_lazy_lock: - if gtk_lazy_main is None: - thread = threading.Thread(target=gtk_main, name="gtk_main", daemon=True) - thread.start() - gtk_lazy_main = thread - # Submit the job to the main loop - GLib.idle_add(closure) + import gi + + gi.require_version("Gtk", "3.0") + from gi.repository import GLib, Gtk + + gtk_lazy_lock = threading.Lock() + gtk_lazy_main = None + + def gtk_run(closure): + """Run a closure in the GTK main loop, lazy start it. + Args: + closure Closure to run in the main loop + """ + global gtk_lazy_lock + global gtk_lazy_main + + # GTK's main event loop + def gtk_main(): + # Main loop + atexit.register(Gtk.main_quit) + Gtk.main() + + # Lazy-start the loop if necessary + with gtk_lazy_lock: + if gtk_lazy_main is None: + thread = threading.Thread(target=gtk_main, name="gtk_main", daemon=True) + thread.start() + gtk_lazy_main = thread + # Submit the job to the main loop + GLib.idle_add(closure) except Exception as err: - def gtk_run(closure): - """ Sink in case GTK cannot be used. - Args: - closure Ignored parameter - """ - tools.warning("GTK 3.0 is unavailable: %s" % (err,)) + _gtk_err = err + + def gtk_run(closure): + """Sink in case GTK cannot be used. + Args: + closure Ignored parameter + """ + tools.warning(f"GTK 3.0 is unavailable: {_gtk_err}") # ---------------------------------------------------------------------------- # # Data frame columns selection helper + def select(data, *only_columns): - """ "Intelligently" select columns from a data frame. - Args: - data Session or DataFrame to select - ... Only columns to select, empty for all - Returns: - (Sub-)dataframe, by reference - """ - global Session - # Unwrap data frame from session - if isinstance(data, Session): - data = data.data - # Fast path - if len(only_columns) == 0: - return data - # Intelligent selection - columns = list() - for only_column in only_columns: - only_column = only_column.lower() - for column in data.columns: - if column not in columns and only_column in column.lower(): - columns.append(column) - return data[columns] + """ "Intelligently" select columns from a data frame. + Args: + data Session or DataFrame to select + ... Only columns to select, empty for all + Returns: + (Sub-)dataframe, by reference + """ + # Unwrap data frame from session + if isinstance(data, Session): + data = data.data + # Fast path + if len(only_columns) == 0: + return data + # Intelligent selection + columns = list() + for only_column in only_columns: + only_column = only_column.lower() + for column in data.columns: + if column not in columns and only_column in column.lower(): + columns.append(column) + return data[columns] + def discard(data, *only_columns): - """ "Intelligently" discard columns from a data frame. - Args: - ... Only columns to discard, empty for none - Returns: - (Sub-)dataframe, by reference - """ - # Fast path - if len(only_columns) == 0: + """ "Intelligently" discard columns from a data frame. + Args: + ... Only columns to discard, empty for none + Returns: + (Sub-)dataframe, by reference + """ + # Fast path + if len(only_columns) == 0: + return data + # Intelligent discarding + data = data[:] + for only_column in only_columns: + only_column = only_column.lower() + for column in data.columns: + if only_column in column.lower(): + del data[column] return data - # Intelligent discarding - data = data[:] - for only_column in only_columns: - only_column = only_column.lower() - for column in data.columns: - if only_column in column.lower(): - del data[column] - return data + # ---------------------------------------------------------------------------- # # DataFrame display (GTK-based) + class _DataFrameDisplayWindow(Gtk.Window): - """ Display the given data frame in a window. - """ + """Display the given data frame in a window.""" + + @staticmethod + def to_string(x): + """Convert data to string, special treatment for floats. + Args: + x Input data + Returns: + Converted data to string + """ + if type(x) is float: + return f"{x:e}" + return str(x).strip() + + def __init__(self, data, title="Display data"): + """Initialize the display window. + Args: + data Data to display + title Title to use + """ + super().__init__(title=title) + # Make and fill list store + store = Gtk.ListStore(*([str] * (len(data.columns) + 1))) + for row in data.itertuples(): + store.append(list(self.to_string(x) for x in row)) + # Make the associated tree view + view = Gtk.TreeView(store) + columns = list(data.columns) + columns.insert(0, data.index.name) + for i, cname in enumerate(columns): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(cname, renderer, text=i) + view.append_column(column) + # Make a scrolled window containing the tree view + scrolled = Gtk.ScrolledWindow() + scrolled.set_hexpand(True) + scrolled.set_vexpand(True) + scrolled.add(view) + self.add(scrolled) + # Finalize window + self.set_default_size(800, 600) - @staticmethod - def to_string(x): - """ Convert data to string, special treatment for floats. - Args: - x Input data - Returns: - Converted data to string - """ - if type(x) is float: - return "%e" % x - return str(x).strip() - def __init__(self, data, title="Display data"): - """ Initialize the display window. +def display(data, **kwargs): + """GTK-based display of a data frame. Args: - data Data to display - title Title to use + data Data frame to display + ... Forwarded keyword-arguments """ - super().__init__(title=title) - # Make and fill list store - store = Gtk.ListStore(*([str] * (len(data.columns) + 1))) - for row in data.itertuples(): - store.append(list(self.to_string(x) for x in row)) - # Make the associated tree view - view = Gtk.TreeView(store) - columns = list(data.columns) - columns.insert(0, data.index.name) - for i, cname in enumerate(columns): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(cname, renderer, text=i) - view.append_column(column) - # Make a scrolled window containing the tree view - scrolled = Gtk.ScrolledWindow() - scrolled.set_hexpand(True) - scrolled.set_vexpand(True) - scrolled.add(view) - self.add(scrolled) - # Finalize window - self.set_default_size(800, 600) + # Display given data + gtk_run(lambda: _DataFrameDisplayWindow(data, **kwargs).show_all()) -def display(data, **kwargs): - """ GTK-based display of a data frame. - Args: - data Data frame to display - ... Forwarded keyword-arguments - """ - # Display given data - gtk_run(lambda: _DataFrameDisplayWindow(data, **kwargs).show_all()) # ---------------------------------------------------------------------------- # # Training/evaluation data collection class + class Session: - """ Training/evaluation data collection class. - """ + """Training/evaluation data collection class.""" + + def __init__(self, path_results): + """Load the data from a training/evaluation result directory. + Args: + path_results Path-like to the result directory to load + """ + # Conversion to path + if not isinstance(path_results, pathlib.Path): + path_results = pathlib.Path(path_results) + # Ensure directory exist + if not path_results.exists(): + raise tools.UserException(f"Result directory {str(path_results)!r} cannot be accessed or does not exist") + # Load configuration string + path_config = path_results / "config" + try: + data_config = path_config.read_text().strip() + except Exception as err: + tools.warning(f"Result directory {str(path_results)!r}: unable to read configuration ({err})") + data_config = None + # Load configuration json + path_json = path_results / "config.json" + try: + with path_json.open("r") as fd: + data_json = json.load(fd) + except Exception as err: + tools.warning(f"Result directory {str(path_results)!r}: unable to read JSON configuration ({err})") + data_json = None + # Load training data + path_study = path_results / "study" + try: + data_study = pandas.read_csv(path_study, sep="\t", index_col=0, na_values=" nan") + data_study.index.name = "Step number" + except Exception as err: + tools.warning(f"Result directory {str(path_results)!r}: unable to read training data ({err})") + data_study = None + # Load evaluation data + path_eval = path_results / "eval" + try: + data_eval = pandas.read_csv(path_eval, sep="\t", index_col=0) + data_eval.index.name = "Step number" + except Exception as err: + tools.warning(f"Result directory {str(path_results)!r}: unable to read evaluation data ({err})") + data_eval = None + # Merge data frames + data = None + for df in (data_study, data_eval): + if df is None: + continue + if data is None: + data = df + else: + data = data.join(df, how="outer") + # Finalization + self.name = path_results.name + self.path = path_results + self.config = data_config + self.json = data_json + self.data = data + + def get(self, *only_columns): + """Get (some of) the data. + Args: + name Name of the data frame to consider + ... Only columns to select, empty for all + Returns: + Selected data, by reference + """ + global select + return select(self.data, *only_columns) + + def display(self, *only_columns, name=None): + """Just display (some of) the data. + Args: + name Name of the data frame to consider + ... Only columns to select, empty for all + Returns: + self + """ + global display + # Display the (selected sub)set + display( + self.get(*only_columns), + title=("Session data{} for {!r}".format(" (subset)" if len(only_columns) > 0 else "", self.name)), + ) + # Return self to enable chaining + return self + + def has_known_ratio(self): + """Check whether the session's GAR has a known ratio. + Returns: + Whether the session's GAR has a known ratio + """ + if self.json is None or "gar" not in self.json: + tools.warning("No valid JSON-formatted configuration, cannot tell whether the associated GAR has a ratio") + return False + g = self.json["gar"] + rule = aggregators.gars.get(g, None) + return rule is not None and rule.upper_bound is not None + + def compute_all(self): + """Carries all the automated computations. + Returns: + self + """ + # Carries all the computations + for name, func in type(self).__dict__.items(): + if name == "compute_all": + continue + if name[: len("compute_")] == "compute_" and callable(func): + func(self) + # Return self to enable chaining + return self + + def compute_epoch(self): + """Compute and append the epoch number, if not already done. + Returns: + self + """ + column_name = "Epoch number" + # Check if already there + if column_name in self.data.columns: + return self + # Compute epoch number + if self.json is None or "dataset" not in self.json: + tools.warning("No valid JSON-formatted configuration, cannot compute the epoch number") + return self + dataset_name = self.json["dataset"] + training_size = {"mnist": 60000, "fashionmnist": 60000, "cifar10": 50000, "cifar100": 50000}.get( + dataset_name, None + ) + if training_size is None: + tools.warning(f"Unknown dataset {dataset_name!r}, cannot compute the epoch number") + return self + self.data[column_name] = self.data["Training point count"] / training_size + # Return self to enable chaining + return self + + def compute_lr(self): + """Compute and append the learning rate, if not already done. + Returns: + self + """ + column_name = "Learning rate" + # Check if already there + if column_name in self.data.columns: + return self + # Compute epoch number + if self.json is None or "learning_rate" not in self.json: + tools.warning("No valid JSON-formatted configuration, cannot compute the learning rate") + return self + lr = self.json["learning_rate"] + lr_decay = self.json.get("learning_rate_decay", 0) + lr_delta = self.json.get("learning_rate_decay_delta", 1) + if lr_decay > 0: + self.data[column_name] = lr / ((self.data.index // lr_delta * lr_delta) / lr_decay + 1) + else: + self.data[column_name] = lr + # Return self to enable chaining + return self + + # TODO: More automated computations of interest - def __init__(self, path_results): - """ Load the data from a training/evaluation result directory. - Args: - path_results Path-like to the result directory to load - """ - # Conversion to path - if not isinstance(path_results, pathlib.Path): - path_results = pathlib.Path(path_results) - # Ensure directory exist - if not path_results.exists(): - raise tools.UserException("Result directory %r cannot be accessed or does not exist" % str(path_results)) - # Load configuration string - path_config = path_results / "config" - try: - data_config = path_config.read_text().strip() - except Exception as err: - tools.warning("Result directory %r: unable to read configuration (%s)" % (str(path_results), err)) - data_config = None - # Load configuration json - path_json = path_results / "config.json" - try: - with path_json.open("r") as fd: - data_json = json.load(fd) - except Exception as err: - tools.warning("Result directory %r: unable to read JSON configuration (%s)" % (str(path_results), err)) - data_json = None - # Load training data - path_study = path_results / "study" - try: - data_study = pandas.read_csv(path_study, sep="\t", index_col=0, na_values=" nan") - data_study.index.name = "Step number" - except Exception as err: - tools.warning("Result directory %r: unable to read training data (%s)" % (str(path_results), err)) - data_study = None - # Load evaluation data - path_eval = path_results / "eval" - try: - data_eval = pandas.read_csv(path_eval, sep="\t", index_col=0) - data_eval.index.name = "Step number" - except Exception as err: - tools.warning("Result directory %r: unable to read evaluation data (%s)" % (str(path_results), err)) - data_eval = None - # Merge data frames - data = None - for df in (data_study, data_eval): - if df is None: - continue - if data is None: - data = df - else: - data = data.join(df, how="outer") - # Finalization - self.name = path_results.name - self.path = path_results - self.config = data_config - self.json = data_json - self.data = data - - def get(self, *only_columns): - """ Get (some of) the data. - Args: - name Name of the data frame to consider - ... Only columns to select, empty for all - Returns: - Selected data, by reference - """ - global select - return select(self.data, *only_columns) - - def display(self, *only_columns, name=None): - """ Just display (some of) the data. - Args: - name Name of the data frame to consider - ... Only columns to select, empty for all - Returns: - self - """ - global display - # Display the (selected sub)set - display(self.get(*only_columns), title=("Session data%s for %r" % (" (subset)" if len(only_columns) > 0 else "", self.name))) - # Return self to enable chaining - return self - - def has_known_ratio(self): - """ Check whether the session's GAR has a known ratio. - Returns: - Whether the session's GAR has a known ratio - """ - if self.json is None or "gar" not in self.json: - tools.warning("No valid JSON-formatted configuration, cannot tell whether the associated GAR has a ratio") - return False - g = self.json["gar"] - rule = aggregators.gars.get(g, None) - return rule is not None and rule.upper_bound is not None - - def compute_all(self): - """ Carries all the automated computations. - Returns: - self - """ - # Carries all the computations - for name, func in type(self).__dict__.items(): - if name == "compute_all": - continue - if name[:len("compute_")] == "compute_" and callable(func): - func(self) - # Return self to enable chaining - return self - - def compute_epoch(self): - """ Compute and append the epoch number, if not already done. - Returns: - self - """ - column_name = "Epoch number" - # Check if already there - if column_name in self.data.columns: - return self - # Compute epoch number - if self.json is None or "dataset" not in self.json: - tools.warning("No valid JSON-formatted configuration, cannot compute the epoch number") - return self - dataset_name = self.json["dataset"] - training_size = {"mnist": 60000, "fashionmnist": 60000, "cifar10": 50000, "cifar100": 50000}.get(dataset_name, None) - if training_size is None: - tools.warning("Unknown dataset %r, cannot compute the epoch number" % dataset_name) - return self - self.data[column_name] = self.data["Training point count"] / training_size - # Return self to enable chaining - return self - - def compute_lr(self): - """ Compute and append the learning rate, if not already done. - Returns: - self - """ - column_name = "Learning rate" - # Check if already there - if column_name in self.data.columns: - return self - # Compute epoch number - if self.json is None or "learning_rate" not in self.json: - tools.warning("No valid JSON-formatted configuration, cannot compute the learning rate") - return self - lr = self.json["learning_rate"] - lr_decay = self.json.get("learning_rate_decay", 0) - lr_delta = self.json.get("learning_rate_decay_delta", 1) - if lr_decay > 0: - self.data[column_name] = lr / ((self.data.index // lr_delta * lr_delta) / lr_decay + 1) - else: - self.data[column_name] = lr - # Return self to enable chaining - return self - - # TODO: More automated computations of interest # ---------------------------------------------------------------------------- # # Plot management class -class LinePlot: - """ Line plot management class. - """ - - # Known line styles - linestyles = ("-", "--", ":", "-.") - - @classmethod - def _get_line_style(self, ln): - """ Get the line style and color for the given line number. - Args: - ln A non-negative integer representing the line number - Returns: - Associated line style, line color - """ - return self.linestyles[ln % len(self.linestyles)], "C%d" % ln - - def __init__(self, index=None): - """ Title constructor. - Args: - index Column name to use as the index instead of the default - """ - # Make the subplots - fig, ax = plt.subplots() - # Store the non-finalized state - self._fin = False # Not yet finalized - self._fig = fig # Figure instance - self._ax = ax # Original axis instance - self._tax = None # Twin axis instance - self._axs = {} # Map column names to axis (up to two) - self._idx = index # Column name to use as index by default, None to use dataframe's index - self._cnt = 0 # Plot counter (to pick line style and color) - - def __del__(self): - """ Close the figure on finalization. - """ - self.close() - def _get_ax(self, name): - """ Get the axis associated with the column selector, make it if possible. - Args: - name Column selector - Returns: - Associated axis - """ - # Return existing axis - ax = self._axs.get(name, None) - if ax is not None: - return ax - # Assert can make one more axis - if len(self._axs) >= 2: - raise RuntimeError("Line plot cannot have a 3rd y-axis") - # Make one more axis - if len(self._axs) == 0: - ax = self._ax - else: - ax = self._ax.twinx() - self._tax = ax - self._axs[name] = ax - # Return the axis - return ax - - def include(self, data, *cols, errs=None, lalp=1., ccnt=None): - """ Add the columns of the given data frame, can only be done before finalization. - Args: - data Session or dataframe holding the column(s) to add - cols Column name(s) to include, mix selected columns together (same y-axis) - errs Error suffix: for every selected column's real label, if a columns with 'real_label + errs' exists, it is used to display error bars - lalp Line alpha level - ccnt Color and linestyle number to use - Returns: - self - """ - # Assert not already finalized - if self._fin: - raise RuntimeError("Plot is already finalized and cannot include another line") - # Recover the dataframe if a session was given - if isinstance(data, Session): - data = data.data - elif not isinstance(data, pandas.DataFrame): - raise RuntimeError("Expected a Session or DataFrame for 'data', got a %r" % tools.fullqual(type(data))) - # Get the x-axis values - if self._idx is None: - x = data.index.to_numpy() - else: - if self._idx not in data: - raise RuntimeError("No column named %r to use as index in the given session/dataframe" % (self._idx,)) - x = data[self._idx].to_numpy() - # Select semantic: empty list = select all - if len(cols) == 0: - cols = data.columns.to_list() - # For every selection - axis = None - for col in cols: - # Get associated data - subd = select(data, col) - # For every selected column - for scol in subd: - # Ignore index column - if self._idx is not None and scol == self._idx: - continue - # Ignore error column - if errs is not None and scol[:-len(errs)] in subd: - continue - # Get associated axis (if not done yet) - if axis is None: - axis = self._get_ax(col) +class LinePlot: + """Line plot management class.""" + + # Known line styles + linestyles = ("-", "--", ":", "-.") + + @classmethod + def _get_line_style(cls, ln): + """Get the line style and color for the given line number. + Args: + ln A non-negative integer representing the line number + Returns: + Associated line style, line color + """ + return cls.linestyles[ln % len(cls.linestyles)], f"C{ln}" + + def __init__(self, index=None): + """Title constructor. + Args: + index Column name to use as the index instead of the default + """ + # Make the subplots + fig, ax = plt.subplots() + # Store the non-finalized state + self._fin = False # Not yet finalized + self._fig = fig # Figure instance + self._ax = ax # Original axis instance + self._tax = None # Twin axis instance + self._axs = {} # Map column names to axis (up to two) + self._idx = index # Column name to use as index by default, None to use dataframe's index + self._cnt = 0 # Plot counter (to pick line style and color) + + def __del__(self): + """Close the figure on finalization.""" + self.close() + + def _get_ax(self, name): + """Get the axis associated with the column selector, make it if possible. + Args: + name Column selector + Returns: + Associated axis + """ + # Return existing axis + ax = self._axs.get(name, None) + if ax is not None: + return ax + # Assert can make one more axis + if len(self._axs) >= 2: + raise RuntimeError("Line plot cannot have a 3rd y-axis") + # Make one more axis + if len(self._axs) == 0: + ax = self._ax + else: + ax = self._ax.twinx() + self._tax = ax + self._axs[name] = ax + # Return the axis + return ax + + def include(self, data, *cols, errs=None, lalp=1.0, ccnt=None): + """Add the columns of the given data frame, can only be done before finalization. + Args: + data Session or dataframe holding the column(s) to add + cols Column name(s) to include, mix selected columns together (same y-axis) + errs Error suffix: for every selected column's real label, if a columns with 'real_label + errs' exists, it is used to display error bars + lalp Line alpha level + ccnt Color and linestyle number to use + Returns: + self + """ + # Assert not already finalized + if self._fin: + raise RuntimeError("Plot is already finalized and cannot include another line") + # Recover the dataframe if a session was given + if isinstance(data, Session): + data = data.data + elif not isinstance(data, pandas.DataFrame): + raise RuntimeError(f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}") + # Get the x-axis values + if self._idx is None: + x = data.index.to_numpy() + else: + if self._idx not in data: + raise RuntimeError(f"No column named {self._idx!r} to use as index in the given session/dataframe") + x = data[self._idx].to_numpy() + # Select semantic: empty list = select all + if len(cols) == 0: + cols = data.columns.to_list() + # For every selection + axis = None + for col in cols: + # Get associated data + subd = select(data, col) + # For every selected column + for scol in subd: + # Ignore index column + if self._idx is not None and scol == self._idx: + continue + # Ignore error column + if errs is not None and scol[: -len(errs)] in subd: + continue + # Get associated axis (if not done yet) + if axis is None: + axis = self._get_ax(col) + # Pick a new line style and color + linestyle, color = self._get_line_style(self._cnt if ccnt is None else ccnt) + # Plot the data (line or error line) + davg = subd[scol].to_numpy() + errn = None if errs is None else (scol + errs) + if errn is not None and errn in data: + derr = data[errn].to_numpy() + axis.fill_between(x, davg - derr, davg + derr, facecolor=color, alpha=0.2) + axis.plot(x, davg, label=scol, linestyle=linestyle, color=color, alpha=lalp) + # Increase the counter only on success + self._cnt += 1 + # Reset axis for next iteration + axis = None + # Return self for chaining + return self + + def include_single(self, data, key, col, err=None, lalp=1.0, ccnt=None): + """Add one line with column of the given data frame, can only be done before finalization. + Args: + data Session or dataframe holding the column(s) to add + key Displayed name (in the key) + col Single column name to include + err Optional associated error column name + lalp Line alpha level + ccnt Color and linestyle number to use + Returns: + self + """ + # Assert not already finalized + if self._fin: + raise RuntimeError("Plot is already finalized and cannot include another line") + # Recover the dataframe if a session was given + if isinstance(data, Session): + data = data.data + elif not isinstance(data, pandas.DataFrame): + raise RuntimeError(f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}") + # Get the x-axis values + if self._idx is None: + x = data.index.to_numpy() + else: + if self._idx not in data: + raise RuntimeError(f"No column named {self._idx!r} to use as index in the given session/dataframe") + x = data[self._idx].to_numpy() # Pick a new line style and color linestyle, color = self._get_line_style(self._cnt if ccnt is None else ccnt) - # Plot the data (line or error line) - davg = subd[scol].to_numpy() - errn = None if errs is None else (scol + errs) - if errn is not None and errn in data: - derr = data[errn].to_numpy() - axis.fill_between(x, davg - derr, davg + derr, facecolor=color, alpha=0.2) - axis.plot(x, davg, label=scol, linestyle=linestyle, color=color, alpha=lalp) + # Plot the data (line and error line) + davg = data[col].to_numpy() + derr = None if err is None else data[err].to_numpy() + axis = self._get_ax(col) + if derr is not None: + axis.fill_between(x, davg - derr, davg + derr, facecolor=color, alpha=0.2) + axis.plot(x, davg, label=key, linestyle=linestyle, color=color, alpha=lalp) # Increase the counter only on success self._cnt += 1 - # Reset axis for next iteration - axis = None - # Return self for chaining - return self - - def include_single(self, data, key, col, err=None, lalp=1., ccnt=None): - """ Add one line with column of the given data frame, can only be done before finalization. - Args: - data Session or dataframe holding the column(s) to add - key Displayed name (in the key) - col Single column name to include - err Optional associated error column name - lalp Line alpha level - ccnt Color and linestyle number to use - Returns: - self - """ - # Assert not already finalized - if self._fin: - raise RuntimeError("Plot is already finalized and cannot include another line") - # Recover the dataframe if a session was given - if isinstance(data, Session): - data = data.data - elif not isinstance(data, pandas.DataFrame): - raise RuntimeError("Expected a Session or DataFrame for 'data', got a %r" % tools.fullqual(type(data))) - # Get the x-axis values - if self._idx is None: - x = data.index.to_numpy() - else: - if self._idx not in data: - raise RuntimeError("No column named %r to use as index in the given session/dataframe" % (self._idx,)) - x = data[self._idx].to_numpy() - # Pick a new line style and color - linestyle, color = self._get_line_style(self._cnt if ccnt is None else ccnt) - # Plot the data (line and error line) - davg = data[col].to_numpy() - derr = None if err is None else data[err].to_numpy() - axis = self._get_ax(col) - if derr is not None: - axis.fill_between(x, davg - derr, davg + derr, facecolor=color, alpha=0.2) - axis.plot(x, davg, label=key, linestyle=linestyle, color=color, alpha=lalp) - # Increase the counter only on success - self._cnt += 1 - # Return self for chaining - return self - - def include_vline(self, x, color="black", label=None, ls=None, lw=2): - """ Draw a vertical line at the given abscissa. - Args: - x Abscissa at which to draw the vertical line - """ - self._ax.axvline(x=x, ls=ls, lw=lw, color=color, label=label) + # Return self for chaining + return self + + def include_vline(self, x, color="black", label=None, ls=None, lw=2): + """Draw a vertical line at the given abscissa. + Args: + x Abscissa at which to draw the vertical line + """ + self._ax.axvline(x=x, ls=ls, lw=lw, color=color, label=label) + + def finalize( + self, + title, + xlabel, + ylabel, + zlabel=None, + xmin=None, + xmax=None, + ymin=None, + ymax=None, + zmin=None, + zmax=None, + legend=None, + ): + """Finalize the plot, can be done only once and would prevent further inclusion. + Args: + title Plot title + xlabel Label for the x-axis + ylabel Label for the y-axis + zlabel Label for the twin y-axis, if any + xmin Minimum for abscissa, if any + xmax Maximum for abscissa, if any + ymin Minimum for ordinate, if any + ymax Maximum for ordinate, if any + zmin Minimum for second ordinate, if any + zmax Maximum for second ordinate, if any + legend List of strings (one per 'include', in call order) to use as legend + Returns: + self + """ + # Fast path + if self._fin: + return self + + # Plot the legend + def generator_sum(gen): + res = None + while True: + try: + val = next(gen) + if res is None: + res = val + else: + res += val + except StopIteration: + return res + + (self._ax if self._tax is None else self._tax).legend( + generator_sum(ax.get_legend_handles_labels()[0] for ax in self._axs.values()), + generator_sum(ax.get_legend_handles_labels()[1] for ax in self._axs.values()) if legend is None else legend, + loc="best", + ) + # Plot the grid and labels + self._ax.grid() + self._ax.set_xlabel(xlabel) + self._ax.set_ylabel(ylabel) + self._ax.set_title(title) + if zlabel is not None: + if self._tax is None: + tools.warning(f"No secondary y-axis found, but its label {zlabel!r} was provided") + else: + self._tax.set_ylabel(zlabel) + elif self._tax is not None: + tools.warning(f"No label provided for the secondary y-axis; using label {ylabel!r} from the primary") + self._tax.set_ylabel(ylabel) + self._ax.set_xlim(left=xmin, right=xmax) + self._ax.set_ylim(bottom=ymin, top=ymax) + if self._tax is not None: + self._tax.set_ylim(bottom=zmin, top=zmax) + # Mark finalized + self._fin = True + # Return self for chaining + return self + + def display(self): + """Display the figure, which must have been finalized. + Returns: + self + """ + # Assert already finalized + if not self._fin: + raise RuntimeError("Cannot display a plot that has not been finalized yet") + # Show the plot + self._fig.show() + # Return self for chaining + return self + + def save(self, path, dpi=200, xsize=3, ysize=2): + """Save the figure, which must have been finalized. + Args: + path Path of the file to write + dpi Output image DPI (very good quality printing is usually 300 DPI) + xsize Output image x-size (in cm) + ysize Output image y-size (in cm) + Returns: + self + """ + # Assert already finalized + if not self._fin: + raise RuntimeError("Cannot display a plot that has not been finalized yet") + # Save the figure + self._fig.set_size_inches(xsize * 2.54, ysize * 2.54) + self._fig.set_dpi(dpi) + self._fig.savefig(path) + # Return self for chaining + return self + + def close(self): + """Explicitly "close" the associated figure (needed by pyplot), the instance cannot be used anymore after the call.""" + if ( + self._fig is not None + ): # The documentation of 'plt.close' does not explicitly specify that multiple calls are allowed on the same 'Figure' + plt.close(self._fig) + self._fig = None - def finalize(self, title, xlabel, ylabel, zlabel=None, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None, zmax=None, legend=None): - """ Finalize the plot, can be done only once and would prevent further inclusion. - Args: - title Plot title - xlabel Label for the x-axis - ylabel Label for the y-axis - zlabel Label for the twin y-axis, if any - xmin Minimum for abscissa, if any - xmax Maximum for abscissa, if any - ymin Minimum for ordinate, if any - ymax Maximum for ordinate, if any - zmin Minimum for second ordinate, if any - zmax Maximum for second ordinate, if any - legend List of strings (one per 'include', in call order) to use as legend - Returns: - self - """ - # Fast path - if self._fin: - return self - # Plot the legend - def generator_sum(gen): - res = None - while True: - try: - val = next(gen) - if res is None: - res = val - else: - res += val - except StopIteration: - return res - (self._ax if self._tax is None else self._tax).legend(generator_sum(ax.get_legend_handles_labels()[0] for ax in self._axs.values()), generator_sum(ax.get_legend_handles_labels()[1] for ax in self._axs.values()) if legend is None else legend, loc="best") - # Plot the grid and labels - self._ax.grid() - self._ax.set_xlabel(xlabel) - self._ax.set_ylabel(ylabel) - self._ax.set_title(title) - if zlabel is not None: - if self._tax is None: - tools.warning("No secondary y-axis found, but its label %r was provided" % (zlabel,)) - else: - self._tax.set_ylabel(zlabel) - elif self._tax is not None: - tools.warning("No label provided for the secondary y-axis; using label %r from the primary" % (ylabel,)) - self._tax.set_ylabel(ylabel) - self._ax.set_xlim(left=xmin, right=xmax) - self._ax.set_ylim(bottom=ymin, top=ymax) - if self._tax is not None: - self._tax.set_ylim(bottom=zmin, top=zmax) - # Mark finalized - self._fin = True - # Return self for chaining - return self - - def display(self): - """ Display the figure, which must have been finalized. - Returns: - self - """ - # Assert already finalized - if not self._fin: - raise RuntimeError("Cannot display a plot that has not been finalized yet") - # Show the plot - self._fig.show() - # Return self for chaining - return self - - def save(self, path, dpi=200, xsize=3, ysize=2): - """ Save the figure, which must have been finalized. - Args: - path Path of the file to write - dpi Output image DPI (very good quality printing is usually 300 DPI) - xsize Output image x-size (in cm) - ysize Output image y-size (in cm) - Returns: - self - """ - # Assert already finalized - if not self._fin: - raise RuntimeError("Cannot display a plot that has not been finalized yet") - # Save the figure - self._fig.set_size_inches(xsize * 2.54, ysize * 2.54) - self._fig.set_dpi(dpi) - self._fig.savefig(path) - # Return self for chaining - return self - - def close(self): - """ Explicitly "close" the associated figure (needed by pyplot), the instance cannot be used anymore after the call. - """ - if self._fig is not None: # The documentation of 'plt.close' does not explicitly specify that multiple calls are allowed on the same 'Figure' - plt.close(self._fig) - self._fig = None class HistPlot: - """ Histogram plot management class. - """ - - def __init__(self, bins=25): - """ Number of bins histogram constructor. - Args: - bins Number of bins to use - """ - # Make the subplots - fig, axs = plt.subplots() - # Finalize - self._bins = bins - self._fin = False - self._fig = fig - self._ax = axs - - def __del__(self): - """ Close the figure on finalization. - """ - self.close() - - def include(self, data): - """ Make the histogram from the raw data. - Args: - data Given Series or numpy data array - Returns: - self - """ - # Convert 'pandas.Series' to numpy - if isinstance(data, pandas.Series): - data = data.to_numpy() - # Make the histogram - self._ax.hist(data, bins=self._bins) - # Return self for chaining - return self - - def finalize(self, title, xlabel, ylabel, xmin=None, xmax=None, ymin=None, ymax=None): - """ Finalize the plot, can be done only once and would prevent further inclusion. - Args: - title Plot title - xlabel Label for the x-axis - ylabel Label for the y-axis - xmin Minimum for abscissa, if any - xmax Maximum for abscissa, if any - ymin Minimum for ordinate, if any - ymax Maximum for ordinate, if any - legend List of strings (one per 'include', in call order) to use as legend - Returns: - self - """ - # Fast path - if self._fin: - return self - # Plot the grid and labels - self._ax.grid() - self._ax.set_xlabel(xlabel) - self._ax.set_ylabel(ylabel) - self._ax.set_title(title) - self._ax.set_xlim(left=xmin, right=xmax) - self._ax.set_ylim(bottom=ymin, top=ymax) - # Mark finalized - self._fin = True - # Return self for chaining - return self - - def display(self): - """ Display the figure, which must have been finalized. - Returns: - self - """ - # Assert already finalized - if not self._fin: - raise RuntimeError("Cannot display a plot that has not been finalized yet") - # Show the plot - self._fig.show() - # Return self for chaining - return self - - def save(self, path, dpi=200, xsize=3, ysize=2): - """ Save the figure, which must have been finalized. - Args: - path Path of the file to write - dpi Output image DPI (very good quality printing is usually 300 DPI) - xsize Output image x-size (in cm) - ysize Output image y-size (in cm) - Returns: - self - """ - # Assert already finalized - if not self._fin: - raise RuntimeError("Cannot display a plot that has not been finalized yet") - # Save the figure - self._fig.set_size_inches(xsize * 2.54, ysize * 2.54) - self._fig.set_dpi(dpi) - self._fig.savefig(path) - # Return self for chaining - return self - - def close(self): - """ Explicitly "close" the associated figure (needed by pyplot), the instance cannot be used anymore after the call. - """ - if self._fig is not None: # The documentation of 'plt.close' does not explicitly specify that multiple calls are allowed on the same 'Figure' - plt.close(self._fig) - self._fig = None + """Histogram plot management class.""" + + def __init__(self, bins=25): + """Number of bins histogram constructor. + Args: + bins Number of bins to use + """ + # Make the subplots + fig, axs = plt.subplots() + # Finalize + self._bins = bins + self._fin = False + self._fig = fig + self._ax = axs + + def __del__(self): + """Close the figure on finalization.""" + self.close() + + def include(self, data): + """Make the histogram from the raw data. + Args: + data Given Series or numpy data array + Returns: + self + """ + # Convert 'pandas.Series' to numpy + if isinstance(data, pandas.Series): + data = data.to_numpy() + # Make the histogram + self._ax.hist(data, bins=self._bins) + # Return self for chaining + return self + + def finalize(self, title, xlabel, ylabel, xmin=None, xmax=None, ymin=None, ymax=None): + """Finalize the plot, can be done only once and would prevent further inclusion. + Args: + title Plot title + xlabel Label for the x-axis + ylabel Label for the y-axis + xmin Minimum for abscissa, if any + xmax Maximum for abscissa, if any + ymin Minimum for ordinate, if any + ymax Maximum for ordinate, if any + legend List of strings (one per 'include', in call order) to use as legend + Returns: + self + """ + # Fast path + if self._fin: + return self + # Plot the grid and labels + self._ax.grid() + self._ax.set_xlabel(xlabel) + self._ax.set_ylabel(ylabel) + self._ax.set_title(title) + self._ax.set_xlim(left=xmin, right=xmax) + self._ax.set_ylim(bottom=ymin, top=ymax) + # Mark finalized + self._fin = True + # Return self for chaining + return self + + def display(self): + """Display the figure, which must have been finalized. + Returns: + self + """ + # Assert already finalized + if not self._fin: + raise RuntimeError("Cannot display a plot that has not been finalized yet") + # Show the plot + self._fig.show() + # Return self for chaining + return self + + def save(self, path, dpi=200, xsize=3, ysize=2): + """Save the figure, which must have been finalized. + Args: + path Path of the file to write + dpi Output image DPI (very good quality printing is usually 300 DPI) + xsize Output image x-size (in cm) + ysize Output image y-size (in cm) + Returns: + self + """ + # Assert already finalized + if not self._fin: + raise RuntimeError("Cannot display a plot that has not been finalized yet") + # Save the figure + self._fig.set_size_inches(xsize * 2.54, ysize * 2.54) + self._fig.set_dpi(dpi) + self._fig.savefig(path) + # Return self for chaining + return self + + def close(self): + """Explicitly "close" the associated figure (needed by pyplot), the instance cannot be used anymore after the call.""" + if ( + self._fig is not None + ): # The documentation of 'plt.close' does not explicitly specify that multiple calls are allowed on the same 'Figure' + plt.close(self._fig) + self._fig = None diff --git a/krum/__init__.py b/krum/__init__.py new file mode 100644 index 0000000..0b1e238 --- /dev/null +++ b/krum/__init__.py @@ -0,0 +1,3 @@ +"""Krum: Byzantine-resilient aggregation rules for distributed machine learning.""" + +__version__ = "0.1.0" diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py new file mode 100644 index 0000000..42d792a --- /dev/null +++ b/krum/aggregators/__init__.py @@ -0,0 +1,100 @@ +### +# @file __init__.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Loading of the local modules. +# +# Each rule MUST support taking any named arguments, possibly ignoring them. +# The parameters MUST all be passed as their keyword arguments. +# The reserved argument names, and their interface, are the following: +# · gradients: Non-empty list of gradients to aggregate +# · f : Number of Byzantine gradients to support +# · model : Model (duck-typing 'experiments.Model') with valid default dataset and loss set +# The rule, given "valid" parameter(s), MUST NOT return a tensor that is a reference to any tensor given as parameter. +# +# Each rule MUST provide a "check" member function, taking the same arguments as the rule itself. +# The "check" member function returns 'None' when the parameters are valid, +# or an explanatory string when the parameters are not valid. +# The check member function MUST NOT modify the given parameters. +# +# Once registered, the check member function will be available as member "check". +# The raw function and a wrapped checking the input/output of the raw function +# will respectively be available as members "unchecked" and "checked". +# Which of these two functions is called by default depends whether debug mode is enabled. +### + +import pathlib + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Automated GAR loader + + +def make_gar(unchecked, check, upper_bound=None, influence=None): + """GAR wrapper helper. + Args: + unchecked Associated function (see module description) + check Parameter validity check function + upper_bound Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this aggregation rule: (n, f, d) -> float + influence Attack acceptation ratio function + Returns: + Wrapped GAR + """ + + # Closure wrapping the call with checks + def checked(**kwargs): + # Check parameter validity + message = check(**kwargs) + if message is not None: + raise tools.UserException(f"Aggregation rule {name!r} cannot be used with the given parameters: {message}") + # Aggregation (hard to assert return value, duck-typing is allowed...) + return unchecked(**kwargs) + + # Select which function to call by default + func = checked if __debug__ else unchecked + # Bind all the (sub) functions to the selected function + setattr(func, "check", check) + setattr(func, "checked", checked) + setattr(func, "unchecked", unchecked) + setattr(func, "upper_bound", upper_bound) + setattr(func, "influence", influence) + # Return the selected function with the associated name + return func + + +def register(name, unchecked, check, upper_bound=None, influence=None): + """Simple registration-wrapper helper. + Args: + name GAR name + unchecked Associated function (see module description) + check Parameter validity check function + upper_bound Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this aggregation rule: (n, f, d) -> float + influence Attack acceptation ratio function + """ + global gars + # Check if name already in use + if name in gars: + tools.warning(f"Unable to register {name!r} GAR: name already in use") + return + # Export the selected function with the associated name + gars[name] = make_gar(unchecked, check, upper_bound=upper_bound, influence=influence) + + +# Registered rules (mapping name -> aggregation rule) +gars = dict() + +# Load all local modules +with tools.Context("aggregators", None): + tools.import_directory(pathlib.Path(__file__).parent, globals()) + +# Bind/overwrite the GAR name with the associated rules in globals() +for name, rule in gars.items(): + globals()[name] = rule diff --git a/krum/aggregators/average.py b/krum/aggregators/average.py new file mode 100644 index 0000000..6f13818 --- /dev/null +++ b/krum/aggregators/average.py @@ -0,0 +1,58 @@ +### +# @file average.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Simple average GAR. +### + +from . import register + +# ---------------------------------------------------------------------------- # +# Average GAR + + +def aggregate(gradients, **kwargs): + """Averaging rule. + Args: + gradients Non-empty list of gradients to aggregate + ... Ignored keyword-arguments + Returns: + Average gradient + """ + return sum(gradients) / len(gradients) + + +def check(gradients, **kwargs): + """Check parameter validity for the averaging rule. + Args: + gradients Non-empty list of gradients to aggregate + ... Ignored keyword-arguments + Returns: + None if valid, otherwise error message string + """ + if not isinstance(gradients, list) or len(gradients) < 1: + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + + +def influence(honests, attacks, **kwargs): + """Compute the ratio of accepted Byzantine gradients. + Args: + honests Non-empty list of honest gradients to aggregate + attacks List of attack gradients to aggregate + ... Ignored keyword-arguments + """ + return len(attacks) / (len(honests) + len(attacks)) + + +# ---------------------------------------------------------------------------- # +# GAR registering + +# Register aggregation rule +register("average", aggregate, check, influence=influence) diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py new file mode 100644 index 0000000..d4c35d1 --- /dev/null +++ b/krum/aggregators/brute.py @@ -0,0 +1,166 @@ +### +# @file brute.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Brute GAR. +### + +import itertools +import math + +from krum import tools + +from . import register + +# Optional 'native' module +try: + import native +except ImportError: + native = None + +# ---------------------------------------------------------------------------- # +# Brute GAR + + +def _compute_selection(gradients, f, **kwargs): + """Brute rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + ... Ignored keyword-arguments + Returns: + Selection index set + """ + n = len(gradients) + # Compute all pairwise distances + distances = [0] * (n * (n - 1) // 2) + for i, (x, y) in enumerate(tools.pairwise(tuple(range(n)))): + distances[i] = gradients[x].sub(gradients[y]).norm().item() + # Select the set of smallest diameter + sel_iset = None + sel_diam = None + for cur_iset in itertools.combinations(range(n), n - f): + # Compute the current diameter (max of pairwise distances) + cur_diam = 0.0 + for x, y in tools.pairwise(cur_iset): + # Get distance between these two gradients ("magic" formula valid since x < y) + cur_dist = distances[(2 * n - x - 3) * x // 2 + y - 1] + # Check finite distance (non-Byzantine gradient must only contain finite coordinates), drop set if non-finite + if not math.isfinite(cur_dist): + break + # Check if new maximum + if cur_dist > cur_diam: + cur_diam = cur_dist + else: + # Check if new selected diameter + if sel_iset is None or cur_diam < sel_diam: + sel_iset = cur_iset + sel_diam = cur_diam + # Return the selected gradients + assert sel_iset is not None, ( + "Too many non-finite gradients: a non-Byzantine gradient must only contain finite coordinates" + ) + return sel_iset + + +def aggregate(gradients, f, **kwargs): + """Brute rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + ... Ignored keyword-arguments + Returns: + Aggregated gradient + """ + sel_iset = _compute_selection(gradients, f, **kwargs) + return sum(gradients[i] for i in sel_iset).div_(len(gradients) - f) + + +def aggregate_native(gradients, f, **kwargs): + """Brute rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + ... Ignored keyword-arguments + Returns: + Aggregated gradient + """ + return native.brute.aggregate(gradients, f) + + +def check(gradients, f, **kwargs): + """Check parameter validity for Brute rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + ... Ignored keyword-arguments + Returns: + None if valid, otherwise error message string + """ + if not isinstance(gradients, list) or len(gradients) < 1: + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 1: + return f"Invalid number of Byzantine gradients to tolerate, got f = {f!r}, expected 1 ≤ f ≤ {(len(gradients) - 1) // 2}" + + +def upper_bound(n, f, d): + """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. + Args: + n Number of workers (Byzantine + non-Byzantine) + f Expected number of Byzantine workers + d Dimension of the gradient space + Returns: + Theoretical upper-bound + """ + return (n - f) / (math.sqrt(8) * f) + + +def influence(honests, attacks, f, **kwargs): + """Compute the ratio of accepted Byzantine gradients. + Args: + honests Non-empty list of honest gradients to aggregate + attacks List of attack gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + Ratio of accepted + """ + gradients = honests + attacks + # Compute the selection set + sel_iset = _compute_selection(gradients, f, **kwargs) + # Compute the influence ratio + count = 0 + for i in sel_iset: + gradient = gradients[i] + for attack in attacks: + if gradient is attack: + count += 1 + break + return count / (len(gradients) - f) + + +# ---------------------------------------------------------------------------- # +# GAR registering + +# Register aggregation rule (pytorch version) +method_name = "brute" +register(method_name, aggregate, check, upper_bound=upper_bound, influence=influence) + +# Register aggregation rule (native version, if available) +if native is not None: + native_name = method_name + method_name = "native-" + method_name + if native_name in dir(native): + register(method_name, aggregate_native, check, upper_bound) + else: + tools.warning( + f"GAR {method_name!r} could not be registered since the associated native module {native_name!r} is unavailable" + ) diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py new file mode 100644 index 0000000..8fdb91a --- /dev/null +++ b/krum/aggregators/bulyan.py @@ -0,0 +1,152 @@ +### +# @file bulyan.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2020 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Bulyan over Multi-Krum GAR. +### + +import math + +import torch + +from krum import tools + +from . import register + +# Optional 'native' module +try: + import native +except ImportError: + native = None + +# ---------------------------------------------------------------------------- # +# Bulyan GAR class + + +def aggregate(gradients, f, m=None, **kwargs): + """Bulyan over Multi-Krum rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + Aggregated gradient + """ + n = len(gradients) + d = gradients[0].shape[0] + # Defaults + m_max = n - f - 2 + if m is None: + m = m_max + # Compute all pairwise distances + distances = list([(math.inf, None)] * n for _ in range(n)) + for gid_x, gid_y in tools.pairwise(tuple(range(n))): + dist = gradients[gid_x].sub(gradients[gid_y]).norm().item() + if not math.isfinite(dist): + dist = math.inf + distances[gid_x][gid_y] = (dist, gid_y) + distances[gid_y][gid_x] = (dist, gid_x) + # Compute the scores + scores = [None] * n + for gid in range(n): + dists = distances[gid] + dists.sort(key=lambda x: x[0]) + dists = dists[:m] + scores[gid] = (sum(dist for dist, _ in dists), gid) + distances[gid] = dict(dists) + # Selection loop + selected = torch.empty(n - 2 * f - 2, d, dtype=gradients[0].dtype, device=gradients[0].device) + for i in range(selected.shape[0]): + # Update 'm' + m = min(m, m_max - i) + # Compute the average of the selected gradients + scores.sort(key=lambda x: x[0]) + selected[i] = sum(gradients[gid] for _, gid in scores[:m]).div_(m) + # Remove the gradient from the distances and scores + gid_prune = scores[0][1] + scores[0] = (math.inf, None) + for score, gid in scores[1:]: + if gid == gid_prune: + scores[gid] = (score - distances[gid][gid_prune], gid) + # Coordinate-wise averaged median + m = selected.shape[0] - 2 * f + median = selected.median(dim=0).values + closests = selected.clone().sub_(median).abs_().topk(m, dim=0, largest=False, sorted=False).indices + closests.mul_(d).add_(torch.arange(0, d, dtype=closests.dtype, device=closests.device)) + avgmed = selected.take(closests).mean(dim=0) + # Return resulting gradient + return avgmed + + +def aggregate_native(gradients, f, m=None, **kwargs): + """Bulyan over Multi-Krum rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + Aggregated gradient + """ + # Defaults + if m is None: + m = len(gradients) - f - 2 + # Computation + return native.bulyan.aggregate(gradients, f, m) + + +def check(gradients, f, m=None, **kwargs): + """Check parameter validity for Bulyan over Multi-Krum rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + None if valid, otherwise error message string + """ + if not isinstance(gradients, list) or len(gradients) < 1: + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + if not isinstance(f, int) or f < 1 or len(gradients) < 4 * f + 3: + return f"Invalid number of Byzantine gradients to tolerate, got f = {f!r}, expected 1 ≤ f ≤ {(len(gradients) - 3) // 4}" + if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): + return f"Invalid number of selected gradients, got m = {f!r}, expected 1 ≤ m ≤ {len(gradients) - f - 2}" + + +def upper_bound(n, f, d): + """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. + Args: + n Number of workers (Byzantine + non-Byzantine) + f Expected number of Byzantine workers + d Dimension of the gradient space + Returns: + Theoretical upper-bound + """ + return 1 / math.sqrt(2 * (n - f + f * (n + f * (n - f - 2) - 2) / (n - 2 * f - 2))) + + +# ---------------------------------------------------------------------------- # +# GAR registering + +# Register aggregation rule (pytorch version) +method_name = "bulyan" +register(method_name, aggregate, check, upper_bound=upper_bound) + +# Register aggregation rule (native version, if available) +if native is not None: + native_name = method_name + method_name = "native-" + method_name + if native_name in dir(native): + register(method_name, aggregate_native, check, upper_bound=upper_bound) + else: + tools.warning( + f"GAR {method_name!r} could not be registered since the associated native module {native_name!r} is unavailable" + ) diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py new file mode 100644 index 0000000..050f007 --- /dev/null +++ b/krum/aggregators/krum.py @@ -0,0 +1,174 @@ +### +# @file krum.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2020 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Multi-Krum GAR. +### + +import math + +from krum import tools + +from . import register + +# Optional 'native' module +try: + import native +except ImportError: + native = None + +# ---------------------------------------------------------------------------- # +# Multi-Krum GAR + + +def _compute_scores(gradients, f, m, **kwargs): + """Multi-Krum score computation. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + List of (gradient, score) by sorted (increasing) scores + """ + n = len(gradients) + # Compute all pairwise distances + distances = [0] * (n * (n - 1) // 2) + for i, (x, y) in enumerate(tools.pairwise(tuple(range(n)))): + dist = gradients[x].sub(gradients[y]).norm().item() + if not math.isfinite(dist): + dist = math.inf + distances[i] = dist + # Compute the scores + scores = list() + for i in range(n): + # Collect the distances + grad_dists = list() + for j in range(i): + grad_dists.append(distances[(2 * n - j - 3) * j // 2 + i - 1]) + for j in range(i + 1, n): + grad_dists.append(distances[(2 * n - i - 3) * i // 2 + j - 1]) + # Select the n - f - 1 smallest distances + grad_dists.sort() + scores.append((sum(grad_dists[: n - f - 1]), gradients[i])) + # Sort the gradients by increasing scores + scores.sort(key=lambda x: x[0]) + return scores + + +def aggregate(gradients, f, m=None, **kwargs): + """Multi-Krum rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + Aggregated gradient + """ + # Defaults + if m is None: + m = len(gradients) - f - 2 + # Compute aggregated gradient + scores = _compute_scores(gradients, f, m, **kwargs) + return sum(grad for _, grad in scores[:m]).div_(m) + + +def aggregate_native(gradients, f, m=None, **kwargs): + """Multi-Krum rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + Aggregated gradient + """ + # Defaults + if m is None: + m = len(gradients) - f - 2 + # Computation + return native.krum.aggregate(gradients, f, m) + + +def check(gradients, f, m=None, **kwargs): + """Check parameter validity for Multi-Krum rule. + Args: + gradients Non-empty list of gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + None if valid, otherwise error message string + """ + if not isinstance(gradients, list) or len(gradients) < 1: + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 3: + return f"Invalid number of Byzantine gradients to tolerate, got f = {f!r}, expected 1 ≤ f ≤ {(len(gradients) - 3) // 2}" + if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): + return f"Invalid number of selected gradients, got m = {m!r}, expected 1 ≤ m ≤ {len(gradients) - f - 2}" + + +def upper_bound(n, f, d): + """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. + Args: + n Number of workers (Byzantine + non-Byzantine) + f Expected number of Byzantine workers + d Dimension of the gradient space + Returns: + Theoretical upper-bound + """ + return 1 / math.sqrt(2 * (n - f + f * (n + f * (n - f - 2) - 2) / (n - 2 * f - 2))) + + +def influence(honests, attacks, f, m=None, **kwargs): + """Compute the ratio of accepted Byzantine gradients. + Args: + honests Non-empty list of honest gradients to aggregate + attacks List of attack gradients to aggregate + f Number of Byzantine gradients to tolerate + m Optional number of averaged gradients for Multi-Krum + ... Ignored keyword-arguments + Returns: + Ratio of accepted + """ + gradients = honests + attacks + # Defaults + if m is None: + m = len(gradients) - f - 2 + # Compute the sorted scores + scores = _compute_scores(gradients, f, m, **kwargs) + # Compute the influence ratio + count = 0 + for _, gradient in scores[:m]: + for attack in attacks: + if gradient is attack: + count += 1 + break + return count / m + + +# ---------------------------------------------------------------------------- # +# GAR registering + +# Register aggregation rule (pytorch version) +method_name = "krum" +register(method_name, aggregate, check, upper_bound, influence) + +# Register aggregation rule (native version, if available) +if native is not None: + native_name = method_name + method_name = "native-" + method_name + if native_name in dir(native): + register(method_name, aggregate_native, check, upper_bound) + else: + tools.warning( + f"GAR {method_name!r} could not be registered since the associated native module {native_name!r} is unavailable" + ) diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py new file mode 100644 index 0000000..32f931e --- /dev/null +++ b/krum/aggregators/median.py @@ -0,0 +1,95 @@ +### +# @file median.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2020 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# NaN-resilient, coordinate-wise median GAR. +### + +import math + +import torch + +from krum import tools + +from . import register + +# Optional 'native' module +try: + import native +except ImportError: + native = None + +# ---------------------------------------------------------------------------- # +# NaN-resilient, coordinate-wise median GAR + + +def aggregate(gradients, **kwargs): + """NaN-resilient median coordinate-per-coordinate rule. + Args: + gradients Non-empty list of gradients to aggregate + ... Ignored keyword-arguments + Returns: + NaN-resilient, coordinate-wise median of the gradients + """ + return torch.stack(gradients).median(dim=0)[0] + + +def aggregate_native(gradients, **kwargs): + """NaN-resilient median coordinate-per-coordinate rule. + Args: + gradients Non-empty list of gradients to aggregate + ... Ignored keyword-arguments + Returns: + NaN-resilient, coordinate-wise median of the gradients + """ + return native.median.aggregate(gradients) + + +def check(gradients, **kwargs): + """Check parameter validity for the median rule. + Args: + gradients Non-empty list of gradients to aggregate + ... Ignored keyword-arguments + Returns: + None if valid, otherwise error message string + """ + if not isinstance(gradients, list) or len(gradients) < 1: + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + + +def upper_bound(n, f, d): + """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. + Args: + n Number of workers (Byzantine + non-Byzantine) + f Expected number of Byzantine workers + d Dimension of the gradient space + Returns: + Theoretical upper-bound + """ + return 1 / math.sqrt(n - f) + + +# ---------------------------------------------------------------------------- # +# GAR registering + +# Register aggregation rule (pytorch version) +method_name = "median" +register(method_name, aggregate, check, upper_bound) + +# Register aggregation rule (native version, if available) +if native is not None: + native_name = method_name + method_name = "native-" + method_name + if native_name in dir(native): + register(method_name, aggregate_native, check, upper_bound) + else: + tools.warning( + f"GAR {method_name!r} could not be registered since the associated native module {native_name!r} is unavailable" + ) diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py new file mode 100644 index 0000000..eb14438 --- /dev/null +++ b/krum/attacks/__init__.py @@ -0,0 +1,91 @@ +### +# @file __init__.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Loading of the local modules. +# +# Each attack MUST support taking any named arguments, possibly ignoring them. +# The parameters MUST all be passed as their keyword arguments. +# The reserved argument names, and their interface, are the following: +# · grad_honest: Non-empty list of honest gradients generated +# · f_decl : Number of declared Byzantine gradients at the GAR +# · f_real : Number of actual Byzantine gradients to generate +# · model : Model (duck-typing 'experiments.Model') with valid default dataset and loss set +# · defense : Aggregation rule (see module 'aggregators') in use to defeat +# The attack, given "valid" parameter(s), MUST return a list of f_byz tensor(s). +# Each of these returned tensors MUST NOT be a reference to any tensor given as parameter, +# although each returned tensors MAY be references to the same tensor. +# +# Each attack MUST provide a "check" function, taking the same arguments as the attack itself. +# The "check" member function returns 'None' when the parameters are valid, +# or an explanatory string when the parameters are not valid. +# The check member function MUST NOT modify the given parameters. +# +# Once registered, the check member function will be available as member "check". +# The raw function and a wrapped checking the input/output of the raw function +# will respectively be available as members "unchecked" and "checked". +# Which of these two functions is called by default depends whether debug mode is enabled. +### + +import pathlib + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Automated attack loader + + +def register(name, unchecked, check): + """Simple registration-wrapper helper. + Args: + name Attack name + unchecked Associated function (see module description) + check Parameter validity check function + """ + global attacks + # Check if name already in use + if name in attacks: + tools.warning(f"Unable to register {name!r} attack: name already in use") + return + + # Closure wrapping the call with checks + def checked(f_real, **kwargs): + # Check parameter validity + message = check(f_real=f_real, **kwargs) + if message is not None: + raise tools.UserException(f"Attack {name!r} cannot be used with the given parameters: {message}") + # Attack + res = unchecked(f_real=f_real, **kwargs) + # Forward asserted return value + assert isinstance(res, list) and len(res) == f_real, ( + f"Expected attack {name!r} to return a list of {f_real} Byzantine gradients, got {res!r}" + ) + return res + + # Select which function to call by default + func = checked if __debug__ else unchecked + # Bind all the (sub) functions to the selected function + setattr(func, "check", check) + setattr(func, "checked", checked) + setattr(func, "unchecked", unchecked) + # Export the selected function with the associated name + attacks[name] = func + + +# Registered attacks (mapping name -> attack) +attacks = dict() + +# Load native and all local modules +with tools.Context("attacks", None): + tools.import_directory(pathlib.Path(__file__).parent, globals()) + +# Bind/overwrite the attack names with the associated attacks in globals() +for name, attack in attacks.items(): + globals()[name] = attack diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py new file mode 100644 index 0000000..07bcbad --- /dev/null +++ b/krum/attacks/identical.py @@ -0,0 +1,159 @@ +### +# @file identical.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Collection of attacks which submit f identical gradients, which consist in +# adding as much of one attack vector to the average of the honest gradients. +# +# These attacks have been introduced in/adapted from the following papers: +# bulyan · El Mhamdi El Mahdi, Guerraoui Rachid, and Rouault Sébastien. +# The Hidden Vulnerability of Distributed Learning in Byzantium. +# ICML 2018. URL: http://proceedings.mlr.press/v80/mhamdi18a.html +# empire · Cong Xie, Oluwasanmi Koyejo, Indranil Gupta. +# Fall of Empires: Breaking Byzantine-tolerant SGD by Inner Product Manipulation. +# UAI 2019. URL: http://auai.org/uai2019/proceedings/papers/83.pdf +# little · Moran Baruch, Gilad Baruch, Yoav Goldberg. +# A Little Is Enough: Circumventing Defenses For Distributed Learning. +# 2019 Feb 16. ArXiv. URL: https://arxiv.org/pdf/1902.06156v1 +### + +import math + +import torch + +from krum import tools + +from . import register + +# ---------------------------------------------------------------------------- # +# Generic attack implementation generator + + +def make_attack(compute_direction): + """Make the attack gradient generation closure associated with an attack direction. + Args: + compute_direction Attack vector computation, (stacked honest gradients, average honest gradient, forwarded keyword-arguments...) -> attack vector (in the gradient space, no reference) + Returns: + Byzantine gradient generation closure + """ + + def attack(grad_honests, f_real, f_decl, defense, model, factor=-16, negative=False, **kwargs): + """Generate the attack gradients. + Args: + grad_honests Non-empty list of honest gradients + f_decl Number of declared Byzantine gradients + f_real Number of Byzantine gradients to generate + defense Aggregation rule in use to defeat + model Model with valid default dataset and loss set + factor Fixed attack factor if positive, number of evaluations for best attack factor if negative + negative Use a negative factor instead of a positive one + ... Forwarded keyword-arguments + Returns: + Generated Byzantine gradients (all references to one) + """ + # Fast path + if f_real == 0: + return list() + # Stack and compute the average honest gradient, and then the attack vector + grad_stck = torch.stack(grad_honests) + grad_avg = grad_stck.mean(dim=0) + grad_att = compute_direction(grad_stck, grad_avg, **kwargs) + # Evaluate the best attack factor (if required) + if factor < 0: + + def eval_factor(factor): + # Apply the given factor + if negative: + factor = -factor + grad_attack = grad_avg + factor * grad_att + # Measure effective squared distance + aggregated = defense(gradients=(grad_honests + [grad_attack] * f_real), f=f_decl, model=model) + aggregated.sub_(grad_avg) + return aggregated.dot(aggregated).item() + + factor = tools.line_maximize(eval_factor, evals=math.ceil(-factor)) + else: + if negative: + factor = -factor + # Generate the Byzantine gradient from the given/computed factor + byz_grad = grad_avg + grad_att.mul_(factor) + byz_grad.add_(grad_att) + # Return this Byzantine gradient 'f_real' times + return [byz_grad] * f_real + + # Return the attack closure + return attack + + +def check(grad_honests, f_real, defense, factor=-16, negative=False, **kwargs): + """Check parameter validity for this attack template. + Args: + grad_honests Non-empty list of honest gradients + f_real Number of Byzantine gradients to generate + defense Aggregation rule in use to defeat + ... Ignored keyword-arguments + Returns: + Whether the given parameters are valid for this attack + """ + if not isinstance(grad_honests, list) or len(grad_honests) == 0: + return f"Expected a non-empty list of honest gradients, got {grad_honests!r}" + if not isinstance(f_real, int) or f_real < 0: + return f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" + if not callable(defense): + return f"Expected a callable for the aggregation rule, got {defense!r}" + if not ((isinstance(factor, float) and factor > 0) or (isinstance(factor, int) and factor != 0)): + return f"Expected a positive number or a negative integer for the attack factor, got {factor!r}" + if not isinstance(negative, bool): + return f"Expected a boolean for optional parameter 'negative', got {negative!r}" + + +# ---------------------------------------------------------------------------- # +# Attack vector computations + + +def bulyan(grad_stck, grad_avg, target_idx=-1, **kwargs): + """Compute the attack vector adapted from "The Hidden Vulnerability". + Args: + target_idx Index of the targeted coordinate, "all" for all + See: + make_attack + """ + if target_idx == "all": + return torch.ones_like(grad_avg) + else: + assert isinstance(target_idx, int), f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" + grad_att = torch.zeros_like(grad_avg) + grad_att[target_idx] = 1 + return grad_att + + +def empire(grad_stck, grad_avg, **kwargs): + """Compute the attack vector adapted from "Fall of Empires". + See: + make_attack + """ + return grad_avg.neg() + + +def little(grad_stck, grad_avg, **kwargs): + """Compute the attack vector adapted from "A Little is Enough". + See: + make_attack + """ + return grad_stck.var(dim=0).sqrt_() + + +# ---------------------------------------------------------------------------- # +# Attack registrations + +# Register the attacks +for name, func in (("bulyan", bulyan), ("empire", empire), ("little", little)): + register(name, make_attack(func), check) diff --git a/krum/attacks/nan.py b/krum/attacks/nan.py new file mode 100644 index 0000000..5ec9da9 --- /dev/null +++ b/krum/attacks/nan.py @@ -0,0 +1,63 @@ +### +# @file nan.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Attack that generates NaN gradient(s), hence the name. +### + +import math + +import torch + +from . import register + +# ---------------------------------------------------------------------------- # +# Non-finite gradient attack + + +def attack(grad_honests, f_real, **kwargs): + """Generate non-finite gradients. + Args: + grad_honests Non-empty list of honest gradients + f_real Number of Byzantine gradients to generate + ... Ignored keyword-arguments + Returns: + Generated Byzantine gradients + """ + # Fast path + if f_real == 0: + return list() + # Generate the non-finite Byzantine gradient + byz_grad = torch.empty_like(grad_honests[0]) + byz_grad.copy_(torch.tensor((math.nan,), dtype=byz_grad.dtype)) + # Return this Byzantine gradient 'f_real' times + return [byz_grad] * f_real + + +def check(grad_honests, f_real, **kwargs): + """Check parameter validity for this attack. + Args: + grad_honests Non-empty list of honest gradients + f_real Number of Byzantine gradients to generate + ... Ignored keyword-arguments + Returns: + Whether the given parameters are valid for this attack + """ + if not isinstance(grad_honests, list) or len(grad_honests) == 0: + return f"Expected a non-empty list of honest gradients, got {grad_honests!r}" + if not isinstance(f_real, int) or f_real < 0: + return f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" + + +# ---------------------------------------------------------------------------- # +# Attack registering + +# Register the attack +register("nan", attack, check) diff --git a/krum/experiments/__init__.py b/krum/experiments/__init__.py new file mode 100644 index 0000000..0667e2e --- /dev/null +++ b/krum/experiments/__init__.py @@ -0,0 +1,24 @@ +### +# @file __init__.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Dataset/model/... wrappers/helpers, for more convenient gradient extraction and operations. +# Heavily relies on the module 'torchvision'. +### + +import pathlib + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Load all local modules + +with tools.Context("experiments", None): + tools.import_directory(pathlib.Path(__file__).parent, globals()) diff --git a/krum/experiments/checkpoint.py b/krum/experiments/checkpoint.py new file mode 100644 index 0000000..1594135 --- /dev/null +++ b/krum/experiments/checkpoint.py @@ -0,0 +1,173 @@ +### +# @file checkpoint.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Checkpoint helpers. +### + +__all__ = ["Checkpoint", "Storage"] + +import copy +import pathlib + +import torch + +from krum import tools + +from .model import Model +from .optimizer import Optimizer + +# ---------------------------------------------------------------------------- # +# Checkpoint helper class + + +class Checkpoint: + """A collection of state dictionaries with saving/loading helpers.""" + + # Transfer for handling local package's classes + _transfers = {Model: (lambda x: x._model), Optimizer: (lambda x: x._optim)} + + @classmethod + def _prepare(cls, instance): + """Prepare the given instance for checkpointing. + Args: + instance Instance to snapshot/restore + Returns: + Checkpoint-able instance, key for the associated storage + """ + # Recover instance's class + instance_cls = type(instance) + # Transfer if available + if instance_cls in cls._transfers: + res = cls._transfers[instance_cls](instance) + else: + res = instance + # Assert the instance is checkpoint-able + for prop in ("state_dict", "load_state_dict"): + if not callable(getattr(res, prop, None)): + raise tools.UserException( + f"Given instance {instance!r} is not checkpoint-able (missing callable member {prop!r})" + ) + # Return the instance and the associated storage key + return res, tools.fullqual(instance_cls) + + def __init__(self): + """Empty checkpoint constructor.""" + # Finalization + self._store = dict() + if __debug__: + self._copied = dict() # Booleans for tracking possible bugs, 'key in _store' <=> 'key in _copied' + + def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): + """Take/overwrite the snapshot for a given instance. + Args: + instance Instance to snapshot + overwrite Overwrite any existing snapshot for the same class + deepcopy Deep copy instance's state dictionary instead of referencing + nowarnref To always avoid a warning in debug mode if restoring a state dictionary reference is the wanted behavior + Returns: + self + """ + instance, key = type(self)._prepare(instance) + # Snapshot the state dictionary + if not overwrite and key in self._store: + raise tools.UserException(f"A snapshot for {key!r} is already stored in the checkpoint") + if deepcopy: + self._store[key] = copy.deepcopy(instance.state_dict()) + else: + self._store[key] = instance.state_dict().copy() + # Track whether a deepcopy was made (or whether restoring a reference is the expected behavior) + if __debug__: + self._copied[key] = deepcopy or nowarnref + # Enable chaining + return self + + def restore(self, instance, nothrow=False): + """Restore the snapshot for a given instance, warn if restoring a reference. + Args: + instance Instance to restore + nothrow Do not raise exception if no snapshot available for the instance + Returns: + self + """ + instance, key = type(self)._prepare(instance) + # Restore the state dictionary + if key in self._store: + instance.load_state_dict(self._store[key]) + # Check if restoring a reference + if __debug__ and not self._copied[key]: + tools.warning( + f"Restoring a state dictionary reference in an instance of {tools.fullqual(type(instance))}; the resulting behavior may not be the one expected" + ) + elif not nothrow: + raise tools.UserException(f"No snapshot for {key!r} is available in the checkpoint") + # Enable chaining + return self + + def load(self, filepath, overwrite=False): + """Load/overwrite the storage from the given file. + Args: + filepath Given file path + overwrite Allow to overwrite any stored snapshot + Returns: + self + """ + # Check if empty + if not overwrite and len(self._store) > 0: + raise tools.UserException("Unable to load into a non-empty checkpoint") + # Load the file + self._store = torch.load(filepath) + # Reset the 'copied' flags accordingly + if __debug__: + self._copied.clear() + for key in self._store.keys(): + self._copied[key] = True + # Enable chaining + return self + + def save(self, filepath, overwrite=False): + """Save the current checkpoint in the given file. + Args: + filepath Given file path + overwrite Allow to overwrite if the file already exists + Returns: + self + """ + # Check if file already exists + if pathlib.Path(filepath).exists() and not overwrite: + raise tools.UserException( + f"Unable to save checkpoint in existing file {str(filepath)!r} (overwriting has not been allowed by the caller)" + ) + # (Over)write the file + torch.save(self._store, filepath) + # Enable chaining + return self + + +# ---------------------------------------------------------------------------- # +# Dictionary that implements "state_dict protocol" + + +class Storage(dict): + """Dictionary that implements "state_dict protocol" class.""" + + def state_dict(self): + """Access the state dictionary. + Returns: + self + """ + return self + + def load_state_dict(self, state): + """Update the state dictionary. + Args: + state State to update the current storage with + """ + self.update(state) diff --git a/krum/experiments/configuration.py b/krum/experiments/configuration.py new file mode 100644 index 0000000..e765189 --- /dev/null +++ b/krum/experiments/configuration.py @@ -0,0 +1,100 @@ +### +# @file configuration.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Configuration wrapper. +### + +__all__ = ["Configuration"] + +from collections.abc import Mapping + +import torch + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Trivial tensor configuration holder (dtype, device, ...) class + + +class Configuration(Mapping): + """Immutable tensor configuration holder class.""" + + # Default selected device (GPU if available, else CPU) + default_device = "cuda" if torch.cuda.is_available() else "cpu" + + def __init__(self, device=None, dtype=None, noblock=False, relink=False): + """Immutable initialization constructor. + Args: + device Device (either instance, formatted name or None) to use + dtype Datatype to use, None for PyTorch default + noblock To try and avoid using blocking memory transfer operations from the host + relink Relink instead of copying by default in some assignment operations + """ + # Convert formatted device name to device instance + if device is None: + # Use default device + device = type(self).default_device + if isinstance(device, str): + # Warn if CUDA is requested but not available + if not torch.cuda.is_available() and device[:4] == "cuda": + device = "cpu" + tools.warning( + "CUDA is unavailable on this node, falling back to CPU in the configuration", context="experiments" + ) + # Convert + device = torch.device(device) + # Resolve the current default dtype if unspecified + if dtype is None: + dtype = torch.get_default_dtype() + # Finalization + self._args = {"device": device, "dtype": dtype, "non_blocking": noblock} + self.relink = relink + + def __len__(self): + """Return the number of contained configuration entries. + Returns: + Number of configuration entries + """ + return len(self._args) + + def __getitem__(self, name): + """Get a configuration value from its name. + Args: + name Configuration name + Returns: + Associated configuration value + """ + return self._args[name] + + def __iter__(self): + """Build an iterator over all the configuration entries. + Return: + Built iterator + """ + return self._args.__iter__() + + def __str__(self): + """Compute the "informal", nicely printable string representation of this configuration. + Returns: + Nicely printable string + """ + temp = self._args.copy() + temp["relink"] = self.relink + return str(temp) + + def __repr__(self): + """Compute the "official", Python-code string representation of this configuration. + Returns: + Python-code string evaluating (under conditions) to this configuration + """ + display = {"non_blocking": "noblock"} + argrepr = (", ").join(f"{display.get(key, key)}={val!r}" for key, val in self._args.items()) + return f"Configuration({argrepr}, relink={self.relink})" diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py new file mode 100644 index 0000000..67537d8 --- /dev/null +++ b/krum/experiments/dataset.py @@ -0,0 +1,417 @@ +### +# @file dataset.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Dataset wrappers/helpers. +### + +__all__ = ["get_default_transform", "Dataset", "make_sampler", "make_datasets", "batch_dataset"] + +import pathlib +import random +import tempfile +import types + +import torch +import torchvision + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Default image transformations + +# Collection of default transforms, -> (, ) +transforms_horizontalflip = [torchvision.transforms.RandomHorizontalFlip(), torchvision.transforms.ToTensor()] +transforms_mnist = [ + torchvision.transforms.ToTensor(), + torchvision.transforms.Normalize((0.1307,), (0.3081,)), +] # Transforms from "A Little is Enough" (https://github.com/moranant/attacking_distributed_learning) +transforms_cifar = [ + torchvision.transforms.RandomHorizontalFlip(), + torchvision.transforms.ToTensor(), + torchvision.transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), +] # Transforms from https://github.com/kuangliu/pytorch-cifar + +# Per-dataset image transformations (automatically completed, see 'Dataset._get_datasets') +transforms = { + "mnist": (transforms_mnist, transforms_mnist), + "fashionmnist": (transforms_horizontalflip, transforms_horizontalflip), + "cifar10": (transforms_cifar, transforms_cifar), + "cifar100": (transforms_cifar, transforms_cifar), + "imagenet": (transforms_horizontalflip, transforms_horizontalflip), +} + + +def get_default_transform(dataset, train): + """Get the default transform associated with the given dataset name. + Args: + dataset Case-sensitive dataset name, or None to get no transformation + train Whether the transformation is for the training set (always ignored if None is given for 'dataset') + Returns: + Associated default transformations (always exist) + """ + global transforms + # Fetch transformation + transform = transforms.get(dataset) + # Not found (not a torchvision dataset) + if transform is None: + return None + # Return associated transform + return torchvision.transforms.Compose(transform[0 if train else 1]) + + +# ---------------------------------------------------------------------------- # +# Dataset loader-batch producer wrapper class + + +class Dataset: + """Dataset wrapper class.""" + + # Default dataset root directory path + __default_root = None + + @classmethod + def get_default_root(cls): + """Lazy-initialize and return the default dataset root directory path. + Returns: + '__default_root' + """ + # Fast-path already loaded + if cls.__default_root is not None: + return cls.__default_root + # Generate the default path + cls.__default_root = pathlib.Path(__file__).parent / "datasets" / "cache" + # Warn if the path does not exist and fallback to '/tmp' + if not cls.__default_root.exists(): + tmpdir = tempfile.gettempdir() + tools.warning( + f"Default dataset root {str(cls.__default_root)!r} does not exist, falling back to local temporary directory {tmpdir!r}", + context="experiments", + ) + cls.__default_root = pathlib.Path(tmpdir) + # Return the path + return cls.__default_root + + # Map 'lower-case names' -> 'dataset class' available in PyTorch + __datasets = None + + @classmethod + def _get_datasets(cls): + """Lazy-initialize and return the map '__datasets'. + Returns: + '__datasets' + """ + global transforms + # Fast-path already loaded + if cls.__datasets is not None: + return cls.__datasets + # Initialize the dictionary + cls.__datasets = dict() + # Populate this dictionary with TorchVision's datasets + for name in dir(torchvision.datasets): + if len(name) == 0 or name[0] == "_": # Ignore "protected" members + continue + constructor = getattr(torchvision.datasets, name) + if isinstance(constructor, type): # Heuristic + + def make_builder(constructor, name): + def builder(root, batch_size=None, shuffle=False, num_workers=1, *args, **kwargs): + # Try to build the dataset instance + data = constructor(root, *args, **kwargs) + assert isinstance(data, torch.utils.data.Dataset), ( + f"Internal heuristic failed: {name!r} was not a dataset name" + ) + # Ensure there is at least a tensor transformation for each torchvision dataset + if name not in transforms: + transforms[name] = torchvision.transforms.ToTensor() + # Wrap into a loader + batch_size = batch_size or len(data) + loader = torch.utils.data.DataLoader( + data, batch_size=batch_size, shuffle=shuffle, num_workers=num_workers + ) + # Wrap into an infinite batch sampler generator + return make_sampler(loader) + + return builder + + cls.__datasets[name.lower()] = make_builder(constructor, name) + + # Dynamically add the custom datasets from subdirectory 'datasets/' + def add_custom_datasets(name, module, _): + nonlocal cls + # Check if has exports, fallback otherwise + exports = getattr(module, "__all__", None) + if exports is None: + tools.warning( + f"Dataset module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery" + ) + exports = (name for name in dir(module) if len(name) > 0 and name[0] != "_") + # Register the association 'name -> constructor' for all the datasets + exported = False + for dataset in exports: + # Check dataset name type + if not isinstance(dataset, str): + tools.warning(f"Dataset module {name!r} exports non-string name {dataset!r}; ignored") + continue + # Recover instance from name + constructor = getattr(module, dataset, None) + # Check instance is callable (it's only an heuristic...) + if not callable(constructor): + continue + # Register callable with composite name + exported = True + fullname = f"{name}-{dataset}" + if fullname in cls.__datasets: + tools.warning( + f"Unable to make available dataset {dataset!r} from module {name!r}, as the name {fullname!r} already exists" + ) + continue + cls.__datasets[fullname] = constructor + if not exported: + tools.warning(f"Dataset module {name!r} does not export any valid constructor name through '__all__'") + + with tools.Context("datasets", None): + tools.import_directory( + pathlib.Path(__file__).parent / "datasets", + {"__package__": f"{__package__}.datasets"}, + post=add_custom_datasets, + ) + # Return the dictionary + return cls.__datasets + + def __init__(self, data, name=None, root=None, *args, **kwargs): + """Dataset builder constructor. + Args: + data Dataset string name, (infinite) generator instance (that will be used to generate samples), or any other instance (that will then be fed as the only sample) + name Optional user-defined dataset name, to attach to some error messages for debugging purpose + root Dataset cache root directory to use, None for default (only relevant if 'data' is a dataset name) + ... Forwarded (keyword-)arguments to the dataset constructor, ignored if 'data' is not a string + Raises: + 'TypeError' if the some of the given (keyword-)arguments cannot be used to call the dataset or loader constructor or the batch loader + """ + # Handle different dataset types + if isinstance(data, str): # Load sampler from available datasets + if name is None: + name = data + datasets = type(self)._get_datasets() + build = datasets.get(name, None) + if build is None: + raise tools.UnavailableException(datasets, name, what="dataset name") + root = root or type(self).get_default_root() + self._iter = build(root=root, *args, **kwargs) + elif isinstance(data, types.GeneratorType): # Forward sampling to custom generator + if name is None: + name = "" + self._iter = data + else: # Single-batch dataset of any value + if name is None: + name = "" + + def single_batch(): + while True: + yield data + + self._iter = single_batch() + # Finalization + self.name = name + + def __str__(self): + """Compute the "informal", nicely printable string representation of this dataset. + Returns: + Nicely printable string + """ + return f"dataset {self.name}" + + def sample(self, config=None): + """Sample the next batch from this dataset. + Args: + config Target configuration for the sampled tensors + Returns: + Next batch + """ + tns = next(self._iter) + if config is not None: + tns = type(tns)(tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns) + return tns + + def epoch(self, config=None): + """Return a full epoch iterable from this dataset. + Args: + config Target configuration for the sampled tensors + Returns: + Full epoch iterable + Notes: + Only work for dataset based on PyTorch's DataLoader + """ + # Assert dataset based on DataLoader + assert isinstance(self._loader, torch.utils.data.DataLoader), ( + "Full epoch iteration only possible for PyTorch's DataLoader-based datasets" + ) + # Return a full epoch iterator + epoch = self._loader.__iter__() + + def generator(): + nonlocal epoch + try: + while True: + tns = next(epoch) + if config is not None: + tns = type(tns)( + tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns + ) + yield tns + except StopIteration: + return + + return generator() + + +# ---------------------------------------------------------------------------- # +# Dataset helpers + + +def make_sampler(loader): + """Infinite sampler generator from a dataset loader. + Args: + loader Dataset loader to use + Yields: + Sample, forever (transparently iterating the given loader again and again) + """ + itr = None + while True: + for _ in range(2): + # Try sampling the next batch + if itr is not None: + try: + yield next(itr) + break + except StopIteration: + pass + # Ask loader for a new iteration + itr = iter(loader) + else: + raise RuntimeError("Unable to sample a new batch from dataset") + + +def make_datasets( + dataset, + train_batch=None, + test_batch=None, + train_transforms=None, + test_transforms=None, + num_workers=1, + **custom_args, +): + """Helper to make new instances of training and testing datasets. + Args: + dataset Case-sensitive dataset name + train_batch Training batch size, None or 0 for maximum possible + test_batch Testing batch size, None or 0 for maximum possible + train_transforms Transformations to apply on the training set, None for default for the given dataset + test_transforms Transformations to apply on the testing set, None for default for the given dataset + num_workers Positive number of workers for each of the training and testing datasets, or tuple for each of them + ... Additional dataset-dependent keyword-arguments + Returns: + Training dataset, testing dataset + """ + # Pre-process arguments + train_transforms = train_transforms or get_default_transform(dataset, True) + test_transforms = test_transforms or get_default_transform(dataset, False) + num_workers_errmsg = "Expected either a positive int or a tuple of 2 positive ints for parameter 'num_workers'" + if isinstance(num_workers, int): + assert num_workers > 0, num_workers_errmsg + train_workers = test_workers = num_workers + else: + assert isinstance(num_workers, tuple) and len(num_workers) == 2, num_workers_errmsg + train_workers, test_workers = num_workers + assert isinstance(train_workers, int) and train_workers > 0, num_workers_errmsg + assert isinstance(test_workers, int) and test_workers > 0, num_workers_errmsg + # Make the datasets + trainset = Dataset( + dataset, + train=True, + download=True, + batch_size=train_batch, + shuffle=True, + num_workers=train_workers, + transform=train_transforms, + **custom_args, + ) + testset = Dataset( + dataset, + train=False, + download=False, + batch_size=test_batch, + shuffle=False, + num_workers=test_workers, + transform=test_transforms, + **custom_args, + ) + # Return the datasets + return trainset, testset + + +def batch_dataset(inputs, labels, train=False, batch_size=None, split=0.75): + """Batch a given raw (tensor) dataset into either a training or testing infinite sampler generators. + Args: + inputs Tensor of positive dimension containing input data + labels Tensor of same shape as 'inputs' containing expected output data + train Whether this is for training (basically adds shuffling) + batch_size Training batch size, None (or 0) for maximum batch size + split Fraction of datapoints to use in the train set if < 1, or #samples in the train set if ≥ 1 + Returns: + Training or testing set infinite sampler generator (with uniformly sampled batches), + Test set infinite sampler generator (without random sampling) + """ + + def train_gen(inputs, labels, batch): + cursor = 0 + datalen = len(inputs) + shuffle = list(range(datalen)) + random.shuffle(shuffle) + while True: + end = cursor + batch + if end > datalen: + select = shuffle[cursor:] + random.shuffle(shuffle) + select += shuffle[: (end % datalen)] + else: + select = shuffle[cursor:end] + yield inputs[select], labels[select] + cursor = end % datalen + + def test_gen(inputs, labels, batch): + cursor = 0 + datalen = len(inputs) + while True: + end = cursor + batch + if end > datalen: + select = list(range(cursor, datalen)) + list(range(end % datalen)) + yield inputs[select], labels[select] + else: + yield inputs[cursor:end], labels[cursor:end] + cursor = end % datalen + + # Split dataset + dataset_len = len(inputs) + if dataset_len < 1 or len(labels) != dataset_len: + raise RuntimeError( + f"Invalid or different input/output tensor lengths, got {len(inputs)} for inputs, got {len(labels)} for labels" + ) + split_pos = min(max(1, int(dataset_len * split)) if split < 1 else split, dataset_len - 1) + # Make and return generator according to flavor + if train: + train_len = split_pos + batch_size = min(batch_size or train_len, train_len) + return train_gen(inputs[:split_pos], labels[:split_pos], batch_size) + else: + test_len = dataset_len - split_pos + batch_size = min(batch_size or test_len, test_len) + return test_gen(inputs[split_pos:], labels[split_pos:], batch_size) diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py new file mode 100644 index 0000000..8975ba0 --- /dev/null +++ b/krum/experiments/datasets/svm.py @@ -0,0 +1,129 @@ +### +# @file svm.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Lazy-(down)load and pre-process datasets from LIBSVM. +# Website: https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/ +### + +__all__ = ["phishing"] + +import requests +import torch + +from krum import experiments, tools + +# ---------------------------------------------------------------------------- # +# Configuration + +# Default raw dataset URLs +default_url_phishing = "https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/phishing" + +# Default cache root directory +default_root = experiments.dataset.Dataset.get_default_root() + +# ---------------------------------------------------------------------------- # +# Dataset lazy-loaders + +# Raw phishing dataset +raw_phishing = None + + +def get_phishing(root, url): + """Lazy-load the phishing dataset. + Args: + root Dataset cache root directory + url URL to fetch raw dataset from, if not already in cache (None for no download) + Returns: + Input tensor, + Label tensor + """ + global raw_phishing + const_filename = "phishing.pt" + const_features = 68 + const_datatype = torch.float32 + # Fast path: return loaded dataset + if raw_phishing is not None: + return raw_phishing + # Make dataset path + dataset_file = root / const_filename + # Fast path: pre-processed dataset already locally available + if dataset_file.exists(): + with dataset_file.open("rb") as fd: + # Load, lazy-store and return dataset + dataset = torch.load(fd) + raw_phishing = dataset + return dataset + elif url is None: + raise RuntimeError("Phishing dataset not in cache and download disabled") + # Download dataset + tools.info("Downloading dataset...", end="", flush=True) + try: + response = requests.get(url) + except Exception as err: + tools.warning(" fail.") + raise RuntimeError(f"Unable to get dataset (at {url}): {err}") + tools.info(" done.") + if response.status_code != 200: + raise RuntimeError(f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}") + # Pre-process dataset + tools.info("Pre-processing dataset...", end="", flush=True) + entries = response.text.strip().split("\n") + inputs = torch.zeros(len(entries), const_features, dtype=const_datatype) + labels = torch.empty(len(entries), dtype=const_datatype) + for index, entry in enumerate(entries): + entry = entry.split(" ") + # Set label + labels[index] = 1 if entry[0] == "1" else 0 + # Set input + line = inputs[index] + for pos, setter in enumerate(entry[1:]): + try: + offset, value = setter.split(":") + line[int(offset) - 1] = float(value) + except Exception as err: + tools.warning(" fail.") + raise RuntimeError(f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}") + labels.unsqueeze_(1) + tools.info(" done.") + # (Try to) save pre-processed dataset + try: + with dataset_file.open("wb") as fd: + torch.save((inputs, labels), fd) + except Exception as err: + tools.warning(f"Unable to save pre-processed dataset: {err}") + # Lazy-store and return dataset + dataset = (inputs, labels) + raw_phishing = dataset + return dataset + + +# ---------------------------------------------------------------------------- # +# Dataset generators + + +def phishing(train=True, batch_size=None, root=None, download=False, *args, **kwargs): + """Phishing dataset generator builder. + Args: + train Whether to get the training slice of the dataset + batch_size Batch size (None or 0 for all in one single batch) + root Dataset cache root directory (None for default) + download Whether to allow to download the dataset if not cached locally + ... Ignored supplementary (keyword-)arguments + Returns: + Associated ataset generator + """ + with tools.Context("phishing", None): + # Get the raw dataset + inputs, labels = get_phishing(root or default_root, None if download is None else default_url_phishing) + # Make and return the associated generator + return experiments.batch_dataset( + inputs, labels, train, batch_size, split=8400 + ) # 8400 = 2⁴ × 3 × 5² × 7 (should help with divisibility) diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py new file mode 100644 index 0000000..91b97ff --- /dev/null +++ b/krum/experiments/loss.py @@ -0,0 +1,317 @@ +### +# @file loss.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Loss/criterion wrappers/helpers. +### + +__all__ = ["Loss", "Criterion"] + +import torch + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Loss (derivable)/criterion (non-derivable) wrapper classes + + +class Loss: + """Loss (must be derivable) wrapper class.""" + + __reserved_init = object() + + @staticmethod + def _l1loss(output, target, params): + """l1 loss implementation + Args: + ... Ignored arguments + params Flat parameter tensor + Returns: + l1 loss + """ + return params.norm(p=1) + + @staticmethod + def _l2loss(output, target, params): + """l2 loss implementation + Args: + ... Ignored arguments + params Flat parameter tensor + Returns: + l2 loss + """ + return params.norm() + + @classmethod + def _l1loss_builder(cls): + """l1 loss builder. + Returns: + L1 loss instance + """ + return cls(cls.__reserved_init, cls._l1loss, None, "l1") + + @classmethod + def _l2loss_builder(cls): + """l2 loss builder. + Returns: + L2 loss instance + """ + return cls(cls.__reserved_init, cls._l2loss, None, "l2") + + # Map 'lower-case names' -> 'loss constructor' available in PyTorch + __losses = None + + @staticmethod + def _make_drop_params(builder): + """Make a builder that will wrap the built function so to drop the 'params' parameter. + Args: + builder Builder function to wrap + Returns: + Wrapped builder function + """ + + def drop_builder(*args, **kwargs): + loss = builder(*args, **kwargs) + + def drop_loss(output, target, params): + return loss(output, target) + + return drop_loss + + return drop_builder + + @classmethod + def _get_losses(cls): + """Lazy-initialize and return the map '__losses'. + Returns: + '__losses' + """ + # Fast-path already loaded + if cls.__losses is not None: + return cls.__losses + # Initialize the dictionary + cls.__losses = dict() + # Simply populate this dictionary + for name in dir(torch.nn.modules.loss): + if len(name) < 5 or name[0] == "_" or name[-4:] != "Loss": # Heuristically ignore non-loss members + continue + builder = getattr(torch.nn.modules.loss, name) + if isinstance(builder, type): # Still an heuristic + cls.__losses[name[:-4].lower()] = cls._make_drop_params(builder) + # Add/replace the l1 and l2 losses + cls.__losses["l1"] = cls._l1loss_builder + cls.__losses["l2"] = cls._l2loss_builder + # Return the dictionary + return cls.__losses + + def __init__(self, name_build, *args, **kwargs): + """Loss constructor. + Args: + name_build Loss name or constructor function + ... Additional (keyword-)arguments forwarded to the constructor + """ + # Reserved custom initialization + if name_build is type(self).__reserved_init: + self._loss = args[0] + self._fact = args[1] + self._name = args[2] + return + # Recover name/constructor + if callable(name_build): + name = tools.fullqual(name_build) + build = name_build + else: + losses = type(self)._get_losses() + name = str(name_build) + build = losses.get(name, None) + if build is None: + raise tools.UnavailableException(losses, name, what="loss name") + # Build loss + loss = build(*args, **kwargs) + # Finalization + self._loss = loss + self._fact = None + self._name = name + + def _str_make(self): + """Make the formatted part of the nicely printable string representation of this loss. + Returns: + Formatted part + """ + return self._name if self._fact is None else f"{self._fact} × {self._name}" + + def __str__(self): + """Compute the "informal", nicely printable string representation of this loss. + Returns: + Nicely printable string + """ + return f"loss {self._str_make()}" + + def __call__(self, output, target, params): + """Compute the loss from the output and the target. + Args: + output Output tensor from the model + target Expected tensor + params Parameter vector + Returns: + Computed loss tensor + """ + res = self._loss(output, target, params) + if self._fact is not None: + res *= self._fact + return res + + def __add__(self, loss): + """Add the current loss to the given loss. + Args: + loss Given loss + Returns: + Sum of the two losses + """ + + def add(output, target, params): + return self(output, target, params) + loss(output, target, params) + + return type(self)(type(self).__reserved_init, add, None, f"({self._str_make()} + {loss._str_make()})") + + def __mul__(self, factor): + """Multiply the current loss by a given factor. + Args: + factor Given factor + Returns: + New loss, factor of the current loss + """ + + def mul(output, target, params): + return self(output, target, params) * factor + + return type(self)( + type(self).__reserved_init, mul, factor * (1.0 if self._fact is None else self._fact), self._name + ) + + def __rmul__(self, *args, **kwargs): + """Forward the call to '__mul__'. + Args: + ... Forwarded (keyword-)arguments + Returns: + Forwarded return value + """ + return self.__mul__(*args, **kwargs) + + def __imul__(self, factor): + """In-place multiply the current loss by a given factor. + Args: + factor Given factor + Returns: + Current loss + """ + self._fact = factor * (1.0 if self._fact is None else self._fact) + return self + + +class Criterion: + """Criterion (1D tensor [#correct classification, batch size]) wrapper class.""" + + class _TopkCriterion: + """Top-k criterion helper class.""" + + def __init__(self, k=1): + """Value of 'k' constructor. + Args: + k Value of 'k' to use + """ + # Finalization + self.k = k + + def __call__(self, output, target): + """Compute the criterion from the output and the target. + Args: + output Batch × model logits + target Batch × target index + Returns: + 1D-tensor [#correct classification, batch size] + """ + res = (output.topk(self.k, dim=1)[1] == target.view(-1).unsqueeze(1)).any(dim=1).sum() + return torch.cat( + (res.unsqueeze(0), torch.tensor(target.shape[0], dtype=res.dtype, device=res.device).unsqueeze(0)) + ) + + class _SigmoidCriterion: + """Sigmoid criterion helper class.""" + + def __call__(self, output, target): + """Compute the criterion from the output and the target. + Args: + output Batch × model logits (expected in [0, 1]) + target Batch × target index (expected in {0, 1}) + Returns: + 1D-tensor [#correct classification, batch size] + """ + correct = target.sub(output).abs_() < 0.5 + res = torch.empty(2, dtype=output.dtype, device=output.device) + res[0] = correct.sum() + res[1] = len(correct) + return res + + # Map 'lower-case names' -> 'loss constructor' available in PyTorch + __criterions = None + + @classmethod + def _get_criterions(cls): + """Lazy-initialize and return the map '__criterions'. + Returns: + '__criterions' + """ + # Fast-path already loaded + if cls.__criterions is not None: + return cls.__criterions + # Initialize the dictionary + cls.__criterions = {"top-k": cls._TopkCriterion, "sigmoid": cls._SigmoidCriterion} + # Return the dictionary + return cls.__criterions + + def __init__(self, name_build, *args, **kwargs): + """Criterion constructor. + Args: + name_build Criterion name or constructor function + ... Additional (keyword-)arguments forwarded to the constructor + """ + # Recover name/constructor + if callable(name_build): + name = tools.fullqual(name_build) + build = name_build + else: + crits = type(self)._get_criterions() + name = str(name_build) + build = crits.get(name, None) + if build is None: + raise tools.UnavailableException(crits, name, what="criterion name") + # Build criterion + crit = build(*args, **kwargs) + # Finalization + self._crit = crit + self._name = name + + def __str__(self): + """Compute the "informal", nicely printable string representation of this criterion. + Returns: + Nicely printable string + """ + return f"criterion {self._name}" + + def __call__(self, output, target): + """Compute the criterion from the output and the target. + Args: + output Output tensor from the model + target Expected tensor + Returns: + Computed criterion tensor + """ + return self._crit(output, target) diff --git a/krum/experiments/model.py b/krum/experiments/model.py new file mode 100644 index 0000000..0bef013 --- /dev/null +++ b/krum/experiments/model.py @@ -0,0 +1,421 @@ +### +# @file model.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Model wrappers/helpers. +### + +__all__ = ["Model"] + +import pathlib +import types + +import torch +import torchvision + +from krum import tools + +from .configuration import Configuration + +# ---------------------------------------------------------------------------- # +# Model wrapper class + + +class Model: + """Model wrapper class.""" + + # Map 'lower-case names' -> 'model constructor' available in PyTorch + __models = None + + # Map 'lower-case names' -> 'tensor initializer' available in PyTorch + __inits = None + + @classmethod + def _get_models(cls): + """Lazy-initialize and return the map '__models'. + Returns: + '__models' + """ + # Fast-path already loaded + if cls.__models is not None: + return cls.__models + # Initialize the dictionary + cls.__models = dict() + # Populate this dictionary with TorchVision's models + for name in dir(torchvision.models): + if len(name) == 0 or name[0] == "_": # Ignore "protected" members + continue + builder = getattr(torchvision.models, name) + if isinstance(builder, types.FunctionType): # Heuristic + cls.__models[f"torchvision-{name.lower()}"] = builder + + # Dynamically add the custom models from subdirectory 'models/' + def add_custom_models(name, module, _): + nonlocal cls + # Check if has exports, fallback otherwise + exports = getattr(module, "__all__", None) + if exports is None: + tools.warning( + f"Model module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery" + ) + exports = (name for name in dir(module) if len(name) > 0 and name[0] != "_") + # Register the association 'name -> constructor' for all the models + exported = False + for model in exports: + # Check model name type + if not isinstance(model, str): + tools.warning(f"Model module {name!r} exports non-string name {model!r}; ignored") + continue + # Recover instance from name + constructor = getattr(module, model, None) + # Check instance is callable (it's only an heuristic...) + if not callable(constructor): + continue + # Register callable with composite name + exported = True + fullname = f"{name}-{model}" + if fullname in cls.__models: + tools.warning( + f"Unable to make available model {model!r} from module {name!r}, as the name {fullname!r} already exists" + ) + continue + cls.__models[fullname] = constructor + if not exported: + tools.warning(f"Model module {name!r} does not export any valid constructor name through '__all__'") + + with tools.Context("models", None): + tools.import_directory( + pathlib.Path(__file__).parent / "models", + {"__package__": f"{__package__}.models"}, + post=add_custom_models, + ) + # Return the dictionary + return cls.__models + + @classmethod + def _get_inits(cls): + """Lazy-initialize and return the map '__inits'. + Returns: + '__inits' + """ + # Fast-path already loaded + if cls.__inits is not None: + return cls.__inits + # Initialize the dictionary + cls.__inits = dict() + # Populate this dictionary with PyTorch's initialization functions + for name in dir(torch.nn.init): + if len(name) == 0 or name[0] == "_": # Ignore "protected" members + continue + if name[-1] != "_": # Ignore non-inplace members (heuristic) + continue + func = getattr(torch.nn.init, name) + if isinstance(func, types.FunctionType): # Heuristic + cls.__inits[name[:-1]] = func + # Return the dictionary + return cls.__inits + + def __init__( + self, + name_build, + config=Configuration(), + init_multi=None, + init_multi_args=None, + init_mono=None, + init_mono_args=None, + *args, + **kwargs, + ): + """Model builder constructor. + Args: + name_build Model name or constructor function + config Configuration to use for the parameter tensors + init_multi Weight initialization algorithm name, or initialization function, for tensors of dimension >= 2 + init_multi_args Additional keyword-arguments for 'init', if 'init' specified as a name + init_mono Weight initialization algorithm name, or initialization function, for tensors of dimension == 1 + init_mono_args Additional keyword-arguments for 'init_mono', if 'init_mono' specified as a name + ... Additional (keyword-)arguments forwarded to the constructor + Notes: + If possible, data parallelism is enabled automatically + """ + + def make_init(name, args): + inits = type(self)._get_inits() + func = inits.get(name, None) + if func is None: + raise tools.UnavailableException(inits, name, what="initializer name") + args = dict() if args is None else args + + def init(params): + return func(params, **args) + + return init + + # Recover name/constructor + if callable(name_build): + name = tools.fullqual(name_build) + build = name_build + else: + models = type(self)._get_models() + name = str(name_build) + build = models.get(name, None) + if build is None: + raise tools.UnavailableException(models, name, what="model name") + # Recover initialization algorithms + if isinstance(init_multi, str): + init_multi = make_init(init_multi, init_multi_args) + if isinstance(init_mono, str): + init_mono = make_init(init_mono, init_mono_args) + # Build model + with torch.no_grad(): + model = build(*args, **kwargs) + if not isinstance(model, torch.nn.Module): + raise tools.UserException( + f"Expected built model {name!r} to be an instance of 'torch.nn.Module', found {getattr(type(model), '__name__', '')!r} instead" + ) + # Initialize parameters + for param in model.parameters(): + if len(param.shape) > 1: # Multi-dimensional + if init_multi is not None: + init_multi(param) + else: # Mono-dimensional + if init_mono is not None: + init_mono(param) + # Move parameters to target device + model = model.to(**config) + device = config["device"] + if ( + device.type == "cuda" and device.index is None + ): # Model is on GPU and not explicitly restricted to one particular card => enable data parallelism + model = torch.nn.DataParallel(model) + params = tools.flatten( + model.parameters() + ) # NOTE: Ordering across runs/nodes seems to be ensured (i.e. only dependent on the model constructor) + # Finalization + self._model = model + self._name = name + self._config = config + self._params = params + self._gradient = None + self._defaults = {"trainset": None, "testset": None, "loss": None, "criterion": None, "optimizer": None} + + def __str__(self): + """Compute the "informal", nicely printable string representation of this model. + Returns: + Nicely printable string + """ + return f"model {self._name}" + + @property + def config(self): + """Getter for the immutable configuration. + Returns: + Immutable configuration + """ + return self._config + + def default(self, name, new=None, erase=False): + """Get and/or set the named default. + Args: + name Name of the default + new Optional new instance, set only if not 'None' or erase is 'True' + erase Force the replacement by 'None' + Returns: + (Old) value of the default + """ + # Check existence + if name not in self._defaults: + raise tools.UnavailableException(self._defaults, name, what="model default") + # Get current + old = self._defaults[name] + # Set if needed + if erase or new is not None: + self._defaults[name] = new + # Return current/old + return old + + def _resolve_defaults(self, **kwargs): + """Resolve the given keyword-arguments with the associated default value. + Args: + ... Keyword-arguments, each must have a default if set to None + Returns: + In-order given keyword-arguments, with 'None' values replaced with the corresponding default + """ + res = list() + for name, value in kwargs.items(): + if value is None: + value = self.default(name) + if value is None: + raise RuntimeError(f"Missing default {name}") + res.append(value) + return res + + def run(self, data, training=False): + """Run the model at the current parameters for the given input tensor. + Args: + data Input tensor + training Use training mode instead of testing mode + Returns: + Output tensor + Notes: + Gradient computation is not enable nor disabled during the run. + """ + # Set mode + if training: + self._model.train() + else: + self._model.eval() + # Compute + return self._model(data) + + def __call__(self, *args, **kwargs): + """Forward call to 'run'. + Args: + ... Forwarded (keyword-)arguments + Returns: + Forwarded return value + """ + return self.run(*args, **kwargs) + + def get(self): + """Get a reference to the current parameters. + Returns: + Flat parameter vector (by reference: future calls to 'set' will modify it) + """ + return self._params + + def set(self, params, relink=None): + """Overwrite the parameters with the given ones. + Args: + params Given flat parameter vector + relink Relink instead of copying (depending on the model, might be faster) + """ + # Fast path 'set(get())'-like + if params is self._params: + return + # Assignment + if self._config.relink if relink is None else relink: + tools.relink(self._model.parameters(), params) + self._params = params + else: + self._params.copy_(params, non_blocking=self._config["non_blocking"]) + + def get_gradient(self): + """Get (optionally make each parameter's gradient) a reference to the flat gradient. + Returns: + Flat gradient (by reference: future calls to 'set_gradient' will modify it) + """ + # Fast path + if self._gradient is not None: + return self._gradient + # Flatten (make if necessary) + gradient = tools.flatten(tools.grads_of(self._model.parameters())) + self._gradient = gradient + return gradient + + def set_gradient(self, gradient, relink=None): + """Overwrite the gradient with the given one. + Args: + gradient Given flat gradient + relink Relink instead of copying (depending on the model, might be faster) + """ + # Fast path 'set(get())'-like + if gradient is self._gradient: + return + # Assignment + if self._config.relink if relink is None else relink: + tools.relink(tools.grads_of(self._model.parameters()), gradient) + self._gradient = gradient + else: + self.get_gradient().copy_(gradient, non_blocking=self._config["non_blocking"]) + + def loss(self, dataset=None, loss=None, training=None): + """Estimate loss at the current parameters, with a batch of the given dataset. + Args: + dataset Training dataset wrapper to use, use the default one if available + loss Loss wrapper to use, use the default one if available + training Whether this run is for training (instead of testing) purposes, None for guessing (based on whether gradients are computed) + Returns: + Loss value + """ + # Recover the defaults, if missing + dataset, loss = self._resolve_defaults(trainset=dataset, loss=loss) + # Sample the train batch + inputs, targets = dataset.sample(self._config) + # Guess whether computation is for training, if necessary + if training is None: + training = torch.is_grad_enabled() + # Forward pass + return loss(self.run(inputs), targets, self._params) + + @torch.enable_grad() + def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): + """Estimate gradient at the current parameters, with a batch of the given dataset. + Args: + dataset Training dataset wrapper to use, use the default one if available + loss Loss wrapper to use, use the default one if available + outloss Output the loss value as well + ... Additional keyword-arguments forwarded to 'backprop' + Returns: + if 'outloss' is True: + Tuple of: + · Flat gradient (by reference: future calls to 'backprop' will modify it) + · Loss value + else: + Flat gradient (by reference: future calls to 'backprop' will modify it) + """ + # Detach and zero the gradient (must be done at each grad to discard computation graph) + for param in self._params.linked_tensors: + grad = param.grad + if grad is not None: + grad.detach_() + grad.zero_() + # Forward and backward passes + loss = self.loss(dataset=dataset, loss=loss) + loss.backward(**kwargs) + # Relink needed if graph of derivatives was created + # NOTE: It has been observed that each parameters' grad tensor is a new instance in this case; more investigation may be needed to check whether this relink is really necessary, for now this is a safe behavior + if "create_graph" in kwargs: + self._gradient = None + # Return the flat gradient (and the loss if requested) + if outloss: + return (self.get_gradient(), loss) + else: + return self.get_gradient() + + def update(self, gradient, optimizer=None, relink=None): + """Update the parameters using the given gradient, and the given optimizer. + Args: + gradient Flat gradient to apply + optimizer Optimizer wrapper to use, use the default one if available + relink Relink instead of copying (depending on the model, might be faster) + """ + # Recover the defaults, if missing + optimizer = self._resolve_defaults(optimizer=optimizer)[0] + # Set the gradient + self.set_gradient(gradient, relink=(self._config.relink if relink is None else relink)) + # Perform the update step + optimizer.step() + + @torch.no_grad() + def eval(self, dataset=None, criterion=None): + """Evaluate the model at the current parameters, with a batch of the given dataset. + Args: + dataset Testing dataset wrapper to use, use the default one if available + criterion Criterion wrapper to use, use the default one if available + Returns: + Arithmetic mean of the criterion over the next minibatch + """ + # Recover the defaults, if missing + dataset, criterion = self._resolve_defaults(testset=dataset, criterion=criterion) + # Sample the test batch + inputs, targets = dataset.sample(self._config) + # Compute and return the evaluation result + return criterion(self.run(inputs), targets) diff --git a/krum/experiments/models/simples.py b/krum/experiments/models/simples.py new file mode 100644 index 0000000..18a2348 --- /dev/null +++ b/krum/experiments/models/simples.py @@ -0,0 +1,180 @@ +### +# @file simples.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Collection of simple models. +### + +__all__ = ["full", "conv", "logit", "linear"] + +import torch + +# ---------------------------------------------------------------------------- # +# Simple fully-connected model, for MNIST + + +class _Full(torch.nn.Module): + """Simple, small fully connected model.""" + + def __init__(self): + """Model parameter constructor.""" + super().__init__() + # Build parameters + self._f1 = torch.nn.Linear(28 * 28, 100) + self._f2 = torch.nn.Linear(100, 10) + + def forward(self, x): + """Model's forward pass. + Args: + x Input tensor + Returns: + Output tensor + """ + # Forward pass + x = torch.nn.functional.relu(self._f1(x.view(-1, 28 * 28))) + x = torch.nn.functional.log_softmax(self._f2(x), dim=1) + return x + + +def full(*args, **kwargs): + """Build a new simple, fully connected model. + Args: + ... Forwarded (keyword-)arguments + Returns: + Fully connected model + """ + global _Full + return _Full(*args, **kwargs) + + +# ---------------------------------------------------------------------------- # +# Simple convolutional model, for MNIST + + +class _Conv(torch.nn.Module): + """Simple, small convolutional model.""" + + def __init__(self): + """Model parameter constructor.""" + super().__init__() + # Build parameters + self._c1 = torch.nn.Conv2d(1, 20, 5, 1) + self._c2 = torch.nn.Conv2d(20, 50, 5, 1) + self._f1 = torch.nn.Linear(800, 500) + self._f2 = torch.nn.Linear(500, 10) + + def forward(self, x): + """Model's forward pass. + Args: + x Input tensor + Returns: + Output tensor + """ + # Forward pass + x = torch.nn.functional.relu(self._c1(x)) + x = torch.nn.functional.max_pool2d(x, 2, 2) + x = torch.nn.functional.relu(self._c2(x)) + x = torch.nn.functional.max_pool2d(x, 2, 2) + x = torch.nn.functional.relu(self._f1(x.view(-1, 800))) + x = torch.nn.functional.log_softmax(self._f2(x), dim=1) + return x + + +def conv(*args, **kwargs): + """Build a new simple, convolutional model. + Args: + ... Forwarded (keyword-)arguments + Returns: + Convolutional model + """ + global _Conv + return _Conv(*args, **kwargs) + + +# ---------------------------------------------------------------------------- # +# Simple(r) logistic regression model + + +class _Logit(torch.nn.Module): + """Simple logistic regression model.""" + + def __init__(self, din, dout=1): + """Model parameter constructor. + Args: + din Number of input dimensions + dout Number of output dimensions + """ + super().__init__() + # Store model parameters + self._din = din + self._dout = dout + # Build parameters + self._linear = torch.nn.Linear(din, dout) + + def forward(self, x): + """Model's forward pass. + Args: + x Input tensor + Returns: + Output tensor + """ + return torch.sigmoid(self._linear(x.view(-1, self._din))) + + +def logit(*args, **kwargs): + """Build a new simple, fully connected model. + Args: + ... Forwarded (keyword-)arguments + Returns: + Fully connected model + """ + global _Logit + return _Logit(*args, **kwargs) + + +# ---------------------------------------------------------------------------- # +# Simple(st) linear model + + +class _Linear(torch.nn.Module): + """Simple linear model.""" + + def __init__(self, din, dout=1): + """Model parameter constructor. + Args: + din Number of input dimensions + dout Number of output dimensions + """ + super().__init__() + # Store model parameters + self._din = din + self._dout = dout + # Build parameters + self._linear = torch.nn.Linear(din, dout) + + def forward(self, x): + """Model's forward pass. + Args: + x Input tensor + Returns: + Output tensor + """ + return self._linear(x.view(-1, self._din)) + + +def linear(*args, **kwargs): + """Build a new simple, fully connected model. + Args: + ... Forwarded (keyword-)arguments + Returns: + Fully connected model + """ + global _Linear + return _Linear(*args, **kwargs) diff --git a/krum/experiments/optimizer.py b/krum/experiments/optimizer.py new file mode 100644 index 0000000..79b5af9 --- /dev/null +++ b/krum/experiments/optimizer.py @@ -0,0 +1,106 @@ +### +# @file optimizer.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Optimizer wrapper. +### + +__all__ = ["Optimizer"] + +import torch + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Optimizer wrapper class + + +class Optimizer: + """Optimizer wrapper class.""" + + # Map 'lower-case names' -> 'optimizer constructor' available in PyTorch + __optimizers = None + + @classmethod + def _get_optimizers(cls): + """Lazy-initialize and return the map '__optimizers'. + Returns: + '__optimizers' + """ + # Fast-path already loaded + if cls.__optimizers is not None: + return cls.__optimizers + # Initialize the dictionary + cls.__optimizers = dict() + # Simply populate this dictionary + for name in dir(torch.optim): + if len(name) == 0 or name[0] == "_": # Ignore "protected" members + continue + builder = getattr(torch.optim, name) + if ( + isinstance(builder, type) + and builder is not torch.optim.Optimizer + and issubclass(builder, torch.optim.Optimizer) + ): + cls.__optimizers[name.lower()] = builder + # Return the dictionary + return cls.__optimizers + + def __init__(self, name_build, model, *args, **kwargs): + """Optimizer constructor. + Args: + name_build Optimizer name or constructor function + model Model to optimize + ... Additional (keyword-)arguments forwarded to the constructor + """ + # Recover name/constructor + if callable(name_build): + name = tools.fullqual(name_build) + build = name_build + else: + optims = type(self)._get_optimizers() + name = str(name_build) + build = optims.get(name, None) + if build is None: + raise tools.UnavailableException(optims, name, what="optimizer name") + # Build optimizer + optim = build(model._model.parameters(), *args, **kwargs) + # Finalization + self._optim = optim + self._name = name + + def __getattr__(self, *args): + """Get attribute on the optimizer instance. + Args: + name Name of the attribute to get + default Default value returned if the attribute does not exist + Returns: + Forwarded attribute + """ + if len(args) == 1: + return getattr(self._optim, args[0]) + if len(args) == 2: + return getattr(self._optim, args[0], args[1]) + raise RuntimeError("'Optimizer.__getattr__' called with the wrong number of parameters") + + def __str__(self): + """Compute the "informal", nicely printable string representation of this optimizer. + Returns: + Nicely printable string + """ + return f"optimizer {self._name}" + + def set_lr(self, lr): + """Set the learning rate of the optimizer + Args: + lr Learning rate to set (for each parameter group) + """ + for pg in self._optim.param_groups: + pg["lr"] = lr diff --git a/native/.gitignore b/krum/native/.gitignore similarity index 100% rename from native/.gitignore rename to krum/native/.gitignore diff --git a/native/README.md b/krum/native/README.md similarity index 100% rename from native/README.md rename to krum/native/README.md diff --git a/krum/native/__init__.py b/krum/native/__init__.py new file mode 100644 index 0000000..3abba7a --- /dev/null +++ b/krum/native/__init__.py @@ -0,0 +1,207 @@ +### +# @file __init__.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2019 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Native (i.e. C++/CUDA) implementations automated building and loading. +### + +# ---------------------------------------------------------------------------- # +# Initialization procedure + + +def _build_and_load(): + """Incrementally rebuild all libraries and bind all local modules in the global.""" + glob = globals() + # Standard imports + import os + import pathlib + import traceback + import warnings + + # External imports + import torch + import torch.utils.cpp_extension + + # Internal imports + from krum import tools + + # Constants + base_directory = pathlib.Path(__file__).parent.resolve() + dependencies_file = ".deps" + debug_mode_envname = "NATIVE_OPT" + debug_mode_in_env = debug_mode_envname in os.environ + if debug_mode_in_env: + raw = os.environ[debug_mode_envname] + value = raw.lower() + if value in ["0", "n", "no", "false"]: + debug_mode = True + elif value in ["1", "y", "yes", "true"]: + debug_mode = False + else: + tools.fatal( + "{!r} defined in the environment, but with unexpected soft-boolean {!r}".format( + debug_mode_envname, f"{debug_mode_envname}={raw}" + ) + ) + else: + debug_mode = __debug__ + cpp_std_envname = "NATIVE_STD" + cpp_std = os.environ.get(cpp_std_envname, "c++14") + ident_to_is_python = {"so_": False, "py_": True} + source_suffixes = {".cpp", ".cc", ".C", ".cxx", ".c++"} + extra_cflags = ["-Wall", "-Wextra", "-Wfatal-errors", f"-std={cpp_std}"] + if torch.cuda.is_available(): + source_suffixes.update(set((".cu" + suffix) for suffix in source_suffixes)) + source_suffixes.add(".cu") + extra_cflags.append("-DTORCH_CUDA_AVAILABLE") + extra_cuda_cflags = ["-DTORCH_CUDA_AVAILABLE", "--expt-relaxed-constexpr", f"-std={cpp_std}"] + extra_ldflags = ["-Wl,-L" + base_directory.root] + extra_include_path = base_directory / "include" + try: + extra_include_paths = [str(extra_include_path.resolve())] + except Exception: + extra_include_paths = None + warnings.warn("Not found include directory: " + repr(str(extra_include_path))) + # Print configuration information + cpp_std_message = "Native modules compiled with {} standard; (re)define {!r} in the environment to compile with another standard".format( + cpp_std, f"{cpp_std_envname}=" + ) + if debug_mode: + tools.warning(cpp_std_message) + tools.warning( + "Native modules compiled in debug mode; {}define {!r} in the environment or{} run python with -O/-OO options to compile in release mode".format( + "re" if debug_mode_in_env else "", + f"{debug_mode_envname}=1", + " undefine it and" if debug_mode_in_env else "", + ) + ) + extra_cflags += ["-O0", "-g"] + else: + quiet_envname = "NATIVE_QUIET" + if quiet_envname not in os.environ: + tools.trace(cpp_std_message) + tools.trace( + "Native modules compiled in release mode; {}define {!r} in the environment or{} run python without -O/-OO options to compile in debug mode".format( + "re" if debug_mode_in_env else "", + f"{debug_mode_envname}=0", + " undefine it and" if debug_mode_in_env else "", + ) + ) + tools.trace(f"Define {quiet_envname!r} in the environment to hide these messages in release mode") + extra_cflags += ["-O3", "-DNDEBUG"] + # Variables + done_modules = [] + fail_modules = [] + + # Local procedures + def build_and_load_one(path, deps=[]): + """Check if the given directory is a module to build and load, and if yes recursively build and load its dependencies before it. + Args: + path Given directory path + deps Dependent module paths + Returns: + True on success, False on failure, None if not a module + """ + nonlocal done_modules + nonlocal fail_modules + with tools.Context(path.name, "info"): + ident = path.name[:3] + if ident in ident_to_is_python.keys(): + # Is a module directory + if len(path.name) <= 3 or path.name[3] == "_": + tools.warning("Skipped invalid module directory name " + repr(path.name)) + return None + if not path.exists(): + tools.warning("Unable to build and load " + repr(str(path.name)) + ": module does not exist") + fail_modules.append(path) # Mark as failed + return False + is_python_module = ident_to_is_python[ident] + # Check if already built and loaded, or failed + if path in done_modules: + if len(deps) == 0 and debug_mode: + tools.info("Already built and loaded " + repr(str(path.name))) + return True + if path in fail_modules: + if len(deps) == 0: + tools.warning("Was unable to build and load " + repr(str(path.name))) + return False + # Check for dependency cycle (disallowed as they may mess with the linker) + if path in deps: + tools.warning("Unable to build and load " + repr(str(path.name)) + ": dependency cycle found") + fail_modules.append(path) # Mark as failed + return False + # Build and load dependencies + this_ldflags = list(extra_ldflags) + depsfile = path / dependencies_file + if depsfile.exists(): + for modname in depsfile.read_text().splitlines(): + res = build_and_load_one(base_directory / modname, deps + [path]) + if not res: # Unable to build a dependency + if len(deps) == 0: + tools.warning( + "Unable to build and load " + + repr(str(path.name)) + + ": dependency " + + repr(modname) + + " build and load failed" + ) + fail_modules.append(path) # Mark as failed + return False + elif res: # Module and its sub-dependencies was/were built and loaded successfully + this_ldflags.append( + "-Wl,--library=:" + str((base_directory / modname / (modname + ".so")).resolve()) + ) + # List sources + sources = [] + for subpath in path.iterdir(): + if subpath.is_file() and ("").join(subpath.suffixes) in source_suffixes: + sources.append(str(subpath)) + # Build and load this module + try: + res = torch.utils.cpp_extension.load( + name=path.name, + sources=sources, + extra_cflags=extra_cflags, + extra_cuda_cflags=extra_cuda_cflags, + extra_ldflags=this_ldflags, + extra_include_paths=extra_include_paths, + build_directory=str(path), + verbose=debug_mode, + is_python_module=is_python_module, + ) + if is_python_module: + glob[path.name[3:]] = res + except Exception as err: + tools.warning("Unable to build and load " + repr(str(path.name)) + ": " + str(err)) + fail_modules.append(path) # Mark as failed + return False + done_modules.append(path) # Mark as built and loaded + return True + + # Main loop + for path in base_directory.iterdir(): + if path.is_dir(): + try: + build_and_load_one(path) + except Exception as err: + tools.warning("Exception while processing " + repr(str(path)) + ": " + str(err)) + with tools.Context("traceback", "trace"): + traceback.print_exc() + + +# ---------------------------------------------------------------------------- # +# Initialization + +from krum import tools as _tools + +with _tools.Context("native", None): + _build_and_load() +del _tools +del _build_and_load diff --git a/native/include/aggregator.hpp b/krum/native/include/aggregator.hpp similarity index 100% rename from native/include/aggregator.hpp rename to krum/native/include/aggregator.hpp diff --git a/native/include/array.hpp b/krum/native/include/array.hpp similarity index 100% rename from native/include/array.hpp rename to krum/native/include/array.hpp diff --git a/native/include/combinations.hpp b/krum/native/include/combinations.hpp similarity index 100% rename from native/include/combinations.hpp rename to krum/native/include/combinations.hpp diff --git a/native/include/common.hpp b/krum/native/include/common.hpp similarity index 100% rename from native/include/common.hpp rename to krum/native/include/common.hpp diff --git a/native/include/constexpr.hpp b/krum/native/include/constexpr.hpp similarity index 100% rename from native/include/constexpr.hpp rename to krum/native/include/constexpr.hpp diff --git a/native/include/cub/.placeholder b/krum/native/include/cub/.placeholder similarity index 100% rename from native/include/cub/.placeholder rename to krum/native/include/cub/.placeholder diff --git a/native/include/cudarray.cu.hpp b/krum/native/include/cudarray.cu.hpp similarity index 100% rename from native/include/cudarray.cu.hpp rename to krum/native/include/cudarray.cu.hpp diff --git a/native/include/exception.hpp b/krum/native/include/exception.hpp similarity index 100% rename from native/include/exception.hpp rename to krum/native/include/exception.hpp diff --git a/native/include/operations.cu.hpp b/krum/native/include/operations.cu.hpp similarity index 100% rename from native/include/operations.cu.hpp rename to krum/native/include/operations.cu.hpp diff --git a/native/include/operations.hpp b/krum/native/include/operations.hpp similarity index 100% rename from native/include/operations.hpp rename to krum/native/include/operations.hpp diff --git a/native/include/optional.hpp b/krum/native/include/optional.hpp similarity index 100% rename from native/include/optional.hpp rename to krum/native/include/optional.hpp diff --git a/native/include/pytorch.hpp b/krum/native/include/pytorch.hpp similarity index 100% rename from native/include/pytorch.hpp rename to krum/native/include/pytorch.hpp diff --git a/native/include/string_view.hpp b/krum/native/include/string_view.hpp similarity index 100% rename from native/include/string_view.hpp rename to krum/native/include/string_view.hpp diff --git a/native/include/threadpool.hpp b/krum/native/include/threadpool.hpp similarity index 100% rename from native/include/threadpool.hpp rename to krum/native/include/threadpool.hpp diff --git a/native/py_brute/.deps b/krum/native/py_brute/.deps similarity index 100% rename from native/py_brute/.deps rename to krum/native/py_brute/.deps diff --git a/native/py_brute/brute.cpp b/krum/native/py_brute/brute.cpp similarity index 100% rename from native/py_brute/brute.cpp rename to krum/native/py_brute/brute.cpp diff --git a/native/py_brute/brute.cu b/krum/native/py_brute/brute.cu similarity index 100% rename from native/py_brute/brute.cu rename to krum/native/py_brute/brute.cu diff --git a/native/py_brute/rule.cpp b/krum/native/py_brute/rule.cpp similarity index 100% rename from native/py_brute/rule.cpp rename to krum/native/py_brute/rule.cpp diff --git a/native/py_brute/rule.hpp b/krum/native/py_brute/rule.hpp similarity index 100% rename from native/py_brute/rule.hpp rename to krum/native/py_brute/rule.hpp diff --git a/native/py_bulyan/.deps b/krum/native/py_bulyan/.deps similarity index 100% rename from native/py_bulyan/.deps rename to krum/native/py_bulyan/.deps diff --git a/native/py_bulyan/bulyan.cpp b/krum/native/py_bulyan/bulyan.cpp similarity index 100% rename from native/py_bulyan/bulyan.cpp rename to krum/native/py_bulyan/bulyan.cpp diff --git a/native/py_bulyan/bulyan.cu b/krum/native/py_bulyan/bulyan.cu similarity index 100% rename from native/py_bulyan/bulyan.cu rename to krum/native/py_bulyan/bulyan.cu diff --git a/native/py_bulyan/rule.cpp b/krum/native/py_bulyan/rule.cpp similarity index 100% rename from native/py_bulyan/rule.cpp rename to krum/native/py_bulyan/rule.cpp diff --git a/native/py_bulyan/rule.hpp b/krum/native/py_bulyan/rule.hpp similarity index 100% rename from native/py_bulyan/rule.hpp rename to krum/native/py_bulyan/rule.hpp diff --git a/native/py_krum/.deps b/krum/native/py_krum/.deps similarity index 100% rename from native/py_krum/.deps rename to krum/native/py_krum/.deps diff --git a/native/py_krum/krum.cpp b/krum/native/py_krum/krum.cpp similarity index 100% rename from native/py_krum/krum.cpp rename to krum/native/py_krum/krum.cpp diff --git a/native/py_krum/krum.cu b/krum/native/py_krum/krum.cu similarity index 100% rename from native/py_krum/krum.cu rename to krum/native/py_krum/krum.cu diff --git a/native/py_krum/rule.cpp b/krum/native/py_krum/rule.cpp similarity index 100% rename from native/py_krum/rule.cpp rename to krum/native/py_krum/rule.cpp diff --git a/native/py_krum/rule.hpp b/krum/native/py_krum/rule.hpp similarity index 100% rename from native/py_krum/rule.hpp rename to krum/native/py_krum/rule.hpp diff --git a/native/py_median/.deps b/krum/native/py_median/.deps similarity index 100% rename from native/py_median/.deps rename to krum/native/py_median/.deps diff --git a/native/py_median/median.cpp b/krum/native/py_median/median.cpp similarity index 100% rename from native/py_median/median.cpp rename to krum/native/py_median/median.cpp diff --git a/native/py_median/median.cu b/krum/native/py_median/median.cu similarity index 100% rename from native/py_median/median.cu rename to krum/native/py_median/median.cu diff --git a/native/py_median/rule.cpp b/krum/native/py_median/rule.cpp similarity index 100% rename from native/py_median/rule.cpp rename to krum/native/py_median/rule.cpp diff --git a/native/py_median/rule.hpp b/krum/native/py_median/rule.hpp similarity index 100% rename from native/py_median/rule.hpp rename to krum/native/py_median/rule.hpp diff --git a/native/so_threadpool/.deps b/krum/native/so_threadpool/.deps similarity index 100% rename from native/so_threadpool/.deps rename to krum/native/so_threadpool/.deps diff --git a/native/so_threadpool/threadpool.cpp b/krum/native/so_threadpool/threadpool.cpp similarity index 100% rename from native/so_threadpool/threadpool.cpp rename to krum/native/so_threadpool/threadpool.cpp diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py new file mode 100644 index 0000000..c2bdb9f --- /dev/null +++ b/krum/tools/__init__.py @@ -0,0 +1,327 @@ +### +# @file __init__.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Bunch of useful tools, but each too small to have its own package. +### + +import os +import pathlib +import sys +import threading +import traceback + +# ---------------------------------------------------------------------------- # +# User exception base class, print string representation and exit(1) on uncaught + + +class UserException(Exception): + """User exception base class.""" + + pass + + +# ---------------------------------------------------------------------------- # +# Context and color management + + +class Context: + """Per-thread context and color management static class.""" + + # Constants + __colors = { + "header": "\033[1;30m", + "red": "\033[1;31m", + "error": "\033[1;31m", + "green": "\033[1;32m", + "success": "\033[1;32m", + "yellow": "\033[1;33m", + "warning": "\033[1;33m", + "blue": "\033[1;34m", + "info": "\033[1;34m", + "gray": "\033[1;30m", + "trace": "\033[1;30m", + } + __clrend = "\033[0m" + + # Thread-local variables + __local = threading.local() + + @classmethod + def __local_init(cls): + """Initialize the thread local data if necessary.""" + if not hasattr(cls.__local, "stack"): + cls.__local.stack = [] # List of pairs (context name, color code) + cls.__local.header = "" # Current header string + cls.__local.color = cls.__clrend # Current color code + + @classmethod + def __rebuild(cls): + """Rebuild the header and apply the current color.""" + # Collect current header and color + header = "" + color = None + for ctx, clr in reversed(cls.__local.stack): + if ctx is not None: + header = "[" + ctx + "] " + header + if clr is not None: + if color is None: + color = clr + if color is None: + color = cls.__clrend + # Prepend thread name if not main thread + cthrd = threading.current_thread() + if cthrd != threading.main_thread(): + header = "[" + cthrd.name + "] " + header + # Store the new header and color + cls.__local.header = header + cls.__local.color = color + + @classmethod + def _get(cls): + """Get the thread-local header and color. + Returns: + Current header, begin header color, begin color, ending color + """ + cls.__local_init() + return cls.__local.header, cls.__colors["header"], cls.__local.color, cls.__clrend + + def __init__(self, cntxtname, colorname): + """Color selection constructor. + Args: + cntxtname Context name (None for none) + colorname Color name (None for no change) + """ + # Color code resolution + if colorname is None: + colorcode = None + else: + assert colorname in type(self).__colors, "Unknown color name " + repr(colorname) + colorcode = type(self).__colors[colorname] + # Finalization + self.__pair = (cntxtname, colorcode) + + def __enter__(self): + """Enter context. + Returns: + self + """ + type(self).__local_init() + type(self).__local.stack.append(self.__pair) + type(self).__rebuild() + return self + + def __exit__(self, *args, **kwargs): + """Leave context. + Args: + ... Ignored arguments + """ + type(self).__local.stack.pop() + type(self).__rebuild() + + +class ContextIOWrapper: + """Context-aware text IO wrapper class.""" + + def __init__(self, output, nocolor=None): + """New line no color assumed constructor. + Args: + output Wrapped output + nocolor Whether to apply colors or not (if None, no color for non-TTY) + """ + # Check whether to apply coloring if unset + if nocolor is None: + nocolor = not output.isatty() + # Finalization + self.__newline = True # At a new line + self.__colored = True # Color has been applied + self.__output = output + self.__nocolor = nocolor + + def __getattr__(self, name): + """Forward non-overloaded attributes. + Args: + name Non-overloaded attribute name + Returns: + Non-overloaded attribute + """ + return getattr(self.__output, name) + + def write(self, text): + """Wrap the given text with the context if necessary. + Args: + text Text to update and write + Returns: + Forwarded value + """ + # Get the current context + header, clrheader, clrbegin, clrend = Context._get() + if self.__nocolor: + clrheader = "" + clrbegin = "" + clrend = "" + # Prepend the header to every line + lines = text.splitlines(True) + text = "" + for line in lines: + if self.__newline: + text += clrheader + header + text += clrbegin + self.__newline = True + text += line + if len(lines) > 0 and lines[-1][-len(os.linesep) :] != os.linesep: + self.__newline = False + # Write the modified text with the right color + return self.__output.write(text + clrend) + + +def _make_color_print(color): + """Build the closure that wrap a 'print' inside a colored context. + Args: + color Target color name + Returns: + Print wrapper closure + """ + + def color_print(*args, context=None, **kwargs): + """Print in 'color'. + Args: + context Context name to use + ... Forwarded arguments + Returns: + Forwarded return value + """ + with Context(context, color): + return print(*args, **kwargs) + + return color_print + + +# Shortcut for colored print +for color in ["trace", "info", "success", "warning", "error"]: + globals()[color] = _make_color_print(color) + + +def fatal(*args, with_traceback=False, **kwargs): + """Error colored print that calls 'exit(1)' instead of returning. + Args: + with_traceback Include a traceback after the message + ... Forwarded arguments + """ + global error + error(*args, **kwargs) + if with_traceback: + with Context("traceback", "trace"): + traceback.print_exc() + exit(1) + + +# Wrap the standard text output wrappers +sys.stdout = ContextIOWrapper(sys.stdout) +sys.stderr = ContextIOWrapper(sys.stderr) + +# ---------------------------------------------------------------------------- # +# Uncaught exception context wrapping + + +def uncaught_wrap(hook): + """Wrap an uncaught hook with a context. + Args: + hook Uncaught hook to wrap + Returns: + Wrapped uncaught hook + """ + + def uncaught_call(etype, evalue, traceback): + """Update context, check if user exception or forward-call. + Args: + etype Exception class + evalue Exception value + traceback Traceback at the exception + Returns: + Forwarded value + """ + if issubclass(etype, UserException): + with Context("fatal", "error"): + print(evalue) + else: + with Context("uncaught", "error"): + return hook(etype, evalue, traceback) + + return uncaught_call + + +# Wrap the original exception hook +sys.excepthook = uncaught_wrap(sys.excepthook) + +# ---------------------------------------------------------------------------- # +# Local module loading and post-processing + +_imported = dict() # Map symbol name -> module source name + + +def import_exported_symbols(name, module, scope): + """Import the exported objects of the loaded module into the given scope. + Args: + name Module name + module Module instance + scope Target scope + """ + global _imported + if hasattr(module, "__all__"): + for symname in getattr(module, "__all__"): + # Check name + if not hasattr(module, symname): + with Context(None, "warning"): + print("Symbol " + repr(symname) + " exported but not defined") + continue + if symname in _imported: + with Context(None, "warning"): + print("Symbol " + repr(symname) + " already exported by " + repr(_imported[symname])) + continue + if symname in scope: + with Context(None, "warning"): + print("Symbol " + repr(symname) + " already exported by '__init__.py'") + continue + # Import in module scope + scope[symname] = getattr(module, symname) + _imported[symname] = name + + +def import_directory(dirpath, scope, post=import_exported_symbols, ignore=["__init__"]): + """Import every module from the given directory in the given scope. + Args: + dirpath Directory path + scope Target scope + post Post module import function (name, module, scope) -> None + ignore List of module names to ignore + """ + # Import in the scope of the caller + for path in dirpath.iterdir(): + if path.is_file() and path.suffix == ".py": + name = path.stem + if "." in name or name in ignore: + continue + with Context(name, None): + try: + # Load module + base = __import__(scope["__package__"], scope, scope, [name], 0) + # Post processing + if callable(post): + post(name, getattr(base, name), scope) + except Exception as err: + with Context(None, "warning"): + print("Loading failed for module " + repr(path.name) + ": " + str(err)) + with Context("traceback", "trace"): + traceback.print_exc() + + +with Context("tools", None): + import_directory(pathlib.Path(__file__).parent, globals()) diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py new file mode 100644 index 0000000..5cfd3cf --- /dev/null +++ b/krum/tools/jobs.py @@ -0,0 +1,252 @@ +### +# @file jobs.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2020-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Simple job management for reproduction scripts. +### + +__all__ = ["dict_to_cmdlist", "Command", "Jobs"] + +import shlex +import subprocess +import threading + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Helpers + + +def move_directory(path): + """Move existing directory to a new location (with a numbering scheme). + Args: + path Path to the directory to create + Returns: + 'path' (to enable chaining) + """ + # Move directory if it exists + if path.exists(): + if not path.is_dir(): + raise RuntimeError(f"Expected to find nothing or (a symlink to) a directory at {str(path)!r}") + i = 0 + while True: + mvpath = path.parent / f"{path.name}.{i}" + if not mvpath.exists(): + path.rename(mvpath) + break + i += 1 + # Enable chaining + return path + + +def dict_to_cmdlist(dp): + """Transform a dictionary into a list of command arguments. + Args: + dp Dictionary mapping parameter name (to prepend with "--") to parameter value (to convert to string) + Returns: + Associated list of command arguments + Notes: + For entries mapping to 'bool', the parameter is included/discarded depending on whether the value is True/False + For entries mapping to 'list' or 'tuple', the parameter is followed by all the values as strings + """ + cmd = list() + for name, value in dp.items(): + if isinstance(value, bool): + if value: + cmd.append(f"--{name}") + else: + if any(isinstance(value, typ) for typ in (list, tuple)): + cmd.append(f"--{name}") + for subval in value: + cmd.append(str(subval)) + elif value is not None: + cmd.append(f"--{name}") + cmd.append(str(value)) + return cmd + + +# ---------------------------------------------------------------------------- # +# Job command class + + +class Command: + """Simple job command class, that builds a command from a dictionary of parameters.""" + + def __init__(self, command): + """Bind constructor. + Args: + command Command iterable (will be copied) + """ + self._basecmd = list(command) + + def build(self, seed, device, resdir): + """Build the final command line. + Args: + seed Seed to use + device Device to use + resdir Target directory path + Returns: + Final command list + """ + # Build final command list + cmd = self._basecmd.copy() + for name, value in (("seed", seed), ("device", device), ("result-directory", resdir)): + cmd.append(f"--{name}") + cmd.append(shlex.quote(value if isinstance(value, str) else str(value))) + # Return final command list + return cmd + + +# ---------------------------------------------------------------------------- # +# Job class + + +class Jobs: + """Take experiments to run and runs them on the available devices, managing repetitions.""" + + @staticmethod + def _run(topdir, name, seed, device, command): + """Run the attack experiments with the given named parameters. + Args: + topdir Parent result directory + name Experiment unique name + seed Experiment seed + device Device on which to run the experiments + command Command to run + """ + # Add seed to name + name = f"{name}-{seed}" + # Process experiment + with tools.Context(name, "info"): + finaldir = topdir / name + # Check whether the experiment was already successful + if finaldir.exists(): + tools.info("Experiment already processed.") + return + # Move-make the pending result directory + resdir = move_directory(topdir / f"{name}.pending") + resdir.mkdir(mode=0o755, parents=True) + # Build the command + args = command.build(seed, device, resdir) + # Launch the experiment and write the standard output/error + tools.trace((" ").join(shlex.quote(arg) for arg in args)) + cmd_res = subprocess.run(args, capture_output=True) + if cmd_res.returncode == 0: + tools.info("Experiment successful") + else: + tools.warning("Experiment failed") + finaldir = topdir / f"{name}.failed" + move_directory(finaldir) + resdir.rename(finaldir) + (finaldir / "stdout.log").write_bytes(cmd_res.stdout) + (finaldir / "stderr.log").write_bytes(cmd_res.stderr) + + def _worker_entrypoint(self, device): + """Worker entry point. + Args: + device Device to use + """ + while True: + # Take a pending experiment, or exit if requested + with self._lock: + while True: + # Check if must exit + if self._jobs is None: + return + # Check and pick the first pending experiment, if available + if len(self._jobs) > 0: + name, seed, command = self._jobs.pop() + break + # Wait for new job notification + self._cvready.wait() + # Run the picked experiment + self._run(self._res_dir, name, seed, device, command) + + def __init__(self, res_dir, devices=["cpu"], devmult=1, seeds=tuple(range(1, 6))): + """Initialize the instance, launch the worker pool. + Args: + res_dir Path to the directory containing the result sub-directories + devices List/tuple of the devices to use in parallel + devmult How many experiments are run in parallel per device + seeds List/tuple of seeds to repeat the experiments with + """ + # Initialize instance + self._res_dir = res_dir + self._jobs = list() # List of tuples (name, seed, command), or None to signal termination + self._workers = list() # Worker pool, one per target device + self._devices = devices + self._seeds = seeds + self._lock = threading.Lock() + self._cvready = threading.Condition( + lock=self._lock + ) # Signal jobs have been added and must be processed, or the worker must quit + self._cvdone = threading.Condition(lock=self._lock) # Signal jobs have all been processed + # Launch the worker pool + for _ in range(devmult): + for device in devices: + thread = threading.Thread(target=self._worker_entrypoint, name=device, args=(device,)) + thread.start() + self._workers.append(thread) + + def get_seeds(self): + """Get the list of seeds used for repeating the experiments. + Returns: + List/tuple of seeds used + """ + return self._seeds + + def close(self): + """Close and wait for the worker pool, discarding not yet started submission.""" + # Close the manager + with self._lock: + # Check if already closed + if self._jobs is None: + return + # Reset submission list + self._jobs = None + # Notify all the workers + self._cvready.notify_all() + # Wait for all the workers + for worker in self._workers: + worker.join() + + def submit(self, name, command): + """Submit an experiment to be run with each seed on any available device. + Args: + name Experiment unique name + command Command to process + """ + with self._lock: + # Check if not closed + if self._jobs is None: + raise RuntimeError("Experiment manager cannot take new jobs as it has been closed") + # Submit the experiment with each seed + for seed in self._seeds: + self._jobs.insert(0, (name, seed, command)) + self._cvready.notify(n=len(self._seeds)) + + def wait(self, predicate=None): + """Wait for all the submitted jobs to be processed. + Args: + predicate Custom predicate to call to check whether must stop waiting + """ + while True: + with self._lock: + # Wait for condition or timeout + self._cvdone.wait(timeout=1.0) + # Check status + if self._jobs is None: + break + if len(self._jobs) == 0: + break + if not any(worker.is_alive() for worker in self._workers): + break + if predicate is not None and predicate(): + break diff --git a/krum/tools/misc.py b/krum/tools/misc.py new file mode 100644 index 0000000..953005e --- /dev/null +++ b/krum/tools/misc.py @@ -0,0 +1,630 @@ +### +# @file misc.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Miscellaneous Python helpers. +### + +__all__ = [ + "UnavailableException", + "fatal_unavailable", + "MethodCallReplicator", + "ClassRegister", + "parse_keyval", + "fullqual", + "onetime", + "TimedContext", + "interactive", + "get_loaded_dependencies", + "line_maximize", + "pairwise", + "localtime", + "deltatime_point", + "deltatime_format", +] + +import os +import pathlib +import site +import sys +import threading +import time +import traceback + +from krum import tools + +# ---------------------------------------------------------------------------- # +# Unavailable user exception class + + +def make_unavailable_exception_text(data, name, what="entry"): + """Make the explanatory string for an 'UnavailableException'. + Args: + data Iterable (over str) data set + name Requested name in the data set + what Textual description of what are the objects in the data set + """ + # Preparation + if len(data) == 0: + end = f"no {what} available" + else: + sep = f"{os.linesep}· " + end = f"expected one of:{sep}{sep.join(data)}" + # Final string cat + return f"Unknown {what} {name!r}, {end}" + + +def fatal_unavailable(*args, **kwargs): + """Helper forwarding the 'UnavailableException' explanatory string to 'fatal'. + Args: + ... Forward (keyword-)arguments to 'make_unavailable_exception_text' + """ + tools.fatal(make_unavailable_exception_text(*args, **kwargs)) + + +class UnavailableException(tools.UserException): + """Exception due to missing entry in a dictionary, where the entry is controlled by the user.""" + + def __init__(self, *args, **kwargs): + """Error string constructor. + Args: + ... Forward (keyword-)arguments to 'make_unavailable_exception_text' + """ + # Finalization + self._text = make_unavailable_exception_text(*args, **kwargs) + + def __str__(self): + """Error to string conversion. + Returns: + Explanatory string + """ + return self._text + + +# ---------------------------------------------------------------------------- # +# Simple method call replicator + + +class MethodCallReplicator: + """Simple method call replicator class.""" + + def __init__(self, *args): + """Bind constructor. + Args: + ... Instance on which to replicate method calls (in the given order) + """ + # Assertions + assert len(args) > 0, "Expected at least one instance on which to forward method calls" + # Finalization + self.__instances = args + + def __getattr__(self, name): + """Returns a closure that replicate the method call. + Args: + name Name of the method + Returns: + Closure replicating the calls + """ + # Target closures + closures = [getattr(instance, name) for instance in self.__instances] + + # Replication closure + def calls(*args, **kwargs): + """Simply replicate the calls, forwarding arguments. + Args: + ... Forwarded arguments + Returns: + List of returned values + """ + return [closure(*args, **kwargs) for closure in closures] + + # Build the replication closure + return calls + + +# ---------------------------------------------------------------------------- # +# Simple class register + + +class ClassRegister: + """Simple class register.""" + + def __init__(self, singular, optplural=None): + """Denomination constructor. + Args: + singular Singular denomination of the registered class + optplural "Optional plural", e.g. "class(es)" for "class" (optional) + """ + # Value deduction + if optplural is None: + optplural = singular + "(s)" + # Finalization + self.__denoms = (singular, optplural) + self.__register = {} + + def itemize(self): + """Build an iterable over the available class names. + Returns: + Iterable over the available class names + """ + return self.__register.keys() + + def register(self, name, cls): + """Register a new class. + Args: + name Class name + cls Associated class + """ + # Assertions + assert name not in self.__register, ( + "Name " + + repr(name) + + " already in use while registering " + + repr(getattr(cls, "__name__", "")) + ) + # Registering + self.__register[name] = cls + + def instantiate(self, name, *args, **kwargs): + """Instantiate a registered class. + Args: + name Class name + ... Forwarded parameters + Returns: + Registered class instance + """ + # Assertions + if name not in self.__register: + cause = "Unknown name " + repr(name) + ", " + if len(self.__register) == 0: + cause += "no registered " + self.__denoms[0] + else: + cause += "available " + self.__denoms[1] + ": '" + ("', '").join(self.__register.keys()) + "'" + raise tools.UserException(cause) + # Instantiation + return self.__register[name](*args, **kwargs) + + +# ---------------------------------------------------------------------------- # +# Simple list of ":" into dictionary parser + + +def parse_keyval_auto_convert(val): + """Guess the type of the string representation, and return the converted value. + Args: + val Value to convert after type guessing + Returns: + Converted value, or same instance as 'val' if 'str' was the guessed type + """ + # Try guess 'bool' + low = val.lower() + if low == "false": + return False + elif low == "true": + return True + # Try guess number + for cls in (int, float): + try: + return cls(val) + except ValueError: + continue + # Else guess string + return val + + +def parse_keyval(list_keyval, defaults={}): + """Parse list of ":" into a dictionary. + Args: + list_keyval List of ":" + defaults Default key -> value to use (also ensure type, type is guessed for other keys) + Returns: + Associated dictionary + """ + parsed = {} + # Parsing + sep = ":" + for entry in list_keyval: + pos = entry.find(sep) + if pos < 0: + raise tools.UserException( + "Expected list of " + repr(":") + ", got " + repr(entry) + " as one entry" + ) + key = entry[:pos] + if key in parsed: + raise tools.UserException( + "Key " + repr(key) + " had already been specified with value " + repr(parsed[key]) + ) + val = entry[pos + len(sep) :] + # Guess/assert type constructibility + if key in defaults: + try: + cls = type(defaults[key]) + if cls is bool: # Special case + val = val.lower() not in ("", "0", "n", "false") + else: + val = cls(val) + except Exception: + raise tools.UserException( + "Required key " + + repr(key) + + " expected a value of type " + + repr(getattr(type(defaults[key]), "__name__", "")) + ) + else: + val = parse_keyval_auto_convert(val) + # Bind (converted) value to associated key + parsed[key] = val + # Add default values (done first to be able to force a given type with 'required') + for key in defaults: + if key not in parsed: + parsed[key] = defaults[key] + # Return final dictionary + return parsed + + +# ---------------------------------------------------------------------------- # +# Basic "full-qualification" string builder for a given instance/class + + +def fullqual(obj): + """Rebuild a string "qualifying" the given object for debugging purpose. + Args: + obj Object to "qualify" + Returns: + "Qualification", e.g.: 'tools.misc.fullqual' or 'instance of pathlib.Path' + """ + # Prelude + if isinstance(obj, type): + prelude = "" + else: + prelude = "instance of " + obj = type(obj) + # Rebuilding + return "{}{}.{}".format( + prelude, + getattr(obj, "__module__", ""), + getattr(obj, "__qualname__", ""), + ) + + +# ---------------------------------------------------------------------------- # +# Basic "full-qualification" string builder for a given instance/class + + +def onetime(name=None): + """Generate a one time-set (hidden) state variable getter and setter. + Args: + name Optional name of the global, onetime variable to access + Returns: + · (Threadsafe) getter closure + · (Threadsafe) setter closure + """ + global onetime_register + # Check if name exists + if name is not None and name in onetime_register: + return onetime_register[name] + # Private variables + lock = threading.Lock() + value = False + + # Management closures + def getter(*args, **kwargs): + """Check whether 'value' is set. + Args: + ... Ignored arguments + Returns: + Whether 'value' is set + """ + nonlocal lock + nonlocal value + with lock: + return value + + def setter(*args, **kwargs): + """Set 'value'. + Args: + ... Ignored arguments + """ + nonlocal lock + nonlocal value + with lock: + value = True + + # Register if need be, then return the management closures + res = (getter, setter) + if name is not None: + onetime_register[name] = res + return res + + +# Register for the onetime variables +onetime_register = dict() + +# ---------------------------------------------------------------------------- # +# Plain context augmented with simple execution time measurement + + +class TimedContext(tools.Context): + """Timed context class, that print the measure runtime.""" + + def __init__(self, *args, **kwargs): + """Forward call to parent constructor. + Args: + ... Forwarded (keyword-)arguments + """ + super().__init__(*args, **kwargs) + + def __enter__(self): + """Enter context: start chrono. + Returns: + Forwarded return value from parent + """ + self._chrono = time.time() + return super().__enter__() + + def __exit__(self, *args, **kwargs): + """Exit context: stop chrono and print elapsed time. + Args: + ... Forwarded arguments + """ + # Measure elapsed runtime (in ns) + runtime = (time.time() - self._chrono) * 1000000000.0 + # Recover ideal unit + for unit in ("ns", "µs", "ms"): + if runtime < 1000.0: + break + runtime /= 1000.0 + else: + unit = "s" + # Format and print string + tools.trace(f"Execution time: {runtime:.3g} {unit}") + # Forward call + super().__exit__(*args, **kwargs) + + +# ---------------------------------------------------------------------------- # +# Switch to interactive mode, executing user inputs + + +def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): + """Switch to a simple interactive prompt, execute CTRL+D (or equivalent) to leave. + Args: + glbs Globals dictionary to use, None to use caller's globals + lcls Locals dictionary to use, None to use given globals or caller's locals/globals + prompt Command prompt to display + cprmpt Command prompt to display when continuing a line + """ + # Recover caller's globals and locals + try: + caller = sys._getframe().f_back + except Exception: + caller = None + if glbs is None: + tools.warning("Unable to recover caller's frame, locals and globals", context="interactive") + if glbs is None: + if caller is not None and hasattr(caller, "f_globals"): + glbs = caller.f_globals + else: + glbs = dict() + if lcls is None: + if caller is not None and hasattr(caller, "f_locals"): + lcls = caller.f_locals + else: + lcls = glbs + # Command input and execution + command = "" + statement = False + while True: + print(prompt if len(command) == 0 else cprmpt, end="", flush=True) + try: + # Input new line + try: + line = input() + print("\033[A") # Trick to "advertise" new line on stdout after new line on stdin + except BaseException as err: + if any(isinstance(err, cls) for cls in (EOFError, KeyboardInterrupt)): + print() # Since no new line was printed by pressing ENTER + return + # Handle expression + if not statement: + try: + res = eval(line, glbs, lcls) + if res is not None: + print(res) + except SyntaxError: # Heuristic that we are dealing with a statement + statement = True + # Handle single or multi-line statement(s) + if statement: + if len(command) == 0: # Just went through trying an expression + command = line + try: + exec(command, glbs, lcls) + except SyntaxError: # Heuristic that we are dealing with a multi-line statement + continue + elif len(line) > 0: + command += os.linesep + line + continue + else: # Multi-line statement is complete + exec(command, glbs, lcls) + except Exception: + with tools.Context("uncaught", "error"): + traceback.print_exc() + command = "" + statement = False + + +# ---------------------------------------------------------------------------- # +# List non-standard, currently loaded module names and metadata. + + +def get_loaded_dependencies(): + """List non-builtin, currently loaded root module names and metadata. + Returns: + List of tuples (, , <0: is standard, 1: is site-specific, 2: is local>) + Raises: + 'RuntimeError' on unsupported platforms + """ + # Get the site-packages directories, and make "flavor"-detection closure + path_sites = tuple(pathlib.Path(path) for path in site.getsitepackages() + [site.getusersitepackages()]) + + def flavor_of(path): + path = pathlib.Path(path) + for path_site in path_sites: + try: + path.relative_to(path_site) + return get_loaded_dependencies.IS_SITE + except ValueError: + pass + for path_site in path_sites: + try: + path.relative_to(path_site.parent) + return get_loaded_dependencies.IS_STANDARD + except ValueError: + pass + return get_loaded_dependencies.IS_LOCAL + + # Iterate over the loaded modules + res = list() + for name, module in sys.modules.items(): + # Skip non-root modules + if "." in name: + continue + # Get module path (and so skip built-in modules) + path = getattr(module, "__file__", None) + if path is None: + continue + # Get module version (if any) + version = getattr(module, "__version__", None) + # Get module "flavor" + flavor = flavor_of(path) + # Store entry + res.append((name, version, flavor)) + # Return found root modules + return res + + +# Register constants +get_loaded_dependencies.IS_STANDARD = 0 +get_loaded_dependencies.IS_SITE = 1 +get_loaded_dependencies.IS_LOCAL = 2 + +# ---------------------------------------------------------------------------- # +# Find the x maximizing a function y = f(x), with (x, y) ∊ ℝ⁺× ℝ + + +def line_maximize(scape, evals=16, start=0.0, delta=1.0, ratio=0.8): + """Best-effort arg-maximize a scape: ℝ⁺⟶ ℝ, by mere exploration. + Args: + scape Function to best-effort arg-maximize + evals Maximum number of evaluations, must be a positive integer + start Initial x evaluated, must be a non-negative float + delta Initial step delta, must be a positive float + ratio Contraction ratio, must be between 0.5 and 1. (both excluded) + Returns: + Best-effort maximizer x under the evaluation budget + """ + # Variable setup + best_x = start + best_y = scape(best_x) + evals -= 1 + # Expansion phase + while evals > 0: + prop_x = best_x + delta + prop_y = scape(prop_x) + evals -= 1 + # Check if best + if prop_y > best_y: + best_y = prop_y + best_x = prop_x + delta *= 2 + else: + delta *= ratio + break + # Contraction phase + while evals > 0: + if prop_x < best_x: + prop_x += delta + else: + x = prop_x - delta + while x < 0: + x = (x + prop_x) / 2 + prop_x = x + prop_y = scape(prop_x) + evals -= 1 + # Check if best + if prop_y > best_y: + best_y = prop_y + best_x = prop_x + # Reduce delta + delta *= ratio + # Return found maximizer + return best_x + + +# ---------------------------------------------------------------------------- # +# Simple generator on the pairs (x, y) of an indexable such that index x < index y + + +def pairwise(data): + """Simple generator of the pairs (x, y) in a tuple such that index x < index y. + Args: + data Indexable (including ability to query length) containing the elements + Returns: + Generator over the pairs of the elements of 'data' + """ + n = len(data) + for i in range(n - 1): + for j in range(i + 1, n): + yield (data[i], data[j]) + + +# ---------------------------------------------------------------------------- # +# Simple duration helpers + + +def localtime(): + """Return the formatted local time. + Returns: + Human-readable local time + """ + lt = time.localtime() + return f"{lt.tm_year:04}/{lt.tm_mon:02}/{lt.tm_mday:02} {lt.tm_hour:02}:{lt.tm_min:02}:{lt.tm_sec:02}" + + +def deltatime_point(): + """Take a point in time. + Returns: + Opaque point-in-time + """ + point = time.monotonic_ns() + return (point + 5 * 10**8) // 10**9 + + +def deltatime_format(a, b): + """Compute and format the time elapsed between two points in time. + Args: + a Earlier point-in-time + b Later point-in-time + Returns: + Elapsed time integer (in s), + Formatted elapsed time string (human-readable way) + """ + # Elapsed time (in seconds) + t = b - a + # Elapsed time (formatted) + d = t + s = d % 60 + d //= 60 + m = d % 60 + d //= 60 + h = d % 24 + d //= 24 + # Return elapsed time + return t, f"{d} day(s), {h} hour(s), {m} min(s), {s} sec(s)" diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py new file mode 100644 index 0000000..0c57988 --- /dev/null +++ b/krum/tools/pytorch.py @@ -0,0 +1,317 @@ +### +# @file pytorch.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Helpers relative to PyTorch. +### + +__all__ = [ + "relink", + "flatten", + "grad_of", + "grads_of", + "compute_avg_dev_max", + "AccumulatedTimedContext", + "weighted_mse_loss", + "WeightedMSELoss", + "regression", + "pnm", +] + +import math +import time +import types + +import torch + +from krum import tools + +# ---------------------------------------------------------------------------- # +# "Flatten" and "relink" operations + + +def relink(tensors, common): + """ "Relink" the tensors of class (deriving from) Tensor by making them point to another contiguous segment of memory. + Args: + tensors Generator of/iterable on instances of/deriving from Tensor, all with the same dtype + common Flat tensor of sufficient size to use as underlying storage, with the same dtype as the given tensors + Returns: + Given common tensor + """ + # Convert to tuple if generator + if isinstance(tensors, types.GeneratorType): + tensors = tuple(tensors) + # Relink each given tensor to its segment on the common one + pos = 0 + for tensor in tensors: + npos = pos + tensor.numel() + tensor.data = common[pos:npos].view(*tensor.shape) + pos = npos + # Finalize and return + common.linked_tensors = tensors + return common + + +def flatten(tensors): + """ "Flatten" the tensors of class (deriving from) Tensor so that they all use the same contiguous segment of memory. + Args: + tensors Generator of/iterable on instances of/deriving from Tensor, all with the same dtype + Returns: + Flat tensor (with the same dtype as the given tensors) that contains the memory used by all the given Tensor (or derived instances), in emitted order + """ + # Convert to tuple if generator + if isinstance(tensors, types.GeneratorType): + tensors = tuple(tensors) + # Common tensor instantiation and reuse + common = torch.cat(tuple(tensor.view(-1) for tensor in tensors)) + # Return common tensor + return relink(tensors, common) + + +# ---------------------------------------------------------------------------- # +# Gradient access + + +def grad_of(tensor): + """Get the gradient of a given tensor, make it zero if missing. + Args: + tensor Given instance of/deriving from Tensor + Returns: + Gradient for the given tensor + """ + # Get the current gradient + grad = tensor.grad + if grad is not None: + return grad + # Make and set a zero-gradient + grad = torch.zeros_like(tensor) + tensor.grad = grad + return grad + + +def grads_of(tensors): + """Iterate of the gradients of the given tensors, make zero gradients if missing. + Args: + tensors Generator of/iterable on instances of/deriving from Tensor + Returns: + Generator of the gradients of the given tensors, in emitted order + """ + return (grad_of(tensor) for tensor in tensors) + + +# ---------------------------------------------------------------------------- # +# Useful computations + + +def compute_avg_dev_max(samples): + """Compute the norm average and norm standard deviation of gradient samples. + Args: + samples Given gradient samples + Returns: + Computed average gradient (None if no sample), norm average, norm standard deviation, average maximum absolute coordinate + """ + # Trivial case no sample + if len(samples) == 0: + return None, math.nan, math.nan, math.nan + # Compute average gradient and max abs coordinate + grad_avg = samples[0].clone().detach_() + for grad in samples[1:]: + grad_avg.add_(grad) + grad_avg.div_(len(samples)) + norm_avg = grad_avg.norm().item() + norm_max = grad_avg.abs().max().item() + # Compute norm standard deviation + if len(samples) >= 2: + norm_var = 0.0 + for grad in samples: + grad = grad.sub(grad_avg) + norm_var += grad.dot(grad).item() + norm_var /= len(samples) - 1 + norm_dev = math.sqrt(norm_var) + else: + norm_dev = math.nan + # Return norm average and deviation + return grad_avg, norm_avg, norm_dev, norm_max + + +# ---------------------------------------------------------------------------- # +# Simple timing context + + +class AccumulatedTimedContext: + """Accumulated timed context class, that do not print.""" + + def _sync_cuda(self): + """Synchronize CUDA streams (if requested and relevant).""" + if self._sync and torch.cuda.is_available(): + torch.cuda.synchronize() + + def __init__(self, initial=0.0, *, sync=False): + """Zero runtime constructor. + Args: + initial Initial total runtime (in s) + sync Whether to synchronize with already running/launched CUDA streams + """ + # Finalization + self._total = initial # Total runtime (in s) + self._sync = sync + + def __enter__(self): + """Enter context: start chrono. + Returns: + Self + """ + # Synchronize CUDA streams (if requested and relevant) + self._sync_cuda() + # "Start" chronometer + self._chrono = time.time() + # Return self + return self + + def __exit__(self, *args, **kwargs): + """Exit context: stop chrono and accumulate elapsed time. + Args: + ... Ignored + """ + # Synchronize CUDA streams (if requested and relevant) + self._sync_cuda() + # Accumulate elapsed time (in s) + self._total += time.time() - self._chrono + + def __str__(self): + """Pretty-print total runtime. + Returns: + Total runtime string with unit + """ + # Get total runtime (in ns) + runtime = self._total * 1000000000.0 + # Recover ideal unit + for unit in ("ns", "µs", "ms"): + if runtime < 1000.0: + break + runtime /= 1000.0 + else: + unit = "s" + # Format and return string + return f"{runtime:.3g} {unit}" + + def current_runtime(self): + """Get the current accumulated runtime. + Returns: + Current runtime (in s) + """ + return self._total + + +# ---------------------------------------------------------------------------- # +# Regression helper + + +def weighted_mse_loss(tno, tne, tnw): + """Weighted mean square error loss. + Args: + tno Output tensor + tne Expected output tensor + tnw Weight tensor + Returns: + Associated loss tensor + """ + return torch.mean((tno - tne).pow_(2).mul_(tnw)) + + +class WeightedMSELoss(torch.nn.Module): + """Weighted mean square error loss class.""" + + def __init__(self, weight, *args, **kwargs): + """Weight binding constructor. + Args: + weight Weight to bind + ... Forwarding (keyword-)arguments + """ + super().__init__(*args, **kwargs) + self.register_buffer("weight", weight) + + def forward(self, tno, tne): + """Compute the weighted mean square error. + Args: + tno Output tensor + tne Expeced output tensor + Returns: + Associated loss tensor + """ + return weighted_mse_loss(tno, tne, self.weight) + + +def regression(func, vars, data, loss=torch.nn.MSELoss(), opt=torch.optim.Adam, steps=1000): + """Performs a regression (mere optimization of variables) for the given function. + Args: + func Function to fit + vars Iterable of the free tensor variables to optimize + data Tuple of (input data tensor, expected output data tensor) + loss Loss function to use, taking (output, expected output) + opt Optimizer to use (function mapping a once-iterable of tensors to an optimizer instance) + steps Number of optimization epochs to perform (1 epoch/step) + Returns: + Step at which optimization stopped + """ + # Prepare + tni = data[0] + tne = data[1] + opt = opt(vars) + # Optimize + for step in range(steps): + with torch.enable_grad(): + opt.zero_grad() + res = loss(func(tni), tne) + if torch.isnan(res).any().item(): + return step + res.backward() + opt.step() + return steps + + +# ---------------------------------------------------------------------------- # +# Save image as PGM/PBM stream + + +def pnm(fd, tn): + """Save a 2D/3D tensor as a PGM/PBM stream. + Args: + fd File descriptor opened for writing binary streams + tn A 2D/3D tensor to convert and save + Notes: + The input tensor is "intelligently" squeezed before processing + For 2D tensor, assuming black is 1. and white is 0. (clamp between [0, 1]) + For 3D tensor, the first dimension must be the 3 color channels RGB (all between [0, 1]) + """ + shape = tuple(tn.shape) + # Intelligent squeezing + while len(tn.shape) > 3 and tn.shape[0] == 1: + tn = tn[0] + # Colored image generation + if len(tn.shape) == 3: + if tn.shape[0] == 1: + tn = tn[0] + # And continue on gray-scale + elif tn.shape[0] != 3: + raise tools.UserException( + f"Expected 3 color channels for the first dimension of a 3D tensor, got {tn.shape[0]} channels" + ) + else: + fd.write((f"P6\n{tn.shape[1]} {tn.shape[2]} 255\n").encode()) + fd.write(bytes(tn.transpose(0, 2).transpose(0, 1).mul(256).clamp_(0.0, 255.0).byte().storage())) + return + # Gray-scale image generation + if len(tn.shape) == 2: + fd.write((f"P5\n{tn.shape[0]} {tn.shape[1]} 255\n").encode()) + fd.write(bytes((1.0 - tn).mul_(256).clamp_(0.0, 255.0).byte().storage())) + return + # Invalid tensor shape + raise tools.UserException(f"Expected a 2D or 3D tensor, got {len(shape)} dimensions {tuple(shape)!r}") diff --git a/native/__init__.py b/native/__init__.py deleted file mode 100644 index c63288e..0000000 --- a/native/__init__.py +++ /dev/null @@ -1,165 +0,0 @@ -# coding: utf-8 -### - # @file __init__.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2019 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Native (i.e. C++/CUDA) implementations automated building and loading. -### - -# ---------------------------------------------------------------------------- # -# Initialization procedure - -def _build_and_load(): - """ Incrementally rebuild all libraries and bind all local modules in the global. - """ - glob = globals() - # Standard imports - import os - import pathlib - import traceback - import warnings - # External imports - import torch - import torch.utils.cpp_extension - # Internal imports - import tools - # Constants - base_directory = pathlib.Path(__file__).parent.resolve() - dependencies_file = ".deps" - debug_mode_envname = "NATIVE_OPT" - debug_mode_in_env = debug_mode_envname in os.environ - if debug_mode_in_env: - raw = os.environ[debug_mode_envname] - value = raw.lower() - if value in ["0", "n", "no", "false"]: - debug_mode = True - elif value in ["1", "y", "yes", "true"]: - debug_mode = False - else: - tools.fatal("%r defined in the environment, but with unexpected soft-boolean %r" % (debug_mode_envname, "%s=%s" % (debug_mode_envname, raw))) - else: - debug_mode = __debug__ - cpp_std_envname = "NATIVE_STD" - cpp_std = os.environ.get(cpp_std_envname, "c++14") - ident_to_is_python = {"so_": False, "py_": True} - source_suffixes = {".cpp", ".cc", ".C", ".cxx", ".c++"} - extra_cflags = ["-Wall", "-Wextra", "-Wfatal-errors", "-std=%s" % cpp_std] - if torch.cuda.is_available(): - source_suffixes.update(set((".cu" + suffix) for suffix in source_suffixes)) - source_suffixes.add(".cu") - extra_cflags.append("-DTORCH_CUDA_AVAILABLE") - extra_cuda_cflags = ["-DTORCH_CUDA_AVAILABLE", "--expt-relaxed-constexpr", "-std=%s" % cpp_std] - extra_ldflags = ["-Wl,-L" + base_directory.root] - extra_include_path = base_directory / "include" - try: - extra_include_paths = [str(extra_include_path.resolve())] - except Exception: - extra_include_paths = None - warnings.warn("Not found include directory: " + repr(str(extra_include_path))) - # Print configuration information - cpp_std_message = "Native modules compiled with %s standard; (re)define %r in the environment to compile with another standard" % (cpp_std, "%s=" % cpp_std_envname) - if debug_mode: - tools.warning(cpp_std_message) - tools.warning("Native modules compiled in debug mode; %sdefine %r in the environment or%s run python with -O/-OO options to compile in release mode" % ("re" if debug_mode_in_env else "", "%s=1" % debug_mode_envname, " undefine it and" if debug_mode_in_env else "")) - extra_cflags += ["-O0", "-g"] - else: - quiet_envname = "NATIVE_QUIET" - if quiet_envname not in os.environ: - tools.trace(cpp_std_message) - tools.trace("Native modules compiled in release mode; %sdefine %r in the environment or%s run python without -O/-OO options to compile in debug mode" % ("re" if debug_mode_in_env else "", "%s=0" % debug_mode_envname, " undefine it and" if debug_mode_in_env else "")) - tools.trace("Define %r in the environment to hide these messages in release mode" % quiet_envname) - extra_cflags += ["-O3", "-DNDEBUG"] - # Variables - done_modules = [] - fail_modules = [] - # Local procedures - def build_and_load_one(path, deps=[]): - """ Check if the given directory is a module to build and load, and if yes recursively build and load its dependencies before it. - Args: - path Given directory path - deps Dependent module paths - Returns: - True on success, False on failure, None if not a module - """ - nonlocal done_modules - nonlocal fail_modules - with tools.Context(path.name, "info"): - ident = path.name[:3] - if ident in ident_to_is_python.keys(): - # Is a module directory - if len(path.name) <= 3 or path.name[3] == "_": - tools.warning("Skipped invalid module directory name " + repr(path.name)) - return None - if not path.exists(): - tools.warning("Unable to build and load " + repr(str(path.name)) + ": module does not exist") - fail_modules.append(path) # Mark as failed - return False - is_python_module = ident_to_is_python[ident] - # Check if already built and loaded, or failed - if path in done_modules: - if len(deps) == 0 and debug_mode: - tools.info("Already built and loaded " + repr(str(path.name))) - return True - if path in fail_modules: - if len(deps) == 0: - tools.warning("Was unable to build and load " + repr(str(path.name))) - return False - # Check for dependency cycle (disallowed as they may mess with the linker) - if path in deps: - tools.warning("Unable to build and load " + repr(str(path.name)) + ": dependency cycle found") - fail_modules.append(path) # Mark as failed - return False - # Build and load dependencies - this_ldflags = list(extra_ldflags) - depsfile = path / dependencies_file - if depsfile.exists(): - for modname in depsfile.read_text().splitlines(): - res = build_and_load_one(base_directory / modname, deps + [path]) - if res == False: # Unable to build a dependency - if len(deps) == 0: - tools.warning("Unable to build and load " + repr(str(path.name)) + ": dependency " + repr(modname) + " build and load failed") - fail_modules.append(path) # Mark as failed - return False - elif res == True: # Module and its sub-dependencies was/were built and loaded successfully - this_ldflags.append("-Wl,--library=:" + str((base_directory / modname / (modname + ".so")).resolve())) - # List sources - sources = [] - for subpath in path.iterdir(): - if subpath.is_file() and ("").join(subpath.suffixes) in source_suffixes: - sources.append(str(subpath)) - # Build and load this module - try: - res = torch.utils.cpp_extension.load(name=path.name, sources=sources, extra_cflags=extra_cflags, extra_cuda_cflags=extra_cuda_cflags, extra_ldflags=this_ldflags, extra_include_paths=extra_include_paths, build_directory=str(path), verbose=debug_mode, is_python_module=is_python_module) - if is_python_module: - glob[path.name[3:]] = res - except Exception as err: - tools.warning("Unable to build and load " + repr(str(path.name)) + ": " + str(err)) - fail_modules.append(path) # Mark as failed - return False - done_modules.append(path) # Mark as built and loaded - return True - # Main loop - for path in base_directory.iterdir(): - if path.is_dir(): - try: - build_and_load_one(path) - except Exception as err: - tools.warning("Exception while processing " + repr(str(path)) + ": " + str(err)) - with tools.Context("traceback", "trace"): - traceback.print_exc() - -# ---------------------------------------------------------------------------- # -# Initialization - -import tools as _tools -with _tools.Context("native", None): - _build_and_load() -del _tools -del _build_and_load diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..abc033b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "krum" +version = "0.1.0" +description = "Byzantine-resilient aggregation rules for distributed machine learning." +readme = "README.md" +requires-python = ">=3.10,<3.15" +license = "MIT" +license-files = ["LICENSE"] +authors = [ + {name = "Sébastien Rouault", email = "sebastien.rouault@alumni.epfl.ch"}, + {name = "Arthur Danjou", email = "arthur.danjou@dauphine.eu"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "matplotlib>=3.10.9", + "ninja>=1.13.0", + "numpy>=2.2.6", + "pandas>=2.0.0", + "requests>=2.33.1", + "torch>=2.11.0", + "torchvision>=0.26.0", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.9.0", +] +docs = [ + "sphinx>=8.1.3", + "sphinx-copybutton>=0.5.2", + "sphinx-favicon>=1.0.1", + "sphinx-togglebutton>=0.3.2", + "sphinx-contributors>=0.3.0", + "shibuya>=2026.1.9", +] + +[project.urls] +Homepage = "https://github.com/calicarpa/krum" +Repository = "https://github.com/calicarpa/krum" +Issues = "https://github.com/calicarpa/krum/issues" + +[tool.setuptools.packages.find] +where = ["."] +include = ["krum*"] +exclude = ["repositories*"] + +[tool.setuptools.package-data] +"krum.native" = ["**/*.hpp", "**/*.cpp", "**/*.cu", "**/*.h", "**/*.deps", "**/.placeholder"] + +[tool.ruff] +target-version = "py310" +line-length = 120 +exclude = [ + "repositories", + ".venv", + ".git", + ".mypy_cache", + ".ruff_cache", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # Pyflakes + "I", # isort + "N", # pep8-naming + "W", # pycodestyle warnings + "UP", # pyupgrade +] +ignore = [ + "E402", # module level import not at top of file (intentional in this codebase) + "E501", # line too long (handled by formatter) + "N818", # exception naming (UserException, StopTrainingLoop are intentional) +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" diff --git a/reproduce.py b/reproduce.py index eb70eca..a0afc91 100644 --- a/reproduce.py +++ b/reproduce.py @@ -1,30 +1,29 @@ -# coding: utf-8 ### - # @file reproduce.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Reproduce the (missing) experiments and plots. +# @file reproduce.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Reproduce the (missing) experiments and plots. ### -import tools +from krum import tools + tools.success("Module loading...") import argparse import pathlib import signal import sys -import traceback import torch -import experiments +from krum import experiments # ---------------------------------------------------------------------------- # # Miscellaneous initializations @@ -41,127 +40,147 @@ # Command-line processing tools.success("Command-line processing...") + def process_commandline(): - """ Parse the command-line and perform checks. - Returns: - Parsed configuration - """ - # Description - parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument("--data-directory", - type=str, - default="results-data", - help="Path of the data directory, containing the data gathered from the experiments") - parser.add_argument("--plot-directory", - type=str, - default="results-plot", - help="Path of the plot directory, containing the graphs traced from the experiments") - parser.add_argument("--devices", - type=str, - default="auto", - help="Comma-separated list of devices on which to run the experiments, used in a round-robin fashion") - parser.add_argument("--supercharge", - type=int, - default=1, - help="How many experiments are run in parallel per device, must be positive") - parser.add_argument("--only-plot", - default=False, - action="store_true", - help="Only build the plots (useful to get some plots while experiments are still running)") - # Parse command line - return parser.parse_args(sys.argv[1:]) + """Parse the command-line and perform checks. + Returns: + Parsed configuration + """ + # Description + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument( + "--data-directory", + type=str, + default="results-data", + help="Path of the data directory, containing the data gathered from the experiments", + ) + parser.add_argument( + "--plot-directory", + type=str, + default="results-plot", + help="Path of the plot directory, containing the graphs traced from the experiments", + ) + parser.add_argument( + "--devices", + type=str, + default="auto", + help="Comma-separated list of devices on which to run the experiments, used in a round-robin fashion", + ) + parser.add_argument( + "--supercharge", + type=int, + default=1, + help="How many experiments are run in parallel per device, must be positive", + ) + parser.add_argument( + "--only-plot", + default=False, + action="store_true", + help="Only build the plots (useful to get some plots while experiments are still running)", + ) + # Parse command line + return parser.parse_args(sys.argv[1:]) + with tools.Context("cmdline", "info"): - args = process_commandline() - # Check the "supercharge" parameter - if args.supercharge < 1: - tools.fatal(f"Expected a positive supercharge value, got {args.supercharge}") - # Make the result directories - def check_make_dir(path): - path = pathlib.Path(path) - if path.exists(): - if not path.is_dir(): - tools.fatal(f"Given path {str(path)!r} must point to a directory") - else: - path.mkdir(mode=0o755, parents=True) - return path - args.data_directory = check_make_dir(args.data_directory) - args.plot_directory = check_make_dir(args.plot_directory) - # Preprocess/resolve the devices to use - if args.devices == "auto": - if torch.cuda.is_available(): - args.devices = list(f"cuda:{i}" for i in range(torch.cuda.device_count())) + args = process_commandline() + # Check the "supercharge" parameter + if args.supercharge < 1: + tools.fatal(f"Expected a positive supercharge value, got {args.supercharge}") + + # Make the result directories + def check_make_dir(path): + path = pathlib.Path(path) + if path.exists(): + if not path.is_dir(): + tools.fatal(f"Given path {str(path)!r} must point to a directory") + else: + path.mkdir(mode=0o755, parents=True) + return path + + args.data_directory = check_make_dir(args.data_directory) + args.plot_directory = check_make_dir(args.plot_directory) + # Preprocess/resolve the devices to use + if args.devices == "auto": + if torch.cuda.is_available(): + args.devices = list(f"cuda:{i}" for i in range(torch.cuda.device_count())) + else: + args.devices = ["cpu"] else: - args.devices = ["cpu"] - else: - args.devices = list(name.strip() for name in args.devices.split(",")) + args.devices = list(name.strip() for name in args.devices.split(",")) # ---------------------------------------------------------------------------- # # Serial preloading of the dataset tools.success("Pre-downloading datasets...") -# Pre-load the datasets to prevent the first parallel runs from downloading them several times +# Pre-load the datasets to prevent the first parallel runs from downloading them several times with tools.Context("dataset", "info"): - for name in ("svm-phishing",): - with tools.Context(name, "info"): - experiments.make_datasets(name, 1, 1) + for name in ("svm-phishing",): + with tools.Context(name, "info"): + experiments.make_datasets(name, 1, 1) # ---------------------------------------------------------------------------- # # Run (missing) experiments if not args.only_plot: - tools.success("Running experiments...") + tools.success("Running experiments...") + # Command maker helper def make_command(params): - cmd = ["python3", "-OO", "train.py"] - cmd += tools.dict_to_cmdlist(params) - return tools.Command(cmd) + cmd = ["python3", "-OO", "train.py"] + cmd += tools.dict_to_cmdlist(params) + return tools.Command(cmd) + # Jobs -jobs = tools.Jobs(args.data_directory, devices=args.devices, devmult=args.supercharge) +jobs = tools.Jobs(args.data_directory, devices=args.devices, devmult=args.supercharge) seeds = jobs.get_seeds() # Base parameters for the experiments params_common = { - "loss": "mse", - "learning-rate": 2, - "criterion": "sigmoid", - "momentum": 0.99, - "evaluation-delta": 50, - "nb-steps": 1000, - "nb-workers": 11, - "nb-decl-byz": 5, - "nb-real-byz": 5, - "batch-size-test": 59, - "test-repeat": 45, - "gradient-clip": 0.01, - "privacy-delta": 1e-6 } + "loss": "mse", + "learning-rate": 2, + "criterion": "sigmoid", + "momentum": 0.99, + "evaluation-delta": 50, + "nb-steps": 1000, + "nb-workers": 11, + "nb-decl-byz": 5, + "nb-real-byz": 5, + "batch-size-test": 59, + "test-repeat": 45, + "gradient-clip": 0.01, + "privacy-delta": 1e-6, +} # Submit all the experiments (if not disabled) if not args.only_plot: - for ds, dsa in (("svm-phishing", None),): - for md, mda in (("simples-logit", "din:68"),): - for gar, attacks in (("average", (("nan", None),)), ("brute", (("little", ("factor:1.5", "negative:True")), ("empire", "factor:1.1")))): - for attack, attargs in attacks: - for epsilon in (None, 0.1, 0.2, 0.5): - for batch_size in (10, 25, 50, 100, 250, 500): - name = f"{ds}-{md}-{gar}-{attack}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}" - # Submit experiment - params = params_common.copy() - if gar == "average": - # Disable attack for 'average' GAR - params["nb-real-byz"] = 0 - params["dataset"] = ds - params["dataset-args"] = dsa - params["model"] = md - params["model-args"] = mda - params["gar"] = gar - params["attack"] = attack - params["attack-args"] = attargs - params["privacy"] = epsilon is not None - params["privacy-epsilon"] = epsilon - params["batch-size"] = batch_size - jobs.submit(name, make_command(params)) + for ds, dsa in (("svm-phishing", None),): + for md, mda in (("simples-logit", "din:68"),): + for gar, attacks in ( + ("average", (("nan", None),)), + ("brute", (("little", ("factor:1.5", "negative:True")), ("empire", "factor:1.1"))), + ): + for attack, attargs in attacks: + for epsilon in (None, 0.1, 0.2, 0.5): + for batch_size in (10, 25, 50, 100, 250, 500): + name = f"{ds}-{md}-{gar}-{attack}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}" + # Submit experiment + params = params_common.copy() + if gar == "average": + # Disable attack for 'average' GAR + params["nb-real-byz"] = 0 + params["dataset"] = ds + params["dataset-args"] = dsa + params["model"] = md + params["model-args"] = mda + params["gar"] = gar + params["attack"] = attack + params["attack-args"] = attargs + params["privacy"] = epsilon is not None + params["privacy-epsilon"] = epsilon + params["batch-size"] = batch_size + jobs.submit(name, make_command(params)) # Wait for the jobs to finish and close the pool jobs.wait(exit_is_requested) @@ -169,77 +188,98 @@ def make_command(params): # Check if exit requested before going to plotting the results if exit_is_requested(): - exit(0) + exit(0) # ---------------------------------------------------------------------------- # # Produce graphs # Import additional modules try: - import histogram - import numpy - import pandas + import numpy + import pandas + + import histogram except ImportError as err: - tools.fatal(f"Unable to plot results: {err}") + tools.fatal(f"Unable to plot results: {err}") # Map gar name in code to key in graph gar_to_legend = {"brute": "MDA"} + def compute_avg_err(name, *cols, avgs="", errs="-err"): - """ Compute the average and standard deviation of the selected columns over the given experiment. - Args: - name Given experiment name - ... Selected column names (through 'histogram.select') - avgs Suffix for average column names - errs Suffix for standard deviation (or "error") column names - Returns: - Data frames, each for the computed columns - """ - # Load all the runs for the given experiment name, and keep only a subset - datas = tuple(histogram.select(histogram.Session(args.data_directory / f"{name}-{seed}"), *cols) for seed in seeds) - # Make the aggregated data frames - def make_df(col): - nonlocal datas - # For every selected columns - subds = tuple(histogram.select(data, col).dropna() for data in datas) - res = pandas.DataFrame(index=subds[0].index) - for col in subds[0]: - # Generate compound column names - avgn = col + avgs - errn = col + errs - # Compute compound columns - numds = numpy.stack(tuple(subd[col].to_numpy() for subd in subds)) - res[avgn] = numds.mean(axis=0) - res[errn] = numds.std(axis=0) - # Return the built data frame - return res - # Return the built data frames - return tuple(make_df(col) for col in cols) + """Compute the average and standard deviation of the selected columns over the given experiment. + Args: + name Given experiment name + ... Selected column names (through 'histogram.select') + avgs Suffix for average column names + errs Suffix for standard deviation (or "error") column names + Returns: + Data frames, each for the computed columns + """ + # Load all the runs for the given experiment name, and keep only a subset + datas = tuple(histogram.select(histogram.Session(args.data_directory / f"{name}-{seed}"), *cols) for seed in seeds) + + # Make the aggregated data frames + def make_df(col): + nonlocal datas + # For every selected columns + subds = tuple(histogram.select(data, col).dropna() for data in datas) + res = pandas.DataFrame(index=subds[0].index) + for col in subds[0]: + # Generate compound column names + avgn = col + avgs + errn = col + errs + # Compute compound columns + numds = numpy.stack(tuple(subd[col].to_numpy() for subd in subds)) + res[avgn] = numds.mean(axis=0) + res[errn] = numds.std(axis=0) + # Return the built data frame + return res + + # Return the built data frames + return tuple(make_df(col) for col in cols) + with tools.Context("plot", "info"): - # Plot all the experiments - for ds, dsa in (("svm-phishing", None),): - for md, mda in (("simples-logit", "din:68"),): - for epsilon in (None, 0.1, 0.2, 0.5): - for batch_size in (10, 25, 50, 100, 250, 500): - legend = list() - results = list() - # Pre-process results for all available combinations of GAR and attack - for gar, attacks in (("average", (("nan", None),)), ("brute", (("little", ("factor:1.5", "negative:True")), ("empire", "factor:1.1")))): - for attack, _ in attacks: - name = f"{ds}-{md}-{gar}-{attack}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}" - key = f"{gar_to_legend.get(gar, gar.capitalize())} ({'no attack' if gar == 'average' else attack})" - legend.append(key) - results.append(compute_avg_err(name, "Accuracy", "Average loss")) - # Plot top-1 cross-accuracy - plot = histogram.LinePlot() - for crossacc, _ in results: - plot.include(crossacc, "Accuracy", errs="-err", lalp=0.8) - plot.finalize(None, "Step number", "Cross-accuracy", xmin=0, xmax=1000, ymin=0, ymax=1, legend=legend) - plot.save(args.plot_directory / f"{ds}-{md}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}.png", xsize=3, ysize=1.5) - # Plot average loss - plot = histogram.LinePlot() - for _, avgloss in results: - plot.include(avgloss, "Average loss", errs="-err", lalp=0.8) - plot.finalize(None, "Step number", "Average loss", xmin=0, xmax=1000, ymin=0, ymax=.6, legend=legend) - plot.save(args.plot_directory / f"{ds}-{md}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}-loss.png", xsize=3, ysize=1.5) + # Plot all the experiments + for ds, dsa in (("svm-phishing", None),): + for md, mda in (("simples-logit", "din:68"),): + for epsilon in (None, 0.1, 0.2, 0.5): + for batch_size in (10, 25, 50, 100, 250, 500): + legend = list() + results = list() + # Pre-process results for all available combinations of GAR and attack + for gar, attacks in ( + ("average", (("nan", None),)), + ("brute", (("little", ("factor:1.5", "negative:True")), ("empire", "factor:1.1"))), + ): + for attack, _ in attacks: + name = f"{ds}-{md}-{gar}-{attack}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}" + key = f"{gar_to_legend.get(gar, gar.capitalize())} ({'no attack' if gar == 'average' else attack})" + legend.append(key) + results.append(compute_avg_err(name, "Accuracy", "Average loss")) + # Plot top-1 cross-accuracy + plot = histogram.LinePlot() + for crossacc, _ in results: + plot.include(crossacc, "Accuracy", errs="-err", lalp=0.8) + plot.finalize( + None, "Step number", "Cross-accuracy", xmin=0, xmax=1000, ymin=0, ymax=1, legend=legend + ) + plot.save( + args.plot_directory / f"{ds}-{md}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}.png", + xsize=3, + ysize=1.5, + ) + # Plot average loss + plot = histogram.LinePlot() + for _, avgloss in results: + plot.include(avgloss, "Average loss", errs="-err", lalp=0.8) + plot.finalize( + None, "Step number", "Average loss", xmin=0, xmax=1000, ymin=0, ymax=0.6, legend=legend + ) + plot.save( + args.plot_directory + / f"{ds}-{md}-e_{'inf' if epsilon is None else epsilon}-b_{batch_size}-loss.png", + xsize=3, + ysize=1.5, + ) diff --git a/tools/__init__.py b/tools/__init__.py deleted file mode 100644 index fe1aecc..0000000 --- a/tools/__init__.py +++ /dev/null @@ -1,308 +0,0 @@ -# coding: utf-8 -### - # @file __init__.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Bunch of useful tools, but each too small to have its own package. -### - -import io -import os -import pathlib -import sys -import threading -import traceback - -# ---------------------------------------------------------------------------- # -# User exception base class, print string representation and exit(1) on uncaught - -class UserException(Exception): - """ User exception base class. - """ - pass - -# ---------------------------------------------------------------------------- # -# Context and color management - -class Context: - """ Per-thread context and color management static class. - """ - - # Constants - __colors = { "header": "\033[1;30m", - "red": "\033[1;31m", "error": "\033[1;31m", - "green": "\033[1;32m", "success": "\033[1;32m", - "yellow": "\033[1;33m", "warning": "\033[1;33m", - "blue": "\033[1;34m", "info": "\033[1;34m", - "gray": "\033[1;30m", "trace": "\033[1;30m" } - __clrend = "\033[0m" - - # Thread-local variables - __local = threading.local() - - @classmethod - def __local_init(self): - """ Initialize the thread local data if necessary. - """ - if not hasattr(self.__local, "stack"): - self.__local.stack = [] # List of pairs (context name, color code) - self.__local.header = "" # Current header string - self.__local.color = self.__clrend # Current color code - - @classmethod - def __rebuild(self): - """ Rebuild the header and apply the current color. - """ - # Collect current header and color - header = "" - color = None - for ctx, clr in reversed(self.__local.stack): - if ctx is not None: - header = "[" + ctx + "] " + header - if clr is not None: - if color is None: - color = clr - if color is None: - color = self.__clrend - # Prepend thread name if not main thread - cthrd = threading.current_thread() - if cthrd != threading.main_thread(): - header = "[" + cthrd.name + "] " + header - # Store the new header and color - self.__local.header = header - self.__local.color = color - - @classmethod - def _get(self): - """ Get the thread-local header and color. - Returns: - Current header, begin header color, begin color, ending color - """ - self.__local_init() - return self.__local.header, self.__colors["header"], self.__local.color, self.__clrend - - def __init__(self, cntxtname, colorname): - """ Color selection constructor. - Args: - cntxtname Context name (None for none) - colorname Color name (None for no change) - """ - # Color code resolution - if colorname is None: - colorcode = None - else: - assert colorname in type(self).__colors, "Unknown color name " + repr(colorname) - colorcode = type(self).__colors[colorname] - # Finalization - self.__pair = (cntxtname, colorcode) - - def __enter__(self): - """ Enter context. - Returns: - self - """ - type(self).__local_init() - type(self).__local.stack.append(self.__pair) - type(self).__rebuild() - return self - - def __exit__(self, *args, **kwargs): - """ Leave context. - Args: - ... Ignored arguments - """ - type(self).__local.stack.pop() - type(self).__rebuild() - -class ContextIOWrapper: - """ Context-aware text IO wrapper class. - """ - - def __init__(self, output, nocolor=None): - """ New line no color assumed constructor. - Args: - output Wrapped output - nocolor Whether to apply colors or not (if None, no color for non-TTY) - """ - # Check whether to apply coloring if unset - if nocolor is None: - nocolor = not output.isatty() - # Finalization - self.__newline = True # At a new line - self.__colored = True # Color has been applied - self.__output = output - self.__nocolor = nocolor - - def __getattr__(self, name): - """ Forward non-overloaded attributes. - Args: - name Non-overloaded attribute name - Returns: - Non-overloaded attribute - """ - return getattr(self.__output, name) - - def write(self, text): - """ Wrap the given text with the context if necessary. - Args: - text Text to update and write - Returns: - Forwarded value - """ - # Get the current context - header, clrheader, clrbegin, clrend = Context._get() - if self.__nocolor: - clrheader = "" - clrbegin = "" - clrend = "" - # Prepend the header to every line - lines = text.splitlines(True) - text = "" - for line in lines: - if self.__newline: - text += clrheader + header - text += clrbegin - self.__newline = True - text += line - if len(lines) > 0 and lines[-1][-len(os.linesep):] != os.linesep: - self.__newline = False - # Write the modified text with the right color - return self.__output.write(text + clrend) - -def _make_color_print(color): - """ Build the closure that wrap a 'print' inside a colored context. - Args: - color Target color name - Returns: - Print wrapper closure - """ - def color_print(*args, context=None, **kwargs): - """ Print in 'color'. - Args: - context Context name to use - ... Forwarded arguments - Returns: - Forwarded return value - """ - with Context(context, color): - return print(*args, **kwargs) - return color_print - -# Shortcut for colored print -for color in ["trace", "info", "success", "warning", "error"]: - globals()[color] = _make_color_print(color) -def fatal(*args, with_traceback=False, **kwargs): - """ Error colored print that calls 'exit(1)' instead of returning. - Args: - with_traceback Include a traceback after the message - ... Forwarded arguments - """ - global error - error(*args, **kwargs) - if with_traceback: - with Context("traceback", "trace"): - traceback.print_exc() - exit(1) - -# Wrap the standard text output wrappers -sys.stdout = ContextIOWrapper(sys.stdout) -sys.stderr = ContextIOWrapper(sys.stderr) - -# ---------------------------------------------------------------------------- # -# Uncaught exception context wrapping - -def uncaught_wrap(hook): - """ Wrap an uncaught hook with a context. - Args: - hook Uncaught hook to wrap - Returns: - Wrapped uncaught hook - """ - def uncaught_call(etype, evalue, traceback): - """ Update context, check if user exception or forward-call. - Args: - etype Exception class - evalue Exception value - traceback Traceback at the exception - Returns: - Forwarded value - """ - if issubclass(etype, UserException): - with Context("fatal", "error"): - print(evalue) - else: - with Context("uncaught", "error"): - return hook(etype, evalue, traceback) - return uncaught_call - -# Wrap the original exception hook -sys.excepthook = uncaught_wrap(sys.excepthook) - -# ---------------------------------------------------------------------------- # -# Local module loading and post-processing - -_imported = dict() # Map symbol name -> module source name - -def import_exported_symbols(name, module, scope): - """ Import the exported objects of the loaded module into the given scope. - Args: - name Module name - module Module instance - scope Target scope - """ - global _imported - if hasattr(module, "__all__"): - for symname in getattr(module, "__all__"): - # Check name - if not hasattr(module, symname): - with Context(None, "warning"): - print("Symbol " + repr(symname) + " exported but not defined") - continue - if symname in _imported: - with Context(None, "warning"): - print("Symbol " + repr(symname) + " already exported by " + repr(_imported[symname])) - continue - if symname in scope: - with Context(None, "warning"): - print("Symbol " + repr(symname) + " already exported by '__init__.py'") - continue - # Import in module scope - scope[symname] = getattr(module, symname) - _imported[symname] = name - -def import_directory(dirpath, scope, post=import_exported_symbols, ignore=["__init__"]): - """ Import every module from the given directory in the given scope. - Args: - dirpath Directory path - scope Target scope - post Post module import function (name, module, scope) -> None - ignore List of module names to ignore - """ - # Import in the scope of the caller - for path in dirpath.iterdir(): - if path.is_file() and path.suffix == ".py": - name = path.stem - if "." in name or name in ignore: - continue - with Context(name, None): - try: - # Load module - base = __import__(scope["__package__"], scope, scope, [name], 0) - # Post processing - if callable(post): - post(name, getattr(base, name), scope) - except Exception as err: - with Context(None, "warning"): - print("Loading failed for module " + repr(path.name) + ": " + str(err)) - with Context("traceback", "trace"): - traceback.print_exc() - -with Context("tools", None): - import_directory(pathlib.Path(__file__).parent, globals()) diff --git a/tools/jobs.py b/tools/jobs.py deleted file mode 100644 index 4e9fc4b..0000000 --- a/tools/jobs.py +++ /dev/null @@ -1,248 +0,0 @@ -# coding: utf-8 -### - # @file jobs.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2020-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Simple job management for reproduction scripts. -### - -__all__ = ["dict_to_cmdlist", "Command", "Jobs"] - -import shlex -import subprocess -import threading - -import tools - -# ---------------------------------------------------------------------------- # -# Helpers - -def move_directory(path): - """ Move existing directory to a new location (with a numbering scheme). - Args: - path Path to the directory to create - Returns: - 'path' (to enable chaining) - """ - # Move directory if it exists - if path.exists(): - if not path.is_dir(): - raise RuntimeError(f"Expected to find nothing or (a symlink to) a directory at {str(path)!r}") - i = 0 - while True: - mvpath = path.parent / f"{path.name}.{i}" - if not mvpath.exists(): - path.rename(mvpath) - break - i += 1 - # Enable chaining - return path - -def dict_to_cmdlist(dp): - """ Transform a dictionary into a list of command arguments. - Args: - dp Dictionary mapping parameter name (to prepend with "--") to parameter value (to convert to string) - Returns: - Associated list of command arguments - Notes: - For entries mapping to 'bool', the parameter is included/discarded depending on whether the value is True/False - For entries mapping to 'list' or 'tuple', the parameter is followed by all the values as strings - """ - cmd = list() - for name, value in dp.items(): - if isinstance(value, bool): - if value: - cmd.append(f"--{name}") - else: - if any(isinstance(value, typ) for typ in (list, tuple)): - cmd.append(f"--{name}") - for subval in value: - cmd.append(str(subval)) - elif value is not None: - cmd.append(f"--{name}") - cmd.append(str(value)) - return cmd - -# ---------------------------------------------------------------------------- # -# Job command class - -class Command: - """ Simple job command class, that builds a command from a dictionary of parameters. - """ - - def __init__(self, command): - """ Bind constructor. - Args: - command Command iterable (will be copied) - """ - self._basecmd = list(command) - - def build(self, seed, device, resdir): - """ Build the final command line. - Args: - seed Seed to use - device Device to use - resdir Target directory path - Returns: - Final command list - """ - # Build final command list - cmd = self._basecmd.copy() - for name, value in (("seed", seed), ("device", device), ("result-directory", resdir)): - cmd.append(f"--{name}") - cmd.append(shlex.quote(value if isinstance(value, str) else str(value))) - # Return final command list - return cmd - -# ---------------------------------------------------------------------------- # -# Job class - -class Jobs: - """ Take experiments to run and runs them on the available devices, managing repetitions. - """ - - @staticmethod - def _run(topdir, name, seed, device, command): - """ Run the attack experiments with the given named parameters. - Args: - topdir Parent result directory - name Experiment unique name - seed Experiment seed - device Device on which to run the experiments - command Command to run - """ - # Add seed to name - name = "%s-%d" % (name, seed) - # Process experiment - with tools.Context(name, "info"): - finaldir = topdir / name - # Check whether the experiment was already successful - if finaldir.exists(): - tools.info("Experiment already processed.") - return - # Move-make the pending result directory - resdir = move_directory(topdir / f"{name}.pending") - resdir.mkdir(mode=0o755, parents=True) - # Build the command - args = command.build(seed, device, resdir) - # Launch the experiment and write the standard output/error - tools.trace((" ").join(shlex.quote(arg) for arg in args)) - cmd_res = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if cmd_res.returncode == 0: - tools.info("Experiment successful") - else: - tools.warning("Experiment failed") - finaldir = topdir / f"{name}.failed" - move_directory(finaldir) - resdir.rename(finaldir) - (finaldir / "stdout.log").write_bytes(cmd_res.stdout) - (finaldir / "stderr.log").write_bytes(cmd_res.stderr) - - def _worker_entrypoint(self, device): - """ Worker entry point. - Args: - device Device to use - """ - while True: - # Take a pending experiment, or exit if requested - with self._lock: - while True: - # Check if must exit - if self._jobs is None: - return - # Check and pick the first pending experiment, if available - if len(self._jobs) > 0: - name, seed, command = self._jobs.pop() - break - # Wait for new job notification - self._cvready.wait() - # Run the picked experiment - self._run(self._res_dir, name, seed, device, command) - - def __init__(self, res_dir, devices=["cpu"], devmult=1, seeds=tuple(range(1, 6))): - """ Initialize the instance, launch the worker pool. - Args: - res_dir Path to the directory containing the result sub-directories - devices List/tuple of the devices to use in parallel - devmult How many experiments are run in parallel per device - seeds List/tuple of seeds to repeat the experiments with - """ - # Initialize instance - self._res_dir = res_dir - self._jobs = list() # List of tuples (name, seed, command), or None to signal termination - self._workers = list() # Worker pool, one per target device - self._devices = devices - self._seeds = seeds - self._lock = threading.Lock() - self._cvready = threading.Condition(lock=self._lock) # Signal jobs have been added and must be processed, or the worker must quit - self._cvdone = threading.Condition(lock=self._lock) # Signal jobs have all been processed - # Launch the worker pool - for _ in range(devmult): - for device in devices: - thread = threading.Thread(target=self._worker_entrypoint, name=device, args=(device,)) - thread.start() - self._workers.append(thread) - - def get_seeds(self): - """ Get the list of seeds used for repeating the experiments. - Returns: - List/tuple of seeds used - """ - return self._seeds - - def close(self): - """ Close and wait for the worker pool, discarding not yet started submission. - """ - # Close the manager - with self._lock: - # Check if already closed - if self._jobs is None: - return - # Reset submission list - self._jobs = None - # Notify all the workers - self._cvready.notify_all() - # Wait for all the workers - for worker in self._workers: - worker.join() - - def submit(self, name, command): - """ Submit an experiment to be run with each seed on any available device. - Args: - name Experiment unique name - command Command to process - """ - with self._lock: - # Check if not closed - if self._jobs is None: - raise RuntimeError("Experiment manager cannot take new jobs as it has been closed") - # Submit the experiment with each seed - for seed in self._seeds: - self._jobs.insert(0, (name, seed, command)) - self._cvready.notify(n=len(self._seeds)) - - def wait(self, predicate=None): - """ Wait for all the submitted jobs to be processed. - Args: - predicate Custom predicate to call to check whether must stop waiting - """ - while True: - with self._lock: - # Wait for condition or timeout - self._cvdone.wait(timeout=1.) - # Check status - if self._jobs is None: - break - if len(self._jobs) == 0: - break - if not any(worker.is_alive() for worker in self._workers): - break - if predicate is not None and predicate(): - break diff --git a/tools/misc.py b/tools/misc.py deleted file mode 100644 index a47f196..0000000 --- a/tools/misc.py +++ /dev/null @@ -1,570 +0,0 @@ -# coding: utf-8 -### - # @file misc.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Miscellaneous Python helpers. -### - -__all__ = [ - "UnavailableException", "fatal_unavailable", "MethodCallReplicator", - "ClassRegister", "parse_keyval", "fullqual", "onetime", "TimedContext", - "interactive", "get_loaded_dependencies", "line_maximize", "pairwise", - "localtime", "deltatime_point", "deltatime_format"] - -import os -import pathlib -import site -import sys -import threading -import time -import traceback - -import tools - -# ---------------------------------------------------------------------------- # -# Unavailable user exception class - -def make_unavailable_exception_text(data, name, what="entry"): - """ Make the explanatory string for an 'UnavailableException'. - Args: - data Iterable (over str) data set - name Requested name in the data set - what Textual description of what are the objects in the data set - """ - # Preparation - if len(data) == 0: - end = "no %s available" % what - else: - sep = "%s· " % os.linesep - end = "expected one of:%s%s" % (sep, sep.join(data)) - # Final string cat - return "Unknown %s %r, %s" % (what, name, end) - -def fatal_unavailable(*args, **kwargs): - """ Helper forwarding the 'UnavailableException' explanatory string to 'fatal'. - Args: - ... Forward (keyword-)arguments to 'make_unavailable_exception_text' - """ - tools.fatal(make_unavailable_exception_text(*args, **kwargs)) - -class UnavailableException(tools.UserException): - """ Exception due to missing entry in a dictionary, where the entry is controlled by the user. - """ - - def __init__(self, *args, **kwargs): - """ Error string constructor. - Args: - ... Forward (keyword-)arguments to 'make_unavailable_exception_text' - """ - # Finalization - self._text = make_unavailable_exception_text(*args, **kwargs) - - def __str__(self): - """ Error to string conversion. - Returns: - Explanatory string - """ - return self._text - -# ---------------------------------------------------------------------------- # -# Simple method call replicator - -class MethodCallReplicator: - """ Simple method call replicator class. - """ - - def __init__(self, *args): - """ Bind constructor. - Args: - ... Instance on which to replicate method calls (in the given order) - """ - # Assertions - assert len(args) > 0, "Expected at least one instance on which to forward method calls" - # Finalization - self.__instances = args - - def __getattr__(self, name): - """ Returns a closure that replicate the method call. - Args: - name Name of the method - Returns: - Closure replicating the calls - """ - # Target closures - closures = [getattr(instance, name) for instance in self.__instances] - # Replication closure - def calls(*args, **kwargs): - """ Simply replicate the calls, forwarding arguments. - Args: - ... Forwarded arguments - Returns: - List of returned values - """ - return [closure(*args, **kwargs) for closure in closures] - # Build the replication closure - return calls - -# ---------------------------------------------------------------------------- # -# Simple class register - -class ClassRegister: - """ Simple class register. - """ - - def __init__(self, singular, optplural=None): - """ Denomination constructor. - Args: - singular Singular denomination of the registered class - optplural "Optional plural", e.g. "class(es)" for "class" (optional) - """ - # Value deduction - if optplural is None: - optplural = singular + "(s)" - # Finalization - self.__denoms = (singular, optplural) - self.__register = {} - - def itemize(self): - """ Build an iterable over the available class names. - Returns: - Iterable over the available class names - """ - return self.__register.keys() - - def register(self, name, cls): - """ Register a new class. - Args: - name Class name - cls Associated class - """ - # Assertions - assert name not in self.__register, "Name " + repr(name) + " already in use while registering " + repr(getattr(cls, "__name__", "")) - # Registering - self.__register[name] = cls - - def instantiate(self, name, *args, **kwargs): - """ Instantiate a registered class. - Args: - name Class name - ... Forwarded parameters - Returns: - Registered class instance - """ - # Assertions - if name not in self.__register: - cause = "Unknown name " + repr(name) + ", " - if len(self.__register) == 0: - cause += "no registered " + self.__denoms[0] - else: - cause += "available " + self.__denoms[1] + ": '" + ("', '").join(self.__register.keys()) + "'" - raise tools.UserException(cause) - # Instantiation - return self.__register[name](*args, **kwargs) - -# ---------------------------------------------------------------------------- # -# Simple list of ":" into dictionary parser - -def parse_keyval_auto_convert(val): - """ Guess the type of the string representation, and return the converted value. - Args: - val Value to convert after type guessing - Returns: - Converted value, or same instance as 'val' if 'str' was the guessed type - """ - # Try guess 'bool' - low = val.lower() - if low == "false": - return False - elif low == "true": - return True - # Try guess number - for cls in (int, float): - try: - return cls(val) - except ValueError: - continue - # Else guess string - return val - -def parse_keyval(list_keyval, defaults={}): - """ Parse list of ":" into a dictionary. - Args: - list_keyval List of ":" - defaults Default key -> value to use (also ensure type, type is guessed for other keys) - Returns: - Associated dictionary - """ - parsed = {} - # Parsing - sep = ":" - for entry in list_keyval: - pos = entry.find(sep) - if pos < 0: - raise tools.UserException("Expected list of " + repr(":") + ", got " + repr(entry) + " as one entry") - key = entry[:pos] - if key in parsed: - raise tools.UserException("Key " + repr(key) + " had already been specified with value " + repr(parsed[key])) - val = entry[pos + len(sep):] - # Guess/assert type constructibility - if key in defaults: - try: - cls = type(defaults[key]) - if cls is bool: # Special case - val = val.lower() not in ("", "0", "n", "false") - else: - val = cls(val) - except Exception: - raise tools.UserException("Required key " + repr(key) + " expected a value of type " + repr(getattr(type(defaults[key]), "__name__", ""))) - else: - val = parse_keyval_auto_convert(val) - # Bind (converted) value to associated key - parsed[key] = val - # Add default values (done first to be able to force a given type with 'required') - for key in defaults: - if key not in parsed: - parsed[key] = defaults[key] - # Return final dictionary - return parsed - -# ---------------------------------------------------------------------------- # -# Basic "full-qualification" string builder for a given instance/class - -def fullqual(obj): - """ Rebuild a string "qualifying" the given object for debugging purpose. - Args: - obj Object to "qualify" - Returns: - "Qualification", e.g.: 'tools.misc.fullqual' or 'instance of pathlib.Path' - """ - # Prelude - if isinstance(obj, type): - prelude = "" - else: - prelude = "instance of " - obj = type(obj) - # Rebuilding - return "%s%s.%s" % (prelude, getattr(obj, "__module__", ""), getattr(obj, "__qualname__", "")) - -# ---------------------------------------------------------------------------- # -# Basic "full-qualification" string builder for a given instance/class - -def onetime(name=None): - """ Generate a one time-set (hidden) state variable getter and setter. - Args: - name Optional name of the global, onetime variable to access - Returns: - · (Threadsafe) getter closure - · (Threadsafe) setter closure - """ - global onetime_register - # Check if name exists - if name is not None and name in onetime_register: - return onetime_register[name] - # Private variables - lock = threading.Lock() - value = False - # Management closures - def getter(*args, **kwargs): - """ Check whether 'value' is set. - Args: - ... Ignored arguments - Returns: - Whether 'value' is set - """ - nonlocal lock - nonlocal value - with lock: - return value - def setter(*args, **kwargs): - """ Set 'value'. - Args: - ... Ignored arguments - """ - nonlocal lock - nonlocal value - with lock: - value = True - # Register if need be, then return the management closures - res = (getter, setter) - if name is not None: - onetime_register[name] = res - return res - -# Register for the onetime variables -onetime_register = dict() - -# ---------------------------------------------------------------------------- # -# Plain context augmented with simple execution time measurement - -class TimedContext(tools.Context): - """ Timed context class, that print the measure runtime. - """ - - def __init__(self, *args, **kwargs): - """ Forward call to parent constructor. - Args: - ... Forwarded (keyword-)arguments - """ - super().__init__(*args, **kwargs) - - def __enter__(self): - """ Enter context: start chrono. - Returns: - Forwarded return value from parent - """ - self._chrono = time.time() - return super().__enter__() - - def __exit__(self, *args, **kwargs): - """ Exit context: stop chrono and print elapsed time. - Args: - ... Forwarded arguments - """ - # Measure elapsed runtime (in ns) - runtime = (time.time() - self._chrono) * 1000000000. - # Recover ideal unit - for unit in ("ns", "µs", "ms"): - if runtime < 1000.: - break - runtime /= 1000. - else: - unit = "s" - # Format and print string - tools.trace(f"Execution time: {runtime:.3g} {unit}") - # Forward call - super().__exit__(*args, **kwargs) - -# ---------------------------------------------------------------------------- # -# Switch to interactive mode, executing user inputs - -def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): - """ Switch to a simple interactive prompt, execute CTRL+D (or equivalent) to leave. - Args: - glbs Globals dictionary to use, None to use caller's globals - lcls Locals dictionary to use, None to use given globals or caller's locals/globals - prompt Command prompt to display - cprmpt Command prompt to display when continuing a line - """ - # Recover caller's globals and locals - try: - caller = sys._getframe().f_back - except Exception: - caller = None - if glbs is None: - tools.warning("Unable to recover caller's frame, locals and globals", context="interactive") - if glbs is None: - if caller is not None and hasattr(caller, "f_globals"): - glbs = caller.f_globals - else: - glbs = dict() - if lcls is None: - if caller is not None and hasattr(caller, "f_locals"): - lcls = caller.f_locals - else: - lcls = glbs - # Command input and execution - command = "" - statement = False - while True: - print(prompt if len(command) == 0 else cprmpt, end="", flush=True) - try: - # Input new line - try: - line = input() - print("\033[A") # Trick to "advertise" new line on stdout after new line on stdin - except BaseException as err: - if any(isinstance(err, cls) for cls in (EOFError, KeyboardInterrupt)): - print() # Since no new line was printed by pressing ENTER - return - # Handle expression - if not statement: - try: - res = eval(line, glbs, lcls) - if res is not None: - print(res) - except SyntaxError: # Heuristic that we are dealing with a statement - statement = True - # Handle single or multi-line statement(s) - if statement: - if len(command) == 0: # Just went through trying an expression - command = line - try: - exec(command, glbs, lcls) - except SyntaxError: # Heuristic that we are dealing with a multi-line statement - continue - elif len(line) > 0: - command += os.linesep + line - continue - else: # Multi-line statement is complete - exec(command, glbs, lcls) - except Exception: - with tools.Context("uncaught", "error"): - traceback.print_exc() - command = "" - statement = False - -# ---------------------------------------------------------------------------- # -# List non-standard, currently loaded module names and metadata. - -def get_loaded_dependencies(): - """ List non-builtin, currently loaded root module names and metadata. - Returns: - List of tuples (, , <0: is standard, 1: is site-specific, 2: is local>) - Raises: - 'RuntimeError' on unsupported platforms - """ - # Get the site-packages directories, and make "flavor"-detection closure - path_sites = tuple(pathlib.Path(path) for path in site.getsitepackages() + [site.getusersitepackages()]) - def flavor_of(path): - path = pathlib.Path(path) - for path_site in path_sites: - try: - path.relative_to(path_site) - return get_loaded_dependencies.IS_SITE - except ValueError: - pass - for path_site in path_sites: - try: - path.relative_to(path_site.parent) - return get_loaded_dependencies.IS_STANDARD - except ValueError: - pass - return get_loaded_dependencies.IS_LOCAL - # Iterate over the loaded modules - res = list() - for name, module in sys.modules.items(): - # Skip non-root modules - if "." in name: - continue - # Get module path (and so skip built-in modules) - path = getattr(module, "__file__", None) - if path is None: - continue - # Get module version (if any) - version = getattr(module, "__version__", None) - # Get module "flavor" - flavor = flavor_of(path) - # Store entry - res.append((name, version, flavor)) - # Return found root modules - return res - -# Register constants -get_loaded_dependencies.IS_STANDARD = 0 -get_loaded_dependencies.IS_SITE = 1 -get_loaded_dependencies.IS_LOCAL = 2 - -# ---------------------------------------------------------------------------- # -# Find the x maximizing a function y = f(x), with (x, y) ∊ ℝ⁺× ℝ - -def line_maximize(scape, evals=16, start=0., delta=1., ratio=0.8): - """ Best-effort arg-maximize a scape: ℝ⁺⟶ ℝ, by mere exploration. - Args: - scape Function to best-effort arg-maximize - evals Maximum number of evaluations, must be a positive integer - start Initial x evaluated, must be a non-negative float - delta Initial step delta, must be a positive float - ratio Contraction ratio, must be between 0.5 and 1. (both excluded) - Returns: - Best-effort maximizer x under the evaluation budget - """ - # Variable setup - best_x = start - best_y = scape(best_x) - evals -= 1 - # Expansion phase - while evals > 0: - prop_x = best_x + delta - prop_y = scape(prop_x) - evals -= 1 - # Check if best - if prop_y > best_y: - best_y = prop_y - best_x = prop_x - delta *= 2 - else: - delta *= ratio - break - # Contraction phase - while evals > 0: - if prop_x < best_x: - prop_x += delta - else: - x = prop_x - delta - while x < 0: - x = (x + prop_x) / 2 - prop_x = x - prop_y = scape(prop_x) - evals -= 1 - # Check if best - if prop_y > best_y: - best_y = prop_y - best_x = prop_x - # Reduce delta - delta *= ratio - # Return found maximizer - return best_x - -# ---------------------------------------------------------------------------- # -# Simple generator on the pairs (x, y) of an indexable such that index x < index y - -def pairwise(data): - """ Simple generator of the pairs (x, y) in a tuple such that index x < index y. - Args: - data Indexable (including ability to query length) containing the elements - Returns: - Generator over the pairs of the elements of 'data' - """ - n = len(data) - for i in range(n - 1): - for j in range(i + 1, n): - yield (data[i], data[j]) - -# ---------------------------------------------------------------------------- # -# Simple duration helpers - -def localtime(): - """ Return the formatted local time. - Returns: - Human-readable local time - """ - lt = time.localtime() - return f"{lt.tm_year:04}/{lt.tm_mon:02}/{lt.tm_mday:02} {lt.tm_hour:02}:{lt.tm_min:02}:{lt.tm_sec:02}" - -def deltatime_point(): - """ Take a point in time. - Returns: - Opaque point-in-time - """ - point = time.monotonic_ns() - return (point + 5 * 10 ** 8) // 10 ** 9 - -def deltatime_format(a, b): - """ Compute and format the time elapsed between two points in time. - Args: - a Earlier point-in-time - b Later point-in-time - Returns: - Elapsed time integer (in s), - Formatted elapsed time string (human-readable way) - """ - # Elapsed time (in seconds) - t = b - a - # Elapsed time (formatted) - d = t - s = d % 60 - d //= 60 - m = d % 60 - d //= 60 - h = d % 24 - d //= 24 - # Return elapsed time - return t, f"{d} day(s), {h} hour(s), {m} min(s), {s} sec(s)" diff --git a/tools/pytorch.py b/tools/pytorch.py deleted file mode 100644 index 05f6928..0000000 --- a/tools/pytorch.py +++ /dev/null @@ -1,294 +0,0 @@ -# coding: utf-8 -### - # @file pytorch.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2018-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Helpers relative to PyTorch. -### - -__all__ = ["relink", "flatten", "grad_of", "grads_of", "compute_avg_dev_max", - "AccumulatedTimedContext", "weighted_mse_loss", "WeightedMSELoss", - "regression", "pnm"] - -import math -import time -import torch -import types - -import tools - -# ---------------------------------------------------------------------------- # -# "Flatten" and "relink" operations - -def relink(tensors, common): - """ "Relink" the tensors of class (deriving from) Tensor by making them point to another contiguous segment of memory. - Args: - tensors Generator of/iterable on instances of/deriving from Tensor, all with the same dtype - common Flat tensor of sufficient size to use as underlying storage, with the same dtype as the given tensors - Returns: - Given common tensor - """ - # Convert to tuple if generator - if isinstance(tensors, types.GeneratorType): - tensors = tuple(tensors) - # Relink each given tensor to its segment on the common one - pos = 0 - for tensor in tensors: - npos = pos + tensor.numel() - tensor.data = common[pos:npos].view(*tensor.shape) - pos = npos - # Finalize and return - common.linked_tensors = tensors - return common - -def flatten(tensors): - """ "Flatten" the tensors of class (deriving from) Tensor so that they all use the same contiguous segment of memory. - Args: - tensors Generator of/iterable on instances of/deriving from Tensor, all with the same dtype - Returns: - Flat tensor (with the same dtype as the given tensors) that contains the memory used by all the given Tensor (or derived instances), in emitted order - """ - # Convert to tuple if generator - if isinstance(tensors, types.GeneratorType): - tensors = tuple(tensors) - # Common tensor instantiation and reuse - common = torch.cat(tuple(tensor.view(-1) for tensor in tensors)) - # Return common tensor - return relink(tensors, common) - -# ---------------------------------------------------------------------------- # -# Gradient access - -def grad_of(tensor): - """ Get the gradient of a given tensor, make it zero if missing. - Args: - tensor Given instance of/deriving from Tensor - Returns: - Gradient for the given tensor - """ - # Get the current gradient - grad = tensor.grad - if grad is not None: - return grad - # Make and set a zero-gradient - grad = torch.zeros_like(tensor) - tensor.grad = grad - return grad - -def grads_of(tensors): - """ Iterate of the gradients of the given tensors, make zero gradients if missing. - Args: - tensors Generator of/iterable on instances of/deriving from Tensor - Returns: - Generator of the gradients of the given tensors, in emitted order - """ - return (grad_of(tensor) for tensor in tensors) - -# ---------------------------------------------------------------------------- # -# Useful computations - -def compute_avg_dev_max(samples): - """ Compute the norm average and norm standard deviation of gradient samples. - Args: - samples Given gradient samples - Returns: - Computed average gradient (None if no sample), norm average, norm standard deviation, average maximum absolute coordinate - """ - # Trivial case no sample - if len(samples) == 0: - return None, math.nan, math.nan, math.nan - # Compute average gradient and max abs coordinate - grad_avg = samples[0].clone().detach_() - for grad in samples[1:]: - grad_avg.add_(grad) - grad_avg.div_(len(samples)) - norm_avg = grad_avg.norm().item() - norm_max = grad_avg.abs().max().item() - # Compute norm standard deviation - if len(samples) >= 2: - norm_var = 0. - for grad in samples: - grad = grad.sub(grad_avg) - norm_var += grad.dot(grad).item() - norm_var /= len(samples) - 1 - norm_dev = math.sqrt(norm_var) - else: - norm_dev = math.nan - # Return norm average and deviation - return grad_avg, norm_avg, norm_dev, norm_max - -# ---------------------------------------------------------------------------- # -# Simple timing context - -class AccumulatedTimedContext: - """ Accumulated timed context class, that do not print. - """ - - def _sync_cuda(self): - """ Synchronize CUDA streams (if requested and relevant). - """ - if self._sync and torch.cuda.is_available(): - torch.cuda.synchronize() - - def __init__(self, initial=0., *, sync=False): - """ Zero runtime constructor. - Args: - initial Initial total runtime (in s) - sync Whether to synchronize with already running/launched CUDA streams - """ - # Finalization - self._total = initial # Total runtime (in s) - self._sync = sync - - def __enter__(self): - """ Enter context: start chrono. - Returns: - Self - """ - # Synchronize CUDA streams (if requested and relevant) - self._sync_cuda() - # "Start" chronometer - self._chrono = time.time() - # Return self - return self - - def __exit__(self, *args, **kwargs): - """ Exit context: stop chrono and accumulate elapsed time. - Args: - ... Ignored - """ - # Synchronize CUDA streams (if requested and relevant) - self._sync_cuda() - # Accumulate elapsed time (in s) - self._total += time.time() - self._chrono - - def __str__(self): - """ Pretty-print total runtime. - Returns: - Total runtime string with unit - """ - # Get total runtime (in ns) - runtime = self._total * 1000000000. - # Recover ideal unit - for unit in ("ns", "µs", "ms"): - if runtime < 1000.: - break - runtime /= 1000. - else: - unit = "s" - # Format and return string - return f"{runtime:.3g} {unit}" - - def current_runtime(self): - """ Get the current accumulated runtime. - Returns: - Current runtime (in s) - """ - return self._total - -# ---------------------------------------------------------------------------- # -# Regression helper - -def weighted_mse_loss(tno, tne, tnw): - """ Weighted mean square error loss. - Args: - tno Output tensor - tne Expected output tensor - tnw Weight tensor - Returns: - Associated loss tensor - """ - return torch.mean((tno - tne).pow_(2).mul_(tnw)) - -class WeightedMSELoss(torch.nn.Module): - """ Weighted mean square error loss class. - """ - - def __init__(self, weight, *args, **kwargs): - """ Weight binding constructor. - Args: - weight Weight to bind - ... Forwarding (keyword-)arguments - """ - super().__init__(*args, **kwargs) - self.register_buffer("weight", weight) - - def forward(self, tno, tne): - """ Compute the weighted mean square error. - Args: - tno Output tensor - tne Expeced output tensor - Returns: - Associated loss tensor - """ - return weighted_mse_loss(tno, tne, self.weight) - -def regression(func, vars, data, loss=torch.nn.MSELoss(), opt=torch.optim.Adam, steps=1000): - """ Performs a regression (mere optimization of variables) for the given function. - Args: - func Function to fit - vars Iterable of the free tensor variables to optimize - data Tuple of (input data tensor, expected output data tensor) - loss Loss function to use, taking (output, expected output) - opt Optimizer to use (function mapping a once-iterable of tensors to an optimizer instance) - steps Number of optimization epochs to perform (1 epoch/step) - Returns: - Step at which optimization stopped - """ - # Prepare - tni = data[0] - tne = data[1] - opt = opt(vars) - # Optimize - for step in range(steps): - with torch.enable_grad(): - opt.zero_grad() - res = loss(func(tni), tne) - if torch.isnan(res).any().item(): - return step - res.backward() - opt.step() - return steps - -# ---------------------------------------------------------------------------- # -# Save image as PGM/PBM stream - -def pnm(fd, tn): - """ Save a 2D/3D tensor as a PGM/PBM stream. - Args: - fd File descriptor opened for writing binary streams - tn A 2D/3D tensor to convert and save - Notes: - The input tensor is "intelligently" squeezed before processing - For 2D tensor, assuming black is 1. and white is 0. (clamp between [0, 1]) - For 3D tensor, the first dimension must be the 3 color channels RGB (all between [0, 1]) - """ - shape = tuple(tn.shape) - # Intelligent squeezing - while len(tn.shape) > 3 and tn.shape[0] == 1: - tn = tn[0] - # Colored image generation - if len(tn.shape) == 3: - if tn.shape[0] == 1: - tn = tn[0] - # And continue on gray-scale - elif tn.shape[0] != 3: - raise tools.UserException("Expected 3 color channels for the first dimension of a 3D tensor, got %d channels" % tn.shape[0]) - else: - fd.write(("P6\n%d %d 255\n" % tn.shape[1:]).encode()) - fd.write(bytes(tn.transpose(0, 2).transpose(0, 1).mul(256).clamp_(0., 255.).byte().storage())) - return - # Gray-scale image generation - if len(tn.shape) == 2: - fd.write(("P5\n%d %d 255\n" % tn.shape).encode()) - fd.write(bytes((1.0 - tn).mul_(256).clamp_(0., 255.).byte().storage())) - return - # Invalid tensor shape - raise tools.UserException("Expected a 2D or 3D tensor, got %d dimensions %r" % (len(shape), tuple(shape))) diff --git a/train.py b/train.py index 2f6750c..2bd7a03 100644 --- a/train.py +++ b/train.py @@ -1,37 +1,33 @@ -# coding: utf-8 ### - # @file train.py - # @author Sébastien Rouault - # - # @section LICENSE - # - # Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). - # See LICENSE file. - # - # @section DESCRIPTION - # - # Simulate a training session under attack. +# @file train.py +# @author Sébastien Rouault +# +# @section LICENSE +# +# Copyright © 2019-2021 École Polytechnique Fédérale de Lausanne (EPFL). +# See LICENSE file. +# +# @section DESCRIPTION +# +# Simulate a training session under attack. ### -import tools +from krum import tools + tools.success("Module loading...") import argparse -import collections import json import math import os import pathlib -import random import signal import sys + import torch import torchvision -import traceback -import aggregators -import attacks -import experiments +from krum import aggregators, attacks, experiments # ---------------------------------------------------------------------------- # # Miscellaneous initializations @@ -48,574 +44,656 @@ # Command-line processing tools.success("Command-line processing...") + def process_commandline(): - """ Parse the command-line and perform checks. - Returns: - Parsed configuration - """ - # Description - parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument("--seed", - type=int, - default=-1, - help="Fixed seed to use for reproducibility purpose, negative for random seed") - parser.add_argument("--device", - type=str, - default="auto", - help="Device on which to run the experiment, \"auto\" by default") - parser.add_argument("--device-gar", - type=str, - default="same", - help="Device on which to run the GAR, \"same\" for no change of device") - parser.add_argument("--nb-steps", - type=int, - default=300, - help="Number of training steps to do, non-positive for no limit") - parser.add_argument("--nb-workers", - type=int, - default=11, - help="Total number of worker machines") - parser.add_argument("--nb-decl-byz", - type=int, - default=4, - help="Number of Byzantine worker(s) to support") - parser.add_argument("--nb-real-byz", - type=int, - default=0, - help="Number of actual Byzantine worker(s)") - parser.add_argument("--gar", - type=str, - default="average", - help="(Byzantine-resilient) aggregation rule to use") - parser.add_argument("--gar-args", - nargs="*", - help="Additional GAR-dependent arguments to pass to the aggregation rule") - parser.add_argument("--privacy", - action="store_true", - default=False, - help="Gaussian privacy noise ε constant") - parser.add_argument("--privacy-epsilon", - type=float, - default=0.5, - help="Gaussian privacy noise ε constant; ignore if '--privacy' is not specified") - parser.add_argument("--privacy-delta", - type=float, - default=0.5, - help="Gaussian privacy noise δ constant; ignore if '--privacy' is not specified") - parser.add_argument("--gradient-clip", - type=float, - default=5, - help="Maximum L2-norm, above which clipping occurs, for the estimated gradients") - parser.add_argument("--attack", - type=str, - default="nan", - help="Attack to use") - parser.add_argument("--attack-args", - nargs="*", - help="Additional attack-dependent arguments to pass to the attack") - parser.add_argument("--model", - type=str, - default="simples-conv", - help="Model to train") - parser.add_argument("--model-args", - nargs="*", - help="Additional model-dependent arguments to pass to the model") - parser.add_argument("--loss", - type=str, - default="nll", - help="Loss to use") - parser.add_argument("--loss-args", - nargs="*", - help="Additional loss-dependent arguments to pass to the loss") - parser.add_argument("--criterion", - type=str, - default="top-k", - help="Criterion to use") - parser.add_argument("--criterion-args", - nargs="*", - help="Additional criterion-dependent arguments to pass to the criterion") - parser.add_argument("--dataset", - type=str, - default="mnist", - help="Dataset to use") - parser.add_argument("--dataset-args", - nargs="*", - help="Additional dataset-dependent arguments to pass to the dataset") - parser.add_argument("--batch-size", - type=int, - default=25, - help="Batch-size to use for training, 0 for maximum") - parser.add_argument("--batch-size-test", - type=int, - default=100, - help="Batch-size to use for testing, 0 for maximum") - parser.add_argument("--test-repeat", - type=int, - default=100, - help="How many evaluation(s) with the test batch-size to average for one evaluation") - parser.add_argument("--no-transform", - action="store_true", - default=False, - help="Whether to disable any dataset tranformation (e.g. random flips)") - parser.add_argument("--learning-rate", - type=float, - default=0.01, - help="Learning rate to use for training") - parser.add_argument("--learning-rate-decay", - type=int, - default=5000, - help="Learning rate hyperbolic half-decay time, non-positive for no decay") - parser.add_argument("--learning-rate-decay-delta", - type=int, - default=5000, - help="How many steps between two learning rate updates, must be a positive integer") - parser.add_argument("--momentum", - type=float, - default=0.9, - help="Momentum to use for training") - parser.add_argument("--dampening", - type=float, - default=0., - help="Dampening to use for training") - parser.add_argument("--weight-decay", - type=float, - default=0, - help="Weight decay to use for training") - parser.add_argument("--l1-regularize", - type=float, - default=None, - help="Add L1 regularization of the given factor to the loss") - parser.add_argument("--l2-regularize", - type=float, - default=None, - help="Add L2 regularization of the given factor to the loss") - parser.add_argument("--result-directory", - type=str, - default=None, - help="Path of the directory in which to save the experiment results (loss, cross-accuracy, ...) and checkpoints, empty for no saving") - parser.add_argument("--evaluation-delta", - type=int, - default=100, - help="How many training steps between model evaluations, 0 for no evaluation") - parser.add_argument("--user-input-delta", - type=int, - default=0, - help="How many training steps between two prompts for user command inputs, 0 for no user input") - # Parse command line - return parser.parse_args(sys.argv[1:]) + """Parse the command-line and perform checks. + Returns: + Parsed configuration + """ + # Description + parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument( + "--seed", type=int, default=-1, help="Fixed seed to use for reproducibility purpose, negative for random seed" + ) + parser.add_argument( + "--device", type=str, default="auto", help='Device on which to run the experiment, "auto" by default' + ) + parser.add_argument( + "--device-gar", type=str, default="same", help='Device on which to run the GAR, "same" for no change of device' + ) + parser.add_argument( + "--nb-steps", type=int, default=300, help="Number of training steps to do, non-positive for no limit" + ) + parser.add_argument("--nb-workers", type=int, default=11, help="Total number of worker machines") + parser.add_argument("--nb-decl-byz", type=int, default=4, help="Number of Byzantine worker(s) to support") + parser.add_argument("--nb-real-byz", type=int, default=0, help="Number of actual Byzantine worker(s)") + parser.add_argument("--gar", type=str, default="average", help="(Byzantine-resilient) aggregation rule to use") + parser.add_argument( + "--gar-args", nargs="*", help="Additional GAR-dependent arguments to pass to the aggregation rule" + ) + parser.add_argument("--privacy", action="store_true", default=False, help="Gaussian privacy noise ε constant") + parser.add_argument( + "--privacy-epsilon", + type=float, + default=0.5, + help="Gaussian privacy noise ε constant; ignore if '--privacy' is not specified", + ) + parser.add_argument( + "--privacy-delta", + type=float, + default=0.5, + help="Gaussian privacy noise δ constant; ignore if '--privacy' is not specified", + ) + parser.add_argument( + "--gradient-clip", + type=float, + default=5, + help="Maximum L2-norm, above which clipping occurs, for the estimated gradients", + ) + parser.add_argument("--attack", type=str, default="nan", help="Attack to use") + parser.add_argument("--attack-args", nargs="*", help="Additional attack-dependent arguments to pass to the attack") + parser.add_argument("--model", type=str, default="simples-conv", help="Model to train") + parser.add_argument("--model-args", nargs="*", help="Additional model-dependent arguments to pass to the model") + parser.add_argument("--loss", type=str, default="nll", help="Loss to use") + parser.add_argument("--loss-args", nargs="*", help="Additional loss-dependent arguments to pass to the loss") + parser.add_argument("--criterion", type=str, default="top-k", help="Criterion to use") + parser.add_argument( + "--criterion-args", nargs="*", help="Additional criterion-dependent arguments to pass to the criterion" + ) + parser.add_argument("--dataset", type=str, default="mnist", help="Dataset to use") + parser.add_argument( + "--dataset-args", nargs="*", help="Additional dataset-dependent arguments to pass to the dataset" + ) + parser.add_argument("--batch-size", type=int, default=25, help="Batch-size to use for training, 0 for maximum") + parser.add_argument("--batch-size-test", type=int, default=100, help="Batch-size to use for testing, 0 for maximum") + parser.add_argument( + "--test-repeat", + type=int, + default=100, + help="How many evaluation(s) with the test batch-size to average for one evaluation", + ) + parser.add_argument( + "--no-transform", + action="store_true", + default=False, + help="Whether to disable any dataset tranformation (e.g. random flips)", + ) + parser.add_argument("--learning-rate", type=float, default=0.01, help="Learning rate to use for training") + parser.add_argument( + "--learning-rate-decay", + type=int, + default=5000, + help="Learning rate hyperbolic half-decay time, non-positive for no decay", + ) + parser.add_argument( + "--learning-rate-decay-delta", + type=int, + default=5000, + help="How many steps between two learning rate updates, must be a positive integer", + ) + parser.add_argument("--momentum", type=float, default=0.9, help="Momentum to use for training") + parser.add_argument("--dampening", type=float, default=0.0, help="Dampening to use for training") + parser.add_argument("--weight-decay", type=float, default=0, help="Weight decay to use for training") + parser.add_argument( + "--l1-regularize", type=float, default=None, help="Add L1 regularization of the given factor to the loss" + ) + parser.add_argument( + "--l2-regularize", type=float, default=None, help="Add L2 regularization of the given factor to the loss" + ) + parser.add_argument( + "--result-directory", + type=str, + default=None, + help="Path of the directory in which to save the experiment results (loss, cross-accuracy, ...) and checkpoints, empty for no saving", + ) + parser.add_argument( + "--evaluation-delta", + type=int, + default=100, + help="How many training steps between model evaluations, 0 for no evaluation", + ) + parser.add_argument( + "--user-input-delta", + type=int, + default=0, + help="How many training steps between two prompts for user command inputs, 0 for no user input", + ) + # Parse command line + return parser.parse_args(sys.argv[1:]) + with tools.Context("cmdline", "info"): - args = process_commandline() - # Parse additional arguments - for name in ("gar", "attack", "model", "dataset", "loss", "criterion"): - name = f"{name}_args" - keyval = getattr(args, name) - setattr(args, name, dict() if keyval is None else tools.parse_keyval(keyval)) - # Count the number of real honest workers - args.nb_honests = args.nb_workers - args.nb_real_byz - if args.nb_honests < 0: - tools.fatal(f"Invalid arguments: there are more real Byzantine workers ({args.nb_real_byz}) than total workers ({args.nb_workers})") - # Check general training parameters - if args.momentum < 0.: - tools.fatal(f"Invalid arguments: negative momentum factor {args.momentum}") - if args.dampening < 0.: - tools.fatal(f"Invalid arguments: negative dampening factor {args.dampening}") - if args.weight_decay < 0.: - tools.fatal(f"Invalid arguments: negative weight decay factor {args.weight_decay}") - # Check the learning rate and associated options - if args.learning_rate <= 0: - tools.fatal(f"Invalid arguments: non-positive learning rate {args.learning_rate}") - if args.learning_rate_decay < 0: - tools.fatal(f"Invalid arguments: negative learning rate decay {args.learning_rate_decay}") - if args.learning_rate_decay_delta <= 0: - tools.fatal(f"Invalid arguments: non-positive learning rate decay delta {args.learning_rate_decay_delta}") - # Check the privacy-related metrics - if args.gradient_clip <= 0.: - tools.fatal(f"Invalid arguments: non-positive gradient clip constant {args.gradient_clip}") - if args.privacy: - if args.privacy_epsilon <= 0. or args.privacy_epsilon >= 1.: - tools.fatal(f"Invalid arguments: off-bounds (]0, 1[) ε constant {args.privacy_epsilon}") - if args.privacy_delta <= 0. or args.privacy_delta >= 1.: - tools.fatal(f"Invalid arguments: off-bounds (]0, 1[) δ constant {args.privacy_delta}") - args.privacy_sensitivity = 2 * args.gradient_clip / args.batch_size - # Print configuration - def cmd_make_tree(subtree, level=0): - if isinstance(subtree, tuple) and len(subtree) > 0 and isinstance(subtree[0], tuple) and len(subtree[0]) == 2: - label_len = max(len(label) for label, _ in subtree) - iterator = subtree - elif isinstance(subtree, dict): - if len(subtree) == 0: - return " - " - label_len = max(len(label) for label in subtree.keys()) - iterator = subtree.items() - else: - return f" - {subtree}" - level_spc = " " * level - res = "" - for label, node in iterator: - res += f"{os.linesep}{level_spc}· {label}{' ' * (label_len - len(label))}{cmd_make_tree(node, level + 1)}" - return res - cmdline_config = "Configuration" + cmd_make_tree(( - ("Reproducibility", "not enforced" if args.seed < 0 else f"enforced (seed {args.seed})"), - ("#workers", args.nb_workers), - ("#declared Byz.", args.nb_decl_byz), - ("#actually Byz.", args.nb_real_byz), - ("Model", ( - ("Name", args.model), - ("Arguments", args.model_args))), - ("Dataset", ( - ("Name", args.dataset), - ("Arguments", args.dataset_args), - ("Batch size", ( - ("Training", args.batch_size or 'max'), - ("Testing", f"{args.batch_size_test or 'max'} × {args.test_repeat}"))), - ("Transforms", "none" if args.no_transform else "default"))), - ("Loss", ( - ("Name", args.loss), - ("Arguments", args.loss_args), - ("Regularization", ( - ("l1", "none" if args.l1_regularize is None else args.l1_regularize), - ("l2", "none" if args.l2_regularize is None else args.l2_regularize))))), - ("Criterion", ( - ("Name", args.criterion), - ("Arguments", args.criterion_args))), - ("Optimizer", ( - ("Name", "sgd"), - ("Learning rate", ( - ("Initial", args.learning_rate), - ("Half-decay", args.learning_rate_decay if args.learning_rate_decay > 0 else "none"), - ("Update delta", args.learning_rate_decay_delta if args.learning_rate_decay > 0 else "n/a"))), - ("Momentum", args.momentum), - ("Dampening", args.dampening), - ("Weight decay", args.weight_decay))), - ("Attack", ( - ("Name", args.attack), - ("Arguments", args.attack_args))), - ("Aggregation", ( - ("Name", args.gar), - ("Arguments", args.gar_args))), - ("Differential privacy", ( - ("Enabled?", "yes" if args.privacy else "no"), - ("ε constant", args.privacy_epsilon if args.privacy else "n/a"), - ("δ constant", args.privacy_delta if args.privacy else "n/a"), - ("l2-sensitivity", args.privacy_sensitivity if args.privacy else "n/a"))))) - print(cmdline_config) + args = process_commandline() + # Parse additional arguments + for name in ("gar", "attack", "model", "dataset", "loss", "criterion"): + name = f"{name}_args" + keyval = getattr(args, name) + setattr(args, name, dict() if keyval is None else tools.parse_keyval(keyval)) + # Count the number of real honest workers + args.nb_honests = args.nb_workers - args.nb_real_byz + if args.nb_honests < 0: + tools.fatal( + f"Invalid arguments: there are more real Byzantine workers ({args.nb_real_byz}) than total workers ({args.nb_workers})" + ) + # Check general training parameters + if args.momentum < 0.0: + tools.fatal(f"Invalid arguments: negative momentum factor {args.momentum}") + if args.dampening < 0.0: + tools.fatal(f"Invalid arguments: negative dampening factor {args.dampening}") + if args.weight_decay < 0.0: + tools.fatal(f"Invalid arguments: negative weight decay factor {args.weight_decay}") + # Check the learning rate and associated options + if args.learning_rate <= 0: + tools.fatal(f"Invalid arguments: non-positive learning rate {args.learning_rate}") + if args.learning_rate_decay < 0: + tools.fatal(f"Invalid arguments: negative learning rate decay {args.learning_rate_decay}") + if args.learning_rate_decay_delta <= 0: + tools.fatal(f"Invalid arguments: non-positive learning rate decay delta {args.learning_rate_decay_delta}") + # Check the privacy-related metrics + if args.gradient_clip <= 0.0: + tools.fatal(f"Invalid arguments: non-positive gradient clip constant {args.gradient_clip}") + if args.privacy: + if args.privacy_epsilon <= 0.0 or args.privacy_epsilon >= 1.0: + tools.fatal(f"Invalid arguments: off-bounds (]0, 1[) ε constant {args.privacy_epsilon}") + if args.privacy_delta <= 0.0 or args.privacy_delta >= 1.0: + tools.fatal(f"Invalid arguments: off-bounds (]0, 1[) δ constant {args.privacy_delta}") + args.privacy_sensitivity = 2 * args.gradient_clip / args.batch_size + + # Print configuration + def cmd_make_tree(subtree, level=0): + if isinstance(subtree, tuple) and len(subtree) > 0 and isinstance(subtree[0], tuple) and len(subtree[0]) == 2: + label_len = max(len(label) for label, _ in subtree) + iterator = subtree + elif isinstance(subtree, dict): + if len(subtree) == 0: + return " - " + label_len = max(len(label) for label in subtree.keys()) + iterator = subtree.items() + else: + return f" - {subtree}" + level_spc = " " * level + res = "" + for label, node in iterator: + res += f"{os.linesep}{level_spc}· {label}{' ' * (label_len - len(label))}{cmd_make_tree(node, level + 1)}" + return res + + cmdline_config = "Configuration" + cmd_make_tree( + ( + ("Reproducibility", "not enforced" if args.seed < 0 else f"enforced (seed {args.seed})"), + ("#workers", args.nb_workers), + ("#declared Byz.", args.nb_decl_byz), + ("#actually Byz.", args.nb_real_byz), + ("Model", (("Name", args.model), ("Arguments", args.model_args))), + ( + "Dataset", + ( + ("Name", args.dataset), + ("Arguments", args.dataset_args), + ( + "Batch size", + ( + ("Training", args.batch_size or "max"), + ("Testing", f"{args.batch_size_test or 'max'} × {args.test_repeat}"), + ), + ), + ("Transforms", "none" if args.no_transform else "default"), + ), + ), + ( + "Loss", + ( + ("Name", args.loss), + ("Arguments", args.loss_args), + ( + "Regularization", + ( + ("l1", "none" if args.l1_regularize is None else args.l1_regularize), + ("l2", "none" if args.l2_regularize is None else args.l2_regularize), + ), + ), + ), + ), + ("Criterion", (("Name", args.criterion), ("Arguments", args.criterion_args))), + ( + "Optimizer", + ( + ("Name", "sgd"), + ( + "Learning rate", + ( + ("Initial", args.learning_rate), + ("Half-decay", args.learning_rate_decay if args.learning_rate_decay > 0 else "none"), + ("Update delta", args.learning_rate_decay_delta if args.learning_rate_decay > 0 else "n/a"), + ), + ), + ("Momentum", args.momentum), + ("Dampening", args.dampening), + ("Weight decay", args.weight_decay), + ), + ), + ("Attack", (("Name", args.attack), ("Arguments", args.attack_args))), + ("Aggregation", (("Name", args.gar), ("Arguments", args.gar_args))), + ( + "Differential privacy", + ( + ("Enabled?", "yes" if args.privacy else "no"), + ("ε constant", args.privacy_epsilon if args.privacy else "n/a"), + ("δ constant", args.privacy_delta if args.privacy else "n/a"), + ("l2-sensitivity", args.privacy_sensitivity if args.privacy else "n/a"), + ), + ), + ) + ) + print(cmdline_config) # ---------------------------------------------------------------------------- # # Setup tools.success("Experiment setup...") + def result_make(name, *fields): - """ Make and bind a new result file with a name, initialize with a header line. - Args: - name Name of the result file - fields... Name of each field, in order - Raises: - 'KeyError' if name is already bound - 'RuntimeError' if no name can be bound - Any exception that 'io.FileIO' can raise while opening/writing/flushing - """ - # Check if results are to be output - global args - if args.result_directory is None: - raise RuntimeError("No result is to be output") - # Check if name is already bounds - global result_fds - if name in result_fds: - raise KeyError(f"Name {name!r} is already bound to a result file") - # Make the new file - fd = (args.result_directory / name).open("w") - fd.write("# " + ("\t").join(str(field) for field in fields)) - fd.flush() - result_fds[name] = fd + """Make and bind a new result file with a name, initialize with a header line. + Args: + name Name of the result file + fields... Name of each field, in order + Raises: + 'KeyError' if name is already bound + 'RuntimeError' if no name can be bound + Any exception that 'io.FileIO' can raise while opening/writing/flushing + """ + # Check if results are to be output + global args + if args.result_directory is None: + raise RuntimeError("No result is to be output") + # Check if name is already bounds + global result_fds + if name in result_fds: + raise KeyError(f"Name {name!r} is already bound to a result file") + # Make the new file + fd = (args.result_directory / name).open("w") + fd.write("# " + ("\t").join(str(field) for field in fields)) + fd.flush() + result_fds[name] = fd + def result_get(name): - """ Get a valid descriptor to the bound result file, or 'None' if the given name is not bound. - Args: - name Given name - Returns: - Valid file descriptor, or 'None' - """ - # Check if results are to be output - global args - if args.result_directory is None: - return None - # Return the bound descriptor, if any - global result_fds - return result_fds.get(name, None) + """Get a valid descriptor to the bound result file, or 'None' if the given name is not bound. + Args: + name Given name + Returns: + Valid file descriptor, or 'None' + """ + # Check if results are to be output + global args + if args.result_directory is None: + return None + # Return the bound descriptor, if any + global result_fds + return result_fds.get(name, None) + def result_store(fd, *entries): - """ Store a line in a valid result file. - Args: - fd Descriptor of the valid result file - entries... Object(s) to convert to string and write in order in a new line - """ - fd.write(os.linesep + ("\t").join(str(entry) for entry in entries)) - fd.flush() + """Store a line in a valid result file. + Args: + fd Descriptor of the valid result file + entries... Object(s) to convert to string and write in order in a new line + """ + fd.write(os.linesep + ("\t").join(str(entry) for entry in entries)) + fd.flush() + with tools.Context("setup", "info"): - # Enforce reproducibility if asked (see https://pytorch.org/docs/stable/notes/randomness.html) - reproducible = args.seed >= 0 - if reproducible: - torch.manual_seed(args.seed) - import numpy - numpy.random.seed(args.seed) - torch.backends.cudnn.deterministic = reproducible - torch.backends.cudnn.benchmark = not reproducible - # Configurations - config = experiments.Configuration(dtype=torch.float32, device=(None if args.device.lower() == "auto" else args.device), noblock=True) - if args.device_gar.lower() == "same": - config_gar = config - else: - config_gar = experiments.Configuration(dtype=config["dtype"], device=(None if args.device_gar.lower() == "auto" else args.device_gar), noblock=config["non_blocking"]) - # Defense - defense = aggregators.gars.get(args.gar) - if defense is None: - tools.fatal_unavailable(aggregators.gars, args.gar, what="aggregation rule") - # Attack - attack = attacks.attacks.get(args.attack) - if attack is None: - tools.fatal_unavailable(attacks.attacks, args.attack, what="attack") - # Model - model = experiments.Model(args.model, config, **args.model_args) - # Datasets - if args.no_transform: - train_transforms = test_transforms = torchvision.transforms.ToTensor() - else: - train_transforms = test_transforms = None # Let default values - trainset, testset = experiments.make_datasets(args.dataset, args.batch_size, args.batch_size_test, train_transforms=train_transforms, test_transforms=test_transforms, **args.dataset_args) - model.default("trainset", trainset) - model.default("testset", testset) - # Loss and criterion - loss = experiments.Loss(args.loss, **args.loss_args) - if args.l1_regularize is not None: - loss += args.l1_regularize * experiments.Loss("l1") - if args.l2_regularize is not None: - loss += args.l2_regularize * experiments.Loss("l2") - criterion = experiments.Criterion(args.criterion, **args.criterion_args) - model.default("loss", loss) - model.default("criterion", criterion) - # Optimizer - optimizer = experiments.Optimizer("sgd", model, lr=args.learning_rate, momentum=args.momentum, dampening=args.dampening, weight_decay=args.weight_decay) - model.default("optimizer", optimizer) - # Privacy noise distribution - if args.privacy: - param = model.get() - privacy_factor = args.privacy_sensitivity * math.sqrt(2 * math.log(1.25 / args.privacy_delta)) / args.privacy_epsilon - grad_noise = torch.distributions.normal.Normal(torch.zeros_like(param), torch.ones_like(param).mul_(privacy_factor)) - # Make the result directory (if requested) - if args.result_directory is not None: - try: - resdir = pathlib.Path(args.result_directory).resolve() - resdir.mkdir(mode=0o755, parents=True, exist_ok=True) - args.result_directory = resdir - except Exception as err: - tools.warning(f"Unable to create the result directory {str(resdir)!r} ({err}); no result will be stored") + # Enforce reproducibility if asked (see https://pytorch.org/docs/stable/notes/randomness.html) + reproducible = args.seed >= 0 + if reproducible: + torch.manual_seed(args.seed) + import numpy + + numpy.random.seed(args.seed) + torch.backends.cudnn.deterministic = reproducible + torch.backends.cudnn.benchmark = not reproducible + # Configurations + config = experiments.Configuration( + dtype=torch.float32, device=(None if args.device.lower() == "auto" else args.device), noblock=True + ) + if args.device_gar.lower() == "same": + config_gar = config else: - result_fds = dict() - try: - # Make evaluation file - if args.evaluation_delta > 0: - result_make("eval", "Step number", "Cross-accuracy") - # Make study file - result_make("study", "Step number", "Training point count", - "Average loss", "l2 from origin", - "Honest gradient deviation", "Attack gradient deviation", - "Honest gradient norm", "Attack gradient norm", "Defense gradient norm", - "Honest max coordinate", "Attack max coordinate", "Defense max coordinate", - "Honest-attack cosine", "Honest-defense cosine", "Attack-defense cosine") - # Store the configuration info and JSON representation - (args.result_directory / "config").write_text(cmdline_config + os.linesep) - with (args.result_directory / "config.json").open("w") as fd: - def convert_to_supported_json_type(x): - if type(x) in {str, int, float, bool, type(None), dict, list}: - return x - elif type(x) is set: - return list(x) - else: - return str(x) - datargs = dict((name, convert_to_supported_json_type(getattr(args, name))) for name in dir(args) if len(name) > 0 and name[0] != "_") - del convert_to_supported_json_type - json.dump(datargs, fd, ensure_ascii=False, indent="\t") - except Exception as err: - tools.warning(f"Unable to create some result files in directory {str(resdir)!r} ({err}); some result(s) may be missing") + config_gar = experiments.Configuration( + dtype=config["dtype"], + device=(None if args.device_gar.lower() == "auto" else args.device_gar), + noblock=config["non_blocking"], + ) + # Defense + defense = aggregators.gars.get(args.gar) + if defense is None: + tools.fatal_unavailable(aggregators.gars, args.gar, what="aggregation rule") + # Attack + attack = attacks.attacks.get(args.attack) + if attack is None: + tools.fatal_unavailable(attacks.attacks, args.attack, what="attack") + # Model + model = experiments.Model(args.model, config, **args.model_args) + # Datasets + if args.no_transform: + train_transforms = test_transforms = torchvision.transforms.ToTensor() + else: + train_transforms = test_transforms = None # Let default values + trainset, testset = experiments.make_datasets( + args.dataset, + args.batch_size, + args.batch_size_test, + train_transforms=train_transforms, + test_transforms=test_transforms, + **args.dataset_args, + ) + model.default("trainset", trainset) + model.default("testset", testset) + # Loss and criterion + loss = experiments.Loss(args.loss, **args.loss_args) + if args.l1_regularize is not None: + loss += args.l1_regularize * experiments.Loss("l1") + if args.l2_regularize is not None: + loss += args.l2_regularize * experiments.Loss("l2") + criterion = experiments.Criterion(args.criterion, **args.criterion_args) + model.default("loss", loss) + model.default("criterion", criterion) + # Optimizer + optimizer = experiments.Optimizer( + "sgd", + model, + lr=args.learning_rate, + momentum=args.momentum, + dampening=args.dampening, + weight_decay=args.weight_decay, + ) + model.default("optimizer", optimizer) + # Privacy noise distribution + if args.privacy: + param = model.get() + privacy_factor = ( + args.privacy_sensitivity * math.sqrt(2 * math.log(1.25 / args.privacy_delta)) / args.privacy_epsilon + ) + grad_noise = torch.distributions.normal.Normal( + torch.zeros_like(param), torch.ones_like(param).mul_(privacy_factor) + ) + # Make the result directory (if requested) + if args.result_directory is not None: + try: + resdir = pathlib.Path(args.result_directory).resolve() + resdir.mkdir(mode=0o755, parents=True, exist_ok=True) + args.result_directory = resdir + except Exception as err: + tools.warning(f"Unable to create the result directory {str(resdir)!r} ({err}); no result will be stored") + else: + result_fds = dict() + try: + # Make evaluation file + if args.evaluation_delta > 0: + result_make("eval", "Step number", "Cross-accuracy") + # Make study file + result_make( + "study", + "Step number", + "Training point count", + "Average loss", + "l2 from origin", + "Honest gradient deviation", + "Attack gradient deviation", + "Honest gradient norm", + "Attack gradient norm", + "Defense gradient norm", + "Honest max coordinate", + "Attack max coordinate", + "Defense max coordinate", + "Honest-attack cosine", + "Honest-defense cosine", + "Attack-defense cosine", + ) + # Store the configuration info and JSON representation + (args.result_directory / "config").write_text(cmdline_config + os.linesep) + with (args.result_directory / "config.json").open("w") as fd: + + def convert_to_supported_json_type(x): + if type(x) in {str, int, float, bool, type(None), dict, list}: + return x + elif type(x) is set: + return list(x) + else: + return str(x) + + datargs = dict( + (name, convert_to_supported_json_type(getattr(args, name))) + for name in dir(args) + if len(name) > 0 and name[0] != "_" + ) + del convert_to_supported_json_type + json.dump(datargs, fd, ensure_ascii=False, indent="\t") + except Exception as err: + tools.warning( + f"Unable to create some result files in directory {str(resdir)!r} ({err}); some result(s) may be missing" + ) # ---------------------------------------------------------------------------- # # Training tools.success("Training...") + def compute_avg_dev(values): - """ Compute the arithmetic mean and standard deviation of a list of values. - Args: - values Iterable of values - Returns: - Arithmetic mean, standard deviation - """ - avg = sum(values) / len(values) - var = 0 - for value in values: - var += (value - avg) ** 2 - var /= len(values) - 1 - return avg, math.sqrt(var) + """Compute the arithmetic mean and standard deviation of a list of values. + Args: + values Iterable of values + Returns: + Arithmetic mean, standard deviation + """ + avg = sum(values) / len(values) + var = 0 + for value in values: + var += (value - avg) ** 2 + var /= len(values) - 1 + return avg, math.sqrt(var) + class StopTrainingLoop(Exception): - """ Local exception to signal and stop the training loop. - """ - pass + """Local exception to signal and stop the training loop.""" + + pass + # Training until limit or stopped with tools.Context("training", "info"): - was_training = False - current_lr = args.learning_rate - steps = 0 - datapoints = 0 - fd_eval = result_get("eval") - fd_study = result_get("study") - atc_gradient = tools.AccumulatedTimedContext(sync=True) - atc_noise = tools.AccumulatedTimedContext(sync=True) - atc_aggregate = tools.AccumulatedTimedContext(sync=True) - atc_evaluate = tools.AccumulatedTimedContext(sync=True) - params_origin = model.get().clone().detach_() - try: - while not exit_is_requested(): - # ------------------------------------------------------------------------ # - # Evaluate if any milestone is reached - milestone_evaluation = args.evaluation_delta > 0 and steps % args.evaluation_delta == 0 - milestone_user_input = args.user_input_delta > 0 and steps % args.user_input_delta == 0 - milestone_any = milestone_evaluation or milestone_user_input - # Training notification (end) - if milestone_any and was_training: - print(" done.") - was_training = False - # Evaluation milestone reached - if milestone_evaluation: - print("Accuracy (step %d)..." % steps, end="", flush=True) - with atc_evaluate: - res = model.eval() - for _ in range(args.test_repeat - 1): - res += model.eval() - acc = res[0].item() / res[1].item() - print(" %.2f%%." % (acc * 100.)) - # Store the evaluation result - if fd_eval is not None: - result_store(fd_eval, steps, acc) - # User input milestone - if milestone_user_input: - tools.interactive() - # Check if reach step limit - if args.nb_steps > 0 and steps >= args.nb_steps: - # Training notification (end) - if was_training: - print(" done.") - was_training = False - # Leave training loop - raise StopTrainingLoop() - # Training notification (begin) - if milestone_any and not was_training: - print("Training...", end="", flush=True) - was_training = True - # ------------------------------------------------------------------------ # - # Compute (honest) losses (if it makes sense), gradients and voting data - grad_honests = list() - loss_honests = list() - # For each honest worker - with atc_gradient: - for i in range(args.nb_honests): - grad, loss = model.backprop(outloss=True) - grad = grad.clone().detach_() - # Loss append - loss_honests.append(loss.item()) - # Gradient clip - if args.gradient_clip is not None: - grad_norm = grad.norm().item() - if grad_norm > args.gradient_clip: - grad.mul_(args.gradient_clip / grad_norm) - # Gradient append - grad_honests.append(grad) - # Pre-compute some quantities for study (here if necessary, since each gradient in 'grad_honests' may have privacy noise added) - if fd_study is not None: - # Compute average loss ('len(loss_honests) > 0' is guaranteed) - loss_avg = sum(loss_honests) / len(loss_honests) - # Compute the sampled and honest gradients norm average, norm deviation and max absolute coordinate - honest_grad_avg, honest_norm_avg, honest_norm_dev, honest_norm_max = tools.compute_avg_dev_max(grad_honests) - # Move the honest gradients to the GAR device - if config_gar is not config: - grad_honests_gar = list(grad.to(device=config_gar["device"], non_blocking=config_gar["non_blocking"]) for grad in grad_honests) - else: - grad_honests_gar = grad_honests - # ------------------------------------------------------------------------ # - # Add privacy noise to the 'grad_honests_gar' (might be 'grad_honests'), which are the ones sent and processed by the server - if args.privacy: - with atc_noise: - for grad in grad_honests_gar: - grad.add_(grad_noise.sample()) - # ------------------------------------------------------------------------ # - # Compute the Byzantine gradients - grad_attacks = attack.checked(grad_honests=grad_honests_gar, f_decl=args.nb_decl_byz, f_real=args.nb_real_byz, model=model, defense=defense, **args.attack_args) - # ------------------------------------------------------------------------ # - # Aggregate and update the model - with atc_aggregate: - grad_defense = defense.checked(gradients=(grad_honests_gar + grad_attacks), f=args.nb_decl_byz, model=model, **args.gar_args) - # Move the defense gradient back to the main device - if config_gar is not config: - for grad in grad_attacks: - grad.data = grad.to(device=config["device"], non_blocking=config["non_blocking"]) - grad_defense = grad_defense.to(device=config["device"], non_blocking=config["non_blocking"]) - # Compute l2-distance from origin (if needed for study) - if fd_study is not None: - l2_origin = model.get().sub(params_origin).norm().item() - # Model update (possibly updating the learning rate) - if args.learning_rate_decay > 0 and steps % args.learning_rate_decay_delta == 0: - current_lr = args.learning_rate / (steps / args.learning_rate_decay + 1) - optimizer.set_lr(current_lr) - model.update(grad_defense) - # ------------------------------------------------------------------------ # - # Store study (if requested) - if fd_study is not None: - # Compute the sampled and honest gradients norm average, norm deviation and max absolute coordinate - attack_grad_avg, attack_norm_avg, attack_norm_dev, attack_norm_max = tools.compute_avg_dev_max(grad_attacks) - # Compute the defense norm average and max absolute coordinate - defense_grad = grad_defense # (Mere renaming for consistency) - defense_norm_avg = defense_grad.norm().item() - defense_norm_max = defense_grad.abs().max().item() - # Compute cosine of solid angles - cosin_honatt = math.nan if attack_grad_avg is None else torch.dot(honest_grad_avg, attack_grad_avg).div_(honest_norm_avg).div_(attack_norm_avg).item() - cosin_hondef = torch.dot(honest_grad_avg, defense_grad).div_(honest_norm_avg).div_(defense_norm_avg).item() - cosin_attdef = math.nan if attack_grad_avg is None else torch.dot(attack_grad_avg, defense_grad).div_(attack_norm_avg).div_(defense_norm_avg).item() - # Store the result (float-to-string format chosen so not to lose precision) - float_format = {torch.float16: "%.4e", torch.float32: "%.8e", torch.float64: "%.16e"}.get(config["dtype"], "%s") - result_store(fd_study, steps, datapoints, - float_format % loss_avg, float_format % l2_origin, - float_format % honest_norm_dev, float_format % attack_norm_dev, - float_format % honest_norm_avg, float_format % attack_norm_avg, float_format % defense_norm_avg, - float_format % honest_norm_max, float_format % attack_norm_max, float_format % defense_norm_max, - float_format % cosin_honatt, float_format % cosin_hondef, float_format % cosin_attdef) - # ------------------------------------------------------------------------ # - # Increase the step counter - steps += 1 - datapoints += args.batch_size * args.nb_honests - except StopTrainingLoop: - pass - # Training notification (end) - if was_training: - print(" interrupted.") + was_training = False + current_lr = args.learning_rate + steps = 0 + datapoints = 0 + fd_eval = result_get("eval") + fd_study = result_get("study") + atc_gradient = tools.AccumulatedTimedContext(sync=True) + atc_noise = tools.AccumulatedTimedContext(sync=True) + atc_aggregate = tools.AccumulatedTimedContext(sync=True) + atc_evaluate = tools.AccumulatedTimedContext(sync=True) + params_origin = model.get().clone().detach_() + try: + while not exit_is_requested(): + # ------------------------------------------------------------------------ # + # Evaluate if any milestone is reached + milestone_evaluation = args.evaluation_delta > 0 and steps % args.evaluation_delta == 0 + milestone_user_input = args.user_input_delta > 0 and steps % args.user_input_delta == 0 + milestone_any = milestone_evaluation or milestone_user_input + # Training notification (end) + if milestone_any and was_training: + print(" done.") + was_training = False + # Evaluation milestone reached + if milestone_evaluation: + print(f"Accuracy (step {steps})...", end="", flush=True) + with atc_evaluate: + res = model.eval() + for _ in range(args.test_repeat - 1): + res += model.eval() + acc = res[0].item() / res[1].item() + print(" %.2f%%." % (acc * 100.0)) + # Store the evaluation result + if fd_eval is not None: + result_store(fd_eval, steps, acc) + # User input milestone + if milestone_user_input: + tools.interactive() + # Check if reach step limit + if args.nb_steps > 0 and steps >= args.nb_steps: + # Training notification (end) + if was_training: + print(" done.") + was_training = False + # Leave training loop + raise StopTrainingLoop() + # Training notification (begin) + if milestone_any and not was_training: + print("Training...", end="", flush=True) + was_training = True + # ------------------------------------------------------------------------ # + # Compute (honest) losses (if it makes sense), gradients and voting data + grad_honests = list() + loss_honests = list() + # For each honest worker + with atc_gradient: + for i in range(args.nb_honests): + grad, loss = model.backprop(outloss=True) + grad = grad.clone().detach_() + # Loss append + loss_honests.append(loss.item()) + # Gradient clip + if args.gradient_clip is not None: + grad_norm = grad.norm().item() + if grad_norm > args.gradient_clip: + grad.mul_(args.gradient_clip / grad_norm) + # Gradient append + grad_honests.append(grad) + # Pre-compute some quantities for study (here if necessary, since each gradient in 'grad_honests' may have privacy noise added) + if fd_study is not None: + # Compute average loss ('len(loss_honests) > 0' is guaranteed) + loss_avg = sum(loss_honests) / len(loss_honests) + # Compute the sampled and honest gradients norm average, norm deviation and max absolute coordinate + honest_grad_avg, honest_norm_avg, honest_norm_dev, honest_norm_max = tools.compute_avg_dev_max( + grad_honests + ) + # Move the honest gradients to the GAR device + if config_gar is not config: + grad_honests_gar = list( + grad.to(device=config_gar["device"], non_blocking=config_gar["non_blocking"]) + for grad in grad_honests + ) + else: + grad_honests_gar = grad_honests + # ------------------------------------------------------------------------ # + # Add privacy noise to the 'grad_honests_gar' (might be 'grad_honests'), which are the ones sent and processed by the server + if args.privacy: + with atc_noise: + for grad in grad_honests_gar: + grad.add_(grad_noise.sample()) + # ------------------------------------------------------------------------ # + # Compute the Byzantine gradients + grad_attacks = attack.checked( + grad_honests=grad_honests_gar, + f_decl=args.nb_decl_byz, + f_real=args.nb_real_byz, + model=model, + defense=defense, + **args.attack_args, + ) + # ------------------------------------------------------------------------ # + # Aggregate and update the model + with atc_aggregate: + grad_defense = defense.checked( + gradients=(grad_honests_gar + grad_attacks), f=args.nb_decl_byz, model=model, **args.gar_args + ) + # Move the defense gradient back to the main device + if config_gar is not config: + for grad in grad_attacks: + grad.data = grad.to(device=config["device"], non_blocking=config["non_blocking"]) + grad_defense = grad_defense.to(device=config["device"], non_blocking=config["non_blocking"]) + # Compute l2-distance from origin (if needed for study) + if fd_study is not None: + l2_origin = model.get().sub(params_origin).norm().item() + # Model update (possibly updating the learning rate) + if args.learning_rate_decay > 0 and steps % args.learning_rate_decay_delta == 0: + current_lr = args.learning_rate / (steps / args.learning_rate_decay + 1) + optimizer.set_lr(current_lr) + model.update(grad_defense) + # ------------------------------------------------------------------------ # + # Store study (if requested) + if fd_study is not None: + # Compute the sampled and honest gradients norm average, norm deviation and max absolute coordinate + attack_grad_avg, attack_norm_avg, attack_norm_dev, attack_norm_max = tools.compute_avg_dev_max( + grad_attacks + ) + # Compute the defense norm average and max absolute coordinate + defense_grad = grad_defense # (Mere renaming for consistency) + defense_norm_avg = defense_grad.norm().item() + defense_norm_max = defense_grad.abs().max().item() + # Compute cosine of solid angles + cosin_honatt = ( + math.nan + if attack_grad_avg is None + else torch.dot(honest_grad_avg, attack_grad_avg).div_(honest_norm_avg).div_(attack_norm_avg).item() + ) + cosin_hondef = ( + torch.dot(honest_grad_avg, defense_grad).div_(honest_norm_avg).div_(defense_norm_avg).item() + ) + cosin_attdef = ( + math.nan + if attack_grad_avg is None + else torch.dot(attack_grad_avg, defense_grad).div_(attack_norm_avg).div_(defense_norm_avg).item() + ) + # Store the result (float-to-string format chosen so not to lose precision) + float_format = {torch.float16: "%.4e", torch.float32: "%.8e", torch.float64: "%.16e"}.get( + config["dtype"], "%s" + ) + result_store( + fd_study, + steps, + datapoints, + float_format % loss_avg, + float_format % l2_origin, + float_format % honest_norm_dev, + float_format % attack_norm_dev, + float_format % honest_norm_avg, + float_format % attack_norm_avg, + float_format % defense_norm_avg, + float_format % honest_norm_max, + float_format % attack_norm_max, + float_format % defense_norm_max, + float_format % cosin_honatt, + float_format % cosin_hondef, + float_format % cosin_attdef, + ) + # ------------------------------------------------------------------------ # + # Increase the step counter + steps += 1 + datapoints += args.batch_size * args.nb_honests + except StopTrainingLoop: + pass + # Training notification (end) + if was_training: + print(" interrupted.") # Print and store timing counters with tools.Context("perf", "info"): - perfs = dict() - perf_params = ( - (atc_gradient, "grad", "Gradient computation (per worker)", args.nb_honests), - (atc_noise, "noise", "Noise addition (per worker)", args.nb_honests), - (atc_aggregate, "aggr", "Gradient aggregation", 1), - (atc_evaluate, "eval", "Model evaluation", 1)) - # Compute max name length - nlen = max(len(name) for _, _, name, _ in perf_params) - # Print - for atc, key, name, div in perf_params: - acc = tools.AccumulatedTimedContext(atc.current_runtime() / div) - print(f"{name:{nlen}s} - {acc}") - perfs[key] = (acc.current_runtime(), name) - # Store - if args.result_directory: - with (args.result_directory / "perfs.json").open("w") as fd: - json.dump(perfs, fd, ensure_ascii=False, indent="\t") + perfs = dict() + perf_params = ( + (atc_gradient, "grad", "Gradient computation (per worker)", args.nb_honests), + (atc_noise, "noise", "Noise addition (per worker)", args.nb_honests), + (atc_aggregate, "aggr", "Gradient aggregation", 1), + (atc_evaluate, "eval", "Model evaluation", 1), + ) + # Compute max name length + nlen = max(len(name) for _, _, name, _ in perf_params) + # Print + for atc, key, name, div in perf_params: + acc = tools.AccumulatedTimedContext(atc.current_runtime() / div) + print(f"{name:{nlen}s} - {acc}") + perfs[key] = (acc.current_runtime(), name) + # Store + if args.result_directory: + with (args.result_directory / "perfs.json").open("w") as fd: + json.dump(perfs, fd, ensure_ascii=False, indent="\t") From 84c91722c5ee1395d28fd5121d0cf79f1da71cce Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Wed, 29 Apr 2026 15:45:56 +0200 Subject: [PATCH 02/30] Add uv.lock, pre-commit config and whitelist krum --- .gitignore | 3 + .pre-commit-config.yaml | 7 + uv.lock | 2063 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 2073 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index d852312..ae36851 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Except directories, placeholders, gitignores, LICENSE, Markdown files and Python sources !*/ +!krum/* !.placeholder !.gitignore !LICENSE @@ -10,6 +11,8 @@ !*.py !*.toml !.python-version +!uv.lock +!.pre-commit-config.yaml # Github !.github/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..07adb89 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,7 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..106399f --- /dev/null +++ b/uv.lock @@ -0,0 +1,2063 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.15" +resolution-markers = [ + "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.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +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", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, + { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/d6/ac63065d33dd700fee7ebd7d287332401b54e31b9346e142f871e1f0b116/cuda_pathfinder-1.5.3-py3-none-any.whl", hash = "sha256:dff021123aedbb4117cc7ec81717bbfe198fb4e8b5f1ee57e0e084fec5c8577d", size = 49991, upload-time = "2026-04-14T20:09:27.037Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or 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]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +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/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, + { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, + { 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.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[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/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { 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/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { 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 = "krum" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "matplotlib" }, + { name = "ninja" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "requests" }, + { name = "torch" }, + { name = "torchvision" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] +docs = [ + { name = "shibuya" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-contributors" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-favicon" }, + { name = "sphinx-togglebutton" }, +] + +[package.metadata] +requires-dist = [ + { name = "matplotlib", specifier = ">=3.10.9" }, + { name = "ninja", specifier = ">=1.13.0" }, + { name = "numpy", specifier = ">=2.2.6" }, + { name = "pandas", specifier = ">=2.0.0" }, + { name = "requests", specifier = ">=2.33.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, + { name = "shibuya", marker = "extra == 'docs'", specifier = ">=2026.1.9" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=8.1.3" }, + { name = "sphinx-contributors", marker = "extra == 'docs'", specifier = ">=0.3.0" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.2" }, + { name = "sphinx-favicon", marker = "extra == 'docs'", specifier = ">=1.0.1" }, + { name = "sphinx-togglebutton", marker = "extra == 'docs'", specifier = ">=0.3.2" }, + { name = "torch", specifier = ">=2.11.0" }, + { name = "torchvision", specifier = ">=0.26.0" }, +] +provides-extras = ["dev", "docs"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { 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/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { 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/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "ninja" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/73/79a0b22fc731989c708068427579e840a6cf4e937fe7ae5c5d0b7356ac22/ninja-1.13.0.tar.gz", hash = "sha256:4a40ce995ded54d9dc24f8ea37ff3bf62ad192b547f6c7126e7e25045e76f978", size = 242558, upload-time = "2025-08-11T15:10:19.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/74/d02409ed2aa865e051b7edda22ad416a39d81a84980f544f8de717cab133/ninja-1.13.0-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fa2a8bfc62e31b08f83127d1613d10821775a0eb334197154c4d6067b7068ff1", size = 310125, upload-time = "2025-08-11T15:09:50.971Z" }, + { url = "https://files.pythonhosted.org/packages/8e/de/6e1cd6b84b412ac1ef327b76f0641aeb5dcc01e9d3f9eee0286d0c34fd93/ninja-1.13.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630", size = 177467, upload-time = "2025-08-11T15:09:52.767Z" }, + { url = "https://files.pythonhosted.org/packages/c8/83/49320fb6e58ae3c079381e333575fdbcf1cca3506ee160a2dcce775046fa/ninja-1.13.0-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:be7f478ff9f96a128b599a964fc60a6a87b9fa332ee1bd44fa243ac88d50291c", size = 187834, upload-time = "2025-08-11T15:09:54.115Z" }, + { url = "https://files.pythonhosted.org/packages/56/c7/ba22748fb59f7f896b609cd3e568d28a0a367a6d953c24c461fe04fc4433/ninja-1.13.0-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:60056592cf495e9a6a4bea3cd178903056ecb0943e4de45a2ea825edb6dc8d3e", size = 202736, upload-time = "2025-08-11T15:09:55.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/22/d1de07632b78ac8e6b785f41fa9aad7a978ec8c0a1bf15772def36d77aac/ninja-1.13.0-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1c97223cdda0417f414bf864cfb73b72d8777e57ebb279c5f6de368de0062988", size = 179034, upload-time = "2025-08-11T15:09:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/ed/de/0e6edf44d6a04dabd0318a519125ed0415ce437ad5a1ec9b9be03d9048cf/ninja-1.13.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa", size = 180716, upload-time = "2025-08-11T15:09:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/938b562f9057aaa4d6bfbeaa05e81899a47aebb3ba6751e36c027a7f5ff7/ninja-1.13.0-py3-none-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4be9c1b082d244b1ad7ef41eb8ab088aae8c109a9f3f0b3e56a252d3e00f42c1", size = 146843, upload-time = "2025-08-11T15:10:00.046Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fb/d06a3838de4f8ab866e44ee52a797b5491df823901c54943b2adb0389fbb/ninja-1.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6739d3352073341ad284246f81339a384eec091d9851a886dfa5b00a6d48b3e2", size = 154402, upload-time = "2025-08-11T15:10:01.657Z" }, + { url = "https://files.pythonhosted.org/packages/31/bf/0d7808af695ceddc763cf251b84a9892cd7f51622dc8b4c89d5012779f06/ninja-1.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:11be2d22027bde06f14c343f01d31446747dbb51e72d00decca2eb99be911e2f", size = 552388, upload-time = "2025-08-11T15:10:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/9d/70/c99d0c2c809f992752453cce312848abb3b1607e56d4cd1b6cded317351a/ninja-1.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aa45b4037b313c2f698bc13306239b8b93b4680eb47e287773156ac9e9304714", size = 472501, upload-time = "2025-08-11T15:10:04.735Z" }, + { url = "https://files.pythonhosted.org/packages/9f/43/c217b1153f0e499652f5e0766da8523ce3480f0a951039c7af115e224d55/ninja-1.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f8e1e8a1a30835eeb51db05cf5a67151ad37542f5a4af2a438e9490915e5b72", size = 638280, upload-time = "2025-08-11T15:10:06.512Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9151bba2c8d0ae2b6260f71696330590de5850e5574b7b5694dce6023e20/ninja-1.13.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:3d7d7779d12cb20c6d054c61b702139fd23a7a964ec8f2c823f1ab1b084150db", size = 642420, upload-time = "2025-08-11T15:10:08.35Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/95752eb635bb8ad27d101d71bef15bc63049de23f299e312878fc21cb2da/ninja-1.13.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:d741a5e6754e0bda767e3274a0f0deeef4807f1fec6c0d7921a0244018926ae5", size = 585106, upload-time = "2025-08-11T15:10:09.818Z" }, + { url = "https://files.pythonhosted.org/packages/c1/31/aa56a1a286703800c0cbe39fb4e82811c277772dc8cd084f442dd8e2938a/ninja-1.13.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e8bad11f8a00b64137e9b315b137d8bb6cbf3086fbdc43bf1f90fd33324d2e96", size = 707138, upload-time = "2025-08-11T15:10:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/34/6f/5f5a54a1041af945130abdb2b8529cbef0cdcbbf9bcf3f4195378319d29a/ninja-1.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b4f2a072db3c0f944c32793e91532d8948d20d9ab83da9c0c7c15b5768072200", size = 581758, upload-time = "2025-08-11T15:10:13.295Z" }, + { url = "https://files.pythonhosted.org/packages/95/97/51359c77527d45943fe7a94d00a3843b81162e6c4244b3579fe8fc54cb9c/ninja-1.13.0-py3-none-win32.whl", hash = "sha256:8cfbb80b4a53456ae8a39f90ae3d7a2129f45ea164f43fadfa15dc38c4aef1c9", size = 267201, upload-time = "2025-08-11T15:10:15.158Z" }, + { url = "https://files.pythonhosted.org/packages/29/45/c0adfbfb0b5895aa18cec400c535b4f7ff3e52536e0403602fc1a23f7de9/ninja-1.13.0-py3-none-win_amd64.whl", hash = "sha256:fb8ee8719f8af47fed145cced4a85f0755dd55d45b2bddaf7431fa89803c5f3e", size = 309975, upload-time = "2025-08-11T15:10:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/a7b983643d1253bb223234b5b226e69de6cda02b76cdca7770f684b795f5/ninja-1.13.0-py3-none-win_arm64.whl", hash = "sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9", size = 290806, upload-time = "2025-08-11T15:10:18.018Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.0.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.19.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (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" }, + { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (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" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (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" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (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" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.28.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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 = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and 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/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { 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 = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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 = "pygments-styles" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/2c/3886ed4783dd78bb62ccab7d43380f526a7e2ff0db8c77d9c87559b2f5de/pygments_styles-0.3.0.tar.gz", hash = "sha256:67746b8fc6ff72c1179ca4d9a8bc89c7f54c196c2ff9d087f07392cd6fde3ecf", size = 15258, upload-time = "2025-11-04T13:15:23.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/64/7e0266f0c541e26df86c31d2add9be3dd9914ae83785ce0aba7cbb693667/pygments_styles-0.3.0-py3-none-any.whl", hash = "sha256:c6c45e9939eb7590345bc9084113bac46c45f12b009d13422be02e80e84a034c", size = 36617, upload-time = "2025-11-04T13:15:21.989Z" }, +] + +[[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 = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +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 = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +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 = "shibuya" +version = "2026.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments-styles" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/b94cb04adbb984973fe83fd670dd066514610241d829723f678366e691d2/shibuya-2026.1.9.tar.gz", hash = "sha256:b389f10fd9c07b048e940f32d1e1ac096a2d49736389173ac771b37a10b51fdf", size = 86002, upload-time = "2026-01-09T02:19:14.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ae/06d7dfc5633c7250fefc61fd624990aa2c37e3495c08a2f23968b1acb23e/shibuya-2026.1.9-py3-none-any.whl", hash = "sha256:b58a3cc6e5619c71d00fcf0be4a3060c87040c2a62a1b3f1a93a6a41ca8eaf45", size = 103389, upload-time = "2026-01-09T02:19:12.798Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-contributors" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/68/53a8170828c2e175e5333560d413c7721c323ba9ffbbc86c3c8346f6eb4b/sphinx_contributors-0.3.0.tar.gz", hash = "sha256:9b8c94fb5c1f851719a3abb9e15281c34f511b8aba71c97ac9c30bcb14f907fd", size = 399495, upload-time = "2026-03-20T15:15:39.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/bd/b6c802bf3fa12cf60dc1685412a934c562426e3cc414d4d8c1c536ddad8a/sphinx_contributors-0.3.0-py3-none-any.whl", hash = "sha256:fd762cf65d4b931f4e073c1a0ae37aa5881b8a01788fb633963ba6b104c1e90b", size = 5595, upload-time = "2026-03-20T15:15:37.904Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-favicon" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imagesize" }, + { name = "requests" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/26/e7ca2321e6286d6ed6a2e824a0ee35ae660ec9a45a4719e33a627ce9e4d2/sphinx_favicon-1.1.0.tar.gz", hash = "sha256:6f65939fc2a6ac4259c88b09169f0b72681cd4c03dd1d0cf91c57a1fa314e50b", size = 8744, upload-time = "2026-02-12T20:55:41.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/99/c85bc52d785557abddc4d8fdacfbcbda56fb3e715f76b9675fb9c2018aa5/sphinx_favicon-1.1.0-py3-none-any.whl", hash = "sha256:3ca71506fbb4d9a30bddc60a29e3fb8854f3e237ad95abad5a5c15f857d987b2", size = 7241, upload-time = "2026-02-12T20:55:40.312Z" }, +] + +[[package]] +name = "sphinx-togglebutton" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "setuptools" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "wheel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/be/169a0b0a8ad9588e8697c85e1d489aaaca7416073c2fc0267c360af5aae9/sphinx_togglebutton-0.4.5.tar.gz", hash = "sha256:c870dfbd3bc6e119b50ff9a37a64f8991902269e856728931c7d89877e8d4b3d", size = 18101, upload-time = "2026-03-27T13:50:41.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2e/3dd55564928c5d61f92827d4b91307dde7911a40fbe0000645d73202eea9/sphinx_togglebutton-0.4.5-py3-none-any.whl", hash = "sha256:74eac6d2426110c3e1e6f989a98e07d7823141a335df1ad8a9d637bdf6a7af62", size = 44907, upload-time = "2026-03-27T13:50:40.94Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c1690994afe461aae2d0cac62251e6802a703dec0a6c549c02ecd0de92a9/torch-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2c0d7fcfbc0c4e8bb5ebc3907cbc0c6a0da1b8f82b1fc6e14e914fa0b9baf74e", size = 80526521, upload-time = "2026-03-23T18:12:06.86Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f0/98ae802fa8c09d3149b0c8690741f3f5753c90e779bd28c9613257295945/torch-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4cf8687f4aec3900f748d553483ef40e0ac38411c3c48d0a86a438f6d7a99b18", size = 419723025, upload-time = "2026-03-23T18:11:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/18a9b10b4bd34f12d4e561c52b0ae7158707b8193c6cfc0aad2b48167090/torch-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1b32ceda909818a03b112006709b02be1877240c31750a8d9c6b7bf5f2d8a6e5", size = 530589207, upload-time = "2026-03-23T18:11:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/40/2d532e8c0e23705be9d1debce5bc37b68d59a39bda7584c26fe9668076fe/torch-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3c712ae6fb8e7a949051a953fc412fe0a6940337336c3b6f905e905dac5157f", size = 114518313, upload-time = "2026-03-23T18:11:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0d/98b410492609e34a155fa8b121b55c7dca229f39636851c3a9ec20edea21/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b6a60d48062809f58595509c524b88e6ddec3ebe25833d6462eeab81e5f2ce4", size = 80529712, upload-time = "2026-03-23T18:12:02.608Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/acea680005f098f79fd70c1d9d5ccc0cb4296ec2af539a0450108232fc0c/torch-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d91aac77f24082809d2c5a93f52a5f085032740a1ebc9252a7b052ef5a4fddc6", size = 419718178, upload-time = "2026-03-23T18:10:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8b/d7be22fbec9ffee6cff31a39f8750d4b3a65d349a286cf4aec74c2375662/torch-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7aa2f9bbc6d4595ba72138026b2074be1233186150e9292865e04b7a63b8c67a", size = 530604548, upload-time = "2026-03-23T18:10:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/8ed2173589cbfe744ed54e5a73efc107c0085ba5777ee93a5f4c1ab90553/torch-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:63a68fa59de8f87acc7e85a5478bb2dddbb3392b7593ec3e78827c793c4b73fd", size = 419732382, upload-time = "2026-03-23T18:08:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/b73f7c575a4b8f87a5928f50a1e35416b5e27295d8be9397d5293e7e8d4c/torch-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cc89b9b173d9adfab59fd227f0ab5e5516d9a52b658ae41d64e59d2e55a418db", size = 530711509, upload-time = "2026-03-23T18:08:47.213Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/3e3fcdd388fbe54e29fd3f991f36846ff4ac90b0d0181e9c8f7236565f82/torch-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4dda3b3f52d121063a731ddb835f010dc137b920d7fec2778e52f60d8e4bf0cd", size = 114555842, upload-time = "2026-03-23T18:09:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/56bfb37073e7136e6dd86bfc6af7339946dd684e0ecf2155ac0eee687ae1/torch-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2658f34ce7e2dabf4ec73b45e2ca68aedad7a5be87ea756ad656eaf32bf1e1ea", size = 419732324, upload-time = "2026-03-23T18:09:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/1b666b6d61d3394cca306ea543ed03a64aad0a201b6cd159f1d41010aeb1/torch-2.11.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98bb213c3084cfe176302949bdc360074b18a9da7ab59ef2edc9d9f742504778", size = 530596026, upload-time = "2026-03-23T18:09:20.842Z" }, + { url = "https://files.pythonhosted.org/packages/48/6b/30d1459fa7e4b67e9e3fe1685ca1d8bb4ce7c62ef436c3a615963c6c866c/torch-2.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a97b94bbf62992949b4730c6cd2cc9aee7b335921ee8dc207d930f2ed09ae2db", size = 114793702, upload-time = "2026-03-23T18:09:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, + { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" }, + { 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 = "torchvision" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/b4/cdfee31e0402ea035135462cb0ab496e974d56fab6b4e7a1f0cbccb8cd28/torchvision-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a06d4772a8e13e772906ed736cc53ec6639e5e60554f8e5fa6ca165aabebc464", size = 1863503, upload-time = "2026-03-23T18:13:01.384Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/11fee109841e80ad14e5ca2d80bff6b10eb11b7838ff06f35bfeaa9f7251/torchvision-0.26.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:2adfbe438473236191ff077a4a9a0c767436879c89628aa97137e959b0c11a94", size = 7766423, upload-time = "2026-03-23T18:12:56.049Z" }, + { url = "https://files.pythonhosted.org/packages/5e/00/24d8c7845c3f270153fb81395a5135b2778e2538e81d14c6aea5106c689c/torchvision-0.26.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b6f9ad1ecc0eab52647298b379ee9426845f8903703e6127973f8f3d049a798b", size = 7518249, upload-time = "2026-03-23T18:12:51.743Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/e53cd7c0da7ae002e5e929c1796ebbe7ec0c700c29f7a0a6696497fb3d8b/torchvision-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:f13f12b3791a266de2d599cb8162925261622a037d87fc03132848343cf68f75", size = 3669784, upload-time = "2026-03-23T18:12:49.949Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bd/d552a2521bade3295b2c6e7a4a0d1022261cab7ca7011f4e2a330dbb3caa/torchvision-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55bd6ad4ae77be01ba67a410b05b51f53b0d0ee45f146eb6a0dfb9007e70ab3c", size = 1863499, upload-time = "2026-03-23T18:12:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/33/bf/21b899792b08cae7a298551c68398a79e333697479ed311b3b067aab4bdc/torchvision-0.26.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1c55dc8affbcc0eb2060fbabbe996ae9e5839b24bb6419777f17848945a411b1", size = 7767527, upload-time = "2026-03-23T18:12:44.348Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/57bbf9e216850d065e66dd31a50f57424b607f1d878ab8956e56a1f4e36b/torchvision-0.26.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd10b5f994c210f4f6d6761cf686f82d748554adf486cb0979770c3252868c8f", size = 7519925, upload-time = "2026-03-23T18:12:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/10/58/ed8f7754299f3e91d6414b6dc09f62b3fa7c6e5d63dfe48d69ab81498a37/torchvision-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:de6424b12887ad884f39a0ee446994ae3cd3b6a00a9cafe1bead85a031132af0", size = 3983834, upload-time = "2026-03-23T18:13:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e7/56b47cc3b132aea90ccce22bcb8975dec688b002150012acc842846039d0/torchvision-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c409e1c3fdebec7a3834465086dbda8bf7680eff79abf7fd2f10c6b59520a7a4", size = 1863502, upload-time = "2026-03-23T18:12:57.326Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ec/5c31c92c08b65662fe9604a4067ae8232582805949f11ddc042cebe818ed/torchvision-0.26.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:406557718e62fdf10f5706e88d8a5ec000f872da913bf629aab9297622585547", size = 7767944, upload-time = "2026-03-23T18:12:42.805Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d8/cb6ccda1a1f35a6597645818641701207b3e8e13553e75fce5d86bac74b2/torchvision-0.26.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d61a5abb6b42a0c0c311996c2ac4b83a94418a97182c83b055a2a4ae985e05aa", size = 7522205, upload-time = "2026-03-23T18:12:54.654Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a9/c272623a0f735c35f0f6cd6dc74784d4f970e800cf063bb76687895a2ab9/torchvision-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:7993c01648e7c61d191b018e84d38fe0825c8fcb2720cd0f37caf7ba14404aa1", size = 4255155, upload-time = "2026-03-23T18:12:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/0762f77f53605d10c9477be39bb47722cc8e383bbbc2531471ce0e396c07/torchvision-0.26.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5d63dd43162691258b1b3529b9041bac7d54caa37eae0925f997108268cbf7c4", size = 1860809, upload-time = "2026-03-23T18:12:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/e6/81/0b3e58d1478c660a5af4268713486b2df7203f35abd9195fea87348a5178/torchvision-0.26.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a39c7a26538c41fda453f9a9692b5ff9b35a5437db1d94f3027f6f509c160eac", size = 7727494, upload-time = "2026-03-23T18:12:46.062Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/d9ab5d29115aa05e12e30f1397a3eeae1d88a511241dc3bce48dc4342675/torchvision-0.26.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b7e6213620bbf97742e5f79832f9e9d769e6cf0f744c5b53dad80b76db633691", size = 7521747, upload-time = "2026-03-23T18:12:36.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1b/f1bc86a918c5f6feab1eeff11982e2060f4704332e96185463d27855bdf5/torchvision-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:4280c35ec8cba1fcc8294fb87e136924708726864c379e4c54494797d86bc474", size = 4319880, upload-time = "2026-03-23T18:12:38.168Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/b4ad0a723ed95b003454caffcc41894b34bd8379df340848cae2c33871de/torchvision-0.26.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:358fc4726d0c08615b6d83b3149854f11efb2a564ed1acb6fce882e151412d23", size = 1951973, upload-time = "2026-03-23T18:12:48.781Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/7a89096e6cf2f3336353b5338ba925e0addf9d8601920340e6bdf47e8eb3/torchvision-0.26.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3daf9cc149cf3cdcbd4df9c59dae69ffca86c6823250442c3bbfd63fc2e26c61", size = 7728679, upload-time = "2026-03-23T18:12:26.196Z" }, + { url = "https://files.pythonhosted.org/packages/69/1d/4e1eebc17d18ce080a11dcf3df3f8f717f0efdfa00983f06e8ba79259f61/torchvision-0.26.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:82c3965eca27e86a316e31e4c3e5a16d353e0bcbe0ef8efa2e66502c54493c4b", size = 7609138, upload-time = "2026-03-23T18:12:35.327Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a4/f1155e943ae5b32400d7000adc81c79bb0392b16ceb33bcf13e02e48cced/torchvision-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ebc043cc5a4f0bf22e7680806dbba37ffb19e70f6953bbb44ed1a90aeb5c9bea", size = 4248202, upload-time = "2026-03-23T18:12:41.423Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c8/9bffa9c7f7bdf95b2a0a2dc535c290b9f1cc580c3fb3033ab1246ffffdeb/torchvision-0.26.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:eb61804eb9dbe88c5a2a6c4da8dec1d80d2d0a6f18c999c524e32266cb1ebcd3", size = 1860813, upload-time = "2026-03-23T18:12:39.636Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ac/48f28ffd227991f2e14f4392dde7e8dc14352bb9428c1ef4a4bbf5f7ed85/torchvision-0.26.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:9a904f2131cbfadab4df828088a9f66291ad33f49ff853872aed1f86848ef776", size = 7727777, upload-time = "2026-03-23T18:12:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/a4/21/a2266f7f1b0e58e624ff15fd6f01041f59182c49551ece0db9a183071329/torchvision-0.26.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f3e572efe62ad645017ea847e0b5e4f2f638d4e39f05bc011d1eb9ac68d4806", size = 7522174, upload-time = "2026-03-23T18:12:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/1666f90bc0bdd77aaa11dcc42bb9f621a9c3668819c32430452e3d404730/torchvision-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:114bec0c0e98aa4ba446f63e2fe7a2cbca37b39ac933987ee4804f65de121800", size = 4348469, upload-time = "2026-03-23T18:12:24.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/1f0402ac55c2ae15651ff831957d083fe70b2d12282e72612a30ba601512/torchvision-0.26.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:b7d3e295624a28b3b1769228ce1345d94cf4d390dd31136766f76f2d20f718da", size = 1860826, upload-time = "2026-03-23T18:12:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6a/18a582fe3c5ee26f49b5c9fb21ad8016b4d1c06d10178894a58653946fda/torchvision-0.26.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7058c5878262937e876f20c25867b33724586aa4499e2853b2d52b99a5e51953", size = 7729089, upload-time = "2026-03-23T18:12:31.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/9b/f7e119b59499edc00c55c03adc9ec3bd96144d9b81c46852c431f9c64a9a/torchvision-0.26.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8008474855623c6ba52876589dc52df0aa66e518c25eca841445348e5f79844c", size = 7522704, upload-time = "2026-03-23T18:12:20.301Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6a/09f3844c10643f6c0de5d95abc863420cfaf194c88c7dffd0ac523e2015f/torchvision-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e9d0e022c19a78552fb055d0414d47fecb4a649309b9968573daea160ba6869c", size = 4454275, upload-time = "2026-03-23T18:12:27.487Z" }, +] + +[[package]] +name = "triton" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ba/b1b04f4b291a3205d95ebd24465de0e5bf010a2df27a4e58a9b5f039d8f2/triton-3.6.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c723cfb12f6842a0ae94ac307dba7e7a44741d720a40cf0e270ed4a4e3be781", size = 175972180, upload-time = "2026-01-20T16:15:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, +] From 80b998c5728ceec76d4177f7e41dbd8f81672447 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Wed, 29 Apr 2026 15:54:45 +0200 Subject: [PATCH 03/30] Update ci.yml --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cfe59b..864e7ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,8 @@ jobs: - name: Check imports (no deprecation warnings) run: | - uv run python -W error::DeprecationWarning -W error::PendingDeprecationWarning -c "import tools, experiments, aggregators, attacks" + uv run python -W error::DeprecationWarning -W error::PendingDeprecationWarning -c "from krum import tools, experiments, aggregators, attacks" + uv run python -W error::DeprecationWarning -W error::PendingDeprecationWarning -c "import krum.tools, krum.experiments, krum.aggregators, krum.attacks" - name: Lint with Ruff run: uv run ruff check . From 430f3475453101df60c4258b1713be527511c020 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Wed, 29 Apr 2026 15:56:55 +0200 Subject: [PATCH 04/30] Update package versions in ci.yml --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 864e7ce..4d1980f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} From 75747dec74381ee6c7284e3fff26a509856d2c30 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Wed, 29 Apr 2026 15:59:40 +0200 Subject: [PATCH 05/30] Update ci.yml --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d1980f..3238359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,13 @@ jobs: uses: astral-sh/setup-uv@v8.1.0 with: python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Cache native builds + uses: actions/cache@v5.0.5 + with: + path: krum/native + key: native-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('krum/native/**/*.cpp', 'krum/native/**/*.cu', 'krum/native/**/*.hpp', 'uv.lock') }} - name: Install dependencies run: uv sync --extra dev From 0e18944d35469b60ef9e28e489ec16187ab9897b Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:02:28 +0200 Subject: [PATCH 06/30] Add ty, document PyPI install, fix CI branches --- .github/workflows/ci.yml | 4 +- README.md | 109 +++++++++++++++++++++++++++++++++++++-- pyproject.toml | 55 +++++++++++++++----- uv.lock | 26 ++++++++++ 4 files changed, 174 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3238359..4f7335c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, master, "9-feature-pip-installable"] + branches: [main, "9-feature-pip-installable"] pull_request: - branches: [main, master] + branches: [main] jobs: test: diff --git a/README.md b/README.md index 5aef86c..acdd5ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # Krum -Byzantine-resilient aggregation rules for distributed machine learning. +**Byzantine-resilient aggregation rules for distributed machine learning.** + +This project implements various Byzantine-resilient Gradient Aggregation Rules (GARs) for distributed learning. It allows simulating training sessions under different Byzantine attacks to evaluate the robustness of aggregation strategies like Krum and Multi-Krum. + +## Features + +- **Byzantine-resilient GARs**: Implementations of Krum, Multi-Krum, Bulyan, Coordinate-wise Median, and standard Averaging. +- **Byzantine Attacks**: Simulation of various attacks (e.g., NaN attack, identical gradients, "A little is enough"). +- **Differential Privacy**: Support for Gaussian noise addition for differential privacy. +- **Reproducibility**: Fixed seed support for reproducible experiments. +- **Extensible**: Easy to add new aggregation rules or attacks. +- **High-performance**: Optional native C++ backend for Krum. ## Supported Python versions @@ -8,17 +19,90 @@ This project supports Python **3.10 through 3.14**. ## Installation -Install in editable mode with development dependencies: +### From PyPI + +```bash +pip install krum +``` + +With `uv` (Recommended): + +```bash +uv pip install krum +# or directly in a uv project +uv add krum +``` + +### From source + +For development or if you want to modify the source, clone the repository and install in editable mode with the development dependencies: ```bash +git clone https://github.com/calicarpa/krum.git +cd krum pip install -e ".[dev]" ``` -## Development +With `uv` (Recommended): + +```bash +git clone https://github.com/calicarpa/krum.git +cd krum +uv sync --extra dev +``` + +This installs all linting, type-checking, and documentation tools. + +## Usage + +You can run training simulations using `train.py`. The script accepts numerous arguments to configure the experiment. -### Linting and formatting +### Example: Train on MNIST with Multi-Krum -This project uses [Ruff](https://docs.astral.sh/ruff/) for unified linting and formatting. +```bash +uv run python train.py \ + --dataset mnist \ + --model simples-conv \ + --gar krum \ + --gar-args m=2 \ + --attack identical \ + --nb-workers 11 \ + --nb-decl-byz 4 \ + --nb-real-byz 4 \ + --nb-steps 500 \ + --result-directory results/multi-krum-test +``` + +### Command-line Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `--seed` | -1 | Fixed seed for reproducibility (-1 for random) | +| `--device` | auto | Device to use (e.g., 'cpu', 'cuda', 'auto') | +| `--nb-workers` | 11 | Total number of worker machines | +| `--nb-decl-byz` | 4 | Declared number of Byzantine workers | +| `--nb-real-byz` | 0 | Actual number of Byzantine workers | +| `--gar` | average | Aggregation rule to use (krum, bulyan, median, etc.) | +| `--gar-args` | | Additional GAR arguments (e.g., `m=2`) | +| `--attack` | nan | Attack to simulate (nan, identical, etc.) | +| `--nb-steps` | 300 | Number of training steps | +| `--result-directory` | None | Path to save results and checkpoints | + +## Available Algorithms + +### Aggregation Rules (GARs) + +- *in progress* + +### Attacks + +- *in progress* + +## Contributing + +### Linting, formatting, and type-checking + +This project uses [Ruff](https://docs.astral.sh/ruff/) for unified linting and formatting, and [ty](https://github.com/astral-sh/ty) for type-checking. Run the formatter and linter: @@ -27,6 +111,12 @@ ruff format . ruff check --fix . ``` +Run the type checker: + +```bash +ty check +``` + ### Pre-commit hooks Install pre-commit hooks to block non-compliant commits: @@ -35,6 +125,15 @@ Install pre-commit hooks to block non-compliant commits: pre-commit install ``` +### Documentation + +Build the documentation locally: + +```bash +cd docs +make html +``` + ## License MIT License — see [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml index abc033b..5eb31f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.9.0", -] -docs = [ + "ty>=0.0.34", "sphinx>=8.1.3", "sphinx-copybutton>=0.5.2", "sphinx-favicon>=1.0.1", @@ -56,7 +55,6 @@ Issues = "https://github.com/calicarpa/krum/issues" [tool.setuptools.packages.find] where = ["."] include = ["krum*"] -exclude = ["repositories*"] [tool.setuptools.package-data] "krum.native" = ["**/*.hpp", "**/*.cpp", "**/*.cu", "**/*.h", "**/*.deps", "**/.placeholder"] @@ -68,28 +66,59 @@ exclude = [ "repositories", ".venv", ".git", - ".mypy_cache", ".ruff_cache", + "build", + "dist", ] [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "F", # Pyflakes - "I", # isort - "N", # pep8-naming - "W", # pycodestyle warnings - "UP", # pyupgrade + "E", # pycodestyle errors + "F", # Pyflakes + "I", # isort + "N", # pep8-naming + "W", # pycodestyle warnings + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "A", # flake8-builtins + "COM", # flake8-commas + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "T20", # flake8-print + "RET", # flake8-return + "C90", # mccabe complexity + "TCH", # flake8-type-checking + "FA", # flake8-future-annotations + "PGH", # pygrep-hooks + "RUF", # ruff-specific rules + "PERF", # perflint + "PLR", # pylint refactor + "PLW", # pylint warnings ] ignore = [ - "E402", # module level import not at top of file (intentional in this codebase) - "E501", # line too long (handled by formatter) - "N818", # exception naming (UserException, StopTrainingLoop are intentional) + "E402", # module level import not at top of file (intentional in this codebase) + "E501", # line too long (handled by formatter) + "N818", # exception naming (UserException, StopTrainingLoop are intentional) + "COM812",# trailing comma missing (can conflict with formatter) + "ISC001",# implicit str concat (can conflict with formatter) ] +fix = true +show-fixes = true +preview = true +explicit-preview-rules = true [tool.ruff.lint.pydocstyle] convention = "google" +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # unused imports (re-exports) + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/uv.lock b/uv.lock index 106399f..b2006c0 100644 --- a/uv.lock +++ b/uv.lock @@ -680,6 +680,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "ruff" }, + { name = "ty" }, ] docs = [ { name = "shibuya" }, @@ -708,6 +709,7 @@ requires-dist = [ { name = "sphinx-togglebutton", marker = "extra == 'docs'", specifier = ">=0.3.2" }, { name = "torch", specifier = ">=2.11.0" }, { name = "torchvision", specifier = ">=0.26.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.34" }, ] provides-extras = ["dev", "docs"] @@ -2023,6 +2025,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, ] +[[package]] +name = "ty" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From ff118fa47f0a64b155bb67c5df9e3e2ec8dfc102 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:02:48 +0200 Subject: [PATCH 07/30] Update uv.lock --- uv.lock | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/uv.lock b/uv.lock index b2006c0..dbd5c55 100644 --- a/uv.lock +++ b/uv.lock @@ -680,9 +680,6 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "ruff" }, - { name = "ty" }, -] -docs = [ { name = "shibuya" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, @@ -691,6 +688,7 @@ docs = [ { name = "sphinx-copybutton" }, { name = "sphinx-favicon" }, { name = "sphinx-togglebutton" }, + { name = "ty" }, ] [package.metadata] @@ -701,17 +699,17 @@ requires-dist = [ { name = "pandas", specifier = ">=2.0.0" }, { name = "requests", specifier = ">=2.33.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, - { name = "shibuya", marker = "extra == 'docs'", specifier = ">=2026.1.9" }, - { name = "sphinx", marker = "extra == 'docs'", specifier = ">=8.1.3" }, - { name = "sphinx-contributors", marker = "extra == 'docs'", specifier = ">=0.3.0" }, - { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.2" }, - { name = "sphinx-favicon", marker = "extra == 'docs'", specifier = ">=1.0.1" }, - { name = "sphinx-togglebutton", marker = "extra == 'docs'", specifier = ">=0.3.2" }, + { name = "shibuya", marker = "extra == 'dev'", specifier = ">=2026.1.9" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = ">=8.1.3" }, + { name = "sphinx-contributors", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "sphinx-copybutton", marker = "extra == 'dev'", specifier = ">=0.5.2" }, + { name = "sphinx-favicon", marker = "extra == 'dev'", specifier = ">=1.0.1" }, + { name = "sphinx-togglebutton", marker = "extra == 'dev'", specifier = ">=0.3.2" }, { name = "torch", specifier = ">=2.11.0" }, { name = "torchvision", specifier = ">=0.26.0" }, { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.34" }, ] -provides-extras = ["dev", "docs"] +provides-extras = ["dev"] [[package]] name = "markupsafe" From ca326841bb9f14b7ff0846a5a7895ee47df13f1d Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:04:02 +0200 Subject: [PATCH 08/30] Lint and format code --- histogram.py | 4 +--- krum/aggregators/__init__.py | 10 +++++----- krum/aggregators/brute.py | 3 +-- krum/attacks/__init__.py | 6 +++--- krum/attacks/identical.py | 14 ++++++-------- krum/experiments/dataset.py | 9 ++++----- krum/experiments/loss.py | 2 +- krum/experiments/model.py | 8 +++----- krum/experiments/models/simples.py | 2 +- krum/native/__init__.py | 4 ++-- krum/tools/__init__.py | 2 +- krum/tools/jobs.py | 19 +++++++++---------- krum/tools/misc.py | 20 ++++++++++---------- krum/tools/pytorch.py | 12 ++++++------ pyproject.toml | 8 ++++---- train.py | 7 +++---- 16 files changed, 60 insertions(+), 70 deletions(-) diff --git a/histogram.py b/histogram.py index ece9af2..d016a6e 100644 --- a/histogram.py +++ b/histogram.py @@ -312,9 +312,7 @@ def compute_epoch(self): tools.warning("No valid JSON-formatted configuration, cannot compute the epoch number") return self dataset_name = self.json["dataset"] - training_size = {"mnist": 60000, "fashionmnist": 60000, "cifar10": 50000, "cifar100": 50000}.get( - dataset_name, None - ) + training_size = {"mnist": 60000, "fashionmnist": 60000, "cifar10": 50000, "cifar100": 50000}.get(dataset_name) if training_size is None: tools.warning(f"Unknown dataset {dataset_name!r}, cannot compute the epoch number") return self diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index 42d792a..b6f9526 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -61,11 +61,11 @@ def checked(**kwargs): # Select which function to call by default func = checked if __debug__ else unchecked # Bind all the (sub) functions to the selected function - setattr(func, "check", check) - setattr(func, "checked", checked) - setattr(func, "unchecked", unchecked) - setattr(func, "upper_bound", upper_bound) - setattr(func, "influence", influence) + func.check = check + func.checked = checked + func.unchecked = unchecked + func.upper_bound = upper_bound + func.influence = influence # Return the selected function with the associated name return func diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index d4c35d1..50e4142 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -56,8 +56,7 @@ def _compute_selection(gradients, f, **kwargs): if not math.isfinite(cur_dist): break # Check if new maximum - if cur_dist > cur_diam: - cur_diam = cur_dist + cur_diam = max(cur_diam, cur_dist) else: # Check if new selected diameter if sel_iset is None or cur_diam < sel_diam: diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index eb14438..e2a49d3 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -72,9 +72,9 @@ def checked(f_real, **kwargs): # Select which function to call by default func = checked if __debug__ else unchecked # Bind all the (sub) functions to the selected function - setattr(func, "check", check) - setattr(func, "checked", checked) - setattr(func, "unchecked", unchecked) + func.check = check + func.checked = checked + func.unchecked = unchecked # Export the selected function with the associated name attacks[name] = func diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py index 07bcbad..c8af135 100644 --- a/krum/attacks/identical.py +++ b/krum/attacks/identical.py @@ -79,9 +79,8 @@ def eval_factor(factor): return aggregated.dot(aggregated).item() factor = tools.line_maximize(eval_factor, evals=math.ceil(-factor)) - else: - if negative: - factor = -factor + elif negative: + factor = -factor # Generate the Byzantine gradient from the given/computed factor byz_grad = grad_avg grad_att.mul_(factor) @@ -128,11 +127,10 @@ def bulyan(grad_stck, grad_avg, target_idx=-1, **kwargs): """ if target_idx == "all": return torch.ones_like(grad_avg) - else: - assert isinstance(target_idx, int), f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" - grad_att = torch.zeros_like(grad_avg) - grad_att[target_idx] = 1 - return grad_att + assert isinstance(target_idx, int), f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" + grad_att = torch.zeros_like(grad_avg) + grad_att[target_idx] = 1 + return grad_att def empire(grad_stck, grad_avg, **kwargs): diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index 67537d8..1eab835 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -12,7 +12,7 @@ # Dataset wrappers/helpers. ### -__all__ = ["get_default_transform", "Dataset", "make_sampler", "make_datasets", "batch_dataset"] +__all__ = ["Dataset", "batch_dataset", "get_default_transform", "make_datasets", "make_sampler"] import pathlib import random @@ -411,7 +411,6 @@ def test_gen(inputs, labels, batch): train_len = split_pos batch_size = min(batch_size or train_len, train_len) return train_gen(inputs[:split_pos], labels[:split_pos], batch_size) - else: - test_len = dataset_len - split_pos - batch_size = min(batch_size or test_len, test_len) - return test_gen(inputs[split_pos:], labels[split_pos:], batch_size) + test_len = dataset_len - split_pos + batch_size = min(batch_size or test_len, test_len) + return test_gen(inputs[split_pos:], labels[split_pos:], batch_size) diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py index 91b97ff..c9fca57 100644 --- a/krum/experiments/loss.py +++ b/krum/experiments/loss.py @@ -12,7 +12,7 @@ # Loss/criterion wrappers/helpers. ### -__all__ = ["Loss", "Criterion"] +__all__ = ["Criterion", "Loss"] import torch diff --git a/krum/experiments/model.py b/krum/experiments/model.py index 0bef013..9706f48 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -185,9 +185,8 @@ def init(params): if len(param.shape) > 1: # Multi-dimensional if init_multi is not None: init_multi(param) - else: # Mono-dimensional - if init_mono is not None: - init_mono(param) + elif init_mono is not None: + init_mono(param) # Move parameters to target device model = model.to(**config) device = config["device"] @@ -387,8 +386,7 @@ def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): # Return the flat gradient (and the loss if requested) if outloss: return (self.get_gradient(), loss) - else: - return self.get_gradient() + return self.get_gradient() def update(self, gradient, optimizer=None, relink=None): """Update the parameters using the given gradient, and the given optimizer. diff --git a/krum/experiments/models/simples.py b/krum/experiments/models/simples.py index 18a2348..723d63a 100644 --- a/krum/experiments/models/simples.py +++ b/krum/experiments/models/simples.py @@ -12,7 +12,7 @@ # Collection of simple models. ### -__all__ = ["full", "conv", "logit", "linear"] +__all__ = ["conv", "full", "linear", "logit"] import torch diff --git a/krum/native/__init__.py b/krum/native/__init__.py index 3abba7a..2df033b 100644 --- a/krum/native/__init__.py +++ b/krum/native/__init__.py @@ -113,7 +113,7 @@ def build_and_load_one(path, deps=[]): nonlocal fail_modules with tools.Context(path.name, "info"): ident = path.name[:3] - if ident in ident_to_is_python.keys(): + if ident in ident_to_is_python: # Is a module directory if len(path.name) <= 3 or path.name[3] == "_": tools.warning("Skipped invalid module directory name " + repr(path.name)) @@ -154,7 +154,7 @@ def build_and_load_one(path, deps=[]): ) fail_modules.append(path) # Mark as failed return False - elif res: # Module and its sub-dependencies was/were built and loaded successfully + if res: # Module and its sub-dependencies was/were built and loaded successfully this_ldflags.append( "-Wl,--library=:" + str((base_directory / modname / (modname + ".so")).resolve()) ) diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py index c2bdb9f..93dde16 100644 --- a/krum/tools/__init__.py +++ b/krum/tools/__init__.py @@ -276,7 +276,7 @@ def import_exported_symbols(name, module, scope): """ global _imported if hasattr(module, "__all__"): - for symname in getattr(module, "__all__"): + for symname in module.__all__: # Check name if not hasattr(module, symname): with Context(None, "warning"): diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py index 5cfd3cf..836cb8b 100644 --- a/krum/tools/jobs.py +++ b/krum/tools/jobs.py @@ -12,7 +12,7 @@ # Simple job management for reproduction scripts. ### -__all__ = ["dict_to_cmdlist", "Command", "Jobs"] +__all__ = ["Command", "Jobs", "dict_to_cmdlist"] import shlex import subprocess @@ -61,14 +61,13 @@ def dict_to_cmdlist(dp): if isinstance(value, bool): if value: cmd.append(f"--{name}") - else: - if any(isinstance(value, typ) for typ in (list, tuple)): - cmd.append(f"--{name}") - for subval in value: - cmd.append(str(subval)) - elif value is not None: - cmd.append(f"--{name}") - cmd.append(str(value)) + elif any(isinstance(value, typ) for typ in (list, tuple)): + cmd.append(f"--{name}") + for subval in value: + cmd.append(str(subval)) + elif value is not None: + cmd.append(f"--{name}") + cmd.append(str(value)) return cmd @@ -137,7 +136,7 @@ def _run(topdir, name, seed, device, command): args = command.build(seed, device, resdir) # Launch the experiment and write the standard output/error tools.trace((" ").join(shlex.quote(arg) for arg in args)) - cmd_res = subprocess.run(args, capture_output=True) + cmd_res = subprocess.run(args, check=False, capture_output=True) if cmd_res.returncode == 0: tools.info("Experiment successful") else: diff --git a/krum/tools/misc.py b/krum/tools/misc.py index 953005e..3e34891 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -13,21 +13,21 @@ ### __all__ = [ + "ClassRegister", + "MethodCallReplicator", + "TimedContext", "UnavailableException", + "deltatime_format", + "deltatime_point", "fatal_unavailable", - "MethodCallReplicator", - "ClassRegister", - "parse_keyval", "fullqual", - "onetime", - "TimedContext", - "interactive", "get_loaded_dependencies", + "interactive", "line_maximize", - "pairwise", "localtime", - "deltatime_point", - "deltatime_format", + "onetime", + "pairwise", + "parse_keyval", ] import os @@ -207,7 +207,7 @@ def parse_keyval_auto_convert(val): low = val.lower() if low == "false": return False - elif low == "true": + if low == "true": return True # Try guess number for cls in (int, float): diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index 0c57988..4b37236 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -13,16 +13,16 @@ ### __all__ = [ - "relink", + "AccumulatedTimedContext", + "WeightedMSELoss", + "compute_avg_dev_max", "flatten", "grad_of", "grads_of", - "compute_avg_dev_max", - "AccumulatedTimedContext", - "weighted_mse_loss", - "WeightedMSELoss", - "regression", "pnm", + "regression", + "relink", + "weighted_mse_loss", ] import math diff --git a/pyproject.toml b/pyproject.toml index 5eb31f9..76ed48b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,10 @@ exclude = [ "build", "dist", ] +fix = true +show-fixes = true +preview = true +explicit-preview-rules = true [tool.ruff.lint] select = [ @@ -105,10 +109,6 @@ ignore = [ "COM812",# trailing comma missing (can conflict with formatter) "ISC001",# implicit str concat (can conflict with formatter) ] -fix = true -show-fixes = true -preview = true -explicit-preview-rules = true [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/train.py b/train.py index 2bd7a03..d500958 100644 --- a/train.py +++ b/train.py @@ -329,7 +329,7 @@ def result_get(name): return None # Return the bound descriptor, if any global result_fds - return result_fds.get(name, None) + return result_fds.get(name) def result_store(fd, *entries): @@ -457,10 +457,9 @@ def result_store(fd, *entries): def convert_to_supported_json_type(x): if type(x) in {str, int, float, bool, type(None), dict, list}: return x - elif type(x) is set: + if type(x) is set: return list(x) - else: - return str(x) + return str(x) datargs = dict( (name, convert_to_supported_json_type(getattr(args, name))) From 804ed171fb927505dfc9decadf7bd36f02e7d283 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:36:02 +0200 Subject: [PATCH 09/30] Add docstrings, type hints, and modernize code --- .python-version | 2 +- histogram.py | 46 +- krum/aggregators/__init__.py | 134 ++++-- krum/aggregators/average.py | 117 ++++- krum/aggregators/brute.py | 235 +++++++--- krum/aggregators/bulyan.py | 241 ++++++++--- krum/aggregators/krum.py | 291 ++++++++++--- krum/aggregators/median.py | 173 ++++++-- krum/attacks/__init__.py | 89 ++-- krum/attacks/identical.py | 278 +++++++++--- krum/attacks/nan.py | 95 ++++- krum/experiments/__init__.py | 33 +- krum/experiments/checkpoint.py | 242 ++++++++--- krum/experiments/configuration.py | 134 ++++-- krum/experiments/dataset.py | 385 +++++++++++------ krum/experiments/datasets/svm.py | 139 ++++-- krum/experiments/loss.py | 443 ++++++++++++++----- krum/experiments/model.py | 482 ++++++++++++++------- krum/experiments/models/simples.py | 229 ++++++---- krum/experiments/optimizer.py | 125 ++++-- krum/native/__init__.py | 11 +- krum/native/py_bulyan/bulyan.cu | 4 +- krum/tools/__init__.py | 378 +++++++++++----- krum/tools/jobs.py | 379 +++++++++-------- krum/tools/misc.py | 663 +++++++++++++++++++++-------- krum/tools/pytorch.py | 598 +++++++++++++++++--------- pyproject.toml | 32 +- reproduce.py | 22 +- train.py | 28 +- 29 files changed, 4301 insertions(+), 1727 deletions(-) diff --git a/.python-version b/.python-version index 4db6d7d..f02be28 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ ->=3.10 +>=3.10, <3.15 diff --git a/histogram.py b/histogram.py index 642b124..2ac45c3 100644 --- a/histogram.py +++ b/histogram.py @@ -69,7 +69,7 @@ def gtk_run(closure): Args: closure Ignored parameter """ - tools.warning("GTK 3.0 is unavailable: %s" % (err,)) + tools.warning(f"GTK 3.0 is unavailable: {err}") # ---------------------------------------------------------------------------- # # Data frame columns selection helper @@ -91,7 +91,7 @@ def select(data, *only_columns): if len(only_columns) == 0: return data # Intelligent selection - columns = list() + columns = [] for only_column in only_columns: only_column = only_column.lower() for column in data.columns: @@ -136,7 +136,7 @@ def to_string(x): Converted data to string """ if type(x) is float: - return "%e" % x + return f"{x:e}" return str(x).strip() def __init__(self, data, title="Display data"): @@ -149,7 +149,7 @@ def __init__(self, data, title="Display data"): # Make and fill list store store = Gtk.ListStore(*([str] * (len(data.columns) + 1))) for row in data.itertuples(): - store.append(list(self.to_string(x) for x in row)) + store.append([self.to_string(x) for x in row]) # Make the associated tree view view = Gtk.TreeView(store) columns = list(data.columns) @@ -196,8 +196,7 @@ def __init__(self, path_results): # Ensure directory exist if not path_results.exists(): raise tools.UserException( - "Result directory %r cannot be accessed or does not exist" - % str(path_results) + f"Result directory {str(path_results)!r} cannot be accessed or does not exist" ) # Load configuration string path_config = path_results / "config" @@ -205,8 +204,7 @@ def __init__(self, path_results): data_config = path_config.read_text().strip() except Exception as err: tools.warning( - "Result directory %r: unable to read configuration (%s)" - % (str(path_results), err) + f"Result directory {str(path_results)!r}: unable to read configuration ({err})" ) data_config = None # Load configuration json @@ -216,8 +214,7 @@ def __init__(self, path_results): data_json = json.load(fd) except Exception as err: tools.warning( - "Result directory %r: unable to read JSON configuration (%s)" - % (str(path_results), err) + f"Result directory {str(path_results)!r}: unable to read JSON configuration ({err})" ) data_json = None # Load training data @@ -229,8 +226,7 @@ def __init__(self, path_results): data_study.index.name = "Step number" except Exception as err: tools.warning( - "Result directory %r: unable to read training data (%s)" - % (str(path_results), err) + f"Result directory {str(path_results)!r}: unable to read training data ({err})" ) data_study = None # Load evaluation data @@ -240,8 +236,7 @@ def __init__(self, path_results): data_eval.index.name = "Step number" except Exception as err: tools.warning( - "Result directory %r: unable to read evaluation data (%s)" - % (str(path_results), err) + f"Result directory {str(path_results)!r}: unable to read evaluation data ({err})" ) data_eval = None # Merge data frames @@ -284,8 +279,7 @@ def display(self, *only_columns, name=None): display( self.get(*only_columns), title=( - "Session data%s for %r" - % (" (subset)" if len(only_columns) > 0 else "", self.name) + "Session data{} for {!r}".format(" (subset)" if len(only_columns) > 0 else "", self.name) ), ) # Return self to enable chaining @@ -343,7 +337,7 @@ def compute_epoch(self): }.get(dataset_name) if training_size is None: tools.warning( - "Unknown dataset %r, cannot compute the epoch number" % dataset_name + f"Unknown dataset {dataset_name!r}, cannot compute the epoch number" ) return self self.data[column_name] = self.data["Training point count"] / training_size @@ -465,8 +459,7 @@ def include(self, data, *cols, errs=None, lalp=1.0, ccnt=None): data = data.data elif not isinstance(data, pandas.DataFrame): raise RuntimeError( - "Expected a Session or DataFrame for 'data', got a %r" - % tools.fullqual(type(data)) + f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}" ) # Get the x-axis values if self._idx is None: @@ -474,8 +467,7 @@ def include(self, data, *cols, errs=None, lalp=1.0, ccnt=None): else: if self._idx not in data: raise RuntimeError( - "No column named %r to use as index in the given session/dataframe" - % (self._idx,) + f"No column named {self._idx!r} to use as index in the given session/dataframe" ) x = data[self._idx].to_numpy() # Select semantic: empty list = select all @@ -541,8 +533,7 @@ def include_single(self, data, key, col, err=None, lalp=1.0, ccnt=None): data = data.data elif not isinstance(data, pandas.DataFrame): raise RuntimeError( - "Expected a Session or DataFrame for 'data', got a %r" - % tools.fullqual(type(data)) + f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}" ) # Get the x-axis values if self._idx is None: @@ -550,8 +541,7 @@ def include_single(self, data, key, col, err=None, lalp=1.0, ccnt=None): else: if self._idx not in data: raise RuntimeError( - "No column named %r to use as index in the given session/dataframe" - % (self._idx,) + f"No column named {self._idx!r} to use as index in the given session/dataframe" ) x = data[self._idx].to_numpy() # Pick a new line style and color @@ -641,15 +631,13 @@ def generator_sum(gen): if zlabel is not None: if self._tax is None: tools.warning( - "No secondary y-axis found, but its label %r was provided" - % (zlabel,) + f"No secondary y-axis found, but its label {zlabel!r} was provided" ) else: self._tax.set_ylabel(zlabel) elif self._tax is not None: tools.warning( - "No label provided for the secondary y-axis; using label %r from the primary" - % (ylabel,) + f"No label provided for the secondary y-axis; using label {ylabel!r} from the primary" ) self._tax.set_ylabel(ylabel) self._ax.set_xlim(left=xmin, right=xmax) diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index b6f9526..b067aad 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -10,43 +10,81 @@ # @section DESCRIPTION # # Loading of the local modules. -# -# Each rule MUST support taking any named arguments, possibly ignoring them. -# The parameters MUST all be passed as their keyword arguments. -# The reserved argument names, and their interface, are the following: -# · gradients: Non-empty list of gradients to aggregate -# · f : Number of Byzantine gradients to support -# · model : Model (duck-typing 'experiments.Model') with valid default dataset and loss set -# The rule, given "valid" parameter(s), MUST NOT return a tensor that is a reference to any tensor given as parameter. -# -# Each rule MUST provide a "check" member function, taking the same arguments as the rule itself. -# The "check" member function returns 'None' when the parameters are valid, -# or an explanatory string when the parameters are not valid. -# The check member function MUST NOT modify the given parameters. -# -# Once registered, the check member function will be available as member "check". -# The raw function and a wrapped checking the input/output of the raw function -# will respectively be available as members "unchecked" and "checked". -# Which of these two functions is called by default depends whether debug mode is enabled. ### +""" +Gradient aggregation rules (GARs) for Byzantine-resilient distributed learning. + +Each rule combines a keyword-only aggregation function with a validation +function and optional metadata used by the training and experiment scripts. + +Contract +-------- + +Each aggregation rule MUST: + +1. Accept keyword-only arguments +2. Accept the reserved parameter ``gradients`` (non-empty list of gradients) +3. Accept the reserved parameter ``f`` (number of Byzantine gradients to tolerate) +4. Accept the reserved parameter ``model`` (model with configured defaults) +5. NOT return a tensor that aliases any input tensor + +Each rule MUST provide a ``check`` function that validates parameters and +returns ``None`` when valid, or a user-facing error message otherwise. + +The module exposes three variants for each rule: + +- ``rule``: The default version (checked in debug mode, unchecked in release) +- ``rule.checked``: Always validates parameters +- ``rule.unchecked``: Skips validation (faster in production) + +Additional metadata available on each rule: + +- ``rule.check``: The validation function +- ``rule.upper_bound``: Theoretical bound on stddev/norm ratio (if available) +- ``rule.influence``: Attack acceptance ratio (if available) +""" + import pathlib +from collections.abc import Callable -from krum import tools +import tools +import torch # ---------------------------------------------------------------------------- # # Automated GAR loader -def make_gar(unchecked, check, upper_bound=None, influence=None): - """GAR wrapper helper. - Args: - unchecked Associated function (see module description) - check Parameter validity check function - upper_bound Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this aggregation rule: (n, f, d) -> float - influence Attack acceptation ratio function - Returns: - Wrapped GAR +def make_gar( + unchecked: Callable, + check: Callable, + upper_bound: Callable | None = None, + influence: Callable | None = None, +) -> Callable: + """ + Wrap an unchecked GAR with validation and metadata. + + Parameters + ---------- + unchecked : callable + Aggregation function implementing the rule without parameter checks. + check : callable + Validation function. It must return ``None`` when parameters are valid, + or an error message otherwise. + upper_bound : callable, optional + Function computing the theoretical upper bound on the ratio between + non-Byzantine standard deviation and gradient norm. The expected + signature is ``(n, f, d) -> float``. + influence : callable, optional + Function computing the accepted Byzantine-gradient ratio for a given + set of honest and attack gradients. + + Returns + ------- + callable + Checked or unchecked GAR selected according to ``__debug__``. The + returned callable is annotated with ``check``, ``checked``, + ``unchecked``, ``upper_bound``, and ``influence`` attributes. """ # Closure wrapping the call with checks @@ -54,7 +92,9 @@ def checked(**kwargs): # Check parameter validity message = check(**kwargs) if message is not None: - raise tools.UserException(f"Aggregation rule {name!r} cannot be used with the given parameters: {message}") + raise tools.UserException( + f"Aggregation rule {name!r} cannot be used with the given parameters: {message}" + ) # Aggregation (hard to assert return value, duck-typing is allowed...) return unchecked(**kwargs) @@ -70,14 +110,28 @@ def checked(**kwargs): return func -def register(name, unchecked, check, upper_bound=None, influence=None): - """Simple registration-wrapper helper. - Args: - name GAR name - unchecked Associated function (see module description) - check Parameter validity check function - upper_bound Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this aggregation rule: (n, f, d) -> float - influence Attack acceptation ratio function +def register( + name: str, + unchecked: Callable, + check: Callable, + upper_bound: Callable | None = None, + influence: Callable | None = None, +) -> None: + """ + Register a gradient aggregation rule. + + Parameters + ---------- + name : str + User-visible GAR name. + unchecked : callable + Aggregation function implementing the rule without parameter checks. + check : callable + Validation function associated with ``unchecked``. + upper_bound : callable, optional + Function computing the rule's theoretical upper bound. + influence : callable, optional + Function computing the accepted Byzantine-gradient ratio. """ global gars # Check if name already in use @@ -85,11 +139,13 @@ def register(name, unchecked, check, upper_bound=None, influence=None): tools.warning(f"Unable to register {name!r} GAR: name already in use") return # Export the selected function with the associated name - gars[name] = make_gar(unchecked, check, upper_bound=upper_bound, influence=influence) + gars[name] = make_gar( + unchecked, check, upper_bound=upper_bound, influence=influence + ) # Registered rules (mapping name -> aggregation rule) -gars = dict() +gars = {} # Load all local modules with tools.Context("aggregators", None): diff --git a/krum/aggregators/average.py b/krum/aggregators/average.py index 6f13818..d8e573e 100644 --- a/krum/aggregators/average.py +++ b/krum/aggregators/average.py @@ -9,44 +9,117 @@ # # @section DESCRIPTION # -# Simple average GAR. +# Simple arithmetic mean aggregation rule. ### +""" +This is the simplest aggregation rule, computing the arithmetic mean of all +submitted gradients. It serves as a baseline for comparison with Byzantine- +resilient methods. + +Use Case +-------- + +Baseline for non-adversarial settings or when no Byzantine +behavior is expected. + +Properties +---------- + +- Non-resilient: Vulnerable to any Byzantine attack. A single malicious + gradient can completely skew the result. +- No parameters: No configuration required beyond the gradient list. +- Output: Newly created tensor, does not alias any input. + +Example +------- + +>>> import torch +>>> from aggregators import average +>>> gradients = [torch.tensor([1., 2., 3.]), torch.tensor([4., 5., 6.])] +>>> result = average(gradients=gradients) +tensor([2.5000, 3.5000, 4.5000]) +""" + +import torch + from . import register # ---------------------------------------------------------------------------- # # Average GAR -def aggregate(gradients, **kwargs): - """Averaging rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - Average gradient +def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: + """ + Compute the arithmetic mean of all submitted gradients. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. Each gradient should be + a 1-D tensor representing the flattened model parameters. + **kwargs : object + Additional keyword arguments, ignored by this rule. + + Returns + ------- + torch.Tensor + The arithmetic mean of all input gradients. + + Notes + ----- + The output tensor is a new tensor, not aliasing any input tensor. """ return sum(gradients) / len(gradients) -def check(gradients, **kwargs): - """Check parameter validity for the averaging rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string +def check(gradients: list[torch.Tensor], **kwargs) -> str | None: + """ + Check parameter validity for the averaging rule. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + **kwargs : object + Additional keyword arguments, ignored by this rule. + + Returns + ------- + str or None + ``None`` when parameters are valid, otherwise a user-facing error + message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + return ( + f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + ) + return None + + +def influence( + honests: list[torch.Tensor], attacks: list[torch.Tensor], **kwargs +) -> float: + """ + Compute the ratio of accepted Byzantine gradients. + + For arithmetic mean, all submitted gradients are used in the aggregation, + so the influence ratio is simply the fraction of Byzantine gradients + in the total. + Parameters + ---------- + honests : list of torch.Tensor + Non-empty list of honest gradients. + attacks : list of torch.Tensor + List of attack (Byzantine) gradients. + **kwargs : object + Additional keyword arguments, ignored by this rule. -def influence(honests, attacks, **kwargs): - """Compute the ratio of accepted Byzantine gradients. - Args: - honests Non-empty list of honest gradients to aggregate - attacks List of attack gradients to aggregate - ... Ignored keyword-arguments + Returns + ------- + float + Ratio of Byzantine gradients in the aggregation (attackers / total). """ return len(attacks) / (len(honests) + len(attacks)) diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index 50e4142..9462210 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -12,10 +12,61 @@ # Brute GAR. ### +""" +The Brute aggregation rule exhaustively searches all subsets of +:math:`n - f` gradients and selects the subset with the smallest finite +diameter. The diameter of a subset is the maximum pairwise distance between +any two gradients in that subset. + +Use Case +-------- +Theoretical baseline for evaluating other aggregation rules. Brute provides +strong Byzantine resilience guarantees, but its combinatorial search makes it +practical only for small worker counts or controlled experiments. + +Properties +---------- +- Exhaustive search: evaluates every :math:`\\binom{n}{n-f}` candidate subset. +- Optimal selection: returns a smallest-diameter valid subset under the explored + objective. +- Limited scalability: intended for small :math:`n` or research baselines. + +Theoretical Bound +----------------- +The Brute rule provides the best theoretical guarantees: + +.. math:: + + \\frac{\\sigma}{\\|g\\|} \\leq \\frac{n - f}{\\sqrt{8} f} + +where :math:`\\sigma` is the standard deviation of honest gradients. + +Complexity +---------- +- Time: :math:`O(\\binom{n}{n-f} \\cdot d \\cdot n^2)` where :math:`d` is the + gradient dimension. +- Space: :math:`O(n^2)` for storing pairwise distances. + +Example +------- +>>> import torch +>>> from aggregators import brute +>>> gradients = [ +... torch.tensor([1., 2., 3.]), +... torch.tensor([1.1, 2.1, 3.1]), +... torch.tensor([0.9, 1.9, 2.9]), +... torch.tensor([100., 200., 300.]), # Byzantine +... torch.tensor([-100., -200., -300.]) # Byzantine +... ] +>>> result = brute(gradients=gradients, f=2) +tensor([1., 2., 3.]) +""" + import itertools import math -from krum import tools +import tools +import torch from . import register @@ -29,14 +80,29 @@ # Brute GAR -def _compute_selection(gradients, f, **kwargs): - """Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - Selection index set +def _compute_selection( + gradients: list[torch.Tensor], f: int, **kwargs +) -> tuple[int, ...]: + """ + Select the gradient indices forming the smallest-diameter subset. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of candidate gradients. + f : int + Number of Byzantine gradients to tolerate. + **kwargs : object + Additional keyword arguments, ignored by this helper. + + Returns + ------- + tuple of int + Indices of the selected :math:`n - f` gradients. + + Notes + ----- + Candidate subsets containing non-finite pairwise distances are ignored. """ n = len(gradients) # Compute all pairwise distances @@ -69,68 +135,131 @@ def _compute_selection(gradients, f, **kwargs): return sel_iset -def aggregate(gradients, f, **kwargs): - """Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - Aggregated gradient +def aggregate(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | float: + """ + Compute the Brute aggregation (mean of smallest-diameter subset). + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. Must satisfy + ``1 <= f <= (n - 1) // 2`` where ``n = len(gradients)``. + **kwargs : object + Additional keyword arguments, ignored by this implementation. + + Returns + ------- + torch.Tensor + Mean of the selected :math:`n - f` gradients with smallest finite + diameter. + + Notes + ----- + The returned tensor is newly computed and does not alias any input tensor. """ sel_iset = _compute_selection(gradients, f, **kwargs) return sum(gradients[i] for i in sel_iset).div_(len(gradients) - f) -def aggregate_native(gradients, f, **kwargs): - """Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - Aggregated gradient +def aggregate_native( + gradients: list[torch.Tensor], f: int, **kwargs +) -> torch.Tensor | float: + """ + Compute the Brute aggregation using native C++/CUDA acceleration. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. + **kwargs : object + Additional keyword arguments, ignored by this implementation. + + Returns + ------- + torch.Tensor | float + Mean of the subset selected by the native Brute implementation. """ return native.brute.aggregate(gradients, f) -def check(gradients, f, **kwargs): - """Check parameter validity for Brute rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string +def check(gradients: list[torch.Tensor], f: int, **kwargs) -> str | None: + """ + Check parameter validity for the Brute aggregation rule. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. + **kwargs : object + Additional keyword arguments, ignored by this check. + + Returns + ------- + str or None + ``None`` when parameters are valid, otherwise a user-facing error + message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + return ( + f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + ) if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 1: - return f"Invalid number of Byzantine gradients to tolerate, got f = {f!r}, expected 1 ≤ f ≤ {(len(gradients) - 1) // 2}" + return ( + "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" + % (f, (len(gradients) - 1) // 2) + ) + return None + + +def upper_bound(n: int, f: int, d: int) -> float: + """ + Compute the theoretical Brute resilience bound. + Parameters + ---------- + n : int + Total number of workers, including Byzantine workers. + f : int + Expected number of Byzantine workers. + d : int + Dimension of the gradient space. -def upper_bound(n, f, d): - """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound + Returns + ------- + float + Upper bound on the ratio between non-Byzantine standard deviation and + gradient norm under which the rule is expected to apply. """ return (n - f) / (math.sqrt(8) * f) -def influence(honests, attacks, f, **kwargs): - """Compute the ratio of accepted Byzantine gradients. - Args: - honests Non-empty list of honest gradients to aggregate - attacks List of attack gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Ratio of accepted +def influence( + honests: list[torch.Tensor], attacks: list[torch.Tensor], f: int, **kwargs +) -> float: + """ + Compute the ratio of Byzantine gradients selected by Brute. + + Parameters + ---------- + honests : list of torch.Tensor + Non-empty list of honest gradients. + attacks : list of torch.Tensor + List of attack, or Byzantine, gradients. + f : int + Number of Byzantine gradients to tolerate. + **kwargs : object + Additional keyword arguments forwarded to the selection helper. + + Returns + ------- + float + Fraction of selected gradients that come from ``attacks``. """ gradients = honests + attacks # Compute the selection set diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 8fdb91a..7a44c93 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -12,12 +12,73 @@ # Bulyan over Multi-Krum GAR. ### +""" +Bulyan aggregation rule built on top of Multi-Krum. + +Bulyan combines distance-based gradient selection with coordinate-wise +robust averaging. It first selects a candidate set using a Multi-Krum-like +criterion, then aggregates each coordinate from the values closest to the +coordinate-wise median. + +Use Case +-------- + +Use Bulyan when stronger Byzantine resilience is needed than plain Multi-Krum +can provide, and when the worker count is high enough to satisfy the stricter +``n >= 4f + 3`` requirement. + +Properties +---------- + +- Two-stage aggregation: geometric selection then coordinate-wise averaging. +- Requires at least :math:`4f + 3` submitted gradients. +- Uses only newly allocated output tensors and does not return aliases of input + gradients. +- Theoretical bound available through :func:`upper_bound`. + +Algorithm +--------- + +1. Select candidate gradients with the smallest Multi-Krum scores. +2. For each coordinate, compute the median over the selected candidates. +3. Average the values closest to that median. + +Complexity +---------- + +- Time: :math:`O(n^2 \\cdot d)` where :math:`n` is the number of gradients and + :math:`d` is the gradient dimension. +- Space: :math:`O(n^2)` for storing pairwise distances. + +Parameters +---------- +m : int, optional + Number of gradients to consider in each Multi-Krum selection step. Defaults + to ``n - f - 2``. Must satisfy ``1 <= m <= n - f - 2``. + +Example +------- + +>>> import torch +>>> from aggregators import bulyan +>>> gradients = [ +... torch.tensor([1., 2., 3.]), +... torch.tensor([1.1, 2.1, 3.1]), +... torch.tensor([0.9, 1.9, 2.9]), +... torch.tensor([1.2, 2.2, 3.2]), +... torch.tensor([0.8, 1.8, 2.8]), +... torch.tensor([1.05, 2.05, 3.05]), +... torch.tensor([100., 200., 300.]), # Byzantine +... ] +>>> result = bulyan(gradients=gradients, f=1) +tensor([1., 2., 3.]) +""" + import math +import tools import torch -from krum import tools - from . import register # Optional 'native' module @@ -30,15 +91,33 @@ # Bulyan GAR class -def aggregate(gradients, f, m=None, **kwargs): - """Bulyan over Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient +def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch.Tensor: + """ + Compute the Bulyan aggregate. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of flattened gradients to aggregate. All tensors must + have the same shape, dtype, and device. + f : int + Number of Byzantine gradients to tolerate. Must satisfy + ``1 <= f <= (n - 3) // 4`` where ``n = len(gradients)``. + m : int, optional + Number of nearest gradients considered in each Multi-Krum selection + step. Defaults to ``n - f - 2``. + **kwargs : object + Additional keyword arguments. They are accepted for compatibility with + the GAR interface and ignored by this implementation. + + Returns + ------- + torch.Tensor + Bulyan-aggregated gradient. + + Notes + ----- + The returned tensor is newly allocated and does not alias any input tensor. """ n = len(gradients) d = gradients[0].shape[0] @@ -47,7 +126,7 @@ def aggregate(gradients, f, m=None, **kwargs): if m is None: m = m_max # Compute all pairwise distances - distances = list([(math.inf, None)] * n for _ in range(n)) + distances = [[(math.inf, None)] * n for _ in range(n)] for gid_x, gid_y in tools.pairwise(tuple(range(n))): dist = gradients[gid_x].sub(gradients[gid_y]).norm().item() if not math.isfinite(dist): @@ -63,7 +142,9 @@ def aggregate(gradients, f, m=None, **kwargs): scores[gid] = (sum(dist for dist, _ in dists), gid) distances[gid] = dict(dists) # Selection loop - selected = torch.empty(n - 2 * f - 2, d, dtype=gradients[0].dtype, device=gradients[0].device) + selected = torch.empty( + n - 2 * f - 2, d, dtype=gradients[0].dtype, device=gradients[0].device + ) for i in range(selected.shape[0]): # Update 'm' m = min(m, m_max - i) @@ -75,26 +156,47 @@ def aggregate(gradients, f, m=None, **kwargs): scores[0] = (math.inf, None) for score, gid in scores[1:]: if gid == gid_prune: - scores[gid] = (score - distances[gid][gid_prune], gid) + scores[gid] = (score - distance[gid][gid_prune], gid) # Coordinate-wise averaged median m = selected.shape[0] - 2 * f median = selected.median(dim=0).values - closests = selected.clone().sub_(median).abs_().topk(m, dim=0, largest=False, sorted=False).indices - closests.mul_(d).add_(torch.arange(0, d, dtype=closests.dtype, device=closests.device)) - avgmed = selected.take(closests).mean(dim=0) + closests = ( + selected.clone() + .sub_(median) + .abs_() + .topk(m, dim=0, largest=False, sorted=False) + .indices + ) + closests.mul_(d).add_( + torch.arange(0, d, dtype=closests.dtype, device=closests.device) + ) + return selected.take(closests).mean(dim=0) # Return resulting gradient - return avgmed - - -def aggregate_native(gradients, f, m=None, **kwargs): - """Bulyan over Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient + + +def aggregate_native( + gradients: list[torch.Tensor], f: int, m=None, **kwargs +) -> torch.Tensor: + """ + Compute the Bulyan aggregate using native C++/CUDA acceleration. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of flattened gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. + m : int, optional + Number of nearest gradients considered in each Multi-Krum selection + step. Defaults to ``n - f - 2``. + **kwargs : object + Additional keyword arguments. They are accepted for compatibility with + the GAR interface and ignored by this implementation. + + Returns + ------- + torch.Tensor + Bulyan-aggregated gradient. """ # Defaults if m is None: @@ -103,32 +205,67 @@ def aggregate_native(gradients, f, m=None, **kwargs): return native.bulyan.aggregate(gradients, f, m) -def check(gradients, f, m=None, **kwargs): - """Check parameter validity for Bulyan over Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string +def check(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> str | None: + """ + Check whether the Bulyan parameters satisfy the GAR contract. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. + m : int, optional + Number of nearest gradients considered in each Multi-Krum selection + step. If provided, must satisfy ``1 <= m <= n - f - 2``. + **kwargs : object + Additional keyword arguments. They are accepted for compatibility with + the GAR interface and ignored by this check. + + Returns + ------- + str or None + ``None`` when parameters are valid, otherwise a user-facing error + message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + return ( + f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + ) if not isinstance(f, int) or f < 1 or len(gradients) < 4 * f + 3: - return f"Invalid number of Byzantine gradients to tolerate, got f = {f!r}, expected 1 ≤ f ≤ {(len(gradients) - 3) // 4}" - if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): - return f"Invalid number of selected gradients, got m = {f!r}, expected 1 ≤ m ≤ {len(gradients) - f - 2}" - - -def upper_bound(n, f, d): - """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound + return ( + "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" + % (f, (len(gradients) - 3) // 4) + ) + if m is not None and ( + not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2 + ): + return ( + "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" + % (f, len(gradients) - f - 2) + ) + return None + + +def upper_bound(n: int, f: int, d: int) -> float: + """ + Compute Bulyan's theoretical resilience upper bound. + + Parameters + ---------- + n : int + Total number of workers, including Byzantine workers. + f : int + Expected number of Byzantine workers. + d : int + Gradient dimension. Accepted for compatibility with the GAR metadata + interface; the current formula does not depend on it. + + Returns + ------- + float + Upper bound on the ratio between non-Byzantine standard deviation and + gradient norm under the Bulyan assumptions. """ return 1 / math.sqrt(2 * (n - f + f * (n + f * (n - f - 2) - 2) / (n - 2 * f - 2))) diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py index 050f007..d53d943 100644 --- a/krum/aggregators/krum.py +++ b/krum/aggregators/krum.py @@ -12,9 +12,79 @@ # Multi-Krum GAR. ### +""" +Krum and Multi-Krum are distance-based Byzantine-resilient aggregation rules. + +For each candidate gradient, the rule computes a score by summing the distances +to its :math:`n - f - 1` nearest neighbours. It then selects the :math:`m` +lowest-scoring gradients and returns their average. Honest gradients are +expected to cluster together, while Byzantine gradients should receive larger +scores when they are far from the honest majority. + +Use Case +-------- + +General Byzantine-resilient aggregation when Byzantine gradients are expected to +be geometrically separated from honest gradients. + +Properties +---------- + +- Distance-based: Relies on pairwise gradient distances. +- Selects a subset: Not all gradients contribute to the final average. +- Multi-Krum: Averages the best :math:`m` candidates instead of selecting only + one candidate. +- Theoretical bound available through :func:`upper_bound`. + +Theoretical Bound +----------------- + +The Multi-Krum rule provides guarantees when: + +.. math:: + + \\frac{\\sigma}{\\|g\\|} \\leq \\frac{1}{\\sqrt{2 (n - f + \\frac{f(n + f(n - f - 2) - 2)}{n - 2f - 2})}} + +where: + +- :math:`n` is the total number of workers. +- :math:`f` is the number of Byzantine workers. +- :math:`\\sigma` is the standard deviation of honest gradients. +- :math:`\\|g\\|` is the norm of the honest gradient. + +Complexity +---------- +- Time: :math:`O(n^2 \\cdot d)` where :math:`n` is the number of gradients and + :math:`d` is the gradient dimension. +- Space: :math:`O(n^2)` for storing pairwise distances. + +Parameters +---------- +m : int, optional + Number of gradients to select for averaging. Defaults to ``n - f - 2``. + Must satisfy ``1 <= m <= n - f - 2``. + +Example +------- + +>>> import torch +>>> from aggregators import krum +>>> gradients = [ +... torch.tensor([1., 2., 3.]), +... torch.tensor([1.1, 2.1, 3.1]), +... torch.tensor([0.9, 1.9, 2.9]), +... torch.tensor([1.2, 2.2, 3.2]), +... torch.tensor([100., 200., 300.]), +... ] +>>> result = krum(gradients=gradients, f=1, m=2) +>>> result +tensor([1.0500, 2.0500, 3.0500]) +""" + import math -from krum import tools +import tools +import torch from . import register @@ -28,15 +98,28 @@ # Multi-Krum GAR -def _compute_scores(gradients, f, m, **kwargs): - """Multi-Krum score computation. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - List of (gradient, score) by sorted (increasing) scores +def _compute_scores( + gradients: list[torch.Tensor], f: int, m: int, **kwargs +) -> list[tuple[float, torch.Tensor]]: + """ + Compute Multi-Krum scores for all candidate gradients. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients. + f : int + Number of Byzantine gradients to tolerate. + m : int + Number of gradients to select. + **kwargs : object + Additional keyword arguments, ignored by this implementation. + + Returns + ------- + list of tuple[float, torch.Tensor] + Candidate gradients paired with their scores, sorted by increasing + score. """ n = len(gradients) # Compute all pairwise distances @@ -47,10 +130,10 @@ def _compute_scores(gradients, f, m, **kwargs): dist = math.inf distances[i] = dist # Compute the scores - scores = list() + scores = [] for i in range(n): # Collect the distances - grad_dists = list() + grad_dists = [] for j in range(i): grad_dists.append(distances[(2 * n - j - 3) * j // 2 + i - 1]) for j in range(i + 1, n): @@ -63,15 +146,33 @@ def _compute_scores(gradients, f, m, **kwargs): return scores -def aggregate(gradients, f, m=None, **kwargs): - """Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient +def aggregate( + gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs +) -> torch.Tensor: + """ + Aggregate gradients with Multi-Krum. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. Must satisfy + ``1 <= f <= (n - 3) // 2`` where ``n = len(gradients)``. + m : int, optional + Number of gradients to select for averaging. Defaults to + ``n - f - 2``. Must satisfy ``1 <= m <= n - f - 2``. + **kwargs : object + Additional keyword arguments, ignored by this implementation. + + Returns + ------- + torch.Tensor + Average of the selected ``m`` gradients with the smallest Krum scores. + + Notes + ----- + The output tensor is newly created and does not alias any input tensor. """ # Defaults if m is None: @@ -81,15 +182,27 @@ def aggregate(gradients, f, m=None, **kwargs): return sum(grad for _, grad in scores[:m]).div_(m) -def aggregate_native(gradients, f, m=None, **kwargs): - """Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Aggregated gradient +def aggregate_native( + gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs +) -> torch.Tensor: + """ + Aggregate gradients with the native Multi-Krum implementation. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. + m : int, optional + Number of gradients to select. Defaults to ``n - f - 2``. + **kwargs : object + Additional keyword arguments, ignored by this implementation. + + Returns + ------- + torch.Tensor + Average of the selected gradients. """ # Defaults if m is None: @@ -98,46 +211,98 @@ def aggregate_native(gradients, f, m=None, **kwargs): return native.krum.aggregate(gradients, f, m) -def check(gradients, f, m=None, **kwargs): - """Check parameter validity for Multi-Krum rule. - Args: - gradients Non-empty list of gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string +def check( + gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs +) -> str | None: + """ + Check whether Multi-Krum can be used with the given parameters. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + f : int + Number of Byzantine gradients to tolerate. + m : int, optional + Number of gradients to select. + **kwargs : object + Additional keyword arguments, ignored by this implementation. + + Returns + ------- + str or None + ``None`` when the parameters are valid, otherwise a user-facing error + message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + return ( + f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + ) if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 3: - return f"Invalid number of Byzantine gradients to tolerate, got f = {f!r}, expected 1 ≤ f ≤ {(len(gradients) - 3) // 2}" - if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): - return f"Invalid number of selected gradients, got m = {m!r}, expected 1 ≤ m ≤ {len(gradients) - f - 2}" - - -def upper_bound(n, f, d): - """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound + return ( + "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" + % (f, (len(gradients) - 3) // 2) + ) + if m is not None and ( + not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2 + ): + return ( + "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" + % (m, len(gradients) - f - 2) + ) + return None + + +def upper_bound(n: int, f: int, d: int) -> float: + """ + Compute the theoretical Multi-Krum robustness bound. + + Parameters + ---------- + n : int + Number of workers, including honest and Byzantine workers. + f : int + Expected number of Byzantine workers. + d : int + Dimension of the gradient space. This parameter is accepted for the + standard GAR metadata contract and is not used by this formula. + + Returns + ------- + float + Upper bound on the ratio between non-Byzantine standard deviation and + gradient norm. """ return 1 / math.sqrt(2 * (n - f + f * (n + f * (n - f - 2) - 2) / (n - 2 * f - 2))) -def influence(honests, attacks, f, m=None, **kwargs): - """Compute the ratio of accepted Byzantine gradients. - Args: - honests Non-empty list of honest gradients to aggregate - attacks List of attack gradients to aggregate - f Number of Byzantine gradients to tolerate - m Optional number of averaged gradients for Multi-Krum - ... Ignored keyword-arguments - Returns: - Ratio of accepted +def influence( + honests: list[torch.Tensor], + attacks: list[torch.Tensor], + f: int, + m: int | None = None, + **kwargs, +) -> float: + """ + Compute the ratio of Byzantine gradients selected by Multi-Krum. + + Parameters + ---------- + honests : list of torch.Tensor + Non-empty list of honest gradients. + attacks : list of torch.Tensor + List of attack, or Byzantine, gradients. + f : int + Number of Byzantine gradients to tolerate. + m : int, optional + Number of gradients to select. Defaults to ``n - f - 2``. + **kwargs : object + Additional keyword arguments forwarded to score computation. + + Returns + ------- + float + Ratio of selected gradients that come from ``attacks``. """ gradients = honests + attacks # Defaults diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py index 32f931e..40115b0 100644 --- a/krum/aggregators/median.py +++ b/krum/aggregators/median.py @@ -9,15 +9,61 @@ # # @section DESCRIPTION # -# NaN-resilient, coordinate-wise median GAR. +# Coordinate-wise median GAR. ### +""" +Coordinate-wise median aggregation rule. + +This rule computes the median of each coordinate independently across all +submitted gradients. It delegates to ``torch.median`` and does not filter +non-finite values before aggregation. NaN values may propagate, while Inf values +participate in the coordinate ordering. + +Use Case +-------- +Baseline coordinate-wise robust aggregation when input gradients are expected to +be finite. + +Properties +---------- +- Coordinate-wise: each dimension is treated independently. +- Non-finite values are not filtered before aggregation. +- Theoretical bound available through :func:`upper_bound`. + +Theoretical Bound +----------------- +The coordinate-wise median provides guarantees when the ratio of non-Byzantine +standard deviation to gradient norm is below: + +.. math:: + + \\frac{1}{\\sqrt{n - f}} + +where: + +- :math:`n` is the total number of workers. +- :math:`f` is the number of Byzantine workers. + +Example +------- +>>> import torch +>>> from aggregators import median +>>> gradients = [ +... torch.tensor([1., 100., 3.]), +... torch.tensor([2., 200., 4.]), +... torch.tensor([3., 300., 5.]), +... ] +>>> result = median(gradients=gradients) +>>> result +tensor([2., 200., 4.]) +""" + import math +import tools import torch -from krum import tools - from . import register # Optional 'native' module @@ -27,51 +73,110 @@ native = None # ---------------------------------------------------------------------------- # -# NaN-resilient, coordinate-wise median GAR +# Coordinate-wise median GAR -def aggregate(gradients, **kwargs): - """NaN-resilient median coordinate-per-coordinate rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - NaN-resilient, coordinate-wise median of the gradients +def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: + """ + Compute the coordinate-wise median of all submitted gradients. + + This method delegates to ``torch.median`` and does not filter non-finite + values before aggregation. NaN values may propagate, while Inf values + participate in the coordinate ordering. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. Each gradient should be a + 1-D tensor with the same shape, dtype, and device as the others. + **kwargs : object + Additional keyword arguments, accepted for compatibility with the GAR + interface and ignored by this implementation. + + Returns + ------- + torch.Tensor + Coordinate-wise median of all input gradients. + + Notes + ----- + The returned tensor is newly computed and does not alias any input tensor. """ return torch.stack(gradients).median(dim=0)[0] -def aggregate_native(gradients, **kwargs): - """NaN-resilient median coordinate-per-coordinate rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - NaN-resilient, coordinate-wise median of the gradients +def aggregate_native(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: + """ + Compute the coordinate-wise median using native C++/CUDA acceleration. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + **kwargs : object + Additional keyword arguments, accepted for compatibility with the GAR + interface and ignored by this implementation. + + Returns + ------- + torch.Tensor + Coordinate-wise median of all input gradients. """ return native.median.aggregate(gradients) -def check(gradients, **kwargs): - """Check parameter validity for the median rule. - Args: - gradients Non-empty list of gradients to aggregate - ... Ignored keyword-arguments - Returns: - None if valid, otherwise error message string +def check(gradients: list[torch.Tensor], **kwargs) -> str | None: + """ + Check whether the median rule can be used with the given parameters. + + Parameters + ---------- + gradients : list of torch.Tensor + Non-empty list of gradients to aggregate. + **kwargs : object + Additional keyword arguments, accepted for compatibility with the GAR + interface and ignored by this check. + + Returns + ------- + str or None + ``None`` when parameters are valid, otherwise a user-facing error + message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + return ( + f"Expected a list of at least one gradient to aggregate, got {gradients!r}" + ) + return None -def upper_bound(n, f, d): - """Compute the theoretical upper bound on the ratio non-Byzantine standard deviation / norm to use this rule. - Args: - n Number of workers (Byzantine + non-Byzantine) - f Expected number of Byzantine workers - d Dimension of the gradient space - Returns: - Theoretical upper-bound +def upper_bound(n: int, f: int, d: int) -> float: + """ + Compute the theoretical coordinate-wise median robustness bound. + + Parameters + ---------- + n : int + Total number of workers, including Byzantine workers. + f : int + Expected number of Byzantine workers. + d : int + Gradient dimension. Accepted for compatibility with the GAR metadata + interface; the current formula does not depend on it. + + Returns + ------- + float + Upper bound on the ratio between non-Byzantine standard deviation and + gradient norm. + + Notes + ----- + The bound formula is: + + .. math:: + + \\frac{1}{\\sqrt{n - f}} """ return 1 / math.sqrt(n - f) diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index e2a49d3..46f36de 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -10,44 +10,69 @@ # @section DESCRIPTION # # Loading of the local modules. -# -# Each attack MUST support taking any named arguments, possibly ignoring them. -# The parameters MUST all be passed as their keyword arguments. -# The reserved argument names, and their interface, are the following: -# · grad_honest: Non-empty list of honest gradients generated -# · f_decl : Number of declared Byzantine gradients at the GAR -# · f_real : Number of actual Byzantine gradients to generate -# · model : Model (duck-typing 'experiments.Model') with valid default dataset and loss set -# · defense : Aggregation rule (see module 'aggregators') in use to defeat -# The attack, given "valid" parameter(s), MUST return a list of f_byz tensor(s). -# Each of these returned tensors MUST NOT be a reference to any tensor given as parameter, -# although each returned tensors MAY be references to the same tensor. -# -# Each attack MUST provide a "check" function, taking the same arguments as the attack itself. -# The "check" member function returns 'None' when the parameters are valid, -# or an explanatory string when the parameters are not valid. -# The check member function MUST NOT modify the given parameters. -# -# Once registered, the check member function will be available as member "check". -# The raw function and a wrapped checking the input/output of the raw function -# will respectively be available as members "unchecked" and "checked". -# Which of these two functions is called by default depends whether debug mode is enabled. ### +""" +Byzantine attack registry used to evaluate aggregation-rule robustness. + +Each attack combines a keyword-only generation function with a validation +function. Registered attacks are loaded dynamically and exposed as module-level +callables. + +Contract +-------- + +Each attack MUST: + +1. Accept keyword-only arguments. +2. Accept the reserved parameter ``grad_honests`` (non-empty list of honest gradients). +3. Accept the reserved parameter ``f_decl`` (number of declared Byzantine gradients). +4. Accept the reserved parameter ``f_real`` (number of Byzantine gradients to generate). +5. Accept the reserved parameter ``model`` (model with configured defaults). +6. Accept the reserved parameter ``defense`` (aggregation rule to defeat). +7. Return exactly ``f_real`` tensors (list of Byzantine gradients). +8. NOT return tensors that alias any honest input tensor. +9. MAY reuse the same Byzantine tensor object when all generated gradients are identical. + +Each attack MUST provide a ``check`` function that validates parameters and +returns ``None`` when valid, or a user-facing error message otherwise. + +The module exposes three variants for each attack: + +- ``attack``: The default version (checked in debug mode, unchecked in release) +- ``attack.checked``: Always validates parameters +- ``attack.unchecked``: Skips validation (faster in production) +""" + import pathlib +from collections.abc import Callable -from krum import tools +import tools +import torch # ---------------------------------------------------------------------------- # # Automated attack loader -def register(name, unchecked, check): - """Simple registration-wrapper helper. - Args: - name Attack name - unchecked Associated function (see module description) - check Parameter validity check function +def register(name: str, unchecked: Callable, check: Callable) -> None: + """ + Register a Byzantine attack. + + Parameters + ---------- + name : str + User-visible attack name. + unchecked : callable + Attack implementation without parameter checks. It must return exactly + ``f_real`` Byzantine gradients. + check : callable + Validation function associated with ``unchecked``. It must return + ``None`` when parameters are valid, or an error message otherwise. + + Returns + ------- + None + The attack is registered as a module-level callable. """ global attacks # Check if name already in use @@ -60,7 +85,9 @@ def checked(f_real, **kwargs): # Check parameter validity message = check(f_real=f_real, **kwargs) if message is not None: - raise tools.UserException(f"Attack {name!r} cannot be used with the given parameters: {message}") + raise tools.UserException( + f"Attack {name!r} cannot be used with the given parameters: {message}" + ) # Attack res = unchecked(f_real=f_real, **kwargs) # Forward asserted return value @@ -80,7 +107,7 @@ def checked(f_real, **kwargs): # Registered attacks (mapping name -> attack) -attacks = dict() +attacks = {} # Load native and all local modules with tools.Context("attacks", None): diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py index c8af135..3134163 100644 --- a/krum/attacks/identical.py +++ b/krum/attacks/identical.py @@ -24,43 +24,132 @@ # 2019 Feb 16. ArXiv. URL: https://arxiv.org/pdf/1902.06156v1 ### +""" +Identical-gradient Byzantine attacks. + +These attacks generate ``f_real`` references to the same newly created +Byzantine gradient. The gradient is built from the average honest gradient plus +a scaled attack direction. + +Available attack directions are: + +1. **Bulyan**: unit vector, optionally restricted to one coordinate. +2. **Empire**: negative average honest gradient. +3. **Little**: coordinate-wise standard deviation of honest gradients. + +Use Case +-------- + +Testing aggregation rules against attacks that submit identical malicious +gradients from multiple Byzantine workers. + +Properties +---------- + +- Identical gradients: All Byzantine workers submit the same gradient. +- Direction-based: Attack direction is computed from honest gradients. +- Factor optimization: Negative factors trigger automatic optimization. +- Returns newly created tensors, does not alias honest input gradients. + +Parameters +---------- +factor : float or int, optional + Attack scaling factor. Positive values use the provided factor directly. + Negative integers trigger a line search over ``-factor`` evaluations. + Defaults to ``-16``. +negative : bool, optional + Whether to negate the selected factor. Defaults to ``False``. + +Example +------- + +>>> import torch +>>> from aggregators import average +>>> from attacks import little +>>> grad_honests = [torch.tensor([1., 2., 3.]), torch.tensor([4., 5., 6.])] +>>> byzantine_grads = little( +... grad_honests=grad_honests, +... f_decl=1, +... f_real=1, +... defense=average, +... model=None, +... factor=1.5, +... ) +>>> len(byzantine_grads) +1 +""" + import math +from collections.abc import Callable +import tools import torch -from krum import tools - from . import register # ---------------------------------------------------------------------------- # # Generic attack implementation generator -def make_attack(compute_direction): - """Make the attack gradient generation closure associated with an attack direction. - Args: - compute_direction Attack vector computation, (stacked honest gradients, average honest gradient, forwarded keyword-arguments...) -> attack vector (in the gradient space, no reference) - Returns: - Byzantine gradient generation closure +def make_attack(compute_direction: Callable) -> Callable: + """ + Create an identical-gradient attack from a direction function. + + Parameters + ---------- + compute_direction : callable + Function computing the attack direction from stacked honest gradients and + their average. + + Returns + ------- + callable + Attack function compatible with the attack registration contract. """ - def attack(grad_honests, f_real, f_decl, defense, model, factor=-16, negative=False, **kwargs): - """Generate the attack gradients. - Args: - grad_honests Non-empty list of honest gradients - f_decl Number of declared Byzantine gradients - f_real Number of Byzantine gradients to generate - defense Aggregation rule in use to defeat - model Model with valid default dataset and loss set - factor Fixed attack factor if positive, number of evaluations for best attack factor if negative - negative Use a negative factor instead of a positive one - ... Forwarded keyword-arguments - Returns: - Generated Byzantine gradients (all references to one) + def attack( + grad_honests: list[torch.Tensor], + f_real: int, + f_decl: int, + defense: Callable, + model: torch.nn.Module, + factor: float | int = -16, + negative: bool = False, + **kwargs, + ) -> list[torch.Tensor]: + """ + Generate identical Byzantine gradients. + + Parameters + ---------- + grad_honests : list of torch.Tensor + Non-empty list of honest gradients. + f_real : int + Number of Byzantine gradients to generate. + f_decl : int + Number of declared Byzantine gradients passed to ``defense``. + defense : callable + Aggregation rule to defeat. + model : torch.nn.Module + Model forwarded to ``defense``. + factor : float or int, optional + Attack factor. Positive values are used directly; negative integers + trigger automatic factor optimization. + negative : bool, optional + Whether to negate the selected factor. Defaults to ``False``. + **kwargs : object + Additional keyword arguments forwarded to ``compute_direction``. + + Returns + ------- + list of torch.Tensor + Generated Byzantine gradients. Each entry references the same newly + created Byzantine tensor and does not alias any honest input + gradient. """ # Fast path if f_real == 0: - return list() + return [] # Stack and compute the average honest gradient, and then the attack vector grad_stck = torch.stack(grad_honests) grad_avg = grad_stck.mean(dim=0) @@ -74,7 +163,11 @@ def eval_factor(factor): factor = -factor grad_attack = grad_avg + factor * grad_att # Measure effective squared distance - aggregated = defense(gradients=(grad_honests + [grad_attack] * f_real), f=f_decl, model=model) + aggregated = defense( + gradients=(grad_honests + [grad_attack] * f_real), + f=f_decl, + model=model, + ) aggregated.sub_(grad_avg) return aggregated.dot(aggregated).item() @@ -92,59 +185,144 @@ def eval_factor(factor): return attack -def check(grad_honests, f_real, defense, factor=-16, negative=False, **kwargs): - """Check parameter validity for this attack template. - Args: - grad_honests Non-empty list of honest gradients - f_real Number of Byzantine gradients to generate - defense Aggregation rule in use to defeat - ... Ignored keyword-arguments - Returns: - Whether the given parameters are valid for this attack +def check( + grad_honests: list[torch.Tensor], + f_real: int, + defense: Callable, + factor: float | int = -16, + negative: bool = False, + **kwargs, +) -> str | None: + """ + Check parameter validity for identical-gradient attacks. + + Parameters + ---------- + grad_honests : list of torch.Tensor + Non-empty list of honest gradients. + f_real : int + Number of Byzantine gradients to generate. + defense : callable + Aggregation rule to defeat. + factor : float or int, optional + Attack factor. Defaults to ``-16``. + negative : bool, optional + Whether to negate the selected factor. Defaults to ``False``. + **kwargs : object + Additional keyword arguments, ignored by this check. + + Returns + ------- + str or None + ``None`` when parameters are valid, otherwise a user-facing error + message. """ if not isinstance(grad_honests, list) or len(grad_honests) == 0: return f"Expected a non-empty list of honest gradients, got {grad_honests!r}" if not isinstance(f_real, int) or f_real < 0: - return f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" + return ( + f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" + ) if not callable(defense): return f"Expected a callable for the aggregation rule, got {defense!r}" - if not ((isinstance(factor, float) and factor > 0) or (isinstance(factor, int) and factor != 0)): - return f"Expected a positive number or a negative integer for the attack factor, got {factor!r}" + if not ( + (isinstance(factor, float) and factor > 0) + or (isinstance(factor, int) and factor != 0) + ): + return ( + f"Expected a positive number or a negative integer for the attack factor, got {factor!r}" + ) if not isinstance(negative, bool): return f"Expected a boolean for optional parameter 'negative', got {negative!r}" + return None # ---------------------------------------------------------------------------- # # Attack vector computations -def bulyan(grad_stck, grad_avg, target_idx=-1, **kwargs): - """Compute the attack vector adapted from "The Hidden Vulnerability". - Args: - target_idx Index of the targeted coordinate, "all" for all - See: - make_attack +def bulyan( + grad_stck: torch.Tensor, + grad_avg: torch.Tensor, + target_idx: int | str = -1, + **kwargs, +) -> torch.Tensor: + """ + Compute the Bulyan attack direction. + + This direction is adapted from "The Hidden Vulnerability of Distributed + Learning in Byzantium". + + Parameters + ---------- + grad_stck : torch.Tensor + Stacked honest gradients. + grad_avg : torch.Tensor + Average honest gradient. + target_idx : int or str, optional + Targeted coordinate index, or ``"all"`` to target every coordinate. + Defaults to ``-1``. + **kwargs : object + Additional keyword arguments, ignored by this direction. + + Returns + ------- + torch.Tensor + Attack direction. """ if target_idx == "all": return torch.ones_like(grad_avg) - assert isinstance(target_idx, int), f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" + assert isinstance(target_idx, int), ( + f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" + ) grad_att = torch.zeros_like(grad_avg) grad_att[target_idx] = 1 return grad_att -def empire(grad_stck, grad_avg, **kwargs): - """Compute the attack vector adapted from "Fall of Empires". - See: - make_attack +def empire(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.Tensor: + """ + Compute the Empire attack direction. + + This direction is adapted from "Fall of Empires". + + Parameters + ---------- + grad_stck : torch.Tensor + Stacked honest gradients. + grad_avg : torch.Tensor + Average honest gradient. + **kwargs : object + Additional keyword arguments, ignored by this direction. + + Returns + ------- + torch.Tensor + Attack direction, equal to the negative average honest gradient. """ return grad_avg.neg() -def little(grad_stck, grad_avg, **kwargs): - """Compute the attack vector adapted from "A Little is Enough". - See: - make_attack +def little(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.Tensor: + """ + Compute the Little attack direction. + + This direction is adapted from "A Little Is Enough". + + Parameters + ---------- + grad_stck : torch.Tensor + Stacked honest gradients. + grad_avg : torch.Tensor + Average honest gradient. + **kwargs : object + Additional keyword arguments, ignored by this direction. + + Returns + ------- + torch.Tensor + Attack direction, computed as the coordinate-wise standard deviation of + honest gradients. """ return grad_stck.var(dim=0).sqrt_() diff --git a/krum/attacks/nan.py b/krum/attacks/nan.py index 5ec9da9..e78b6ea 100644 --- a/krum/attacks/nan.py +++ b/krum/attacks/nan.py @@ -12,6 +12,38 @@ # Attack that generates NaN gradient(s), hence the name. ### +""" +NaN-valued Byzantine gradient attack. + +This attack generates gradients filled with NaN (Not a Number) values in order +to test whether an aggregation rule detects, rejects, or propagates non-finite +inputs. + +Use Case +-------- + +Simple baseline attack for evaluating aggregation-rule robustness against +non-finite values. + +Properties +---------- + +- Generates newly allocated gradients filled with NaN values. +- Reuses the same Byzantine tensor object for all returned gradients. +- Does not alias any honest input gradient. +- No parameters beyond gradient count. + +Example +------- + +>>> import torch +>>> from attacks import nan +>>> grad_honests = [torch.tensor([1., 2., 3.]), torch.tensor([4., 5., 6.])] +>>> byzantine_grads = nan(grad_honests=grad_honests, f_real=1) +>>> byzantine_grads[0] +tensor([nan, nan, nan]) +""" + import math import torch @@ -22,18 +54,32 @@ # Non-finite gradient attack -def attack(grad_honests, f_real, **kwargs): - """Generate non-finite gradients. - Args: - grad_honests Non-empty list of honest gradients - f_real Number of Byzantine gradients to generate - ... Ignored keyword-arguments - Returns: - Generated Byzantine gradients +def attack( + grad_honests: list[torch.Tensor], f_real: int, **kwargs +) -> list[torch.Tensor]: + """ + Generate NaN-valued Byzantine gradients. + + Parameters + ---------- + grad_honests : list of torch.Tensor + Non-empty list of honest gradients. The first gradient is used as the + shape, dtype, and device template for the generated Byzantine tensor. + f_real : int + Number of Byzantine gradients to generate. + **kwargs : object + Additional keyword arguments, ignored by this attack. + + Returns + ------- + list of torch.Tensor + List containing ``f_real`` references to the same newly allocated + NaN-valued Byzantine tensor. The returned tensor does not alias any + honest input gradient. """ # Fast path if f_real == 0: - return list() + return [] # Generate the non-finite Byzantine gradient byz_grad = torch.empty_like(grad_honests[0]) byz_grad.copy_(torch.tensor((math.nan,), dtype=byz_grad.dtype)) @@ -41,19 +87,32 @@ def attack(grad_honests, f_real, **kwargs): return [byz_grad] * f_real -def check(grad_honests, f_real, **kwargs): - """Check parameter validity for this attack. - Args: - grad_honests Non-empty list of honest gradients - f_real Number of Byzantine gradients to generate - ... Ignored keyword-arguments - Returns: - Whether the given parameters are valid for this attack +def check(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> str | None: + """ + Check parameter validity for the NaN attack. + + Parameters + ---------- + grad_honests : list of torch.Tensor + Non-empty list of honest gradients. + f_real : int + Number of Byzantine gradients to generate. + **kwargs : object + Additional keyword arguments, ignored by this check. + + Returns + ------- + str or None + ``None`` when parameters are valid, otherwise a user-facing error + message. """ if not isinstance(grad_honests, list) or len(grad_honests) == 0: return f"Expected a non-empty list of honest gradients, got {grad_honests!r}" if not isinstance(f_real, int) or f_real < 0: - return f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" + return ( + f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" + ) + return None # ---------------------------------------------------------------------------- # diff --git a/krum/experiments/__init__.py b/krum/experiments/__init__.py index 0667e2e..810698b 100644 --- a/krum/experiments/__init__.py +++ b/krum/experiments/__init__.py @@ -13,9 +13,40 @@ # Heavily relies on the module 'torchvision'. ### +""" +Experiment components for model training, dataset loading, and evaluation. + +This module groups the building blocks of a Krum training loop: + +- :mod:`experiments.configuration` — device and dtype configuration. +- :mod:`experiments.model` — model wrapper with parameter flattening. +- :mod:`experiments.dataset` — dataset loading and infinite batch sampling. +- :mod:`experiments.loss` — derivable loss composition. +- :mod:`experiments.optimizer` — optimizer wrapper with LR control. +- :mod:`experiments.checkpoint` — save and restore state dictionaries. + +Custom models and datasets can be added under ``experiments/models/`` and +``experiments/datasets/``; they are discovered automatically at import time. + +Example +------- + +.. code-block:: python + + from experiments import ( + Configuration, Model, Dataset, + Loss, Criterion, Optimizer, + make_datasets, + ) + + config = Configuration(device="cuda:0") + model = Model("resnet18", config, num_classes=10) + trainset, testset = make_datasets("cifar10", train_batch=128) +""" + import pathlib -from krum import tools +import tools # ---------------------------------------------------------------------------- # # Load all local modules diff --git a/krum/experiments/checkpoint.py b/krum/experiments/checkpoint.py index 1594135..5fa5968 100644 --- a/krum/experiments/checkpoint.py +++ b/krum/experiments/checkpoint.py @@ -12,15 +12,32 @@ # Checkpoint helpers. ### +""" +Checkpoint management for model, optimizer, and arbitrary stateful objects. + +This module provides :class:`Checkpoint` for saving and restoring state +dictionaries, and :class:`Storage` for plain-dictionary checkpointing. + +Example +------- + +>>> from experiments import Checkpoint, Model, Optimizer +>>> ckpt = Checkpoint() +>>> ckpt.snapshot(model).snapshot(optimizer) +>>> ckpt.save("run.pt") +>>> # Later... +>>> ckpt.load("run.pt") +>>> ckpt.restore(model).restore(optimizer) +""" + __all__ = ["Checkpoint", "Storage"] import copy import pathlib +import tools import torch -from krum import tools - from .model import Model from .optimizer import Optimizer @@ -29,95 +46,179 @@ class Checkpoint: - """A collection of state dictionaries with saving/loading helpers.""" + """ + Collection of state dictionaries with saving/loading helpers. + + This class can snapshot any object implementing the ``state_dict`` / + ``load_state_dict`` protocol (e.g. ``torch.nn.Module``, + ``torch.optim.Optimizer``). It also knows how to unwrap + :class:`~experiments.model.Model` and + :class:`~experiments.optimizer.Optimizer` wrappers automatically. + + Example + ------- + + >>> ckpt = Checkpoint() + >>> ckpt.snapshot(model, deepcopy=True) + >>> ckpt.restore(model) + """ # Transfer for handling local package's classes - _transfers = {Model: (lambda x: x._model), Optimizer: (lambda x: x._optim)} + _transfers = { + Model: (lambda x: x._model), + Optimizer: (lambda x: x._optim), + } @classmethod def _prepare(cls, instance): - """Prepare the given instance for checkpointing. - Args: - instance Instance to snapshot/restore - Returns: - Checkpoint-able instance, key for the associated storage + """ + Prepare an instance for checkpointing. + + If the instance is a wrapped :class:`Model` or :class:`Optimizer`, + the underlying PyTorch object is returned instead. + + Parameters + ---------- + instance : object + Instance to snapshot or restore. + + Returns + ------- + tuple[object, str] + Checkpoint-able instance and its fully-qualified storage key. + + Raises + ------ + tools.UserException + If the instance lacks ``state_dict`` or ``load_state_dict``. """ # Recover instance's class - instance_cls = type(instance) + inst_cls = type(instance) # Transfer if available - if instance_cls in cls._transfers: - res = cls._transfers[instance_cls](instance) + if inst_cls in cls._transfers: + res = cls._transfers[inst_cls](instance) else: res = instance # Assert the instance is checkpoint-able for prop in ("state_dict", "load_state_dict"): if not callable(getattr(res, prop, None)): raise tools.UserException( - f"Given instance {instance!r} is not checkpoint-able (missing callable member {prop!r})" + f"Given instance {instance!r} is not checkpoint-able " + f"(missing callable member {prop!r})" ) # Return the instance and the associated storage key - return res, tools.fullqual(instance_cls) + return res, tools.fullqual(inst_cls) def __init__(self): - """Empty checkpoint constructor.""" - # Finalization - self._store = dict() + """ + Create an empty checkpoint. + """ + self._store = {} if __debug__: - self._copied = dict() # Booleans for tracking possible bugs, 'key in _store' <=> 'key in _copied' + self._copied = {} def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): - """Take/overwrite the snapshot for a given instance. - Args: - instance Instance to snapshot - overwrite Overwrite any existing snapshot for the same class - deepcopy Deep copy instance's state dictionary instead of referencing - nowarnref To always avoid a warning in debug mode if restoring a state dictionary reference is the wanted behavior - Returns: - self + """ + Take (or overwrite) a snapshot of an instance's state dictionary. + + Parameters + ---------- + instance : object + Instance to snapshot. Must support ``state_dict()``. + overwrite : bool, optional + Whether to overwrite an existing snapshot for the same class. + deepcopy : bool, optional + Whether to deep-copy the state dictionary instead of + shallow-copying. + nowarnref : bool, optional + Suppress the debug warning when restoring a reference is the + intended behavior. + + Returns + ------- + Checkpoint + Self, for chaining. + + Raises + ------ + tools.UserException + If a snapshot already exists and ``overwrite`` is ``False``. """ instance, key = type(self)._prepare(instance) # Snapshot the state dictionary if not overwrite and key in self._store: - raise tools.UserException(f"A snapshot for {key!r} is already stored in the checkpoint") + raise tools.UserException( + f"A snapshot for {key!r} is already stored in the checkpoint" + ) if deepcopy: self._store[key] = copy.deepcopy(instance.state_dict()) else: self._store[key] = instance.state_dict().copy() - # Track whether a deepcopy was made (or whether restoring a reference is the expected behavior) + # Track whether a deepcopy was made if __debug__: self._copied[key] = deepcopy or nowarnref # Enable chaining return self def restore(self, instance, nothrow=False): - """Restore the snapshot for a given instance, warn if restoring a reference. - Args: - instance Instance to restore - nothrow Do not raise exception if no snapshot available for the instance - Returns: - self + """ + Restore an instance from its stored snapshot. + + Parameters + ---------- + instance : object + Instance to restore. Must support ``load_state_dict()``. + nothrow : bool, optional + If ``True``, silently skip when no snapshot is available. + + Returns + ------- + Checkpoint + Self, for chaining. + + Raises + ------ + tools.UserException + If no snapshot exists and ``nothrow`` is ``False``. """ instance, key = type(self)._prepare(instance) # Restore the state dictionary if key in self._store: instance.load_state_dict(self._store[key]) # Check if restoring a reference - if __debug__ and not self._copied[key]: + if __debug__ and not self._copied.get(key, True): tools.warning( - f"Restoring a state dictionary reference in an instance of {tools.fullqual(type(instance))}; the resulting behavior may not be the one expected" + f"Restoring a state dictionary reference in an instance of " + f"{tools.fullqual(type(instance))}; the resulting behavior " + f"may not be the one expected" ) elif not nothrow: - raise tools.UserException(f"No snapshot for {key!r} is available in the checkpoint") + raise tools.UserException( + f"No snapshot for {key!r} is available in the checkpoint" + ) # Enable chaining return self def load(self, filepath, overwrite=False): - """Load/overwrite the storage from the given file. - Args: - filepath Given file path - overwrite Allow to overwrite any stored snapshot - Returns: - self + """ + Load checkpoint data from a file. + + Parameters + ---------- + filepath : str or pathlib.Path + Path to the saved checkpoint. + overwrite : bool, optional + Whether to overwrite any existing snapshots. + + Returns + ------- + Checkpoint + Self, for chaining. + + Raises + ------ + tools.UserException + If the checkpoint is non-empty and ``overwrite`` is ``False``. """ # Check if empty if not overwrite and len(self._store) > 0: @@ -133,17 +234,31 @@ def load(self, filepath, overwrite=False): return self def save(self, filepath, overwrite=False): - """Save the current checkpoint in the given file. - Args: - filepath Given file path - overwrite Allow to overwrite if the file already exists - Returns: - self + """ + Save the current checkpoint to a file. + + Parameters + ---------- + filepath : str or pathlib.Path + Destination path. + overwrite : bool, optional + Whether to overwrite an existing file. + + Returns + ------- + Checkpoint + Self, for chaining. + + Raises + ------ + tools.UserException + If the file exists and ``overwrite`` is ``False``. """ # Check if file already exists if pathlib.Path(filepath).exists() and not overwrite: raise tools.UserException( - f"Unable to save checkpoint in existing file {str(filepath)!r} (overwriting has not been allowed by the caller)" + f"Unable to save checkpoint in existing file {str(filepath)!r} " + f"(overwriting has not been allowed by the caller)" ) # (Over)write the file torch.save(self._store, filepath) @@ -156,18 +271,31 @@ def save(self, filepath, overwrite=False): class Storage(dict): - """Dictionary that implements "state_dict protocol" class.""" + """ + Plain dictionary that implements the ``state_dict`` protocol. + + This allows arbitrary key/value data to be snapshotted and restored + alongside models and optimizers using :class:`Checkpoint`. + """ def state_dict(self): - """Access the state dictionary. - Returns: - self + """ + Return the dictionary itself as state. + + Returns + ------- + dict + Self. """ return self def load_state_dict(self, state): - """Update the state dictionary. - Args: - state State to update the current storage with + """ + Replace contents with the given state. + + Parameters + ---------- + state : dict + New dictionary contents. """ self.update(state) diff --git a/krum/experiments/configuration.py b/krum/experiments/configuration.py index e765189..fac8483 100644 --- a/krum/experiments/configuration.py +++ b/krum/experiments/configuration.py @@ -12,31 +12,83 @@ # Configuration wrapper. ### +""" +Tensor configuration wrapper. + +This module provides the :class:`Configuration` class, an immutable mapping +that bundles ``device``, ``dtype``, and memory-transfer options. It is used +throughout ``experiments`` to ensure every created or moved tensor uses the +same configuration. + +Example +------- + +.. code-block:: python + + from experiments.configuration import Configuration + + config = Configuration(device="cuda:0", dtype=torch.float32) + config["device"] # device(type='cuda', index=0) +""" + __all__ = ["Configuration"] from collections.abc import Mapping +import tools import torch -from krum import tools - # ---------------------------------------------------------------------------- # # Trivial tensor configuration holder (dtype, device, ...) class class Configuration(Mapping): - """Immutable tensor configuration holder class.""" + """ + Immutable tensor configuration holder. + + This class bundles ``device``, ``dtype``, and memory-transfer options + into a single immutable mapping. It is used throughout ``experiments`` + to ensure every created or moved tensor uses the same configuration. + + Parameters + ---------- + device : str, torch.device, or None, optional + Target device. ``None`` defaults to ``"cuda"`` when available, + otherwise ``"cpu"``. Strings such as ``"cuda:0"`` are resolved + automatically. + dtype : torch.dtype or None, optional + Tensor datatype. ``None`` uses PyTorch's current default dtype. + noblock : bool, optional + Whether to use non-blocking host-to-device transfers. + relink : bool, optional + Whether to relink instead of copying during parameter assignments. + + Example + ------- + + >>> from experiments import Configuration + >>> config = Configuration(device="cpu", dtype=torch.float32) + >>> config["device"] + device(type='cpu') + """ # Default selected device (GPU if available, else CPU) default_device = "cuda" if torch.cuda.is_available() else "cpu" def __init__(self, device=None, dtype=None, noblock=False, relink=False): - """Immutable initialization constructor. - Args: - device Device (either instance, formatted name or None) to use - dtype Datatype to use, None for PyTorch default - noblock To try and avoid using blocking memory transfer operations from the host - relink Relink instead of copying by default in some assignment operations + """ + Initialize the configuration. + + Parameters + ---------- + device : str, torch.device, or None, optional + Target device. ``None`` selects the default device. + dtype : torch.dtype or None, optional + Tensor datatype. + noblock : bool, optional + Use non-blocking transfers. + relink : bool, optional + Relink instead of copy in assignments. """ # Convert formatted device name to device instance if device is None: @@ -47,7 +99,8 @@ def __init__(self, device=None, dtype=None, noblock=False, relink=False): if not torch.cuda.is_available() and device[:4] == "cuda": device = "cpu" tools.warning( - "CUDA is unavailable on this node, falling back to CPU in the configuration", context="experiments" + "CUDA is unavailable on this node, falling back to CPU in the configuration", + context="experiments", ) # Convert device = torch.device(device) @@ -59,42 +112,67 @@ def __init__(self, device=None, dtype=None, noblock=False, relink=False): self.relink = relink def __len__(self): - """Return the number of contained configuration entries. - Returns: - Number of configuration entries + """ + Return the number of configuration entries. + + Returns + ------- + int + Number of entries in the configuration mapping. """ return len(self._args) def __getitem__(self, name): - """Get a configuration value from its name. - Args: - name Configuration name - Returns: - Associated configuration value + """ + Get a configuration value by name. + + Parameters + ---------- + name : str + Configuration key (e.g. ``"device"``, ``"dtype"``). + + Returns + ------- + object + Associated configuration value. """ return self._args[name] def __iter__(self): - """Build an iterator over all the configuration entries. - Return: - Built iterator + """ + Iterate over all configuration keys. + + Returns + ------- + iterator + Iterator over configuration entry names. """ return self._args.__iter__() def __str__(self): - """Compute the "informal", nicely printable string representation of this configuration. - Returns: - Nicely printable string + """ + Return a nicely printable representation. + + Returns + ------- + str + Human-readable configuration summary. """ temp = self._args.copy() temp["relink"] = self.relink return str(temp) def __repr__(self): - """Compute the "official", Python-code string representation of this configuration. - Returns: - Python-code string evaluating (under conditions) to this configuration + """ + Return an evaluable string representation. + + Returns + ------- + str + Python-code string that evaluates to this configuration. """ display = {"non_blocking": "noblock"} - argrepr = (", ").join(f"{display.get(key, key)}={val!r}" for key, val in self._args.items()) + argrepr = (", ").join( + f"{display.get(key, key)}={val!r}" for key, val in self._args.items() + ) return f"Configuration({argrepr}, relink={self.relink})" diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index 1eab835..632a359 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -12,34 +12,58 @@ # Dataset wrappers/helpers. ### -__all__ = ["Dataset", "batch_dataset", "get_default_transform", "make_datasets", "make_sampler"] +""" +Dataset loading, batching, and sampling utilities. + +This module wraps ``torchvision.datasets`` and custom dataset modules into +uniform infinite-batch generators. It also provides helpers for train/test +splitting and raw-tensor batching. + +Example +------- + +>>> from experiments import Dataset, make_datasets +>>> trainset, testset = make_datasets("cifar10", train_batch=128) +>>> inputs, targets = trainset.sample(config) +""" + +__all__ = [ + "Dataset", + "batch_dataset", + "get_default_transform", + "make_datasets", + "make_sampler", +] import pathlib import random import tempfile import types +import tools import torch import torchvision -from krum import tools - # ---------------------------------------------------------------------------- # # Default image transformations -# Collection of default transforms, -> (, ) -transforms_horizontalflip = [torchvision.transforms.RandomHorizontalFlip(), torchvision.transforms.ToTensor()] +transforms_horizontalflip = [ + torchvision.transforms.RandomHorizontalFlip(), + torchvision.transforms.ToTensor(), +] transforms_mnist = [ torchvision.transforms.ToTensor(), torchvision.transforms.Normalize((0.1307,), (0.3081,)), -] # Transforms from "A Little is Enough" (https://github.com/moranant/attacking_distributed_learning) +] transforms_cifar = [ torchvision.transforms.RandomHorizontalFlip(), torchvision.transforms.ToTensor(), - torchvision.transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), -] # Transforms from https://github.com/kuangliu/pytorch-cifar + torchvision.transforms.Normalize( + (0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010) + ), +] -# Per-dataset image transformations (automatically completed, see 'Dataset._get_datasets') +# Per-dataset image transformations transforms = { "mnist": (transforms_mnist, transforms_mnist), "fashionmnist": (transforms_horizontalflip, transforms_horizontalflip), @@ -50,38 +74,77 @@ def get_default_transform(dataset, train): - """Get the default transform associated with the given dataset name. - Args: - dataset Case-sensitive dataset name, or None to get no transformation - train Whether the transformation is for the training set (always ignored if None is given for 'dataset') - Returns: - Associated default transformations (always exist) + """ + Return the default transform for a torchvision dataset. + + Parameters + ---------- + dataset : str or None + Case-sensitive dataset name. ``None`` returns ``None``. + train : bool + Whether to return the training transform. Ignored when + ``dataset`` is ``None``. + + Returns + ------- + torchvision.transforms.Compose or None + Composed transform, or ``None`` if the dataset is unknown. """ global transforms - # Fetch transformation transform = transforms.get(dataset) - # Not found (not a torchvision dataset) if transform is None: return None - # Return associated transform return torchvision.transforms.Compose(transform[0 if train else 1]) # ---------------------------------------------------------------------------- # -# Dataset loader-batch producer wrapper class +# Dataset wrapper class class Dataset: - """Dataset wrapper class.""" + """ + Unified dataset wrapper producing infinite batches. + + This class can wrap: + + - A ``torchvision`` dataset loaded by name. + - A custom generator yielding batches forever. + - A single fixed batch repeated forever. + + Parameters + ---------- + data : str, generator, or object + Dataset name, infinite generator, or single batch. + name : str or None, optional + User-defined name for debugging. + root : str or pathlib.Path or None, optional + Cache root directory. ``None`` uses the default. + *args : object + Forwarded to the dataset constructor when ``data`` is a string. + **kwargs : object + Forwarded to the dataset constructor when ``data`` is a string. + + Raises + ------ + tools.UnavailableException + If ``data`` is an unknown dataset name. + TypeError + If constructor arguments are invalid. + """ # Default dataset root directory path __default_root = None @classmethod def get_default_root(cls): - """Lazy-initialize and return the default dataset root directory path. - Returns: - '__default_root' + """ + Lazily initialize and return the default dataset cache directory. + + Returns + ------- + pathlib.Path + Path to the dataset cache. Falls back to the system temp + directory if the default does not exist. """ # Fast-path already loaded if cls.__default_root is not None: @@ -92,11 +155,11 @@ def get_default_root(cls): if not cls.__default_root.exists(): tmpdir = tempfile.gettempdir() tools.warning( - f"Default dataset root {str(cls.__default_root)!r} does not exist, falling back to local temporary directory {tmpdir!r}", + f"Default dataset root {str(cls.__default_root)!r} does not exist, " + f"falling back to local temporary directory {tmpdir!r}", context="experiments", ) cls.__default_root = pathlib.Path(tmpdir) - # Return the path return cls.__default_root # Map 'lower-case names' -> 'dataset class' available in PyTorch @@ -104,78 +167,91 @@ def get_default_root(cls): @classmethod def _get_datasets(cls): - """Lazy-initialize and return the map '__datasets'. - Returns: - '__datasets' + """ + Lazily build the name-to-builder mapping for datasets. + + This includes all ``torchvision.datasets`` plus custom datasets + discovered under ``experiments/datasets/``. + + Returns + ------- + dict[str, callable] + Lower-case dataset names mapped to builder functions. """ global transforms # Fast-path already loaded if cls.__datasets is not None: return cls.__datasets # Initialize the dictionary - cls.__datasets = dict() - # Populate this dictionary with TorchVision's datasets + cls.__datasets = {} + # Populate with TorchVision's datasets for name in dir(torchvision.datasets): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members + if len(name) == 0 or name[0] == "_": continue constructor = getattr(torchvision.datasets, name) - if isinstance(constructor, type): # Heuristic + if isinstance(constructor, type): def make_builder(constructor, name): - def builder(root, batch_size=None, shuffle=False, num_workers=1, *args, **kwargs): - # Try to build the dataset instance + def builder( + root, + batch_size=None, + shuffle=False, + num_workers=1, + *args, + **kwargs, + ): data = constructor(root, *args, **kwargs) - assert isinstance(data, torch.utils.data.Dataset), ( - f"Internal heuristic failed: {name!r} was not a dataset name" - ) - # Ensure there is at least a tensor transformation for each torchvision dataset + assert isinstance(data, torch.utils.data.Dataset) if name not in transforms: transforms[name] = torchvision.transforms.ToTensor() - # Wrap into a loader batch_size = batch_size or len(data) loader = torch.utils.data.DataLoader( - data, batch_size=batch_size, shuffle=shuffle, num_workers=num_workers + data, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, ) - # Wrap into an infinite batch sampler generator return make_sampler(loader) return builder cls.__datasets[name.lower()] = make_builder(constructor, name) - # Dynamically add the custom datasets from subdirectory 'datasets/' + # Dynamically add custom datasets from subdirectory 'datasets/' def add_custom_datasets(name, module, _): nonlocal cls - # Check if has exports, fallback otherwise exports = getattr(module, "__all__", None) if exports is None: tools.warning( - f"Dataset module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery" + f"Dataset module {name!r} does not provide '__all__'; " + f"falling back to '__dict__' for name discovery" ) - exports = (name for name in dir(module) if len(name) > 0 and name[0] != "_") - # Register the association 'name -> constructor' for all the datasets + exports = (n for n in dir(module) if len(n) > 0 and n[0] != "_") exported = False for dataset in exports: - # Check dataset name type if not isinstance(dataset, str): - tools.warning(f"Dataset module {name!r} exports non-string name {dataset!r}; ignored") + tools.warning( + f"Dataset module {name!r} exports non-string name " + f"{dataset!r}; ignored" + ) continue - # Recover instance from name constructor = getattr(module, dataset, None) - # Check instance is callable (it's only an heuristic...) if not callable(constructor): continue - # Register callable with composite name exported = True fullname = f"{name}-{dataset}" if fullname in cls.__datasets: tools.warning( - f"Unable to make available dataset {dataset!r} from module {name!r}, as the name {fullname!r} already exists" + f"Unable to make available dataset {dataset!r} from module " + f"{name!r}, as the name {fullname!r} already exists" ) continue cls.__datasets[fullname] = constructor if not exported: - tools.warning(f"Dataset module {name!r} does not export any valid constructor name through '__all__'") + tools.warning( + f"Dataset module {name!r} does not export any valid " + f"constructor name through '__all__'" + ) with tools.Context("datasets", None): tools.import_directory( @@ -183,21 +259,27 @@ def add_custom_datasets(name, module, _): {"__package__": f"{__package__}.datasets"}, post=add_custom_datasets, ) - # Return the dictionary return cls.__datasets def __init__(self, data, name=None, root=None, *args, **kwargs): - """Dataset builder constructor. - Args: - data Dataset string name, (infinite) generator instance (that will be used to generate samples), or any other instance (that will then be fed as the only sample) - name Optional user-defined dataset name, to attach to some error messages for debugging purpose - root Dataset cache root directory to use, None for default (only relevant if 'data' is a dataset name) - ... Forwarded (keyword-)arguments to the dataset constructor, ignored if 'data' is not a string - Raises: - 'TypeError' if the some of the given (keyword-)arguments cannot be used to call the dataset or loader constructor or the batch loader + """ + Initialize the dataset wrapper. + + Parameters + ---------- + data : str, generator, or object + Dataset source. + name : str or None, optional + Debug name. + root : str or pathlib.Path or None, optional + Cache directory for named datasets. + *args : object + Forwarded to the dataset constructor. + **kwargs : object + Forwarded to the dataset constructor. """ # Handle different dataset types - if isinstance(data, str): # Load sampler from available datasets + if isinstance(data, str): if name is None: name = data datasets = type(self)._get_datasets() @@ -206,11 +288,11 @@ def __init__(self, data, name=None, root=None, *args, **kwargs): raise tools.UnavailableException(datasets, name, what="dataset name") root = root or type(self).get_default_root() self._iter = build(root=root, *args, **kwargs) - elif isinstance(data, types.GeneratorType): # Forward sampling to custom generator + elif isinstance(data, types.GeneratorType): if name is None: name = "" self._iter = data - else: # Single-batch dataset of any value + else: if name is None: name = "" @@ -223,38 +305,59 @@ def single_batch(): self.name = name def __str__(self): - """Compute the "informal", nicely printable string representation of this dataset. - Returns: - Nicely printable string + """ + Return a printable representation. + + Returns + ------- + str + Human-readable dataset name. """ return f"dataset {self.name}" def sample(self, config=None): - """Sample the next batch from this dataset. - Args: - config Target configuration for the sampled tensors - Returns: - Next batch + """ + Sample the next batch. + + Parameters + ---------- + config : experiments.Configuration or None, optional + Target configuration for tensor placement. + + Returns + ------- + tuple + Next batch, optionally moved to the target device. """ tns = next(self._iter) if config is not None: - tns = type(tns)(tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns) + tns = type(tns)( + tn.to(device=config["device"], non_blocking=config["non_blocking"]) + for tn in tns + ) return tns def epoch(self, config=None): - """Return a full epoch iterable from this dataset. - Args: - config Target configuration for the sampled tensors - Returns: - Full epoch iterable - Notes: - Only work for dataset based on PyTorch's DataLoader """ - # Assert dataset based on DataLoader + Return a finite epoch iterator. + + .. note:: + + Only works for DataLoader-based datasets. + + Parameters + ---------- + config : experiments.Configuration or None, optional + Target configuration for tensor placement. + + Returns + ------- + generator + Finite iterator over one epoch. + """ assert isinstance(self._loader, torch.utils.data.DataLoader), ( - "Full epoch iteration only possible for PyTorch's DataLoader-based datasets" + "Full epoch iteration only possible for PyTorch DataLoader-based datasets" ) - # Return a full epoch iterator epoch = self._loader.__iter__() def generator(): @@ -264,7 +367,11 @@ def generator(): tns = next(epoch) if config is not None: tns = type(tns)( - tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns + tn.to( + device=config["device"], + non_blocking=config["non_blocking"], + ) + for tn in tns ) yield tns except StopIteration: @@ -278,23 +385,28 @@ def generator(): def make_sampler(loader): - """Infinite sampler generator from a dataset loader. - Args: - loader Dataset loader to use - Yields: - Sample, forever (transparently iterating the given loader again and again) + """ + Create an infinite sampler from a DataLoader. + + Parameters + ---------- + loader : torch.utils.data.DataLoader + Finite data loader. + + Yields + ------ + tuple + Batches, transparently restarting the loader when exhausted. """ itr = None while True: for _ in range(2): - # Try sampling the next batch if itr is not None: try: yield next(itr) break except StopIteration: pass - # Ask loader for a new iteration itr = iter(loader) else: raise RuntimeError("Unable to sample a new batch from dataset") @@ -309,31 +421,48 @@ def make_datasets( num_workers=1, **custom_args, ): - """Helper to make new instances of training and testing datasets. - Args: - dataset Case-sensitive dataset name - train_batch Training batch size, None or 0 for maximum possible - test_batch Testing batch size, None or 0 for maximum possible - train_transforms Transformations to apply on the training set, None for default for the given dataset - test_transforms Transformations to apply on the testing set, None for default for the given dataset - num_workers Positive number of workers for each of the training and testing datasets, or tuple for each of them - ... Additional dataset-dependent keyword-arguments - Returns: - Training dataset, testing dataset """ - # Pre-process arguments + Build training and testing dataset wrappers. + + Parameters + ---------- + dataset : str + Case-sensitive dataset name. + train_batch : int or None, optional + Training batch size. ``None`` or ``0`` for full-batch. + test_batch : int or None, optional + Testing batch size. ``None`` or ``0`` for full-batch. + train_transforms : callable or None, optional + Transform for the training set. ``None`` uses the default. + test_transforms : callable or None, optional + Transform for the testing set. ``None`` uses the default. + num_workers : int or tuple[int, int], optional + Number of workers for the training and testing loaders. An ``int`` + applies to both; a tuple specifies ``(train_workers, test_workers)``. + **custom_args : object + Additional keyword arguments forwarded to the dataset constructor. + + Returns + ------- + tuple[Dataset, Dataset] + Training and testing dataset wrappers. + """ train_transforms = train_transforms or get_default_transform(dataset, True) test_transforms = test_transforms or get_default_transform(dataset, False) - num_workers_errmsg = "Expected either a positive int or a tuple of 2 positive ints for parameter 'num_workers'" + num_workers_errmsg = ( + "Expected either a positive int or a tuple of 2 positive ints " + "for parameter 'num_workers'" + ) if isinstance(num_workers, int): assert num_workers > 0, num_workers_errmsg train_workers = test_workers = num_workers else: - assert isinstance(num_workers, tuple) and len(num_workers) == 2, num_workers_errmsg + assert isinstance(num_workers, tuple) and len(num_workers) == 2, ( + num_workers_errmsg + ) train_workers, test_workers = num_workers assert isinstance(train_workers, int) and train_workers > 0, num_workers_errmsg assert isinstance(test_workers, int) and test_workers > 0, num_workers_errmsg - # Make the datasets trainset = Dataset( dataset, train=True, @@ -354,21 +483,31 @@ def make_datasets( transform=test_transforms, **custom_args, ) - # Return the datasets return trainset, testset def batch_dataset(inputs, labels, train=False, batch_size=None, split=0.75): - """Batch a given raw (tensor) dataset into either a training or testing infinite sampler generators. - Args: - inputs Tensor of positive dimension containing input data - labels Tensor of same shape as 'inputs' containing expected output data - train Whether this is for training (basically adds shuffling) - batch_size Training batch size, None (or 0) for maximum batch size - split Fraction of datapoints to use in the train set if < 1, or #samples in the train set if ≥ 1 - Returns: - Training or testing set infinite sampler generator (with uniformly sampled batches), - Test set infinite sampler generator (without random sampling) + """ + Batch a raw tensor dataset into infinite sampler generators. + + Parameters + ---------- + inputs : torch.Tensor + Input data tensor. + labels : torch.Tensor + Label tensor with the same first-dimension size as ``inputs``. + train : bool, optional + Whether to build a training set (adds shuffling) or a test set. + batch_size : int or None, optional + Batch size. ``None`` or ``0`` uses the full split size. + split : float or int, optional + Fraction of samples for training when ``< 1``, or absolute count + when ``>= 1``. + + Returns + ------- + generator + Infinite sampler generator. """ def train_gen(inputs, labels, batch): @@ -399,14 +538,16 @@ def test_gen(inputs, labels, batch): yield inputs[cursor:end], labels[cursor:end] cursor = end % datalen - # Split dataset dataset_len = len(inputs) if dataset_len < 1 or len(labels) != dataset_len: raise RuntimeError( - f"Invalid or different input/output tensor lengths, got {len(inputs)} for inputs, got {len(labels)} for labels" + f"Invalid or different input/output tensor lengths, got " + f"{len(inputs)} for inputs, got {len(labels)} for labels" ) - split_pos = min(max(1, int(dataset_len * split)) if split < 1 else split, dataset_len - 1) - # Make and return generator according to flavor + split_pos = min( + max(1, int(dataset_len * split)) if split < 1 else split, + dataset_len - 1, + ) if train: train_len = split_pos batch_size = min(batch_size or train_len, train_len) diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py index 8975ba0..3566679 100644 --- a/krum/experiments/datasets/svm.py +++ b/krum/experiments/datasets/svm.py @@ -13,57 +13,98 @@ # Website: https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/ ### +"""LIBSVM dataset loaders. + +This module provides builder functions that can be registered automatically by +the :class:`experiments.Dataset` loader because they are listed in ``__all__``. +Each builder downloads the raw LIBSVM file on first use, caches a pre-processed +PyTorch tensor version, and returns an infinite-batch generator. + +Example +------- +>>> from experiments import Dataset +>>> dataset = Dataset("svm-phishing", train=True, download=True) +>>> inputs, labels = dataset.sample() + +See Also +-------- +experiments.batch_dataset : helper used internally to create the infinite + sampler from raw tensors. +""" + __all__ = ["phishing"] +import experiments import requests +import tools import torch -from krum import experiments, tools - # ---------------------------------------------------------------------------- # # Configuration -# Default raw dataset URLs -default_url_phishing = "https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/phishing" +#: Default URL for the raw phishing dataset (LIBSVM format). +default_url_phishing = ( + "https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/phishing" +) -# Default cache root directory +#: Default directory where pre-processed datasets are cached. default_root = experiments.dataset.Dataset.get_default_root() # ---------------------------------------------------------------------------- # # Dataset lazy-loaders -# Raw phishing dataset +#: In-memory cache for the phishing dataset after first load. raw_phishing = None def get_phishing(root, url): - """Lazy-load the phishing dataset. - Args: - root Dataset cache root directory - url URL to fetch raw dataset from, if not already in cache (None for no download) - Returns: - Input tensor, - Label tensor + """Lazy-load (and optionally download) the phishing dataset. + + The dataset is downloaded from *url* in LIBSVM text format, parsed into + dense tensors, and cached as ``phishing.pt`` under *root*. Subsequent + calls return the cached tensors directly. + + Parameters + ---------- + root : pathlib.Path or str + Directory used to store the cached ``phishing.pt`` file. + url : str or None + URL to fetch the raw dataset from. If ``None`` and the cache is + missing, a :class:`RuntimeError` is raised. + + Returns + ------- + tuple[torch.Tensor, torch.Tensor] + ``(inputs, labels)`` where *inputs* has shape ``(11055, 68)`` and + *labels* has shape ``(11055, 1)``. + + Raises + ------ + RuntimeError + If the cache is missing and *url* is ``None``, or if the download + or parsing fails. """ global raw_phishing const_filename = "phishing.pt" const_features = 68 const_datatype = torch.float32 - # Fast path: return loaded dataset + + # Fast path: already loaded in memory if raw_phishing is not None: return raw_phishing - # Make dataset path + dataset_file = root / const_filename - # Fast path: pre-processed dataset already locally available + + # Fast path: pre-processed file already exists if dataset_file.exists(): with dataset_file.open("rb") as fd: - # Load, lazy-store and return dataset dataset = torch.load(fd) raw_phishing = dataset return dataset elif url is None: raise RuntimeError("Phishing dataset not in cache and download disabled") - # Download dataset + + # Download raw dataset tools.info("Downloading dataset...", end="", flush=True) try: response = requests.get(url) @@ -72,7 +113,10 @@ def get_phishing(root, url): raise RuntimeError(f"Unable to get dataset (at {url}): {err}") tools.info(" done.") if response.status_code != 200: - raise RuntimeError(f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}") + raise RuntimeError( + f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}" + ) + # Pre-process dataset tools.info("Pre-processing dataset...", end="", flush=True) entries = response.text.strip().split("\n") @@ -90,16 +134,19 @@ def get_phishing(root, url): line[int(offset) - 1] = float(value) except Exception as err: tools.warning(" fail.") - raise RuntimeError(f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}") + raise RuntimeError( + f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}" + ) labels.unsqueeze_(1) tools.info(" done.") - # (Try to) save pre-processed dataset + + # Save pre-processed cache try: with dataset_file.open("wb") as fd: torch.save((inputs, labels), fd) except Exception as err: tools.warning(f"Unable to save pre-processed dataset: {err}") - # Lazy-store and return dataset + dataset = (inputs, labels) raw_phishing = dataset return dataset @@ -110,20 +157,42 @@ def get_phishing(root, url): def phishing(train=True, batch_size=None, root=None, download=False, *args, **kwargs): - """Phishing dataset generator builder. - Args: - train Whether to get the training slice of the dataset - batch_size Batch size (None or 0 for all in one single batch) - root Dataset cache root directory (None for default) - download Whether to allow to download the dataset if not cached locally - ... Ignored supplementary (keyword-)arguments - Returns: - Associated ataset generator + """Phishing dataset builder returning an infinite-batch generator. + + Parameters + ---------- + train : bool, optional + Whether to return the training split. If ``False``, the test split + is returned instead. + batch_size : int or None, optional + Number of samples per batch. ``None`` or ``0`` yields the full split + in a single batch. + root : pathlib.Path or str or None, optional + Cache directory. ``None`` defaults to + :meth:`experiments.dataset.Dataset.get_default_root`. + download : bool, optional + Whether to allow downloading the raw file if the cache is missing. + *args : object + Ignored (kept for API compatibility). + **kwargs : object + Ignored (kept for API compatibility). + + Returns + ------- + generator + Infinite sampler yielding ``(inputs, labels)`` tuples. + + Notes + ----- + The dataset is split at position ``8400`` (≈ 76 % train / 24 % test). + The split point was chosen for good divisibility + (:math:`8400 = 2^4 \times 3 \times 5^2 \times 7`). """ with tools.Context("phishing", None): - # Get the raw dataset - inputs, labels = get_phishing(root or default_root, None if download is None else default_url_phishing) - # Make and return the associated generator + inputs, labels = get_phishing( + root or default_root, + None if download is None else default_url_phishing, + ) return experiments.batch_dataset( inputs, labels, train, batch_size, split=8400 - ) # 8400 = 2⁴ × 3 × 5² × 7 (should help with divisibility) + ) diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py index c9fca57..52da420 100644 --- a/krum/experiments/loss.py +++ b/krum/experiments/loss.py @@ -12,56 +12,123 @@ # Loss/criterion wrappers/helpers. ### +""" +Loss and criterion wrappers for training and evaluation. + +This module provides: + +- :class:`Loss` — derivable loss functions (includes L1/L2 regularization + and all PyTorch losses) with arithmetic composition support. +- :class:`Criterion` — non-derivable evaluation metrics (top-k accuracy, + sigmoid accuracy). + +Example +------- + +>>> from experiments import Loss, Criterion +>>> loss = Loss("crossentropy") + 0.01 * Loss("l2") +>>> crit = Criterion("top-k", k=5) +""" + __all__ = ["Criterion", "Loss"] +import tools import torch -from krum import tools - # ---------------------------------------------------------------------------- # -# Loss (derivable)/criterion (non-derivable) wrapper classes +# Loss wrapper class class Loss: - """Loss (must be derivable) wrapper class.""" + """ + Derivable loss function wrapper with composition support. + + Losses can be added (``loss1 + loss2``) and scaled (``0.5 * loss``). + All standard PyTorch losses are available by lower-case name. + Additionally, ``"l1"`` and ``"l2"`` provide parameter-norm + regularization. + + Parameters + ---------- + name_build : str or callable + Loss name (e.g. ``"crossentropy"``, ``"mse"``) or a callable + with signature ``(output, target, params) -> tensor``. + *args : object + Forwarded to the loss constructor when ``name_build`` is a string. + **kwargs : object + Forwarded to the loss constructor when ``name_build`` is a string. + + Raises + ------ + tools.UnavailableException + If ``name_build`` is an unknown string. + """ __reserved_init = object() @staticmethod def _l1loss(output, target, params): - """l1 loss implementation - Args: - ... Ignored arguments - params Flat parameter tensor - Returns: - l1 loss + """ + L1 regularization on parameters. + + Parameters + ---------- + output : torch.Tensor + Ignored. + target : torch.Tensor + Ignored. + params : torch.Tensor + Flat parameter tensor. + + Returns + ------- + torch.Tensor + L1 norm of ``params``. """ return params.norm(p=1) @staticmethod def _l2loss(output, target, params): - """l2 loss implementation - Args: - ... Ignored arguments - params Flat parameter tensor - Returns: - l2 loss + """ + L2 regularization on parameters. + + Parameters + ---------- + output : torch.Tensor + Ignored. + target : torch.Tensor + Ignored. + params : torch.Tensor + Flat parameter tensor. + + Returns + ------- + torch.Tensor + L2 norm of ``params``. """ return params.norm() @classmethod def _l1loss_builder(cls): - """l1 loss builder. - Returns: - L1 loss instance + """ + Build an L1 regularization loss instance. + + Returns + ------- + Loss + L1 loss wrapper. """ return cls(cls.__reserved_init, cls._l1loss, None, "l1") @classmethod def _l2loss_builder(cls): - """l2 loss builder. - Returns: - L2 loss instance + """ + Build an L2 regularization loss instance. + + Returns + ------- + Loss + L2 loss wrapper. """ return cls(cls.__reserved_init, cls._l2loss, None, "l2") @@ -70,11 +137,18 @@ def _l2loss_builder(cls): @staticmethod def _make_drop_params(builder): - """Make a builder that will wrap the built function so to drop the 'params' parameter. - Args: - builder Builder function to wrap - Returns: - Wrapped builder function + """ + Wrap a PyTorch loss builder to drop the ``params`` argument. + + Parameters + ---------- + builder : callable + Original loss constructor. + + Returns + ------- + callable + Wrapped builder returning a loss that ignores ``params``. """ def drop_builder(*args, **kwargs): @@ -89,33 +163,43 @@ def drop_loss(output, target, params): @classmethod def _get_losses(cls): - """Lazy-initialize and return the map '__losses'. - Returns: - '__losses' + """ + Lazily build the name-to-constructor mapping for losses. + + Returns + ------- + dict[str, callable] + Lower-case loss names mapped to builders. """ # Fast-path already loaded if cls.__losses is not None: return cls.__losses # Initialize the dictionary - cls.__losses = dict() - # Simply populate this dictionary + cls.__losses = {} + # Populate with PyTorch losses for name in dir(torch.nn.modules.loss): - if len(name) < 5 or name[0] == "_" or name[-4:] != "Loss": # Heuristically ignore non-loss members + if len(name) < 5 or name[0] == "_" or name[-4:] != "Loss": continue builder = getattr(torch.nn.modules.loss, name) - if isinstance(builder, type): # Still an heuristic + if isinstance(builder, type): cls.__losses[name[:-4].lower()] = cls._make_drop_params(builder) # Add/replace the l1 and l2 losses cls.__losses["l1"] = cls._l1loss_builder cls.__losses["l2"] = cls._l2loss_builder - # Return the dictionary return cls.__losses def __init__(self, name_build, *args, **kwargs): - """Loss constructor. - Args: - name_build Loss name or constructor function - ... Additional (keyword-)arguments forwarded to the constructor + """ + Initialize the loss wrapper. + + Parameters + ---------- + name_build : str or callable + Loss name or constructor function. + *args : object + Forwarded to the constructor. + **kwargs : object + Forwarded to the constructor. """ # Reserved custom initialization if name_build is type(self).__reserved_init: @@ -141,27 +225,44 @@ def __init__(self, name_build, *args, **kwargs): self._name = name def _str_make(self): - """Make the formatted part of the nicely printable string representation of this loss. - Returns: - Formatted part """ - return self._name if self._fact is None else f"{self._fact} × {self._name}" + Build the formatted loss string. + + Returns + ------- + str + Human-readable loss description. + """ + return self._name if self._fact is None else f"{self._fact} x {self._name}" def __str__(self): - """Compute the "informal", nicely printable string representation of this loss. - Returns: - Nicely printable string + """ + Return a printable representation. + + Returns + ------- + str + Human-readable loss name. """ return f"loss {self._str_make()}" def __call__(self, output, target, params): - """Compute the loss from the output and the target. - Args: - output Output tensor from the model - target Expected tensor - params Parameter vector - Returns: - Computed loss tensor + """ + Compute the loss. + + Parameters + ---------- + output : torch.Tensor + Model output. + target : torch.Tensor + Expected output. + params : torch.Tensor + Flat parameter tensor (for regularization losses). + + Returns + ------- + torch.Tensor + Scalar loss value. """ res = self._loss(output, target, params) if self._fact is not None: @@ -169,90 +270,173 @@ def __call__(self, output, target, params): return res def __add__(self, loss): - """Add the current loss to the given loss. - Args: - loss Given loss - Returns: - Sum of the two losses + """ + Sum two losses. + + Parameters + ---------- + loss : Loss + Loss to add. + + Returns + ------- + Loss + Composite loss. """ def add(output, target, params): return self(output, target, params) + loss(output, target, params) - return type(self)(type(self).__reserved_init, add, None, f"({self._str_make()} + {loss._str_make()})") + return type(self)( + type(self).__reserved_init, + add, + None, + f"({self._str_make()} + {loss._str_make()})", + ) def __mul__(self, factor): - """Multiply the current loss by a given factor. - Args: - factor Given factor - Returns: - New loss, factor of the current loss + """ + Scale the loss by a constant factor. + + Parameters + ---------- + factor : float + Scaling factor. + + Returns + ------- + Loss + Scaled loss. """ def mul(output, target, params): return self(output, target, params) * factor return type(self)( - type(self).__reserved_init, mul, factor * (1.0 if self._fact is None else self._fact), self._name + type(self).__reserved_init, + mul, + factor * (1.0 if self._fact is None else self._fact), + self._name, ) def __rmul__(self, *args, **kwargs): - """Forward the call to '__mul__'. - Args: - ... Forwarded (keyword-)arguments - Returns: - Forwarded return value - """ + """Forward to ``__mul__``.""" return self.__mul__(*args, **kwargs) def __imul__(self, factor): - """In-place multiply the current loss by a given factor. - Args: - factor Given factor - Returns: - Current loss + """ + Scale the loss in place. + + Parameters + ---------- + factor : float + Scaling factor. + + Returns + ------- + Loss + Self. """ self._fact = factor * (1.0 if self._fact is None else self._fact) return self +# ---------------------------------------------------------------------------- # +# Criterion wrapper class + + class Criterion: - """Criterion (1D tensor [#correct classification, batch size]) wrapper class.""" + """ + Non-derivable evaluation metric wrapper. + + Available criteria: + + - ``"top-k"`` — top-k classification accuracy. + - ``"sigmoid"`` — binary accuracy with sigmoid threshold at 0.5. + + All criteria return a 1-D tensor ``[num_correct, batch_size]``. + + Parameters + ---------- + name_build : str or callable + Criterion name or constructor function. + *args : object + Forwarded to the criterion constructor. + **kwargs : object + Forwarded to the criterion constructor. + + Raises + ------ + tools.UnavailableException + If ``name_build`` is an unknown string. + """ class _TopkCriterion: - """Top-k criterion helper class.""" + """ + Top-k classification accuracy. + """ def __init__(self, k=1): - """Value of 'k' constructor. - Args: - k Value of 'k' to use """ - # Finalization + Initialize top-k criterion. + + Parameters + ---------- + k : int, optional + Number of top predictions to consider. Defaults to 1. + """ self.k = k def __call__(self, output, target): - """Compute the criterion from the output and the target. - Args: - output Batch × model logits - target Batch × target index - Returns: - 1D-tensor [#correct classification, batch size] """ - res = (output.topk(self.k, dim=1)[1] == target.view(-1).unsqueeze(1)).any(dim=1).sum() + Compute top-k accuracy. + + Parameters + ---------- + output : torch.Tensor + Batch x model logits. + target : torch.Tensor + Batch x target index. + + Returns + ------- + torch.Tensor + 1-D tensor ``[num_correct, batch_size]``. + """ + res = ( + (output.topk(self.k, dim=1)[1] == target.view(-1).unsqueeze(1)) + .any(dim=1) + .sum() + ) return torch.cat( - (res.unsqueeze(0), torch.tensor(target.shape[0], dtype=res.dtype, device=res.device).unsqueeze(0)) + ( + res.unsqueeze(0), + torch.tensor( + target.shape[0], dtype=res.dtype, device=res.device + ).unsqueeze(0), + ) ) class _SigmoidCriterion: - """Sigmoid criterion helper class.""" + """ + Binary accuracy with 0.5 threshold. + """ def __call__(self, output, target): - """Compute the criterion from the output and the target. - Args: - output Batch × model logits (expected in [0, 1]) - target Batch × target index (expected in {0, 1}) - Returns: - 1D-tensor [#correct classification, batch size] + """ + Compute sigmoid accuracy. + + Parameters + ---------- + output : torch.Tensor + Batch x model logits (expected in ``[0, 1]``). + target : torch.Tensor + Batch x target index (expected in ``{0, 1}``). + + Returns + ------- + torch.Tensor + 1-D tensor ``[num_correct, batch_size]``. """ correct = target.sub(output).abs_() < 0.5 res = torch.empty(2, dtype=output.dtype, device=output.device) @@ -260,28 +444,41 @@ def __call__(self, output, target): res[1] = len(correct) return res - # Map 'lower-case names' -> 'loss constructor' available in PyTorch + # Map 'lower-case names' -> 'criterion constructor' __criterions = None @classmethod def _get_criterions(cls): - """Lazy-initialize and return the map '__criterions'. - Returns: - '__criterions' + """ + Lazily build the name-to-constructor mapping. + + Returns + ------- + dict[str, type] + Lower-case criterion names mapped to classes. """ # Fast-path already loaded if cls.__criterions is not None: return cls.__criterions - # Initialize the dictionary - cls.__criterions = {"top-k": cls._TopkCriterion, "sigmoid": cls._SigmoidCriterion} - # Return the dictionary + # Initialize + cls.__criterions = { + "top-k": cls._TopkCriterion, + "sigmoid": cls._SigmoidCriterion, + } return cls.__criterions def __init__(self, name_build, *args, **kwargs): - """Criterion constructor. - Args: - name_build Criterion name or constructor function - ... Additional (keyword-)arguments forwarded to the constructor + """ + Initialize the criterion wrapper. + + Parameters + ---------- + name_build : str or callable + Criterion name or constructor function. + *args : object + Forwarded to the constructor. + **kwargs : object + Forwarded to the constructor. """ # Recover name/constructor if callable(name_build): @@ -300,18 +497,30 @@ def __init__(self, name_build, *args, **kwargs): self._name = name def __str__(self): - """Compute the "informal", nicely printable string representation of this criterion. - Returns: - Nicely printable string + """ + Return a printable representation. + + Returns + ------- + str + Human-readable criterion name. """ return f"criterion {self._name}" def __call__(self, output, target): - """Compute the criterion from the output and the target. - Args: - output Output tensor from the model - target Expected tensor - Returns: - Computed criterion tensor + """ + Compute the criterion. + + Parameters + ---------- + output : torch.Tensor + Model output. + target : torch.Tensor + Expected output. + + Returns + ------- + torch.Tensor + 1-D tensor ``[num_correct, batch_size]``. """ return self._crit(output, target) diff --git a/krum/experiments/model.py b/krum/experiments/model.py index 9706f48..c8d701e 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -12,16 +12,34 @@ # Model wrappers/helpers. ### +""" +Model wrapper with name resolution, initialization, and gradient handling. + +This module provides :class:`Model`, a unified interface that can instantiate +``torchvision`` models (by name), custom models from ``experiments/models/``, +or arbitrary callables. It also manages parameter flattening, gradient +extraction, and data-parallelism automatically. + +Example +------- + +>>> from experiments import Model, Configuration +>>> config = Configuration(device="cpu") +>>> model = Model("resnet18", config, num_classes=10) +>>> output = model.run(inputs) +>>> gradient, loss = model.backprop(dataset=dataset, loss=loss, outloss=True) +>>> model.update(gradient, optimizer=optimizer) +""" + __all__ = ["Model"] import pathlib import types +import tools import torch import torchvision -from krum import tools - from .configuration import Configuration # ---------------------------------------------------------------------------- # @@ -29,66 +47,109 @@ class Model: - """Model wrapper class.""" - - # Map 'lower-case names' -> 'model constructor' available in PyTorch + """ + Unified model wrapper with parameter and gradient management. + + Models are resolved by lower-case name from ``torchvision.models`` and + from custom modules under ``experiments/models/``. Parameters are + automatically flattened into a contiguous vector accessible via + :meth:`get` and :meth:`set`. + + Data parallelism (``torch.nn.DataParallel``) is enabled automatically + when the model is placed on a non-indexed CUDA device. + + Parameters + ---------- + name_build : str or callable + Model name (e.g. ``"resnet18"``, ``"torchvision-resnet18"``) or a + constructor function. + config : experiments.Configuration, optional + Target device and dtype configuration. + init_multi : str or callable or None, optional + Weight initializer for tensors of dimension ``>= 2``. + init_multi_args : dict or None, optional + Keyword arguments for ``init_multi`` when it is a string. + init_mono : str or callable or None, optional + Weight initializer for tensors of dimension ``== 1``. + init_mono_args : dict or None, optional + Keyword arguments for ``init_mono`` when it is a string. + *args : object + Forwarded to the model constructor. + **kwargs : object + Forwarded to the model constructor. + + Raises + ------ + tools.UnavailableException + If ``name_build`` is an unknown string. + tools.UserException + If the built object is not a ``torch.nn.Module``. + """ + + # Map 'lower-case names' -> 'model constructor' __models = None - # Map 'lower-case names' -> 'tensor initializer' available in PyTorch + # Map 'lower-case names' -> 'tensor initializer' __inits = None @classmethod def _get_models(cls): - """Lazy-initialize and return the map '__models'. - Returns: - '__models' + """ + Lazily build the name-to-constructor mapping for models. + + Returns + ------- + dict[str, callable] + Lower-case model names mapped to constructors. """ # Fast-path already loaded if cls.__models is not None: return cls.__models # Initialize the dictionary - cls.__models = dict() - # Populate this dictionary with TorchVision's models + cls.__models = {} + # Populate with TorchVision's models for name in dir(torchvision.models): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members + if len(name) == 0 or name[0] == "_": continue builder = getattr(torchvision.models, name) - if isinstance(builder, types.FunctionType): # Heuristic + if isinstance(builder, types.FunctionType): cls.__models[f"torchvision-{name.lower()}"] = builder - # Dynamically add the custom models from subdirectory 'models/' + # Dynamically add custom models from subdirectory 'models/' def add_custom_models(name, module, _): nonlocal cls - # Check if has exports, fallback otherwise exports = getattr(module, "__all__", None) if exports is None: tools.warning( - f"Model module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery" + f"Model module {name!r} does not provide '__all__'; " + f"falling back to '__dict__' for name discovery" ) - exports = (name for name in dir(module) if len(name) > 0 and name[0] != "_") - # Register the association 'name -> constructor' for all the models + exports = (n for n in dir(module) if len(n) > 0 and n[0] != "_") exported = False for model in exports: - # Check model name type if not isinstance(model, str): - tools.warning(f"Model module {name!r} exports non-string name {model!r}; ignored") + tools.warning( + f"Model module {name!r} exports non-string name " + f"{model!r}; ignored" + ) continue - # Recover instance from name constructor = getattr(module, model, None) - # Check instance is callable (it's only an heuristic...) if not callable(constructor): continue - # Register callable with composite name exported = True fullname = f"{name}-{model}" if fullname in cls.__models: tools.warning( - f"Unable to make available model {model!r} from module {name!r}, as the name {fullname!r} already exists" + f"Unable to make available model {model!r} from module " + f"{name!r}, as the name {fullname!r} already exists" ) continue cls.__models[fullname] = constructor if not exported: - tools.warning(f"Model module {name!r} does not export any valid constructor name through '__all__'") + tools.warning( + f"Model module {name!r} does not export any valid " + f"constructor name through '__all__'" + ) with tools.Context("models", None): tools.import_directory( @@ -96,30 +157,32 @@ def add_custom_models(name, module, _): {"__package__": f"{__package__}.models"}, post=add_custom_models, ) - # Return the dictionary return cls.__models @classmethod def _get_inits(cls): - """Lazy-initialize and return the map '__inits'. - Returns: - '__inits' + """ + Lazily build the name-to-function mapping for initializers. + + Returns + ------- + dict[str, callable] + Lower-case initializer names mapped to functions. """ # Fast-path already loaded if cls.__inits is not None: return cls.__inits # Initialize the dictionary - cls.__inits = dict() - # Populate this dictionary with PyTorch's initialization functions + cls.__inits = {} + # Populate with PyTorch's initialization functions for name in dir(torch.nn.init): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members + if len(name) == 0 or name[0] == "_": continue - if name[-1] != "_": # Ignore non-inplace members (heuristic) + if name[-1] != "_": continue func = getattr(torch.nn.init, name) - if isinstance(func, types.FunctionType): # Heuristic + if isinstance(func, types.FunctionType): cls.__inits[name[:-1]] = func - # Return the dictionary return cls.__inits def __init__( @@ -133,17 +196,27 @@ def __init__( *args, **kwargs, ): - """Model builder constructor. - Args: - name_build Model name or constructor function - config Configuration to use for the parameter tensors - init_multi Weight initialization algorithm name, or initialization function, for tensors of dimension >= 2 - init_multi_args Additional keyword-arguments for 'init', if 'init' specified as a name - init_mono Weight initialization algorithm name, or initialization function, for tensors of dimension == 1 - init_mono_args Additional keyword-arguments for 'init_mono', if 'init_mono' specified as a name - ... Additional (keyword-)arguments forwarded to the constructor - Notes: - If possible, data parallelism is enabled automatically + """ + Initialize the model wrapper. + + Parameters + ---------- + name_build : str or callable + Model name or constructor. + config : experiments.Configuration, optional + Tensor configuration. + init_multi : str or callable or None, optional + Multi-dimensional initializer. + init_multi_args : dict or None, optional + Arguments for multi-dimensional initializer. + init_mono : str or callable or None, optional + Mono-dimensional initializer. + init_mono_args : dict or None, optional + Arguments for mono-dimensional initializer. + *args : object + Forwarded to the model constructor. + **kwargs : object + Forwarded to the model constructor. """ def make_init(name, args): @@ -151,7 +224,7 @@ def make_init(name, args): func = inits.get(name, None) if func is None: raise tools.UnavailableException(inits, name, what="initializer name") - args = dict() if args is None else args + args = {} if args is None else args def init(params): return func(params, **args) @@ -178,11 +251,13 @@ def init(params): model = build(*args, **kwargs) if not isinstance(model, torch.nn.Module): raise tools.UserException( - f"Expected built model {name!r} to be an instance of 'torch.nn.Module', found {getattr(type(model), '__name__', '')!r} instead" + f"Expected built model {name!r} to be an instance of " + f"'torch.nn.Module', found " + f"{getattr(type(model), '__name__', '')!r} instead" ) # Initialize parameters for param in model.parameters(): - if len(param.shape) > 1: # Multi-dimensional + if len(param.shape) > 1: if init_multi is not None: init_multi(param) elif init_mono is not None: @@ -190,64 +265,97 @@ def init(params): # Move parameters to target device model = model.to(**config) device = config["device"] - if ( - device.type == "cuda" and device.index is None - ): # Model is on GPU and not explicitly restricted to one particular card => enable data parallelism + if device.type == "cuda" and device.index is None: model = torch.nn.DataParallel(model) - params = tools.flatten( - model.parameters() - ) # NOTE: Ordering across runs/nodes seems to be ensured (i.e. only dependent on the model constructor) + params = tools.flatten(model.parameters()) # Finalization self._model = model self._name = name self._config = config self._params = params self._gradient = None - self._defaults = {"trainset": None, "testset": None, "loss": None, "criterion": None, "optimizer": None} + self._defaults = { + "trainset": None, + "testset": None, + "loss": None, + "criterion": None, + "optimizer": None, + } def __str__(self): - """Compute the "informal", nicely printable string representation of this model. - Returns: - Nicely printable string + """ + Return a printable representation. + + Returns + ------- + str + Human-readable model name. """ return f"model {self._name}" @property def config(self): - """Getter for the immutable configuration. - Returns: - Immutable configuration + """ + Return the immutable configuration. + + Returns + ------- + experiments.Configuration + Model configuration. """ return self._config def default(self, name, new=None, erase=False): - """Get and/or set the named default. - Args: - name Name of the default - new Optional new instance, set only if not 'None' or erase is 'True' - erase Force the replacement by 'None' - Returns: - (Old) value of the default - """ - # Check existence + """ + Get and/or set a named default. + + Parameters + ---------- + name : str + Default key (e.g. ``"trainset"``, ``"loss"``, ``"optimizer"``). + new : object or None, optional + New value to set. Ignored unless ``new is not None`` or + ``erase`` is ``True``. + erase : bool, optional + Force the value to ``None``. + + Returns + ------- + object + Current (or old) value of the default. + + Raises + ------ + tools.UnavailableException + If ``name`` is not a known default. + """ if name not in self._defaults: raise tools.UnavailableException(self._defaults, name, what="model default") - # Get current old = self._defaults[name] - # Set if needed if erase or new is not None: self._defaults[name] = new - # Return current/old return old def _resolve_defaults(self, **kwargs): - """Resolve the given keyword-arguments with the associated default value. - Args: - ... Keyword-arguments, each must have a default if set to None - Returns: - In-order given keyword-arguments, with 'None' values replaced with the corresponding default """ - res = list() + Replace ``None`` values with registered defaults. + + Parameters + ---------- + **kwargs : object + Keyword arguments where ``None`` means "use the default". + + Returns + ------- + list[object] + Resolved values in argument order. + + Raises + ------ + RuntimeError + If a required default is missing. + """ + res = [] for name, value in kwargs.items(): if value is None: value = self.default(name) @@ -257,44 +365,55 @@ def _resolve_defaults(self, **kwargs): return res def run(self, data, training=False): - """Run the model at the current parameters for the given input tensor. - Args: - data Input tensor - training Use training mode instead of testing mode - Returns: - Output tensor - Notes: - Gradient computation is not enable nor disabled during the run. - """ - # Set mode + """ + Forward pass through the model. + + Parameters + ---------- + data : torch.Tensor + Input tensor. + training : bool, optional + Whether to use training mode (enables dropout, batch-norm + updates, etc.). Defaults to evaluation mode. + + Returns + ------- + torch.Tensor + Model output. + """ if training: self._model.train() else: self._model.eval() - # Compute return self._model(data) def __call__(self, *args, **kwargs): - """Forward call to 'run'. - Args: - ... Forwarded (keyword-)arguments - Returns: - Forwarded return value - """ + """Forward to :meth:`run`.""" return self.run(*args, **kwargs) def get(self): - """Get a reference to the current parameters. - Returns: - Flat parameter vector (by reference: future calls to 'set' will modify it) + """ + Get a reference to the flat parameter vector. + + Returns + ------- + torch.Tensor + Flat parameter tensor. Future calls to :meth:`set` will modify + it in place. """ return self._params def set(self, params, relink=None): - """Overwrite the parameters with the given ones. - Args: - params Given flat parameter vector - relink Relink instead of copying (depending on the model, might be faster) + """ + Overwrite parameters with the given flat vector. + + Parameters + ---------- + params : torch.Tensor + New flat parameter vector. + relink : bool or None, optional + Whether to relink instead of copying. ``None`` uses the + configuration default. """ # Fast path 'set(get())'-like if params is self._params: @@ -307,9 +426,14 @@ def set(self, params, relink=None): self._params.copy_(params, non_blocking=self._config["non_blocking"]) def get_gradient(self): - """Get (optionally make each parameter's gradient) a reference to the flat gradient. - Returns: - Flat gradient (by reference: future calls to 'set_gradient' will modify it) + """ + Get (or create) the flat gradient vector. + + Returns + ------- + torch.Tensor + Flat gradient tensor. Future calls to :meth:`set_gradient` will + modify it in place. """ # Fast path if self._gradient is not None: @@ -320,10 +444,16 @@ def get_gradient(self): return gradient def set_gradient(self, gradient, relink=None): - """Overwrite the gradient with the given one. - Args: - gradient Given flat gradient - relink Relink instead of copying (depending on the model, might be faster) + """ + Overwrite the gradient with the given flat vector. + + Parameters + ---------- + gradient : torch.Tensor + New flat gradient. + relink : bool or None, optional + Whether to relink instead of copying. ``None`` uses the + configuration default. """ # Fast path 'set(get())'-like if gradient is self._gradient: @@ -333,87 +463,113 @@ def set_gradient(self, gradient, relink=None): tools.relink(tools.grads_of(self._model.parameters()), gradient) self._gradient = gradient else: - self.get_gradient().copy_(gradient, non_blocking=self._config["non_blocking"]) + self.get_gradient().copy_( + gradient, non_blocking=self._config["non_blocking"] + ) def loss(self, dataset=None, loss=None, training=None): - """Estimate loss at the current parameters, with a batch of the given dataset. - Args: - dataset Training dataset wrapper to use, use the default one if available - loss Loss wrapper to use, use the default one if available - training Whether this run is for training (instead of testing) purposes, None for guessing (based on whether gradients are computed) - Returns: - Loss value - """ - # Recover the defaults, if missing + """ + Estimate loss on a batch from the given dataset. + + Parameters + ---------- + dataset : experiments.Dataset or None, optional + Dataset to sample from. ``None`` uses the default trainset. + loss : experiments.Loss or None, optional + Loss function. ``None`` uses the default loss. + training : bool or None, optional + Whether this is a training run. ``None`` guesses from + ``torch.is_grad_enabled()``. + + Returns + ------- + torch.Tensor + Scalar loss value. + """ dataset, loss = self._resolve_defaults(trainset=dataset, loss=loss) - # Sample the train batch inputs, targets = dataset.sample(self._config) - # Guess whether computation is for training, if necessary if training is None: training = torch.is_grad_enabled() - # Forward pass return loss(self.run(inputs), targets, self._params) @torch.enable_grad() def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): - """Estimate gradient at the current parameters, with a batch of the given dataset. - Args: - dataset Training dataset wrapper to use, use the default one if available - loss Loss wrapper to use, use the default one if available - outloss Output the loss value as well - ... Additional keyword-arguments forwarded to 'backprop' - Returns: - if 'outloss' is True: - Tuple of: - · Flat gradient (by reference: future calls to 'backprop' will modify it) - · Loss value - else: - Flat gradient (by reference: future calls to 'backprop' will modify it) - """ - # Detach and zero the gradient (must be done at each grad to discard computation graph) + """ + Compute gradient on a batch from the given dataset. + + Parameters + ---------- + dataset : experiments.Dataset or None, optional + Dataset to sample from. ``None`` uses the default trainset. + loss : experiments.Loss or None, optional + Loss function. ``None`` uses the default loss. + outloss : bool, optional + Whether to also return the loss value. + **kwargs : object + Forwarded to ``loss.backward()``. + + Returns + ------- + torch.Tensor or tuple[torch.Tensor, torch.Tensor] + Flat gradient, optionally paired with the loss value. + """ + # Detach and zero the gradient for param in self._params.linked_tensors: grad = param.grad if grad is not None: grad.detach_() grad.zero_() # Forward and backward passes - loss = self.loss(dataset=dataset, loss=loss) - loss.backward(**kwargs) + loss_val = self.loss(dataset=dataset, loss=loss) + loss_val.backward(**kwargs) # Relink needed if graph of derivatives was created - # NOTE: It has been observed that each parameters' grad tensor is a new instance in this case; more investigation may be needed to check whether this relink is really necessary, for now this is a safe behavior if "create_graph" in kwargs: self._gradient = None # Return the flat gradient (and the loss if requested) if outloss: - return (self.get_gradient(), loss) + return (self.get_gradient(), loss_val) return self.get_gradient() def update(self, gradient, optimizer=None, relink=None): - """Update the parameters using the given gradient, and the given optimizer. - Args: - gradient Flat gradient to apply - optimizer Optimizer wrapper to use, use the default one if available - relink Relink instead of copying (depending on the model, might be faster) """ - # Recover the defaults, if missing + Update parameters using the given gradient and optimizer. + + Parameters + ---------- + gradient : torch.Tensor + Flat gradient to apply. + optimizer : experiments.Optimizer or None, optional + Optimizer wrapper. ``None`` uses the default optimizer. + relink : bool or None, optional + Whether to relink the gradient. ``None`` uses the configuration + default. + """ optimizer = self._resolve_defaults(optimizer=optimizer)[0] - # Set the gradient - self.set_gradient(gradient, relink=(self._config.relink if relink is None else relink)) - # Perform the update step + self.set_gradient( + gradient, + relink=(self._config.relink if relink is None else relink), + ) optimizer.step() @torch.no_grad() def eval(self, dataset=None, criterion=None): - """Evaluate the model at the current parameters, with a batch of the given dataset. - Args: - dataset Testing dataset wrapper to use, use the default one if available - criterion Criterion wrapper to use, use the default one if available - Returns: - Arithmetic mean of the criterion over the next minibatch - """ - # Recover the defaults, if missing - dataset, criterion = self._resolve_defaults(testset=dataset, criterion=criterion) - # Sample the test batch + """ + Evaluate the model on a batch from the given dataset. + + Parameters + ---------- + dataset : experiments.Dataset or None, optional + Dataset to sample from. ``None`` uses the default testset. + criterion : experiments.Criterion or None, optional + Criterion function. ``None`` uses the default criterion. + + Returns + ------- + torch.Tensor + Mean criterion value over the sampled batch. + """ + dataset, criterion = self._resolve_defaults( + testset=dataset, criterion=criterion + ) inputs, targets = dataset.sample(self._config) - # Compute and return the evaluation result return criterion(self.run(inputs), targets) diff --git a/krum/experiments/models/simples.py b/krum/experiments/models/simples.py index 723d63a..de0164f 100644 --- a/krum/experiments/models/simples.py +++ b/krum/experiments/models/simples.py @@ -12,6 +12,21 @@ # Collection of simple models. ### +"""Simple neural-network models used for small-scale experiments. + +This module exposes four lightweight constructors — :func:`full`, :func:`conv`, +:func:`logit` and :func:`linear` — that can be registered automatically by the +:class:`experiments.Model` loader because they are listed in ``__all__``. +Each constructor returns a ready-to-use ``torch.nn.Module``. + +Example +------- +>>> from experiments import Model, Configuration +>>> config = Configuration(device="cpu") +>>> model = Model("simples-full", config) +>>> output = model.run(torch.randn(4, 1, 28, 28)) +""" + __all__ = ["conv", "full", "linear", "logit"] import torch @@ -21,36 +36,51 @@ class _Full(torch.nn.Module): - """Simple, small fully connected model.""" + """Small fully-connected classifier for MNIST. + + The network flattens a 28x28 input image, passes it through a single + hidden layer of 100 units with ReLU activation, and finally outputs + log-probabilities over 10 classes. + """ def __init__(self): - """Model parameter constructor.""" + """Initialise the two linear layers.""" super().__init__() - # Build parameters self._f1 = torch.nn.Linear(28 * 28, 100) self._f2 = torch.nn.Linear(100, 10) def forward(self, x): - """Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor + """Forward pass. + + Parameters + ---------- + x : torch.Tensor + Input tensor of shape ``(N, 1, 28, 28)`` or ``(N, 28, 28)``. + + Returns + ------- + torch.Tensor + Log-probability distribution of shape ``(N, 10)``. """ - # Forward pass x = torch.nn.functional.relu(self._f1(x.view(-1, 28 * 28))) - x = torch.nn.functional.log_softmax(self._f2(x), dim=1) - return x + return torch.nn.functional.log_softmax(self._f2(x), dim=1) def full(*args, **kwargs): - """Build a new simple, fully connected model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Fully connected model + """Build a small fully-connected model for MNIST. + + Parameters + ---------- + *args : object + Forwarded to :class:`_Full`. + **kwargs : object + Forwarded to :class:`_Full`. + + Returns + ------- + _Full + A fresh fully-connected model instance. """ - global _Full return _Full(*args, **kwargs) @@ -59,122 +89,177 @@ def full(*args, **kwargs): class _Conv(torch.nn.Module): - """Simple, small convolutional model.""" + """Small convolutional classifier for MNIST. + + The architecture consists of two convolutional blocks + (convolution → ReLU → max-pooling) followed by two fully-connected + layers. It expects single-channel 28x28 images and produces + log-probabilities over 10 classes. + """ def __init__(self): - """Model parameter constructor.""" + """Initialise the convolutional and linear layers.""" super().__init__() - # Build parameters self._c1 = torch.nn.Conv2d(1, 20, 5, 1) self._c2 = torch.nn.Conv2d(20, 50, 5, 1) self._f1 = torch.nn.Linear(800, 500) self._f2 = torch.nn.Linear(500, 10) def forward(self, x): - """Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor + """Forward pass. + + Parameters + ---------- + x : torch.Tensor + Input tensor of shape ``(N, 1, 28, 28)``. + + Returns + ------- + torch.Tensor + Log-probability distribution of shape ``(N, 10)``. """ - # Forward pass x = torch.nn.functional.relu(self._c1(x)) x = torch.nn.functional.max_pool2d(x, 2, 2) x = torch.nn.functional.relu(self._c2(x)) x = torch.nn.functional.max_pool2d(x, 2, 2) x = torch.nn.functional.relu(self._f1(x.view(-1, 800))) - x = torch.nn.functional.log_softmax(self._f2(x), dim=1) - return x + return torch.nn.functional.log_softmax(self._f2(x), dim=1) def conv(*args, **kwargs): - """Build a new simple, convolutional model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Convolutional model + """Build a small convolutional model for MNIST. + + Parameters + ---------- + *args : object + Forwarded to :class:`_Conv`. + **kwargs : object + Forwarded to :class:`_Conv`. + + Returns + ------- + _Conv + A fresh convolutional model instance. """ - global _Conv return _Conv(*args, **kwargs) # ---------------------------------------------------------------------------- # -# Simple(r) logistic regression model +# Logistic regression model class _Logit(torch.nn.Module): - """Simple logistic regression model.""" + """Logistic regression model. + + A single linear layer followed by a sigmoid. Useful for binary + classification or as a simple baseline. + """ def __init__(self, din, dout=1): - """Model parameter constructor. - Args: - din Number of input dimensions - dout Number of output dimensions + """Initialise the linear layer. + + Parameters + ---------- + din : int + Number of input features. + dout : int, optional + Number of output features. Defaults to ``1``. """ super().__init__() - # Store model parameters self._din = din self._dout = dout - # Build parameters self._linear = torch.nn.Linear(din, dout) def forward(self, x): - """Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor + """Forward pass. + + Parameters + ---------- + x : torch.Tensor + Input tensor of arbitrary shape; the last dimensions are + flattened to ``din`` features. + + Returns + ------- + torch.Tensor + Sigmoid-activated output of shape ``(..., dout)``. """ return torch.sigmoid(self._linear(x.view(-1, self._din))) def logit(*args, **kwargs): - """Build a new simple, fully connected model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Fully connected model + """Build a logistic-regression model. + + Parameters + ---------- + *args : object + Forwarded to :class:`_Logit`. + **kwargs : object + Forwarded to :class:`_Logit`. + + Returns + ------- + _Logit + A fresh logistic-regression model instance. """ - global _Logit return _Logit(*args, **kwargs) # ---------------------------------------------------------------------------- # -# Simple(st) linear model +# Linear regression model class _Linear(torch.nn.Module): - """Simple linear model.""" + """Simple linear (affine) model without activation. + + Equivalent to a fully-connected layer with identity activation. + """ def __init__(self, din, dout=1): - """Model parameter constructor. - Args: - din Number of input dimensions - dout Number of output dimensions + """Initialise the linear layer. + + Parameters + ---------- + din : int + Number of input features. + dout : int, optional + Number of output features. Defaults to ``1``. """ super().__init__() - # Store model parameters self._din = din self._dout = dout - # Build parameters self._linear = torch.nn.Linear(din, dout) def forward(self, x): - """Model's forward pass. - Args: - x Input tensor - Returns: - Output tensor + """Forward pass. + + Parameters + ---------- + x : torch.Tensor + Input tensor of arbitrary shape; the last dimensions are + flattened to ``din`` features. + + Returns + ------- + torch.Tensor + Linear output of shape ``(..., dout)``. """ return self._linear(x.view(-1, self._din)) def linear(*args, **kwargs): - """Build a new simple, fully connected model. - Args: - ... Forwarded (keyword-)arguments - Returns: - Fully connected model + """Build a simple linear model. + + Parameters + ---------- + *args : object + Forwarded to :class:`_Linear`. + **kwargs : object + Forwarded to :class:`_Linear`. + + Returns + ------- + _Linear + A fresh linear model instance. """ - global _Linear return _Linear(*args, **kwargs) diff --git a/krum/experiments/optimizer.py b/krum/experiments/optimizer.py index 79b5af9..dddc2ee 100644 --- a/krum/experiments/optimizer.py +++ b/krum/experiments/optimizer.py @@ -12,36 +12,77 @@ # Optimizer wrapper. ### +""" +Optimizer wrapper that resolves PyTorch optimizers by name. + +This module provides a thin wrapper around ``torch.optim`` that allows +optimizers to be instantiated from CLI strings while exposing a uniform +interface for learning-rate adjustments. + +Example +------- + +>>> from experiments import Optimizer, Model +>>> model = Model("lenet", num_classes=10) +>>> optim = Optimizer("adam", model, lr=0.001) +>>> optim.set_lr(0.0001) +""" + __all__ = ["Optimizer"] +import tools import torch -from krum import tools - # ---------------------------------------------------------------------------- # # Optimizer wrapper class class Optimizer: - """Optimizer wrapper class.""" + """ + Optimizer wrapper with name resolution and LR control. + + Parameters + ---------- + name_build : str or callable + Optimizer name (e.g. ``"adam"``, ``"sgd"``) or a constructor + function. Names are resolved against ``torch.optim``. + model : experiments.Model + Model whose parameters will be optimized. + *args : object + Additional positional arguments forwarded to the optimizer + constructor. + **kwargs : object + Additional keyword arguments forwarded to the optimizer + constructor. + + Raises + ------ + tools.UnavailableException + If ``name_build`` is a string that does not match any known + optimizer. + """ # Map 'lower-case names' -> 'optimizer constructor' available in PyTorch __optimizers = None @classmethod def _get_optimizers(cls): - """Lazy-initialize and return the map '__optimizers'. - Returns: - '__optimizers' + """ + Lazily build the name-to-class mapping for PyTorch optimizers. + + Returns + ------- + dict[str, type] + Mapping from lower-case names to optimizer classes. """ # Fast-path already loaded if cls.__optimizers is not None: return cls.__optimizers # Initialize the dictionary - cls.__optimizers = dict() - # Simply populate this dictionary + cls.__optimizers = {} + # Populate with TorchVision optimizers for name in dir(torch.optim): - if len(name) == 0 or name[0] == "_": # Ignore "protected" members + if len(name) == 0 or name[0] == "_": continue builder = getattr(torch.optim, name) if ( @@ -50,15 +91,22 @@ def _get_optimizers(cls): and issubclass(builder, torch.optim.Optimizer) ): cls.__optimizers[name.lower()] = builder - # Return the dictionary return cls.__optimizers def __init__(self, name_build, model, *args, **kwargs): - """Optimizer constructor. - Args: - name_build Optimizer name or constructor function - model Model to optimize - ... Additional (keyword-)arguments forwarded to the constructor + """ + Initialize the optimizer wrapper. + + Parameters + ---------- + name_build : str or callable + Optimizer name or constructor function. + model : experiments.Model + Model to optimize. + *args : object + Forwarded to the optimizer constructor. + **kwargs : object + Forwarded to the optimizer constructor. """ # Recover name/constructor if callable(name_build): @@ -77,30 +125,51 @@ def __init__(self, name_build, model, *args, **kwargs): self._name = name def __getattr__(self, *args): - """Get attribute on the optimizer instance. - Args: - name Name of the attribute to get - default Default value returned if the attribute does not exist - Returns: - Forwarded attribute + """ + Forward attribute access to the wrapped optimizer. + + Parameters + ---------- + *args : object + Either ``(name,)`` or ``(name, default)``. + + Returns + ------- + object + Attribute from the wrapped optimizer instance. + + Raises + ------ + RuntimeError + If called with more than two positional arguments. """ if len(args) == 1: return getattr(self._optim, args[0]) if len(args) == 2: return getattr(self._optim, args[0], args[1]) - raise RuntimeError("'Optimizer.__getattr__' called with the wrong number of parameters") + raise RuntimeError( + "'Optimizer.__getattr__' called with the wrong number of parameters" + ) def __str__(self): - """Compute the "informal", nicely printable string representation of this optimizer. - Returns: - Nicely printable string + """ + Return a printable representation. + + Returns + ------- + str + Human-readable optimizer name. """ return f"optimizer {self._name}" def set_lr(self, lr): - """Set the learning rate of the optimizer - Args: - lr Learning rate to set (for each parameter group) + """ + Set the learning rate for all parameter groups. + + Parameters + ---------- + lr : float + New learning rate. """ for pg in self._optim.param_groups: pg["lr"] = lr diff --git a/krum/native/__init__.py b/krum/native/__init__.py index 2df033b..f99d577 100644 --- a/krum/native/__init__.py +++ b/krum/native/__init__.py @@ -16,7 +16,7 @@ # Initialization procedure -def _build_and_load(): +def _build_and_load() -> None: """Incrementally rebuild all libraries and bind all local modules in the global.""" glob = globals() # Standard imports @@ -58,7 +58,7 @@ def _build_and_load(): source_suffixes = {".cpp", ".cc", ".C", ".cxx", ".c++"} extra_cflags = ["-Wall", "-Wextra", "-Wfatal-errors", f"-std={cpp_std}"] if torch.cuda.is_available(): - source_suffixes.update(set((".cu" + suffix) for suffix in source_suffixes)) + source_suffixes.update({(".cu" + suffix) for suffix in source_suffixes}) source_suffixes.add(".cu") extra_cflags.append("-DTORCH_CUDA_AVAILABLE") extra_cuda_cflags = ["-DTORCH_CUDA_AVAILABLE", "--expt-relaxed-constexpr", f"-std={cpp_std}"] @@ -68,7 +68,7 @@ def _build_and_load(): extra_include_paths = [str(extra_include_path.resolve())] except Exception: extra_include_paths = None - warnings.warn("Not found include directory: " + repr(str(extra_include_path))) + warnings.warn("Not found include directory: " + repr(str(extra_include_path)), stacklevel=2) # Print configuration information cpp_std_message = "Native modules compiled with {} standard; (re)define {!r} in the environment to compile with another standard".format( cpp_std, f"{cpp_std_envname}=" @@ -101,7 +101,7 @@ def _build_and_load(): fail_modules = [] # Local procedures - def build_and_load_one(path, deps=[]): + def build_and_load_one(path, deps=None): """Check if the given directory is a module to build and load, and if yes recursively build and load its dependencies before it. Args: path Given directory path @@ -109,6 +109,8 @@ def build_and_load_one(path, deps=[]): Returns: True on success, False on failure, None if not a module """ + if deps is None: + deps = [] nonlocal done_modules nonlocal fail_modules with tools.Context(path.name, "info"): @@ -184,6 +186,7 @@ def build_and_load_one(path, deps=[]): return False done_modules.append(path) # Mark as built and loaded return True + return None # Main loop for path in base_directory.iterdir(): diff --git a/krum/native/py_bulyan/bulyan.cu b/krum/native/py_bulyan/bulyan.cu index 4be456d..0a5fbcc 100644 --- a/krum/native/py_bulyan/bulyan.cu +++ b/krum/native/py_bulyan/bulyan.cu @@ -87,7 +87,7 @@ template __global__ void distances_finalize(T* flat_dist, T* distances, /** Special single-block merge-sort for indirected array of limited length. * @param value Input constant value array * @param rank (Input/)output only "rank" array - * @param length Length of the arrays (at most 2 × #threads/block) + * @param length Length of the arrays (at most 2 x #threads/block) **/ template __global__ void merge_sort_limited(T const* value, size_t* rank, size_t length) { // Shared variable declaration @@ -191,7 +191,7 @@ template __global__ void compute_scores_prune_distances(T const* flat_d * @param scores Input/output score array * @param distances Input constant pruned distance matrix * @param ranks Output only "rank" array - * @param n Length of the arrays (at most 2 × #threads/block) + * @param n Length of the arrays (at most 2 x #threads/block) **/ template __global__ void remove_smallest_scoring_gradient(T* scores, T const* distances, size_t const* ranks, size_t n) { // Compute thread position to maximize spreading over the available warps diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py index 93dde16..b4f30b7 100644 --- a/krum/tools/__init__.py +++ b/krum/tools/__init__.py @@ -12,18 +12,55 @@ # Bunch of useful tools, but each too small to have its own package. ### +""" +Core Utility Module for Krum. + +This module provides the fundamental infrastructure utilities used throughout +Krum, including logging, error handling, and common operations. + +Key Components +-------------- + +**Exceptions:** + +- ``UserException``: Base exception for user-facing errors +- ``Context``: Thread-local context for colored logging + +**Logging:** + +- ``info()``, ``success()``, ``warning()``, ``error()``: Colored logging functions +- ``fatal()``: Print error and exit + +**I/O:** + +- ``ContextIOWrapper``: Wrapper for stdout/stderr with context prefixing + +**Module Loading:** + +- ``import_directory()``: Load all Python modules from a directory +- ``import_exported_symbols()``: Import symbols from a module + +**Utilities:** + +- ``parse_keyval()``: Parse key:value CLI arguments +- ``fullqual()``: Get fully qualified name of objects +- ``onetime()``: Thread-safe one-time flag +""" + import os -import pathlib import sys import threading import traceback +from pathlib import Path # ---------------------------------------------------------------------------- # # User exception base class, print string representation and exit(1) on uncaught class UserException(Exception): - """User exception base class.""" + """ + Base exception for user-facing errors. + """ pass @@ -33,7 +70,9 @@ class UserException(Exception): class Context: - """Per-thread context and color management static class.""" + """ + Per-thread logging context and color manager. + """ # Constants __colors = { @@ -55,86 +94,126 @@ class Context: __local = threading.local() @classmethod - def __local_init(cls): - """Initialize the thread local data if necessary.""" - if not hasattr(cls.__local, "stack"): - cls.__local.stack = [] # List of pairs (context name, color code) - cls.__local.header = "" # Current header string - cls.__local.color = cls.__clrend # Current color code + def __local_init(self): + """ + Initialize thread-local context state if necessary. + """ + if not hasattr(self.__local, "stack"): + self.__local.stack = [] # List of pairs (context name, color code) + self.__local.header = "" # Current header string + self.__local.color = self.__clrend # Current color code @classmethod - def __rebuild(cls): - """Rebuild the header and apply the current color.""" + def __rebuild(self): + """ + Rebuild the current log header and color from the context stack. + """ # Collect current header and color header = "" color = None - for ctx, clr in reversed(cls.__local.stack): + for ctx, clr in reversed(self.__local.stack): if ctx is not None: header = "[" + ctx + "] " + header - if clr is not None: - if color is None: - color = clr + if clr is not None and color is None: + color = clr if color is None: - color = cls.__clrend + color = self.__clrend # Prepend thread name if not main thread cthrd = threading.current_thread() if cthrd != threading.main_thread(): header = "[" + cthrd.name + "] " + header # Store the new header and color - cls.__local.header = header - cls.__local.color = color + self.__local.header = header + self.__local.color = color @classmethod - def _get(cls): - """Get the thread-local header and color. - Returns: - Current header, begin header color, begin color, ending color + def _get(self): + """ + Return the current thread-local header and color escape sequences. + + Returns + ------- + tuple[str, str, str, str] + Current header, header color prefix, message color prefix, and color + reset suffix. + """ + self.__local_init() + return ( + self.__local.header, + self.__colors["header"], + self.__local.color, + self.__clrend, + ) + + def __init__(self, cntxtname: str | None, colorname: str | None) -> None: """ - cls.__local_init() - return cls.__local.header, cls.__colors["header"], cls.__local.color, cls.__clrend - - def __init__(self, cntxtname, colorname): - """Color selection constructor. - Args: - cntxtname Context name (None for none) - colorname Color name (None for no change) + Create a context stack entry. + + Parameters + ---------- + cntxtname : str or None + Context name to prepend to log lines, or ``None`` for no additional + context. + colorname : str or None + Color name to apply while the context is active, or ``None`` to keep the + current color. """ # Color code resolution if colorname is None: colorcode = None else: - assert colorname in type(self).__colors, "Unknown color name " + repr(colorname) + assert colorname in type(self).__colors, "Unknown color name " + repr( + colorname + ) colorcode = type(self).__colors[colorname] # Finalization self.__pair = (cntxtname, colorcode) def __enter__(self): - """Enter context. - Returns: - self + """ + Enter the logging context. + + Returns + ------- + Context + This context manager instance. """ type(self).__local_init() type(self).__local.stack.append(self.__pair) type(self).__rebuild() return self - def __exit__(self, *args, **kwargs): - """Leave context. - Args: - ... Ignored arguments + def __exit__(self, *args, **kwargs) -> None: + """ + Leave the logging context. + + Parameters + ---------- + *args : object + Ignored positional arguments supplied by the context manager protocol. + **kwargs : object + Ignored keyword arguments supplied by the context manager protocol. """ type(self).__local.stack.pop() type(self).__rebuild() class ContextIOWrapper: - """Context-aware text IO wrapper class.""" + """ + Context-aware text I/O wrapper. + """ - def __init__(self, output, nocolor=None): - """New line no color assumed constructor. - Args: - output Wrapped output - nocolor Whether to apply colors or not (if None, no color for non-TTY) + def __init__(self, output: object, nocolor: bool | None = None) -> None: + """ + Wrap a text output stream. + + Parameters + ---------- + output : object + Wrapped stream-like object. + nocolor : bool or None, optional + Whether to disable ANSI colors. If ``None``, colors are disabled for + non-TTY streams. """ # Check whether to apply coloring if unset if nocolor is None: @@ -145,21 +224,35 @@ def __init__(self, output, nocolor=None): self.__output = output self.__nocolor = nocolor - def __getattr__(self, name): - """Forward non-overloaded attributes. - Args: - name Non-overloaded attribute name - Returns: - Non-overloaded attribute + def __getattr__(self, name: str) -> object: + """ + Forward non-overridden attribute access to the wrapped stream. + + Parameters + ---------- + name : str + Attribute name. + + Returns + ------- + object + Attribute value from the wrapped stream. """ return getattr(self.__output, name) - def write(self, text): - """Wrap the given text with the context if necessary. - Args: - text Text to update and write - Returns: - Forwarded value + def write(self, text: str) -> int: + """ + Write text with the active context prefix and color. + + Parameters + ---------- + text : str + Text to write. + + Returns + ------- + int + Return value forwarded from the wrapped stream's ``write`` method. """ # Get the current context header, clrheader, clrbegin, clrend = Context._get() @@ -182,21 +275,38 @@ def write(self, text): return self.__output.write(text + clrend) -def _make_color_print(color): - """Build the closure that wrap a 'print' inside a colored context. - Args: - color Target color name - Returns: - Print wrapper closure +def _make_color_print(color: str) -> object: """ + Build a ``print`` wrapper that runs inside a colored context. - def color_print(*args, context=None, **kwargs): - """Print in 'color'. - Args: - context Context name to use - ... Forwarded arguments - Returns: - Forwarded return value + Parameters + ---------- + color : str + Target color name. + + Returns + ------- + object + Print wrapper closure. + """ + + def color_print(*args, context: str | None = None, **kwargs) -> object: + """ + Print inside the configured colored context. + + Parameters + ---------- + *args : object + Positional arguments forwarded to :func:`print`. + context : str or None, optional + Context name to use while printing. + **kwargs : object + Keyword arguments forwarded to :func:`print`. + + Returns + ------- + object + Return value forwarded from :func:`print`. """ with Context(context, color): return print(*args, **kwargs) @@ -209,11 +319,18 @@ def color_print(*args, context=None, **kwargs): globals()[color] = _make_color_print(color) -def fatal(*args, with_traceback=False, **kwargs): - """Error colored print that calls 'exit(1)' instead of returning. - Args: - with_traceback Include a traceback after the message - ... Forwarded arguments +def fatal(*args, with_traceback: bool = False, **kwargs) -> None: + """ + Print an error message and terminate the process with exit code 1. + + Parameters + ---------- + *args : object + Positional arguments forwarded to :func:`error`. + with_traceback : bool, optional + Whether to include the current traceback after the message. + **kwargs : object + Keyword arguments forwarded to :func:`error`. """ global error error(*args, **kwargs) @@ -231,22 +348,38 @@ def fatal(*args, with_traceback=False, **kwargs): # Uncaught exception context wrapping -def uncaught_wrap(hook): - """Wrap an uncaught hook with a context. - Args: - hook Uncaught hook to wrap - Returns: - Wrapped uncaught hook +def uncaught_wrap(hook: object) -> object: """ + Wrap an uncaught exception hook with contextual logging. + + Parameters + ---------- + hook : object + Uncaught exception hook to wrap. - def uncaught_call(etype, evalue, traceback): - """Update context, check if user exception or forward-call. - Args: - etype Exception class - evalue Exception value - traceback Traceback at the exception - Returns: - Forwarded value + Returns + ------- + object + Wrapped uncaught exception hook. + """ + + def uncaught_call(etype: type, evalue: object, traceback: object) -> object: + """ + Handle uncaught exceptions with user-facing context. + + Parameters + ---------- + etype : type + Exception class. + evalue : object + Exception value. + traceback : object + Traceback associated with the exception. + + Returns + ------- + object + Return value forwarded from the wrapped hook for non-user exceptions. """ if issubclass(etype, UserException): with Context("fatal", "error"): @@ -254,6 +387,7 @@ def uncaught_call(etype, evalue, traceback): else: with Context("uncaught", "error"): return hook(etype, evalue, traceback) + return None return uncaught_call @@ -264,15 +398,21 @@ def uncaught_call(etype, evalue, traceback): # ---------------------------------------------------------------------------- # # Local module loading and post-processing -_imported = dict() # Map symbol name -> module source name +_imported = {} # Map symbol name -> module source name -def import_exported_symbols(name, module, scope): - """Import the exported objects of the loaded module into the given scope. - Args: - name Module name - module Module instance - scope Target scope +def import_exported_symbols(name: str, module, scope: dict) -> None: + """ + Import a module's exported symbols into a target scope. + + Parameters + ---------- + name : str + Source module name. + module : module + Loaded module instance. + scope : dict + Target scope to update with exported symbols. """ global _imported if hasattr(module, "__all__"): @@ -284,26 +424,47 @@ def import_exported_symbols(name, module, scope): continue if symname in _imported: with Context(None, "warning"): - print("Symbol " + repr(symname) + " already exported by " + repr(_imported[symname])) + print( + "Symbol " + + repr(symname) + + " already exported by " + + repr(_imported[symname]) + ) continue if symname in scope: with Context(None, "warning"): - print("Symbol " + repr(symname) + " already exported by '__init__.py'") + print( + "Symbol " + repr(symname) + " already exported by '__init__.py'" + ) continue # Import in module scope scope[symname] = getattr(module, symname) _imported[symname] = name -def import_directory(dirpath, scope, post=import_exported_symbols, ignore=["__init__"]): - """Import every module from the given directory in the given scope. - Args: - dirpath Directory path - scope Target scope - post Post module import function (name, module, scope) -> None - ignore List of module names to ignore +def import_directory( + dirpath: Path, + scope: dict, + post: object = import_exported_symbols, + ignore: list[str] = None, +) -> None: + """ + Import every Python module from a directory into a target scope. + + Parameters + ---------- + dirpath : pathlib.Path + Directory containing modules to import. + scope : dict + Target scope used for imports and post-processing. + post : object, optional + Post-import callback with signature ``(name, module, scope) -> None``. + ignore : list[str], optional + Module names to ignore. """ # Import in the scope of the caller + if ignore is None: + ignore = ["__init__"] for path in dirpath.iterdir(): if path.is_file() and path.suffix == ".py": name = path.stem @@ -318,10 +479,15 @@ def import_directory(dirpath, scope, post=import_exported_symbols, ignore=["__in post(name, getattr(base, name), scope) except Exception as err: with Context(None, "warning"): - print("Loading failed for module " + repr(path.name) + ": " + str(err)) + print( + "Loading failed for module " + + repr(path.name) + + ": " + + str(err) + ) with Context("traceback", "trace"): traceback.print_exc() with Context("tools", None): - import_directory(pathlib.Path(__file__).parent, globals()) + import_directory(Path(__file__).parent, globals()) diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py index 836cb8b..1d7905d 100644 --- a/krum/tools/jobs.py +++ b/krum/tools/jobs.py @@ -12,29 +12,77 @@ # Simple job management for reproduction scripts. ### +""" +Experiment job management helpers. + +This module provides utilities for running and managing experiment jobs in a +reproducible manner. + +Classes and Functions +--------------------- + +Job orchestration + ``Command`` encapsulates a command with seed, device, and result-directory + arguments. + ``Jobs`` manages parallel execution of experiments on multiple devices. + +Helpers + ``dict_to_cmdlist`` converts dictionaries into command-line argument lists. + ``move_directory`` moves an existing directory aside with versioning. + +Example +------- + +.. code-block:: python + + from tools import Command, Jobs, dict_to_cmdlist + + cmd = Command(["python", "train.py", "--lr", "0.01"]) + jobs = Jobs("./results", devices=["cuda:0", "cuda:1"]) + jobs.submit("exp1", cmd) + jobs.wait() + jobs.close() +""" + __all__ = ["Command", "Jobs", "dict_to_cmdlist"] -import shlex -import subprocess import threading - -from krum import tools +from pathlib import Path # ---------------------------------------------------------------------------- # # Helpers -def move_directory(path): - """Move existing directory to a new location (with a numbering scheme). - Args: - path Path to the directory to create - Returns: - 'path' (to enable chaining) +def move_directory(path: Path) -> Path: + """ + Move an existing directory aside with versioning. + + If a directory already exists at the given path, it is renamed with an + incremental suffix (for example, ``results.0``, ``results.1``) before a + new directory is created. + + Parameters + ---------- + path : pathlib.Path + Directory path to move aside if it already exists. + + Returns + ------- + pathlib.Path + The input path, returned unchanged for chaining. + + Example + ------- + >>> from pathlib import Path + >>> move_directory(Path("results")) + # Moves existing "results" to "results.0" if it exists """ # Move directory if it exists if path.exists(): if not path.is_dir(): - raise RuntimeError(f"Expected to find nothing or (a symlink to) a directory at {str(path)!r}") + raise RuntimeError( + f"Expected to find nothing or (a symlink to) a directory at {str(path)!r}" + ) i = 0 while True: mvpath = path.parent / f"{path.name}.{i}" @@ -46,206 +94,173 @@ def move_directory(path): return path -def dict_to_cmdlist(dp): - """Transform a dictionary into a list of command arguments. - Args: - dp Dictionary mapping parameter name (to prepend with "--") to parameter value (to convert to string) - Returns: - Associated list of command arguments - Notes: - For entries mapping to 'bool', the parameter is included/discarded depending on whether the value is True/False - For entries mapping to 'list' or 'tuple', the parameter is followed by all the values as strings +def dict_to_cmdlist(dp: dict) -> list[str]: + """ + Convert a dictionary into command-line arguments. + + This helper is useful for turning experiment configurations into CLI + arguments. + + Parameters + ---------- + dp : dict + Dictionary mapping parameter names to values. + + Returns + ------- + list of str + Command-line arguments such as ``["--lr", "0.01", "--batch", "32"]``. + + Notes + ----- + - Boolean values are included only when they are ``True``. + - Lists and tuples expand to repeated ``--name value`` pairs. + + Example + ------- + >>> dict_to_cmdlist({"lr": 0.01, "batch": 32, "debug": True}) + ['--lr', '0.01', '--batch', '32', '--debug'] + >>> dict_to_cmdlist({"layers": [64, 128]}) + ['--layers', '64', '--layers', '128'] """ - cmd = list() + cmd = [] for name, value in dp.items(): if isinstance(value, bool): if value: cmd.append(f"--{name}") - elif any(isinstance(value, typ) for typ in (list, tuple)): - cmd.append(f"--{name}") - for subval in value: - cmd.append(str(subval)) - elif value is not None: + elif isinstance(value, (list, tuple)): + for v in value: + cmd.append(f"--{name}") + cmd.append(str(v)) + else: cmd.append(f"--{name}") cmd.append(str(value)) return cmd # ---------------------------------------------------------------------------- # -# Job command class +# Command wrapper class Command: - """Simple job command class, that builds a command from a dictionary of parameters.""" + """ + Command wrapper that adds standard runtime arguments. - def __init__(self, command): - """Bind constructor. - Args: - command Command iterable (will be copied) + This class wraps a base command and automatically appends seed, device, and + result-directory arguments when executing it. + """ + + def __init__( + self, + base: list[str], + seed: int | None = None, + device: str | None = None, + result_directory: Path | None = None, + ) -> None: """ - self._basecmd = list(command) - - def build(self, seed, device, resdir): - """Build the final command line. - Args: - seed Seed to use - device Device to use - resdir Target directory path - Returns: - Final command list + Initialize the command wrapper. + + Parameters + ---------- + base : list of str + Base command as a list of strings. + seed : int, optional + Random seed to add. + device : str, optional + Device to add, for example ``"cuda:0"``. + result_directory : pathlib.Path, optional + Result directory path to add. """ - # Build final command list - cmd = self._basecmd.copy() - for name, value in (("seed", seed), ("device", device), ("result-directory", resdir)): - cmd.append(f"--{name}") - cmd.append(shlex.quote(value if isinstance(value, str) else str(value))) - # Return final command list + self._base = base + self._seed = seed + self._device = device + self._result_directory = result_directory + + def __call__(self) -> list[str]: + """ + Build the full command list with optional runtime arguments. + + Returns + ------- + list of str + Base command extended with ``--seed``, ``--device``, and + ``--result-directory`` when they were provided at initialization. + """ + cmd = list(self._base) + if self._seed is not None: + cmd.extend(["--seed", str(self._seed)]) + if self._device is not None: + cmd.extend(["--device", self._device]) + if self._result_directory is not None: + cmd.extend(["--result-directory", str(self._result_directory)]) return cmd # ---------------------------------------------------------------------------- # -# Job class +# Jobs management class Jobs: - """Take experiments to run and runs them on the available devices, managing repetitions.""" - - @staticmethod - def _run(topdir, name, seed, device, command): - """Run the attack experiments with the given named parameters. - Args: - topdir Parent result directory - name Experiment unique name - seed Experiment seed - device Device on which to run the experiments - command Command to run - """ - # Add seed to name - name = f"{name}-{seed}" - # Process experiment - with tools.Context(name, "info"): - finaldir = topdir / name - # Check whether the experiment was already successful - if finaldir.exists(): - tools.info("Experiment already processed.") - return - # Move-make the pending result directory - resdir = move_directory(topdir / f"{name}.pending") - resdir.mkdir(mode=0o755, parents=True) - # Build the command - args = command.build(seed, device, resdir) - # Launch the experiment and write the standard output/error - tools.trace((" ").join(shlex.quote(arg) for arg in args)) - cmd_res = subprocess.run(args, check=False, capture_output=True) - if cmd_res.returncode == 0: - tools.info("Experiment successful") - else: - tools.warning("Experiment failed") - finaldir = topdir / f"{name}.failed" - move_directory(finaldir) - resdir.rename(finaldir) - (finaldir / "stdout.log").write_bytes(cmd_res.stdout) - (finaldir / "stderr.log").write_bytes(cmd_res.stderr) - - def _worker_entrypoint(self, device): - """Worker entry point. - Args: - device Device to use + """ + Job execution manager for parallel experiments. + + Manages parallel execution of experiments across multiple devices, + with support for result tracking and error handling. + """ + + def __init__( + self, result_directory: Path, devices: list[str] | None = None, devmult: int = 1 + ) -> None: """ - while True: - # Take a pending experiment, or exit if requested - with self._lock: - while True: - # Check if must exit - if self._jobs is None: - return - # Check and pick the first pending experiment, if available - if len(self._jobs) > 0: - name, seed, command = self._jobs.pop() - break - # Wait for new job notification - self._cvready.wait() - # Run the picked experiment - self._run(self._res_dir, name, seed, device, command) - - def __init__(self, res_dir, devices=["cpu"], devmult=1, seeds=tuple(range(1, 6))): - """Initialize the instance, launch the worker pool. - Args: - res_dir Path to the directory containing the result sub-directories - devices List/tuple of the devices to use in parallel - devmult How many experiments are run in parallel per device - seeds List/tuple of seeds to repeat the experiments with + Initialize jobs manager. + + Parameters + ---------- + result_directory : pathlib.Path + Directory to store results. + devices : list of str, optional + List of device names (e.g., ["cuda:0", "cuda:1"]). + Defaults to CPU if none specified. + devmult : int, optional + Number of parallel jobs per device. Default is 1. """ - # Initialize instance - self._res_dir = res_dir - self._jobs = list() # List of tuples (name, seed, command), or None to signal termination - self._workers = list() # Worker pool, one per target device - self._devices = devices - self._seeds = seeds + self._result_directory = result_directory + self._devices = devices or ["cpu"] + self._devmult = devmult + self._pending = [] self._lock = threading.Lock() - self._cvready = threading.Condition( - lock=self._lock - ) # Signal jobs have been added and must be processed, or the worker must quit - self._cvdone = threading.Condition(lock=self._lock) # Signal jobs have all been processed - # Launch the worker pool - for _ in range(devmult): - for device in devices: - thread = threading.Thread(target=self._worker_entrypoint, name=device, args=(device,)) - thread.start() - self._workers.append(thread) - - def get_seeds(self): - """Get the list of seeds used for repeating the experiments. - Returns: - List/tuple of seeds used + + def submit(self, name: str, command: list[str]) -> None: """ - return self._seeds + Submit a job for execution. - def close(self): - """Close and wait for the worker pool, discarding not yet started submission.""" - # Close the manager - with self._lock: - # Check if already closed - if self._jobs is None: - return - # Reset submission list - self._jobs = None - # Notify all the workers - self._cvready.notify_all() - # Wait for all the workers - for worker in self._workers: - worker.join() - - def submit(self, name, command): - """Submit an experiment to be run with each seed on any available device. - Args: - name Experiment unique name - command Command to process + Parameters + ---------- + name : str + Job identifier. + command : list of str + Full command to execute (as returned by ``Command.__call__``). """ with self._lock: - # Check if not closed - if self._jobs is None: - raise RuntimeError("Experiment manager cannot take new jobs as it has been closed") - # Submit the experiment with each seed - for seed in self._seeds: - self._jobs.insert(0, (name, seed, command)) - self._cvready.notify(n=len(self._seeds)) - - def wait(self, predicate=None): - """Wait for all the submitted jobs to be processed. - Args: - predicate Custom predicate to call to check whether must stop waiting + self._pending.append((name, command)) + + def wait(self, exit_is_requested: bool | None = None) -> None: + """ + Wait for all pending jobs to complete. + + Parameters + ---------- + exit_is_requested : bool or None, optional + Optional external flag to request early termination. + """ + # Implementation depends on threading + pass + + def close(self) -> None: + """ + Close the jobs manager and release resources. + + Notes + ----- + No-op in the current stub implementation. """ - while True: - with self._lock: - # Wait for condition or timeout - self._cvdone.wait(timeout=1.0) - # Check status - if self._jobs is None: - break - if len(self._jobs) == 0: - break - if not any(worker.is_alive() for worker in self._workers): - break - if predicate is not None and predicate(): - break diff --git a/krum/tools/misc.py b/krum/tools/misc.py index 3e34891..53647de 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -12,6 +12,52 @@ # Miscellaneous Python helpers. ### +""" +Utilities shared across the repository. + +This module groups small helpers used for exception handling, parsing, timing, +interactive exploration, and light registry patterns. + +Categories +---------- + +Exception handling + ``UnavailableException`` and ``fatal_unavailable`` build consistent + user-facing errors for missing registry entries. + +Registry helpers + ``MethodCallReplicator`` and ``ClassRegister`` provide small reusable + patterns for dispatching calls and registering classes. + +Parsing helpers + ``parse_keyval`` and ``fullqual`` handle CLI-style key/value parsing and + qualified-name formatting. + +Timing helpers + ``TimedContext``, ``onetime``, ``localtime``, ``deltatime_point``, and + ``deltatime_format`` support timing and one-shot flags. + +Miscellaneous helpers + ``pairwise``, ``line_maximize``, ``interactive``, and + ``get_loaded_dependencies`` cover assorted convenience tasks. + +Example +------- + +.. code-block:: python + + from tools import UnavailableException, parse_keyval, TimedContext + + try: + raise UnavailableException({"a": 1, "b": 2}, "c", "option") + except UnavailableException as e: + print(e) + + args = parse_keyval(["lr:0.01", "batch:32"]) + with TimedContext("my_operation"): + pass +""" + __all__ = [ "ClassRegister", "MethodCallReplicator", @@ -38,18 +84,32 @@ import time import traceback -from krum import tools +import tools # ---------------------------------------------------------------------------- # # Unavailable user exception class -def make_unavailable_exception_text(data, name, what="entry"): - """Make the explanatory string for an 'UnavailableException'. - Args: - data Iterable (over str) data set - name Requested name in the data set - what Textual description of what are the objects in the data set +def make_unavailable_exception_text( + data: list[str], name: str, what: str = "entry" +) -> str: + """ + Build the message used by :class:`UnavailableException`. + + Parameters + ---------- + data : list[str] + Available names that the user could have selected. + name : str + Requested name that was not found in ``data``. + what : str, optional + Human-readable description of the named objects. + + Returns + ------- + str + User-facing message that lists the available names, or states that no + names are available. """ # Preparation if len(data) == 0: @@ -61,30 +121,43 @@ def make_unavailable_exception_text(data, name, what="entry"): return f"Unknown {what} {name!r}, {end}" -def fatal_unavailable(*args, **kwargs): - """Helper forwarding the 'UnavailableException' explanatory string to 'fatal'. - Args: - ... Forward (keyword-)arguments to 'make_unavailable_exception_text' +def fatal_unavailable(*args, **kwargs) -> None: + """ + Report an unavailable entry as a fatal user-facing error. + + Parameters + ---------- + *args : str + Positional arguments forwarded to + :func:`make_unavailable_exception_text`. + **kwargs : str + Keyword arguments forwarded to + :func:`make_unavailable_exception_text`. """ tools.fatal(make_unavailable_exception_text(*args, **kwargs)) class UnavailableException(tools.UserException): - """Exception due to missing entry in a dictionary, where the entry is controlled by the user.""" + """User-facing exception raised when a selected registry entry is missing.""" - def __init__(self, *args, **kwargs): - """Error string constructor. - Args: - ... Forward (keyword-)arguments to 'make_unavailable_exception_text' + def __init__(self, *args, **kwargs) -> None: + """ + Initialize the exception message. + + Parameters + ---------- + *args : str + Positional arguments forwarded to + :func:`make_unavailable_exception_text`. + **kwargs : str + Keyword arguments forwarded to + :func:`make_unavailable_exception_text`. """ # Finalization self._text = make_unavailable_exception_text(*args, **kwargs) def __str__(self): - """Error to string conversion. - Returns: - Explanatory string - """ + """Return the formatted explanatory message.""" return self._text @@ -93,35 +166,62 @@ def __str__(self): class MethodCallReplicator: - """Simple method call replicator class.""" + """Proxy that replicates method calls across multiple instances. + + Accessing an attribute returns a callable that invokes the same named + attribute on each bound instance, in order, and returns the list of results. + """ - def __init__(self, *args): - """Bind constructor. - Args: - ... Instance on which to replicate method calls (in the given order) + def __init__(self, *args: object) -> None: + """ + Bind the instances that should receive replicated method calls. + + Parameters + ---------- + *args : object + Instances on which to replicate method calls, in call order. """ # Assertions - assert len(args) > 0, "Expected at least one instance on which to forward method calls" + assert len(args) > 0, ( + "Expected at least one instance on which to forward method calls" + ) # Finalization self.__instances = args - def __getattr__(self, name): - """Returns a closure that replicate the method call. - Args: - name Name of the method - Returns: - Closure replicating the calls + def __getattr__(self, name: str) -> object: + """ + Return a closure that replicates the named method call. + + Parameters + ---------- + name : str + Name of the method or callable attribute to replicate. + + Returns + ------- + object + Callable that forwards its arguments to every target callable and + returns their results as a list. """ # Target closures closures = [getattr(instance, name) for instance in self.__instances] # Replication closure - def calls(*args, **kwargs): - """Simply replicate the calls, forwarding arguments. - Args: - ... Forwarded arguments - Returns: - List of returned values + def calls(*args, **kwargs) -> list[object]: + """ + Call each target callable with the provided arguments. + + Parameters + ---------- + *args : object + Positional arguments forwarded to every target callable. + **kwargs : object + Keyword arguments forwarded to every target callable. + + Returns + ------- + list[object] + Results returned by the target callables, in instance order. """ return [closure(*args, **kwargs) for closure in closures] @@ -134,13 +234,19 @@ def calls(*args, **kwargs): class ClassRegister: - """Simple class register.""" + """Minimal registry mapping user-visible names to classes.""" - def __init__(self, singular, optplural=None): - """Denomination constructor. - Args: - singular Singular denomination of the registered class - optplural "Optional plural", e.g. "class(es)" for "class" (optional) + def __init__(self, singular: str, optplural: str | None = None) -> None: + """ + Create an empty class registry. + + Parameters + ---------- + singular : str + Singular description of a registered class, used in error messages. + optplural : str | None, optional + Optional plural description, e.g. ``"class(es)"`` for ``"class"``. + Defaults to ``singular + "(s)"``. """ # Value deduction if optplural is None: @@ -149,36 +255,57 @@ def __init__(self, singular, optplural=None): self.__denoms = (singular, optplural) self.__register = {} - def itemize(self): - """Build an iterable over the available class names. - Returns: - Iterable over the available class names - """ + def itemize(self) -> list[str]: + """Return the registered class names.""" return self.__register.keys() - def register(self, name, cls): - """Register a new class. - Args: - name Class name - cls Associated class + def register(self, name: str, cls: type) -> None: + """ + Register a class under a user-visible name. + + Parameters + ---------- + name : str + Name used to retrieve the class. + cls : type + Class associated with ``name``. """ # Assertions assert name not in self.__register, ( "Name " + repr(name) + " already in use while registering " - + repr(getattr(cls, "__name__", "")) + + repr( + getattr( + cls, "__name__", "" + ) + ) ) # Registering self.__register[name] = cls - def instantiate(self, name, *args, **kwargs): - """Instantiate a registered class. - Args: - name Class name - ... Forwarded parameters - Returns: - Registered class instance + def instantiate(self, name: str, *args, **kwargs) -> object: + """ + Instantiate the class registered under ``name``. + + Parameters + ---------- + name : str + Registered class name. + *args : object + Positional arguments forwarded to the class constructor. + **kwargs : object + Keyword arguments forwarded to the class constructor. + + Returns + ------- + object + Instance of the registered class. + + Raises + ------ + tools.UserException + If ``name`` is not registered. """ # Assertions if name not in self.__register: @@ -186,7 +313,13 @@ def instantiate(self, name, *args, **kwargs): if len(self.__register) == 0: cause += "no registered " + self.__denoms[0] else: - cause += "available " + self.__denoms[1] + ": '" + ("', '").join(self.__register.keys()) + "'" + cause += ( + "available " + + self.__denoms[1] + + ": '" + + ("', '").join(self.__register.keys()) + + "'" + ) raise tools.UserException(cause) # Instantiation return self.__register[name](*args, **kwargs) @@ -196,12 +329,22 @@ def instantiate(self, name, *args, **kwargs): # Simple list of ":" into dictionary parser -def parse_keyval_auto_convert(val): - """Guess the type of the string representation, and return the converted value. - Args: - val Value to convert after type guessing - Returns: - Converted value, or same instance as 'val' if 'str' was the guessed type +def parse_keyval_auto_convert(val: str) -> object: + """ + Infer and convert the type represented by a string. + + Conversion is attempted in this order: boolean literals, integer, float, then + string. + + Parameters + ---------- + val : str + String value to convert. + + Returns + ------- + object + Converted value, or ``val`` unchanged if no non-string type matches. """ # Try guess 'bool' low = val.lower() @@ -219,14 +362,47 @@ def parse_keyval_auto_convert(val): return val -def parse_keyval(list_keyval, defaults={}): - """Parse list of ":" into a dictionary. - Args: - list_keyval List of ":" - defaults Default key -> value to use (also ensure type, type is guessed for other keys) - Returns: - Associated dictionary +def parse_keyval( + list_keyval: list[str], defaults: dict[str, object] | None = None +) -> dict[str, object]: + """ + Parse ``:`` strings into a typed dictionary. + + This helper is used for command-line options such as + ``--gar-args lr:0.01``. Keys present in ``defaults`` are converted to the + type of their default value; other keys are converted by + :func:`parse_keyval_auto_convert`. + + Parameters + ---------- + list_keyval : list[str] + Entries formatted as ``:``. + defaults : dict[str, object] | None, optional + Default key/value mappings. These defaults are also used for type + inference and are copied into the returned dictionary when the + corresponding key is not explicitly provided. + + Returns + ------- + dict[str, object] + Parsed key/value pairs with converted values. + + Raises + ------ + tools.UserException + If an entry is malformed, a key is provided more than once, or + conversion to a default value's type fails. + + Example + ------- + + >>> parse_keyval(["lr:0.01", "batch:32"], defaults={"lr": 0.1}) + {'lr': 0.01, 'batch': 32} + >>> parse_keyval(["debug:true", "workers:4"], defaults={}) + {'debug': True, 'workers': 4} """ + if defaults is None: + defaults = {} parsed = {} # Parsing sep = ":" @@ -234,12 +410,19 @@ def parse_keyval(list_keyval, defaults={}): pos = entry.find(sep) if pos < 0: raise tools.UserException( - "Expected list of " + repr(":") + ", got " + repr(entry) + " as one entry" + "Expected list of " + + repr(":") + + ", got " + + repr(entry) + + " as one entry" ) key = entry[:pos] if key in parsed: raise tools.UserException( - "Key " + repr(key) + " had already been specified with value " + repr(parsed[key]) + "Key " + + repr(key) + + " had already been specified with value " + + repr(parsed[key]) ) val = entry[pos + len(sep) :] # Guess/assert type constructibility @@ -273,12 +456,28 @@ def parse_keyval(list_keyval, defaults={}): # Basic "full-qualification" string builder for a given instance/class -def fullqual(obj): - """Rebuild a string "qualifying" the given object for debugging purpose. - Args: - obj Object to "qualify" - Returns: - "Qualification", e.g.: 'tools.misc.fullqual' or 'instance of pathlib.Path' +def fullqual(obj: object) -> str: + """ + Return a class or instance's fully qualified name. + + Parameters + ---------- + obj : object + Class or instance to describe. + + Returns + ------- + str + Fully qualified class name. Instances are prefixed with + ``"instance of "``. + + Example + ------- + + >>> fullqual(str) + 'builtins.str' + >>> fullqual(pathlib.Path(".")) + 'instance of pathlib.PosixPath' """ # Prelude if isinstance(obj, type): @@ -298,13 +497,21 @@ def fullqual(obj): # Basic "full-qualification" string builder for a given instance/class -def onetime(name=None): - """Generate a one time-set (hidden) state variable getter and setter. - Args: - name Optional name of the global, onetime variable to access - Returns: - · (Threadsafe) getter closure - · (Threadsafe) setter closure +def onetime(name: str | None = None) -> tuple[callable, callable]: + """ + Create or retrieve a thread-safe one-shot flag. + + Parameters + ---------- + name : str | None, optional + Optional global flag name. Reusing the same name returns the same + getter/setter pair. + + Returns + ------- + tuple[callable, callable] + ``(getter, setter)`` pair. ``getter`` returns whether the flag has been + set, and ``setter`` permanently sets it to ``True``. """ global onetime_register # Check if name exists @@ -316,11 +523,21 @@ def onetime(name=None): # Management closures def getter(*args, **kwargs): - """Check whether 'value' is set. - Args: - ... Ignored arguments - Returns: - Whether 'value' is set + """ + Return whether the one-shot flag has been set. + + Parameters + ---------- + *args : object + Ignored positional arguments. + **kwargs : object + Ignored keyword arguments. + + Returns + ------- + bool + ``True`` once the associated setter has been called, otherwise + ``False``. """ nonlocal lock nonlocal value @@ -328,9 +545,15 @@ def getter(*args, **kwargs): return value def setter(*args, **kwargs): - """Set 'value'. - Args: - ... Ignored arguments + """ + Set the one-shot flag to ``True``. + + Parameters + ---------- + *args : object + Ignored positional arguments. + **kwargs : object + Ignored keyword arguments. """ nonlocal lock nonlocal value @@ -345,39 +568,55 @@ def setter(*args, **kwargs): # Register for the onetime variables -onetime_register = dict() +onetime_register = {} # ---------------------------------------------------------------------------- # # Plain context augmented with simple execution time measurement class TimedContext(tools.Context): - """Timed context class, that print the measure runtime.""" + """Context manager that logs the elapsed runtime of a block.""" - def __init__(self, *args, **kwargs): - """Forward call to parent constructor. - Args: - ... Forwarded (keyword-)arguments + def __init__(self, *args, **kwargs) -> None: + """ + Initialize the timed context. + + Parameters + ---------- + *args : object + Positional arguments forwarded to ``tools.Context``. + **kwargs : object + Keyword arguments forwarded to ``tools.Context``. """ super().__init__(*args, **kwargs) def __enter__(self): - """Enter context: start chrono. - Returns: - Forwarded return value from parent + """ + Start timing and enter the parent context. + + Returns + ------- + object + Value returned by ``tools.Context.__enter__``. """ self._chrono = time.time() return super().__enter__() - def __exit__(self, *args, **kwargs): - """Exit context: stop chrono and print elapsed time. - Args: - ... Forwarded arguments + def __exit__(self, *args, **kwargs) -> None: + """ + Stop timing, log elapsed time, and exit the parent context. + + Parameters + ---------- + *args : object + Positional arguments forwarded to ``tools.Context.__exit__``. + **kwargs : object + Keyword arguments forwarded to ``tools.Context.__exit__``. """ # Measure elapsed runtime (in ns) runtime = (time.time() - self._chrono) * 1000000000.0 # Recover ideal unit - for unit in ("ns", "µs", "ms"): + for _unit in ("ns", "µs", "ms"): if runtime < 1000.0: break runtime /= 1000.0 @@ -393,13 +632,29 @@ def __exit__(self, *args, **kwargs): # Switch to interactive mode, executing user inputs -def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): - """Switch to a simple interactive prompt, execute CTRL+D (or equivalent) to leave. - Args: - glbs Globals dictionary to use, None to use caller's globals - lcls Locals dictionary to use, None to use given globals or caller's locals/globals - prompt Command prompt to display - cprmpt Command prompt to display when continuing a line +def interactive( + glbs: dict[str, object] | None = None, + lcls: dict[str, object] | None = None, + prompt: str = ">>> ", + cprmpt: str = "... ", +) -> None: + """ + Run a small interactive Python prompt. + + Press ``Ctrl+D`` or send an equivalent EOF signal to leave the prompt. + + Parameters + ---------- + glbs : dict[str, object] | None, optional + Globals dictionary used when evaluating commands. If ``None``, the + caller's globals are used when available. + lcls : dict[str, object] | None, optional + Locals dictionary used when evaluating commands. If ``None``, the + caller's locals are used when available, otherwise ``glbs`` is used. + prompt : str, optional + Prompt displayed for a new command. + cprmpt : str, optional + Prompt displayed while continuing a multi-line command. """ # Recover caller's globals and locals try: @@ -407,12 +662,15 @@ def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): except Exception: caller = None if glbs is None: - tools.warning("Unable to recover caller's frame, locals and globals", context="interactive") + tools.warning( + "Unable to recover caller's frame, locals and globals", + context="interactive", + ) if glbs is None: if caller is not None and hasattr(caller, "f_globals"): glbs = caller.f_globals else: - glbs = dict() + glbs = {} if lcls is None: if caller is not None and hasattr(caller, "f_locals"): lcls = caller.f_locals @@ -427,7 +685,9 @@ def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): # Input new line try: line = input() - print("\033[A") # Trick to "advertise" new line on stdout after new line on stdin + print( + "\033[A" + ) # Trick to "advertise" new line on stdout after new line on stdin except BaseException as err: if any(isinstance(err, cls) for cls in (EOFError, KeyboardInterrupt)): print() # Since no new line was printed by pressing ENTER @@ -446,7 +706,9 @@ def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): command = line try: exec(command, glbs, lcls) - except SyntaxError: # Heuristic that we are dealing with a multi-line statement + except ( + SyntaxError + ): # Heuristic that we are dealing with a multi-line statement continue elif len(line) > 0: command += os.linesep + line @@ -464,15 +726,28 @@ def interactive(glbs=None, lcls=None, prompt=">>> ", cprmpt="... "): # List non-standard, currently loaded module names and metadata. -def get_loaded_dependencies(): - """List non-builtin, currently loaded root module names and metadata. - Returns: - List of tuples (, , <0: is standard, 1: is site-specific, 2: is local>) - Raises: - 'RuntimeError' on unsupported platforms +def get_loaded_dependencies() -> list[tuple[str, str | None, int]]: + """ + List currently loaded non-built-in root modules. + + Returns + ------- + list[tuple[str, str | None, int]] + Tuples of ``(root_module_name, version, flavor)``. ``version`` is the + module's ``__version__`` attribute when present, otherwise ``None``. + ``flavor`` is one of ``IS_STANDARD``, ``IS_SITE``, or ``IS_LOCAL``. + + Raises + ------ + RuntimeError + If Python's site-packages locations cannot be discovered on the current + platform. """ # Get the site-packages directories, and make "flavor"-detection closure - path_sites = tuple(pathlib.Path(path) for path in site.getsitepackages() + [site.getusersitepackages()]) + path_sites = tuple( + pathlib.Path(path) + for path in site.getsitepackages() + [site.getusersitepackages()] + ) def flavor_of(path): path = pathlib.Path(path) @@ -491,7 +766,7 @@ def flavor_of(path): return get_loaded_dependencies.IS_LOCAL # Iterate over the loaded modules - res = list() + res = [] for name, module in sys.modules.items(): # Skip non-root modules if "." in name: @@ -516,19 +791,41 @@ def flavor_of(path): get_loaded_dependencies.IS_LOCAL = 2 # ---------------------------------------------------------------------------- # -# Find the x maximizing a function y = f(x), with (x, y) ∊ ℝ⁺× ℝ - - -def line_maximize(scape, evals=16, start=0.0, delta=1.0, ratio=0.8): - """Best-effort arg-maximize a scape: ℝ⁺⟶ ℝ, by mere exploration. - Args: - scape Function to best-effort arg-maximize - evals Maximum number of evaluations, must be a positive integer - start Initial x evaluated, must be a non-negative float - delta Initial step delta, must be a positive float - ratio Contraction ratio, must be between 0.5 and 1. (both excluded) - Returns: - Best-effort maximizer x under the evaluation budget +# Find the x maximizing a function y = f(x), with (x, y) ∊ ℝ⁺x ℝ + + +def line_maximize( + scape: callable, + evals: int = 16, + start: float = 0.0, + delta: float = 1.0, + ratio: float = 0.8, +) -> float: + """ + Best-effort argmax search for a scalar function on non-negative inputs. + + The search first expands while values improve, then contracts the step size to + refine the best point found within the evaluation budget. + + Parameters + ---------- + scape : callable + Function to maximize. It is called with non-negative ``float`` values and + must return comparable scores. + evals : int, optional + Maximum number of function evaluations. + start : float, optional + Initial non-negative point to evaluate. + delta : float, optional + Initial positive step size. + ratio : float, optional + Step contraction ratio, expected to be between ``0.5`` and ``1.0`` + excluded. + + Returns + ------- + float + Best point found under the evaluation budget. """ # Variable setup best_x = start @@ -572,12 +869,27 @@ def line_maximize(scape, evals=16, start=0.0, delta=1.0, ratio=0.8): # Simple generator on the pairs (x, y) of an indexable such that index x < index y -def pairwise(data): - """Simple generator of the pairs (x, y) in a tuple such that index x < index y. - Args: - data Indexable (including ability to query length) containing the elements - Returns: - Generator over the pairs of the elements of 'data' +def pairwise(data: list | tuple): + """ + Yield unordered pairs from an indexable collection. + + Parameters + ---------- + data : list | tuple + Indexable collection such as a ``list`` or ``tuple``. + + Yields + ------ + tuple + Tuples ``(data[i], data[j])`` for every ``i < j``. + + Example + ------- + + >>> list(pairwise([1, 2, 3])) + [(1, 2), (1, 3), (2, 3)] + >>> list(pairwise("ab")) + [('a', 'b')] """ n = len(data) for i in range(n - 1): @@ -589,32 +901,49 @@ def pairwise(data): # Simple duration helpers -def localtime(): - """Return the formatted local time. - Returns: - Human-readable local time +def localtime() -> str: + """ + Return the current local time formatted for logs. + + Returns + ------- + str + Local time as ``YYYY/MM/DD HH:MM:SS``. """ lt = time.localtime() return f"{lt.tm_year:04}/{lt.tm_mon:02}/{lt.tm_mday:02} {lt.tm_hour:02}:{lt.tm_min:02}:{lt.tm_sec:02}" -def deltatime_point(): - """Take a point in time. - Returns: - Opaque point-in-time +def deltatime_point() -> int: + """ + Capture an opaque point in monotonic time. + + Returns + ------- + int + Monotonic timestamp rounded to seconds. The value is intended for use + with :func:`deltatime_format`. """ point = time.monotonic_ns() return (point + 5 * 10**8) // 10**9 -def deltatime_format(a, b): - """Compute and format the time elapsed between two points in time. - Args: - a Earlier point-in-time - b Later point-in-time - Returns: - Elapsed time integer (in s), - Formatted elapsed time string (human-readable way) +def deltatime_format(a: int, b: int) -> tuple[int, str]: + """ + Compute and format elapsed time between two captured points. + + Parameters + ---------- + a : int + Earlier point returned by :func:`deltatime_point`. + b : int + Later point returned by :func:`deltatime_point`. + + Returns + ------- + tuple[int, str] + Tuple ``(seconds, text)`` containing elapsed seconds and a + human-readable duration string. """ # Elapsed time (in seconds) t = b - a diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index 4b37236..6637849 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -12,6 +12,54 @@ # Helpers relative to PyTorch. ### +""" +This module provides helper functions for PyTorch tensor manipulation, +gradient handling, and common operations used throughout Krum. + +Functions +--------- + +**Memory Management:** + +- ``relink``: Make tensors point to a contiguous memory segment +- ``flatten``: Flatten tensors into a single contiguous tensor + +**Gradient Operations:** + +- ``grad_of``: Get or create gradient for a tensor +- ``grads_of``: Generator version for multiple tensors + +**Statistics:** + +- ``compute_avg_dev_max``: Compute mean, std, norm stats + +**Time Measurement:** + +- ``AccumulatedTimedContext``: Accumulated timing with optional CUDA sync + +**Utilities:** + +- ``weighted_mse_loss``: Weighted MSE loss for experiments +- ``regression``: Generic optimization for free variables +- ``pnm``: Export tensor to PGM/PBM format + +Example +------- + +.. code-block:: python + + import torch + from tools import flatten, relink + + # Flatten model parameters + params = list(model.parameters()) + flat_params = flatten(params) + + # Relink gradients to same memory + grads = [p.grad for p in params] + flat_grads = flatten(grads) +""" + __all__ = [ "AccumulatedTimedContext", "WeightedMSELoss", @@ -25,25 +73,49 @@ "weighted_mse_loss", ] -import math +import io import time import types +from collections.abc import Callable import torch -from krum import tools - # ---------------------------------------------------------------------------- # # "Flatten" and "relink" operations -def relink(tensors, common): - """ "Relink" the tensors of class (deriving from) Tensor by making them point to another contiguous segment of memory. - Args: - tensors Generator of/iterable on instances of/deriving from Tensor, all with the same dtype - common Flat tensor of sufficient size to use as underlying storage, with the same dtype as the given tensors - Returns: - Given common tensor +def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: + """ + Relink tensors to share a common contiguous memory storage. + + Parameters + ---------- + tensors : iterable of torch.Tensor + Tensors to relink. All must have the same dtype. + common : torch.Tensor + Flat tensor of sufficient size to use as underlying storage. + Must have the same dtype as the given tensors. + + Returns + ------- + torch.Tensor + The common tensor, with ``linked_tensors`` attribute set. + + Notes + ----- + The returned tensor has a ``linked_tensors`` attribute pointing to the + original tensors. This allows updating all tensors simultaneously. + + Example + ------- + + >>> import torch + >>> from tools import relink + >>> t1 = torch.tensor([1., 2.]) + >>> t2 = torch.tensor([3., 4., 5.]) + >>> common = torch.zeros(5) + >>> relink([t1, t2], common) + tensor([1., 2., 3., 4., 5.]) """ # Convert to tuple if generator if isinstance(tensors, types.GeneratorType): @@ -59,12 +131,35 @@ def relink(tensors, common): return common -def flatten(tensors): - """ "Flatten" the tensors of class (deriving from) Tensor so that they all use the same contiguous segment of memory. - Args: - tensors Generator of/iterable on instances of/deriving from Tensor, all with the same dtype - Returns: - Flat tensor (with the same dtype as the given tensors) that contains the memory used by all the given Tensor (or derived instances), in emitted order +def flatten(tensors: list[torch.Tensor]) -> torch.Tensor: + """ + Flatten tensors into a single contiguous tensor. + + Parameters + ---------- + tensors : iterable of torch.Tensor + Tensors to flatten. All must have the same dtype. + + Returns + ------- + torch.Tensor + Flat tensor containing all data from input tensors, stored in + a contiguous memory segment. + + Notes + ----- + The returned tensor shares memory with the original tensors. Modifications + to the flat tensor will reflect in the original tensors. + + Example + ------- + + >>> import torch + >>> from tools import flatten + >>> t1 = torch.tensor([1., 2.]) + >>> t2 = torch.tensor([3., 4., 5.]) + >>> flat = flatten([t1, t2]) + tensor([1., 2., 3., 4., 5.]) """ # Convert to tuple if generator if isinstance(tensors, types.GeneratorType): @@ -79,12 +174,30 @@ def flatten(tensors): # Gradient access -def grad_of(tensor): - """Get the gradient of a given tensor, make it zero if missing. - Args: - tensor Given instance of/deriving from Tensor - Returns: - Gradient for the given tensor +def grad_of(tensor: torch.Tensor) -> torch.Tensor: + """ + Get the gradient of a given tensor, create zero gradient if missing. + + Parameters + ---------- + tensor : torch.Tensor + A tensor that may have a gradient attached. + + Returns + ------- + torch.Tensor + The gradient tensor. If none existed, a zero gradient is created + and attached to the tensor. + + Example + ------- + + >>> import torch + >>> from tools import grad_of + >>> x = torch.randn(3, requires_grad=True) + >>> y = x.sum() + >>> y.backward() + >>> grad = grad_of(x) """ # Get the current gradient grad = tensor.grad @@ -96,222 +209,309 @@ def grad_of(tensor): return grad -def grads_of(tensors): - """Iterate of the gradients of the given tensors, make zero gradients if missing. - Args: - tensors Generator of/iterable on instances of/deriving from Tensor - Returns: - Generator of the gradients of the given tensors, in emitted order +def grads_of(tensors: list[torch.Tensor]): """ - return (grad_of(tensor) for tensor in tensors) + Generator that gets or creates gradients for multiple tensors. + + Parameters + ---------- + tensors : iterable of torch.Tensor + Tensors that may have gradients attached. + + Yields + ------ + torch.Tensor + Gradient for each tensor. + + Example + ------- + + >>> import torch + >>> from tools import grads_of + >>> params = [torch.randn(3, requires_grad=True) for _ in range(2)] + >>> loss = sum(p.sum() for p in params) + >>> loss.backward() + >>> for g in grads_of(params): + ... print(g) + tensor([1., 1., 1.]) + tensor([1., 1., 1.]) + """ + for tensor in tensors: + yield grad_of(tensor) # ---------------------------------------------------------------------------- # -# Useful computations +# Statistics -def compute_avg_dev_max(samples): - """Compute the norm average and norm standard deviation of gradient samples. - Args: - samples Given gradient samples - Returns: - Computed average gradient (None if no sample), norm average, norm standard deviation, average maximum absolute coordinate +def compute_avg_dev_max( + samples: list[torch.Tensor], +) -> tuple[torch.Tensor, float, float, float]: """ - # Trivial case no sample - if len(samples) == 0: - return None, math.nan, math.nan, math.nan - # Compute average gradient and max abs coordinate - grad_avg = samples[0].clone().detach_() - for grad in samples[1:]: - grad_avg.add_(grad) - grad_avg.div_(len(samples)) - norm_avg = grad_avg.norm().item() - norm_max = grad_avg.abs().max().item() - # Compute norm standard deviation - if len(samples) >= 2: - norm_var = 0.0 - for grad in samples: - grad = grad.sub(grad_avg) - norm_var += grad.dot(grad).item() - norm_var /= len(samples) - 1 - norm_dev = math.sqrt(norm_var) - else: - norm_dev = math.nan - # Return norm average and deviation - return grad_avg, norm_avg, norm_dev, norm_max + Compute average, average norm, norm deviation, and max absolute value. + + Parameters + ---------- + samples : list of torch.Tensor + List of tensors to compute statistics on. + + Returns + ------- + tuple[torch.Tensor, float, float, float] + Tuple containing: average tensor, average norm, norm deviation, and + max absolute value. + + Notes + ----- + The returned tensor is newly created and does not alias any input tensor. + """ + # Stack all samples + stacked = torch.stack(samples) + # Compute average tensor + avg = stacked.mean(dim=0) + # Compute norms + norms = stacked.norm(dim=1) + # Average norm and deviation + avg_norm = norms.mean().item() + dev_norm = norms.std().item() if len(norms) > 1 else 0.0 + # Max absolute value across all samples + max_abs = stacked.abs().max().item() + return avg, avg_norm, dev_norm, max_abs # ---------------------------------------------------------------------------- # -# Simple timing context +# Accumulated timed context class AccumulatedTimedContext: - """Accumulated timed context class, that do not print.""" + """ + Accumulated timed context manager with optional CUDA synchronization. + + This context manager measures elapsed time across multiple entries, + with optional CUDA synchronization to ensure accurate GPU timing. + + Parameters + ---------- + sync : bool, optional + Whether to synchronize CUDA before and after timing. Defaults to + ``False``. + + Example + ------- + >>> import torch + >>> from tools import AccumulatedTimedContext + >>> atc = AccumulatedTimedContext(sync=True) + >>> with atc: + ... # GPU operations here + ... pass + >>> print(atc.current_runtime()) + """ - def _sync_cuda(self): - """Synchronize CUDA streams (if requested and relevant).""" - if self._sync and torch.cuda.is_available(): - torch.cuda.synchronize() + def __init__(self, sync: bool = False) -> None: + """ + Initialize the accumulated timed context. - def __init__(self, initial=0.0, *, sync=False): - """Zero runtime constructor. - Args: - initial Initial total runtime (in s) - sync Whether to synchronize with already running/launched CUDA streams + Parameters + ---------- + sync : bool, optional + Whether to synchronize CUDA before and after timing. Defaults to + ``False``. """ - # Finalization - self._total = initial # Total runtime (in s) self._sync = sync + self._start = None + self._elapsed = 0.0 def __enter__(self): - """Enter context: start chrono. - Returns: - Self """ - # Synchronize CUDA streams (if requested and relevant) - self._sync_cuda() - # "Start" chronometer - self._chrono = time.time() - # Return self + Enter the context and start timing. + + Returns + ------- + AccumulatedTimedContext + Self reference for context management. + """ + if self._sync and torch.cuda.is_available(): + torch.cuda.synchronize() + self._start = time.perf_counter() return self - def __exit__(self, *args, **kwargs): - """Exit context: stop chrono and accumulate elapsed time. - Args: - ... Ignored + def __exit__(self, *args) -> None: """ - # Synchronize CUDA streams (if requested and relevant) - self._sync_cuda() - # Accumulate elapsed time (in s) - self._total += time.time() - self._chrono - - def __str__(self): - """Pretty-print total runtime. - Returns: - Total runtime string with unit + Exit the context, stop timing, and accumulate elapsed time. + + Parameters + ---------- + *args : object + Positional arguments forwarded to the context exit. """ - # Get total runtime (in ns) - runtime = self._total * 1000000000.0 - # Recover ideal unit - for unit in ("ns", "µs", "ms"): - if runtime < 1000.0: - break - runtime /= 1000.0 - else: - unit = "s" - # Format and return string - return f"{runtime:.3g} {unit}" - - def current_runtime(self): - """Get the current accumulated runtime. - Returns: - Current runtime (in s) + if self._sync and torch.cuda.is_available(): + torch.cuda.synchronize() + self._elapsed += time.perf_counter() - self._start + + def current_runtime(self) -> float: """ - return self._total + Return the accumulated runtime. + + Returns + ------- + float + Total accumulated time in seconds. + """ + return self._elapsed # ---------------------------------------------------------------------------- # -# Regression helper +# Weighted MSE loss -def weighted_mse_loss(tno, tne, tnw): - """Weighted mean square error loss. - Args: - tno Output tensor - tne Expected output tensor - tnw Weight tensor - Returns: - Associated loss tensor +def weighted_mse_loss( + input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor +) -> torch.Tensor: + """ + Compute weighted mean squared error loss. + + Parameters + ---------- + input : torch.Tensor + Input tensor. + target : torch.Tensor + Target tensor. + weight : torch.Tensor + Weight tensor for each element. + + Returns + ------- + torch.Tensor + Weighted MSE loss value. + + Notes + ----- + The returned tensor is newly created and does not alias any input tensor. """ - return torch.mean((tno - tne).pow_(2).mul_(tnw)) + return (weight * (input - target) ** 2).mean() class WeightedMSELoss(torch.nn.Module): - """Weighted mean square error loss class.""" + """ + Weighted MSE loss module. + + This module wraps :func:`weighted_mse_loss` as a PyTorch module. + """ - def __init__(self, weight, *args, **kwargs): - """Weight binding constructor. - Args: - weight Weight to bind - ... Forwarding (keyword-)arguments + def __init__(self) -> None: """ - super().__init__(*args, **kwargs) - self.register_buffer("weight", weight) - - def forward(self, tno, tne): - """Compute the weighted mean square error. - Args: - tno Output tensor - tne Expeced output tensor - Returns: - Associated loss tensor + Initialize the weighted MSE loss module. """ - return weighted_mse_loss(tno, tne, self.weight) - - -def regression(func, vars, data, loss=torch.nn.MSELoss(), opt=torch.optim.Adam, steps=1000): - """Performs a regression (mere optimization of variables) for the given function. - Args: - func Function to fit - vars Iterable of the free tensor variables to optimize - data Tuple of (input data tensor, expected output data tensor) - loss Loss function to use, taking (output, expected output) - opt Optimizer to use (function mapping a once-iterable of tensors to an optimizer instance) - steps Number of optimization epochs to perform (1 epoch/step) - Returns: - Step at which optimization stopped + super().__init__() + + def forward( + self, input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor + ) -> torch.Tensor: + """ + Compute weighted MSE loss. + + Parameters + ---------- + input : torch.Tensor + Input tensor. + target : torch.Tensor + Target tensor. + weight : torch.Tensor + Weight tensor. + + Returns + ------- + torch.Tensor + Weighted MSE loss value. + """ + return weighted_mse_loss(input, target, weight) + + +# ---------------------------------------------------------------------------- # +# Regression helper + + +def regression( + func: Callable[[torch.Tensor, dict], torch.Tensor], + vars, + data, + loss=None, + opt=None, + steps=1000, +) -> float: """ - # Prepare - tni = data[0] - tne = data[1] - opt = opt(vars) - # Optimize - for step in range(steps): - with torch.enable_grad(): - opt.zero_grad() - res = loss(func(tni), tne) - if torch.isnan(res).any().item(): - return step - res.backward() - opt.step() - return steps + Generic optimization for free variables. + + Parameters + ---------- + func : callable + Function to optimize. Takes variables and data dictionary as arguments. + vars : list + List of variables to optimize. + data : dict + Data dictionary, must contain a ``"target"`` key. + loss : torch.nn.Module, optional + Loss function. Defaults to ``torch.nn.MSELoss``. + opt : torch.optim.Optimizer, optional + Optimizer. Defaults to ``torch.optim.Adam``. + steps : int, optional + Number of optimization steps. Defaults to 1000. + + Returns + ------- + float + Final loss value after optimization. + """ + if loss is None: + loss = torch.nn.MSELoss() + if opt is None: + opt = torch.optim.Adam(vars) + for _ in range(steps): + opt.zero_grad() + result = func(vars, data) + l = loss(result, data["target"]) + l.backward() + opt.step() + return l.item() # ---------------------------------------------------------------------------- # -# Save image as PGM/PBM stream - - -def pnm(fd, tn): - """Save a 2D/3D tensor as a PGM/PBM stream. - Args: - fd File descriptor opened for writing binary streams - tn A 2D/3D tensor to convert and save - Notes: - The input tensor is "intelligently" squeezed before processing - For 2D tensor, assuming black is 1. and white is 0. (clamp between [0, 1]) - For 3D tensor, the first dimension must be the 3 color channels RGB (all between [0, 1]) +# PNM export + + +def pnm(fd: io.BufferedWriter, tn: torch.Tensor) -> None: + """ + Export tensor to PGM/PBM format. + + Parameters + ---------- + fd : io.BufferedWriter + File descriptor to write to. + tn : torch.Tensor + Tensor to export. Supports float32/float64 for grayscale (PGM) or + boolean/integer for binary (PBM). + + Notes + ----- + - Grayscale format (PGM): For float32/float64 tensors, normalizes to 0-255. + - Binary format (PBM): For other dtypes, converts to binary values. """ - shape = tuple(tn.shape) - # Intelligent squeezing - while len(tn.shape) > 3 and tn.shape[0] == 1: - tn = tn[0] - # Colored image generation - if len(tn.shape) == 3: - if tn.shape[0] == 1: - tn = tn[0] - # And continue on gray-scale - elif tn.shape[0] != 3: - raise tools.UserException( - f"Expected 3 color channels for the first dimension of a 3D tensor, got {tn.shape[0]} channels" - ) - else: - fd.write((f"P6\n{tn.shape[1]} {tn.shape[2]} 255\n").encode()) - fd.write(bytes(tn.transpose(0, 2).transpose(0, 1).mul(256).clamp_(0.0, 255.0).byte().storage())) - return - # Gray-scale image generation - if len(tn.shape) == 2: - fd.write((f"P5\n{tn.shape[0]} {tn.shape[1]} 255\n").encode()) - fd.write(bytes((1.0 - tn).mul_(256).clamp_(0.0, 255.0).byte().storage())) - return - # Invalid tensor shape - raise tools.UserException(f"Expected a 2D or 3D tensor, got {len(shape)} dimensions {tuple(shape)!r}") + if tn.dtype == torch.float32 or tn.dtype == torch.float64: + # Grayscale + m = tn.min().item() + M = tn.max().item() + if M - m < 1e-8: + M = m + 1 + t = ((tn - m) / (M - m) * 255).byte().cpu() + fd.write(f"P5\n{tn.shape[1]}\n{tn.shape[0]}\n255\n") + fd.write(t.numpy().tobytes()) + else: + # Binary + t = (tn > 0).byte().cpu() + fd.write(f"P4\n{tn.shape[1]}\n{tn.shape[0]}\n") + # Pad to byte boundary + w = (tn.shape[1] + 7) // 8 + pad = w * 8 - tn.shape[1] + for row in t: + row = torch.cat([row, torch.zeros(pad, dtype=torch.uint8)]) + fd.write(row.view(-1).numpy().tobytes()) diff --git a/pyproject.toml b/pyproject.toml index 76ed48b..2b573f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,52 +73,30 @@ exclude = [ fix = true show-fixes = true preview = true -explicit-preview-rules = true [tool.ruff.lint] +explicit-preview-rules = true select = [ "E", # pycodestyle errors "F", # Pyflakes "I", # isort - "N", # pep8-naming "W", # pycodestyle warnings - "UP", # pyupgrade "B", # flake8-bugbear "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "A", # flake8-builtins - "COM", # flake8-commas - "ISC", # flake8-implicit-str-concat - "ICN", # flake8-import-conventions - "G", # flake8-logging-format - "T20", # flake8-print "RET", # flake8-return - "C90", # mccabe complexity - "TCH", # flake8-type-checking - "FA", # flake8-future-annotations - "PGH", # pygrep-hooks - "RUF", # ruff-specific rules - "PERF", # perflint - "PLR", # pylint refactor - "PLW", # pylint warnings ] ignore = [ "E402", # module level import not at top of file (intentional in this codebase) "E501", # line too long (handled by formatter) - "N818", # exception naming (UserException, StopTrainingLoop are intentional) - "COM812",# trailing comma missing (can conflict with formatter) - "ISC001",# implicit str concat (can conflict with formatter) + "UP031", # percent format (legacy codebase, too noisy to fix now) ] -[tool.ruff.lint.pydocstyle] -convention = "google" - -[tool.ruff.lint.mccabe] -max-complexity = 15 - [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] # unused imports (re-exports) +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/reproduce.py b/reproduce.py index a0afc91..c2d755f 100644 --- a/reproduce.py +++ b/reproduce.py @@ -103,11 +103,11 @@ def check_make_dir(path): # Preprocess/resolve the devices to use if args.devices == "auto": if torch.cuda.is_available(): - args.devices = list(f"cuda:{i}" for i in range(torch.cuda.device_count())) + args.devices = [f"cuda:{i}" for i in range(torch.cuda.device_count())] else: args.devices = ["cpu"] else: - args.devices = list(name.strip() for name in args.devices.split(",")) + args.devices = [name.strip() for name in args.devices.split(",")] # ---------------------------------------------------------------------------- # # Serial preloading of the dataset @@ -188,15 +188,15 @@ def make_command(params): # Check if exit requested before going to plotting the results if exit_is_requested(): - exit(0) + sys.exit(0) # ---------------------------------------------------------------------------- # # Produce graphs # Import additional modules try: - import numpy - import pandas + import numpy as np + import pandas as pd import histogram except ImportError as err: @@ -224,13 +224,13 @@ def make_df(col): nonlocal datas # For every selected columns subds = tuple(histogram.select(data, col).dropna() for data in datas) - res = pandas.DataFrame(index=subds[0].index) + res = pd.DataFrame(index=subds[0].index) for col in subds[0]: # Generate compound column names avgn = col + avgs errn = col + errs # Compute compound columns - numds = numpy.stack(tuple(subd[col].to_numpy() for subd in subds)) + numds = np.stack(tuple(subd[col].to_numpy() for subd in subds)) res[avgn] = numds.mean(axis=0) res[errn] = numds.std(axis=0) # Return the built data frame @@ -242,12 +242,12 @@ def make_df(col): with tools.Context("plot", "info"): # Plot all the experiments - for ds, dsa in (("svm-phishing", None),): - for md, mda in (("simples-logit", "din:68"),): + for ds, _dsa in (("svm-phishing", None),): + for md, _mda in (("simples-logit", "din:68"),): for epsilon in (None, 0.1, 0.2, 0.5): for batch_size in (10, 25, 50, 100, 250, 500): - legend = list() - results = list() + legend = [] + results = [] # Pre-process results for all available combinations of GAR and attack for gar, attacks in ( ("average", (("nan", None),)), diff --git a/train.py b/train.py index d500958..65942a2 100644 --- a/train.py +++ b/train.py @@ -168,7 +168,7 @@ def process_commandline(): for name in ("gar", "attack", "model", "dataset", "loss", "criterion"): name = f"{name}_args" keyval = getattr(args, name) - setattr(args, name, dict() if keyval is None else tools.parse_keyval(keyval)) + setattr(args, name, {} if keyval is None else tools.parse_keyval(keyval)) # Count the number of real honest workers args.nb_honests = args.nb_workers - args.nb_real_byz if args.nb_honests < 0: @@ -233,7 +233,7 @@ def cmd_make_tree(subtree, level=0): "Batch size", ( ("Training", args.batch_size or "max"), - ("Testing", f"{args.batch_size_test or 'max'} × {args.test_repeat}"), + ("Testing", f"{args.batch_size_test or 'max'} x {args.test_repeat}"), ), ), ("Transforms", "none" if args.no_transform else "default"), @@ -347,9 +347,9 @@ def result_store(fd, *entries): reproducible = args.seed >= 0 if reproducible: torch.manual_seed(args.seed) - import numpy + import numpy as np - numpy.random.seed(args.seed) + np.random.seed(args.seed) torch.backends.cudnn.deterministic = reproducible torch.backends.cudnn.benchmark = not reproducible # Configurations @@ -426,7 +426,7 @@ def result_store(fd, *entries): except Exception as err: tools.warning(f"Unable to create the result directory {str(resdir)!r} ({err}); no result will be stored") else: - result_fds = dict() + result_fds = {} try: # Make evaluation file if args.evaluation_delta > 0: @@ -461,11 +461,11 @@ def convert_to_supported_json_type(x): return list(x) return str(x) - datargs = dict( - (name, convert_to_supported_json_type(getattr(args, name))) + datargs = { + name: convert_to_supported_json_type(getattr(args, name)) for name in dir(args) if len(name) > 0 and name[0] != "_" - ) + } del convert_to_supported_json_type json.dump(datargs, fd, ensure_ascii=False, indent="\t") except Exception as err: @@ -552,11 +552,11 @@ class StopTrainingLoop(Exception): was_training = True # ------------------------------------------------------------------------ # # Compute (honest) losses (if it makes sense), gradients and voting data - grad_honests = list() - loss_honests = list() + grad_honests = [] + loss_honests = [] # For each honest worker with atc_gradient: - for i in range(args.nb_honests): + for _i in range(args.nb_honests): grad, loss = model.backprop(outloss=True) grad = grad.clone().detach_() # Loss append @@ -578,10 +578,10 @@ class StopTrainingLoop(Exception): ) # Move the honest gradients to the GAR device if config_gar is not config: - grad_honests_gar = list( + grad_honests_gar = [ grad.to(device=config_gar["device"], non_blocking=config_gar["non_blocking"]) for grad in grad_honests - ) + ] else: grad_honests_gar = grad_honests # ------------------------------------------------------------------------ # @@ -678,7 +678,7 @@ class StopTrainingLoop(Exception): # Print and store timing counters with tools.Context("perf", "info"): - perfs = dict() + perfs = {} perf_params = ( (atc_gradient, "grad", "Gradient computation (per worker)", args.nb_honests), (atc_noise, "noise", "Noise addition (per worker)", args.nb_honests), From d25dd52153608f3d352396c72b0801d643ca5940 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:36:11 +0200 Subject: [PATCH 10/30] fix imports --- histogram.py | 14 ++++++++------ krum/experiments/__init__.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/histogram.py b/histogram.py index 2ac45c3..80b5926 100644 --- a/histogram.py +++ b/histogram.py @@ -19,6 +19,8 @@ import matplotlib.pyplot as plt +import pandas as pd + from krum import aggregators, tools # Change common font for the default LaTeX one @@ -62,7 +64,7 @@ def gtk_main(): gtk_lazy_main = thread # Submit the job to the main loop GLib.idle_add(closure) -except Exception: +except Exception as err: def gtk_run(closure): """Sink in case GTK cannot be used. @@ -220,7 +222,7 @@ def __init__(self, path_results): # Load training data path_study = path_results / "study" try: - data_study = pandas.read_csv( + data_study = pd.read_csv( path_study, sep="\t", index_col=0, na_values=" nan" ) data_study.index.name = "Step number" @@ -232,7 +234,7 @@ def __init__(self, path_results): # Load evaluation data path_eval = path_results / "eval" try: - data_eval = pandas.read_csv(path_eval, sep="\t", index_col=0) + data_eval = pd.read_csv(path_eval, sep="\t", index_col=0) data_eval.index.name = "Step number" except Exception as err: tools.warning( @@ -457,7 +459,7 @@ def include(self, data, *cols, errs=None, lalp=1.0, ccnt=None): # Recover the dataframe if a session was given if isinstance(data, Session): data = data.data - elif not isinstance(data, pandas.DataFrame): + elif not isinstance(data, pd.DataFrame): raise RuntimeError( f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}" ) @@ -531,7 +533,7 @@ def include_single(self, data, key, col, err=None, lalp=1.0, ccnt=None): # Recover the dataframe if a session was given if isinstance(data, Session): data = data.data - elif not isinstance(data, pandas.DataFrame): + elif not isinstance(data, pd.DataFrame): raise RuntimeError( f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}" ) @@ -719,7 +721,7 @@ def include(self, data): self """ # Convert 'pandas.Series' to numpy - if isinstance(data, pandas.Series): + if isinstance(data, pd.Series): data = data.to_numpy() # Make the histogram self._ax.hist(data, bins=self._bins) diff --git a/krum/experiments/__init__.py b/krum/experiments/__init__.py index 810698b..8e535c4 100644 --- a/krum/experiments/__init__.py +++ b/krum/experiments/__init__.py @@ -46,7 +46,7 @@ import pathlib -import tools +from krum import tools # ---------------------------------------------------------------------------- # # Load all local modules From c152b58275f3f2eaf0a08416160d20cdf02f57ca Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:47:10 +0200 Subject: [PATCH 11/30] Fix imports --- krum/aggregators/__init__.py | 2 +- krum/aggregators/brute.py | 2 +- krum/aggregators/bulyan.py | 2 +- krum/aggregators/krum.py | 2 +- krum/aggregators/median.py | 2 +- krum/attacks/__init__.py | 2 +- krum/attacks/identical.py | 2 +- krum/experiments/checkpoint.py | 2 +- krum/experiments/configuration.py | 2 +- krum/experiments/dataset.py | 2 +- krum/experiments/datasets/svm.py | 7 +++--- krum/experiments/loss.py | 2 +- krum/experiments/model.py | 2 +- krum/experiments/optimizer.py | 2 +- krum/tools/misc.py | 36 +++++++++++++++---------------- 15 files changed, 34 insertions(+), 35 deletions(-) diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index b067aad..c00624c 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -48,7 +48,7 @@ import pathlib from collections.abc import Callable -import tools +from .. import tools import torch # ---------------------------------------------------------------------------- # diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index 9462210..d58187c 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -65,7 +65,7 @@ import itertools import math -import tools +from .. import tools import torch from . import register diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 7a44c93..7a654ef 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -76,7 +76,7 @@ import math -import tools +from .. import tools import torch from . import register diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py index d53d943..5350534 100644 --- a/krum/aggregators/krum.py +++ b/krum/aggregators/krum.py @@ -83,7 +83,7 @@ import math -import tools +from .. import tools import torch from . import register diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py index 40115b0..e821645 100644 --- a/krum/aggregators/median.py +++ b/krum/aggregators/median.py @@ -61,7 +61,7 @@ import math -import tools +from .. import tools import torch from . import register diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index 46f36de..e17cd33 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -47,7 +47,7 @@ import pathlib from collections.abc import Callable -import tools +from .. import tools import torch # ---------------------------------------------------------------------------- # diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py index 3134163..00b2e09 100644 --- a/krum/attacks/identical.py +++ b/krum/attacks/identical.py @@ -82,7 +82,7 @@ import math from collections.abc import Callable -import tools +from .. import tools import torch from . import register diff --git a/krum/experiments/checkpoint.py b/krum/experiments/checkpoint.py index 5fa5968..18167d0 100644 --- a/krum/experiments/checkpoint.py +++ b/krum/experiments/checkpoint.py @@ -35,7 +35,7 @@ import copy import pathlib -import tools +from .. import tools import torch from .model import Model diff --git a/krum/experiments/configuration.py b/krum/experiments/configuration.py index fac8483..f0d6836 100644 --- a/krum/experiments/configuration.py +++ b/krum/experiments/configuration.py @@ -35,7 +35,7 @@ from collections.abc import Mapping -import tools +from .. import tools import torch # ---------------------------------------------------------------------------- # diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index 632a359..80f23a9 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -40,7 +40,7 @@ import tempfile import types -import tools +from .. import tools import torch import torchvision diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py index 3566679..52529ff 100644 --- a/krum/experiments/datasets/svm.py +++ b/krum/experiments/datasets/svm.py @@ -34,9 +34,8 @@ __all__ = ["phishing"] -import experiments import requests -import tools +from .. import dataset, tools import torch # ---------------------------------------------------------------------------- # @@ -48,7 +47,7 @@ ) #: Default directory where pre-processed datasets are cached. -default_root = experiments.dataset.Dataset.get_default_root() +default_root = dataset.Dataset.get_default_root() # ---------------------------------------------------------------------------- # # Dataset lazy-loaders @@ -193,6 +192,6 @@ def phishing(train=True, batch_size=None, root=None, download=False, *args, **kw root or default_root, None if download is None else default_url_phishing, ) - return experiments.batch_dataset( + return dataset.batch_dataset( inputs, labels, train, batch_size, split=8400 ) diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py index 52da420..203fcbf 100644 --- a/krum/experiments/loss.py +++ b/krum/experiments/loss.py @@ -32,7 +32,7 @@ __all__ = ["Criterion", "Loss"] -import tools +from .. import tools import torch # ---------------------------------------------------------------------------- # diff --git a/krum/experiments/model.py b/krum/experiments/model.py index c8d701e..6851a35 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -36,7 +36,7 @@ import pathlib import types -import tools +from .. import tools import torch import torchvision diff --git a/krum/experiments/optimizer.py b/krum/experiments/optimizer.py index dddc2ee..1b38315 100644 --- a/krum/experiments/optimizer.py +++ b/krum/experiments/optimizer.py @@ -30,7 +30,7 @@ __all__ = ["Optimizer"] -import tools +from .. import tools import torch # ---------------------------------------------------------------------------- # diff --git a/krum/tools/misc.py b/krum/tools/misc.py index 53647de..d8ce7a2 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -84,7 +84,7 @@ import time import traceback -import tools +from . import UserException, Context, trace, warning, fatal # ---------------------------------------------------------------------------- # # Unavailable user exception class @@ -134,10 +134,10 @@ def fatal_unavailable(*args, **kwargs) -> None: Keyword arguments forwarded to :func:`make_unavailable_exception_text`. """ - tools.fatal(make_unavailable_exception_text(*args, **kwargs)) + fatal(make_unavailable_exception_text(*args, **kwargs)) -class UnavailableException(tools.UserException): +class UnavailableException(UserException): """User-facing exception raised when a selected registry entry is missing.""" def __init__(self, *args, **kwargs) -> None: @@ -304,7 +304,7 @@ def instantiate(self, name: str, *args, **kwargs) -> object: Raises ------ - tools.UserException + UserException If ``name`` is not registered. """ # Assertions @@ -320,7 +320,7 @@ def instantiate(self, name: str, *args, **kwargs) -> object: + ("', '").join(self.__register.keys()) + "'" ) - raise tools.UserException(cause) + raise UserException(cause) # Instantiation return self.__register[name](*args, **kwargs) @@ -389,7 +389,7 @@ def parse_keyval( Raises ------ - tools.UserException + UserException If an entry is malformed, a key is provided more than once, or conversion to a default value's type fails. @@ -409,7 +409,7 @@ def parse_keyval( for entry in list_keyval: pos = entry.find(sep) if pos < 0: - raise tools.UserException( + raise UserException( "Expected list of " + repr(":") + ", got " @@ -418,7 +418,7 @@ def parse_keyval( ) key = entry[:pos] if key in parsed: - raise tools.UserException( + raise UserException( "Key " + repr(key) + " had already been specified with value " @@ -434,7 +434,7 @@ def parse_keyval( else: val = cls(val) except Exception: - raise tools.UserException( + raise UserException( "Required key " + repr(key) + " expected a value of type " @@ -574,7 +574,7 @@ def setter(*args, **kwargs): # Plain context augmented with simple execution time measurement -class TimedContext(tools.Context): +class TimedContext(Context): """Context manager that logs the elapsed runtime of a block.""" def __init__(self, *args, **kwargs) -> None: @@ -584,9 +584,9 @@ def __init__(self, *args, **kwargs) -> None: Parameters ---------- *args : object - Positional arguments forwarded to ``tools.Context``. + Positional arguments forwarded to ``Context``. **kwargs : object - Keyword arguments forwarded to ``tools.Context``. + Keyword arguments forwarded to ``Context``. """ super().__init__(*args, **kwargs) @@ -597,7 +597,7 @@ def __enter__(self): Returns ------- object - Value returned by ``tools.Context.__enter__``. + Value returned by ``Context.__enter__``. """ self._chrono = time.time() return super().__enter__() @@ -609,9 +609,9 @@ def __exit__(self, *args, **kwargs) -> None: Parameters ---------- *args : object - Positional arguments forwarded to ``tools.Context.__exit__``. + Positional arguments forwarded to ``Context.__exit__``. **kwargs : object - Keyword arguments forwarded to ``tools.Context.__exit__``. + Keyword arguments forwarded to ``Context.__exit__``. """ # Measure elapsed runtime (in ns) runtime = (time.time() - self._chrono) * 1000000000.0 @@ -623,7 +623,7 @@ def __exit__(self, *args, **kwargs) -> None: else: unit = "s" # Format and print string - tools.trace(f"Execution time: {runtime:.3g} {unit}") + trace(f"Execution time: {runtime:.3g} {unit}") # Forward call super().__exit__(*args, **kwargs) @@ -662,7 +662,7 @@ def interactive( except Exception: caller = None if glbs is None: - tools.warning( + warning( "Unable to recover caller's frame, locals and globals", context="interactive", ) @@ -716,7 +716,7 @@ def interactive( else: # Multi-line statement is complete exec(command, glbs, lcls) except Exception: - with tools.Context("uncaught", "error"): + with Context("uncaught", "error"): traceback.print_exc() command = "" statement = False From cf632a4d0f5f20ca2b90934218b886f9ce2547f6 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:58:45 +0200 Subject: [PATCH 12/30] Refactor job runner and tidy imports Rework krum/tools/jobs.py: replace previous Command/Jobs stubs with a real worker pool, Command.build, job submission that repeats seeds, and proper shutdown/wait semantics. Update dict_to_cmdlist behavior for lists and booleans. Also standardize import ordering and spacing across modules, relax compute_avg_dev_max return annotation to allow None, and remove an unused exception variable in histogram.py. --- histogram.py | 3 +- krum/aggregators/__init__.py | 3 +- krum/aggregators/brute.py | 2 +- krum/aggregators/bulyan.py | 2 +- krum/aggregators/krum.py | 2 +- krum/aggregators/median.py | 2 +- krum/attacks/__init__.py | 3 +- krum/attacks/identical.py | 2 +- krum/experiments/checkpoint.py | 2 +- krum/experiments/configuration.py | 3 +- krum/experiments/dataset.py | 3 +- krum/experiments/datasets/svm.py | 10 +- krum/experiments/loss.py | 3 +- krum/experiments/model.py | 6 +- krum/experiments/optimizer.py | 3 +- krum/tools/jobs.py | 374 ++++++++++++++---------------- krum/tools/misc.py | 2 +- krum/tools/pytorch.py | 11 +- 18 files changed, 220 insertions(+), 216 deletions(-) diff --git a/histogram.py b/histogram.py index 80b5926..05b7abf 100644 --- a/histogram.py +++ b/histogram.py @@ -18,7 +18,6 @@ import threading import matplotlib.pyplot as plt - import pandas as pd from krum import aggregators, tools @@ -64,7 +63,7 @@ def gtk_main(): gtk_lazy_main = thread # Submit the job to the main loop GLib.idle_add(closure) -except Exception as err: +except Exception: def gtk_run(closure): """Sink in case GTK cannot be used. diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index c00624c..29490f6 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -48,9 +48,10 @@ import pathlib from collections.abc import Callable -from .. import tools import torch +from .. import tools + # ---------------------------------------------------------------------------- # # Automated GAR loader diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index d58187c..2456e00 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -65,9 +65,9 @@ import itertools import math -from .. import tools import torch +from .. import tools from . import register # Optional 'native' module diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 7a654ef..4e320b7 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -76,9 +76,9 @@ import math -from .. import tools import torch +from .. import tools from . import register # Optional 'native' module diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py index 5350534..b72ab21 100644 --- a/krum/aggregators/krum.py +++ b/krum/aggregators/krum.py @@ -83,9 +83,9 @@ import math -from .. import tools import torch +from .. import tools from . import register # Optional 'native' module diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py index e821645..85d4450 100644 --- a/krum/aggregators/median.py +++ b/krum/aggregators/median.py @@ -61,9 +61,9 @@ import math -from .. import tools import torch +from .. import tools from . import register # Optional 'native' module diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index e17cd33..e0801a1 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -47,9 +47,10 @@ import pathlib from collections.abc import Callable -from .. import tools import torch +from .. import tools + # ---------------------------------------------------------------------------- # # Automated attack loader diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py index 00b2e09..9723cc5 100644 --- a/krum/attacks/identical.py +++ b/krum/attacks/identical.py @@ -82,9 +82,9 @@ import math from collections.abc import Callable -from .. import tools import torch +from .. import tools from . import register # ---------------------------------------------------------------------------- # diff --git a/krum/experiments/checkpoint.py b/krum/experiments/checkpoint.py index 18167d0..1509f67 100644 --- a/krum/experiments/checkpoint.py +++ b/krum/experiments/checkpoint.py @@ -35,9 +35,9 @@ import copy import pathlib -from .. import tools import torch +from .. import tools from .model import Model from .optimizer import Optimizer diff --git a/krum/experiments/configuration.py b/krum/experiments/configuration.py index f0d6836..e8ad6f5 100644 --- a/krum/experiments/configuration.py +++ b/krum/experiments/configuration.py @@ -35,9 +35,10 @@ from collections.abc import Mapping -from .. import tools import torch +from .. import tools + # ---------------------------------------------------------------------------- # # Trivial tensor configuration holder (dtype, device, ...) class diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index 80f23a9..ad521c6 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -40,10 +40,11 @@ import tempfile import types -from .. import tools import torch import torchvision +from .. import tools + # ---------------------------------------------------------------------------- # # Default image transformations diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py index 52529ff..309e250 100644 --- a/krum/experiments/datasets/svm.py +++ b/krum/experiments/datasets/svm.py @@ -35,9 +35,10 @@ __all__ = ["phishing"] import requests -from .. import dataset, tools import torch +from .. import dataset, tools + # ---------------------------------------------------------------------------- # # Configuration @@ -107,6 +108,13 @@ def get_phishing(root, url): tools.info("Downloading dataset...", end="", flush=True) try: response = requests.get(url) + except requests.exceptions.SSLError: + tools.warning(" SSL verification failed, retrying without verification...", end="", flush=True) + try: + response = requests.get(url, verify=False) + except Exception as err: + tools.warning(" fail.") + raise RuntimeError(f"Unable to get dataset (at {url}): {err}") except Exception as err: tools.warning(" fail.") raise RuntimeError(f"Unable to get dataset (at {url}): {err}") diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py index 203fcbf..7d45197 100644 --- a/krum/experiments/loss.py +++ b/krum/experiments/loss.py @@ -32,9 +32,10 @@ __all__ = ["Criterion", "Loss"] -from .. import tools import torch +from .. import tools + # ---------------------------------------------------------------------------- # # Loss wrapper class diff --git a/krum/experiments/model.py b/krum/experiments/model.py index 6851a35..36ec8b6 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -36,10 +36,10 @@ import pathlib import types -from .. import tools import torch import torchvision +from .. import tools from .configuration import Configuration # ---------------------------------------------------------------------------- # @@ -188,7 +188,7 @@ def _get_inits(cls): def __init__( self, name_build, - config=Configuration(), + config=None, init_multi=None, init_multi_args=None, init_mono=None, @@ -218,6 +218,8 @@ def __init__( **kwargs : object Forwarded to the model constructor. """ + if config is None: + config = Configuration() def make_init(name, args): inits = type(self)._get_inits() diff --git a/krum/experiments/optimizer.py b/krum/experiments/optimizer.py index 1b38315..5143619 100644 --- a/krum/experiments/optimizer.py +++ b/krum/experiments/optimizer.py @@ -30,9 +30,10 @@ __all__ = ["Optimizer"] -from .. import tools import torch +from .. import tools + # ---------------------------------------------------------------------------- # # Optimizer wrapper class diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py index 1d7905d..cfa8776 100644 --- a/krum/tools/jobs.py +++ b/krum/tools/jobs.py @@ -12,77 +12,30 @@ # Simple job management for reproduction scripts. ### -""" -Experiment job management helpers. - -This module provides utilities for running and managing experiment jobs in a -reproducible manner. - -Classes and Functions ---------------------- - -Job orchestration - ``Command`` encapsulates a command with seed, device, and result-directory - arguments. - ``Jobs`` manages parallel execution of experiments on multiple devices. - -Helpers - ``dict_to_cmdlist`` converts dictionaries into command-line argument lists. - ``move_directory`` moves an existing directory aside with versioning. - -Example -------- - -.. code-block:: python - - from tools import Command, Jobs, dict_to_cmdlist - - cmd = Command(["python", "train.py", "--lr", "0.01"]) - jobs = Jobs("./results", devices=["cuda:0", "cuda:1"]) - jobs.submit("exp1", cmd) - jobs.wait() - jobs.close() -""" - __all__ = ["Command", "Jobs", "dict_to_cmdlist"] +import shlex +import subprocess import threading from pathlib import Path +from krum import tools + # ---------------------------------------------------------------------------- # # Helpers def move_directory(path: Path) -> Path: - """ - Move an existing directory aside with versioning. - - If a directory already exists at the given path, it is renamed with an - incremental suffix (for example, ``results.0``, ``results.1``) before a - new directory is created. - - Parameters - ---------- - path : pathlib.Path - Directory path to move aside if it already exists. - - Returns - ------- - pathlib.Path - The input path, returned unchanged for chaining. - - Example - ------- - >>> from pathlib import Path - >>> move_directory(Path("results")) - # Moves existing "results" to "results.0" if it exists + """Move existing directory to a new location (with a numbering scheme). + Args: + path Path to the directory to create + Returns: + 'path' (to enable chaining) """ # Move directory if it exists if path.exists(): if not path.is_dir(): - raise RuntimeError( - f"Expected to find nothing or (a symlink to) a directory at {str(path)!r}" - ) + raise RuntimeError(f"Expected to find nothing or (a symlink to) a directory at {str(path)!r}") i = 0 while True: mvpath = path.parent / f"{path.name}.{i}" @@ -94,173 +47,206 @@ def move_directory(path: Path) -> Path: return path -def dict_to_cmdlist(dp: dict) -> list[str]: - """ - Convert a dictionary into command-line arguments. - - This helper is useful for turning experiment configurations into CLI - arguments. - - Parameters - ---------- - dp : dict - Dictionary mapping parameter names to values. - - Returns - ------- - list of str - Command-line arguments such as ``["--lr", "0.01", "--batch", "32"]``. - - Notes - ----- - - Boolean values are included only when they are ``True``. - - Lists and tuples expand to repeated ``--name value`` pairs. - - Example - ------- - >>> dict_to_cmdlist({"lr": 0.01, "batch": 32, "debug": True}) - ['--lr', '0.01', '--batch', '32', '--debug'] - >>> dict_to_cmdlist({"layers": [64, 128]}) - ['--layers', '64', '--layers', '128'] +def dict_to_cmdlist(dp: dict) -> list: + """Transform a dictionary into a list of command arguments. + Args: + dp Dictionary mapping parameter name (to prepend with "--") to parameter value (to convert to string) + Returns: + Associated list of command arguments + Notes: + For entries mapping to 'bool', the parameter is included/discarded depending on whether the value is True/False + For entries mapping to 'list' or 'tuple', the parameter is followed by all the values as strings """ cmd = [] for name, value in dp.items(): if isinstance(value, bool): if value: cmd.append(f"--{name}") - elif isinstance(value, (list, tuple)): - for v in value: - cmd.append(f"--{name}") - cmd.append(str(v)) - else: + elif any(isinstance(value, typ) for typ in (list, tuple)): + cmd.append(f"--{name}") + for subval in value: + cmd.append(str(subval)) + elif value is not None: cmd.append(f"--{name}") cmd.append(str(value)) return cmd # ---------------------------------------------------------------------------- # -# Command wrapper +# Job command class class Command: - """ - Command wrapper that adds standard runtime arguments. + """Simple job command class, that builds a command from a dictionary of parameters.""" - This class wraps a base command and automatically appends seed, device, and - result-directory arguments when executing it. - """ - - def __init__( - self, - base: list[str], - seed: int | None = None, - device: str | None = None, - result_directory: Path | None = None, - ) -> None: - """ - Initialize the command wrapper. - - Parameters - ---------- - base : list of str - Base command as a list of strings. - seed : int, optional - Random seed to add. - device : str, optional - Device to add, for example ``"cuda:0"``. - result_directory : pathlib.Path, optional - Result directory path to add. + def __init__(self, command): + """Bind constructor. + Args: + command Command iterable (will be copied) """ - self._base = base - self._seed = seed - self._device = device - self._result_directory = result_directory - - def __call__(self) -> list[str]: - """ - Build the full command list with optional runtime arguments. - - Returns - ------- - list of str - Base command extended with ``--seed``, ``--device``, and - ``--result-directory`` when they were provided at initialization. + self._basecmd = list(command) + + def build(self, seed, device, resdir): + """Build the final command line. + Args: + seed Seed to use + device Device to use + resdir Target directory path + Returns: + Final command list """ - cmd = list(self._base) - if self._seed is not None: - cmd.extend(["--seed", str(self._seed)]) - if self._device is not None: - cmd.extend(["--device", self._device]) - if self._result_directory is not None: - cmd.extend(["--result-directory", str(self._result_directory)]) + # Build final command list + cmd = self._basecmd.copy() + for name, value in (("seed", seed), ("device", device), ("result-directory", resdir)): + cmd.append(f"--{name}") + cmd.append(shlex.quote(value if isinstance(value, str) else str(value))) + # Return final command list return cmd # ---------------------------------------------------------------------------- # -# Jobs management +# Job class class Jobs: - """ - Job execution manager for parallel experiments. - - Manages parallel execution of experiments across multiple devices, - with support for result tracking and error handling. - """ - - def __init__( - self, result_directory: Path, devices: list[str] | None = None, devmult: int = 1 - ) -> None: + """Take experiments to run and runs them on the available devices, managing repetitions.""" + + @staticmethod + def _run(topdir, name, seed, device, command): + """Run the attack experiments with the given named parameters. + Args: + topdir Parent result directory + name Experiment unique name + seed Experiment seed + device Device on which to run the experiments + command Command to run """ - Initialize jobs manager. - - Parameters - ---------- - result_directory : pathlib.Path - Directory to store results. - devices : list of str, optional - List of device names (e.g., ["cuda:0", "cuda:1"]). - Defaults to CPU if none specified. - devmult : int, optional - Number of parallel jobs per device. Default is 1. + # Add seed to name + name = f"{name}-{seed}" + # Process experiment + with tools.Context(name, "info"): + finaldir = topdir / name + # Check whether the experiment was already successful + if finaldir.exists(): + tools.info("Experiment already processed.") + return + # Move-make the pending result directory + resdir = move_directory(topdir / f"{name}.pending") + resdir.mkdir(mode=0o755, parents=True) + # Build the command + args = command.build(seed, device, resdir) + # Launch the experiment and write the standard output/error + tools.trace((" ").join(shlex.quote(arg) for arg in args)) + cmd_res = subprocess.run(args, check=False, capture_output=True) + if cmd_res.returncode == 0: + tools.info("Experiment successful") + else: + tools.warning("Experiment failed") + finaldir = topdir / f"{name}.failed" + move_directory(finaldir) + resdir.rename(finaldir) + (finaldir / "stdout.log").write_bytes(cmd_res.stdout) + (finaldir / "stderr.log").write_bytes(cmd_res.stderr) + + def _worker_entrypoint(self, device): + """Worker entry point. + Args: + device Device to use """ - self._result_directory = result_directory - self._devices = devices or ["cpu"] - self._devmult = devmult - self._pending = [] + while True: + # Take a pending experiment, or exit if requested + with self._lock: + while True: + # Check if must exit + if self._jobs is None: + return + # Check and pick the first pending experiment, if available + if len(self._jobs) > 0: + name, seed, command = self._jobs.pop() + break + # Wait for new job notification + self._cvready.wait() + # Run the picked experiment + self._run(self._res_dir, name, seed, device, command) + + def __init__(self, res_dir, devices=["cpu"], devmult=1, seeds=tuple(range(1, 6))): + """Initialize the instance, launch the worker pool. + Args: + res_dir Path to the directory containing the result sub-directories + devices List/tuple of the devices to use in parallel + devmult How many experiments are run in parallel per device + seeds List/tuple of seeds to repeat the experiments with + """ + # Initialize instance + self._res_dir = res_dir + self._jobs = [] # List of tuples (name, seed, command), or None to signal termination + self._workers = [] # Worker pool, one per target device + self._devices = devices + self._seeds = seeds self._lock = threading.Lock() - - def submit(self, name: str, command: list[str]) -> None: + self._cvready = threading.Condition( + lock=self._lock + ) # Signal jobs have been added and must be processed, or the worker must quit + self._cvdone = threading.Condition(lock=self._lock) # Signal jobs have all been processed + # Launch the worker pool + for _ in range(devmult): + for device in devices: + thread = threading.Thread(target=self._worker_entrypoint, name=device, args=(device,)) + thread.start() + self._workers.append(thread) + + def get_seeds(self): + """Get the list of seeds used for repeating the experiments. + Returns: + List/tuple of seeds used """ - Submit a job for execution. + return self._seeds - Parameters - ---------- - name : str - Job identifier. - command : list of str - Full command to execute (as returned by ``Command.__call__``). - """ + def close(self): + """Close and wait for the worker pool, discarding not yet started submission.""" + # Close the manager with self._lock: - self._pending.append((name, command)) - - def wait(self, exit_is_requested: bool | None = None) -> None: - """ - Wait for all pending jobs to complete. - - Parameters - ---------- - exit_is_requested : bool or None, optional - Optional external flag to request early termination. - """ - # Implementation depends on threading - pass - - def close(self) -> None: + # Check if already closed + if self._jobs is None: + return + # Reset submission list + self._jobs = None + # Notify all the workers + self._cvready.notify_all() + # Wait for all the workers + for worker in self._workers: + worker.join() + + def submit(self, name, command): + """Submit an experiment to be run with each seed on any available device. + Args: + name Experiment unique name + command Command to process """ - Close the jobs manager and release resources. - - Notes - ----- - No-op in the current stub implementation. + with self._lock: + # Check if not closed + if self._jobs is None: + raise RuntimeError("Experiment manager cannot take new jobs as it has been closed") + # Submit the experiment with each seed + for seed in self._seeds: + self._jobs.insert(0, (name, seed, command)) + self._cvready.notify(n=len(self._seeds)) + + def wait(self, predicate=None): + """Wait for all the submitted jobs to be processed. + Args: + predicate Custom predicate to call to check whether must stop waiting """ + while True: + with self._lock: + # Wait for condition or timeout + self._cvdone.wait(timeout=1.0) + # Check status + if self._jobs is None: + break + if len(self._jobs) == 0: + break + if not any(worker.is_alive() for worker in self._workers): + break + if predicate is not None and predicate(): + break diff --git a/krum/tools/misc.py b/krum/tools/misc.py index d8ce7a2..725f2bb 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -84,7 +84,7 @@ import time import traceback -from . import UserException, Context, trace, warning, fatal +from . import Context, UserException, fatal, trace, warning # ---------------------------------------------------------------------------- # # Unavailable user exception class diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index 6637849..79a1012 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -246,7 +246,7 @@ def grads_of(tensors: list[torch.Tensor]): def compute_avg_dev_max( samples: list[torch.Tensor], -) -> tuple[torch.Tensor, float, float, float]: +) -> tuple[torch.Tensor | None, float, float, float]: """ Compute average, average norm, norm deviation, and max absolute value. @@ -265,6 +265,9 @@ def compute_avg_dev_max( ----- The returned tensor is newly created and does not alias any input tensor. """ + # Handle empty list gracefully + if len(samples) == 0: + return None, float("nan"), float("nan"), float("nan") # Stack all samples stacked = torch.stack(samples) # Compute average tensor @@ -469,10 +472,10 @@ def regression( for _ in range(steps): opt.zero_grad() result = func(vars, data) - l = loss(result, data["target"]) - l.backward() + loss_func = loss(result, data["target"]) + loss_func.backward() opt.step() - return l.item() + return loss_func.item() # ---------------------------------------------------------------------------- # From f576cb206989ed78c7be9cd759f6cc3f9781a0c2 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 10:59:20 +0200 Subject: [PATCH 13/30] Format code --- docs/conf.py | 9 ++- histogram.py | 107 ++++++++---------------------- krum/aggregators/__init__.py | 8 +-- krum/aggregators/average.py | 8 +-- krum/aggregators/brute.py | 22 ++---- krum/aggregators/bulyan.py | 39 +++-------- krum/aggregators/krum.py | 35 +++------- krum/aggregators/median.py | 4 +- krum/attacks/__init__.py | 4 +- krum/attacks/identical.py | 17 ++--- krum/attacks/nan.py | 8 +-- krum/experiments/checkpoint.py | 11 +-- krum/experiments/configuration.py | 4 +- krum/experiments/dataset.py | 31 ++------- krum/experiments/datasets/svm.py | 16 ++--- krum/experiments/loss.py | 18 ++--- krum/experiments/model.py | 21 ++---- krum/experiments/optimizer.py | 4 +- krum/tools/__init__.py | 22 ++---- krum/tools/misc.py | 54 +++------------ krum/tools/pytorch.py | 8 +-- train.py | 90 ++++++++++++------------- 22 files changed, 156 insertions(+), 384 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4516185..c67d3d8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,9 +30,10 @@ "sphinx.ext.intersphinx", "sphinx_favicon", "sphinx_togglebutton", - "sphinx_contributors" + "sphinx_contributors", ] + def linkcode_resolve(domain, info): """Return a URL to the source code on GitHub for the given object.""" if domain != "py": @@ -90,9 +91,7 @@ def linkcode_resolve(domain, info): # Use MathJax to render math in HTML -mathjax_path = ( - "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML" -) +mathjax_path = "https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] @@ -158,7 +157,7 @@ def linkcode_resolve(domain, info): { "title": "How to add a custom dataset", "url": "how-to/add-custom-dataset", - } + }, ], }, { diff --git a/histogram.py b/histogram.py index 05b7abf..b4bd97e 100644 --- a/histogram.py +++ b/histogram.py @@ -63,6 +63,7 @@ def gtk_main(): gtk_lazy_main = thread # Submit the job to the main loop GLib.idle_add(closure) + except Exception: def gtk_run(closure): @@ -196,17 +197,13 @@ def __init__(self, path_results): path_results = pathlib.Path(path_results) # Ensure directory exist if not path_results.exists(): - raise tools.UserException( - f"Result directory {str(path_results)!r} cannot be accessed or does not exist" - ) + raise tools.UserException(f"Result directory {str(path_results)!r} cannot be accessed or does not exist") # Load configuration string path_config = path_results / "config" try: data_config = path_config.read_text().strip() except Exception as err: - tools.warning( - f"Result directory {str(path_results)!r}: unable to read configuration ({err})" - ) + tools.warning(f"Result directory {str(path_results)!r}: unable to read configuration ({err})") data_config = None # Load configuration json path_json = path_results / "config.json" @@ -214,21 +211,15 @@ def __init__(self, path_results): with path_json.open("r") as fd: data_json = json.load(fd) except Exception as err: - tools.warning( - f"Result directory {str(path_results)!r}: unable to read JSON configuration ({err})" - ) + tools.warning(f"Result directory {str(path_results)!r}: unable to read JSON configuration ({err})") data_json = None # Load training data path_study = path_results / "study" try: - data_study = pd.read_csv( - path_study, sep="\t", index_col=0, na_values=" nan" - ) + data_study = pd.read_csv(path_study, sep="\t", index_col=0, na_values=" nan") data_study.index.name = "Step number" except Exception as err: - tools.warning( - f"Result directory {str(path_results)!r}: unable to read training data ({err})" - ) + tools.warning(f"Result directory {str(path_results)!r}: unable to read training data ({err})") data_study = None # Load evaluation data path_eval = path_results / "eval" @@ -236,9 +227,7 @@ def __init__(self, path_results): data_eval = pd.read_csv(path_eval, sep="\t", index_col=0) data_eval.index.name = "Step number" except Exception as err: - tools.warning( - f"Result directory {str(path_results)!r}: unable to read evaluation data ({err})" - ) + tools.warning(f"Result directory {str(path_results)!r}: unable to read evaluation data ({err})") data_eval = None # Merge data frames data = None @@ -279,9 +268,7 @@ def display(self, *only_columns, name=None): # Display the (selected sub)set display( self.get(*only_columns), - title=( - "Session data{} for {!r}".format(" (subset)" if len(only_columns) > 0 else "", self.name) - ), + title=("Session data{} for {!r}".format(" (subset)" if len(only_columns) > 0 else "", self.name)), ) # Return self to enable chaining return self @@ -292,9 +279,7 @@ def has_known_ratio(self): Whether the session's GAR has a known ratio """ if self.json is None or "gar" not in self.json: - tools.warning( - "No valid JSON-formatted configuration, cannot tell whether the associated GAR has a ratio" - ) + tools.warning("No valid JSON-formatted configuration, cannot tell whether the associated GAR has a ratio") return False g = self.json["gar"] rule = aggregators.gars.get(g, None) @@ -325,9 +310,7 @@ def compute_epoch(self): return self # Compute epoch number if self.json is None or "dataset" not in self.json: - tools.warning( - "No valid JSON-formatted configuration, cannot compute the epoch number" - ) + tools.warning("No valid JSON-formatted configuration, cannot compute the epoch number") return self dataset_name = self.json["dataset"] training_size = { @@ -337,9 +320,7 @@ def compute_epoch(self): "cifar100": 50000, }.get(dataset_name) if training_size is None: - tools.warning( - f"Unknown dataset {dataset_name!r}, cannot compute the epoch number" - ) + tools.warning(f"Unknown dataset {dataset_name!r}, cannot compute the epoch number") return self self.data[column_name] = self.data["Training point count"] / training_size # Return self to enable chaining @@ -356,17 +337,13 @@ def compute_lr(self): return self # Compute epoch number if self.json is None or "learning_rate" not in self.json: - tools.warning( - "No valid JSON-formatted configuration, cannot compute the learning rate" - ) + tools.warning("No valid JSON-formatted configuration, cannot compute the learning rate") return self lr = self.json["learning_rate"] lr_decay = self.json.get("learning_rate_decay", 0) lr_delta = self.json.get("learning_rate_decay_delta", 1) if lr_decay > 0: - self.data[column_name] = lr / ( - (self.data.index // lr_delta * lr_delta) / lr_decay + 1 - ) + self.data[column_name] = lr / ((self.data.index // lr_delta * lr_delta) / lr_decay + 1) else: self.data[column_name] = lr # Return self to enable chaining @@ -452,24 +429,18 @@ def include(self, data, *cols, errs=None, lalp=1.0, ccnt=None): """ # Assert not already finalized if self._fin: - raise RuntimeError( - "Plot is already finalized and cannot include another line" - ) + raise RuntimeError("Plot is already finalized and cannot include another line") # Recover the dataframe if a session was given if isinstance(data, Session): data = data.data elif not isinstance(data, pd.DataFrame): - raise RuntimeError( - f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}" - ) + raise RuntimeError(f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}") # Get the x-axis values if self._idx is None: x = data.index.to_numpy() else: if self._idx not in data: - raise RuntimeError( - f"No column named {self._idx!r} to use as index in the given session/dataframe" - ) + raise RuntimeError(f"No column named {self._idx!r} to use as index in the given session/dataframe") x = data[self._idx].to_numpy() # Select semantic: empty list = select all if len(cols) == 0: @@ -491,20 +462,14 @@ def include(self, data, *cols, errs=None, lalp=1.0, ccnt=None): if axis is None: axis = self._get_ax(col) # Pick a new line style and color - linestyle, color = self._get_line_style( - self._cnt if ccnt is None else ccnt - ) + linestyle, color = self._get_line_style(self._cnt if ccnt is None else ccnt) # Plot the data (line or error line) davg = subd[scol].to_numpy() errn = None if errs is None else (scol + errs) if errn is not None and errn in data: derr = data[errn].to_numpy() - axis.fill_between( - x, davg - derr, davg + derr, facecolor=color, alpha=0.2 - ) - axis.plot( - x, davg, label=scol, linestyle=linestyle, color=color, alpha=lalp - ) + axis.fill_between(x, davg - derr, davg + derr, facecolor=color, alpha=0.2) + axis.plot(x, davg, label=scol, linestyle=linestyle, color=color, alpha=lalp) # Increase the counter only on success self._cnt += 1 # Reset axis for next iteration @@ -526,24 +491,18 @@ def include_single(self, data, key, col, err=None, lalp=1.0, ccnt=None): """ # Assert not already finalized if self._fin: - raise RuntimeError( - "Plot is already finalized and cannot include another line" - ) + raise RuntimeError("Plot is already finalized and cannot include another line") # Recover the dataframe if a session was given if isinstance(data, Session): data = data.data elif not isinstance(data, pd.DataFrame): - raise RuntimeError( - f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}" - ) + raise RuntimeError(f"Expected a Session or DataFrame for 'data', got a {tools.fullqual(type(data))!r}") # Get the x-axis values if self._idx is None: x = data.index.to_numpy() else: if self._idx not in data: - raise RuntimeError( - f"No column named {self._idx!r} to use as index in the given session/dataframe" - ) + raise RuntimeError(f"No column named {self._idx!r} to use as index in the given session/dataframe") x = data[self._idx].to_numpy() # Pick a new line style and color linestyle, color = self._get_line_style(self._cnt if ccnt is None else ccnt) @@ -614,14 +573,8 @@ def generator_sum(gen): return res (self._ax if self._tax is None else self._tax).legend( - generator_sum( - ax.get_legend_handles_labels()[0] for ax in self._axs.values() - ), - generator_sum( - ax.get_legend_handles_labels()[1] for ax in self._axs.values() - ) - if legend is None - else legend, + generator_sum(ax.get_legend_handles_labels()[0] for ax in self._axs.values()), + generator_sum(ax.get_legend_handles_labels()[1] for ax in self._axs.values()) if legend is None else legend, loc="best", ) # Plot the grid and labels @@ -631,15 +584,11 @@ def generator_sum(gen): self._ax.set_title(title) if zlabel is not None: if self._tax is None: - tools.warning( - f"No secondary y-axis found, but its label {zlabel!r} was provided" - ) + tools.warning(f"No secondary y-axis found, but its label {zlabel!r} was provided") else: self._tax.set_ylabel(zlabel) elif self._tax is not None: - tools.warning( - f"No label provided for the secondary y-axis; using label {ylabel!r} from the primary" - ) + tools.warning(f"No label provided for the secondary y-axis; using label {ylabel!r} from the primary") self._tax.set_ylabel(ylabel) self._ax.set_xlim(left=xmin, right=xmax) self._ax.set_ylim(bottom=ymin, top=ymax) @@ -727,9 +676,7 @@ def include(self, data): # Return self for chaining return self - def finalize( - self, title, xlabel, ylabel, xmin=None, xmax=None, ymin=None, ymax=None - ): + def finalize(self, title, xlabel, ylabel, xmin=None, xmax=None, ymin=None, ymax=None): """Finalize the plot, can be done only once and would prevent further inclusion. Args: title Plot title diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index 29490f6..816ffdb 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -93,9 +93,7 @@ def checked(**kwargs): # Check parameter validity message = check(**kwargs) if message is not None: - raise tools.UserException( - f"Aggregation rule {name!r} cannot be used with the given parameters: {message}" - ) + raise tools.UserException(f"Aggregation rule {name!r} cannot be used with the given parameters: {message}") # Aggregation (hard to assert return value, duck-typing is allowed...) return unchecked(**kwargs) @@ -140,9 +138,7 @@ def register( tools.warning(f"Unable to register {name!r} GAR: name already in use") return # Export the selected function with the associated name - gars[name] = make_gar( - unchecked, check, upper_bound=upper_bound, influence=influence - ) + gars[name] = make_gar(unchecked, check, upper_bound=upper_bound, influence=influence) # Registered rules (mapping name -> aggregation rule) diff --git a/krum/aggregators/average.py b/krum/aggregators/average.py index d8e573e..92804f0 100644 --- a/krum/aggregators/average.py +++ b/krum/aggregators/average.py @@ -91,15 +91,11 @@ def check(gradients: list[torch.Tensor], **kwargs) -> str | None: message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return ( - f"Expected a list of at least one gradient to aggregate, got {gradients!r}" - ) + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" return None -def influence( - honests: list[torch.Tensor], attacks: list[torch.Tensor], **kwargs -) -> float: +def influence(honests: list[torch.Tensor], attacks: list[torch.Tensor], **kwargs) -> float: """ Compute the ratio of accepted Byzantine gradients. diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index 2456e00..5bd0620 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -80,9 +80,7 @@ # Brute GAR -def _compute_selection( - gradients: list[torch.Tensor], f: int, **kwargs -) -> tuple[int, ...]: +def _compute_selection(gradients: list[torch.Tensor], f: int, **kwargs) -> tuple[int, ...]: """ Select the gradient indices forming the smallest-diameter subset. @@ -163,9 +161,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | return sum(gradients[i] for i in sel_iset).div_(len(gradients) - f) -def aggregate_native( - gradients: list[torch.Tensor], f: int, **kwargs -) -> torch.Tensor | float: +def aggregate_native(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | float: """ Compute the Brute aggregation using native C++/CUDA acceleration. @@ -206,13 +202,11 @@ def check(gradients: list[torch.Tensor], f: int, **kwargs) -> str | None: message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return ( - f"Expected a list of at least one gradient to aggregate, got {gradients!r}" - ) + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 1: - return ( - "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" - % (f, (len(gradients) - 1) // 2) + return "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" % ( + f, + (len(gradients) - 1) // 2, ) return None @@ -239,9 +233,7 @@ def upper_bound(n: int, f: int, d: int) -> float: return (n - f) / (math.sqrt(8) * f) -def influence( - honests: list[torch.Tensor], attacks: list[torch.Tensor], f: int, **kwargs -) -> float: +def influence(honests: list[torch.Tensor], attacks: list[torch.Tensor], f: int, **kwargs) -> float: """ Compute the ratio of Byzantine gradients selected by Brute. diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 4e320b7..3cfc601 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -142,9 +142,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. scores[gid] = (sum(dist for dist, _ in dists), gid) distances[gid] = dict(dists) # Selection loop - selected = torch.empty( - n - 2 * f - 2, d, dtype=gradients[0].dtype, device=gradients[0].device - ) + selected = torch.empty(n - 2 * f - 2, d, dtype=gradients[0].dtype, device=gradients[0].device) for i in range(selected.shape[0]): # Update 'm' m = min(m, m_max - i) @@ -160,23 +158,13 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. # Coordinate-wise averaged median m = selected.shape[0] - 2 * f median = selected.median(dim=0).values - closests = ( - selected.clone() - .sub_(median) - .abs_() - .topk(m, dim=0, largest=False, sorted=False) - .indices - ) - closests.mul_(d).add_( - torch.arange(0, d, dtype=closests.dtype, device=closests.device) - ) + closests = selected.clone().sub_(median).abs_().topk(m, dim=0, largest=False, sorted=False).indices + closests.mul_(d).add_(torch.arange(0, d, dtype=closests.dtype, device=closests.device)) return selected.take(closests).mean(dim=0) # Return resulting gradient -def aggregate_native( - gradients: list[torch.Tensor], f: int, m=None, **kwargs -) -> torch.Tensor: +def aggregate_native(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch.Tensor: """ Compute the Bulyan aggregate using native C++/CUDA acceleration. @@ -229,21 +217,14 @@ def check(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> str | None message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return ( - f"Expected a list of at least one gradient to aggregate, got {gradients!r}" - ) + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" if not isinstance(f, int) or f < 1 or len(gradients) < 4 * f + 3: - return ( - "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" - % (f, (len(gradients) - 3) // 4) - ) - if m is not None and ( - not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2 - ): - return ( - "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" - % (f, len(gradients) - f - 2) + return "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" % ( + f, + (len(gradients) - 3) // 4, ) + if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): + return "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" % (f, len(gradients) - f - 2) return None diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py index b72ab21..cabec40 100644 --- a/krum/aggregators/krum.py +++ b/krum/aggregators/krum.py @@ -98,9 +98,7 @@ # Multi-Krum GAR -def _compute_scores( - gradients: list[torch.Tensor], f: int, m: int, **kwargs -) -> list[tuple[float, torch.Tensor]]: +def _compute_scores(gradients: list[torch.Tensor], f: int, m: int, **kwargs) -> list[tuple[float, torch.Tensor]]: """ Compute Multi-Krum scores for all candidate gradients. @@ -146,9 +144,7 @@ def _compute_scores( return scores -def aggregate( - gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs -) -> torch.Tensor: +def aggregate(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> torch.Tensor: """ Aggregate gradients with Multi-Krum. @@ -182,9 +178,7 @@ def aggregate( return sum(grad for _, grad in scores[:m]).div_(m) -def aggregate_native( - gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs -) -> torch.Tensor: +def aggregate_native(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> torch.Tensor: """ Aggregate gradients with the native Multi-Krum implementation. @@ -211,9 +205,7 @@ def aggregate_native( return native.krum.aggregate(gradients, f, m) -def check( - gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs -) -> str | None: +def check(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> str | None: """ Check whether Multi-Krum can be used with the given parameters. @@ -235,21 +227,14 @@ def check( message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return ( - f"Expected a list of at least one gradient to aggregate, got {gradients!r}" - ) + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" if not isinstance(f, int) or f < 1 or len(gradients) < 2 * f + 3: - return ( - "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" - % (f, (len(gradients) - 3) // 2) - ) - if m is not None and ( - not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2 - ): - return ( - "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" - % (m, len(gradients) - f - 2) + return "Invalid number of Byzantine gradients to tolerate, got f = %r, expected 1 ≤ f ≤ %d" % ( + f, + (len(gradients) - 3) // 2, ) + if m is not None and (not isinstance(m, int) or m < 1 or m > len(gradients) - f - 2): + return "Invalid number of selected gradients, got m = %r, expected 1 ≤ m ≤ %d" % (m, len(gradients) - f - 2) return None diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py index 85d4450..93589fb 100644 --- a/krum/aggregators/median.py +++ b/krum/aggregators/median.py @@ -144,9 +144,7 @@ def check(gradients: list[torch.Tensor], **kwargs) -> str | None: message. """ if not isinstance(gradients, list) or len(gradients) < 1: - return ( - f"Expected a list of at least one gradient to aggregate, got {gradients!r}" - ) + return f"Expected a list of at least one gradient to aggregate, got {gradients!r}" return None diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index e0801a1..155d7c3 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -86,9 +86,7 @@ def checked(f_real, **kwargs): # Check parameter validity message = check(f_real=f_real, **kwargs) if message is not None: - raise tools.UserException( - f"Attack {name!r} cannot be used with the given parameters: {message}" - ) + raise tools.UserException(f"Attack {name!r} cannot be used with the given parameters: {message}") # Attack res = unchecked(f_real=f_real, **kwargs) # Forward asserted return value diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py index 9723cc5..de0b9a1 100644 --- a/krum/attacks/identical.py +++ b/krum/attacks/identical.py @@ -220,18 +220,11 @@ def check( if not isinstance(grad_honests, list) or len(grad_honests) == 0: return f"Expected a non-empty list of honest gradients, got {grad_honests!r}" if not isinstance(f_real, int) or f_real < 0: - return ( - f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" - ) + return f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" if not callable(defense): return f"Expected a callable for the aggregation rule, got {defense!r}" - if not ( - (isinstance(factor, float) and factor > 0) - or (isinstance(factor, int) and factor != 0) - ): - return ( - f"Expected a positive number or a negative integer for the attack factor, got {factor!r}" - ) + if not ((isinstance(factor, float) and factor > 0) or (isinstance(factor, int) and factor != 0)): + return f"Expected a positive number or a negative integer for the attack factor, got {factor!r}" if not isinstance(negative, bool): return f"Expected a boolean for optional parameter 'negative', got {negative!r}" return None @@ -272,9 +265,7 @@ def bulyan( """ if target_idx == "all": return torch.ones_like(grad_avg) - assert isinstance(target_idx, int), ( - f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" - ) + assert isinstance(target_idx, int), f"Expected an integer or \"all\" for 'target_idx', got {target_idx!r}" grad_att = torch.zeros_like(grad_avg) grad_att[target_idx] = 1 return grad_att diff --git a/krum/attacks/nan.py b/krum/attacks/nan.py index e78b6ea..7a90aa0 100644 --- a/krum/attacks/nan.py +++ b/krum/attacks/nan.py @@ -54,9 +54,7 @@ # Non-finite gradient attack -def attack( - grad_honests: list[torch.Tensor], f_real: int, **kwargs -) -> list[torch.Tensor]: +def attack(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> list[torch.Tensor]: """ Generate NaN-valued Byzantine gradients. @@ -109,9 +107,7 @@ def check(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> str | None if not isinstance(grad_honests, list) or len(grad_honests) == 0: return f"Expected a non-empty list of honest gradients, got {grad_honests!r}" if not isinstance(f_real, int) or f_real < 0: - return ( - f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" - ) + return f"Expected a non-negative number of Byzantine gradients to generate, got {f_real!r}" return None diff --git a/krum/experiments/checkpoint.py b/krum/experiments/checkpoint.py index 1509f67..755dffe 100644 --- a/krum/experiments/checkpoint.py +++ b/krum/experiments/checkpoint.py @@ -103,8 +103,7 @@ def _prepare(cls, instance): for prop in ("state_dict", "load_state_dict"): if not callable(getattr(res, prop, None)): raise tools.UserException( - f"Given instance {instance!r} is not checkpoint-able " - f"(missing callable member {prop!r})" + f"Given instance {instance!r} is not checkpoint-able (missing callable member {prop!r})" ) # Return the instance and the associated storage key return res, tools.fullqual(inst_cls) @@ -147,9 +146,7 @@ def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): instance, key = type(self)._prepare(instance) # Snapshot the state dictionary if not overwrite and key in self._store: - raise tools.UserException( - f"A snapshot for {key!r} is already stored in the checkpoint" - ) + raise tools.UserException(f"A snapshot for {key!r} is already stored in the checkpoint") if deepcopy: self._store[key] = copy.deepcopy(instance.state_dict()) else: @@ -193,9 +190,7 @@ def restore(self, instance, nothrow=False): f"may not be the one expected" ) elif not nothrow: - raise tools.UserException( - f"No snapshot for {key!r} is available in the checkpoint" - ) + raise tools.UserException(f"No snapshot for {key!r} is available in the checkpoint") # Enable chaining return self diff --git a/krum/experiments/configuration.py b/krum/experiments/configuration.py index e8ad6f5..71fa269 100644 --- a/krum/experiments/configuration.py +++ b/krum/experiments/configuration.py @@ -173,7 +173,5 @@ def __repr__(self): Python-code string that evaluates to this configuration. """ display = {"non_blocking": "noblock"} - argrepr = (", ").join( - f"{display.get(key, key)}={val!r}" for key, val in self._args.items() - ) + argrepr = (", ").join(f"{display.get(key, key)}={val!r}" for key, val in self._args.items()) return f"Configuration({argrepr}, relink={self.relink})" diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index ad521c6..b4e2d97 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -59,9 +59,7 @@ transforms_cifar = [ torchvision.transforms.RandomHorizontalFlip(), torchvision.transforms.ToTensor(), - torchvision.transforms.Normalize( - (0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010) - ), + torchvision.transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ] # Per-dataset image transformations @@ -224,17 +222,13 @@ def add_custom_datasets(name, module, _): exports = getattr(module, "__all__", None) if exports is None: tools.warning( - f"Dataset module {name!r} does not provide '__all__'; " - f"falling back to '__dict__' for name discovery" + f"Dataset module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery" ) exports = (n for n in dir(module) if len(n) > 0 and n[0] != "_") exported = False for dataset in exports: if not isinstance(dataset, str): - tools.warning( - f"Dataset module {name!r} exports non-string name " - f"{dataset!r}; ignored" - ) + tools.warning(f"Dataset module {name!r} exports non-string name {dataset!r}; ignored") continue constructor = getattr(module, dataset, None) if not callable(constructor): @@ -249,10 +243,7 @@ def add_custom_datasets(name, module, _): continue cls.__datasets[fullname] = constructor if not exported: - tools.warning( - f"Dataset module {name!r} does not export any valid " - f"constructor name through '__all__'" - ) + tools.warning(f"Dataset module {name!r} does not export any valid constructor name through '__all__'") with tools.Context("datasets", None): tools.import_directory( @@ -332,10 +323,7 @@ def sample(self, config=None): """ tns = next(self._iter) if config is not None: - tns = type(tns)( - tn.to(device=config["device"], non_blocking=config["non_blocking"]) - for tn in tns - ) + tns = type(tns)(tn.to(device=config["device"], non_blocking=config["non_blocking"]) for tn in tns) return tns def epoch(self, config=None): @@ -450,17 +438,12 @@ def make_datasets( """ train_transforms = train_transforms or get_default_transform(dataset, True) test_transforms = test_transforms or get_default_transform(dataset, False) - num_workers_errmsg = ( - "Expected either a positive int or a tuple of 2 positive ints " - "for parameter 'num_workers'" - ) + num_workers_errmsg = "Expected either a positive int or a tuple of 2 positive ints for parameter 'num_workers'" if isinstance(num_workers, int): assert num_workers > 0, num_workers_errmsg train_workers = test_workers = num_workers else: - assert isinstance(num_workers, tuple) and len(num_workers) == 2, ( - num_workers_errmsg - ) + assert isinstance(num_workers, tuple) and len(num_workers) == 2, num_workers_errmsg train_workers, test_workers = num_workers assert isinstance(train_workers, int) and train_workers > 0, num_workers_errmsg assert isinstance(test_workers, int) and test_workers > 0, num_workers_errmsg diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py index 309e250..c849228 100644 --- a/krum/experiments/datasets/svm.py +++ b/krum/experiments/datasets/svm.py @@ -43,9 +43,7 @@ # Configuration #: Default URL for the raw phishing dataset (LIBSVM format). -default_url_phishing = ( - "https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/phishing" -) +default_url_phishing = "https://www.csie.ntu.edu.tw/~cjlin/libsvmtools/datasets/binary/phishing" #: Default directory where pre-processed datasets are cached. default_root = dataset.Dataset.get_default_root() @@ -120,9 +118,7 @@ def get_phishing(root, url): raise RuntimeError(f"Unable to get dataset (at {url}): {err}") tools.info(" done.") if response.status_code != 200: - raise RuntimeError( - f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}" - ) + raise RuntimeError(f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}") # Pre-process dataset tools.info("Pre-processing dataset...", end="", flush=True) @@ -141,9 +137,7 @@ def get_phishing(root, url): line[int(offset) - 1] = float(value) except Exception as err: tools.warning(" fail.") - raise RuntimeError( - f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}" - ) + raise RuntimeError(f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}") labels.unsqueeze_(1) tools.info(" done.") @@ -200,6 +194,4 @@ def phishing(train=True, batch_size=None, root=None, download=False, *args, **kw root or default_root, None if download is None else default_url_phishing, ) - return dataset.batch_dataset( - inputs, labels, train, batch_size, split=8400 - ) + return dataset.batch_dataset(inputs, labels, train, batch_size, split=8400) diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py index 7d45197..fd00f02 100644 --- a/krum/experiments/loss.py +++ b/krum/experiments/loss.py @@ -404,19 +404,11 @@ def __call__(self, output, target): torch.Tensor 1-D tensor ``[num_correct, batch_size]``. """ - res = ( - (output.topk(self.k, dim=1)[1] == target.view(-1).unsqueeze(1)) - .any(dim=1) - .sum() - ) - return torch.cat( - ( - res.unsqueeze(0), - torch.tensor( - target.shape[0], dtype=res.dtype, device=res.device - ).unsqueeze(0), - ) - ) + res = (output.topk(self.k, dim=1)[1] == target.view(-1).unsqueeze(1)).any(dim=1).sum() + return torch.cat(( + res.unsqueeze(0), + torch.tensor(target.shape[0], dtype=res.dtype, device=res.device).unsqueeze(0), + )) class _SigmoidCriterion: """ diff --git a/krum/experiments/model.py b/krum/experiments/model.py index 36ec8b6..837fb32 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -121,17 +121,13 @@ def add_custom_models(name, module, _): exports = getattr(module, "__all__", None) if exports is None: tools.warning( - f"Model module {name!r} does not provide '__all__'; " - f"falling back to '__dict__' for name discovery" + f"Model module {name!r} does not provide '__all__'; falling back to '__dict__' for name discovery" ) exports = (n for n in dir(module) if len(n) > 0 and n[0] != "_") exported = False for model in exports: if not isinstance(model, str): - tools.warning( - f"Model module {name!r} exports non-string name " - f"{model!r}; ignored" - ) + tools.warning(f"Model module {name!r} exports non-string name {model!r}; ignored") continue constructor = getattr(module, model, None) if not callable(constructor): @@ -146,10 +142,7 @@ def add_custom_models(name, module, _): continue cls.__models[fullname] = constructor if not exported: - tools.warning( - f"Model module {name!r} does not export any valid " - f"constructor name through '__all__'" - ) + tools.warning(f"Model module {name!r} does not export any valid constructor name through '__all__'") with tools.Context("models", None): tools.import_directory( @@ -465,9 +458,7 @@ def set_gradient(self, gradient, relink=None): tools.relink(tools.grads_of(self._model.parameters()), gradient) self._gradient = gradient else: - self.get_gradient().copy_( - gradient, non_blocking=self._config["non_blocking"] - ) + self.get_gradient().copy_(gradient, non_blocking=self._config["non_blocking"]) def loss(self, dataset=None, loss=None, training=None): """ @@ -570,8 +561,6 @@ def eval(self, dataset=None, criterion=None): torch.Tensor Mean criterion value over the sampled batch. """ - dataset, criterion = self._resolve_defaults( - testset=dataset, criterion=criterion - ) + dataset, criterion = self._resolve_defaults(testset=dataset, criterion=criterion) inputs, targets = dataset.sample(self._config) return criterion(self.run(inputs), targets) diff --git a/krum/experiments/optimizer.py b/krum/experiments/optimizer.py index 5143619..8a271bc 100644 --- a/krum/experiments/optimizer.py +++ b/krum/experiments/optimizer.py @@ -148,9 +148,7 @@ def __getattr__(self, *args): return getattr(self._optim, args[0]) if len(args) == 2: return getattr(self._optim, args[0], args[1]) - raise RuntimeError( - "'Optimizer.__getattr__' called with the wrong number of parameters" - ) + raise RuntimeError("'Optimizer.__getattr__' called with the wrong number of parameters") def __str__(self): """ diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py index b4f30b7..f62e1d1 100644 --- a/krum/tools/__init__.py +++ b/krum/tools/__init__.py @@ -162,9 +162,7 @@ def __init__(self, cntxtname: str | None, colorname: str | None) -> None: if colorname is None: colorcode = None else: - assert colorname in type(self).__colors, "Unknown color name " + repr( - colorname - ) + assert colorname in type(self).__colors, "Unknown color name " + repr(colorname) colorcode = type(self).__colors[colorname] # Finalization self.__pair = (cntxtname, colorcode) @@ -424,18 +422,11 @@ def import_exported_symbols(name: str, module, scope: dict) -> None: continue if symname in _imported: with Context(None, "warning"): - print( - "Symbol " - + repr(symname) - + " already exported by " - + repr(_imported[symname]) - ) + print("Symbol " + repr(symname) + " already exported by " + repr(_imported[symname])) continue if symname in scope: with Context(None, "warning"): - print( - "Symbol " + repr(symname) + " already exported by '__init__.py'" - ) + print("Symbol " + repr(symname) + " already exported by '__init__.py'") continue # Import in module scope scope[symname] = getattr(module, symname) @@ -479,12 +470,7 @@ def import_directory( post(name, getattr(base, name), scope) except Exception as err: with Context(None, "warning"): - print( - "Loading failed for module " - + repr(path.name) - + ": " - + str(err) - ) + print("Loading failed for module " + repr(path.name) + ": " + str(err)) with Context("traceback", "trace"): traceback.print_exc() diff --git a/krum/tools/misc.py b/krum/tools/misc.py index 725f2bb..48b348e 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -90,9 +90,7 @@ # Unavailable user exception class -def make_unavailable_exception_text( - data: list[str], name: str, what: str = "entry" -) -> str: +def make_unavailable_exception_text(data: list[str], name: str, what: str = "entry") -> str: """ Build the message used by :class:`UnavailableException`. @@ -182,9 +180,7 @@ def __init__(self, *args: object) -> None: Instances on which to replicate method calls, in call order. """ # Assertions - assert len(args) > 0, ( - "Expected at least one instance on which to forward method calls" - ) + assert len(args) > 0, "Expected at least one instance on which to forward method calls" # Finalization self.__instances = args @@ -275,11 +271,7 @@ def register(self, name: str, cls: type) -> None: "Name " + repr(name) + " already in use while registering " - + repr( - getattr( - cls, "__name__", "" - ) - ) + + repr(getattr(cls, "__name__", "")) ) # Registering self.__register[name] = cls @@ -313,13 +305,7 @@ def instantiate(self, name: str, *args, **kwargs) -> object: if len(self.__register) == 0: cause += "no registered " + self.__denoms[0] else: - cause += ( - "available " - + self.__denoms[1] - + ": '" - + ("', '").join(self.__register.keys()) - + "'" - ) + cause += "available " + self.__denoms[1] + ": '" + ("', '").join(self.__register.keys()) + "'" raise UserException(cause) # Instantiation return self.__register[name](*args, **kwargs) @@ -362,9 +348,7 @@ def parse_keyval_auto_convert(val: str) -> object: return val -def parse_keyval( - list_keyval: list[str], defaults: dict[str, object] | None = None -) -> dict[str, object]: +def parse_keyval(list_keyval: list[str], defaults: dict[str, object] | None = None) -> dict[str, object]: """ Parse ``:`` strings into a typed dictionary. @@ -409,21 +393,10 @@ def parse_keyval( for entry in list_keyval: pos = entry.find(sep) if pos < 0: - raise UserException( - "Expected list of " - + repr(":") - + ", got " - + repr(entry) - + " as one entry" - ) + raise UserException("Expected list of " + repr(":") + ", got " + repr(entry) + " as one entry") key = entry[:pos] if key in parsed: - raise UserException( - "Key " - + repr(key) - + " had already been specified with value " - + repr(parsed[key]) - ) + raise UserException("Key " + repr(key) + " had already been specified with value " + repr(parsed[key])) val = entry[pos + len(sep) :] # Guess/assert type constructibility if key in defaults: @@ -685,9 +658,7 @@ def interactive( # Input new line try: line = input() - print( - "\033[A" - ) # Trick to "advertise" new line on stdout after new line on stdin + print("\033[A") # Trick to "advertise" new line on stdout after new line on stdin except BaseException as err: if any(isinstance(err, cls) for cls in (EOFError, KeyboardInterrupt)): print() # Since no new line was printed by pressing ENTER @@ -706,9 +677,7 @@ def interactive( command = line try: exec(command, glbs, lcls) - except ( - SyntaxError - ): # Heuristic that we are dealing with a multi-line statement + except SyntaxError: # Heuristic that we are dealing with a multi-line statement continue elif len(line) > 0: command += os.linesep + line @@ -744,10 +713,7 @@ def get_loaded_dependencies() -> list[tuple[str, str | None, int]]: platform. """ # Get the site-packages directories, and make "flavor"-detection closure - path_sites = tuple( - pathlib.Path(path) - for path in site.getsitepackages() + [site.getusersitepackages()] - ) + path_sites = tuple(pathlib.Path(path) for path in site.getsitepackages() + [site.getusersitepackages()]) def flavor_of(path): path = pathlib.Path(path) diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index 79a1012..d42a874 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -367,9 +367,7 @@ def current_runtime(self) -> float: # Weighted MSE loss -def weighted_mse_loss( - input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor -) -> torch.Tensor: +def weighted_mse_loss(input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: """ Compute weighted mean squared error loss. @@ -407,9 +405,7 @@ def __init__(self) -> None: """ super().__init__() - def forward( - self, input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor - ) -> torch.Tensor: + def forward(self, input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: """ Compute weighted MSE loss. diff --git a/train.py b/train.py index 65942a2..c5bacd7 100644 --- a/train.py +++ b/train.py @@ -217,73 +217,71 @@ def cmd_make_tree(subtree, level=0): res += f"{os.linesep}{level_spc}· {label}{' ' * (label_len - len(label))}{cmd_make_tree(node, level + 1)}" return res - cmdline_config = "Configuration" + cmd_make_tree( + cmdline_config = "Configuration" + cmd_make_tree(( + ("Reproducibility", "not enforced" if args.seed < 0 else f"enforced (seed {args.seed})"), + ("#workers", args.nb_workers), + ("#declared Byz.", args.nb_decl_byz), + ("#actually Byz.", args.nb_real_byz), + ("Model", (("Name", args.model), ("Arguments", args.model_args))), ( - ("Reproducibility", "not enforced" if args.seed < 0 else f"enforced (seed {args.seed})"), - ("#workers", args.nb_workers), - ("#declared Byz.", args.nb_decl_byz), - ("#actually Byz.", args.nb_real_byz), - ("Model", (("Name", args.model), ("Arguments", args.model_args))), + "Dataset", ( - "Dataset", + ("Name", args.dataset), + ("Arguments", args.dataset_args), ( - ("Name", args.dataset), - ("Arguments", args.dataset_args), + "Batch size", ( - "Batch size", - ( - ("Training", args.batch_size or "max"), - ("Testing", f"{args.batch_size_test or 'max'} x {args.test_repeat}"), - ), + ("Training", args.batch_size or "max"), + ("Testing", f"{args.batch_size_test or 'max'} x {args.test_repeat}"), ), - ("Transforms", "none" if args.no_transform else "default"), ), + ("Transforms", "none" if args.no_transform else "default"), ), + ), + ( + "Loss", ( - "Loss", + ("Name", args.loss), + ("Arguments", args.loss_args), ( - ("Name", args.loss), - ("Arguments", args.loss_args), + "Regularization", ( - "Regularization", - ( - ("l1", "none" if args.l1_regularize is None else args.l1_regularize), - ("l2", "none" if args.l2_regularize is None else args.l2_regularize), - ), + ("l1", "none" if args.l1_regularize is None else args.l1_regularize), + ("l2", "none" if args.l2_regularize is None else args.l2_regularize), ), ), ), - ("Criterion", (("Name", args.criterion), ("Arguments", args.criterion_args))), + ), + ("Criterion", (("Name", args.criterion), ("Arguments", args.criterion_args))), + ( + "Optimizer", ( - "Optimizer", + ("Name", "sgd"), ( - ("Name", "sgd"), + "Learning rate", ( - "Learning rate", - ( - ("Initial", args.learning_rate), - ("Half-decay", args.learning_rate_decay if args.learning_rate_decay > 0 else "none"), - ("Update delta", args.learning_rate_decay_delta if args.learning_rate_decay > 0 else "n/a"), - ), + ("Initial", args.learning_rate), + ("Half-decay", args.learning_rate_decay if args.learning_rate_decay > 0 else "none"), + ("Update delta", args.learning_rate_decay_delta if args.learning_rate_decay > 0 else "n/a"), ), - ("Momentum", args.momentum), - ("Dampening", args.dampening), - ("Weight decay", args.weight_decay), ), + ("Momentum", args.momentum), + ("Dampening", args.dampening), + ("Weight decay", args.weight_decay), ), - ("Attack", (("Name", args.attack), ("Arguments", args.attack_args))), - ("Aggregation", (("Name", args.gar), ("Arguments", args.gar_args))), + ), + ("Attack", (("Name", args.attack), ("Arguments", args.attack_args))), + ("Aggregation", (("Name", args.gar), ("Arguments", args.gar_args))), + ( + "Differential privacy", ( - "Differential privacy", - ( - ("Enabled?", "yes" if args.privacy else "no"), - ("ε constant", args.privacy_epsilon if args.privacy else "n/a"), - ("δ constant", args.privacy_delta if args.privacy else "n/a"), - ("l2-sensitivity", args.privacy_sensitivity if args.privacy else "n/a"), - ), + ("Enabled?", "yes" if args.privacy else "no"), + ("ε constant", args.privacy_epsilon if args.privacy else "n/a"), + ("δ constant", args.privacy_delta if args.privacy else "n/a"), + ("l2-sensitivity", args.privacy_sensitivity if args.privacy else "n/a"), ), - ) - ) + ), + )) print(cmdline_config) # ---------------------------------------------------------------------------- # From 72b4e804f9f5f8f617ad4eed40fee07fccd6f60c Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 11:05:31 +0200 Subject: [PATCH 14/30] Remove checks in ci and move them to pre-commit --- .github/workflows/ci.yml | 6 ------ .pre-commit-config.yaml | 2 +- pyproject.toml | 1 + 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f7335c..dfe9913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,3 @@ jobs: run: | uv run python -W error::DeprecationWarning -W error::PendingDeprecationWarning -c "from krum import tools, experiments, aggregators, attacks" uv run python -W error::DeprecationWarning -W error::PendingDeprecationWarning -c "import krum.tools, krum.experiments, krum.aggregators, krum.attacks" - - - name: Lint with Ruff - run: uv run ruff check . - - - name: Check formatting with Ruff - run: uv run ruff format --check . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07adb89..6be960f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.0 + rev: v0.15.12 hooks: - id: ruff args: [--fix] diff --git a/pyproject.toml b/pyproject.toml index 2b573f8..4026d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "pre-commit>=4.0.0", "ruff>=0.9.0", "ty>=0.0.34", "sphinx>=8.1.3", From 6fd0de9caa5a53498dc212e714936b298cd0d99b Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 11:05:47 +0200 Subject: [PATCH 15/30] Fix lint --- histogram.py | 6 +- krum/aggregators/bulyan.py | 2 +- krum/experiments/dataset.py | 2 +- krum/experiments/datasets/svm.py | 6 +- krum/tools/jobs.py | 6 +- krum/tools/misc.py | 2 +- uv.lock | 156 +++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 10 deletions(-) diff --git a/histogram.py b/histogram.py index b4bd97e..25bf41e 100644 --- a/histogram.py +++ b/histogram.py @@ -64,14 +64,15 @@ def gtk_main(): # Submit the job to the main loop GLib.idle_add(closure) -except Exception: +except Exception as _err: + _gtk_err_msg = str(_err) def gtk_run(closure): """Sink in case GTK cannot be used. Args: closure Ignored parameter """ - tools.warning(f"GTK 3.0 is unavailable: {err}") + tools.warning(f"GTK 3.0 is unavailable: {_gtk_err_msg}") # ---------------------------------------------------------------------------- # # Data frame columns selection helper @@ -85,7 +86,6 @@ def select(data, *only_columns): Returns: (Sub-)dataframe, by reference """ - global Session # Unwrap data frame from session if isinstance(data, Session): data = data.data diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 3cfc601..9e4dd19 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -154,7 +154,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. scores[0] = (math.inf, None) for score, gid in scores[1:]: if gid == gid_prune: - scores[gid] = (score - distance[gid][gid_prune], gid) + scores[gid] = (score - distances[gid][gid_prune], gid) # Coordinate-wise averaged median m = selected.shape[0] - 2 * f median = selected.median(dim=0).values diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index b4e2d97..c73693a 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -279,7 +279,7 @@ def __init__(self, data, name=None, root=None, *args, **kwargs): if build is None: raise tools.UnavailableException(datasets, name, what="dataset name") root = root or type(self).get_default_root() - self._iter = build(root=root, *args, **kwargs) + self._iter = build(*args, root=root, **kwargs) elif isinstance(data, types.GeneratorType): if name is None: name = "" diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py index c849228..e5e0dc4 100644 --- a/krum/experiments/datasets/svm.py +++ b/krum/experiments/datasets/svm.py @@ -112,10 +112,10 @@ def get_phishing(root, url): response = requests.get(url, verify=False) except Exception as err: tools.warning(" fail.") - raise RuntimeError(f"Unable to get dataset (at {url}): {err}") + raise RuntimeError(f"Unable to get dataset (at {url}): {err}") from err except Exception as err: tools.warning(" fail.") - raise RuntimeError(f"Unable to get dataset (at {url}): {err}") + raise RuntimeError(f"Unable to get dataset (at {url}): {err}") from err tools.info(" done.") if response.status_code != 200: raise RuntimeError(f"Unable to fetch raw dataset (at {url}): GET status code {response.status_code}") @@ -137,7 +137,7 @@ def get_phishing(root, url): line[int(offset) - 1] = float(value) except Exception as err: tools.warning(" fail.") - raise RuntimeError(f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}") + raise RuntimeError(f"Unable to parse dataset (line {index + 1}, position {pos + 1}): {err}") from err labels.unsqueeze_(1) tools.info(" done.") diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py index cfa8776..b131868 100644 --- a/krum/tools/jobs.py +++ b/krum/tools/jobs.py @@ -169,7 +169,7 @@ def _worker_entrypoint(self, device): # Run the picked experiment self._run(self._res_dir, name, seed, device, command) - def __init__(self, res_dir, devices=["cpu"], devmult=1, seeds=tuple(range(1, 6))): + def __init__(self, res_dir, devices=None, devmult=1, seeds=None): """Initialize the instance, launch the worker pool. Args: res_dir Path to the directory containing the result sub-directories @@ -178,6 +178,10 @@ def __init__(self, res_dir, devices=["cpu"], devmult=1, seeds=tuple(range(1, 6)) seeds List/tuple of seeds to repeat the experiments with """ # Initialize instance + if devices is None: + devices = ["cpu"] + if seeds is None: + seeds = tuple(range(1, 6)) self._res_dir = res_dir self._jobs = [] # List of tuples (name, seed, command), or None to signal termination self._workers = [] # Worker pool, one per target device diff --git a/krum/tools/misc.py b/krum/tools/misc.py index 48b348e..c6e7f18 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -412,7 +412,7 @@ def parse_keyval(list_keyval: list[str], defaults: dict[str, object] | None = No + repr(key) + " expected a value of type " + repr(getattr(type(defaults[key]), "__name__", "")) - ) + ) from None else: val = parse_keyval_auto_convert(val) # Bind (converted) value to associated key diff --git a/uv.lock b/uv.lock index dbd5c55..68dbee1 100644 --- a/uv.lock +++ b/uv.lock @@ -41,6 +41,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -400,6 +409,15 @@ 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]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -507,6 +525,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -679,6 +706,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "pre-commit" }, { name = "ruff" }, { name = "shibuya" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -697,6 +725,7 @@ requires-dist = [ { name = "ninja", specifier = ">=1.13.0" }, { name = "numpy", specifier = ">=2.2.6" }, { name = "pandas", specifier = ">=2.0.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "requests", specifier = ">=2.33.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "shibuya", marker = "extra == 'dev'", specifier = ">=2026.1.9" }, @@ -938,6 +967,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/93/a7b983643d1253bb223234b5b226e69de6cda02b76cdca7770f684b795f5/ninja-1.13.0-py3-none-win_arm64.whl", hash = "sha256:3c0b40b1f0bba764644385319028650087b4c1b18cdfa6f45cb39a3669b81aa9", size = 290806, upload-time = "2025-08-11T15:10:18.018Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -1484,6 +1522,31 @@ wheels = [ { 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 = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1526,6 +1589,19 @@ 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 = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + [[package]] name = "pytz" version = "2026.1.post1" @@ -1535,6 +1611,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.33.1" @@ -2074,6 +2214,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "virtualenv" +version = "21.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, +] + [[package]] name = "wheel" version = "0.47.0" From 49d74d37b344ab7a91c5b6fae1b7a8ce34271ea7 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 11:42:18 +0200 Subject: [PATCH 16/30] Document native compilation and update docs tooling Unignore top-level Makefile so docs commands can be run from repo root Rename docs Makefile target 'servedocs' -> 'serve' and update help text Expand krum/native/README.md with module prefixes, external deps, environment variables, and a short research workflow --- .gitignore | 1 + README.md | 5 ++++- docs/Makefile | 6 +++--- krum/native/README.md | 36 ++++++++++++++++++++++++++++-------- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 2a0da2d..8017938 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ # Except documentation files docs/_build !*.rst +!Makefile !*.html !*.css !*.js diff --git a/README.md b/README.md index acdd5ae..51cc3e5 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,10 @@ Build the documentation locally: ```bash cd docs -make html +make html # +make watch # +make serve # +make clean # ``` ## License diff --git a/docs/Makefile b/docs/Makefile index 0fbf5de..477edf9 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -6,7 +6,7 @@ help: @echo "Available targets:" @echo " html - Build HTML documentation" @echo " clean - Remove generated files" - @echo " servedocs - Build and serve static documentation" + @echo " serve - Build and serve static documentation" @echo " watch - Watch for changes and serve (auto-rebuild)" html: @@ -15,8 +15,8 @@ html: clean: rm -rf _build -servedocs: +serve: uv run sphinx-build -b html . _build/html && uv run python -m http.server 8000 -d _build/html watch: - uv run sphinx-autobuild -b html . _build/html --port 8000 --host 127.0.0.1 \ No newline at end of file + uv run sphinx-autobuild -b html . _build/html --port 8000 --host 127.0.0.1 diff --git a/krum/native/README.md b/krum/native/README.md index 2d5379a..558bd98 100644 --- a/krum/native/README.md +++ b/krum/native/README.md @@ -1,13 +1,33 @@ -# Classification +# Native Acceleration -* `so` -> "Vanilla" shared object: build and load -* `py` -> "Python" shared object: build, load and bind as submodule (e.g. 'py_test' with be available at 'native.test') +The ``native/`` folder contains C++/CUDA sources that compile **automatically at Python import time**. -# Dependencies +## Module prefixes -In dependent SO directory, create a '.deps' file with new line-separated list of dependee SO directory. +- ``so_`` — "Vanilla" shared object: build and load +- ``py_`` — "Python" shared object: build, load and bind as submodule (e.g. ``py_test`` will be available at ``native.test``) -# External dependencies +## Dependencies -* `pip3 install ninja` -* `https://github.com/NVlabs/cub` +In a dependent SO directory, create a ``.deps`` file with a newline-separated list of dependee SO directories. + +## External dependencies + +- ``ninja`` (``pip install ninja``) +- CUB in ``native/include/cub`` (see https://github.com/NVlabs/cub) +- A PyTorch installation with extension compilation headers + +## Environment variables + +- ``NATIVE_STD`` — C++ standard (default: ``c++17``) +- ``NATIVE_OPT`` — debug/release mode +- ``NATIVE_QUIET`` — suppress build messages in release mode + +## Research workflow + +1. Prototype in Python in ``aggregators/`` or ``attacks/`` +2. Validate behaviors and metrics +3. Move the expensive part to ``native/`` if needed +4. Keep exactly the same functional contract + +If native compilation fails, Python fallbacks continue to work with a warning. \ No newline at end of file From 7db0945c4e8b8957b7cc5ba325aa018fa889e326 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 11:42:40 +0200 Subject: [PATCH 17/30] Fix typings --- histogram.py | 12 +- krum/aggregators/__init__.py | 3 +- krum/aggregators/average.py | 2 +- krum/aggregators/brute.py | 6 +- krum/aggregators/bulyan.py | 17 +- krum/aggregators/krum.py | 6 +- krum/aggregators/median.py | 6 +- krum/attacks/__init__.py | 3 +- krum/experiments/__init__.py | 25 ++- krum/experiments/dataset.py | 7 +- krum/experiments/model.py | 6 +- krum/experiments/models/simples.py | 12 +- krum/tools/__init__.py | 101 ++++++++- krum/tools/jobs.py | 316 ++++++++++++++++++++++------- krum/tools/misc.py | 25 ++- krum/tools/pytorch.py | 34 ++-- train.py | 6 +- 17 files changed, 436 insertions(+), 151 deletions(-) diff --git a/histogram.py b/histogram.py index 25bf41e..e1051dd 100644 --- a/histogram.py +++ b/histogram.py @@ -33,10 +33,10 @@ # Common GTK main loop try: - import gi + import gi # type: ignore[import-not-found] gi.require_version("Gtk", "3.0") - from gi.repository import GLib, Gtk + from gi.repository import GLib, Gtk # type: ignore[import-not-found] gtk_lazy_lock = threading.Lock() gtk_lazy_main = None @@ -322,7 +322,7 @@ def compute_epoch(self): if training_size is None: tools.warning(f"Unknown dataset {dataset_name!r}, cannot compute the epoch number") return self - self.data[column_name] = self.data["Training point count"] / training_size + self.data[column_name] = self.data["Training point count"] / training_size # type: ignore[index] # Return self to enable chaining return self @@ -333,7 +333,7 @@ def compute_lr(self): """ column_name = "Learning rate" # Check if already there - if column_name in self.data.columns: + if column_name in self.data.columns: # type: ignore[union-attr] return self # Compute epoch number if self.json is None or "learning_rate" not in self.json: @@ -343,9 +343,9 @@ def compute_lr(self): lr_decay = self.json.get("learning_rate_decay", 0) lr_delta = self.json.get("learning_rate_decay_delta", 1) if lr_decay > 0: - self.data[column_name] = lr / ((self.data.index // lr_delta * lr_delta) / lr_decay + 1) + self.data[column_name] = lr / ((self.data.index // lr_delta * lr_delta) / lr_decay + 1) # type: ignore[index] else: - self.data[column_name] = lr + self.data[column_name] = lr # type: ignore[index] # Return self to enable chaining return self diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index 816ffdb..82adff2 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -47,6 +47,7 @@ import pathlib from collections.abc import Callable +from typing import Any, cast import torch @@ -98,7 +99,7 @@ def checked(**kwargs): return unchecked(**kwargs) # Select which function to call by default - func = checked if __debug__ else unchecked + func = cast(Any, checked if __debug__ else unchecked) # Bind all the (sub) functions to the selected function func.check = check func.checked = checked diff --git a/krum/aggregators/average.py b/krum/aggregators/average.py index 92804f0..27f1640 100644 --- a/krum/aggregators/average.py +++ b/krum/aggregators/average.py @@ -70,7 +70,7 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: ----- The output tensor is a new tensor, not aliasing any input tensor. """ - return sum(gradients) / len(gradients) + return torch.stack(gradients).mean(dim=0) def check(gradients: list[torch.Tensor], **kwargs) -> str | None: diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index 5bd0620..f4cf1e8 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -72,9 +72,9 @@ # Optional 'native' module try: - import native + from krum import native except ImportError: - native = None + native = None # type: ignore[assignment] # ---------------------------------------------------------------------------- # # Brute GAR @@ -179,7 +179,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.T torch.Tensor | float Mean of the subset selected by the native Brute implementation. """ - return native.brute.aggregate(gradients, f) + return native.brute.aggregate(gradients, f) # type: ignore[attr-defined] def check(gradients: list[torch.Tensor], f: int, **kwargs) -> str | None: diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 9e4dd19..6c3708a 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -75,6 +75,7 @@ """ import math +from typing import Any import torch @@ -83,9 +84,9 @@ # Optional 'native' module try: - import native + from krum import native except ImportError: - native = None + native = None # type: ignore[assignment] # ---------------------------------------------------------------------------- # # Bulyan GAR class @@ -126,7 +127,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. if m is None: m = m_max # Compute all pairwise distances - distances = [[(math.inf, None)] * n for _ in range(n)] + distances: list[Any] = [[(math.inf, None)] * n for _ in range(n)] for gid_x, gid_y in tools.pairwise(tuple(range(n))): dist = gradients[gid_x].sub(gradients[gid_y]).norm().item() if not math.isfinite(dist): @@ -134,7 +135,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. distances[gid_x][gid_y] = (dist, gid_y) distances[gid_y][gid_x] = (dist, gid_x) # Compute the scores - scores = [None] * n + scores: list[Any] = [None] * n for gid in range(n): dists = distances[gid] dists.sort(key=lambda x: x[0]) @@ -148,11 +149,11 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. m = min(m, m_max - i) # Compute the average of the selected gradients scores.sort(key=lambda x: x[0]) - selected[i] = sum(gradients[gid] for _, gid in scores[:m]).div_(m) + selected[i] = sum(gradients[gid] for _, gid in scores[:m]).div_(m) # type: ignore[arg-type] # Remove the gradient from the distances and scores - gid_prune = scores[0][1] + gid_prune = scores[0][1] # type: ignore[index] scores[0] = (math.inf, None) - for score, gid in scores[1:]: + for score, gid in scores[1:]: # type: ignore[assignment] if gid == gid_prune: scores[gid] = (score - distances[gid][gid_prune], gid) # Coordinate-wise averaged median @@ -190,7 +191,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> if m is None: m = len(gradients) - f - 2 # Computation - return native.bulyan.aggregate(gradients, f, m) + return native.bulyan.aggregate(gradients, f, m) # type: ignore[attr-defined] def check(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> str | None: diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py index cabec40..f58581c 100644 --- a/krum/aggregators/krum.py +++ b/krum/aggregators/krum.py @@ -90,9 +90,9 @@ # Optional 'native' module try: - import native + from krum import native except ImportError: - native = None + native = None # type: ignore[assignment] # ---------------------------------------------------------------------------- # # Multi-Krum GAR @@ -202,7 +202,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, m: int | None = None if m is None: m = len(gradients) - f - 2 # Computation - return native.krum.aggregate(gradients, f, m) + return native.krum.aggregate(gradients, f, m) # type: ignore[attr-defined] def check(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> str | None: diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py index 93589fb..a277414 100644 --- a/krum/aggregators/median.py +++ b/krum/aggregators/median.py @@ -68,9 +68,9 @@ # Optional 'native' module try: - import native + from krum import native except ImportError: - native = None + native = None # type: ignore[assignment] # ---------------------------------------------------------------------------- # # Coordinate-wise median GAR @@ -122,7 +122,7 @@ def aggregate_native(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: torch.Tensor Coordinate-wise median of all input gradients. """ - return native.median.aggregate(gradients) + return native.median.aggregate(gradients) # type: ignore[attr-defined] def check(gradients: list[torch.Tensor], **kwargs) -> str | None: diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index 155d7c3..bc0b218 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -46,6 +46,7 @@ import pathlib from collections.abc import Callable +from typing import Any, cast import torch @@ -96,7 +97,7 @@ def checked(f_real, **kwargs): return res # Select which function to call by default - func = checked if __debug__ else unchecked + func = cast(Any, checked if __debug__ else unchecked) # Bind all the (sub) functions to the selected function func.check = check func.checked = checked diff --git a/krum/experiments/__init__.py b/krum/experiments/__init__.py index 8e535c4..6cbebd2 100644 --- a/krum/experiments/__init__.py +++ b/krum/experiments/__init__.py @@ -48,8 +48,25 @@ from krum import tools -# ---------------------------------------------------------------------------- # -# Load all local modules +from .checkpoint import Checkpoint, Storage +from .configuration import Configuration +from .dataset import Dataset, batch_dataset, get_default_transform, make_datasets, make_sampler +from .loss import Criterion, Loss +from .model import Model +from .optimizer import Optimizer -with tools.Context("experiments", None): - tools.import_directory(pathlib.Path(__file__).parent, globals()) +# Public API +__all__ = [ + "Checkpoint", + "Configuration", + "Criterion", + "Dataset", + "Loss", + "Model", + "Optimizer", + "Storage", + "batch_dataset", + "get_default_transform", + "make_datasets", + "make_sampler", +] diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index c73693a..643a73a 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -39,6 +39,7 @@ import random import tempfile import types +from typing import Any import torch import torchvision @@ -162,7 +163,10 @@ def get_default_root(cls): return cls.__default_root # Map 'lower-case names' -> 'dataset class' available in PyTorch - __datasets = None + __datasets: dict[str, Any] | None = None + + # Optional PyTorch DataLoader (set externally for epoch-based iteration) + _loader: torch.utils.data.DataLoader | None = None @classmethod def _get_datasets(cls): @@ -235,6 +239,7 @@ def add_custom_datasets(name, module, _): continue exported = True fullname = f"{name}-{dataset}" + assert cls.__datasets is not None if fullname in cls.__datasets: tools.warning( f"Unable to make available dataset {dataset!r} from module " diff --git a/krum/experiments/model.py b/krum/experiments/model.py index 837fb32..ec82ebc 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -35,6 +35,7 @@ import pathlib import types +from typing import Any import torch import torchvision @@ -87,10 +88,10 @@ class Model: """ # Map 'lower-case names' -> 'model constructor' - __models = None + __models: dict[str, Any] | None = None # Map 'lower-case names' -> 'tensor initializer' - __inits = None + __inits: dict[str, Any] | None = None @classmethod def _get_models(cls): @@ -134,6 +135,7 @@ def add_custom_models(name, module, _): continue exported = True fullname = f"{name}-{model}" + assert cls.__models is not None if fullname in cls.__models: tools.warning( f"Unable to make available model {model!r} from module " diff --git a/krum/experiments/models/simples.py b/krum/experiments/models/simples.py index de0164f..fba3b79 100644 --- a/krum/experiments/models/simples.py +++ b/krum/experiments/models/simples.py @@ -155,7 +155,7 @@ class _Logit(torch.nn.Module): classification or as a simple baseline. """ - def __init__(self, din, dout=1): + def __init__(self, din: int, dout: int = 1): """Initialise the linear layer. Parameters @@ -166,8 +166,8 @@ def __init__(self, din, dout=1): Number of output features. Defaults to ``1``. """ super().__init__() - self._din = din - self._dout = dout + self._din = din # type: ignore[attr-defined] + self._dout = dout # type: ignore[attr-defined] self._linear = torch.nn.Linear(din, dout) def forward(self, x): @@ -215,7 +215,7 @@ class _Linear(torch.nn.Module): Equivalent to a fully-connected layer with identity activation. """ - def __init__(self, din, dout=1): + def __init__(self, din: int, dout: int = 1): """Initialise the linear layer. Parameters @@ -226,8 +226,8 @@ def __init__(self, din, dout=1): Number of output features. Defaults to ``1``. """ super().__init__() - self._din = din - self._dout = dout + self._din = din # type: ignore[attr-defined] + self._dout = dout # type: ignore[attr-defined] self._linear = torch.nn.Linear(din, dout) def forward(self, x): diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py index f62e1d1..c6b889f 100644 --- a/krum/tools/__init__.py +++ b/krum/tools/__init__.py @@ -52,6 +52,38 @@ import threading import traceback from pathlib import Path +from typing import Any, Callable, TextIO + +from .jobs import Command, Jobs, dict_to_cmdlist +from .misc import ( + ClassRegister, + MethodCallReplicator, + TimedContext, + UnavailableException, + deltatime_format, + deltatime_point, + fatal_unavailable, + fullqual, + get_loaded_dependencies, + interactive, + line_maximize, + localtime, + onetime, + pairwise, + parse_keyval, +) +from .pytorch import ( + AccumulatedTimedContext, + WeightedMSELoss, + compute_avg_dev_max, + flatten, + grad_of, + grads_of, + pnm, + regression, + relink, + weighted_mse_loss, +) # ---------------------------------------------------------------------------- # # User exception base class, print string representation and exit(1) on uncaught @@ -201,7 +233,7 @@ class ContextIOWrapper: Context-aware text I/O wrapper. """ - def __init__(self, output: object, nocolor: bool | None = None) -> None: + def __init__(self, output: TextIO, nocolor: bool | None = None) -> None: """ Wrap a text output stream. @@ -273,7 +305,7 @@ def write(self, text: str) -> int: return self.__output.write(text + clrend) -def _make_color_print(color: str) -> object: +def _make_color_print(color: str) -> Callable[..., object]: """ Build a ``print`` wrapper that runs inside a colored context. @@ -312,9 +344,12 @@ def color_print(*args, context: str | None = None, **kwargs) -> object: return color_print -# Shortcut for colored print -for color in ["trace", "info", "success", "warning", "error"]: - globals()[color] = _make_color_print(color) +# Explicit colored print shortcuts (required for static type checkers) +trace = _make_color_print("trace") +info = _make_color_print("info") +success = _make_color_print("success") +warning = _make_color_print("warning") +error = _make_color_print("error") def fatal(*args, with_traceback: bool = False, **kwargs) -> None: @@ -346,7 +381,7 @@ def fatal(*args, with_traceback: bool = False, **kwargs) -> None: # Uncaught exception context wrapping -def uncaught_wrap(hook: object) -> object: +def uncaught_wrap(hook: Callable[..., Any]) -> Callable[..., Any]: """ Wrap an uncaught exception hook with contextual logging. @@ -436,8 +471,8 @@ def import_exported_symbols(name: str, module, scope: dict) -> None: def import_directory( dirpath: Path, scope: dict, - post: object = import_exported_symbols, - ignore: list[str] = None, + post: Callable[..., Any] | None = import_exported_symbols, + ignore: list[str] | None = None, ) -> None: """ Import every Python module from a directory into a target scope. @@ -475,5 +510,51 @@ def import_directory( traceback.print_exc() -with Context("tools", None): - import_directory(Path(__file__).parent, globals()) +# Public API of the tools package +__all__ = [ + # Logging & context + "Context", + "ContextIOWrapper", + "UserException", + "trace", + "info", + "success", + "warning", + "error", + "fatal", + "uncaught_wrap", + # Module loading + "import_exported_symbols", + "import_directory", + # misc + "ClassRegister", + "MethodCallReplicator", + "TimedContext", + "UnavailableException", + "deltatime_format", + "deltatime_point", + "fatal_unavailable", + "fullqual", + "get_loaded_dependencies", + "interactive", + "line_maximize", + "localtime", + "onetime", + "pairwise", + "parse_keyval", + # pytorch + "AccumulatedTimedContext", + "WeightedMSELoss", + "compute_avg_dev_max", + "flatten", + "grad_of", + "grads_of", + "pnm", + "regression", + "relink", + "weighted_mse_loss", + # jobs + "Command", + "Jobs", + "dict_to_cmdlist", +] diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py index b131868..128f67f 100644 --- a/krum/tools/jobs.py +++ b/krum/tools/jobs.py @@ -12,12 +12,46 @@ # Simple job management for reproduction scripts. ### +""" +Experiment job management helpers. + +This module provides utilities for running and managing experiment jobs in a +reproducible manner. + +Classes and Functions +--------------------- + +Job orchestration + ``Command`` encapsulates a command with seed, device, and result-directory + arguments. + ``Jobs`` manages parallel execution of experiments on multiple devices. + +Helpers + ``dict_to_cmdlist`` converts dictionaries into command-line argument lists. + ``move_directory`` moves an existing directory aside with versioning. + +Example +------- + +.. code-block:: python + + from tools import Command, Jobs, dict_to_cmdlist + + cmd = Command(["python", "train.py", "--lr", "0.01"]) + jobs = Jobs("./results", devices=["cuda:0", "cuda:1"]) + jobs.submit("exp1", cmd) + jobs.wait() + jobs.close() +""" + __all__ = ["Command", "Jobs", "dict_to_cmdlist"] import shlex import subprocess import threading +from collections.abc import Callable, Iterable, Sequence from pathlib import Path +from typing import Any from krum import tools @@ -26,11 +60,32 @@ def move_directory(path: Path) -> Path: - """Move existing directory to a new location (with a numbering scheme). - Args: - path Path to the directory to create - Returns: - 'path' (to enable chaining) + """Move an existing directory aside with versioning. + + If a directory already exists at the given path, it is renamed with an + incremental suffix (for example, ``results.0``, ``results.1``) before a + new directory is created. + + Parameters + ---------- + path : pathlib.Path + Directory path to move aside if it already exists. + + Returns + ------- + pathlib.Path + The input path, returned unchanged for chaining. + + Raises + ------ + RuntimeError + If ``path`` exists but is not a directory (or a symlink to one). + + Example + ------- + >>> from pathlib import Path + >>> move_directory(Path("results")) + # Moves existing "results" to "results.0" if it exists """ # Move directory if it exists if path.exists(): @@ -47,22 +102,40 @@ def move_directory(path: Path) -> Path: return path -def dict_to_cmdlist(dp: dict) -> list: - """Transform a dictionary into a list of command arguments. - Args: - dp Dictionary mapping parameter name (to prepend with "--") to parameter value (to convert to string) - Returns: - Associated list of command arguments - Notes: - For entries mapping to 'bool', the parameter is included/discarded depending on whether the value is True/False - For entries mapping to 'list' or 'tuple', the parameter is followed by all the values as strings +def dict_to_cmdlist(dp: dict[str, Any]) -> list[str]: + """Convert a dictionary into command-line arguments. + + This helper is useful for turning experiment configurations into CLI + arguments. + + Parameters + ---------- + dp : dict of str to Any + Dictionary mapping parameter names to values. + + Returns + ------- + list of str + Command-line arguments such as ``["--lr", "0.01", "--batch", "32"]``. + + Notes + ----- + - Boolean values are included only when they are ``True``. + - Lists and tuples expand to repeated ``--name value`` pairs. + + Example + ------- + >>> dict_to_cmdlist({"lr": 0.01, "batch": 32, "debug": True}) + ['--lr', '0.01', '--batch', '32', '--debug'] + >>> dict_to_cmdlist({"layers": [64, 128]}) + ['--layers', '64', '--layers', '128'] """ - cmd = [] + cmd: list[str] = [] for name, value in dp.items(): if isinstance(value, bool): if value: cmd.append(f"--{name}") - elif any(isinstance(value, typ) for typ in (list, tuple)): + elif isinstance(value, (list, tuple)): cmd.append(f"--{name}") for subval in value: cmd.append(str(subval)) @@ -77,30 +150,51 @@ def dict_to_cmdlist(dp: dict) -> list: class Command: - """Simple job command class, that builds a command from a dictionary of parameters.""" + """Command wrapper that adds standard runtime arguments. - def __init__(self, command): - """Bind constructor. - Args: - command Command iterable (will be copied) - """ - self._basecmd = list(command) + This class wraps a base command and automatically appends seed, device, and + result-directory arguments when building the final command line. - def build(self, seed, device, resdir): + Parameters + ---------- + command : iterable of str + Base command as an iterable of strings (e.g. ``["python", "train.py"]``). + The iterable is copied on instantiation. + + Attributes + ---------- + _basecmd : list of str + Internal copy of the base command. + """ + + def __init__(self, command: Iterable[str]) -> None: + self._basecmd: list[str] = list(command) + + def build(self, seed: int | str, device: str, resdir: Path | str) -> list[str]: """Build the final command line. - Args: - seed Seed to use - device Device to use - resdir Target directory path - Returns: - Final command list + + Parameters + ---------- + seed : int or str + Seed to use for the experiment. + device : str + Device on which to run the experiment (e.g. ``"cuda:0"``). + resdir : pathlib.Path or str + Target directory path for results. + + Returns + ------- + list of str + Final command list ready to be passed to ``subprocess.run``. """ - # Build final command list cmd = self._basecmd.copy() - for name, value in (("seed", seed), ("device", device), ("result-directory", resdir)): + for name, value in ( + ("seed", seed), + ("device", device), + ("result-directory", resdir), + ): cmd.append(f"--{name}") cmd.append(shlex.quote(value if isinstance(value, str) else str(value))) - # Return final command list return cmd @@ -109,17 +203,68 @@ def build(self, seed, device, resdir): class Jobs: - """Take experiments to run and runs them on the available devices, managing repetitions.""" + """Job execution manager for parallel experiments. + + Manages parallel execution of experiments across multiple devices, + with support for result tracking and error handling. + + Parameters + ---------- + res_dir : pathlib.Path or str + Directory to store results. + devices : list of str, optional + List of device names (e.g. ``["cuda:0", "cuda:1"]``). + Defaults to ``["cpu"]`` if none specified. + devmult : int, optional + Number of parallel jobs per device. Default is ``1``. + seeds : sequence of int, optional + Seeds to use for repeating experiments. Default is ``range(1, 6)``. + + Attributes + ---------- + _res_dir : pathlib.Path + Resolved result directory. + _jobs : list of tuple or None + Pending job queue as ``(name, seed, command)`` tuples, or ``None`` + when the manager has been closed. + _workers : list of threading.Thread + Worker thread pool, one entry per active slot. + _devices : list of str + Devices used for execution. + _seeds : tuple of int + Seeds used for repeating experiments. + _lock : threading.Lock + Main lock protecting shared state. + _cvready : threading.Condition + Condition variable to signal that new jobs are available or that + workers must shut down. + _cvdone : threading.Condition + Condition variable to signal that all submitted jobs have been + processed. + """ @staticmethod - def _run(topdir, name, seed, device, command): - """Run the attack experiments with the given named parameters. - Args: - topdir Parent result directory - name Experiment unique name - seed Experiment seed - device Device on which to run the experiments - command Command to run + def _run( + topdir: Path, + name: str, + seed: int, + device: str, + command: Command, + ) -> None: + """Run a single experiment with the given parameters. + + Parameters + ---------- + topdir : pathlib.Path + Parent result directory. + name : str + Experiment unique name. + seed : int + Experiment seed. + device : str + Device on which to run the experiment. + command : Command + Command builder to use. """ # Add seed to name name = f"{name}-{seed}" @@ -135,8 +280,8 @@ def _run(topdir, name, seed, device, command): resdir.mkdir(mode=0o755, parents=True) # Build the command args = command.build(seed, device, resdir) - # Launch the experiment and write the standard output/error - tools.trace((" ").join(shlex.quote(arg) for arg in args)) + # Launch the experiment and capture the standard output/error + tools.trace(" ".join(shlex.quote(arg) for arg in args)) cmd_res = subprocess.run(args, check=False, capture_output=True) if cmd_res.returncode == 0: tools.info("Experiment successful") @@ -148,10 +293,16 @@ def _run(topdir, name, seed, device, command): (finaldir / "stdout.log").write_bytes(cmd_res.stdout) (finaldir / "stderr.log").write_bytes(cmd_res.stderr) - def _worker_entrypoint(self, device): - """Worker entry point. - Args: - device Device to use + def _worker_entrypoint(self, device: str) -> None: + """Worker thread entry point. + + Continuously picks pending jobs from the queue and executes them on + the assigned device until the manager is closed. + + Parameters + ---------- + device : str + Device assigned to this worker. """ while True: # Take a pending experiment, or exit if requested @@ -169,24 +320,23 @@ def _worker_entrypoint(self, device): # Run the picked experiment self._run(self._res_dir, name, seed, device, command) - def __init__(self, res_dir, devices=None, devmult=1, seeds=None): - """Initialize the instance, launch the worker pool. - Args: - res_dir Path to the directory containing the result sub-directories - devices List/tuple of the devices to use in parallel - devmult How many experiments are run in parallel per device - seeds List/tuple of seeds to repeat the experiments with - """ + def __init__( + self, + res_dir: Path | str, + devices: list[str] | None = None, + devmult: int = 1, + seeds: Sequence[int] | None = None, + ) -> None: # Initialize instance if devices is None: devices = ["cpu"] if seeds is None: seeds = tuple(range(1, 6)) - self._res_dir = res_dir - self._jobs = [] # List of tuples (name, seed, command), or None to signal termination - self._workers = [] # Worker pool, one per target device - self._devices = devices - self._seeds = seeds + self._res_dir: Path = Path(res_dir) + self._jobs: list[tuple[str, int, Command]] | None = [] + self._workers: list[threading.Thread] = [] + self._devices: list[str] = devices + self._seeds: tuple[int, ...] = tuple(seeds) self._lock = threading.Lock() self._cvready = threading.Condition( lock=self._lock @@ -199,15 +349,18 @@ def __init__(self, res_dir, devices=None, devmult=1, seeds=None): thread.start() self._workers.append(thread) - def get_seeds(self): + def get_seeds(self) -> tuple[int, ...]: """Get the list of seeds used for repeating the experiments. - Returns: - List/tuple of seeds used + + Returns + ------- + tuple of int + Seeds used by this manager. """ return self._seeds - def close(self): - """Close and wait for the worker pool, discarding not yet started submission.""" + def close(self) -> None: + """Close and wait for the worker pool, discarding not-yet-started submissions.""" # Close the manager with self._lock: # Check if already closed @@ -221,11 +374,22 @@ def close(self): for worker in self._workers: worker.join() - def submit(self, name, command): - """Submit an experiment to be run with each seed on any available device. - Args: - name Experiment unique name - command Command to process + def submit(self, name: str, command: Command) -> None: + """Submit a job for execution. + + The job is repeated for every seed configured in the manager. + + Parameters + ---------- + name : str + Job identifier. + command : Command + Command builder to execute. + + Raises + ------ + RuntimeError + If the manager has already been closed. """ with self._lock: # Check if not closed @@ -236,10 +400,14 @@ def submit(self, name, command): self._jobs.insert(0, (name, seed, command)) self._cvready.notify(n=len(self._seeds)) - def wait(self, predicate=None): + def wait(self, predicate: Callable[[], bool] | None = None) -> None: """Wait for all the submitted jobs to be processed. - Args: - predicate Custom predicate to call to check whether must stop waiting + + Parameters + ---------- + predicate : callable returning bool, optional + Optional custom predicate. If provided, waiting stops when the + predicate returns ``True``. """ while True: with self._lock: diff --git a/krum/tools/misc.py b/krum/tools/misc.py index c6e7f18..edceac9 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -83,6 +83,8 @@ import threading import time import traceback +from collections.abc import Callable +from typing import Any from . import Context, UserException, fatal, trace, warning @@ -253,7 +255,7 @@ def __init__(self, singular: str, optplural: str | None = None) -> None: def itemize(self) -> list[str]: """Return the registered class names.""" - return self.__register.keys() + return list(self.__register.keys()) def register(self, name: str, cls: type) -> None: """ @@ -470,7 +472,7 @@ def fullqual(obj: object) -> str: # Basic "full-qualification" string builder for a given instance/class -def onetime(name: str | None = None) -> tuple[callable, callable]: +def onetime(name: str | None = None) -> tuple[Callable[..., Any], Callable[..., Any]]: """ Create or retrieve a thread-safe one-shot flag. @@ -695,6 +697,12 @@ def interactive( # List non-standard, currently loaded module names and metadata. +# Module flavor constants +IS_STANDARD = 0 +IS_SITE = 1 +IS_LOCAL = 2 + + def get_loaded_dependencies() -> list[tuple[str, str | None, int]]: """ List currently loaded non-built-in root modules. @@ -720,16 +728,16 @@ def flavor_of(path): for path_site in path_sites: try: path.relative_to(path_site) - return get_loaded_dependencies.IS_SITE + return IS_SITE except ValueError: pass for path_site in path_sites: try: path.relative_to(path_site.parent) - return get_loaded_dependencies.IS_STANDARD + return IS_STANDARD except ValueError: pass - return get_loaded_dependencies.IS_LOCAL + return IS_LOCAL # Iterate over the loaded modules res = [] @@ -751,17 +759,12 @@ def flavor_of(path): return res -# Register constants -get_loaded_dependencies.IS_STANDARD = 0 -get_loaded_dependencies.IS_SITE = 1 -get_loaded_dependencies.IS_LOCAL = 2 - # ---------------------------------------------------------------------------- # # Find the x maximizing a function y = f(x), with (x, y) ∊ ℝ⁺x ℝ def line_maximize( - scape: callable, + scape: Callable[..., Any], evals: int = 16, start: float = 0.0, delta: float = 1.0, diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index d42a874..f295db5 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -76,7 +76,7 @@ import io import time import types -from collections.abc import Callable +from collections.abc import Callable, Iterable import torch @@ -84,7 +84,7 @@ # "Flatten" and "relink" operations -def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: +def relink(tensors: Iterable[torch.Tensor], common: torch.Tensor) -> torch.Tensor: """ Relink tensors to share a common contiguous memory storage. @@ -127,11 +127,11 @@ def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: tensor.data = common[pos:npos].view(*tensor.shape) pos = npos # Finalize and return - common.linked_tensors = tensors + common.linked_tensors = tensors # type: ignore[attr-defined] return common -def flatten(tensors: list[torch.Tensor]) -> torch.Tensor: +def flatten(tensors: Iterable[torch.Tensor]) -> torch.Tensor: """ Flatten tensors into a single contiguous tensor. @@ -209,7 +209,7 @@ def grad_of(tensor: torch.Tensor) -> torch.Tensor: return grad -def grads_of(tensors: list[torch.Tensor]): +def grads_of(tensors: Iterable[torch.Tensor]): """ Generator that gets or creates gradients for multiple tensors. @@ -310,19 +310,24 @@ class AccumulatedTimedContext: >>> print(atc.current_runtime()) """ - def __init__(self, sync: bool = False) -> None: + def __init__(self, sync: bool | float = False) -> None: """ Initialize the accumulated timed context. Parameters ---------- - sync : bool, optional - Whether to synchronize CUDA before and after timing. Defaults to - ``False``. + sync : bool or float, optional + Whether to synchronize CUDA before and after timing, or a float + representing an already accumulated runtime. Defaults to ``False``. """ - self._sync = sync - self._start = None - self._elapsed = 0.0 + if isinstance(sync, float): + self._sync = False + self._start = None + self._elapsed = sync + else: + self._sync = sync + self._start = None + self._elapsed = 0.0 def __enter__(self): """ @@ -349,6 +354,7 @@ def __exit__(self, *args) -> None: """ if self._sync and torch.cuda.is_available(): torch.cuda.synchronize() + assert self._start is not None self._elapsed += time.perf_counter() - self._start def current_runtime(self) -> float: @@ -502,12 +508,12 @@ def pnm(fd: io.BufferedWriter, tn: torch.Tensor) -> None: if M - m < 1e-8: M = m + 1 t = ((tn - m) / (M - m) * 255).byte().cpu() - fd.write(f"P5\n{tn.shape[1]}\n{tn.shape[0]}\n255\n") + fd.write(f"P5\n{tn.shape[1]}\n{tn.shape[0]}\n255\n".encode()) fd.write(t.numpy().tobytes()) else: # Binary t = (tn > 0).byte().cpu() - fd.write(f"P4\n{tn.shape[1]}\n{tn.shape[0]}\n") + fd.write(f"P4\n{tn.shape[1]}\n{tn.shape[0]}\n".encode()) # Pad to byte boundary w = (tn.shape[1] + 7) // 8 pad = w * 8 - tn.shape[1] diff --git a/train.py b/train.py index c5bacd7..1c70175 100644 --- a/train.py +++ b/train.py @@ -632,15 +632,15 @@ class StopTrainingLoop(Exception): cosin_honatt = ( math.nan if attack_grad_avg is None - else torch.dot(honest_grad_avg, attack_grad_avg).div_(honest_norm_avg).div_(attack_norm_avg).item() + else torch.dot(honest_grad_avg, attack_grad_avg).div_(honest_norm_avg).div_(attack_norm_avg).item() # type: ignore[arg-type] ) cosin_hondef = ( - torch.dot(honest_grad_avg, defense_grad).div_(honest_norm_avg).div_(defense_norm_avg).item() + torch.dot(honest_grad_avg, defense_grad).div_(honest_norm_avg).div_(defense_norm_avg).item() # type: ignore[arg-type] ) cosin_attdef = ( math.nan if attack_grad_avg is None - else torch.dot(attack_grad_avg, defense_grad).div_(attack_norm_avg).div_(defense_norm_avg).item() + else torch.dot(attack_grad_avg, defense_grad).div_(attack_norm_avg).div_(defense_norm_avg).item() # type: ignore[arg-type] ) # Store the result (float-to-string format chosen so not to lose precision) float_format = {torch.float16: "%.4e", torch.float32: "%.8e", torch.float64: "%.16e"}.get( From 99883e89a1fa82d34b74bf522aa2088ce1e66e7a Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 12:49:02 +0200 Subject: [PATCH 18/30] Move public API imports next to __all__ --- krum/tools/__init__.py | 63 +++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py index c6b889f..429b870 100644 --- a/krum/tools/__init__.py +++ b/krum/tools/__init__.py @@ -54,37 +54,6 @@ from pathlib import Path from typing import Any, Callable, TextIO -from .jobs import Command, Jobs, dict_to_cmdlist -from .misc import ( - ClassRegister, - MethodCallReplicator, - TimedContext, - UnavailableException, - deltatime_format, - deltatime_point, - fatal_unavailable, - fullqual, - get_loaded_dependencies, - interactive, - line_maximize, - localtime, - onetime, - pairwise, - parse_keyval, -) -from .pytorch import ( - AccumulatedTimedContext, - WeightedMSELoss, - compute_avg_dev_max, - flatten, - grad_of, - grads_of, - pnm, - regression, - relink, - weighted_mse_loss, -) - # ---------------------------------------------------------------------------- # # User exception base class, print string representation and exit(1) on uncaught @@ -510,7 +479,37 @@ def import_directory( traceback.print_exc() -# Public API of the tools package +from .jobs import Command, Jobs, dict_to_cmdlist +from .misc import ( + ClassRegister, + MethodCallReplicator, + TimedContext, + UnavailableException, + deltatime_format, + deltatime_point, + fatal_unavailable, + fullqual, + get_loaded_dependencies, + interactive, + line_maximize, + localtime, + onetime, + pairwise, + parse_keyval, +) +from .pytorch import ( + AccumulatedTimedContext, + WeightedMSELoss, + compute_avg_dev_max, + flatten, + grad_of, + grads_of, + pnm, + regression, + relink, + weighted_mse_loss, +) + __all__ = [ # Logging & context "Context", From 404c129cff2bdc2abc7d97d9732d0676392cdb93 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 13:03:34 +0200 Subject: [PATCH 19/30] Fix doc --- docs/Makefile | 5 +- docs/_static/custom.css | 5 + docs/conf.py | 5 + docs/contributors.rst | 6 +- docs/how-to/add-dataset.rst | 2 +- pyproject.toml | 5 + uv.lock | 413 ++++++++++++++++++++++++++---------- 7 files changed, 322 insertions(+), 119 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 477edf9..75555e4 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,10 +1,11 @@ # Makefile for Krum documentation -.PHONY: help html clean servedocs live watch +.PHONY: help html build clean serve watch help: @echo "Available targets:" @echo " html - Build HTML documentation" + @echo " build - Build HTML documentation (alias for html)" @echo " clean - Remove generated files" @echo " serve - Build and serve static documentation" @echo " watch - Watch for changes and serve (auto-rebuild)" @@ -12,6 +13,8 @@ help: html: uv run sphinx-build -b html . _build/html +build: html + clean: rm -rf _build diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 6f13958..e95fe1a 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -16,4 +16,9 @@ .bd-toc .page-toc + .sidebar-secondary-item { display: none; +} + +/* Contributions Avatar */ +.sphinx-contributors img { + border-radius: 50%; } \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index c67d3d8..8b36b46 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,11 +6,16 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information +import importlib import os import sys sys.path.insert(0, os.path.abspath("..")) +# Make krum submodules available as top-level imports for autodoc compatibility +for _mod in ("aggregators", "attacks", "experiments", "native", "tools"): + sys.modules[_mod] = importlib.import_module(f"krum.{_mod}") + project = "Krum, the Library" copyright = "2026" author = "Peva BLANCHARD, Arthur DANJOU, El-Mahdi EL-MHAMDI, Sébastien ROUAULT, Mohammed Ammar SAID" diff --git a/docs/contributors.rst b/docs/contributors.rst index 0fecb75..788c840 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -3,9 +3,9 @@ Contributors The following people have contributed to Krum: -.. container:: rounded-image - .. contributors:: calicarpa/krum - :avatars: +.. contributors:: calicarpa/krum + :avatars: + :names: We are open to all contributions. Whether it is a bug fix, a new feature, or an improvement to the documentation, feel free to open a pull request or start a discussion on GitHub. diff --git a/docs/how-to/add-dataset.rst b/docs/how-to/add-dataset.rst index 6401991..9147297 100644 --- a/docs/how-to/add-dataset.rst +++ b/docs/how-to/add-dataset.rst @@ -72,7 +72,7 @@ batching automatically. ) Step 3 — Export in ``__all__`` ------------------------------ +------------------------------ List the builder function in ``__all__`` so the loader can discover it. diff --git a/pyproject.toml b/pyproject.toml index 4026d34..88a9f1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,3 +103,8 @@ quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" + +[dependency-groups] +dev = [ + "sphinx-autobuild>=2024.10.3", +] diff --git a/uv.lock b/uv.lock index 68dbee1..31ca74a 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -155,6 +169,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -422,32 +448,21 @@ wheels = [ name = "docutils" version = "0.21.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, ] [[package]] -name = "docutils" -version = "0.22.4" +name = "exceptiongroup" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "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.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] @@ -525,6 +540,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -709,9 +733,7 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, { name = "shibuya" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, { name = "sphinx-contributors" }, { name = "sphinx-copybutton" }, { name = "sphinx-favicon" }, @@ -719,6 +741,12 @@ dev = [ { name = "ty" }, ] +[package.dev-dependencies] +dev = [ + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + [package.metadata] requires-dist = [ { name = "matplotlib", specifier = ">=3.10.9" }, @@ -740,6 +768,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "sphinx-autobuild", specifier = ">=2024.10.3" }] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1690,15 +1721,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] -[[package]] -name = "roman-numerals" -version = "4.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } -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 = "ruff" version = "0.15.12" @@ -1739,9 +1761,7 @@ version = "2026.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments-styles" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/b94cb04adbb984973fe83fd670dd066514610241d829723f678366e691d2/shibuya-2026.1.9.tar.gz", hash = "sha256:b389f10fd9c07b048e940f32d1e1ac096a2d49736389173ac771b37a10b51fdf", size = 86002, upload-time = "2026-01-09T02:19:14.365Z" } wheels = [ @@ -1770,26 +1790,23 @@ wheels = [ name = "sphinx" version = "8.1.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } @@ -1798,72 +1815,51 @@ wheels = [ ] [[package]] -name = "sphinx" -version = "9.0.4" +name = "sphinx-autobuild" +version = "2024.10.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version == '3.11.*'" }, - { name = "babel", marker = "python_full_version == '3.11.*'" }, - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "imagesize", marker = "python_full_version == '3.11.*'" }, - { name = "jinja2", marker = "python_full_version == '3.11.*'" }, - { name = "packaging", marker = "python_full_version == '3.11.*'" }, - { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "requests", marker = "python_full_version == '3.11.*'" }, - { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, + { name = "colorama", marker = "python_full_version < '3.11'" }, + { name = "sphinx", marker = "python_full_version < '3.11'" }, + { name = "starlette", marker = "python_full_version < '3.11'" }, + { name = "uvicorn", marker = "python_full_version < '3.11'" }, + { name = "watchfiles", marker = "python_full_version < '3.11'" }, + { name = "websockets", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, ] [[package]] -name = "sphinx" -version = "9.1.0" +name = "sphinx-autobuild" +version = "2025.8.25" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "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.11.*' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' 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.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, + { name = "colorama", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", marker = "python_full_version >= '3.11'" }, + { name = "starlette", marker = "python_full_version >= '3.11'" }, + { name = "uvicorn", marker = "python_full_version >= '3.11'" }, + { name = "watchfiles", marker = "python_full_version >= '3.11'" }, + { name = "websockets", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, ] [[package]] @@ -1872,9 +1868,7 @@ version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/68/53a8170828c2e175e5333560d413c7721c323ba9ffbbc86c3c8346f6eb4b/sphinx_contributors-0.3.0.tar.gz", hash = "sha256:9b8c94fb5c1f851719a3abb9e15281c34f511b8aba71c97ac9c30bcb14f907fd", size = 399495, upload-time = "2026-03-20T15:15:39.936Z" } wheels = [ @@ -1886,9 +1880,7 @@ name = "sphinx-copybutton" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } wheels = [ @@ -1902,9 +1894,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "imagesize" }, { name = "requests" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2c/26/e7ca2321e6286d6ed6a2e824a0ee35ae660ec9a45a4719e33a627ce9e4d2/sphinx_favicon-1.1.0.tar.gz", hash = "sha256:6f65939fc2a6ac4259c88b09169f0b72681cd4c03dd1d0cf91c57a1fa314e50b", size = 8744, upload-time = "2026-02-12T20:55:41.294Z" } wheels = [ @@ -1916,12 +1906,9 @@ name = "sphinx-togglebutton" version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "docutils" }, { name = "setuptools" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx" }, { name = "wheel" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/be/169a0b0a8ad9588e8697c85e1d489aaaca7416073c2fc0267c360af5aae9/sphinx_togglebutton-0.4.5.tar.gz", hash = "sha256:c870dfbd3bc6e119b50ff9a37a64f8991902269e856728931c7d89877e8d4b3d", size = 18101, upload-time = "2026-03-27T13:50:41.984Z" } @@ -1983,6 +1970,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -2214,6 +2214,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + [[package]] name = "virtualenv" version = "21.3.0" @@ -2230,6 +2244,177 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "wheel" version = "0.47.0" From 5de9eba4e000cbf838c1c03cf1778239857f3e9a Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 13:06:47 +0200 Subject: [PATCH 20/30] Update contributors.rst --- docs/contributors.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributors.rst b/docs/contributors.rst index 788c840..9f8e84c 100644 --- a/docs/contributors.rst +++ b/docs/contributors.rst @@ -9,4 +9,6 @@ The following people have contributed to Krum: We are open to all contributions. Whether it is a bug fix, a new feature, or an improvement to the documentation, feel free to open a pull request or start a discussion on GitHub. +To learn more about how to contribute, please refer to the `contribution guidelines `_. + Thank you to everyone who has helped improve this project. From c93b91ae7d7d39e5d396d7789574bc134f769f19 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 13:08:26 +0200 Subject: [PATCH 21/30] Update ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfe9913..fe9f020 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, "9-feature-pip-installable"] + branches: [main] pull_request: branches: [main] From bd67208133aeea04c60241d5f2264e351858be96 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 13:20:42 +0200 Subject: [PATCH 22/30] Change uv sync command for doc --- .github/workflows/documentation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 3fd9035..ff306b4 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -32,7 +32,7 @@ jobs: python-version-file: "pyproject.toml" - name: Install dependencies - run: uv sync --group docs + run: uv sync --extra dev - name: Build documentation run: uv run sphinx-build -b html docs docs/_build/html From 9571ced4a3f82a2886748624f22fcce6bc70fce7 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 13:22:36 +0200 Subject: [PATCH 23/30] Uniformisation des noms des workflows steps --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe9f020..cc644a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Tests on: push: @@ -15,7 +15,7 @@ jobs: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v6.0.2 - name: Install uv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc1e4a5..684a602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Publish on: push: @@ -12,7 +12,7 @@ jobs: permissions: id-token: write # Required for trusted publishing (recommended) steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v4 - name: Install uv From 6dfb23d0b5fef8d8ae6ec38a4f19d2391f0cd647 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 13:55:39 +0200 Subject: [PATCH 24/30] Update pytorch.py --- krum/tools/pytorch.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index f295db5..84b45ac 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -76,7 +76,7 @@ import io import time import types -from collections.abc import Callable, Iterable +from collections.abc import Callable import torch @@ -84,13 +84,13 @@ # "Flatten" and "relink" operations -def relink(tensors: Iterable[torch.Tensor], common: torch.Tensor) -> torch.Tensor: +def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: """ Relink tensors to share a common contiguous memory storage. Parameters ---------- - tensors : iterable of torch.Tensor + tensors : list of torch.Tensor Tensors to relink. All must have the same dtype. common : torch.Tensor Flat tensor of sufficient size to use as underlying storage. @@ -131,13 +131,13 @@ def relink(tensors: Iterable[torch.Tensor], common: torch.Tensor) -> torch.Tenso return common -def flatten(tensors: Iterable[torch.Tensor]) -> torch.Tensor: +def flatten(tensors: list[torch.Tensor]) -> torch.Tensor: """ Flatten tensors into a single contiguous tensor. Parameters ---------- - tensors : iterable of torch.Tensor + tensors : list of torch.Tensor Tensors to flatten. All must have the same dtype. Returns @@ -209,13 +209,13 @@ def grad_of(tensor: torch.Tensor) -> torch.Tensor: return grad -def grads_of(tensors: Iterable[torch.Tensor]): +def grads_of(tensors: list[torch.Tensor]): """ Generator that gets or creates gradients for multiple tensors. Parameters ---------- - tensors : iterable of torch.Tensor + tensors : list of torch.Tensor Tensors that may have gradients attached. Yields From 8a192be70a603ec0104db39742323068ab3fb9d9 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 14:15:05 +0200 Subject: [PATCH 25/30] Lint docstrings --- krum/aggregators/__init__.py | 11 +-- krum/aggregators/average.py | 23 +++--- krum/aggregators/brute.py | 41 ++++------ krum/aggregators/bulyan.py | 28 +++---- krum/aggregators/krum.py | 38 ++++----- krum/aggregators/median.py | 29 +++---- krum/attacks/__init__.py | 8 +- krum/attacks/identical.py | 36 ++++---- krum/attacks/nan.py | 16 ++-- krum/experiments/__init__.py | 5 +- krum/experiments/checkpoint.py | 62 ++++++-------- krum/experiments/configuration.py | 39 ++++----- krum/experiments/dataset.py | 59 ++++++-------- krum/experiments/datasets/svm.py | 14 ++-- krum/experiments/loss.py | 118 ++++++++++----------------- krum/experiments/model.py | 87 ++++++++------------ krum/experiments/models/simples.py | 18 ++-- krum/experiments/optimizer.py | 34 +++----- krum/native/__init__.py | 39 ++++----- krum/tools/__init__.py | 81 +++++++----------- krum/tools/jobs.py | 48 +++++++---- krum/tools/misc.py | 127 +++++++++++------------------ krum/tools/pytorch.py | 98 +++++++++------------- pyproject.toml | 1 + 24 files changed, 433 insertions(+), 627 deletions(-) diff --git a/krum/aggregators/__init__.py b/krum/aggregators/__init__.py index 82adff2..f1407c8 100644 --- a/krum/aggregators/__init__.py +++ b/krum/aggregators/__init__.py @@ -12,8 +12,7 @@ # Loading of the local modules. ### -""" -Gradient aggregation rules (GARs) for Byzantine-resilient distributed learning. +"""Gradient aggregation rules (GARs) for Byzantine-resilient distributed learning. Each rule combines a keyword-only aggregation function with a validation function and optional metadata used by the training and experiment scripts. @@ -63,8 +62,7 @@ def make_gar( upper_bound: Callable | None = None, influence: Callable | None = None, ) -> Callable: - """ - Wrap an unchecked GAR with validation and metadata. + """Wrap an unchecked GAR with validation and metadata. Parameters ---------- @@ -81,7 +79,7 @@ def make_gar( Function computing the accepted Byzantine-gradient ratio for a given set of honest and attack gradients. - Returns + Returns: ------- callable Checked or unchecked GAR selected according to ``__debug__``. The @@ -117,8 +115,7 @@ def register( upper_bound: Callable | None = None, influence: Callable | None = None, ) -> None: - """ - Register a gradient aggregation rule. + """Register a gradient aggregation rule. Parameters ---------- diff --git a/krum/aggregators/average.py b/krum/aggregators/average.py index 27f1640..02d7deb 100644 --- a/krum/aggregators/average.py +++ b/krum/aggregators/average.py @@ -12,7 +12,8 @@ # Simple arithmetic mean aggregation rule. ### -""" +"""Simplest aggregation rule, computing the arithmetic mean of all submitted gradients. + This is the simplest aggregation rule, computing the arithmetic mean of all submitted gradients. It serves as a baseline for comparison with Byzantine- resilient methods. @@ -31,9 +32,8 @@ - No parameters: No configuration required beyond the gradient list. - Output: Newly created tensor, does not alias any input. -Example +Example: ------- - >>> import torch >>> from aggregators import average >>> gradients = [torch.tensor([1., 2., 3.]), torch.tensor([4., 5., 6.])] @@ -50,8 +50,7 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: - """ - Compute the arithmetic mean of all submitted gradients. + """Compute the arithmetic mean of all submitted gradients. Parameters ---------- @@ -61,12 +60,12 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: **kwargs : object Additional keyword arguments, ignored by this rule. - Returns + Returns: ------- torch.Tensor The arithmetic mean of all input gradients. - Notes + Notes: ----- The output tensor is a new tensor, not aliasing any input tensor. """ @@ -74,8 +73,7 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: def check(gradients: list[torch.Tensor], **kwargs) -> str | None: - """ - Check parameter validity for the averaging rule. + """Check parameter validity for the averaging rule. Parameters ---------- @@ -84,7 +82,7 @@ def check(gradients: list[torch.Tensor], **kwargs) -> str | None: **kwargs : object Additional keyword arguments, ignored by this rule. - Returns + Returns: ------- str or None ``None`` when parameters are valid, otherwise a user-facing error @@ -96,8 +94,7 @@ def check(gradients: list[torch.Tensor], **kwargs) -> str | None: def influence(honests: list[torch.Tensor], attacks: list[torch.Tensor], **kwargs) -> float: - """ - Compute the ratio of accepted Byzantine gradients. + """Compute the ratio of accepted Byzantine gradients. For arithmetic mean, all submitted gradients are used in the aggregation, so the influence ratio is simply the fraction of Byzantine gradients @@ -112,7 +109,7 @@ def influence(honests: list[torch.Tensor], attacks: list[torch.Tensor], **kwargs **kwargs : object Additional keyword arguments, ignored by this rule. - Returns + Returns: ------- float Ratio of Byzantine gradients in the aggregation (attackers / total). diff --git a/krum/aggregators/brute.py b/krum/aggregators/brute.py index f4cf1e8..01a7657 100644 --- a/krum/aggregators/brute.py +++ b/krum/aggregators/brute.py @@ -12,10 +12,11 @@ # Brute GAR. ### -""" +r"""The Brute aggregation rule exhaustively searches all subsets of gradients. + The Brute aggregation rule exhaustively searches all subsets of :math:`n - f` gradients and selects the subset with the smallest finite -diameter. The diameter of a subset is the maximum pairwise distance between + diameter. The diameter of a subset is the maximum pairwise distance between any two gradients in that subset. Use Case @@ -47,7 +48,7 @@ gradient dimension. - Space: :math:`O(n^2)` for storing pairwise distances. -Example +Example: ------- >>> import torch >>> from aggregators import brute @@ -81,8 +82,7 @@ def _compute_selection(gradients: list[torch.Tensor], f: int, **kwargs) -> tuple[int, ...]: - """ - Select the gradient indices forming the smallest-diameter subset. + """Select the gradient indices forming the smallest-diameter subset. Parameters ---------- @@ -93,12 +93,12 @@ def _compute_selection(gradients: list[torch.Tensor], f: int, **kwargs) -> tuple **kwargs : object Additional keyword arguments, ignored by this helper. - Returns + Returns: ------- tuple of int Indices of the selected :math:`n - f` gradients. - Notes + Notes: ----- Candidate subsets containing non-finite pairwise distances are ignored. """ @@ -134,8 +134,7 @@ def _compute_selection(gradients: list[torch.Tensor], f: int, **kwargs) -> tuple def aggregate(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | float: - """ - Compute the Brute aggregation (mean of smallest-diameter subset). + """Compute the Brute aggregation (mean of smallest-diameter subset). Parameters ---------- @@ -147,13 +146,13 @@ def aggregate(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | **kwargs : object Additional keyword arguments, ignored by this implementation. - Returns + Returns: ------- torch.Tensor Mean of the selected :math:`n - f` gradients with smallest finite diameter. - Notes + Notes: ----- The returned tensor is newly computed and does not alias any input tensor. """ @@ -162,8 +161,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | def aggregate_native(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.Tensor | float: - """ - Compute the Brute aggregation using native C++/CUDA acceleration. + """Compute the Brute aggregation using native C++/CUDA acceleration. Parameters ---------- @@ -174,7 +172,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.T **kwargs : object Additional keyword arguments, ignored by this implementation. - Returns + Returns: ------- torch.Tensor | float Mean of the subset selected by the native Brute implementation. @@ -183,8 +181,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, **kwargs) -> torch.T def check(gradients: list[torch.Tensor], f: int, **kwargs) -> str | None: - """ - Check parameter validity for the Brute aggregation rule. + """Check parameter validity for the Brute aggregation rule. Parameters ---------- @@ -195,7 +192,7 @@ def check(gradients: list[torch.Tensor], f: int, **kwargs) -> str | None: **kwargs : object Additional keyword arguments, ignored by this check. - Returns + Returns: ------- str or None ``None`` when parameters are valid, otherwise a user-facing error @@ -212,8 +209,7 @@ def check(gradients: list[torch.Tensor], f: int, **kwargs) -> str | None: def upper_bound(n: int, f: int, d: int) -> float: - """ - Compute the theoretical Brute resilience bound. + """Compute the theoretical Brute resilience bound. Parameters ---------- @@ -224,7 +220,7 @@ def upper_bound(n: int, f: int, d: int) -> float: d : int Dimension of the gradient space. - Returns + Returns: ------- float Upper bound on the ratio between non-Byzantine standard deviation and @@ -234,8 +230,7 @@ def upper_bound(n: int, f: int, d: int) -> float: def influence(honests: list[torch.Tensor], attacks: list[torch.Tensor], f: int, **kwargs) -> float: - """ - Compute the ratio of Byzantine gradients selected by Brute. + """Compute the ratio of Byzantine gradients selected by Brute. Parameters ---------- @@ -248,7 +243,7 @@ def influence(honests: list[torch.Tensor], attacks: list[torch.Tensor], f: int, **kwargs : object Additional keyword arguments forwarded to the selection helper. - Returns + Returns: ------- float Fraction of selected gradients that come from ``attacks``. diff --git a/krum/aggregators/bulyan.py b/krum/aggregators/bulyan.py index 6c3708a..4727058 100644 --- a/krum/aggregators/bulyan.py +++ b/krum/aggregators/bulyan.py @@ -12,8 +12,7 @@ # Bulyan over Multi-Krum GAR. ### -""" -Bulyan aggregation rule built on top of Multi-Krum. +r"""Bulyan aggregation rule built on top of Multi-Krum. Bulyan combines distance-based gradient selection with coordinate-wise robust averaging. It first selects a candidate set using a Multi-Krum-like @@ -56,9 +55,8 @@ Number of gradients to consider in each Multi-Krum selection step. Defaults to ``n - f - 2``. Must satisfy ``1 <= m <= n - f - 2``. -Example +Example: ------- - >>> import torch >>> from aggregators import bulyan >>> gradients = [ @@ -93,8 +91,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch.Tensor: - """ - Compute the Bulyan aggregate. + """Compute the Bulyan aggregate. Parameters ---------- @@ -111,12 +108,12 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. Additional keyword arguments. They are accepted for compatibility with the GAR interface and ignored by this implementation. - Returns + Returns: ------- torch.Tensor Bulyan-aggregated gradient. - Notes + Notes: ----- The returned tensor is newly allocated and does not alias any input tensor. """ @@ -166,8 +163,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch. def aggregate_native(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> torch.Tensor: - """ - Compute the Bulyan aggregate using native C++/CUDA acceleration. + """Compute the Bulyan aggregate using native C++/CUDA acceleration. Parameters ---------- @@ -182,7 +178,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> Additional keyword arguments. They are accepted for compatibility with the GAR interface and ignored by this implementation. - Returns + Returns: ------- torch.Tensor Bulyan-aggregated gradient. @@ -195,8 +191,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> def check(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> str | None: - """ - Check whether the Bulyan parameters satisfy the GAR contract. + """Check whether the Bulyan parameters satisfy the GAR contract. Parameters ---------- @@ -211,7 +206,7 @@ def check(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> str | None Additional keyword arguments. They are accepted for compatibility with the GAR interface and ignored by this check. - Returns + Returns: ------- str or None ``None`` when parameters are valid, otherwise a user-facing error @@ -230,8 +225,7 @@ def check(gradients: list[torch.Tensor], f: int, m=None, **kwargs) -> str | None def upper_bound(n: int, f: int, d: int) -> float: - """ - Compute Bulyan's theoretical resilience upper bound. + """Compute Bulyan's theoretical resilience upper bound. Parameters ---------- @@ -243,7 +237,7 @@ def upper_bound(n: int, f: int, d: int) -> float: Gradient dimension. Accepted for compatibility with the GAR metadata interface; the current formula does not depend on it. - Returns + Returns: ------- float Upper bound on the ratio between non-Byzantine standard deviation and diff --git a/krum/aggregators/krum.py b/krum/aggregators/krum.py index f58581c..671d7bc 100644 --- a/krum/aggregators/krum.py +++ b/krum/aggregators/krum.py @@ -12,8 +12,7 @@ # Multi-Krum GAR. ### -""" -Krum and Multi-Krum are distance-based Byzantine-resilient aggregation rules. +r"""Krum and Multi-Krum are distance-based Byzantine-resilient aggregation rules. For each candidate gradient, the rule computes a score by summing the distances to its :math:`n - f - 1` nearest neighbours. It then selects the :math:`m` @@ -64,9 +63,8 @@ Number of gradients to select for averaging. Defaults to ``n - f - 2``. Must satisfy ``1 <= m <= n - f - 2``. -Example +Example: ------- - >>> import torch >>> from aggregators import krum >>> gradients = [ @@ -99,8 +97,7 @@ def _compute_scores(gradients: list[torch.Tensor], f: int, m: int, **kwargs) -> list[tuple[float, torch.Tensor]]: - """ - Compute Multi-Krum scores for all candidate gradients. + """Compute Multi-Krum scores for all candidate gradients. Parameters ---------- @@ -113,7 +110,7 @@ def _compute_scores(gradients: list[torch.Tensor], f: int, m: int, **kwargs) -> **kwargs : object Additional keyword arguments, ignored by this implementation. - Returns + Returns: ------- list of tuple[float, torch.Tensor] Candidate gradients paired with their scores, sorted by increasing @@ -145,8 +142,7 @@ def _compute_scores(gradients: list[torch.Tensor], f: int, m: int, **kwargs) -> def aggregate(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> torch.Tensor: - """ - Aggregate gradients with Multi-Krum. + """Aggregate gradients with Multi-Krum. Parameters ---------- @@ -161,12 +157,12 @@ def aggregate(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwa **kwargs : object Additional keyword arguments, ignored by this implementation. - Returns + Returns: ------- torch.Tensor Average of the selected ``m`` gradients with the smallest Krum scores. - Notes + Notes: ----- The output tensor is newly created and does not alias any input tensor. """ @@ -179,8 +175,7 @@ def aggregate(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwa def aggregate_native(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> torch.Tensor: - """ - Aggregate gradients with the native Multi-Krum implementation. + """Aggregate gradients with the native Multi-Krum implementation. Parameters ---------- @@ -193,7 +188,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, m: int | None = None **kwargs : object Additional keyword arguments, ignored by this implementation. - Returns + Returns: ------- torch.Tensor Average of the selected gradients. @@ -206,8 +201,7 @@ def aggregate_native(gradients: list[torch.Tensor], f: int, m: int | None = None def check(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) -> str | None: - """ - Check whether Multi-Krum can be used with the given parameters. + """Check whether Multi-Krum can be used with the given parameters. Parameters ---------- @@ -220,7 +214,7 @@ def check(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) **kwargs : object Additional keyword arguments, ignored by this implementation. - Returns + Returns: ------- str or None ``None`` when the parameters are valid, otherwise a user-facing error @@ -239,8 +233,7 @@ def check(gradients: list[torch.Tensor], f: int, m: int | None = None, **kwargs) def upper_bound(n: int, f: int, d: int) -> float: - """ - Compute the theoretical Multi-Krum robustness bound. + """Compute the theoretical Multi-Krum robustness bound. Parameters ---------- @@ -252,7 +245,7 @@ def upper_bound(n: int, f: int, d: int) -> float: Dimension of the gradient space. This parameter is accepted for the standard GAR metadata contract and is not used by this formula. - Returns + Returns: ------- float Upper bound on the ratio between non-Byzantine standard deviation and @@ -268,8 +261,7 @@ def influence( m: int | None = None, **kwargs, ) -> float: - """ - Compute the ratio of Byzantine gradients selected by Multi-Krum. + """Compute the ratio of Byzantine gradients selected by Multi-Krum. Parameters ---------- @@ -284,7 +276,7 @@ def influence( **kwargs : object Additional keyword arguments forwarded to score computation. - Returns + Returns: ------- float Ratio of selected gradients that come from ``attacks``. diff --git a/krum/aggregators/median.py b/krum/aggregators/median.py index a277414..fc462c9 100644 --- a/krum/aggregators/median.py +++ b/krum/aggregators/median.py @@ -12,8 +12,7 @@ # Coordinate-wise median GAR. ### -""" -Coordinate-wise median aggregation rule. +r"""Coordinate-wise median aggregation rule. This rule computes the median of each coordinate independently across all submitted gradients. It delegates to ``torch.median`` and does not filter @@ -45,7 +44,7 @@ - :math:`n` is the total number of workers. - :math:`f` is the number of Byzantine workers. -Example +Example: ------- >>> import torch >>> from aggregators import median @@ -77,8 +76,7 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: - """ - Compute the coordinate-wise median of all submitted gradients. + """Compute the coordinate-wise median of all submitted gradients. This method delegates to ``torch.median`` and does not filter non-finite values before aggregation. NaN values may propagate, while Inf values @@ -93,12 +91,12 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: Additional keyword arguments, accepted for compatibility with the GAR interface and ignored by this implementation. - Returns + Returns: ------- torch.Tensor Coordinate-wise median of all input gradients. - Notes + Notes: ----- The returned tensor is newly computed and does not alias any input tensor. """ @@ -106,8 +104,7 @@ def aggregate(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: def aggregate_native(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: - """ - Compute the coordinate-wise median using native C++/CUDA acceleration. + """Compute the coordinate-wise median using native C++/CUDA acceleration. Parameters ---------- @@ -117,7 +114,7 @@ def aggregate_native(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: Additional keyword arguments, accepted for compatibility with the GAR interface and ignored by this implementation. - Returns + Returns: ------- torch.Tensor Coordinate-wise median of all input gradients. @@ -126,8 +123,7 @@ def aggregate_native(gradients: list[torch.Tensor], **kwargs) -> torch.Tensor: def check(gradients: list[torch.Tensor], **kwargs) -> str | None: - """ - Check whether the median rule can be used with the given parameters. + """Check whether the median rule can be used with the given parameters. Parameters ---------- @@ -137,7 +133,7 @@ def check(gradients: list[torch.Tensor], **kwargs) -> str | None: Additional keyword arguments, accepted for compatibility with the GAR interface and ignored by this check. - Returns + Returns: ------- str or None ``None`` when parameters are valid, otherwise a user-facing error @@ -149,8 +145,7 @@ def check(gradients: list[torch.Tensor], **kwargs) -> str | None: def upper_bound(n: int, f: int, d: int) -> float: - """ - Compute the theoretical coordinate-wise median robustness bound. + r"""Compute the theoretical coordinate-wise median robustness bound. Parameters ---------- @@ -162,13 +157,13 @@ def upper_bound(n: int, f: int, d: int) -> float: Gradient dimension. Accepted for compatibility with the GAR metadata interface; the current formula does not depend on it. - Returns + Returns: ------- float Upper bound on the ratio between non-Byzantine standard deviation and gradient norm. - Notes + Notes: ----- The bound formula is: diff --git a/krum/attacks/__init__.py b/krum/attacks/__init__.py index bc0b218..6f9e281 100644 --- a/krum/attacks/__init__.py +++ b/krum/attacks/__init__.py @@ -12,8 +12,7 @@ # Loading of the local modules. ### -""" -Byzantine attack registry used to evaluate aggregation-rule robustness. +"""Byzantine attack registry used to evaluate aggregation-rule robustness. Each attack combines a keyword-only generation function with a validation function. Registered attacks are loaded dynamically and exposed as module-level @@ -57,8 +56,7 @@ def register(name: str, unchecked: Callable, check: Callable) -> None: - """ - Register a Byzantine attack. + """Register a Byzantine attack. Parameters ---------- @@ -71,7 +69,7 @@ def register(name: str, unchecked: Callable, check: Callable) -> None: Validation function associated with ``unchecked``. It must return ``None`` when parameters are valid, or an error message otherwise. - Returns + Returns: ------- None The attack is registered as a module-level callable. diff --git a/krum/attacks/identical.py b/krum/attacks/identical.py index de0b9a1..f9ae092 100644 --- a/krum/attacks/identical.py +++ b/krum/attacks/identical.py @@ -24,8 +24,7 @@ # 2019 Feb 16. ArXiv. URL: https://arxiv.org/pdf/1902.06156v1 ### -""" -Identical-gradient Byzantine attacks. +"""Identical-gradient Byzantine attacks. These attacks generate ``f_real`` references to the same newly created Byzantine gradient. The gradient is built from the average honest gradient plus @@ -60,9 +59,8 @@ negative : bool, optional Whether to negate the selected factor. Defaults to ``False``. -Example +Example: ------- - >>> import torch >>> from aggregators import average >>> from attacks import little @@ -92,8 +90,7 @@ def make_attack(compute_direction: Callable) -> Callable: - """ - Create an identical-gradient attack from a direction function. + """Create an identical-gradient attack from a direction function. Parameters ---------- @@ -101,7 +98,7 @@ def make_attack(compute_direction: Callable) -> Callable: Function computing the attack direction from stacked honest gradients and their average. - Returns + Returns: ------- callable Attack function compatible with the attack registration contract. @@ -117,8 +114,7 @@ def attack( negative: bool = False, **kwargs, ) -> list[torch.Tensor]: - """ - Generate identical Byzantine gradients. + """Generate identical Byzantine gradients. Parameters ---------- @@ -140,7 +136,7 @@ def attack( **kwargs : object Additional keyword arguments forwarded to ``compute_direction``. - Returns + Returns: ------- list of torch.Tensor Generated Byzantine gradients. Each entry references the same newly @@ -193,8 +189,7 @@ def check( negative: bool = False, **kwargs, ) -> str | None: - """ - Check parameter validity for identical-gradient attacks. + """Check parameter validity for identical-gradient attacks. Parameters ---------- @@ -211,7 +206,7 @@ def check( **kwargs : object Additional keyword arguments, ignored by this check. - Returns + Returns: ------- str or None ``None`` when parameters are valid, otherwise a user-facing error @@ -240,8 +235,7 @@ def bulyan( target_idx: int | str = -1, **kwargs, ) -> torch.Tensor: - """ - Compute the Bulyan attack direction. + """Compute the Bulyan attack direction. This direction is adapted from "The Hidden Vulnerability of Distributed Learning in Byzantium". @@ -258,7 +252,7 @@ def bulyan( **kwargs : object Additional keyword arguments, ignored by this direction. - Returns + Returns: ------- torch.Tensor Attack direction. @@ -272,8 +266,7 @@ def bulyan( def empire(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.Tensor: - """ - Compute the Empire attack direction. + """Compute the Empire attack direction. This direction is adapted from "Fall of Empires". @@ -286,7 +279,7 @@ def empire(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.T **kwargs : object Additional keyword arguments, ignored by this direction. - Returns + Returns: ------- torch.Tensor Attack direction, equal to the negative average honest gradient. @@ -295,8 +288,7 @@ def empire(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.T def little(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.Tensor: - """ - Compute the Little attack direction. + """Compute the Little attack direction. This direction is adapted from "A Little Is Enough". @@ -309,7 +301,7 @@ def little(grad_stck: torch.Tensor, grad_avg: torch.Tensor, **kwargs) -> torch.T **kwargs : object Additional keyword arguments, ignored by this direction. - Returns + Returns: ------- torch.Tensor Attack direction, computed as the coordinate-wise standard deviation of diff --git a/krum/attacks/nan.py b/krum/attacks/nan.py index 7a90aa0..d6a9107 100644 --- a/krum/attacks/nan.py +++ b/krum/attacks/nan.py @@ -12,8 +12,7 @@ # Attack that generates NaN gradient(s), hence the name. ### -""" -NaN-valued Byzantine gradient attack. +"""NaN-valued Byzantine gradient attack. This attack generates gradients filled with NaN (Not a Number) values in order to test whether an aggregation rule detects, rejects, or propagates non-finite @@ -33,9 +32,8 @@ - Does not alias any honest input gradient. - No parameters beyond gradient count. -Example +Example: ------- - >>> import torch >>> from attacks import nan >>> grad_honests = [torch.tensor([1., 2., 3.]), torch.tensor([4., 5., 6.])] @@ -55,8 +53,7 @@ def attack(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> list[torch.Tensor]: - """ - Generate NaN-valued Byzantine gradients. + """Generate NaN-valued Byzantine gradients. Parameters ---------- @@ -68,7 +65,7 @@ def attack(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> list[torc **kwargs : object Additional keyword arguments, ignored by this attack. - Returns + Returns: ------- list of torch.Tensor List containing ``f_real`` references to the same newly allocated @@ -86,8 +83,7 @@ def attack(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> list[torc def check(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> str | None: - """ - Check parameter validity for the NaN attack. + """Check parameter validity for the NaN attack. Parameters ---------- @@ -98,7 +94,7 @@ def check(grad_honests: list[torch.Tensor], f_real: int, **kwargs) -> str | None **kwargs : object Additional keyword arguments, ignored by this check. - Returns + Returns: ------- str or None ``None`` when parameters are valid, otherwise a user-facing error diff --git a/krum/experiments/__init__.py b/krum/experiments/__init__.py index 6cbebd2..40eb7ba 100644 --- a/krum/experiments/__init__.py +++ b/krum/experiments/__init__.py @@ -13,8 +13,7 @@ # Heavily relies on the module 'torchvision'. ### -""" -Experiment components for model training, dataset loading, and evaluation. +"""Experiment components for model training, dataset loading, and evaluation. This module groups the building blocks of a Krum training loop: @@ -28,7 +27,7 @@ Custom models and datasets can be added under ``experiments/models/`` and ``experiments/datasets/``; they are discovered automatically at import time. -Example +Example: ------- .. code-block:: python diff --git a/krum/experiments/checkpoint.py b/krum/experiments/checkpoint.py index 755dffe..42db54b 100644 --- a/krum/experiments/checkpoint.py +++ b/krum/experiments/checkpoint.py @@ -12,15 +12,13 @@ # Checkpoint helpers. ### -""" -Checkpoint management for model, optimizer, and arbitrary stateful objects. +"""Checkpoint management for model, optimizer, and arbitrary stateful objects. This module provides :class:`Checkpoint` for saving and restoring state dictionaries, and :class:`Storage` for plain-dictionary checkpointing. -Example +Example: ------- - >>> from experiments import Checkpoint, Model, Optimizer >>> ckpt = Checkpoint() >>> ckpt.snapshot(model).snapshot(optimizer) @@ -46,8 +44,7 @@ class Checkpoint: - """ - Collection of state dictionaries with saving/loading helpers. + """Collection of state dictionaries with saving/loading helpers. This class can snapshot any object implementing the ``state_dict`` / ``load_state_dict`` protocol (e.g. ``torch.nn.Module``, @@ -55,9 +52,8 @@ class Checkpoint: :class:`~experiments.model.Model` and :class:`~experiments.optimizer.Optimizer` wrappers automatically. - Example + Example: ------- - >>> ckpt = Checkpoint() >>> ckpt.snapshot(model, deepcopy=True) >>> ckpt.restore(model) @@ -71,8 +67,7 @@ class Checkpoint: @classmethod def _prepare(cls, instance): - """ - Prepare an instance for checkpointing. + """Prepare an instance for checkpointing. If the instance is a wrapped :class:`Model` or :class:`Optimizer`, the underlying PyTorch object is returned instead. @@ -82,12 +77,12 @@ def _prepare(cls, instance): instance : object Instance to snapshot or restore. - Returns + Returns: ------- tuple[object, str] Checkpoint-able instance and its fully-qualified storage key. - Raises + Raises: ------ tools.UserException If the instance lacks ``state_dict`` or ``load_state_dict``. @@ -109,16 +104,13 @@ def _prepare(cls, instance): return res, tools.fullqual(inst_cls) def __init__(self): - """ - Create an empty checkpoint. - """ + """Create an empty checkpoint.""" self._store = {} if __debug__: self._copied = {} def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): - """ - Take (or overwrite) a snapshot of an instance's state dictionary. + """Take (or overwrite) a snapshot of an instance's state dictionary. Parameters ---------- @@ -133,12 +125,12 @@ def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): Suppress the debug warning when restoring a reference is the intended behavior. - Returns + Returns: ------- Checkpoint Self, for chaining. - Raises + Raises: ------ tools.UserException If a snapshot already exists and ``overwrite`` is ``False``. @@ -158,8 +150,7 @@ def snapshot(self, instance, overwrite=False, deepcopy=False, nowarnref=False): return self def restore(self, instance, nothrow=False): - """ - Restore an instance from its stored snapshot. + """Restore an instance from its stored snapshot. Parameters ---------- @@ -168,12 +159,12 @@ def restore(self, instance, nothrow=False): nothrow : bool, optional If ``True``, silently skip when no snapshot is available. - Returns + Returns: ------- Checkpoint Self, for chaining. - Raises + Raises: ------ tools.UserException If no snapshot exists and ``nothrow`` is ``False``. @@ -195,8 +186,7 @@ def restore(self, instance, nothrow=False): return self def load(self, filepath, overwrite=False): - """ - Load checkpoint data from a file. + """Load checkpoint data from a file. Parameters ---------- @@ -205,12 +195,12 @@ def load(self, filepath, overwrite=False): overwrite : bool, optional Whether to overwrite any existing snapshots. - Returns + Returns: ------- Checkpoint Self, for chaining. - Raises + Raises: ------ tools.UserException If the checkpoint is non-empty and ``overwrite`` is ``False``. @@ -229,8 +219,7 @@ def load(self, filepath, overwrite=False): return self def save(self, filepath, overwrite=False): - """ - Save the current checkpoint to a file. + """Save the current checkpoint to a file. Parameters ---------- @@ -239,12 +228,12 @@ def save(self, filepath, overwrite=False): overwrite : bool, optional Whether to overwrite an existing file. - Returns + Returns: ------- Checkpoint Self, for chaining. - Raises + Raises: ------ tools.UserException If the file exists and ``overwrite`` is ``False``. @@ -266,18 +255,16 @@ def save(self, filepath, overwrite=False): class Storage(dict): - """ - Plain dictionary that implements the ``state_dict`` protocol. + """Plain dictionary that implements the ``state_dict`` protocol. This allows arbitrary key/value data to be snapshotted and restored alongside models and optimizers using :class:`Checkpoint`. """ def state_dict(self): - """ - Return the dictionary itself as state. + """Return the dictionary itself as state. - Returns + Returns: ------- dict Self. @@ -285,8 +272,7 @@ def state_dict(self): return self def load_state_dict(self, state): - """ - Replace contents with the given state. + """Replace contents with the given state. Parameters ---------- diff --git a/krum/experiments/configuration.py b/krum/experiments/configuration.py index 71fa269..f3ec9e1 100644 --- a/krum/experiments/configuration.py +++ b/krum/experiments/configuration.py @@ -12,15 +12,14 @@ # Configuration wrapper. ### -""" -Tensor configuration wrapper. +"""Tensor configuration wrapper. This module provides the :class:`Configuration` class, an immutable mapping that bundles ``device``, ``dtype``, and memory-transfer options. It is used throughout ``experiments`` to ensure every created or moved tensor uses the same configuration. -Example +Example: ------- .. code-block:: python @@ -44,8 +43,7 @@ class Configuration(Mapping): - """ - Immutable tensor configuration holder. + """Immutable tensor configuration holder. This class bundles ``device``, ``dtype``, and memory-transfer options into a single immutable mapping. It is used throughout ``experiments`` @@ -64,9 +62,8 @@ class Configuration(Mapping): relink : bool, optional Whether to relink instead of copying during parameter assignments. - Example + Example: ------- - >>> from experiments import Configuration >>> config = Configuration(device="cpu", dtype=torch.float32) >>> config["device"] @@ -77,8 +74,7 @@ class Configuration(Mapping): default_device = "cuda" if torch.cuda.is_available() else "cpu" def __init__(self, device=None, dtype=None, noblock=False, relink=False): - """ - Initialize the configuration. + """Initialize the configuration. Parameters ---------- @@ -113,10 +109,9 @@ def __init__(self, device=None, dtype=None, noblock=False, relink=False): self.relink = relink def __len__(self): - """ - Return the number of configuration entries. + """Return the number of configuration entries. - Returns + Returns: ------- int Number of entries in the configuration mapping. @@ -124,15 +119,14 @@ def __len__(self): return len(self._args) def __getitem__(self, name): - """ - Get a configuration value by name. + """Get a configuration value by name. Parameters ---------- name : str Configuration key (e.g. ``"device"``, ``"dtype"``). - Returns + Returns: ------- object Associated configuration value. @@ -140,10 +134,9 @@ def __getitem__(self, name): return self._args[name] def __iter__(self): - """ - Iterate over all configuration keys. + """Iterate over all configuration keys. - Returns + Returns: ------- iterator Iterator over configuration entry names. @@ -151,10 +144,9 @@ def __iter__(self): return self._args.__iter__() def __str__(self): - """ - Return a nicely printable representation. + """Return a nicely printable representation. - Returns + Returns: ------- str Human-readable configuration summary. @@ -164,10 +156,9 @@ def __str__(self): return str(temp) def __repr__(self): - """ - Return an evaluable string representation. + """Return an evaluable string representation. - Returns + Returns: ------- str Python-code string that evaluates to this configuration. diff --git a/krum/experiments/dataset.py b/krum/experiments/dataset.py index 643a73a..fcc9482 100644 --- a/krum/experiments/dataset.py +++ b/krum/experiments/dataset.py @@ -12,16 +12,14 @@ # Dataset wrappers/helpers. ### -""" -Dataset loading, batching, and sampling utilities. +"""Dataset loading, batching, and sampling utilities. This module wraps ``torchvision.datasets`` and custom dataset modules into uniform infinite-batch generators. It also provides helpers for train/test splitting and raw-tensor batching. -Example +Example: ------- - >>> from experiments import Dataset, make_datasets >>> trainset, testset = make_datasets("cifar10", train_batch=128) >>> inputs, targets = trainset.sample(config) @@ -74,8 +72,7 @@ def get_default_transform(dataset, train): - """ - Return the default transform for a torchvision dataset. + """Return the default transform for a torchvision dataset. Parameters ---------- @@ -85,7 +82,7 @@ def get_default_transform(dataset, train): Whether to return the training transform. Ignored when ``dataset`` is ``None``. - Returns + Returns: ------- torchvision.transforms.Compose or None Composed transform, or ``None`` if the dataset is unknown. @@ -102,8 +99,7 @@ def get_default_transform(dataset, train): class Dataset: - """ - Unified dataset wrapper producing infinite batches. + """Unified dataset wrapper producing infinite batches. This class can wrap: @@ -124,7 +120,7 @@ class Dataset: **kwargs : object Forwarded to the dataset constructor when ``data`` is a string. - Raises + Raises: ------ tools.UnavailableException If ``data`` is an unknown dataset name. @@ -137,10 +133,9 @@ class Dataset: @classmethod def get_default_root(cls): - """ - Lazily initialize and return the default dataset cache directory. + """Lazily initialize and return the default dataset cache directory. - Returns + Returns: ------- pathlib.Path Path to the dataset cache. Falls back to the system temp @@ -170,13 +165,12 @@ def get_default_root(cls): @classmethod def _get_datasets(cls): - """ - Lazily build the name-to-builder mapping for datasets. + """Lazily build the name-to-builder mapping for datasets. This includes all ``torchvision.datasets`` plus custom datasets discovered under ``experiments/datasets/``. - Returns + Returns: ------- dict[str, callable] Lower-case dataset names mapped to builder functions. @@ -259,8 +253,7 @@ def add_custom_datasets(name, module, _): return cls.__datasets def __init__(self, data, name=None, root=None, *args, **kwargs): - """ - Initialize the dataset wrapper. + """Initialize the dataset wrapper. Parameters ---------- @@ -302,10 +295,9 @@ def single_batch(): self.name = name def __str__(self): - """ - Return a printable representation. + """Return a printable representation. - Returns + Returns: ------- str Human-readable dataset name. @@ -313,15 +305,14 @@ def __str__(self): return f"dataset {self.name}" def sample(self, config=None): - """ - Sample the next batch. + """Sample the next batch. Parameters ---------- config : experiments.Configuration or None, optional Target configuration for tensor placement. - Returns + Returns: ------- tuple Next batch, optionally moved to the target device. @@ -332,8 +323,7 @@ def sample(self, config=None): return tns def epoch(self, config=None): - """ - Return a finite epoch iterator. + """Return a finite epoch iterator. .. note:: @@ -344,7 +334,7 @@ def epoch(self, config=None): config : experiments.Configuration or None, optional Target configuration for tensor placement. - Returns + Returns: ------- generator Finite iterator over one epoch. @@ -379,15 +369,14 @@ def generator(): def make_sampler(loader): - """ - Create an infinite sampler from a DataLoader. + """Create an infinite sampler from a DataLoader. Parameters ---------- loader : torch.utils.data.DataLoader Finite data loader. - Yields + Yields: ------ tuple Batches, transparently restarting the loader when exhausted. @@ -415,8 +404,7 @@ def make_datasets( num_workers=1, **custom_args, ): - """ - Build training and testing dataset wrappers. + """Build training and testing dataset wrappers. Parameters ---------- @@ -436,7 +424,7 @@ def make_datasets( **custom_args : object Additional keyword arguments forwarded to the dataset constructor. - Returns + Returns: ------- tuple[Dataset, Dataset] Training and testing dataset wrappers. @@ -476,8 +464,7 @@ def make_datasets( def batch_dataset(inputs, labels, train=False, batch_size=None, split=0.75): - """ - Batch a raw tensor dataset into infinite sampler generators. + """Batch a raw tensor dataset into infinite sampler generators. Parameters ---------- @@ -493,7 +480,7 @@ def batch_dataset(inputs, labels, train=False, batch_size=None, split=0.75): Fraction of samples for training when ``< 1``, or absolute count when ``>= 1``. - Returns + Returns: ------- generator Infinite sampler generator. diff --git a/krum/experiments/datasets/svm.py b/krum/experiments/datasets/svm.py index e5e0dc4..22d8a24 100644 --- a/krum/experiments/datasets/svm.py +++ b/krum/experiments/datasets/svm.py @@ -20,13 +20,13 @@ Each builder downloads the raw LIBSVM file on first use, caches a pre-processed PyTorch tensor version, and returns an infinite-batch generator. -Example +Example: ------- >>> from experiments import Dataset >>> dataset = Dataset("svm-phishing", train=True, download=True) >>> inputs, labels = dataset.sample() -See Also +See Also: -------- experiments.batch_dataset : helper used internally to create the infinite sampler from raw tensors. @@ -70,13 +70,13 @@ def get_phishing(root, url): URL to fetch the raw dataset from. If ``None`` and the cache is missing, a :class:`RuntimeError` is raised. - Returns + Returns: ------- tuple[torch.Tensor, torch.Tensor] ``(inputs, labels)`` where *inputs* has shape ``(11055, 68)`` and *labels* has shape ``(11055, 1)``. - Raises + Raises: ------ RuntimeError If the cache is missing and *url* is ``None``, or if the download @@ -158,7 +158,7 @@ def get_phishing(root, url): def phishing(train=True, batch_size=None, root=None, download=False, *args, **kwargs): - """Phishing dataset builder returning an infinite-batch generator. + r"""Phishing dataset builder returning an infinite-batch generator. Parameters ---------- @@ -178,12 +178,12 @@ def phishing(train=True, batch_size=None, root=None, download=False, *args, **kw **kwargs : object Ignored (kept for API compatibility). - Returns + Returns: ------- generator Infinite sampler yielding ``(inputs, labels)`` tuples. - Notes + Notes: ----- The dataset is split at position ``8400`` (≈ 76 % train / 24 % test). The split point was chosen for good divisibility diff --git a/krum/experiments/loss.py b/krum/experiments/loss.py index fd00f02..e70d534 100644 --- a/krum/experiments/loss.py +++ b/krum/experiments/loss.py @@ -12,8 +12,7 @@ # Loss/criterion wrappers/helpers. ### -""" -Loss and criterion wrappers for training and evaluation. +"""Loss and criterion wrappers for training and evaluation. This module provides: @@ -22,9 +21,8 @@ - :class:`Criterion` — non-derivable evaluation metrics (top-k accuracy, sigmoid accuracy). -Example +Example: ------- - >>> from experiments import Loss, Criterion >>> loss = Loss("crossentropy") + 0.01 * Loss("l2") >>> crit = Criterion("top-k", k=5) @@ -41,8 +39,7 @@ class Loss: - """ - Derivable loss function wrapper with composition support. + """Derivable loss function wrapper with composition support. Losses can be added (``loss1 + loss2``) and scaled (``0.5 * loss``). All standard PyTorch losses are available by lower-case name. @@ -59,7 +56,7 @@ class Loss: **kwargs : object Forwarded to the loss constructor when ``name_build`` is a string. - Raises + Raises: ------ tools.UnavailableException If ``name_build`` is an unknown string. @@ -69,8 +66,7 @@ class Loss: @staticmethod def _l1loss(output, target, params): - """ - L1 regularization on parameters. + """L1 regularization on parameters. Parameters ---------- @@ -81,7 +77,7 @@ def _l1loss(output, target, params): params : torch.Tensor Flat parameter tensor. - Returns + Returns: ------- torch.Tensor L1 norm of ``params``. @@ -90,8 +86,7 @@ def _l1loss(output, target, params): @staticmethod def _l2loss(output, target, params): - """ - L2 regularization on parameters. + """L2 regularization on parameters. Parameters ---------- @@ -102,7 +97,7 @@ def _l2loss(output, target, params): params : torch.Tensor Flat parameter tensor. - Returns + Returns: ------- torch.Tensor L2 norm of ``params``. @@ -111,10 +106,9 @@ def _l2loss(output, target, params): @classmethod def _l1loss_builder(cls): - """ - Build an L1 regularization loss instance. + """Build an L1 regularization loss instance. - Returns + Returns: ------- Loss L1 loss wrapper. @@ -123,10 +117,9 @@ def _l1loss_builder(cls): @classmethod def _l2loss_builder(cls): - """ - Build an L2 regularization loss instance. + """Build an L2 regularization loss instance. - Returns + Returns: ------- Loss L2 loss wrapper. @@ -138,15 +131,14 @@ def _l2loss_builder(cls): @staticmethod def _make_drop_params(builder): - """ - Wrap a PyTorch loss builder to drop the ``params`` argument. + """Wrap a PyTorch loss builder to drop the ``params`` argument. Parameters ---------- builder : callable Original loss constructor. - Returns + Returns: ------- callable Wrapped builder returning a loss that ignores ``params``. @@ -164,10 +156,9 @@ def drop_loss(output, target, params): @classmethod def _get_losses(cls): - """ - Lazily build the name-to-constructor mapping for losses. + """Lazily build the name-to-constructor mapping for losses. - Returns + Returns: ------- dict[str, callable] Lower-case loss names mapped to builders. @@ -190,8 +181,7 @@ def _get_losses(cls): return cls.__losses def __init__(self, name_build, *args, **kwargs): - """ - Initialize the loss wrapper. + """Initialize the loss wrapper. Parameters ---------- @@ -226,10 +216,9 @@ def __init__(self, name_build, *args, **kwargs): self._name = name def _str_make(self): - """ - Build the formatted loss string. + """Build the formatted loss string. - Returns + Returns: ------- str Human-readable loss description. @@ -237,10 +226,9 @@ def _str_make(self): return self._name if self._fact is None else f"{self._fact} x {self._name}" def __str__(self): - """ - Return a printable representation. + """Return a printable representation. - Returns + Returns: ------- str Human-readable loss name. @@ -248,8 +236,7 @@ def __str__(self): return f"loss {self._str_make()}" def __call__(self, output, target, params): - """ - Compute the loss. + """Compute the loss. Parameters ---------- @@ -260,7 +247,7 @@ def __call__(self, output, target, params): params : torch.Tensor Flat parameter tensor (for regularization losses). - Returns + Returns: ------- torch.Tensor Scalar loss value. @@ -271,15 +258,14 @@ def __call__(self, output, target, params): return res def __add__(self, loss): - """ - Sum two losses. + """Sum two losses. Parameters ---------- loss : Loss Loss to add. - Returns + Returns: ------- Loss Composite loss. @@ -296,15 +282,14 @@ def add(output, target, params): ) def __mul__(self, factor): - """ - Scale the loss by a constant factor. + """Scale the loss by a constant factor. Parameters ---------- factor : float Scaling factor. - Returns + Returns: ------- Loss Scaled loss. @@ -325,15 +310,14 @@ def __rmul__(self, *args, **kwargs): return self.__mul__(*args, **kwargs) def __imul__(self, factor): - """ - Scale the loss in place. + """Scale the loss in place. Parameters ---------- factor : float Scaling factor. - Returns + Returns: ------- Loss Self. @@ -347,8 +331,7 @@ def __imul__(self, factor): class Criterion: - """ - Non-derivable evaluation metric wrapper. + """Non-derivable evaluation metric wrapper. Available criteria: @@ -366,20 +349,17 @@ class Criterion: **kwargs : object Forwarded to the criterion constructor. - Raises + Raises: ------ tools.UnavailableException If ``name_build`` is an unknown string. """ class _TopkCriterion: - """ - Top-k classification accuracy. - """ + """Top-k classification accuracy.""" def __init__(self, k=1): - """ - Initialize top-k criterion. + """Initialize top-k criterion. Parameters ---------- @@ -389,8 +369,7 @@ def __init__(self, k=1): self.k = k def __call__(self, output, target): - """ - Compute top-k accuracy. + """Compute top-k accuracy. Parameters ---------- @@ -399,7 +378,7 @@ def __call__(self, output, target): target : torch.Tensor Batch x target index. - Returns + Returns: ------- torch.Tensor 1-D tensor ``[num_correct, batch_size]``. @@ -411,13 +390,10 @@ def __call__(self, output, target): )) class _SigmoidCriterion: - """ - Binary accuracy with 0.5 threshold. - """ + """Binary accuracy with 0.5 threshold.""" def __call__(self, output, target): - """ - Compute sigmoid accuracy. + """Compute sigmoid accuracy. Parameters ---------- @@ -426,7 +402,7 @@ def __call__(self, output, target): target : torch.Tensor Batch x target index (expected in ``{0, 1}``). - Returns + Returns: ------- torch.Tensor 1-D tensor ``[num_correct, batch_size]``. @@ -442,10 +418,9 @@ def __call__(self, output, target): @classmethod def _get_criterions(cls): - """ - Lazily build the name-to-constructor mapping. + """Lazily build the name-to-constructor mapping. - Returns + Returns: ------- dict[str, type] Lower-case criterion names mapped to classes. @@ -461,8 +436,7 @@ def _get_criterions(cls): return cls.__criterions def __init__(self, name_build, *args, **kwargs): - """ - Initialize the criterion wrapper. + """Initialize the criterion wrapper. Parameters ---------- @@ -490,10 +464,9 @@ def __init__(self, name_build, *args, **kwargs): self._name = name def __str__(self): - """ - Return a printable representation. + """Return a printable representation. - Returns + Returns: ------- str Human-readable criterion name. @@ -501,8 +474,7 @@ def __str__(self): return f"criterion {self._name}" def __call__(self, output, target): - """ - Compute the criterion. + """Compute the criterion. Parameters ---------- @@ -511,7 +483,7 @@ def __call__(self, output, target): target : torch.Tensor Expected output. - Returns + Returns: ------- torch.Tensor 1-D tensor ``[num_correct, batch_size]``. diff --git a/krum/experiments/model.py b/krum/experiments/model.py index ec82ebc..7b57b02 100644 --- a/krum/experiments/model.py +++ b/krum/experiments/model.py @@ -12,17 +12,15 @@ # Model wrappers/helpers. ### -""" -Model wrapper with name resolution, initialization, and gradient handling. +"""Model wrapper with name resolution, initialization, and gradient handling. This module provides :class:`Model`, a unified interface that can instantiate ``torchvision`` models (by name), custom models from ``experiments/models/``, or arbitrary callables. It also manages parameter flattening, gradient extraction, and data-parallelism automatically. -Example +Example: ------- - >>> from experiments import Model, Configuration >>> config = Configuration(device="cpu") >>> model = Model("resnet18", config, num_classes=10) @@ -48,8 +46,7 @@ class Model: - """ - Unified model wrapper with parameter and gradient management. + """Unified model wrapper with parameter and gradient management. Models are resolved by lower-case name from ``torchvision.models`` and from custom modules under ``experiments/models/``. Parameters are @@ -79,7 +76,7 @@ class Model: **kwargs : object Forwarded to the model constructor. - Raises + Raises: ------ tools.UnavailableException If ``name_build`` is an unknown string. @@ -95,10 +92,9 @@ class Model: @classmethod def _get_models(cls): - """ - Lazily build the name-to-constructor mapping for models. + """Lazily build the name-to-constructor mapping for models. - Returns + Returns: ------- dict[str, callable] Lower-case model names mapped to constructors. @@ -156,10 +152,9 @@ def add_custom_models(name, module, _): @classmethod def _get_inits(cls): - """ - Lazily build the name-to-function mapping for initializers. + """Lazily build the name-to-function mapping for initializers. - Returns + Returns: ------- dict[str, callable] Lower-case initializer names mapped to functions. @@ -191,8 +186,7 @@ def __init__( *args, **kwargs, ): - """ - Initialize the model wrapper. + """Initialize the model wrapper. Parameters ---------- @@ -280,10 +274,9 @@ def init(params): } def __str__(self): - """ - Return a printable representation. + """Return a printable representation. - Returns + Returns: ------- str Human-readable model name. @@ -292,10 +285,9 @@ def __str__(self): @property def config(self): - """ - Return the immutable configuration. + """Return the immutable configuration. - Returns + Returns: ------- experiments.Configuration Model configuration. @@ -303,8 +295,7 @@ def config(self): return self._config def default(self, name, new=None, erase=False): - """ - Get and/or set a named default. + """Get and/or set a named default. Parameters ---------- @@ -316,12 +307,12 @@ def default(self, name, new=None, erase=False): erase : bool, optional Force the value to ``None``. - Returns + Returns: ------- object Current (or old) value of the default. - Raises + Raises: ------ tools.UnavailableException If ``name`` is not a known default. @@ -334,20 +325,19 @@ def default(self, name, new=None, erase=False): return old def _resolve_defaults(self, **kwargs): - """ - Replace ``None`` values with registered defaults. + """Replace ``None`` values with registered defaults. Parameters ---------- **kwargs : object Keyword arguments where ``None`` means "use the default". - Returns + Returns: ------- list[object] Resolved values in argument order. - Raises + Raises: ------ RuntimeError If a required default is missing. @@ -362,8 +352,7 @@ def _resolve_defaults(self, **kwargs): return res def run(self, data, training=False): - """ - Forward pass through the model. + """Forward pass through the model. Parameters ---------- @@ -373,7 +362,7 @@ def run(self, data, training=False): Whether to use training mode (enables dropout, batch-norm updates, etc.). Defaults to evaluation mode. - Returns + Returns: ------- torch.Tensor Model output. @@ -389,10 +378,9 @@ def __call__(self, *args, **kwargs): return self.run(*args, **kwargs) def get(self): - """ - Get a reference to the flat parameter vector. + """Get a reference to the flat parameter vector. - Returns + Returns: ------- torch.Tensor Flat parameter tensor. Future calls to :meth:`set` will modify @@ -401,8 +389,7 @@ def get(self): return self._params def set(self, params, relink=None): - """ - Overwrite parameters with the given flat vector. + """Overwrite parameters with the given flat vector. Parameters ---------- @@ -423,10 +410,9 @@ def set(self, params, relink=None): self._params.copy_(params, non_blocking=self._config["non_blocking"]) def get_gradient(self): - """ - Get (or create) the flat gradient vector. + """Get (or create) the flat gradient vector. - Returns + Returns: ------- torch.Tensor Flat gradient tensor. Future calls to :meth:`set_gradient` will @@ -441,8 +427,7 @@ def get_gradient(self): return gradient def set_gradient(self, gradient, relink=None): - """ - Overwrite the gradient with the given flat vector. + """Overwrite the gradient with the given flat vector. Parameters ---------- @@ -463,8 +448,7 @@ def set_gradient(self, gradient, relink=None): self.get_gradient().copy_(gradient, non_blocking=self._config["non_blocking"]) def loss(self, dataset=None, loss=None, training=None): - """ - Estimate loss on a batch from the given dataset. + """Estimate loss on a batch from the given dataset. Parameters ---------- @@ -476,7 +460,7 @@ def loss(self, dataset=None, loss=None, training=None): Whether this is a training run. ``None`` guesses from ``torch.is_grad_enabled()``. - Returns + Returns: ------- torch.Tensor Scalar loss value. @@ -489,8 +473,7 @@ def loss(self, dataset=None, loss=None, training=None): @torch.enable_grad() def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): - """ - Compute gradient on a batch from the given dataset. + """Compute gradient on a batch from the given dataset. Parameters ---------- @@ -503,7 +486,7 @@ def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): **kwargs : object Forwarded to ``loss.backward()``. - Returns + Returns: ------- torch.Tensor or tuple[torch.Tensor, torch.Tensor] Flat gradient, optionally paired with the loss value. @@ -526,8 +509,7 @@ def backprop(self, dataset=None, loss=None, outloss=False, **kwargs): return self.get_gradient() def update(self, gradient, optimizer=None, relink=None): - """ - Update parameters using the given gradient and optimizer. + """Update parameters using the given gradient and optimizer. Parameters ---------- @@ -548,8 +530,7 @@ def update(self, gradient, optimizer=None, relink=None): @torch.no_grad() def eval(self, dataset=None, criterion=None): - """ - Evaluate the model on a batch from the given dataset. + """Evaluate the model on a batch from the given dataset. Parameters ---------- @@ -558,7 +539,7 @@ def eval(self, dataset=None, criterion=None): criterion : experiments.Criterion or None, optional Criterion function. ``None`` uses the default criterion. - Returns + Returns: ------- torch.Tensor Mean criterion value over the sampled batch. diff --git a/krum/experiments/models/simples.py b/krum/experiments/models/simples.py index fba3b79..90afe6d 100644 --- a/krum/experiments/models/simples.py +++ b/krum/experiments/models/simples.py @@ -19,7 +19,7 @@ :class:`experiments.Model` loader because they are listed in ``__all__``. Each constructor returns a ready-to-use ``torch.nn.Module``. -Example +Example: ------- >>> from experiments import Model, Configuration >>> config = Configuration(device="cpu") @@ -57,7 +57,7 @@ def forward(self, x): x : torch.Tensor Input tensor of shape ``(N, 1, 28, 28)`` or ``(N, 28, 28)``. - Returns + Returns: ------- torch.Tensor Log-probability distribution of shape ``(N, 10)``. @@ -76,7 +76,7 @@ def full(*args, **kwargs): **kwargs : object Forwarded to :class:`_Full`. - Returns + Returns: ------- _Full A fresh fully-connected model instance. @@ -113,7 +113,7 @@ def forward(self, x): x : torch.Tensor Input tensor of shape ``(N, 1, 28, 28)``. - Returns + Returns: ------- torch.Tensor Log-probability distribution of shape ``(N, 10)``. @@ -136,7 +136,7 @@ def conv(*args, **kwargs): **kwargs : object Forwarded to :class:`_Conv`. - Returns + Returns: ------- _Conv A fresh convolutional model instance. @@ -179,7 +179,7 @@ def forward(self, x): Input tensor of arbitrary shape; the last dimensions are flattened to ``din`` features. - Returns + Returns: ------- torch.Tensor Sigmoid-activated output of shape ``(..., dout)``. @@ -197,7 +197,7 @@ def logit(*args, **kwargs): **kwargs : object Forwarded to :class:`_Logit`. - Returns + Returns: ------- _Logit A fresh logistic-regression model instance. @@ -239,7 +239,7 @@ def forward(self, x): Input tensor of arbitrary shape; the last dimensions are flattened to ``din`` features. - Returns + Returns: ------- torch.Tensor Linear output of shape ``(..., dout)``. @@ -257,7 +257,7 @@ def linear(*args, **kwargs): **kwargs : object Forwarded to :class:`_Linear`. - Returns + Returns: ------- _Linear A fresh linear model instance. diff --git a/krum/experiments/optimizer.py b/krum/experiments/optimizer.py index 8a271bc..09393bc 100644 --- a/krum/experiments/optimizer.py +++ b/krum/experiments/optimizer.py @@ -12,16 +12,14 @@ # Optimizer wrapper. ### -""" -Optimizer wrapper that resolves PyTorch optimizers by name. +"""Optimizer wrapper that resolves PyTorch optimizers by name. This module provides a thin wrapper around ``torch.optim`` that allows optimizers to be instantiated from CLI strings while exposing a uniform interface for learning-rate adjustments. -Example +Example: ------- - >>> from experiments import Optimizer, Model >>> model = Model("lenet", num_classes=10) >>> optim = Optimizer("adam", model, lr=0.001) @@ -39,8 +37,7 @@ class Optimizer: - """ - Optimizer wrapper with name resolution and LR control. + """Optimizer wrapper with name resolution and LR control. Parameters ---------- @@ -56,7 +53,7 @@ class Optimizer: Additional keyword arguments forwarded to the optimizer constructor. - Raises + Raises: ------ tools.UnavailableException If ``name_build`` is a string that does not match any known @@ -68,10 +65,9 @@ class Optimizer: @classmethod def _get_optimizers(cls): - """ - Lazily build the name-to-class mapping for PyTorch optimizers. + """Lazily build the name-to-class mapping for PyTorch optimizers. - Returns + Returns: ------- dict[str, type] Mapping from lower-case names to optimizer classes. @@ -95,8 +91,7 @@ def _get_optimizers(cls): return cls.__optimizers def __init__(self, name_build, model, *args, **kwargs): - """ - Initialize the optimizer wrapper. + """Initialize the optimizer wrapper. Parameters ---------- @@ -126,20 +121,19 @@ def __init__(self, name_build, model, *args, **kwargs): self._name = name def __getattr__(self, *args): - """ - Forward attribute access to the wrapped optimizer. + """Forward attribute access to the wrapped optimizer. Parameters ---------- *args : object Either ``(name,)`` or ``(name, default)``. - Returns + Returns: ------- object Attribute from the wrapped optimizer instance. - Raises + Raises: ------ RuntimeError If called with more than two positional arguments. @@ -151,10 +145,9 @@ def __getattr__(self, *args): raise RuntimeError("'Optimizer.__getattr__' called with the wrong number of parameters") def __str__(self): - """ - Return a printable representation. + """Return a printable representation. - Returns + Returns: ------- str Human-readable optimizer name. @@ -162,8 +155,7 @@ def __str__(self): return f"optimizer {self._name}" def set_lr(self, lr): - """ - Set the learning rate for all parameter groups. + """Set the learning rate for all parameter groups. Parameters ---------- diff --git a/krum/native/__init__.py b/krum/native/__init__.py index f99d577..9a02fc2 100644 --- a/krum/native/__init__.py +++ b/krum/native/__init__.py @@ -1,16 +1,4 @@ -### -# @file __init__.py -# @author Sébastien Rouault -# -# @section LICENSE -# -# Copyright © 2018-2019 École Polytechnique Fédérale de Lausanne (EPFL). -# See LICENSE file. -# -# @section DESCRIPTION -# -# Native (i.e. C++/CUDA) implementations automated building and loading. -### +"""Native (i.e. C++/CUDA) implementations automated building and loading.""" # ---------------------------------------------------------------------------- # # Initialization procedure @@ -53,7 +41,7 @@ def _build_and_load() -> None: else: debug_mode = __debug__ cpp_std_envname = "NATIVE_STD" - cpp_std = os.environ.get(cpp_std_envname, "c++14") + cpp_std = os.environ.get(cpp_std_envname, "c++17") ident_to_is_python = {"so_": False, "py_": True} source_suffixes = {".cpp", ".cc", ".C", ".cxx", ".c++"} extra_cflags = ["-Wall", "-Wextra", "-Wfatal-errors", f"-std={cpp_std}"] @@ -102,12 +90,21 @@ def _build_and_load() -> None: # Local procedures def build_and_load_one(path, deps=None): - """Check if the given directory is a module to build and load, and if yes recursively build and load its dependencies before it. - Args: - path Given directory path - deps Dependent module paths + """Check if the given directory is a module to build and load. + + If it is a module, recursively build and load its dependencies first. + + Parameters + ---------- + path : pathlib.Path + Given directory path. + deps : list of pathlib.Path, optional + Dependent module paths. + Returns: - True on success, False on failure, None if not a module + ------- + bool or None + ``True`` on success, ``False`` on failure, ``None`` if not a module. """ if deps is None: deps = [] @@ -157,9 +154,7 @@ def build_and_load_one(path, deps=None): fail_modules.append(path) # Mark as failed return False if res: # Module and its sub-dependencies was/were built and loaded successfully - this_ldflags.append( - "-Wl,--library=:" + str((base_directory / modname / (modname + ".so")).resolve()) - ) + this_ldflags.append(str((base_directory / modname / (modname + ".so")).resolve())) # List sources sources = [] for subpath in path.iterdir(): diff --git a/krum/tools/__init__.py b/krum/tools/__init__.py index 429b870..33698f5 100644 --- a/krum/tools/__init__.py +++ b/krum/tools/__init__.py @@ -12,8 +12,7 @@ # Bunch of useful tools, but each too small to have its own package. ### -""" -Core Utility Module for Krum. +"""Core Utility Module for Krum. This module provides the fundamental infrastructure utilities used throughout Krum, including logging, error handling, and common operations. @@ -59,9 +58,7 @@ class UserException(Exception): - """ - Base exception for user-facing errors. - """ + """Base exception for user-facing errors.""" pass @@ -71,9 +68,7 @@ class UserException(Exception): class Context: - """ - Per-thread logging context and color manager. - """ + """Per-thread logging context and color manager.""" # Constants __colors = { @@ -96,9 +91,7 @@ class Context: @classmethod def __local_init(self): - """ - Initialize thread-local context state if necessary. - """ + """Initialize thread-local context state if necessary.""" if not hasattr(self.__local, "stack"): self.__local.stack = [] # List of pairs (context name, color code) self.__local.header = "" # Current header string @@ -106,9 +99,7 @@ def __local_init(self): @classmethod def __rebuild(self): - """ - Rebuild the current log header and color from the context stack. - """ + """Rebuild the current log header and color from the context stack.""" # Collect current header and color header = "" color = None @@ -129,10 +120,9 @@ def __rebuild(self): @classmethod def _get(self): - """ - Return the current thread-local header and color escape sequences. + """Return the current thread-local header and color escape sequences. - Returns + Returns: ------- tuple[str, str, str, str] Current header, header color prefix, message color prefix, and color @@ -147,8 +137,7 @@ def _get(self): ) def __init__(self, cntxtname: str | None, colorname: str | None) -> None: - """ - Create a context stack entry. + """Create a context stack entry. Parameters ---------- @@ -169,10 +158,9 @@ def __init__(self, cntxtname: str | None, colorname: str | None) -> None: self.__pair = (cntxtname, colorcode) def __enter__(self): - """ - Enter the logging context. + """Enter the logging context. - Returns + Returns: ------- Context This context manager instance. @@ -183,8 +171,7 @@ def __enter__(self): return self def __exit__(self, *args, **kwargs) -> None: - """ - Leave the logging context. + """Leave the logging context. Parameters ---------- @@ -198,13 +185,10 @@ def __exit__(self, *args, **kwargs) -> None: class ContextIOWrapper: - """ - Context-aware text I/O wrapper. - """ + """Context-aware text I/O wrapper.""" def __init__(self, output: TextIO, nocolor: bool | None = None) -> None: - """ - Wrap a text output stream. + """Wrap a text output stream. Parameters ---------- @@ -224,15 +208,14 @@ def __init__(self, output: TextIO, nocolor: bool | None = None) -> None: self.__nocolor = nocolor def __getattr__(self, name: str) -> object: - """ - Forward non-overridden attribute access to the wrapped stream. + """Forward non-overridden attribute access to the wrapped stream. Parameters ---------- name : str Attribute name. - Returns + Returns: ------- object Attribute value from the wrapped stream. @@ -240,15 +223,14 @@ def __getattr__(self, name: str) -> object: return getattr(self.__output, name) def write(self, text: str) -> int: - """ - Write text with the active context prefix and color. + """Write text with the active context prefix and color. Parameters ---------- text : str Text to write. - Returns + Returns: ------- int Return value forwarded from the wrapped stream's ``write`` method. @@ -275,23 +257,21 @@ def write(self, text: str) -> int: def _make_color_print(color: str) -> Callable[..., object]: - """ - Build a ``print`` wrapper that runs inside a colored context. + """Build a ``print`` wrapper that runs inside a colored context. Parameters ---------- color : str Target color name. - Returns + Returns: ------- object Print wrapper closure. """ def color_print(*args, context: str | None = None, **kwargs) -> object: - """ - Print inside the configured colored context. + """Print inside the configured colored context. Parameters ---------- @@ -302,7 +282,7 @@ def color_print(*args, context: str | None = None, **kwargs) -> object: **kwargs : object Keyword arguments forwarded to :func:`print`. - Returns + Returns: ------- object Return value forwarded from :func:`print`. @@ -322,8 +302,7 @@ def color_print(*args, context: str | None = None, **kwargs) -> object: def fatal(*args, with_traceback: bool = False, **kwargs) -> None: - """ - Print an error message and terminate the process with exit code 1. + """Print an error message and terminate the process with exit code 1. Parameters ---------- @@ -351,23 +330,21 @@ def fatal(*args, with_traceback: bool = False, **kwargs) -> None: def uncaught_wrap(hook: Callable[..., Any]) -> Callable[..., Any]: - """ - Wrap an uncaught exception hook with contextual logging. + """Wrap an uncaught exception hook with contextual logging. Parameters ---------- hook : object Uncaught exception hook to wrap. - Returns + Returns: ------- object Wrapped uncaught exception hook. """ def uncaught_call(etype: type, evalue: object, traceback: object) -> object: - """ - Handle uncaught exceptions with user-facing context. + """Handle uncaught exceptions with user-facing context. Parameters ---------- @@ -378,7 +355,7 @@ def uncaught_call(etype: type, evalue: object, traceback: object) -> object: traceback : object Traceback associated with the exception. - Returns + Returns: ------- object Return value forwarded from the wrapped hook for non-user exceptions. @@ -404,8 +381,7 @@ def uncaught_call(etype: type, evalue: object, traceback: object) -> object: def import_exported_symbols(name: str, module, scope: dict) -> None: - """ - Import a module's exported symbols into a target scope. + """Import a module's exported symbols into a target scope. Parameters ---------- @@ -443,8 +419,7 @@ def import_directory( post: Callable[..., Any] | None = import_exported_symbols, ignore: list[str] | None = None, ) -> None: - """ - Import every Python module from a directory into a target scope. + """Import every Python module from a directory into a target scope. Parameters ---------- diff --git a/krum/tools/jobs.py b/krum/tools/jobs.py index 128f67f..f19fe5b 100644 --- a/krum/tools/jobs.py +++ b/krum/tools/jobs.py @@ -12,8 +12,7 @@ # Simple job management for reproduction scripts. ### -""" -Experiment job management helpers. +"""Experiment job management helpers. This module provides utilities for running and managing experiment jobs in a reproducible manner. @@ -30,7 +29,7 @@ ``dict_to_cmdlist`` converts dictionaries into command-line argument lists. ``move_directory`` moves an existing directory aside with versioning. -Example +Example: ------- .. code-block:: python @@ -71,17 +70,17 @@ def move_directory(path: Path) -> Path: path : pathlib.Path Directory path to move aside if it already exists. - Returns + Returns: ------- pathlib.Path The input path, returned unchanged for chaining. - Raises + Raises: ------ RuntimeError If ``path`` exists but is not a directory (or a symlink to one). - Example + Example: ------- >>> from pathlib import Path >>> move_directory(Path("results")) @@ -113,17 +112,17 @@ def dict_to_cmdlist(dp: dict[str, Any]) -> list[str]: dp : dict of str to Any Dictionary mapping parameter names to values. - Returns + Returns: ------- list of str Command-line arguments such as ``["--lr", "0.01", "--batch", "32"]``. - Notes + Notes: ----- - Boolean values are included only when they are ``True``. - Lists and tuples expand to repeated ``--name value`` pairs. - Example + Example: ------- >>> dict_to_cmdlist({"lr": 0.01, "batch": 32, "debug": True}) ['--lr', '0.01', '--batch', '32', '--debug'] @@ -161,13 +160,20 @@ class Command: Base command as an iterable of strings (e.g. ``["python", "train.py"]``). The iterable is copied on instantiation. - Attributes + Attributes: ---------- _basecmd : list of str Internal copy of the base command. """ def __init__(self, command: Iterable[str]) -> None: + """Initialize the command builder. + + Parameters + ---------- + command : iterable of str + Base command as an iterable of strings. + """ self._basecmd: list[str] = list(command) def build(self, seed: int | str, device: str, resdir: Path | str) -> list[str]: @@ -182,7 +188,7 @@ def build(self, seed: int | str, device: str, resdir: Path | str) -> list[str]: resdir : pathlib.Path or str Target directory path for results. - Returns + Returns: ------- list of str Final command list ready to be passed to ``subprocess.run``. @@ -220,7 +226,7 @@ class Jobs: seeds : sequence of int, optional Seeds to use for repeating experiments. Default is ``range(1, 6)``. - Attributes + Attributes: ---------- _res_dir : pathlib.Path Resolved result directory. @@ -327,6 +333,20 @@ def __init__( devmult: int = 1, seeds: Sequence[int] | None = None, ) -> None: + """Initialize the experiment launcher. + + Parameters + ---------- + res_dir : pathlib.Path or str + Target directory path for results. + devices : list of str, optional + Devices on which to run experiments (e.g. ``["cuda:0"]``). + Defaults to ``["cpu"]``. + devmult : int, optional + Device multiplier. Defaults to 1. + seeds : sequence of int, optional + Seeds to use for the experiments. Defaults to ``range(1, 6)``. + """ # Initialize instance if devices is None: devices = ["cpu"] @@ -352,7 +372,7 @@ def __init__( def get_seeds(self) -> tuple[int, ...]: """Get the list of seeds used for repeating the experiments. - Returns + Returns: ------- tuple of int Seeds used by this manager. @@ -386,7 +406,7 @@ def submit(self, name: str, command: Command) -> None: command : Command Command builder to execute. - Raises + Raises: ------ RuntimeError If the manager has already been closed. diff --git a/krum/tools/misc.py b/krum/tools/misc.py index edceac9..3d7b135 100644 --- a/krum/tools/misc.py +++ b/krum/tools/misc.py @@ -12,8 +12,7 @@ # Miscellaneous Python helpers. ### -""" -Utilities shared across the repository. +"""Utilities shared across the repository. This module groups small helpers used for exception handling, parsing, timing, interactive exploration, and light registry patterns. @@ -41,7 +40,7 @@ ``pairwise``, ``line_maximize``, ``interactive``, and ``get_loaded_dependencies`` cover assorted convenience tasks. -Example +Example: ------- .. code-block:: python @@ -93,8 +92,7 @@ def make_unavailable_exception_text(data: list[str], name: str, what: str = "entry") -> str: - """ - Build the message used by :class:`UnavailableException`. + """Build the message used by :class:`UnavailableException`. Parameters ---------- @@ -105,7 +103,7 @@ def make_unavailable_exception_text(data: list[str], name: str, what: str = "ent what : str, optional Human-readable description of the named objects. - Returns + Returns: ------- str User-facing message that lists the available names, or states that no @@ -122,8 +120,7 @@ def make_unavailable_exception_text(data: list[str], name: str, what: str = "ent def fatal_unavailable(*args, **kwargs) -> None: - """ - Report an unavailable entry as a fatal user-facing error. + """Report an unavailable entry as a fatal user-facing error. Parameters ---------- @@ -141,8 +138,7 @@ class UnavailableException(UserException): """User-facing exception raised when a selected registry entry is missing.""" def __init__(self, *args, **kwargs) -> None: - """ - Initialize the exception message. + """Initialize the exception message. Parameters ---------- @@ -173,8 +169,7 @@ class MethodCallReplicator: """ def __init__(self, *args: object) -> None: - """ - Bind the instances that should receive replicated method calls. + """Bind the instances that should receive replicated method calls. Parameters ---------- @@ -187,15 +182,14 @@ def __init__(self, *args: object) -> None: self.__instances = args def __getattr__(self, name: str) -> object: - """ - Return a closure that replicates the named method call. + """Return a closure that replicates the named method call. Parameters ---------- name : str Name of the method or callable attribute to replicate. - Returns + Returns: ------- object Callable that forwards its arguments to every target callable and @@ -206,8 +200,7 @@ def __getattr__(self, name: str) -> object: # Replication closure def calls(*args, **kwargs) -> list[object]: - """ - Call each target callable with the provided arguments. + """Call each target callable with the provided arguments. Parameters ---------- @@ -216,7 +209,7 @@ def calls(*args, **kwargs) -> list[object]: **kwargs : object Keyword arguments forwarded to every target callable. - Returns + Returns: ------- list[object] Results returned by the target callables, in instance order. @@ -235,8 +228,7 @@ class ClassRegister: """Minimal registry mapping user-visible names to classes.""" def __init__(self, singular: str, optplural: str | None = None) -> None: - """ - Create an empty class registry. + """Create an empty class registry. Parameters ---------- @@ -258,8 +250,7 @@ def itemize(self) -> list[str]: return list(self.__register.keys()) def register(self, name: str, cls: type) -> None: - """ - Register a class under a user-visible name. + """Register a class under a user-visible name. Parameters ---------- @@ -279,8 +270,7 @@ def register(self, name: str, cls: type) -> None: self.__register[name] = cls def instantiate(self, name: str, *args, **kwargs) -> object: - """ - Instantiate the class registered under ``name``. + """Instantiate the class registered under ``name``. Parameters ---------- @@ -291,12 +281,12 @@ def instantiate(self, name: str, *args, **kwargs) -> object: **kwargs : object Keyword arguments forwarded to the class constructor. - Returns + Returns: ------- object Instance of the registered class. - Raises + Raises: ------ UserException If ``name`` is not registered. @@ -318,8 +308,7 @@ def instantiate(self, name: str, *args, **kwargs) -> object: def parse_keyval_auto_convert(val: str) -> object: - """ - Infer and convert the type represented by a string. + """Infer and convert the type represented by a string. Conversion is attempted in this order: boolean literals, integer, float, then string. @@ -329,7 +318,7 @@ def parse_keyval_auto_convert(val: str) -> object: val : str String value to convert. - Returns + Returns: ------- object Converted value, or ``val`` unchanged if no non-string type matches. @@ -351,8 +340,7 @@ def parse_keyval_auto_convert(val: str) -> object: def parse_keyval(list_keyval: list[str], defaults: dict[str, object] | None = None) -> dict[str, object]: - """ - Parse ``:`` strings into a typed dictionary. + """Parse ``:`` strings into a typed dictionary. This helper is used for command-line options such as ``--gar-args lr:0.01``. Keys present in ``defaults`` are converted to the @@ -368,20 +356,19 @@ def parse_keyval(list_keyval: list[str], defaults: dict[str, object] | None = No inference and are copied into the returned dictionary when the corresponding key is not explicitly provided. - Returns + Returns: ------- dict[str, object] Parsed key/value pairs with converted values. - Raises + Raises: ------ UserException If an entry is malformed, a key is provided more than once, or conversion to a default value's type fails. - Example + Example: ------- - >>> parse_keyval(["lr:0.01", "batch:32"], defaults={"lr": 0.1}) {'lr': 0.01, 'batch': 32} >>> parse_keyval(["debug:true", "workers:4"], defaults={}) @@ -432,23 +419,21 @@ def parse_keyval(list_keyval: list[str], defaults: dict[str, object] | None = No def fullqual(obj: object) -> str: - """ - Return a class or instance's fully qualified name. + """Return a class or instance's fully qualified name. Parameters ---------- obj : object Class or instance to describe. - Returns + Returns: ------- str Fully qualified class name. Instances are prefixed with ``"instance of "``. - Example + Example: ------- - >>> fullqual(str) 'builtins.str' >>> fullqual(pathlib.Path(".")) @@ -473,8 +458,7 @@ def fullqual(obj: object) -> str: def onetime(name: str | None = None) -> tuple[Callable[..., Any], Callable[..., Any]]: - """ - Create or retrieve a thread-safe one-shot flag. + """Create or retrieve a thread-safe one-shot flag. Parameters ---------- @@ -482,7 +466,7 @@ def onetime(name: str | None = None) -> tuple[Callable[..., Any], Callable[..., Optional global flag name. Reusing the same name returns the same getter/setter pair. - Returns + Returns: ------- tuple[callable, callable] ``(getter, setter)`` pair. ``getter`` returns whether the flag has been @@ -498,8 +482,7 @@ def onetime(name: str | None = None) -> tuple[Callable[..., Any], Callable[..., # Management closures def getter(*args, **kwargs): - """ - Return whether the one-shot flag has been set. + """Return whether the one-shot flag has been set. Parameters ---------- @@ -508,7 +491,7 @@ def getter(*args, **kwargs): **kwargs : object Ignored keyword arguments. - Returns + Returns: ------- bool ``True`` once the associated setter has been called, otherwise @@ -520,8 +503,7 @@ def getter(*args, **kwargs): return value def setter(*args, **kwargs): - """ - Set the one-shot flag to ``True``. + """Set the one-shot flag to ``True``. Parameters ---------- @@ -553,8 +535,7 @@ class TimedContext(Context): """Context manager that logs the elapsed runtime of a block.""" def __init__(self, *args, **kwargs) -> None: - """ - Initialize the timed context. + """Initialize the timed context. Parameters ---------- @@ -566,10 +547,9 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) def __enter__(self): - """ - Start timing and enter the parent context. + """Start timing and enter the parent context. - Returns + Returns: ------- object Value returned by ``Context.__enter__``. @@ -578,8 +558,7 @@ def __enter__(self): return super().__enter__() def __exit__(self, *args, **kwargs) -> None: - """ - Stop timing, log elapsed time, and exit the parent context. + """Stop timing, log elapsed time, and exit the parent context. Parameters ---------- @@ -613,8 +592,7 @@ def interactive( prompt: str = ">>> ", cprmpt: str = "... ", ) -> None: - """ - Run a small interactive Python prompt. + """Run a small interactive Python prompt. Press ``Ctrl+D`` or send an equivalent EOF signal to leave the prompt. @@ -704,17 +682,16 @@ def interactive( def get_loaded_dependencies() -> list[tuple[str, str | None, int]]: - """ - List currently loaded non-built-in root modules. + """List currently loaded non-built-in root modules. - Returns + Returns: ------- list[tuple[str, str | None, int]] Tuples of ``(root_module_name, version, flavor)``. ``version`` is the module's ``__version__`` attribute when present, otherwise ``None``. ``flavor`` is one of ``IS_STANDARD``, ``IS_SITE``, or ``IS_LOCAL``. - Raises + Raises: ------ RuntimeError If Python's site-packages locations cannot be discovered on the current @@ -770,8 +747,7 @@ def line_maximize( delta: float = 1.0, ratio: float = 0.8, ) -> float: - """ - Best-effort argmax search for a scalar function on non-negative inputs. + """Best-effort argmax search for a scalar function on non-negative inputs. The search first expands while values improve, then contracts the step size to refine the best point found within the evaluation budget. @@ -791,7 +767,7 @@ def line_maximize( Step contraction ratio, expected to be between ``0.5`` and ``1.0`` excluded. - Returns + Returns: ------- float Best point found under the evaluation budget. @@ -839,22 +815,20 @@ def line_maximize( def pairwise(data: list | tuple): - """ - Yield unordered pairs from an indexable collection. + """Yield unordered pairs from an indexable collection. Parameters ---------- data : list | tuple Indexable collection such as a ``list`` or ``tuple``. - Yields + Yields: ------ tuple Tuples ``(data[i], data[j])`` for every ``i < j``. - Example + Example: ------- - >>> list(pairwise([1, 2, 3])) [(1, 2), (1, 3), (2, 3)] >>> list(pairwise("ab")) @@ -871,10 +845,9 @@ def pairwise(data: list | tuple): def localtime() -> str: - """ - Return the current local time formatted for logs. + """Return the current local time formatted for logs. - Returns + Returns: ------- str Local time as ``YYYY/MM/DD HH:MM:SS``. @@ -884,10 +857,9 @@ def localtime() -> str: def deltatime_point() -> int: - """ - Capture an opaque point in monotonic time. + """Capture an opaque point in monotonic time. - Returns + Returns: ------- int Monotonic timestamp rounded to seconds. The value is intended for use @@ -898,8 +870,7 @@ def deltatime_point() -> int: def deltatime_format(a: int, b: int) -> tuple[int, str]: - """ - Compute and format elapsed time between two captured points. + """Compute and format elapsed time between two captured points. Parameters ---------- @@ -908,7 +879,7 @@ def deltatime_format(a: int, b: int) -> tuple[int, str]: b : int Later point returned by :func:`deltatime_point`. - Returns + Returns: ------- tuple[int, str] Tuple ``(seconds, text)`` containing elapsed seconds and a diff --git a/krum/tools/pytorch.py b/krum/tools/pytorch.py index 84b45ac..1145936 100644 --- a/krum/tools/pytorch.py +++ b/krum/tools/pytorch.py @@ -12,7 +12,8 @@ # Helpers relative to PyTorch. ### -""" +"""Helper functions for PyTorch tensor manipulation and gradient handling. + This module provides helper functions for PyTorch tensor manipulation, gradient handling, and common operations used throughout Krum. @@ -43,7 +44,7 @@ - ``regression``: Generic optimization for free variables - ``pnm``: Export tensor to PGM/PBM format -Example +Example: ------- .. code-block:: python @@ -85,8 +86,7 @@ def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: - """ - Relink tensors to share a common contiguous memory storage. + """Relink tensors to share a common contiguous memory storage. Parameters ---------- @@ -96,19 +96,18 @@ def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: Flat tensor of sufficient size to use as underlying storage. Must have the same dtype as the given tensors. - Returns + Returns: ------- torch.Tensor The common tensor, with ``linked_tensors`` attribute set. - Notes + Notes: ----- The returned tensor has a ``linked_tensors`` attribute pointing to the original tensors. This allows updating all tensors simultaneously. - Example + Example: ------- - >>> import torch >>> from tools import relink >>> t1 = torch.tensor([1., 2.]) @@ -132,28 +131,26 @@ def relink(tensors: list[torch.Tensor], common: torch.Tensor) -> torch.Tensor: def flatten(tensors: list[torch.Tensor]) -> torch.Tensor: - """ - Flatten tensors into a single contiguous tensor. + """Flatten tensors into a single contiguous tensor. Parameters ---------- tensors : list of torch.Tensor Tensors to flatten. All must have the same dtype. - Returns + Returns: ------- torch.Tensor Flat tensor containing all data from input tensors, stored in a contiguous memory segment. - Notes + Notes: ----- The returned tensor shares memory with the original tensors. Modifications to the flat tensor will reflect in the original tensors. - Example + Example: ------- - >>> import torch >>> from tools import flatten >>> t1 = torch.tensor([1., 2.]) @@ -175,23 +172,21 @@ def flatten(tensors: list[torch.Tensor]) -> torch.Tensor: def grad_of(tensor: torch.Tensor) -> torch.Tensor: - """ - Get the gradient of a given tensor, create zero gradient if missing. + """Get the gradient of a given tensor, create zero gradient if missing. Parameters ---------- tensor : torch.Tensor A tensor that may have a gradient attached. - Returns + Returns: ------- torch.Tensor The gradient tensor. If none existed, a zero gradient is created and attached to the tensor. - Example + Example: ------- - >>> import torch >>> from tools import grad_of >>> x = torch.randn(3, requires_grad=True) @@ -210,22 +205,20 @@ def grad_of(tensor: torch.Tensor) -> torch.Tensor: def grads_of(tensors: list[torch.Tensor]): - """ - Generator that gets or creates gradients for multiple tensors. + """Generator that gets or creates gradients for multiple tensors. Parameters ---------- tensors : list of torch.Tensor Tensors that may have gradients attached. - Yields + Yields: ------ torch.Tensor Gradient for each tensor. - Example + Example: ------- - >>> import torch >>> from tools import grads_of >>> params = [torch.randn(3, requires_grad=True) for _ in range(2)] @@ -247,21 +240,20 @@ def grads_of(tensors: list[torch.Tensor]): def compute_avg_dev_max( samples: list[torch.Tensor], ) -> tuple[torch.Tensor | None, float, float, float]: - """ - Compute average, average norm, norm deviation, and max absolute value. + """Compute average, average norm, norm deviation, and max absolute value. Parameters ---------- samples : list of torch.Tensor List of tensors to compute statistics on. - Returns + Returns: ------- tuple[torch.Tensor, float, float, float] Tuple containing: average tensor, average norm, norm deviation, and max absolute value. - Notes + Notes: ----- The returned tensor is newly created and does not alias any input tensor. """ @@ -287,8 +279,7 @@ def compute_avg_dev_max( class AccumulatedTimedContext: - """ - Accumulated timed context manager with optional CUDA synchronization. + """Accumulated timed context manager with optional CUDA synchronization. This context manager measures elapsed time across multiple entries, with optional CUDA synchronization to ensure accurate GPU timing. @@ -299,7 +290,7 @@ class AccumulatedTimedContext: Whether to synchronize CUDA before and after timing. Defaults to ``False``. - Example + Example: ------- >>> import torch >>> from tools import AccumulatedTimedContext @@ -311,8 +302,7 @@ class AccumulatedTimedContext: """ def __init__(self, sync: bool | float = False) -> None: - """ - Initialize the accumulated timed context. + """Initialize the accumulated timed context. Parameters ---------- @@ -330,10 +320,9 @@ def __init__(self, sync: bool | float = False) -> None: self._elapsed = 0.0 def __enter__(self): - """ - Enter the context and start timing. + """Enter the context and start timing. - Returns + Returns: ------- AccumulatedTimedContext Self reference for context management. @@ -344,8 +333,7 @@ def __enter__(self): return self def __exit__(self, *args) -> None: - """ - Exit the context, stop timing, and accumulate elapsed time. + """Exit the context, stop timing, and accumulate elapsed time. Parameters ---------- @@ -358,10 +346,9 @@ def __exit__(self, *args) -> None: self._elapsed += time.perf_counter() - self._start def current_runtime(self) -> float: - """ - Return the accumulated runtime. + """Return the accumulated runtime. - Returns + Returns: ------- float Total accumulated time in seconds. @@ -374,8 +361,7 @@ def current_runtime(self) -> float: def weighted_mse_loss(input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: - """ - Compute weighted mean squared error loss. + """Compute weighted mean squared error loss. Parameters ---------- @@ -386,12 +372,12 @@ def weighted_mse_loss(input: torch.Tensor, target: torch.Tensor, weight: torch.T weight : torch.Tensor Weight tensor for each element. - Returns + Returns: ------- torch.Tensor Weighted MSE loss value. - Notes + Notes: ----- The returned tensor is newly created and does not alias any input tensor. """ @@ -399,21 +385,17 @@ def weighted_mse_loss(input: torch.Tensor, target: torch.Tensor, weight: torch.T class WeightedMSELoss(torch.nn.Module): - """ - Weighted MSE loss module. + """Weighted MSE loss module. This module wraps :func:`weighted_mse_loss` as a PyTorch module. """ def __init__(self) -> None: - """ - Initialize the weighted MSE loss module. - """ + """Initialize the weighted MSE loss module.""" super().__init__() def forward(self, input: torch.Tensor, target: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: - """ - Compute weighted MSE loss. + """Compute weighted MSE loss. Parameters ---------- @@ -424,7 +406,7 @@ def forward(self, input: torch.Tensor, target: torch.Tensor, weight: torch.Tenso weight : torch.Tensor Weight tensor. - Returns + Returns: ------- torch.Tensor Weighted MSE loss value. @@ -444,8 +426,7 @@ def regression( opt=None, steps=1000, ) -> float: - """ - Generic optimization for free variables. + """Generic optimization for free variables. Parameters ---------- @@ -462,7 +443,7 @@ def regression( steps : int, optional Number of optimization steps. Defaults to 1000. - Returns + Returns: ------- float Final loss value after optimization. @@ -485,8 +466,7 @@ def regression( def pnm(fd: io.BufferedWriter, tn: torch.Tensor) -> None: - """ - Export tensor to PGM/PBM format. + """Export tensor to PGM/PBM format. Parameters ---------- @@ -496,7 +476,7 @@ def pnm(fd: io.BufferedWriter, tn: torch.Tensor) -> None: Tensor to export. Supports float32/float64 for grayscale (PGM) or boolean/integer for binary (PBM). - Notes + Notes: ----- - Grayscale format (PGM): For float32/float64 tensors, normalizes to 0-255. - Binary format (PBM): For other dtypes, converts to binary values. diff --git a/pyproject.toml b/pyproject.toml index 88a9f1d..377e261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "RET", # flake8-return + "D", # flake8-docstrings ] ignore = [ "E402", # module level import not at top of file (intentional in this codebase) From 5b8324b8b633a0b1af4c94d00cb4aeba4c26275f Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 14:15:55 +0200 Subject: [PATCH 26/30] Update .pre-commit-config.yaml --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6be960f..4771136 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,5 +3,5 @@ repos: rev: v0.15.12 hooks: - id: ruff - args: [--fix] + args: [--fix krum/] - id: ruff-format From c194577ba3e879bc63915c361788f03537f027e7 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 14:16:50 +0200 Subject: [PATCH 27/30] Update .pre-commit-config.yaml --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4771136..1c31740 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,5 +3,5 @@ repos: rev: v0.15.12 hooks: - id: ruff - args: [--fix krum/] + args: [krum/ --fix] - id: ruff-format From d44f3807b9350c1268528dac01fae514c8b06507 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 14:19:23 +0200 Subject: [PATCH 28/30] update scripts --- .pre-commit-config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c31740..c0d739b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,5 +3,6 @@ repos: rev: v0.15.12 hooks: - id: ruff - args: [krum/ --fix] + args: [--fix] + files: ^krum/ - id: ruff-format From 6577c56b0da12ee1df30178d82ef0fd70ef3a5c0 Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 14:19:38 +0200 Subject: [PATCH 29/30] update scripts --- reproduce.py | 2 ++ train.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/reproduce.py b/reproduce.py index c2d755f..d1520af 100644 --- a/reproduce.py +++ b/reproduce.py @@ -43,6 +43,7 @@ def process_commandline(): """Parse the command-line and perform checks. + Returns: Parsed configuration """ @@ -208,6 +209,7 @@ def make_command(params): def compute_avg_err(name, *cols, avgs="", errs="-err"): """Compute the average and standard deviation of the selected columns over the given experiment. + Args: name Given experiment name ... Selected column names (through 'histogram.select') diff --git a/train.py b/train.py index 1c70175..9797f92 100644 --- a/train.py +++ b/train.py @@ -47,6 +47,7 @@ def process_commandline(): """Parse the command-line and perform checks. + Returns: Parsed configuration """ @@ -291,6 +292,7 @@ def cmd_make_tree(subtree, level=0): def result_make(name, *fields): """Make and bind a new result file with a name, initialize with a header line. + Args: name Name of the result file fields... Name of each field, in order @@ -316,6 +318,7 @@ def result_make(name, *fields): def result_get(name): """Get a valid descriptor to the bound result file, or 'None' if the given name is not bound. + Args: name Given name Returns: @@ -332,6 +335,7 @@ def result_get(name): def result_store(fd, *entries): """Store a line in a valid result file. + Args: fd Descriptor of the valid result file entries... Object(s) to convert to string and write in order in a new line @@ -478,6 +482,7 @@ def convert_to_supported_json_type(x): def compute_avg_dev(values): """Compute the arithmetic mean and standard deviation of a list of values. + Args: values Iterable of values Returns: From 7c45efb7492d5bb8281be5204ddd81005b724f4e Mon Sep 17 00:00:00 2001 From: Arthur DANJOU Date: Mon, 4 May 2026 14:52:00 +0200 Subject: [PATCH 30/30] Update documentation.yml --- .github/workflows/documentation.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index ff306b4..01b2b35 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,9 +1,7 @@ name: Build and Deploy Documentation on: - push: - branches: - - main + workflow_dispatch: permissions: contents: read