CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: 正の相互情報量 (PPMI) と特異値分解 (SVD) を使った単語の分散表現

(2021-02-02 追記): 共起行列の計算を NumPy の Integer array indexing を使った実装にした

オライリーの「ゼロから作るDeep Learning ❷ ――自然言語処理編」を読んでいる。 この中に、カウントベースで計算する初歩的な単語の分散表現が紹介されていて、なかなか面白かった。 単語の分散表現を得る方法は色々とあるけど、カウントベースは単語の出現数を元にした比較的シンプルなもの。 今回は、正の相互情報量 (Positive Pointwise Mutual Information; PPMI) と特異値分解 (Singular Value Decomposition; SVD) を使って単語の分散表現を獲得する例を実装してみた。 書籍に記載されている内容を参考にしつつも、自分なりのコードにしてある。 オリジナルとの差分で大きいのは、正の相互情報量と単語間のコサイン類似度を計算する部分を NumPy のベクトル演算にしたところかな。

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

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H114
$ python -V               
Python 3.8.7

もくじ

下準備

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

$ pip install scikit-learn

コーパスは、前述の書籍の中で用いられていた「Penn Treebank (PTB)」を使う。 こちらも、あらかじめダウンロードしておこう。

$ wget https://raw.githubusercontent.com/tomsercu/lstm/master/data/ptb.train.txt

サンプルコード

以下に実装したサンプルコードを示す。 コメントもつけてあるけど、サンプルコードでは次のような流れで処理している。

  • PTB コーパスを読み込む
  • 単語を ID に変換する
    • 語彙 (Vocabulary) を構築する
  • 共起行列を計算する
    • 文章の中で、ある単語と別の単語が共に用いられた回数を表している
      • つまり文の中で近い位置に登場した、ということ
  • 正の相互情報量を計算する
    • ある単語と別の単語が共に用いられる度合いを表している
  • 特異値分解して単語の分散表現を得る
    • 相互情報量を複数の行列に分解すると共に次元圧縮をかける
  • いくつかの単語について類似の分散表現を持った単語を調べる
    • 分散表現の近さをコサイン類似度で測る
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from __future__ import annotations

import re
from itertools import count
from typing import Iterable
from typing import Iterator

import numpy as np  # type: ignore
from sklearn.utils.extmath import randomized_svd  # type: ignore


def load_corpus(filepath: str) -> Iterator[str]:
    """テキストファイルからコーパスを読み出す"""
    tag_replace = re.compile('<.*?>')
    with open(filepath, mode='r') as fp:
        for line in fp:
            # コーパスに含まれる <unk> や <eos> は取り除きたい
            yield re.sub(tag_replace, '', line)


def sentences_to_words(sentences: Iterable[str], lower: bool = True) -> Iterator[list[str]]:
    """文章を単語に分割する"""
    for sentence in sentences:
        if lower:
            sentence = sentence.lower()
        words = re.split('\\W+', sentence)
        yield [word for word in words if len(word) > 0]  # 空文字は取り除く


def word_id_mappings(sentences: Iterable[Iterable[str]]) -> dict[str, int]:
    """単語を ID に変換する対応テーブルを作る"""
    counter = count(start=0)

    word_to_id = {}
    for sentence in sentences:
        for word in sentence:

            if word in word_to_id:
                # 登録済みの単語
                continue

            # 単語の識別子を採番する
            word_id = next(counter)
            word_to_id[word] = word_id

    return word_to_id


def words_to_ids(sentences: Iterable[list[str]], word_to_id: dict[str, int]) -> Iterator[list[int]]:
    # 単語を対応する ID に変換する
    for words in sentences:
        # NOTE: Out-of-Vocabulary への対応がない
        yield [word_to_id[word] for word in words]


def co_occurence_matrix(corpus_ids: Iterable[list[int]], vocab_size: int, window_size: int) -> np.ndarray:
    """共起行列 (Co-occurence Matrix) を計算する"""
    co_matrix = np.zeros((vocab_size, vocab_size), dtype=np.int32)

    for word_ids in corpus_ids:
        for context_len in range(1, window_size + 1):
            # 元の ID 列と、コンテキスト分をずらした ID 列を用意する
            base = word_ids[context_len:]
            shifted = word_ids[:-context_len]
            # 元の列とずらした列の対応を取れば、共起した単語を Integer array indexing で処理できる
            co_matrix[base, shifted] += 1
            co_matrix[shifted, base] += 1

    return co_matrix


def positive_pointwise_mutual_information(co_matrix: np.ndarray, eps: float = 1e-8):
    # 正の相互情報量 (Positive Pointwise Mutual Information) を計算する
    total_words = np.sum(co_matrix)
    word_count = np.sum(co_matrix, axis=0)

    # PPMI = max(PMI, 0)
    # PMI = log2(P(x, y) / (P(x) * P(y)) = log2(C(x, y) * N / (C(x) * C(y)))
    # P(x) = C(x) / N
    # P(x, y) = C(x, y) / N

    # ベクトル化した計算
    ppmi_matrix = co_matrix.copy().astype(np.float32)
    ppmi_matrix *= total_words
    ppmi_matrix /= word_count * word_count.reshape(word_count.shape[0], -1) + eps
    ppmi_matrix = np.log2(ppmi_matrix + eps)
    ppmi_matrix = np.clip(ppmi_matrix, 0., None)

    return ppmi_matrix


def cosine_similarity_matrix(word_vectors: np.ndarray, eps: float = 1e-8) -> np.ndarray:
    """単語ベクトル間のコサイン類似度を計算する"""
    word_norm = np.sqrt(np.sum(word_vectors ** 2, axis=1)).reshape(word_vectors.shape[0], -1)
    normalized_word_vectors = word_vectors / (word_norm + eps)
    cs_matrix = np.dot(normalized_word_vectors, normalized_word_vectors.T)
    return cs_matrix


def most_similar_words(word_id: int, cs_matrix: np.ndarray, top_n: int = 5):
    """コサイン類似度が最も高い単語の ID を取り出す"""
    similarities = cs_matrix[word_id, :]
    similar_word_ids = np.argsort(similarities)[::-1]
    # 基本的にコサイン類似度が最も高い ID は自分自身になるはずなので先頭は取り除く
    return similar_word_ids[1: top_n + 1]


def main():
    # Penn Treebank コーパスを読み込む
    corpus_sentences = load_corpus('ptb.train.txt')

    # コーパスを単語に分割する
    corpus_words = list(sentences_to_words(corpus_sentences))

    # 単語 -> ID
    word_to_id = word_id_mappings(corpus_words)

    # コーパスの語彙数
    vocab_size = len(word_to_id.keys())

    # コーパスの単語を ID に変換する
    corpus_ids = words_to_ids(corpus_words, word_to_id)

    # 共起行列を計算する
    co_matrix = co_occurence_matrix(corpus_ids, vocab_size, window_size=2)

    # PPMI を計算する
    ppmi_matrix = positive_pointwise_mutual_information(co_matrix)

    # 特異値分解 (Singular Value Decomposition; SVD) する
    # 特異値の大きな次元を基準に先頭を取り出すことで次元圧縮する
    word_vectors, _, _ = randomized_svd(ppmi_matrix, n_components=100)

    # 単語を表すベクトル間のコサイン類似度を計算する
    cs_matrix = cosine_similarity_matrix(word_vectors)

    # ID -> 単語
    id_to_word = {value: key for key, value in word_to_id.items()}

    # いくつか似ているベクトルを持った単語を確認してみる
    example_words = ['you', 'year', 'car', 'toyota']
    for target_word in example_words:
        # ID に変換した上で最も似ている単語とそのベクトルを取り出す
        print(f'The most similar words of "{target_word}"')
        target_word_id = word_to_id[target_word]
        most_similar_word_ids = most_similar_words(target_word_id, cs_matrix)
        # 単語とベクトルを表示する
        for rank, similar_word_id in enumerate(most_similar_word_ids, start=1):
            similar_word = id_to_word[similar_word_id]
            similarity = cs_matrix[target_word_id, similar_word_id]
            print(f'TOP {rank}: {similar_word} = {similarity}')
        print('-' * 50)


if __name__ == '__main__':
    main()

上記を適当な名前のモジュールとして保存した上で実行してみよう。 サンプルコードでは「you」「year」「car」「toyota」について類似の分散表現を持つ単語を調べている。

$ python pmisvd.py      
The most similar words of "you"
TOP 1: we = 0.7093088626861572
TOP 2: i = 0.699989378452301
TOP 3: yourself = 0.5935164093971252
TOP 4: anybody = 0.5696209073066711
TOP 5: ll = 0.537631094455719
--------------------------------------------------
The most similar words of "year"
TOP 1: month = 0.727214515209198
TOP 2: earlier = 0.6935610771179199
TOP 3: period = 0.6458954215049744
TOP 4: next = 0.6107259392738342
TOP 5: fiscal = 0.5819855332374573
--------------------------------------------------
The most similar words of "car"
TOP 1: auto = 0.706072986125946
TOP 2: luxury = 0.6855229735374451
TOP 3: vehicle = 0.48040351271629333
TOP 4: cars = 0.46684157848358154
TOP 5: truck = 0.4660819172859192
--------------------------------------------------
The most similar words of "toyota"
TOP 1: motor = 0.722280740737915
TOP 2: motors = 0.6682790517807007
TOP 3: nissan = 0.6666470170021057
TOP 4: honda = 0.6519786715507507
TOP 5: lexus = 0.6209456920623779
--------------------------------------------------

見ると、意味的に近い単語が類似した分散表現を持つように学習できていることがわかる。 「toyota」の類似語に「motor」と「motors」が別々に出てきてしまっているのはステミング (Stemming) の処理が入っていないため。

サンプルコードで共起行列を作るのに使ったウィンドウサイズは 2 ということを前提にして考えたとき、次のような感想を抱いた。

  • そんなに共起するか?っていう「you」と「we」や「i」が類似した分散表現になっているのは面白い
  • 「toyota」の類似語に「nissan」や「honda」があるところには納得感がある
    • 同じ文章の中で並べられることも多いだろうし

ところで、書籍の中には共起行列で特定の単語に対応する行ベクトルもカウントベースの分散表現の一種とあった。 個人的にその認識はなかったので、読んだときには「なるほど」と感じた。

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

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

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

Python: REPL に複数行をペーストしたときの挙動が変わって困った件について

表題のとおりなんだけど、最近 Python の REPL に複数行のコードをペーストしたときの挙動が以前と変わってしまい困っていた。 その Python というのは、具体的には Homebrew でインストールしたものや、Pyenv を使ってソースコードからビルドしたもの。

使っている環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H114
$ brew info readline | head -n 1
readline: stable 8.1 (bottled) [keg-only]
$ pyenv --version
pyenv 1.2.22

もくじ

TL;DR

結論から先に述べると、これは Python のビルドに使った GNU Readline のオプションが関係している。 具体的には、バージョン 8.1 から enable-bracketed-paste というオプションのデフォルト値が off から on に変更されたらしい。

そのため、問題のワークアラウンドとしては、このオプションの値を明示的に指定すれば良い。 たとえば、以下のようなコマンドで GNU Readline の設定ファイルを作ろう。

$ echo "set enable-bracketed-paste off" >> ~/.inputrc

これで、以前と同じ挙動になる。

問題についてもうちょっと詳しく

たとえば、次のような 2 行をコピーアンドペーストしたいときを想定する。

a = 1
b = 2

以前の REPL であれば、次のように別々の行として認識された。

>>> a = 1
>>> b = 2

それが、問題のある環境では以下のように SyntaxError 例外になってしまう。

>>> a = 1
b = 2
  File "<stdin>", line 1
    a = 1
b = 2
         ^
SyntaxError: multiple statements found while compiling a single statement

複数行を一度にペーストできないのは REPL として使い勝手が良くない。

上記の問題が起こる環境には、バージョンは問わず割と最近にビルドしたもの、という共通点があった。 そのため、GNU Readline が怪しいという当たりはついていたので、そこを重点的に調べていったところ以下のページにたどり着いた。

github.com

どうやら GNU Readline のバージョン 8.1 から、enable-bracketed-paste というオプションがデフォルトで on になったらしい。 その影響を受けて、複数行のペーストが上記のような挙動に変わってしまった。

最近の端末エミュレータには、Bracketed Paste Mode というものがある。 これは、通常の文字の入力とペーストによる入力を区別できるようにすることを意図して追加された機能らしい。 そして、GNU Readline はコマンドラインの編集操作に関する機能を提供するライブラリとなっている。 これまで、GNU Readline はペースト時のデフォルトの設定として Bracketed Paste Mode を無効にしていたようだ。 それが、8.1 からデフォルトの設定が変更になって、今回の問題が生じた、ということらしい。

それはそれとして、Python の REPL が Bracketed Paste に対応するという話もあると思う。 対応していると、空行の入ったスクリプトのコードをそのまま入れてもエラーにならないという嬉しさがあるはず。 ただ、調べたところ以下のチケットはあったが現状であまり話は前に進んでいないようだ。

bugs.python.org

いじょう。

Python: TensorFlow/Keras で Entity Embedding を試してみる

ニューラルネットワークでカテゴリ変数を扱う方法としては One-Hot エンコーディングがある。 しかし、One-Hot エンコーディングでは特徴量のカーディナリティが高いと扱う次元数が大きくなる。 そこで、今回紹介する Entity Embedding を使うと、ラベルエンコードしたカテゴリ変数をニューラルネットワークで扱うことができるようになる。 Entity Embedding では、Embedding 層を使うことでカテゴリ変数ごとにパラメータの重み (分散表現) を学習する。 今回は TensorFlow/Keras で Entity Embedding を試してみる。

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

$ sw_vers         
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H114
$ python -V                                                 
Python 3.8.7

下準備

あらかじめ、使用するパッケージをインストールしておく。

$ pip install tensorflow scikit-learn sklearn-pandas seaborn

Embedding 層について

Entity Embedding を理解する上では、まず Embedding 層について理解する必要がある。 Keras では Embedding 層の実装として tensorflow.keras.layers.Embedding が使える。

keras.io

上記を実装するには tensorflow.nn.embedding_lookup() という関数が使われている。

www.tensorflow.org

そこで、まずはこの関数の振る舞いについて見ていくことにしよう。 はじめに Python のインタプリタを起動する。

$ python

TensorFlow をインポートしたら、例として次のような 5 行 2 列の Tensor オブジェクトを用意する。

>>> import tensorflow as tf
>>> tensor = tf.constant([
...     [1, 2],
...     [3, 4],
...     [5, 6],
...     [7, 8],
...     [9, 10],
... ])
>>> tensor
<tf.Tensor: shape=(5, 2), dtype=int32, numpy=
array([[ 1,  2],
       [ 3,  4],
       [ 5,  6],
       [ 7,  8],
       [ 9, 10]], dtype=int32)>

上記の Tensor オブジェクトに対してtf.nn.embedding_lookup() 関数を使ってみよう。 この関数を使ってできることは単純で、行のインデックス番号を指定すると、その行を取り出すことができる。 例えば、次のように 0, 2, 3 をリストで渡すと、その行に対応した要素を取り出すことができる。

>>> tf.nn.embedding_lookup(tensor, [0, 2, 3])
<tf.Tensor: shape=(3, 2), dtype=int32, numpy=
array([[1, 2],
       [5, 6],
       [7, 8]], dtype=int32)>

先ほど作った Tensor オブジェクトから 0, 2, 3 行目が取り出されていることがわかる。

できることがわかったところで、これの何が嬉しいのか考えてみよう。 先ほど作った Tensor オブジェクトで、行がカテゴリ変数の各カテゴリ、列がそのカテゴリの重み (分散表現) に対応すると捉えたらどうだろうか。 カテゴリ変数を扱う上で、One-Hot にしなくても、ラベルエンコードしたカテゴリ変数についてインデックス番号ごとに分散表現を管理できることがわかる。

念の為、tensorflow.keras.layers.Embedding の挙動についても確認しておこう。 先ほど作った Tensor オブジェクトと同じ重みを持った Embedding オブジェクトを次のように作る。

>>> initializer = tf.keras.initializers.Constant(
    value=[
        [1, 2],
        [3, 4],
        [5, 6],
        [7, 8],
        [9, 10],
    ]
)
>>> embedding_layer = tf.keras.layers.Embedding(5, 2, embeddings_initializer=initializer)

あとは入力として、先ほど tf.nn.embedding_lookup() 関数の引数に使ったのと同じインデックス番号を渡してみよう。

>>> embedding_layer(inputs=tf.constant([0, 2, 3]))
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[1., 2.],
       [5., 6.],
       [7., 8.]], dtype=float32)>

すると、ちゃんとインデックス番号に対応した重みが出力として得られることがわかる。

カテゴリ変数を Entity Embedding で扱う MLP を書いてみる

続いては、実際にカテゴリ変数を Entity Embedding で扱うサンプルコードを見てみる。 以下では、Diamonds データセットを使って、3 層の MLP (Multi Layer Perceptron) で価格 (price) を推定する回帰問題を学習している。 Diamonds データセットには、カテゴリ変数として cutcolorclarity という特徴量がある。 今回は、それらのカテゴリ変数をラベルエンコードした数値として受け取って、2 次元の分散表現として学習させた。 モデルの評価は 3-Fold CV で、Out-of-Fold で推定した値を RMSLE で最後に出力している。

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

import numpy as np
import tensorflow as tf
from sklearn.metrics import mean_squared_log_error
from sklearn_pandas import DataFrameMapper
from sklearn.model_selection import KFold
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import MinMaxScaler
import seaborn as sns
from matplotlib import pyplot as plt


class MultiLayerPerceptronWithEntityEmbedding(tf.keras.Model):
    """カテゴリ変数を Entity Embedding で扱う MLP"""

    def __init__(self):
        super().__init__()

        # cut を Entity Embedding する層
        self.cut_embedding = tf.keras.Sequential([
            tf.keras.layers.Input(shape=(1,)),
            tf.keras.layers.Embedding(5, 2),
            tf.keras.layers.Flatten(),
        ], name='cut_embedding')

        # color を Entity Embedding する層
        self.color_embedding = tf.keras.Sequential([
            tf.keras.layers.Input(shape=(1,)),
            tf.keras.layers.Embedding(7, 2),
            tf.keras.layers.Flatten(),
        ], name='color_embedding')

        # clarity を Entity Embedding する層
        self.clarity_embedding = tf.keras.Sequential([
            tf.keras.layers.Input(shape=(1,)),
            tf.keras.layers.Embedding(8, 2),
            tf.keras.layers.Flatten(),
        ], name='clarity_embedding')

        # MLP 層
        self.mlp = tf.keras.Sequential([
            tf.keras.layers.Dense(64,
                                  activation=tf.keras.activations.relu),
            tf.keras.layers.Dropout(0.25),
            tf.keras.layers.Dense(32,
                                  activation=tf.keras.activations.relu),
            tf.keras.layers.Dropout(0.25),
            tf.keras.layers.Dense(1),
        ], name='mlp')

    def call(self, inputs):
        # cut の Embedding
        cut = inputs[:, 0]
        cut_embed = self.cut_embedding(cut)

        # color の Embedding
        color = inputs[:, 1]
        color_embed = self.color_embedding(color)

        # clarity の Embedding
        clarity = inputs[:, 2]
        clarity_embed = self.clarity_embedding(clarity)

        # 残りの特徴量と連結する
        concat_layers = [
            cut_embed,
            color_embed,
            clarity_embed,
            inputs[:, 3:],
        ]
        x = tf.keras.layers.Concatenate()(concat_layers)

        # MLP 層に入れる
        x = self.mlp(x)

        return x


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

    mapper = DataFrameMapper([
        # カテゴリ変数はラベルエンコードする
        ('cut', LabelEncoder()),
        ('color', LabelEncoder()),
        ('clarity', LabelEncoder()),
        # 連続変数は Min-Max スケーリングで標準化する
        (['carat', 'depth', 'table', 'x', 'y', 'z'], MinMaxScaler()),
    ], df_out=True)

    # エンコードする
    x_transformed = mapper.fit_transform(x)
    print(x_transformed.head())

    models = []
    folds = KFold(n_splits=3, shuffle=True, random_state=42)
    for train_index, val_index in folds.split(x_transformed, y):
        train_x = x_transformed.iloc[train_index]
        train_y = y.iloc[train_index]
        valid_x = x_transformed.iloc[val_index]
        valid_y = y.iloc[val_index]

        # タスクを定義する
        model = MultiLayerPerceptronWithEntityEmbedding()
        optimizer = tf.keras.optimizers.Adam(learning_rate=1e-2)
        loss_function = tf.keras.losses.MeanSquaredLogarithmicError()
        model.compile(optimizer=optimizer,
                      loss=loss_function)
        model.build((None,) + train_x.shape[1:])

        # 学習に使うコールバック
        checkpoint_filepath = '/tmp/keras-checkpoint'
        callbacks = [
            # 検証データに対する損失が一定のエポック数の間に改善しないときは学習を打ち切る
            tf.keras.callbacks.EarlyStopping(monitor='val_loss',
                                             patience=50,
                                             verbose=1,
                                             mode='min'),
            # モデルを記録する
            tf.keras.callbacks.ModelCheckpoint(monitor='val_loss',
                                               filepath=checkpoint_filepath,
                                               save_best_only=True,
                                               verbose=1,
                                               mode='min'),
        ]

        # 学習する
        model.fit(train_x, train_y,
                  batch_size=1024,
                  epochs=1_000,
                  verbose=1,
                  validation_data=(valid_x, valid_y),
                  callbacks=callbacks,
                  )

        # 最良のモデルを読み込む
        model.load_weights(checkpoint_filepath)
        models.append(model)

    y_preds = np.zeros_like(y, dtype=np.float64)
    model_fold_mappings = zip(models, folds.split(x_transformed, y))
    for model, (_, val_index) in model_fold_mappings:
        # Out-of-Fold で予測を作る
        valid_x = x_transformed.iloc[val_index]
        y_pred = model.predict(valid_x)
        y_preds[val_index] = y_pred.ravel()

    rmsle = np.sqrt(mean_squared_log_error(y, y_preds))
    print(f'Root Mean Squared Log Error: {rmsle}')

    # predicted vs actual plot
    plt.scatter(y_preds, y)
    y_max = max(max(y_preds), max(y))
    plt.plot([0, y_max], [0, y_max], color='r')
    plt.xlabel('Predicted')
    plt.ylabel('Actual')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。 なお、データもネットワークも小さいので CPU で十分処理できる。

$ python entityembed.py
...
Root Mean Squared Log Error: 0.11266712415049261

回帰問題は評価指標だけだとイマイチイメージがつきにくいので真の価格と推定した価格の散布図も示す。

f:id:momijiame:20210115015343p:plain
真の価格と Out-of-Fold で推定した価格のプロット

とりあえず、カテゴリ変数を One-Hot しなくても学習できていることがわかった。 これができると、特徴量を扱う処理でもニューラルネットワーク用とそれ以外の差分が小さくなる余地ができてありがたい。

補足

今回使った Diamonds データセットについては、以下の理由からあえて Entity Embedding する必要はないかなという話もある。

  • カテゴリ変数のカーディナリティが小さいので One-Hot エンコーディングでも十分に扱える
  • カテゴリ変数が名義尺度ではなく順序尺度なので、順序にもとづいて Ordinal エンコーディングすることもできる

なお、軽く検証した限りでは、今回のデータとネットワークと評価指標だと性能は One-Hot Encoding > Entity Embedding > Ordinal Encoding という感じだった。 Ordinal Encoding がイマイチ効かないのは、順序尺度毎の寄与がすべて一定という仮定を置くことになって、それが誤りだからなのかな。

コード的な差分は、One-Hot エンコードに関しては前処理を次のようにする。

    mapper = DataFrameMapper([
        # カテゴリ変数は One-Hot エンコードする
        ('cut', LabelBinarizer()),
        ('color', LabelBinarizer()),
        ('clarity', LabelBinarizer()),
        (['carat', 'depth', 'table', 'x', 'y', 'z'], MinMaxScaler()),
    ], df_out=True)

Ordinal エンコードであれば、こう。

    mapper = DataFrameMapper([
        # カテゴリ変数を Ordinal エンコードした後に標準化する
        (['cut', 'color', 'clarity'], [
            OrdinalEncoder(categories=[
                ['Ideal', 'Premium', 'Very Good', 'Good', 'Fair'],
                ['D', 'E', 'F', 'G', 'H', 'I', 'J'],
                ['IF', 'VVS1', 'VVS2', 'VS1', 'VS2', 'SI1', 'SI2', 'I1'],
            ]),
            MinMaxScaler(),
        ]),
        (['carat', 'depth', 'table', 'x', 'y', 'z'], MinMaxScaler()),
    ], df_out=True)

そして、モデルは MLP 層だけにして上記の出力をそのまま突っ込んでやれば良い。

参考文献

元ネタの論文では、Entity Embedding のメリットとして扱う次元数の減少や学習の高速化以外についても触れられている。

arxiv.org

いじょう。

Python: LightGBM の cv() 関数と SHAP を使ってみる

以前、このブログでは機械学習モデルの解釈可能性を向上させる手法として SHAP を扱った。

blog.amedama.jp

上記のエントリでは、LightGBM の train() 関数と共に、モデルの学習に使ったデータを解釈していた。 今度は cv() 関数を使って、Out-of-Fold なデータを解釈する例を試してみる。 つまり、モデルにとって未知のデータを、どのような根拠で予測をしているのかざっくり把握することが目的になる。

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

$ sw_vers         
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H114
$ python -V          
Python 3.8.6

もくじ

下準備

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

$ pip install -U "lightgbm>3" shap scikit-learn pandas matplotlib

擬似的な二値分類用のデータを作って試してみる

前回のエントリと同じように、scikit-learn の API を使って擬似的な二値分類用のデータを作って試してみよう。 データは 100 次元あるものの、先頭の 5 次元しか分類には寄与する特徴になっていない。

サンプルコードを以下に示す。 基本的には cv() 関数から取り出した CVBooster を使って、Out-of-Fold な Prediction を作る要領で SHAP Value を計算するだけ。 あとは、それを好きなように可視化していく。 以下では、CVBooster を構成している各 Booster について summary_plot() で可視化した。

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

import numpy as np
import pandas as pd
import shap
import lightgbm as lgb
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold
from matplotlib import pyplot as plt


def main():
    # 疑似的な教師信号を作るためのパラメータ
    args = {
        # データ点数
        'n_samples': 10_000,
        # 次元数
        'n_features': 100,
        # その中で意味のあるもの
        'n_informative': 5,
        # 重複や繰り返しはなし
        'n_redundant': 0,
        'n_repeated': 0,
        # タスクの難易度
        'class_sep': 0.65,
        # 二値分類問題
        'n_classes': 2,
        # 生成に用いる乱数
        'random_state': 42,
        # 特徴の順序をシャッフルしない (先頭の次元が informative になる)
        'shuffle': False,
    }
    # 擬似的な二値分類用の教師データを作る
    dummy_x, dummy_y = make_classification(**args)
    # 一般的なユースケースを想定して Pandas のデータフレームにしておく
    col_names = [f'feature_{i:02d}'
                 for i in range(dummy_x.shape[1])]
    df = pd.DataFrame(data=dummy_x, columns=col_names)
    df['target'] = pd.Series(data=dummy_y, dtype=bool)
    # 教師データ
    train_x, train_y = df.drop(['target'], axis=1), df.target

    # データの分割方法は Stratified 5-Fold CV
    folds = StratifiedKFold(n_splits=5,
                            shuffle=True,
                            random_state=42,
                            )
    # LightGBM のデータセット形式にする
    lgb_train = lgb.Dataset(train_x, train_y)
    # 学習時のパラメータ
    lgb_params = {
        'objective': 'binary',
        'metric': 'binary_logloss',
        'first_metric_only': True,
        'verbose': -1,
    }
    # 学習する
    cv_result = lgb.cv(params=lgb_params,
                       train_set=lgb_train,
                       folds=folds,
                       num_boost_round=10_000,
                       early_stopping_rounds=100,
                       verbose_eval=10,
                       return_cvbooster=True,
                       )
    # CVBooster を取り出す
    cvbooster = cv_result['cvbooster']

    # Out-of-Fold なデータで SHAP Value を計算する
    split_indices = list(folds.split(train_x, train_y))
    cv_shap_values = np.zeros_like(train_x, dtype=np.float)
    # Booster と学習に使ったデータの対応関係を取る
    booster_split_mappings = list(zip(cvbooster.boosters,
                                      split_indices))
    for booster, (_, val_index) in booster_split_mappings:
        # Booster が学習に使っていないデータ
        booster_val_x = train_x.iloc[val_index]
        # SHAP Value を計算する
        explainer = shap.TreeExplainer(booster,
                                       model_output='probability',
                                       data=booster_val_x)
        train_x_shap_values = explainer.shap_values(booster_val_x)
        cv_shap_values[val_index] = train_x_shap_values

    # 各教師データに対応する SHAP Value さえ計算できれば、後は好きに可視化すれば良い
    # 試しに各 Booster の summary_plot を眺めてみる
    plt.figure(figsize=(12, 16))
    for i, (_, val_index) in enumerate(split_indices):
        # 描画位置
        plt.subplot(3, 2, i + 1)
        # 各 Booster の Explainer が出力した SHAP Value ごとにプロットしていく
        shap.summary_plot(shap_values=cv_shap_values[val_index],
                          features=train_x.iloc[val_index],
                          feature_names=train_x.columns,
                          plot_size=None,
                          show=False)
        plt.title(f'Booster: {i}')
    plt.show()

    # 1 つのグラフにするなら、これだけで良い
    """
    shap.summary_plot(shap_values=cv_shap_values,
                      features=train_x,
                      feature_names=train_x.columns)
    """


if __name__ == '__main__':
    main()

上記を保存したら、実行してみよう。 パフォーマンス的には、SHAP Value の計算部分を並列化すると、もうちょっと速くできそう。

$ python lgbcvshap.py
[10]    cv_agg's binary_logloss: 0.417799 + 0.00797668
[20]    cv_agg's binary_logloss: 0.333266 + 0.0108777
[30]    cv_agg's binary_logloss: 0.30215 + 0.0116352
...(省略)...
[220]   cv_agg's binary_logloss: 0.282142 + 0.0185382
[230]   cv_agg's binary_logloss: 0.283128 + 0.0188667
[240]   cv_agg's binary_logloss: 0.284197 + 0.0195125
 98%|===================| 1951/2000 [00:29<00:00]

実行すると、次のようなプロットが得られる。

f:id:momijiame:20210108185439p:plain
各 Booster の SHAP Value を可視化したチャート

基本的には、どの Booster の結果も、先頭 5 次元の SHAP Value が大きくなっていることが見て取れる。

いじょう。

Ubuntu 20.04 LTS に後から GUI (X Window System) を追加する

Ubuntu 20.04 LTS のサーバ版をインストールした場合には、デフォルトでは GUI 環境が用意されない。 しかし、後から必要になる場合もある。 今回は、そんなときどうするかについて。 なお、必要な操作は Ubuntu 18.04 LTS の場合と変わらなかった。

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

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.1 LTS
Release:    20.04
Codename:   focal
$ uname -r
5.4.0-59-generic

もくじ

下準備

あらかじめ、リポジトリの情報を更新しておく。

$ sudo apt-get update

デスクトップ環境が必要なとき

デスクトップ環境が必要なときは ubuntu-desktop パッケージをインストールする。

$ sudo apt-get -y install ubuntu-desktop

かなり色々と入るので気長に待つ必要がある。

インストールが終わったら再起動する。

$ sudo shutdown -r now

うまくいけば自動的にデスクトップ環境が有効な状態で起動してくる。

f:id:momijiame:20210106073254p:plain
Ubuntu 20.04 LTS のデスクトップ環境

X Window System だけで良いとき

デスクトップ環境は必要なく、単純に X Windows System だけで良いときは xserver-xorg パッケージをインストールする。 これは、たとえば X11 Forwarding を使って、リモートで起動したアプリケーションの画面をローカルに出したいときに多い。

$ sudo apt-get -y install xserver-xorg

X Windows System を使うアプリケーションとして xeyes などを起動して動作確認する。

$ sudo apt-get -y install x11-apps
$ xeyes

f:id:momijiame:20210106072626p:plain
xeyes

いじょう。

普段使ってるオーラルケア用品について

このブログでは、年に何回か技術系じゃないエントリも書いているんだけど、今回もそれ。 普段使っているオーラルケア用品で、これは良いなと思っているものを理由と一緒に書いていく。 なお、完全なる主観に過ぎないことをあらかじめ断っておきます。

マウスウォッシュ

はじめにマウスウォッシュから。 ジャンルとしては液体ハミガキ粉に分類されるのかな。 歯磨きをする前に口に含んで、ゆすいでから吐き出すやつ。 普段はリステリンの紫 (トータルケア) を使っている。

初めて使うときは以下の低刺激タイプから入るのが良さそう。

以下は口に含むと口の中がブワッてなるので最初は驚くかも。

効果としては、使うと口臭が抑えられる感じがある。

電動歯ブラシ

歯医者さんの検診の歯磨き指導で毎回磨き残しがあると言われ続けて、ついには磨く時間が 5 分を越えたときに「これ以上は手でやるのムリ」と判断して購入したのが電動歯ブラシ。 導入後の指導では「まあ良いでしょう」というコメントをもらえた。

今は、もう廃版になっているフィリップスのモデルを買ってから使い続けている。 他のメーカーは使ったことがないのでわからない。 ただ、選定する上では先端の交換用ブラシのアタッチメントの互換性とか、値段は考慮した方が良いかも。 ランニングコストに関わってくるので。 とりあえず、フィリップスに関しては 10 年くらいアタッチメントの互換性は保たれている。 交換用のブラシは一本あたり千円くらいはする。

フロス

歯ブラシだけだと歯の間の掃除はできない、みたいなことはよく聞くけど本当にそうだと思う。 丁寧に磨いた後でも、フロスを使うと口をゆすいだときに食べかすが出てくる。

リーチデンタルフロス ワックス 18M

リーチデンタルフロス ワックス 18M

  • メディア: ヘルスケア&ケア用品

あと、最初使い始めた頃はフロスが当たった歯肉から血が出てきて驚いた。 結局のところ、それは歯肉炎になっているのが原因だったのだと思う。 使い続けると血は出なくなるし、歯肉が引き締まる感じがある。 使ってない人には是非使ってもらいたい。

歯みがき粉

歯みがき粉はマウスウォッシュとは別に、目的に合わせて 2 種類を使い分けている。

ひとつは以下のビリリアントモアっていうやつで、これはホワイトニング効果の高いもの。 リモートワークが始まってコーヒーと紅茶の消費量が増えて、歯のステインに悩んでいたんだけど、これのおかげで解消した。 ステインが気になるところを、これを付けた電動歯ブラシで重点的に磨くと本当にキレイになる。 最初は連続して使っていたけど、今は週に一回くらいのペースで使っている。

もうひとつは知覚過敏を防ぐ効果のあるシュミテクトを使っている。 冷たいものを食べるとしみるのが気になって使い始めたんだけど、最近はそれなりにマシになっている気がしないでもない。 いや、もしかすると気のせいかも。 ブリリアントモアを使わない日はこちらを使っている。

キシリトールタブレット

これはちょっと番外編かもしれないけど、テーブルにキシリトールタブレットを置いといて、気が向いたときにポリポリと食べてる。 きっかけは子どもの歯みがきトレーニングでご褒美に使えるかなと考えて、大人が味見がてら買ってから続いている。 ほのかな甘さでなかなか美味しい。

ハキラ ブルーベリー 45粒

ハキラ ブルーベリー 45粒

  • メディア: ヘルスケア&ケア用品

子どもにも奥歯が生えたら、歯みがきのご褒美にあげようと思う。

まとめ

とりあえず今フロスを使っていないなら是非使ってもらいたい。 面倒くさがりで時短したいなら電動ハブラシはおすすめ。 口臭ケアが気になってる人はリステリン良いと思う。 歯磨き粉は用途に合わせて。

そんなかんじで。

Docker でコンテナにマウントできるボリュームについて

Docker では、ボリュームという機能を使うことで、ホストや外部のストレージをコンテナにマウントできる。 今回は、それらについてざっと使い方を見ていく。 紹介するボリュームの種類は次のとおり。

  • bind mount
  • volume mount
  • tempfs mount

使った環境は以下のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 18.04.5 LTS
Release:    18.04
Codename:   bionic
$ uname -r
4.15.0-124-generic
$ docker version
Client: Docker Engine - Community
 Version:           20.10.1
 API version:       1.41
 Go version:        go1.13.15
 Git commit:        831ebea
 Built:             Tue Dec 15 04:34:59 2020
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.1
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       f001486
  Built:            Tue Dec 15 04:32:40 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.4.3
  GitCommit:        269548fa27e0089a8b8278fc4fc781d7f65a939b
 runc:
  Version:          1.0.0-rc92
  GitCommit:        ff819c7e9184c13b7c2607fe6c30ae19403a7aff
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

もくじ

bind mount

はじめに、従来からある bind mount について。 このやり方では、コンテナの起動時に Docker ホストのディレクトリパスを指定すると、そのディレクトリをコンテナにマウントできる。

たとえば、カレントディレクトリに確認用のテキストファイルを書き込んでおこう。

$ echo "Hello, World" > greet.txt

bind mount するときは、一般的には -v または --volume オプションを使うことが多い。 このオプションでは、左辺にマウントしたい Docker ホストのディレクトリを絶対パスで指定する。 そして、コロンを挟んだ右辺にコンテナでマウント先のディレクトリを指定する。

以下では、カレントディレクトリをコンテナの /mnt にマウントしている。

$ docker container run \
    --rm \
    -v $(pwd):/mnt \
    -it ubuntu:18.04 \
    bash

起動したコンテナの中で /mnt 以下を確認すると、先ほど作ったテキストファイルが確認できる。

root@4829d9a83fe9:/# cat /mnt/greet.txt 
Hello, World

ただし、最近では --mount というオプションも使える。 このオプションでは、マウントの種類とマウント元、マウント先を指定することで、より明示的な指示になっている。

$ docker container run \
    --rm \
    --mount type=bind,source=$(pwd),destination=/mnt \
    -it ubuntu:18.04 \
    bash

結果は先ほどと変わらないので割愛する。

volume mount

続いては volume mount というやり方を扱う。 bind mount ではマウント元のディレクトリを指定していた。 これは直感的な反面、マウントするディレクトリの管理が煩雑になるデメリットがある。 一方、volume mount では、Docker が管理している領域にマウント用のディレクトリが作られて、それを名前で指定できる。

たとえば、以下のようにしてボリュームを作成しておく。 ここでは example-volume という名前でボリュームを作った。

$ docker volume create example-volume

ボリュームの一覧を見ると、ちゃんとボリュームが作成されている。 ここで着目すべき点として DRIVER というカラムがある。 先ほど作ったボリュームでは、値が local になっている。 これは、ドライバを変更することで Docker ホスト以外の場所もボリュームとして管理できることを表している。 公式のドキュメントでも SSHFS や NFS を使ってコンテナ間でストレージを共有する方法が示されている。

$ docker volume ls
DRIVER    VOLUME NAME
local     example-volume

volume mount するときは、bind mount と同じように -v オプションが使える。 先ほどは左辺に Docker ホストのディレクトリを指定したのに対し、今回はボリュームの名前を指定する。

$ docker container run \
    --rm \
    -v example-volume:/mnt \
    -it ubuntu:18.04 \
    bash

あるいは、--mount オプションを使っても良い。 こちらの場合は typebind ではなく volume を指定した上で、source にボリュームの名前を指定する。

$ docker container run \
    --rm \
    --mount type=volume,source=example-volume,destination=/mnt \
    -it ubuntu:18.04 \
    bash

初期状態では、マウントされたディレクトリにファイルが何もない。 そこで、一旦は確認用のファイルを書き込んでコンテナを終了しておこう。

root@10cc21f927a7:/# ls /mnt
root@10cc21f927a7:/# echo "Hello, World" > /mnt/greet.txt
root@10cc21f927a7:/# exit
exit

そして、改めて同じボリュームをマウントするコンテナを起動する。

$ docker container run \
    --rm \
    --mount type=volume,source=example-volume,destination=/mnt \
    -it ubuntu:18.04 \
    bash

すると、次のように先ほど作成したファイルが存在することが確認できる。

root@324a3d594723:/# ls /mnt
greet.txt
root@324a3d594723:/# cat /mnt/greet.txt 
Hello, World

ちなみに、ボリュームの場所は次のようにして調べることができる。 まずはコンテナの識別子か名前を確認しておく。

$ docker ps
CONTAINER ID   IMAGE          COMMAND   CREATED          STATUS          PORTS     NAMES
324a3d594723   ubuntu:18.04   "bash"    59 seconds ago   Up 58 seconds             reverent_golick

そして、docker inspect を使えばボリュームの情報が確認できる。 これで、"/var/lib/docker/volumes/example-volume/_data" にマウント元のディレクトリがあることがわかった。

$ docker inspect 324a3d594723 | grep example-volume
                "example-volume:/mnt"
                "Name": "example-volume",
                "Source": "/var/lib/docker/volumes/example-volume/_data",

Docker ホスト側でディレクトリの内容を確認しておこう。 ちゃんとファイルが存在することがわかる。

$ sudo ls /var/lib/docker/volumes/example-volume/_data
greet.txt
$ sudo cat /var/lib/docker/volumes/example-volume/_data/greet.txt
Hello, World

tmpfs mount

続いては、これまでとちょっと毛色が異なる tmpfs mount を紹介する。 tmpfs は Linux のファイルシステムの一種で、主記憶装置を使った (つまりオンメモリの) ファイルシステムになっている。 もちろん、オンメモリということはデータは永続化されず揮発する。

tmpfs mount をするときは、専用のオプションとして --tmpfs が使える。 指定するのはコンテナのマウント先だけ。

$ docker container run \
    --rm \
    --tmpfs /mnt \
    -it ubuntu:18.04 \
    bash

あるいは、--mount オプションで typetmpfs を指定しても良い。

$ docker container run \
    --rm \
    --mount type=tmpfs,destination=/mnt \
    -it ubuntu:18.04 \
    bash

起動したコンテナを確認すると、/mnt ディレクトリに tmpfs でマウントされている。

root@71b7c20cbcb8:/# df -h | grep /mnt
tmpfs           7.9G     0  7.9G   0% /mnt

dd(1) を使ってちょっと大きめのファイルを書くこんでみると、別の場所に比べてスループットが高いことが確認できる。

root@71b7c20cbcb8:/# dd if=/dev/zero of=/mnt/zeros bs=1MB count=2000
2000+0 records in
2000+0 records out
2000000000 bytes (2.0 GB, 1.9 GiB) copied, 0.651633 s, 3.1 GB/s
root@71b7c20cbcb8:/# dd if=/dev/zero of=/zeros bs=1MB count=2000
2000+0 records in
2000+0 records out
2000000000 bytes (2.0 GB, 1.9 GiB) copied, 1.93574 s, 1.0 GB/s

いじょう。

参考

docs.docker.com