From c97066b6f8887156c39d4b60c85f72b964b344fa Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 12:48:19 +0100 Subject: [PATCH 1/8] feat(model): parameterize the bare model name; build uc_model_name model_name is now a single bundle variable holding the bare, unqualified name (default fraud_detection), passed everywhere. Notebooks that own the name (training, batch_inference) build the three-level Unity Catalog name locally as uc_model_name = catalog_name.ml_schema.model_name and use it for every UC/MLflow operation. The deployment-job notebooks (evaluation, approval, deployment) receive the full name that Databricks injects into the model_name parameter and read it straight into uc_model_name. No base_model_name, and the bare name is no longer hardcoded in the notebooks. The deployment job's model_name default composes the full name from the parts. Regenerate src. --- README.md | 4 ++- databricks.yml | 6 +++-- resources/deployment-job-workflow.yml | 2 +- .../notebooks/batch_inference.py | 15 +++++------ .../model_deployment/notebooks/approval.py | 10 ++++---- .../model_deployment/notebooks/deployment.py | 12 ++++----- .../model_deployment/notebooks/evaluation.py | 20 +++++++-------- solution/training/notebooks/training.py | 25 ++++++++++--------- .../notebooks/batch_inference.py | 13 +++++----- .../model_deployment/notebooks/approval.py | 10 ++++---- .../model_deployment/notebooks/deployment.py | 12 ++++----- .../model_deployment/notebooks/evaluation.py | 20 +++++++-------- src/training/notebooks/training.py | 23 +++++++++-------- 13 files changed, 90 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index c824edf..c5b5fe2 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,9 @@ base_parameters: ``` and the model name follows the same schema: -`${var.catalog_name}.${resources.schemas.ml_workspace.name}.fraud_detection`. +`${var.catalog_name}.${resources.schemas.ml_workspace.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 diff --git a/databricks.yml b/databricks.yml index ad0dc07..c63b540 100644 --- a/databricks.yml +++ b/databricks.yml @@ -15,8 +15,10 @@ variables: description: MLflow experiment for model training runs (per-user path). default: /Users/${workspace.current_user.userName}/${bundle.target}-mlops-workshop-fraud model_name: - description: Unity Catalog registered model name (in the per-user workspace schema). - default: ${var.catalog_name}.${var.ml_schema}.fraud_detection + description: >- + Bare (unqualified) registered model name. Notebooks prepend catalog_name + ml_schema + to form the three-level Unity Catalog name (uc_model_name). + default: fraud_detection catalog_name: description: Unity Catalog catalog for the environment (dev/staging/prod). default: adoption_workshop diff --git a/resources/deployment-job-workflow.yml b/resources/deployment-job-workflow.yml index 4325023..3df05ba 100644 --- a/resources/deployment-job-workflow.yml +++ b/resources/deployment-job-workflow.yml @@ -24,7 +24,7 @@ resources: enabled: true parameters: - name: model_name - default: ${var.model_name} + default: ${var.catalog_name}.${var.ml_schema}.${var.model_name} - name: model_version default: "" tasks: diff --git a/solution/deployment/batch_inference/notebooks/batch_inference.py b/solution/deployment/batch_inference/notebooks/batch_inference.py index 37f4237..cd61677 100644 --- a/solution/deployment/batch_inference/notebooks/batch_inference.py +++ b/solution/deployment/batch_inference/notebooks/batch_inference.py @@ -26,7 +26,7 @@ dbutils.widgets.text("catalog_name", "adoption_workshop") dbutils.widgets.text("gold_schema", "fraud_gold") dbutils.widgets.text("ml_schema", "") -dbutils.widgets.text("model_name", "") +dbutils.widgets.text("model_name", "fraud_detection") dbutils.widgets.text("model_alias", "champion") catalog_name = dbutils.widgets.get("catalog_name") @@ -43,14 +43,15 @@ ml_schema = f"dev_{_short}_fraud" model_alias = dbutils.widgets.get("model_alias") -# The batch-inference job passes the full three-level model name; fall back for interactive runs. -model_name = dbutils.widgets.get("model_name") or f"{catalog_name}.{ml_schema}.fraud_detection" +model_name = dbutils.widgets.get("model_name") +# Prepend catalog + ml_schema to the bare model name to form the three-level UC name. +uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" # Read the transactions to score from the shared gold source; write predictions to the personal schema. source_table = f"{catalog_name}.{gold_schema}.transactions_enriched" predictions_table = f"{catalog_name}.{ml_schema}.fraud_predictions" -print(f"Model: {model_name}@{model_alias}") +print(f"Model: {uc_model_name}@{model_alias}") print(f"Score from: {source_table}") print(f"Write to: {predictions_table}") @@ -77,7 +78,7 @@ # Resolve the champion alias to a concrete version, to stamp every scored row with the model # version that produced it (the monitor's model_id_col groups metrics by this). client = MlflowClient() -champion = client.get_model_version_by_alias(model_name, model_alias) +champion = client.get_model_version_by_alias(uc_model_name, model_alias) model_version = str(champion.version) print(f"{model_alias} -> version {model_version}") @@ -121,10 +122,10 @@ # COMMAND ---------- # TODO-BEGIN: score the batch with the champion model -# HINT: fe.score_batch(model_uri=f"models:/{model_name}@{model_alias}", df=to_score). +# HINT: fe.score_batch(model_uri=f"models:/{uc_model_name}@{model_alias}", df=to_score). # HINT: the result has the lookup key plus a "prediction" column (the monitor's prediction_col). scored = fe.score_batch( - model_uri=f"models:/{model_name}@{model_alias}", + model_uri=f"models:/{uc_model_name}@{model_alias}", df=to_score, ) # TODO-END diff --git a/solution/deployment/model_deployment/notebooks/approval.py b/solution/deployment/model_deployment/notebooks/approval.py index 7c518af..a226a92 100644 --- a/solution/deployment/model_deployment/notebooks/approval.py +++ b/solution/deployment/model_deployment/notebooks/approval.py @@ -25,17 +25,17 @@ dbutils.widgets.text("approval_tag_name", "") dbutils.widgets.text("auto_approve", "false") -model_name = dbutils.widgets.get("model_name") +uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") # The deployment job passes the task name here (e.g. "Approval_Check") as the tag key. tag_name = dbutils.widgets.get("approval_tag_name") or "Approval_Check" auto_approve = dbutils.widgets.get("auto_approve").strip().lower() == "true" -assert model_name and model_version, ( +assert uc_model_name and model_version, ( "model_name and model_version are injected by the deployment job." ) print( - f"Approval check for {model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" + f"Approval check for {uc_model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" ) # COMMAND ---------- @@ -47,10 +47,10 @@ # In dev, auto-approve: set the tag so this run (and the UI) reflect an approved state, then # pass. In staging/prod, a human must have set the tag to "Approved". if auto_approve: - client.set_model_version_tag(model_name, model_version, tag_name, "Approved") + client.set_model_version_tag(uc_model_name, model_version, tag_name, "Approved") print(f"Auto-approved (dev): set {tag_name}=Approved on v{model_version}.") else: - mv = client.get_model_version(model_name, model_version) + mv = client.get_model_version(uc_model_name, model_version) status = (mv.tags or {}).get(tag_name) print(f"Current approval tag {tag_name}={status!r}") if status != "Approved": diff --git a/solution/deployment/model_deployment/notebooks/deployment.py b/solution/deployment/model_deployment/notebooks/deployment.py index 12c7de0..9185617 100644 --- a/solution/deployment/model_deployment/notebooks/deployment.py +++ b/solution/deployment/model_deployment/notebooks/deployment.py @@ -24,11 +24,11 @@ dbutils.widgets.text("catalog_name", "adoption_workshop") dbutils.widgets.text("ml_schema", "") -model_name = dbutils.widgets.get("model_name") +uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") -assert model_name and model_version, ( +assert uc_model_name and model_version, ( "model_name and model_version are injected by the deployment job." ) @@ -46,7 +46,7 @@ card_feature_table = f"{catalog_name}.{ml_schema}.card_features" client_feature_table = f"{catalog_name}.{ml_schema}.client_features" -print(f"Deploying {model_name} v{model_version} -> endpoint {endpoint_name}") +print(f"Deploying {uc_model_name} v{model_version} -> endpoint {endpoint_name}") # COMMAND ---------- @@ -63,9 +63,9 @@ mlflow.set_registry_uri("databricks-uc") client = MlflowClient() -client.set_registered_model_alias(model_name, "champion", model_version) +client.set_registered_model_alias(uc_model_name, "champion", model_version) try: - client.delete_registered_model_alias(model_name, "challenger") + client.delete_registered_model_alias(uc_model_name, "challenger") except Exception: pass # there may be no challenger alias (e.g. first deployment) print(f"@champion -> version {model_version}") @@ -131,7 +131,7 @@ def wait_online_store(name, minutes=20): served_entities = [ ServedEntityInput( - entity_name=model_name, + entity_name=uc_model_name, entity_version=model_version, scale_to_zero_enabled=True, workload_size="Small", diff --git a/solution/deployment/model_deployment/notebooks/evaluation.py b/solution/deployment/model_deployment/notebooks/evaluation.py index 9d2003c..e829262 100644 --- a/solution/deployment/model_deployment/notebooks/evaluation.py +++ b/solution/deployment/model_deployment/notebooks/evaluation.py @@ -7,7 +7,7 @@ # MAGIC This is the first task of the **MLflow 3 deployment job**. A deployment job is a # MAGIC Lakeflow Job that is **connected to a Unity Catalog registered model** and # MAGIC **auto-triggers on every new model version** (Databricks injects the job-level -# MAGIC parameters `model_name` and `model_version`). The three tasks are: +# MAGIC parameters `uc_model_name` and `model_version`). The three tasks are: # MAGIC # MAGIC 1. **Evaluation** (this notebook): score the new version and record its metrics on # MAGIC the model-version page so an approver can decide. @@ -28,16 +28,16 @@ dbutils.widgets.text("metric", "roc_auc") dbutils.widgets.text("baseline", "0.65") -model_name = dbutils.widgets.get("model_name") +uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") metric = dbutils.widgets.get("metric") baseline = float(dbutils.widgets.get("baseline")) -assert model_name and model_version, ( +assert uc_model_name and model_version, ( "model_name and model_version are injected by the deployment job." ) -print(f"Evaluating {model_name} version {model_version} on '{metric}' (floor {baseline}).") +print(f"Evaluating {uc_model_name} version {model_version} on '{metric}' (floor {baseline}).") # COMMAND ---------- @@ -79,7 +79,7 @@ def score_holdout(version): """Score a model version on the holdout; return a pandas DF of is_fraud + fraud score.""" - scored = fe.score_batch(model_uri=f"models:/{model_name}/{version}", df=eval_spine) + scored = fe.score_batch(model_uri=f"models:/{uc_model_name}/{version}", df=eval_spine) return scored.select("is_fraud", F.col("prediction").cast("double").alias("score")).toPandas() @@ -89,7 +89,7 @@ def score_holdout(version): # Fair comparison: re-score the current champion on the SAME holdout. If there is no champion # yet (first model), gate against the metric floor instead. try: - champion = client.get_model_version_by_alias(model_name, "champion") + champion = client.get_model_version_by_alias(uc_model_name, "champion") champ_pdf = score_holdout(champion.version) bar = float(roc_auc_score(champ_pdf["is_fraud"], champ_pdf["score"])) bar_label = f"champion (v{champion.version})" @@ -99,9 +99,9 @@ def score_holdout(version): print(f"candidate {metric}={candidate_score:.4f} vs {bar_label}={bar:.4f}") # Surface the decision inputs on the model version so the approver sees them in the UI. -client.set_model_version_tag(model_name, model_version, "eval_metric", metric) -client.set_model_version_tag(model_name, model_version, "eval_score", f"{candidate_score:.4f}") -client.set_model_version_tag(model_name, model_version, "eval_bar", f"{bar:.4f}") +client.set_model_version_tag(uc_model_name, model_version, "eval_metric", metric) +client.set_model_version_tag(uc_model_name, model_version, "eval_score", f"{candidate_score:.4f}") +client.set_model_version_tag(uc_model_name, model_version, "eval_bar", f"{bar:.4f}") # COMMAND ---------- @@ -125,7 +125,7 @@ def score_holdout(version): ConfusionMatrixDisplay(cm, display_labels=["legit", "fraud"]).plot( cmap="Blues", ax=ax, colorbar=False ) -ax.set_title(f"{model_name.split('.')[-1]} v{model_version} (holdout, threshold 0.5)") +ax.set_title(f"{uc_model_name.split('.')[-1]} v{model_version} (holdout, threshold 0.5)") fig.tight_layout() with mlflow.start_run(run_name=f"evaluation_v{model_version}") as ev_run: mlflow.log_figure(fig, "confusion_matrix.png") diff --git a/solution/training/notebooks/training.py b/solution/training/notebooks/training.py index 1a55220..858aaf3 100644 --- a/solution/training/notebooks/training.py +++ b/solution/training/notebooks/training.py @@ -25,7 +25,7 @@ dbutils.widgets.text("gold_schema", "fraud_gold") dbutils.widgets.text("ml_schema", "") dbutils.widgets.text("experiment_name", "") -dbutils.widgets.text("model_name", "") +dbutils.widgets.text("model_name", "fraud_detection") dbutils.widgets.text("deployment_job_id", "") catalog_name = dbutils.widgets.get("catalog_name") @@ -52,13 +52,14 @@ experiment_name = ( dbutils.widgets.get("experiment_name") or f"/Users/{current_user}/mlops-workshop-fraud" ) -model_name = dbutils.widgets.get("model_name") or f"{catalog_name}.{ml_schema}.fraud_detection" +model_name = dbutils.widgets.get("model_name") +uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" print(f"Card features: {card_feature_table}") print(f"Client features: {client_feature_table}") print(f"Labels: {source_table}") print(f"Experiment: {experiment_name}") -print(f"Model: {model_name}") +print(f"Model: {uc_model_name}") # COMMAND ---------- @@ -110,11 +111,11 @@ _client = MlflowClient() try: - _client.get_registered_model(model_name) - _client.update_registered_model(model_name, deployment_job_id=deployment_job_id) + _client.get_registered_model(uc_model_name) + _client.update_registered_model(uc_model_name, deployment_job_id=deployment_job_id) except RestException: - _client.create_registered_model(model_name, deployment_job_id=deployment_job_id) - print(f"Connected {model_name} to deployment job {deployment_job_id}.") + _client.create_registered_model(uc_model_name, deployment_job_id=deployment_job_id) + print(f"Connected {uc_model_name} to deployment job {deployment_job_id}.") else: print("No deployment_job_id passed; skipping deployment-job connection (interactive run).") @@ -318,13 +319,13 @@ def predict(self, context, model_input): # HINT: reproduces the same features) and registers it to UC in a single call. # HINT: fe.log_model(model=FraudProbabilityModel(model), artifact_path="model", # HINT: flavor=mlflow.pyfunc, training_set=training_set, - # HINT: registered_model_name=model_name, infer_input_example=True) + # HINT: registered_model_name=uc_model_name, infer_input_example=True) fe.log_model( model=FraudProbabilityModel(model), artifact_path="model", flavor=mlflow.pyfunc, training_set=training_set, - registered_model_name=model_name, + registered_model_name=uc_model_name, infer_input_example=True, ) # TODO-END @@ -416,10 +417,10 @@ def predict(self, context, model_input): client = MlflowClient() # Unity Catalog's search_model_versions only supports a `name='...'` filter (no run_id # filtering), so fetch this model's versions and take the highest: the one registered above. -versions = client.search_model_versions(f"name='{model_name}'") +versions = client.search_model_versions(f"name='{uc_model_name}'") new_version = max(int(mv.version) for mv in versions) -client.set_registered_model_alias(model_name, "challenger", new_version) -print(f"Registered {model_name} version {new_version} and set alias @challenger.") +client.set_registered_model_alias(uc_model_name, "challenger", new_version) +print(f"Registered {uc_model_name} version {new_version} and set alias @challenger.") # COMMAND ---------- diff --git a/src/deployment/batch_inference/notebooks/batch_inference.py b/src/deployment/batch_inference/notebooks/batch_inference.py index 1dc8932..fa05ab0 100644 --- a/src/deployment/batch_inference/notebooks/batch_inference.py +++ b/src/deployment/batch_inference/notebooks/batch_inference.py @@ -26,7 +26,7 @@ dbutils.widgets.text("catalog_name", "adoption_workshop") dbutils.widgets.text("gold_schema", "fraud_gold") dbutils.widgets.text("ml_schema", "") -dbutils.widgets.text("model_name", "") +dbutils.widgets.text("model_name", "fraud_detection") dbutils.widgets.text("model_alias", "champion") catalog_name = dbutils.widgets.get("catalog_name") @@ -43,14 +43,15 @@ ml_schema = f"dev_{_short}_fraud" model_alias = dbutils.widgets.get("model_alias") -# The batch-inference job passes the full three-level model name; fall back for interactive runs. -model_name = dbutils.widgets.get("model_name") or f"{catalog_name}.{ml_schema}.fraud_detection" +model_name = dbutils.widgets.get("model_name") +# Prepend catalog + ml_schema to the bare model name to form the three-level UC name. +uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" # Read the transactions to score from the shared gold source; write predictions to the personal schema. source_table = f"{catalog_name}.{gold_schema}.transactions_enriched" predictions_table = f"{catalog_name}.{ml_schema}.fraud_predictions" -print(f"Model: {model_name}@{model_alias}") +print(f"Model: {uc_model_name}@{model_alias}") print(f"Score from: {source_table}") print(f"Write to: {predictions_table}") @@ -77,7 +78,7 @@ # Resolve the champion alias to a concrete version, to stamp every scored row with the model # version that produced it (the monitor's model_id_col groups metrics by this). client = MlflowClient() -champion = client.get_model_version_by_alias(model_name, model_alias) +champion = client.get_model_version_by_alias(uc_model_name, model_alias) model_version = str(champion.version) print(f"{model_alias} -> version {model_version}") @@ -121,7 +122,7 @@ # COMMAND ---------- # TODO: score the batch with the champion model -# HINT: fe.score_batch(model_uri=f"models:/{model_name}@{model_alias}", df=to_score). +# HINT: fe.score_batch(model_uri=f"models:/{uc_model_name}@{model_alias}", df=to_score). # HINT: the result has the lookup key plus a "prediction" column (the monitor's prediction_col). # <-- Your code here diff --git a/src/deployment/model_deployment/notebooks/approval.py b/src/deployment/model_deployment/notebooks/approval.py index 7c518af..a226a92 100644 --- a/src/deployment/model_deployment/notebooks/approval.py +++ b/src/deployment/model_deployment/notebooks/approval.py @@ -25,17 +25,17 @@ dbutils.widgets.text("approval_tag_name", "") dbutils.widgets.text("auto_approve", "false") -model_name = dbutils.widgets.get("model_name") +uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") # The deployment job passes the task name here (e.g. "Approval_Check") as the tag key. tag_name = dbutils.widgets.get("approval_tag_name") or "Approval_Check" auto_approve = dbutils.widgets.get("auto_approve").strip().lower() == "true" -assert model_name and model_version, ( +assert uc_model_name and model_version, ( "model_name and model_version are injected by the deployment job." ) print( - f"Approval check for {model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" + f"Approval check for {uc_model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" ) # COMMAND ---------- @@ -47,10 +47,10 @@ # In dev, auto-approve: set the tag so this run (and the UI) reflect an approved state, then # pass. In staging/prod, a human must have set the tag to "Approved". if auto_approve: - client.set_model_version_tag(model_name, model_version, tag_name, "Approved") + client.set_model_version_tag(uc_model_name, model_version, tag_name, "Approved") print(f"Auto-approved (dev): set {tag_name}=Approved on v{model_version}.") else: - mv = client.get_model_version(model_name, model_version) + mv = client.get_model_version(uc_model_name, model_version) status = (mv.tags or {}).get(tag_name) print(f"Current approval tag {tag_name}={status!r}") if status != "Approved": diff --git a/src/deployment/model_deployment/notebooks/deployment.py b/src/deployment/model_deployment/notebooks/deployment.py index 12c7de0..9185617 100644 --- a/src/deployment/model_deployment/notebooks/deployment.py +++ b/src/deployment/model_deployment/notebooks/deployment.py @@ -24,11 +24,11 @@ dbutils.widgets.text("catalog_name", "adoption_workshop") dbutils.widgets.text("ml_schema", "") -model_name = dbutils.widgets.get("model_name") +uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") -assert model_name and model_version, ( +assert uc_model_name and model_version, ( "model_name and model_version are injected by the deployment job." ) @@ -46,7 +46,7 @@ card_feature_table = f"{catalog_name}.{ml_schema}.card_features" client_feature_table = f"{catalog_name}.{ml_schema}.client_features" -print(f"Deploying {model_name} v{model_version} -> endpoint {endpoint_name}") +print(f"Deploying {uc_model_name} v{model_version} -> endpoint {endpoint_name}") # COMMAND ---------- @@ -63,9 +63,9 @@ mlflow.set_registry_uri("databricks-uc") client = MlflowClient() -client.set_registered_model_alias(model_name, "champion", model_version) +client.set_registered_model_alias(uc_model_name, "champion", model_version) try: - client.delete_registered_model_alias(model_name, "challenger") + client.delete_registered_model_alias(uc_model_name, "challenger") except Exception: pass # there may be no challenger alias (e.g. first deployment) print(f"@champion -> version {model_version}") @@ -131,7 +131,7 @@ def wait_online_store(name, minutes=20): served_entities = [ ServedEntityInput( - entity_name=model_name, + entity_name=uc_model_name, entity_version=model_version, scale_to_zero_enabled=True, workload_size="Small", diff --git a/src/deployment/model_deployment/notebooks/evaluation.py b/src/deployment/model_deployment/notebooks/evaluation.py index 9d2003c..e829262 100644 --- a/src/deployment/model_deployment/notebooks/evaluation.py +++ b/src/deployment/model_deployment/notebooks/evaluation.py @@ -7,7 +7,7 @@ # MAGIC This is the first task of the **MLflow 3 deployment job**. A deployment job is a # MAGIC Lakeflow Job that is **connected to a Unity Catalog registered model** and # MAGIC **auto-triggers on every new model version** (Databricks injects the job-level -# MAGIC parameters `model_name` and `model_version`). The three tasks are: +# MAGIC parameters `uc_model_name` and `model_version`). The three tasks are: # MAGIC # MAGIC 1. **Evaluation** (this notebook): score the new version and record its metrics on # MAGIC the model-version page so an approver can decide. @@ -28,16 +28,16 @@ dbutils.widgets.text("metric", "roc_auc") dbutils.widgets.text("baseline", "0.65") -model_name = dbutils.widgets.get("model_name") +uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") metric = dbutils.widgets.get("metric") baseline = float(dbutils.widgets.get("baseline")) -assert model_name and model_version, ( +assert uc_model_name and model_version, ( "model_name and model_version are injected by the deployment job." ) -print(f"Evaluating {model_name} version {model_version} on '{metric}' (floor {baseline}).") +print(f"Evaluating {uc_model_name} version {model_version} on '{metric}' (floor {baseline}).") # COMMAND ---------- @@ -79,7 +79,7 @@ def score_holdout(version): """Score a model version on the holdout; return a pandas DF of is_fraud + fraud score.""" - scored = fe.score_batch(model_uri=f"models:/{model_name}/{version}", df=eval_spine) + scored = fe.score_batch(model_uri=f"models:/{uc_model_name}/{version}", df=eval_spine) return scored.select("is_fraud", F.col("prediction").cast("double").alias("score")).toPandas() @@ -89,7 +89,7 @@ def score_holdout(version): # Fair comparison: re-score the current champion on the SAME holdout. If there is no champion # yet (first model), gate against the metric floor instead. try: - champion = client.get_model_version_by_alias(model_name, "champion") + champion = client.get_model_version_by_alias(uc_model_name, "champion") champ_pdf = score_holdout(champion.version) bar = float(roc_auc_score(champ_pdf["is_fraud"], champ_pdf["score"])) bar_label = f"champion (v{champion.version})" @@ -99,9 +99,9 @@ def score_holdout(version): print(f"candidate {metric}={candidate_score:.4f} vs {bar_label}={bar:.4f}") # Surface the decision inputs on the model version so the approver sees them in the UI. -client.set_model_version_tag(model_name, model_version, "eval_metric", metric) -client.set_model_version_tag(model_name, model_version, "eval_score", f"{candidate_score:.4f}") -client.set_model_version_tag(model_name, model_version, "eval_bar", f"{bar:.4f}") +client.set_model_version_tag(uc_model_name, model_version, "eval_metric", metric) +client.set_model_version_tag(uc_model_name, model_version, "eval_score", f"{candidate_score:.4f}") +client.set_model_version_tag(uc_model_name, model_version, "eval_bar", f"{bar:.4f}") # COMMAND ---------- @@ -125,7 +125,7 @@ def score_holdout(version): ConfusionMatrixDisplay(cm, display_labels=["legit", "fraud"]).plot( cmap="Blues", ax=ax, colorbar=False ) -ax.set_title(f"{model_name.split('.')[-1]} v{model_version} (holdout, threshold 0.5)") +ax.set_title(f"{uc_model_name.split('.')[-1]} v{model_version} (holdout, threshold 0.5)") fig.tight_layout() with mlflow.start_run(run_name=f"evaluation_v{model_version}") as ev_run: mlflow.log_figure(fig, "confusion_matrix.png") diff --git a/src/training/notebooks/training.py b/src/training/notebooks/training.py index 838d773..01f3872 100644 --- a/src/training/notebooks/training.py +++ b/src/training/notebooks/training.py @@ -25,7 +25,7 @@ dbutils.widgets.text("gold_schema", "fraud_gold") dbutils.widgets.text("ml_schema", "") dbutils.widgets.text("experiment_name", "") -dbutils.widgets.text("model_name", "") +dbutils.widgets.text("model_name", "fraud_detection") dbutils.widgets.text("deployment_job_id", "") catalog_name = dbutils.widgets.get("catalog_name") @@ -52,13 +52,14 @@ experiment_name = ( dbutils.widgets.get("experiment_name") or f"/Users/{current_user}/mlops-workshop-fraud" ) -model_name = dbutils.widgets.get("model_name") or f"{catalog_name}.{ml_schema}.fraud_detection" +model_name = dbutils.widgets.get("model_name") +uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" print(f"Card features: {card_feature_table}") print(f"Client features: {client_feature_table}") print(f"Labels: {source_table}") print(f"Experiment: {experiment_name}") -print(f"Model: {model_name}") +print(f"Model: {uc_model_name}") # COMMAND ---------- @@ -110,11 +111,11 @@ _client = MlflowClient() try: - _client.get_registered_model(model_name) - _client.update_registered_model(model_name, deployment_job_id=deployment_job_id) + _client.get_registered_model(uc_model_name) + _client.update_registered_model(uc_model_name, deployment_job_id=deployment_job_id) except RestException: - _client.create_registered_model(model_name, deployment_job_id=deployment_job_id) - print(f"Connected {model_name} to deployment job {deployment_job_id}.") + _client.create_registered_model(uc_model_name, deployment_job_id=deployment_job_id) + print(f"Connected {uc_model_name} to deployment job {deployment_job_id}.") else: print("No deployment_job_id passed; skipping deployment-job connection (interactive run).") @@ -299,7 +300,7 @@ def predict(self, context, model_input): # HINT: reproduces the same features) and registers it to UC in a single call. # HINT: fe.log_model(model=FraudProbabilityModel(model), artifact_path="model", # HINT: flavor=mlflow.pyfunc, training_set=training_set, - # HINT: registered_model_name=model_name, infer_input_example=True) + # HINT: registered_model_name=uc_model_name, infer_input_example=True) # <-- Your code here # MLflow 3: surface the eval metrics on the LoggedModel (and thus the UC model-version @@ -389,10 +390,10 @@ def predict(self, context, model_input): client = MlflowClient() # Unity Catalog's search_model_versions only supports a `name='...'` filter (no run_id # filtering), so fetch this model's versions and take the highest: the one registered above. -versions = client.search_model_versions(f"name='{model_name}'") +versions = client.search_model_versions(f"name='{uc_model_name}'") new_version = max(int(mv.version) for mv in versions) -client.set_registered_model_alias(model_name, "challenger", new_version) -print(f"Registered {model_name} version {new_version} and set alias @challenger.") +client.set_registered_model_alias(uc_model_name, "challenger", new_version) +print(f"Registered {uc_model_name} version {new_version} and set alias @challenger.") # COMMAND ---------- From ad95553938c15e3167de8ebb6a7a87b47c55d895 Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 14:15:50 +0100 Subject: [PATCH 2/8] feat(deployment): make evaluation/deployment runnable interactively The deployment-job notebooks previously asserted on the injected model_name/model_version and could not be run by hand. Evaluation and deployment now rebuild those interactively: ml_schema falls back to the per-user dev__fraud, uc_model_name is composed from catalog + ml_schema + fraud_detection, and model_version defaults to the latest registered version. Added a comment to all three (evaluation, approval, deployment) noting that the deployment job injects model_name as the full three-level namespace. Regenerate src. --- .../model_deployment/notebooks/approval.py | 2 ++ .../model_deployment/notebooks/deployment.py | 21 ++++++++++---- .../model_deployment/notebooks/evaluation.py | 28 ++++++++++++++++--- .../model_deployment/notebooks/approval.py | 2 ++ .../model_deployment/notebooks/deployment.py | 21 ++++++++++---- .../model_deployment/notebooks/evaluation.py | 28 ++++++++++++++++--- 6 files changed, 82 insertions(+), 20 deletions(-) diff --git a/solution/deployment/model_deployment/notebooks/approval.py b/solution/deployment/model_deployment/notebooks/approval.py index a226a92..4b34c6a 100644 --- a/solution/deployment/model_deployment/notebooks/approval.py +++ b/solution/deployment/model_deployment/notebooks/approval.py @@ -20,6 +20,8 @@ # COMMAND ---------- +# The deployment job injects the FULL three-level model name (catalog.schema.model) into +# `model_name`, plus the `model_version` it is gating. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") dbutils.widgets.text("approval_tag_name", "") diff --git a/solution/deployment/model_deployment/notebooks/deployment.py b/solution/deployment/model_deployment/notebooks/deployment.py index 9185617..411acea 100644 --- a/solution/deployment/model_deployment/notebooks/deployment.py +++ b/solution/deployment/model_deployment/notebooks/deployment.py @@ -18,7 +18,9 @@ # COMMAND ---------- -# The deployment job injects model_name + model_version as JOB-level parameters. +# The deployment job injects the FULL three-level model name (catalog.schema.model) into +# `model_name`, plus the `model_version` that triggered it. Both are empty when this notebook +# is run interactively, so the fallback below rebuilds them. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") dbutils.widgets.text("catalog_name", "adoption_workshop") @@ -28,18 +30,25 @@ model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") -assert uc_model_name and model_version, ( - "model_name and model_version are injected by the deployment job." -) from pyspark.sql import functions as F -# Interactive fallback: jobs pass the resolved personal schema; running standalone derives -# the same per-user name the bundle uses (dev__fraud) so runs stay isolated. +# Interactive fallback: rebuild the personal schema and the full model name, and default to +# the latest version, so the notebook can be run by hand (outside the deployment job). 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" +if not uc_model_name: + uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection" +if not model_version: + import mlflow + from mlflow.tracking import MlflowClient + + mlflow.set_registry_uri("databricks-uc") + model_version = str( + max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) + ) endpoint_name = f"{ml_schema}_fraud" online_store_name = "fraud-workshop-online" diff --git a/solution/deployment/model_deployment/notebooks/evaluation.py b/solution/deployment/model_deployment/notebooks/evaluation.py index e829262..e97c44a 100644 --- a/solution/deployment/model_deployment/notebooks/evaluation.py +++ b/solution/deployment/model_deployment/notebooks/evaluation.py @@ -21,22 +21,42 @@ # COMMAND ---------- -# The deployment job injects model_name + model_version as JOB-level parameters. +# The deployment job injects the FULL three-level model name (catalog.schema.model) into +# `model_name`, plus the `model_version` that triggered it. Both are empty when this notebook +# is run interactively, so the fallback below rebuilds them. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") dbutils.widgets.text("catalog_name", "adoption_workshop") +dbutils.widgets.text("ml_schema", "") dbutils.widgets.text("metric", "roc_auc") dbutils.widgets.text("baseline", "0.65") uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") +ml_schema = dbutils.widgets.get("ml_schema") metric = dbutils.widgets.get("metric") baseline = float(dbutils.widgets.get("baseline")) -assert uc_model_name and model_version, ( - "model_name and model_version are injected by the deployment job." -) +from pyspark.sql import functions as F + +# Interactive fallback: rebuild the personal schema and the full model name the same way the +# training/batch notebooks do, and default to the latest version, so this runs by hand too. +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" +if not uc_model_name: + uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection" +if not model_version: + import mlflow + from mlflow.tracking import MlflowClient + + mlflow.set_registry_uri("databricks-uc") + model_version = str( + max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) + ) + print(f"Evaluating {uc_model_name} version {model_version} on '{metric}' (floor {baseline}).") # COMMAND ---------- diff --git a/src/deployment/model_deployment/notebooks/approval.py b/src/deployment/model_deployment/notebooks/approval.py index a226a92..4b34c6a 100644 --- a/src/deployment/model_deployment/notebooks/approval.py +++ b/src/deployment/model_deployment/notebooks/approval.py @@ -20,6 +20,8 @@ # COMMAND ---------- +# The deployment job injects the FULL three-level model name (catalog.schema.model) into +# `model_name`, plus the `model_version` it is gating. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") dbutils.widgets.text("approval_tag_name", "") diff --git a/src/deployment/model_deployment/notebooks/deployment.py b/src/deployment/model_deployment/notebooks/deployment.py index 9185617..411acea 100644 --- a/src/deployment/model_deployment/notebooks/deployment.py +++ b/src/deployment/model_deployment/notebooks/deployment.py @@ -18,7 +18,9 @@ # COMMAND ---------- -# The deployment job injects model_name + model_version as JOB-level parameters. +# The deployment job injects the FULL three-level model name (catalog.schema.model) into +# `model_name`, plus the `model_version` that triggered it. Both are empty when this notebook +# is run interactively, so the fallback below rebuilds them. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") dbutils.widgets.text("catalog_name", "adoption_workshop") @@ -28,18 +30,25 @@ model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") -assert uc_model_name and model_version, ( - "model_name and model_version are injected by the deployment job." -) from pyspark.sql import functions as F -# Interactive fallback: jobs pass the resolved personal schema; running standalone derives -# the same per-user name the bundle uses (dev__fraud) so runs stay isolated. +# Interactive fallback: rebuild the personal schema and the full model name, and default to +# the latest version, so the notebook can be run by hand (outside the deployment job). 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" +if not uc_model_name: + uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection" +if not model_version: + import mlflow + from mlflow.tracking import MlflowClient + + mlflow.set_registry_uri("databricks-uc") + model_version = str( + max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) + ) endpoint_name = f"{ml_schema}_fraud" online_store_name = "fraud-workshop-online" diff --git a/src/deployment/model_deployment/notebooks/evaluation.py b/src/deployment/model_deployment/notebooks/evaluation.py index e829262..e97c44a 100644 --- a/src/deployment/model_deployment/notebooks/evaluation.py +++ b/src/deployment/model_deployment/notebooks/evaluation.py @@ -21,22 +21,42 @@ # COMMAND ---------- -# The deployment job injects model_name + model_version as JOB-level parameters. +# The deployment job injects the FULL three-level model name (catalog.schema.model) into +# `model_name`, plus the `model_version` that triggered it. Both are empty when this notebook +# is run interactively, so the fallback below rebuilds them. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") dbutils.widgets.text("catalog_name", "adoption_workshop") +dbutils.widgets.text("ml_schema", "") dbutils.widgets.text("metric", "roc_auc") dbutils.widgets.text("baseline", "0.65") uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") catalog_name = dbutils.widgets.get("catalog_name") +ml_schema = dbutils.widgets.get("ml_schema") metric = dbutils.widgets.get("metric") baseline = float(dbutils.widgets.get("baseline")) -assert uc_model_name and model_version, ( - "model_name and model_version are injected by the deployment job." -) +from pyspark.sql import functions as F + +# Interactive fallback: rebuild the personal schema and the full model name the same way the +# training/batch notebooks do, and default to the latest version, so this runs by hand too. +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" +if not uc_model_name: + uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection" +if not model_version: + import mlflow + from mlflow.tracking import MlflowClient + + mlflow.set_registry_uri("databricks-uc") + model_version = str( + max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) + ) + print(f"Evaluating {uc_model_name} version {model_version} on '{metric}' (floor {baseline}).") # COMMAND ---------- From 98560250c10de0b3ea92c8dff2e92ea946239aea Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 14:27:15 +0100 Subject: [PATCH 3/8] fix(model): guard against double-qualifying uc_model_name; restore model_alias training and batch_inference now accept either a bare model_name or an already-qualified three-level name (model_name.count('.') == 2) instead of always prepending, so passing a full name no longer double-prefixes. Also restore the model_alias widget read in batch_inference that a prior edit dropped, and correct an evaluation.py markdown line that wrongly named the injected parameter uc_model_name (it is model_name). Regenerate src. --- .../deployment/batch_inference/notebooks/batch_inference.py | 6 ++++-- .../deployment/model_deployment/notebooks/evaluation.py | 2 +- solution/training/notebooks/training.py | 5 ++++- src/deployment/batch_inference/notebooks/batch_inference.py | 6 ++++-- src/deployment/model_deployment/notebooks/evaluation.py | 2 +- src/training/notebooks/training.py | 5 ++++- 6 files changed, 18 insertions(+), 8 deletions(-) diff --git a/solution/deployment/batch_inference/notebooks/batch_inference.py b/solution/deployment/batch_inference/notebooks/batch_inference.py index cd61677..699c7ca 100644 --- a/solution/deployment/batch_inference/notebooks/batch_inference.py +++ b/solution/deployment/batch_inference/notebooks/batch_inference.py @@ -44,8 +44,10 @@ model_alias = dbutils.widgets.get("model_alias") model_name = dbutils.widgets.get("model_name") -# Prepend catalog + ml_schema to the bare model name to form the three-level UC name. -uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" +# Accept either a bare name (prepend catalog + ml_schema) or an already-qualified 3-level name. +uc_model_name = ( + model_name if model_name.count(".") == 2 else f"{catalog_name}.{ml_schema}.{model_name}" +) # Read the transactions to score from the shared gold source; write predictions to the personal schema. source_table = f"{catalog_name}.{gold_schema}.transactions_enriched" diff --git a/solution/deployment/model_deployment/notebooks/evaluation.py b/solution/deployment/model_deployment/notebooks/evaluation.py index e97c44a..a68cbfb 100644 --- a/solution/deployment/model_deployment/notebooks/evaluation.py +++ b/solution/deployment/model_deployment/notebooks/evaluation.py @@ -7,7 +7,7 @@ # MAGIC This is the first task of the **MLflow 3 deployment job**. A deployment job is a # MAGIC Lakeflow Job that is **connected to a Unity Catalog registered model** and # MAGIC **auto-triggers on every new model version** (Databricks injects the job-level -# MAGIC parameters `uc_model_name` and `model_version`). The three tasks are: +# MAGIC parameters `model_name` and `model_version`). The three tasks are: # MAGIC # MAGIC 1. **Evaluation** (this notebook): score the new version and record its metrics on # MAGIC the model-version page so an approver can decide. diff --git a/solution/training/notebooks/training.py b/solution/training/notebooks/training.py index 858aaf3..5f2a470 100644 --- a/solution/training/notebooks/training.py +++ b/solution/training/notebooks/training.py @@ -53,7 +53,10 @@ dbutils.widgets.get("experiment_name") or f"/Users/{current_user}/mlops-workshop-fraud" ) model_name = dbutils.widgets.get("model_name") -uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" +# Accept either a bare name (prepend catalog + ml_schema) or an already-qualified 3-level name. +uc_model_name = ( + model_name if model_name.count(".") == 2 else f"{catalog_name}.{ml_schema}.{model_name}" +) print(f"Card features: {card_feature_table}") print(f"Client features: {client_feature_table}") diff --git a/src/deployment/batch_inference/notebooks/batch_inference.py b/src/deployment/batch_inference/notebooks/batch_inference.py index fa05ab0..53f2309 100644 --- a/src/deployment/batch_inference/notebooks/batch_inference.py +++ b/src/deployment/batch_inference/notebooks/batch_inference.py @@ -44,8 +44,10 @@ model_alias = dbutils.widgets.get("model_alias") model_name = dbutils.widgets.get("model_name") -# Prepend catalog + ml_schema to the bare model name to form the three-level UC name. -uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" +# Accept either a bare name (prepend catalog + ml_schema) or an already-qualified 3-level name. +uc_model_name = ( + model_name if model_name.count(".") == 2 else f"{catalog_name}.{ml_schema}.{model_name}" +) # Read the transactions to score from the shared gold source; write predictions to the personal schema. source_table = f"{catalog_name}.{gold_schema}.transactions_enriched" diff --git a/src/deployment/model_deployment/notebooks/evaluation.py b/src/deployment/model_deployment/notebooks/evaluation.py index e97c44a..a68cbfb 100644 --- a/src/deployment/model_deployment/notebooks/evaluation.py +++ b/src/deployment/model_deployment/notebooks/evaluation.py @@ -7,7 +7,7 @@ # MAGIC This is the first task of the **MLflow 3 deployment job**. A deployment job is a # MAGIC Lakeflow Job that is **connected to a Unity Catalog registered model** and # MAGIC **auto-triggers on every new model version** (Databricks injects the job-level -# MAGIC parameters `uc_model_name` and `model_version`). The three tasks are: +# MAGIC parameters `model_name` and `model_version`). The three tasks are: # MAGIC # MAGIC 1. **Evaluation** (this notebook): score the new version and record its metrics on # MAGIC the model-version page so an approver can decide. diff --git a/src/training/notebooks/training.py b/src/training/notebooks/training.py index 01f3872..ceae3f3 100644 --- a/src/training/notebooks/training.py +++ b/src/training/notebooks/training.py @@ -53,7 +53,10 @@ dbutils.widgets.get("experiment_name") or f"/Users/{current_user}/mlops-workshop-fraud" ) model_name = dbutils.widgets.get("model_name") -uc_model_name = f"{catalog_name}.{ml_schema}.{model_name}" +# Accept either a bare name (prepend catalog + ml_schema) or an already-qualified 3-level name. +uc_model_name = ( + model_name if model_name.count(".") == 2 else f"{catalog_name}.{ml_schema}.{model_name}" +) print(f"Card features: {card_feature_table}") print(f"Client features: {client_feature_table}") From 3eed70db4604fd62c17b3f1b47a161fa966538fe Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 14:34:47 +0100 Subject: [PATCH 4/8] feat(deployment): pass ml_schema to Evaluation; make approval interactive Pass ml_schema explicitly to the Evaluation task for clarity/consistency with the other job tasks (evaluation only needs it for the interactive fallback). Also give approval.py the same interactive fallback as evaluation/deployment (rebuild ml_schema, uc_model_name, and the latest model_version) so all three deployment-job notebooks can be run by hand, not just via the job. Regenerate src. --- resources/deployment-job-workflow.yml | 1 + .../model_deployment/notebooks/approval.py | 28 ++++++++++++++++--- .../model_deployment/notebooks/approval.py | 28 ++++++++++++++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/resources/deployment-job-workflow.yml b/resources/deployment-job-workflow.yml index 3df05ba..a4ebea6 100644 --- a/resources/deployment-job-workflow.yml +++ b/resources/deployment-job-workflow.yml @@ -35,6 +35,7 @@ resources: notebook_path: ../${var.code_root}/deployment/model_deployment/notebooks/evaluation.py base_parameters: catalog_name: ${var.catalog_name} + ml_schema: ${var.ml_schema} metric: roc_auc baseline: "0.65" diff --git a/solution/deployment/model_deployment/notebooks/approval.py b/solution/deployment/model_deployment/notebooks/approval.py index 4b34c6a..a68939d 100644 --- a/solution/deployment/model_deployment/notebooks/approval.py +++ b/solution/deployment/model_deployment/notebooks/approval.py @@ -21,21 +21,41 @@ # COMMAND ---------- # The deployment job injects the FULL three-level model name (catalog.schema.model) into -# `model_name`, plus the `model_version` it is gating. +# `model_name`, plus the `model_version` it is gating. Both are empty when this notebook is +# run interactively, so the fallback below rebuilds them. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") +dbutils.widgets.text("catalog_name", "adoption_workshop") +dbutils.widgets.text("ml_schema", "") dbutils.widgets.text("approval_tag_name", "") dbutils.widgets.text("auto_approve", "false") uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") +catalog_name = dbutils.widgets.get("catalog_name") +ml_schema = dbutils.widgets.get("ml_schema") # The deployment job passes the task name here (e.g. "Approval_Check") as the tag key. tag_name = dbutils.widgets.get("approval_tag_name") or "Approval_Check" auto_approve = dbutils.widgets.get("auto_approve").strip().lower() == "true" -assert uc_model_name and model_version, ( - "model_name and model_version are injected by the deployment job." -) +from pyspark.sql import functions as F + +# Interactive fallback: rebuild the personal schema and the full model name, and default to +# the latest version, so the notebook can be run by hand (outside the deployment job). +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" +if not uc_model_name: + uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection" +if not model_version: + import mlflow + from mlflow.tracking import MlflowClient + + mlflow.set_registry_uri("databricks-uc") + model_version = str( + max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) + ) print( f"Approval check for {uc_model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" ) diff --git a/src/deployment/model_deployment/notebooks/approval.py b/src/deployment/model_deployment/notebooks/approval.py index 4b34c6a..a68939d 100644 --- a/src/deployment/model_deployment/notebooks/approval.py +++ b/src/deployment/model_deployment/notebooks/approval.py @@ -21,21 +21,41 @@ # COMMAND ---------- # The deployment job injects the FULL three-level model name (catalog.schema.model) into -# `model_name`, plus the `model_version` it is gating. +# `model_name`, plus the `model_version` it is gating. Both are empty when this notebook is +# run interactively, so the fallback below rebuilds them. dbutils.widgets.text("model_name", "") dbutils.widgets.text("model_version", "") +dbutils.widgets.text("catalog_name", "adoption_workshop") +dbutils.widgets.text("ml_schema", "") dbutils.widgets.text("approval_tag_name", "") dbutils.widgets.text("auto_approve", "false") uc_model_name = dbutils.widgets.get("model_name") model_version = dbutils.widgets.get("model_version") +catalog_name = dbutils.widgets.get("catalog_name") +ml_schema = dbutils.widgets.get("ml_schema") # The deployment job passes the task name here (e.g. "Approval_Check") as the tag key. tag_name = dbutils.widgets.get("approval_tag_name") or "Approval_Check" auto_approve = dbutils.widgets.get("auto_approve").strip().lower() == "true" -assert uc_model_name and model_version, ( - "model_name and model_version are injected by the deployment job." -) +from pyspark.sql import functions as F + +# Interactive fallback: rebuild the personal schema and the full model name, and default to +# the latest version, so the notebook can be run by hand (outside the deployment job). +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" +if not uc_model_name: + uc_model_name = f"{catalog_name}.{ml_schema}.fraud_detection" +if not model_version: + import mlflow + from mlflow.tracking import MlflowClient + + mlflow.set_registry_uri("databricks-uc") + model_version = str( + max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) + ) print( f"Approval check for {uc_model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" ) From 8521452a721a8ebb76f6fda21849b76dfa5c86ee Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 14:53:15 +0100 Subject: [PATCH 5/8] fix(deployment): fail fast when only one of model_name/model_version is set The deployment-job notebooks derived a missing model_name or model_version from defaults, so a half-configured job (only one injected) would silently gate the wrong model or version. Strip both widget values and raise if exactly one is present: both set is a job, both empty is an interactive run (still derived), exactly one is a misconfiguration. Applied to evaluation, approval, and deployment. Regenerate src. --- .../model_deployment/notebooks/approval.py | 13 +++++++++++-- .../model_deployment/notebooks/deployment.py | 13 +++++++++++-- .../model_deployment/notebooks/evaluation.py | 13 +++++++++++-- .../model_deployment/notebooks/approval.py | 13 +++++++++++-- .../model_deployment/notebooks/deployment.py | 13 +++++++++++-- .../model_deployment/notebooks/evaluation.py | 13 +++++++++++-- 6 files changed, 66 insertions(+), 12 deletions(-) diff --git a/solution/deployment/model_deployment/notebooks/approval.py b/solution/deployment/model_deployment/notebooks/approval.py index a68939d..59e798b 100644 --- a/solution/deployment/model_deployment/notebooks/approval.py +++ b/solution/deployment/model_deployment/notebooks/approval.py @@ -30,14 +30,23 @@ dbutils.widgets.text("approval_tag_name", "") dbutils.widgets.text("auto_approve", "false") -uc_model_name = dbutils.widgets.get("model_name") -model_version = dbutils.widgets.get("model_version") +uc_model_name = dbutils.widgets.get("model_name").strip() +model_version = dbutils.widgets.get("model_version").strip() catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") # The deployment job passes the task name here (e.g. "Approval_Check") as the tag key. tag_name = dbutils.widgets.get("approval_tag_name") or "Approval_Check" auto_approve = dbutils.widgets.get("auto_approve").strip().lower() == "true" +# Fail fast on a half-configured deployment job: model_name and model_version are injected +# together. Both present = job; both empty = interactive run (derived below). Exactly one +# present is a misconfiguration that would silently gate the wrong model or version. +if bool(uc_model_name) != bool(model_version): + raise ValueError( + "Only one of model_name / model_version was provided. Pass both (deployment job) " + "or neither (interactive run)." + ) + from pyspark.sql import functions as F # Interactive fallback: rebuild the personal schema and the full model name, and default to diff --git a/solution/deployment/model_deployment/notebooks/deployment.py b/solution/deployment/model_deployment/notebooks/deployment.py index 411acea..12e5bd2 100644 --- a/solution/deployment/model_deployment/notebooks/deployment.py +++ b/solution/deployment/model_deployment/notebooks/deployment.py @@ -26,11 +26,20 @@ dbutils.widgets.text("catalog_name", "adoption_workshop") dbutils.widgets.text("ml_schema", "") -uc_model_name = dbutils.widgets.get("model_name") -model_version = dbutils.widgets.get("model_version") +uc_model_name = dbutils.widgets.get("model_name").strip() +model_version = dbutils.widgets.get("model_version").strip() catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") +# Fail fast on a half-configured deployment job: model_name and model_version are injected +# together. Both present = job; both empty = interactive run (derived below). Exactly one +# present is a misconfiguration that would silently gate the wrong model or version. +if bool(uc_model_name) != bool(model_version): + raise ValueError( + "Only one of model_name / model_version was provided. Pass both (deployment job) " + "or neither (interactive run)." + ) + from pyspark.sql import functions as F # Interactive fallback: rebuild the personal schema and the full model name, and default to diff --git a/solution/deployment/model_deployment/notebooks/evaluation.py b/solution/deployment/model_deployment/notebooks/evaluation.py index a68cbfb..b26f358 100644 --- a/solution/deployment/model_deployment/notebooks/evaluation.py +++ b/solution/deployment/model_deployment/notebooks/evaluation.py @@ -31,13 +31,22 @@ dbutils.widgets.text("metric", "roc_auc") dbutils.widgets.text("baseline", "0.65") -uc_model_name = dbutils.widgets.get("model_name") -model_version = dbutils.widgets.get("model_version") +uc_model_name = dbutils.widgets.get("model_name").strip() +model_version = dbutils.widgets.get("model_version").strip() catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") metric = dbutils.widgets.get("metric") baseline = float(dbutils.widgets.get("baseline")) +# Fail fast on a half-configured deployment job: model_name and model_version are injected +# together. Both present = job; both empty = interactive run (derived below). Exactly one +# present is a misconfiguration that would silently gate the wrong model or version. +if bool(uc_model_name) != bool(model_version): + raise ValueError( + "Only one of model_name / model_version was provided. Pass both (deployment job) " + "or neither (interactive run)." + ) + from pyspark.sql import functions as F # Interactive fallback: rebuild the personal schema and the full model name the same way the diff --git a/src/deployment/model_deployment/notebooks/approval.py b/src/deployment/model_deployment/notebooks/approval.py index a68939d..59e798b 100644 --- a/src/deployment/model_deployment/notebooks/approval.py +++ b/src/deployment/model_deployment/notebooks/approval.py @@ -30,14 +30,23 @@ dbutils.widgets.text("approval_tag_name", "") dbutils.widgets.text("auto_approve", "false") -uc_model_name = dbutils.widgets.get("model_name") -model_version = dbutils.widgets.get("model_version") +uc_model_name = dbutils.widgets.get("model_name").strip() +model_version = dbutils.widgets.get("model_version").strip() catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") # The deployment job passes the task name here (e.g. "Approval_Check") as the tag key. tag_name = dbutils.widgets.get("approval_tag_name") or "Approval_Check" auto_approve = dbutils.widgets.get("auto_approve").strip().lower() == "true" +# Fail fast on a half-configured deployment job: model_name and model_version are injected +# together. Both present = job; both empty = interactive run (derived below). Exactly one +# present is a misconfiguration that would silently gate the wrong model or version. +if bool(uc_model_name) != bool(model_version): + raise ValueError( + "Only one of model_name / model_version was provided. Pass both (deployment job) " + "or neither (interactive run)." + ) + from pyspark.sql import functions as F # Interactive fallback: rebuild the personal schema and the full model name, and default to diff --git a/src/deployment/model_deployment/notebooks/deployment.py b/src/deployment/model_deployment/notebooks/deployment.py index 411acea..12e5bd2 100644 --- a/src/deployment/model_deployment/notebooks/deployment.py +++ b/src/deployment/model_deployment/notebooks/deployment.py @@ -26,11 +26,20 @@ dbutils.widgets.text("catalog_name", "adoption_workshop") dbutils.widgets.text("ml_schema", "") -uc_model_name = dbutils.widgets.get("model_name") -model_version = dbutils.widgets.get("model_version") +uc_model_name = dbutils.widgets.get("model_name").strip() +model_version = dbutils.widgets.get("model_version").strip() catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") +# Fail fast on a half-configured deployment job: model_name and model_version are injected +# together. Both present = job; both empty = interactive run (derived below). Exactly one +# present is a misconfiguration that would silently gate the wrong model or version. +if bool(uc_model_name) != bool(model_version): + raise ValueError( + "Only one of model_name / model_version was provided. Pass both (deployment job) " + "or neither (interactive run)." + ) + from pyspark.sql import functions as F # Interactive fallback: rebuild the personal schema and the full model name, and default to diff --git a/src/deployment/model_deployment/notebooks/evaluation.py b/src/deployment/model_deployment/notebooks/evaluation.py index a68cbfb..b26f358 100644 --- a/src/deployment/model_deployment/notebooks/evaluation.py +++ b/src/deployment/model_deployment/notebooks/evaluation.py @@ -31,13 +31,22 @@ dbutils.widgets.text("metric", "roc_auc") dbutils.widgets.text("baseline", "0.65") -uc_model_name = dbutils.widgets.get("model_name") -model_version = dbutils.widgets.get("model_version") +uc_model_name = dbutils.widgets.get("model_name").strip() +model_version = dbutils.widgets.get("model_version").strip() catalog_name = dbutils.widgets.get("catalog_name") ml_schema = dbutils.widgets.get("ml_schema") metric = dbutils.widgets.get("metric") baseline = float(dbutils.widgets.get("baseline")) +# Fail fast on a half-configured deployment job: model_name and model_version are injected +# together. Both present = job; both empty = interactive run (derived below). Exactly one +# present is a misconfiguration that would silently gate the wrong model or version. +if bool(uc_model_name) != bool(model_version): + raise ValueError( + "Only one of model_name / model_version was provided. Pass both (deployment job) " + "or neither (interactive run)." + ) + from pyspark.sql import functions as F # Interactive fallback: rebuild the personal schema and the full model name the same way the From d795af7b2b630edc3e6cebc49bde175c3682e5c5 Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 15:04:16 +0100 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../model_deployment/notebooks/approval.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/deployment/model_deployment/notebooks/approval.py b/src/deployment/model_deployment/notebooks/approval.py index 59e798b..6346aff 100644 --- a/src/deployment/model_deployment/notebooks/approval.py +++ b/src/deployment/model_deployment/notebooks/approval.py @@ -62,9 +62,15 @@ from mlflow.tracking import MlflowClient mlflow.set_registry_uri("databricks-uc") - model_version = str( - max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) - ) + _versions = [ + int(mv.version) + for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'") + ] + if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Provide model_version explicitly." + ) + model_version = str(max(_versions)) print( f"Approval check for {uc_model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" ) From ca152b8724cc82a3518ad48dad4cc5807011fa85 Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 15:11:49 +0100 Subject: [PATCH 7/8] fix(deployment): move empty-versions guard into solution; apply to all three The Copilot Autofix (d795af7) hardened the latest-version fallback against an empty search result, but it edited the generated src/approval.py directly. src is generated from solution, so the next sync would revert it. Port the same guard into solution/approval.py (so generate_src reproduces the identical src, no drift) and apply it to evaluation.py and deployment.py, which had the same max()-on-empty fallback. Regenerate src. --- .../model_deployment/notebooks/approval.py | 12 +++++++++--- .../model_deployment/notebooks/deployment.py | 12 +++++++++--- .../model_deployment/notebooks/evaluation.py | 12 +++++++++--- .../model_deployment/notebooks/deployment.py | 12 +++++++++--- .../model_deployment/notebooks/evaluation.py | 12 +++++++++--- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/solution/deployment/model_deployment/notebooks/approval.py b/solution/deployment/model_deployment/notebooks/approval.py index 59e798b..6346aff 100644 --- a/solution/deployment/model_deployment/notebooks/approval.py +++ b/solution/deployment/model_deployment/notebooks/approval.py @@ -62,9 +62,15 @@ from mlflow.tracking import MlflowClient mlflow.set_registry_uri("databricks-uc") - model_version = str( - max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) - ) + _versions = [ + int(mv.version) + for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'") + ] + if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Provide model_version explicitly." + ) + model_version = str(max(_versions)) print( f"Approval check for {uc_model_name} v{model_version} | tag='{tag_name}' | auto_approve={auto_approve}" ) diff --git a/solution/deployment/model_deployment/notebooks/deployment.py b/solution/deployment/model_deployment/notebooks/deployment.py index 12e5bd2..0eed33c 100644 --- a/solution/deployment/model_deployment/notebooks/deployment.py +++ b/solution/deployment/model_deployment/notebooks/deployment.py @@ -55,9 +55,15 @@ from mlflow.tracking import MlflowClient mlflow.set_registry_uri("databricks-uc") - model_version = str( - max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) - ) + _versions = [ + int(mv.version) + for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'") + ] + if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Provide model_version explicitly." + ) + model_version = str(max(_versions)) endpoint_name = f"{ml_schema}_fraud" online_store_name = "fraud-workshop-online" diff --git a/solution/deployment/model_deployment/notebooks/evaluation.py b/solution/deployment/model_deployment/notebooks/evaluation.py index b26f358..493150f 100644 --- a/solution/deployment/model_deployment/notebooks/evaluation.py +++ b/solution/deployment/model_deployment/notebooks/evaluation.py @@ -62,9 +62,15 @@ from mlflow.tracking import MlflowClient mlflow.set_registry_uri("databricks-uc") - model_version = str( - max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) - ) + _versions = [ + int(mv.version) + for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'") + ] + if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Provide model_version explicitly." + ) + model_version = str(max(_versions)) print(f"Evaluating {uc_model_name} version {model_version} on '{metric}' (floor {baseline}).") diff --git a/src/deployment/model_deployment/notebooks/deployment.py b/src/deployment/model_deployment/notebooks/deployment.py index 12e5bd2..0eed33c 100644 --- a/src/deployment/model_deployment/notebooks/deployment.py +++ b/src/deployment/model_deployment/notebooks/deployment.py @@ -55,9 +55,15 @@ from mlflow.tracking import MlflowClient mlflow.set_registry_uri("databricks-uc") - model_version = str( - max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) - ) + _versions = [ + int(mv.version) + for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'") + ] + if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Provide model_version explicitly." + ) + model_version = str(max(_versions)) endpoint_name = f"{ml_schema}_fraud" online_store_name = "fraud-workshop-online" diff --git a/src/deployment/model_deployment/notebooks/evaluation.py b/src/deployment/model_deployment/notebooks/evaluation.py index b26f358..493150f 100644 --- a/src/deployment/model_deployment/notebooks/evaluation.py +++ b/src/deployment/model_deployment/notebooks/evaluation.py @@ -62,9 +62,15 @@ from mlflow.tracking import MlflowClient mlflow.set_registry_uri("databricks-uc") - model_version = str( - max(int(mv.version) for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'")) - ) + _versions = [ + int(mv.version) + for mv in MlflowClient().search_model_versions(f"name='{uc_model_name}'") + ] + if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Provide model_version explicitly." + ) + model_version = str(max(_versions)) print(f"Evaluating {uc_model_name} version {model_version} on '{metric}' (floor {baseline}).") From 2709767e9966ae9094e89260f208dcf36b155a22 Mon Sep 17 00:00:00 2001 From: Ben Constable Date: Mon, 13 Jul 2026 15:17:39 +0100 Subject: [PATCH 8/8] fix(training): guard the latest-version max() against no registered versions training.py resolves the just-registered version with max() over search_model_versions, the same pattern the Autofix hardened in the deployment notebooks. In the gapped student src the registration sits in a TODO block, so running this cell before completing it returns no versions and max() throws a cryptic empty-sequence error. Guard it with a clear message. Regenerate src. --- solution/training/notebooks/training.py | 8 ++++++-- src/training/notebooks/training.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/solution/training/notebooks/training.py b/solution/training/notebooks/training.py index 5f2a470..b97d89a 100644 --- a/solution/training/notebooks/training.py +++ b/solution/training/notebooks/training.py @@ -420,8 +420,12 @@ def predict(self, context, model_input): client = MlflowClient() # Unity Catalog's search_model_versions only supports a `name='...'` filter (no run_id # filtering), so fetch this model's versions and take the highest: the one registered above. -versions = client.search_model_versions(f"name='{uc_model_name}'") -new_version = max(int(mv.version) for mv in versions) +_versions = [int(mv.version) for mv in client.search_model_versions(f"name='{uc_model_name}'")] +if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Was the model registered above?" + ) +new_version = max(_versions) client.set_registered_model_alias(uc_model_name, "challenger", new_version) print(f"Registered {uc_model_name} version {new_version} and set alias @challenger.") diff --git a/src/training/notebooks/training.py b/src/training/notebooks/training.py index ceae3f3..ac55125 100644 --- a/src/training/notebooks/training.py +++ b/src/training/notebooks/training.py @@ -393,8 +393,12 @@ def predict(self, context, model_input): client = MlflowClient() # Unity Catalog's search_model_versions only supports a `name='...'` filter (no run_id # filtering), so fetch this model's versions and take the highest: the one registered above. -versions = client.search_model_versions(f"name='{uc_model_name}'") -new_version = max(int(mv.version) for mv in versions) +_versions = [int(mv.version) for mv in client.search_model_versions(f"name='{uc_model_name}'")] +if not _versions: + raise ValueError( + f"No model versions found for {uc_model_name!r}. Was the model registered above?" + ) +new_version = max(_versions) client.set_registered_model_alias(uc_model_name, "challenger", new_version) print(f"Registered {uc_model_name} version {new_version} and set alias @challenger.")