eagleplot 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
eagleplot/math.py ADDED
@@ -0,0 +1,1210 @@
1
+ """Mathematical Operations Module.
2
+
3
+ This module provides mathematical and statistical functions for
4
+ scientific computing, including numerical operations, statistics,
5
+ and optimization routines.
6
+
7
+ Features
8
+ --------
9
+ - Numerical array operations (NumPy)
10
+ - Statistical analysis and distributions (SciPy)
11
+ - Optimization and fitting algorithms
12
+ - Linear algebra operations
13
+
14
+ Examples
15
+ --------
16
+ >>> from eagleplot.math import calculate_mean, fit_polynomial
17
+ >>> mean_value = calculate_mean(data)
18
+ >>> coefficients = fit_polynomial(x, y, degree=2)
19
+ """
20
+
21
+ import numpy as np
22
+ from scipy import stats, optimize, linalg
23
+ from typing import Dict, Tuple, Callable, Optional, Union, Any
24
+
25
+ # ============================================================================
26
+ # Basic Statistics
27
+ # ============================================================================
28
+
29
+
30
+ def calculate_mean(data: np.ndarray, axis: Optional[int] = None) -> Union[float, np.ndarray]:
31
+ """Calculate the mean of data.
32
+
33
+ Parameters
34
+ ----------
35
+ data : np.ndarray
36
+ Input data
37
+ axis : int, optional
38
+ Axis along which to compute the mean. If None, compute over all data.
39
+
40
+ Returns
41
+ -------
42
+ mean : float or np.ndarray
43
+ Mean value(s)
44
+
45
+ Examples
46
+ --------
47
+ >>> data = np.array([1, 2, 3, 4, 5])
48
+ >>> calculate_mean(data)
49
+ 3.0
50
+ """
51
+ return np.mean(data, axis=axis)
52
+
53
+
54
+ def calculate_median(data: np.ndarray, axis: Optional[int] = None) -> Union[float, np.ndarray]:
55
+ """Calculate the median of data.
56
+
57
+ Parameters
58
+ ----------
59
+ data : np.ndarray
60
+ Input data
61
+ axis : int, optional
62
+ Axis along which to compute the median. If None, compute over all data.
63
+
64
+ Returns
65
+ -------
66
+ median : float or np.ndarray
67
+ Median value(s)
68
+
69
+ Examples
70
+ --------
71
+ >>> data = np.array([1, 2, 3, 4, 5])
72
+ >>> calculate_median(data)
73
+ 3.0
74
+ """
75
+ return np.median(data, axis=axis)
76
+
77
+
78
+ def calculate_std(
79
+ data: np.ndarray, axis: Optional[int] = None, ddof: int = 0
80
+ ) -> Union[float, np.ndarray]:
81
+ """Calculate the standard deviation of data.
82
+
83
+ Parameters
84
+ ----------
85
+ data : np.ndarray
86
+ Input data
87
+ axis : int, optional
88
+ Axis along which to compute the std. If None, compute over all data.
89
+ ddof : int, default 0
90
+ Delta degrees of freedom (0 for population, 1 for sample)
91
+
92
+ Returns
93
+ -------
94
+ std : float or np.ndarray
95
+ Standard deviation value(s)
96
+
97
+ Examples
98
+ --------
99
+ >>> data = np.array([1, 2, 3, 4, 5])
100
+ >>> calculate_std(data, ddof=1)
101
+ 1.5811388300841898
102
+ """
103
+ return np.std(data, axis=axis, ddof=ddof)
104
+
105
+
106
+ def calculate_statistics(
107
+ data: np.ndarray, axis: Optional[int] = None
108
+ ) -> Dict[str, Union[float, np.ndarray]]:
109
+ """Calculate comprehensive statistics for data.
110
+
111
+ Parameters
112
+ ----------
113
+ data : np.ndarray
114
+ Input data
115
+ axis : int, optional
116
+ Axis along which to compute statistics. If None, compute over all data.
117
+
118
+ Returns
119
+ -------
120
+ stats : dict
121
+ Dictionary containing:
122
+ - 'mean': Mean value
123
+ - 'median': Median value
124
+ - 'std': Standard deviation (sample)
125
+ - 'var': Variance (sample)
126
+ - 'min': Minimum value
127
+ - 'max': Maximum value
128
+ - 'q1': First quartile (25th percentile)
129
+ - 'q3': Third quartile (75th percentile)
130
+ - 'iqr': Interquartile range
131
+ - 'range': Data range (max - min)
132
+
133
+ Examples
134
+ --------
135
+ >>> data = np.array([1, 2, 3, 4, 5])
136
+ >>> stats = calculate_statistics(data)
137
+ >>> print(f"Mean: {stats['mean']}, Std: {stats['std']}")
138
+ Mean: 3.0, Std: 1.5811388300841898
139
+ """
140
+ return {
141
+ "mean": np.mean(data, axis=axis),
142
+ "median": np.median(data, axis=axis),
143
+ "std": np.std(data, axis=axis, ddof=1),
144
+ "var": np.var(data, axis=axis, ddof=1),
145
+ "min": np.min(data, axis=axis),
146
+ "max": np.max(data, axis=axis),
147
+ "q1": np.percentile(data, 25, axis=axis),
148
+ "q3": np.percentile(data, 75, axis=axis),
149
+ "iqr": np.percentile(data, 75, axis=axis) - np.percentile(data, 25, axis=axis),
150
+ "range": np.ptp(data, axis=axis), # peak-to-peak (max - min)
151
+ }
152
+
153
+
154
+ # ============================================================================
155
+ # Distribution Analysis
156
+ # ============================================================================
157
+
158
+
159
+ def check_normality(data: np.ndarray) -> Dict[str, float]:
160
+ """Test if data follows a normal distribution using Shapiro-Wilk test.
161
+
162
+ Parameters
163
+ ----------
164
+ data : np.ndarray
165
+ 1D array of data to test
166
+
167
+ Returns
168
+ -------
169
+ result : dict
170
+ Dictionary containing:
171
+ - 'statistic': Test statistic
172
+ - 'p_value': P-value
173
+ - 'is_normal': Boolean (True if p > 0.05)
174
+
175
+ Examples
176
+ --------
177
+ >>> data = np.random.normal(0, 1, 100)
178
+ >>> result = check_normality(data)
179
+ >>> print(f"Is normal: {result['is_normal']}")
180
+ """
181
+ data = np.asarray(data).flatten()
182
+ statistic, p_value = stats.shapiro(data)
183
+
184
+ return {
185
+ "statistic": float(statistic),
186
+ "p_value": float(p_value),
187
+ "is_normal": bool(p_value > 0.05),
188
+ }
189
+
190
+
191
+ def fit_distribution(data: np.ndarray, distribution: str = "norm") -> Dict[str, Any]:
192
+ """Fit a statistical distribution to data.
193
+
194
+ Parameters
195
+ ----------
196
+ data : np.ndarray
197
+ 1D array of data to fit
198
+ distribution : str, default 'norm'
199
+ Distribution name. Options: 'norm', 'expon', 'gamma', 'lognorm', 'beta'
200
+
201
+ Returns
202
+ -------
203
+ result : dict
204
+ Dictionary containing:
205
+ - 'params': Fitted distribution parameters
206
+ - 'distribution': Distribution name
207
+ - 'ks_statistic': Kolmogorov-Smirnov test statistic
208
+ - 'ks_pvalue': KS test p-value
209
+
210
+ Raises
211
+ ------
212
+ ValueError
213
+ If distribution name is not supported
214
+
215
+ Examples
216
+ --------
217
+ >>> data = np.random.normal(5, 2, 1000)
218
+ >>> result = fit_distribution(data, 'norm')
219
+ >>> print(f"Mean: {result['params'][0]:.2f}, Std: {result['params'][1]:.2f}")
220
+ """
221
+ data = np.asarray(data).flatten()
222
+
223
+ # Get distribution from scipy.stats
224
+ dist_map = {
225
+ "norm": stats.norm,
226
+ "expon": stats.expon,
227
+ "gamma": stats.gamma,
228
+ "lognorm": stats.lognorm,
229
+ "beta": stats.beta,
230
+ }
231
+
232
+ if distribution not in dist_map:
233
+ raise ValueError(
234
+ f"Unsupported distribution: {distribution}. Supported: {list(dist_map.keys())}"
235
+ )
236
+
237
+ dist = dist_map[distribution]
238
+
239
+ # Fit distribution
240
+ params = dist.fit(data)
241
+
242
+ # Perform Kolmogorov-Smirnov test
243
+ ks_stat, ks_pval = stats.kstest(data, lambda x: dist.cdf(x, *params))
244
+
245
+ return {
246
+ "params": params,
247
+ "distribution": distribution,
248
+ "ks_statistic": float(ks_stat),
249
+ "ks_pvalue": float(ks_pval),
250
+ }
251
+
252
+
253
+ # ============================================================================
254
+ # Curve Fitting
255
+ # ============================================================================
256
+
257
+
258
+ def fit_polynomial(x: np.ndarray, y: np.ndarray, degree: int) -> Dict[str, Any]:
259
+ """Fit polynomial to data and return coefficients with quality metrics.
260
+
261
+ Parameters
262
+ ----------
263
+ x : np.ndarray
264
+ Independent variable
265
+ y : np.ndarray
266
+ Dependent variable
267
+ degree : int
268
+ Polynomial degree
269
+
270
+ Returns
271
+ -------
272
+ result : dict
273
+ Dictionary containing:
274
+ - 'coefficients': Polynomial coefficients (highest degree first)
275
+ - 'degree': Polynomial degree
276
+ - 'r_squared': R² coefficient of determination
277
+ - 'residuals': Sum of squared residuals
278
+ - 'predict': Function to predict y values for new x
279
+
280
+ Examples
281
+ --------
282
+ >>> x = np.linspace(0, 10, 50)
283
+ >>> y = 2*x + 3 + np.random.normal(0, 1, 50)
284
+ >>> result = fit_polynomial(x, y, degree=1)
285
+ >>> print(f"R²: {result['r_squared']:.3f}")
286
+ """
287
+ x = np.asarray(x)
288
+ y = np.asarray(y)
289
+
290
+ # Fit polynomial
291
+ coeffs = np.polyfit(x, y, degree)
292
+
293
+ # Calculate predictions and R²
294
+ y_pred = np.polyval(coeffs, x)
295
+ ss_res = np.sum((y - y_pred) ** 2)
296
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
297
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot != 0 else 0.0
298
+
299
+ # Create prediction function
300
+ def predict(x_new):
301
+ return np.polyval(coeffs, x_new)
302
+
303
+ return {
304
+ "coefficients": coeffs,
305
+ "degree": degree,
306
+ "r_squared": float(r_squared),
307
+ "residuals": float(ss_res),
308
+ "predict": predict,
309
+ }
310
+
311
+
312
+ def fit_linear(x: np.ndarray, y: np.ndarray) -> Dict[str, float]:
313
+ """Fit linear model y = mx + b and return parameters with statistics.
314
+
315
+ Parameters
316
+ ----------
317
+ x : np.ndarray
318
+ Independent variable
319
+ y : np.ndarray
320
+ Dependent variable
321
+
322
+ Returns
323
+ -------
324
+ result : dict
325
+ Dictionary containing:
326
+ - 'slope': Slope (m)
327
+ - 'intercept': Intercept (b)
328
+ - 'r_value': Correlation coefficient
329
+ - 'r_squared': R² coefficient of determination
330
+ - 'p_value': P-value for hypothesis test
331
+ - 'std_err': Standard error of the estimate
332
+
333
+ Examples
334
+ --------
335
+ >>> x = np.array([1, 2, 3, 4, 5])
336
+ >>> y = np.array([2, 4, 5, 4, 5])
337
+ >>> result = fit_linear(x, y)
338
+ >>> print(f"y = {result['slope']:.2f}x + {result['intercept']:.2f}")
339
+ """
340
+ x = np.asarray(x).flatten()
341
+ y = np.asarray(y).flatten()
342
+
343
+ slope, intercept, r_value, p_value, std_err = stats.linregress(x, y)
344
+
345
+ return {
346
+ "slope": float(slope),
347
+ "intercept": float(intercept),
348
+ "r_value": float(r_value),
349
+ "r_squared": float(r_value**2),
350
+ "p_value": float(p_value),
351
+ "std_err": float(std_err),
352
+ }
353
+
354
+
355
+ def fit_exponential(x: np.ndarray, y: np.ndarray) -> Dict[str, Any]:
356
+ """Fit exponential model y = a * exp(b * x) + c.
357
+
358
+ Parameters
359
+ ----------
360
+ x : np.ndarray
361
+ Independent variable
362
+ y : np.ndarray
363
+ Dependent variable (must be positive)
364
+
365
+ Returns
366
+ -------
367
+ result : dict
368
+ Dictionary containing:
369
+ - 'a': Amplitude parameter
370
+ - 'b': Exponential rate parameter
371
+ - 'c': Offset parameter
372
+ - 'r_squared': R² coefficient of determination
373
+ - 'predict': Function to predict y values for new x
374
+
375
+ Examples
376
+ --------
377
+ >>> x = np.linspace(0, 2, 50)
378
+ >>> y = 2 * np.exp(0.5 * x) + 1 + np.random.normal(0, 0.1, 50)
379
+ >>> result = fit_exponential(x, y)
380
+ >>> print(f"a={result['a']:.2f}, b={result['b']:.2f}, c={result['c']:.2f}")
381
+ """
382
+ x = np.asarray(x)
383
+ y = np.asarray(y)
384
+
385
+ # Define exponential function
386
+ def exp_func(x, a, b, c):
387
+ return a * np.exp(b * x) + c
388
+
389
+ # Initial guess
390
+ p0 = [1.0, 0.1, 0.0]
391
+
392
+ try:
393
+ # Fit using curve_fit
394
+ popt, _ = optimize.curve_fit(exp_func, x, y, p0=p0, maxfev=10000)
395
+
396
+ # Calculate R²
397
+ y_pred = exp_func(x, *popt)
398
+ ss_res = np.sum((y - y_pred) ** 2)
399
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
400
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot != 0 else 0.0
401
+
402
+ # Create prediction function
403
+ def predict(x_new):
404
+ return exp_func(x_new, *popt)
405
+
406
+ return {
407
+ "a": float(popt[0]),
408
+ "b": float(popt[1]),
409
+ "c": float(popt[2]),
410
+ "r_squared": float(r_squared),
411
+ "predict": predict,
412
+ }
413
+
414
+ except RuntimeError as e:
415
+ raise RuntimeError(
416
+ "Exponential fit failed to converge. Try adjusting initial data or ensure y > 0."
417
+ ) from e
418
+
419
+
420
+ def fit_custom(
421
+ x: np.ndarray, y: np.ndarray, func: Callable, p0: Optional[list] = None
422
+ ) -> Dict[str, Any]:
423
+ """Fit custom function to data using non-linear least squares.
424
+
425
+ Parameters
426
+ ----------
427
+ x : np.ndarray
428
+ Independent variable
429
+ y : np.ndarray
430
+ Dependent variable
431
+ func : callable
432
+ Function to fit. Must have signature ``func(x, *params)``
433
+ p0 : list, optional
434
+ Initial guess for parameters
435
+
436
+ Returns
437
+ -------
438
+ result : dict
439
+ Dictionary containing:
440
+ - 'parameters': Optimized parameters
441
+ - 'covariance': Covariance matrix
442
+ - 'std_errors': Standard errors of parameters
443
+ - 'r_squared': R² coefficient of determination
444
+ - 'predict': Function to predict y values for new x
445
+
446
+ Examples
447
+ --------
448
+ >>> def gaussian(x, amp, mu, sigma):
449
+ ... return amp * np.exp(-(x - mu)**2 / (2 * sigma**2))
450
+ >>> x = np.linspace(-5, 5, 100)
451
+ >>> y = gaussian(x, 10, 0, 1) + np.random.normal(0, 0.5, 100)
452
+ >>> result = fit_custom(x, y, gaussian, p0=[8, 0, 1])
453
+ """
454
+ x = np.asarray(x)
455
+ y = np.asarray(y)
456
+
457
+ try:
458
+ # Fit using curve_fit
459
+ popt, pcov = optimize.curve_fit(func, x, y, p0=p0)
460
+
461
+ # Calculate standard errors
462
+ perr = np.sqrt(np.diag(pcov))
463
+
464
+ # Calculate R²
465
+ y_pred = func(x, *popt)
466
+ ss_res = np.sum((y - y_pred) ** 2)
467
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
468
+ r_squared = 1 - (ss_res / ss_tot) if ss_tot != 0 else 0.0
469
+
470
+ # Create prediction function
471
+ def predict(x_new):
472
+ return func(x_new, *popt)
473
+
474
+ return {
475
+ "parameters": popt,
476
+ "covariance": pcov,
477
+ "std_errors": perr,
478
+ "r_squared": float(r_squared),
479
+ "predict": predict,
480
+ }
481
+
482
+ except RuntimeError as e:
483
+ raise RuntimeError(
484
+ "Custom fit failed to converge. "
485
+ "Try adjusting initial guess (p0) or check your function."
486
+ ) from e
487
+
488
+
489
+ # ============================================================================
490
+ # Optimization
491
+ # ============================================================================
492
+
493
+
494
+ def minimize_function(
495
+ func: Callable,
496
+ x0: np.ndarray,
497
+ method: str = "Nelder-Mead",
498
+ bounds: Optional[list] = None,
499
+ ) -> Dict[str, Any]:
500
+ """Minimize a scalar function using scipy.optimize.minimize.
501
+
502
+ Parameters
503
+ ----------
504
+ func : callable
505
+ Objective function to minimize
506
+ x0 : np.ndarray
507
+ Initial guess
508
+ method : str, default 'Nelder-Mead'
509
+ Optimization method. Options: 'Nelder-Mead', 'BFGS', 'L-BFGS-B', etc.
510
+ bounds : list of tuples, optional
511
+ Bounds for variables (for methods that support bounds)
512
+
513
+ Returns
514
+ -------
515
+ result : dict
516
+ Dictionary containing:
517
+ - 'x': Optimal parameters
518
+ - 'fun': Function value at optimum
519
+ - 'success': Whether optimization succeeded
520
+ - 'message': Termination message
521
+ - 'nfev': Number of function evaluations
522
+
523
+ Examples
524
+ --------
525
+ >>> def rosenbrock(x):
526
+ ... return (1 - x[0])**2 + 100*(x[1] - x[0]**2)**2
527
+ >>> result = minimize_function(rosenbrock, x0=[0, 0], method='BFGS')
528
+ >>> print(f"Minimum at: {result['x']}")
529
+ """
530
+ x0 = np.asarray(x0)
531
+
532
+ # Perform optimization
533
+ res = optimize.minimize(func, x0, method=method, bounds=bounds)
534
+
535
+ return {
536
+ "x": res.x,
537
+ "fun": float(res.fun),
538
+ "success": bool(res.success),
539
+ "message": str(res.message),
540
+ "nfev": int(res.nfev) if hasattr(res, "nfev") else None,
541
+ }
542
+
543
+
544
+ def find_roots(func: Callable, bracket: Tuple[float, float]) -> Dict[str, Any]:
545
+ """Find root of a function in given bracket using Brent's method.
546
+
547
+ Parameters
548
+ ----------
549
+ func : callable
550
+ Function for which to find root
551
+ bracket : tuple of (a, b)
552
+ Bracketing interval [a, b] where func(a) and func(b) have opposite signs
553
+
554
+ Returns
555
+ -------
556
+ result : dict
557
+ Dictionary containing:
558
+ - 'root': Root of the function
559
+ - 'function_value': Function value at root
560
+ - 'iterations': Number of iterations
561
+ - 'converged': Whether root finding converged
562
+
563
+ Examples
564
+ --------
565
+ >>> func = lambda x: x**2 - 4
566
+ >>> result = find_roots(func, bracket=(0, 3))
567
+ >>> print(f"Root: {result['root']:.4f}")
568
+ """
569
+ try:
570
+ root, res = optimize.brentq(func, bracket[0], bracket[1], full_output=True)
571
+
572
+ return {
573
+ "root": float(root),
574
+ "function_value": float(res.function_calls),
575
+ "iterations": int(res.iterations),
576
+ "converged": bool(res.converged),
577
+ }
578
+
579
+ except ValueError as e:
580
+ raise ValueError(
581
+ "Root finding failed. Ensure func(a) and func(b) have opposite signs."
582
+ ) from e
583
+
584
+
585
+ # ============================================================================
586
+ # Linear Algebra
587
+ # ============================================================================
588
+
589
+
590
+ def compute_eigenvalues(matrix: np.ndarray, eigenvectors: bool = True) -> Dict[str, np.ndarray]:
591
+ """Compute eigenvalues and optionally eigenvectors of a matrix.
592
+
593
+ Parameters
594
+ ----------
595
+ matrix : np.ndarray
596
+ Square matrix
597
+ eigenvectors : bool, default True
598
+ Whether to compute eigenvectors
599
+
600
+ Returns
601
+ -------
602
+ result : dict
603
+ Dictionary containing:
604
+ - 'eigenvalues': Array of eigenvalues
605
+ - 'eigenvectors': Matrix of eigenvectors (if requested)
606
+
607
+ Examples
608
+ --------
609
+ >>> A = np.array([[1, 2], [2, 1]])
610
+ >>> result = compute_eigenvalues(A)
611
+ >>> print(f"Eigenvalues: {result['eigenvalues']}")
612
+ """
613
+ matrix = np.asarray(matrix)
614
+
615
+ if matrix.shape[0] != matrix.shape[1]:
616
+ raise ValueError("Matrix must be square")
617
+
618
+ if eigenvectors:
619
+ eigenvals, eigenvecs = linalg.eig(matrix)
620
+ return {"eigenvalues": eigenvals, "eigenvectors": eigenvecs}
621
+ else:
622
+ eigenvals = linalg.eigvals(matrix)
623
+ return {"eigenvalues": eigenvals}
624
+
625
+
626
+ def solve_linear_system(A: np.ndarray, b: np.ndarray) -> Dict[str, Any]:
627
+ """Solve linear system Ax = b.
628
+
629
+ Parameters
630
+ ----------
631
+ A : np.ndarray
632
+ Coefficient matrix
633
+ b : np.ndarray
634
+ Right-hand side vector
635
+
636
+ Returns
637
+ -------
638
+ result : dict
639
+ Dictionary containing:
640
+ - 'x': Solution vector
641
+ - 'residual': Residual norm
642
+ - 'condition_number': Condition number of A
643
+
644
+ Examples
645
+ --------
646
+ >>> A = np.array([[3, 1], [1, 2]])
647
+ >>> b = np.array([9, 8])
648
+ >>> result = solve_linear_system(A, b)
649
+ >>> print(f"Solution: {result['x']}")
650
+ """
651
+ A = np.asarray(A)
652
+ b = np.asarray(b)
653
+
654
+ # Solve system
655
+ x = linalg.solve(A, b)
656
+
657
+ # Calculate residual
658
+ residual = linalg.norm(A @ x - b)
659
+
660
+ # Calculate condition number
661
+ cond = np.linalg.cond(A)
662
+
663
+ return {"x": x, "residual": float(residual), "condition_number": float(cond)}
664
+
665
+
666
+ def matrix_decomposition(matrix: np.ndarray, method: str = "svd") -> Dict[str, np.ndarray]:
667
+ """Perform matrix decomposition.
668
+
669
+ Parameters
670
+ ----------
671
+ matrix : np.ndarray
672
+ Input matrix
673
+ method : str, default 'svd'
674
+ Decomposition method. Options: 'svd', 'qr', 'lu', 'cholesky'
675
+
676
+ Returns
677
+ -------
678
+ result : dict
679
+ Dictionary with decomposition components (depends on method)
680
+ - 'svd': {'U', 'S', 'Vh'}
681
+ - 'qr': {'Q', 'R'}
682
+ - 'lu': {'P', 'L', 'U'}
683
+ - 'cholesky': {'L'}
684
+
685
+ Examples
686
+ --------
687
+ >>> A = np.random.rand(5, 3)
688
+ >>> result = matrix_decomposition(A, method='svd')
689
+ >>> U, S, Vh = result['U'], result['S'], result['Vh']
690
+ """
691
+ matrix = np.asarray(matrix)
692
+
693
+ if method == "svd":
694
+ U, S, Vh = linalg.svd(matrix)
695
+ return {"U": U, "S": S, "Vh": Vh}
696
+
697
+ elif method == "qr":
698
+ Q, R = linalg.qr(matrix)
699
+ return {"Q": Q, "R": R}
700
+
701
+ elif method == "lu":
702
+ P, L, U = linalg.lu(matrix)
703
+ return {"P": P, "L": L, "U": U}
704
+
705
+ elif method == "cholesky":
706
+ if matrix.shape[0] != matrix.shape[1]:
707
+ raise ValueError("Cholesky decomposition requires square matrix")
708
+ L = linalg.cholesky(matrix, lower=True)
709
+ return {"L": L}
710
+
711
+ else:
712
+ raise ValueError(f"Unsupported method: {method}. Supported: 'svd', 'qr', 'lu', 'cholesky'")
713
+
714
+
715
+ # ============================================================================
716
+ # Statistical Tests (M11)
717
+ # ============================================================================
718
+
719
+
720
+ def ttest_one_sample(data: np.ndarray, popmean: float, alpha: float = 0.05) -> Dict[str, Any]:
721
+ """One-sample t-test against a known population mean.
722
+
723
+ Parameters
724
+ ----------
725
+ data : np.ndarray
726
+ Sample data.
727
+ popmean : float
728
+ Hypothesized population mean (H₀: μ = popmean).
729
+ alpha : float, default 0.05
730
+ Significance level.
731
+
732
+ Returns
733
+ -------
734
+ result : dict
735
+ t_statistic, p_value, df, reject_null, ci_low, ci_high, mean, std, n.
736
+ """
737
+ data = np.asarray(data).flatten()
738
+ t_stat, p_value = stats.ttest_1samp(data, popmean)
739
+ n = len(data)
740
+ df = n - 1
741
+ se = stats.sem(data)
742
+ ci = stats.t.interval(1 - alpha, df, loc=float(np.mean(data)), scale=se)
743
+ return {
744
+ "t_statistic": float(t_stat),
745
+ "p_value": float(p_value),
746
+ "df": int(df),
747
+ "reject_null": bool(p_value < alpha),
748
+ "alpha": float(alpha),
749
+ "ci_low": float(ci[0]),
750
+ "ci_high": float(ci[1]),
751
+ "mean": float(np.mean(data)),
752
+ "std": float(np.std(data, ddof=1)),
753
+ "n": int(n),
754
+ }
755
+
756
+
757
+ def ttest_two_sample(
758
+ a: np.ndarray,
759
+ b: np.ndarray,
760
+ equal_var: bool = True,
761
+ alpha: float = 0.05,
762
+ ) -> Dict[str, Any]:
763
+ """Independent two-sample t-test.
764
+
765
+ Parameters
766
+ ----------
767
+ a, b : np.ndarray
768
+ Two independent samples.
769
+ equal_var : bool, default True
770
+ If True, Student's t-test (equal variances). If False, Welch's t-test.
771
+ alpha : float, default 0.05
772
+ Significance level.
773
+
774
+ Returns
775
+ -------
776
+ result : dict
777
+ t_statistic, p_value, df, reject_null, effect_size_cohens_d,
778
+ mean_a, mean_b, std_a, std_b, n_a, n_b, test_type.
779
+ """
780
+ a = np.asarray(a).flatten()
781
+ b = np.asarray(b).flatten()
782
+ t_stat, p_value = stats.ttest_ind(a, b, equal_var=equal_var)
783
+ na, nb = len(a), len(b)
784
+ pooled_std = np.sqrt(
785
+ ((na - 1) * np.var(a, ddof=1) + (nb - 1) * np.var(b, ddof=1)) / (na + nb - 2)
786
+ )
787
+ cohens_d = (
788
+ (float(np.mean(a)) - float(np.mean(b))) / float(pooled_std) if pooled_std > 0 else 0.0
789
+ )
790
+ df: Optional[int] = int(na + nb - 2) if equal_var else None
791
+ return {
792
+ "t_statistic": float(t_stat),
793
+ "p_value": float(p_value),
794
+ "df": df,
795
+ "reject_null": bool(p_value < alpha),
796
+ "alpha": float(alpha),
797
+ "effect_size_cohens_d": float(cohens_d),
798
+ "mean_a": float(np.mean(a)),
799
+ "mean_b": float(np.mean(b)),
800
+ "std_a": float(np.std(a, ddof=1)),
801
+ "std_b": float(np.std(b, ddof=1)),
802
+ "n_a": int(na),
803
+ "n_b": int(nb),
804
+ "test_type": "Student" if equal_var else "Welch",
805
+ }
806
+
807
+
808
+ def ttest_paired(a: np.ndarray, b: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
809
+ """Paired t-test.
810
+
811
+ Parameters
812
+ ----------
813
+ a, b : np.ndarray
814
+ Two matched samples (must be the same length).
815
+ alpha : float, default 0.05
816
+ Significance level.
817
+
818
+ Returns
819
+ -------
820
+ result : dict
821
+ t_statistic, p_value, df, reject_null, mean_diff, std_diff, n.
822
+ """
823
+ a = np.asarray(a).flatten()
824
+ b = np.asarray(b).flatten()
825
+ t_stat, p_value = stats.ttest_rel(a, b)
826
+ diff = a - b
827
+ n = len(diff)
828
+ return {
829
+ "t_statistic": float(t_stat),
830
+ "p_value": float(p_value),
831
+ "df": int(n - 1),
832
+ "reject_null": bool(p_value < alpha),
833
+ "alpha": float(alpha),
834
+ "mean_diff": float(np.mean(diff)),
835
+ "std_diff": float(np.std(diff, ddof=1)),
836
+ "n": int(n),
837
+ }
838
+
839
+
840
+ def mannwhitney_u(a: np.ndarray, b: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
841
+ """Mann-Whitney U test (nonparametric two-sample test).
842
+
843
+ Parameters
844
+ ----------
845
+ a, b : np.ndarray
846
+ Two independent samples.
847
+ alpha : float, default 0.05
848
+ Significance level.
849
+
850
+ Returns
851
+ -------
852
+ result : dict
853
+ u_statistic, p_value, reject_null, n_a, n_b.
854
+ """
855
+ a = np.asarray(a).flatten()
856
+ b = np.asarray(b).flatten()
857
+ u_stat, p_value = stats.mannwhitneyu(a, b, alternative="two-sided")
858
+ return {
859
+ "u_statistic": float(u_stat),
860
+ "p_value": float(p_value),
861
+ "reject_null": bool(p_value < alpha),
862
+ "alpha": float(alpha),
863
+ "n_a": int(len(a)),
864
+ "n_b": int(len(b)),
865
+ }
866
+
867
+
868
+ def wilcoxon_signed_rank(a: np.ndarray, b: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
869
+ """Wilcoxon signed-rank test (nonparametric paired test).
870
+
871
+ Parameters
872
+ ----------
873
+ a, b : np.ndarray
874
+ Two matched samples (must be the same length).
875
+ alpha : float, default 0.05
876
+ Significance level.
877
+
878
+ Returns
879
+ -------
880
+ result : dict
881
+ statistic, p_value, reject_null, n.
882
+ """
883
+ a = np.asarray(a).flatten()
884
+ b = np.asarray(b).flatten()
885
+ stat, p_value = stats.wilcoxon(a, b)
886
+ return {
887
+ "statistic": float(stat),
888
+ "p_value": float(p_value),
889
+ "reject_null": bool(p_value < alpha),
890
+ "alpha": float(alpha),
891
+ "n": int(len(a)),
892
+ }
893
+
894
+
895
+ def kruskal_wallis(*groups: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
896
+ """Kruskal-Wallis H test (nonparametric one-way ANOVA equivalent).
897
+
898
+ Parameters
899
+ ----------
900
+ groups : np.ndarray
901
+ Two or more independent samples (variadic positional arguments).
902
+ alpha : float, default 0.05
903
+ Significance level.
904
+
905
+ Returns
906
+ -------
907
+ result : dict
908
+ h_statistic, p_value, df, reject_null, n_groups, group_sizes.
909
+ """
910
+ arrays = [np.asarray(g).flatten() for g in groups]
911
+ h_stat, p_value = stats.kruskal(*arrays)
912
+ return {
913
+ "h_statistic": float(h_stat),
914
+ "p_value": float(p_value),
915
+ "df": int(len(arrays) - 1),
916
+ "reject_null": bool(p_value < alpha),
917
+ "alpha": float(alpha),
918
+ "n_groups": int(len(arrays)),
919
+ "group_sizes": [int(len(g)) for g in arrays],
920
+ }
921
+
922
+
923
+ def anova_oneway(*groups: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
924
+ """One-way analysis of variance (ANOVA).
925
+
926
+ Parameters
927
+ ----------
928
+ groups : np.ndarray
929
+ Two or more independent samples (variadic positional arguments).
930
+ alpha : float, default 0.05
931
+ Significance level.
932
+
933
+ Returns
934
+ -------
935
+ result : dict
936
+ f_statistic, p_value, df_between, df_within, reject_null,
937
+ eta_squared, n_groups, group_sizes.
938
+ """
939
+ arrays = [np.asarray(g).flatten() for g in groups]
940
+ f_stat, p_value = stats.f_oneway(*arrays)
941
+ k = len(arrays)
942
+ N = sum(len(g) for g in arrays)
943
+ grand_mean = float(np.mean(np.concatenate(arrays)))
944
+ ss_between = float(sum(len(g) * (float(np.mean(g)) - grand_mean) ** 2 for g in arrays))
945
+ ss_total = float(sum(float(np.sum((g - grand_mean) ** 2)) for g in arrays))
946
+ eta_squared = ss_between / ss_total if ss_total > 0 else 0.0
947
+ return {
948
+ "f_statistic": float(f_stat),
949
+ "p_value": float(p_value),
950
+ "df_between": int(k - 1),
951
+ "df_within": int(N - k),
952
+ "reject_null": bool(p_value < alpha),
953
+ "alpha": float(alpha),
954
+ "eta_squared": float(eta_squared),
955
+ "n_groups": int(k),
956
+ "group_sizes": [int(len(g)) for g in arrays],
957
+ }
958
+
959
+
960
+ def tukey_hsd(*groups: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
961
+ """Tukey HSD post-hoc pairwise comparisons (requires scipy >= 1.8).
962
+
963
+ Parameters
964
+ ----------
965
+ groups : np.ndarray
966
+ Two or more independent samples (variadic positional arguments).
967
+ alpha : float, default 0.05
968
+ Significance level.
969
+
970
+ Returns
971
+ -------
972
+ result : dict
973
+ comparisons (list of dicts with group_i, group_j, mean_diff,
974
+ p_value, significant), alpha, n_groups.
975
+ """
976
+ from scipy.stats import tukey_hsd as _tukey_hsd
977
+
978
+ arrays = [np.asarray(g).flatten() for g in groups]
979
+ res = _tukey_hsd(*arrays)
980
+ k = len(arrays)
981
+ comparisons = []
982
+ for i in range(k):
983
+ for j in range(i + 1, k):
984
+ p_val = float(res.pvalue[i, j])
985
+ comparisons.append(
986
+ {
987
+ "group_i": int(i),
988
+ "group_j": int(j),
989
+ "mean_diff": float(np.mean(arrays[i])) - float(np.mean(arrays[j])),
990
+ "p_value": p_val,
991
+ "significant": bool(p_val < alpha),
992
+ }
993
+ )
994
+ return {
995
+ "comparisons": comparisons,
996
+ "alpha": float(alpha),
997
+ "n_groups": int(k),
998
+ }
999
+
1000
+
1001
+ def anova_twoway(
1002
+ data: "Any",
1003
+ response: str,
1004
+ factor_a: str,
1005
+ factor_b: str,
1006
+ include_interaction: bool = True,
1007
+ ) -> Dict[str, Any]:
1008
+ """Two-way ANOVA with optional interaction term.
1009
+
1010
+ Uses ``statsmodels`` OLS (Type II sums of squares via
1011
+ ``statsmodels.stats.anova.anova_lm``).
1012
+
1013
+ Parameters
1014
+ ----------
1015
+ data : pd.DataFrame
1016
+ DataFrame containing the response and both factor columns.
1017
+ response : str
1018
+ Name of the continuous response variable column.
1019
+ factor_a : str
1020
+ Name of the first categorical factor column.
1021
+ factor_b : str
1022
+ Name of the second categorical factor column.
1023
+ include_interaction : bool, default True
1024
+ Whether to include the A×B interaction term.
1025
+
1026
+ Returns
1027
+ -------
1028
+ dict
1029
+ Keys: ``table`` (pd.DataFrame — ANOVA table with F, p-value, df, SS),
1030
+ ``factor_a`` (str), ``factor_b`` (str), ``interaction_included`` (bool),
1031
+ ``significant_a`` (bool), ``significant_b`` (bool),
1032
+ ``significant_interaction`` (bool or None).
1033
+
1034
+ Raises
1035
+ ------
1036
+ ImportError
1037
+ If ``statsmodels`` is not installed.
1038
+
1039
+ Examples
1040
+ --------
1041
+ >>> import pandas as pd
1042
+ >>> df = pd.DataFrame({'y': [5,6,7,8,4,5], 'A': ['a','a','b','b','a','b'],
1043
+ ... 'B': ['x','y','x','y','x','y']})
1044
+ >>> result = anova_twoway(df, 'y', 'A', 'B')
1045
+ >>> result['table'].index.tolist() # doctest: +SKIP
1046
+ ['A', 'B', 'A:B', 'Residual']
1047
+ """
1048
+ try:
1049
+ import statsmodels.formula.api as smf
1050
+ from statsmodels.stats.anova import anova_lm
1051
+ except ImportError as exc:
1052
+ raise ImportError(
1053
+ "statsmodels is required for two-way ANOVA. Install it with: pip install statsmodels"
1054
+ ) from exc
1055
+
1056
+ if include_interaction:
1057
+ formula = f"Q('{response}') ~ C(Q('{factor_a}')) * C(Q('{factor_b}'))"
1058
+ else:
1059
+ formula = f"Q('{response}') ~ C(Q('{factor_a}')) + C(Q('{factor_b}'))"
1060
+
1061
+ model = smf.ols(formula, data=data).fit()
1062
+ table = anova_lm(model, typ=2)
1063
+
1064
+ # Rename index for readability
1065
+ rename_map = {}
1066
+ for idx in table.index:
1067
+ if factor_a in idx and factor_b in idx:
1068
+ rename_map[idx] = f"{factor_a}:{factor_b}"
1069
+ elif factor_a in idx:
1070
+ rename_map[idx] = factor_a
1071
+ elif factor_b in idx:
1072
+ rename_map[idx] = factor_b
1073
+ table = table.rename(index=rename_map)
1074
+
1075
+ alpha = 0.05
1076
+ p_a = float(table.loc[factor_a, "PR(>F)"]) if factor_a in table.index else 1.0
1077
+ p_b = float(table.loc[factor_b, "PR(>F)"]) if factor_b in table.index else 1.0
1078
+ interaction_key = f"{factor_a}:{factor_b}"
1079
+ p_int = float(table.loc[interaction_key, "PR(>F)"]) if interaction_key in table.index else None
1080
+
1081
+ return {
1082
+ "table": table,
1083
+ "factor_a": factor_a,
1084
+ "factor_b": factor_b,
1085
+ "interaction_included": include_interaction,
1086
+ "significant_a": p_a < alpha,
1087
+ "significant_b": p_b < alpha,
1088
+ "significant_interaction": (p_int < alpha) if p_int is not None else None,
1089
+ "p_value_a": p_a,
1090
+ "p_value_b": p_b,
1091
+ "p_value_interaction": p_int,
1092
+ }
1093
+
1094
+
1095
+ def bonferroni_correction(p_values: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
1096
+ """Bonferroni multiple-comparison correction.
1097
+
1098
+ Parameters
1099
+ ----------
1100
+ p_values : np.ndarray
1101
+ Array of raw p-values from multiple tests.
1102
+ alpha : float, default 0.05
1103
+ Family-wise significance level.
1104
+
1105
+ Returns
1106
+ -------
1107
+ result : dict
1108
+ adjusted_alpha, corrected_p_values, reject, n_tests, original_alpha.
1109
+ """
1110
+ p_values = np.asarray(p_values).flatten()
1111
+ m = len(p_values)
1112
+ adjusted_alpha = alpha / m
1113
+ reject = p_values < adjusted_alpha
1114
+ return {
1115
+ "adjusted_alpha": float(adjusted_alpha),
1116
+ "corrected_p_values": [float(p) for p in p_values],
1117
+ "reject": [bool(r) for r in reject],
1118
+ "n_tests": int(m),
1119
+ "original_alpha": float(alpha),
1120
+ }
1121
+
1122
+
1123
+ def pearson_correlation(x: np.ndarray, y: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
1124
+ """Pearson correlation coefficient and significance test.
1125
+
1126
+ Parameters
1127
+ ----------
1128
+ x, y : np.ndarray
1129
+ Two paired numeric arrays of equal length.
1130
+ alpha : float, default 0.05
1131
+ Significance level.
1132
+
1133
+ Returns
1134
+ -------
1135
+ result : dict
1136
+ r, p_value, reject_null, alpha, ci_low, ci_high, n.
1137
+ """
1138
+ x = np.asarray(x).flatten()
1139
+ y = np.asarray(y).flatten()
1140
+ r, p_value = stats.pearsonr(x, y)
1141
+ n = len(x)
1142
+ # Fisher z-transform 95% CI
1143
+ z = np.arctanh(float(r))
1144
+ se_z = 1.0 / np.sqrt(n - 3)
1145
+ z_crit = float(stats.norm.ppf(1 - alpha / 2))
1146
+ ci_low = float(np.tanh(z - z_crit * se_z))
1147
+ ci_high = float(np.tanh(z + z_crit * se_z))
1148
+ return {
1149
+ "r": float(r),
1150
+ "p_value": float(p_value),
1151
+ "reject_null": bool(p_value < alpha),
1152
+ "alpha": float(alpha),
1153
+ "ci_low": ci_low,
1154
+ "ci_high": ci_high,
1155
+ "n": int(n),
1156
+ }
1157
+
1158
+
1159
+ def spearman_correlation(x: np.ndarray, y: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
1160
+ """Spearman rank correlation and significance test.
1161
+
1162
+ Parameters
1163
+ ----------
1164
+ x, y : np.ndarray
1165
+ Two paired numeric arrays of equal length.
1166
+ alpha : float, default 0.05
1167
+ Significance level.
1168
+
1169
+ Returns
1170
+ -------
1171
+ result : dict
1172
+ rho, p_value, reject_null, alpha, n.
1173
+ """
1174
+ x = np.asarray(x).flatten()
1175
+ y = np.asarray(y).flatten()
1176
+ rho, p_value = stats.spearmanr(x, y)
1177
+ return {
1178
+ "rho": float(rho),
1179
+ "p_value": float(p_value),
1180
+ "reject_null": bool(p_value < alpha),
1181
+ "alpha": float(alpha),
1182
+ "n": int(len(x)),
1183
+ }
1184
+
1185
+
1186
+ def chi_square_independence(contingency_table: np.ndarray, alpha: float = 0.05) -> Dict[str, Any]:
1187
+ """Chi-square test of independence.
1188
+
1189
+ Parameters
1190
+ ----------
1191
+ contingency_table : np.ndarray
1192
+ 2-D observed frequency table.
1193
+ alpha : float, default 0.05
1194
+ Significance level.
1195
+
1196
+ Returns
1197
+ -------
1198
+ result : dict
1199
+ chi2, p_value, df, reject_null, alpha, expected.
1200
+ """
1201
+ contingency_table = np.asarray(contingency_table)
1202
+ chi2, p_value, df, expected = stats.chi2_contingency(contingency_table)
1203
+ return {
1204
+ "chi2": float(chi2),
1205
+ "p_value": float(p_value),
1206
+ "df": int(df),
1207
+ "reject_null": bool(p_value < alpha),
1208
+ "alpha": float(alpha),
1209
+ "expected": expected.tolist(),
1210
+ }