From 62357ea78d9edb8fd59dc10f3f8ed6f69562d9fe Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 13 May 2020 16:26:45 -0700 Subject: [PATCH 01/28] new split pipelines --- .pipelines/diabetes_regression-deploy.yml | 158 ++++++++++++++++++ ...betes_regression-get-model-id-template.yml | 37 ++++ ...etes_regression-package-model-template.yml | 30 ++++ ...ession-publish-model-artifact-template.yml | 23 +++ .../diabetes_regression-train-register.yml | 98 +++++++++++ diabetes_regression/ci_dependencies.yml | 1 + 6 files changed, 347 insertions(+) create mode 100644 .pipelines/diabetes_regression-deploy.yml create mode 100644 .pipelines/diabetes_regression-get-model-id-template.yml create mode 100644 .pipelines/diabetes_regression-package-model-template.yml create mode 100644 .pipelines/diabetes_regression-publish-model-artifact-template.yml create mode 100644 .pipelines/diabetes_regression-train-register.yml diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml new file mode 100644 index 00000000..33c0f6c2 --- /dev/null +++ b/.pipelines/diabetes_regression-deploy.yml @@ -0,0 +1,158 @@ +# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, registration, deployment, and testing of the diabetes_regression model. +# Runtime parameters to select artifacts +parameters: +- name : artifactBranch + displayName: Artifact Branch (e.g. feature/myfeature) + type: string + default: $(Build.SourceBranch) +- name : artifactBuildId + displayName: Artifact Build Id (e.g. run Id for the build to download). Overrides Artifact Branch if not "latest". + type: string + default: latest + +resources: + containers: + - container: mlops + image: mcr.microsoft.com/mlops/python:latest + pipelines: + - pipeline: model-train-ci + source: model-train-ci + trigger: + branches: + include: + - master + +trigger: none + +variables: +- template: diabetes_regression-variables-template.yml +- group: devopsforai-aml-vg + +stages: +- stage: 'Deploy_ACI' + displayName: 'Deploy to ACI' + condition: variables['ACI_DEPLOYMENT_NAME'] + jobs: + - job: "Deploy_ACI" + displayName: "Deploy to ACI" + container: mlops + timeoutInMinutes: 0 + steps: + - script: jq --version + - download: none + - template: diabetes_regression-get-model-id-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + - task: AzureCLI@1 + displayName: "Deploy to ACI (CLI)" + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring + inlineScript: | + set -e # fail on error + az ml model deploy --name $(ACI_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ + --ic inference_config.yml \ + --dc deployment_config_aci.yml \ + -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ + --overwrite -v + - task: AzureCLI@1 + displayName: 'Smoke test' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.util.smoke_test_scoring_service --type ACI --service "$(ACI_DEPLOYMENT_NAME)" + +- stage: 'Deploy_AKS' + displayName: 'Deploy to AKS' + dependsOn: Deploy_ACI + condition: and(succeeded(), variables['AKS_DEPLOYMENT_NAME']) + jobs: + - job: "Deploy_AKS" + displayName: "Deploy to AKS" + container: mlops + timeoutInMinutes: 0 + steps: + - template: diabetes_regression-get-model-id-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + - task: AzureCLI@1 + displayName: 'Install AzureML CLI' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: 'az extension add -n azure-cli-ml' + - task: AzureCLI@1 + displayName: "Deploy to AKS (CLI)" + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring + inlineScript: | + set -e # fail on error + az ml model deploy --name $(AKS_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ + --compute-target $(AKS_COMPUTE_NAME) \ + --ic inference_config.yml \ + --dc deployment_config_aks.yml \ + -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ + --overwrite -v + - task: AzureCLI@1 + displayName: 'Smoke test' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.util.smoke_test_scoring_service --type AKS --service "$(AKS_DEPLOYMENT_NAME)" + +- stage: 'Deploy_Webapp' + displayName: 'Deploy to Webapp' + dependsOn: Deploy_ACI + condition: and(succeeded(), variables['WEBAPP_DEPLOYMENT_NAME']) + jobs: + - job: "Package_Model" + displayName: "Package model" + container: mlops + timeoutInMinutes: 0 + steps: + - template: diabetes_regression-get-model-id-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + - template: diabetes_regression-package-model-template.yml + parameters: + modelId: $(MODEL_NAME):$(MODEL_VERSION) + scoringScriptPath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/score.py' + condaFilePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/conda_dependencies.yml' + - script: echo $(IMAGE_LOCATION) >image_location.txt + displayName: "Write image location file" + - job: "Deploy_Webapp" + displayName: "Deploy Webapp" + container: mlops + dependsOn: Package_Model + condition: succeeded() + steps: + - task: AzureWebAppContainer@1 + name: WebAppDeploy + displayName: 'Azure Web App on Container Deploy' + inputs: + azureSubscription: '$(AZURE_RM_SVC_CONNECTION)' + appName: '$(WEBAPP_DEPLOYMENT_NAME)' + resourceGroupName: '$(RESOURCE_GROUP)' + imageName: '$(IMAGE_LOCATION)' + - task: AzureCLI@1 + displayName: 'Smoke test' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.util.smoke_test_scoring_service --type Webapp --service "$(WebAppDeploy.AppServiceApplicationUrl)/score" \ No newline at end of file diff --git a/.pipelines/diabetes_regression-get-model-id-template.yml b/.pipelines/diabetes_regression-get-model-id-template.yml new file mode 100644 index 00000000..6bb21a93 --- /dev/null +++ b/.pipelines/diabetes_regression-get-model-id-template.yml @@ -0,0 +1,37 @@ +parameters: +- name: projectId + type: string + default: '' +- name: pipelineId + type: string + default: '' + +steps: + - checkout: none + - download: none + - task: DownloadPipelineArtifact@2 + displayName: Download Pipeline Artifacts + inputs: + source: 'specific' + project: '${{ parameters.projectId }}' + pipeline: '${{ parameters.pipelineId }}' + preferTriggeringPipeline: true + runVersion: 'latestFromBranch' + runBranch: '$(Build.SourceBranch)' + path: $(Build.SourcesDirectory)/bin + - task: Bash@3 + inputs: + targetType: 'inline' + script: | + # Print JSON + cat $(Build.SourcesDirectory)/bin/model/model.json | jq '.' + + # Set model name and version variables as strings + MODEL_NAME=$(jq '.name' <$(Build.SourcesDirectory)/bin/model/model.json) + MODEL_VERSION=$(jq '.version' <$(Build.SourcesDirectory)/bin/model/model.json) + + echo "Model Name: $MODEL_NAME" + echo "Model Version: $MODEL_VERSION" + + echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" + echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" \ No newline at end of file diff --git a/.pipelines/diabetes_regression-package-model-template.yml b/.pipelines/diabetes_regression-package-model-template.yml new file mode 100644 index 00000000..ac317ca6 --- /dev/null +++ b/.pipelines/diabetes_regression-package-model-template.yml @@ -0,0 +1,30 @@ +# Pipeline template that creates a model package and adds the package location to the environment for subsequent tasks to use. +parameters: +- name: modelId + type: string + default: '' +- name: scoringScriptPath + type: string + default: '' +- name: condaFilePath + type: string + default: '' + +steps: + - task: AzureCLI@1 + displayName: 'Install AzureML CLI' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: 'az extension add -n azure-cli-ml' + - task: AzureCLI@1 + displayName: 'Create model package and set IMAGE_LOCATION variable' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + IMAGE_LOCATION=$(az ml model package --workspace-name $(WORKSPACE_NAME) -g $(RESOURCE_GROUP) --model '${{ parameters.modelId }}' --entry-script '${{ parameters.scoringScriptPath }}' --cf '${{ parameters.condaFilePath }}' --rt python --query 'location') + echo $IMAGE_LOCATION + echo "##vso[task.setvariable variable=IMAGE_LOCATION]$IMAGE_LOCATION" \ No newline at end of file diff --git a/.pipelines/diabetes_regression-publish-model-artifact-template.yml b/.pipelines/diabetes_regression-publish-model-artifact-template.yml new file mode 100644 index 00000000..0fa53c6d --- /dev/null +++ b/.pipelines/diabetes_regression-publish-model-artifact-template.yml @@ -0,0 +1,23 @@ +# Pipeline template that attempts to get the latest model version and adds it to the environment for subsequent tasks to use. +steps: +- task: AzureCLI@1 + displayName: 'Install AzureML CLI' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: 'az extension add -n azure-cli-ml' +- task: AzureCLI@1 + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: | + set -e # fail on error + FOUND_MODEL=$(az ml model list -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) --tag BuildId=$(modelbuildid) --query '[0]') + [[ -z "$FOUND_MODEL" ]] && { echo "Model not found" ; exit 1; } + echo $FOUND_MODEL >model.json + name: 'getversion' + displayName: "Determine if evaluation succeeded and new model is registered" +- publish: model.json + artifact: model \ No newline at end of file diff --git a/.pipelines/diabetes_regression-train-register.yml b/.pipelines/diabetes_regression-train-register.yml new file mode 100644 index 00000000..780c6fb4 --- /dev/null +++ b/.pipelines/diabetes_regression-train-register.yml @@ -0,0 +1,98 @@ +# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, registration, deployment, and testing of the diabetes_regression model. + +resources: + containers: + - container: mlops + image: mcr.microsoft.com/mlops/python:latest + +pr: none +trigger: + branches: + include: + - master + paths: + include: + - diabetes_regression/ + - ml_service/pipelines/diabetes_regression_build_train_pipeline.py + - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r.py + - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r_on_dbricks.py + +variables: +- template: diabetes_regression-variables-template.yml +- group: devopsforai-aml-vg + +pool: + vmImage: ubuntu-latest + +stages: +- stage: 'Model_CI' + displayName: 'Model CI' + condition: not(variables['MODEL_BUILD_ID']) + jobs: + - job: "Model_CI_Pipeline" + displayName: "Model CI Pipeline" + container: mlops + timeoutInMinutes: 0 + steps: + - template: code-quality-template.yml + - task: AzureCLI@1 + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + # Invoke the Python building and publishing a training pipeline + python -m ml_service.pipelines.diabetes_regression_build_train_pipeline + displayName: 'Publish Azure Machine Learning Pipeline' + +- stage: 'Trigger_AML_Pipeline' + displayName: 'Train model' + condition: and(succeeded(), not(variables['MODEL_BUILD_ID'])) + variables: + BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' + jobs: + - job: "Get_Pipeline_ID" + condition: and(succeeded(), eq(coalesce(variables['auto-trigger-training'], 'true'), 'true')) + displayName: "Get Pipeline ID for execution" + container: mlops + timeoutInMinutes: 0 + steps: + - task: AzureCLI@1 + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.pipelines.run_train_pipeline --output_pipeline_id_file "pipeline_id.txt" --skip_train_execution + # Set AMLPIPELINEID variable for next AML Pipeline task in next job + AMLPIPELINEID="$(cat pipeline_id.txt)" + echo "##vso[task.setvariable variable=AMLPIPELINEID;isOutput=true]$AMLPIPELINEID" + name: 'getpipelineid' + displayName: 'Get Pipeline ID' + - job: "Run_ML_Pipeline" + dependsOn: "Get_Pipeline_ID" + displayName: "Trigger ML Training Pipeline" + timeoutInMinutes: 0 + pool: server + variables: + AMLPIPELINE_ID: $[ dependencies.Get_Pipeline_ID.outputs['getpipelineid.AMLPIPELINEID'] ] + steps: + - task: ms-air-aiagility.vss-services-azureml.azureml-restApi-task.MLPublishedPipelineRestAPITask@0 + displayName: 'Invoke ML pipeline' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + PipelineId: '$(AMLPIPELINE_ID)' + ExperimentName: '$(EXPERIMENT_NAME)' + PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)"}, "tags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}, "StepTags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}' + - job: "Training_Run_Report" + dependsOn: "Run_ML_Pipeline" + condition: always() + displayName: "Determine if evaluation succeeded and publish artifact if new model is registered" + container: mlops + timeoutInMinutes: 0 + steps: + - template: diabetes_regression-publish-model-artifact-template.yml \ No newline at end of file diff --git a/diabetes_regression/ci_dependencies.yml b/diabetes_regression/ci_dependencies.yml index 72c91cd3..e335fe24 100644 --- a/diabetes_regression/ci_dependencies.yml +++ b/diabetes_regression/ci_dependencies.yml @@ -26,3 +26,4 @@ dependencies: - flake8==3.7.* - flake8_formatter_junit_xml==0.0.* - azure-cli==2.3.* + - jq==1.5.* From 70468e7284c33e68ea3b5fb58947e24ae3123df6 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 13 May 2020 17:26:49 -0700 Subject: [PATCH 02/28] specify channel --- diabetes_regression/ci_dependencies.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/diabetes_regression/ci_dependencies.yml b/diabetes_regression/ci_dependencies.yml index e335fe24..62b7b06c 100644 --- a/diabetes_regression/ci_dependencies.yml +++ b/diabetes_regression/ci_dependencies.yml @@ -12,6 +12,7 @@ dependencies: - r=3.6.0 - r-essentials=3.6.0 + - conda-forge::jq - pip=20.0.* - pip: From 413b4ff840d3ff725f1b600d30cee565f9ebb5bc Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 13 May 2020 17:31:52 -0700 Subject: [PATCH 03/28] fix pip jq install --- diabetes_regression/ci_dependencies.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/diabetes_regression/ci_dependencies.yml b/diabetes_regression/ci_dependencies.yml index 62b7b06c..2869948a 100644 --- a/diabetes_regression/ci_dependencies.yml +++ b/diabetes_regression/ci_dependencies.yml @@ -27,4 +27,3 @@ dependencies: - flake8==3.7.* - flake8_formatter_junit_xml==0.0.* - azure-cli==2.3.* - - jq==1.5.* From 0b4d411795cb59bbbeb7a594d86381e52ab18ea7 Mon Sep 17 00:00:00 2001 From: j-so Date: Thu, 21 May 2020 10:50:37 -0700 Subject: [PATCH 04/28] fixes and cleanup --- .pipelines/diabetes_regression-deploy.yml | 34 ++++++++------ ...ression-get-model-id-artifact-template.yml | 44 +++++++++++++++++++ ...ession-publish-model-artifact-template.yml | 2 +- .../diabetes_regression-train-register.yml | 10 ++--- 4 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 .pipelines/diabetes_regression-get-model-id-artifact-template.yml diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml index 33c0f6c2..9c1dd047 100644 --- a/.pipelines/diabetes_regression-deploy.yml +++ b/.pipelines/diabetes_regression-deploy.yml @@ -1,29 +1,26 @@ -# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, registration, deployment, and testing of the diabetes_regression model. +# Continuous Integration (CI) pipeline that orchestrates the deployment of the diabetes_regression model. + # Runtime parameters to select artifacts parameters: -- name : artifactBranch - displayName: Artifact Branch (e.g. feature/myfeature) - type: string - default: $(Build.SourceBranch) - name : artifactBuildId - displayName: Artifact Build Id (e.g. run Id for the build to download). Overrides Artifact Branch if not "latest". + displayName: Model Train CI Build ID. Default is 'latest'. type: string default: latest +# Trigger this pipeline on model-train pipeline completion +trigger: none resources: containers: - container: mlops - image: mcr.microsoft.com/mlops/python:latest + image: mlops/diabetes_regression + endpoint: acrconnection pipelines: - pipeline: model-train-ci source: model-train-ci trigger: branches: - include: - master -trigger: none - variables: - template: diabetes_regression-variables-template.yml - group: devopsforai-aml-vg @@ -38,12 +35,19 @@ stages: container: mlops timeoutInMinutes: 0 steps: - - script: jq --version - download: none - - template: diabetes_regression-get-model-id-template.yml + - template: diabetes_regression-get-model-id-artifact-template.yml parameters: projectId: '$(resources.pipeline.model-train-ci.projectID)' pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} + - task: AzureCLI@1 + displayName: 'Install AzureML CLI' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: 'az extension add -n azure-cli-ml' - task: AzureCLI@1 displayName: "Deploy to ACI (CLI)" inputs: @@ -77,10 +81,11 @@ stages: container: mlops timeoutInMinutes: 0 steps: - - template: diabetes_regression-get-model-id-template.yml + - template: diabetes_regression-get-model-id-artifact-template.yml parameters: projectId: '$(resources.pipeline.model-train-ci.projectID)' pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} - task: AzureCLI@1 displayName: 'Install AzureML CLI' inputs: @@ -122,10 +127,11 @@ stages: container: mlops timeoutInMinutes: 0 steps: - - template: diabetes_regression-get-model-id-template.yml + - template: diabetes_regression-get-model-id-artifact-template.yml parameters: projectId: '$(resources.pipeline.model-train-ci.projectID)' pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} - template: diabetes_regression-package-model-template.yml parameters: modelId: $(MODEL_NAME):$(MODEL_VERSION) diff --git a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml new file mode 100644 index 00000000..d1180b23 --- /dev/null +++ b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml @@ -0,0 +1,44 @@ +parameters: +- name: projectId + type: string + default: '' +- name: pipelineId + type: string + default: '' +- name: artifactBuildId + type: string + default: latest + +steps: + - download: none + - task: DownloadPipelineArtifact@2 + displayName: Download Pipeline Artifacts + inputs: + source: 'specific' + project: '${{ parameters.projectId }}' + pipeline: '${{ parameters.pipelineId }}' + preferTriggeringPipeline: true + ${{ if eq(parameters.artifactBuildId, 'latest') }}: + buildVersionToDownload: 'latestFromBranch' + ${{ if ne(parameters.artifactBuildId, 'latest') }}: + buildVersionToDownload: 'specific' + runId: '${{ parameters.artifactBuildId }}' + runBranch: '$(Build.SourceBranch)' + path: $(Build.SourcesDirectory)/bin + - task: Bash@3 + displayName: Parse Json for Model Name and Version + inputs: + targetType: 'inline' + script: | + # Print JSON + cat $(Build.SourcesDirectory)/bin/model/model.json | jq '.' + + # Set model name and version variables as strings + MODEL_NAME=$(jq -r '.name' <$(Build.SourcesDirectory)/bin/model/model.json) + MODEL_VERSION=$(jq -r '.version' <$(Build.SourcesDirectory)/bin/model/model.json) + + echo "Model Name: $MODEL_NAME" + echo "Model Version: $MODEL_VERSION" + + echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" + echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" \ No newline at end of file diff --git a/.pipelines/diabetes_regression-publish-model-artifact-template.yml b/.pipelines/diabetes_regression-publish-model-artifact-template.yml index 0fa53c6d..8b599961 100644 --- a/.pipelines/diabetes_regression-publish-model-artifact-template.yml +++ b/.pipelines/diabetes_regression-publish-model-artifact-template.yml @@ -18,6 +18,6 @@ steps: [[ -z "$FOUND_MODEL" ]] && { echo "Model not found" ; exit 1; } echo $FOUND_MODEL >model.json name: 'getversion' - displayName: "Determine if evaluation succeeded and new model is registered" + displayName: "Determine if evaluation succeeded and new model is registered (CLI)" - publish: model.json artifact: model \ No newline at end of file diff --git a/.pipelines/diabetes_regression-train-register.yml b/.pipelines/diabetes_regression-train-register.yml index 780c6fb4..f61d98e4 100644 --- a/.pipelines/diabetes_regression-train-register.yml +++ b/.pipelines/diabetes_regression-train-register.yml @@ -1,9 +1,10 @@ -# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, registration, deployment, and testing of the diabetes_regression model. +# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, and registration of the diabetes_regression model. resources: containers: - container: mlops - image: mcr.microsoft.com/mlops/python:latest + image: mlops/diabetes_regression + endpoint: acrconnection pr: none trigger: @@ -27,7 +28,6 @@ pool: stages: - stage: 'Model_CI' displayName: 'Model CI' - condition: not(variables['MODEL_BUILD_ID']) jobs: - job: "Model_CI_Pipeline" displayName: "Model CI Pipeline" @@ -49,7 +49,7 @@ stages: - stage: 'Trigger_AML_Pipeline' displayName: 'Train model' - condition: and(succeeded(), not(variables['MODEL_BUILD_ID'])) + condition: succeeded() variables: BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' jobs: @@ -91,7 +91,7 @@ stages: - job: "Training_Run_Report" dependsOn: "Run_ML_Pipeline" condition: always() - displayName: "Determine if evaluation succeeded and publish artifact if new model is registered" + displayName: "Publish artifact if new model was registered" container: mlops timeoutInMinutes: 0 steps: From 1d309983876690083783febb177c342d73bc956a Mon Sep 17 00:00:00 2001 From: j-so Date: Thu, 21 May 2020 11:40:52 -0700 Subject: [PATCH 05/28] add new lines --- .pipelines/diabetes_regression-deploy.yml | 2 +- .../diabetes_regression-get-model-id-artifact-template.yml | 2 +- .pipelines/diabetes_regression-package-model-template.yml | 2 +- .../diabetes_regression-publish-model-artifact-template.yml | 2 +- .pipelines/diabetes_regression-train-register.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml index 9c1dd047..caa9fd21 100644 --- a/.pipelines/diabetes_regression-deploy.yml +++ b/.pipelines/diabetes_regression-deploy.yml @@ -161,4 +161,4 @@ stages: inlineScript: | set -e # fail on error export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type Webapp --service "$(WebAppDeploy.AppServiceApplicationUrl)/score" \ No newline at end of file + python -m ml_service.util.smoke_test_scoring_service --type Webapp --service "$(WebAppDeploy.AppServiceApplicationUrl)/score" diff --git a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml index d1180b23..8b2b50da 100644 --- a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml +++ b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml @@ -41,4 +41,4 @@ steps: echo "Model Version: $MODEL_VERSION" echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" - echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" \ No newline at end of file + echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" diff --git a/.pipelines/diabetes_regression-package-model-template.yml b/.pipelines/diabetes_regression-package-model-template.yml index ac317ca6..536e0e0e 100644 --- a/.pipelines/diabetes_regression-package-model-template.yml +++ b/.pipelines/diabetes_regression-package-model-template.yml @@ -27,4 +27,4 @@ steps: set -e # fail on error IMAGE_LOCATION=$(az ml model package --workspace-name $(WORKSPACE_NAME) -g $(RESOURCE_GROUP) --model '${{ parameters.modelId }}' --entry-script '${{ parameters.scoringScriptPath }}' --cf '${{ parameters.condaFilePath }}' --rt python --query 'location') echo $IMAGE_LOCATION - echo "##vso[task.setvariable variable=IMAGE_LOCATION]$IMAGE_LOCATION" \ No newline at end of file + echo "##vso[task.setvariable variable=IMAGE_LOCATION]$IMAGE_LOCATION" diff --git a/.pipelines/diabetes_regression-publish-model-artifact-template.yml b/.pipelines/diabetes_regression-publish-model-artifact-template.yml index 8b599961..01b3aad5 100644 --- a/.pipelines/diabetes_regression-publish-model-artifact-template.yml +++ b/.pipelines/diabetes_regression-publish-model-artifact-template.yml @@ -20,4 +20,4 @@ steps: name: 'getversion' displayName: "Determine if evaluation succeeded and new model is registered (CLI)" - publish: model.json - artifact: model \ No newline at end of file + artifact: model diff --git a/.pipelines/diabetes_regression-train-register.yml b/.pipelines/diabetes_regression-train-register.yml index f61d98e4..25fbeebf 100644 --- a/.pipelines/diabetes_regression-train-register.yml +++ b/.pipelines/diabetes_regression-train-register.yml @@ -95,4 +95,4 @@ stages: container: mlops timeoutInMinutes: 0 steps: - - template: diabetes_regression-publish-model-artifact-template.yml \ No newline at end of file + - template: diabetes_regression-publish-model-artifact-template.yml From c28b886c65ff35a9c093e85838fc9896c712c094 Mon Sep 17 00:00:00 2001 From: j-so Date: Tue, 26 May 2020 10:57:28 -0700 Subject: [PATCH 06/28] add docs and clean up naming --- .pipelines/diabetes_regression-deploy.yml | 7 +- ...ression-get-model-id-artifact-template.yml | 3 +- ...etes_regression-package-model-template.yml | 11 +- ...ession-publish-model-artifact-template.yml | 10 +- .../diabetes_regression-train-register.yml | 2 +- docs/images/model-deploy-configure.png | Bin 0 -> 39099 bytes docs/images/model-deploy-result.png | Bin 0 -> 19960 bytes .../images/model-train-register-artifacts.png | Bin 0 -> 8827 bytes docs/images/model-train-register.png | Bin 0 -> 15690 bytes docs/split_cicd_pipelines.md | 109 ++++++++++++++++++ 10 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 docs/images/model-deploy-configure.png create mode 100644 docs/images/model-deploy-result.png create mode 100644 docs/images/model-train-register-artifacts.png create mode 100644 docs/images/model-train-register.png create mode 100644 docs/split_cicd_pipelines.md diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml index caa9fd21..6075eccc 100644 --- a/.pipelines/diabetes_regression-deploy.yml +++ b/.pipelines/diabetes_regression-deploy.yml @@ -16,7 +16,7 @@ resources: endpoint: acrconnection pipelines: - pipeline: model-train-ci - source: model-train-ci + source: Model-Train-Register-CI # Name of the triggering pipeline trigger: branches: - master @@ -55,7 +55,6 @@ stages: scriptLocation: inlineScript workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring inlineScript: | - set -e # fail on error az ml model deploy --name $(ACI_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ --ic inference_config.yml \ --dc deployment_config_aci.yml \ @@ -100,10 +99,9 @@ stages: scriptLocation: inlineScript workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring inlineScript: | - set -e # fail on error az ml model deploy --name $(AKS_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ --compute-target $(AKS_COMPUTE_NAME) \ - --ic inference_config.yml \ + --ic inference_config.yml \ --dc deployment_config_aks.yml \ -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ --overwrite -v @@ -119,7 +117,6 @@ stages: - stage: 'Deploy_Webapp' displayName: 'Deploy to Webapp' - dependsOn: Deploy_ACI condition: and(succeeded(), variables['WEBAPP_DEPLOYMENT_NAME']) jobs: - job: "Package_Model" diff --git a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml index 8b2b50da..954308c5 100644 --- a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml +++ b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml @@ -33,12 +33,13 @@ steps: # Print JSON cat $(Build.SourcesDirectory)/bin/model/model.json | jq '.' - # Set model name and version variables as strings + # Set model name and version variables MODEL_NAME=$(jq -r '.name' <$(Build.SourcesDirectory)/bin/model/model.json) MODEL_VERSION=$(jq -r '.version' <$(Build.SourcesDirectory)/bin/model/model.json) echo "Model Name: $MODEL_NAME" echo "Model Version: $MODEL_VERSION" + # Set environment variables echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" diff --git a/.pipelines/diabetes_regression-package-model-template.yml b/.pipelines/diabetes_regression-package-model-template.yml index 536e0e0e..accb1596 100644 --- a/.pipelines/diabetes_regression-package-model-template.yml +++ b/.pipelines/diabetes_regression-package-model-template.yml @@ -25,6 +25,15 @@ steps: scriptLocation: inlineScript inlineScript: | set -e # fail on error - IMAGE_LOCATION=$(az ml model package --workspace-name $(WORKSPACE_NAME) -g $(RESOURCE_GROUP) --model '${{ parameters.modelId }}' --entry-script '${{ parameters.scoringScriptPath }}' --cf '${{ parameters.condaFilePath }}' --rt python --query 'location') + + # Create model package using CLI + IMAGE_LOCATION=$(\ + az ml model package --workspace-name $(WORKSPACE_NAME) -g $(RESOURCE_GROUP) \ + --model '${{ parameters.modelId }}' \ + --entry-script '${{ parameters.scoringScriptPath }}' \ + --cf '${{ parameters.condaFilePath }}' \ + --rt python --query 'location') + + # Set environment variable echo $IMAGE_LOCATION echo "##vso[task.setvariable variable=IMAGE_LOCATION]$IMAGE_LOCATION" diff --git a/.pipelines/diabetes_regression-publish-model-artifact-template.yml b/.pipelines/diabetes_regression-publish-model-artifact-template.yml index 01b3aad5..77fb53e4 100644 --- a/.pipelines/diabetes_regression-publish-model-artifact-template.yml +++ b/.pipelines/diabetes_regression-publish-model-artifact-template.yml @@ -1,4 +1,4 @@ -# Pipeline template that attempts to get the latest model version and adds it to the environment for subsequent tasks to use. +# Pipeline template to check if a model was registered for the build and publishes an artifact with the model JSON steps: - task: AzureCLI@1 displayName: 'Install AzureML CLI' @@ -14,8 +14,14 @@ steps: workingDirectory: $(Build.SourcesDirectory) inlineScript: | set -e # fail on error + + # Get the model using the build ID tag FOUND_MODEL=$(az ml model list -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) --tag BuildId=$(modelbuildid) --query '[0]') - [[ -z "$FOUND_MODEL" ]] && { echo "Model not found" ; exit 1; } + + # If the variable is empty, print and fail + [[ -z "$FOUND_MODEL" ]] && { echo "Model was not registered for this run." ; exit 1; } + + # Write to a file echo $FOUND_MODEL >model.json name: 'getversion' displayName: "Determine if evaluation succeeded and new model is registered (CLI)" diff --git a/.pipelines/diabetes_regression-train-register.yml b/.pipelines/diabetes_regression-train-register.yml index 25fbeebf..ba116954 100644 --- a/.pipelines/diabetes_regression-train-register.yml +++ b/.pipelines/diabetes_regression-train-register.yml @@ -48,7 +48,7 @@ stages: displayName: 'Publish Azure Machine Learning Pipeline' - stage: 'Trigger_AML_Pipeline' - displayName: 'Train model' + displayName: 'Train and evaluate model' condition: succeeded() variables: BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' diff --git a/docs/images/model-deploy-configure.png b/docs/images/model-deploy-configure.png new file mode 100644 index 0000000000000000000000000000000000000000..fcd8775012c39f58da4bf456bcf5fa093f851a53 GIT binary patch literal 39099 zcmdqJ2T)UOv^Hu%0cj#gktQnGkbodPpdcbj6=?!NLWoA%A?clPZ&fZMn4pbFDr@J*DNpbhxg?{P;v z>7jNcd8JREBe^+>mm@hp3sHFPqDk@1CqpH=7AR-k@j#kW{2A@|wZmA{(oL5}n$0H0 zZ|1$NUgH^E>aKA7j@oXcMz07dz(GNVV6N0Pd^=fEw+X47eVzjw#dGNvq<-bjNSTd> zeFAt&@0inMuiE<{?~PqOOtzcb>r^e4F->BpNc15lY@(@kzyQoiJO0dv(6Oamw@tCx zU`CPi?jEQKs93fc`zWMg<6W=?cue87KMkZj^CoJIqz^}gvvBm}85UkiiAarGprg5@ zr25Lo3Tg@OZL76~>!P|l4&uMm`%}rCkHN2JJSqM0_TJ26iId%#j3g$UzAE*hTkv>L)H^i`R>Dv4C)7~NfS*&Q^$3tG4X`O%%I;-lrV zp2#!`{>7>7_}ClL+pa&Uk?{U!|LfAzU_>MpR{=D5OzxvoT7<8D3UG>+YH=t(p(}g* z-^ch8^Ilajj~NvnSg-S?C`H`@51Bi}j~1-6^+zG;vvlI1m4>(RMBqX>=0AUy^f3)5 zX^8|>Rus_Z3ib-=cLdkdw*BLD9GvG3_^O}fGJM5r=@_~#I6c&vm;}B`69s)M3R&mg z`GIl&+p(Eeq_W7PJN#&oz>Ueycc8U+#I2)Bo`(PV~=yXc&o6*yruP57A=7!Jo?D2eRjtbw9WfQ}+Op*%uEp1DpCz0|n7^Y8E^BS^$WIrweRY1_Ak z#5{|w1{!Hz;sWT}2i}JcpWseJ$?=+h`pSh&*RkS9=OknInq581f$PCS1<`HvzH(u} z5+s_GSf*CFjE|*WcJrrn$U6=c8qYrGP`yb%a`uJv(^7N+p70r5KK#k9tv8raIA!gZ zeKvf+*z~;UiH8&U<`{D}#P_i6lJXjTbY{($jaT ze%*^lNQ8c%d9D4IU576wgO(JmynfluhVjDAAuP}4s4Qf;x1OJ^=u(|m`DAY#Mj|h# z$~oPWwgSUDBIH$T>zzDq^rPF;TZ*3(tv&PnD4OEF_<1Prl0_9fDMMxOqeXHcIU$?7 z9w(TveBztoarE*+MOU5%2UIrGXX@qbN7UGy4%lKRUh3Vs+>v(WiIP#8jbf5Ll*=mA z`SR+QFw%2&r2?kF+es4VTSay^FmNzh4#sP(YTJEkiY3)zp7d?~fj4xfPfz*dIo;>$ zBnf5V8*Dh8=;cwaOK=gdVVl~O+{?KSJ2MoUZ2t5c>FZ_(ZaNYSW5nnyJ7TpRO1Q={ z`a{E;Rhpk2H=5(utuF7dl@q6mo>v(O2zS|<_}ptyXg~sGS!;Fj6J1)+Ik0X-TPWg*j#7&K<9jEqv}WN#)gXP-o94D zB7^H=eoOB!mQ+93=sZ*Gx3=)BHu|=G&@g1?=X|yDJZrJ-V6pjZ^>pmT83Oy6Ez~p0 zTmR%lbT@8(CC}-{8n1+z0jubUOXGH=9lP4rANc%7)Y>Qs7^DYtVmcg;Cl2!?dc#OV z)k7vM5*{EO4E=MauH&l046`&Mrgf_4aWx!gI5>kyiT-Q(MNr~jA}Qt{mI()=fPUNDEzkimeKhY@@MbEIV<&b zwVi1-9-9}FOy1)G-qH&boytoK5=yG%ZmR}AX3v;zZP5aJ)47%la4tRO4>e&dyzGod zb6U{apYzAC7dXc63viY&e{hRmJq9Ky!M7Tv*jKpraciS?slh^LJhK~@%T|LW-G+_f~QtcyW z>%mOSw-3qNwnVpDNX*bgR);}1o(RlfXzemxIs)>}3+>yPbvyE-t{QgRV%oj;Uruz; zX!JP=_|g0D@uw^N>BRwE(DmH&(ix#;D-mj2$dn(h&z5EH5%c5|4a^7nG9r4>_eXCN zW4lx5?Qk{zJ1meosQ@ZIIaBLKE2?dYCgZ)T6}?|Z$Xb$x426ZS#sLnDbn^;K-8;ea&PJxAiit3bb-rUYDW6T= zWlY7t=nL9yj@}lLr-TveqeT=qN_YuOuue2m3>eI>Lyj$0Qe;2Xpw$7>CREn6+e}-) z)-t9Enei!KT|P`s(t2OD&+-)f%YH`gxa^=EYEzTl-c;2%_T|9`&Fzo(ZM^-aQg0bO zENi)r4IiD8N}5gzs}DT)3QSy1GB~qel{s(^E44Y%%yy<$o&&B1Z4s%zzsAv&xrlrh zzi%I#-JTI(pI(L@N3V|u?Y?tWdpg*WT=(Zv)bhtD|Em*T2xM|M4xUM6JU)}E#0+*~aUvKlqIEDPqgN_4!?w_O^;3TdmyP^Yr1P37mO29-x^a-DC+fQ?a| z7CL*4Z4BS7D|rG~TCX_f=RD};HLMG76K6`?7?#O5^rkbEcfTZLCDP-N<5qpX`no2s z)!os2X>}J^N()qoQcmCgdGo)$^Nc&Pc}GP1KWq2RA}s`B*PK!lou`MvcK_b5|3dit zgw=8M&yml_biX69`7jOnrqc!943|UXf&~QRo}NPl~a3cZ>YO7-l2{sO1x2Q z)O3!+3;O;F_p>*|Zz0h9T@dZ~!x$3XsfwFs!41rpisR;cr3PbXq5 zQY@TQZcE1~BYwY;=}+r#6UAQ&pfOo=FX*_`a|Q0PdsV|*nUxNr{Nmh+;~3}K zj+)=CZK9)&%Jq{P$r|4N*E%JU=`@xTe=+5cZoHpo!spq4p-g^v$8&hZKfvx3JmJG% zT*gA~)~)&n?mX@PPmmSVicF6NNT#SEhD^6?{{q;(regoWo)3wIF7IDE0ZzXzqZe`?788rcI$G3ACD-qD1=ZEnw=08Q`_vUg^)m7?^7 z=LbLDHpgliERP;L1%8_v-b#3sbG2!PJ zf>c(zAVbp=(2)g5)h10XC=fwgMD?M@1F@+iv4ulC<1EA%?931C4bR2Ra(Zab#?2=`*RKWJ?P^O}vEzzWr6}Kn@_qHeQbY5r2 zVNouSG0P8F;8jx_w#aRrrxyzlPKc?OXCoV3>XutpexA+d?mM%n7)nCByw3pG8(ZZx z3qa&Wm_p$#HJ8hNr{H}>SCE3x|_Pg`EjM14-W%4ad|1vkQ=Z`dF@|C-G^(P;c=*{T4=2sqV!pnm`YQlfE9k z_N}|#TN>_d>Y1gp=7H&kDrul(mshyazOL`WoWmWZZaB*_Q1ETZ`O2i~(^iPt_nI7v zG|E>-5mxITy^GB+S@eZ{CW0r+r{%}}J(gWM$c-~*L&{5aMzT|-fvZIi#(+gUPEp<6 zqBM`=dl%WHgZj3%C|yxOw?8@MEhh-?`tMS=yMlH++%g`}`ctL-sdD51yJ{9Ek0v&G z);Q^C7NjwZLv}5m^vv_HDL44#*Mk= zLpLNa5&y==)FeSp)Eny&i{7m2=w2a&2L73fKWUd ze$yC6cHbw%PPk=JRv&<2JVO`09j$TV3n4g`cmm zEDp1!XtdMK7q=|4vYAn$q)&Ft4p=i^9_bj5gRTurW*$$MbGk6{GC-nnC(J)!;q1JP z+K1bH+pPrqlpny98ZYo-2^lk=YxKru@ajV8!d$K@T7HWK2}>;VR&_qhe<&exRyFSU z7oLgOLQCt;+5$+}dSPMtki^j96)rsCAbFZ^vAb)TXh)0pI*v>qS_LqIv+1_awfmNq zC^tuy8~Yvx!d!hhe?Ms3NawwGw_YLGm9q~R(&>)4!;wxD@f%hJTycg>DO$_{pXr;3T+P$U)yB9|yC zIRGD%@TYmHj)^Pl10+aGXqqk56MsztR=_;Zy|$S4q~v^_;e*Bx`ZZ&MOPcREs5%2dX)b6HSL+H9&wrP1rG=BLoaHZMlmv+4h?9ol+m+%P}6A z9LAPEjuFKA&I=y=cGjT?mc^*MUM^VezHAm)vbwj(t$kCT37w1~#-teev2%mByBF}Q zFUljf^pFj@>lgEuGiIRg{UsE}Lh%>+<0y(SOpcSn4ZsXJ`0F4777Ms&dRN<5v9sJax zVBYG&Ec@>)tQ0yFYL`-pRrQ8T5@~gq+i{hap~GbF~SmGh3eF zaZ%}iDSrSqL6LW#yOLI*9AS=&HP6F2aHU79 zc}qoq*sF$3QL-m|0UYkLYCIBL^D`MQs*r!ucou(3-%r=cXTLxG_guSAv^LvrvDT~R zw-K}lWY@yvekLDWr6bNL-?m-OpUs=+D#Zx<+uqFvHp{jG6dm3rrW$=?xJ@m^)J7x) zGH(AH6B-aRbbRF1(D^(9!LYPc$deo}Sr?fFTYDK`i)e^-w|Fz(7KmBy%^K7R(_xWnNC`I$&LQXl`YSt?p$X!3h&d^jMG*5#oQMthU`w|E|vQJ28S@Awp>_J-j<5Pr?_*BC6+2Yi>CQ=idL%ng?ad{gsm&5 zJ6~@#U3+6DLO51hQIkhI-q%_xL}gU(%tsI10l>7aR81sk%dJPd$yQ?g_vakF1(y10 zUkAn@8K|{Z%}4TPZ=aRw`fTMy`NqWP?<*B^0Uzfq9o<#Z8Xny~;doNRdqVU1TP~<^ zo#^*QGMRVSIgB@l9AzYY3VnPeV%b^3;yB5p?#pGhw&rv@d1FXyjSup-{X`Q3grqZl zP>U7w1C_e;gc@_Wj>x6N9F@Vaa=#@ay^wd#hr&cnzv-)%BcHlx6o)P+`Tj)lJH@-m zs9t@Oe33V)W8Q>4$^Yi`c#%pA_4`hb^1xz@h_TC4#k^%ctk}c~0#+V`_c+<nyB~@eu4i0RNgCQ;uaaN1_VMvO*3&;VI2XTH7AoN% z1o+=?d(NLmGKEm$OtQCpkJBPxam;Pg_Y_g>kKmQ zZN$N%q@1IFsPbnh%~20B3wbA;3t@sD%MX6&huB1CKRSVYji?y!!SY~>5u)s@ZZ9UZ zMRnX3Y+3Cgkbad;e-)Flu3@iA<5(2Z535o;J!#P zcX|ltAHAVaijFZ{7|eP>d9~O}8JPDq9_SOAk6gQ0G@^DuAcztwZr0bvU;n5)aOS1( zpB}^qre^8D1WJrl>&C{0yaI+BH-g?8c5vd08(Q=xR%N+c%#aDk5COunzqI^0@Ktawa$SDfzRzVz1W7Op}PKZAs-qeqOKCU4Jro6kwv81e-N+6&zv4kvR){mHy*Cq~`AU zjiZCQ|I1VrA#~M^Ya_O#lU&Gq)5M&mGEPP(5|&J?zk5_>549H(GzQ$B{&R+3vRbj@ z9xev6;HWI;AQIH^oYg5XCCyQ{EuWg79oGxA(dLI3)VWHF1l!3jPnmg&a$uu_qIz$e zuOGg9<#4rR%J0>SjT`d5NYa}4F>I{-E2i8Wt77hV!c>;PofZ>eBP+QP0ZaZxnR~-+ z*b4tF&Rf$YMQOd-A<7ZFS67^$@wRQ99c|?w4$>+`U+CS;^7GsM;Q0=*2;`mB=1)sT_(`G8``? z$n3h|)k-U+MFb7U8te3oJtsyOgrjz^Uh(ZHMyTEu!iZyXW}j5$zW&kuaMn;m9-=ob zg!b+AC_km4!+dJ~B2ZAS!jl?<^QI8? zir$Qic-@8@V?=dGwvx0+{T(XqYD%0zd*iBYy98yqwB6a|+zVce>?x#VF#o%Nft3_ewXW=P=C+-0H#;!0^~t<*&Mt^@rz4F7(Rj*5k(RGY z)T3N3CKKa}?Y9FQXh&UiD%HMTR_h$y|4iAitf6P_2DbUr{ZSc$5i09T9rOb);rRSp z1KzpoU9O?OfU8OzKWT2C@#JX*i6q9bt#VyDH*~hbzDt;MUrL0MfymBn*?V#J3m0iz z-tUpF87VDt3XtxPAKT)}V~N2XeqQG@a(Wuv#`=H8X$Csyj3(76BvXI=rdv@?+d{>| zb&_R4#n}|pD1Et%X(p(~y1+k(&gOO%!m#A(30>S~_ru1ci@4iAike=<4L=Gj_JfL- z>(IdhfI+dk84&6#tr6uNBNO^c^^1}muK!#L01^I8_=P(<^FI&Rr86j5ar7Wr}k<5n*F$9 zr(2JCf4K$DjH7Q_@a1%S?OA4YvFfPlk7N$Q<7H=?MI>rW;$_%yQr!XMDdoTn2zCaqrxt^>O;Ip6uHJ~ zAW5_nU*K2T^*oPuQ|p?liArZe;o1{&M#%`unI_=<9JxXk;e}gR1G`)4JwP1<2W3o^ z8^+=TTSe$hZ4p-iLg-JPZVFQQ<)T{GLW!Zg-jxg`@8CV(v6*K%Vb;#qF;aq6RyOJG ztw1SOH%=>I=&AN;P2$XF|8Eh_Aso>CbvI|cbtVo)TBza2FZ25t;@KH`Y`;V?Kn7Rt ze~;3`rC*tUkSVKE_x8^_9gf4$OVldjq!0d$X{14uTuL^tAtLxx+44J0eY?8*(r+6` zY>*(g+`ziY3c>kD^>~;$LQ7E(xH$i5Sl}eAZoemsc#raN5k4jwI3d>kc9pzyTe`Vh zdA!)XKYkPAiL8w;ye7}mendnP6%WwbmI<0!R0W=%qGOBMjhr5_Qa4g}L*H89h?981 z2bn6u7ziS3?)Rd$1?Pil%jvV%PL4AItYKV#?VW##;YOyDTSGEl;~25NL+$E!*Mq`Q zHfM21W2x?(bGnlU4=oB14yyHh{5UV1<6SnV5=SZZA4~L9M$L~L!=u+XRfzT?ju%HH zM&fK?1A)X{Z@j-WBh2bD?JKukMhtF zdvKe{l*#iIcaQSYV85q;ro3XamoU9&0^HETWOv77F5-+uI{Jd-*Q!5s;T; z{(j7#K=0zMckHeccQ8R)T7SFo>hF{J`O_r+HhJ$3oo(K0!!i1w4uN?Cx%8w}FbgBS zd&9p1<`3kO?>+kOK6pTC_@9T}|L=bP=Oz)33_8HD(c%3 z;C$0E4okv=K#p`a(MV4l#G+j<+yT{?2eR$wKzjJ24B@*x57KYCC1#eMEs}ZE{%>YE zUOYaS4M)$Z^{veIMO}6pse}WW0M8_?3hR7Z*=vE=4lR$-B*N-^+>}}8Vtm-DQ@=-@xXa7qlZ?MHSldZhW4c|qm zjo5pK)Pd<}8O&wIr6_wlD=m~Y^;)>p52Ri$Uvwtsd8fmi5w*|hbL}{lDvL}P6$5$A z0O^KQw@#a1pX{2;xB^Se%1>niZe`YQoCc#4z{?8I-@wP|VT8Obr9sYo(R3vx=+9Ti z=4nyuu0rFYUj3lWzRINrTHZkp6)7ukoTp<`I9sNo76N3$dqFZYm0fjX;(>4A&Kjyw zmrf7?@}69|NKfasWB5G3!Ep*sc`~uemKt8U#o<^#L zIE^>dr<_FvZf`yqe*haH0pjmw&_wOn{@mr-rw8fQQ6rF)5z^((!n{H;9) zj898WotaSG`qQL&->y#4spjF&cv+BDg|}ToaR)1_uTu%x)}h#cGp=B_cdm=)Ri6X7`dEv#A2wwjIB-jErv_G=c5`uyB3Du{;C+;+?s zoPPlAk!x?$pY!ZgB!qhrie}$#ro{#QCM|KWTo_T+(skYi9ff%CpFSjwhnZ zeJL~WF<-**`cdnERb%Oow?avU_l4r{HVSnsy|CnwGpy&tDis#^ z3BRKr{mRJK;$q5Q$zlS`N(&a8nfpErO=n7~ry{4+HLstXvFC@ju(C}qH*u(`SOu&O z%mVI9(rf4KbM``3obmii%qh5ck*#Eir}5Pi7M$DHbHO!_hi{>}bzg`}c$A-;MpHTV zH$AC#mF4Kk346RZM45yOaMQ41XPZ>F9!5detBAP|`8T2uoz*#ZfyRa#)%{)BrzgO< zQ*?Jg{<|%{yF-dQJjY=Df+teBp(Y;YiJ0day0z2CmCTS(+WwVF3o@{(wht&Zj-_p6ka#Ahwm1^3I$vy8~zD z{eansTJDJU1ZpT0)lJPwW?wDYvFxW?2 z?v!ux3aY@U)~;OeW@%%%8zRwmnWKKpUz-(~WKg4w6j*8SuzuQ55Pq~>#h)U!*m~%- zuDUntJP|(_;a@AZKlPceE3EAP*FP&8;Xk+1<$ra#zsPe+f>^`+nUExjCMoV2d$m0e zS1(ZAU5}c2zOR1N|0bWysPikx=|~qe(5~I_4kztDs&Rww@He0NWp3+sBVidyaG(%C zJT<;e@(y=qvo3CRO&}Yb0ET`*2L6OM`Ya(T9*lgk@ax?M9H{y4aYa1zgbp zD{kZvuWx6znp&nur(*M=M25u;p4*bGf{W^Vp5o#Eji38(edPbfmvRQk^dt8qc%qsg zl$%LxCoMl9R+;_%bK#0z9qZpV)1=_?dtUaA8Y_-%Z&&qmXm}so^WdvZ-MD9a9)A1( z)MaJzK*Iy`yJY41_0TNvSVs1dz;8@!f?RoD$RU>AC`iL_YOi41C-%XCL}YDn{dqV@ z+i%Z~Z`TH)EY9YEi?5UCic0eusp*Z{ZAq8rb!LUy$|UBA5y;v-NY#6P{;bcE^F%nf zyWF|>qXjC@dTWYT)4VY-fSxDWgzVxQb9XmHc5Tyu-fMFz3ep=Zq}&VW;NIO$>)j%z zuUnRA_^jma;Bb|b1p)wtUJl^nR_N*$NUJ^&eN_U&yHjwSx-lKsH@>?)o=?B;2{>Iq zTIDca+PI@szrBW?Ey#(zkVnAN-P@idlP&j4 z;D?lKymJjcsnTt_Wo6!a7hNy}?A>Up2EYhiQ4`w$)4+u;e6ww@s0|hfCmkL+f>Ya` z)sK4a3%o(TW)#o+i=}m+nDXgBA`M^;P6v^Im7aGQK;xzf6hZ#J_Lnq>sp7?4dAtyq ziptXNR`#yUM_4^t#`Hp<{Rx{5px(MPW0z{ZLr&HVVA_P#{ZU8L&%Bf_R&9OMqj}gl z1PXp@T5!$e#dk}J?Hx2^mHSm&&+F}eACZ4v{cSI z3ievJ2~qsT7)U%eF=mV{GJOEL+sF56y_}?lEGIU`cd$XX`||Zg+(No(+rYSc?VLD9 z8S@;n$~><@`$KOH65`Bppsl!FLspIR)44!qKvm;~PQO^}yVw9Q--9&zM!ds5 z=EUF}?HT*DwYy$M#TvN$S&(C1&=FD}7F)UTjICV9nE1$YV=o0MO3U!*=Mw> zhy_0Nl$py|T}=uM0N>dw_6XFmCaqKOb@KjeV8!cxf31!A!UhhmQy8hx-~_T(5K)w5 zFn(2uAF!NB;Hnl5=v~{Hl7#1CnfxkK<7ld~k41Fy5w3r2VfAkoy zTkm{k5bX?VwP-}O$uy-ih0?QUfx`LF0x3U`=~>Q*x`P^ekJ?iVi%(~-Q@Yed+&m?j zx+SuAFN#%JvkzGw^`%4licY1A0py=&Ge2ll<0caHRew?2=$+vH+p^I5*v!<;@RaWX z61Wr3Pgw-Gukb7nC3(Pu_Z}MR>FPTAiK^>;Y>5%1u_Q9N*5~W zeCU&JaDT2DkY%S#Drf%TLkVupH}1@r%uCZls=X(T#n5A;uMdgNU+6u~4Fp4%qW}6q z1RUBus4({*mMVVJ=70LKlPB8f-hu8UX7i(YHBfubf+xzZ^!&8lTXnkNSN6qTFYdJc zKmI_6ljt#2^LM6EkC}FDFjFK6r41N>$8aDr$)^ir^K=C1!W9|&PKm%OyPYknCO@~| zJ6+K1NcR<>miA_Fegp&{waQVkBI=iIsNYy1f|5Yy;w0F@irWJXP~;P0ER4i7y5ioI zcBQC-xM2o@AI?wIREtW7o=|4oGV-k@=7J~X1ls!wr-pZeEtuvtn3aY+$Mq#S8(K*% z>6Z7Huf2T_bP0rUdi3a|MdI8EG#jpVZxbPalxTN3E=WRMGDu3g#V0PANg)(pexE1@ zUy`JQ!@1_GVa`X7`LVTz&iRjeD;5uhIaj@gm>w@U^%PGjZyju_F z0_FNd&UIq6K`P@I4nw&s*fyxFl*Rf(MYACX3{kHJ&b%1t zuwZvU)@{SqKi|Ub+{iq&5>wT+?BmaLA?Ro!mc8#C3GZZLqg-F9{RXXcQmw%_$lJ(! z>p&mH3;Df{x(4$&2U)OzjgE#d1F*W=JE<2Hi_s#VhWQiLwOz|YMr zUb(-9E;XJDaZp?t7!aFjLm9z%Jh3+SLOaQU#vh6>4s9*v9}5q*il#RfeRF-|jEPgi z&v(rK`lx5@1PUn*V{@5s_wv>9(lj8@h2X>N%%uRWdAQ47M?~|b}A~QutlBr~z zxZew7PW&XX>gE6#{@nUhAT52lqi7BlcE3g$t=hTy(nq}z+E};#G;hE0( zeqg!DknP`4=xUtBBYMHQz$)!eQQnV4I!*wpvK~KDtrlVzg(Vp{4M(cWee&MT> zE0VU15z(hDH3r?QcD1p;Nmt?w z{3gSA^C+Flkd6$+3MLwe!ksyt?K{%m(4&HPn9SKqmf-ai+gNQt=fUhRjfdH_B-16+ zj&uDV9KVJfn(e&}xit5!!2r^dYjn01OqDeQCD}T#rXmSAN8~PDqo=yhAX~t>TJ)0n zr!!Ch>NQ^_gK7VM8HAR?vvkyD<<@+B7Pw84j*!s)q?2u$Nh=ZrrqT#O_nSQw9R;mW z(m{5Gw?t2N(6Xt_^4EAK)h=R zKA?(1v-^F%3g%Ps=~9bOygx1&Z_Y8_Rt~LdbC=+tpT6Gv0zbMtTq9viX1%wUHUM2^ zz4Ew`+I{w{-vIa;2%l;oJ_ARRGAr`Dz zUL%fHerQ5RdNR8b@$IDN9(6C91iU4e7jwP;{BOV(D;dpmRx*#~#LRYzh$MD*Xuicq z$pe@uyUc1yd=!0U_0-d2bwj1rsA;Y2|DTy5pG z1yyg35BRIQ*+OL23uc6|+$}y_1?K^Y8Dudk{D&T2G`ts~P?}}&G`KZjfSc>^Fv>M% zoPWW@ZQp{ffOVXyU=ct^`4oCNoOom~D=rxFj!IX$`&XRVpJdR%OE6d>cPF?sP{pC! zD}BwpBS-sFvmx(&iO7gZNcvB{_c}ekXucW&D9SUANB0na zZqh$Vg8!KhPc|H#`Ud*LJ#2@bj#ciZYoI|S(eqD<;6G?q5Oxam;&VG4e4qLJbV7Z7 zZlI_SL>k!u@tFVRP5>P4SNA%j5t|=;1Se}^tnn|alJjPnbqs9HoCvy9IrBUi*d6b2 ze~RvF(Vy{j0PXF63vA!`G`=N4M>@yTn`{3+4dMTf#0~#nYcvZOzIWvfAUV{@4Ho)C zEyw^J=Z}|Agn_eM3adwK7)AaQ`ZV|Ez7oT<0ZiX*)-&z&PjplsZSo^_n!j4wb&n4_ zA-hMAeX#rg)rYRS(Fq<9gnBLFk+m0|y@@)kKj0=ff4QJ=&8`xh6R)8&>^S2|8-4DW z#=_|bzv}7VVa_n)j74qU@qW%PtiNPy1df2(VF|#NKdb+&<7G^H%D)RP1EDKmAK3tT z2f|tGgL2t(dm*lKRCDiJ0G~kEdx_`v^Z%_2F=aaGa$=e8t%)nY><8k!FIu4Vw0W&Z zcgar2=T_US^|T2YGGZUCt)X3G_X@gU@Z zVCXCeUyEfl<7Lm$ZTCYaS=xe(7<9|k_}uVk2}=b#Lb^9C*h@c@#N@CLgt}}(LxUO~ z`Eea>koFO?^2H~@HzrT7hc#OJ;%j}=D`l?9S@Yt*9L&$XSdtPy=2wbHg|+#BLI~fY zwFHlyVcYy%>s>^FljF31YMz~g04@4l=#*_7)Qcb&SnnZFx;WY*uS~!}>2(yH0V>5z zH~>SiHMkl-J0g&^vG0M6*5Ayy1x6=5?CsvM2X8e%QcPdlqUtVV=s6%XXje12R#^f{ z8@87*M!;I?Yxm`Iyb*9tiDy@Qcxw}|kLfq45kV>Opv)X0#P#v%n2Ol;_^vWMnqAfB z7J74TX>A%kpO?#}pAir9nXILzhXd!&SED!L+qj(vz+6WBSYR!pCa}#XoJ0;B^QRED=--)G%S_D2($kq~>>gCU&^YV`m4z z9S=ge;$?b%G4uRlAq(!JX^9TKRI}4#DXcRQS`^b=4=A^lDC@0L&_7=ef{SLWE@_~0 z>wCV01O2ez5eG>9U7@4z{wRosJsWU2N%|a(`4jKJQDwEdaUJ4+?i6b;Z7V`eM;lvZ z+?AZ1hfJT=H|xj;G${8S`%Xc{0B`yb3b%8+TNV@jgH9Y7 zi-Vd#yBywI5L-aijz!(8qje&F3+0{tGpslb;N)a$;Qz%L5;|SsT4+Ew_eJx|C`H9a zytbLm-t~2GXr3=MjbO;5DA+d6hn$$U1jOj?#>qHk=hn^}d`AqZb%uN_Tk=6N+&s+E zfJsiQd~Cce$G7A+|4{=00sG5Xy8M$D8ckx*h9fAbjV} zic@%SE(LN=mit7U;-l71`L|Z6a%Ws3yoFzbw-Qg`ddBZ9U{hgXHg7^+`;m{?lteq6 zbLAqRleP|(SAD8N{$WtRv9zD2`$w|$Y^kZvv>KCTu9jbq^Mc5t`U_B1wKQmGIxLO_ zwrz76{Y`acL=P|38X?i*mhDGMgj4aS<6NvP2BCu=omy9Im7m6KgA6<}pplS;qz10! zoS%LBe1rq1a<=;LGs1N>-sZJqXy#}1`diLA%G;La*9>3#^wpryjfv;VUC9{lEP1uP z_-sn;u>^*1LhN?Zf{H!l!b~P8L<1A?)$5n)BnhP6fBg!N^2(|Rs;$snzjWZy3o6KR zrpvqNf!bZma*%Efw z>6;Z0iLQon{e`nMmJr$L73PsGl+E%~bMuy#pxgJ(p)wh-hNO=dXRx5)5WQH1jeOhD zPzGI#3CM-jKfN8lcH^LOUFaXKCM_n;l_~swQN$`=!_|ooN7T2{19rFb7ySD~_(!Oj zCvRPSClAeY8CBrE!`u@(o1$mq!(bybei*q^Sd^CwNKCYu)QsJBwDlt$x6wB_lEpFq zte3-cN~ge)G^#53TBz{ORxD3;km}0hba6#qu5S8`68f5*{!S1PJh!sZ_k7f8Sf5*! zRWM*BF%ww`7OYnmj$w?gD~sJqF_;_qL@IZ7GAgmg)wI<7j8LBI_af%4C|Mix->dQ? z9{wto?E<29&68(-;E``$c~8g4lT;XEh1mxS{t6AiQe+FeMI?5q?fMfGX?>Z51#{)D zU%JheI5r&e!i1O6=PX5!nOyoC4C+Bns(C#4kG79fVWD1q6^mIczGx&|^x8OWY~4#W z!qhX5^=0i(*Wio98Z}Vz9wpTg@%~HsHn|O=9JKfgDG@KrbXW;a@g9&0W@1_i=@Wn* z4`otb=^w=H7ZW~Pc0<8-nBnWoY&?~DIN3_hU7$Zkl))HU!=QVB02uY|aZekk$~(?+ z=gL(>wjFrWXTny0dlGph;?x3%IFu`oJGC&zKpF+9W;KbZfhUfi1PRxuCBi}L-WnpW zHkPgntIq$Otpt66Ma$_81FgAxfyI`GrQv$tmm~97HcqHTT>ba!nt3i_`(nEsVE9!yF09&l5L=Lym-QK zLc=>0r_$YhI@9{0`SH+GD_*sJho-YBS;IYuM{PE)V|7-3W^H0?Ifb$HiSB;aXYB&o zn-~wQMXf2%6(E)H$l}LP>5mu8RL(`j0pe z?peJm0sQyci9AAk>L;0f`r1XNrfOp!S5Bar(?S;picEQf`ZR8B%$bny4_<5;ji3oEu$F3$T5v_%*>#NhUX3dV;EII#7f~7tNGj>``Ua{(z_}iS`mC z-%$u7m@K`xRR>;G5rvfD{E;karSvX&6abtAilY(LH}KeBYMA=F-A>gm&pvqoh( z3%5lWlyww}9ye^Y`LjcdXbB@&FWfOr3mvGt`W#~;xa`}4C^!RQ5qV5jGgmpMZ@oQKNs4I3KbEpVZmnXD1=CcLx zJyf_WArgi-y3>ZqDeM=jb*5=g3O;Idm=A0W^KcMYMW(xb9ewj0=0qCd+_>QVjn}(| zs0rQ{!P47Pku`KN{MOw=H$zTNN?Ge$nKgj?xfa7{Pc#quD}IKP16sINKhR`Q0=3_&hndWPi%Z{{QyuFxuK4!b5F{*%-)#`IVl>F zMMT#oyY@WX(LCiA^^N?IAvN2xti*lz9b?0VyT=-`e5Pt$ht3qwyf1-12;dIv5{a5H zAuUHMm;L8Lqtt7=RRJU9bhr&#Hfj}y<-8%47p+MwInmd#vHPTiKTS;yHkyfV5zvbL znh$@H?tg-I-J-7w57{4SajS|SC`nbk-`mjWZ)KUTPeMA~jc^V(a7D2?%dZ3e%qx$5 zvfMUS@zHlD!h!v9SjHK27^#j?h}H3ax^KPN*~!;OZ}PGFpS%Ah3~ z;h;v!CF_(Qo7FyvQ8&{8rmnAKc@WrwBbX&&za5MB#dQreFVWvs2xR;hR` z`%4_!3A{(=aW4pRCvUpO`kf`L&(5f};%N>>t&?dq2~0qJm!kc-=wuY< z`f6U8YD;=o78E{CQmK?9rLIX#%{aym$2&26q9>372jwGVoER^HQ)Pi-g4agKDTRBB2^-l}S(SBKrL?wVexn~w8eUW5*q!w0q|G+vnjdPx4$Q`h*5vT%LBqq-u#h0b`c}!_60C{4>!BkjC+r*7 zGMQ&bDu++ZTqpjxkH&4md6MXXjnII+xnrk6`$LmrUS8cGk~khL?uCE*ov_ zWNkZ6VfutcIfAAOnQ|8f92v1Z=Djp?4f0<%L_YT}f@iUq)${o52tW7RUtJtU1S~do zL|V0%fmo#5u*mX#quFHF@w4djw8nc~ftX{(Q)8l@Y2<0*V;=c72Kg&eY{%$dA(qCn zfpym}I_OWb@=jM1_MCU5xN^u0 z%f~%*`b&(8_pgx<*jp*E%fXa{^M< zAr60n98-73tv5w0@P1Lf_O1`G2i$C`@Ex3_+I#|+LCzTBV(2N0y!|%`;7~%3e_C%1 zWIIk>jdHDdcJjhAkrq!XQVV+~N`4Ug@NDEqt;*?_cI9CVw^jz3f0nf}<83d1SGiD< zwWLG7=$(*me?gdb=TuhT-yaawLSwW;{)O(ho-Qp!r1m|`g4fMGZ$IA%I!2nzf4wwx z7=_o%HBLmttrhS$|2J8ZrGSskQI78RRC<2asjlB0ezw2EtQNU4J2>SBK&_lbS|~5^ zTa)p2&uFhlbD%-T9J>Vq&C9Q!-AUK>TUBS}|MyzK!YnO7RuP;7$d=9gKxRreeAlE1`uWS9(X8BPY2DnD{KjZICO3rj9M6Zs)a^d5d1W+ z?z<_FTe5Gl=w-%jRjd!H>lu4k%0M*|O4#rK2_b^23<~xOTo+=nMu?2-3Fv@s#LTtt zlZK)hZRFgkTEFQ8C<|@GGb6M5^;QT;7F45d@N4VJ4K^1c7cddLWJPfD5V!>(iKIdV zSp%O-l-aL;{o?|YUU1y*+TI{{BX;V*8K5ntL-ms;{_y*WNMF!Ao)*at`yK9@h%re* za0fY&s|B>p0jv4OkJmo+7|WU~%!G6nA=Z&jHaxqvth3!fiD+xQBzoW3Zkfsd<}w!I z(2-PlVlcKGoQTu8yED062#tVZaS?hw82||r`rW6nw!}@RMQQyB5*ar=5fC9RO!Kq*dcJEU zTmgB2>{KD`I|LwsK)1S(Cz2LzHpq(nl=9!q9yl6MEk1gvY#UzTo)(_M#+IBuCZ@nuRYk82ZBcR9{#vR}qY92>M9x*;utXlil{U zGk*t&CnskASW!fb%xe3c0`P(mFUR9#9}89VTZYvE>yD~tf~V!kOGAN1ecOgdk|$9H zleh{DSoE*Yg#YvAr{}ZtLy2tO}X3@mW>ry{)W6I8 z#M=W8o?GTwHb;-{4hYtvh*074O~d^~e{YsAwU9T|KrlpUK&Eayw$WA()Q+nU_0r8g)}xBfW$FAGk!zvG}ta z(M=^P)NV33NifR*TDe}Ql5zx5y7 z!2%!JfVeVcKzateCFn1I-MdyW?#`CkQQ^5dGopR&Uk@ycdX(}%>ztB>YUFRLQ~kxe zys{Bi!wuCz`wgj%|B8mx|Hro0|J#1nziqAmwLexzVA)kiTz*K92JzY6gylaRe3KVA zfgOlIPe5kJ=8t4)gSGNP-*Y4v)AqGy4ZOG+NUy#(1Ac(4?LbLB`3ZdUCxC1Xfxte( zcx5DerV3Wiw$vR_=g9CzRy^5zlw-sYHlUgNHv=&qv0s zI1QhdLnQp~&%50AyjiyAaH=ihw{Ap@AoO z9u#}}p12`cEnR!6L*`?QJ>O3`{K2y`&aZf#lG9mdY3rn(;R{e;XWI;0+3|gPsgJG-lF}VH27ktF!oO6xJBoYCmrYdFrgjz)tmc(u+ zQ*)m+V4Z3UP7Wb_!qptZ(t?Z5RqzX@pR3THOaB(m-mXZc!vzim^UZy8K`oL6l7Xix zgj*2^f?YXxK6a&R#y{I|47@#wGJi4D(Cx$uN})ek3#eGLyn1XboPZCzH94aqK4#9(W7HoBc= zG%l5c*fSnD$?Qqhr{jI}sV;vaW>TtBq2cD2sc zXJ^&~WhQ1~Wt@4zMVPX~vFsbC)uD5HoYeV-Mo%}v^`rLCtqHdNnD5X}zhi!l3!kBt zc$Z=-Ysabzs{Ot@52%VoCVY;l@? z7eh~Z{Lt|&waZ9aBIK~{D>;3pW%JFJII&QhQf!#V0A6HfKSkD$`#>Ri$b{>a`7R^x z^VQC7^$HN2%U8b}gm{T%3v(cNK{X0%PeqG6lNE>tId?=EwzcxF;W8iYemcLilA*|H ztj#q@6~1&Z;AKzbjv!?_aNXPv$00x^$Bm_t02YF9t$<6qq&#Y_;W^*hyZ)t}8U~-| z3h+M6r9HpJk%HMVtUd`taaTtioN0(H%4O8nB(&!yl0gS zbQ#3V&Mw5Y1$;vcKAmdZMjBSz?h1l|b9wSy&0e+B)9LFUA6z?#lkl9J-WdQ|6zRna z`P>Q50wMala!HhTXS4+@WVRYbXI%}-%H1lnY$iwRJT10#E9P?e7ZMl5ok`q= zSu=H&q>Ac^J?rr~Fjr)}V^G}ABY$C>teAQm8cgEtn7qo~ZG^%%kHpACw^x*F#`5pw zy-M!ZZT5u&(lnAZ%y-xPRiHAy@nCwZyC$ydv<7D>cH=Os)H-4r8nH|bNoMeII4CN=S9nMI zr~1pZCGqF*4Q!$qnV66$NBicw(=`cx#%CBVPYgG0p3ZX^x&NgOqpQys*f|C(v|9$W zKk=-0kE2v?&M3Q45DV}!leqkC|Fl09e9=L9F_i6&k-iZg7Nz^VB1(^vhy%|$`{;-f zcJR7}da`h8I86V>$}*+D&ZqX4nDy;jB~r2AM;wGhMmrd864#a2Z!UYecC)uWKb#(; zg85(|(Ck%6=DPlvrF&U)(zN#=>V5<3Py9_3{ypXDx$JDdij4d=xv=kz0fy)xm#&W* z3}U`9{roYMH;TmG$aT2NDtxJPs7y}%#E$12@=;m0ZVtjvkY&Do()Gz%_MU9JxH3al z;^WG&Z*wsI&IuOTLMt%Ow1sf5++Ai&U{FtqDaTSi?GSo(BZRe#Q{}?)pj&YAXV$xu ztpsDIW=RYiL23*EYCrtKAtd+xn`o7-`M11R7bEOil80JT*>}tA!6t@kHiV4azJ*`z z;?3@kR!@Cy^2gm}Ur|SwgU*GnhvkQvbec`lzS>Qt&zYBQEAm*n5QLJAd;W6S;$SUn zcMdKd=Uy%m`Ieg<^M}ng@&XrHHDi+$eRmg$XPVAb=?mB1^4U`GY#uWw6oB{6-2DfX zt_n$STnHVU+@A#fe*8M)s*vlppUYNeRRH6O`2 z`+??VDMls0k@nluP}Sx-owvj$!s(buaWv()kBXa*mNR>IB7=5Q2o9vEHV4wnlBhpD z2Fd`g_z~LMVppY=)do`y&oD@OP?>X^b5;zvq@H&s;UWoqw66nPxH__gBMeIHN9;L) zdrRWhD9MXpU?e_v_8B?H8=fa|s!v{6;N9)jw8`T3)UQVx3${yE9oJuGf51QZoM^Ue z+C-VVytD)9m=Hyg+eQ>JeId{B9f`@zu;N9>w6+UH&=tGV?gV3sZmia;axi@0G#$h{ zfmLvh4Qkfr;(OV%N9a6$rJ-czFV#viO`1$8`<)*$d9!6sC|Szt2&Y=0kesh+U6@#F z^uoU>wP{5_R0H?+Xa|ImM!g)1{cvmp|aEdM)~Hd zH@IuXns-N0Ro5UgoBYgDrxGQlVr9-Q*zS3{<<`vOdu&@b+_+t4l7=$kb-35t={qqk zA7L9ttrw>obL=4w#LpE4*}Kfu^&j$m`eM8cKN67**u7lXIe>@5wpIpD%qZJddNmz8rNdti?H}UH`*%K+o`U8K&bte@W%6_eJ{PU z`;YFaxhw@J7M8j2%@GX{dG zyF_J4+uGP2yi;gxX|=~4g$!4IXEpDvW3%G{fv^=Q!u|N+N1!K3&$*?` zU8K}5PP*^WdrhF2eXCJ~Ir`m>G{ajT7YH&3Fp-YV5u6SZmEAzi(^oP0Oey@`0dNc~ z@?>2SrV7ZX!==~DK8rqa9c@fZz6C26mUbW(V{<9hTOSUpeciOgH_k{cX!BOo`C%6l z;YfSh(DCGe|r~%&!3dO@_V}T-tz#;MfBKb$kRAzRo`^WA@4K>GLzt1=f@t zbhnThf3DI62Gzkhyw`pqKSO{z59qh?!-#hf3s)|uc>%(h2bCuSQhhRi z-}#s>=CZwC_N{3hX2bK{?m5y+HPK_}q$!Gq=v;*}eDuvjHP~BNYYVE()E7vQU%*2uESOyX109J5oTXRZbDkY!`4o`QtVGX$S1W=iJ_fk3w zh*u=?aO_CGgy|w#hRHgv!9v>e@wDcBIkCD6NS4o>iV7EP&wiSf)U9kZg(<)&F9MQ5 z=GN8GCAmyJTRSKtfy;enGSW;SOr50f10qMgGpHL%H9CXbJ5y@)E|12`fgpXD6EA7o z_o_{1HpdzM4U(y%9jXI9ptohtd$4+d;$mcO{!5 z^=uR0Q~FB1>*?b^iusmlujl*rL;SP^VCrl z0Vs6MUU$6d5q<3bP3l4IPax9g8%j>q-=F#d=GoAyY~^*K;(?3iNybQi74e&^F5cIW zvZ#X!{AkTRs=>p6LGOpMUsXydaw(!Lv_r*^MMWGu9XmPkHUp|@VM(5zX-HIWR9WqI z(EKm+lq{F9;Hapv>$&StbauqSOo>ZNbe@Y`zdAV0UJv#|sM;!~EbfYku-F?2STfxU>_NJNamGua#?gkX`lv{5-jS>`p=#@7Z_}o~ zvO)7OtB6uda6Z}*9$J=>cn_*gibXjMMUbIYz45JlaCNMYin;^un&OhD8ue?e*C^ao zX_T{om_D9b_P81%3H2;g%5I9DpG`cVt74gqE@X$UOZb)Mw z!jam1N%M_C(W7aN_YDWV5PcmGE8U>%cE$DWV<#Y9U!{&LJ)0no%HHbF2MzBM1ceJR zMEvN&QXfg@L*&g)mO^Tht)f&q@`yWVK=`;8%Ho}Ke;bxwB-+=}sd`1C)~rr*&ieER zk_2*}l1c3g8q;k~hA$i1cqlP&(mpH4{FeFz@z20$sGZjx4dL|4#gGD~B4PS!@Lj_1 zG3EL3Z9aG&djII18NI4Mfd}vF3Ao{B5?~mOp(CEzDS8SUK?4B>3cwE>tn~Ks1ckQ? zX#||`%)vlT`w}E}M{Hg}OCl$>s;8quGS>QF_2q(WFwh{+)V?nrB(=`Ci=rBZ9Yi#o zALcxwc2(jLl(&0$v)b)KCz{?aei})Ah?+!f>Yyj5{~{D* zwu3#d8=@UIzDoDS(i|Y;of43v*{EEaf(zlfWv^YZe#hijh_cMT&vBcwN(P0in~8Kp zaU*{08_kRcacscU3z482?scD?AbS*!HE(OQt8>g^2o*CPHQN&?@jh!1{Oia|QB`qC z(Bv`z2zcWn)(~CIn1_(jV`RpABO#QN)2rZeer!7>d=R$uQM(A{IZ+TZ&1DN|&>Y_>+M((G~p%5YtU7*eUbxZjo#_W=|?rV)|HRI?N{Gv5vDgicK$!0miNyQ08U%xHMDY(UE! z@e375`XnAmV4=^3Mcn2GPABmAGBf0BKMIkJ&qK_jL)(**swupgM+XCc&_~u6xvP*g zHxU~n{Xt@_-cP5mAr`Gg)PLnfCVx$wzTvj0|C8DF3a%pds^8DxO?y#xLeOf%aza=j ztq;RjWUh!tLevKKQOK&_&OKl1i#P200ybZ%Xn&Yi*n`;Dxgw=9RWGnGks-{F>6_@i zjZ2~+B4b)U?=H&Dmds>jUrlJQ;4s-;ZIGezIpKqJtGc20qw6(LCXWWjQEFR)iqf00 zW*N|LB?cr44{~2BX*-=9IlUpRZe2bRZE(KMD&lf$<*jv&ktkpO28SnOuZ#C!%!hG; z7>t`5x3>=`G36S`C2`kfNtm6oZE_N|V~DUZwC3o)Uj<>N!4VI=zwhc&IJco@n6rwy zo0opB8%F=3Y3QXomCpb4K>Ye?rXp%oV1N#N%1Y7kV~F2ubfLQU2gW_sGQQAY_bS7o zBO`v2&Y*mi--$Uq&=AhvZRfky|8_mzGf%tRYzXu~KF@0R?(~pI20DiuHb%di)}?1| z(KF;MAU$N|PSCWT*{nz$=c2mg(O=O1)9Y)#R^hE(UWy6teI)&+PGmKfLQVLsrA)C& z8t3T3W#aPY>(!6#6Eb|YC(HyNTc7Mp9@A0a|G`D#vpr67iW+0zQW{y;aT|!2Lua+A zdInJk0bocEqhgO)Qoi*;<;OmSyNN`%Mew;ApJ$g_xjFO1`yMwJ!jvY&S_HatE%63A z@#%d#`_t>Ey8GVFdN(u*4X~NYg`y;it>o0*a=+!rO$!2c89ie^7D@$S3dP#94x^I^ z(+BUFEax?sdmMO289I1vkd`c2*?A>aaJ`rJv1)l-L%h2#AgFR@=%O#57WnVTk2#VXSTtCc=a3!DSxaM;+ZXYmt|4ioXhc{xY}t&%#?d=sdVI(sI$NMivUn zA|!#Dn9GCN>02#BqAj?vwKTQLej!#Hc2DgM?LY5ue3VL#=NNMI3dD~Yua*lWCojpp zmqAJINip0(^{4L4DA?z^!@K?Y$xUb!e7*? z-on21qL|WCdLCCEA%)5=Vh`GRx{+1;NAI9D>9Dykb>EXskwIC89gPgN#3WDSYImQm z`3;G;TDtA2SrQ|1W*Q1L$-I)ZLO6cKHKrTMSv(Bio42>as>pMncPHlbgdOCzq+`xp zXYMg(!h2DpGT+2VN(W1Pu6HS?j`6zRoHf{?@eM~yQUVWou4fgoTRfX}e#l?My*kRuSVYZ@^&1rpMlB{&X$@?%J|3dBg`}7slR1^( z1daz#%yIy1Bi%M8=?ofg6PuU9cLa*n&;4t-g8qw$g*-G8!&txfPTmH)cKvS%D2`cc5+-OcRvA+GAy59xf`3>FV>a|qkLUCJT zE?F%jX-Tq0Z0cMlA8+RT_T?z=c}w@}Se~r9{9Ky(!ZWs~`X4%CEfkyPI-4CBCR{$- zN8xc=W`!G>zQlat)Nl*q=rL)OQj|rLhYlBW_%dU(JC`o!mSg;4R8_M|qdApk0=tVX zN2Z<6c5^=w`PY(-jq1#JW)pJiA{DXU_311=-$we;tsP+|9 zHz!fE4HjnyoWZ~ZkLS^Bu;|b zd=^cUZvuSVD-%}z@As0an{smdgI;ZL%4g*bVFfNw%0-VPG={KVlCI3>?&QM7jBz#1 zruZN;1EUvQ^;jmykDb&g;jhvUjt$(A+LOV$IN()RTtgrrs%cpXh%=x>1Q0 zD5lf>EL;-lFJ^(4ffqU+wpO}TNgk3 zkDS=ft}ysA9v(f|^y8xys+ru-szbGVvW?x{hwvm*O?PUN<=)B@7{aB!z`A#nzjwl} zH;|(i?ZOv(5#7%hD<*h$EjMOS7w)5@Ah)fiNJ@AwzgCJhzt3^5_<{CvK?Y)=L7V(! ztKA)f$bnjQ4wcD?d-tnS>8za^!rf-W?F!mWiCFu$J`T;2eTi#m?)dYMB1tTB4f>Wd z*W;H4o^4L{&#wMz@VVj@XdK~{c~=b^J3PR*nD2dz;J5R{WpU(*=Op3jW&`C5yoFIT zrML2-ILox+Q8`h(G**5;8AiqIm5V6aR+lgJ zRhMssL}V2$Ot`Ok39G+6E3D$iBHTpoiaKfXN@;=5?o0XghHV!(HAt!*I%M}sus#0L zm>9X5YZZpEtps)*MvX~R%TxF-SNfUON;%Y;wlq6fuu||Itq9D`+N6lK*eL(|j@#}T z^rLvMi`}z##IdLKbd#TlU^)z%cI^cP0;L-INQCFg`k^#}HZ+wQX%BYb*``hZIB#vI zd}7^lVM5s;PxBCab3QhKJnxI!G=>Z`6G@D4 zZl=*;R6KxmupdGpy1nS&o3GF*6svDhax=B$01|b343>RK?tJl?tiC{Wjajl(Y3K{!A-SHA%_7KKoRw2jY*X>QN?g zHeOXf_#s(w38CF+YJf&IU|RGT|5CnmE@| z^Pi|>6ECr&j>u2fs%z0KYb3F!4g^(pGiafmp*Wt*63U)0W{8EO#~+> z0TVY*huzd9y%;w0%MFGZWQvkArv&`+HQ{Zmq(|akHcV4g+<2%ENDiL`jXon-I9AH6th!*neVa&JAP(TJ5f=!Ur$${ zTyXJAeO8Gse~BiQV+{8p27$f`6wb^rf6$Z@4BuI%M#X4&f)`X3Oa;(v3xJ~F>K&Fp z2fdFHaM>sRMWNqtJ=I|9eiFd4N~wMzcs0=zf!=2sk6+|-cB6^T^h|KM5y`4%th+pN zVWp!xO3R4Vu9fjsCnn?xKIK&Rd$mNW?)+pId~1*0)Q2oRVkOvND^R>!Px+Gsl-GGE z7l1rd!$=qW;7Ug}4Usm=w1k3p2b6SRoz)iT;^NJCbhB#DRE#==M4tT>-TMAk21`x6 z=tvmdLb`Bf0ms&NNZKy_EIjFEGQCesZs-QxFiO}tCSVY$HEG$;h0J3{(80l1zJwhx z5Km|0aGz|iJL4a1GZ2@0PV01EqOqN@`_Hp8f6-@A*dE%aWOB8;&=6O?JKeDU*1{gnfzm~OhE8!`-;PHP0$?W8-5+&9G2Ura+dPd z)?Ft*FoxXKprQRq`tAj+PkSbeubzv6f-ukcn!o>2&qP#k-e9x)>{MeeUgQOh_#L(4 zvX}wzFQ+EuLI14(UO&Yid~{00T&xr1O)5+R=2o;Y@HXe+} z;B8{eP9z){=y|hIdikZtonTu>uWC8xWmy*z7cp#g@~zHyLZvQI=nTR2*X3#-9R|4g>hvut#E%S2PKNr#4=cIL13)riuOt4_=?7bxnVV{88(u z0M8N{aHPF#py!Rxlc$3JTM@r+Vv!0`IgLD`<%sY(&xNFg>th`N*-CS|bc+gg?4s72 zvR5J>kmH5dAP0U0b0}w3T z1T@e3l}5(hv#0x3K{OJ4Jbe#f=>VXPrS)fr;{!B)*51a57rV~&ofeTtMth$-r4B>c zuESVO-TeuKP2&hJ#b$IkzRGtcm7C6a_-apw=Hjods=cj!WTgP@P|4(*hmbf%ttQ}-8mm|{RJ$u_UVfY&Y>L1^wzwmbOR5uzf;U)M$Ph?$_UgHv6ez3l$kfd?;_LJAWVZBGNEf4#*|HIOrxEd=>YeglC!^5n+`px2`BI1fK_!7}x`~8$Jby zcz}iTByWsM0cO&|2daqP5UNiUV+aIS)rc%3mbl6^is)sh(~j$GG-7EJ4wrv4tGgS%3@@LGiS0|G_H=b8JjFnHQP`dE=b+Z)SXlYhxO_VLBnnVsI zlc5af5=i??Of)_ibLb1$=WYcqPz^I5fxGh_~wXt+r79_PM|>+e3ud2}HTPo#&#WF|&XV0vH^!$JPY5s5Ga zfCBnyVk2%HQT7__#3E!nf&=Fr4Wj#B7(3OXYoh(w-_i%}5vhq^{bkd~ZL393n>w-A zP2niX9bSptWq1kmExB|1cSsQK5F(9qlGPR`O;%1kUcYjgiqCx_C{Op~t4(Sm?U}~I zoK)qyN~NfYygzf%BXJ`7gIbAHDbQlD>m(Znji5Qc;5`sZ$!KgtPQT1-9^ zEIHoXwblH~#uK}-aIm5Z=AsL6Wiz{EAA4<9=yCdU*I~`!oMWGO`-JR5@e%^Xe7?+n z)U}p32ym5jxqaSNe$D-mm)P0&#N$8L1;Oz!l~1ci7%x2KVIcaas4qNFa7B4tbQa6; zr{Q={Srct`X5|4IIzus*@5f-SX9(K(9@e3BT>|Bj%3suG?e7TrBZZXCY$FzzfkY54 zpQTnB5?k_KWYsRd?Cg7iM;cLG`ZJJfjij(^n7q1pHb;X3jv8E(&|NB^(%5kSNo5h1 z+Q#HrdikP>HbsG)buhj1e$lm{BS z7oNo1JQTLx0oQwIm6IrK8jLbf*V<4Ie_L4*h&d!aR#djbf1opBgA?N@X!6Mi3R55W zFvwNYuDFtFyiP-j-do~eA)3Hfb?aK7oN!=^%3m$A(YW~ht_d#VqQ)Y!C3WjW{=iDP zYne{eMrld@%%N&HJj?4zGc=2Z1&Ory9swloWRV|YIb6b>wR4)eoN39qAyHa(OvUzB zTqsNf#z36SEEUUZc?a8T&N;!LHqh|G38r4azIU^yqxCb@;D%1r(-g2A8UH1GbG%mV z3WL;0c~% z-dr@REXw;vwtc+#(Y-x3$Il{3zj&@j%8>K9f_Fu@$xd4KW&4SvQ|iDTV|Mo&X0D0X zxyZy=JEJUFN7>8dDLCDF)GRc=aw+H>g>JAE-$#AscNa(|5OQfJ> zGQp^5%i++!&eP(?1e{6{#0pKZ0rD)Q?f`ok3?XQL(_wQCy)H^{^JLLUJ%m|_=K#k{D6~67lZ?n zks7`di6JDwOY8b|YO2E<28pNrN7w)yfNu=F++y`B^{fw-HY4^5Ks&ei$+?Um=%l4eI)oj>L7AIF@F|GzRC6msXEg@ zj?Dn?6Dl&<<@+7JLu4d1dB6*CXCae`fee@iByy@6^%&u~7U{I)4)0D?)Jgw%Mh!Cs zot`@%@4tf%tK|M<5~Mpi;{g%`s=H2q0Xd>?icKr8!yt^m0!R8WWkPek`Khs2bLVyS zj9v>vOTQ3iM3|qVnp&ZCQkn!@`ArSJ*yHxeaOI(+7HJoFyWttARX9TM>Qt!+RCB`p!K6I6k)9=oReU%P7^tgO^ElqqqHA$nv(U)Dgh)Y&YGstxB8ddP zuWyq(G-0&IRVu{$W4dgQDkd_;;y=$zxk-bn_U zdiqQsR6y)A@Hy7g$cQO5&~~F8iUqTkRZ}m>4#q3zEh^gKff%#A2(}WC09fzSU-fyx zi=w@#Aj43jDH8l-g1W$T>ZpHFKR`5M?EvZ=*O8EY>d!7AXQzjX^XF@Oh@`Ra%?+N( zu(UOFr+|`x!c+GlUOoqr(bKgh{Zww^jUl)F%CPnakig#P0suGUkbx-m3ufQ4bnA8K zY9(07+X39$DG(6vHTJ<-80LHdt1R;$wYIr9b`Ak$#o6;c;{U#FsGSgDAt3j$;z%I# zGu+ugQ+rvtU=5wABm6%dwI9rzC59M$Z@$E3F+c_e<}Je#efnOYI(y^q6rG=H=s-1S zXeY{iq8iyAuRY-TcYGkmvjo7A%ctD|nNzwyIA$#an>?RCXa&#Vl0;^W*Ueyz96WZa z0%sTr{-)TAL#&Ml-}c1WnT+?J;)@N)Gu(|PEX(c`0Iq=f8i=lNvA6{bF;?_<#2M*!@$4eoujd zcE23$LrUBIzulE~R)iw>Q?V`=dS5fA6AX!go$9*KsaL;?*EXKt#jDOyq2I-;=#$^3 zpFkx#F#Sa0kRp``V~1=YEC!_^4MOQcNI?IThMx&E)d2X4OV>_LLLPO*z4M-Y%~vWz zs#uC}XioHB#}9(*JDZD?w*ZBO3NZtM;|wsB=DaTNJKNxSS0vjCG3X)qK(1t)?3^dU zGdc~8@BTSFJDdl-J}NQnzZy81Qe?u37>%TB63cYBOGH2Faf5Ncn5k`l23 z%c^twPcFC?Hg@7{hrm{2VeO{8J}=mRL8UJ#9~_=2wZ_rPku8?*%8 zkar-kmc{ZnOkjHRaAIl|S;OTJq2FG-15m50i)j%s+<;E`Mw_P@<^@}cFI%vkE9Kw z`=P@LV|hk@O=vgi`yd`zs6GW|2-}5xt3Ow%w-H+{Vperm+-MxyD6NsMw(wvRVnyZ` zSXV*qPL~^lir((Mo!DLxJdd%BG>$OIZRad{ksR=oc{YM9Z||d*=&QTM)_!3viu~A? z)E?=$^}3yVabURI^3Jg;F(Ju54}t4l^Suw1CH?)XmGXweUz@HqUC2GLg~A1SAcdYY zqV^+BA1Z5r^VEeb(nusBezga)?(6$3-K)A zI&_mO=Mb4WOPaUIKOjY!$BLcDxfRiT&lmE~o;kqu$a;aQkU~*7cs$`Q)X0m@MfE-% zSHHfVu(_aoqtF6G-!h6veP+Osu6N9dA-~f3;vi@jUu0HMNOxv3+w@@~6qFuB|Q$J#sFSfxcf^>t!Yo_he_YmVY z6$JBW%#8OqTnSF)Q2Y2Q8|DY6dl`ACL=orR6AvmHL3O{WVB7rGZOu&pB5^KB&Ysym z{#XI5H*H^MENh%+PT+*?Wo2r?@bvIm74@GhI$g7y4k)?& z+|{D2P?IaOSJ?IoqSCR#qRgY0POiwb=J9C$ zujeQF&pI#ho_z#4_#GHnCbS#%qWBh@?Vr|y=t!!+J=%TIB&%VEwij51wGrSrpK_3E zCW(2C36+BR7daD`eMK_+HGC$_`Hv(W!i*KkHbVCDY?y00DmI_9~!O z_3#9bN-WQ-`dv`ilVoe%jjm0Jr};~m?s({&9`EpC?~V;wgE-Ew%P)o|(Xd^Q89oZR zY|z7Q_`&T_F~?~Yis}+cS7H%}jaq9M!t10P3V!9Vk|mzwbD*Vq!z zVl}U+irj!y3!1eoAw=TwfswQiuT&h^`p>tk=uevTt}rqiPTjHG%8Be?%!&^87Uhy}9hlO4mbeKXYFy z%!b3c9@!%Gl&s@(SJA@wIm!OniITs(S!r^TM|cuUrY5#qL>cPXrp`=CH=t(nD$CpQ zwKp~fY^xkyY`N$3T?XEMUW1SEZ927MIsAL3wc9ZJX>gW1c5tr5&m(K?!k>jEXcBPg z%Pe4~H(LG`lfbg1orvOI`q|j!balTcyobv>FCrTWT3M%*QO=x7>15GsN~zt6)~YHG z<_yYkvom4b_a900pQR02E_iaF_Se^}O`YYDlN5pA|Eyv~Ye|{AU*`JaNoV8Fbw!y< zabvMsY8}g}S}2Oz(ftgOT{Xa6nUv-!qTEFir4uTKX?pVVx9W@T>1P+ zE)m{M?Ihz!D(7^kvd}LK zNE*ZYS_Rmv_XFP|(?skRbDRp3K4O|i{HmzMCmaad@l`P}g)XHcJ2)mR*uHLa z*%a;zO(2ujG0$>~Z6}{jUjMLaeQ`NlPLD0~6mpV2y?ABoRm{XM`-H3P#FQro^I7^{KPt1oxq-A9M~M zCfr_5i#v-c&I;}kg2(E~!nscD&@3D4X?Ehri2ayTZra931Uf?tNhDGpY}EBmsWHIN zrpS&^ALcOa58QREaFqMYryTpw-H>o5A8oAip?*=$OGZ03h?3LWtD>mc9ArfngEe}{@`R6#;qU0dTsT=W zs1%L^FX@#<5p$R2e79CZGn=UwDtO zzOrM}r*FWPgv~i}6=?POktkJUJ*kPAGQx5urCT)IC9^a2+DQ-L(Ce7b)px&;tn)IB zC<+0Ha-3r^y=URr=#^UDgN56c5`mP;7Q07IVN2DdpLA>y;w3BGTL|&uRfx~GPQJkf zKFEyPDLjUG!nG9ZBlkIMq@BgkN^zd~=xwek-L#&B#p5nk$!uO+6;9aUCum{ZqHOo5 zLx(ZN@!I2LMn^pHJVPh@pSkx8FWK^j0`_zf%nKi}~G;7a5uIPX2& b+&g^4Cqf|L;P{&Z@ZWV+tt%gtO&|YX^MA8c literal 0 HcmV?d00001 diff --git a/docs/images/model-deploy-result.png b/docs/images/model-deploy-result.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3d166e289f31b68b231bae8c4ecf96285003da GIT binary patch literal 19960 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 literal 0 HcmV?d00001 diff --git a/docs/images/model-train-register-artifacts.png b/docs/images/model-train-register-artifacts.png new file mode 100644 index 0000000000000000000000000000000000000000..0d3eed26b63f5988289b64772d1491adeff808cf GIT binary patch literal 8827 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 literal 0 HcmV?d00001 diff --git a/docs/images/model-train-register.png b/docs/images/model-train-register.png new file mode 100644 index 0000000000000000000000000000000000000000..5ce4ef417fa3dbf55e83418dabfe8feea9186ff5 GIT binary patch literal 15690 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 literal 0 HcmV?d00001 diff --git a/docs/split_cicd_pipelines.md b/docs/split_cicd_pipelines.md new file mode 100644 index 00000000..5f9979f0 --- /dev/null +++ b/docs/split_cicd_pipelines.md @@ -0,0 +1,109 @@ +# CI/CD pipelines for model train/register and deployment + +Follow this guide to set up two separate pipelines to train/register models and deploy models. This set of pipelines is functionally similar to the [diabetes_regression-ci.yml](../.pipelines/diabetes_regression-ci.yml) pipeline in the [getting started](getting_started.md) guide. + +1. **Model CI, training, evaluation, and registration** - triggered on code changes to master branch on GitHub. Runs linting, unit tests, code coverage, and publishes and runs the training pipeline. If a new model is registered after evaluation, it creates a build artifact containing the JSON metadata of the model. Definition: [diabetes_regression-train-register.yml](../.pipelines/diabetes_regression-train-register.yml). +1. **Release deployment** - consumes the artifact of the previous pipeline and deploys a model to either [Azure Container Instances (ACI)](https://azure.microsoft.com/en-us/services/container-instances/), [Azure Kubernetes Service (AKS)](https://azure.microsoft.com/en-us/services/kubernetes-service), or [Azure App Service](https://docs.microsoft.com/en-us/azure/machine-learning/service/how-to-deploy-app-service) environments. Definition: [diabetes_regression-deploy.yml](../.pipelines/diabetes_regression-deploy.yml). + +## Prerequisites + +--- + +It is recommended to go through the [getting started guide](getting_started.md) before starting this guide. + +--- + +Before continuing this guide, you will need: + +- An existing workspace. To setup your environment with a new workspace, follow the steps here. +- An Azure DevOps Service Connection with your Azure ML Workspace. +- A variable group named **``devopsforai-aml-vg``** with the required variables set. + +## Model CI, training, evaluation, and registration pipeline + +In this section, we will create the pipeline that will perform model IC, training, evaluation, and registration. + +### Set up the Pipeline + +In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-train-register.yml](../.pipelines/diabetes_regression-train-register.yml) +pipeline definition in your forked repository. + +If you plan to use the release deployment pipeline (in the next section), you will need to rename this pipeline to `Model-Train-Register-CI`. + +Once the pipeline is finished, check the execution result: + +![Build](./images/model-train-register.png) + +And the pipeline artifacts: + +![Build](./images/model-train-register-artifacts.png) + +The pipeline stages are summarized below: + +#### Model CI + +- Linting (code quality analysis) +- Unit tests and code coverage analysis +- Build and publish *ML Training Pipeline* in an *ML Workspace* + +#### Train model + +- Determine the ID of the *ML Training Pipeline* published in the previous stage. +- Trigger the *ML Training Pipeline* and waits for it to complete. + - This is an **agentless** job. The CI pipeline can wait for ML pipeline completion for hours or even days without using agent resources. +- Determine if a new model was registered by the *ML Training Pipeline*. + - If the model evaluation determines that the new model doesn't perform any better than the previous one, the new model won't register and the *ML Training Pipeline* will be **canceled**. In this case, you'll see a message in the 'Train Model' job under the 'Determine if evaluation succeeded and new model is registered' step saying '**Model was not registered for this run.**' + - See [evaluate_model.py](../diabetes_regression/evaluate/evaluate_model.py#L118) for the evaluation logic. + - [Additional Variables and Configuration](getting_started.md#additional-variables-and-configuration) for configuring this and other behavior. + +#### Create pipeline artifact + +- Get the info about the registered model +- Create a pipeline artifact called `model` that contains a `model.json` file containing the model information. + +## Release deployment pipeline + +--- +**PREREQUISITE** + +In order to use this pipeline: + +1. Follow the steps to set up the Model CI, training, evaluation, and registration pipeline. +1. You **must** rename your model CI/train/eval/register pipeline to `Model-Train-Register-CI`. + +The release deploymment pipeline relies on the model CI pipeline and references it by name. + +--- + +In this section, we will set up the pipeline for release deployment to ACI, AKS, or Webapp. This pipeline uses the resulting artifact of the [Model-Train-Register-CI pipeline](#) to identify the model to be deployed. + +This pipeline has the following behaviors: + +- The pipeline will **automatically trigger** on completion of the Model-Train-Register-CI pipeline +- The pipeline will default to using the latest successful build of the Model-Train-Register-CI pipeline. It will deploy the model produced by that build. + - You can specify a build ID when running the pipeline manually. + +### Set up the pipeline + +In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-deploy.yml](../.pipelines/diabetes_regression-deploy.yml) +pipeline definition in your forked repository. + +Your first run will use the latest model created by the Model-Train-Register-CI pipeline. + +Once the pipeline is finished, check the execution result: + +![Build](./images/model-deploy-result.png) + +To specify a particular build's model, set the `Model Train CI Build Id` parameter to the build Id you would like to use. + +![Build](./images/model-deploy-configure.png) + +The pipeline has the following stage: + +#### Deploy to ACI + +- Deploy the model to the QA environment in [Azure Container Instances](https://azure.microsoft.com/en-us/services/container-instances/). +- Smoke test + - The test sends a sample query to the scoring web service and verifies that it returns the expected response. Have a look at the [smoke test code](../ml_service/util/smoke_test_scoring_service.py) for an example. + +See [Further Exploration](getting_started.md#further-exploration) to learn about other deployment targets. From 9c07bd3fc3221e1e85ae051c4c6d608061341596 Mon Sep 17 00:00:00 2001 From: j-so Date: Tue, 26 May 2020 11:37:59 -0700 Subject: [PATCH 07/28] use shared image --- .pipelines/diabetes_regression-deploy.yml | 4 ++-- .pipelines/diabetes_regression-train-register.yml | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml index 6075eccc..25d0d462 100644 --- a/.pipelines/diabetes_regression-deploy.yml +++ b/.pipelines/diabetes_regression-deploy.yml @@ -12,13 +12,13 @@ trigger: none resources: containers: - container: mlops - image: mlops/diabetes_regression - endpoint: acrconnection + image: mcr.microsoft.com/mlops/python:latest pipelines: - pipeline: model-train-ci source: Model-Train-Register-CI # Name of the triggering pipeline trigger: branches: + include: - master variables: diff --git a/.pipelines/diabetes_regression-train-register.yml b/.pipelines/diabetes_regression-train-register.yml index ba116954..5a539af0 100644 --- a/.pipelines/diabetes_regression-train-register.yml +++ b/.pipelines/diabetes_regression-train-register.yml @@ -3,8 +3,7 @@ resources: containers: - container: mlops - image: mlops/diabetes_regression - endpoint: acrconnection + image: mcr.microsoft.com/mlops/python:latest pr: none trigger: From a7c7fa5b8de1be04bd0559b69b439ae6b8abadd2 Mon Sep 17 00:00:00 2001 From: j-so Date: Thu, 28 May 2020 14:26:47 -0700 Subject: [PATCH 08/28] rename --- .pipelines/diabetes_regression-cd-deploy.yml | 161 ++++++++++++++++++ .../diabetes_regression-ci-train-register.yml | 97 +++++++++++ docs/split_cicd_pipelines.md | 4 +- 3 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 .pipelines/diabetes_regression-cd-deploy.yml create mode 100644 .pipelines/diabetes_regression-ci-train-register.yml diff --git a/.pipelines/diabetes_regression-cd-deploy.yml b/.pipelines/diabetes_regression-cd-deploy.yml new file mode 100644 index 00000000..25d0d462 --- /dev/null +++ b/.pipelines/diabetes_regression-cd-deploy.yml @@ -0,0 +1,161 @@ +# Continuous Integration (CI) pipeline that orchestrates the deployment of the diabetes_regression model. + +# Runtime parameters to select artifacts +parameters: +- name : artifactBuildId + displayName: Model Train CI Build ID. Default is 'latest'. + type: string + default: latest + +# Trigger this pipeline on model-train pipeline completion +trigger: none +resources: + containers: + - container: mlops + image: mcr.microsoft.com/mlops/python:latest + pipelines: + - pipeline: model-train-ci + source: Model-Train-Register-CI # Name of the triggering pipeline + trigger: + branches: + include: + - master + +variables: +- template: diabetes_regression-variables-template.yml +- group: devopsforai-aml-vg + +stages: +- stage: 'Deploy_ACI' + displayName: 'Deploy to ACI' + condition: variables['ACI_DEPLOYMENT_NAME'] + jobs: + - job: "Deploy_ACI" + displayName: "Deploy to ACI" + container: mlops + timeoutInMinutes: 0 + steps: + - download: none + - template: diabetes_regression-get-model-id-artifact-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} + - task: AzureCLI@1 + displayName: 'Install AzureML CLI' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: 'az extension add -n azure-cli-ml' + - task: AzureCLI@1 + displayName: "Deploy to ACI (CLI)" + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring + inlineScript: | + az ml model deploy --name $(ACI_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ + --ic inference_config.yml \ + --dc deployment_config_aci.yml \ + -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ + --overwrite -v + - task: AzureCLI@1 + displayName: 'Smoke test' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.util.smoke_test_scoring_service --type ACI --service "$(ACI_DEPLOYMENT_NAME)" + +- stage: 'Deploy_AKS' + displayName: 'Deploy to AKS' + dependsOn: Deploy_ACI + condition: and(succeeded(), variables['AKS_DEPLOYMENT_NAME']) + jobs: + - job: "Deploy_AKS" + displayName: "Deploy to AKS" + container: mlops + timeoutInMinutes: 0 + steps: + - template: diabetes_regression-get-model-id-artifact-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} + - task: AzureCLI@1 + displayName: 'Install AzureML CLI' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: 'az extension add -n azure-cli-ml' + - task: AzureCLI@1 + displayName: "Deploy to AKS (CLI)" + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring + inlineScript: | + az ml model deploy --name $(AKS_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ + --compute-target $(AKS_COMPUTE_NAME) \ + --ic inference_config.yml \ + --dc deployment_config_aks.yml \ + -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ + --overwrite -v + - task: AzureCLI@1 + displayName: 'Smoke test' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.util.smoke_test_scoring_service --type AKS --service "$(AKS_DEPLOYMENT_NAME)" + +- stage: 'Deploy_Webapp' + displayName: 'Deploy to Webapp' + condition: and(succeeded(), variables['WEBAPP_DEPLOYMENT_NAME']) + jobs: + - job: "Package_Model" + displayName: "Package model" + container: mlops + timeoutInMinutes: 0 + steps: + - template: diabetes_regression-get-model-id-artifact-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} + - template: diabetes_regression-package-model-template.yml + parameters: + modelId: $(MODEL_NAME):$(MODEL_VERSION) + scoringScriptPath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/score.py' + condaFilePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/conda_dependencies.yml' + - script: echo $(IMAGE_LOCATION) >image_location.txt + displayName: "Write image location file" + - job: "Deploy_Webapp" + displayName: "Deploy Webapp" + container: mlops + dependsOn: Package_Model + condition: succeeded() + steps: + - task: AzureWebAppContainer@1 + name: WebAppDeploy + displayName: 'Azure Web App on Container Deploy' + inputs: + azureSubscription: '$(AZURE_RM_SVC_CONNECTION)' + appName: '$(WEBAPP_DEPLOYMENT_NAME)' + resourceGroupName: '$(RESOURCE_GROUP)' + imageName: '$(IMAGE_LOCATION)' + - task: AzureCLI@1 + displayName: 'Smoke test' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.util.smoke_test_scoring_service --type Webapp --service "$(WebAppDeploy.AppServiceApplicationUrl)/score" diff --git a/.pipelines/diabetes_regression-ci-train-register.yml b/.pipelines/diabetes_regression-ci-train-register.yml new file mode 100644 index 00000000..5a539af0 --- /dev/null +++ b/.pipelines/diabetes_regression-ci-train-register.yml @@ -0,0 +1,97 @@ +# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, and registration of the diabetes_regression model. + +resources: + containers: + - container: mlops + image: mcr.microsoft.com/mlops/python:latest + +pr: none +trigger: + branches: + include: + - master + paths: + include: + - diabetes_regression/ + - ml_service/pipelines/diabetes_regression_build_train_pipeline.py + - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r.py + - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r_on_dbricks.py + +variables: +- template: diabetes_regression-variables-template.yml +- group: devopsforai-aml-vg + +pool: + vmImage: ubuntu-latest + +stages: +- stage: 'Model_CI' + displayName: 'Model CI' + jobs: + - job: "Model_CI_Pipeline" + displayName: "Model CI Pipeline" + container: mlops + timeoutInMinutes: 0 + steps: + - template: code-quality-template.yml + - task: AzureCLI@1 + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + # Invoke the Python building and publishing a training pipeline + python -m ml_service.pipelines.diabetes_regression_build_train_pipeline + displayName: 'Publish Azure Machine Learning Pipeline' + +- stage: 'Trigger_AML_Pipeline' + displayName: 'Train and evaluate model' + condition: succeeded() + variables: + BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' + jobs: + - job: "Get_Pipeline_ID" + condition: and(succeeded(), eq(coalesce(variables['auto-trigger-training'], 'true'), 'true')) + displayName: "Get Pipeline ID for execution" + container: mlops + timeoutInMinutes: 0 + steps: + - task: AzureCLI@1 + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + scriptLocation: inlineScript + workingDirectory: $(Build.SourcesDirectory) + inlineScript: | + set -e # fail on error + export SUBSCRIPTION_ID=$(az account show --query id -o tsv) + python -m ml_service.pipelines.run_train_pipeline --output_pipeline_id_file "pipeline_id.txt" --skip_train_execution + # Set AMLPIPELINEID variable for next AML Pipeline task in next job + AMLPIPELINEID="$(cat pipeline_id.txt)" + echo "##vso[task.setvariable variable=AMLPIPELINEID;isOutput=true]$AMLPIPELINEID" + name: 'getpipelineid' + displayName: 'Get Pipeline ID' + - job: "Run_ML_Pipeline" + dependsOn: "Get_Pipeline_ID" + displayName: "Trigger ML Training Pipeline" + timeoutInMinutes: 0 + pool: server + variables: + AMLPIPELINE_ID: $[ dependencies.Get_Pipeline_ID.outputs['getpipelineid.AMLPIPELINEID'] ] + steps: + - task: ms-air-aiagility.vss-services-azureml.azureml-restApi-task.MLPublishedPipelineRestAPITask@0 + displayName: 'Invoke ML pipeline' + inputs: + azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' + PipelineId: '$(AMLPIPELINE_ID)' + ExperimentName: '$(EXPERIMENT_NAME)' + PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)"}, "tags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}, "StepTags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}' + - job: "Training_Run_Report" + dependsOn: "Run_ML_Pipeline" + condition: always() + displayName: "Publish artifact if new model was registered" + container: mlops + timeoutInMinutes: 0 + steps: + - template: diabetes_regression-publish-model-artifact-template.yml diff --git a/docs/split_cicd_pipelines.md b/docs/split_cicd_pipelines.md index 5f9979f0..7facaa1a 100644 --- a/docs/split_cicd_pipelines.md +++ b/docs/split_cicd_pipelines.md @@ -25,7 +25,7 @@ In this section, we will create the pipeline that will perform model IC, trainin ### Set up the Pipeline -In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-train-register.yml](../.pipelines/diabetes_regression-train-register.yml) +In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-ci-train-register.yml](../.pipelines/diabetes_regression-ci-train-register.yml) pipeline definition in your forked repository. If you plan to use the release deployment pipeline (in the next section), you will need to rename this pipeline to `Model-Train-Register-CI`. @@ -85,7 +85,7 @@ This pipeline has the following behaviors: ### Set up the pipeline -In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-deploy.yml](../.pipelines/diabetes_regression-deploy.yml) +In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-cd-deploy.yml](../.pipelines/diabetes_regression-cd-deploy.yml) pipeline definition in your forked repository. Your first run will use the latest model created by the Model-Train-Register-CI pipeline. From a138306a685ebc837666470018f91ae0a7500372 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 3 Jun 2020 15:12:36 -0700 Subject: [PATCH 09/28] strip quotes from location --- .pipelines/diabetes_regression-package-model-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/diabetes_regression-package-model-template.yml b/.pipelines/diabetes_regression-package-model-template.yml index accb1596..b8206a47 100644 --- a/.pipelines/diabetes_regression-package-model-template.yml +++ b/.pipelines/diabetes_regression-package-model-template.yml @@ -32,7 +32,7 @@ steps: --model '${{ parameters.modelId }}' \ --entry-script '${{ parameters.scoringScriptPath }}' \ --cf '${{ parameters.condaFilePath }}' \ - --rt python --query 'location') + --rt python --query 'location' -o tsv) # Set environment variable echo $IMAGE_LOCATION From 11ee8f610dcb8be01d1dc741a7c0b577f8091bd5 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 3 Jun 2020 16:11:36 -0700 Subject: [PATCH 10/28] fix env var --- .pipelines/diabetes_regression-deploy.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml index 25d0d462..592add47 100644 --- a/.pipelines/diabetes_regression-deploy.yml +++ b/.pipelines/diabetes_regression-deploy.yml @@ -119,8 +119,8 @@ stages: displayName: 'Deploy to Webapp' condition: and(succeeded(), variables['WEBAPP_DEPLOYMENT_NAME']) jobs: - - job: "Package_Model" - displayName: "Package model" + - job: "Deploy_Webapp" + displayName: "Package and deploy model" container: mlops timeoutInMinutes: 0 steps: @@ -136,12 +136,6 @@ stages: condaFilePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/conda_dependencies.yml' - script: echo $(IMAGE_LOCATION) >image_location.txt displayName: "Write image location file" - - job: "Deploy_Webapp" - displayName: "Deploy Webapp" - container: mlops - dependsOn: Package_Model - condition: succeeded() - steps: - task: AzureWebAppContainer@1 name: WebAppDeploy displayName: 'Azure Web App on Container Deploy' From 0a914b66e02b9ba3abbde1454819489af3c7d10e Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 3 Jun 2020 16:13:01 -0700 Subject: [PATCH 11/28] remove succeeded condition --- .pipelines/diabetes_regression-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml index 592add47..96753dff 100644 --- a/.pipelines/diabetes_regression-deploy.yml +++ b/.pipelines/diabetes_regression-deploy.yml @@ -117,7 +117,7 @@ stages: - stage: 'Deploy_Webapp' displayName: 'Deploy to Webapp' - condition: and(succeeded(), variables['WEBAPP_DEPLOYMENT_NAME']) + condition: variables['WEBAPP_DEPLOYMENT_NAME'] jobs: - job: "Deploy_Webapp" displayName: "Package and deploy model" From d1f7f03bf33d8d98d29e8d4af4836dfa3a40f9c1 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 3 Jun 2020 16:30:25 -0700 Subject: [PATCH 12/28] remove extra deploy yml --- .pipelines/diabetes_regression-cd-deploy.yml | 12 +- .pipelines/diabetes_regression-deploy.yml | 155 ------------------- 2 files changed, 3 insertions(+), 164 deletions(-) delete mode 100644 .pipelines/diabetes_regression-deploy.yml diff --git a/.pipelines/diabetes_regression-cd-deploy.yml b/.pipelines/diabetes_regression-cd-deploy.yml index 25d0d462..96753dff 100644 --- a/.pipelines/diabetes_regression-cd-deploy.yml +++ b/.pipelines/diabetes_regression-cd-deploy.yml @@ -117,10 +117,10 @@ stages: - stage: 'Deploy_Webapp' displayName: 'Deploy to Webapp' - condition: and(succeeded(), variables['WEBAPP_DEPLOYMENT_NAME']) + condition: variables['WEBAPP_DEPLOYMENT_NAME'] jobs: - - job: "Package_Model" - displayName: "Package model" + - job: "Deploy_Webapp" + displayName: "Package and deploy model" container: mlops timeoutInMinutes: 0 steps: @@ -136,12 +136,6 @@ stages: condaFilePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/conda_dependencies.yml' - script: echo $(IMAGE_LOCATION) >image_location.txt displayName: "Write image location file" - - job: "Deploy_Webapp" - displayName: "Deploy Webapp" - container: mlops - dependsOn: Package_Model - condition: succeeded() - steps: - task: AzureWebAppContainer@1 name: WebAppDeploy displayName: 'Azure Web App on Container Deploy' diff --git a/.pipelines/diabetes_regression-deploy.yml b/.pipelines/diabetes_regression-deploy.yml deleted file mode 100644 index 96753dff..00000000 --- a/.pipelines/diabetes_regression-deploy.yml +++ /dev/null @@ -1,155 +0,0 @@ -# Continuous Integration (CI) pipeline that orchestrates the deployment of the diabetes_regression model. - -# Runtime parameters to select artifacts -parameters: -- name : artifactBuildId - displayName: Model Train CI Build ID. Default is 'latest'. - type: string - default: latest - -# Trigger this pipeline on model-train pipeline completion -trigger: none -resources: - containers: - - container: mlops - image: mcr.microsoft.com/mlops/python:latest - pipelines: - - pipeline: model-train-ci - source: Model-Train-Register-CI # Name of the triggering pipeline - trigger: - branches: - include: - - master - -variables: -- template: diabetes_regression-variables-template.yml -- group: devopsforai-aml-vg - -stages: -- stage: 'Deploy_ACI' - displayName: 'Deploy to ACI' - condition: variables['ACI_DEPLOYMENT_NAME'] - jobs: - - job: "Deploy_ACI" - displayName: "Deploy to ACI" - container: mlops - timeoutInMinutes: 0 - steps: - - download: none - - template: diabetes_regression-get-model-id-artifact-template.yml - parameters: - projectId: '$(resources.pipeline.model-train-ci.projectID)' - pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' - artifactBuildId: ${{ parameters.artifactBuildId }} - - task: AzureCLI@1 - displayName: 'Install AzureML CLI' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: 'az extension add -n azure-cli-ml' - - task: AzureCLI@1 - displayName: "Deploy to ACI (CLI)" - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring - inlineScript: | - az ml model deploy --name $(ACI_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ - --ic inference_config.yml \ - --dc deployment_config_aci.yml \ - -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ - --overwrite -v - - task: AzureCLI@1 - displayName: 'Smoke test' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type ACI --service "$(ACI_DEPLOYMENT_NAME)" - -- stage: 'Deploy_AKS' - displayName: 'Deploy to AKS' - dependsOn: Deploy_ACI - condition: and(succeeded(), variables['AKS_DEPLOYMENT_NAME']) - jobs: - - job: "Deploy_AKS" - displayName: "Deploy to AKS" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-get-model-id-artifact-template.yml - parameters: - projectId: '$(resources.pipeline.model-train-ci.projectID)' - pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' - artifactBuildId: ${{ parameters.artifactBuildId }} - - task: AzureCLI@1 - displayName: 'Install AzureML CLI' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: 'az extension add -n azure-cli-ml' - - task: AzureCLI@1 - displayName: "Deploy to AKS (CLI)" - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring - inlineScript: | - az ml model deploy --name $(AKS_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ - --compute-target $(AKS_COMPUTE_NAME) \ - --ic inference_config.yml \ - --dc deployment_config_aks.yml \ - -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) \ - --overwrite -v - - task: AzureCLI@1 - displayName: 'Smoke test' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type AKS --service "$(AKS_DEPLOYMENT_NAME)" - -- stage: 'Deploy_Webapp' - displayName: 'Deploy to Webapp' - condition: variables['WEBAPP_DEPLOYMENT_NAME'] - jobs: - - job: "Deploy_Webapp" - displayName: "Package and deploy model" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-get-model-id-artifact-template.yml - parameters: - projectId: '$(resources.pipeline.model-train-ci.projectID)' - pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' - artifactBuildId: ${{ parameters.artifactBuildId }} - - template: diabetes_regression-package-model-template.yml - parameters: - modelId: $(MODEL_NAME):$(MODEL_VERSION) - scoringScriptPath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/score.py' - condaFilePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/conda_dependencies.yml' - - script: echo $(IMAGE_LOCATION) >image_location.txt - displayName: "Write image location file" - - task: AzureWebAppContainer@1 - name: WebAppDeploy - displayName: 'Azure Web App on Container Deploy' - inputs: - azureSubscription: '$(AZURE_RM_SVC_CONNECTION)' - appName: '$(WEBAPP_DEPLOYMENT_NAME)' - resourceGroupName: '$(RESOURCE_GROUP)' - imageName: '$(IMAGE_LOCATION)' - - task: AzureCLI@1 - displayName: 'Smoke test' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type Webapp --service "$(WebAppDeploy.AppServiceApplicationUrl)/score" From eb62b5cc75c4b42150897a2e23f218c28005d803 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 3 Jun 2020 17:07:20 -0700 Subject: [PATCH 13/28] no pr trigger --- .pipelines/diabetes_regression-cd-deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.pipelines/diabetes_regression-cd-deploy.yml b/.pipelines/diabetes_regression-cd-deploy.yml index 96753dff..f9dc8b63 100644 --- a/.pipelines/diabetes_regression-cd-deploy.yml +++ b/.pipelines/diabetes_regression-cd-deploy.yml @@ -7,6 +7,8 @@ parameters: type: string default: latest +pr: none + # Trigger this pipeline on model-train pipeline completion trigger: none resources: From b9cd127f9b29d43471ba4cb865b98d968849aed3 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 8 Jun 2020 17:35:16 -0700 Subject: [PATCH 14/28] remove unused template --- ...betes_regression-get-model-id-template.yml | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 .pipelines/diabetes_regression-get-model-id-template.yml diff --git a/.pipelines/diabetes_regression-get-model-id-template.yml b/.pipelines/diabetes_regression-get-model-id-template.yml deleted file mode 100644 index 6bb21a93..00000000 --- a/.pipelines/diabetes_regression-get-model-id-template.yml +++ /dev/null @@ -1,37 +0,0 @@ -parameters: -- name: projectId - type: string - default: '' -- name: pipelineId - type: string - default: '' - -steps: - - checkout: none - - download: none - - task: DownloadPipelineArtifact@2 - displayName: Download Pipeline Artifacts - inputs: - source: 'specific' - project: '${{ parameters.projectId }}' - pipeline: '${{ parameters.pipelineId }}' - preferTriggeringPipeline: true - runVersion: 'latestFromBranch' - runBranch: '$(Build.SourceBranch)' - path: $(Build.SourcesDirectory)/bin - - task: Bash@3 - inputs: - targetType: 'inline' - script: | - # Print JSON - cat $(Build.SourcesDirectory)/bin/model/model.json | jq '.' - - # Set model name and version variables as strings - MODEL_NAME=$(jq '.name' <$(Build.SourcesDirectory)/bin/model/model.json) - MODEL_VERSION=$(jq '.version' <$(Build.SourcesDirectory)/bin/model/model.json) - - echo "Model Name: $MODEL_NAME" - echo "Model Version: $MODEL_VERSION" - - echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" - echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" \ No newline at end of file From b865800a00d8ffd278938855af40e1691cf847cb Mon Sep 17 00:00:00 2001 From: j-so Date: Tue, 9 Jun 2020 14:01:25 -0700 Subject: [PATCH 15/28] remove unused pipeline --- .../diabetes_regression-train-register.yml | 97 ------------------- 1 file changed, 97 deletions(-) delete mode 100644 .pipelines/diabetes_regression-train-register.yml diff --git a/.pipelines/diabetes_regression-train-register.yml b/.pipelines/diabetes_regression-train-register.yml deleted file mode 100644 index 5a539af0..00000000 --- a/.pipelines/diabetes_regression-train-register.yml +++ /dev/null @@ -1,97 +0,0 @@ -# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, and registration of the diabetes_regression model. - -resources: - containers: - - container: mlops - image: mcr.microsoft.com/mlops/python:latest - -pr: none -trigger: - branches: - include: - - master - paths: - include: - - diabetes_regression/ - - ml_service/pipelines/diabetes_regression_build_train_pipeline.py - - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r.py - - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r_on_dbricks.py - -variables: -- template: diabetes_regression-variables-template.yml -- group: devopsforai-aml-vg - -pool: - vmImage: ubuntu-latest - -stages: -- stage: 'Model_CI' - displayName: 'Model CI' - jobs: - - job: "Model_CI_Pipeline" - displayName: "Model CI Pipeline" - container: mlops - timeoutInMinutes: 0 - steps: - - template: code-quality-template.yml - - task: AzureCLI@1 - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - # Invoke the Python building and publishing a training pipeline - python -m ml_service.pipelines.diabetes_regression_build_train_pipeline - displayName: 'Publish Azure Machine Learning Pipeline' - -- stage: 'Trigger_AML_Pipeline' - displayName: 'Train and evaluate model' - condition: succeeded() - variables: - BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' - jobs: - - job: "Get_Pipeline_ID" - condition: and(succeeded(), eq(coalesce(variables['auto-trigger-training'], 'true'), 'true')) - displayName: "Get Pipeline ID for execution" - container: mlops - timeoutInMinutes: 0 - steps: - - task: AzureCLI@1 - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.pipelines.run_train_pipeline --output_pipeline_id_file "pipeline_id.txt" --skip_train_execution - # Set AMLPIPELINEID variable for next AML Pipeline task in next job - AMLPIPELINEID="$(cat pipeline_id.txt)" - echo "##vso[task.setvariable variable=AMLPIPELINEID;isOutput=true]$AMLPIPELINEID" - name: 'getpipelineid' - displayName: 'Get Pipeline ID' - - job: "Run_ML_Pipeline" - dependsOn: "Get_Pipeline_ID" - displayName: "Trigger ML Training Pipeline" - timeoutInMinutes: 0 - pool: server - variables: - AMLPIPELINE_ID: $[ dependencies.Get_Pipeline_ID.outputs['getpipelineid.AMLPIPELINEID'] ] - steps: - - task: ms-air-aiagility.vss-services-azureml.azureml-restApi-task.MLPublishedPipelineRestAPITask@0 - displayName: 'Invoke ML pipeline' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - PipelineId: '$(AMLPIPELINE_ID)' - ExperimentName: '$(EXPERIMENT_NAME)' - PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)"}, "tags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}, "StepTags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}' - - job: "Training_Run_Report" - dependsOn: "Run_ML_Pipeline" - condition: always() - displayName: "Publish artifact if new model was registered" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-publish-model-artifact-template.yml From 1128194c0aed560dc7291fb219ba50b4bd7ecbbb Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 10 Jun 2020 12:19:22 -0700 Subject: [PATCH 16/28] add more docs and add to bootstrap --- bootstrap/bootstrap.py | 4 ++++ docs/images/model-deploy-artifact-logs.PNG | Bin 0 -> 104171 bytes docs/split_cicd_pipelines.md | 8 ++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/images/model-deploy-artifact-logs.PNG diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 6e51b503..e26a5718 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -84,9 +84,13 @@ def replace_project_name(project_dir, project_name, rename_name): files = [r".env.example", r".pipelines/code-quality-template.yml", r".pipelines/pr.yml", + r".pipelines/diabetes_regression-cd-deploy.yml", + r".pipelines/diabetes_regression-ci-train-register.yml", r".pipelines/diabetes_regression-ci.yml", r".pipelines/abtest.yml", r".pipelines/diabetes_regression-ci-image.yml", + r".pipelines/diabetes_regression-publish-model-artifact-template.yml", + r".pipelines/diabetes_regression-get-model-id-artifact-template.yml", r".pipelines/diabetes_regression-get-model-version-template.yml", # NOQA: E501 r".pipelines/diabetes_regression-variables-template.yml", r"environment_setup/Dockerfile", diff --git a/docs/images/model-deploy-artifact-logs.PNG b/docs/images/model-deploy-artifact-logs.PNG new file mode 100644 index 0000000000000000000000000000000000000000..2dfee3057bb122de04b0ceea2a7bb1247a623781 GIT binary patch literal 104171 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 IjV)sAT+2qSh$xE@6H> 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
LMjdY!oQB+ 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 literal 0 HcmV?d00001 diff --git a/docs/split_cicd_pipelines.md b/docs/split_cicd_pipelines.md index 7facaa1a..a0071d78 100644 --- a/docs/split_cicd_pipelines.md +++ b/docs/split_cicd_pipelines.md @@ -81,14 +81,14 @@ This pipeline has the following behaviors: - The pipeline will **automatically trigger** on completion of the Model-Train-Register-CI pipeline - The pipeline will default to using the latest successful build of the Model-Train-Register-CI pipeline. It will deploy the model produced by that build. - - You can specify a build ID when running the pipeline manually. +- You can specify a `Model-Train-Register-CI` build ID when running the pipeline manually. You can find this in the url of the build, and the model registered from that build will also be tagged with the build ID. ### Set up the pipeline In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-cd-deploy.yml](../.pipelines/diabetes_regression-cd-deploy.yml) pipeline definition in your forked repository. -Your first run will use the latest model created by the Model-Train-Register-CI pipeline. +Your first run will use the latest model created by the `Model-Train-Register-CI` pipeline. Once the pipeline is finished, check the execution result: @@ -98,6 +98,10 @@ To specify a particular build's model, set the `Model Train CI Build Id` paramet ![Build](./images/model-deploy-configure.png) +Once your pipeline run begins, you can see the model name and version downloaded from the `Model-Train-Register-CI` pipeline. + +![Build](./images/model-deploy-artifact-logs.png) + The pipeline has the following stage: #### Deploy to ACI From 7a69c0e94c1a0d0fd1568e64f77718bba91debe9 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 10 Jun 2020 12:26:09 -0700 Subject: [PATCH 17/28] linting --- bootstrap/bootstrap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index e26a5718..0186865f 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -89,8 +89,8 @@ def replace_project_name(project_dir, project_name, rename_name): r".pipelines/diabetes_regression-ci.yml", r".pipelines/abtest.yml", r".pipelines/diabetes_regression-ci-image.yml", - r".pipelines/diabetes_regression-publish-model-artifact-template.yml", - r".pipelines/diabetes_regression-get-model-id-artifact-template.yml", + r".pipelines/diabetes_regression-publish-model-artifact-template.yml", # NOQA: E501 + r".pipelines/diabetes_regression-get-model-id-artifact-template.yml", # NOQA: E501 r".pipelines/diabetes_regression-get-model-version-template.yml", # NOQA: E501 r".pipelines/diabetes_regression-variables-template.yml", r"environment_setup/Dockerfile", From bae4b613114c1084f0295966c5958023a0c9435a Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 10 Jun 2020 18:54:31 -0700 Subject: [PATCH 18/28] fix model package to show logs --- .../diabetes_regression-package-model-template.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.pipelines/diabetes_regression-package-model-template.yml b/.pipelines/diabetes_regression-package-model-template.yml index b8206a47..7725b19c 100644 --- a/.pipelines/diabetes_regression-package-model-template.yml +++ b/.pipelines/diabetes_regression-package-model-template.yml @@ -27,13 +27,16 @@ steps: set -e # fail on error # Create model package using CLI - IMAGE_LOCATION=$(\ az ml model package --workspace-name $(WORKSPACE_NAME) -g $(RESOURCE_GROUP) \ --model '${{ parameters.modelId }}' \ --entry-script '${{ parameters.scoringScriptPath }}' \ --cf '${{ parameters.condaFilePath }}' \ - --rt python --query 'location' -o tsv) + -v \ + --rt python --query 'location' -o tsv > image_logs.txt - # Set environment variable - echo $IMAGE_LOCATION + # Show logs + cat image_logs.txt + + # Set environment variable using the last line of logs that has the package location + IMAGE_LOCATION=$(tail -n 1 image_logs.txt) echo "##vso[task.setvariable variable=IMAGE_LOCATION]$IMAGE_LOCATION" From dbe8b33ab22d08ad4caa5e036c70ff5e01341451 Mon Sep 17 00:00:00 2001 From: j-so Date: Fri, 12 Jun 2020 14:16:28 -0700 Subject: [PATCH 19/28] Squashed commit of the following: commit 01af8da39c77787a3a533c3cb506cb9b83ec6e43 Author: j-so Date: Fri Jun 12 14:15:35 2020 -0700 fixed failure handling commit 1e6f9068451f24927e4624b22ec9da8b1e9c6191 Author: j-so Date: Fri Jun 12 14:03:09 2020 -0700 test failed conda dep commit a8030d75ec0f2822116554e08eb29a1f6be2da67 Author: j-so Date: Fri Jun 12 13:55:00 2020 -0700 test package fail commit c7845aaca16d98e0b560619b1cf13a11bc7e8eb0 Author: j-so Date: Fri Jun 12 13:46:27 2020 -0700 fail on deploy error' --- .pipelines/diabetes_regression-cd-deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.pipelines/diabetes_regression-cd-deploy.yml b/.pipelines/diabetes_regression-cd-deploy.yml index f9dc8b63..16bc4724 100644 --- a/.pipelines/diabetes_regression-cd-deploy.yml +++ b/.pipelines/diabetes_regression-cd-deploy.yml @@ -57,6 +57,8 @@ stages: scriptLocation: inlineScript workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring inlineScript: | + set -e # fail on error + az ml model deploy --name $(ACI_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ --ic inference_config.yml \ --dc deployment_config_aci.yml \ @@ -101,6 +103,8 @@ stages: scriptLocation: inlineScript workingDirectory: $(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring inlineScript: | + set -e # fail on error + az ml model deploy --name $(AKS_DEPLOYMENT_NAME) --model '$(MODEL_NAME):$(MODEL_VERSION)' \ --compute-target $(AKS_COMPUTE_NAME) \ --ic inference_config.yml \ From 9bc9f8b8d591aec4ffa7ae50c4a90e8439bbe797 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 15 Jun 2020 18:32:22 -0700 Subject: [PATCH 20/28] Squashed commit of the following: commit 5af6eeb242dfbbeeb1782fdabbe3d3eec1c7dd02 Author: j-so Date: Mon Jun 15 18:31:15 2020 -0700 fix bootstrap commit f61e103ed08cdfbaf1dca555f14b286ea28bc003 Merge: 2796b40 08bb6f4 Author: j-so Date: Mon Jun 15 18:30:21 2020 -0700 Merge branch 'master' into jenns/splitpipeline_docfix commit 2796b4002487e058a92f68ce09fbb068b5c8e114 Author: j-so Date: Mon Jun 15 18:30:00 2020 -0700 remove old pipeline commit 08bb6f4a26d3db7ee998a45da62825c47411aec1 Author: David Tesar Date: Mon Jun 15 14:09:12 2020 -0700 Simplify docs flow (#297) commit cd762ecaa9dd1914d4e72dc514d0ce7dad66a58d Author: jotaylo Date: Mon Jun 15 12:28:23 2020 -0700 Move instruction to install AML extension to Azure Devops setup instructions (#298) --- ...-deploy.yml => diabetes_regression-cd.yml} | 0 .pipelines/diabetes_regression-ci-image.yml | 15 +-- .../diabetes_regression-ci-train-register.yml | 97 -------------- .pipelines/diabetes_regression-ci.yml | 119 +----------------- ..._regression-get-model-version-template.yml | 15 --- bootstrap/README.md | 17 +-- bootstrap/bootstrap.py | 4 +- docs/custom_model.md | 24 +++- docs/getting_started.md | 98 +++++++++++---- docs/split_cicd_pipelines.md | 113 ----------------- 10 files changed, 105 insertions(+), 397 deletions(-) rename .pipelines/{diabetes_regression-cd-deploy.yml => diabetes_regression-cd.yml} (100%) delete mode 100644 .pipelines/diabetes_regression-ci-train-register.yml delete mode 100644 .pipelines/diabetes_regression-get-model-version-template.yml delete mode 100644 docs/split_cicd_pipelines.md diff --git a/.pipelines/diabetes_regression-cd-deploy.yml b/.pipelines/diabetes_regression-cd.yml similarity index 100% rename from .pipelines/diabetes_regression-cd-deploy.yml rename to .pipelines/diabetes_regression-cd.yml diff --git a/.pipelines/diabetes_regression-ci-image.yml b/.pipelines/diabetes_regression-ci-image.yml index 6282fd31..d7c925bf 100644 --- a/.pipelines/diabetes_regression-ci-image.yml +++ b/.pipelines/diabetes_regression-ci-image.yml @@ -30,14 +30,9 @@ variables: value: 'scoring/scoreB.py' steps: -- task: AzureCLI@1 - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: | - set -e - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python3 -m ml_service.util.create_scoring_image - displayName: 'Create Scoring Image' +- template: diabetes_regression-package-model-template.yml + parameters: + modelId: $(MODEL_NAME):$(MODEL_VERSION) + scoringScriptPath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/$(SCORE_SCRIPT)' + condaFilePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/conda_dependencies.yml' diff --git a/.pipelines/diabetes_regression-ci-train-register.yml b/.pipelines/diabetes_regression-ci-train-register.yml deleted file mode 100644 index 5a539af0..00000000 --- a/.pipelines/diabetes_regression-ci-train-register.yml +++ /dev/null @@ -1,97 +0,0 @@ -# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, and registration of the diabetes_regression model. - -resources: - containers: - - container: mlops - image: mcr.microsoft.com/mlops/python:latest - -pr: none -trigger: - branches: - include: - - master - paths: - include: - - diabetes_regression/ - - ml_service/pipelines/diabetes_regression_build_train_pipeline.py - - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r.py - - ml_service/pipelines/diabetes_regression_build_train_pipeline_with_r_on_dbricks.py - -variables: -- template: diabetes_regression-variables-template.yml -- group: devopsforai-aml-vg - -pool: - vmImage: ubuntu-latest - -stages: -- stage: 'Model_CI' - displayName: 'Model CI' - jobs: - - job: "Model_CI_Pipeline" - displayName: "Model CI Pipeline" - container: mlops - timeoutInMinutes: 0 - steps: - - template: code-quality-template.yml - - task: AzureCLI@1 - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - # Invoke the Python building and publishing a training pipeline - python -m ml_service.pipelines.diabetes_regression_build_train_pipeline - displayName: 'Publish Azure Machine Learning Pipeline' - -- stage: 'Trigger_AML_Pipeline' - displayName: 'Train and evaluate model' - condition: succeeded() - variables: - BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' - jobs: - - job: "Get_Pipeline_ID" - condition: and(succeeded(), eq(coalesce(variables['auto-trigger-training'], 'true'), 'true')) - displayName: "Get Pipeline ID for execution" - container: mlops - timeoutInMinutes: 0 - steps: - - task: AzureCLI@1 - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - workingDirectory: $(Build.SourcesDirectory) - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.pipelines.run_train_pipeline --output_pipeline_id_file "pipeline_id.txt" --skip_train_execution - # Set AMLPIPELINEID variable for next AML Pipeline task in next job - AMLPIPELINEID="$(cat pipeline_id.txt)" - echo "##vso[task.setvariable variable=AMLPIPELINEID;isOutput=true]$AMLPIPELINEID" - name: 'getpipelineid' - displayName: 'Get Pipeline ID' - - job: "Run_ML_Pipeline" - dependsOn: "Get_Pipeline_ID" - displayName: "Trigger ML Training Pipeline" - timeoutInMinutes: 0 - pool: server - variables: - AMLPIPELINE_ID: $[ dependencies.Get_Pipeline_ID.outputs['getpipelineid.AMLPIPELINEID'] ] - steps: - - task: ms-air-aiagility.vss-services-azureml.azureml-restApi-task.MLPublishedPipelineRestAPITask@0 - displayName: 'Invoke ML pipeline' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - PipelineId: '$(AMLPIPELINE_ID)' - ExperimentName: '$(EXPERIMENT_NAME)' - PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)"}, "tags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}, "StepTags": {"BuildId": "$(Build.BuildId)", "BuildUri": "$(BUILD_URI)"}' - - job: "Training_Run_Report" - dependsOn: "Run_ML_Pipeline" - condition: always() - displayName: "Publish artifact if new model was registered" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-publish-model-artifact-template.yml diff --git a/.pipelines/diabetes_regression-ci.yml b/.pipelines/diabetes_regression-ci.yml index 56258d50..5a539af0 100644 --- a/.pipelines/diabetes_regression-ci.yml +++ b/.pipelines/diabetes_regression-ci.yml @@ -1,4 +1,4 @@ -# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, registration, deployment, and testing of the diabetes_regression model. +# Continuous Integration (CI) pipeline that orchestrates the training, evaluation, and registration of the diabetes_regression model. resources: containers: @@ -27,7 +27,6 @@ pool: stages: - stage: 'Model_CI' displayName: 'Model CI' - condition: not(variables['MODEL_BUILD_ID']) jobs: - job: "Model_CI_Pipeline" displayName: "Model CI Pipeline" @@ -48,8 +47,8 @@ stages: displayName: 'Publish Azure Machine Learning Pipeline' - stage: 'Trigger_AML_Pipeline' - displayName: 'Train model' - condition: and(succeeded(), not(variables['MODEL_BUILD_ID'])) + displayName: 'Train and evaluate model' + condition: succeeded() variables: BUILD_URI: '$(SYSTEM.COLLECTIONURI)$(SYSTEM.TEAMPROJECT)/_build/results?buildId=$(BUILD.BUILDID)' jobs: @@ -91,116 +90,8 @@ stages: - job: "Training_Run_Report" dependsOn: "Run_ML_Pipeline" condition: always() - displayName: "Determine if evaluation succeeded and new model is registered" + displayName: "Publish artifact if new model was registered" container: mlops timeoutInMinutes: 0 steps: - - template: diabetes_regression-get-model-version-template.yml - -- stage: 'Deploy_ACI' - displayName: 'Deploy to ACI' - dependsOn: Trigger_AML_Pipeline - condition: and(or(succeeded(), variables['MODEL_BUILD_ID']), variables['ACI_DEPLOYMENT_NAME']) - jobs: - - job: "Deploy_ACI" - displayName: "Deploy to ACI" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-get-model-version-template.yml - - task: ms-air-aiagility.vss-services-azureml.azureml-model-deploy-task.AMLModelDeploy@0 - displayName: 'Azure ML Model Deploy' - inputs: - azureSubscription: $(WORKSPACE_SVC_CONNECTION) - modelSourceType: manualSpec - modelName: '$(MODEL_NAME)' - modelVersion: $(MODEL_VERSION) - inferencePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/inference_config.yml' - deploymentTarget: ACI - deploymentName: $(ACI_DEPLOYMENT_NAME) - deployConfig: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/deployment_config_aci.yml' - overwriteExistingDeployment: true - - task: AzureCLI@1 - displayName: 'Smoke test' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type ACI --service "$(ACI_DEPLOYMENT_NAME)" - -- stage: 'Deploy_AKS' - displayName: 'Deploy to AKS' - dependsOn: Deploy_ACI - condition: and(succeeded(), variables['AKS_DEPLOYMENT_NAME']) - jobs: - - job: "Deploy_AKS" - displayName: "Deploy to AKS" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-get-model-version-template.yml - - task: ms-air-aiagility.vss-services-azureml.azureml-model-deploy-task.AMLModelDeploy@0 - displayName: 'Azure ML Model Deploy' - inputs: - azureSubscription: $(WORKSPACE_SVC_CONNECTION) - modelSourceType: manualSpec - modelName: '$(MODEL_NAME)' - modelVersion: $(MODEL_VERSION) - inferencePath: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/inference_config.yml' - deploymentTarget: AKS - aksCluster: $(AKS_COMPUTE_NAME) - deploymentName: $(AKS_DEPLOYMENT_NAME) - deployConfig: '$(Build.SourcesDirectory)/$(SOURCES_DIR_TRAIN)/scoring/deployment_config_aks.yml' - overwriteExistingDeployment: true - - task: AzureCLI@1 - displayName: 'Smoke test' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type AKS --service "$(AKS_DEPLOYMENT_NAME)" - -- stage: 'Deploy_Webapp' - displayName: 'Deploy to Webapp' - dependsOn: Trigger_AML_Pipeline - condition: and(or(succeeded(), variables['MODEL_BUILD_ID']), variables['WEBAPP_DEPLOYMENT_NAME']) - jobs: - - job: "Deploy_Webapp" - displayName: "Deploy to Webapp" - container: mlops - timeoutInMinutes: 0 - steps: - - template: diabetes_regression-get-model-version-template.yml - - task: AzureCLI@1 - displayName: 'Create scoring image and set IMAGE_LOCATION variable' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.create_scoring_image --output_image_location_file image_location.txt - # Output image location to Azure DevOps job - IMAGE_LOCATION="$(cat image_location.txt)" - echo "##vso[task.setvariable variable=IMAGE_LOCATION]$IMAGE_LOCATION" - - task: AzureWebAppContainer@1 - name: WebAppDeploy - displayName: 'Azure Web App on Container Deploy' - inputs: - azureSubscription: '$(AZURE_RM_SVC_CONNECTION)' - appName: '$(WEBAPP_DEPLOYMENT_NAME)' - resourceGroupName: '$(RESOURCE_GROUP)' - imageName: '$(IMAGE_LOCATION)' - - task: AzureCLI@1 - displayName: 'Smoke test' - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.util.smoke_test_scoring_service --type Webapp --service "$(WebAppDeploy.AppServiceApplicationUrl)/score" + - template: diabetes_regression-publish-model-artifact-template.yml diff --git a/.pipelines/diabetes_regression-get-model-version-template.yml b/.pipelines/diabetes_regression-get-model-version-template.yml deleted file mode 100644 index 870985a6..00000000 --- a/.pipelines/diabetes_regression-get-model-version-template.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Pipeline template that attempts to get the latest model version and adds it to the environment for subsequent tasks to use. -steps: -- task: AzureCLI@1 - inputs: - azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' - scriptLocation: inlineScript - inlineScript: | - set -e # fail on error - export SUBSCRIPTION_ID=$(az account show --query id -o tsv) - python -m ml_service.pipelines.diabetes_regression_verify_train_pipeline --build_id $(modelbuildid) --output_model_version_file "model_version.txt" - # Output model version to Azure DevOps job - MODEL_VERSION="$(cat model_version.txt)" - echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" - name: 'getversion' - displayName: "Determine if evaluation succeeded and new model is registered" diff --git a/bootstrap/README.md b/bootstrap/README.md index 27051f2b..0841cc30 100644 --- a/bootstrap/README.md +++ b/bootstrap/README.md @@ -1,18 +1,3 @@ # Bootstrap from MLOpsPython repository -To use this existing project structure and scripts for your new ML project, you can quickly get started from the existing repository, bootstrap and create a template that works for your ML project. - -Bootstrapping will prepare a directory structure for your project which includes: - -* renaming files and folders from the base project name `diabetes_regression` to your project name -* fixing imports and absolute path based on your project name -* deleting and cleaning up some directories - -To bootstrap from the existing MLOpsPython repository: - -1. Ensure Python 3 is installed locally -1. Clone this repository locally -1. Run bootstrap.py script -`python bootstrap.py -d [dirpath] -n [projectname]` - * `[dirpath]` is the absolute path to the root of the directory where MLOpsPython is cloned - * `[projectname]` is the name of your ML project +For steps on how to use the bootstrap script, please see the "Bootstrap the project" section of the [custom model guide](../docs/custom_model.md#bootstrap-the-project). diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 0186865f..25c36b80 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -84,14 +84,12 @@ def replace_project_name(project_dir, project_name, rename_name): files = [r".env.example", r".pipelines/code-quality-template.yml", r".pipelines/pr.yml", - r".pipelines/diabetes_regression-cd-deploy.yml", - r".pipelines/diabetes_regression-ci-train-register.yml", + r".pipelines/diabetes_regression-cd.yml", r".pipelines/diabetes_regression-ci.yml", r".pipelines/abtest.yml", r".pipelines/diabetes_regression-ci-image.yml", r".pipelines/diabetes_regression-publish-model-artifact-template.yml", # NOQA: E501 r".pipelines/diabetes_regression-get-model-id-artifact-template.yml", # NOQA: E501 - r".pipelines/diabetes_regression-get-model-version-template.yml", # NOQA: E501 r".pipelines/diabetes_regression-variables-template.yml", r"environment_setup/Dockerfile", r"environment_setup/install_requirements.sh", diff --git a/docs/custom_model.md b/docs/custom_model.md index bce1fb8a..d21c8b8d 100644 --- a/docs/custom_model.md +++ b/docs/custom_model.md @@ -3,11 +3,11 @@ This document provides steps to follow when using this repository as a template to train models and deploy the models with real-time inference in Azure ML with your own scripts and data. 1. Follow the MLOpsPython [Getting Started](getting_started.md) guide -1. Follow the MLOpsPython [bootstrap instructions](../bootstrap/README.md) to create your project starting point +1. Bootstrap the project 1. Configure training data 1. [If necessary] Convert your ML experimental code into production ready code 1. Replace the training code -1. Update the evaluation code +1. [Optional] Update the evaluation code 1. Customize the build agent environment 1. [If appropriate] Replace the score code @@ -17,24 +17,36 @@ Follow the [Getting Started](getting_started.md) guide to set up the infrastruct Take a look at the [Repo Details](code_description.md) document for a description of the structure of this repository. -## Follow the Bootstrap instructions +## Bootstrap the project -The [Bootstrap from MLOpsPython repository](../bootstrap/README.md) guide will help you to quickly prepare the repository for your project. +Bootstrapping will prepare the directory structure to be used for your project name which includes: + +* renaming files and folders from the base project name `diabetes_regression` to your project name +* fixing imports and absolute path based on your project name +* deleting and cleaning up some directories **Note:** Since the bootstrap script will rename the `diabetes_regression` folder to the project name of your choice, we'll refer to your project as `[project name]` when paths are involved. +To bootstrap from the existing MLOpsPython repository: + +1. Ensure Python 3 is installed locally +1. From a local copy of the code, run the `bootstrap.py` script in the `bootstrap` folder +`python bootstrap.py -d [dirpath] -n [projectname]` + * `[dirpath]` is the absolute path to the root of the directory where MLOpsPython is cloned + * `[projectname]` is the name of your ML project + ## Configure training data The training ML pipeline uses a [sample diabetes dataset](https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_diabetes.html) as training data. -To use your own data: +**Important** Convert the template to use your own Azure ML Dataset for model training via these steps: 1. [Create a Dataset](https://docs.microsoft.com/azure/machine-learning/how-to-create-register-datasets) in your Azure ML workspace 1. Update the `DATASET_NAME` and `DATASTORE_NAME` variables in `.pipelines/[project name]-variables-template.yml` ## Convert your ML experimental code into production ready code -The MLOpsPython template creates an Azure Machine Learning (ML) pipeline that invokes a set of [Azure ML pipeline steps](https://docs.microsoft.com/python/api/azureml-pipeline-steps/azureml.pipeline.steps) (see `ml_service/pipelines/[project name]_build_train_pipeline.py`). If your experiment is currently in a Jupyter notebook, it will need to be refactored into scripts that can be run independantly and dropped into the template which the existing Azure ML pipeline steps utilize. +The MLOpsPython template creates an Azure Machine Learning (ML) pipeline that invokes a set of [Azure ML pipeline steps](https://docs.microsoft.com/python/api/azureml-pipeline-steps/azureml.pipeline.steps) (see `ml_service/pipelines/[project name]_build_train_pipeline.py`). If your experiment is currently in a Jupyter notebook, it will need to be refactored into scripts that can be run independently and dropped into the template which the existing Azure ML pipeline steps utilize. 1. Refactor your experiment code into scripts 1. [Recommended] Prepare unit tests diff --git a/docs/getting_started.md b/docs/getting_started.md index 2c63fa94..57d6b234 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -1,11 +1,12 @@ # Getting Started with MLOpsPython -This guide shows how to get MLOpsPython working with a sample ML project ***diabetes_regression***. The project creates a linear regression model to predict diabetes. You can adapt this example to use with your own project. +This guide shows how to get MLOpsPython working with a sample ML project ***diabetes_regression***. The project creates a linear regression model to predict diabetes and has CI/CD DevOps practices enabled for model training and serving when these steps are completed in this getting started guide. -We recommend working through this guide completely to ensure everything is working in your environment. After the sample is working, follow the [bootstrap instructions](../bootstrap/README.md) to convert the ***diabetes_regression*** sample into a starting point for your project. +If you would like to bring your own model code to use this template structure, follow the [custom model](custom_model.md) guide. We recommend completing this getting started guide with the diabetes model through ACI deployment first to ensure everything is working in your environment before converting the template to use your own model code. - [Setting up Azure DevOps](#setting-up-azure-devops) + - [Install the Azure Machine Learning extension](#install-the-azure-machine-learning-extension) - [Get the code](#get-the-code) - [Create a Variable Group for your Pipeline](#create-a-variable-group-for-your-pipeline) - [Variable Descriptions](#variable-descriptions) @@ -33,6 +34,12 @@ You'll use Azure DevOps for running the multi-stage pipeline with build, model t If you already have an Azure DevOps organization, create a new project using the guide at [Create a project in Azure DevOps and TFS](https://docs.microsoft.com/en-us/azure/devops/organizations/projects/create-project?view=azure-devops). +### Install the Azure Machine Learning extension + +Install the **Azure Machine Learning** extension to your Azure DevOps organization from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-air-aiagility.vss-services-azureml). + +This extension contains the Azure ML pipeline tasks and adds the ability to create Azure ML Workspace service connections. + ## Get the code We recommend using the [repository template](https://github.com/microsoft/MLOpsPython/generate), which effectively forks the repository to your own GitHub location and squashes the history. You can use the resulting repository for this guide and for your own experimentation. @@ -118,8 +125,6 @@ Check that the newly created resources appear in the [Azure Portal](https://port At this point, you should have an Azure ML Workspace created. Similar to the Azure Resource Manager service connection, you need to create an additional one for the Azure ML Workspace. -Install the **Azure Machine Learning** extension to your Azure DevOps organization from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-air-aiagility.vss-services-azureml). The extension is required for the service connection. - Create a new service connection to your Azure ML Workspace using the [Machine Learning Extension](https://marketplace.visualstudio.com/items?itemName=ms-air-aiagility.vss-services-azureml) instructions to enable executing the Azure ML training pipeline. The connection name needs to match `WORKSPACE_SVC_CONNECTION` that you set in the variable group above. ![Created resources](./images/ml-ws-svc-connection.png) @@ -127,25 +132,30 @@ Create a new service connection to your Azure ML Workspace using the [Machine Le **Note:** Similar to the Azure Resource Manager service connection you created earlier, creating a service connection with Azure Machine Learning workspace scope requires 'Owner' or 'User Access Administrator' permissions on the Workspace. You'll need sufficient permissions to register an application with your Azure AD tenant, or you can get the ID and secret of a service principal from your Azure AD Administrator. That principal must have Contributor permissions on the Azure ML Workspace. -## Set up Build, Release Trigger, and Release Multi-Stage Pipeline +## Set up Build, Release Trigger, and Release Multi-Stage Pipelines -Now that you've provisioned all the required Azure resources and service connections, you can set up the pipeline for deploying your machine learning model to production. The pipeline has a sequence of stages for: +Now that you've provisioned all the required Azure resources and service connections, you can set up the pipelines for training (CI) and deploying (CD) your machine learning model to production. -1. **Model Code Continuous Integration:** triggered on code changes to master branch on GitHub. Runs linting, unit tests, code coverage and publishes a training pipeline. -1. **Train Model**: invokes the Azure ML service to trigger the published training pipeline to train, evaluate, and register a model. -1. **Release Deployment:** deploys a model to either [Azure Container Instances (ACI)](https://azure.microsoft.com/en-us/services/container-instances/), [Azure Kubernetes Service (AKS)](https://azure.microsoft.com/en-us/services/kubernetes-service), or [Azure App Service](https://docs.microsoft.com/en-us/azure/machine-learning/service/how-to-deploy-app-service) environments. For simplicity, you're going to initially focus on Azure Container Instances. See [Further Exploration](#further-exploration) for other deployment types. +1. **Model CI, training, evaluation, and registration** - triggered on code changes to master branch on GitHub. Runs linting, unit tests, code coverage, and publishes and runs the training pipeline. If a new model is registered after evaluation, it creates a build artifact containing the JSON metadata of the model. Definition: [diabetes_regression-ci.yml](../.pipelines/diabetes_regression-ci.yml). +1. **Release deployment** - consumes the artifact of the previous pipeline and deploys a model to either [Azure Container Instances (ACI)](https://azure.microsoft.com/en-us/services/container-instances/), [Azure Kubernetes Service (AKS)](https://azure.microsoft.com/en-us/services/kubernetes-service), or [Azure App Service](https://docs.microsoft.com/en-us/azure/machine-learning/service/how-to-deploy-app-service) environments. See [Further Exploration](#further-exploration) for other deployment types. Definition: [diabetes_regression-cd.yml](../.pipelines/diabetes_regression-cd.yml). 1. **Note:** Edit the pipeline definition to remove unused stages. For example, if you're deploying to Azure Container Instances and Azure Kubernetes Service only, delete the unused `Deploy_Webapp` stage. -### Set up the Pipeline +These pipelines use a Docker container on the Azure Pipelines agents to accomplish the pipeline steps. The container image ***mcr.microsoft.com/mlops/python:latest*** is built with [this Dockerfile](../environment_setup/Dockerfile) and has all the necessary dependencies installed for MLOpsPython and ***diabetes_regression***. This image is an example of a custom Docker image with a pre-baked environment. The environment is guaranteed to be the same on any building agent, VM, or local machine. In your project, you'll want to build your own Docker image that only contains the dependencies and tools required for your use case. Your image will probably be smaller and faster, and it will be maintained by your team. + +### Set up the Model CI, training, evaluation, and registration pipeline In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-ci.yml](../.pipelines/diabetes_regression-ci.yml) pipeline definition in your forked repository. -![Configure CI build pipeline](./images/ci-build-pipeline-configure.png) +If you plan to use the release deployment pipeline (in the next section), you will need to rename this pipeline to `Model-Train-Register-CI`. Once the pipeline is finished, check the execution result: -![Build](./images/multi-stage-aci.png) +![Build](./images/model-train-register.png) + +And the pipeline artifacts: + +![Build](./images/model-train-register-artifacts.png) Also check the published training pipeline in the **mlops-AML-WS** workspace in [Azure Portal](https://portal.azure.com/): @@ -153,6 +163,12 @@ Also check the published training pipeline in the **mlops-AML-WS** workspace in Great, you now have the build pipeline set up which automatically triggers every time there's a change in the master branch! +After the pipeline is finished, you'll see a new model in the **ML Workspace**: + +![Trained model](./images/trained-model.png) + +To disable the automatic trigger of the training pipeline, change the `auto-trigger-training` variable as listed in the `.pipelines\diabetes_regression-ci.yml` pipeline to `false`. You can also override the variable at runtime execution of the pipeline. + The pipeline stages are summarized below: #### Model CI @@ -168,28 +184,64 @@ The pipeline stages are summarized below: - This is an **agentless** job. The CI pipeline can wait for ML pipeline completion for hours or even days without using agent resources. - Determine if a new model was registered by the *ML Training Pipeline*. - If the model evaluation determines that the new model doesn't perform any better than the previous one, the new model won't register and the *ML Training Pipeline* will be **canceled**. In this case, you'll see a message in the 'Train Model' job under the 'Determine if evaluation succeeded and new model is registered' step saying '**Model was not registered for this run.**' - - See [evaluate_model.py](../diabetes_regression/evaluate/evaluate_model.py#L118) for the evaluation logic and [diabetes_regression_verify_train_pipeline.py](../ml_service/pipelines/diabetes_regression_verify_train_pipeline.py#L54) for the ML pipeline reporting logic. + - See [evaluate_model.py](../diabetes_regression/evaluate/evaluate_model.py#L118) for the evaluation logic. - [Additional Variables and Configuration](#additional-variables-and-configuration) for configuring this and other behavior. -#### Deploy to ACI +#### Create pipeline artifact -- Deploy the model to the QA environment in [Azure Container Instances](https://azure.microsoft.com/en-us/services/container-instances/). -- Smoke test - - The test sends a sample query to the scoring web service and verifies that it returns the expected response. Have a look at the [smoke test code](../ml_service/util/smoke_test_scoring_service.py) for an example. +- Get the info about the registered model +- Create a pipeline artifact called `model` that contains a `model.json` file containing the model information. -The pipeline uses a Docker container on the Azure Pipelines agents to accomplish the pipeline steps. The container image ***mcr.microsoft.com/mlops/python:latest*** is built with [this Dockerfile](../environment_setup/Dockerfile) and has all the necessary dependencies installed for MLOpsPython and ***diabetes_regression***. This image is an example of a custom Docker image with a pre-baked environment. The environment is guaranteed to be the same on any building agent, VM, or local machine. In your project, you'll want to build your own Docker image that only contains the dependencies and tools required for your use case. Your image will probably be smaller and faster, and it will be maintained by your team. +### Set up the Release deployment pipeline -After the pipeline is finished, you'll see a new model in the **ML Workspace**: +--- +**PREREQUISITE** -![Trained model](./images/trained-model.png) +In order to use this pipeline: -To disable the automatic trigger of the training pipeline, change the `auto-trigger-training` variable as listed in the `.pipelines\diabetes_regression-ci.yml` pipeline to `false`. You can also override the variable at runtime execution of the pipeline. +1. Follow the steps to set up the Model CI, training, evaluation, and registration pipeline. +1. You **must** rename your model CI/train/eval/register pipeline to `Model-Train-Register-CI`. + +The release deploymment pipeline relies on the model CI pipeline and references it by name. -To skip model training and registration, and deploy a model successfully registered by a previous build (for testing changes to the score file or inference configuration), add the variable `MODEL_BUILD_ID` when the pipeline is queued, and set the value to the ID of the previous build. +--- + +In this section, we will set up the pipeline for release deployment to ACI, AKS, or Webapp. This pipeline uses the resulting artifact of the [Model-Train-Register-CI pipeline](#) to identify the model to be deployed. + +This pipeline has the following behaviors: + +- The pipeline will **automatically trigger** on completion of the Model-Train-Register-CI pipeline +- The pipeline will default to using the latest successful build of the Model-Train-Register-CI pipeline. It will deploy the model produced by that build. +- You can specify a `Model-Train-Register-CI` build ID when running the pipeline manually. You can find this in the url of the build, and the model registered from that build will also be tagged with the build ID. This is useful to skip model training and registration, and deploy a model successfully registered by a `Model-Train-Register-CI` build. + +In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-cd.yml](../.pipelines/diabetes_regression-cd.yml) +pipeline definition in your forked repository. + +Your first run will use the latest model created by the `Model-Train-Register-CI` pipeline. + +Once the pipeline is finished, check the execution result: + +![Build](./images/model-deploy-result.png) + +To specify a particular build's model, set the `Model Train CI Build Id` parameter to the build Id you would like to use. + +![Build](./images/model-deploy-configure.png) + +Once your pipeline run begins, you can see the model name and version downloaded from the `Model-Train-Register-CI` pipeline. + +![Build](./images/model-deploy-artifact-logs.png) + +The pipeline has the following stage: + +#### Deploy to ACI + +- Deploy the model to the QA environment in [Azure Container Instances](https://azure.microsoft.com/en-us/services/container-instances/). +- Smoke test + - The test sends a sample query to the scoring web service and verifies that it returns the expected response. Have a look at the [smoke test code](../ml_service/util/smoke_test_scoring_service.py) for an example. ## Further Exploration -You should now have a working pipeline that can get you started with MLOpsPython. Below are some additional features offered that might suit your scenario. +You should now have a working set of pipelines that can get you started with MLOpsPython. Below are some additional features offered that might suit your scenario. ### Deploy the model to Azure Kubernetes Service diff --git a/docs/split_cicd_pipelines.md b/docs/split_cicd_pipelines.md deleted file mode 100644 index a0071d78..00000000 --- a/docs/split_cicd_pipelines.md +++ /dev/null @@ -1,113 +0,0 @@ -# CI/CD pipelines for model train/register and deployment - -Follow this guide to set up two separate pipelines to train/register models and deploy models. This set of pipelines is functionally similar to the [diabetes_regression-ci.yml](../.pipelines/diabetes_regression-ci.yml) pipeline in the [getting started](getting_started.md) guide. - -1. **Model CI, training, evaluation, and registration** - triggered on code changes to master branch on GitHub. Runs linting, unit tests, code coverage, and publishes and runs the training pipeline. If a new model is registered after evaluation, it creates a build artifact containing the JSON metadata of the model. Definition: [diabetes_regression-train-register.yml](../.pipelines/diabetes_regression-train-register.yml). -1. **Release deployment** - consumes the artifact of the previous pipeline and deploys a model to either [Azure Container Instances (ACI)](https://azure.microsoft.com/en-us/services/container-instances/), [Azure Kubernetes Service (AKS)](https://azure.microsoft.com/en-us/services/kubernetes-service), or [Azure App Service](https://docs.microsoft.com/en-us/azure/machine-learning/service/how-to-deploy-app-service) environments. Definition: [diabetes_regression-deploy.yml](../.pipelines/diabetes_regression-deploy.yml). - -## Prerequisites - ---- - -It is recommended to go through the [getting started guide](getting_started.md) before starting this guide. - ---- - -Before continuing this guide, you will need: - -- An existing workspace. To setup your environment with a new workspace, follow the steps here. -- An Azure DevOps Service Connection with your Azure ML Workspace. -- A variable group named **``devopsforai-aml-vg``** with the required variables set. - -## Model CI, training, evaluation, and registration pipeline - -In this section, we will create the pipeline that will perform model IC, training, evaluation, and registration. - -### Set up the Pipeline - -In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-ci-train-register.yml](../.pipelines/diabetes_regression-ci-train-register.yml) -pipeline definition in your forked repository. - -If you plan to use the release deployment pipeline (in the next section), you will need to rename this pipeline to `Model-Train-Register-CI`. - -Once the pipeline is finished, check the execution result: - -![Build](./images/model-train-register.png) - -And the pipeline artifacts: - -![Build](./images/model-train-register-artifacts.png) - -The pipeline stages are summarized below: - -#### Model CI - -- Linting (code quality analysis) -- Unit tests and code coverage analysis -- Build and publish *ML Training Pipeline* in an *ML Workspace* - -#### Train model - -- Determine the ID of the *ML Training Pipeline* published in the previous stage. -- Trigger the *ML Training Pipeline* and waits for it to complete. - - This is an **agentless** job. The CI pipeline can wait for ML pipeline completion for hours or even days without using agent resources. -- Determine if a new model was registered by the *ML Training Pipeline*. - - If the model evaluation determines that the new model doesn't perform any better than the previous one, the new model won't register and the *ML Training Pipeline* will be **canceled**. In this case, you'll see a message in the 'Train Model' job under the 'Determine if evaluation succeeded and new model is registered' step saying '**Model was not registered for this run.**' - - See [evaluate_model.py](../diabetes_regression/evaluate/evaluate_model.py#L118) for the evaluation logic. - - [Additional Variables and Configuration](getting_started.md#additional-variables-and-configuration) for configuring this and other behavior. - -#### Create pipeline artifact - -- Get the info about the registered model -- Create a pipeline artifact called `model` that contains a `model.json` file containing the model information. - -## Release deployment pipeline - ---- -**PREREQUISITE** - -In order to use this pipeline: - -1. Follow the steps to set up the Model CI, training, evaluation, and registration pipeline. -1. You **must** rename your model CI/train/eval/register pipeline to `Model-Train-Register-CI`. - -The release deploymment pipeline relies on the model CI pipeline and references it by name. - ---- - -In this section, we will set up the pipeline for release deployment to ACI, AKS, or Webapp. This pipeline uses the resulting artifact of the [Model-Train-Register-CI pipeline](#) to identify the model to be deployed. - -This pipeline has the following behaviors: - -- The pipeline will **automatically trigger** on completion of the Model-Train-Register-CI pipeline -- The pipeline will default to using the latest successful build of the Model-Train-Register-CI pipeline. It will deploy the model produced by that build. -- You can specify a `Model-Train-Register-CI` build ID when running the pipeline manually. You can find this in the url of the build, and the model registered from that build will also be tagged with the build ID. - -### Set up the pipeline - -In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-cd-deploy.yml](../.pipelines/diabetes_regression-cd-deploy.yml) -pipeline definition in your forked repository. - -Your first run will use the latest model created by the `Model-Train-Register-CI` pipeline. - -Once the pipeline is finished, check the execution result: - -![Build](./images/model-deploy-result.png) - -To specify a particular build's model, set the `Model Train CI Build Id` parameter to the build Id you would like to use. - -![Build](./images/model-deploy-configure.png) - -Once your pipeline run begins, you can see the model name and version downloaded from the `Model-Train-Register-CI` pipeline. - -![Build](./images/model-deploy-artifact-logs.png) - -The pipeline has the following stage: - -#### Deploy to ACI - -- Deploy the model to the QA environment in [Azure Container Instances](https://azure.microsoft.com/en-us/services/container-instances/). -- Smoke test - - The test sends a sample query to the scoring web service and verifies that it returns the expected response. Have a look at the [smoke test code](../ml_service/util/smoke_test_scoring_service.py) for an example. - -See [Further Exploration](getting_started.md#further-exploration) to learn about other deployment targets. From cd450ec6fca055d20e7e15700f1543424709ba40 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 15 Jun 2020 18:37:41 -0700 Subject: [PATCH 21/28] remove need for model build id --- .../diabetes_regression-publish-model-artifact-template.yml | 2 +- .pipelines/diabetes_regression-variables-template.yml | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.pipelines/diabetes_regression-publish-model-artifact-template.yml b/.pipelines/diabetes_regression-publish-model-artifact-template.yml index 77fb53e4..00e45105 100644 --- a/.pipelines/diabetes_regression-publish-model-artifact-template.yml +++ b/.pipelines/diabetes_regression-publish-model-artifact-template.yml @@ -16,7 +16,7 @@ steps: set -e # fail on error # Get the model using the build ID tag - FOUND_MODEL=$(az ml model list -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) --tag BuildId=$(modelbuildid) --query '[0]') + FOUND_MODEL=$(az ml model list -g $(RESOURCE_GROUP) --workspace-name $(WORKSPACE_NAME) --tag BuildId=$(Build.BuildId) --query '[0]') # If the variable is empty, print and fail [[ -z "$FOUND_MODEL" ]] && { echo "Model was not registered for this run." ; exit 1; } diff --git a/.pipelines/diabetes_regression-variables-template.yml b/.pipelines/diabetes_regression-variables-template.yml index def14549..39d6dbee 100644 --- a/.pipelines/diabetes_regression-variables-template.yml +++ b/.pipelines/diabetes_regression-variables-template.yml @@ -63,12 +63,6 @@ variables: # - name: ALLOW_RUN_CANCEL # value: "true" - # For debugging deployment issues. Specify a build id with the MODEL_BUILD_ID pipeline variable at queue time - # to skip training and deploy a model registered by a previous build. - - name: modelbuildid - value: $[coalesce(variables['MODEL_BUILD_ID'], variables['Build.BuildId'])] - - # Flag to allow rebuilding the AML Environment after it was built for the first time. This enables dependency updates from conda_dependencies.yaml. # - name: AML_REBUILD_ENVIRONMENT # value: "false" From c6167eb9774520a4de742776ee57c182da90d111 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 22 Jun 2020 13:50:04 -0700 Subject: [PATCH 22/28] fix batch scoring --- .pipelines/diabetes_regression-batchscoring-ci.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.pipelines/diabetes_regression-batchscoring-ci.yml b/.pipelines/diabetes_regression-batchscoring-ci.yml index 8c7382fb..19936891 100644 --- a/.pipelines/diabetes_regression-batchscoring-ci.yml +++ b/.pipelines/diabetes_regression-batchscoring-ci.yml @@ -48,8 +48,12 @@ stages: container: mlops timeoutInMinutes: 0 steps: - - download: none - template: code-quality-template.yml + - template: diabetes_regression-get-model-id-artifact-template.yml + parameters: + projectId: '$(resources.pipeline.model-train-ci.projectID)' + pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' + artifactBuildId: ${{ parameters.artifactBuildId }} - task: AzureCLI@1 name: publish_batchscore inputs: @@ -70,12 +74,6 @@ stages: variables: pipeline_id: $[ dependencies.Build_Batch_Scoring_Pipeline.outputs['publish_batchscore.pipeline_id']] steps: - - download: none - - template: diabetes_regression-get-model-id-artifact-template.yml - parameters: - projectId: '$(resources.pipeline.model-train-ci.projectID)' - pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' - artifactBuildId: ${{ parameters.artifactBuildId }} - task: ms-air-aiagility.vss-services-azureml.azureml-restApi-task.MLPublishedPipelineRestAPITask@0 displayName: 'Invoke Batch Scoring pipeline' inputs: From 4bfa69b5d85ed52f5d1fbf7254399d0caa2c62f6 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 22 Jun 2020 14:52:44 -0700 Subject: [PATCH 23/28] use model version for batch scoring --- .../diabetes_regression-batchscoring-ci.yml | 2 +- .../evaluate/evaluate_model.py | 5 +- .../scoring/parallel_batchscore.py | 27 +++++++-- diabetes_regression/util/model_helper.py | 59 +++++-------------- ...sion_build_parallel_batchscore_pipeline.py | 42 ++----------- ...abetes_regression_verify_train_pipeline.py | 6 +- .../run_parallel_batchscore_pipeline.py | 1 + 7 files changed, 51 insertions(+), 91 deletions(-) diff --git a/.pipelines/diabetes_regression-batchscoring-ci.yml b/.pipelines/diabetes_regression-batchscoring-ci.yml index 19936891..8f861dd7 100644 --- a/.pipelines/diabetes_regression-batchscoring-ci.yml +++ b/.pipelines/diabetes_regression-batchscoring-ci.yml @@ -80,5 +80,5 @@ stages: azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' PipelineId: '$(pipeline_id)' ExperimentName: '$(EXPERIMENT_NAME)' - PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)"}' + PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)", "model_version": "$(MODEL_VERSION)"}' \ No newline at end of file diff --git a/diabetes_regression/evaluate/evaluate_model.py b/diabetes_regression/evaluate/evaluate_model.py index 125a16a5..75343a52 100644 --- a/diabetes_regression/evaluate/evaluate_model.py +++ b/diabetes_regression/evaluate/evaluate_model.py @@ -109,7 +109,10 @@ tag_name = 'experiment_name' model = get_latest_model( - model_name, tag_name, exp.name, ws) + model_name=model_name, + tag_name=tag_name, + tag_value=exp.name, + aml_workspace=ws) if (model is not None): production_model_mse = 10000 diff --git a/diabetes_regression/scoring/parallel_batchscore.py b/diabetes_regression/scoring/parallel_batchscore.py index aef6f3fb..eae0cd7e 100644 --- a/diabetes_regression/scoring/parallel_batchscore.py +++ b/diabetes_regression/scoring/parallel_batchscore.py @@ -29,7 +29,7 @@ import joblib import sys from typing import List -from util.model_helper import get_latest_model +from util.model_helper import get_model model = None @@ -59,6 +59,19 @@ def parse_args() -> List[str]: model_name = model_name_param[0][1] + model_version_param = [ + (sys.argv[idx], sys.argv[idx + 1]) + for idx, itm in enumerate(sys.argv) + if itm == "--model_version" + ] + + if len(model_version_param) == 0: + raise ValueError( + "Model name is required but no model name parameter was passed to the script" # NOQA: E501 + ) + + model_version = model_version_param[0][1] + model_tag_name_param = [ (sys.argv[idx], sys.argv[idx + 1]) for idx, itm in enumerate(sys.argv) @@ -83,7 +96,7 @@ def parse_args() -> List[str]: else model_tag_value_param[0][1] ) - return [model_name, model_tag_name, model_tag_value] + return [model_name, model_version, model_tag_name, model_tag_value] def init(): @@ -95,12 +108,14 @@ def init(): print("Initializing batch scoring script...") model_filter = parse_args() - amlmodel = get_latest_model( - model_filter[0], model_filter[1], model_filter[2] - ) # NOQA: E501 + amlmodel = get_model( + model_name=env.model_filter[0], + model_version=model_filter[1], + tag_name=model_filter[2], + tag_value=model_filter[3]) global model - modelpath = amlmodel.get_model_path(model_name=model_filter[0]) + modelpath = Model.get_model_path(model_name=model_filter[0]) model = joblib.load(modelpath) print("Loaded model {}".format(model_filter[0])) except Exception as ex: diff --git a/diabetes_regression/util/model_helper.py b/diabetes_regression/util/model_helper.py index ceceff41..31dc1bab 100644 --- a/diabetes_regression/util/model_helper.py +++ b/diabetes_regression/util/model_helper.py @@ -22,8 +22,9 @@ def get_current_workspace() -> Workspace: return experiment.workspace -def get_latest_model( +def get_model( model_name: str, + model_version: int = None, # If none, return latest model tag_name: str = None, tag_value: str = None, aml_workspace: Workspace = None @@ -35,53 +36,25 @@ def get_latest_model( Parameters: aml_workspace (Workspace): aml.core Workspace that the model lives. model_name (str): name of the model we are looking for + (optional) model_version (str): version of the model. Returns latest if not provided. (optional) tag (str): the tag value & name the model was registered under. Return: A single aml model from the workspace that matches the name and tag. """ - try: - # Validate params. cannot be None. - if model_name is None: - raise ValueError("model_name[:str] is required") - - if aml_workspace is None: + if aml_workspace is None: print("No workspace defined - using current experiment workspace.") aml_workspace = get_current_workspace() - model_list = None - tag_ext = "" - - # Get lastest model - # True: by name and tags - if tag_name is not None and tag_value is not None: - model_list = AMLModel.list( - aml_workspace, name=model_name, - tags=[[tag_name, tag_value]], latest=True - ) - tag_ext = f"tag_name: {tag_name}, tag_value: {tag_value}." - # False: Only by name - else: - model_list = AMLModel.list( - aml_workspace, name=model_name, latest=True) - - # latest should only return 1 model, but if it does, - # then maybe sdk or source code changed. - - # define the error messages - too_many_model_message = ("Found more than one latest model. " - f"Models found: {model_list}. " - f"{tag_ext}") - - no_model_found_message = (f"No Model found with name: {model_name}. " - f"{tag_ext}") - - if len(model_list) > 1: - raise ValueError(too_many_model_message) - if len(model_list) == 1: - return model_list[0] - else: - print(no_model_found_message) - return None - except Exception: - raise + if tagname is not None and tagvalue is not None: + model = Model(aml_workspace, name=model_name, version=model_version, tags=[[tag_name, tag_value]]) + elif (tagname is None and tagvalue is not None) or ( + tagvalue is None and tagname is not None + ): + raise ValueError( + "model_tag_name and model_tag_value should both be supplied" + + "or excluded" # NOQA: E501 + ) + else: + model = Model(aml_workspace, name=env.model_name, version=env.model_version) + return model diff --git a/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py b/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py index d7acbf46..df4d2d82 100644 --- a/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py +++ b/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py @@ -61,37 +61,6 @@ def parse_args() -> Namespace: args = parser.parse_args() return args - -def get_model( - ws: Workspace, env: Env, tagname: str = None, tagvalue: str = None -) -> Model: - """ - Gets a model from the models registered with the AML workspace. - If a tag/value pair is supplied, uses it to filter. - - :param ws: Current AML workspace - :param env: Environment variables - :param tagname: Optional tag name, default is None - :param tagvalue: Optional tag value, default is None - - :returns: Model - - :raises: ValueError - """ - if tagname is not None and tagvalue is not None: - model = Model(ws, name=env.model_name, tags=[[tagname, tagvalue]]) - elif (tagname is None and tagvalue is not None) or ( - tagvalue is None and tagname is not None - ): - raise ValueError( - "model_tag_name and model_tag_value should both be supplied" - + "or excluded" # NOQA: E501 - ) - else: - model = Model(ws, name=env.model_name) - return model - - def get_or_create_datastore( datastorename: str, ws: Workspace, env: Env, input: bool = True ) -> Datastore: @@ -331,7 +300,6 @@ def get_run_configs( def get_scoring_pipeline( - model: Model, scoring_dataset: Dataset, output_loc: PipelineData, score_run_config: ParallelRunConfig, @@ -362,6 +330,9 @@ def get_scoring_pipeline( model_name_param = PipelineParameter( "model_name", default_value=env.model_name ) # NOQA: E501 + model_version_param = PipelineParameter( + "model_version", default_value=env.model_version + ) # NOQA: E501 model_tag_name_param = PipelineParameter( "model_tag_name", default_value=" " ) # NOQA: E501 @@ -376,6 +347,8 @@ def get_scoring_pipeline( arguments=[ "--model_name", model_name_param, + "--model_version", + model_version_param, "--model_tag_name", model_tag_name_param, "--model_tag_value", @@ -450,12 +423,7 @@ def build_batchscore_pipeline(): aml_workspace, aml_compute_score, env ) - trained_model = get_model( - aml_workspace, env, args.model_tag_name, args.model_tag_value - ) - scoring_pipeline = get_scoring_pipeline( - trained_model, input_dataset, output_location, scoring_runconfig, diff --git a/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py b/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py index 306f2259..e02ea0dc 100644 --- a/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py +++ b/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py @@ -3,7 +3,7 @@ import os from azureml.core import Run, Experiment, Workspace from ml_service.util.env_variables import Env -from diabetes_regression.util.model_helper import get_latest_model +from diabetes_regression.util.model_helper import get_model def main(): @@ -53,8 +53,8 @@ def main(): try: tag_name = 'BuildId' - model = get_latest_model( - model_name, tag_name, build_id, exp.workspace) + model = get_model( + model_name=model_name, tag_name=tag_name, tag_value=build_id, aml_workspace=exp.workspace) if (model is not None): print("Model was registered for this build.") if (model is None): diff --git a/ml_service/pipelines/run_parallel_batchscore_pipeline.py b/ml_service/pipelines/run_parallel_batchscore_pipeline.py index ec6cebae..c046eb9c 100644 --- a/ml_service/pipelines/run_parallel_batchscore_pipeline.py +++ b/ml_service/pipelines/run_parallel_batchscore_pipeline.py @@ -115,6 +115,7 @@ def run_batchscore_pipeline(): scoringpipeline, pipeline_parameters={ "model_name": env.model_name, + "model_version": env.model_version, "model_tag_name": " ", "model_tag_value": " ", }, From c9fc6edde8f69ad515ac29647e8544a4ffbbf328 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 22 Jun 2020 15:08:41 -0700 Subject: [PATCH 24/28] linting --- .../scoring/parallel_batchscore.py | 8 ++++---- diabetes_regression/util/model_helper.py | 16 ++++++++++------ ...ression_build_parallel_batchscore_pipeline.py | 1 + .../diabetes_regression_verify_train_pipeline.py | 6 +++++- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/diabetes_regression/scoring/parallel_batchscore.py b/diabetes_regression/scoring/parallel_batchscore.py index eae0cd7e..7bf01a61 100644 --- a/diabetes_regression/scoring/parallel_batchscore.py +++ b/diabetes_regression/scoring/parallel_batchscore.py @@ -60,9 +60,9 @@ def parse_args() -> List[str]: model_name = model_name_param[0][1] model_version_param = [ - (sys.argv[idx], sys.argv[idx + 1]) - for idx, itm in enumerate(sys.argv) - if itm == "--model_version" + (sys.argv[idx], sys.argv[idx + 1]) + for idx, itm in enumerate(sys.argv) + if itm == "--model_version" ] if len(model_version_param) == 0: @@ -109,7 +109,7 @@ def init(): model_filter = parse_args() amlmodel = get_model( - model_name=env.model_filter[0], + model_name=model_filter[0], model_version=model_filter[1], tag_name=model_filter[2], tag_value=model_filter[3]) diff --git a/diabetes_regression/util/model_helper.py b/diabetes_regression/util/model_helper.py index 31dc1bab..d5db2fb7 100644 --- a/diabetes_regression/util/model_helper.py +++ b/diabetes_regression/util/model_helper.py @@ -24,7 +24,7 @@ def get_current_workspace() -> Workspace: def get_model( model_name: str, - model_version: int = None, # If none, return latest model + model_version: int = None, # If none, return latest model tag_name: str = None, tag_value: str = None, aml_workspace: Workspace = None @@ -36,18 +36,22 @@ def get_model( Parameters: aml_workspace (Workspace): aml.core Workspace that the model lives. model_name (str): name of the model we are looking for - (optional) model_version (str): version of the model. Returns latest if not provided. + (optional) model_version (str): model version. Latest if not provided. (optional) tag (str): the tag value & name the model was registered under. Return: A single aml model from the workspace that matches the name and tag. """ if aml_workspace is None: - print("No workspace defined - using current experiment workspace.") - aml_workspace = get_current_workspace() + print("No workspace defined - using current experiment workspace.") + aml_workspace = get_current_workspace() if tagname is not None and tagvalue is not None: - model = Model(aml_workspace, name=model_name, version=model_version, tags=[[tag_name, tag_value]]) + model = AMLModel( + aml_workspace, + name=model_name, + version=model_version, + tags=[[tag_name, tag_value]]) elif (tagname is None and tagvalue is not None) or ( tagvalue is None and tagname is not None ): @@ -56,5 +60,5 @@ def get_model( + "or excluded" # NOQA: E501 ) else: - model = Model(aml_workspace, name=env.model_name, version=env.model_version) + model = AMLModel(aml_workspace, name=env.model_name, version=env.model_version) # NOQA: E501 return model diff --git a/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py b/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py index df4d2d82..aff9ea1d 100644 --- a/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py +++ b/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py @@ -61,6 +61,7 @@ def parse_args() -> Namespace: args = parser.parse_args() return args + def get_or_create_datastore( datastorename: str, ws: Workspace, env: Env, input: bool = True ) -> Datastore: diff --git a/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py b/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py index e02ea0dc..28511f9b 100644 --- a/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py +++ b/ml_service/pipelines/diabetes_regression_verify_train_pipeline.py @@ -54,7 +54,11 @@ def main(): try: tag_name = 'BuildId' model = get_model( - model_name=model_name, tag_name=tag_name, tag_value=build_id, aml_workspace=exp.workspace) + model_name=model_name, + tag_name=tag_name, + tag_value=build_id, + aml_workspace=exp.workspace) + if (model is not None): print("Model was registered for this build.") if (model is None): From a95183b22a8e424926bd816e5df8027ae5340b85 Mon Sep 17 00:00:00 2001 From: j-so Date: Mon, 22 Jun 2020 16:54:39 -0700 Subject: [PATCH 25/28] Squashed commit of the following: commit 493ed3e64f89483005eade0b553db21a98f6f1bb Author: j-so Date: Mon Jun 22 16:42:07 2020 -0700 mark as output commit 1ca7a592ad24ee0fe1e7ba28b41f97431f07039d Author: j-so Date: Mon Jun 22 16:12:10 2020 -0700 fix import commit 743e301081a439472b3bc778b22df3764b16d945 Author: j-so Date: Mon Jun 22 15:59:43 2020 -0700 more fixes commit 44abcac49d1b593da0f87bf8553638887d98f63c Author: j-so Date: Mon Jun 22 15:50:49 2020 -0700 fix batch scoring --- .../diabetes_regression-batchscoring-ci.yml | 8 ++++--- ...ression-get-model-id-artifact-template.yml | 7 ++++-- .../evaluate/evaluate_model.py | 6 ++--- .../scoring/parallel_batchscore.py | 19 ++++++++------- diabetes_regression/util/model_helper.py | 8 +++---- ...sion_build_parallel_batchscore_pipeline.py | 23 ------------------- 6 files changed, 28 insertions(+), 43 deletions(-) diff --git a/.pipelines/diabetes_regression-batchscoring-ci.yml b/.pipelines/diabetes_regression-batchscoring-ci.yml index 8f861dd7..f3cef82c 100644 --- a/.pipelines/diabetes_regression-batchscoring-ci.yml +++ b/.pipelines/diabetes_regression-batchscoring-ci.yml @@ -65,14 +65,16 @@ stages: export SUBSCRIPTION_ID=$(az account show --query id -o tsv) # Invoke the Python building and publishing a training pipeline python -m ml_service.pipelines.diabetes_regression_build_parallel_batchscore_pipeline - + - job: "Run_Batch_Score_Pipeline" displayName: "Run Batch Scoring Pipeline" - dependsOn: "Build_Batch_Scoring_Pipeline" + dependsOn: ["Build_Batch_Scoring_Pipeline"] timeoutInMinutes: 240 pool: server variables: pipeline_id: $[ dependencies.Build_Batch_Scoring_Pipeline.outputs['publish_batchscore.pipeline_id']] + model_name: $[ dependencies.Build_Batch_Scoring_Pipeline.outputs['get_model.MODEL_NAME']] + model_version: $[ dependencies.Build_Batch_Scoring_Pipeline.outputs['get_model.MODEL_VERSION']] steps: - task: ms-air-aiagility.vss-services-azureml.azureml-restApi-task.MLPublishedPipelineRestAPITask@0 displayName: 'Invoke Batch Scoring pipeline' @@ -80,5 +82,5 @@ stages: azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' PipelineId: '$(pipeline_id)' ExperimentName: '$(EXPERIMENT_NAME)' - PipelineParameters: '"ParameterAssignments": {"model_name": "$(MODEL_NAME)", "model_version": "$(MODEL_VERSION)"}' + PipelineParameters: '"ParameterAssignments": {"model_name": "$(model_name)", "model_version": "$(model_version)"}' \ No newline at end of file diff --git a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml index 954308c5..b9e61306 100644 --- a/.pipelines/diabetes_regression-get-model-id-artifact-template.yml +++ b/.pipelines/diabetes_regression-get-model-id-artifact-template.yml @@ -1,3 +1,5 @@ +# Pipeline template that gets the model name and version from a previous build's artifact + parameters: - name: projectId type: string @@ -26,6 +28,7 @@ steps: runBranch: '$(Build.SourceBranch)' path: $(Build.SourcesDirectory)/bin - task: Bash@3 + name: get_model displayName: Parse Json for Model Name and Version inputs: targetType: 'inline' @@ -41,5 +44,5 @@ steps: echo "Model Version: $MODEL_VERSION" # Set environment variables - echo "##vso[task.setvariable variable=MODEL_VERSION]$MODEL_VERSION" - echo "##vso[task.setvariable variable=MODEL_NAME]$MODEL_NAME" + echo "##vso[task.setvariable variable=MODEL_VERSION;isOutput=true]$MODEL_VERSION" + echo "##vso[task.setvariable variable=MODEL_NAME;isOutput=true]$MODEL_NAME" diff --git a/diabetes_regression/evaluate/evaluate_model.py b/diabetes_regression/evaluate/evaluate_model.py index 75343a52..5a69addb 100644 --- a/diabetes_regression/evaluate/evaluate_model.py +++ b/diabetes_regression/evaluate/evaluate_model.py @@ -26,7 +26,7 @@ from azureml.core import Run import argparse import traceback -from util.model_helper import get_latest_model +from util.model_helper import get_model run = Run.get_context() @@ -45,7 +45,7 @@ # sources_dir = 'diabetes_regression' # path_to_util = os.path.join(".", sources_dir, "util") # sys.path.append(os.path.abspath(path_to_util)) # NOQA: E402 -# from model_helper import get_latest_model +# from model_helper import get_model # workspace_name = os.environ.get("WORKSPACE_NAME") # experiment_name = os.environ.get("EXPERIMENT_NAME") # resource_group = os.environ.get("RESOURCE_GROUP") @@ -108,7 +108,7 @@ firstRegistration = False tag_name = 'experiment_name' - model = get_latest_model( + model = get_model( model_name=model_name, tag_name=tag_name, tag_value=exp.name, diff --git a/diabetes_regression/scoring/parallel_batchscore.py b/diabetes_regression/scoring/parallel_batchscore.py index 7bf01a61..cd42c79c 100644 --- a/diabetes_regression/scoring/parallel_batchscore.py +++ b/diabetes_regression/scoring/parallel_batchscore.py @@ -30,6 +30,7 @@ import sys from typing import List from util.model_helper import get_model +from azureml.core import Model model = None @@ -64,13 +65,12 @@ def parse_args() -> List[str]: for idx, itm in enumerate(sys.argv) if itm == "--model_version" ] - - if len(model_version_param) == 0: - raise ValueError( - "Model name is required but no model name parameter was passed to the script" # NOQA: E501 - ) - - model_version = model_version_param[0][1] + model_version = ( + None + if len(model_version_param) < 1 + or len(model_version_param[0][1].strip()) == 0 # NOQA: E501 + else model_version_param[0][1] + ) model_tag_name_param = [ (sys.argv[idx], sys.argv[idx + 1]) @@ -107,6 +107,7 @@ def init(): try: print("Initializing batch scoring script...") + # Get the model using name/version/tags filter model_filter = parse_args() amlmodel = get_model( model_name=model_filter[0], @@ -114,8 +115,10 @@ def init(): tag_name=model_filter[2], tag_value=model_filter[3]) + # Load the model using name/version found global model - modelpath = Model.get_model_path(model_name=model_filter[0]) + modelpath = Model.get_model_path( + model_name=amlmodel.name, version=amlmodel.version) model = joblib.load(modelpath) print("Loaded model {}".format(model_filter[0])) except Exception as ex: diff --git a/diabetes_regression/util/model_helper.py b/diabetes_regression/util/model_helper.py index d5db2fb7..f90237e5 100644 --- a/diabetes_regression/util/model_helper.py +++ b/diabetes_regression/util/model_helper.py @@ -46,19 +46,19 @@ def get_model( print("No workspace defined - using current experiment workspace.") aml_workspace = get_current_workspace() - if tagname is not None and tagvalue is not None: + if tag_name is not None and tag_value is not None: model = AMLModel( aml_workspace, name=model_name, version=model_version, tags=[[tag_name, tag_value]]) - elif (tagname is None and tagvalue is not None) or ( - tagvalue is None and tagname is not None + elif (tag_name is None and tag_value is not None) or ( + tag_value is None and tag_name is not None ): raise ValueError( "model_tag_name and model_tag_value should both be supplied" + "or excluded" # NOQA: E501 ) else: - model = AMLModel(aml_workspace, name=env.model_name, version=env.model_version) # NOQA: E501 + model = AMLModel(aml_workspace, name=model_name, version=model_version) # NOQA: E501 return model diff --git a/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py b/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py index aff9ea1d..ac3d3407 100644 --- a/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py +++ b/ml_service/pipelines/diabetes_regression_build_parallel_batchscore_pipeline.py @@ -33,35 +33,15 @@ Workspace, Dataset, Datastore, - Model, RunConfiguration, ) from azureml.pipeline.core import Pipeline, PipelineData, PipelineParameter from azureml.core.compute import ComputeTarget from azureml.data.datapath import DataPath from azureml.pipeline.steps import PythonScriptStep -from argparse import ArgumentParser, Namespace from typing import Tuple -def parse_args() -> Namespace: - """ - Parse arguments supplied to the pipeline creation script. - The only allowed arguments are model_tag_name and model_tag_value - specifying a custom tag/value pair to help locate a specific model. - - - :returns: Namespace with two attributes model_tag_name and model_tag_value - and corresponding values - - """ - parser = ArgumentParser() - parser.add_argument("--model_tag_name", default=None, type=str) - parser.add_argument("--model_tag_value", default=None, type=str) - args = parser.parse_args() - return args - - def get_or_create_datastore( datastorename: str, ws: Workspace, env: Env, input: bool = True ) -> Datastore: @@ -312,7 +292,6 @@ def get_scoring_pipeline( """ Creates the scoring pipeline. - :param model: The model to use for scoring :param scoring_dataset: Data to score :param output_loc: Location to save the scoring results :param score_run_config: Parallel Run configuration to support @@ -399,8 +378,6 @@ def build_batchscore_pipeline(): try: env = Env() - args = parse_args() - # Get Azure machine learning workspace aml_workspace = Workspace.get( name=env.workspace_name, From 16063ca62fa1b2aa25af39bb6b3ec3288b3b2c46 Mon Sep 17 00:00:00 2001 From: j-so Date: Wed, 24 Jun 2020 12:02:56 -0700 Subject: [PATCH 26/28] improve the docs --- docs/getting_started.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docs/getting_started.md b/docs/getting_started.md index 57a17fbd..c3abed02 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -147,6 +147,7 @@ Now that you've provisioned all the required Azure resources and service connect 1. **Model CI, training, evaluation, and registration** - triggered on code changes to master branch on GitHub. Runs linting, unit tests, code coverage, and publishes and runs the training pipeline. If a new model is registered after evaluation, it creates a build artifact containing the JSON metadata of the model. Definition: [diabetes_regression-ci.yml](../.pipelines/diabetes_regression-ci.yml). 1. **Release deployment** - consumes the artifact of the previous pipeline and deploys a model to either [Azure Container Instances (ACI)](https://azure.microsoft.com/en-us/services/container-instances/), [Azure Kubernetes Service (AKS)](https://azure.microsoft.com/en-us/services/kubernetes-service), or [Azure App Service](https://docs.microsoft.com/en-us/azure/machine-learning/service/how-to-deploy-app-service) environments. See [Further Exploration](#further-exploration) for other deployment types. Definition: [diabetes_regression-cd.yml](../.pipelines/diabetes_regression-cd.yml). 1. **Note:** Edit the pipeline definition to remove unused stages. For example, if you're deploying to Azure Container Instances and Azure Kubernetes Service only, delete the unused `Deploy_Webapp` stage. +1. **Batch Scoring Code Continuous Integration** - consumes the artifact of the model training pipeline. Runs linting, unit tests, code coverage, publishes a batch scoring pipeline, and invokes the published batch scoring pipeline to score a model. These pipelines use a Docker container on the Azure Pipelines agents to accomplish the pipeline steps. The container image ***mcr.microsoft.com/mlops/python:latest*** is built with [this Dockerfile](../environment_setup/Dockerfile) and has all the necessary dependencies installed for MLOpsPython and ***diabetes_regression***. This image is an example of a custom Docker image with a pre-baked environment. The environment is guaranteed to be the same on any building agent, VM, or local machine. In your project, you'll want to build your own Docker image that only contains the dependencies and tools required for your use case. Your image will probably be smaller and faster, and it will be maintained by your team. @@ -200,27 +201,27 @@ The pipeline stages are summarized below: - Get the info about the registered model - Create a pipeline artifact called `model` that contains a `model.json` file containing the model information. -### Set up the Release deployment pipeline +### Set up the Release Deployment and/or Batch Scoring pipelines --- **PREREQUISITE** -In order to use this pipeline: +In order to use these pipelines: 1. Follow the steps to set up the Model CI, training, evaluation, and registration pipeline. 1. You **must** rename your model CI/train/eval/register pipeline to `Model-Train-Register-CI`. -The release deploymment pipeline relies on the model CI pipeline and references it by name. +These pipelines rely on the model CI pipeline and reference it by name. --- -In this section, we will set up the pipeline for release deployment to ACI, AKS, or Webapp. This pipeline uses the resulting artifact of the [Model-Train-Register-CI pipeline](#) to identify the model to be deployed. +These pipelines have the following behaviors: -This pipeline has the following behaviors: - -- The pipeline will **automatically trigger** on completion of the Model-Train-Register-CI pipeline +- The pipeline will **automatically trigger** on completion of the Model-Train-Register-CI pipeline for the master branch. - The pipeline will default to using the latest successful build of the Model-Train-Register-CI pipeline. It will deploy the model produced by that build. -- You can specify a `Model-Train-Register-CI` build ID when running the pipeline manually. You can find this in the url of the build, and the model registered from that build will also be tagged with the build ID. This is useful to skip model training and registration, and deploy a model successfully registered by a `Model-Train-Register-CI` build. +- You can specify a `Model-Train-Register-CI` build ID when running the pipeline manually. You can find this in the url of the build, and the model registered from that build will also be tagged with the build ID. This is useful to skip model training and registration, and deploy/score a model successfully registered by a `Model-Train-Register-CI` build. + +### Set up the Release Deployment pipeline In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-cd.yml](../.pipelines/diabetes_regression-cd.yml) pipeline definition in your forked repository. @@ -247,12 +248,6 @@ The pipeline has the following stage: - Smoke test - The test sends a sample query to the scoring web service and verifies that it returns the expected response. Have a look at the [smoke test code](../ml_service/util/smoke_test_scoring_service.py) for an example. - -### **Azure [pipeline](../.pipelines/diabetes_regression-batchscoring-ci.yml) for batch scoring** -This pipeline has a sequence of stages for: -1. **Batch Scoring Code Continuous Integration:** triggered on code changes to master branch on GitHub. Runs linting, unit tests, code coverage and publishes a batch scoring pipeline. -1. **Run Batch Scoring**: invokes the published batch scoring pipeline to score a model. - ### Set up the Batch Scoring pipeline In your Azure DevOps project, create and run a new build pipeline based on the [diabetes_regression-batchscoring-ci.yml](../.pipelines/diabetes_regression-batchscoring-ci.yml) From e07e93ef13aa1046be292f0b4b92bc64cf3cce41 Mon Sep 17 00:00:00 2001 From: j-so Date: Thu, 25 Jun 2020 14:38:46 -0700 Subject: [PATCH 27/28] fix secret access --- .pipelines/diabetes_regression-batchscoring-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.pipelines/diabetes_regression-batchscoring-ci.yml b/.pipelines/diabetes_regression-batchscoring-ci.yml index f3cef82c..ca993bee 100644 --- a/.pipelines/diabetes_regression-batchscoring-ci.yml +++ b/.pipelines/diabetes_regression-batchscoring-ci.yml @@ -54,6 +54,11 @@ stages: projectId: '$(resources.pipeline.model-train-ci.projectID)' pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' artifactBuildId: ${{ parameters.artifactBuildId }} + # Map the datastore access secret to an environment variable + - script: | + echo ##vso[task.setvariable variable=SCORING_DATASTORE_ACCESS_KEY]$(SCORING_DATASTORE_ACCESS_KEY) + env: + SCORING_DATASTORE_ACCESS_KEY: $(SCORING_DATASTORE_ACCESS_KEY) - task: AzureCLI@1 name: publish_batchscore inputs: From bcbeb98caa41ab0cc06b8341e133727ad41d31a4 Mon Sep 17 00:00:00 2001 From: j-so Date: Thu, 25 Jun 2020 14:49:01 -0700 Subject: [PATCH 28/28] pass to cli task and impove naming --- .pipelines/diabetes_regression-batchscoring-ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.pipelines/diabetes_regression-batchscoring-ci.yml b/.pipelines/diabetes_regression-batchscoring-ci.yml index ca993bee..1392fddb 100644 --- a/.pipelines/diabetes_regression-batchscoring-ci.yml +++ b/.pipelines/diabetes_regression-batchscoring-ci.yml @@ -54,12 +54,8 @@ stages: projectId: '$(resources.pipeline.model-train-ci.projectID)' pipelineId: '$(resources.pipeline.model-train-ci.pipelineID)' artifactBuildId: ${{ parameters.artifactBuildId }} - # Map the datastore access secret to an environment variable - - script: | - echo ##vso[task.setvariable variable=SCORING_DATASTORE_ACCESS_KEY]$(SCORING_DATASTORE_ACCESS_KEY) - env: - SCORING_DATASTORE_ACCESS_KEY: $(SCORING_DATASTORE_ACCESS_KEY) - task: AzureCLI@1 + displayName: "Publish Batch Scoring Pipeline" name: publish_batchscore inputs: azureSubscription: '$(WORKSPACE_SVC_CONNECTION)' @@ -70,6 +66,8 @@ stages: export SUBSCRIPTION_ID=$(az account show --query id -o tsv) # Invoke the Python building and publishing a training pipeline python -m ml_service.pipelines.diabetes_regression_build_parallel_batchscore_pipeline + env: + SCORING_DATASTORE_ACCESS_KEY: $(SCORING_DATASTORE_ACCESS_KEY) - job: "Run_Batch_Score_Pipeline" displayName: "Run Batch Scoring Pipeline"