论如何在KubeFlow上跑通第一个Pipeline(四):Katib 自动调参初体验

1,188 阅读7分钟

Katib

Katib 是一个用于自动化机器学习 (AutoML) 的 K8s 原生项目。Katib 支持超参数调整、提前停止和神经架构搜索 (NAS)。

Katib 是一个与机器学习 (ML) 框架无关的项目。它可以调整以用户选择的任何语言编写的应用程序的超参数,并原生支持许多 ML 框架,例如 TensorFlow、MXNet、PyTorch、XGBoost 等。

Katib 支持多种 AutoML 算法,例如 贝叶斯优化、 Parzen 估计器树、 随机搜索、 协方差矩阵自适应进化策略、 Hyperband、 高效神经架构搜索、 可微分架构搜索 等等。其他算法支持即将推出。

下面开始介绍我体验Katib的全过程。

Katib 原生方法

u1s1,Katib对新人上手并不友好:简略的文档,稀少的网络资料,以及难上手的使用方式:直接部署YAML文件。

简而言之,要使用Katib,我们首先必须得写个直接运行起来就可以训练模型的脚本;

构建训练脚本

# train.py

from __future__ import print_function

import argparse
import logging
import os

from torchvision import datasets, transforms
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

class Net(nn.Module):
    def __init__(self):
        ...

    def forward(self, x):
        ...

def train(args, model, device, train_loader, optimizer, epoch):
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        ...


def test(args, model, device, test_loader, epoch, hpt):
    model.eval()
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += F.nll_loss(output, target, reduction="sum").item()  
            pred = output.max(1, keepdim=True)[1]
            correct += pred.eq(target.view_as(pred)).sum().item()

    test_loss /= len(test_loader.dataset)
    test_accuracy = float(correct) / len(test_loader.dataset)
    logging.info("{{metricName: accuracy, metricValue: {:.4f}}};{{metricName: loss, metricValue: {:.4f}}}\n".format(
        test_accuracy, test_loss)) #注意输出log信息

def main():
    # Training settings
    parser = argparse.ArgumentParser(description="PyTorch MNIST Example")
    parser.add_argument("--batch-size", type=int, default=64, help=...)
    parser.add_argument("--test-batch-size", type=int, default=1000, help=...)
    parser.add_argument("--epochs", type=int, default=10, help=...)
    parser.add_argument("--lr", type=float, default=0.01, help=...)
    parser.add_argument("--momentum", type=float, default=0.5, help=...)
    parser.add_argument("--no-cuda", action="store_true", default=False, help=...)
    parser.add_argument("--log-path", type=str, default="", help=...)
    parser.add_argument("--save-model", action="store_true", default=False, help=...)

    args = parser.parse_args()

    if args.log_path == " ":
        logging.basicConfig(
            format="%(asctime)s %(levelname)-8s %(message)s",
            datefmt="%Y-%m-%dT%H:%M:%SZ",
            level=logging.DEBUG)
    else:
        logging.basicConfig(
            format="%(asctime)s %(levelname)-8s %(message)s",
            datefmt="%Y-%m-%dT%H:%M:%SZ",
            level=logging.DEBUG,
            filename=args.log_path) #定义log信息的输出文件路径

    use_cuda = not args.no_cuda and torch.cuda.is_available()
    
    if use_cuda:
        print("Using CUDA")

    device = torch.device("cuda" if use_cuda else "cpu")

    kwargs = {"num_workers": 1, "pin_memory": True} if use_cuda else {}

    train_loader = torch.utils.data.DataLoader(
        datasets.FashionMNIST("./data",
                              train=True,
                              download=True,
                              transform=transforms.Compose([
                                  transforms.ToTensor()
                              ])),
        batch_size=args.batch_size, shuffle=True, **kwargs) #

    test_loader = torch.utils.data.DataLoader(
        datasets.FashionMNIST("./data",
                              train=False,
                              transform=transforms.Compose([
                                  transforms.ToTensor()
                              ])),
        batch_size=args.test_batch_size, shuffle=False, **kwargs)

    model = Net().to(device)

    optimizer = optim.SGD(model.parameters(), lr=args.lr, momentum=args.momentum)

    for epoch in range(1, args.epochs + 1):
        train(args, model, device, train_loader, optimizer, epoch)
        test(args, model, device, test_loader, epoch, hpt)

    if (args.save_model):
        torch.save(model.state_dict(), "mnist_cnn.pt")


if __name__ == "__main__":
    main()

该脚本中,主要有三点值得注意:

  • 我们将下载数据和模型训练放在一脚本中完成;换言之,只要直接运行该脚本,我们就可以得到一个训练好的模型;
  • 我们自定义了一些argument变量,这些将成为Katib自调参时的对象;
  • 在test函数中,我们用字典形式定义了log信息:logging.info("{{metricName: accuracy, metricValue: {:.4f}}};{{metricName: loss, metricValue: {:.4f}}}\n".format( test_accuracy, test_loss)),这些log输出信息将被Katib用于评估调参的结果优劣;

接下来开始第二步,构建TMD的镜像;是的, Katib也希望我们把它做成一个镜像;(毕竟方便直接启动容器运行,可以理解)。

构建镜像

原文件夹下只包含train.py、requirements.txt和DockerFile。

DockerFile

FROM python:3.9

ADD . /opt/pytorch-mnist

WORKDIR /opt/pytorch-mnist

RUN mkdir /katib
RUN pip install --no-cache-dir -r requirements.txt

RUN chgrp -R 0 /opt/pytorch-mnist \
  && chmod -R g+rwX /opt/pytorch-mnist \
  && chgrp -R 0 /katib \
  && chmod -R g+rwX /katib

ENTRYPOINT ["python3", "/opt/pytorch-mnist/mnist.py"]

最难受的部分来了,开始写Yaml文件

编写Yaml文件

1.png

u1s1,Kubeflow提供的这个UI,简直聊胜于无。

反正我只用到了绿色画圈的部分:Edit,以及Create。

不过它上面那个12345的流程,最后倒是可以帮我们捋一捋YAML文件的结构。

先丢一个YAML文件让大家懵一懵:

---
apiVersion: kubeflow.org/v1beta1
kind: Experiment
metadata:
  namespace: kubeflow
  name: file-metrics-collector
spec:
  objective:
    type: maximize
    goal: 0.99
    objectiveMetricName: accuracy
    additionalMetricNames:
      - loss
  metricsCollectorSpec:
    source:
      filter:
        metricsFormat:
          - "{metricName: ([\\w|-]+), metricValue: ((-?\\d+)(\\.\\d+)?)}"
      fileSystemPath:
        path: "/katib/mnist.log"
        kind: File
    collector:
      kind: File
  algorithm:
    algorithmName: random
  parallelTrialCount: 3
  maxTrialCount: 12
  maxFailedTrialCount: 3
  parameters:
    - name: lr
      parameterType: double
      feasibleSpace:
        min: "0.01"
        max: "0.03"
    - name: momentum
      parameterType: double
      feasibleSpace:
        min: "0.3"
        max: "0.7"
  trialTemplate:
    primaryContainerName: training-container
    trialParameters:
      - name: learningRate
        description: Learning rate for the training model
        reference: lr
      - name: momentum
        description: Momentum for the training model
        reference: momentum
    trialSpec:
      apiVersion: batch/v1
      kind: Job
      spec:
        template:
          spec:
            containers:
              - name: training-container
                image: ...
                command:
                  - "python3"
                  - "/opt/pytorch-mnist/train.py"
                  - "--epochs=1"
                  - "--log-path=/katib/mnist.log"
                  - "--lr=${trialParameters.learningRate}"
                  - "--momentum=${trialParameters.momentum}"
            restartPolicy: Never

知道这是在干嘛吗?着实懵逼了很久。

现在,划分一下结构:

2.png

3.png

可以分为5个大部分:

  1. objective: 调参指标。这里的调参指标是:accuracy达到0.99;附加目标loss尽可能低;
  2. metricsCollectorSpec: 调参指标收集器:分别定义了收集路劲及收集格式;结合一下上文脚本log输出信息的格式和存放位置,应该很快就明白了;
  3. algorithm:调参使用算法
  4. parameters: 调参对象
  5. trailTemplate 每次调参实验的模板:确保调参代码以命令行的方式传入参数运行;其中的 trailParameters将在parameters和脚本的argument间建立对应关系;containers则定义了上文我们所构建镜像的具体运行方式;

附: algorithm 可用调参算法:

16.png

此外,还可以手动添加额外配置:

  • parallelTrialCount: 并行实验个数
  • maxTrialCount: 最大实验次数
  • maxFailedTrialCount: 最多失败实验次数
  • metricsCollectorSpec: 回收存储每次迭代实验结果的方式

编写完后,点击提交,就算完成啦。

Katib Python SDK

直接编写YAML文件,是不是也太那啥了? 事实上,不久后,就出现了Katib的Python SDK。

有了上文使用Katib原生方法的经验后,我们也对Katib的YAML文件结构有了一定认识,使用Python SDK我们也得心应手了不少;

首先导入包:

from kubeflow.katib import KatibClient
from kubernetes.client import V1ObjectMeta
from kubeflow.katib import V1beta1Experiment
from kubeflow.katib import V1beta1AlgorithmSpec
from kubeflow.katib import V1beta1ObjectiveSpec
from kubeflow.katib import V1beta1FeasibleSpace
from kubeflow.katib import V1beta1ExperimentSpec
from kubeflow.katib import V1beta1ObjectiveSpec
from kubeflow.katib import V1beta1ParameterSpec
from kubeflow.katib import V1beta1TrialTemplate
from kubeflow.katib import V1beta1TrialParameterSpec

定义各部分:

namespace = "kubeflow-user-example-com"
experiment_name = "cmaes-example"

metadata = V1ObjectMeta(
    name=experiment_name,
    namespace=namespace
)

调参算法:

algorithm_spec=V1beta1AlgorithmSpec(
    algorithm_name="random"
)

调参指标;

objective_spec=V1beta1ObjectiveSpec(
    type="maximize",
    goal= 0.99,
    objective_metric_name="Validation-accuracy",
    additional_metric_names=["Train-accuracy"]
)

调参对象:

parameters=[
    V1beta1ParameterSpec(
        name="lr",
        parameter_type="double",
        feasible_space=V1beta1FeasibleSpace(
            min="0.01",
            max="0.06"
        ),
    ),
    V1beta1ParameterSpec(
        name="num-layers",
        parameter_type="int",
        feasible_space=V1beta1FeasibleSpace(
            min="2",
            max="5"
        ),
    ),
    V1beta1ParameterSpec(
        name="optimizer",
        parameter_type="categorical",
        feasible_space=V1beta1FeasibleSpace(
            list=["sgd", "adam", "ftrl"]
        ),
    ),
]

等等。其他各部分定义可以查函数具体功能介绍:

4.png

5.png

定义完各部分后,我们便可以创建、提交实验:

7.png

8.png

并获取实验相关信息、状态等:

9.png

10.png

11.png

不过可以发现,所谓的Katib Python SDK并未提供更便携的构建方式,只是使YAML文件各部分可拆分,定义并赋值给变量,提高了各部分的复用性罢了。

还好,这个问题最近也开始得到解决:

6.png

社区中有人提出要在Katib中创建Tune API。

这不仅使我们从编写YAML文件中解放了出来,也使我们得以绕过构建镜像的步骤直接训练模型代码,使我们的AutoML低代码可视化开发成为了可能。

Katib SDK Tune API

u1s1,这个包确实来得晚,以至于出现了个很神奇的事,我pip install kubeflow-katib时,一开始甚至没有Tune API,然后我反复pip uninstall和install了很多次,忽然就莫名其妙又有了....

来个简单的例子:

12.png

在该tune API中,name指定了实验名称,objective参数指定了调参指标,parameters指定了调参对象,base_image定义了运行该模型训练函数的基础镜像。

提交该函数后,我们便可以在Katib UI中查看该实验了:

17.png

13.png

14.png

当然,这个Tune API的实现方式也算是蛮简单粗暴的? 看了下提交后的YAML文件:

15.png

16.png

结合Katib 的 Python SDK,我们已经可以观察到Katib的大部分可复用性是很强的,完全可以做成积木+填空;

然后,再观察它的containers部分:

它基于我们提供的镜像环境,执行了一段sh:将我们写的函数放入$program_path/ephemeral_objective.py,并执行该文件。

下周目标

  1. 构建私有Elyra镜像,并成功跑通流程;
  2. 将Katib结合进流程的一部分;并同样基于Elyra可视化编程实现。