CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: 多様体学習 (Manifold Learning) を用いた次元縮約

今回は多様体学習を使ってデータの次元を縮約する方法について。 これはデータの前処理として、主に二つの目的で使われる。 一つ目は、次元を縮約することで二次元や三次元の形でデータを可視化できるようにするため。 もう一つは、次元を縮約した結果を教師データとして用いることでモデルの認識精度を上げられる場合があるため。

データの次元を縮約する手法としては主成分分析 (PCA) が有名だけど、これは線形な変換になっている。 ただ、実際に取り扱うデータは必ずしもそれぞれの次元が線形な関係になっているとは限らない。 そこで、非線形な変換をするのが多様体学習ということらしい。

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

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

下準備

まずは、今回使う Python のライブラリをインストールしておく。

$ pip install numpy scipy scikit-learn matplotlib

扱うデータセットとしては scikit-learn の digits データセットにした。 これは 8 x 8 のピクセルで表現された手書きの数値データになっている。 8 x 8 ピクセルを扱うので 64 次元になっていて、それが 1797 点ある。

>>> from sklearn import datasets
>>> dataset = datasets.load_digits()
>>> dataset.data.shape
(1797, 64)

上記のデータセットが具体的にどういったものなのかを可視化しておく。 データセットからランダムに 25 点を取り出して画像として表示してみよう。

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

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


def main():
    dataset = datasets.load_digits()
    X = dataset.data
    y = dataset.target

    # データの中から 25 点を無作為に選び出す
    sample_indexes = random.choice(np.arange(len(X)), 25, replace=False)

    # 選んだデータとラベルを matplotlib で表示する
    samples = np.array(list(zip(X, y)))[sample_indexes]
    for index, (data, label) in enumerate(samples):
        # 画像データを 5x5 の格子状に配置する
        plt.subplot(5, 5, index + 1)
        # 軸に関する表示はいらない
        plt.axis('off')
        # データを 8x8 のグレースケール画像として表示する
        plt.imshow(data.reshape(8, 8), cmap=cm.gray_r, interpolation='nearest')
        # 画像データのタイトルに正解ラベルを表示する
        plt.title(label, color='red')
    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記を適当な名前をつけて保存したら実行する。

$ python digits.py

すると、次のようなグラフが得られる。 MNIST (28 x 28 ピクセル) に比べると、だいぶ荒いことが分かる。

f:id:momijiame:20171209032808p:plain

とはいえデータセットのダウンロードが生じないので取り回しが良い。

多様体学習を使ってデータセットの次元を縮約する

続いては、上記で確認したデータセットを実際に多様体学習アルゴリズムを使って次元縮約してみる。 元々のデータセットは 64 次元なので、個々を画像として表示はできるものの全体を散布図のように図示することはできない。 そこで、多様体学習を用いて 2 次元に縮約することで散布図として図示できるようにしてしまおう、ということ。

scikit-learn に組み込まれている多様体学習アルゴリズムの一覧は次のページで確認できる。

2.2. Manifold learning — scikit-learn 0.19.1 documentation

次のサンプルコードでは、上記からいくつか主要なアルゴリズムを使っている。 digits データセットの 64 次元を 2 次元に縮約した結果を散布図として図示した。 一応、比較対象として主成分分析も入れている。

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

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

from sklearn.decomposition import PCA
from sklearn.manifold import MDS
from sklearn.manifold import LocallyLinearEmbedding
from sklearn.manifold import SpectralEmbedding
from sklearn.manifold import Isomap
from sklearn.manifold import TSNE

def main():
    dataset = datasets.load_digits()

    X = dataset.data
    y = dataset.target

    plt.figure(figsize=(12, 8))

    # 主な多様体学習アルゴリズム (と主成分分析)
    manifolders = {
        'PCA': PCA(),
        'MDS': MDS(),
        'Isomap': Isomap(),
        'LLE': LocallyLinearEmbedding(),
        'Laplacian Eigenmaps': SpectralEmbedding(),
        't-SNE': TSNE(),
    }
    for i, (name, manifolder) in enumerate(manifolders.items()):
        plt.subplot(2, 3, i + 1)

        # 多様体学習アルゴリズムを使って教師データを 2 次元に縮約する
        X_transformed = manifolder.fit_transform(X)

        # 縮約した結果を二次元の散布図にプロットする
        for label in np.unique(y):
            plt.title(name)
            plt.scatter(X_transformed[y == label, 0], X_transformed[y == label, 1])

    plt.show()

if __name__ == '__main__':
    main()

上記を適当な名前をつけて保存したら実行する。

$ python manifoldlearning.py

すると、次のようなグラフが得られる。 グラフの各色は、それぞれの数字 (0 ~ 9) を表している。 それぞれのクラスタがキレイにまとまった上で分かれているほど、上手く縮約できているということだと思う。 この中だと t-SNE が頭一つ抜けてるなという感じ。

f:id:momijiame:20171209035139p:plain

次元を縮約した結果を教師データとして用いる

続いては多様体学習を使う目的の二つ目、認識精度の向上について見ていく。 素の教師データ、主成分分析の結果、t-SNE の結果それぞれをランダムフォレストの教師データとして渡してみよう。 汎化性能は、どのように変化するだろうか。

まずは素の教師データから。 digits データセットをそのままランダムフォレストに渡している。 その際の精度をK-分割交差検証で確かめる。

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

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

from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold

from sklearn.ensemble import RandomForestClassifier

def main():
    dataset = datasets.load_digits()

    X = dataset.data
    y = dataset.target

    scores = np.array([], dtype=np.bool)

    # K-分割交差検証で汎化性能を調べる (分割数は 10)
    cross_validator = KFold(n_splits=10)
    for train, test in cross_validator.split(X):
        X_train, X_test = X[train], X[test]
        y_train, y_test = y[train], y[test]

        # ランダムフォレストで素の教師データを学習する
        cls = RandomForestClassifier()
        cls.fit(X_train, y_train)

        # テストデータに対して分類する
        y_pred = cls.predict(X_test)
        scores = np.hstack((scores, y_test == y_pred))

    # テストデータに対する精度から汎化性能を求める
    accuracy_score = sum(scores) / len(scores)
    print(accuracy_score)


if __name__ == '__main__':
    main()

実行結果は次の通り。 これはランダムフォレストの使う木構造の作られ方にもよるので、出力される数値は毎回微妙に異なる。 今回については約 93% となった。

$ python randomforest.py 
0.929326655537

続いては主成分分析を使って次元を縮約した結果をランダムフォレストに渡すパターン。

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

from matplotlib import pyplot as plt
import numpy as np
from sklearn import datasets
from sklearn.decomposition import PCA
from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold
from sklearn.ensemble import RandomForestClassifier


def main():
    dataset = datasets.load_digits()

    X = dataset.data
    y = dataset.target

    # 次元縮約に主成分分析を使う
    manifolder = PCA()

    scores = np.array([], dtype=np.bool)

    cross_validator = KFold(n_splits=10)
    for train, test in cross_validator.split(X):
        # 教師データを主成分分析を使って次元縮約する
        X_transformed = manifolder.fit_transform(X)

        X_train, X_test = X_transformed[train], X_transformed[test]
        y_train, y_test = y[train], y[test]

        # 縮約した教師データを学習する
        cls = RandomForestClassifier()
        cls.fit(X_train, y_train)

        y_pred = cls.predict(X_test)
        scores = np.hstack((scores, y_test == y_pred))

    accuracy_score = sum(scores) / len(scores)
    print(accuracy_score)


if __name__ == '__main__':
    main()

実行結果は次の通り。 残念ながら精度は約 87% に低下してしまった。

$ python randomforestpca.py 
0.870339454647

これは、元々のデータセットの性質が非線形なため上手く主成分を取り出すことができなかったということだろう。 まあ、可視化した段階でもそれぞれのクラスタがごちゃっと固まってたしね。

続いては t-SNE を使った場合。

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

from matplotlib import pyplot as plt
import numpy as np
from sklearn import datasets
from sklearn.manifold import TSNE
from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold
from sklearn.ensemble import RandomForestClassifier


def main():
    dataset = datasets.load_digits()

    X = dataset.data
    y = dataset.target

    # 次元縮約に t-SNE を使う
    manifolder = TSNE()

    scores = np.array([], dtype=np.bool)

    cross_validator = KFold(n_splits=10)
    for train, test in cross_validator.split(X):
        # 教師データを t-SNE で次元縮約する
        X_transformed = manifolder.fit_transform(X)

        X_train, X_test = X_transformed[train], X_transformed[test]
        y_train, y_test = y[train], y[test]

        # 縮約した教師データを学習する
        cls = RandomForestClassifier()
        cls.fit(X_train, y_train)

        y_pred = cls.predict(X_test)
        scores = np.hstack((scores, y_test == y_pred))

    accuracy_score = sum(scores) / len(scores)
    print(accuracy_score)


if __name__ == '__main__':
    main()

実行結果は次の通り。 今度は精度が約 97% に向上した。

$ python randomforesttsne.py 
0.973288814691

素のデータセットをそのまま渡す場合に比べると 4% も認識精度が良くなっている。

t-SNE の高速化

ところで、実際に scikit-learn の t-SNE を使うコードを実行してみると、かなり遅いことに気づくはず。 どうやら、元々 t-SNE は他の多様体学習アルゴリズムに比べると計算量が多いようだ。 ただ、正直このままだと実際のデータセットに使うのは厳しいなあと感じた。 そこで、もうちょっと高速に動作する実装がないか調べたところ Multicore-TSNE という実装があった。

次はこれを試してみよう。 まずはパッケージをインストールする。

$ pip install MulticoreTSNE

次のサンプルコードでは t-SNE の実装を scikit-learn から Multicore-TSNE に切り替えている。 やっていることは scikit-learn 版と変わらない。 コード上の変更点もインポート文を変えたのとジョブ数を指定しているくらい。

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

import multiprocessing

from matplotlib import pyplot as plt
import numpy as np
from sklearn import datasets
from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold
from sklearn.ensemble import RandomForestClassifier
from MulticoreTSNE import MulticoreTSNE as TSNE

def main():

    dataset = datasets.load_digits()

    X = dataset.data
    y = dataset.target

    manifolder = TSNE(n_jobs=multiprocessing.cpu_count())

    scores = np.array([], dtype=np.bool)

    cross_validator = KFold(n_splits=10)
    for train, test in cross_validator.split(X):
        X_transformed = manifolder.fit_transform(X)

        X_train, X_test = X_transformed[train], X_transformed[test]
        y_train, y_test = y[train], y[test]

        cls = RandomForestClassifier()
        cls.fit(X_train, y_train)

        y_pred = cls.predict(X_test)
        scores = np.hstack((scores, y_test == y_pred))

    accuracy_score = sum(scores) / len(scores)
    print(accuracy_score)


if __name__ == '__main__':
    main()

上記の実行時間は次の通り。 今回使った環境では、だいたい 1 分ほどで終わった。

$ time python multicoretsne.py
0.9816360601
python multicoretsne.py  59.58s user 1.51s system 95% cpu 1:03.96 total

比較として scikit-learn 版の実行時間も示しておく。 こちらは、なんと 6 分ほどかかっている。

$ time python randomforesttsne.py 
0.979410127991
python randomforesttsne.py  382.42s user 34.13s system 96% cpu 7:11.93 total

64 次元 1797 点で 1 分かあ、と思うところはあるものの 6 倍速いという結果は頼もしい限り。

まとめ

今回は多様体学習アルゴリズムを使ってデータセットの次元を縮約してみた。 多様体学習を使って次元を縮約することで、データセットを可視化できたりモデルの認識精度を向上できる場合がある。

Python: NumPy で正方行列を三角行列に加工する

今回は NumPy で正方行列を扱うとき、上三角行列とか下三角行列を取り出す方法について。 三角行列というのは、正方行列において対角要素より上の成分が全て 0 だったり、下の成分が全て 0 だったりする行列のこと。 ちなみに、最初この呼び方を知らなくて「行列 斜め 上」とかでたくさんぐぐった。

使った環境は次の通り。

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

インストール

ひとまず、何はともあれ NumPy はインストールしておく。

$ pip install numpy

サンプル用の正方行列を用意する

まずは、加工する前の正方行列を用意する。 今回は 1 ~ 25 の数字を入れた配列を 5 x 5 の正方行列にして使おう。

>>> import numpy as np
>>> array = np.arange(1, 5 * 5 + 1).reshape(5, 5)

中身はこんな感じ。

>>> array
array([[ 1,  2,  3,  4,  5],
       [ 6,  7,  8,  9, 10],
       [11, 12, 13, 14, 15],
       [16, 17, 18, 19, 20],
       [21, 22, 23, 24, 25]])

上三角行列 (対角要素あり)

まず、一番簡単なのは対角要素のある上三角行列かな。 これは単純に numpy.triu() 関数に正方行列を渡すだけで得られる。

>>> np.triu(array)
array([[ 1,  2,  3,  4,  5],
       [ 0,  7,  8,  9, 10],
       [ 0,  0, 13, 14, 15],
       [ 0,  0,  0, 19, 20],
       [ 0,  0,  0,  0, 25]])

簡単だね。

上三角行列 (対角要素なし)

続いて対角要素のない上三角行列を。 これは numpy.triu() 関数の k オプションに 1 を渡してやれば良い。

>>> np.triu(array, k=1)
array([[ 0,  2,  3,  4,  5],
       [ 0,  0,  8,  9, 10],
       [ 0,  0,  0, 14, 15],
       [ 0,  0,  0,  0, 20],
       [ 0,  0,  0,  0,  0]])

ちなみに k オプションの値は増やしたり減らすことで削る領域を調整できる。

>>> np.triu(array, k=2)
array([[ 0,  0,  3,  4,  5],
       [ 0,  0,  0,  9, 10],
       [ 0,  0,  0,  0, 15],
       [ 0,  0,  0,  0,  0],
       [ 0,  0,  0,  0,  0]])
>>> np.triu(array, k=-1)
array([[ 1,  2,  3,  4,  5],
       [ 6,  7,  8,  9, 10],
       [ 0, 12, 13, 14, 15],
       [ 0,  0, 18, 19, 20],
       [ 0,  0,  0, 24, 25]])

下三角行列 (対角要素あり)

(2017/12/06 追記)

下三角行列の効率的な加工方法を教えて頂きました。 転置行列を使うことで、短く書けるみたいです。 本文も、このやり方を使ったものに変更しました。 大変勉強になりました。この場を借りてお礼を申し上げます。


続いては下三角行列を見ていく。 下三角行列には、転置行列を使うことで効率的に加工できるようだ。

まず NumPy では行列の T アトリビュートから転置行列が得られる。 転置行列では、上三角と下三角が反転した状態になる。

>>> array.T
array([[ 1,  6, 11, 16, 21],
       [ 2,  7, 12, 17, 22],
       [ 3,  8, 13, 18, 23],
       [ 4,  9, 14, 19, 24],
       [ 5, 10, 15, 20, 25]])

そこで NumPy.triu() 関数を使うと上三角だけが残る。 転置する前においては下三角だった要素たちだ。

>>> np.triu(array.T)
array([[ 1,  6, 11, 16, 21],
       [ 0,  7, 12, 17, 22],
       [ 0,  0, 13, 18, 23],
       [ 0,  0,  0, 19, 24],
       [ 0,  0,  0,  0, 25]])

あとは、この状態から、さらに転置行列を取得することで元々の下三角行列が得られる。

>>> np.triu(array.T).T
array([[ 1,  0,  0,  0,  0],
       [ 6,  7,  0,  0,  0],
       [11, 12, 13,  0,  0],
       [16, 17, 18, 19,  0],
       [21, 22, 23, 24, 25]])

下三角行列 (対角要素なし)

対角要素なしの下三角行列の取り出しは、これまでの考え方の組み合わせでいける。

先ほどの考え方はそのままに、上三角行列を取り出すタイミングで k オプションを指定すれば良いだけ。

>>> np.triu(array.T, k=1).T
array([[ 0,  0,  0,  0,  0],
       [ 6,  0,  0,  0,  0],
       [11, 12,  0,  0,  0],
       [16, 17, 18,  0,  0],
       [21, 22, 23, 24,  0]])

めでたしめでたし。

電球形蛍光灯を買ったけどすぐに LED 電球を買い直した話

今回は、珍しくコンピュータではなく照明器具の話題で。 実際にやった (やらかした) のは今から 2 年ほど前のこと。 照明の節電と長寿命化のために電球形蛍光灯を選んでみたら失敗した、という話。

今となっては電球形蛍光灯と LED 電球の価格差はだいぶ縮まってきている。 なので、これから電球形蛍光灯をあえて選ぼうという人も減ってきているはず。 とはいえ、同じ失敗をする人がいるとも限らないので、今さらながら書いてみることにした。

照明を見直したきっかけ

ことの始まりは、今住んでいる賃貸マンションが初期状態で廊下などの照明ソケットに白熱電球を入れていることだった。 ただ、白熱電球は寿命が短いので、だいたい半年から一年くらいするとフィラメントが切れてしまう。 最初こそ同じタイプの白熱電球を買って付け替えていたんだけど、何度も交換するうちにうんざりしてきた。

購入してたのは、こういうやつ。 廊下なんかで使われるソケットは基本的には E26 口金というやつを選べば良いようだ。 以下の商品であれば、定格電力は 54W で定格寿命は 1,500 時間となっている。 Amazon の履歴を見ると当時の価格は ¥369 だった。

パナソニック レフ電球(屋内用) E26口金 100V60形 散光形(ビーム角=60°)

パナソニック レフ電球(屋内用) E26口金 100V60形 散光形(ビーム角=60°)

代替製品の検討

そこで、節電と長寿命化を狙って電球形蛍光灯か LED 電球への交換を考えた。 両者を比較すると、消費電力の面でも定格寿命の面でも LED 電球の方が勝っている。 ただ、当時は電球形蛍光灯なら LED 電球の 1/2 ~ 1/3 くらいの値段で買うことができた。 今だと LED 電球の普及が進んだことで、両者の価格差はもっと縮まっている。 とはいえ、同じ会社で同じシリーズならやはり電球形蛍光灯の方が安く買える。

例えば以下の商品であれば、定格電力が 11W で定格寿命が 10,000 時間となっている。 白熱電球に比べると定格電力で約 1/5 だし、定格寿命は約 6 倍になる。

パナソニック 電球形蛍光灯 パルックボール 電球60W形相当 口金直径26mm  電球色相当    EFD15EL11E

パナソニック 電球形蛍光灯 パルックボール 電球60W形相当 口金直径26mm 電球色相当 EFD15EL11E

実際に購入したのは上記の旧モデルだけど、当時は ¥400 で購入できた。 製品の値段が白熱電球と大差ないことを考えると、しめしめこれは良さそうだぞとなった。 しかし、実際に購入して交換してみると、思った以上にデメリットがある。

具体的には、照明が点灯するまでに大きなディレイ (遅れ) があるため。 一般的な蛍光灯を使ったことがある人なら、スイッチを入れてから「・・・チチッ、パッ」という感覚が分かると思う。 それと、完全に明るくなるまでつけっぱなしでしばらく (数分) かかる。 考えてみれば当たり前のことなんだけど、電球形蛍光灯はその特性を完全に引き継いでしまっている。

実際に使うまでは、そんなコンマ数秒の遅れがあっても気にならないだろうと思っていた。 しかし、元々が白熱電球を使っていた場所にそれを交換すると、これがもう気になって仕方がない。 スイッチを入れてから、ほんの少しの間を置かないと明るくならないだけで、こんなにストレスになるとは思わなかった。

LED 電球の買い直し

結局、それから我慢しきれずに LED 電球を買い直すことになった。 例えば、以下の商品なら定格電力が 4.9W で定格寿命が 40,000 時間に及ぶ。 電球形蛍光灯と比べても電力で約半分、寿命は 4 倍になる。 何より、使い勝手が白熱電球とほとんど変わらず、点灯までのディレイがないし光量が変化したりもしない。

実際に購入したのは上記の旧モデルだけど、当時は ¥752 だったようだ。 電球形蛍光灯と比較したとき、金額的には大差なくても定格電力の差を考えるとペイするのは相当先のことだった。 それもあって最初、電球形蛍光灯を選んだように思う。 もちろん、白熱電球との比較であれば製品の寿命的にすぐペイできるんだけど。

ところで上記の商品は白熱電球 40 形相当、となっている。 つまり、白熱電球であれば 40W タイプに相当することになる。 あれ、じゃあ最初に出てきた白熱電球よりも暗いの?というと、そんなこともなかった。 どうやら LED 電球は前者二つに比べると明るく感じやすいのか、一つ大きさを落としたくらいでちょうど良いらしい。 実際に購入して、今使っている製品も 40 形相当なんだけど元の白熱電球に比べて暗いという感じは全くない。 また、サイズが小さい方が価格が安いというメリットもある。

ということで、実際に電球形蛍光灯を使っていたのは一ヶ月くらいだったようだ。 安物買いの銭失いとは、正にこのこと。 ちなみに、当たり前だけど交換した LED 電球は 2 年経った今も元気に我が家を照らしている。

ところで「賃貸だから長寿命の照明器具を買ってもなあ」と購入に消極的な考えを持つ必要はないと思う。 というのも、元々ついている電球を、取り外して保管しておけば済む話なので。 別の家に引っ越すときは、保管していたものに付けなおして、自分で購入したものは引越し先に持って行けば良い。 ソケットの規格は日本に住んでいる限りは変わらないだろうから、また使うことができるはず。

Python: ジェネレータをイテレータから理解する

Python のイテレータとジェネレータという概念は意外と分かりにくい。 今回は、実は深い関わり合いを持った両者についてまとめてみることにする。 というのも、最終的にジェネレータを理解するにはイテレータへの理解が欠かせないためだ。

使った環境は次の通り。

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

イテレータとは

まず、そもそもイテレータとは何者だろうか。 それについて、いくつかの側面から考えてみることにしよう。

使い方から考える

最初は、使い方という側面からイテレータとは何かを考えてみよう。 このとき、答えは「要素を一つずつ取り出すことのできるオブジェクト」になる。

実際に、使い方からイテレータについて見ていこう。 そのために、まずは整数がいくつか入ったリストを用意する。 ここでは、まだイテレータという概念は何も登場していない。

>>> l = [1, 2, 3]

次に、上記のリストから今回の主役であるイテレータを取り出す。 これには組み込み関数の iter() を用いる。

>>> iterator_object = iter(l)

「リストからイテレータを取り出す」と書いたように、リスト自体はイテレータではない。 あくまで、リストはイテレータを取り出すための存在に過ぎない。 こういった存在のことをイテレータの「コンテナオブジェクト」と呼ぶ。 詳しくは後述するものの、コンテナオブジェクトはリスト以外にもたくさんある。

上記で得られたイテレータからは、要素を一つずつ取り出すことができる。 ここで取り出される要素というのは、イテレータの元になったリストに含まれていたオブジェクトになる。 要素の取り出しには組み込み関数の next() を用いる。

>>> next(iterator_object)
1
>>> next(iterator_object)
2
>>> next(iterator_object)
3

要素を全て取り出しきった状態で、さらに next() 関数を使うと StopIteration 例外になる。 こうなると、もう新しい要素は取り出すことはできない。

>>> next(iterator_object)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

もし、もう一度リストに含まれていた要素を取り出し直したいときは、元のリストからイテレータを作り直す必要がある。 つまり、イテレータというのは使い捨てのオブジェクトということ。

上記は全てのイテレータに共通の考え方・使い方になっている。 なので、辞書 (dict) や集合 (set) といった別のコンテナからイテレータを作ったときも同じことが言える。

実は、日頃からお世話になっている構文の中にも、上記イテレータの仕組みを利用しているものがある。 次のスニペットを見てもらいたい。 以下では、リストの内容を for 文で回して、得られた要素をプリントしている。

>>> l = [1, 2, 3]
>>> for i in l:
...     print(i)
... 
1
2
3

実は、上記の for 文こそ最も身近なイテレータの利用例といえる。

なぜなら、上記の for 文が内部的にやっていることは以下と等価なため。 for 文は以下を簡単に書くためのシンタックスシュガーと捉えても構わない。

>>> iterator_object = iter(l)
>>> while True:
...     try:
...         i = next(iterator_object)
...         print(i)
...     except StopIteration:
...         break
... 
1
2
3

実は気づかないうちにイテレータを活用していたのだ。

作り方から考える

次は、作り方という側面からイテレータについて考えてみよう。 イテレータを作るにはイテレータプロトコルと呼ばれる特殊メソッドをクラスに実装する必要がある。

特殊メソッドというのは、前後にアンダースコア (_) が二つ入った特定の名前を持つメソッドのことを言う。 Python では、そういった名前を持つメソッドが特殊な振る舞いをする決まり (仕様) になっている。

オブジェクトがイテレータとして振る舞うには、そのオブジェクトに __next__()__iter__() という二つの特殊メソッドが必要になる。 この二つをまとめてイテレータプロトコルと呼ぶ。 ちなみに蛇足だけど Python 2 では __next__()next() だった。 バージョン 2 と 3 の両方に対応させるときは、どっちも実装しちゃうのが手っ取り早い。

それでは、実際にイテレータを作ってみよう。 次のスニペットでは MyCounter という名前でイテレータを作るクラスを定義している。 このクラスにはイテレータプロトコルが実装されているので、生成したインスタンスがイテレータとして振る舞うことができる。

>>> class MyCounter(object):
...     """整数を連番で提供するイテレータクラス"""
...     def __init__(self, start, stop):
...         self._counter = start
...         self._stop = stop
...     def __iter__(self):
...         # 自分自身を返す
...         return self
...     def __next__(self):
...         if self._counter > self._stop:
...             # 最後まで到達したときは StopIteration 例外を上げる
...             raise StopIteration()
...         ret = self._counter
...         self._counter += 1
...         return ret
...     def next(self):
...         # Python 2 対応
...         return self.__next__()
... 

実際にインスタンス化して使ってみよう。 前述した通り、上記のクラスをインスタンス化したオブジェクトはイテレータとして扱うことができる。

>>> c = MyCounter(start=1, stop=3)
>>> next(c)
1
>>> next(c)
2
>>> next(c)
3
>>> next(c)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 9, in __next__
StopIteration

そして、もうお気づきかもしれないけど組み込み関数 next() で呼ばれたときの処理の実体が __next__() メソッドに対応する。

ちなみに iter() 関数も同じで、今回の場合だと __iter__() で自分自身を返すことになる。

>>> c = MyCounter(start=1, stop=3)
>>> next(c)
1
>>> c2 = iter(c)
>>> next(c2)
2
>>> next(c2)
3

また、イテレータプロトコルの中で __iter__() だけを実装したものがイテレータの「コンテナオブジェクト」になる。 コンテナオブジェクトというのは、例えば最初に出てきたリストがそうだった。 前述した通り、イテレータオブジェクトは使い捨てなので使うたびに新しく作り直さないといけない。 そこで、設定 (状態) を別の場所に保持しておいて、そこからイテレータを何度も生成できるようにするのがコンテナオブジェクトの役割だ。

試しに、先ほど定義した MyCounter クラスに対応するコンテナオブジェクトを作ってみよう。

>>> class MyContainer(object):
...     """MyCounterを生成するコンテナクラス"""
...     def __init__(self, start, stop):
...         self._start = start
...         self._stop = stop
...     def __iter__(self):
...         # コンテナクラスの __iter__() ではイテレータオブジェクトを返す
...         return MyCounter(start=self._start, stop=self._stop)
... 

まずは上記で定義したコンテナクラスからインスタンスを生成する。 生成したインスタンスがコンテナオブジェクトになる。

>>> container_object = MyContainer(start=1, stop=3)

コンテナオブジェクトからは iter() 関数を使ってイテレータオブジェクトを得る。

>>> c = iter(container_object)
>>> next(c)
1
>>> next(c)
2

ちなみに for 文に渡すものはイテレータオブジェクトそのものでも良いし、コンテナオブジェクトでも構わない。 ただし、コンテナオブジェクトの場合はイテレータオブジェクトを何度でも作り直せるので使いまわすことができる。

>>> for i in container_object:
...     print(i)
... 
1
2
3

for 文にイテレータオブジェクトを直接渡している場合だと、こうはいかない。 イテレータオブジェクトは使い捨てなので、一度 for 文で回したら再度使うことができない。

使う理由から考える

続いては、イテレータを使う理由から考えてみたい。 イテレータを使う理由は、主に二つ挙げられる。

まず、一つ目は for 文の例にあるようにインターフェースの統一という点がある。 Python にはリスト (list) や辞書 (dict)、集合 (set)、その他色々なコンテナオブジェクトがある。 それらの値を列挙しようとしたとき、もしもインターフェースが統一されていなかったらどうなるだろうか? それぞれのオブジェクトごとに、バラバラのメソッドを使い分けて値を取り出すことになって大変なはず。 イテレータプロトコルという統一されたインターフェースがあるおかげで、値の列挙に同じ操作が適用できる。

二つ目は空間計算量の問題、ようするにメモリの節約がある。 例えば先ほどの MyCounter のように整数を連番で生成することを考えてみよう。 もし、それを用意するのにリストを使うとしたら、あらかじめ全ての要素をメモリに格納しなければならない。 要素の数が少なければ問題ないだろうけど、もし膨大な数を扱うとすればメモリを大量に消費することになる。 それに対しイテレータを使えば、そのつど生成した値を使い終わったら後は覚えておく必要はない。 変数から参照されなくなったら要素はガーベジコレクションの対象となるためメモリの節約につながる。

以上、イテレータについて使い方、作り方、使う理由という側面から解説した。

ジェネレータとは

続いてはジェネレータの説明に入る。 とはいえ、実はイテレータについて理解できた時点でジェネレータについての説明は半分以上終わったようなものだったりする。 というのも、ジェネレータというのはイテレータを簡単に作るための手段に過ぎないため。

先ほどのイテレータの作り方の説明を読んで「意外とめんどくさいな」と思った人もいるんじゃないだろうか。 それはその通りだと思っていて、クラスに特殊メソッドを実装してイテレータを作るのは、ぶっちゃけめんどくさい。 そんなとき、簡単にイテレータを作れるのがジェネレータという方法だったりする。

ジェネレータというのは、実のところイテレータオブジェクトを生成するための特殊な関数に過ぎない。 通常の関数との違いは、値を返すのに return ではなく yield を使うところだけ。 この yield の呼び出しが、イテレータプロトコルの __next__() に対応している。

説明だけ聞いてもよく分からないと思うので、実際にサンプルコードを見てみよう。 先ほどの MyCounter を、クラスではなくジェネレータを使って実装してみる。

>>> def mycounter(start, stop):
...     counter = start
...     while True:
...         if counter > stop:
...             break
...         yield counter
...         counter += 1
... 

クラスを使った場合に比べると、ずいぶんスッキリしていることが分かる。

上記のジェネレータを呼び出して、まず返ってくるのはイテレータオブジェクトだ。

>>> iterator_object = mycounter(1, 3)

その証拠に組み込み関数 dir() を使ってアトリビュートを確認すると __iter__()__next__() があることが分かる。

>>> dir(iterator_object)
['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__next__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running', 'gi_yieldfrom', 'send', 'throw']

そのイテレータオブジェクトに next() 関数を使うと yield を使って返された値が得られる。 ちなみに、ジェネレータが返すイテレータオブジェクトは、ジェネレータイテレータと呼ばれる。

>>> next(iterator_object)
1
>>> next(iterator_object)
2
>>> next(iterator_object)
3
>>> next(iterator_object)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

先ほどの「イテレータの説明が終わったらジェネレータの説明も半分以上は終わっている」という言葉はこういうわけだった。 ジェネレータという特殊な関数は、イテレータオブジェクトを簡単に作るためのラッパーかシンタックスシュガーに過ぎない。

まとめ

ジェネレータを理解するのは難しい。 その理由は、まずイテレータを理解する必要があるからだと感じている。 そこで、今回はイテレータとジェネレータについて一通り解説する記事を書いてみた。

ちなみに、手前味噌だけどここらへんの話は拙著の「スマートPythonプログラミング」にも書いてある。 もし、Python に登場する特徴的な概念への理解がふんわりしているという場合には読んでみると良いかもしれない。

head コマンドの不思議な挙動と標準入出力のバッファリング

今回は Unix における標準入出力のバッファリングモードについて扱う。 普段あまり意識していなかったけど head コマンドの挙動をきっかけに気になって調べていったらなかなか面白かった。

使った環境は次の通り。

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

head コマンド

まず、head コマンドというのは標準出力の中から先頭にある行だけを取り出すのに使われるユーティリティのこと。 例えば、次のようにして 5 行ある出力の中から先頭 3 行だけを取り出す、みたいな用途で使う。

$ for i in 1 2 3 4 5; do echo $i; done | head -n 3
1
2
3

左辺にあるコマンドの標準出力が、パイプを通して head コマンドの標準入力につながれる。 head コマンドは標準入力から指定された行数だけを読み出して、それを標準出力に書き出す。

長大な出力を head したときの不思議な挙動

調べ始めたきっかけは、長大な出力を出す自作スクリプトから head コマンドで先頭だけを取り出そうとしたときの挙動だった。

例えば、Python で次のようなサンプルコードを用意する。 このコードは、整数の連番を 1 秒ごとに延々と標準出力に書き出すというものになっている。

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

import itertools
import time


def main():
    # 整数の連番を返すイテレータ
    c = itertools.count()
    for i in c:
        # イテレータから得られた数値を標準出力に書き出す
        print(i)
        # 書いたら 1 秒待つ
        time.sleep(1)


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。 整数の連番が 1 秒ごとに、ずっと出力され続けるはずだ。

$ python count.py 
0
1
2
3
4
5
6
7
8
9
...(省略)

では、次に上記の出力から head コマンドを使って先頭行を取り出してみよう。

上記の出力に対して head コマンドを使ったとき、どういった結果を期待するだろうか? おそらく、すぐに 0 が出力されて端末の制御が戻ってくることを期待するはず。 しかし、残念ながら実際はそうならない。 なんと、コマンドがずっと実行されっぱなしで返ってこなくなる。

$ python count.py | head -n 1
...(戻ってこない)

どうして、こんな挙動になるのだろうか?

結論から先に言ってしまうと、これには標準入出力のバッファリングが関わっている。 具体的には、直接端末に書き出すときとパイプを使って別のコマンド (今回であれば head) に標準出力をつなぐときでは、標準出力のバッファリングモードが変わる。

直接端末に書き出すときには「行単位バッファリング」だったのが、パイプを使うと「完全バッファリング」になる。 行単位バッファリングでは改行コードのタイミングでバッファがフラッシュされる。 しかし、完全バッファリングではバッファが一杯になるまでフラッシュされない。 つまり、今回のケースでは count.py の出力バッファが一杯になるまで head コマンドにデータが流れてこないのだ。 そのため、いつまでも結果が出力されず端末に制御が戻ってこなかった。

標準入出力のバッファリングとは

ここからは、より詳しく標準入出力のバッファリングについて見ていくことにしよう。 まず、そもそも標準入出力のバッファリングとは何だろうか? それには、まず前提として「アンバッファド入出力」について理解する必要がある。

アンバッファド入出力というのは read(2) や write(2) といった、入出力を司るシステムコールのことを指す。 システムコールというのは OS のカーネルがユーザ空間に提供している API のこと。 何らかの入出力をしたいとき、最終的にはこのアンバッファド入出力のシステムコールを呼び出すことになる。 その結果として、端末に文字を書き出したりファイルの内容を読み出したりできるというわけ。 そして、アンバッファド入出力はその名の通りバッファリングされていない。 つまり、呼び出されたら直後に入出力が実際に実行されることになる。

しかし、標準入出力を扱う上でユーザ空間のプログラムがアンバッファド入出力のシステムコールを直接使うことはあまりない。 なぜかというと、入出力の内容をバッファリングした方が効率が良いから。 アンバッファド入出力の呼び出し回数は、なるべく少ない方が計算機資源の節約になる。 そのため、一旦読み書きする内容をメモリにバッファしておいて、適切なタイミングでアンバッファド入出力を呼び出すことになる。 この作業をする存在のことを「標準入出力ライブラリ」と呼ぶ。

標準入出力ライブラリのバッファリングモード

標準入出力ライブラリはそれぞれのプログラミング言語ごとに用意されている。 例えば C 言語の printf() 関数や scanf() 関数がそれに当たるし、Python であれば print() 関数や input() 関数になる。 ただし、実装が異なっていてもバッファリングに関しては基本的に以下の 3 つのモードが用意されているはず。

  • 完全バッファリング
    • バッファが一杯になるまでシステムコールの呼び出しがされない
  • 行単位バッファリング
    • 改行コードが出現するタイミングでシステムコールが呼び出される
  • アンバッファド
    • バッファリングせずにシステムコールが呼び出される

大抵の場合は、デフォルトで上記が状況に応じて自動的に選ばれる。 例えば Python では、その選択方法について open() 関数の buffering オプションのところで説明されている。

2. 組み込み関数 — Python 3.6.3 ドキュメント

どうやら、端末につながっているものについては行単位バッファリングに、それ以外は完全バッファリングになるようだ。 そして、端末につながっているかどうかはファイルオブジェクトの isatty() メソッドで確認できる。

バッファリングモードが変わっていることを確認する

ここまでで、コマンドの入出力が端末につながっているか否かで、そのバッファリングモードが変わることが分かった。 そして、端末につながっているかどうかの判断方法はファイルオブジェクトの isatty() メソッドで確認できるらしい。

なので、次はコマンドをそのまま実行するときとパイプをつないだときで isatty() メソッドの結果が変わることを確認してみよう。 標準入出力のファイルオブジェクトは sys モジュールから取得できるので、その内容を確認すれば良い。 ただしパイプをつなぐので書き出す先は標準エラー出力にする。

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

from __future__ import print_function

import sys


def main():
    # 標準出力が端末につながっているかを調べる
    print(sys.stdout.isatty(), file=sys.stderr)


if __name__ == '__main__':
    main()

上記を保存して、まずはそのまま実行してみよう。

$ python stdouttty.py 
True

標準出力の isatty()True となることが分かった。 つまり、この状態では行単位バッファリングが使われている。

では、続いてパイプをつないでみよう。

$ python stdouttty.py | head
False

今度は False になった。 つまり、完全バッファリングになっている。 この状態ではバッファが一杯になるまで後続のコマンドにはデータが流れない。

システムコールから確認してみる

もう一つ、システムコールからも挙動の違いを確認してみよう。 直接端末に書き出すときと、パイプにつないだときはアンバッファド入出力のシステムコールの呼び出され方が変わるはず。

それにはまず、最初の連番を出すサンプルコードに手を加えて最初にプロセス番号を出力するようにしておこう。 先ほどと同じように、書き出す先は標準エラー出力にする。

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

from __future__ import print_function

import itertools
import os
import sys
import time


def main():
    # 自信のプロセス識別番号を標準エラー出力に書き出す
    print(os.getpid(), file=sys.stderr)

    c = itertools.count()
    for i in c:
        print(i)
        time.sleep(1)


if __name__ == '__main__':
    main()

まずは、そのまま実行しよう。 今回はプロセス番号 16750 で起動したことが分かる。

$ python pid.py 
16750
0
1
2
3
4
5
...

macOS であれば dtruss コマンドでプロセスが発行するシステムコールを追跡できる。 すると、定期的に write() システムコールが呼び出されていることが分かる。

$ sudo dtruss -p 16750
SYSCALL(args)        = return
write(0x1, "53\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
write(0x1, "54\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
write(0x1, "55\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
write(0x1, "56\n\0", 0x3)      = 3 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A9CEFA8)        = 0 0
...(省略)

行単位でアンバッファド入出力のシステムコールが呼ばれていることが分かった。

続いてはパイプをつないで実行しよう。 今度はプロセス番号 17027 で起動した。

$ python pid.py | head
17027
...

さっきと同じように dtruss コマンドでプロセスのシステムコールを追跡する。 すると、今度は write() システムコールが発行されていないことが分かる。

$ sudo dtruss -p 17027
SYSCALL(args)        = return
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
select(0x0, 0x0, 0x0, 0x0, 0x7FFF5A317FA8)        = 0 0
...(省略)

つまり、アンバッファド入出力のシステムコールは呼ばれていない。 内容はずっとバッファリングされてしまっているようだ。

バッファリングモードを指定する

ここまでで、パイプを使ったときそのままでは標準出力が完全バッファリングモードになってしまうことが分かった。 これではバッファが一杯になるまで後続にデータが流れず head コマンドの実行に影響を与えてしまう。

対応方法はいくつか考えられる。 まず、最も簡単なのは Python のインタプリタを -u オプションをつけて起動すること。 そうすると標準出力と標準エラー出力がアンバッファドモードになる。 これは python コマンドの help から確認できる。

$ python --help | grep -A 2 unbuffered
-u     : unbuffered binary stdout and stderr, stdin always buffered;
         also PYTHONUNBUFFERED=x
         see man page for details on internal buffering relating to '-u'

実際に -u オプションを付けて実行してみよう。 すると、すぐに実行結果が得られることが分かる。

$ python -u count.py | head -n 1
0
Traceback (most recent call last):
  File "count.py", line 19, in <module>
    main()
  File "count.py", line 13, in main
    print(i)
BrokenPipeError: [Errno 32] Broken pipe

ただし、Python の BrokenPipeError 例外も同時に上がってしまっているようだ。 この例外は、head コマンドによって標準出力が途中で閉じられたことに起因している。

対応方法としては、プログラム側で対処しない方法であれば標準エラー出力の内容を捨ててやれば良い。 ただし、これはお行儀の悪いやり方。

$ python -u count.py 2>/dev/null | head -n 1
0

ハンドリングされない例外が残っているのはバグなので、本来はプログラム側で対処すべき問題。 次のように IOError を拾って errno の数値で Broken pipe を検出すると良い。

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

import itertools
import time
import sys


def main():
    # 整数の連番を返すイテレータ
    c = itertools.count()
    for i in c:
        # イテレータから得られた数値を標準出力に書き出す
        print(i)
        # 書いたら 1 秒待つ
        time.sleep(1)


if __name__ == '__main__':
    try:
        main()
    except IOError as e:
        # SIGPIPE
        if e.errno == 32:
            # Broken pipe
            sys.exit(1)
        else:
            raise

ちなみに BrokenPipeError を直接使ってしまうと Python 2 にはない例外なのでバージョン互換性を失ってしまう。

今度は例外にならずに実行できる。

$ python -u count.py | head -n 1
0

もう一つの対応方法としては、手動で標準出力のバッファリングモードを指定し直すことが考えられる。 具体的には、標準出力を改めて行単位バッファリングモードで開き直せば良い。

次のサンプルコードでは、標準出力を行単位バッファリングモードで開き直している。 その他の点については、最初に示した整数を連番で書き出すものと変わっていない。

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

from __future__ import print_function

import itertools
import time
import sys


def main():
    stdout_fd = sys.stdout.fileno()
    linebuf_stdout = open(stdout_fd, mode='w', buffering=1)

    c = itertools.count()
    for i in c:
        print(i, file=linebuf_stdout)
        time.sleep(1)


if __name__ == '__main__':
    try:
        main()
    except IOError as e:
        # SIGPIPE は IOError になるので対処する
        if e.errno == 32:
            # Broken pipe
            sys.exit(1)
        else:
            raise

これも問題なく実行できる。

$ python linebuf.py | head -n 1
0

最後のやり方は、標準出力に一行書くごとにバッファをフラッシュしてやるというもの。

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

import itertools
import time
import sys


def main():
    # 整数の連番を返すイテレータ
    c = itertools.count()
    for i in c:
        # イテレータから得られた数値を標準出力に書き出す
        print(i)
        # 標準出力のバッファをフラッシュする
        sys.stdout.flush()
        # 書いたら 1 秒待つ
        time.sleep(1)


if __name__ == '__main__':
    try:
        main()
    except IOError as e:
        # SIGPIPE
        if e.errno == 32:
            # Broken pipe
            sys.exit(1)
        else:
            raise

これも上手くいく。 ただし、このやり方だと例外を拾っても次のようなエラー出力が出てしまう。

$ python count.py | head -n 1
0
Exception ignored in: <_io.TextIOWrapper name='<stdout>' mode='w' encoding='UTF-8'>
BrokenPipeError: [Errno 32] Broken pipe

結局標準エラー出力を捨てることになるので、ちょっとイマイチな感じ。

$ python count.py 2>/dev/null | head -n 1
0

まとめ

今回は Unix における標準入出力のバッファリングについて調べた。 まず、入出力を司る read(2) や write(2) といったシステムコールは「アンバッファド入出力」といってバッファリングがされない。 それらを標準入出力で使うとき、効率的に呼び出すために内容をメモリにバッファリングする標準入出力ライブラリがある。 標準入出力ライブラリのバッファリングには「完全バッファリング」「行単位バッファリング」「アンバッファド」の 3 つがある。 標準入出力ライブラリの実装としては、パイプを使うと完全バッファリングモードになるものが多い。 そのため、head コマンドと組み合わせて使うと結果がなかなか返ってこないといったことが起こりうる。 これを防ぐには手動で標準出力を行単位バッファリングモードに指定するか、あるいは行単位でバッファをフラッシュすると良い。

ちなみに、ここらへんの話は「詳解UNIXプログラミング」に詳しく書かれている。 ちょっと高いけど、この本は Unix でプログラミングをするなら必ず読んだ方が良い、というくらいの名著だと思う。

詳解UNIXプログラミング 第3版

詳解UNIXプログラミング 第3版

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

めでたしめでたし。