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
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Accuracy is misleading on imbalanced data, so the tracked metrics are:
| Serving | Deploy champion via the deployment job; test the endpoint with `query_endpoint` |
| Monitoring | Batch-score to an inference table; watch drift + precision/recall; trigger retrain |

> **The Serving step is slow.** The deployment job (`fraud_deployment_job`) provisions a
> **The Serving step is slow.** The deployment job (`deployment_job`) provisions a
> **Lakebase online feature store**, publishes the `card_features`/`client_features` tables to
> it, and builds a first-time serving endpoint, which typically takes **15-25 minutes**
> end-to-end. It runs automatically when a new model version is registered; allow it to
Expand Down Expand Up @@ -436,34 +436,36 @@ The bundle declares the schema with a **base name** in [resources/schemas-resour
```yaml
resources:
schemas:
ml_workspace:
fraud_ml:
catalog_name: ${var.catalog_name}
name: fraud
name: fraud_ml # personal target overrides this to `fraud`
```

In **development mode** the bundle automatically prepends `[dev <your-short-name>]` to every
resource name (sanitised for schemas), so `fraud` becomes **`dev_<you>_fraud`**, a private
schema per user. In **production mode** there is no prefix, so it stays the shared `fraud`.
This prefixing is a built-in feature of development mode.
On the clean **dev/staging/prod** targets the schema is the shared **`fraud_ml`**, sitting
alongside `fraud_landing` / `fraud_bronze` / `fraud_silver` / `fraud_gold`. The **personal**
target overrides the schema name to `fraud`, and development mode prepends
`[dev <your-short-name>]` (sanitised for schemas), so each attendee gets a private
**`dev_<you>_fraud`** sandbox they can use for anything. This prefixing is a built-in feature
of development mode.

Jobs then pass the schema's **resolved** name to the notebooks so they always write to exactly
what the bundle created:

```yaml
base_parameters:
gold_schema: ${var.gold_schema} # shared read -> fraud_gold
ml_schema: ${resources.schemas.ml_workspace.name} # personal write -> dev_<you>_fraud
ml_schema: ${resources.schemas.fraud_ml.name} # personal write -> dev_<you>_fraud
```

and the model name follows the same schema:
`${var.catalog_name}.${resources.schemas.ml_workspace.name}.${var.model_name}`
`${var.catalog_name}.${resources.schemas.fraud_ml.name}.${var.model_name}`
(where `model_name` is the bare name, default `fraud_detection`). The notebooks prepend
the catalog and `ml_schema` to it to build the three-level `uc_model_name`.

> **Why reference the resource, not a plain variable?** Development mode prefixes the schema
> *resource* but not a hand-written variable, so a variable like `${short_name}_fraud` would
> not match the schema the bundle actually creates. Referencing
> `${resources.schemas.ml_workspace.name}` guarantees notebooks and the provisioned schema
> `${resources.schemas.fraud_ml.name}` guarantees notebooks and the provisioned schema
> use the identical name.

### Attendee flow
Expand Down
32 changes: 20 additions & 12 deletions databricks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
# Docs: Declarative Automation Bundles https://learn.microsoft.com/azure/databricks/dev-tools/bundles/
# Settings & targets https://learn.microsoft.com/azure/databricks/dev-tools/bundles/settings
bundle:
name: mlops_workshop
name: databricks_mlops_workshop
engine: direct

variables:
experiment_name:
description: MLflow experiment for model training runs (per-user path).
default: /Users/${workspace.current_user.userName}/${bundle.target}-mlops-workshop-fraud
default: /Users/${workspace.current_user.userName}/${bundle.target}-databricks-mlops-workshop
model_name:
description: >-
Bare (unqualified) registered model name. Notebooks prepend catalog_name + ml_schema
Expand All @@ -20,10 +20,10 @@ variables:
default: dev
ml_schema:
description: >-
ML outputs schema (feature tables, model, predictions, monitoring). Resolves to the
ml_workspace schema resource: development mode prefixes the base `fraud` to
`dev_<user>_fraud` (per-user); the clean dev/staging/prod targets use the shared `fraud`.
default: ${resources.schemas.ml_workspace.name}
Resolved name of the ML schema passed to notebooks. The clean dev/staging/prod targets
use the shared `fraud_ml`; the personal target overrides the schema name to `fraud`, so
development mode gives each attendee a `dev_<you>_fraud` sandbox.
default: ${resources.schemas.fraud_ml.name}
gold_schema:
description: Shared, read-only gold schema holding the source consumption tables.
default: fraud_gold
Expand Down Expand Up @@ -81,13 +81,14 @@ include:
# landing volume). Declared ONLY on the clean (non-development) targets so that:
# * a `bundle deploy -t personal` (development mode) NEVER creates them. Attendees read
# the shared, instructor-seeded schemas and write only to their own
# `dev_<user>_fraud` (ml_workspace). No empty per-user copies.
# `dev_<user>_fraud` sandbox. No empty per-user copies.
# * a clean deploy (`-t dev` / `-t staging` / `-t prod`, no name-prefixing) provisions
# them literally as the shared `fraud_landing` / `fraud_bronze` / `fraud_silver` /
# `fraud_gold` + volume in that target's catalog.
# `fraud_gold` is the shared source that everyone reads; the per-user `ml_workspace`
# schema is separately named `fraud` (ML outputs), so there is no collision on the clean
# targets.
# `fraud_gold` is the shared source that everyone reads; the shared ML schema is separately
# named `fraud_ml` (ML outputs). On the personal target each attendee instead gets a general
# `dev_<user>_fraud` sandbox (base name overridden to `fraud`), so there is no collision on
# the clean targets.
# These resources only create the empty schemas/volume; the actual data load
# (upload + landing->bronze->silver) still runs via `data_ingestion_job`.

Expand All @@ -105,6 +106,13 @@ targets:
default: true
variables:
catalog_name: dev
# The shared ML schema is `fraud_ml`; on personal each attendee gets a general
# `dev_<you>_fraud` sandbox instead, so override just the schema name to `fraud`
# (development mode then prefixes it to dev_<you>_fraud).
resources:
schemas:
fraud_ml:
name: fraud
# workspace:
# host: https://adb-xxxxxxxxxxxxxxxx.x.azuredatabricks.net # add your workspace URL if deploying from a local CLI

Expand Down Expand Up @@ -154,7 +162,7 @@ targets:
# Production mode needs a fixed, non-user-scoped root_path so exactly one copy is
# deployed and it is not tied to the deploying user. CI deploys and runs this target as
# the cicd_service_principal.
root_path: /Workspace/mlops-workshop/.bundle/${bundle.name}/${bundle.target}
root_path: /Workspace/databricks-mlops-workshop/.bundle/${bundle.name}/${bundle.target}

prod:
mode: production
Expand All @@ -169,4 +177,4 @@ targets:
# deployed. Use a dedicated (non-/Shared) folder: /Shared is writable by ALL workspace
# users, so anyone could tamper with prod code. In a real prod you would deploy as a
# service principal and restrict write ACLs on this folder to that identity.
root_path: /Workspace/mlops-workshop/.bundle/${bundle.name}/${bundle.target}
root_path: /Workspace/databricks-mlops-workshop/.bundle/${bundle.name}/${bundle.target}
Comment thread
BenConstable9 marked this conversation as resolved.
2 changes: 1 addition & 1 deletion resources/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ reconciles them into the workspace.

| File | Description |
|---|---|
| `schemas-resource.yml` | Per-user ML workspace schema (base name `fraud`; dev mode → `dev_<user>_fraud`). |
| `schemas-resource.yml` | The ML schema: shared `fraud_ml` on clean targets; a per-attendee `dev_<user>_fraud` sandbox on personal. |
| `ml-artifacts-resource.yml` | The MLflow experiment for the fraud project. |
| `data-ingestion-workflow.yml` | Instructor-only job: land raw files into Bronze, then build Silver. |
| `feature-engineering-workflow.yml` | Build the card/client entity feature tables and on-demand feature functions. |
Expand Down
2 changes: 1 addition & 1 deletion resources/batch-inference-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
resources:
jobs:
batch_inference_job:
name: ${bundle.target}-mlops-workshop-batch-inference
name: ${bundle.target}-databricks-mlops-workshop-batch-inference
tasks:
- task_key: batch_inference
notebook_task:
Expand Down
2 changes: 1 addition & 1 deletion resources/data-ingestion-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
resources:
jobs:
data_ingestion_job:
name: ${bundle.target}-mlops-workshop-data-ingestion
name: ${bundle.target}-databricks-mlops-workshop-data-ingestion
tasks:
- task_key: landing_to_bronze
notebook_task:
Expand Down
4 changes: 2 additions & 2 deletions resources/deployment-job-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
# Docs: MLflow 3 deployment jobs https://learn.microsoft.com/azure/databricks/mlflow/deployment-job
resources:
jobs:
fraud_deployment_job:
name: ${bundle.target}-mlops-workshop-deployment-job
deployment_job:
name: ${bundle.target}-databricks-mlops-workshop-deployment-job
max_concurrent_runs: 1
queue:
enabled: true
Expand Down
2 changes: 1 addition & 1 deletion resources/feature-engineering-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
resources:
jobs:
feature_engineering_job:
name: ${bundle.target}-mlops-workshop-feature-engineering
name: ${bundle.target}-databricks-mlops-workshop-feature-engineering
tasks:
- task_key: build_features
notebook_task:
Expand Down
4 changes: 2 additions & 2 deletions resources/model-training-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
resources:
jobs:
model_training_job:
name: ${bundle.target}-mlops-workshop-training
name: ${bundle.target}-databricks-mlops-workshop-training
tasks:
- task_key: train
notebook_task:
Expand All @@ -24,4 +24,4 @@ resources:
# Connect this model to its deployment job so registering a new version
# auto-triggers the governed evaluate -> approve -> deploy pipeline. The
# validation + serving that used to be separate tasks now live in that job.
deployment_job_id: ${resources.jobs.fraud_deployment_job.id}
deployment_job_id: ${resources.jobs.deployment_job.id}
28 changes: 14 additions & 14 deletions resources/monitoring-resource.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ resources:
# Batch path: the weekly fraud report. Labels are joined, so this monitor tracks
# model quality (accuracy/precision/recall/f1) as well as drift.
batch_monitor:
table_name: ${var.catalog_name}.${resources.schemas.ml_workspace.name}.fraud_predictions
output_schema_name: ${var.catalog_name}.${resources.schemas.ml_workspace.name}
table_name: ${var.catalog_name}.${resources.schemas.fraud_ml.name}.fraud_predictions
output_schema_name: ${var.catalog_name}.${resources.schemas.fraud_ml.name}
assets_dir: /Workspace/Users/${workspace.current_user.userName}/lakehouse_monitoring
inference_log:
granularities: [1 day]
Expand All @@ -42,8 +42,8 @@ resources:
# Online path: ad-hoc checks captured by the serving endpoint's AI Gateway inference
# table, unpacked into the same shape. Labels lag, so this leads with drift.
online_monitor:
table_name: ${var.catalog_name}.${resources.schemas.ml_workspace.name}.fraud_serving_inference
output_schema_name: ${var.catalog_name}.${resources.schemas.ml_workspace.name}
table_name: ${var.catalog_name}.${resources.schemas.fraud_ml.name}.fraud_serving_inference
output_schema_name: ${var.catalog_name}.${resources.schemas.fraud_ml.name}
assets_dir: /Workspace/Users/${workspace.current_user.userName}/lakehouse_monitoring
inference_log:
granularities: [1 day]
Expand All @@ -61,24 +61,24 @@ resources:
# across recent windows, run the training job (which retrains on the latest labelled
# data -> the actual remedy for drift once fresh labels have landed).
retraining_job:
name: ${bundle.target}-mlops-workshop-retraining
name: ${bundle.target}-databricks-mlops-workshop-retraining
tasks:
- task_key: monitored_metric_violation_check
- task_key: model_drift_check
notebook_task:
notebook_path: ../${var.code_root}/monitoring/notebooks/metric_violation_check.py
notebook_path: ../${var.code_root}/monitoring/notebooks/model_drift_check.py
base_parameters:
table_name_under_monitor: ${var.catalog_name}.${resources.schemas.ml_workspace.name}.fraud_predictions
table_name_under_monitor: ${var.catalog_name}.${resources.schemas.fraud_ml.name}.fraud_predictions
metric_to_monitor: f1_score.macro
metric_violation_threshold: "0.20"
model_drift_threshold: "0.20"
num_evaluation_windows: "5"
num_violation_windows: "2"

- task_key: is_metric_violated
- task_key: is_model_drift_violated
depends_on:
- task_key: monitored_metric_violation_check
- task_key: model_drift_check
condition_task:
op: EQUAL_TO
left: "{{tasks.monitored_metric_violation_check.values.is_metric_violated}}"
left: "{{tasks.model_drift_check.values.is_model_drift_violated}}"
right: "true"

# Feature (data) drift check: sustained input drift on any watched feature is a
Expand All @@ -87,7 +87,7 @@ resources:
notebook_task:
notebook_path: ../${var.code_root}/monitoring/notebooks/feature_drift_check.py
base_parameters:
table_name_under_monitor: ${var.catalog_name}.${resources.schemas.ml_workspace.name}.fraud_predictions
table_name_under_monitor: ${var.catalog_name}.${resources.schemas.fraud_ml.name}.fraud_predictions
features_to_monitor: amount,credit_score,credit_limit,yearly_income,current_age,num_cards_issued,is_night,is_online,is_high_risk_mcc,amount_to_income_ratio,amount_to_credit_limit_ratio
drift_metric: population_stability_index
drift_violation_threshold: "0.20"
Expand All @@ -107,7 +107,7 @@ resources:
# (it runs as long as at least one true-branch was taken, the other is just skipped).
- task_key: trigger_retraining
depends_on:
- task_key: is_metric_violated
- task_key: is_model_drift_violated
outcome: "true"
- task_key: is_feature_drift_violated
outcome: "true"
Expand Down
19 changes: 9 additions & 10 deletions resources/schemas-resource.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
# Per-user ML workspace schema. Declared with a BASE name (`fraud_ml`); in development
# mode the bundle prefixes it to `dev_<user>_fraud_ml`, giving each user their own schema
# for the ML artifacts they produce (feature tables, model, predictions, monitoring).
# In production mode (no prefix) it resolves to the shared `fraud_ml`. Reads always come
# from the shared medallion schemas (fraud_bronze / fraud_silver / fraud_gold). Jobs
# reference ${resources.schemas.ml_workspace.name} so notebooks write to exactly this
# schema. The layer ("gold") is intentionally NOT in the name: this schema holds ML
# outputs (model, predictions, monitoring), not a mirror of the shared gold layer.
# The ML schema. On the clean dev/staging/prod targets it is the shared `fraud_ml`, sitting
# alongside the medallion schemas (fraud_landing / fraud_bronze / fraud_silver / fraud_gold).
# The personal target overrides the name to `fraud` (see databricks.yml), and development mode
# prefixes it, so each attendee gets a private `dev_<you>_fraud` sandbox they can use for
# anything, not just ML outputs. Reads always come from the shared medallion schemas; jobs
# pass ${resources.schemas.fraud_ml.name} so notebooks write to exactly the schema the bundle
# created for that target.
# Docs: Bundle deployment modes (dev prefixing) https://learn.microsoft.com/azure/databricks/dev-tools/bundles/deployment-modes
resources:
schemas:
ml_workspace:
fraud_ml:
catalog_name: ${var.catalog_name}
name: fraud_ml
comment: Per-user ML outputs (feature tables, model, predictions, monitoring). Shared in prod.
comment: Shared ML outputs (fraud_ml) on clean targets; a general per-attendee sandbox (dev_<you>_fraud) on personal.
2 changes: 1 addition & 1 deletion resources/serving-log-processing-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
resources:
jobs:
serving_log_processing_job:
name: ${bundle.target}-mlops-workshop-serving-log-processing
name: ${bundle.target}-databricks-mlops-workshop-serving-log-processing
tasks:
- task_key: process_serving_logs
notebook_task:
Expand Down
23 changes: 22 additions & 1 deletion scripts/generate_src.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
HINT = "# HINT"
PLACEHOLDER = "# <-- Your code here"

# Width of the banner rules that wrap each generated TODO block so the gaps stand out
# clearly from the surrounding provided code.
_BANNER = "-" * 20
TODO_TOP = f"# {_BANNER} TODO {_BANNER}"
TODO_BOTTOM = "# " + "-" * (len(TODO_TOP) - 2)


def strip_solution(text: str) -> str:
"""Replace every TODO-BEGIN..TODO-END block with the hint, any retained HINT
Expand All @@ -49,22 +55,37 @@ def strip_solution(text: str) -> str:
* the text after ``TODO-BEGIN:`` becomes the ``# TODO:`` summary line,
* any ``# HINT:`` comment lines are kept verbatim (guidance for participants),
* all other (solution) lines are removed and replaced by a single placeholder.
Files with no markers pass through unchanged."""
Each generated block is wrapped in a banner and blank lines so the gap stands out
from the provided code around it. Files with no markers pass through unchanged."""
lines = text.splitlines()
out: list[str] = []
in_block = False
indent = ""
swallow_blank = False
for line in lines:
stripped = line.lstrip()
# Drop a single blank line that immediately follows a block: we add our own
# trailing blank, so this avoids a double gap before the next line.
if swallow_blank:
swallow_blank = False
if stripped == "":
continue
if stripped.startswith(BEGIN):
in_block = True
indent = line[: len(line) - len(stripped)]
hint = stripped[len(BEGIN) :].lstrip(": ").rstrip()
# Blank line before the banner for a clear separation from preceding code.
if out and out[-1].strip() != "":
out.append("")
out.append(f"{indent}{TODO_TOP}")
out.append(f"{indent}# TODO: {hint}" if hint else f"{indent}# TODO")
continue
if stripped.startswith(END):
in_block = False
out.append(f"{indent}{PLACEHOLDER}")
out.append(f"{indent}{TODO_BOTTOM}")
out.append("")
swallow_blank = True
continue
if in_block:
# Retain author-provided hints; drop everything else (the solution).
Expand Down
2 changes: 1 addition & 1 deletion solution/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ Each stage has its notebook entrypoints in `<stage>/notebooks/`.
| `feature_engineering/` | Build the card/client entity feature tables and on-demand feature functions. |
| `training/` | Train, track and register the fraud detection model. |
| `deployment/` | Promote and serve the champion model (deployment job + endpoint) and run batch inference. |
| `monitoring/` | Refresh the Lakehouse Monitors, process serving logs, and check for metric violations that trigger retraining. |
| `monitoring/` | Refresh the Lakehouse Monitors, process serving logs, and check for model drift that triggers retraining. |
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
if not ml_schema:
_user = spark.range(1).select(F.current_user()).first()[0]
_short = "".join(c if c.isalnum() else "_" for c in _user.split("@")[0])
ml_schema = f"dev_{_short}_fraud_ml"
ml_schema = f"dev_{_short}_fraud"

model_alias = dbutils.widgets.get("model_alias")
model_name = dbutils.widgets.get("model_name")
Expand Down
2 changes: 1 addition & 1 deletion solution/deployment/model_deployment/notebooks/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
if not ml_schema:
_user = spark.range(1).select(F.current_user()).first()[0]
_short = "".join(c if c.isalnum() else "_" for c in _user.split("@")[0])
ml_schema = f"dev_{_short}_fraud_ml"
ml_schema = f"dev_{_short}_fraud"
if not uc_model_name:
uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection"
if not model_version:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
if not ml_schema:
_user = spark.range(1).select(F.current_user()).first()[0]
_short = "".join(c if c.isalnum() else "_" for c in _user.split("@")[0])
ml_schema = f"dev_{_short}_fraud_ml"
ml_schema = f"dev_{_short}_fraud"
if not uc_model_name:
uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection"
if not model_version:
Expand Down
Loading
Loading