本章内容包括:
- 为 Kubeflow Notebooks 构建并启动镜像
- 使用 Kubeflow Notebooks 做数据分析
- 在 Kubeflow Pipelines 中传递数据
- 编写能够传递数据的 Kubeflow 组件
- 为目标检测开发数据准备流水线
机器学习(ML)的版图始终在快速演进,几乎每隔一两周就会出现新的进展。在深度学习成为焦点的时代,像 You Only Look Once(YOLO)和 ResNet 的新版本一度是圈内热点。如今(至少在我们写作本书的时候),大语言模型(LLM)和视觉语言模型(VLM)凭借性能与广泛应用走到了舞台中央。
尽管总有新的架构和技术吸引聚光灯,但这些技术的成功往往取决于 ML 中 arguably 最不“性感”、却最关键的部分:数据准备。“垃圾进,垃圾出(Garbage in, garbage out)”并不只是脾气不太好的 ML 工程师随口抱怨的一句话;它揭示了一个基本事实:输入数据的质量与完整性,最终决定了你的 ML 模型与结果的可靠性和有效性。
在前面章节打下的机器学习运维(MLOps)基础之上,我们现在将处理两个真实世界的 ML 项目:一个身份证(ID card)检测器,以及一个电影推荐系统。这些综合项目(capstone projects)将帮助我们通过动手实现来应用并加深对 MLOps 实践的理解。本章聚焦于任何 ML 项目都至关重要的一步:数据分析与准备(见图 7.1)。我们会把前面章节的知识迁移过来,使用 Kubeflow notebooks 进行交互式数据探索,并构建可复现的数据准备流水线。通过实战示例,你会看到:扎实的数据准备如何为成功的 ML 项目奠定基础。
图 7.1 我们现在聚焦的“心智地图”:流水线的第一步/组件——数据采集与准备(3)
综合项目 1:身份证检测(ID CARD DETECTION)
第一个项目将使用 Kubeflow 来检测身份证。该项目会带你用 Kubeflow 构建一条目标检测流水线,并帮助你看到:ML 技术的成败在很大程度上取决于数据准备——也就是本章的重点。在这个项目中,我们将使用 YOLO 的一个变体。YOLO 是一种目标检测算法,可以在图像中检测并分类目标。
综合项目 2:电影推荐(MOVIE RECOMMENDATIONS)
第二个项目将使用 MovieLens 数据集构建一个电影推荐系统(我们会在本章后面介绍该数据集)。本章同样聚焦该项目中的数据准备部分,并引入一些与表格数据处理相关的概念。我们会构建六个组件,并把它们组合成一条数据准备流水线。
第 1 章到第 6 章把我们带到了这里;而从本章开始,我们会把你学到的内容真正用到一个 ML 问题和数据集上,并且沿途再学一些新东西!我们假设你已经有一个可用的 Kubeflow 环境,并且有一台足够强的机器来执行这些流水线。
在进入综合项目之前,我们先用更简单的示例把概念铺垫好。我们会使用“笑话(jokes)”玩具流水线来解释并回顾:在 Kubeflow 中用 notebooks 做数据分析、在流水线中传递数据,以及一些其他概念,然后再把这些概念用于综合项目的实现。如果你更想直接从项目开始,可以跳到第 7.3 节。
7.1 数据分析
数据分析对 ML 工作流与基础设施提出了一些独特约束。一方面,我们需要分析与实验系统尽可能靠近数据集运行,这样即便数据集非常庞大也能被处理。另一方面,我们还需要对昂贵的计算资源(例如 GPU、以及资源规格很高的机器)具备临时、交互式的访问能力,并且能根据用户需要随时启停。举例来说,你可能想启动一个 Jupyter Notebook 来检查目标检测模型在训练数据集上的结果,甚至可能在 notebook 里加载模型,做一些临时(ad hoc)的推理。
最后,我们总是更倾向于在一个尽可能贴近部署环境的实验环境中开展实验,同时还能支持健壮的版本管理与持续集成/持续部署(CI/CD)实践。
乍一看,这一长串需求似乎需要一个完整团队来管理。在传统工作流里,这可能确实如此;即使做得到,也往往意味着大量定制工程与持续维护。
这就是 Kubeflow notebooks 大显身手的地方。Kubeflow notebooks 旨在解决上述所有问题,同时为开发者提供熟悉的界面:在浏览器中使用 Jupyter Notebooks、Visual Studio Code(VS Code)或 RStudio。它们与我们的流水线运行在同一个 Kubeflow 集群上,并且(如后文所示)可以复用与流水线相同的基础镜像,从而让环境尽可能贴近部署环境。我们还会把 notebook 镜像的构建纳入与流水线、部署容器相同的 CI 流水线中,进一步强化版本控制。
我们的计划是:先进入一个示例 Kubeflow notebook 进行探索。我们会走通启动一个简单 notebook 的流程,理解 notebook 在底层是如何配置的,并修改一些默认设置。最后,我们会讨论:什么样的镜像才与 Kubeflow notebooks 兼容,以及如何在基础镜像上叠加一些层,让它能作为 notebook 运行。
7.1.1 在 Kubeflow 中启动一个 notebook server
在 Kubeflow UI 中,进入 Notebooks 区域,点击 NEW NOTEBOOK(见图 7.2)。创建 notebook 时要留意 Kubernetes(K8s)的 Namespace 选择器,因为它往往会限制资源配额、卷挂载、以及 secrets 等内容。
图 7.2 启动一个 Kubeflow notebook
图 7.3 展示了 notebook 创建页面。Image 区域用于选择要在集群上启动的镜像。现在我们先选一个示例镜像,后面会再回到这里添加自定义镜像。资源菜单的行为与 K8s 中的 request/limit 完全一致,所以要小心别把内存 limit 设得太低,尤其是在测试阶段。
图 7.3 镜像配置选项:名称、命名空间、镜像与资源
接下来,如果集群具备 GPU 或加速器资源(例如张量处理单元 TPU),在页面的 GPUs 区域选择所需数量。注意,这些资源通常非常昂贵,而且 notebook 会阻止其他 pod 使用同一资源(除非启用了某种形式的 overprovisioning)。如果你希望把这些资源提供给 notebooks,请确保 notebooks 配置了生命周期规则,在不使用时停止 pods(我们后面会讨论)。
7.1.2 Workspace 与数据卷
创建 notebook 时,Kubeflow 会自动把 workspace volume 挂载到 /home/jovyan(后面我们会讨论如何修改)。这提供了一个持久化存储,用于保存环境或配置文件等内容。你也可以选择性地挂载数据卷(data volumes),它们是在集群上的持久卷(Persistent Volumes,PV)。
workspace volume 通常会在启动时根据集群的默认 storage class 创建,而数据卷通常是运行时挂载的、已有的持久卷声明(Persistent Volume Claims,PVC)(见图 7.4)。
图 7.4 为 notebook 添加 workspace 与数据卷的配置选项
如果你不熟悉 PVC:它是运行在 K8s 集群上的容器化应用对存储资源发出的请求,允许应用访问并使用由底层基础设施动态供给与管理的存储卷。例外情况是选择了预先存在的 workspace volume(例如你想更换运行容器但保留 workspace 数据时)。
7.1.3 Configurations 与 affinity/tolerations
Configurations 指的是该命名空间里存在的 PodDefaults。举例来说,假设我们有一个 PodDefault,如清单 7.1 所示,用于在运行时给 pod 注入一些环境变量。注意 env 关键字下的环境变量。PodDefaults 以显式的 name-value 对存储环境变量。
清单 7.1 PodDefault 示例
apiVersion: kubeflow.org/v1alpha1
kind: PodDefault
metadata:
annotations:
name: add-proxy-vars
namespace: kubeflow
spec:
desc: add proxy variables
env:
- name: http_proxy
value: http://192.168.1.1:3128
- name: https_proxy
value: http://192.168.1.1:3128
- name: no_proxy
value: .svc,.local,127.0.0.0/8
- name: MLFLOW_TRACKING_URI
value: http://mlflow.kubeflow.svc.cluster.local:5000
selector:
matchLabels:
add-proxy-vars: 'true'
我们看到该 PodDefault 资源名为 add-proxy-vars,而你稍后会看到,它也可以被加入到 UI 配置里,以便让用户选择。若在启动 notebook 时选择了它,PodDefaults 会自动应用;在我们的例子中,它会自动注入环境变量 MLFLOW_TRACKING_URI。这个环境变量非常特殊——你会在后续章节看到原因。你还可以把它扩展到:注入认证凭据、执行特定初始化、甚至挂载某些卷。这通常也是让用户选择卷挂载的一种更简单方式,因为路径与 PVC 名称都可以由集群层统一控制。
Affinity 与 tolerations 的行为与 K8s 中对应概念一致。Affinity 选择器会把 pod “吸引”到具备相应 affinity 标签的节点上;tolerations 则允许 pod 被调度到带有特定 taints 的节点上,从而把缺乏相应 toleration 的 pods “推开”。Affinity 与 tolerations 最常见的用例是控制对含有特殊资源(如 GPU)的节点的访问。
根据需求启用或禁用共享内存(某些 AI 框架与方法,比如分布式训练或基于内存缓存的数据加载器,会从共享内存中受益),然后点击 Launch 按钮将 pod 调度到节点上。如果你在 notebook 调度时观察 K8s 集群,你会看到:workspace drive 被供给(provisioned)、affinity 与 tolerations 生效以把 pod 调度到某个节点、以及节点拉取镜像。如果你留意得更仔细,还能在 pod 中看到 Istio sidecar 容器,以及主容器和一个 init 容器。Kubeflow 把 notebook 的大部分网络工作抽象掉了,让用户可以不必操心这些就开始实验。
在集群上,试着列出 kubeflow.org 之下的自定义资源定义(CRD):
kubectl get crd -o=name | grep kubeflow.org
你应该能看到一个 notebook 资源,以及我们刚创建的 notebook 作为资源存在(见图 7.5):
图 7.5 Affinity 与 tolerations 分组配置
customresourcedefinition.apiextensions.k8s.io/notebooks.kubeflow.org
如果在启动 notebook server 后需要任何变更,就需要编辑这个 CRD。注意:变更会触发重新部署,导致容器重启时所有非持久化数据被清除。
7.1.4 自定义菜单
作为集群管理员,你可能希望更改 Kubeflow notebooks 向用户呈现的选项。一个常见用例是自定义镜像列表,以创建可复现、且尽可能贴近部署环境的运行环境。要更改默认值、定制选项、并向 notebook 菜单中添加新的可用镜像,需要编辑一个 ConfigMap。该 ConfigMap 由 notebook controller 用于向用户展示选项。它位于 kubeflow 命名空间里,名称通常以 jupyter-web-app-config 开头。你可以用下面命令找到其确切名称:
kubectl get cm -n kubeflow | grep jupyter-web-app-config
查看其内容后会发现,每个选项都配置了一个 default value 键,以及一个 readOnly 标记,用于让某个选项对启动 notebook 的用户不可编辑。下面我们谈几个有意思的选项。
自定义展示给用户的镜像
如果你编辑上述 jupyter-web-app-config,会看到类似清单 7.2 的片段。
清单 7.2 Jupyter web app ConfigMap
apiVersion: v1
data:
spawner_ui_config.yaml: >
spawnerFormDefaults:
image:
value: kubeflownotebookswg/jupyter-scipy:v1.6.1
options:
- asia.gcr.io/kubeflow-notebook-servers/jupyter-tensorflow:2.6.0
- asia.gcr.io/kubeflow-notebook-servers/jupyter-pytorch:1.9.0
imageGroupOne:
- kubeflownotebookswg/codeserver-python:v1.6.1
imageGroupTwo:
- kubeflownotebookswg/rstudio-tidyverse:v1.6.1
spawnerFormDefaults 的第一个字段与镜像相关。镜像被分组:image 配置 Jupyter 选项;imageGroupOne 与 imageGroupTwo 分别对应 code-server 和 RStudio 选项。每个组里,value 表示默认选项,options 列出下拉菜单中展示给用户的可选项。这给了我们一种简单但很强大的方式:把这里的镜像列表与自动化 CD 流水线集成——例如在部署流程中新增或删除这里列出的镜像。镜像分组下面还会有一些额外选项,比如把 UI 中的 image 字段设为不可编辑,从而限制用户只能使用列表中列出的镜像。
affinity 与 tolerations 分组
如前所述,affinity 与 tolerations 会共同作用,把 pod 调度到集群的特定节点上。为便于讨论,我们这里只讲 affinity 配置;tolerations 与之类似,会与节点 taints 配合。
对 Kubeflow notebooks 来说,affinity 配置与你在 K8s 普通 pod 上写 affinity 的方式完全一样。和其他配置项一样,这个选项也可以被设为只读,并提供默认配置,从而限制用户可以把 notebook 启动到哪些节点上。为了让行为更动态,我们还可以给用户展示一组选项。比如,假设集群中有些节点的 CPU 更强。为了帮助用户为自己的工作流选择最佳节点,我们可以设置两个 affinity 配置:small-cpu 和 xl-cpu,如清单 7.3 所示。我们的示例中,相关节点会带有对应的 labels。
清单 7.3 Node affinity ConfigMap
affinityConfig:
value: ""
options:
- configKey: "small-cpu-selector"
displayName: "small-cpu-node"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: "compute"
operator: "In"
values:
- "small"
- configKey: "xl-cpu-selector"
displayName: "xl-cpu-node"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: "compute"
operator: "In"
values:
- "xl"
通过这个配置,用户现在会看到两个 CPU 选择项。与默认值结合使用时,我们可以让 notebooks 默认调度到小 CPU 节点,并把更强的 CPU 留给确实需要它们的 notebooks。把它们设为 affinity 配置还意味着:如果小 CPU 节点满了,强 CPU 节点仍然可以接收 pods。如果你需要限制对强 CPU 节点的访问,就把 affinity 配置与节点 taints 和 tolerations 配置结合起来使用。
7.1.5 创建自定义 Kubeflow notebook 镜像
现在我们已经对如何自定义界面有了一个概念,接下来的问题是:如何在 Kubeflow notebook 中运行自定义镜像。要理解这个过程,最好先弄清楚:一个自定义 Kubeflow notebook 镜像是由什么组成的,以及需要哪些层(见图 7.6)。
图 7.6 自定义 Kubeflow notebook 镜像的分层概览
从本质上讲,一个自定义镜像由三部分构成:
- code-server/Jupyter 运行时所需的一组依赖
- 运行时本身(code-server 或 Jupyter)
- 为了与 Kubeflow 兼容、用于启动 notebook server 的配置文件
项目仓库中提供了一个多阶段(multistage)Dockerfile:它接受一个基础镜像作为参数,并能构建出 code-server 或 Jupyter 镜像。注意:当你把它部署到组织内部时,应该审阅这个 Dockerfile,并做必要改动以优化镜像体积。
对从本地开发工作流迁移过来的数据科学家或 ML 工程师来说,Kubeflow notebooks 可能是最熟悉的界面,因此它为团队过渡到 Kubeflow 与现代化 ML 工作流提供了一条简单路径。把自定义镜像与 Kubeflow 提供的“启动 notebooks、运行实验”的灵活性结合起来,可以在现代的基于 Git 的工作流中为用户带来无缝体验。
现在我们已经讲完 notebooks,接下来看看启用实验所需的下一组工具——结构化 ETL(extract、transform、load)与 Kubeflow Pipelines(KFP)。我们会用它们来构建可重复、可复现、可观测的 ML 工作流。
7.2 数据传递
理解 KFP 中的数据传递至关重要,因为它在构建能够协同工作的组件方面起着基础作用——这些组件共同构成 ML 工作流。然而,每个组件都是在隔离环境中运行的:KFP 中的每个组件都在一个独立的 K8s pod 中运行。这意味着它们必须有某种机制来传递并共享数据——而这恰恰是新手与有经验的人都经常踩坑的地方之一!在本节中,我们会讲解数据传递的基础知识,并给出一些要点,帮助你把每一种场景适配到你自己的用例中。
7.2.1 场景 1:向下游组件传递简单值
先从第一个场景开始:向下游组件传递简单值。这里我们有三个简单函数,我们会把它们转换成 Kubeflow 组件,然后把它们组合成一条流水线。
清单 7.4 将要转换成 Kubeflow 组件的函数
def generate_joke() -> str:
import pyjokes #1
return pyjokes.get_joke()
def count_words(input: str) -> int:
return len(input.split())
def output_result(count: int) -> str:
return f"Word count: {count}"
#1 在创建组件函数时,import 应该放在函数定义内部。
这条流水线的想法如下:首先,generate_joke 函数使用 PyJokes 库生成一段笑话文本;然后把这段文本传给词数统计函数,输出词数;最后,把最终结果(词数)传给最后一个组件,由它打印结果。现在我们来看它在 KFP 中会是什么样子(见清单 7.5)。
最重要的一点是:所有 import 语句都应该写在函数定义内部,这样依赖会被封装起来,便于打包成 Docker 容器并在 KFP 中执行。这正是我们在清单 7.4 中对 import pyjokes 所做的。
清单 7.5 Kubeflow 组件与流水线定义
import kfp.dsl as dsl
@dsl.component( #1
packages_to_install=["pyjokes"], #2
base_image="python:3.11" #3
)
def generate_joke() -> str:
import pyjokes
return pyjokes.get_joke()
@dsl.component
def count_words(input: str) -> int:
return len(input.split())
@dsl.component
def output_result(count: int) -> str:
return f"Word count: {count}"
@dsl.pipeline( #4
name="joke_pipeline", #4
description="simple pipeline to demo data passing") #4
def pipeline():
generate_joke_op = generate_joke()
count_word = count_words(input=
generate_joke_op.output) #5
output_result = output_result(count=
count_word_op.output) #5
if __name__ == '__main__':
from kfp import compiler
compiler.Compiler().compile(
pipeline_func=pipeline,
package_path='_pipeline.yaml'
)
#1 从 generate_joke 创建组件
#2 使用 packages_to_install 指定自定义包
#3 指定组件的基础镜像
#4 注解:将函数作为 KFP 流水线
#5 把输出传递给下游组件
我们使用 @dsl.component 注解把函数变成 Kubeflow 组件。packages_to_install 参数用于指定需要安装到组件执行环境中的 Python 包列表,类似于我们在 requirements.txt 中指定依赖。这些包对 generate_joke 的正常运行是必需的。只要某个功能不属于 Python 核心库,就应该把它包含进来。
Kubeflow 组件中的包管理规则
每个 Kubeflow 组件都运行在一个 Docker 容器镜像中,并在一个 K8s pod 里执行。要运行自定义包,必须满足以下条件之一:
- 包已经安装在容器镜像中。 你可以通过
@dsl.component的base_image参数指定镜像来实现。对于轻量组件,镜像需要包含 Python 3.5+。默认情况下会使用与你当前 Python 环境对应的 Python 镜像。 - 通过
@dsl.component的packages_to_install参数声明包。 我们刚刚用 pyjokes 演示了这种方式。 - 在函数内部安装包。 例如用
subprocess执行pip install来安装包。但这比前两种方式更不显式,因此不推荐。
接着,我们创建 KFP。这里你会注意到我们引入了一个在前面章节中简要提到的概念:output。
回想一下,前两个函数 generate_joke 和 count_words 都会返回值。这些返回值会被生成的组件以 output 属性表示。有意思的是底层发生了什么。前面清单 7.5 为了简洁只展示了 KFP 规范的一小段;如果你在 IDE 中跟着操作,这个就是生成出来的 joke_pipeline.yaml 文件。
deploymentSpec 区段定义了流水线中的每个 executor,每个 executor 对应一个组件。我们聚焦 exec-count-words。在每个 executor 的定义里,你会看到熟悉的 Docker 概念:container、args 和 image:
exec-count-words:
container:
args:
- --executor_input
- '{{$}}'
- --function_to_execute
- count_words
image: python:3.9
command 部分尤其有意思,它包含了大部分复杂性,并分多个阶段搭建执行环境。
第一段命令用于准备 Python 环境:
command:
sh
-c
| if ! [ -x "$(command -v pip)" ]; then python3 -m ensurepip
|| python3 -m ensurepip --user
|| apt-get install python3-pip fi
PIP_DISABLE_PIP_VERSION_CHECK=1
python3 -m pip install
--quiet
--no-warn-script-location
'kfp==2.11.0'
--no-deps
'typing-extensions>=3.7.4,<5; python_version<"3.9"'
&& "$0" "$@"
这段会确保 pip 已安装,并使用特定 flags 安装所需依赖(kfp 与 typing-extensions),以最小化输出与警告。
第二段命令负责准备并执行组件:
- sh
- ec
- |
program_path=$(mktemp -d)
printf "%s" "$0" > "$program_path/ephemeral_component.py"
_KFP_RUNTIME=true python3 -m kfp.dsl.executor_main \
--component_module_path "$program_path/ephemeral_component.py" \
"$@"
它会创建一个临时目录,把组件代码写入一个临时 Python 文件,然后用 KFP runtime executor 执行。最后一部分是实际的 Python 实现:
@dsl.component
def count_words(input: str) -> int:
return len(input.split())
它用类型提示定义了 count_words:输入是字符串,输出是词数(整数)。KFP 框架会自动处理序列化与数据传递。
流水线中的每个组件都以类似方式组织起来,差异主要在于具体的 Python 函数实现,以及是否需要额外依赖(比如 generate-joke 组件会额外安装 pyjokes)。
这份组合后的代码把 count-words 组件的所有方面集中到了一个地方:
- 组件定义:声明输入/输出接口
- 部署规格:容器配置、命令、以及 Python 实现
- 流水线 DAG 任务定义:描述它如何与其他组件连接、以及缓存等配置
每个部分承担不同职责:
- 组件定义声明接口。
- 部署规格负责运行时环境与实现。
- DAG 任务定义负责工作流编排与数据传递。
清单 7.6 KFP SDK 通过代码注入创建文件
deploymentSpec:
executors:
exec-count-words:
container:
args:
- --executor_input
- '{{$}}'
- --function_to_execute
- count_words
command:
- sh #1
- -c #1
- | #1
if ! [ -x "$(command -v pip)" ]; then #1
python3 -m ensurepip fi #1
export PIP_DISABLE_PIP_VERSION_CHECK=1 #1
python3 -m pip install #1
--no-warn-script-location \ #1
'kfp==2.11.0' '--no-deps' \ #1
'typing-extensions>=3.7.4,<5;\ #1
python_version<"3.9"' && "$0" "$@" #1
- sh #2
- -ec #2
- | #2
program_path=$(mktemp -d) #2
printf "%s" "$0" > "$program_path/ephemeral_component.py" #2
_KFP_RUNTIME=true \ #2
python3 -m kfp.dsl.executor_main #2
--component_module_path #2
"$program_path/ephemeral_component.py" #2
"$@" #2
- | #3
import kfp #3
from kfp import dsl #3
from kfp.dsl import * #3
from typing import * #3
def count_words(input: str) -> int: #3
return len(input.split()) #3
image: python:3.9 #3
# Component Interface Definition #4
comp-count-words:
executorLabel: exec-count-words
inputDefinitions:
parameters: #5
input:
parameterType: STRING
outputDefinitions:
parameters:
Output:
parameterType: NUMBER_INTEGER
# Pipeline Task Definition #6
root: #6
dag: #6
tasks: #6
count-words: #6
cachingOptions: #6
enableCache: true #6
componentRef: #6
name: comp-count-words #6
dependentTasks: #6
- generate-joke #6
inputs: #6
parameters: #6
input: #6
taskOutputParameter: #6
outputParameterKey: Output #6
producerTask: generate-joke #6
#1 搭建 Python 环境并安装所需依赖
#2 创建临时目录,把组件代码写入文件,并用 KFP runtime 执行
#3 使用类型提示实现组件——KFP 自动处理序列化
#4 用强类型输入/输出定义组件接口
#5 用强类型输入/输出定义组件接口
#6 定义组件在流水线 DAG 中如何连接其他组件,并配置缓存
这部分稍微有点“进阶”,如果你一时没完全跟上也没关系。Kubeflow 为了让数据传递生效,用的是一种有点“偷偷摸摸”的代码注入方式。要看注入了哪些代码,找到 count_words 的定义,把它在脑子里“减掉”,剩下的其他 Python 代码基本就是注入进去的。
它会加上一些辅助函数,把变量的输出捕获下来并存进一个字典。编译流水线后你可以检查生成的 YAML 来看到这些代码。
这种传递简单值的方式非常适合简单函数与简单值。但如果你要传递更大的东西,比如几百 GB 量级的数据集,该怎么办?下一节就会覆盖。继续!
7.2.2 场景 2:为更大数据传递路径
我们认为,在大多数情况下,你会希望你的组件处理大量数据——无论是数据准备还是模型训练。再次强调,需要记住的关键点是:组件是在 K8s pod 的上下文中执行的。为什么这很重要?因为每个 pod 都会有预先设定的内存上限。如果超过这个内存上限——例如处理大量数据需要占用过多 RAM——pod 就会被杀掉,并出现让人头疼的 OOMKilled 错误。
因此,一个建议是:如果可行,尽量使用文件或目录来存储数据,并在函数之间传递这个文件或目录的路径,而不是直接传递原始值。为了说明我们的意思,这里是我们之前用过的“生成笑话”的例子,不过我们给它加了点料:新增了一个参数,用于指定笑话数量,同时加上输出文件路径。
清单 7.7 使用 Output[Artifact] 将结果写入文件
from kfp.dsl import Output, Input, Artifact
@dsl.component(
base_image="python:3.11",
packages_to_install=["pyjokes"]
)
def generate_joke(
num_of_jokes: int,
output_jokes: Output[Artifact]): #1
import pyjokes
import os
os.makedirs(output_jokes.path, exist_ok=True) #2
jokes_path = os.path.join(
output_jokes.path,
"jokes.txt") #3
with open(jokes_path, 'w') as f:
for _ in range(num_of_jokes):
joke = pyjokes.get_joke()
f.write(joke + '\n')
@dsl.component
def count_words(jokes_file: Input[Artifact])) -> int: #4
import os
# Read from the artifact path
jokes_path = os.path.join(
jokes_file.path,
"jokes.txt") #5
with open(jokes_path, 'r') as f:
content = f.read()
return len(content.split())
@dsl.component
def output_result(count: int) -> str:
return f"Word count: {count}"
#1 Output[Artifact] 指定组件要把数据写到哪里。
#2 创建输出目录,因为 KFP 只提供路径本身
#3 将 artifact 目录路径与我们的具体文件名拼接
#4 Input[Artifact] 接收包含上游数据的目录。
#5 构造与 generate_joke 中相同的文件路径模式
这里我们仍然是三个函数,但做了修改以支持“基于文件”的数据传递。
首先,generate_joke 现在多了一个参数 output_jokes,类型是 Output[Artifact]。这是 KFP v2 用来提供“组件可以写出输出数据的目录”的方式。组件会创建这个目录(用 os.makedirs),并把笑话写入该目录下的一个文本文件。和之前一样,函数会生成笑话,但它不再直接返回笑话文本,而是把内容写到指定 artifact 目录中的文件里。
对 count_words 来说,它不再把笑话作为直接输入,而是接收一个 jokes_file 参数,类型为 Input[Artifact],它表示 generate_joke 写出文件所在的目录。组件会用标准 Python 文件 I/O 在该目录里查找 jokes.txt(路径构造方式与 generate_joke 写文件一致),然后返回一个整数作为词数统计结果。
output_result 函数保持不变,因为它只是格式化一个简单的字符串输出。在这里不需要文件处理,因为我们只是直接返回字符串。下面我们来看流水线定义(见清单 7.8)。
清单 7.8 三个组件的数据传递
@dsl.pipeline(
name="joke-pipeline",
description="Pipeline that generates jokes and counts words"
)
def joke_pipeline(num_jokes: int = 42):
jokes = generate_joke(num_of_jokes=num_jokes)
count = count_words(
jokes_file=jokes.outputs["output_jokes"]) #1
output = output_result(count=count.output) #2
#1 通过名称访问 artifact 输出,把目录传给下一个组件
#2 使用 .output 属性访问简单的返回值
题外话:KFP v1 与 v2 的输入/输出参数命名
如果你是从 KFP v1 迁移过来的(如果不是可以跳过),你可能熟悉一套关于 InputPath、OutputPath、InputFile、OutputFile 的命名约定。事实上,我原本写了整整一节,但后来不得不重写,因为 v1 和 v2 的命名约定不一样!
在 v1 中,参数名如何被解析有一些特定规则。比如,如果某个参数以 _path 或 _file 结尾,这些后缀会被移除,以生成最终的参数名。
KFP v2 采用了更直接、更“类型安全”的方式。你不再围绕文件路径并依赖命名约定,而是直接使用 kfp.dsl 提供的 Input 和 Output 这些强类型 artifacts。下面是对比:
KFP v1 的方式看起来像这样:
def data_prep(train_images_output_path: OutputPath(str),
train_labels_output_path: OutputPath(str)):
with open(train_images_output_path, "w") as f:
f.writelines(line + '\n' for line in x_train)
# ...
def train_and_eval(train_images_path: InputPath(str),
train_labels_path: InputPath(str))
# ...
# In pipeline:
train_and_eval_task = train_and_eval_op(
train_images_path=data_prep_task.outputs["train_images_output"],
train_labels_path=data_prep_task.outputs["train_labels_output"]
)
而 KFP v2 的方式看起来像这样:
from kfp import dsl
from kfp.dsl import Input, Output, Dataset, Model, Metrics
@dsl.component
def data_prep(
output_images: Output[Dataset],
output_labels: Output[Dataset]
):
# Create some sample data
images = np.random.rand(100, 28, 28)
labels = np.random.randint(0, 10, 100)
# Save using the .path property
np.save(output_images.path, images)
np.save(output_labels.path, labels)
# Add metadata about the datasets
output_images.metadata.update({
'samples': len(images),
'shape': images.shape
})
@dsl.component
def train_and_eval(
train_images: Input[Dataset],
train_labels: Input[Dataset],
metrics: Output[Metrics]
):
# Load data using the .path property
images = np.load(train_images.path)
labels = np.load(train_labels.path)
# Training simulation
accuracy = len(images) / 100 # dummy calculation
# Record metrics
metrics.log_metric('accuracy', accuracy)
metrics.metadata['num_samples'] = len(images)
# In pipeline:
@dsl.pipeline
def training_pipeline():
prep_task = data_prep()
train_and_eval(
train_images=prep_task.outputs['output_images'],
train_labels=prep_task.outputs['output_labels']
)
从 v1 迁移到 v2 时,你需要做下面这些事:
- 用合适的
Input[T]/Output[T]artifacts 替换InputPath/OutputPath。 - 尽可能使用专门的类型(如
Dataset、Model、Metrics)。 - 用
.path属性访问文件。 - 给 artifacts 添加相关 metadata。
- 去掉任何手动处理路径后缀的逻辑。
v2 的方式提供了更直观、更可维护的数据传递方式,并且对 ML 工作流有更好的内建支持与类型安全。
7.2.3 KFP v2 artifact 类型概览
KFP v2 提供了若干专门的 artifact 类型,用来处理 ML 流水线中的不同数据。下面是每种类型及其使用场景的完整指南。
Dataset 类型:表示任何形式的 ML 数据集
ML 工作流的成败取决于数据。Dataset artifact 类型是流水线中管理各种 ML 数据的基础——从原始输入到处理后的特征都适用。它提供了一种标准方式在组件之间传递数据,同时还能跟踪数据特征与变换相关的关键信息。你应该在以下情况使用它:
- 训练/验证/测试数据
- 特征集
- 预处理数据
- 数据切分结果
- 任何结构化的样本集合
下面的代码展示了一个组件:它用 Input[Dataset] 消费一个 dataset artifact,并输出一个处理后的 Output[Dataset],同时写入一些 metadata:
@dsl.component
def prepare_dataset(raw_data: Input[Dataset],
processed: Output[Dataset]):
df = pd.read_csv(raw_data.path)
df_processed = process_data(df)
df_processed.to_csv(processed.path, index=False)
processed.metadata.update({
'num_samples': len(df_processed),
'columns': list(df_processed.columns),
'preprocessing_date': datetime.now().isoformat()
})
Model 类型:表示训练好的 ML 模型
模型是大多数 ML 流水线的主要产物,KFP 的 Model artifact 类型就是为它设计的。它对框架相关的 metadata 有内建支持,能更容易追踪模型血缘:从训练数据到模型参数再到最终产物。你应该在以下情况使用 Model:
- 训练好的模型
- 模型 checkpoint
- 模型权重
- 预训练模型 artifacts
下面的代码展示了一个组件:它消费 dataset artifact,输出一个 Output[Model]:
@dsl.component
def train_model(dataset: Input[Dataset],
model: Output[Model]):
trained_model = train(dataset.path)
trained_model.save(model.path)
Metrics 类型:存储简单的键值指标
理解模型表现是 ML 工作流的核心。Metrics artifact 类型提供了一种简单但强大的方式来跟踪与对比流水线中的数值指标,便于监控进展并做出数据驱动决策。你应该在以下情况使用 Metrics:
- 模型性能指标
- 训练过程指标
- 评估结果
- 任何数值测量
下面代码展示了一个组件:它消费 model 与 dataset artifacts,并输出 metrics artifact,同时写入 metadata:
@dsl.component
def evaluate_model(model: Input[Model],
test_data: Input[Dataset],
metrics: Output[Metrics]):
results = evaluate(model.path, test_data.path)
metrics.log_metric('accuracy', results['accuracy'])
metrics.log_metric('f1_score', results['f1'])
metrics.metadata['evaluation_date'] = datetime.now().isoformat()
ClassificationMetrics 类型:分类任务的专用指标
分类是最常见的 ML 任务之一,而分类模型评估需要特定指标与可视化。ClassificationMetrics 专用 artifact 类型内建支持 ROC 曲线、混淆矩阵等,并且在 UI 里有专门渲染,帮助你理解模型表现。Kubeflow 还提供 SlicedClassificationMetrics,用于对数据切片计算分类指标,使用方式与 ClassificationMetrics 相同。你应该在以下情况使用它:
- ROC 曲线
- 混淆矩阵
- 分类相关评估
- 多分类指标
下面代码展示了一个组件:输出 ClassificationMetrics 并记录混淆矩阵:
@dsl.component
def evaluate_model( model: Input[Model],
test_data: Input[Dataset],
metrics: Output[ClassificationMetrics]):
results = evaluate(model.path, test_data.path)
metrics.log_confusion_matrix(
categories=["negative", "positive"],
matrix=results["confusion_matrix"]
)
HTML 类型:用于可视化与报告的 HTML 内容
有时候你需要比纯文本更丰富的可视化和报告能力。HTML artifact 类型允许你创建交互式可视化与富格式报告,并可直接在 KFP UI 中查看,使流水线输出更直观、更易用。你应该在以下情况使用它:
- 交互式可视化
- 富格式报告
- 仪表盘输出
- 自定义可视化 artifacts
下面示例导出一个简单 HTML 页面作为报告,展示在 UI 中(当然可以扩展为更复杂的报告):
@dsl.component
def create_report(
metrics: Input[Metrics],
report: Output[HTML]):
html_content = f"""
<html>
<body>
<h1>Model Performance Report</h1>
<p>Accuracy: {metrics.metadata['accuracy']}</p>
<div id="visualization">...</div>
</body>
</html>
"""
with open(report.path, 'w') as f:
f.write(html_content)
Markdown 类型:Markdown 格式的文档与报告
在 ML 流水线中,文档和代码同样重要。Markdown artifact 类型提供一种清晰可读的方式来生成并存储文档、报告、model card 等内容,既能在 KFP UI 中美观渲染,也能作为纯文本阅读。你应该在以下情况使用它:
- 文档生成
- 简单报告
- Model card
- README
- 训练总结
下面示例导出一个 markdown 格式报告,显示在 UI 中:
@dsl.component
def generate_model_card(
model: Input[Model],
metrics: Input[Metrics],
model_card: Output[Markdown]):
content = f"""# Model Card
- Framework: {model.metadata['framework']}
- Architecture: {model.metadata['architecture']}
- Accuracy: {metrics.metadata['accuracy']}
"""
with open(model_card.path, 'w') as f:
f.write(content)
Artifact 类型:通用 artifact 的基础类型
并不是所有东西都能自然归类到预定义类型里。基础 Artifact 类型是一个灵活的兜底方案:用于处理不适合其他专门类型的通用文件或数据,同时仍然具备 KFP artifact 系统的核心能力。你应该在以下情况使用它:
- 自定义 artifact 类型
- 通用文件输出
- 其他类型不适用时
- 临时或中间结果
- 配置文件
下面示例把一个 JSON 文件写成 Output artifact,并补充 metadata:
@dsl.component
def save_config(
config: Dict[str, Any],
output_config: Output[Artifact]):
with open(output_config.path, 'w') as f:
json.dump(config, f)
output_config.metadata.update({
'config_type': 'training_parameters',
'version': '1.0'
})
KFP v2 的 artifact 系统通过 8 类专用类型,为 ML 流水线的数据管理带来了类型安全与语义含义:Dataset、Model、Metrics(以及 ClassificationMetrics 和 SlicedClassificationMetrics)、HTML、Markdown,以及基础 Artifact。每种类型都提供内建的 metadata 跟踪与标准化存储处理,并在适当场景下提供专门的 UI 渲染能力。理解这些 artifact 类型及其用途,有助于构建可维护的 ML 流水线,并有效追踪数据血缘与模型溯源。
7.3 数据准备实战
现在,我们将为两个综合项目的训练流水线打下基础,从数据准备阶段开始。这个阶段分为两部分:下载数据集,以及将其划分为训练集、测试集和验证集。每个阶段都对应一个 Kubeflow 组件。这两个组件将被组装成一个可执行的流水线。
到本章结束时,你将学会如何创建你的第一个 KFP!更重要的是,你将学到如何创建可组合且定义清晰的 Kubeflow 组件,并且知道如何根据自身需求定制 Kubeflow 组件。
7.3.1 数据准备:目标检测
我们先梳理一下目标检测示例需要构建的模块。电影推荐系统与之类似,因此我们不会重复同样的组件,而只讨论需要注意的一些差异点。
NOTE 你可以跟着项目的 GitHub 代码一起做。本节所用项目的完整源码地址为:mng.bz/yNwJ。
背景:YOLO 需要什么格式
训练目标检测器并没有一个统一的标准数据集格式。就我们这个例子而言,要训练 YOLOv8 模型,必须按它要求的格式来组织数据集。我们已经完成了将数据集转换为 Ultralytics YOLO 格式的“脏活累活”(见清单 7.9)。但总体来说,这通常需要你或数据科学家来完成。
清单 7.9 用自定义数据集配置 YOLOv8 训练
path: /home/jovyan/data/
train: "train/images"
val: "val/images"
test: "test/images"
names:
0: id_card
这个配置文件格式很直观。第一行指定数据集根目录的完整路径,并假设数据已经按三种用途拆分:训练、验证、测试。
对每一种用途,你需要指定图片所在路径。标签则默认在一个对应的 labels 文件夹中。例如,训练集标签应在 train/labels,如下面清单所示。
清单 7.10 YOLOv8 期望的目录结构
:~/data$ ls train/ val/ test/
test/:
images labels
train/:
images labels
val/:
images labels
最后一个字段 names 指定类别索引与类别名称。这里的 class 是我们要检测的目标类别。类别编号从 0 开始(零基)。在我们的例子里,我们只关心一个目标——身份证(ID card),用 id_card 表示。图片与标签的命名规则很简单:images/id0042.png 的标签文件是 labels/id0042.txt。
这些背景信息之所以重要,是因为我们马上要构建一个“切分数据集”的组件。不同实现往往会导致不同的配置文件、目录结构和命名约定,所以在动手前查阅相关文档总是明智的。
创建 Kubeflow 组件有多种方法,这里我们介绍最常见的一种:使用 Python 函数。本节我们会构建两个组件——一个下载数据,一个切分数据集——然后将它们组装成一个流水线。开始之前,我们先聊聊这两个项目会用到的数据集。
MIDV-500:用于目标检测示例的数据集
目标检测示例将使用 MIDV-500 数据集(图 7.7),你可以在这里找到:github.com/fcakyon/mid…。
**图 7.7 MIDV-500 数据集(来源:github.com/fcakyon/mid…)
该数据集包含 500 段视频片段,覆盖 50 种不同类型的证件,包括身份证、护照、驾照等。为了本书的目的,我们已经过滤数据集,只保留身份证(ID cards)。
数据集下载组件
本节将一步步带你搭建第一个 Kubeflow 组件:从远程位置下载数据集。流程如下:
- 从远程位置下载数据。 在生产系统中,这个组件可能从数据库、数据湖中拉取数据,或从已有的数据存储中挖掘有价值的数据。对我们的项目而言,我们会从开源仓库下载数据。
- 解压并预处理数据。 这里我们会把下载的数据解压,并整理成可用于数据集摄取的形式。在生产流程中,这一步还可能包括数据校验与日志记录等。
- 存储数据。 我们将使用 MinIO 作为数据存储。MinIO 随 Kubeflow 预装,并提供类似 Amazon S3 的访问接口,便于脚本与自动化集成。在本项目中,我们会先在 MinIO 上创建一个 bucket,再把第 2 步预处理后的数据复制进去。MinIO 也提供一定的版本控制能力(注意:可能需要启用 versioning,且在 MinIO 中有一些特定“怪癖”,请查阅文档:mng.bz/Mwn2。)
完成这些步骤后,我们就可以开始为每一步创建组件,并先用“单组件流水线”进行测试。
第 1 步:从远程位置下载数据
我们的旅程从获取目标检测项目所需的关键数据开始。我们会通过书中提供的 Box URL 从远程位置拉取数据。该位置托管了包含图像及其对应标签的数据文件。在 Python 中实现很直接,如下所示。
清单 7.11 下载数据集(带进度条)
import requests
from tqdm import tqdm #1
# ~ 10GB
base = "https://manning.box.com/shared/static"
url=f"{base}/34dbdkmhahuafcxh0yhiqaf05rqnzjq9.gz" #2
downloaded_file = "DATASET.gz"
response = requests.get(url, stream=True) #3
file_size = response.headers.get("Content-Length", 0)
file_size = int(file_size) #3
progress_bar = tqdm(
total=file_size,
unit="B",
unit_scale=True) #3
with open(downloaded_file, 'wb') as file:
for chunk in response.iter_content(chunk_size=1024): #6
progress_bar.update(len(chunk)) #6
file.write(chunk) #7
#1 Tracks download progress with tqdm
#2 The remote location of the dataset
#3 Downloads the file in chunks to update the progress bar
#6 Instantiates a progress bar object
#7 Writes (part of) the file as the loop progresses
清单 7.11 的代码会从指定 URL 下载数据集,显示进度条用于可视化下载过程,并把下载的文件按指定目录与文件名保存下来。对于大文件下载,这能显著提升体验,并便于观察下载进度。
第 2 步:解压 tar 文件
数据集被打包成一个文件,而不是成千上万个小文件,以节省空间并提升传输效率。在这里,数据以 tar 文件形式到达。要访问并使用这些数据,你需要解压 tar 文件。Python 实现如下。
清单 7.12 解压下载的数据集
import tarfile
output_dir = "DATASET"
with tarfile.open(downloaded_file, 'r:gz') as tar: #1
tar.extractall(output_dir) #2
#1 Opens the downloaded tar file
#2 Extracts the tar to the output directory
这里使用 tarfile 模块以 r:gz 模式打开一个 gzip 压缩的 tar 包(downloaded_file)。在 with 块中,调用 tar.extractall(output_dir) 把所有文件解压到 output_dir 目录。该代码假设 output_dir 保存了解压目录的路径。
第 3 步:创建 MinIO bucket 并复制数据
现在数据已经准备好可以使用了,是时候创建一个 MinIO bucket(见清单 7.13)。MinIO 是一个 S3 兼容的对象存储,并与 Kubeflow 无缝集成。这个 bucket 将作为流水线全过程的数据与模型存储空间。
清单 7.13 初始化 MinIO 客户端并创建 bucket
import boto3
minio_client = boto3.client( #1
's3', #1
endpoint_url='http://minio-service.kubeflow:9000', #1
aws_access_key_id='minio', #1
aws_secret_access_key='minio123') #1
try: #1
minio_client.create_bucket(Bucket=bucket_name) #2
except Exception as e: pass
#1 Initializes a Boto3 client pointing to a MinIO instance
#2 Creates a MinIO bucket
我们使用常见的 boto3 库创建一个指向 S3 兼容对象存储的客户端连接,这里是 MinIO。boto3.client() 用于创建客户端;endpoint_url 指向 MinIO 服务地址。
这里提供的 URL 表示 MinIO 可通过 http://minio-service.kubeflow:9000 访问,这是大多数 Kubeflow 安装的默认设置。aws_access_key_id 与 aws_secret_access_key 用于身份认证;在本例中分别设为 'minio' 和 'minio123',也是 Kubeflow 默认值。
WARNING 生产环境请修改密码!不言自明,使用默认密码且明文写入是糟糕实践。生产中应使用类似 Vault 的方案,通过 K8s sidecar 将密码注入到运行中的 pod。
客户端初始化成功后,我们创建一个 bucket。该方法在 bucket 已存在时会抛异常,因此我们用 try/catch 包裹来处理。
创建好 bucket 后,下一步是把解压后的文件内容上传到 bucket 中。我们继续使用 MinIO Python API 来完成上传(见清单 7.14)。把数据存到 MinIO bucket 后,KFP 的不同组件就都能方便访问这些数据。
清单 7.14 上传图片与标签到 MinIO
import os
for f in ["images", "labels"]:
local_dir_path = os.path.join(output_dir, "DATA", f)
files = os.listdir(local_dir_path)
for file in files:
local_path = os.path.join(local_dir_path, file) #1
s3_path = os.path.join(bucket_name, f, file) #2
minio_client.upload_file(
local_path, bucket_name, s3_path) #3
#1 Constructs the source path
#2 Constructs the destination path
#3 Uploads the file (in the source path) to the destination
这段代码遍历 images 与 labels 两个目录。对目录中的每个文件,构造源路径 local_path 与目标路径 s3_path,然后用 upload_file 方法上传。
第 4 步:把这些步骤封装成 Kubeflow 组件
到了这里,你可能会问:前面这些步骤要怎么变成一个 Kubeflow 组件?好问题,敏锐的读者!首先,我们会把前面看到的逻辑封装成一个函数。
清单 7.15 import 语句应放在函数定义内
def download_dataset(bucket_name: str):
import boto3 #1
import os #1
import requests #1
import tarfile #1
from tqdm import tqdm #1
#1 Import statements go inside the function definition.
如前所述,定义 Kubeflow 组件最常见的一种方式就是使用 Python 函数,比如 download_dataset。注意这里再次强调:所有 import 语句都放在函数定义内部,这样依赖就被封装起来,便于打包进 Docker 容器。下面清单展示了如何创建 Kubeflow 组件。
清单 7.16 Kubeflow 组件中的外部 Python 库
from kfp import dsl
from kfp.dsl import Input, Output, Dataset
@dsl.component(
packages_to_install=["requests", "boto3", "tqdm"], #1
base_image="python:3.11"
)
def download_dataset(
output_dataset: Output[Dataset],
output_dir: str = "DATASET"): #2
import os
import requests
import tarfile
from tqdm import tqdm
base_url = "https://manning.box.com/shared/static"
url = f"{base_url}/34dbdkmhahuafcxh0yhiqaf05rqnzjq9.gz"
downloaded_file = "DATASET.gz"
response = requests.get(url, stream=True)
file_size = int(response.headers.get("Content-Length", 0))
progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)
with open(downloaded_file, 'wb') as file:
for chunk in response.iter_content(chunk_size=1024):
progress_bar.update(len(chunk))
file.write(chunk)
extraction_path = os.path.join(
output_dataset.path,
output_dir) #3
os.makedirs(extraction_path, exist_ok=True) #4
with tarfile.open(downloaded_file, 'r:gz') as tar:
tar.extractall(extraction_path)
#1 Installs the required Python packages for data downloading
#2 Defines the component with the output dataset parameter and default dir
#3 Creates the path by joining the output dataset path with the output directory
#4 Creates the output directory if it doesn’t exist
你之前已经见过 @dsl.component 装饰器用于把一个 Python 函数转换成可复用组件。这里,函数 download_dataset 被转换为组件。装饰器接受如 packages_to_install(指定组件运行环境需要安装的 Python 包列表)和 base_image(指定组件运行环境使用的基础 Docker 镜像)等参数。
加分步骤:在流水线中使用该组件
我们先稍微跳一下,看看如何在流水线中使用它。这里我们仅构建一个“单组件流水线”(见清单 7.17)。
清单 7.17 构建一个单组件 KFP
from kfp import dsl #1
from kfp.dsl import Input, Output, Dataset #1
@dsl.component(
packages_to_install=["requests", "boto3", "tqdm"],
base_image="python:3.11"
)
def download_dataset(
output_dataset: Output[Dataset],
output_dir: str = "DATASET"):
@dsl.pipeline( #2
name="data_preparation_pipeline",
description="Pipeline for preparing and splitting dataset"
)
def pipeline(random_state: int = 42):
download_op = download_dataset() #3
if __name__ == '__main__':
from kfp import compiler #4
compiler.Compiler().compile( #4
pipeline_func=pipeline, #4
package_path='dataprep_pipeline.yaml' #4
) #4
#1 Imports all the neccessary modules from KFP
#2 The @dsl.pipeline decorator defines a KFP
#3 download_op is created by invoking download_dataset.
#4 Compiles the pipeline and saves it
首先导入需要的 KFP 模块,然后用 @dsl.pipeline 定义 pipeline() 函数。在流水线中,通过调用 download_dataset 组件创建 download_op。
NOTE pipeline.yaml 长什么样?我们建议你打开生成的 pipeline.yaml 看看。你能大致看出各组件如何被定义并拼装起来吗?有没有什么让你意外的地方?你能否隐约看到 Kubeflow 组件“底层是怎么工作的”,尤其是文件创建部分?花点时间读读代码,你会发现它一点也不神秘!
组件之间的数据传递
这里做一个小思考实验。看看目前的流水线定义:
def pipeline():
download_op = download_dataset()
我们还没实现 split_dataset 组件,但假设要把 download_op 下载的数据传给 split_dataset_op,你可能会写成:
def pipeline():
download_op = download_dataset()
split_dataset_op = split_dataset_task(data=download_op.output)
你的第一反应可能是“复制一份并传给下游”。但数据集有 10GB,如果你有 5 个组件,下载 50GB 同样的数据显然不聪明。
替代方案是:传递数据的路径——这里指向 MinIO bucket 的路径。为实现这一点,我们需要对 download_dataset 的函数签名与函数实现做两处修改,如下所示。
清单 7.18 写入 OutputPath
from kfp.components import OutputPath
def download_dataset(
output_dataset: Output[Dataset],
output_dir: str = "DATASET"): #1
with open(downloaded_file, 'wb') as file:
file.write(...) #2
extraction_path = os.path.join(
output_dataset.path,
output_dir) #3
os.makedirs(extraction_path, exist_ok=True) #4
with tarfile.open(downloaded_file, 'r:gz') as tar:
tar.extractall(extraction_path) #5
#1 Output[Dataset] provides the directory where data will be stored.
#2 Downloads the compressed dataset to a temporary file
#3 Constructs the path where extracted data will be stored
#4 Creates the directory structure for the extracted dataset
#5 Extracts the dataset into the artifact directory
这种方式使用 KFP v2 的 Output[Dataset] 来管理 dataset artifact,从而实现组件之间更规范的数据处理。它不是直接把内容写到 artifact 目录里,而是先把压缩包下载到一个临时文件中。下载完成后,再把内容解压到 artifact 目录。下游组件随后就能通过 input_dataset.path 来访问这份解压后的数据,从而以干净、高效的方式在流水线中传递大数据集。
在背后,SDK 会处理输出值对应的文件创建与存储。作为最佳实践,对于大文件来说,最好让它指向远程位置,而不是直接把文件内容当作值传递。这是为了避免把文件内容加载到内存里;一旦文件过大,就可能超过 pod 分配的内存上限,导致(令人闻风丧胆的)OOMKilled。在这个例子中,我们是把 bucket 名写到文件里,从而传递给下一个组件。
注意:虽然现在多了一个参数(output_dataset: Output[Dataset]),但这并不会改变你调用组件的方式。换句话说,你仍然只需要按原样调用它,因为 SDK 会在幕后创建对应的输出文件。我们接下来会讲数据集切分组件,然后你会看到该组件如何接收输入。
数据集切分组件
数据切分指将数据集划分成不同部分——最常见的是训练集、验证集和测试集。我们将讲解数据切分,并演示如何为 Kubeflow 组件安装可能需要的自定义 Python 包。你也会更多练习如何用 OutputPath 在组件之间传递数据。之后,当切分组件完成,我们会把它们组合成一个流水线。我们要实现的目标如图 7.8 所示。
图 7.8 数据准备流水线:先从远程下载数据,再进行切分,并选择两个文件做一次 sanity check。
第一个组件把数据从远程位置下载到 MinIO bucket。它的输出是“下载文件列表”。这个列表的路径会传给第二个组件。第二个组件读取该文件,并把 labels 与 images 的文件名拆分成 train、test、val,因此总共有 6 个文件。最后,我们从 6 个文件中选两个并输出其内容用于 sanity check。
第 1 步:按 YOLO 需求进行数据集切分,并安装自定义包
当数据集安全存储在 MinIO bucket 后,下一步就是将其分为训练、测试、验证集。有时你需要一些 Kubeflow 组件默认没有的工具或库。本节会演示如何把自定义 Python 包安装进组件中,从而能够使用项目所需的全套资源。这种灵活性让你可以按 ML 项目的独特需求定制流水线。
这里我们会用 scikit-learn 中很方便的 train_test_split 来切分数据,它几乎一行就能完成“二分”。但我们要切成三份,所以需要一点点“创意和数学”。首先设定函数签名与 imports,如下所示。
清单 7.19 使用多个 Output[Dataset] 与内部 imports
@dsl.component(
packages_to_install=["scikit-learn"],
base_image="python:3.11"
)
def split_dataset(
random_state: int,
input_dataset: Input[Dataset], #1
train_dataset: Output[Dataset], #2
validation_dataset: Output[Dataset], #2
test_dataset: Output[Dataset] #2
):
import os
import glob
import shutil
from sklearn.model_selection import train_test_split
#1 Input[Dataset] specifies where to read the input dataset from.
#2 Output[Dataset] parameters define where to write split datasets.
组件装饰器负责安装依赖包并指定基础镜像。它使用 Dataset artifacts 来管理数据位置。scikit-learn 通过 packages_to_install 安装,使组件更加自包含。
接下来是真正切分的逻辑。我们有 images 与 labels 的文件列表后,可以用 scikit-learn 的 train_test_split 把它们切成 train/test/val。由于该函数默认只切两份,所以我们需要分两次切才能得到三份。
清单 7.20 按 75/15/10% 切分 train/val/test
@dsl.component(
packages_to_install=["scikit-learn"],
base_image="python:3.11"
)
def split_dataset(
random_state: int,
input_dataset: Input[Dataset],
train_dataset: Output[Dataset], #1
validation_dataset: Output[Dataset],
test_dataset: Output[Dataset]
):
import os
import glob
import shutil
from sklearn.model_selection import train_test_split
BASE_PATH = "MINIDATA" #2
images = list(
glob.glob(
os.path.join(
input_dataset.path,
"DATASET",
BASE_PATH,
"images", "**")
)
) #3
labels = list(
glob.glob(
os.path.join(
input_dataset.path,
"DATASET",
BASE_PATH,
"labels", "**")
)
)
train_ratio = 0.75 #4
validation_ratio = 0.15
test_ratio = 0.10
x_train, x_test, y_train, y_test = train_test_split( #5
images,
labels,
test_size=1 - train_ratio,
random_state=random_state
)
x_val, x_test, y_val, y_test = train_test_split( #6
x_test,
y_test,
test_size=test_ratio / (test_ratio + validation_ratio),
random_state=random_state
)
#1 Output[Dataset] artifacts for each data split (train/val/test)
#2 Toggles between mini dataset for testing and full dataset
#3 Locates all image and label files in input dataset
#4 Defines split ratios: 75% train, 15% val, 10% test
#5 First split: separates training set (75%) from rest (25%)
#6 Second split: divides remaining data into val (15%) and test (10%)
该组件会把数据集切分成训练、验证、测试集。它先在输入数据目录中找到所有图片和对应标签,然后用两阶段的 train_test_split 完成三分:先拿出训练集,再把剩余部分按设定比例拆成验证与测试集。random_state 用于保证切分可复现。接下来我们引入一个辅助函数,把刚刚切分出来的数据整理成我们想要的目录结构,如下所示。
清单 7.21 用辅助函数整理切分结果
def split_dataset(...):
for dataset_output, x_files, y_files in [ #1
(train_dataset, x_train, y_train),
(validation_dataset, x_val, y_val),
(test_dataset, x_test, y_test)
]:
os.makedirs(
os.path.join(
dataset_output.path,
"images"
),
exist_ok=True
) #2
os.makedirs(
os.path.join(dataset_output.path, "labels"),
exist_ok=True
)
for src in x_files: #3
dest = os.path.join(
dataset_output.path,
"images",
os.path.basename(src)
)
shutil.copy2(src, dest)
for src in y_files: #4
dest = os.path.join(
dataset_output.path,
"labels",
os.path.basename(src)
)
shutil.copy2(src, dest)
#1 Iterates through each dataset split (train/val/test) with its files
#2 Creates images and labels directories in each output artifact path
#3 Copies image files to their respective split directories
#4 Copies label files to their respective split directories
这段代码会把切分出来的数据按目录结构整理好。对每一个切分(训练、验证、测试),它会在对应的 Output[Dataset] 路径下创建 images 与 labels 子目录,然后把源文件复制到相应目录中,并保留原始文件名。这样三份数据都具有一致的目录结构(images / labels),保证数据组织的统一性。
第 2 步:从输入文件输出文件内容
让我们构建一个“一次性”的组件,它非常有用,可以用来确保一切都正常工作。这个组件会接收数据集切分组件输出的路径,并输出文件内容。
清单 7.22 给定文件路径后打印其内容的组件
@dsl.component
def output_file_contents(dataset: Input[Dataset]): #1
import os
def list_files(start_path): #2
for root, dirs, files in os.walk(start_path):
level = root.replace(
start_path, ''
).count(os.sep) #3
indent = ' ' * 4 * (level)
print(f'{indent}{os.path.basename(root)}/')
sub_indent = ' ' * 4 * (level + 1)
for f in files:
print(f'{sub_indent}{f}') #4
print(f"Contents of {dataset.path}:")
list_files(dataset.path)
#1 Takes the Dataset artifact as input to inspect its contents
#2 Helper function to recursively list files in the directory tree
#3 Calculates the indentation level based on the directory depth
#4 Prints each file with proper indentation for visual hierarchy
我们还需要创建一个工具组件来验证数据处理流水线。这个组件以一个 Dataset artifact 作为输入,并以类似树形结构的方式打印其目录结构,从而能很容易地检查切分后数据集文件的组织方式与是否存在。它虽然简单,但对调试、以及确保文件被正确放入各自目录非常宝贵。
该组件会递归遍历数据集目录,并对更深层的子目录与文件进行缩进,从而展示层级结构。输出结果能清晰地可视化上游切分组件如何组织数据集。
由组件组装成完整流水线
现在我们已经有了下载数据集、切分数据集、以及验证输出的组件,让我们把它们组装成一个完整的流水线。下面清单展示了如何用 KFP v2 定义数据准备流水线。
清单 7.23 创建完整的数据准备流水线
from kfp import dsl
from kfp.dsl
import Input, Output, Dataset
@dsl.pipeline(
name="data_preparation_pipeline",
description="Pipeline for preparing and splitting dataset"
)
def pipeline(random_state: int = 42):
download_op = download_dataset()
split_op = split_dataset(
random_state=random_state,
input_dataset=download_op.outputs["output_dataset"]
)
output_file_contents(dataset=split_op.outputs["x_val_output"])
output_file_contents(dataset=split_op.outputs["y_val_output"])
流水线装饰器(@dsl.pipeline)将该函数标记为一个 KFP 流水线,并提供名称与描述。该流水线接收一个 random_state 参数,默认值为 42,以保证可复现性。在流水线内部,我们通过连接组件来创建工作流:
download_dataset创建第一个操作。split_dataset的input_dataset来自download_dataset的输出。output_file_contents用来验证验证集切分的两个输出。
注意 KFP v2 如何通过 outputs 与 inputs 来管理依赖:当 split_op 使用 download_op 的输出时,KFP 会自动确保 download_op 先运行。Dataset artifacts 负责在组件之间进行底层的数据传递。我们不再需要显式管理执行顺序,也不用担心组件会同时执行导致数据竞争。
每个组件所需的依赖包都在各自的装饰器中声明(例如 packages_to_install=["scikit-learn"]),因此依赖关系清晰且组件自包含,而不是集中在流水线层面。
通过运行整个脚本来编译流水线:
% python data_prep_pipeline.py
如果没有错误,就会生成一个 dataprep_pipeline.yaml 文件。
上传并运行流水线
在 Kubeflow UI 中,点击图 7.9 所示页面右上角的 Upload pipeline 按钮。你会在这里看到所有已上传的流水线。
图 7.9 Pipelines 页面列出了所有已上传的流水线,并允许你上传新的流水线。
如果你已经上传过其他流水线,你也会在这里看到它们。然后导航到第 3 步编译出来的数据准备流水线文件所在目录(dataprep_pipeline.yaml),如图 7.10 所示。
图 7.10 流水线可以是 zip 文件或 YAML;这里我们选择 YAML 文件。
点击 Upload 后,你会看到流水线的图形化表示(图 7.11)。这也有助于检查所有组件是否按预期连接起来。
图 7.11 成功上传后,你可以看到流水线。
下一步是执行流水线。在 Kubeflow 的术语中,这叫创建一次 run。点击 Create Run。大部分字段会自动填好,除了 Experiment。如果你还没创建 experiment,现在正好创建一个。
Kubeflow 的 experiments 用于把多次流水线 run 组织成一个组,以便跟踪与管理。注意 experiments 也可以包含不同类型流水线的 runs(图 7.12)。
图 7.12 Run 详情页大部分信息会自动填充,你需要选择 experiment。
在 Run Details 页面还可以选择其他选项,例如创建一个周期性 run(而不是默认的一次性 run)。这对需要重复运行且负载可预测的流水线很有用。
如果你回头看流水线函数签名:
@dsl.pipeline(name="Simple pipeline")
def pipeline(random_state=42):
...
你会发现这些字段在 UI 中会自动创建并填充默认值。
点击 Start。图中的每个节点会随着组件初始化和执行逐步被填充。在我的机器上,整个流程大约需要 30 分钟完成。如果一切顺利,你会看到图 7.13 所示的页面。
图 7.13 一个完全执行完成的 KFP。每次看到它我们都会感到温暖又安心(同时长舒一口气)。
在组件执行期间查看日志
为组件添加日志信息非常有用,尤其是那些执行时间较长的组件。可视化反馈与进度信息能让你更早发现问题。点击每个“已执行”(蓝色)或“已完成”(绿色)的组件,你都能看到它输出的日志。例如点击 Output file contents 组件,你应该能看到文件目录被列出来,类似前文展示的效果。
点击某个组件会展开侧边菜单,再点击 Logs 标签即可查看输出;如果组件仍在运行,输出会实时刷新。
生产环境中的日志管理
虽然详细日志与进度条对调试很有帮助,但它们可能生成大量日志,尤其是在周期性 runs 中,会非常快地消耗磁盘空间。如果你的 MinIO 数据存储部署在某个 K8s 节点上,这可能导致该节点出现
diskPressure事件,进而触发 K8s 驱逐该节点上的 pods,并停止在该节点上调度新的 pods。务必检查 run 日志存储所在的 MinIO buckets,并按需清理。对于生产环境,强烈建议建立自动化日志轮转与归档策略来缓解这一问题。
7.3.2 数据准备:电影推荐器
对于电影推荐应用,要遵循的步骤大体相同。不过,我们会在这里尝试一些新东西,包括:使用 Parquet 文件来存储表格数据,以优化对更大数据集的处理(为什么要这么做,会在本节稍后的“数据质量评估”小节中展开);从 Kubeflow notebooks 运行流水线;以及加入一个简单的 QA(质量保证) 区块,确保流水线运行产出了所需的数据。
注意 你可以在 GitHub 上跟着项目代码一起做。本节使用的项目完整源码在 mng.bz/a98J。
MOVIELENS 25M:推荐示例使用的数据集
对于电影推荐项目,我们会使用广受欢迎的 MovieLens 25M 数据集。该数据集来自 GroupLens——明尼苏达大学双城校区内部的一个研究实验室,专注于推荐系统与在线交互研究。该数据集包含 2500 万条评分,以及 100 万次标签应用,由 162,000 名用户对 62,000 部电影产生。使用该数据集前,请务必阅读并遵守 MovieLens 网站上的许可条款(grouplens.org/datasets/mo…)。
计划
在提供的示例代码里,我们会使用 Kubeflow notebooks 来构建流水线。这样做的目的是向你展示:在交互式环境中进行这种迭代过程往往更简单,也更便于调试、搭建,甚至把流水线推送到集群上。我们会构建 六个组件,然后把它们组装成一个完整流水线。在真实部署中,很多组件会合并,以减少数据来回搬运的开销;但这里我们主要用它们来演示 Kubeflow 如何处理数据搬运,以及它提供了哪些“提升体验”的便利能力。我们将构建以下组件:
- 数据集下载组件
- 数据解压组件
- CSV 转 Parquet 转换组件
- 数据集切分组件
- MinIO 上传组件
- 质量保证(QA)组件
先给你一个“剧透”:最终完成的流水线会如图 7.14 所示。
图 7.14 本节将构建的流水线
数据集下载
与前面的目标检测示例一样,第一步是从 MovieLens 网站下载数据。该组件与最初的数据集下载组件完全相同。
清单 7.24 只有单个输出的数据集下载函数
from kfp.dsl import Output
@dsl.component(
base_image="python:3.11",
target_image="mlsysfromscratch/data_preprocessor:1.0.0",
packages_to_install=["requests"])
def download_ml25m_data(output_path_one: Output[Artifact]):
import requests
url = 'https://files.grouplens.org/datasets/movielens/ml-25m.zip'
response = requests.get(url, stream=True, verify=False)
with open(output_path_one.path, 'wb') as file:
for chunk in response.iter_content(chunk_size=1024*1024):
if chunk:
file.write(chunk)
这里,数据从 GroupLens 网页下载下来,并直接写入 output_path。在这种情况下,pipeline op 的 output 属性会直接指向 zip 文件。
在这个函数里,有一个小改动却会对组件运行时行为产生很大影响:我们指定了一个 target_image 参数。设置该参数后,Kubeflow 会构建一个镜像并推送到我们指定的仓库。随后运行组件时会直接使用该镜像,而无需像前面示例那样在运行时先安装依赖。这在处理复杂依赖(运行时未必总能用 pip install 解决)时非常有用。同时也意味着组件启动会比普通组件快得多。Kubeflow 把这类组件称为 containerized Python components(容器化 Python 组件) 。它们位于两者之间:一端是轻量级 Python 组件(依赖在运行时用 pip 安装),另一端是完整的容器组件。
容器化 Python 组件也放宽了“必须把所有代码都写在组件函数内部”的限制。这意味着我们可以在组件内部从其他模块导入方法、符号和代码。
Kubeflow 建议在指定了 packages_to_install 时使用容器化 Python 组件,因为依赖是在 构建时 安装,而不是在 运行时 安装。
注意 容器化 Python 组件需要 Docker daemon、一个可用的 Docker API socket,以及 UNIX 操作系统才能正常工作。由于这一过程涉及 Kubeflow 连接 Docker 运行时、基于给定依赖与基础镜像构建并推送镜像,因此 Docker daemon 也必须能够向指定仓库 push/pull 镜像。
数据集解压
对于解压,我们(目前)只会使用数据集里的两个文件,因此我们会写一个组件,只解压这两个文件。
清单 7.25 带多个输出的解压函数
@dsl.component(base_image="python:3.11",
target_image="mlsysfromscratch/data_preprocessor:1.0.0")
def unzip_data(input_path: Input[Artifact],
ratings_output_path: Output[Artifact],
movies_output_path: Output[Artifact]):
with zipfile.ZipFile(input_path.path, 'r') as z:
with open(ratings_output_path.path, 'wb') as f:
f.write(z.read('ml-25m/ratings.csv'))
with open(movies_output_path.path, 'wb') as f:
f.write(z.read('ml-25m/movies.csv'))
这里有一个输入文件(下载的 zip 文件路径)以及两个输出文件。同样,我们直接写入输出的 Dataset artifacts,这意味着输出会直接指向被解压出来的 csv 文件路径。后续组件可以通过 path 属性引用这些文件:
ratings_output_path.path
Parquet 转换
这是第一个“电影推荐器独有”的组件。因为我们处理的是表格数据,使用为列式数据优化过的数据格式更合理。Parquet 是一个不错的选择,相比简单的 CSV,它还有一些优势:比如可以对文件进行分片以提升查询;体积更小;以及由于列式存储格式,在某些场景下能带来更高性能。
在这个组件里,我们会用 pandas 和一个叫 fastparquet 的库,把 CSV 文件转换成 Parquet 格式。这里不会做分片或进一步优化,目的只是演示这种转换。
清单 7.26 单输入单输出的 Parquet 转换
@dsl.component(
base_image="python:3.11",
target_image="mlsysfromscratch/data_preprocessor:1.0.0"
)
def unzip_data(
input_path: Input[Artifact],
ratings_output_path: Output[Artifact],
movies_output_path: Output[Artifact]):
import pandas as pd
df = pd.read_csv(inputFile.path, index_col=False)
df.to_parquet(output_path.path, compression='gzip')
和前面的组件一样,这个组件直接读取 inputFile.path 指向的文件,并写到 output_path.path 指向的位置。
使用 InputPath 或 OutputPath 时的文件名
output_path.path 的值会被自动设置为指向 MinIO 中的某个 key。凡是涉及文件名的操作都要记住这一点。如果你必须使用显式路径,请使用 dsl.OutputPath,它会在运行时提供一个系统生成的路径。
如果你在流水线代码里打印 InputPath 或 OutputPath,你会发现它指向 /tmp/<file_or_folder_name>。在底层,Argo 会把这个路径打包压缩,并上传到 MinIO bucket,从而在组件之间提供持久化的 artifacts。下一组件使用这些数据时,文件/目录会被下载到 /tmp 并解压后供使用。
这里我们也做了复用:用同一个组件并行地把 movies 和 ratings 两个 CSV 都转换成 Parquet。
数据切分
数据集切分与目标检测示例的切分类似。唯一主要差别是:这里输入是 Parquet 文件,而输出路径是一个文件夹;这个文件夹里又包含三个 Parquet 文件。这是另一种使用 OutputPath 的方式,也体现了 KFP SDK 的灵活性。
清单 7.27 单输入/单输出文件夹的数据切分组件
@dsl.component(
base_image="python:3.11",
target_image="mlsysfromscratch/data_preprocessor:1.0.0",
packages_to_install=[
"scikit-learn",
"pandas",
"fastparquet"
]
)
def split_dataset(
input_parquet: Input[Artifact],
dataset_path: Output[Artifact],
random_state: int = 42
):
train_ratio = 0.75
validation_ratio = 0.15
test_ratio = 0.10
ratings_df = pd.read_parquet(input_parquet.path)
train, test = train_test_split(
ratings_df,
test_size=1 - train_ratio,
random_state=random_state)
n_users = ratings_df.user_id.max()
n_items = ratings_df.item_id.max()
val, test = train_test_split(
test,
test_size=test_ratio / (test_ratio + validation_ratio),
random_state=random_state)
os.mkdir(dataset_path.path)
train.to_parquet(
os.path.join(dataset_path.path, "train.parquet.gzip"),
compression="gzip"
)
test.to_parquet(
os.path.join(dataset_path.path, "test.parquet.gzip"),
compression="gzip"
)
val.to_parquet(
os.path.join(dataset_path.path, "val.parquet.gzip"),
compression="gzip"
)
数据上传
这一步与目标检测示例相同。不过我们稍微调整了上传代码,使组件能够同时上传文件和文件夹。
清单 7.28 支持输入路径为文件或文件夹的数据上传函数
@dsl.component(
base_image="python:3.11",
target_image="mlsysfromscratch/data_preprocessor:1.0.0",
packages_to_install=["boto3"]
)
def put_to_minio(
inputFile: Input[Artifact],
upload_file_name: str = '',
bucket: str = 'datasets'
):
import boto3
minio_client = boto3.client(
's3',
endpoint_url='http://minio-service.kubeflow:9000',
aws_access_key_id='minio',
aws_secret_access_key='minio123')
try:
minio_client.create_bucket(Bucket=bucket)
except Exception as e:
pass
if os.path.isdir(inputFile.path): #1
for file in os.listdir(inputFile.path):
s3_path = os.path.join('ml-25m', file)
minio_client.upload_file(
os.path.join(inputFile.path, file), bucket, s3_path)
else: #2
if upload_file_name == '':
_, file = os.path.split(inputFile.path)
else:
file = upload_file_name
s3_path = os.path.join('ml-25m', file)
minio_client.upload_file(inputFile.path, bucket, s3_path)
#1 Shows uploading a folder
#2 Shows uploading a single file
上面的代码突出了我们在 Parquet 转换步骤里提到的细节:OutputPath 的名字不是我们自己命名的,而是 Kubeflow 命名的。这也是为什么我们需要额外输入参数 upload_file_name——把 /tmp/data 这样的文件名重命名为我们希望的文件名。这个组件会被复用:既用于上传 train/test/val 切分文件夹,也用于上传 movies Parquet 文件。
数据质量评估
最后一个组件对输出做一些基础的 QA。它会验证我们期望的四个文件是否存在,并检查训练集长度是否大约是完整数据集的 75%(符合预期)。你可以、也应该在生产环境的数据准备流水线中增加更多校验。就像前面章节提到的,尽早验证所有假设非常重要。
这个 QA 组件还有另一个有趣之处:这里的数据访问通过 PyArrow 完成,它是一个替代性的高性能数据处理框架。我们会在后续章节中使用 PyArrow 直接流式读取数据、而无需下载到训练进程中。这里我们用 PyArrow 打开 Parquet 文件并转换成 pandas DataFrame 再做校验,这也只是为了演示。在生产环境中,你可能会坚持使用单一框架来读取与处理 Parquet 文件。即使下游组件也许能捕获这些问题,通常更好的做法是在生成阶段尽早发现问题,而不是事后回溯式调试。
清单 7.29 PyArrow 直接从 MinIO 读取文件进行数据 QA
@dsl.component(
base_image="python:3.11",
target_image="mlsysfromscratch/data_preprocessor:1.0.0",
packages_to_install=["pyarrow"]
)
def qa_data(bucket:str = 'datasets', dataset:str = 'ml-25m'):
from pyarrow import fs, parquet
print("Running QA")
minio = fs.S3FileSystem(
endpoint_override='http://minio-service.kubeflow:9000',
access_key='minio',
secret_key='minio123',
scheme='http')
train_parquet = minio.open_input_file(
f'{bucket}/{dataset}/train.parquet.gzip')
df = parquet.read_table(train_parquet).to_pandas()
assert df.shape[1] == 4
assert df.shape[0] >= 0.75 * 25 * 1e6
print('QA passed!')
警告 虽然这里的示例比较简单,但它能防住两类最常见的数据错误:规模不匹配与文件缺失。务必验证你对数据的所有假设,包括规模、schema 与内容,尤其是在生成类流水线中。
由组件组装成完整流水线
首先,我们像之前一样把各个组件组合并编译。下面清单展示了如何做。
清单 7.30 将各个组件组合成一个流水线
import kfp
import kfp.dsl as dsl
from data_components import (
download_ml25m_data,
unzip_data,
csv_to_parquet,
split_dataset,
put_to_minio,
qa_data
)
@dsl.pipeline(
name='Data prep pipeline',
description=(
"A pipeline that retrieves data from movielens and ingests it into "
"parquet files on minio"
)
def dataprep_pipeline(minio_bucket:str='datasets', random_init:int=42):
download_dataset = download_ml25m_data()
unzip_folder = unzip_data(
input_path=download_dataset.outputs['output_path_one']
)
ratings_parquet_op = csv_to_parquet(
inputFile=unzip_folder.outputs['ratings_output_path']
)
movies_parquet_op = csv_to_parquet(
inputFile=unzip_folder.outputs['movies_output_path']
)
split_op = split_dataset(
input_parquet=ratings_parquet_op.output,
random_state=random_init
)
u1 = put_to_minio(
inputFile=movies_parquet_op.output,
upload_file_name='movies.parquet.gzip',
bucket=minio_bucket
) u2 = put_to_minio(inputFile=split_op.output, bucket=minio_bucket)
qa_op = qa_data(
bucket=minio_bucket).after(u1).after(u2) #1
download_dataset.set_caching_options(False)
unzip_folder.set_caching_options(False)
ratings_parquet_op.set_caching_options(False)
movies_parquet_op.set_caching_options(False)
split_op.set_caching_options(False)
u1.set_caching_options(False)
u2.set_caching_options(False)
qa_op.set_caching_options(False)
if __name__ == "__main__":
kfp.compiler.Compiler().compile(
pipeline_func=dataprep_pipeline,
package_path='compiled_pipelines/dataPrep_pipeline.yaml')
#1 Shows manually enforced ordering of components after two other components
目标检测示例是通过 Web UI 上传并运行流水线的。这里我们换一种方式,使用 Kubeflow SDK 来上传并运行流水线。上传流水线时,可以使用 kfp 的 client 对象:
client = kfp.Client()
这里很妙的一点是:因为我们是在 Kubeflow 集群里的 notebook 中运行整个过程,所以无需设置任何凭证,也无需在 Client 构造器里指定 endpoint。所有这些都通过 notebook 启动时注入的环境变量被无缝处理了。如果你在集群外部运行,记得先用 Kubeflow 实例的 API endpoint 配好 client。
上传流水线可以这样做:
pipeline = client.pipeline_uploads.upload_pipeline(
'dataPrep_pipeline.yaml',
name='ml-25m-processing'
)
注意 Kubeflow 上的所有流水线都由一个唯一的 UUID 标识,在通过 SDK 调用或删除流水线时需要用到。要运行流水线,你还需要一个 job name(主要用于帮助用户识别一次 run 的唯一名称)以及一个 experiment ID。Experiments 是流水线 runs 的逻辑分组,可以通过 Web UI 或 SDK 创建。
现在你已经上传了流水线,打开 Web UI,你应该能看到我们闪亮的新流水线了!
运行流水线
要启动流水线 run,你可以使用 Kubeflow SDK。不过,如果你是多用户(multiuser)部署,你需要添加 service account 凭证以满足 RBAC(基于角色的访问控制)。关于这一点,你可以参考 Kubeflow 官网(mng.bz/gmdR)。现在我们先从熟悉的 UI 发起一次 run,看看最终流水线(图 7.15)。
图 7.15 成功运行的流水线
本章展示了如何用 Kubeflow 为真实的 ML 项目构建健壮的数据准备流水线。通过两个实践示例——身份证件检测器与电影推荐系统——我们探索了如何用 Kubeflow notebooks 搭建开发环境、创建可复用的流水线组件,并在规模化场景下高效处理数据。这些基础技能对任何需要构建生产级 ML 系统的 ML 工程师都至关重要。我们覆盖的概念与模式将成为后续章节中模型训练与部署工作流的基础。
下一章将继续创建训练目标检测模型与电影推荐模型所需的组件与流水线。我们会复用本章数据准备流水线产出的数据,并在数据准备组件中加入一些特性,反映我们从训练实验中学到的经验。
Summary(总结)
- ML 工作流既要求尽可能贴近数据集以便处理,同时又需要能按需(ad hoc)访问诸如 GPU 之类的专用资源。
- Kubeflow notebooks 在与流水线相同的集群内提供类似 Jupyter 的界面,从而保证运行环境与部署设置保持一致。
- 这些 notebooks 通过共享基础镜像并集成到 CI 流水线中,简化了管理、强化了控制,并让版本管理与 CI/CD 实践更顺畅。
- Kubeflow 组件可以使用
kfp.create_component_from_func来创建。 - 理解 Kubeflow 组件中的数据传递规则,以及组件之间不同的数据传递场景(包括流水线参数输入输出的结果、以及诸如
training_path、_file等命名约定)对于构建 Kubeflow 组件至关重要。 - 当用函数构建组件时,
import语句应当写在函数定义内部。第三方依赖可以通过在kfp.create_component_from_func中指定packages_to_install参数来安装。 - Kubeflow 组件可以组合成一个 KFP,并使用
@dsl.pipeline装饰器来定义。 - 当流水线被编译后,会生成一个 YAML 文件。该 YAML 文件包含样板代码:不仅包含各个函数定义,还包含处理参数解析以及输入/输出参数序列化的代码。
- 该 YAML 文件可以通过 Kubeflow UI 上传并执行,也可以通过 SDK 执行。你还可以在 UI 中查看实时生成的日志,从而跟踪执行进度。
- 借助 Kubeflow 组件,你可以组装一条目标检测流水线,涵盖从数据集下载到将数据切分为 train/test/validation 的多个步骤。
- 组装电影推荐系统流水线同样包含多种步骤——从数据集下载到 QA 检查。
- Parquet 文件存储与转换技术可用于高效数据处理,包括 CSV 转 Parquet,以及用于模型训练的数据集切分。