PCA で C×A×N PASS 銘柄の構造を Python で可視化する手順

Chelsea-Labs #26 サムネイル

免責事項

本記事は投資助言を目的としたものではなく、技術・分析手法の紹介です。PCA 分析の閾値や主成分の解釈は教育目的であり、特定の銘柄・金融商品の売買を推奨するものではありません。投資判断はご自身の責任で行ってください。本記事中に J-Quants API・FMP から取得した個別銘柄の特徴量実値・株価実値は掲載していません(教育目的の概念数値例に限定、利用規約に基づく方針)。CAN-SLIM はオニール(William J. O’Neil)著「How to Make Money in Stocks」で提唱された手法を基にエンジニア視点で再構築しています。

前回 #25では、CAN-SLIM の N 要素(新製品・新サービス・新経営陣・新高値)を製品アーキテクト視点で半定量化して自動判定しました。本記事 #26 から、発展編は機械学習の領域に入ります。主成分分析(PCA、Principal Component Analysis)を使って、C×A×N PASS 銘柄群の銘柄相関と業種クラスタを Python で可視化する手順とサンプルコード入門です。

応用編 #19 の HHI(業種集中度)で「PASS 銘柄が IT に偏っている」を発見しました。本記事の PCA はそれを多変量空間で銘柄相関を数学的に裏付け、業種クラスタの構造を可視化してポートフォリオの「真の独立性」を測ります。「絶対値より変化率、突発より連続性」原則を、PCA の loading 安定性(経時変化)に拡張して自動判定の信頼性を担保するのが本記事の核心です。

多くの読者がぶつかる壁:

  • 機械学習未経験: PCA は数学的に高度に見えるが、本質は「銘柄相関の強い特徴量を集約して少数の軸に圧縮する」だけ。エンジニアの直感に訳せば理解しやすい
  • 特徴量選択: C verdict / A verdict / N verdict / 業種 / 時価総額 / 配当利回り / 過去5年リターン … 何を入れて何を入れないかで業種クラスタの解像度が変わる
  • 標準化: PCA は分散ベースの手法、特徴量のスケールが揃っていないと「時価総額」だけが支配する結果になる。StandardScaler を必ず先に入れる
  • 主成分の解釈: 第1主成分(PC1)が何を表すか(業種クラスタ?成長性?規模?)を loading matrix から読む作法

筆者は製造業の研究開発部門で、多変量プロセスデータの PCA 分析を長く運用してきました。センサー20個から取得する品質データを PCA で2次元可視化して、不良品クラスタを特定する作法は、CAN-SLIM PASS 銘柄群の銘柄相関分析・業種クラスタ可視化と呼応する作法です。本業の経験を投資データに転用する典型例として、本記事のエピソードで詳しく整理します。

本記事で扱う専門用語の予習

  • PCA(Principal Component Analysis、主成分分析): 高次元データを少数の合成軸(主成分)に圧縮する次元削減手法。各主成分は元の特徴量の線形結合で表される
  • 主成分(Principal Component): PC1, PC2, PC3, … と寄与率の降順で並ぶ。PC1 がデータの最大分散を捉え、PC2 が PC1 と直交する次に大きな分散を捉える
  • 寄与率(Explained Variance Ratio): 各主成分が元データの分散を何%説明するか。sklearnpca.explained_variance_ratio_ で取得
  • Loading Matrix(厳密定義): 各主成分が元の特徴量にどれくらい依存するかを示す係数行列。scikit-learn の pca.components_ は単位固有ベクトルであり、古典統計の loading は components_.T * sqrt(explained_variance_) で計算する点に注意(v2 H1 修正)
  • Scree Plot: 寄与率を主成分番号順にプロットしたグラフ。「肘(elbow)」で何個の主成分を採用するか判断
  • StandardScaler: sklearn.preprocessing の標準化(平均0、分散1 への変換)。PCA の前処理で必須
  • 業種クラスタ可視化: 圧縮した2次元空間で銘柄を散布図プロットし、業種色付けで構造を観察
  • biplot: 散布図に loading の矢印を重ね描きして、主成分と特徴量の関係を同時表示する図法

「実装したくない読者」向け代替案

「PCA を自分で実装するのは大変」という読者には、米国 IBD の “Industry Group Strength Rating”マネックス証券の銘柄スクリーナー業種フィルタ有料サービス(Bloomberg Industry Classification 等)で類似機能が利用可能です。本記事の自動判定は「自分の特徴量で日米両市場の銘柄相関と業種クラスタを可視化したい」用途で、結果だけ見たい読者には不要です。

本記事の前提と難易度

  • 必須前提: #22-#25 の C×A×N 実装、または同等の特徴量データが揃っている状態
  • numpy / pandas / matplotlib の基本、scikit-learn のチュートリアル経験があると進めやすい(必須ではない)
  • 動作環境: Python 3.11+ / pandas 2.x / numpy / matplotlib 3.9+ / scikit-learn 1.4+ / scipy 1.13+ / duckdb 1.0+
  • 戦略軸 vs 戦術軸 vs 事業構造軸 vs 機械学習軸: #21(戦略)→ #22-#24(戦術:C/A 定量)→ #25(事業構造:N 半定量)→ 本記事 #26(機械学習:PCA で銘柄相関分析と業種クラスタ可視化) → #27 エントロピー → #28 NLP の進化

サンプル数不足時の判定停止ルール(v2 I1 追加)

PCA はサンプル数 ≥ 特徴量数 × 3(経験則)の条件で信頼できる結果を返します。本記事の特徴量10個の場合:

  • 銘柄数 30以上: PCA 実行可能、寄与率の信頼性あり
  • 銘柄数 10-30: 結果は参考値、CAUTION 注記必須
  • 銘柄数 10未満: PCA 結果は意味なし、警告表示で停止推奨

C×A×N PASS 銘柄のみだと米国 5-15銘柄、日本 1-5銘柄程度に絞られるケースが頻出。CAUTION 銘柄も含めて 30以上を確保するのが標準運用です。

成長株投資 固有のリスク(PCA 結果の解釈ミスで顕在化)

  • 業種クラスタ偏向の見落とし: PCA で「PC1 が業種クラスタを表す」結果が出たとき、IT クラスタに集中しているのを見逃すと、応用編 #19 HHI 警告を無視したポートフォリオになる
  • 過剰な解釈: 主成分は数学的合成軸で、必ずしも経済学的に意味があるとは限らない。「PC2 = 成長性」と決めつけて投資判断すると誤る
  • サンプル数不足: 上記の判定停止ルール参照
  • 時系列の経時変化: ある時点の PCA 結果が、3ヶ月後も同じ構造とは限らない。「絶対値より変化率、突発より連続性」原則で loading の安定性をチェック推奨
  • 非線形構造の見落とし: PCA は線形手法、業種間の非線形な銘柄相関構造(例: 半導体↔自動車の循環関係)は捉えきれない

応用編 #16 安定性 + #19 業種分散 + #24 C×A + #25 N + 本記事 PCA + #24 ポートフォリオ運用ルールの多層防御が実装上の対策です。

本記事では 4個のサンプルコードで、特徴量データセット構築 → sklearn PCA 実装 → 主成分の解釈 → 散布図 + 業種クラスタ色付け可視化まで自動判定基盤を完成させます。発展編後半の 「事業構造分析ブロック」第2回。

結論:PCA は CAN-SLIM C×A×N PASS 銘柄群の銘柄相関を多変量空間で可視化し、業種クラスタ構造を機械学習で自動判定する手法。scikit-learn の PCAStandardScaler 標準化と組合せ、PC1/PC2 散布図 + 業種クラスタ色付けで「真の独立性」を観察、厳密な loading(components_.T × sqrt(explained_variance_))で各主成分の意味を解釈。事業構造分析ブロック第2回として #27 エントロピー / #28 NLP に接続する基礎工事。

PCA 寄与率の参考値(運用前の期待値設定)— v2 D3 callout 分離

2026年5月時点の参考値(市場・特徴量セットで変動):

  • 米国 S&P500 標準10特徴量: PC1 寄与率 30-40%(業種クラスタ or 規模を反映)、PC2 15-20%、PC3 8-12%、累積80%に 6-8主成分
  • 東証プライム: PC1 25-35%、PC2 15-25%、累積80%に 5-7主成分
  • C×A×N PASS のみ: サンプル数少のため信頼区間広い、全市場 PCA との併用推奨
目次

PCA の直感的理解:エンジニア視点での「銘柄相関の強い軸を圧縮する」発想

PCA は「N次元のデータを2-3次元に圧縮する」次元削減手法です。数学的には共分散行列の固有値分解ですが、エンジニアの直感に訳すとシンプル:

エンジニア的に言い換えると(多変量プロセスデータの主成分抽出)

製造業の品質管理で言う 「センサー20個のデータから、品質を支配する2-3個の隠れた要因を見つける」作業と等価の構造です:

  • センサー20個 = 元の特徴量(C verdict, A verdict, N verdict, 業種, 時価総額, 配当利回り, 過去リターン, ボラ等)
  • 隠れた2-3要因 = 主成分(PC1, PC2, PC3…)
  • 第1主成分 PC1 = データの最大分散を捉える軸(往々にして「業種クラスタ」や「時価総額規模」を表す)
  • 第2主成分 PC2 = PC1 と直交する次の軸(往々にして「成長性 vs 配当性向」のような対立軸)

絶対値より変化率、突発より連続性」原則を PCA に適用すると、loading(各主成分への特徴量の寄与)が時系列でどう変化するか、突発的な loading 変動と継続的なシフトを区別する観察軸が出てきます。

PCA と SVD — 同じ結果を導く2つの数学的入口

本記事では scikit-learn の PCA を使いますが、その内部では SVD(特異値分解、Singular Value Decomposition)が走っています。「PCA は共分散行列の固有値分解」と教科書には書かれることが多いものの、実装上は 「中心化したデータ行列の SVD」で計算するのが標準です。なぜ2つの入口があり、どちらを選ぶべきかを整理しておきます。E資格でも頻出するテーマで、線形代数の特異値分解(SVD)の解説記事と双方向で読むと理解が深まります。

エンジニア的に言い換えると(同じ景色への2つの入口)

「PCA は固有値分解、SVD は特異値分解」と別々の章で習いますが、中心化したデータ行列に対しては結果が一致します:

  • 入口A:共分散行列の固有値分解 — N×d データを d×d の共分散行列に集約してから分解する
  • 入口B:データ行列の SVD — N×d データを直接 SVD で分解する

同じ景色を、屋根から見るか壁から見るかの違いです。数値計算上は SVD(入口B)が有利で、scikit-learn の PCA もデフォルトで SVD を内部使用しています。

数学的等価性:なぜ同じ結果になるのか

銘柄数 N × 特徴量数 d のデータ行列を X(列ごとに中心化済み)とします。共分散行列は

$$
C = \frac{1}{N-1}\, X^{\top} X
$$

です。PCA はこの C の固有値分解から主成分方向を取り出します。一方 SVD は X 自体を

$$
X = U \Sigma V^{\top}
$$

と分解します。両者の関係は XX = V Σ2 V で繋がっており、V が主成分方向(固有ベクトル)、σi2/(N-1) が固有値(分散)になります。つまり「中心化データの SVD」を一度走らせれば、PCA の主成分方向もスコアも同時に取れるわけです。

数値安定性の罠:共分散行列を経由しない理由

  • 共分散行列の構築で精度が落ちる: XX を計算した時点で、X の小さい特異値が二乗されてさらに小さくなり、丸め誤差で消える
  • SVD は X を直接扱う: 中間で共分散を作らないため、小さい特異値(=微小な構造シグナル)まで安定して取れる
  • 特徴量が増えると差が顕在化: 銘柄 N=3,500・特徴量 d=20 では実害は小さいが、業種 one-hot 等で d=50+ になると共分散経由は精度劣化が見えてくる

本業の多変量プロセスデータ分析でも、センサー数が増えてからは「共分散行列の固有値分解」ではなく「データ行列の SVD」をデフォルトにする運用に切り替えました。理由は同じ — 小さい主成分(=微小な構造変化のシグナル)が丸め誤差で潰れていたためです。

scikit-learn の PCA(svd_solver=...) 内部実装の選び方

scikit-learn の PCA は SVD ソルバを4種類持っており、データサイズに応じて自動で切り替わります(svd_solver='auto' 既定)。本記事スニペット2では既定の 'auto' のまま使いますが、明示する場合のコード例は次の通りです。

# sklearn PCA の svd_solver 切替例(参考、本記事では 'auto' を採用)
from sklearn.decomposition import PCA

# 既定: 'auto' → サンプル数・特徴量数・n_components から最適選択
pca_auto = PCA(n_components=5)

# 厳密 SVD(小〜中規模、scipy.linalg.svd ベース)
pca_full = PCA(n_components=5, svd_solver="full")

# Randomized SVD(大規模、Halko et al. 2011 の乱択アルゴリズム)
pca_rand = PCA(n_components=5, svd_solver="randomized", random_state=42)

# Arpack(疎行列 / 上位 k 主成分のみ必要なときの反復法)
pca_arpack = PCA(n_components=5, svd_solver="arpack", random_state=42)
  • 'full': scipy.linalg.svd による厳密 SVD。CAN-SLIM PASS + CAUTION(数十〜数百規模)にはこれで十分
  • 'randomized': 乱択 SVD。N×d が大きい(数千銘柄 × 数十特徴量以上)ときに高速、上位主成分の近似精度は十分
  • 'arpack': 反復法ベース。上位 k 主成分だけ欲しい疎行列向け
  • 'auto': n_samples > 500 and n_features > 500 and n_components < 0.8 * min(shape) なら randomized、それ以外は full を選択(sklearn 1.4 時点の規則)

E資格頻出ポイント — PCA と SVD の橋渡し

  • 中心化したデータ行列 X に対し、X = U Σ V の V が PCA の主成分方向と一致
  • 特異値 σi と固有値 λi の関係: λi = σi2 / (N-1)(中心化後の不偏分散基準)
  • PCA スコア(主成分得点) = U Σ — 上位 k 列を取れば k 次元削減になる
  • scikit-learn の pca.components_ は V の行(単位固有ベクトル)に対応。本記事スニペット2の「厳密 loading」は components_.T × sqrt(explained_variance_) で算出する点に注意
  • 正方行列を要求しないのが SVD の強み — 銘柄数 N と特徴量数 d が異なる現実のデータに直接適用できる
  • 符号の自由度: SVD は U と V の符号がペアで反転しても等価。手計算と sklearn 結果が符号違いでも、復元誤差が機械精度オーダーなら同じ分解

線形代数の理論的背景は特異値分解(SVD)の解説記事固有値分解(2×2行列の例題)で確認できます。E資格対策と投資実装の双方で同じ数学が動いている、典型的な「学習の横展開」事例です。

以降のスニペット1〜4 では PCA(svd_solver='auto') 既定値のまま走らせます。CAN-SLIM PASS + CAUTION で 30〜数百銘柄規模を想定するため、内部的には 'full' ソルバが選ばれ、厳密 SVD で計算されます。「sklearn の PCA を呼んでも実体は SVD」を頭に入れておくと、scores と loading の符号の自由度や、本記事の「厳密 loading は components_.T × sqrt(explained_variance_)」という補正式の意味が掴めるようになります。

スニペット1:C×A×N PASS 銘柄の特徴量データセット構築サンプルコード入門

応用編 #14 で構築した DuckDB から、PCA 入力用の特徴量行列を組み立てます。ticker_normalized を行、特徴量を列にする標準的な ML 入力形式で、銘柄相関分析の前処理にあたる工程です。

# build_pca_features.py — C×A×N PASS 銘柄の特徴量データセット構築
# 動作環境: Python 3.11+ / pandas 2.x / duckdb 1.0+
import duckdb
import pandas as pd
from pathlib import Path
from datetime import date

DB_PATH = Path("data/stocks.duckdb")

def build_feature_matrix(as_of_date: date,
                            include_caution: bool = True,
                            min_market_cap_jpy: float = 1e10) -> pd.DataFrame:
    """C×A×N の verdict + 補助指標を1銘柄1行の特徴量行列に変換

    Args:
        as_of_date: スクリーニング基準日。screening_results.run_date と一致する日。
        include_caution: True (既定) なら C verdict が PASS/CAUTION の両方を含める。
            False なら C verdict が PASS の銘柄のみに絞る(c_score==2 でフィルタ、
            サンプル数が減るので PCA の信頼性が落ちる点に注意)。
        min_market_cap_jpy: 時価総額下限(円)。低流動性銘柄の除外用。

    v2 I2: min_market_cap_jpy 引数追加(低流動性銘柄を除外)
    v2.2: include_caution の挙動説明を docstring に明示
    """
    with duckdb.connect(str(DB_PATH)) as conn:
        df = conn.execute(f"""
            SELECT
                s.ticker_normalized,
                s.industry_profile,
                LOG(s.market_cap_jpy) AS market_cap_log,
                s.dividend_yield_pct,
                c.yoy_growth_pct_capped AS yoy_growth_pct,
                a.cagr_5y_pct,
                n.n_density,
                n.distance_from_52w_high_pct,
                n.vol_20d_pct,
                CASE r.can_slim_c WHEN 'PASS' THEN 2 WHEN 'CAUTION' THEN 1 ELSE 0 END AS c_score,
                CASE r.can_slim_a WHEN 'PASS' THEN 2 WHEN 'CAUTION' THEN 1 ELSE 0 END AS a_score,
                CASE r.can_slim_n WHEN 'PASS' THEN 2 WHEN 'CAUTION' THEN 1 ELSE 0 END AS n_score
            FROM v_screening_input s
            LEFT JOIN screening_results r
              ON s.ticker_normalized = r.ticker_normalized
              AND r.run_date = DATE '{as_of_date.isoformat()}'
            LEFT JOIN (
                SELECT ticker_normalized, yoy_growth_pct_capped FROM (
                    SELECT *, ROW_NUMBER() OVER (PARTITION BY ticker_normalized
                                                   ORDER BY period_end DESC) AS rn
                    FROM quarterly_eps_with_yoy
                ) WHERE rn = 1
            ) c ON s.ticker_normalized = c.ticker_normalized
            LEFT JOIN (
                SELECT ticker_normalized, cagr_5y_pct FROM (
                    SELECT *, ROW_NUMBER() OVER (PARTITION BY ticker_normalized
                                                   ORDER BY fiscal_year DESC) AS rn
                    FROM annual_eps_with_cagr
                ) WHERE rn = 1
            ) a ON s.ticker_normalized = a.ticker_normalized
            LEFT JOIN n_metrics n ON s.ticker_normalized = n.ticker_normalized
            WHERE r.can_slim_composite IN ('PASS', 'CAUTION')
              AND s.market_cap_jpy >= {min_market_cap_jpy}
        """).fetchdf()

    df = df.set_index("ticker_normalized")
    if not include_caution:
        df = df[df["c_score"] == 2]
    df = df.dropna()
    print(f"[build_feature_matrix] サンプル数: {len(df)} 銘柄(最小時価総額 {min_market_cap_jpy:.0e} 円以上)")
    return df

特徴量設計の罠:欠損値とスケールと流動性

  • 欠損値: PCA は欠損を扱えないため dropna。多すぎると有効銘柄が極端に減る → 中央値補完や IterativeImputer を検討
  • 外れ値: 時価総額や配当利回りに極端な値があると PCA を支配する → ロバスト標準化(中央値+MAD)の選択肢あり
  • 低流動性銘柄: v2 I2 修正で min_market_cap_jpy ガード追加。デフォルト100億円以上で日本のマイクロキャップを除外、select_diversified_picks が外れ値を引き寄せるリスクを軽減
  • カテゴリ変数: industry_profile はそのままでは PCA に入れられない。one-hot encoding で数値化してから入れる選択肢もあるが、本記事では業種クラスタの色付け用に分離
  • サンプル数: 特徴量数 × 3 以上を確保(前述の判定停止ルール)。include_caution=True でサンプル数確保

スニペット2:scikit-learn PCA で次元削減する自動判定パイプライン(厳密 loading 算出)

StandardScaler で標準化してから PCA を適用します。標準化を必ず先に入れることで、時価総額や売上の絶対値が PCA を支配する罠を回避します。v2 では loading を厳密定義(components_.T × sqrt(explained_variance_))で算出します。

# run_pca.py — scikit-learn で PCA 自動判定パイプラインを実行(v2: 厳密 loading)
# 動作環境: Python 3.11+ / pandas 2.x / numpy / scikit-learn 1.4+
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

_PCA_FEATURES = [
    "c_score", "a_score", "n_score",
    "yoy_growth_pct", "cagr_5y_pct", "n_density",
    "distance_from_52w_high_pct", "dividend_yield_pct",
    "market_cap_log", "vol_20d_pct",
]

def run_pca(feature_df: pd.DataFrame,
              n_components: int = 5,
              random_state: int = 42,
              min_samples_ratio: float = 3.0) -> dict:
    """C×A×N PASS 銘柄の特徴量を PCA で次元削減する自動判定パイプライン

    v2 I1: サンプル数 < 特徴量数 × min_samples_ratio で警告
    v2 H1: 厳密 loading(components_.T * sqrt(explained_variance_))を別途算出
    """
    n_features = len(_PCA_FEATURES)
    if len(feature_df) < n_features * min_samples_ratio:
        print(f"[警告] サンプル数 {len(feature_df)} が特徴量数 {n_features} × {min_samples_ratio} 未満。"
              f"PCA 結果の信頼性が低下します(推奨: {int(n_features * min_samples_ratio)} 以上)")

    X = feature_df[_PCA_FEATURES].values

    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    pca = PCA(n_components=n_components, random_state=random_state)
    X_pca = pca.fit_transform(X_scaled)

    scores_df = pd.DataFrame(
        X_pca,
        index=feature_df.index,
        columns=[f"PC{i+1}" for i in range(n_components)],
    )

    # v2 H1: pca.components_ は単位固有ベクトル
    # 古典統計の loading は components_.T * sqrt(explained_variance_) で算出
    components_unit = pd.DataFrame(
        pca.components_.T,
        index=_PCA_FEATURES,
        columns=[f"PC{i+1}" for i in range(n_components)],
    )
    loading_strict = components_unit * np.sqrt(pca.explained_variance_)
    loading_strict.columns = [f"PC{i+1}_loading" for i in range(n_components)]

    return {
        "pca": pca,
        "scaler": scaler,
        "scores_df": scores_df,
        "components_unit": components_unit,    # 単位固有ベクトル(biplot で使用)
        "loading_df": loading_strict,           # 厳密 loading(解釈で使用)
        "explained_variance_ratio": pca.explained_variance_ratio_,
        "cumulative_variance": np.cumsum(pca.explained_variance_ratio_),
    }

PCA 実行の罠:標準化と loading の厳密定義

  • StandardScaler 必須: 時価総額(兆円スケール)と配当利回り(%スケール)は単位が違いすぎ、標準化なしでは時価総額が PCA を支配
  • loading の厳密定義(v2 H1): scikit-learn の pca.components_単位固有ベクトルであり、古典統計の loading とは異なる。loading の絶対値で「主成分への寄与度」を比較するには components_.T × sqrt(explained_variance_) で正規化する必要あり
  • n_components の選び方: 累積寄与率 80% を目安に主成分数を決める Scree plot を併用(次のスニペット3で実装)
  • random_state: PCA は決定的アルゴリズムだが、内部処理の安定化のため固定推奨
  • fit_transform の罠: 訓練時 fit_transform、推論時 transform のみ。本記事は単発分析だが、定期再学習時は scalerpca オブジェクトを保存推奨

スニペット3:主成分の解釈と Scree Plot 自動判定可視化

厳密 loading から各主成分の意味を読み解き、Scree Plot で何個の主成分を採用するか判断します。「絶対値より変化率、突発より連続性」原則を、loading の経時変化監視に拡張する設計で、銘柄相関構造の安定性を観察できます。

# interpret_pca.py — 主成分の解釈支援 + Scree Plot 可視化
# 動作環境: Python 3.11+ / matplotlib 3.9+ / pandas 2.x / numpy
import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from pathlib import Path

matplotlib.rcParams["font.family"] = ["Hiragino Sans", "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

def plot_scree(explained_variance_ratio: np.ndarray,
                 cumulative_variance: np.ndarray,
                 out_path: Path = Path("data/plots/pca_scree.png")) -> Path:
    """Scree Plot で寄与率と累積寄与率を可視化(肘 = 採用主成分数の目安)"""
    out_path.parent.mkdir(parents=True, exist_ok=True)
    fig, ax1 = plt.subplots(figsize=(8, 5))

    n = len(explained_variance_ratio)
    x = range(1, n + 1)

    bar = ax1.bar(x, explained_variance_ratio * 100, color="#805ad5", alpha=0.7, label="個別寄与率")
    ax1.set_xlabel("主成分番号")
    ax1.set_ylabel("個別寄与率 (%)", color="#805ad5")
    ax1.tick_params(axis="y", labelcolor="#805ad5")

    ax2 = ax1.twinx()
    line, = ax2.plot(x, cumulative_variance * 100, "o-", color="#3182ce", label="累積寄与率")
    threshold = ax2.axhline(80, color="green", linestyle="--", alpha=0.5, label="80% ライン")
    ax2.set_ylabel("累積寄与率 (%)", color="#3182ce")
    ax2.tick_params(axis="y", labelcolor="#3182ce")
    ax2.set_ylim(0, 105)

    # twinx の凡例を1つに統合
    fig.legend(handles=[bar, line, threshold], loc="upper right",
               bbox_to_anchor=(0.95, 0.95), framealpha=0.9)
    fig.suptitle("PCA Scree Plot — 寄与率と累積寄与率")
    fig.tight_layout()
    fig.savefig(out_path, dpi=120)
    plt.close(fig)
    return out_path

def interpret_loadings(loading_df: pd.DataFrame, top_n: int = 3) -> dict:
    """各主成分の上位寄与特徴量を抽出して意味づけを支援(厳密 loading 使用)"""
    interpretation = {}
    for pc in loading_df.columns:
        loadings = loading_df[pc]
        top_features = loadings.abs().nlargest(top_n).index
        interpretation[pc] = [
            (feat, float(loadings[feat])) for feat in top_features
        ]
    return interpretation

def compare_loadings_stability(loading_df_now: pd.DataFrame,
                                  loading_df_past: pd.DataFrame,
                                  top_n: int = 3,
                                  swap_threshold: int = 2) -> dict:
    """loading 安定性の定量判定(v2 I3 追加)

    PC1 上位 top_n=3 特徴量のうち swap_threshold=2 個以上が入れ替わっていれば
    「構造変化あり」と判定。「絶対値より変化率、突発より連続性」原則の経時監視
    """
    result = {}
    for pc in loading_df_now.columns:
        if pc not in loading_df_past.columns:
            continue
        now_top = set(loading_df_now[pc].abs().nlargest(top_n).index)
        past_top = set(loading_df_past[pc].abs().nlargest(top_n).index)
        swapped = len(now_top - past_top)
        result[pc] = {
            "now_top": list(now_top),
            "past_top": list(past_top),
            "swapped_count": swapped,
            "structural_change": swapped >= swap_threshold,
        }
    return result

# 使用例(概念例、実値ではない)— v2 E2 注記
# PC1: market_cap_log(+0.42), c_score(+0.38), a_score(+0.35) → 「規模 + 成長性」軸
# PC2: dividend_yield_pct(+0.51), market_cap_log(-0.38), c_score(-0.32) → 「配当 vs 成長」軸
# PC3: n_density(+0.55), distance_from_52w_high_pct(+0.41) → 「N 要素 + 高値圏」軸

エンジニア的に言い換えると(センサー20個のうち重要な3個を見つける作法)

本コードは、製造業のプロセスデータ分析で言う 「20個のセンサー値のうち品質変動を支配する3個を特定する」作法と同じ流儀です。Loading の絶対値上位の特徴量が「主成分の意味」を構成し、寄与率と累積寄与率の組合せで「何個の軸でデータを表現すべきか」を決める。「絶対値より変化率、突発より連続性」原則は、loading の経時的な安定性(複数時点で同じ特徴量が PC1 上位に来るか、上位3で2個入替なら構造変化)の監視として運用に落とし込まれます。

スニペット4:PC1 vs PC2 散布図と業種クラスタ色付けで自動判定可視化

圧縮した2次元空間で銘柄を散布図プロットし、業種クラスタごとに色分けすることで構造的な銘柄相関を可視化します。「PC1 が業種クラスタを表す」が見えれば、応用編 #19 HHI 警告との整合性も観察できます。v2 では matplotlib 3.7 deprecated 対応 + scipy.cdist でベクトル化しています。

# plot_pca_scatter.py — PC1 vs PC2 散布図 + 業種クラスタ色付け(v2: matplotlib 3.9 + scipy ベクトル化)
# 動作環境: Python 3.11+ / matplotlib 3.9+ / pandas 2.x / numpy / scipy 1.13+
import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from pathlib import Path
from scipy.spatial.distance import cdist

matplotlib.rcParams["font.family"] = ["Hiragino Sans", "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "DejaVu Sans"]
matplotlib.rcParams["axes.unicode_minus"] = False

def plot_pca_scatter(scores_df: pd.DataFrame,
                       industry_series: pd.Series,
                       components_unit: pd.DataFrame | None = None,
                       annotate_top_n: int = 5,
                       out_path: Path = Path("data/plots/pca_scatter.png")) -> Path:
    """PC1 vs PC2 散布図 + 業種クラスタ色付け + biplot(v2 H2: matplotlib 3.9 対応)

    components_unit を渡すと biplot として loading 矢印を重ね描き
    """
    out_path.parent.mkdir(parents=True, exist_ok=True)
    fig, ax = plt.subplots(figsize=(10, 8))

    industries = industry_series.unique()
    n_ind = len(industries)
    # v2 H2: matplotlib 3.7 deprecated 対応、25業種想定で tab20 推奨
    cmap_name = "tab20" if n_ind > 10 else "tab10"
    cmap = matplotlib.colormaps[cmap_name].resampled(n_ind)

    for i, ind in enumerate(industries):
        mask = industry_series == ind
        ax.scatter(scores_df.loc[mask, "PC1"], scores_df.loc[mask, "PC2"],
                    color=cmap(i), label=ind, alpha=0.6, s=60, edgecolors="white")

    if annotate_top_n > 0:
        top_tickers = scores_df["PC1"].abs().nlargest(annotate_top_n).index
        for ticker in top_tickers:
            x, y = scores_df.loc[ticker, "PC1"], scores_df.loc[ticker, "PC2"]
            ax.annotate(ticker, (x, y), fontsize=9,
                         xytext=(5, 5), textcoords="offset points")

    # biplot: 単位固有ベクトルをスコアの分散に合わせてスケーリング
    if components_unit is not None:
        score_scale = np.sqrt((scores_df[["PC1", "PC2"]] ** 2).sum().sum() / len(scores_df))
        for feat in components_unit.index:
            x = components_unit.loc[feat, "PC1"] * score_scale * 2
            y = components_unit.loc[feat, "PC2"] * score_scale * 2
            ax.arrow(0, 0, x, y, color="red", alpha=0.5,
                      head_width=0.05 * score_scale, length_includes_head=True)
            ax.text(x * 1.1, y * 1.1, feat, color="red", fontsize=8, alpha=0.8)

    ax.set_xlabel("PC1")
    ax.set_ylabel("PC2")
    ax.set_title("CAN-SLIM C×A×N PASS 銘柄 PCA 散布図(業種クラスタ別色付け)")
    ax.legend(loc="best", fontsize=9, framealpha=0.8)
    ax.grid(alpha=0.3)
    ax.axhline(0, color="gray", linestyle="-", alpha=0.3)
    ax.axvline(0, color="gray", linestyle="-", alpha=0.3)

    fig.tight_layout()
    fig.savefig(out_path, dpi=120)
    plt.close(fig)
    return out_path

def select_diversified_picks(scores_df: pd.DataFrame,
                                market_cap_log_series: pd.Series | None = None,
                                n_picks: int = 5,
                                min_market_cap_log: float | None = None) -> list[str]:
    """PC1 vs PC2 の象限から離れた銘柄を選んで分散ポートフォリオ候補を抽出

    v2 H3 修正: scipy.spatial.distance.cdist でベクトル化(apply 二重ネスト排除)
    v2 I2 修正: 流動性(時価総額log)下限フィルタを引数で受ける
    """
    pc12 = scores_df[["PC1", "PC2"]].copy()

    # 流動性フィルタ
    if min_market_cap_log is not None and market_cap_log_series is not None:
        liquid_mask = market_cap_log_series >= min_market_cap_log
        pc12 = pc12.loc[liquid_mask]

    if pc12.empty:
        return []

    selected: list[str] = []
    remaining_idx = list(pc12.index)

    # 最初の1銘柄: 原点から最も遠い
    distances_from_origin = np.linalg.norm(pc12.values, axis=1)
    first_pos = int(np.argmax(distances_from_origin))
    selected.append(remaining_idx[first_pos])
    remaining_idx.pop(first_pos)

    # 2銘柄目以降: 既選択銘柄群から最も遠い銘柄を貪欲法で追加(cdist ベクトル化)
    while len(selected) < n_picks and remaining_idx:
        remaining_pts = pc12.loc[remaining_idx].values
        selected_pts = pc12.loc[selected].values
        dist_matrix = cdist(remaining_pts, selected_pts, metric="euclidean")
        min_dist_per_remaining = dist_matrix.min(axis=1)
        next_pos = int(np.argmax(min_dist_per_remaining))
        selected.append(remaining_idx[next_pos])
        remaining_idx.pop(next_pos)

    return selected

エンジニア的に言い換えると(業種クラスタ可視化と分散選択)

本コードは、製造業の品質管理で言う 「不良品クラスタの2次元可視化と、改善対象の分散選択」作法と重ね描きになります。plot_pca_scatter で業種クラスタの構造を観察し、select_diversified_picks で「PCA 空間で互いに離れた銘柄」を選ぶことで、応用編 #19 HHI の業種集中警告を補完する分散戦略が実装できます。「絶対値より変化率、突発より連続性」原則は、選択された銘柄群の PCA 結果を時系列で観察し、銘柄相関構造の安定性を確認する運用で担保されます。

設計判断の記録:PCA 実装の3判断 + 応用編〜発展編 #26 の俯瞰表(39件)

判断1:PCA 入力の特徴量に何を含めるか

  • 採用理由: 定量指標 10個(C/A/N score + YoY/CAGR/N密度/高値距離/配当利回り/時価総額log/ボラ)。代替案との比較:
    • verdict 数値化のみ(3個): シンプルだが情報量が少なく PCA の意味が出ない
    • 20+ 特徴量フル投入: 多重共線性で PC1 が不安定、解釈困難
    • 採用:10個に絞る: 適度な情報量、解釈可能性、サンプル数 vs 特徴量数の比率が健全
  • 採用したことで失うもの: ファンダメンタル細部(PER、PBR、ROE 等)が直接入らない
  • トリガー条件: バリュー視点も加えたい → PER/PBR を追加(多重共線性に注意)
  • 残るメリット: 解釈可能性、サンプル数効率、定期再学習の安定性
  • 製造業の評価作法: 「20センサーから3-5個の代表センサーを選ぶ」プロセス管理の作法と呼応します

判断2:標準化を StandardScaler か RobustScaler か

  • 採用理由: StandardScaler(平均0、分散1)をデフォルト。CAN-SLIM 系特徴量は外れ値が極端に多くないため
  • 採用したことで失うもの: 外れ値耐性(極端な時価総額銘柄等で PCA が引っ張られる)
  • トリガー条件: 時価総額分布が極端に歪 → RobustScaler(中央値+IQR)に切替
  • 残るメリット: 標準的な ML 作法、解釈の容易さ
  • 品質管理での運用: 「正規分布近似のセンサー値は StandardScaler、長尾分布は RobustScaler」の使い分けが標準

判断3:採用主成分数を Scree Plot 肘か累積寄与率 80% か

  • 採用理由: 累積寄与率 80% を主、Scree Plot を補助。代替案との比較:
    • 固定 n_components=2: 可視化には便利だが情報損失大
    • Kaiser 基準(固有値>1): 標準化前提で機械的だが分散が大きい銘柄群で過剰選択
    • 採用:累積80% + Scree: 情報量と簡潔性のバランス
  • 採用したことで失うもの: 統計学的厳密さ(80% は経験的閾値)
  • トリガー条件: 厳密な統計分析が必要 → 並列分析(parallel analysis)等の高度手法
  • 残るメリット: 個人運用での実用性、解釈可能性
  • 生産技術の作法: 「累積寄与率80%で主要要因を捉える」が品質管理の業界標準

応用編 #13〜#21 + 発展編 #22〜#26 の主要設計判断 俯瞰表(39件)

記事主要判断採用
#13データソース選定J-Quants + EDINET
#14DB / アーキテクチャDuckDB + ELT + マスタ駆動
#14業種別補正設計industry_indicator_map + direction
#14正規化キーticker_normalized 5桁0埋め
#15並列化技術multiprocessing initializer
#15通知メディアLINE Messaging API
#15スケジューラcron → GitHub Actions
#16連続増配判定連続非減少
#16EPS 安定性指標変動係数 CV + 線形回帰
#16新規上場銘柄の扱い5期未満は CAUTION 強制
#17異常値検知IQR + Z + SPC + WER の3-4層
#17市場全体ショック対応絶対値 + TOPIX 相対値併用
#18業種粒度10→25業種
#18業種別主指標数1業種1主指標
#18マスタ更新サイクル四半期レビュー
#18テーブル PK 設計複合PK / 単列PK 使い分け
#19集中度指標HHI 2000/3000
#19相関分析期間過去24ヶ月 + 直近6ヶ月
#19FMEA RPN 重み等倍積(S × O × D)
#19ポートフォリオテーブル個別銘柄評価とは別テーブル
#20master_runner 方式NotImplementedError 委譲
#20業種カバレッジ25業種で時価総額9割
#21成長株フレームワークCAN-SLIM
#21パイプライン再利用応用編流用、判定層拡張
#21市場カバレッジ日米両市場(FMP + SEC EDGAR)
#22YoY 計算ロジック絶対値分母
#22連続性判定min_consecutive=2
#23CAGR 期間優先順位5年優先 + 3年フォールバック
#23赤字期 CAGR 扱いNone で CAUTION
#23可視化粒度PASS 銘柄のみ詳細
#24複合戦略デフォルトAND(OR/重み付け 切替可)
#249セル表示粒度9セル全表示
#24バックテスト粒度概念実装のみ
#25テキスト分析手法簡易キーワード(LLM 切替可)
#25N 4観点の統合テキスト × 高値の2軸 AND
#25株価/ファンダのバランス両軸併用
#26PCA 入力特徴量定量10個(多重共線性回避)
#26標準化方法StandardScaler(外れ値時 RobustScaler)
#26採用主成分数累積寄与率80% + Scree Plot

本業の話:センサー20個のプロセスデータを PCA で2次元可視化した経験

筆者が研究開発部門で多変量プロセスデータの分析を担当していたとき、ベテランから次の指導を受けました:

  • センサー20個を全部表示するな。PCA で2-3個の主成分に圧縮して可視化しろ。人間の目は同時に20次元を見られない」
  • 標準化を必ず先に入れろ。温度センサー(℃)と圧力センサー(Pa)を生のままで PCA すると、圧力が支配する PC1 になり意味のない分析になる」
  • Loading の経時変化を追え。PC1 の上位寄与センサーが3ヶ月で入れ替わっていたら、プロセスが構造的に変化したシグナル」

具体的な業務インパクトと3-4年スパンのマイルストーン(v2 D2: 2年目と2.5年目の役割を明確化):

  • 1年目(指摘): 初稿のプロセスデータ分析では「相関の高いセンサーペアを2次元プロットで個別確認」していた。20個のセンサーで C(20,2)=190 通りの可視化が必要で実用性なし。ベテラン指摘で PCA 導入
  • 2年目(PCA 導入:可視化基盤の構築): StandardScaler + sklearn PCA で20次元を3主成分に圧縮、Scree Plot + 散布図 + biplot の3点セットを基盤として実装。不良品クラスタ特定の作業時間が約8割短縮(測定方法: 不良品ロット発生時の原因センサー特定にかかる時間を、PCA 導入前の個別ペア確認方式と比較。サンプル 不良品ロット 約30件のシミュレーション再評価)。役割: 静的なスナップショット分析の基盤
  • 2.5年目(loading 経時監視:動的な構造変化検知): 「絶対値より変化率」原則を実装するため、PC1 の loading 上位寄与センサーを四半期ごとにダッシュボード化、上位3センサーのうち2個入替で「構造変化アラート」発火する閾値を設定。プロセス構造変化の早期検知が前年比約3ヶ月早まる(測定方法: プロセス変化を察知してから対応までの時間、サンプル ダッシュボード導入後12ヶ月の構造変化案件 約6件)。役割: 2年目の静的基盤を時系列に拡張、構造変化の早期検知レイヤー
  • 3年目(運用): PCA + 散布図 + loading 経時監視の3点セットが品質管理会議の標準資料に。「絶対値より変化率、突発より連続性」原則の多変量版が部内浸透
  • 4年目(発展): 非線形構造の捉え漏れを補完するため UMAP / t-SNE を補助的に追加。本記事の PCA は基礎、より高度な手法へのステップとして機能

本記事の PCA 自動判定フレームは、この本業の多変量プロセスデータ分析経験と等価の構造です。製造業の「センサー多変量データ → 主成分圧縮 → 不良品クラスタ可視化」が、投資の「銘柄特徴量多変量データ → 主成分圧縮 → 業種クラスタ可視化」に呼応しているのが、本記事の核心的な発見です。

逆方向の転移:投資の PCA loading 安定性監視が本業のプロセス管理を強化

本記事の loading 経時変化監視パターン(複数時点で同じ特徴量が PC1 上位に来るかチェック)を、本業のプロセス管理に逆輸入する余地があります。本業ではこれまで「異常検知(Hotelling T2 等)」中心でしたが、loading の継続的シフトを「プロセス進化のシグナル」として早期検知する運用に発展させられる見込みです。応用編で確立した双方向の知識循環が機械学習領域でも続いています。

まとめ:PCA で銘柄相関と業種クラスタを可視化、ポートフォリオ分散選択に活用

  • PCA は CAN-SLIM C×A×N PASS 銘柄群の銘柄相関を機械学習で可視化する手法。scikit-learn の PCA + StandardScaler で次元削減、PC1/PC2 散布図 + 業種クラスタ色付けで「真の独立性」を観察、厳密 loading(components_.T × sqrt(explained_variance_))で各主成分の意味を解釈。「絶対値より変化率、突発より連続性」原則を loading 安定性の経時変化監視(PC1 上位3で2個入替なら構造変化)に拡張
  • 応用編 #19 HHI 警告との補完: HHI が業種シェアの集中度(1次元)、PCA が多次元構造の業種クラスタ可視化。両者併用でポートフォリオの真の独立性を確認
  • ポートフォリオ分散選択への活用: select_diversified_picks(v2 で scipy.cdist ベクトル化 + 流動性フィルタ)で PCA 空間から離れた銘柄を機械的に選ぶことで、業種・規模が自然に分散される。#24 のポートフォリオ運用ルール(5-8銘柄、損切 -7〜-8%、利確 +20-25%)と組合わせて運用
  • 事業構造分析ブロック第2回: 本記事 PCA → #27 情報エントロピー(HHI の情報理論的再解釈) → #28 NLP(N 要素の高度化)に接続

今日からできる3つのアクション

  1. scikit-learn 1.4+ をインストール(pip install scikit-learn scipy)し、本記事スニペット2の run_pca 関数を iris dataset 等の既存データセットでまず動作確認。iris.data(4特徴量×150サンプル)に PCA を適用 → PC1×PC2 散布図 + 種別色付けで「明確な3クラスタが見える」を確認、PCA の出力構造(scores、厳密 loading、explained_variance_ratio)に慣れる
  2. 本記事スニペット1〜3を実装し、自分の screening_results テーブルから C×A×N PASS + CAUTION 銘柄の特徴量行列を構築。サンプル数30以上を確保した上で、Scree Plot で適切な主成分数を決定(米国 6-8、日本 5-7 が想定)
  3. スニペット4 で散布図 + 業種クラスタ色付け可視化を作成し、抽出後の人手最終判定手順として:
    • PC1 の意味を厳密 loading から読む(業種クラスタ?規模?成長性?配当性向?)
    • HHI と整合性確認: PCA で見える業種クラスタ集中が、応用編 #19 HHI 警告と一致するか
    • select_diversified_picks で5-8銘柄選択: 流動性フィルタ(時価総額100億円以上)を効かせて PCA 空間で互いに離れた銘柄を機械的に抽出
    • 3ヶ月後に PCA 再実行 + compare_loadings_stability: PC1 上位3特徴量のうち2個以上入替で構造変化アラート、ポートフォリオ見直し
    • #24 ポートフォリオ運用ルール適用: 損切 -7〜-8%・利確 +20-25%・1銘柄上限 20-25% で運用

次回予告:情報エントロピーで業種分散を再解釈 — 応用編 #19 HHI の情報理論版

次回(記事#27)では、情報理論の シャノンエントロピーで業種分散を再解釈します。応用編 #19 で導入した HHI(業種シェア二乗和)が「集中度の指標」だったのに対し、エントロピーは「不確実性 = 分散度の指標」。両者の数学的関係と、ポートフォリオ運用での使い分けを Python で自動判定実装します。事業構造分析ブロック第3回。

HowTo schema 実装サンプル(v2 S1 追加)

本記事を JSON-LD で構造化する場合の例(WordPress テーマやプラグインで <head> に挿入):

{
  "@context": "https://schema.org",
  "@type": "HowTo",
  "name": "PCA で C×A×N PASS 銘柄の構造を Python で可視化する手順",
  "step": [
    {"@type": "HowToStep", "name": "特徴量データセット構築",
     "text": "DuckDB から C×A×N の verdict + 補助指標を抽出"},
    {"@type": "HowToStep", "name": "scikit-learn PCA 実行",
     "text": "StandardScaler 標準化後、PCA fit_transform で次元削減"},
    {"@type": "HowToStep", "name": "主成分の解釈",
     "text": "厳密 loading + Scree Plot で意味づけと採用主成分数決定"},
    {"@type": "HowToStep", "name": "業種クラスタ可視化",
     "text": "PC1×PC2 散布図 + 業種色付けで構造観察、分散選択"}
  ]
}

SWELL では「カスタム HTML」ブロックで <script type="application/ld+json"> を挿入できます。

「製品開発DXエンジニアの投資術」シリーズ全体像

本記事は 発展編(記事#21〜#30)の第6回 — 事業構造分析ブロック第2回(機械学習初登場) です。

  • 基礎編(#01〜#10): 完了
  • 応用編(#11〜#20): 完了 ✅
  • 発展編(#21〜#30): 進行中

発展編 ロードマップ:

前回 #25 N 製品アーキテクト視点本記事 #26 PCA で銘柄相関分析 | 次回 #27(情報エントロピー、公開予定)

関連記事(応用編から): #19 業種分散 FMEA#20 応用編まとめ

免責事項(再掲)

本記事は投資助言を目的としたものではなく、技術・分析手法の紹介です。PCA 分析の閾値・主成分の解釈・分散選択ロジックは教育目的であり、特定の銘柄・金融商品の売買を推奨するものではありません。投資判断はご自身の責任で行ってください。J-Quants API・FMP の利用規約は変更される可能性があるため、実装時は各サービスの公式ドキュメント・利用規約を必ず確認してください。scikit-learn は BSD ライセンスのオープンソースですが、バージョン間で API が変更される場合があり、本記事は scikit-learn 1.4+ を前提としています。本記事中に J-Quants/FMP から取得した個別銘柄の特徴量実値・株価実値は掲載していません。PCA は線形手法であり、業種間の非線形相関構造は捉えきれない点に注意してください。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

CAPTCHA


目次