KFServing – Explainer를 포함한 InferenceService

Anchors

KFServing 은 Alibi의 Anchors 알고리즘을 이용한 Explainer를 제공하고 있습니다.

앵커 알고리즘은 Ribeiro et al.의 Anchors : High-Precision Model-Agnostic Descriptions 논문을 기반으로 하고 있습니다. 이 알고리즘은 이미지, 텍스트 및 테이블 형식 데이터에 적용되는 분류 모델에 적합한 모델 독립적 (블랙 박스) 인 설명을 제공합니다. 앵커의 기본 개념은 앵커라는 고정밀 규칙을 사용하여 복잡한 모델의 동작을 설명하는 것입니다.

Alibi

Alibi는 학습한 머신 러닝 모델이 왜 그런 결정을 내었는지에 대해 설명하기 위한 오픈 소스 파이썬 라이브러리입니다. 블랙 박스인 머신 러닝 모델을 설명하고 해석할 수 있는 알고리즘을 제공합니다. 그리고 해석 가능한 머신 러닝 메소드에 대한 일관된 API를 지원하기 때문에 사용하기 편합니다. Alibi는 테이블 형식(tabular), 텍스트 그리고 이미지 데이터 분류 등 같은 다양한 사용 사례를 지원합니다.

지원하는 알고리즘

Alibi에서 지원하는 알고리즘들은 머신러닝 모델 예측에 대한 설명을 제공합니다. 모델 예측을 요청하면, 왜 이러한 예측 결과를 도출하게 되었는지에 대해 설명합니다. 다음 알고리즘은 모두 블랙 박스 모델에서 작동하기 때문에, 예측 함수(prediction function)에 접근할 수 있어야합니다.

Model explanations

사용한 Alibi 패키비의 버전은 0.2.2 입니다. KFServing에서 Alibi 서빙을 위해서 사용하는 이미지 버전이 0.2.2 이기 때문입니다. 다른 버전을 사용하고 싶다면, KFServing의 설정을 바꾸면 됩니다. Alibi 0.2.2 버전은 텐서플로우 2 버전을 지원하기 않기 때문에, 테스트를 위해서 텐서플로우 1 버전을 사용하였습니다.


Alibi Image Explainer

Alibi Image Explainer 를 사용하는 InferenceService 를 작성해 보겠습니다. Alibi Image Explainer 는 블랙 박스 설명자 알고리즘으로서, Alibi 오픈 소스 라이브러리의 Anchors 이미지 버전입니다.

Anchors 이미지는 먼저 슈퍼 픽셀로 분할되어 로컬 이미지 구조를 유지하고 있습니다다. 해석 가능한 표현은 앵커에 각 수퍼 픽셀의 존재 또는 부재로 구성됩니다. 해석 가능한 설명에 도달하려면 의미있는 수퍼 픽셀을 생성하는 것이 중요합니다. 이 알고리즘은 여러 표준 이미지 분할 알고리즘 (felzenszwalb, slic 및 quickshift)을 지원하며 사용자가 사용자 정의 분할 기능을 제공 할 수 있습니다.

이 알고리즘에 대한 자세한 내용은 Seldon Alibi 설명서를 참조하십시오.

모델 생성

Alibi Image Explainer 를 사용하기 위해서 Explainer 모델을 저장해야합니다. AnchorImage 인스턴스를 생성한 후, dill을 이용해서 저장하면 됩니다. Alibi Image Explainer 를 사용하는 InferenceService를 생성하기 위해서는 예측 모델과 설명 모델 둘 다 필요하기 때문에 같이 작성하겠습니다.

케라스의 데이터셋 중의 하나인 패션 mnist 데이터를 분류하는 모델을 작성해 보겠습니다.

먼저, 예측 모델을 만들고 훈련하기 위한 텐서플로우의 고수준 API인 tf.keras를 사용합니다. tf.saved_model.save() 메소드를 이용하여, 전체 모델을 지정한 위치에 저장합니다. 여기에는 가중치, 모델 구성 등이 포함됩니다. 모델을 저장할 때 주의해야할 점은 모델 저장 위치의 마지막 디렉토리에 모델의 버전이 포함되어야 합니다. 모델 버전은 숫자를 사용해야합니다.

def model():
    x_in = Input(shape=(28, 28, 1))
    x = Conv2D(filters=64, kernel_size=2, padding='same', activation='relu')(x_in)
    x = MaxPooling2D(pool_size=2)(x)
    x = Dropout(0.3)(x)

    x = Conv2D(filters=32, kernel_size=2, padding='same', activation='relu')(x)
    x = MaxPooling2D(pool_size=2)(x)
    x = Dropout(0.3)(x)

    x = Flatten()(x)
    x = Dense(256, activation='relu')(x)
    x = Dropout(0.5)(x)
    x_out = Dense(10, activation='softmax')(x)

    cnn = Model(inputs=x_in, outputs=x_out)
    cnn.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

    return cnn

생성 된 모델을 사용하여 Tensorflow 서버를 실행하고 예측을 수행 할 수 있습니다. 모델은 PV, S3 호환 가능 개체 저장소, Azure Blob 저장소 또는 Google Cloud Storage에 있을 수 있습니다.

그 다음, AnchorImage 인스턴스를 생성합니다. 모델의 예측 함수와 이미지 크기를 지정해 줍니다. 수퍼 픽셀은 표준 이미지 분할 알고리즘인 slic를 사용하여였습니다. 생성한 인스턴스를 dill을 이용해서 지정한 위치에 저장합니다.

predict_fn = lambda x: model.predict(x)

image_shape = (28, 28, 1)
segmentation_fn = 'slic'
explainer = AnchorImage(predict_fn, image_shape=image_shape, segmentation_fn=segmentation_fn)
explainer.predict_fn = None  # Clear explainer predict_fn as its a lambda and will be reset when loaded
with open(explainer_export_path, 'wb') as f:
    dill.dump(explainer, f)

모델 저장하기

쿠버네티스의 퍼시스턴스 볼륨에 모델을 저장해 보겠습니다. PVC 는 앞서 생성한 kfserving-models-pvc 을 사용하겠습니다. 모델을 학습시키기 위해서 쿠버네티스 잡(Job)을 사용하겠습니다. Job을 생성할 때 모델을 저장하기 위한 PVC를 마운트 해줍니다.

모델 코드 작성하기

패션 mnist 이미지를 분류하는 모델입니다. 예측 모델을 저장할 위치를 --model_path 파라미터로 explainer 모델의 저장할 위치를 --explainer_path 파라미터로 입력받게 하였습니다.

tensorflow_fashion_mnist.py

from __future__ import absolute_import, division, print_function, unicode_literals

import argparse
import os

import dill
import numpy as np
import tensorflow as tf
from alibi.explainers import AnchorImage
from tensorflow.keras.layers import Conv2D, Dense, Dropout, Flatten, MaxPooling2D, Input
from tensorflow.keras.models import Model
from tensorflow.keras.utils import to_categorical

print("TensorFlow version: ", tf.__version__)

parser = argparse.ArgumentParser()
parser.add_argument('--model_path', default='/mnt/pv/tensorflow/fashion_mnist/model', type=str)
parser.add_argument('--explainer_path', default='/mnt/pv/tensorflow/fashion_mnist/explainer', type=str)

args = parser.parse_args()

version = 1
export_path = os.path.join(args.model_path, str(version))

(x_train, y_train), (x_test, y_test) = tf.keras.datasets.fashion_mnist.load_data()
print('x_train shape:', x_train.shape, 'y_train shape:', y_train.shape)

x_train = x_train.astype('float32') / 255
x_test = x_test.astype('float32') / 255
x_train = np.reshape(x_train, x_train.shape + (1,))
x_test = np.reshape(x_test, x_test.shape + (1,))
print('x_train shape:', x_train.shape, 'x_test shape:', x_test.shape)
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
print('y_train shape:', y_train.shape, 'y_test shape:', y_test.shape)


def model():
    x_in = Input(shape=(28, 28, 1))
    x = Conv2D(filters=64, kernel_size=2, padding='same', activation='relu')(x_in)
    x = MaxPooling2D(pool_size=2)(x)
    x = Dropout(0.3)(x)

    x = Conv2D(filters=32, kernel_size=2, padding='same', activation='relu')(x)
    x = MaxPooling2D(pool_size=2)(x)
    x = Dropout(0.3)(x)

    x = Flatten()(x)
    x = Dense(256, activation='relu')(x)
    x = Dropout(0.5)(x)
    x_out = Dense(10, activation='softmax')(x)

    cnn = Model(inputs=x_in, outputs=x_out)
    cnn.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

    return cnn


cnn = model()
cnn.summary()

cnn.fit(x_train, y_train, batch_size=64, epochs=3)

# Evaluate the model on test set
score = cnn.evaluate(x_test, y_test, verbose=0)
print('Test accuracy: ', score[1])

tf.saved_model.save(cnn, export_path)
print('Saved model to {}'.format(export_path))

predict_fn = lambda x: cnn.predict(x)

image_shape = (28, 28, 1)
segmentation_fn = 'slic'
kwargs = {'n_segments': 15, 'compactness': 20, 'sigma': .5}
explainer = AnchorImage(predict_fn, image_shape, segmentation_fn=segmentation_fn, segmentation_kwargs=kwargs)


explainer.predict_fn = None  # Clear explainer predict_fn as its a lambda and will be reset when loaded
explainer_export_path = os.path.join(args.explainer_path, 'explainer.dill')
if not (os.path.isdir(os.path.dirname(explainer_export_path))):
    os.makedirs(os.path.dirname(explainer_export_path))
with open(explainer_export_path, 'wb') as f:
    dill.dump(explainer, f)
print('Saved explainer to {}'.format(explainer_export_path))

컨테이너 이미지를 만들기

컨테이너 이미지를 만들기 위한 Dockerfile 입니다. 텐서플로우 1.15 를 기본 이미지로 사용합니다. 사용한 Alibi 패키비의 버전은 0.3.2 입니다. KFServing에서 Alibi 서빙을 위해서 사용하는 이미지 버전이 0.3.x 이기 때문입니다. 다른 버전을 사용하고 싶다면, KFServing의 설정을 바꾸면 됩니다. Alibi 0.3.2 버전은 텐서플로우 2 버전을 지원하기 않기 때문에, 텐서플로우 1.15 버전을 사용하였습니다.

Dockerfile

FROM tensorflow/tensorflow:1.15.2-py3

RUN pip install alibi==0.3.2
RUN pip install numpy joblib dill

RUN mkdir -p /app
ADD tensorflow_fashion_mnist.py /app/

쿠버네티스 잡 실행하기

컨테이너 이미지를 빌드하고, 컨테이너 이미지 레지스트리에 푸시 한 다음, 쿠버네티스 잡(Job)을 생성하겠습니다.

Job을 생성할 때는 모델을 저장하기 위해서 PVC를 마운트 해줍니다. 이 일련의 작업들은 직접 실행 할 수 있습니다. 하지만 좀 더 편하게 하기 위해서 앞서 배운 Kubeflow Fairing을 사용하겠습니다.

다음은 로컬 개발 환경에서 Fairing을 사용하여 컨테이너 이미지를 만들고, 쿠버네티스 잡을 실행하는 예제입니다.

fairing-local-docker.py

import uuid

from kubeflow import fairing
from kubeflow.fairing.kubernetes import utils as k8s_utils

CONTAINER_REGISTRY = 'kangwoo'

namespace = 'admin'
job_name = f'tensorflow-fashion-mnist-job-{uuid.uuid4().hex[:4]}'

command = ["python", "tensorflow_fashion_mnist.py", "--model_path", "/mnt/pv/tensorflow/fashion-mnist/model",
           "--explainer_path", "/mnt/pv/tensorflow/fashion-mnist/explainer"]
output_map = {
    "Dockerfile": "Dockerfile",
    "tensorflow_fashion_mnist.py": "tensorflow_fashion_mnist.py"
}

fairing.config.set_preprocessor('python', command=command, path_prefix="/app", output_map=output_map)

fairing.config.set_builder('docker', registry=CONTAINER_REGISTRY, image_name="tensorflow-fashion-mnist",
                           dockerfile_path="Dockerfile")

fairing.config.set_deployer('job', namespace=namespace, job_name=job_name,
                            pod_spec_mutators=[
                                k8s_utils.mounting_pvc(pvc_name='kfserving-models-pvc', pvc_mount_path='/mnt/pv')],
                            cleanup=True, stream_log=True)

fairing.config.run()

fairing을 실행하면 쿠버네티스 잡이 생성되고, 학습이 완료된 모델이 지정한 경로에 저장됩니다.

Tensorflow을 사용하는 InferenceService 로 예측 하기

InferenceService 생성

InferenceService 매니페스트를 작성합니다.

predictor로 tensorflow 를 사용합니다. storageUri 필드로 모델 저장 위치를 지정해 줍니다. pvc 의 이름이 kfserving-models-pvc 이고 저장 위치가 tensorflow/fashion-mnist/model 이므로, pvc://kfserving-models-pvc/tensorflow/fashion-mnist/model 라고 지정해 줍니다.

explainer로 alibi 를 사용합니다. typeAnchorImages 로 지정합니다. 그리고 storageUri 필드로 Explainer 의 모델 저장 위치인 pvc://kfserving-models-pvc/tensorflow/fashion-mnist/explainer 를 지정해 줍니다.

alibi-fashion-mnist.yaml

apiVersion: "serving.kubeflow.org/v1alpha2"
kind: "InferenceService"
metadata:
  name: "alibi-fashion-mnist"
spec:
  default:
    predictor:
      tensorflow:
        storageUri: "pvc://kfserving-models-pvc/tensorflow/fashion-mnist/model"
    explainer:
      alibi:
        type: AnchorImages
        storageUri: "pvc://kfserving-models-pvc/tensorflow/fashion-mnist/explainer"
        config:
          batch_size: "25"

InferenceService 를 생성합니다.

다음은 admin 네임스페이스 InferenceService 를 생성하는 예제입니다.

kubectl -n admin apply -f alibi-fashion-mnist.yaml

생성한 InferenceService를 조회해 보겠습니다.

kubectl -n admin get inferenceservice

InferenceService 가 정상적으로 생성되면 다음과 같은 응답 결과를 확인할 수 있습니다.

NAME                  URL                                                                          READY   DEFAULT TRAFFIC   CANARY TRAFFIC   AGE
alibi-fashion-mnist   <http://alibi-fashion-mnist.admin.example.com/v1/models/alibi-fashion-mnist>   True    100                                2m

예측 실행하기

예측을 요청하기 위해서는 모델 서버에 접근해야 합니다. 모델 서버는 ingressgateway 를 통해서 접근할 수 있습니다. ingressgateway 는 모델 서버들을 구분하기 위해서 호스트 이름을 사용합니다. ingressgateway에 접근하 기 위한 주소는 앞서 정의한 CLUSTER_IP 를 사용하겠습니다.

예측을 요청할 데이터를 json 파일로 작성합니다.

데이터의 크기가 크기 때문에 git 에 있는 파일을 다운받아서 사용해주세요.

fashion-mnist-input.json

{
  "instances": [
    [
    
...
    ]
  ]
}

다음은 admin 네임스페이스의 tensorflow-mnist InferenceService 에 예측을 요청하는 예제입니다.

MODEL_NAME=alibi-fashion-mnist
SERVICE_HOSTNAME=$(kubectl -n admin get inferenceservice alibi-fashion-mnist -o jsonpath='{.status.url}' | cut -d "/" -f 3)

INPUT_PATH=@./fashion-mnist-input.json
curl -v -H "Host: ${SERVICE_HOSTNAME}" http://$CLUSTER_IP/v1/models/${MODEL_NAME}:predict -d $INPUT_PATH

정상적으로 실행되면 다음과 같은 응답 결과를 확인 할 수 있습니다.

*   Trying 192.168.21.38...
* TCP_NODELAY set
* Connected to 192.168.21.38 (192.168.21.38) port 32380 (#0)
> POST /v1/models/alibi-fashion-mnist:predict HTTP/1.1
> Host: alibi-fashion-mnist.admin.example.com
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Length: 29557
> Content-Type: application/x-www-form-urlencoded
> Expect: 100-continue
> 
< HTTP/1.1 100 Continue
* We are completely uploaded and fine
< HTTP/1.1 200 OK
< content-length: 181
< content-type: application/json
< date: Sat, 04 Apr 2020 12:10:22 GMT
< server: istio-envoy
< x-envoy-upstream-service-time: 8701
< 
{
    "predictions": [[3.36351741e-06, 6.70785425e-07, 8.033673e-07, 5.26102497e-07, 3.26495325e-07, 0.00148782798, 6.07064408e-07, 0.0196282454, 4.8258451e-05, 0.978829265]
    ]
* Connection #0 to host 192.168.21.38 left intact
}
* Closing connection 0

설명 실행하기

설명을 요청하기 위해서는 모델 서버에 접근해야 합니다. 모델 서버는 ingressgateway 를 통해서 접근할 수 있습니다. ingressgateway 는 모델 서버들을 구분하기 위해서 호스트 이름을 사용합니다. ingressgateway에 접근하 기 위한 주소는 앞서 정의한 CLUSTER_IP 를 사용하겠습니다.

설명을 요청할 데이터를 json 파일로 작성합니다.

데이터의 크기가 크기 때문에 git 에 있는 파일을 다운받아서 사용해주세요.

fashion-mnist-input.json

{
  "instances": [
    [
    
...
    ]
  ]
}

다음은 admin 네임스페이스의 tensorflow-mnist InferenceService 에 설명을 요청하는 예제입니다.

MODEL_NAME=alibi-fashion-mnist
SERVICE_HOSTNAME=$(kubectl -n admin get inferenceservice alibi-fashion-mnist -o jsonpath='{.status.url}' | cut -d "/" -f 3)

INPUT_PATH=@./fashion-mnist-input.json
curl -v -H "Host: ${SERVICE_HOSTNAME}" http://$CLUSTER_IP/v1/models/${MODEL_NAME}:explain -d $INPUT_PATH

정상적으로 실행되면 다음과 같은 응답 결과를 확인 할 수 있습니다.

*   Trying 192.168.21.38...
* TCP_NODELAY set
* Connected to 192.168.21.38 (192.168.21.38) port 32380 (#0)
> POST /v1/models/alibi-fashion-mnist:explain HTTP/1.1
> Host: alibi-fashion-mnist.admin.example.com
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Length: 29557
> Content-Type: application/x-www-form-urlencoded
> Expect: 100-continue
> 
< HTTP/1.1 100 Continue
* We are completely uploaded and fine
< HTTP/1.1 200 OK
< content-length: 101253
< content-type: text/html; charset=UTF-8
< date: Sat, 04 Apr 2020 10:51:23 GMT
< server: istio-envoy
< x-envoy-upstream-service-time: 10601
<
< 
{
  "anchor": [...],
  "segments": [...],
  "precision": 1.0,
  "coverage": 0.5106,
  "raw": {
    "feature": [
      13
    ],
    "mean": [
      1.0
    ],
    "precision": [
      1.0
    ],
    "coverage": [
      0.5106
    ],
    "examples": [
      {
        "covered": [...],
        "covered_true": [...],
        "covered_false": [],
        "uncovered_true": [],
        "uncovered_false": []
      }
    ],
    "all_precision": 0,
    "num_preds": 250026,
    "instance": [...],
    "prediction": 9
  },
  "meta": {
    "name": "AnchorImage"
  }
}
* Closing connection 0

설명 확인하기

설명 요청의 응답 결과를 이미지로 확인하기 위해서 테스트 코드를 작성해 보겠습니다.

test_fashion_mnist.py

import argparse
import json
import os
import ssl

import matplotlib.pyplot as plt
import numpy as np
import requests
import tensorflow as tf

ssl._create_default_https_context = ssl._create_unverified_context

HOST = 'alibi-fashion-mnist.admin.example.com'
PREDICT_TEMPLATE = 'http://{0}/v1/models/alibi-fashion-mnist:predict'
EXPLAIN_TEMPLATE = 'http://{0}/v1/models/alibi-fashion-mnist:explain'


def get_image_data(idx):
    (_, _), (x_test, y_test) = tf.keras.datasets.fashion_mnist.load_data()

    return x_test[idx]


def preprocess_input(input):
    data = input.astype('float32') / 255
    data = np.reshape(data, data.shape + (1,))
    data = data.reshape(1, 28, 28, 1)
    return data


CLASSES = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']


def decode_predictions(predictions):
    labels = np.argmax(predictions, axis=1)
    return CLASSES[labels[0]]


def predict(cluster_ip):
    image_data = get_image_data(0)
    data = preprocess_input(image_data)
    payload = json.dumps({"instances": data.tolist()})

    headers = {'Host': HOST}
    url = PREDICT_TEMPLATE.format(cluster_ip)
    print("Calling ", url)
    r = requests.post(url, data=payload, headers=headers)
    resp_json = json.loads(r.content.decode('utf-8'))
    preds = np.array(resp_json["predictions"])
    label = decode_predictions(preds)

    plt.imshow(image_data)
    plt.title(label)
    plt.show()


def explain(cluster_ip):
    image_data = get_image_data(1)
    data = preprocess_input(image_data)
    payload = json.dumps({"instances": data.tolist()})

    headers = {'Host': HOST}
    url = EXPLAIN_TEMPLATE.format(cluster_ip)
    print("Calling ", url)
    r = requests.post(url, data=payload, headers=headers)
    if r.status_code == 200:
        explanation = json.loads(r.content.decode('utf-8'))

        exp_arr = np.array(explanation['anchor'])

        f, axarr = plt.subplots(1, 2)
        axarr[0].imshow(image_data)
        axarr[1].imshow(exp_arr[:, :, 0])
        plt.show()
    else:
        print("Received response code and content", r.status_code, r.content)


parser = argparse.ArgumentParser()
parser.add_argument('--cluster_ip', default=os.environ.get("CLUSTER_IP"), help='Address of istio-ingress-gateway')
parser.add_argument('--op', choices=["predict", "explain"], default="predict",
                    help='Operation to run')
args, _ = parser.parse_known_args()

if __name__ == "__main__":
    if args.op == "predict":
        predict(args.cluster_ip)
    elif args.op == "explain":
        explain(args.cluster_ip)

다음 명령어를 실행하면, 설명을 요청할 수 있습니다.

python test_fashion_mnist.py --luster_ip ${CLUSTER_IP} --op explain

정상적으로 실행되면 다음과 같은 응답 결과를 확인 할 수 있습니다.