{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# SageMaker Endpoint에 사전 훈련된 모델을 호스팅 후 Object Detection 수행하기 (PyTorch)\n",
"---\n",
"\n",
"Amazon SageMaker에서 추론(inference)을 수행하려면 반드시 SageMaker에서 먼저 훈련을 수행해야 하나요? 그렇지 않습니다.
\n",
"만약 여러분이 SageMaker에서 추론만 수행하고 싶다면, 여러분의 온프레미스(on-premise)에서 훈련한 모델이나 공개 모델 저장소(model zoo)에 저장되어 있는 사전 훈련된(pre-trained) 모델들을 도커(Docker) 이미지 빌드 없이 그대로 SageMaker Endpoint에 배포할 수 있습니다. 여러분이 수행할 작업은 오로지 추론용 엔트리포인트(entrypoint)만 작성하는 것입니다.\n",
"\n",
"이 노트북에서는 PyTorch API를 사용하여 사전 훈련된 `faster_rcnn` 모델을 SageMaker 엔드포인트에 배포 후, Object Detection을 수행합니다. \n",
"\n",
"## Pre-requisites\n",
"\n",
"- 기본 용법: [PyTorch](https://gluon-cv.mxnet.io/tutorials/index.html)\n",
"- AWS 서비스: [AWS S3](https://docs.aws.amazon.com/s3/index.html), [Amazon SageMaker](https://aws.amazon.com/sagemaker/)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"%load_ext autoreload\n",
"%autoreload 2"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"SageMaker SDK를 최신 버전으로 업그레이드합니다. 본 노트북은 SDK 2.x 버전 이상에서 구동해야 합니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import sys, sagemaker, boto3\n",
"!{sys.executable} -m pip install -qU \"sagemaker>=2.11.0\"\n",
"print(sagemaker.__version__)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"
\n",
"\n",
"# 1. Inference script\n",
"---\n",
"\n",
"아래 코드 셀은 `src` 디렉토리에 SageMaker 추론 스크립트를 저장합니다.
\n",
"\n",
"이 스크립트는 SageMaker 상에서 MXNet에 최적화된 추론 서버인 MMS(Multi Model Server)나 PyTorch에 최적화된 추론 서버인 torchserve를 쉽고 편하게 배포할 수 있는 high-level 툴킷인 SageMaker inference toolkit의 인터페이스를 사용하고 있으며, 여러분께서는 인터페이스에 정의된 핸들러(handler) 함수들만 구현하시면 됩니다. MXNet 및 PyTorch용 엔트리포인트(entrypoint) 인터페이스는 아래 두 가지 옵션 중 하나를 선택하면 되며, 본 예제에서는 Option 2.의 사용 예시를 보여줍니다.\n",
"\n",
"\n",
"### Option 1.\n",
"- `model_fn(model_dir)`: 딥러닝 네트워크 아키텍처를 정의하고 S3의 model_dir에 저장된 모델 아티팩트를 로드합니다.\n",
"- `input_fn(request_body, content_type)`: 입력 데이터를 전처리합니다. (예: request_body로 전송된 bytearray 배열을 PIL.Image로 변환 수 cropping, resizing, normalization등의 전처리 수행). content_type은 입력 데이터 종류에 따라 다양하게 처리 가능합니다. (예: application/x-npy, application/json, application/csv 등)\n",
"- `predict_fn(input_object, model)`: input_fn을 통해 들어온 데이터에 대해 추론을 수행합니다. \n",
"- `output_fn(prediction, accept_type)`: predict_fn에서 받은 추론 결과를 추가 변환을 거쳐 프론트 엔드로 전송합니다. \n",
"\n",
"### Option 2. \n",
"- `model_fn(model_dir)`: 딥러닝 네트워크 아키텍처를 정의하고 S3의 model_dir에 저장된 모델 아티팩트를 로드합니다.\n",
"- `transform_fn(model, request_body, content_type, accept_type)`: input_fn(), predict_fn(), output_fn()을 transform_fn()으로 통합할 수 있습니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"%%writefile src/inference_pytorch.py\n",
"\n",
"# Built-Ins\n",
"import io, os, sys\n",
"import json\n",
"import subprocess, time\n",
"\n",
"import numpy as np\n",
"from base64 import b64decode\n",
"from PIL import Image\n",
"\n",
"import torch\n",
"import torchvision\n",
"from torchvision import datasets, transforms, models\n",
"from torchvision.models.detection import FasterRCNN\n",
"import torchvision.transforms as transforms\n",
"device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n",
" \n",
" \n",
"def model_fn(model_dir=None):\n",
" '''\n",
" Loads the model into memory from storage and return the model.\n",
" '''\n",
" model = torchvision.models.detection.fasterrcnn_resnet50_fpn(pretrained=True)\n",
" # load the model onto the computation device\n",
" model = model.eval().to(device) \n",
" return model\n",
"\n",
"\n",
"def transform_fn(model, request_body, content_type='application/x-image', accept_type=None):\n",
" '''\n",
" Deserialize the request body and predicts on the deserialized object with the model from model_fn()\n",
" '''\n",
" if content_type == 'application/x-image': \n",
" img = np.array(Image.open(io.BytesIO(request_body)))\n",
" elif content_type == 'application/x-npy': \n",
" img = np.frombuffer(request_body, dtype='uint8').reshape(137, 236) \n",
" else:\n",
" raise ValueError(\n",
" 'Requested unsupported ContentType in content_type : ' + content_type)\n",
"\n",
" t0 = time.time()\n",
" \n",
" test_transforms = transforms.Compose([\n",
" transforms.ToTensor()\n",
" ])\n",
" img_tensor = test_transforms(img).to(device)\n",
" img_tensor = img_tensor.unsqueeze(0)\n",
" \n",
" with torch.no_grad(): \n",
" result = model(img_tensor)\n",
"\n",
" t1 = time.time() - t0\n",
" print(\"--- Elapsed time: %s secs ---\" % t1)\n",
" \n",
" scores = result[0]['scores'].detach().cpu().numpy()\n",
" bboxes = result[0]['boxes'].detach().cpu().numpy()\n",
" cids = result[0]['labels'].detach().cpu().numpy() \n",
" \n",
" outputs = json.dumps({'score': scores.tolist(), \n",
" 'bbox': bboxes.tolist(),\n",
" 'cid': cids.tolist()})\n",
" \n",
" return outputs\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Object Detection에 필요한 유틸리티 함수들을 정의합니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"%%writefile src/utils.py\n",
"\n",
"def get_label_map(label_file):\n",
" label_map = {}\n",
" labels = open(label_file, 'r')\n",
" \n",
" for line in labels:\n",
" line = line.rstrip(\"\\n\")\n",
" ids = line.split(',')\n",
" label_map[int(ids[0])] = ids[2] \n",
" \n",
" return label_map\n",
"\n",
"\n",
"def get_label_map_imagenet(label_file):\n",
" label_map = {}\n",
" with open(label_file, 'r') as f:\n",
" for line in f:\n",
" key, val = line.strip().split(':')\n",
" label_map[key] = val.replace(',', '')\n",
" return label_map\n",
"\n",
"\n",
"def delete_endpoint(client, endpoint_name):\n",
" response = client.describe_endpoint_config(EndpointConfigName=endpoint_name)\n",
" model_name = response['ProductionVariants'][0]['ModelName']\n",
"\n",
" client.delete_model(ModelName=model_name) \n",
" client.delete_endpoint(EndpointName=endpoint_name)\n",
" client.delete_endpoint_config(EndpointConfigName=endpoint_name) \n",
" \n",
" print(f'--- Deleted model: {model_name}')\n",
" print(f'--- Deleted endpoint: {endpoint_name}')\n",
" print(f'--- Deleted endpoint_config: {endpoint_name}') \n",
" \n",
" \n",
"def plot_bbox(img_resized, bboxes, scores, cids, class_info, framework='pytorch', threshold=0.5):\n",
"\n",
" import numpy as np\n",
" import random\n",
" import matplotlib.patches as patches\n",
" import matplotlib.pyplot as plt\n",
" \n",
" if framework=='mxnet':\n",
" img_np = img_resized.asnumpy()\n",
" scores = scores.asnumpy()\n",
" bboxes = bboxes.asnumpy()\n",
" cids = cids.asnumpy()\n",
" else:\n",
" img_np = img_resized\n",
" scores = np.array(scores)\n",
" bboxes = np.array(bboxes)\n",
" cids = np.array(cids) \n",
"\n",
" # Get only results that are above the threshold. Default threshold is 0.5. \n",
" scores = scores[scores > threshold]\n",
" num_detections = len(scores)\n",
" bboxes = bboxes[:num_detections, :]\n",
" cids = cids[:num_detections].astype('int').squeeze()\n",
"\n",
" # Get bounding-box colors\n",
" cmap = plt.get_cmap('tab20b')\n",
" colors = [cmap(i) for i in np.linspace(0, 1, 20)]\n",
" random.seed(42)\n",
" random.shuffle(colors)\n",
" \n",
" plt.figure()\n",
" fig, ax = plt.subplots(1, figsize=(10,10))\n",
" ax.imshow(img_np)\n",
"\n",
" if cids is not None:\n",
" # Get unique class labels \n",
" unique_labels = set(list(cids.astype('int').squeeze()))\n",
" unique_labels = np.array(list(unique_labels))\n",
" n_cls_preds = len(unique_labels)\n",
" bbox_colors = colors[:n_cls_preds]\n",
"\n",
" for b, cls_pred, cls_conf in zip(bboxes, cids, scores):\n",
" x1, y1, x2, y2 = b[0], b[1], b[2], b[3]\n",
" predicted_class = class_info[int(cls_pred)]\n",
" label = '{} {:.2f}'.format(predicted_class, cls_conf)\n",
" \n",
" # Get box height and width\n",
" box_h = y2 - y1\n",
" box_w = x2 - x1\n",
"\n",
" # Add a box with the color for this class\n",
" color = bbox_colors[int(np.where(unique_labels == int(cls_pred))[0])]\n",
" bbox = patches.Rectangle((x1, y1), box_w, box_h, linewidth=3, edgecolor=color, facecolor='none')\n",
" ax.add_patch(bbox)\n",
"\n",
" plt.text(x1, y1, s=label, color='white', verticalalignment='top',\n",
" bbox={'color': color, 'pad': 0})\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"
\n",
"\n",
"# 2. Local Endpoint Inference\n",
"---\n",
"\n",
"충분한 검증 및 테스트 없이 훈련된 모델을 곧바로 실제 운영 환경에 배포하기에는 많은 위험 요소들이 있습니다. 따라서, 로컬 모드를 사용하여 실제 운영 환경에 배포하기 위한 추론 인스턴스를 시작하기 전에 노트북 인스턴스의 로컬 환경에서 모델을 배포하는 것을 권장합니다. 이를 로컬 모드 엔드포인트(Local Mode Endpoint)라고 합니다.\n",
"\n",
"먼저, 로컬 모드 엔드포인트의 컨테이너 배포 이전에 로컬 환경 상에서 직접 추론을 수행하여 결과를 확인하고, 곧바로 로컬 모드 엔드포인트를 배포해 보겠습니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import json\n",
"import numpy as np\n",
"from io import BytesIO\n",
"from PIL import Image\n",
"from src.inference_pytorch import model_fn, transform_fn\n",
"from src.utils import get_label_map, delete_endpoint, plot_bbox\n",
"\n",
"label_map = get_label_map('files/coco_labels.txt')\n",
"label_list = list(label_map.values())\n",
"print(label_map)\n",
"\n",
"path = \"./images/test\"\n",
"img_list = os.listdir(path)\n",
"img_path_list = [os.path.join(path, img) for img in img_list]\n",
"\n",
"test_idx = 0\n",
"img_path = img_path_list[test_idx]\n",
"\n",
"with open(img_path, mode='rb') as file:\n",
" img_byte = bytearray(file.read())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2.1. Local Inference without Endpoint\n",
"\n",
"로컬 모드 엔드포인트 배포 이전에 로컬 환경상에서 아래와 같이 추론을 수행하면서 디버깅을 수행할 수 있습니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"model = model_fn()\n",
"response_body = transform_fn(model, img_byte)\n",
"outputs = json.loads(response_body)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"img = Image.open(img_path)\n",
"plot_bbox(img, outputs['bbox'], outputs['score'], outputs['cid'], class_info=label_map)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2.2. Local Mode Endpoint\n",
"\n",
"이제 로컬 모드로 배포를 수행합니다.\n",
"\n",
"Fine-tuning 없이 곧바로 사전 훈련된 모델을 사용할 것이므로 `model.tar.gz`는 0바이트의 빈 파일로 구성합니다.\n",
"만약 온프레미스에서 fine-tuning을 수행한 모델을 사용하고 싶다면, 모델 파라메터(예: model.pth)들을 `model.tar.gz`로 압축하세요."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"f = open(\"model.pth\", 'w')\n",
"f.close()\n",
"!tar -czf model.tar.gz model.pth"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import time\n",
"from sagemaker.deserializers import JSONDeserializer\n",
"from sagemaker.serializers import IdentitySerializer\n",
"from sagemaker.pytorch.model import PyTorchModel\n",
"role = sagemaker.get_execution_role()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"endpoint_name = \"local-endpoint-pytorch-{}\".format(int(time.time()))\n",
"local_model_path = f'file://{os.getcwd()}/model.tar.gz'"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"\n",
"아래 코드 셀을 실행 후, 로그를 확인해 보세요. torchserve 대한 세팅값들을 확인하실 수 있습니다.\n",
"\n",
"```bash\n",
"algo-1-9m80o_1 | ['torchserve', '--start', '--model-store', '/.sagemaker/ts/models', '--ts-config', '/etc/sagemaker-ts.properties', '--log-config', '/opt/conda/lib/python3.6/site-packages/sagemaker_pytorch_serving_container/etc/log4j.properties', '--models', 'model.mar']\n",
"algo-1-9m80o_1 | 2020-12-29 14:07:56,008 [INFO ] main org.pytorch.serve.ModelServer - \n",
"algo-1-9m80o_1 | Torchserve version: 0.2.1\n",
"algo-1-9m80o_1 | TS Home: /opt/conda/lib/python3.6/site-packages\n",
"algo-1-9m80o_1 | Current directory: /\n",
"algo-1-9m80o_1 | Temp directory: /home/model-server/tmp\n",
"...\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"**[Note]** SageMaker SDK v2부터 serializer와 deserializer의 content_type에 대한 property를 직접 지정하는 것이 아니라 직접 클래스 인스턴스를 생성해야 합니다. content_type이 `'application/x-image'`일 경우는 `IdentitySerializer` 클래스를 사용하시면 됩니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"model = PyTorchModel(model_data=local_model_path,\n",
" role=role,\n",
" entry_point='inference_pytorch.py', \n",
" source_dir='src',\n",
" framework_version='1.6.0',\n",
" py_version='py3')\n",
"\n",
"predictor = model.deploy(\n",
" initial_instance_count=1,\n",
" instance_type='local',\n",
" serializer=IdentitySerializer(content_type='application/x-image'),\n",
" deserializer=JSONDeserializer()\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"로컬에서 컨테이너를 배포했기 때문에 컨테이너가 현재 실행 중임을 확인할 수 있습니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!docker ps"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### SageMaker SDK로 엔드포인트 호출"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"outputs = predictor.predict(img_byte)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"plot_bbox(img, outputs['bbox'], outputs['score'], outputs['cid'], class_info=label_map)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Boto3 API로 엔드포인트 호출\n",
"\n",
"위의 코드 셀처럼 SageMaker SDK `predict()` 메서드로 추론을 수행할 수도 있지만, 이번에는 boto3의 `invoke_endpoint()` 메서드로 추론을 수행해 보겠습니다.
\n",
"Boto3는 서비스 레벨의 low-level SDK로, ML 실험에 초점을 맞춰 일부 기능들이 추상화된 high-level SDK인 SageMaker SDK와 달리\n",
"SageMaker API를 완벽하게 제어할 수 있습으며, 프로덕션 및 자동화 작업에 적합합니다.\n",
"\n",
"참고로 `invoke_endpoint()` 호출을 위한 런타임 클라이언트 인스턴스 생성 시, 로컬 배포 모드에서는 `sagemaker.local.LocalSagemakerRuntimeClient()`를 호출해야 합니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"runtime_client = sagemaker.local.LocalSagemakerRuntimeClient()\n",
"endpoint_name = model.endpoint_name\n",
"\n",
"response = runtime_client.invoke_endpoint(\n",
" EndpointName=endpoint_name, \n",
" ContentType='application/x-image',\n",
" Accept='application/json',\n",
" Body=img_byte\n",
" )\n",
"outputs = json.loads(response['Body'].read().decode())"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"plot_bbox(img, outputs['bbox'], outputs['score'], outputs['cid'], class_info=label_map)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Local Mode Endpoint Clean-up\n",
"\n",
"엔드포인트를 계속 사용하지 않는다면, 엔드포인트를 삭제해야 합니다. SageMaker SDK에서는 `delete_endpoint()` 메소드로 간단히 삭제할 수 있습니다.
\n",
"참고로, 노트북 인스턴스에서 추론 컨테이너를 배포했기 때문에 엔드포인트를 띄워 놓아도 별도로 추가 요금이 과금되지는 않습니다. \n",
"\n",
"\n",
"```python\n",
"# SageMaker SDK\n",
"predictor.delete_endpoint()\n",
"\n",
"# Boto3 API\n",
"client.delete_model(ModelName=model_name) \n",
"client.delete_endpoint(EndpointName=endpoint_name)\n",
"client.delete_endpoint_config(EndpointConfigName=endpoint_name) \n",
"\n",
"# 직접 삭제\n",
"!docker rm $(docker ps -a -q)\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"predictor.delete_endpoint()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"
\n",
"\n",
"# 3. SageMaker Hosted Endpoint Inference\n",
"---\n",
"\n",
"이제 실제 운영 환경에 엔드포인트 배포를 수행해 보겠습니다. 로컬 모드 엔드포인트와 대부분의 코드가 동일하며, 모델 아티팩트 경로(`model_data`)와 인스턴스 유형(`instance_type`)만 변경해 주시면 됩니다."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### [주의] 아래 코드 셀을 그대로 실행하지 마시고 bucket 이름을 반드시 수정해 주세요.\n",
"```python\n",
"bucket = '[YOUR-S3-BUCKET]' # as-is\n",
"bucket = 'sagemaker-hol-daekeun' # to-be\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"role = sagemaker.get_execution_role()\n",
"#bucket = '[YOUR-S3-BUCKET]' # bucket 이름을 반드시 수정해 주세요.\n",
"bucket = sagemaker.Session().default_bucket() # SageMaker에서 자동으로 생성되는 bucket"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"%%bash -s \"$role\" \"$bucket\"\n",
"ROLE=$1\n",
"BUCKET=$2\n",
"\n",
"aws s3 cp model.tar.gz s3://$BUCKET/model.tar.gz"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"SageMaker가 관리하는 배포 클러스터를 프로비저닝하고 추론 컨테이너를 배포하기 때문에, 추론 서비스를 시작하는 데에는 약 5~10분 정도 소요됩니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"%%time\n",
"\n",
"model_path = 's3://{}/model.tar.gz'.format(bucket)\n",
"endpoint_name = \"endpoint-pytorch-object-detection-{}\".format(int(time.time()))\n",
"\n",
"model = PyTorchModel(model_data=model_path,\n",
" role=role,\n",
" entry_point='inference_pytorch.py', \n",
" source_dir='src',\n",
" framework_version='1.6.0',\n",
" py_version='py3')\n",
"\n",
"predictor = model.deploy(\n",
" initial_instance_count=1,\n",
" instance_type='ml.c5.large',\n",
" serializer=IdentitySerializer(content_type='application/x-image'),\n",
" deserializer=JSONDeserializer()\n",
")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"outputs = predictor.predict(img_byte)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"plot_bbox(img, outputs['bbox'], outputs['score'], outputs['cid'], class_info=label_map)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Endpoint Clean-up\n",
"\n",
"SageMaker Endpoint로 인한 과금을 막기 위해, 본 핸즈온이 끝나면 반드시 Endpoint를 삭제해 주시기 바랍니다."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"predictor.delete_endpoint()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "conda_pytorch_p36",
"language": "python",
"name": "conda_pytorch_p36"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.10"
}
},
"nbformat": 4,
"nbformat_minor": 4
}