diff --git a/examples/ept_attack/config.yaml b/examples/ept_attack/config.yaml index a4009858..b5ac2efc 100644 --- a/examples/ept_attack/config.yaml +++ b/examples/ept_attack/config.yaml @@ -9,12 +9,14 @@ data_paths: output_data_path: ${base_data_dir}/output # Directory to save processed data and results data_types_file_path: ${base_data_dir}/data_configs/data_types.json # Path to the JSON file defining column types attribute_features_path: ${data_paths.output_data_path}/attribute_prediction_features # Path to save attribute prediction features + inference_results_path: ${data_paths.output_data_path}/inference_results # Path to save inference (membership prediction) results # Pipeline control pipeline: run_data_processing: false # Whether to run data processing run_shadow_model_training: false # Whether to run shadow model training run_feature_extraction: false # Whether to run attribute prediction model training run_attack_classifier_training: true # Whether to run attack classifier training + run_inference: true # Whether to run inference on the target model classifier_settings: results_output_path: ${data_paths.output_data_path}/evaluation_ML diff --git a/examples/ept_attack/run_ept_attack.py b/examples/ept_attack/run_ept_attack.py index f5a11a83..c8bad0fe 100644 --- a/examples/ept_attack/run_ept_attack.py +++ b/examples/ept_attack/run_ept_attack.py @@ -8,6 +8,7 @@ import itertools import json +import pickle from collections import defaultdict from datetime import datetime from logging import INFO @@ -19,7 +20,7 @@ from examples.common.utils import directory_checks, iterate_model_folders from midst_toolkit.attacks.ensemble.data_utils import load_dataframe, save_dataframe -from midst_toolkit.attacks.ept.classification import ClassifierType, train_attack_classifier +from midst_toolkit.attacks.ept.classification import ClassifierType, filter_data, train_attack_classifier from midst_toolkit.attacks.ept.feature_extraction import extract_features from midst_toolkit.common.logger import log from midst_toolkit.common.random import set_all_random_seeds @@ -137,6 +138,64 @@ def _summarize_and_save_training_results( return summary_df +def _train_and_save_best_attack_classifier( + config: DictConfig, best_result: pd.DataFrame, diffusion_model_name: str, model_save_path: Path +) -> None: + """ + Trains and saves the best attack classifier based on the summary DataFrame. + + Args: + config: Configuration object set in config.yaml. Used to access attribute features path. + best_result: DataFrame containing the best attack configuration (classifier and column types). + diffusion_model_name: Name of the diffusion model (e.g., 'tabddpm', 'tabsyn', 'clavaddpm'). + Used to locate the training features and labels. + model_save_path: Path where the trained model will be saved. + """ + # Train and save the best attack classifier + best_classifier_name = best_result["classifier"].iloc[0] + best_column_types_str = best_result["column_types"].iloc[0] + best_column_types = best_column_types_str.split(" ") + + log( + INFO, + f"Training final attack model for {diffusion_model_name} with classifier: {best_classifier_name} and features: {best_column_types}", + ) + + train_features_data_path = ( + Path(config.data_paths.attribute_features_path) / f"{diffusion_model_name}_black_box" / "train" + ) + + # Concatenate all train features and labels for final training + train_feature_files = train_features_data_path.glob("*.csv") + df_train_features = pd.concat([pd.read_csv(f) for f in train_feature_files], ignore_index=True) + train_labels = df_train_features["is_train"] + df_train_features = df_train_features.drop(columns=["is_train"]) + + # Train the final model + final_model_results = train_attack_classifier( + classifier_type=ClassifierType(best_classifier_name), + column_types=best_column_types, + x_train=df_train_features, + y_train=train_labels, + x_test=None, # No test set, training on all available data + y_test=None, + ) + + final_model = final_model_results["trained_model"] + + model_save_path = Path(model_save_path) / f"{diffusion_model_name}_best_attack_classifier.pkl" + + final_model_metadata = { + "trained_model": final_model, + "column_types": best_column_types, + } + + with open(model_save_path, "wb") as file: + pickle.dump(final_model_metadata, file) + + log(INFO, f"Saved the best attack model to {model_save_path}") + + # Step 4: Attack classifier training def run_attack_classifier_training(config: DictConfig) -> None: """ @@ -161,6 +220,7 @@ def run_attack_classifier_training(config: DictConfig) -> None: metric ('final_tpr_fpr_10') representing the best TPR at 10% FPR across all diffusion models for a given classifier and feature set. 7. Logging the best-performing attack configuration based on this final metric. + 8. Training and saving the best attack classifier using all available training data. Args: config: Configuration object set in config.yaml. @@ -191,6 +251,9 @@ def run_attack_classifier_training(config: DictConfig) -> None: # ... # } + # TODO: Move this part of code to a separate function (hyper-parameter tuning) + # TODO: Move some of the code to midst_toolkit.attacks.ept.classification module + summary_results: dict[tuple[str, str], list[tuple[str, dict[str, float]]]] = defaultdict(list) for diffusion_model_name in diffusion_models: @@ -262,11 +325,98 @@ def run_attack_classifier_training(config: DictConfig) -> None: summary_results, output_summary_path, "attack_classifier_summary.csv" ) - summary_df.sort_values(by=["final_tpr_fpr_10"], ascending=False, inplace=True) + if data_format == "single_table": + # For single-table data, focus on tabddpm results + summary_df.sort_values(by=["tabddpm_tpr_fpr_10"], ascending=False, inplace=True) + else: + # For multi-table data, get the clavaddpm results + summary_df.sort_values(by=["clavaddpm_tpr_fpr_10"], ascending=False, inplace=True) + best_result = summary_df.head(1) log(INFO, f"Best performing attack configuration:\n{best_result}") - log(INFO, f"Best performing attack configuration:\n{best_result}") + for diffusion_model_name in diffusion_models: + model_save_path = Path(config.classifier_settings.results_output_path) / data_format + _train_and_save_best_attack_classifier(config, best_result, diffusion_model_name, model_save_path) + + +def run_inference(config: DictConfig, diffusion_model_name_override: str | None = None) -> None: + """ + Runs inference using the trained attack classifier on the challenge data. + + Args: + config: Configuration object set in config.yaml. + diffusion_model_name_override: If provided and valid, runs inference + only for this model. If None or invalid, runs for all applicable models. + + Throws: + FileNotFoundError: If the trained attack classifier model file is not found. + """ + log(INFO, "Running inference with the trained attack classifier.") + + # Determine which diffusion models to run inference on. If an override is provided + # use that; otherwise, use all applicable models based on the specified data format + + is_single_table = config.attack_settings.single_table + default_single_table_models = ["tabddpm", "tabsyn"] + default_multi_table_models = ["clavaddpm"] + + data_format = "single_table" if is_single_table else "multi_table" + + if diffusion_model_name_override is not None: + diffusion_models = [diffusion_model_name_override] + elif is_single_table: + diffusion_models = default_single_table_models + else: + diffusion_models = default_multi_table_models + + for diffusion_model_name in diffusion_models: + # Load the trained attack classifier + model_path = ( + Path(config.classifier_settings.results_output_path) + / data_format + / f"{diffusion_model_name}_best_attack_classifier.pkl" + ) + + if not model_path.exists(): + raise FileNotFoundError( + f"No trained model found at {model_path} for {diffusion_model_name}. " + "Please run the attack classifier training step first." + ) + + with open(model_path, "rb") as file: + final_model_metadata = pickle.load(file) + trained_model = final_model_metadata["trained_model"] + column_types = final_model_metadata["column_types"] + + # Load new feature data for inference + features_data_path = Path(config.data_paths.attribute_features_path) + inference_features_path = features_data_path / f"{diffusion_model_name}_black_box" / "final" + + directory_checks(inference_features_path, "Make sure to run feature extraction on final data first.") + + challenge_feature_files = inference_features_path.glob("*.csv") + + df_inference_features = pd.concat([pd.read_csv(f) for f in challenge_feature_files], ignore_index=True) + filtered_features = filter_data(df_inference_features, column_types) + predictions = trained_model.predict(filtered_features) + + # Save inference results + inference_output_path = Path(config.data_paths.inference_results_path) + inference_output_path.mkdir(parents=True, exist_ok=True) + + inference_results_file_name = f"{diffusion_model_name}_attack_inference_results.csv" + + save_dataframe( + df=pd.DataFrame({"prediction": predictions}), + file_path=inference_output_path, + file_name=inference_results_file_name, + ) + + log(INFO, f"Saved inference results to {inference_output_path / inference_results_file_name}") + + # TODO: Implement evaluation of inference results using the challenge labels + # _evaluate_inference_results(predictions, diffusion_model_name) @hydra.main(config_path=".", config_name="config", version_base=None) @@ -298,6 +448,9 @@ def main(config: DictConfig) -> None: if config.pipeline.run_attack_classifier_training: run_attack_classifier_training(config) + if config.pipeline.run_inference: + run_inference(config) + if __name__ == "__main__": main() diff --git a/src/midst_toolkit/attacks/ept/classification.py b/src/midst_toolkit/attacks/ept/classification.py index 07de10db..8b524786 100644 --- a/src/midst_toolkit/attacks/ept/classification.py +++ b/src/midst_toolkit/attacks/ept/classification.py @@ -80,7 +80,14 @@ def filter_data(features_df: pd.DataFrame, column_types: list[str]) -> np.ndarra class MLPClassifier(nn.Module): - def __init__(self, input_size: int = 100, hidden_size: int = 64, output_size: int = 1): + def __init__( + self, + input_size: int = 100, + hidden_size: int = 64, + output_size: int = 1, + epochs: int = 10, + device: torch.device = DEVICE, + ): """ Creates the Multi-layer perceptron classifier. Defines a simple feedforward neural network with customizable input, hidden, and output sizes. @@ -89,11 +96,16 @@ def __init__(self, input_size: int = 100, hidden_size: int = 64, output_size: in input_size: The number of features in the input data. Defaults to 100. hidden_size: The number of neurons in the hidden layer. Defaults to 64. output_size: The number of output neurons, typically 1 for binary classification. Defaults to 1. + epochs: Number of training epochs. Default is 10. + device: The device to train and evaluate the model on. """ super().__init__() self.layers = nn.Sequential( nn.Linear(input_size, hidden_size), nn.ReLU(), nn.Linear(hidden_size, output_size), nn.Sigmoid() ) + self.epochs = epochs + self.device = device + self.to(self.device) def forward(self, x: torch.Tensor) -> torch.Tensor: """ @@ -107,61 +119,58 @@ def forward(self, x: torch.Tensor) -> torch.Tensor: """ return self.layers(x).squeeze(dim=-1) + def fit(self, x_train: np.ndarray, y_train: np.ndarray) -> None: + """ + Trains the MLP classifier. -def train_mlp( - x_train: np.ndarray, - y_train: np.ndarray, - x_test: np.ndarray | None = None, - device: torch.device = DEVICE, - epochs: int = 10, -) -> tuple[np.ndarray | None, np.ndarray | None]: - """ - Trains a simple MLP classifier and optionally evaluates it on a test set. + Args: + x_train: Training data features. + y_train: Training data labels. + """ + criterion = nn.BCELoss() + optimizer = optim.Adam(self.parameters(), lr=0.001) - Args: - x_train: Training data features. - y_train: Training data labels. - x_test: Test data features. - device: The device to train the model on (e.g., 'cpu' or 'cuda'). - eval: If True, evaluates the model on the test set. - epochs: Number of training epochs. Default is 10. + x_train_tensor = torch.tensor(x_train, dtype=torch.float32).to(self.device) + y_train_tensor = torch.tensor(y_train, dtype=torch.float32).to(self.device) - Returns: - A tuple containing: - - The predicted labels for the test set (or None if eval is False). - - The prediction probabilities for the test set (or None if eval is False). - """ - input_size = x_train.shape[1] - model = MLPClassifier(input_size=input_size).to(device) - criterion = nn.BCELoss() - optimizer = optim.Adam(model.parameters(), lr=0.001) - - x_train_tensor, y_train_tensor = ( - torch.tensor(x_train, dtype=torch.float32).to(device), - torch.tensor(y_train, dtype=torch.float32).to(device), - ) + for _ in range(self.epochs): + self.train() + optimizer.zero_grad() + outputs = self(x_train_tensor) + loss = criterion(outputs, y_train_tensor) + loss.backward() + optimizer.step() - # Train the model - for _ in range(epochs): - model.train() - optimizer.zero_grad() - outputs = model(x_train_tensor) - loss = criterion(outputs, y_train_tensor) - loss.backward() - optimizer.step() + def predict_proba(self, x_test: np.ndarray) -> np.ndarray: + """ + Gets prediction probabilities for the test set. - y_pred, y_proba = None, None + Args: + x_test: Test data features. - if x_test is not None: - model.eval() - x_test_tensor = torch.tensor(x_test, dtype=torch.float32).to(device) + Returns: + The prediction probabilities for the test set. + """ + self.eval() + x_test_tensor = torch.tensor(x_test, dtype=torch.float32).to(self.device) with torch.no_grad(): - # Get probabilities - y_proba = model(x_test_tensor).cpu().numpy() - # Convert probabilities to binary predictions - y_pred = (y_proba > 0.5).astype(float) + y_proba = self(x_test_tensor).cpu().numpy() - return y_pred, y_proba + # Reshape to (n_samples, 2) for compatibility with scikit-learn's predict_proba + return np.vstack([1 - y_proba, y_proba]).T + + def predict(self, x_test: np.ndarray) -> np.ndarray: + """ + Gets predicted labels for the test set. + + Args: + x_test: Test data features. + + Returns: + The predicted labels for the test set. + """ + y_proba = self.predict_proba(x_test)[:, 1] + return (y_proba > 0.5).astype(float) def get_scores( @@ -212,8 +221,8 @@ def train_attack_classifier( column_types: list[str], x_train: pd.DataFrame, y_train: pd.Series, - x_test: pd.DataFrame, - y_test: pd.Series, + x_test: pd.DataFrame | None, + y_test: pd.Series | None, ) -> dict[str, dict]: """ Trains a specified classifier for a membership inference attack. @@ -233,7 +242,8 @@ def train_attack_classifier( y_test: The labels for the test set (membership status). Returns: - A dictionary containing the results. It has two keys: + A dictionary containing the results. It has three keys: + - "trained_model": The trained classifier model. - "prediction_results": A dictionary with the true labels ('y_true'), predicted probabilities ('y_proba'), and predicted labels ('y_pred'). - "scores": A dictionary of performance metrics, including accuracy, @@ -246,50 +256,54 @@ def train_attack_classifier( x_train_processed = filter_data(x_train, column_types) y_train_processed = y_train.to_numpy() - x_test_processed = filter_data(x_test, column_types) - y_test_processed = y_test.to_numpy() - assert x_train_processed.shape[0] == y_train_processed.shape[0], ( "Mismatch in number of training samples and labels" ) - assert x_test_processed.shape[0] == y_test_processed.shape[0], "Mismatch in number of test samples and labels" - assert x_train_processed.shape[1] == x_test_processed.shape[1], ( - "Mismatch in number of features between train and test sets" - ) y_pred, y_proba = None, None + model: XGBClassifier | CatBoostClassifier | MLPClassifier + if classifier_type == ClassifierType.XGBOOST: model = XGBClassifier() - model.fit(x_train_processed, y_train_processed) - y_pred = model.predict(x_test_processed) - y_proba = model.predict_proba(x_test_processed)[:, 1] elif classifier_type == ClassifierType.CATBOOST: model = CatBoostClassifier(verbose=0) - model.fit(x_train_processed, y_train_processed) - y_pred = model.predict(x_test_processed) - y_proba = model.predict_proba(x_test_processed)[:, 1] elif classifier_type == ClassifierType.MLP: - y_pred, y_proba = train_mlp(x_train_processed, y_train_processed, x_test_processed, DEVICE) + model = MLPClassifier(input_size=x_train_processed.shape[1], device=DEVICE) else: raise ValueError(f"Unsupported classifier type: {classifier_type}") - assert y_pred is not None and y_proba is not None, ( - "Predictions and probabilities should not be None to get scores." - ) + model.fit(x_train_processed, y_train_processed) - prediction_results = { - "y_true": y_test_processed, - "y_proba": y_proba, - "y_pred": y_pred, - } + all_results["trained_model"] = model - fpr_thresholds = [0.1, 0.01, 0.001] + if x_test is not None and y_test is not None: + x_test_processed = filter_data(x_test, column_types) + y_test_processed = y_test.to_numpy() + assert x_test_processed.shape[0] == y_test_processed.shape[0], "Mismatch in number of test samples and labels" + assert x_train_processed.shape[1] == x_test_processed.shape[1], ( + "Mismatch in number of features between train and test sets" + ) + + y_pred = model.predict(x_test_processed) + y_proba = model.predict_proba(x_test_processed)[:, 1] + + assert y_pred is not None and y_proba is not None, ( + "Predictions and probabilities should not be None to get scores." + ) + + prediction_results = { + "y_true": y_test_processed, + "y_proba": y_proba, + "y_pred": y_pred, + } + + fpr_thresholds = [0.1, 0.01, 0.001] - all_results["prediction_results"] = prediction_results - all_results["scores"] = get_scores(y_test_processed, y_proba, y_pred, fpr_thresholds) + all_results["prediction_results"] = prediction_results + all_results["scores"] = get_scores(y_test_processed, y_proba, y_pred, fpr_thresholds) return all_results diff --git a/tests/unit/attacks/ept_attack/test_classification.py b/tests/unit/attacks/ept_attack/test_classification.py index 4f184ffc..fb81b131 100644 --- a/tests/unit/attacks/ept_attack/test_classification.py +++ b/tests/unit/attacks/ept_attack/test_classification.py @@ -5,6 +5,7 @@ import pandas as pd import pytest import torch +from torch import nn from midst_toolkit.attacks.ept.classification import ( ClassifierType, @@ -13,7 +14,6 @@ filter_data, get_scores, train_attack_classifier, - train_mlp, ) from midst_toolkit.common.variables import DEVICE @@ -145,42 +145,6 @@ def test_mlp_classifier(): ) -@patch("midst_toolkit.attacks.ept.classification.MLPClassifier") -def test_train_mlp(mock_mlp_class): - # Tests the train_mlp function with mocked MLPClassifier. - - mock_model = MagicMock() - mock_model.parameters.return_value = [torch.nn.Parameter(torch.randn(2, 2))] - mock_mlp_class.return_value.to.return_value = mock_model - - train_output_tensor = torch.rand(10, 1, requires_grad=True) - - eval_output_mock = MagicMock() - eval_output_mock.cpu.return_value.numpy.return_value = np.array([0.6, 0.4, 0.7]) - - x_train = np.random.rand(10, 5) - y_train = np.random.randint(0, 2, (10, 1)) - x_test = np.random.rand(3, 5) - - mock_model.side_effect = [train_output_tensor, eval_output_mock] - y_pred, y_proba = train_mlp(x_train, y_train, x_test=x_test, device=DEVICE, epochs=1) - - assert y_pred is not None - assert y_proba is not None - assert y_pred.shape == (3,) - assert y_proba.shape == (3,) - - np.testing.assert_array_equal(y_pred, np.array([1, 0, 1])) - - mock_model.side_effect = [train_output_tensor] - - # No eval - y_pred_no_eval, y_proba_no_eval = train_mlp(x_train, y_train, x_test=None, device=DEVICE, epochs=1) - - assert y_pred_no_eval is None - assert y_proba_no_eval is None - - def test_get_scores(): # Tests the get_scores function with known values. y_true = np.array([1, 0, 1, 0, 1, 0]) @@ -256,18 +220,100 @@ def test_train_attack_classifier_mismatched_data(attack_data): train_attack_classifier(ClassifierType.XGBOOST, column_types, x_train, y_train, x_test_wrong_features, y_test) -@patch("midst_toolkit.attacks.ept.classification.train_mlp") -def test_train_attack_classifier_mlp(mock_train_mlp, attack_data): - # Tests train_attack_classifier for the MLP model - x_train, y_train, x_test, y_test = attack_data - column_types = [ColumnType.ERROR] - mock_train_mlp.return_value = (np.zeros(10), np.zeros(10)) +@pytest.fixture +def input_dim(): + return 20 - results = train_attack_classifier(ClassifierType.MLP, column_types, x_train, y_train, x_test, y_test) - assert "prediction_results" in results - assert "scores" in results - mock_train_mlp.assert_called_once() +@pytest.fixture +def hidden_dim(): + return 10 + + +@pytest.fixture +def sample_size(): + return 50 + + +@pytest.fixture +def model(input_dim, hidden_dim): + """Fixture to create a fresh model instance for each test.""" + return MLPClassifier(input_size=input_dim, hidden_size=hidden_dim, output_size=1, epochs=5, device=DEVICE) + + +@pytest.fixture +def dummy_data(sample_size, input_dim): + """Fixture to create synthetic training data.""" + # Random features + x = np.random.randn(sample_size, input_dim).astype(np.float32) + # Random binary labels (0 or 1) + y = np.random.randint(0, 2, size=(sample_size,)).astype(np.float32) + return x, y + + +def test_initialization(model, input_dim, hidden_dim): + """Test if the model initializes with correct layer dimensions.""" + assert isinstance(model.layers[0], nn.Linear) + assert model.layers[0].in_features == input_dim + assert model.layers[0].out_features == hidden_dim + assert isinstance(model.layers[2], nn.Linear) + assert model.layers[2].out_features == 1 + + +def test_fit_updates_weights(model, dummy_data): + """ + Test if calling fit() actually changes the model parameters. + If weights don't change, training is broken. + """ + x, y = dummy_data + + # Save a copy of the initial weights (specifically the first layer) + initial_weights = model.layers[0].weight.data.clone() + + model.fit(x, y) + + # Check if weights have changed + current_weights = model.layers[0].weight.data + assert not torch.equal(initial_weights, current_weights), "Weights did not update after training" + + +def test_predict_proba_shape_and_values(model, dummy_data): + """ + Test predict_proba output shape and probability constraints. + Should return (n_samples, 2) and values between 0 and 1. + """ + x, _ = dummy_data + + # We do not need to fit to test the shape of the output + probas = model.predict_proba(x) + + # Check Shape: (n_samples, 2) + assert probas.shape == (x.shape[0], 2) + + # Check Values: All between 0 and 1 + assert (probas >= 0).all() and (probas <= 1).all() + + # Check Sum: Probabilities across classes should sum to 1 + # We use np.isclose to handle floating point arithmetic + row_sums = np.sum(probas, axis=1) + assert np.allclose(row_sums, 1.0), "Probabilities do not sum to 1" + + +def test_predict_output_structure(model, dummy_data): + """ + Test predict output structure. + Should return (n_samples,) binary array. + """ + x, _ = dummy_data + + predictions = model.predict(x) + + # Check Shape + assert predictions.shape == (x.shape[0],) + + # Check content is binary (0.0 or 1.0) + unique_vals = np.unique(predictions) + assert np.isin(unique_vals, [0.0, 1.0]).all() def test_train_attack_classifier_unsupported(attack_data): diff --git a/uv.lock b/uv.lock index b4396ba7..226997ab 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -2093,11 +2093,11 @@ wheels = [ [[package]] name = "pip" -version = "25.3" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/c2/65686a7783a7c27a329706207147e82f23c41221ee9ae33128fc331670a0/pip-26.0.tar.gz", hash = "sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa", size = 1812654, upload-time = "2026-01-31T01:40:54.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, + { url = "https://files.pythonhosted.org/packages/69/00/5ac7aa77688ec4d34148b423d34dc0c9bc4febe0d872a9a1ad9860b2f6f1/pip-26.0-py3-none-any.whl", hash = "sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754", size = 1787564, upload-time = "2026-01-31T01:40:52.252Z" }, ] [[package]]