Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ on:
workflow_dispatch:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"
enable-cache: true

- name: Sync dependencies
run: uv sync

# Check only, never fix. Contributors auto-fix locally via the pre-commit hook.
- name: Ruff lint
run: uv run ruff check .

- name: Ruff format
run: uv run ruff format --check .

test:
runs-on: ubuntu-latest
steps:
Expand All @@ -32,7 +53,7 @@ jobs:
run: uv run pytest

release:
needs: test
needs: [lint, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
Expand Down
19 changes: 19 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Install with: uv run pre-commit install
# Installs both the pre-commit (ruff) and commit-msg (commitizen) hooks.
default_install_hook_types: [pre-commit, commit-msg]
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. Keep in sync with the dev-group ruff pin in pyproject.toml.
rev: v0.15.21
hooks:
# Run the linter.
- id: ruff-check
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.10.1
hooks:
# Validate Conventional Commit messages (semantic-release parses these).
- id: commitizen
stages: [commit-msg]
7 changes: 7 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,16 @@ You can help improve the project in several ways:
This project uses [uv](https://docs.astral.sh/uv/) for dependency management, building, and publishing.

- Install dependencies (including the `dev` group): `uv sync`
- Set up the git hooks: `uv run pre-commit install` (wires up both the ruff and
commit-message hooks in one step)
- Run the tests: `uv run pytest`
- Build the package locally: `uv build`

[ruff](https://docs.astral.sh/ruff/) runs on every commit and auto-fixes lint and
formatting locally. CI runs the same checks in check-only mode, so a PR won't merge
if anything is left unformatted. The commit-message hook rejects commits that don't
follow the Conventional Commits format below.

## Releases and Commit Messages

Releases are fully automated with [python-semantic-release](https://python-semantic-release.readthedocs.io/). The next version, changelog, git tag, GitHub Release, and PyPI upload are all derived from commit messages on `main`, so **do not bump the version by hand**.
Expand Down
18 changes: 9 additions & 9 deletions example/scripts/01_load_training_data.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""
Step 1: Generate synthetic housing data and load into BigQuery offline_features table.
"""

import os
import uuid
import numpy as np
import pandas as pd
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
Expand All @@ -27,16 +27,16 @@

np.random.seed(RANDOM_SEED)

cities = list(range(5)) # 0-4 representing 5 cities
states = list(range(3)) # 0-2 representing 3 states
cities = list(range(5)) # 0-4 representing 5 cities
states = list(range(3)) # 0-2 representing 3 states

bedrooms = np.random.randint(1, 6, N_ROWS).astype(float)
bathrooms = np.random.randint(1, 4, N_ROWS).astype(float)
area_sqft = np.random.randint(800, 4000, N_ROWS).astype(float)
lot_size = np.random.randint(2000, 10000, N_ROWS).astype(float)
bedrooms = np.random.randint(1, 6, N_ROWS).astype(float)
bathrooms = np.random.randint(1, 4, N_ROWS).astype(float)
area_sqft = np.random.randint(800, 4000, N_ROWS).astype(float)
lot_size = np.random.randint(2000, 10000, N_ROWS).astype(float)
year_built = np.random.randint(1960, 2023, N_ROWS).astype(float)
city = np.random.choice(cities, N_ROWS).astype(float)
state = np.random.choice(states, N_ROWS).astype(float)
city = np.random.choice(cities, N_ROWS).astype(float)
state = np.random.choice(states, N_ROWS).astype(float)

now = datetime.now(timezone.utc)
rows = [
Expand Down
33 changes: 23 additions & 10 deletions example/scripts/02_train_model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Step 2: Pull features from BigQuery and train a RandomForest model, logging to MLflow.
"""

import os
import numpy as np
import mlflow
Expand All @@ -16,18 +17,28 @@
load_dotenv(Path.cwd() / ".env")

MLFLOW_URL = os.environ.get("MLFLOW_URL")
PROJECT = os.environ.get("BIGQUERY_PROJECT")
PROJECT = os.environ.get("BIGQUERY_PROJECT")
if not MLFLOW_URL or not PROJECT:
raise SystemExit("MLFLOW_URL or BIGQUERY_PROJECT missing. Run `deployml get-urls` to write .env.")
DATASET = os.getenv("BIGQUERY_DATASET", "mlops")
EXPERIMENT = "housing-price-prediction"
FEATURE_COLS = ["bedrooms", "bathrooms", "area_sqft", "lot_size", "year_built", "city", "state"]
raise SystemExit(
"MLFLOW_URL or BIGQUERY_PROJECT missing. Run `deployml get-urls` to write .env."
)
DATASET = os.getenv("BIGQUERY_DATASET", "mlops")
EXPERIMENT = "housing-price-prediction"
FEATURE_COLS = [
"bedrooms",
"bathrooms",
"area_sqft",
"lot_size",
"year_built",
"city",
"state",
]

# Pull features from BigQuery
print(f"Querying BigQuery {PROJECT}.{DATASET}.offline_features ...")
client = bigquery.Client(project=PROJECT)
query = f"SELECT {', '.join(FEATURE_COLS)} FROM `{PROJECT}.{DATASET}.offline_features`"
df = client.query(query).to_dataframe()
query = f"SELECT {', '.join(FEATURE_COLS)} FROM `{PROJECT}.{DATASET}.offline_features`"
df = client.query(query).to_dataframe()
print(f"✓ Loaded {len(df)} rows from BigQuery")

# Generate target (same formula as seed_model.py)
Expand All @@ -42,7 +53,9 @@

X = df[FEATURE_COLS]
y = df["price"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)

print(f"Connecting to MLflow at {MLFLOW_URL} ...")
mlflow.set_tracking_uri(MLFLOW_URL)
Expand All @@ -56,8 +69,8 @@
model.fit(X_train, y_train)

preds = model.predict(X_test)
rmse = root_mean_squared_error(y_test, preds)
r2 = r2_score(y_test, preds)
rmse = root_mean_squared_error(y_test, preds)
r2 = r2_score(y_test, preds)

print("Logging to MLflow ...")
mlflow.log_params(params)
Expand Down
17 changes: 10 additions & 7 deletions example/scripts/03_register_model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Step 3: Register the best MLflow run as HousingPriceModel and promote to Production.
"""

import os
import mlflow
from pathlib import Path
Expand All @@ -12,15 +13,17 @@
MLFLOW_URL = os.environ.get("MLFLOW_URL")
if not MLFLOW_URL:
raise SystemExit("MLFLOW_URL missing. Run `deployml get-urls` to write .env.")
MODEL_NAME = "HousingPriceModel"
EXPERIMENT = "housing-price-prediction"
MODEL_NAME = "HousingPriceModel"
EXPERIMENT = "housing-price-prediction"

mlflow.set_tracking_uri(MLFLOW_URL)
client = MlflowClient()

experiment = client.get_experiment_by_name(EXPERIMENT)
if experiment is None:
raise ValueError(f"Experiment '{EXPERIMENT}' not found. Run 02_train_model.py first.")
raise ValueError(
f"Experiment '{EXPERIMENT}' not found. Run 02_train_model.py first."
)

runs = client.search_runs(
experiment_ids=[experiment.experiment_id],
Expand All @@ -31,13 +34,13 @@
raise ValueError("No runs found. Run 02_train_model.py first.")

best_run = runs[0]
run_id = best_run.info.run_id
rmse = best_run.data.metrics.get("rmse")
run_id = best_run.info.run_id
rmse = best_run.data.metrics.get("rmse")
print(f"✓ Best run: {run_id} RMSE: {rmse:.2f}")

model_uri = f"runs:/{run_id}/model"
result = mlflow.register_model(model_uri, MODEL_NAME)
version = result.version
result = mlflow.register_model(model_uri, MODEL_NAME)
version = result.version
print(f"✓ Registered as '{MODEL_NAME}' version {version}")

client.transition_model_version_stage(
Expand Down
29 changes: 16 additions & 13 deletions example/scripts/04_make_predictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Step 4: Pull features from BigQuery and send prediction requests to FastAPI.
FastAPI automatically logs each prediction to the BigQuery predictions table.
"""

import os
import requests
from pathlib import Path
Expand All @@ -11,14 +12,16 @@
load_dotenv(Path.cwd() / ".env")

FASTAPI_URL = os.environ.get("FASTAPI_URL")
PROJECT = os.environ.get("BIGQUERY_PROJECT")
PROJECT = os.environ.get("BIGQUERY_PROJECT")
if not FASTAPI_URL or not PROJECT:
raise SystemExit("FASTAPI_URL or BIGQUERY_PROJECT missing. Run `deployml get-urls` to write .env.")
DATASET = os.getenv("BIGQUERY_DATASET", "mlops")
N_PREDICT = 50
raise SystemExit(
"FASTAPI_URL or BIGQUERY_PROJECT missing. Run `deployml get-urls` to write .env."
)
DATASET = os.getenv("BIGQUERY_DATASET", "mlops")
N_PREDICT = 50

client = bigquery.Client(project=PROJECT)
query = f"""
client = bigquery.Client(project=PROJECT)
query = f"""
SELECT entity_id, bedrooms, bathrooms, area_sqft, lot_size, year_built, city, state
FROM `{PROJECT}.{DATASET}.offline_features`
LIMIT {N_PREDICT}
Expand All @@ -31,14 +34,14 @@
payload = {
"entity_id": row["entity_id"],
"features": {
"bedrooms": row["bedrooms"],
"bathrooms": row["bathrooms"],
"area_sqft": row["area_sqft"],
"lot_size": row["lot_size"],
"bedrooms": row["bedrooms"],
"bathrooms": row["bathrooms"],
"area_sqft": row["area_sqft"],
"lot_size": row["lot_size"],
"year_built": row["year_built"],
"city": row["city"],
"state": row["state"],
}
"city": row["city"],
"state": row["state"],
},
}
resp = requests.post(f"{FASTAPI_URL}/predict", json=payload, timeout=10)
if resp.status_code == 200 and resp.json().get("prediction", -1) != -1:
Expand Down
9 changes: 5 additions & 4 deletions example/scripts/05_generate_ground_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Step 5: Generate fake ground truth values for each prediction and store in BigQuery.
In production this would be real outcomes (e.g. actual sale price) matched back to predictions.
"""

import os
import numpy as np
from pathlib import Path
Expand All @@ -15,7 +16,7 @@
if not PROJECT:
raise SystemExit("BIGQUERY_PROJECT missing. Run `deployml get-urls` to write .env.")
DATASET = os.getenv("BIGQUERY_DATASET", "mlops")
NOISE = 15000 # std dev of fake noise around predicted value
NOISE = 15000 # std dev of fake noise around predicted value

client = bigquery.Client(project=PROJECT)

Expand All @@ -28,12 +29,12 @@
print(f"✓ Found {len(predictions)} predictions to generate ground truth for")

np.random.seed(0)
now = datetime.now(timezone.utc)
now = datetime.now(timezone.utc)
rows = [
{
"entity_id": row["entity_id"],
"entity_id": row["entity_id"],
"event_timestamp": now.isoformat(),
"actual_value": float(row["predicted_value"]) + np.random.normal(0, NOISE),
"actual_value": float(row["predicted_value"]) + np.random.normal(0, NOISE),
}
for row in predictions
]
Expand Down
Loading
Loading