CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: LightGBM の学習率と精度および最適なイテレーション数の関係について

勾配ブースティング決定木 (Gradient Boosting Decision Tree; GBDT) では、以下が経験則として知られている。

  • 学習率 (Learning Rate) を下げることで精度が高まる
  • 一方で、学習にはより多くのイテレーション数 (≒時間) を必要とする

しかしながら、上記が実際に実験などで示される機会はさほど無いように思われた。 そこで、今回は代表的な GBDT の実装のひとつである LightGBM と、疑似的に生成した学習データを使ってそれを確かめていく。 確かめる内容としては、以下のそれぞれのタスクで学習率を変化させながら精度と最適なイテレーション数の関係を記録して可視化する。

  • 二値分類タスク
  • 多値分類タスク
  • 回帰タスク

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.2
BuildVersion:   21G320
$ uname -srm
Darwin 21.6.0 arm64
$ python -V           
Python 3.10.9
$ python -m pip list | egrep "(lightgbm|scikit-learn|matplotlib)"
lightgbm          3.3.4
matplotlib        3.6.2
scikit-learn      1.2.0

もくじ

下準備

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

$ pip install lightgbm scikit-learn matplotlib

二値分類タスク

まずは二値分類タスクから。 早速、以下にサンプルコードを示す。 サンプルコードでは、擬似的な学習データを scikit-learn で生成して、それを LightGBM で学習し交差検証している。 最適なイテレーション数については Early Stopping を使ってメトリックが平均的に改善しなくなったタイミングを記録する。 損失関数とメトリックには、いずれも LogLoss を使っている。

import logging

import lightgbm as lgb
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold
from matplotlib import pyplot as plt


def main():
    logging.basicConfig(level=logging.INFO)

    # 疑似的な学習データを作るためのパラメータ
    args = {
        # データ点数
        "n_samples": 100_000,
        # 次元数
        "n_features": 100,
        # その中で意味のあるもの
        "n_informative": 10,
        # 重複や繰り返しはなし
        "n_redundant": 0,
        "n_repeated": 0,
        # 二値分類問題
        "n_classes": 2,
        # 生成に用いる乱数
        "random_state": 42,
        # 特徴の順序をシャッフルしない (先頭の次元が informative になる)
        "shuffle": False,
    }
    x, y = make_classification(**args)

    # StratifiedKFold で交差検証する
    folds = StratifiedKFold(
        n_splits=5,
        shuffle=True,
        random_state=42,
    )

    lgb_train = lgb.Dataset(x, y)
    lgb_params = {
        "objective": "binary",
        "metric": "binary_logloss",
        "seed": 42,
        "deterministic": True,
        "verbose": -1,
    }

    # 学習率を変化させながら精度と最適なイテレーション数を記録する
    best_iterations = {}
    best_performances = {}
    for lr in [0.8, 0.4, 0.2, 0.1, 0.05, 0.025, 0.0125]:
        lgb_params["learning_rate"] = lr
        callbacks = [
            lgb.log_evaluation(
                period=100,
                show_stdv=True,
            ),
            lgb.early_stopping(
                stopping_rounds=10_000,
                first_metric_only=True,
            ),
        ]
        cv_result = lgb.cv(
            params=lgb_params,
            train_set=lgb_train,
            num_boost_round=1_000_000,
            callbacks=callbacks,
            folds=folds,
            return_cvbooster=True,
        )
        best_iteration = cv_result["cvbooster"].best_iteration
        best_iterations[lr] = best_iteration
        logging.info("best iteration (lr: %f): %d", lr, best_iteration)

        best_performance = cv_result[f"{lgb_params['metric']}-mean"][-1]
        best_performances[lr] = best_performance
        logging.info("best performance (lr: %f): %.6f", lr, best_performance)

    # 結果を 2 軸グラフで可視化する
    fig, ax1 = plt.subplots(1, 1)
    ax1.plot(
        list(best_iterations.keys()),
        list(best_iterations.values()),
        marker="o",
        color="r",
        label="iterations",
    )
    ax1.set_xlabel("learning rate")
    ax1.set_ylabel("best iteration")
    ax1.set_title(f"objective: {lgb_params['objective']}")
    ax2 = ax1.twinx()
    ax2.plot(
        list(best_performances.keys()),
        list(best_performances.values()),
        marker="+",
        color="b",
        label="performances",
    )
    ax2.set_ylabel("best performance")

    axes = [ax1, ax2]
    ax1.legend(
        [ax.get_legend_handles_labels()[0][0] for ax in axes],
        [ax.get_legend_handles_labels()[1][0] for ax in axes],
    )
    fig.savefig(f"{lgb_params['objective']}.png")

    logging.info("best iterations: %s", best_iterations)
    logging.info("best performances: %s", best_performances)


if __name__ == "__main__":
    main()

上記を実行する。 実際に実行すると、それなりに時間がかかるので気長に待つ。

$ python binary.py

... (省略) ...

INFO:root:best iterations: {0.8: 10, 0.4: 24, 0.2: 226, 0.1: 443, 0.05: 950, 0.025: 1832, 0.0125: 3848}
INFO:root:best performances: {0.8: 0.09831524322108712, 0.4: 0.0828017991689846, 0.2: 0.06952118448905203, 0.1: 0.06681424674263811, 0.05: 0.06607646242537414, 0.025: 0.06574445115864827, 0.0125: 0.06539702674132752}

実行が完了すると、以下のようなグラフが得られる。 横軸が学習率で、縦軸が精度 (右) と最適なイテレーション数 (左) になっている。

二値分類タスクにおける関係性

上記から、学習率を下げることで精度が改善する一方、最適なイテレーション数は増加することが確認できる。 とくに、学習率が小さくなると、急激に増加するイテレーション数と比較して精度の向上する幅は小さい。 なお、イテレーション数はニアリーイコールで学習にかかる時間と見なすこともできる。 つまり、ごくわずかな精度の向上を得るために、より多くの学習時間を要することになる。

多値分類タスク

続いては多値分類タスクで確認する。 ただし、変わっているのは生成するデータと LightGBM の学習パラメータが多値分類用のものになったところだけ。 損失とメトリックには、いずれも Softmax を用いる。

import logging

import lightgbm as lgb
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold
from matplotlib import pyplot as plt


def main():
    logging.basicConfig(level=logging.INFO)

    args = {
        "n_samples": 100_000,
        "n_features": 100,
        "n_informative": 10,
        "n_redundant": 0,
        "n_repeated": 0,
        # 多値分類問題 (5 クラス)
        "n_classes": 5,
        "random_state": 42,
        "shuffle": False,
    }
    x, y = make_classification(**args)

    folds = StratifiedKFold(
        n_splits=5,
        shuffle=True,
        random_state=42,
    )

    lgb_train = lgb.Dataset(x, y)
    lgb_params = {
        "objective": "multiclass",
        "num_class": 5,
        "metric": "multi_logloss",
        "seed": 42,
        "deterministic": True,
        "verbose": -1,
    }

    best_iterations = {}
    best_performances = {}
    for lr in [0.8, 0.4, 0.2, 0.1, 0.05, 0.025, 0.0125]:
        lgb_params["learning_rate"] = lr
        callbacks = [
            lgb.log_evaluation(
                period=100,
                show_stdv=True,
            ),
            lgb.early_stopping(
                stopping_rounds=10_000,
                first_metric_only=True,
            ),
        ]
        cv_result = lgb.cv(
            params=lgb_params,
            train_set=lgb_train,
            num_boost_round=1_000_000,
            callbacks=callbacks,
            folds=folds,
            return_cvbooster=True,
        )
        best_iteration = cv_result["cvbooster"].best_iteration
        best_iterations[lr] = best_iteration
        logging.info("best iteration (lr: %f): %d", lr, best_iteration)

        best_performance = cv_result[f"{lgb_params['metric']}-mean"][-1]
        best_performances[lr] = best_performance
        logging.info("best performance (lr: %f): %.6f", lr, best_performance)

    fig, ax1 = plt.subplots(1, 1)
    ax1.plot(
        list(best_iterations.keys()),
        list(best_iterations.values()),
        marker="o",
        color="r",
        label="iterations",
    )
    ax1.set_xlabel("learning rate")
    ax1.set_ylabel("best iteration")
    ax1.set_title(f"objective: {lgb_params['objective']}")
    ax2 = ax1.twinx()
    ax2.plot(
        list(best_performances.keys()),
        list(best_performances.values()),
        marker="+",
        color="b",
        label="performances",
    )
    ax2.set_ylabel("best performance")

    axes = [ax1, ax2]
    ax1.legend(
        [ax.get_legend_handles_labels()[0][0] for ax in axes],
        [ax.get_legend_handles_labels()[1][0] for ax in axes],
    )
    fig.savefig(f"{lgb_params['objective']}.png")

    logging.info("best iterations: %s", best_iterations)
    logging.info("best performances: %s", best_performances)


if __name__ == "__main__":
    main()

上記を実行する。

$ python multiclass.py

... (省略) ...

INFO:root:best iterations: {0.8: 11, 0.4: 85, 0.2: 420, 0.1: 931, 0.05: 1853, 0.025: 3848, 0.0125: 7501}
INFO:root:best performances: {0.8: 0.477352753580447, 0.4: 0.36786071609859394, 0.2: 0.3046891483717674, 0.1: 0.29422504356417634, 0.05: 0.2910298309291036, 0.025: 0.2904041640822766, 0.0125: 0.2902090219284779}

以下のグラフが得られる。

多値分類タスクにおける関係性

多値分類タスクについても、先ほどと概ね同じ傾向が確認できる。 LightGBM は多値分類問題を扱う際に、二値分類するブースティング決定木をクラス数分生成しているはずなので、それはそうという感じかもしれない。

回帰タスク (MSE)

続いては回帰タスクを確認する。 データは回帰タスク用になっており、交差検証は単純な Random KFold で実施する。 損失とメトリックには、いずれも MSE (Mean Squared Error) を使っている。

import logging

import lightgbm as lgb
from sklearn.datasets import make_regression
from sklearn.model_selection import KFold
from matplotlib import pyplot as plt


def main():
    logging.basicConfig(level=logging.INFO)

    # 回帰タスク用の疑似データを生成する
    args = {
        "n_samples": 100_000,
        "n_features": 100,
        "n_informative": 10,
        "random_state": 42,
        "shuffle": False,
    }
    x, y = make_regression(**args)

    # 単純な Random KFold で検証する
    folds = KFold(
        n_splits=5,
        shuffle=True,
        random_state=42,
    )

    lgb_train = lgb.Dataset(x, y)
    lgb_params = {
        "objective": "l1",
        "metric": "l1",
        "seed": 42,
        "deterministic": True,
        "verbose": -1,
    }

    best_iterations = {}
    best_performances = {}
    for lr in [0.8, 0.4, 0.2, 0.1, 0.05, 0.025, 0.0125]:
        lgb_params["learning_rate"] = lr
        callbacks = [
            lgb.log_evaluation(
                period=100,
                show_stdv=True,
            ),
            lgb.early_stopping(
                stopping_rounds=10_000,
                first_metric_only=True,
            ),
        ]
        cv_result = lgb.cv(
            params=lgb_params,
            train_set=lgb_train,
            num_boost_round=1_000_000,
            callbacks=callbacks,
            folds=folds,
            return_cvbooster=True,
        )
        best_iteration = cv_result["cvbooster"].best_iteration
        best_iterations[lr] = best_iteration
        logging.info("best iteration (lr: %f): %d", lr, best_iteration)

        best_performance = cv_result[f"{lgb_params['metric']}-mean"][-1]
        best_performances[lr] = best_performance
        logging.info("best performance (lr: %f): %.6f", lr, best_performance)

    fig, ax1 = plt.subplots(1, 1)
    ax1.plot(
        list(best_iterations.keys()),
        list(best_iterations.values()),
        marker="o",
        color="r",
        label="iterations",
    )
    ax1.set_xlabel("learning rate")
    ax1.set_ylabel("best iteration")
    ax1.set_title(f"objective: {lgb_params['objective']}")
    ax2 = ax1.twinx()
    ax2.plot(
        list(best_performances.keys()),
        list(best_performances.values()),
        marker="+",
        color="b",
        label="performances",
    )
    ax2.set_ylabel("best performance")

    axes = [ax1, ax2]
    ax1.legend(
        [ax.get_legend_handles_labels()[0][0] for ax in axes],
        [ax.get_legend_handles_labels()[1][0] for ax in axes],
    )
    fig.savefig(f"{lgb_params['objective']}.png")

    logging.info("best iterations: %s", best_iterations)
    logging.info("best performances: %s", best_performances)


if __name__ == "__main__":
    main()

上記を実行する。

$ python l2.py

... (省略) ...

INFO:root:best iterations: {0.8: 120, 0.4: 459, 0.2: 1358, 0.1: 8218, 0.05: 18254, 0.025: 58758, 0.0125: 174082}
INFO:root:best performances: {0.8: 620.5233864841325, 0.4: 403.4180953244528, 0.2: 276.98699934231274, 0.1: 168.65622625748594, 0.05: 107.56471173868105, 0.025: 78.86550385866083, 0.0125: 66.78599993592725}

以下のグラフが得られる。

回帰タスク (MSE) の関係性

こちらも、これまでと同様の傾向が確認できる。 ただし、学習率が小さい場合のイテレーション数の増加は二値分類や多値分類に比べてより急峻になっている。

回帰タスク (MAE)

回帰タスクについては損失やメトリックも色々とあるので、一応 MAE (Mean Absolute Error) についても確認しておく。 変更点は損失とメトリックが変更されているところだけ。

import logging

import lightgbm as lgb
from sklearn.datasets import make_regression
from sklearn.model_selection import KFold
from matplotlib import pyplot as plt


def main():
    logging.basicConfig(level=logging.INFO)

    args = {
        "n_samples": 100_000,
        "n_features": 100,
        "n_informative": 10,
        "random_state": 42,
        "shuffle": False,
    }
    x, y = make_regression(**args)

    folds = KFold(
        n_splits=5,
        shuffle=True,
        random_state=42,
    )

    lgb_train = lgb.Dataset(x, y)
    lgb_params = {
        "objective": "l1",
        "metric": "l1",
        "seed": 42,
        "deterministic": True,
        "verbose": -1,
    }

    best_iterations = {}
    best_performances = {}
    for lr in [0.8, 0.4, 0.2, 0.1, 0.05, 0.025, 0.0125]:
        lgb_params["learning_rate"] = lr
        callbacks = [
            lgb.log_evaluation(
                period=100,
                show_stdv=True,
            ),
            lgb.early_stopping(
                stopping_rounds=10_000,
                first_metric_only=True,
            ),
        ]
        cv_result = lgb.cv(
            params=lgb_params,
            train_set=lgb_train,
            num_boost_round=1_000_000,
            callbacks=callbacks,
            folds=folds,
            return_cvbooster=True,
        )
        best_iteration = cv_result["cvbooster"].best_iteration
        best_iterations[lr] = best_iteration
        logging.info("best iteration (lr: %f): %d", lr, best_iteration)

        best_performance = cv_result[f"{lgb_params['metric']}-mean"][-1]
        best_performances[lr] = best_performance
        logging.info("best performance (lr: %f): %.6f", lr, best_performance)

    fig, ax1 = plt.subplots(1, 1)
    ax1.plot(
        list(best_iterations.keys()),
        list(best_iterations.values()),
        marker="o",
        color="r",
        label="iterations",
    )
    ax1.set_xlabel("learning rate")
    ax1.set_ylabel("best iteration")
    ax1.set_title(f"objective: {lgb_params['objective']}")
    ax2 = ax1.twinx()
    ax2.plot(
        list(best_performances.keys()),
        list(best_performances.values()),
        marker="+",
        color="b",
        label="performances",
    )
    ax2.set_ylabel("best performance")

    axes = [ax1, ax2]
    ax1.legend(
        [ax.get_legend_handles_labels()[0][0] for ax in axes],
        [ax.get_legend_handles_labels()[1][0] for ax in axes],
    )
    fig.savefig(f"{lgb_params['objective']}.png")

    logging.info("best iterations: %s", best_iterations)
    logging.info("best performances: %s", best_performances)


if __name__ == "__main__":
    main()

上記を実行する。

$ python l1.py

... (省略) ...

INFO:root:best iterations: {0.8: 105, 0.4: 29015, 0.2: 251095, 0.1: 349269, 0.05: 999746, 0.025: 999900, 0.0125: 999996}
INFO:root:best performances: {0.8: 24.84506083319512, 0.4: 18.973220015575627, 0.2: 14.361268813657691, 0.1: 10.862815071557257, 0.05: 8.626113874984942, 0.025: 7.535406392204019, 0.0125: 7.066955421845141}

以下のグラフが得られる。

回帰タスク (MAE) の関係性

こちらも学習率を下げることで精度が改善する傾向にあることが確認できる。 ただし、MAE では MSE よりもさらに多くのイテレーション数を必要とするようだ。 とくに 0.1 未満の学習率では、あらかじめ設定したイテレーション数の上限 (1M) に達してしまっている。 とはいえ、これ以上のイテレーション数を求めると学習に時間がかかりすぎるため上限は増やさないことにした。

前述したとおり 0.1 未満の学習率ではイテレーション数の上限に達しているため、同じイテレーション数になっている。 にも関わらず、学習率が小さいほど精度が改善している点は印象的に感じる。 なぜなら、学習率が小さいと一般に学習が進むペースも遅くなることから、未学習となって精度が低くなってもおかしくないため。 この点は、Early Stopping がかからないようなごく僅かな改善は続いているものの、学習曲線的にはすでに底に到達しているためかもしれない。 MAE は他の損失関数と比較してゼロ近辺において大きな損失を生むため、そのような振る舞いになっている可能性はある。

上記を、実際に確認してみよう。 以下のサンプルコードでは、学習率が 0.02500.0125 の場合で学習曲線を比較している。 具体的には、交差検証において Out-of-Fold なデータに対する平均的なメトリックをイテレーション毎にプロットする。 先ほどの仮説が正しければ Early Stopping を引き起こさないまでも、それ以前のイテレーションにおいてメトリックはほとんど底を打っているはず。

import logging

import lightgbm as lgb
from sklearn.datasets import make_regression
from sklearn.model_selection import KFold
from matplotlib import pyplot as plt


def main():
    logging.basicConfig(level=logging.INFO)

    args = {
        "n_samples": 100_000,
        "n_features": 100,
        "n_informative": 10,
        "random_state": 42,
        "shuffle": False,
    }
    x, y = make_regression(**args)

    folds = KFold(
        n_splits=5,
        shuffle=True,
        random_state=42,
    )

    lgb_train = lgb.Dataset(x, y)
    lgb_params = {
        "objective": "l1",
        "metric": "l1",
        "seed": 42,
        "deterministic": True,
        "verbose": -1,
    }

    fig, ax1 = plt.subplots(1, 1)
    for lr in [0.025, 0.0125]:
        lgb_params["learning_rate"] = lr
        callbacks = [
            lgb.log_evaluation(
                period=1_000,
                show_stdv=True,
            ),
        ]
        cv_result = lgb.cv(
            params=lgb_params,
            train_set=lgb_train,
            num_boost_round=200_000,
            callbacks=callbacks,
            folds=folds,
            return_cvbooster=True,
        )
        eval_performances = cv_result[f"{lgb_params['metric']}-mean"]

        ax1.plot(
            [i for i, _ in enumerate(eval_performances, start=1)],
            eval_performances,
            label=f"lr: {lr:.4f}",
        )

    ax1.set_ylabel("performance")
    ax1.set_xlabel("iterations")
    ax1.legend()
    ax1.set_title(f"objective: {lgb_params['objective']}")
    fig.savefig(f"learning-curve-{lgb_params['objective']}.png")


if __name__ == "__main__":
    main()

上記を実行する。

$ learningcurve.py
...(省略)...

以下のグラフが得られる。

回帰タスク (MAE) の学習曲線 (lr: 0.0250, 0.0125)

やはり、メトリックは早々に底を打っており、それ以降はダラダラとごく僅かな改善が続いているようだ。 このように明確な過学習をなかなか起こさない状況では既存の Early Stopping が有効に働かない。 そのため「メトリックが悪化したら」ではなく「改善幅が十分に小さくなったら」学習を打ち切るような Early Stopping が必要かもしれない。

まとめ

今回は、勾配ブースティング決定木について経験則として知られている以下について LightGBM を使った実験で確かめた。

  • 学習率 (Learning Rate) を下げることで精度が高まる
  • 一方で、学習にはより多くのイテレーション数 (≒時間) を必要とする

Python: Polars で行・列が省略されないようにする

今回は Python のデータフレームライブラリの Polars で、データフレームを表示するときに行と列が省略されないようにする方法について。 結論から先に述べると、省略したくないときは pl.Config.set_tbl_cols()pl.Config.set_tbl_rows() に負の整数 (たとえば -1) を指定すれば良い。 また、正の整数を指定すれば、その数までは省略されないように振る舞う。

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.2
BuildVersion:   21G320
$ uname -srm
Darwin 21.6.0 arm64
$ python -V
Python 3.10.9
$ python -m pip list | grep polars
polars            0.15.16

もくじ

下準備

サンプルデータとして Diamonds データセットの CSV ファイルをダウンロードしておく。

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

そして、肝心の Polars をインストールする。

$ pip install polars

インストールできたら Python のインタプリタを起動する。

$ python

Polars をインポートして、先ほどダウンロードした CSV ファイルからデータフレームを生成する。

>>> import polars as pl
>>> df = pl.read_csv("diamonds.csv")

列が省略されないようにする

現在のバージョンでは、デフォルトで省略されずに表示される列は 8 列まで。 データフレームにそれ以上の列があるときは、表示するときに中間の列が「...」と省略される。

>>> 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 │
└───────┴─────────┴───────┴─────────┴─────┴───────┴──────┴──────┴──────┘

まったく省略してほしくないときは pl.Config.set_tbl_cols() に負の整数を指定する。

>>> pl.Config.set_tbl_cols(-1)
<class 'polars.cfg.Config'>

すると、次のように何列あっても省略されなくなる。

>>> df.head()
shape: (5, 10)
┌───────┬─────────┬───────┬─────────┬───────┬───────┬───────┬──────┬──────┬──────┐
│ carat ┆ cut     ┆ color ┆ clarity ┆ depth ┆ table ┆ price ┆ x    ┆ y    ┆ z    │
│ ---   ┆ ---     ┆ ---   ┆ ---     ┆ ---   ┆ ---   ┆ ---   ┆ ---  ┆ ---  ┆ ---  │
│ f64   ┆ strstrstr     ┆ f64   ┆ f64   ┆ i64   ┆ f64  ┆ f64  ┆ f64  │
╞═══════╪═════════╪═══════╪═════════╪═══════╪═══════╪═══════╪══════╪══════╪══════╡
│ 0.23  ┆ Ideal   ┆ E     ┆ SI2     ┆ 61.555.03263.953.982.43 │
│ 0.21  ┆ Premium ┆ E     ┆ SI1     ┆ 59.861.03263.893.842.31 │
│ 0.23  ┆ Good    ┆ E     ┆ VS1     ┆ 56.965.03274.054.072.31 │
│ 0.29  ┆ Premium ┆ I     ┆ VS2     ┆ 62.458.03344.24.232.63 │
│ 0.31  ┆ Good    ┆ J     ┆ SI2     ┆ 63.358.03354.344.352.75 │
└───────┴─────────┴───────┴─────────┴───────┴───────┴───────┴──────┴──────┴──────┘

正の整数を指定すると、その列数まで省略されないように振る舞う。 試しに 2 を指定してみよう。

>>> pl.Config.set_tbl_cols(2)

次のように 2 列だけ省略されずに表示されるようになった。

>>> df.head()
shape: (5, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ 0.21  ┆ ... ┆ 2.31 │
│ 0.23  ┆ ... ┆ 2.31 │
│ 0.29  ┆ ... ┆ 2.63 │
│ 0.31  ┆ ... ┆ 2.75 │
└───────┴─────┴──────┘

行が省略されないようにする

同様に、行が省略されないようにしてみよう。 現在のバージョンのデフォルトでは 8 行まで省略されずに表示される。

>>> df.head(10)
shape: (10, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ 0.21  ┆ ... ┆ 2.31 │
│ 0.23  ┆ ... ┆ 2.31 │
│ 0.29  ┆ ... ┆ 2.63 │
│ ...   ┆ ... ┆ ...  │
│ 0.24  ┆ ... ┆ 2.47 │
│ 0.26  ┆ ... ┆ 2.53 │
│ 0.22  ┆ ... ┆ 2.49 │
│ 0.23  ┆ ... ┆ 2.39 │
└───────┴─────┴──────┘

行をまったく省略したくないときは pl.Config.set_tbl_rows() に負の整数を指定すれば良い。

>>> pl.Config.set_tbl_rows(-1)
<class 'polars.cfg.Config'>

これで行が省略されなくなる。

>>> df.head(10)
shape: (10, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ 0.21  ┆ ... ┆ 2.31 │
│ 0.23  ┆ ... ┆ 2.31 │
│ 0.29  ┆ ... ┆ 2.63 │
│ 0.31  ┆ ... ┆ 2.75 │
│ 0.24  ┆ ... ┆ 2.48 │
│ 0.24  ┆ ... ┆ 2.47 │
│ 0.26  ┆ ... ┆ 2.53 │
│ 0.22  ┆ ... ┆ 2.49 │
│ 0.23  ┆ ... ┆ 2.39 │
└───────┴─────┴──────┘

明示的に省略されるまでの行数を指定したいときは、先ほどと同じように正の整数を指定する。

>>> pl.Config.set_tbl_rows(2)
<class 'polars.cfg.Config'>
>>> df.head(10)
shape: (10, 10)
┌───────┬─────┬──────┐
│ carat ┆ ... ┆ z    │
│ ---   ┆     ┆ ---  │
│ f64   ┆     ┆ f64  │
╞═══════╪═════╪══════╡
│ 0.23  ┆ ... ┆ 2.43 │
│ ...   ┆ ... ┆ ...  │
│ 0.23  ┆ ... ┆ 2.39 │
└───────┴─────┴──────┘

いじょう。

参考

pola-rs.github.io

pola-rs.github.io

OpenSSH で過去に作った鍵の種類や長さを確かめる

今回は OpenSSH で過去に作った鍵の種類や長さを確認する方法について。 結論から先に述べると ssh-keygen(1) の -l オプションと -f オプションを組み合わせれば良い。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.1 LTS"
$ uname -srm
Linux 5.15.0-58-generic x86_64
$ ssh -V
OpenSSH_8.9p1 Ubuntu-3ubuntu0.1, OpenSSL 3.0.2 15 Mar 2022

もくじ

下準備

あらかじめ OpenSSH のクライアントをインストールしておく。

$ sudo apt-get update
$ sudo apt-get -y install openssh-client

動作確認用の公開鍵ペアを生成する

動作確認用に RSA で公開鍵のペアを作っておく。 パスフレーズは空で、鍵の名前は id_rsa にした。

$ ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa
Generating public/private rsa key pair.
Your identification has been saved in /home/ubuntu/.ssh/id_rsa
Your public key has been saved in /home/ubuntu/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:FPI5/OZLR94xU83YcUQ82sD7k6ckRqh7bL67tGXcL2U ubuntu@jammy
The key's randomart image is:
+---[RSA 3072]----+
|      . .   .  =+|
|       + o   o ==|
|        *  .  * *|
|       . o. .o o |
|        S.o.. = .|
|        .o +oo.BE|
|         o+.*oo+o|
|        .o+*  o. |
|         +B+   ..|
+----[SHA256]-----+

上記から、どうやら 3072 ビットの RSA で公開鍵ペアが生成されたようだ。

鍵の種類や長さを確認する

鍵の種類や長さを確認するには ssh-keygen(1) の -l オプションと -f オプションを組み合わせて使う。 -l オプションを使うことで公開鍵のフィンガープリントと付随する情報を出力するモードになる。 -f オプションでは鍵のファイルパスを指定する。

$ ssh-keygen -l -f ~/.ssh/id_rsa
3072 SHA256:FPI5/OZLR94xU83YcUQ82sD7k6ckRqh7bL67tGXcL2U ubuntu@jammy (RSA)

上記から、この鍵では 3072 ビットの RSA を使っていることが確認できる。

上記では秘密鍵の方を指定していたけど、もちろん公開鍵の方を指定しても問題ない。

$ ssh-keygen -l -f ~/.ssh/id_rsa.pub
3072 SHA256:FPI5/OZLR94xU83YcUQ82sD7k6ckRqh7bL67tGXcL2U ubuntu@jammy (RSA)

別の鍵の種類でも確認する

念の為、別の鍵の種類でも試しておく。 ここでは ED25519 を使った。

$ ssh-keygen -t ed25519 -P '' -f ~/.ssh/id_ed25519
Generating public/private ed25519 key pair.
Your identification has been saved in /home/ubuntu/.ssh/id_ed25519
Your public key has been saved in /home/ubuntu/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:pmGTVjlzXSv1vJLpwNiRpCF4Q62hyXpys3qPWyrE2lM ubuntu@jammy
The key's randomart image is:
+--[ED25519 256]--+
|      oo.. .  o  |
|     . +.o+..o + |
|    . + O..oo . o|
|     + + ++ ..o .|
|  . . * S. + + . |
|   = E =    o .  |
|  + = +.     .   |
| . + o+          |
|   .=+o.         |
+----[SHA256]-----+

上記から、どうやら 256 ビットの ED25519 の公開鍵ペアが生成されたことが確認できる。 なお、ED25519 はビット長が 256 で固定らしい。

先ほどと同じように ssh-keygen(1) で確認する。

$ ssh-keygen -l -f ~/.ssh/id_ed25519.pub 
256 SHA256:pmGTVjlzXSv1vJLpwNiRpCF4Q62hyXpys3qPWyrE2lM ubuntu@jammy (ED25519)
$ ssh-keygen -l -f ~/.ssh/id_ed25519
256 SHA256:pmGTVjlzXSv1vJLpwNiRpCF4Q62hyXpys3qPWyrE2lM ubuntu@jammy (ED25519)

ちゃんと 256 ビットの ED25519 であることが確認できた。

いじょう。

2023-01-24 追記

フィンガープリントを計算する際のハッシュ形式と署名で用いられるハッシュ形式を混同している点をご指摘いただいたため修正しました。 ありがとうございます。

Target Encoding のスムージングについて

Target (Mean) Encoding の出典は、2001 年の ACM SIGKDD Explorations Newsletter, Volume 3, Issue 1 に掲載された以下の論文らしい。

https://dl.acm.org/doi/10.1145/507533.507538

この論文には Target Encoding のスムージングに関する詳しい記述があった。 そこで、その内容を元に巷のフレームワークがどのようにスムージングを実装しているかを併せて調査してみた。 自分用のメモも兼ねて、ここに書き残すことにする。

なお、Target Encoding 自体の説明については以下を参照のこと。

blog.amedama.jp

もくじ

なぜスムージングするのか

そもそも、なぜスムージングが必要になるのか。 それは、スムージングのない Target Encoding は、少数のサンプルしか含まれないカテゴリにおいて極端な値を取りやすいため。

たとえば、二値分類のタスクを題材にして考えてみよう。 極端なケースとして、学習データの中にサンプルが 1 つしか含まれないカテゴリがあった場合の計算について考える。 ただし、単純のため Target Leakage を軽減するための Out-of-fold (OOF) での計算は考えない。 この場合、スムージングのないナイーブは Target Encoding の計算は論文の (2) 式に対応する。

 S_i = \frac{ n_iY }{ n_i }

上記において  S_{i} が、あるカテゴリ  i の Target Statistics になる。  n_i はカテゴリ  i に属するサンプル数を表す。  n_iY はカテゴリ  i に属する  Y = 1 のサンプル数を表している。

もし、1 つしかないサンプルの目的変数が 1 だとすると、Target Statistics は  S_i = \frac{1}{1} = 1 になる。 では、このカテゴリについて未知のテストデータにおいても Target Statistics が 1 と信じることは妥当だろうか。 直感的には、サンプル数が不足していることから信頼できないと考えるはず。

このような値をモデルが信用して学習に利用してしまうと、過学習を引き起こす恐れ 1 がある。

Empirical Bayesian (EB) を用いたスムージング

前述した問題を緩和するためにスムージングを導入する。 やりたいこととしては、少数のサンプルしか含まれないカテゴリにおいても極端な値を取らないようにしたい。 これには、学習データ全体で計算した目的変数の平均を計算結果に「ブレンド」する。 論文で最初に示されている方法は以下で、これは (3) 式に対応する。 なお、この式は Empirical Bayesian という手法に由来するらしい。

 S_i = \lambda (n_i) \frac{ n_iY }{ n_i } + (1 - \lambda (n_i) ) \frac{ n_Y }{ n_{TR} }

上記は、第 1 項があるカテゴリ  i について計算した値で、第 2 項が学習データ全体について計算した値になる。 第 2 項に登場する  n_{TR} は学習データに含まれるサンプル数で、 n_Y が学習データの中で  Y = 1 なサンプル数を表している。 そして、 \lambda (n_i) がスムージングの強度を決める (出力は 0 ~ 1)。 もし  \lambda (n_i) = 0 なら完全に学習データ全体の平均になり、 \lambda (n_i) = 1 なら完全にカテゴリ  i の平均になる。

 \lambda (n_i) の計算方法については、以下が例として挙げられている。 これは論文において (4) 式に対応する。

 \lambda (n) = \frac{1}{1 + e^{\frac{n - k}{f}} }

上記で  k  f は特性を決めるハイパーパラメータとなる。 つまり、対象のデータと扱うモデルごとに最適な値が存在する。

category_encoders の実装

この Empirical Bayesian に由来するスムージングは category_encoders の TargetEncoder が採用している。

ソースコードにおいて、以下が (4) 式に対応する。

github.com

そして、(4) 式で求めたスムージングの強度を、以下の (3) 式に対応する箇所で使っている。

github.com

m-probability estimate を用いたスムージング

論文では、もうひとつ簡略化したスムージングの手法が紹介されている。 これは Additive Smoothing の一種で、m-probability estimate と呼ばれるやり方らしい。 以下は論文の (7) 式に対応する

 p^*_c = \frac{ n_c + p_c m }{ n + m }

上記で、 p^*_c が、あるカテゴリ  c の Target Statistics になる。  n は、あるカテゴリに属するサンプル数を表す。  n_c は、あるカテゴリ  c に属する  Y = 1 なサンプル数を表す。  m は、スムージングの強度を決めるハイパーパラメータを表す (0 ~)。  m = 0 のときスムージングしないことになる。  p_c は、あるカテゴリ  c の事前確率を表す。 典型的には全カテゴリの平均的な Target Statistics を使えば良さそうだ。

category_encoders の実装

この m-probability estimate を用いたスムージングは category_encoders の MEstimateEncoder が採用している。

ソースコードにおいて、以下が (7) 式に対応する。

github.com

cuML の実装

同様に、cuML の TargetEncoder もスムージングに m-probability estimate を採用している。

ソースコードにおいて、以下が (7) 式に対応する。

github.com

まとめ

今回は Target Encoding の出典となる論文を読んで、スムージングの手法について調べた。 また、各フレームワークがどのようなスムージングを導入しているのか実装を調べた。


  1. 実際に問題となるかは対象のデータや扱うモデルの特性に依存する

Python: PyTorch でバックプロパゲーションが上手くいかない場所を自動で見つける

PyTorch を使っていると、はるか遠く離れた場所で計算した結果に nan や inf が含まれることで、思いもよらない場所から非直感的なエラーを生じることがある。 あるいは、自動微分したときにゼロ除算が生じるようなパターンでは、順伝搬の結果だけ見ていても原因にたどり着くことが難しい。 こういった問題は、デバッガなどを使って地道に原因を探ろうとすると多くの手間と時間がかかる。

そんな折、PyTorch にはそうした問題に対処する上で有益な機能があることを知った。 具体的には、以下の関数を使うと自動でバックプロパゲーションが上手くいかない箇所を見つけることができる。 今回は、この機能について書いてみる。

  • torch.autograd.set_detect_anomaly()
  • torch.autograd.detect_anomaly()

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

$ sw_vers
ProductName:    macOS
ProductVersion: 12.6.2
BuildVersion:   21G320
$ python -V
Python 3.10.9
$ pip list | grep -i torch
torch                        1.13.1

もくじ

下準備

あらかじめ PyTorch と NumPy をインストールしておく。

$ pip install torch numpy

入力によって逆伝搬が上手くいかないコード (1)

例として RMSLE (Root Mean Squared Logarithmic Error) を計算する場合について考える。 以下のサンプルコードでは、RMSLE の計算を RMSLELoss というクラスで実装している。 このコードは入力によってはバックプロパゲーションが上手くいかない。 具体的には、入力されるモデルの予測 (y_pred) に -1 以下の値が含まれたとき torch.log1p() の返り値に inf が含まれる。

import torch
from torch import nn


class RMSLELoss(nn.Module):
    """Root Mean Squared Logarithmic Error"""

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        # ここで y_pred に -1 以下の値が含まれると順伝搬の返り値が -inf になる
        log_y_pred = torch.log1p(y_pred)
        log_y_true = torch.log1p(y_true)
        # 入力に -inf が含まれることで返り値が inf になってしまう
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    # モデルの出力に -1 以下の値が含まれるとする
    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    # 順伝搬
    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    # 結果
    print(out)

    # 逆伝搬
    out.backward()

    # 勾配
    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

上記を実行してみよう。 入力に -1 以下の値が入ると、最終的な結果が inf になっている。 そして y_predy_true の勾配に nan が確認できる。

tensor(inf, dtype=torch.float64, grad_fn=<SqrtBackward0>)
tensor([nan, -0., -0.], dtype=torch.float64)
tensor([nan, 0., 0.], dtype=torch.float64)

上手くいかない箇所を自動で見つける

では、今回の主題となる torch.autograd.set_detect_anomaly() を使ってみよう。 この関数には、第一引数に真偽値のフラグを渡して機能の有効・無効を切り替える。 もちろんデフォルトでは機能は無効となっており、デバッグをするときだけ有効にすることが推奨されている。 これは、機能を有効にするとバックプロパゲーションにおいて値のチェックが逐一入ることによるオーバーヘッドが生じるため。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        log_y_pred = torch.log1p(y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    # 逆伝搬で nan になるケースを自動で見つける
    torch.autograd.set_detect_anomaly(True)

    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()

    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、MseLossBackward0 の結果において値に nan が含まれることが示されている。

$ python anodet.py       
tensor(inf, dtype=torch.float64, grad_fn=<SqrtBackward0>)
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py:197: UserWarning: Error detected in MseLossBackward0. Traceback of forward call that caused the error:
  File "/Users/amedama/Documents/temporary/anodet.py", line 35, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 27, in main
    out = loss_fn(y_pred, y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/Documents/temporary/anodet.py", line 14, in forward
    msle = self.mse_loss(log_y_pred, log_y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/loss.py", line 536, in forward
    return F.mse_loss(input, target, reduction=self.reduction)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/functional.py", line 3292, in mse_loss
    return torch._C._nn.mse_loss(expanded_input, expanded_target, _Reduction.get_enum(reduction))
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/fx/traceback.py", line 57, in format_stack
    return traceback.format_stack()
 (Triggered internally at /Users/runner/work/pytorch/pytorch/pytorch/torch/csrc/autograd/python_anomaly_mode.cpp:119.)
  Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
Traceback (most recent call last):
  File "/Users/amedama/Documents/temporary/anodet.py", line 35, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 31, in main
    out.backward()
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/_tensor.py", line 488, in backward
    torch.autograd.backward(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py", line 197, in backward
    Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
RuntimeError: Function 'MseLossBackward0' returned nan values in its 0th output.

このように、バックプロパゲーションが上手くいかない場所を自動で検知できた。

問題を修正する (1)

では、問題を修正するため試しに y_pred の値の下限が 0 となるように torch.clamp() の処理を挟んでみよう。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        # 入力の下限を 0. に制限する
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    torch.autograd.set_detect_anomaly(True)

    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()


if __name__ == '__main__':
    main()

実行すると、今度は例外にならずに済んでいる。 y_predy_true の勾配にも nan は登場しない。

$ python anodet.py 
tensor(1.1501, dtype=torch.float64, grad_fn=<SqrtBackward0>)
tensor([0.0000, -0.4018, -0.1328], dtype=torch.float64)
tensor([0.1061, 0.1004, 0.0531], dtype=torch.float64)

入力によって逆伝搬が上手くいかないコード (2)

さて、これで万事解決かと思いきや、実はまだ問題が残っている。 損失がゼロになるときを考えると torch.sqrt() のバックプロパゲーションにおいてゼロ除算が生じるため。 これは順伝搬では値に nan や inf が登場しないことから問題に気づきにくそう。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        # 損失がゼロのときは...?
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    torch.autograd.set_detect_anomaly(True)

    # 損失がゼロになるパターンを考えてみると...?
    y_pred = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()

    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

実行すると、今度も MseLossBackward0 において結果に nan が含まれると指摘されている。

$ python anodet.py 
tensor(0., dtype=torch.float64, grad_fn=<SqrtBackward0>)
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py:197: UserWarning: Error detected in MseLossBackward0. Traceback of forward call that caused the error:
  File "/Users/amedama/Documents/temporary/anodet.py", line 36, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 28, in main
    out = loss_fn(y_pred, y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/Documents/temporary/anodet.py", line 15, in forward
    msle = self.mse_loss(log_y_pred, log_y_true)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1194, in _call_impl
    return forward_call(*input, **kwargs)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/modules/loss.py", line 536, in forward
    return F.mse_loss(input, target, reduction=self.reduction)
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/nn/functional.py", line 3292, in mse_loss
    return torch._C._nn.mse_loss(expanded_input, expanded_target, _Reduction.get_enum(reduction))
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/fx/traceback.py", line 57, in format_stack
    return traceback.format_stack()
 (Triggered internally at /Users/runner/work/pytorch/pytorch/pytorch/torch/csrc/autograd/python_anomaly_mode.cpp:119.)
  Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
Traceback (most recent call last):
  File "/Users/amedama/Documents/temporary/anodet.py", line 36, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 32, in main
    out.backward()
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/_tensor.py", line 488, in backward
    torch.autograd.backward(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py", line 197, in backward
    Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
RuntimeError: Function 'MseLossBackward0' returned nan values in its 0th output.

問題を修正する (2)

では、先ほどの問題を修正するために torch.sqrt() の計算に小さな値を足してみよう。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self, epsilon=1e-5):
        super().__init__()
        self.mse_loss = nn.MSELoss()
        self.epsilon = epsilon

    def forward(self, y_pred, y_true):
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        # ゼロ除算が生じないように小さな値を足す
        rmsle_loss = torch.sqrt(msle + self.epsilon)
        return rmsle_loss


def main():
    torch.autograd.set_detect_anomaly(True)

    y_pred = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([1., 2., 3.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    out.backward()

    print(y_pred.grad)
    print(y_true.grad)


if __name__ == '__main__':
    main()

実行すると、今度は例外にならない。

$ python anodet.py
tensor(0.0032, dtype=torch.float64, grad_fn=<SqrtBackward0>)
tensor([0., 0., 0.], dtype=torch.float64)
tensor([0., 0., 0.], dtype=torch.float64)

特定のスコープでチェックする

ちなみに、特定のスコープでだけ backward() の結果をチェックしたいときは torch.autograd.detect_anomaly() が使える。 これはコンテキストマネージャになっているため、チェックしたい部分にだけ入れて使うことができる。

import torch
from torch import nn


class RMSLELoss(nn.Module):

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        log_y_pred = torch.log1p(y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    y_pred = torch.tensor([-1., 0., 1.], dtype=torch.float64, requires_grad=True)
    y_true = torch.tensor([2., 3., 4.], dtype=torch.float64, requires_grad=True)

    loss_fn = RMSLELoss()
    out = loss_fn(y_pred, y_true)

    print(out)

    # 特定のスコープでチェックする
    with torch.autograd.detect_anomaly():
        out.backward()


if __name__ == '__main__':
    main()

とはいえ、そんなに出番は無さそうかな。 また、トレースバックに含まれる情報も torch.autograd.set_detect_anomaly() より少なくなっているようだ。

$ python anodet.py 
tensor(inf, dtype=torch.float64, grad_fn=<SqrtBackward0>)
/Users/amedama/Documents/temporary/anodet.py:29: UserWarning: Anomaly Detection has been enabled. This mode will increase the runtime and should only be enabled for debugging.
  with torch.autograd.detect_anomaly():
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py:197: UserWarning: Error detected in MseLossBackward0. No forward pass information available. Enable detect anomaly during forward pass for more information. (Triggered internally at /Users/runner/work/pytorch/pytorch/pytorch/torch/csrc/autograd/python_anomaly_mode.cpp:97.)
  Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
Traceback (most recent call last):
  File "/Users/amedama/Documents/temporary/anodet.py", line 34, in <module>
    main()
  File "/Users/amedama/Documents/temporary/anodet.py", line 30, in main
    out.backward()
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/_tensor.py", line 488, in backward
    torch.autograd.backward(
  File "/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/torch/autograd/__init__.py", line 197, in backward
    Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
RuntimeError: Function 'MseLossBackward0' returned nan values in its 0th output.

まとめ

今回は PyTorch でバックプロパゲーションが上手くいかない場所を自動で見つけることのできる機能を試してみた。

参考

pytorch.org

pytorch.org

Python: TabNet を使ってみる

一般に、テーブルデータの教師あり学習では、勾配ブースティング決定木の性能の良さについて語られることが多い。 これは、汎化性能の高さや前処理の容易さ、学習・推論の速さ、解釈可能性の高さなどが理由として挙げられる。 一方で、ニューラルネットワークをテーブルデータに適用する取り組みについても、以前から様々な試みがある。 今回は、その中でも近年機械学習コンテストにおいて結果を残している TabNet というモデルを試してみる。 TabNet には Unsupervised pre-training と Supervised fine-tuning を組み合わせた学習方法や、モデルの解釈可能性を向上させる試みなどに特徴がある。

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

$ 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-56-generic x86_64
$ python -V
Python 3.10.9
$ pip list | grep -i torch
pytorch-tabnet               4.0
torch                        1.13.1

もくじ

下準備

あらかじめ利用するパッケージをインストールしておく。 pytorch-tabnet は非公式ながら PyTorch を使った TabNet の最も有名な実装となる。

$ pip install pytorch-tabnet seaborn scikit-learn

回帰タスク (Diamonds データセット)

今回は試しに Diamonds データセット 1 について、価格 (price) カラムを目的変数として RMSLE で評価してみよう。

早速だけど以下にサンプルコードを示す。 TabNet の特徴として Unsupervised pre-training で学習した重みを Supervised fine-tuning の学習に転用する点が挙げられる。 これらは、それぞれ TabNetPretrainerTabNetRegressor というクラスとして表現されている。 いずれも scikit-learn の Transformer API を実装しているため簡便に扱うことができる。 なお、Unsupervised pre-training は必須ではないことから、精度を落としても構わなければ省略できる。 ハイパーパラメータなどについては、デフォルトからいじっているのは Entity Embedding の埋め込み次元数と学習率、エポック数くらい。

import math

import numpy as np
import pandas as pd
import seaborn as sns
from pytorch_tabnet.pretraining import TabNetPretrainer
from pytorch_tabnet.tab_model import TabNetRegressor
from sklearn.compose import ColumnTransformer
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import OrdinalEncoder
import torch
from torch import nn
from matplotlib import pyplot as plt


def rmsle_metric(y_pred, y_true):
    """Root Mean Squared Logarithmic Error を計算する関数"""
    clamped_y_pred = np.clip(y_pred, a_min=0., a_max=None)
    log_y_pred = np.log1p(clamped_y_pred)
    log_y_true = np.log1p(y_true)
    return mean_squared_error(log_y_pred, log_y_true, squared=False)


class RMSLELoss(nn.Module):
    """PyTorch で RMSLE を計算するモジュール"""

    def __init__(self):
        super().__init__()
        self.mse_loss = nn.MSELoss()

    def forward(self, y_pred, y_true):
        # 入力が -1 以下になると NaN になるため値に下限を設ける
        clamped_y_pred = torch.clamp(y_pred, min=0.)
        log_y_pred = torch.log1p(clamped_y_pred)
        log_y_true = torch.log1p(y_true)
        msle = self.mse_loss(log_y_pred, log_y_true)
        rmsle_loss = torch.sqrt(msle)
        return rmsle_loss


def main():
    # Diamonds データセットを読み込む
    df = sns.load_dataset('diamonds')
    x, y = df.drop(['price'], axis=1), df['price'].values

    # カテゴリ変数は Ordinal Encode する
    categorical_cut_pipeline = Pipeline(steps=[
        ("cut-ordinal", OrdinalEncoder())
    ])
    categorical_color_pipeline = Pipeline(steps=[
        ("color-ordinal", OrdinalEncoder())
    ])
    categorical_clarity_pipeline = Pipeline(steps=[
        ("clarity-ordinal", OrdinalEncoder())
    ])
    # 連続変数は標準化する
    numerical_pipeline = Pipeline(steps=[
        ("numerical", StandardScaler()),
    ])
    pipeline = ColumnTransformer(transformers=[
        ("categorical-cut-pipeline", categorical_cut_pipeline, ["cut"]),
        ("categorical-color-pipeline", categorical_color_pipeline, ["color"]),
        ("categorical-clarity-pipeline", categorical_clarity_pipeline, ["clarity"]),
        ("numerical-pipeline", numerical_pipeline, ["carat", "depth", "table", "x", "y", "z"])
    ])
    transformed_x = pipeline.fit_transform(x)

    # 最終的な汎化性能の評価に使うホールドアウト
    train_x, test_x, train_y, test_y = train_test_split(
        transformed_x,
        y.reshape(-1, 1),
        test_size=0.33,
        random_state=42,
        shuffle=True,
    )

    # 学習に使うために学習データと評価データを分割する
    train_x, eval_x, train_y, eval_y = train_test_split(
        train_x,
        train_y,
        test_size=0.2,
        random_state=42,
        shuffle=True,
    )

    # カテゴリ変数のインデックスとユニークな値の数を計算しておく
    categorical_index_with_modalities = {
        i: np.unique(transformed_x[:, i]).shape[0]
        for i in [0, 1, 2]
    }

    # 学習パラメータ
    tabnet_params = {
        # カテゴリ変数は Entity Embedding する
        "cat_idxs": list(categorical_index_with_modalities.keys()),
        "cat_dims": list(categorical_index_with_modalities.values()),
        # 埋め込み次元数はユニークな値の数から目安を決める
        "cat_emb_dim": [math.ceil(n ** 0.25) for n in categorical_index_with_modalities.values()],
        "optimizer_params": {
            "lr": 2e-3,
        },
    }

    # Unsupervised pre-training
    unsupervised_model = TabNetPretrainer(**tabnet_params)
    unsupervised_model.fit(
        X_train=train_x,
        eval_set=[
            eval_x,
        ],
        max_epochs=1_000,
        # 精度の改善が見られないときに Early stopping をかけるエポック数
        patience=100,
    )

    # Supervised fine-tuning
    clf = TabNetRegressor(**tabnet_params)
    clf.fit(
        X_train=train_x,
        y_train=train_y,
        eval_set=[
            (eval_x, eval_y),
        ],
        loss_fn=RMSLELoss(),
        eval_metric=[
            "rmsle",
        ],
        max_epochs=1_000,
        patience=100,
        from_unsupervised=unsupervised_model,
    )

    test_y_pred = clf.predict(test_x)

    # ホールドアウトしたテストデータで汎化性能を求める
    test_loss = rmsle_metric(test_y_pred, test_y)
    print(f"Test Loss: {test_loss:.8f}")

    # 特徴量の重要度を可視化する
    fig, ax = plt.subplots(1, 1)
    feature_imprtance = pd.Series(data={k: v for k, v in zip(x.columns, clf.feature_importances_)})
    feature_imprtance.sort_values().plot(kind="barh", ax=ax)
    ax.set_xlabel("importance")
    ax.set_ylabel("features")
    fig.savefig("importance.png")


if __name__ == '__main__':
    main()

上記を実行してみよう。 GPU (CUDA) は認識されていれば自動で使われる。 マシンの計算リソースによっては、だいぶ時間がかかる。

$ python tabnet_diamonds.py

...(snip)...

epoch 811| loss: 0.11108 | val_0_rmsle: 0.01169 |  0:20:33s
epoch 812| loss: 0.11114 | val_0_rmsle: 0.01161 |  0:20:34s
epoch 813| loss: 0.11284 | val_0_rmsle: 0.01422 |  0:20:36s

Early stopping occurred at epoch 813 with best_epoch = 713 and best_val_0_rmsle = 0.01049
/Users/amedama/.virtualenvs/py310/lib/python3.10/site-packages/pytorch_tabnet/callbacks.py:172: UserWarning: Best weights from best epoch are automatically used!
  warnings.warn(wrn_msg)
Test Loss: 0.10144833

テストデータに対して 0.10144833 という精度が得られた。

また、次のような特徴量の重要度を可視化したグラフが得られる。

TabNet の特徴量重要度

参考: LightGBM の場合のスコア

参考までに、勾配ブースティング決定木の代表として LightGBM を使った場合についても記載する。 あらかじめ LightGBM のパッケージをインストールする。

$ pip install lightgbm

サンプルコードは次のとおり。 LightGBM には学習用の損失関数として RMSLE が用意されていない。 そこで、代わりに目的変数を numpy.log1p() で変換した上で RMSE で学習している。

import numpy as np
import seaborn as sns
from sklearn.compose import ColumnTransformer
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import OrdinalEncoder
import lightgbm as lgb


def rmse_metric(y_pred, y_true):
    return mean_squared_error(y_pred, y_true, squared=False)


def main():
    df = sns.load_dataset('diamonds')
    x, y = df.drop(['price'], axis=1), df['price'].values

    y = np.log1p(y)

    categorical_cut_pipeline = Pipeline(steps=[
        ("cut-ordinal", OrdinalEncoder())
    ])
    categorical_color_pipeline = Pipeline(steps=[
        ("color-ordinal", OrdinalEncoder())
    ])
    categorical_clarity_pipeline = Pipeline(steps=[
        ("clarity-ordinal", OrdinalEncoder())
    ])
    numerical_pipeline = Pipeline(steps=[
        ("numerical", StandardScaler()),
    ])
    pipeline = ColumnTransformer(transformers=[
        ("categorical-cut-pipeline", categorical_cut_pipeline, ["cut"]),
        ("categorical-color-pipeline", categorical_color_pipeline, ["color"]),
        ("categorical-clarity-pipeline", categorical_clarity_pipeline, ["clarity"]),
        ("numerical-pipeline", numerical_pipeline, ["carat", "depth", "table", "x", "y", "z"])
    ])
    transformed_x = pipeline.fit_transform(x)

    train_x, test_x, train_y, test_y = train_test_split(
        transformed_x,
        y.reshape(-1, 1),
        test_size=0.33,
        random_state=42,
        shuffle=True,
    )

    train_x, eval_x, train_y, eval_y = train_test_split(
        train_x,
        train_y,
        test_size=0.2,
        random_state=42,
        shuffle=True,
    )

    lgb_train = lgb.Dataset(train_x, train_y)
    lgb_eval = lgb.Dataset(eval_x, eval_y, reference=lgb_train)

    lgb_params = {
        'objective': 'regression',
        'metric': "rmse",
        'verbose': -1,
        'seed': 42,
        'deterministic': True,
    }
    callbacks = [
        lgb.log_evaluation(period=10),
        lgb.early_stopping(
            stopping_rounds=100,
            first_metric_only=True,
        ),
    ]

    booster = lgb.train(
        params=lgb_params,
        train_set=lgb_train,
        valid_sets=[lgb_train, lgb_eval],
        num_boost_round=1_000,
        callbacks=callbacks,
    )

    test_y_pred = booster.predict(test_x,
                                  num_iteration=booster.best_iteration)

    test_loss = rmse_metric(test_y_pred, test_y)
    print(f"Test Loss: {test_loss:.8f}")


if __name__ == '__main__':
    main()

上記の実行結果は次のとおり。

$ python lgbm_diamonds.py

... (snip) ...

[700]  training's rmse: 0.0670463  valid_1's rmse: 0.0860752
[710]  training's rmse: 0.0668764  valid_1's rmse: 0.0860719
[720]  training's rmse: 0.0667363  valid_1's rmse: 0.0860665
Early stopping, best iteration is:
[623]  training's rmse: 0.0684748  valid_1's rmse: 0.0860188
Evaluated only: rmse
Test Loss: 0.08698780

テストデータに対して 0.08698780 という精度が得られた。

今回の条件においては LightGBM に分があるようだが、これはもちろんデータの性質などにも依存する。 また、性能を向上させる試みとして、勾配ブースティング決定木とニューラルネットワークをアンサンブルするのが常套手段である点も見逃すことはできない。

参考

github.com

arxiv.org


  1. データの量がさほど多くないため、ニューラルネットワークが得意とするデータの性質ではないかもしれない

Linux でリンクアグリゲーション (LAG) を試してみる

リンクアグリゲーションは、複数のネットワークインターフェイスを束ねて扱う技術の総称。 たとえば、2 本のイーサネットを束ねて冗長化することで 1 本に障害が起こってもサービスを提供し続けることができる。 あるいは、フレームをロードバランスすることで単一のイーサネットよりもスループットを向上させる用途でも用いられる。 今回は Linux のリンクアグリゲーションの実装であるボンディング (Bonding) を Network Namespace と共に使ってみよう。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=22.04
DISTRIB_CODENAME=jammy
DISTRIB_DESCRIPTION="Ubuntu 22.04.1 LTS"
$ uname -srm
Linux 5.15.0-56-generic aarch64
$ ip -V
ip utility, iproute2-5.15.0, libbpf 0.5.0

もくじ

下準備

まずは利用するパッケージをインストールしておく。

$ sudo apt-get update
$ sudo apt-get install iproute2 tcpdump iputils-ping

ネットワークを構築する

ここからは Network Namespace と Virtual Ethernet Device を使ってネットワークを構築していく。 今回は ns1ns2bridge という名前で Network Namespace を用意してつないでいく。 物理構成としては [ns1]=[bridge]-[ns2] という感じ。 ns1bridge の間が 2 本のネットワークインターフェイスでつながる。

$ sudo ip netns add ns1
$ sudo ip netns add ns2
$ sudo ip netns add bridge

続いて Virtual Ethernet Device を追加する。 ここで ns1 に所属させる Virtual Ethernet Device は ns1-veth0ns1-veth1 という 2 つを用意する。 これらをボンディングで冗長化する。

$ sudo ip link add ns1-veth0 type veth peer name ns1-veth0-br0
$ sudo ip link add ns1-veth1 type veth peer name ns1-veth1-br0
$ sudo ip link add ns2-veth0 type veth peer name ns2-br0

それぞれのネットワークインターフェイスを Network Namespace に所属させていく。

$ sudo ip link set ns1-veth0 netns ns1
$ sudo ip link set ns1-veth1 netns ns1
$ sudo ip link set ns2-veth0 netns ns2
$ sudo ip link set ns1-veth0-br0 netns bridge
$ sudo ip link set ns1-veth1-br0 netns bridge
$ sudo ip link set ns2-br0 netns bridge

ns1 側のネットワークインターフェイスの設定をする。 まずは ns1-bond0 という名前でボンディング用のインターフェイスを用意する。 miimon というのは MII (Media Independent Interface) で何 ms ごとにインターフェイスの状態を監視するかを表している。 その後ろの mode active-backup はボンディングのやり方で、active-backup だと 1 つのインターフェイスだけがアクティブになって通信に使われる。

$ sudo ip netns exec ns1 ip link add ns1-bond0 type bond miimon 100 mode active-backup

ボンディング用のインターフェイスに 2 つの Virtual Network Device を所属させる。

$ sudo ip netns exec ns1 ip link set ns1-veth0 master ns1-bond0
$ sudo ip netns exec ns1 ip link set ns1-veth1 master ns1-bond0
$ sudo ip netns exec ns1 ip link set ns1-veth0 up
$ sudo ip netns exec ns1 ip link set ns1-veth1 up

ボンディング用のインターフェイスの状態を UP に設定して、IP アドレスを付与する。 これで 2 つの Virtual Ethernet Device を冗長化した形でボンディング用のインターフェイスが使えるようになった。

$ sudo ip netns exec ns1 ip link set ns1-bond0 up
$ sudo ip netns exec ns1 ip address add 192.0.2.1/24 dev ns1-bond0

次は ns2 の方のインターフェイスを設定する。 こちらは特に説明すべきことはない。

$ sudo ip netns exec ns2 ip link set ns2-veth0 up
$ sudo ip netns exec ns2 ip address add 192.0.2.2/24 dev ns2-veth0

次に bridge のインターフェイスを設定する。 Linux ブリッジを用意して、それぞれのネットワークインターフェイスをつないで使える状態にする。

$ sudo ip netns exec bridge ip link set ns1-veth0-br0 up
$ sudo ip netns exec bridge ip link set ns1-veth1-br0 up
$ sudo ip netns exec bridge ip link set ns2-br0 up
$ sudo ip netns exec bridge ip link add dev br0 type bridge
$ sudo ip netns exec bridge ip link set br0 up
$ sudo ip netns exec bridge ip link set ns1-veth0-br0 master br0
$ sudo ip netns exec bridge ip link set ns1-veth1-br0 master br0
$ sudo ip netns exec bridge ip link set ns2-br0 master br0

動作を確認する

さて、これですべての準備が整った。 ns1 から ns2 の IP アドレスに向かって ping を打ってみよう。 うまくいけば、ちゃんと応答が返ってくるはず。

$ sudo ip netns exec ns1 ping 192.0.2.2 -I 192.0.2.1
PING 192.0.2.2 (192.0.2.2) from 192.0.2.1 : 56(84) bytes of data.
64 bytes from 192.0.2.2: icmp_seq=1 ttl=64 time=0.110 ms
64 bytes from 192.0.2.2: icmp_seq=2 ttl=64 time=0.157 ms
64 bytes from 192.0.2.2: icmp_seq=3 ttl=64 time=0.175 ms

通信がどのように流れているかを観察してみよう。 まずは ns1 のボンディング用インターフェイス ns1-bond0 から。 ちゃんと ICMP echo request / reply がやり取りされている。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-bond0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-bond0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 13, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 13, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 14, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 14, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 15, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 15, length 64

次は ns1ns1-veth0 を観察してみよう。 このインターフェイスは ns1-bond0 によって束ねられている。 こちらも ICMP echo request / reply がやり取りされているようだ。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 35, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 35, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 36, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 36, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 37, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 37, length 64

では ns1ns1-veth1 はどうだろうか。 このインターフェースも ns1-bond0 によって束ねられている。 しかし、このインターフェイスにはパケットが流れてこない。 これはボンディングのモードが active-backup になっているため。 つまり、こちらのインターフェイスがバックアップになっている。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

試しにアクティブになっている方のインターフェイスをダウンさせてみよう。 bridge の方に所属している ns1-veth0-br0 の状態を DOWN に設定する。

$ sudo ip netns exec bridge ip link set ns1-veth0-br0 down

すると、バックアップになっていた ns1-veth1 の方にパケットが流れ始める。 障害を検知してこちらがマスターに昇格したようだ。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 82, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 82, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 83, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 83, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 84, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 84, length 64

試しにダウンしていたインターフェイスの状態を UP に戻してみよう。

$ sudo ip netns exec bridge ip link set ns1-veth0-br0 up

それでも、ns1-veth1 の方にパケットが流れ続ける。 元のマスターとバックアップの状態に自動で戻らないのは、フラッピングを防ぐ上で合理的といえる。

$ sudo ip netns exec ns1 tcpdump -tnl -i ns1-veth1 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ns1-veth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes

...

IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 108, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 108, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 109, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 109, length 64
IP 192.0.2.1 > 192.0.2.2: ICMP echo request, id 56648, seq 110, length 64
IP 192.0.2.2 > 192.0.2.1: ICMP echo reply, id 56648, seq 110, length 64

いじょう。

まとめ

今回は Linux におけるリンクアグリゲーション (LAG) の実装であるボンディングを試してみた。 ボンディングを使うことで、ネットワークインターフェイスを束ねて扱うことができる。