CUBE SUGAR CONTAINER

技術系のこと書きます。

MySQL の InnoDB でトランザクション分離レベルの違いを試す

今回は MySQL の InnoDB を使ってトランザクション分離レベル (Transaction Isolation Level) の違いを試してみる。 トランザクション分離レベルは、SQL を実装したシステムの ACID 特性において I (Isolation) に対応する概念となっている。 利用する分離レベルによって、複数のトランザクション間でデータの一貫性に関する振る舞いが変化する。 なお、この概念は ANSI SQL で定義されているもので、MySQL に固有というわけではない。

InnoDB では、次の 4 種類のトランザクション分離レベルがサポートされている。 下にいくほど、より厳格にトランザクションを分離できる一方で、上にいくほど処理のオーバーヘッドは少ない。 言いかえると、トランザクション分離レベルを切り替えることでパフォーマンスと一貫性のバランスを調整できる。 なお、デフォルトでは REPEATABLE READ が用いられる。

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

トランザクション分離レベルを落とすと、複数のトランザクション間で分離が不十分になる現象が生じる。 古典的な定義において、生じる現象には次のようなものがある 1

  • ダーティーリード (Dirty Read)
  • ノンリピータブルリード (Non-repeatable Read / Fuzzy Read)
  • ファントムリード (Phantom Read)

トランザクション分離レベルと現象には、次のような関係性がある。 表で「X」になっている項目は、起こる可能性があることを示す。 ただし、MySQL の InnoDB では、例外的に REPEATABLE READ でもファントムリードが生じない。

ダーティーリード ノンリピータブルリード ファントムリード
READ UNCOMMITTED X X X
READ COMMITTED - X X
REPEATABLE READ - - X 1
SERIALIZABLE - - -

今回、検証に使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ mysql --version
mysql  Ver 8.0.19 for osx10.14 on x86_64 (Homebrew)

下準備

まずは Homebrew を使って MySQL をインストールしておく。

$ brew install mysql

インストールしたら、次に MySQL のサービスを開始する。

$ brew services start mysql
$ brew services list | grep mysql   
mysql   started amedama /Users/amedama/Library/LaunchAgents/homebrew.mxcl.mysql.plist

MySQL クライアントを起動して、MySQL サーバにログインする。

$ mysql -u root

ログインできたら、サンプルとして使うデータベースを用意する。

mysql> CREATE DATABASE example;
Query OK, 1 row affected (0.01 sec)

mysql> USE example;
Database changed

続いて、サンプルとして使うテーブルを用意する。

mysql> CREATE TABLE users (
    ->     id INTEGER PRIMARY KEY,
    ->     name VARCHAR(32) NOT NULL
    -> );
Query OK, 0 rows affected (0.02 sec)

mysql> DESC users;
+-------+-------------+------+-----+---------+-------+
| Field | Type        | Null | Key | Default | Extra |
+-------+-------------+------+-----+---------+-------+
| id    | int         | NO   | PRI | NULL    |       |
| name  | varchar(32) | NO   |     | NULL    |       |
+-------+-------------+------+-----+---------+-------+
2 rows in set (0.01 sec)

テーブルにレコードを 1 つ追加しておく。

mysql> INSERT INTO users VALUES (1, "Alice");
Query OK, 1 row affected (0.01 sec)

データベースエンジンを確認する

続いて、テーブルで利用されているデータベースエンジンが InnoDB になっていることを確認する。

mysql> SELECT
    ->     TABLE_NAME,
    ->     ENGINE
    -> FROM
    ->     information_schema.TABLES
    -> WHERE
    ->     TABLE_SCHEMA = "example";
+------------+--------+
| TABLE_NAME | ENGINE |
+------------+--------+
| users      | InnoDB |
+------------+--------+
1 row in set (0.00 sec)

もし InnoDB でなければデータベースエンジンを変更しておく。

mysql> ALTER TABLE users ENGINE = "InnoDB";
Query OK, 0 rows affected (0.21 sec)
Records: 0  Duplicates: 0  Warnings: 0

なお、テーブルを作る段階でエンジンを指定することもできる。

トランザクション分離レベルの変更について

トランザクション分離レベルはグローバル変数とセッション変数で管理されている。 一時的に変更するときはセッション変数を変更すれば良い。

mysql> SELECT
    ->     @@global.transaction_isolation,
    ->     @@session.transaction_isolation;
+--------------------------------+---------------------------------+
| @@global.transaction_isolation | @@session.transaction_isolation |
+--------------------------------+---------------------------------+
| REPEATABLE-READ                | REPEATABLE-READ                 |
+--------------------------------+---------------------------------+
1 row in set (0.00 sec)

ここではセッション変数を使って変更する。 たとえば、READ UNCOMMITTED に変更するときは次のようにする。

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT
    ->     @@global.transaction_isolation,
    ->     @@session.transaction_isolation;
+--------------------------------+---------------------------------+
| @@global.transaction_isolation | @@session.transaction_isolation |
+--------------------------------+---------------------------------+
| REPEATABLE-READ                | READ-UNCOMMITTED                |
+--------------------------------+---------------------------------+
1 row in set (0.00 sec)

プロンプトを 2 つ用意する

ここからは複数のトランザクションを扱うので、ターミナルを 2 つ用意してどちらも MySQL サーバにログインしておく。

それぞれのプロンプトを区別するために、次のようにして表示を切りかえる。 ひとつ目は "mysql1" という名前にする。

mysql> PROMPT mysql1> 
PROMPT set to 'mysql1> '

もうひとつは "mysql2" にしておく。

mysql> PROMPT mysql2> 
PROMPT set to 'mysql2> '

ダーティーリード

前置きが長くなったけど、ここから実際の検証に入る。 ダーティーリードを一言でいうと、あるトランザクションでコミットしていない変更が、他のトランザクションから見えてしまうというもの。

あらかじめ、両方のプロンプトのトランザクション分離レベルを READ UNCOMMITTED に変更しておく。

mysql1> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql2> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

両方のプロンプトでトランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

片方のトランザクションで、既存のレコードのカラムを変更してみよう。

mysql1> UPDATE
    ->     users
    -> SET
    ->     name = 'Bob'
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

そして、もう一方のトランザクションから読み取ってみる。 すると、先ほど "mysql1" でレコードに加えた変更が "mysql2" から見えてしまっている。 この現象をダーティーリードという。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+------+
| id | name |
+----+------+
|  1 | Bob  |
+----+------+
1 row in set (0.00 sec)

振る舞いを確認できたら、両方のトランザクションをロールバックしておく。

mysql1> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

mysql2> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

ノンリピータブルリード

続いてはノンリピータブルリード、もしくはファジーリードと呼ばれる現象について。 この現象を一言で表すと、あるトランザクションでコミットした変更が、他のトランザクションから見えてしまうというもの。

はじめに、プロンプトのトランザクション分離レベルを READ COMMITTED に変更しておく。

mysql1> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql2> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.00 sec)

そして、トランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

片方のトランザクションで、先ほどと同じようにレコードに変更を加えてみよう。

mysql1> UPDATE
    ->     users
    -> SET
    ->     name = 'Carol'
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

この時点では、変更はコミットされていない。 もう一方のトランザクションから変更は見えていないので、今回はダーティーリードは生じていない。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

それでは、トランザクションをコミットしてみよう。

mysql1> COMMIT;
Query OK, 0 rows affected (0.00 sec)

すると、もう一方のトランザクションから変更が見えてしまった。 この現象をノンリピータブルリード、またはファジーリードという。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Carol |
+----+-------+
1 row in set (0.00 sec)

コミットしていない方のトランザクションはロールバックしておこう。

mysql2> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

ファントムリード

続いてはファントムリードについて。 この現象を一言で表すと、あるトランザクションでコミットしたレコードの追加や削除が、別のトランザクションから見えてしまうというもの。

なお、ファントムリードは、本来であればトランザクション分離レベルが REPEATABLE READ 以下のときに生じる。 しかし、MySQL の InnoDB では REPEATABLE READ でも例外的にファントムリードが生じない。 そのため、この検証は先ほどに引き続き READ COMMITTED を使って行う。

はじめに、トランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

そして、片方のトランザクションでレコードを削除する。

mysql1> DELETE
    -> FROM
    ->     users
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)

トランザクションはコミットされていない。 この時点では、もう一方のトランザクションからレコードの削除は見えていない。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Carol |
+----+-------+
1 row in set (0.00 sec)

それでは、トランザクションをコミットしてみよう。

mysql1> COMMIT;
Query OK, 0 rows affected (0.01 sec)

すると、もう一方のトランザクションからレコードが見えなくなった。 つまり、あるトランザクションでレコードを削除した内容が、別のトランザクションから見えてしまっている。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
Empty set (0.00 sec)

動作が確認できたらコミットしていないトランザクションをロールバックしておこう。

mysql2> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

REPEATABLE READ のときの挙動を確認しておく

念のため、トランザクション分離レベルを REPEATABLE READ にしたときの振る舞いも確認しておこう。

両方のプロンプトのトランザクション分離レベルを REPEATABLE READ に変更する。

mysql1> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

mysql2> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

サンプルとなるレコードをあらためて追加しておく。

mysql1> INSERT INTO users VALUES (1, "Alice");
Query OK, 1 row affected (0.00 sec)

ダーティーリードが生じないことを確認する

両方のプロンプトでトランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

一方のプロンプトからレコードのカラムに変更を加える。

mysql1> UPDATE
    ->     users
    -> SET
    ->     name = 'Bob'
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

この時点でレコードを確認してもカラムは変更されていない。 つまり、ダーティーリードが生じていないことがわかる。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

ノンリピータブルリードが生じないことを確認する

先ほどの状況から、変更を加えたトランザクションをコミットする。

mysql1> COMMIT;
Query OK, 0 rows affected (0.00 sec)

この状態でもう一方のトランザクションから確認してもレコードのカラムは変更されていない。 つまり、ノンリピータブルリードが生じていないことがわかる。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

ファントムリードが生じないことを確認する

トランザクションをあらためて開始した上でレコードを削除してコミットする。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql1> DELETE
    -> FROM
    ->     users
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)

mysql1> COMMIT;
Query OK, 0 rows affected (0.00 sec)

この状況でもう一方のトランザクションから確認してもレコードは存在しているように見える。 つまり、ファントムリードが生じていないことがわかる。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

参考

https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdfwww.microsoft.com

dev.mysql.com


  1. ただし、ANSI SQL における定義の曖昧さから、厳密にはより様々な現象が生じることが知られている。

  2. MySQL の InnoDB では生じない。

Python: 学習済み機械学習モデルの特性を PDP で把握する

機械学習を用いるタスクで、モデルの解釈可能性 (Interpretability) が重要となる場面がある。 今回は、モデルの解釈可能性を得る手法のひとつとして PDP (Partial Dependence Plot: 部分従属プロット) を扱ってみる。 PDP を使うと、モデルにおいて説明変数と目的変数がどのような関係にあるか理解する上で助けになることがある。 なお、今回は PDP を計算・描画するのに専用のライブラリは使わず、自分で実装してみることにした。

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

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

下準備

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

$ pip install scikit-learn pandas matplotlib

題材とするデータセットとモデルについて

今回は scikit-learn 付属の糖尿病 (Diabetes) データセットを題材として話を進める。 このデータセットは、患者の情報が説明変数で、糖尿病の進行具合が目的変数となっている。

たとえば、これをランダムフォレストで学習させることを考えてみよう。 次のサンプルコードでは、モデルにデータセットを学習させた上で、特徴量の重要度をグラフにプロットしている。

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

import numpy as np
from matplotlib import pyplot as plt
from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor


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

    # ランダムフォレストを学習させる
    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    # ジニ不純度を元に計算した重要度を得る
    importances = clf.feature_importances_
    feature_names = np.array(dataset.feature_names)
    sorted_indices = np.argsort(importances)[::-1]

    # 重要度をプロットする
    plt.bar(feature_names[sorted_indices],
            importances[sorted_indices])
    plt.show()


if __name__ == '__main__':
    main()

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

$ python giniimp.py

すると、次のような棒グラフが得られる。 モデルにおいて、s5bmi といった特徴量が重要視されていることがわかる。

f:id:momijiame:20200507173935p:plain
Gini / Split Importance

なお、ここで計算している重要度は決定木系のモデルに特有のもの。 具体的には、決定木がデータを分割する上でジニ不純度をうまく下げることのできた特徴量を重要なものと判断している。 一般的には Gini Importance や Split Importance と呼ばれている。

この他にも、モデルに依存しない特徴量の重要度として Permutation Importance などが知られている。

blog.amedama.jp

BMI に着目してみる

先ほど計算した重要度では、モデルが特定の特徴量を予測する上で重要視していることがわかった。 しかし、その特徴量が予測する上でどのように重要とされているのかはわからない。

ここでは、先ほど重要とされた BMI にひとまず着目してみよう。 まずは、BMI に含まれる値を確認しておく。 次のサンプルコードでは、BMI に含まれる値をヒストグラムにプロットしている。

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

import pandas as pd
from sklearn import datasets
from matplotlib import pyplot as plt


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

    # 特定カラムのヒストグラムを描く
    df = pd.DataFrame(X, columns=dataset.feature_names)
    df['bmi'].plot.hist()

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python bmihist.py

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

f:id:momijiame:20200507174148p:plain
BMI のヒストグラム

上記のグラフから、BMI は -0.1 ~ +0.15 前後の範囲に値を持つことがわかった。

特定の行 (患者) について BMI を変化させて予測を観察する

続いて、今回扱う PDP の根底となる考え方を説明する。 それは、ある行 (患者) において他の条件は固定したまま特定の説明変数を変化させたとき、予測がどのように変化するかを観察するというもの。

今回であれば、その他の特徴量は固定したまま、BMI の値を色々と変化させたとき、予測がどう変化するかを観察する。 次のサンプルコードでは、先頭の 1 行 (1 人の患者) について BMI を 10 段階で変化させながらモデルの予測を折れ線グラフにプロットしている。

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

import numpy as np
import pandas as pd
from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor
from matplotlib import pyplot as plt


def main():
    # データセットを読み込む
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target
    df = pd.DataFrame(X, columns=dataset.feature_names)

    # あらかじめ解釈したいモデルを学習させておく
    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    # 特定のカラムが取りうる値を等間隔で計算する
    resolution = 10  # 間隔数
    target_column = 'bmi'  # 対象とするカラム名
    min_, max_ = df[target_column].quantile([0, 1])
    candidate_values = np.linspace(min_, max_, resolution)

    # 例として先頭の行を取り出す
    target_row = df.iloc[0]

    # 特定のカラムの値を入れかえながらモデルに予測させる
    y_preds = np.zeros(resolution)
    for trial, candidate_value in enumerate(candidate_values):
        target_row[target_column] = candidate_value
        y_preds[trial] = clf.predict([target_row])

    # 予測させた結果をプロットする

    plt.plot(candidate_values, y_preds)
    plt.xlabel('factor')
    plt.ylabel('target')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python icehead.py

すると、次のようなグラフが得られる。 どうやら BMI の値が大きくなると、概ね病気の進行具合も進んでいると判断されるようだ。

f:id:momijiame:20200507175751p:plain
先頭行の ICE Plot

とはいえ、一人の患者だけを見て判断するのは早計なはず。 もっと、たくさんの行も確認して、より大局的な傾向を把握したい。

次のサンプルコードでは、全データの 10% をサンプリングした上で先ほどと同じことをしている。

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

import numpy as np
import pandas as pd
from sklearn import datasets
from matplotlib import pyplot as plt
from sklearn.ensemble import RandomForestRegressor


def main():
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target
    df = pd.DataFrame(X, columns=dataset.feature_names)

    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    resolution = 10
    target_column = 'bmi'
    min_, max_ = df[target_column].quantile([0, 1])
    candidate_values = np.linspace(min_, max_, resolution)

    # いくつかの行について、特定のカラムの値を入れかえながらモデルに予測させる
    sampling_factor = 0.1
    sampled_df = df.sample(frac=sampling_factor,
                           random_state=42)
    y_preds = np.zeros((len(sampled_df), resolution))
    for index, (_, target_row) in enumerate(sampled_df.iterrows()):
        for trial, candidate_value in enumerate(candidate_values):
            target_row[target_column] = candidate_value
            y_preds[index][trial] = clf.predict([target_row])

    # 予測させた結果をプロットする
    for y_pred_row in y_preds:
        plt.plot(candidate_values, y_pred_row, color='b')
    plt.xlabel('factor')
    plt.ylabel('target')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python ice.py

すると、次のようなグラフが得られる。 やはり、概ね BMI が大きくなると病気の進行具合も進んでいると判断できるようだ。

f:id:momijiame:20200507175821p:plain
いくつかの行に対する ICE Plot

上記の手法を ICE (Individual Conditional Expectation: 個別条件付き期待値) Plot という。

ICE の要約統計量をグラフにプロットする

ICE Plot を使うことで、個別の行について説明変数と目的変数の関係性を把握できる。 一方で、たくさん折れ線グラフがあるとぶっちゃけ見にくい。 そこで、平均値などの要約統計量をグラフにプロットしてみよう。

次のサンプルコードでは ICE の平均値と、バラつきを確認するために 1 SD (Standard Division: 標準偏差) を描画している。

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

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor


def main():
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target
    df = pd.DataFrame(X, columns=dataset.feature_names)

    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    resolution = 10
    target_column = 'bmi'
    min_, max_ = df[target_column].quantile([0, 1])
    candidate_values = np.linspace(min_, max_, resolution)

    # いくつかの行について、特定のカラムの値を入れかえながらモデルに予測させる
    sampling_factor = 0.5
    sampled_df = df.sample(frac=sampling_factor)
    y_preds = np.zeros((len(sampled_df), resolution))
    for index, (_, target_row) in enumerate(sampled_df.iterrows()):
        for trial, candidate_value in enumerate(candidate_values):
            target_row[target_column] = candidate_value
            y_preds[index][trial] = clf.predict([target_row])

    # 予測させた結果をプロットする
    mean_y_preds = y_preds.mean(axis=0)  # 平均
    sd_y_preds = y_preds.std(axis=0)  # 標準偏差
    # 平均 ± 1 SD を折れ線グラフにする
    plt.plot(candidate_values, mean_y_preds)
    plt.fill_between(candidate_values,
                     mean_y_preds - sd_y_preds,
                     mean_y_preds + sd_y_preds,
                     alpha=0.5)
    plt.xlabel('factor')
    plt.ylabel('target')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python pdp.py

すると、次のようなグラフが得られる。 ICE をたくさんプロットするよりも見やすい。 そして、このグラフこそ、今回の本題である PDP というらしい。

f:id:momijiame:20200507175849p:plain
PDP

同じ要領で、他の特徴量についても PDP をプロットしていくことで説明変数と目的変数の関係性を把握する。

補足

なお、scikit-learn には PDP を描画するための専用の API もある。 今回は勉強がてら自分で書いてみたけど、普段はこちらを使う方が手っ取り早いはず。

scikit-learn.org

いじょう。

Kaggleで勝つデータ分析の技術

Kaggleで勝つデータ分析の技術

Python: PySpark で UDAF が作れない場合の回避策について

PySpark では、ごく最近まで UDAF (User Defined Aggregate Function: ユーザ定義集計関数) がサポートされていなかった。 Apache Spark 2.3 以降では Pandas UDF を使うことで UDAF に相当する処理を書くことができるようになっている。 今回は、それ以前のバージョンを使っているときに、同等の処理を書くための回避策について書いてみる。

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

$ cat /etc/redhat-release 
CentOS Linux release 7.7.1908 (Core)
$ uname -r
3.10.0-1062.18.1.el7.x86_64
$ hadoop version
Hadoop 2.9.2
Subversion https://git-wip-us.apache.org/repos/asf/hadoop.git -r 826afbeae31ca687bc2f8471dc841b66ed2c6704
Compiled by ajisaka on 2018-11-13T12:42Z
Compiled with protoc 2.5.0
From source with checksum 3a9939967262218aa556c684d107985
This command was run using /home/vagrant/hadoop-2.9.2/share/hadoop/common/hadoop-common-2.9.2.jar
$ pyspark --version
Welcome to
      ____              __
     / __/__  ___ _____/ /__
    _\ \/ _ \/ _ `/ __/  '_/
   /___/ .__/\_,_/_/ /_/\_\   version 2.4.5
      /_/
                        
Using Scala version 2.11.12, OpenJDK 64-Bit Server VM, 1.8.0_242
Branch HEAD
Compiled by user centos on 2020-02-02T19:38:06Z
Revision cee4ecbb16917fa85f02c635925e2687400aa56b
Url https://gitbox.apache.org/repos/asf/spark.git
Type --help for more information.

下準備

はじめに、PySpark の REPL を起動しておく、

$ pyspark --master yarn

次のようにして、サンプルとなる DataFrame を用意する。 このデータは `'category`` カラムを使ってグループ化できる。

>>> data = [
...   ('A', 'Alice', 10),
...   ('A', 'Bob', 15),
...   ('A', 'Carol', 20),
...   ('B', 'Daniel', 25),
...   ('B', 'Ellie', 30),
...   ('C', 'Frank', 35),
... ]
>>> df = spark.createDataFrame(data, ('category', 'name', 'age'))

組み込みの集計関数について

はじめに、PySpark に組み込みで用意されている集計関数はどのように使うのかおさらいしておく。 たとえば、カテゴリーごとの平均値を計算してみよう。

たとえば年齢の平均を計算するときは、次のようにする。 DataFrame#groupBy() からは GroupedData というクラスのインスタンスが返る。 さらに、そのインスタンスに対して GroupedData#agg() を使って集計関数を適用する。

>>> df.groupBy('category').agg({'age': 'mean'}).show()
+--------+--------+                                                             
|category|avg(age)|
+--------+--------+
|       B|    27.5|
|       C|    35.0|
|       A|    15.0|
+--------+--------+

集計する処理を辞書と文字列で表す以外に pyspark.sql.functions を使う方法もある。 たとえば、上記と同じ処理を書いてみよう。

>>> from pyspark.sql import functions as F
>>> df.groupBy('category').agg(F.mean('age').alias('mean-age')).show()
+--------+--------+                                                             
|category|mean-age|
+--------+--------+
|       B|    27.5|
|       C|    35.0|
|       A|    15.0|
+--------+--------+

以上が組み込みの集計関数を使う方法になる。 基礎的な統計量などを計算するだけなら、これでも問題ないはず。 しかし、集計の処理は組み込みの関数だけで完結しない場合も多い。 そこで、ユーザ定義の関数で集計の処理をしたくなる。

UDAF 代わりの処理の書き方

前述したとおり Apache Spark 2.3 以降では Pandas UDF を使って UDAF に相当する処理が書ける。 ただし、ここではそれについての具体的な紹介はしない。 紹介するのは、Pandas UDF が使えない環境での回避策となる。

回避策のキモは pyspark.sql.functions.collect_list を使うところ。 この関数は GroupedData の特定カラムを、グループ単位でリストに入れて返すことができる。 言葉で説明するよりも、次のサンプルコードを見てもらった方が早いかもしれない。

>>> agg_df = df.groupBy('category').agg(F.collect_list('age').alias('grouped-age'))
>>> agg_df.show()
+--------+------------+                                                         
|category| grouped-age|
+--------+------------+
|       B|    [25, 30]|
|       C|        [35]|
|       A|[10, 15, 20]|
+--------+------------+

この状態までくれば、あとは単なる UDF (User Defined Function) を使って集計できる。 例として平均を計算する UDF を書いてみよう。

>>> def mean(values):
...     return sum(values) / len(values)
... 
>>> mean_udf = F.udf(mean)

上記の UDF をリストのカラムに対して適用する。

>>> agg_df.withColumn('mean-age', mean_udf('grouped-age')).show()
+--------+------------+--------+                                                
|category| grouped-age|mean-age|
+--------+------------+--------+
|       B|    [25, 30]|    27.5|
|       C|        [35]|    35.0|
|       A|[10, 15, 20]|    15.0|
+--------+------------+--------+

ばっちり。

参考

stackoverflow.com

入門 PySpark ―PythonとJupyterで活用するSpark 2エコシステム

入門 PySpark ―PythonとJupyterで活用するSpark 2エコシステム

ピクセラ PIX-MT100 を iPad から使ってみる

外出先でパソコンからインターネットを使いたいときがある。 そんなときのために、普段はピクセラの PIX-MT100 という LTE 対応 USB ドングルに MVNO の SIM カードを入れて持ち歩いている。

ピクセラ LTE対応USBドングル ホワイト  PIX-MT100

ピクセラ LTE対応USBドングル ホワイト PIX-MT100

  • 発売日: 2016/06/17
  • メディア: Personal Computers

この製品は Mac などのパソコンに USB 端子でつなぐと、有線の Ethernet デバイスとして認識する。 つまり、USB Ethernet アダプタを挿して LAN ケーブルをつないだような状態になる。 また、使う上でとくに専用のドライバを入れる必要もない。 テザリングに比べると無線 LAN チャネルの混雑状況に関係なく安定した通信ができるので意外と重宝している。

で、今回は PIX-MT100 が iPad から使えるのかが気になった。 製品のサポートページを見ると、一応 iPhone / iPad は iOS 8 以降でサポートされているようだ。

www.pixela.co.jp

とはいえ、事前に Web を軽く調べても、実際に検証している人が見当たらなかったので自分で試してみることにした。

確認に使った iPad の環境は次のとおり。

f:id:momijiame:20200501212844p:plain
検証に使った iPad の情報

結論としては、ちゃんと使えることが分かった。 USB-A to C アダプタ経由で挿すと、しばらくして "4G Modem" という名前で Ethernet デバイスを認識する。 以下の写真を見ると分かるとおり、PIX-MT100 のステータスランプも正常を示す青色が点灯する。

f:id:momijiame:20200501193752j:plain
PIX-MT100 を iPad OS が認識した状態

設定情報を確認すると、ちゃんと IPv4 / IPv6 の設定が配布されていることがわかる。

f:id:momijiame:20200501193802j:plain
ネットワークの設定情報

もちろん、無線 LAN などを切った状態でちゃんとインターネットが使えることも確認できた。

実際にこの状態で使うかは別として、iPad でも PIX-MT100 を使うことはできるようだ。 いじょう。

Word2Vec 形式のファイルフォーマットについて

Word2Vec では、Skip-gram や CBOW といったタスクを学習させたニューラルネットワークの隠れ層の重みを使って単語を特徴ベクトルにエンコードする。 つまり、Word2Vec で成果物として得られるのは、コーパスの各単語に対応する特徴ベクトルになる。 今回は、単語の特徴ベクトルを永続化するために使われる、Word2Vec 形式とか呼ばれているファイルフォーマットについて調べたので書いてみる。

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

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

下準備

はじめに、学習済みモデルを取得するのに使うコマンドをインストールしておく。

$ brew install gzip wget

また、動作確認に使うための Python パッケージとして gensim をインストールしておく。

$ pip install gensim

Word2Vec 形式 (バイナリ)

Word2Vec 形式と呼ばれているフォーマットには、テキスト形式とバイナリ形式の 2 種類がある。 はじめに、バイナリ形式から紹介する。 たとえば、本家 Google が配布している学習済み Word2Vec モデルはバイナリ形式になっている。

code.google.com

上記から「GoogleNews-vectors-negative300.bin.gz」というファイルをダウンロードしてこよう。 ただし、圧縮された状態で 1.5GB あるので結構時間がかかる。

ダウンロードできたら Python の REPL を起動する。

$ python

Python の gzip モジュール経由でダウンロードしたファイルを開く。

>>> import gzip
>>> f = gzip.open('GoogleNews-vectors-negative300.bin.gz', mode='rb')

バイナリ形式といっても、最初の一行は ASCII 文字列で記述されている。 ここには、スペースで区切られたコーパスの単語 (語彙) 数と、単語の特徴ベクトルの次元数が入る。 今回の学習済みモデルでは語彙が 300 万語、特徴ベクトルが 300 次元とわかる。

>>> header = f.readline()
>>> header
b'3000000 300\n'

この数字はバイナリの読み込みに使うので数値に変換して変数に入れておこう。

>>> words, veclen = [int(x) for x in header.strip().split()]
>>> words, veclen
(3000000, 300)

以降のデータには、単語と特徴ベクトルがスペース区切りで記録される。 はじめに、スペースまでを単語として読み込むための関数を用意しよう。

>>> def readword(f):
...    """単語を読み込むための関数"""
...     word = []
...     while True:
...         c = f.read(1)
...         if c == b' ':
...             break
...         if c == b'':
...             raise EOFError()
...         if c != b'\n':
...             word.append(c)
...     return b''.join(word)
... 

ファイルオブジェクトを読むと、単語として '</s>' が得られた。 いや、これ HTML のタグだから、本来は除外すべき単語だと思うんだけどね。

>>> readword(f)
b'</s>'

単語を取り出せたので、次は対応する特徴ベクトルを読み出す。 特徴ベクトルでは、それぞれの次元が倍精度浮動小数点 (32 ビット) として記録されている。 そこで、まずは読み出すべき特徴ベクトルのバイト数を計算しておこう。

>>> import numpy as np
>>> np.dtype(np.float32).itemsize
4
>>> bin_weights_len = np.dtype(np.float32).itemsize * veclen
>>> bin_weights_len
1200

今回であれば 300 次元 x 4 バイト = 1,200 バイトとなった。

計算した特徴ベクトルのバイト数を、ファイルオブジェクトから読み出す。

>>> bin_weights = f.read(bin_weights_len)

読みだしたバイト列を、NumPy を使って配列に変換しよう。 これが '</s>' という単語に対応する特徴ベクトルということ。

>>> word_weights = np.frombuffer(bin_weights, dtype=np.float32)
>>> word_weights
array([ 1.1291504e-03, -8.9645386e-04,  3.1852722e-04,  1.5335083e-03,
        1.1062622e-03, -1.4038086e-03, -3.0517578e-05, -4.1961670e-04,
       -5.7601929e-04,  1.0757446e-03, -1.0223389e-03, -6.1798096e-04,
...(snip)...
       -9.8419189e-04, -5.4931641e-04, -1.5487671e-03,  1.3732910e-03,
       -6.0796738e-05, -8.2397461e-04,  1.3275146e-03,  1.1596680e-03,
        5.6838989e-04, -1.5640259e-03, -1.2302399e-04, -8.6307526e-05],
      dtype=float32)

以降は同様に単語と特徴ベクトルが交互に記録されている。 たとえば、もう一単語読み出すと 'in' という単語が得られた。 あとは、語彙の数だけ繰り返せば良いことがわかる。

>>> readword(f)
b'in'

念のため gensim を使って答え合わせをしよう。 学習済みモデルを読み込む。 この処理には数分単位で時間がかかる。

>>> import gensim
>>> model = gensim.models.KeyedVectors.load_word2vec_format('GoogleNews-vectors-negative300.bin.gz', binary=True)

読み込んだモデルを使って '</s>' に対応する特徴ベクトルを得る。

>>> model.wv['</s>']
array([ 1.1291504e-03, -8.9645386e-04,  3.1852722e-04,  1.5335083e-03,
        1.1062622e-03, -1.4038086e-03, -3.0517578e-05, -4.1961670e-04,
       -5.7601929e-04,  1.0757446e-03, -1.0223389e-03, -6.1798096e-04,
...(snip)...
       -9.8419189e-04, -5.4931641e-04, -1.5487671e-03,  1.3732910e-03,
       -6.0796738e-05, -8.2397461e-04,  1.3275146e-03,  1.1596680e-03,
        5.6838989e-04, -1.5640259e-03, -1.2302399e-04, -8.6307526e-05],
      dtype=float32)

すると、先ほど手動で読みだした数値と一致していることがわかる。

Word2Vec 形式 (テキスト)

続いてはテキスト形式について紹介する。 ただし、バイナリ形式に比べると非常に単純なので説明は一瞬でおわる。 なお、テキスト形式の Word2Vec モデルとして、あまり良いものが見当たらなかったので Facebook の fastText を使うことにした。

次のコマンドを使って配布されているモデルの先頭 3 行を読み出そう。

$ wget -qO - https://dl.fbaipublicfiles.com/fasttext/vectors-crawl/cc.en.300.vec.gz | gzip -d | head -n 3
2000000 300
, 0.1250 -0.1079 0.0245 -0.2529 0.1057 -0.0184 0.1177 -0.0701 -0.0401 -0.0080 0.0772 -0.0226 0.0893 -0.0487 -0.0897 -0.0835 0.0200 0.0273 -0.0194 0.0964 0.0875 0.0098 0.0453 0.0155 0.1462 0.0225 0.0448 0.0137 0.0570 0.1764 -0.1072 -0.0826 0.0173 0.1090 0.0207 -0.1271 0.2445 0.0375 -0.0209 -0.0445 0.0540 0.1282 0.0437 0.0588 0.0984 0.0539 0.0004 0.1290 0.0242 -0.0120 -0.0480 0.0346 -0.0664 -0.0330 -0.0625 -0.0708 -0.0579 0.1738 0.4448 0.0370 -0.1001 -0.0032 0.0359 -0.0685 -0.0361 0.0070 0.1316 -0.0945 -0.0610 0.0178 -0.0763 -0.0192 0.0033 0.0056 0.1878 -0.0754 -0.0095 0.0446 -0.0588 0.0244 -0.0251 -0.0493 0.0308 -0.0359 -0.1884 -0.0988 0.1887 0.0459 -0.0816 -0.1524 -0.0375 -0.0692 0.0427 -0.0471 -0.0086 -0.2190 -0.0064 0.0877 -0.0074 -0.1400 -0.0156 0.0161 0.1040 -0.1445 -0.0719 -0.0144 -0.0293 -0.0126 0.0619 -0.0373 -0.1471 -0.2552 -0.0685 0.2892 -0.0275 0.0436 0.0311 0.0249 0.0142 0.0403 0.1729 0.0023 -0.0255 -0.0212 0.0701 -0.0727 0.0279 0.1151 -0.0394 -0.0962 -0.0598 -0.0459 -0.0326 -0.2317 0.0945 0.0110 -0.2511 0.1087 -0.0699 0.0359 0.0208 -0.0536 0.0478 0.0178 0.0095 0.0354 -0.7726 -0.0790 0.0472 0.0584 -0.1013 0.0448 -0.1202 0.0376 0.0510 -0.0616 0.4321 0.0179 0.0263 0.0271 0.0473 -0.0951 -0.2261 0.0261 0.0262 -0.0235 -0.0369 -0.1655 -0.0697 0.0122 -0.0303 0.0427 0.0787 -0.0360 0.0206 -0.0068 0.1257 0.0447 -0.0776 -0.1122 -0.0291 0.4654 0.1010 0.4440 0.0095 0.1312 0.0766 0.0873 -0.0878 -0.0296 0.0046 0.0416 -0.0134 0.0571 -0.0109 -0.0655 0.0082 -0.0563 -0.0830 -0.0550 -0.0688 0.0091 -0.0677 -0.1001 0.0200 -0.0979 0.1134 -0.0188 0.0136 0.0782 0.0207 0.0133 -0.0492 -0.0139 0.0123 0.0360 0.1249 0.0503 0.0015 0.1246 -0.0897 -0.0121 -0.0182 0.2245 -0.0313 -0.1596 0.0073 -0.0772 -0.0830 -0.0716 0.0112 0.0218 0.1245 -0.0361 0.0312 0.0652 0.0560 0.0670 0.0709 -0.0248 0.0449 -0.1296 0.1408 -0.0359 1.1585 0.0027 0.0185 0.0549 0.1367 0.2742 -0.0472 -0.0414 -0.0548 -0.0911 -0.0015 -0.1613 0.0179 -0.0040 0.0610 0.0559 0.1138 0.2978 -0.1511 -0.0079 -0.0838 0.0296 -0.1041 0.1627 -0.0670 0.0504 -0.0420 -0.0020 0.1840 0.0596 0.0448 0.0989 -0.2157 -0.0117 0.2142 -0.1672 -0.0444 0.2045 -0.4620 -0.0482 0.0688 -0.0304 0.0478 0.1583 0.0920 0.0949 0.0650 -0.0398 -0.1376 -0.0436 0.0578 0.0188 0.0148 0.2305 -0.0696 -0.0215
the -0.0517 0.0740 -0.0131 0.0447 -0.0343 0.0212 0.0069 -0.0163 -0.0181 -0.0020 -0.1021 0.0059 0.0257 -0.0026 -0.0586 -0.0378 0.0163 0.0146 -0.0088 -0.0176 -0.0085 -0.0078 -0.0183 0.0088 0.0013 -0.0938 0.0139 0.0149 -0.0394 -0.0294 0.0094 -0.0252 -0.0104 -0.2213 -0.0229 -0.0089 -0.0322 0.0822 0.0021 0.0282 0.0072 -0.0091 -0.0352 -0.0178 -0.0706 0.0630 -0.0092 -0.0223 -0.0056 0.0515 -0.0307 0.0436 -0.0110 -0.0555 0.0089 -0.0673 0.0105 0.0574 0.0099 -0.0283 0.0470 0.0053 0.0030 0.0007 0.0443 0.0069 -0.0334 0.0091 -0.0076 0.0066 0.0917 0.0311 0.0543 0.0282 -0.0200 -0.0334 0.0053 0.0364 0.2249 0.0928 -0.0123 0.0086 -0.0599 0.0676 0.0402 0.0012 0.0464 -0.0437 0.0059 0.0917 -0.0412 -0.0151 -0.0231 0.0095 0.0588 0.0279 0.0647 -0.0568 -0.0130 0.0474 0.0354 -0.0121 -0.0077 -0.1306 0.0134 -0.0506 0.0111 0.0119 -0.0221 0.0394 0.0221 0.0245 0.0039 0.1146 0.0228 -0.0468 -0.0459 -0.0189 0.0076 -0.0302 -0.0348 -0.0289 -0.0399 0.0245 -0.0102 0.0578 -0.0388 -0.0117 -0.0304 0.2466 -0.0111 0.0356 0.0046 0.2094 -0.1022 0.0336 0.0688 -0.0708 0.0269 -0.0423 0.0077 -0.0267 0.0072 0.0035 0.0351 -0.0063 -0.4462 0.0105 -0.0120 -0.0449 -0.1696 0.0504 0.0930 -0.0044 -0.0041 0.0322 0.2026 0.0613 -0.0295 0.0228 -0.0190 0.0173 0.1480 -0.0175 -0.0125 0.0687 0.0333 -0.0303 0.0428 0.0051 0.0228 0.0104 0.0731 0.0079 -0.0051 0.0543 -0.0325 0.0512 0.0288 -0.0586 -0.0000 0.0493 0.0166 -0.0143 0.0359 0.0543 -0.0005 -0.0589 0.0162 -0.0222 -0.0199 0.0235 -0.0678 0.0179 0.0033 0.0114 0.0473 -0.0443 0.0323 0.0195 -0.0647 0.3388 0.0699 -0.0215 -0.0244 -0.0034 -0.0034 -0.0622 0.0123 0.0375 -0.0197 0.0241 -0.0877 0.0201 -0.0061 -0.0256 -0.0191 -0.0264 0.0190 -0.0422 0.0251 0.0825 -0.0096 0.1288 0.0621 0.0538 0.0189 0.0422 0.1803 -0.0010 -0.0328 -0.0559 -0.0157 0.0490 0.0352 -0.0417 0.0159 -0.0766 -0.0657 0.0497 0.0102 0.1471 -0.0711 -0.1469 0.4737 -0.0169 -0.0051 0.0159 0.0550 -0.0634 -0.0210 0.0122 0.0269 0.0060 0.0664 0.0106 -0.0705 -0.0207 -0.0784 -0.0291 -0.0283 -0.1568 -0.0393 0.0050 0.0204 -0.0026 0.0436 0.0279 -0.0393 0.0367 -0.0042 -0.0156 -0.0733 -0.1637 0.0652 -0.0062 -0.0650 -0.1984 -0.0411 -0.1534 0.0020 0.0133 -0.2364 -0.0528 -0.0042 -0.0447 0.0112 -0.0332 -0.0550 0.0013 0.0169 -0.0439 -0.0578 0.0223 -0.0777 -0.0432 -0.0251 0.2370 0.0004 -0.0042

先頭の行は、バイナリ形式と同じでコーパスの語彙数と特徴ベクトルの次元数になっている。 そして、以降は各行が単語と特徴ベクトルを表している。 最初の列が単語で、以降はスペース区切りで特徴ベクトルが浮動小数点の文字列として記録されている。

テキスト形式はとても単純なので、答え合わせをする必要もないかな。 ちなみに、単語の特徴ベクトルはモデルや配布元によってフォーマットがかなりバラバラ。 ここで紹介した Word2Vec 形式は、たくさんあるフォーマットのひとつに過ぎない。

いじょう。

ゼロから作るDeep Learning ❷ ―自然言語処理編

ゼロから作るDeep Learning ❷ ―自然言語処理編

  • 作者:斎藤 康毅
  • 発売日: 2018/07/21
  • メディア: 単行本(ソフトカバー)

NAS を買ったら両親に孫の動画を見せやすくなった話

子どもが生まれると、必然的に動画を撮影する機会が増える。 今回は、子どもを撮影した動画を保存するために NAS (Network Attached Storage) を買ったら、副次的な効果として遠隔にいる両親に孫の動画を見せやすくなって良かったという話について。

TL; DR

  • 最近の売ってる NAS は使いやすい
  • 家の外からコンテンツを閲覧するのも簡単
  • NAS の選び方について

背景

子どもが生まれる前後に、きっとこれから動画をたくさん撮るだろうと思ってビデオカメラを購入した。

そして、ビデオカメラを購入すると、今度は撮影した動画を保存しておく場所として NAS がほしくなった。 なぜなら、撮影した動画を microSD カードのような外部記録媒体に入れたままだと、次のような問題点がある。

  • 撮影した動画を鑑賞しにくい
  • 外部記録媒体が故障するリスクは比較的高い

かといって、保存する先としてクラウドストレージを選ぶと、中長期的に見て月額が大きな出費になる。 同時に、最近の製品としての NAS というものを体験してみたい気持ちもあった *1

NAS の提供する機能

はじめに、NAS の提供する主だった機能について整理しておく。

ネットワークファイルシステム

これは、NAS の提供する最も基本的な機能のひとつ。 たとえば SMB (Server Message Block) や NFS (Network File System) といったプロトコルのサーバになる。 家の中でパソコンなどから、NAS のボリュームをネットワークごしにマウントしてファイルを操作する。

リモートアクセス

この機能も、最近の NAS では標準的に提供されている。 ようするに、家の外からでも NAS に保存したファイルにアクセスするための機能。

一昔前なら Dynamic DNS やルータの Destination NAT の設定など、自分で色々と頑張る必要があったけど今は昔。 今どきのものは、そういった手間が不要になっている。 たとえば専用のアプリや Web ページに、管理用 UI から自分で設定したアカウントを入力するだけで使えます、という感じ。

今回のいわば本題である遠隔地にいる両親にも、iPad にアプリを入れてもらってコンテンツを閲覧できるようにした。

ストレージの冗長化

ほとんどの NAS は、HDD (Hard Disk Drive) や SSD (Solid State Drive) といったストレージを複数搭載できる。 そのため、RAID (Redundant Arrays of Inexpensive Disks) を組むことでストレージの物理的な故障に対する冗長性を確保できる。 RAID の方式に関しては、とりあえずストレージが 2 本積めるものなら RAID1 さえ選んでおけば良い。

その他

その他にも、メーカーやモデルによっては NAS という枠にとらわれず多種多様な機能を提供している。 たとえば、アドオンで機能を追加することで仮想マシンや Docker コンテナが動いたりするようなものまである。 もはや、管理のしやすい Linux サーバという感じ。

NAS の選び方について

ここからは、NAS を買うために見るべきポイントについて書いていく。

スマホ・タブレット向けアプリの有無

今どきの NAS は、たいていリモートアクセスのできるスマホ・タブレット向けのアプリが用意されている。 ただし、以下の点については、あらかじめ確認しておいた方が良い。

  • 自分が求めているユースケースに合致するか
  • 使うまでに必要なステップが複雑でないか

ストレージに関して

NAS には、あらかじめストレージが内蔵されているものと、自分で買ってきて入れるものがある。 日本国内のメーカーが作っている製品や、ライトユーザー向けの製品ほどストレージを内蔵している傾向がある。 ストレージを内蔵しているモデルは手間がかからない一方で、ストレージの種類が自分で選べなかったり自分で同じモデルを買うよりも割高な傾向がある。

なお、ストレージには NAS で使うことを想定して作られた専用のシリーズがある。 専用のモデルは、一般的なモデルよりも信頼性が高かったり、ファームウェアにチューニングが施されている。 たとえば Western Digital であれば Red シリーズ、Seagate なら IronWolf シリーズがそれに当たる。

国内テレビ・レコーダーとの連携

これが一番やっかいで分かりにくいポイント。 せっかく大きなストレージがあるならと、国内のテレビ・レコーダーで録画した番組を NAS に保存して再生したいというニーズはあるだろう。 そのためには、NAS が DLNA (Digital Living Network Alliance) の DTCP-IP (Digital Transmission Content Protection over Internet Protocol) という規格に対応している必要がある。 これは、著作権保護の機能が働いた状態でコンテンツをやり取りするための規格だけど、注意点が山盛りにある。 結論から先に述べると、このニーズを満たしたいときはあきらめて「テレビ・レコーダーに NAS の機能が付与されたもの」を買った方が良いと思われる。 例えば、次のようなもの。

相性問題

DTCP-IP はサーバとクライアントの相性問題が強くある。 たとえば特定のレコーダーと NAS ではうまく記録できない、NAS に記録した内容を特定のアプリでは再生できない、というもの。 そのため、実際に使ってみたりレビューで確認できる環境でないと記録・再生できるかはわからないというレベルにある。

一度入れたら取り出せない?!

ほとんどの製品は、NAS に記録したら最後、別の製品には録画した番組を移動できない。 そのため、その NAS が故障したら記録した番組はあきらめる他ない、という状態になる。 テレビ・レコーダーに NAS の機能がついたものであれば、ブルーレイに焼くような選択肢もある。 また、一部の国内製品は同じシリーズ間でのみ番組を転送できるものがある。

国外製品は基本的にアドオン機能で対応する

DTCP-IP は、基本的に日本国内の製品でのみ使われている規格となる。 そのため、国外の製品には基本的に標準では載っていないと考えた方が良い。 対応したい場合には、アドオン機能を使って有料のサードパーティ製のメディアサーバーの機能を追加することになる。

もちろん、機能を追加しても前述した問題は存在する。 また、対応しているのが特定の機種に限られるような場合もあって、なんというか地雷だらけ。 あきらめてテレビ・レコーダーを買った方が良い、という気持ちになる。

アドオン機能の有無

前述したとおり、国外の製品では特に、アドオン機能を使って機能を NAS に追加できるものが多い。 サードパーティーのメーカーがアドオンを配布するストアなども整備されていて、エコシステムが構築されている。 アドオンで追加できる機能が豊富にあると、NAS という用途に限らない使い方が楽しめる。

管理用 UI の使いやすさ

たいていの NAS は、管理用の Web UI で全て設定が完結する。 この点は自前のサーバマシンに Linux を入れてネットワークファイルシステムを構築するのとは比べ物にならないほど楽。 最近は台湾の NAS 専業メーカー (QNAP や Synology) が、この管理用 UI の分かりやすさという点で一歩リードしているようだ。

パフォーマンス

一般的なパソコンと比べると、お世辞にも NAS に積んである CPU やメモリのパフォーマンスは優れているとは言えない。 たとえば、エントリーグレードの CPU はシングルコアでメモリは 512MB とかになっている。 意外とここらへんは実際に使っているときのパフォーマンスに影響してくる。 基本的に上位のグレードを使うほどスループットなどのパフォーマンスに悩まされることは少ないと考えて良い。

主要メーカーと特徴など

  • Synology

    • 台湾の NAS 専業メーカー
    • 主な製品はストレージ非内蔵タイプ
    • DTCP-IP に対応するときはアドオン機能で対応する
      • あまり期待しない方が良い
      • DiXiM Media Server が使えるのは DS218j だけ
  • QNAP

    • 台湾の NAS 専業メーカー
    • 主な製品はストレージ非内蔵タイプ
    • DTCP-IP に対応するときはアドオン機能で対応する
      • あまり期待しない方が良い
  • NETGEAR

    • ネットワーク機器を手広く手掛けるアメリカのメーカー
    • 主な製品はストレージ非内蔵タイプ
    • DTCP-IP には対応していない
  • I-O DATA

    • IT 機器を手広く手掛ける国内メーカー
    • 主な製品はストレージ内蔵タイプ
    • DTCP-IP には標準で対応しているモデルがある
  • バッファロー

    • IT 機器を手広く手掛ける国内メーカー
    • 主な製品はストレージ内蔵タイプ
    • DTCP-IP には標準で対応しているモデルがある
  • (メーカーではないけど) テレビ・レコーダーに NAS 機能がついたもの (Panasonic ほか)

    • 基本的にストレージ内蔵タイプ
    • DTCP-IP 対応を重視するならこれ

購入した NAS について

我が家では Synology の DS218+ という製品を購入した。 なお、DTCP-IP については、前述したとおり対応できても中途半端なのであきらめている。

Synology の NAS では、リモートアクセスに QuickConnect という機能が用意されている。 これは、専用のアプリ (DS File や DS Video など) や Web ページから NAS にアクセスできるというもの。 もちろん、ルータなどに特に設定をしなくても NAT 越えできる。 設定も NAS の管理用 Web UI で接続に使う識別子を確保して、あとはユーザのアカウントを用意するだけで使える。

この製品はストレージが非内蔵タイプとなっている。 うちでは Amazon のセールで購入した Western Digital の外付けハードディスクを殻割りして取り出した HDD を使っている。 この HDD については以下を参照のこと。

blog.amedama.jp

まとめ

うちでは、両親の iPad にアプリを入れて、上記の環境に接続してもらっている。 NAS のアクセス状況を見ると、最近は毎日のように実家で孫の動画を楽しんでいるようだ。 両親いわく COVID-19 もあって物理的に会うことが難しいので余計に嬉しいとのことだった。

いじょう。

*1:以前は自宅のサーバに Linux を入れて運用していた

Python: Keras で Convolutional AutoEncoder を書いてみる

以前に Keras で AutoEncoder を実装するエントリを書いた。 このときは AutoEncoder を構成する Neural Network のアーキテクチャとして単純な全結合層から成る MLP (Multi Layer Perceptron) を使っている。

blog.amedama.jp

一方で、データとして画像を扱う場合にはアーキテクチャとして CNN (Convolutional Neural Network) が使われることも多い。 そこで、今回は CNN をアーキテクチャとして採用した Convolutional AutoEncoder を書いてみた。

使った環境は次のとおり。 CNN は MLP に比べると計算量が大きいので GPU もしくは TPU が使える環境を用意した方が良い。

$ python -V
Python 3.6.9
$ uname -a
Linux b5244776fd7d 4.19.104+ #1 SMP Wed Feb 19 05:26:34 PST 2020 x86_64 x86_64 x86_64 GNU/Linux
$ pip list | egrep -i "(keras |tensorflow-gpu )"
Keras                    2.3.1          
tensorflow-gpu           2.1.0
$ nvidia-smi
Thu Apr 16 09:25:07 2020       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 440.64.00    Driver Version: 418.67       CUDA Version: 10.1     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla P100-PCIE...  Off  | 00000000:00:04.0 Off |                    0 |
| N/A   54C    P0    39W / 250W |   4685MiB / 16280MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
+-----------------------------------------------------------------------------+

下準備

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

$ pip install keras tensorflow-gpu matplotlib

Convolutional AutoEncoder を Keras の Sequential API で実装する

以下のサンプルコードでは、Keras の Sequential API を使って Convolutional AutoEncoder を実装している。 ポイントは Conv2D 層のパディングで "same" を指定しないと次元をうまく合わせることが難しい。

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

import numpy as np
from keras import layers
from keras import models
from keras import callbacks
from keras import backend as K
from keras.datasets import mnist
from matplotlib import pyplot as plt
from matplotlib import cm


def main():
    # MNIST データセットを読み込む
    (x_train, train), (x_test, y_test) = mnist.load_data()
    image_height, image_width = 28, 28

    # バックエンドに依存したチャネルの位置を調整する
    if K.image_data_format() == 'channels_last':
        x_train = x_train.reshape(x_train.shape[0],
                                  image_height, image_width, 1)
        x_test = x_test.reshape(x_test.shape[0],
                                image_height, image_width, 1)
        input_shape = (image_height, image_width, 1)
    else:
        x_train = x_train.reshape(x_train.shape[0],
                                  1, image_height, image_width)
        x_test = x_test.reshape(x_test.shape[0],
                                1, image_height, image_width)
        input_shape = (1, image_height, image_width)

    # Min-Max Normalization (0. ~ 1. の範囲に値を収める)
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # 畳み込み演算を用いた AutoEncoder のネットワーク (Sequential API)
    model = models.Sequential()
    # 28 x 28 x 1
    model.add(layers.Conv2D(16, kernel_size=(3, 3),
                            activation='relu', padding='same',
                            input_shape=input_shape))
    # 28 x 28 x 16
    model.add(layers.MaxPooling2D(pool_size=(2, 2), padding='same'))
    # 14 x 14 x 16
    model.add(layers.Conv2D(8, kernel_size=(3, 3),
                            activation='relu', padding='same'))
    # 14 x 14 x 8
    model.add(layers.MaxPooling2D(pool_size=(2, 2), padding='same'))
    # 7 x 7 x 8
    model.add(layers.Conv2D(8, kernel_size=(3, 3),
                            activation='relu', padding='same'))
    # 7 x 7 x 8
    model.add(layers.UpSampling2D(size=(2, 2)))
    # 14 x 14 x 8
    model.add(layers.Conv2D(16, kernel_size=(3, 3),
                            activation='relu', padding='same'))
    # 14 x 14 x 16
    model.add(layers.UpSampling2D(size=(2, 2)))
    # 28 x 28 x 16
    model.add(layers.Conv2D(1, kernel_size=(3, 3),
                            activation='sigmoid', padding='same'))
    # 28 x 28 x 1
    model.compile(optimizer='adam',
                  loss='binary_crossentropy')

    # モデルの構造を確認する
    print(model.summary())

    fit_callbacks = [
        callbacks.EarlyStopping(monitor='val_loss',
                                patience=5,
                                mode='min')
    ]

    # モデルを学習させる
    model.fit(x_train, x_train,
              epochs=1000,
              batch_size=4096,
              shuffle=True,
              validation_data=(x_test, x_test),
              callbacks=fit_callbacks,
              )

    # テストデータの損失を確認しておく
    score = model.evaluate(x_test, x_test, verbose=0)
    print('test xentropy:', score)

    # 学習済みのモデルを元に、次元圧縮だけするモデルを用意する
    encoder = models.clone_model(model)
    encoder.compile(optimizer='adam',
                    loss='binary_crossentropy')
    encoder.set_weights(model.get_weights())

    # 中間層までのレイヤーを取り除く
    encoder.pop()
    encoder.pop()
    encoder.pop()
    encoder.pop()

    # テストデータからランダムに 10 点を選び出す
    p = np.random.randint(0, len(x_test), 10)
    x_test_sampled = x_test[p]
    # 選びだしたサンプルを AutoEncoder にかける
    x_test_sampled_pred = model.predict_proba(x_test_sampled,
                                              verbose=0)
    # 次元圧縮だけする場合
    x_test_sampled_enc = encoder.predict_proba(x_test_sampled,
                                               verbose=0)

    # 処理結果を可視化する
    fig, axes = plt.subplots(3, 10, figsize=(12, 12))
    for i, label in enumerate(y_test[p]):
        # 元画像を上段に表示する
        img = x_test_sampled[i].reshape(image_height, image_width)
        axes[0][i].imshow(img, cmap=cm.gray_r)
        axes[0][i].axis('off')
        axes[0][i].set_title(label, color='red')
        # AutoEncoder で次元圧縮した画像を中段に表示する
        enc_img = x_test_sampled_enc[i].reshape(7, 7 * 8).T
        axes[1][i].imshow(enc_img, cmap=cm.gray_r)
        axes[1][i].axis('off')
        # AutoEncoder で復元した画像を下段に表示する
        pred_img = x_test_sampled_pred[i].reshape(image_height, image_width)
        axes[2][i].imshow(pred_img, cmap=cm.gray_r)
        axes[2][i].axis('off')

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python cae.py
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
conv2d_11 (Conv2D)           (None, 28, 28, 16)        160       
_________________________________________________________________
max_pooling2d_5 (MaxPooling2 (None, 14, 14, 16)        0         
_________________________________________________________________
conv2d_12 (Conv2D)           (None, 14, 14, 8)         1160      
_________________________________________________________________
max_pooling2d_6 (MaxPooling2 (None, 7, 7, 8)           0         
_________________________________________________________________
conv2d_13 (Conv2D)           (None, 7, 7, 8)           584       
_________________________________________________________________
up_sampling2d_5 (UpSampling2 (None, 14, 14, 8)         0         
_________________________________________________________________
conv2d_14 (Conv2D)           (None, 14, 14, 16)        1168      
_________________________________________________________________
up_sampling2d_6 (UpSampling2 (None, 28, 28, 16)        0         
_________________________________________________________________
conv2d_15 (Conv2D)           (None, 28, 28, 1)         145       
=================================================================
Total params: 3,217
Trainable params: 3,217
Non-trainable params: 0
_________________________________________________________________
None
Train on 60000 samples, validate on 10000 samples
Epoch 1/1000
60000/60000 [==============================] - 1s 18us/step - loss: 0.6363 - val_loss: 0.5522
Epoch 2/1000
60000/60000 [==============================] - 1s 14us/step - loss: 0.5042 - val_loss: 0.4346
Epoch 3/1000
60000/60000 [==============================] - 1s 14us/step - loss: 0.3655 - val_loss: 0.2909

...(省略)...

Epoch 373/1000
60000/60000 [==============================] - 1s 14us/step - loss: 0.0728 - val_loss: 0.0721
Epoch 374/1000
60000/60000 [==============================] - 1s 14us/step - loss: 0.0727 - val_loss: 0.0721
Epoch 375/1000
60000/60000 [==============================] - 1s 14us/step - loss: 0.0727 - val_loss: 0.0721
test xentropy: 0.07211270518302917

検証用データに対するクロスエントロピーは約 0.072 と、前述した MLP の AutoEncoder よりも小さくなっていることがわかる。

以下は、上段が検証用データの画像、中段が AutoEncoder が次元圧縮した特徴の画像表現、下段が復元した画像となっている。

f:id:momijiame:20200416183423p:plain
Convolutional AutoEncoder の入出力の画像表現

入力に比べれば少しかすれているものの、ちゃんと復元できている。

Functional API を使った場合

おまけとして Functional API を使った例も以下に示す。 学習済みモデルから中間層の出力を取り出すところだけは Functional API を使う方法が分からなかったので Sequential API を使った。

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

import numpy as np
from keras import layers
from keras import models
from keras import callbacks
from keras import backend as K
from keras.datasets import mnist
from matplotlib import pyplot as plt
from matplotlib import cm


def main():
    (x_train, train), (x_test, y_test) = mnist.load_data()
    image_height, image_width = 28, 28

    if K.image_data_format() == 'channels_last':
        x_train = x_train.reshape(x_train.shape[0],
                                  image_height, image_width, 1)
        x_test = x_test.reshape(x_test.shape[0],
                                image_height, image_width, 1)
        input_shape = (image_height, image_width, 1)
    else:
        x_train = x_train.reshape(x_train.shape[0],
                                  1, image_height, image_width)
        x_test = x_test.reshape(x_test.shape[0],
                                1, image_height, image_width)
        input_shape = (1, image_height, image_width)

    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # Functional API を使う場合
    input_ = layers.Input(shape=input_shape)
    # 28 x 28 x 1
    x = layers.Conv2D(16, kernel_size=(3, 3),
                      activation='relu', padding='same')(input_)
    # 28 x 28 x 16
    x = layers.MaxPooling2D(pool_size=(2, 2), padding='same')(x)
    # 14 x 14 x 16
    x = layers.Conv2D(8, kernel_size=(3, 3),
                      activation='relu', padding='same')(x)
    # 14 x 14 x 8
    x = layers.MaxPooling2D(pool_size=(2, 2), padding='same')(x)
    # 7 x 7 x 8
    x = layers.Conv2D(8, kernel_size=(3, 3),
                      activation='relu', padding='same')(x)
    # 7 x 7 x 8
    x = layers.UpSampling2D(size=(2, 2))(x)
    # 14 x 14 x 8
    x = layers.Conv2D(16, kernel_size=(3, 3),
                      activation='relu', padding='same')(x)
    # 14 x 14 x 16
    x = layers.UpSampling2D(size=(2, 2))(x)
    # 28 x 28 x 16
    output_ = layers.Conv2D(1, kernel_size=(3, 3),
                            activation='sigmoid', padding='same')(x)
    # 28 x 28 x 1
    model = models.Model(inputs=input_, output=output_)
    model.compile(optimizer='adam',
                  loss='binary_crossentropy')

    print(model.summary())

    fit_callbacks = [
        callbacks.EarlyStopping(monitor='val_loss',
                                patience=5,
                                mode='min')
    ]

    model.fit(x_train, x_train,
              epochs=1000,
              batch_size=4096,
              shuffle=True,
              validation_data=(x_test, x_test),
              callbacks=fit_callbacks,
              )

    score = model.evaluate(x_test, x_test, verbose=0)
    print('test xentropy:', score)

    encoder = models.clone_model(model)
    encoder.compile(optimizer='adam',
                    loss='binary_crossentropy')
    encoder.set_weights(model.get_weights())

    # Sequential API を使ってモデルを構築し直す
    encoder = models.Sequential()
    # 真ん中の層までを取り出す
    for layer in model.layers[:-4]:
        encoder.add(layer)

    p = np.random.randint(0, len(x_test), 10)
    x_test_sampled = x_test[p]
    x_test_sampled_pred = model.predict(x_test_sampled,
                                        verbose=0)
    x_test_sampled_enc = encoder.predict_proba(x_test_sampled,
                                               verbose=0)

    fig, axes = plt.subplots(3, 10, figsize=(12, 12))
    for i, label in enumerate(y_test[p]):
        img = x_test_sampled[i].reshape(image_height, image_width)
        axes[0][i].imshow(img, cmap=cm.gray_r)
        axes[0][i].axis('off')
        axes[0][i].set_title(label, color='red')

        enc_img = x_test_sampled_enc[i].reshape(7, 7 * 8).T
        axes[1][i].imshow(enc_img, cmap=cm.gray_r)
        axes[1][i].axis('off')

        pred_img = x_test_sampled_pred[i].reshape(image_height, image_width)
        axes[2][i].imshow(pred_img, cmap=cm.gray_r)
        axes[2][i].axis('off')

    plt.show()


if __name__ == '__main__':
    main()

結果は同じなので省略する。

いじょう。