CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: inspect.signature() で関数のシグネチャを調べる

Python は inspect モジュールを使うことでオブジェクトから多くの情報を取得できる。 そして、関数のシグネチャを調べる方法としては、これまで getargspec() 関数が使われることが多かった。 ただ、この関数は Python 3 系では非推奨 (Deprecated) となっている。 そのため、今後は使わない方が良い。

getargspec() 関数が非推奨となった代わりに、今は signature() 関数を使うことが推奨されているようだ。 今回は、この関数の使い方について扱う。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.6
BuildVersion:   15G1004
$ python --version
Python 3.5.2

インストール

Python 3.3 以降であれば signature() 関数は標準ライブラリの inspect モジュールに用意されている。 そのため、特に何も意識することなく使うことができる。

しかし、もし使っているのが Python 3.2 未満であれば PyPI からバックポート版をダウンロードして使うことができる。

$ pip install funcsigs

使ってみる

今回は Python の REPL を使って動作を確認していこう。

$ python

早速 inspect モジュールの signature() 関数をインポートしよう。

>>> from inspect import signature

もし、Python 2 系でバックポート版を使っているのであれば、次のようにしてインポートできる。

>>> from funcsigs import signature

まず手始めに、次のような関数 f() を定義しておく。 この関数は foo と bar というふたつのパラメータを受け取る。 bar に関してはデフォルト値が設定されている。

>>> def f(foo, bar='Hello'):
...     pass
...

それでは、signature() 関数に先ほど定義した関数 f() を渡してみよう。 すると Signature というオブジェクトが得られることが分かる。

>>> signature(f)
<Signature (foo, bar='Hello')>

このオブジェクトには parameters という名前で受け取るパラメータの情報が格納されている。 このメンバは次のように順序関係を持った辞書型のオブジェクトとして得られる。

>>> sig = signature(f)
>>> sig.parameters
mappingproxy(OrderedDict([('foo', <Parameter "foo">), ('bar', <Parameter "bar='Hello'">)]))

例えばパラメータの foo について知りたいときは、次のように名前をキーとしてアクセスすれば良い。 得られるのは Parameter というオブジェクトになっている。

>>> sig.parameters['foo']
<Parameter "foo">

このオブジェクトには名前はもちろん、デフォルト値などの情報も入っている。 もしデフォルト値が設定されていないときは inspect.Signature.empty で得られるオブジェクトになる。

>>> sig.parameters['foo'].name
'foo'
>>> sig.parameters['foo'].default
<class 'inspect._empty'>
>>> sig.parameters['bar'].default
'Hello'

その他、パラメータがどのような使われ方をしているかも得られる。 例えば、通常であれば次のように POSITIONAL_OR_KEYWORD になる。

>>> sig.parameters['foo'].kind
<_ParameterKind.POSITIONAL_OR_KEYWORD: 1>

それに対し *args, **kwargs といった可変長引数のときは VAR_POSITIONAL や VAR_KEYWORD になる。

>>> def f(*args, **kwargs):
...     pass
...
>>> signature(f)
<Signature (*args, **kwargs)>
>>> sig = signature(f)
>>> sig.parameters['args'].kind
<_ParameterKind.VAR_POSITIONAL: 2>
>>> sig.parameters['kwargs'].kind
<_ParameterKind.VAR_KEYWORD: 4>

また、Python 3 の機能ということもあってアノテーションにも対応している。 次のように引数や返り値にアノテーションをつけた関数を用意して signature() にかけてみる。

>>> def f(foo: str) -> int:
...     return -1
...
>>> signature(f)
<Signature (foo:str) -> int>
>>> sig = signature(f)

すると Parameter からは annotation というメンバでアノテーションに指定した型が得られる。

>>> sig.parameters['foo'].annotation
<class 'str'>

また Signature からも return_annotation というメンバで返り値のアノテーションが取得できる。

>>> sig.return_annotation
<class 'int'>

それ以外にも便利な機能がある。 例えば、その関数を特定の引数で実行したときに、パラメータが引数に対してどのようにバインドされるかを確かめることもできる。 これには Signature#bind() メソッドを使う。

>>> def f(foo, bar='Hello', baz=None, *args, **kwargs):
...     pass
...
>>> sig = signature(f)
>>> sig.bind('foo', baz=1, bar='Bye', hoge=True)
<BoundArguments (foo='foo', bar='Bye', baz=1, kwargs={'hoge': True})>

上記で得られるのは BoundArguments というオブジェクトになっている。 このオブジェクトからは arguments というメンバでバインドした結果が得られる。

>>> bound_args = sig.bind('foo', baz=1, bar='Bye', hoge=True)
>>> bound_args.arguments
OrderedDict([('foo', 'foo'), ('bar', 'Bye'), ('baz', 1), ('kwargs', {'hoge': True})])

上記を応用すると、関数の呼び出しをトレースするようなデコレータを書きやすいなと思った。 次のサンプルコードでは @recording というデコレータを定義している。 このデコレータは修飾した関数をラップして、呼び出し内容と結果をログに自動で残す機能を持っている。

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

try:
    from inspect import signature
except ImportError:
    from funcsigs import signature

import logging
import functools


LOG = logging.getLogger(__name__)


def recording(f):
    """関数 (メソッド) の呼び出しを記録するデコレータです。

    受け取ったパラメータと返り値をログに出力します。"""
    @functools.wraps(f)
    def _recording(*args, **kwargs):
        # 表面上は元々の関数 (メソッド) がそのまま実行されたように振る舞う
        result = f(*args, **kwargs)
        # デコレーションする関数のシグネチャを取得する
        sig = signature(f)
        # 受け取ったパラメータをシグネチャにバインドする
        bound_args = sig.bind(*args, **kwargs)
        # 関数名やバインドしたパラメータの対応関係を取得する
        func_name = f.__name__
        func_args = ','.join('{k}={v}'.format(k=k, v=v)
                             for k, v in bound_args.arguments.items())
        # ログに残す
        fmt = '{func_name}({func_args}) -> {result}'
        msg = fmt.format(func_name=func_name,
                         func_args=func_args,
                         result=result)
        LOG.debug(msg)
        # 結果を返す
        return result
    return _recording


# デコレーションすると、その関数の呼び出しをログに記録する
@recording
def add(a, b):
    return a + b


def main():
    # デバッグレベルのログも出力する
    logging.basicConfig(level=logging.DEBUG)
    # デコレーションされた関数を呼び出すと自動でログが出力される
    print(add(1, 2))


if __name__ == '__main__':
    main()

上記を実行すると、次のような出力が得られる。

$ python recording.py
DEBUG:__main__:add(a=1,b=2) -> 3
3

見事に関数の呼び出しと、その返り値までが自動でログに記録された。

めでたしめでたし。

統計: 最小二乗法を使って回帰直線を求める

今回は、データセットのある次元の値から別の次元の値を予測する方法の一つとして、最小二乗法というやり方で回帰直線を求めてみる。 とはいえ、いきなり最小二乗法や回帰直線といわれても何が何やらという感じなので、最初はその説明からしていく。

回帰

まず、ある次元の値から別の次元の値を予測するというのは具体的にどういうことだろうか。 例えば、賃貸の部屋の広さと家賃の値段に相関関係があるとしよう。 つまり、広い部屋はそれだけ家賃も高いということで、これは直感に反していない。 だとすれば、部屋の広さと家賃の値段の関係が含まれたデータセットがあるとき、部屋の広さから家賃の値段を予測できるはずだ。

統計や機械学習の世界では、このような問題のジャンルに「教師あり学習」の「回帰」という名前がついている。 ただし、ある次元の値から別の次元の値を予測する問題がすべて回帰と呼ばれるわけではない。 なぜかというと、その次元に含まれるデータの種類によって、やることが異なるため。

データの種類には、大きく分けて二つある。 まず、男性や女性、あるいは好きな食べ物といった、いくつかの種類からひとつを選ぶようなものは質的データという。 それに対し、年齢や一日の摂取カロリーなど、値が連続性を持ったものは量的データという。

ある次元から別の次元を予測するとき、予測する対象が質的データなら「分類」で、量的データなら「回帰」になる。 そして、予測の元ネタになるデータのことを説明変数、予測する値のことを応答変数と呼ぶ。 このとき、説明変数がひとつなら単回帰、複数なら重回帰と呼ばれる。

以上から、今回このエントリでやってみるのは単回帰になる。 回帰直線というのは、説明変数と応答変数の関係を次の一次式で表した直線のこと。

y = \alpha + \beta x

このとき、x が説明変数で y は応答変数を表している。 \alpha\beta は切片と傾きに対応していて、これは回帰係数と呼ばれている。

最小二乗法

最小二乗法というのは、教師データに最もフィットする回帰係数を見つける手法のことをいう。

最小二乗法では、次に示す e_i を最小化するようになっている。 ここで \hat{y_i}y の予測値を表している。 予測値にはハットの記号をつける慣例になっているため。 この e_i のことを残差という。 ようするに、これは真の値と予測値との差のこと。

e_i = y_i - \hat{y_i}

より具体的には、この残差を自乗した合計値が最も小さくなる回帰係数を見つけるのが目的になる。

S(\hat{\alpha}, \hat{\beta}) = \sum_{i = 1}^{N} e_i ^2

これを残差平方和という。 自乗しているのは符号をすべてプラスにするため。

これは e_i を展開すると、こうなる。

S(\hat{\alpha}, \hat{\beta}) = \sum_{i = 1}^{N} ( y_i - \hat{ y_i }) ^2 = \sum_{i = 1}^{N} (y_i - (\hat{\alpha} + \hat{\beta} x_i)) ^2

これを最小にする回帰係数を得る手法を最小二乗法という

具体的な手順

先ほどの式は \hat{\alpha}\hat{\beta} で偏微分して、それが 0 に等しいとすると連立一次方程式に直すことができる、らしい。 が、そこに至るまでの過程はひとまず飛ばして、先人の知恵だけ享受することにする。

まず、必要なのは次の回帰直線を表した一次式。

\hat{y} = \hat{\alpha} + \hat{\beta} x

この中で、まずは \hat{\beta} は、次の式で得られる。

\hat{\beta} = \frac{s_{xy}}{s_x ^2}

ここで、s_{xy}xy の共分散を表している。 つまり、こう。

s_{xy}  = \frac{1}{N} \sum_{i = 1}^{N} (x_i - \bar{x})(y_i - \bar{y}) = \frac{1}{N} \sum_{i = 1}^{N} x_i y_i - \bar{x} \bar{y}

共分散や相関関係については、前回のエントリに詳しく書いたので見てもらいたい。

blog.amedama.jp

また、s_x ^2x の分散を表している。 つまり、こう。

s_x ^2 = \frac{1}{N} \sum_{i = 1}^{N} (xi - \bar{x}) ^2

\bar{x}x の平均値を表すので、こうだね。

\bar{x} = \frac{1}{N} \sum_{i = 1}^{N} x_i

そして、次に \hat{\alpha} は、次のようにして得られる。

\hat{\alpha} = \bar{y} - \hat{\beta}\bar{x}

\bar{x} と同様に、\bar{y}y の平均値を表している。

\bar{y} = \frac{1}{N} \sum_{i = 1}^{N} y_i

数式がずらずら並んでなんのこっちゃという感じになってきたので、まとめると…

\hat{y} = \hat{\alpha} + \hat{\beta} x

という回帰直線の一次式に含まれる回帰係数 \hat{\alpha}\hat{\beta} は、それぞれ

\hat{\beta} = \frac{s_{xy}}{s_x ^2}

\hat{\alpha} = \bar{y} - \hat{\beta}\bar{x}

という式で得られる、ということ。

実際に計算してみよう

では、上記を Python のプログラムで実際に計算して上手くいくか試してみよう。

サンプルプログラムでは matplotlib を使ってグラフを描いているので、まずはインストールしておく。

$ pip install matplotlib

次が肝心のサンプルプログラム。 中で最小二乗法を使って回帰直線を得ている。

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

from __future__ import division

from matplotlib import pyplot as plt

import numpy as np


def main():
    # データ点数は 100 にする
    N = 100
    # 0 ~ 10 (-5 ~ +5) の範囲で、ランダムに値を散らばらせる
    R = 10

    x = np.arange(N)
    # y = 50 + x の直線に (-5 ~ +5) でランダム性をもたせる
    y = np.arange(N) + np.random.rand(N) * R - R // 2 + 50

    # 平均値と標準偏差
    xmu, xsigma2 = x.mean(), x.var()
    # 平均値
    ymu = y.mean()

    # 共分散
    sxy = np.sum(x * y) / N - xmu * ymu

    # 回帰係数ベータ
    beta = sxy / xsigma2
    # 回帰係数アルファ
    alpha = ymu - beta * xmu

    # 得られた回帰直線の一次式
    print('y = {0} + {1}x'.format(alpha, beta))

    # グラフに回帰直線をひく
    lsm = np.array([alpha + beta * xi for xi in x])
    plt.plot(x, lsm, color='r')

    # 元データもプロットする
    plt.scatter(x, y)

    # グラフを表示する
    plt.xlabel('x')
    plt.ylabel('y')
    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

これを lsm.py という名前をつけて実行してみる。

$ python lsm.py 
y = 49.52693410299069 + 0.9984255719901004x

データにはランダム性を持たせているため、微妙に異なるものの、ほぼ y = 50 + x の一次式が得られている!

また、可視化したグラフは次のようになった。

f:id:momijiame:20161008224310p:plain

データに沿って、ちゃんと回帰直線がひけていることが分かる。

まとめ

  • データセットのある次元から別の次元を予測するのに、回帰という問題のジャンルがある
  • 予測するデータの種類によって問題の呼び方は異なる
    • 質的データなら「分類」になる
    • 量的データなら「回帰」になる
  • 予測の元ネタを説明変数、予測する値のことを応答変数という
  • 説明変数がひとつの次元なら単回帰、ふたつ以上なら重回帰という
  • 回帰のやり方のひとつに最小二乗法という手法がある
    • 最小二乗法では残差平方和を最小化する
    • 予測値と真の値との差を残差という
    • 残差の自乗をすべて足したものが残差平方和
  • 説明変数と応答変数の関係を一次式で表した直線を回帰直線という
    • 回帰直線のパラメータとなる \hat{\alpha}\hat{\beta} を回帰係数という

おまけ

回帰係数の \hat{\beta} は式変形すると別の求め方もできる。

これには、回帰係数 r_{xy}xy の標準偏差を使う。

\hat{\beta} = r_{xy} \frac{s_y}{s_x}

回帰係数は、次のように求める。

r_{xy} = \frac{s_{xy}}{s_x s_y}

ここで、s_{xy} は共分散なので、次のように求める。

s_{xy}  = \frac{1}{N} \sum_{i = 1}^{N} (x_i - \bar{x})(y_i - \bar{y}) = \frac{1}{N} \sum_{i = 1}^{N} x_i y_i - \bar{x} \bar{y}

s_xs_y は標準偏差なので、こう。

s_x = \sqrt{\frac{1}{N} \sum_{i = 1}^{N} (x_i - \bar{x}) ^2 }

s_y = \sqrt{\frac{1}{N} \sum_{i = 1}^{N} (y_i - \bar{y}) ^2 }

\bar{x}\bar{y} は平均値なので、こう。

\bar{x} = \frac{1}{N} \sum_{i = 1}^{N} x_i

\bar{y} = \frac{1}{N} \sum_{i = 1}^{N} y_i

これを、先ほどのプログラムに適用してみる。

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

from __future__ import division

from matplotlib import pyplot as plt

import numpy as np


def main():
    N = 100
    R = 10
    x = np.arange(N)
    y = np.arange(N) + np.random.rand(N) * R - R // 2 + 50

    xmu, xsigma = x.mean(), x.std()
    ymu, ysigma = y.mean(), x.std()

    sxy = np.sum(x * y) / N - xmu * ymu

    rxy = sxy / (xsigma * ysigma)

    beta = rxy * (ysigma / xsigma)
    alpha = ymu - beta * xmu

    print('y = {0} + {1}x'.format(alpha, beta))

    lsm = np.array([alpha + beta * xi for xi in x])
    plt.plot(x, lsm, color='r')

    plt.scatter(x, y)

    plt.xlabel('x')
    plt.ylabel('y')

    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

実行結果は次の通り。

$ python lsm2.py 
y = 50.359765922390736 + 0.9931534165033539x

グラフもこのように。 f:id:momijiame:20161008225924p:plain

どちらでも、大丈夫だね。

統計: 共分散と相関係数でデータセットの相関を調べる

まず、二次元の特徴量をもったデータセットがあるときを考えてみよう。

もし、一方の次元の値が高いときに、もう一方も高い傾向があるときは、両者に正の相関があるという。 反対に、一方の次元の値が高いときに、もう一方は低い傾向があるときは、両者に負の相関があるという。

では、それぞれの次元に正または負の相関があるか否かを調べるには、具体的にどうしたら良いのだろうか。

散布図を描いてみる

それにはまず、散布図を描いてみるという選択肢がある。 x 軸と y 軸に、それぞれの次元の値をプロットするやり方だ。

このとき、例えば正の相関があるなら、値は次のように左下から右上にかけてプロットされる。

f:id:momijiame:20160924161630p:plain

これはつまり x 軸の次元の値が高いときに y 軸の次元の値も高くなることを示す。

反対に、負の相関があるなら、値は次のように左上から右下にかけてプロットされる。

f:id:momijiame:20160924161639p:plain

これはつまり x 軸の次元の値が高いときに y 軸の次元の値は低くなることを示す。

共分散・相関係数という統計量

ただ、散布図を描いただけでは、具体的にどれくらいの相関があるのかが分からない。 そう、相関には強弱がある。

例えば、次のようなふたつの散布図がある。 相関の強弱でいえば、前者の方が後者よりも強い。

f:id:momijiame:20160924161630p:plain

上記の相関は、後者と比較すると強い。

f:id:momijiame:20160924161729p:plain

上記の相関は、前者と比較すると弱い。

では、相関の強弱は具体的にどのようにすれば分かるのだろうか。 それには、相関の強弱を表す統計量を計算することになる。 それが今回紹介する共分散や相関係数といったもの。

共分散

共分散というのは、各次元の値から平均値を引いたもの同士をかけ合わせた上で、総和を取ってデータの点数で割ったもの。 これは、一言で表せば各次元の偏差 (平均値を引いた値) の積 (かけ算した値) の平均値 (総和をデータ点数で割った値) を計算している。

数式で表すと、次のようになる。

{ \displaystyle
s_{xy} = \frac{1}{N} \sum_{i = 1}^{N} (x_i - \bar{x}) (y_i - \bar{y})
}

上記で \bar{x}\bar{y} は、各次元の平均値を表している。

この共分散という統計量は、正の相関が強いほど数値がプラスに大きくなる。 そして、反対に負の相関が強いほど数値はマイナスに大きくなる。 また、相関が弱ければ数値はゼロに近づく。

例えば、先ほどの散布図であれば、前者は共分散が 835 であるのに対し、後者は 820 だった。

なぜ、そうなるのか?

先ほど、共分散の性質として、正の相関が強いほど数値がプラスに大きく、負の相関が強いほど数値がマイナスに大きくなる、と説明した。 ここでは、「そういうものなんだ」と納得してしまうよりも、理屈から理解しておきたい。

そこで、まずは補助線と補足を入れた次の散布図を見てほしい。

f:id:momijiame:20160924162131p:plain

補助線は x 軸と y 軸の平均値を示している。

各次元の補助線にもとづいて、それぞれの点が意味するところを考えてみよう。 点が、もし x 軸の補助線よりも右側にあれば偏差はプラスとなり、そして左側にあればマイナスになることが分かる。 同様に、点がもし y 軸の補助線よりも上側にあれば偏差はプラスとなり、そして下側にあればマイナスになることが分かる。

先ほどの例で示した散布図では、補助線を引くと第一象限と第三象限に点が集まっていた。 それにもとづいて、各象限における偏差と偏差の積の関係を図示してみよう。

f:id:momijiame:20160924162313p:plain

第一象限においては各次元の偏差がプラスになるため、積はプラスになる。 同様に第三象限においては各次元の偏差がマイナスになるため、積はプラスになる。

上記のようなパターンでは偏差の積はほとんどがプラスになることから、その平均値もプラスになることが分かる。

同じように、第二象限と第四象限に点が集まっているパターンについても考えてみよう。

f:id:momijiame:20160924162442p:plain

先ほどの例から、既にだいたい分かると思うんだけど、このときは偏差の積がマイナスになる。 そのため、偏差の積の平均値を取ると、その値がマイナスになることが分かる。

では、値がまんべんなく分布しているパターンではどうなるか。

f:id:momijiame:20160924162529p:plain

このようなときは、偏差の積がプラスだったりマイナスだったりとまちまちになる。 そのため、平均値を計算するとゼロに近づくというわけ。

ということで、共分散を見れば相関が正なのか負なのか分かることが理解できた。

相関係数への拡張

じゃあ共分散さえ見ておけばいつでもオッケーかというと、そうはいかない。 なぜかというと、共分散はそれ単独では相関の強弱が分かりづらいし、異なるデータセットで比較ができない。 どういうことかというと、共分散は元々のデータセットの値の大小 (単位) に影響を受けてしまう。

例えば、カブトムシとクジラの体長と体高の共分散について考えてみよう。 それぞれの値は、カブトムシが数 cm オーダーだとしたら、クジラは数千 cm オーダーになる。 それぞれの偏差の積の平均値を考えると、値の大きさが全く異なるだろうことが分かる。

相関の強度を異なるデータセットで比べるには、まずは単位に依存しない無名数に変換しないといけない。 それが次に紹介する相関係数だ。 これは、共分散を、各次元の標準偏差の積で割ったもの。

数式で表すと次のようになる。

r = \frac{s_{xy}}{s_x s_y}

s_{xy} が共分散で、s_xs_y が各次元の標準偏差。

省略せずに書くと、こう。

{ \displaystyle
r = \frac
{
  \frac{1}{N} \sum_{i = 1}^{N} (x_i - \bar{x}) (y_i - \bar{y})}
{
  \sqrt{\frac{1}{N} \sum_{i = 1}^{N} (x_i - \bar{x}) {}^2}
  \sqrt{\frac{1}{N} \sum_{i = 1}^{N} (y_i - \bar{y}) {}^2}
}
}

分母となる各次元の標準偏差は、二乗して平方根を取ることで符号をすべてプラスにしている。 そのため、共分散と比べるとプラスとマイナスの値が互いに打ち消し合うことがない。 つまり、分母の標準偏差の積は共分散が取りうる最大値となることが分かる。 結果として、相関係数は相関の強弱が -1 から 1 の間で得られることになる。

例えば、共分散で相関の強弱を示したときに使った散布図で、同じように相関係数も計算してみよう。 まず、この散布図では相関係数が 0.9897 として得られた。

f:id:momijiame:20160924161630p:plain

続いて、こちらの場合には相関係数は 0.805 だった。

f:id:momijiame:20160924161729p:plain

相関の強弱が、共分散よりも分かりやすく得られている。

また、次のようにふたつの次元が完全に比例するときは、相関係数が 1.0 になる。

f:id:momijiame:20160924163017p:plain

共分散と相関係数の注意点

ただ、共分散や相関係数でふたつの次元の相関関係が分かるとはいえ、それだけに頼るのは避けた方が良い。

例えば、次の散布図を見てもらいたい。

f:id:momijiame:20160924163634p:plain

上記の散布図には、ふたつの次元に明らかな規則性が見て取れる。

では、上記の共分散と相関係数はどうなるだろうか? なんと、どちらもゼロになるのだ。

共分散と相関係数は、あくまでふたつの次元の間に線形な関係があるか否かしか見ることができない。 先ほどのように、人の目でみればあきらかな法則性があったとして、数値の上ではそれが分からない。 そのため、ふたつの次元の間に関係性を見いだそうとするときは、共分散や相関係数だけを確認して終わることは避ける必要がある。

まとめ

  • ふたつの次元の相関関係の強弱は、共分散や相関係数といった統計量を計算することで分かる
  • ただし、それで分かるのは線形な関係があるか否かだけなので、それだけを確認して終わることは避ける必要がある

おまけ

最初のグラフを描くのに使った Python のソースコードは次の通り。

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

from matplotlib import pyplot as plt

import numpy as np


def main():
    N = 100
    R = 10
    x = np.arange(N) + np.random.rand(N) * R - R // 2
    y = np.arange(N) + np.random.rand(N) * R - R // 2

    xmu, xsigma = x.mean(), x.std()
    ymu, ysigma = y.mean(), y.std()

    covariance = sum([(xi - xmu) * (yi - ymu) for xi, yi in zip(x, y)]) / N
    print('共分散:', covariance)
    correlation_coefficient = covariance / (xsigma * ysigma)
    print('相関係数:', correlation_coefficient)

    plt.scatter(x, y)

    plt.xlabel('x')
    plt.ylabel('y')

    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

依存ライブラリとして matplotlib を使っているので、実行する前にインストールする必要がある。

$ pip install matplotlib

Python: Fabric をスクリプトに組み込んで使う

Fabric は Python で書かれたデプロイやオペレーションを自動化するためのツール。 Fabric では、タスクと呼ばれるオペレーション内容も Python で書く。

今回は、普段ならコマンドラインツールから使うことが多い Fabric を Python のスクリプトに組み込んで使う方法について書く。 尚、Fabric はリモートのホストに接続して使うことが多いため、そのホストとして Vagrant を使って Ubuntu 16.04 LTS のマシンを用意した。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.6
BuildVersion:   15G1004
$ python --version
Python 2.7.10

インストール

まずは pip を使って Fabric をインストールしておく。

$ pip install fabric

Vagrant で仮想マシンを用意する

ここでは Vagrant がすでにインストール済みであることを想定する。 もし、入っていないときは公式サイトのバイナリか Homebrew Cask などを使ってインストールしてほしい。

まずは Vagrant の設定ファイル (Vagrantfile) を用意する。 イメージは Chef 社の提供する bento リポジトリにあるものを使った。

$ cat << 'EOF' > Vagrantfile
# -*- mode: ruby -*-
Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-16.04"
  config.vm.provider "virtualbox" do |vb|
    vb.cpus = "2"
    vb.memory = "1024"
  end
end
EOF

仮想マシンを起動する。

$ vagrant up

起動できたら vagrant ssh-config コマンドを実行しよう。 ここで Port の項目を確認しておく。 おそらく通常は 2222 が割り振られているはず。 ただ、異なる場合には後述する手順で指定するポート番号を変更する必要がある。

$ vagrant ssh-config
Host default
  HostName 127.0.0.1
  User vagrant
  Port 2222
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile /Users/amedama/Documents/vagrant/ubuntu1604/.vagrant/machines/default/virtualbox/private_key
  IdentitiesOnly yes
  LogLevel FATAL

これは OpenSSH の設定ファイルと同じフォーマットになっている。 上記の設定から、先ほど Vagrant で作った仮想マシンには 127.0.0.1:2222 に SSH することでログインできることがわかる。

さて、これで Fabric を使う対象となる仮想マシンの準備ができた。

Fabric の設定ファイルを用意する

Fabric はデフォルトで fabfile という名前のモジュール (またはパッケージ) を、タスクが記述された処理対象として扱う。 なので fabfile.py という名前でファイルを用意しよう。 Python ではスクリプトファイル (*.py) とモジュールが 1:1 で対応している。 例えば fabfile.py であれば Python インタプリタはそれを fabfile というモジュールとして扱うということ。

それでは、今回の主役となる fabfile.py を用意する。 これには task() 関数として Fabric のタスクが定義されている。 この task() 関数はリモートのホスト上で、通常ユーザ権限を使って uptime コマンドを実行することを示している。 また、main() 関数では task 関数を引数にして execute() 関数を実行することもわかる。

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

from fabric.api import run
from fabric.api import execute

def task():
    run('uptime')


def main():
    execute(task, hosts=['vagrant@127.0.0.1:2222'])


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

普段の使い方

Fabric がインストールされていると fab コマンドが使えるようになる。 この fab コマンド経由でタスクを実行するのが Fabric の普段の使い方だ。

先ほど用意した fabfile.py も fab コマンド経由で実行してみよう。 -H オプションでホスト名、--port オプションでポート名、-u オプションでユーザ名を指定する。 最後の引数は実行するタスク名だ。 仮想マシンのログインパスワードを聞かれるので「vagrant」答えよう。

$ fab -H localhost --port 2222 -u vagrant task
[localhost] Executing task 'task'
[localhost] run: uptime
[localhost] Login password for 'vagrant':
[localhost] out:  10:46:20 up 6 min,  1 user,  load average: 0.00, 0.05, 0.02
[localhost] out:


Done.
Disconnecting from localhost:2222... done.

上手く実行できた。

Python スクリプトとして実行する

先ほどは fab コマンドからタスクを実行した。 今度は Python スクリプトからタスクを実行してみよう。

これは、ただ単に python コマンドで fabfile.py ファイルを実行するだけだ。 先ほどと同じようにログインパスワードを聞かれるので「vagrant」と答える。

$ python fabfile.py
[vagrant@127.0.0.1:2222] Executing task 'task'
[vagrant@127.0.0.1:2222] run: uptime
[vagrant@127.0.0.1:2222] Login password for 'vagrant':
[vagrant@127.0.0.1:2222] out:  10:48:08 up 8 min,  1 user,  load average: 0.00, 0.03, 0.01
[vagrant@127.0.0.1:2222] out:

ちゃんと実行できたことがわかる。

実行した fabfile.py の内容をもう一度掲載しておく。 Python スクリプトとして実行したときは、この中の main() 関数が実行される。 そして、その中では execute() 関数にタスクとログイン名、ホスト名、ポート番号を指定していた。 つまり、組み込みで実行したいときはこの execute() 関数を使えば良いということが分かる。

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

from fabric.api import run
from fabric.api import execute

def task():
    run('uptime')


def main():
    execute(task, hosts=['vagrant@127.0.0.1:2222'])


if __name__ == '__main__':
    main()

環境を設定する

先ほどの例ではパスワードを手動で入力しなければいけなかった。 今度は、そこも自動化してみよう。

次の fabfile.py ではパスワードの手動入力が不要になっている。 ホスト名やユーザ名、パスワードといった Fabric の環境設定は fabric.api.env を指定すれば良い。

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

from fabric.api import run
from fabric.api import execute
from fabric.api import env

def task():
    run('uptime')


def main():
    env.hosts = ['127.0.0.1:2222']
    env.user = 'vagrant'
    env.password = 'vagrant'
    execute(task)


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

実行してみよう。

$ python fabfile.py
[127.0.0.1:2222] Executing task 'task'
[127.0.0.1:2222] run: uptime
[127.0.0.1:2222] out:  10:48:44 up 8 min,  1 user,  load average: 0.00, 0.03, 0.00
[127.0.0.1:2222] out:

ちゃんと実行できて、今度はパスワードを聞かれることもない。

まとめ

Fabric を Python スクリプトに組み込んで使うときは fabric.api.execute() 関数を使おう。

追記

このやり方にはちょっとした注意点があるため、次の記事で補足している。

blog.amedama.jp

統計: 変動係数で値のバラつきを比べる

まず初めに、次のようなヒストグラムがあったとする。

f:id:momijiame:20160902215826p:plain

このヒストグラムには、青色と緑色のふたつのグループが含まれている。 それぞれのグループは、平均値や度数が異なるようだ。 果たして、それぞれのグループはどちらの方が値のバラつきが大きいのだろうか?

標準偏差だけでは比較できない

通常、データセットの値のバラつきは分散や標準偏差、四分位数といった統計量で表される。 しかし、これらの統計量は、平均値や単位などが異なると単純に比較することはできない。

例えば、あるカブトムシの大きさの標準偏差が 1 cm で、あるクジラの大きさの標準偏差が 1 m だとしよう。 クジラの方が標準偏差にして 100 倍の大きさがある。 しかし、だからといってクジラの方がバラつきも大きいとは限らない。 元々、その生物がだいたいどれくらいの大きさなのかが分からなければ判断がつかない。

先ほどの例であれば、カブトムシがだいたい 10 cm で標準偏差が 1 cm なのと、クジラがだいたい 20 m で標準偏差が 1 m だとしたら? なんとなくカブトムシの方が値のバラつきが大きそうだ、というのが感覚的にも分かる。

変動係数を使う

こういったときは、両者を比較するのに変動係数という統計量を使う。 なんだか大仰な名前がついてるけど、これは単に標準偏差を平均値で割ったもの。 先ほど、感覚的にバラつきの大小を思い浮かべたときは、暗にこの値を比較していたはず。

変動係数の定義としては、こう

C.V. = \frac{\sigma}{\mu}

\sigma は母集団の標準偏差で、\mu は母集団の平均値を表している。

あるいは、対象が標本であれば標準偏差を s で、平均値を \bar{x} で表す。

C.V. = \frac{s}{\bar{x}}

統計の世界では、記号を使い分けることでその意味を伝える。 母集団が、鍋の中に入っているたくさんのスープだとしよう。 標本は、そのスープを味見するためにスプーンですくった一杯を表している。

変動係数で比べてみる

さて、話を変動係数に戻そう。 変動係数の定義通りに、標準偏差を平均値で割ると結果はどうなるだろうか。 これは、無名数といって単位がなくなる。 異なる単位のものを比べるには、この操作が必要になる。

先ほどのカブトムシとクジラの例で計算してみよう。 カブトムシは平均 10 cm で標準偏差が 1 cm だとすると変動係数は 1 / 10 = 0.1 になる。 クジラは平均 20m で標準偏差が 1 m なので変動係数は 1 / 20 = 0.05 だ。 変動係数は値が大きい方がバラつきが大きいことを表している。 感覚的にカブトムシの方がバラつきが大きそうだということを、数値の上でも確かめることができた。

最初に紹介したヒストグラムは?

最初に紹介したヒストグラムの母数 (パラメータ) は、実は次のようになっていた

    • 平均値: 66
    • 標準偏差: 20
    • 度数: 370000
    • 平均値: 122
    • 標準偏差: 41
    • 度数: 520000

ただし、変動係数の定義からすると度数は必要ない。

両方の変動係数を計算すると、まず青は次のようになる

C.V. = \frac{\sigma}{\mu} = \frac{20}{66} \fallingdotseq 0.303

続いて緑

C.V. = \frac{\sigma}{\mu} = \frac{41}{122} \fallingdotseq 0.336

青と緑を比較すると、緑の方が大きい

0.303 \lt 0.336

ということで、緑の方がバラついていることが分かった。

まとめ

異なる平均値や単位をもったデータセットのバラつきを比べるときには変動係数を使おう。

おまけ (その一)

最初のヒストグラムを書くのに使った Python のプログラムは次の通り。

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

import numpy as np

from matplotlib import pyplot as plt


def main():
    x1 = np.random.normal(66, 20, 370000)
    x2 = np.random.normal(122, 41, 520000)

    plt.hist(x1, 100, alpha=0.5)
    plt.hist(x2, 100, alpha=0.5)

    plt.grid()
    plt.show()


if __name__ == '__main__':
    main()

実行には matplotlib が必要なので pip でインストールする。

$ pip install matplotlib

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.6
BuildVersion:   15G31
$ python --version
Python 3.5.2

おまけ (その二)

ヒストグラムを生成するのに使った母数 (パラメータ) は大学入試センターの資料を使った。 具体的には、青が数学I/数学Aを、緑が英語の内容を、キリの良い数字で使っている。

www.dnc.ac.jp

ただし、実際のデータには最高値があるが、今回のヒストグラムにはない。

Python: line_profiler でボトルネックを調べる

前回は Python の標準ライブラリに用意されているプロファイラの profile/cProfile モジュールについて書いた。

blog.amedama.jp

今回は、同じ決定論的プロファイリングを採用したプロファイラの中でも、サードパーティ製の line_profiler を使ってみることにする。 line_profiler は profile/cProfile モジュールに比べるとコード単位でプロファイリングが取れるところが魅力的なパッケージ。 また、インターフェースについても profile/cProfile に近いものとなっている。

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

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.6
BuildVersion:   15G31
$ python --version
Python 3.5.2

インストール

まずは line_profiler をインストールする。 サードパーティ製のパッケージなので pip を使う。

$ pip install line_profiler

題材とするプログラム

ひとまず、前回も使った FizzBuzz を表示するソースコードをプロファイリングの対象にしよう。

次のようなソースコードを用意する。

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


def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)


def main():
    for i in range(1, 100):
        message = fizzbuzz(i)
        print(message)


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

このプログラムは実行すると 1 から 99 までの FizzBuzz を表示する。

$ python fizzbuzz.py
1
2
Fizz
...(省略)...
97
98
Fizz

プロファイリングしてみる

さて、それでは用意したソースコードを早速プロファイリングしてみよう。

ここでは Python の REPL を使って動作を確認していく。 先ほどのソースコードを用意したのと同じディレクトリで REPL を起動しよう。

$ python

REPL が起動したら、まずはプロファイリング対象の fizzbuzz モジュールをインポートしておく。 補足しておくと Python ではソースコード (*.py) が、そのままモジュールに対応している。 例えば先ほどの fizzbuzz.py であれば fizzbuzz モジュールとして使えるわけだ。

>>> import fizzbuzz

続いて line_prpfiler モジュールをインポートして LineProfiler クラスのインスタンスを生成しよう。 このインスタンスを使ってプロファイリングをする。

>>> import line_profiler
>>> pr = line_profiler.LineProfiler()

プロファイリング対象の関数を LineProfiler#add_function() メソッドを使って登録しよう。 ここでは、前述の fizzbuzz モジュールの中にある fizzbuzz 関数を登録した。

>>> pr.add_function(fizzbuzz.fizzbuzz)

そして、続いて LineProfiler#runcall() メソッドでプロファイリングを実行する。 このメソッドで実行した処理の中で、先ほど LineProfiler#add_function() メソッドで登録した関数が呼び出されることを期待する。 もし呼びだされた場合には、それが処理される過程が line_profiler によって逐一記録されるという寸法だ。

>>> pr.runcall(fizzbuzz.main)
1
2
Fizz
...(省略)...
97
98
Fizz

ちなみに、上記の LineProfiler#runcall() メソッドは、代わりに次のようにしても構わない。 こちらのやり方では LineProfiler#enable() メソッドと LineProfiler#disable() メソッドによってプロファイリングのオン・オフを切り替えている。 LineProfiler#enable() メソッドと LineProfiler#disable() メソッドの間に実行された処理がプロファイリングの対象ということになる。

>>> pr.enable()
>>> fizzbuzz.main()
1
2
Fizz
...(省略)...
97
98
Fizz
>>> pr.disable()

プロファイリングが終わったら LineProfiler#print_stats() メソッドで結果を表示しよう。 すると、次のようにプロファイリング結果が得られる。

>>> pr.print_stats()
Timer unit: 1e-06 s

Total time: 0.000292 s
File: /Users/amedama/Documents/temporary/fizzbuzz.py
Function: fizzbuzz at line 5

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     5                                           def fizzbuzz(n):
     6        99           95      1.0     32.5      if n % 3 == 0 and n % 5 == 0:
     7         6            0      0.0      0.0          return 'FizzBuzz'
     8
     9        93           57      0.6     19.5      if n % 3 == 0:
    10        27           13      0.5      4.5          return 'Fizz'
    11
    12        66           46      0.7     15.8      if n % 5 == 0:
    13        13           29      2.2      9.9          return 'Buzz'
    14
    15        53           52      1.0     17.8      return str(n)

上記の表のカラムは次のような意味がある。

  • Line

    • ソースコードの行番号
  • Hits

    • その行が実行された回数
  • Time

    • その行の実行に費やした時間の合計
    • 単位は先頭行の Timer unit で示されている
  • Per Hit

    • その行を 1 回実行するのに費やした時間
  • % Time

    • その行の実行に費やした時間の全体に対する割合
  • Line Contents

    • 行番号に対応するソースコード

とても分かりやすい。

コマンドラインから調べる

line_profiler には、もうひとつコマンドラインからプロファイリングを実行するインターフェースがある。 むしろ、公式の説明を見るとこちらを推している雰囲気がある。 ただ、個人的には先ほどのやり方が便利なので、こちらをあまり使うことはないかな…という感じ。

コマンドラインからの処理では、まずプロファイリングを実行したい関数に一工夫が必要になる。 具体的には、プロファイリングを実行したい関数やメソッドを @profile デコレータで修飾する。

以下は fizzbuzz() 関数に @profile デコレータをつけたサンプルコード。

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


@profile
def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)


def main():
    for i in range(1, 100):
        message = fizzbuzz(i)
        print(message)


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

ちなみに、するどい人は上記が @profile デコレータがインポートされていないのでエラーになると気づくかもしれない。

その通りで、実はこのソースコードは通常の Python インタプリタでは実行できなくなる。

$ python fizzbuzz.py 
Traceback (most recent call last):
  File "fizzbuzz.py", line 5, in <module>
    @profile
NameError: name 'profile' is not defined

ただ、line_profiler を使ってコマンドラインでプロファイリングするときは専用のコマンドを使うことになる。 具体的には kernprof というコマンドを使う。 このコマンドではソースコードの中の @profile デコレータがちゃんと動作するように細工して実行するので問題ない。

早速 kernprof コマンドで fizzbuzz.py をプロファイリングしてみよう。

$ kernprof -l fizzbuzz.py
1
2
Fizz
...(省略)...
97
98
Fizz
Wrote profile results to fizzbuzz.py.lprof

実行すると、プロファイリング対象のファイル名に .lprof という拡張子のついたファイルができる。

そして、そのできたファイルを line_profiler モジュールをコマンドライン経由で読み込ませる。 すると、解析結果が表示される。

$ python -m line_profiler fizzbuzz.py.lprof
Timer unit: 1e-06 s

Total time: 0.000275 s
File: fizzbuzz.py
Function: fizzbuzz at line 5

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     5                                           @profile
     6                                           def fizzbuzz(n):
     7        99          116      1.2     42.2      if n % 3 == 0 and n % 5 == 0:
     8         6            3      0.5      1.1          return 'FizzBuzz'
     9
    10        93           53      0.6     19.3      if n % 3 == 0:
    11        27           15      0.6      5.5          return 'Fizz'
    12
    13        66           33      0.5     12.0      if n % 5 == 0:
    14        13            5      0.4      1.8          return 'Buzz'
    15
    16        53           50      0.9     18.2      return str(n)

ただ、上記のやり方だとソースコードに手を加えないといけないから面倒くさい。 それに、モジュールが実行可能になっていないとプロファイリングできないところも汎用性が低いと感じた。

まとめ

  • 標準ライブラリには profile/cProfile というプロファイラがある
  • それ以外にもサードパーティ製で line_profiler というパッケージがある
  • line_profiler はコード一行単位でプロファイリング結果が見えるので分かりやすい

Python: profile/cProfile モジュールでボトルネックを調べる

プログラムを作ってみたは良いけれど、想定していたよりもパフォーマンスが出ないという事態はよくある。 そんなときはパフォーマンス・チューニングをするわけだけど、それにはまず測定が必要になる。 具体的に何を測定するかというと、プログラムの中のどこがボトルネックになっているかという点だ。 この測定の作業は一般にプロファイリングと呼ばれる。

プロファイリングにはふたつの種類がある。 ひとつ目は今回紹介する profile/cProfile モジュールが採用している決定論的プロファイリング。 そして、もうひとつが統計的プロファイリングだ。

決定論的プロファイリングでは、プログラムが実行されていく過程を逐一記録していく。 そのため、時間のかかっている処理を正確に把握することができる。 ただし、測定自体がプログラムに与える影響 (プローブ・エフェクト) も大きくなる。

それに対し統計的プロファイリングでは、プログラムが実行されていく過程をランダムに記録していく。 これは、統計的にいえばプログラムが実行されていく過程の全体を母集団と捉えて、そこから標本を抽出している。 そして、抽出した標本から母集団を推定する形で、呼び出しの多い処理や時間のかかっている処理を推定することになる。 この場合、結果はあくまで推定 (一定の割合で誤りを許容すること) になるものの、測定自体が与える影響は比較的小さくなる。

profile/cProfile モジュール

前置きが長くなったけど、今回は Python の標準ライブラリに用意されているプロファイリング用のモジュールを紹介する。 具体的には profile と cProfile というふたつの実装で、どちらも前述した通り決定論的プロファイリングを採用している。 ふたつある理由は profile が Pure Python の実装で、cProfile が C 拡張モジュールを使った実装になっている。 それぞれの特徴は、profile に比べると cProfile は測定にかかるオーバーヘッドが少ない代わりに、ユーザの拡張性が低くなっている。 使い分けについては、一般的なユースケースでは cProfile を常に使っていれば良さそうだ。

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

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.11.6
BuildVersion:   15G31
$ python --version
Python 3.5.1

まずは使ってみる

何はともあれ profile/cProfile を使ってみることにしよう。 まずは Python の REPL を使って動作を確認していくことにする。

$ python

起動したら cProfile をインポートする。

>>> import cProfile

もちろん profile を使っても構わない。

>>> import profile

動作確認には time モジュールを使うことにしよう。 このモジュールを使うとスレッドを一定時間停止させることができる。

>>> import time

最もシンプルな使い方としてはモジュールの run() 関数に呼び出したい Python のコードを渡す形になる。 こうすると、渡した Python のコードが cProfile 経由で実行されて、プロファイリング結果が得られる。

>>> cProfile.run('time.sleep(1)')
         4 function calls in 1.001 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    1.001    1.001 <string>:1(<module>)
        1    0.000    0.000    1.001    1.001 {built-in method builtins.exec}
        1    1.001    1.001    1.001    1.001 {built-in method time.sleep}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

結果として得られる表の読み方は次の通り。

  • ncalls

    • 呼び出し回数
  • tottime

    • その関数の実行にかかった時間の合計
    • (別の関数呼び出しにかかった時間を除く)
  • percall

    • かかった時間を呼び出し回数で割ったもの
    • (つまり 1 回の呼び出しにかかった平均時間)
  • cumtime

    • その関数の実行にかかった時間の合計
    • (別の関数呼び出しにかかった時間を含む)
  • filename

    • lineno(function): 関数のファイル名、行番号、関数名

再帰的な処理が含まれる場合

上記の ncalls (呼び出し回数) は、再帰呼び出しがあるときとないときで表示が異なる。 例えば、次のような再帰を使ってフィボナッチ数を計算する関数を用意する。

>>> def fib(n):
...     if n == 0:
...         return 0
...     if n == 1:
...         return 1
...     return fib(n - 2) + fib(n - 1)
...

これをプロファイリングにかけると 177/1 といったように分数のような表示が得られる。

>>> cProfile.run('fib(10)')
         180 function calls (4 primitive calls) in 0.000 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    177/1    0.000    0.000    0.000    0.000 <stdin>:1(fib)
        1    0.000    0.000    0.000    0.000 <string>:1(<module>)
        1    0.000    0.000    0.000    0.000 {built-in method builtins.exec}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

これは、元々は 1 回の呼び出しから関数が再帰的に 176 回呼びだされたことを意味している。 右側が再帰によるものでない呼び出し回数、左側は再帰を含む呼び出し回数になっているため。

より実践的な使い方

先ほどの例では Python のコードを文字列で渡すことでプロファイリングした。 とはいえ、より柔軟に一連の Python のコードをプロファイリングしたいという場合も多いはず。 次は、そんなときはどうしたら良いかについて。

まずは Profile クラスのインスタンスを用意する。

>>> pr = cProfile.Profile()

そして、プロファイリングを開始するために enable() メソッドを呼びだそう。

>>> pr.enable()

その上で、プロファイリングしたいコードを実行する。

>>> fib(10)
55

そして、プロファイリングを終了するために disable() メソッドを呼び出す。

>>> pr.disable()

ちなみに、これは代わりに runcall() メソッドを使っても構わない。

>>> pr.runcall(fib, 10)
55

プロファイリングが終わったら print_stats() メソッドで結果を出力する。

>>> pr.print_stats()
         179 function calls (3 primitive calls) in 0.000 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.000    0.000 <stdin>:1(<module>)
    177/1    0.000    0.000    0.000    0.000 <stdin>:1(fib)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

結果を詳しく見る

これまでの例では、プロファイリングの対象となるコードがシンプルだったので結果もシンプルだった。 とはいえ、実際にプロファイリングしたいようなコードは色々な処理が呼び出しあうような複雑なものになるはず。 結果も自ずと重厚長大なものになるはずだ。 そんなときは、プロファイリングの結果を pstats モジュールで詳しく調べることができる。

まずは pstats モジュールをインポートする。

>>> import pstats

そして、先ほどの Profile クラスのインスタンスを渡して Stats クラスをインスタンス化しよう。

>>> stats = pstats.Stats(pr)

Stats クラスのインスタンスでは、例えば特定のカラムで結果をソートできる。 ここでは ncalls (呼び出し回数) で降順ソートしてみよう。

>>> stats.sort_stats('ncalls')
<pstats.Stats object at 0x102518278>

>>> stats.print_stats()
         178 function calls (2 primitive calls) in 0.000 seconds

   Ordered by: call count

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    177/1    0.000    0.000    0.000    0.000 <stdin>:1(fib)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


<pstats.Stats object at 0x102518278>

これで、呼び出し回数の多い fib() 関数が上にくる。

プロファイリング結果をファイルに残す

プロファイリングの結果はデータとして残せるようになっていると色々と便利なはず。

そんなときは Profile クラスの dump_stats() というメソッドが使える。 このメソッドは、ファイル名を指定してプロファイリング結果をファイルに保存できる。

>>> pr.dump_stats('fib.profile')

上記であれば Python の REPL を起動したディレクトリに fib.profile という名前のファイルができる。

$ ls | grep ^fib
fib.profile

上記のファイルは pstats モジュールから読み込んで使うことができる。 Stats クラスをインスタンス化するときにファイル名を文字列で指定すれば結果を読み込むことができる。

>>> import pstats
>>> stats = pstats.Stats('fib.profile')
>>> stats.print_stats()
Tue Aug 30 19:30:07 2016    fib.profile

         178 function calls (2 primitive calls) in 0.000 seconds

   Random listing order was used

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    177/1    0.000    0.000    0.000    0.000 <stdin>:1(fib)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}


<pstats.Stats object at 0x10ec9acf8>

コマンドラインでプロファイリングする

次は、そんなに使う場面はないかもしれないけどコマンドラインでプロファイリングする方法について。

例えば、次のような FizzBuzz を実行するソースコードを用意しておこう。

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


def fizzbuzz(n):
    if n % 3 == 0 and n % 5 == 0:
        return 'FizzBuzz'

    if n % 3 == 0:
        return 'Fizz'

    if n % 5 == 0:
        return 'Buzz'

    return str(n)


def main():
    for i in range(1, 100):
        message = fizzbuzz(i)
        print(message)


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

このソースコードは実行すると 1 から 99 までの FizzBuzz を出力する。

$ python fizzbuzz.py 
1
2
Fizz
...(省略)...
97
98
Fizz

このとき -m cProfile というオプションをつけて cProfile モジュールが実行されるようにしてみよう。 これで、自動的にプロファイリングが実行される。

$ python -m cProfile fizzbuzz.py
1
2
Fizz
...(省略)...
97
98
Fizz
         202 function calls in 0.001 seconds

   Ordered by: standard name

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.001    0.001 fizzbuzz.py:18(main)
        1    0.000    0.000    0.001    0.001 fizzbuzz.py:5(<module>)
       99    0.000    0.000    0.000    0.000 fizzbuzz.py:5(fizzbuzz)
        1    0.000    0.000    0.001    0.001 {built-in method builtins.exec}
       99    0.000    0.000    0.000    0.000 {built-in method builtins.print}
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

この使い方では -s オプションを使って結果のソートができる。

$ python -m cProfile -s ncalls fizzbuzz.py
1
2
Fizz
...(省略)...
97
98
Fizz
         202 function calls in 0.001 seconds

   Ordered by: call count

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       99    0.000    0.000    0.000    0.000 {built-in method builtins.print}
       99    0.000    0.000    0.000    0.000 fizzbuzz.py:5(fizzbuzz)
        1    0.000    0.000    0.001    0.001 fizzbuzz.py:5(<module>)
        1    0.000    0.000    0.001    0.001 fizzbuzz.py:18(main)
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}
        1    0.000    0.000    0.001    0.001 {built-in method builtins.exec}

同じように -o オプションを使えばプロファイリング結果をファイルに保存できる。

$ python -m cProfile -o fizzbuzz.profile fizzbuzz.py
$ ls | grep .profile$
fizzbuzz.profile

呼び出し関係を調べる

profile/cProfile は処理の呼び出し回数や時間などの他に、呼び出し関係も調べることができる。 例えば、先ほどの FizzBuzz のプロファイリング結果を例に使ってみよう。

まずは改めて Python の REPL を起動する。

$ python

そしてプロファイリング結果をファイル経由で読み込む。

>>> import pstats
>>> p = pstats.Stats('fizzbuzz.profile')

そして print_callers() メソッドで、呼び出し元が分かる。 左が呼び出された関数で、右が呼び出し元の関数になっている。

>>> p.print_callers()
   Random listing order was used

Function                                          was called by...
                                                      ncalls  tottime  cumtime
{built-in method builtins.print}                  <-      99    0.000    0.000  fizzbuzz.py:18(main)
fizzbuzz.py:5(<module>)                           <-       1    0.000    0.001  {built-in method builtins.exec}
{built-in method builtins.exec}                   <-
{method 'disable' of '_lsprof.Profiler' objects}  <-
fizzbuzz.py:18(main)                              <-       1    0.000    0.001  fizzbuzz.py:5(<module>)
fizzbuzz.py:5(fizzbuzz)                           <-      99    0.000    0.000  fizzbuzz.py:18(main)


<pstats.Stats object at 0x100d24a58>

その逆が print_callees() メソッドで得られる。 今度は左が呼び出し元で、右が呼び出し先になっている。

>>> p.print_callees()
   Random listing order was used

Function                                          called...
                                                      ncalls  tottime  cumtime
{built-in method builtins.print}                  ->
fizzbuzz.py:5(<module>)                           ->       1    0.000    0.001  fizzbuzz.py:18(main)
{built-in method builtins.exec}                   ->       1    0.000    0.001  fizzbuzz.py:5(<module>)
{method 'disable' of '_lsprof.Profiler' objects}  ->
fizzbuzz.py:18(main)                              ->      99    0.000    0.000  fizzbuzz.py:5(fizzbuzz)
                                                          99    0.000    0.000  {built-in method builtins.print}
fizzbuzz.py:5(fizzbuzz)                           ->


<pstats.Stats object at 0x100d24a58>

まとめ

  • パフォーマンスチューニングには、まずプロファイリングが必要になる
  • プロファイリングには決定論的プロファイリングと統計的プロファイリングのふたつがある
  • Python には profile/cProfile という決定論的プロファイリングができるモジュールがある
  • このモジュールを使うと特定の処理における関数の呼び出し回数や、かかった時間を調べることができる

参考

27.4. Python プロファイラ — Python 3.6.5 ドキュメント