assumption_checks.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. """
  2. Comprehensive statistical assumption checking utilities.
  3. This module provides functions to check common statistical assumptions:
  4. - Normality
  5. - Homogeneity of variance
  6. - Independence
  7. - Linearity
  8. - Outliers
  9. """
  10. import numpy as np
  11. import pandas as pd
  12. from scipy import stats
  13. import matplotlib.pyplot as plt
  14. import seaborn as sns
  15. from typing import Dict, List, Tuple, Optional, Union
  16. def check_normality(
  17. data: Union[np.ndarray, pd.Series, List],
  18. name: str = "data",
  19. alpha: float = 0.05,
  20. plot: bool = True
  21. ) -> Dict:
  22. """
  23. Check normality assumption using Shapiro-Wilk test and visualizations.
  24. Parameters
  25. ----------
  26. data : array-like
  27. Data to check for normality
  28. name : str
  29. Name of the variable (for labeling)
  30. alpha : float
  31. Significance level for Shapiro-Wilk test
  32. plot : bool
  33. Whether to create Q-Q plot and histogram
  34. Returns
  35. -------
  36. dict
  37. Results including test statistic, p-value, and interpretation
  38. """
  39. data = np.asarray(data)
  40. data_clean = data[~np.isnan(data)]
  41. # Shapiro-Wilk test
  42. statistic, p_value = stats.shapiro(data_clean)
  43. # Interpretation
  44. is_normal = p_value > alpha
  45. interpretation = (
  46. f"Data {'appear' if is_normal else 'do not appear'} normally distributed "
  47. f"(W = {statistic:.3f}, p = {p_value:.3f})"
  48. )
  49. # Visual checks
  50. if plot:
  51. fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
  52. # Q-Q plot
  53. stats.probplot(data_clean, dist="norm", plot=ax1)
  54. ax1.set_title(f"Q-Q Plot: {name}")
  55. ax1.grid(alpha=0.3)
  56. # Histogram with normal curve
  57. ax2.hist(data_clean, bins='auto', density=True, alpha=0.7, color='steelblue', edgecolor='black')
  58. mu, sigma = data_clean.mean(), data_clean.std()
  59. x = np.linspace(data_clean.min(), data_clean.max(), 100)
  60. ax2.plot(x, stats.norm.pdf(x, mu, sigma), 'r-', linewidth=2, label='Normal curve')
  61. ax2.set_xlabel('Value')
  62. ax2.set_ylabel('Density')
  63. ax2.set_title(f'Histogram: {name}')
  64. ax2.legend()
  65. ax2.grid(alpha=0.3)
  66. plt.tight_layout()
  67. plt.show()
  68. return {
  69. 'test': 'Shapiro-Wilk',
  70. 'statistic': statistic,
  71. 'p_value': p_value,
  72. 'is_normal': is_normal,
  73. 'interpretation': interpretation,
  74. 'n': len(data_clean),
  75. 'recommendation': (
  76. "Proceed with parametric test" if is_normal
  77. else "Consider non-parametric alternative or transformation"
  78. )
  79. }
  80. def check_normality_per_group(
  81. data: pd.DataFrame,
  82. value_col: str,
  83. group_col: str,
  84. alpha: float = 0.05,
  85. plot: bool = True
  86. ) -> pd.DataFrame:
  87. """
  88. Check normality assumption for each group separately.
  89. Parameters
  90. ----------
  91. data : pd.DataFrame
  92. Data containing values and group labels
  93. value_col : str
  94. Column name for values to check
  95. group_col : str
  96. Column name for group labels
  97. alpha : float
  98. Significance level
  99. plot : bool
  100. Whether to create Q-Q plots for each group
  101. Returns
  102. -------
  103. pd.DataFrame
  104. Results for each group
  105. """
  106. groups = data[group_col].unique()
  107. results = []
  108. if plot:
  109. n_groups = len(groups)
  110. fig, axes = plt.subplots(1, n_groups, figsize=(5 * n_groups, 4))
  111. if n_groups == 1:
  112. axes = [axes]
  113. for idx, group in enumerate(groups):
  114. group_data = data[data[group_col] == group][value_col].dropna()
  115. stat, p = stats.shapiro(group_data)
  116. results.append({
  117. 'Group': group,
  118. 'N': len(group_data),
  119. 'W': stat,
  120. 'p-value': p,
  121. 'Normal': 'Yes' if p > alpha else 'No'
  122. })
  123. if plot:
  124. stats.probplot(group_data, dist="norm", plot=axes[idx])
  125. axes[idx].set_title(f"Q-Q Plot: {group}")
  126. axes[idx].grid(alpha=0.3)
  127. if plot:
  128. plt.tight_layout()
  129. plt.show()
  130. return pd.DataFrame(results)
  131. def check_homogeneity_of_variance(
  132. data: pd.DataFrame,
  133. value_col: str,
  134. group_col: str,
  135. alpha: float = 0.05,
  136. plot: bool = True
  137. ) -> Dict:
  138. """
  139. Check homogeneity of variance using Levene's test.
  140. Parameters
  141. ----------
  142. data : pd.DataFrame
  143. Data containing values and group labels
  144. value_col : str
  145. Column name for values
  146. group_col : str
  147. Column name for group labels
  148. alpha : float
  149. Significance level
  150. plot : bool
  151. Whether to create box plots
  152. Returns
  153. -------
  154. dict
  155. Results including test statistic, p-value, and interpretation
  156. """
  157. groups = [group[value_col].values for name, group in data.groupby(group_col)]
  158. # Levene's test (robust to non-normality)
  159. statistic, p_value = stats.levene(*groups)
  160. # Variance ratio (max/min)
  161. variances = [np.var(g, ddof=1) for g in groups]
  162. var_ratio = max(variances) / min(variances)
  163. is_homogeneous = p_value > alpha
  164. interpretation = (
  165. f"Variances {'appear' if is_homogeneous else 'do not appear'} homogeneous "
  166. f"(F = {statistic:.3f}, p = {p_value:.3f}, variance ratio = {var_ratio:.2f})"
  167. )
  168. if plot:
  169. fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
  170. # Box plot
  171. data.boxplot(column=value_col, by=group_col, ax=ax1)
  172. ax1.set_title('Box Plots by Group')
  173. ax1.set_xlabel(group_col)
  174. ax1.set_ylabel(value_col)
  175. plt.sca(ax1)
  176. plt.xticks(rotation=45)
  177. # Variance plot
  178. group_names = data[group_col].unique()
  179. ax2.bar(range(len(variances)), variances, color='steelblue', edgecolor='black')
  180. ax2.set_xticks(range(len(variances)))
  181. ax2.set_xticklabels(group_names, rotation=45)
  182. ax2.set_ylabel('Variance')
  183. ax2.set_title('Variance by Group')
  184. ax2.grid(alpha=0.3, axis='y')
  185. plt.tight_layout()
  186. plt.show()
  187. return {
  188. 'test': 'Levene',
  189. 'statistic': statistic,
  190. 'p_value': p_value,
  191. 'is_homogeneous': is_homogeneous,
  192. 'variance_ratio': var_ratio,
  193. 'interpretation': interpretation,
  194. 'recommendation': (
  195. "Proceed with standard test" if is_homogeneous
  196. else "Consider Welch's correction or transformation"
  197. )
  198. }
  199. def check_linearity(
  200. x: Union[np.ndarray, pd.Series],
  201. y: Union[np.ndarray, pd.Series],
  202. x_name: str = "X",
  203. y_name: str = "Y"
  204. ) -> Dict:
  205. """
  206. Check linearity assumption for regression.
  207. Parameters
  208. ----------
  209. x : array-like
  210. Predictor variable
  211. y : array-like
  212. Outcome variable
  213. x_name : str
  214. Name of predictor
  215. y_name : str
  216. Name of outcome
  217. Returns
  218. -------
  219. dict
  220. Visualization and recommendations
  221. """
  222. x = np.asarray(x)
  223. y = np.asarray(y)
  224. # Fit linear regression
  225. slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)
  226. y_pred = intercept + slope * x
  227. # Calculate residuals
  228. residuals = y - y_pred
  229. # Visualization
  230. fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
  231. # Scatter plot with regression line
  232. ax1.scatter(x, y, alpha=0.6, s=50, edgecolors='black', linewidths=0.5)
  233. ax1.plot(x, y_pred, 'r-', linewidth=2, label=f'y = {intercept:.2f} + {slope:.2f}x')
  234. ax1.set_xlabel(x_name)
  235. ax1.set_ylabel(y_name)
  236. ax1.set_title('Scatter Plot with Regression Line')
  237. ax1.legend()
  238. ax1.grid(alpha=0.3)
  239. # Residuals vs fitted
  240. ax2.scatter(y_pred, residuals, alpha=0.6, s=50, edgecolors='black', linewidths=0.5)
  241. ax2.axhline(y=0, color='r', linestyle='--', linewidth=2)
  242. ax2.set_xlabel('Fitted values')
  243. ax2.set_ylabel('Residuals')
  244. ax2.set_title('Residuals vs Fitted Values')
  245. ax2.grid(alpha=0.3)
  246. plt.tight_layout()
  247. plt.show()
  248. return {
  249. 'r': r_value,
  250. 'r_squared': r_value ** 2,
  251. 'interpretation': (
  252. "Examine residual plot. Points should be randomly scattered around zero. "
  253. "Patterns (curves, funnels) suggest non-linearity or heteroscedasticity."
  254. ),
  255. 'recommendation': (
  256. "If non-linear pattern detected: Consider polynomial terms, "
  257. "transformations, or non-linear models"
  258. )
  259. }
  260. def detect_outliers(
  261. data: Union[np.ndarray, pd.Series, List],
  262. name: str = "data",
  263. method: str = "iqr",
  264. threshold: float = 1.5,
  265. plot: bool = True
  266. ) -> Dict:
  267. """
  268. Detect outliers using IQR method or z-score method.
  269. Parameters
  270. ----------
  271. data : array-like
  272. Data to check for outliers
  273. name : str
  274. Name of variable
  275. method : str
  276. Method to use: 'iqr' or 'zscore'
  277. threshold : float
  278. Threshold for outlier detection
  279. For IQR: typically 1.5 (mild) or 3 (extreme)
  280. For z-score: typically 3
  281. plot : bool
  282. Whether to create visualizations
  283. Returns
  284. -------
  285. dict
  286. Outlier indices, values, and visualizations
  287. """
  288. data = np.asarray(data)
  289. data_clean = data[~np.isnan(data)]
  290. if method == "iqr":
  291. q1 = np.percentile(data_clean, 25)
  292. q3 = np.percentile(data_clean, 75)
  293. iqr = q3 - q1
  294. lower_bound = q1 - threshold * iqr
  295. upper_bound = q3 + threshold * iqr
  296. outlier_mask = (data_clean < lower_bound) | (data_clean > upper_bound)
  297. elif method == "zscore":
  298. z_scores = np.abs(stats.zscore(data_clean))
  299. outlier_mask = z_scores > threshold
  300. lower_bound = data_clean.mean() - threshold * data_clean.std()
  301. upper_bound = data_clean.mean() + threshold * data_clean.std()
  302. else:
  303. raise ValueError("method must be 'iqr' or 'zscore'")
  304. outlier_indices = np.where(outlier_mask)[0]
  305. outlier_values = data_clean[outlier_mask]
  306. n_outliers = len(outlier_indices)
  307. pct_outliers = (n_outliers / len(data_clean)) * 100
  308. if plot:
  309. fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
  310. # Box plot
  311. bp = ax1.boxplot(data_clean, vert=True, patch_artist=True)
  312. bp['boxes'][0].set_facecolor('steelblue')
  313. ax1.set_ylabel('Value')
  314. ax1.set_title(f'Box Plot: {name}')
  315. ax1.grid(alpha=0.3, axis='y')
  316. # Scatter plot highlighting outliers
  317. x_coords = np.arange(len(data_clean))
  318. ax2.scatter(x_coords[~outlier_mask], data_clean[~outlier_mask],
  319. alpha=0.6, s=50, color='steelblue', label='Normal', edgecolors='black', linewidths=0.5)
  320. if n_outliers > 0:
  321. ax2.scatter(x_coords[outlier_mask], data_clean[outlier_mask],
  322. alpha=0.8, s=100, color='red', label='Outliers', marker='D', edgecolors='black', linewidths=0.5)
  323. ax2.axhline(y=lower_bound, color='orange', linestyle='--', linewidth=1.5, label='Bounds')
  324. ax2.axhline(y=upper_bound, color='orange', linestyle='--', linewidth=1.5)
  325. ax2.set_xlabel('Index')
  326. ax2.set_ylabel('Value')
  327. ax2.set_title(f'Outlier Detection: {name}')
  328. ax2.legend()
  329. ax2.grid(alpha=0.3)
  330. plt.tight_layout()
  331. plt.show()
  332. return {
  333. 'method': method,
  334. 'threshold': threshold,
  335. 'n_outliers': n_outliers,
  336. 'pct_outliers': pct_outliers,
  337. 'outlier_indices': outlier_indices,
  338. 'outlier_values': outlier_values,
  339. 'lower_bound': lower_bound,
  340. 'upper_bound': upper_bound,
  341. 'interpretation': f"Found {n_outliers} outliers ({pct_outliers:.1f}% of data)",
  342. 'recommendation': (
  343. "Investigate outliers for data entry errors. "
  344. "Consider: (1) removing if errors, (2) winsorizing, "
  345. "(3) keeping if legitimate, (4) using robust methods"
  346. )
  347. }
  348. def comprehensive_assumption_check(
  349. data: pd.DataFrame,
  350. value_col: str,
  351. group_col: Optional[str] = None,
  352. alpha: float = 0.05
  353. ) -> Dict:
  354. """
  355. Perform comprehensive assumption checking for common statistical tests.
  356. Parameters
  357. ----------
  358. data : pd.DataFrame
  359. Data to check
  360. value_col : str
  361. Column name for dependent variable
  362. group_col : str, optional
  363. Column name for grouping variable (if applicable)
  364. alpha : float
  365. Significance level
  366. Returns
  367. -------
  368. dict
  369. Summary of all assumption checks
  370. """
  371. print("=" * 70)
  372. print("COMPREHENSIVE ASSUMPTION CHECK")
  373. print("=" * 70)
  374. results = {}
  375. # Outlier detection
  376. print("\n1. OUTLIER DETECTION")
  377. print("-" * 70)
  378. outlier_results = detect_outliers(
  379. data[value_col].dropna(),
  380. name=value_col,
  381. method='iqr',
  382. plot=True
  383. )
  384. results['outliers'] = outlier_results
  385. print(f" {outlier_results['interpretation']}")
  386. print(f" {outlier_results['recommendation']}")
  387. # Check if grouped data
  388. if group_col is not None:
  389. # Normality per group
  390. print(f"\n2. NORMALITY CHECK (by {group_col})")
  391. print("-" * 70)
  392. normality_results = check_normality_per_group(
  393. data, value_col, group_col, alpha=alpha, plot=True
  394. )
  395. results['normality_per_group'] = normality_results
  396. print(normality_results.to_string(index=False))
  397. all_normal = normality_results['Normal'].eq('Yes').all()
  398. print(f"\n All groups normal: {'Yes' if all_normal else 'No'}")
  399. if not all_normal:
  400. print(" → Consider non-parametric alternative (Mann-Whitney, Kruskal-Wallis)")
  401. # Homogeneity of variance
  402. print(f"\n3. HOMOGENEITY OF VARIANCE")
  403. print("-" * 70)
  404. homogeneity_results = check_homogeneity_of_variance(
  405. data, value_col, group_col, alpha=alpha, plot=True
  406. )
  407. results['homogeneity'] = homogeneity_results
  408. print(f" {homogeneity_results['interpretation']}")
  409. print(f" {homogeneity_results['recommendation']}")
  410. else:
  411. # Overall normality
  412. print(f"\n2. NORMALITY CHECK")
  413. print("-" * 70)
  414. normality_results = check_normality(
  415. data[value_col].dropna(),
  416. name=value_col,
  417. alpha=alpha,
  418. plot=True
  419. )
  420. results['normality'] = normality_results
  421. print(f" {normality_results['interpretation']}")
  422. print(f" {normality_results['recommendation']}")
  423. # Summary
  424. print("\n" + "=" * 70)
  425. print("SUMMARY")
  426. print("=" * 70)
  427. if group_col is not None:
  428. all_normal = results.get('normality_per_group', pd.DataFrame()).get('Normal', pd.Series()).eq('Yes').all()
  429. is_homogeneous = results.get('homogeneity', {}).get('is_homogeneous', False)
  430. if all_normal and is_homogeneous:
  431. print("✓ All assumptions met. Proceed with parametric test (t-test, ANOVA).")
  432. elif not all_normal:
  433. print("✗ Normality violated. Use non-parametric alternative.")
  434. elif not is_homogeneous:
  435. print("✗ Homogeneity violated. Use Welch's correction or transformation.")
  436. else:
  437. is_normal = results.get('normality', {}).get('is_normal', False)
  438. if is_normal:
  439. print("✓ Normality assumption met.")
  440. else:
  441. print("✗ Normality violated. Consider transformation or non-parametric method.")
  442. print("=" * 70)
  443. return results
  444. if __name__ == "__main__":
  445. # Example usage
  446. np.random.seed(42)
  447. # Simulate data
  448. group_a = np.random.normal(75, 8, 50)
  449. group_b = np.random.normal(68, 10, 50)
  450. df = pd.DataFrame({
  451. 'score': np.concatenate([group_a, group_b]),
  452. 'group': ['A'] * 50 + ['B'] * 50
  453. })
  454. # Run comprehensive check
  455. results = comprehensive_assumption_check(
  456. df,
  457. value_col='score',
  458. group_col='group',
  459. alpha=0.05
  460. )