diff --git a/README.md b/README.md index da3a6b6..9ea4d16 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ This package makes use of the following tools and libraries: - **auto-verify** ([GitHub](https://github.com/ADA-research/auto-verify)) - For integrating verifiers [nnenum](https://github.com/stanleybak/nnenum), [AB-Crown](https://github.com/Verified-Intelligence/alpha-beta-CROWN), [VeriNet](https://github.com/vas-group-imperial/VeriNet), and [Oval-Bab](https://github.com/oval-group/oval-bab). Please refer to the auto-verify [documentation](https://ada-research.github.io/auto-verify/) for details about auto-verify. + +- **foolbox** ([GitHub](https://github.com/bethgelab/foolbox)) + - Rauber, J., Brendel, W., and Bethge, M., "Foolbox: A Python toolbox to benchmark the robustness of machine learning models," in *Reliable Machine Learning in the Wild Workshop, 34th International Conference on Machine Learning*, 2017. [Online]. Available: http://arxiv.org/abs/1707.04131 + - Rauber, J., Zimmermann, R., Bethge, M., and Brendel, W., "Foolbox Native: Fast adversarial attacks to benchmark the robustness of machine learning models in PyTorch, TensorFlow, and JAX," *Journal of Open Source Software*, vol. 5, no. 53, p. 2607, 2020. [Online]. Available: https://doi.org/10.21105/joss.02607 We thank the authors and maintainers of these projects, as well as the authors and maintainers of the verifiers for their contributions to the robustness research community. diff --git a/ada_verona/__init__.py b/ada_verona/__init__.py index be2ce78..b7613ca 100644 --- a/ada_verona/__init__.py +++ b/ada_verona/__init__.py @@ -42,6 +42,7 @@ # Dataset sampler classes from .dataset_sampler.dataset_sampler import DatasetSampler +from .dataset_sampler.identity_sampler import IdentitySampler from .dataset_sampler.predictions_based_sampler import PredictionsBasedSampler # Epsilon value estimator classes @@ -87,12 +88,22 @@ stacklevel=2, ) +# Check for foolbox availability +HAS_FOOLBOX = importlib.util.find_spec("foolbox") is not None +if not HAS_FOOLBOX: + warnings.warn( + "Foolbox not found. Some adversarial attack features will be limited. " + "To install: pip install foolbox", + stacklevel=2, + ) + __all__ = [ "__version__", "__author__", "HAS_AUTOATTACK", "HAS_AUTOVERIFY", + "HAS_FOOLBOX", # Core abstract classes "DatasetSampler", "EpsilonValueEstimator", @@ -114,6 +125,7 @@ "DataPoint", # Dataset sampler classes "PredictionsBasedSampler", + "IdentitySampler", "PytorchExperimentDataset", "ImageFileDataset", # Epsilon value estimator classes @@ -146,3 +158,8 @@ "parse_counter_example_label", ] ) + +if HAS_FOOLBOX: + foolbox_attack_module = importlib.import_module(".verification_module.attacks.foolbox_attack", __package__) + FoolboxAttack = foolbox_attack_module.FoolboxAttack + __all__.extend(["FoolboxAttack"]) diff --git a/ada_verona/verification_module/attacks/foolbox_attack.py b/ada_verona/verification_module/attacks/foolbox_attack.py new file mode 100644 index 0000000..5a9b6bb --- /dev/null +++ b/ada_verona/verification_module/attacks/foolbox_attack.py @@ -0,0 +1,95 @@ +# Copyright 2025 ADA Reseach Group and VERONA council. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== + +import foolbox +from torch import Tensor, nn + +from ada_verona.verification_module.attacks.attack import Attack + + +class FoolboxAttack(Attack): + """ + A wrapper for Foolbox adversarial attacks. + Requires foolbox to be installed: pip install foolbox + + Attributes: + attack_cls (class): The Foolbox attack class to use. + kwargs (dict): Arguments to pass to the attack constructor. + """ + + def __init__(self, attack_cls, bounds=(0, 1), **kwargs) -> None: + """ + Initialize the FoolboxAttack wrapper. + + Args: + attack_cls (class): The Foolbox attack class (e.g., foolbox.attacks.LinfPGD). + bounds (tuple, optional): The bounds of the input data. Defaults to (0, 1). + **kwargs: Arguments to be passed to the attack constructor (e.g., steps=40). + """ + super().__init__() + self.attack_cls = attack_cls + self.bounds = bounds + self.kwargs = kwargs + self.name = f"FoolboxAttack ({attack_cls.__name__}, bounds={bounds}, {kwargs})" + + def execute(self, model: nn.Module, data: Tensor, target: Tensor, epsilon: float) -> Tensor: + """ + Execute the Foolbox attack on the given model and data. + + Args: + model (nn.Module): The model to attack. + data (Tensor): The input data to perturb. + target (Tensor): The target labels for the data. + epsilon (float): The perturbation magnitude. + + Returns: + Tensor: The perturbed data. + """ + fmodel = foolbox.PyTorchModel(model, bounds=self.bounds) + + attack = self.attack_cls(**self.kwargs) + + # Ensure data has batch dimension (Foolbox requires batch dimension) + # Data should be (batch_size, channels, height, width) or (batch_size, features) + # Foolbox expects at least 2D tensors: (batch_size, ...) + if data.dim() == 0: + # Scalar, add batch dimension: (1,) + data = data.unsqueeze(0) + elif data.dim() == 1: + # 1D tensor, add batch dimension: (1, features) + data = data.unsqueeze(0) + elif data.dim() == 3: + # 3D tensor (C, H, W), add batch dimension: (1, C, H, W) + data = data.unsqueeze(0) + # If data is already 4D (B, C, H, W) or 2D (B, features), keep as is + # But verify it has a batch dimension + if data.dim() >= 2 and data.shape[0] == 0: + raise ValueError(f"Data tensor has invalid batch size: {data.shape}") + + # Ensure target has batch dimension + # Target should be 1D with shape (batch_size,) for a single sample: (1,) + if target.dim() == 0: + # Scalar target, add batch dimension + target = target.unsqueeze(0) + elif target.dim() == 1: + # Already 1D, should be fine (typically shape (1,) for single sample) + # But ensure it's not empty + if target.shape[0] == 0: + raise ValueError("Target tensor cannot be empty") + # If target is already correct shape, keep as is + + _, clipped_advs, _ = attack(fmodel, data, target, epsilons=epsilon) + + return clipped_advs diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index fe3b77d..3d1347c 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -95,6 +95,7 @@ VERONA implements the following adversarial attack methods: - **Fast Gradient Sign Method (FGSM)** - [Goodfellow et al., 2015](https://arxiv.org/abs/1412.6572) - **Projected Gradient Descent (PGD)** - [Madry et al., 2018](https://arxiv.org/abs/1706.06083) - **AutoAttack** - [Croce and Hein, 2020](https://proceedings.mlr.press/v119/croce20b.html) +- All attacks from [foolbox](https://github.com/bethgelab/foolbox/tree/master/foolbox/attacks) through the [`FoolboxAttack`](../ada_verona/verification_module/attacks/foolbox_attack.py) class ### Optional: AutoAttack Installation diff --git a/examples/scripts/create_robustness_dist_foolbox.py b/examples/scripts/create_robustness_dist_foolbox.py new file mode 100644 index 0000000..49a2756 --- /dev/null +++ b/examples/scripts/create_robustness_dist_foolbox.py @@ -0,0 +1,84 @@ +# Copyright 2025 ADA Reseach Group and VERONA council. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== + +import logging +from pathlib import Path + +import numpy as np +from foolbox.attacks import LinfPGD + +import ada_verona.util.logger as logger +from ada_verona.database.dataset.image_file_dataset import ImageFileDataset +from ada_verona.database.experiment_repository import ExperimentRepository +from ada_verona.dataset_sampler.predictions_based_sampler import PredictionsBasedSampler +from ada_verona.epsilon_value_estimator.binary_search_epsilon_value_estimator import ( + BinarySearchEpsilonValueEstimator, +) +from ada_verona.verification_module.attack_estimation_module import AttackEstimationModule +from ada_verona.verification_module.attacks.foolbox_attack import FoolboxAttack +from ada_verona.verification_module.property_generator.one2any_property_generator import ( + One2AnyPropertyGenerator, +) + +logger.setup_logging(level=logging.INFO) + +experiment_name = "foolbox_pgd" +timeout = 600 +experiment_repository_path = Path("../example_experiment/results_foolbox") +network_folder = Path("../example_experiment/data/networks") +image_folder = Path("../example_experiment/data/images") +image_label_file = Path("../example_experiment/data/image_labels.csv") +epsilon_list = np.arange(0.00, 0.4, 0.0039) + +dataset = ImageFileDataset(image_folder=image_folder, label_file=image_label_file) + +file_database = ExperimentRepository(base_path=experiment_repository_path, network_folder=network_folder) + +file_database.initialize_new_experiment(experiment_name) + +file_database.save_configuration( + dict( + experiment_name=experiment_name, + experiment_repository_path=str(experiment_repository_path), + network_folder=str(network_folder), + dataset=str(dataset), + timeout=timeout, + epsilon_list=[str(x) for x in epsilon_list], + ) +) + +property_generator = One2AnyPropertyGenerator() +verifier = AttackEstimationModule(attack=FoolboxAttack(LinfPGD, bounds=(0, 1), steps=10)) + +epsilon_value_estimator = BinarySearchEpsilonValueEstimator(epsilon_value_list=epsilon_list.copy(), verifier=verifier) +dataset_sampler = PredictionsBasedSampler(sample_correct_predictions=True) + +network_list = file_database.get_network_list() + +print(f"Found {len(network_list)} networks.") + +for network in network_list: + print(f"Processing network: {network.name}") + sampled_data = dataset_sampler.sample(network, dataset) + print(f"Sampled {len(sampled_data)} data points.") + + for i, data_point in enumerate(sampled_data): + print(f"Verifying data point {i}...") + verification_context = file_database.create_verification_context(network, data_point, property_generator) + epsilon_value_result = epsilon_value_estimator.compute_epsilon_value(verification_context) + print(f"Result: {epsilon_value_result}") + file_database.save_result(epsilon_value_result) + +print("Done.") diff --git a/examples/scripts/create_robustness_dist_foolbox_cw.py b/examples/scripts/create_robustness_dist_foolbox_cw.py new file mode 100644 index 0000000..88f5df2 --- /dev/null +++ b/examples/scripts/create_robustness_dist_foolbox_cw.py @@ -0,0 +1,90 @@ +# Copyright 2025 ADA Reseach Group and VERONA council. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== + +import logging +from pathlib import Path + +import numpy as np +from foolbox.attacks import L2CarliniWagnerAttack + +import ada_verona.util.logger as logger +from ada_verona.database.dataset.image_file_dataset import ImageFileDataset +from ada_verona.database.experiment_repository import ExperimentRepository +from ada_verona.dataset_sampler.predictions_based_sampler import PredictionsBasedSampler +from ada_verona.epsilon_value_estimator.binary_search_epsilon_value_estimator import ( + BinarySearchEpsilonValueEstimator, +) +from ada_verona.verification_module.attack_estimation_module import AttackEstimationModule +from ada_verona.verification_module.attacks.foolbox_attack import FoolboxAttack +from ada_verona.verification_module.property_generator.one2any_property_generator import ( + One2AnyPropertyGenerator, +) + +logger.setup_logging(level=logging.INFO) + +experiment_name = "foolbox_cw" +timeout = 600 +experiment_repository_path = Path("../example_experiment/results_foolbox_cw") +network_folder = Path("../example_experiment/data/networks") +image_folder = Path("../example_experiment/data/images") +image_label_file = Path("../example_experiment/data/image_labels.csv") + +epsilon_list = np.arange(0.00, 0.4, 0.0039) + +dataset = ImageFileDataset(image_folder=image_folder, label_file=image_label_file) + +file_database = ExperimentRepository(base_path=experiment_repository_path, network_folder=network_folder) + +file_database.initialize_new_experiment(experiment_name) + +file_database.save_configuration( + dict( + experiment_name=experiment_name, + experiment_repository_path=str(experiment_repository_path), + network_folder=str(network_folder), + dataset=str(dataset), + timeout=timeout, + epsilon_list=[str(x) for x in epsilon_list], + ) +) + +property_generator = One2AnyPropertyGenerator() +verifier = AttackEstimationModule(attack=FoolboxAttack(L2CarliniWagnerAttack, bounds=(0, 1), steps=100)) + +epsilon_value_estimator = BinarySearchEpsilonValueEstimator(epsilon_value_list=epsilon_list.copy(), verifier=verifier) +dataset_sampler = PredictionsBasedSampler(sample_correct_predictions=True) + +network_list = file_database.get_network_list() + +print(f"Found {len(network_list)} networks.") + +for network in network_list: + print(f"Processing network: {network.name}") + sampled_data = dataset_sampler.sample(network, dataset) + print(f"Sampled {len(sampled_data)} data points.") + + for i, data_point in enumerate(sampled_data): + if i >= 1: + break + + print(f"Verifying data point {i}...") + verification_context = file_database.create_verification_context(network, data_point, property_generator) + + epsilon_value_result = epsilon_value_estimator.compute_epsilon_value(verification_context) + + print(f"Result: {epsilon_value_result}") + file_database.save_result(epsilon_value_result) + +print("Done.") diff --git a/pyproject.toml b/pyproject.toml index f51f674..0551c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "onnx>=1.14.0", "onnxruntime>=1.14.1", "onnx2torch>=1.5.14", + "foolbox>=3.3.4", "pandas>=2.0.1", "PyYAML>=6.0.1", "result>=0.9.0", diff --git a/tests/test_verification_module/attacks/conftest.py b/tests/test_verification_module/attacks/conftest.py index b53a698..c845894 100644 --- a/tests/test_verification_module/attacks/conftest.py +++ b/tests/test_verification_module/attacks/conftest.py @@ -13,12 +13,14 @@ # limitations under the License. # ============================================================================== +import foolbox as fb import pytest import torch from torch import nn from ada_verona.verification_module.attacks.auto_attack_wrapper import AutoAttackWrapper from ada_verona.verification_module.attacks.fgsm_attack import FGSMAttack +from ada_verona.verification_module.attacks.foolbox_attack import FoolboxAttack from ada_verona.verification_module.attacks.pgd_attack import PGDAttack @@ -34,22 +36,32 @@ def forward(self, x): return SimpleModel() + @pytest.fixture def data(): return torch.randn(1, 10) + @pytest.fixture def target(): return torch.tensor([1]) + @pytest.fixture def attack_wrapper(): return AutoAttackWrapper(device="cpu", norm="Linf", version="standard", verbose=False) + @pytest.fixture def pgd_attack(): - return PGDAttack(number_iterations=10, step_size=0.01, randomise=True) + return PGDAttack(number_iterations=10, step_size=0.01, randomise=True) + @pytest.fixture def fgsm_attack(): - return FGSMAttack() \ No newline at end of file + return FGSMAttack() + + +@pytest.fixture +def foolbox_attack(): + return FoolboxAttack(attack_cls=fb.attacks.LinfFastGradientAttack) diff --git a/tests/test_verification_module/attacks/test_foolbox_attack.py b/tests/test_verification_module/attacks/test_foolbox_attack.py new file mode 100644 index 0000000..960338c --- /dev/null +++ b/tests/test_verification_module/attacks/test_foolbox_attack.py @@ -0,0 +1,57 @@ +# Copyright 2025 ADA Reseach Group and VERONA council. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== + +import torch +from torch import nn + + +def test_foolbox_attack_execute(foolbox_attack, model, data, target): + epsilon = 0.1 + normalized_data = torch.sigmoid(data) + perturbed_data = foolbox_attack.execute(model, normalized_data, target, epsilon) + assert isinstance(perturbed_data, torch.Tensor) + assert perturbed_data.shape == normalized_data.shape + assert torch.all(perturbed_data >= 0) and torch.all(perturbed_data <= 1) + + +def test_foolbox_attack_execute_3d_data(foolbox_attack, target): + epsilon = 0.1 + + class FlattenModel(nn.Module): + def __init__(self): + super().__init__() + self.fc = nn.Linear(10, 2) + + def forward(self, x): + x = x.view(x.size(0), -1) + return self.fc(x) + + model = FlattenModel() + data_3d = torch.randn(1, 1, 10) + normalized_data = torch.sigmoid(data_3d) + perturbed_data = foolbox_attack.execute(model, normalized_data, target, epsilon) + assert isinstance(perturbed_data, torch.Tensor) + assert perturbed_data.shape == (1, 1, 1, 10) + assert torch.all(perturbed_data >= 0) and torch.all(perturbed_data <= 1) + + +def test_foolbox_attack_execute_0d_target(foolbox_attack, model, data): + epsilon = 0.1 + target_0d = torch.tensor(1) + normalized_data = torch.sigmoid(data) + perturbed_data = foolbox_attack.execute(model, normalized_data, target_0d, epsilon) + assert isinstance(perturbed_data, torch.Tensor) + assert perturbed_data.shape == normalized_data.shape + assert torch.all(perturbed_data >= 0) and torch.all(perturbed_data <= 1) diff --git a/uv.lock b/uv.lock index 1b3b7e2..3f2c960 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.10.*" [[package]] @@ -7,6 +7,7 @@ name = "ada-verona" version = "1.0.1" source = { editable = "." } dependencies = [ + { name = "foolbox" }, { name = "matplotlib" }, { name = "numpy" }, { name = "onnx" }, @@ -55,6 +56,7 @@ gpu = [ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=22.0.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "foolbox", specifier = ">=3.3.4" }, { name = "matplotlib", specifier = ">=3.10.0" }, { name = "mkdocs", marker = "extra == 'dev'", specifier = ">=1.6.1" }, { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.7.0" }, @@ -278,6 +280,19 @@ 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 = "eagerpy" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/33/3bc665b3438fed5af8e95ec0ad04c7b12a228b36246ba4981031022f783d/eagerpy-0.30.0.tar.gz", hash = "sha256:014c02b5a7f7e19f8471885cf8aa469f2e9cf518c88400f20b6b8db83d413106", size = 22972, upload-time = "2021-08-08T12:09:33.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/b7/445e74a70503630a9d3c58563da1f0c831532d45bd5987b861f562826ea4/eagerpy-0.30.0-py3-none-any.whl", hash = "sha256:79c461b04577f02bf3b48191b2f911b55521204df99ec02288d96bfa34f13d80", size = 31221, upload-time = "2021-08-08T12:09:31.519Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -325,6 +340,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, ] +[[package]] +name = "foolbox" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eagerpy" }, + { name = "gitpython" }, + { name = "numpy" }, + { name = "requests" }, + { name = "scipy" }, + { name = "setuptools" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/31/7237827f675b18b8b8c4417275564a81c0c05ffafe46228d0f77a3176365/foolbox-3.3.4.tar.gz", hash = "sha256:1276cb1c1f636d1e6db08fb0d5cb00ec02144f145465166192dc86a86fce9e1c", size = 1641668, upload-time = "2024-03-04T20:59:27.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/c4/2343e1accf8cbdba957e1f190ed860969abdd83181531a936626d4d7cfd0/foolbox-3.3.4-py3-none-any.whl", hash = "sha256:11049d515d38e765e206a73ace9cc648b81a0d6d1da9ad9057e149ee117989cb", size = 1677112, upload-time = "2024-03-04T20:59:17.778Z" }, +] + [[package]] name = "fsspec" version = "2025.10.0" @@ -346,6 +379,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + [[package]] name = "griffe" version = "1.15.0" @@ -1209,6 +1266,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, ] +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, +] + [[package]] name = "seaborn" version = "0.13.2" @@ -1223,6 +1300,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1232,6 +1318,15 @@ 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 = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "sympy" version = "1.14.0"