mvn.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """
  2. Multivariate normality tests
  3. Authors: Martin Horvat, Janury 2026
  4. """
  5. import numpy as np
  6. import scipy
  7. from typing import Tuple
  8. def mardia_test(data: np.ndarray, cov: bool = True) -> Tuple[float, float, float, float]:
  9. """
  10. Mardia's multivariate skewness and kurtosis.
  11. Calculates the Mardia's multivariate skewness and kurtosis coefficients
  12. as well as their corresponding statistical test. For large sample size
  13. the multivariate skewness is asymptotically distributed as a Chi-square
  14. random variable; here it is corrected for small sample size. However,
  15. both uncorrected and corrected skewness statistic are presented. Likewise,
  16. the multivariate kurtosis it is distributed as a unit-normal.
  17. Syntax: function [Mskekur] = Mskekur(X,c,alpha)
  18. Ref:
  19. * https://rdrr.io/cran/MVN/src/R/mvn.R
  20. * https://stats.stackexchange.com/questions/317147/how-to-get-a-single-p-value-from-the-two-p-values-of-a-mardias-multinormality-t
  21. Inputs:
  22. X - multivariate data matrix [Size of matrix must be n(data)-by-p(variables)].
  23. cov - boolean to whether to normalize the covariance matrix by n (c=1[default]) or by n-1 (c~=1)
  24. Outputs:
  25. tuple containing:
  26. skewness test statistic,
  27. kurtosis test statistic,
  28. significance value for skewness,
  29. significance value for kurtosis
  30. """
  31. n, p = data.shape
  32. # correct for small sample size
  33. small: bool = True if n < 20 else False
  34. if cov:
  35. S = ((n - 1)/n) * np.cov(data.T)
  36. else:
  37. S = np.cov(data.T)
  38. # calculate mean
  39. data_mean = data.mean(axis=0)
  40. # inverse - check if singular matrix
  41. try:
  42. iS = np.linalg.inv(S)
  43. except Exception as e:
  44. # print for now
  45. print(e)
  46. return 0.0, 0.0, 0.0, 0.0
  47. # squared-Mahalanobis' distances matrix
  48. D: np.ndarray = (data - data_mean) @ iS @ (data - data_mean).T
  49. # multivariate skewness coefficient
  50. g1p: float = np.sum(D**3)/n**2
  51. # multivariate kurtosis coefficient
  52. g2p: float = np.trace(D**2)/n
  53. # small sample correction
  54. k: float = ((p + 1)*(n + 1)*(n + 3))/(n*(((n + 1)*(p + 1)) - 6))
  55. # degrees of freedom
  56. df: float = (p * (p + 1) * (p + 2))/6
  57. if small:
  58. # skewness test statistic corrected for small sample: it approximates to a chi-square distribution
  59. g_skew = (n * g1p * k)/6
  60. else:
  61. # skewness test statistic:it approximates to a chi-square distribution
  62. g_skew = (n * g1p)/6
  63. # significance value associated to the skewness corrected for small sample
  64. p_skew: float = 1.0 - scipy.stats.chi2.cdf(g_skew, df)
  65. # kurtosis test statistic: it approximates to a unit-normal distribution
  66. g_kurt = (g2p - (p*(p + 2)))/(np.sqrt((8 * p * (p + 2))/n))
  67. # significance value associated to the kurtosis
  68. p_kurt: float = 2 * (1.0 - scipy.stats.norm.cdf(np.abs(g_kurt)))
  69. return g_skew, g_kurt, p_skew, p_kurt
  70. def hz_test(data: np.ndarray, cov: bool = True) -> Tuple[float, float]:
  71. """
  72. Henze-Zirkler method for goodness of fit of data to a multivariate normal distribution.
  73. Researchers tend to use this MVN test for larger samples (N > 100).
  74. Ref:
  75. * https://www.tandfonline.com/doi/abs/10.1080/03610929008830400
  76. Input:
  77. data: multivariate data matrix [Size of matrix must be n(data)-by-p(variables)].
  78. cov: boolean to whether to normalize the covariance matrix by n (c=1[default]) or by n-1 (c~=1)
  79. Return:
  80. tuple containing:
  81. HZ - Henze-Zirkler test statistic
  82. p_value - significance value
  83. """
  84. n, p = data.shape
  85. if cov:
  86. S = ((n - 1)/n) * np.cov(data.T)
  87. else:
  88. S = np.cov(data.T)
  89. # calculate mean
  90. data_mean = data.mean(axis=0)
  91. try:
  92. iS = np.linalg.inv(S)
  93. except Exception as e:
  94. print(e)
  95. return 0.0, 0.0
  96. Y = data @ iS @ data.T
  97. Dj = np.diag((data - data_mean) @ iS @ (data - data_mean).T)
  98. 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)
  99. b: float = 1 / (np.sqrt(2)) * ((2 * p + 1) / 4) ** (1 / (p + 4)) * (n ** (1 / (p + 4)))
  100. # calculate rank of matrix
  101. S_rank = np.linalg.matrix_rank(S)
  102. if S_rank == p:
  103. 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)))
  104. else:
  105. HZ = n * 4
  106. wb = (1 + b ** 2) * (1 + 3 * b ** 2)
  107. a = 1 + 2 * b ** 2
  108. # HZ mean
  109. mu = 1 - a ** (- p / 2) * (1 + p * b ** 2 / a + (p * (p + 2) * (b ** 4)) / (2 * a ** 2)) # HZ mean
  110. # HZ variance
  111. 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))
  112. pmu = np.log(np.sqrt(mu ** 4 / (si2 + mu ** 2))) # lognormal HZ mean
  113. psi = np.sqrt(np.log((si2 + mu ** 2) / mu ** 2)) # lognormal HZ standard deviation
  114. # calculate p-value
  115. p_value = 1.0 - scipy.stats.lognorm.cdf(HZ, psi, scale=np.exp(pmu))
  116. return HZ, p_value
  117. import numpy as np
  118. from scipy.stats import shapiro, chi2
  119. def royston_test(X):
  120. """
  121. Royston's Multivariate Normality Test using Fisher's method on Shapiro-Wilk p-values.
  122. Input:
  123. X (ndarray): 2D array (n_samples x n_variables)
  124. Returns:
  125. stat (float): Fisher's combined test statistic
  126. p_value (float): p-value for overall multivariate normality
  127. """
  128. X = np.asarray(X)
  129. n, p = X.shape
  130. if n < 3:
  131. raise ValueError("At least 3 observations are required.")
  132. if p < 2:
  133. raise ValueError("At least 2 variables required.")
  134. p_values = []
  135. for i in range(p):
  136. _, pval = shapiro(X[:, i])
  137. p_values.append(pval)
  138. p_values = np.clip(p_values, 1e-16, 1.0) # avoid log(0)
  139. stat = -2 * np.sum(np.log(p_values))
  140. df = 2 * p
  141. p_combined = 1 - chi2.cdf(stat, df)
  142. return stat, p_combined