Pythonで相関係数を計算|信頼区間と外れ値の落とし穴

銘柄同士の関係を数値で確かめる 基礎知識・戦略

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

先に結論です。returns.corr()で出た数字は、点推定にすぎません。3か月分(約60営業日)のデータで相関0.5が出たとしても、95%信頼区間は0.28〜0.67くらいの幅を持ちます。「0.5だから中程度」と言い切れるほど、この数字は確定していません。

さらに、たった1日の暴落が入るだけで相関は跳ね上がります。ピアソンの相関係数は外れ値に弱く、コロナショックのような日が1つ混ざるだけで、無関係な2銘柄でも0.6くらい出せてしまいます。

この記事では、相関マトリクスを作るところから始めて、信頼区間の計算外れ値の影響の測定ピアソンとスピアマンの使い分け、そしてクラスタリングで実質的な分散数を数える方法まで実装します。

📘 外部参考DataFrame.corr(pandas公式)scipy.stats.pearsonr(公式)

相関係数とは何か

「A株が上がるときにB株も上がる」「円高になると輸出株が下がる」という関係性を数値化したものが相関係数(Correlation Coefficient)です。-1から+1の値を取ります。

相関係数の値意味
+1に近い強い正の相関(一緒に動く)同業種の2銘柄
0に近い相関なし(無関係)異なる業種・地域
-1に近い強い負の相関(逆に動く)株価と債券(一般的に)

分散投資が効くのは相関の低い資産を組み合わせたときです。相関が+1に近い銘柄ばかりを持っていても、一方が下落すれば全部下落します。目安として0.7以上の銘柄同士は「ほぼ同じもの」と考えてよいでしょう。

相関マトリクスを作る

import numpy as np
import pandas as pd
import yfinance as yf

PORTFOLIO = {
    "7203.T": "トヨタ",
    "9984.T": "SBG",
    "6758.T": "ソニー",
    "8306.T": "三菱UFJ",
    "9432.T": "NTT",
    "^N225":  "日経平均",
}

def load_returns(portfolio: dict, period: str = "3y", log: bool = True) -> pd.DataFrame:
    frames = {}
    for sym, name in portfolio.items():
        df = yf.Ticker(sym).history(period=period, auto_adjust=True)
        s = df["Close"].dropna()
        s.index = pd.to_datetime(s.index).tz_localize(None)
        frames[name] = s
    px = pd.DataFrame(frames).dropna(how="any").sort_index()
    if log:
        return np.log(px / px.shift(1)).dropna()
    return px.pct_change().dropna()

returns = load_returns(PORTFOLIO)
print(returns.corr().round(2).to_string())
print("サンプル数:", len(returns))

対数リターンを使っているのは、加法性があって扱いやすいからです。日次のような小さい変化では単純リターンとほとんど差は出ませんが、相関の計算においてはどちらを使ったかを揃えることの方が大事です。混ぜると比較できません。

dropna(how="any")で全銘柄が揃った日だけ残しています。これをしないと、ペアごとに使う日数が変わり、マトリクスの中で条件が不揃いになります。

その相関、偶然ではないか

相関係数には、サンプル数に応じた不確かさがあります。Fisherのz変換で信頼区間を出すと、数字の幅が見えます。

from scipy import stats

def corr_with_ci(x: pd.Series, y: pd.Series, alpha: float = 0.05) -> dict:
    d = pd.concat([x, y], axis=1).dropna()
    n = len(d)
    r, p = stats.pearsonr(d.iloc[:, 0], d.iloc[:, 1])
    # Fisher z 変換で信頼区間
    z = np.arctanh(r)
    se = 1 / np.sqrt(n - 3)
    crit = stats.norm.ppf(1 - alpha / 2)
    lo, hi = np.tanh(z - crit * se), np.tanh(z + crit * se)
    return {
        "n": n,
        "r": round(float(r), 3),
        "p_value": float(f"{p:.2e}"),
        "ci_low": round(float(lo), 3),
        "ci_high": round(float(hi), 3),
        "ci_width": round(float(hi - lo), 3),
    }

print(corr_with_ci(returns["トヨタ"], returns["ソニー"]))

サンプル数で幅がどう変わるか

def ci_by_sample(x: pd.Series, y: pd.Series, sizes=(20, 60, 120, 250, 750)) -> pd.DataFrame:
    rows = []
    for n in sizes:
        if len(x) < n:
            continue
        res = corr_with_ci(x.tail(n), y.tail(n))
        res["window"] = n
        rows.append(res)
    return pd.DataFrame(rows).set_index("window")

print(ci_by_sample(returns["トヨタ"], returns["ソニー"]))

20日(約1か月)だと信頼区間の幅が0.7以上になることも珍しくありません。「先月の相関は0.5でした」は、ほぼ何も言っていないのと同じです。判断に使うなら、最低でも半年、できれば1年以上のサンプルが必要です。

逆に、サンプルが多ければp値は簡単に小さくなります。750日もあれば、相関0.08でも「統計的に有意」になります。有意であることと、実用的に意味があることは別です。p値ではなく、rの大きさと信頼区間を見てください。

外れ値1点で相関は化ける

ピアソンの相関係数は、平均からの偏差の積を使うため、極端な1日の影響を強く受けます。どれくらい効くかを実験してみます。

def outlier_impact(n: int = 250, shock: float = -0.10, seed: int = 0) -> pd.DataFrame:
    rng = np.random.default_rng(seed)
    a = rng.normal(0, 0.01, n)
    b = rng.normal(0, 0.01, n)   # a とは完全に無関係

    rows = [{"case": "元データ", "pearson": np.corrcoef(a, b)[0, 1],
             "spearman": stats.spearmanr(a, b).statistic}]

    for k in (1, 3, 5):
        aa, bb = a.copy(), b.copy()
        aa[:k] = shock   # 同じ日に両方が暴落した、という状況
        bb[:k] = shock
        rows.append({
            "case": f"共通ショック{k}日",
            "pearson": np.corrcoef(aa, bb)[0, 1],
            "spearman": stats.spearmanr(aa, bb).statistic,
        })
    return pd.DataFrame(rows).set_index("case").round(3)

print(outlier_impact())

無関係な2系列でも、共通の暴落日を1日入れるだけでピアソンは0.4〜0.5に跳ね上がります。スピアマン(順位相関)は同じ条件でもほとんど動きません。「暴落時に一緒に落ちた」という1日が、平常時の関係まで塗り替えてしまうのがピアソンの性質です。

影響の大きい日を特定する

def influential_days(x: pd.Series, y: pd.Series, top: int = 5) -> pd.DataFrame:
    d = pd.concat([x, y], axis=1).dropna()
    base = d.iloc[:, 0].corr(d.iloc[:, 1])
    rows = []
    for i in range(len(d)):
        sub = d.drop(d.index[i])
        r = sub.iloc[:, 0].corr(sub.iloc[:, 1])
        rows.append({"date": d.index[i].date(), "corr_without": r, "delta": base - r})
    df = pd.DataFrame(rows)
    df["abs_delta"] = df["delta"].abs()
    print(f"全データの相関: {base:.3f}")
    return df.nlargest(top, "abs_delta").drop(columns="abs_delta").round(4)

print(influential_days(returns["トヨタ"], returns["ソニー"]))

1日抜くだけで相関が0.05以上動く日があれば、その数字は少数のイベントに依存しています。上位の日付を見ると、決算やショック相場に集中しているはずです。

ピアソンとスピアマンの使い分け

def both_corr(returns: pd.DataFrame) -> pd.DataFrame:
    p = returns.corr(method="pearson")
    s = returns.corr(method="spearman")
    rows = []
    cols = list(returns.columns)
    for i in range(len(cols)):
        for j in range(i + 1, len(cols)):
            a, b = cols[i], cols[j]
            rows.append({
                "pair": f"{a} - {b}",
                "pearson": round(float(p.loc[a, b]), 3),
                "spearman": round(float(s.loc[a, b]), 3),
                "gap": round(float(p.loc[a, b] - s.loc[a, b]), 3),
            })
    return pd.DataFrame(rows).sort_values("gap", key=abs, ascending=False)

print(both_corr(returns))
指標測るもの外れ値使いどころ
ピアソン線形の関係の強さ弱いヘッジ比率、ポートフォリオ分散の計算
スピアマン順位の一致度強い平常時の関係を知りたいとき

gapが大きいペアは、少数の大きな日に引っ張られています。両方を出して差を見るのが、いちばん手軽な健全性チェックです。なお、リスク計算に使う分散共分散行列はピアソンが前提なので、置き換えればよいという話ではありません。

実質的にいくつに分散できているか

6銘柄持っていても、相関の高いグループが3つあるなら、実質3分散です。階層クラスタリングで、相関距離をもとにグループ分けします。

from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import squareform

def correlation_clusters(returns: pd.DataFrame, threshold: float = 0.5) -> pd.DataFrame:
    corr = returns.corr()
    # 相関距離:相関が高いほど距離が近い
    dist = np.sqrt(np.clip(2 * (1 - corr.values), 0, None))
    np.fill_diagonal(dist, 0.0)
    z = linkage(squareform(dist, checks=False), method="average")
    labels = fcluster(z, t=threshold, criterion="distance")
    out = pd.DataFrame({"name": corr.columns, "cluster": labels})
    print(f"銘柄数 {len(out)} → 実質グループ数 {out['cluster'].nunique()}")
    return out.sort_values("cluster")

print(correlation_clusters(returns))

日経平均を含めていると、多くの日本株が同じクラスタに入ります。当然で、指数は構成銘柄の集合体だからです。「6銘柄持っている」ではなく「実質2グループしか持っていない」と分かると、次に足すべき資産の方向が見えます。

相関から分散効果を計算する

def portfolio_vol(returns: pd.DataFrame, weights=None, trading_days: int = 245) -> dict:
    n = returns.shape[1]
    w = np.repeat(1 / n, n) if weights is None else np.asarray(weights, float)
    w = w / w.sum()
    cov = returns.cov().values * trading_days
    port = float(np.sqrt(w @ cov @ w))
    each = np.sqrt(np.diag(cov))
    naive = float((each * w).sum())
    return {
        "portfolio_vol": round(port, 4),
        "weighted_avg_vol": round(naive, 4),
        "reduction_pct": round((1 - port / naive) * 100, 2),
    }

print(portfolio_vol(returns))

このreduction_pctが、相関の低さが実際に生んだ効果です。0%に近ければ、相関の話は机上のもので終わっています。より詳しい分散効果の測り方は複数銘柄の比較記事、相関が期間で崩れる問題は相関係数の落とし穴にまとめています。

まとめ

  • 相関係数は点推定。Fisherのz変換で信頼区間を出し、幅を確認する
  • 短期間(20〜60日)の相関は幅が広すぎて判断材料にならない
  • サンプルが多いとp値は簡単に有意になる。rの大きさで判断する
  • ピアソンは外れ値1日で化ける。スピアマンと並べて差を見る
  • 銘柄数ではなくクラスタ数が、実質的な分散数

相関マトリクスを1枚出すだけなら1行です。その1行を信じてよいかを確かめるのに、この記事のコードを使ってください。特に信頼区間と外れ値チェックは、慣れれば数十秒で終わります。

🔗 関連記事

Pythonで複数銘柄の株価を比較する方法【正規化・相関分析】

タイトルとURLをコピーしました