CUBE SUGAR CONTAINER

技術系のこと書きます。

動作中の Docker コンテナからイメージを作る

Docker コンテナを使って技術検証をしているときに、色々と試行錯誤している場面では、ある手順から作業をやり直したくなることがある。 すべての作業がすぐに終わるなら特に問題にはならないものの、時間がかかる場合には初めからやり直したときに大きなロスが生じる。 そんなときは、動作中の Docker コンテナから Docker イメージを作ることで時間の節約ができる。 具体的には docker commit サブコマンドを使うことで実現できる。

今回、使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15
$ docker version                     
Client: Docker Engine - Community
 Cloud integration: 1.0.2
 Version:           19.03.13
 API version:       1.40
 Go version:        go1.13.15
 Git commit:        4484c46d9d
 Built:             Wed Sep 16 16:58:31 2020
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.13
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       4484c46d9d
  Built:            Wed Sep 16 17:07:04 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          v1.3.7
  GitCommit:        8fba4e9a7d01810a393d5d25a3621dc101981175
 runc:
  Version:          1.0.0-rc10
  GitCommit:        dc9208a3303feef5b3839f4323d9beb36df0a9dd
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

もくじ

作業中を模した Docker コンテナを用意する

とりあえず、何でも構わないのでコンテナを立ち上げておく。

$ docker container run \
  --rm \
  -it ubuntu:18.04 \
  bash

立ち上げた以下のコンテナを、何らかの検証で作業中のコンテナに見立てる。

root@f499b0f5b72b:/#

たとえば、次のようにファイルを書き出しておこう。 このファイルがあることで、作業が途中まで完了していることの目印とする。

root@f499b0f5b72b:/# echo "Hello, World" > /var/tmp/greet.txt

動作中のコンテナからイメージを作る

Docker ホスト側で、お目当てのコンテナが動作していることを確認する。 別にコンテナが終了していても構わないんだけど、今回は --rm オプションをつけているのでコンテナは終了すると消えてしまう。

$ docker container ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
f499b0f5b72b        ubuntu:18.04        "bash"              31 seconds ago      Up 29 seconds                           eloquent_keller

それでは、docker commit コマンドを使って、コンテナからイメージを作ろう。 最初の引数にお目当てとなるコンテナの ID ないし名前を指定して、次の引数にコンテナを元ネタにしたイメージの名前を指定する。

$ docker commit f499b0f5b72b example
sha256:0bbb09b0e9a30bfd0ba7a342eddb7bbcc6380b0542a1379c7c8e54587e24392a

イメージの一覧を確認すると、ちゃんと指定した名前でイメージができている。

$ docker image list | grep example
example                              latest                                           0bbb09b0e9a3        14 seconds ago      64.2MB

作ったイメージからコンテナを起動する

上記のイメージを使ってコンテナを立ち上げてみよう。

$ docker container run \
  --rm \
  -it example \
  bash

イメージの元ネタになったコンテナにあったはずのファイルを確認すると、次のようにちゃんとある。 つまり、作業の途中からやり直すことができている。

root@a05ba64a868b:/# cat /var/tmp/greet.txt 
Hello, World

めでたしめでたし。

参考

docs.docker.com

シェルスクリプトで数値をゼロパディングする

シェルスクリプトを書いていると、数値をゼロパディングする必要に迫られることがある。 たとえば、ファイル名や日付を処理するときに多い。 結論から先に述べると、数値のゼロパディングは printf(1) を使うことで実現できる。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15

数値をゼロパディングする

たとえば、以下のようにシェル変数を用意する。

$ N=5

このままでは、当然のことながらパディングされていない。

$ echo $N   
5

printf(1) を使って二桁にゼロパディングするには、次のようにする。 なお、使えるフォーマットは man 1 printf を参照のこと。

$ printf "%02d\n" $N
05

シェルスクリプトの中で使う場合には、インラインで実行してやれば良いかな。

$ PADDING_N=$(printf "%02d" $N)
$ echo $PADDING_N         
05

いじょう。

GNU date で月末の日付を得る

今回は、GNU date を使って月末の日付を得る方法について。 シェルスクリプトで一ヶ月単位の処理を書こうとすると、よく調べることになるのでメモしておく。

検証に使った環境は次のとおり。

$ sw_vers                                        
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15
$ date --version
date (GNU coreutils) 8.32
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by David MacKenzie.

もくじ

下準備

検証に使ったのは macOS で、デフォルトでは date(1) が BSD date になっている。 GNU date の方がオプションが多彩で扱いやすいので入れ替えよう。 Homebrew で Coreutils を入れると gdate というコマンドで GNU date が使えるようになる。

$ brew install coreutils
$ alias date=gdate

GNU date の使い方

GNU date では + 以降にフォーマット文字列を指定することで日時を整形できる。 使えるフォーマット文字列は man 1 gdate を参照のこと。

たとえば、次のようにすれば年月日をハイフンでつないだフォーマットで今日の日付を表示できる。

$ date '+%Y-%m-%d'                 
2020-11-13

先ほどの実行結果を見てわかるとおり、デフォルトでは表示される内容として現在の日時が使われる。 これは --date オプションに now を指定していることに等しい。

$ date '+%Y-%m-%d' --date 'now'    
2020-11-13

--date オプションは Human readable なフォーマットを受け付けるようになっている。 たとえば 1 month を指定すると、現在の 1 ヶ月後になる。

$ date '+%Y-%m-%d' --date '1 month'
2020-12-13

他にも、1 day agoyesterday とすれば昨日が得られる。

$ date '+%Y-%m-%d' --date '1 day ago'
2020-11-12
$ date '+%Y-%m-%d' --date 'yesterday'
2020-11-13

それ以外のテクニックとして、日時をずらした上でフォーマットを駆使して意図した内容を得ることもできる。 たとえば、月初の日付がほしいなら、フォーマットの日付を 1 日にしてやれば良い。 以下では、来月の 1 日の日付を取得している。

$ date '+%Y-%m-01' --date '1 month'
2020-12-01

月末の日付を得る

さて、それでは今回の本題に入る。 たとえば、ここまでの要領で「今月末の日付」を得るには「来月の 1 日の 1 日前」を計算してやれば良いことになる。 これを一回の date(1) の実行で得るのは難しいので、インラインコマンドを駆使する。 つまり、まずは「来月の 1 日」を得た上で、それを次の date(1) の --date オプションに埋め込んでやる。

$ date '+%Y-%m-%d' --date "1 day ago $(date '+%Y-%m-01' --date '1 month')"
2020-11-30

ちゃんと今月末の日付が得られた。

次に、任意の月の月末の日付がほしいなら、インラインで実行する date(1) の --date オプションにその月の日付を指定すれば良い。 たとえば、以下では 2020 年 2 月の末日を取得している。 今年はうるう日があった。

$ date '+%Y-%m-%d' --date "1 day ago $(date '+%Y-%m-01' --date '1 month 2020-02-01')"
2020-02-29

めでたしめでたし。

シェルスクリプトの中でスクリプトのあるディレクトリを取得する

シェルスクリプトの中から、実行したスクリプトのあるディレクトリを必要とする場面はちょいちょいある。 たとえば、スクリプトの中で相対パスを使って別のファイルを読み込むような処理が典型的だと思う。 その場合、スクリプトを実行したときのカレントディレクトリを起点として相対パスを処理してしまうと上手くいかない。 代わりに、スクリプトのあるディレクトリを把握した上で、そこを起点に相対パスを処理する必要がある。 今回は、それを実現する方法についてメモしておく。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15
$ bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin19)
Copyright (C) 2007 Free Software Foundation, Inc.
$ dirname --version
dirname (GNU coreutils) 8.32
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by David MacKenzie and Jim Meyering.

もくじ

下準備

今回のサンプルコードは、macOS に標準で入っている BSD dirname でも動作する。 ただし、検証に使っている環境としては GNU Coreutils の実装を利用している。

$ brew install coreutils
$ alias dirname="gdirname"

スクリプトのあるディレクトリを取得する

早速だけど、以下にサンプルコードを示す。 以下のサンプルコードでは SCRIPT_DIR というシェル変数に、スクリプトのあるディレクトリを絶対パスで代入している。

#!/usr/bin/env bash

set -CEuo pipefail

# dirname $0 でスクリプトの存在するディレクトリが得られる
# インラインコマンドで cd して pwd すれば絶対パスが得られる
SCRIPT_DIR=$(cd $(dirname $0) && pwd)
echo ${SCRIPT_DIR}

上記を適当な名前と場所に保存しよう。 ここでは example.sh という名前で /tmp ディレクトリに保存した。

まずは、試しに Bash に絶対パスでシェルスクリプトを指定して実行してみる。

$ bash /tmp/example.sh 
/tmp

ちゃんと保存されているディレクトリの絶対パスが表示された。

続いてはルートディレクトリに移動してから相対パスでシェルスクリプトを指定してみよう。

$ cd /
$ bash ./tmp/example.sh 
/tmp

これでも、ちゃんと保存されているディレクトリの絶対パスが表示された。

ついでに、実行権限をつけて、Shebang 経由でスクリプトを実行してみよう。

$ chmod +x example.sh
$ ./tmp/example.sh    
/tmp

これも、ちゃんと表示される。

動作原理について

あとはもう蛇足だけど、どうして先ほどのサンプルコードが動作するかの説明をしていく。

変数 $0 について

そもそも、シェルスクリプトでは $0 という変数にスクリプトが実行されたときのパスが入る。 たとえば、次のようなサンプルコードを用意してみよう。

#!/usr/bin/env bash

set -CEuo pipefail

# 最初の引数は実行するときに使ったパスになる
echo $0

上記を、先ほどと同じように色々な呼び方で実行してみよう。

$ bash /tmp/example.sh 
/tmp/example.sh
$ bash ./tmp/example.sh
./tmp/example.sh
$ ./tmp/example.sh    
./tmp/example.sh

呼び方によって絶対パスだったり相対パスだったりが入っている。

dirname(1) について

そして、dirname(1) を使うとパスの中でディレクトリの部分が得られる。 たとえば、シェルから直接呼んでみると、次のようになる。

$ dirname /tmp/example.sh 
/tmp
$ dirname ./tmp/example.sh 
./tmp

これで、ひとまずスクリプトのあるディレクトリが相対パスで得られた。 あとは、扱いやすさを考えて必要に応じて絶対パスにする。 絶対パスにするときはインラインで cd して pwd すれば良い。

$ (cd $(dirname ./tmp/example.sh) && pwd)
/tmp
$ (cd $(dirname /tmp/example.sh) && pwd) 
/tmp

いじょう。

補足

シンボリックリンクが使われている場合には、必要に応じて pwd(1) に -P オプションを指定して解決した方が良いかも。

Python: SQLAlchemy のテーブルに後からインデックスを追加する

今回は、Python の O/R マッパーである SQLAlchemy について。 テーブルを定義した時点のモデルには無かったインデックスを、後から追加する方法についてメモしておく。

なお、実務における RDBMS のスキーマ変更に関しては、Alembic のようなフレームワークを使ってバージョン管理することを強くおすすめしたい。

blog.amedama.jp

使った環境は次のとおり。

$ sw_vers                     
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
$ python -V
Python 3.8.6
$ python -c "import sqlite3; print(sqlite3.version)"    
2.6.0
$ sqlite3 --version           
3.28.0 2019-04-15 14:49:49 378230ae7f4b721c8b8d83c8ceb891449685cd23b1702a57841f1be40b5daapl

もくじ

下準備

あらかじめ、SQLAlchemy をインストールしておく。

$ pip install sqlalchemy

最初からテーブルの定義にインデックスがある場合

はじめに、最初からテーブルを表すモデルにインデックスの指定がある場合について確認しておく。 以下のサンプルコードには User というクラスが users というテーブルを表している。 そして、age というカラムに対応するアトリビュートに index=True が指定されている。 こうなっていると、テーブルを初期化した時点で、age というカラムにインデックスが有効となる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import Column
from sqlalchemy.types import Integer
from sqlalchemy.types import Text

ModelBase = declarative_base()


LOGGER = logging.getLogger(__name__)


class User(ModelBase):
    """ユーザのデータを模したサンプル用のモデル"""
    __tablename__ = 'users'

    # ユーザ名
    name = Column(Text, primary_key=True)
    # NOTE: 最初からインデックスを張るときは index=True オプションを使う
    age = Column(Integer, nullable=False, index=True)


def init_db(engine: Engine, drop: bool = False):
    """データベースを初期化する"""
    if drop:
        ModelBase.metadata.drop_all(engine)

    ModelBase.metadata.create_all(engine)


def get_engine(db_uri: str) -> Engine:
    engine = create_engine(
        db_uri,
        pool_recycle=3600,
        encoding='utf-8',
    )
    return engine


def main():
    # SQLAlchemy のログを DEBUG レベルで出力する
    logger = logging.getLogger('sqlalchemy')
    logger.setLevel(logging.DEBUG)
    # DEBUG レベル以上のログを出力する
    logging.basicConfig(level=logging.DEBUG)

    # データベースを初期化する
    db_uri = 'sqlite:///example.db?cache=shared'
    engine = get_engine(db_uri)
    init_db(engine, drop=True)


if __name__ == '__main__':
    main()

上記を実行してみよう。 SQLAlchemy のデバッグログを有効にしているため、バックエンドに発行したクエリなどが出力される。 ここでは、それらの出力は省略している。

$ python withindex.py
...

作成されたデータベースのスキーマを確認してみよう。 すると、次のとおり age カラムにインデックスが確認できる。

$ sqlite3 example.db ".schema"            
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE users (
    name TEXT NOT NULL, 
    age INTEGER NOT NULL, 
    PRIMARY KEY (name)
);
CREATE INDEX ix_users_age ON users (age);

最初からテーブルの定義にインデックスがない場合

続いてが本題の、最初の定義にはインデックスの指定がない場合について。 以下のサンプルコードでは、先ほど作成したテーブルを DROP して、新たにインデックスの指定がないものを作り直している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import Column
from sqlalchemy.types import Integer
from sqlalchemy.types import Text

ModelBase = declarative_base()


LOGGER = logging.getLogger(__name__)


class User(ModelBase):
    __tablename__ = 'users'

    name = Column(Text, primary_key=True)
    # この時点ではインデックスがない
    age = Column(Integer, nullable=False)


def init_db(engine: Engine, drop: bool = False):
    if drop:
        ModelBase.metadata.drop_all(engine)

    ModelBase.metadata.create_all(engine)


def get_engine(db_uri: str) -> Engine:
    engine = create_engine(
        db_uri,
        pool_recycle=3600,
        encoding='utf-8',
    )
    return engine


def main():
    logger = logging.getLogger('sqlalchemy')
    logger.setLevel(logging.DEBUG)
    logging.basicConfig(level=logging.DEBUG)

    db_uri = 'sqlite:///example.db?cache=shared'
    engine = get_engine(db_uri)
    init_db(engine, drop=True)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python withoutindex.py
...

すると、次のようにインデックスのないテーブルが作り直される。

$ sqlite3 example.db ".schema"
CREATE TABLE users (
    name TEXT NOT NULL, 
    age INTEGER NOT NULL, 
    PRIMARY KEY (name)
);

この状況から、ただモデルのアトリビュートに index=True を追加して ModelBase.metadata.create_all() しても上手くいかない。

SQLAlchemy でインデックスを後から追加する

SQLAlchemy で後からインデックスを追加するには Index というクラスのインスタンスを作る。 そして、create() というメソッドを呼ぶことでインデックスが作成できるようだ。

文章で説明するよりも実際に試す方が分かりやすいと思うので REPL を使って作業していく。 なお、REPL を起動する場所は、先ほどのサンプルコードを作ったディレクトリにしよう。 あるいは、サンプルコードの場所をシェル変数の PYTHONPATH に指定してもいい。

$ python

以下がインデックスを作ることができるクラス。

>>> from sqlalchemy import Index

既存モデルのカラムに対応したインデックスを作りたいなら、次のようにする。 先ほどサンプルコードとして示したモジュールをインポートして使っている。

>>> from withoutindex import User
>>> UserAgeIndex = Index('ix_users_age', User.age)

動かすのに sqlalchemy.engine.Engine のインスタンスが必要なので用意する。

>>> from withoutindex import get_engine
>>> db_uri = 'sqlite:///example.db?cache=shared'
>>> engine = get_engine(db_uri)

あとは、先ほど作った Index クラスのインスタンスに対して create() メソッドの呼ぶだけ。

>>> UserAgeIndex.create(engine)
Index('ix_users_age', Column('age', Integer(), table=<users>, nullable=False))

テーブルの定義を確認すると、次のようにインデックスが追加されている。

$ sqlite3 example.db ".schema"
CREATE TABLE users (
    name TEXT NOT NULL, 
    age INTEGER NOT NULL, 
    PRIMARY KEY (name)
);
CREATE INDEX ix_users_age ON users (age);

いじょう。

繰り返しになるけど、実際には上記のような手動オペレーションをするのではなく、Alembic などを使ってスキーマをバージョン管理しよう。

Python: OmegaConf を使ってみる

OmegaConf は、Python の Configuration フレームワークのひとつ。 Hydra が低レイヤー API に利用している、という点が有名だと思う。 というより、Hydra を使おうとすると OmegaConf の API が部分的にそのまま露出していることに気づく。 なので、OmegaConf を理解しておかないと、Hydra はちゃんと扱えないくらいまであると思う。 今回は、そんな OmegaConf の使い方を一通り見ていく。

OmegaConf には、ざっくりと次のような特徴がある。

  • 良い (と私が感じた) ところ

    • YAML 形式で設定ファイルを管理できる
    • 設定の Key-Value ペアを階層的に管理できる
    • Data Classes (PEP 557) を使って設定の構造を定義できる
    • Syntax for Variable Annotations (PEP 526) を使ってバリデーションできる
    • ある場所で定義されている Value を、別の Value から利用できる (Interpolation)
    • Interpolation の解決をカスタマイズする仕組みがある (Custom Resolver)
  • 発展途上 (と私が略) なところ

    • Value のバリデーションをカスタマイズする仕組みがない
    • プリミティブ型以外を Value として扱うための仕組みが限られている

検証に使った環境は次のとおり。

$ sw_vers                
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
$ python -V            
Python 3.8.6
$ pip list | grep -i omegaconf
omegaconf                 2.0.4

下準備

あらかじめ OmegaConf をインストールしておく。

$ pip install omegaconf

設定を表すオブジェクトを作る

OmegaConf では omegaconf.OmegaConf というクラスのオブジェクトが設定を司っている。 まずは、あまり実用性はないサンプルコードを以下に示す。 このサンプルコードでは、設定の元ネタになる辞書から OmegaConf のインスタンスを生成している。 そして、それを YAML 形式で標準出力に書き出している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from omegaconf import OmegaConf


def main():
    # 辞書からコンフィグを読み込む
    dict_conf = {
        'name': 'Alice',
        # リストを含んだり
        'friends': ['Bob', 'Carol'],
        # ネストさせても良い
        'location': {
            'country': 'Japan',
            'prefecture': 'Tokyo',
        }
    }
    conf = OmegaConf.create(dict_conf)
    # 読み込んだ内容を出力する
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、辞書から読み込んだ設定が YAML 形式で表示される。

$ python example.py            
name: Alice
friends:
- Bob
- Carol
location:
  country: Japan
  prefecture: Tokyo

読み込んだ設定にアクセスする

先ほどのサンプルコードでは、辞書から読み込んだ内容を、ただ YAML 形式にしているだけだった。 続いては、読み込んだ内容にプログラムからアクセスする方法について示す。 以下のサンプルコードでは、OmegaConf に内包されている設定に、「object style」と「dictionary style」という 2 つの方法でアクセスしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from omegaconf import OmegaConf


def main():
    dict_conf = {
        'name': 'Alice',
        'friends': ['Bob', 'Carol'],
        'location': {
            'country': 'Japan',
            'prefecture': None,
        }
    }
    conf = OmegaConf.create(dict_conf)

    # 読み込んだコンフィグの使い方
    print(conf.location.country)  # object style
    print(conf['location']['country'])  # dictionary style

    # 取得するタイミングでデフォルト値を設定する場合
    print(conf.location.get('prefecture', 'Tokyo'))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、辞書に入っていた内容に OmegaConf のインスタンス経由でアクセスできることがわかる。

$ python example.py            
Japan
Japan
Tokyo

ただ、このサンプルも実用上はあまり意味がない。 なぜなら、上記であれば OmegaConf を使うまでもなく辞書をそのまま使っていれば良い。

YAML 形式の設定ファイルから読み出す

続いては、もう少し実用的なサンプルになる。 以下のサンプルコードでは YAML 形式で書かれた設定ファイルから OmegaConf のインスタンスを生成している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import tempfile
import os

from omegaconf import OmegaConf


def main():
    yaml_conf = """
    name: Alice
    friends:
    - Bob
    - Carol
    location:
      country: Japan
      prefecture: Tokyo
    """
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as fp:
        # 一時ファイルに YAML を書き込む
        fp.write(yaml_conf)
    # YAML ファイルから設定を読み込む
    conf_filepath = fp.name
    conf = OmegaConf.load(conf_filepath)
    """
    # あるいは File-like オブジェクトからも読み込める
    with open(conf_filepath, mode='r') as fp:
        conf = OmegaConf.load(fp)
    """
    # 読み込んだ内容を出力する
    print(OmegaConf.to_yaml(conf))
    # 後片付け
    os.remove(conf_filepath)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、YAML 形式の設定ファイルから読み込まれた内容が、また YAML 形式で出力される。

$ python example.py            
name: Alice
friends:
- Bob
- Carol
location:
  country: Japan
  prefecture: Tokyo

とはいえ、このサンプルも別に OmegaConf を使う必要はない。 なぜなら、YAML 形式を Python の辞書と相互に変換するパッケージさえあれば実現できるため。

コマンドラインの引数から設定を読み込む

ここらへんから、じわりじわりと便利なポイントが見えてくる。 次のサンプルコードでは、スクリプトを実行する際に渡されたコマンドラインの引数から設定を読み込んでいる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from omegaconf import OmegaConf


def main():
    conf = OmegaConf.from_cli()
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 以下では、コマンドラインの引数として server.port=8080server.host=127.0.0.1 を渡した。 すると、コマンドライン経由で仮想構造になった設定がちゃんと読み込まれていることがわかる。

$ python example.py server.port=8080 server.host=127.0.0.1
server:
  port: 8080
  host: 127.0.0.1

コマンドラインで指定するのがめんどくさいときは、次のようにしても良い。 以下では sys.argv の内容をスクリプトの中で上書きしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

from omegaconf import OmegaConf


def main():
    sys.argv = [__file__,
                'server.port=8080',
                'server.host=127.0.0.1']
    conf = OmegaConf.from_cli()
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

複数の経路で読み込んだ設定をマージする

また、設定ファイルや辞書から作った OmegaConf をひとつにマージすることもできる。 次のサンプルコードでは、辞書の設定をベースとして、コマンドラインの設定で上書きしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

from omegaconf import OmegaConf


def main():
    dict_conf = {
        'server': {
            'port': 80,
            'host': '127.0.0.1',
        }
    }
    base_conf = OmegaConf.create(dict_conf)

    sys.argv = [__file__,
                'server.host=0.0.0.0']
    cli_conf = OmegaConf.from_cli()

    # 複数のコンフィグをマージする
    merged_conf = OmegaConf.merge(base_conf, cli_conf)
    print(OmegaConf.to_yaml(merged_conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、ベースとなった辞書の設定では server.host127.0.0.1 だったのに、その内容がコマンドライン経由で渡された 0.0.0.0 に上書きされている。

$ python example.py                                       
server:
  port: 80
  host: 0.0.0.0

Data Classes (PEP 557) で設定の構造を定義する

OmegaConf は、Python 3.7 から導入された Data Classes を使って設定の構造を定義できる。 以下のサンプルコードでは、先ほどの server.portserver.host という要素が含まれる設定を定義している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from dataclasses import dataclass

from omegaconf import OmegaConf


@dataclass
class ServerStructure:
    port: int = 80
    host: str = '127.0.0.1'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()


def main():
    # Data Classes を使って設定を読み込む
    conf = OmegaConf.structured(RootStructure)
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、Data Classes を使って設定の構造を定義できていることがわかる。 また、デフォルト値も読み込むことができている。

$ python example.py                                       
server:
  port: 80
  host: 127.0.0.1

Data Classes の有無 (structured / non-structured) の違いについて

続いては、Data Classes を使う場合と、使わない場合で OmegaConf のインスタンスにどのような違いがあるか見ておく。 以下のサンプルコードでは辞書から生成した OmegaConf と Data Classes で生成した OmegaConf を比較している。 コメントで補足してあるけど、Data Classes から生成したものは struct モードになっている。 このモードでは、元々の定義には存在しない Key-Value ペアを設定に追加できない。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from dataclasses import dataclass

from omegaconf import OmegaConf
from omegaconf.errors import ConfigAttributeError


LOGGER = logging.getLogger(__name__)


@dataclass
class ConfigStructure:
    message: str = 'Hello, World!'


def main():
    # OmegaConf.create() で作ったコンフィグ
    dict_conf = OmegaConf.create({
        'message': 'Hello, World!'
    })
    # OmegaConf.structured() で作ったコンフィグ
    struct_conf = OmegaConf.structured(ConfigStructure)

    # 後者は struct フラグが有効になっている
    OmegaConf.is_struct(dict_conf)
    OmegaConf.is_struct(struct_conf)

    # struct でない場合は後から存在しない要素を追加できる
    dict_conf.hoge = 'fuga'
    # 一方で struct になっていると存在しない要素を追加できない
    try:
        struct_conf.hoge = 'fuga'
    except ConfigAttributeError as e:
        LOGGER.error(e)

    # この振る舞いは後から変更することもできる
    # たとえば、次のようにすると non struct にできる
    OmegaConf.set_struct(struct_conf, False)
    struct_conf.hoge = 'fuga'


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、struct モードな OmegaConf に存在しない Key-Value を設定しようとすると例外になることが確認できる。

$ python example.py                                       
Key 'hoge' not in 'ConfigStructure'
    full_key: hoge
    reference_type=Optional[ConfigStructure]
    object_type=ConfigStructure

Syntax for Variable Annotations (PEP 526) を使ったバリデーション

先ほどの例にもあったけど Data Classes のアトリビュートには型ヒントがつけられる。 以下のサンプルコードでは、型ヒントと合わない Value をアトリビュートに代入しようとしたときの振る舞いを確認している。 また、設定を後から上書きされたくないときに用いる OmegaConf のインスタンスを読み取り専用にする方法も試している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from dataclasses import dataclass

from omegaconf import OmegaConf
from omegaconf.errors import ValidationError
from omegaconf.errors import ReadonlyConfigError


LOGGER = logging.getLogger(__name__)


@dataclass
class ServerStructure:
    port: int = 80
    host: str = '127.0.0.1'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()


def main():
    # DataClass から読み込む
    conf = OmegaConf.structured(RootStructure)

    # 型ヒントが int になっている要素に対して...
    # OK: 当然 int の値は入る
    conf.server.port = 8080
    # OK: int として解釈できるときは自動的に変換される
    conf.server.port = '8080'
    # NG: int として解釈できないときは例外になる
    try:
        conf.server.port = 'Oops!'
    except ValidationError as e:
        LOGGER.error(e)

    # 値を上書きされたくないときは read only にする
    OmegaConf.set_readonly(conf, True)

    try:
        # NG: read only にしたコンフィグには代入できない
        conf.server.port = 8888
    except ReadonlyConfigError as e:
        LOGGER.error(e)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、型の合わない代入をしたり、読み取り専用のオブジェクトに代入をしたときに例外になることがわかる。

$ python example.py                                       
Value 'Oops!' could not be converted to Integer
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure
Cannot change read-only config container
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure

ちなみに Data Classes で読み込んだ OmegaConf のインスタンスを読み取り専用にする方法はもうひとつある。 それは Data Classes を定義するときにデコレータに frozen=True を設定すること。 こうすると、後から変更するまでもなく最初から OmegaConf のインスタンスが読み取り専用になる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging
from typing import List
from dataclasses import dataclass
from dataclasses import field

from omegaconf import OmegaConf
from omegaconf import ReadonlyConfigError


LOGGER = logging.getLogger(__name__)


# デフォルトで上書きさせたくないときは dataclass を frozen にする
@dataclass(frozen=True)
class RootStructure:
    name: str = 'Alice'
    friends: List[str] = field(default_factory=lambda: ['Bob', 'Carol'])


def main():
    conf = OmegaConf.structured(RootStructure)

    # デフォルトで上書きができなくなっている
    try:
        conf.friends.append('Daniel')
    except ReadonlyConfigError as e:
        LOGGER.error(e)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、読み取り専用なのにリストに要素を追加したことで例外となることがわかる。

$ python example.py                                       
ListConfig is read-only
    full_key: friends[2]
    reference_type=List[str]
    object_type=list

項目ごとに必須かオプションかを指定する

また、項目が必須かオプションかは、次のように指定できる。

  • アトリビュートのデフォルト値を omegaconf.MISSING にすることで必須のパラメータにできる
  • アトリビュートの型ヒントを typing.Optional にすることでオプションのパラメータにできる
    • 言いかえると Value が None でも構わない
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from dataclasses import dataclass
from typing import Optional

from omegaconf import OmegaConf
from omegaconf import MISSING
from omegaconf import MissingMandatoryValue
from omegaconf import ValidationError


LOGGER = logging.getLogger(__name__)


@dataclass
class ServerStructure:
    # デフォルト値に MISSING を設定しておくと指定が必須のパラメータになる
    port: int = MISSING
    # 型ヒントが Optional になっていると None を入れても良い
    host: Optional[str] = '127.0.0.1'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()


def main():
    conf = OmegaConf.structured(RootStructure)

    try:
        # 中身が空のままで読み取ろうとすると例外になる
        conf.server.port
    except MissingMandatoryValue as e:
        LOGGER.error(e)

    # ちゃんと中身を入れれば例外は出なくなる
    conf.server.port = 8080
    print(conf.server.port)

    # また、デフォルトでは None を入れようとしても例外になる
    try:
        conf.server.port = None
    except ValidationError as e:
        LOGGER.error(e)

    # 一方、型ヒントが Optional になっていれば None を入れても例外にならない
    conf.server.host = None


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、項目ごとに必須かオプションかが制御できていることがわかる。

$ python example.py                                       
Missing mandatory value: server.port
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure
8080
child 'server.port' is not Optional
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure

Interpolation と Custom Resolver について

OmegaConf には Interpolation という機能がある。 この機能を使うと、典型的にはある Value を別の Value を使って定義できる。 文字だと説明しにくいけど Literal String Interpolation (PEP 498 / いわゆる f-string) とかテンプレートの考え方に近い。 また、Interpolation の指定に : (コロン) でプレフィックスをつけると、それを解釈する Resolver が指定できる。 Resolver は自分で書くこともできて、使いこなすとだいぶ便利そうな感じがする。

正直、ここは文字で説明するのが難しいので以下のサンプルコードを見てもらいたい。 たとえば ClientStructureurl というアトリビュートは、別の場所で定義した ServerStructure の内容を参照して組み立てている。 また、UserStructurename は、プレフィックスに env をつけることで環境変数 USER から読み込むようになっている。 そして、SystemStructuredatesome_value には、自分で Resolver を書いて OmegaConf に登録している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging
from datetime import datetime
from dataclasses import dataclass

from omegaconf import OmegaConf


LOGGER = logging.getLogger(__name__)


@dataclass
class ServerStructure:
    port: int = 80
    host: str = '127.0.0.1'


@dataclass
class ClientStructure:
    # Interpolation を使うと別の場所にある値を埋め込むことができる
    url: str = 'http://${server.host}:${server.port}'


@dataclass
class UserStructure:
    # 環境変数を読み込むこともできる
    name: str = '${env:USER}'


# Interpolation の解決をカスタマイズする (引数なし)
def today_resolver() -> str:
    return datetime.now().strftime('%Y-%m-%d')


# Interpolation の解決をカスタマイズする (引数あり)
def double_resolver(n: str) -> int:
    # 解決する時点では文字列で渡される点に注意する
    return int(n) * 2


@dataclass
class SystemStructure:
    date: str = '${today:}'
    some_value: int = '${double:100}'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()
    client: ClientStructure = ClientStructure()
    user: UserStructure = UserStructure()
    system: SystemStructure = SystemStructure()


def main():
    conf = OmegaConf.structured(RootStructure)

    # カスタムリゾルバを登録する
    OmegaConf.register_resolver('today', today_resolver)
    OmegaConf.register_resolver('double', double_resolver)

    print(conf.client.url)
    print(conf.user.name)
    print(conf.system.date)
    print(conf.system.some_value)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、最初に参照している conf.client.urlconf.server.hostconf.server.port のデフォルト値で組み立てられていることが確認できる。 次に参照している conf.user.name は、実行したシェルの環境変数を読み込んでいる。 その次に参照している conf.system.datetoday_resolver() によって、今日の日付が設定されている。 そして、最後に参照している conf.system.some_value は、元の値である 100double_resolver() が 2 倍にしている。

$ python example.py                                       
http://127.0.0.1:80
amedama
2020-11-05
200
$ echo $USER                                
amedama
$ date +%Y-%m-%d                   
2020-11-05

補足

最初に、発展途上なところとして以下の 2 点を挙げた。

  • Value のバリデーションをカスタマイズする仕組みがない
  • プリミティブ型以外を Value として扱うための仕組みが限られている

このうち、最初の方については Flexible function and variable annotations (PEP 593) を使った実装が検討されているようだ。

github.com

後の方については、まあ Custom Resolver を駆使して何とかする方法もあるかな?

そんな感じで。

Python: MLflow Projects を使ってみる

MLflow は MLOps に関連した OSS のひとつ。 いくつかのコンポーネントに分かれていて、それぞれを必要に応じて独立して使うことができる。 今回は、その中でも MLflow Projects というコンポーネントを使ってみる。 MLflow Projects を使うと、なるべく環境に依存しない形で、ソフトウェアのコードをどのように動かすかを示すことができる。

機械学習に限らず、特定のリポジトリのソースコードをどのように動かすかはプロジェクトによって異なる。 よくあるのは Makefile やシェルスクリプトを用意しておくパターンだと思う。 ただ、そういったスクリプトをプロジェクトごとに、環境依存のできるだけ少ない形で書いていくのはなかなかつらい作業になる。 MLflow Projects を使うと、そういった作業の負担を軽減できる可能性がある。

今回使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
$ python -V
Python 3.8.6
$ pip list | grep -i mlflow
mlflow                    1.11.0
$ docker version   
Client: Docker Engine - Community
 Cloud integration  0.1.18
 Version:           19.03.13
 API version:       1.40
 Go version:        go1.13.15
 Git commit:        4484c46d9d
 Built:             Wed Sep 16 16:58:31 2020
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.13
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       4484c46d9d
  Built:            Wed Sep 16 17:07:04 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          v1.3.7
  GitCommit:        8fba4e9a7d01810a393d5d25a3621dc101981175
 runc:
  Version:          1.0.0-rc10
  GitCommit:        dc9208a3303feef5b3839f4323d9beb36df0a9dd
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

もくじ

下準備

あらかじめ、今回使うパッケージをインストールしておく。

$ pip install mlflow

また、Python のパッケージ以外にも Docker をインストールしておこう。

$ brew cask install docker

最も単純なサンプル

MLflow Projects を最低限使うのに必要なのは MLproject という YAML ファイルを用意することだけ。 ここにプロジェクトを実行するときのエントリポイントを記述していく。 基本的には、単純にシェルで実行するコマンドを書く。

以下は wget(1) を使って Iris データセットの CSV をダウンロードする MLproject を用意している。 エントリポイントは複数書けるけどデフォルトでは main という名前が使われる。

$ cat << 'EOF' > MLproject               
name: example

entry_points:
  main:
    command: "wget -P data https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
EOF

上記のファイルが用意できたら、実際に実行してみよう。 MLproject がある場合には mlflow コマンドの run サブコマンドが使えるようになる。 たとえば、以下のようにして実行する。 .MLproject のある場所を表していて、--no-conda は Conda の仮想環境を使わずに実行することを示す。

$ mlflow run . --no-conda
2020/10/19 19:31:56 INFO mlflow.projects.utils: === Created directory /var/folders/1f/2k50hyvd2xq2p4yg668_ywsh0000gn/T/tmp84mtkoa7 for downloading remote URIs passed to arguments of type 'path' ===
2020/10/19 19:31:56 INFO mlflow.projects.backend.local: === Running command 'wget -P data https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv' in run with ID '96c013ed298f48be81ceafc9e64bb726' === 

...

2020-10-19 19:31:57 (8.16 MB/s) - `data/iris.csv' へ保存完了 [3858/3858]

2020/10/19 19:31:57 INFO mlflow.projects: === Run (ID '96c013ed298f48be81ceafc9e64bb726') succeeded ===

すると、次のようにファイルがダウンロードされることがわかる。

$ ls data
iris.csv
$ head data/iris.csv 
sepal_length,sepal_width,petal_length,petal_width,species
5.1,3.5,1.4,0.2,setosa
4.9,3.0,1.4,0.2,setosa
4.7,3.2,1.3,0.2,setosa
4.6,3.1,1.5,0.2,setosa
5.0,3.6,1.4,0.2,setosa
5.4,3.9,1.7,0.4,setosa
4.6,3.4,1.4,0.3,setosa
5.0,3.4,1.5,0.2,setosa
4.4,2.9,1.4,0.2,setosa

Docker コンテナで実行する

ただ、先ほどの例はシステムに wget(1) がインストールされていることを仮定している。 しかしながら、誰もがシステムに wget(1) をインストールしているとは限らないだろう。 かといって、各プラットフォームごとに wget(1) をインストールするスクリプトや、方法について README には書きたくない。 そこで、続いては MLflow Projects を Docker コンテナ上で実行できるようにしてみよう。

MLflow Projects を Docker コンテナ上で実行する場合には、docker_env という要素を MLproject ファイルに書く。 たとえば、次のサンプルでは example/mlflow-docker というイメージでこのプロジェクトを実行しますよ、という宣言になる。

$ cat << 'EOF' > MLproject               
name: example

docker_env:
  image: example/mlflow-docker

entry_points:
  main:
    command: "wget -P data https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
EOF

ただ、上記を実行するにはシステムのローカルないし Docker リポジトリに example/mlflow-docker というイメージが必要になる。 そこで、たとえば次のような Dockerfile を用意してみよう。 これで、wget(1) のインストールされた Ubuntu 20.04 LTS のイメージがビルドできる。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:20.04

RUN apt-get update \
 && apt-get -yq dist-upgrade \
 && apt-get install -yq \
      wget \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*
EOF

イメージのビルド手順も設定ファイルに書きたいので Docker Compose を使うことにする。

$ cat << 'EOF' > docker-compose.yaml
version: "3.8"

services:
  mlflow:
    image: example/mlflow-docker
    build:
      context: .
EOF

これで特に何を考えずイメージがビルドできる。

$ docker-compose build
Building mlflow
Step 1/2 : FROM ubuntu:20.04

...

Successfully tagged example/mlflow-docker:latest

システムのローカルに必要なイメージが登録されたことを確認しよう。

$ docker image list | grep example
example/mlflow-docker                latest                                           09c03e33daea        3 minutes ago       111MB

動作確認の邪魔になるので、先ほどダウンロードしたファイルは一旦削除しておこう。

$ rm -rf data

それでは、満を持して MLflow Projects を実行してみよう。 今度は実行環境として Docker を使うことが設定ファイルからわかるので --no-conda オプションをつける必要はない。

$ mlflow run .
2020/10/19 19:50:16 INFO mlflow.projects.docker: === Building docker image example ===

...

2020-10-19 10:50:18 (5.87 MB/s) - 'data/iris.csv' saved [3858/3858]

2020/10/19 19:50:19 INFO mlflow.projects: === Run (ID '18f4068e82434b119dc7cf9979082f9b') succeeded ===

しめしめ、これでカレントディレクトリにファイルがダウンロードされるはず…と思っていると何も見当たらない。

$ ls
Dockerfile      docker-compose.yaml
MLproject       mlruns

なぜ、こうなるか。 MLflow Projects は、Docker コンテナを実行するときに、ホストのディレクトリをコンテナにコピーしている。 あくまで、共有ではなくコピーなので、コンテナ上にファイルをダウンロードしたからといってホストには反映されない。

この問題を回避するには、ホストのボリュームをコンテナにマウントする必要がある。 たとえば、次のようにしてホストのディレクトリをコンテナの /mnt にマウントできる。 そして、実行するコマンドでは、保存先のディレクトリを /mnt 以下にする。

$ cat << 'EOF' > MLproject               
name: example

docker_env:
  image: example/mlflow-docker
  volumes: ["$(pwd):/mnt"]

entry_points:
  main:
    command: "wget -P /mnt/data https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
EOF

もう一度実行してみよう。

$ mlflow run .
2020/10/19 19:54:32 INFO mlflow.projects.docker: === Building docker image example ===

...

2020-10-19 10:54:34 (776 KB/s) - '/mnt/data/iris.csv' saved [3858/3858]

2020/10/19 19:54:34 INFO mlflow.projects: === Run (ID '4372f3f53dbf478bbc8ac96b4f7d5811') succeeded ===

すると、次のようにホスト側にディレクトリとファイルができる。

$ ls
Dockerfile      MLproject       data            docker-compose.yaml mlruns
$ head data/iris.csv 
sepal_length,sepal_width,petal_length,petal_width,species
5.1,3.5,1.4,0.2,setosa
4.9,3.0,1.4,0.2,setosa
4.7,3.2,1.3,0.2,setosa
4.6,3.1,1.5,0.2,setosa
5.0,3.6,1.4,0.2,setosa
5.4,3.9,1.7,0.4,setosa
4.6,3.4,1.4,0.3,setosa
5.0,3.4,1.5,0.2,setosa
4.4,2.9,1.4,0.2,setosa

エントリポイントを増やしてみる

続いては、例としてエントリポイントを増やしてみることにしよう。 example という Python のパッケージを用意する。

$ mkdir -p example
$ touch example/__init__.py

そして、その中に preprocess.py というモジュールを追加する。 これは CSV ファイルから読み込んだ Pandas の DataFrame を Pickle にして保存するコードになっている。

$ cat << 'EOF' > example/preprocess.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CSV ファイルから読み込んだ DataFrame を Pickle として永続化する"""

import os
import logging
import pathlib

import pandas as pd

LOGGER = logging.getLogger(__name__)


def main():
    logging.basicConfig(level=logging.INFO)

    # データの置き場所
    data_dir = pathlib.Path('/mnt/data')

    # CSV ファイルを読み込む
    raw_data_file = data_dir / 'iris.csv'
    LOGGER.info(f'load data from {raw_data_file}')
    df = pd.read_csv(str(raw_data_file))

    LOGGER.info(f'data shape: {df.shape}')

    # Pickle として永続化する
    parsed_data_file = data_dir / 'iris.pickle'
    LOGGER.info(f'save data to {parsed_data_file}')
    df.to_pickle(parsed_data_file)


if __name__ == '__main__':
    main()
EOF

以下のようにエントリポイントを fetchpreprocess に分離してみる。

$ cat << 'EOF' > MLproject
name: example

docker_env:
  image: example/mlflow-docker
  volumes: ["$(pwd):/mnt"]

entry_points:
  fetch:
    command: "wget -P /mnt/data https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv"
  preprocess:
    command: "python3 -m example.preprocess"
EOF

先ほどのモジュールを見るとわかる通り、動作には Pandas が必要になる。 そこで、Docker イメージも次のように更新しておく。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:20.04

RUN apt-get update \
 && apt-get -yq dist-upgrade \
 && apt-get install -yq \
      wget \
      python3-pip \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/* \
 && pip3 install --user pandas
EOF
$ docker-compose build

新しく追加した preprocess エントリポイントを実行してみよう。 エントリポイントを指定するときは -e オプションを使う。

$ mlflow run -e preprocess .
2020/10/19 20:11:00 INFO mlflow.projects.docker: === Building docker image example ===

...

INFO:__main__:load data from /mnt/data/iris.csv
INFO:__main__:data shape: (150, 5)
INFO:__main__:save data to /mnt/data/iris.pickle
2020/10/19 20:11:02 INFO mlflow.projects: === Run (ID '6bae8e1f7d3e4bde85fa8e9922d2938d') succeeded ===

これで、次のように Pickle フォーマットのファイルができあがる。

$ ls data       
iris.csv    iris.pickle
$ python -m pickle data/iris.pickle
     sepal_length  sepal_width  petal_length  petal_width    species
0             5.1          3.5           1.4          0.2     setosa
1             4.9          3.0           1.4          0.2     setosa
2             4.7          3.2           1.3          0.2     setosa
3             4.6          3.1           1.5          0.2     setosa
4             5.0          3.6           1.4          0.2     setosa
..            ...          ...           ...          ...        ...
145           6.7          3.0           5.2          2.3  virginica
146           6.3          2.5           5.0          1.9  virginica
147           6.5          3.0           5.2          2.0  virginica
148           6.2          3.4           5.4          2.3  virginica
149           5.9          3.0           5.1          1.8  virginica

[150 rows x 5 columns]

エントリポイントをまとめる

ただ、先ほどのように複数のエントリポイントがあると何をいつどのように実行すれば良いのかが不明瞭になる。 それでは MLflow Projects を導入した意味が薄れてしまうかもしれない。 おそらく、エントリポイントは先ほどよりももっと大きなタスクで分けた方が良いと思う。 たとえば、モデルの学習と結果の可視化のような粒度であれば、分けていても混乱は招かないかもしれない。 あまり巨大すぎるエントリポイントを作るのも、後から取り回しが悪くなるだろう。

さて、前置きが長くなったけど、ここではあくまで一例としてエントリポイントを Metaflow を使ってまとめてみよう。 まずはデータのダウンロード部分も Python のコードにしてしまう。 めんどくさければ subprocess モジュールを使って wget(1) をキックするだけでも良いと思う。

$ cat << 'EOF' > example/fetchdata.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""データセットを取得して CSV として保存する"""

import logging
import pathlib
import os
from urllib.parse import urlparse

import requests

LOGGER = logging.getLogger(__name__)

DATASET_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv'


def main():
    logging.basicConfig(level=logging.INFO)

    # データの置き場所
    data_dir = pathlib.Path('/mnt/data')

    # ディレクトリがなければ作る
    os.makedirs(str(data_dir), exist_ok=True)

    # 保存先のファイルパス
    url_path = urlparse(DATASET_URL).path
    save_filepath = data_dir / pathlib.Path(url_path).name

    # データセットとの接続を開く
    LOGGER.info(f'starting download: {DATASET_URL}')
    with requests.get(DATASET_URL, stream=True) as r:
        # 2XX でないときは例外にする
        r.raise_for_status()
        # 書き込み先のファイルを開く
        with open(save_filepath, mode='wb') as w:
            # チャンク単位で書き込んでいく
            for chunk in r.iter_content(chunk_size=8192):
                w.write(chunk)
    LOGGER.info(f'download complete: {save_filepath}')


if __name__ == '__main__':
    main()
EOF

そして、次のようにデータの取得と加工をパイプライン化してみる。 ここでは Metaflow を使ったけど、別に何を使っても良いと思う。 手軽だったので選んだに過ぎない。

$ cat << 'EOF' > entrypoint.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import metaflow

from example import fetchdata
from example import preprocess


class MyFlow(metaflow.FlowSpec):

    @metaflow.step
    def start(self):
        self.next(self.fetch)

    @metaflow.step
    def fetch(self):
        fetchdata.main()
        self.next(self.preprocess)

    @metaflow.step
    def preprocess(self):
        preprocess.main()
        self.next(self.end)

    @metaflow.step
    def end(self):
        pass


if __name__ == '__main__':
    MyFlow()
EOF

あとは、次のようにパイプラインを実行するように MLproject を編集する。 説明が遅くなったけど、次のように可変な部分はパラメータとして与えることができる。

$ cat << 'EOF' > MLproject               
name: example

docker_env:
  image: example/mlflow-docker
  volumes: ["$(pwd):/mnt"]
  environment: [["USERNAME", "$(whoami)"]]

entry_points:
  main:
    parameters:
      command:
        type: string
        default: run
    command: "python3 -m entrypoint {command}"
EOF

動作に必要なパッケージが増えたので、Docker イメージも編集する。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:20.04

RUN apt-get update \
 && apt-get -yq dist-upgrade \
 && apt-get install -yq \
      wget \
      python3-pip \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/* \
 && pip3 install --user pandas requests metaflow
EOF
$ docker-compose build

先ほどダウンロードして加工したファイルは動作確認の邪魔になるので一旦削除しよう。

$ rm -rf data

ディレクトリ構成は以下のようになる。

$ ls     
Dockerfile      docker-compose.yaml example
MLproject       entrypoint.py       mlruns

それでは、実行してみよう。

$ mlflow run .
2020/10/19 20:24:57 INFO mlflow.projects.docker: === Building docker image example ===

...

2020-10-19 11:25:04.898 Done!
2020/10/19 20:25:05 INFO mlflow.projects: === Run (ID 'd9dd8d4daa264db99004142b60640307') succeeded ===

これで、次のようにファイルができる。

$ ls data 
iris.csv    iris.pickle

Python のコードから実行する

ちなみに、これまでの例は MLflow Projects を mlflow run コマンドから実行していた。 もちろん Python のコードからも実行できる。

たとえば Python の REPL を起動しよう。

$ python

あとは mlflow をインポートして mlflow.projects.run() を呼ぶだけ。

>>> import mlflow
>>> mlflow.projects.run(uri='.')

いじょう。

参考

複数ステップで構成された MLflow Projects のサンプルプロジェクトが以下にある。

github.com