CUBE SUGAR CONTAINER

技術系のこと書きます。

macOS で Git LFS (Large File Storage) を使ってみる

元々 Git というバージョン管理システムは、その性質として大きなファイルやバイナリファイルを扱うのが苦手だった。 そんな欠点を補うために GitHub が開発したのが今回扱う Git LFS (Large File Storage) という拡張機能 (仕様) になる。

git-lfs.github.com

これは、大きなファイルやバイナリファイルの実体を Git リポジトリではなく HTTPS サーバで保持することで実現している。 Git リポジトリでは、ファイルを本体の代わりにメタ情報を含むテキストファイルの形で管理することになる。 これらの仕様 (プロトコル) は公開されているため GitHub 以外の Git ホスティング事業者でも Git LFS を実装できる。

github.com

例えば現在では Bitbucket Cloud でも次のように Git LFS に対応している。

Git Large File Storage in Bitbucket - Atlassian Documentation

今回は、そんな Git LFS を GitHub 上のリポジトリを使って試してみることにする。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G1036

利用する上での注意点

使い方の説明に入る前に注意点を一つ。 Git LFS では大きなファイルをアップロードできることから、保存先のストレージと転送量も大量に消費することになる。 そのため、一般的には利用する上で無条件にタダというわけにはいかない。

例えば GitHub であればストレージと転送量という二つのクオータ (割当量) がある。 具体的には、無料で保存できるストレージの容量は 1GB まで。 そして、転送量についても無料で利用できるのは月間で 1GB まで、という制限がある。 もし、それを超えて使いたいときは別途ストレージおよび帯域幅のクオータを購入する必要がある。 詳しくは次のドキュメントに記載されている。

About storage and bandwidth usage - User Documentation

ちなみに転送量のクオータについてはアップロードのトラフィックはカウントされない。 クオータに換算されるのは、リポジトリをクローンするときなどに生じるダウンロードのトラフィックだけ。 つまり、アップロードして消してアップロードして消して、というような操作であればカウントされない。

インストールとセットアップ

前置きが長くなったけど、ここからやっと Git LFS を使っていく。 まずは Git LFS クライアントをインストールする。 というのも、前述した通り Git LFS はあくまで Git の拡張機能という位置づけになっている。 そのため、利用するにはまず拡張機能を含むソフトウェアを入れる必要があるというわけ。

macOS であれば Homebrew を使って git-lfs をインストールする。

$ brew install git-lfs

インストールすると git コマンドで lfs サブコマンドが使えるようになる。

$ git lfs version
git-lfs/2.3.4 (GitHub; darwin amd64; go 1.9.1)

次に git lfs install コマンドを実行して Git LFS クライアントの初期設定をする。

$ git lfs install
Git LFS initialized.

具体的には、このコマンドを実行すると Git クライアントの設定ファイルである ~/.gitconfig に Git LFS 用の設定が入る。

$ grep -A 4 lfs ~/.gitconfig
[filter "lfs"]
    smudge = git-lfs smudge -- %f
    process = git-lfs filter-process
    required = true
    clean = git-lfs clean -- %f

サンプル用のリポジトリを準備する

ここからは実際に GitHub にサンプル用のリポジトリを用意して試していく。 ここからの手順を自分で試すときは、アカウントやリポジトリ名を自分で作ったものに適宜読み替えてほしい。

まずはサンプル用に作ったリポジトリをクローンしてくる。

$ git clone git@github.com:momijiame/lfs-example.git
$ cd lfs-example

現状では、まっさらな Git リポジトリになっている。

$ git status
On branch master

Initial commit

nothing to commit (create/copy files and use "git add" to track)

ひとまず、これで準備が整った。

そのまま大きなファイルを扱おうとするとどうなるか?

ここに、ひとまず大きなファイルとして 101MB のブランクファイルを作って追加してみることにしよう。

$ dd if=/dev/zero of=blankfile bs=1m count=101
101+0 records in
101+0 records out
105906176 bytes transferred in 0.060923 secs (1738358305 bytes/sec)
$ du -m blankfile 
101    blankfile

まずは、何も考えず作ったファイルをそのまま Git リポジトリに追加してみる。

$ git add blankfile
$ git commit -m "Add blankfile"
[master 7cfa851] Add blankfile
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 blankfile

コミットした内容を GitHub のリモートリポジトリにプッシュしようとすると、次のようなエラーになる。 GitHub では 50MB を超えるファイルがあると警告になるし 100MB を超えるとそもそもプッシュできない。

$ git push origin master
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 100.78 KiB | 146.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
remote: error: Trace: 7d4213addf9cf92b5299d989f6f34b1d
remote: error: See http://git.io/iEPt8g for more information.
remote: error: File blankfile is 101.00 MB; this exceeds GitHub's file size limit of 100.00 MB
To github.com:momijiame/lfs-example.git
 ! [remote rejected] master -> master (pre-receive hook declined)
error: failed to push some refs to 'git@github.com:momijiame/lfs-example.git'

エラーメッセージにも大きなファイルを扱うときは Git LFS を使うべし、とある。

このままだと先に進めないので、一旦コミット内容を取り消しておこう。

$ git reset --soft HEAD^
$ git reset blankfile

大きなファイルを Git LFS で扱う

次は先ほどのファイルを Git LFS で扱ってみる。 それには、まず git lfs track コマンドを使って Git LFS で管理するファイルに追加する。

$ git lfs track blankfile
Tracking "blankfile"

これで先ほど作った 101MB のファイルが Git LFS の管理対象になった。

$ git lfs track
Listing tracked patterns
    blankfile (.gitattributes)

この際 .gitattributes が作成される。

$ cat .gitattributes 
blankfile filter=lfs diff=lfs merge=lfs -text

作成されたファイルと一緒にステージングエリアに追加する。

$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Untracked files:
  (use "git add <file>..." to include in what will be committed)

    .gitattributes
    blankfile

nothing added to commit but untracked files present (use "git add" to track)
$ git add -A
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    new file:   .gitattributes
    new file:   blankfile

あとは一般的な Git の使い方と同じようにコミットする。

$ git commit -m "Add blankfile"
[master 35e7ee7] Add blankfile
 2 files changed, 4 insertions(+)
 create mode 100644 .gitattributes
 create mode 100644 blankfile

コミット内容をリモートにプッシュすると Git LFS で管理されているファイルは別口でアップロードされる。

$ git push origin master
Git LFS: (1 of 1 files) 101.00 MB / 101.00 MB                                  
Counting objects: 4, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (4/4), 449 bytes | 449.00 KiB/s, done.
Total 4 (delta 0), reused 0 (delta 0)
To github.com:momijiame/lfs-example.git
   ca8478b..35e7ee7  master -> master

Git LFS でファイルを管理するのに必要な作業は、これだけ。

次はリモートリポジトリをクローンするときの挙動の説明に入りたいんだけど、その前に大きなファイルは消しておく。 なぜかというと最初に説明した通り GitHub にはストレージ容量と転送量にクオータがあるから。 大きいファイルを使ってクローン操作を実行するとダウンロード方向でカウントされる転送量を大きく消費してしまう。

$ git rm blankfile
rm 'blankfile'
$ git commit -m "Delete blankfile"
[master 0401cca] Delete blankfile
 1 file changed, 3 deletions(-)
 delete mode 100644 blankfile
$ git push origin master
Counting objects: 2, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (1/1), done.
Writing objects: 100% (2/2), 249 bytes | 249.00 KiB/s, done.
Total 2 (delta 0), reused 0 (delta 0)
To github.com:momijiame/lfs-example.git
   35e7ee7..0401cca  master -> master

代わりに小さなテキストファイルを Git LFS で管理される形でリモートリポジトリにプッシュしておこう。 別に小さなファイルだからといって Git LFS で管理してはいけないということはない。

$ echo "Hello, World" > greeting.txt
$ git lfs track greeting.txt 
Tracking "greeting.txt"
$ git add -A
$ git commit -m "Add greeting.txt"
[master 283f21e] Add greeting.txt
 2 files changed, 4 insertions(+)
 create mode 100644 greeting.txt
$ git push origin master
Git LFS: (1 of 1 files) 13 B / 13 B                                            
Counting objects: 4, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 466 bytes | 466.00 KiB/s, done.
Total 4 (delta 0), reused 0 (delta 0)
To github.com:momijiame/lfs-example.git
   0401cca..283f21e  master -> master

別のディレクトリに移動して、先ほどファイルをアップロードしたリポジトリをクローンしてみよう。 すると自動的に Git LFS で管理されているファイルについてもダウンロードされてくる。

$ git clone git@github.com:momijiame/lfs-example.git
Cloning into 'lfs-example'...
remote: Counting objects: 15, done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 15 (delta 0), reused 13 (delta 0), pack-reused 0
Receiving objects: 100% (15/15), 101.34 KiB | 97.00 KiB/s, done.
Downloading greeting.txt (13 B)
$ cat lfs-example/greeting.txt 
Hello, World

もしクローンの時点ではファイルをダウンロードしたくないという場合は GIT_LFS_SKIP_SMUDGE という環境変数を有効にする。

$ GIT_LFS_SKIP_SMUDGE=1 git clone git@github.com:momijiame/lfs-example.git
Cloning into 'lfs-example'...
remote: Counting objects: 15, done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 15 (delta 0), reused 13 (delta 0), pack-reused 0
Receiving objects: 100% (15/15), 101.34 KiB | 180.00 KiB/s, done.

すると、クローンした時点では Git LFS で管理されているファイルがメタ情報を含むテキストの状態になる。

$ cat lfs-example/greeting.txt 
version https://git-lfs.github.com/spec/v1
oid sha256:8663bab6d124806b9727f89bb4ab9db4cbcc3862f6bbf22024dfa7212aa4ab7d
size 13

こうすれば HTTPS サーバからファイルのダウンロードが発生しないので転送量の節約になる。

改めてファイルを HTTPS サーバからダウンロードしたいときは git lfs pull コマンドを使う。

$ cd lfs-example
$ git lfs pull
Git LFS: (1 of 1 files) 13 B / 13 B

もしファイルを個別にダウンロードしたいときは -I オプションでファイル名や名前のパターンを指定する。

$ git lfs pull -I greeting.txt
Git LFS: (1 of 1 files) 13 B / 13 B

まとめ

  • Git は大きなファイルやバイナリファイルを扱うのが苦手
  • その欠点を補うために開発されたのが Git LFS という拡張機能
  • Git LFS を使うとファイルを Git リポジトリではなく HTTPS サーバに保存する
  • Git LFS は GitHub や Bitbucket といった Git ホスティング事業者で使える

標準入力の内容を直接 gzip で圧縮・解凍する

今回は、標準入力の内容を直接 gzip を使って圧縮したり解凍する方法について。 どうしてそんなことをするかというと、ディスクの消費を抑えるため。 一般的には一旦内容をファイルに書き出してから、それを圧縮・解凍すると思う。 ただ、圧縮しない状態で書き出すとローカルディスクの容量を圧迫するような状況が考えられる。 例えば分散ファイルシステムからデータを手元に持ってくるときなんかにありがち。

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

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G1036
$ gzip --version
Apple gzip 264.50.1

ちなみに GNU gzip でも同じように動作する。

$ brew install gzip
$ gzip --version
gzip 1.8
Copyright (C) 2016 Free Software Foundation, Inc.
Copyright (C) 1993 Jean-loup Gailly.
This is free software.  You may redistribute copies of it under the terms of
the GNU General Public License <http://www.gnu.org/licenses/gpl.html>.
There is NO WARRANTY, to the extent permitted by law.

Written by Jean-loup Gailly.

標準入力の内容を圧縮する

今回は例として echo コマンドを使うことにする。 やることは単純で、コマンドの標準出力をパイプで gzip コマンドの標準入力につないでやる。 あとは gzip コマンドの -c オプションで圧縮した結果を標準出力に書き出すだけ。 ここではリダイレクトを使って greeting.txt.gz というファイルに保存した。

$ echo "Hello, World" | gzip -c > greeting.txt.gz

これなら圧縮した結果をファイルに書き出す以外にも色々と加工できる。

できあがったファイルは、ちゃんと gzip 形式になっている。

$ file greeting.txt.gz 
greeting.txt.gz: gzip compressed data, last modified: Sat Nov 18 08:12:22 2017, from Unix

標準入力の内容を解凍する

先ほどは圧縮したので、次は解凍する方法について。 やり方としては、ファイルの内容を cat コマンド経由で gzip コマンドの標準入力につないでやる。 あとは gzip コマンドの -d オプションで解凍処理を指定する。

$ cat greeting.txt.gz | gzip -d
Hello, World

ちゃんと圧縮前の内容が得られた。

もちろん今回の趣旨からは外れるけど、ファイルを指定して解凍しても構わない。

$ gzip -d greeting.txt.gz 
$ cat greeting.txt 
Hello, World

めでたしめでたし。

Python: pandas でカラムの型を変換する

pandas はデータを読み込むとき、よきに計らってカラムに型を付与してくれる。 ただ、その内容が意図しない場合もある。 そんなとき、どうやってカラムの型を直すか、ということについて。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ python --version
Python 3.6.3

下準備

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

$ pip install pandas
$ pip list --format=columns | grep pandas
pandas          0.21.0 

サンプルデータ

続いてサンプルとなるデータフレームを次のように用意する。

>>> columns = ['name', 'innings']
>>> data = [
...   ['Sugano', 187.1],
...   ['Mikolas', 188],
...   ['Messenger', 143],
...   ['Nomura', 155.1],
...   ['Imanaga', 148],
... ]
>>> import pandas as pd
>>> df = pd.DataFrame(data, columns=columns)
>>> df
        name  innings
0     Sugano    187.1
1    Mikolas    188.0
2  Messenger    143.0
3     Nomura    155.1
4    Imanaga    148.0

カラムの型は DataFrame#dtype プロパティで確認できる。

>>> df['innings'].dtype
dtype('float64')

例えば、この innings カラムを整数型 (int64) に変換してみよう。

カラムの型を変換する

特定のカラム (Series) の型を変換するには Series#astype() メソッドを使う。 このメソッドを使うと同じ値で指定された型の Series オブジェクトができる。

>>> import numpy as np
>>> df['innings'].astype(np.int64)
0    187
1    188
2    143
3    155
4    148
Name: innings, dtype: int64

型を変換したカラムを非破壊的に追加する

元々のデータフレームに、型を変換したカラムを追加したものを作りたいときは DataFrame#assign() メソッドを使うと良い。

>>> df.assign(innings_int = df['innings'].astype(np.int64))
        name  innings  innings_int
0     Sugano    187.1          187
1    Mikolas    188.0          188
2  Messenger    143.0          143
3     Nomura    155.1          155
4    Imanaga    148.0          148

このメソッドは非破壊的な処理で、カラムを追加した新しいデータフレームを作って返す。

そのため、元々のデータフレームを確認してもカラムは追加されていない。

>>> df
        name  innings
0     Sugano    187.1
1    Mikolas    188.0
2  Messenger    143.0
3     Nomura    155.1
4    Imanaga    148.0

つまり、新しくできたデータフレームを別の変数とかに格納して使えば良い。

>>> df2 = df.assign(innings_int = df['innings'].astype(np.int64))

型を変換したカラムを破壊的に追加する

破壊的に追加したいときは、元々のデータフレームにブラケット演算子で名前を指定して代入すれば良い。

>>> df['innings_int'] = df['innings'].astype(np.int64)
>>> df
        name  innings  innings_int
0     Sugano    187.1          187
1    Mikolas    188.0          188
2  Messenger    143.0          143
3     Nomura    155.1          155
4    Imanaga    148.0          148

もっと柔軟に変換する

これまでの例のように浮動小数点数型を整数型にするくらいの処理ならいいんだけど、もっと柔軟に型を変換したいという場合もある。 例えば、小数点の端数を切り上げた上で整数型に変換したい、という場合を考えてみよう。

こういった場合には DataFrame#apply() メソッドを使って、その処理を一行ずつに適用していくことが考えられる。 ただし、このやり方はカラム単位ではなく行単位の処理になるので遅い。 これに限らず axis=1 となるような処理は、なるべく避けた方が良いと思う。

>>> import math
>>> df.apply(lambda x: math.ceil(x['innings']), axis=1)
0    188
1    188
2    143
3    156
4    148
dtype: int64

作った Series をデータフレームに追加する方法については先ほどと同じ。

>>> df.assign(innings_ceil = df.apply(lambda x: math.ceil(x['innings']), axis=1))
        name  innings  innings_ceil
0     Sugano    187.1           188
1    Mikolas    188.0           188
2  Messenger    143.0           143
3     Nomura    155.1           156
4    Imanaga    148.0           148

値として NaN が入っている場合

ちなみに、値として NaN が入っているときは、ちょっと話が変わってくる。

>>> columns = ['name', 'innings']
>>> data = [
...   ['Sugano', 187.1],
...   ['Mikolas', 188],
...   ['Messenger', 143],
...   ['Nomura', 155.1],
...   ['Imanaga', 148],
...   ['Sawamura', np.nan],
... ]
>>> import pandas as pd
>>> df = pd.DataFrame(data, columns=columns)

NaN が入っている場合に、最初の例のように Series#astype() メソッドを使うと ValueError 例外になってしまう。

>>> df['innings'].astype(np.int64)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...(省略)...
ValueError: Cannot convert non-finite values (NA or inf) to integer

そういったときは Series#fillna() メソッドなどを使って NaN をそれ以外の値に置き換えてやる必要がある。 もちろん Series#dropna() などで、そもそも集計の対象外としてしまうことも考えられる。

>>> df['innings'].fillna(0.0)
0    187.1
1    188.0
2    143.0
3    155.1
4    148.0
5      0.0
Name: innings, dtype: float64

Series#fillna() メソッドなどを使って NaN さえ無くすことができれば、ちゃんと変換できる。

>>> df['innings'].fillna(0.0).astype(int)
0    187
1    188
2    143
3    155
4    148
5      0
Name: innings, dtype: int64

変換した結果を元のデータフレームに追加する方法については、これまでと変わらない。

>>> df.assign(innings_int = df['innings'].fillna(0.0).astype(np.int64))
        name  innings  innings_int
0     Sugano    187.1          187
1    Mikolas    188.0          188
2  Messenger    143.0          143
3     Nomura    155.1          155
4    Imanaga    148.0          148
5   Sawamura      NaN            0

めでたしめでたし。

Docker イメージをファイルでやり取りする

基本的に Docker イメージは Docker リポジトリからダウンロードしたりアップロードするものだと思う。 とはいえ、インターネットに疎通がなかったりすることもあるし、自前でリポジトリを運用するのもつらい。 そんなときは Docker イメージを、そのままファイルでやり取りしたいなと思うときがある。 結論から先に言ってしまうと、そんなときは docker save コマンドと docker load コマンドを使うと良い。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ docker version
Client:
 Version:      17.09.0-ce
 API version:  1.32
 Go version:   go1.9
 Git commit:   afdb6d4
 Built:        unknown-buildtime
 OS/Arch:      darwin/amd64

Server:
 Version:      17.09.0-ce
 API version:  1.32 (minimum version 1.12)
 Go version:   go1.8.3
 Git commit:   afdb6d4
 Built:        Tue Sep 26 22:45:38 2017
 OS/Arch:      linux/amd64
 Experimental: false

ひとまず、サンプルとして busybox イメージを Docker の公式リポジトリから取ってくる。

$ docker pull busybox
Using default tag: latest
latest: Pulling from library/busybox
03b1be98f3f9: Pull complete
Digest: sha256:3e8fa85ddfef1af9ca85a5cfb714148956984e02f00bec3f7f49d3925a91e0e7
Status: Downloaded newer image for busybox:latest

これで、ローカルに busybox イメージが登録された。

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
busybox             latest              54511612f1c4        6 weeks ago         1.13MB

イメージをファイルに書き出すには docker save コマンドを使う。 保存先のファイル名は -o オプションで指定する。

$ docker save busybox -o busybox.tar

書き出されたファイルは tar 形式になっている。

$ file busybox.tar
busybox.tar: POSIX tar archive

あとは、このファイルを好きな方法で保存したり運用環境に持っていけば良い。

イメージを読み込む検証をするために、一旦さきほど取ってきた busybox イメージを削除する。

$ docker rmi busybox
$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE

ファイルとして書き出された Docker イメージは docker load コマンドで読み込める。

$ docker load -i busybox.tar
6a749002dd6a: Loading layer  1.338MB/1.338MB
Loaded image: busybox:latest

次のように busybox イメージが再び登録された。

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
busybox             latest              54511612f1c4        6 weeks ago         1.13MB

この通り、ちゃんと使うことができる。

$ docker run -t busybox /bin/echo "Hello World"
Hello World

めでたしめでたし。

Python: map/filter 処理を並列化する

今回は Python でリストなんかへの map/filter 処理をマルチプロセスで並列化する方法について。 この説明だけだと、なんのこっちゃという感じだと思うので詳しくは後述する。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ python --version
Python 3.6.3

下準備

Python の REPL を使って説明していくので、まずは起動する。

$ python

まずは、サンプルのデータとして 0 から 9 までの数字が入ったリストの data を用意しておく。

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> data = list(range(10))

map/filter

まず map 処理というのは、具体的には次のようなもの。 リストなどの全ての要素に対して同じ処理を適用して返ってきた内容で新しいリストなどのオブジェクトを作ること。 次のサンプルコードでは map 処理を使って各要素を 2 倍している。

>>> [i * 2 for i in data]
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
>>> list(map(lambda x: x * 2, data))
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

ちなみに、可読性の面から言って内包表記を使う方が望ましい。

そして filter 処理というのは次のようにリストなどの中から特定の条件に合致するものだけを取り出して新しいオブジェクトを作ること。 次のサンプルコードでは filter 処理を使って偶数だけを取り出している。

>>> [i for i in data if i % 2 == 0]
[0, 2, 4, 6, 8]
>>> list(filter(lambda x: x % 2 == 0, data))
[0, 2, 4, 6, 8]

上記の処理は、そのままだとシングルプロセス・シングルスレッドで実行される。 もちろんリストに含まれるデータ数が少なかったり、それぞれに適用する処理が軽ければ、それでも問題はない。 ただ、データ数が多くて適用する処理も多い場合には、これが全体のボトルネックになることがある。

concurrent map

まずは並列化した map 処理から示す。

マルチプロセスの処理を扱いたいので、まずは multiprocessing モジュールをインポートする。

>>> from multiprocessing import Pool
>>> import multiprocessing

続いては map 処理で適用する処理を関数として定義しておく。

>>> def double(x):
...     return x * 2
...

あとは multiprocessing.Pool#map() メソッドを使って関数をリストの各要素に適用してやる。

>>> with Pool(multiprocessing.cpu_count()) as pool:
...     pool.map(double, data)
...
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

これで map 処理がマルチプロセスで並列化できる。

ちなみに multiprocessing.Pool がコンテキストマネージャとして使えるのは Python 3 以降らしい。 なので Python 2 であれば、次のようにして後始末をする。 具体的には、使い終わったら multiprocessing.Pool#close() メソッドを呼ぶ。

>>> pool = Pool(multiprocessing.cpu_count())
>>> try:
...     pool.map(double, data)
... finally:
...     pool.close()
...
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

これを忘れるとプロセスプールが使うメモリが開放されない。

concurrent filter

続いては filter 処理の並列化について。

まずは filter 処理に使う関数を定義しておく。

>>> def is_even(x):
...     return x % 2 == 0
...

並列化する場合は次のようにする。 さっきの map 処理よりも、ちょっと難しいかもしれない。

>>> with Pool(multiprocessing.cpu_count()) as pool:
...     [i for i, keep in zip(data, pool.map(is_even, data)) if keep]
...
[0, 2, 4, 6, 8]

上記の処理を分解しながら見ていく。 まず、最初のポイントは pool.map(is_even, data) のところで、先ほど用意した is_even() をリストに適用している。 その部分だけを取り出すと、こんな感じ。

>>> with Pool(multiprocessing.cpu_count()) as pool:
...     pool.map(is_even, data)
... 
[True, False, True, False, True, False, True, False, True, False]

続いては、それで得られたリストと元のリストを zip() 関数でタプルを要素にもったイテレータにしている。

>>> with Pool(multiprocessing.cpu_count()) as pool:
...     list(zip(data, pool.map(is_even, data)))
... 
[(0, True), (1, False), (2, True), (3, False), (4, True), (5, False), (6, True), (7, False), (8, True), (9, False)]

あとは、タプルのうち二番目の要素が残すべきかを示すフラグになっているのでリスト内包表記の if を使って除外すれば良い。

>>> with Pool(multiprocessing.cpu_count()) as pool:
...     [i for i, keep in zip(data, pool.map(is_even, data)) if keep]
...
[0, 2, 4, 6, 8]

そうした意味では、除外する処理そのものはシングルスレッドになっている。 マルチプロセスで並列化されているのは、除外すべきか否かを判定する処理ということになる。

まとめ

今回はリストなどに対する map/filter 処理をマルチプロセスで並列化する方法について見てきた。

ちなみに、上記のコードを使って自分のタスクが本当に速くなるかはあらかじめ確認した方が良い。 なぜかというと、マルチプロセスでの並列化にはプロセス生成やプロセス間通信のオーバーヘッドがあるため。 特に後者に支払うコストがでかい。 上記のようにデータ数が少ない、適用する処理が軽いという場合には確実にオーバーヘッドの方が大きくなる。

適用できる場面としては、まず前提としてデータ数が多くて適用する処理が重いこと。 さらに、実際に適用した上でパフォーマンスを測ってみて速くなることが確認できる場合のみ、ということになる。 ちなみに自分で試した限りだとログのパース処理なんかにはかなり効いた。

Python: pandas で数の少ない項目を「その他」にまとめる

pandas で集計した内容をグラフにするとき、数の少ない項目がたくさんあるとごちゃっとしてしまう。 今回は、そんな数の少ない項目を「その他」としてまとめる方法について。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ python --version
Python 3.6.3

下準備

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

$ pip install pandas
$ pip list --format=columns | grep pandas
pandas          0.21.0 

サンプルとなるデータ

サンプルとしては各国の GDP を使うことにした。

以下は 2016 年の GDP 上位 10 ヶ国を示している。

>>> columns = ['country', 'gdp']
>>> data = [
...     ['ITA', 1849970464191],
...     ['CAN', 1529760492201],
...     ['JPN', 4939383909875],
...     ['IND', 2263522518124],
...     ['USA', 18569100000000],
...     ['BRA', 1796186586414],
...     ['CHN', 11199145157649],
...     ['GBR', 2618885692029],
...     ['FRA', 2465453975282],
...     ['DEU', 3466756880460],
... ]
>>> import pandas as pd
>>> df = pd.DataFrame(data, columns=columns)

このデータを使って、上位 5 ヶ国はそのままに下位 5 ヶ国を「その他」としてまとめてしまうことにする。

まずは、そのまま使う上位 5 ヶ国を取り出す。 これには gdp のカラムを使って値の大きいものを DataFrame#nlargest() メソッドで取り出す。

>>> df.nlargest(5, columns='gdp')
  country             gdp
4     USA  18569100000000
6     CHN  11199145157649
2     JPN   4939383909875
9     DEU   3466756880460
7     GBR   2618885692029
>>> df_largest = df.nlargest(5, columns='gdp')

ひとまず、これを df_largest という変数に格納しておく。

ちなみに別解としては DataFrame#sort_values() を使ってソートした上で先頭を取り出しても良い。

>>> df.sort_values('gdp', ascending=False)
  country             gdp
4     USA  18569100000000
6     CHN  11199145157649
2     JPN   4939383909875
9     DEU   3466756880460
7     GBR   2618885692029
8     FRA   2465453975282
3     IND   2263522518124
0     ITA   1849970464191
5     BRA   1796186586414
1     CAN   1529760492201
>>> df.sort_values('gdp', ascending=False)[:5]
  country             gdp
4     USA  18569100000000
6     CHN  11199145157649
2     JPN   4939383909875
9     DEU   3466756880460
7     GBR   2618885692029

続いては「その他」にまとめたい下位 5 ヶ国を取り出す。 これには DataFrame#drop() メソッドに、さきほど作った上位 5 ヶ国のデータフレームの index プロパティを渡せば良い。

>>> df.drop(df_largest.index)
  country            gdp
0     ITA  1849970464191
1     CAN  1529760492201
3     IND  2263522518124
5     BRA  1796186586414
8     FRA  2465453975282
>>> df_others = df.drop(df_largest.index)

この内容は df_others として格納しておく。

続いては「その他」にまとめたい内容を DataFrame#sum() メソッドで集約してしまう。

>>> df_others.sum()['gdp']
9904894036212

上記の内容を使って新しい DataFrame オブジェクトを作る。 カラムの内容は揃えた上で国名には Others を指定してやる。

>>> pd.DataFrame([['Others', df_others.sum()['gdp']]], columns=df.columns)
  country            gdp
0  Others  9904894036212
>>> df_others_sum = pd.DataFrame([['Others', df_others.sum()['gdp']]], columns=df.columns)

この内容を df_others_sum という変数に入れておく。

あとは、最初に作った上位 5 ヶ国の df_largest と下位 5 ヶ国を集約した df_others_sumconcat() 関数で連結するだけ。

>>> pd.concat([df_largest, df_others_sum], ignore_index=True)
  country             gdp
0     USA  18569100000000
1     CHN  11199145157649
2     JPN   4939383909875
3     DEU   3466756880460
4     GBR   2618885692029
5  Others   9904894036212

これで下位の内容を「その他」にまとめることができた。

めでたしめでたし。

Python: 無名数化によるデータの前処理

データエンジニアリングの分野では、分類精度などを高めるためにデータの前処理が重要となってくる。 今回は、そんな前処理の中でも無名数化と呼ばれる手法について見ていく。

無名数化というのは、具体的にはデータに含まれる次元の単位をなくす処理のことを指している。 単位というのは、例えば長さなら cm 重さなら kg といったもの。 単位のついた数値のことを名数、単位のない数値のことを無名数と呼ぶ。 単位の情報がある状態から、ない状態に変換することから無名数化と呼ばれる。

無名数化のメリットは使う手法によって異なるものの、基本的には次元による数値の大小の影響がなくなるところ。 使うモデルによっては数値の大きさに影響を受けやすいものがある。 例えば最近傍法などはその代表で、数値の大きな次元に影響を受けやすい。

使った環境は次の通り。 扱うデータセットにはアイリス (あやめ) データセットを用いた。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ python --version
Python 3.6.3

下準備として、今回のサンプルコードで使うライブラリをインストールしておこう。

$ pip install scikit-learn matplotlib scipy

標準化

まず最初に紹介するのは標準化と呼ばれる手法から。 この手法は統計の世界でもよく使われるものになっている。 一般正規分布を標準化することで標準正規分布が得られることが有名。

これは、具体的にはデータの各要素からデータの平均値を引いて標準偏差で割る手法をいう。 数式で表すと、次のようになる。 標準化した値のことを Z スコアと呼んだりすることもある。

 Z = \frac{X - \mu}{\sigma}

ここで  X はデータの集合、 \mu はその平均値、 \sigma は標準偏差を表している。  Z が標準化した後のデータ、Z スコアということ。

データを標準化して Z スコアにすると、その平均値は 0 で標準偏差は 1 になる。

実際にアイリスデータセットを使って標準化するとどうなるのか試してみよう。 次のサンプルコードではアイリスデータセットの花びらの長さと幅の二次元を取り出して標準化している。 そして変換前と変換後でどのように分布が変わるのかを図示している。

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

from sklearn import datasets
from matplotlib import pyplot as plt


def main():
    # アイリスデータセットをロードする
    iris = datasets.load_iris()
    # petal length (花びらの長さ), petal width (花びらの幅) だけ取り出す
    X = iris.data[:, 2:]

    # 標準化する (平均を引いて標準偏差で割る)
    Z = (X - X.mean(axis=0)) / X.std(axis=0)

    # 標準化した後の母数を表示する
    Z_mean = Z.mean(axis=0)
    print('標準化後の平均値: {mean}'.format(mean=Z_mean))
    Z_std = Z.std(axis=0)
    print('標準化後の標準偏差: {std}'.format(std=Z_std))

    plt.scatter(X[:, 0], X[:, 1], label='before')  # 標準化前
    plt.scatter(Z[:, 0], Z[:, 1], label='after')  # 標準化後

    plt.ylim((-2, 3))
    plt.xlim((-2, 8))
    plt.xlabel('petal length')
    plt.ylabel('petal width')
    plt.plot([0, 0], [-2, 3], 'k--')
    plt.plot([-2, 8], [0, 0], 'k--')
    plt.grid(True)
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。 プログラムの中では標準化後の次元の平均値と標準偏差を出力している。

$ python standarding.py
標準化後の平均値: [ -1.48251781e-15  -1.62314606e-15]
標準化後の標準偏差: [ 1.  1.]

平均値はとても小さくなってほとんど 0 になっているし、標準偏差はちゃんと 1 になっている。

標準化前と標準化後の散布図は次の通り。 標準化後は分布の中心が原点になって、バラつきも小さくなっていることが分かる。

f:id:momijiame:20171010182132p:plain

無相関化

続いては無相関化を扱う。 無相関化とは、文字通りだけど次元間の相関をなくす処理のことをいう。

データに複数の次元があるとき、それぞれの次元の間に相関があるか否かはデータエンジニアリングにおいて重要なポイントとなる。 なぜなら、二つの次元に相関があるとき、それはほとんど同じものが二つあることを意味しているため。 ある次元 A と B の間に正の強い相関があるとすれば、次元 A の値が大きいときは次元 B の値を大きいことになる。 だとすれば、分類や回帰をするときには A か B どちらか一方の次元さえあれば事足りてしまうことを意味する。

取り扱う次元の数が増えることは、時間・空間計算量が指数的に増加することを意味している。 つまり、データセットの各次元にはなるべく相関が少ない方が好ましい。 とはいえ、元から相関のないデータばかりではないことから、無相関化という処理で相関を取り除くというわけだ。 無相関化すれば、各次元間の相関は 0 になる。

無相関化の具体的なやり方としては、分散共分散行列の固有値問題を解くのが最初の一歩となる。 分散共分散行列というのは対角成分が分散、それ以外が各次元間の共分散になった行列のこと。 ひとまず、その分散共分散行列の固有値問題を解いて得られる固有ベクトルが重要になる。 この固有ベクトルのことを回転行列と呼んで、この回転行列を使ってデータを線形変換する。 数式に表すと次の通り。

 y = S^{ \mathrm{ T } }x

上記において  S が分散共分散行列の固有値問題を解いて得られた回転行列とする。 線形変換するデータが  x で、した結果が  y となる。 理論的な部分を細かく説明しても分かりにくくなるので、とりあえずこれくらいに抑えておく。

実際にデータを無相関化するサンプルコードを次に示す。

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

import numpy as np
from sklearn import datasets
from matplotlib import pyplot as plt


def main():
    iris = datasets.load_iris()
    X = iris.data[:, 2:]

    # 分散共分散行列を計算する
    Sigma = np.cov(X, rowvar=0)

    # 固有値、固有ベクトル (回転行列) を得る
    _, S = np.linalg.eig(Sigma)

    # 回転行列を使ってデータを線形変換する
    y = np.dot(S.T, X.T).T

    # 無相関化後の分散共分散行列を計算する
    y_cov = np.cov(y, rowvar=0)
    print('無相関化後の分散共分散行列: {cov}'.format(cov=y_cov))

    plt.scatter(X[:, 0], X[:, 1], label='before')
    plt.scatter(y[:, 0], y[:, 1], label='after')

    plt.ylim((-2, 3))
    plt.xlim((-2, 8))
    plt.xlabel('petal length')
    plt.ylabel('petal width')
    plt.plot([0, 0], [-2, 3], 'k--')
    plt.plot([-2, 8], [0, 0], 'k--')
    plt.grid(True)
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。 すると、無相関化した後の各次元の分散共分散行列が得られる。 確認すると、二つの次元の共分散はとても小さい値になっていることから相関が取り除かれたことが分かる。

$ python decorrelating.py 
無相関化後の分散共分散行列: [[  3.65937449e+00  -1.43062296e-16]
 [ -1.43062296e-16   3.62192472e-02]]

得られる散布図は次の通り。 分布が右上がりや右下がりだと相関があることを意味している。 元々の分布は右上がりなので正の相関があったことを読み取れる。 それに対し無相関化後の分布は横にまっすぐ分布していることから相関があることは読み取れない。

f:id:momijiame:20171010190049p:plain

無相関化と主成分分析

実は無相関化と主成分分析 (PCA) には深い関わりがある。 というより、やっていることはほとんど同じといっていい。 具体的には無相関化に中心化という処理を加えたものが主成分分析になる。 中心化というのは、標準化でやっていた「平均を引く」処理のこと。 これをするとデータの分布の中心が原点になるので、文字通り中心化となる。

次のサンプルコードは先ほどとほとんど変わらない。 変更点は、無相関化するデータをあらかじめ中心化しているのみ。

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

import numpy as np
from sklearn import datasets
from matplotlib import pyplot as plt


def main():
    iris = datasets.load_iris()
    X = iris.data[:, 2:]

    # データを中心化する (平均を引くことで平均を 0 にする)
    X_centerized = X - X.mean(axis=0)

    # 分散共分散行列を計算する
    Sigma = np.cov(X, rowvar=0)

    # 固有値、固有ベクトル (回転行列) を得る
    _, S = np.linalg.eig(Sigma)

    # 回転行列を使ってデータを線形変換する
    y = np.dot(S.T, X_centerized.T).T

    # 無相関化後の分散共分散行列を計算する
    y_cov = np.cov(y, rowvar=0)
    print('無相関化後の分散共分散行列: {cov}'.format(cov=y_cov))

    plt.scatter(X[:, 0], X[:, 1], label='before')
    plt.scatter(y[:, 0], y[:, 1], label='after')

    plt.ylim((-2, 3))
    plt.xlim((-4, 8))
    plt.xlabel('petal length')
    plt.ylabel('petal width')
    plt.plot([0, 0], [-2, 3], 'k--')
    plt.plot([-2, 8], [0, 0], 'k--')
    plt.grid(True)
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

ファイルに保存して実行してみよう。

$ python centerizing.py 
無相関化後の分散共分散行列: [[  3.65937449e+00  -1.28159973e-16]
 [ -1.28159973e-16   3.62192472e-02]]

すると、次のような散布図が得られる。 先ほどの無相関化した散布図を中心に移動させた感じ。

f:id:momijiame:20171010201507p:plain

上記の散布図の形をちょっと覚えておいてほしい。

続いて登場するサンプルコードは scikit-learn を使って同じデータを主成分分析している。 scikit-learn では sklearn.decomposition.PCA を使って主成分分析ができる。 以下では、主成分分析した結果から第一主成分と第二主成分を散布図にプロットしている。

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

import numpy as np
from sklearn import datasets
from sklearn.decomposition import PCA
from matplotlib import pyplot as plt


def main():
    iris = datasets.load_iris()
    X = iris.data[:, 2:]

    # 主成分分析
    pca = PCA()
    pca.fit(X)
    y = pca.fit_transform(X)

    # 第一・第二主成分の分散共分散行列を計算する
    y_cov = np.cov(y, rowvar=0)
    print('無相関化後の分散共分散行列: {cov}'.format(cov=y_cov))

    plt.scatter(X[:, 0], X[:, 1], label='before')
    plt.scatter(y[:, 0], y[:, 1], label='after')

    plt.ylim((-2, 3))
    plt.xlim((-4, 8))
    plt.xlabel('petal length')
    plt.ylabel('petal width')
    plt.plot([0, 0], [-2, 3], 'k--')
    plt.plot([-2, 8], [0, 0], 'k--')
    plt.grid(True)
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

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

$ python pca.py 
無相関化後の分散共分散行列: [[  3.65937449e+00   1.26669741e-17]
 [  1.26669741e-17   3.62192472e-02]]

次のような散布図が得られるので、さきほどの散布図と比較してほしい。 全く同じものになっているはずだ。

f:id:momijiame:20171010201526p:plain

つまり、主成分分析というのは中心化したデータを無相関化することと同義ということになる。

白色化

続いては白色化という処理を紹介する。 これは、無相関化した結果を標準化したような考えに近い。 つまり、白色化するとデータは各次元間の相関がなくなった上に平均が 0 で標準偏差が 1 になる。

ただし、やり方は少し複雑になっている。 処理は途中まで主成分分析のそれに近い。 つまり、まずは中心化したデータの分散共分散行列について固有値問題を解いて回転ベクトル  S を手に入れる。 次に、回転ベクトルの逆行列と分散共分散行列と回転行列の内積を計算して、これを  \Lambda とおく。

 \Lambda = S^{-1} \Sigma S

続いて  \Lambda の逆行列の平方根と、回転行列と中心化したデータの内積を計算して  u とおく。

 u = \Lambda^{-\frac{1}{2}} S^{\mathrm{ T }} (x - \mu)

この  u が白色化したデータを表す。

まあ、上の式だけを眺めていてもなんのこっちゃという感じなのでサンプルコードを以下に示す。

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

import numpy as np
from sklearn import datasets
from matplotlib import pyplot as plt


def main():
    iris = datasets.load_iris()
    X = iris.data[:, 2:]

    # データを中心化する (平均を引くことで平均を 0 にする)
    X_centerized = X - X.mean(axis=0)

    # 分散共分散行列を計算する
    Sigma = np.cov(X, rowvar=0)

    # 固有値問題を解いて固有値と固有ベクトル (回転行列) を得る
    _, S = np.linalg.eig(Sigma)

    # 回転行列の逆行列
    S_inv = np.linalg.inv(S)

    # 対角行列
    Lambda = S_inv.dot(Sigma).dot(S)

    # 対角成分だけ残す
    Lambda = (Lambda * np.identity(2)).transpose()

    # 各対角要素の平方根をとった行列の逆行列
    Lambda_sqrt_inv = np.linalg.inv(np.sqrt(Lambda))

    # 白色化する
    u = X_centerized.dot(S).dot(Lambda_sqrt_inv.T)

    # 白色化後の分散共分散行列
    u_cov = np.cov(u, rowvar=0)
    print('白色化後の分散共分散行列: {cov}'.format(cov=u_cov))
    # 同、平均
    u_mu = u.mean(axis=0)
    print('白色化後の平均: {mu}'.format(mu=u_mu))
    # 同、標準偏差
    u_std = u.std(axis=0)
    print('白色化後の標準偏差: {std}'.format(std=u_std))

    plt.scatter(X[:, 0], X[:, 1], label='before')
    plt.scatter(u[:, 0], u[:, 1], label='after')

    plt.ylim((-3, 3))
    plt.xlim((-2, 8))
    plt.xlabel('petal length')
    plt.ylabel('petal width')
    plt.plot([0, 0], [-3, 3], 'k--')
    plt.plot([-2, 8], [0, 0], 'k--')
    plt.grid(True)
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみる。

$ python whitening.py 
白色化後の分散共分散行列: [[  1.00000000e+00  -3.81499455e-16]
 [ -3.81499455e-16   1.00000000e+00]]
白色化後の平均: [ -1.36631447e-15   2.68192875e-15]
白色化後の標準偏差: [ 0.99666109  0.99666109]

出力内容から白色化後のデータは相関がなくなって平均が 0 になり標準偏差が 1 になることがわかった。

また、同時に次のような散布図が得られる。

f:id:momijiame:20171010204135p:plain

どの点がどの点に対応するのかも、もはやよく分からないほどだけど、これで良いようだ。

まとめ

今回はデータの前処理として無名数化と呼ばれる手法をいくつか試してみた。 無名数化というのは、データに含まれる次元の単位をなくす処理のことだった。

まずはじめに、標準化と呼ばれるデータの平均を 0 にして標準偏差を 1 にする手法を紹介した。 その次の無相関化では、データの各次元間の共分散 (相関) を 0 にできた。 そして、最後に紹介した白色化では共分散 (相関) を 0 にした上で平均を 0 標準偏差を 1 にできた。

参考文献

はじめてのパターン認識

はじめてのパターン認識