本章内容涵盖:
- 使用 BentoML 部署管理器将 ML 模型以服务形式部署
- 使用 Evidently 跟踪数据漂移
在第 5 章编排了 ML 流水线之后,我们现在要面对 ML 生命周期中的两个关键挑战:部署与监控。我们如何在生产环境中可靠地对外提供模型服务?又如何确保模型在时间推移中持续表现良好?
接下来我们将攻克将 ML 模型部署到生产环境的核心挑战——这些挑战同样适用于传统 ML 模型和大语言模型(LLM)。我们会介绍如何使用 BentoML 将模型高效地以 API 形式对外服务。BentoML 是一个强大的平台,它简化了部署流程,并抽象掉大量底层基础设施复杂度(见图 6.1)。通过自动化部署工作流中的关键环节,BentoML 让 ML 工程师可以把精力集中在服务逻辑上,而不是环境搭建细节上。
图 6.1 心智图:本章聚焦将模型以 API 方式部署(E)
我们还会讨论生产级 ML 系统中一个常见但关键的问题:数据漂移。随着现实世界数据分布演化,模型性能可能会逐步下降。为此,我们将引入 Evidently——一个用于检测与分析数据漂移的监控工具,帮助你在生产环境中主动维护模型性能。
本章将通过实操示例与分步指南,帮助你建立自信地部署与监控模型的能力。我们会继续使用前几章的收入分类用例来演示这些工具的使用。尽管本章讨论的技术与工具同时适用于传统模型与 LLM,但关于 LLM 的生产化专项内容(包括 prompt 处理、可观测性与评估)会在第 12、13 章深入展开。本章全部代码见 GitHub: github.com/practical-m…。
6.1 BentoML 作为部署平台
在上一章中,我们通过批处理流水线部署了收入分类模型。本节我们将使用 BentoML 把模型部署为一个 API 端点。在第 3 章中,我们把 hello-joker 应用部署成了一个 FastAPI 端点。为此我们需要构建 Docker 镜像、编写多个 Kubernetes manifests,并在代码里加入监控逻辑。为了自动化部署,我们还需要搭建持续集成(CI)与持续部署(CD)。
这套东西搭起来很耗时——更不用说团队里不是每个数据科学家都具备配置这些系统所需的技能。于是 BentoML 登场:它提供端到端方案,通过把建模应用所需的一切打包进一个 Bento 并部署出去,从而显著简化部署过程。Bento 是一种文件归档格式,用作 ML 应用的统一分发形式——可以把它想象成一个日本便当盒,只不过里面装的不是食物,而是你的 ML 应用。要跟着本章动手,你需要按附录 A 的说明在 Kubernetes 集群中安装 BentoML。
我们会用 Bento 作为应用打包格式,而部署与监控则由 Yatai 负责。Yatai 是 BentoML 框架中的组件,支持在 Kubernetes 上部署、运维与扩缩容 ML 服务,并提供 UI 用于部署、扩缩容与监控应用。本节我们将通过两个主要步骤,用 BentoML + Yatai 把收入分类器部署成 API 端点:
- 构建应用的 Bento
- 使用 Yatai 部署该 Bento
我们会在本地构建 Bento 并推送到 Yatai,后者负责将 Bento 容器化并部署(见图 6.2)。
图 6.2 BentoML 将模型部署为服务的流程:构建 Bento 并推送到容器镜像仓库,Yatai 构建镜像并完成部署
6.1.1 构建 Bento
如前所述,Bento 是一种统一文件格式,用于承载我们的 ML 应用,这在概念上非常类似 Docker 容器。要构建一个 Bento,需要完成以下工作:
- 在 BentoML 本地 store 中注册一个模型(本例中是把模型从 MLflow 迁移到 BentoML)。
- 初始化并定义一个 service,用于定义应用的 API 端点。service 内包含 runner 对象,用于实际推理并可横向扩展。
- 定义 bentofile.yaml 文件,指定 Bento 的依赖与环境变量。
- 使用 BentoML 预置命令构建并把 Bento 推送到 Yatai。
注册模型
Bento 容器包含数据科学模型、特征获取逻辑以及模型预测。首先我们需要把模型以 Bento 能使用的形式保存下来。当前模型在 MLflow 中。Bento 原生集成 MLflow,因此可以把 MLflow tracking 记录的模型迁移到 BentoML,用于高性能推理服务。我们会先把模型从 MLflow 下载到本地 BentoML store(见代码清单 6.1)。为此运行 download_model.py。BentoML 提供 bentoml.mlflow.import_model 方法,把模型从 MLflow 拉取到本地 Bento store。需要提供模型名和 MLflow model URI(包含模型名与 stage)。运行该脚本前,确保已对 mlflow-service 与 minio-service 做 port-forward。
清单 6.1 将 MLflow 模型下载到本地 Bento store
import mlflow
import bentoml
import os
mlflow.set_tracking_uri("http://localhost:5000") #1
os.environ["AWS_ACCESS_KEY_ID"] = "minio"
os.environ["AWS_SECRET_ACCESS_KEY"] = "minio123"
os.environ["AWS_ENDPOINT_URL"] = "http://localhost:9000"
model_name = "random-forest-classifier"
model_stage = "Production"
bentoml.mlflow.import_model(
"random-forest-classifier",
model_uri=f"models:/{model_name}/{model_stage}",
) #2
#1 设置 MLflow tracking URI
#2 从 MLflow 导入模型到 BentoML 本地 store
在终端运行 bentoml models list 可以看到模型已导入。输出会包含模型名与 tag;Module 表示模型来源(这里是 MLflow);还会显示模型大小与创建时间:
bentoml models list
Tag
random-forest-classifier:7hrh6ndohg3mtktg
Module
bentoml.mlflow
Size
36.87 MiB
Creation Time
2023-10-07 12:42:57
初始化 service 与 runner
现在我们有了模型,接下来要搭建服务。Serving 是 BentoML 的核心构件之一。service 用于定义模型的服务逻辑:在本例中,服务逻辑包括从 Feast 获取特征并做模型推理。我们会在 service.py 中实现该逻辑,先初始化 BentoML service 对象与 runner。
runner 表示一个可在远程 Python worker 上执行的计算单元,并且可以独立扩缩容。runner 允许 bentoml.Service 并行运行多个 bentoml.Runnable 实例,每个实例位于独立的 Python worker 中。当 Bento API server 启动时,会创建一组 runner worker 进程,bentoml.Service 触发的 run 调用会被调度到这些 worker 上。
在我们的场景里,runner 负责模型预测。因此初始化 service 时,先构建 runner。BentoML 提供 bentoml.mlflow.get(model_name).to_runner(),可将 MLflow 模型转为 runner。随后用 bentoml.Service 创建 svc,并把 runner 传入。必要时也可以配置多个 runner(例如在后台跑多个模型做对比),但这里我们只有一个 runner:income_clf_runner。
接下来做 service 启动初始化(见清单 6.2),包括:
-
配置服务所需 feature store。
-
从 MLflow 获取 column list(用于生成 dummy features)。
-
将逻辑放入 initialize 初始化函数。
-
在 initialize 中创建并配置 MLflow client,并下载 column list。
-
用 MinIO 相关信息创建 feature store:
- access key
- secret key
- hostname
- 包含 feature registry 的 bucket 名
- feature_store.yaml 文件
-
将 column list 与 feature store 对象存入 BentoML service context(context.state),方便复用。
-
用
@svc.on_startup标注,使初始化在服务启动时执行。 -
使用 python-dotenv 从 .env 文件加载环境变量。
清单 6.2 初始化 BentoML service
income_clf_runner = bentoml.mlflow.get(
"random-forest-classifier:latest"
).to_runner() #1
full_input_spec = JSON(pydantic_model=IncomeClassifierUsers)
svc = bentoml.Service( #2
"income_classifier_service",
runners=[income_clf_runner],
)
@svc.on_startup
async def initialise(context: bentoml.Context):
from src.feature_store import DataStore
from mlflow.tracking import MlflowClient
import mlflow
config = dotenv_values(ENV_FILE_NAME) #3
os.environ["FEAST_S3_ENDPOINT_URL"] = config["FEAST_S3_ENDPOINT_URL"]
os.environ["AWS_ENDPOINT_URL"] = config["FEAST_S3_ENDPOINT_URL"]
mlflow_client = MlflowClient(config["MLFLOW_HOST"]) #4
mlflow.set_tracking_uri(config["MLFLOW_HOST"])
for model in mlflow_client.search_model_versions(
f"name='{config['MLFLOW_MODEL_NAME']}'"
):
if model.current_stage == config["MLFLOW_MODEL_STAGE"]:
model_run_id = model.run_id
mlflow.artifacts.download_artifacts(
f"runs:/{model_run_id}/column_list.pkl", dst_path="column_list"
) #5
with open("column_list/column_list.pkl", "rb") as f:
col_list = pickle.load(f)
feature_store = DataStore(
config["MINIO_HOST"],
config["MINIO_ACCESS_KEY"],
config["MINIO_SECRET_KEY"],
config["FEATURE_REGISTRY_BUCKET_NAME"],
config["FEATURE_REGSITRY_FILE_NAME"],
config["FEAST_S3_ENDPOINT_URL"],
config["FEAST_REDIS_HOST"],
config["FEAST_REDIS_PASSWORD"],
) #6
context.state["store"] = feature_store.init_feature_store() #7
context.state["col_list"] = col_list
context.state["feature_list"] = [
"demographic:Sex",
"demographic:Native_country",
"demographic:Race",
"relationship:Relationship",
"relationship:Marital-Status",
"occupation:Workclass",
"occupation:Education",
"occupation:Occupation",
]
#1 初始化 BentoML runner
#2 初始化 BentoML service
#3 加载环境变量
#4 初始化 MLflow client
#5 下载 MLflow artifacts
#6 创建 feature store 对象
#7 将 feature store、column list、feature list 存入 BentoML context
定义服务端点
随后我们定义 service API(见清单 6.3)。service 输入是 user_id(在 Feast 术语里是 entity)。我们在 IncomeClassifierUsers 中声明,并用 bentoml.io.JSON 包装以定义 API 输入。predict 函数参数是输入与 BentoML context。我们把输入转成 dict,用 feature_store.get_online_features 拉取该 user 的特征,再用 col_list 把特征列映射到 dummy 列,最终用 OutputMapper 把模型输出映射到标签(0 = <= 50K,1 = >50K)。API 返回 income_category 与 user_id。
清单 6.3 定义服务端点
@svc.api(input=full_input_spec, output=JSON(), route="/predict")
def predict(
inputs: IncomeClassifierUsers,
ctx: bentoml.Context
) -> Dict[str, Any]:
input_dict = inputs.dict() #1
feature_df = (
ctx.state["store"]
.get_online_features(
features=ctx.state["feature_list"],
entity_rows=[input_dict],
)
.to_df()
) #2
feature_df.drop(columns=["user_id"], inplace=True)
data_mapper = InputMapper(feature_df, ctx.state["col_list"])
input_df = data_mapper.generate_pandas_dataframe() #3
output_mapper = OutputMapper(
income_clf_runner.predict.run(input_df)[0]
) #4
return {
"income_category": output_mapper.map_prediction(),
"user_id": input_dict["user_id"],
} #5
#1 将输入转成字典
#2 从 Feast 获取在线特征
#3 生成模型输入 DataFrame
#4 将模型输出映射为对应标签
#5 返回 API 响应
到这里,我们已经成功定义了 service。下一步,我们将编写 bentofile.yaml。
定义 BENTOFILE.YAML
在服务已经定义好之后,就该把我们的应用打包(或者说 bentofy)起来了。为了打包应用,我们需要编写一个 bentofile.yaml 文件(见清单 6.4)。它和 Dockerfile 类似,但更简单。
我们首先定义 service,指向在 service.py 中创建的 svc 对象:service: "service:svc"(格式含义是:service 文件名 : service 对象名)。如有需要,也可以添加 labels。labels 是键值对形式的元数据;这里我们添加了一个应用 owner 的 label。接着指定要包含进 Bento 的文件。我们需要所有 Python 文件,以及包含应用配置变量的 production.env 文件。然后指定 requirements.txt 文件,它会用于下载应用依赖。
最后,在 env 下指定应用的环境变量。ENV_NAME 用于指向应用应使用的配置文件。AWS 的环境变量会在 Feast 从 MinIO 拉取特征时用到。
清单 6.4 用于打包应用的 bentofile.yaml
service: "service:svc"
labels:
owner: ml-engineering-team
include:
- "service.py"
- "production.env"
- "src/*.py"
- "requirements.txt"
- "*.env"
python:
requirements_txt: requirements.txt
docker:
env:
- ENV_NAME=local
- AWS_ACCESS_KEY_ID=minio
- AWS_SECRET_ACCESS_KEY=minio123
6.1.2 构建并推送 Bento
现在我们已经定义了 service 和 bentofile.yaml。要构建 Bento,我们使用 bentoml build 命令,如下所示:
bentoml build -f bento/bentofile.yaml
输出示例:
ENV_NAME=production
AWS_ACCESS_KEY_ID=minio
AWS_SECRET_ACCESS_KEY=minio123
██████╗ ███████╗███╗ ██╗████████╗ ██████╗ ███╗ ███╗██╗
██╔══██╗██╔════╝████╗ ██║╚══██╔══╝██╔═══██╗████╗ ████║██║
██████╔╝█████╗ ██╔██╗ ██║ ██║ ██║ ██║██╔████╔██║██║
██╔══██╗██╔══╝ ██║╚██╗██║ ██║ ██║ ██║██║╚██╔╝██║██║
██████╔╝███████╗██║ ╚████║ ██║ ╚██████╔╝██║ ╚═╝ ██║███████╗
╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝
Successfully built Bento(tag="income_classifier_service:2umhoidopolz3ktg").
Possible next steps:
* Containerize your Bento with `bentoml containerize`:
$ bentoml containerize \
income_classifier_service:2umhoidopolz3ktg
[or: bentoml build –containerize]
* Push to BentoCloud with `bentoml push`:
$ bentoml push \
income_classifier_service:2umhoidopolz3ktg
[or bentoml build --push]
我们可以运行 bentoml list 来列出新建的 Bento;输出会给出 Bento 名称、应用大小与模型大小。Size 指应用的大小,而 Model Size 是模型文件大小:
bentoml list
示例输出:
Tag
income_classifier_service:2umhoidopolz3ktg
Size
20.14 KiB
Model Size
36.87 MiB
Creation Time
2023-10-07 20:34:23
一旦 Bento 构建完成并保存在本地 Bento Store,我们就可以在本地运行该 Bento service。先用下面命令把 Bento 容器化:
bentoml containerize income_classifier_service:2umhoidopolz3ktg
容器构建完成后,就可以用 docker run 启动 Docker 容器。注意需要分别在三个终端里对 minio-service、mlflow-service 以及 Feast Redis Online Store service 做 port-forward:
kubectl port-forward svc/minio-service -n kubeflow 9000:9000
kubectl port-forward svc/redis-deployment-master -n redis 6379:6379
kubectl port-forward svc/mlflow-service 5000:5000 -n mlflow
然后执行 docker run:
docker run --rm -p 3000:3000 income_classifier_service:mruvdsvykog2rlg6
当模型与 artifacts 下载完成后,你可以访问 localhost:3000,输入一个 user_id 来测试 /predict 端点(类似于稍后在 6.1.3 节的图 6.10)。
测试通过后,我们需要把 Bento 推送到远端 Bento Store,这样 Yatai 才能拉取并在 Kubernetes 集群中部署成应用。推送前,先对 Yatai service 做 port-forward:
kubectl --namespace yatai-system port-forward svc/yatai 8080:80
然后使用 API token 登录 Yatai(API token 见附录 A):
bentoml yatai login --api-token <api_token> --endpoint http://127.0.0.1:8080
示例输出:
Overriding existing cloud context config: default
Successfully logged in to Cloud for Varun Mallya in default
登录成功后,用 bentoml push 推送 Bento,它会自动推送最新的 income classifier Bento(包括模型与应用):
bentoml push income_classifier_service
示例输出:
│ Successfully pushed model "random-forest-classifier:7hrh6ndohg3mtktg"│
│ Successfully pushed bento "income_classifier_service:2umhoidopolz3ktg"│
Pushing Bento "income_classifier_service:2umhoidopolz3ktg" ━━━━━━━━━━━━ 100.0% • 71.7/71.7 kB • 442.5 MB/s
Uploading model "random-forest-classifier:7hrh6ndohg3mtktg" ━━━━━━━━━━━━ 100.0% • 38.7/38.7 MB • 10.2 MB/s
当 Bento 构建并推送到远端 Bento Store 后,我们就可以在浏览器里登录 Yatai,继续进行部署。
6.1.3 部署 Bento
Bento 推送到 Yatai 后,我们需要把它部署出来。首先,验证模型与 Bento 是否都已推送到各自的远端仓库。
要在远端 model store 里查看模型,点击侧边栏的 Models 标签页,它会列出所有已推送的模型。我们可以在这里看到 random-forest-classifier 模型(图 6.3)。
图 6.3 Yatai UI 的 Models 标签页显示注册在 BentoML 模型仓库中的模型
要检查 Bento 是否已推送,查看 Bentos 标签页(图 6.4),在这里可以看到 income-classifier-service Bento。
图 6.4 Yatai UI 的 Bentos 标签页显示已推送到 Bento registry 的 Bentos
确认模型与 Bento 都存在后,就可以部署应用了。点击侧边栏的 Deployments,再点击 Create 按钮(图 6.5)。
图 6.5 点击 Create 按钮创建 deployment
接着填写 deployment 名称,以及 Bento 名称与 tag(图 6.6)。
图 6.6 通过提供 Bento 名称与版本创建 deployment
在同一页面,还可以配置 deployment 的副本数以及 pod 的 CPU/内存需求(图 6.7)。在 Runners 标签页中也可以为 runner 做类似配置。
图 6.7 deployment 的额外配置:资源限制与副本数等
配置完成后点击 Submit 创建 deployment。回到 Deployments 页面并点击新创建的 deployment。Yatai 会在内部通过运行 Kubernetes job 来构建 Docker 镜像;在本例中会启动一个名为 yatai-bento-image-builder-income-classifier 的 job(图 6.8)。
图 6.8 Yatai 通过 job 将应用容器化并构建镜像
当 job 完成后,会看到 income-classifier-application 开始生成新的 pods。pods 就绪后,在 Yatai UI 的 Replicas 标签页下能看到应用。这里有两类 Pods:API Server 和 Runner。API Server pod 是应用前端,接收来自用户和其他应用的请求;Runner pod 是应用后端,持有模型并执行推理。API Server pod 通过与 Runner pod 通信来获取预测结果。两者可以在部署配置时独立扩缩容。我们能看到 1 个 API Server pod 与 2 个 Runner pod(图 6.9)。
图 6.9 Yatai 成功启动:1 个 API Server pod 与 2 个 Runner pods
这些 pods 会运行在 yatai namespace 下,可用 kubectl get 命令查看所有 income classifier pods:
kubectl get pod -n yatai | grep income-classifier-service
示例输出:
income-classifier-service-5cfb889666-8bc9d
2/2 Running 0 12m
income-classifier-service-runner-0-7df4c46b58-dz99k
2/2 Running 0 12m
income-classifier-service-runner-0-7df4c46b58-fz2k4
2/2 Running 0 12m
BentoML 还会生成一个 ingress service,用于与该服务通信。要获取 ingress,使用 kubectl get ingress:
kubectl get ingress -n yatai | grep income
示例输出:
income-classifier-service nginx
income-classifier-service-yatai.10.65.0.40.sslip.io
10.65.0.40 80 17m
在浏览器访问该 endpoint,会看到一个 Swagger 文档。Swagger 文档是一种机器可读的规范,用于描述与文档化 RESTful APIs。我们可以用它来测试应用的 /predict 端点(图 6.10)。
图 6.10 部署服务的 Swagger 文档(包含 /predict 端点)
输入一个合适的 user ID,即可向 /predict 发起请求并得到响应(图 6.11)。
图 6.11 示例请求与预测结果
BentoML 应用还提供 /metrics 端点,可被 Prometheus 抓取用于可观测性。这包括诸如每秒请求数、请求延迟等标准指标(图 6.12)。
图 6.12 BentoML 默认在 /metrics 端点提供的指标
到这里,我们已经在不需要编写 Dockerfile、不需要配置任何 Kubernetes manifests、也不需要自己编写 /metrics 端点逻辑的情况下,通过 BentoML 部署了应用。只要提供 bentofile.yaml,BentoML 就能把这些都处理好,因此它是标准化 ML 部署的一个很不错的工具。
NOTE 你可以在 docs.bentoml.com/en/latest/ 获取更多 BentoML 信息。
在前面几节里,我们已经以批处理与实时两种方式部署了收入分类应用。但对于数据科学应用,模型在部署后存在随时间性能下降的风险。造成下降的一个原因,是模型输入数据偏离了训练与验证时的数据——也就是数据漂移(data drift)。接下来我们将讨论数据漂移监控。
6.2 用 Evidently 做数据漂移监控
在前面的章节中,我们已经把模型以批处理流水线和在线端点两种方式部署到了生产环境。假设一开始模型表现很好,但几个月后性能开始衰减。经过仔细分析,我们发现模型引用的一些特征偏离了训练时学到的分布,导致了数据漂移(data drift)。数据漂移是 ML 和数据科学中经常出现的问题:当用于训练模型的数据的统计特征发生变化时,模型的性能与准确率会随着时间下降。它可能由多种原因引起,包括目标变量的改变、数据属性的变化,或底层数据分布的变化。
大多数模型都是基于历史数据训练的,并假设输入与输出变量之间的关系保持一致。我们也默认输入变量的分布不会发生足以影响预测结果的显著变化。但在真实世界里,随着时间推移,这些假设未必成立。现实数据可能逐渐偏离我们训练模型所用的历史数据,从而导致模型表现变差。因此,在生产环境中检测数据漂移并采取纠正措施非常重要。
数据漂移可能由多种原因产生:
- Label drift(标签漂移) ——在监督学习中,随着时间推移目标变量(ground truth)发生变化,就会出现标签漂移。例如,如果你训练的是制造业中的瑕疵品检测模型,“什么算瑕疵”的定义可能会改变。
- Prior probability shift(先验概率变化) ——在分类任务中,各类别的先验概率可能会发生变化。例如,欺诈检测系统中正负样本的比例可能会随时间波动。
- Covariate shift(协变量漂移) ——输入特征的分布随时间变化。例如,如果你在做客户行为预测,人口属性、地域、行为模式等可能随时间改变(见图 6.13)。
- Sudden drift(突发漂移) ——某些漂移会突然且不可预期地发生,例如由于外部因素(如疫情或重大市场事件)导致客户行为突然改变。
图 6.13 一个特征发生协变量漂移的示例:2021 年的分布与 2018 年不同。
Evidently 是一个通过对历史数据与推理数据运行统计检验来帮助检测数据漂移的工具。这些检验能帮助我们判断数据的统计特征是否在时间维度上发生了显著变化。常见检验包括:
- Kolmogorov–Smirnov Test(KS 检验) ——比较两个数据集的累积分布函数(CDF)。它可以帮助判断某个特征的分布是否发生显著变化。如果 KS 检验的 p-value 低于某个阈值(例如 0.05),通常意味着存在漂移。
- Chi-square test(卡方检验) ——检验类别变量的独立性。如果我们有类别型特征,可以用它检测类别分布是否发生变化。
- Wasserstein distance(沃瑟斯坦距离) ——也称 Earth Mover’s distance(推土机距离),衡量一个基线数据集(代表“正常”行为)与后续某时间点采集的当前数据集之间的分布差异。距离越大表示漂移越显著;距离越小表示更相似、漂移更小。
Evidently 会根据数据类型选择最合适的检验并应用来检测漂移。我们也可以显式指定要用的检验,甚至自定义检验。
6.2.1 数据漂移检测报告与仪表盘
Evidently 提供了一个简单的 Python SDK,帮助我们检测不同类型的漂移。要检测漂移,我们需要给 Evidently 两份数据:参考数据集(reference dataset,历史数据)和当前数据集(current dataset,需要预测的数据) 。只靠这两份数据,就可以生成漂移报告。
一般来说,一个 report 由多个 metrics 组成。metric 是 Evidently 的核心组件,我们可以在一个 report 中组合多个 metric。metric 可以是单一指标(例如 DatasetMissingValuesMetric() 给出缺失值比例),也可以是指标组合(例如 DatasetSummaryMetric() 计算一组描述性统计)。metric 既有数据集级别的,也有列级别的。每个 metric 都有自己的可视化渲染方式:有的只返回数值,适合作为 stat 展示;有的会返回较丰富的可视化图表。
图 6.14 展示了一个 RegressionQualityMetric:它通过展示 mean error(ME)、mean absolute error(MAE)和 mean absolute percentage error(MAPE)来衡量回归模型在参考数据与当前数据上的表现;并为每个指标同时展示标准差,用来估计性能稳定性。其他指标会返回更丰富的图表可视化(见图 6.15)。
图 6.14 回归模型报告展示 ME、MAE、MAPE,并给出每个指标的标准差以估计稳定性。
图 6.15 一个列级别指标示例:展示当前数据 vs 参考数据的漂移分数与特征分布。
Evidently 还提供了 metric presets:为特定场景预先组合好的 report 模板。可以把它理解为模板,例如用于数据漂移、数据质量、回归表现的 preset(分别是 DataDriftPreset、DataQualityPreset、RegressionPreset)。
图 6.16 展示了一个数据漂移报告:报告的每一行都在对比参考数据集中的某个特征分布(Reference Distribution 列)与推理数据集中的同一特征分布(Current Distribution 列)。报告在 Start Test 列标明使用了哪个检验,并在 Drift Score 列给出该检验的 p-value 或漂移分数。每个统计检验都会产生一个漂移分数。本例使用的是 Wasserstein distance,它的分数范围从 0 到无穷大。Evidently 对该检验的默认漂移阈值是 0.1:也就是说,如果漂移分数大于 0.1,Evidently 就会判定检测到漂移。
图 6.16 一个 Evidently 数据漂移报告示例:面板快速展示当前/参考数据中漂移列数量与缺失值情况。
该报告由清单 6.5 的简短代码生成。第一行用 metric preset DataDriftPreset 定义了 report 对象,它会对每一列运行统计检验,对比 reference dataframe 与 current dataframe。定义完成后,通过传入参考与当前数据运行 report。最后用 save_html 保存为 HTML。
清单 6.5 使用 DataDriftPreset 生成数据漂移报告
report = Report(metrics=[
DataDriftPreset(),
]) #1
report.run(
reference_data=reference_data,
current_data=current_data
) #2
report.save_html("drift_report.xhtml") #3
#1 定义 report
#2 用参考数据与当前数据运行 report
#3 保存 report
报告是基于数据快照(snapshot)生成的,这些快照可以来自每日批处理任务或 API。报告很适合用来查看某个时间点的模型/数据状态;但如果我们想在一段时间内持续跟踪模型表现,就需要在多个时间点生成报告并进行对比。为了简化这个过程,Evidently 提供了用于监控的 dashboard 能力。它的 Web UI 可通过 evidently ui 命令在本地启动(默认端口 8000):
evidently ui
INFO: Started server process [40045]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
在浏览器访问该地址,会看到 Evidently UI,此时 Project List 下还没有任何项目(见图 6.17)。
图 6.17 Evidently UI:Project List 为空
接下来,我们为 income classifier 模型搭建一个 dashboard,并显示在 Evidently UI 上。要构建 dashboard,首先需要创建 workspace 和 project。可以把 workspace 理解为一个目录,用来存放所有数据快照与项目;project 则是 workspace 下的子目录,用于进一步组织这些日志。例如,一个 workspace 目录叫 workspace,里面有两个 project:project_1 和 project_2;每个 project 有两个数据快照 snapshot_1.json、snapshot_2.json:
workspace
├── project_1
│ ├── snapshot_1.json
│ └── snapshot_2.json
└── project_2
├── snapshot_1.json
└── snapshot_2.json
我们用清单 6.6 的脚本创建 workspace 与 project。通过 RemoteWorkspace 连接 UI(它相当于 UI 的客户端)。在 workspace 中创建一个名为 income-classifier 的 project,并填写合适的描述。
清单 6.6 指向远程 workspace 并创建 project
from evidently.ui.workspace import RemoteWorkspace
ws = RemoteWorkspace("http://localhost:8000") #1
project = ws.create_project(
name="income-classifier",
description="Used to classify users into multiple income bands",
) #2
#1 通过指向 Evidently UI 定义 remote workspace
#2 创建 project,用于存储数据漂移报告与 dashboard
运行脚本后,Project List 中会出现该 project。点击项目链接会看到一个空 dashboard。要构建 dashboard,需要设计 panels——它们是把 report metrics 渲染到 dashboard 上的可视化组件。添加 panel 时,需要指定类型与属性(比如 width、title)。例如,DashboardPanelCounter 用来显示单个 stat;DashboardPanelPlot 可把测量值画成折线图、柱状图、散点图或直方图。
一个常用 panel 是展示 dashboard 标题。为此我们使用 DashboardPanelCounter(见清单 6.7),只显示 panel 标题而不显示数值。filter 可以用于从快照中筛选子集,但这里不设过滤条件;agg 表示聚合方式,因为这里只显示文本,我们设为 NONE;title 是 panel 的标题。
清单 6.7 为数据漂移 dashboard 创建标题 panel
DashboardPanelCounter(
filter=ReportFilter(metadata_values={}, tag_values=[]),
agg=CounterAgg.NONE,
title="Income Classifier Batch Data Drift Dashboard",
) #1
#1 使用不做聚合的 DashboardPanelCounter 定义标题 panel
我们还可以再加一个 panel 展示漂移列(drifted columns)的百分比。因为它是单值,我们继续使用 DashboardPanelCounter,但这次给它提供一个 value(见清单 6.8)。要指定 value,需要创建一个 PanelValue:其中 metric_id 是指标名称;field_path 指向 metric 结果对象中的目标字段;这里我们要取 share_of_drifted_columns。同时用 legend 为该 stat 设置标签。counter 还可以设置 text 作为可选文本。我们要展示最新的漂移列占比,因此使用 CounterAgg.LAST 聚合。size 控制 panel 宽度:1 为半宽,2 为全宽。
清单 6.8 创建一个 panel 展示漂移列占比
DashboardPanelCounter(
title="Share of Drifted Features",
filter=ReportFilter(metadata_values={}, tag_values=[]),
value=PanelValue(
metric_id="DatasetDriftMetric",
field_path="share_of_drifted_columns",
legend="share",
),
text="share",
agg=CounterAgg.LAST,
size=1,
) #1
#1 定义一个 panel,用于查看漂移特征占比
同样地,我们还可以创建更多 panel(本章 GitHub 仓库里提供了完整定义)。然后用 project.dashboard.add_panel() 把这些 panel 加到项目 dashboard 中,并用 project.save() 保存改动。保存很关键,否则 UI 不会更新。
如果现在刷新 UI,会看到一个空 dashboard(图 6.18)。第一个 panel 显示 dashboard 标题;后续 panel 描述模型调用次数、漂移特征数量以及漂移指标等。
图 6.18 空的 income classifier dashboard
在接下来的几节中,我们会为批处理与实时两种用例填充这个 dashboard。附录 A 提供了在 Kubernetes 集群中部署 Evidently UI 的说明,我们也会给 income classifier dashboard 添加更多 panels。进入下一节之前,请确保已完成相关部署设置。
6.2.2 用 Kubeflow Pipeline 组件做数据漂移检测
用于数据漂移监控的批处理任务,对模型验证、数据质量监控等场景非常有用。针对批处理用例,我们将再构建一个 Kubeflow Pipelines(KFP)组件,用来为 income classifier 的数据漂移仪表盘填充数据。这个组件会被我们在第 5 章 5.1.2 节构建的推理流水线使用,并被放置在特征检索(feature retrieval) 与运行推理(run inference) 组件之间。如果在新特征数据与训练数据(参考数据,reference data)之间检测到漂移,我们就会停止流水线执行。
我们照常把组件代码写在一个 Python 文件(detect_drift.py)里,如清单 6.9 所示。我们初始化 MLflow client,并用它获取模型的 run ID。这个 run 持有参考数据集的信息(如第 4 章 4.1.2 节所述,我们已在 MLflow 中记录了该数据集),因此我们可以用它从 MinIO 下载文件。我们还会初始化 Evidently workspace,以便把报告数据添加到仪表盘中。推理数据(inference data)就是从 Feast 取回的那份数据,我们会从上一个任务(retrieve_features_task)得到它。接着我们通过定义所有 metrics 来初始化 report。report 中使用的 metrics 应该与仪表盘中使用的 metrics 对应。然后基于 reference 与 current 两个数据集,通过 report.run 生成报告。
NOTE 这是一个较长的清单,用于展示完整的 KFP 组件。
清单 6.9 用于检测数据漂移的 KFP 组件
def detect_drift(
model_name: str,
model_stage: str,
mlflow_host: str,
minio_host: str,
access_key: str,
secret_key: str,
reference_dataset_name: str,
feature_dataset_path: str,
evidently_workspace_url: str,
evidently_ui_project_name: str,
):
os.environ["AWS_ACCESS_KEY_ID"] = access_key
os.environ["AWS_SECRET_ACCESS_KEY"] = secret_key
if not minio_host.startswith("http"):
os.environ["AWS_ENDPOINT_URL"] = "http://" + minio_host
else:
os.environ["AWS_ENDPOINT_URL"] = minio_host
mlflow.set_tracking_uri(mlflow_host)
mlflow_client = MlflowClient(mlflow_host) #1
evidently_workspace = \
RemoteWorkspace(evidently_workspace_url) #2
model_run_id = None
for model in mlflow_client.search_model_versions(f"name='{model_name}'"):
if model.current_stage == model_stage:
model_run_id = model.run_id
break
if not model_run_id:
raise ValueError(
f"Model in stage {model_stage} "
f"not found for {model_name}."
)
run = mlflow.get_run(model_run_id)
dataset_source = None
for dataset_input in run.inputs.dataset_inputs:
for tag in dataset_input.tags:
if tag.value == reference_dataset_name:
dataset_source = json.loads(
dataset_input.dataset.source
)["uri"]
break
if not dataset_source:
raise ValueError(
f"Reference dataset {reference_dataset_name} "
"not found."
)
bucket_name = dataset_source.split("/")[0]
object_name = "/".join(dataset_source.split("/")[1:])
file_path = object_name.split("/")[-1]
download_file_from_minio(
minio_host=minio_host,
access_key=access_key,
secret_key=secret_key,
bucket_name=bucket_name,
object_name=object_name,
file_path=file_path,
) #3
reference_df = pd.read_csv(file_path)
feature_df = pd.read_parquet(feature_dataset_path) #4
feature_df.drop(columns=["user_id"], errors="ignore", inplace=True)
report = Report(
metrics=[
DatasetDriftMetric(),
DatasetMissingValuesMetric(),
ColumnDriftMetric(column_name="Education"),
ColumnSummaryMetric(column_name="Education"),
ColumnDriftMetric(column_name="Marital-Status"),
ColumnSummaryMetric(column_name="Marital-Status"),
ColumnDriftMetric(column_name="Native_country"),
ColumnSummaryMetric(column_name="Native_country"),
ColumnDriftMetric(column_name="Occupation"),
ColumnSummaryMetric(column_name="Occupation"),
ColumnDriftMetric(column_name="Race"),
ColumnSummaryMetric(column_name="Race"),
ColumnDriftMetric(column_name="Relationship"),
ColumnSummaryMetric(column_name="Relationship"),
ColumnDriftMetric(column_name="Sex"),
ColumnSummaryMetric(column_name="Sex"),
ColumnDriftMetric(column_name="Workclass"),
ColumnSummaryMetric(column_name="Workclass"),
],
) #5
report.run(
reference_data=reference_df,
current_data=feature_df
) #6
report_file_path = "drift_report.json"
with open(report_file_path, "w") as f:
f.write(report.json())
print("report written")
project = get_evidently_project(
evidently_workspace,
evidently_ui_project_name
)
print("project retreived")
evidently_workspace.add_report(project.id, report) #7
project.save()
#1 初始化 MLflow client
#2 初始化 Evidently remote workspace
#3 从 MinIO 下载参考数据集
#4 获取当前数据集
#5 用不同 metrics 初始化 Evidently report
#6 运行 Evidently report
#7 将 report 添加到已有 workspace 与 project 中
随后,我们就可以把这个组件插入到 income classifier 的推理流水线中。我们创建一个 component.yaml 文件,定义该数据漂移组件的输入与输出(与第 5 章 5.1.1 节的做法类似)。然后在 pipeline 中加载该组件。完整的流水线执行效果如图 6.19 所示。
图 6.19 含 detect data drift 组件的 Kubeflow 推理流水线一次成功运行
接着我们查看仪表盘,可以看到报告已经写入,所有 panel 不再是空的,而是有了数据。根据 “Share of Drifted Features” panel,我们可以看到在当前这批数据中,没有任何列发生显著漂移(图 6.20)。
图 6.20 一个 Evidently 仪表盘:展示数据质量(缺失数据)指标
这个仪表盘会在每次流水线运行后更新。我们甚至可以通过过滤数据来观察漂移统计随时间如何变化。接下来,我们将修改 BentoML 服务,以更新 income classifier 的实时项目仪表盘。
6.2.3 针对以 API 形式部署的模型进行数据漂移检测
某些应用(例如欺诈检测、推荐系统)可能需要实时的数据漂移监控。这类应用对实时决策、对数据变化的响应能力,以及预测模型的准确性要求很高。我们会在使用 BentoML 部署的 income classifier 应用上演示这一点。
不建议对每一个打到 /predict 端点的新请求都运行漂移检测统计检验。因为当我们用参考数据集与单条请求去对比时,结果不会准确。相反,我们必须用一个微批次(microbatch) 来对比参考数据集。微批次的大小因应用而异。基于这一点,我们现在修改 BentoML 应用,以支持实时(更准确地说:微批次)漂移检测。
为实现微批次,我们需要在服务启动时初始化 MonitoringService 对象,如清单 6.10 所示。监控服务的主要目标是:维护当前的微批数据窗口,并在窗口达到指定大小时运行 report。它还会滑动窗口,通过在每次后续请求到来时驱逐(evict)旧数据,确保窗口里只保留最近的数据。例如,我们希望拿到大小为 100 的样本与参考数据集对比。应用启动后,我们会累计 100 个样本(可能对应对 /predict 的 100 次调用)。当样本达到 100 后,我们才运行漂移检测。之后每来一个新请求,我们都会丢弃窗口中最旧的一条样本,从而始终保持最近 100 条样本作为当前窗口。
我们会持续将新行追加到当前窗口,直到新行数量使窗口大小超过设定值。达到设定窗口大小时,我们触发 report 并把它写入 workspace。窗口大小超过设定值时,我们还会从窗口中丢弃旧数据。
清单 6.10 用于定期运行漂移报告的 MonitoringService
class MonitoringService:
def __init__(
self,
report: Report,
reference: pandas.DataFrame,
workspace: Workspace,
project_id: str,
window_size: int,
):
self.window_size = int(window_size)
self.report = report
self.new_rows = 0
self.reference = reference
self.workspace = workspace
self.project_id = project_id
self.current = pandas.DataFrame()
def iterate(self, new_rows: pandas.DataFrame):
rows_count = new_rows.shape[0]
self.current = pandas.concat(
[self.current, new_rows],
ignore_index=True
) #1
self.new_rows += rows_count
current_size = self.current.shape[0]
if current_size > self.window_size: #2
self.current = self.current.iloc[-self.window_size :]
self.current.reset_index(drop=True, inplace=True)
if current_size < self.window_size: #3
logger.info(
f"Not enough data for measurement: {current_size} "
f"of {self.window_size}. Waiting more data"
)
return
self.report.timestamp = datetime.datetime.now()
logger.info("Running report")
self.report.run(
reference_data=self.reference,
current_data=self.current,
) #4
self.workspace.add_report(
project_id=self.project_id,
report=self.report
) #5
#1 每个新请求的数据都会追加到 current data frame 中。
#2 确保 current dataset 的大小不超过 window size 的条件
#3 当 current dataset 小于 window size 时,不计算漂移报告的条件
#4 当 current dataset 大小等于 window size 时运行 report
#5 将 report 添加到 workspace
在清单 6.11 中,我们在 BentoML 的 initialize 函数(应用启动时运行)里初始化 MonitoringService。具体做法是:从 MLflow 取回参考数据集作为 MonitoringService 的 reference;定义 report 结构;配置 RemoteWorkspace,并从 .env 文件中读取 project ID 和 window size。我们可以设置合适的 window size;在测试中,我们在 .env 里把它设为 50。
清单 6.11 设置 RemoteWorkspace 与 MonitoringService
run = mlflow.get_run(model_run_id)
dataset_source = None
for dataset_input in run.inputs.dataset_inputs:
for tag in dataset_input.tags:
if tag.value == config["REFERENCE_DATASET_NAME"]:
dataset_source = json.loads(
dataset_input.dataset.source
)["uri"]
break
if not dataset_source:
raise ValueError(
f"Reference dataset {config['REFERENCE_DATASET_NAME']} not found."
)
bucket_name = dataset_source.split("/")[0]
object_name = "/".join(dataset_source.split("/")[1:])
file_path = object_name.split("/")[-1]
print(bucket_name, object_name, file_path)
download_file_from_minio(
minio_host=config["MINIO_HOST"],
access_key=config["MINIO_ACCESS_KEY"],
secret_key=config["MINIO_SECRET_KEY"],
bucket_name=bucket_name,
object_name=object_name,
file_path=file_path,
)
reference_df = pd.read_csv(file_path) #1
evidently_workspace = \
RemoteWorkspace(
config["EVIDENTLY_WORKSPACE_URL"]
) #2
context.state["evidently_workspace"] = evidently_workspace
context.state["monitoring_service"] = MonitoringService(
report=data_drift_report,
reference=reference_df,
workspace=evidently_workspace,
project_id=config["EVIDENTLY_PROJECT_ID"],
window_size=config["EVIDENTLY_REPORT_WINDOW_SIZE"],
) #3
#1 下载参考数据集
#2 定义 RemoteWorkspace
#3 定义 MonitoringService
在 predict 函数里,我们需要调用监控服务的 iterate 方法来运行 report,并维护 current dataset 的窗口:
ctx.state["monitoring_service"].iterate(feature_df)
然后我们用 bentoml build 与 push 命令部署应用(使用 Bento 文件 bentofile_with_drift.yaml)。为了验证应用是否能监控漂移,我们可以运行 generate_mock_request.py 触发几百次请求,它只是用不同的 user_id 调用我们的端点。应用收到超过 50 个请求后,就会生成第一份报告。从那以后,每来一个新请求都会生成一个报告;每次 current data 的大小仍会是 50,因为我们会从窗口里移除最旧的一条请求数据。每一个报告快照都会显示在仪表盘上(图 6.21)。
图 6.21 实时用例的 Evidently 仪表盘:每个 window size / snapshot 会生成一个报告数据点。
仪表盘的结构与批处理用例相同,但实时用例的数据点会更多。对于窗口大小 50,“Share of Drifted Features” 指标接近 0.875,也就是 87% 的列发生了漂移。我们也可以随着时间查看这个指标,因为我们现在在周期性地发送多个快照。不过,这个数值看起来有些奇怪:因为我们是从训练数据集本身取的 current data,理论上不应该有漂移。这可能是由样本量 50 太小导致的,因此值得回过头调整阈值,确保只有在真正发生漂移时才触发告警。你也可以尝试使用更大的 window size 来验证。
识别数据漂移只是第一步,关键还在于采取必要措施来处理漂移及其对应用造成的潜在影响。发生漂移时我们可以采取以下步骤:
- 调查漂移:先检查漂移的类型与严重程度,判断它是否重要,是否影响模型公平性或准确性。可能需要进一步统计检验与更深入的数据分析。
- 修复上游数据流水线并改进预处理:如果漂移来自上游数据处理流水线变化,需要修复流水线问题,或加强数据清洗流程以保证数据质量与一致性。
- 重训模型:如果漂移显著影响模型表现,可能需要用最新数据重新训练模型。
- 更新特征选择:如果漂移与特征重要性变化有关,重新审视特征选择流程,可能需要新增、删除或修改特征以适应变化的数据模式。
- 持续监控与迭代:监控漂移不只是一个流程,而是对数据驱动业务持续成功的长期承诺。
Summary
- BentoML 之类的工具通过降低部署的技术门槛、促进数据科学家与工程师之间的协作,并加速从模型开发到真实业务影响的落地路径,从而赋能数据科学团队。
- 尽管这些工具提供了更顺滑的部署流程,但理解其底层技术(如 Docker、Kubernetes)能让数据科学家在满足特定需求时更好地定制与精细化部署。
- 将数据漂移监控集成到 ML 流水线中,可以及早发现性能退化,从而及时干预并避免代价高昂的错误。
- 监控数据漂移对于保持 ML 系统的精度与效率至关重要。通过识别并纠正数据分布随时间发生的变化,可以降低模型劣化与错误决策的风险。
- Evidently 等数据漂移检测工具有助于识别数据模式随时间的偏移,帮助我们在 ML 应用中维持模型准确性、合规性与数据质量。