CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: LightGBM 開発環境メモ

最近 LightGBM にコントリビューションする機会を得たので、その際に調べたことの備忘録を残しておく。 現時点では、Python 周りの開発環境についてドキュメントは特に見当たらないようだった。 以下は CI 環境のスクリプトやエラーメッセージを読みながら雰囲気で作ったもの。 そのため、あまり信用せずオフィシャルなところは本家のリポジトリで確認してほしい。

使った環境は次のとおり。

$ sw_vers                            
ProductName:    Mac OS X
ProductVersion: 10.15.5
BuildVersion:   19F101
$ python -V                      
Python 3.7.7

下準備

拡張モジュールやドキュメントのビルドに必要なパッケージをあらかじめインストールしておく。

$ brew install cmake doxygen

そして、ソースコードのリポジトリをチェックアウトする。

$ git clone https://github.com/microsoft/LightGBM.git
$ cd LightGBM

Python のテストを通す

今回は手を入れるのが Python binding 部分だったので、ひとまず Python のユニットテストを通す必要がある。

まずはテストに必要なパッケージをインストールする。

$ pip install pandas psutil scipy

続いて、Python binding のあるディレクトリに移動する。

$ cd LightGBM/python-package

LightGBM の本体といえる拡張モジュール (lib_lightgbm.so) をビルドした上で、開発モードで LightGBM をインストールする。

$ python setup.py bdist develop

ユニットテストのあるディレクトリに移動する。

$ cd ../tests/python_package_test 

テストランナーを実行して、ユニットテストがパスすることを確認する。

$ python -m unittest discover -v
...
test_stacking_regressor (test_sklearn.TestSklearn) ... ok
test_xendcg (test_sklearn.TestSklearn) ... ok

----------------------------------------------------------------------
Ran 107 tests in 146.355s

OK (skipped=5)

ドキュメントをビルドする

続いて、API に修正があった場合にはドキュメントにも手を入れる必要がある。 LightGBM では Sphinx を使ってドキュメントを書いている。

まずはドキュメントのディレクトリに移動する。

$ cd ../../docs/

ドキュメントをビルドするのに必要なパッケージをインストールする。

$ pip install -r requirements.txt

あとは通常の Sphinx の手順でターゲットを指定してドキュメントをビルドする。

$ make html

うまくいけば _build ディレクトリ以下に成果物ができるので修正した内容を確認する。

$ ls _build/html   
Advanced-Topics.html        Python-Intro.html
C-API.html          Quick-Start.html
Development-Guide.html      README.html
Experiments.html        _images
FAQ.html            _modules
Features.html           _sources
GPU-Performance.html        _static
GPU-Targets.html        gcc-Tips.html
GPU-Tutorial.html       genindex.html
GPU-Windows.html        index.html
Installation-Guide.html     objects.inv
Parallel-Learning-Guide.html    pythonapi
Parameters-Tuning.html      search.html
Parameters.html         searchindex.js
Python-API.html

いじょう。

xargs(1) でシェル関数を使いたい

コマンドラインの処理を並列実行したいときなどに使う xargs(1) だけど、引数にシェル関数を使おうとすると少し工夫する必要がある。 工夫しない場合に失敗する理由から説明しているので、うまくいくやり方だけ知りたいときは下までスクロールしてもらえると。

使った環境は次のとおり。 シェルとしては bash を想定する。 なお、xargs(1) は GNU 版か BSD 版かは問わない。

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

とりあえず失敗させてみる

たとえば、次のように greet() という名前でシェル関数を定義しておく。

$ greet() { echo "Hello, $1"; }
$ greet "World"
Hello, World

そして、特に何も考えず上記で定義した関数を xargs(1) 経由で実行しようとすると、次のようにエラーとなる。

$ echo "World" | xargs -I {} greet {}
xargs: greet {}: No such file or directory

コマンドが失敗する理由について

上記でコマンドが失敗する理由は複数ある。 まず、xargs(1) の基本的な動作原理は、プロセスを fork(2) して exec*(2) することに相当する 1 。 この動作原理から、少なくとも xargs(1) の引数は実行可能ファイルが先頭に来る必要があることがわかる。 シェル関数はあくまでシェルの中で利用できるものなので、まずはここが問題となる。

では、次のように bash をインラインで実行すればどうだろうか。 だいぶ確信には迫っているものの、まだ足りないのでエラーになる。

$ echo "World" | xargs -I {} bash -c "greet {}"
bash: greet: command not found

原因の 2 つ目は、xargs(1) 経由で実行したシェルの中でシェル関数が有効ではない点。 そのため、次のように export することでサブプロセスのシェルでもシェル関数が有効であるようにしなければいけない。

$ export -f greet

ようするに、xargs(1) は関係なくインラインで bash を実行したときに、ちゃんとシェル関数が使えるようになっている必要がある。

$ bash -c "greet 'World'"
Hello, World

うまくいくやり方

ということで、うまくいくやり方は次のとおり。

$ greet() { echo "Hello, $1"; }
$ export -f greet
$ echo "World" | xargs -I {} bash -c "greet {}"
Hello, World

いじょう。


  1. 少なくとも GNU 版の実装はそうなっているようだった

Python: 画像データをフーリエ変換して周波数領域で扱ってみる

フーリエ変換は音声データに対して用いられることが多い手法だけど、画像データにも応用が効く。 音声データの場合、フーリエ変換を使うことで時間領域の情報を周波数領域の情報に直せる。 それに対し、画像データでは空間領域の情報を周波数領域の情報に直すことになる。 つまり、画像データの濃淡が複数の波形の合成によって作られていると見なす。 今回は、画像データをフーリエ変換して、周波数領域の情報にフィルタをかけたり元に戻したりして遊んでみる。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ python -V        
Python 3.7.7

下準備

あらかじめ、必要なパッケージをインストールしておく。

$ pip install pillow matplotlib

周波数領域の情報を可視化してみる

最初に、画像データをフーリエ変換して周波数領域で可視化してみる。 画像データは、二次元の NumPy 配列として読み込んだ上で np.fft.fft2() に渡すことでフーリエ変換できる。 また、周波数領域のデータは np.fft.ifft2() を使うことで空間領域のデータに戻せる。

以下のサンプルコードでは、元の画像と、周波数領域でのパワースペクトル、そして逆変換することで元に戻した画像を可視化している。 なお、読み込む画像は適当に用意しよう。

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

from PIL import Image
import numpy as np
from matplotlib import pyplot as plt


def main():
    # 画像を読み込む
    img = Image.open('lena.png')
    # グレイスケールに変換する
    gray_img = img.convert('L')
    # NumPy 配列にする
    f_xy = np.asarray(gray_img)

    # 2 次元高速フーリエ変換で周波数領域の情報を取り出す
    f_uv = np.fft.fft2(f_xy)
    # 画像の中心に低周波数の成分がくるように並べかえる
    shifted_f_uv = np.fft.fftshift(f_uv)

    # パワースペクトルに変換する
    magnitude_spectrum2d = 20 * np.log(np.absolute(shifted_f_uv))

    # 元の並びに直す
    unshifted_f_uv = np.fft.fftshift(shifted_f_uv)
    # 2 次元逆高速フーリエ変換で空間領域の情報に戻す
    i_f_xy = np.fft.ifft2(unshifted_f_uv).real  # 実数部だけ使う

    # 上記を画像として可視化する
    fig, axes = plt.subplots(1, 3, figsize=(8, 4))
    # 枠線と目盛りを消す
    for ax in axes:
        for spine in ax.spines.values():
            spine.set_visible(False)
        ax.set_xticks([])
        ax.set_yticks([])
    # 元画像
    axes[0].imshow(f_xy, cmap='gray')
    axes[0].set_title('Input Image')
    # 周波数領域のパワースペクトル
    axes[1].imshow(magnitude_spectrum2d, cmap='gray')
    axes[1].set_title('Magnitude Spectrum')
    # FFT -> IFFT した画像
    axes[2].imshow(i_f_xy, cmap='gray')
    axes[2].set_title('Reversed Image')
    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

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

$ python imgfft2d.py

すると、次のようなグラフが得られる。

f:id:momijiame:20200616180009p:plain
FFT -> IFFT

真ん中のプロットが、元となった画像データの周波数領域での表現になる。 この表現では、中心に近いほど低い周波数・遠いほど高い周波数の成分を含む。 白い部分ほど成分が多いことを示しているため、この画像は低周波の成分が比較的多いように見える。 また、右の画像を見ると、ちゃんと周波数領域のデータから空間領域のデータに復元できていることがわかる。

ローパスフィルタ (Low Pass Filter) をかけて復元してみる

先ほど述べた通り、周波数領域の表現では中心に近いほど低い周波数・遠いほど高い周波数の成分を含んでいる。 つまり、中心部分だけを取り出すような演算をすると、画像を構成する波形にローパスフィルタをかけることができる。

以下のサンプルコードでは、周波数領域のデータに対して中心部分を取り出すフィルタをかけることで低周波成分だけを抽出した。 そして、フィルタしたデータを逆フーリエ変換することで画像に戻している。

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

from PIL import Image
from PIL import ImageDraw
import numpy as np
from matplotlib import pyplot as plt


def main():
    # 画像を読み込む
    img = Image.open('lena.png')
    # グレイスケールに変換する
    gray_img = img.convert('L')
    # NumPy 配列にする
    f_xy = np.asarray(gray_img)

    # 2 次元高速フーリエ変換で周波数領域の情報を取り出す
    f_uv = np.fft.fft2(f_xy)
    # 画像の中心に低周波数の成分がくるように並べかえる
    shifted_f_uv = np.fft.fftshift(f_uv)

    # フィルタ (ローパス) を用意する
    x_pass_filter = Image.new(mode='L',  # 8-bit pixels, black and white
                              size=(shifted_f_uv.shape[0],
                                    shifted_f_uv.shape[1]),
                              color=0,  # default black
                              )
    # 中心に円を描く
    draw = ImageDraw.Draw(x_pass_filter)
    # 円の半径
    ellipse_r = 50
    # 画像の中心
    center = (shifted_f_uv.shape[0] // 2,
              shifted_f_uv.shape[1] // 2)
    # 円の座標
    ellipse_pos = (center[0] - ellipse_r,
                   center[1] - ellipse_r,
                   center[0] + ellipse_r,
                   center[1] + ellipse_r)
    draw.ellipse(ellipse_pos, fill=255)
    # フィルタ
    filter_array = np.asarray(x_pass_filter)

    # フィルタを適用する
    filtered_f_uv = np.multiply(shifted_f_uv, filter_array)

    # パワースペクトルに変換する
    magnitude_spectrum2d = 20 * np.log(np.absolute(filtered_f_uv))

    # 元の並びに直す
    unshifted_f_uv = np.fft.fftshift(filtered_f_uv)
    # 2 次元逆高速フーリエ変換で空間領域の情報に戻す
    i_f_xy = np.fft.ifft2(unshifted_f_uv).real  # 実数部だけ使う

    # 上記を画像として可視化する
    fig, axes = plt.subplots(1, 4, figsize=(12, 4))
    # 枠線と目盛りを消す
    for ax in axes:
        for spine in ax.spines.values():
            spine.set_visible(False)
        ax.set_xticks([])
        ax.set_yticks([])
    # 元画像
    axes[0].imshow(f_xy, cmap='gray')
    axes[0].set_title('Input Image')
    # フィルタ画像
    axes[1].imshow(filter_array, cmap='gray')
    axes[1].set_title('Filter Image')
    # フィルタされた周波数領域のパワースペクトル
    axes[2].imshow(magnitude_spectrum2d, cmap='gray')
    axes[2].set_title('Filtered Magnitude Spectrum')
    # FFT -> Band-pass Filter -> IFFT した画像
    axes[3].imshow(i_f_xy, cmap='gray')
    axes[3].set_title('Reversed Image')
    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python imglpf.py

すると、次のようなグラフが得られる。 分かりにくいかもしれないけど、復元した画像はちょっとぼやけている。 また、ぼやけ方も縞模様になっていて波を感じるものとなった。 これは、画像を構成するすべての周波数の中から、高周波の成分がフィルタによって取り除かれたことで生じている。

f:id:momijiame:20200616180053p:plain
FFT -> LPF -> IFFT

中心のごく一部だけを取り出している (つまり、多くの情報が失われている) のに、それっぽい画像になるのはなんとも面白い。 なお、フィルタに使う円の半径を小さくすれば、それだけ高周波の成分が少なくなって、より分かりやすくぼやける。

ハイパスフィルタ (High Pass Filter) をかけて復元してみる

次は、同様に高周波成分だけを取り出すハイパスフィルタをかけてみよう。 つまり、中心だけを取り除くことになる。 先ほどとのコードの違いは、適用するフィルタの周波数特性が違うだけ。

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

from PIL import Image
from PIL import ImageDraw
import numpy as np
from matplotlib import pyplot as plt


def main():
    # 画像を読み込む
    img = Image.open('lena.png')
    # グレイスケールに変換する
    gray_img = img.convert('L')
    # NumPy 配列にする
    f_xy = np.asarray(gray_img)

    # 2 次元高速フーリエ変換で周波数領域の情報を取り出す
    f_uv = np.fft.fft2(f_xy)
    # 画像の中心に低周波数の成分がくるように並べかえる
    shifted_f_uv = np.fft.fftshift(f_uv)

    # フィルタ (ハイパス) を用意する
    x_pass_filter = Image.new(mode='L',  # 8-bit pixels, black and white
                              size=(shifted_f_uv.shape[0],
                                    shifted_f_uv.shape[1]),
                              color=255,  # default white
                              )
    # 中心に円を描く
    draw = ImageDraw.Draw(x_pass_filter)
    # 円の半径
    ellipse_r = 50
    # 画像の中心
    center = (shifted_f_uv.shape[0] // 2,
              shifted_f_uv.shape[1] // 2)
    # 円の座標
    ellipse_pos = (center[0] - ellipse_r,
                   center[1] - ellipse_r,
                   center[0] + ellipse_r,
                   center[1] + ellipse_r)
    draw.ellipse(ellipse_pos, fill=0)
    # フィルタ
    filter_array = np.asarray(x_pass_filter)

    # フィルタを適用する
    filtered_f_uv = np.multiply(shifted_f_uv, filter_array)

    # パワースペクトルに変換する
    magnitude_spectrum2d = 20 * np.log(np.absolute(filtered_f_uv))

    # 元の並びに直す
    unshifted_f_uv = np.fft.fftshift(filtered_f_uv)
    # 2 次元逆高速フーリエ変換で空間領域の情報に戻す
    i_f_xy = np.fft.ifft2(unshifted_f_uv).real  # 実数部だけ使う

    # 上記を画像として可視化する
    fig, axes = plt.subplots(1, 4, figsize=(12, 4))
    # 枠線と目盛りを消す
    for ax in axes:
        for spine in ax.spines.values():
            spine.set_visible(False)
        ax.set_xticks([])
        ax.set_yticks([])
    # 元画像
    axes[0].imshow(f_xy, cmap='gray')
    axes[0].set_title('Input Image')
    # フィルタ画像
    axes[1].imshow(filter_array, cmap='gray')
    axes[1].set_title('Filter Image')
    # フィルタされた周波数領域のパワースペクトル
    axes[2].imshow(magnitude_spectrum2d, cmap='gray')
    axes[2].set_title('Filtered Magnitude Spectrum')
    # FFT -> Band-pass Filter -> IFFT した画像
    axes[3].imshow(i_f_xy, cmap='gray')
    axes[3].set_title('Reversed Image')
    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記を保存して実行する。

$ python imghpf.py

すると、今度は次のようなグラフが得られる。 またもや分かりにくいけど、復元した画像は元の画像の輪郭だけがぼんやりと浮かび上がったものとなっている。 これが画像を構成する高周波の成分で、先ほどの復元画像から取り除かれたものと解釈できる。

f:id:momijiame:20200616180116p:plain
FFT -> HPF -> IFFT

カラー画像を扱う場合

なお、カラー画像を扱う場合、それぞれのチャネルごとに処理する必要がある。 以下のサンプルコードでは、最初のサンプルコードを RGB のチャネルでそれぞれ適用している。

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

from itertools import chain

from PIL import Image
import numpy as np
from matplotlib import pyplot as plt


def main():
    # 画像を読み込む
    img = Image.open('lena.png')
    # NumPy 配列にする
    f_xy_rgb = np.asarray(img)

    # 画像として可視化する
    fig, axes = plt.subplots(3, 5, figsize=(12, 6))
    # 枠線と目盛りを消す
    for ax in chain.from_iterable(axes):
        for spine in ax.spines.values():
            spine.set_visible(False)
        ax.set_xticks([])
        ax.set_yticks([])

    # 元画像
    axes[1][0].imshow(f_xy_rgb)
    axes[1][0].set_title('Input Image (RGB)')

    # 逆変換した画像を入れる領域を用意しておく
    i_f_xy_rgb = np.empty_like(f_xy_rgb)
    # RGB ごとに処理する
    channel_names = ('R', 'G', 'B')
    channel_indices = range(f_xy_rgb.shape[2])
    zipped_channel_info = zip(channel_names, channel_indices)
    for channel_name, channel_index in zipped_channel_info:
        # 各チャネルごとに配列として取り出す
        f_xy = f_xy_rgb[:, :, channel_index]

        # 各チャネルの元画像
        axes[channel_index][1].imshow(f_xy, cmap='gray')
        axes[channel_index][1].set_title(f'Input Image ({channel_name})')

        # 2 次元高速フーリエ変換で周波数領域の情報を取り出す
        f_uv = np.fft.fft2(f_xy)
        # 画像の中心に低周波数の成分がくるように並べかえる
        shifted_f_uv = np.fft.fftshift(f_uv)
        # パワースペクトルに変換する
        magnitude_spectrum2d = 20 * np.log(np.absolute(shifted_f_uv))
        # 元の並びに直す
        unshifted_f_uv = np.fft.fftshift(shifted_f_uv)
        # 2 次元逆高速フーリエ変換で空間領域の情報に戻す
        i_f_xy = np.fft.ifft2(unshifted_f_uv).real  # 実数部だけ使う

        # 周波数領域のパワースペクトル
        axes[channel_index][2].imshow(magnitude_spectrum2d, cmap='gray')
        axes[channel_index][2].set_title(f'Magnitude Spectrum ({channel_name})')

        # FFT -> Band-pass Filter -> IFFT した画像
        axes[channel_index][3].imshow(i_f_xy, cmap='gray')
        axes[channel_index][3].set_title(f'Reversed Image ({channel_name})')

        # 逆変換したチャネルを保存しておく
        i_f_xy_rgb[:, :, channel_index] = i_f_xy

    # 逆変換した RGB 画像
    axes[1][4].imshow(i_f_xy_rgb)
    axes[1][4].set_title('Reversed Image (RGB)')

    # グラフを表示する
    plt.show()


if __name__ == '__main__':
    main()

上記を保存して実行する。

$ python imgfftc.py

すると、次のようなグラフが得られる。

f:id:momijiame:20200616180139p:plain
FFT (RGB) -> IFFT (RGB)

ちゃんと RGB それぞれのチャネルの情報から、カラー画像が復元できていることがわかる。

めでたしめでたし。

Python: UMAP を使ってみる

UMAP (Uniform Manifold Approximation and Projection) は次元削減手法のひとつ。 似た手法としては t-SNE (t-distributed Stochastic Neighbor Embedding) があるけど、それよりも高速らしい。 公式のベンチマークが以下で紹介されていて、t-SNE に比べるとスケーラビリティに優れていることが伺える。

umap-learn.readthedocs.io

使った環境は次のとおり。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ python -V                          
Python 3.7.7
$ pip list | grep -i umap            
umap-learn                0.4.4

下準備

$ pip install umap-learn scikit-learn

Digit データセットを次元削減してみる

scikit-learn に付属している数字の画像データセットを使って動作を確認してみよう。 UMAP は scikit-learn に準拠したインターフェースを提供している。 以下のサンプルコードでは、元が 8 x 8 ピクセルから成る 64 次元の画像データを、UMAP で 2 次元にまで削減している。

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

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


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

    # 次元削減する
    mapper = umap.UMAP(random_state=0)
    embedding = mapper.fit_transform(X)

    # 結果を二次元でプロットする
    embedding_x = embedding[:, 0]
    embedding_y = embedding[:, 1]
    for n in np.unique(y):
        plt.scatter(embedding_x[y == n],
                    embedding_y[y == n],
                    label=n)

    # グラフを表示する
    plt.grid()
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記を保存して実行する。

$ python digitsumap.py

すると、次のようなグラフが得られる。

f:id:momijiame:20200613011251p:plain

t-SNE をはじめて使ったときも思ったけど、教師なしでここまで同じラベルのデータがまとまるのは不思議。

t-SNE も試してみる

一応、比較対象として t-SNE も試しておく。 以下のサンプルコードは、基本的に先ほどのコードで umap.UMAP()sklearn.manifold.TSNE に変えただけ。

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

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


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

    # 次元削減する
    mapper = manifold.TSNE(random_state=0)
    embedding = mapper.fit_transform(X)

    # 結果を二次元でプロットする
    embedding_x = embedding[:, 0]
    embedding_y = embedding[:, 1]
    for n in np.unique(y):
        plt.scatter(embedding_x[y == n],
                    embedding_y[y == n],
                    label=n)

    # グラフを表示する
    plt.grid()
    plt.legend()
    plt.show()


if __name__ == '__main__':
    main()

上記を保存して実行する。

$ digitstsne.py

すると、次のようなグラフが得られる。

f:id:momijiame:20200613011625p:plain

おそらく学習に使うパラメータにもよるだろうけど、UMAP の方がぎゅっとまとまってる感じかな。

いじょう。

Python: XGBoost の cv() 関数から学習済みモデルを取り出す

今回は、以下のエントリを XGBoost で焼き直したもの。 つまり、XGBoost でも cv() 関数から学習済みモデルを取り出して Fold Averaging してみようという話。

blog.amedama.jp

使った環境は次のとおり。

$ sw_vers                   
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ python -V              
Python 3.7.7
$ pip list | grep xgboost               
xgboost                    1.1.1

 下準備

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

$ pip install xgboost scikit-learn numpy

学習済みモデルを取り出して Fold Averaging してみる

早速、以下にサンプルコードを示す。 乳がんデータセットをホールドアウトして、一方のデータで学習して、他方のデータを Fold Averaging している。

実装方法としては、LightGBM と同じようにコールバックを使って学習済みモデルへの参照を引っ張り出した。 cv() 関数のコールバックには cvfolds というパラメータ名で xgboost.training.CVPack のリストが渡される。 あとは CVPack#bst という名前で Booster オブジェクトにアクセスするだけ。

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

import inspect

import numpy as np
import xgboost as xgb
from sklearn import datasets
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import train_test_split


class ModelExtractionCallback:
    """XGBoost の cv() 関数から学習済みモデルを取り出すためのコールバック"""

    def __init__(self):
        self.cvfolds = None

    def __call__(self, env):
        # コールバックの呼び出しで xgboost.training.CVPack のリストが得られる
        if self.cvfolds is None:
            self.cvfolds = env.cvfolds


class BoostersProxy:
    """コールバックから得られた CVPack のリストを使って Fold Averaging をやりやすくするクラス

    実用上は ModelExtractionCallback とニコイチしちゃっても良いけど一応分けた"""

    def __init__(self, cvfolds):
        self._cvfolds = cvfolds

    def __getattr__(self, name):
        def _wrap(*args, **kwargs):
            ret = []
            for cvpack in self._cvfolds:
                # それぞれの Booster から指定されたアトリビュートを取り出す
                attr = getattr(cvpack.bst, name)
                if inspect.ismethod(attr):
                    # オブジェクトがメソッドなら呼び出した結果を入れる
                    res = attr(*args, **kwargs)
                    ret.append(res)
                else:
                    # それ以外ならそのまま入れる
                    ret.append(attr)
            return ret
        return _wrap


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

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

    # XGBoost のデータセット表現に直す
    dtrain = xgb.DMatrix(X_train, label=y_train)
    dtest = xgb.DMatrix(X_test, label=y_test)

    # データの分割に使うコンテキスト
    folds = StratifiedKFold(n_splits=5,
                            shuffle=True,
                            random_state=42)
    # 学習に使うパラメータ
    xgb_params = {
        'objective': 'binary:logistic',
        'eval_metric': 'logloss',
    }

    # モデルを取り出すのに使うコールバック
    extraction_cb = ModelExtractionCallback()
    callbacks = [
        extraction_cb,
    ]

    # 交差検証する
    xgb.cv(xgb_params,
           dtrain,
           num_boost_round=1000,
           early_stopping_rounds=100,
           folds=folds,
           verbose_eval=True,
           # コールバックを渡す
           callbacks=callbacks,
           )

    # コールバックから学習済みモデルを取り出してプロキシにくべる
    proxy = BoostersProxy(cvfolds=extraction_cb.cvfolds)

    # ホールドアウトしておいた検証データを Fold Averaging で予測する
    y_pred_proba_list = proxy.predict(dtest)
    y_pred_proba_avg = np.array(y_pred_proba_list).mean(axis=0)
    y_pred = np.where(y_pred_proba_avg > 0.5, 1, 0)
    accuracy = accuracy_score(y_test, y_pred)
    print('Fold averaging accuracy:', accuracy)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python xgbcv.py 
[0]    train-logloss:0.46506+0.00234  test-logloss:0.48322+0.01040
[1]    train-logloss:0.33602+0.00369  test-logloss:0.36709+0.01240
[2]    train-logloss:0.25204+0.00529  test-logloss:0.29438+0.01717

...

[130]  train-logloss:0.00727+0.00008  test-logloss:0.11461+0.07024
[131]  train-logloss:0.00727+0.00008  test-logloss:0.11468+0.07019
[132]  train-logloss:0.00727+0.00008  test-logloss:0.11473+0.07017
Fold averaging accuracy: 0.9627659574468085

ホールドアウトしたデータを Fold Averaging で予測して約 0.962 という Accuracy スコアが得られた。

いじょう。

kubectl の複数の設定ファイルを一つにマージする

何度も調べることになりそうなのでメモしておく。 kubectl で複数の設定ファイルがあるときに、ひとつにまとめる方法について。

使った環境は次のとおり。

$ sw_vers                  
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G5033
$ kubectl version --short=true
Client Version: v1.18.3
Server Version: v1.18.2

まとめ方

はじめに、まとめたいコンフィグのファイルパスを、コロン (:) 区切りで KUBECONFIG というシェル変数に列挙する。

$ KUBECONFIG=<config-src1>:<config-src2>:...

あとは kubectl config view --flatten コマンドを使えばひとつのコンフィグにまとめることができる。 リダイレクトでファイルに書き出して、使っているコンフィグと入れ替えればオッケー。

$ kubectl config view --flatten > <config-dst>

いじょう。

4K ディスプレイを導入するときは HDMI ケーブルに注意が必要

今回は、自宅で 4K ディスプレイを導入した際に HDMI ケーブルを起因とするトラブルが起きた話について。

生じた問題について

我が家では、自宅の作業環境を改善する一環として半年ほど前に 4K ディスプレイを導入した。 そして、普段使っているパソコンとは HDMI ケーブルを介して接続することにした。 その際に、自宅にあった適当な HDMI ケーブルを使ってつないでしまったのがまずかった。

具体的には、次のような問題が発生した。 普段はちゃんと映るのに、たまに何の前触れもなく画面が真っ暗になる (ブラックアウトする) 。 問題が発生する間隔はまちまちで、数十分発生しないかと思いきや、ひどいときは数分と待たずに連続して起こる。

結局、問題を切り分けながら HDMI ケーブルに当たりをつけて 4K 対応を謳っている製品に入れ替えたところ問題は解消した。 そして、この問題はどうやら HDMI を扱う上で意外と有名な現象らしかった。 具体的には、ケーブルの相性問題がある。

HDMI ケーブルには準拠する仕様としてのデータ転送レートがある上で、信号の減衰やノイズがのったりするとエラーが生じて画面が写りにくくなるらしい。 そして、ディスプレイの解像度やリフレッシュレートが上がると、必要とするデータ転送レートも上がるため、よりエラーの発生にシビアになる。 今回も、解像度がフル HD のディスプレイでは何の問題もなく使えるのに、4K にしたところ発生するようになった。

それまで使っていた機材を流用してディスプレイを入れ替えるときは気をつけよう。