Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

ポリシリアル相関係数

ポリシリアル相関係数(polyserial correlation) は順序尺度の変数と連続変数の間の相関係数。

理論

モデルの仮定

順序尺度の変数YYは連続潜在変数YY^*をある閾値で分割したものであると仮定する

Y=yj if  τj1<Yτj,j=1,2,,JY= y_j \quad \text { if } ~ \tau_{j-1}<Y^* \leq \tau_j , \quad j = 1, 2, \dots, J

ここで

  • YY^* :連続潜在変数。標準正規分布に従う:E[Y]=0,Var[Y]=1\operatorname{E}[Y] = 0, \operatorname{Var}[Y] = 1

  • XX :連続観測変数。E[X]=μX,Var[X]=σX\operatorname{E}[X] = \mu_X, \operatorname{Var}[X] = \sigma_X

  • (X,Y)(X, Y^*) は 2変量正規分布に従うと仮定:

[XY]N([μX0],[σX2ρρ1])\left[\begin{array}{c} X \\ Y^* \end{array}\right] \sim \mathcal{N}\left(\left[\begin{array}{l} \mu_X \\ 0 \end{array}\right],\left[\begin{array}{ll} \sigma_X^2 & \rho \\ \rho & 1 \end{array}\right]\right)

尤度関数

nn個のサンプル(xi,yi)(x_i, y_i)の尤度関数LLは、正規分布をもちいて

L=i=1nfXY(xi,yi)=i=1nfX(xi)P(Y=yiX=xi)L = \prod_{i=1}^n f_{XY}(x_i, y_i) = \prod_{i=1}^n f_{X}(x_i) P(Y=y_i \mid X=x_i)

と、同時確率密度 fXYf_{XY} を周辺密度fXf_{X}と条件付き確率密度fYX(YX)=P(Y=yiX=xi)f_{Y\mid X}(Y\mid X) = P(Y=y_i \mid X=x_i)の積の形に変形できる。そしてρ\rhoが関わるのは条件付き確率密度のほうになる。

YYX=xiX=x_iによる条件つき分布P(Y=yiX=xi)P(Y=y_i \mid X=x_i)は、xix_iを標準化したzi=(xiμX)/σXz_i =(x_i - \mu_X) / \sigma_Xを考えると 平均ρzi\rho z_i、分散(1ρ)(1- \rho)の正規分布に従うため

P(Y=yjX=xi)=Φ(τj)Φ(τj1),j=1,2,,JP(Y = y_j \mid X = x_i) = \Phi(\tau_j^*) - \Phi(\tau_{j-1}^*), \quad j = 1, 2, \dots, J

ここでτj\tau_j^*は正規化した閾値(標準正規空間での閾値)

τj=τjρzi1ρ2\tau_j^* = \frac{\tau_j-\rho z_i}{\sqrt{1-\rho^2}}

である。

こうして対数尤度関数

logL(ρ)=i=1nlog[Φ(τyiρzi1ρ2)Φ(τyi1ρzi1ρ2)]\log L(\rho)=\sum_{i=1}^n \log \left[\Phi\left(\frac{\tau_{y_i}-\rho z_i}{\sqrt{1-\rho^2}}\right)-\Phi\left(\frac{\tau_{y_i-1}-\rho z_i}{\sqrt{1-\rho^2}}\right)\right]

を構築できる。

条件付き分布について

2変量正規分布:

[XY]N([μXμY],[σX2ρσXσYρσXσYσY2])\left[\begin{array}{c} X \\ Y \end{array}\right] \sim \mathcal{N}\left(\left[\begin{array}{l} \mu_X \\ \mu_Y \end{array}\right],\left[\begin{array}{cc} \sigma_X^2 & \rho \sigma_X \sigma_Y \\ \rho \sigma_X \sigma_Y & \sigma_Y^2 \end{array}\right]\right)

があるとき,X=xX=x という条件のもとでの YY の条件付き分布は fYX(yx)=fX,Y(x,y)fX(x)f_{Y\mid X}(y|x) = \frac{f_{X,Y}(x,y)}{f_X(x)} より、

fYX(yx)=12π(1ρ2)σY2×exp[12(1ρ2)σY2{yμYρσYσX(xμX)}2]f_{Y \mid X}(y \mid x)=\frac{1}{\sqrt{2 \pi (1-\rho^2) \sigma_Y^2}} \times \exp \left[-\frac{1}{2\left(1-\rho^2\right) \sigma_Y^2}\left\{y-\mu_Y-\rho \frac{\sigma_Y}{\sigma_X}\left(x-\mu_X\right)\right\}^2\right]

から、条件付き分布は

YX=xN(μY+ρσYσX(xμX),(1ρ2)σY2)Y \mid X = x \sim \mathcal{N}\left( \mu_Y + \rho \frac{\sigma_Y}{\sigma_X} (x-\mu_X), (1-\rho^2) \sigma_Y^2 \right)

となる。μY=0,σY=1\mu_Y=0, \sigma_Y=1

[XY]N([μX0],[σX2ρσXρσX1])\left[\begin{array}{c} X \\ Y^* \end{array}\right] \sim \mathcal{N}\left(\left[\begin{array}{l} \mu_X \\ 0 \end{array}\right],\left[\begin{array}{cc} \sigma_X^2 & \rho \sigma_X \\ \rho \sigma_X & 1 \end{array}\right]\right)

という分布であれば

YX=xN(ρ1σX(xμX),1ρ2)Y^* \mid X = x \sim \mathcal{N}\left( \rho \frac{1}{\sigma_X} (x-\mu_X), 1-\rho^2 \right)

となる。XXを標準化して zi=(xiμX)/σXz_i = (x_i - \mu_X) / \sigma_X とおけば

YZ=ziN(ρzi,1ρ2)Y^* \mid Z = z_i \sim \mathcal{N}\left(\rho z_i, 1-\rho^2\right)

参考:

関連文献
  • Drasgow, F. (1986). Polychoric and polyserial correlations In: Kotz S, Johnson N, editors. The Encyclopedia of Statistics.

import numpy as np
from scipy.stats import norm
from scipy.optimize import minimize_scalar
import matplotlib.pyplot as plt
import matplotlib_fontja


def normalize_ordinal(x: np.ndarray[int]) -> np.ndarray[int]:
    """Normalize ordinal variable to be integer-coded starting from 0."""
    unique_values = np.unique(x)
    value_to_code = {value: code for code, value in enumerate(unique_values)}
    return np.vectorize(value_to_code.get)(x)

def polyserial_correlation(x: np.ndarray, y: np.ndarray, plot: bool = False) -> float:
    """
    Estimate the polyserial correlation coefficient between a continuous variable x
    and an ordinal variable y using maximum likelihood estimation.
    """
    x = np.asarray(x)
    y = np.asarray(y)

    if not np.issubdtype(y.dtype, np.integer):
        raise ValueError("y must be an integer-coded ordinal variable.")

    # Standardize x
    z = (x - np.mean(x)) / np.std(x)

    # Estimate thresholds τ from y
    def estimate_thresholds(y):
        levels = np.sort(np.unique(y))
        thresholds = []
        for level in levels[:-1]:  # exclude top category
            p = np.mean(y <= level)
            thresholds.append(norm.ppf(p))
        return np.concatenate(([-np.inf], thresholds, [np.inf]))

    y = normalize_ordinal(y)
    tau = estimate_thresholds(y)

    def neg_log_likelihood(rho):
        log_likelihood = 0.0
        for i in range(len(z)):
            j = y[i]
            tau_lower = (tau[j] - rho * z[i]) / np.sqrt(1 - rho**2)
            tau_upper = (tau[j + 1] - rho * z[i]) / np.sqrt(1 - rho**2)
            p_i = norm.cdf(tau_upper) - norm.cdf(tau_lower)
            p_i = max(p_i, 1e-6)  # soft clipping
            if np.isnan(p_i):
                continue
            log_likelihood += np.log(p_i)
        return -log_likelihood
    
    result = minimize_scalar(neg_log_likelihood, bounds=(-0.999, 0.999), method='bounded')
    
    if plot:
        # plot for debug
        rho_range = np.linspace(-0.999, 0.999, 200)
        likelihoods = np.array([neg_log_likelihood(rho) for rho in rho_range])
        rho_hat = rho_range[np.argmin(likelihoods)]

        fig, ax = plt.subplots(figsize=[4,3])
        ax.plot(rho_range, likelihoods, color="dimgray")
        ax.set(xlabel=r"$\rho$", ylabel="log likelihood", title="Maximum Likelihood Estimate")
        l = likelihoods[~np.isinf(likelihoods)]
        # y = -(l.max() - l.min()) / 2
        y = np.min(likelihoods)
        ax.text(rho_hat * 1.1, y * 1.5, r"$\hat{\rho}$"+f"={rho_hat:.3f}", color="steelblue")
        ax.axvline(rho_hat, color="steelblue")
        fig.show()

    return result.x
import pandas as pd
from scipy.stats import multivariate_normal
n = 100
mean = [50, 0]
std = [10, 3]
rho = 0.5
cov = rho * std[0] * std[1]
Cov = np.array([
    [std[0]**2, cov],
    [cov, std[1]**2]
])

rho = 0.75
Cov = np.array([[1, rho], [rho, 1]])
X = multivariate_normal.rvs(mean=mean, cov=Cov, size=n, random_state=0)
df = pd.DataFrame(X, columns=["x", "y"])
df["y"], _ = pd.cut(df["y"], bins=3).factorize(sort=True)

polyserial_correlation(df["x"], df["y"], plot=True)
/tmp/ipykernel_1190577/413508437.py:69: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown
  fig.show()
np.float64(0.7481134651912188)
<Figure size 400x300 with 1 Axes>

実装の高速化メモ

forループよりベクトル化、高レベルAPIより低レベル

scipy.special.ndtr は標準正規分布の累積確率の低レベル関数で、norm.cdf よりも高速

import numpy as np
from scipy.stats import norm
from scipy.special import ndtr  # 標準正規CDFの計算本体(引数検証なし・C実装)

rng = np.random.default_rng(0)
v = rng.normal(size=10_000)

# 方法1: Python ループ + norm.cdf(改善前の polyserial と同じパターン)
%timeit -r 3 -n 1 [norm.cdf(vi) for vi in v]

# 方法2: norm.cdf に配列を一括で渡す(ベクトル化)
%timeit -r 3 norm.cdf(v)

# 方法3: ndtr に配列を一括で渡す(ベクトル化 + 低レベル関数)
%timeit -r 3 ndtr(v)
162 ms ± 2.73 ms per loop (mean ± std. dev. of 3 runs, 1 loop each)
130 μs ± 1.23 μs per loop (mean ± std. dev. of 3 runs, 10,000 loops each)
59.5 μs ± 2.31 μs per loop (mean ± std. dev. of 3 runs, 10,000 loops each)

polyserialの高速化

polyserial のモデル

yy は潜在的な連続変数 yy^* を閾値 τ\tau で切ったものと仮定する。xx を標準化した zz を使うと、観測 ii の尤度への寄与は

pi=Φ ⁣(τyi+1ρzi1ρ2)Φ ⁣(τyiρzi1ρ2)p_i = \Phi\!\left(\frac{\tau_{y_i+1} - \rho z_i}{\sqrt{1-\rho^2}}\right) - \Phi\!\left(\frac{\tau_{y_i} - \rho z_i}{\sqrt{1-\rho^2}}\right)

で、ilogpi-\sum_i \log p_iρ\rho について最小化する(2段階最尤法: τ\tau は周辺分布から先に推定)。

改善前の実装

観測 ii ごとに Python ループを回し、1件ずつ norm.cdf を呼ぶ実装だと、最適化ソルバーは尤度関数を数十回評価するので、コストは「反復回数 × n × norm.cdf のオーバーヘッド」になる。

import numpy as np
from scipy.stats import norm

rng = np.random.default_rng(0)


def gen(n, k=5, rho=0.5):
    """真の相関 rho の潜在2変量正規から、順序変数 x, y と連続変数 z を作る"""
    latent = rng.multivariate_normal([0, 0], [[1, rho], [rho, 1]], size=n)
    cuts0 = np.quantile(latent[:, 0], np.linspace(0, 1, k + 1)[1:-1])
    cuts1 = np.quantile(latent[:, 1], np.linspace(0, 1, k + 1)[1:-1])
    x = np.digitize(latent[:, 0], cuts0)  # 順序変数 (0..k-1)
    y = np.digitize(latent[:, 1], cuts1)  # 順序変数 (0..k-1)
    return x, y, latent[:, 0]  # z は x の元になった連続変数


x, y, z = gen(1000)
print("x:", x[:10], "...")
print("y:", y[:10], "...")
print("z:", np.round(z[:10], 2), "...")
x: [2 1 2 0 4 3 4 4 3 0] ...
y: [2 1 3 1 2 3 4 3 3 2] ...
z: [-0.04 -0.61  0.28 -1.6   1.24  0.52  2.12  1.45  0.63 -0.88] ...
def estimate_thresholds(values):
    """周辺比率から閾値 τ を推定(±100 を擬似的な ±∞ として使う)"""
    inf = 100
    _, counts = np.unique(values, return_counts=True)
    cum_p = np.cumsum(counts)[:-1] / values.size  # P(Y <= i)、最上位カテゴリは除く
    return np.concatenate(([-inf], norm.ppf(cum_p), [inf]))


# --- 改善前: 観測ごとの Python ループ ---
def nll_loop(rho, z, y, tau):
    ll = 0.0
    for i in range(len(z)):
        j = y[i]
        lo = (tau[j] - rho * z[i]) / np.sqrt(1 - rho**2)
        hi = (tau[j + 1] - rho * z[i]) / np.sqrt(1 - rho**2)
        p = norm.cdf(hi) - norm.cdf(lo)
        ll += np.log(max(p, 1e-6))
    return -ll


x, y, z_raw = gen(5000)
z = (z_raw - z_raw.mean()) / z_raw.std()
tau = estimate_thresholds(y)

%timeit -r 3 -n 1 nll_loop(0.5, z, y, tau)
189 ms ± 3.74 ms per loop (mean ± std. dev. of 3 runs, 1 loop each)

改善後: fancy indexing で一括計算

鍵は fancy indexing です。tau[y] と書くと、各観測 ii に対応する閾値 τyi\tau_{y_i} を並べた長さ n の配列が一発で得られます。

# fancy indexing のミニ例
tau_demo = np.array([-100.0, -0.8, 0.3, 100.0])  # 3カテゴリの閾値
y_demo = np.array([0, 2, 1, 1, 0])
print("tau[y]   =", tau_demo[y_demo])      # 各観測の下側閾値 τ_{y_i}
print("tau[y+1] =", tau_demo[y_demo + 1])  # 各観測の上側閾値 τ_{y_i+1}
tau[y]   = [-100.     0.3   -0.8   -0.8 -100. ]
tau[y+1] = [ -0.8 100.    0.3   0.3  -0.8]
# --- 改善後: 全観測を1回のベクトル演算で ---
def nll_vec(rho, z, y, tau):
    t_lo = tau[y]      # τ_{y_i}      (長さ n の配列)
    t_hi = tau[y + 1]  # τ_{y_i + 1}  (長さ n の配列)
    s = np.sqrt(1 - rho**2)
    p = ndtr((t_hi - rho * z) / s) - ndtr((t_lo - rho * z) / s)
    return -np.sum(np.log(np.maximum(p, 1e-6)))


%timeit -r 3 nll_vec(0.5, z, y, tau)

# 結果は一致する
print("loop:", nll_loop(0.5, z, y, tau))
print("vec: ", nll_vec(0.5, z, y, tau))
94.1 μs ± 502 ns per loop (mean ± std. dev. of 3 runs, 10,000 loops each)
loop: 7413.73487638945
vec:  7413.734876389446

高速化バージョン

import warnings
from typing import Any, Sequence

import numpy as np
import numpy.typing as npt
from scipy.optimize import minimize_scalar
from scipy.special import ndtr, owens_t
from scipy.stats import norm


def polyserial_fast(x: npt.ArrayLike, y: npt.ArrayLike) -> float:
    x = np.asarray(x)
    y = np.asarray(y)

    z = (x - np.mean(x)) / np.std(x, ddof=0)
    y = normalize_ordinal(y)
    tau = estimate_thresholds(y)
    tau_lower = tau[y]  # τ_{y_i}
    tau_upper = tau[y + 1]  # τ_{y_i + 1}

    def neg_log_likelihood(rho: float) -> float:
        scale = np.sqrt(1 - rho**2)
        p = univariate_cdf((tau_lower - rho * z) / scale, (tau_upper - rho * z) / scale)
        p = np.maximum(p, 1e-6)  # soft clipping
        return -np.sum(np.log(p, where=~np.isnan(p), out=np.zeros_like(p)))

    result = minimize_scalar(neg_log_likelihood, bounds=(-1, 1), method="bounded")
    return float(result.x)


def estimate_thresholds(values: npt.NDArray[Any]) -> npt.NDArray[np.float64]:
    r"""Estimate thresholds from empirical marginal proportions"""
    inf = 100  # to make log-likelihood smooth, use large value instead of np.inf
    _, counts = np.unique(values, return_counts=True)
    cum_p = np.cumsum(counts)[:-1] / values.size  # P(X ≤ i), exclude top category
    thresholds = norm.ppf(cum_p)  # τ_i = Φ⁻¹(P(X ≤ i))
    return np.concatenate(([-inf], thresholds, [inf]))


def normalize_ordinal(x: npt.NDArray[Any]) -> npt.NDArray[np.int_]:
    r"""Normalize ordinal variable to be integer-coded starting from 0."""
    _, codes = np.unique(x, return_inverse=True)
    return codes

def univariate_cdf(
    lower: npt.ArrayLike, upper: npt.ArrayLike
) -> npt.NDArray[np.float64]:
    """Compute the univariate cumulative distribution function (CDF) for a standard normal distribution.
    P(lower < X <= upper) = Φ(upper) - Φ(lower)
    where Φ is the CDF of the standard normal distribution.
    """
    return ndtr(upper) - ndtr(lower)
# 改善前
%timeit -r 3 polyserial_correlation(x, y)

# 改善後
%timeit -r 3 polyserial_fast(x, y)
1.75 s ± 13.5 ms per loop (mean ± std. dev. of 3 runs, 1 loop each)
1.23 ms ± 3.26 μs per loop (mean ± std. dev. of 3 runs, 1,000 loops each)