CUBE SUGAR CONTAINER

技術系のこと書きます。

trap コマンドを使ったシェルスクリプトのエラーハンドリング

今回は、シェルの組み込みコマンドの trap を使ったシェルスクリプトのエラーハンドリングについて。 シェルの組み込みコマンド trap は、特定のシグナルやコマンドの返り値が非ゼロとなったときに実行する処理を指定できる。

trap コマンドは、次のようにして使う。 以下の <arg> が実行する処理で、<sigspec> が反応させたいシグナルや状況となる。

$ trap <arg> <sigspec>

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ bash -version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin18)
Copyright (C) 2007 Free Software Foundation, Inc.

コマンドが非ゼロの返り値を返したときのハンドリング

以下のサンプルコードでは、コマンドの返り値が非ゼロになったときに標準エラー出力に "ERROR" という文字列を表示する。 このサンプルコードでは、最後に実行結果として非ゼロを返す false コマンドを実行しているため、必ずエラーハンドラが実行される。 trap コマンドの <arg> には関数 error_handler() を指定してあって、<sigspec> には非ゼロが返ったときに反応する ERR を指定している。

#!/usr/bin/env bash

# 実行した処理を標準出力に記録する
set -x

# エラーになったときに実行したい関数
function error_handler() {
  # 何か起きたことを標準エラー出力に書く
  echo "ERROR" >&2
  # スクリプトを終了する
  exit 1
}

# コマンドの返り値が非ゼロのときハンドラを実行するように指定する
trap error_handler ERR

# 例として非ゼロを返すコマンドを実行する
false

上記を適当な名前で保存して実行してみよう。

$ bash errhandle.sh 
+ trap error_handler ERR
+ false
++ error_handler
++ echo ERROR
ERROR
++ exit 1

ちゃんとエラーハンドラが発火して "ERROR" という文字列が表示されていることがわかる。

ちなみに、trap コマンドで指定されたハンドラは、スクリプトの中で set -E されていたとしても発火する。 set -E は、コマンドの返り値が非ゼロになった時点でスクリプトの実行を止めるという指定になる。

#!/usr/bin/env bash

# コマンドの返り値が非ゼロになった時点で止める
set -E

# 実行した処理を標準出力に記録する
set -x

# エラーになったときに実行したい関数
function error_handler() {
  # 何か起きたことを標準エラー出力に書く
  echo "ERROR" >&2
  # スクリプトを終了する
  exit 1
}

# コマンドの返り値が非ゼロのときハンドラを実行するように指定する
trap error_handler ERR

# 例として非ゼロを返すコマンドを実行する
false

実行結果は先ほどと変わらない。

$ bash errhandle.sh
+ trap error_handler ERR
+ false
++ error_handler
++ echo ERROR
ERROR
++ exit 1

プロセスが特定のシグナルを受信したときのハンドリング

同様に、プロセスが特定のシグナルを受信したときのハンドリングについても確認しておく。

以下のサンプルコードでは SIGINT シグナルを受信したときにハンドラが発火するように trap コマンドで指定している。 ハンドラでは "SIGINT" という文字列を標準エラー出力に表示する。 スクリプトは 2 秒のスリープをはさみながら、無限ループで SIGINT を待ち受ける。

#!/usr/bin/env bash

# SIGINT を受け取ったら実行するハンドラ
function sigint_handler() {
  echo "SIGINT" >&2
  exit 0
}

# SIGINT を受け取ったときにハンドラを実行する
trap sigint_handler SIGINT

# 無限ループで SIGINT を待つ
while true;
do
  echo "press Ctrl+C to stop"
  sleep 2
done

上記も名前をつけて保存したら実行してみよう。 SIGINT はキーボードの Ctrl + C キーを使って送れる。

$ bash trapsigint.sh
press Ctrl+C to stop
press Ctrl+C to stop
press Ctrl+C to stop
^CSIGINT

どうやら、ちゃんとハンドラが発火しているようだ。

いじょう。

Python: Target Encoding のやり方について

データ分析コンペでは Target Encoding という特徴量抽出の手法が用いられることがある。 Target Encoding では、一般的に説明変数に含まれるカテゴリ変数と目的変数を元にして特徴量を作り出す。 データによっては強力な反面、目的変数をエンコードに用いるためリークも生じやすく扱いが難しい。

今回は、そんな Target Encoding のやり方にもいくつか種類があることを知ったので紹介してみる。 元ネタは CatBoost の論文から。

CatBoost: unbiased boosting with categorical features (PDF)

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V            
Python 3.7.4

もくじ

Target Encoding の基本的な考え方

問題を単純にするため、このエントリでは二値分類問題に限定して考える。 二値分類問題における Target Encoding では、一般的に説明変数に含まれるカテゴリ変数ごとの、目的変数の平均値を特徴量として用いる。 カテゴリ変数は、複数の組み合わせになることもある。 また、平均値を用いる手法は、より限定的に Target Mean Encoding と呼称することもある。 Target Encoding 自体は目的変数を用いた特徴量抽出の手法全般に対する呼称と理解してるけど、一般的には Target Mean Encoding を指すことが多い気がする。

Target Encoding の手法について

前述した CatBoost の論文には、Targe Encoding の手法として以下の 4 つが紹介されている。 手法の名前に共通で含まれる TS は Target Statistics の略となっている。

  • Greedy TS
  • Leave-one-out TS
  • Holdout TS
  • Ordered TS

上記の中で、Greedy TS と Leave-one-out TS はリークが生じるため使うべきではない。 そのため、一般的には Holdout TS が用いられている。 Ordered TS は CatBoost の論文の中で提案されている手法で、リークが生じにくいとされている。

下準備

ここからは実際に Python のコードを使って Target Encoding の手法について見ていく。

その前に、下準備として必要なパッケージをインストールしておく。

$ pip install pandas

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

$ python

サンプル用のデータフレームを用意する。 色々なフルーツと、それが美味しいかを示しているとでも考えてもらえれば。

>>> import pandas as pd
>>> 
>>> data = {
...     'category': ['apple', 'apple',
...                  'banana', 'banana', 'banana',
...                  'cherry', 'cherry', 'cherry', 'cherry',
...                  'durian'],
...     'label': [0, 1,
...               0, 0, 1,
...               0, 1, 1, 1,
...               1],
... }

>>> df = pd.DataFrame(data=data)
>>> df
  category  label
0    apple      0
1    apple      1
2   banana      0
3   banana      0
4   banana      1
5   cherry      0
6   cherry      1
7   cherry      1
8   cherry      1
9   durian      1

上記を見ると、なんとなく cherry は美味しい割合が高そうで banana は低そうと感じるはず。 この、ラベルの割合が高そう低そう、というのが実は正に Target Encoding の考え方になる。

Greedy TS (使っちゃダメ)

まず最初に示すのは Greedy TS から。 最初に断っておくと、この手法はリークを起こすため使ってはいけない。

Greedy TS では、データ全体で計算したカテゴリ変数ごとの目的変数の平均値がそのまま特徴量になる。 つまり、以下のようにカテゴリごとに集計した平均値となる。

>>> ts = df.groupby('category', as_index=False).agg({'label': 'mean'})
>>> ts
  category     label
0    apple  0.500000
1   banana  0.333333
2   cherry  0.750000
3   durian  1.000000

元のデータに特徴量を追加する場合、次のようになる。 基本的に、同じカテゴリは同じ特徴量になる。

>>> pd.merge(df, ts, on='category', right_index=True)
  category  label_x   label_y
0    apple        0  0.500000
1    apple        1  0.500000
2   banana        0  0.333333
3   banana        0  0.333333
4   banana        1  0.333333
5   cherry        0  0.750000
6   cherry        1  0.750000
7   cherry        1  0.750000
8   cherry        1  0.750000
9   durian        1  1.000000

上記の Greedy TS は特徴量を付与するデータ自体も集計対象としている。 そのため、本来は使えない目的変数の情報が説明変数に漏れてしまっている。 結果として、Local CV で性能を高く見積もってしまうことになる。

Leave-one-out TS (使っちゃダメ)

続いては Leave-one-out TS という手法。 一見すると上手くいきそうだけど、このやり方もリークが生じるため使ってはいけない。

まず、Leave-one-out TS の基本的な考え方は、特徴量を付与する対象となるデータをピンポイントで除いて集計するというもの。 計算方法にはいくつかやり方があるけど、ここではあらかじめ集計した値から付与対象のデータを取り除く方法を取る。

まずはカテゴリ変数ごとの目的変数の合計とカウントを計算しておく。

>>> agg_df = df.groupby('category').agg({'label': ['sum', 'count']})

上記の集計から、付与する対象のデータだけを除外して計算した平均値を計算する関数を定義する。

>>> def loo_ts(row):
...     # 処理対象の集計を取り出す
...     group_ts = agg_df.loc[row.category]
...     # 集計した合計値から自身の目的変数を除く
...     loo_sum = group_ts.loc[('label', 'sum')] - row.label
...     # 集計したカウントから自身の存在を除く
...     loo_count = group_ts.loc[('label', 'count')] - 1
...     # 合計をカウントで割って平均を取り出す
...     return loo_sum / loo_count
... 

上記の関数を各行に適用して得られる結果が次の通り。 これが Leave-one-out TS の特徴量となる。 先ほどの結果と違って同じカテゴリの中でも特徴量の値が異なっていることがわかる。

>>> ts = df.apply(loo_ts, axis=1)
__main__:9: RuntimeWarning: invalid value encountered in long_scalars
>>> ts
0    1.000000
1    0.000000
2    0.500000
3    0.500000
4    0.000000
5    1.000000
6    0.666667
7    0.666667
8    0.666667
9         NaN
dtype: float64

このやり方のまずさは元の説明変数と結合してみるとわかる。 以下で、例えば apple のカテゴリの結果は目的変数を反転させた結果となっていることがわかる。 もちろん、これは極端なパターンだけど、これでは目的変数をそのまま説明変数に埋め込んでいるのと変わりがない。

>>> ts.name = 'loo_ts'
>>> df.join(ts)
  category  label    loo_ts
0    apple      0  1.000000
1    apple      1  0.000000
2   banana      0  0.500000
3   banana      0  0.500000
4   banana      1  0.000000
5   cherry      0  1.000000
6   cherry      1  0.666667
7   cherry      1  0.666667
8   cherry      1  0.666667
9   durian      1       NaN

ちなみに durianNaN は Leave-one-out しようにも、同じカテゴリのデータがないために生じている。 これを回避するには、分母 (と場合によっては分子にも) に定数を加える Smoothing をした方が良い。 以下では分かりやすさのために足して引いてしている。

>>> def loo_ts(row):
...     # 処理対象の集計を取り出す
...     group_ts = agg_df.loc[row.category]
...     # 集計した合計値から自身の目的変数を除く
...     loo_sum = group_ts.loc[('label', 'sum')] - row.label
...     # 集計したカウントから自身の存在を除く
...     loo_count = group_ts.loc[('label', 'count')] - 1
...     # 合計をカウントで割って平均を取り出す
...     return loo_sum / (loo_count + 1)  # smoothing
... 
>>> df.apply(loo_ts, axis=1)
0    0.500000
1    0.000000
2    0.333333
3    0.333333
4    0.000000
5    0.750000
6    0.500000
7    0.500000
8    0.500000
9    0.000000
dtype: float64

今度は NaN が登場しない。

Holdout TS

続いて紹介するのが、現在一般的な Target Encoding として用いられている Holdout TS という手法。 より厳密には Holdout TS を交差させて全データに適用したもの。 Holdout TS は、前述した 2 つの手法よりもリークが起こりにくいとされる (起こらないわけではない)。

Holdout TS では、Leave-one-out TS ではひとつだけだった除外データを増やす。 つまり、特定の割合でデータを学習用とホールドアウトに分割することになる。 その上で、学習用のデータを用いて計算した平均値をホールドアウトの特徴量として使う。 これを全データに対して k-Fold CV の要領で適用すれば良い。

計算方法は、Leave-one-out TS と同じようにあらかじめ集計した値から除外対象を引くやり方にしてみる。 まずは単純に合計とカウントを集計する。

>>> agg_df = df.groupby('category').agg({'label': ['sum', 'count']})

データを分割するための KFold オブジェクトを用意する。

>>> from sklearn.model_selection import StratifiedKFold
>>> 
>>> folds = StratifiedKFold(n_splits=3,
...                         shuffle=True,
...                         random_state=42)

生成した特徴量を入れる Series オブジェクトを用意しておく。

>>> import numpy as np
>>> ts = pd.Series(np.empty(df.shape[0]), index=df.index)

そして、次のようにしてホールドアウト分を全体から除外した上で平均値を計算する。

>>> for _, holdout_idx in folds.split(df, df.label):
...     # ホールドアウトする行を取り出す
...     holdout_df = df.iloc[holdout_idx]
...     # ホールドアウトしたデータで合計とカウントを計算する
...     holdout_agg_df = holdout_df.groupby('category').agg({'label': ['sum', 'count']})
...     # 全体の集計からホールドアウトした分を引く
...     train_agg_df = agg_df - holdout_agg_df
...     # ホールドアウトしたデータの平均値を計算していく
...     oof_ts = holdout_df.apply(lambda row: train_agg_df.loc[row.category][('label', 'sum')] \
...                                           / train_agg_df.loc[row.category][('label', 'count')], axis=1)
...     # 生成した特徴量を記録する
...     ts[oof_ts.index] = oof_ts
... 
__main__:10: RuntimeWarning: invalid value encountered in double_scalars

生成された特徴量は次の通り。 先ほどの Leave-one-out TS とは違って目的変数を単純に反転したものとはなっていない。

>>> ts.name = 'holdout_ts'
>>> df.join(ts)
  category  label  holdout_ts
0    apple      0         NaN
1    apple      1         NaN
2   banana      0         0.0
3   banana      0         0.5
4   banana      1         0.0
5   cherry      0         1.0
6   cherry      1         0.5
7   cherry      1         1.0
8   cherry      1         0.5
9   durian      1         NaN

しかし、上記では NaN となっている値が多いことに気づく。 これは、データの分割方法によっては学習データが少なくなって平均値が計算できなくなってしまうため。

Holdout TS でも、やはり Smoothing はした方が良さそう。

>>> for _, holdout_idx in folds.split(df, df.label):
...     # ホールドアウトする行を取り出す
...     holdout_df = df.iloc[holdout_idx]
...     # ホールドアウトしたデータで合計とカウントを計算する
...     holdout_agg_df = holdout_df.groupby('category').agg({'label': ['sum', 'count']})
...     # 全体の集計からホールドアウトした分を引く
...     train_agg_df = agg_df - holdout_agg_df
...     # ホールドアウトしたデータの平均値を計算していく
...     oof_ts = holdout_df.apply(lambda row: train_agg_df.loc[row.category][('label', 'sum')] \
...                                           / (train_agg_df.loc[row.category][('label', 'count')] + 1), axis=1)
...     # 生成した特徴量を記録する
...     ts[oof_ts.index] = oof_ts
... 
>>> ts.name = 'holdout_ts'
>>> df.join(ts)
  category  label  holdout_ts
0    apple      0    0.000000
1    apple      1    0.000000
2   banana      0    0.000000
3   banana      0    0.333333
4   banana      1    0.000000
5   cherry      0    0.666667
6   cherry      1    0.333333
7   cherry      1    0.666667
8   cherry      1    0.333333
9   durian      1    0.000000

Ordered TS

最後に紹介するのが CatBoost の論文で提案されている Ordered TS というやり方。 このやり方は Holdout TS よりも、さらにリークを起こしにくいらしい。

Ordered TS の基本的な考え方はオンライン学習に着想を得たもの。 ある行の特徴量として平均値を計算するのに、その時点で過去に登場したデータの集計を用いる。 ようするにストリーミング的にデータが次々と到着する場面で、到着したデータには過去の平均値を付与していくのをイメージすると良い。 データが到着する毎に、過去のデータ (History) も増えて平均値も更新されていく。

しかし、上記の考え方はデータに時系列の要素が含まれないことも多い点が問題となる。 そこで、Ordered TS では artificial "time" (人工的な時間) という概念を持ち込む。 これは、ようするにデータが登場する順番を人工的に定義したもの。 典型的には、データのインデックス番号をランダムにシャッフルして使えば良い。

説明が長くなってもあれなのでコードに移る。 まずはデータフレームのインデックスを元に artificial "time" を定義する。

>>> np.random.seed(42)
>>> artificial_time = np.random.permutation(df.index)
>>> artificial_time
array([8, 1, 5, 0, 7, 2, 9, 4, 3, 6])

続いて、グループ化するのに使うカラムとターゲットのカラム、Smoothing の有無について変数を用意しておく。

>>> group_col = 'category'
>>> target_col = 'label'
>>> smooth = False

ここではターゲットの値が NaN になっているものはテストデータ (ターゲットの値を推論したいデータ) と仮定する。 そのまま単純に平均値を計算すると NaN になってしまう。 そこで、ターゲットの積算値と件数を学習データのみで構成するためにカラムを用意する。

>>> counter_name = 'Train'
>>> assert counter_name not in df.columns, f'Oops! need to rename {counter_name} column'
>>> df[counter_name] = ~df[target_col].isnull()

次に、出現時間のカラムを使ってソートしてデータをひとつずつずらす。 これは、計算対象のデータに、自身のターゲットの値を計算に含めるとリークしてしまうため。 また、シフトするとグループ化に使ったカラムが消えてしまうため埋め直す。

>>> sorted_indices = np.argsort(artificial_time)
>>> df_shifted = df.iloc[sorted_indices].groupby(group_col).shift(1)
>>> df_shifted[group_col] = df.iloc[sorted_indices][group_col]

シフトすると最初のデータが NaN になるので値を埋めておく。 これがないと後続の cumsum が計算できない。

>>> df_shifted[target_col].fillna(value=0, inplace=True)
>>> df_shifted[counter_name].fillna(value=False, inplace=True)

あとはターゲットの積算値と、学習データの件数から尤度を計算するだけ。

>>> gdf = df_shifted.groupby(group_col)
>>> agg_df = gdf.agg({target_col: 'cumsum', counter_name: 'cumsum'})
>>> ordered_ts = agg_df[target_col] / (agg_df[counter_name] + int(smooth))

この値は artificial "time" 順に並んでいるため、元に戻すとこうなる。 その時点での過去 (History) の平均値が入っている。

>>> ordered_ts[df.index]
0    1.000000
1         NaN
2    0.000000
3         NaN
4    0.000000
5         NaN
6    0.666667
7    0.500000
8    0.000000
9         NaN
dtype: float64

元のデータを結合してみよう。

>>> df.join(ordered_ts[df.index].rename('ordered-ts'))
  category  label  Train  ordered-ts
0    apple      0   True    1.000000
1    apple      1   True         NaN
2   banana      0   True    0.000000
3   banana      0   True         NaN
4   banana      1   True    0.000000
5   cherry      0   True         NaN
6   cherry      1   True    0.666667
7   cherry      1   True    0.500000
8   cherry      1   True    0.000000
9   durian      1   True         NaN

Ordered TS の問題点について

Ordered TS はリークはしにくいものの、完全無欠の手法というわけではなさそう。 理由は次の通り。

蛇足: Category Encoders の実装について

scikit-learn の Transformer としてカテゴリ変数のエンコーダーを実装している Category Encoders という実装がある。 その実装がどうなっているか調べてみた。

https://contrib.scikit-learn.org/categorical-encoding/targetencoder.htmlcontrib.scikit-learn.org

TargetEncoder の実装は Greedy TS っぽい。

https://contrib.scikit-learn.org/categorical-encoding/leaveoneout.htmlcontrib.scikit-learn.org

LeaveOneOutEncoder は Leave-one-out TS っぽい。

https://contrib.scikit-learn.org/categorical-encoding/catboost.htmlcontrib.scikit-learn.org

CatBoostEncoder は Ordered TS になっている。

上記の中では CatBoostEncoder ならリークの危険性が低そうかな。

参考文献

CatBoost: unbiased boosting with categorical features (PDF)

絶対買った方が良い。

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

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

Python: sklearn-pandas で scikit-learn と pandas の食べ合わせを改善する

Python を使った機械学習でよく用いられるパッケージの scikit-learn は API の入出力に numpy の配列を仮定している。 そのため、データフレームの実装である pandas と一緒に使おうとすると、色々な場面で食べ合わせの悪さを感じることになる。 今回は、その問題を sklearn-pandas というパッケージを使うことで改善を試みる。

使った環境は次の通り。

$ sw_vers                         
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V        
Python 3.7.4

もくじ

下準備

まずは今回使うパッケージをインストールしておく。 seaborn についてはデータセットの読み込みにだけ用いる。

$ pip install sklearn-pandas seaborn

インストールできたら Python のインタプリタを起動しておく。

$ python

起動したら seaborn を使って Titanic データセットのデータフレームを読み込んでおく。

>>> import seaborn as sns
>>> df = sns.load_dataset('titanic')
>>> df.head()
   survived  pclass     sex   age  sibsp  parch     fare  ...  class    who adult_male  deck  embark_town alive  alone
0         0       3    male  22.0      1      0   7.2500  ...  Third    man       True   NaN  Southampton    no  False
1         1       1  female  38.0      1      0  71.2833  ...  First  woman      False     C    Cherbourg   yes  False
2         1       3  female  26.0      0      0   7.9250  ...  Third  woman      False   NaN  Southampton   yes   True
3         1       1  female  35.0      1      0  53.1000  ...  First  woman      False     C  Southampton   yes  False
4         0       3    male  35.0      0      0   8.0500  ...  Third    man       True   NaN  Southampton    no   True

[5 rows x 15 columns]

scikit-learn の API と pandas の DataFrame について

前述した通り、scikit-learn の API はその入出力に numpy の配列を仮定している。 そのため、pandas の DataFrame と一緒に使おうとすると相性があまり良くない。

例えば、LabelEncoder を使ってみることにしよう。

>>> from sklearn.preprocessing import LabelEncoder
>>> label_encoder = LabelEncoder()

先ほど読み込んだデータフレームの中から、乗客の性別を表すカラムをエンコードしてみよう。 すると、入力は pandas の Series なのに対して出力は numpy の ndarray になっていることがわかる。

>>> result = label_encoder.fit_transform(df.sex)
>>> type(result)
<class 'numpy.ndarray'>
>>> result[:5]
array([1, 0, 0, 0, 1])
>>> type(df.sex)
<class 'pandas.core.series.Series'>

このように scikit-learn と pandas を組み合わせて使うと入出力でデータの型が変わるため扱いにくいことがある。

sklearn-pandas を使って scikit-learn API をラップする

今回紹介する sklearn-pandas を使うと、両者を組み合わせたときの食べ合わせの悪さを改善できる可能性がある。

例えば sklearn-pandas では DataFrameMapper というクラスを提供している。 このクラスには scikit-learn の API をラップする機能がある。

>>> from sklearn_pandas import DataFrameMapper

DataFrameMapper は、次のように使う。 まず、処理の対象としたいデータフレームのカラム名と、適用したい scikit-learn の Transformer をタプルとして用意する。 そして、オプションの df_outTrue を指定しておく。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
... ], df_out=True)

DataFrameMapper は scikit-learn の API を備えているため、次のように fit_transform() メソッドが使える。 このメソッドにデータフレームを渡す。 すると、次のように先ほど指定したカラムがエンコードされた上で、結果がまたデータフレームとして返ってくる。

>>> mapper.fit_transform(df)
     sex
0      1
1      0
2      0
3      0
4      1
..   ...
886    1
887    0
888    0
889    1
890    1

[891 rows x 1 columns]

結果を numpy 配列として受け取る

ちなみに、先ほど指定したオプションの df_out を指定しないとデフォルトでは numpy の配列として結果が返ってくる。 sklearn-pandas は内部的には一旦結果を numpy の配列として受け取った上で、それをデータフレームに変換している。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
... ])
>>> mapper.fit_transform(df)
array([[1],
       [0],
       [0],
...(snip)...
       [0],
       [1],
       [1]])

指定していないカラムをそのままの状態で受け取る

先ほどの例では、処理の対象となるカラムだけが入ったデータフレームが結果として得られた。 処理対象として指定していないカラムについて、そのまま受け取りたい場合は default オプションに None を指定すれば良い。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
... ], default=None, df_out=True)

性別 (sex) カラムについてはエンコードされており、その他のカラムについてはそのままの状態でデータフレームが返ってくる。

>>> mapper.fit_transform(df)
     sex survived pclass  age sibsp parch     fare embarked   class    who adult_male deck  embark_town alive  alone
0      1        0      3   22     1     0     7.25        S   Third    man       True  NaN  Southampton    no  False
1      0        1      1   38     1     0  71.2833        C   First  woman      False    C    Cherbourg   yes  False
2      0        1      3   26     0     0    7.925        S   Third  woman      False  NaN  Southampton   yes   True
3      0        1      1   35     1     0     53.1        S   First  woman      False    C  Southampton   yes  False
4      1        0      3   35     0     0     8.05        S   Third    man       True  NaN  Southampton    no   True
..   ...      ...    ...  ...   ...   ...      ...      ...     ...    ...        ...  ...          ...   ...    ...
886    1        0      2   27     0     0       13        S  Second    man       True  NaN  Southampton    no   True
887    0        1      1   19     0     0       30        S   First  woman      False    B  Southampton   yes   True
888    0        0      3  NaN     1     2    23.45        S   Third  woman      False  NaN  Southampton    no  False
889    1        1      1   26     0     0       30        C   First    man       True    C    Cherbourg   yes   True
890    1        0      3   32     0     0     7.75        Q   Third    man       True  NaN   Queenstown    no   True

[891 rows x 15 columns]

複数のカラムを一度に扱う

DataFrameMapper には複数のカラムを指定することもできる。 例えば性別 (sex) と客室等級 (class) を一度にエンコードしてみよう。

>>> mapper = DataFrameMapper([
...   ('sex', LabelEncoder()),
...   ('class', LabelEncoder()),
... ], df_out=True)

次の通り、両方のカラムを同時にエンコードできた。

>>> mapper.fit_transform(df)
     sex  class
0      1      2
1      0      0
2      0      2
3      0      0
4      1      2
..   ...    ...
886    1      1
887    0      0
888    0      2
889    1      0
890    1      2

[891 rows x 2 columns]

複数のカラムを一つの Transformer で処理する

ここまでの例では単一のカラムを scikit-learn の Transformer でエンコードしてきた。 今度は複数のカラムを一度に Transformer に渡す場合を試してみる。 ただし、これはあくまで Transformer が複数のカラムを受け取れることが前提となる。

例えば OneHotEncoder に性別と客室等級を渡してみよう。

>>> import numpy as np
>>> from sklearn.preprocessing import OneHotEncoder
>>> 
>>> mapper = DataFrameMapper([
...   (['sex', 'class'], OneHotEncoder(dtype=np.uint8)),
... ], df_out=True)

結果は次のようになる。 両方のカラムを別々に One-Hot エンコードしている。

>>> mapper.fit_transform(df)
     sex_class_x0_female  sex_class_x0_male  sex_class_x1_First  sex_class_x1_Second  sex_class_x1_Third
0                      0                  1                   0                    0                   1
1                      1                  0                   1                    0                   0
2                      1                  0                   0                    0                   1
3                      1                  0                   1                    0                   0
4                      0                  1                   0                    0                   1
..                   ...                ...                 ...                  ...                 ...
886                    0                  1                   0                    1                   0
887                    1                  0                   1                    0                   0
888                    1                  0                   0                    0                   1
889                    0                  1                   1                    0                   0
890                    0                  1                   0                    0                   1

[891 rows x 5 columns]

なお、上記で得られる結果は、次のようにして得られた内容と等価になる。

>>> encoder = OneHotEncoder(dtype=np.uint8)
>>> encoder.fit_transform(df[['sex', 'class']].values).toarray()
array([[0, 1, 0, 0, 1],
       [1, 0, 1, 0, 0],
       [1, 0, 0, 0, 1],
       ...,
       [1, 0, 0, 0, 1],
       [0, 1, 1, 0, 0],
       [0, 1, 0, 0, 1]], dtype=uint8)

pandas の DataFrame を入力として受け取る Transformer を扱う場合

ここまでの例では scikit-learn に組み込まれている Transformer を使ってきた。 scikit-learn 組み込みの Transformer は入力を numpy 配列と仮定して扱う。 それに対し、独自に定義した Transformer であれば入力と pandas のデータフレームと仮定することもできる。

例として、次のような独自の Transformer を定義してみる。 このクラスでは、入力を pandas の DataFrame と仮定としている。 そして、指定されたカラムに 1 を足す操作をする。 もちろん、実用性は皆無だけど、あくまでサンプルとして。

>>> import pandas as pd
>>> from sklearn.base import BaseEstimator
>>> from sklearn.base import TransformerMixin
>>> 
>>> class PlusOneTransformer(BaseEstimator, TransformerMixin):
...     def __init__(self, col):
...         self.col = col
...     def fit(self, X, y=None):
...         assert type(X) in [pd.DataFrame]
...         return self
...     def transform(self, X):
...         assert type(X) in [pd.DataFrame]
...         X_copy = X.copy()
...         X_copy[self.col] += 1
...         return X_copy
... 

上記をデフォルトのまま DataFrameMapper で扱おうとすると、次のように例外になってしまう。 これは DataFrameMapper がデフォルトで Transformer への入力を numpy の配列に変換した上で扱おうとするため。

>>> mapper = DataFrameMapper([
...   (['survived'], PlusOneTransformer('survived')),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...(snip)...
  File "<stdin>", line 5, in fit
AssertionError: ['survived']

エラーにならないようにするには、次のように input_dfTrue にする。 このオプションは Transformer の入力を pandas のデータフレームの状態で行うことを指定する。

>>> mapper = DataFrameMapper([
...   (['survived'], PlusOneTransformer('survived')),
... ], input_df=True, df_out=True)
>>> 
>>> mapper.fit_transform(df)
     survived
0           1
1           2
2           2
3           2
4           1
..        ...
886         1
887         2
888         1
889         2
890         1

[891 rows x 1 columns]

Transformer の入力次元 (dimension) の指定について

普段そんなに意識していないかもしれないけど、scikit-learn はクラスによって入力する次元の仮定が異なっていることがある。

例えば MinMaxScaler を、ここまでの例と同じように使ってみよう。 エンコードする対象のカラムは運賃 (fare) にする。

>>> from sklearn.preprocessing import MinMaxScaler
>>> 
>>> mapper = DataFrameMapper([
...   ('fare', MinMaxScaler()),
... ], df_out=True)

上記を使ってエンコードしてみよう。 すると、次のようにエラーになる。

>>> mapper.fit_transform(df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
ValueError: fare: Expected 2D array, got 1D array instead:
...
Reshape your data either using array.reshape(-1, 1) if your data has a single feature or array.reshape(1, -1) if it contains a single sample.
...

上記は MinMaxScaler が入力に 2-d 配列を仮定しているために起こる。 それに対し、入力が 1-d 配列だったために例外となってしまった。

もうちょっと問題を単純に考えるために MinMaxScaler をそのまま使って問題を再現してみよう。 先ほどの例は、以下と等価になる。

>>> scaler = MinMaxScaler()
>>> scaler.fit_transform(df.fare.values)
...
ValueError: fare: Expected 2D array, got 1D array instead:
...
Reshape your data either using array.reshape(-1, 1) if your data has a single feature or array.reshape(1, -1) if it contains a single sample.

上記で渡したデータは、次のように ndim が 1 の 1-d 配列とわかる。

>>> df.fare.values.ndim
1
>>> df.fare.values[:10]
array([ 7.25  , 71.2833,  7.925 , 53.1   ,  8.05  ,  8.4583, 51.8625,
       21.075 , 11.1333, 30.0708])

実際には、次のような 2-d 配列を渡す必要がある。

>>> df.fare.values.reshape(-1, 1).ndim
2
>>> df.fare.values.reshape(-1, 1)[:10]
array([[ 7.25  ],
       [71.2833],
       [ 7.925 ],
       [53.1   ],
       [ 8.05  ],
       [ 8.4583],
       [51.8625],
       [21.075 ],
       [11.1333],
       [30.0708]])

上記のような 1-d から 2-d への変換を DataFrameMapper でどのように表現するか。 次のようにカラム名が入った配列として渡せば良い。

>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler()),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
         fare
0    0.014151
1    0.139136
2    0.015469
3    0.103644
4    0.015713
..        ...
886  0.025374
887  0.058556
888  0.045771
889  0.058556
890  0.015127

[891 rows x 1 columns]

配列として渡すか否かの違いは、以下のように考えると理解しやすいと思う。 pandas では、データフレームのスライス操作 ([]) に文字列をそのまま渡すと 1-d 表現の Series オブジェクトが返ってくる。

>>> df['fare']
0       7.2500
1      71.2833
2       7.9250
3      53.1000
4       8.0500
        ...   
886    13.0000
887    30.0000
888    23.4500
889    30.0000
890     7.7500
Name: fare, Length: 891, dtype: float64

それに対してスライス操作に、カラム名の入った配列を渡すと、こちらは 2-d 表現の DataFrame オブジェクトが返る。

>>> df[['fare']]
        fare
0     7.2500
1    71.2833
2     7.9250
3    53.1000
4     8.0500
..       ...
886  13.0000
887  30.0000
888  23.4500
889  30.0000
890   7.7500

[891 rows x 1 columns]

エンコードしたカラムに別名をつける

DataFrameMapper では、同じカラムに別々の処理を施すこともできる。 例えば、次の例では運賃 (fare) のカラムを MinMaxScalerStandardScaler で別々に標準化した結果を得ている。

>>> from sklearn.preprocessing import StandardScaler
>>> 
>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler()),
...   (['fare'], StandardScaler()),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
         fare      fare
0    0.014151 -0.502445
1    0.139136  0.786845
2    0.015469 -0.488854
3    0.103644  0.420730
4    0.015713 -0.486337
..        ...       ...
886  0.025374 -0.386671
887  0.058556 -0.044381
888  0.045771 -0.176263
889  0.058556 -0.044381
890  0.015127 -0.492378

[891 rows x 2 columns]

ただし、上記を見ると変換した結果として得られたカラムの名前が同じになってしまっていることがわかる。

区別するために別の名前をつけたいときは、次のようにする。 これまで渡すタプルの要素が 2 つだったのに対し、3 つになっている。 3 つ目の要素は辞書オブジェクトを仮定していて、カラムごとの設定を渡す。 この中で alias というキーを指定すると、変換後のカラムの名前が指定できる。

>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler(), {'alias': 'fare_minmax'}),
...   (['fare'], StandardScaler(), {'alias': 'fare_std'}),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
     fare_minmax  fare_std
0       0.014151 -0.502445
1       0.139136  0.786845
2       0.015469 -0.488854
3       0.103644  0.420730
4       0.015713 -0.486337
..           ...       ...
886     0.025374 -0.386671
887     0.058556 -0.044381
888     0.045771 -0.176263
889     0.058556 -0.044381
890     0.015127 -0.492378

[891 rows x 2 columns]

処理の対象となったカラムをそのまま残す

ちなみに、default=None を指定した場合、処理の対象となったカラムはそのままでは残らない。 Transformer を適用した結果で置換されてしまう。

>>> mapper = DataFrameMapper([
...   (['fare'], MinMaxScaler(), {'alias': 'fare_minmax'}),
...   (['fare'], StandardScaler(), {'alias': 'fare_std'}),
... ], default=None, df_out=True)
>>> 
>>> mapper.fit_transform(df).columns
Index(['fare_minmax', 'fare_std', 'survived', 'pclass', 'sex', 'age', 'sibsp',
       'parch', 'embarked', 'class', 'who', 'adult_male', 'deck',
       'embark_town', 'alive', 'alone'],
      dtype='object')

処理対象となったカラムも置換せずに残したいときは、次のように処理内容に None を指定したタプルを別途渡す必要がある。

>>> mapper = DataFrameMapper([
...   (['fare'], None),
...   (['fare'], MinMaxScaler(), {'alias': 'fare_minmax'}),
...   (['fare'], StandardScaler(), {'alias': 'fare_std'}),
... ], default=None, df_out=True)
>>> 
>>> mapper.fit_transform(df)
        fare  fare_minmax  fare_std survived pclass     sex  ...    who adult_male deck  embark_town alive  alone
0     7.2500     0.014151 -0.502445        0      3    male  ...    man       True  NaN  Southampton    no  False
1    71.2833     0.139136  0.786845        1      1  female  ...  woman      False    C    Cherbourg   yes  False
2     7.9250     0.015469 -0.488854        1      3  female  ...  woman      False  NaN  Southampton   yes   True
3    53.1000     0.103644  0.420730        1      1  female  ...  woman      False    C  Southampton   yes  False
4     8.0500     0.015713 -0.486337        0      3    male  ...    man       True  NaN  Southampton    no   True
..       ...          ...       ...      ...    ...     ...  ...    ...        ...  ...          ...   ...    ...
886  13.0000     0.025374 -0.386671        0      2    male  ...    man       True  NaN  Southampton    no   True
887  30.0000     0.058556 -0.044381        1      1  female  ...  woman      False    B  Southampton   yes   True
888  23.4500     0.045771 -0.176263        0      3  female  ...  woman      False  NaN  Southampton    no  False
889  30.0000     0.058556 -0.044381        1      1    male  ...    man       True    C    Cherbourg   yes   True
890   7.7500     0.015127 -0.492378        0      3    male  ...    man       True  NaN   Queenstown    no   True

[891 rows x 17 columns]

パイプライン的な処理について

DataFrameMapper には簡易的なパイプラインのような処理も扱える。 題材として欠損値の補完について考えてみる。

Titanic のデータセットには、いくつかのカラムに欠損値が含まれる。

>>> df.isnull().sum()
survived         0
pclass           0
sex              0
age            177
sibsp            0
parch            0
fare             0
embarked         2
class            0
who              0
adult_male       0
deck           688
embark_town      2
alive            0
alone            0
dtype: int64

かつ、scikit-learn 組み込みの Transformer には欠損値が含まれるデータを扱えないものがある。

例えば欠損値が含まれる乗船地 (embarked) を LabelEncoder でエンコードしてみよう。 すると、次のように例外になってしまう。

>>> mapper = DataFrameMapper([
...   ('embarked', LabelEncoder()),
... ], df_out=True)
>>> 
>>> mapper.fit_transform(df)
Traceback (most recent call last):
...(snip)...
TypeError: embarked: argument must be a string or number

上記の問題に対処するため、前段に欠損値を補完する処理を入れてみよう。 例として、欠損値の補完には SimpleImputer を使って既知の値とは異なる定数を用いる。 処理を多段にパイプラインのように扱うには、タプルの第 2 要素をリストにして適用したい処理を順番に指定する。

>>> from sklearn.impute import SimpleImputer
>>> 
>>> mapper = DataFrameMapper([
...     (['embarked'], [SimpleImputer(strategy='constant', fill_value='X'),
...                     LabelEncoder()])
... ], df_out=True)

上記を試すと、今度は例外にならずエンコードできる。 ただし、SimpleImputer の出力が 2-d 配列になっているせいで、今度は LabelEncoder の方が 1-d 配列にしろと怒っている。

>>> mapper.fit_transform(df)
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/sklearn/preprocessing/label.py:235: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
  y = column_or_1d(y, warn=True)
     embarked
0           2
1           0
2           2
3           2
4           2
..        ...
886         2
887         2
888         2
889         0
890         1

[891 rows x 1 columns]

パイプラインの間で次元を揃えるために、次のように 2-d から 1-d に変換する Transformer を適当に用意してみる。

>>> class FlattenTransformer(BaseEstimator, TransformerMixin):
...     def fit(self, X, y=None):
...         return self
...     def transform(self, X):
...         return X.flatten()
... 

欠損値の補完とエンコードの間に処理を挟み込む。

>>> mapper = DataFrameMapper([
...     (['embarked'], [SimpleImputer(strategy='constant', fill_value='X'),
...                     FlattenTransformer(),
...                     LabelEncoder()])
... ], df_out=True)

すると、今度は警告も出ない。

>>> mapper.fit_transform(df)
     embarked
0           2
1           0
2           2
3           2
4           2
..        ...
886         2
887         2
888         2
889         0
890         1

[891 rows x 1 columns]

カラムの処理が分岐・合流する場合の処理について

先ほどは特定のカラムを多段的に処理してみたけど、これが使えるのは限定的な用途にとどまると思う。 なぜなら、カラムによって前処理の内容を変えるなど、処理の内容が分岐したり合流するときに使えないため。

例として、運賃 (fare) と乗客の年齢 (age) を主成分分析 (PCA) することを考えてみる。 ここで、運賃と乗客に別々の前処理をしたいとしたら、どうなるだろうか。

>>> from sklearn.decomposition import PCA
>>> 
>>> mapper = DataFrameMapper([
...   (['fare', 'age'], PCA()),
... ], df_out=True)

実際のところ、乗客の年齢には欠損値が含まれるため、上記はそのままだと例外になる。

>>> mapper.fit_transform(df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...(snip)...
ValueError: ['fare', 'age']: Input contains NaN, infinity or a value too large for dtype('float64').

しかし、もしカラムごとに別々の欠損値の補完がしたいとしたら、DataFrameMapper のパイプライン処理では表現ができない。

最初に思いつくのは DataFrameMapper 自体を scikit-learn の Pipeline に組み込んで多段で処理をすることだった。 実際にやってみよう。

>>> impute_phase = DataFrameMapper([
...     ('fare', None),
...     (['age'], SimpleImputer(strategy='mean')),
... ], df_out=True)
>>> 
>>> encode_phase = DataFrameMapper([
...   (['fare', 'age'], PCA(), {'alias': 'pca'}),
... ], df_out=True)
>>> 
>>> from sklearn.pipeline import Pipeline
>>> steps = [
...     ('impute', impute_phase),
...     ('encode', encode_phase),
... ]
>>> pipeline = Pipeline(steps)

ただ、これをやると最終的に得られる結果が numpy 配列となってしまう。 データフレームとして扱いたいために sklearn-pandas を使っているのに、これだとちょっと微妙な感じがする。

>>> pipeline.fit_transform(df)
         pca_0      pca_1
0   -25.143783  -7.055429
1    39.279465   7.294086
2   -24.366234  -3.074093
3    21.025089   4.762258
4   -24.010039   5.919725
..         ...        ...
886 -19.267217  -2.204814
887  -2.478372 -10.638953
888  -8.751318   0.224921
889  -2.298521  -3.641264
890 -24.387019   2.928423

[891 rows x 2 columns]

そこで、やや近視眼的な解決方法だけど、次のように自分で多段の処理ができるようなクラスを定義してみた。

>>> class PhasedTransformer(BaseEstimator, TransformerMixin):
...     def __init__(self, steps):
...         self.steps = steps
...     def fit_transform(self, X, y=None):
...         transformed = X
...         for _, transformer in self.steps:
...             transformed = transformer.fit_transform(transformed, y)
...         return transformed
...     def transform(self, X):
...         transformed = X
...         for _, transformer in self.steps:
...             transformed = transformer.transform(transformed)
...         return transformed
... 

これなら最終的に得られる結果もデータフレームのままとなる。

>>> transformer = PhasedTransformer(steps)
>>> transformer.fit_transform(df)
         pca_0      pca_1
0   -25.143783  -7.055429
1    39.279465   7.294086
2   -24.366234  -3.074093
3    21.025089   4.762258
4   -24.010039   5.919725
..         ...        ...
886 -19.267217  -2.204814
887  -2.478372 -10.638953
888  -8.751318   0.224921
889  -2.298521  -3.641264
890 -24.387019   2.928423

[891 rows x 2 columns]

sklearn-pandas で処理したデータを使って Titanic を予測してみる

もうちょっと複雑なパターンも試しておいた方が良いと思って、実際に予測までする一連の処理を書いてみた。

まずは欠損値の補完とエンコードの二段階で構成されたパイプラインを用意する。

>>> impute_phase = DataFrameMapper([
...     ('pclass', None),
...     ('sex', None),
...     (['age'], SimpleImputer(strategy='mean')),
...     ('sibsp', None),
...     ('parch', None),
...     ('fare', None),
...     (['embarked'], SimpleImputer(strategy='most_frequent')),
...     ('adult_male', None),
...     (['deck'], SimpleImputer(strategy='most_frequent')),
...     ('alone', None),
... ], df_out=True)
>>> 
>>> encode_phase = DataFrameMapper([
...     (['sex'], [FlattenTransformer(), LabelEncoder()]),
...     (['embarked'], OneHotEncoder()),
...     (['adult_male'], [FlattenTransformer(), LabelEncoder()]),
...     (['deck'], OneHotEncoder()),
...     (['alone'], [FlattenTransformer(), LabelEncoder()]),
... ], default=None, df_out=True)
>>> 
>>> steps = [
...     ('impute', impute_phase),
...     ('encode', encode_phase),
... ]
>>> transformer = PhasedTransformer(steps)

上記によって、以下のようなデータが得られる。

>>> features = transformer.fit_transform(df)
>>> 
>>> features.head()
   sex  embarked_x0_C  embarked_x0_Q  embarked_x0_S  adult_male  deck_x0_A  ...  alone  pclass   age  sibsp  parch     fare
0    1            0.0            0.0            1.0           1        0.0  ...      0     3.0  22.0    1.0    0.0   7.2500
1    0            1.0            0.0            0.0           0        0.0  ...      0     1.0  38.0    1.0    0.0  71.2833
2    0            0.0            0.0            1.0           0        0.0  ...      1     3.0  26.0    0.0    0.0   7.9250
3    0            0.0            0.0            1.0           0        0.0  ...      0     1.0  35.0    1.0    0.0  53.1000
4    1            0.0            0.0            1.0           1        0.0  ...      1     3.0  35.0    0.0    0.0   8.0500

[5 rows x 18 columns]

上記を 5-Fold CV と RandomForest で予測してみる。

>>> from sklearn.model_selection import StratifiedKFold
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.model_selection import cross_validate
>>> 
>>> folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
>>> result = cross_validate(RandomForestClassifier(n_estimators=100),
...                         X=features,
...                         y=df.survived,
...                         cv=folds,
...                         )

結果は次の通り。 データを考えると妥当な内容といえるはず。

>>> result['test_score'].mean()
0.8194004369833607

まとめ

今回は sklearn-pandas を使って scikit-learn と pandas の食べ合わせの悪さを改善する方法について書いた。 ややクセがあって自分で記述が必要な部分もあるものの、場面を選べば便利なパッケージだと感じた。

また、記述方法に関して、個人的にはデータフレームを上書きしながら手続き的に処理していく一般的なやり方はあまり好きではない。 そのため、sklearn-pandas を使うと関数的に書けるところに好印象を受ける。 独自のパイプラインを組むのにパーツとして使うのも良いかもしれない。

ちなみに、ソースコードを見るとあんまりパフォーマンスチューニングについては考慮されていない印象を受ける。 加えて、numpy の配列とデータフレームの変換にかかるコストもあるので、大きいデータだと辛いかもしれない。

いじょう。

Re:VIEW で書いた本に記載するコマンドライン操作をテストする方法について考えた

今回は、Re:VIEW で記述している技術書に記載するコマンドライン操作がちゃんと動くか確認する方法について考えてみた話。 このエントリでは、コマンドライン操作を記述しているテキストファイルをシェルスクリプトに変換して実行する方法を提案する。 なお、Re:VIEW にはさほど慣れていないので、もっと良いやり方があれば教えてもらいたい。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ review version
3.2.0
$ sed --version | head -n 1
gsed (GNU sed) 4.7
$ bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin18)
Copyright (C) 2007 Free Software Foundation, Inc.

もくじ

技術書にコマンドライン操作を記載する際の課題について

技術書にコマンドライン操作を記載する場合、その内容が実機でちゃんと動作するか確認する必要がある。 それも、動作確認は一回やって終わりではなく、少なくとも書籍が完成するまでは継続的に実施することになる。 なぜなら、記載する内容は随時修正される可能性があるから。 ある箇所を修正したら、別の箇所で整合性が取れなくなっていないかレグレッションテストしなければならない。 また、出版してからも、使っているシステムやツールの更新などによって動作しなくなっていないか確認することが望ましい。

しかし、上記の作業を手作業で実施することには困難が伴う。 内容を修正する度に、本から一行ずつコマンドをコピペしていく作業は考えただけで気が遠くなる。 そのため、なるべく一連の作業は自動化することが望ましい。

また、コマンドライン操作特有の要件として、以下のようなものがある。

  • 実行して得られる出力も記載したい
    • 入力だけを羅列すれば良いわけではない
    • 出力はコマンドによって毎回内容が異なる可能性がある (ping のレイテンシなど)
  • 自動化する工程を環境構築用にも流用したい
    • たくさんのコマンドライン操作を読者に強要することを避けるため

今回は、上記を満たせるように Re:VIEW において自動化する方法を考えてみた。 具体的には、一連のコマンドライン操作をプリプロセッサが解釈する命令を頼りにシェルスクリプトに変換する。 シェルスクリプトの形になっていれば、実機で実行することも比較的容易といえるはず。

サンプルとなる Re:VIEW のプロジェクトを用意する

ここからは、実際に Re:VIEW を使って課題を解決するまでの流れを解説する。 なお、Re:VIEW の環境構築などについては以下のエントリを参照のこと。

blog.amedama.jp

まずはサンプルとなるプロジェクトを用意しよう。 ここでは example-book という名前で Re:VIEW のプロジェクトを作成する。

$ review-init example-book

すると、次のように必要なファイル一式が用意される。

$ cd example-book
$ ls
Gemfile     config.yml  images      sty
Rakefile    doc     layouts     style.css
catalog.yml example-book.re lib

上記の中で example-book.re ファイルがデフォルトで組み込まれるマークアップテキストファイルとなる。

Re:VIEW にコマンドライン操作を記載する方法について

Re:VIEW でコマンドライン操作を示すには、ブロック命令 cmd を用いる。 以下では、実際に cmd 命令を使っていくつかのコマンドライン操作を示すように、マークアップテキストファイルを編集している。

$ cat << 'EOF-' > example-book.re
= Hello, World

//cmd{
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
//}

EOF-

それでは、実際に上記をテキストフォーマットにビルドしてみよう。

$ rake text

ビルドすると、次のような結果が得られた。 コマンドラインの操作が本の中に組み込まれていることがわかる。

$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プロプロセッサ命令を使って外部のファイルを取り込む

先ほどのやり方では、コマンドライン操作を直接マークアップテキストファイルに記述した。 このやり方は単純で分かりやすい反面、記述した内容を変更する際のメンテナンス性が悪い。 そこで、Re:VIEW ではプリプロセッサを使ってファイルのファイルに記述された内容を取り込むことができる。

例えば、次のように commands.txt という名前でコマンドライン操作を記述したテキストファイルを用意する。

$ cat << 'EOF-' > commands.txt
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
EOF-

そして、マークアップテキストファイルでは上記のファイルをプリプロセッサ命令の #@mapfile で読み込むように指定する。

$ cat << 'EOF' > example-book.re
= Hello, World

//cmd{
#@mapfile(commands.txt)
#@end
//}

EOF

準備ができたら rakepreproc タスクを実行する。

$ rake preproc

すると、プリプロセッサ命令の中にファイルの内容が取り込まれる。

$ cat example-book.re 
= Hello, World

//cmd{
#@mapfile(commands.txt)
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@end
//}

もちろん、ビルドした後の成果物にはプリプロセッサ命令は残らない。

$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プロプロセッサ命令を使って外部のファイルの一部を取り込む

先ほどのやり方も、直接マークアップテキストファイルにコマンドライン操作をベタ書きするよりはだいぶメンテナンス性がよくなる。 ただ、本の中で取り扱うコマンドライン操作が多いと、大量のファイルを扱うことになってこれまたメンテナンス性が悪い。 そこで、Re:VIEW にはファイルの一部だけを取り込むプリプロセッサ命令も用意されている。

例えば、それぞれのコマンドライン操作をプリプロセッサ命令を使って別々に取り出すことを考えてみよう。 この場合、次のように外部のファイルにもプリプロセッサ命令を記述する。 具体的には #@range_begin(<id>) ~ #@range_end(<id>) を使って取り出す範囲を指定する。

$ cat << 'EOF-' > commands.txt

#@range_begin(whole)

#@range_begin(echo)
$ echo 'Hello, World'
Hello, World
#@range_end(echo)

#@range_begin(cat)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(cat)

#@range_begin(ping)
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

コマンドライン操作を記述したファイルが用意できたら、それをマークアップテキストファイルに取り込む。 取り込むときは #@maprange(<filepath>, <id>) ~ #@end を使って取り出す内容を指定する。

$ cat << 'EOF' > example-book.re
= Hello, World

//cmd{
#@maprange(commands.txt,echo)
#@end
//}

//cmd{
#@maprange(commands.txt,cat)
#@end
//}

//cmd{
#@maprange(commands.txt,ping)
#@end
//}

//cmd{
#@maprange(commands.txt,whole)
#@end
//}

EOF

プリプロセスを実行する。

$ rake preproc

すると、次のようにファイルの一部分が取り込まれる。

$ cat example-book.re           
= Hello, World

//cmd{
#@maprange(commands.txt,echo)
$ echo 'Hello, World'
Hello, World
#@end
//}

//cmd{
#@maprange(commands.txt,cat)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@end
//}

//cmd{
#@maprange(commands.txt,ping)
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@end
//}

//cmd{
#@maprange(commands.txt,whole)

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms

#@end
//}

ビルドした結果は次の通り。

$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ cat << 'EOF' > greet.txt
Hello, World
EOF
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

◆→開始:コマンド←◆

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プリプロセッサ命令を頼りに外部のファイルをシェルスクリプトに変換する

さて、これでようやく前提知識の共有が終わったので本題に入る。 基本的なコンセプトとしては、コマンドライン操作を記述したファイルの内容をシェルスクリプトに変換したい。 シェルスクリプトに変換できれば、あとはそれを実機上で実行するだけで済む。

しかし、コマンドライン操作を記述したファイルはそのままだと使えない。 例えば先ほどのファイルの内容を見るとコマンドラインの操作を実行して得られた標準 (エラー) 出力の内容も含まれている。 また、ヒアドキュメントや複数行のコマンドライン操作も存在して話をややこしくしている。

そこで、実際には使わないプリプロセッサ命令を使って「ここがコマンドラインで実行したい箇所ですよ」と印をつけることにした。 論よりソースということで、以下のコマンドで生成されるファイルを見てほしい。 この中には exec という ID を使って、実際に実行したいコマンドライン操作の前後にプリプロセッサ命令が記述されている。

$ cat << 'EOF-' > commands.txt

#@range_begin(whole)

#@range_begin(echo)
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
Hello, World
#@range_end(echo)

#@range_begin(cat)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_end(cat)

#@range_begin(ping)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

上記のように、実際には使われないプリプロセッサ命令が含まれていたとしても、無視されるだけで成果物のビルドには影響を与えない。

$ rake preproc
$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ cat << 'EOF' > greet.txt
Hello, World
EOF
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

◆→開始:コマンド←◆

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

しかし、命令を頼りにコマンドライン操作で実行したい箇所を次のように取り出すことはできる。

$ sed -n '/^#@range_begin(exec)$/,/^#@range_end(exec)$/p' commands.txt
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)

なお、今回は GNU sed を使ってシェルスクリプトに変換してみた。 とはいえ、別に何を使っても良いと思う。

$ brew install gnu-sed
$ alias sed=gsed

こんな感じでシェルスクリプトに変換できる。 set -e しておくと、コマンドライン操作で非ゼロ (エラー) が返ったときに止まるので異常に気づきやすくなる。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  '
#!/usr/bin/env bash
set -Ceuxo pipefail
echo 'Hello, World'
cat << 'EOF' > greet.txt
Hello, World
EOF
ping \
    -c 3 \
    8.8.8.8

変換したスクリプトを、ファイルとして書き出してみよう。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  ' > commands.sh

できたシェルスクリプトを実行してみる。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ cat
+ ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=9.517 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=15.521 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=9.736 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 9.517/11.591/15.521/2.780 ms

ちゃんと実行できた。

今回のサンプルだと、スクリプトに set -C しているので、リダイレクトでファイルが上書きできないようにしてある。 なので、もう一度実行してみたときコマンドライン操作の中でファイルを上書きしてしまっているということにも気づける。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ cat
commands.sh: line 4: greet.txt: cannot overwrite existing file

もし、一時的にチェックを無効にして回避するならコマンドライン操作を記述したファイルに set +C を追加する。

$ cat << 'EOF-' > commands.txt           

#@range_begin(whole)

#@range_begin(echo)
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
Hello, World
#@range_end(echo)

#@range_begin(exec)
$ set +C  # temporarily disable overwrite check
#@range_end(exec)

#@range_begin(cat)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_end(cat)

#@range_begin(exec)
$ set -C  # enable overwrite check
#@range_end(exec)

#@range_begin(ping)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

シェルスクリプトを作り直す。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  ' > commands.sh

実行すると、今度は失敗しない。 スクリプトに冪等性が得られた。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ set +C
+ cat
+ set -C
+ ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=9.741 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=9.680 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=10.080 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 9.680/9.834/10.080/0.176 ms

このやり方ならコマンドラインの操作とテストする内容が二重管理にもならないところが良いかなーと思っている。 ただ、コマンドライン操作を記述するファイルがちょっとごちゃごちゃするね。 あと、解決できていない課題もある。 例えば、ユーザの入力を伴うインタラクティブな操作があるときどうするかとか。 あと、複数のターミナルにまたがった操作をどうするか、とか。

いじょう。

Docker のマルチステージビルドで自前でビルドした Wheel を含むイメージを作る

今回は Docker のマルチステージビルドを使って Wheel が提供されていない Python パッケージを含む Docker イメージを作ってみる。 これだけだと、なんのこっちゃという感じなので、以下で前提から説明しておく。

まず、今の Python のパッケージングにはソースコード配布物 (sdist) と Wheel という二つのフォーマットが主に使われている。 ソースコード配布物は、文字通りソースコードをそのままパッケージングしたもの。 ソースコード配布物では、パッケージの中に Python/C API などで書かれた拡張モジュールがあっても、ソースコードの状態で含まれる。 それに対して Wheel は、拡張モジュールが含まれる場合にはビルドされたバイナリの状態で提供される。 そして、現行の pip はソースコード配布物をインストールするとき、一旦 Wheel にビルドした上でインストールするように振る舞う。 このソースコード配布物を Wheel にビルドするタイミングでは、ランタイムとは別にビルドで必要なツール類の一式が必要になる。

ここで、ソースコード配布物として提供されている Python パッケージを Docker イメージに含めることを考えてみよう。 もし、対象のパッケージが拡張モジュールを含む場合、ビルドに必要なツール類の一式が Docker イメージに必要になってしまう。 Docker イメージは、なるべく不要なものを入れない方が一般的に望ましいとされている。

そこで、上記の問題を解決するのに Docker のマルチステージビルドという機能が使える。 マルチステージビルドでは、複数のイメージを連携させて一つのイメージが作れる。 例えばパッケージのビルドをするステージと、それを組み込むステージを分ければ、後者にはビルドに必要なツールが必要なくなるというわけ。

使った環境は次の通り。

$ sw_vers     
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ docker version                              
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:26:49 2019
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:32:21 2019
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          v1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

Wheel のビルドについて

実際にマルチステージビルドを試す前に、Wheel に関するオペレーションについて解説しておく。 まず、Python のパッケージ管理ツールの pip は、デフォルトで PyPI というパッケージサイトからパッケージを探してくる。

pypi.org

インストールするパッケージに、使っているプラットフォーム向けの Wheel があれば、それがインストールされる。 もし、ソースコード配布物しか提供されていないときは、それを元に Wheel をビルドした上でインストールされる。 ただし、Wheel が提供されている場合であってもオプションを指定することで、あえてソースコード配布物から自前でビルドすることもできる。

実際に試してみることにしよう。 Docker を使って Ubuntu 18.04 LTS のイメージを起動する。

$ docker run --rm -it ubuntu:18.04 /bin/bash

パッケージシステムの APT を使って Python 3 の pip をインストールする。

# apt update
# apt -y install python3-pip

試しに LightGBM というパッケージをソースコード配布物からビルドしてみよう。 pip は wheel サブコマンドを使うことでインストールに必要な Wheel パッケージが取得できる。 その際、--no-binary オプションを指定すると、ビルド済みの Wheel ではなく自分でソースコード配布物からビルドすることになる。 ちなみに、このオプションは pip install サブコマンドでも有効なので覚えておくと良いかも。

# pip3 wheel --no-binary lightgbm lightgbm

なお、上記のコマンド実行は失敗する。 なぜならビルドに必要なパッケージ類が入っていないため。

# pip3 wheel --no-binary lightgbm lightgbm
...
  Exception: Please install CMake and all required dependencies first
  The full version of error log was saved into /root/LightGBM_compilation.log
  
  ----------------------------------------
  Failed building wheel for lightgbm
  Running setup.py clean for lightgbm
Failed to build lightgbm
ERROR: Failed to build one or more wheels

Ubuntu で LightGBM をビルドするのに必要な cmake と gcc をインストールしよう。

# apt -y install cmake gcc

もう一度、先ほどのコマンドを実行すると、今度はエラーにならず上手くいく。

# pip3 wheel --no-binary lightgbm lightgbm

これで、カレントワーキングディレクトリにインストールに必要な Wheel 一式ができあがる。 この中の LightGBM は自分でソースコード配布物からビルドしたもの。

# ls *.whl
joblib-0.14.0-py2.py3-none-any.whl
lightgbm-2.3.0-py3-none-any.whl
numpy-1.17.3-cp36-cp36m-manylinux1_x86_64.whl
scikit_learn-0.21.3-cp36-cp36m-manylinux1_x86_64.whl
scipy-1.3.1-cp36-cp36m-manylinux1_x86_64.whl

ビルドした Wheel をインストールしてみよう。

# pip3 install lightgbm-2.3.0-py3-none-any.whl

これでインストールしたパッケージが使えるようになる。

# python3 -c "import lightgbm"

シングルステージで Docker イメージをビルドする

続いては、マルチステージビルドを試す前にシングルステージの場合を見ておこう。 これは、ビルドに必要なツールも一緒に Docker イメージに含まれてしまうパターン。

以下のように Dockerfile を用意する。 ビルドに必要なパッケージをインストールした上で、LightGBM をインストールする構成になっている。

FROM ubuntu:18.04

# Use fastest mirror
RUN sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

# Install prerequisite apt packages to build
RUN apt update \
 && apt -yq dist-upgrade \
 && apt -yq install \
      python3-pip \
      cmake \
      gcc \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Install Python package from source code distribution
RUN pip3 install --no-binary lightgbm lightgbm

上記を元に Docker イメージをビルドする。

$ docker build -t example/singlestage .

ビルドしたイメージを元にコンテナを起動してみよう。

$ docker run --rm -it example/singlestage /bin/bash

このイメージでは、ちゃんとインストールした LightGBM がインポートできる。

# python3 -c "import lightgbm as lgb"

反面、ビルドに使った cmake や gcc もイメージに含まれてしまっている。

# cmake --version
cmake version 3.10.2

CMake suite maintained and supported by Kitware (kitware.com/cmake).

マルチステージで Docker イメージをビルドする

それでは、今回の本題となるマルチステージビルドを試してみよう。

マルチステージビルドでは FROM 命令が複数登場する。 それぞれの FROM 命令がステージとなる Docker イメージを表しており、AS を使って名前をつけられる。 名前をつけた Docker イメージからは COPY 命令を使ってファイルをコピーできる。

以下の Dockerfile は build-stageusing-stage という二つのステージに分かれている。 まず、build-stage では LightGBM の Wheel をビルドしている。 そして、using-stage でビルドした Wheel をインストールしている。

FROM ubuntu:18.04 AS build-stage

# Use fastest mirror
RUN sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

# Install prerequisite apt packages to build
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq \
      python3-pip \
      cmake \
      gcc \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Build wheel
RUN pip3 wheel -w /tmp/wheelhouse --no-binary lightgbm lightgbm

FROM ubuntu:18.04 AS using-stage

# Use fastest mirror
RUN sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

# Install prerequisite apt packages to build
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq \
      python3-pip \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Copy binaries from building stage
COPY --from=build-stage /tmp/wheelhouse /tmp/wheelhouse

# Install binary package
RUN pip3 install /tmp/wheelhouse/lightgbm-*.whl

それでは、上記の Dockerfile をビルドしてみよう。

$ docker build -t example/multistage .

ビルドしたイメージからコンテナを起動する。

$ docker run --rm -it example/multistage /bin/bash

このイメージでは、ちゃんと LightGBM がインポートして使える。

# python3 -c "import lightgbm as lgb"

そして、イメージにはビルド用のツールも含まれていない。

# cmake
bash: cmake: command not found

いじょう。

macOS (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の macOS でシステムコールをアセンブラから呼んでみる。 ただし、前回のエントリで FreeBSD についても同じようにシステムコールをアセンブラから呼んだ。 macOS は BSD を先祖に持つ XNU カーネルで動いている。 そのため、大筋は FreeBSD の場合と違いはない。 ようするに System V x86/x86-64 ABI の規約にもとづいて呼び出してやればいいだけだ。

blog.amedama.jp

とはいえ、FreeBSD と全く違いがないわけではない。 なので、それについて見ていくことにしよう。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ uname -sr
Darwin 18.7.0
$ nasm -v
NASM version 2.14.02 compiled on Dec 27 2018
$ ld -v
@(#)PROGRAM:ld  PROJECT:ld64-450.3
BUILD 18:16:53 Apr  5 2019
configured to support archs: armv6 armv7 armv7s arm64 arm64e arm64_32 i386 x86_64 x86_64h armv6m armv7k armv7m armv7em
LTO support using: LLVM version 10.0.1, (clang-1001.0.46.4) (static support for 22, runtime is 22)
TAPI support using: Apple TAPI version 10.0.1 (tapi-1001.0.4.1)

もくじ

下準備

最初に、アセンブラの実装として NASM (Netwide Assembler) を Homebrew でインストールしておく。

$ brew install nasm

GAS (GNU Assembler) を使っても問題ないけど、INTEL 記法と AT&T 記法でちょっと手直しが必要になる。

x86 のアセンブラで exit(2) を呼び出すだけのプログラム

先のエントリと同じように、まずは終了するだけのプログラムを書いてみよう。 macOS にも、他の Unix 系 OS と同じようにプログラムを終了するためのシステムコールとして exit(2) がある。 macOS のカーネルである XNU のソースコードは、リリースからやや遅れはあるもののオープンソースとして公開されている。 そのため、次のようにシステムコールの識別子を確認できる。

github.com

上記から、exit(1) に対応する識別子が 1 であることがわかる。 ただし、実は上記の値をそのまま使ってもエラーになってしまう。

実際には、上記の識別子に 0x2000000 を加えなきゃいけない。 その理由は以下のファイルを見るとわかる。

github.com

どうやら macOS のカーネルである XNU が Mach と BSD から生まれた影響で、何に由来するシステムコールなのか指定が必要らしい。 その指定が、BSD であれば 2 を 24 ビット左シフトした値、ということみたいだ。

前置きが長くなったので、そろそろサンプルコードを見ることにしよう。 以下が exit(2) を呼び出すだけのサンプルコードになる。 FreeBSD の x86 版と異なるのは、前述したマスク値がひとつ。 もう一つがエントリポイントのシンボルが _start ではなく _main になっている。 まあ、これはデフォルト値の話なので、リンカで別途上書きしてしまっても良いはず。

global _main

section .text

_syscall:
  int 0x80
  ret

_main:
  ; exit system call (macOS / x86)
  push dword 0
  mov eax, 0x2000001  ; 2 << 24 + 1
  call _syscall
  add esp, byte 4  ; restore ESP
  ret  ; don't reach here

x86 (32bit) 向けにビルドする

コードが用意できたので、x86 (32bit) 向けのバイナリとして Mach-O 32bit フォーマットにアセンブルする。

$ nasm -f macho32 nop.asm
$ file nop.o                       
nop.o: Mach-O object i386

動作に必要な最低バージョンの指定を入れつつ実行可能オブジェクトファイルにリンクする。

$ ld -macosx_version_min 10.14 -lsystem -o nop nop.o
$ file nop  
nop: Mach-O executable i386

できたファイルを実行してみよう。

$ ./nop
$ echo $?
0

ちゃんと返り値もゼロが設定されている。 うまく動いているようだ。

ちなみに、今回使っている macOS のバージョンが Mojave (10.14) だからこそ上記は動く。 なぜなら、次のバージョン Catalina (10.15) からは 32bit アプリケーションのサポートがなくなってしまった。 なので、おそらくこのマシンも OS をアップデートしたら上記のバイナリが動かなくなるはずだ。

x86-64 (64bit) 向けにビルドする

じゃあ 64bit 向けにビルドすれば良いのか、というとそうもいかない。 なぜなら、先のエントリで取り扱った通り System V ABI は x86 と x86-64 で引数の渡し方が異なっている。 そのため、先ほどのサンプルコードは x86-64 (64bit) 向けのアプリケーションとしては動作しない。

やろうと思えば、一応は Mach-O 64bit フォーマットとしてアセンブリはできる。

$ nasm -f macho64 nop.asm
$ file nop.o
nop.o: Mach-O 64-bit object x86_64
$ ld -macosx_version_min 10.14 -lsystem -o nop nop.o
$ file nop  
nop: Mach-O 64-bit executable x86_64

しかし、実行しようとするとエラーになってしまう。

$ ./nop 
zsh: illegal hardware instruction  ./nop

x86-64 のアセンブラで exit(2) を呼び出すだけのプログラム

64 bit 向けのアプリケーションを作るためには、以下のように System V v86-64 ABI に準拠した呼び出しが必要になる。

global _main

section .text

_main:
  ; exit system call (macOS / x86-64)
  mov rax, 0x2000001
  mov rdi, 0
  syscall

上記を Mach-O 64 bit フォーマットとしてアセンブリする。

$ nasm -f macho64 nop.asm
$ file nop.o
nop.o: Mach-O 64-bit object x86_64

そして、リンクする。

$ ld -macosx_version_min 10.14 -lsystem -o nop nop.o
$ file nop  
nop: Mach-O 64-bit executable x86_64

できたファイルを実行してみよう。 今度はエラーにならない。

$ ./nop
$ echo $?
0

返り値についても、ちゃんとゼロが設定されている。

x86-64 のアセンブラでハローワールド

一応、ハローワールドについても以下にサンプルコードを示す。

global _main

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_main:
  ; write system call (macOS / x86-64)
  mov rax, 0x2000004
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (macOS / x86-64)
  mov rax, 0x2000001
  mov rdi, 0
  syscall

ビルドする。

$ nasm -f macho64 greet.asm
$ ld -macosx_version_min 10.14 -lsystem -o greet greet.o

実行する。

$ ./greet
Hello, World!
$ echo $?
0

うまく動いているようだ。

いじょう。

FreeBSD (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の FreeBSD でアセンブラからシステムコールを呼んでみる。 システムコールは、OS (ディストリビューション) のコアとなるカーネルがユーザ空間のプログラムに向けて提供しているインターフェースのこと。 ファイルの入出力など、ユーザープログラムは大抵のことはシステムコールを通じてカーネルにお願いしてやってもらうことになる。 ただ、普段は色々な API がラップして実体が見えにくいので、今回はアセンブラから直接呼んでみることにした。

使った環境は次の通り。

$ uname -sr
FreeBSD 12.0-RELEASE
$ nasm -v
NASM version 2.14.02 compiled on Aug 22 2019
$ ld -v
LLD 6.0.1 (FreeBSD 335540-1200005) (compatible with GNU linkers)

TL; DR

分かったことは次の通り。

  • 商用 UNIX の流れをくむ Unix 系 OS のシステムコールは ABI (Application Binary Interface) が x86 と x86-64 で異なる
    • x86 (32bit) ではスタックを使って引数を渡す
    • x86-64 (64bit) ではレジスタを使って引数を渡す

ちなみに Linux では、どちらもレジスタ経由でシステムコールの引数を渡していた点が異なる。

blog.amedama.jp

もくじ

下準備

あらかじめ、下準備として NASM (Netwide Assembler) をインストールしておく。

$ sudo pkg install nasm

NASM / x86 で exit(2) を呼び出すだけのプログラム

まずは、ハローワールドですらなく「終了するだけ」のプログラムを書いてみる。 FreeBSD には、プログラムを終了するためのシステムコールとして exit(2) がある。

最初は x86 (32bit) アーキテクチャのアセンブラを用いる。 FreeBSD で x86 のシステムコールに関するドキュメントとしては以下があった。

www.freebsd.org

以下が、上記のドキュメントにもとづいて x86 で exit(2) を呼び出すだけのアセンブラのソースコードとなる。 FreeBSD の x86 アセンブラでは、割り込み番号 0x80int 命令を発行するだけの関数経由でシステムコールを呼ぶことを想定しているらしい。 また、Linux の x86 のシステムコール呼び出しとは異なり、引数はシステムコールの識別子を除いてスタック経由で受け渡しされる。

global _start

section .text

_syscall:  ; FreeBSD expects to call system call via int 0x80 only function
  int 0x80
  ret

_start:
  ; exit system call (FreeBSD / x86)
  push dword 0  ; return code
  mov eax, 1  ; system call id (exit)
  call _syscall  ; call system call
  add esp, byte 4  ; restore ESP (DWORD x1)
  ret  ; don't reach here

x86 (32bit) 向けにビルドする

それでは、上記を実際にビルドして実行してみよう。

まずは NASM を使って 32bit の ELF (Executable and Linkable Format) としてアセンブルする。

$ nasm -f elf32 nop.asm
$ file nop.o
nop.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

上記をリンカを使って実行可能オブジェクトファイルにする。

$ ld -m elf_i386_fbsd -o nop nop.o
$ file nop
nop: ELF 32-bit LSB executable, Intel 80386, version 1 (FreeBSD), statically linked, not stripped

できたファイルを実行してみよう。

$ ./nop

何も表示されないけど、エラーにならずに実行できていることがうまくいっていることを示している。

プログラムの返り値についてもスタック経由で指定したゼロになっている。

$ echo $?
0

x86-64 (64bit) 向けにビルドする

では、次に先ほどのサンプルコードを x86-64 (64bit) 向けのアプリケーションとしてビルドしてみよう。 x86-64 は機械語のレベルでは x86 と後方互換性があるけど、どういった結果になるだろうか。

まずは 64bit の ELF としてアセンブルする。

$ nasm -f elf64 nop.asm

実行可能オブジェクトファイルとしてリンクする。

$ ld -m elf_amd64_fbsd -o nop nop.o

実行してみよう。

$ ./nop

特にエラーにならず実行できているので、うまくいっていそうだけど…?

返り値を見ると非ゼロの値が入っている。

$ echo $?
144

どうやら返り値の受け渡しの部分がうまくいっていないようだ。

NASM / x86-64 で exit(2) を呼び出すだけのプログラム

実は FreeBSD というか商用 UNIX の流れをくんだ Unix 系 OS は x86 と x86-64 でシステムコールの ABI が異なっている。 具体的には、x86 ではスタック経由で引数を渡していたのが x86-64 ではレジスタ経由で渡すことになった。 この問題については以下が分かりやすい。

stackoverflow.com

また、詳しくは以下のリポジトリでメンテナンスされている PDF についても参照のこと。

github.com

上記にもとづいて 64 ビットのアプリケーションとしてビルドできるサンプルコードを以下に示す。 これはシステムコールの識別子は異なるものの、前述した GNU/Linux の記事に出てきたサンプルコードとほとんど同じもの。 x86-64 においては Linux と商用 UNIX の子孫たちでシステムコールの呼び出し方は同じになっている。

global _start

section .text

_start:
  ; exit system call (FreeBSD / x86-64)
  mov rax, 1  ; system call id
  mov rdi, 0  ; return value
  syscall  ; call system call

実際に上記を 64 bit の実行可能オブジェクトファイルとしてビルドしてみよう。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ ld -m elf_amd64_fbsd -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD), statically linked, not stripped

できたファイルを実行する。

$ ./nop
$ echo $?
0

今度は返り値についてもゼロが設定されている。

ちなみに Linux では同じシステムコールでも x86 t x86-64 で識別子が異なったけど、FreeBSD の場合は変わらないらしい。

github.com

write(2) を追加したハローワールドでも比較してみる

呼び出すシステムコールが exit(2) だけだと味気ないので write(2) も追加してハローワールドも見ておこう。

x86 版 ABI のハローワールド

まずは x86 版 ABI を使ったハローワールドが以下の通り。

global _start

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_syscall:
  int 0x80
  ret

_start:
  ; write system call (FreeBSD / x86)
  push dword msg_len
  push dword msg
  push dword 1
  mov eax, 4
  call _syscall
  add esp, byte 12  ; restore ESP (DWORD x3)

  ; exit system call (FreeBSD / x86)
  push dword 0
  mov eax, 1
  call _syscall
  add esp, byte 4  ; restore ESP (DWORD x1)
  ret  ; don't reach here

x86 (32bit) 向けの実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf32 greet.asm
$ ld -m elf_i386_fbsd -o greet greet.o

できたファイルを実行する。

$ ./greet
Hello, World!
$ echo $?
0

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

x86-64 版 ABI のハローワールド

続いて以下が x86-64 版 ABI のハローワールドになる。

global _start

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_start:
  ; write system call (FreeBSD / x86-64)
  mov rax, 4
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (FreeBSD / x86-64)
  mov rax, 1
  mov rdi, 0
  syscall

x86-64 (64bit) 向けの実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf64 greet.asm
$ ld -m elf_amd64_fbsd -o greet greet.o

できたファイルを実行する。

$ ./greet
Hello, World!
$ echo $?
0

うまくいっている。

FreeBSD の Linux バイナリ互換機能

ちなみに FreeBSD には Linux の ABI を使ったアプリケーションを実行するためのエミュレーション機能があるらしい。 実際に試してみよう。

エミュレーション機能はカーネルモジュールとして実行されているため linux.ko を読み込む。

$ sudo kldload linux.ko
$ kldstat | grep linux
 7    1 0xffffffff8284c000    39960 linux.ko
 8    1 0xffffffff82886000     2e28 linux_common.ko

Linux で動作する x86 版 ABI のサンプルコードを以下のように用意する。 レジスタで引数を渡しているので System V の x86 版 ABI とは異なる。

global _start

section .text

_start:
  mov eax, 1
  mov ebx, 0
  int 0x80

上記を 32 版の実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf32 nop.asm
$ ld -m elf_i386 -o nop nop.o

このままでは実行できない。

$ ./nop 
ELF binary type "0" not known.
-bash: ./nop: cannot execute binary file: Exec format error

そこで、brandelf コマンドを使って Linux 互換のバイナリとしてマークする。

$ brandelf -t Linux nop

実行してみる。

$ ./nop
$ echo $?
0

今度はちゃんと動作した。