diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..fe5f950 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/devcontainers/python:3.10 + +COPY zscaler-bundle.pem /tmp/zscaler.pem +RUN if [ -s /tmp/zscaler.pem ]; then \ + cp /tmp/zscaler.pem /usr/local/share/ca-certificates/zscaler.crt && \ + update-ca-certificates; \ + fi && rm -f /tmp/zscaler.pem + +RUN find /etc/apt /usr/share/keyrings -name "*yarn*" -delete 2>/dev/null || true diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..fbc79c1 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:3": { + "version": "3.0.1", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:ca2508495b01ba29eba93e8153772a2daa65eaa86471cc6863fe2a3d21933df9", + "integrity": "sha256:ca2508495b01ba29eba93e8153772a2daa65eaa86471cc6863fe2a3d21933df9" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 14a6eb7..4de17f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ { "name": "MLE Interview", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/python:1-3.10", + "build": { "dockerfile": "Dockerfile" }, + "initializeCommand": "cp ~/.ssl/zscaler-bundle.pem .devcontainer/zscaler-bundle.pem 2>/dev/null || touch .devcontainer/zscaler-bundle.pem", "customizations": { "vscode": { "settings": { @@ -14,7 +14,7 @@ "source.organizeImports.ruff": true, "source.organizeImports": true } - }, + } }, "extensions": [ "charliermarsh.ruff", @@ -29,7 +29,10 @@ } }, "features": { - "ghcr.io/devcontainers/features/docker-in-docker": {} + "ghcr.io/devcontainers/features/docker-in-docker:3": { + "moby": false, + "disableIp6tables": true + } }, "postCreateCommand": "make setup" -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index 3f83ee9..8d5a0af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Zscaler cert (machine-specific, copied by initializeCommand) +.devcontainer/zscaler-bundle.pem + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/INTERVIEW.md b/INTERVIEW.md index 534694b..93360e8 100644 --- a/INTERVIEW.md +++ b/INTERVIEW.md @@ -1,28 +1,121 @@ - ## Coding Challenge -You can refer to Google or relevant documentation to guide you in addressing the issues below. -You are not allowed to use ChatGPT, Copilot, or any similar tools for assistance. +You are working on a user interest recommendation system. It consists of: + +- A **TensorFlow model** trained on user content interactions, served via TF Serving (Docker) +- A **Flask API** that fetches user features, calls the model, and returns ranked interests +- A **feature store** backed by CSV files + +There are **7 tasks** to complete. Tasks 1–6 each have a failing test or a clear broken behaviour that tells you what to fix. Task 7 is an open-ended feature implementation. You can refer to Google or relevant documentation — no AI-assisted coding tools. + +--- + +### Terminal setup (do this before task 4) + +Once the model is trained and served, you'll need **two terminals** running simultaneously inside the container: + +| Terminal | Command | Purpose | +|---|---|---| +| 1 | `make serve-model` | TF Serving on port 8501 | +| 2 | `make serve-api` | Flask API on port 5005 | + +Keep both running while working on tasks 4–7. + +--- + +### Tasks + +**1. Fix model training** + +Run `make test`. The `TestModel::test_model_build` test fails. + +Find and fix the bug in the model code, then verify: +``` +make test # TestModel::test_model_build must pass +``` + +Once passing, train the model and start the model server (leave it running): +``` +make train +make serve-model +``` + +--- + +**2. Fix the API startup** + +Run `make serve-api`. The Flask application fails to start. + +Find and fix the bug, then verify the server starts without errors: +``` +make serve-api +``` + +Leave the server running in its terminal. + +--- + +**3. Fix the probability scores** + +The model's serving function returns raw logit scores, not probabilities. These values are not bounded between 0 and 1 and cannot be meaningfully compared or filtered. + +Find where the issue originates and fix it so that the scores returned represent proper probability values. There are two valid approaches — both are acceptable. + +Verify: +``` +make test # TestModel::test_probability_scores must pass +``` + +--- + +**4. Fix the interests count** + +With both servers running, run `make test`. This test fails: +``` +TestInterestsAPI::test_basic_response — AssertionError: Incorrect number of interests +``` + +Fix the API so it returns the correct number of interests. Verify: +``` +make test # test_basic_response must pass (interests count assertion) +``` + +--- + +**5. Fix response time** + +With both servers running, run `make test`. This test fails: +``` +TestInterestsAPI::test_basic_response — AssertionError: Request greater than 1 second +``` + +Find the performance bottleneck and fix it. Verify: +``` +make test # test_basic_response must pass (timing assertion) +``` + +--- + +**6. Add probability to the response** + +With both servers running, run `make test`. This test fails: +``` +TestInterestsAPI::test_with_probability_threshold +``` -1. **Model Training Error Resolution:** - - Begin by executing `make test` to identify any issues during model training. - - After passing the `TestModel::test_model_build` test, use `make train` followed by `make serve-model` to train and serve the model, preparing it for upcoming steps. +Update the API response so each interest includes a `probability` field. Verify: +``` +make test # test_with_probability_threshold must pass +``` -2. **API Startup Issue Fix:** - - Resolve the Flask API startup issue with `make serve-api`. - - Run `make serve-api` to start a development Flask server, then open a new terminal window without shutting down the server. +--- -3. **Data Return and Interests Length Correction:** - - Execute `make test` and resolve the `TestInterestsAPI::test_basic_response - AssertionError: Incorrect number of interests` test. - - Ensure the return data structure and interests array length meet the specifications, adjusting the API as necessary. +**7. Add probability threshold filtering** -4. **Response Time Optimization:** - - Run `make test` and fix the `TestInterestsAPI::test_basic_response - AssertionError: Request greater than 1 second` test. - - Enhance the API to achieve a response time of one second or less. +Implement a feature that lets API clients filter results by a minimum probability score. For example: -5. **Probability Field Inclusion:** - - Perform `make test` and correct the `TestInterestsAPI::test_with_probability` test. - - Update the API response to incorporate a 'probability' field. +``` +GET /interests/?min_probability=0.5 +``` -6. **Probability Score Filtering Implementation:** - - Create a feature allowing API clients to filter results by probability score, enabling users to specify a probability threshold for more targeted results. \ No newline at end of file +Should return only interests with probability ≥ 0.5. There is no automated test for this task — demonstrate it works with a curl request. diff --git a/Makefile b/Makefile index 80daa2d..9468596 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ serve-api: serve-model: docker compose up tf-serving +ping-model: + curl -s http://localhost:8501/v1/models/interests-model | python3 -m json.tool + train: TF_CPP_MIN_LOG_LEVEL=3 python -m model.main diff --git a/README.md b/README.md index 5306772..c709418 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,92 @@ -# MLE Interview: - +# MLE Interview ## Setup -1. Install [Visual Studio Code](https://code.visualstudio.com/). Also the following plugins: - - [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) -2. Clone this repo -3. Open the repository in VSCode (`code .`) -4. VSCode will prompt you to install recommended extensions. Select `Yes` - - If you missed this step, you can install recommended extensions using the extensions sidebar (`⇧⌘X`) -5. VSCode will prompt you to reopen the workspace in a Dev Container. Select `Yes`. - - If you missed this step, open the command prompt palette (`⇧⌘P`) and run the command `Remote-Containers: Rebuild and Reopen in Container` + +### Option A — GitHub Codespaces (recommended) + +Click **Code → Open with Codespaces** on the repository page. The environment builds automatically — no local installation needed. + +### Option B — Local Dev Container (VS Code) + +1. Install [Visual Studio Code](https://code.visualstudio.com/) and the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension +2. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) +3. Clone this repo and open it in VS Code (`code .`) +4. VS Code will prompt you to reopen in a Dev Container — select **Yes** + - If you miss the prompt: `⇧⌘P` → **Dev Containers: Rebuild and Reopen in Container** + +> **Note (corporate networks / Zscaler):** If your machine uses Zscaler SSL inspection, ensure your Zscaler certificate bundle exists at `~/.ssl/zscaler-bundle.pem` before building the container. The build handles this automatically. + +Once the container starts, dependencies are installed automatically via `make setup`. You do not need to run it manually. ## Make Commands -Before you start, ensure you have all the necessary dependencies installed: +### Test ``` -make setup +make test ``` -This command will install the Python dependencies listed in `requirements.txt`. +Runs all tests in `./tests` with pytest. Use this to verify each task as you work through the interview. -### Serve API - -To start the Flask API, use the following command: +### Train ``` -make serve-api +make train ``` -This command launches the Flask application defined in `api/app` on port 5005, accessible via `0.0.0.0`. The `--debug` flag is included to enable debug mode, which provides useful debugging information in case of errors and automatically reloads the server on code changes. +Trains the model using `model/main.py` and exports a SavedModel to `serving/interests-model/1/`. ### Serve Model -If you're using a TensorFlow model served via Docker, you can start the TensorFlow serving using: - ``` make serve-model ``` -This command uses Docker Compose to spin up a container defined in the Docker configuration, specifically targeting the TensorFlow serving service (`tf-serving`). Ensure Docker is installed and running on your system before executing this command. +Starts TF Serving via Docker Compose on port 8501. Run this after `make train` — the model must be trained first. -### Train - -To train the model, execute: +### Serve API ``` -make train +make serve-api ``` -This command runs the main training script located at `model.main`. +Starts the Flask development server on port 5005. Requires the model server to be running for inference endpoints to work. -### Test -Finally, to run the tests for your project, use: +## API ``` -make test +GET http://localhost:5005/interests/ ``` -This command runs all the tests in the `./tests` directory using pytest. +Optional query parameters: +- `top_k` (int) — number of interests to return (default: 10) +- `min_probability` (float) — filter results below this probability score +Example response: -## API - -Route: GET: http://localhost:5005/interests/:userHandle - -API Response: - -``` +```json { "user_handle": "e337a675-46f5-437e-aa72-5d43643b5461", + "name": "Kisiza", + "type": "B2B", "interests": [ { "id": "29e020bb-7a02-41db-b21f-527a8ef4dfdf", - "label": "Accounting technician" + "label": "Accounting technician", + "probability": 0.94 }, { "id": "012abcd1-3a6a-4803-a47e-42f46b402024", - "label": "Field seismologist" + "label": "Field seismologist", + "probability": 0.87 }, { "id": "a1b028dd-8464-4c63-85e8-ae29ea184fc7", - "label": "Designer, industrial/product" - }, - { - "id": "686af7e4-6d58-4148-b227-3bf65ff10273", - "label": "Materials engineer" - }, - { - "id": "8d1526b9-a7bf-4972-be43-7b912f149667", - "label": "Fashion designer" - }, - { - "id": "d83a55a3-0143-4318-91c1-88f44ad59390", - "label": "Journalist, newspaper" - }, - { - "id": "cda4441f-dba6-495c-9e2e-7429bd5e0465", - "label": "Therapist, music" - }, - { - "id": "e9b23ad4-753b-4331-9cfa-525965fcf281", - "label": "Secretary, company" - }, - { - "id": "ca4b03b2-0ae2-440a-afc8-5469510b19cb", - "label": "Commissioning editor" - }, - { - "id": "fd3c41b8-8c15-47e2-a80d-cf3683b2d0da", - "label": "Copywriter, advertising" + "label": "Designer, industrial/product", + "probability": 0.81 } - ], - "name": "Kisiza", - "type": "B2B" + ] } -``` \ No newline at end of file +``` diff --git a/tests/test_model.py b/tests/test_model.py index 5469061..541552c 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -32,6 +32,21 @@ def setup_method(self): hyper_params=hyper_params, ) + def test_probability_scores(self): + input_record = { + "USER_HANDLE": tf.constant([self.users[0]]), + "USER_TYPE": tf.constant(["B2B"]), + "CONTENT_TITLES_JOINED": tf.constant([self.content_titles[0]]), + } + top_k = tf.constant(1, dtype=tf.int64) + + result = self.model.serving_predict(input_record, top_k) + probabilities = result["probabilities"].numpy().flatten() + + assert all(0 <= p <= 1 for p in probabilities), ( + "Probabilities must be between 0 and 1" + ) + def test_model_build(self): interests_input = [self.interests_vocab[0], self.interests_vocab[3]]