From e191414a0cd9aca3d144dea51afaf77a81a9e210 Mon Sep 17 00:00:00 2001 From: itwastony Date: Mon, 15 Dec 2025 22:42:54 +0300 Subject: [PATCH 1/8] feat(hw3): Implement experiment tracking with MLflow and add report --- reports/HW3_REPORT.md | 87 +++++++++++++++ src/models/run_experiments.py | 198 +++++++++++++++++++++++++++++++++ src/models/train_model.py | 98 ++++++++++------ src/utils/get_best_run.py | 44 ++++++++ src/utils/mlflow_decorators.py | 60 ++++++++++ 5 files changed, 450 insertions(+), 37 deletions(-) create mode 100644 reports/HW3_REPORT.md create mode 100644 src/models/run_experiments.py create mode 100644 src/utils/get_best_run.py create mode 100644 src/utils/mlflow_decorators.py diff --git a/reports/HW3_REPORT.md b/reports/HW3_REPORT.md new file mode 100644 index 0000000..b1d9d3d --- /dev/null +++ b/reports/HW3_REPORT.md @@ -0,0 +1,87 @@ +# ДЗ 3: Отчет о трекинге экспериментов + +## 1. Выбор инструмента и настройка + +**Выбранный инструмент:** MLflow + +Я выбрал MLflow из-за его широкого распространения, простоты настройки и отличной интеграции с Python и Scikit-learn. + +### Детали настройки +- **Установка:** Добавил `mlflow` в `pyproject.toml` и установил через Poetry. +- **Хранилище:** Настроил использование локального файлового хранилища (`./mlruns`) для простоты в текущем окружении разработки. + ```python + mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) + ``` +- **Управление экспериментами:** Создал эксперименты с именами `wine_quality_experiment` (для одиночных запусков) и `wine_quality_multimodel_v1` (для сравнительного анализа). + +## 2. Интеграция с кодом + +Я интегрировал MLflow в проект, создав переиспользуемые утилиты и декораторы для автоматического логирования. + +### Утилиты (`src/utils/mlflow_decorators.py`) +Я реализовал декоратор `@log_experiment`, который берет на себя: +- Запуск MLflow run. +- Установку имени эксперимента. +- Автоматическое логирование времени выполнения. +- Перехват и логирование исключений. +- Логирование параметров и метрик через вспомогательные функции. + +### Пример использования декоратора +```python +@log_experiment(experiment_name="wine_quality_multimodel_v1") +def run_experiment(self, model_name: str, model_class: Any, params: Dict[str, Any]): + # ... логика ... + mlflow.log_param("model_type", model_name) + # ... обучение ... + mlflow.sklearn.log_model(model, "model") +``` + +### Рефакторинг +Оригинальный скрипт `train_model.py` был переписан с использованием этих декораторов, что обеспечило единообразное логирование как для ручных запусков обучения, так и для систематических экспериментов. + +## 3. Проведенные эксперименты + +Я создал скрипт `src/models/run_experiments.py` для систематического запуска экспериментов с различными алгоритмами и конфигурациями гиперпараметров. + +### Протестированные алгоритмы +1. **Random Forest Classifier** (Базовая модель) +2. **Gradient Boosting Classifier** +3. **Logistic Regression** +4. **Support Vector Machine (SVM)** +5. **Decision Tree** +6. **K-Nearest Neighbors (KNN)** + +Всего экспериментов: 18 + +### Сводка результатов +Сравнение моделей проводилось по метрикам **F1 Score** (weighted) и **Accuracy**. + +| Тип модели | Параметры | Accuracy | F1 Score | Precision | Recall | +|--------------------|----------------------------------------------|----------|----------|-----------|--------| +| **GradientBoosting** | n_estimators=100, learning_rate=0.2 | **0.6625** | **0.6543** | 0.6520 | 0.6625 | +| RandomForest | n_estimators=50, min_samples_split=5 | 0.6656 | 0.6470 | 0.6337 | 0.6656 | +| RandomForest | n_estimators=200, max_depth=None | 0.6562 | 0.6390 | 0.6287 | 0.6562 | +| RandomForest | n_estimators=100, max_depth=10 | 0.6437 | 0.6240 | 0.6108 | 0.6437 | +| GradientBoosting | n_estimators=50, learning_rate=0.1 | 0.6031 | 0.5923 | 0.5952 | 0.6031 | + +*Примечание: Таблица отсортирована по убыванию F1 Score.* + +### Наблюдения +- **Gradient Boosting** с более высоким `learning_rate` (0.2) показал лучший результат по F1 Score (0.654), немного превзойдя модели Random Forest по балансу метрик, хотя Random Forest показал чуть более высокую "сырую" точность (Accuracy) в одной из конфигураций. +- **Logistic Regression** и **SVM** показали результаты значительно хуже (F1 ~0.51-0.54), что говорит о нелинейных зависимостях в данных, которые лучше улавливаются древовидными моделями. +- **KNN** показал худший результат (F1 ~0.43). + +## 4. Воспроизводимость +- Все эксперименты версионируются через Git и DVC. +- Скрипт `src/models/run_experiments.py` позволяет перезапустить весь набор экспериментов. +- MLflow отслеживает точные параметры, использованные для каждого запуска. + +## 5. Визуализации +(Симуляция скриншота интерфейса MLflow) +Интерфейс MLflow (`mlflow ui`) позволяет сравнивать эти запуски. График Parallel Coordinates в MLflow эффективно визуализирует влияние `n_estimators` и `learning_rate` на F1 score для моделей Gradient Boosting. + +![MLflow UI](https://placeholder-image-url.com/mlflow-ui-mockup) +*(Примечание: Сюда вставляются реальные скриншоты в локальном отчете)* + +## Заключение +Настройка системы трекинга успешно помогла выявить Gradient Boosting как сильного кандидата для данного датасета, улучшив показатели базовой модели. Интегрированная инфраструктура логирования упростит будущий подбор гиперпараметров. diff --git a/src/models/run_experiments.py b/src/models/run_experiments.py new file mode 100644 index 0000000..12e2284 --- /dev/null +++ b/src/models/run_experiments.py @@ -0,0 +1,198 @@ +import logging +import sys +from pathlib import Path +from typing import Any + +import mlflow +import mlflow.sklearn +import pandas as pd +from sklearn.base import ClassifierMixin +from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score +from sklearn.neighbors import KNeighborsClassifier +from sklearn.svm import SVC +from sklearn.tree import DecisionTreeClassifier + +# Add project root to path to ensure imports work +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from src.utils.mlflow_decorators import ( # noqa: E402 + log_experiment, + log_metrics, + log_params, +) + +logger = logging.getLogger(__name__) + + +class ExperimentRunner: + def __init__(self, data_path: Path): + self.data_path = data_path + self.X_train: pd.DataFrame + self.y_train: pd.Series + self.X_test: pd.DataFrame + self.y_test: pd.Series + self._load_data() + + def _load_data(self) -> None: + train_path = self.data_path / "train.csv" + test_path = self.data_path / "test.csv" + + if not train_path.exists() or not test_path.exists(): + raise FileNotFoundError(f"Data not found at {self.data_path}") + + train_df = pd.read_csv(train_path) + test_df = pd.read_csv(test_path) + + self.X_train = train_df.iloc[:, :-1] + self.y_train = train_df.iloc[:, -1] + self.X_test = test_df.iloc[:, :-1] + self.y_test = test_df.iloc[:, -1] + + @log_experiment(experiment_name="wine_quality_multimodel_v1") + def run_experiment( + self, + model_name: str, + model_class: type[ClassifierMixin], + params: dict[str, Any], + ) -> dict[str, float]: + """ + Runs a single experiment with the given model and parameters. + """ + logger.info(f"Running experiment with {model_name} and params {params}") + + # Log params + log_params(params) + mlflow.log_param("model_type", model_name) + + # Initialize and train model + model = model_class(**params) + model.fit(self.X_train, self.y_train) + + # Predict + y_pred = model.predict(self.X_test) + + # Calculate metrics + metrics = { + "accuracy": float(accuracy_score(self.y_test, y_pred)), + "precision": float( + precision_score(self.y_test, y_pred, average="weighted") + ), + "recall": float(recall_score(self.y_test, y_pred, average="weighted")), + "f1_score": float(f1_score(self.y_test, y_pred, average="weighted")), + } + + # Log metrics + log_metrics(metrics) + logger.info(f"Metrics: {metrics}") + + # Log model + mlflow.sklearn.log_model(model, "model", registered_model_name=model_name) + + return metrics + + +def main() -> None: + # Setup logging + logging.basicConfig(level=logging.INFO) + + # Path to processed data + data_path = Path("data/processed") + runner = ExperimentRunner(data_path) + + # Define experiments + # (name, class, params) + experiments: list[tuple[str, type[ClassifierMixin], dict[str, Any]]] = [ + # Random Forest Experiments + ( + "RandomForest", + RandomForestClassifier, + {"n_estimators": 50, "max_depth": 5, "random_state": 42}, + ), + ( + "RandomForest", + RandomForestClassifier, + {"n_estimators": 100, "max_depth": 10, "random_state": 42}, + ), + ( + "RandomForest", + RandomForestClassifier, + {"n_estimators": 200, "max_depth": None, "random_state": 42}, + ), + ( + "RandomForest", + RandomForestClassifier, + {"n_estimators": 50, "min_samples_split": 5, "random_state": 42}, + ), + # Gradient Boosting Experiments + ( + "GradientBoosting", + GradientBoostingClassifier, + {"n_estimators": 50, "learning_rate": 0.1, "random_state": 42}, + ), + ( + "GradientBoosting", + GradientBoostingClassifier, + { + "n_estimators": 100, + "learning_rate": 0.05, + "max_depth": 3, + "random_state": 42, + }, + ), + ( + "GradientBoosting", + GradientBoostingClassifier, + {"n_estimators": 100, "learning_rate": 0.2, "random_state": 42}, + ), + # Logistic Regression Experiments + ( + "LogisticRegression", + LogisticRegression, + {"C": 0.1, "max_iter": 1000, "random_state": 42}, + ), + ( + "LogisticRegression", + LogisticRegression, + {"C": 1.0, "max_iter": 1000, "random_state": 42}, + ), + ( + "LogisticRegression", + LogisticRegression, + {"C": 10.0, "max_iter": 1000, "random_state": 42}, + ), + # SVM Experiments + ("SVM", SVC, {"kernel": "rbf", "C": 1.0, "random_state": 42}), + ("SVM", SVC, {"kernel": "linear", "C": 1.0, "random_state": 42}), + ("SVM", SVC, {"kernel": "poly", "degree": 3, "random_state": 42}), + # Decision Tree Experiments + ( + "DecisionTree", + DecisionTreeClassifier, + {"max_depth": 5, "random_state": 42}, + ), + ( + "DecisionTree", + DecisionTreeClassifier, + {"max_depth": 10, "min_samples_split": 5, "random_state": 42}, + ), + # KNN Experiments + ("KNN", KNeighborsClassifier, {"n_neighbors": 3}), + ("KNN", KNeighborsClassifier, {"n_neighbors": 5}), + ("KNN", KNeighborsClassifier, {"n_neighbors": 7}), + ] + + logger.info(f"Starting {len(experiments)} experiments...") + + mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) + + for model_name, model_class, params in experiments: + try: + runner.run_experiment(model_name, model_class, params) + except Exception as e: + logger.error(f"Experiment failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/models/train_model.py b/src/models/train_model.py index bba47ab..519f876 100644 --- a/src/models/train_model.py +++ b/src/models/train_model.py @@ -11,7 +11,65 @@ from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score +# Add project root to path +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from src.utils.mlflow_decorators import ( # noqa: E402 + log_experiment, + log_metrics, + log_params, +) + warnings.filterwarnings("ignore") +logger = logging.getLogger(__name__) + + +@log_experiment(experiment_name="wine_quality_experiment") +def train_rf( + X_train: pd.DataFrame, + y_train: pd.Series, + X_test: pd.DataFrame, + y_test: pd.Series, + n_estimators: int, + max_depth: int, +) -> None: + """Train Random Forest model.""" + # Log parameters + log_params({"n_estimators": n_estimators, "max_depth": max_depth}) + + # Train model + clf = RandomForestClassifier( + n_estimators=n_estimators, max_depth=max_depth, random_state=42 + ) + clf.fit(X_train, y_train) + + # Predict + y_pred = clf.predict(X_test) + + # metrics + accuracy = accuracy_score(y_test, y_pred) + precision = precision_score(y_test, y_pred, average="weighted") + recall = recall_score(y_test, y_pred, average="weighted") + f1 = f1_score(y_test, y_pred, average="weighted") + + logger.info(f"Accuracy: {accuracy}") + logger.info(f"F1 Score: {f1}") + + # Log metrics + log_metrics( + { + "accuracy": float(accuracy), + "precision": float(precision), + "recall": float(recall), + "f1_score": float(f1), + } + ) + + # Log model + mlflow.sklearn.log_model( + clf, "model", registered_model_name="WineQualityRandomForest" + ) + logger.info("Model logged to MLflow") @click.command() # type: ignore[misc] @@ -24,7 +82,6 @@ ) def main(input_filepath: str, n_estimators: int, max_depth: int) -> None: """Trains a model on processed data.""" - logger = logging.getLogger(__name__) logger.info("Training model...") # Load data @@ -45,42 +102,9 @@ def main(input_filepath: str, n_estimators: int, max_depth: int) -> None: # Set up MLflow mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) - mlflow.set_experiment("wine_quality_experiment") - - with mlflow.start_run(): - # Log parameters - mlflow.log_param("n_estimators", n_estimators) - mlflow.log_param("max_depth", max_depth) - - # Train model - clf = RandomForestClassifier( - n_estimators=n_estimators, max_depth=max_depth, random_state=42 - ) - clf.fit(X_train, y_train) - - # Predict - y_pred = clf.predict(X_test) - - # metrics - accuracy = accuracy_score(y_test, y_pred) - precision = precision_score(y_test, y_pred, average="weighted") - recall = recall_score(y_test, y_pred, average="weighted") - f1 = f1_score(y_test, y_pred, average="weighted") - - logger.info(f"Accuracy: {accuracy}") - logger.info(f"F1 Score: {f1}") - - # Log metrics - mlflow.log_metric("accuracy", accuracy) - mlflow.log_metric("precision", precision) - mlflow.log_metric("recall", recall) - mlflow.log_metric("f1_score", f1) - - # Log model - mlflow.sklearn.log_model( - clf, "model", registered_model_name="WineQualityRandomForest" - ) - logger.info("Model logged to MLflow") + + # Run training + train_rf(X_train, y_train, X_test, y_test, n_estimators, max_depth) if __name__ == "__main__": diff --git a/src/utils/get_best_run.py b/src/utils/get_best_run.py new file mode 100644 index 0000000..5de17b1 --- /dev/null +++ b/src/utils/get_best_run.py @@ -0,0 +1,44 @@ +import sys +from pathlib import Path + +import mlflow + +# Add project root to path +sys.path.append(str(Path(__file__).resolve().parents[2])) + + +def get_experiment_results(experiment_name: str = "wine_quality_multimodel_v1") -> None: + mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) + experiment = mlflow.get_experiment_by_name(experiment_name) + + if experiment is None: + print(f"Experiment '{experiment_name}' not found.") + return + + runs = mlflow.search_runs(experiment_ids=[experiment.experiment_id]) + + # Sort by f1_score descending + runs = runs.sort_values("metrics.f1_score", ascending=False) + + # Select interesting columns + cols = [ + "tags.mlflow.runName", + "params.model_type", + "metrics.accuracy", + "metrics.f1_score", + "metrics.precision", + "metrics.recall", + ] + + print("\nTop 5 Runs:") + print(runs[cols].head(5).to_markdown(index=False)) + + print("\nBest Run Details:") + best_run = runs.iloc[0] + for col in runs.columns: + if col.startswith("params.") or col.startswith("metrics."): + print(f"{col}: {best_run[col]}") + + +if __name__ == "__main__": + get_experiment_results() diff --git a/src/utils/mlflow_decorators.py b/src/utils/mlflow_decorators.py new file mode 100644 index 0000000..f5a88b2 --- /dev/null +++ b/src/utils/mlflow_decorators.py @@ -0,0 +1,60 @@ +import functools +import logging +import time +from collections.abc import Callable +from typing import Any + +import mlflow + +logger = logging.getLogger(__name__) + + +def log_experiment(experiment_name: str = "default_experiment") -> Callable[..., Any]: + """ + Decorator to wrap a function execution in an MLflow run. + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + mlflow.set_experiment(experiment_name) + + # Start run + with mlflow.start_run(run_name=func.__name__) as run: + logger.info(f"Started MLflow run: {run.info.run_id}") + + # Log execution time + start_time = time.time() + + try: + # Execute function + result = func(*args, **kwargs) + + # Log duration + duration = time.time() - start_time + mlflow.log_metric("execution_time", duration) + + return result + + except Exception as e: + # Log error + logger.error(f"Error in {func.__name__}: {str(e)}") + mlflow.set_tag("status", "failed") + mlflow.log_param("error", str(e)) + raise e + + return wrapper + + return decorator + + +def log_metrics(metrics: dict[str, float]) -> None: + """Helper to log multiple metrics.""" + for name, value in metrics.items(): + mlflow.log_metric(name, value) + + +def log_params(params: dict[str, Any]) -> None: + """Helper to log multiple parameters.""" + for name, value in params.items(): + mlflow.log_param(name, value) From abeb1e69fa711b695e11ee7fa538cee5ae18cd44 Mon Sep 17 00:00:00 2001 From: itwastony Date: Mon, 15 Dec 2025 22:48:51 +0300 Subject: [PATCH 2/8] docs: Update REPORT.md with HW3 details --- REPORT.md | 215 ++++++++++++++++++++---------------------------------- 1 file changed, 80 insertions(+), 135 deletions(-) diff --git a/REPORT.md b/REPORT.md index df47530..b1d9d3d 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,142 +1,87 @@ -# Отчет по ДЗ 2: Версионирование данных и моделей - -## Инструменты - -- **Версионирование данных**: DVC (Data Version Control) -- **Версионирование моделей**: MLflow -- **Удаленное хранилище (Remote Storage)**: Local Storage (эмуляция remote) - -## Настройка DVC - -1. **Инициализация DVC**: - ```bash - dvc init - ``` - -2. **Настройка Remote Storage**: - Использована локальная директория `../dvc_remote` для имитации удаленного хранилища. - ```bash - mkdir -p ../dvc_remote - dvc remote add -d localremote ../dvc_remote - dvc config core.analytics false - ``` - -3. **Версионирование данных и пайплайн**: - - Датасет Wine Quality отслеживается (`data/raw/winequality-red.csv.dvc`). - - Настроен DVC пайплайн (`dvc.yaml`) с этапами `prepare` и `train`. - - ```bash - # Добавление данных - dvc add data/raw/winequality-red.csv - dvc push - - # Запуск пайплайна - dvc repro - ``` - -## Настройка MLflow - -MLflow настроен для трекинга экспериментов и реестра моделей. - -1. **Запуск сервера (опционально) или локальный трекинг**: - В данном проекте используется локальный трекинг в директорию `mlruns`. - -2. **Обучение и логирование**: - Скрипт `src/models/train_model.py` обучает RandomForest и логирует параметры, метрики и модель. - - Пример запуска: - ```bash - poetry run python src/models/train_model.py data/processed - ``` - - Пример запуска с другими гиперпараметрами (версия 2): - ```bash - poetry run python src/models/train_model.py data/processed --n_estimators 200 --max_depth 10 - ``` - -## Результаты - -### Логи запуска (Screenshots emulation) - -**Запуск 1 (Default params):** -```text -2025-12-08 22:12:27,045 - __main__ - INFO - Training model... -2025/12/08 22:12:27 INFO mlflow.tracking.fluent: Experiment with name 'wine_quality_experiment' does not exist. Creating a new experiment. -2025-12-08 22:12:28,243 - __main__ - INFO - Accuracy: 0.659375 -2025-12-08 22:12:28,243 - __main__ - INFO - F1 Score: 0.6442498546491976 -Successfully registered model 'WineQualityRandomForest'. -Created version '1' of model 'WineQualityRandomForest'. +# ДЗ 3: Отчет о трекинге экспериментов + +## 1. Выбор инструмента и настройка + +**Выбранный инструмент:** MLflow + +Я выбрал MLflow из-за его широкого распространения, простоты настройки и отличной интеграции с Python и Scikit-learn. + +### Детали настройки +- **Установка:** Добавил `mlflow` в `pyproject.toml` и установил через Poetry. +- **Хранилище:** Настроил использование локального файлового хранилища (`./mlruns`) для простоты в текущем окружении разработки. + ```python + mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) + ``` +- **Управление экспериментами:** Создал эксперименты с именами `wine_quality_experiment` (для одиночных запусков) и `wine_quality_multimodel_v1` (для сравнительного анализа). + +## 2. Интеграция с кодом + +Я интегрировал MLflow в проект, создав переиспользуемые утилиты и декораторы для автоматического логирования. + +### Утилиты (`src/utils/mlflow_decorators.py`) +Я реализовал декоратор `@log_experiment`, который берет на себя: +- Запуск MLflow run. +- Установку имени эксперимента. +- Автоматическое логирование времени выполнения. +- Перехват и логирование исключений. +- Логирование параметров и метрик через вспомогательные функции. + +### Пример использования декоратора +```python +@log_experiment(experiment_name="wine_quality_multimodel_v1") +def run_experiment(self, model_name: str, model_class: Any, params: Dict[str, Any]): + # ... логика ... + mlflow.log_param("model_type", model_name) + # ... обучение ... + mlflow.sklearn.log_model(model, "model") ``` -**Запуск 2 (Tuned params):** -```text -2025-12-08 22:12:56,617 - __main__ - INFO - Training model... -2025-12-08 22:12:57,279 - __main__ - INFO - Accuracy: 0.646875 -2025-12-08 22:12:57,279 - __main__ - INFO - F1 Score: 0.6266469214465146 -Registered model 'WineQualityRandomForest' already exists. Creating a new version of this model... -Created version '2' of model 'WineQualityRandomForest'. -``` +### Рефакторинг +Оригинальный скрипт `train_model.py` был переписан с использованием этих декораторов, что обеспечило единообразное логирование как для ручных запусков обучения, так и для систематических экспериментов. -## Воспроизводимость - -Для обеспечения воспроизводимости используются: -1. **DVC** для данных (`dvc.lock` / `.dvc` файлы). -2. **Poetry** для зависимостей (`poetry.lock`). -3. **Git** для кода. - -### Инструкция по воспроизведению - -1. **Клонировать репозиторий и перейти в ветку**: - ```bash - git checkout HW2 - ``` - -2. **Установить зависимости**: - ```bash - poetry install - ``` - -3. **Получить данные (DVC)**: - ```bash - poetry run dvc pull - ``` - *Примечание: Так как remote локальный (`../dvc_remote`), он должен существовать на машине. В реальном проекте это был бы S3 bucket.* - -4. **Запустить обучение (через DVC Pipeline)**: - Это автоматически запустит подготовку данных (`prepare`) и обучение (`train`). - ```bash - poetry run dvc repro - ``` - - *Альтернативно (вручную)*: - ```bash - poetry run python src/data/make_dataset.py data/raw data/processed - poetry run python src/models/train_model.py data/processed - ``` - -5. **Просмотр результатов MLflow**: - ```bash - poetry run mlflow ui - ``` - -## Docker - -Docker образ собирается с помощью команды: -```bash -docker build -t epml-hw2 . -``` +## 3. Проведенные эксперименты -Запуск контейнера: +Я создал скрипт `src/models/run_experiments.py` для систематического запуска экспериментов с различными алгоритмами и конфигурациями гиперпараметров. -*Важно: Для работы с локальным DVC remote его необходимо примонтировать в контейнер.* -Предполагая, что локальный remote находится в `../dvc_remote` относительно корня проекта: +### Протестированные алгоритмы +1. **Random Forest Classifier** (Базовая модель) +2. **Gradient Boosting Classifier** +3. **Logistic Regression** +4. **Support Vector Machine (SVM)** +5. **Decision Tree** +6. **K-Nearest Neighbors (KNN)** -```bash -docker run -it -v $(pwd)/../dvc_remote:/dvc_remote epml-hw2 bash -``` +Всего экспериментов: 18 -Внутри контейнера: -```bash -dvc pull -dvc repro -``` +### Сводка результатов +Сравнение моделей проводилось по метрикам **F1 Score** (weighted) и **Accuracy**. + +| Тип модели | Параметры | Accuracy | F1 Score | Precision | Recall | +|--------------------|----------------------------------------------|----------|----------|-----------|--------| +| **GradientBoosting** | n_estimators=100, learning_rate=0.2 | **0.6625** | **0.6543** | 0.6520 | 0.6625 | +| RandomForest | n_estimators=50, min_samples_split=5 | 0.6656 | 0.6470 | 0.6337 | 0.6656 | +| RandomForest | n_estimators=200, max_depth=None | 0.6562 | 0.6390 | 0.6287 | 0.6562 | +| RandomForest | n_estimators=100, max_depth=10 | 0.6437 | 0.6240 | 0.6108 | 0.6437 | +| GradientBoosting | n_estimators=50, learning_rate=0.1 | 0.6031 | 0.5923 | 0.5952 | 0.6031 | + +*Примечание: Таблица отсортирована по убыванию F1 Score.* + +### Наблюдения +- **Gradient Boosting** с более высоким `learning_rate` (0.2) показал лучший результат по F1 Score (0.654), немного превзойдя модели Random Forest по балансу метрик, хотя Random Forest показал чуть более высокую "сырую" точность (Accuracy) в одной из конфигураций. +- **Logistic Regression** и **SVM** показали результаты значительно хуже (F1 ~0.51-0.54), что говорит о нелинейных зависимостях в данных, которые лучше улавливаются древовидными моделями. +- **KNN** показал худший результат (F1 ~0.43). + +## 4. Воспроизводимость +- Все эксперименты версионируются через Git и DVC. +- Скрипт `src/models/run_experiments.py` позволяет перезапустить весь набор экспериментов. +- MLflow отслеживает точные параметры, использованные для каждого запуска. + +## 5. Визуализации +(Симуляция скриншота интерфейса MLflow) +Интерфейс MLflow (`mlflow ui`) позволяет сравнивать эти запуски. График Parallel Coordinates в MLflow эффективно визуализирует влияние `n_estimators` и `learning_rate` на F1 score для моделей Gradient Boosting. + +![MLflow UI](https://placeholder-image-url.com/mlflow-ui-mockup) +*(Примечание: Сюда вставляются реальные скриншоты в локальном отчете)* + +## Заключение +Настройка системы трекинга успешно помогла выявить Gradient Boosting как сильного кандидата для данного датасета, улучшив показатели базовой модели. Интегрированная инфраструктура логирования упростит будущий подбор гиперпараметров. From 75e1e1a8ec7a0c00eb822a5f48e8d632183b741a Mon Sep 17 00:00:00 2001 From: itwastony Date: Mon, 15 Dec 2025 22:57:49 +0300 Subject: [PATCH 3/8] docs: Add MLflow experiment screenshots and remove redundant report file --- REPORT.md | 4 +- reports/HW3_REPORT.md | 87 ------------------------------ reports/figures/mlflow_exps_1.jpg | Bin 0 -> 75874 bytes reports/figures/mlflow_exps_2.jpg | Bin 0 -> 67496 bytes 4 files changed, 2 insertions(+), 89 deletions(-) delete mode 100644 reports/HW3_REPORT.md create mode 100644 reports/figures/mlflow_exps_1.jpg create mode 100644 reports/figures/mlflow_exps_2.jpg diff --git a/REPORT.md b/REPORT.md index b1d9d3d..38de7c1 100644 --- a/REPORT.md +++ b/REPORT.md @@ -80,8 +80,8 @@ def run_experiment(self, model_name: str, model_class: Any, params: Dict[str, An (Симуляция скриншота интерфейса MLflow) Интерфейс MLflow (`mlflow ui`) позволяет сравнивать эти запуски. График Parallel Coordinates в MLflow эффективно визуализирует влияние `n_estimators` и `learning_rate` на F1 score для моделей Gradient Boosting. -![MLflow UI](https://placeholder-image-url.com/mlflow-ui-mockup) -*(Примечание: Сюда вставляются реальные скриншоты в локальном отчете)* +![MLflow UI](reports/figures/mlflow_exps_1.jpg) +![MLflow UI](reports/figures/mlflow_exps_2.jpg) ## Заключение Настройка системы трекинга успешно помогла выявить Gradient Boosting как сильного кандидата для данного датасета, улучшив показатели базовой модели. Интегрированная инфраструктура логирования упростит будущий подбор гиперпараметров. diff --git a/reports/HW3_REPORT.md b/reports/HW3_REPORT.md deleted file mode 100644 index b1d9d3d..0000000 --- a/reports/HW3_REPORT.md +++ /dev/null @@ -1,87 +0,0 @@ -# ДЗ 3: Отчет о трекинге экспериментов - -## 1. Выбор инструмента и настройка - -**Выбранный инструмент:** MLflow - -Я выбрал MLflow из-за его широкого распространения, простоты настройки и отличной интеграции с Python и Scikit-learn. - -### Детали настройки -- **Установка:** Добавил `mlflow` в `pyproject.toml` и установил через Poetry. -- **Хранилище:** Настроил использование локального файлового хранилища (`./mlruns`) для простоты в текущем окружении разработки. - ```python - mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) - ``` -- **Управление экспериментами:** Создал эксперименты с именами `wine_quality_experiment` (для одиночных запусков) и `wine_quality_multimodel_v1` (для сравнительного анализа). - -## 2. Интеграция с кодом - -Я интегрировал MLflow в проект, создав переиспользуемые утилиты и декораторы для автоматического логирования. - -### Утилиты (`src/utils/mlflow_decorators.py`) -Я реализовал декоратор `@log_experiment`, который берет на себя: -- Запуск MLflow run. -- Установку имени эксперимента. -- Автоматическое логирование времени выполнения. -- Перехват и логирование исключений. -- Логирование параметров и метрик через вспомогательные функции. - -### Пример использования декоратора -```python -@log_experiment(experiment_name="wine_quality_multimodel_v1") -def run_experiment(self, model_name: str, model_class: Any, params: Dict[str, Any]): - # ... логика ... - mlflow.log_param("model_type", model_name) - # ... обучение ... - mlflow.sklearn.log_model(model, "model") -``` - -### Рефакторинг -Оригинальный скрипт `train_model.py` был переписан с использованием этих декораторов, что обеспечило единообразное логирование как для ручных запусков обучения, так и для систематических экспериментов. - -## 3. Проведенные эксперименты - -Я создал скрипт `src/models/run_experiments.py` для систематического запуска экспериментов с различными алгоритмами и конфигурациями гиперпараметров. - -### Протестированные алгоритмы -1. **Random Forest Classifier** (Базовая модель) -2. **Gradient Boosting Classifier** -3. **Logistic Regression** -4. **Support Vector Machine (SVM)** -5. **Decision Tree** -6. **K-Nearest Neighbors (KNN)** - -Всего экспериментов: 18 - -### Сводка результатов -Сравнение моделей проводилось по метрикам **F1 Score** (weighted) и **Accuracy**. - -| Тип модели | Параметры | Accuracy | F1 Score | Precision | Recall | -|--------------------|----------------------------------------------|----------|----------|-----------|--------| -| **GradientBoosting** | n_estimators=100, learning_rate=0.2 | **0.6625** | **0.6543** | 0.6520 | 0.6625 | -| RandomForest | n_estimators=50, min_samples_split=5 | 0.6656 | 0.6470 | 0.6337 | 0.6656 | -| RandomForest | n_estimators=200, max_depth=None | 0.6562 | 0.6390 | 0.6287 | 0.6562 | -| RandomForest | n_estimators=100, max_depth=10 | 0.6437 | 0.6240 | 0.6108 | 0.6437 | -| GradientBoosting | n_estimators=50, learning_rate=0.1 | 0.6031 | 0.5923 | 0.5952 | 0.6031 | - -*Примечание: Таблица отсортирована по убыванию F1 Score.* - -### Наблюдения -- **Gradient Boosting** с более высоким `learning_rate` (0.2) показал лучший результат по F1 Score (0.654), немного превзойдя модели Random Forest по балансу метрик, хотя Random Forest показал чуть более высокую "сырую" точность (Accuracy) в одной из конфигураций. -- **Logistic Regression** и **SVM** показали результаты значительно хуже (F1 ~0.51-0.54), что говорит о нелинейных зависимостях в данных, которые лучше улавливаются древовидными моделями. -- **KNN** показал худший результат (F1 ~0.43). - -## 4. Воспроизводимость -- Все эксперименты версионируются через Git и DVC. -- Скрипт `src/models/run_experiments.py` позволяет перезапустить весь набор экспериментов. -- MLflow отслеживает точные параметры, использованные для каждого запуска. - -## 5. Визуализации -(Симуляция скриншота интерфейса MLflow) -Интерфейс MLflow (`mlflow ui`) позволяет сравнивать эти запуски. График Parallel Coordinates в MLflow эффективно визуализирует влияние `n_estimators` и `learning_rate` на F1 score для моделей Gradient Boosting. - -![MLflow UI](https://placeholder-image-url.com/mlflow-ui-mockup) -*(Примечание: Сюда вставляются реальные скриншоты в локальном отчете)* - -## Заключение -Настройка системы трекинга успешно помогла выявить Gradient Boosting как сильного кандидата для данного датасета, улучшив показатели базовой модели. Интегрированная инфраструктура логирования упростит будущий подбор гиперпараметров. diff --git a/reports/figures/mlflow_exps_1.jpg b/reports/figures/mlflow_exps_1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8ceb2632c1694ce5a2e54806149a5dc1389c9920 GIT binary patch literal 75874 zcmeFZ1y~+SwkZ1JAxQ9`!QC}Dgy8Nj!5xBoAV>)A?jGDFK!D)x5S-vn@Zh`#viIyg zd*fI%XE8`$529VPWCn;4u*pFqsH(37P)wbk_<(egc6F1%d!00)ZogK_G+OwS#Z~ zBEcc<$^AWnLxMp-!9W8@Y%m}J{eMRRivov$go3`C0l@>!;7AZiK+^?skr${=!Jg_z zkZ2dhA3Fa#;LFXFIG3vv&7oahuHuwge=djcX=&~o%RQRyAwCa_9m7D|mG#Ps00y*w zXH*dAIb~FJN4X}{rc9de7DoknrW~6+M zG5C=8co}hj!CG?Jk~MneM8O5Kdt`@q`dTc>;x_JUVTS>sYXRTJp|SZEkJ189deUX^>v3Wo)Wt4@K`$uiYZ5f)RqL zU>_=@vGh(Heg5kC#>w;8^4T+~#?)H+WDIVVnMUPqxL@=F3f-j!*<82N0{m~1SA<4X)rdIFee!C<4bjeThK z^_cGQgzSr?Aqd>RejwT>J+M@`^UlmV3S;sw0b0wSz1wM2O}q|EI#>>!#2Q1@>@A(g z#qnS)H6Q~8`;w!G-p3huVq&HDgT*r%?bRdLHLY(z`s`v6CLl1(U9vA9QO$c8(!pR` z-#qEIb5-Bkwud&?ZcnUaHC2Kh zuB`p*ZgYC{vr_a5@RmpB0x=h|n}q0o#{($!uk_u2pg z3l28M0$>nGpIAeen4*2^Tbb_?D+oHJ)$j7Mnl5;l7%zY@c!f#+y+L~%=78D4`lj|+ zP|aZW$v!^e=^co1cuz^)c_B{k4n*{0%sDpk<^Esv(in@A&1E_$hBOd}r-LOJzb+Pv0UZAWfgormAFKyuhDggQq26zgqIDrdgVGSlsn=ko2_6Q0 zVAC_k?6e)U@f&1L(xrU!VZe>D<;UUmpq7M8+MbAeiAR`lb(GC=)5LNOzv|=6&~?(_ zat}UG`0eksya+hM$3MdwOB?>$UpkA+9to9BrGZ;8ztmDY z{vb{eIMXXEFn(Z!dpTmyK6wq@$yD%sBFmF^#uCK~oQbS)kmCcBo~lH^_H4nLgY&Iz z>oWwsd9E~NgjDK1ZE6E3FW_b%0g?Hk{FQ-irv2uVk&?-PMz9b!VlvqosB{1pcpDGo z5LmB*-}C=geT@T)B}_Kxu%*amXB`FXJf29AV1?=%eMu~#H}{`hn)4ajp8qWVqkI#( z@b7IB7~^k=2ldhF?ma*Y2n3Dk!y&qClTSzf(IJR-2nQ5 zVF{pEO-`%lFu(zd2m7PHJ(wuaRgbKZX}TL#J?~biFw8 zWCPpyf*ODUnm`Dq6$}xUsf^bH zF*axu+WcR$76|1jacJ3l4FnPpkFdTmG5d}~sF6drDXuDw<2ok6x&8(}bwX1kdoKo}>{*#y2q0Ir)@ZbIwS@46PKX=+frQZ(+0x>4$#4(l6 ze(SD^Tg$V-1=ybc&<IOntbH6e5T)ra z{xzorrPc) z2?R^9ln%zL@t<5hXDIK zuFgG_fsn*q2?fWxX&T?EArhfj>Pnh30o!Sn@{k=MZXLqcPM5GxT3Ps3pY4qpRMJ8o z=eC+B+t%-yM7U&m;*%vFk2G5PE*TLGBrzgX?99Rf@!tXCZ4$F~dwKI~BL@p(B^K4v zo-PV;M1`T)UW(!SSZGzOH@_Egaj$sf*7PS{DF4?lxOMW| ziZfdtmkjLdc|5ZQmp63OFL*~rhPu9AJs&jV%?Q3{iF0TJUQk+^qXTa3@HDT|;(~kM zHg)Mx@pu`4{OrnJ(jQ8^S%c4XDJ24S((o+S_}=+zZFLRRD-WV>8MwDK*Ind|ne*Dq z0y^VXjL+6E#_Mr1%ByuE$Ln#*Dh)~poOYoRJ9SFdx{egY2EI)C#m`X_b^$LC2yt-p zJI&9bI1k=u2~I`898`I@vY zo1apKl8KT!x-tN$V00$$p|6-I4rIt#{AznZ+E|!{9%^HBXlkG{|JL~z;0q0Gwb02bUQ-~ix7^Vqt|V1pHRXhjTj#hL{tMK^$eXm{BCY&T@T zYMa8594BMb$ywd2J&W6MMUFRGVe{Nn7P1ugr;LFu9-cqte>$uT>|Snqne zv~u`ytVI}y%WS^w;`&S4;rzhO+3TVmp0SOi#f_O!1hclVo99lh6|w2IC$Up`iTmv} z6pVt}kiC)ZWWg|0PX~+w4CCaaot9Y+>p&Olc!`2TLG-*DuJ026b8<91N!%`V7{;lM zm~S+=wZc&7H(iwDb2dWO4-5LWq-6W!61+jLF_~9|W$v%z6a1E+2cK8+t5^0vABXCVzegSm4J4m9 zR3D27YksZy$)K46X7+ORpl!@cv(D4_YqHH2J6b9=lZf*vjZ4EkA-i5 zk#Tlqg-*dH$A<7IjytcoPwmY%MTATIG-`W00XKvk*7P*y_` zV}e8tfK=}@d7&5pW2>fw5d&5I$`Yt*#wz*UXFwl}zafATfVMJq;Ffz3W1>u5Il%H^ zFsA!P4XB)lX&y0OP5*=er~o>BwNxjqw!kR(ZvAWX1%yhN4hpD)c4$nchxSI zrgq#v?%2P3E!5sCLQu44R65RW^wjdztV2q?-B}Zw6eY5 z>G123u3*dzSKCj!N~OV`#QKX)ih_a+XPYF zLlwemriSPaL^tRo`-q+u-u%+Fbu0-ywgHK5+jn`9Y46u-h|g5B#D>(-Ee|~x9y&a{ zJ>l`pTTzIcO63gGbKX275B-Nlp!z;dZXIRGQ*-ac@BgQO^?3z5tOxRer>@esqjM~^ z2()by{|Ny;;Ke5s5(o?e0vrMc9OnKFD3HK`21sNSR5T=XekcqALIxHAXhtGpCT3RI zw~0WU_F3x5Fh>eq!R9vt+ojPV~6g&_R&VrY%iKE>p|Jmd56cprZ8lgc#c& zX`NzZx>z+P7+-u0>1l55yJiPvQ|d-O--Po;ekJpYVN40~O9F{at$y#e^gtxLPI->L zf90TRTSYFpD^Vn@8Z*DxW%qX&hD)$G?ZGaACk)?ar;Ky}k4{a?VEO> zz~(nOD_>T$L1tyKb@jW&gCBzH;jTdCs$7*&mpnCSkRZ4OS`paAaHG8ta1AMw$#JID zhTo%J8s6f96^anQqcVl2`cUY>vua^8v6@;WY-$XVeG=p=tFY@|iJ8cWl*p7Bs=}$7 znTHg}u2DJN7AL%hK!V|lIVhZt_u0{&o@3tj~#uWNZiOs z(a6DkS47`e4Y&%DAdfq-kLNa01<}!wKU7@`DacDRk3+g1&u>$`;F(ZB9pGx;H)vCk zr#lioJiy8Zn~cJ9u{F&$kjEl-ax~2`OkjU#jqnW)FjCv@d5?v2Fd6Z6J!VYQqB^@= zijXG7*ML#Ig73D!4neVnt7r1ap)tjpT;m@Dq=J#P=Qf^Vc-&)bt;H5plmBhAvC5zm zzfN&vT`FSq@(3RIAU%P!%5?+9Y{mw)%e1^NrMHQB^Cx~?+FCADhT(`ccCQr}@;*g$ z7p#+Gtzv4WEIwDQ@0g@3cL*xd(%bo`mF-C#88;nV6y#D%&*5WwFtKWw(#QIWj?!_xK4jI3N?L-x;ryQ-gf`Z5 z0jsk<4T_3LX!aH)&ceG$tw$jKwHzs5$kxIa+d)t{hyf-6Xo`DiC0EmdOksDRru6AR zDb~YFx0!PbW%mW>3Dq87Nw?6{jFiFKRz9&gGHsc;w4XZYh@(-?WU0XlQzix{_B=bA* z*9v&sgdd^D7j+m>GpYslE9+JMZ>ey%4AqBN={2pBjCBgd7x@|V5vnou#7b5eM$!2F zHdO_BIDnxhRD0U3Ebv=Ja`!exreO>2QU*{AghD;VU8yRlDl{a97xAlKacbge8PwlY zeKqwSgUW@I#JI2WSG_j{ABD<>6U7LpCHPa79HdSd8%3k9Zd3ec#NYLMtnx5$d?Are zjmP655u5>Yq4Y}0Um`OzbEx#Q_-gseaz&kymSS=~)_X)8$Zt9PJx84Ep$eH(4) z=LrV$e1osZY&zwx$eh%u7yV-6Y1vdc%S$^eNE64=A!Lz<-A z&sE&6sOkD|xxOmwUka?0McI#6vhU`O&q}@jO5y@e2mlr0+1bG(s%q+_4zUe`Jrgr<4y8lNf8G(OZScWCyV^4yd@bSUe?0-X9J zCliX9)s3nKOcTdgBn3*g_x~5y&OYDc?6l{uFWtzjVTQtHyMpN3`^Zjj-97R>&bhSI zd#^1W&Qw%R-?>xG-u6+`4W7Mtn&ve69OI_jG*Of-BfnNp!}*9)7A6w;iSCnL&ZLde zP!3TdxRsNi)d!0{?@H*t8M)chZFLJJw~NTq7vjZt^lfab@1Q#w3=VC(pEqbFp5%4C zc)4Qr+`&^~qcx{_yF;&mZ|eNfn{-al4SaTJpx)O?vllI>z%vqdImO2Z)cI+5pj;v| zgR6_x$q}bFF_y@W?~e7xr;EO=LO2~{Tw=7#az%dxXou7TR z0;b|M+grIfNNL{e(EG#_=Y21j&i@s&{+^pVfa$MTPb>kmHW&P`e!VfG*LTR&)9A?u z>m$mT)UJ)A5ps>OvR<8X^3H>NUaw>~ zVfq@M4=tj~2Q74Hsn+}USu+kjtD_Dt^kPzAhn$-X)V(E#T<@3Mr{;_CdpBx%*=W_1 zy0^)7sl%HxouW2v*tW3}%Of19gV*?cg^^wO6sl$peI`@HWP_RQ#vQNu1vNT+qW#QD zZJ?Cux#Cq*cwO@xH0D-kKn}utT^4M+0YOG7c?3L}uKasHI(zE+Y_;k3rJJiKZ*IB) zDXx(Mo1}&0t?!6IW*oi10$eG%YDGsZu{rClJ67YN>ZeS*yxDh(a;Aq5KHRg>0Y#8j zftq%1YUf4S5W)KdQO_57k8In#O+)?iMJ7$<*wI+oQO$WRM2O6C)8ostG>~Cnhr+wA zN1<##rW+N&9Q_n?aHROYyC%?`qA+>ObBO%ddKV_2bru{XeO#2?`aJ6GV(I9WJs&Uo zwBd3zqw{Ld7dZObpp8~umc`1yasKP=nRcG8tVi^p^}?drxKy`ja?P0{iEK}XHs8hc89jFFPqcy z%nP}Q%9#xyNu!*DXBb^NGQQ#d6xd}2i5!1le1|NSCsQ(OT-eFz7WhYnIw`(4f#&?< z?b~9TJ-H!&aMy($KTr8&vW_>K7AXBfc|fnEi7aSi!OIMTN;^v= z$ki(PJvbnqqZl3N8^+XKpNrmsQXCwF_PGBTSG4h)i!VK%rHP!aB$jjP0xtP*I^_*D z)WZ<0K*YU<=CmRWdr+1*Se@hjl20CKe>2tvQ@P_%~1aY^I+#zI)6|fi4Jx9ke7wf#BJ^R%LVdqYis^VC8DxKTH z!UE9+ab&+%cD+%n-&fd3aa{bQ@#)dBE#LibIWo`CzAQBH>?KF9|a zt4O=XtbNNbY3$;pq+jY1S9_>yqqX=Bv|C@l`$P9{#ss)Aop&)aTdB9t(sI}P4mrPm zu}+hG9rZF2<#_3MN%anNHm~JqeR%s2Gal{8R zb$HPiF1L3o`9lAuROizr9bMa6&sesxt?(s%pvPiiyX z9Ch<;DAb}EwFoEo;*<$$jMqebqpgZB93Z`U<&UzyIvL#UnNQFvjZ!~vc$Y_JXzLL2 zZ8oQY4?j)ujaLO7x<`lts}5nI=7(b0R>hNde^|rIG$~n*cDbakniw9A1|itsJ)Fw2 zN(TtYo|XJYVX@=*l!JMjBuO&V5n`UXc#0xt{B~3= zk(8zlsajYN9o&qg8t!2S{17D$qKV{9=4vtrszO9wB-w9ZWPRa6wiL3|munxn1DVp$ zQgNEBT0UzZ-|Z%9`SLgVZdiZc_B}oiJSf&3Na}v4@L*to^8M~Q?m%OYertDO92>Kt z9j=l8zi*bqBUUeP2O^+90*>wo=-|J3FfXCWMKh0~k&G)LdJ~5a1`2CXzBLoGQVi1v z555Dn2L+6ak(fQr^oIE4trA;?sJa22Y)%-0r)X4L#!#)Qo!%ze4^B-<*RL!WNgQPl|&CzHM@id8}ow5u&e zkS_G?w5b+Zt5%QH$C!lgtk`9JVJR>8Oz910k*>@8?7%|B!0fMq*{Uyf;;^C0PM(0p#))w&I>V+1{^T#JIMXXvu>tpuF z1v8Cv8`xns4BdfVDf)mU*Itx2LMbkH$u957t)o8=vWIsIyM)do#tP}PKkuN+Lk-tA zCAUFMXw~YQGyA$^mN)h^_HCpsl53JwffS1pXQYU1XJXFWOFFTFtgq`f@l?1$!AFgf z4ysAxGmZsB8r~G4>Ap`3QDB2t<~}n|+@gFkqHSA8hqPjjm65Y&m1{I)4sH~GgUT7M zYg&zCzlWn33d0C%-W4?-O3Z=EWBffb3ydlWM^TMOmT{*mX}l{KWxM*`zxW$_;-V43 z^!2ppb;@N%vZ8U)?r>>k=8e%lA2V}oG1bI;qwDA!-x`O;;sZpMn*7a<6<3WuJT}Oa zpYOMlV<+IrKr~V<33KZ4RW)mNULmQe8+jKU(m&9mOR4voxlMbo%yV-)Qj-pNn3wGb zZ4uZ=;e~Lv!pnk##Z9VxUf;UppQdD94}98&BBNrT(|LVF`Z0uuX*Q@hZ)t`Lu4P|J z%G4VIW0oiD{mL6GtnuOP;&S5m{b!2-`Fs$gpJ@xH2s#3jh)pCkO|hcnq0VX$O4nl0 z+tY2sO%L74R%i@~sC3A4t_@|J7ZOeevET#KSWZ^I%W{CpYl?^Co!x;#Io;u2A34T? zXV#%1iuleM0NR5+S{TOAZh^=U~r!E1G6E?UCrFpV@Xa<1|&yXj<&{dq!75p(?O;@6j{DL0u*}T zm}Uk!xu(#uT)@BxnX)GG(HrR8W<`Rgm#z2OWN&=+QLH7@l+-|aT?>RIEJE1pn+%r$ zD@|e~j}+x|Lda6ih?>C2^-=&GRhZ8qYH7Gyu%DXT=GM3FK?B=46aRxVpnrJO^Un^9 z{Zo`dp^-jbWk5~!k%293ydOn%Z=Z*|`RcaTRACg*134BXp#t6&c@>1nx zzFo|=);duD@v1_EO zS4_e)6LBUHSsMy-M2uLKg!6V+`7YV7SkeP>=)#fnPbAURE$b|sz9VErEX8u>g|ctx zdf0Z88)+0}Jq;lCrAB1WiCB*dmgg2^4xKCD+YZg}dQn`ho;!lBz=hUg(?#4_E0cq# zoWK$~BBJ!3C3JY7WZ3KiQnZqnkl4#l(lnP$P6^bYcq^x5h&933Ly|!OAIg}b5rrTP z8sie4h^&&Ihz?H?^v7q6%DZ=6PQsp_Z&B_!x*xnvGCtQ`%S78BWJmPh8Ss4hxce9r z6s$!5>;w4uijE(~sxiAeC=IZDaGY1<##WdyyFV*y1Bcu=D}k3D0P1-~$9~bzt0M8; z!R?{tj``iQ_7vb1$d}ZqFEJ_1T58N(MPkfc=ezqwTfQlJ`$gMB#?P%V+?crzjGUAu zfz2aN(a!T4lA^Q9JY%3;ZP5p=zH(;B+!<)8=-3OO<|(Ic;Cc%*83s+O?HM;en89Wx z=0I+UY;6P>B&Tikv{Hk}&(mi=U%XBfOPNk|qBOV$UwuunehisV86bQ)J;3qKP%@h$ z+wx2&Q5biIs{4mRE?TnCAx)s!^eQ1n!a~s<2qFR_9m~qLH{jS?{w2pM*Ch9pqaVi# z1uDGP8=g#bH5?k-c&g3! zj5S0Z7&`*TFZ26%AY;#$-v@UW3b}%Eou02a)oMOkQ7f$^k*Z)>fG$casjtM7SY(?2 zd#ZP!AEJ+|yaU-?liY{9HU%!kOSa&hME0KsN7gH4N{p9XS5fWA<+6960Tz0&6Y%~u zIm`~4`INwLmZ9_z-%!;~s?2N@TU?3s+>d0bsd#2S8MM+LQVC)N2#oAgJ>YG?zDnTA zcPs9XcnaT7wv-7*r-PmNfn#hGBDIy;gza7a?RsGYk8a(NtU|NEF$d~oQsWrfkv%zG zMzt8rk?2v+u&_05#NZ+OH%y!_i|L^vAH9u3mhvfHXJFw%sft@Rh~xv_RWKRQ6&kK|Y z4v(ZeP>C^71d|l*7L*(UuRrfW6|pl_l#vc`)fM$4iV%wx zBEtAMKxWD;mMC(cCmkzdN#-h0GF}vn$gL{6puZ3jiMR)7$1Lw=PlQErJ*MyxIcNwwbAWb zS!B39@Btv1SlE@fk!l+#<*Et-?9+N@E;?C!O`nV+-{zM8bEf>6TVa<6aVVp<$l+yW zvEx{DlUd!?G%U-3Sq0%b5?{=R?TU9KG z2~ACP?-S+09VqD&z-U6a17kot1c9$2!66`Eok#TSi9*G%?LXrtEwdZSqcpD2^$>C4Otsn-jjd zm-s>CrZ@jx3bP8K!4;~gRiDMHs(|+?BzA`6qK;uFkZkQEWaC>EvX>PY5~~J@2*RU+ zW4YvH2@>0x8WmfbDGX92F9Ir3G98at9JAOyY;+@`yBtMt%_Ivc;9M|E#wL9R(>ZnF zyE1AbP$)sUq(J&qfgUBKOhYAQVr|xQnLem-2O8Eb`M5k%mKNECR7!Q4{ezXsxX(P` zP)X6a$xI2$4h8Z(a9Rw@nA258q4?Fk&#q`s27$;DHTGo2GRbV7L<;&V-xfywq35Ml ztH_7|*pDO)Vrw&JJl%IY%69Z7&{3qBh%Pn>&E+W8+!J$gl!yw`J;o$g=C@` zgQJD^s&8|*`CS@6Rr3V*U~y(Py+F0)7<$`_kblS&z8E!TE|tNUOGDP~P=#v7FlS{S zARTb9M^b&VhUX%i$-Lybow^}*wwYG}JMbcCX~}c=TeQB|TT*w4j5>oQcEnjlSf1vh z(eD)@0f;0aBn)#3cOYfd)UPiYa0J{khhO7lDy)1g!$QpDLN&%f)jnh^OA_zpBqsE_ zL~uh*iuO~jVsre=BIrPyl4t9Kv$PbUki+aER}o$j-4<`=|LivCHvIEw@t7PQQgudX z=0f?dP=cybuWn^kQPwgOs#vy4)u&=QeI}Lhhh>f6jv!eIejJt}lNlV5U8<_Sc*Rzy z8Lx(cEBtv}am+@J@18cM7RyLKta1&xrg{3j>xA$b3EPN#8+;-3V!IUB;X+ir{Aw-h zB?cCUWK*|NCV^ZgD?123b>=THveTn>#cPrD zb6!FkD_Xk)^9O!OAC>JVQIYQttz zTckipbU;)Au1^%i9q2k%u3Cd*%IjIqaHf$=3G#V-8ZtS9+1iwkZNK+fUGkZx?Io{+j@1M}s%g1G(N(Ls`!uPtpmlWc^gAom(P4iUOAk~FjjYL>fL|rMUYmef& z#1A~hqQ?-z=-2x0XJw?E*PWn4jW(wiht$8ns8`6A#~|qqCKG{(+UPkn4@0KOPxiVf z)vVDd3NcEw%C~n`!7~0pT6fgV4{>ff^Fk;aB59K84zwp3Ig&Wcw8`2Cw0`*W>{2RK}jAyc_9Fdgp>{ zeAAW&uS9g>sMH;3D}8t!oCuYiQ{#Dihh0YC?3-sWw&wG1)0Vj5`=?o$w|xq1(7|zN z;ZU)E)~#1*Tou@Vp^_Zoo9UhjFNA}2xfQ#1qgeGi9~})lp<0jG}r4bXCNnp z1|4Km&%Eb@I7!htiol9zB`4*E-V%+HR@lnrV0>Ubf&HPTi;@S|Qb%F+aq?+Kl$P4q zv>tQ;M3Cz62$!g=gjp}vM15N?6hY(QF+ItMLLmW`!WZ>rA;=3dv3B?r=|s9->1kN9 zxDPY%^GctvMiFSsED4z|_tL1T5R#Hb%&I#J%%f|_0tOw=31-xpTR=EnW}~bMnmOeh zMads-6`lw%BaF!xO>JxG*%jDeary-&_J0MNv{w4u4vXq!_EAFJkW$g0648XNwp`G@ zrwjx51(yN!F{a5mBrKI_+l(-0t2NHWvMhxKW7mu@H;p=)80#!ssF4?jdObD7Z7&JT zJwCjnCez~$>29D#F*=Q+_rQsIm4MJtjxrU7`URR}FkKyNEvJ-#V5Z+2hQbIbY9Nkg z1GO3?<-Mlgn`@LZS5>_VC1s=$YZst2Znp5LXza68r*UlH09Sa@ykTz_PPSLkwC$h^ z7cy$uf*&1`3b}Fe&XwnSM#dy!N6u!|7mT>f(Gzv;Ac+xES6X_ZKR1c3>u=pwCR4~b z-+_?IMx2$g>7TXu;G~nLGQ;FzVH5MU+ErCLbOZz31f}D5~>E z+K{M)Rat{=c6GWa92Z8A*}i5iQAaaeB_dvDEB!iq4!yPGtqNIvl7`c-TVE1v zaTZ;uCmQS7ymDM&qy+8+3|!>Axlu!7cRae=v|=F~yd zr?MNTTrNaE??9yq-#_|&mazW(+I;4URNX4Yg@}u+N>8rLGWpBoTl>o{V^M!m2mYWa zQS&o}Y0LapIXK&z8>F#b@jDRY4lYhb={%%Q$OpwAQ%Pj?J=4^^K}yPl!*n%06fPeG zmaI-ux}b6L`XA(%IHFE1q**0rZUi<}r5rdS9~na}WKxWtS*7&%!g`cZWzM%G-hf=8 z2@O3qEbE0Tt=Xv{e%|eaD*wdM7v`ca#f0nshe0t2E@X7Asj3*lNr0Hrrw4z#;6!N& zs=Pflm9V=3SUC)2ZIZbkA&A%@x}<_fUJ@sA$Ap+(`k8|c)De#UUtdZZC(o&kYW9@-rv%sY zW?A)d7^m_gj7?%P%vOuc-D86=id<>cmY!~T&Xnp^gelp%_m1F7@E_8`xY9nqfU-MR z(eMLYv2nV3=Or!K1(frWmNxptl9n|39cX7s<@LkGw&xRFKEnUDtk_xoz-P*6%;rMh zX=vkT{1>28ylwp97EfXI3k@2LOA+th*P}Chk;Mfg%jh4>MvhGUs+^0Ns{A9ji=QcF z?n@E&Wny?8sZ$wrJH;p^Sm!^RY}(x!KRWXe$fLGeGfhb@zuJg>HntYB$d?-4jfyo; zP}2TQE^&pjK*FLR(fe6XqEz>IpYNr`y}0_hqAIf;kMI*)i(iNj1}yc&ejvY|ZN39h zaOA!zL~mTn&@AOOmn`gxtaHhSwuCb1o>eLCNoe6R519T8L56KW%C*auJ1CDau|)cA zQ1#}x@8COI9%l=NMj^>JdFJ4%R>&jadv}MQHwk@{^aaU~%?s*GHU1E>vz?KYXAAf* z9JvbALfcenVfo}c5Dy#RA=mNxrbqI6U6M9Vm8XXrIV6>zjd5De&*aj=WSN9Y)!KIc zNY(MDb)d8CFxX+~`{v3Nki7EJD7Q>=gidt+_+kqM_-NjgmNXmqSpME|6;5oQy8Z?5 zBzau_YD9yvDEE9U?F~fRYWz5GR@9a4d_RS%4vp2t^+_0-rZJb{_fOFh^nASoA2DhO z22CRNq^uk|I>#MCYXnkarJh7_jOJavrUKQzylz0x6n3(=*m^&ejVCuFG#BA^2l~eF zBUU6sY9C}xmB$YDMLdJ+heB}$n|9D^-_CZIou^eU)Eq<4B8B7~g!hMH7vo%;bd;Q4 zF7@Fwx#-@3_7`Jcy5G8r1cuW=*bi4vYFTzCrZKxDCeQN~a17O4DF_x>j;g$fdER_u zTNA-D5|zRx?(uQj`Y7d0v&f&oUgSV9f_mLECV!D7y z!DxC(nII}Xe=L$dWejk9z}Y>g1Qp6)F>Au9&v-zuN=j8}uFIcB`(n+1<>HTO>ohbw zO3zz0S>RCf*}>z2!9Azf+t_&L%e8fatXRvSa@?{4j+@% zw{~!`>i)E+y?LWHawv7Uh`R~5oNME_ck%Ha9%8+z8rmQrv#)!w5GO`P&zH0PsZqMe z$^o{{cKEAwRQ}o=LCUAbJ-RXJGPdy%?rS@gxwG{{Yi1A>vj%1{lQ^hGLNy9Y9HodV zKZiDAF%j|yZ9pzXH9b;F#_!V}gzKGvLJhZLgTW~usjEvMFUC|WE>2i7DP#CStB~m1 z@b>CnWb%?zp+Q5ozbX9!R(!|t(X6}LO>8(`USPei($CI;XXC(_(i%D%U z^FQCYxXWxj7<|NA6{ug=md(BDQJYq{DVR8L^;{C0?Dy`_tH0@8p=Ei++%&Xe=JfKG zFD%x0qjAU}?}p;d8q9IbPr4>~ZEW71JJ58Ep!8_)_N?Omu1K!4dn~$T8mr|YTy4@^ z@dY=B)}qr!T>I@iRlweE)&Zkks|L0wTHX7$8XQJ0V`JRB<}}J-^syOP=URkS#ngAEH_GielloHM-Z z_TF9%lF9D=fa2#eZ(=sVxEy~;=FPoIgdbAVQ7FtuL6!UZ9xy%O_b=) z%?(n|j_xcMVYXnJD_Fk$jblQZ65Rj`tND*MQBjSE4QE1`va8Y<(PS zyU(%`f=6`kUc(4DK~nI1V&Ydj!Oz6MyZhKYLS5#$kfVhXCu==g)SuGpdB>7E$8ju@ zeu&{zo)w>}p1iA-LYb`TiLz?R5zQXdesVH|_TeJf?Nx ztCse)R?1(-IJG|28|>pmeNIT#(_fE@&R6;nP^ea8()f}+YmA!{zB625cJiq%V6+@M zK$K9v`O{FkbL~T{j4J~#qU1E(OQdc^^w>iEH0+~0=5_mY(@LT53jGw ze-HfqTpI}+TpUrOrgXW(mFn(N+ogvH!Yw5#X{@wo2mK%=gywi8b+yl zKJus9=slTba9R>cOz<)HnlVz%IUR?UMEVE!SXKRgtht;`c7vKkMvv(A7p~pU(q2^4 zJZNGd9jhyadJcn@MMjR?Ub2u$nkio}ha^K%nw%PML-z?KNe#kNk+cC+ zc(AKj5Bx?2xj)Uji#4BIV#bBA`LW1lAMD?k*H(= zk&KchCq)IxiVA`o5ZSNY`~UAd@4a)!Iq#19&KUQ;e~hBSsvcC;T(f3W&GZ|kfB10UR0%rIbSY(`%g)7cTVR=(Xia) zv~oSU#Wl(xxv$^y72udOPE<+DHmjjI@5)Vaa)X@*d&C;8{uu@CRSAKSbQ7_T?qitaA>nhZOfe{z4{)+o3A z%T;e8Zq~hI{3SbG!KOoZJz_?pes*~&PwMcU}HVv^eeI`Klu9Rjl0$sM{*twtOIYpVQLC1Jby6o%;RG57p}URNEODE(wvmV7q>}GZxc>ITo*VV1 zhJ;^@-{)3x>H1)UM@3=f{)>WiVZz`(Dtk##${CbtZ8>gU;7-i=*b7d=y0^^DW>-z|AwPaN;nt z9#db5GjgBNmX%UT_ei9I0!DYH;!qEs<>?4yNShQbq6%{D(Xws6$E-RPokv0Su!)*q zaa~PXHEw$D;@-iJ7aE~;_Z3Dx+yM`Y&t`!B3m@?iWj3FN3D&lB*rPE;v2yk+6=|T7 znUx}$0s-(MyEqokt%<6kgq?ztfGKnJ?X^5;Is^Z1-){4CRh1~ zHIG|b#^b3>$+gf?O}C$oE(&aIwWq%E*L)IFWQYbA4FVXBtp(1^ZxDz zncH&gnuO7x7CYm7c#m4eS&NJ!eZRMx=CR8jZ{)ACElREWe-cF9O%9?KYkY})V<|WK z>sh!3R#V)+`5nVyP+&Nc4)04Ej2vHf@l6{1DS^qfHh9{qC}VhcSN2qZjlU}oIW-+H zBV(g-WAvwG(u=va8zL)xZPf*k)Hu}EFuhEf$I=Kbqe26{vh^o=VW+Ce@9qheZ7vDp zqp@s)7F_}@5k@s459i*FajS?MBiKlJW?-{8X&K#_Cfz5p^q)J(X7~)AKHI36jKH1w zBe@yRT&}(S#4v{DpJ81P=vXXTD9ux#gKXcO_P=slxM^03&rd6xa-dpai5^CHD_CeQ z>tWw?lN4@Vj=ZF&j1eRert;`{nO_O6^d7v5G@j?={%J5r6x&A;_rE}1wT$$3df1|C z?75s|A`4c^y2D0pp)yj8oSerEUG2~O{qWnv=y_#@G!#Fg{%+#P*cv zFP>?6nm4FKuuOce!6u*V7k?fMTic%iLT}=BO=qM+iN^Q(!oh8ML!%;@!m03^aDT(e z3AbH3qx+UmS~{3j_})uhYot(Dq6_gm#MC9CBtN#Y6rMGO8P+|Bdzl` zE~#-L@on=uiOyc$jow=Zth(+3k`EI?*2kawD?FZ9zxF4<&g9!$Q2BYjh^|&Wsea>H zNJZ%=e-aaT-p^op(#-ZYJ{^)J8E-;eVI3c0@JZpCZMb)Vm`RK|e73n_5pKtqWKgkv zp$H)COhnh1BH-(9VQ>ug8=mZu|OSN`L5GL9YJ<6;{OF^Yzrp8nb_1yFKQ* zA(2LJ=DR_qVeh^dx@>vn>Q;5`OwF|10{Ieb(tkI36t-k_@g#Wsty}&| zLBR!+K#P(Nh3#@F&zhHFz3OOf3(kuuc9a z@8=FTpS&m;)tx%bOP8TD5|9Y=M~-g7q-LCVN^Izi+B~jK-81_X=@T4OT5Qd$5qXL>D#kgNcmpGT#K3!fU9z8Fs)_R@#j@L5 zd=);$$gt}~U40u(Fvt3zw zkp6kAjO9U1HXYICi0FTw?R{5!Aqu>IhXOQGA{)N})HwEtH^0nkBJWqO}RdxHQ zEgI{a3gtDYwP|CAGN@BWnmlT^uuX7p)Q>s8 ztdWikrs3E6ihE_*8h&qIcA9s-^N-~PNhIp>y`?W=;tKFhir=ThX|OhtPitg|6e&!a zzi(9+glGaSa`U&|6>d$e*jy+GHSA<63})7J_ZQ@{Dl`uDEgGym{G3(qESC{p-m0qM z>K7+oG|TapESs{7{Hi)1Ob6X&CdE^NT1&3$B-dx6kY>&#YI0{=?aS=Vk+Ft#q`QNM5`X3 z_NBesRD~XfzsM?;TE0>pbJ>gUC|Ba_!kBlvp6%4!NlZShoE7Dtsu)hbK2^5On$ZfU zPVOl+8-C*TmX=?3qHuGn8sTWPdq_6f7w{`%?Pqk^rQ@~tZ$3A|e}!lBa6fGt)HwcO z9e1yL3_-iN?1DP`DTY&bbdS6L>0dwj-;^$^ynnI5r;8-wf}&X=GqPo@CL_DnWg+O) zp;?-8J66E2(H(_{Jr=CG{y>MuJ0xF0*9*t&@aA!l*fam8ZXLw4!khEhLcdq>Il7lr zXHf3qOjTljdeMEdAXUSCUmJ_Je7RQ{rhM|mS6da2IW-7x1hyFt?fx)ZfBSOs3tFbx z!&t};%v~cagDe`-_=R2Q@qNSRbU%?>+Z)RwEg`V5axcDz)^g*jD7H{Bk11WmFR9-x zD5OaEF5ZA4$J1@8*8Ie;<7f1Lu_VEM@s_y@<4FG^%)(7`pUlW__GyT za;rDyYs{<)9?oscOJz9UBU>&I9r0w!R7rb*i$c)qA+SyMVE3Cnd};5hz>38x1MQr6 z@wAq|F%N6DufIrpiWno}#>VFgGZtYIg*O&joq~HZwfyb{vyNvO$WXsl`Q&h5--tF2 zJ?i$xV_u`!zfH>EbxL38nD~_1#bPR1KrFQGS zM#!!R;Ofr8EqYAe2zNA5M$@J2%jJxGEdpQ7Qr3_aCZni&B_c@c^u{N!Do@`^Ff3vZ z{v*oWaOPcN;dau8X?>4UxQ}Gu^_jU7OH19JjgWOSG#~ZuWnJL%Blo84amt&5mmjO(A~Wc1{3^ybu69T*eArGW^UuESNYnd?r$wcHWa6#+ zAuc|%H7cYWktVPv9B<>3BGlGT)cLm4(JBhIWwIi^Za=QQYt7pS+V%S6Y2uQ_L+!8n zS!_qV_TQPPqZzCwyHqvZ1Wz zpA5Ysauo^<;>r8EZ}328JoEmv^3c#EkGa4G#tR&rx_lT{$-<|Q_9dE)Uol77@Y_HP zsL7xJ^tyUtIRtMUNZ>mVq^KhFdp{Z5M0MzB_*-PpFBN4p7BFB>oF{J4U!S)6Aw4Ub znJE|>nK4bdAm8t9;tb0|nyQT)oP8;JgHUlxHPo>n%{V@F8I^SA*7w*>B!+>3P=n9wy{84Uw$3NtKCTEP{ zXWtiyM4dc;nQ!SCrL7a0&qVNUJxR~`%1qoG_@!OiRc3;Tde-L*{ioD9!%NXM9uLP; z<==UfzR1y_28SoyR>&bSH`+9}F@eGSKBqUqCqfOLX(})o*!XR?n(bif@&bW(y8?q? zH{=Q^*i++~;Z=$!omVAHlZXayx8;i{o;+{b_|XaH|580!m{Ho0J?AN92~e{|eBVnP z&b-jWFJXsSZWy`Gn)1pyN(pvu8W|W_EG}7ixHPJiT?A9?u&9@EZuS)s0|f$Rk->wSSX*I(Z{gL?Bj6t zGtD>S7dj8JbJL?1`!k&LmMrW$FxLxbJz*esH~;iIoG`lYGP~7ogTtW7cVOD#wmuOL zcm7gA3i+Z)oLkFAt3M4NGxsJRI(8*ayml{l8TQK3Cq#9I%6q!QtvdCMw{Yr~mse(G z%hd|w3l-zPA;vG(Z@>DcIm;uyQC0Uco8u)O1NKHhXCWw53A`6TI``pUkN<~g@_71! zyPc7Tm+F~u+VX(H#n6-cLsOi`8<|P-qZY;dfo2u2L$V%Rw#x~!1(Gih$qr7)?x zFo)5TKgt)kyi^DIb@@(0=dqJupEYIs(qmD@S_~IQMpi=KWqpvk5%EuT)1|$7cr0vM zB&a!ziT#>Y)a9;>tb+Ns+2FejzyI5_^Y2*+>yLTGKjErJheV9iTlCX|%(DtJes{%4 z>+0O2>$*h1V2Z9%zpTOktBj zrBcIbGN-KAqn~!codXAx&-c0+;vOJ`vm9T}lwUrT!g?2Obu(_Xv`Zy!_Ke^XRS$bd znCE$jHXut(@=xl6n|sU}E3jVD)d8_G6{-erLS_dxdL7memzo~0Qas2o2z3>@$AzBU z7Pu%E1!d9u_gI4F@6~!PJbmf?r8<3#}dAhSDVsyDDYc}kD*uLGa)J5 z9o2}Kdo0~ZdRxT$Y(KPZrA)1nU0^uPgt5~xM*o30Od+nwdsXl4kCUhIBRJM8WHWn@ zj$7#|MagbwPZZ3NlaKat$|~QgT$vEmv2PB$7qZTz;5TM}{m}G%=0wK#f}^D}>6fp_ z2CBDK+p1u(7#L}H4>I^V)jHQwiPYs<4$|-hjtZ{rPE<}#=SjYsPXzHBvta^l-FTIYJ$ZvAF7@KwoX2jyS{o{z@RQi30ILq zUBe+TSfar3-Y@=*WPeR%gS*|%M6KtK`da~^nRBwZKdw7&o1!{wV=n!q_XpT1OqzRR zR}RZ=R^M-lS8#q(de=>5h|JBesJfRaZ&q1d(B&jnL8&0R(oXViq5iwsOfmz7Ea;&4 zHKVG`lLB>0(MU2+&*2~AWF5)H={Ii4l9q8|CyB@fcsVd~s<=uh6JaYkt zWf&|-akTxcANf#9%FUm`RWm8yEpE5IQq_GPMe`{({UEaj>vKtf#2$81FP%O-YjF1c zZJ~jc+n#J%rG3*%5v<+%0JT*(^JChkdOw`*n^l*R639?PE?lw7=5>+LxOc;^D+4bj z{7r19#s36;`b6A1FGkQff!{pKi=jc>x?$3ySDJe{@gtiNE?{g13CKlK*F zANh9Aw7&PY{n}E0bLC$Z|C34bxZ>__qK8LJ7t_9lYThvDmljH8N_5LVvlk@i=3DHh z?^XD0%5I2iWShEPI34IZ;HGbUn9j=&M~Lz_e4jk4a!-+<@!OV|TWskpH=dd>Tc5e3 zLfrJZZqbmwg2^-Rlko|zo_yoJmlu7sgH)S)b~mFmU7v0DMkSPgR;+&hGZ(aNOfiJ1 z@oNRU7!;e2ibc!OJB%C&S{y>Xk)W_gYg;!c8LnvD8fh54_GYTAyZI_)H@)Q}y@pjFozxs`RcmvF+znl0L7GL2PsAg#i=*}wUxKeH zs#LhbDK5uPbKXfhqjHe+&d>F|F?q+ck(^A*jBnaMA)KtVoj_%~94SmReKz6c|2&Mo z_ftH>p6#@`cOwf@LF@ZZ#eX0t7Ph4W63t%5_{70R>08+Xl5~SZ>(;ql`t;24E-nXR z1`hoHsj7tzU%S>Pwu|Dv*{x%?oAp8eKY5Tzv`qz%AYSnkhyT6+N(v?;Ueo>irY;Bp zprkRT&fg9Kr{PE`;@v)UW1oprz#9IaJ`dDUe*)F-Yy@_I#$-1hbIRAhq|RS3b0GoP z5}^*>o|I96VmrvNtwYAaT?-jQu{D^nE`bawiYhy@__kg8D3VYD6-&%y?0fU01=c7F`j8Aj(fa?(1Un z*FFFZPI2l|yjQWDyHY7{C_%S|Oz^)>7~RDM#n&og`Y{^Zr49&#EmRJ&IkAK@JEwZ1(o{==Qs>S zRk!jc^r#LuEgJDEWsFWF=gB&jgHXPnX1eE7LYG(q@>svyeA-Zz#s>3+dd!SW@k%a!D-#-FitnKmzO$SS zF<^#q+&&TR2vAJnrS$tE9nyvWpmz72cZqVD0>n(5&Fm~0QoWuUrAC5u#3(5DSbVwl ziZo{zD4QVUR^(guO;rs@ekla#exXJ6++31SUuu8Ld^vAFGJ z3T<0Q^x+YGv~_YBMg4vbQ)Q}|6@0U{ugF;IN$23)phIRFsfN%J`xYlZvtKwI3kqN| zSg4{C#be58 zV5c9{Q&hAYQqBrha?$r^GOOVQ5Es?ja@ZCl9oojhE|SuERlWsQHe#`2_!6L>M`~Vvh^xP)DHmdK4`5d;JYWXE#VYKZtUn!o|L8@u_SF3FRG#~&Ej z&@d-m2EH6~<1-jzsJ`G@oRk5x$r_qvHRAY08pnvYh;TV;HKV*p&ze4{>n|%p5q-nb zq)*nM`)d2HxmuuBsqJ63#L37UK^Kr9N=9c6E;nk69Um9)i$^(OZXbYmLQwA06F|~J z6Z;`C3H5DbuB^0-QI9&t!oZXzk28Y-xcqfb4$df013VHx*EpjEm!yitI21XXA`NbW z$m$43kAKNsO4#t4UzMAWnf!NB%&vV0hv`Ehs%D(-O}4CFiWc z&4a!j2X0mAi!4wAV_HU|`WW22*br=)@-14D@!GwnPCUY8J-Zo;Rn)?TJZ|Z=w^cBe zg9Fu2l^P8=GmB_8T^a;_8mY1tP{MqW1i5KA1W<0<7_dX2rq3+DsF{@{V&Q(SP+VZN z)ADW-mrge$SzrXbWQo!*(&cflel>##4XjUs{|QLSg+_Oq<334^SXNcVuqz85uNxvk z(=^mZ)&LFq?!`N^5q;$R4C>k-c z57p3&CanQO=HznK$jB_q3>E^)#8mrA)R*o%rSnpul7uOcDr>pnq=cZ%D~a4!AW;!?}7mR+eN%NVaam42Vl8an5G3eXtZ&< zu+F-=j^rmzpj0`}$0%w9EEUX14vsfhJfFjIxRc*n;p>~yEOi#1uHR&|7Q2PNKUeC8 zCW&K&kAJ7Mm25-6AY59pjxwaJ*QIfwDKPmuJXOSf5=19Ov1%*t;a~a*PWV7DheD(+ z;M0)a$OHai6DKH13uh@x9!=GpscdOU0OKzuOSMqPnpC6Gfo>~;a4r>TaWdf+3pn9+ zSr~{_vrP%{ofyg6TqK;oAdbpCZzl-Jt-;Ev)KHe zGwG;&_kW?Jk4Xo|LHqGRX>KO?W$G?TLmr=$F37Zv*Nz*`=fUbO^fIQ`foRf@q>v=D zz7zWaJ5TYHE)T}5pWRma`1-G`76x+xby4r~3*+Zo=lI8dsl0$ytVIQ=ghz9-XrTW|2>cjEjF17iJl8`$fw{1A;5w-%`Jsr-*Zsc2MzAyr2F1wZmta)IS#?CYsQD$3CVvSUw`sflo_oF^V<=4w(fh67{N)ILJ}j-?*Y&2R=kdY zaZsaUCK{qaP9pAXo1G#}#g}gUwnB3>-|;XmE;l4THSbaWd49!$Vg+_r*o(yZj^0MC zqKLGmQBK6)EsOQXK|Jt}L}tGAmrDw=xmT1*cYYoZj)`aDi7W^~F8z{EGRy6PNU?n^ z405u4_Bie&tJ1!7WBHW^sk}^mXjIUso*Yx)mxa_9gBa;YJ^>uu5*A{Vt@ab^^OzDw zlG+nHSp*e^((abq=>xfb_Rh0P)I_fcG8TC8)r~onNM#qjs6J^sxxf-Bd=p~yM2RQ1 zIp&e*J9fSDpqD*f>~?tp7|G#FDh8BBtqpN+=72`EQo$?eMA6t}m^h^maHOQj=?`vh zxViXCBhh+Dy3`6|lPj|1&=3uMCskS2G72>W>F3EmLevQp68Nm1TqJ)jIOt!8^8u>~ zh6jp$gw*mC3d}3rZd7RxNx}N{_-ZA>alg6O}1& zyq=EG6EB_;OqwEeLPriv$mf^@dE$r{U1rTP9|`OI_oH|a z8I;?|oD`c!VLHeV`@^0;U!zR!bz`v_5F4>{(oy}|$R~Irf)Y+Zx>%&TufrTHHUj`>_vrftudj7vC7RSzNCvu#lw&-b6 z6C!y~!ey609fHRcA~t-j($VKpee941y4P<-A0rd^z1heI3w;8sUp{90H%Y%Bw2&tt z{@+Q+(b#F(3Caqc5-(i%HzSl1ZjGK`g!u0#dP^#K(2F5qtYs6Fh33(+Vmz`Zjfu-; z%{^nmX=|gu6dMLgKzw4{&_w9g#LR~RQ2bf}Imvp$h2u$FyV~oysZg=8KTeYx+7?WC zlfoavc_Kcs&MGZ>*QjZZABn%CV|SV1(}`=~2~GC9=D@FtPrebHZ%fi$ z&E(ZkGFLJu`kX!da@w=(FBirgaIb_z6apb(I!|;qYsiUx6ihyYDs{2bd>*150L*|H zriU+-x|uvsPO0tDi?zp})d*acx$+^bhh+ zy$c8LjbHsS9jbVXw&Iq@z#yIn^x%e^eDU(U#Ql?~KLOCRp(l;2CmA3B8+@$OTaqxe zsUYP6C%r$VfY}o$45=`6>fiqYqEA_%Pnm%bF$TfZM@Z`%WUmjTi?$hFs$GDw;*;U( zUgfNq)}RuI>#h6FDRDvcXvTP%iy_Sv9nrIxNP~SSGM?%Bz|DzMFfP+&!}u-wl>}^5 zN*#mxQZ2UQr@W;v_5{a~N3m3P9sn&@#Of-gkEJ!nLh9@e994Pu!Vy%Iug4 zPscb|CWh%@)%y>L=Y|+-N8=x`LJKeoz+tH@(BUC6nc*y2rYS$D)rn(RP=x%*b4n(} zr!bYBYkQ}}n??)WN|STfBokyvG(jMzZKWANC)ZAUI3!Ra6rJE}teqaa1(nzGh)L{T z=z?#z$MdcKk;qk^O6za#>C$m#!M*r+dU9`F=JD|5w2hJb#jJ@s-+)~W5T(~fcwj^- zMu~Oj^$Hk(TVDq4P11FiD?zPiz%;S&FI)}BDx>SX^-ii9b7-#iF84(4bYZ zLjI6xdoTY<06TCGM7ruRs~wNDtI}bXjxE%)u$qoe`Wbz^0j4gc`lx(CDIDbP zPCB^N#$XaRFlX1CdA!b8O~7}NmC;t*))Wc{195>dYo%3xG1DH^IUWvUgF6HLVc$1F z&M9=v3^H>xQ_^0s>sLtNJI>-Nj~X$|*HS57&2zUujX!?9zq$JTo{}Bb$DPzeKL*k3 zeUJt|20PKAs1POz{7%iT^*B6p4?$J#_2FL~jT6BaHetVIm+{9Lk}nNJJ<{(j`NfZh zutme4PPa51QMWg!BL)(meji0?v94YI6=d&3%T>uj(5<}Eb%oK(%UX>GrdxjJxf+ss zgnb1<;?tJLF!dbtbdb@mz;RRq{~=ry|0MW`VD3FnfUt-05HIt>B|u4ig26|3b7CUm zzMaXGy(fbmxQoWc$A=I&pls0^EcTsfKi^$yg~j#j^0#l^eV@9JKWpo2=v^;g+43J| z`K}S^|9hH_x8bbET+!{v;V;1;$HvjCD8gr(BVIR~5(`?Jkv|8#KG=idmiu5BfdS2yth zSvY1CaOkN5a|IryDAU%Fo>c%iWzBtNdeu)%8XL@~3%S=*-HT~1y9hAJQiMkYdPK{W zCJKE=KylXR{kXPT7Q4%->};Pl*bM8}!;6>ydW8-QNK!WKu{CZJv*ChN~J%EF92W@us57 z`KDQ^#uG#4$IbK?3JBjZX^+GhXIOtuGO#2?v94vvX(*ayl#$rqmABk=C9Schy*~Dj z&c)H4#sn3+ge7RIhn|0?I|rhccrP` zuHHrG=`10}`T2Y0g)%`CJ5r%kG%wtpQJ1eF0htsdE2U+7(&@8@BTp1~V)8iaLnc*FyS(nv%1e5yr6|sU zaQ(OQxm)pF!+9kTj%J3h&t4f*FhmKyMd=@k@ zt%>Rgo1*}8FkT{y@V+}WHfTJH!I`Vdf~$D;o8|=%2zsBLw6vw{T^;W|vs8Ds#2-iq z#g`*}y&;N6rW=VOy+nsOOSGvXewG|T@6OJjcVGW;)sPDb0UkKZ_x{u)l*RPCqaZvz1#Mbddfc zt<9nA#YgIbJwZ@=>a5o-iGB6bfbmTHUIzbxcwX?BMAaF{5k4THw;U-+ldkees9}y$ z?CyiY!!Z$cT^gJSH%u}Jn0%F)B^o7#A%kEYYa7JcHpMh_ES}v(!bRXFB~V8X2kBSi zchS_~3*-%!@?>+N)Ne>4zkR;z$zehB!cpdO4+W;f6{hD|wMyW(F z0|;yG7^oW;t+)GhoDIl>B%n0!ye7(#cqEw<>{$chgCyXfp(^+m-t$z-+kz;m(}sV- zrtG=uBPF60=J}qyT6J|}RJ2VC(A`i6QgW$|hW-TbWY*T>TfGW(!qnx$ZkJ&NSCzm= z+8sk2!J!jGG4@wCu+(u728~Fd7hwXABg7^`U37nGSYSjdr6N+TE0Eb>8$1|v<#dM* zlnau?yHnUZQ9aEaN{O2USPn}F$rV~oHP*Fe5HP%Wu9fU*U>6fApzkQ9j~8)^>L}(< zAj)br8ahn7Jf|N@1JMjBZpXN$9Yqz`b{W1>%(*#nG3H%9MP%@8e&Q(4!E_}o1!q8j z821tsl^Djm%T4gI&iT&O3K3$hNAg$}rw?y>;aN@?$bW(ph}%c%c)eUCt1BGZqU3`_ z#)?aT##2XuR&z(R)z(w)FBe}Wz83t-{>OTw1Ii`23#KEG(C@OnyR^HLQgM&VPj${k zI&rF<22(y*;^KL!ALa$ZK_D1?;m1GnFuN4b+Cg#B;hBaLGmH_}n$=V?AHE^yCQ&;4~Y3O?w^6g$g1Pw2_iT6oI+_1^1$)9&cs?b=dihrhtsD!wsBhw~m> ze#jK(ucmOCC!J}Df8udLe4Xg)z;S-yj0A|V^rBaqM(}f`7gs9Ly1cA|N-Erz{r#|` zo76g#<@wi~EKS3fmWxq9j(Oc`tNZfLEB}U$8^7uJD|$eIs~RBqL)WyvZWj{UBN1*+ zt@Ff@lbe&do%cml@#_^q^n2fd_5?CR;_)Amzj_nZTgsHENzQnp{sc_DMd#Ay5TH2F zWgJPQ`MTB1Zsq&ZB}4I2-4G3%8P>>}xCmp;zh^(YkV-BnmD3%INT|kf@?Qt&(8#pg z#PxkJlQkOAMl!Jb^SC$K%PH`i@?fJ^5Mc}^kW9`<2Iccww1hH%hfyuhPvIMxp%x-L zzuBfK%ByQ9B`Aae4&XtOq(XQ5iJyN~yNNtk1_nj5Mhg~STx=>yVdAq88%j&Lq*15n zmFzQ8kDBQiGG^^77V07#W|g+1kt8mU;0IcV-Iw}BqUprRGa!7cw@s{4wHprp!S=m; z2vlqeix@yLWUKCVJHS*tox4H^%_C|?b=D6#0W2RB2CRQN|3;)I%|rti*`rwXC~57j zOT1X9bsS}fJ1%U1r3;9$yQ>Ei0NFp-y&bOM>1P>bY;2u%D|Xwk66d}u;&eq_n1&?s zF!2{cUHZ~_*nOna*qfoB=l#$JKZ&O#wh;|aQ~t(;zh~mnpzd{{qXuB{Mep*&)nhi548dj7OcM^ipAx+mpJqXVloC0#_GgA27iIB>Wh9itj;0 zN|VW;4OfqkMe z@v-Mye**4*t2=}$vYY34bp|}%Wh$I^7=y90G|D~CWm#%I$FRiTlKf_Cv4f;KsN2_2 z|CI>V%lkQ~2N&sEzo&F9^tV#*dz|PT>w84OaUo>|$V-tR$vj*@m5&e=UQ}^Yh-h)o z8M>_>dB)b0a@J_#c+X9p$VvE+{=G_?_2fRomZjpwY{b5p z9cgvMKPGWW29@W>gHa%E^U#V0`9QDa6|XSo4$C_(01U6f|ANV|fUdfr?rMYD0Ac5Q zXv~HSwMzLfv&XzTrcMig#4CU!7kZkarJ|j3wnPnSEC1%d?~qpKs0I8=IZrBJF(wwp zenMYyH0gI2cdgwi0;!D$K+Lg1&H1232c5A5EAU(yCjsy6+i&yN0HP*wZ*@hdU52%{*`#7Ro0As6C&ik6bf3Sp>S z2bbhVs;A1VMqXvK_KIR$qM`vMkTTYwN-i6;i1UZY8Lw05r)G`)!YzOegW8i_g%x20 zai3|kB2;iy!4t!`tZog5VMIJf1TEZ$;eWq`)60L$+VkSSrTXuU&A(nU@oM?+G4s}R zuo$T+9etcl`Clmk8lpJzh^F8JkibX@UBklIH;F8hwCl1JwwWe1FNGo4Ui;vy% z(r!<-NQQMt72Qd(0E3XS70~Mv=Tyn{?W{B%U@w=RC4S4-7Y5rUZrnZr(XER`m!4*8 zcEyC*-~r~;iquqrw`(c+yUV4CxUc`p{@2i95_dCFv%)Q65ByB^ed;&0MFN$*T+B|ymXR*HBy>a&wa6Yyx2R4#^BSjzh9q8y|j-!hWlBt@?F!NVdevcf41P-%p?n?0Gj zsww@^f#&eVfsN}&elnU93@Jms5e{MQyc!?`@Lp1F#J>RJ6+6vasu#tTUDc@NYcWc(EL?>mv z+$1j$Y3o>yhcgrk4j|E)GX=WLPmHPyho%fx_&6tNE@(}rcE5QeCi@6vYKO7b}llXb8OS=5}FN)XH#+(WJG3n@m8 zMvF3X$QLm$?94^Q#Y484cZT^u*K{3JC*FsCIR`62?%ky0;(B=n&5WWgRU~j}gTFnw zjSUjIdEdmuB7|C(w=8pB%7rN?#xvI-GBook02NU-kEXye0Vy4qA3`{tZi`UBEMvKV z=W%`Jv;k%JHZy~psQR0^v`(;k*NWUtV5pi8sA`36E~L8<*t5D>3COmlHI*=(p$=dh zx(n=#T_v8As(D94Da4naUyhctu5y!Yc_o^t>jr_j+x>7Cfw-`!4k!hY2Vt2uz4KDr@O##miZ4 z{kjuxQq5hCv+>O%@Ogv|o3l5!{=pi}R605$lA*fB&|BU$*{qcZm4B_IWWYv-V2*8! zoQBUxff>@ou9(qQ)+_34$bj)4xytb;(1)HZFBPZl+P(2xmQjBsfuc5jN;x$^ksUqf zv6BAIGT#D~Oxf@h4Nl>*tRBEE_B!@3wqiR6D`&Kg{-$lLgf=_ z06H|ORwpKolt5iQ3eCidbLW72Gy5n33AW4Gj{;3D3d5SY2YLuV;00afnx#0 zqi#*VZL&TqET#T~qU0A?EnzR=UuP)Pg(zs|Y{4xf0=_ET?X zI1&)s3XFP-7FvGBGTdNdQz=tP$q3<#HK9P4q@?``JS3~Abc%)PASzkNi#{|qxMGJt zAd$e1grH+E8#$w`ARr^6V^ZhJVwi8%qv^G*iGC8COu~*#OZ$P(1Yzafz?)K^qY%`@+xR4!E8DR*1r{jo{#B!p7zXzf?{tyxGVF7y+d zWl}DFQE7m(M1#e-%tW!xWg=7m13sI4R@fqC;lr_Fj~)Z;qqjUl+F zMcE&hd3zfXPG=H3N>tVV61m9~`gQh1UqjZNLkkj(p^5*G0x1ZM{}afMVKCMFpC8P% zI17oGk%1CfCPn@dFZ7@`!p^wo*Xe%5z>6L_;&F2)q8+t>K9+~|Zd2;RWIbI__dESV z(dqT#n`)NKQ`e9m?6Wk{0V3>Ze=x#U)GF^INJOuy7~lNJso`jFsw@nXi$Yd`3C#=0 z^pg=3O!C${I}dJ?U2uu*b4*d~J}Hh;u}XO>mf0+UE;@P+sYbdMGL+nqlO;CvE+AWI zXmy1oIyr9+Tk}cS71H`k5N|8D z@yV|~ls5r)mf(Q~m0WvzJ}dl3@m+&9vc#kQCz51RU>wDz=Hf3DU~%&0U7D&a9TgdT zK4XD3@V@TqmJUx8bg}MQrhpWvD#8ikOsdHJeu18$9lfC6hi94YPt@JEE-|TtC`p%U zHhR*a6rN9Im^yC3ZPxeYH6Q#8bKz6fq36$c05%LdM}C^+^lHTeP3o!B)N1;mKY?$1 zYk=0%uvs~Gf0xz=$T5M>E_?Q+dN5N)zs^0E7s6Vn)`QZdRX~*DTwNK(+*%jq)1FF# zjEhhl83p|dbilY1pTN3|Ss2Ta^p5M) ztI`w=u2?nLzgP5lbmEyTU}w;t7bvstJ~$SiLoe~aPZ5HKhqC+$Wd5f1pFp`SJQpPa zkR~XG;mRA>NR)iNK&jBDU4~ao3dE&j!^b@EQkn7wdUR`3y?pH$rza#zOnfk@luE(( z7awx@(!8N*2K$V2$P&&-P6ID?@mcS8Q)wkPY^dn9TsId+Fm7Z#{G-SH>FKxIK`b)( ztu9b13oX4`;+qhJTwy53P^I)c0Yfw>VG-)nOW;CvCIQiU)4iDLNFFb(c9x_M`?7E0 zW+%_bl2Eh7Ux!J+=?xG$WcmSsC+}h@A!R`qu98GuE$HwChbA=EcyuJ=`+JA1jN<5D zaxnE)VcFdnC}x~-I8(DffmCs<7sF!r=*W=lrh3sa zi3Mvtp`c5ULR(yvGLbMcsZz{X zK44}A_KVODGN8<)b1Guab622dtV^oMy(s>u^5+-mDU?z`evH#s=^3WW!?`uGnTJqR z(A2CoSQgzYRHIq7Qn@q2Vz3LsJ#!i6UK%N31joVe^~PQs+%e+9*n>fpy2IGgAQ}MF z)Qi2A>@!!=xd04L#W;o5NtE}EQGJoqs>L^Q#1Q0pNDD`6zyNV4*v5($vQW;SN@!x8 zC#mWyM~bN36HnrA+_3_vH?1-EAF`X(3g59fBJ+TZhU-0-IG!QRlY?O{&#@4A9n*^t z(Lxj#XuqXPNJ`w6r!8f?%Uv941`5D163Limrp%0ER{=_~HfB|{96JWLUioOCj)irH z6%=;jV{+L{Gk-y^y*YoSdXP1zTr)*F4hh4@Y;yI5Bu+48T5IT#R$lbL(pt7cWskM^WM6jz7> zsflu!8^mgf;b7QX3idT!yzP{VR4>$pYaaAQVnaK+_=ksOBz-@L%;2dRObXzg-L zR`UcvRr9c#BqZ)h_jIoE+yY4D714rmyIVEDbD%e1lMk1Oqmyai{vy8(Flw96u%}Q& zy<6o0?c`6;?w~Y58WVN(o2Jq0K4isk5DeuG));m2?A0x!TBU{c7W9J5@7{A+NNr>Q zEVp$WbcPy0xeZb{O~-{gvGlCV(2D6YZ*ZWdx#GLL0kl}fsy2(xx)cv>i~J3PW=PS8 z^#nG<#OeeQbd-qom9DzDQh%0I$BxJ{5R8#j=eET(-RxuRraDV?^(hF)YPM8M`K5H2 z$!W{=JQF78G<6M6DCrYiDMq{`NE=DMv)JjC(j8dB;uq-jc|n9xR@z%(XcXVg!Ixcd zuX*vtq#8Ll&{Cvk)0gLdt`aK^SoEBH3o{1d!5Cs9Mfuazh4g~VCi1c`Hwptn0)ZyU z{x>K`b1_UWl{%CYR&-G*U(CE4+^VXn*oW}h@GP9 zeiccS4kUjSd(nqh#2M^0njGV8@KNDtoP(48%SVOfxKy4UaRw3jzHHc=&%NS6^1PvI zXi;B@TW(0NZ5OCmU^%qq(GhszKA(hKqI2d^_~s~-0Wswahpz;dsIAj1C6W>ip~G=- zIEN;8OTb{1!0Byp@BhKxTR_#-Yy0AR<8H-@6sLHB;x5IZxH}Yw0tHG-3mbQLXXEZx z9E!C-ad#`l-HP=s+OOYt@Be?!x#ygB$9UttaaY1xE7|#F&SWJsGa*7z?4q$rE3WC-ht5RNOpz%jPUkQhh%9Pf=wpekwI1U4 zDc8k@9(OYm8F$12TK$6low1lZ zFfwx&3YYtzG(9l{k{_UqoY}oEW%K`Y`@4Cd-$H?d_XR>;`gB0%@(>YTBo1OFANk^; zh`Zzc>JVUZaP1R@f&`Q-7%VV>wE=|}eMw}f=QD-G=6zdFWNOr?gN z?G<$pozNpFpcO^b=R5;n;ReVQMsagehbpnz&ERh`?P}tKsm7F!jAY_ zp8`E?rVT7q_B@UFvwGk>LxyKfB3KCMb1Xf(z!r~!dFt@_amw0R<66H*pDM|XJ(vkW zii9dyu9{4Wa-c-p9iY=uuo1(qd@LSgI8oj*UT27*C3Iv?+2mryaPUV38L3Ny2&_<0HA^Ps3CUCf4RP&}B69!)El$40p}`&FuT z8HcGlzVg@@foVj^D+ErQT`0SZ4Hk|$*x1@8Crgwvtp6*@Aw1m1*rsMpzR^fy(_;Q7 zm-ZS}<-F-J`B6Cct~uO&>JbceS%+pd2980TXRDd=_4@oVJdX=F;E<|Ld)3wmt}U$6{XsaR|;65m6~hdL=GPTrAj)ClD# zNDi1TYVD|>OCVK^rk#i(2a8YiI)V|d zUg~yQ@_cYYMZnef%V8I_cOf;!f#>pNpf|zkZ^x~=?Ii0WqRhJN+!vhoG(;$i?BHR- zC4*3SQ&UE4%G!RQqYdG5NQ4WnLDWI=38gz4Ch$YBL;V4=93CNr#*(i{>ClhCyK1XL z*^srcjXnIPp{y-$-~FZWg4-+kn3{Qz;_OitF8 z+UWc@+=Agk9{$zMF(~GNLsf1uhKevxAtvl|U}h9NhbxuvZ|vqt_w)vi1Fl#}DuY`l zB2xp@yT0vM8ZFgTID8mi&$Wu41UPFFibyCF^;5-;mg>W`2l>kz zk>E^D=|FjM++duocbO45)Jd!mxQ;NeQnV2!7F-HSEO}>XElF0}>v&;rH7##K7)YdA zkXUHOTP^WtpCZw=E+uF{C=X!r7vV`{#6{3#9KIG5L1F5Ido3%T(Ur`qBKX2c+pGB( zSX7jM)UOYde{jZ#Q@=?JZEjA1IeP|l;^t|QDbZ%`KB5hLh4L6tu)-klt;*ayZa30K z3bUf8FkvL%oPx*2!WOUCxuo>_7^R3(=Og1~zKtlxB;lwfmf@W+jF{u@=HmcZZ#iO-FBeK-EWo}z4i^m;IEfQ)Mkif~mWvh?qymMy_ z|3&PrJ?7HeAD}ff%1$)a&cT0!HjnC%K<1FZk*ow^wInFon;J_L?w__5_f*lI_8Ik) zKxLV{|9Kz~@@R->3`lLCsn&MzbYWupNZs9L0~Nmz=|SZiH82u-2RIB zuvdWXp02E{%j4ALe<85|{20vf;2!(0p?=XlPGK^=$I1z?50M;qfL77qI*{RfK!pDU z|9=#zKDSVTHF*JWH=!dumLnpe@;{STzp;t}$DjWUKegn3@n75bzp-|Oe(9a|t-Zhr z%_PRULjq1fLVnugL5@Gd4AD{j_B=v2JQ))Fjdee7`T?rHP{18i;25*h4F~1k$8vAG zNR)rQ^eVDmEWDW_aRGQbpCkDX;NJm$64vSzfcs626HS%pK0*b~C4mgW-d(PZ@F?R` zLpN^wKM6z1PwoT!v@!tGst12CoqtAp^0nz#Ov)Kn`C-i$Cxcd;kn`j}vF=l89=|h7 zMmLN{rT6A%cwR+#f!?7f*FU3D={c*|roXlaq7@YUi>@OoHS{-DRHs)!b?Cu8_LCIr z7lFzpAn^7zz)b(sqdNowO!n&`hKr>?>B<0|m_M=iJP!#B4GA2p-kfv`oDBZt;h;%% zNN8$^$;dOUGNTFH@+a2)ecNA^=NI+|s7)XNB5)7xkN=qfCe(6)h75tq$zDJ)C@1{u zGXZ7E_)p3@?Py~F%WYRrhnNn(04xguECYCzq4<+7fQxql^<`&&VgFNg_(l0-aPpoW zKzK-?*#7>mIwSxLKFmj!HdZI# zUZ7kq2|$MEL>2Byo(www#tLwl1Mx7S{v)O-6@~;35B^~KWC;DF{{h(@a7M+Dz*MF6 zwIWaNKLCF!zX0eBH2+F5Ky?~&qdFu2xkPAdWj*@F^O^bYFx|)V%Hjv;p4t8|fv9$J8IKYW4nSlmgcYbWaWcE?D>{fs-LRaO2Xi@F)F3wFmdH0rvQy z^y2S-WAGnXf#Z-r<;NdD0DE-bj{RM_f6)3J!{2iL_hS|R;_hz|{NvZdKYshS+J`@} zXsrEc|3sVcXJrLm{=~}DMSuD?vC97_NB?&62Y%C40|V+02n2gD%SP(VyUg2n;LY$L zihNNe@MUqM5(~DO@A5-m(3p;!map$9#j`2nt(@1tu_8aAAS^(U_-F0SpICA+Bmk~- z+$nDa$Pp>DhE8fA`A0*c4uDe;ji>WImkv8a5AU4|E^y1S2V$ph#_ z0Lc{m0r~;*_~|43Q_TPLX#ME{40;^^HRLz-XRO}$Q7EzXqD2j%VM(AJbqY9rhGXqy zs_bOK5+`vSVsh+&WA7(9>_m%{BB|~bsP6cU75NDTDV;32pZox|jw##$XGGmz>~YTl z^6m%dZp>Z)@BpOG9zzQ4>39$8}9S@+t-A?YRFk zSjxY&H~8aUPKfQjrzrg8FXy|GpU&S{nO_A&@%{___nVzR$PC_d@Gn~B`|W$M?>hmo z2=H*Q;9u1N1jfdqU?XQ00k-gQjOti9R5Hp0X388nME=~q2M8vNH7%aNNZ@*IqK-B- zB;kWPs+Qx}_I2y4cGOq~_p9*MtGonb*s^{hxr~r&IYrT|+>&JCSK=;N zPcqdqj0`_)Da-lMJLF0{nVf5mJ~`*d6gI8+h12$AQ+vtyfMLw~N+jG^Vr@0btQZXP}9Smi`DZNTIA z?v=z0)8%_Px9*!qWIU}=`#{kpGl4ZjG;x;}xi)kD$tscagNu9)U<-;rua+|^sV#agxP#y}PE95FqTx*df{{X!a+@(Nn(Y0Z!o5NY? zNTy0(Oyn@P6lG5mK$o`>da4ECUA~;Cn;?*?noa=fv$4*&f?yh(JG_lDdu*eY8Blevnbwlzt~_`Z*qV8`DGalWkmlscTYnk@eXIlta&X=K@YuphCfbjU0`=_pK< z!=Q=D*x4f;ak+dFh&ud}tgTgtZf269SR`%w04MeEeh+H(>7fVCtr5U-SKegGcIXWL zxV$&^*;B?%71mvBnxmMGOH?4n{K)t<`Fe#Mjnl^lI4}pBhnANBrW%^S-KhU)VN>!9 zN6z1~X=SwIsMu80Wuf-jcLCF=6iQ&-0Qx;VsKs(1?M+XFzSmLlwtbkP>U-;}l(nD5 zuH>_OYCxTjkCdCQhiJ? zC^Z<+<71yq_58{>adgx}`pfAZYGTwKI&-?e)JZ`GYvV0i?HFQS^rQDRcS3F%d?@1{ z-a{D%vqS_NhBHT*Z>JQUs1V-W`F`280?to@e1jODx)F6J2tUu5aS`WTemN`mEpg9q zv-%GGG+DUg=4}GBMOa`6Yo+iXERFxYa9;8U=oly@K#nBbJ^V}Hz$3zg{}MP9Y|6k+ zVWP$&j$Zc?2M{=byixikaA58Q&b-^>L4&WSrc&|*LzN- z=r)u3dypQ*dUt8i+bT}V^6w-R$W>S=6iABKR!;R__gx+td{h4aJ~(-}-?iK&+utSL zw+A3l+UiH(J0W)fqZxyVhR{Nrio)0Rb%zG;YI5m40t7B7qB4oF?=AMv3o#sN^HX=_ zy(pypCg)aepx9+KVm-bIBNac2jSa*ECnr1nym)R%aQ@QnSWMMie~u+bT)`op@C1KB zi#>xZL*Q9>)^ii9uXU7IUA?B=2Qz(Z6|Agec;8ww;9I3-Uz+)kuV}JcbzHXzt!X|@ zX74CZ`>2V_awyw1*6)V8b3OBUtx(p0amanAb%~mWJDyN*b^y8ca3Z}YhBx>95q_xD z$(0(mfG6BBm@#jP94F#@hYhTU}g0Pxw9NS7AkRTWId8NW_w9bDqOos%H7vYTTlak=*`1Lc@{we}10A}Qrtv^gBYl6oDz zUacx}mevR}%CfZPljZu)$gl8I%^NcLyRz6{oX;C`&QZ_E(gX3s09?_2uG`zYGG z_g4I&C>OPr#j~EnByw{0O!>#I7 zSj*_@mi4gZw)teVzI>{@cTLL0BSyVkx5td>SII}_^5}6)Le7r_304l zM?WnQ)xUfJ6Z%+uACDw#iu^3Y_r-X3djF-=;0scNHV*Pi-rIrgs~}qIZtbhn3;ro{ z#AcaC5CQ)+biSKYHUWndvL0IdU0%UEOX{S`r$0b;i832)CPg}zK}KD*Yg1hcxo2$7 zcj#1|<0EHm^NalWs7vh+*u4K+~Q1}eEM?xJ+W0lvw<@v>gJe@ zBrO?eYyZ8A4Ve8L6?3Bo2BZ=b7BCzNh6S$QdJq_d4M-(p(Wpbmjw39uOh6&3R2o@v zF#4$lfkA*!`T_b>GJkNT^0ki{WxUjh1+9IWMPSnC%80YIJpYcZ^kb}D8Uv5;Semh) z(PI=50xI|osGaUv;%tLjV;OTZ*LvH7Ofijxf?iM1RLB>N30#V&LzS(M&A*jjbq$73^n*1L_tj)YxY@O zCUK)N-`eD{T~ys(s!f)g&(J36bDEtK<#L&!TUj9M*dLZzB=c0qR%0S*_>a?Sn*(J&cBXbE~|?lU); zt3iQ^#+M$dF3VABYVoF$X-AO8l&8x!q$uvX?HQcNbaIRa=%J$As6%*_aqZ5NA-y!_ zvGQGEhdN-1<{C|mNS$HO-jlBLgw=O)M}oPR>7zG_RvwRQ+8)gQqaQ$?EC>giu1|ea zsaGB&^T>RBj^hyX|D@wwSf}X|oByeELdN!D(RzuoFNby4JlIu}WR)R1=wl(w6#723 zk7SzPq(L5D$%pWHBG$xP!{G2rKvD3An4UpZ=+!l6Ioa5CeDqdm1N|bQswts3==JhC z)^ou-qne&s68NLD@pX&r>)P9;ZN*2*lg{p_T3$|O<@<)AUO8yV<*&@gi6|4oEGG)w zK5pcT_rjqsOr3Ugi_m)yOib1~OxGxjx@0C2mDH!#zKYouhZ*D0a|=)+m&z8lT*VoH zSIOs-K*q`xwb3(_^DL-pq;H{bUc%D&z!g8qG1=fru1PLM;XE0N@wyaKY;6aJ*Dg$Q*6?Qo|o4b)5zI>#_Zmh;0%YA4htrgaJq9>8q=i z@Z#sn!t%yO7&P{e!P>|_jjrEvjDve~NbiM#UEMsoi#V%XlZmgaV_|8rUT8|6f5M1U zS~|nu|17;F4hiQaJ{G-$B zRj0-yHg6B8`<$uzc(qGN=|0Hr;W5w0b@hpw?+U-bp$fInnd9<)h+?3C@~ z_4!!!SV=wI&rpHlFa-{lxwc-xbbXe1!Xyz@(6+#Ddz2hjQ{yzDkJ=S}MXNgdfbPpx z3@fb8oKVIsXT{B{1QctOnp!M*M4iU%Vcd1M_IcETPWSG;0pY5--uJjN`pgU`7={h> zY-4ha=Gklsb-thUUF0TnHQ~s^;_0qJuG7dw^gdg~jcm{5jnFZPAgLz1wAD4l!8b8l zFZ2)`4XA{1LE(AUy_@9bcxS@u3r^PT>uK_^Nx1`0hs&U9lUUV?yU8Mi&l5Mpo1V2} zv3fgBenMIXt=lzlE+S76ikQ_fv^a&Wgg?Ws9K;L*+40qsMQAH^MiOhPYVxlqeJ*N# zxpRS8!>tv-kT0=_!6)XNGRhrUV>KXF^#N3=Rb#DX`5M{+@9%_Kr{aVRQr$wCApcs# z$`)yzVhStaviKy@yGk@UxC7n;p$~qmUh9TqI4jr6tx1yZQ_cZOe2!gmqo#_6P9UR7 zbV#*fjW|k}jxMhSu3N83aS|VTLdKicYLvAS*5I)>yu{B2K3PI!^tRt3h$z{Vnh1yv zpHB4BrgVl;iTfq?d@f;Hp~|a?i@st(^bWH}YGTacKol89Vsn2qQW2#XZIuTM6`%GO zZ3Y+BkY=5)*rGP})eemYNsflKxn`e{)6fKs_-}W-_RK!@rOl1CB^5uo;b!uBkR@9t zajm8dtRmhit0m4-!Bn6eZzW=#B+-S4m}1@7s3*CuLPK>; zN_P1wXW&*1yrRQ6_PnZGPu|YENULbE2-W1PlaB(;(kuon!;B+Hf+4-w3FHzQJG>23 z#hFGU@s^p!AVi`uZuDzDH)L4e&`dtmQvy`XY8BPAMJkg?3<`$nRCmvPeLN-pA~wVE zQ=NMW*&FEJ=3CY7g)i;BFOyPn`W_zm`t!L z2xs&_$wJ%w_zR#c;L)Rki*{;PPurGH-H~39gDRWDbJ!@PQ-YoA-V9%ngW^{1Ie0?rOl&-QdnMLKiNI9H_90Fu zYrnJ>!Xh8Bu5BXyhb%QdiI{=>Fg{1PX|jfnx|xGiFPSG{K6h$7#c73;LXIo_RJx04{xENOf&P+ z59Qb+`{`R_if4{er**4WEclXe;B{@5vx4aSI>r#i?dLatfI345*63flDXu;(qIlFy zH#ivmtyh!~ueQU>SFgN2&Qgj-o)JISW~)Eo&As0qqwBTZ0r%I|;L^ zhDi3d40WR1T0K4fYeEtcDt4Zuj3m2RKAB#z13fzU#g}=i9rG9v>2u4iSIKVt@Z3gM zh9xkCNCcUhqAjzGiTz$d%iZ>*=myba1fVR7!!67^eU9L+ESjt-?FkFucp$xUpE8)Z z?zt%}I{d_J9J7qNM#)0v$c)YLEoNoZ8omJYVd=~_Hr^r5j{3A#!y1{HncMCp*nCu+ zo)dYEqb}iAc{c}C#R4Ss+Eg3^5Wg0-4PzKBw6Bo{@GX-PGN%9uUN^ETXT zO%Z8qE_IFNsJAkMIr}L11#&GEk?DdeHrwdd5E2tO=NY0PDi<~sy=ko9(==cd3`dhc z-o6U5C#J_$SI02njnf9p+Su%<6Fes>8%@EK`(owjyqFLEj>b}mM<4iERD5KX&ey&k z^jzeTFIv=#x+jPs1LQU^jSSP{d^^Z^!<56eyoj8nwSCIO;`{Sz9O9lg2#7kNhnrlS za!bM*DCN=wrFQS1?NQod&CCqwMQ=(%SK_F!&?WQpv2mOvCOuo*-JvD8Quub|&95(2 zl^82VdD$wBYCRevi&WqzxO|^YT0U%@O7;m;Q)igkAKWCE|4=dFrnBH?U=7a%w#ydZ z78z&y7>4yaQPhe;E?NrT4`TyTrMGYtPHr%X^ zU(WV*{PZ4k<*cr0OSk=d~jEJRNpV0CDyW zX3Zg))w0l7yLjkyv4l#ykdFdu%eptO805vA94MJZk_`25!ozTtFrf4p!{l(4 zM45UGTAfKRutp4?8JDXTlCBfRVv@(!#*~YjV5_4MWm^@d;L~)cQHfZZ!E3Epninxf zm>K1jxTXF8F&;r~SE{o&hUmMTd<9)tY_~I z&=lc@nXREni*yXRLdU_iI16aZ;J1m&4;ZPGV`%h^Lp+r@87dnwylxT$|GNA`N}F#C zcSy-8B5}0vTwiP0L%y=t7*l}Cyqv^Dv^YpMg(^IE^)lB_VTxYnKTr><9I1O_QvY^3 zOxwlc7`vNNn&4s9wzq1`?f`Q=zI&$Hl)75)8W`7UtRPVE$#>3=+9DG8=IU6D_z>Re?5*!zsxBgJS~TtWUki_r z7_14NV=a1b!mNUEMn;Fc6+IQb6@$dg!Jp!oh*MDMHr~VB8#*LwPU-P=Ctq3R@!o?w zf-8&FA0Q*F787RRznk=1Uea1Uy)g!9DWdl1V+tCD&LM?TVpcEPK`=h~$Id>4vZgZM zU#9Hvh-3+qvyM@;rDjoJv$V9Ctz6y+T6Of=~-SA6YNpDetlvEqkLH7cI$w{Dj-B z+)l)R$IfhBIo^kT8^X8yVUOB5G6L?BF5AWlp1w7|{=!E}j%bCz;Q2~Cle9tvmBLjV zVdf2))W!I(0Paqv&wIg|I~LEBJQs3K2{4wiR-0UH$Dw z7)Fe8nP#QLlgue@`4)n_Dej`G8(`*bt~ZS?dff1!o;{8WD+99}`c-1U1Fk@LdXd>p z8n!9l$uNEKW*44>1Eiv1RrN7ij=Ar1d_uuVn^B@gE2i2$6xXehBrh1c@oR~5O@Q6i z!caDe>2H9ye8i?L)S0}RY8H?YL+Ycguc|%^0>La4n(riZ-xVHj{av%^`yv>pT>G*O zTBC+}$n3C-$`+siqh@#aauTX6Q5551Gh7^xm2Vs>ppM2^|U<^83?7 z04^Wk@3*;k|1@1APnC#(jbE#?x2Yr!cuBueHLts-hMz<&zTzL0di&|$^|Joj0OWK$ z&A2m4jHpzu+T0d01@leg9EbcjZng@dJ`sPGO`9-t`6N3Z(H>MH4Vw4I55S==3zc}T z6h^9HM&APZW!4wRKDXq@yi=q-PgmFzNSS@hU2|-2^~*cxog&V8I?bMd-0Yi#nxiLH zs|I6TE%|@Ld!+g~LIMki_eP)6qh=AQVlTnYS&STaZnpw96U_LSzYY33asBD-KkfJe z2V6861*TWHEJ{2TFFURdq%Fvotf9`|6dhOQcGMI}~}T#Lo2+wD9VWv%;o z6)9Y2JMcNK(lBD5{VU$NG}l7=(Ee%|MCXQ9-xKUYGW>KJ?z~;&@iQuyfK$v1=9cA2 zd_fkeb6LTJ*bhP>d)!h7&)qzN^lBP~f>9k(g?EyxPa&2p`9t>5M==+Ws#or7x1H~= zP~OLrjI1Z^oEbAJcopUxD_X6m53?hSSn9i+-f0X*9A<>v`n1~y25CeXJcoQR>&Ab4 z4C4hf@$>>9M}b~e!B30Gud^T68BFcv>`#Qu^lrGf&~oM@aq8}_>W3lX z5}hKgJhs)!#gBC6jop65u0ZGEr6iR5MeMd+I529R#mwg;PY8SS6*d0nH|J=u`u)O< zi3y9s6MdZS{MWSfr9Sk|AM)_eXw`-C=*!KBcQI8UN;_sjh)miBqr?h()|px$;$%L{ zp;_AYmYxn9Ol+l;yol)9GLyzqU>k+WiQ|UN^gz0{I9)!W5U~LnIO6yxdVJizzOV?; z1QW^FJZvwB6lLVDYm8^hg4uoh!X2e9Q3h3gKS zFT!DB-iy9+g4@E1Y(pc=3OebBw$)o2=AWHrM1FuENmF>6{5If9Qme7c4DY+8tYWMB zN{!Bn(p;-l4n;muV&*2ry5W|P>VR8TGbTREbVpR{K{COw%sfXKJ=M<%UbdAYc+mJW zOasTpdp9{jn$if)9t|T1T0-PAGjuX$4)FY3(Iy31>XLQ*kT&{^)Gg$;aF$U#@vHFR z=(UGRS&#ad@+xXOhy9=qpQT9kwOwnB3V4uAg>`l8pPh>G<<{jgEQ5@k6|9T6^;nK- zTqrHOA9K&-9nvL2RYO>T?I|pzMwk>`Hkg&G5TK?2=0mC7Ec;rYm)3k02Fq&@&W_CTpvf>hq7}O&P_Q ztIEq0zwH!ftexg>!eYL1CV&bKopGS-v(0ybDzUdAh=+!AXV&O0apRE8vc}FUF+v$N z8TQto@mOqYn}W9kIQ{&Naj!ML^KXj?Fzo&SIWE}il>Gn^4LGnYyz|(`$g!SXV2>c+ zG8)I;F3cz`|7Kqh;wU7i6rRQI$q}uMk^OEf2Nd*%+EV}*;bM~hGg!w<`%Y*u0$Sx( z3A%#SHj~;U0~hjMgJ_5fddc>iYVmHE9Mbx9Bt{oxs3(TifN6y(?(!dc^FolTG2!%gBD1 zt`VUm>P+Z(BGfj*{tStj3plWmz*${ZLL+X{Ar>2AFaM+2f&A4^y_E$W0NKiw!T5@DKB89;HmTmrhTq zETdw}oFn&#S*-j3<#{~WMm6H7EkO6QiZ@p_wsON6F`1tlMG~Pe;8O%wpHlMc^oQBo z19QN9yfFfUQZ_@GL$=Up)X|^FSE?s91Und{cKFU}*rM-rA*8)%%`6n?70!gqsiM)} zY=M{*_BQKDF#K&Bloc7K%Z+UIo8q%majE*_n0sw7u*5|&Lp!~JmbO@KK#$;AM8G z;%=Fk#bvKt^%$ge*bJMvGq6dgbt!6?W6h|FhOQE0F*n4LJC0It7dHRJ6P*4zJ7=f{ zW6UUTs$T2CC`_Wy>>}`*s<+l3MB$!ed5y(OF zy72xfm-d;6iZ*^gv7C+3^nVy#RlGr!+HihE_bpqxipJWci6kJ)WFnf~(JzsfsW{ZO8(j z3p;~Dv@j}hnM*Ok0zn|?t$}IAIIKl>O*NGBwqKh&&VwVN%s~iqTOZLb|L(TU5J=t` z@=l^;tYyTrxX3BVu!9$yhrpU?0+`;s_5zEBb0aC%GoTkR6Ya-dbtj{vz#HrynZP+n z68%|{`zd&W_r>m))`e>}6#B}}xl`xMsht7Ch4i3J?+}acsuoh0irse|%Um9fv7Ay; z0f9)P!3t96?S!hJ9dI;70(2E#YUhOHuEp3Zn=h0$e}4*VhQ(0G@fNctGv^EfXlhPU z@y39wzH5wV!e8GnxS@a*bqa_8E1QZ3Z_E2$fQ zS3N+IRc`R!`YwAIp$cs*wdGq|o(Y}Ux+@M1u;g%0eYo(3q2zxGi1cfiNdv9Mc~>23 z6Abp!N8V>W5r(?A=HIv$gxA%BTZ77P)BV4xm=#=)z8z4`wEtEEHg#ll^NMRwIW8T~diT*ySQ?q);N!2tAN;AJ`e@L@sY0FLX%$Q`j5r9A zJF$&zDZR%evW*NmQBo;z!-{m%lLCk*OHxEXgJwcrbTdkj8fs6hDSrJ`5YP3~*2pBk zvGxZuDNYjMe?TY(uR-}7OdqA1qyQh+#9i6bns88)BV$y$H#a9M0qfhSMxW_q`wZYrLQ{LON~ z#2&8WyZnWx74w|bU6&3M%)xlbQibfw7Cm`zL#L_bsGwMp>sx=s9LgP=S!1Kt2*Y8p z+v6-fd>#_0tycK47oH4Vfbv9ZvJ}GD>NZNX7Dn4Apa|TodZ=C}JnkR~?s9OU+<@!s zD}wv{dfg;nA{f~$lEpI1<3V|AQ5FU+?Bl+8okGW6fBXqcx98c2nB9rVG#}1pN0`K8D)HO7_qJyaeZ+^vZ| zxo{^Xo4lFyo`qL3k%4-dm>}%n1`Z7CMe`-2z*jaNOMEP&c z4|JM%AiEziUb|e;?shhyPDb3iTQlF~5RtaUHSr0RrYKKH2`2%f>n-MWy7=9<$RD7F zU#?JD1RK}mHi9$U`2-YZL{u9Bgfba=l{8|)DZ-@3TQlSYI<;jS!TiQY+FFeU1$D+< zs+H@5uV)!aBsnc_TelYO+RBGrg^ebBo_t3SjMd(>yNaNxwIyl|Xpwz`?o~DK@4VpB ziAWN*-*{bqZcijxsXl?KA!?Pc3`4YsdT2A25;nr(^X_ggF8OTjQW#J$i=c?gw50*1 zcU@2N_gx_$K$KKj9&tvdFcBAzTzorGtHm4koo{2Rj9pyM;=gwcrd^3EY3BU^?Q_r- zJ4w&uVgQ>K%Zs%U3SM>SjLf6~E!W7+yk8Ta@JFB(e$lOuN@1xIVmx@ft5nOLA_8Yp z{rpRwJh^wmB=FVhJfC5IWP=X+xV${Sf{%a_l^E-FKKF)0@sEJa143#g&Sk6j@V;%$ z*UIXgQyov{M=uuI$lp+poQFtK6wRKKymZ~_Fw3U7afdn zKZ@7`Wxha=%ja=l-CUUrP0vwf8j_Uc4o~+{aJ?G;0RrY=rID=&6I>&D&RhpL&Ao}0 z)Cb@2CU8GCjesly3Eo6;4&=7TZaeTmy@puVYYiIg{$=XW~ z+}VQYq*?Vpr>%TDRlY~ZY#G>vWi`QcAiS{c5=x*KfZDpOEpEYskE*p)3jT=O21-7S?1##-)p~rbQf>4 z8@`9$X0B<4Om6SN_$X$BT?~425FR{BBfBpUx9C?ut-6z6|#0?DTReO>n5YI$pQ*6 zy&-QKid*EEG|)0ktS%Z|E}3j#I)Wa-do|A5qB=o>uK(E*dtCCRDRx%r{^_W0PVjJM z4wSpK9`BUm50xEY`YP7JL5CR8bvXWRawcm?#5Xo64okdrI8a>a{imRQIU+s1!))*V z5r2!wnQNBl!10=M;ay`+vT)<%iS2_c68fTQ=uB)}c<}gZh%!qEO2ssORf2zK&f^$; zy0hR|=gDo^qN1LQ2T@wk5#$h9pQP8%!Q2dZ+RYjxLGA=IkKK zcE)VE5`)@6cf9f3Gu(B{YRTLUT)Z7kNxu=7QlK+@sN`|D2&z7B1L}K6iGmX6dr1C_ zNxv`f^fEIQ~TLy{9@=}8> z##K$pxReieH{QR-CdtsBzl8wbwDK|#&9+jH@hUd0<$B-=i>(}?Fynl?{l|Jr4bD2u z%?^5lSpJx!d7~f`p$s(>jJ=U5f$Gg~5}x{vg;Q^P&$g~OgPB|-RAsU&X4`t&<{2o) z1#WGKA?B)Y71nAO?$rJ&0Z^a>cqy6jy*fF4T9=0Y{dQc05vg6evH!c%?Ohw&(2-DI z>p~z*P{Zq3(-XY7Pug?9zR^a)ih$000n+K4XbCR?L9@3m_a9bI3l}QSrrgep4xTMd zNbv-1mOBwDFB>IW3-wyysd2Im>o%sG?y>s5@2Bc`?vpXh4oiXVbe3~z;P$NyR^@vw zy`~5PavHH%PvIDPbQ33TEZ-s-pz_?;IwcBbOOZOI;9BcyKJ`*t|3gvo!m;W%iISeP zZbRVeZKPPD5>ym_)oYJ%m$CBjjMl(CInoy`5!c~xdf`5epZTG(WzvSN&>N2M^`w%3O>&7^sR+-z{n8s^Gq8(D>f?( zo06@!0!4J7_2O6IXcn(w&{Zk)$wZz(CrrR2eGvN*EobX5z_BSPe7Q!fzV$n%>cq#hZwrBbtWxzSLqBrD}Tugkz z+v?&HwLd_@QiOu$qS*0m-hvw>3{nVbh&Uy> zolz4VX1rkf?3GPtB~y3?49VW*EI`Rj!#sZ5kn`^q1rw^Lz1>*o;+p^%?YO)xK^JNkoM8(J$Wzn;>stzRhAI6Eh~y) zzB1wbr@E4S;+n9#Pn*?@UJ;?ELR9a?R)62OBDbvCJ7rs7f(-Iy+q3|}XH}QsMYfl6 zn%^;yrKHLq3(vE!hTs78LR^+=xc(+p@4)k>JvcqcWz*8VEzoCj+a3;|OA+|$Bce9X zTF8_qK^+%&oCw1(eT6ZCh*CN7rv(~>woCapRJ7FYw+g*GGT#lyUpM~%#r>uUw)YHk zw@_#cOWM-_e@`u|3R~%l+E+WutwMKn^R6q1kKndT zv$2n1GY|PNif~As$g)|~Dyia|?k|Ep+?I0_o8tU}5$hj(#MP^hglVbpWDP=}dfhd; zOMP*Q)iZcJMD;DQEWahbI^f`@^q0s+&?bP_2)+}XkLASUv06JorGt@sN#bLuqUJko z$8X-!xPv-21*IjNs7yWXwF><>3pWLx+QVTYF%NSseo- z)>A_+VWc~mD}w&cMgbxIIu{0nf&Oj>gn}X*|HSxo`Q`Z8&wumWJO=(Ts&vB{rNO~s z*SKl&NmNFyt0<(^$f?98+0OaJ$3+HOzKI+tAfiX+;tGPzD!PZ2GE0dOnF)QX*+teDvK1zA_9>!N5MEX#J7%Y54F0N}1&FcQUl)K$ zkzpT7#ju};&gf^3WP{&O;b}a4!lvFTJ@r6JH`iu>k8qSoG#OSzXz3G6EE-b5hZb>3 zWd<;ZTE%7y1L6m^CM0!Ixk*rp$XQ3+}KbCcJYLMEE*7{XM4WNoZ7w%sFRDOXOX6QdZ+z-$q@z0v`1Jum* zvmyga-~K3CcZUG~I2oO>@F<_Vj)tt((#LuQ^TXdMlANdW?g==}zWG-3Ki>Z_Pe_8q z?U1IvUAB;YN0hn~9RW6Kb#{O7vE4S{(iObI^7rrx<$f_$I9Dq}oR7GwTZ3Pw{*YeLdyOD(qs$kmAK&ci7Mk zH@P_CwAf&eS5_khF5NvIIeWOr$&jjB(Q%5$rIg*0lBF5~=TExq42!+e2Y%iDwCOUw zAXFX%pG#MDlIW7zsfJt7tlONuiB8dzsZOW1VFZ2fdAN>a?4f&?4tYmmPR}bVdIt$K zPY#n$HAZbWiohIe^35xxod&g$ej#(ir%#d+2KqP5PT0REy&q`(ACJHCBO{mIJ}*r) z=itU5B0qKR2MCE%ILs+~DVM3=9s{S_q}&KWZT zTGU%Ls?2gcy)>q0#aq`qNHxqv)ljX3sv^r%qX2T1^^!(du8FNl$`_KpAiC|^Jo;H} zY|Bk!ZUA0Mc3j8w&C{I1h{2Xf zm^qgMBy@znDpd;D^s1>~E)jH$q@HWZRhC4F_}t*$}c2;Z#8tsdit`P}mPZcLL}gw0TV% znW*y%x^Em`@6_(FA|)$3TOw~K?;)I}BwN}9@0>5P%Fd9AQB?`+jxNB4;V-jybqiX1 zm7JN^Qf-!J3)Q^hD{=`Vf59rK)(*AE$s^27W+DfRtz|fB&sG&96sv2KBdgGeG)X>x zW5Ohn5)3m~h(}P2<5?ps_C@7N^g8Y8v$gG#-Ku{&T|rT`n}VxnKRo^D4k9%l0>SdK zx+&??RPjgnv8pH=yCrU0h&2l@@S)IVJqBH74fQpyiTTn@|IZ%&Ot}5|n@EpjI-nnm zITSdkxVPZ`S9{+X)a2H-n*e-8Du^_dS~+n+RJo4%ymMe`#9D_IijQC|2E_qb8Q- zEAUmNw~liMHKdhXqo{q|x{ZZlC_kJ7QjlP2+o5INcj_L?%2XgS8zDzfc$AEa#-_~= zA@07?@-W6~QgSLT3B^dxCz)>zwz^Cd+eo~NnNIv{e}OXV-o2|V^Uq@_5LKh~7j)^&G z{xE6WJi|Ui@=qk=^-udI5d-Tf{`@9WW#}Yj{xR*dMP?|pp|<}%z*IyBpg2Mc(mK=s z-KLw`>?yLJR_f{B(;Y2Q_xiy#itGBtArS4^*4X=3%7_mF-V-7!ko&I5LCv1 z4um*j?tkW`M2NMp3f5v@9%19=>D1?8^uGY|gfS5;wWmE%6z3iDJ)EE*#wh=&CDw9* z*WL4q?rj>XreG5YQGRm$(rOg7-M@Da{w&DNiQqET)%lyV>wxp5!W^>U*^R1f+IM6+ zExfVyBgt|@W#O4PsQ;m`rCc=ed7M}Vt^FfPt`CvAE%od$ehDs55A-!$cy9QS^A`ti zk?-qZn#~T~ymV>dYFiRDeTPkM^{~WtHD9X-EJ)Sy~W}9iWTUdK`5n63e`$JqU zU4fAvCp0^xP!^1SdnhYzEn)UbD`jGGz;AxQ!k4x$53fc5#vt_4621oUFlsqN9BsR+ zd%t%YH&-X;AM-0=m!txd%DFG zB;+Vc$@=pq{c9uG3n0a~7n5(>#Tv5AM7ejn25!WKFhZYP+^0e|O#w*Q*T6d^BeF!F zy<`NI<>O3{8A_8-LLo&@-kB+Aa-g;`)c>8yE^Urq@wokrZPMz%f>t^)ZBXj@d{=Xj+%(mfYMqML?dT+&wVZBn}lv1 z9j}K}C0!j!N`e| z$ypJJrRwe{A4#5r3e)g1A1$%?to!0bkH(!YRTGg%zL&z{zvoW{tgrF1_kbn?wjWFt z8tl}Ng7*u7)^?^yrQ=hXBBoAvF+zZO2TG5YF-kmx=-OYpk6}kC;F;s2k(DC+kQ=c) zd7Mh{fK3JvmBd5fN#@n+_Z$u7nuxbf*jZ4y=1x%0vZ$3=@+ppc1h2$SY%4!Ac z+6vC47^a+8z4OSjDXeTZ$bo+QT~Z0FIJT#Zaa`y^i%&`*nAh4DAByP)A8EYu4NUI0(H-^?U1w&tY0gV2Qm&_3l?S- z|DKUu+}{us9mr4C#WHY8f$i6{mrR_k59L}4Dfr7yB*FWetO(9wUFSbi>Dlpzljmoq zIvKlgj>%$!u9gufHiNzwIHco)F`R^T;KL!&`kH;KSuww3P|Ay@SC;=$dt^(x?z_LV z5;;?qIzzVFS-N_pJ-X}o1t&tpz@PCZ%6-Y#*L;y;__@f62>x{sv=S*_w@(6gnD``Q zJ|wa#`O$Vu6^V@2oT;lWJ!887^h7F(`RP7_+tpsI)^~h+y2n*H28seVv?S<=pjGvd zCMv2=^EM%by@z3%F;zJQ9Y|_0U@C0uLWntWpgxXy%mJlHFCWl@t2nYeynM3srkCD- zo}ff7$jEdEvyc}nuHxEG77K|wqn7D6oHWZSi5ra#r>p<<2e_2I-|dV0v@!7O4J|dD zd%Zp+A;!6PvN#3M#eM@3woh^T2wN!bj!;IMmd3)XuB^x>i$NtRPFXmLtY55tu*D!1wX)v> zG_<5(IBuUzd4H37j>+}lXjB=Q)P^k-atrAH?x5vWvpW8q#XkW1dlPyeT`qgRlKtOQ zZ2YzzY2sNA%Krfng+-)PtusLXpN$$y98sM5%fNB&Y3vUx`%ixle`vkgn7=H_&dTB| z49P_l5WvF3njXPizKxTKR&d(L&&mKc%LDB>POyWo;Co%t%*Ky;s@sG5HT*vw-rH%O znu9mOs)(yU&bWdC9W@^Oop$5x7yE=FHtFz&4e@=gE*GFc{sxBjo56H8!v*j){6K!V zsErpuR#9bx-Vk6+xn;X$PCR>8pSqYkdX684D{0u3z(z>$CB2aCpy|Vdc9EED$(+Y2 zBti`L2rypnn>ar?Ex?Mc?B}i1nAl>cQ(G^Fs8dPg6`Iy>uDI*`se6<%~#PL28ZdQ;*UM{<#;4R z0D!#)(L!PEmU-~3tlsNXR0u$t4`Vn8Izb!j8z*^mdCGCl^-?$hH3exMmqV+ACQfvp zkMhfc*;#XtJ}aU@?jRD9Z*AaI+Y4X7AjJ%kXJmF|12*dDN>nupBzc5B(M^ zU@!*P;p$%@OtF_z#7cLkHv(CqIh|5lAd_MGW`ZVb8L4kye34KfRLy<8fvxFHv!xUJ z$PY=%pl-Hd>SzLKT=vgxl6;j)7}SM@!qquEA-5@xP28s!d>BEZz>w_y8xPve6rX-C zst?~kS&mzI^`(KKEok*9bX+hICHmz-PI_Bm?n?{g{UQSe%nfempeF^pu=-e3IoOXG zAZZ|lQMo9DwR}tdP*h5U-e%>l>WLpfz5EKnZ{303sAwKpTYYJL9#1@S-2qhE$9JqY zyX|S*I<_CjT+4=2I%KFxg04x?CLFsB=YzK7=?NpNd|F8!^U?VOoe)5>B5mRxl=a2p zRtXV)q?|>F9R7*~`x>Yu)awW0Iek8MpoX|zazBW|-pN9QCtAJ?Z4-uqe$AGMv)+p6 z7gj(nf|t_>`+BTKBDJ#7(E;}%LD9}Z zA#^5T!OLM{Y}A)oo0hM9Ao1(UQFq0z(o|d?)ue$aPi_Obj~}oK z&g=Hj)!|Q!ZR6!Yf!4iJ@h4S0()O|sTKFT*2kVf(7=cE|z}Y?>alJo>_NOEINP(k1 zc-2d! z{|Kzg^1AKjvelPk+hNHMBJcES9QTqvva{l77CBCz&>BL|n11R@Zd!eis%uE)nq>jW z2uDny%XD2${J94FB)VU5AoSqR5sZyn%`-pcY?$|ujNX@4P<_wT_u={_V$pQ&k{|n( zQ`IH-%L7{S=UJw~^l@v63iCzj6&x^y*-^ArQOPftlum#fg1u`=C>k*P9bk`$O?58z zj&PG5>JJQ4nv9%8{k4E}jjQl9KJ`X{UF}FU0rn+bT$t;0I^rMJNtWuP)av`YHO)L zBCa^Zfty`mvR9RBZWw(gL5?^2Mp8w5ByeTj@RA9RsS2fS{mv6&_+nHM==00)6QwrH zXjn+$64?jOcREt}nUYq%Xb36#HQici%hxeuCX6`{Zenm`BW@X4-_|i9<>QBgto2Gn7^R0X-6R7onhW?Pi0G^R>yt z9nx0Y9a#qci@)>KIugl#*5lJ%$(Ndd@d3=Hh}5%(`h`)gXx zOi+#POTWR+)P3QEjp`@=)>in>()0?)%eiVPx_~iJXq01qmh{`Yy-{_T_o zvQ_dmUunn+2%v3VidtbX9kO7#?8oxvCP=8gNAR+P^U;(%^y z5TBkmVzq~8Du_$R;YhKCub=t1vOnJUrEgswVP=z6{O5hulXY1_i8@%M=N{Fn2{ z@F{mHYZT#6WG{F3YLds)`jdY1iok__9_AW2ftj2I3H2KPtO z^u;VP2saomKB&IsV2j4P4}bk0jMNNvSrq+5rkKZNb3t&Mj|(3X*% z^|R4B>5jGsV)tXl1XMT>OSPPE0i(P2H(D2mz`pPH#bYl0lq<%o)Kz%1>sbE+GgV3`}3aH4Xb7Q@{5NLT2QPe1NS z61$s}#+H9;-ZXnCFkas6#VOm4?y^B19Jx+M`(`V5C#pwRg#S9g+lD9~*uit<;Zw-e z@D=%#IjpRT6}H|+gr{}Xsmad6^UAeo7y-NveNa}?w4D~VEn?)Qr{ z>lc;xrImvD7SUpbi}dj#2z_gAc-c~<4}32*grrB~zf8P~DO;pJfb|qZcDUnMrRa?1 zb;4!%k!FP0-!82EPb%08R8D<&D=zK6M?WtuYwzZ`3veQHq7r3%cunrm{HS0l+I7^@ zj_f#XKadiN2s_g#bhb}rZngSSx zs@h}Qwkz}Ht|~EwNXe^|a_RIF@@Uu|ozKY*tvqruz8ROtZNjM8dMOAVqeec(Sw6L; z6eJxd*E(38%Ae&h+6;`8Q9ne3W8=C*Vp{CtV_~K!1~$$JS5VRG(4)tjpARe&2{O{s zf8J-CeXG7D-}thSR^CcYFsRSLDtew*10Ky>w71jHWDmJC`CmTtQ52m(WcM5CV5HSj z&vLs2C|Q_>u}regIhJKcuXY^c%|8HJSUzb8Q{yF6Mpu$W3B~D@YNzpqW!Y1ft25dj z8%?O|vcH8&(}yNo)ev`Dk*#snvoa?bR=3QvsUj(GCN5esDzaiLx$8VP(1Bw_Iz; zdtxS}hvnL-D=s<|owHeSQRHKe>0EoXGcy5{_D-@WN6L7`N20@4xrT6oo&(*w5u(#W z*qHU#60|~&xr%4#5SP2@>)||a!HKjZT`2urCFcc>p^SmUaT#UJHXBTSlUutJMr45$ zH`aUJa>hFylJD}!B*Bg1vDEGliZXf2yfADkuSD%$v_n?nPgQz~?yBDOt~zr4WB;i; ziRah;1>x>WR)6uY_4QvkmBEH?CFyLXjW;`Ra{@?Ue;b?6fS)E;o?iSM;MY@lrF??}2*4Aa#_b43@wh#VfUYeony zY)937kUrq)E7bC@ns76w1Tc@*{O!f$dmUg#(>^A3GKWc3Er$ds#z*`oFyYn=54zvbRr!BipZq$qk4nD! z>gzZ+5(}QUL;A`__+VIJ+!mbJbE_EXUPrODhTFWjvUNZp3Q9H)^yQrC&C`xr^Tj{R zg8Lc6+QBS3*o@b2b?MtuM1y2PZ*gBJD)19CsOvtP>Zmy|oSDicNmgLk+^w4wg65i()gz1Y@ z=f1jL^VS0vbC8|-S}7*GvC5L}HgOuw(#c>sdLZ^Hxk;&Dw4CYU)9Q-&BS5iyX`QD~ zn*{({hDy_gur)VV%cI^cF`keVJ#)7*W|O_kS?V5CRiGFQUcBtFK}X5mYy99U$L;YC zO2dh#GZ7Ir8g|@#)F*V9I0yivN+teKk1mGTWX$BSE(Y899M7&yzUPwn0}I#C@El`9 z41WCfZ}9ZEAIm+c^v@Gd?FRp=_>ae6cz<%tq*+@h9b7+F;`~26Xj9rhfY1H^4-z7_ k?59=ia1+Nc`iCc4j{ea7@3PJRu?0tM?}y`E{pZ<#14+cFg8%>k literal 0 HcmV?d00001 diff --git a/reports/figures/mlflow_exps_2.jpg b/reports/figures/mlflow_exps_2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..240f3a884a3b90b02a6c34207032b3918a8fd7d9 GIT binary patch literal 67496 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 Date: Mon, 22 Dec 2025 23:09:42 +0300 Subject: [PATCH 4/8] HW4: ML Pipeline Automation with DVC Pipelines and Hydra --- .gitignore | 4 + Makefile | 61 ++++ REPORT_HW4.md | 436 ++++++++++++++++++++++++++++ conf/config.yaml | 25 ++ conf/data/default.yaml | 13 + conf/model/decision_tree.yaml | 11 + conf/model/gradient_boosting.yaml | 13 + conf/model/knn.yaml | 12 + conf/model/logistic_regression.yaml | 11 + conf/model/random_forest.yaml | 14 + conf/model/svm.yaml | 10 + conf/training/default.yaml | 20 ++ dvc.lock | 315 +++++++++++++++++++- dvc.yaml | 148 +++++++++- poetry.lock | 2 +- pyproject.toml | 3 + src/config/__init__.py | 21 ++ src/config/schemas.py | 137 +++++++++ src/pipelines/__init__.py | 1 + src/pipelines/evaluate_models.py | 276 ++++++++++++++++++ src/pipelines/monitoring.py | 336 +++++++++++++++++++++ src/pipelines/prepare_data.py | 205 +++++++++++++ src/pipelines/run_all_models.py | 162 +++++++++++ src/pipelines/train_pipeline.py | 364 +++++++++++++++++++++++ 24 files changed, 2586 insertions(+), 14 deletions(-) create mode 100644 REPORT_HW4.md create mode 100644 conf/config.yaml create mode 100644 conf/data/default.yaml create mode 100644 conf/model/decision_tree.yaml create mode 100644 conf/model/gradient_boosting.yaml create mode 100644 conf/model/knn.yaml create mode 100644 conf/model/logistic_regression.yaml create mode 100644 conf/model/random_forest.yaml create mode 100644 conf/model/svm.yaml create mode 100644 conf/training/default.yaml create mode 100644 src/config/__init__.py create mode 100644 src/config/schemas.py create mode 100644 src/pipelines/__init__.py create mode 100644 src/pipelines/evaluate_models.py create mode 100644 src/pipelines/monitoring.py create mode 100644 src/pipelines/prepare_data.py create mode 100644 src/pipelines/run_all_models.py create mode 100644 src/pipelines/train_pipeline.py diff --git a/.gitignore b/.gitignore index d43c902..805e039 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,7 @@ target/ .mypy_cache/ .ruff_cache/ mlruns/ + +# Hydra outputs +outputs/ +multirun/ diff --git a/Makefile b/Makefile index d7fe925..7ffd6df 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,67 @@ test_environment: # PROJECT RULES # ################################################################################# +################################################################################# +# HW4: ML Pipeline Automation # +################################################################################# + +## Prepare data using Hydra config +prepare: + $(PYTHON_INTERPRETER) -m src.pipelines.prepare_data + +## Train single model (usage: make train MODEL=random_forest) +train: + $(PYTHON_INTERPRETER) -m src.pipelines.train_pipeline model=$(MODEL) + +## Train Random Forest model +train_rf: + $(PYTHON_INTERPRETER) -m src.pipelines.train_pipeline model=random_forest + +## Train Gradient Boosting model +train_gb: + $(PYTHON_INTERPRETER) -m src.pipelines.train_pipeline model=gradient_boosting + +## Train all models sequentially +train_all: + $(PYTHON_INTERPRETER) -m src.pipelines.run_all_models + +## Evaluate and compare all models +evaluate: + $(PYTHON_INTERPRETER) -m src.pipelines.evaluate_models + +## Run full DVC pipeline (prepare + all models + evaluate) +pipeline: + dvc repro + +## Run DVC pipeline for specific stage +pipeline_stage: + dvc repro $(STAGE) + +## Show DVC pipeline DAG +dag: + dvc dag + +## Show DVC metrics +metrics: + dvc metrics show + +## Compare DVC metrics with previous runs +metrics_diff: + dvc metrics diff + +## Show DVC params +params: + dvc params diff + +## Clean output directories +clean_outputs: + rm -rf outputs/ + rm -rf multirun/ + +## Run full pipeline from scratch +run_full: clean_outputs pipeline evaluate + @echo "Full pipeline completed!" + ################################################################################# diff --git a/REPORT_HW4.md b/REPORT_HW4.md new file mode 100644 index 0000000..57eea77 --- /dev/null +++ b/REPORT_HW4.md @@ -0,0 +1,436 @@ +# ДЗ 4: Автоматизация ML пайплайнов + +## Обзор + +В данном домашнем задании реализована полная автоматизация ML пайплайнов с использованием: +- **DVC Pipelines** — для оркестрации пайплайнов и версионирования данных +- **Hydra** — для управления конфигурациями + +### Выбор инструментов + +**DVC Pipelines** выбран как инструмент оркестрации по следующим причинам: +- Уже использовался в проекте для версионирования данных +- Отлично интегрируется с Git workflow +- Поддерживает кэширование и параллельное выполнение +- Позволяет отслеживать метрики и параметры экспериментов + +**Hydra** выбран для управления конфигурациями: +- Иерархическая композиция конфигураций +- Поддержка переопределения параметров из командной строки +- Интерполяция переменных между конфигурациями +- Автоматическое создание директорий для выходных данных + +--- + +## 1. Настройка DVC Pipelines (4 балла) + +### 1.1 Структура пайплайна + +Реализован многоэтапный ML пайплайн со следующими стадиями: + +``` +prepare → train_random_forest ─────┐ + → train_gradient_boosting ─┤ + → train_logistic_regression┼→ evaluate + → train_svm ───────────────┤ + → train_decision_tree ─────┤ + → train_knn ───────────────┘ +``` + +### 1.2 Конфигурация DVC (`dvc.yaml`) + +```yaml +stages: + prepare: + cmd: python -m src.pipelines.prepare_data + deps: + - src/pipelines/prepare_data.py + - conf/data/default.yaml + params: + - conf/config.yaml: + - seed + - conf/data/default.yaml: + - test_size + outs: + - data/processed: + cache: true + + train_random_forest: + cmd: python -m src.pipelines.train_pipeline model=random_forest + deps: + - data/processed + - src/pipelines/train_pipeline.py + - conf/model/random_forest.yaml + params: + - conf/model/random_forest.yaml: + - params + metrics: + - outputs/randomforest/metrics.json: + cache: false + # ... аналогично для остальных моделей + + evaluate: + cmd: python -m src.pipelines.evaluate_models + deps: + - outputs/randomforest/metrics.json + - outputs/gradientboosting/metrics.json + # ... остальные метрики + metrics: + - outputs/comparison/best_model.json + plots: + - outputs/comparison/metrics_comparison.csv +``` + +### 1.3 Зависимости между этапами + +DVC автоматически определяет зависимости через: +- **deps** — файлы, от которых зависит стадия +- **outs** — выходные файлы стадии +- **params** — параметры из конфигурационных файлов + +### 1.4 Кэширование и параллельное выполнение + +- **Кэширование**: DVC кэширует все выходные файлы (`cache: true`), что позволяет пропускать стадии при повторных запусках +- **Параллельное выполнение**: Стадии обучения моделей могут выполняться параллельно, т.к. зависят только от `prepare` + +Запуск с параллелизацией: +```bash +dvc repro --parallel +``` + +### 1.5 Визуализация DAG + +``` + +---------+ + | prepare | + +---------+ + | + ┌───────────┼───────────┬───────────┬───────────┬───────────┐ + ↓ ↓ ↓ ↓ ↓ ↓ +train_rf train_gb train_lr train_svm train_dt train_knn + │ │ │ │ │ │ + └───────────┴───────────┴─────┬─────┴───────────┴───────────┘ + ↓ + +----------+ + | evaluate | + +----------+ +``` + +--- + +## 2. Настройка Hydra (3 балла) + +### 2.1 Структура конфигураций + +``` +conf/ +├── config.yaml # Главный файл конфигурации +├── data/ +│ └── default.yaml # Конфигурация данных +├── model/ +│ ├── random_forest.yaml +│ ├── gradient_boosting.yaml +│ ├── logistic_regression.yaml +│ ├── svm.yaml +│ ├── decision_tree.yaml +│ └── knn.yaml +└── training/ + └── default.yaml # Конфигурация обучения +``` + +### 2.2 Главный файл конфигурации (`conf/config.yaml`) + +```yaml +defaults: + - model: random_forest + - data: default + - training: default + - _self_ + +mlflow: + tracking_uri: "file://${hydra:runtime.cwd}/mlruns" + experiment_name: "wine_quality_hydra" + +logging: + level: INFO + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +seed: 42 +output_dir: "outputs" +``` + +### 2.3 Композиция конфигураций + +Hydra автоматически объединяет конфигурации из разных файлов: + +```yaml +# conf/model/random_forest.yaml +name: "RandomForest" +_target_: "sklearn.ensemble.RandomForestClassifier" +params: + n_estimators: 100 + max_depth: 10 + random_state: ${seed} # Интерполяция из главного конфига +``` + +### 2.4 Валидация конфигураций + +Реализована валидация с использованием Pydantic (`src/config/schemas.py`): + +```python +class ModelConfig(BaseModel): + name: str = Field(..., description="Model name") + _target_: str = Field(..., description="Full path to model class") + params: dict[str, Any] = Field(default_factory=dict) + + @field_validator("name") + @classmethod + def validate_model_name(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Model name cannot be empty") + return v +``` + +### 2.5 Переопределение параметров + +```bash +# Изменение модели +python -m src.pipelines.train_pipeline model=gradient_boosting + +# Изменение гиперпараметров +python -m src.pipelines.train_pipeline model.params.n_estimators=200 + +# Multirun для нескольких моделей +python -m src.pipelines.train_pipeline --multirun model=random_forest,gradient_boosting +``` + +--- + +## 3. Интеграция и тестирование (2 балла) + +### 3.1 Интеграция DVC + Hydra + +Пайплайн интегрирует оба инструмента: +1. **DVC** управляет порядком выполнения и кэшированием +2. **Hydra** управляет конфигурациями для каждого этапа + +### 3.2 Мониторинг выполнения + +Реализован модуль мониторинга (`src/pipelines/monitoring.py`): + +```python +class PipelineMonitor: + def start_pipeline(self) -> None + def end_pipeline(self, success: bool, error: str = None) -> dict + def start_stage(self, stage_name: str) -> None + def end_stage(self, stage_name: str, success: bool) -> None + def save_report(self) -> Path +``` + +Пример вывода мониторинга: +``` +============================================================ +PIPELINE COMPLETED +Status: SUCCESS +Total duration: 3.77s +---------------------------------------- + ✓ data_loading: 0.01s + ✓ model_creation: 0.57s + ✓ training: 0.61s + ✓ mlflow_logging: 2.58s + ✓ save_results: 0.00s +============================================================ +``` + +### 3.3 Уведомления о результатах + +Система уведомлений логирует результаты в файл: +- `outputs/{model}/notifications.log` — лог уведомлений +- `outputs/{model}/pipeline_report.json` — детальный отчет + +### 3.4 Тестирование воспроизводимости + +Воспроизводимость обеспечивается через: +1. **Фиксированный seed** (`seed: 42` в конфигурации) +2. **DVC версионирование** данных и конфигураций +3. **MLflow tracking** для логирования экспериментов + +Команда для воспроизведения: +```bash +# Клонирование репозитория +git clone +cd epml_itmo + +# Установка зависимостей +poetry install + +# Запуск полного пайплайна +dvc repro +``` + +--- + +## 4. Результаты + +### 4.1 Сравнение моделей + +| Model | Accuracy | Precision | Recall | F1 Score | +|--------------------|----------|-----------|---------|----------| +| GradientBoosting | 0.6500 | 0.6394 | 0.6500 | **0.6393** | +| RandomForest | 0.6438 | 0.6108 | 0.6438 | 0.6240 | +| DecisionTree | 0.5531 | 0.5320 | 0.5531 | 0.5409 | +| LogisticRegression | 0.5719 | 0.5245 | 0.5719 | 0.5382 | +| SVM | 0.5094 | 0.5645 | 0.5094 | 0.4618 | +| KNN | 0.4562 | 0.4223 | 0.4562 | 0.4299 | + +**Лучшая модель**: GradientBoosting с F1 Score = 0.6393 + +### 4.2 DVC Metrics + +```bash +$ dvc metrics show +``` + +![DVC Metrics](reports/figures/dvc_metrics.png) + +### 4.3 DVC DAG + +```bash +$ dvc dag +``` + +``` + +---------+ + **************| prepare |****************** + * +---------+ * + * | * + * ┌───────────────┼───────────────┐ * + * ↓ ↓ ↓ * ++--------+ +------------+ +-------------+ +-----+ +--------+ +-----+ +|train_rf| |train_gb | |train_lr | |svm | |train_dt| |knn | ++--------+ +------------+ +-------------+ +-----+ +--------+ +-----+ + * * * * * * + * * * * * * + **********+-------+-------+--------------+----------+******** + ↓ + +----------+ + | evaluate | + +----------+ +``` + +--- + +## 5. Команды для воспроизведения + +### Быстрый старт + +```bash +# 1. Установка зависимостей +poetry install + +# 2. Запуск полного пайплайна +make pipeline + +# 3. Просмотр метрик +make metrics +``` + +### Отдельные команды + +```bash +# Подготовка данных +make prepare + +# Обучение конкретной модели +make train MODEL=random_forest + +# Обучение всех моделей +make train_all + +# Оценка и сравнение +make evaluate + +# Просмотр DAG +make dag + +# Очистка и перезапуск +make run_full +``` + +### Параметры Hydra + +```bash +# Изменение числа деревьев в Random Forest +python -m src.pipelines.train_pipeline model=random_forest model.params.n_estimators=200 + +# Изменение глубины +python -m src.pipelines.train_pipeline model=random_forest model.params.max_depth=15 + +# Мультизапуск +python -m src.pipelines.train_pipeline --multirun model=random_forest,gradient_boosting +``` + +--- + +## 6. Структура проекта + +``` +epml_itmo/ +├── conf/ # Hydra конфигурации +│ ├── config.yaml # Главный конфиг +│ ├── data/default.yaml # Конфиг данных +│ ├── model/ # Конфиги моделей +│ │ ├── random_forest.yaml +│ │ ├── gradient_boosting.yaml +│ │ ├── logistic_regression.yaml +│ │ ├── svm.yaml +│ │ ├── decision_tree.yaml +│ │ └── knn.yaml +│ └── training/default.yaml # Конфиг обучения +├── src/ +│ ├── config/ # Pydantic схемы валидации +│ │ ├── __init__.py +│ │ └── schemas.py +│ └── pipelines/ # Скрипты пайплайнов +│ ├── __init__.py +│ ├── prepare_data.py # Подготовка данных +│ ├── train_pipeline.py # Обучение модели +│ ├── evaluate_models.py # Оценка моделей +│ ├── run_all_models.py # Запуск всех моделей +│ └── monitoring.py # Мониторинг +├── dvc.yaml # DVC pipeline конфигурация +├── dvc.lock # DVC lock file +├── Makefile # Команды Make +├── outputs/ # Выходные данные +│ ├── randomforest/ +│ ├── gradientboosting/ +│ ├── ... +│ └── comparison/ +└── mlruns/ # MLflow артефакты +``` + +--- + +## Заключение + +В рамках данного домашнего задания реализована полная автоматизация ML пайплайнов: + +1. **DVC Pipelines** обеспечивает: + - Автоматическое определение зависимостей + - Кэширование результатов + - Параллельное выполнение + - Отслеживание метрик и параметров + +2. **Hydra** обеспечивает: + - Иерархическую композицию конфигураций + - Валидацию через Pydantic + - Переопределение параметров из CLI + - Интерполяцию переменных + +3. **Интеграция**: + - Мониторинг выполнения с детальными отчетами + - Уведомления о результатах + - Полная воспроизводимость экспериментов + +Все результаты воспроизводимы через команду `dvc repro`. + diff --git a/conf/config.yaml b/conf/config.yaml new file mode 100644 index 0000000..d5194cd --- /dev/null +++ b/conf/config.yaml @@ -0,0 +1,25 @@ +# Main Hydra configuration file +# This file composes all sub-configurations + +defaults: + - model: random_forest + - data: default + - training: default + - _self_ + +# MLflow configuration +mlflow: + tracking_uri: "file://${hydra:runtime.cwd}/mlruns" + experiment_name: "wine_quality_hydra" + +# Logging configuration +logging: + level: INFO + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +# Random seed for reproducibility +seed: 42 + +# Output directory for results +output_dir: "outputs" + diff --git a/conf/data/default.yaml b/conf/data/default.yaml new file mode 100644 index 0000000..8fcc968 --- /dev/null +++ b/conf/data/default.yaml @@ -0,0 +1,13 @@ +# Data configuration +raw_path: "data/raw" +processed_path: "data/processed" +train_file: "train.csv" +test_file: "test.csv" +target_column: "quality" +test_size: 0.2 +random_state: ${seed} + +# Data preprocessing options +preprocessing: + normalize: false + handle_missing: "drop" # drop, mean, median diff --git a/conf/model/decision_tree.yaml b/conf/model/decision_tree.yaml new file mode 100644 index 0000000..6dbe3f2 --- /dev/null +++ b/conf/model/decision_tree.yaml @@ -0,0 +1,11 @@ +# Decision Tree model configuration +name: "DecisionTree" +_target_: "sklearn.tree.DecisionTreeClassifier" + +# Model hyperparameters +params: + max_depth: 10 + min_samples_split: 2 + min_samples_leaf: 1 + criterion: "gini" + random_state: ${seed} diff --git a/conf/model/gradient_boosting.yaml b/conf/model/gradient_boosting.yaml new file mode 100644 index 0000000..b7721ad --- /dev/null +++ b/conf/model/gradient_boosting.yaml @@ -0,0 +1,13 @@ +# Gradient Boosting model configuration +name: "GradientBoosting" +_target_: "sklearn.ensemble.GradientBoostingClassifier" + +# Model hyperparameters +params: + n_estimators: 100 + learning_rate: 0.1 + max_depth: 3 + min_samples_split: 2 + min_samples_leaf: 1 + subsample: 1.0 + random_state: ${seed} diff --git a/conf/model/knn.yaml b/conf/model/knn.yaml new file mode 100644 index 0000000..7fb6358 --- /dev/null +++ b/conf/model/knn.yaml @@ -0,0 +1,12 @@ +# K-Nearest Neighbors model configuration +name: "KNN" +_target_: "sklearn.neighbors.KNeighborsClassifier" + +# Model hyperparameters +params: + n_neighbors: 5 + weights: "uniform" + algorithm: "auto" + leaf_size: 30 + p: 2 # Euclidean distance + n_jobs: -1 diff --git a/conf/model/logistic_regression.yaml b/conf/model/logistic_regression.yaml new file mode 100644 index 0000000..4099a20 --- /dev/null +++ b/conf/model/logistic_regression.yaml @@ -0,0 +1,11 @@ +# Logistic Regression model configuration +name: "LogisticRegression" +_target_: "sklearn.linear_model.LogisticRegression" + +# Model hyperparameters +params: + C: 1.0 + penalty: "l2" + solver: "lbfgs" + max_iter: 1000 + random_state: ${seed} diff --git a/conf/model/random_forest.yaml b/conf/model/random_forest.yaml new file mode 100644 index 0000000..15479e5 --- /dev/null +++ b/conf/model/random_forest.yaml @@ -0,0 +1,14 @@ +# Random Forest model configuration +name: "RandomForest" +_target_: "sklearn.ensemble.RandomForestClassifier" + +# Model hyperparameters +params: + n_estimators: 100 + max_depth: 10 + min_samples_split: 2 + min_samples_leaf: 1 + max_features: "sqrt" + bootstrap: true + random_state: ${seed} + n_jobs: -1 diff --git a/conf/model/svm.yaml b/conf/model/svm.yaml new file mode 100644 index 0000000..b0dd99e --- /dev/null +++ b/conf/model/svm.yaml @@ -0,0 +1,10 @@ +# SVM model configuration +name: "SVM" +_target_: "sklearn.svm.SVC" + +# Model hyperparameters +params: + C: 1.0 + kernel: "rbf" + gamma: "scale" + random_state: ${seed} diff --git a/conf/training/default.yaml b/conf/training/default.yaml new file mode 100644 index 0000000..4fa36f6 --- /dev/null +++ b/conf/training/default.yaml @@ -0,0 +1,20 @@ +# Training configuration +cv_folds: 5 +shuffle: true + +# Metrics to track +metrics: + - accuracy + - precision + - recall + - f1_score + +# Model registration +register_model: true +model_name: "${model.name}" + +# Early stopping (for applicable models) +early_stopping: + enabled: false + patience: 10 + min_delta: 0.001 diff --git a/dvc.lock b/dvc.lock index ea9196f..46084c8 100644 --- a/dvc.lock +++ b/dvc.lock @@ -1,16 +1,26 @@ schema: '2.0' stages: prepare: - cmd: python src/data/make_dataset.py data/raw data/processed + cmd: python -m src.pipelines.prepare_data deps: - - path: data/raw/winequality-red.csv + - path: conf/config.yaml hash: md5 - md5: 2daeecee174368f8a33b82c8cccae3a5 - size: 84199 - - path: src/data/make_dataset.py + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/data/default.yaml hash: md5 - md5: c485f51def978f2d9c6e2f92c5049db4 - size: 1853 + md5: ff90d2a07156af29118ad21498160520 + size: 294 + - path: src/pipelines/prepare_data.py + hash: md5 + md5: 2010975563acf3f8875478837357313c + size: 5368 + params: + conf/config.yaml: + seed: 42 + conf/data/default.yaml: + random_state: ${seed} + test_size: 0.2 outs: - path: data/processed hash: md5 @@ -29,3 +39,294 @@ stages: hash: md5 md5: 715de07ddf949c59578ad0717ed3d400 size: 2887 + train_random_forest: + cmd: python -m src.pipelines.train_pipeline model=random_forest + deps: + - path: conf/config.yaml + hash: md5 + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/model/random_forest.yaml + hash: md5 + md5: 009db803cc0475494bbc3ad4c7d2dbf0 + size: 301 + - path: data/processed + hash: md5 + md5: e32cf3ebecf024f29e7403c2720f9f65.dir + size: 92145 + nfiles: 2 + - path: src/pipelines/monitoring.py + hash: md5 + md5: d2e9c4ff1a4843f2a4530913d40f29c0 + size: 9957 + - path: src/pipelines/train_pipeline.py + hash: md5 + md5: 0a67fe036f5c540d1af6435a196c2be3 + size: 11725 + params: + conf/model/random_forest.yaml: + params: + n_estimators: 100 + max_depth: 10 + min_samples_split: 2 + min_samples_leaf: 1 + max_features: sqrt + bootstrap: true + random_state: ${seed} + n_jobs: -1 + outs: + - path: outputs/randomforest/metrics.json + hash: md5 + md5: 8740a394154bf4df9dae5e1678329223 + size: 351 + - path: outputs/randomforest/pipeline_report.json + hash: md5 + md5: c72edc1e12f6495755390b4991efb04d + size: 1612 + train_gradient_boosting: + cmd: python -m src.pipelines.train_pipeline model=gradient_boosting + deps: + - path: conf/config.yaml + hash: md5 + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/model/gradient_boosting.yaml + hash: md5 + md5: b5246eb98037315726a98e29a134f19b + size: 296 + - path: data/processed + hash: md5 + md5: e32cf3ebecf024f29e7403c2720f9f65.dir + size: 92145 + nfiles: 2 + - path: src/pipelines/monitoring.py + hash: md5 + md5: d2e9c4ff1a4843f2a4530913d40f29c0 + size: 9957 + - path: src/pipelines/train_pipeline.py + hash: md5 + md5: 0a67fe036f5c540d1af6435a196c2be3 + size: 11725 + params: + conf/model/gradient_boosting.yaml: + params: + n_estimators: 100 + learning_rate: 0.1 + max_depth: 3 + min_samples_split: 2 + min_samples_leaf: 1 + subsample: 1.0 + random_state: ${seed} + outs: + - path: outputs/gradientboosting/metrics.json + hash: md5 + md5: b984009fc498829906a97c5997ffbd32 + size: 348 + - path: outputs/gradientboosting/pipeline_report.json + hash: md5 + md5: 0627eb1efaebdccacb5606ff9490a992 + size: 1602 + train_logistic_regression: + cmd: python -m src.pipelines.train_pipeline model=logistic_regression + deps: + - path: conf/config.yaml + hash: md5 + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/model/logistic_regression.yaml + hash: md5 + md5: d3c0db59f2560b9f0377ee317368c1a1 + size: 238 + - path: data/processed + hash: md5 + md5: e32cf3ebecf024f29e7403c2720f9f65.dir + size: 92145 + nfiles: 2 + - path: src/pipelines/monitoring.py + hash: md5 + md5: d2e9c4ff1a4843f2a4530913d40f29c0 + size: 9957 + - path: src/pipelines/train_pipeline.py + hash: md5 + md5: 0a67fe036f5c540d1af6435a196c2be3 + size: 11725 + params: + conf/model/logistic_regression.yaml: + params: + C: 1.0 + penalty: l2 + solver: lbfgs + max_iter: 1000 + random_state: ${seed} + outs: + - path: outputs/logisticregression/metrics.json + hash: md5 + md5: 3e82c66b555205263cb78b6c40efff72 + size: 359 + - path: outputs/logisticregression/pipeline_report.json + hash: md5 + md5: b84e6951b9d38b73189d58615d9f6113 + size: 1528 + train_svm: + cmd: python -m src.pipelines.train_pipeline model=svm + deps: + - path: conf/config.yaml + hash: md5 + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/model/svm.yaml + hash: md5 + md5: 8989392bf681dba9d9218f2b843d8814 + size: 165 + - path: data/processed + hash: md5 + md5: e32cf3ebecf024f29e7403c2720f9f65.dir + size: 92145 + nfiles: 2 + - path: src/pipelines/monitoring.py + hash: md5 + md5: d2e9c4ff1a4843f2a4530913d40f29c0 + size: 9957 + - path: src/pipelines/train_pipeline.py + hash: md5 + md5: 0a67fe036f5c540d1af6435a196c2be3 + size: 11725 + params: + conf/model/svm.yaml: + params: + C: 1.0 + kernel: rbf + gamma: scale + random_state: ${seed} + outs: + - path: outputs/svm/metrics.json + hash: md5 + md5: 1765a3dc33179a2b335b2bd8b3cb3dd6 + size: 343 + - path: outputs/svm/pipeline_report.json + hash: md5 + md5: d5fa3b762f64f06b9bacb57f2e78d8bb + size: 1476 + train_decision_tree: + cmd: python -m src.pipelines.train_pipeline model=decision_tree + deps: + - path: conf/config.yaml + hash: md5 + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/model/decision_tree.yaml + hash: md5 + md5: 8bd548ac7ed28073f3d276ee1fca8fc1 + size: 243 + - path: data/processed + hash: md5 + md5: e32cf3ebecf024f29e7403c2720f9f65.dir + size: 92145 + nfiles: 2 + - path: src/pipelines/monitoring.py + hash: md5 + md5: d2e9c4ff1a4843f2a4530913d40f29c0 + size: 9957 + - path: src/pipelines/train_pipeline.py + hash: md5 + md5: 0a67fe036f5c540d1af6435a196c2be3 + size: 11725 + params: + conf/model/decision_tree.yaml: + params: + max_depth: 10 + min_samples_split: 2 + min_samples_leaf: 1 + criterion: gini + random_state: ${seed} + outs: + - path: outputs/decisiontree/metrics.json + hash: md5 + md5: f2d5834a94d8163089349ec24990022a + size: 344 + - path: outputs/decisiontree/pipeline_report.json + hash: md5 + md5: ffe0cfe4a8c6c1b732ab476d401660dd + size: 1539 + train_knn: + cmd: python -m src.pipelines.train_pipeline model=knn + deps: + - path: conf/config.yaml + hash: md5 + md5: 0f693e7eafdf2dffbdecbe1887a39c38 + size: 500 + - path: conf/model/knn.yaml + hash: md5 + md5: 48a5609baa64a6642e2836d4cd27cc5c + size: 254 + - path: data/processed + hash: md5 + md5: e32cf3ebecf024f29e7403c2720f9f65.dir + size: 92145 + nfiles: 2 + - path: src/pipelines/monitoring.py + hash: md5 + md5: d2e9c4ff1a4843f2a4530913d40f29c0 + size: 9957 + - path: src/pipelines/train_pipeline.py + hash: md5 + md5: 0a67fe036f5c540d1af6435a196c2be3 + size: 11725 + params: + conf/model/knn.yaml: + params: + n_neighbors: 5 + weights: uniform + algorithm: auto + leaf_size: 30 + p: 2 + n_jobs: -1 + outs: + - path: outputs/knn/metrics.json + hash: md5 + md5: 17ec88c3113f44c1206c220074c26907 + size: 343 + - path: outputs/knn/pipeline_report.json + hash: md5 + md5: 8670988def549f6bdd474760db8e350e + size: 1519 + evaluate: + cmd: python -m src.pipelines.evaluate_models + deps: + - path: outputs/decisiontree/metrics.json + hash: md5 + md5: f2d5834a94d8163089349ec24990022a + size: 344 + - path: outputs/gradientboosting/metrics.json + hash: md5 + md5: b984009fc498829906a97c5997ffbd32 + size: 348 + - path: outputs/knn/metrics.json + hash: md5 + md5: 17ec88c3113f44c1206c220074c26907 + size: 343 + - path: outputs/logisticregression/metrics.json + hash: md5 + md5: 3e82c66b555205263cb78b6c40efff72 + size: 359 + - path: outputs/randomforest/metrics.json + hash: md5 + md5: 8740a394154bf4df9dae5e1678329223 + size: 351 + - path: outputs/svm/metrics.json + hash: md5 + md5: 1765a3dc33179a2b335b2bd8b3cb3dd6 + size: 343 + - path: src/pipelines/evaluate_models.py + hash: md5 + md5: d375924fefbc7c3697dbb28e8943e3e0 + size: 7035 + outs: + - path: outputs/comparison/best_model.json + hash: md5 + md5: df0009a067adc5ce9c34fa9627641f13 + size: 464 + - path: outputs/comparison/metrics_comparison.csv + hash: md5 + md5: c8dd433e3bccfd956775b116f57a4a37 + size: 1075 diff --git a/dvc.yaml b/dvc.yaml index b623072..67506b4 100644 --- a/dvc.yaml +++ b/dvc.yaml @@ -1,15 +1,151 @@ stages: + # Data preparation stage - downloads and splits data prepare: - cmd: python src/data/make_dataset.py data/raw data/processed + cmd: python -m src.pipelines.prepare_data deps: - - data/raw/winequality-red.csv - - src/data/make_dataset.py + - src/pipelines/prepare_data.py + - conf/data/default.yaml + - conf/config.yaml + params: + - conf/config.yaml: + - seed + - conf/data/default.yaml: + - test_size + - random_state outs: + - data/processed: + cache: true + + # Training stage with Random Forest + train_random_forest: + cmd: python -m src.pipelines.train_pipeline model=random_forest + deps: + - data/processed + - src/pipelines/train_pipeline.py + - src/pipelines/monitoring.py + - conf/model/random_forest.yaml + - conf/config.yaml + params: + - conf/model/random_forest.yaml: + - params + plots: + - outputs/randomforest/pipeline_report.json: + cache: false + metrics: + - outputs/randomforest/metrics.json: + cache: false + + # Training stage with Gradient Boosting + train_gradient_boosting: + cmd: python -m src.pipelines.train_pipeline model=gradient_boosting + deps: + - data/processed + - src/pipelines/train_pipeline.py + - src/pipelines/monitoring.py + - conf/model/gradient_boosting.yaml + - conf/config.yaml + params: + - conf/model/gradient_boosting.yaml: + - params + plots: + - outputs/gradientboosting/pipeline_report.json: + cache: false + metrics: + - outputs/gradientboosting/metrics.json: + cache: false + + # Training stage with Logistic Regression + train_logistic_regression: + cmd: python -m src.pipelines.train_pipeline model=logistic_regression + deps: - data/processed + - src/pipelines/train_pipeline.py + - src/pipelines/monitoring.py + - conf/model/logistic_regression.yaml + - conf/config.yaml + params: + - conf/model/logistic_regression.yaml: + - params + plots: + - outputs/logisticregression/pipeline_report.json: + cache: false + metrics: + - outputs/logisticregression/metrics.json: + cache: false - train: - cmd: python src/models/train_model.py data/processed + # Training stage with SVM + train_svm: + cmd: python -m src.pipelines.train_pipeline model=svm deps: - data/processed - - src/models/train_model.py + - src/pipelines/train_pipeline.py + - src/pipelines/monitoring.py + - conf/model/svm.yaml + - conf/config.yaml + params: + - conf/model/svm.yaml: + - params + plots: + - outputs/svm/pipeline_report.json: + cache: false + metrics: + - outputs/svm/metrics.json: + cache: false + # Training stage with Decision Tree + train_decision_tree: + cmd: python -m src.pipelines.train_pipeline model=decision_tree + deps: + - data/processed + - src/pipelines/train_pipeline.py + - src/pipelines/monitoring.py + - conf/model/decision_tree.yaml + - conf/config.yaml + params: + - conf/model/decision_tree.yaml: + - params + plots: + - outputs/decisiontree/pipeline_report.json: + cache: false + metrics: + - outputs/decisiontree/metrics.json: + cache: false + + # Training stage with KNN + train_knn: + cmd: python -m src.pipelines.train_pipeline model=knn + deps: + - data/processed + - src/pipelines/train_pipeline.py + - src/pipelines/monitoring.py + - conf/model/knn.yaml + - conf/config.yaml + params: + - conf/model/knn.yaml: + - params + plots: + - outputs/knn/pipeline_report.json: + cache: false + metrics: + - outputs/knn/metrics.json: + cache: false + + # Evaluate all models - compare results + evaluate: + cmd: python -m src.pipelines.evaluate_models + deps: + - outputs/randomforest/metrics.json + - outputs/gradientboosting/metrics.json + - outputs/logisticregression/metrics.json + - outputs/svm/metrics.json + - outputs/decisiontree/metrics.json + - outputs/knn/metrics.json + - src/pipelines/evaluate_models.py + metrics: + - outputs/comparison/best_model.json: + cache: false + plots: + - outputs/comparison/metrics_comparison.csv: + cache: false + x: model + y: accuracy diff --git a/poetry.lock b/poetry.lock index 1f32b22..e066068 100644 --- a/poetry.lock +++ b/poetry.lock @@ -5618,4 +5618,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.12" -content-hash = "a47fd0f19b7dfcb319201abbce2b854839d2af70db4987f9545adb47a4846c82" +content-hash = "e9167ea8f0e5b0e4661167d3f2c9176145730cb536686c7cf66c9930eb584c70" diff --git a/pyproject.toml b/pyproject.toml index 66577f9..86e764d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ scikit-learn = "^1.7.2" ipykernel = "^7.1.0" dvc = "^3.64.2" mlflow = "^3.7.0" +hydra-core = "^1.3.2" +omegaconf = "^2.3.0" +pydantic = "^2.12.5" [tool.poetry.group.dev.dependencies] ruff = "^0.14.6" diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..b633105 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,21 @@ +"""Configuration module with Pydantic schemas.""" + +from src.config.schemas import ( + DataConfig, + LoggingConfig, + MLflowConfig, + ModelConfig, + PipelineConfig, + TrainingConfig, + validate_config, +) + +__all__ = [ + "DataConfig", + "LoggingConfig", + "MLflowConfig", + "ModelConfig", + "PipelineConfig", + "TrainingConfig", + "validate_config", +] diff --git a/src/config/schemas.py b/src/config/schemas.py new file mode 100644 index 0000000..56f97c1 --- /dev/null +++ b/src/config/schemas.py @@ -0,0 +1,137 @@ +""" +Configuration validation schemas using Pydantic. +Provides type-safe configuration validation for the ML pipeline. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator + + +class DataConfig(BaseModel): # type: ignore[misc] + """Data configuration schema.""" + + raw_path: str = Field(default="data/raw", description="Path to raw data") + processed_path: str = Field( + default="data/processed", description="Path to processed data" + ) + train_file: str = Field(default="train.csv", description="Training data filename") + test_file: str = Field(default="test.csv", description="Test data filename") + target_column: str = Field(default="quality", description="Target column name") + test_size: float = Field( + default=0.2, ge=0.0, le=1.0, description="Test set size ratio" + ) + random_state: int = Field(default=42, description="Random state for splitting") + + +class PreprocessingConfig(BaseModel): # type: ignore[misc] + """Preprocessing configuration schema.""" + + normalize: bool = Field(default=False, description="Whether to normalize features") + handle_missing: Literal["drop", "mean", "median"] = Field( + default="drop", description="Missing value handling strategy" + ) + + +class DataFullConfig(BaseModel): # type: ignore[misc] + """Full data configuration including preprocessing.""" + + data: DataConfig = Field(default_factory=DataConfig) + preprocessing: PreprocessingConfig = Field(default_factory=PreprocessingConfig) + + +class ModelParamsConfig(BaseModel): # type: ignore[misc] + """Base model parameters configuration.""" + + random_state: int = Field(default=42, description="Random state for model") + + class Config: + extra = "allow" # Allow additional model-specific parameters + + +class ModelConfig(BaseModel): # type: ignore[misc] + """Model configuration schema.""" + + name: str = Field(..., description="Model name") + _target_: str = Field(..., description="Full path to model class") + params: dict[str, Any] = Field( + default_factory=dict, description="Model hyperparameters" + ) + + @field_validator("name") # type: ignore[misc] + @classmethod + def validate_model_name(cls, v: str) -> str: + """Validate model name is not empty.""" + if not v or not v.strip(): + raise ValueError("Model name cannot be empty") + return v + + +class TrainingConfig(BaseModel): # type: ignore[misc] + """Training configuration schema.""" + + cv_folds: int = Field(default=5, ge=2, le=20, description="Number of CV folds") + shuffle: bool = Field(default=True, description="Shuffle data before CV") + metrics: list[str] = Field( + default=["accuracy", "precision", "recall", "f1_score"], + description="Metrics to track", + ) + register_model: bool = Field(default=True, description="Register model to MLflow") + model_name: str = Field(default="", description="Registered model name") + + +class EarlyStoppingConfig(BaseModel): # type: ignore[misc] + """Early stopping configuration schema.""" + + enabled: bool = Field(default=False, description="Enable early stopping") + patience: int = Field(default=10, ge=1, description="Patience for early stopping") + min_delta: float = Field( + default=0.001, ge=0.0, description="Minimum improvement delta" + ) + + +class MLflowConfig(BaseModel): # type: ignore[misc] + """MLflow configuration schema.""" + + tracking_uri: str = Field(..., description="MLflow tracking URI") + experiment_name: str = Field(..., description="Experiment name") + + +class LoggingConfig(BaseModel): # type: ignore[misc] + """Logging configuration schema.""" + + level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field( + default="INFO", description="Logging level" + ) + format: str = Field( + default="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + description="Log format string", + ) + + +class PipelineConfig(BaseModel): # type: ignore[misc] + """Full pipeline configuration schema.""" + + model: ModelConfig + data: DataConfig = Field(default_factory=DataConfig) + training: TrainingConfig = Field(default_factory=TrainingConfig) + mlflow: MLflowConfig + logging: LoggingConfig = Field(default_factory=LoggingConfig) + seed: int = Field(default=42, description="Global random seed") + output_dir: str = Field(default="outputs", description="Output directory") + + +def validate_config(config: dict[str, Any]) -> PipelineConfig: + """ + Validate configuration dictionary against schema. + + Args: + config: Configuration dictionary from Hydra + + Returns: + Validated PipelineConfig instance + + Raises: + ValidationError: If configuration is invalid + """ + return PipelineConfig(**config) diff --git a/src/pipelines/__init__.py b/src/pipelines/__init__.py new file mode 100644 index 0000000..34cac6c --- /dev/null +++ b/src/pipelines/__init__.py @@ -0,0 +1 @@ +"""Pipelines module for ML workflow orchestration.""" diff --git a/src/pipelines/evaluate_models.py b/src/pipelines/evaluate_models.py new file mode 100644 index 0000000..12d9c0b --- /dev/null +++ b/src/pipelines/evaluate_models.py @@ -0,0 +1,276 @@ +""" +Model evaluation and comparison script. + +Collects metrics from all trained models and generates comparison reports. +""" + +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +import pandas as pd + +logger = logging.getLogger(__name__) + +# Model names to evaluate (directory names are lowercase without separators) +MODEL_NAMES = [ + "randomforest", + "gradientboosting", + "logisticregression", + "svm", + "decisiontree", + "knn", +] + + +def load_model_metrics(model_name: str, base_path: Path) -> dict[str, Any] | None: + """ + Load metrics for a single model. + + Args: + model_name: Name of the model + base_path: Base outputs directory + + Returns: + Metrics dictionary or None if not found + """ + metrics_path = base_path / model_name / "metrics.json" + + if not metrics_path.exists(): + logger.warning(f"Metrics not found for {model_name}: {metrics_path}") + return None + + with open(metrics_path) as f: + data = json.load(f) + + return { + "model": model_name, + **data.get("metrics", {}), + "run_id": data.get("run_id", ""), + "timestamp": data.get("timestamp", ""), + } + + +def collect_all_metrics(base_path: Path) -> list[dict[str, Any]]: + """ + Collect metrics from all models. + + Args: + base_path: Base outputs directory + + Returns: + List of metrics dictionaries + """ + all_metrics = [] + + for model_name in MODEL_NAMES: + metrics = load_model_metrics(model_name, base_path) + if metrics: + all_metrics.append(metrics) + + logger.info(f"Collected metrics from {len(all_metrics)} models") + return all_metrics + + +def find_best_model( + metrics_list: list[dict[str, Any]], metric: str = "f1_score" +) -> dict[str, Any]: + """ + Find the best model based on specified metric. + + Args: + metrics_list: List of metrics dictionaries + metric: Metric to use for comparison + + Returns: + Best model information + """ + if not metrics_list: + return {"error": "No metrics available"} + + best = max(metrics_list, key=lambda x: x.get(metric, 0)) + + return { + "best_model": best["model"], + "best_metric_name": metric, + "best_metric_value": best.get(metric, 0), + "run_id": best.get("run_id", ""), + "all_metrics": {m["model"]: m.get(metric, 0) for m in metrics_list}, + } + + +def create_comparison_table(metrics_list: list[dict[str, Any]]) -> pd.DataFrame: + """ + Create comparison DataFrame. + + Args: + metrics_list: List of metrics dictionaries + + Returns: + Comparison DataFrame + """ + if not metrics_list: + return pd.DataFrame() + + df = pd.DataFrame(metrics_list) + + # Reorder columns + cols = ["model", "accuracy", "precision", "recall", "f1_score"] + existing_cols = [c for c in cols if c in df.columns] + other_cols = [c for c in df.columns if c not in cols] + df = df[existing_cols + other_cols] + + # Sort by f1_score descending + if "f1_score" in df.columns: + df = df.sort_values("f1_score", ascending=False) + + return df + + +def generate_report( + metrics_list: list[dict[str, Any]], best_model: dict[str, Any] +) -> str: + """ + Generate text comparison report. + + Args: + metrics_list: List of metrics dictionaries + best_model: Best model information + + Returns: + Report text + """ + lines = [ + "=" * 70, + "MODEL COMPARISON REPORT", + "=" * 70, + f"Generated: {datetime.now().isoformat()}", + f"Models evaluated: {len(metrics_list)}", + "", + "-" * 70, + "RESULTS (sorted by F1 Score)", + "-" * 70, + "", + f"{'Model':<25} {'Accuracy':>10} {'Precision':>10} {'Recall':>10} {'F1':>10}", + "-" * 70, + ] + + # Sort by f1_score + sorted_metrics = sorted( + metrics_list, key=lambda x: x.get("f1_score", 0), reverse=True + ) + + for m in sorted_metrics: + lines.append( + f"{m['model']:<25} " + f"{m.get('accuracy', 0):>10.4f} " + f"{m.get('precision', 0):>10.4f} " + f"{m.get('recall', 0):>10.4f} " + f"{m.get('f1_score', 0):>10.4f}" + ) + + lines.extend( + [ + "", + "-" * 70, + "BEST MODEL", + "-" * 70, + f"Model: {best_model.get('best_model', 'N/A')}", + f"F1 Score: {best_model.get('best_metric_value', 0):.4f}", + f"MLflow Run ID: {best_model.get('run_id', 'N/A')}", + "", + "=" * 70, + ] + ) + + return "\n".join(lines) + + +def save_results( + metrics_list: list[dict[str, Any]], + best_model: dict[str, Any], + comparison_df: pd.DataFrame, + output_path: Path, +) -> None: + """ + Save evaluation results. + + Args: + metrics_list: List of metrics dictionaries + best_model: Best model information + comparison_df: Comparison DataFrame + output_path: Output directory + """ + output_path.mkdir(parents=True, exist_ok=True) + + # Save best model info + best_model_path = output_path / "best_model.json" + with open(best_model_path, "w") as f: + json.dump( + { + **best_model, + "timestamp": datetime.now().isoformat(), + }, + f, + indent=2, + ) + logger.info(f"Best model info saved to: {best_model_path}") + + # Save comparison CSV + comparison_csv_path = output_path / "metrics_comparison.csv" + comparison_df.to_csv(comparison_csv_path, index=False) + logger.info(f"Comparison CSV saved to: {comparison_csv_path}") + + # Save full metrics + full_metrics_path = output_path / "all_metrics.json" + with open(full_metrics_path, "w") as f: + json.dump(metrics_list, f, indent=2) + logger.info(f"All metrics saved to: {full_metrics_path}") + + # Save report + report = generate_report(metrics_list, best_model) + report_path = output_path / "comparison_report.txt" + with open(report_path, "w") as f: + f.write(report) + logger.info(f"Report saved to: {report_path}") + + # Print report + print(report) + + +def main() -> None: + """Main evaluation function.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + logger.info("Starting model evaluation") + + # Paths + base_path = Path("outputs") + output_path = base_path / "comparison" + + # Collect metrics + metrics_list = collect_all_metrics(base_path) + + if not metrics_list: + logger.error("No metrics found. Run training pipelines first.") + return + + # Find best model + best_model = find_best_model(metrics_list) + + # Create comparison table + comparison_df = create_comparison_table(metrics_list) + + # Save results + save_results(metrics_list, best_model, comparison_df, output_path) + + logger.info("Model evaluation completed") + + +if __name__ == "__main__": + main() diff --git a/src/pipelines/monitoring.py b/src/pipelines/monitoring.py new file mode 100644 index 0000000..cea565f --- /dev/null +++ b/src/pipelines/monitoring.py @@ -0,0 +1,336 @@ +""" +Pipeline monitoring and notification system. + +Provides execution tracking, timing, and reporting for ML pipelines. +""" + +import json +import logging +import smtplib +import time +from datetime import datetime +from email.mime.text import MIMEText +from pathlib import Path +from typing import Any + +from omegaconf import DictConfig + +logger = logging.getLogger(__name__) + + +class PipelineMonitor: + """ + Monitor for tracking pipeline execution. + + Tracks timing, success/failure status, and generates reports. + """ + + def __init__(self, cfg: DictConfig): + """ + Initialize pipeline monitor. + + Args: + cfg: Hydra configuration object + """ + self.cfg = cfg + self.stages: dict[str, dict[str, Any]] = {} + self.pipeline_start: float | None = None + self.pipeline_end: float | None = None + self.current_stage: str | None = None + + def start_pipeline(self) -> None: + """Mark pipeline start.""" + self.pipeline_start = time.time() + logger.info("=" * 60) + logger.info("PIPELINE STARTED") + logger.info(f"Model: {self.cfg.model.name}") + logger.info(f"Timestamp: {datetime.now().isoformat()}") + logger.info("=" * 60) + + def end_pipeline( + self, success: bool = True, error: str | None = None + ) -> dict[str, Any]: + """ + Mark pipeline end and generate summary. + + Args: + success: Whether pipeline completed successfully + error: Error message if failed + + Returns: + Pipeline execution report + """ + self.pipeline_end = time.time() + duration = self.pipeline_end - (self.pipeline_start or 0) + + report = { + "success": success, + "model": self.cfg.model.name, + "total_duration_seconds": round(duration, 2), + "timestamp": datetime.now().isoformat(), + "stages": self.stages, + } + + if error: + report["error"] = error + + # Log summary + logger.info("=" * 60) + logger.info("PIPELINE COMPLETED") + logger.info(f"Status: {'SUCCESS' if success else 'FAILED'}") + logger.info(f"Total duration: {duration:.2f}s") + logger.info("-" * 40) + + for stage_name, stage_info in self.stages.items(): + status = "✓" if stage_info.get("success") else "✗" + stage_duration = stage_info.get("duration", 0) + logger.info(f" {status} {stage_name}: {stage_duration:.2f}s") + + if error: + logger.error(f"Error: {error}") + + logger.info("=" * 60) + + # Send notification + self._send_notification(report) + + return report + + def start_stage(self, stage_name: str) -> None: + """ + Mark stage start. + + Args: + stage_name: Name of the stage + """ + self.current_stage = stage_name + self.stages[stage_name] = { + "start_time": time.time(), + "status": "running", + } + logger.info(f"[STAGE] Starting: {stage_name}") + + def end_stage( + self, stage_name: str, success: bool = True, error: str | None = None + ) -> None: + """ + Mark stage end. + + Args: + stage_name: Name of the stage + success: Whether stage completed successfully + error: Error message if failed + """ + if stage_name not in self.stages: + logger.warning(f"Stage {stage_name} was not started") + return + + end_time = time.time() + start_time = self.stages[stage_name]["start_time"] + duration = end_time - start_time + + self.stages[stage_name].update( + { + "end_time": end_time, + "duration": round(duration, 2), + "success": success, + "status": "completed" if success else "failed", + } + ) + + if error: + self.stages[stage_name]["error"] = error + + status_msg = "✓ Completed" if success else "✗ Failed" + logger.info(f"[STAGE] {status_msg}: {stage_name} ({duration:.2f}s)") + + self.current_stage = None + + def save_report(self, output_path: Path | None = None) -> Path: + """ + Save execution report to file. + + Args: + output_path: Optional custom output path + + Returns: + Path to saved report + """ + if output_path is None: + output_path = Path(self.cfg.output_dir) / "pipeline_report.json" + + output_path.parent.mkdir(parents=True, exist_ok=True) + + report = { + "model": self.cfg.model.name, + "experiment": self.cfg.mlflow.experiment_name, + "timestamp": datetime.now().isoformat(), + "total_duration": ( + round((self.pipeline_end or 0) - (self.pipeline_start or 0), 2) + ), + "stages": self.stages, + "config": { + "model_params": dict(self.cfg.model.params), + "training": dict(self.cfg.training), + "seed": self.cfg.seed, + }, + } + + with open(output_path, "w") as f: + json.dump(report, f, indent=2, default=str) + + logger.info(f"Pipeline report saved to: {output_path}") + return output_path + + def _send_notification(self, report: dict[str, Any]) -> None: + """ + Send notification about pipeline completion. + + Currently logs to file. Can be extended for email/Slack notifications. + + Args: + report: Pipeline execution report + """ + # Log notification to file + notification_file = Path(self.cfg.output_dir) / "notifications.log" + notification_file.parent.mkdir(parents=True, exist_ok=True) + + status = "SUCCESS" if report["success"] else "FAILED" + message = f""" +================================================================================ +PIPELINE NOTIFICATION - {status} +================================================================================ +Model: {report['model']} +Timestamp: {report['timestamp']} +Duration: {report['total_duration_seconds']}s + +Stages: +""" + for stage_name, stage_info in report.get("stages", {}).items(): + stage_status = "✓" if stage_info.get("success") else "✗" + message += ( + f" {stage_status} {stage_name}: {stage_info.get('duration', 0):.2f}s\n" + ) + + if not report["success"]: + message += f"\nError: {report.get('error', 'Unknown error')}\n" + + message += "=" * 80 + "\n" + + with open(notification_file, "a") as f: + f.write(message) + + logger.info(f"Notification logged to: {notification_file}") + + # Console notification + if report["success"]: + logger.info("🎉 Pipeline completed successfully!") + else: + logger.error("❌ Pipeline failed! Check logs for details.") + + +class EmailNotifier: + """ + Email notification handler for pipeline events. + + Note: Requires SMTP configuration. Disabled by default. + """ + + def __init__( + self, + smtp_server: str = "localhost", + smtp_port: int = 587, + username: str | None = None, + password: str | None = None, + ): + """ + Initialize email notifier. + + Args: + smtp_server: SMTP server address + smtp_port: SMTP server port + username: SMTP username + password: SMTP password + """ + self.smtp_server = smtp_server + self.smtp_port = smtp_port + self.username = username + self.password = password + + def send( + self, + to_email: str, + subject: str, + body: str, + from_email: str | None = None, + ) -> bool: + """ + Send email notification. + + Args: + to_email: Recipient email address + subject: Email subject + body: Email body + from_email: Sender email address + + Returns: + True if sent successfully, False otherwise + """ + try: + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = from_email or self.username or "noreply@localhost" + msg["To"] = to_email + + with smtplib.SMTP(self.smtp_server, self.smtp_port) as server: + if self.username and self.password: + server.starttls() + server.login(self.username, self.password) + server.send_message(msg) + + logger.info(f"Email notification sent to {to_email}") + return True + + except Exception as e: + logger.warning(f"Failed to send email notification: {e}") + return False + + +def format_pipeline_report(report: dict[str, Any]) -> str: + """ + Format pipeline report as readable text. + + Args: + report: Pipeline execution report + + Returns: + Formatted report string + """ + lines = [ + "=" * 60, + "ML PIPELINE EXECUTION REPORT", + "=" * 60, + f"Model: {report.get('model', 'N/A')}", + f"Status: {'SUCCESS' if report.get('success') else 'FAILED'}", + f"Timestamp: {report.get('timestamp', 'N/A')}", + f"Total Duration: {report.get('total_duration_seconds', 0):.2f}s", + "", + "-" * 40, + "STAGES:", + "-" * 40, + ] + + for stage_name, stage_info in report.get("stages", {}).items(): + status = "✓" if stage_info.get("success") else "✗" + duration = stage_info.get("duration", 0) + lines.append(f" {status} {stage_name}: {duration:.2f}s") + + if not stage_info.get("success") and stage_info.get("error"): + lines.append(f" Error: {stage_info['error']}") + + if not report.get("success") and report.get("error"): + lines.extend(["", f"Pipeline Error: {report['error']}"]) + + lines.append("=" * 60) + + return "\n".join(lines) diff --git a/src/pipelines/prepare_data.py b/src/pipelines/prepare_data.py new file mode 100644 index 0000000..79b80a7 --- /dev/null +++ b/src/pipelines/prepare_data.py @@ -0,0 +1,205 @@ +""" +Data preparation pipeline with Hydra configuration. + +Handles data downloading, preprocessing, and splitting. +""" + +import logging +import sys +import urllib.request +from pathlib import Path + +import hydra +import pandas as pd +from omegaconf import DictConfig, OmegaConf +from sklearn.model_selection import train_test_split +from sklearn.preprocessing import StandardScaler + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +logger = logging.getLogger(__name__) + +# Wine Quality dataset URL +DATASET_URL = "https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/winequality-red.csv" + + +def download_data(raw_path: Path, force: bool = False) -> Path: + """ + Download Wine Quality dataset. + + Args: + raw_path: Directory to save raw data + force: Force re-download even if file exists + + Returns: + Path to downloaded file + """ + raw_path.mkdir(parents=True, exist_ok=True) + file_path = raw_path / "winequality-red.csv" + + if file_path.exists() and not force: + logger.info(f"File already exists: {file_path}") + return file_path + + logger.info(f"Downloading data from {DATASET_URL}") + urllib.request.urlretrieve(DATASET_URL, file_path) # nosec + logger.info(f"Downloaded to: {file_path}") + + return file_path + + +def preprocess_data( + df: pd.DataFrame, + normalize: bool = False, + handle_missing: str = "drop", +) -> pd.DataFrame: + """ + Preprocess data according to configuration. + + Args: + df: Input DataFrame + normalize: Whether to normalize features + handle_missing: Missing value handling strategy + + Returns: + Preprocessed DataFrame + """ + # Handle missing values + if handle_missing == "drop": + df = df.dropna() + elif handle_missing == "mean": + df = df.fillna(df.mean(numeric_only=True)) + elif handle_missing == "median": + df = df.fillna(df.median(numeric_only=True)) + + logger.info(f"After handling missing values: {df.shape}") + + # Normalize features (except target) + if normalize: + feature_cols = df.columns[:-1] + scaler = StandardScaler() + df[feature_cols] = scaler.fit_transform(df[feature_cols]) + logger.info("Features normalized") + + return df + + +def split_data( + df: pd.DataFrame, + test_size: float = 0.2, + random_state: int = 42, +) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Split data into train and test sets. + + Args: + df: Input DataFrame + test_size: Test set proportion + random_state: Random seed for reproducibility + + Returns: + Tuple of (train_df, test_df) + """ + train_df, test_df = train_test_split( + df, + test_size=test_size, + random_state=random_state, + ) + + logger.info(f"Train set: {train_df.shape}") + logger.info(f"Test set: {test_df.shape}") + + return train_df, test_df + + +def save_data( + train_df: pd.DataFrame, + test_df: pd.DataFrame, + output_path: Path, + train_file: str = "train.csv", + test_file: str = "test.csv", +) -> None: + """ + Save processed data to files. + + Args: + train_df: Training DataFrame + test_df: Test DataFrame + output_path: Output directory + train_file: Training file name + test_file: Test file name + """ + output_path.mkdir(parents=True, exist_ok=True) + + train_path = output_path / train_file + test_path = output_path / test_file + + train_df.to_csv(train_path, index=False) + test_df.to_csv(test_path, index=False) + + logger.info(f"Saved train data to: {train_path}") + logger.info(f"Saved test data to: {test_path}") + + +@hydra.main(config_path="../../conf", config_name="config", version_base=None) # type: ignore[misc] +def main(cfg: DictConfig) -> None: + """ + Main data preparation pipeline. + + Args: + cfg: Hydra configuration + """ + # Setup logging + log_level = getattr(logging, cfg.logging.level, logging.INFO) + logging.basicConfig(level=log_level, format=cfg.logging.format) + + logger.info("Starting data preparation pipeline") + logger.info(f"Configuration:\n{OmegaConf.to_yaml(cfg.data)}") + + # Get original working directory + original_cwd = hydra.utils.get_original_cwd() + + # Paths - access directly from cfg.data (Hydra merges defaults) + raw_path = Path(original_cwd) / cfg.data.raw_path + processed_path = Path(original_cwd) / cfg.data.processed_path + + # Download data + data_file = download_data(raw_path) + + # Load data + df = pd.read_csv(data_file, sep=";") + logger.info(f"Loaded dataset: {df.shape}") + + # Get preprocessing config if available + normalize = False + handle_missing = "drop" + + if "preprocessing" in cfg.data: + normalize = cfg.data.preprocessing.get("normalize", False) + handle_missing = cfg.data.preprocessing.get("handle_missing", "drop") + + # Preprocess data + df = preprocess_data(df, normalize=normalize, handle_missing=handle_missing) + + # Split data + train_df, test_df = split_data( + df, + test_size=cfg.data.test_size, + random_state=cfg.data.random_state, + ) + + # Save data + save_data( + train_df, + test_df, + processed_path, + cfg.data.train_file, + cfg.data.test_file, + ) + + logger.info("Data preparation completed successfully") + + +if __name__ == "__main__": + main() diff --git a/src/pipelines/run_all_models.py b/src/pipelines/run_all_models.py new file mode 100644 index 0000000..c4bd74b --- /dev/null +++ b/src/pipelines/run_all_models.py @@ -0,0 +1,162 @@ +""" +Script to run training pipeline for all configured models. + +Uses Hydra's multirun feature for parallel execution. +""" + +import logging +import subprocess # nosec B404 +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Available model configurations +MODELS = [ + "random_forest", + "gradient_boosting", + "logistic_regression", + "svm", + "decision_tree", + "knn", +] + + +def run_single_model(model: str) -> tuple[str, bool, str]: + """ + Run training pipeline for a single model. + + Args: + model: Model configuration name + + Returns: + Tuple of (model_name, success, output) + """ + logger.info(f"Running training for model: {model}") + + cmd = [ + sys.executable, + "-m", + "src.pipelines.train_pipeline", + f"model={model}", + ] + + try: + result = subprocess.run( # nosec B603 + cmd, + capture_output=True, + text=True, + cwd=Path(__file__).resolve().parents[2], + timeout=300, # 5 minute timeout + ) + + success = result.returncode == 0 + output = result.stdout if success else result.stderr + + if success: + logger.info(f"✓ {model} completed successfully") + else: + logger.error(f"✗ {model} failed: {result.stderr}") + + return model, success, output + + except subprocess.TimeoutExpired: + logger.error(f"✗ {model} timed out") + return model, False, "Execution timed out" + except Exception as e: + logger.error(f"✗ {model} error: {e}") + return model, False, str(e) + + +def run_all_models(models: list[str] | None = None) -> dict[str, bool]: + """ + Run training pipeline for all specified models. + + Args: + models: List of model names to run. Defaults to all models. + + Returns: + Dictionary mapping model names to success status + """ + if models is None: + models = MODELS + + logger.info(f"Running {len(models)} model(s): {models}") + logger.info("=" * 60) + + results = {} + + for model in models: + model_name, success, output = run_single_model(model) + results[model_name] = success + logger.info("-" * 40) + + # Summary + logger.info("=" * 60) + logger.info("SUMMARY") + logger.info("=" * 60) + + successful = sum(1 for v in results.values() if v) + failed = len(results) - successful + + for model, success in results.items(): + status = "✓ SUCCESS" if success else "✗ FAILED" + logger.info(f" {model}: {status}") + + logger.info("-" * 40) + logger.info(f"Total: {successful} succeeded, {failed} failed") + + return results + + +def run_multirun() -> None: + """ + Run all models using Hydra's multirun feature. + + This enables parallel execution when configured. + """ + models_str = ",".join(MODELS) + + cmd = [ + sys.executable, + "-m", + "src.pipelines.train_pipeline", + "--multirun", + f"model={models_str}", + ] + + logger.info(f"Running multirun with models: {models_str}") + + result = subprocess.run( # nosec B603 + cmd, + cwd=Path(__file__).resolve().parents[2], + ) + + if result.returncode == 0: + logger.info("Multirun completed successfully") + else: + logger.error("Multirun failed") + + +def main() -> None: + """Main entry point.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # Parse arguments + if len(sys.argv) > 1: + if sys.argv[1] == "--multirun": + run_multirun() + else: + # Run specific models + models = sys.argv[1:] + run_all_models(models) + else: + # Run all models sequentially + run_all_models() + + +if __name__ == "__main__": + main() diff --git a/src/pipelines/train_pipeline.py b/src/pipelines/train_pipeline.py new file mode 100644 index 0000000..83f8ffd --- /dev/null +++ b/src/pipelines/train_pipeline.py @@ -0,0 +1,364 @@ +""" +Main training pipeline with Hydra configuration management. + +This module provides an automated ML training pipeline that integrates: +- Hydra for configuration management +- MLflow for experiment tracking +- DVC for data versioning and pipeline orchestration +""" + +import importlib +import json +import logging +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +import hydra +import mlflow +import mlflow.sklearn +import pandas as pd +from omegaconf import DictConfig, OmegaConf +from sklearn.base import ClassifierMixin +from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score +from sklearn.model_selection import cross_val_score + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.pipelines.monitoring import PipelineMonitor # noqa: E402 + +logger = logging.getLogger(__name__) + + +class TrainingPipeline: + """ + ML Training Pipeline with Hydra configuration support. + + Handles data loading, model training, evaluation, and MLflow logging. + """ + + def __init__(self, cfg: DictConfig): + """ + Initialize training pipeline. + + Args: + cfg: Hydra configuration object + """ + self.cfg = cfg + self.monitor = PipelineMonitor(cfg) + self.model: ClassifierMixin | None = None + self.metrics: dict[str, float] = {} + + # Setup logging + log_level = getattr(logging, cfg.logging.level, logging.INFO) + logging.basicConfig(level=log_level, format=cfg.logging.format) + + logger.info("Training pipeline initialized") + logger.info(f"Configuration:\n{OmegaConf.to_yaml(cfg)}") + + def load_data(self) -> tuple[pd.DataFrame, pd.Series, pd.DataFrame, pd.Series]: + """ + Load training and test data. + + Returns: + Tuple of (X_train, y_train, X_test, y_test) + """ + self.monitor.start_stage("data_loading") + + try: + # Get original working directory (before Hydra changes it) + original_cwd = hydra.utils.get_original_cwd() + processed_path = self.cfg.data.get("processed_path", "data/processed") + data_path = Path(original_cwd) / processed_path + + train_file = self.cfg.data.get("train_file", "train.csv") + test_file = self.cfg.data.get("test_file", "test.csv") + train_path = data_path / train_file + test_path = data_path / test_file + + if not train_path.exists() or not test_path.exists(): + raise FileNotFoundError( + f"Data not found. Run 'dvc repro prepare' first. " + f"Looking in: {data_path}" + ) + + train_df = pd.read_csv(train_path) + test_df = pd.read_csv(test_path) + + X_train = train_df.iloc[:, :-1] + y_train = train_df.iloc[:, -1] + X_test = test_df.iloc[:, :-1] + y_test = test_df.iloc[:, -1] + + logger.info(f"Loaded training data: {X_train.shape}") + logger.info(f"Loaded test data: {X_test.shape}") + + self.monitor.end_stage("data_loading", success=True) + return X_train, y_train, X_test, y_test + + except Exception as e: + self.monitor.end_stage("data_loading", success=False, error=str(e)) + raise + + def create_model(self) -> ClassifierMixin: + """ + Create model instance from configuration. + + Returns: + Initialized model instance + """ + self.monitor.start_stage("model_creation") + + try: + # Parse model class path + target = self.cfg.model._target_ + module_path, class_name = target.rsplit(".", 1) + + # Import and instantiate model + module = importlib.import_module(module_path) + model_class = getattr(module, class_name) + + # Get model parameters + params = OmegaConf.to_container(self.cfg.model.params, resolve=True) + + # Create model instance + self.model = model_class(**params) + + logger.info(f"Created model: {self.cfg.model.name}") + logger.info(f"Parameters: {params}") + + self.monitor.end_stage("model_creation", success=True) + return self.model + + except Exception as e: + self.monitor.end_stage("model_creation", success=False, error=str(e)) + raise + + def train( + self, + X_train: pd.DataFrame, + y_train: pd.Series, + X_test: pd.DataFrame, + y_test: pd.Series, + ) -> dict[str, float]: + """ + Train model and evaluate metrics. + + Args: + X_train: Training features + y_train: Training labels + X_test: Test features + y_test: Test labels + + Returns: + Dictionary of evaluation metrics + """ + self.monitor.start_stage("training") + + try: + if self.model is None: + raise ValueError("Model not created. Call create_model() first.") + + # Train model + logger.info("Training model...") + self.model.fit(X_train, y_train) + + # Cross-validation + if self.cfg.training.cv_folds > 1: + cv_scores = cross_val_score( + self.model, + X_train, + y_train, + cv=self.cfg.training.cv_folds, + scoring="accuracy", + ) + cv_mean = cv_scores.mean() + cv_std = cv_scores.std() * 2 + logger.info(f"CV Accuracy: {cv_mean:.4f} (+/- {cv_std:.4f})") + + # Predict on test set + y_pred = self.model.predict(X_test) + + # Calculate metrics + self.metrics = { + "accuracy": float(accuracy_score(y_test, y_pred)), + "precision": float( + precision_score(y_test, y_pred, average="weighted", zero_division=0) + ), + "recall": float( + recall_score(y_test, y_pred, average="weighted", zero_division=0) + ), + "f1_score": float( + f1_score(y_test, y_pred, average="weighted", zero_division=0) + ), + } + + if self.cfg.training.cv_folds > 1: + self.metrics["cv_accuracy_mean"] = float(cv_scores.mean()) + self.metrics["cv_accuracy_std"] = float(cv_scores.std()) + + logger.info(f"Test metrics: {self.metrics}") + + self.monitor.end_stage("training", success=True) + return self.metrics + + except Exception as e: + self.monitor.end_stage("training", success=False, error=str(e)) + raise + + def log_to_mlflow(self) -> str: + """ + Log experiment to MLflow. + + Returns: + MLflow run ID + """ + self.monitor.start_stage("mlflow_logging") + + try: + # Get original working directory + original_cwd = hydra.utils.get_original_cwd() + tracking_uri = f"file://{original_cwd}/mlruns" + + mlflow.set_tracking_uri(tracking_uri) + mlflow.set_experiment(self.cfg.mlflow.experiment_name) + + with mlflow.start_run( + run_name=f"{self.cfg.model.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) as run: + # Log parameters + params = OmegaConf.to_container(self.cfg.model.params, resolve=True) + mlflow.log_params(params) + mlflow.log_param("model_type", self.cfg.model.name) + mlflow.log_param("cv_folds", self.cfg.training.cv_folds) + mlflow.log_param("seed", self.cfg.seed) + + # Log metrics + mlflow.log_metrics(self.metrics) + + # Log configuration as artifact + config_path = Path("config.yaml") + with open(config_path, "w") as f: + OmegaConf.save(self.cfg, f) + mlflow.log_artifact(str(config_path)) + + # Log model + if self.cfg.training.register_model and self.model is not None: + mlflow.sklearn.log_model( + self.model, + "model", + registered_model_name=self.cfg.model.name, + ) + + run_id: str = run.info.run_id + logger.info(f"Logged to MLflow. Run ID: {run_id}") + + self.monitor.end_stage("mlflow_logging", success=True) + return run_id + + except Exception as e: + self.monitor.end_stage("mlflow_logging", success=False, error=str(e)) + raise + + def save_results(self, run_id: str) -> None: + """ + Save pipeline results to output directory. + + Args: + run_id: MLflow run ID + """ + self.monitor.start_stage("save_results") + + try: + # Get original working directory + original_cwd = hydra.utils.get_original_cwd() + + # Create model-specific output directory + model_name = self.cfg.model.name.lower().replace(" ", "_") + output_dir = Path(original_cwd) / "outputs" / model_name + output_dir.mkdir(parents=True, exist_ok=True) + + # Save metrics + metrics_file = output_dir / "metrics.json" + with open(metrics_file, "w") as f: + json.dump( + { + "metrics": self.metrics, + "model": self.cfg.model.name, + "run_id": run_id, + "timestamp": datetime.now().isoformat(), + }, + f, + indent=2, + ) + + logger.info(f"Results saved to {output_dir}") + self.monitor.end_stage("save_results", success=True) + + # Update config output_dir for monitor + self.cfg.output_dir = str(output_dir) + + except Exception as e: + self.monitor.end_stage("save_results", success=False, error=str(e)) + raise + + def run(self) -> dict[str, Any]: + """ + Execute full training pipeline. + + Returns: + Dictionary with pipeline results + """ + self.monitor.start_pipeline() + + try: + # Execute pipeline stages + X_train, y_train, X_test, y_test = self.load_data() + self.create_model() + metrics = self.train(X_train, y_train, X_test, y_test) + run_id = self.log_to_mlflow() + self.save_results(run_id) + + # Generate final report + report = self.monitor.end_pipeline(success=True) + self.monitor.save_report() + + return { + "success": True, + "metrics": metrics, + "run_id": run_id, + "model": self.cfg.model.name, + "report": report, + } + + except Exception as e: + logger.error(f"Pipeline failed: {e}") + report = self.monitor.end_pipeline(success=False, error=str(e)) + self.monitor.save_report() + + return { + "success": False, + "error": str(e), + "report": report, + } + + +@hydra.main(config_path="../../conf", config_name="config", version_base=None) # type: ignore[misc] +def main(cfg: DictConfig) -> dict[str, Any]: + """ + Main entry point for training pipeline. + + Args: + cfg: Hydra configuration + + Returns: + Pipeline results + """ + pipeline = TrainingPipeline(cfg) + return pipeline.run() + + +if __name__ == "__main__": + main() From c06f21fc0b472712da757cd78bf2b8a62c4222d9 Mon Sep 17 00:00:00 2001 From: itwastony Date: Mon, 22 Dec 2025 23:16:18 +0300 Subject: [PATCH 5/8] fix: Remove broken image link from report --- REPORT_HW4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/REPORT_HW4.md b/REPORT_HW4.md index 57eea77..05ad59d 100644 --- a/REPORT_HW4.md +++ b/REPORT_HW4.md @@ -291,7 +291,7 @@ dvc repro $ dvc metrics show ``` -![DVC Metrics](reports/figures/dvc_metrics.png) +Результаты метрик представлены в таблице выше (раздел 4.1). ### 4.3 DVC DAG From c373e0c3a1360b5f41f3ad8c3e32b8e64113d78f Mon Sep 17 00:00:00 2001 From: itwastony Date: Sat, 27 Dec 2025 00:41:16 +0500 Subject: [PATCH 6/8] HW5: ClearML MLOps integration --- .gitignore | 10 + Makefile | 82 +++ README.md | 670 ++++++++++++++++- clearml/clearml.conf.example | 64 ++ clearml/docker-compose.yml | 146 ++++ clearml/env.example | 13 + clearml/setup_clearml.py | 260 +++++++ conf/clearml/default.yaml | 39 + conf/config.yaml | 7 + poetry.lock | 270 ++++++- pyproject.toml | 16 + src/clearml_integration/__init__.py | 31 + src/clearml_integration/dashboard.py | 365 ++++++++++ src/clearml_integration/experiment_tracker.py | 683 ++++++++++++++++++ src/clearml_integration/model_manager.py | 560 ++++++++++++++ src/clearml_integration/pipeline.py | 649 +++++++++++++++++ src/clearml_integration/run_experiments.py | 403 +++++++++++ 17 files changed, 4232 insertions(+), 36 deletions(-) create mode 100644 clearml/clearml.conf.example create mode 100644 clearml/docker-compose.yml create mode 100644 clearml/env.example create mode 100644 clearml/setup_clearml.py create mode 100644 conf/clearml/default.yaml create mode 100644 src/clearml_integration/__init__.py create mode 100644 src/clearml_integration/dashboard.py create mode 100644 src/clearml_integration/experiment_tracker.py create mode 100644 src/clearml_integration/model_manager.py create mode 100644 src/clearml_integration/pipeline.py create mode 100644 src/clearml_integration/run_experiments.py diff --git a/.gitignore b/.gitignore index 805e039..3910090 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,13 @@ mlruns/ # Hydra outputs outputs/ multirun/ + +# ClearML +clearml/.env +clearml/clearml.conf +~/clearml.conf +*.joblib + +# Keep ClearML examples and configs +!clearml/env.example +!clearml/clearml.conf.example diff --git a/Makefile b/Makefile index 7ffd6df..e365ac5 100644 --- a/Makefile +++ b/Makefile @@ -142,6 +142,88 @@ run_full: clean_outputs pipeline evaluate @echo "Full pipeline completed!" +################################################################################# +# HW5: ClearML MLOps # +################################################################################# + +## Start ClearML Server (Docker) +clearml_server_start: + cd clearml && docker-compose up -d + @echo "ClearML Server started!" + @echo "Web UI: http://localhost:8080" + @echo "API: http://localhost:8008" + @echo "Files: http://localhost:8081" + +## Stop ClearML Server +clearml_server_stop: + cd clearml && docker-compose down + @echo "ClearML Server stopped" + +## Show ClearML Server status +clearml_server_status: + cd clearml && docker-compose ps + +## Setup ClearML configuration +clearml_setup: + $(PYTHON_INTERPRETER) clearml/setup_clearml.py --status + +## Test ClearML connection +clearml_test: + $(PYTHON_INTERPRETER) clearml/setup_clearml.py --test + +## Create ClearML project structure +clearml_create_project: + $(PYTHON_INTERPRETER) clearml/setup_clearml.py --create-project + +## Run single experiment with ClearML (usage: make clearml_experiment MODEL=RandomForest) +clearml_experiment: + $(PYTHON_INTERPRETER) -m src.clearml_integration.run_experiments --model $(MODEL) + +## Run all experiments with ClearML tracking +clearml_experiments_all: + $(PYTHON_INTERPRETER) -m src.clearml_integration.run_experiments --all + +## Run experiments in offline mode (no server required) +clearml_experiments_offline: + $(PYTHON_INTERPRETER) -m src.clearml_integration.run_experiments --all --offline + +## Compare ClearML experiments +clearml_compare: + $(PYTHON_INTERPRETER) -m src.clearml_integration.run_experiments --compare + +## Compare registered models +clearml_compare_models: + $(PYTHON_INTERPRETER) -m src.clearml_integration.run_experiments --compare-models + +## Run ClearML pipeline for single model (usage: make clearml_pipeline MODEL=RandomForest) +clearml_pipeline: + $(PYTHON_INTERPRETER) -m src.clearml_integration.pipeline --model $(MODEL) + +## Run ClearML pipeline for all models +clearml_pipeline_all: + $(PYTHON_INTERPRETER) -m src.clearml_integration.pipeline --all + +## Generate ClearML dashboard report +clearml_dashboard: + $(PYTHON_INTERPRETER) -m src.clearml_integration.dashboard --summary + +## Generate full ClearML report +clearml_report: + $(PYTHON_INTERPRETER) -m src.clearml_integration.dashboard --report + +## Export ClearML metrics and reports +clearml_export: + $(PYTHON_INTERPRETER) -m src.clearml_integration.dashboard --all + +## Full ClearML workflow: experiments + comparison + report +clearml_full: clearml_experiments_all clearml_compare_models clearml_report + @echo "Full ClearML workflow completed!" + +## Clean ClearML outputs +clearml_clean: + rm -rf outputs/clearml/ + @echo "ClearML outputs cleaned" + ################################################################################# # Self Documenting Commands # diff --git a/README.md b/README.md index 9bb35f6..5eef4b4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,632 @@ -# EPML ITMO Project +# EPML ITMO Project - Wine Quality Classification -Data Science Project for EPML ITMO. +Data Science Project for EPML ITMO with MLOps integration using ClearML. -## Project Organization +--- + +## 📋 Домашнее задание 5: ClearML для MLOps + +### Содержание +- [Описание проекта](#описание-проекта) +- [Настройка ClearML](#1-настройка-clearml-3-балла) +- [Трекинг экспериментов](#2-трекинг-экспериментов-3-балла) +- [Управление моделями](#3-управление-моделями-3-балла) +- [Пайплайны](#4-пайплайны-2-балла) +- [Быстрый старт](#быстрый-старт) +- [Структура проекта](#структура-проекта) + +--- + +## Описание проекта + +Проект демонстрирует полную интеграцию ClearML для MLOps workflow на примере задачи классификации качества вина. Реализованы: +- Автоматический трекинг экспериментов +- Версионирование моделей +- ML пайплайны +- Дашборды и сравнение экспериментов + +### Используемые модели +- Random Forest +- Gradient Boosting +- Logistic Regression +- SVM +- Decision Tree +- KNN + +--- + +## 1. Настройка ClearML (3 балла) + +### 1.1 Установка ClearML Server через Docker + +Проект включает готовый `docker-compose.yml` для развертывания ClearML Server: + +```bash +# Запуск ClearML Server +make clearml_server_start + +# Или напрямую +cd clearml && docker-compose up -d +``` + +**Компоненты:** +- **MongoDB** - основная база данных +- **Elasticsearch** - поиск и аналитика +- **Redis** - кэширование и сессии +- **ClearML API Server** - REST API (порт 8008) +- **ClearML Web Server** - веб-интерфейс (порт 8080) +- **ClearML File Server** - хранилище файлов (порт 8081) +- **ClearML Agent** - опционально для удаленного выполнения + +**Доступ к сервисам:** +- Web UI: http://localhost:8080 +- API: http://localhost:8008 +- Files: http://localhost:8081 + +### 1.2 Настройка аутентификации + +1. Откройте Web UI: http://localhost:8080 +2. Перейдите в Settings → Workspace → Create new credentials +3. Скопируйте credentials + +**Способы настройки:** + +**Вариант 1: Интерактивная настройка** +```bash +clearml-init +``` + +**Вариант 2: Переменные окружения** +```bash +export CLEARML_API_HOST=http://localhost:8008 +export CLEARML_WEB_HOST=http://localhost:8080 +export CLEARML_FILES_HOST=http://localhost:8081 +export CLEARML_API_ACCESS_KEY= +export CLEARML_API_SECRET_KEY= +``` + +**Вариант 3: Файл конфигурации** +```bash +cp clearml/clearml.conf.example ~/clearml.conf +# Отредактируйте файл, добавив credentials +``` + +### 1.3 Проверка настройки + +```bash +# Проверка статуса +make clearml_setup + +# Тестирование подключения +make clearml_test + +# Создание проекта +make clearml_create_project +``` + +### 1.4 Конфигурация Docker Compose + +```yaml:clearml/docker-compose.yml +version: "3.8" + +services: + mongo: + image: mongo:6.0 + volumes: + - clearml-mongo-data:/data/db + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + + redis: + image: redis:7 + volumes: + - clearml-redis-data:/data + + apiserver: + image: allegroai/clearml:latest + ports: + - "8008:8008" + + webserver: + image: allegroai/clearml:latest + ports: + - "8080:80" + + fileserver: + image: allegroai/clearml:latest + ports: + - "8081:8081" +``` + +--- + +## 2. Трекинг экспериментов (3 балла) + +### 2.1 Автоматическое логирование + +Модуль `src/clearml_integration/experiment_tracker.py` обеспечивает: + +```python +from src.clearml_integration import ClearMLExperiment + +# Контекстный менеджер для экспериментов +with ClearMLExperiment( + experiment_name="RandomForest_Experiment", + project_name="EPML-ITMO/Wine-Quality/Experiments", + tags=["RandomForest", "wine-quality"], +) as exp: + # Автоматическое логирование параметров + exp.log_parameters({"n_estimators": 100, "max_depth": 10}) + + # Обучение модели + model.fit(X_train, y_train) + + # Логирование метрик с визуализацией + exp.log_classification_report(y_test, y_pred) + + # Логирование модели + exp.log_model(model, "random_forest") +``` + +**Декоратор для функций:** +```python +from src.clearml_integration import clearml_experiment + +@clearml_experiment( + experiment_name="training_pipeline", + project_name="EPML-ITMO/Wine-Quality" +) +def train_model(clearml_experiment=None): + # Эксперимент автоматически создается и закрывается + clearml_experiment.log_metrics({"accuracy": 0.95}) +``` + +### 2.2 Система сравнения экспериментов + +```python +from src.clearml_integration.experiment_tracker import ExperimentComparison + +comparison = ExperimentComparison(project_name="EPML-ITMO/Wine-Quality/Experiments") + +# Получение всех экспериментов +experiments = comparison.get_experiments(tags=["classification"]) + +# Сравнение метрик +df = comparison.compare_metrics(metric_names=["accuracy", "f1_score"]) + +# Генерация отчета +comparison.generate_report("outputs/comparison_report.json") +``` + +### 2.3 Логирование метрик и параметров + +**Поддерживаемые типы логирования:** +- Скалярные метрики с итерациями +- Confusion Matrix +- Classification Report +- Произвольные графики +- Артефакты (DataFrame, файлы, словари) +- Датасеты + +```python +# Скалярные метрики +exp.log_metric("accuracy", 0.95, series="validation", iteration=epoch) + +# Множественные метрики +exp.log_metrics({ + "accuracy": 0.95, + "precision": 0.94, + "recall": 0.93, + "f1_score": 0.935 +}) + +# Confusion Matrix +exp.log_confusion_matrix(y_true, y_pred, labels=class_names) + +# Артефакты +exp.log_artifact("feature_importance", feature_df) +exp.log_dataset(train_df, test_df) +``` + +### 2.4 Дашборды для анализа + +```bash +# Запуск дашборда с саммари +make clearml_dashboard + +# Генерация полного отчета +make clearml_report + +# Экспорт всех метрик и отчетов +make clearml_export +``` + +**Модуль `dashboard.py`:** +```python +from src.clearml_integration.dashboard import ClearMLDashboard + +dashboard = ClearMLDashboard() + +# Печать саммари +dashboard.print_summary() + +# Генерация Markdown отчета +dashboard.generate_full_report() + +# Экспорт метрик в CSV +dashboard.export_metrics_csv() + +# Экспорт саммари в JSON +dashboard.export_summary_json() +``` + +--- + +## 3. Управление моделями (3 балла) + +### 3.1 Регистрация и версионирование моделей + +```python +from src.clearml_integration import ClearMLModelManager + +manager = ClearMLModelManager(project_name="EPML-ITMO/Wine-Quality/Models") + +# Регистрация модели с автоматическим версионированием +model_id = manager.register_model( + model=trained_model, + model_name="RandomForest", + metrics={"accuracy": 0.95, "f1_score": 0.93}, + parameters={"n_estimators": 100}, + tags=["production", "wine-quality"], + description="Best RandomForest model for wine quality" +) +``` + +### 3.2 Система метаданных + +Каждая модель сохраняется со следующими метаданными: +```json +{ + "model_id": "RandomForest_v1_20251227_120000", + "model_name": "RandomForest", + "version": 1, + "framework": "sklearn", + "created_at": "2025-12-27T12:00:00", + "model_path": "outputs/clearml/models/RandomForest/v1/...", + "metrics": {"accuracy": 0.95, "f1_score": 0.93}, + "parameters": {"n_estimators": 100}, + "tags": ["production", "wine-quality"], + "model_class": "RandomForestClassifier" +} +``` + +### 3.3 Автоматическое создание версий + +```python +# Версии создаются автоматически при каждой регистрации +manager.register_model(model_v1, "RandomForest", metrics_v1) # v1 +manager.register_model(model_v2, "RandomForest", metrics_v2) # v2 +manager.register_model(model_v3, "RandomForest", metrics_v3) # v3 + +# Получение всех версий +versions = manager.get_model_versions("RandomForest") + +# Загрузка конкретной версии +model, metadata = manager.load_model("RandomForest", version=2) + +# Загрузка последней версии +model, metadata = manager.load_model("RandomForest", version="latest") +``` + +### 3.4 Система сравнения моделей + +```python +# Сравнение всех моделей по метрике +comparison_df = manager.compare_models(metric="accuracy") + +# Получение лучшей модели +best_id, best_metadata = manager.get_best_model(metric="accuracy") + +# Генерация отчета +manager.generate_model_report("outputs/model_report.md") + +# Экспорт модели для деплоя +manager.export_model("RandomForest", version="latest", export_path="deployment/") +``` + +```bash +# CLI команды +make clearml_compare_models +``` + +--- + +## 4. Пайплайны (2 балла) + +### 4.1 ClearML пайплайны для ML workflow + +```python +from src.clearml_integration import ClearMLPipeline + +# Создание пайплайна +pipeline = ClearMLPipeline( + pipeline_name="Wine-Quality-RandomForest", + project_name="EPML-ITMO/Wine-Quality/Pipelines", + version="1.0.0" +) + +# Добавление шагов +pipeline.add_data_step( + train_path="data/processed/train.csv", + test_path="data/processed/test.csv" +) + +pipeline.add_training_step( + model_name="RandomForest", + model_params={"n_estimators": 100, "max_depth": 10} +) + +pipeline.add_evaluation_step( + metrics=["accuracy", "precision", "recall", "f1_score"] +) + +pipeline.add_model_registration_step() + +# Запуск пайплайна +results = pipeline.run(local_mode=True) +``` + +### 4.2 Готовые функции для пайплайнов + +```python +from src.clearml_integration.pipeline import ( + create_wine_quality_pipeline, + run_all_models_pipeline +) + +# Создание пайплайна для одной модели +pipeline = create_wine_quality_pipeline( + model_name="GradientBoosting", + model_params={"n_estimators": 100} +) +results = pipeline.run() + +# Запуск всех моделей +all_results = run_all_models_pipeline() +``` + +### 4.3 Мониторинг выполнения + +Пайплайн автоматически логирует: +- Время выполнения каждого шага +- Успех/неудачу шагов +- Промежуточные результаты +- Финальные метрики + +```python +# Результаты пайплайна +{ + "pipeline_name": "Wine-Quality-RandomForest", + "version": "1.0.0", + "success": True, + "total_duration": 15.5, + "steps": { + "data_loading": {"success": True, "duration": 0.5}, + "train_randomforest": {"success": True, "duration": 10.0}, + "evaluation": {"success": True, "duration": 2.0}, + "model_registration": {"success": True, "duration": 3.0} + }, + "final_metrics": {"accuracy": 0.95, "f1_score": 0.93} +} +``` + +### 4.4 Уведомления + +Система мониторинга отправляет уведомления о: +- Успешном завершении пайплайна +- Ошибках выполнения +- Результатах метрик + +Уведомления сохраняются в `outputs/clearml/pipelines/` и логируются в ClearML. + +--- + +## Быстрый старт + +### Установка + +```bash +# Клонирование репозитория +git clone +cd epml_itmo + +# Установка зависимостей +poetry install + +# Активация окружения +poetry shell +``` + +### Запуск ClearML Server + +```bash +# Запуск Docker контейнеров +make clearml_server_start + +# Проверка статуса +make clearml_server_status + +# Настройка credentials (интерактивно) +clearml-init +``` + +### Запуск экспериментов + +```bash +# Запуск одного эксперимента +make clearml_experiment MODEL=RandomForest + +# Запуск всех экспериментов +make clearml_experiments_all + +# Запуск в офлайн режиме (без сервера) +make clearml_experiments_offline +``` + +### Запуск пайплайнов + +```bash +# Пайплайн для одной модели +make clearml_pipeline MODEL=RandomForest + +# Пайплайн для всех моделей +make clearml_pipeline_all +``` + +### Анализ и отчеты + +```bash +# Сравнение экспериментов +make clearml_compare + +# Сравнение моделей +make clearml_compare_models + +# Генерация отчетов +make clearml_report + +# Полный workflow +make clearml_full +``` + +### Воспроизведение результатов + +```bash +# Полная последовательность команд для воспроизведения +make clearml_server_start +clearml-init # Ввести credentials из Web UI +make clearml_create_project +make clearml_experiments_all +make clearml_compare_models +make clearml_report +``` + +--- + +## Структура проекта + +``` +├── clearml/ +│ ├── docker-compose.yml # ClearML Server конфигурация +│ ├── env.example # Пример переменных окружения +│ ├── clearml.conf.example # Пример конфигурации клиента +│ └── setup_clearml.py # Скрипт настройки +│ +├── conf/ +│ ├── config.yaml # Основная конфигурация Hydra +│ └── clearml/ +│ └── default.yaml # Конфигурация ClearML +│ +├── src/ +│ └── clearml_integration/ +│ ├── __init__.py +│ ├── experiment_tracker.py # Трекинг экспериментов +│ ├── model_manager.py # Управление моделями +│ ├── pipeline.py # ML пайплайны +│ ├── dashboard.py # Дашборды и отчеты +│ └── run_experiments.py # CLI для экспериментов +│ +├── outputs/ +│ └── clearml/ +│ ├── models/ # Зарегистрированные модели +│ ├── pipelines/ # Результаты пайплайнов +│ └── dashboard/ # Отчеты и экспорты +│ +├── Makefile # Make команды +└── README.md # Этот файл +``` + +--- + +## Команды Makefile + +| Команда | Описание | +|---------|----------| +| `make clearml_server_start` | Запуск ClearML Server | +| `make clearml_server_stop` | Остановка ClearML Server | +| `make clearml_server_status` | Статус контейнеров | +| `make clearml_setup` | Проверка конфигурации | +| `make clearml_test` | Тест подключения | +| `make clearml_create_project` | Создание проекта | +| `make clearml_experiment MODEL=X` | Запуск одного эксперимента | +| `make clearml_experiments_all` | Запуск всех экспериментов | +| `make clearml_experiments_offline` | Офлайн режим | +| `make clearml_compare` | Сравнение экспериментов | +| `make clearml_compare_models` | Сравнение моделей | +| `make clearml_pipeline MODEL=X` | Запуск пайплайна | +| `make clearml_pipeline_all` | Все пайплайны | +| `make clearml_dashboard` | Дашборд саммари | +| `make clearml_report` | Полный отчет | +| `make clearml_export` | Экспорт данных | +| `make clearml_full` | Полный workflow | +| `make clearml_clean` | Очистка outputs | + +--- + +## Скриншоты + +### ClearML Web UI - Эксперименты +*После запуска `make clearml_experiments_all` в Web UI отображаются все эксперименты с метриками.* + +![ClearML Experiments](reports/figures/clearml_experiments.png) + +### ClearML Web UI - Сравнение +*Функция сравнения позволяет визуально сопоставить результаты разных моделей.* + +![ClearML Comparison](reports/figures/clearml_comparison.png) + +### ClearML Web UI - Модели +*Реестр моделей с версионированием и метаданными.* + +![ClearML Models](reports/figures/clearml_models.png) + +> **Примечание:** Для получения скриншотов запустите ClearML Server и выполните эксперименты. +> Скриншоты будут доступны в Web UI по адресу http://localhost:8080 + +--- + +## Требования + +- Python 3.12+ +- Poetry +- Docker & Docker Compose + +### Зависимости Python +``` +clearml>=2.1.0 +pandas +numpy +scikit-learn +hydra-core +omegaconf +pydantic +``` + +--- + +## Ссылки + +- [ClearML Documentation](https://clear.ml/docs/) +- [ClearML GitHub](https://github.com/allegroai/clearml) +- [ClearML Server Setup](https://clear.ml/docs/latest/docs/deploying_clearml/clearml_server) + +--- + +## Project Organization (Original) ``` ├── LICENSE @@ -18,9 +642,7 @@ Data Science Project for EPML ITMO. │ ├── models <- Trained and serialized models, model predictions, or model summaries │ -├── notebooks <- Jupyter notebooks. Naming convention is a number (for ordering), -│ the creator's initials, and a short `-` delimited description, e.g. -│ `1.0-jqp-initial-data-exploration`. +├── notebooks <- Jupyter notebooks. │ ├── pyproject.toml <- Project configuration and dependencies. ├── poetry.lock <- Locked dependency versions. @@ -32,28 +654,23 @@ Data Science Project for EPML ITMO. │ ├── src <- Source code for use in this project. │ ├── __init__.py <- Makes src a Python module -│ │ │ ├── data <- Scripts to download or generate data -│ │ └── make_dataset.py -│ │ │ ├── features <- Scripts to turn raw data into features for modeling -│ │ └── build_features.py -│ │ -│ ├── models <- Scripts to train models and then use trained models to make -│ │ │ predictions -│ │ ├── predict_model.py -│ │ └── train_model.py -│ │ -│ └── visualization <- Scripts to create exploratory and results oriented visualizations -│ └── visualize.py +│ ├── models <- Scripts to train models and make predictions +│ ├── pipelines <- ML pipeline orchestration +│ ├── clearml_integration <- ClearML MLOps integration +│ └── visualization <- Scripts to create visualizations ``` +--- + ## Getting Started ### Prerequisites - Python 3.12+ - Poetry (for dependency management) +- Docker (for ClearML Server) ### Installation @@ -84,23 +701,6 @@ poetry run bandit -r src Pre-commit hooks are configured to run automatically on commit. -### Development Workflow - -This project follows a simplified Git Flow: -- `main`: Stable releases. **Direct commits are disabled.** -- `develop`: Main integration branch. -- `feature/name`: New features, branched from `develop`. -- `fix/name`: Bug fixes, branched from `develop`. - -To start a new feature: -```bash -git checkout develop -git pull -git checkout -b feature/my-new-feature -``` - -When finished, open a Pull Request to `develop`. - ### Docker Build the docker image: diff --git a/clearml/clearml.conf.example b/clearml/clearml.conf.example new file mode 100644 index 0000000..708c1c1 --- /dev/null +++ b/clearml/clearml.conf.example @@ -0,0 +1,64 @@ +# ClearML Configuration File +# Copy to ~/clearml.conf or set CLEARML_CONFIG_FILE env variable +# +# Generate credentials in ClearML Web UI: +# 1. Go to Settings -> Workspace +# 2. Create new credentials +# 3. Copy access_key and secret_key + +api { + # ClearML API server + web_server: http://localhost:8080 + api_server: http://localhost:8008 + files_server: http://localhost:8081 + + # Credentials - get from ClearML Web UI + credentials { + "access_key" = "YOUR_ACCESS_KEY" + "secret_key" = "YOUR_SECRET_KEY" + } +} + +# SDK configuration +sdk { + # Default output URI for models and artifacts + default_output_uri: "file://outputs/clearml" + + # Development mode settings + development { + # Store models locally in development + store_development_task_output_uri: "file://outputs/clearml/dev" + + # Support async logging + support_async: true + } + + # AWS S3 configuration (if using cloud storage) + # aws { + # s3 { + # credentials { + # access_key: "" + # secret_key: "" + # } + # } + # } +} + +# Agent configuration (for remote execution) +agent { + # Default queue to pull tasks from + default_queue: "default" + + # Git configuration + git { + # Git user for cloning + user: "" + pass: "" + } + + # Docker default image + default_docker { + image: "python:3.12-slim" + } +} + diff --git a/clearml/docker-compose.yml b/clearml/docker-compose.yml new file mode 100644 index 0000000..1203043 --- /dev/null +++ b/clearml/docker-compose.yml @@ -0,0 +1,146 @@ +# ClearML Server Docker Compose Configuration +# For HW5: ClearML MLOps Setup +# +# Usage: +# cd clearml && docker-compose up -d +# +# Access: +# - Web UI: http://localhost:8080 +# - API: http://localhost:8008 +# - Files: http://localhost:8081 + +version: "3.8" + +services: + # MongoDB - Main database for ClearML + mongo: + image: mongo:6.0 + container_name: clearml-mongo + restart: unless-stopped + command: --setParameter internalQueryMaxBlockingSortMemoryUsageBytes=196100200 + volumes: + - clearml-mongo-data:/data/db + - clearml-mongo-config:/data/configdb + networks: + - clearml-network + + # Elasticsearch - Search and analytics + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + container_name: clearml-elasticsearch + restart: unless-stopped + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + - cluster.routing.allocation.disk.threshold_enabled=false + volumes: + - clearml-elastic-data:/usr/share/elasticsearch/data + networks: + - clearml-network + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + + # Redis - Caching and session management + redis: + image: redis:7 + container_name: clearml-redis + restart: unless-stopped + volumes: + - clearml-redis-data:/data + networks: + - clearml-network + + # ClearML API Server + apiserver: + image: allegroai/clearml:latest + container_name: clearml-apiserver + restart: unless-stopped + depends_on: + - mongo + - elasticsearch + - redis + environment: + - CLEARML_HOST_IP=${CLEARML_HOST_IP:-localhost} + - CLEARML__SECURE__CREDENTIALS__APISERVER__access_key=${CLEARML_ACCESS_KEY:-} + - CLEARML__SECURE__CREDENTIALS__APISERVER__secret_key=${CLEARML_SECRET_KEY:-} + - CLEARML__APISERVER__DEFAULT_COMPANY=epml-itmo + volumes: + - clearml-logs:/var/log/clearml + - clearml-config:/opt/clearml/config + - clearml-data-fileserver:/mnt/fileserver + ports: + - "8008:8008" + networks: + - clearml-network + + # ClearML Web Server (UI) + webserver: + image: allegroai/clearml:latest + container_name: clearml-webserver + restart: unless-stopped + depends_on: + - apiserver + environment: + - CLEARML_HOST_IP=${CLEARML_HOST_IP:-localhost} + ports: + - "8080:80" + networks: + - clearml-network + + # ClearML File Server + fileserver: + image: allegroai/clearml:latest + container_name: clearml-fileserver + restart: unless-stopped + depends_on: + - apiserver + volumes: + - clearml-data-fileserver:/mnt/fileserver + ports: + - "8081:8081" + networks: + - clearml-network + + # ClearML Agent (optional - for remote execution) + agent: + image: allegroai/clearml-agent:latest + container_name: clearml-agent + restart: unless-stopped + depends_on: + - apiserver + environment: + - CLEARML_API_HOST=http://apiserver:8008 + - CLEARML_WEB_HOST=http://webserver:80 + - CLEARML_FILES_HOST=http://fileserver:8081 + - CLEARML_API_ACCESS_KEY=${CLEARML_ACCESS_KEY:-} + - CLEARML_API_SECRET_KEY=${CLEARML_SECRET_KEY:-} + - CLEARML_AGENT_GIT_USER=${GIT_USER:-} + - CLEARML_AGENT_GIT_PASS=${GIT_PASS:-} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - clearml-agent-data:/root/.clearml + networks: + - clearml-network + profiles: + - agent # Only starts with --profile agent + +networks: + clearml-network: + driver: bridge + +volumes: + clearml-mongo-data: + clearml-mongo-config: + clearml-elastic-data: + clearml-redis-data: + clearml-logs: + clearml-config: + clearml-data-fileserver: + clearml-agent-data: + diff --git a/clearml/env.example b/clearml/env.example new file mode 100644 index 0000000..23c61b6 --- /dev/null +++ b/clearml/env.example @@ -0,0 +1,13 @@ +# ClearML Server Environment Variables +# Copy this file to .env and fill in the values + +# Host IP for ClearML services (use localhost for local development) +CLEARML_HOST_IP=localhost + +# API credentials (auto-generated on first run, or set manually) +CLEARML_ACCESS_KEY= +CLEARML_SECRET_KEY= + +# Git credentials for ClearML Agent (optional) +GIT_USER= +GIT_PASS= diff --git a/clearml/setup_clearml.py b/clearml/setup_clearml.py new file mode 100644 index 0000000..09cbdcc --- /dev/null +++ b/clearml/setup_clearml.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +ClearML Setup Script + +This script helps to configure ClearML for the project. +It can be used to: +1. Initialize ClearML configuration +2. Test connection to ClearML server +3. Create default project and experiments + +Usage: + python clearml/setup_clearml.py --init # Initialize configuration + python clearml/setup_clearml.py --test # Test connection + python clearml/setup_clearml.py --create-project # Create project +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def init_clearml_config() -> None: + """Initialize ClearML configuration interactively.""" + print("=" * 60) + print("ClearML Configuration Setup") + print("=" * 60) + print() + print("This will help you configure ClearML for the project.") + print() + print("Prerequisites:") + print("1. ClearML Server running (docker-compose up -d)") + print("2. Access to ClearML Web UI (http://localhost:8080)") + print() + print("Steps:") + print("1. Open ClearML Web UI") + print("2. Go to Settings -> Workspace -> Create new credentials") + print("3. Copy the credentials") + print() + + try: + import clearml # noqa: F401 + + print("\nTo configure ClearML, run:") + print(" clearml-init") + print() + print("Or set environment variables:") + print(" export CLEARML_API_HOST=http://localhost:8008") + print(" export CLEARML_WEB_HOST=http://localhost:8080") + print(" export CLEARML_FILES_HOST=http://localhost:8081") + print(" export CLEARML_API_ACCESS_KEY=") + print(" export CLEARML_API_SECRET_KEY=") + + except ImportError: + print("ERROR: ClearML not installed. Run: poetry add clearml") + sys.exit(1) + + +def test_clearml_connection() -> bool: + """Test connection to ClearML server.""" + print("=" * 60) + print("Testing ClearML Connection") + print("=" * 60) + print() + + try: + from typing import Any + + from clearml import Task + + # Try to create a test task + task: Any = Task.init( + project_name="EPML-ITMO/Test", + task_name="Connection Test", + task_type=Task.TaskTypes.testing, + reuse_last_task_id=False, + ) + + print("✓ Successfully connected to ClearML!") + print(f" Task ID: {task.id}") + print(f" Project: {task.get_project_name()}") + + # Log a test metric + task.get_logger().report_scalar( + title="test", series="connection", value=1, iteration=0 + ) + + task.close() + print("\n✓ Connection test passed!") + return True + + except Exception as e: + print(f"\n✗ Connection test failed: {e}") + print("\nTroubleshooting:") + print("1. Check if ClearML server is running") + print("2. Verify credentials in ~/clearml.conf") + print("3. Check network connectivity") + return False + + +def create_project() -> None: + """Create default project structure in ClearML.""" + print("=" * 60) + print("Creating ClearML Project") + print("=" * 60) + print() + + try: + from typing import Any + + from clearml import Task + + # Create main project with a setup task + task: Any = Task.init( + project_name="EPML-ITMO/Wine-Quality", + task_name="Project Setup", + task_type=Task.TaskTypes.custom, + reuse_last_task_id=False, + ) + + # Add project description + task.set_comment( + """ +Wine Quality Classification Project +===================================== + +This project uses ClearML for MLOps workflow management. + +Models: +- Random Forest +- Gradient Boosting +- Logistic Regression +- SVM +- Decision Tree +- KNN + +Dataset: +- Wine Quality (Red Wine) from UCI ML Repository + +Experiments Structure: +- EPML-ITMO/Wine-Quality/Experiments - Training experiments +- EPML-ITMO/Wine-Quality/Models - Model registry +- EPML-ITMO/Wine-Quality/Pipelines - ML Pipelines + """ + ) + + # Add tags + task.add_tags(["setup", "project-init"]) + + print("✓ Created project: EPML-ITMO/Wine-Quality") + print(f" Task ID: {task.id}") + + task.close() + print("\n✓ Project created successfully!") + + except Exception as e: + print(f"\n✗ Failed to create project: {e}") + sys.exit(1) + + +def show_status() -> None: + """Show current ClearML configuration status.""" + print("=" * 60) + print("ClearML Configuration Status") + print("=" * 60) + print() + + # Check for config file + config_paths = [ + Path.home() / "clearml.conf", + Path.home() / ".clearml" / "clearml.conf", + Path("/etc/clearml.conf"), + ] + + config_found = False + for path in config_paths: + if path.exists(): + print(f"✓ Config file found: {path}") + config_found = True + break + + if not config_found: + print("✗ Config file not found") + print(" Expected locations:") + for path in config_paths: + print(f" - {path}") + + # Check environment variables + env_vars = [ + "CLEARML_API_HOST", + "CLEARML_WEB_HOST", + "CLEARML_FILES_HOST", + "CLEARML_API_ACCESS_KEY", + "CLEARML_API_SECRET_KEY", + ] + + print("\nEnvironment Variables:") + for var in env_vars: + value = os.environ.get(var, "") + if value: + # Mask sensitive values + if "KEY" in var: + display_value = value[:4] + "..." if len(value) > 4 else "***" + else: + display_value = value + print(f" ✓ {var}: {display_value}") + else: + print(f" ✗ {var}: not set") + + # Try to import and check configuration + print("\nClearML Package:") + try: + import clearml + + version = getattr(clearml, "__version__", "unknown") + print(f" ✓ Version: {version}") + except ImportError: + print(" ✗ Not installed") + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser(description="ClearML Setup Utility") + parser.add_argument("--init", action="store_true", help="Initialize configuration") + parser.add_argument("--test", action="store_true", help="Test connection") + parser.add_argument( + "--create-project", action="store_true", help="Create project structure" + ) + parser.add_argument( + "--status", action="store_true", help="Show configuration status" + ) + + args = parser.parse_args() + + if args.init: + init_clearml_config() + elif args.test: + success = test_clearml_connection() + sys.exit(0 if success else 1) + elif args.create_project: + create_project() + elif args.status: + show_status() + else: + # Default: show status + show_status() + print("\nUsage:") + print(" python clearml/setup_clearml.py --init # Initialize") + print(" python clearml/setup_clearml.py --test # Test connection") + print(" python clearml/setup_clearml.py --create-project # Create project") + print(" python clearml/setup_clearml.py --status # Show status") + + +if __name__ == "__main__": + main() diff --git a/conf/clearml/default.yaml b/conf/clearml/default.yaml new file mode 100644 index 0000000..c9987f5 --- /dev/null +++ b/conf/clearml/default.yaml @@ -0,0 +1,39 @@ +# ClearML Configuration for Hydra + +# ClearML Server settings +server: + api_host: "http://localhost:8008" + web_host: "http://localhost:8080" + files_host: "http://localhost:8081" + +# Project settings +project: + name: "EPML-ITMO/Wine-Quality" + experiments_subproject: "Experiments" + models_subproject: "Models" + pipelines_subproject: "Pipelines" + +# Experiment settings +experiment: + auto_connect_frameworks: true + auto_log_artifacts: true + offline_mode: false + +# Model settings +model: + output_uri: "outputs/clearml/models" + auto_version: true + register_after_training: true + +# Pipeline settings +pipeline: + default_queue: "default" + local_mode: true + save_results: true + +# Notification settings +notifications: + enabled: true + on_success: true + on_failure: true + diff --git a/conf/config.yaml b/conf/config.yaml index d5194cd..142ae70 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -5,6 +5,7 @@ defaults: - model: random_forest - data: default - training: default + - clearml: default - _self_ # MLflow configuration @@ -12,6 +13,12 @@ mlflow: tracking_uri: "file://${hydra:runtime.cwd}/mlruns" experiment_name: "wine_quality_hydra" +# ClearML configuration (overridable) +clearml: + enabled: true + project_name: "EPML-ITMO/Wine-Quality" + offline_mode: false + # Logging configuration logging: level: INFO diff --git a/poetry.lock b/poetry.lock index e066068..083f5c6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -713,6 +713,39 @@ files = [ {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] +[[package]] +name = "clearml" +version = "2.1.0" +description = "ClearML - Auto-Magical Experiment Manager, Version Control, and MLOps for AI" +optional = false +python-versions = "*" +files = [ + {file = "clearml-2.1.0-py2.py3-none-any.whl", hash = "sha256:877fa5d71806bd6cb38331e945cb475ac5b561acfcbd7468866c729231880704"}, +] + +[package.dependencies] +attrs = ">=18.0" +furl = ">=2.0.0" +jsonschema = ">=2.6.0" +numpy = ">=1.10" +pathlib2 = ">=2.3.0" +Pillow = {version = ">=10.3.0", markers = "python_version >= \"3.8\""} +psutil = ">=3.4.2" +pyjwt = ">=2.4.0,<2.11.0" +pyparsing = ">=2.0.3" +python-dateutil = ">=2.6.1" +PyYAML = ">=3.12" +referencing = {version = "<0.40", markers = "python_version >= \"3.8\""} +requests = {version = ">=2.32.0", markers = "python_version >= \"3.8\""} +six = ">=1.16.0" +urllib3 = ">=1.21.1" + +[package.extras] +azure = ["azure-storage-blob (>=12.0.0)"] +gs = ["google-cloud-storage (>=1.13.2)"] +router = ["fastapi (>=0.115.2)", "httpx (>=0.27.2)", "uvicorn (>=0.31.1)"] +s3 = ["boto3 (>=1.9)"] + [[package]] name = "click" version = "8.3.1" @@ -1818,6 +1851,21 @@ files = [ {file = "funcy-2.0.tar.gz", hash = "sha256:3963315d59d41c6f30c04bc910e10ab50a3ac4a225868bfa96feed133df075cb"}, ] +[[package]] +name = "furl" +version = "2.1.4" +description = "URL manipulation made simple." +optional = false +python-versions = "*" +files = [ + {file = "furl-2.1.4-py2.py3-none-any.whl", hash = "sha256:da34d0b34e53ffe2d2e6851a7085a05d96922b5b578620a37377ff1dbeeb11c8"}, + {file = "furl-2.1.4.tar.gz", hash = "sha256:877657501266c929269739fb5f5980534a41abd6bbabcb367c136d1d3b2a6015"}, +] + +[package.dependencies] +orderedmultidict = ">=1.0.1" +six = ">=1.8.0" + [[package]] name = "gitdb" version = "4.0.12" @@ -2310,6 +2358,41 @@ files = [ {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, + {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "jupyter-client" version = "8.6.3" @@ -3279,6 +3362,20 @@ files = [ opentelemetry-api = "1.39.0" typing-extensions = ">=4.5.0" +[[package]] +name = "orderedmultidict" +version = "1.0.2" +description = "Ordered Multivalue Dictionary" +optional = false +python-versions = "*" +files = [ + {file = "orderedmultidict-1.0.2-py2.py3-none-any.whl", hash = "sha256:ab5044c1dca4226ae4c28524cfc5cc4c939f0b49e978efa46a6ad6468049f79b"}, + {file = "orderedmultidict-1.0.2.tar.gz", hash = "sha256:16a7ae8432e02cc987d2d6d5af2df5938258f87c870675c73ee77a0920e6f4a6"}, +] + +[package.dependencies] +six = ">=1.8.0" + [[package]] name = "orjson" version = "3.11.5" @@ -3496,6 +3593,20 @@ files = [ qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] testing = ["docopt", "pytest"] +[[package]] +name = "pathlib2" +version = "2.3.7.post1" +description = "Object-oriented filesystem paths" +optional = false +python-versions = "*" +files = [ + {file = "pathlib2-2.3.7.post1-py2.py3-none-any.whl", hash = "sha256:5266a0fd000452f1b3467d782f079a4343c63aaa119221fbdc4e39577489ca5b"}, + {file = "pathlib2-2.3.7.post1.tar.gz", hash = "sha256:9fe0edad898b83c0c3e199c842b27ed216645d2e177757b2dd67384d4113c641"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "pathspec" version = "0.12.1" @@ -4279,6 +4390,23 @@ files = [ {file = "pygtrie-2.5.0.tar.gz", hash = "sha256:203514ad826eb403dab1d2e2ddd034e0d1534bbe4dbe0213bb0593f66beba4e2"}, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pyparsing" version = "3.2.5" @@ -4547,6 +4675,22 @@ files = [ [package.dependencies] cffi = {version = "*", markers = "implementation_name == \"pypy\""} +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "requests" version = "2.32.5" @@ -4586,6 +4730,130 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + [[package]] name = "rsa" version = "4.2" @@ -5618,4 +5886,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.12" -content-hash = "e9167ea8f0e5b0e4661167d3f2c9176145730cb536686c7cf66c9930eb584c70" +content-hash = "b0f43e5aecb8b4273637415f4d1bc6990b8f5e048ff84d4e0d19d74fcab5e0bf" diff --git a/pyproject.toml b/pyproject.toml index 86e764d..2fd4664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ mlflow = "^3.7.0" hydra-core = "^1.3.2" omegaconf = "^2.3.0" pydantic = "^2.12.5" +clearml = "^2.1.0" [tool.poetry.group.dev.dependencies] ruff = "^0.14.6" @@ -41,5 +42,20 @@ python_version = "3.12" strict = true ignore_missing_imports = true +[[tool.mypy.overrides]] +module = [ + "clearml", + "clearml.*", + "setup_clearml", +] +ignore_missing_imports = true +ignore_errors = true + +[[tool.mypy.overrides]] +module = [ + "src.clearml_integration.*", +] +disable_error_code = ["attr-defined", "no-untyped-call"] + [tool.bandit] exclude_dirs = ["tests", ".venv"] diff --git a/src/clearml_integration/__init__.py b/src/clearml_integration/__init__.py new file mode 100644 index 0000000..d650cbc --- /dev/null +++ b/src/clearml_integration/__init__.py @@ -0,0 +1,31 @@ +""" +ClearML Integration Module for EPML-ITMO Project. + +This module provides comprehensive ClearML integration including: +- Experiment tracking with automatic logging +- Model management and versioning +- Pipeline orchestration +- Dashboard and comparison utilities + +Example usage: + from src.clearml_integration import ClearMLExperiment + + with ClearMLExperiment("my_experiment") as exp: + exp.log_parameters({"lr": 0.01}) + exp.log_metrics({"accuracy": 0.95}) + exp.log_model(model, "best_model") +""" + +from src.clearml_integration.experiment_tracker import ( + ClearMLExperiment, + clearml_experiment, +) +from src.clearml_integration.model_manager import ClearMLModelManager +from src.clearml_integration.pipeline import ClearMLPipeline + +__all__ = [ + "ClearMLExperiment", + "clearml_experiment", + "ClearMLModelManager", + "ClearMLPipeline", +] diff --git a/src/clearml_integration/dashboard.py b/src/clearml_integration/dashboard.py new file mode 100644 index 0000000..8dd41b6 --- /dev/null +++ b/src/clearml_integration/dashboard.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" +ClearML Dashboard and Analysis Utilities. + +Provides utilities for: +- Creating experiment dashboards +- Generating comparison reports +- Visualizing model performance +- Exporting analysis results + +Usage: + python -m src.clearml_integration.dashboard --report + python -m src.clearml_integration.dashboard --summary +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.clearml_integration.experiment_tracker import ( # noqa: E402 + ExperimentComparison, +) +from src.clearml_integration.model_manager import ClearMLModelManager # noqa: E402 + +logger = logging.getLogger(__name__) + + +class ClearMLDashboard: + """ + Dashboard utility for ClearML experiments and models. + + Features: + - Generate experiment summaries + - Create comparison reports + - Export analysis to various formats + """ + + def __init__( + self, + project_name: str = "EPML-ITMO/Wine-Quality", + output_dir: str = "outputs/clearml/dashboard", + ): + """ + Initialize dashboard. + + Args: + project_name: ClearML project name + output_dir: Output directory for reports + """ + self.project_name = project_name + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + self.experiment_comparison = ExperimentComparison( + project_name=f"{project_name}/Experiments" + ) + self.model_manager = ClearMLModelManager(project_name=f"{project_name}/Models") + + def get_experiments_summary(self) -> dict[str, Any]: + """ + Get summary of all experiments. + + Returns: + Summary dictionary + """ + experiments = self.experiment_comparison.get_experiments() + + summary: dict[str, Any] = { + "total_experiments": len(experiments), + "successful": sum(1 for e in experiments if e.get("status") == "completed"), + "failed": sum(1 for e in experiments if e.get("status") == "failed"), + "running": sum(1 for e in experiments if e.get("status") == "running"), + "experiments": experiments, + } + + # Get best experiment by accuracy + metrics_data = [e.get("metrics", {}) for e in experiments] + accuracies = [ + m.get("classification/accuracy", m.get("metrics/accuracy", 0)) + for m in metrics_data + ] + if accuracies and max(accuracies) > 0: + best_idx = accuracies.index(max(accuracies)) + summary["best_experiment"] = { + "name": experiments[best_idx].get("name"), + "accuracy": max(accuracies), + } + + return summary + + def get_models_summary(self) -> dict[str, Any]: + """ + Get summary of all registered models. + + Returns: + Summary dictionary + """ + all_models = self.model_manager.get_all_models() + + total_versions = sum(len(versions) for versions in all_models.values()) + + summary: dict[str, Any] = { + "total_models": len(all_models), + "total_versions": total_versions, + "models": {}, + } + + for model_name, versions in all_models.items(): + if versions: + latest = max(versions, key=lambda v: v.get("version", 0)) + summary["models"][model_name] = { + "versions": len(versions), + "latest_version": latest.get("version"), + "latest_metrics": latest.get("metrics", {}), + } + + # Get best model + best = self.model_manager.get_best_model(metric="accuracy") + if best: + summary["best_model"] = { + "id": best[0], + "accuracy": best[1].get("metrics", {}).get("accuracy", 0), + } + + return summary + + def generate_full_report(self) -> str: + """ + Generate a comprehensive Markdown report. + + Returns: + Report content as string + """ + timestamp = datetime.now() + + report_lines = [ + "# ClearML Dashboard Report", + f"\n*Generated: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}*\n", + f"*Project: {self.project_name}*\n", + "---\n", + ] + + # Experiments Section + report_lines.append("## 📊 Experiments Summary\n") + exp_summary = self.get_experiments_summary() + report_lines.extend( + [ + f"- **Total Experiments:** {exp_summary.get('total_experiments', 0)}", + f"- **Successful:** {exp_summary.get('successful', 0)}", + f"- **Failed:** {exp_summary.get('failed', 0)}", + f"- **Running:** {exp_summary.get('running', 0)}", + ] + ) + + if exp_summary.get("best_experiment"): + best_exp = exp_summary["best_experiment"] + report_lines.extend( + [ + f"\n**Best Experiment:** {best_exp.get('name')}", + f"- Accuracy: {best_exp.get('accuracy', 0):.4f}", + ] + ) + + report_lines.append("\n") + + # Models Section + report_lines.append("## 🤖 Models Summary\n") + models_summary = self.get_models_summary() + report_lines.extend( + [ + f"- **Total Models:** {models_summary.get('total_models', 0)}", + f"- **Total Versions:** {models_summary.get('total_versions', 0)}", + ] + ) + + if models_summary.get("best_model"): + best_model = models_summary["best_model"] + report_lines.extend( + [ + f"\n**Best Model:** {best_model.get('id')}", + f"- Accuracy: {best_model.get('accuracy', 0):.4f}", + ] + ) + + report_lines.append("\n") + + # Models Comparison Table + if models_summary.get("models"): + report_lines.append("### Model Versions\n") + report_lines.append("| Model | Versions | Latest | Accuracy |") + report_lines.append("|-------|----------|--------|----------|") + + for model_name, info in models_summary.get("models", {}).items(): + versions = info.get("versions", 0) + latest = info.get("latest_version", "-") + accuracy = info.get("latest_metrics", {}).get("accuracy", 0) + report_lines.append( + f"| {model_name} | {versions} | v{latest} | {accuracy:.4f} |" + ) + + report_lines.append("\n") + + # Experiment Details + report_lines.append("## 📋 Experiment Details\n") + experiments = exp_summary.get("experiments", []) + if experiments: + report_lines.append("| Name | Status | Created |") + report_lines.append("|------|--------|---------|") + for exp in experiments[:10]: # Limit to 10 + name = exp.get("name", "N/A") + status = exp.get("status", "N/A") + created = exp.get("created", "N/A")[:19] # Trim timestamp + report_lines.append(f"| {name} | {status} | {created} |") + else: + report_lines.append("*No experiments found.*") + + report_lines.append("\n---\n") + + # Footer + report_lines.extend( + [ + "## 🔗 Quick Links\n", + "- **ClearML Web UI:** http://localhost:8080", + "- **ClearML API:** http://localhost:8008", + "- **Project Docs:** README.md", + ] + ) + + report = "\n".join(report_lines) + + # Save report + report_file = self.output_dir / "dashboard_report.md" + with open(report_file, "w") as f: + f.write(report) + + logger.info(f"Report saved to: {report_file}") + return report + + def export_metrics_csv(self) -> Path: + """ + Export all metrics to CSV. + + Returns: + Path to CSV file + """ + # Get model comparison + df = self.model_manager.compare_models() + + if df.empty: + logger.warning("No metrics to export") + return Path() + + output_file = self.output_dir / "metrics_export.csv" + df.to_csv(output_file, index=False) + + logger.info(f"Metrics exported to: {output_file}") + return output_file + + def export_summary_json(self) -> Path: + """ + Export summary to JSON. + + Returns: + Path to JSON file + """ + summary: dict[str, Any] = { + "generated_at": datetime.now().isoformat(), + "project": self.project_name, + "experiments": self.get_experiments_summary(), + "models": self.get_models_summary(), + } + + output_file = self.output_dir / "summary.json" + with open(output_file, "w") as f: + json.dump(summary, f, indent=2, default=str) + + logger.info(f"Summary exported to: {output_file}") + return output_file + + def print_summary(self) -> None: + """Print a quick summary to console.""" + print("\n" + "=" * 60) + print("ClearML Dashboard Summary") + print("=" * 60) + + # Experiments + exp_summary = self.get_experiments_summary() + print("\n📊 Experiments:") + print(f" Total: {exp_summary.get('total_experiments', 0)}") + print(f" Successful: {exp_summary.get('successful', 0)}") + print(f" Failed: {exp_summary.get('failed', 0)}") + + if exp_summary.get("best_experiment"): + best = exp_summary["best_experiment"] + print(f"\n 🏆 Best: {best.get('name')}") + print(f" Accuracy: {best.get('accuracy', 0):.4f}") + + # Models + models_summary = self.get_models_summary() + print("\n🤖 Models:") + print(f" Total: {models_summary.get('total_models', 0)}") + print(f" Versions: {models_summary.get('total_versions', 0)}") + + if models_summary.get("best_model"): + best = models_summary["best_model"] + print(f"\n 🏆 Best: {best.get('id')}") + print(f" Accuracy: {best.get('accuracy', 0):.4f}") + + # Model list + if models_summary.get("models"): + print("\n📋 Model Performance:") + for model_name, info in models_summary.get("models", {}).items(): + acc = info.get("latest_metrics", {}).get("accuracy", 0) + print(f" - {model_name}: {acc:.4f}") + + print("\n" + "=" * 60) + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser(description="ClearML Dashboard Utilities") + parser.add_argument("--report", action="store_true", help="Generate full report") + parser.add_argument("--summary", action="store_true", help="Print summary") + parser.add_argument("--export-csv", action="store_true", help="Export metrics CSV") + parser.add_argument( + "--export-json", action="store_true", help="Export summary JSON" + ) + parser.add_argument("--all", action="store_true", help="Run all exports") + + args = parser.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + dashboard = ClearMLDashboard() + + if args.all: + dashboard.print_summary() + dashboard.generate_full_report() + dashboard.export_metrics_csv() + dashboard.export_summary_json() + elif args.report: + report = dashboard.generate_full_report() + print(report) + elif args.export_csv: + dashboard.export_metrics_csv() + elif args.export_json: + dashboard.export_summary_json() + else: + dashboard.print_summary() + + +if __name__ == "__main__": + main() diff --git a/src/clearml_integration/experiment_tracker.py b/src/clearml_integration/experiment_tracker.py new file mode 100644 index 0000000..2314991 --- /dev/null +++ b/src/clearml_integration/experiment_tracker.py @@ -0,0 +1,683 @@ +""" +ClearML Experiment Tracker Module. + +Provides automatic experiment tracking with ClearML including: +- Parameter logging +- Metric logging with plots +- Artifact management +- Comparison dashboards +- Automatic scikit-learn integration +""" + +from __future__ import annotations + +import functools +import json +import logging +import pickle # nosec B403 +import time +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np +import pandas as pd +from sklearn.base import BaseEstimator +from sklearn.metrics import ( + accuracy_score, + classification_report, + confusion_matrix, + f1_score, + precision_score, + recall_score, +) + +if TYPE_CHECKING: + from clearml import Task as ClearMLTask + from clearml.logger import Logger as ClearMLLogger + +logger = logging.getLogger(__name__) + + +class ClearMLExperiment: + """ + ClearML Experiment Tracker with automatic logging support. + + Features: + - Automatic parameter and metric logging + - Sklearn model auto-logging + - Artifact and model versioning + - Comparison dashboards + - Offline mode support + + Example: + with ClearMLExperiment("my_experiment") as exp: + exp.log_parameters({"n_estimators": 100}) + model.fit(X_train, y_train) + exp.log_model(model, "random_forest") + exp.log_metrics({"accuracy": 0.95}) + """ + + def __init__( + self, + experiment_name: str, + project_name: str = "EPML-ITMO/Wine-Quality", + task_type: str = "training", + tags: list[str] | None = None, + auto_connect_frameworks: bool = True, + offline_mode: bool = False, + ): + """ + Initialize ClearML experiment. + + Args: + experiment_name: Name of the experiment + project_name: ClearML project name + task_type: Type of task (training, testing, inference, etc.) + tags: List of tags for the experiment + auto_connect_frameworks: Enable automatic framework logging + offline_mode: Run in offline mode (no server connection required) + """ + self.experiment_name = experiment_name + self.project_name = project_name + self.task_type = task_type + self.tags = tags or [] + self.auto_connect_frameworks = auto_connect_frameworks + self.offline_mode = offline_mode + self.task: ClearMLTask | None = None + self._logger: ClearMLLogger | None = None + self._start_time: float | None = None + + def __enter__(self) -> ClearMLExperiment: + """Start the experiment context.""" + self.start() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """End the experiment context.""" + success = exc_type is None + self.end(success=success) + + def start(self) -> None: + """Start the ClearML experiment.""" + try: + from clearml import Task + + # Set offline mode if specified + if self.offline_mode: + Task.set_offline(offline_mode=True) + + # Map task type string to Task.TaskTypes + task_types = { + "training": Task.TaskTypes.training, + "testing": Task.TaskTypes.testing, + "inference": Task.TaskTypes.inference, + "data_processing": Task.TaskTypes.data_processing, + "qc": Task.TaskTypes.qc, + "service": Task.TaskTypes.service, + "optimizer": Task.TaskTypes.optimizer, + "monitor": Task.TaskTypes.monitor, + "controller": Task.TaskTypes.controller, + "application": Task.TaskTypes.application, + "custom": Task.TaskTypes.custom, + } + + task_type_enum = task_types.get(self.task_type, Task.TaskTypes.training) + + # Create task + self.task = Task.init( + project_name=self.project_name, + task_name=self.experiment_name, + task_type=task_type_enum, + auto_connect_frameworks=self.auto_connect_frameworks, + reuse_last_task_id=False, + ) + + # Add tags + if self.tags and self.task: + self.task.add_tags(self.tags) + + if self.task: + self._logger = self.task.get_logger() + + self._start_time = time.time() + + logger.info(f"Started ClearML experiment: {self.experiment_name}") + if self.task: + logger.info(f"Task ID: {self.task.id}") + + except Exception as e: + logger.warning(f"Failed to start ClearML experiment: {e}") + logger.info("Running in local mode without ClearML tracking") + self.task = None + self._start_time = time.time() + + def end(self, success: bool = True) -> None: + """ + End the ClearML experiment. + + Args: + success: Whether the experiment completed successfully + """ + if self._start_time: + duration = time.time() - self._start_time + self.log_metric("total_duration_seconds", duration) + + if self.task: + try: + if not success: + self.task.mark_failed() + self.task.close() + logger.info(f"Closed ClearML experiment: {self.experiment_name}") + except Exception as e: + logger.warning(f"Error closing ClearML task: {e}") + + def log_parameters(self, params: dict[str, Any], prefix: str = "") -> None: + """ + Log parameters to the experiment. + + Args: + params: Dictionary of parameters + prefix: Optional prefix for parameter names + """ + if self.task: + try: + # Add prefix if specified + if prefix: + params = {f"{prefix}/{k}": v for k, v in params.items()} + self.task.connect(params) + logger.debug(f"Logged parameters: {list(params.keys())}") + except Exception as e: + logger.warning(f"Failed to log parameters: {e}") + + def log_metric( + self, name: str, value: float, series: str = "metrics", iteration: int = 0 + ) -> None: + """ + Log a single metric. + + Args: + name: Metric name + value: Metric value + series: Series name for grouping + iteration: Iteration number + """ + if self._logger: + try: + self._logger.report_scalar( + title=series, series=name, value=value, iteration=iteration + ) + logger.debug(f"Logged metric {name}: {value}") + except Exception as e: + logger.warning(f"Failed to log metric {name}: {e}") + + def log_metrics( + self, + metrics: dict[str, float], + series: str = "metrics", + iteration: int = 0, + ) -> None: + """ + Log multiple metrics. + + Args: + metrics: Dictionary of metrics + series: Series name for grouping + iteration: Iteration number + """ + for name, value in metrics.items(): + self.log_metric(name, value, series=series, iteration=iteration) + + def log_plot( + self, + title: str, + series: str, + x: list[float] | np.ndarray, + y: list[float] | np.ndarray, + xlabel: str = "x", + ylabel: str = "y", + ) -> None: + """ + Log a 2D plot. + + Args: + title: Plot title + series: Series name + x: X values + y: Y values + xlabel: X-axis label + ylabel: Y-axis label + """ + if self._logger: + try: + self._logger.report_line_plot( + title=title, + series=[series], + iteration=0, + xaxis=xlabel, + yaxis=ylabel, + mode="lines+markers", + ) + logger.debug(f"Logged plot: {title}") + except Exception as e: + logger.warning(f"Failed to log plot: {e}") + + def log_confusion_matrix( + self, + y_true: np.ndarray | list[Any], + y_pred: np.ndarray | list[Any], + labels: list[str] | None = None, + title: str = "Confusion Matrix", + ) -> None: + """ + Log a confusion matrix. + + Args: + y_true: True labels + y_pred: Predicted labels + labels: Optional class labels + title: Plot title + """ + if self._logger: + try: + cm = confusion_matrix(y_true, y_pred) + self._logger.report_confusion_matrix( + title=title, + series="Confusion Matrix", + matrix=cm, + xlabels=labels, + ylabels=labels, + iteration=0, + ) + logger.debug("Logged confusion matrix") + except Exception as e: + logger.warning(f"Failed to log confusion matrix: {e}") + + def log_classification_report( + self, + y_true: np.ndarray | list[Any], + y_pred: np.ndarray | list[Any], + target_names: list[str] | None = None, + ) -> dict[str, Any]: + """ + Log classification metrics and report. + + Args: + y_true: True labels + y_pred: Predicted labels + target_names: Optional class names + + Returns: + Dictionary with classification metrics + """ + # Calculate metrics + metrics = { + "accuracy": float(accuracy_score(y_true, y_pred)), + "precision_weighted": float( + precision_score(y_true, y_pred, average="weighted", zero_division=0) + ), + "recall_weighted": float( + recall_score(y_true, y_pred, average="weighted", zero_division=0) + ), + "f1_weighted": float( + f1_score(y_true, y_pred, average="weighted", zero_division=0) + ), + "precision_macro": float( + precision_score(y_true, y_pred, average="macro", zero_division=0) + ), + "recall_macro": float( + recall_score(y_true, y_pred, average="macro", zero_division=0) + ), + "f1_macro": float( + f1_score(y_true, y_pred, average="macro", zero_division=0) + ), + } + + # Log metrics + self.log_metrics(metrics, series="classification") + + # Log confusion matrix + self.log_confusion_matrix(y_true, y_pred, labels=target_names) + + # Log classification report as text + report_text = classification_report( + y_true, y_pred, target_names=target_names, zero_division=0 + ) + if self._logger: + self._logger.report_text(report_text) + + return metrics + + def log_model( + self, + model: BaseEstimator, + model_name: str, + framework: str = "sklearn", + metadata: dict[str, Any] | None = None, + ) -> str | None: + """ + Log a trained model. + + Args: + model: Trained model object + model_name: Name for the model + framework: ML framework (sklearn, pytorch, etc.) + metadata: Optional metadata dictionary + + Returns: + Model ID if successful, None otherwise + """ + if self.task: + try: + import joblib + + from clearml import OutputModel + + # Create output model + output_model = OutputModel(task=self.task, framework=framework) + + # Save model to temporary file + output_dir = Path("outputs/clearml/models") + output_dir.mkdir(parents=True, exist_ok=True) + + model_path = output_dir / f"{model_name}_{datetime.now():%Y%m%d_%H%M%S}" + + # Use joblib for sklearn models + if framework == "sklearn": + model_file = str(model_path) + ".joblib" + joblib.dump(model, model_file) + else: + # Generic pickle + model_file = str(model_path) + ".pkl" + with open(model_file, "wb") as f: + pickle.dump(model, f) # nosec B301 + + # Update model with file + output_model.update_weights(model_file) + + # Add metadata as labels + if metadata: + output_model.update_labels(metadata) + + logger.info(f"Logged model: {model_name}") + return str(output_model.id) + + except Exception as e: + logger.warning(f"Failed to log model: {e}") + + return None + + def log_artifact( + self, + name: str, + artifact: Any, + artifact_type: str = "data", + ) -> None: + """ + Log an artifact (data, file, etc.). + + Args: + name: Artifact name + artifact: Artifact object (DataFrame, dict, path, etc.) + artifact_type: Type hint for the artifact + """ + if self.task: + try: + if isinstance(artifact, pd.DataFrame): + self.task.upload_artifact(name=name, artifact_object=artifact) + elif isinstance(artifact, dict): + self.task.upload_artifact( + name=name, artifact_object=json.dumps(artifact, indent=2) + ) + elif isinstance(artifact, str | Path): + self.task.upload_artifact(name=name, artifact_object=str(artifact)) + else: + self.task.upload_artifact(name=name, artifact_object=artifact) + + logger.debug(f"Logged artifact: {name}") + except Exception as e: + logger.warning(f"Failed to log artifact {name}: {e}") + + def log_dataset( + self, + train_df: pd.DataFrame | None = None, + test_df: pd.DataFrame | None = None, + name: str = "dataset", + ) -> None: + """ + Log training and test datasets. + + Args: + train_df: Training DataFrame + test_df: Test DataFrame + name: Dataset name prefix + """ + if train_df is not None: + self.log_artifact(f"{name}_train", train_df) + self.log_parameters( + { + f"{name}_train_shape": str(train_df.shape), + f"{name}_train_columns": list(train_df.columns), + }, + prefix="data", + ) + + if test_df is not None: + self.log_artifact(f"{name}_test", test_df) + self.log_parameters( + { + f"{name}_test_shape": str(test_df.shape), + }, + prefix="data", + ) + + def set_comment(self, comment: str) -> None: + """ + Set experiment comment/description. + + Args: + comment: Comment text + """ + if self.task: + try: + self.task.set_comment(comment) + except Exception as e: + logger.warning(f"Failed to set comment: {e}") + + def get_task_id(self) -> str | None: + """Get the ClearML task ID.""" + return str(self.task.id) if self.task else None + + +def clearml_experiment( + experiment_name: str | None = None, + project_name: str = "EPML-ITMO/Wine-Quality", + task_type: str = "training", + tags: list[str] | None = None, +) -> Callable[..., Any]: + """ + Decorator to wrap a function in a ClearML experiment. + + Args: + experiment_name: Name of the experiment (defaults to function name) + project_name: ClearML project name + task_type: Type of task + tags: List of tags + + Returns: + Decorated function + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + exp_name = experiment_name or func.__name__ + + with ClearMLExperiment( + experiment_name=exp_name, + project_name=project_name, + task_type=task_type, + tags=tags, + ) as exp: + # Inject experiment into kwargs if function accepts it + import inspect + + sig = inspect.signature(func) + if "clearml_experiment" in sig.parameters: + kwargs["clearml_experiment"] = exp + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +class ExperimentComparison: + """ + Utility for comparing multiple ClearML experiments. + + Features: + - Compare metrics across experiments + - Generate comparison reports + - Create comparison plots + """ + + def __init__(self, project_name: str = "EPML-ITMO/Wine-Quality"): + """ + Initialize experiment comparison utility. + + Args: + project_name: ClearML project name to search + """ + self.project_name = project_name + + def get_experiments( + self, + tags: list[str] | None = None, + status: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + """ + Get experiments from ClearML. + + Args: + tags: Filter by tags + status: Filter by status + limit: Maximum number of experiments + + Returns: + List of experiment dictionaries + """ + try: + from clearml import Task + + # Get tasks from project + tasks: list[Any] = Task.get_tasks( + project_name=self.project_name, + tags=tags, + task_filter={"status": [status]} if status else None, + ) + + experiments: list[dict[str, Any]] = [] + for task in tasks[:limit]: + exp_data: dict[str, Any] = { + "id": task.id, + "name": task.name, + "status": task.status, + "created": str(task.data.created), + "tags": list(task.get_tags()), + "parameters": task.get_parameters(), + "metrics": {}, + } + + # Get last metrics + try: + scalars = task.get_last_scalar_metrics() + for title, series_dict in scalars.items(): + for series, value in series_dict.items(): + exp_data["metrics"][f"{title}/{series}"] = value + except Exception: # nosec B110 + pass + + experiments.append(exp_data) + + return experiments + + except Exception as e: + logger.warning(f"Failed to get experiments: {e}") + return [] + + def compare_metrics( + self, + experiment_ids: list[str] | None = None, + metric_names: list[str] | None = None, + ) -> pd.DataFrame: + """ + Compare metrics across experiments. + + Args: + experiment_ids: List of experiment IDs to compare + metric_names: Specific metrics to compare + + Returns: + DataFrame with comparison results + """ + experiments = self.get_experiments() + + if experiment_ids: + experiments = [e for e in experiments if e["id"] in experiment_ids] + + # Build comparison DataFrame + rows = [] + for exp in experiments: + row: dict[str, Any] = { + "experiment_id": exp["id"], + "experiment_name": exp["name"], + "status": exp["status"], + } + row.update(exp["metrics"]) + rows.append(row) + + df = pd.DataFrame(rows) + + # Filter columns if specific metrics requested + if metric_names: + cols = ["experiment_id", "experiment_name", "status"] + [ + c for c in df.columns if any(m in c for m in metric_names) + ] + df = df[cols] + + return df + + def generate_report( + self, + output_path: str | Path = "outputs/clearml/comparison_report.json", + ) -> dict[str, Any]: + """ + Generate a comparison report for all experiments. + + Args: + output_path: Path to save the report + + Returns: + Report dictionary + """ + experiments = self.get_experiments() + + report: dict[str, Any] = { + "generated_at": datetime.now().isoformat(), + "project": self.project_name, + "total_experiments": len(experiments), + "experiments": experiments, + } + + # Save report + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + json.dump(report, f, indent=2, default=str) + + logger.info(f"Comparison report saved to: {output_path}") + return report diff --git a/src/clearml_integration/model_manager.py b/src/clearml_integration/model_manager.py new file mode 100644 index 0000000..5cc193f --- /dev/null +++ b/src/clearml_integration/model_manager.py @@ -0,0 +1,560 @@ +""" +ClearML Model Manager Module. + +Provides comprehensive model management including: +- Model registration and versioning +- Metadata management +- Model comparison +- Automatic version control +- Model deployment support +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import joblib +import pandas as pd +from sklearn.base import BaseEstimator + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class ClearMLModelManager: + """ + Model Manager for ClearML with versioning and metadata support. + + Features: + - Automatic model versioning + - Rich metadata support + - Model comparison utilities + - Deployment-ready model export + - Model lineage tracking + + Example: + manager = ClearMLModelManager("Wine-Quality-Models") + model_id = manager.register_model( + model=trained_model, + model_name="RandomForest", + metrics={"accuracy": 0.95}, + parameters={"n_estimators": 100} + ) + """ + + def __init__( + self, + project_name: str = "EPML-ITMO/Wine-Quality/Models", + output_uri: str = "outputs/clearml/models", + ): + """ + Initialize Model Manager. + + Args: + project_name: ClearML project for models + output_uri: Local output directory for models + """ + self.project_name = project_name + self.output_uri = Path(output_uri) + self.output_uri.mkdir(parents=True, exist_ok=True) + self._models_registry: dict[str, list[dict[str, Any]]] = {} + self._registry_file = self.output_uri / "model_registry.json" + self._load_registry() + + def _load_registry(self) -> None: + """Load local model registry.""" + if self._registry_file.exists(): + with open(self._registry_file) as f: + self._models_registry = json.load(f) + else: + self._models_registry = {} + + def _save_registry(self) -> None: + """Save local model registry.""" + with open(self._registry_file, "w") as f: + json.dump(self._models_registry, f, indent=2, default=str) + + def register_model( + self, + model: BaseEstimator, + model_name: str, + metrics: dict[str, float] | None = None, + parameters: dict[str, Any] | None = None, + tags: list[str] | None = None, + description: str = "", + framework: str = "sklearn", + task_id: str | None = None, + ) -> str: + """ + Register a trained model with ClearML. + + Args: + model: Trained model object + model_name: Name of the model + metrics: Model performance metrics + parameters: Model hyperparameters + tags: Tags for categorization + description: Model description + framework: ML framework (sklearn, pytorch, etc.) + task_id: Optional ClearML task ID to associate + + Returns: + Model ID + """ + timestamp = datetime.now() + version = self._get_next_version(model_name) + model_id = f"{model_name}_v{version}_{timestamp:%Y%m%d_%H%M%S}" + + # Save model locally + model_dir = self.output_uri / model_name / f"v{version}" + model_dir.mkdir(parents=True, exist_ok=True) + + model_path = model_dir / f"{model_id}.joblib" + joblib.dump(model, model_path) + + # Create metadata + metadata: dict[str, Any] = { + "model_id": model_id, + "model_name": model_name, + "version": version, + "framework": framework, + "created_at": timestamp.isoformat(), + "model_path": str(model_path), + "metrics": metrics or {}, + "parameters": parameters or {}, + "tags": tags or [], + "description": description, + "task_id": task_id, + "model_class": type(model).__name__, + } + + # Save metadata + metadata_path = model_dir / "metadata.json" + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + + # Update registry + if model_name not in self._models_registry: + self._models_registry[model_name] = [] + self._models_registry[model_name].append(metadata) + self._save_registry() + + # Register with ClearML if available + clearml_model_id = self._register_with_clearml( + model_path=model_path, + model_name=model_name, + version=version, + metadata=metadata, + framework=framework, + task_id=task_id, + ) + + if clearml_model_id: + metadata["clearml_model_id"] = clearml_model_id + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Registered model: {model_id}") + return model_id + + def _get_next_version(self, model_name: str) -> int: + """Get next version number for a model.""" + if model_name not in self._models_registry: + return 1 + versions = [m["version"] for m in self._models_registry[model_name]] + return int(max(versions)) + 1 if versions else 1 + + def _register_with_clearml( + self, + model_path: Path, + model_name: str, + version: int, + metadata: dict[str, Any], + framework: str, + task_id: str | None = None, + ) -> str | None: + """ + Register model with ClearML server. + + Args: + model_path: Path to saved model + model_name: Model name + version: Version number + metadata: Model metadata + framework: ML framework + task_id: Optional task ID + + Returns: + ClearML model ID if successful + """ + try: + from clearml import OutputModel, Task + + # Create or get task + task: Any + if task_id: + task = Task.get_task(task_id=task_id) + else: + task = Task.init( + project_name=self.project_name, + task_name=f"Register {model_name} v{version}", + task_type=Task.TaskTypes.custom, + reuse_last_task_id=False, + ) + + # Create output model + output_model = OutputModel( + task=task, + name=f"{model_name}_v{version}", + framework=framework, + comment=metadata.get("description", ""), + ) + + # Upload model weights + output_model.update_weights(weights_filename=str(model_path)) + + # Add labels (metadata) + labels = { + "version": str(version), + "model_class": metadata.get("model_class", ""), + **{ + f"metric_{k}": str(v) + for k, v in metadata.get("metrics", {}).items() + }, + **{ + f"param_{k}": str(v) + for k, v in metadata.get("parameters", {}).items() + }, + } + output_model.update_labels(labels) + + # Add tags + for tag in metadata.get("tags", []): + task.add_tags([tag]) + + if not task_id: + task.close() + + logger.info(f"Registered model with ClearML: {output_model.id}") + return str(output_model.id) + + except Exception as e: + logger.warning(f"Failed to register with ClearML: {e}") + return None + + def load_model( + self, + model_name: str, + version: int | str = "latest", + ) -> tuple[BaseEstimator, dict[str, Any]]: + """ + Load a registered model. + + Args: + model_name: Model name + version: Version number or "latest" + + Returns: + Tuple of (model, metadata) + """ + if model_name not in self._models_registry: + raise ValueError(f"Model '{model_name}' not found in registry") + + models = self._models_registry[model_name] + + if version == "latest": + model_meta = max(models, key=lambda m: m["version"]) + else: + model_meta_found = next( + (m for m in models if m["version"] == version), None + ) + if not model_meta_found: + raise ValueError( + f"Version {version} not found for model '{model_name}'" + ) + model_meta = model_meta_found + + model_path = Path(model_meta["model_path"]) + if not model_path.exists(): + raise FileNotFoundError(f"Model file not found: {model_path}") + + model: BaseEstimator = joblib.load(model_path) + logger.info(f"Loaded model: {model_name} v{model_meta['version']}") + + return model, model_meta + + def get_model_versions(self, model_name: str) -> list[dict[str, Any]]: + """ + Get all versions of a model. + + Args: + model_name: Model name + + Returns: + List of version metadata + """ + return self._models_registry.get(model_name, []) + + def get_all_models(self) -> dict[str, list[dict[str, Any]]]: + """ + Get all registered models. + + Returns: + Dictionary of model name -> versions + """ + return self._models_registry.copy() + + def compare_models( + self, + model_names: list[str] | None = None, + metric: str = "accuracy", + ) -> pd.DataFrame: + """ + Compare models by a specific metric. + + Args: + model_names: List of model names to compare (None = all) + metric: Metric to compare + + Returns: + DataFrame with comparison results + """ + rows: list[dict[str, Any]] = [] + + models_to_compare = model_names or list(self._models_registry.keys()) + + for model_name in models_to_compare: + versions = self._models_registry.get(model_name, []) + for version_meta in versions: + row: dict[str, Any] = { + "model_name": model_name, + "version": version_meta["version"], + "created_at": version_meta["created_at"], + "model_class": version_meta.get("model_class", ""), + } + + # Add all metrics + for metric_name, metric_value in version_meta.get( + "metrics", {} + ).items(): + row[metric_name] = metric_value + + rows.append(row) + + df = pd.DataFrame(rows) + + # Sort by specified metric if available + if metric in df.columns: + df = df.sort_values(metric, ascending=False) + + return df + + def get_best_model( + self, + model_name: str | None = None, + metric: str = "accuracy", + higher_is_better: bool = True, + ) -> tuple[str, dict[str, Any]] | None: + """ + Get the best model by a specific metric. + + Args: + model_name: Specific model name (None = search all) + metric: Metric to optimize + higher_is_better: Whether higher metric values are better + + Returns: + Tuple of (model_id, metadata) or None + """ + comparison = self.compare_models( + model_names=[model_name] if model_name else None, + metric=metric, + ) + + if comparison.empty or metric not in comparison.columns: + return None + + if higher_is_better: + best_idx = comparison[metric].idxmax() + else: + best_idx = comparison[metric].idxmin() + + best_row = comparison.loc[best_idx] + + best_model_name = str(best_row["model_name"]) + version = best_row["version"] + + versions = self._models_registry.get(best_model_name, []) + metadata = next((m for m in versions if m["version"] == version), None) + + if metadata: + return str(metadata["model_id"]), metadata + + return None + + def delete_model(self, model_name: str, version: int | None = None) -> bool: + """ + Delete a model version or all versions. + + Args: + model_name: Model name + version: Specific version to delete (None = all) + + Returns: + True if successful + """ + if model_name not in self._models_registry: + logger.warning(f"Model '{model_name}' not found") + return False + + if version is None: + # Delete all versions + for version_meta in self._models_registry[model_name]: + model_path = Path(version_meta["model_path"]) + if model_path.exists(): + model_path.unlink() + del self._models_registry[model_name] + else: + # Delete specific version + versions = self._models_registry[model_name] + for i, version_meta in enumerate(versions): + if version_meta["version"] == version: + model_path = Path(version_meta["model_path"]) + if model_path.exists(): + model_path.unlink() + versions.pop(i) + break + + self._save_registry() + logger.info(f"Deleted model: {model_name} v{version or 'all'}") + return True + + def export_model( + self, + model_name: str, + version: int | str = "latest", + export_path: str | Path | None = None, + include_metadata: bool = True, + ) -> Path: + """ + Export a model for deployment. + + Args: + model_name: Model name + version: Version to export + export_path: Export destination + include_metadata: Include metadata file + + Returns: + Path to exported model + """ + model, metadata = self.load_model(model_name, version) + + if export_path is None: + export_path = self.output_uri / "exports" / model_name + + export_path = Path(export_path) + export_path.mkdir(parents=True, exist_ok=True) + + # Export model + model_file = export_path / f"{model_name}_v{metadata['version']}.joblib" + joblib.dump(model, model_file) + + # Export metadata + if include_metadata: + metadata_file = export_path / "metadata.json" + with open(metadata_file, "w") as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Exported model to: {export_path}") + return export_path + + def generate_model_report( + self, + output_path: str | Path = "outputs/clearml/model_report.md", + ) -> str: + """ + Generate a Markdown report of all models. + + Args: + output_path: Path to save the report + + Returns: + Report content + """ + report_lines = [ + "# Model Registry Report", + f"\n*Generated: {datetime.now().isoformat()}*\n", + "## Summary\n", + f"- Total Models: {len(self._models_registry)}", + f"- Total Versions: {sum(len(v) for v in self._models_registry.values())}", + "\n## Models\n", + ] + + for model_name, versions in self._models_registry.items(): + report_lines.append(f"### {model_name}\n") + report_lines.append(f"- Versions: {len(versions)}") + + if versions: + latest = max(versions, key=lambda m: m["version"]) + report_lines.append(f"- Latest Version: v{latest['version']}") + report_lines.append(f"- Latest Created: {latest['created_at']}") + + if latest.get("metrics"): + report_lines.append("\n**Latest Metrics:**\n") + for metric, value in latest["metrics"].items(): + report_lines.append(f"- {metric}: {value:.4f}") + + report_lines.append("") + + # Add comparison table + comparison = self.compare_models() + if not comparison.empty: + report_lines.append("## Model Comparison\n") + report_lines.append(comparison.to_markdown(index=False)) + + report = "\n".join(report_lines) + + # Save report + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as f: + f.write(report) + + logger.info(f"Model report saved to: {output_path}") + return report + + def sync_with_clearml(self) -> int: + """ + Sync local registry with ClearML server. + + Returns: + Number of models synced + """ + synced = 0 + + try: + from clearml import Model + + # Get all models from ClearML + clearml_models = Model.query_models( + project_name=self.project_name, + ) + + for _ in clearml_models: + # Check if already in registry + # This is a simplified sync - in production would be more robust + synced += 1 + + logger.info(f"Synced {synced} models with ClearML") + + except Exception as e: + logger.warning(f"Failed to sync with ClearML: {e}") + + return synced diff --git a/src/clearml_integration/pipeline.py b/src/clearml_integration/pipeline.py new file mode 100644 index 0000000..39ca00b --- /dev/null +++ b/src/clearml_integration/pipeline.py @@ -0,0 +1,649 @@ +""" +ClearML Pipeline Module. + +Provides ML pipeline orchestration using ClearML Pipelines: +- Automated pipeline creation +- Step-by-step execution +- Monitoring and notifications +- Automatic retries and error handling +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pandas as pd +from sklearn.base import ClassifierMixin + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +if TYPE_CHECKING: + from clearml.logger import Logger as ClearMLLogger + +logger = logging.getLogger(__name__) + + +class ClearMLPipeline: + """ + ClearML Pipeline for ML Workflow Orchestration. + + Features: + - Multi-step pipeline execution + - Automatic task creation for each step + - Progress monitoring + - Notification support + - Pipeline versioning + + Example: + pipeline = ClearMLPipeline("Wine-Quality-Pipeline") + pipeline.add_data_step(train_path, test_path) + pipeline.add_training_step("RandomForest", params) + pipeline.add_evaluation_step() + results = pipeline.run() + """ + + def __init__( + self, + pipeline_name: str, + project_name: str = "EPML-ITMO/Wine-Quality/Pipelines", + version: str = "1.0.0", + ): + """ + Initialize ClearML Pipeline. + + Args: + pipeline_name: Name of the pipeline + project_name: ClearML project name + version: Pipeline version + """ + self.pipeline_name = pipeline_name + self.project_name = project_name + self.version = version + self.steps: list[dict[str, Any]] = [] + self.results: dict[str, Any] = {} + self._pipeline_controller: Any = None + self._start_time: datetime | None = None + + def add_data_step( + self, + train_path: str | Path, + test_path: str | Path, + step_name: str = "data_loading", + ) -> ClearMLPipeline: + """ + Add data loading step to pipeline. + + Args: + train_path: Path to training data + test_path: Path to test data + step_name: Name of the step + + Returns: + Self for chaining + """ + self.steps.append( + { + "name": step_name, + "type": "data", + "config": { + "train_path": str(train_path), + "test_path": str(test_path), + }, + } + ) + return self + + def add_training_step( + self, + model_name: str, + model_params: dict[str, Any], + step_name: str | None = None, + ) -> ClearMLPipeline: + """ + Add training step to pipeline. + + Args: + model_name: Name of the model type + model_params: Model hyperparameters + step_name: Optional custom step name + + Returns: + Self for chaining + """ + self.steps.append( + { + "name": step_name or f"train_{model_name.lower()}", + "type": "training", + "config": { + "model_name": model_name, + "params": model_params, + }, + } + ) + return self + + def add_evaluation_step( + self, + metrics: list[str] | None = None, + step_name: str = "evaluation", + ) -> ClearMLPipeline: + """ + Add evaluation step to pipeline. + + Args: + metrics: List of metrics to compute + step_name: Name of the step + + Returns: + Self for chaining + """ + self.steps.append( + { + "name": step_name, + "type": "evaluation", + "config": { + "metrics": metrics + or ["accuracy", "precision", "recall", "f1_score"], + }, + } + ) + return self + + def add_model_registration_step( + self, + step_name: str = "model_registration", + ) -> ClearMLPipeline: + """ + Add model registration step to pipeline. + + Args: + step_name: Name of the step + + Returns: + Self for chaining + """ + self.steps.append( + { + "name": step_name, + "type": "registration", + "config": {}, + } + ) + return self + + def run( + self, + local_mode: bool = True, + queue_name: str = "default", + ) -> dict[str, Any]: + """ + Execute the pipeline. + + Args: + local_mode: Run locally or on ClearML agent + queue_name: Queue name for remote execution + + Returns: + Pipeline results + """ + self._start_time = datetime.now() + + logger.info("=" * 60) + logger.info(f"Starting Pipeline: {self.pipeline_name}") + logger.info(f"Version: {self.version}") + logger.info(f"Steps: {len(self.steps)}") + logger.info("=" * 60) + + if local_mode: + return self._run_local() + else: + return self._run_remote(queue_name) + + def _run_local(self) -> dict[str, Any]: + """Run pipeline locally.""" + self.results = { + "pipeline_name": self.pipeline_name, + "version": self.version, + "start_time": self._start_time.isoformat() if self._start_time else None, + "steps": {}, + "success": True, + } + + # Initialize ClearML task for pipeline + pipeline_task: Any = None + pipeline_logger: ClearMLLogger | None = None + + try: + from clearml import Task + + pipeline_task = Task.init( + project_name=self.project_name, + task_name=f"{self.pipeline_name}_v{self.version}", + task_type=Task.TaskTypes.controller, + reuse_last_task_id=False, + ) + + pipeline_task.add_tags(["pipeline", f"v{self.version}"]) + pipeline_logger = pipeline_task.get_logger() + except Exception as e: + logger.warning(f"ClearML not available: {e}") + pipeline_task = None + pipeline_logger = None + + # Track data and model across steps + train_df: pd.DataFrame | None = None + test_df: pd.DataFrame | None = None + model: ClassifierMixin | None = None + model_name: str = "" + metrics: dict[str, float] = {} + + # Execute each step + for i, step in enumerate(self.steps): + step_name = step["name"] + step_type = step["type"] + step_config = step["config"] + + logger.info(f"\n[Step {i + 1}/{len(self.steps)}] {step_name}") + step_start = datetime.now() + + try: + if step_type == "data": + train_df, test_df = self._execute_data_step(step_config) + step_result = { + "train_shape": ( + train_df.shape if train_df is not None else None + ), + "test_shape": test_df.shape if test_df is not None else None, + } + + elif step_type == "training": + if train_df is None or test_df is None: + raise ValueError("Data not loaded. Add data step first.") + model, model_name, train_metrics = self._execute_training_step( + train_df, test_df, step_config + ) + metrics.update(train_metrics) + step_result = { + "model_name": model_name, + "metrics": train_metrics, + } + + elif step_type == "evaluation": + if model is None or test_df is None: + raise ValueError("Model not trained. Add training step first.") + eval_metrics = self._execute_evaluation_step( + model, test_df, step_config + ) + metrics.update(eval_metrics) + step_result = {"metrics": eval_metrics} + + elif step_type == "registration": + if model is None: + raise ValueError( + "Model not available. Add training step first." + ) + model_id = self._execute_registration_step( + model, model_name, metrics + ) + step_result = {"model_id": model_id} + + else: + logger.warning(f"Unknown step type: {step_type}") + step_result = {} + + step_duration = (datetime.now() - step_start).total_seconds() + self.results["steps"][step_name] = { + "success": True, + "duration": step_duration, + "result": step_result, + } + + # Log to ClearML + if pipeline_logger: + pipeline_logger.report_scalar( + title="Pipeline Progress", + series="steps_completed", + value=i + 1, + iteration=0, + ) + + logger.info(f" ✓ Completed in {step_duration:.2f}s") + + except Exception as e: + logger.error(f" ✗ Step failed: {e}") + self.results["steps"][step_name] = { + "success": False, + "error": str(e), + } + self.results["success"] = False + break + + # Finalize + end_time = datetime.now() + total_duration = ( + (end_time - self._start_time).total_seconds() if self._start_time else 0 + ) + self.results["end_time"] = end_time.isoformat() + self.results["total_duration"] = total_duration + self.results["final_metrics"] = metrics + + # Log final results + if pipeline_task: + for metric_name, metric_value in metrics.items(): + pipeline_task.get_logger().report_scalar( + title="Final Metrics", + series=metric_name, + value=metric_value, + iteration=0, + ) + pipeline_task.close() + + # Save results + self._save_results() + + logger.info("\n" + "=" * 60) + logger.info(f"Pipeline {'COMPLETED' if self.results['success'] else 'FAILED'}") + logger.info(f"Total Duration: {total_duration:.2f}s") + logger.info("=" * 60) + + return self.results + + def _run_remote(self, queue_name: str) -> dict[str, Any]: + """Run pipeline on ClearML agent.""" + try: + from clearml import PipelineController + + # Create pipeline controller + pipe = PipelineController( + name=self.pipeline_name, + project=self.project_name, + version=self.version, + ) + + # Add steps as pipeline components + # This is a simplified version - full implementation would use + # @PipelineDecorator.component decorators + + # Start pipeline + pipe.start(queue=queue_name) + + logger.info(f"Pipeline started on queue: {queue_name}") + return {"status": "started", "queue": queue_name} + + except Exception as e: + logger.error(f"Failed to start remote pipeline: {e}") + return {"status": "failed", "error": str(e)} + + def _execute_data_step( + self, + config: dict[str, Any], + ) -> tuple[pd.DataFrame, pd.DataFrame]: + """Execute data loading step.""" + train_path = Path(config["train_path"]) + test_path = Path(config["test_path"]) + + train_df = pd.read_csv(train_path) + test_df = pd.read_csv(test_path) + + logger.info(f" Loaded train: {train_df.shape}") + logger.info(f" Loaded test: {test_df.shape}") + + return train_df, test_df + + def _execute_training_step( + self, + train_df: pd.DataFrame, + test_df: pd.DataFrame, + config: dict[str, Any], + ) -> tuple[ClassifierMixin, str, dict[str, float]]: + """Execute model training step.""" + from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier + from sklearn.linear_model import LogisticRegression + from sklearn.metrics import accuracy_score, f1_score + from sklearn.neighbors import KNeighborsClassifier + from sklearn.svm import SVC + from sklearn.tree import DecisionTreeClassifier + + model_name = config["model_name"] + params = config.get("params", {}) + + # Model mapping + model_classes: dict[str, type] = { + "RandomForest": RandomForestClassifier, + "GradientBoosting": GradientBoostingClassifier, + "LogisticRegression": LogisticRegression, + "SVM": SVC, + "DecisionTree": DecisionTreeClassifier, + "KNN": KNeighborsClassifier, + } + + if model_name not in model_classes: + raise ValueError(f"Unknown model: {model_name}") + + # Create and train model + model_class = model_classes[model_name] + model: ClassifierMixin = model_class(**params) + + X_train = train_df.iloc[:, :-1] + y_train = train_df.iloc[:, -1] + X_test = test_df.iloc[:, :-1] + y_test = test_df.iloc[:, -1] + + logger.info(f" Training {model_name}...") + model.fit(X_train, y_train) + + # Quick evaluation + y_pred = model.predict(X_test) + metrics = { + "accuracy": float(accuracy_score(y_test, y_pred)), + "f1_score": float(f1_score(y_test, y_pred, average="weighted")), + } + + logger.info(f" Accuracy: {metrics['accuracy']:.4f}") + + return model, model_name, metrics + + def _execute_evaluation_step( + self, + model: ClassifierMixin, + test_df: pd.DataFrame, + config: dict[str, Any], + ) -> dict[str, float]: + """Execute model evaluation step.""" + from sklearn.metrics import ( + accuracy_score, + f1_score, + precision_score, + recall_score, + ) + + X_test = test_df.iloc[:, :-1] + y_test = test_df.iloc[:, -1] + + y_pred = model.predict(X_test) + + metrics_to_compute = config.get( + "metrics", ["accuracy", "precision", "recall", "f1_score"] + ) + metrics: dict[str, float] = {} + + metric_funcs = { + "accuracy": lambda: accuracy_score(y_test, y_pred), + "precision": lambda: precision_score( + y_test, y_pred, average="weighted", zero_division=0 + ), + "recall": lambda: recall_score( + y_test, y_pred, average="weighted", zero_division=0 + ), + "f1_score": lambda: f1_score( + y_test, y_pred, average="weighted", zero_division=0 + ), + } + + for metric_name in metrics_to_compute: + if metric_name in metric_funcs: + metrics[metric_name] = float(metric_funcs[metric_name]()) + logger.info(f" {metric_name}: {metrics[metric_name]:.4f}") + + return metrics + + def _execute_registration_step( + self, + model: ClassifierMixin, + model_name: str, + metrics: dict[str, float], + ) -> str: + """Execute model registration step.""" + from src.clearml_integration.model_manager import ClearMLModelManager + + manager = ClearMLModelManager() + model_id = manager.register_model( + model=model, + model_name=model_name, + metrics=metrics, + tags=["pipeline", self.pipeline_name], + ) + + logger.info(f" Registered model: {model_id}") + return model_id + + def _save_results(self) -> None: + """Save pipeline results to file.""" + output_dir = Path("outputs/clearml/pipelines") + output_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = output_dir / f"{self.pipeline_name}_{timestamp}.json" + + with open(output_file, "w") as f: + json.dump(self.results, f, indent=2, default=str) + + logger.info(f"Results saved to: {output_file}") + + +def create_wine_quality_pipeline( + model_name: str = "RandomForest", + model_params: dict[str, Any] | None = None, +) -> ClearMLPipeline: + """ + Create a standard Wine Quality classification pipeline. + + Args: + model_name: Model type to use + model_params: Model hyperparameters + + Returns: + Configured ClearMLPipeline + """ + if model_params is None: + model_params = {"n_estimators": 100, "random_state": 42} + + pipeline = ClearMLPipeline( + pipeline_name=f"Wine-Quality-{model_name}", + version="1.0.0", + ) + + pipeline.add_data_step( + train_path="data/processed/train.csv", + test_path="data/processed/test.csv", + ) + + pipeline.add_training_step( + model_name=model_name, + model_params=model_params, + ) + + pipeline.add_evaluation_step() + + pipeline.add_model_registration_step() + + return pipeline + + +def run_all_models_pipeline() -> dict[str, dict[str, Any]]: + """ + Run pipeline for all available models. + + Returns: + Dictionary of model results + """ + models_config: dict[str, dict[str, Any]] = { + "RandomForest": {"n_estimators": 100, "max_depth": 10, "random_state": 42}, + "GradientBoosting": { + "n_estimators": 100, + "max_depth": 5, + "random_state": 42, + }, + "LogisticRegression": {"max_iter": 1000, "random_state": 42}, + "SVM": {"kernel": "rbf", "random_state": 42}, + "DecisionTree": {"max_depth": 10, "random_state": 42}, + "KNN": {"n_neighbors": 5}, + } + + results: dict[str, dict[str, Any]] = {} + + for model_name, params in models_config.items(): + logger.info(f"\n{'=' * 60}") + logger.info(f"Running pipeline for: {model_name}") + logger.info("=" * 60) + + pipeline = create_wine_quality_pipeline( + model_name=model_name, + model_params=params, + ) + + results[model_name] = pipeline.run(local_mode=True) + + # Summary + logger.info("\n" + "=" * 60) + logger.info("ALL PIPELINES COMPLETED") + logger.info("=" * 60) + + for model_name, result in results.items(): + status = "✓" if result.get("success") else "✗" + result_metrics = result.get("final_metrics", {}) + acc = result_metrics.get("accuracy", 0) + logger.info(f" {status} {model_name}: accuracy={acc:.4f}") + + return results + + +# CLI entry point +def main() -> None: + """Main entry point.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + parser = argparse.ArgumentParser(description="ClearML Pipeline Runner") + parser.add_argument( + "--model", + type=str, + default="RandomForest", + help="Model to train", + ) + parser.add_argument( + "--all", + action="store_true", + help="Run all models", + ) + + args = parser.parse_args() + + if args.all: + run_all_models_pipeline() + else: + pipeline = create_wine_quality_pipeline(model_name=args.model) + pipeline.run() + + +if __name__ == "__main__": + main() diff --git a/src/clearml_integration/run_experiments.py b/src/clearml_integration/run_experiments.py new file mode 100644 index 0000000..8032fbb --- /dev/null +++ b/src/clearml_integration/run_experiments.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +""" +Run ML experiments with ClearML tracking. + +This script provides a unified interface for running experiments +with automatic ClearML tracking, model registration, and comparison. + +Usage: + python -m src.clearml_integration.run_experiments --model RandomForest + python -m src.clearml_integration.run_experiments --all + python -m src.clearml_integration.run_experiments --compare +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path +from typing import Any + +import pandas as pd +from sklearn.ensemble import GradientBoostingClassifier, RandomForestClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.neighbors import KNeighborsClassifier +from sklearn.svm import SVC +from sklearn.tree import DecisionTreeClassifier + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from src.clearml_integration.experiment_tracker import ( # noqa: E402 + ClearMLExperiment, + ExperimentComparison, +) +from src.clearml_integration.model_manager import ClearMLModelManager # noqa: E402 + +logger = logging.getLogger(__name__) + +# Model configurations +MODELS_CONFIG: dict[str, dict[str, Any]] = { + "RandomForest": { + "class": RandomForestClassifier, + "params": { + "n_estimators": 100, + "max_depth": 10, + "min_samples_split": 2, + "random_state": 42, + }, + }, + "GradientBoosting": { + "class": GradientBoostingClassifier, + "params": { + "n_estimators": 100, + "max_depth": 5, + "learning_rate": 0.1, + "random_state": 42, + }, + }, + "LogisticRegression": { + "class": LogisticRegression, + "params": { + "max_iter": 1000, + "random_state": 42, + }, + }, + "SVM": { + "class": SVC, + "params": { + "kernel": "rbf", + "C": 1.0, + "random_state": 42, + }, + }, + "DecisionTree": { + "class": DecisionTreeClassifier, + "params": { + "max_depth": 10, + "min_samples_split": 2, + "random_state": 42, + }, + }, + "KNN": { + "class": KNeighborsClassifier, + "params": { + "n_neighbors": 5, + "weights": "uniform", + }, + }, +} + + +def load_data( + train_path: str = "data/processed/train.csv", + test_path: str = "data/processed/test.csv", +) -> tuple[pd.DataFrame, pd.Series, pd.DataFrame, pd.Series]: + """Load training and test data.""" + train_df = pd.read_csv(train_path) + test_df = pd.read_csv(test_path) + + X_train = train_df.iloc[:, :-1] + y_train = train_df.iloc[:, -1] + X_test = test_df.iloc[:, :-1] + y_test = test_df.iloc[:, -1] + + return X_train, y_train, X_test, y_test + + +def run_single_experiment( + model_name: str, + offline_mode: bool = False, + register_model: bool = True, +) -> dict[str, Any]: + """ + Run a single model experiment with ClearML tracking. + + Args: + model_name: Name of the model to train + offline_mode: Run without ClearML server + register_model: Whether to register the model + + Returns: + Experiment results + """ + if model_name not in MODELS_CONFIG: + raise ValueError(f"Unknown model: {model_name}") + + config = MODELS_CONFIG[model_name] + + # Create experiment + with ClearMLExperiment( + experiment_name=f"{model_name}_Experiment", + project_name="EPML-ITMO/Wine-Quality/Experiments", + task_type="training", + tags=[model_name, "wine-quality", "classification"], + offline_mode=offline_mode, + ) as exp: + # Log experiment description + exp.set_comment( + f""" +Wine Quality Classification Experiment +====================================== +Model: {model_name} +Dataset: Wine Quality (Red Wine) + +This experiment trains a {model_name} model on the wine quality dataset +and logs all metrics, parameters, and the trained model to ClearML. + """ + ) + + # Load data + logger.info("Loading data...") + X_train, y_train, X_test, y_test = load_data() + + # Log data info + exp.log_parameters( + { + "train_samples": len(X_train), + "test_samples": len(X_test), + "features": X_train.shape[1], + "classes": len(y_train.unique()), + }, + prefix="data", + ) + + # Log model parameters + exp.log_parameters(config["params"], prefix="model") + + # Create and train model + logger.info(f"Training {model_name}...") + model_class = config["class"] + model = model_class(**config["params"]) + model.fit(X_train, y_train) + + # Predict + y_pred = model.predict(X_test) + + # Log classification metrics + metrics = exp.log_classification_report( + y_true=y_test, + y_pred=y_pred, + target_names=[str(i) for i in sorted(y_test.unique())], + ) + + logger.info(f"Accuracy: {metrics['accuracy']:.4f}") + logger.info(f"F1 Score: {metrics['f1_weighted']:.4f}") + + # Log model + if exp.task: + exp.log_model( + model=model, + model_name=model_name, + metadata={"metrics": metrics, "params": config["params"]}, + ) + + # Register model with model manager + if register_model: + manager = ClearMLModelManager() + model_id = manager.register_model( + model=model, + model_name=model_name, + metrics=metrics, + parameters=config["params"], + tags=["wine-quality", "classification"], + task_id=exp.get_task_id(), + ) + logger.info(f"Registered model: {model_id}") + + return { + "model_name": model_name, + "task_id": exp.get_task_id(), + "metrics": metrics, + "success": True, + } + + +def run_all_experiments( + offline_mode: bool = False, + register_models: bool = True, +) -> dict[str, Any]: + """ + Run experiments for all configured models. + + Args: + offline_mode: Run without ClearML server + register_models: Whether to register models + + Returns: + Dictionary of all experiment results + """ + logger.info("=" * 60) + logger.info("Running All Model Experiments") + logger.info("=" * 60) + + results: dict[str, Any] = {} + + for model_name in MODELS_CONFIG: + logger.info(f"\n{'=' * 40}") + logger.info(f"Running: {model_name}") + logger.info("=" * 40) + + try: + result = run_single_experiment( + model_name=model_name, + offline_mode=offline_mode, + register_model=register_models, + ) + results[model_name] = result + logger.info(f"✓ {model_name} completed") + + except Exception as e: + logger.error(f"✗ {model_name} failed: {e}") + results[model_name] = { + "model_name": model_name, + "success": False, + "error": str(e), + } + + # Print summary + logger.info("\n" + "=" * 60) + logger.info("EXPERIMENT SUMMARY") + logger.info("=" * 60) + + for model_name, result in results.items(): + if result.get("success"): + metrics = result.get("metrics", {}) + acc = metrics.get("accuracy", 0) + f1 = metrics.get("f1_weighted", 0) + logger.info(f" ✓ {model_name}: accuracy={acc:.4f}, f1={f1:.4f}") + else: + logger.info(f" ✗ {model_name}: FAILED - {result.get('error', 'Unknown')}") + + # Find best model + successful = { + k: v for k, v in results.items() if v.get("success") and v.get("metrics") + } + if successful: + best_model = max( + successful.items(), + key=lambda x: x[1]["metrics"].get("accuracy", 0), + ) + logger.info(f"\n Best Model: {best_model[0]}") + logger.info(f" Best Accuracy: {best_model[1]['metrics']['accuracy']:.4f}") + + return results + + +def compare_experiments() -> pd.DataFrame: + """ + Compare all experiments in the project. + + Returns: + DataFrame with comparison results + """ + comparison = ExperimentComparison(project_name="EPML-ITMO/Wine-Quality/Experiments") + + # Get and display comparison + df = comparison.compare_metrics() + + if not df.empty: + logger.info("\nExperiment Comparison:") + logger.info(df.to_string()) + + # Generate report + comparison.generate_report() + + return df + + +def compare_models() -> pd.DataFrame: + """ + Compare all registered models. + + Returns: + DataFrame with comparison results + """ + manager = ClearMLModelManager() + + # Get comparison + df = manager.compare_models() + + if not df.empty: + logger.info("\nModel Comparison:") + logger.info(df.to_string()) + + # Generate report + manager.generate_model_report() + + # Find best model + best = manager.get_best_model(metric="accuracy") + if best: + logger.info(f"\nBest Model: {best[0]}") + logger.info(f"Accuracy: {best[1]['metrics'].get('accuracy', 0):.4f}") + + return df + + +def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Run ML experiments with ClearML tracking" + ) + parser.add_argument( + "--model", + type=str, + choices=list(MODELS_CONFIG.keys()), + help="Model to train", + ) + parser.add_argument( + "--all", + action="store_true", + help="Run all models", + ) + parser.add_argument( + "--compare", + action="store_true", + help="Compare experiments", + ) + parser.add_argument( + "--compare-models", + action="store_true", + help="Compare registered models", + ) + parser.add_argument( + "--offline", + action="store_true", + help="Run in offline mode (no ClearML server)", + ) + parser.add_argument( + "--no-register", + action="store_true", + help="Don't register models", + ) + + args = parser.parse_args() + + # Setup logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + if args.compare: + compare_experiments() + elif args.compare_models: + compare_models() + elif args.all: + run_all_experiments( + offline_mode=args.offline, + register_models=not args.no_register, + ) + elif args.model: + run_single_experiment( + model_name=args.model, + offline_mode=args.offline, + register_model=not args.no_register, + ) + else: + parser.print_help() + + +if __name__ == "__main__": + main() From 6394cf9190661c4f8295c207a4d8342662dfb254 Mon Sep 17 00:00:00 2001 From: itwastony Date: Sat, 27 Dec 2025 00:52:52 +0500 Subject: [PATCH 7/8] HW5: Move report from README.md to REPORT.md --- README.md | 670 +++--------------------------------------------- REPORT.md | 751 +++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 722 insertions(+), 699 deletions(-) diff --git a/README.md b/README.md index 5eef4b4..9bb35f6 100644 --- a/README.md +++ b/README.md @@ -1,632 +1,8 @@ -# EPML ITMO Project - Wine Quality Classification +# EPML ITMO Project -Data Science Project for EPML ITMO with MLOps integration using ClearML. +Data Science Project for EPML ITMO. ---- - -## 📋 Домашнее задание 5: ClearML для MLOps - -### Содержание -- [Описание проекта](#описание-проекта) -- [Настройка ClearML](#1-настройка-clearml-3-балла) -- [Трекинг экспериментов](#2-трекинг-экспериментов-3-балла) -- [Управление моделями](#3-управление-моделями-3-балла) -- [Пайплайны](#4-пайплайны-2-балла) -- [Быстрый старт](#быстрый-старт) -- [Структура проекта](#структура-проекта) - ---- - -## Описание проекта - -Проект демонстрирует полную интеграцию ClearML для MLOps workflow на примере задачи классификации качества вина. Реализованы: -- Автоматический трекинг экспериментов -- Версионирование моделей -- ML пайплайны -- Дашборды и сравнение экспериментов - -### Используемые модели -- Random Forest -- Gradient Boosting -- Logistic Regression -- SVM -- Decision Tree -- KNN - ---- - -## 1. Настройка ClearML (3 балла) - -### 1.1 Установка ClearML Server через Docker - -Проект включает готовый `docker-compose.yml` для развертывания ClearML Server: - -```bash -# Запуск ClearML Server -make clearml_server_start - -# Или напрямую -cd clearml && docker-compose up -d -``` - -**Компоненты:** -- **MongoDB** - основная база данных -- **Elasticsearch** - поиск и аналитика -- **Redis** - кэширование и сессии -- **ClearML API Server** - REST API (порт 8008) -- **ClearML Web Server** - веб-интерфейс (порт 8080) -- **ClearML File Server** - хранилище файлов (порт 8081) -- **ClearML Agent** - опционально для удаленного выполнения - -**Доступ к сервисам:** -- Web UI: http://localhost:8080 -- API: http://localhost:8008 -- Files: http://localhost:8081 - -### 1.2 Настройка аутентификации - -1. Откройте Web UI: http://localhost:8080 -2. Перейдите в Settings → Workspace → Create new credentials -3. Скопируйте credentials - -**Способы настройки:** - -**Вариант 1: Интерактивная настройка** -```bash -clearml-init -``` - -**Вариант 2: Переменные окружения** -```bash -export CLEARML_API_HOST=http://localhost:8008 -export CLEARML_WEB_HOST=http://localhost:8080 -export CLEARML_FILES_HOST=http://localhost:8081 -export CLEARML_API_ACCESS_KEY= -export CLEARML_API_SECRET_KEY= -``` - -**Вариант 3: Файл конфигурации** -```bash -cp clearml/clearml.conf.example ~/clearml.conf -# Отредактируйте файл, добавив credentials -``` - -### 1.3 Проверка настройки - -```bash -# Проверка статуса -make clearml_setup - -# Тестирование подключения -make clearml_test - -# Создание проекта -make clearml_create_project -``` - -### 1.4 Конфигурация Docker Compose - -```yaml:clearml/docker-compose.yml -version: "3.8" - -services: - mongo: - image: mongo:6.0 - volumes: - - clearml-mongo-data:/data/db - - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 - environment: - - discovery.type=single-node - - xpack.security.enabled=false - - redis: - image: redis:7 - volumes: - - clearml-redis-data:/data - - apiserver: - image: allegroai/clearml:latest - ports: - - "8008:8008" - - webserver: - image: allegroai/clearml:latest - ports: - - "8080:80" - - fileserver: - image: allegroai/clearml:latest - ports: - - "8081:8081" -``` - ---- - -## 2. Трекинг экспериментов (3 балла) - -### 2.1 Автоматическое логирование - -Модуль `src/clearml_integration/experiment_tracker.py` обеспечивает: - -```python -from src.clearml_integration import ClearMLExperiment - -# Контекстный менеджер для экспериментов -with ClearMLExperiment( - experiment_name="RandomForest_Experiment", - project_name="EPML-ITMO/Wine-Quality/Experiments", - tags=["RandomForest", "wine-quality"], -) as exp: - # Автоматическое логирование параметров - exp.log_parameters({"n_estimators": 100, "max_depth": 10}) - - # Обучение модели - model.fit(X_train, y_train) - - # Логирование метрик с визуализацией - exp.log_classification_report(y_test, y_pred) - - # Логирование модели - exp.log_model(model, "random_forest") -``` - -**Декоратор для функций:** -```python -from src.clearml_integration import clearml_experiment - -@clearml_experiment( - experiment_name="training_pipeline", - project_name="EPML-ITMO/Wine-Quality" -) -def train_model(clearml_experiment=None): - # Эксперимент автоматически создается и закрывается - clearml_experiment.log_metrics({"accuracy": 0.95}) -``` - -### 2.2 Система сравнения экспериментов - -```python -from src.clearml_integration.experiment_tracker import ExperimentComparison - -comparison = ExperimentComparison(project_name="EPML-ITMO/Wine-Quality/Experiments") - -# Получение всех экспериментов -experiments = comparison.get_experiments(tags=["classification"]) - -# Сравнение метрик -df = comparison.compare_metrics(metric_names=["accuracy", "f1_score"]) - -# Генерация отчета -comparison.generate_report("outputs/comparison_report.json") -``` - -### 2.3 Логирование метрик и параметров - -**Поддерживаемые типы логирования:** -- Скалярные метрики с итерациями -- Confusion Matrix -- Classification Report -- Произвольные графики -- Артефакты (DataFrame, файлы, словари) -- Датасеты - -```python -# Скалярные метрики -exp.log_metric("accuracy", 0.95, series="validation", iteration=epoch) - -# Множественные метрики -exp.log_metrics({ - "accuracy": 0.95, - "precision": 0.94, - "recall": 0.93, - "f1_score": 0.935 -}) - -# Confusion Matrix -exp.log_confusion_matrix(y_true, y_pred, labels=class_names) - -# Артефакты -exp.log_artifact("feature_importance", feature_df) -exp.log_dataset(train_df, test_df) -``` - -### 2.4 Дашборды для анализа - -```bash -# Запуск дашборда с саммари -make clearml_dashboard - -# Генерация полного отчета -make clearml_report - -# Экспорт всех метрик и отчетов -make clearml_export -``` - -**Модуль `dashboard.py`:** -```python -from src.clearml_integration.dashboard import ClearMLDashboard - -dashboard = ClearMLDashboard() - -# Печать саммари -dashboard.print_summary() - -# Генерация Markdown отчета -dashboard.generate_full_report() - -# Экспорт метрик в CSV -dashboard.export_metrics_csv() - -# Экспорт саммари в JSON -dashboard.export_summary_json() -``` - ---- - -## 3. Управление моделями (3 балла) - -### 3.1 Регистрация и версионирование моделей - -```python -from src.clearml_integration import ClearMLModelManager - -manager = ClearMLModelManager(project_name="EPML-ITMO/Wine-Quality/Models") - -# Регистрация модели с автоматическим версионированием -model_id = manager.register_model( - model=trained_model, - model_name="RandomForest", - metrics={"accuracy": 0.95, "f1_score": 0.93}, - parameters={"n_estimators": 100}, - tags=["production", "wine-quality"], - description="Best RandomForest model for wine quality" -) -``` - -### 3.2 Система метаданных - -Каждая модель сохраняется со следующими метаданными: -```json -{ - "model_id": "RandomForest_v1_20251227_120000", - "model_name": "RandomForest", - "version": 1, - "framework": "sklearn", - "created_at": "2025-12-27T12:00:00", - "model_path": "outputs/clearml/models/RandomForest/v1/...", - "metrics": {"accuracy": 0.95, "f1_score": 0.93}, - "parameters": {"n_estimators": 100}, - "tags": ["production", "wine-quality"], - "model_class": "RandomForestClassifier" -} -``` - -### 3.3 Автоматическое создание версий - -```python -# Версии создаются автоматически при каждой регистрации -manager.register_model(model_v1, "RandomForest", metrics_v1) # v1 -manager.register_model(model_v2, "RandomForest", metrics_v2) # v2 -manager.register_model(model_v3, "RandomForest", metrics_v3) # v3 - -# Получение всех версий -versions = manager.get_model_versions("RandomForest") - -# Загрузка конкретной версии -model, metadata = manager.load_model("RandomForest", version=2) - -# Загрузка последней версии -model, metadata = manager.load_model("RandomForest", version="latest") -``` - -### 3.4 Система сравнения моделей - -```python -# Сравнение всех моделей по метрике -comparison_df = manager.compare_models(metric="accuracy") - -# Получение лучшей модели -best_id, best_metadata = manager.get_best_model(metric="accuracy") - -# Генерация отчета -manager.generate_model_report("outputs/model_report.md") - -# Экспорт модели для деплоя -manager.export_model("RandomForest", version="latest", export_path="deployment/") -``` - -```bash -# CLI команды -make clearml_compare_models -``` - ---- - -## 4. Пайплайны (2 балла) - -### 4.1 ClearML пайплайны для ML workflow - -```python -from src.clearml_integration import ClearMLPipeline - -# Создание пайплайна -pipeline = ClearMLPipeline( - pipeline_name="Wine-Quality-RandomForest", - project_name="EPML-ITMO/Wine-Quality/Pipelines", - version="1.0.0" -) - -# Добавление шагов -pipeline.add_data_step( - train_path="data/processed/train.csv", - test_path="data/processed/test.csv" -) - -pipeline.add_training_step( - model_name="RandomForest", - model_params={"n_estimators": 100, "max_depth": 10} -) - -pipeline.add_evaluation_step( - metrics=["accuracy", "precision", "recall", "f1_score"] -) - -pipeline.add_model_registration_step() - -# Запуск пайплайна -results = pipeline.run(local_mode=True) -``` - -### 4.2 Готовые функции для пайплайнов - -```python -from src.clearml_integration.pipeline import ( - create_wine_quality_pipeline, - run_all_models_pipeline -) - -# Создание пайплайна для одной модели -pipeline = create_wine_quality_pipeline( - model_name="GradientBoosting", - model_params={"n_estimators": 100} -) -results = pipeline.run() - -# Запуск всех моделей -all_results = run_all_models_pipeline() -``` - -### 4.3 Мониторинг выполнения - -Пайплайн автоматически логирует: -- Время выполнения каждого шага -- Успех/неудачу шагов -- Промежуточные результаты -- Финальные метрики - -```python -# Результаты пайплайна -{ - "pipeline_name": "Wine-Quality-RandomForest", - "version": "1.0.0", - "success": True, - "total_duration": 15.5, - "steps": { - "data_loading": {"success": True, "duration": 0.5}, - "train_randomforest": {"success": True, "duration": 10.0}, - "evaluation": {"success": True, "duration": 2.0}, - "model_registration": {"success": True, "duration": 3.0} - }, - "final_metrics": {"accuracy": 0.95, "f1_score": 0.93} -} -``` - -### 4.4 Уведомления - -Система мониторинга отправляет уведомления о: -- Успешном завершении пайплайна -- Ошибках выполнения -- Результатах метрик - -Уведомления сохраняются в `outputs/clearml/pipelines/` и логируются в ClearML. - ---- - -## Быстрый старт - -### Установка - -```bash -# Клонирование репозитория -git clone -cd epml_itmo - -# Установка зависимостей -poetry install - -# Активация окружения -poetry shell -``` - -### Запуск ClearML Server - -```bash -# Запуск Docker контейнеров -make clearml_server_start - -# Проверка статуса -make clearml_server_status - -# Настройка credentials (интерактивно) -clearml-init -``` - -### Запуск экспериментов - -```bash -# Запуск одного эксперимента -make clearml_experiment MODEL=RandomForest - -# Запуск всех экспериментов -make clearml_experiments_all - -# Запуск в офлайн режиме (без сервера) -make clearml_experiments_offline -``` - -### Запуск пайплайнов - -```bash -# Пайплайн для одной модели -make clearml_pipeline MODEL=RandomForest - -# Пайплайн для всех моделей -make clearml_pipeline_all -``` - -### Анализ и отчеты - -```bash -# Сравнение экспериментов -make clearml_compare - -# Сравнение моделей -make clearml_compare_models - -# Генерация отчетов -make clearml_report - -# Полный workflow -make clearml_full -``` - -### Воспроизведение результатов - -```bash -# Полная последовательность команд для воспроизведения -make clearml_server_start -clearml-init # Ввести credentials из Web UI -make clearml_create_project -make clearml_experiments_all -make clearml_compare_models -make clearml_report -``` - ---- - -## Структура проекта - -``` -├── clearml/ -│ ├── docker-compose.yml # ClearML Server конфигурация -│ ├── env.example # Пример переменных окружения -│ ├── clearml.conf.example # Пример конфигурации клиента -│ └── setup_clearml.py # Скрипт настройки -│ -├── conf/ -│ ├── config.yaml # Основная конфигурация Hydra -│ └── clearml/ -│ └── default.yaml # Конфигурация ClearML -│ -├── src/ -│ └── clearml_integration/ -│ ├── __init__.py -│ ├── experiment_tracker.py # Трекинг экспериментов -│ ├── model_manager.py # Управление моделями -│ ├── pipeline.py # ML пайплайны -│ ├── dashboard.py # Дашборды и отчеты -│ └── run_experiments.py # CLI для экспериментов -│ -├── outputs/ -│ └── clearml/ -│ ├── models/ # Зарегистрированные модели -│ ├── pipelines/ # Результаты пайплайнов -│ └── dashboard/ # Отчеты и экспорты -│ -├── Makefile # Make команды -└── README.md # Этот файл -``` - ---- - -## Команды Makefile - -| Команда | Описание | -|---------|----------| -| `make clearml_server_start` | Запуск ClearML Server | -| `make clearml_server_stop` | Остановка ClearML Server | -| `make clearml_server_status` | Статус контейнеров | -| `make clearml_setup` | Проверка конфигурации | -| `make clearml_test` | Тест подключения | -| `make clearml_create_project` | Создание проекта | -| `make clearml_experiment MODEL=X` | Запуск одного эксперимента | -| `make clearml_experiments_all` | Запуск всех экспериментов | -| `make clearml_experiments_offline` | Офлайн режим | -| `make clearml_compare` | Сравнение экспериментов | -| `make clearml_compare_models` | Сравнение моделей | -| `make clearml_pipeline MODEL=X` | Запуск пайплайна | -| `make clearml_pipeline_all` | Все пайплайны | -| `make clearml_dashboard` | Дашборд саммари | -| `make clearml_report` | Полный отчет | -| `make clearml_export` | Экспорт данных | -| `make clearml_full` | Полный workflow | -| `make clearml_clean` | Очистка outputs | - ---- - -## Скриншоты - -### ClearML Web UI - Эксперименты -*После запуска `make clearml_experiments_all` в Web UI отображаются все эксперименты с метриками.* - -![ClearML Experiments](reports/figures/clearml_experiments.png) - -### ClearML Web UI - Сравнение -*Функция сравнения позволяет визуально сопоставить результаты разных моделей.* - -![ClearML Comparison](reports/figures/clearml_comparison.png) - -### ClearML Web UI - Модели -*Реестр моделей с версионированием и метаданными.* - -![ClearML Models](reports/figures/clearml_models.png) - -> **Примечание:** Для получения скриншотов запустите ClearML Server и выполните эксперименты. -> Скриншоты будут доступны в Web UI по адресу http://localhost:8080 - ---- - -## Требования - -- Python 3.12+ -- Poetry -- Docker & Docker Compose - -### Зависимости Python -``` -clearml>=2.1.0 -pandas -numpy -scikit-learn -hydra-core -omegaconf -pydantic -``` - ---- - -## Ссылки - -- [ClearML Documentation](https://clear.ml/docs/) -- [ClearML GitHub](https://github.com/allegroai/clearml) -- [ClearML Server Setup](https://clear.ml/docs/latest/docs/deploying_clearml/clearml_server) - ---- - -## Project Organization (Original) +## Project Organization ``` ├── LICENSE @@ -642,7 +18,9 @@ pydantic │ ├── models <- Trained and serialized models, model predictions, or model summaries │ -├── notebooks <- Jupyter notebooks. +├── notebooks <- Jupyter notebooks. Naming convention is a number (for ordering), +│ the creator's initials, and a short `-` delimited description, e.g. +│ `1.0-jqp-initial-data-exploration`. │ ├── pyproject.toml <- Project configuration and dependencies. ├── poetry.lock <- Locked dependency versions. @@ -654,23 +32,28 @@ pydantic │ ├── src <- Source code for use in this project. │ ├── __init__.py <- Makes src a Python module +│ │ │ ├── data <- Scripts to download or generate data +│ │ └── make_dataset.py +│ │ │ ├── features <- Scripts to turn raw data into features for modeling -│ ├── models <- Scripts to train models and make predictions -│ ├── pipelines <- ML pipeline orchestration -│ ├── clearml_integration <- ClearML MLOps integration -│ └── visualization <- Scripts to create visualizations +│ │ └── build_features.py +│ │ +│ ├── models <- Scripts to train models and then use trained models to make +│ │ │ predictions +│ │ ├── predict_model.py +│ │ └── train_model.py +│ │ +│ └── visualization <- Scripts to create exploratory and results oriented visualizations +│ └── visualize.py ``` ---- - ## Getting Started ### Prerequisites - Python 3.12+ - Poetry (for dependency management) -- Docker (for ClearML Server) ### Installation @@ -701,6 +84,23 @@ poetry run bandit -r src Pre-commit hooks are configured to run automatically on commit. +### Development Workflow + +This project follows a simplified Git Flow: +- `main`: Stable releases. **Direct commits are disabled.** +- `develop`: Main integration branch. +- `feature/name`: New features, branched from `develop`. +- `fix/name`: Bug fixes, branched from `develop`. + +To start a new feature: +```bash +git checkout develop +git pull +git checkout -b feature/my-new-feature +``` + +When finished, open a Pull Request to `develop`. + ### Docker Build the docker image: diff --git a/REPORT.md b/REPORT.md index 38de7c1..5eef4b4 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,87 +1,710 @@ -# ДЗ 3: Отчет о трекинге экспериментов +# EPML ITMO Project - Wine Quality Classification -## 1. Выбор инструмента и настройка +Data Science Project for EPML ITMO with MLOps integration using ClearML. -**Выбранный инструмент:** MLflow +--- -Я выбрал MLflow из-за его широкого распространения, простоты настройки и отличной интеграции с Python и Scikit-learn. +## 📋 Домашнее задание 5: ClearML для MLOps -### Детали настройки -- **Установка:** Добавил `mlflow` в `pyproject.toml` и установил через Poetry. -- **Хранилище:** Настроил использование локального файлового хранилища (`./mlruns`) для простоты в текущем окружении разработки. - ```python - mlflow.set_tracking_uri("file://" + str(Path.cwd() / "mlruns")) - ``` -- **Управление экспериментами:** Создал эксперименты с именами `wine_quality_experiment` (для одиночных запусков) и `wine_quality_multimodel_v1` (для сравнительного анализа). +### Содержание +- [Описание проекта](#описание-проекта) +- [Настройка ClearML](#1-настройка-clearml-3-балла) +- [Трекинг экспериментов](#2-трекинг-экспериментов-3-балла) +- [Управление моделями](#3-управление-моделями-3-балла) +- [Пайплайны](#4-пайплайны-2-балла) +- [Быстрый старт](#быстрый-старт) +- [Структура проекта](#структура-проекта) -## 2. Интеграция с кодом +--- -Я интегрировал MLflow в проект, создав переиспользуемые утилиты и декораторы для автоматического логирования. +## Описание проекта -### Утилиты (`src/utils/mlflow_decorators.py`) -Я реализовал декоратор `@log_experiment`, который берет на себя: -- Запуск MLflow run. -- Установку имени эксперимента. -- Автоматическое логирование времени выполнения. -- Перехват и логирование исключений. -- Логирование параметров и метрик через вспомогательные функции. +Проект демонстрирует полную интеграцию ClearML для MLOps workflow на примере задачи классификации качества вина. Реализованы: +- Автоматический трекинг экспериментов +- Версионирование моделей +- ML пайплайны +- Дашборды и сравнение экспериментов + +### Используемые модели +- Random Forest +- Gradient Boosting +- Logistic Regression +- SVM +- Decision Tree +- KNN + +--- + +## 1. Настройка ClearML (3 балла) + +### 1.1 Установка ClearML Server через Docker + +Проект включает готовый `docker-compose.yml` для развертывания ClearML Server: + +```bash +# Запуск ClearML Server +make clearml_server_start + +# Или напрямую +cd clearml && docker-compose up -d +``` + +**Компоненты:** +- **MongoDB** - основная база данных +- **Elasticsearch** - поиск и аналитика +- **Redis** - кэширование и сессии +- **ClearML API Server** - REST API (порт 8008) +- **ClearML Web Server** - веб-интерфейс (порт 8080) +- **ClearML File Server** - хранилище файлов (порт 8081) +- **ClearML Agent** - опционально для удаленного выполнения + +**Доступ к сервисам:** +- Web UI: http://localhost:8080 +- API: http://localhost:8008 +- Files: http://localhost:8081 + +### 1.2 Настройка аутентификации + +1. Откройте Web UI: http://localhost:8080 +2. Перейдите в Settings → Workspace → Create new credentials +3. Скопируйте credentials + +**Способы настройки:** + +**Вариант 1: Интерактивная настройка** +```bash +clearml-init +``` + +**Вариант 2: Переменные окружения** +```bash +export CLEARML_API_HOST=http://localhost:8008 +export CLEARML_WEB_HOST=http://localhost:8080 +export CLEARML_FILES_HOST=http://localhost:8081 +export CLEARML_API_ACCESS_KEY= +export CLEARML_API_SECRET_KEY= +``` + +**Вариант 3: Файл конфигурации** +```bash +cp clearml/clearml.conf.example ~/clearml.conf +# Отредактируйте файл, добавив credentials +``` + +### 1.3 Проверка настройки + +```bash +# Проверка статуса +make clearml_setup + +# Тестирование подключения +make clearml_test + +# Создание проекта +make clearml_create_project +``` + +### 1.4 Конфигурация Docker Compose + +```yaml:clearml/docker-compose.yml +version: "3.8" + +services: + mongo: + image: mongo:6.0 + volumes: + - clearml-mongo-data:/data/db + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + + redis: + image: redis:7 + volumes: + - clearml-redis-data:/data + + apiserver: + image: allegroai/clearml:latest + ports: + - "8008:8008" + + webserver: + image: allegroai/clearml:latest + ports: + - "8080:80" + + fileserver: + image: allegroai/clearml:latest + ports: + - "8081:8081" +``` + +--- + +## 2. Трекинг экспериментов (3 балла) + +### 2.1 Автоматическое логирование + +Модуль `src/clearml_integration/experiment_tracker.py` обеспечивает: -### Пример использования декоратора ```python -@log_experiment(experiment_name="wine_quality_multimodel_v1") -def run_experiment(self, model_name: str, model_class: Any, params: Dict[str, Any]): - # ... логика ... - mlflow.log_param("model_type", model_name) - # ... обучение ... - mlflow.sklearn.log_model(model, "model") +from src.clearml_integration import ClearMLExperiment + +# Контекстный менеджер для экспериментов +with ClearMLExperiment( + experiment_name="RandomForest_Experiment", + project_name="EPML-ITMO/Wine-Quality/Experiments", + tags=["RandomForest", "wine-quality"], +) as exp: + # Автоматическое логирование параметров + exp.log_parameters({"n_estimators": 100, "max_depth": 10}) + + # Обучение модели + model.fit(X_train, y_train) + + # Логирование метрик с визуализацией + exp.log_classification_report(y_test, y_pred) + + # Логирование модели + exp.log_model(model, "random_forest") +``` + +**Декоратор для функций:** +```python +from src.clearml_integration import clearml_experiment + +@clearml_experiment( + experiment_name="training_pipeline", + project_name="EPML-ITMO/Wine-Quality" +) +def train_model(clearml_experiment=None): + # Эксперимент автоматически создается и закрывается + clearml_experiment.log_metrics({"accuracy": 0.95}) ``` -### Рефакторинг -Оригинальный скрипт `train_model.py` был переписан с использованием этих декораторов, что обеспечило единообразное логирование как для ручных запусков обучения, так и для систематических экспериментов. +### 2.2 Система сравнения экспериментов + +```python +from src.clearml_integration.experiment_tracker import ExperimentComparison + +comparison = ExperimentComparison(project_name="EPML-ITMO/Wine-Quality/Experiments") + +# Получение всех экспериментов +experiments = comparison.get_experiments(tags=["classification"]) -## 3. Проведенные эксперименты +# Сравнение метрик +df = comparison.compare_metrics(metric_names=["accuracy", "f1_score"]) -Я создал скрипт `src/models/run_experiments.py` для систематического запуска экспериментов с различными алгоритмами и конфигурациями гиперпараметров. +# Генерация отчета +comparison.generate_report("outputs/comparison_report.json") +``` -### Протестированные алгоритмы -1. **Random Forest Classifier** (Базовая модель) -2. **Gradient Boosting Classifier** -3. **Logistic Regression** -4. **Support Vector Machine (SVM)** -5. **Decision Tree** -6. **K-Nearest Neighbors (KNN)** +### 2.3 Логирование метрик и параметров -Всего экспериментов: 18 +**Поддерживаемые типы логирования:** +- Скалярные метрики с итерациями +- Confusion Matrix +- Classification Report +- Произвольные графики +- Артефакты (DataFrame, файлы, словари) +- Датасеты -### Сводка результатов -Сравнение моделей проводилось по метрикам **F1 Score** (weighted) и **Accuracy**. +```python +# Скалярные метрики +exp.log_metric("accuracy", 0.95, series="validation", iteration=epoch) -| Тип модели | Параметры | Accuracy | F1 Score | Precision | Recall | -|--------------------|----------------------------------------------|----------|----------|-----------|--------| -| **GradientBoosting** | n_estimators=100, learning_rate=0.2 | **0.6625** | **0.6543** | 0.6520 | 0.6625 | -| RandomForest | n_estimators=50, min_samples_split=5 | 0.6656 | 0.6470 | 0.6337 | 0.6656 | -| RandomForest | n_estimators=200, max_depth=None | 0.6562 | 0.6390 | 0.6287 | 0.6562 | -| RandomForest | n_estimators=100, max_depth=10 | 0.6437 | 0.6240 | 0.6108 | 0.6437 | -| GradientBoosting | n_estimators=50, learning_rate=0.1 | 0.6031 | 0.5923 | 0.5952 | 0.6031 | +# Множественные метрики +exp.log_metrics({ + "accuracy": 0.95, + "precision": 0.94, + "recall": 0.93, + "f1_score": 0.935 +}) -*Примечание: Таблица отсортирована по убыванию F1 Score.* +# Confusion Matrix +exp.log_confusion_matrix(y_true, y_pred, labels=class_names) -### Наблюдения -- **Gradient Boosting** с более высоким `learning_rate` (0.2) показал лучший результат по F1 Score (0.654), немного превзойдя модели Random Forest по балансу метрик, хотя Random Forest показал чуть более высокую "сырую" точность (Accuracy) в одной из конфигураций. -- **Logistic Regression** и **SVM** показали результаты значительно хуже (F1 ~0.51-0.54), что говорит о нелинейных зависимостях в данных, которые лучше улавливаются древовидными моделями. -- **KNN** показал худший результат (F1 ~0.43). +# Артефакты +exp.log_artifact("feature_importance", feature_df) +exp.log_dataset(train_df, test_df) +``` -## 4. Воспроизводимость -- Все эксперименты версионируются через Git и DVC. -- Скрипт `src/models/run_experiments.py` позволяет перезапустить весь набор экспериментов. -- MLflow отслеживает точные параметры, использованные для каждого запуска. +### 2.4 Дашборды для анализа -## 5. Визуализации -(Симуляция скриншота интерфейса MLflow) -Интерфейс MLflow (`mlflow ui`) позволяет сравнивать эти запуски. График Parallel Coordinates в MLflow эффективно визуализирует влияние `n_estimators` и `learning_rate` на F1 score для моделей Gradient Boosting. +```bash +# Запуск дашборда с саммари +make clearml_dashboard -![MLflow UI](reports/figures/mlflow_exps_1.jpg) -![MLflow UI](reports/figures/mlflow_exps_2.jpg) +# Генерация полного отчета +make clearml_report -## Заключение -Настройка системы трекинга успешно помогла выявить Gradient Boosting как сильного кандидата для данного датасета, улучшив показатели базовой модели. Интегрированная инфраструктура логирования упростит будущий подбор гиперпараметров. +# Экспорт всех метрик и отчетов +make clearml_export +``` + +**Модуль `dashboard.py`:** +```python +from src.clearml_integration.dashboard import ClearMLDashboard + +dashboard = ClearMLDashboard() + +# Печать саммари +dashboard.print_summary() + +# Генерация Markdown отчета +dashboard.generate_full_report() + +# Экспорт метрик в CSV +dashboard.export_metrics_csv() + +# Экспорт саммари в JSON +dashboard.export_summary_json() +``` + +--- + +## 3. Управление моделями (3 балла) + +### 3.1 Регистрация и версионирование моделей + +```python +from src.clearml_integration import ClearMLModelManager + +manager = ClearMLModelManager(project_name="EPML-ITMO/Wine-Quality/Models") + +# Регистрация модели с автоматическим версионированием +model_id = manager.register_model( + model=trained_model, + model_name="RandomForest", + metrics={"accuracy": 0.95, "f1_score": 0.93}, + parameters={"n_estimators": 100}, + tags=["production", "wine-quality"], + description="Best RandomForest model for wine quality" +) +``` + +### 3.2 Система метаданных + +Каждая модель сохраняется со следующими метаданными: +```json +{ + "model_id": "RandomForest_v1_20251227_120000", + "model_name": "RandomForest", + "version": 1, + "framework": "sklearn", + "created_at": "2025-12-27T12:00:00", + "model_path": "outputs/clearml/models/RandomForest/v1/...", + "metrics": {"accuracy": 0.95, "f1_score": 0.93}, + "parameters": {"n_estimators": 100}, + "tags": ["production", "wine-quality"], + "model_class": "RandomForestClassifier" +} +``` + +### 3.3 Автоматическое создание версий + +```python +# Версии создаются автоматически при каждой регистрации +manager.register_model(model_v1, "RandomForest", metrics_v1) # v1 +manager.register_model(model_v2, "RandomForest", metrics_v2) # v2 +manager.register_model(model_v3, "RandomForest", metrics_v3) # v3 + +# Получение всех версий +versions = manager.get_model_versions("RandomForest") + +# Загрузка конкретной версии +model, metadata = manager.load_model("RandomForest", version=2) + +# Загрузка последней версии +model, metadata = manager.load_model("RandomForest", version="latest") +``` + +### 3.4 Система сравнения моделей + +```python +# Сравнение всех моделей по метрике +comparison_df = manager.compare_models(metric="accuracy") + +# Получение лучшей модели +best_id, best_metadata = manager.get_best_model(metric="accuracy") + +# Генерация отчета +manager.generate_model_report("outputs/model_report.md") + +# Экспорт модели для деплоя +manager.export_model("RandomForest", version="latest", export_path="deployment/") +``` + +```bash +# CLI команды +make clearml_compare_models +``` + +--- + +## 4. Пайплайны (2 балла) + +### 4.1 ClearML пайплайны для ML workflow + +```python +from src.clearml_integration import ClearMLPipeline + +# Создание пайплайна +pipeline = ClearMLPipeline( + pipeline_name="Wine-Quality-RandomForest", + project_name="EPML-ITMO/Wine-Quality/Pipelines", + version="1.0.0" +) + +# Добавление шагов +pipeline.add_data_step( + train_path="data/processed/train.csv", + test_path="data/processed/test.csv" +) + +pipeline.add_training_step( + model_name="RandomForest", + model_params={"n_estimators": 100, "max_depth": 10} +) + +pipeline.add_evaluation_step( + metrics=["accuracy", "precision", "recall", "f1_score"] +) + +pipeline.add_model_registration_step() + +# Запуск пайплайна +results = pipeline.run(local_mode=True) +``` + +### 4.2 Готовые функции для пайплайнов + +```python +from src.clearml_integration.pipeline import ( + create_wine_quality_pipeline, + run_all_models_pipeline +) + +# Создание пайплайна для одной модели +pipeline = create_wine_quality_pipeline( + model_name="GradientBoosting", + model_params={"n_estimators": 100} +) +results = pipeline.run() + +# Запуск всех моделей +all_results = run_all_models_pipeline() +``` + +### 4.3 Мониторинг выполнения + +Пайплайн автоматически логирует: +- Время выполнения каждого шага +- Успех/неудачу шагов +- Промежуточные результаты +- Финальные метрики + +```python +# Результаты пайплайна +{ + "pipeline_name": "Wine-Quality-RandomForest", + "version": "1.0.0", + "success": True, + "total_duration": 15.5, + "steps": { + "data_loading": {"success": True, "duration": 0.5}, + "train_randomforest": {"success": True, "duration": 10.0}, + "evaluation": {"success": True, "duration": 2.0}, + "model_registration": {"success": True, "duration": 3.0} + }, + "final_metrics": {"accuracy": 0.95, "f1_score": 0.93} +} +``` + +### 4.4 Уведомления + +Система мониторинга отправляет уведомления о: +- Успешном завершении пайплайна +- Ошибках выполнения +- Результатах метрик + +Уведомления сохраняются в `outputs/clearml/pipelines/` и логируются в ClearML. + +--- + +## Быстрый старт + +### Установка + +```bash +# Клонирование репозитория +git clone +cd epml_itmo + +# Установка зависимостей +poetry install + +# Активация окружения +poetry shell +``` + +### Запуск ClearML Server + +```bash +# Запуск Docker контейнеров +make clearml_server_start + +# Проверка статуса +make clearml_server_status + +# Настройка credentials (интерактивно) +clearml-init +``` + +### Запуск экспериментов + +```bash +# Запуск одного эксперимента +make clearml_experiment MODEL=RandomForest + +# Запуск всех экспериментов +make clearml_experiments_all + +# Запуск в офлайн режиме (без сервера) +make clearml_experiments_offline +``` + +### Запуск пайплайнов + +```bash +# Пайплайн для одной модели +make clearml_pipeline MODEL=RandomForest + +# Пайплайн для всех моделей +make clearml_pipeline_all +``` + +### Анализ и отчеты + +```bash +# Сравнение экспериментов +make clearml_compare + +# Сравнение моделей +make clearml_compare_models + +# Генерация отчетов +make clearml_report + +# Полный workflow +make clearml_full +``` + +### Воспроизведение результатов + +```bash +# Полная последовательность команд для воспроизведения +make clearml_server_start +clearml-init # Ввести credentials из Web UI +make clearml_create_project +make clearml_experiments_all +make clearml_compare_models +make clearml_report +``` + +--- + +## Структура проекта + +``` +├── clearml/ +│ ├── docker-compose.yml # ClearML Server конфигурация +│ ├── env.example # Пример переменных окружения +│ ├── clearml.conf.example # Пример конфигурации клиента +│ └── setup_clearml.py # Скрипт настройки +│ +├── conf/ +│ ├── config.yaml # Основная конфигурация Hydra +│ └── clearml/ +│ └── default.yaml # Конфигурация ClearML +│ +├── src/ +│ └── clearml_integration/ +│ ├── __init__.py +│ ├── experiment_tracker.py # Трекинг экспериментов +│ ├── model_manager.py # Управление моделями +│ ├── pipeline.py # ML пайплайны +│ ├── dashboard.py # Дашборды и отчеты +│ └── run_experiments.py # CLI для экспериментов +│ +├── outputs/ +│ └── clearml/ +│ ├── models/ # Зарегистрированные модели +│ ├── pipelines/ # Результаты пайплайнов +│ └── dashboard/ # Отчеты и экспорты +│ +├── Makefile # Make команды +└── README.md # Этот файл +``` + +--- + +## Команды Makefile + +| Команда | Описание | +|---------|----------| +| `make clearml_server_start` | Запуск ClearML Server | +| `make clearml_server_stop` | Остановка ClearML Server | +| `make clearml_server_status` | Статус контейнеров | +| `make clearml_setup` | Проверка конфигурации | +| `make clearml_test` | Тест подключения | +| `make clearml_create_project` | Создание проекта | +| `make clearml_experiment MODEL=X` | Запуск одного эксперимента | +| `make clearml_experiments_all` | Запуск всех экспериментов | +| `make clearml_experiments_offline` | Офлайн режим | +| `make clearml_compare` | Сравнение экспериментов | +| `make clearml_compare_models` | Сравнение моделей | +| `make clearml_pipeline MODEL=X` | Запуск пайплайна | +| `make clearml_pipeline_all` | Все пайплайны | +| `make clearml_dashboard` | Дашборд саммари | +| `make clearml_report` | Полный отчет | +| `make clearml_export` | Экспорт данных | +| `make clearml_full` | Полный workflow | +| `make clearml_clean` | Очистка outputs | + +--- + +## Скриншоты + +### ClearML Web UI - Эксперименты +*После запуска `make clearml_experiments_all` в Web UI отображаются все эксперименты с метриками.* + +![ClearML Experiments](reports/figures/clearml_experiments.png) + +### ClearML Web UI - Сравнение +*Функция сравнения позволяет визуально сопоставить результаты разных моделей.* + +![ClearML Comparison](reports/figures/clearml_comparison.png) + +### ClearML Web UI - Модели +*Реестр моделей с версионированием и метаданными.* + +![ClearML Models](reports/figures/clearml_models.png) + +> **Примечание:** Для получения скриншотов запустите ClearML Server и выполните эксперименты. +> Скриншоты будут доступны в Web UI по адресу http://localhost:8080 + +--- + +## Требования + +- Python 3.12+ +- Poetry +- Docker & Docker Compose + +### Зависимости Python +``` +clearml>=2.1.0 +pandas +numpy +scikit-learn +hydra-core +omegaconf +pydantic +``` + +--- + +## Ссылки + +- [ClearML Documentation](https://clear.ml/docs/) +- [ClearML GitHub](https://github.com/allegroai/clearml) +- [ClearML Server Setup](https://clear.ml/docs/latest/docs/deploying_clearml/clearml_server) + +--- + +## Project Organization (Original) + +``` +├── LICENSE +├── Makefile <- Makefile with commands like `make data` or `make train` +├── README.md <- The top-level README for developers using this project. +├── data +│ ├── external <- Data from third party sources. +│ ├── interim <- Intermediate data that has been transformed. +│ ├── processed <- The final, canonical data sets for modeling. +│ └── raw <- The original, immutable data dump. +│ +├── docs <- A default Sphinx project; see sphinx-doc.org for details +│ +├── models <- Trained and serialized models, model predictions, or model summaries +│ +├── notebooks <- Jupyter notebooks. +│ +├── pyproject.toml <- Project configuration and dependencies. +├── poetry.lock <- Locked dependency versions. +│ +├── references <- Data dictionaries, manuals, and all other explanatory materials. +│ +├── reports <- Generated analysis as HTML, PDF, LaTeX, etc. +│ └── figures <- Generated graphics and figures to be used in reporting +│ +├── src <- Source code for use in this project. +│ ├── __init__.py <- Makes src a Python module +│ ├── data <- Scripts to download or generate data +│ ├── features <- Scripts to turn raw data into features for modeling +│ ├── models <- Scripts to train models and make predictions +│ ├── pipelines <- ML pipeline orchestration +│ ├── clearml_integration <- ClearML MLOps integration +│ └── visualization <- Scripts to create visualizations +``` + +--- + +## Getting Started + +### Prerequisites + +- Python 3.12+ +- Poetry (for dependency management) +- Docker (for ClearML Server) + +### Installation + +1. Clone the repository +2. Install dependencies with Poetry: + +```bash +poetry install +``` + +3. Activate the virtual environment: + +```bash +poetry shell +``` + +### Code Quality + +This project uses `ruff`, `mypy`, and `bandit` for code quality. + +Run linters: + +```bash +poetry run ruff check . +poetry run mypy . +poetry run bandit -r src +``` + +Pre-commit hooks are configured to run automatically on commit. + +### Docker + +Build the docker image: + +```bash +docker build -t epml-itmo . +``` From cebab85f3c89c121e9634cfb3289cb4a592c0bd1 Mon Sep 17 00:00:00 2001 From: itwastony Date: Sat, 27 Dec 2025 01:07:38 +0500 Subject: [PATCH 8/8] HW5: Added screenshots and fix report.md --- REPORT.md | 8 +++----- reports/figures/clearml_comparison.jpg | Bin 0 -> 96342 bytes reports/figures/clearml_experiments.jpg | Bin 0 -> 63280 bytes reports/figures/clearml_models.jpg | Bin 0 -> 57785 bytes 4 files changed, 3 insertions(+), 5 deletions(-) create mode 100644 reports/figures/clearml_comparison.jpg create mode 100644 reports/figures/clearml_experiments.jpg create mode 100644 reports/figures/clearml_models.jpg diff --git a/REPORT.md b/REPORT.md index 5eef4b4..2c861db 100644 --- a/REPORT.md +++ b/REPORT.md @@ -582,20 +582,18 @@ make clearml_report ### ClearML Web UI - Эксперименты *После запуска `make clearml_experiments_all` в Web UI отображаются все эксперименты с метриками.* -![ClearML Experiments](reports/figures/clearml_experiments.png) +![ClearML Experiments](reports/figures/clearml_experiments.jpg) ### ClearML Web UI - Сравнение *Функция сравнения позволяет визуально сопоставить результаты разных моделей.* -![ClearML Comparison](reports/figures/clearml_comparison.png) +![ClearML Comparison](reports/figures/clearml_comparison.jpg) ### ClearML Web UI - Модели *Реестр моделей с версионированием и метаданными.* -![ClearML Models](reports/figures/clearml_models.png) +![ClearML Models](reports/figures/clearml_models.jpg) -> **Примечание:** Для получения скриншотов запустите ClearML Server и выполните эксперименты. -> Скриншоты будут доступны в Web UI по адресу http://localhost:8080 --- diff --git a/reports/figures/clearml_comparison.jpg b/reports/figures/clearml_comparison.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c3a24898fc0061beeededa2065f5d40664ed2525 GIT binary patch literal 96342 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 literal 0 HcmV?d00001 diff --git a/reports/figures/clearml_experiments.jpg b/reports/figures/clearml_experiments.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d292c3eb6e56338908be22150647cae4a970cb0 GIT binary patch literal 63280 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 LGq09V|9=wzt)m3P literal 0 HcmV?d00001 diff --git a/reports/figures/clearml_models.jpg b/reports/figures/clearml_models.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8918faad9743536b9ebd9db21e2e177898db8f6e GIT binary patch literal 57785 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 literal 0 HcmV?d00001