CUBE SUGAR CONTAINER

技術系のこと書きます。

IPv4 アドレスの値段

今回は IPv4 アドレスの値段や、その売買に関する動向について調べてみた。

TL; DR

  • IPv4 アドレスは売買できる
  • オークションにおける IPv4 アドレスの売買単価は値上がり傾向にある
  • 2018 年 11 月 1 日現在、IPv4 アドレス一つあたり 17 ~ 20 USD ほどで取引されている
  • 近年は取引も活発になっている傾向にある

IPv4 アドレスの在庫枯渇と売買について

IPv4 アドレスの在庫が枯渇したというニュースが話題になってから、ずいぶんと時間が経ったように思う。 実際のところ IANA の中央在庫が枯渇したのは 2011 年 2 月 3 日なので、実に 7 年以上が経っている。

IANAにおけるIPv4アドレス在庫枯渇、およびJPNICの今後のアドレス分配について - JPNIC

IPv4 アドレスの在庫は、階層構造になったインターネットレジストリが管理している。 階層構造において下流に位置する組織は、上流の組織に対して必要に応じて割り当てを申請して、在庫の一部を受け取る。 しかし、在庫が枯渇してしまった上流の組織からは、新規の割り当てが制限される。 ちなみに、日本のインターネットレジストリである JPNIC の在庫は 2011 年 4 月 15 日に枯渇している。

IPv4アドレスの在庫枯渇に関して - JPNIC

新規の割り当てを受けられない状況において、ネットワーク事業者は既存の割り当て済みアドレスの利用効率を上げるなど、いくつかの対策が考えられる。 しかし、現実問題として既存の割り当て済みアドレスだけではどうにもならないようなケースも存在する。 例えば新たに IaaS のようなサービスを始めたり、設備を大幅に増強するような局面においては IPv4 アドレスが大量に必要となる恐れがある。

そのような場合の救済策として、他の事業者に割り当てられたアドレスを一定の条件下で譲り受ける (移転する) ことが可能になっている。 これは、過去に割り当てを受けたものの、既に不要となった IPv4 アドレスが企業などで死蔵されているケースなどがあるため。 もちろん、慈善事業ではないのでアドレスの移転を受ける場合には基本的に有償になると考えられる。 ただし、補足しておくとアドレスの移転ポリシーについては管轄のインターネットレジストリによって色々と事情が異なる。

そうした状況において、アドレスを買いたい事業者と売りたい事業者を仲介するブローカーも登場している。 前置きが長くなったけど、今回扱うのはそんなブローカーの一つである以下のサイト。 ここでは売却したい IPv4 アドレスのブロックがオークションにかけられている。

www.ipv4auctions.com

上記のサイトには、過去に売却された IPv4 アドレスのブロックに関する情報が記載されている。 そこで、今回はそれをスクレイピングして可視化してみることにした。 このサイトにおける売却は全世界のアドレス移転のごく一部を見ているにすぎないとはいえ、傾向については読み取れるんじゃないかと。

IPv4 アドレスの価格の推移

早速だけど、まずは IPv4 アドレスの価格の推移から。 横軸に売却された日付、縦軸に単一アドレスあたりの価格 (米ドル) をプロットした散布図にしてみた。 点の色は、中央在庫を管理している IANA の直下に位置する RIR (Regional Internet Registry: 地域インターネットレジストリ) を表している。

f:id:momijiame:20181031215956p:plain

2014 ~ 2016 年頃までは、だいたい 7 ~ 10 USD ほどで売却されていたアドレスが、最近では 17 ~ 20 USD 近くまで値上がりしていることが分かる。 仮にこの価格が下がるとすれば、もしかすると IPv4 アドレスが不要になりつつある兆しかもしれない。 他の要因ももちろんあるとはいえ IPv4 のインターネットが縮小期に入っている可能性はある。

また、このサイトでの売却は、北アメリカを管轄している ARIN (American Registry for Internet Numbers) のアドレスブロックで活発なようだ。 北アメリカはインターネット発祥の地とも言えるので、やはり初期に大きく割り当てられて死蔵されているアドレスブロックも多かったりするんだろうか?

売却されたアドレスの数・回数の推移

続いては、売却された IPv4 アドレスの数や回数の推移を時系列でプロットしてみた。

まず、以下は売却された IPv4 アドレスの数を /24 のブロック (232-24 = 256 個) 単位で時系列の棒グラフにしている。

f:id:momijiame:20181031220016p:plain

上記を見ると、売却される IPv4 アドレスの数は近年増加傾向にあることが分かる。

また、同様に売却の件数についても月ごとにプロットしてみた。

f:id:momijiame:20181031220023p:plain

こちらも近年は増加傾向にあることが見て取れる。

売却されたブロックサイズの推移

続いては、売却されたアドレスブロックのサイズについても月ごとに集計してプロットしてみた。 月単位での CIDR 長の最大値 (max)、最小値 (min)、平均値 (mean)、中央値 (median) を示している。

f:id:momijiame:20181031220006p:plain

これについては、時系列での傾向は特に見受けられなかった。 最も小さなブロックのサイズが期間を通じて /24 で固定なのは、それより小さいと BGP の経路フィルタで落とされる可能性が高いためだろうか?

ブロックサイズごとの価格の違い

ブロックサイズについて見たところで、次はブロックサイズと価格の関係をプロットしてみる。

f:id:momijiame:20181117192921p:plain

/17 だけやけに安いのは、そもそもオークションに出品された件数が少なく、それも初期に固まっているためだろう。 それ以外のブロックサイズについては、サイズごとに極端な傾向は見られない。 ただ、/20 ~ /24 の間に、ブロックサイズが大きいほどアドレス単価の中央値が安くなる傾向が見られるのは意外だった。 それというのも、ブロックサイズは大きい方が使い勝手が良いため、大きいほど高いのかと思っていた。 ブロックサイズが大きくなるほど取引にかかる総額も大きくなるため、価格が抑制されやすいという可能性はあるかもしれない。 あるいは、価格が上昇すると共に単に大きめのブロックサイズが出品されにくくなっているという疑似相関だろうか。

まとめ

今回は IPv4 アドレスのオークションサイトのデータをスクレイピングしてグラフとして可視化してみた。 最近は IPv4 アドレスの値段が値上がり傾向にあることが分かった。

備考

今回スクレイピングと可視化に使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V          
Python 3.6.7

スクリプトを動作させるのに必要となるパッケージは次の通り。

$ pip install pandas lxml matplotlib tqdm percache

以下はスクレイピングと可視化に用いたスクリプト。

IPv4 アドレスの価格の推移

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

import datetime
import time

import percache
from tqdm import tqdm
import pandas as pd
from matplotlib import pyplot as plt


def _fetch(year):
    """オークションの履歴をスクレイピングする"""
    base_url = 'https://www.ipv4auctions.com/customer/account/previous?year={yyyy}'
    url = base_url.format(yyyy=year)
    dfs = pd.read_html(url)
    return dfs[1]


# スクレイピングした内容をローカルのディスクにキャッシュする
cache = percache.Cache('df-cache')


@cache
def auction_history(start_year, stop_year, fetch_interval=2):
    """各年の情報を結合して一枚のデータフレームにする"""
    dfs = []
    years = range(start_year, stop_year + 1)
    for year in tqdm(years):
        year_df = _fetch(year)
        dfs.append(year_df)
        # サービスに負荷をかけないようにインターバルをかける
        time.sleep(fetch_interval)

    # データフレームを結合する
    return pd.concat(dfs, axis=0)


def filter_outlier(df_):
    """データフレームから外れ値を取り除く"""
    # ブロックサイズが適切にパースできるものだけに絞る
    prefix_series = df_['BLOCK'].apply(lambda x: x[1:])
    return df_[prefix_series.str.isnumeric()]


def sold_region(df_):
    """取引された地域をパースする"""
    def parse(x):
        # 末尾にカンマの入ったデータが混ざっていたので取り除く
        return x[:-1] if x[-1] == ',' else x
    return df_.REGION.apply(parse)


def sold_date(df_):
    """取引された日付をパースする"""
    def parse(x):
        datetime_ = datetime.datetime.strptime(x, '%Y-%m-%d')
        return datetime.date(datetime_.year, datetime_.month, datetime_.day)
    return df_['SOLD DATE'].apply(parse)


def sold_yyyymm(df_):
    """取引された年・月をパースする"""
    def to_str(dt):
        return dt.strftime('%Y-%m')
    dt_series = pd.to_datetime(df_['SOLD DATE'])
    return dt_series.apply(to_str)


def price_per_addr(df_):
    """取引された価格をパースする"""
    def parse(x):
        ex_comma_x = x.replace(',', '')
        return float(ex_comma_x[1:])
    return df_['PRICE PER ADDRESS'].apply(parse)


def main():
    # データ取得・描画範囲
    YEAR_RANGE = (2014, 2018)

    # オークションの履歴を取得する
    df = auction_history(YEAR_RANGE[0], YEAR_RANGE[-1])

    # 外れ値を取り除く
    df = filter_outlier(df)

    # 可視化に使うデータを取り出す
    df = df.assign(sold_date=sold_date(df))
    df = df.assign(price_per_addr=price_per_addr(df))
    df = df.assign(sold_region=sold_region(df))
    df = df.assign(yyyymm=sold_yyyymm(df))

    # 地域ごとに集計・可視化する
    fig, ax = plt.subplots()
    regions = pd.unique(df.sold_region)
    for region in regions:
        region_df = df[df.sold_region == region]
        ax.plot_date(region_df.sold_date, region_df.price_per_addr, label=region)

    ax.set_title('IPv4 auction price graph')
    ax.legend()
    ax.set_ylabel('price per address (USD)')
    ax.set_xlabel('sold date')
    plt.show()


if __name__ == '__main__':
    main()

売却されたアドレスの数の推移

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

import time

import percache
from tqdm import tqdm
import pandas as pd
from matplotlib import pyplot as plt


def _fetch(year):
    """オークションの履歴をスクレイピングする"""
    base_url = 'https://www.ipv4auctions.com/customer/account/previous?year={yyyy}'
    url = base_url.format(yyyy=year)
    dfs = pd.read_html(url)
    return dfs[1]


# スクレイピングした内容をローカルのディスクにキャッシュする
cache = percache.Cache('df-cache')


@cache
def auction_history(start_year, stop_year, fetch_interval=2):
    """各年の情報を結合して一枚のデータフレームにする"""
    dfs = []
    years = range(start_year, stop_year + 1)
    for year in tqdm(years):
        year_df = _fetch(year)
        dfs.append(year_df)
        # サービスに負荷をかけないようにインターバルをかける
        time.sleep(fetch_interval)

    # データフレームを結合する
    return pd.concat(dfs, axis=0)


def class_c_blocks(df_):
    """取引された /24 ブロックの数を数える"""
    def parse(x):
        return 2 ** (24 - int(x[1:]))
    return df_['BLOCK'].apply(parse)


def filter_outlier(df_):
    """データフレームから外れ値を取り除く"""
    # ブロックサイズが適切にパースできるものだけに絞る
    prefix_series = df_['BLOCK'].apply(lambda x: x[1:])
    return df_[prefix_series.str.isnumeric()]


def sold_yyyymm(df_):
    """取引された年・月をパースする"""
    def to_str(dt):
        return dt.strftime('%Y-%m')
    dt_series = pd.to_datetime(df_['SOLD DATE'])
    return dt_series.apply(to_str)


def region(df_):
    """取引された地域をパースする"""
    def parse(x):
        # 末尾にカンマの入ったデータが混ざっていたので取り除く
        return x[:-1] if x[-1] == ',' else x
    return df_.REGION.apply(parse)


def main():
    # データ取得・描画範囲
    YEAR_RANGE = (2014, 2018)

    # オークションの履歴を取得する
    df = auction_history(YEAR_RANGE[0], YEAR_RANGE[-1])

    # 外れ値を取り除く
    df = filter_outlier(df)

    # 必要なデータを取り出す
    df = df.assign(blocks=class_c_blocks(df))
    df = df.assign(yyyymm=sold_yyyymm(df))
    df = df.assign(region=region(df))

    # 集計する
    pivot_df = df.pivot_table(index=['yyyymm'], columns=['region'], values=['blocks'], aggfunc='sum', fill_value=0)

    # 可視化する
    fig, ax = plt.subplots()
    for _, data in pivot_df.items():
        region_name = data.name[1]
        ax.bar(data.index, data, label=region_name)

    ax.set_title('Number of /24 blocks sold in the auction')
    ax.legend()
    ax.set_ylabel('sold /24 blocks')
    ax.set_xlabel('month')
    ax.xaxis.set_ticks(['{y}-06'.format(y=y) for y in range(YEAR_RANGE[0], YEAR_RANGE[-1] + 1)])
    plt.show()


if __name__ == '__main__':
    main()

売却された回数の推移

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

import time

import percache
from tqdm import tqdm
import pandas as pd
from matplotlib import pyplot as plt


def _fetch(year):
    """オークションの履歴をスクレイピングする"""
    base_url = 'https://www.ipv4auctions.com/customer/account/previous?year={yyyy}'
    url = base_url.format(yyyy=year)
    dfs = pd.read_html(url)
    return dfs[1]


# スクレイピングした内容をローカルのディスクにキャッシュする
cache = percache.Cache('df-cache')


@cache
def auction_history(start_year, stop_year, fetch_interval=2):
    """各年の情報を結合して一枚のデータフレームにする"""
    dfs = []
    years = range(start_year, stop_year + 1)
    for year in tqdm(years):
        year_df = _fetch(year)
        dfs.append(year_df)
        # サービスに負荷をかけないようにインターバルをかける
        time.sleep(fetch_interval)

    # データフレームを結合する
    return pd.concat(dfs, axis=0)


def class_c_blocks(df_):
    """取引された /24 ブロックの数を数える"""
    def parse(x):
        return 2 ** (24 - int(x[1:]))
    return df_['BLOCK'].apply(parse)


def filter_outlier(df_):
    """データフレームから外れ値を取り除く"""
    # ブロックサイズが適切にパースできるものだけに絞る
    prefix_series = df_['BLOCK'].apply(lambda x: x[1:])
    return df_[prefix_series.str.isnumeric()]


def sold_yyyymm(df_):
    """取引された年・月をパースする"""
    def to_str(dt):
        return dt.strftime('%Y-%m')
    dt_series = pd.to_datetime(df_['SOLD DATE'])
    return dt_series.apply(to_str)


def region(df_):
    """取引された地域をパースする"""
    def parse(x):
        # 末尾にカンマの入ったデータが混ざっていたので取り除く
        return x[:-1] if x[-1] == ',' else x
    return df_.REGION.apply(parse)


def main():
    # データ取得・描画範囲
    YEAR_RANGE = (2014, 2018)

    # オークションの履歴を取得する
    df = auction_history(YEAR_RANGE[0], YEAR_RANGE[-1])

    # 外れ値を取り除く
    df = filter_outlier(df)

    # 必要なデータを取り出す
    df = df.assign(blocks=class_c_blocks(df))
    df = df.assign(yyyymm=sold_yyyymm(df))
    df = df.assign(region=region(df))

    # 集計する
    pivot_df = df.pivot_table(index=['yyyymm'], columns=['region'], values=['blocks'], aggfunc='count', fill_value=0)

    # 可視化する
    fig, ax = plt.subplots()
    for _, data in pivot_df.items():
        region_name = data.name[1]
        ax.bar(data.index, data, label=region_name)

    ax.set_title('Number of sold counts in the auction')
    ax.legend()
    ax.set_ylabel('sold count')
    ax.set_xlabel('month')
    ax.xaxis.set_ticks(['{y}-06'.format(y=y) for y in range(YEAR_RANGE[0], YEAR_RANGE[-1] + 1)])
    plt.show()


if __name__ == '__main__':
    main()

売却されたブロックサイズの推移

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

import time

import percache
from tqdm import tqdm
import pandas as pd
import matplotlib.pyplot as plt


def _fetch(year):
    """オークションの履歴をスクレイピングする"""
    base_url = 'https://www.ipv4auctions.com/customer/account/previous?year={yyyy}'
    url = base_url.format(yyyy=year)
    dfs = pd.read_html(url)
    return dfs[1]


# スクレイピングした内容をローカルのディスクにキャッシュする
cache = percache.Cache('df-cache')


@cache
def auction_history(start_year, stop_year, fetch_interval=2):
    """各年の情報を結合して一枚のデータフレームにする"""
    dfs = []
    years = range(start_year, stop_year + 1)
    for year in tqdm(years):
        year_df = _fetch(year)
        dfs.append(year_df)
        # サービスに負荷をかけないようにインターバルをかける
        time.sleep(fetch_interval)

    # データフレームを結合する
    return pd.concat(dfs, axis=0)


def block_size(df_):
    """取引された IPv4 アドレスのブロックサイズをパースする"""
    def parse(x):
        return int(x[1:])
    return df_['BLOCK'].apply(parse)


def filter_outlier(df_):
    """データフレームから外れ値を取り除く"""
    # ブロックサイズが適切にパースできるものだけに絞る
    prefix_series = df_['BLOCK'].apply(lambda x: x[1:])
    return df_[prefix_series.str.isnumeric()]


def sold_yyyymm(df_):
    """取引された年・月をパースする"""
    def to_str(dt):
        return dt.strftime('%Y-%m')
    dt_series = pd.to_datetime(df_['SOLD DATE'])
    return dt_series.apply(to_str)


def region(df_):
    """取引された地域をパースする"""
    def parse(x):
        # 末尾にカンマの入ったデータが混ざっていたので取り除く
        return x[:-1] if x[-1] == ',' else x
    return df_.REGION.apply(parse)


def main():
    # データ取得・描画範囲
    YEAR_RANGE = (2014, 2018)

    # オークションの履歴を取得する
    df = auction_history(YEAR_RANGE[0], YEAR_RANGE[-1])

    # 外れ値を取り除く
    df = filter_outlier(df)

    # 取引された日付と価格を取得する
    df = df.assign(block_size=block_size(df))
    df = df.assign(yyyymm=sold_yyyymm(df))
    df = df.assign(region=region(df))

    # 集計する
    pivot_df = df.pivot_table(index=['yyyymm'], values=['block_size'], aggfunc=['max', 'mean', 'median', 'min'], fill_value=0)

    # 可視化する
    fig, ax = plt.subplots()
    for _, data in pivot_df.items():
        agg_type = data.name[0]
        ax.plot(data.index, data, label=agg_type)

    ax.legend()
    ax.set_ylabel('cidr')
    ax.set_xlabel('month')
    ax.xaxis.set_ticks(['{y}-06'.format(y=y) for y in range(YEAR_RANGE[0], YEAR_RANGE[-1] + 1)])
    plt.show()


if __name__ == '__main__':
    main()
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import time

import percache
from tqdm import tqdm
import pandas as pd
from matplotlib import pyplot as plt


def _fetch(year):
    """オークションの履歴をスクレイピングする"""
    base_url = 'https://www.ipv4auctions.com/customer/account/previous?year={yyyy}'
    url = base_url.format(yyyy=year)
    dfs = pd.read_html(url)
    return dfs[1]


# スクレイピングした内容をローカルのディスクにキャッシュする
cache = percache.Cache('df-cache')


@cache
def auction_history(start_year, stop_year, fetch_interval=2):
    """各年の情報を結合して一枚のデータフレームにする"""
    dfs = []
    years = range(start_year, stop_year + 1)
    for year in tqdm(years):
        year_df = _fetch(year)
        dfs.append(year_df)
        # サービスに負荷をかけないようにインターバルをかける
        time.sleep(fetch_interval)

    # データフレームを結合する
    return pd.concat(dfs, axis=0)


def filter_outlier(df_):
    """データフレームから外れ値を取り除く"""
    # ブロックサイズが適切にパースできるものだけに絞る
    prefix_series = df_['BLOCK'].apply(lambda x: x[1:])
    return df_[prefix_series.str.isnumeric()]


def price_per_addr(df_):
    """取引された価格をパースする"""
    def parse(x):
        ex_comma_x = x.replace(',', '')
        return float(ex_comma_x[1:])
    return df_['PRICE PER ADDRESS'].apply(parse)


def sold_block_size(df_):
    """取引されたブロックのサイズ"""
    def parse(x):
        return int(x[1:])
    return df_['BLOCK'].apply(parse)


def main():
    # データ取得・描画範囲
    YEAR_RANGE = (2014, 2018)

    # オークションの履歴を取得する
    df = auction_history(YEAR_RANGE[0], YEAR_RANGE[-1])

    # 外れ値を取り除く
    df = filter_outlier(df)

    # 可視化に使うデータを取り出す
    df = df.assign(price_per_addr=price_per_addr(df))
    df = df.assign(sold_block_size=sold_block_size(df))

    # ブロックサイズごとに集計・可視化する
    fig, ax = plt.subplots()
    sizes = sorted(pd.unique(df.sold_block_size))
    block_size_series = [df[df.sold_block_size == size].price_per_addr for size in sizes]
    ax.boxplot(block_size_series, labels=sizes)

    ax.set_title('IPv4 auction price graph')
    ax.set_ylabel('price per address (USD)')
    ax.set_xlabel('sold block size')
    plt.show()


if __name__ == '__main__':
    main()

いじょう。

マスタリングTCP/IP 入門編 第5版

マスタリングTCP/IP 入門編 第5版

  • 作者: 竹下隆史,村山公保,荒井透,苅田幸雄
  • 出版社/メーカー: オーム社
  • 発売日: 2012/02/25
  • メディア: 単行本(ソフトカバー)
  • 購入: 4人 クリック: 34回
  • この商品を含むブログ (37件) を見る

Python: pandas-profiling でデータセットの概要を確認する

今回は pandas-profiling というパッケージを使ってみる。 このパッケージを使うと pandas の DataFrame に含まれる各次元の基本的な統計量や相関係数などを一度に確認できる。 最初にデータセットのサマリーを確認できると、その後の EDA (Exploratory Data Analysis: 探索的データ分析) の取っ掛かりにしやすいと思う。

使った環境は次の通り。

$ sw_vers 
ProductName:    macOS
ProductVersion: 12.4
BuildVersion:   21F79
$ python -V         
Python 3.9.13
$ pip3 list | grep pandas-profiling
pandas-profiling              3.2.0

下準備

まずは必要なパッケージをインストールしておく。

$ pip install pandas-profiling

サンプルのデータセットを用意する

サンプルとなるデータセットは Kaggle でおなじみの Titanic データセットにした。

Titanic - Machine Learning from Disaster | Kaggle

データセットは Web サイトか、あるいはコマンドラインツールからダウンロードしておく。

$ pip install kaggle
$ kaggle competitions download -c titanic
$ head train.csv 
PassengerId,Survived,Pclass,Name,Sex,Age,SibSp,Parch,Ticket,Fare,Cabin,Embarked
1,0,3,"Braund, Mr. Owen Harris",male,22,1,0,A/5 21171,7.25,,S
2,1,1,"Cumings, Mrs. John Bradley (Florence Briggs Thayer)",female,38,1,0,PC 17599,71.2833,C85,C
3,1,3,"Heikkinen, Miss. Laina",female,26,0,0,STON/O2. 3101282,7.925,,S
4,1,1,"Futrelle, Mrs. Jacques Heath (Lily May Peel)",female,35,1,0,113803,53.1,C123,S
5,0,3,"Allen, Mr. William Henry",male,35,0,0,373450,8.05,,S
6,0,3,"Moran, Mr. James",male,,0,0,330877,8.4583,,Q
7,0,1,"McCarthy, Mr. Timothy J",male,54,0,0,17463,51.8625,E46,S
8,0,3,"Palsson, Master. Gosta Leonard",male,2,3,1,349909,21.075,,S
9,1,3,"Johnson, Mrs. Oscar W (Elisabeth Vilhelmina Berg)",female,27,0,2,347742,11.1333,,S

pandas-profiling を使ってみる

それでは、実際に pandas-profiling を使ってみる。 まずは Python のインタプリタを起動する。

$ python

データセットの CSV を pandas で読み込む。

>>> import pandas as pd
>>> df = pd.read_csv('train.csv')

あとは読み込んだ DataFrame を pandas-profiling に渡すだけ。

>>> import pandas_profiling
>>> profile_report = pandas_profiling.ProfileReport(df)

解析結果は、次のように HTML で出力できる。

>>> profile_report.to_file('report.html')

一旦、インタプリタからは抜けておこう。

>>> exit()

あとは出力された HTML を確認する。

$ open report.html

このように、各次元の基本的な統計量や欠損値の有無、分布や相関係数などが一度に確認できる。

いじょう。

Python: scikit-learn の FeatureUnion を pandas の DataFrame と一緒に使う

今回は scikit-learnFeatureUnionpandasDataFrame を一緒に使うときの問題点とその解決策について。 scikit-learn の FeatureUnion は、典型的には Pipeline においてバラバラに作った複数の特徴量を一つにまとめるのに使われる機能。 この FeatureUnion を pandas の DataFrame と一緒に使おうとすると、ちょっとばかり予想外の挙動になる。 具体的には FeatureUnion の出力するデータが、本来なら DataFrame になってほしいところで numpy の ndarray 形式に変換されてしまう。 今回は、それをなんとか DataFrame に直す方法がないか調べたり模索してみた話。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V      
Python 3.7.0
$ pip list --format=columns | egrep -i "(scikit-learn|pandas)"
pandas          0.23.4 
scikit-learn    0.20.0 

下準備

まずは今回使うパッケージをインストールしておく。

$ pip install scikit-learn pandas

FeatureUnion について

まずは前提知識として FeatureUnion について紹介する。 これは Pipeline と一緒に使うことで真価を発揮する機能で、複数の特徴量を一つにまとめ上げるのに使われる。

次のサンプルコードでは Iris データセットを題材にして Pipeline と FeatureUnion を使っている。 この中では、元々 Iris データセットに含まれていた特徴量と、それらを標準化した特徴量を並列に準備して結合している。 動作確認のために Pipeline への入力データと出力データの情報を標準出力に書き出している。

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

from sklearn import datasets
from sklearn.pipeline import FeatureUnion
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # パイプラインの入力データ
    print('input:')
    print(type(X), X.shape)
    print(X[:5])

    steps = [
        # 複数の特徴量を結合する
        ('union', FeatureUnion([
            # 生の特徴量をそのまま返す
            ('raw', FunctionTransformer(lambda x: x, validate=False)),
            # 標準化した特徴量を返す
            ('scaler', StandardScaler()),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data), transformed_data.shape)
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

論より証拠ということで上記を実行してみよう。 すると、入力時点では 4 次元 150 行だったデータが、出力時点では 8 次元 150 行になっている。 これは、元々 Iris データセットに含まれていた 4 次元の特徴量に加えて、それらを標準化した特徴量が 4 次元加わっているため。

$ python featunion.py
input:
<class 'numpy.ndarray'> (150, 4)
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]
output:
<class 'numpy.ndarray'> (150, 8)
[[ 5.1         3.5         1.4         0.2        -0.90068117  1.01900435
  -1.34022653 -1.3154443 ]
 [ 4.9         3.          1.4         0.2        -1.14301691 -0.13197948
  -1.34022653 -1.3154443 ]
 [ 4.7         3.2         1.3         0.2        -1.38535265  0.32841405
  -1.39706395 -1.3154443 ]
 [ 4.6         3.1         1.5         0.2        -1.50652052  0.09821729
  -1.2833891  -1.3154443 ]
 [ 5.          3.6         1.4         0.2        -1.02184904  1.24920112
  -1.34022653 -1.3154443 ]]

このように FeatureUnion を使うと特徴量エンジニアリングの作業が簡潔に表現できる。

pandas の DataFrame と一緒に使うときの問題点について

ただ、この FeatureUnion を pandas の DataFrame と一緒に使おうとすると問題が出てくる。 具体的には FeatureUnion を通すと、入力が DataFrame でも出力が numpy の ndarray の変換されてしまうのだ。

次のサンプルコードを見てほしい。 このコードでは Pipeline の入力データを pandas の DataFrame に変換している。

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

import pandas as pd

from sklearn import datasets
from sklearn.pipeline import Pipeline
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', FeatureUnion([
            ('raw', FunctionTransformer(lambda x: x, validate=False)),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X_df)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data))
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると Pipeline の出力データが numpy の ndarray に変換されてしまっていることが分かる。

$ python dfissue.py
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'numpy.ndarray'>
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]

上記のような振る舞いになる原因は scikit-learn の以下のコードにある。 FeatureUnion では特徴量の結合に numpy.hstack() を使っているのだ。 この関数の返り値は numpy の ndarray になるので FeatureUnion の出力もそれになってしまう。

github.com

ColumnTransformer でも同じ問題が起こる

ちなみに FeatureUnion と似たような機能の ColumnTransformer を使っても同じ問題が起こる。 ColumnTransformer は処理対象のカラム (次元) を指定して特徴量が生成できる。

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

import pandas as pd

from sklearn import datasets
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', ColumnTransformer([
            ('transparent', FunctionTransformer(lambda x: x, validate=False), [i for i, _ in enumerate(X_df)]),
        ])),
    ]
    pipeline = Pipeline(steps=steps)
    transformed_data = pipeline.fit_transform(X_df)

    # パイプラインの出力データ
    print('output:')
    print(type(transformed_data))
    print(transformed_data[:5])


if __name__ == '__main__':
    main()

上記を実行してみよう。 FeatureUnion のときと同じように出力データは numpy の ndarray 形式に変換されてしまった。

$ python columnissue.py 
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'numpy.ndarray'>
[[5.1 3.5 1.4 0.2]
 [4.9 3.  1.4 0.2]
 [4.7 3.2 1.3 0.2]
 [4.6 3.1 1.5 0.2]
 [5.  3.6 1.4 0.2]]

解決方法について

この問題について調べると、同じように悩んでいる人たちがいる。 そして、例えば次のような解決策を提示している人がいた。

zablo.net

上記では、自分で Transformer と FeatureUnion を拡張することで対応している。 ただ、このやり方だと作業が大掛かりで scikit-learn の API 変更などに継続的に追随していく必要があると感じた。

そこで、別の解決策として「とてもダーティーなハック」を思いついてしまったので共有してみる。 この問題は、特徴量の結合に numpy の hstack() 関数を使っていることが原因だった。 だとすれば、この関数をモンキーパッチで一時的にすり替えてしまえば問題を回避できるのではないか?と考えた。

実際に試してみたのが次のサンプルコードになる。 このコードでは unittest.mock.patch() を使って、一時的に numpy.hstack()pd.concat() にすり替えている。 これなら出力データは pandas の DataFrame にできるのではないか?

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

from functools import partial
from unittest.mock import patch

import pandas as pd

from sklearn import datasets
from sklearn.pipeline import Pipeline
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import FunctionTransformer


def main():
    # Iris データセットを読み込む
    dataset = datasets.load_iris()
    X, y = dataset.data, dataset.target

    # データセットを pandas の DataFrame に変換する
    X_df = pd.DataFrame(X, columns=dataset.feature_names)

    # パイプラインの入力データ
    print('input:')
    print(type(X_df))
    print(X_df.head())

    steps = [
        ('union', FeatureUnion([
            ('transparent', FunctionTransformer(lambda x: x, validate=False)),
        ])),
    ]
    pipeline = Pipeline(steps=steps)

    # numpy.hstack を一時的に pd.concat(axis=1) にパッチしてしまう
    horizontal_concat = partial(pd.concat, axis=1)
    with patch('numpy.hstack', side_effect=horizontal_concat):
        transformed_data = pipeline.fit_transform(X_df)

    print('output:')
    print(type(transformed_data))
    print(transformed_data.head())


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、見事に出力データが pandas の DataFrame になっていることが分かる。

$ python monkeypatch.py
input:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]
output:
<class 'pandas.core.frame.DataFrame'>
   sepal length (cm)        ...         petal width (cm)
0                5.1        ...                      0.2
1                4.9        ...                      0.2
2                4.7        ...                      0.2
3                4.6        ...                      0.2
4                5.0        ...                      0.2

[5 rows x 4 columns]

この解決策の問題点としては、学習の過程において numpy.hstack() が使えなくなるところ。 pd.concat() は入力として pandas のオブジェクトしか渡すことができない。 もし numpy の ndarray を渡してしまうと、次のようにエラーになる。

>>> import numpy as n
>>> l1 = np.array([1, 2, 3])
>>> l2 = np.array([4, 5, 6])
>>> import pandas as pd
>>> pd.concat([l1, l2], axis=1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/pandas/core/reshape/concat.py", line 225, in concat
    copy=copy, sort=sort)
  File "/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/pandas/core/reshape/concat.py", line 286, in __init__
    raise TypeError(msg)
TypeError: cannot concatenate object of type "<class 'numpy.ndarray'>"; only pd.Series, pd.DataFrame, and pd.Panel (deprecated) objs are valid

巨大なパイプラインであれば numpy.hstack() をどこかで使っている可能性は十分に考えられる。 とはいえ、一旦試して駄目だったら前述した別の解決策に切り替える、というのもありかもしれない。

いじょう。

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

Pythonではじめる機械学習 ―scikit-learnで学ぶ特徴量エンジニアリングと機械学習の基礎

scikit-learnとTensorFlowによる実践機械学習

scikit-learnとTensorFlowによる実践機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Python: scikit-learn の Pipeline 機能のキャッシュを試す

今回は scikit-learn の Pipeline に存在するキャッシュの機能を試してみる。 scikit-learn の Pipeline は、データセットの前処理・特徴量抽出からモデルの学習・推論までの一連の処理手順をひとまとめにして扱うことのできる機能。 以前に、このブログでも扱ったことがある。

blog.amedama.jp

機械学習のタスクにおいて、データセットの前処理や特徴量抽出には意外と時間がかかる。 大抵の場合、特徴量エンジニアリングを通してモデルで使う特徴量はどんどん増えていく。 そうした状況で、同じ特徴量を毎回ゼロから計算するのは時間がもったいない。 そこで、計算済みの特徴量についてはキャッシュしておきたくなる。 そのニーズを Pipeline で満たすのがキャッシュ機能になる。

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

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
$ python -V
Python 3.7.0

下準備

まずは scikit-learn をインストールしておく。

$ pip install scikit-learn

キャッシュ機能を試す

早速だけど、以下が Pipeline でキャッシュを有効にしたサンプルコードになる。 今回の本質ではないけど、この中では Digits データセットを主成分分析して、それをランダムフォレストで識別している。 ポイントは Pipeline クラスのコンストラクタで memory オプションに sklearn.externals.joblib.memory.Memory オブジェクトを渡しているところ。 これで Pipeline のキャッシュが有効になる。 また、キャッシュの効果を確認するために学習と推論にかかった時間を計測している。

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

from contextlib import contextmanager
import time

from sklearn import datasets
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score
from sklearn.pipeline import Pipeline
from sklearn.decomposition import KernelPCA
from sklearn.model_selection import train_test_split
from sklearn.externals.joblib import memory


@contextmanager
def elapsed(section):
    """コードブロックの実行時間を測るためのコンテキストマネージャ"""
    start_time = time.time()
    yield
    end_time = time.time()
    print(f'{section} elapsed: {end_time - start_time} sec')


def main():
    # Digits データセットを読み込む
    dataset = datasets.load_digits()
    X, y = dataset.data, dataset.target

    # ホールドアウト検証用にデータを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y, shuffle=True, test_size=0.33, random_state=42)

    # 処理のパイプライン
    steps = [
        # 主成分分析
        ('pca', KernelPCA()),
        # ランダムフォレスト
        ('rf', RandomForestClassifier(n_estimators=100))
    ]

    # pipeline-cache ディレクトリにキャッシュデータを保存する
    cache = memory.Memory(location='pipeline-cache', verbose=3)
    pipeline = Pipeline(steps=steps, memory=cache)

    # 学習フェーズにかかる時間を計測する
    with elapsed('fit'):
        pipeline.fit(X_train, y_train)

    # 推論フェーズにかかる時間を計測する
    with elapsed('pred'):
        y_pred = pipeline.predict(X_test)
        acc = accuracy_score(y_pred, y_test)
        print(f'accuracy: {acc}')


if __name__ == '__main__':
    main()

では、上記のサンプルコードを実行してみよう。 最初の実行では、当然ながらキャッシュは効かない。

$ python pipecache.py 
________________________________________________________________________________
[Memory] Calling sklearn.pipeline._fit_transform_one...
_fit_transform_one(KernelPCA(alpha=1.0, coef0=1, copy_X=True, degree=3, eigen_solver='auto',
     fit_inverse_transform=False, gamma=None, kernel='linear',
     kernel_params=None, max_iter=None, n_components=None, n_jobs=None,
     random_state=None, remove_zero_eig=False, tol=0), 
array([[0., ..., 0.],
       ...,
       [0., ..., 0.]]), array([1, ..., 1]), None)
________________________________________________fit_transform_one - 0.4s, 0.0min
fit elapsed: 1.4524390697479248 sec
accuracy: 0.9478114478114478
pred elapsed: 0.05564618110656738 sec

学習には 1.45 秒かかった。 尚、これには前処理・特徴量抽出の時間も含まれている。 今回に関して言えばデータセットを主成分分析してランダムフォレストを学習させるのにかかった時間ということになる。

それでは、もう一度同じように実行してみよう。 二回目はキャッシュが効くので、出力も少し異なったものになる。

$ python pipecache.py
[Memory]0.0s, 0.0min    : Loading _fit_transform_one...
fit elapsed: 1.1166560649871826 sec
accuracy: 0.9562289562289562
pred elapsed: 0.055490970611572266 sec

今度は学習が 1.11 秒と、先ほどよりも短い時間で終わっている。 これがキャッシュの効果といえる。

ちなみに、キャッシュされるものは Pipeline の最終段に位置するモデル (Estimator) に渡される直前のデータになる。 つまり、前処理や特徴量抽出の部分がキャッシュされるのであって、モデルの学習結果自体はキャッシュされない。 これは、以下のソースコードを読むと確認できる。

github.com

もし、学習結果を含めて保存したいなら、学習済みの Pipeline オブジェクトそのものを Pickle で保存しておくのが良い。

blog.amedama.jp

ちなみに、キャッシュに使う joblib の Memory は、やろうと思えば自分で拡張もできそうな雰囲気がある。

github.com

例えば一次キャッシュをローカルのディスクにして、二次キャッシュをクラウド上のオブジェクトストレージにしたような実装もできそうだ。 そうしたものがあれば、どんな環境からでもキャッシュ済みのデータが利用できて時間の節約になりそうに感じた。 GCP の Preemptible VM なんか使ってるときは特に便利そう。

Pythonによるデータ分析入門 第2版 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 第2版 ―NumPy、pandasを使ったデータ処理

  • 作者: Wes McKinney,瀬戸山雅人,小林儀匡,滝口開資
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2018/07/26
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

Pythonデータサイエンスハンドブック ―Jupyter、NumPy、pandas、Matplotlib、scikit-learnを使ったデータ分析、機械学習

リモートサーバ上の Docker コンテナで Jupyter Notebook を使う

今回は、以下のエントリの続き。

blog.amedama.jp

上記の記事でやったことを Docker コンテナにしてみる。

使った環境は次の通り。 まずは Docker ホストとして使う Ubuntu 18.04 のマシンから。 こちらも前回と同じように Vagrant で構築している。

vagrant $ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
vagrant $ uname -r
4.15.0-29-generic

そこに接続するクライアントは次の通り。

client $ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
client $ ssh -V
OpenSSH_7.6p1, LibreSSL 2.6.2

Docker をインストールする

まずは Docker ホストとして使う Ubuntu に Docker をインストールする。 やり方については以下の公式サイトに記載がある。

docs.docker.com

まずは、すでに Docker 関連のパッケージがインストールされているようであればアンインストールする。

vagrant $ sudo apt-get remove docker docker-engine docker.io

続いて作業に必要なパッケージをインストールしておく。

vagrant $ sudo apt-get update
vagrant $ sudo apt-get -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    software-properties-common

Docker の公式リポジトリを検証するための GPG 鍵をインストールする。

vagrant $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

Docker の公式リポジトリを登録する。

vagrant $ sudo add-apt-repository \
    "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
    $(lsb_release -cs) \
    stable"

あとは Docker 本体をインストールするだけ。

vagrant $ sudo apt-get update
vagrant $ sudo apt-get -y install docker-ce

これで Docker を使えるようになった。

vagrant $ sudo docker version
Client:
 Version:           18.06.1-ce
 API version:       1.38
 Go version:        go1.10.3
 Git commit:        e68fc7a
 Built:             Tue Aug 21 17:24:51 2018
 OS/Arch:           linux/amd64
 Experimental:      false

Server:
 Engine:
  Version:          18.06.1-ce
  API version:      1.38 (minimum version 1.12)
  Go version:       go1.10.3
  Git commit:       e68fc7a
  Built:            Tue Aug 21 17:23:15 2018
  OS/Arch:          linux/amd64
  Experimental:     false

Jupyter Notebook が使える Docker イメージを用意する

Jupyter Notebook は公式が Docker のリポジトリを持っている。

https://hub.docker.com/u/jupyter/

元になっている Dockerfile などは以下にあるようだ。

github.com

最初、上記を使ってやろうかと思ったんだけど、ちょっと不都合があってやめることにした。 具体的には Jupyter Notebook が Bind する IP アドレスがループバックアドレスになっている。 もしかすると環境変数なんかで変更できるのかもしれないけど、それだと単なる特定のイメージ紹介になってしまうので。

そこで、自分で Docker イメージを作ることにした。 準備した Dockerfile が以下の通り。

vagrant $ cat << 'EOF' > Dockerfile 
FROM ubuntu:18.04

# Install prerequisite packages
RUN apt-get update \
 && apt-get install -yq --no-install-recommends \
      jupyter-notebook

# User configuration
ARG USERNAME=jupyter
RUN useradd -m -s /bin/bash ${USERNAME}
USER ${USERNAME}

# Jupyter configuration
RUN jupyter notebook --generate-config \
 && mkdir -p /home/${USERNAME}/jupyter-working \
 && sed -i.back \
    -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
    -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = '*':" \
    -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
    -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = '/home/${USERNAME}/jupyter-working':" \
    /home/${USERNAME}/.jupyter/jupyter_notebook_config.py

# Expose container ports
EXPOSE 8888

# Boot process
CMD jupyter notebook
EOF

基本的に実行しているコマンドは前回のエントリと同じようなものになっている。 一点違うとすれば、前述した通り Bind する IP アドレスをループバックアドレスではなく任意のもの (*: アスタリスク) になっている。

Docker イメージをビルドする。 タグには example/jupyter という名前をつけた。

vagrant $ sudo docker image build -t example/jupyter .
...
Step 8/8 : CMD jupyter notebook
 ---> Using cache
 ---> 0d2915a8d871
Successfully built 0d2915a8d871
Successfully tagged example/jupyter:latest

あとは上記を起動するだけ・・・の前にアクセス制御をかけておこう。 SSH に使うポート以外は閉じておく。

vagrant $ sudo ufw allow 22
vagrant $ sudo ufw default DENY
vagrant $ yes | sudo ufw enable
vagrant $ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)           

ビルドしたイメージをコンテナとして起動する コンテナの TCP/8888 を Docker ホストのループバックアドレスにマッピングする。 こうすることでインターネットからは疎通がない状態でコンテナとポートをマッピングできる。

vagrant $ sudo docker container run --rm -p 127.0.0.1:8888:8888 -it example/jupyter
[I 23:45:00.610 NotebookApp] Writing notebook server cookie secret to /home/jupyter/.local/share/jupyter/runtime/notebook_cookie_secret
[W 23:45:00.778 NotebookApp] WARNING: The notebook server is listening on all IP addresses and not using encryption. This is not recommended.
[W 23:45:00.781 NotebookApp] WARNING: The notebook server is listening on all IP addresses and not using authentication. This is highly insecure and not recommended.
[I 23:45:00.788 NotebookApp] Serving notebooks from local directory: /home/jupyter/jupyter-working
[I 23:45:00.789 NotebookApp] 0 active kernels
[I 23:45:00.790 NotebookApp] The Jupyter Notebook is running at:
[I 23:45:00.792 NotebookApp] http://[all ip addresses on your system]:8888/
[I 23:45:00.793 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).

確認すると、たしかにループバックアドレスで Listen している。

vagrant $ ss -tlnp | grep 8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*            

あとは、前回のエントリと同じように SSH Port Forwarding するだけ。

client $ vagrant ssh-config > ssh.config
client $ ssh -L 8888:localhost:8888 -F ssh.config default

ブラウザでクライアントのループバックアドレスの 8888 ポートを開いてみよう。

client $ open http://localhost:8888

いつもの画面が見えるはず。

f:id:momijiame:20181014022447p:plain

Docker Compose を使う場合

一応 Docker Compose を使うパターンについても紹介しておく。 Docker Compose を使えばコンテナの起動オプションなんかを覚えておく必要がなくなる。

まずは Docker Compose をインストールする。

vagrant $ sudo apt-get -y install docker-compose
vagrant $ docker-compose version
docker-compose version 1.17.1, build unknown
docker-py version: 2.5.1
CPython version: 2.7.15rc1
OpenSSL version: OpenSSL 1.1.0g  2 Nov 2017

続いて Docker Compose の設定ファイルを用意する。

vagrant $ cat << 'EOF' > docker-compose.yml 
version: "3"
services:
  jupyter:
    build: .
    image: example/jupyter
    ports:
      - "127.0.0.1:8888:8888"
EOF

あとは、上記の設定ファイルを使ってイメージをビルドする。 さっきビルドしたキャッシュが残っていればすぐに終わるはず。

vagrant $ sudo docker-compose build

設定ファイルを元にコンテナを起動する。

vagrant $ sudo docker-compose up jupyter

起動したコンテナの Jupyter Notebook を使う方法については Docker をそのまま使うときと同じ。

めでたしめでたし。

Docker/Kubernetes 実践コンテナ開発入門

Docker/Kubernetes 実践コンテナ開発入門

リモートサーバの Jupyter Notebook を SSH Port Forwarding 経由で使う

一般的に Jupyter Notebook はローカルの環境にインストールして使うことが多い。 ただ、ローカルの環境は計算資源が乏しい場合もある。 そんなときは IaaS などリモートにあるサーバで Jupyter Notebook を使いたい場面が存在する。 ただ、セキュリティのことを考えると Jupyter Notebook の Web UI をインターネットに晒したくはない。

そこで、今回は SSH Port Forwarding を使って Web UI をインターネットに晒すことなく使う方法について書く。 このやり方ならリモートサーバに SSH でログインしたユーザだけが Jupyter Notebook を使えるようになる。 また、Web UI との通信も SSH 経由になるので HTTP over SSL/TLS (HTTPS) を使わなくても盗聴のリスクを下げられる。

リモートサーバを想定した環境は次の通り。 話を単純にするために環境は Vagrant で作ってある。

vagrant $ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
vagrant $ uname -r
4.15.0-29-generic

そこに接続するクライアントの環境は次の通り。

client $ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65
client $ ssh -V
OpenSSH_7.6p1, LibreSSL 2.6.2

必要なパッケージをインストールする

ここからは、すでにリモートの Ubuntu マシンに SSH でログインしている前提で話を進める。

まずは必要なパッケージをインストールする。 ログインするたびに Jupyter Notebook を起動するコマンドを入力するのも面倒なので、最終的に Supervisord でデーモン化することにした。

vagrant $ sudo apt-get update
vagrant $ sudo apt-get -y install jupyter-notebook supervisor

今回は OS のパッケージ管理システム経由でインストールしてるけど pip を使うとかはお好みで。

まずはサクッと試す

ひとまず手っ取り早く今回やることの本質を示す。

最初にリモートサーバ上で Jupyter Notebook を起動する。 これで TCP/8888 で Jupyter Notebook の Web UI が動く。

vagrant $ jupyter notebook

ターミナルに Web UI のアクセストークンが表示されるのでメモしておこう。

続いて、クライアントの別のターミナルを開いて、改めてリモートサーバに SSH でログインする。 このとき SSH Port Forwarding を使って、リモートサーバの TCP/8888 をローカルホストのポートにマッピングする。

client $ ssh -L 8888:localhost:8888 <username>@<remotehost>

今回は Vagrant の環境を使っているのでこんな感じ。 恒久的に設定を入れたいなら Vagrantfile を編集する。

client $ vagrant ssh-config > ssh.config
client $ ssh -L 8888:localhost:8888 -F ssh.config default

あとは、クライアントのブラウザでローカルホストにマッピングしたポート番号を開くだけ。

client $ open http://localhost:8888

すると、Jupyter Notebook の Web UI でアクセストークンを入力する画面が表示される。 先ほど Jupyter Notebook を起動するときにターミナルに表示されたトークンを入力しよう。

f:id:momijiame:20181015080902p:plain

これで、いつもの見慣れた Web UI が表示されるはず。 あとは使うだけ。

f:id:momijiame:20181014022447p:plain

以上で、今回やることの本質は示せた。

ただ、上記の操作は毎回やるには結構めんどくさいしセキュリティをあまり考慮していない。 そこで、ここからは運用をできるだけ楽に、そしてセキュアな環境を手に入れるべく手順を記載していく。

以降の手順を試すときは、一旦先ほど起動した Jupyter Notebook は停止しておこう。

アクセス制御をかける

リモートサーバを想定しているので、念のため必要なポート以外はファイアウォールを使って閉じておく。

SSH に使うポートだけを残して、それ以外は全て閉じる。 SSH に使うポート番号を 22 以外にしているときは、適宜読み替える感じで。

vagrant $ sudo ufw allow 22
vagrant $ sudo ufw default DENY
vagrant $ yes | sudo ufw enable
vagrant $ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)             

ファイアウォールの設定を変更するときはリモートサーバから追い出されないように注意しよう。

Jupyter Notebook を起動するユーザを追加する

若干好みの問題にも近いけど、念のため Jupyter Notebook を起動する専用のユーザを追加しておく。

vagrant $ sudo useradd -m -s $SHELL jupyter

Jupyter Notebook を設定する

ここからは Jupyter Notebook を設定していく。

まずは先ほど作ったユーザにログインする。

vagrant $ sudo su - jupyter

続いて、設定ファイルを生成する。

jupyter $ jupyter notebook --generate-config
Writing default config to: /home/jupyter/.jupyter/jupyter_notebook_config.py

Jupyter Notebook の作業ディレクトリを用意する。

jupyter $ mkdir -p /home/$(whoami)/jupyter-working

設定ファイルを編集する。

jupyter $ sed -i.back \
  -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
  -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = 'localhost':" \
  -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
  -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = '/home/$(whoami)/jupyter-working':" \
  /home/$(whoami)/.jupyter/jupyter_notebook_config.py
jupyter $ cat ~/.jupyter/jupyter_notebook_config.py | sed -e "/^#/d" -e "/^$/d"
c.NotebookApp.ip = 'localhost'
c.NotebookApp.notebook_dir = '/home/jupyter/jupyter-working'
c.NotebookApp.open_browser = False
c.NotebookApp.token = u''

それぞれの設定の内容や意図としては以下のような感じ。

  • c.NotebookApp.ip = 'localhost'
    • Jupyter Notebook が Listen するアドレスをループバックアドレスにする
    • もしファイアウォールがなくてもインターネットからは Jupyter Notebook の WebUI に疎通がなくなる
  • c.NotebookApp.notebook_dir = '/home/jupyter/jupyter-working'
    • Jupyter Notebook の作業ディレクトリを専用ユーザのディレクトリにする
    • 仮に Web UI が不正アクセスを受けたときにも影響範囲を小さくとどめる (気休め程度)
  • c.NotebookApp.open_browser = False
    • 起動時にブラウザを開く動作を抑制する
    • ローカル環境ではないので起動するときにブラウザを起動する必要はない
  • c.NotebookApp.token = u''
    • Jupyter Notebook の Web UI にビルトインで備わっている認証を使わない
    • 認証は SSH によるログインで担保する場合の設定 (心配なときは後述する共通パスワードなどを設定する)

(オプション) Jupyter Notebook の Web UI に共通パスワードをかける

SSH のログイン以外にも認証をかけたいときは、例えばシンプルなものだと共通パスワードが設定できる。

Jupyter Notebook の Web UI に共通パスワードをかけるには jupyter notebook password コマンドを実行する。

jupyter $ jupyter notebook password
Enter password: 
Verify password: 
[NotebookPasswordApp] Wrote hashed password to /home/jupyter/.jupyter/jupyter_notebook_config.json

すると、ソルト付きの暗号化されたパスワードが設定ファイルとしてできる。

jupyter $ cat ~/.jupyter/jupyter_notebook_config.json 
{
  "NotebookApp": {
    "password": "sha1:217911554b0b:f2fa9cd9f336951c335bdaa06a6c16eb6286c192"
  }
}

上記のやり方だとハッシュのアルゴリズムが SHA1 固定っぽい。 もし、より頑丈なものが使いたいときは次のように Python のインタプリタ経由で生成する。

jupyter $ python3
Python 3.6.6 (default, Sep 12 2018, 18:26:19) 
[GCC 8.0.1 20180414 (experimental) [trunk revision 259383]] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from notebook.auth import passwd
>>> passwd('jupyter-server-password', algorithm='sha512')
'sha512:d197670d2987:19bb2eedfc6fde56f1a9fc04d403999c3f03a99af368e528f45ee9a68f01a7c5f07e375bd34ec176d1c66a0f2e8ef7615ebcf9e524a23ace5ab6dd5a930398d4'

生成した暗号化済みパスワードを、次のような形で Jupyter Notebook の設定ファイルに入力すれば良い。

c.NotebookApp.password_required = True
c.NotebookApp.password = u'sha512:d197670d2987:19bb2eedfc6fde56f1a9fc04d403999c3f03a99af368e528f45ee9a68f01a7c5f07e375bd34ec176d1c66a0f2e8ef7615ebcf9e524a23ace5ab6dd5a930398d4'

上記の共通パスワード方式を含む Jupyter Notebook の認証周りについては以下の公式ドキュメントを参照のこと。

Running a notebook server — Jupyter Notebook 5.7.0 documentation

Jupyter Notebook を Supervisord 経由で起動する

続いては Jupyter Notebook をデーモン化する設定に入る。

一旦、元の管理者権限をもったユーザに戻る。

jupyter $ exit
logout

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

vagrant $ cat << 'EOF' | sudo tee /etc/supervisor/conf.d/jupyter.conf > /dev/null
[program:jupyter]
command=jupyter notebook
user=jupyter
stdout_logfile=/var/log/supervisor/jupyter.log
redirect_stderr=true
autostart=true
autorestart=true
EOF

Supervisord を起動する。

vagrant $ sudo systemctl enable supervisor
vagrant $ sudo systemctl reload supervisor

ちゃんと Jupyter Notebook が起動しているかを確認する。

vagrant $ ps auxww | grep [j]upyter
jupyter   4689 27.0  5.4 183560 55088 ?        S    16:31   0:01 /usr/bin/python3 /usr/bin/jupyter-notebook
vagrant $ ss -tlnp | grep :8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*       
LISTEN   0         128                   [::1]:8888                [::]:*       

もし、上手く立ち上がっていないときはログから原因を調べよう。

vagrant $ sudo tail /var/log/supervisor/supervisord.log 
vagrant $ sudo tail /var/log/supervisor/jupyter.log 

(オプション) ログインシェルを無効化する

もし Jupyter Notebook 専用に作ったユーザをシェル経由で操作するつもりがなければ、ログインシェルを無効化しておく。

vagrant $ sudo usermod -s /usr/sbin/nologin jupyter

こうするとシェル経由でユーザにログインできなくなる。

vagrant $ grep jupyter /etc/passwd
jupyter:x:1001:1001::/home/jupyter:/usr/sbin/nologin
vagrant $ sudo su - jupyter
This account is currently not available.

デーモンプログラムを起動するユーザは、不正アクセスを受けた場合の影響を小さくする意図でこうすることが多い。

SSH Port Forwarding 経由で Jupyter Notebook の Web UI にアクセスする

ここまでで、リモートサーバ上の Jupyter Notebook の設定は終わった。

一旦リモートサーバから SSH でログアウトする。

vagrant $ exit

改めて SSH Port Forwarding を有効にしてリモートサーバにログインする。 このときリモートサーバの TCP/8888 ポートを、ローカルホストのポートにマッピングする。 ユーザ名やホスト名は適宜読み替える。

client $ ssh -L 8888:localhost:8888 <username>@<remotehost>

今回は Vagrant の環境を使っているので、こんな感じで。

client $ vagrant ssh-config > ssh.config
client $ ssh -L 8888:localhost:8888 -F ssh.config default

あとは、クライアントのブラウザでローカルホストにマッピングしたポート番号を開く。

client $ open http://localhost:8888

すると、見覚えのある Web UI が表示される。 オプションの共通パスワード認証を使っていないのであれば、いきなりいつもの画面になるはず。

f:id:momijiame:20181014022447p:plain

あとは、もしポータビリティとかを考えるのであればお好みで Docker イメージとかにする感じで。

めでたしめでたし。

Python: デコレータについて

Python の特徴的な構文の一つにデコレータがある。 便利な機能なんだけど、最初はとっつきにくいかもしれない。 そこで、今回はデコレータについて一通り色々と書いてみる。 先に断っておくと、とても長い。

これを読むと、以下が分かる。

  • デコレータの本質
    • デコレータはシンタックスシュガー (糖衣構文) に過ぎない
  • デコレータの作り方
    • 引数を取るデコレータと取らないデコレータ
  • デコレータの用途
    • 用途はラッピングとマーキングの二つに大別できる
  • デコレータの種類
    • デコレータは関数、メソッド、インスタンスで作れる
  • デコレータの対象
    • デコレートできるのは関数、メソッド以外にクラスもある

今回使った環境は次の通り。 尚、紹介するコードの中には、一部に Python 3 以降でないと動作しないものが含まれている。

$ python -V
Python 3.6.6

デコレータについて

まずはデコレータのおさらかいから。 デコレータは、その名の通りオブジェクトをデコレーション (装飾) するための機能。 構文としては、デコレートしたいオブジェクトの前で @ を先頭につけて使う。 デコレートできるオブジェクトの種類は、関数、メソッド、クラスがサポートされている。

標準モジュールにも、組み込みでいくつかのデコレータがある。 その中の一つを見てみよう。 以下のサンプルコードでは functools モジュールの lru_cache というデコレータを使っている。 このデコレータを使うと、デコレートした関数を簡単にメモ化できる。 メモ化というのは、ようするに関数の戻り値をキャッシュすること。 サンプルコードでは足し算をする add() という関数をメモ化している。

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

from functools import lru_cache


# 関数をメモ化するデコレータ
@lru_cache()
def add(a, b):
    # 実際に関数が処理された場合を区別するための出力
    print('calculate')
    return a + b


def main():
    # 同じ引数で 2 回呼び出す
    print(add(1, 2))
    print(add(1, 2))


if __name__ == '__main__':
    main()

ポイントは add() 関数の中で calculate という文字列を出力しているところ。 これで、実際に関数が呼び出されたのか、それともキャッシュされた値が返ったのか区別できる。

それでは、上記を保存して実行してみよう。 サンプルコードでは同じ引数 (1, 2) を使って add() 関数を 2 回呼び出している。

$ python cache.py
calculate
3
3

実行しても calculate が 1 回しか出力されない。 つまり 2 回目の呼び出しではキャッシュされた値が返っていることが分かる。 見事に @lru_cache デコレータが機能しているようだ。

デコレータの本質

おさらいが終わったところで、早速本題に入る。 デコレータという機能は、実はシンタックスシュガー (糖衣構文) に過ぎない。 シンタックスシュガーというのは、プログラミング言語において、ある書き方に対して別の書き方ができるようにしたもの。 デコレータがシンタックスシュガーということは、つまり同じ内容はデコレータを使わなくても書けるということ。

先ほどのサンプルコードを、デコレータを使わない形に直してみよう。 つまり、足し算をする add() 関数をデコレータを使わずに functools.lru_cache でメモ化している。

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

from functools import lru_cache


# デコレータ構文は使っていない
def add(a, b):
    print('calculate')
    return a + b


# デコレータの代わりになる書き方
# デコレータ構文は、以下を書きやすくしたシンタックスシュガーにすぎない
add = lru_cache()(add)


def main():
    # 同じ引数で 2 回呼び出す
    print(add(1, 2))
    print(add(1, 2))


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。

$ python cache.py
calculate
3
3

ちゃんとメモ化が動作していることが分かる。

先ほどのサンプルコードでは functools.lru_cache をデコレータとして使っていない。 代わりに、次のようなコードが登場している。 これは lru_cache() を通して add() 関数を代入し直している。 ようするに add() 関数の内容が lru_cache() の返り値で上書きされることになる。

add = lru_cache()(add)

つまり、最初のコードで登場した以下と上記は本質的に等価ということ。

@lru_cache()
def add(a, b):

これは理解する上で重要なポイントで、デコレータを使って書かれたコードは、必ず使わずに書くこともできる。

デコレータの作り方

続いてはデコレータの作り方を見ていく。 前述したように、デコレータは単なるシンタックスシュガーで、やっていることは単なる返り値を使った上書きだった。 それさえ分かっていればデコレータの作り方は理解しやすい。

例えば、関数をデコレートするデコレータについて考えてみよう。 これまで理解した内容から考えれば「関数を受け取って、代わりとなる関数を返す」ものを作れば良い。

以下のサンプルコードでは deco という名前でデコレータを作っている。 見て分かる通り、普通の関数と見た目は何ら変わらない。 つまり deco はデコレータとして動作する関数、ということになる。 デコレータとして動作するために、引数 func という名前で関数を受け取って、代わりとなる wrapper() という関数の参照を返している。

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


def deco(func):
    """デコレートした関数の前後に処理を挟み込む自作デコレータ"""

    def wrapper(*args, **kwargs):
        """本来の関数の代わりに返される関数"""
        print('before')  # 本来の関数が呼び出される前に実行される処理
        result = func(*args, **kwargs)  # 本来の関数の呼び出し
        print('after')  # 本来の関数が呼び出された後に実行される処理
        return result  # 本来の関数の返り値を返す

    # 引数で関数を受け取って、代わりに別の関数を返す
    return wrapper


# @deco デコレータで greet() 関数をデコレートしている
@deco
def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


def main():
    # デコレータでデコレートされた関数を呼び出す
    greet()


if __name__ == '__main__':
    main()

このデコレータは、本来の関数の呼び出しの前後に文字列の出力を挟み込むものになっている。 @deco を使ってデコレートする対象は greet() という関数で、内容は文字列を出力するだけ。

上記を保存して実行してみよう。 greet() 関数が出力する文字列の前後に @deco で追加した処理が挟み込まれていることが分かる。

$ python deco.py
before
Hello, World!
after

念のため、デコレータを使わないパターンも見ておこう。 繰り返しになるけど、デコレータはただのシンタックスシュガーなので、必ず使わない形にも直せる。 デコレータを使わない形にすれば、やっていることがよく分かる。

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


def deco(func):
    """デコレートした関数の前後に処理を挟み込む自作デコレータ"""

    def wrapper(*args, **kwargs):
        """本来の関数の代わりに呼び出される関数"""
        print('before')  # 本来の関数が呼び出される前に実行される処理
        result = func(*args, **kwargs)  # 本来の関数の呼び出し
        print('after')  # 本来の関数が呼び出された後に実行される処理
        return result  # 本来の関すが返した結果を返す

    # 引数で関数を受け取って、別の関数を返している
    return wrapper


def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


# デコレータは単なるシンタックスシュガーに過ぎないため、
# 必ず以下のような代入文に置き換えることができる
greet = deco(greet)


def main():
    # デコレータでデコレートされた関数を呼び出す
    greet()


if __name__ == '__main__':
    main()

ようするに deco() 関数が greet() 関数の参照を受け取って、代わりに wrapper() 関数の参照を返しているだけ。

上記を保存して実行してみよう。

$ python deco.py  
before
Hello, World!
after

ちゃんと動作している。

引数を受け取るデコレータ

先ほどのサンプルコードで登場した deco デコレータは lru_cache デコレータと違うところが一つあった。 それは、デコレータとして使うとき後ろにカッコがあるかないか。

lru_cache の例を思い出すと、後ろにカッコがついていた。

@lru_cache()
def add(a, b):

それに対して deco の例では、後ろにカッコがない。

@deco
def greet():

上記の違いは、デコレータが引数を受け取るか受け取らないか。 例えば lru_cache であれば、キャッシュする数の上限を設定するために maxsize というオプションがあったりするため。 つまり、こんな感じで書ける。

@lru_cache(maxsize=32)
def add(a, b):

先ほどの deco を引数を受け取れるように書き換えてみよう。 次のサンプルコードでは deco デコレータが本来の処理の前後に挿入するメッセージを引数で指定できるようにしている。 コード上の変化としては、先ほどよりも deco のネストが増していることが分かる。 引数の受け取らないパターンで deco という名前だった関数が今度は wrapper という名前になって、新しい deco がそれを返している。

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


def deco(before_msg='before', after_msg='after'):
    """引数を受け取るデコレータ (ネストが一段増える)"""

    def wrapper(func):
        def _wrapper(*args, **kwargs):
            print(before_msg)
            result = func(*args, **kwargs)
            print(after_msg)
            return result
        return _wrapper

    return wrapper


# デコレータにカッコがあって引数を受け取っている
@deco('mae', 'ato')
def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


def main():
    # デコレータでデコレートされた関数を呼び出す
    greet()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 今度はデコレータを使うときに指定した引数にもとづいて前後の出力が変化している。

$ python decoargs.py 
mae
Hello, World!
ato

引数を取るパターンでは、取らないパターンよりも何をやっているのかが分かりにくいかもしれない。 これも、デコレータを使わない形に書き直すと理解しやすくなる。 以下のサンプルコードは、同じ内容をデコレータを使わない形に直してある。

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


def deco(before_msg='before', after_msg='after'):
    """引数を受け取るデコレータ (ネストが一段増える)"""

    def wrapper(func):
        def _wrapper(*args, **kwargs):
            print(before_msg)
            result = func(*args, **kwargs)
            print(after_msg)
            return result
        return _wrapper

    return wrapper


def greet():
    """文字列を書き出すだけの関数"""
    print('Hello, World!')


# デコレータ構文を使わずに書いたパターン
greet = deco('mae', 'ato')(greet)
# より冗長に、分かりやすく書くと以下のようになる
# wrap_func = deco('mae', 'ato')
# greet = wrap_func(greet)


def main():
    greet()


if __name__ == '__main__':
    main()

上記を見ると、関数を上書きする工程が二段階に分かれていることが見て取れる。 冗長に分かりやすく書いたパターンでは、まず deco() が関数を上書きするのに使う関数 (変数 wrap_func) を返している。 そして、その関数を使って対象の関数 greet() を上書きしている。 これが引数を受け取るデコレータの動作原理ということ。

以降は、デコレータを使わずに書いたパターンを示すことは基本的には省略する。 しかし、デコレータが単なるシンタックスシュガーで、使わないパターンに必ず書き直せるという点は意識しながら読むと理解が深まると思う。

デコレータの用途

デコレータの基本が分かったところで、次は用途について考えてみる。 デコレータの用途は、大きく分けて「ラッピング」と「マーキング」の二つがある。 これまで紹介してきた内容は、用途が全て前者の「ラッピング」だった。

ラッピング

それでは、まずラッピングの用途から見ていこう。 これは、これまでにも紹介してきた通り元の関数などをデコレータを通して上書きするというもの。 以下のサンプルコードでは関数の返り値に 2 倍をかけて返すデコレータ double を定義している。

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


def double(func):
    """デコレートした関数の返り値を 2 倍にするデコレータ"""
    def wrapper(*args, **kwargs):
        # 本来の関数の返り値に 2 をかけて返す
        return func(*args, **kwargs) * 2
    return wrapper


# 返り値を倍にするデコレータをつける
@double
def add(a, b):
    """足し算をする関数"""
    return a + b


def main():
    # 1 + 2 を計算すると...?
    print('1 + 2 =', add(1, 2))  # 1 + 2 = 3 ... 6!!


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 @double デコレータによってデコレートされた add() 関数は、計算結果を倍にして返すように上書きされる。

$ python wrapping.py 
1 + 2 = 6

ラッピング用途での注意点

ちなみに、ラッピング用途でデコレータを使うときは一つ注意点がある。 それは、ラッピング用途のデコレータが、デコレートしたオブジェクトを代わりの何かで上書きするという性質に由来している。

以下のサンプルコードを見てほしい。 このコードでは、デコレートされた関数 add() の名前を __name__ プロパティから取得して出力している。 もちろん、本来の意図としては add という文字列が出力されてほしいはず。

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


def double(func):
    """デコレートした関数の返り値を 2 倍にするデコレータ"""
    def wrapper(*args, **kwargs):
        # 本来の関数の返り値に 2 をかけて返す
        return func(*args, **kwargs) * 2
    return wrapper


# 返り値を倍にするデコレータをつける
@double
def add(a, b):
    """足し算をする関数"""
    return a + b


def main():
    # add() 関数の名前は?
    print('add()\'s name:', add.__name__)


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。

$ python name.py 
add()'s name: wrapper

なんと、残念ながら wrapper という出力になってしまった。

ここまで読んできていれば、理由は何となく想像がつくと思う。 ようするにデコレータを通して add() 関数は wrapper() 関数に置き換えられてしまっている。 そのため add() 関数のつもりで扱うと、実際には置き換えられた関数だった、ということが起こる。

この問題は functools.wraps を使うと解決できる。 以下のサンプルコードでは、デコレータが返す代わりの関数を functools.wraps でデコレートしている。

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

from functools import wraps


def double(func):
    """デコレートした関数の返り値を 2 倍にするデコレータ"""
    # デコレータが返す関数を functools.wraps でデコレートする
    @wraps(func)
    def wrapper(*args, **kwargs):
        # 本来の関数の返り値に 2 をかけて返す
        return func(*args, **kwargs) * 2
    return wrapper


# 返り値を倍にするデコレータをつける
@double
def add(a, b):
    """足し算をする関数"""
    return a + b


def main():
    # add() 関数の名前は?
    print('add()\'s name:', add.__name__)


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 今度はちゃんと add という名前が出力された。

$ python name.py 
add()'s name: add

このように functools.wraps を使うと、置き換える関数が元の関数の性質を引き継げる。

マーキング

もう一つの用途としてマーキングを見てみよう。 この用途では、デコレータは受け取ったオブジェクトをそのまま返す。 ただし、受け取ったオブジェクトを何処かに記録しておいて、それを後から利用することになる。 以下のサンプルコードでは @register デコレータでデコレートした関数は _MARKED_FUNCTIONS というリストに保存される。 そして、保存されたリストから関数を呼び出している。

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


# デコレータでデコレートした関数を入れるリスト
_MARKED_FUNCTIONS = []


def register(func):
    """関数を登録するデコレータ"""

    # デコレートした関数をリストに追加する
    _MARKED_FUNCTIONS.append(func)

    # 受け取った関数をそのまま返す
    return func


# デコレータを使って、それぞれの関数をマーキングしていく
@register
def greet_morning():
    print('Good morning!')


@register
def greet_afternoon():
    print('Good afternoon!')


@register
def greet_evening():
    print('Good evening!')


def main():
    # リストに追加された関数を確認する
    print(_MARKED_FUNCTIONS)
    # 先頭の一つを呼び出してみる
    _MARKED_FUNCTIONS[0]()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 デコレートした関数がリストに保存されて、それを後から呼び出すことができている。

$ python marking.py 
[<function greet_morning at 0x10b4d1598>, <function greet_afternoon at 0x10b59b378>, <function greet_evening at 0x10b59b400>]
Good morning!

マーキング用途のデコレータは、典型的にはイベントハンドラで用いられる。 例えば Web アプリケーションフレームワークの Flask は、マーキングした関数がクライアントからのアクセスを捌くハンドラになる。

もちろん、上記のコードもデコレータを使わない形に直せる。

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


# デコレータでデコレートした関数を入れるリスト
_MARKED_FUNCTIONS = []


def register(func):
    """関数を登録するデコレータ"""

    # デコレートした関数をリストに追加する
    _MARKED_FUNCTIONS.append(func)

    # 受け取った関数をそのまま返す
    return func


# デコレータを使って、それぞれの関数をマーキングしていく
def greet_morning():
    print('Good morning!')


def greet_afternoon():
    print('Good afternoon!')


def greet_evening():
    print('Good evening!')


# デコレータを使わずに書き換えたパターン
greet_morning = register(greet_morning)
greet_afternoon = register(greet_afternoon)
greet_evening = register(greet_evening)


def main():
    # リストに追加された関数を確認する
    print(_MARKED_FUNCTIONS)
    # 先頭の一つを呼び出してみる
    _MARKED_FUNCTIONS[0]()


if __name__ == '__main__':
    main()

上記を見て分かる通り、デコレータはモジュールが読み込まれるタイミングで解釈される。 そのため、あらかじめデコレートされた関数の情報を収集するようなこともできるというわけ。

関数以外で作るデコレータ

ここまで紹介してきたデコレータは、全て関数を使って実装されていた。 しかし、デコレータはそれ以外を使った作り方もある。

メソッドで作るデコレータ

例えば、以下のサンプルコードを見てほしい。 ここでは Decorator クラスの deco() というインスタンスメソッドでデコレータを実装している。 内容は最初に自作した処理の前後に出力を挿入するものだ。

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


class Decorator(object):

    def deco(self, func):
        """デコレータとして機能するメソッド"""

        def wrapper(*args, **kwargs):
            print('before')
            result = func(*args, **kwargs)
            print('after')
            return result

        return wrapper


# クラスをインスタンス化する
instance = Decorator()


# インスタンスメソッドで作ったデコレータ
@instance.deco
def greet():
    print('Hello, World!')


def main():
    greet()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 ちゃんとデコレータとして機能していることが分かる。

$ python instance.py 
before
Hello, World!
after

インスタンスメソッドとしてデコレータを実装すると嬉しいのは、インスタンスごとにコンテキストを持たせられるところ。 以下のサンプルコードにおいて japaneseenglish という二つのインスタンスは、それぞれ異なる引数で初期化されている。 そして、それぞれが別の関数をデコレートしている。

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


class Decorator(object):

    def __init__(self, before_msg='before', after_msg='after'):
        # 前後に挿入するメッセージ
        self.before_msg = before_msg
        self.after_msg = after_msg

    def deco(self, func):
        """デコレータとして機能するメソッド"""

        def wrapper(*args, **kwargs):
            print(self.before_msg)
            result = func(*args, **kwargs)
            print(self.after_msg)
            return result

        return wrapper


# インスタンスごとにコンテキストが持てるのがポイント
japanese = Decorator('mae', 'ato')
english = Decorator('before', 'after')


@japanese.deco
def greet_morning():
    print('Good morning')


@english.deco
def greet_afternoon():
    print('Good afternoon')


def main():
    greet_morning()
    greet_afternoon()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 すると、初期化したときの引数によってデコレートされた結果が異なっていることが分かる。

$ python context.py 
mae
Good morning
ato
before
Good afternoon
after

このようにインスタンスメソッドでデコレータを作ると、インスタンスにコンテキストをもたせられるというメリットがある。

呼び出し可能オブジェクトで作るデコレータ

メソッドで作るデコレータの変わり種として、呼び出し可能オブジェクトを使うパターンも考えられる。 これはクラスに特殊メソッド __call__() を実装するというもの。 この特殊メソッドを実装すると、インスタンス自体を関数みたいに実行できるようになる。 で、その特殊メソッド __call__() がデコレータとして動作するとしたら?という。

以下のサンプルコードでは特殊メソッド __call__() がデコレータとして動作する。 そのためインスタンス化したオブジェクトの instance が、そのままデコレータとして使えている。

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


class Decorator(object):

    def __call__(self, func):
        """呼び出し可能オブジェクトを作るための特殊メソッド

        デコレータとして動作する"""

        def wrapper(*args, **kwargs):
            print('before')
            result = func(*args, **kwargs)
            print('after')
            return result

        return wrapper


# クラスをインスタンス化する
instance = Decorator()


# 呼び出し可能オブジェクトなので
# インスタンスそのものがデコレータとして使える
@instance
def greet():
    print('Hello, World!')


def main():
    greet()


if __name__ == '__main__':
    main()

実行結果はこれまでと変わらないので省略する。

デコレートする対象

ここまでの例では、デコレートする対象は全て関数だった。 しかし、デコレータは関数以外もデコレートすることができる。

メソッドをデコレートする

以下のサンプルコードでは、おなじみの @deco デコレータがインスタンスメソッドをデコレートしている。

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


def deco(func):
    """デコレートした関数の前後に処理を挟み込む自作デコレータ"""

    def wrapper(*args, **kwargs):
        """本来の関数の代わりに呼び出される関数"""
        print('before')  # 本来の関数が呼び出される前に実行される処理
        result = func(*args, **kwargs)  # 本来の関数の呼び出し
        print('after')  # 本来の関数が呼び出された後に実行される処理
        return result  # 本来の関すが返した結果を返す

    # 引数で関数を受け取って、別の関数を返している
    return wrapper


class MyClass(object):

    # インスタンスメソッドをデコレートする
    @deco
    def greet(self):
        print('Hello, World!')


def main():
    # クラスをインスタンス化する
    obj = MyClass()
    # デコレートされたメソッドを呼び出す
    obj.greet()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 ちゃんと動くことが分かる。

$ python method.py 
before
Hello, World!
after

ちなみにメソッドをデコレートするときの注意点が一つある。 それは、置き換える関数の第一引数にインスタンスオブジェクトを受け取れるようにすること。 Python のメソッドは、典型的には self という名前で第一引数にインスタンスを受け取る。 置き換える関数が、この一つ余分な引数を受け取れるようになっていないと動作しない。 先ほどのサンプルコードでは引数を (*args, **kwargs) という任意の形で受け取れるようにしていたので、特に気にすることはなかった。

クラスをデコレートする

デコレータはクラスをデコレートすることもできる。

以下のサンプルコードでは @deco デコレータが MyClass をデコレートしている。 @deco デコレータでは、クラスが持っているメソッドを上書きして回っている。 上書きされたメソッドは、呼び出されたタイミングでその旨が出力されるようになる。

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

import inspect


def deco(cls):
    """クラスオブジェクトを引数に取るデコレータ

    XXX: Python 3 でしか動作しない"""

    # クラスからメソッド一覧を取得する
    methods = inspect.getmembers(cls, predicate=inspect.isfunction)

    # クラスのメソッドを上書きして回る
    for method_name, method_object in methods:
        wrapped_method = logging_wrapper(method_object)
        setattr(cls, method_name, wrapped_method)

    # 受け取ったクラスはそのまま返す
    return cls


def logging_wrapper(func):
    """関数の呼び出しを記録するラッパー"""
    def _wrapper(*args, **kwargs):
        # 本当は logging モジュールを使うべき
        print('call:', func.__name__)
        result = func(*args, **kwargs)
        return result
    return _wrapper


# クラスをデコレータでデコレートする
@deco
class MyClass(object):

    def greet_morning(self):
        print('Good morning!')

    def greet_afternoon(self):
        print('Good afternoon!')

    def greet_evening(self):
        print('Good evening!')


def main():
    # デコレータでデコレートされたクラスをインスタンス化する
    o = MyClass()
    # いくつかのメソッドを呼び出す (実はデコレータによって上書き済み)
    o.greet_morning()
    o.greet_afternoon()
    o.greet_evening()


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 上書きされたメソッドによって、呼び出しが記録されていることが分かる。

$ python clsdeco.py 
call: greet_morning
Good morning!
call: greet_afternoon
Good afternoon!
call: greet_evening
Good evening!

まとめ

今回扱った内容は以下の通り。

  • デコレータの本質
    • デコレータはシンタックスシュガー (糖衣構文) に過ぎない
  • デコレータの作り方
    • 引数を取るデコレータと取らないデコレータ
  • デコレータの用途
    • 用途はラッピングとマーキングの二つに大別できる
  • デコレータの種類
    • デコレータは関数、メソッド、インスタンスで作れる
  • デコレータの対象
    • デコレートできるのは関数、メソッド以外にクラスもある

上記さえ理解していれば、あとは目的に応じてどのようなデコレータを作れば良いかが自動的に決まる。

参考

www.python.org

www.python.org