22.9. ナイーブベイズ¶
これまでの節を通して、確率論と確率変数について学んできた。この理論を実際に役立てるために、ナイーブベイズ分類器を導入しよう。これは確率の基本原理だけを用いて、数字の分類を可能にする。
学習とは、仮定を置くことにほかならない。これまで見たことのない新しいデータ例を分類したいなら、どのデータ例が互いに似ているかについて何らかの仮定を置かなければならない。ナイーブベイズ分類器は、広く使われている非常に明快なアルゴリズムで、計算を簡単にするために、すべての特徴が互いに独立であると仮定する。この節では、このモデルを画像中の文字認識に適用する。
%matplotlib inline
from d2l import torch as d2l
import math
import torch
import torchvision
d2l.use_svg_display()
%matplotlib inline
from d2l import mxnet as d2l
import math
from mxnet import gluon, np, npx
npx.set_np()
d2l.use_svg_display()
image.shape, image.dtype
%matplotlib inline
from d2l import tensorflow as d2l
import math
import tensorflow as tf
d2l.use_svg_display()
22.9.1. 光学文字認識¶
MNIST (LeCun et al., 1998) は、広く使われているデータセットの一つである。これは学習用に60,000枚、検証用に10,000枚の画像を含む。各画像には0から9までの手書き数字が1つ含まれている。課題は、それぞれの画像を対応する数字に分類することである。
Gluon は data.vision モジュールに MNIST クラスを提供しており、
データセットをインターネットから自動的に取得できる。 その後は、Gluon
はすでにダウンロード済みのローカルコピーを使用する。
学習セットかテストセットかは、パラメータ train の値をそれぞれ
True または False に設定することで指定する。
各画像は幅と高さがともに \(28\) のグレースケール画像で、形状は
(\(28\),\(28\),\(1\))
である。最後のチャネル次元を取り除くために、カスタマイズした変換を使う。さらに、このデータセットでは各画素が符号なし
\(8\)
ビット整数で表されている。問題を簡単にするために、これらを2値特徴に量子化する。
data_transform = torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
lambda x: torch.floor(x * 255 / 128).squeeze(dim=0)
])
mnist_train = torchvision.datasets.MNIST(
root='./temp', train=True, transform=data_transform, download=True)
mnist_test = torchvision.datasets.MNIST(
root='./temp', train=False, transform=data_transform, download=True)
Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz to ./temp/MNIST/raw/train-images-idx3-ubyte.gz
100%|██████████| 9912422/9912422 [00:00<00:00, 119248043.86it/s]
Extracting ./temp/MNIST/raw/train-images-idx3-ubyte.gz to ./temp/MNIST/raw
Downloading http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/train-labels-idx1-ubyte.gz to ./temp/MNIST/raw/train-labels-idx1-ubyte.gz
100%|██████████| 28881/28881 [00:00<00:00, 6166549.27it/s]
Extracting ./temp/MNIST/raw/train-labels-idx1-ubyte.gz to ./temp/MNIST/raw
Downloading http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/t10k-images-idx3-ubyte.gz to ./temp/MNIST/raw/t10k-images-idx3-ubyte.gz
100%|██████████| 1648877/1648877 [00:00<00:00, 31792085.86it/s]
Extracting ./temp/MNIST/raw/t10k-images-idx3-ubyte.gz to ./temp/MNIST/raw
Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz
Downloading http://yann.lecun.com/exdb/mnist/t10k-labels-idx1-ubyte.gz to ./temp/MNIST/raw/t10k-labels-idx1-ubyte.gz
100%|██████████| 4542/4542 [00:00<00:00, 6371414.30it/s]
Extracting ./temp/MNIST/raw/t10k-labels-idx1-ubyte.gz to ./temp/MNIST/raw
def transform(data, label):
return np.floor(data.astype('float32') / 128).squeeze(axis=-1), label
mnist_train = gluon.data.vision.MNIST(train=True, transform=transform)
mnist_test = gluon.data.vision.MNIST(train=False, transform=transform)
Downloading /opt/mxnet/datasets/mnist/train-images-idx3-ubyte.gz from https://apache-mxnet.s3-accelerate.dualstack.amazonaws.com/gluon/dataset/mnist/train-images-idx3-ubyte.gz...
Downloading /opt/mxnet/datasets/mnist/train-labels-idx1-ubyte.gz from https://apache-mxnet.s3-accelerate.dualstack.amazonaws.com/gluon/dataset/mnist/train-labels-idx1-ubyte.gz...
[06:59:19] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
Downloading /opt/mxnet/datasets/mnist/t10k-images-idx3-ubyte.gz from https://apache-mxnet.s3-accelerate.dualstack.amazonaws.com/gluon/dataset/mnist/t10k-images-idx3-ubyte.gz...
Downloading /opt/mxnet/datasets/mnist/t10k-labels-idx1-ubyte.gz from https://apache-mxnet.s3-accelerate.dualstack.amazonaws.com/gluon/dataset/mnist/t10k-labels-idx1-ubyte.gz...
d2l.show_images(images, 2, 9);
((train_images, train_labels), (
test_images, test_labels)) = tf.keras.datasets.mnist.load_data()
# Original pixel values of MNIST range from 0-255 (as the digits are stored as
# uint8). For this section, pixel values that are greater than 128 (in the
# original image) are converted to 1 and values that are less than 128 are
# converted to 0. See section 18.9.2 and 18.9.3 for why
train_images = tf.floor(tf.constant(train_images / 128, dtype = tf.float32))
test_images = tf.floor(tf.constant(test_images / 128, dtype = tf.float32))
train_labels = tf.constant(train_labels, dtype = tf.int32)
test_labels = tf.constant(test_labels, dtype = tf.int32)
Downloading data from https://storage.googleapis.com/tensorflow/tf-keras-datasets/mnist.npz
11490434/11490434 [==============================] - 0s 0us/step
画像と対応するラベルを含む、特定の例にアクセスできる。
image, label = mnist_train[2]
image.shape, label
(torch.Size([28, 28]), 4)
image, label = mnist_train[2]
image.shape, label
((28, 28), array(4, dtype=int32))
image, label = train_images[2], train_labels[2]
image.shape, label.numpy()
(TensorShape([28, 28]), 4)
ここで変数 image に格納されている例は、縦横ともに \(28\)
ピクセルの画像に対応している。
image.shape, image.dtype
(torch.Size([28, 28]), torch.float32)
コードでは、各画像のラベルをスカラーとして保存している。その型は32ビット整数である。
label, type(label)
(4, int)
label, type(label), label.dtype
(array(4, dtype=int32), mxnet.numpy.ndarray, dtype('int32'))
label.numpy(), label.dtype
(4, tf.int32)
複数の例に同時にアクセスすることもできる。
images = torch.stack([mnist_train[i][0] for i in range(10, 38)], dim=0)
labels = torch.tensor([mnist_train[i][1] for i in range(10, 38)])
images.shape, labels.shape
(torch.Size([28, 28, 28]), torch.Size([28]))
images, labels = mnist_train[10:38]
images.shape, labels.shape
((28, 28, 28), (28,))
images = tf.stack([train_images[i] for i in range(10, 38)], axis=0)
labels = tf.constant([train_labels[i].numpy() for i in range(10, 38)])
images.shape, labels.shape
(TensorShape([28, 28, 28]), TensorShape([28]))
これらの例を可視化してみよう。
d2l.show_images(images, 2, 9);
22.9.2. 分類のための確率モデル¶
分類タスクでは、例をあるカテゴリに写像する。ここでの例はグレースケールの \(28\times 28\) 画像で、カテゴリは数字である。(より詳しい説明は 4.1 章 を参照する。) 分類タスクを表現する自然な方法の一つは、確率的な問いとして考えることである。すなわち、特徴(つまり画像の画素)が与えられたとき、最もありそうなラベルは何か、という問いである。例の特徴を \(\mathbf x\in\mathbb R^d\)、ラベルを \(y\in\mathbb R\) と表する。ここで特徴は画像の画素であり、2次元画像をベクトルに変形すれば \(d=28^2=784\) となる。ラベルは数字である。 特徴が与えられたときのラベルの確率は \(p(y \mid \mathbf{x})\) である。もしこれらの確率、つまりこの例では \(y=0, \ldots,9\) に対する \(p(y \mid \mathbf{x})\) を計算できるなら、分類器は次の式で与えられる予測 \(\hat{y}\) を出力する。
残念ながら、これを行うには \(\mathbf{x} = x_1, ..., x_d\) のすべての値に対して \(p(y \mid \mathbf{x})\) を推定しなければならない。各特徴が \(2\) つの値のどちらかを取るとしよう。たとえば、特徴 \(x_1 = 1\) はある文書に単語 apple が現れることを意味し、\(x_1 = 0\) は現れないことを意味するとする。もしそのような2値特徴が \(30\) 個あれば、入力ベクトル \(\mathbf{x}\) の \(2^{30}\)(10億以上!)通りの可能な値のどれに対しても分類できるように準備しておく必要があることになる。
さらに、どこに学習があるのだろうか。対応するラベルを予測するためにあり得るすべての例を見なければならないなら、私たちは本当にパターンを学習しているのではなく、単にデータセットを記憶しているだけである。
22.9.3. ナイーブベイズ分類器¶
幸いなことに、条件付き独立についていくつかの仮定を置くことで、帰納バイアスを導入し、比較的少数の学習例から一般化できるモデルを構築できる。まずベイズの定理を使って、分類器を次のように表しよう。
分母は正規化項 \(p(\mathbf{x})\) であり、ラベル \(y\) の値には依存しないことに注意する。その結果、異なる \(y\) の値に対して分子を比較することだけを考えれば十分である。たとえ分母の計算が困難であっても、分子を評価できる限り、それを無視して構わない。幸いなことに、正規化定数を復元したくなったとしても可能である。\(\sum_y p(y \mid \mathbf{x}) = 1\) なので、正規化項は常に復元できる。
では、\(p( \mathbf{x} \mid y)\) に注目しよう。確率の連鎖律を用いると、\(p( \mathbf{x} \mid y)\) を次のように表せる。
これだけでは、まだ先に進めない。依然としておよそ \(2^d\) 個のパラメータを推定しなければならない。しかし、ラベルが与えられたとき、特徴同士は条件付き独立である と仮定すれば、この項は \(\prod_i p(x_i \mid y)\) に簡単化され、予測器は
となる。
すべての \(i\) と \(y\) に対して \(p(x_i=1 \mid y)\) を推定し、その値を \(P_{xy}[i, y]\) に保存できるとする。ここで \(P_{xy}\) は \(d\times n\) 行列で、\(n\) はクラス数、\(y\in\{1, \ldots, n\}\) である。すると、これを使って \(p(x_i = 0 \mid y)\) も推定できる。すなわち、
さらに、各 \(y\) に対して \(p(y)\) を推定して \(P_y[y]\) に保存する。ここで \(P_y\) は長さ \(n\) のベクトルである。すると、新しい例 \(\mathbf t = (t_1, t_2, \ldots, t_d)\) に対して、次を計算できる。
任意の \(y\) に対してである。したがって、条件付き独立という仮定により、モデルの複雑さは特徴数に対する指数的依存 \(\mathcal{O}(2^dn)\) から線形依存 \(\mathcal{O}(dn)\) へと減少した。
22.9.4. 学習¶
問題は、\(P_{xy}\) と \(P_y\) が分からないことである。したがって、まずいくつかの学習データからそれらの値を推定する必要がある。これがモデルの学習である。\(P_y\) の推定はそれほど難しくない。扱うクラスは \(10\) 個しかないので、各数字の出現回数 \(n_y\) を数え、全データ数 \(n\) で割ればよいのである。たとえば、数字8が \(n_8 = 5,800\) 回現れ、画像の総数が \(n = 60,000\) 枚なら、確率推定は \(p(y=8) = 0.0967\) である。
X = torch.stack([mnist_train[i][0] for i in range(len(mnist_train))], dim=0)
Y = torch.tensor([mnist_train[i][1] for i in range(len(mnist_train))])
n_y = torch.zeros(10)
for y in range(10):
n_y[y] = (Y == y).sum()
P_y = n_y / n_y.sum()
P_y
tensor([0.0987, 0.1124, 0.0993, 0.1022, 0.0974, 0.0904, 0.0986, 0.1044, 0.0975,
0.0992])
X, Y = mnist_train[:] # All training examples
n_y = np.zeros((10))
for y in range(10):
n_y[y] = (Y == y).sum()
P_y = n_y / n_y.sum()
P_y
array([0.09871667, 0.11236667, 0.0993 , 0.10218333, 0.09736667,
0.09035 , 0.09863333, 0.10441667, 0.09751666, 0.09915 ])
X = train_images
Y = train_labels
n_y = tf.Variable(tf.zeros(10))
for y in range(10):
n_y[y].assign(tf.reduce_sum(tf.cast(Y == y, tf.float32)))
P_y = n_y / tf.reduce_sum(n_y)
P_y
<tf.Tensor: shape=(10,), dtype=float32, numpy=
array([0.09871667, 0.11236667, 0.0993 , 0.10218333, 0.09736667,
0.09035 , 0.09863333, 0.10441667, 0.09751666, 0.09915 ],
dtype=float32)>
次に、少し難しい \(P_{xy}\) に進む。白黒画像を選んだので、\(p(x_i \mid y)\) は、クラス \(y\) に対して画素 \(i\) がオンになる確率を表する。先ほどと同様に、事象が起こる回数 \(n_{iy}\) を数え、それを \(y\) の総出現回数、すなわち \(n_y\) で割ればよいのである。しかし、少し厄介な点がある。ある画素が決して黒にならないことがあり得るのです(たとえば、きちんと切り抜かれた画像では、角の画素は常に白かもしれない)。統計学者がこの問題に対処する便利な方法は、すべての出現回数に擬似カウントを加えることである。したがって、\(n_{iy}\) の代わりに \(n_{iy}+1\) を使い、\(n_y\) の代わりに \(n_{y}+2\) を使う(画素 \(i\) が取りうる値は2つ、つまり黒か白のどちらかだからです)。これは ラプラス平滑化 とも呼ばれる。これは場当たり的に見えるかもしれないが、ベータ二項モデルによるベイズ的観点から動機づけることができる。
n_x = torch.zeros((10, 28, 28))
for y in range(10):
n_x[y] = torch.tensor(X.numpy()[Y.numpy() == y].sum(axis=0))
P_xy = (n_x + 1) / (n_y + 2).reshape(10, 1, 1)
d2l.show_images(P_xy, 2, 5);
n_x = np.zeros((10, 28, 28))
for y in range(10):
n_x[y] = np.array(X.asnumpy()[Y.asnumpy() == y].sum(axis=0))
P_xy = (n_x + 1) / (n_y + 2).reshape(10, 1, 1)
d2l.show_images(P_xy, 2, 5);
n_x = tf.Variable(tf.zeros((10, 28, 28)))
for y in range(10):
n_x[y].assign(tf.cast(tf.reduce_sum(
X.numpy()[Y.numpy() == y], axis=0), tf.float32))
P_xy = (n_x + 1) / tf.reshape((n_y + 2), (10, 1, 1))
d2l.show_images(P_xy, 2, 5);
これらの \(10\times 28\times 28\) 個の確率(各クラスの各画素ごと)を可視化すると、何となく数字らしいものが見えてきる。
これで (22.9.6) を使って新しい画像を予測できる。\(\mathbf x\) が与えられたとき、次の関数は各 \(y\) に対する \(p(\mathbf x \mid y)p(y)\) を計算する。
def bayes_pred(x):
x = x.unsqueeze(0) # (28, 28) -> (1, 28, 28)
p_xy = P_xy * x + (1 - P_xy)*(1 - x)
p_xy = p_xy.reshape(10, -1).prod(dim=1) # p(x|y)
return p_xy * P_y
image, label = mnist_test[0]
bayes_pred(image)
tensor([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
def bayes_pred(x):
x = np.expand_dims(x, axis=0) # (28, 28) -> (1, 28, 28)
p_xy = P_xy * x + (1 - P_xy)*(1 - x)
p_xy = p_xy.reshape(10, -1).prod(axis=1) # p(x|y)
return np.array(p_xy) * P_y
image, label = mnist_test[0]
bayes_pred(image)
array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
def bayes_pred(x):
x = tf.expand_dims(x, axis=0) # (28, 28) -> (1, 28, 28)
p_xy = P_xy * x + (1 - P_xy)*(1 - x)
p_xy = tf.math.reduce_prod(tf.reshape(p_xy, (10, -1)), axis=1) # p(x|y)
return p_xy * P_y
image, label = train_images[0], train_labels[0]
bayes_pred(image)
<tf.Tensor: shape=(10,), dtype=float32, numpy=array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.], dtype=float32)>
これはひどく失敗した!その理由を調べるために、画素ごとの確率を見てみよう。これらは通常 \(0.001\) から \(1\) の間の数である。私たちはそれらを784個掛け合わせている。ここで重要なのは、これらの数をコンピュータ上で計算しているため、指数部の範囲が有限であるということである。起こるのは 数値アンダーフロー で、小さな数をすべて掛け合わせるとさらに小さくなり、最終的には0に丸め込まれてしまいる。これは 22.7 章 で理論的な問題として議論したが、ここではその現象が実際に明確に見える。
その節で述べたように、\(\log a b = \log a + \log b\) という性質を使って、対数の和を取るようにすれば解決できる。 $ a $ と $ b $ の両方が小さい数であっても、対数値は適切な範囲に収まるはずである。
a = 0.1
print('underflow:', a**784)
print('logarithm is normal:', 784*math.log(a))
underflow: 0.0
logarithm is normal: -1805.2267129073316
a = 0.1
print('underflow:', a**784)
print('logarithm is normal:', 784*math.log(a))
underflow: 0.0
logarithm is normal: -1805.2267129073316
a = 0.1
print('underflow:', a**784)
print('logarithm is normal:', 784*tf.math.log(a).numpy())
underflow: 0.0
logarithm is normal: -1805.2267379760742
対数は単調増加関数なので、(22.9.6) を次のように書き換えられる。
次の安定版を実装できる。
log_P_xy = torch.log(P_xy)
log_P_xy_neg = torch.log(1 - P_xy)
log_P_y = torch.log(P_y)
def bayes_pred_stable(x):
x = x.unsqueeze(0) # (28, 28) -> (1, 28, 28)
p_xy = log_P_xy * x + log_P_xy_neg * (1 - x)
p_xy = p_xy.reshape(10, -1).sum(dim=1) # p(x|y)
return p_xy + log_P_y
py = bayes_pred_stable(image)
py
tensor([-268.9725, -301.7044, -245.1951, -218.8738, -193.4570, -206.0909,
-292.5226, -114.6257, -220.3313, -163.1784])
log_P_xy = np.log(P_xy)
log_P_xy_neg = np.log(1 - P_xy)
log_P_y = np.log(P_y)
def bayes_pred_stable(x):
x = np.expand_dims(x, axis=0) # (28, 28) -> (1, 28, 28)
p_xy = log_P_xy * x + log_P_xy_neg * (1 - x)
p_xy = p_xy.reshape(10, -1).sum(axis=1) # p(x|y)
return p_xy + log_P_y
py = bayes_pred_stable(image)
py
array([-268.97253, -301.7044 , -245.19514, -218.87384, -193.45703,
-206.09088, -292.52264, -114.62566, -220.33133, -163.17842])
log_P_xy = tf.math.log(P_xy)
log_P_xy_neg = tf.math.log(1 - P_xy)
log_P_y = tf.math.log(P_y)
def bayes_pred_stable(x):
x = tf.expand_dims(x, axis=0) # (28, 28) -> (1, 28, 28)
p_xy = log_P_xy * x + log_P_xy_neg * (1 - x)
p_xy = tf.math.reduce_sum(tf.reshape(p_xy, (10, -1)), axis=1) # p(x|y)
return p_xy + log_P_y
py = bayes_pred_stable(image)
py
<tf.Tensor: shape=(10,), dtype=float32, numpy=
array([-266.65015, -320.79282, -261.50186, -202.62967, -295.57236,
-199.49718, -318.66226, -266.01474, -224.59566, -271.9329 ],
dtype=float32)>
これで予測が正しいかどうか確認できる。
py.argmax(dim=0) == label
tensor(True)
# Convert label which is a scalar tensor of int32 dtype to a Python scalar
# integer for comparison
py.argmax(axis=0) == int(label)
array(True)
tf.argmax(py, axis=0, output_type = tf.int32) == label
<tf.Tensor: shape=(), dtype=bool, numpy=True>
いくつかの検証例を予測してみると、ベイズ分類器がかなりうまく機能していることが分かる。
def predict(X):
return [bayes_pred_stable(x).argmax(dim=0).type(torch.int32).item()
for x in X]
X = torch.stack([mnist_test[i][0] for i in range(18)], dim=0)
y = torch.tensor([mnist_test[i][1] for i in range(18)])
preds = predict(X)
d2l.show_images(X, 2, 9, titles=[str(d) for d in preds]);
def predict(X):
return [bayes_pred_stable(x).argmax(axis=0).astype(np.int32) for x in X]
X, y = mnist_test[:18]
preds = predict(X)
d2l.show_images(X, 2, 9, titles=[str(d) for d in preds]);
def predict(X):
return [tf.argmax(
bayes_pred_stable(x), axis=0, output_type = tf.int32).numpy()
for x in X]
X = tf.stack([train_images[i] for i in range(10, 38)], axis=0)
y = tf.constant([train_labels[i].numpy() for i in range(10, 38)])
preds = predict(X)
d2l.show_images(X, 2, 9, titles=[str(d) for d in preds]);
最後に、分類器全体の精度を計算しよう。
X = torch.stack([mnist_test[i][0] for i in range(len(mnist_test))], dim=0)
y = torch.tensor([mnist_test[i][1] for i in range(len(mnist_test))])
preds = torch.tensor(predict(X), dtype=torch.int32)
float((preds == y).sum()) / len(y) # Validation accuracy
0.8427
X, y = mnist_test[:]
preds = np.array(predict(X), dtype=np.int32)
float((preds == y).sum()) / len(y) # Validation accuracy
0.8427
X = test_images
y = test_labels
preds = tf.constant(predict(X), dtype=tf.int32)
# Validation accuracy
tf.reduce_sum(tf.cast(preds == y, tf.float32)).numpy() / len(y)
0.8427
現代の深層ネットワークは、\(0.01\) 未満の誤り率を達成する。比較的性能が低いのは、モデルで置いた統計的仮定が誤っているからである。つまり、各画素がラベルのみに依存して独立に生成されると仮定したのである。これは人間が数字を書く方法とは明らかに異なり、この誤った仮定が、あまりにもナイーブな(ベイズ)分類器の失敗につながりた。
22.9.5. まとめ¶
ベイズの規則を用いると、観測されたすべての特徴が独立であると仮定することで分類器を作れる。
この分類器は、ラベルと画素値の組合せの出現回数を数えることで、データセット上で学習できる。
この分類器は、スパム検出のようなタスクにおいて何十年もの間、標準的な手法だった。
22.9.6. 演習¶
データセット \([[0,0], [0,1], [1,0], [1,1]]\) を考え、ラベルを2つの要素の XOR で与えた \([0,1,1,0]\) とする。このデータセット上に構築したナイーブベイズ分類器の確率はどうなるか。点を正しく分類できるか。できない場合、どの仮定が破られているか。
確率を推定する際にラプラス平滑化を使わず、テスト時に学習で一度も観測されなかった値を含むデータ例が来たとする。このときモデルは何を出力するだろうか。
ナイーブベイズ分類器はベイジアンネットワークの特別な例であり、確率変数の依存関係がグラフ構造で表現される。この節の範囲を超える完全な理論については Koller and Friedman (2009) を参照されたいが、XOR モデルにおいて2つの入力変数の間の明示的な依存を許すと、なぜ成功する分類器を作れるのか説明する。