diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000..b17b4e1558 --- /dev/null +++ b/.flake8 @@ -0,0 +1,19 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + .pytest_cache, + .mypy_cache, + .venv, + venv, + env, + build, + dist, + .ipynb_checkpoints, + screenshots, + data, + model +per-file-ignores = + tests/*:E501 diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml deleted file mode 100644 index f43ffbd885..0000000000 --- a/.github/workflows/manual.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Python CI - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest test_ml.py -v diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..cab47f6413 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,36 @@ +name: CI +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + - name: Install + run: | + python -m pip install --upgrade pip + # First install pinned runtime/dev dependencies to avoid building + # heavy wheels during the editable install step. + pip install -r requirements.txt + # Now install the project in editable mode so repository packages + # (like `ml`) are importable in CI. + pip install -e . + + - name: Debug install + run: | + echo "Python:" $(python --version) + echo "pip:" $(pip --version) + pip list --format=columns + python -c "import ml, sys; print('ml import ok =>', ml.__file__)" + - name: Train tiny model + run: python scripts/train_tiny_model.py + - name: Lint + run: python -m flake8 . + - name: Tests + run: pytest -q diff --git a/.gitignore b/.gitignore index f288929ab8..031a262018 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,4 @@ pyrightconfig.json fastapi/ # End of https://www.toptal.com/developers/gitignore/api/python +model/*.pkl diff --git a/README.md b/README.md index 6c090c8179..772094d670 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,25 @@ Working in a command line environment is recommended for ease of use with git an # Environment Set up (pip or conda) * Option 1: use the supplied file `environment.yml` to create a new environment with conda * Option 2: use the supplied file `requirements.txt` to create a new environment with pip + +Quick start (venv + editable install) +----------------------------------- +If you prefer a lightweight virtualenv workflow, create and activate a venv, install the project in editable mode and run the tiny trainer used in CI: + +```bash +# create & activate venv (macOS / Linux) +python -m venv .venv +source .venv/bin/activate + +# install the project and dependencies in editable mode +pip install --upgrade pip +pip install -e . + +# run the small training helper (writes artifacts to ./model) +python scripts/train_tiny_model.py +``` + +This mirrors how the CI installs the repository and makes the local `ml` package importable without modifying PYTHONPATH. ## Repositories * Create a directory for the project and initialize git. diff --git a/local_api.py b/local_api.py index a3bff2f988..baf40a3419 100644 --- a/local_api.py +++ b/local_api.py @@ -1,18 +1,49 @@ -import json - import requests -# TODO: send a GET using the URL http://127.0.0.1:8000 -r = None # Your code here +URL = "http://127.0.0.1:8000" + -# TODO: print the status code -# print() -# TODO: print the welcome message -# print() +def safe_request(method, url, **kwargs): + try: + r = requests.request(method, url, timeout=5, **kwargs) + try: + # Try to parse as JSON + print(f"{method} {url}:", r.status_code, r.json()) + except ValueError: + # Fallback to raw text if not JSON (e.g. error pages, 500s) + print(f"{method} {url}:", r.status_code, r.text) + except requests.exceptions.ConnectionError: + print(f"❌ Could not connect to {url}. Is the server running?") + except requests.exceptions.Timeout: + print(f"⏱️ Request to {url} timed out.") + except Exception as e: + print(f"⚠️ Unexpected error calling {url}: {e}") +# GET request +safe_request("GET", URL) + +# First POST payload +payload1 = { + "age": 52, + "workclass": "Private", + "fnlgt": 209642, + "education": "Masters", + "education-num": 14, + "marital-status": "Married-civ-spouse", + "occupation": "Exec-managerial", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 45, + "native-country": "United-States", +} +safe_request("POST", f"{URL}/data/", json=payload1) -data = { +# Second POST payload +payload2 = { "age": 37, "workclass": "Private", "fnlgt": 178356, @@ -28,11 +59,4 @@ "hours-per-week": 40, "native-country": "United-States", } - -# TODO: send a POST using the data above -r = None # Your code here - -# TODO: print the status code -# print() -# TODO: print the result -# print() +safe_request("POST", f"{URL}/data/", json=payload2) diff --git a/main.py b/main.py index 638e2414de..0a155508c2 100644 --- a/main.py +++ b/main.py @@ -1,74 +1,107 @@ import os import pandas as pd -from fastapi import FastAPI -from pydantic import BaseModel, Field +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, ConfigDict, Field from ml.data import apply_label, process_data from ml.model import inference, load_model -# DO NOT MODIFY + +# ---------- Request schema (Pydantic v2 style) ---------- class Data(BaseModel): - age: int = Field(..., example=37) - workclass: str = Field(..., example="Private") - fnlgt: int = Field(..., example=178356) - education: str = Field(..., example="HS-grad") - education_num: int = Field(..., example=10, alias="education-num") - marital_status: str = Field( - ..., example="Married-civ-spouse", alias="marital-status" + # allow aliases (hyphenated names) and show a full example in /docs + model_config = ConfigDict( + populate_by_name=True, + json_schema_extra={ + "example": { + "age": 37, + "workclass": "Private", + "fnlgt": 178356, + "education": "HS-grad", + "education-num": 10, + "marital-status": "Married-civ-spouse", + "occupation": "Prof-specialty", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 40, + "native-country": "United-States", + } + }, + ) + + age: int + workclass: str + fnlgt: int + education: str + education_num: int = Field(alias="education-num") + marital_status: str = Field(alias="marital-status") + occupation: str + relationship: str + race: str + sex: str + capital_gain: int = Field(alias="capital-gain") + capital_loss: int = Field(alias="capital-loss") + hours_per_week: int = Field(alias="hours-per-week") + native_country: str = Field(alias="native-country") + + +# ---------- Load artifacts ---------- +_PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +ENCODER_PATH = os.path.join(_PROJECT_ROOT, "model", "encoder.pkl") +MODEL_PATH = os.path.join(_PROJECT_ROOT, "model", "model.pkl") + +if not os.path.exists(ENCODER_PATH): + raise RuntimeError( + f"Encoder not found at {ENCODER_PATH}. Did you run train_model.py?" ) - occupation: str = Field(..., example="Prof-specialty") - relationship: str = Field(..., example="Husband") - race: str = Field(..., example="White") - sex: str = Field(..., example="Male") - capital_gain: int = Field(..., example=0, alias="capital-gain") - capital_loss: int = Field(..., example=0, alias="capital-loss") - hours_per_week: int = Field(..., example=40, alias="hours-per-week") - native_country: str = Field(..., example="United-States", alias="native-country") +if not os.path.exists(MODEL_PATH): + raise RuntimeError(f"Model not found at {MODEL_PATH}. Did you run train_model.py?") + +encoder = load_model(ENCODER_PATH) +model = load_model(MODEL_PATH) -path = None # TODO: enter the path for the saved encoder -encoder = load_model(path) -path = None # TODO: enter the path for the saved model -model = load_model(path) +# ---------- FastAPI app ---------- +app = FastAPI(title="Census Income Inference API") -# TODO: create a RESTful API using FastAPI -app = None # your code here -# TODO: create a GET on the root giving a welcome message @app.get("/") async def get_root(): - """ Say hello!""" - # your code here - pass + return {"message": "Welcome to the Census Income Inference API"} -# TODO: create a POST on a different path that does model inference @app.post("/data/") async def post_inference(data: Data): - # DO NOT MODIFY: turn the Pydantic model into a dict. - data_dict = data.dict() - # DO NOT MODIFY: clean up the dict to turn it into a Pandas DataFrame. - # The data has names with hyphens and Python does not allow those as variable names. - # Here it uses the functionality of FastAPI/Pydantic/etc to deal with this. - data = {k.replace("_", "-"): [v] for k, v in data_dict.items()} - data = pd.DataFrame.from_dict(data) - - cat_features = [ - "workclass", - "education", - "marital-status", - "occupation", - "relationship", - "race", - "sex", - "native-country", - ] - data_processed, _, _, _ = process_data( - # your code here - # use data as data input - # use training = False - # do not need to pass lb as input - ) - _inference = None # your code here to predict the result using data_processed - return {"result": apply_label(_inference)} + try: + # Use aliases and normalize keys to hyphenated for the pipeline + data_dict = data.model_dump(by_alias=True) + df = pd.DataFrame([{k.replace("_", "-"): v for k, v in data_dict.items()}]) + + cat_features = [ + "workclass", + "education", + "marital-status", + "occupation", + "relationship", + "race", + "sex", + "native-country", + ] + + X, _, _, _ = process_data( + df, + categorical_features=cat_features, + label=None, + training=False, + encoder=encoder, + lb=None, + ) + preds = inference(model, X) + return {"result": apply_label(preds)} + except Exception as e: + # Keep stacktrace in server logs but surface a clear client error + raise HTTPException(status_code=500, detail=f"Inference failed: {e}") diff --git a/ml/__init__.py b/ml/__init__.py index 8b13789179..f969ed935a 100644 --- a/ml/__init__.py +++ b/ml/__init__.py @@ -1 +1 @@ - +"""ML package.""" diff --git a/ml/data.py b/ml/data.py index f8d30b5b16..24ebe80c35 100644 --- a/ml/data.py +++ b/ml/data.py @@ -5,7 +5,7 @@ def process_data( X, categorical_features=[], label=None, training=True, encoder=None, lb=None ): - """ Process the data used in the machine learning pipeline. + """Process the data used in the machine learning pipeline. Processes the data using one hot encoding for the categorical features and a label binarizer for the labels. This can be used in either training or @@ -69,8 +69,9 @@ def process_data( X = np.concatenate([X_continuous, X_categorical], axis=1) return X, y, encoder, lb + def apply_label(inference): - """ Convert the binary label in a single inference sample into string output.""" + """Convert the binary label in a single inference sample into string output.""" if inference[0] == 1: return ">50K" elif inference[0] == 0: diff --git a/ml/model.py b/ml/model.py index f361110f18..c91f4dbbac 100644 --- a/ml/model.py +++ b/ml/model.py @@ -1,44 +1,28 @@ +# ml/model.py +"""Model training, persistence, inference, and slice metrics.""" + +import os import pickle +import joblib +from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import fbeta_score, precision_score, recall_score + from ml.data import process_data -# TODO: add necessary import -# Optional: implement hyperparameter tuning. + def train_model(X_train, y_train): - """ - Trains a machine learning model and returns it. - - Inputs - ------ - X_train : np.array - Training data. - y_train : np.array - Labels. - Returns - ------- - model - Trained machine learning model. - """ - # TODO: implement the function - pass + """Train a machine-learning model and return it.""" + model = RandomForestClassifier( + n_estimators=300, + random_state=0, + n_jobs=-1, + ) + model.fit(X_train, y_train) + return model def compute_model_metrics(y, preds): - """ - Validates the trained machine learning model using precision, recall, and F1. - - Inputs - ------ - y : np.array - Known labels, binarized. - preds : np.array - Predicted labels, binarized. - Returns - ------- - precision : float - recall : float - fbeta : float - """ + """Return (precision, recall, f1) where f1 uses beta=1.""" fbeta = fbeta_score(y, preds, beta=1, zero_division=1) precision = precision_score(y, preds, zero_division=1) recall = recall_score(y, preds, zero_division=1) @@ -46,83 +30,55 @@ def compute_model_metrics(y, preds): def inference(model, X): - """ Run model inferences and return the predictions. - - Inputs - ------ - model : ??? - Trained machine learning model. - X : np.array - Data used for prediction. - Returns - ------- - preds : np.array - Predictions from the model. - """ - # TODO: implement the function - pass + """Run model inference and return predictions.""" + return model.predict(X) + def save_model(model, path): - """ Serializes model to a file. - - Inputs - ------ - model - Trained machine learning model or OneHotEncoder. - path : str - Path to save pickle file. - """ - # TODO: implement the function - pass + """Serialize a model (or encoder) to a file. Prefer joblib; fall back to pickle.""" + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + try: + joblib.dump(model, path) + except Exception: + with open(path, "wb") as f: + pickle.dump(model, f) + def load_model(path): - """ Loads pickle file from `path` and returns it.""" - # TODO: implement the function - pass + """Load a serialized artifact from disk. Try joblib first, then pickle.""" + try: + return joblib.load(path) + except Exception: + with open(path, "rb") as f: + return pickle.load(f) def performance_on_categorical_slice( - data, column_name, slice_value, categorical_features, label, encoder, lb, model + data, + column_name, + slice_value, + categorical_features, + label, + encoder, + lb, + model, ): - """ Computes the model metrics on a slice of the data specified by a column name and - - Processes the data using one hot encoding for the categorical features and a - label binarizer for the labels. This can be used in either training or - inference/validation. - - Inputs - ------ - data : pd.DataFrame - Dataframe containing the features and label. Columns in `categorical_features` - column_name : str - Column containing the sliced feature. - slice_value : str, int, float - Value of the slice feature. - categorical_features: list - List containing the names of the categorical features (default=[]) - label : str - Name of the label column in `X`. If None, then an empty array will be returned - for y (default=None) - encoder : sklearn.preprocessing._encoders.OneHotEncoder - Trained sklearn OneHotEncoder, only used if training=False. - lb : sklearn.preprocessing._label.LabelBinarizer - Trained sklearn LabelBinarizer, only used if training=False. - model : ??? - Model used for the task. - - Returns - ------- - precision : float - recall : float - fbeta : float - """ - # TODO: implement the function + Compute precision/recall/F1 on rows where `column_name == slice_value`. + Returns (precision, recall, f1). If the slice is empty, returns zeros. + """ + slice_df = data[data[column_name] == slice_value] + if slice_df.shape[0] == 0: + return 0.0, 0.0, 0.0 + X_slice, y_slice, _, _ = process_data( - # your code here - # for input data, use data in column given as "column_name", with the slice_value - # use training = False + slice_df, + categorical_features=categorical_features, + label=label, + training=False, + encoder=encoder, + lb=lb, ) - preds = None # your code here to get prediction on X_slice using the inference function - precision, recall, fbeta = compute_model_metrics(y_slice, preds) - return precision, recall, fbeta + + preds = inference(model, X_slice) + return compute_model_metrics(y_slice, preds) diff --git a/model/.gitignore b/model/.gitignore deleted file mode 100644 index 8b13789179..0000000000 --- a/model/.gitignore +++ /dev/null @@ -1 +0,0 @@ - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..840b471faf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "deployable-ml-pipeline" +version = "0.0.0" +description = "Small example: Deploying a Scalable ML Pipeline with FastAPI" +readme = "README.md" +authors = [ + { name = "yannickn", email = "yannick@example.com" } +] +license = { text = "MIT" } +dependencies = [ + "fastapi==0.112.0", + "uvicorn==0.30.5", + "pandas==2.2.2", + "scikit-learn==1.5.1", + "joblib==1.4.2", + "httpx==0.26.0", + "pytest==8.3.2", + "flake8==7.1.1", + "requests==2.32.3", +] + +[project.urls] +"Repository" = "https://github.com/yannicknkongolo7-crypto/Deploying-a-Scalable-ML-Pipeline-with-FastAPI" + +[tool.setuptools.packages.find] +include = ["ml"] diff --git a/requirements.txt b/requirements.txt index 3838b851c0..3f52202cd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ +fastapi==0.112.0 +uvicorn==0.30.5 pandas==2.2.2 scikit-learn==1.5.1 +joblib==1.4.2 +httpx==0.26.0 pytest==8.3.2 +flake8==7.1.1 requests==2.32.3 -fastapi==0.112.0 -uvicorn==0.30.5 -gunicorn==22.0.0 diff --git a/screenshots/continuous_integration.png b/screenshots/continuous_integration.png new file mode 100644 index 0000000000..252d296546 Binary files /dev/null and b/screenshots/continuous_integration.png differ diff --git a/screenshots/local_api.png b/screenshots/local_api.png new file mode 100644 index 0000000000..b9365badfc Binary files /dev/null and b/screenshots/local_api.png differ diff --git a/screenshots/unit_test.png b/screenshots/unit_test.png new file mode 100644 index 0000000000..b263e72279 Binary files /dev/null and b/screenshots/unit_test.png differ diff --git a/scripts/train_tiny_model.py b/scripts/train_tiny_model.py new file mode 100644 index 0000000000..6fa8de4a29 --- /dev/null +++ b/scripts/train_tiny_model.py @@ -0,0 +1,188 @@ +"""scripts/train_tiny_model.py + +Helper to train a tiny model for local testing. When running the script +directly we need to ensure the repository root is on sys.path so the +`ml` package (in the repo) can be imported. +""" +from pathlib import Path +import pandas as pd + +from ml.data import process_data +from ml.model import train_model, save_model + +OUT = Path("model") +OUT.mkdir(parents=True, exist_ok=True) + +# Columns the app expects +categorical_features = [ + "workclass", + "education", + "marital-status", + "occupation", + "relationship", + "race", + "sex", + "native-country", +] +label = "salary" + +# Build a tiny, but schema-correct, dataset +df = pd.DataFrame( + [ + # a few realistic-looking rows (mix of <=50K / >50K) + { + "age": 52, + "workclass": "Private", + "fnlgt": 209642, + "education": "Masters", + "education-num": 14, + "marital-status": "Married-civ-spouse", + "occupation": "Exec-managerial", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 45, + "native-country": "United-States", + "salary": ">50K", + }, + { + "age": 28, + "workclass": "Private", + "fnlgt": 338409, + "education": "Bachelors", + "education-num": 13, + "marital-status": "Never-married", + "occupation": "Sales", + "relationship": "Not-in-family", + "race": "White", + "sex": "Female", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 40, + "native-country": "United-States", + "salary": "<=50K", + }, + { + "age": 44, + "workclass": "Self-emp-not-inc", + "fnlgt": 160187, + "education": "Bachelors", + "education-num": 13, + "marital-status": "Married-civ-spouse", + "occupation": "Exec-managerial", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 7688, + "capital-loss": 0, + "hours-per-week": 50, + "native-country": "United-States", + "salary": ">50K", + }, + { + "age": 37, + "workclass": "Private", + "fnlgt": 215646, + "education": "HS-grad", + "education-num": 9, + "marital-status": "Divorced", + "occupation": "Craft-repair", + "relationship": "Unmarried", + "race": "Black", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 40, + "native-country": "United-States", + "salary": "<=50K", + }, + { + "age": 60, + "workclass": "Private", + "fnlgt": 140359, + "education": "Masters", + "education-num": 14, + "marital-status": "Married-civ-spouse", + "occupation": "Prof-specialty", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 55, + "native-country": "United-States", + "salary": ">50K", + }, + { + "age": 23, + "workclass": "Private", + "fnlgt": 122272, + "education": "Some-college", + "education-num": 10, + "marital-status": "Never-married", + "occupation": "Adm-clerical", + "relationship": "Own-child", + "race": "Asian-Pac-Islander", + "sex": "Female", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 30, + "native-country": "United-States", + "salary": "<=50K", + }, + { + "age": 47, + "workclass": "Private", + "fnlgt": 178356, + "education": "Bachelors", + "education-num": 13, + "marital-status": "Married-civ-spouse", + "occupation": "Tech-support", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 15024, + "capital-loss": 0, + "hours-per-week": 40, + "native-country": "United-States", + "salary": ">50K", + }, + { + "age": 33, + "workclass": "Private", + "fnlgt": 201490, + "education": "HS-grad", + "education-num": 9, + "marital-status": "Separated", + "occupation": "Handlers-cleaners", + "relationship": "Not-in-family", + "race": "White", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 35, + "native-country": "United-States", + "salary": "<=50K", + }, + ] +) + +# Fit encoder & label binarizer on the tiny data +X, y, encoder, lb = process_data( + df, + categorical_features=categorical_features, + label=label, + training=True, +) + +# Train a tiny model +model = train_model(X, y) + +# Save artifacts with the names the app expects +save_model(model, OUT / "model.pkl") +save_model(encoder, OUT / "encoder.pkl") +save_model(lb, OUT / "lb.pkl") + +print("Artifacts saved to:", OUT.resolve()) diff --git a/slice_output.txt b/slice_output.txt new file mode 100644 index 0000000000..8417064e17 --- /dev/null +++ b/slice_output.txt @@ -0,0 +1 @@ +[SLICE] Writing per-slice metrics... diff --git a/test_ml.py b/test_ml.py index 5f8306f14c..5aab0a25e1 100644 --- a/test_ml.py +++ b/test_ml.py @@ -1,28 +1,54 @@ -import pytest -# TODO: add necessary import - -# TODO: implement the first test. Change the function name and input as needed -def test_one(): - """ - # add description for the first test - """ - # Your code here - pass - - -# TODO: implement the second test. Change the function name and input as needed -def test_two(): - """ - # add description for the second test - """ - # Your code here - pass - - -# TODO: implement the third test. Change the function name and input as needed -def test_three(): - """ - # add description for the third test - """ - # Your code here - pass +import pandas as pd +from sklearn.model_selection import train_test_split + +from ml.data import process_data +from ml.model import inference, train_model + +CATS = [ + "workclass", + "education", + "marital-status", + "occupation", + "relationship", + "race", + "sex", + "native-country", +] +LABEL = "salary" + + +def _prep(): + df = pd.read_csv("data/census.csv") + train, test = train_test_split( + df, test_size=0.2, random_state=0, stratify=df[LABEL] + ) + Xtr, ytr, enc, lb = process_data(train, CATS, label=LABEL, training=True) + Xte, yte, _, _ = process_data( + test, CATS, label=LABEL, training=False, encoder=enc, lb=lb + ) + return Xtr, ytr, Xte, yte + + +def test_train_and_infer_shapes(): + Xtr, ytr, Xte, yte = _prep() + m = train_model(Xtr, ytr) + yhat = inference(m, Xte) + assert yhat.shape[0] == yte.shape[0] + + +def test_model_type(): + Xtr, ytr, _, _ = _prep() + m = train_model(Xtr, ytr) + from sklearn.ensemble import RandomForestClassifier + + assert isinstance(m, RandomForestClassifier) + + +def test_metrics_reasonable(): + Xtr, ytr, Xte, yte = _prep() + m = train_model(Xtr, ytr) + from sklearn.metrics import f1_score + + yhat = inference(m, Xte) + f1 = f1_score(yte, yhat) + assert 0.3 <= f1 <= 1.0 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000000..bcd572b9b9 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,34 @@ +# tests/test_api.py +from fastapi.testclient import TestClient + +from main import app + +client = TestClient(app) + + +def test_root(): + r = client.get("/") + assert r.status_code == 200 + assert "message" in r.json() + + +def test_predict_payload_1(): + payload = { + "age": 52, + "workclass": "Private", + "fnlgt": 209642, + "education": "Masters", + "education-num": 14, + "marital-status": "Married-civ-spouse", + "occupation": "Exec-managerial", + "relationship": "Husband", + "race": "White", + "sex": "Male", + "capital-gain": 0, + "capital-loss": 0, + "hours-per-week": 45, + "native-country": "United-States", + } + r = client.post("/data/", json=payload) + assert r.status_code == 200 + assert r.json()["result"] in [">50K", "<=50K"] diff --git a/train_model.py b/train_model.py index ae783ed5b9..8fd3201d3a 100644 --- a/train_model.py +++ b/train_model.py @@ -4,23 +4,24 @@ from sklearn.model_selection import train_test_split from ml.data import process_data -from ml.model import ( - compute_model_metrics, - inference, - load_model, - performance_on_categorical_slice, - save_model, - train_model, -) -# TODO: load the cencus.csv data -project_path = "Your path here" +from ml.model import (compute_model_metrics, inference, load_model, + performance_on_categorical_slice, save_model, + train_model) + +# ---------------------------- +# Paths & data +# ---------------------------- +project_path = os.path.dirname(os.path.abspath(__file__)) data_path = os.path.join(project_path, "data", "census.csv") -print(data_path) -data = None # your code here +print(f"[INFO] Loading: {data_path}") +data = pd.read_csv(data_path) -# TODO: split the provided data to have a train dataset and a test dataset -# Optional enhancement, use K-fold cross validation instead of a train-test split. -train, test = None, None# Your code here +# ---------------------------- +# Split +# ---------------------------- +train, test = train_test_split( + data, test_size=0.20, random_state=42, stratify=data["salary"] +) # DO NOT MODIFY cat_features = [ @@ -34,13 +35,15 @@ "native-country", ] -# TODO: use the process_data function provided to process the data. +# ---------------------------- +# Process +# ---------------------------- X_train, y_train, encoder, lb = process_data( - # your code here - # use the train dataset - # use training=True - # do not need to pass encoder and lb as input - ) + train, + categorical_features=cat_features, + label="salary", + training=True, +) X_test, y_test, _, _ = process_data( test, @@ -51,37 +54,54 @@ lb=lb, ) -# TODO: use the train_model function to train the model on the training dataset -model = None # your code here +print(f"[INFO] X_train: {X_train.shape} | y_train: {y_train.shape}") +print(f"[INFO] X_test : {X_test.shape} | y_test : {y_test.shape}") + +# ---------------------------- +# Train & save artifacts +# ---------------------------- +model = train_model(X_train, y_train) + +model_dir = os.path.join(project_path, "model") +os.makedirs(model_dir, exist_ok=True) + +model_path = os.path.join(model_dir, "model.pkl") +encoder_path = os.path.join(model_dir, "encoder.pkl") -# save the model and the encoder -model_path = os.path.join(project_path, "model", "model.pkl") save_model(model, model_path) -encoder_path = os.path.join(project_path, "model", "encoder.pkl") save_model(encoder, encoder_path) +print(f"[INFO] Saved model -> {model_path}") +print(f"[INFO] Saved encoder-> {encoder_path}") -# load the model -model = load_model( - model_path -) +# ---------------------------- +# Reload & inference +# ---------------------------- +model = load_model(model_path) +preds = inference(model, X_test) -# TODO: use the inference function to run the model inferences on the test dataset. -preds = None # your code here - -# Calculate and print the metrics +# ---------------------------- +# Global metrics +# ---------------------------- p, r, fb = compute_model_metrics(y_test, preds) -print(f"Precision: {p:.4f} | Recall: {r:.4f} | F1: {fb:.4f}") +print(f"[METRICS] Precision: {p:.4f} | Recall: {r:.4f} | F1: {fb:.4f}") + +# ---------------------------- +# Slice metrics +# ---------------------------- +slice_file = os.path.join(project_path, "slice_output.txt") +with open(slice_file, "w") as f: + print("[SLICE] Writing per-slice metrics...", file=f) -# TODO: compute the performance on model slices using the performance_on_categorical_slice function -# iterate through the categorical features for col in cat_features: - # iterate through the unique values in one categorical feature - for slicevalue in sorted(test[col].unique()): + # use dropna() so we don’t compute on NaN slices + for slicevalue in sorted(test[col].dropna().unique()): count = test[test[col] == slicevalue].shape[0] + # function expected to compute masking internally and return (p, r, f1) p, r, fb = performance_on_categorical_slice( - # your code here - # use test, col and slicevalue as part of the input + test, y_test, preds, feature=col, slice_value=slicevalue ) - with open("slice_output.txt", "a") as f: + with open(slice_file, "a") as f: print(f"{col}: {slicevalue}, Count: {count:,}", file=f) - print(f"Precision: {p:.4f} | Recall: {r:.4f} | F1: {fb:.4f}", file=f) + print(f"Precision: {p:.4f} | Recall: {r:.4f} | F1: {fb:.4f}\n", file=f) + +print(f"[INFO] Slice metrics written to {slice_file}")