Pythonスクリプトのテンプレートを考えてみた

Pythonの書き捨てスクリプトを書く速度を上げられるように、Pythonスクリプトのテンプレートを考えてみました。

経験上、どんなスクリプトでも以下を心がけると良いことが多いです。

  • コードは全部メイン関数の中に入れて、グローバル変数は許容しない
  • 出力はなるべくprintではなくloggerを使い、常にファイル保存を心がける
  • ログは同内容を標準エラー出力とファイル出力に流す
  • 実行時引数や処理時間も常にログに残す
  • ファイルの先頭にスクリプトの概要を書く

テンプレートは以下です。 https://github.com/minus9d/python_exercise/blob/master/template/template.py にも同じものがあります。

"""
○○するスクリプト
"""
import argparse
import logging
from pathlib import Path
import sys
import time


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()

    parser.add_argument(
        '-o', '--outdir', default='./outdir',
        help="出力ディレクトリ")

    args = parser.parse_args()
    return args


def setup_logger(logger_path: Path) -> logging.Logger:
    """loggerオブジェクトを生成

    Args:
        logger_path (Path): ログの書き込み先

    Returns:
        logger: loggerオブジェクト
    """

    logger = logging.getLogger('my_logger')
    logger.setLevel(logging.DEBUG)

    stream_handler = logging.StreamHandler()
    file_handler = logging.FileHandler(logger_path)

    stream_handler.setLevel(logging.DEBUG)
    file_handler.setLevel(logging.DEBUG)

    handler_format = logging.Formatter('%(asctime)s : %(levelname)s : %(message)s')
    stream_handler.setFormatter(handler_format)
    file_handler.setFormatter(handler_format)

    logger.addHandler(stream_handler)
    logger.addHandler(file_handler)

    return logger


def write_arguments(logger: logging.Logger, args: argparse.Namespace):
    """実行時引数をloggerに記録
    """
    logger.info(f"sys.argv:")
    logger.info(f"    {sys.argv}")
    logger.info(f"parsed args:")
    for key, value in vars(args).items():
        logger.info(f"    {key}: {value}")


def main():
    start_time = time.perf_counter()

    args = parse_args()

    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)

    logger_path = outdir / 'log.log'
    logger = setup_logger(logger_path)

    logger.info('start')
    write_arguments(logger, args)

    # ここに実処理を書く

    end_time = time.perf_counter()
    logger.info('finished ({:.2f} sec)'.format(end_time - start_time))


if __name__ == '__main__':
    main()

bashスクリプトでset -uしているときの unbound variable を回避する

bashスクリプトの定番テクニックで、set -uを冒頭に書いておくことで、未定義の変数をうっかり使った時点でスクリプトを終了させるようにする、というものがあります( 参考 )。しかし、PATHやPYTHONPATHにパスを追加するときに困ることがあります。例えば、以下のようにPYTHONPATHにパスを追加するスクリプト

#!/bin/bash
set -u

export PYTHONPATH=/path/to/somewhere:${PYTHONPATH}

を実行するとき、もしPYTHONPATHが未set状態なら、

bash_undefined.sh: line 4: PYTHONPATH: unbound variable

というエラーが出てしまいます。

回避方法はいくつかありそうです。

-vを使う方法

bash 4.2から、-vを使うことで変数がsetされているかどうかを確認できるようになったそうです ( shell - How to check if a variable is set in Bash - Stack Overflow )。これを使うと以下のようにかけます。

#!/bin/bash
set -u

PATH1=/path/to/somewhere
if [[ -v PYTHONPATH ]]; then
    export PYTHONPATH=${PATH1}:${PYTHONPATH}
else
    export PYTHONPATH=${PATH1}
fi

一時的にset -uをやめる方法

set +uset -uで挟んだ部分だけ未定義の変数が使えるようになります。

#!/bin/bash
set -u

set +u
export PYTHONPATH=/path/to/somewhere:${PYTHONPATH}
set -u

Shell Parameter Expansionを使う方法

Assigning default values to shell variables with a single command in bash - Stack Overflow によると、例えば${VARIABLE:-default}と書くとき、もし変数${VARIABLE}が未定義ならかわりにdefaultという文字列を使うという意味になります。これは Shell Parameter Expansion というテクニックだそうです。

いまの例だと、PYTHONPATHという変数が未定義な場合は空文字列で代替したいので、以下のように書けます。

#!/bin/bash
set -u

export PYTHONPATH=/path/to/somewhere:${PYTHONPATH:-}

std::setやstd::multisetで末尾の要素を削除する

std::setやstd::multisetで末尾の要素を削除するには、https://www.geeksforgeeks.org/how-to-delete-last-element-from-a-set-in-c/ で紹介されているテクニックを使います。例えばsをstd::setのオブジェクトとした場合、std::prev(s.end()) が末尾の要素を指すので、s.erase(std::prev(s.end())) とすれば末尾の要素を削除できます。例を以下に示します。wandboxで試すこともできます。

#include <iostream>
#include <set>

int main() {
    std::set<int> s{3, 2, 1, 5, 4};
    s.erase(std::prev(s.end()));

    // 1 2 3 4 と表示される
    for(auto e: s) std::cout << e << " ";
    std::cout << std::endl;

    std::multiset<int> ms{3, 2, 1, 5, 5, 4, 4};
    ms.erase(std::prev(ms.end()));

    // 1 2 3 4 4 5 と表示される
    for(auto e: ms) std::cout << e << " ";
    std::cout << std::endl;

    return 0;
}

setやmultisetの要素数がゼロの場合、上記コードは実行時にSegmentation faultを吐くので気をつけてください。

NumPyでベクトルの長さを1に正規化

NumPyでベクトルの長さを1に正規化するには、np.linalg.normを使います。

ベクトルの例

例えば、要素数3のベクトルの長さ(より正確には、ユークリッド距離。すなわちL2ノルム)を1に正規化するには以下のようにします。

vec = np.array([3.0, 4.0, 5.0])
unit_vec = vec / np.linalg.norm(vec, ord=2)  # ordは省略可
print(unit_vec)  # [0.42426407 0.56568542 0.70710678] と表示

検算すると sqrt(0.42426407 ^ 2 + 0.56568542 ^ 2 + 0.70710678 ^ 2) ≒ 1.0で、たしかに正規化できています。

行列の例

素数3の横ベクトルが縦に2つ並んだ行列(shapeが(2, 3))について、横ベクトルそれぞれのL2ノルムを1に正規化する例を以下に示します。

arr = [[3.0, 4.0, 5.0],
       [1.0, 2.0, 3.0]]
arr /= np.linalg.norm(arr, ord=2, axis=1, keepdims=True)
print(arr)

出力結果は以下です。

[[0.42426407 0.56568542 0.70710678]
 [0.26726124 0.53452248 0.80178373]]

素数3の縦ベクトルが横に2つ並んだ行列(shapeが(3, 2))について、縦ベクトルそれぞれのL2ノルムを1に正規化する例を以下に示します。

arr = [[3.0, 1.0],
       [4.0, 2.0],
       [5.0, 3.0]]
arr /= np.linalg.norm(arr, ord=2, axis=0, keepdims=True)
print(arr)

出力結果は以下です。

[[0.42426407 0.26726124]
 [0.56568542 0.53452248]
 [0.70710678 0.80178373]]

random.randint()とnp.random.randint()の定義の違い

Python 3の random.randint() と NumPy の np.random.randint() には次のような微妙だが重大な違いがあります。

random.randint(a, b)

a以上b以下の整数を1個選んで返します。以下のコードで、random.randint(3, 7)が3以上7以下の整数をランダムに生成することを確認できます。

>>> import random
>>> from collections import Counter
>>> Counter([random.randint(3, 7) for _ in range(1000)])
Counter({4: 206, 3: 204, 6: 201, 7: 200, 5: 189})

np.random.randint(a, b)

a以上b未満の数を1個選んで返します。

>>> import numpy as np
>>> from collections import Counter
>>> Counter([np.random.randint(3, 7) for _ in range(1000)])
Counter({6: 276, 3: 252, 5: 245, 4: 227})

np.random.ranom_integers(a, b) (deprecated)

deprecatedですが、かつてはこのような関数がありました。a以上b以下の整数を1個選んで返します。

>>> import numpy as np
>>> from collections import Counter
>>> Counter([np.random.random_integers(3, 7) for _ in range(1000)])
<stdin>:1: DeprecationWarning: This function is deprecated. Please call randint(3, 7 + 1) instead
Counter({7: 214, 3: 207, 4: 194, 6: 193, 5: 192})

参考URL

NumPyのブロードキャストのルール

NumPyのブロードキャストのルールについて曖昧にしか理解していなかったので調べました。わかってしまえば簡単です。

2つのarrayの間でブロードキャストができるかどうかは、2つのarrayのshapeによってのみ決まります。アルゴリズムは以下です。

  • もし2つのarrayの次元が異なれば、小さい方のarrayの左に1を詰めて、同じ次元にする
  • 2つのarrayのshapeのうち、各次元の値を比較。「すべての次元の値が、「一致」または「どちらか片方が1」である」という条件を満たすならば、ブロードキャスト可能。大きな値

例えば、array1が(2, 3, 4, 1), array2が(4, 8)の場合を考えます。

まず、array2のほうがarray1より次元が小さいので、array2の左に1を詰めて、array2を(1, 1, 4, 8)とします。この結果、array1とarray2は以下のようになります。

  • array1: (2, 3, 4, 1)
  • array2: (1, 1, 4, 8)

つぎに、array1, array2の各次元の値を比較していきます。

ここで、array1とarary2は、上で述べた「すべての次元の値が、「一致」または「どちらか片方が1」である」という条件を満たすので、ブロードキャスト可能です。この場合、各次元の値は数値の大きい方に合わせるので、ブロードキャスト後のshapeは(2, 3, 4, 8)になります。

コード例は以下です。

>>> import numpy as np
>>> a = np.zeros((2, 3, 4, 1))
>>> b = np.zeros((4, 8))
>>> (a + b).shape
(2, 3, 4, 8)

参考URLs

tensorflow.kerasでMNIST分類器を学習

tensorflowの初心者向けページをもとに、tensorflow.kerasのチュートリアル記事を読みながら、MNISTデータセットでかんたんな分類器を学習するサンプルコードを作成しました。