ぽよメモ

レガシーシステム考古学専攻

ChainerのEarlyStoppingとOptunaによる最適化

はじめに

前回こんな記事を書きました.

poyo.hatenablog.jp

本当は今回の記事もまとめて1つで公開する予定だったのですが長くなりすぎたので分割しました.

環境

環境は全て前回の記事と同様です.

  • Chainer v5.3.0
  • CuPy v5.3.0
  • Optuna v0.9.0

枝刈りと過学習

当初,Optunaのプレスリリースにあった「学習曲線から、最終的な結果がどのぐらいうまくいきそうかを大まかに予測する」という一文から,「過学習を起こしそうだったら早めに切る」という意味だと誤解していました.実際にはその試行内ではなく,過去の試行との比較を行うため,これは全く意味が異なってしまいます.これに関連したIssueは以下です.

github.com

必要な部分だけ抽出すると,

  • Optunaの枝刈りは過学習を検知するものではない
  • 過学習を気にするなら,例えばChainerならchainer.training.triggers.EarlyStoppingTriggerを使うように

ということです.

EarlyStopping

これは,モデルの学習の収束を判定するための方法です.何らかの指標,例えばvalidation lossを監視し,train lossは減少し続けるのに対して,validation lossが改善されなくなった場合,学習を打ち切ります.ChainerではTriggerとして実装されています.

    # 1 epochごとにvalidationのaccuracyを監視し,3回以上改善しなければstopする
    early_trigger = training.triggers.EarlyStoppingTrigger(
        check_trigger=(1, "epoch"),
        monitor="validation/main/accuracy",
        patients=3,
        mode="max",
        max_trigger=(epoch, "epoch")
    )
    # `(epoch, "epoch")`の代わりに上記のTriggerを渡す
    trainer = training.Trainer(updater, early_trigger, out='output')

これはあくまで終了するだけなので,学習終了後にそのパラメータを読み出したりはしてくれません.そういうことがしたければ以下の記事が参考になります.

qiita.com

qiita.com

タイミング

適当な値を出しますが,こんな感じのlossの推移があり,

epoch 1 2 3 4 5 6 7 8
loss 100 80 60 40 20 30 25 28

EarlyStoppingTriggerのパラメータとして以下の物を渡したとします.

  • patients=3
  • mode="min"
  • max_trigger=(10, "epoch")

patientsは「最小値からいくつ連続して値が改善しなかった場合に学習を止めるか」というパラメータです.例の場合,最小値は5 epoch目の20です.以降,30 → 25 → 28と値が上下していますが,一貫して最小値20を上回っているため,8 epochで中断されます.
modeには,最小,最大どちらの方向で値を監視するかを設定します.lossならば最小の方向になり,accuracyなら最大の方向に監視することになると思います.デフォルトは"auto"ですが,明示した方がトラブルはないんじゃないかなと思います.
max_triggerは値が改善され続けたときにいくつまで学習するかを設定します.

OptunaとEarlyStopping

Optunaは,あくまでも目的関数が返す最後の値を最適なパラメータの選出に使用します.枝刈りを行っても行わなくても,学習過程で記録した最小値が利用されるわけではないため注意が必要です.これを簡単なサンプルで示してみます.

import optuna

def objective(trial):
    sample_losses = [
        [200, 90, 52, 31, 15, 7, 17, 28, 45, 56],  # A
        [143, 82, 56, 40, 26, 18, 24, 23, 26, 28]  # B
    ]
    losses = sample_losses[trial.number]
    # 途中経過を報告する
    for i, loss in enumerate(losses):
        trial.report(loss, step=i)
    # 最後の値を返す
    return losses[-1]

if __name__ == "__main__":
    study = optuna.study.create_study("sqlite:///test.db")
    study.optimize(objective, 2)
    # 全ての試行のvalueをprint
    print("[Trials]")
    for t in study.trials:
        # Trialの番号,その時の値,値の推移
        print(t.number, t.value, t.intermediate_values)
    # Optunaが選んだbestなtrial
    best = study.best_trial
    print("[Best]")
    print("Number:", best.number)
    print("Value:", best.value)

実行されるTrialの順に応じて異なる数列をOptunaへ報告する目的関数を設定し,これを最適化させてみます.この sample_losses をプロットすると以下の様になります.

f:id:pudding_info:20190324234949p:plain
sample_lossesの推移

見て分かるように,実際には試行Aの方が6 epochで(epochではないですが便宜上の単位として使います)最も低い値を記録しますが,Optunaは試行Bをbest trialとして選出します.

[I 2019-03-24 23:45:29,729] A new study created with name: no-name-22ecd572-e23d-4ce4-8370-26a12267b372
[I 2019-03-24 23:45:29,830] Finished trial#0 resulted in value: 56.0. Current best value is 56.0 with parameters: {}.
[I 2019-03-24 23:45:29,918] Finished trial#1 resulted in value: 28.0. Current best value is 28.0 with parameters: {}.
[Trials]
0 56.0 {0: 200.0, 1: 90.0, 2: 52.0, 3: 31.0, 4: 15.0, 5: 7.0, 6: 17.0, 7: 28.0, 8: 45.0, 9: 56.0}
1 28.0 {0: 143.0, 1: 82.0, 2: 56.0, 3: 40.0, 4: 26.0, 5: 18.0, 6: 24.0, 7: 23.0, 8: 26.0, 9: 28.0}
[Best]
Number: 1
Value: 28.0

これは嬉しくありません.正しく最も良い値で判断して欲しいところです.そこで,最終的に返す値を変えます.途中経過の値は枝刈りに使われるのみ*1なので,無視できます.

import optuna

def objective(trial):
    sample_losses = [
        [200, 90, 52, 31, 15, 7, 17, 28, 45, 56],  # A
        [143, 82, 56, 40, 26, 18, 24, 23, 26, 28]  # B
    ]
    losses = sample_losses[trial.number]
    # 途中経過を報告する
    for i, loss in enumerate(losses):
        trial.report(loss, step=i)
    # 最小値を返す
    losses.sort()
    return losses[0]

if __name__ == "__main__":
    # 省略

実行してみます.

[I 2019-03-25 00:02:33,012] A new study created with name: no-name-36544734-db8e-4478-83d5-314d3d999c7b
[I 2019-03-25 00:02:33,122] Finished trial#0 resulted in value: 7.0. Current best value is 7.0 with parameters: {}.
[I 2019-03-25 00:02:33,223] Finished trial#1 resulted in value: 18.0. Current best value is 7.0 with parameters: {}.
[Trials]
0 7.0 {0: 200.0, 1: 90.0, 2: 52.0, 3: 31.0, 4: 15.0, 5: 7.0, 6: 17.0, 7: 28.0, 8: 45.0, 9: 56.0}
1 18.0 {0: 143.0, 1: 82.0, 2: 56.0, 3: 40.0, 4: 26.0, 5: 18.0, 6: 24.0, 7: 23.0, 8: 26.0, 9: 28.0}
[Best]
Number: 0
Value: 7.0

このように,結局は目的関数の返す値によって決定されることが分かりました.よってOptunaで最適化する際には,その試行の中の最良の値を返す必要があるように思われます*2

枝刈りを行う際の更なる注意点として,Optunaは枝刈りした試行についてPRUNEDというステータスで記録しますが,best trialの選出には PRUNED のものは含まれません*3.これは,過去の同じステップと比べて値が悪化しているのだから当然と考えられます.しかし,前述のように,学習の過程でベストな値を取っても,その後改善せずむしろ過学習により劣化した場合に枝刈りされる可能性は依然としてあり,その場合は本来最適であったパラメータが見逃されることになります.

これはEarlyStoppingを用いても抑制はできるでしょうが完全に防ぐことは出来ないと考えています.その仕組み上,最良の値からいくつかぶん学習を進める必要があるため,その長さ(patients)の分だけ枝刈りの可能性が残されてしまうためです.あまり長い patients を設定することは避けるべきかと思います.

実際にやってみた

前回の記事で使用した実験コードに更に手を加える形で実装しました.

全体は以下にあります.

optuna-sample/main.py at 1b7cfccea08b4a2255ff685d931f746ce0de2007 · pddg/optuna-sample · GitHub

    # 省略
    early_trigger = training.triggers.EarlyStoppingTrigger(
        check_trigger=(1, "epoch"),
        monitor="validation/main/accuracy",
        patients=3,
        mode="max",
        max_trigger=(epoch, "epoch")
    )
    trainer = training.Trainer(updater, early_trigger, out='output')

    # 実行中のログを取る
    log_reporter = extensions.LogReport()
    trainer.extend(log_reporter)
    
    # 省略

    # 学習を実行
    trainer.run()

    # Accuracyが最大のものを探す
    observed_log = log_reporter.log
    observed_log.sort(key=lambda x: x['validation/main/accuracy'])
    best_epoch = observed_log[-1]

    # 何epoch目がベストだったかを記録しておく
    trial.set_user_attr('epoch', best_epoch['epoch'])

    # accuracyを評価指標として用いる
    return 1 - best_epoch['validation/main/accuracy']

上記のコードの途中でTrialオブジェクトに対してuser_attrとして最良であった場合のEpoch数を記録していますが,これは後から以下の様にして取り出すことが出来ます.

    print("[Best Params]")
    best = study.best_trial
    print("Epoch:", best.user_attrs.get('epoch'))

これを用いて,枝刈り無し,MedianPrunerによる枝刈り有り,SuccessiveHalvingPrunerによる枝刈り有りの3種類でそれぞれ100回の最適化を行いました.EarlyStopping無しの結果については前回の記事をご覧ください.また,前回から繰り返し書いていますがこれは厳密な時間測定ではなく,なんとなく感覚を掴んでいるだけですので,悪しからず.

枝刈り無し

[I 2019-03-24 15:57:00,465] A new study created with name: prune_test
[I 2019-03-24 15:57:42,481] Finished trial#0 resulted in value: 0.03238105773925781. Current best value is 0.03238105773925781 with parameters: {'n_unit': 36, 'batch_size': 105}.
# 省略
[I 2019-03-24 20:16:07,683] Finished trial#99 resulted in value: 0.022976338863372803. Current best value is 0.018449485301971436 with parameters: {'n_unit': 95, 'batch_size': 37}.
[Trial summary]
Copmleted: 100
Pruned: 0
Failed: 0
[Best Params]
Epoch: 9
Accuracy: 0.9815505146980286
Batch size: 37
N unit: 95

4時間強程度の時間がかかりました.思ったより全然時間を短縮できませんでしたね.今回は最大で20 epoch学習をおこなっているのですが,これが思ったより多すぎなかったということなのでしょうか. とはいえ,Best Paramsを見て頂ければ分かるように,9 epoch目でベストの値をたたき出していることが分かります.

MedianPrunerによる枝刈り

[I 2019-03-24 15:56:47,192] A new study created with name: prune_test
[I 2019-03-24 15:57:31,076] Finished trial#0 resulted in value: 0.02388054132461548. Current best value is 0.02388054132461548 with parameters: {'batch_size': 67, 'n_unit': 116}.
# 省略
[I 2019-03-24 16:29:43,264] Setting status of trial#99 as TrialState.PRUNED. Trial was pruned at epoch 1.
[Trial summary]
Copmleted: 14
Pruned: 86
Failed: 0
[Best Params]
Epoch: 8
Accuracy: 0.9790022373199463
Batch size: 69
N unit: 125

約30分程度で済んでいます.EarlyStopping無しで行った時よりも多少時間が短縮できているようですが,たまたまかも知れません.こちらもBest Paramsは8 Epoch目と比較的早い段階で収束していることがわかります.

SuccessiveHalvingPrunerによる枝刈り

[I 2019-03-24 15:56:58,310] A new study created with name: prune_test
[I 2019-03-24 15:58:00,723] Finished trial#0 resulted in value: 0.023097515106201172. Current best value is 0.023097515106201172 with parameters: {'batch_size': 61, 'n_unit': 70}.
# 省略
[I 2019-03-24 16:26:50,098] Setting status of trial#99 as TrialState.PRUNED. Trial was pruned at epoch 1.
[Trial summary]
Copmleted: 8
Pruned: 92
Failed: 0
[Best Params]
Epoch: 9
Accuracy: 0.9769024848937988
Batch size: 61
N unit: 70

30分弱程度で完了しました.やはり枝刈りは強力ですね.こちらも9 Epoch目で学習が収束していることから,今回のサンプルネットワークを用いたMNISTの学習では8,9 epochあたりで十分収束するということでしょうか(もちろん触るパラメータ次第だとは思いますが).

まとめ

ChainerのEarlyStoppingTriggerは簡単に使えて強力ですので,無意味に長い学習を行って計算リソースや時間を無駄に消費したくない方は是非導入してみてはいかがでしょうか.

また枝刈り有り・無しの場合で,かかる時間と得られる最適化の妥当さのバランスがどうなっていくのか,上記の結果を見る限り同じ100回の最適化でもそれぞれ異なるパラメータに行き着いており,最終的にどこに収束していくのか,気になります.

あとこれはどなたかご存じの方がいらっしゃれば教えて頂きたいのですが,こういった最適化のようなタスク,および実際の学習において numpy.random.seed(0)のようにseed値を固定すべきなのでしょうか.再現性を中途半端に考慮するより最初からランダムにし,複数回行って平均等を見るべきなのでしょうか.

深層学習は難しいですね.

*1:と解釈しているのですが,パラメータのサンプリングに使われたりするのでしょうか

*2:むしろOptunaはなぜ途中で値を報告させる機能を有しているにも関わらず,それらを考慮しないのでしょう.

*3:少なくともv0.9.0ではそうなっています. optuna/base.py at v0.9.0 · pfnet/optuna · GitHub

Optunaによる枝刈りとAsynchronous Successive Halving Algorithm

はじめに

PFNから発表されたハイパーパラメータ最適化ツールOptunaの記事が多数見受けられるようになってきました.Optunaは特に探索中に試行の枝刈りを行うことで,効率の良い探索を行うことができることが目玉の一つです.ここでは特に,Chainerと組み合わせる際の枝刈りの方法と,Optunaの採用する枝刈りのアルゴリズムについてまとめておきます.また,僕自身そこまで詳しいわけでは無いため,厳密な枝刈りによる効率化の度合い,その是非等についてはここでは議論しません*1. Optunaがパラメータの選択に採用しているアルゴリズムに関する情報は以下の記事が大変詳しく書かれています.

qiita.com

環境

  • Chainer v5.3.0
  • CuPy v5.3.0
  • Optuna v0.9.0

実行環境はUbuntu 18.04 LTSで,Docker,Docker-compose,nvidia-docker2を使用しました.また,NVIDIA Driverのバージョンは415.27です.

$ docker --version
Docker version 18.09.2, build 6247962
$ docker-compose --version
docker-compose version 1.23.2, build 1110ad01

ハードウェアは以下の通りです.

  • Intel(R) Xeon(R) CPU E5-2630 v4 @ 2.20GHz 10コア20スレッド × 2
  • RAM 64GB
  • GPU GTX 1080 ti

使用するコード

特にどんなコードでも大差ないため,MNISTを使って例を示します.まず単純に最適化する場合のコードを以下に示します.

import argparse
import functools
import chainer
import numpy as np
import optuna
from chainer import links as L
from chainer import functions as F
from chainer import training
from chainer.training import extensions

# From: https://github.com/chainer/chainer/blob/v5/examples/mnist/train_mnist.py
# Copyright (c) 2015 Preferred Infrastructure, Inc.
# Copyright (c) 2015 Preferred Networks, Inc.
# Network definition
class MLP(chainer.Chain):

    def __init__(self, n_units, n_out):
        super(MLP, self).__init__()
        with self.init_scope():
            # the size of the inputs to each layer will be inferred
            self.l1 = L.Linear(None, n_units)  # n_in -> n_units
            self.l2 = L.Linear(None, n_units)  # n_units -> n_units
            self.l3 = L.Linear(None, n_out)  # n_units -> n_out

    def forward(self, x):
        h1 = F.relu(self.l1(x))
        h2 = F.relu(self.l2(h1))
        return self.l3(h2)

# 目的関数を設定する
def objective(trial, device, train_data, test_data):
    # trialからパラメータを取得
    n_unit = trial.suggest_int("n_unit", 8, 128)
    batch_size = trial.suggest_int("batch_size", 2, 128)
    n_out = 10
    epoch = 20

    # モデルを定義
    model = L.Classifier(MLP(n_unit, n_out))

    if device >= 0:
        chainer.backends.cuda.get_device_from_id(device).use()
        model.to_gpu()

    optimizer = chainer.optimizers.Adam()
    optimizer.setup(model)

    train_iter = chainer.iterators.SerialIterator(train_data, batch_size)
    test_iter = chainer.iterators.SerialIterator(test_data, batch_size,
                                                 repeat=False, shuffle=False)
    updater = training.updaters.StandardUpdater(
                    train_iter, optimizer, device=device)
    trainer = training.Trainer(updater, (epoch, 'epoch'), out='output')

    # validationをするextensionを追加
    trainer.extend(extensions.Evaluator(test_iter, model, device=device))

    # 学習を実行
    trainer.run()

    # accuracyを評価指標として用いる
    return 1 - trainer.observation['validation/main/accuracy']

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('trials', type=int, help='Number of trials')
    parser.add_argument('-g', '--gpu', type=int, default=-1, help='GPU ID')
    args = parser.parse_args()

    np.random.seed(0)

    # MNISTデータを読み込む
    train, test = chainer.datasets.get_mnist()
    # 目的関数にパラメータを渡す
    obj = functools.partial(objective, device=args.gpu, train_data=train, test_data=test)
    # Studyを作成
    study = optuna.study.create_study(
        storage='sqlite:///optimize.db', study_name='prune_test', load_if_exists=True
    )
    # 最適化を実行
    study.optimize(obj, n_trials=args.trials)

    # Summaryを出力
    print("[Trial summary]")
    df = study.trials_dataframe()
    state = optuna.structs.TrialState
    print("Copmleted:", len(df[df['state'] == state.COMPLETE]))
    print("Pruned:", len(df[df['state'] == state.PRUNED]))
    print("Failed:", len(df[df['state'] == state.FAIL]))

    # 最良のケース
    print("[Best Params]")
    best = study.best_trial
    print("Accuracy:", 1 - best.value)
    print("Batch size:", best.params['batch_size'])
    print("N unit:", best.params['n_unit'])

試しに100回ほど最適化してみた結果,約4時間程度かかりました*2

$ python main.py 100 -g 0
[I 2019-03-24 09:44:51,751] A new study created with name: prune_test
[I 2019-03-24 09:48:36,239] Finished trial#0 resulted in value: 0.02411597967147827. Current best value is 0.02411597967147827 with parameters: {'n_unit': 57, 'batch_size': 23}.
# 省略
[I 2019-03-24 15:01:56,820] Finished trial#99 resulted in value: 0.023074626922607422. Current best value is 0.019391894340515137 with parameters: {'n_unit': 93, 'batch_size': 41}.
[Trial summary]
Copmleted: 100
Pruned: 0
Failed: 0
[Best Params]
Accuracy: 0.9806081056594849
Batch size: 41
N unit: 93

また,実際に使用したDockerfileと以下の実験を行ったスクリプトファイルはそれぞれこちらこちらにあります.

枝刈りとは

Optunaによる試行の枝刈りとは,

深層学習や勾配ブースティングなど、反復アルゴリズムが学習に用いられる場合、学習曲線から、最終的な結果がどのぐらいうまくいきそうかを大まかに予測することができます。この予測を用いて、良い結果を残すことが見込まれない試行は、最後まで行うことなく早期に終了させてしまうことができます。これが、Optuna のもつ枝刈りの機能になります。

となっています*3.Optunaではv0.9.0現在,2種類の枝刈り方法が存在します.以降,試行をtrial,その試行の中での学習のステップの単位をepochとします.

MedianPruner

アルゴリズム

最小化したい値(validationのlossやaccuracyなど)を定期的(1 epochごとなど)に報告し,その値を過去のtrialにおける同じepochにおける値と比較して,それらの中央値より悪ければ試行を止めるPrunerです.

例えば,以下のような試行が行われているとします(これらの報告されている値はvalidation lossだと考えてください).

. 1 epoch 2 epoch 3 epoch 4 epoch
1 trial 100 80 60 40
2 trial 120 100 90 80
3 trial 110 75 65 10

4 trialでは,1 epoch目で95,2 epoch目で90を取ったとすると,このとき,過去の3回の試行における各タイムステップの中央値は以下のようになり,

. 1 epoch 2 epoch 3 epoch 4 epoch
median 110 80 60 40

4 trialの2 epoch目の値90は過去の試行における2 epoch目の中央値80よりも大きいため,4 trialは破棄されます.

使い方

MedianPrunerをインスタンス化し,studyに渡します.

    # 途中省略
    # Prunerを作成
    pruner = optuna.pruners.MedianPruner(n_startup_trials=5, n_warmup_steps=0)
    # Studyを作成してPrunerを指定
    study = optuna.study.create_study(
        storage='sqlite:///optimize.db', study_name='prune_test', pruner=pruner, load_if_exists=True
    )

Chainer用のインテグレーションを追加します.これはTrainerのExtensionで提供されています.これは僕がハマったことなのですが,実はpruner=NoneとしてStudyに渡すと,デフォルトでMedianPrunerが使われるため,以下のExtensionを追加するだけでPruneされてしまいます.Pruneされたくない人は注意してください((単に中間の値を全て保存しておきたいだけならtrial.report(値, step=epoch数)で出来ます.)).

    # 省略
    trainer = training.Trainer(updater, (epoch, 'epoch'), out='output')

    # validationをするextensionを追加
    trainer.extend(extensions.Evaluator(test_iter, model, device=device))

    # Optunaとのインテグレーションのためのextensionを追加
    # trialオブジェクト,監視するメトリクス,監視する頻度を指定
    integrator = optuna.integration.ChainerPruningExtension(
        trial, 'validation/main/accuracy', (1, 'epoch')
    )
    trainer.extend(integrator)

実行すると時折枝刈りされる試行が出てきます.

$ python main.py 100 -g 0
[I 2019-03-24 12:30:48,730] A new study created with name: prune_test
[I 2019-03-24 12:32:23,866] Finished trial#0 resulted in value: 0.033683180809020996. Current best value is 0.033683180809020996 with parameters: {'n_unit': 26, 'batch_size': 60}.
# 省略
 [I 2019-03-24 13:16:01,994] Setting status of trial#99 as TrialState.PRUNED. Trial was pruned at epoch 1.
[Trial summary]
Copmleted: 10
Pruned: 90
Failed: 0
[Best Params]
Accuracy: 0.978394627571106
Batch size: 108
N unit: 72

45分程度かかったようです.Accuracyは枝刈り無しよりも悪いですが,100回しか行っていないためもっと試行回数を増やせば良くなりそうです.

n_startup_trials

枝刈りを開始するまでの必要trial数を指定します.n_startup_trials=0なら試行の数にかかわらず,過去の試行の中央値よりも悪ければ破棄されます.例えば,最初に上げた例の2 trialは本来1 epoch目で中断されてしまいます.n_startup_trials=5とすると,5 trialまでは必ず実行され,6 trialから枝刈りが行われるようになります. デフォルトはn_startup_trials=5です.

n_warmup_steps

学習開始直後の学習曲線の傾きの角度が大きいものと,そうでないものがあるとき,小さいものの方が最終的な性能は良くなる場合もあるにもかかわらず,枝刈りが行われてしまうという可能性があります.これを回避するため,ある試行の中で,必ず実行するステップ数を決めることができます.例えばn_warmup_steps=5とすると,5 epoch目までは必ず全ての試行において実行され,6 epoch目から枝刈りが行われるようになります. この値を大きく取ればそれだけ様々なパラメータの可能性を見ることができますが,逆に枝刈りの効率は下がってしまいます.

SucccessiveHalvingPruner

v0.6.0から追加された,異なるアルゴリズムを採用したPrunerです.

アルゴリズム

単純なSuccessive Halvingアルゴリズム(SHA)ではなく,Asynchronous Successive Halvingアルゴリズム(ASHA)という改善された(?)アルゴリズムを採用していることがドキュメントに述べられています*4.ASHAについては以下の論文で提案されています.

arxiv.org

SHAそのものの解説はこちらの記事が詳しかったです.

adtech.cyberagent.io

SHAは学習の総時間を決め,途中まで学習をすすめてその中からよりよい組  1/\eta 個を抽出,学習を続けてまた上位  1/\eta 個を抽出……というのを繰り返すことで,良いパラメータでの学習に時間をかけるということのようです.ASHA自体の論文について詳しく読み込めているわけではないこと,こういったアルゴリズムに対して詳しいわけでもないことなどから,以下で述べることは全く正確性に欠ける可能性があることをご了承ください.

用語の定義

  •  n : ハイパーパラメータの組み合わせの数.
  •  R : 一つの試行におけるmaximum resource.
  •  r : 一つの試行におけるminimum resource.
  •  \eta : reduction factor.2以上の数値.
  •  s : minimum early stopping rate.
  • rung
    • 上位の組み合わせを抽出するための区切り?
    • ある  i について  n_i 個の試行を行うことを一つのrungとしている?
  • brackets
    • ある  n 個のハイパーパラメータの組についての最適化
  • 昇格
    • あるrungの上位の組み合わせを次のrungへと移行する(学習を継続する)こと

まず,論文中にSHAのアルゴリズムは以下の様に示されています.

f:id:pudding_info:20190324134012p:plain

また,  n = 9 R = 9 r = 1 s = 0 のとき以下の左図のようになり,異なる  s の組み合わせについて示したものが以下の右表になるようです.

f:id:pudding_info:20190324134743p:plain
Figure 1: Promotion scheme for SHA

 s = 0 のとき,最初のrungでは9個の試行が行われ,上位1/3だけが次のrungへ,そして最終的に1つが選出されていることが分かります.rungが進むごとに一つの試行に割り当てられるresource  r_i が増え,学習が進んでいることがわかります.

まず,SHAを単純に並列化(これを論文中では"synchronous" SHA,同期的SHAと呼んでいる)する上での問題は論文中に,

  1. あるrungは次のrungに進むために, n_i 個の試行が全て完了しないといけないため,stragglerやdropped job*5に弱い
  2. 試行するジョブが全て無くなったときには新しいbracketを追加するが,上位  1/\eta 個を選ぶのは各bracketについて独立であるため,bracketを並列しても,上位  1/\eta 個を選ぶパフォーマンスは向上しない
    • 自信無いです

というようなことが述べられているように見えます.これを解決するために,筆者らはASHAを提唱しており,アルゴリズムの概略は以下の様になっています.

f:id:pudding_info:20190324145915p:plain
Asynchronous Successive Halving Algorithm

同期と非同期の違いは以下の図のように表されるようです.

f:id:pudding_info:20190324150153p:plain
Figure 2: Comparison of promotion schemes for SHA and ASHA.

同期SHAでは,rung 1に進むためにrung 0の試行が全て完了するまで待つのに対して,ASHAでは,全ての完了を待たずに先にrung 1のジョブが実行されます.つまり,これまでに実行された  m 個の試行について,常に  1/\eta という比率を保つように次のrungの学習を行う,というようなことのようです(これも自信無い).もし昇格するジョブが無かったとき,単にbaseのrung(rung 0)に新しいjobを追加します.これはつまりパラメータの組の全体数  n を決めないということです.ASHAで必要なパラメータは,SHAのパラメータから  n を除いた全てです.

使い方

単にMedianPrunerをSuccessiveHalvingPrunerに置き換えれば良いです.記述は省略しますがChainerPruningExtensionも必要です.

    # 途中省略
    # Prunerを作成
    pruner = optuna.pruners.SuccessiveHalvingPruner(
        min_resource=1,
        reduction_factor=4,
        min_early_stopping_rate=0
    )
    # Studyを作成してPrunerを指定
    study = optuna.study.create_study(
        storage='sqlite:///optimize.db', study_name='prune_test', pruner=pruner, load_if_exists=True
    )

引数は3種類あり,

  • min_resource:論文中の  r
  • reduction_factor:論文中の \eta
  • min_early_stopping_rate:論文中の s

にそれぞれ相当します.なお,最大リソース数  R はパラメータとして渡しません.これは各trialの中で決まる値(Trainerに渡すEpoch数など)に当たるためだそうです.また,これらの値から

  • 最低限実行されるepoch数: e_{min}
  • Pruneされるタイミング: e_{prune}

などが以下の式で計算出来ます*6


\begin{aligned}
e_{min}  &= r \times \eta^{\left( s \right)} \\
e_{prune} &= r \times \eta^{\left( s + rung\right)}  \\
\end{aligned}


例えば,デフォルトの値(  r=1 \eta=4 s=0 )のとき,


\begin{aligned}
e_{min}  &= 1 \times 4^{\left( 0 \right)} \\
              &= 1 \\
e_{prune} &= r \times \eta^{\left(s + rung\right)}  \\
                 &= 1 \times 4^{\left(0 + rung \right)}
\end{aligned}


ここで, e_{prune} は例えばrung 0で他よりも良い成績であった場合,次にPruneされるのは rung=1として  e_{prune} = 4 と計算することが出来ます.つまり,1,4,16,64…epoch目でそれぞれPruneされるかどうかが決まります.実際に,100回実行してみた結果が以下です.

$ docker-compose up asha
[I 2019-03-24 12:30:48,446] A new study created with name: prune_test
[I 2019-03-24 12:32:16,421] Finished trial#0 resulted in value: 0.02267676591873169. Current best value is 0.02267676591873169 with parameters: {'batch_size': 65, 'n_unit': 119}.
# 省略
[I 2019-03-24 12:58:49,325] Setting status of trial#99 as TrialState.PRUNED. Trial was pruned at epoch 1.
[Trial summary]
Copmleted: 3
Pruned: 97
Failed: 0
[Best Params]
Accuracy: 0.9773232340812683
Batch size: 65
N unit: 119

かかった時間は30分程度でした.MedianPrunerの時と同じく,枝刈り無しで行ったときより結果が悪いのは気になりますが,こちらも同じく時間が大きく短縮されているのでもっと試行回数を増やして良さそうです.

まとめ

MedianPrunerの挙動はドキュメントを読んでだいたい理解したのですが,SuccessiveHalvingPrunerは「Successive Halving Algorithmの非同期版」という書かれ方がされており,全く分からなかったので論文を流し読みしてまとめてみました.やはり枝刈りを行うと圧倒的に時間が短縮されることも実際に確かめることが出来ました.積極的に活用していきたいですね.
内容に間違っている箇所があれば,コメントで優しく指摘して頂けると助かります.

*1:実際,各セクションで実際に最適化を実行していますが,それぞれ同時に動かしたりしています.1080tiなら大丈夫だろwと気軽にやっているので実行時間の正確さは期待しないでください.

*2:遅くないか…?

*3:research.preferred.jp

*4:https://optuna.readthedocs.io/en/stable/reference/pruners.html#optuna.pruners.SuccessiveHalvingPruner

*5:厳密な意味はわからないのですが,stragglerは他と比べて長い試行,dropped jobは実行中の失敗(メモリ不足とか,ノードが落ちたとか?)でしょうか

*6:これらの記号は僕が勝手に決めたもので特に意味はありません

Python開発環境メモ(2019)

これは何

2019/3現在における,Pythonとそれを取り巻くツールを活用した開発における,個人的プラクティスをまとめたものです.主にpuddingが人に教える時に自分で参考にするためのものです.
正確性にはできるだけ万全を期していますが,間違っていたらコメントして欲しいです.

言いたいこと

無駄に長い記事を読みたくない人向けに簡潔にまとめておきます

  • 自分の環境をできるだけ把握しておく
  • Python 2.xを使うな.Python 3.5未満を使うな.
  • Pyenvで複数バージョンを導入
  • Pipenvで仮想環境とパッケージを管理
  • エディタはPyCharmかVSCodeがおすすめ

Pythonとは

スクリプト言語であり,コンパイルによって実行ファイルを作成するのではなく,インタプリタと呼ばれるプログラムによってソースコードを読み取り,順次実行する形式を取ります.
大きく分けて以下の2つのバージョンが現在でも使われており,環境によって色々かわって来ます.

  • 2.7.x:早く滅びろ
  • 3.x:3.5以下は早く滅びろ

これらにはある程度の文法の互換性が存在しますが,だいたいSyntax Errorで落ちるので,注意してください.今から使うならPython 3.6以降以外の選択肢はありません.もし職場等でPython 2.xを使うことを強制されたなら,落ち着いてハラスメント窓口に駆け込むか,退職願いを書いて転職した方が良いでしょう.

python というコマンド

まず,Python初学者(というかプログラム初学者)に知っていて欲しい事があります.それは

pythonpython3 というコマンドは魔法ではなく,
単にそのシステムのどこかにある実行可能なバイナリファイルを指しているだけ

ということです.つまり,pythonという実行ファイルが存在していなければ当然Not foundみたいなことを言われてしまうし,pythonコマンドの指す実体がPython 2.xかもしれないしPython 3.xかもしれません.当たり前のことですが,Python 2.xに対してPython 3.xの文法で書いたPythonスクリプトを渡しても,Syntax Errorにより実行できない場合がほとんどでなので,自分の今の環境における python コマンドとは一体どこを指しているものなのか常に意識した方が良いでしょう.

複数バージョンの共存

さて,前節においてpythonというコマンドは,どこかにある実行ファイルを指していると言いましたが,これはつまり,pythonというコマンドのさす場所を変えれば,同じpythonというコマンドで全く別のバージョンのPythonを実行できるということを意味しています.これにより自分の環境で任意のPythonバージョンを共存させることを目指したのがPyenvです.

github.com

残念ながらWindowsでは利用できませんが,WSLやMacLinuxなどでは簡単に利用できるため,多少ちゃんと開発をするならばぜひ導入してほしいツールです. Pythonしか開発しない!なんて人はたぶんいないので,このpyenvのような~~env系を簡単に扱えるラッパーであるanyenvをインストールするのがおすすめです.以下のURLを参考にインストールしてください(まだ古いリポジトリの話がネット上には散見されるので注意してください).

qiita.com

# pyenvをインストール
$ anyenv install pyenv
# シェルを再起動
$ exec $SHELL -l
# Pyenvコマンドが使えるようになっていれば成功
$ pyenv versions
* system

このsystemというのは,そのOSにインストールされている(macならデフォルトで入っているものや,brewでインストールした)Pythonを指しています.

バージョンを指定してインストール

Pyenvは任意のバージョンのPythonのインストールと,python(またはpython3)コマンドの指す実体を変えるだけのものであるので,まずはPythonをインストールする必要があります.インストール時にビルドするため,依存ライブラリが必要となることに注意が必要です.macOSでは少し厄介になることが多く,特に最新のバージョンのmacOSにアップグレードすると大抵死ぬので,そういうときはpyenvのリポジトリのIssueやWik*1を読むと良いでしょう.

# インストールできるバージョンの一覧
$ pyenv install --list
# とりあえずPython 3.7.2をインストールする
# 依存関係をインストール
$ brew install openssl readline sqlite3 xz zlib
# Macでは以下の様にする
$ CONFIGURE_OPTS="--with-openssl=$(brew --prefix openssl)" pyenv install 3.7.2

pyenvには「global」と「local」という考え方があります.globalは,そのシステム内でpythonコマンドを使ったときに基本的に呼ばれるPythonのバージョンを指定し,localは特定のディレクトリ以下でpythonコマンドのバージョンを上書きすることができます.

# python -> システムにインストールされているPython 2.7.x
# python3 -> Python 3.7.2
# になるようにグローバルを設定する
$ pyenv global system 3.7.2
$ python -V
Python 2.7.14
$ python3 -V
Python 3.7.2
# local-testディレクトリ以下で使うバージョンを変える
$ mkdir local-test
$ cd local-test
# .python-versionというファイルが作られる
$ pyenv local 3.7.2
$ ls -a | grep python-version
.python-version
$ python -V
Python 3.7.2

これは個人的な考えですが,基本的にglobalはsystemのPythonを参照するようにし,pyenvはあくまで任意のバージョンのPythonを簡単に導入するためのもの,と割り切った方が何かとトラブルが少なく済むと思います.

$ pyenv global system

実際に呼ばれるPythonのパス

pyenvはPyenvのインストールされたディレクトリ以下にあるshimsディレクトリ以下に,インストールされた各バージョンのPythonインタプリタに対してシンボリックリンクを張ります.そのため,pythonコマンドで呼ばれるPythonがどこにあるかを調べようとしても,すべてそのシンボリックリンクを指してしまいます.本当のパスを知りたいときはpyenv whichを使います.

$ which python3
{{ pyenvのルートディレクトリ }}/shims/python3
# 本当のパスを調べたいとき
$ pyenv which python3
{{ pyenvのルートディレクトリ }}/versions/3.7.2/bin/python

仮想環境

さて,複数バージョンの共存は出来るようになりました.次は,各プロジェクトごとにPython環境を分けましょう.プロジェクトとは,例えばあるWebアプリケーションなど一つの機能のまとまりを指します.GitHubリポジトリ==プロジェクトと考えて良いです.そして仮想環境とは,まぁ要するに各プロジェクトごとにそれぞれPythonインタプリタを用意する,ということを指します.プロジェクトごとに新しくPythonをインストールするというイメージです.これには以下の様なメリットが上げられます.

  • 問題の切り分けを簡単にできる
    • モジュールの依存関係,バージョン違いによる挙動の変更など
  • 必要なモジュールのみから構成されるため,IDE等への負荷軽減になる
  • 自分以外のメンバーも同じ環境を再現しやすい

そのため,本当に簡単なスニペットレベルのコードを除いてほぼ必須だと思っています.僕個人は使ったことのないモジュールを試用するとき,標準ライブラリだけでは足りないレベルの使い捨てスニペットを作るときなど比較的軽い用途でもどんどん環境を切り分けています.
Pythonでは非常にややこしいことにこれを実現する方法が複数存在し,どれがベストかは人によると思いますが,個人的な見解を以下に書いておきます.

Pyenv + Pyenv-virtualenv

github.com

pyenv-virtualenvというプラグインを導入しPyenvで仮想環境を作る.

  • 良いところ
    • 操作をPyenv系のコマンドに集約できる
    • Pythonのバージョンを変更できる
  • 微妙なところ
    • pyenv versionsコマンドの一覧がどんどん圧迫される
    • 結局パッケージの管理はpip
    • Windowsでは使えない
# pyenv-virtualenvをインストールする
$  git clone https://github.com/pyenv/pyenv-virtualenv.git $(pyenv root)/plugins/pyenv-virtualenv
# `pyenv virtualenv {{ バージョン }} {{ 環境名 }}`で作成できる
$ pyenv virtualenv 3.7.2 test
$ mkdir test-pyenv && cd test-pyenv
# test環境のpythonが呼ばれるようになる.
$ pyenv local test
(test) $ pyenv which python
{{ pyenvのルートディレクトリ }}/versions/test/bin/python
# パッケージのインストール
(test) $ pip install {{ パッケージ名 }}

Venv(or virtualenv)

Python3.3から標準化された(virtualenvはそれまでpipで導入できた)仮想環境作成モジュール

  • 良いところ
    • Python公式であり,(venvなら)大抵最初から使えてお手軽
  • 微妙なところ
    • 結局パッケージの管理はpip
    • たまにVenvが有効化されていない場合がある
    • Python2と3で使い勝手が変わるのがつらい(とはいえもう2.xは死んだ)
    • VenvはあるバージョンのPythonに付属するモジュールであり,そのバージョン以外の仮想環境を構築出来ない.
$ python3 -V
Python 3.7.2
$ mkdir test-venv && cd test-venv
# `python3 -m venv {{ 環境名 }}`でカレントディレクトリ以下に
# その環境名で仮想環境のディレクトリを作成する.
$ python3 -m venv .venv
$ ls -a | grep venv
.venv
# 仮想環境をアクティベート
$ source .venv/bin/activate
(.venv) $ which python
{{ カレントディレクトリ }}/.venv/bin/python
# パッケージのインストール
(.venv) $ pip install {{ パッケージ名 }}
# 仮想環境から抜ける
(.venv) $ deactivate

Conda

Anaconda/Minicondaのパッケージマネージャ兼環境管理ツール.ビルド済みの様々なモジュール・ライブラリを内包する一つのPythonディストリビューション.科学計算ライブラリ系にかなり特化しており,依存ライブラリの用意が難しいWindowsでは非常に便利.

  • 良いところ
    • パッケージ管理と環境構築が同じコマンド体系
    • Pythonのバージョンも一つのモジュールのバージョンのように変更できる
  • 微妙なところ
    • Anacondaの場合容量がびっくりするほどデカい
    • condaでインストールできないパッケージはpipでインストールすることになり,パッケージの管理が分散してしまう
    • 仮想環境のアクティベーションのためにトリッキーな小技が必要になることが多い
    • データ分析界隈で使われることが多いが,初心者のやってみた記事が多すぎて検索結果が大抵地獄
# pyenvでMinicondaをインストールする.
$ pyenv install miniconda3-4.3.30
# Minicondaが仮想環境を作成するディレクトリを`/Users/{{ ユーザ名 }}/.conda/envs`にする
$ echo -e "envs_dirs:\n  - $HOME/.conda/envs" >> $HOME/.condarc
# 検証用のディレクトリを作る
$ mkdir test-conda && cd test-conda
$ pyenv local miniconda3-4.3.30
# `conda env create -n {{ 環境名 }}`で作成する
$ conda create -n test
# 作成した環境には何もないのでPythonからインストールする
$ conda install -n test "python==3.7"
# 仮想環境をアクティベートするconda activateコマンドを有効にする
$ source $(conda info --root)/etc/peofile.d/conda.sh
$ conda activate test
(test) $ which python
/Users/{{ ユーザ名 }}/.conda/envs/test-env/bin/python
# パッケージのインストール
(test) $ conda install {{ パッケージ名 }}
# condaで見つからない場合は
(test) $ pip install {{ パッケージ名 }}
# 仮想環境から抜ける
(test) $ conda deactivate

Pipenv

pipenv-ja.readthedocs.io

requestsモジュールを作ったKenneth Reitz氏が開発されたpipとvirtualenv,そしてpyenvのラッパー.Rubyを書いている人にはPython版のbundlerだと思って貰えるとよい.

  • 良いところ
    • パッケージの管理と仮想環境の作成が同一のコマンド体系で扱える
    • pipenv runを使うことで,いちいち仮想環境をアクティベートする必要がない
    • pythonへのパスを指定するとそのバージョンをベースにした環境を作れる
  • 微妙なところ
    • これ一つでだいたい片が付くが,pipでインストールするライブラリを書く時はpipがPipfileを認識できないので,setup.pyを書いて自分で依存関係を記述しなければならない.
# pipを使ってpipenvをインストール
$ pip install pipenv
$ mkdir test-pipenv && cd test-pipenv
# 仮想環境を`~/.virtualenvs`ではなく,`pipenv install`したディレクトリの直下に作る場合,以下を指定
# $ export PIPENV_VENV_IN_PROJECT=true
# Python 3.7の仮想環境を作る
$ pipenv install --python 3.7
# または直接Pythonへのパスを参照しても良い
# $ pipenv install --python $(pyenv which python3)
# カレントディレクトリに`.venv`ディレクトリができる
$ ls -a | grep .venv
.venv
# PipfileとPipfile.lockによって管理されている
$ cat Pipfile
[[source]]
name ="pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]

[requires]
python_version = "3.7"
# `pipenv run`で仮想環境下のコマンドを呼べる
$ pipenv run python -V
Python 3.7.2
# パッケージのインストール
$ pipenv install {{ パッケージ名 }}
# 従来通り仮想環境をアクティベートすることもできる
$ pipenv shell
(.venv) $ python -V
Python 3.7.2
# 仮想環境から抜ける
(.venv) $ exit

Poetry

poetry.eustace.io

最近標準化されたpyproject.tomlを使って管理します.Pipenvと違い,パッケージングまで面倒を見てくれるため,setup.pyを別で記述したり,細かいパッケージング処理を自分で書く必要が無いというのが最も大きな違いです.他にも色々と利点が挙げられているようです.

  • 良いところ
    • pipでインストールするようなモジュールを書きたい場合にパッケージングの面倒を見てくれる
    • どうも依存関係の解消がPipenvより上手く出来ているらしい
  • 微妙なところ
    • モジュールではなくアプリケーションを作る場合,ほとんどの機能が必要ない
    • 2019/03/18現在,まだVSCodeでサポートされていない*2
      • ただしpoetry config settings.virtualenvs.in-project trueでプロジェクト直下に仮想環境を作ると認識してくれる
    • バージョンを自動で解決してくれないので,まずpyenvで使うバージョンを指定,poetry installで仮想環境を作成という手順になり少し面倒臭い
$ curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python
# Poetryのコマンドにパスを通す
$ echo "export PATH=$PATH:$HOME/.poetry/bin" >> ~/.bashrc
$ source ~/.bashrc
# 仮想環境をプロジェクト直下に作る
$ poetry config settings.virtualenvs.in-project true
# 新しいプロジェクトを作る
$ poetry new test-poetry
$ cd test-poetry
# 以下の様なファイル群が自動生成される
$ tree -L 2
.
├── README.rst
├── poetry.lock
├── pyproject.toml
├── test_poetry
│   └── __init__.py
├── test_poetry.egg-info
│   ├── PKG-INFO
│   ├── SOURCES.txt
│   ├── dependency_links.txt
│   └── top_level.txt
└── tests
    ├── __init__.py
    └── test_test_poetry.py
# このプロジェクトで使うPythonのバージョンを指定する
$ pyenv local 3.7.2
# このプロジェクトで使うPythonのバージョンをpyproject.tomlに設定する.今回は3.7.
$ sed -i '' 's/^python = "\^[0-9]\.[0-9]"/python = "^3.7"/g' pyproject.toml
# 仮想環境を構築する
$ poetry install
# pipenv runと同等のコマンド
$ poetry run python -V
Python 3.7.2
# 仮想環境をアクティベート
# 僕の環境では上手く仮想環境のpythonへパスが通らず,正常に動作しませんでした……
$ poetry shell

参考

kk6.hateblo.jp

まとめ

Pyenv+pyenv-virtualenvで作った環境が結局今どのバージョンを使っているのか分からなくなったり,venvを素のまま使っていてこの環境はバージョン何だったっけとなったりしていい加減嫌になったので,

  1. Pyenvで任意のバージョンをインストール(インストールするだけ)
  2. Pipenvで仮想環境作成

が一番しっくりきました.今のところ,これで困るようなフェーズはほとんど遭遇していません.pipenv runで大抵うまくいくという安心感が良いです.が,Poetryの網羅する範囲が非常に広いため,今後Poetryに移行していく可能性はあります.

パッケージの管理

ここでパッケージとは,pipコマンド等でインストールできる,何らかの機能が実装されたPythonモジュール群を指しています.かつてはeasy_installなどもありましたが,現状ではpip一択です.Pipは,PyPIPython Package Index)というサードパーティー製ライブラリを管理するサーバから,指定したパッケージ名を検索,取得してインストールしてくれます. 前述のPipenv,Poetryはパッケージの管理まで面倒を見てくれるため,これらのどれかを使うことをオススメします.これらは裏側でpipを使用してパッケージをインストールしてくれます.PipenvとPoetryはそれぞれ,インストールするパッケージやそのバージョンについての情報が含まれたPipfile(Poetryならばpyproject.toml)が自動的に作られるため,ユーザ自身がそれらの管理について気にする必要はほとんど有りません.

# Pipのみの場合
$ pip install requests

# Pipenvの場合
# パッケージをインストールすると勝手にPipfileとPipfile.lockが更新される
$ pipenv install requests
# 開発用パッケージの追加
$ pipenv install --dev pytest

# Poetryの場合
# パッケージをインストールすると勝手にpyproject.tomlとpoetry.lockが更新される
$ poetry add requests
# 開発用パッケージのインストール
$ poetry add --dev pytest

開発環境を再構築するために

再構築という作業を可能にしておくことは色々な意味で有用です.

  • 自分のため
    • 急にマシンがぶっ壊れる,環境が壊れたのでやりなおすなど,再構築は大抵ハプニングのときに生じる.すぐに開発を再開できるようにしておく
    • 環境を整理し,常にクリーンに保てるように注意しておくことができる
  • 一緒に開発してくれるメンバーのため
    • 全員が同じPCで編集していては仕事が進まない.メンバーが同等の環境を再現できる様にしておく.
  • バグや実験の再現性
    • 研究でも開発でも再現性は重要です.

READMEにちゃんと構築の仕方を簡単で良いので書いておきましょう.

pip freeze > requirements.txt

pipしか使えない場合です.これまでのPython開発(少なくとも3年くらい前まで)では,requirements.txtというファイルが多く使われていました.以下の様な非常に単純な形式を取っています.

# パッケージ名とバージョン制約から成る
requests==2.21.0
pipenv==2018.11.26

これはpip freezeという,pipでインストールしたモジュールの一覧を吐き出すコマンドの出力とフォーマットが一致しており,以下の様に保存・再構築ができます.

# 今インストールされているパッケージを出力して保存
$ pip freeze > requirements.txt
# 一覧からインストール
$ pip install -r requirements.txt

しかし,この方法にはいくつかの欠点があり,現状あまりオススメはしません(pipenv等を使えない場合は仕方有りません…)

  • pip freezeはインストールしたものを全て出力するため,インストールしたライブラリの依存先まで全て表示されてしまい,どれが本当に必要なものか分かりづらい
  • pip install -U {{ パッケージ名 }}でバージョンをアップグレードした際にrequirements.txtを更新するのを忘れがちになる
  • 開発時のみに必要なパッケージ(テスト用のライブラリなど)を別で列挙したrequirements-dev.txtのようなファイルを作ることになる
  • そもそも指定したバージョンをインストールするだけなので,本当に依存関係の解消をしているわけではない

より詳細なバージョンコントロールについては参考のリンク*3を見てみてください.

conda env export

ドキュメントにそのまま載っている方法*4です.

# 環境の情報を出力
$ conda env export -n {{ 環境名 }} > environment.yml
# 出力された情報から環境を作る
$ conda env create -f environment.yml

environment.ymlは以下の様になります.

# 環境名
name: test-env
channels:
  - defaults
# 必要な依存関係
dependencies:
  # "パッケージ名=バージョン=ビルド"の形式
  - numpy=1.16.2=py37hacdab7b_0
  - python=3.7.0=hc167b69_0
# 仮想環境へのパス
prefix: /Users/ユーザ名/.conda/envs/test-env

この方法は,同じOS,同じプラットフォーム,同じアーキテクチャのマシン間で環境を共有するには良い方法です.ビルド番号まで含めて再現されるためです.しかし,環境特有のビルドを含むもの[^mkl]をインストールしていると他の環境ではエラーになる場合があります.これを解決する手段は公式からは提供されていないので,もし必要な場合は主要なモジュールのみを書いて,バージョン番号のみ指定する事が出来ます.

name: test-env-generic
channels:
  - defaults
dependencies:
  # "パッケージ名=バージョン"の形式で書く
   - numpy=1.16.2
   - python=3.7.0
# 省略

pipenv install or poetry install

Pipenvで管理している場合,PipfileとPipfile.lockをリポジトリに含めておけば大抵上手くいきます.Poetryの場合もpyproject.ymlとpoetry.lockを含めておけば良いです.

# Pipfileのあるディレクトリで実行する
$ pipenv sync
# 開発用パッケージを含める場合
$ pipenv sync --dev

エディタ

何でコードを書くかはしばしば宗教戦争となりがちですが,僕個人は

という棲み分け(?)をしています.この記事を参考にする人の多くはVimは慣れていないと思いますので,代わりにVSCodeを推していきます(実際たまに使っています)

PyCharm

www.jetbrains.com

JetBrains製のPythonIDEです.WindowsMacLinuxをサポートしており,3つのエディションがあります.

バージョン 価格 特徴
Professional 有料・サブスクリプション リモートインタプリタを使用可能,各種フレームワークのサポートなど
Community 無料 -
Education 無料 機能はCommunityと同等.教育用で学習コースが付属.

ややこしいのですが,学生はアカデミックアカウントの登録を行うことで,Professionalを無料で使えます*5(Educationは元から無料です).

良いところは

  • ほとんど設定無しにデフォルトのLinter,Formatterでそれなりに綺麗にコードを書ける
  • 補完候補表示の精度が良い
  • デバッグがめちゃくちゃやりやすい
  • 設定無しに型ヒントによる文法チェックと補完が有効
  • IDEらしくこれ一つで完結するため,ターミナルの出番が少ない

微妙なところ

  • やはり単純なエディタよりメモリの消費が激しい
  • 動作中は軽いが,仮想環境のモジュールに対するインデクシング処理が激しく重く,また,これが動いている間は補完やシンタックスハイライトが死ぬ
  • 慣れると他のものを使えなくなるので学生の間に慣れると将来の出費が確約されてしまう

VSCode

code.visualstudio.com

Microsoft製のOSSWindows, Mac, Linuxで動く超人気テキストエディタ.実際とてもよく出来ているし,軽い.

良いところ

  • 豊富なツールサポート(Git,Linter,Formatter,仮想環境…etc)
  • InteliSenseがよく出来ている
  • 比較的軽い
  • 豊富な拡張機能

微妙なところ

  • 設定が見づらい上に量が多すぎて検索しないとやってられない
  • Formatterなどを毎回そのプロジェクトの仮想環境にインストールしないといけない.

正直そこまで弱点がない良いエディタだと思います.

コーディングの補助

Pythonにはコーディング規約としてPEP8というものがあります*6.コーディング規約というのは,複数人で開発する際に個人間で書き方に細かい違いが出てしまわないように,あらかじめ制定されたコーディングの際のルールです.これに従わなくてもPythonとしての文法が保たれていれば実行できますが,コードの保守性,クオリティを担保するため従っておくべきでしょう. 書いているといつの間にかある程度覚えてきますが,そのチェックを毎回人が行うことは容易ではありません.そういう退屈なことはPythonがやってくれます.

Linter

Lintとは文法チェックのことを指します.コードを読み取り.コーディング規約にちゃんと従っているかを検証してくれます. Pythonでは様々なLinterが実装されていますが,代表的なもののみ示します.

  • pycodestyle
    • 元々はpep8というモジュール名だったが,ややこしいため最近名前が変更された
    • blog.amedama.jp
  • flake8
    • pycodestyleとpyflakes,mccabeのラッパー.
    • pyflakesはエラー解析を行い,未使用の変数や使われていないimport文を検知する
    • mccabeはifやforなどの複雑さを評価する循環的複雑度を算出する
    • minus9d.hatenablog.com
  • pylint
    • VSCodeではデフォルトで設定されているLinter.
    • 上記2つより少し厳しい
  • mypy
    • Pythonは動的型付けですが,type hintsというPEP484で策定された型補完機能を活用し,引数などの型の一致不一致を検証してくれます
    • Python 3.5まででは専用の形式のコメントで,3.5以降では専用の構文によってPythonの構文に型を付けることが出来るようになりました
    • これは型チェックだけでなく,補完が有効になるという嬉しい特典もあります.
    • qiita.com

個人的にはいいかんじに文法チェックとコーディング規約のチェックをやってくれてうるさすぎないflake8 + 型チェックのためのmypyの組み合わせが,バランスも良いと思います.

VSCodeでの設定

PyCharmでは面倒臭がってデフォルトの設定から動かしていない*7ので,VSCodeの設定例だけ示しておきます.なお,前提としてVSCodeにはPython拡張機能がインストールされているものとします.

Cmd + ,で設定を開き,以下の項目を検索して値を変更します.

  • python.linting.pylintEnabled:チェックを外す
  • python.linting.flake8Enabled:チェックを入れる
  • python.linting.mypyEnabled:チェックを入れる
  • python.linting.lintOnSave:チェックを入れる

~~ is not installedみたいなエラーが出ると思うので,それぞれインストールします.

$ pipenv install --dev flake8 mypy

単純なFizzBuzzのコードですが,わざと汚く書いてみました.

f:id:pudding_info:20190319011122p:plain
FizzBuzz

問題のある箇所に赤の波線が引かれています.カーソルを合わせると内容が出ます.例えばimportしているけど使っていないosモジュールについての警告が以下です.Quick Fixを押すと修正……され…ません…*8

f:id:pudding_info:20190319011209p:plain
unused import

また,i % 5i % "hoge"にわざと間違えてみると型チェックにより警告が出ます.

f:id:pudding_info:20190319011232p:plain
type error

なお,Quick Fixを押しても解決はされません…

Formatter

Linterがコードには手を加えない文法チェッカであったのに対して,Formatterは実際にコードを自動で書き換え,適合するスタイルに書き換えてくれます.これもまた複数の実装が存在します.

  • autopep8
    • PEP8に準拠するようにフォーマットしてくれる
    • おそらく最も有名
  • yapf
  • black
    • つい最近出てきた,比較的新しいFormatter.
    • 大変ストイックで,設定できるのが1行当たりの文字数しかない.
    • フォーマットの例:https://black.now.sh

ドキュメントの豊富さと,導入の簡単さからautopep8を推します.pycodestyleのチェックに通るようにしてくれる程度で過度なスタイルを強要してこないのもカジュアルに書けて嬉しいところです.

VSCodeでの設定

Cmd + ,で設定を開き,以下の項目を検索して値を変更します.

  • python.formatting.providerautopep8を選択
  • editor.formatOnType:チェックを入れる
  • editor.formatOnSave:チェックを入れる

~~ is not installedみたいなエラーが出ると思うので,それぞれインストールします.

$ pipenv install --dev autopep8

Cmd + Shift + Pでコマンドパレットを開き,formatと検索して実行するとフォーマットされます.

f:id:pudding_info:20190319011449g:plain
vscode fmt

formatOnTypeを有効にしているので,打ち込みながら勝手に修正されていきます.

f:id:pudding_info:20190319011512g:plain
vscode fmt on type

GitとPython

近年,コードはGitで管理というのが当たり前です*9Pythonで書かれるプロジェクトもよくGitで管理されますが,ときどき悲しくなるリポジトリを見かけます.それは

  • 仮想環境が丸々入っている
  • *.pyc__pycache__が含まれている
  • 環境を再構築するための情報が無い

などです.これらは簡単に避けることが出来ます.

Git監視下に置かない方が良いファイル

.gitignoreというファイルをプロジェクト直下に記述します.gitignoreすべきファイルの参考となるものが,GitHub公式に上がっています.基本はこれをコピペで良いでしょう.

github.com

面倒臭いときは以下のものを足しておけば十分かと思います

# 仮想環境の排除
.venv
venv/

# pyenvのバージョンファイル
.python-version

# キャッシュなど
__pycache__/
*.py[cod]
*$py.class

# mypyのキャッシュ
.mypy_cache/

追加しておくべきファイル

requirements.txt,Pipfile,Pipfile.lockなどパッケージにまつわるファイルは追加しておくべきです. また,

  • README.md
    • プロジェクトの説明や使い方,環境構築の仕方などを書く
  • LICENSE
    • GitHub等から追加できるのでOSSにする場合はライセンスを選択して追加しておく
    • qiita.com

などもあると良いでしょう.他に,.pylintrcなどのlintの設定なども共有のために含めておくことをオススメします.

開発開始までの流れ

  1. プロジェクトのディレクトリを作成し,pipenv install --python 3.7
  2. pipenv install --dev flake8 autopep8 mypy
  3. VSCodeかPyCharmで開く
  4. コードを書く

まとめ

長々と書いてしまって本当にすいませんという気持ちで一杯です. これはこうした方がいい,setup.pyについて書かなさすぎだろ等,様々な意見があると思いますのでコメント等頂ければ追記していきたいです.

*1:github.com

*2:Support Poetry depenedency manager · Issue #1871 · Microsoft/vscode-python · GitHub

*3:qiita.com

*4:docs.conda.io

*5:www.jetbrains.com

*6:pep8-ja.readthedocs.io

*7:PyCharmではデフォルトでPEP8準拠の警告,型チェック等がFormatterと共に装備されています.

*8:なんで?

*9:これはソフトウェア工学を学ぶ人間としての感想です