0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

PCA、つまり主成分分析は、データサイエンスや機械学習でよく出てくる基本的な手法です。

ただ、最初に学ぶときには、

  • 主成分とは何か
  • 固有値・固有ベクトルがなぜ出てくるのか
  • 次元削減とは何を削っているのか
  • 「分散が大きい方向」とは何を意味するのか

が少し分かりにくいと思います。

この記事では、2次元のダミーデータを作り、PCAが何をしているのかを図で確認します。

この記事の中心メッセージはシンプルです。

PCAは、データを見る座標軸を回転して、
最もよくばらつく方向を第1主成分として見つける方法です。

今回は scikit-learn を使わず、NumPyでPCAの中身を計算します。
便利な関数を使う前に、「PCAが何をしているのか」を目で見て理解することが目的です。


この記事でやること

この記事では、以下の流れでPCAを理解します。

  1. 相関のある2次元ダミーデータを作る
  2. データを平均0にする
  3. 共分散行列を計算する
  4. 固有値・固有ベクトルを求める
  5. 第1主成分・第2主成分を図に描く
  6. データをPCA座標に変換する
  7. 第1主成分だけで2次元データを近似してみる

Google Colabでそのまま実行できます。


PCAを一言でいうと

PCAは、Principal Component Analysis の略で、日本語では主成分分析と呼ばれます。

ざっくり言うと、

多変量データを、よくばらつく方向から順番に見直す方法

です。

たとえば、2つの特徴量 $x_1, x_2$ を持つデータがあるとします。

元の座標軸は、たとえば次のようなものです。

  • 横軸:特徴量1
  • 縦軸:特徴量2

しかし、データが斜め方向に細長く分布している場合、横軸・縦軸のまま見るよりも、データの伸びている方向に沿って見るほうが、構造をつかみやすいことがあります。

PCAは、その「データが最も伸びている方向」を探します。

その方向を 第1主成分、英語で PC1 と呼びます。

PC1に直交する方向のうち、次によくばらつく方向を 第2主成分、つまり PC2 と呼びます。

2次元データでは、PC1とPC2は互いに直交する2本の軸になります。


まず用語を整理する

PCAでは、いくつかの用語が出てきます。

主成分

主成分とは、PCAが新しく作る軸のことです。

元のデータの軸が、

  • feature 1
  • feature 2

だったとすると、PCA後の軸は、

  • PC1
  • PC2

になります。

PC1は、データが最もよくばらつく方向です。
PC2は、PC1と直交する方向のうち、次によくばらつく方向です。


スコア

スコアとは、各データ点を主成分軸の上で見たときの座標です。

たとえば、ある点がPC1方向にどれくらい進んでいるかを表す値が、PC1スコアです。

元の座標が、

$$(x_1, x_2)$$

だったものを、PCA後には、

$$(PC1\ score, PC2\ score)$$

として見直します。


寄与率

寄与率は、その主成分がデータ全体のばらつきをどれくらい説明しているかを表す割合です。

たとえば、PC1の寄与率が0.95なら、

データ全体のばらつきの約95%は、PC1方向のばらつきで説明できる

という意味です。


次元削減

次元削減とは、情報をなるべく残しながら、データの次元を減らすことです。

たとえば、2次元データをPC1だけで表すと、2次元から1次元に圧縮したことになります。

もちろん、PC2方向の情報は捨てるので、完全には元に戻せません。

ただし、PC1だけで大部分のばらつきが説明できるなら、PC1だけを使ってもデータの大まかな構造をかなりよく表せます。


PCAで出てくる最小限の数式

データ行列を $X$ とします。

ここでは、行がデータ点、列が特徴量だと思ってください。

まず、各特徴量の平均を引いて、平均0のデータにします。

$$
\tilde{X} = X - \bar{X}
$$

これを 中心化 と呼びます。

次に、共分散行列を計算します。

$$
C = \frac{1}{n-1}\tilde{X}^{\mathsf{T}}\tilde{X}
$$

共分散行列 $C$ は、特徴量どうしがどのように一緒に変動しているかを表す行列です。

PCAでは、この共分散行列に対して固有値分解を行います。

$$
Cw = \lambda w
$$

ここで、

  • $w$:主成分の方向を表すベクトル
  • $\lambda$:その方向の分散の大きさ

です。

つまり、固有ベクトル $w$ が主成分の方向で、固有値 $\lambda$ がその方向にどれくらいデータがばらついているかを表します。

最も大きい固有値に対応する固有ベクトルが、第1主成分です。


Google Colabで実行するコード

以下のコードをGoogle Colabに貼り付けて実行してください。

このコードでは、相関のある2次元ダミーデータを作り、PCAをNumPyで計算し、図を保存します。

# ============================================
# PCA demo with 2D dummy data
# ============================================

import os
import numpy as np
import matplotlib.pyplot as plt

# 表示を少し読みやすくする
np.set_printoptions(precision=3, suppress=True)

# 乱数シードを固定すると、毎回ほぼ同じ結果になります
SEED = 42
rng = np.random.default_rng(SEED)

# 図の保存先
os.makedirs("fig", exist_ok=True)

# -----------------------------
# 1. 相関のある2次元ダミーデータを作る
# -----------------------------
n = 250

true_mean = np.array([2.0, 1.0])

# 共分散行列
# 対角成分は各特徴量の分散、
# 非対角成分は2つの特徴量の共分散を表します。
true_cov = np.array([
    [3.0, 2.2],
    [2.2, 2.0]
])

X = rng.multivariate_normal(true_mean, true_cov, size=n)

# -----------------------------
# 2. 平均を引いて中心化する
# -----------------------------
x_mean = X.mean(axis=0)
X_centered = X - x_mean

# -----------------------------
# 3. 共分散行列を計算する
# -----------------------------
cov_matrix = np.cov(X_centered, rowvar=False)

# -----------------------------
# 4. 固有値・固有ベクトルを求める
# -----------------------------
eigvals, eigvecs = np.linalg.eigh(cov_matrix)

# np.linalg.eigh は固有値を小さい順に返すので、
# 大きい順に並べ替えます。
order = np.argsort(eigvals)[::-1]
eigvals = eigvals[order]
eigvecs = eigvecs[:, order]

# 表示を見やすくするために、固有ベクトルの符号をそろえます。
# 固有ベクトルは向きが逆でも同じ軸を表すため、符号は本質的ではありません。
if eigvecs[0, 0] < 0:
    eigvecs[:, 0] *= -1

if eigvecs[1, 1] < 0:
    eigvecs[:, 1] *= -1

# 寄与率
explained_variance_ratio = eigvals / eigvals.sum()

# -----------------------------
# 5. PCAスコアを計算する
# -----------------------------
# 中心化したデータを、主成分方向に射影します。
# これがPCA後の座標です。
Z = X_centered @ eigvecs

# PC1だけを使って元の2次元空間で近似する
X_pc1_only = np.outer(Z[:, 0], eigvecs[:, 0])

# -----------------------------
# 6. 数値結果を表示する
# -----------------------------
print("Sample mean:")
print(x_mean)

print("\nCovariance matrix:")
print(cov_matrix)

print("\nEigenvalues:")
print(eigvals)

print("\nEigenvectors:")
print(eigvecs)

print("\nExplained variance ratio:")
print(explained_variance_ratio)

# 中心化後の図で使う表示範囲
lim = np.max(np.abs(X_centered)) * 1.15

# -----------------------------
# Figure 1: 元のダミーデータ
# -----------------------------
plt.figure(figsize=(6, 6))
plt.scatter(X[:, 0], X[:, 1], s=24, alpha=0.65)
plt.scatter(
    x_mean[0],
    x_mean[1],
    s=120,
    marker="x",
    linewidths=3,
    label="sample mean"
)
plt.title("Figure 1. Original dummy data")
plt.xlabel("feature 1")
plt.ylabel("feature 2")
plt.legend()
plt.axis("equal")
plt.tight_layout()
plt.savefig("fig/fig1_original_data.png", dpi=160)
plt.show()

# -----------------------------
# Figure 2: 平均を引いたデータ
# -----------------------------
plt.figure(figsize=(6, 6))
plt.scatter(X_centered[:, 0], X_centered[:, 1], s=24, alpha=0.65)
plt.axhline(0, linestyle="--", linewidth=1)
plt.axvline(0, linestyle="--", linewidth=1)
plt.scatter(
    0,
    0,
    s=120,
    marker="x",
    linewidths=3,
    label="centered mean"
)
plt.title("Figure 2. Mean-centered data")
plt.xlabel("feature 1 - mean(feature 1)")
plt.ylabel("feature 2 - mean(feature 2)")
plt.xlim(-lim, lim)
plt.ylim(-lim, lim)
plt.legend()
plt.axis("equal")
plt.tight_layout()
plt.savefig("fig/fig2_mean_centered_data.png", dpi=160)
plt.show()

# -----------------------------
# Figure 3: 主成分方向
# -----------------------------
plt.figure(figsize=(6, 6))
plt.scatter(X_centered[:, 0], X_centered[:, 1], s=24, alpha=0.5)
plt.axhline(0, linestyle="--", linewidth=1)
plt.axvline(0, linestyle="--", linewidth=1)

# 矢印の長さは、各主成分方向の標準偏差に比例させます。
scale1 = 2.5 * np.sqrt(eigvals[0])
scale2 = 2.5 * np.sqrt(eigvals[1])

plt.arrow(
    0, 0,
    eigvecs[0, 0] * scale1,
    eigvecs[1, 0] * scale1,
    width=0.04,
    head_width=0.25,
    length_includes_head=True
)

plt.arrow(
    0, 0,
    eigvecs[0, 1] * scale2,
    eigvecs[1, 1] * scale2,
    width=0.04,
    head_width=0.25,
    length_includes_head=True
)

plt.text(
    eigvecs[0, 0] * scale1 * 1.05,
    eigvecs[1, 0] * scale1 * 1.05,
    "PC1"
)

plt.text(
    eigvecs[0, 1] * scale2 * 1.10,
    eigvecs[1, 1] * scale2 * 1.10,
    "PC2"
)

plt.title("Figure 3. Principal component directions")
plt.xlabel("centered feature 1")
plt.ylabel("centered feature 2")
plt.xlim(-lim, lim)
plt.ylim(-lim, lim)
plt.axis("equal")
plt.tight_layout()
plt.savefig("fig/fig3_principal_component_directions.png", dpi=160)
plt.show()

# -----------------------------
# Figure 4: PCA後の座標
# -----------------------------
lim_z = np.max(np.abs(Z)) * 1.15

plt.figure(figsize=(6, 6))
plt.scatter(Z[:, 0], Z[:, 1], s=24, alpha=0.65)
plt.axhline(0, linestyle="--", linewidth=1)
plt.axvline(0, linestyle="--", linewidth=1)
plt.title("Figure 4. Data represented by PCA coordinates")
plt.xlabel("PC1 score")
plt.ylabel("PC2 score")
plt.xlim(-lim_z, lim_z)
plt.ylim(-lim_z, lim_z)
plt.axis("equal")
plt.tight_layout()
plt.savefig("fig/fig4_pca_scores.png", dpi=160)
plt.show()

# -----------------------------
# Figure 5: 寄与率
# -----------------------------
plt.figure(figsize=(6, 4))
labels = ["PC1", "PC2"]
plt.bar(labels, explained_variance_ratio)
plt.ylim(0, 1)
plt.title("Figure 5. Explained variance ratio")
plt.ylabel("ratio")

for i, v in enumerate(explained_variance_ratio):
    plt.text(i, v + 0.03, f"{v:.2f}", ha="center")

plt.tight_layout()
plt.savefig("fig/fig5_explained_variance_ratio.png", dpi=160)
plt.show()

# -----------------------------
# Figure 6: PC1だけで近似する
# -----------------------------
plt.figure(figsize=(6, 6))
plt.scatter(
    X_centered[:, 0],
    X_centered[:, 1],
    s=24,
    alpha=0.35,
    label="centered data"
)
plt.scatter(
    X_pc1_only[:, 0],
    X_pc1_only[:, 1],
    s=24,
    alpha=0.65,
    label="projection onto PC1"
)

# 線が多すぎると見づらいので、一部の点だけ線を描きます。
idx = np.arange(0, n, 8)

for i in idx:
    plt.plot(
        [X_centered[i, 0], X_pc1_only[i, 0]],
        [X_centered[i, 1], X_pc1_only[i, 1]],
        linewidth=0.6,
        alpha=0.45
    )

plt.axhline(0, linestyle="--", linewidth=1)
plt.axvline(0, linestyle="--", linewidth=1)
plt.title("Figure 6. Approximating 2D data using only PC1")
plt.xlabel("centered feature 1")
plt.ylabel("centered feature 2")
plt.xlim(-lim, lim)
plt.ylim(-lim, lim)
plt.legend()
plt.axis("equal")
plt.tight_layout()
plt.savefig("fig/fig6_projection_onto_pc1.png", dpi=160)
plt.show()

実行結果の確認

実行すると、次のような数値が表示されます。

Sample mean:
[2.011 0.994]

Covariance matrix:
[[2.932 2.053]
 [2.053 1.770]]

Eigenvalues:
[4.485 0.218]

Eigenvectors:
[[ 0.798 -0.603]
 [ 0.603  0.798]]

Explained variance ratio:
[0.954 0.046]

ここで重要なのは、最後の寄与率です。

Explained variance ratio:
[0.954 0.046]

これは、

  • PC1がデータ全体のばらつきの約95.4%を説明する
  • PC2が残りの約4.6%を説明する

という意味です。

つまり、今回のダミーデータでは、2次元データの大部分のばらつきがPC1方向に集まっています。


図1:元のダミーデータを見る

fig1_original_data.png

図1では、相関のある2次元データを表示しています。

横軸が feature 1、縦軸が feature 2 です。

点の集まりを見ると、右上がりの斜め方向に細長く分布していることが分かります。

これは、2つの特徴量が独立ではなく、ある程度一緒に増減していることを意味します。

PCAは、このようなデータに対して、

この点の集まりは、どの方向に一番よく伸びているのか?

を調べます。


図2:平均を引いて中心化する

fig2_mean_centered_data.png

図2では、各特徴量から平均値を引いて、データの中心を原点に移動しています。

これを中心化と呼びます。

なぜ中心化するのでしょうか。

PCAが見たいのは、データがどこにあるかではなく、

平均のまわりで、どの方向にどれくらいばらついているか

だからです。

元データの平均が $(2, 1)$ 付近にあっても、PCAにとって重要なのは、平均から見た相対的な広がりです。

そのため、まず平均を引いて、データの中心を原点にそろえます。


図3:主成分方向を見る

fig3_principal_component_directions.png

図3では、中心化したデータに対して、PC1とPC2の方向を矢印で描いています。

PC1は、点の集まりが最も長く伸びている方向です。

今回のデータでは、点群が右上がりの斜め方向に長く伸びているため、PC1もその方向を向いています。

PC2は、PC1と直交する方向です。

ここで大事なのは、PCAが新しい軸を作っているということです。

元の軸は、

  • feature 1
  • feature 2

でした。

PCA後の軸は、

  • PC1
  • PC2

です。

つまりPCAは、データに合わせて座標軸を回転している、と考えることができます。


「よくばらつく方向」とは何か

「よくばらつく方向」という表現は、少し直感的すぎるかもしれません。

もう少し数式に近づけると、ある方向 $w$ にデータを射影したとき、その射影後の値の分散が大きい方向、という意味です。

中心化済みのデータを $\tilde{X}$ とします。

方向ベクトルを $w$ とすると、各データ点をその方向に射影した値は、

$$
z = \tilde{X}w
$$

です。

この $z$ の分散が最大になるような $w$ を探すと、それがPC1になります。

つまり、PC1は、

データを1本の軸に押しつぶしたとき、最も情報が残りやすい方向

と考えることもできます。


図4:PCA後の座標で見る

fig4_pca_scores.png

図4では、データをPCA後の座標で表示しています。

横軸がPC1スコア、縦軸がPC2スコアです。

図3では斜めに伸びていた点群が、図4では横方向に伸びて見えます。

これは、PCAによって座標軸を回転したからです。

つまり、

斜めに伸びていたデータを、PC1方向が横軸になるように見直した

ということです。

この図を見ると、PC1方向には大きくばらついていますが、PC2方向にはあまりばらついていないことが分かります。


図5:寄与率を見る

fig5_explained_variance_ratio.png

図5は、PC1とPC2の寄与率を表しています。

今回の結果では、おおよそ次のようになりました。

PC1: 0.95
PC2: 0.05

つまり、データ全体のばらつきの約95%は、PC1で説明できています。

これはかなり大きな割合です。

このような場合、

2次元データだけれど、実質的にはPC1方向の1次元的な構造が強い

と見ることができます。

もちろん、PC2方向の情報が完全に不要という意味ではありません。
ただ、今回のダミーデータでは、PC2方向のばらつきはかなり小さいです。


図6:PC1だけで2次元データを近似する

fig6_projection_onto_pc1.png

図6では、元の中心化データをPC1方向だけに射影しています。

薄い点が元の2次元データ、濃い点がPC1だけで表した近似です。

PC1だけを使うということは、PC2方向の情報を捨てるということです。

図では、各点をPC1の直線上に落としている、と考えると分かりやすいです。

この操作によって、2次元データを1次元で表せます。

これがPCAによる次元削減の基本的な考え方です。

今回のデータではPC1の寄与率が約95%なので、PC1だけでもかなり元データの構造を表せています。


PCAの処理を言葉でまとめる

PCAの流れを、もう一度言葉で整理します。

  1. データの平均を引く
  2. データのばらつき方を共分散行列で表す
  3. 共分散行列の固有値・固有ベクトルを求める
  4. 最大の固有値に対応する固有ベクトルをPC1にする
  5. 次に大きい固有値に対応する固有ベクトルをPC2にする
  6. データをPC1、PC2の座標で見直す
  7. 必要なら、寄与率の高い主成分だけを残す

数式で見ると難しく感じるかもしれません。

しかし、図で見るとPCAがしていることはかなり直感的です。

データに合わせて座標軸を回転し、
よくばらつく方向から順番に新しい軸として使う。

これがPCAの基本です。


固有値・固有ベクトルはなぜ出てくるのか

PCAで急に固有値・固有ベクトルが出てくると、少し驚くかもしれません。

でも、直感的にはそれほど不自然ではありません。

共分散行列 $C$ は、データのばらつき方を表す行列です。

ある方向 $w$ にデータを射影したとき、その方向の分散は次の形で表せます。

$$
w^{\mathsf{T}} C w
$$

PCAでは、長さ1の方向ベクトル $w$ の中で、この値が最大になる方向を探します。

$$
|w| = 1
$$

この条件のもとで $w^{\mathsf{T}} C w$ を最大化すると、答えは共分散行列 $C$ の固有ベクトルになります。

そして、そのときの分散の大きさが固有値になります。

つまり、

  • 固有ベクトル:ばらつきの方向
  • 固有値:その方向のばらつきの大きさ

です。

この対応が分かると、PCAに固有値・固有ベクトルが出てくる理由が少し見えやすくなります。


PCAで注意すること

PCAは便利ですが、いくつか注意点があります。

1. PCAは「教師なし」の方法である

PCAは、ラベルや正解を見ません。

分類問題であれば、クラスラベルを見ずに、データ全体のばらつきだけを見ています。

そのため、PC1が分類に一番役立つ方向とは限りません。

PCAが見ているのは、あくまで「分散が大きい方向」です。


2. 分散が大きい方向が、必ず重要とは限らない

PCAは分散の大きい方向を重視します。

しかし、分散が大きいからといって、その方向が目的にとって重要とは限りません。

たとえば、測定ノイズやバッチ差が大きい場合、PCAはそれをPC1として拾ってしまうことがあります。

PCAの結果を見るときには、

PC1は何を反映しているのか?

を考える必要があります。


3. 特徴量のスケールに影響される

PCAは分散を見る方法なので、特徴量の単位やスケールに影響されます。

たとえば、

  • 身長を m で表す
  • 身長を cm で表す

だけでも分散の大きさは変わります。

特徴量の単位やスケールが大きく異なる場合には、平均0、標準偏差1にそろえる標準化を行ってからPCAすることがよくあります。

今回は2つの特徴量を同じようなスケールで作ったため、平均を引く中心化だけにしています。


4. 主成分の符号に本質的な意味はない

PC1の矢印が右上を向くか、左下を向くかは、本質的には同じです。

固有ベクトル $w$ に対して、$-w$ も同じ軸を表すからです。

そのため、別のライブラリや別の環境でPCAを実行すると、PC1やPC2の符号が逆になることがあります。

これは間違いではありません。


scikit-learnで書くとどうなるか

実務では、scikit-learnPCA を使うことが多いです。

今回の例を scikit-learn で書くと、次のようになります。

from sklearn.decomposition import PCA

pca = PCA(n_components=2)
Z_sklearn = pca.fit_transform(X)

print("Explained variance ratio:")
print(pca.explained_variance_ratio_)

print("Principal components:")
print(pca.components_)

scikit-learnPCA は、内部で平均を引いてくれます。

ただし、主成分の符号はNumPyで自分で計算した場合と逆になることがあります。
符号が逆でも、同じ軸を表していれば問題ありません。


まとめ

この記事では、2次元ダミーデータを使って、PCAが何をしているのかを図で確認しました。

重要なポイントは以下です。

  • PCAは、データに合わせて座標軸を回転する方法と考えられる
  • PC1は、データが最もよくばらつく方向である
  • PC2は、PC1に直交する方向のうち、次によくばらつく方向である
  • 固有ベクトルは主成分の方向を表す
  • 固有値はその方向のばらつきの大きさを表す
  • 寄与率は、その主成分が全体のばらつきの何割を説明するかを表す
  • PC1だけを残すと、2次元データを1次元に近似できる

PCAを数式だけで見ると難しく感じます。

しかし、2次元データで図にしてみると、

PCAは、データが伸びている方向に座標軸を回す方法

としてかなり直感的に理解できます。


おまけ:自分で試すと理解が深まる変更点

理解を深めるには、コードの以下の部分を変更してみるとよいです。

1. 相関を弱くする

true_cov = np.array([
    [3.0, 0.3],
    [0.3, 2.0]
])

相関を弱くすると、PC1の寄与率はどう変わるでしょうか。


2. 相関を強くする

true_cov = np.array([
    [3.0, 2.4],
    [2.4, 2.0]
])

点群がより細長くなると、PC1の寄与率はどう変わるでしょうか。


3. 片方の分散だけ大きくする

true_cov = np.array([
    [10.0, 0.0],
    [0.0, 1.0]
])

この場合、PC1はどちらの軸に近くなるでしょうか。


おわりに

PCAは、データ解析で非常によく使われる基本手法です。

実際のデータでは、ノイズ、外れ値、特徴量のスケール、バッチ効果、測定条件の違いなどがPCAの結果に影響します。

そのため、PCAの図を見たときには、

PC1は何を反映しているのか?
PC2は何を反映しているのか?
大きなばらつきは、本当に知りたい構造なのか?

を考えることが大切です。

PCAは単なる前処理ではなく、データの構造を観察するための強力な道具でもあります。

まずは2次元のダミーデータで、PCAが「座標軸を回転している」感覚をつかむと、より高次元のデータにも応用しやすくなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?