CUBE SUGAR CONTAINER

技術系のこと書きます。

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

リモートの Docker ホストでコンテナを SSH Port Forward 経由で動かす

今回は、Docker クライアントをリモートの Docker ホストに SSH Port Forward 経由で接続させてコンテナを操作する方法を試してみる。

まず、Docker クライアントの環境は次のとおり。 macOS に Docker for Mac をインストールしてある。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
$ 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

リモートの Docker ホストの環境は次のとおり。 Ubuntu 18.04 LTS に Docker をインストールしてある。

$ cat /etc/*-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.4 LTS"
NAME="Ubuntu"
VERSION="18.04.4 LTS (Bionic Beaver)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 18.04.4 LTS"
VERSION_ID="18.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=bionic
UBUNTU_CODENAME=bionic
$ sudo docker version
Client:
 Version:           19.03.6
 API version:       1.40
 Go version:        go1.12.17
 Git commit:        369ce74a3c
 Built:             Fri Feb 28 23:45:43 2020
 OS/Arch:           linux/amd64
 Experimental:      false

Server:
 Engine:
  Version:          19.03.6
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.17
  Git commit:       369ce74a3c
  Built:            Wed Feb 19 01:06:16 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.3.3-0ubuntu1~18.04.2
  GitCommit:        
 runc:
  Version:          spec: 1.0.1-dev
  GitCommit:        
 docker-init:
  Version:          0.18.0
  GitCommit:        

ようするに、上記の macOS を Docker クライアント、Ubuntu を Docker サーバとして動作させたい、ということ。

Docker デーモンをローカルホストの TCP:2376 で待ち受けるようにする

デフォルトでは、Docker デーモンは Unix ドメインソケット経由で制御されるように設定されている。 しかし、これだとリモートから扱う上で都合が悪い。 そこで、まずは Docker ホストのデーモンを TCP で待ち受けるように変更する。

まずは、リモートにある Docker ホストにログインする。 この段階では、シェルの操作ができさえすれば良いので、別にログインの方法は何でも構わない。

$ ssh <hostname>

はじめに、Docker の設定が入った systemd のコンフィグをバックアップしておく。

$ sudo cp /lib/systemd/system/docker.service{,.orig}

そして、次のように dockerd のオプションとしてローカルホストの 2376 ポートで待ち受けるようにオプションを追加する。

$ diff -u /lib/systemd/system/docker.service{.orig,}
--- /lib/systemd/system/docker.service.orig 2020-10-10 14:22:10.482817997 +0000
+++ /lib/systemd/system/docker.service  2020-10-10 14:22:56.297714001 +0000
@@ -11,7 +11,7 @@
 # the default is not to use systemd for cgroups because the delegate issues still
 # exists and systemd currently does not support the cgroup feature set required
 # for containers run by docker
-ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
+ExecStart=/usr/bin/dockerd -H fd:// -H tcp://127.0.0.1:2376 --containerd=/run/containerd/containerd.sock
 ExecReload=/bin/kill -s HUP $MAINPID
 TimeoutSec=0
 RestartSec=2

ちなみに、上記は APT でインストールしたパッケージが管理しているファイルなので、直接編集するのはあまりお行儀が良くない。

$ dpkg-query -L docker.io | grep systemd
/lib/systemd
/lib/systemd/system
/lib/systemd/system/docker.service
/lib/systemd/system/docker.socket

編集したらサービスを再起動する。

$ sudo systemctl daemon-reload
$ sudo systemctl restart docker

これで Docker デーモンがローカルホストの TCP:2376 ポートで待ち受けるようになる。

$ ss -tlnp | grep 2376
LISTEN0      128                             127.0.0.1:2376        0.0.0.0:*

これでリモートの Docker ホストの準備は整った。

クライアントからリモートの Docker デーモンを操作する

ここからは Docker クライアントの操作になる。

リモートの Docker ホストに、SSH Port Forward を有効にしてあらためてログインする。

$ ssh -L 2376:localhost:2376 <hostname>

これで、ローカルの TCP:2376 ポートにアクセスすると、リモートの TCP:2376 ポートにつながることになる。

$ lsof -i -P | grep -i listen | grep 2376
ssh       7028 amedama    5u  IPv6 0xda00d64e20c3a761      0t0  TCP localhost:2376 (LISTEN)
ssh       7028 amedama    6u  IPv4 0xda00d64e24baa8d1      0t0  TCP localhost:2376 (LISTEN)

あとは、シェル変数の DOCKER_HOSTtcp://127.0.0.1:2376 を指定して docker コマンドを使うだけ。

$ DOCKER_HOST=tcp://127.0.0.1:2376 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:
 Engine:
  Version:          19.03.6
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.17
  Git commit:       369ce74a3c
  Built:            Wed Feb 19 01:06:16 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.3.3-0ubuntu1~18.04.2
  GitCommit:        
 runc:
  Version:          spec: 1.0.1-dev
  GitCommit:        
 docker-init:
  Version:          0.18.0
  GitCommit:        

上記から、クライアントとサーバで Docker のバージョンが異なっていることが確認できる。

また、シェル変数を有効にしたときとしないときで uname -a の結果が変わっていることもわかる。 コンテナはホストのカーネルを共有するため、このようなことになる。

$ docker container run -it alpine:latest uname -a
Linux 829155145f04 4.19.76-linuxkit #1 SMP Tue May 26 11:42:35 UTC 2020 x86_64 Linux
$ DOCKER_HOST=tcp://127.0.0.1:2376 docker container run -it alpine:latest uname -a
Linux f4981d8001f3 4.15.0-111-generic #112-Ubuntu SMP Thu Jul 9 20:32:34 UTC 2020 x86_64 Linux

これで、ローカルの docker コマンドを使って、リモートにある Docker ホスト上のコンテナを操作できるようになった。

注意点

ただし、このやり方にはいくつか注意点もある。

たとえば、このやり方ではコンテナが動いているのはあくまでリモートのホストになる。 そのため、ホストのボリュームをコンテナでマウントしようとしたときに使われるディレクトリはリモートのものになる。

確認しておこう。 まずはリモートにある Docker ホストにディレクトリを作ってファイルを用意する。

$ mkdir -p /tmp/mnt
$ echo "Remote" > /tmp/mnt/loc.txt

そして、ローカルの Docker クライアントにも同じようにディレクトリを作って区別できるようにファイルを用意する。

$ mkdir -p /tmp/mnt
$ echo "Local" > /tmp/mnt/loc.txt

上記で作ったディレクトリをマウントしたコンテナを起動してみよう。

$ export DOCKER_HOST=tcp://127.0.0.1:2376
$ docker container run \
  -v /tmp/mnt:/mnt \
  -it ubuntu:latest \
  bash

そして、ファイルの中身を確認する。

# cat /mnt/loc.txt 
Remote

うん、リモートだね。

あとは、受け付けるアドレスをローカルホストに絞っているとはいえ、これだとホストにログインできるユーザは誰でも Docker が使える。 もし、それが好ましくない状況であれば、以下のようにクライアントからのアクセス制御をした方が良いと思われる。

docs.docker.com

いじょう。

Python: LIME (Local Interpretable Model Explanations) を LightGBM と使ってみる

今回は、機械学習モデルの解釈可能性を向上させる手法のひとつである LIME (Local Interpretable Model Explanations) を LightGBM と共に使ってみる。 LIME は、大局的には非線形なモデルを、局所的に線形なモデルを使って近似することで、予測の解釈を試みる手法となっている。

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

$ sw_vers                            
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
$ python -V                                      
Python 3.8.5

もくじ

下準備

まずは、下準備として使うパッケージをインストールしておく。

$ pip install lime scikit-learn lightgbm jupyterlab

LIME は Jupyter の WebUI に可視化する API を提供している。 そのため、今回は Jupyter Lab 上でインタラクティブに試していくことにしよう。

$ jupyter lab

Boston データセットを LightGBM で学習させる

とりあえず、LIME を使うにしても学習済みモデルがないと話が始まらない。 そこで、まずは scikit-learn から Boston データセットを読み込む。

>>> from sklearn import datasets
>>> dataset = datasets.load_boston()
>>> train_x, train_y = dataset.data, dataset.target
>>> feature_names = dataset.feature_names

続いて LightGBM の Early Stopping で検証用データにするためにデータセットを分割しておく。

>>> from sklearn.model_selection import train_test_split
>>> tr_x, val_x, tr_y, val_y = train_test_split(train_x, train_y,
...                                             shuffle=True,
...                                             random_state=42,
...                                            )

それぞれを LightGBM のデータ表現にする。

>>> import lightgbm as lgb
>>> lgb_train = lgb.Dataset(tr_x, tr_y)
>>> lgb_val = lgb.Dataset(val_x, val_y, reference=lgb_train)

上記のデータセットを LightGBM で回帰タスクとして学習させる。

>>> lgbm_params = {
...     'objective': 'regression',
...     'metric': 'rmse',
...     'verbose': -1,
... }
>>> booster = lgb.train(lgbm_params,
...                     lgb_train,
...                     valid_sets=lgb_val,
...                     num_boost_round=1_000,
...                     early_stopping_rounds=100,
...                     verbose_eval=50,
...                     )
Training until validation scores don't improve for 100 rounds
[50]  valid_0's rmse: 3.41701
[100]  valid_0's rmse: 3.30722
[150] valid_0's rmse: 3.24115
[200]  valid_0's rmse: 3.22073
[250] valid_0's rmse: 3.2121
[300]  valid_0's rmse: 3.21216
[350] valid_0's rmse: 3.21811
Early stopping, best iteration is:
[271]  valid_0's rmse: 3.20437

これで LightGBM の学習済みモデルが手に入った。

LIME を使って局所的な解釈を得る

続いては学習モデルの予測を LIME で解釈してみよう。

今回使ったのは構造化されたテーブルデータなので LimeTabularExplainer を用いる。 このクラスに学習データやタスクの内容といった情報を渡してインスタンスを作る。 なお、LIME 自体はテーブルデータ以外にも自然言語処理や画像認識など幅広い応用が効くらしい。

>>> from lime.lime_tabular import LimeTabularExplainer
>>> explainer = LimeTabularExplainer(training_data=tr_x,
...                                  feature_names=feature_names,
...                                  training_labels=tr_y,
...                                  mode='regression',
...                                  verbose=True,
...                                  )

上記を使うには、ひとつの特徴ベクトルを受け取って予測値を返す関数が必要になる。 そこで、今回は次のように定義しておく。

>>> predict_func = lambda x: booster.predict(x,
...                                          num_iteration=booster.best_iteration)

上記を使って、試しに学習データの先頭要素に対する予測を解釈してみよう。 ここでは、線形モデルを使って近似させたときの切片と予測値、そして本来のモデルの予測値が出力される。

>>> explanation = explainer.explain_instance(tr_x[0], predict_func)
Intercept 20.92723014344181
Prediction_local [37.81548009]
Right: 37.949401123930926

次のようにすると、結果が Notebook 上で視覚的に確認できる。

>>> explanation.show_in_notebook(show_table=False)

結果からは、予測されるレンジの中で要素がどこにあるか、そして各特徴量が線形モデルでどのように作用しているかがわかる。 たとえば、近似した線形モデルでは特徴量の RM6.66 以上なので、予測値を 8.71 押し上げる効果があるようだ。

f:id:momijiame:20201009235425p:plain
LIME による予測の解釈

ちなみに、学習データの先頭要素の中身はこんな感じ。

>>> import prettyprint
>>> pprint(dict(zip(feature_names, tr_x[0])))
{'CRIM': 0.09103,
 'ZN': 0.0,
 'INDUS': 2.46,
 'CHAS': 0.0,
 'NOX': 0.488,
 'RM': 7.155,
 'AGE': 92.2,
 'DIS': 2.7006,
 'RAD': 3.0,
 'TAX': 193.0,
 'PTRATIO': 17.8,
 'B': 394.12,
 'LSTAT': 4.82}

条件と、予測値に対してどのように作用しているかは as_list() メソッドで得られる。

>>> pprint(explanation.as_list())
[('RM > 6.66', 8.711363613654798),
 ('LSTAT <= 6.87', 7.923627881873513),
 ('TAX <= 279.00', 1.018446806095536),
 ('78.10 < AGE <= 93.85', -0.5986522163835298),
 ('0.45 < NOX <= 0.54', 0.5314517960646176),
 ('2.08 < DIS <= 3.11', 0.4316641373927205),
 ('16.60 < PTRATIO <= 18.60', 0.29967804190197933),
 ('INDUS <= 5.13', 0.21697123227933096),
 ('0.08 < CRIM <= 0.27', -0.17672992076030344),
 ('RAD <= 4.00', -0.16478059886664842)]

たとえば切片と上記の要素をすべて足すと、最初に得られた線形モデルで近似した予測値になる。

>>> sum(value for key, value in explanation.as_list()) + explanation.intercept[0]
38.33991784140311

上記はあくまで線形モデルを使った近似なので、本来のモデルのアルゴリズムにもとづいて説明しているわけではないはず。 とはいえ、局所的に特徴量がどのように作用しているか確認できるのはなかなか面白い。

所感としては、大局的な解釈にも応用が効く SHAP の方が使い勝手は良さそうかな。

blog.amedama.jp

参考

github.com

arxiv.org