From c3f1c2426f8e90c3165f0a9dc650a29702799f3d Mon Sep 17 00:00:00 2001 From: GeoSegun Date: Wed, 22 Oct 2025 19:11:29 +0100 Subject: [PATCH 1/4] mlflow server for tracking trained ML model to aid model re-use using rRun ID --- .../mlflow-server/README.md | 13 + .../mlflow-server/mlflow-tracking.ipynb | 237 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 examples/machine-learning_ClassicAI/mlflow-server/README.md create mode 100644 examples/machine-learning_ClassicAI/mlflow-server/mlflow-tracking.ipynb diff --git a/examples/machine-learning_ClassicAI/mlflow-server/README.md b/examples/machine-learning_ClassicAI/mlflow-server/README.md new file mode 100644 index 00000000..66dadb90 --- /dev/null +++ b/examples/machine-learning_ClassicAI/mlflow-server/README.md @@ -0,0 +1,13 @@ +# MLflow Tracking Server Template + +This template demonstrates how to use MLflow to track, log, and manage machine learning experiments in a single notebook. It trains a Random Forest model on the Diabetes dataset, logs parameters, metrics, and artifacts, and enables viewing and reloading runs locally or through a remote MLflow tracking server. + +You can deploy MLflow-tracked models via platforms like **Saturn Cloud**, refer to Saturn’s documentation for deployment guidance. + +--- + +## References + +* [MLflow Documentation](https://mlflow.org/docs/latest/index.html) +* [Saturn Cloud Docs](https://saturncloud.io/docs/) +* [Scikit-learn RandomForestRegressor](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html) \ No newline at end of file diff --git a/examples/machine-learning_ClassicAI/mlflow-server/mlflow-tracking.ipynb b/examples/machine-learning_ClassicAI/mlflow-server/mlflow-tracking.ipynb new file mode 100644 index 00000000..8497df8f --- /dev/null +++ b/examples/machine-learning_ClassicAI/mlflow-server/mlflow-tracking.ipynb @@ -0,0 +1,237 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "id": "JwE9FH9oFOMb" + }, + "source": [ + "# MLflow Tracking Server\n", + "\n", + "**MLflow** is an open-source platform that simplifies the tracking, comparison, and deployment of machine learning experiments.\n", + "\n", + "In this sample example template, you’ll use MLflow to **track training runs**, **log parameters and metrics**, and **store models** for future reuse — all within a single notebook.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oV2djjnvFOMj" + }, + "source": [ + "Install **MLflow**, **Gradio**, and supporting libraries including **scikit‑learn**, **matplotlib**, and **pandas**.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "b5yEqAXTFOMk" + }, + "outputs": [], + "source": [ + "!pip install -q mlflow scikit-learn matplotlib pandas gradio\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "IppJqmrgFOMm" + }, + "source": [ + "Import MLflow, perform a quick GPU check with PyTorch, and load helper libraries used throughout.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "F31VfPfuFOMn" + }, + "outputs": [], + "source": [ + "import mlflow, os, torch, pandas as pd, matplotlib.pyplot as plt, gradio as gr\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.datasets import load_diabetes\n", + "from sklearn.ensemble import RandomForestRegressor\n", + "\n", + "device = 'cuda' if torch.cuda.is_available() else 'cpu'\n", + "print(f'✅ Using device: {device}')\n", + "if device == 'cpu':\n", + " print('⚠️ Running on CPU — switch to GPU for faster performance if available.')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "fyS8Q66nFOMp" + }, + "source": [ + "By default, MLflow saves runs to the local **`mlruns/`** directory. You can switch to a **remote tracking server** later by setting a different tracking URI.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "Le6Ec_F_FOMq" + }, + "outputs": [], + "source": [ + "mlflow.set_tracking_uri('file:///content/mlruns')\n", + "mlflow.set_experiment('mlflow_tracking_demo')\n", + "print('🎯 Tracking URI:', mlflow.get_tracking_uri())\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "eTSpUqXLFOMs" + }, + "source": [ + "It fetches experiment metadata, parameters, and metrics from your local `mlruns/` directory (or a remote server if configured).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "tNSyaQmHFOMu" + }, + "outputs": [], + "source": [ + "from mlflow.tracking import MlflowClient\n", + "\n", + "def show_mlflow_runs_table(experiment_name=\"mlflow_tracking_demo\"):\n", + " \"\"\"Display all MLflow runs (similar to MLflow UI Table).\"\"\"\n", + " client = MlflowClient()\n", + " experiment = client.get_experiment_by_name(experiment_name)\n", + "\n", + " if not experiment:\n", + " return pd.DataFrame({\"Info\": [\"No experiment found. Run a training cell first.\"]})\n", + " runs = client.search_runs([experiment.experiment_id])\n", + " if not runs:\n", + " return pd.DataFrame({\"Info\": [\"No runs logged yet.\"]})\n", + "\n", + " rows = []\n", + " for r in runs:\n", + " row = {\n", + " \"Run ID\": r.info.run_id,\n", + " \"Status\": r.info.status,\n", + " \"Start Time\": pd.to_datetime(r.info.start_time, unit=\"ms\"),\n", + " \"End Time\": pd.to_datetime(r.info.end_time, unit=\"ms\"),\n", + " \"Duration (s)\": round((r.info.end_time - r.info.start_time) / 1000, 2)\n", + " if r.info.end_time else None,\n", + " }\n", + " row.update(r.data.params)\n", + " row.update(r.data.metrics)\n", + " rows.append(row)\n", + "\n", + " df = pd.DataFrame(rows)\n", + " main_cols = [\"Run ID\", \"Status\", \"Start Time\", \"End Time\", \"Duration (s)\"]\n", + " other_cols = [c for c in df.columns if c not in main_cols]\n", + " df = df[main_cols + other_cols]\n", + " print(f\"✅ Showing {len(df)} runs from experiment '{experiment_name}'\")\n", + " return df\n", + "\n", + "runs_df = show_mlflow_runs_table()\n", + "display(runs_df)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oMeP7H8qFOMw" + }, + "source": [ + "Let's train a small **Random Forest** on the Diabetes dataset and log parameters, metrics, and the model artefact to MLflow.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "tqqgobq6FOMy" + }, + "outputs": [], + "source": [ + "from mlflow.models.signature import infer_signature\n", + "\n", + "with mlflow.start_run() as run:\n", + " db = load_diabetes()\n", + " X_train, X_test, y_train, y_test = train_test_split(db.data, db.target, test_size=0.2, random_state=42)\n", + "\n", + " model = RandomForestRegressor(n_estimators=100, max_depth=6, random_state=42)\n", + " model.fit(X_train, y_train)\n", + " preds = model.predict(X_test)\n", + " signature = infer_signature(X_test, preds)\n", + "\n", + " mlflow.log_params({'n_estimators': 100, 'max_depth': 6})\n", + " mlflow.log_metric('mean_prediction', float(preds.mean()))\n", + " mlflow.sklearn.log_model(model, 'model', signature=signature)\n", + "\n", + " print(f'Run ID: {run.info.run_id}')\n", + " print('✅ Training and logging complete!')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "KwHrJpK9FOMz" + }, + "source": [ + "Use the run ID to load the stored model for inference.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "iB6HPf3kFOM0" + }, + "outputs": [], + "source": [ + "run_id = run.info.run_id\n", + "loaded_model = mlflow.sklearn.load_model(f'runs:/{run_id}/model')\n", + "print('✅ Model loaded successfully!')\n", + "print('Sample predictions:', loaded_model.predict(X_test[:5]))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "7_ULM2cqFOM0" + }, + "source": [ + "So, you've configured MLflow tracking locally (can be configure for MLflow remote server too), logged parameters, metrics, and model artifacts.\n", + "\n", + "Additionally, you can reload a trained model from specific run using the `run Id`. Guide on deployment on saturn can be found in the [saturn documentation](https://saturncloud.io/docs)." + ] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From fc066e3972dfdf2aea439a6f43049effe0b0f0ca Mon Sep 17 00:00:00 2001 From: Olusegun Durojaye Date: Fri, 23 Jan 2026 19:54:03 -0500 Subject: [PATCH 2/4] Base model setup for production pipeline using python server --- .../cpu-sklearn-gbm_/README.md | 129 ++++++++++++++++++ .../cpu-sklearn-gbm_/app.py | 58 ++++++++ .../asset/baseline_comparison.png | Bin 0 -> 39317 bytes .../cpu-sklearn-gbm_/baseline.py | 87 ++++++++++++ .../cpu-sklearn-gbm_/setup.sh | 42 ++++++ 5 files changed, 316 insertions(+) create mode 100644 examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/README.md create mode 100644 examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/app.py create mode 100644 examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/asset/baseline_comparison.png create mode 100644 examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/baseline.py create mode 100755 examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/setup.sh diff --git a/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/README.md b/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/README.md new file mode 100644 index 00000000..e652acbc --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/README.md @@ -0,0 +1,129 @@ +# scikit-learn GBM (Python Server) + +
+ +
+ +This template implements a production-ready **Baseline Model Comparison** workflow using **scikit-learn** on a **Python Server**. It demonstrates two core server capabilities: +1. **Batch Processing**: A script to train a model and generate static reports (headless plotting). +2. **API Service**: A FastAPI server to serve real-time predictions from the trained model. + +**Infrastructure:** [Saturn Cloud](https://saturncloud.io/) +**Resource:** Python Server +**Hardware:** CPU +**Tech Stack:** scikit-learn, FastAPI, Pandas, Seaborn + +--- + +## 🚀 Quick Start + +### 1. Environment Setup +Run the setup script to automatically create the Python virtual environment (`venv`) and install all required dependencies (including FastAPI and scikit-learn). + +```bash +# 1. Make the script executable (if needed) +chmod +x setup.sh + +# 2. Run the setup +bash setup.sh + +``` + +### 2. Train the Model (Batch Job) + +Run the baseline script. This performs the following actions: + +* Loads the Iris dataset. +* Trains multiple models (Dummy, SVM, Logistic Regression, Decision Tree). +* **Saves the Model:** Exports the trained Logistic Regression model to `iris_model.pkl`. +* **Saves the Report:** Generates a performance plot (`baseline_comparison.png`) without requiring a display monitor. + +```bash +# Activate the environment +source venv/bin/activate + +# Run the training pipeline +python baseline.py + +``` + +### 3. Start the API Server + +Once the model is trained and saved, start the **FastAPI** server to begin accepting prediction requests. + +```bash +# Start the server (runs on port 8000) +python app.py + +``` + +--- + +## 🧠 Project Architecture + +### Files Included + +* **`setup.sh`**: Robust setup script that handles virtual environment creation and dependency installation. +* **`baseline.py`**: The "Batch" workload. It compares model performance against a baseline and saves artifacts (model + plots) to disk. +* **`app.py`**: The "Service" workload. A FastAPI application that loads `iris_model.pkl` and serves an HTTP endpoint for predictions. + +### Model Details + +* **Baseline Strategy**: Uses a "Dummy Classifier" to establish minimum acceptable accuracy. +* **Production Model**: A **Logistic Regression** classifier is selected for deployment due to its efficiency and interpretability. + +--- + +## 🧪 Testing & Validation + +You can interact with this template in two ways: + +### A. Check Batch Results + +After running `baseline.py`, verify that the artifacts were created: + +```bash +# Check for the plot and the saved model +ls -lh baseline_comparison.png iris_model.pkl + +``` + +### B. Test the API (Web Interface) + +While `python app.py` is running, open your browser to the auto-generated documentation: + +* **URL:** `http://localhost:8000/docs` +* **Action:** Click **POST /predict** -> **Try it out** -> **Execute**. + +### C. Test the API (Terminal) + +You can also send a raw HTTP request from a separate terminal window: + +```bash +curl -X 'POST' \ + 'http://localhost:8000/predict' \ + -H 'Content-Type: application/json' \ + -d '{ + "sepal_length": 5.1, + "sepal_width": 3.5, + "petal_length": 1.4, + "petal_width": 0.2 +}' + +``` + +**Expected Output:** + +```json +{"class_id":0,"class_name":"setosa"} + +``` + +--- + +## 🏁 Conclusion + +This template provides a robust foundation for deploying machine learning models on CPU-based Python Servers. By separating the **training pipeline** (`baseline.py`) from the **inference service** (`app.py`), it adheres to MLOps best practices, ensuring that model artifacts are versioned and reproducible. + +For scaling this workflow to larger datasets or deploying it to a managed cluster, consider moving this pipeline to [Saturn Cloud](https://saturncloud.io/). Use this structure as a starting point to deploy more complex models, such as Random Forests or Gradient Boosting Machines, while maintaining a clean and scalable deployment architecture. + diff --git a/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/app.py b/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/app.py new file mode 100644 index 00000000..318d2210 --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/app.py @@ -0,0 +1,58 @@ +from fastapi import FastAPI +from pydantic import BaseModel +import joblib +import numpy as np +import os + +# 1. Initialize API +app = FastAPI(title="Iris Baseline API") + +# 2. Define Input Schema (Sepal/Petal dimensions) +class IrisData(BaseModel): + sepal_length: float + sepal_width: float + petal_length: float + petal_width: float + +# 3. Load Model Global Variable +model = None +MODEL_PATH = "iris_model.pkl" + +@app.on_event("startup") +def load_model(): + global model + if os.path.exists(MODEL_PATH): + model = joblib.load(MODEL_PATH) + print(f"✅ Loaded model from {MODEL_PATH}") + else: + print(f"⚠️ Error: {MODEL_PATH} not found. Did you run baseline_demo.py?") + +# 4. Prediction Endpoint +@app.post("/predict") +def predict(data: IrisData): + if not model: + return {"error": "Model not trained yet."} + + # Convert input JSON to model-ready array + features = np.array([[ + data.sepal_length, + data.sepal_width, + data.petal_length, + data.petal_width + ]]) + + # Predict Class (0, 1, or 2) + prediction = int(model.predict(features)[0]) + + # Map to String Name + classes = {0: "setosa", 1: "versicolor", 2: "virginica"} + return { + "class_id": prediction, + "class_name": classes.get(prediction, "unknown") + } + +# 5. Run Server (If executed directly) +if __name__ == "__main__": + import uvicorn + # Host 0.0.0.0 is crucial for cloud servers to be accessible + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/asset/baseline_comparison.png b/examples/machine-learning_ClassicAI/cpu-sklearn-gbm_/asset/baseline_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..a33139ce25a065eebd106a16c6ce91aef2c83b5c GIT binary patch literal 39317 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 /dev/null; then + PY_CMD="python3" +elif command -v python &> /dev/null; then + PY_CMD="python" +else + echo -e "${RED}❌ Error: Could not find 'python3' or 'python' in your PATH.${NC}" + echo "Please install Python 3 before continuing." + exit 1 +fi + +echo -e "✅ Found Python executable: ${BLUE}$PY_CMD${NC}" + +# 2. Create Virtual Environment +echo -e "${BLUE}📦 Creating Virtual Environment 'venv'...${NC}" +$PY_CMD -m venv venv + +# 3. Install Dependencies +echo -e "${BLUE}⬇️ Installing libraries...${NC}" +source venv/bin/activate +pip install --upgrade pip +# Installing all required libraries for the Demo + API +pip install scikit-learn pandas numpy matplotlib seaborn fastapi uvicorn joblib + +echo -e "${GREEN}✅ Environment Ready!${NC}" +echo "-------------------------------------------------------" +echo "To run the full pipeline:" +echo "1. Train & Save Model: python baseline.py" +echo "2. Start API Server: python app.py" +echo "-------------------------------------------------------" \ No newline at end of file From 2ab2bc3ecd5cfbe52f4001bf9adadd2347580d31 Mon Sep 17 00:00:00 2001 From: Olusegun Durojaye Date: Sat, 24 Jan 2026 22:03:34 -0500 Subject: [PATCH 3/4] XGBboost model serving with API --- .../cpu-xgb-api/README.md | 112 +++++++++++ .../cpu-xgb-api/xgboost_serving.ipynb | 181 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 examples/machine-learning_ClassicAI/cpu-xgb-api/README.md create mode 100644 examples/machine-learning_ClassicAI/cpu-xgb-api/xgboost_serving.ipynb diff --git a/examples/machine-learning_ClassicAI/cpu-xgb-api/README.md b/examples/machine-learning_ClassicAI/cpu-xgb-api/README.md new file mode 100644 index 00000000..6866992a --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-xgb-api/README.md @@ -0,0 +1,112 @@ +# XGBoost Serving API + +This template implements a **maintenance-free** Model Serving workflow for a **Regression** problem. It uses the **California Housing dataset** (~20,000 samples) to train an XGBoost model that predicts house prices, deployed via a schema-agnostic FastAPI service. + +**Infrastructure:** [Saturn Cloud](https://saturncloud.io/) +**Resource:** Jupyter Notebook +**Hardware:** CPU +**Tech Stack:** XGBoost (Regression), FastAPI, Pandas, Scikit-Learn + +--- + +## 📖 Overview + +In traditional model serving, changing the model's features (e.g., adding "zip_code" or removing "age") often requires rewriting the API code. This template demonstrates a **"Model-First"** architecture where the API is generic and adapts to the model artifact automatically. + +This is deployed as a **Jupyter Notebook** resource on [Saturn Cloud](https://saturncloud.io/), allowing you to develop, train, and serve from a single environment. + +--- + +## 🚀 Quick Start + +### 1. Workflow + +1. Open **`xgboost_serving.ipynb`** in the Jupyter interface. +2. **Run All Cells**: +* **Install:** Installs dependencies (`xgboost`, `fastapi`, `uvicorn`) directly in the kernel. +* **Train:** Trains an `XGBRegressor` on the California Housing dataset (20,640 samples). +* **Generate:** Writes the `app.py` server file to disk. + + + +### 2. Launch the Server + +The notebook generates the API code for you. To run it, open a **Terminal** in Jupyter (File -> New -> Terminal) and execute: + +```bash +uvicorn app:app --host 0.0.0.0 --port 8000 + +``` +We can run it from the next code cell in the jupyter notebook. +--- + +## 🧠 Architecture: Schema-Agnostic Design + +This template uses a **"Model-First"** approach where the API code is decoupled from the specific features of the model. This allows the API to serve the regression model dynamically. + +* **Inputs:** A generic list of numerical values (representing the 8 housing features like `MedInc`, `HouseAge`, etc.). +* **Outputs:** A continuous float value (Estimated House Value). +* **Maintenance:** To update the model features (e.g., adding "Zip Code" or removing "Rooms"), you simply retrain and replace `model.json`. The Python API code remains untouched. + +### Dataset Details + +* **Source:** California Housing Dataset (1990 Census). +* **Target:** Median House Value in units of **$100,000**. +* **Features:** 8 numerical features including Median Income, House Age, Average Rooms, Latitude, and Longitude. + +--- + +## 🧪 Testing + +The API using the built-in Swagger UI or via the terminal. + +### Method 1: Web Interface + +Visit `http://localhost:8000/docs`. Click **POST /predict** -> **Try it out** and paste the JSON below. + +```json +{ + "features": [ + 8.32, + 41.0, + 6.98, + 1.02, + 322.0, + 2.55, + 37.88, + -122.23 + ] +} + +``` + +### Method 2: Terminal (CURL) + +Run this command in a separate terminal window to send a sample request: + +```bash +# Features: [MedInc, HouseAge, AveRooms, AveBedrms, Population, AveOccup, Lat, Long] +curl -X POST "http://localhost:8000/predict" \ + -H "Content-Type: application/json" \ + -d '{"features": [8.32, 41.0, 6.98, 1.02, 322.0, 2.55, 37.88, -122.23]}' + +``` + +**Expected Output:** + +```json +{"estimated_value": 4.526} + +``` + +*Interpretation: The model predicts a median house value of roughly **$452,600**.* + +--- + +Note: you can use whatever values for the parameters and get predicted results + +## 🏁 Conclusion + +For scaling this workflow—such as deploying this API to a Kubernetes cluster or scheduling the training job—consider moving this pipeline to [Saturn Cloud](https://saturncloud.io/). + +``` \ No newline at end of file diff --git a/examples/machine-learning_ClassicAI/cpu-xgb-api/xgboost_serving.ipynb b/examples/machine-learning_ClassicAI/cpu-xgb-api/xgboost_serving.ipynb new file mode 100644 index 00000000..2cd74509 --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-xgb-api/xgboost_serving.ipynb @@ -0,0 +1,181 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 🚀 XGBoost Serving API (California Housing)\n", + "\n", + "This notebook demonstrates a **Regression** workflow using a larger dataset.\n", + "\n", + "1. **Dataset**: California Housing (20,640 samples, 8 features).\n", + "2. **Model**: XGBoost Regressor (predicting continuous house prices).\n", + "3. **Serve**: A schema-agnostic FastAPI service." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. Install Dependencies\n", + "%pip install xgboost fastapi uvicorn scikit-learn pandas numpy\n", + "\n", + "print(\"✅ Dependencies installed. Restart kernel if needed.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import xgboost as xgb\n", + "import pandas as pd\n", + "import numpy as np\n", + "from sklearn.datasets import fetch_california_housing\n", + "from sklearn.model_selection import train_test_split\n", + "from sklearn.metrics import mean_squared_error\n", + "\n", + "print(\"✅ Libraries imported.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Train on California Housing Data\n", + "We use `fetch_california_housing`. This dataset is much larger than Iris, so the model learns real patterns rather than memorizing data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 1. Load Data (20,640 samples)\n", + "housing = fetch_california_housing()\n", + "X = pd.DataFrame(housing.data, columns=housing.feature_names)\n", + "y = housing.target\n", + "\n", + "# 2. Split\n", + "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)\n", + "\n", + "# 3. Train (Regression)\n", + "# We use XGBRegressor instead of Classifier\n", + "model = xgb.XGBRegressor(objective='reg:squarederror')\n", + "model.fit(X_train, y_train)\n", + "\n", + "# 4. Evaluate (RMSE)\n", + "preds = model.predict(X_test)\n", + "rmse = np.sqrt(mean_squared_error(y_test, preds))\n", + "print(f\"🎯 Model RMSE: {rmse:.4f} (Lower is better)\")\n", + "print(f\"📊 Typical Price Range: 0.15 to 5.0 (Units of $100k)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model.save_model(\"model.json\")\n", + "print(\"💾 Model saved to 'model.json'\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. The Maintenance-Free API\n", + "We use `%%writefile` to create `app.py`. \n", + "\n", + "Notice how we didn't have to manually define `MedInc`, `HouseAge`, etc. in the API. \n", + "The API simply accepts a list of floats `[feature_1, feature_2, ...]`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%writefile app.py\n", + "import xgboost as xgb\n", + "import numpy as np\n", + "import os\n", + "from fastapi import FastAPI\n", + "from pydantic import BaseModel\n", + "from typing import List\n", + "\n", + "app = FastAPI(title=\"Housing Price API\")\n", + "model = xgb.XGBRegressor()\n", + "MODEL_FILE = \"model.json\"\n", + "\n", + "# Generic Input Schema\n", + "class Payload(BaseModel):\n", + " features: List[float]\n", + "\n", + "@app.on_event(\"startup\")\n", + "def load_model():\n", + " if os.path.exists(MODEL_FILE):\n", + " model.load_model(MODEL_FILE)\n", + " print(f\"✅ Loaded {MODEL_FILE}\")\n", + " else:\n", + " print(\"⚠️ Model file missing.\")\n", + "\n", + "@app.post(\"/predict\")\n", + "def predict(payload: Payload):\n", + " # Convert generic list -> numpy array\n", + " vector = np.array([payload.features])\n", + " \n", + " # Predict (Returns a float for regression)\n", + " prediction = float(model.predict(vector)[0])\n", + " return {\"estimated_value\": prediction}\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Launch Server\n", + "To start the server, open a **Terminal** in Jupyter and run:\n", + "```bash\n", + "uvicorn app:app --host 0.0.0.0 --port 8000\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!uvicorn app:app --host 0.0.0.0 --port 8000" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "cpu-plotly-env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From aea043f4b0d9a74416f7eeb08cf84f2a50c7cdf4 Mon Sep 17 00:00:00 2001 From: Olusegun Durojaye Date: Sun, 25 Jan 2026 19:15:12 -0500 Subject: [PATCH 4/4] hyperparameter tuning and serving using optuna and ray tune --- .../cpu-hpo-optuna/README.md | 140 ++++++++++++++++++ .../cpu-hpo-optuna/app.py | 55 +++++++ .../cpu-hpo-optuna/setup.sh | 35 +++++ .../cpu-hpo-optuna/tune_hpo.py | 86 +++++++++++ 4 files changed, 316 insertions(+) create mode 100644 examples/machine-learning_ClassicAI/cpu-hpo-optuna/README.md create mode 100644 examples/machine-learning_ClassicAI/cpu-hpo-optuna/app.py create mode 100755 examples/machine-learning_ClassicAI/cpu-hpo-optuna/setup.sh create mode 100644 examples/machine-learning_ClassicAI/cpu-hpo-optuna/tune_hpo.py diff --git a/examples/machine-learning_ClassicAI/cpu-hpo-optuna/README.md b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/README.md new file mode 100644 index 00000000..390cdbc1 --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/README.md @@ -0,0 +1,140 @@ +# Hyperparameter Tuning & Serving (Optuna + Ray Tune) + +This template implements an end-to-end **Auto-ML workflow** on a **Python Server**. It automates the lifecycle of a machine learning model, combining the intelligent search of Optuna with the scalable execution of Ray Tune. + +**Infrastructure:** [Saturn Cloud](https://saturncloud.io/) +**Resource:** Python Server +**Hardware:** CPU +**Tech Stack:** Optuna, Ray Tune, FastAPI, Scikit-Learn + +--- + +## 📖 Overview + +Standard hyperparameter tuning scripts often stop at printing the best parameters. This template goes further by **operationalizing** the result. It solves two key problems: + +1. **Efficient Search:** It uses **Optuna's** Tree-Parzen Estimator (TPE) algorithm to intelligently select hyperparameters, wrapped in **Ray Tune** to run multiple trials in parallel. +2. **Instant Deployment:** The workflow automatically retrains the model with the best parameters, saves the artifact, and serves it via a production-ready **FastAPI** server. + +--- + +## 🚀 Quick Start + +### 1. Environment Setup +Run the setup script to create a virtual environment and install all dependencies (Ray, Optuna, FastAPI, Uvicorn, Scikit-Learn). +```bash +# 1. Make executable +chmod +x setup.sh + +# 2. Run setup +bash setup.sh + +``` + +### 2. Run the Tuning Job (Batch) + +Execute the tuning script. This will: + +* Launch 20 concurrent trials using Ray Tune. +* Identify the best configuration (e.g., `n_estimators`, `max_depth`). +* **Retrain** the model on the full dataset using those winning parameters. +* **Save** the final model to `best_model.pkl`. + +```bash +# Activate environment +source venv/bin/activate + +# Start tuning +python tune_hpo.py + +``` + +### 3. Start the API Server + +Once `tune_hpo.py` finishes and generates `best_model.pkl`, start the server to accept real-time requests. + +```bash +python app.py + +``` + +--- + +## 🧠 Architecture: "Tune & Serve" + +The workflow consists of two distinct stages designed to bridge the gap between experimentation and production. + +### Stage 1: Optimization (`tune_hpo.py`) + +* **Search Algorithm:** We use `OptunaSearch`, which leverages Bayesian optimization to learn from previous trials and find optimal parameters faster. +* **Execution Engine:** Ray Tune manages the resources. It uses a `ConcurrencyLimiter` to run 4 trials simultaneously on the CPU, significantly reducing total wait time. + +### Stage 2: Inference (`app.py`) + +* **Loader:** On startup, the API loads the optimized `best_model.pkl` artifact. +* **Endpoint:** Exposes a `/predict` route that accepts Iris flower features and returns the classified species (Setosa, Versicolor, or Virginica). + +--- + +## 🧪 Testing + +You can test the API using the built-in Swagger UI or via the terminal. + +### Method 1: Web Interface + +Visit `http://localhost:8000/docs`. Click **POST /predict** -> **Try it out**. + +**Test Case A: Setosa (Small Petals)** +Paste this JSON: + +```json +{ + "sepal_length": 5.1, "sepal_width": 3.5, + "petal_length": 1.4, "petal_width": 0.2 +} + +``` + +*Expected Result:* `{"class_name": "setosa"}` + +**Test Case B: Virginica (Large Petals)** +Paste this JSON: + +```json +{ + "sepal_length": 6.5, "sepal_width": 3.0, + "petal_length": 5.2, "petal_width": 2.0 +} + +``` + +*Expected Result:* `{"class_name": "virginica"}` + +### Method 2: Terminal (CURL) + +Run this command in a new terminal window to test the Virginica prediction: + +```bash +curl -X 'POST' \ + 'http://localhost:8000/predict' \ + -H 'Content-Type: application/json' \ + -d '{ + "sepal_length": 6.5, + "sepal_width": 3.0, + "petal_length": 5.2, + "petal_width": 2.0 +}' + +``` + +--- + +## 🏁 Conclusion + +This template demonstrates a "Best of Both Worlds" approach: using Optuna for search intelligence and Ray Tune for scaling. By automating the retraining and serving steps, you create a pipeline where model improvements can be deployed rapidly. + +To scale the tuning phase—running hundreds of parallel trials across a distributed cluster of machines—consider deploying this workflow on [Saturn Cloud](https://saturncloud.io/). + +``` + +``` \ No newline at end of file diff --git a/examples/machine-learning_ClassicAI/cpu-hpo-optuna/app.py b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/app.py new file mode 100644 index 00000000..86a8d2dc --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/app.py @@ -0,0 +1,55 @@ +from fastapi import FastAPI +from pydantic import BaseModel +import joblib +import numpy as np +import os + +app = FastAPI(title="Auto-Tuned Iris API") + +# Define Input Schema +class IrisData(BaseModel): + sepal_length: float + sepal_width: float + petal_length: float + petal_width: float + +# Global Model Variable +model = None +MODEL_PATH = "best_model.pkl" + +@app.on_event("startup") +def load_model(): + global model + if os.path.exists(MODEL_PATH): + model = joblib.load(MODEL_PATH) + print(f"✅ Loaded optimized model: {MODEL_PATH}") + else: + print(f"⚠️ Error: {MODEL_PATH} not found. Run 'python tune_hpo.py' first.") + +@app.post("/predict") +def predict(data: IrisData): + if not model: + return {"error": "Model not loaded"} + + # Prepare features + features = np.array([[ + data.sepal_length, + data.sepal_width, + data.petal_length, + data.petal_width + ]]) + + # Predict + prediction = int(model.predict(features)[0]) + + # Map to Class Name + classes = {0: "setosa", 1: "versicolor", 2: "virginica"} + + return { + "class_id": prediction, + "class_name": classes.get(prediction, "unknown") + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/examples/machine-learning_ClassicAI/cpu-hpo-optuna/setup.sh b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/setup.sh new file mode 100755 index 00000000..d5ccbad7 --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${GREEN}🚀 Starting Auto-ML Setup...${NC}" + +# 1. Robust Python Detection +if command -v python3 &> /dev/null; then + PY_CMD="python3" +elif command -v python &> /dev/null; then + PY_CMD="python" +else + echo "❌ Error: Could not find 'python3' or 'python' in your PATH." + exit 1 +fi + +# 2. Create Virtual Environment +echo -e "${BLUE}📦 Creating Virtual Environment 'venv'...${NC}" +$PY_CMD -m venv venv + +# 3. Install Dependencies +echo -e "${BLUE}⬇️ Installing libraries...${NC}" +. venv/bin/activate +pip install --upgrade pip +# Core stack: Ray Tune (HPO), Optuna (Search), FastAPI (Serving), Scikit-Learn (Model) +pip install "ray[tune]" "optuna>=3.0.0" scikit-learn pandas numpy fastapi uvicorn joblib + +echo -e "${GREEN}✅ Environment Ready!${NC}" +echo "-------------------------------------------------------" +echo "1. Tune & Save Model: python tune_hpo.py" +echo "2. Serve Model: python app.py" +echo "-------------------------------------------------------" \ No newline at end of file diff --git a/examples/machine-learning_ClassicAI/cpu-hpo-optuna/tune_hpo.py b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/tune_hpo.py new file mode 100644 index 00000000..8284d833 --- /dev/null +++ b/examples/machine-learning_ClassicAI/cpu-hpo-optuna/tune_hpo.py @@ -0,0 +1,86 @@ +import time +import joblib +import ray +from ray import tune +from ray.tune.search import ConcurrencyLimiter +from ray.tune.search.optuna import OptunaSearch +from sklearn.datasets import load_iris +from sklearn.ensemble import RandomForestClassifier +from sklearn.model_selection import cross_val_score + +# 1. Define Objective (The "Black Box" function) +def objective(config): + data = load_iris() + X, y = data.data, data.target + + # Initialize model with current trial's hyperparameters + clf = RandomForestClassifier( + n_estimators=int(config["n_estimators"]), + max_depth=int(config["max_depth"]), + min_samples_split=float(config["min_samples_split"]), + random_state=42 + ) + + # Evaluate performance using Cross-Validation + scores = cross_val_score(clf, X, y, cv=3) + accuracy = scores.mean() + + # Report metric to Ray Tune + tune.report({"accuracy": accuracy}) + +def run_hpo(): + print("🧠 Initializing Ray...") + ray.init(configure_logging=False) + + # 2. Define Search Space + search_space = { + "n_estimators": tune.randint(10, 200), + "max_depth": tune.randint(2, 20), + "min_samples_split": tune.uniform(0.1, 1.0) + } + + # 3. Setup Optuna Search Algorithm + algo = OptunaSearch() + algo = ConcurrencyLimiter(algo, max_concurrent=4) + + print("🚀 Starting Tuning Job...") + tuner = tune.Tuner( + objective, + tune_config=tune.TuneConfig( + metric="accuracy", + mode="max", + search_alg=algo, + num_samples=20, + ), + param_space=search_space, + ) + + results = tuner.fit() + + # 4. Process Best Result + best_result = results.get_best_result("accuracy", "max") + best_config = best_result.config + + print("\n" + "="*50) + print(f"🏆 Best Accuracy: {best_result.metrics['accuracy']:.4f}") + print(f"🔧 Best Config: {best_config}") + print("="*50) + + # 5. Retrain & Save Best Model + print("💾 Retraining final model with best parameters...") + data = load_iris() + X, y = data.data, data.target + + final_model = RandomForestClassifier( + n_estimators=int(best_config["n_estimators"]), + max_depth=int(best_config["max_depth"]), + min_samples_split=float(best_config["min_samples_split"]), + random_state=42 + ) + final_model.fit(X, y) + + joblib.dump(final_model, "best_model.pkl") + print("✅ Model saved to 'best_model.pkl'") + +if __name__ == "__main__": + run_hpo() \ No newline at end of file