はじめに
PCA、つまり主成分分析は、データサイエンスや機械学習でよく出てくる基本的な手法です。
ただ、最初に学ぶときには、
- 主成分とは何か
- 固有値・固有ベクトルがなぜ出てくるのか
- 次元削減とは何を削っているのか
- 「分散が大きい方向」とは何を意味するのか
が少し分かりにくいと思います。
この記事では、2次元のダミーデータを作り、PCAが何をしているのかを図で確認します。
この記事の中心メッセージはシンプルです。
PCAは、データを見る座標軸を回転して、
最もよくばらつく方向を第1主成分として見つける方法です。
今回は scikit-learn を使わず、NumPyでPCAの中身を計算します。
便利な関数を使う前に、「PCAが何をしているのか」を目で見て理解することが目的です。
この記事でやること
この記事では、以下の流れでPCAを理解します。
- 相関のある2次元ダミーデータを作る
- データを平均0にする
- 共分散行列を計算する
- 固有値・固有ベクトルを求める
- 第1主成分・第2主成分を図に描く
- データをPCA座標に変換する
- 第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:元のダミーデータを見る
図1では、相関のある2次元データを表示しています。
横軸が feature 1、縦軸が feature 2 です。
点の集まりを見ると、右上がりの斜め方向に細長く分布していることが分かります。
これは、2つの特徴量が独立ではなく、ある程度一緒に増減していることを意味します。
PCAは、このようなデータに対して、
この点の集まりは、どの方向に一番よく伸びているのか?
を調べます。
図2:平均を引いて中心化する
図2では、各特徴量から平均値を引いて、データの中心を原点に移動しています。
これを中心化と呼びます。
なぜ中心化するのでしょうか。
PCAが見たいのは、データがどこにあるかではなく、
平均のまわりで、どの方向にどれくらいばらついているか
だからです。
元データの平均が $(2, 1)$ 付近にあっても、PCAにとって重要なのは、平均から見た相対的な広がりです。
そのため、まず平均を引いて、データの中心を原点にそろえます。
図3:主成分方向を見る
図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後の座標で見る
図4では、データをPCA後の座標で表示しています。
横軸がPC1スコア、縦軸がPC2スコアです。
図3では斜めに伸びていた点群が、図4では横方向に伸びて見えます。
これは、PCAによって座標軸を回転したからです。
つまり、
斜めに伸びていたデータを、PC1方向が横軸になるように見直した
ということです。
この図を見ると、PC1方向には大きくばらついていますが、PC2方向にはあまりばらついていないことが分かります。
図5:寄与率を見る
図5は、PC1とPC2の寄与率を表しています。
今回の結果では、おおよそ次のようになりました。
PC1: 0.95
PC2: 0.05
つまり、データ全体のばらつきの約95%は、PC1で説明できています。
これはかなり大きな割合です。
このような場合、
2次元データだけれど、実質的にはPC1方向の1次元的な構造が強い
と見ることができます。
もちろん、PC2方向の情報が完全に不要という意味ではありません。
ただ、今回のダミーデータでは、PC2方向のばらつきはかなり小さいです。
図6:PC1だけで2次元データを近似する
図6では、元の中心化データをPC1方向だけに射影しています。
薄い点が元の2次元データ、濃い点がPC1だけで表した近似です。
PC1だけを使うということは、PC2方向の情報を捨てるということです。
図では、各点をPC1の直線上に落としている、と考えると分かりやすいです。
この操作によって、2次元データを1次元で表せます。
これがPCAによる次元削減の基本的な考え方です。
今回のデータではPC1の寄与率が約95%なので、PC1だけでもかなり元データの構造を表せています。
PCAの処理を言葉でまとめる
PCAの流れを、もう一度言葉で整理します。
- データの平均を引く
- データのばらつき方を共分散行列で表す
- 共分散行列の固有値・固有ベクトルを求める
- 最大の固有値に対応する固有ベクトルをPC1にする
- 次に大きい固有値に対応する固有ベクトルをPC2にする
- データをPC1、PC2の座標で見直す
- 必要なら、寄与率の高い主成分だけを残す
数式で見ると難しく感じるかもしれません。
しかし、図で見ると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-learn の PCA を使うことが多いです。
今回の例を 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-learn の PCA は、内部で平均を引いてくれます。
ただし、主成分の符号は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が「座標軸を回転している」感覚をつかむと、より高次元のデータにも応用しやすくなります。





