| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- """
- Multivariate normality tests
-
- Authors: Martin Horvat, Janury 2026
- """
- import numpy as np
- import scipy
- from typing import Tuple
- def mardia_test(data: np.ndarray, cov: bool = True) -> Tuple[float, float, float, float]:
- """
- Mardia's multivariate skewness and kurtosis.
- Calculates the Mardia's multivariate skewness and kurtosis coefficients
- as well as their corresponding statistical test. For large sample size
- the multivariate skewness is asymptotically distributed as a Chi-square
- random variable; here it is corrected for small sample size. However,
- both uncorrected and corrected skewness statistic are presented. Likewise,
- the multivariate kurtosis it is distributed as a unit-normal.
- Syntax: function [Mskekur] = Mskekur(X,c,alpha)
- Ref:
- * https://rdrr.io/cran/MVN/src/R/mvn.R
- * https://stats.stackexchange.com/questions/317147/how-to-get-a-single-p-value-from-the-two-p-values-of-a-mardias-multinormality-t
-
- Inputs:
- X - multivariate data matrix [Size of matrix must be n(data)-by-p(variables)].
- cov - boolean to whether to normalize the covariance matrix by n (c=1[default]) or by n-1 (c~=1)
- Outputs:
- tuple containing:
- skewness test statistic,
- kurtosis test statistic,
- significance value for skewness,
- significance value for kurtosis
- """
- n, p = data.shape
- # correct for small sample size
- small: bool = True if n < 20 else False
- if cov:
- S = ((n - 1)/n) * np.cov(data.T)
- else:
- S = np.cov(data.T)
- # calculate mean
- data_mean = data.mean(axis=0)
- # inverse - check if singular matrix
- try:
- iS = np.linalg.inv(S)
- except Exception as e:
- # print for now
- print(e)
- return 0.0, 0.0, 0.0, 0.0
- # squared-Mahalanobis' distances matrix
- D: np.ndarray = (data - data_mean) @ iS @ (data - data_mean).T
- # multivariate skewness coefficient
- g1p: float = np.sum(D**3)/n**2
- # multivariate kurtosis coefficient
- g2p: float = np.trace(D**2)/n
- # small sample correction
- k: float = ((p + 1)*(n + 1)*(n + 3))/(n*(((n + 1)*(p + 1)) - 6))
- # degrees of freedom
- df: float = (p * (p + 1) * (p + 2))/6
- if small:
- # skewness test statistic corrected for small sample: it approximates to a chi-square distribution
- g_skew = (n * g1p * k)/6
- else:
- # skewness test statistic:it approximates to a chi-square distribution
- g_skew = (n * g1p)/6
- # significance value associated to the skewness corrected for small sample
- p_skew: float = 1.0 - scipy.stats.chi2.cdf(g_skew, df)
- # kurtosis test statistic: it approximates to a unit-normal distribution
- g_kurt = (g2p - (p*(p + 2)))/(np.sqrt((8 * p * (p + 2))/n))
- # significance value associated to the kurtosis
- p_kurt: float = 2 * (1.0 - scipy.stats.norm.cdf(np.abs(g_kurt)))
- return g_skew, g_kurt, p_skew, p_kurt
- def hz_test(data: np.ndarray, cov: bool = True) -> Tuple[float, float]:
- """
- Henze-Zirkler method for goodness of fit of data to a multivariate normal distribution.
- Researchers tend to use this MVN test for larger samples (N > 100).
- Ref:
- * https://www.tandfonline.com/doi/abs/10.1080/03610929008830400
- Input:
- data: multivariate data matrix [Size of matrix must be n(data)-by-p(variables)].
- cov: boolean to whether to normalize the covariance matrix by n (c=1[default]) or by n-1 (c~=1)
-
- Return:
- tuple containing:
- HZ - Henze-Zirkler test statistic
- p_value - significance value
- """
- n, p = data.shape
- if cov:
- S = ((n - 1)/n) * np.cov(data.T)
- else:
- S = np.cov(data.T)
- # calculate mean
- data_mean = data.mean(axis=0)
- try:
- iS = np.linalg.inv(S)
- except Exception as e:
- print(e)
- return 0.0, 0.0
- Y = data @ iS @ data.T
- Dj = np.diag((data - data_mean) @ iS @ (data - data_mean).T)
- Djk = - 2 * Y.T + np.tensordot(np.diag(Y.T), np.ones(n), axes=0) + np.tensordot(np.ones(n), np.diag(Y.T), axes=0)
- b: float = 1 / (np.sqrt(2)) * ((2 * p + 1) / 4) ** (1 / (p + 4)) * (n ** (1 / (p + 4)))
- # calculate rank of matrix
- S_rank = np.linalg.matrix_rank(S)
- if S_rank == p:
- HZ = n * (1 / (n ** 2) * np.sum(np.sum(np.exp(- (b ** 2) / 2 * Djk))) - 2 * ((1 + (b ** 2)) ** (- p / 2)) * (1 / n) * (np.sum(np.exp(- ((b ** 2) / (2 * (1 + (b ** 2)))) * Dj))) + ((1 + (2 * (b ** 2))) ** (- p / 2)))
- else:
- HZ = n * 4
- wb = (1 + b ** 2) * (1 + 3 * b ** 2)
- a = 1 + 2 * b ** 2
- # HZ mean
- mu = 1 - a ** (- p / 2) * (1 + p * b ** 2 / a + (p * (p + 2) * (b ** 4)) / (2 * a ** 2)) # HZ mean
- # HZ variance
- si2 = 2 * (1 + 4 * b ** 2) ** (- p / 2) + 2 * a ** (- p) * (1 + (2 * p * b ** 4) / a ** 2 + (3 * p * (p + 2) * b ** 8) / (4 * a ** 4)) - 4 * wb ** (- p / 2) * (1 + (3 * p * b ** 4) / (2 * wb) + (p * (p + 2) * b ** 8) / (2 * wb ** 2))
- pmu = np.log(np.sqrt(mu ** 4 / (si2 + mu ** 2))) # lognormal HZ mean
- psi = np.sqrt(np.log((si2 + mu ** 2) / mu ** 2)) # lognormal HZ standard deviation
- # calculate p-value
- p_value = 1.0 - scipy.stats.lognorm.cdf(HZ, psi, scale=np.exp(pmu))
- return HZ, p_value
- import numpy as np
- from scipy.stats import shapiro, chi2
- def royston_test(X):
- """
- Royston's Multivariate Normality Test using Fisher's method on Shapiro-Wilk p-values.
-
- Input:
- X (ndarray): 2D array (n_samples x n_variables)
- Returns:
- stat (float): Fisher's combined test statistic
- p_value (float): p-value for overall multivariate normality
- """
- X = np.asarray(X)
- n, p = X.shape
- if n < 3:
- raise ValueError("At least 3 observations are required.")
- if p < 2:
- raise ValueError("At least 2 variables required.")
- p_values = []
- for i in range(p):
- _, pval = shapiro(X[:, i])
- p_values.append(pval)
- p_values = np.clip(p_values, 1e-16, 1.0) # avoid log(0)
- stat = -2 * np.sum(np.log(p_values))
- df = 2 * p
- p_combined = 1 - chi2.cdf(stat, df)
-
- return stat, p_combined
|