CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: pandas で縦持ちのデータを横持ちにする

データ処理の世界では、データの持ち方に縦持ちと横持ちという考え方がある。 縦持ちでは、レコードに種類といったカラムを持たせてデータを追加していく。 それに対し横持ちでは種類ごとにカラムを用意した上でデータを追加する形を取る。 一般的にはデータの持ち方としては縦持ちのものが多いと思う。 今回は pandas で縦持ちのデータを横持ちに直す方法について書く。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ python -V    
Python 3.6.5

下準備

まずは pandas をインストールしておく。

$ pip install pandas

続いて Python のインタプリタを起動する。

$ python

あとは縦持ちの形式になったサンプルの DataFrame オブジェクトを用意する。 今回は、時系列データで商品が売れた数っぽいものを用意した。

>>> import pandas as pd
>>> data = [
...     ('2018-05-30 20:00:00', 'Onigiri', 1),
...     ('2018-05-30 20:00:00', 'Pan', 2),
...     ('2018-05-30 20:00:00', 'Pasta', 1),
...     ('2018-05-30 20:00:10', 'Onigiri', 3),
...     ('2018-05-30 20:00:20', 'Pan', 2),
...     ('2018-05-30 20:00:30', 'Pasta', 1),
...     ('2018-05-30 20:01:00', 'Onigiri', 2),
...     ('2018-05-30 20:01:00', 'Pan', 2),
...     ('2018-05-30 20:01:00', 'Pasta', 2),
... ]
>>> columns = ['datetime', 'code', 'amount']
>>> df = pd.DataFrame(data, columns=columns)
>>> df = df.assign(datetime = pd.to_datetime(df.datetime, format='%Y-%m-%d %H:%M:%S'))

出来上がる DataFrame はこんな感じ。

>>> df
             datetime     code  amount
0 2018-05-30 20:00:00  Onigiri       1
1 2018-05-30 20:00:00      Pan       2
2 2018-05-30 20:00:00    Pasta       1
3 2018-05-30 20:00:10  Onigiri       3
4 2018-05-30 20:00:20      Pan       2
5 2018-05-30 20:00:30    Pasta       1
6 2018-05-30 20:01:00  Onigiri       2
7 2018-05-30 20:01:00      Pan       2
8 2018-05-30 20:01:00    Pasta       2
>>> df.dtypes
datetime    datetime64[ns]
code                object
amount               int64
dtype: object

横持ちにする

それでは上記を横持ちに直してみる。 pandas でデータを横持ちにするときは DataFrame#pivot_table() を使う。

インデックスには時刻 (datetime) を、集計対象は売れた数 (amount)、カラムの種類は商品 (code) でテーブルを組んでみよう。 集計用の関数には加算 (sum) を指定している。 fill_value オプションを指定しておくと、値が存在しないときにそれで埋めてくれる。

>>> df.pivot_table(values=['amount'], index=['datetime'], columns=['code'], aggfunc='sum', fill_value=0)
                     amount          
code                Onigiri Pan Pasta
datetime                             
2018-05-30 20:00:00       1   2     1
2018-05-30 20:00:10       3   0     0
2018-05-30 20:00:20       0   2     0
2018-05-30 20:00:30       0   0     1
2018-05-30 20:01:00       2   2     2

いいかんじ。

それぞれのオプションを変えるとテーブルの形を変えられる。 例えばインデックスを指定しない場合は上記から時系列情報が抜ける。

>>> df.pivot_table(values=['amount'], columns=['code'], aggfunc='sum')
code    Onigiri  Pan  Pasta
amount        6    6      4

インデックスを商品にすれば縦横が入れ替わる。

>>> df.pivot_table(values=['amount'], index=['code'], aggfunc='sum')
         amount
code           
Onigiri       6
Pan           6
Pasta         4

まあ、とはいえ上2つのような集計なら DataFrame#groupby() でも十分かな。

>>> df.groupby('code').sum()
         amount
code           
Onigiri       6
Pan           6
Pasta         4

めでたしめでたし。

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

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

前処理大全[データ分析のためのSQL/R/Python実践テクニック]

前処理大全[データ分析のためのSQL/R/Python実践テクニック]

Python: pandas で DataFrame を連結したら dtype が int から float になって驚いた話

今回は pandas を使っているときに二つの DataFrame を pd.concat() で連結したところ int のカラムが float になって驚いた、という話。 先に結論から書いてしまうと、これは片方の DataFrame に存在しないカラムがあったとき、それが全て NaN 扱いになることで発生する。 NaN は浮動小数点数型にしか存在しない概念なので、それが元で整数型と浮動小数点数型の演算になりキャストされてしまった。

使った環境は次の通り。

$ sw_vers  
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ python -V
Python 3.6.5

下準備

まずは下準備として pandas と numpy をインストールしておく。

$ pip install pandas numpy

インストールできたら Python の REPL を起動する。

$ python

これで下準備はおわり。

問題の起こらないパターン

それでは早速 pandas.concat() を使って二つの DataFrame を連結してみよう。 最初は問題の起こらないパターンについて概要を説明する。

まずは pandas をインポートしておく。

>>> import pandas as pd

続いて、次のように同じカラムを持った DataFrame を二つ用意する。 カラム名は両者で順番が異なっている。 最初の DataFrame はユーザ名、年齢の順になっているが、二つ目は年齢、ユーザ名の順になっている。

>>> users_df1 = pd.DataFrame([
...   ['alice', 10],
...   ['bob', 20],
...   ['carol', 30]
... ], columns=['name', 'age'])
>>> users_df2 = pd.DataFrame([
...   [40, 'daniel'],
...   [50, 'emily'],
...   [60, 'frank'],
... ], columns=['age', 'name'])

それぞれの DataFrame に含まれる年齢 (age) の dtype は int64 になっている。

>>> users_df1.age.dtype
dtype('int64')
>>> users_df2.age.dtype
dtype('int64')

それでは pandas.concat() 関数を使って上記二つの DataFrame を縦に連結してみよう。 この関数は、カラム名さえ一致すれば順番が異なっていても上手いこと連結してくれることが分かる。

>>> concat_df = pd.concat([users_df1, users_df2])
>>> concat_df
   age    name
0   10   alice
1   20     bob
2   30   carol
0   40  daniel
1   50   emily
2   60   frank

カラムの dtype も、ちゃんと int64 のままだ。

>>> concat_df.age.dtype
dtype('int64')

問題の起こるパターン

では、続いて問題の起こるパターンについて見てみよう。

連結に使う DataFrame は次の通り。 先ほどとほとんど同じだが users_df2 に関しては user_id というカラムが追加されている。 そしてポイントは user_id というカラムが users_df1 には存在しないことだ。

>>> users_df1 = pd.DataFrame([
...   ['alice', 10],
...   ['bob', 20],
...   ['carol', 30]
... ], columns=['name', 'age'])
>>> users_df2 = pd.DataFrame([
...   [10, 40, 'daniel'],
...   [20, 50, 'emily'],
...   [30, 60, 'frank'],
... ], columns=['user_id', 'age', 'name'])

追加された users_df2user_id カラムの dtype は int64 になっている。

>>> users_df2.user_id.dtype
dtype('int64')

それでは二つの DataFrame を結合してみよう。

>>> concat_df = pd.concat([users_df1, users_df2])

結合された結果を確認してみよう。 すると user_id カラムについては一部に NaN が入っており、入っていないものについては小数点以下が表示されている。

>>> concat_df
   age    name  user_id
0   10   alice      NaN
1   20     bob      NaN
2   30   carol      NaN
0   40  daniel     10.0
1   50   emily     20.0
2   60   frank     30.0

おや、と思いながら dtype を確認すると、結合前の int64 が結合後には float64 に変換されている。

>>> concat_df.user_id.dtype
dtype('float64')

何が起こったのか

NumPy をインポートして numpy.nan を参照すると、たしかに型は float になっている。

>>> import numpy as np
>>> type(np.nan)
<class 'float'>

NaN を整数型にキャストしようとしても、上手くいかない。 そもそも、整数型には NaN という概念が存在しないため。

>>> np.int64(np.nan)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: cannot convert float NaN to integer

で、よくよく考えるとそもそも整数型と浮動小数点型の演算結果は浮動小数点型に合わせて出てくるものだった。

>>> 1 + 1.0
2.0
>>> type(1 + 1.0)
<class 'float'>

つまり、最初にやっていたことをよりシンプルな形で表すと、次のようになる。 まず、numpy.int64numpy.float64pandas.Series を用意する。

>>> s1 = pd.Series([0, 1, 2], dtype=np.int64)
>>> s2 = pd.Series([np.nan, np.nan, np.nan], dtype=np.float64)

そして、それらを結合する。 結果は浮動小数点型に合わせられる。

>>> pd.concat([s1, s2])
0    0.0
1    1.0
2    2.0
0    NaN
1    NaN
2    NaN
dtype: float64

なるほど、これなら違和感なし。

じゃあ、どうすれば良いのか?

これを解決するためのアプローチとしては次の二つの方法になると思う。 - あらかじめ存在しないカラムを追加しておく - 結合した上で値を置換、キャストする

最初の方法は自明として、二つ目に関して補足する。 まず、さきほどの DataFrame があったとする。

>>> concat_df
   age    name  user_id
0   10   alice      NaN
1   20     bob      NaN
2   30   carol      NaN
0   40  daniel     10.0
1   50   emily     20.0
2   60   frank     30.0

浮動小数点になっちゃったカラムについて NaN になっているところを Series#fillna() を使って適当な値に置換する。 DataFrame.fillna() するとカラムに関係なく埋められてしまう点に注意する。

>>> concat_df.user_id.fillna(-1, inplace=True)
>>> concat_df
   age    name  user_id
0   10   alice     -1.0
1   20     bob     -1.0
2   30   carol     -1.0
0   40  daniel     10.0
1   50   emily     20.0
2   60   frank     30.0

あとは Series#astype() で元々の型にキャストする。

>>> df.assign(user_id = df.user_id.astype(np.int64))
   age    name  user_id
0   10   alice       -1
1   20     bob       -1
2   30   carol       -1
0   40  daniel       10
1   50   emily       20
2   60   frank       30

いじょう。

めでたしめでたし。

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

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

Python: pickle を使って pandas の CSV 読み込み時間を削減する

機械学習やデータ分析に使うデータセットは CSV などの形式で提供される場合がある。 それを Python で処理するときは pandas の DataFrame 形式に変換することが多い。 このとき CSV から DataFrame に変換する処理は意外と時間がかかる。 特に大きなデータセットでこれを毎回やっていると効率が悪い。 今回は、一旦読み込んだ DataFrame を pickle を使って直接ファイルに保存することで時間を節約できるという話について。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ python -V                                
Python 3.6.5

下準備

まずは pandas と iptyhon をインストールしておく。 ipython を入れているのは、実行にかかる時間を測るのに便利なため。

$ pip install pandas ipython

サンプルとして大きな CSV のデータセットが欲しかったので適当に Kaggle から見繕ってきた。 これは大気の汚染状況に関するデータセット。

$ kaggle datasets download -d epa/hazardous-air-pollutants

kaggle コマンドについては以下の記事を参照のこと。

blog.amedama.jp

もちろん Kaggle の Web サイトからダウンロードしてきても問題はない。

Hazardous Air Pollutants | Kaggle

ダウンロードしてきたデータセットを解凍する。

$ mv ~/.kaggle/datasets/epa/hazardous-air-pollutants/hazardous-air-pollutants.zip .
$ unzip hazardous-air-pollutants.zip 
Archive:  hazardous-air-pollutants.zip
  inflating: epa_hap_daily_summary.csv

これで 2GB 強の CSV ファイルが表れる。

$ du -m epa_hap_daily_summary.csv 
2350   epa_hap_daily_summary.csv

CSV を DataFrame に変換する

それでは上記の CSV を DataFrame に変換してみよう。

まずは IPython を起動する。

$ ipython

起動したら pandas をインポートしよう。

>>> import pandas as pd

あとは pandas.read_csv() 関数で CSV を DataFrame に変換する。 このとき IPython の %time マジックコマンドで処理にかかる時間を測っておこう。

>>> %time df = pd.read_csv('epa_hap_daily_summary.csv')
CPU times: user 55 s, sys: 9.76 s, total: 1min 4s
Wall time: 1min 7s

読み込みには 1 分強かかった。

読み込まれた DataFrame は次の通り。

>>> df.head()
state_code  county_code  site_num  parameter_code  poc   latitude  \
0          42          125      5001           88103    5  40.445278   
1          48          439      1002           43843   15  32.805818   
2          22          127      9000           88128    1  32.057581   
3          18           89        22           45201    1  41.606680   
4           6           89      3003           88136    1  40.539990   

 longitude  datum      parameter_name sample_duration         ...           \
0  -80.420833  WGS84    Arsenic PM2.5 LC         24 HOUR         ...            
1  -97.356568  WGS84  Ethylene dibromide         24 HOUR         ...            
2  -92.435157  WGS84       Lead PM2.5 LC         24 HOUR         ...            
3  -87.304729  WGS84             Benzene          1 HOUR         ...            
4 -121.576460  NAD83     Nickel PM2.5 LC         24 HOUR         ...            
...(省略)

さて、今回かかった時間は 1 分とはいえちりも積もればという話もある。 また、より複雑なパースを指定すれば必要な時間はどんどん増えていく。

DataFrame を pickle で保存・復元する

そこで、次は DataFrame を pickle で保存・復元してみる。 CSV から逐一変換するのに比べて、どれくらい速くなるだろうか。

pandas の DataFrame には to_pickle() というメソッドがあるので、それを使えば DataFrame をファイルに保存できる。

>>> df.to_pickle('hazardous-air-pollutants.pickle')

約 1.5GB の pickle ファイルができた。

$ du -m hazardous-air-pollutants.pickle 
1501   hazardous-air-pollutants.pickle

それでは、上記のファイルを改めて読み込んでみる。 そのために、ここで一旦インタプリタを終了しておこう。

>>> exit()

もう一度 IPython を起動し直す。

$ ipython

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

>>> import pickle

そして、次のようにして保存したファイルから DataFrame を復元する。 こちらも、処理に要する時間を %%time マジックコマンドで計測しておこう。

>>> %%time
... with open('hazardous-air-pollutants.pickle', mode='rb') as fp:
...     df = pickle.load(fp)
...
CPU times: user 4.5 s, sys: 2.24 s, total: 6.74 s
Wall time: 7.21 s

今度は約 7 秒で処理が終わった。 ここまで速くなる理由は CSV の各データを識別・パースする処理が不要になるため。

あるいは pandas の API を使って次のように一行で読み込むこともできる。

>>> %time df = pd.read_pickle('hazardous-air-pollutants.pickle')
CPU times: user 4.51 s, sys: 4.07 s, total: 8.58 s
Wall time: 9.03 s

ちゃんと DataFrame が復元されている。

>>> df.head()
state_code  county_code  site_num  parameter_code  poc   latitude  \
0          42          125      5001           88103    5  40.445278   
1          48          439      1002           43843   15  32.805818   
2          22          127      9000           88128    1  32.057581   
3          18           89        22           45201    1  41.606680   
4           6           89      3003           88136    1  40.539990   

 longitude  datum      parameter_name sample_duration         ...           \
0  -80.420833  WGS84    Arsenic PM2.5 LC         24 HOUR         ...            
1  -97.356568  WGS84  Ethylene dibromide         24 HOUR         ...            
2  -92.435157  WGS84       Lead PM2.5 LC         24 HOUR         ...            
3  -87.304729  WGS84             Benzene          1 HOUR         ...            
4 -121.576460  NAD83     Nickel PM2.5 LC         24 HOUR         ...            

めでたしめでたし。

Python: 機械学習の学習済みモデルを pickle でファイルに保存する

今回は機械学習において学習済みのモデルを取り回す方法の一つとして pickle を扱う方法を取り上げてみる。 尚、使うフレームワークによっては pickle 以外の方法があらかじめ提供されている場合もある。 例えば学習済みモデルのパラメータを文字列などの形でダンプできるようになっているものとか。

ちなみに pickle という機能自体は機械学習に限らず色々な用途に応用が効く。 より汎用な解説については以前に別の記事でまとめたことがある。

blog.amedama.jp

使う環境は以下の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ python -V               
Python 3.6.5

下準備

今回はサンプルとして scikit-learn を使うため、まずはインストールしておく。

$ pip install scikit-learn

インストールが終わったら Python のインタプリタを起動する。

$ python

学習済みモデルを用意する

まずは学習済みモデルを用意する。 今回はサンプルとして Iris データセットを k-NN 分類器で学習させることにした。

まずは Iris データセットを読み込む。 この際、一番最後のデータについては学習から除外しておく。 これは、後ほど保存・復元したモデルに識別させるため。

>>> from sklearn import datasets
>>> iris = datasets.load_iris()
>>> X, y = iris.data[:-1], iris.target[:-1]

続いては k-NN 分類器のモデルを用意する。

>>> from sklearn.neighbors import KNeighborsClassifier
>>> clf = KNeighborsClassifier()

Iris データセットを使ってモデルを学習させる。

>>> clf.fit(X, y)
KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
           metric_params=None, n_jobs=1, n_neighbors=5, p=2,
           weights='uniform')

これで学習済みモデルが用意できた。

学習済みモデルを保存する

続いては用意できた学習済みモデルを pickle を使って保存する。 以下のコードで model.pickle というファイル名でモデルを保存できる。

>>> import pickle
>>> with open('model.pickle', mode='wb') as fp:
...     pickle.dump(clf, fp)
... 

別のバージョンの Python からも読み込むことが考えられるときは protocol オプションに 2 を指定しておくと良い。 こうすれば Python 2.3 以降であれば保存したモデルを復元できるようになる。

>>> with open('model.pickle', mode='wb') as fp:
...     pickle.dump(clf, fp, protocol=2)
... 

これでモデルがファイルに保存できたので一旦インタプリタを終了しておく。

>>> exit()

学習済みモデルを復元する

続いては、先ほどファイルに保存した学習済みモデルを別のインタプリタから復元できることを確認しよう。

$ python

次のコードで、先ほど保存した model.pickle というファイルから学習済みモデルを復元できる。

>>> import pickle
>>> with open('model.pickle', mode='rb') as fp:
...     clf = pickle.load(fp)
... 

たしかに k-NN 分類器のモデルが得られた。

>>> clf
KNeighborsClassifier(algorithm='auto', leaf_size=30, metric='minkowski',
           metric_params=None, n_jobs=1, n_neighbors=5, p=2,
           weights='uniform')

先ほど学習に使わなかった一番最後のデータを識別させてみよう。 まずはデータセットからデータを取り出す。

>>> from sklearn import datasets
>>> iris = datasets.load_iris()
>>> X_last, y_last = iris.data[-1], iris.target[-1]

学習済みモデルで識別させてみる。

>>> clf.predict([X_last])
array([2])

モデルはデータをラベル 2 と判断した。

正解ラベルも 2 なので正しく識別できている。

>>> y_last
2

めでたしめでたし。

Kaggle をコマンドラインで操作する

最近、データ分析コンペサイトの Kaggle に公式のコマンドラインツールができた。 今回はそれを使ってみる。

環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ python -V                
Python 3.6.5

インストール

ツールは Python で書かれているので pip を使ってインストールする。

$ pip install kaggle

設定

ダウンロードしただけではユーザ固有の操作ができない。 なので、まずはその設定から始めよう。

まずはブラウザで自分のアカウントの設定画面を開こう。 <your-username> のところにはユーザ名が入る。

$ open https://www.kaggle.com/<your-username>/account

もちろん公式のトップページから辿っても問題ない。

開いたら、ページ内の API という項目に Create New API Token というボタンがあるのでそれを押す。 すると、自分用の設定ファイル (kaggle.json) がダウンロードされる。

あとはダウンロードした設定ファイルを所定の場所においてパーミッションを自分だけ読み書きできるようにすれば完了。

$ mkdir -p ~/.kaggle
$ mv ~/Downloads/kaggle.json ~/.kaggle
$ chmod 600 ~/.kaggle/kaggle.json

使ってみる

これで kaggle コマンドが使えるようになった。

試しにアクティブなコンペの一覧を表示させてみよう。 これには competitions list サブコマンドを使う。

$ kaggle competitions list
ref                                             deadline             category            reward  teamCount  userHasEntered  
----------------------------------------------  -------------------  ---------------  ---------  ---------  --------------  
imagenet-object-detection-challenge             2029-12-31 07:00:00  Research         Knowledge          0           False  
imagenet-object-detection-from-video-challenge  2029-12-31 07:00:00  Research         Knowledge          0           False  
imagenet-object-localization-challenge          2029-12-31 07:00:00  Research         Knowledge          8           False  
titanic                                         2020-04-07 00:00:00  Getting Started  Knowledge      11370           False  
house-prices-advanced-regression-techniques     2020-03-01 23:59:00  Getting Started  Knowledge       5219           False  
digit-recognizer                                2020-01-07 00:00:00  Getting Started  Knowledge       2347           False  
competitive-data-science-predict-future-sales   2019-01-01 23:59:00  Playground           Kudos        397           False  
trackml-particle-identification                 2018-08-13 23:59:00  Featured           $25,000         36           False  
freesound-audio-tagging                         2018-07-31 23:59:00  Research         Knowledge         92           False  
whale-categorization-playground                 2018-07-09 23:59:00  Playground           Kudos        234           False  
avito-demand-prediction                         2018-06-27 23:59:00  Featured           $25,000        416           False  
cvpr-2018-autonomous-driving                    2018-06-11 23:59:00  Research            $2,500         48           False  
inaturalist-2018                                2018-06-04 23:59:00  Research             Kudos         37           False  
imaterialist-challenge-fashion-2018             2018-05-30 23:59:00  Research            $2,500         92           False  
imaterialist-challenge-furniture-2018           2018-05-30 23:59:00  Research            $2,500        273           False  
landmark-retrieval-challenge                    2018-05-22 23:59:00  Research            $2,500        137           False  
landmark-recognition-challenge                  2018-05-22 23:59:00  Research            $2,500        327           False  
talkingdata-adtracking-fraud-detection          2018-05-07 23:59:00  Featured           $25,000       3777            True  
donorschoose-application-screening              2018-04-25 23:59:00  Playground            Swag        581           False  
data-science-bowl-2018                          2018-04-16 23:59:00  Featured          $100,000       3634           False  

他にも例えば competitions download コマンドを使うと、コンペで使われているデータセットがダウンロードできたりする。 ただし、この操作の前にはあらかじめ Web ブラウザからコンペに参加しておく必要がある。

$ kaggle competitions download -c <competition-name>

その他の使い方については -h オプションをつけてコマンドを実行することで確認できる。

$ kaggle -h                      
usage: kaggle [-h] {competitions,datasets,config} ...

optional arguments:
  -h, --help            show this help message and exit

commands:
  {competitions,datasets,config}
                        Use one of:
                        competitions {list, files, download, submit, submissions}
                        datasets {list, files, download, create, version, init}
                        config {view, set, unset}
    competitions        Commands related to Kaggle competitions
    datasets            Commands related to Kaggle datasets
    config              Configuration settings

そんなかんじ。

Python: LightGBM を使ってみる

LightGBM は Microsoft が開発した勾配ブースティング決定木 (Gradient Boosting Decision Tree) アルゴリズムを扱うためのフレームワーク。 勾配ブースティング決定木は、ブースティング (Boosting) と呼ばれる学習方法を決定木 (Decision Tree) に適用したアンサンブル学習のアルゴリズムになっている。 勾配ブースティング決定木のフレームワークとしては、他にも XGBoostCatBoost なんかがよく使われている。 調べようとしたきっかけは、データ分析コンペサイトの Kaggle で大流行しているのを見たため。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ python -V        
Python 3.6.5

もくじ

インストール

(2019-1-28 追記)

macOS でのインストール方法がバージョン 2.2.1 から変わった。 ビルド済みバイナリが配布されるようになったので、これまでのように gcc をインストールしてビルドする必要はない。 代わりに OpenMP のライブラリが実行時に必要となった。

$ brew install libomp

あとは PyPI からバイナリのパッケージをインストールするだけで良い。

$ pip install lightgbm
$ pip list --format=columns | grep -i lightgbm
lightgbm        2.2.2

過去のインストール方法 (バージョン 2.2.1 以前)

LightGBM は並列計算処理に OpenMP を採用しているので、まずはそれに必要なパッケージを入れておく。

$ brew install cmake gcc@7

あとは pip を使ってソースコードから LightGBM をビルドする。

$ export CXX=g++-7 CC=gcc-7
$ pip install --no-binary lightgbm lightgbm
$ pip list --format=columns | grep -i lightgbm
lightgbm        2.1.1  

多値分類問題 (Iris データセット)

それでは早速 LightGBM を使ってみる。

次のサンプルコードでは Iris データセットを LightGBM で分類している。 ポイントとしては LightGBM に渡すパラメータの目的 (objective) に multiclass (多値分類) を指定するところ。 そして、具体的なクラス数として num_class3 を指定する。 scikit-learn や numpy は LightGBM の依存パッケージとして自動的にインストールされるはず。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split

import numpy as np

"""LightGBM を使った多値分類のサンプルコード"""


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

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval)

    # テストデータを予測する
    y_pred = model.predict(X_test, num_iteration=model.best_iteration)
    y_pred_max = np.argmax(y_pred, axis=1)  # 最尤と判断したクラスの値にする

    # 精度 (Accuracy) を計算する
    accuracy = sum(y_test == y_pred_max) / len(y_test)
    print(accuracy)


if __name__ == '__main__':
    main()

細かい精度の検証が目的ではないので、交差検証はざっくり訓練データとテストデータに分けるだけに留めた。

上記に適当な名前をつけて実行してみよう。

$ python mc.py | tail -n 1
0.9736842105263158

今回の実行では精度 (Accuracy) として 97.36% が得られた。 ちなみに、この値は訓練データとテストデータの分けられ方に依存するので毎回異なったものになる。

scikit-learn インターフェース

LightGBM には scikit-learn に準拠したインターフェースも用意されている。 ネイティブな API と好みに合わせて使い分けられるのは嬉しい。

次のサンプルコードでは、先ほどと同じコードを scikit-learn インターフェースを使って書いてみる。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split

import numpy as np

"""LightGBM を使った多値分類のサンプルコード (scikit-learn interface)"""


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

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # 上記のパラメータでモデルを学習する
    model = lgb.LGBMClassifier()
    model.fit(X_train, y_train)

    # テストデータを予測する
    y_pred = model.predict_proba(X_test)
    y_pred_max = np.argmax(y_pred, axis=1)  # 最尤と判断したクラスの値にする

    # 精度 (Accuracy) を計算する
    accuracy = sum(y_test == y_pred_max) / len(y_test)
    print(accuracy)


if __name__ == '__main__':
    main()

適当な名前でファイルに保存して実行してみよう。

$ python mcs.py | tail -n 1
0.9473684210526315

ちゃんと動いているようだ。

交差検証 (Cross Validation)

また、LightGBM にはブーストラウンドごとの評価関数の状況を交差検証で確認できる機能もある。

次のサンプルコードでは、先ほどと同じ Iris データセットを使った多値分類問題において、どのように学習が進むのかを可視化している。

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

import lightgbm as lgb

from sklearn import datasets

import numpy as np

from matplotlib import pyplot as plt

"""LightGBM を使った多値分類のサンプルコード (CV)"""


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

    # データセットを生成する
    lgb_train = lgb.Dataset(X, y)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習〜交差検証までする
    cv_results = lgb.cv(lgbm_params, lgb_train, nfold=10)
    cv_logloss = cv_results['multi_logloss-mean']
    round_n = np.arange(len(cv_logloss))

    plt.xlabel('round')
    plt.ylabel('logloss')
    plt.plot(round_n, cv_logloss)
    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけたら実行してみよう。

$ python mcv.py

すると、次のようなグラフが得られる。 f:id:momijiame:20180501065447p:plain

上記は各ブーストラウンドごとの評価関数の値を折れ線グラフでプロットしている。 ブーストラウンド数はデフォルトで 100 になっており評価関数は LogLoss 損失関数になっている。 グラフを見ると損失が最も小さいのはラウンド数が 40 付近であり、そこを過ぎるとむしろ増えていることが分かる。

つまり、汎化性能を求めるにはブーストラウンド数を 40 あたりで止めたモデルにするのが望ましいことが分かる。 次のサンプルコードでは lightgbm.train() 関数のオプションとして num_boost_round に 40 を指定している。 これによって最適なブーストラウンド数で学習を終えている。

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

import lightgbm as lgb

from sklearn import datasets

"""LightGBM を使った多値分類のサンプルコード"""


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

    # データセットを生成する
    lgb_train = lgb.Dataset(X, y)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, num_boost_round=40)


if __name__ == '__main__':
    main()

最適なブーストラウンド数を自動で決める

といっても、最適なブーストラウンド数を毎回確認して調整するのは意外と手間だったりもする。 そこで LightGBM には自動で決めるための機能として early_stopping_rounds というものが用意されている。 これは、モデルの評価用データを渡した状態で学習させて、性能が頭打ちになったところで学習を打ち切るというもの。

次のサンプルコードでは LightGBM.train()early_stopping_rounds オプションを渡して機能を有効にしている。 数値として 10 を渡しているので 10 ラウンド進めても性能に改善が見られなかったときは停止することになる。 この数値は、あまり小さいと局所最適解にはまりやすくなってしまう恐れもあるので気をつけよう。 学習ラウンド数は最大で 1000 まで回るように num_boost_round オプションで指定している。 注意点としては、前述した通りこの機能を使う際は学習用データとは別に評価用データを valid_sets オプションで渡す必要がある。

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

import lightgbm as lgb

import numpy as np

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

"""LightGBM を使った多値分類のサンプルコード"""


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

    # データセットを学習用とテスト用に分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
    # テスト用のデータを評価用と検証用に分ける
    X_eval, X_valid, y_eval, y_valid = train_test_split(X_test, y_test, random_state=42)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_eval, y_eval, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train,
                      # モデルの評価用データを渡す
                      valid_sets=lgb_eval,
                      # 最大で 1000 ラウンドまで学習する
                      num_boost_round=1000,
                      # 10 ラウンド経過しても性能が向上しないときは学習を打ち切る
                      early_stopping_rounds=10)

    # 学習したモデルでホールドアウト検証する
    y_pred_proba = model.predict(X_valid, num_iteration=model.best_iteration)
    # 返り値は確率になっているので最尤に寄せる
    y_pred = np.argmax(y_pred_proba, axis=1)

    # 精度 (Accuracy) を計算する
    accuracy = accuracy_score(y_valid, y_pred)
    print(accuracy)


if __name__ == '__main__':
    main()

また、ホールドアウト検証するときは評価用データとはまた別に検証用データを用意する必要がある点にも注意しよう。 評価用データで得られた精度はパラメータの調整に使ってしまっているので、それで確認しても正しい検証はできない。

上記を実行すると最大 1000 ラウンドまでいくはずが性能が頭打ちになって 55 で停止していることがわかる。

$ python mes.py 
[LightGBM] [Info] Total Bins 89
[LightGBM] [Info] Number of data: 112, number of used features: 4
...(snip)...
[LightGBM] [Warning] No further splits with positive gain, best gain: -inf
[65]   valid_0's multi_logloss: 0.060976
Early stopping, best iteration is:
[55]  valid_0's multi_logloss: 0.0541825
1.0

最終的にホールドアウト検証で精度が 100% として得られている。 ただ、これはちょっと分割を重ねすぎてデータが少なくなりすぎたせいかも。

特徴量の重要度の可視化

LightGBM では各特徴量がどれくらい予測に寄与したのか数値で確認できる。

次のサンプルコードでは lightgbm.plot_importance() 関数を使って特徴量の重要度を棒グラフでプロットしている。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split

from matplotlib import pyplot as plt

"""LightGBM を使った特徴量の重要度の可視化"""


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

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train, feature_name=iris.feature_names)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval, num_boost_round=40)

    # 特徴量の重要度をプロットする
    lgb.plot_importance(model, figsize=(12, 6))
    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行しよう。

$ python mci.py

すると、次のようなグラフが得られる。 f:id:momijiame:20180501073242p:plain

上記を見ると Iris を識別するための特徴量としては Petal Width と Petal Length が重要なことが分かる。

回帰問題 (Boston データセット)

続いては Boston データセットを使った回帰問題に取り組んでみよう。 データセットには住宅価格を予測する Boston データセットを用いた。

早速だけどサンプルコードは次の通り。 多値分類問題とは、学習時に渡すパラメータしか違わない。 具体的には目的 (Objective) に regression を渡して、評価関数に rmse (Root Mean Squared Error) を指定している。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

import numpy as np

"""LightGBM を使った回帰のサンプルコード"""


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

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 回帰問題
        'objective': 'regression',
        # RMSE (平均二乗誤差平方根) の最小化を目指す
        'metric': 'rmse',
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval)

    # テストデータを予測する
    y_pred = model.predict(X_test, num_iteration=model.best_iteration)

    # RMSE を計算する
    mse = mean_squared_error(y_test, y_pred)
    rmse = np.sqrt(mse)
    print(rmse)


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行しよう。

$ python rb.py | tail -n 1
2.686275348587732

テストデータに対して RMSE が約 2.68 として得られた。 尚、訓練データとテストデータの分けられ方によって毎回出る値が異なる点については最初の問題と同じ。

scikit-learn インターフェース

続いては回帰問題を scikit-learn インターフェースで解いてみる。

サンプルコードは次の通り。 scikit-learn インターフェースにおいて回帰問題を解くときは lightgbm.LGBMRegressor クラスを使う。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

import numpy as np

"""LightGBM を使った回帰のサンプルコード (scikit-learn interface)"""


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

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # 上記のパラメータでモデルを学習する
    model = lgb.LGBMRegressor()
    model.fit(X_train, y_train)

    # テストデータを予測する
    y_pred = model.predict(X_test)

    # RMSE を計算する
    mse = mean_squared_error(y_test, y_pred)
    rmse = np.sqrt(mse)
    print(rmse)


if __name__ == '__main__':
    main()

実行結果の内容については先ほどと変わらないので省略する。

二値分類問題 (Breast Cancer データセット)

続いては Breast Cancer データセットを使った二値分類問題について。

サンプルコードは次の通り。 これまでの内容からも分かる通り、学習において変更すべき点は渡すパラメータ部分のみ。 今度は目的として binary を指定する。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn import metrics

"""LightGBM を使った二値分類のサンプルコード"""


def main():
    # Breast Cancer データセットを読み込む
    bc = datasets.load_breast_cancer()
    X, y = bc.data, bc.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 二値分類問題
        'objective': 'binary',
        # AUC の最大化を目指す
        'metric': 'auc',
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval)

    # テストデータを予測する
    y_pred = model.predict(X_test, num_iteration=model.best_iteration)

    # AUC (Area Under the Curve) を計算する
    fpr, tpr, thresholds = metrics.roc_curve(y_test, y_pred)
    auc = metrics.auc(fpr, tpr)
    print(auc)


if __name__ == '__main__':
    main()

上記に名前をつけて実行しよう。

$ python bc.py | tail -n 1
0.9920212765957447

AUC として 99.2% という結果が得られた。

scikit-learn インターフェース

同様に scikit-learn インターフェースからも使ってみる。 とはいえ、これに関しては最初に紹介した Iris データセットを使った多値分類問題と変わらない。 lightgbm.LGBMClassifier を使えば二値問題も多値問題も同じように扱うことができる。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn import metrics

"""LightGBM を使った二値分類のサンプルコード (scikit-learn interface)"""


def main():
    # Breast Cancer データセットを読み込む
    bc = datasets.load_breast_cancer()
    X, y = bc.data, bc.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # 上記のパラメータでモデルを学習する
    model = lgb.LGBMClassifier()
    model.fit(X_train, y_train)

    # テストデータを予測する
    y_pred = model.predict(X_test)

    # AUC (Area Under the Curve) を計算する
    fpr, tpr, thresholds = metrics.roc_curve(y_test, y_pred)
    auc = metrics.auc(fpr, tpr)
    print(auc)


if __name__ == '__main__':
    main()

実行結果の内容については先ほどと変わらない。

まとめ

今回は勾配ブースティングアルゴリズムを扱うフレームワーク LightGBM を試してみた。 使ってみて、勾配ブースティングによる性能の高さはもちろん、細部まで使いやすさに配慮されている印象を受けた。 同じアルゴリズムを分類にも回帰にも応用できる上、CV や特徴量の重要度まで確認できる。 計算量も Deep Learning ほど大きくないし、最近のコンペで Winning Solution を獲得した実績も多い。 これは Kaggle で流行る理由もうなずけるね。

Kaggleで勝つデータ分析の技術

Kaggleで勝つデータ分析の技術

vagrant-hosts プラグインを使ってホスト名を名前解決する

Vagrant では一つの Vagrantfile を使って複数の仮想マシンを管理することもできる。 そういったとき、それぞれの仮想マシンでお互いのホスト名が解決できると扱いやすくなる。 それを実現するには、自前でプロビジョニングを設定する以外にも vagrant-hosts というプラグインを使うと楽ができる。 今回は、これまでやっていた自前のプロビジョニングと、それを自動化してくれる vagrant-hosts プラグインについて紹介する。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ vagrant --version    
Vagrant 2.0.4
$ vboxmanage | head -n 1
Oracle VM VirtualBox Command Line Management Interface Version 5.2.10

一つの Vagrantfile で複数の仮想マシンを扱う上での問題点

複数のマシンで構成されるシステムの検証で Vagrant を使うときは、次のように一つの Vagrantfile で複数の仮想マシンを取り扱う。 この Vagrantfile を使えば vm1 と vm2 という名前の二つの仮想マシンが Vagrant で管理できる。 ポイントとしては Private Network の設定を入れるところで、こうするとお互いにその IP アドレスを使って通信できるようになる。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
  end

end
EOF

それでは上記の Vagrantfile を使って実際に仮想マシンを起動してみよう。

$ vagrant up

起動し終わったら、試しに vm1 から vm2 に向けて ping を打ってみる。 宛先は Private Network で vm2 に割り振られた IP アドレスの 192.168.33.11 を使う。

$ vagrant ssh vm1 -c "ping -c 3 192.168.33.11"
PING 192.168.33.11 (192.168.33.11) 56(84) bytes of data.
64 bytes from 192.168.33.11: icmp_seq=1 ttl=64 time=0.678 ms
64 bytes from 192.168.33.11: icmp_seq=2 ttl=64 time=0.664 ms
64 bytes from 192.168.33.11: icmp_seq=3 ttl=64 time=0.566 ms

--- 192.168.33.11 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 0.566/0.636/0.678/0.049 ms
Connection to 127.0.0.1 closed.

ちゃんと届いた。

ただし、この状態では vm1 から vm2 に対してホスト名を使った通信はできない。 これは vm1 の DNS リゾルバに vm2 に関する情報が登録されていないため。

$ vagrant ssh vm1 -c "ping -c 3 vm2"
ping: unknown host vm2
Connection to 127.0.0.1 closed.

まあ、これでも問題はないんだけどやっぱりホスト名を使って通信をしたいよねと思う。

従来の解決方法 (自前でのプロビジョニング)

続いては、これまでにやっていた自前でのプロビジョニングについて。 先ほどからの変更点は Shell を使ったプロビジョニングを登録しているところ。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
    vm1.vm.provision "shell" do |s|
      s.path = "setup.sh"
    end
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
    vm2.vm.provision "shell" do |s|
      s.path = "setup.sh"
    end
  end

end
EOF

続いて上記で参照している setup.sh を用意する。 内容としては /etc/hosts に vm1 と vm2 の情報を書き込むものになっている。

$ cat << 'EOF_' > setup.sh 
#!/bin/sh

set -x

: "Add hostname to /etc/hosts" && {
  grep vm1 /etc/hosts >/dev/null
  if [ $? -ne 0 ]; then
      cat << 'EOF' | sudo tee -a /etc/hosts
192.168.33.10 vm1
192.168.33.11 vm2
EOF
  fi
}
EOF_

/etc/hosts に IP アドレスとホスト名を登録することで DNS リゾルバが名前を解決できるようになる。

実際にプロビジョニングを実行してみよう。

$ vagrant provision

プロビジョニングが終わったら vm1 から vm2 に対して ping を打ってみる。

$ vagrant ssh vm1 -c "ping -c 3 vm2"
PING vm2 (192.168.33.11) 56(84) bytes of data.
64 bytes from vm2 (192.168.33.11): icmp_seq=1 ttl=64 time=0.772 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=2 ttl=64 time=0.618 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=3 ttl=64 time=0.587 ms

--- vm2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 0.587/0.659/0.772/0.080 ms
Connection to 127.0.0.1 closed.

ちゃんと名前解決できて疎通がとれている。

ただ、これだと環境に合わせて setup.sh を作らないといけないしめんどくさい。

vagrant-hosts プラグインを使った場合

vagrant-hosts プラグインを使うと前述した問題点を解消できる。

まずはプラグインをインストールしよう。

$ vagrant plugin install vagrant-hosts

vagrant-hosts プラグインに対応させた Vagrantfile を次に示す。 ポイントは vagrant-hosts を使ったプロビジョニングが登録されているところ。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
    vm1.vm.provision :hosts, :sync_hosts => true
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
    vm2.vm.provision :hosts, :sync_hosts => true
  end

end
EOF

先ほど作った仮想マシンには既に /etc/hosts にホスト名が登録されてしまっているので、一旦作り直そう。

$ vagrant destroy -f && vagrant up

名前解決はキャッシュが効くので、上手く動いていたと思ったらキャッシュでした、というオチが起こりやすい。 ちなみに解決に失敗した情報もキャッシュされることがある。(ネガティブキャッシュという)

仮想マシンを作り直したら、先ほどと同じように vm1 から vm2 に ping を打ってみよう。

$ vagrant ssh vm1 -c "ping -c 3 vm2"
PING vm2 (192.168.33.11) 56(84) bytes of data.
64 bytes from vm2 (192.168.33.11): icmp_seq=1 ttl=64 time=0.655 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=2 ttl=64 time=0.781 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=3 ttl=64 time=0.658 ms

--- vm2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 0.655/0.698/0.781/0.058 ms
Connection to 127.0.0.1 closed.

ちゃんと名前解決できた。

自前の仮想マシン以外の名前を登録する

先ほどの例では Vagrantfile に含まれる仮想マシンだけに限った名前解決をした。 とはいえ、それ以外にも IP アドレスに名前をつけたいといった場面はあるはず。 続いてはそれを vagrant-hosts プラグインで扱ってみることにしよう。

早速、サンプルとなる Vagrantfile を以下に示す。 この中では Google Public DNS (Primary) の IP アドレスである 8.8.8.8 を google-dns という名前で解決できるようにしている。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
    vm1.vm.provision :hosts do |provisioner|
      provisioner.autoconfigure = true
      provisioner.sync_hosts = true
      provisioner.add_host '8.8.8.8', ['google-dns']
    end
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
    vm2.vm.provision :hosts, :sync_hosts => true
    vm2.vm.provision :hosts do |provisioner|
      provisioner.autoconfigure = true
      provisioner.sync_hosts = true
      provisioner.add_host '8.8.8.8', ['google-dns']
    end
  end

end
EOF

追加したのは新しく解決するホスト名なので仮想マシンは使いまわすことにしよう。 vagrant-hosts プラグインを使ったプロビジョニングを実行する。

$ vagrant provision

まずは Google Public DNS (Primary) サーバに ping を打ってみよう。

$ vagrant ssh vm1 -c "ping -c 3 google-dns"
PING google-dns (8.8.8.8) 56(84) bytes of data.
64 bytes from google-dns (8.8.8.8): icmp_seq=1 ttl=63 time=4.83 ms
64 bytes from google-dns (8.8.8.8): icmp_seq=2 ttl=63 time=5.52 ms
64 bytes from google-dns (8.8.8.8): icmp_seq=3 ttl=63 time=8.03 ms

--- google-dns ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 4.836/6.132/8.034/1.376 ms
Connection to 127.0.0.1 closed.

ちゃんと解決できた。

仮想マシンについても再度確認しておく。

$ vagrant ssh vm1 -c "ping -c 3 vm2" 
PING vm2 (192.168.33.11) 56(84) bytes of data.
64 bytes from vm2 (192.168.33.11): icmp_seq=1 ttl=64 time=0.322 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=2 ttl=64 time=0.750 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=3 ttl=64 time=0.710 ms

--- vm2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2001ms
rtt min/avg/max/mdev = 0.322/0.594/0.750/0.193 ms
Connection to 127.0.0.1 closed.

ちゃんと解決できているね。ばっちり。

めでたしめでたし。