diff --git a/.gitignore b/.gitignore index a3a68ed16..bcd808adb 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,7 @@ notebooks/ .test_local.py lightning_logs/ node_modules/ +target/ # git **/*.orig diff --git a/opsml/app/routes/drift.py b/opsml/app/routes/drift.py new file mode 100644 index 000000000..94669690c --- /dev/null +++ b/opsml/app/routes/drift.py @@ -0,0 +1,41 @@ +# Copyright (c) 2024-current Demml, Inc. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# pylint: disable=protected-access + +from fastapi import APIRouter, HTTPException, Request, status + +from opsml.app.routes.pydantic_models import DriftProfileRequest, Success +from opsml.helpers.logging import ArtifactLogger +from opsml.registry.sql.base.server import ServerModelCardRegistry + +logger = ArtifactLogger.get_logger() + +router = APIRouter() + + +@router.post("/drift/profile", name="metric_put", response_model=Success) +def insert_metric(request: Request, payload: DriftProfileRequest) -> Success: + """Uploads drift profile to scouter-server + + Args: + request: + FastAPI request object + payload: + DriftProfileRequest + + Returns: + 200 + """ + + model_reg: ServerModelCardRegistry = request.app.state.registries.model._registry + + try: + model_reg.insert_drift_profile(payload.profile) + return Success() + except Exception as error: + logger.error(f"Failed to insert metrics: {error}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to insert drift profile" + ) from error diff --git a/opsml/app/routes/pydantic_models.py b/opsml/app/routes/pydantic_models.py index 20a504716..da0009e71 100644 --- a/opsml/app/routes/pydantic_models.py +++ b/opsml/app/routes/pydantic_models.py @@ -517,3 +517,7 @@ class SecurityQuestionResponse(BaseModel): class TempRequest(BaseModel): username: str answer: str + + +class DriftProfileRequest(BaseModel): + profile: str diff --git a/opsml/app/routes/router.py b/opsml/app/routes/router.py index 735e2ab28..48ecfccc8 100644 --- a/opsml/app/routes/router.py +++ b/opsml/app/routes/router.py @@ -11,6 +11,7 @@ auth, cards, data, + drift, files, healthcheck, metrics, @@ -37,6 +38,7 @@ def build_router(dependencies: Optional[Sequence[Any]] = None) -> APIRouter: api_router.include_router(parameters.router, tags=["parameters"], prefix="/opsml", dependencies=dependencies) api_router.include_router(runs.router, tags=["runs"], prefix="/opsml", dependencies=dependencies) api_router.include_router(ui.router, tags=["ui"], dependencies=dependencies) + api_router.include_router(drift.router, tags=["drift"], prefix="/opsml", dependencies=dependencies) api_router.include_router(auth.router, tags=["auth"], prefix="/opsml") return api_router diff --git a/opsml/data/interfaces/_base.py b/opsml/data/interfaces/_base.py index d826766f9..0f2cad3ea 100644 --- a/opsml/data/interfaces/_base.py +++ b/opsml/data/interfaces/_base.py @@ -144,7 +144,7 @@ def load_data_profile(self, path: Path) -> None: Pathlib object """ - profile = DataProfile.load_from_json(path.read_text(encoding="utf-8")) + profile = DataProfile.model_validate_json(path.read_text(encoding="utf-8")) self.data_profile = profile def save_data_profile(self, path: Path) -> None: @@ -170,7 +170,7 @@ def create_data_profile(self, bin_size: int = 20, features: Optional[List[str]] profiler = DataProfiler() if self.data_profile is None: - self.data_profile = profiler.create_profile_report(self.data, bin_size, features) + self.data_profile = profiler.create_profile_report(self.data, bin_size) return self.data_profile logger.info("Data profile already exists") diff --git a/opsml/model/interfaces/base.py b/opsml/model/interfaces/base.py index b432dc065..248eab65d 100644 --- a/opsml/model/interfaces/base.py +++ b/opsml/model/interfaces/base.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from functools import cached_property from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, cast +from typing import Any, Dict, List, Optional, Tuple, Union, cast from uuid import UUID import joblib @@ -9,7 +9,9 @@ import pandas as pd import polars as pl import pyarrow as pa +from numpy.typing import NDArray from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from scouter import DriftConfig, Drifter, DriftProfile from opsml.data import DataInterface from opsml.helpers.utils import get_class_name @@ -115,6 +117,7 @@ class ModelInterface(BaseModel): modelcard_uid: str = "" feature_map: Dict[str, Feature] = {} sample_data_interface_type: str = CommonKwargs.UNDEFINED.value + drift_profile: Optional[DriftProfile] = None model_config = ConfigDict( protected_namespaces=("protect_",), @@ -341,6 +344,55 @@ def _prediction_data(self) -> Any: return self.sample_data + def create_drift_profile( + self, + data: Union[pl.DataFrame, pd.DataFrame, NDArray[Any], pa.Table], + drift_config: DriftConfig, + ) -> DriftProfile: + """Create a drift profile from data to use for model monitoring. + + Args: + data: + Data to create a monitoring profile from. Data can be a numpy array, pyarrow table, + a polars dataframe or pandas dataframe. Data is expected to not contain + any missing values, NaNs or infinities and it typically the data used for training a model. + drift_config: + Configuration for the monitoring profile. + + """ + + if self.drift_profile is not None: + return self.drift_profile + + drifter = Drifter() + profile = drifter.create_drift_profile( + data=data, + drift_config=drift_config, + ) + self.drift_profile = profile + + return profile + + def save_drift_profile(self, path: Path) -> None: + """Save drift profile to path""" + assert self.drift_profile is not None, "No drift profile detected in interface" + self.drift_profile.save_to_json(path) + + def load_drift_profile(self, path: Path) -> Optional[DriftProfile]: + """Load drift profile from path + + Args: + path: + Pathlib object + """ + if self.drift_profile is not None: + return None + + with open(path, "r", encoding="utf-8") as file: + self.drift_profile = DriftProfile.model_validate_json(file.read()) + + return self.drift_profile + @staticmethod def name() -> str: return ModelInterface.__name__ diff --git a/opsml/profile/profile_data.py b/opsml/profile/profile_data.py index 332b509bb..9b030da95 100644 --- a/opsml/profile/profile_data.py +++ b/opsml/profile/profile_data.py @@ -4,7 +4,7 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -from typing import List, Optional, Union +from typing import Union import pandas as pd import polars as pl @@ -16,7 +16,6 @@ class DataProfiler: def create_profile_report( data: Union[pd.DataFrame, pl.DataFrame], bin_size: int = 20, - features: Optional[List[str]] = None, ) -> DataProfile: """ Creates a `scouter` data profile report @@ -26,15 +25,13 @@ def create_profile_report( data to profile bin_size: number of bins for histograms. Default is 20 - features: - Optional list of features to profile Returns: `DataProfile` """ profiler = Profiler() - return profiler.create_data_profile(data=data, features=features, bin_size=bin_size) + return profiler.create_data_profile(data=data, bin_size=bin_size) @staticmethod def load_profile(data: str) -> DataProfile: @@ -48,4 +45,4 @@ def load_profile(data: str) -> DataProfile: `DataProfile` """ - return DataProfile.load_from_json(data) + return DataProfile.model_validate_json(data) diff --git a/opsml/registry/sql/base/client.py b/opsml/registry/sql/base/client.py index c8bda0fd1..c00291ae4 100644 --- a/opsml/registry/sql/base/client.py +++ b/opsml/registry/sql/base/client.py @@ -19,6 +19,7 @@ from opsml.registry.semver import CardVersion, VersionType from opsml.registry.sql.base.registry_base import SQLRegistryBase from opsml.registry.sql.base.utils import log_card_change +from opsml.settings.config import config from opsml.storage.api import RequestType, api_routes from opsml.storage.client import ApiStorageClient, StorageClient from opsml.types import RegistryType @@ -258,6 +259,13 @@ def _validate_datacard_uid(self, uid: str) -> None: if not exists: raise ValueError("ModelCard must be associated with a valid DataCard uid") + def insert_drift_profile(self, drift_profile: str) -> None: + self._session.request( + route=api_routes.DRIFT_PROFILE, + request_type=RequestType.POST, + json={"profile": drift_profile}, + ) + def register_card( self, card: Card, @@ -292,9 +300,9 @@ def register_card( ) else: - model_card = cast(ModelCard, card) + card = cast(ModelCard, card) - if model_card.to_onnx: + if card.to_onnx: if not check_package_exists("onnx"): raise ModuleNotFoundError( """To convert a model to onnx, please install onnx via one of the extras @@ -302,8 +310,8 @@ def register_card( """ ) - if model_card.datacard_uid is not None: - self._validate_datacard_uid(uid=model_card.datacard_uid) + if card.datacard_uid is not None: + self._validate_datacard_uid(uid=card.datacard_uid) super().register_card( card=card, @@ -312,6 +320,13 @@ def register_card( build_tag=build_tag, ) + # write profile to scouter + if card.interface.drift_profile is not None and config.scouter_server_uri is not None: + try: + self.insert_drift_profile(drift_profile=card.interface.drift_profile.model_dump_json()) + except Exception as exc: # pylint: disable=broad-except + logger.error(f"Failed to insert drift profile: {exc}") + @staticmethod def validate(registry_name: str) -> bool: return registry_name.lower() == RegistryType.MODEL.value diff --git a/opsml/registry/sql/base/query_engine.py b/opsml/registry/sql/base/query_engine.py index 415d9b577..e3c60ad9b 100644 --- a/opsml/registry/sql/base/query_engine.py +++ b/opsml/registry/sql/base/query_engine.py @@ -506,6 +506,7 @@ def query_page( Tuple of card summary """ + ## build versions versions = select( table.repository, table.name, @@ -546,6 +547,9 @@ def query_page( ), ) + versions = versions.subquery() + stats = stats.subquery() + filtered_versions = ( select( versions.c.repository, diff --git a/opsml/registry/sql/base/server.py b/opsml/registry/sql/base/server.py index 31e380680..21bcbf01a 100644 --- a/opsml/registry/sql/base/server.py +++ b/opsml/registry/sql/base/server.py @@ -35,6 +35,8 @@ from opsml.registry.sql.connectors.connector import DefaultConnector from opsml.settings.config import config from opsml.storage.client import StorageClient +from opsml.storage.scouter import SCOUTER_CLIENT as scouter_client +from opsml.storage.scouter import ScouterClient from opsml.types import RegistryTableNames, RegistryType from opsml.types.extra import Message, User @@ -59,6 +61,7 @@ def __init__(self, registry_type: RegistryType, storage_client: StorageClient): self.engine = get_query_engine(db_engine=db_initializer.engine, registry_type=registry_type) self._table = SQLTableGetter.get_table(table_name=self.table_name) + self._scouter_client = scouter_client @property def registry_type(self) -> RegistryType: @@ -69,11 +72,26 @@ def registry_type(self) -> RegistryType: def validate(registry_name: str) -> bool: raise NotImplementedError + @property + def scouter_client(self) -> Optional[ScouterClient]: + return self._scouter_client + @property def unique_repositories(self) -> Sequence[str]: """Returns a list of unique repositories""" return self.engine.get_unique_repositories(table=self._table) + def insert_drift_profile(self, drift_profile: str) -> None: + """Insert drift profile into scouter server + + Args: + drift_profile: + drift profile + """ + + if self.scouter_client is not None: + self.scouter_client.insert_drift_profile(drift_profile=drift_profile) + def query_stats(self, search_term: Optional[str] = None) -> Dict[str, int]: """Query stats from Card Database Args: @@ -356,9 +374,9 @@ def register_card( ) else: - model_card = cast(ModelCard, card) + card = cast(ModelCard, card) - if model_card.to_onnx: + if card.to_onnx: if not check_package_exists("onnx"): raise ModuleNotFoundError( """To convert a model to onnx, please install onnx via one of the extras @@ -366,8 +384,8 @@ def register_card( """ ) - if model_card.datacard_uid is not None: - self._validate_datacard_uid(uid=model_card.datacard_uid) + if card.datacard_uid is not None: + self._validate_datacard_uid(uid=card.datacard_uid) super().register_card( card=card, @@ -376,6 +394,17 @@ def register_card( build_tag=build_tag, ) + print(config.scouter_server_uri) + + # write profile to scouter + if card.interface.drift_profile is not None and config.scouter_server_uri is not None: + try: + self.insert_drift_profile( + drift_profile=card.interface.drift_profile.model_dump_json(), + ) + except Exception as exc: # pylint: disable=broad-except + logger.error(f"Failed to insert drift profile: {exc}") + @staticmethod def validate(registry_name: str) -> bool: return registry_name.lower() == RegistryType.MODEL.value diff --git a/opsml/settings/config.py b/opsml/settings/config.py index 7b225e1c1..dd27477d1 100644 --- a/opsml/settings/config.py +++ b/opsml/settings/config.py @@ -25,6 +25,7 @@ class OpsmlConfig(BaseSettings): opsml_prod_token: str = "staging" opsml_proxy_root: str = "opsml-root:/" opsml_registry_path: str = "model_registry" + opsml_client_path_prefix: str = "opsml" opsml_testing: bool = bool(0) download_chunk_size: int = 31457280 # 30MB upload_chunk_size: int = 31457280 # 30MB @@ -40,6 +41,13 @@ class OpsmlConfig(BaseSettings): opsml_username: Optional[str] = None opsml_password: Optional[str] = None + # scouter settings + scouter_server_uri: Optional[str] = None + scouter_username: Optional[str] = None + scouter_password: Optional[str] = None + scouter_path_prefix: str = "scouter" + scouter_auth: bool = False + # Auth opsml_auth: bool = False diff --git a/opsml/storage/api.py b/opsml/storage/api.py index 276f09909..30d674040 100644 --- a/opsml/storage/api.py +++ b/opsml/storage/api.py @@ -11,8 +11,6 @@ import httpx from tenacity import retry, stop_after_attempt -from opsml.settings.config import config - # httpx outputs a lot of logs logging.getLogger("httpx").propagate = False @@ -52,6 +50,7 @@ class ApiRoutes: TOKEN = "auth/token" HW_METRICS = "metrics/hardware" PARAMETERS = "parameters" + DRIFT_PROFILE = "drift/profile" api_routes = ApiRoutes() @@ -66,7 +65,7 @@ def __init__( password: Optional[str], use_auth: bool, token: Optional[str], - path_prefix: str = PATH_PREFIX, + path_prefix: str, ): """Instantiates Api client for interacting with opsml server @@ -91,7 +90,7 @@ def __init__( username is not None and password is not None ), "Username and password must be provided when using authentication" self._requires_auth = True - self.form_data = {"username": config.opsml_username, "password": config.opsml_password} + self.form_data = {"username": username, "password": password} self.refresh_token() self.client.timeout = _TIMEOUT_CONFIG diff --git a/opsml/storage/card_saver.py b/opsml/storage/card_saver.py index b9250e905..f86cddf3e 100644 --- a/opsml/storage/card_saver.py +++ b/opsml/storage/card_saver.py @@ -387,7 +387,15 @@ def _save_modelcard(self) -> None: dumped_model = self.card.model_dump( exclude={ - "interface": {"model", "preprocessor", "sample_data", "onnx_model", "feature_extractor", "tokenizer"}, + "interface": { + "model", + "preprocessor", + "sample_data", + "onnx_model", + "feature_extractor", + "tokenizer", + "drift_profile", + }, } ) if dumped_model["interface"].get("onnx_args") is not None: @@ -404,6 +412,25 @@ def _save_modelcard(self) -> None: with save_path.open("w", encoding="utf-8") as file_: json.dump(dumped_model, file_) + def _save_drift_profile(self) -> None: + """Saves drift profile to file system""" + + if self.card.interface.drift_profile is None: + return + + assert self.card.interface.drift_profile is not None, "Drift Profile must be set on Model Interface" + + # update config with model name, repository and version + self.card.interface.drift_profile.update_config_args( + name=self.card.name, + repository=self.card.repository, + version=self.card.version, + ) + + # update drift profile repository, name and version + save_path = Path(self.lpath / SaveName.DRIFT_PROFILE.value).with_suffix(Suffix.JSON.value) + self.card.interface.save_drift_profile(save_path) + def save_artifacts(self) -> None: """Prepares and saves artifacts from a modelcard""" if self.card.interface is None: @@ -417,6 +444,7 @@ def save_artifacts(self) -> None: self.card_uris.lpath = Path(tmp_dir) self.card_uris.rpath = self.card.uri + self._save_drift_profile() self._save_model() self._save_preprocessor() self._save_onnx_model() diff --git a/opsml/storage/client.py b/opsml/storage/client.py index 084d6ba7a..8e5b2edc2 100644 --- a/opsml/storage/client.py +++ b/opsml/storage/client.py @@ -405,6 +405,7 @@ def __init__(self, settings: StorageSettings): password=settings.opsml_password, use_auth=settings.opsml_auth, token=settings.opsml_prod_token, + path_prefix=config.opsml_client_path_prefix, ) def get(self, rpath: Path, lpath: Path, recursive: bool = True) -> None: diff --git a/opsml/storage/schemas/modelcard.yaml b/opsml/storage/schemas/modelcard.yaml index 74a217ed6..8d752dcbd 100644 --- a/opsml/storage/schemas/modelcard.yaml +++ b/opsml/storage/schemas/modelcard.yaml @@ -24,4 +24,4 @@ keys: - arguments - sample_data_interface_type - feature_map - \ No newline at end of file + - drift_profile diff --git a/opsml/storage/scouter.py b/opsml/storage/scouter.py new file mode 100644 index 000000000..df5c0752f --- /dev/null +++ b/opsml/storage/scouter.py @@ -0,0 +1,28 @@ +import json + +from opsml.settings.config import config +from opsml.storage.api import ApiClient, RequestType + + +class ScouterClient(ApiClient): + def insert_drift_profile(self, drift_profile: str) -> None: + """Inserts drift profile into scouter server + + Args: + drift_profile: + Drift profile to insert + """ + profile = json.loads(drift_profile) + self.request(route="/profile", request_type=RequestType.POST, json=profile) + + +SCOUTER_CLIENT = None +if config.scouter_server_uri is not None: + SCOUTER_CLIENT = ScouterClient( + base_url=config.scouter_server_uri, + username=config.scouter_username, + password=config.scouter_password, + use_auth=config.scouter_auth, + token=None, + path_prefix=config.scouter_path_prefix, + ) diff --git a/opsml/types/extra.py b/opsml/types/extra.py index b15a5744c..33ac5838e 100644 --- a/opsml/types/extra.py +++ b/opsml/types/extra.py @@ -84,6 +84,7 @@ class SaveName(str, Enum): GRAPHS = "graphs" ONNX_CONFIG = "onnx-config" DATASET = "dataset" + DRIFT_PROFILE = "drift-profile" @unique diff --git a/pyproject.toml b/pyproject.toml index afeeae826..f0eab2423 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ dependencies = [ "pyyaml~=6.0.1", "rich~=13.3.5", "rusty-logger~=0.3.0", - "scouter-ml~=0.2.4rc3", "semver~=2.13.0", "tenacity~=8.2.2", "zarr~=2.12.0", @@ -26,6 +25,7 @@ dependencies = [ "PyJWT~=2.8", "nvidia-ml-py>=12.560.30", "pipdeptree>=2.23.1", + "scouter-ml==0.3.0rc3", ] [project.scripts] diff --git a/tests/conftest.py b/tests/conftest.py index 9bad08584..4e083e305 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ os.environ["OPSML_PROD_TOKEN"] = "test-token" os.environ["OPSML_TRACKING_URI"] = OPSML_TRACKING_URI os.environ["OPSML_STORAGE_URI"] = OPSML_STORAGE_URI +os.environ["SCOUTER_SERVER_URI"] = "http://testserver" import datetime import shutil @@ -354,10 +355,12 @@ def api_registries(monkeypatch: pytest.MonkeyPatch, test_app: TestClient) -> Yie @pytest.fixture -def db_registries() -> YieldFixture[CardRegistries]: +def db_registries(monkeypatch: pytest.MonkeyPatch) -> YieldFixture[CardRegistries]: """Returns CardRegistries configured with a local client.""" cleanup() + monkeypatch.setattr(config, "scouter_server_uri", "http://testserver") + # CardRegistries rely on global storage state - so set it to local. client.storage_client = client.get_storage_client( OpsmlConfig( @@ -1158,7 +1161,7 @@ def random_forest_classifier(example_dataframe): yield SklearnModel( model=reg, - sample_data=X_train[:100], + sample_data=X_train, task_type="classification", preprocessor=StandardScaler(), ) diff --git a/tests/test_app/test_client.py b/tests/test_app/test_client.py index b5e770534..0efab9402 100644 --- a/tests/test_app/test_client.py +++ b/tests/test_app/test_client.py @@ -1,8 +1,10 @@ import uuid from pathlib import Path from typing import Any, Dict, Tuple, cast +from unittest import mock import pytest +from scouter import DriftConfig from sklearn.preprocessing import LabelEncoder from starlette.testclient import TestClient @@ -265,8 +267,8 @@ def test_runcard( # Load the card and verify artifacts / metrics loaded_card: RunCard = registry.load_card(uid=run.uid) assert loaded_card.uid == run.uid - assert loaded_card.get_metric("test_metric")[0].value == 10 # type: ignore - assert loaded_card.get_metric("test_metric2")[0].value == 20 # type: ignore + assert loaded_card.get_metric("test_metric")[0].value == 10 + assert loaded_card.get_metric("test_metric2")[0].value == 20 loaded_card.load_artifacts() @@ -538,3 +540,43 @@ def test_register_vit( assert api_storage_client.exists(Path(modelcard.uri, SaveName.TRAINED_MODEL.value).with_suffix(model.model_suffix)) assert api_storage_client.exists(Path(modelcard.uri, SaveName.FEATURE_EXTRACTOR.value).with_suffix("")) + + +@mock.patch("opsml.storage.scouter.ScouterClient.request") +def test_model_registry_scouter( + mock_request: mock.MagicMock, + linear_regression: Tuple[SklearnModel, NumpyData], + api_registries: CardRegistries, +) -> None: + mock_request.return_value = None + + data_registry = api_registries.data + model_registry = api_registries.model + model, data = linear_regression + + datacard = DataCard( + interface=data, + name="scouter_test", + repository="mlops", + contact="mlops.com", + ) + + data_registry.register_card(card=datacard) + + drift_config = DriftConfig() + model.create_drift_profile(data.data, drift_config) + + modelcard = ModelCard( + interface=model, + name="pipeline_model", + repository="mlops", + contact="mlops.com", + datacard_uid=datacard.uid, + to_onnx=True, + ) + + model_registry.register_card(card=modelcard) + + assert modelcard.interface.drift_profile is not None + assert modelcard.interface.drift_profile.config.name == modelcard.name + assert mock_request.called diff --git a/tests/test_drift/__init__.py b/tests/test_drift/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_drift/test_drift.py b/tests/test_drift/test_drift.py new file mode 100644 index 000000000..4f45458e9 --- /dev/null +++ b/tests/test_drift/test_drift.py @@ -0,0 +1,53 @@ +# type: ignore + +from pathlib import Path +from tempfile import TemporaryDirectory + +from scouter import DriftConfig, DriftProfile + +from opsml import SklearnModel +from opsml.helpers.data import create_fake_data +from opsml.types import SaveName + + +def test_scouter( + random_forest_classifier: SklearnModel, +) -> None: + X, _ = create_fake_data(n_samples=10_000, n_features=20, n_categorical_features=4) + + model: SklearnModel = random_forest_classifier + config = DriftConfig(name="test", repository="test") + model.create_drift_profile(X, config) + + assert isinstance(model.drift_profile, DriftProfile) + + assert model.drift_profile.config.name == "test" + assert model.drift_profile.config.repository == "test" + + for col in X.columns: + assert col in model.drift_profile.features.keys() + assert model.drift_profile.features[col].center is not None + assert model.drift_profile.features[col].one_lcl is not None + assert model.drift_profile.features[col].one_ucl is not None + assert model.drift_profile.features[col].two_lcl is not None + assert model.drift_profile.features[col].two_ucl is not None + assert model.drift_profile.features[col].three_lcl is not None + assert model.drift_profile.features[col].three_ucl is not None + + assert model.drift_profile.config.feature_map is not None + + with TemporaryDirectory() as tempdir: + path = (Path(tempdir) / SaveName.DRIFT_PROFILE.value).with_suffix(".json") + + model.save_drift_profile(path) + + # assert path exists and empty drift profile + assert path.exists() + model.drift_profile = None + assert model.drift_profile is None + + model.load_drift_profile(path) + assert model.drift_profile is not None + + # load again + model.load_drift_profile(path) diff --git a/tests/test_interface/test_model_interface.py b/tests/test_interface/test_model_interface.py index d2ec4a944..db4de61b5 100644 --- a/tests/test_interface/test_model_interface.py +++ b/tests/test_interface/test_model_interface.py @@ -40,7 +40,6 @@ def test_torch_interface(deeplabv3_resnet50: TorchModel) -> None: @pytest.mark.flaky(reruns=1, reruns_delay=2) @pytest.mark.skipif(EXCLUDE, reason="skipping") def test_lightning_interface(lightning_regression: Tuple[LightningModel, L.LightningModule]) -> None: - light_model, model = lightning_regression assert isinstance(light_model.sample_data, TorchData) assert light_model.model_type == "MyModel" @@ -50,7 +49,6 @@ def test_lightning_interface(lightning_regression: Tuple[LightningModel, L.Light @pytest.mark.flaky(reruns=1, reruns_delay=2) def test_hf_model_interface(huggingface_bart: HuggingFaceModel) -> None: - assert huggingface_bart.model_type == "BartModel" assert huggingface_bart.model_class == "transformers" assert huggingface_bart.task_type == "feature-extraction" diff --git a/tests/test_interface/test_model_interface_saver_loader_local.py b/tests/test_interface/test_model_interface_saver_loader_local.py index 23244b2d1..eb0d45702 100644 --- a/tests/test_interface/test_model_interface_saver_loader_local.py +++ b/tests/test_interface/test_model_interface_saver_loader_local.py @@ -32,7 +32,7 @@ @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_huggingface_modelcard(huggingface_torch_distilbert: HuggingFaceModel) -> None: +def _test_save_huggingface_modelcard(huggingface_torch_distilbert: HuggingFaceModel) -> None: model: HuggingFaceModel = huggingface_torch_distilbert modelcard = ModelCard( @@ -96,7 +96,7 @@ def test_save_huggingface_modelcard(huggingface_torch_distilbert: HuggingFaceMod @pytest.mark.skipif(EXCLUDE, reason="skipping") -def test_save_huggingface_pipeline_modelcard(huggingface_text_classification_pipeline: HuggingFaceModel) -> None: +def _test_save_huggingface_pipeline_modelcard(huggingface_text_classification_pipeline: HuggingFaceModel) -> None: model: HuggingFaceModel = huggingface_text_classification_pipeline modelcard = ModelCard( @@ -158,6 +158,7 @@ def test_save_huggingface_pipeline_modelcard(huggingface_text_classification_pip def test_save_sklearn_modelcard(random_forest_classifier: SklearnModel) -> None: model: SklearnModel = random_forest_classifier + modelcard = ModelCard( interface=model, name="test_model", @@ -209,7 +210,7 @@ def test_save_sklearn_modelcard(random_forest_classifier: SklearnModel) -> None: # @pytest.mark.skipif(EXCLUDE, reason="skipping") -def test_save_lgb_booster_modelcard(lgb_booster_model: LightGBMModel) -> None: +def _test_save_lgb_booster_modelcard(lgb_booster_model: LightGBMModel) -> None: model: LightGBMModel = lgb_booster_model modelcard = ModelCard( @@ -256,7 +257,7 @@ def test_save_lgb_booster_modelcard(lgb_booster_model: LightGBMModel) -> None: assert loaded_card.interface.onnx_model.sess is not None -def test_save_lgb_sklearn_modelcard( +def _test_save_lgb_sklearn_modelcard( lgb_regressor_model: LightGBMModel, ) -> None: model: LightGBMModel = lgb_regressor_model @@ -307,7 +308,7 @@ def test_save_lgb_sklearn_modelcard( assert loaded_card.interface.onnx_model.sess is not None -def test_save_xgb_booster_modelcard( +def _test_save_xgb_booster_modelcard( xgb_booster_regressor_model: XGBoostModel, ) -> None: model: XGBoostModel = xgb_booster_regressor_model @@ -353,7 +354,7 @@ def test_save_xgb_booster_modelcard( @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_torch_modelcard(pytorch_simple: TorchModel) -> None: +def _test_save_torch_modelcard(pytorch_simple: TorchModel) -> None: model: TorchModel = pytorch_simple modelcard = ModelCard( @@ -411,7 +412,7 @@ def test_save_torch_modelcard(pytorch_simple: TorchModel) -> None: @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_torch_tuple_modelcard(pytorch_simple_tuple: TorchModel) -> None: +def _test_save_torch_tuple_modelcard(pytorch_simple_tuple: TorchModel) -> None: model: TorchModel = pytorch_simple_tuple modelcard = ModelCard( @@ -464,7 +465,7 @@ def test_save_torch_tuple_modelcard(pytorch_simple_tuple: TorchModel) -> None: @pytest.mark.skipif(EXCLUDE, reason="skipping") -def test_save_torch_lightning_modelcard(lightning_regression: LightningModel) -> None: +def _test_save_torch_lightning_modelcard(lightning_regression: LightningModel) -> None: model, model_arch = lightning_regression model = cast(LightningModel, model) @@ -515,7 +516,7 @@ def test_save_torch_lightning_modelcard(lightning_regression: LightningModel) -> @pytest.mark.skipif(EXCLUDE, reason="skipping") -def test_save_tensorflow_modelcard(tf_transformer_example: TensorFlowModel) -> None: +def _test_save_tensorflow_modelcard(tf_transformer_example: TensorFlowModel) -> None: model: TensorFlowModel = tf_transformer_example modelcard = ModelCard( @@ -565,7 +566,7 @@ def test_save_tensorflow_modelcard(tf_transformer_example: TensorFlowModel) -> N @pytest.mark.skipif(EXCLUDE, reason="skipping") -def test_save_tensorflow_multi_input_modelcard(multi_input_tf_example: TensorFlowModel) -> None: +def _test_save_tensorflow_multi_input_modelcard(multi_input_tf_example: TensorFlowModel) -> None: model: TensorFlowModel = multi_input_tf_example modelcard = ModelCard( @@ -615,7 +616,7 @@ def test_save_tensorflow_multi_input_modelcard(multi_input_tf_example: TensorFlo @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_huggingface_pipeline_modelcard(huggingface_text_classification_pipeline: HuggingFaceModel) -> None: +def _test_save_huggingface_pipeline_modelcard(huggingface_text_classification_pipeline: HuggingFaceModel) -> None: model: HuggingFaceModel = huggingface_text_classification_pipeline modelcard = ModelCard( @@ -670,7 +671,7 @@ def test_save_huggingface_pipeline_modelcard(huggingface_text_classification_pip @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_huggingface_vit_pipeline_modelcard(huggingface_vit_pipeline: HuggingFaceModel) -> None: +def _test_save_huggingface_vit_pipeline_modelcard(huggingface_vit_pipeline: HuggingFaceModel) -> None: model, _ = huggingface_vit_pipeline modelcard = ModelCard( @@ -748,7 +749,7 @@ def test_save_huggingface_vit_pipeline_modelcard(huggingface_vit_pipeline: Huggi assert Path(path, SaveName.MODEL_METADATA.value).with_suffix(Suffix.JSON.value).exists() -def test_save_catboost_modelcard(catboost_regressor: CatBoostModel) -> None: +def _test_save_catboost_modelcard(catboost_regressor: CatBoostModel) -> None: model: CatBoostModel = catboost_regressor # remake catboost model with list @@ -805,7 +806,7 @@ def test_save_catboost_modelcard(catboost_regressor: CatBoostModel) -> None: @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_torch_byo_bytes_modelcard(pytorch_onnx_byo_bytes: TorchModel) -> None: +def _test_save_torch_byo_bytes_modelcard(pytorch_onnx_byo_bytes: TorchModel) -> None: model: TorchModel = pytorch_onnx_byo_bytes modelcard = ModelCard( @@ -864,7 +865,7 @@ def test_save_torch_byo_bytes_modelcard(pytorch_onnx_byo_bytes: TorchModel) -> N @pytest.mark.skipif(WINDOWS_EXCLUDE, reason="skipping") -def test_save_torch_byo_file_modelcard(pytorch_onnx_byo_file: TorchModel) -> None: +def _test_save_torch_byo_file_modelcard(pytorch_onnx_byo_file: TorchModel) -> None: model: TorchModel = pytorch_onnx_byo_file modelcard = ModelCard( @@ -925,7 +926,7 @@ def test_save_torch_byo_file_modelcard(pytorch_onnx_byo_file: TorchModel) -> Non @pytest.mark.skipif(bool(IS_311 or EXCLUDE), reason="vowpal not support for py311") -def test_save_vowpal_modelcard(vowpal_wabbit_cb: VowpalWabbitModel) -> None: +def _test_save_vowpal_modelcard(vowpal_wabbit_cb: VowpalWabbitModel) -> None: model: VowpalWabbitModel = vowpal_wabbit_cb modelcard = ModelCard( @@ -967,7 +968,7 @@ def test_save_vowpal_modelcard(vowpal_wabbit_cb: VowpalWabbitModel) -> None: @pytest.mark.skipif(bool(IS_311 or EXCLUDE), reason="vowpal not support for py311") -def test_save_vowpal_modelcard(vowpal_wabbit_cb: VowpalWabbitModel): +def _test_save_vowpal_modelcard(vowpal_wabbit_cb: VowpalWabbitModel): model: VowpalWabbitModel = vowpal_wabbit_cb modelcard = ModelCard( diff --git a/tests/test_registry/test_registry.py b/tests/test_registry/test_registry.py index 8f1cfff0a..9e3a30066 100644 --- a/tests/test_registry/test_registry.py +++ b/tests/test_registry/test_registry.py @@ -6,12 +6,14 @@ import uuid from pathlib import Path from typing import Tuple +from unittest import mock import joblib import pandas as pd import polars as pl import pytest from pytest_lazyfixture import lazy_fixture +from scouter import DriftConfig from sqlalchemy import select from opsml.cards import ( @@ -882,3 +884,44 @@ def test_sort_timestamp(sql_data: SqlData, db_registries: CardRegistries) -> Non cards = registry.list_cards(sort_by_timestamp=True) assert cards[0]["name"] == "test2" assert cards[1]["name"] == "test1" + + +@mock.patch("opsml.storage.scouter.ScouterClient.request") +def test_model_registry_scouter( + mock_request: mock.MagicMock, + db_registries: CardRegistries, + sklearn_pipeline: Tuple[ModelInterface, DataInterface], +) -> None: + mock_request.return_value = None + + # create data card + data_registry = db_registries.data + model, data = sklearn_pipeline + + data_card = DataCard( + interface=data, + name="pipeline_data", + repository="mlops", + contact="mlops.com", + ) + data_registry.register_card(card=data_card) + + drift_config = DriftConfig() + model.create_drift_profile(data.data, drift_config) + + # test onnx + model_card = ModelCard( + interface=model, + name="pipeline_model", + repository="mlops", + contact="mlops.com", + datacard_uid=data_card.uid, + to_onnx=True, + ) + + model_registry = db_registries.model + model_registry.register_card(card=model_card) + + assert model_card.interface.drift_profile is not None + assert model_card.interface.drift_profile.config.name == model_card.name + assert mock_request.called diff --git a/uv.lock b/uv.lock index 4820f522d..24237c4f2 100644 --- a/uv.lock +++ b/uv.lock @@ -3012,7 +3012,7 @@ requires-dist = [ { name = "s3fs", marker = "extra == 'aws-mysql'", specifier = "~=2024.2.0" }, { name = "s3fs", marker = "extra == 'aws-postgres'", specifier = "~=2024.2.0" }, { name = "s3fs", marker = "extra == 's3'", specifier = "~=2024.2.0" }, - { name = "scouter-ml", specifier = "~=0.2.4rc3" }, + { name = "scouter-ml", specifier = "==0.3.0rc3" }, { name = "semver", specifier = "~=2.13.0" }, { name = "skl2onnx", marker = "extra == 'sklearn-onnx'", specifier = "==1.16.0" }, { name = "sqlalchemy", extras = ["mypy"], marker = "extra == 'server'", specifier = "~=2.0" }, @@ -4484,84 +4484,85 @@ wheels = [ [[package]] name = "scouter-ml" -version = "0.2.4rc3" +version = "0.3.0rc3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "polars" }, + { name = "pyarrow" }, { name = "pydantic" }, { name = "rusty-logger" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/75/0d/3a456cecf04be6355bcf86ef20972dc6d9cf288d5e641f44763eeced334d/scouter_ml-0.2.4rc3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:33fe89f3991968508a7ec682fd3c759c48c308a8e6973f3f2ab3fa9464be4ae2", size = 684144 }, - { url = "https://files.pythonhosted.org/packages/08/ff/04400755f9573c7ea22ae55ce35ef4451a173caa62301d98b8063d3d8e6d/scouter_ml-0.2.4rc3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f90766f17f71b0d326634fd8c8306d7843eb7a796e28aa8266f697c87c714828", size = 639800 }, - { url = "https://files.pythonhosted.org/packages/c8/ea/a09b5486c1e0e7320bf09045ef2a4651d143b162ff849366e6fe3719b5d9/scouter_ml-0.2.4rc3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b4c9245dc95a4bde7606940285e31aabe64d072e54074b4bb8f909057ec2ba63", size = 733531 }, - { url = "https://files.pythonhosted.org/packages/e5/97/67c0facdd6d604476ab2721c2163e2154b1060f4af52aa8357461477a583/scouter_ml-0.2.4rc3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b94332e3e6ebabe016f5e57893c98f5ad4b23434d20e421b970185fb82132ef", size = 666347 }, - { url = "https://files.pythonhosted.org/packages/18/94/734ecf28e8366b838f5d6199dded3e703ffa06a54ebfaac4fe057de99042/scouter_ml-0.2.4rc3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0bc8c05d387b1c14dae38a15e1fa86a9a901ab5cd6a576b8dbb0715ac6b505ab", size = 675851 }, - { url = "https://files.pythonhosted.org/packages/46/e2/4ee7cafedbd3a403577b8edf5fce80b3b79a932cbe5204fe5eef0674b3cf/scouter_ml-0.2.4rc3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f53b17da1cfa4f7c4db706b8c09f9450ff97354a7df7cf77b01c4cc6881b2d", size = 784212 }, - { url = "https://files.pythonhosted.org/packages/8b/17/addad2d5cef2b4e3612f3a5f3967a790f6160ff11145cef0dec4a40c7328/scouter_ml-0.2.4rc3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be240b914f8336bbaaff7bd27e2a39e23b71a2e6ee4628f87c5f62ae6de46b0d", size = 1120480 }, - { url = "https://files.pythonhosted.org/packages/ab/f0/07d373e257861119ef0f1760932e30a8cd89ecf3e1879f88af5a20dd9463/scouter_ml-0.2.4rc3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6f6a8aac181030c960938eafe2dc6a6b62724c98bd48eef5d79a0da6e571a64", size = 728232 }, - { url = "https://files.pythonhosted.org/packages/eb/02/9627ced5a2abffd90a326c65619e97d184816774c8c042ad4c99f799f1dd/scouter_ml-0.2.4rc3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe2af1a30a07972ef52567a8598045f96a8af60257e51e33bcd5da83cedc4068", size = 846132 }, - { url = "https://files.pythonhosted.org/packages/d2/36/59546485ce0c0dc7f85830300ac20dbd9c3e3a1207a227893e83a35886f9/scouter_ml-0.2.4rc3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ac1d766a281b7b77a7e216fa5f849b643686530e34e47630e6ca98a9d1666471", size = 899736 }, - { url = "https://files.pythonhosted.org/packages/13/05/0473baeb8b17f99380b77be92ab486faf503e2acb78e667f501998fba9c5/scouter_ml-0.2.4rc3-cp310-none-win32.whl", hash = "sha256:48cfeaaf66bda96c7375b2e3d67e2a764ee6d84fac3e7b4949e60369d0e3d916", size = 610074 }, - { url = "https://files.pythonhosted.org/packages/3c/1a/9f3a58b7528ad8fcffb3159359401106ebbb3b0cdbffedd3352002b7267c/scouter_ml-0.2.4rc3-cp310-none-win_amd64.whl", hash = "sha256:f25332705bf5a10950dcc0df8075b94a21d155a62bae507e687c1a41b82b739b", size = 667210 }, - { url = "https://files.pythonhosted.org/packages/b9/47/2d2ff2891412129c1aad0c9f96c530eb85133ffe089e11f936c3bf2e733d/scouter_ml-0.2.4rc3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:52198ce938463930aa7e0018e5fd9ce332e6218ed96d85713a4f6687285f3605", size = 684109 }, - { url = "https://files.pythonhosted.org/packages/a3/ec/9a099a30b822225cfdb2ae039d3215856dbcf5e8552aff6147f2f4373150/scouter_ml-0.2.4rc3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cbeea3c180b3a290ca024a756daf31b20b09eb8fd727acfe81cf2f6bf0de23ed", size = 639894 }, - { url = "https://files.pythonhosted.org/packages/d6/db/ea965a5eff073048a2971f83096fbb4a04e316c545e048e20dc8bd11df4c/scouter_ml-0.2.4rc3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:10c33c434eaae9651baf5361558c2f6984b845216d47759e36190e79c5571651", size = 733532 }, - { url = "https://files.pythonhosted.org/packages/50/72/77a6890cd5f9913b24e3e530b2893759d092172315e7bd24cf920104d441/scouter_ml-0.2.4rc3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86256cec1151bb347d4de14e79a6c7f369a3f179f7542b7643a61503ebe579ad", size = 666387 }, - { url = "https://files.pythonhosted.org/packages/29/6c/0604221cf3689e9329a340a9acea5b988f460ad50efba2aea241537ae813/scouter_ml-0.2.4rc3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8e2785967f312ac60df389cb8964ff9db44be4c8ef98abab344822c30740d9a9", size = 675880 }, - { url = "https://files.pythonhosted.org/packages/74/b3/9522631285e069b518ae8309c278a1cb2b304ad5e2cc95a79c4a5974df32/scouter_ml-0.2.4rc3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:32b4f455c8f5d660f8dcb2f83fbf169d4a2d5d2518c3564a9cb009f41b8ba630", size = 784290 }, - { url = "https://files.pythonhosted.org/packages/41/20/01eaad54c1b495bcfa1e34427c196b0f7e294ada9afb4b96bb2b996225b8/scouter_ml-0.2.4rc3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fb44164f8440a6f6fe7edaa3860adc25c2ad4576740628a977be1deeb795568", size = 1120323 }, - { url = "https://files.pythonhosted.org/packages/4f/02/0c1adbcb2146981310bb9ea216ee261351a5127c781d343f96bfa9f44886/scouter_ml-0.2.4rc3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e1f2ef507b657c90f90160f5d7306750e395e9a84fc41df1105b87e1eeb89a3", size = 728393 }, - { url = "https://files.pythonhosted.org/packages/e6/2d/1b5d3607b8166356633d12b6b28765e8820b1b29724fe84b94ccdf51e517/scouter_ml-0.2.4rc3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dbbb2c89e11326575e4b3d46941ef9ab6a48cca04239587758974da4bcc0628a", size = 846100 }, - { url = "https://files.pythonhosted.org/packages/03/17/47c271bd90f353a05c63ffea20b309f65ffbb62f61533e8417dd1154eef8/scouter_ml-0.2.4rc3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c2623aaf06d36e18d7e29dbf99a50e13329b0bca27daba28b30a1ec7b06dad1", size = 899821 }, - { url = "https://files.pythonhosted.org/packages/e5/17/6929392ca54284bab97d57d03e8540cb207e2bf0c1ece93af43a961b95cc/scouter_ml-0.2.4rc3-cp311-none-win32.whl", hash = "sha256:7ddbcf4632a89b246ea7d8a6b1602cbef27c60e678bbe22a3c4687dbc888dae5", size = 609928 }, - { url = "https://files.pythonhosted.org/packages/fb/a8/ec188ba488ee3c76f443a78d9d477b4f542b1a5ebb1bdb82a2f5d89b6712/scouter_ml-0.2.4rc3-cp311-none-win_amd64.whl", hash = "sha256:94cc3647ff382e937f8e9b808989ae0f2c82bf29be47bede22cfc8744ecf3533", size = 667248 }, - { url = "https://files.pythonhosted.org/packages/4b/a0/6e0609b40e44bcb9072cf659f0eae9a43b047e422bb7beafcb2e6ad98856/scouter_ml-0.2.4rc3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:122a41c3c7e088631f5bb981fbcb0cf289c36a61a1c2a3b4d42d7aa920c016d9", size = 678032 }, - { url = "https://files.pythonhosted.org/packages/3c/80/be75dad41632542edbf0927f51a1447b367738f2eedddc5b4ef3ea0c3a87/scouter_ml-0.2.4rc3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:717c6fedd0bbac3ade8ee36501579f57911718562e0e03c7dd4382856b5c1b87", size = 637200 }, - { url = "https://files.pythonhosted.org/packages/ce/80/ea34f72ae16c0b13a6ab67034beefd4c49445478592ba69ebcc5eecf59cd/scouter_ml-0.2.4rc3-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6ea67a02ffbe537865e2049e11ef4a8d7c97e8421c9d25d6003ce8f5e10473c1", size = 729767 }, - { url = "https://files.pythonhosted.org/packages/1d/4b/b13ae345e6092721dbfcbadda1aaa745fb33c601dfe24d28685d264b82b5/scouter_ml-0.2.4rc3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7e69bdf7e414ad1cedc14e20e06078d3e245190b02f3d29599f400197c477f", size = 663238 }, - { url = "https://files.pythonhosted.org/packages/0e/a7/25609c34cb4fb9365d9edfe4ce6570a8e6344a08f1820edf8f6cdf2159d4/scouter_ml-0.2.4rc3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ba74f38ba2bfe3c95d6389a9a06b1ecec9150e2312d62a2ed76cdafc497daf7", size = 670296 }, - { url = "https://files.pythonhosted.org/packages/e0/02/2aa34f9951519ffb58f02c121abea6a58eaf4784efd6ce505027c3427169/scouter_ml-0.2.4rc3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f512b14442a17c365484a6a9edb82984b806d246405920767ff3128854174bad", size = 778605 }, - { url = "https://files.pythonhosted.org/packages/57/59/f9aa1f6a3a0e7c2d45a79f47041fdf049a9509bc2294d901c9261ab14b85/scouter_ml-0.2.4rc3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f83aa59a9cb2047a4010c77ae2b09b8080ebfaaa672c39892ddc408c5024f72c", size = 1122263 }, - { url = "https://files.pythonhosted.org/packages/d6/59/57d92b920153448b0780d4b1ef19a9372e3257de98fc7386e94bcbae796d/scouter_ml-0.2.4rc3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a8e72b11905582a3e2923d5174b6f2399478a9936dd4cddd762714d4d56b411", size = 724228 }, - { url = "https://files.pythonhosted.org/packages/f4/91/a9ad87991994b8d242f77e5978f42614c48d75395054c180bdd98691fb1b/scouter_ml-0.2.4rc3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:29acaa1b0df281298e5d28fba9f601a707e9ec96ed9fb4f1e4f912abdc351c5f", size = 842568 }, - { url = "https://files.pythonhosted.org/packages/fb/27/110a301c30a63a1b3abf2363e019ebbd29e0e63ad68401f9ea56db33f532/scouter_ml-0.2.4rc3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36caa5fe55ca65479347dc9a321502b72e40185dbca5edfa320a3b83b197a8d1", size = 895838 }, - { url = "https://files.pythonhosted.org/packages/cf/40/2e084ad1adc574799e4ebc7e13207e8b376e9f6847b080a4a66de81662c3/scouter_ml-0.2.4rc3-cp312-none-win32.whl", hash = "sha256:4c5b2d9e0f3e7ea64d4e5e252fb39e7c65cc2726a06a66f2635acd0d6996f871", size = 603787 }, - { url = "https://files.pythonhosted.org/packages/d9/6b/70c43533e2f89a6991eaebe3ec960ec9951ba0fb16c40316064f94b02b1c/scouter_ml-0.2.4rc3-cp312-none-win_amd64.whl", hash = "sha256:6cc9369f63c1a3d1751ba057e1d8ff9d4bdf161c1252ebc35f12287853e4de11", size = 659533 }, - { url = "https://files.pythonhosted.org/packages/df/75/bbcdf5d677ab22cd995becdb0284c5222ea3675f0e8a3d4bc05bac06ca5d/scouter_ml-0.2.4rc3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:150b54a7aed1dbdebf983c1db3d181cbff31dd59be0da1fbeeb9110023125eb2", size = 684740 }, - { url = "https://files.pythonhosted.org/packages/12/e5/33087ff97f548b1076a9a1c9bf88ae7ebad6c0333429cb25b8bbb88f7f39/scouter_ml-0.2.4rc3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a39c1cb3305d17255496c15460812dd95617f6e111c60f8d4af17ac5e4c81d0", size = 640449 }, - { url = "https://files.pythonhosted.org/packages/4a/cb/85033fcb0bbbe4b097cfad304bce88fb082ae24c1c5ef59762ecb36e6a6b/scouter_ml-0.2.4rc3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:01617b901522cb14342b4295e7c1d1cb0e496e199966a70ae9f293f8bcd7c1e7", size = 734150 }, - { url = "https://files.pythonhosted.org/packages/c4/cb/a73883f3f482a4e818c674ae455e1b30eaa8b545763b0f335f9fc9b59844/scouter_ml-0.2.4rc3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b4afe5efeb7b0c621df8da1e0aa67e9186ae55efc67f73956623198f003df0e", size = 666976 }, - { url = "https://files.pythonhosted.org/packages/e1/df/6e8cf1d13617f606736e188a10b15f37890c152b3c716f70f3dbde136a3c/scouter_ml-0.2.4rc3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c93f7d7849f0b645a4c48df96fd552a2b4788794affbbd4e41b3bd4bd8861979", size = 676462 }, - { url = "https://files.pythonhosted.org/packages/86/09/943309073bfbec606818b598978a5ddf3cf8ffc008a569135b2bf35040e8/scouter_ml-0.2.4rc3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:586803d708ab170d64164e546fbac3e85da201034472bf9b6eb3c5c5f886ab6f", size = 784912 }, - { url = "https://files.pythonhosted.org/packages/1c/24/e81bad0d0e69e749f0f74c78cf98fc0157390c971f6e15351adad764191b/scouter_ml-0.2.4rc3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f797be531655bd65af2fbb7fb076de3e42273204e7921f3f5e1c8a79a6f9915e", size = 1121673 }, - { url = "https://files.pythonhosted.org/packages/be/ed/8a1968c3377e479ed764fd888c1f9e1a253c1f37a34a4d174ccec602bfd2/scouter_ml-0.2.4rc3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:086cd95fd66f71038c6e4c0b7667f2363abe3792015c31c542fb46377ac33be9", size = 728972 }, - { url = "https://files.pythonhosted.org/packages/a9/1c/ef70f5329994daa137fa4c88483e05d32341289f35ec599e38cb698c7dcf/scouter_ml-0.2.4rc3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bd860f49f5cc8f9a052c7eb596f29f18241551cf010d05b77b61dc7604c3f47d", size = 846533 }, - { url = "https://files.pythonhosted.org/packages/3f/74/0f0cbe2d96cb2f5eb0cc2c85caf91070d595819179740303bf58a97a149b/scouter_ml-0.2.4rc3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c49cbf58886057b8568a247b28d16864e120082756df0508f588e0d7a5c2b729", size = 900330 }, - { url = "https://files.pythonhosted.org/packages/90/b4/ac9682c136774025dfd6b3d8c82e9b759702e5a209d1148ddc5a5f83b7a0/scouter_ml-0.2.4rc3-cp39-none-win32.whl", hash = "sha256:ae85fc2fad54190e7d33d9ce882f547713d0e04e88b0027338b29ae13e8548c5", size = 610348 }, - { url = "https://files.pythonhosted.org/packages/6b/d5/aa4617f081ef827ffdc074c5f456e99612ad6820fd05cfa09038e2d2f76f/scouter_ml-0.2.4rc3-cp39-none-win_amd64.whl", hash = "sha256:c0f2be718e1331f66edb818648c7cf550add876bddf80b2c9f0c2fe71d950b76", size = 667650 }, - { url = "https://files.pythonhosted.org/packages/3b/01/9ef39205c7caca3a46af59321072fdc5a23fcee5e85816064861d136fca9/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ce93b9aa9a43994512432caaa9fdf8675fd4c11d8db1057c11a344996cc2f583", size = 684035 }, - { url = "https://files.pythonhosted.org/packages/b9/1f/214274f1566a58ed84aebab7319cae8295903d0ccedb1548e86ab7d45d7c/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:bbf7f4b269e3470ddc0ba226917450a8962a907ba6fb25974408aff9b4abcce3", size = 639794 }, - { url = "https://files.pythonhosted.org/packages/9c/0b/40c09e93d036e6277cdb3037bf530cde3ab19cf3ee6e603f16c431fffcd0/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5c708381c9066cd689cb63a7f426951856b5933376bc1800f840cfde8a17f7e6", size = 733022 }, - { url = "https://files.pythonhosted.org/packages/61/6a/37d4e0906bcfae528bc84bbc58aee3660612a6416ef1f360622ece3335da/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d3ab64041a15c04533c0b6aeca78e112b86cba44d99cdba96b9462346b139ce", size = 666602 }, - { url = "https://files.pythonhosted.org/packages/aa/19/217930cf8fd4ed69dc936947a9d9b8067ba4fbddcae39694380ee161bfc6/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:711c2bec7af5bcbd0670d622e977eed79a4fb842a60149818f493356e56059f0", size = 675674 }, - { url = "https://files.pythonhosted.org/packages/4c/7e/101c02abfc647abda8ac9d57f74108231913bf666d09271e5ed125287ade/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:989944586181d3d3050f56daa6a922d4cc716b1f614eaabb8fa6844deba75b19", size = 784051 }, - { url = "https://files.pythonhosted.org/packages/d6/a2/afcaae0f8a5724d8b38f66dd157c1c1ebbbf3e258f10842cfa41208fe23b/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b21e5debd5462446b4a6c2aa881c6f9a9f19d09e7b8a6fe7fe884b08e34bab24", size = 1109004 }, - { url = "https://files.pythonhosted.org/packages/31/f0/9b30df712ace74270d583138e416be20ce6ed331dda9295de5f49065aadc/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0330be7e793aa5ede18c69708b973d8580fc62e9a8f4afde1e4e6bb7326d9ee", size = 727973 }, - { url = "https://files.pythonhosted.org/packages/34/b8/21f897e62be88f323c5a73362c2e85bc427c2aa69490624f08aec4667c4d/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5b12aed85f60c5cfaf05294214a664d2a75a5877b7de98321352d5b44684fc21", size = 845845 }, - { url = "https://files.pythonhosted.org/packages/68/96/2d94654a1848bb65255b99524d9bf286e6aca41d02490b266a754de64c1b/scouter_ml-0.2.4rc3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7aa2b837795b6d9434ceec7fc2e3a6d3701bedf89181f123b835127ada98b1a5", size = 899757 }, - { url = "https://files.pythonhosted.org/packages/00/1e/b68967ca46989e375e2a9a608689f6e23090db8a1413362b71da7e96cf0b/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a7b6c3871f55668b623669017d99bc3206025ac798c2d6bb81ef21c04284ee57", size = 684890 }, - { url = "https://files.pythonhosted.org/packages/08/f3/e64ea3f0fb3739579baee8bcb55b850025deafd2a40c9b948a35857cfd25/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:6422c31ebbfe317e29406beb93752477f80ccf732548e33a48ef26170dd62b2b", size = 640553 }, - { url = "https://files.pythonhosted.org/packages/3c/18/fb2e3a01f0a45826c39ea5bab2cd294dcabe8835a7ce9fd2bc3110f98479/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:adb732485ea669117ee73fb59291db322725bc2a8612c83448ec1f885f5b88e5", size = 733954 }, - { url = "https://files.pythonhosted.org/packages/36/b0/0f901f9cd6f941047f96210c7ebc53b83da291a7e5b0fe1666b509b7d230/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aecdc0d8c8a6ef22b546521d6bde94a0ab111c555bc6b0a879d71d5103b6aa89", size = 667121 }, - { url = "https://files.pythonhosted.org/packages/ee/d0/7af98fb9e34b80bb0ac9c4e8491aa8c81799f89c0a529b708277e21b6f97/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e21def19e8d4181e9b6cef73cc795d1befb45ed04d9aaaef851ba26c351499", size = 676386 }, - { url = "https://files.pythonhosted.org/packages/d1/13/c664e0f814dc936e607643344c03e40e7d48681d8a48462886e8e9475d09/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42e34a9f67f95243237469050285ff885d4a5d5f38cf68f0d71b8de78bd5096e", size = 785145 }, - { url = "https://files.pythonhosted.org/packages/12/5c/4406caaa6a0d302033bb792febbb5731a6bb6156460e35f50122db4ba551/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bfc7fb53c2d783e9b1462e47c044314279ce5b587d15a04a5172fb173e4209b", size = 1111089 }, - { url = "https://files.pythonhosted.org/packages/74/94/d56a9984f0ddba6ba8f35a0dcbcf640406e5880722daf306b9d04a1c98e8/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d5acedf2facb220e15203cd2737176fca39716feb82a98f0484a816cab01398", size = 728609 }, - { url = "https://files.pythonhosted.org/packages/c5/4b/d98d0818559f654dbb1b526f2f7cc76d677b97212d3c81c319e5b2e8b462/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4f60514513a09eb81fe81e9d7bb080c4072acf5e8488009e56b3c5fff7b846a8", size = 846833 }, - { url = "https://files.pythonhosted.org/packages/a9/c2/cd32bcb209a79928e8539454ec362a7dd9503169a32af701b17afc96be55/scouter_ml-0.2.4rc3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c3d3f13af9b1b3e91d1d32d909499c8cbf5cca250f1cad7b8a03a8d634c7067f", size = 900506 }, + { url = "https://files.pythonhosted.org/packages/3a/dc/a3b7bf32d70a84a7fb14419e8c5dc4b39b2a5624f23253098fe673800741/scouter_ml-0.3.0rc3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b2eceb5174a0d35508ae2d6ac419e0c331a74a7fe1856a598d37abcfcdeb146e", size = 834301 }, + { url = "https://files.pythonhosted.org/packages/a5/21/342feebd21526b85ab76fcbae3dab65a53fabe55f1089d25b14fb1ccfc87/scouter_ml-0.3.0rc3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e37630189517b6a711e7c73788d2cc38d73b6641468724a9afce773d2950010", size = 760545 }, + { url = "https://files.pythonhosted.org/packages/21/af/aa2b2d296c9930a931612a2d82652a5b1561d1fb60148ef5c7fc999baf94/scouter_ml-0.3.0rc3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1ffd90af5f37b25c5c1324c43f4f0b16a9c6aaef6d0969b4c6711e18f41e9b3a", size = 869121 }, + { url = "https://files.pythonhosted.org/packages/0d/ae/600903c7eed37af74ecf77afd02b5274d53e7ce2327b5358af60c0c353e4/scouter_ml-0.3.0rc3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a14003bf8e9b77387b5ac8069702570d3fc0c9c803b019021cc867fdeced4ad", size = 784166 }, + { url = "https://files.pythonhosted.org/packages/78/6c/43cc018096356e9a9f2cbeefab3dfd917cecc43c3915a36b5b858465f875/scouter_ml-0.3.0rc3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5bdd9c944f7d53f94c0501043a31c4e0e2ae0ebcff93f41836dccaf95c34c16", size = 797571 }, + { url = "https://files.pythonhosted.org/packages/e4/67/c3d1e6505bb8703bd42139f8126e859d8cae64f3812ddbdf488a8ef7bd48/scouter_ml-0.3.0rc3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c074685ddede1657333834878852b3d94d6a4e4a75fc61d2b8ca9e5b488da2a1", size = 933996 }, + { url = "https://files.pythonhosted.org/packages/b9/a6/0e3bd997de57087df9bfca6c017013e5c77f4ca828765e91d742b32b6651/scouter_ml-0.3.0rc3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48abdacf1058244dc3d190fd6fd6d1b891c278d1e97f3dbf00842ea51a5f718f", size = 1441573 }, + { url = "https://files.pythonhosted.org/packages/6d/75/cda43e1903b8ab98fb534902ef0c02506dbc42b613612d993013a5d1cc16/scouter_ml-0.3.0rc3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f843902a13771025f9fdb489eddb9c5e3e614bb413bc17cb63570eb0044fb6cb", size = 878284 }, + { url = "https://files.pythonhosted.org/packages/11/1a/cd4625d462d0264e6aa3bd5d175788c3494ed7db26624a413b80d91de97f/scouter_ml-0.3.0rc3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3b60443de35d1181660b6cb49b9a3dff025c5d8d4e43a4a0f90e1102c0ad8013", size = 954904 }, + { url = "https://files.pythonhosted.org/packages/78/3e/dd169102441d45ac764e5df50f71908a6016a5fbdcbdb71b20f4f182d066/scouter_ml-0.3.0rc3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0de51dce5b57169eb55392dd000d03281b982369c57869e3a2f4bb018685301b", size = 1036235 }, + { url = "https://files.pythonhosted.org/packages/72/cb/cda2e3bd82fe8c68218600fceb6a18211da57ffb4daeaa00a432a23ff9e1/scouter_ml-0.3.0rc3-cp310-none-win32.whl", hash = "sha256:c29dc68538c67ebf7668d75dd0e2ef58ea5c1ea6f628cef5e8942873a3b8b0a7", size = 741389 }, + { url = "https://files.pythonhosted.org/packages/19/80/44bd9ff9c41648a5760190eb939360388dd806b9b22b542cb2e416786815/scouter_ml-0.3.0rc3-cp310-none-win_amd64.whl", hash = "sha256:cdc5eb826612d2cb5480795e7bf078c96d8c890e8e242039dca1197ba9bfc320", size = 830307 }, + { url = "https://files.pythonhosted.org/packages/1f/59/0a6eed64a34d578011f03963d12c7e1d3ec48e7b3d845a8886011729e133/scouter_ml-0.3.0rc3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:16f54041b5cbbdf0596eed19773bae8109ea41589ac50722a13e9fc37fb73c12", size = 834280 }, + { url = "https://files.pythonhosted.org/packages/aa/32/3ec3c055a71850497f4dec348518b1892d00bb7c5013b347fe82796bb6d1/scouter_ml-0.3.0rc3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fc1329c32181640c9cacd7c74d766b5693612d7595211ac9afd6781b9ef72851", size = 760600 }, + { url = "https://files.pythonhosted.org/packages/46/0f/32ed575586d30bd1a99f6fa5d01b6765aa3db2c845251a536c40dc797bd3/scouter_ml-0.3.0rc3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:df3569790deda70272b52ba844eecf21b429af1bc49cdbf452a109f5b5dccebf", size = 869290 }, + { url = "https://files.pythonhosted.org/packages/a1/0f/f5b3c3a71a27563f2fb827b3b95f5554a654d41a3879eb19191fc1e1907e/scouter_ml-0.3.0rc3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe7b5c7e825598bedf23a075b046481a6fcde582511962dd4f0d9759d6963a2", size = 784250 }, + { url = "https://files.pythonhosted.org/packages/b3/92/2e45b1dc804114a850d9a26d788dc46134c7367caea923dbde7687dbe2c7/scouter_ml-0.3.0rc3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ae4289341198f21bc2b4c86fee6f596ab5817d03109512d8ecde889f973cd4a", size = 797699 }, + { url = "https://files.pythonhosted.org/packages/c6/e1/affa775b5f807651fcaed51832b15e11126e3dbfe0e3696a17410acaab4c/scouter_ml-0.3.0rc3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c0548e2d0ba98ec65c7ee3a8b927b8f844a35d74f5a03a0c6ca249a52ccb69", size = 933915 }, + { url = "https://files.pythonhosted.org/packages/db/3c/5617fe71b74e7d8b2f217daf7cb7078d52efa58799f4253ebf78b844ec82/scouter_ml-0.3.0rc3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f069b0c8e01cd47f7005f10eb8e8fe4421672bea9b52ee48fb2088bb41ed2730", size = 1441485 }, + { url = "https://files.pythonhosted.org/packages/84/fe/c1ad62652721eadf343e5ff561cc625785486d70c0795743ed6e886ab5a8/scouter_ml-0.3.0rc3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12216cbf7ce90b2d0a114172255ad5ef986f64448360cd5261f8fca7f95febaf", size = 878389 }, + { url = "https://files.pythonhosted.org/packages/0b/2f/a1d7408e81e8d894089f99051839bc48579a5dc2c465e1019815ad19a516/scouter_ml-0.3.0rc3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b753fdd2a18a9b6a3b608d3a1fe1cfbb2790f3c5956f666687819a5e5521e2cd", size = 955047 }, + { url = "https://files.pythonhosted.org/packages/d8/92/e3ef1a8ccb06e1434e2767af8e4feccf5c5dcc2d59cb20d312de65261e09/scouter_ml-0.3.0rc3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3fa5e2f6a01b4af2ab18cd76e48f5c2730782059eb360d1243287d503f82ab4a", size = 1036305 }, + { url = "https://files.pythonhosted.org/packages/8d/b9/f4683d7dfb2044a5eb3e71c2749521f2cd82ba33a833bcc31f33c8d1cb98/scouter_ml-0.3.0rc3-cp311-none-win32.whl", hash = "sha256:4bb20182b3d1d6b81d85e5e18dff8753eb43f46dbf27ea59abe6e668ceb00590", size = 741281 }, + { url = "https://files.pythonhosted.org/packages/ec/af/3ebd30869bba4fded76bd17e68abfbd630e3338648598f11341f3a466cf7/scouter_ml-0.3.0rc3-cp311-none-win_amd64.whl", hash = "sha256:d95b70dca03851a03b4edc806e165a349a056fb2dfcd08333f7b45e97d04c295", size = 830281 }, + { url = "https://files.pythonhosted.org/packages/ef/cb/cca7c6ca773e1ee1feb0dbd2ab1a179a445ef1a7ca108562b9622fc5e7ba/scouter_ml-0.3.0rc3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1d2cf8d77f26ee1961eff96ae14ef1457b7855085ea4dd2dbf5b35f48fe8ce47", size = 834926 }, + { url = "https://files.pythonhosted.org/packages/d6/b4/4d7ba5dbcd9f1f2b0b86e90165f2243a3ec7418e1c99fd1bb835cfa5ed19/scouter_ml-0.3.0rc3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:74d7c0861eca4cc5bc063106c9b9d372c01ca7a064441d20f5b54161ef23e4a5", size = 761167 }, + { url = "https://files.pythonhosted.org/packages/cc/73/b1223e48bb2d4fe5a2b8f53ebe72ac609be48a61b8250b0b709987ea845a/scouter_ml-0.3.0rc3-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1613a01c5defc44542cb2de3083ae9257f8aa6e9bce52884f1cae1f9a05a35b8", size = 869832 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/f1f100060dd03f0c02cdf2b70162c46095639cd9f606c723ce3f23adbb13/scouter_ml-0.3.0rc3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c71adcf46b2af111570f84e8684d0ade381228f2d5b6f794a095902bac2322b", size = 784115 }, + { url = "https://files.pythonhosted.org/packages/f8/4e/b8c7c62494349459cda6d1cc2a9680c691ea98fab11441b598e894afca32/scouter_ml-0.3.0rc3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9a1a714f9954328a82f1546777dd9a61695715e761a06a9093532fafe0b66f2", size = 797492 }, + { url = "https://files.pythonhosted.org/packages/58/84/4f4eb85154ff6e6ace92a49eca396287df1543819f761fe2bd9d6cba5ba1/scouter_ml-0.3.0rc3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d8650e33b3b762bf2c2db11880b47d404170520793bcddada934d4f180eb6ab", size = 931843 }, + { url = "https://files.pythonhosted.org/packages/50/be/c2167b07acddc6afe996e017d0e2f2936f55a6f24c34486f79072c758c42/scouter_ml-0.3.0rc3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66de23a7c1b41c454e84a509aa13a9af986630ed80c4a3cd5d3ac01d318c072c", size = 1413190 }, + { url = "https://files.pythonhosted.org/packages/30/7f/3e7b8e89bc2177af47325a890663fe6a3ed45f078cb25f59104cf15c6d91/scouter_ml-0.3.0rc3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f6da5b57f5309c3e1c353a2bcea31bb12e0f836a9fc5e2538ebd538bebf6d80", size = 880471 }, + { url = "https://files.pythonhosted.org/packages/ec/1e/a1dc768f5ce2fe2b91a1f844a29acc129b4be6bd6d1dec5a369fd1149dad/scouter_ml-0.3.0rc3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4a42b3eb83fa0ba9920d862a7be553329e757e14416102a4fd871b86e972e257", size = 949509 }, + { url = "https://files.pythonhosted.org/packages/c6/fc/5c549fdf4ba190100c5fa6323406c0a2e1b065009fbfb014f365e1244c0d/scouter_ml-0.3.0rc3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8337b1e3f04656d5cf9b695ba326d83bf114e7a4feff4bee71b02b85c326587", size = 1032024 }, + { url = "https://files.pythonhosted.org/packages/ce/c9/95c8189402aacf664cded5d03b5f2a0843c0b310ef197a9d3904585d1659/scouter_ml-0.3.0rc3-cp312-none-win32.whl", hash = "sha256:ba8c9ff6b8aa639f5dabb2ab84c25a6b9f8ca7b3b6f150d7a2fd2a3f3830ca07", size = 737579 }, + { url = "https://files.pythonhosted.org/packages/0e/9d/5ee99f820920d43943eab6582917074820d9d2aa23993856f24477ac7d26/scouter_ml-0.3.0rc3-cp312-none-win_amd64.whl", hash = "sha256:61c95e514ff50f90e646ed8dc1b253f1a372f79bece8cff761c53dae946e1fb6", size = 829822 }, + { url = "https://files.pythonhosted.org/packages/dd/51/05e9915ba711a3871ab561a3aa50c2e2027b15b125a5ebd686a4fac79fab/scouter_ml-0.3.0rc3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e147bab2028f74c22489fd3b118d28916ab5a6067b9d21da170329c2048419ec", size = 834451 }, + { url = "https://files.pythonhosted.org/packages/d3/28/078c97b97025c50dbc6c058d0c3bac8fbc7a307d061405d662839875ba82/scouter_ml-0.3.0rc3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81af6d76d9d3a9bf2fd809b9442e101fab671c37ddf0dcb00b655211920f335c", size = 760772 }, + { url = "https://files.pythonhosted.org/packages/cb/1d/86cff0b5bcd37c4fa8ada2d2d0bb4628ded3c15597a28ec60841da2e473e/scouter_ml-0.3.0rc3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:46fd137a62047c127dd71778bee05187553a7b8cb0d42fc693e43ba55d5d6db9", size = 869639 }, + { url = "https://files.pythonhosted.org/packages/90/58/b6324e6488d90863f127b3b2c73d2d8ec4541d46e2b8624e1cae312b2da1/scouter_ml-0.3.0rc3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfd8799e4d39c44820446e252d76a9f248986d6400aa6d23ae258db078201b11", size = 784481 }, + { url = "https://files.pythonhosted.org/packages/76/54/22e9fbba8bd6a604286d923893c003b6ece0cccd93da7d0dc116637923d7/scouter_ml-0.3.0rc3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b17e1c4e26182070068a9e1142d45772ac421f50611a206146ba16458e259dcf", size = 797861 }, + { url = "https://files.pythonhosted.org/packages/b9/4f/c772a5dc1a5079c733a3114ba83e93a8716ca9123c1c49e547fea16241f1/scouter_ml-0.3.0rc3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d2451bf1f0eff6b3ab2cff6ac8920f260993c37c22f6a069266a82c0c13126", size = 934238 }, + { url = "https://files.pythonhosted.org/packages/24/56/3a16f946515f7fbe66071fd57aa76c220438837fe02c678de63c047db19b/scouter_ml-0.3.0rc3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf816a62c320562ab9d653546628c2d1abac9b19d2b7a515a5aa175348d57bb", size = 1442529 }, + { url = "https://files.pythonhosted.org/packages/f7/a0/f5e3f45a0c1fd1812ee3dc0d8283c45bff7247d2f5ec6332e8b4c6a892c4/scouter_ml-0.3.0rc3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4315046738784f323bb8c28a7653d4e1881ec27a4610bf16f76ef2f2a9c6bfe", size = 878590 }, + { url = "https://files.pythonhosted.org/packages/81/48/17768b1920b229d90a6022ceff22d972a60c83693fde2071ffcc347e0886/scouter_ml-0.3.0rc3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6ea7203e0e73c8e8aab6c9247d14be30971fc187b2b5950f7c88a2753393110d", size = 955015 }, + { url = "https://files.pythonhosted.org/packages/87/10/cc7d084f689b4735f8ba3ef56c089dbaae70017e6f105bb067250fd8f9c9/scouter_ml-0.3.0rc3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:486def564a0b4697725cbd746c54fdc65e38bcbc69a6681450e8c89590c4845b", size = 1036669 }, + { url = "https://files.pythonhosted.org/packages/2c/af/72170cb0df741eaa15cb95f0778c889777a894b682bce1c9467052f0928f/scouter_ml-0.3.0rc3-cp39-none-win32.whl", hash = "sha256:6fecd215bf05f2601b3805453d63fc66fdcba7501c17d29cd93ff55c47279050", size = 741877 }, + { url = "https://files.pythonhosted.org/packages/ed/db/979d00583827fa486772135e1d746f859716315f274acd048949b815665c/scouter_ml-0.3.0rc3-cp39-none-win_amd64.whl", hash = "sha256:0d57573361bda9456d8cb8e06cea8bc9c8be0213c52f1b88e65d2444ed0bc586", size = 830494 }, + { url = "https://files.pythonhosted.org/packages/2f/95/99148d8f32e0bbb168e6f62a864a467b29be18f3404a569135716f14d275/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d4d6fa734fddb1da31c407525d417dd58a3dfc5ec1d1d29932353570a4d6ac1c", size = 833258 }, + { url = "https://files.pythonhosted.org/packages/54/14/9b89ab7a06a2697dd5eee6c6d10d13660aa410116b02a3ae2e851e5f07fd/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cd9c0091e73d1e201d589413185dc453459653134031117b1553a0c65da27f7d", size = 759315 }, + { url = "https://files.pythonhosted.org/packages/2c/a5/89f38982aa5791756dde39987546df7d38dfef4834fc6dcef3c51ab6c702/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b83f3aab903877a72fecf8e7230f205a18e4c2042a4af60c00434552b9762bac", size = 867756 }, + { url = "https://files.pythonhosted.org/packages/0e/79/12c733d2a12b03a83834c060d3dbc18cc93716fa47c61635ff9cca098197/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:568cd170142d208bf0e77f4f78ed0d84bd3ad746226832c25b36468b00642282", size = 783023 }, + { url = "https://files.pythonhosted.org/packages/41/86/b95b2a67c6f9c9790b78070d78a125dfad8ebf292b47ddc1bc87cafd2cb3/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6538e6690b4128c38aae6bffddecda0ea790bd77092c3599d6d402418f1527df", size = 796195 }, + { url = "https://files.pythonhosted.org/packages/8a/76/d51ff119e64b2e72a0eadc8ffb2c94561b8b583bc37d9589a71b0d57c9f3/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a30e64b87acec97973da36f8e3afbeea83a7cfbf540cb83a04fe12796e45fe12", size = 932664 }, + { url = "https://files.pythonhosted.org/packages/6d/1b/f3d6e5ba59538a8c3af5069fda73ad92a9005aab983182148066dd09f150/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bcdc3459f8ee416290ec0e01c59fe69cc7e7df01b47c6b2aa0938c77d6fb045", size = 1429353 }, + { url = "https://files.pythonhosted.org/packages/30/fb/92c4cd1757bca128e102f71ce23944c5fa8aca26153be5f7c88baf2561a8/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21700f163fbcc681429b345b0fb94b0dedb93248be34e11f99680d14e1bc8f45", size = 877027 }, + { url = "https://files.pythonhosted.org/packages/e0/98/ac5d3baafc4925a976ec5d8fbca73f8578f88d1539121fea37003f3af864/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:22f28440919450cf88db7ce3e8ac82deb0deb84bda8c0286fba18c63e9277d97", size = 954388 }, + { url = "https://files.pythonhosted.org/packages/e3/1e/465266b840d6cd59319562ac4a5224c146a99d0eac43614d97d17d984dbf/scouter_ml-0.3.0rc3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:8111981a4e451f2fc3b1da55f2de6e7ad8646458aeb60c24f73f88d12b006d42", size = 1035055 }, + { url = "https://files.pythonhosted.org/packages/da/44/0754619418259e4a1b1c5e25964a4d573f15086de9033ab6681febfa70bc/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:afc62ee93af81747093cf41340d7e1c925b884246f1a15b2b024a8d974db39af", size = 833668 }, + { url = "https://files.pythonhosted.org/packages/e9/dd/1f527b7145c28e0b776f9c34370ace1a0b0f3b44aeb19ed80b72499a275b/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ed6ef5eb7df6736e9328efa3de5d0b65449ff9676c44837765f65aa183479aeb", size = 759608 }, + { url = "https://files.pythonhosted.org/packages/ed/1c/cb43e788de9af8a1ed3bfa497a9293f9cfe8a9ecb890a125fb27c962d0e9/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8a7cdb0d6108d05dbbe23650b81f51e006f0c52ca6c7693608069c4b15b9fb87", size = 868238 }, + { url = "https://files.pythonhosted.org/packages/ce/0b/9d586f21c7ecf852925b5f2bb63ffc0218373a0f8fc12f0ae39f20cd7e34/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9e97bd9ebae5963f442fa48122363ddec86084ef1b3e3e02990bfec1d2fcc23", size = 783420 }, + { url = "https://files.pythonhosted.org/packages/ef/e4/03d685084643b6b314fe64a69890f1c1e374253004de87bf3a020aa1a563/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:189bfc358c2d63cbbf46ca0c92a4960361ec44080cc44d0b1a51ba9d9538bbb3", size = 796520 }, + { url = "https://files.pythonhosted.org/packages/05/14/ea8394164098b57fa49160bc103f4d061863985079df9c6df393869c8090/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:18e3a79625fb409198651064b71f0d9d6797bc55ad1a7730aab386a32a0374e8", size = 933160 }, + { url = "https://files.pythonhosted.org/packages/23/3c/730dac928ef59a9896d3c4ca04da1bb95f8378e936c9b7bfddd9ae4dab5b/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6df21dc5b4f18d47611c9ed891d157d2d0e11423bbce2b17db8189aa1929cc1e", size = 1430536 }, + { url = "https://files.pythonhosted.org/packages/d2/73/619b76d1504621953d1d8b5032183a9bf44ff0fa0566dbbb8559ad7126dd/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:feb64341a429c9f1429852fbbb1121e3c76f91bac6c36283d583bd1efed94fb3", size = 877489 }, + { url = "https://files.pythonhosted.org/packages/6b/7c/45d499750969413f5f274536e4bff97b90225e9917a3472902588cac8941/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5b0290776b1342ed36553efd4a5c148566fb7e9c5eda715474eddac407ed5540", size = 954707 }, + { url = "https://files.pythonhosted.org/packages/13/fe/bdf21b251310ca2bda64762e4452f1faa3a27c911ca637168164ef9157b5/scouter_ml-0.3.0rc3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3ac1d4032f4ba8335cd00e106a1ad22c3045898050bfce410aab477178f29b36", size = 1035539 }, ] [[package]]