CPLEXサンプル(diet.py)をWatson MLのWebサービス化する

# はじめに
別記事 [CPLEXサンプル(diet.py)をWatson StudioのDecision Optimization上で動かす](https://qiita.com/makaishi2/items/734785dc567088abfda1) の続編です。

上記の記事で動作が確認されたCPLEXのコード(Python API)は、Watson Machine Learningにデプロイして、Webサービスとして呼び出すことが可能です。その手順を以下で説明します。
以下で説明するコード一式は、次のURLにアップしてあります。

https://github.com/makaishi2/cplex-samples/tree/master/diet-ml

(2020-09-28 Watson ML v2の仕様変更に伴い、コード修正)

# 対象アプリケーション
上記のものとまったく同じです。
pythonコード: ``model.py``
CSVファイル: ``diet_food.csv``、``diet_nutrients.csv``と``diet_food_nutrients.csv``となります。


# 認証情報の取得
最初にWatson MLの認証情報(localtionとapikey)を取得する必要があります。手順は、下記リンク先にあるので、そちらを参照して下さい。
なお、locationに関してはダラスのサイトの場合、決め打ちで 'us-south' になります。

https://qiita.com/makaishi2/items/c6cd88c29f4c705fce8b#ロケーションapikeyの設定

# space_idの取得
Watson ML v2になってから、space_idも必要になりました。手順については、下記リンク先を参照して下さい。

https://qiita.com/makaishi2/items/c6cd88c29f4c705fce8b#space_id-の取得設定


# gzファイルの生成

Watson Machine Learningにモデルを登録するために、登録対象のコードを事前にzipないしgz形式に圧縮する必要があります。
生成用のコマンドは以下のとおりです。

```sh
export COPYFILE_DISABLE=1
tar czvf diet-model.gz main.py model.py
```

最初のEXPORT文はMACで圧縮をする場合に必要になります。
この環境変数設定をしないと、変なファイルもtarに入ってしまい、その後の処理でエラーになります。
もう一つのポイントは、業務ロジックが一切含まれていない``main.py``をアーカイブに含めることです。
このコードは汎用的に利用可能なので、上記のgithubからダウンロードしたものをそのままコピーして利用可能です。
``main.py``はPythonコードのエントリーポイントになり、入力のcsvファイルをデータフレームに読み込み、更に辞書型変数``inputs``にセットします。その後で最適化のロジックが実装されているmodel.pyを呼び出します。
また、最適化実行後の後処理も行っています。

簡易的なフレームワークのようなものだと考えてください。

実装の内容までユーザーが意識する必要はないのですが、参考までにmain.pyの中身もアップしておきます。

```py3
from functools import partial, wraps
import os
from os.path import splitext
import time
import threading
import traceback
import sys
import ntpath

import pandas
from six import iteritems

from docplex.util.environment import get_environment

output_lock = threading.Lock()


def set_stop_callback(cb):
    env = get_environment()
    env.abort_callbacks += [cb]


def get_all_inputs():
    '''Utility method to read a list of files and return a tuple with all
    read data frames.
    Returns:
        a map { datasetname: data frame }
    '''
    result = {}
    env = get_environment()
    for iname in [f for f in os.listdir('.') if splitext(f)[1] == '.csv']:
        with env.get_input_stream(iname) as in_stream:
            df = pandas.read_csv(in_stream)
            datasetname, _ = splitext(iname)
            result[datasetname] = df
    return result


def callonce(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        if not wrapper.called:
            wrapper.called = True
            return f(*args, **kwargs)
    wrapper.called = False
    return wrapper


@callonce
def write_all_outputs(outputs):
    '''Write all dataframes in ``outputs`` as .csv.

    Args:
        outputs: The map of outputs 'outputname' -> 'output df'
    '''
    global output_lock
    with output_lock:
        for (name, df) in iteritems(outputs):
            csv_file = '%s.csv' % name
            print(csv_file)
            with get_environment().get_output_stream(csv_file) as fp:
                if sys.version_info[0] < 3:
                    fp.write(df.to_csv(index=False, encoding='utf8'))
                else:
                    fp.write(df.to_csv(index=False).encode(encoding='utf8'))
    if len(outputs) == 0:
        print("Warning: no outputs written")


def wait_and_save_all_cb(outputs):
    global output_lock
    # just wait for the output_lock to be available
    t = time.time()
    with output_lock:
        pass
    elapsed = time.time() - t
    # write outputs
    write_all_outputs(outputs)


def get_line_of_model(n):
    env = get_environment()
    with env.get_input_stream('model.py') as m:
        lines = m.readlines()
        return lines[n - 1].decode("utf-8")


class InterpreterError(Exception):
    pass

if __name__ == '__main__':
    inputs = get_all_inputs()
    outputs = {}
    set_stop_callback(partial(wait_and_save_all_cb, outputs))
    
    env = get_environment()
    # The IS_DODS env must be True for model.py if running in DODS
    os.environ['IS_DODS'] = 'True'
    # This allows docplex.mp to behave the same (publish kpis.csv and solution.json
    # if this script is run locally
    os.environ['DOCPLEX_CONTEXT'] = 'solver.auto_publish=True'
    with env.get_input_stream('model.py') as m:
        try:
            exec(m.read().decode('utf-8'), globals())
        except SyntaxError as err:
            error_class = err.__class__.__name__
            detail = err.args[0]
            line_number = err.lineno
            fileName = ntpath.basename(err.filename)
            # When the error occurs in model.py, there is no err.filename
            if fileName == "<string>":
                fileName = "model.py"
            imsg = '\nFile "' + fileName + '", line %s\n' % line_number
            if err.text != None:
                imsg += err.text.rstrip() + '\n'
                spaces = ' ' * (err.offset - 1) if err.offset > 1 else ''
                imsg += spaces + "^\n"
            imsg += '%s: %s\n' % (error_class, detail)
            sys.tracebacklimit = 0
            raise InterpreterError(imsg)
        except Exception as err:
            error_class = err.__class__.__name__
            detail = ""
            if(len(err.args) > 0):
                detail = err.args[0]
            cl, exc, tb = sys.exc_info()
            ttb = traceback.extract_tb(tb)
            if(len(ttb) > 1):
                for i in range(len(ttb)):
                    fileName = ntpath.basename(ttb[i][0])
                    line = ttb[i][3]
                    if(fileName == "<string>"):
                        fileName = "model.py"
                        line = get_line_of_model(ttb[i][1])
                    ## need to use basename, otherwise we get the full path
                    ttb[i] = (fileName, ttb[i][1], ttb[i][2], line)
                ttb = ttb[1:]
            s = traceback.format_list(ttb)
            imsg = '\n' + (''.join(s))
            imsg += '%s: %s\n' % (error_class, detail)
            sys.tracebacklimit = 0
            raise InterpreterError(imsg)
        else:
            write_all_outputs(outputs)
```

# モデルの登録
モデルをWatson Machine Learingに登録するためには、下記に示す``ml-deploy.py``を使ってください。
実行前に3箇所修正する必要があり、``apikey``、``location``、``space_id``を事前に調べた値に設定します。

(2020-09-28追記)
モデル登録時、Webサービス登録時のMata情報の設定の方法がML v2に対応して微妙に変わっています。
(ModelMetaNames.SOFTWARE_SPEC_UID, ConfigurationMetaNames.HARDWARE_SPEC)
登録がうまくいかない場合は、このあたりをチェックして下さい。

(参考リンク)
https://medium.com/@AlainChabrier/migrate-your-python-code-for-do-in-wml-v2-instances-710025796f7
https://www.ibm.com/support/producthub/icpdata/docs/content/SSQNUZ_current/wsj/wmls/wmls-deploy-python-types.html

 

```py3
# -*- coding: utf-8 -*-

# コマンドによる事前準備
# $ pip install -U ibm-watson-machine-learning 
# $ MACでは次が重要
# $ export COPYFILE_DISABLE=1
# $ tar czvf diet-model.gz main.py model.py
# main.py 共通に使われるmodel呼び出し用コード
# model.py DO実装コード model.builderで動作確認したもの

import sys

# Watson ML credentails
apikey = 'xxxx'
location = 'us-south'

tarfile = 'diet-model.gz'

# --------------------------------------------------------
# メインルーチン
# --------------------------------------------------------
if __name__ == '__main__':

    # 引数の受け取り
    argv = sys.argv
    argc = len(argv)


    wml_credentials = {
        "apikey": apikey,
        "url": 'https://' + location + '.ml.cloud.ibm.com'
    }

    from ibm_watson_machine_learning import APIClient
    client = APIClient(wml_credentials)

    client.spaces.list()
    space_id = 'xxxx'
    client.set.default_space(space_id)
    
    software_spec_uid = client.software_specifications.get_uid_by_name("do_12.10")
    print(software_spec_uid)
    
    # 登録に必要な情報の設定
    mdl_metadata = {
        client.repository.ModelMetaNames.NAME: "Diet Python",
        client.repository.ModelMetaNames.DESCRIPTION: "Diet Python",
        client.repository.ModelMetaNames.TYPE: "do-docplex_12.10",
        client.repository.ModelMetaNames.SOFTWARE_SPEC_UID: software_spec_uid
    }

    # モデルの登録
    model_details = client.repository.store_model(model=tarfile, meta_props=mdl_metadata)

    # モデルUIDの取得
    model_uid = client.repository.get_model_uid(model_details)
    print( model_uid )

    # Webサービス化に必要な情報
    
    meta_props = {
        client.deployments.ConfigurationMetaNames.NAME: "Diet Python Web",
        client.deployments.ConfigurationMetaNames.DESCRIPTION: "Diet Python Web",
        client.deployments.ConfigurationMetaNames.BATCH: {},
        client.deployments.ConfigurationMetaNames.HARDWARE_SPEC: {'name': 'S', 'nodes': 1}  # S / M / XL
    }

    # Webサービス化
    deployment_details = client.deployments.create(model_uid, meta_props=meta_props)

    deployment_uid = client.deployments.get_uid(deployment_details)
    print( deployment_uid )
    
    # Webサービスの一覧表示
    client.deployments.list()
```

このコードでは、最初のAPI呼び出し``client.repository.store_model``でモデルの登録を、次のAPI呼び出しである``client.deployments.create``でWebサービス化をしています。

```sh
$ python ml-deploy.py
```

と実行すると、以下のような結果がかえってくるはずです。

```txt
779aad56-68d8-464b-89f5-2b46452b7a3d
List Models
------------------------------------  -------------------------------------  ------------------------  ----------------
GUID                                  NAME                                   CREATED                   TYPE
779aad56-68d8-464b-89f5-2b46452b7a3d  DIET_PYTHON                            2020-07-30T01:51:53.502Z  do-docplex_12.10
03731a59-9a75-4d9a-b345-2df11f05e08d  Auto generated DO docplex model        2020-07-29T12:45:50.047Z  do-docplex_12.10
211f73af-6a7c-445c-a5b8-ffa526fd703f  WAREHOUSE OPL                          2020-07-21T04:06:37.264Z  do-opl_12.10
b862c975-dc26-4b0a-94d5-06f099fb5a51  Auto generated DO opl model            2020-07-19T06:59:38.305Z  do-opl_12.10
3bc5312b-88bc-4740-9302-8c80eab2e1d3  Auto generated DO docplex 12.10 model  2020-07-17T05:16:08.670Z  do-docplex_12.10

#######################################################################################

Synchronous deployment creation for uid: '779aad56-68d8-464b-89f5-2b46452b7a3d' started

#######################################################################################


ready.


------------------------------------------------------------------------------------------------
Successfully finished deployment creation, deployment_uid='e3b2cf98-e81e-4619-87de-52a4a228e580'
------------------------------------------------------------------------------------------------


e3b2cf98-e81e-4619-87de-52a4a228e580
------------------------------------  ------------------------------------------  -----  ------------------------  -------------
GUID                                  NAME                                        STATE  CREATED                   ARTIFACT_TYPE
e3b2cf98-e81e-4619-87de-52a4a228e580  DIET_PYTHON Deployment                      ready  2020-07-30T01:51:58.421Z  model
13c873dc-84f4-43ff-a0c5-898b80e802ed  Auto generated DO docplex deployment        ready  2020-07-29T12:45:50.292Z  model
cf184900-bce6-4737-bfdb-ec8902057f35  WAREHOUSE OPL Deployment                    ready  2020-07-21T04:06:42.233Z  model
00616f70-64b0-46fb-97f1-3284bc8a6640  Auto generated DO opl deployment            ready  2020-07-19T06:59:38.400Z  model
ece5c71c-a9a2-4be1-8100-8b20867f13f0  Auto generated DO docplex 12.10 deployment  ready  2020-07-17T05:16:08.764Z  model
------------------------------------  ------------------------------------------  -----  ------------------------  -------------

```

# モデルの呼び出し
登録ができたら、REST APIによりモデルを呼び出すことが可能です。
呼び出しコード``ml-submit.py``のサンプルと結果例を示します。
deployment_uidには、先ほど登録時に返ってきたIDを設定して下さい。
この場合も、``apikey``、``location``と``space_id``に設定が必要です。

```py3
# -*- coding: utf-8 -*-

# コマンドによる事前準備
# $ pip install -U ibm-watson-machine-learning 

import sys

# Watson ML credentails
apikey = 'xxxx'
location = 'us-south'

import pandas as pd

# Watson ML credentails

# DO Deployment ID
deployment_uid = 'xxxx'

# Input CSV File
input_data1 = 'diet_food.csv'
input_data2 = 'diet_nutrients.csv'
input_data3 = 'diet_food_nutrients.csv'

# --------------------------------------------------------
# メインルーチン
# --------------------------------------------------------
if __name__ == '__main__':

    # 引数の受け取り
    argv = sys.argv
    argc = len(argv)

    wml_credentials = {
        "apikey": apikey,
        "url": 'https://' + location + '.ml.cloud.ibm.com'
    }

    from ibm_watson_machine_learning import APIClient
    client = APIClient(wml_credentials)

    client.spaces.list()
    space_id = '20f3d4c5-1faa-4c80-a361-4da68d362b0f'
    client.set.default_space(space_id)

    input_df1 = pd.read_csv(input_data1)
    input_df2 = pd.read_csv(input_data2)
    input_df3 = pd.read_csv(input_data3)
    
    solve_payload = {
        client.deployments.DecisionOptimizationMetaNames.INPUT_DATA: [
            {
                "id": input_data1,
                "values" : input_df1
            },
            {
                "id": input_data2,
                "values" : input_df2
            },
            {
                "id": input_data3,
                "values" : input_df3
            }
        ],
        client.deployments.DecisionOptimizationMetaNames.OUTPUT_DATA: [
            {
                "id":".*\.csv"
            }
        ]
    }

    # DO Job 投入
    job_details = client.deployments.create_job(deployment_uid, solve_payload)
    job_uid = client.deployments.get_job_uid(job_details)
    print( job_uid )


    #  status確認
    from time import sleep
    while job_details['entity']['decision_optimization']['status']['state'] not in ['completed', 'failed', 'canceled']:
        print(job_details['entity']['decision_optimization']['status']['state'] + '...')
        sleep(5)
        job_details=client.deployments.get_job_details(job_uid)

    detail =  job_details['entity']['decision_optimization']['output_data']

    # 結果確認
    import json
    detail2 =  job_details['entity']['decision_optimization']
    
    # 最終ステータス表示
    print(json.dumps(detail2['status'], indent=2))
    
    for item in detail:
        id = item['id']
        fields = item['fields']
        values = item['values']
        df_work = pd.DataFrame(values, columns=fields)
        name = id[:id.index('.csv')]
        print('name = ', name)
        print(df_work.head())
        df_work.to_csv(id, index=False)
```

呼び出しコマンド

```sh
$ python ml-submit.py
```

結果サンプル

```txt
Note: 'limit' is not provided. Only first 50 records will be displayed if the number of records exceed 50
------------------------------------  -------------------  ------------------------
ID                                    NAME                 CREATED
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  do-space-xx          2020-09-25T08:55:05.513Z
------------------------------------  -------------------  ------------------------
04f0ecf7-57ea-494b-a372-3fb1d88101cf
queued...
queued...
queued...
queued...
running...
running...
running...
{
  "completed_at": "2020-09-27T23:04:16.378Z",
  "running_at": "2020-09-27T23:04:15.715Z",
  "state": "completed"
}
name =  kpis
                  Name        Value
0       Total Calories  2000.000000
1        Total Calcium   800.000000
2           Total Iron    11.278318
3          Total Vit_A  8518.432542
4  Total Dietary_Fiber    25.000000
name =  stats
                                 Name Value
0    STAT.cplex.size.integerVariables     0
1  STAT.cplex.size.continousVariables     9
2   STAT.cplex.size.linearConstraints     7
3    STAT.cplex.size.booleanVariables     0
4         STAT.cplex.size.constraints     7
name =  solution
                     name      value
0      Spaghetti W/ Sauce   2.155172
1  Chocolate Chip Cookies  10.000000
2             Lowfat Milk   1.831167
3                  Hotdog   0.929698

```