機械学習チュートリアル (ポイントクラウド版)¶

まず利用するライブラリを読み込みます。Pythonの機械学習ライブラリ sklearn などを利用します。 sklearn については https://scikit-learn.org/stable/index.html を参考にしてください。その他のライブラリについても 勉強してください。

In [1]:
import os  # for makedirs
import homcloud.interface as hc  # HomCloud 
import homcloud.plotly_3d as p3d  # 3D visualizaiton
import plotly.graph_objects as go  # 3D visualizaiton
import numpy as np  # Numerical array library
from tqdm import tqdm_notebook as tqdm  # For progressbar
import matplotlib.pyplot as plt  # Plotting
import sklearn.linear_model as lm  # Machine learning
from sklearn.decomposition import PCA  # for PCA
from sklearn.model_selection import train_test_split
In [2]:
%matplotlib inline
%load_ext autoreload
%autoreload 2

機械学習を始める前に¶

まずはデータがどんなものか確認していきます。チュートリアル用のデータは pc というディレクトリ以下にあります。

In [3]:
ls pc/
0000.txt  0026.txt  0052.txt  0078.txt  0104.txt  0130.txt  0156.txt  0182.txt
0001.txt  0027.txt  0053.txt  0079.txt  0105.txt  0131.txt  0157.txt  0183.txt
0002.txt  0028.txt  0054.txt  0080.txt  0106.txt  0132.txt  0158.txt  0184.txt
0003.txt  0029.txt  0055.txt  0081.txt  0107.txt  0133.txt  0159.txt  0185.txt
0004.txt  0030.txt  0056.txt  0082.txt  0108.txt  0134.txt  0160.txt  0186.txt
0005.txt  0031.txt  0057.txt  0083.txt  0109.txt  0135.txt  0161.txt  0187.txt
0006.txt  0032.txt  0058.txt  0084.txt  0110.txt  0136.txt  0162.txt  0188.txt
0007.txt  0033.txt  0059.txt  0085.txt  0111.txt  0137.txt  0163.txt  0189.txt
0008.txt  0034.txt  0060.txt  0086.txt  0112.txt  0138.txt  0164.txt  0190.txt
0009.txt  0035.txt  0061.txt  0087.txt  0113.txt  0139.txt  0165.txt  0191.txt
0010.txt  0036.txt  0062.txt  0088.txt  0114.txt  0140.txt  0166.txt  0192.txt
0011.txt  0037.txt  0063.txt  0089.txt  0115.txt  0141.txt  0167.txt  0193.txt
0012.txt  0038.txt  0064.txt  0090.txt  0116.txt  0142.txt  0168.txt  0194.txt
0013.txt  0039.txt  0065.txt  0091.txt  0117.txt  0143.txt  0169.txt  0195.txt
0014.txt  0040.txt  0066.txt  0092.txt  0118.txt  0144.txt  0170.txt  0196.txt
0015.txt  0041.txt  0067.txt  0093.txt  0119.txt  0145.txt  0171.txt  0197.txt
0016.txt  0042.txt  0068.txt  0094.txt  0120.txt  0146.txt  0172.txt  0198.txt
0017.txt  0043.txt  0069.txt  0095.txt  0121.txt  0147.txt  0173.txt  0199.txt
0018.txt  0044.txt  0070.txt  0096.txt  0122.txt  0148.txt  0174.txt  label.txt
0019.txt  0045.txt  0071.txt  0097.txt  0123.txt  0149.txt  0175.txt
0020.txt  0046.txt  0072.txt  0098.txt  0124.txt  0150.txt  0176.txt
0021.txt  0047.txt  0073.txt  0099.txt  0125.txt  0151.txt  0177.txt
0022.txt  0048.txt  0074.txt  0100.txt  0126.txt  0152.txt  0178.txt
0023.txt  0049.txt  0075.txt  0101.txt  0127.txt  0153.txt  0179.txt
0024.txt  0050.txt  0076.txt  0102.txt  0128.txt  0154.txt  0180.txt
0025.txt  0051.txt  0077.txt  0103.txt  0129.txt  0155.txt  0181.txt

0000.txt から 0199.txt まで 200個のテキストデータがあります。また、label.txtには 0 か 1 のラベルデータがあり、 これは対象のデータが2つのグループに分けられていることを示しています。

では、ラベルを読み込んで表示します。

In [4]:
labels = np.loadtxt("pc/label.txt")
labels
Out[4]:
array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.,
       0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 1.,
       1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
       1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
       1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
       1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
       1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
       1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])

これは 200個の 0/1の配列です。では、どのデータが0/1でラベル付けされているのかを確認しましょう。 numpy の機能を使います。

In [5]:
np.nonzero(labels == 0), np.nonzero(labels == 1)
Out[5]:
((array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
         17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
         34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
         51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67,
         68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84,
         85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99]),),
 (array([100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112,
         113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125,
         126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138,
         139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151,
         152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164,
         165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177,
         178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190,
         191, 192, 193, 194, 195, 196, 197, 198, 199]),))

0 から 99 までが 0 で、100から199までが 1 で、それぞれラベル付けられていることがわかります。 では、0番目のデータを100番目のデータを可視化してみましょう。

In [6]:
go.Figure(data=[p3d.PointCloud(np.loadtxt("pc/0000.txt"))], layout=dict(scene=p3d.SimpleScene()))
In [7]:
go.Figure(data=[p3d.PointCloud(np.loadtxt("pc/0100.txt"))], layout=dict(scene=p3d.SimpleScene()))

ポイントクラウドデータの読み込み¶

ではファイルからポイントクラウドデータを読み込みます。

In [8]:
pointclouds = [np.loadtxt("pc/{:04d}.txt".format(i)) for i in tqdm(range(200))]
Widget Javascript not detected.  It may not be installed or enabled properly.

0番目のデータの「形」を見ると 1000x3 となっていることがわかります。これは3次元上の点が1000個あることを意味します。 どのポイントクラウドも同じです。パーシステント図による機械学習では点の個数は同じくらいでないとあまりうまくいかない (不可能ではないが注意点が多くなる)ことに注意しましょう。

In [9]:
pointclouds[0].shape
Out[9]:
(1000, 3)

パーシステント図の計算¶

では、HomCloudでパーシステント図を計算します。 計算したファイルは pd というディレクトリに保存しておきます。

保存しておいたデータは再利用できるので、別の解析をする場合などにはいちいち計算しなおすことなく利用できます。

In [10]:
os.makedirs("pd", exist_ok=True)  # 保存先のディレクトリを作成
for i in tqdm(range(200)):
    hc.PDList.from_alpha_filtration(pointclouds[i], save_boundary_map=True, 
                                   save_to="pd/{:04d}.pdgm".format(i))
Widget Javascript not detected.  It may not be installed or enabled properly.

このチュートリアルでは2次のパーシステント図を使います。つまり空隙構造に注目していきます。 もちろん1次や0次なども使えますが、実はこのデータは2次の所に一番特徴があるのでこうします。

より高度な解析法として、1次と2次を同時に使う方法もあります。

In [11]:
pds = [hc.PDList("pd/{:04d}.pdgm".format(i)).dth_diagram(2) for i in tqdm(range(200))]
Widget Javascript not detected.  It may not be installed or enabled properly.

とりあえず計算したパーシステント図を表示してみます。0番目(ラベル0)と100番目(ラベル1)を表示します。

In [12]:
pds[0].histogram(x_range=(0, 0.03)).plot(colorbar={"type": "log"})
pds[100].histogram(x_range=(0, 0.03)).plot(colorbar={"type": "log"})
No description has been provided for this image
No description has been provided for this image

Persistence Image (PI) でベクトル化¶

Persistence Image については https://arxiv.org/abs/1507.06217 http://www.jmlr.org/papers/volume18/16-337/16-337.pdf を参考にしてください。 ただし、ここで説明するやりかたはこの論文の手法そのままではなく、 https://arxiv.org/abs/1706.10082 https://link.springer.com/article/10.1007%2Fs41468-018-0013-5 で用いられている改変版です。

まずはベクトル化の仕様を決めます。 (0, 0.03), 128 で [0, 0.03]x[0, 0.03] という正方形内を128x128に分割して計算することを指定します。 sigma=0.002 はガウス分布の標準偏差を指定します。また、weight=("atan", 0.01, 3) で対角線からの距離による重みの変化を 指定します。この場合の重みを決めるルールは $\textrm{atan} (0.01 \cdot \ell^3)$ です。ただし$\ell$はlifetime(=birth-death)を意味 します。

In [13]:
vectorize_spec = hc.PIVectorizeSpec((0, 0.03), 128, sigma=0.002, weight=("atan", 0.01, 3))

ではパーシステント図をベクトル化しましょう

In [14]:
pdvects = np.vstack([vectorize_spec.vectorize(pd) for pd in pds])

ベクトルの要素の最大最小を見ると$10^{-10}$と非常に小さいです。これは機械学習に悪い影響を与えるので正規化しておきます。

In [15]:
pdvects.min(), pdvects.max()
Out[15]:
(0.0, 2.099302720413663e-10)
In [16]:
pdvects = pdvects / pdvects.max()

主成分解析(PCA)¶

まずはPCAを使って解析します。PCAは高次元のデータをできるだけ情報を損わずに低次元に落とすための手法です。 あまり解析結果が破綻しないのでとりあえずパーシステント図との組み合わせでは試す価値があります。 パーシステント図との組み合わせにおいては、個々のパーシステント図を低い次元の点として表現でき、パーシステント図互いの 関係を理解しやすく表現します。

ここでは可視化しやすい2次元に落とします。

In [17]:
pca = PCA(n_components=2)
pca.fit(pdvects)
Out[17]:
PCA(copy=True, iterated_power='auto', n_components=2, random_state=None,
    svd_solver='auto', tol=0.0, whiten=False)

さてどのように2次元上に表現されているか可視化します。 赤がラベル0、青がラベル1です。

In [18]:
reduced = pca.transform(pdvects)  # すべてのデータを2次元に落とす
plt.gca().set_aspect('equal')  # 縦横のアスペクト比を揃える
plt.scatter(reduced[labels == 0, 0], reduced[labels == 0, 1], c="r")  # ラベル0のデータを赤で描画
plt.scatter(reduced[labels == 1, 0], reduced[labels == 1, 1], c="b")  # ラベル1のデータを青で描画
Out[18]:
<matplotlib.collections.PathCollection at 0x7f3b1cf8a8b0>
No description has been provided for this image

2次元平面上で赤と青がそれなりに分離できているようなので、ここでPIで計算したベクトルは2つのグループの間の違いをうまく表現できているようです。

ではこのX軸とY軸は何を表現しているのでしょうか?ここでは普通のPCAを使っているので、元の次元の所に ベクトルがあり、各点がそのベクトルの成分をどれだけ含んでいるかを表しています。数式で書くと、 X軸、Y軸に対応するベクトル$v_1, v_2$があり、PIによる高次元ベクトル$w_i$($i$はデータごとのインデックスです)は $$ w_i \approx \mu_{i1} v_1 + \mu_{i2} v_2 + v_0 $$ ただし$v_0$は$w_1,\ldots,$の平均、と近似されます(このあたりはPCAの理論を勉強してください)。 すると、$v_1, v_2$がどのようなベクトルであるかがわかればこの図の意味がわかるわけです。

sklearn の PCA では、$v_0$ が PCA.mean_, $v_1, v_2$ が PCA.components_ に保存されています。

In [19]:
pca.mean_
Out[19]:
array([3.32287297e-05, 3.52588389e-05, 3.67793759e-05, ...,
       9.95550058e-08, 7.27036741e-08, 7.13024938e-08])
In [20]:
pca.components_
Out[20]:
array([[ 2.59806997e-07,  2.70363482e-07,  2.77565448e-07, ...,
        -3.62878022e-09, -4.09395688e-09, -3.50500694e-09],
       [-6.08939377e-07, -6.42464501e-07, -6.65625771e-07, ...,
         4.65980431e-09, -5.76173853e-10,  1.41943997e-09]])

PIの一つの利点として、ベクトルをパーシステント図に変換できる点があります。これを使うとこれら平均や$v_1, v_2$をパーシステント図の形で 可視化できます。実際にやってみましょう。ベクトル化で使ったhc.PIVectorizerMeshのオブジェクトがこの変換(逆変換)のための情報を持っています。

In [21]:
vectorize_spec.histogram_from_vector(pca.mean_).plot()
No description has been provided for this image
In [22]:
vectorize_spec.histogram_from_vector(pca.components_[0, :]).plot(colorbar={"type":"linear-midpoint", "midpoint": 0})
vectorize_spec.histogram_from_vector(pca.components_[1, :]).plot(colorbar={"type":"linear-midpoint", "midpoint": 0})
No description has been provided for this image
No description has been provided for this image

PCAのベクトルの成分には±があり、それが各軸の±と対応しているので±が赤と黒に対応するよう表示させるとわかりやすいです。 それは、colorbar={"type":"linear-midpoint", "midpoint": 0}の部分で調整できます。ここからさらに この赤い部分、青い部分、に含まれるbirth-death pairを取り出して、逆解析で取り出してさらなる解析をする、ということもできます。 この逆解析の部分は次のロジスティック回帰の部分で実習しましょう。

ロジスティック回帰¶

次はロジスティック回帰です。ロジスティック回帰もPHとの組み合わせが比較的やりやすい手法です。 特に2クラス分類が解釈のしやすさもあってやりやすいです。

まず学習をする前にテスト用データ(test set)と学習用データ(training set)に分けます。

In [23]:
pdvects_train, pdvects_test, labels_train, labels_test = train_test_split(pdvects, labels, test_size=0.25)

学習用データで学習します。sklearnのLogisitcRegressionというクラスを使います。正則化パラメータCはここでは あらかじめ適当なものを選んでいますが、LogisitcRegressionCVというクラスを使って交差検証を使って自動的にパラメータを 決めることもできます。

In [24]:
model = lm.LogisticRegression(C=0.01, solver="lbfgs")
In [25]:
model.fit(pdvects_train, labels_train, )
Out[25]:
LogisticRegression(C=0.01, class_weight=None, dual=False, fit_intercept=True,
                   intercept_scaling=1, l1_ratio=None, max_iter=100,
                   multi_class='auto', n_jobs=None, penalty='l2',
                   random_state=None, solver='lbfgs', tol=0.0001, verbose=0,
                   warm_start=False)

テスト用データを使って性能を確認します。

In [26]:
model.score(pdvects_test, labels_test)
Out[26]:
0.82

なかなか良さそうな精度です。では、学習結果を可視化します。

In [27]:
vectorize_spec.histogram_from_vector(model.coef_.ravel()).plot(colorbar={"type": "linear-midpoint", "midpoint": 0.0})
No description has been provided for this image

この図の見方ですが、濃い赤の領域に沢山birth-death pairがあるデータはラベル1である可能性が高く、 濃い青の領域に沢山birth-death pairがあるデータはラベル0である可能性が高いことを意味します。

逆解析 (Optimal volume)¶

ではこの赤/青の領域を抽出しましょう。 値が0.005を基準として、0.005より大、-0.005より小、の部分を切り出します。 これには numpy の不等号演算が役立ちます。

In [28]:
coef = model.coef_.ravel()
In [29]:
red_area = vectorize_spec.mask_from_vector(coef > 0.005)
In [30]:
blue_area = vectorize_spec.mask_from_vector(coef < -0.005)

取り出した領域を可視化します。

In [31]:
blue_area.plot(title="blue", ax=plt.subplot(1, 2, 1))
red_area.plot(title="red", ax=plt.subplot(1, 2, 2))
No description has been provided for this image

取り出した領域に含まれているbirth-death pairを0番目のPD(ラベル0)100番目のPD(ラベル1)から取り出します。

In [32]:
pairs_in_blue_0 = blue_area.filter_pairs(pds[0].pairs())
pairs_in_red_0 = red_area.filter_pairs(pds[0].pairs())
In [33]:
pairs_in_blue_100 = blue_area.filter_pairs(pds[100].pairs())
pairs_in_red_100 = red_area.filter_pairs(pds[100].pairs())

個数を表示しましょう。

In [34]:
len(pairs_in_blue_0), len(pairs_in_red_0), len(pairs_in_blue_100), len(pairs_in_red_100)
Out[34]:
(3, 2, 0, 7)

確かに0番目PDには青い領域にあるpairが多く、100番目PDには赤い領域にあるpairが多いようです。実際に見てみると以下のような pairのようです。

In [35]:
pairs_in_blue_0
Out[35]:
[Pair(0.008484743298521374, 0.015315059365253271),
 Pair(0.0086407914437143, 0.01634538350339904),
 Pair(0.006687816186736094, 0.01715384087925338)]

さて、ここでoptimal volumeを計算して可視化してみます。 詳しくは説明しませんが、

  • optimal volumeの理論: https://epubs.siam.org/doi/abs/10.1137/17M1159439
  • optimal volume計算のインターフェース https://homcloud.dev/python-api/interface.html#homcloud.interface.Pair.optimal_volume
  • 可視化のインターフェース https://homcloud.dev/python-api/plotly_3d.html

などを参考にしてください。これによってどのような空隙が分類に重要な役割を果たしているのか可視化できます。

In [36]:
optimal_volumes_blue_0 = [pair.optimal_volume(cutoff_radius=0.4) for pair in pairs_in_blue_0]
optimal_volumes_red_100 = [pair.optimal_volume(cutoff_radius=0.4) for pair in pairs_in_red_100]
In [37]:
go.Figure(data=[
    ov.to_plotly3d_mesh(color="blue") for ov in optimal_volumes_blue_0
] + [
    p3d.PointCloud(pointclouds[0])
], layout=dict(scene=p3d.SimpleScene()))
In [38]:
go.Figure(data=[
    ov.to_plotly3d_mesh(color="red") for ov in optimal_volumes_red_100
] + [
    p3d.PointCloud(pointclouds[100])
], layout=dict(scene=p3d.SimpleScene()))

ちょっとわかりにくい気もするので2つ重ねて表示してみます。

In [39]:
fig = go.Figure(data=[
    ov.to_plotly3d_mesh(color="blue") for ov in optimal_volumes_blue_0
] + [
    ov.to_plotly3d_mesh(color="red") for ov in optimal_volumes_red_100
], layout=dict(scene=p3d.SimpleScene()))
fig.update_traces(opacity=0.5, selector=dict(type="mesh3d"))

青い空隙がラベル0に「典型的」な空隙で、赤い空隙がラベル1に「典型的」な空隙です。

青のほうが赤よりちょっと細い感じがすると思います。学習結果のパーシステント図を見ると、 青い領域は赤い領域よりY軸(death)で小さいほうにあります。実は2次のパーシステント図において、 deathはその空隙に収まる最大の球の半径の2乗に対応しています。つまりこの学習結果は ラベル1のほうがこのスケールの隙間が大きい、ことを意味しています。 赤い領域の中心のdeathが0.022、青い領域の中心が0.017くらいですので、 それぞれの平方根、つまり

In [40]:
np.sqrt(0.017), np.sqrt(0.022)
Out[40]:
(0.130384048104053, 0.14832396974191325)

あたりがそれぞれのグループの特徴的空隙のスケールである、ということがわかります。 元々のパーシステント図を眺めると、もっと小さい空隙はどちらも共通に沢山ある、ということも 見てとれます。

以上でこのチュートリアルは終わりです。