CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Polars と NumPy / SciPy の関数を組み合わせて使う

Polars を使って数値を加工しようとすると、数学に関する API がさほど多くないことに気づく。 そうしたときに、最初に思いつくのは Series オブジェクトを NumPy 配列に変換した上で処理する方法かもしれない。 しかし、実際には Polars の Expr オブジェクトと NumPy の ufunc (ユニバーサル関数) にはインテグレーションが提供されている。 今回は Polars と NumPy や SciPy を組み合わせて使う方法について書いてみる。

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

$ sw_vers    
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68
$ python -V            
Python 3.9.16
$ pip list | egrep "(polars|numpy|scipy)"  
numpy             1.24.2
polars            0.16.14
scipy             1.10.1

もくじ

下準備

あらかじめ Polars と、オプションで利用できる NumPy をインストールする。 また、ついでに SciPy も入れておく。

$ pip install "polars[numpy]" scipy

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

$ python

サンプルとして x という名前のカラムを持った Polars の DataFrame を用意する。

>>> import polars as pl
>>> df = pl.DataFrame({"x": [-1, 0, 1, 2]})
>>> df
shape: (4, 1)
┌─────┐
│ x   │
│ --- │
│ i64 │
╞═════╡
│ -1  │
│ 0   │
│ 1   │
│ 2   │
└─────┘

Polars と NumPy を組み合わせて使う

たとえば x カラムの値について、絶対値を取って log1p したいとする。 これはあくまで例であって、処理自体に何か意味があるわけではない。

実のところ、この処理は pl.col("x") から得られる Expr オブジェクトを np.abs()np.log1p() の引数にするだけで実現できる。 なんと、これで返り値として Polars の Expr オブジェクトが得られる。

>>> import numpy as np
>>> np.log1p(np.abs(pl.col("x")))
<polars.expr.expr.Expr object at 0x1018c5ee0>

あとは、この Expr オブジェクトを DataFrame#select() とかに突っ込めばいい。

>>> df.select(np.log1p(np.abs(pl.col("x"))))
shape: (4, 1)
┌──────────┐
│ x        │
│ ---      │
│ f64      │
╞══════════╡
│ 0.693147 │
│ 0.0      │
│ 0.693147 │
│ 1.098612 │
└──────────┘

Polars と SciPy を組み合わせて使う

また、公式にサポートは名言されていないようだけど、実は SciPy の ufunc でも同じことができるようだ。

たとえば SciPy の sp.special.expit() で試してみる。

>>> import scipy as sp
>>> sp.special.expit(pl.col("x"))
<polars.expr.expr.Expr object at 0x103180fa0>

ちゃんと Expr オブジェクトが返ってくる。

先ほどと同じように DataFrame#select() に突っ込んでみる。

>>> df.select(sp.special.expit(pl.col("x")))
shape: (4, 1)
┌──────────┐
│ x        │
│ ---      │
│ f64      │
╞══════════╡
│ 0.268941 │
│ 0.5      │
│ 0.731059 │
│ 0.880797 │
└──────────┘

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

参考

pola-rs.github.io

Apple Silicon の macOS にコマンドラインで Rosetta 2 をインストールする

Apple の Rosetta 2 は、x86 アーキテクチャ向けにビルドされた macOS のアプリケーションを Apple Silicon の環境で実行できるようにするソフトウェア。 今回は、そんな Rosetta 2 をコマンドラインでインストールする方法について書いておく。

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

$ sw_vers                                            
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68
$ uname -srm
Darwin 22.3.0 arm64

もくじ

下準備

Rosetta 2 を利用するには Apple の SLA (Software License Agreement) に同意する必要がある。 そこで、あらかじめ以下にアクセスして同意できることを確認しておく。

www.apple.com

Rosetta 2 をコマンドラインでインストールする

コマンドラインから Rosetta 2 をインストールするには、以下のコマンドを利用する。 --agree-to-license オプションについては、付けなくてもインタラクティブな確認が入るだけなのでお好みで。

$ softwareupdate --install-rosetta --agree-to-license

インストールが終わったら、次のような x86 向けにビルドされたアプリケーションも開けるようになる。

$ file /Applications/Kindle.app/Contents/MacOS/Kindle 
/Applications/Kindle.app/Contents/MacOS/Kindle: Mach-O 64-bit executable x86_64
$ open /Applications/Kindle.app

いじょう。

Python: CatBoost をいくつかの環境でベンチマークしてみる

今回は GBDT (Gradient Boosting Decision Tree) フレームワークのひとつである CatBoost について、いくつかの環境で同一のソースコードを使って学習にかかる時間を比較してみた。 きっかけは、最近入手した Apple M2 Pro を搭載した Mac mini が、どれくらいの性能を出せるのか気になったため。

使った Python とパッケージのバージョンは次のとおり。

$ python -V              
Python 3.9.16
$ pip list | egrep -i "(scikit-learn|catboost)"
catboost            1.1.1
scikit-learn        1.2.2

もくじ

下準備

すべての環境で、あらかじめ CatBoost と scikit-learn をインストールしておく。

$ pip install catboost scikit-learn

その他、GPU を使う環境では CUDA Driver のインストールも必要になるけど、その部分は省略する。

ベンチマークについて

ベンチマークに使ったコードを以下に示す。 内容は、scikit-learn で二値分類のダミーデータを生成して、それを catboost.cv() で学習する。 そして、固定の 10,000 イテレーションを実行するのにかかる時間を測定する。

import contextlib
import logging
import time

import catboost
from catboost import Pool
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold

LOGGER = logging.getLogger(__name__)


@contextlib.contextmanager
def stopwatch():
    """実行にかかる時間を測定するコンテキストマネージャ"""
    start = time.time()
    LOGGER.info("start: CatBoost")

    yield

    end = time.time()
    LOGGER.info("end: CatBoost")

    elapsed = end - start
    LOGGER.info("elapsed time: %.2f sec", elapsed)


def main():
    log_fmt = (
        "%(process)d %(threadName)s %(name)s %(levelname)s %(message)s"
    )
    logging.basicConfig(level=logging.INFO, format=log_fmt)

    # ダミーデータを生成する
    args = {
        "n_samples": 100_000,
        "n_features": 100,
        "n_informative": 20,
        "n_redundant": 0,
        "n_repeated": 0,
        "class_sep": 0.65,
        "n_classes": 2,
        "random_state": 42,
        "shuffle": False,
    }
    x, y = make_classification(**args)

    # Stratified 5-Fold の交差検証を想定する
    folds = StratifiedKFold(
        n_splits=5,
        shuffle=True,
        random_state=42,
    )

    cat_train = Pool(x, label=y)
    cat_params = {
        "loss_function": "Logloss",
        "num_boost_round": 10_000,
        "metric_period": 1_000,
        # 学習に GPU を使う場合
        # "task_type": "GPU",
    }

    with stopwatch():
        catboost.cv(
            params=cat_params,
            pool=cat_train,
            folds=folds,
            # 時間を測るのが目的なので Early Stopping しない
        )


if __name__ == "__main__":
    main()

Apple M2 Pro w/ macOS

ここからは、実際にそれぞれの環境で実行していく。

まずは、きっかけとなった Apple M2 Pro のマシンから。 コア数は 12 (8 Performance Cores + 4 Efficient Cores) のモデルを使っている。 発売されたのは 2023 年になる。

環境は次のとおり。

$ sysctl -a | grep brand_string
machdep.cpu.brand_string: Apple M2 Pro
$ uname -srm
Darwin 22.3.0 arm64
$ sw_vers                      
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68

ベンチマークを実行する。

$ python benchmark.py
67539 MainThread __main__ INFO start: CatBoost
...
67539 MainThread __main__ INFO end: CatBoost
67539 MainThread __main__ INFO elapsed time: 536.84 sec

実行には 536 秒かかった。

Apple M1 w/ macOS

続いては Apple M1 を載せたモデルでを試す。 M2 Pro と比べて 1 世代古く、コア数も 8 (4 Performance Cores + 4 Efficient Cores) と少ない。 発売されたのは 2020 年になる。

$ sysctl -a | grep brand_string
machdep.cpu.brand_string: Apple M1
$ uname -srm
Darwin 22.3.0 arm64
$ sw_vers                      
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68

同様に、ベンチマークを実行する。

$ python benchmark.py 
7308 MainThread __main__ INFO start: CatBoost
...
7308 MainThread __main__ INFO end: CatBoost
7308 MainThread __main__ INFO elapsed time: 688.01 sec

こちらの環境では 688 秒かかった。

Apple M2 Pro に比べると約 28% 余計に時間がかかっている。 この差を大きいと見るか小さいと見るか。

Intel Core i7-8700B w/ macOS

続いては Intel の CPU を載せた最後の世代の Mac mini でも試す。 発売されたのは 2018 年になる。

$ sysctl -a | grep brand_string
machdep.cpu.brand_string: Intel(R) Core(TM) i7-8700B CPU @ 3.20GHz
$ uname -srm
Darwin 22.3.0 x86_64
$ sw_vers                      
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68

実行する。

14256 MainThread __main__ INFO start: CatBoost
...
14256 MainThread __main__ INFO end: CatBoost
14256 MainThread __main__ INFO elapsed time: 850.76 sec

こちらは 850 秒かかった。 どうやら CatBoost の実行に関しては Intel CPU から Apple Silicon になって順当に性能が改善しているようだ。

Intel Core i7-12700 w/ Linux

続いては、割と最近のデスクトップ向けの x86 CPU でも比較しておく。 OS には Ubuntu 20.04 LTS を使った。

環境としてはオンプレマシンの Intel Core i7-12700 を使った。 Apple Silicon と同じようにヘテロジニアスマルチコアで 12 コア (8 Performance Cores + 4 Efficient Cores) のモデル。 発売されたのは 2022 年になる。

$ head /proc/cpuinfo | grep name
model name  : 12th Gen Intel(R) Core(TM) i7-12700
$ uname -srm
Linux 5.15.0-60-generic x86_64
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.5 LTS"

なお、最近のデスクトップ向けの CPU は、同じモデルであっても供給する電力によってパフォーマンスが大きく変わってくる。 今回は PBT と MTP の両方に固定で 125W を設定している。

ベンチマークを実行する。

136812 MainThread __main__ INFO start: CatBoost
...
136812 MainThread __main__ INFO end: CatBoost
136812 MainThread __main__ INFO elapsed time: 394.81 sec

こちらは 394 秒かかった。

同じコア構成 (8 Performance Cores + 4 Performance Cores) の Apple M2 Pro と比較して約 26 % 時間が短縮されている。 ただし、Apple M2 Pro の消費電力は最大でも 60W なので約半分という点は留意する必要がある。

NVIDIA RTX 3060 w/ Linux

前述のオンプレマシンには NVIDIA RTX 3060 の GPU が載っている。 CatBoost は GPU を使った学習もできるため、試してみよう。

$ nvidia-smi
Fri Mar 10 22:00:21 2023       
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 530.30.02              Driver Version: 530.30.02    CUDA Version: 12.1     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                  Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf            Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  NVIDIA GeForce RTX 3060         On | 00000000:01:00.0 Off |                  N/A |
|  0%   50C    P8               14W / 170W|     14MiB / 12288MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+
                                                                                         
+---------------------------------------------------------------------------------------+
| Processes:                                                                            |
|  GPU   GI   CI        PID   Type   Process name                            GPU Memory |
|        ID   ID                                                             Usage      |
|=======================================================================================|
|    0   N/A  N/A      1138      G   /usr/lib/xorg/Xorg                            9MiB |
|    0   N/A  N/A      1283      G   /usr/bin/gnome-shell                          2MiB |
+---------------------------------------------------------------------------------------+

task_typeGPU に変更してベンチマークを実行する。

8379 MainThread __main__ INFO start: CatBoost
...
8379 MainThread __main__ INFO end: CatBoost
8379 MainThread __main__ INFO elapsed time: 816.01 sec

意外なことに GPU を使って学習すると 816 秒かかった。

何が意外かというと、以前であれば CatBoost は GPU を使わないと学習に長い時間がかかる印象を持っていたため。 もしかすると、最近のバージョンアップで CPU を使った学習速度に改善があったのかもしれない 1。 なお、CatBoost は CPU と GPU で使用できるパラメータやデフォルトのパラメータに差異が多い点には留意が必要になる。

NVIDIA Tesla A100 40GB w/ Linux

先ほど使ったのはコンシューマ向けで、しかもミドルレンジの GPU だった。 エンタープライズ向けのハイエンドを使ったらどうなるか気になったので、実際に試してみた。

以下の環境は Google Cloud の a2-highgpu-1g インスタンスを用意した。 NVIDIA Tesla A100 の 40GB モデルを 1 基積んで、12 vCPU と 85GB のメモリが利用できる。

$ head /proc/cpuinfo | grep name
model name  : Intel(R) Xeon(R) CPU @ 2.20GHz
$ uname -srm
Linux 5.15.0-1030-gcp x86_64
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.5 LTS"
$ nvidia-smi
Fri Mar 10 10:20:35 2023       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 510.47.03    Driver Version: 510.47.03    CUDA Version: 11.6     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  NVIDIA A100-SXM...  Off  | 00000000:00:04.0 Off |                    0 |
| N/A   34C    P0    54W / 400W |      0MiB / 40960MiB |     27%      Default |
|                               |                      |             Disabled |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

ベンチマークを実行する。

11514 MainThread __main__ INFO start: CatBoost
...
11514 MainThread __main__ INFO end: CatBoost
11514 MainThread __main__ INFO elapsed time: 1251.17 sec

実行には 1251 秒かかった。

この結果は非直感的で、オンプレマシンの NVIDIA RTX 3060 よりも時間がかかっている。 もしかすると GPU 以外の何かで律速していたり、何らかのオーバーヘッドに由来するものかもしれない。 実行中の Utilization に関しては 70 ~ 80% 前後で NVIDIA RTX 3060 と大きな違いは見られなかった 2

なお、上記のマシンは以下のコマンドで作成した。

$ gcloud compute instances create a100-vm \
    --preemptible \
    --zone=us-central1-a \
    --machine-type=a2-highgpu-1g \
    --accelerator=count=1,type=nvidia-tesla-a100 \
    --image-project=deeplearning-platform-release \
    --image-family=common-container-ubuntu-2004

まとめ

以下に、各環境ごとの実行時間をグラフで示す。 左側の 3 項目が macOS で、右側の 3 項目が Ubuntu の結果になっている。

環境ごとの実行時間 (秒)

分かったことを以下に示す。

  • Mac における CatBoost の実行性能は最近の SoC で順当に改善はしているように見える
  • ただし、性能を追い求めるならコアが多くて消費電力も大きい CPU と Linux を組み合わせた方が速い
  • 少なくとも今回のベンチマークした条件においては学習に GPU を使うメリットは薄いように見える

いじょう。


  1. 何らかの理由で GPU を使った場合の学習時間が遅くなった可能性もある
  2. ただし、消費電力が 200W もいってないので全力を出せているわけでもなさそう

Python: Polars の DataFrame をゼロコピーで Pandas の DataFrame に変換する

Polars の DataFrame は to_pandas() メソッドを使うことで Pandas の DataFrame に変換できる。 このとき、デフォルトではメモリのコピーが生じる。

pola-rs.github.io

ただし、オプションとして use_pyarrow_extension_array=True を渡すとゼロコピーで変換できる。 これは、メモリ上のデータ表現として Apache Arrow を Polars と Pandas の DataFrame で共有できるため。 今回は、この機能について実際にメモリの使用量をプロファイラで確認しながら使ってみよう。

なお、この機能には注意点もあるようだ。 具体的には、変換後の DataFrame に対して PyArrow でサポートされていない処理を実施した場合、NumPy 配列に変換した上で実行される。 つまり、オンデマンドでメモリのコピーを含むオーバーヘッドが生じることになる。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.5 LTS"
$ uname -srm
Linux 5.15.0-60-generic x86_64
$ python -V
Python 3.10.10
$ pip list | egrep "(polars|pandas)"
pandas            1.5.3
polars            0.16.9

もくじ

下準備

下準備として pandaspyarrow のオプションを有効にして Polars をインストールする。 また、memory_profiler もインストールしておこう。

$ pip install "polars[pandas,pyarrow]" memory_profiler

大きめのデータセットとして Higgs データセットをダウンロードしておく。

$ wget https://archive.ics.uci.edu/ml/machine-learning-databases/00280/HIGGS.csv.gz
$ gunzip HIGGS.csv.gz 

Polars の DataFrame を Pandas の DataFrame に変換する

まずはオプションを指定せずに pl.DataFrame#to_pandas() を実行してみよう。 実行する main() 関数は memory_profiler のプロファイル対象にする。

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

import polars as pl
from memory_profiler import profile


@profile
def main():
    # CSV を読み込んで Polars のデータフレームにする
    pl_df = pl.read_csv("HIGGS.csv")
    # Polars のデータフレームを Pandas のデータフレームに変換する
    pd_df = pl_df.to_pandas()


if __name__ == '__main__':
    main()

上記を実行する。 すると、CSV を Polars の DataFrame に読み込むタイミングでメモリの使用量が 3,412MiB 増加する。 そして、Pandas の DataFrame に変換するタイミングで、さらにメモリの使用量が 2,436MiB 増えている。

$ python withcopy.py 
Filename: /home/amedama/Documents/temporary/withcopy.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     8    100.1 MiB    100.1 MiB           1   @profile
     9                                         def main():
    10                                             # CSV を読み込んで Polars のデータフレームにする
    11   3512.4 MiB   3412.3 MiB           1       pl_df = pl.read_csv("HIGGS.csv")
    12                                             # Polars のデータフレームを Pandas のデータフレームに変換する
    13   5948.5 MiB   2436.1 MiB           1       pd_df = pl_df.to_pandas()

続いては use_pyarrow_extension_array=True を指定する。

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

import polars as pl
from memory_profiler import profile


@profile
def main():
    pl_df = pl.read_csv("HIGGS.csv")
    # PyArrow を使ってゼロコピーで Pandas の DataFrame に変換する
    pd_df = pl_df.to_pandas(use_pyarrow_extension_array=True)
    # NOTE: 後続の処理が PyArrow でサポートされていない場合は NumPy 配列に変換されることもある
    ...


if __name__ == '__main__':
    main()

上記を実行する。 すると、今度は Pandas の DataFrame に変換するタイミングでは 2.6MiB しかメモリの使用量が増えていない。

$ python zerocopy.py 
Filename: /home/amedama/Documents/temporary/zerocopy.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     8     99.2 MiB     99.2 MiB           1   @profile
     9                                         def main():
    10   3510.8 MiB   3411.6 MiB           1       pl_df = pl.read_csv("HIGGS.csv")
    11                                             # PyArrow を使ってゼロコピーで Pandas の DataFrame に変換する
    12   3513.4 MiB      2.6 MiB           1       pd_df = pl_df.to_pandas(use_pyarrow_extension_array=True)
    13                                             # 後続の処理が PyArrow でサポートされていない場合は NumPy 配列に変換されることもある
    14   3513.4 MiB      0.0 MiB           1       ...

GC 時のメモリ使用量について確認する

GC (Garbage Collection) が実行されたときの挙動も確認しておこう。 Pandas の DataFrame に変換した後で、Polars の DataFrame への参照を削除する。

まずはオプションを指定しない場合から。

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

import polars as pl
from memory_profiler import profile


@profile
def main():
    pl_df = pl.read_csv("HIGGS.csv")
    pd_df = pl_df.to_pandas()

    # Polars のデータフレームの参照を削除する
    # CPython であれば直後にオブジェクトが GC されるはず
    del pl_df


if __name__ == '__main__':
    main()

上記を実行する。 すると、Polars の DataFrame への参照を削除したタイミングでメモリの使用量が 2,433MiB 減少する。 減少したメモリの使用量は少し違和感がある。 というのも、Polars の DataFrame が最初に確保した 3,411MiB ではなく、Pandas の DataFrame に変換時する際に増加した 2,436MiB に近いため。 ここはどういった理屈なのか、まだよく分かっていない。

$ python withcopygc.py 
Filename: /home/amedama/Documents/temporary/withcopygc.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     8     99.3 MiB     99.3 MiB           1   @profile
     9                                         def main():
    10   3510.9 MiB   3411.6 MiB           1       pl_df = pl.read_csv("HIGGS.csv")
    11   5946.9 MiB   2436.0 MiB           1       pd_df = pl_df.to_pandas()
    12                                         
    13                                             # Polars のデータフレームの参照を削除する
    14                                             # CPython であれば直後にオブジェクトが GC されるはず
    15   3513.3 MiB  -2433.6 MiB           1       del pl_df

ひとまず、オプションを付けた場合の検証に進む。 オプションを付けた以外にやっていることは先ほどと同じ。

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

import polars as pl
from memory_profiler import profile


@profile
def main():
    pl_df = pl.read_csv("HIGGS.csv")
    pd_df = pl_df.to_pandas(use_pyarrow_extension_array=True)

    # Polars のデータフレームの参照を削除する
    # メモリ内の Arrow 配列への参照は残るためメモリ使用量は変わらない想定
    del pl_df


if __name__ == '__main__':
    main()

上記を実行してみよう。 今度は Polars の DataFrame への参照を削除したタイミングで、メモリの使用量に変化がない。 これは、内部的にデータを共有している Pandas の DataFrame からの参照が残っているためにメモリが開放されなかったためと思われる。

$ python zerocopygc.py 
Filename: /home/amedama/Documents/temporary/zerocopygc.py

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     8     99.0 MiB     99.0 MiB           1   @profile
     9                                         def main():
    10   3510.4 MiB   3411.4 MiB           1       pl_df = pl.read_csv("HIGGS.csv")
    11   3513.0 MiB      2.6 MiB           1       pd_df = pl_df.to_pandas(use_pyarrow_extension_array=True)
    12                                         
    13                                             # Polars のデータフレームの参照を削除する
    14   3513.0 MiB      0.0 MiB           1       del pl_df

まとめ

今回は Polars の DataFrame をゼロコピーで Pandas の DataFrame に変換する方法について確認した。 注意点もあるので、特性を理解した上で使う必要はあるものの、Pandas のエコシステムを流用できる可能性を秘めた便利な機能だと思う。

Python: Polars で各種エンコーダを実装したライブラリ「Shirokumas」を作った

最近は Polars が気に入っていて、主にプライベートで使っている。 ただ、エコシステムという観点では Pandas に比べて発展途上の段階にあると思う。 そこで、今回は発展の一助として「Shirokumas」というライブラリを作ってみた。

github.com

どんなライブラリかというと、現時点の機能では Pandas の category_encoders 1 のサブセットに相当する。 より具体的には、scikit-learn のスタイルで書かれた特徴量抽出をするための基本的なエンコーダを実装してある。

特徴としては、同じ処理を完了するまでにかかる時間が短いこと。 Pandas のエコシステムで使われるフレームワークとパフォーマンスを比較したグラフを以下に示す。 グラフから、比較対象の概ね 1/10 以下の時間で処理を完了できることが分かる。 詳細については、このエントリの後半に記述している。

処理にかかる時間の比較

今回のエントリを書くにあたって、使った環境は次のとおり。

$ sw_vers
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68
$ uname -srm
Darwin 22.3.0 arm64
$ python -V
Python 3.9.16
$ pip list | egrep -i "(polars|shirokuma)"
polars                        0.16.8
shirokumas                    0.0.1

注意点

現時点では、以下の点に注意が必要となる。

  • 作ったばかりのライブラリなので、不具合があったり機能が不足している恐れがある
  • Polars の API 自体がさほど安定していないため、今後のバージョンアップによって動作しなくなる恐れがある

もくじ

下準備

あらかじめ pip を使って PyPI から shirokumas をインストールする。

$ pip install shirokumas

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

$ python

shirokumas をインポートしておく。

>>> import shirokumas as sk

使い方

ここからは実装したエンコーダの使い方をひとつずつ説明していく。 パラメータや振る舞いについては category_encoders を参考にしているところが多い。

OrdinalEncoder

まずは OrdinalEncoder から。 これは Ordinal/Label Encoding と呼ばれるエンコード手法を実装している。 具体的には、カテゴリ変数の特定のクラスを特定の整数に対応させるもの。

あらかじめサンプルとなるデータフレームを用意しておく。 これにはカテゴリ変数の "fruits" というカラムが含まれる。

>>> import polars as pl
>>> train_df = pl.DataFrame({"fruits": ["apple", "banana", "cherry"]})

エンコーダをインスタンス化する。

>>> encoder = sk.OrdinalEncoder()

データフレームをエンコーダで学習・変換する。

>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 1      │
│ 2      │
│ 3      │
└────────┘

エンコードされる整数は 1 から連続しており、カテゴリが登場した順番に割り振られる。

対応関係はエンコーダに記録されているので、新規のデータフレームを変換するときにも同じ対応関係が使われる。

>>> test_df = pl.DataFrame({"fruits": ["cherry", "banana", "apple"]})
>>> encoder.transform(test_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 3      │
│ 2      │
│ 1      │
└────────┘

また、mappings 引数を指定することで、対応関係を明示的に指定することもできる。

>>> encoder = sk.OrdinalEncoder(mappings={
...     "fruits": {
...         "apple": 10,
...         "banana": 20,
...         "cherry": 30,
...     }
... })
>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 10     │
│ 20     │
│ 30     │
└────────┘

デフォルトでは、学習データに登場しなかった未知の値や None については定数に置き換えられる。 未知の値は -1 で、None については -2 になる。

>>> test_df = pl.DataFrame({"fruits": ["unseen", None, "apple"]})
>>> encoder.transform(test_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ -1     │
│ -2     │
│ 10     │
└────────┘

未知の値や None に遭遇したときのハンドリングを変更することもできる。 たとえば None を変換しようとしたときエラーにしたいなら handle_missing 引数に "error" を指定する。

>>> train_df = pl.DataFrame({"fruits": ["apple", "banana", "cherry"]})
>>> encoder = sk.OrdinalEncoder(handle_missing="error")
>>> encoder.fit(train_df)
OrdinalEncoder(cols=['fruits'], handle_missing='error',
               mappings={'fruits': {'apple': 1, 'banana': 2, 'cherry': 3}})

未知の値に遭遇すると、次のように ValueError がスローされる。

>>> test_df = pl.DataFrame({"fruits": ["apple", None, "banana"]})
>>> encoder.transform(test_df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
ValueError: Columns to be encoded can not contain null

同様に、未知の値を変換しようとしたときエラーにしたいなら handle_unknown 引数に "error" を指定する。

encoder = sk.OrdinalEncoder(handle_unknown="error")
encoder.fit(train_df)

test_df = pl.DataFrame({"fruits": ["apple", "blueberry", "banana"]})
>>> encoder.transform(test_df)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
ValueError: Columns to be encoded can not contain unknown value

また、デフォルトでは渡された DataFrame のすべてのカラムをカテゴリ変数として処理しようとする 2。 もし、特定のカラムだけを処理したいなら cols 引数にカラム名を指定する。

train_df = pl.DataFrame({
    "fruits": ["apple", "banana", "cherry"],
    "vegetables": ["avocados", "broccoli", "carrots"],
})
encoder = sk.OrdinalEncoder(cols=["vegetables"])
>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────────┐
│ vegetables │
│ ---        │
│ i64        │
╞════════════╡
│ 1          │
│ 2          │
│ 3          │
└────────────┘

OneHotEncoder

続いては OneHotEncoder について。 これは OneHot Encoding と呼ばれるエンコード手法を実装している。 このエンコード手法では、カテゴリ変数があるクラスかどうかを真偽値で表現したカラムが、クラスの数だけ作成される。

実際にやってみよう。

>>> train_df = pl.DataFrame({"fruits": ["apple", "banana", "cherry"]})
>>> encoder = sk.OneHotEncoder()
>>> encoder.fit_transform(train_df)
shape: (3, 3)
┌──────────────┬───────────────┬───────────────┐
│ fruits_apple ┆ fruits_banana ┆ fruits_cherry │
│ ---          ┆ ---           ┆ ---           │
│ boolboolbool          │
╞══════════════╪═══════════════╪═══════════════╡
│ true         ┆ false         ┆ false         │
│ false        ┆ true          ┆ false         │
│ false        ┆ false         ┆ true          │
└──────────────┴───────────────┴───────────────┘

CountEncoder

続いては CountEncoder について。 これは Count/Frequency Encoding と呼ばれるエンコード手法を実装している。 このエンコード手法では、カテゴリ変数の各クラスの出現回数が特徴量として使われる。

実際に試してみよう。 元のデータフレームには "apple" が 2 回、"banana" が 3 回、"cherry" が 1 回登場する。

>>> train_df = pl.DataFrame({"fruits": [
...     "apple",
...     "apple",
...     "banana",
...     "banana",
...     "banana",
...     "cherry",
... ]})
>>> encoder = sk.CountEncoder()
>>> encoder.fit_transform(train_df)
shape: (6, 1)
┌────────┐
│ fruits │
│ ---    │
│ i64    │
╞════════╡
│ 2      │
│ 2      │
│ 3      │
│ 3      │
│ 3      │
│ 1      │
└────────┘

上記から、元のデータフレームに含まれる各フルーツの出現回数が変換後のデータフレームで置き換えられていることがわかる。

NullEncoder

続いては NullEncoder について。 Null Encoding ...と呼ぶのかは分からないけど、あるカラムの値が欠損値かどうかも有益な特徴量になることがある。 なので、このエンコーダはカラムの値が None かどうかを真偽値で表現する。

>>> train_df = pl.DataFrame({"fruits": ["apple", None, "cherry"]})
>>> encoder = sk.NullEncoder()
>>> encoder.fit_transform(train_df)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ bool   │
╞════════╡
│ false  │
│ true   │
│ false  │
└────────┘

TargetEncoder

続いては TargetEncoder について。 これは Target/Likelihood (Mean) Encoding と呼ばれる手法を実装している。 目的変数を特徴量抽出に使う、いわゆる教師あり (Supervised) な手法になる。

基本的なアイデアは、カテゴリごとに目的変数の平均値を求めたら良い特徴量になるのでは、というもの。 ただし、学習データをすべて使って計算してしまう (Greedy Target Statistics) と CV でリークが生じて楽観的な見積もりになってしまう。 そこで、データを分割して Fold ごとに計算する (Hold-out Target Statistics) といった工夫が必要になる。

詳しくは下記のエントリを参考にしてもらいたい。

blog.amedama.jp

実際に試してみよう。 今回は学習データについて、説明変数だけでなく目的変数も定義しておく。

>>> train_x = pl.DataFrame(
...     {
...         "fruits": ["apple", "banana", "banana", "apple"],
...     }
... )
>>> train_y = pl.Series(
...     name="target",
...     values=[1, 0, 1, 1],
... )

また、前述したとおりリークを防ぐためにデータを分割して計算する必要がある。 そこで、分割方法を指定するために scikit-learn の KFold オブジェクトを用意する。

>>> from sklearn.model_selection import KFold
>>> folds = KFold(n_splits=4, shuffle=False)

今回は分かりやすさのために 4 行の学習データを 4 分割する。 順番に、学習データのうち 3 行が統計量の計算に用いられ、1 行が計算した統計量で埋められる。

>>> encoder = sk.TargetEncoder(folds=folds)
>>> encoder.fit(train_x, train_y)
TargetEncoder(folds=KFold(n_splits=4, random_state=None, shuffle=False))
>>> encoder.transform(train_x)
shape: (4, 1)
┌────────┐
│ fruits │
│ ---    │
│ f64    │
╞════════╡
│ 1.0    │
│ 1.0    │
│ 0.0    │
│ 1.0    │
└────────┘

各 1 行を目隠ししながらカテゴリごとの平均値を計算して埋めていくと上記のような対応になることがわかるはず。

新規のデータをエンコードするときは、学習データ全体で計算した各カテゴリの平均値が使われる。 未知のデータは学習データ全体の目的変数の平均値 (Global Mean) で埋められる。 今回であれば学習データ全体の平均は 3/4 = 0.75 になる。

test_x = pl.DataFrame(
    {
        "fruits": ["apple", "banana", "cherry"],
    }
)
>>> encoder.transform(test_x)
shape: (3, 1)
┌────────┐
│ fruits │
│ ---    │
│ f64    │
╞════════╡
│ 1.0    │
│ 0.5    │
│ 0.75   │
└────────┘

上記の "cherry" は学習データには含まれていなかった未知の値なので 0.75 に置き換えられている。

また、Target Encoding は過学習を防ぐために Smoothing が用いられる場合がある。 Shirokumas では、empirical bayesian と m-probability estimate という 2 種類の Smoothing を実装している。 それぞれの Smoothing の詳細については、以下のエントリを参考してもらいたい。 なお、デフォルトでは Smoothing しないので明示的に指定する必要がある。

blog.amedama.jp

まず、m-probability estimate を使うときは smoothing_method 引数に "m-estimate" を指定する。 このとき、Smoothing の特性を決めるパラメータも存在している。 そこで、smoothing_params に辞書で "m" というキーを使って浮動小数点を指定する。 なお、指定しないときはデフォルトで 1.0 が使われる3

encoder = sk.TargetEncoder(
    folds=folds,
    smoothing_method="m-estimate",
    smoothing_params={
        "m": 1.0,
    },
)
>>> encoder.fit_transform(train_x, train_y)
shape: (4, 1)
┌──────────┐
│ fruits   │
│ ---      │
│ f64      │
╞══════════╡
│ 0.833333 │
│ 1.0      │
│ 0.333333 │
│ 0.833333 │
└──────────┘

もうひとつの Smoothing 手法の empirical bayesian を使うときは smoothing_method"eb" を指定する。 Smoothing の特性を決めるパラメータには "k""f" という 2 つのキーで整数が必要になる。 指定されないときは、それぞれ 2010 が使われる 4

>>> encoder = sk.TargetEncoder(
...     folds=folds,
...     smoothing_method="eb",
...     smoothing_params={
...         "k": 1,
...         "f": 1,
...     },
... )
>>> encoder.fit_transform(train_x, train_y)
shape: (4, 1)
┌──────────┐
│ fruits   │
│ ---      │
│ f64      │
╞══════════╡
│ 0.833333 │
│ 1.0      │
│ 0.333333 │
│ 0.833333 │
└──────────┘

適切な Smoothing の有無やパラメータは、利用するデータやモデルに依存するため色々と試してみる必要があるはず。

ちなみに Out-of-Fold での計算と、上記 2 種類の Smoothing の両方を実装したフレームワークは、私が知る限り現時点 (2023-02-26) で他に無いと思われる。

AggregateEncoder

続いては AggregateEncoder について。 これについては具体的に何 Encoding と呼ぶべきなのか私にも分からない 5。 日本語では、この手法で作られた特徴量のことを集約特徴量と呼ぶことが多い。 やっていることは Target Encoding と少し似ているが、集計する対象が目的変数ではなく説明変数になっている。 つまり教師なし (Unsupervised) な特徴量抽出の手法になる。

実際に使ってみよう。 まずはサンプルデータとしてカテゴリ変数と連続変数の両方を含むデータフレームを用意する。 これらはいずれも説明変数であり、目的変数は別にあると考えてもらいたい。 たとえば、そのフルーツが美味しいかどうかみたいな。

>>> train_df = pl.DataFrame(
...     {
...         "fruits": ["apple", "apple", "banana", "banana", "cherry"],
...         "price": [100, 200, 300, 400, 500],
...     }
... )

エンコーダは colsagg_exprs 引数の指定が必須になる。 cols には groupby() するのに使われるカラムを指定し、agg_exprs には agg() するのに使われる式を指定する。 agg_exprs については集計した後に作られるカラムのサフィックスを辞書のキーとして指定する。

>>> encoder = sk.AggregateEncoder(
...     cols=[
...         "fruits",
...     ],
...     agg_exprs={
...         "mean": pl.col("price").mean(),
...         "max": pl.col("price").max(),
...     },
... )

上記であれば fruits カラムについて groupby() して price カラムの平均値と最大値をエンコードする、という意味になる。

変換してみると、カテゴリごとの平均値と最大値にエンコードされることがわかる。

>>> encoder.fit_transform(train_df)
shape: (5, 2)
┌─────────────┬────────────┐
│ fruits_mean ┆ fruits_max │
│ ---         ┆ ---        │
│ f64         ┆ i64        │
╞═════════════╪════════════╡
│ 150.0200        │
│ 150.0200        │
│ 350.0400        │
│ 350.0400        │
│ 500.0500        │
└─────────────┴────────────┘

パフォーマンスについて

さて、ここまでで一通り現時点で実装されているエンコーダの説明が終わった。 続いては、パフォーマンスについて雑にベンチマークして確かめてみよう。 長いので結果だけ知りたい場合は下の方にスクロールしてもらうとグラフがある。

データセットについてはカテゴリ変数がそれなりに含まれている Diamonds を使う。 カテゴリ変数のカーディナリティが低い点については目をつぶることにする。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/diamonds.csv

比較対象とするエンコーダなどをインストールしておく。

$ pip install ipython pandas category_encoders nyaggle xfeat
$ pip list | egrep "(pandas|scikit-learn|category-encoders|nyaggle|xfeat)"
category-encoders                 2.6.0
nyaggle                           0.1.5
pandas                            1.5.3
scikit-learn                      1.2.1
xfeat                             0.1.1

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

$ ipython

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

import polars as pl
import shirokumas as sk
import category_encoders as ce
import xfeat

データセットを読み込む。

raw_df = pl.read_csv("diamonds.csv")

そのままだとデータサイズが小さいので連結して大きくしておく。

pl_df = pl.concat([raw_df for _ in range(200)])

比較対象として使う Pandas のデータフレームも用意する。

pd_df = pl_df.to_pandas()

Polars のデータフレームはメモリ上で 930MB ほどのサイズになっている。

pl_df.dtypes
[Float64, Utf8, Utf8, Utf8, Float64, Float64, Int64, Float64, Float64, Float64]
pl_df.estimated_size(unit="mb")
930.0697555541992

Pandas のデータフレームはメモリ上で 2.4GB ほどのサイズになっている。

pd_df.info(memory_usage="deep")
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10788000 entries, 0 to 10787999
Data columns (total 10 columns):
 #   Column   Dtype  
---  ------   -----  
 0   carat    float64
 1   cut      object 
 2   color    object 
 3   clarity  object 
 4   depth    float64
 5   table    float64
 6   price    int64  
 7   x        float64
 8   y        float64
 9   z        float64
dtypes: float64(6), int64(1), object(3)
memory usage: 2.4 GB

OrdinalEncoder

まずは OrdinalEncoder から確認する。

カテゴリ変数のカラムである cut, color, clarity をエンコードする。 先頭をエンコードして出力を確かめておく。

pl_encoder = sk.OrdinalEncoder(cols=["cut", "color", "clarity"])
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 3)
┌─────┬───────┬─────────┐
│ cut ┆ color ┆ clarity │
│ --- ┆ ---   ┆ ---     │
│ i64 ┆ i64   ┆ i64     │
╞═════╪═══════╪═════════╡
│ 111       │
│ 212       │
│ 313       │
│ 224       │
│ 331       │
└─────┴───────┴─────────┘

データ全体をエンコードするのにかかる時間を %timeit マジックコマンドで確認する。

%timeit pl_encoder.fit_transform(pl_df)
758 ms ± 8.65 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

758 ms ± 8.65 ms という結果が得られた。

続いては比較対象として category_encoders の OrdinalEncoder を使う。 こちらも先頭部分をエンコードしてみて出力を確認する。

pd_encoder = ce.OrdinalEncoder(cols=["cut", "color", "clarity"])
pd_encoder.fit_transform(pd_df.iloc[:5])
   carat  cut  color  clarity  depth  table  price     x     y     z
0   0.23    1      1        1   61.5   55.0    326  3.95  3.98  2.43
1   0.21    2      1        2   59.8   61.0    326  3.89  3.84  2.31
2   0.23    3      1        3   56.9   65.0    327  4.05  4.07  2.31
3   0.29    2      2        4   62.4   58.0    334  4.20  4.23  2.63
4   0.31    3      3        1   63.3   58.0    335  4.34  4.35  2.75

category_encoders についてはエンコードに使われないカラムも一緒に返ってくることがわかる。 ただし、エンコードした cut, color, clarity については同じ結果が得られていることが確認できる。

データ全体をエンコードしてかかる時間を確認してみよう。

%timeit pd_encoder.fit_transform(pd_df)
8.47 s ± 21.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

8.47 s ± 21.6 ms という結果が得られた。

OneHotEncoder

続いては OneHotEncoder を試してみよう。

使うカラムは先ほどと同じ。 先頭部分をエンコードして出力を確かめる。

pl_encoder = sk.OneHotEncoder(cols=["cut", "color", "clarity"])
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 10)
┌─────────┬───────────┬────────┬───────┬─────┬───────────┬───────────┬───────────┬───────────┐
│ cut_Ide ┆ cut_Premi ┆ cut_Go ┆ color ┆ ... ┆ clarity_S ┆ clarity_S ┆ clarity_V ┆ clarity_V │
│ al      ┆ um        ┆ od     ┆ _E    ┆     ┆ I2        ┆ I1        ┆ S1        ┆ S2        │
│ ---     ┆ ---       ┆ ---    ┆ ---   ┆     ┆ ---       ┆ ---       ┆ ---       ┆ ---       │
│ boolboolboolbool  ┆     ┆ boolboolboolbool      │
╞═════════╪═══════════╪════════╪═══════╪═════╪═══════════╪═══════════╪═══════════╪═══════════╡
│ true    ┆ false     ┆ false  ┆ true  ┆ ... ┆ true      ┆ false     ┆ false     ┆ false     │
│ false   ┆ true      ┆ false  ┆ true  ┆ ... ┆ false     ┆ true      ┆ false     ┆ false     │
│ false   ┆ false     ┆ true   ┆ true  ┆ ... ┆ false     ┆ false     ┆ true      ┆ false     │
│ false   ┆ true      ┆ false  ┆ false ┆ ... ┆ false     ┆ false     ┆ false     ┆ true      │
│ false   ┆ false     ┆ true   ┆ false ┆ ... ┆ true      ┆ false     ┆ false     ┆ false     │
└─────────┴───────────┴────────┴───────┴─────┴───────────┴───────────┴───────────┴───────────┘

データ全体をエンコードして時間を確認する。

%timeit pl_encoder.fit_transform(pl_df)
836 ms ± 15.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

836 ms ± 15.2 ms という結果が得られた。

同様に category_encoders の OneHotEncoder でも確かめる。

pd_encoder = ce.OneHotEncoder(cols=["cut", "color", "clarity"])
pd_encoder.fit_transform(pd_df.iloc[:5])
   carat  cut_1  cut_2  cut_3  color_1  ...  table  price     x     y     z
0   0.23      1      0      0        1  ...   55.0    326  3.95  3.98  2.43
1   0.21      0      1      0        1  ...   61.0    326  3.89  3.84  2.31
2   0.23      0      0      1        1  ...   65.0    327  4.05  4.07  2.31
3   0.29      0      1      0        0  ...   58.0    334  4.20  4.23  2.63
4   0.31      0      0      1        0  ...   58.0    335  4.34  4.35  2.75

[5 rows x 17 columns]

データ全体をエンコードする。

%timeit pd_encoder.fit_transform(pd_df)
20.5 s ± 912 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

こちらは 20.5 s ± 912 ms という結果が得られた。

CountEncoder

続いては CountEncoder を試してみよう。

pl_encoder = sk.CountEncoder(cols=["cut", "color", "clarity"])
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 3)
┌─────┬───────┬─────────┐
│ cut ┆ color ┆ clarity │
│ --- ┆ ---   ┆ ---     │
│ i64 ┆ i64   ┆ i64     │
╞═════╪═══════╪═════════╡
│ 132       │
│ 231       │
│ 231       │
│ 211       │
│ 212       │
└─────┴───────┴─────────┘
%timeit pl_encoder.fit_transform(pl_df)
508 ms ± 5.05 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

508 ms ± 5.05 ms という結果が得られた。

続いては category_encoder の CountEncoder で試す。

pd_encoder = ce.CountEncoder(cols=["cut", "color", "clarity"])
pd_encoder.fit_transform(pd_df.iloc[:5])
   carat  cut  color  clarity  depth  table  price     x     y     z
0   0.23    1      3        2   61.5   55.0    326  3.95  3.98  2.43
1   0.21    2      3        1   59.8   61.0    326  3.89  3.84  2.31
2   0.23    2      3        1   56.9   65.0    327  4.05  4.07  2.31
3   0.29    2      1        1   62.4   58.0    334  4.20  4.23  2.63
4   0.31    2      1        2   63.3   58.0    335  4.34  4.35  2.75
%timeit pd_encoder.fit_transform(pd_df)
17.2 s ± 64.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

17.2 s ± 64.2 ms という結果が得られた。

TargetEncoder

続いては TargetEncoder で試してみる。 条件は 5 fold で Smoothing には k=20, f=10 の empirical bayesian を使う。 カラムについては cut だけを使用した。

from sklearn.model_selection import KFold
folds = KFold(n_splits=5, shuffle=False)
pl_encoder = sk.TargetEncoder(
    folds=folds,
    cols=["cut"],
    smoothing_method="eb",
    smoothing_params={
        "k": 20,
        "f": 10,
    }
)
pl_x = pl_df.select(pl.exclude("price"))
pl_y = pl_df.get_column("price")
pl_encoder.fit_transform(pl_x[:5], pl_y[:5])
shape: (5, 1)
┌────────────┐
│ cut        │
│ ---        │
│ f64        │
╞════════════╡
│ 330.5      │
│ 330.95538  │
│ 330.868015 │
│ 328.174729 │
│ 328.087364 │
└────────────┘

データ全体をエンコードする。

%timeit pl_encoder.fit_transform(pl_x, pl_y)
2.91 s ± 114 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

2.91 s ± 114 ms という結果が得られた。

続いては nyaggle 6 の TargetEncoder で試す。 category_encoders の TargetEncoder を使わないのは Out-of-Fold での計算をサポートしていないため。 ただし nyaggle は category_encoders の TargetEncoder をラップした実装になっている。

from nyaggle.feature.category_encoder.target_encoder import TargetEncoder
pd_encoder = TargetEncoder(
    cv=folds,
    cols=["cut"],
    smoothing=10.,
    min_samples_leaf=20.,
)
pd_x = pd_df.drop(["price"], axis=1)
pd_y = pd_df["price"]
pd_encoder.fit_transform(pd_x.iloc[:5], pd_y.iloc[:5])
   carat         cut color clarity  depth  table     x     y     z
0   0.23  330.500000     E     SI2   61.5   55.0  3.95  3.98  2.43
1   0.21  330.955380     E     SI1   59.8   61.0  3.89  3.84  2.31
2   0.23  330.868015     E     VS1   56.9   65.0  4.05  4.07  2.31
3   0.29  328.174729     I     VS2   62.4   58.0  4.20  4.23  2.63
4   0.31  328.087364     J     SI2   63.3   58.0  4.34  4.35  2.75

cut カラムの結果が、先ほど Shirokumas で計算した結果とまったく同じ点に注目してもらいたい。

データ全体をエンコードする。

%timeit pd_encoder.fit_transform(pd_x, pd_y)
40.3 s ± 305 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

40.3 s ± 305 ms という結果が得られた。

AggregateEncoder

続いては AggregateEncoder を試す。 cut カラムについては price カラムの平均をエンコードする。

pl_encoder = sk.AggregateEncoder(
    cols=["cut"],
    agg_exprs={
        "mean": pl.col("carat").mean(),
    }
)
pl_encoder.fit_transform(pl_df[:5])
shape: (5, 1)
┌──────────┐
│ cut_mean │
│ ---      │
│ f64      │
╞══════════╡
│ 0.23     │
│ 0.25     │
│ 0.27     │
│ 0.25     │
│ 0.27     │
└──────────┘

データ全体を計算する。

%timeit pl_encoder.fit_transform(pl_df)
196 ms ± 8.08 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

196 ms ± 8.08 ms という結果が得られた。

比較対象としては xfeat 7 の aggregation() 関数を使う。 というのも、このエンコード手法が実装されているフレームワークの例を他に知らないため。

agg_df, _ = xfeat.aggregation(
    pd_df[:5],
    group_key="cut",
    group_values=["carat"],
    agg_methods=["mean"],
)
agg_df.iloc[:, -1]
0    0.23
1    0.25
2    0.27
3    0.25
4    0.27
Name: agg_mean_carat_grpby_cut, dtype: float64

計算結果は先ほど Shirokumas で求めたものと一致している。

%%timeit
xfeat.aggregation(
    pd_df,
    group_key="cut",
    group_values=["carat"],
    agg_methods=["mean"],
)
1.44 s ± 17.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

1.44 s ± 17.2 ms という結果が得られた。

結果

ここまでの内容をグラフにまとめるとこんな感じ。 左側の縦軸の単位はミリ秒。

処理にかかる時間の比較

まとめ

今回は Polars 版の category_encoders といえる「Shirokumas」について紹介した。 また、Pandas と対応するフレームワークとの比較では、エンコードにかかる時間の短縮が確認できた。

もちろん、パフォーマンスに関しては GPU を使ったフレームワーク (cuDF/cuML など) を使った方が高速に処理できる場合が多いかと思う。 とはいえ、特別なアクセラレータなしにこれだけのパフォーマンス向上が望めるのは、やはり大きなインパクトがあるのではないだろうか。


  1. https://contrib.scikit-learn.org/category_encoders/
  2. category_encoders ではデフォルトで category 型だけを対象にするはず
  3. category_encoders のデフォルト値を参考にした
  4. category_encoders のデフォルト値を参考にした
  5. この手法を紹介している論文があったら教えてもらいたい
  6. https://github.com/nyanp/nyaggle
  7. https://github.com/pfnet-research/xfeat

Python: DuckDB と Polars を組み合わせて使う

DuckDB のバージョン 0.7 で Polars とのインテグレーションが強化された。 たとえば Polars の DataFrame に対して DuckDB の Python API で直接クエリを発行できるようになったらしい。 また、DuckDB で実行したクエリの結果を Polars の DataFrame に変換することもできる。 ユースケースとしては、たとえばすべてインメモリで扱うのが辛くなったときに読み書きする先を DuckDB に切り替えるなどが考えられる。 あるいは、データの加工をなるべく SQL でやりたい場合にもマッチしそうだ。 今回は、その機能を実際に試してみることにする。

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

$ sw_vers                        
ProductName:        macOS
ProductVersion:     13.2.1
BuildVersion:       22D68
$ python -V
Python 3.10.10
$ pip list | egrep -i "(polars|duckdb)"
duckdb            0.7.0
polars            0.16.5

もくじ

下準備

あらかじめ DuckDB の Python API と Polars をインストールしておく。 Polars のインストール時はオプションとして PyArrow を選択する。

$ pip install duckdb "polars[pyarrow]"

サンプルに使う Diamonds データセットの CSV をダウンロードしておく。

$ wget https://raw.githubusercontent.com/mwaskom/seaborn-data/master/diamonds.csv

中身はこんな感じ。

$ head diamonds.csv 
"carat","cut","color","clarity","depth","table","price","x","y","z"
0.23,"Ideal","E","SI2",61.5,55,326,3.95,3.98,2.43
0.21,"Premium","E","SI1",59.8,61,326,3.89,3.84,2.31
0.23,"Good","E","VS1",56.9,65,327,4.05,4.07,2.31
0.29,"Premium","I","VS2",62.4,58,334,4.2,4.23,2.63
0.31,"Good","J","SI2",63.3,58,335,4.34,4.35,2.75
0.24,"Very Good","J","VVS2",62.8,57,336,3.94,3.96,2.48
0.24,"Very Good","I","VVS1",62.3,57,336,3.95,3.98,2.47
0.26,"Very Good","H","SI1",61.9,55,337,4.07,4.11,2.53
0.22,"Fair","E","VS2",65.1,61,337,3.87,3.78,2.49

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

$ python

Diamonds データセットの CSV ファイルを Polars の DataFrame に読み込む。

>>> import polars as pl
>>> df = pl.read_csv("diamonds.csv")
>>> df.head()
shape: (5, 10)
┌───────┬─────────┬───────┬─────────┬─────┬───────┬──────┬──────┬──────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ ... ┆ price ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆     ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ strstrstr     ┆     ┆ i64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═════════╪═══════╪═════════╪═════╪═══════╪══════╪══════╪══════╡
│ 0.23  ┆ Ideal   ┆ E     ┆ SI2     ┆ ... ┆ 3263.953.982.43 │
│ 0.21  ┆ Premium ┆ E     ┆ SI1     ┆ ... ┆ 3263.893.842.31 │
│ 0.23  ┆ Good    ┆ E     ┆ VS1     ┆ ... ┆ 3274.054.072.31 │
│ 0.29  ┆ Premium ┆ I     ┆ VS2     ┆ ... ┆ 3344.24.232.63 │
│ 0.31  ┆ Good    ┆ J     ┆ SI2     ┆ ... ┆ 3354.344.352.75 │
└───────┴─────────┴───────┴─────────┴─────┴───────┴──────┴──────┴──────┘

DuckDB で Polars の DataFrame に対してクエリを発行する

では、DuckDB の Python API を使って Polars の DataFrame にクエリを発行してみよう。

まずは duckdb パッケージをインポートする。

>>> import duckdb

あとは ducbdb.sql() 関数を使ってクエリを発行できる。 ここで SQL の from 句に、現在のスコープにある Polars の DataFrame の名前を指定できる。

>>> duckdb.sql("select * from df")

┌────────┬───────────┬─────────┬─────────┬───┬────────┬────────┬────────┐
│ carat  │    cut    │  color  │ clarity │ … │   x    │   y    │   z    │
│ double │  varchar  │ varchar │ varchar │   │ double │ double │ double │
├────────┼───────────┼─────────┼─────────┼───┼────────┼────────┼────────┤
│   0.23 │ Ideal     │ E       │ SI2     │ … │   3.953.982.43 │
│   0.21 │ Premium   │ E       │ SI1     │ … │   3.893.842.31 │
│   0.23 │ Good      │ E       │ VS1     │ … │   4.054.072.31 │
│   0.29 │ Premium   │ I       │ VS2     │ … │    4.24.232.63 │
│   0.31 │ Good      │ J       │ SI2     │ … │   4.344.352.75 │
│   0.24 │ Very Good │ J       │ VVS2    │ … │   3.943.962.48 │
│   0.24 │ Very Good │ I       │ VVS1    │ … │   3.953.982.47 │
│   0.26 │ Very Good │ H       │ SI1     │ … │   4.074.112.53 │
│   0.22 │ Fair      │ E       │ VS2     │ … │   3.873.782.49 │
│   0.23 │ Very Good │ H       │ VS1     │ … │    4.04.052.39 │
│     ·  │    ·      │ ·       │  ·      │ · │     ·  │     ·  │     ·  │
│     ·  │    ·      │ ·       │  ·      │ · │     ·  │     ·  │     ·  │
│     ·  │    ·      │ ·       │  ·      │ · │     ·  │     ·  │     ·  │
│    1.2 │ Premium   │ J       │ VS1     │ … │   6.776.724.18 │
│   1.12 │ Premium   │ H       │ SI2     │ … │   6.746.664.15 │
│    1.0 │ Premium   │ F       │ SI1     │ … │   6.436.383.86 │
│    1.0 │ Premium   │ E       │ SI2     │ … │   6.396.343.93 │
│    1.0 │ Very Good │ D       │ SI2     │ … │   6.386.354.03 │
│    1.0 │ Very Good │ E       │ SI2     │ … │   6.386.314.03 │
│    1.0 │ Premium   │ E       │ SI2     │ … │   6.416.363.92 │
│    1.0 │ Premium   │ E       │ SI2     │ … │   6.486.443.95 │
│    1.0 │ Premium   │ D       │ SI1     │ … │   6.416.293.94 │
│    1.0 │ Fair      │ D       │ SI1     │ … │    6.26.134.0 │
├────────┴───────────┴─────────┴─────────┴───┴────────┴────────┴────────┤
│ ? rows (>9999 rows, 20 shown)                    10 columns (7 shown) │
└───────────────────────────────────────────────────────────────────────┘

もちろん、通常の SQL のように色々な条件が書ける。 たとえば 1 カラット以上のレコードについて caratcutcolor カラムだけ取り出すとか。

>>> duckdb.sql("select carat, cut, color from df where carat > 1.0")
┌────────┬───────────┬─────────┐
│ carat  │    cut    │  color  │
│ double │  varchar  │ varchar │
├────────┼───────────┼─────────┤
│   1.17 │ Very Good │ J       │
│   1.01 │ Premium   │ F       │
│   1.01 │ Fair      │ E       │
│   1.01 │ Premium   │ H       │
│   1.05 │ Very Good │ J       │
│   1.05 │ Fair      │ J       │
│   1.01 │ Fair      │ E       │
│   1.04 │ Premium   │ G       │
│    1.2 │ Fair      │ F       │
│   1.02 │ Premium   │ G       │
│     ·  │   ·       │ ·       │
│     ·  │   ·       │ ·       │
│     ·  │   ·       │ ·       │
│   1.02 │ Ideal     │ G       │
│    1.5 │ Good      │ G       │
│   1.06 │ Premium   │ E       │
│   1.06 │ Ideal     │ G       │
│    1.5 │ Fair      │ H       │
│    1.7 │ Premium   │ I       │
│    3.0 │ Fair      │ H       │
│   1.56 │ Good      │ I       │
│   1.88 │ Good      │ J       │
│   1.51 │ Very Good │ J       │
├────────┴───────────┴─────────┤
│ ? rows             3 columns │
└──────────────────────────────┘

ちなみに DuckDB は CSV ファイルなどに対して直接クエリを発行することもできたりする。

>>> duckdb.sql("select carat, cut, color from 'diamonds.csv' where carat > 1.0")
┌────────┬───────────┬─────────┐
│ carat  │    cut    │  color  │
│ double │  varchar  │ varchar │
├────────┼───────────┼─────────┤
│   1.17 │ Very Good │ J       │
│   1.01 │ Premium   │ F       │
│   1.01 │ Fair      │ E       │
│   1.01 │ Premium   │ H       │
│   1.05 │ Very Good │ J       │
│   1.05 │ Fair      │ J       │
│   1.01 │ Fair      │ E       │
│   1.04 │ Premium   │ G       │
│    1.2 │ Fair      │ F       │
│   1.02 │ Premium   │ G       │
│     ·  │   ·       │ ·       │
│     ·  │   ·       │ ·       │
│     ·  │   ·       │ ·       │
│   1.02 │ Ideal     │ G       │
│    1.5 │ Good      │ G       │
│   1.06 │ Premium   │ E       │
│   1.06 │ Ideal     │ G       │
│    1.5 │ Fair      │ H       │
│    1.7 │ Premium   │ I       │
│    3.0 │ Fair      │ H       │
│   1.56 │ Good      │ I       │
│   1.88 │ Good      │ J       │
│   1.51 │ Very Good │ J       │
├────────┴───────────┴─────────┤
│ ? rows             3 columns │
└──────────────────────────────┘

ここまでの例では Polars の DataFrame にしておくとメモリにデータが乗るのでその分アクセスが速いというのはありそう。

DuckDB のクエリ結果を Polars の DataFrame に変換する

さて、DuckDB で実行したクエリの結果は、そのままだと DuckDB のオブジェクトになっている。

>>> query = "select carat, cut, color from df where carat > 1.0"
>>> type(duckdb.sql(query))
<class 'duckdb.DuckDBPyRelation'>

これは DuckDBPyRelation#pl() メソッドを使うことで Polars の DataFrame に変換できる。

>>> duckdb.sql(query).pl()
shape: (17502, 3)
┌───────┬───────────┬───────┐
│ carat ┆ cut       ┆ color │
│ ---   ┆ ---       ┆ ---   │
│ f64   ┆ strstr   │
╞═══════╪═══════════╪═══════╡
│ 1.17  ┆ Very Good ┆ J     │
│ 1.01  ┆ Premium   ┆ F     │
│ 1.01  ┆ Fair      ┆ E     │
│ 1.01  ┆ Premium   ┆ H     │
│ ...   ┆ ...       ┆ ...   │
│ 1.01  ┆ Very Good ┆ I     │
│ 1.04  ┆ Very Good ┆ I     │
│ 1.04  ┆ Fair      ┆ G     │
│ 1.02  ┆ Good      ┆ H     │
└───────┴───────────┴───────┘
>>> type(duckdb.sql(query).pl())
<class 'polars.internals.dataframe.frame.DataFrame'>

なお、これは Polars の DataFrame に対してクエリを発行したから、というわけではない。 DuckDB の Python API で実行したクエリの結果は pl() メソッドで Polars の DataFrame に変換できる。

試しに Diamonds データセットの CSV をデータベースに取り込んでからクエリを発行してみよう。 まずは CSV ファイルを元にテーブルを作成する。

>>> duckdb.sql("create table diamonds as select * from read_csv_auto('diamonds.csv')")
>>> duckdb.sql("show tables")
┌──────────┐
│   name   │
│ varchar  │
├──────────┤
│ diamonds │
└──────────┘

今度はテーブルに対してクエリを発行して、結果を pl() メソッドで Polars の DataFrame に変換してみる。

>>> query = "select carat, cut, color from diamonds where carat > 1.0"
>>> duckdb.sql(query).pl()
shape: (17502, 3)
┌───────┬───────────┬───────┐
│ carat ┆ cut       ┆ color │
│ ---   ┆ ---       ┆ ---   │
│ f64   ┆ strstr   │
╞═══════╪═══════════╪═══════╡
│ 1.17  ┆ Very Good ┆ J     │
│ 1.01  ┆ Premium   ┆ F     │
│ 1.01  ┆ Fair      ┆ E     │
│ 1.01  ┆ Premium   ┆ H     │
│ ...   ┆ ...       ┆ ...   │
│ 1.01  ┆ Very Good ┆ I     │
│ 1.04  ┆ Very Good ┆ I     │
│ 1.04  ┆ Fair      ┆ G     │
│ 1.02  ┆ Good      ┆ H     │
└───────┴───────────┴───────┘

ちゃんと変換できた。

まとめ

今回は DuckDB バージョン 0.7 で強化された Polars とのインテグレーションを試してみた。

なお、データベースに関しては、これまでも Polars は BigQuery などと連携して使うことはできた。 一方で、データセットのサイズが数十 GB 程度であれば DuckDB で事足りる状況も多いはず。 そこは、得られるスループットと、セットアップの手間や利用料金とのトレードオフで考えることになるだろう。

参考

duckdb.org

k3d (k3s) を使って Ingress と NetworkPolicy を検証する

Kubernetes を手元で検証しようとすると CNI (Container Network Interface) プラグインの機能が障壁になることがある。 たとえば kind を使う場合はデフォルトで kindnetd という CNI プラグインがインストールされる。 しかし、この CNI プラグインは動作する上で最低限の機能しか有していない。 そのため、Ingress リソースや NetworkPolicy リソースはデフォルトでは利用できない。 もちろん、別途 Calico などの CNI プラグインをインストールすることも考えられるが、その分の手間はかかる。

そこで、今回は k3s という IoT 向けの軽量な Kubernetes ディストリビューションを k3d というツールでインストールして試してみる。 k3d (k3s) ではデフォルトで Flannel が CNI プラグインとしてインストールされる。 そのため Ingress リソースを扱うことができる。 また、Flannel 自体は NetworkPolicy リソースをサポートしていないが k3s は kube-router の機能を使ってサポートしているらしい。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.3
BuildVersion:   21G419
$ uname -srm
Darwin 21.6.0 x86_64
$ k3d version
k3d version v5.4.7
k3s version v1.25.6-k3s1 (default)
$ docker version
Client:
 Cloud integration: v1.0.29
 Version:           20.10.22
 API version:       1.41
 Go version:        go1.18.9
 Git commit:        3a2c30b
 Built:             Thu Dec 15 22:28:41 2022
 OS/Arch:           darwin/amd64
 Context:           default
 Experimental:      true

Server: Docker Desktop 4.16.2 (95914)
 Engine:
  Version:          20.10.22
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.18.9
  Git commit:       42c8b31
  Built:            Thu Dec 15 22:26:14 2022
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.14
  GitCommit:        9ba4b250366a5ddde94bb7c9d1def331423aa323
 runc:
  Version:          1.1.4
  GitCommit:        v1.1.4-0-g5fd4c4d
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

もくじ

下準備

ホストには、あらかじめ Docker と Homebrew がインストールされた状態を仮定する。

そして Homebrew で k3d をインストールする。

$ brew install k3d

インストールすると k3d コマンドが使えるようになる。

$ k3d version
k3d version v5.4.7
k3s version v1.25.6-k3s1 (default)

これで下準備が整った。

Ingress リソースを試す

まずは Ingress リソースから試してみよう。

その前に k3d cluster create サブコマンドを使ってクラスタを作成する。 このとき Ingress リソースを試すときは -p オプションを付ける必要がある。 これはワーカーノードの前段に入るプロキシの機能を提供するホストの特定のポートをローカルホストのポートに対応付けて扱うため。

今回は Nginx の Pod を使って HTTP を公開する Ingress リソースを作って試してみる。 そこで、プロキシ機能を提供するホストの 80 ポートをローカルホストの 8080 ポートに対応付ける。 これには -p オプションに "8080:80@loadbalancer" という引数を渡す。

$ k3d cluster create helloworld \
    -p "8080:80@loadbalancer"

上記を実行するとクラスタが作成される。 作成されたクラスタは k3d cluster list で状態を確認できる。

$ k3d cluster list
NAME         SERVERS   AGENTS   LOADBALANCER
helloworld   1/1       0/0      true

作成されたクラスタを操作するための kubeconfig は k3d kubeconfig write サブコマンドで生成できる。 環境変数の KUBECONFIG にセットしよう。

$ export KUBECONFIG=$(k3d kubeconfig write helloworld)

このコマンドは kubeconfig を生成しつつ、そのパスを標準出力に返す。

$ k3d kubeconfig write helloworld
/Users/amedama/.k3d/kubeconfig-helloworld.yaml

kubectl config current-context サブコマンドで k3d で作ったクラスタが操作対象になっていることを確認する。

$ kubectl config current-context 
k3d-helloworld

作成した直後はシステムの作成するリソースの準備が整っていないことがある。 そこで kubectl get all サブコマンドなどを使ってシステムのリソースが稼働していることを確認する。

$ kubectl get all -A
NAMESPACE     NAME                                          READY   STATUS      RESTARTS   AGE
kube-system   pod/local-path-provisioner-79f67d76f8-hpcs9   1/1     Running     0          64s
kube-system   pod/coredns-597584b69b-n268h                  1/1     Running     0          64s
kube-system   pod/metrics-server-5f9f776df5-qhzz2           1/1     Running     0          64s
kube-system   pod/helm-install-traefik-crd-7m896            0/1     Completed   0          64s
kube-system   pod/helm-install-traefik-mnlxz                0/1     Completed   1          64s
kube-system   pod/svclb-traefik-07031ac5-j8cx2              2/2     Running     0          36s
kube-system   pod/traefik-66c46d954f-qgfh7                  1/1     Running     0          36s

NAMESPACE     NAME                     TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)                      AGE
default       service/kubernetes       ClusterIP      10.43.0.1     <none>        443/TCP                      79s
kube-system   service/kube-dns         ClusterIP      10.43.0.10    <none>        53/UDP,53/TCP,9153/TCP       76s
kube-system   service/metrics-server   ClusterIP      10.43.76.82   <none>        443/TCP                      75s
kube-system   service/traefik          LoadBalancer   10.43.82.71   172.18.0.3    80:31992/TCP,443:32477/TCP   36s

NAMESPACE     NAME                                    DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
kube-system   daemonset.apps/svclb-traefik-07031ac5   1         1         1       1            1           <none>          36s

NAMESPACE     NAME                                     READY   UP-TO-DATE   AVAILABLE   AGE
kube-system   deployment.apps/local-path-provisioner   1/1     1            1           76s
kube-system   deployment.apps/coredns                  1/1     1            1           76s
kube-system   deployment.apps/metrics-server           1/1     1            1           75s
kube-system   deployment.apps/traefik                  1/1     1            1           36s

NAMESPACE     NAME                                                DESIRED   CURRENT   READY   AGE
kube-system   replicaset.apps/local-path-provisioner-79f67d76f8   1         1         1       65s
kube-system   replicaset.apps/coredns-597584b69b                  1         1         1       65s
kube-system   replicaset.apps/metrics-server-5f9f776df5           1         1         1       65s
kube-system   replicaset.apps/traefik-66c46d954f                  1         1         1       36s

NAMESPACE     NAME                                 COMPLETIONS   DURATION   AGE
kube-system   job.batch/helm-install-traefik-crd   1/1           32s        74s
kube-system   job.batch/helm-install-traefik       1/1           34s        74s

準備が整っていることを確認したらリソースを作成していく。 まずは Pod を作る。

$ kubectl run nginx-pod \
    --image=nginx \
    --labels="app=web"
pod/nginx-pod created
$ kubectl get pod
NAME        READY   STATUS    RESTARTS   AGE
nginx-pod   1/1     Running   0          11s

続いて Pod に対応する Service を作成する。 Service では nginx-pod の TCP/80 ポートを公開する。 Pod に対応する Service を作るには kubectl expose pod サブコマンドを使うと手っ取り早い。

$ kubectl expose pod nginx-pod \
    --port 80 \
    --protocol TCP \
    --name nginx-service
service/nginx-service exposed
$ kubectl get service
NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
kubernetes      ClusterIP   10.43.0.1       <none>        443/TCP   9m50s
nginx-service   ClusterIP   10.43.138.181   <none>        80/TCP    22s

最後に Service に対応する Ingress を作る。

$ kubectl apply -f - << EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-ingress
  annotations:
    ingress.kubernetes.io/ssl-redirect: "false"
spec:
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nginx-service
            port:
              number: 80
EOF
ingress.networking.k8s.io/nginx-ingress created

Ingress リソースが作成されたことを確認する。

$ kubectl get ingress
NAME            CLASS     HOSTS   ADDRESS      PORTS   AGE
nginx-ingress   traefik   *       172.18.0.3   80      18s

これでローカルホストの TCP/8080 ポート経由で Ingress リソースにアクセスできる。 実際に curl(1) を使ってアクセスしてみよう。

$ curl -sL http://localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

ちゃんと Nginx がデフォルトで提供するウェルカムページの内容が得られた。

確認が終わったら作成したリソースを一通り削除して掃除する。

$ kubectl delete ingress nginx-ingress
ingress.networking.k8s.io "nginx-ingress" deleted
$ kubectl delete service nginx-service
service "nginx-service" deleted
$ kubectl delete pod nginx-pod        
pod "nginx-pod" deleted

NetworkPolicy リソースを試す

続いては NetworkPolicy リソースを試す。

まずは次のように Pod を 3 つ作成する。 名前やラベルは pod[123] として付けておく。

$ kubectl apply -f - << EOF
apiVersion: v1
kind: Pod
metadata:
  name: pod1
  labels:
    app: pod1
spec:
  containers:
  - name: pod1
    image: nginx
---
apiVersion: v1
kind: Pod
metadata:
  name: pod2
  labels:
    app: pod2
spec:
  containers:
  - name: pod2
    image: nginx
---
apiVersion: v1
kind: Pod
metadata:
  name: pod3
  labels:
    app: pod3
spec:
  containers:
  - name: pod3
    image: nginx
EOF
pod/pod1 created
pod/pod2 created
pod/pod3 created

次のように Pod に IP アドレスが割り当てられて、ちゃんと起動したことを確認する。

$ kubectl get pod -o wide
NAME   READY   STATUS    RESTARTS   AGE    IP           NODE                      NOMINATED NODE   READINESS GATES
pod2   1/1     Running   0          5m5s   10.42.0.13   k3d-helloworld-server-0   <none>           <none>
pod3   1/1     Running   0          5m5s   10.42.0.14   k3d-helloworld-server-0   <none>           <none>
pod1   1/1     Running   0          5m5s   10.42.0.12   k3d-helloworld-server-0   <none>           <none>

NetworkPolicy のないデフォルトの状態では、同じネームスペース内で自由に通信できる。 たとえば pod2pod3 から pod1 に HTTP GET してみよう。

$ kubectl exec -it pod2 -- curl 10.42.0.12 | head -n 5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
$ kubectl exec -it pod3 -- curl 10.42.0.12 | head -n 5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>

いずれのリクエストにも、ちゃんと Nginx のウェルカムページの内容が返ってきている。

では、ここで pod1 を対象とする NetworkPolicy を作成してみよう。 以下の NetworkPolicy は pod1 に送信元が pod2 からの通信だけを許可する。

$ kubectl apply -f - << EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: pod1-nwpolicy
spec:
  podSelector:
    matchLabels:
      app: pod1
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: pod2
EOF
networkpolicy.networking.k8s.io/pod1-nwpolicy created

NetworkPolicy を作成した状態で pod2 から pod1 に HTTP GET してみよう。

$ kubectl exec -it pod2 -- curl 10.42.0.12 | head -n 5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>

これは明示的に NetworkPolicy で通信を許可しているのでうまくいく。

では pod3 から pod1 に HTTP GET した場合はどうだろうか。

$ kubectl exec -it pod3 -- curl 10.42.0.12 | head -n 5
curl: (7) Failed to connect to 10.42.0.12 port 80: Connection refused
command terminated with exit code 7

こちらは NetworkPolicy で許可されていないため Connection refused となった。 どうやら、ちゃんと動作しているようだ。

まとめ

今回は k3d を使って k3s のクラスタを作成し、Ingress リソースと NetworkPolicy リソースが動作することを確かめた。

参考

k3s.io

k3d.io

k3d.io